Skip to content

Commit 6981985

Browse files
committed
feature: multi-level selection system with MVC architecture
Introduce a full selection system supporting measure, bar, staff, and note-group granularity with precise hit-testing. - Add MVC selection architecture: SelectionManager, SelectionView, and shared selection types (SelectionGranularity, SelectionRange, etc.) - Support all modifier key combinations (toggle, extend, range select) - Implement precise staff-level and note-group hit-testing - Add end-to-end selection tests (staff-selection.spec.ts) - Wire selection into ModeManager and MouseHandler - Update existing tests and snapshots to match new selection behavior Signed-off-by: Mike Lischke <mike@lischke-online.de>
1 parent 7cd3967 commit 6981985

47 files changed

Lines changed: 2714 additions & 959 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/App.scss

Lines changed: 231 additions & 137 deletions
Large diffs are not rendered by default.

src/App.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -807,7 +807,7 @@ export class App extends UIComponent<{}, IAppState> {
807807
switch (event.key) {
808808
case "Escape": {
809809
Overlay.closeAllOverlays();
810-
this.services.selectionManager.deselectAll();
810+
this.services.selectionManager.clearSelection();
811811
this.services.modeManager.deletePolyrhythmMode = false;
812812

813813
break;
@@ -844,9 +844,9 @@ export class App extends UIComponent<{}, IAppState> {
844844
this.undoManager?.edit({
845845
type: "EditCommand_ArrangementClearSelection",
846846
arrangement: this.dataModel.arrangement!,
847-
clearSelection: this.services.selectionManager.selections
847+
clearSelection: this.services.selectionManager.currentTrackSelections
848848
});
849-
this.services.selectionManager.deselectAll();
849+
this.services.selectionManager.clearSelection();
850850
}
851851

852852
break;

src/components/ui/Arrangement/ArrangementEditControls.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*/
55

66
import type { ISbDmArrangement, ISbDmTrack, ScoreBookDataModel } from "../../../core/ScoreBookDataModel.js";
7+
import type { ISelectionDelta } from "../../../ui/selection-types.js";
78
import type { EditCommand_TimeParamsTimeSignature } from "../../../core/types/edit_commands.js";
89
import type { UndoManager } from "../../../core/UndoManager.js";
910
import type { ScoreBookUiServices } from "../../../player/types.js";
@@ -313,11 +314,11 @@ export class ArrangementEditControls
313314
}
314315
};
315316

316-
private onSelectionChanged = (): Promise<boolean> => {
317+
private onSelectionChanged = (_delta: ISelectionDelta): Promise<boolean> => {
317318
const { services } = this.props;
318319

319320
const selectionManager = services.selectionManager;
320-
Overlay.toggleOverlay("selection_controls", selectionManager.selections.size ? "show" : "hide");
321+
Overlay.toggleOverlay("selection_controls", selectionManager.currentTrackSelections.size ? "show" : "hide");
321322

322323
return Promise.resolve(true);
323324
};

src/components/ui/Arrangement/ArrangementTitle.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { Input } from "../framework/Input.js";
1111
import { Label } from "../framework/Label.js";
1212
import { UIComponent, type ICommonUIProperties } from "../framework/UIComponent.js";
1313

14-
export interface IArrangementTitleProps extends ICommonUIProperties {
14+
export interface IArrangementTitleProperties extends ICommonUIProperties {
1515
arrangement: Readonly<ISbDmArrangement>;
1616
undoManager: UndoManager;
1717
editMode: boolean;
@@ -23,10 +23,10 @@ interface IArrangementTitleState {
2323
inputValue?: string;
2424
}
2525

26-
export class ArrangementTitle extends UIComponent<IArrangementTitleProps, IArrangementTitleState> {
26+
export class ArrangementTitle extends UIComponent<IArrangementTitleProperties, IArrangementTitleState> {
2727
private inputRef = createRef<HTMLInputElement>();
2828

29-
public constructor(props: IArrangementTitleProps) {
29+
public constructor(props: IArrangementTitleProperties) {
3030
super(props);
3131

3232
this.state = {
@@ -43,8 +43,7 @@ export class ArrangementTitle extends UIComponent<IArrangementTitleProps, IArran
4343
this.setState({ title: arrangement.title, inputValue: arrangement.title });
4444
}
4545

46-
public override componentDidUpdate(prevProps: IArrangementTitleProps, prevState: IArrangementTitleState): void {
47-
46+
public override componentDidUpdate(): void {
4847
const { arrangement } = this.props;
4948
const { title } = this.state;
5049

@@ -84,7 +83,12 @@ export class ArrangementTitle extends UIComponent<IArrangementTitleProps, IArran
8483
}
8584

8685
return (
87-
<Label id={id}>{title}</Label>
86+
<Label
87+
id={id}
88+
{...this.dataAttributes}
89+
>
90+
{title}
91+
</Label>
8892
);
8993
}
9094

src/components/ui/Arrangement/ArrangementViewer.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ interface IArrangementViewerState {
4343
}
4444

4545
export class ArrangementViewer extends UIComponent<IArrangementViewerProps, IArrangementViewerState> {
46+
private arrangementViewerRef = createRef<HTMLDivElement>();
4647
private viewerRef = createRef<HTMLDivElement>();
4748
private playBeamRef = createRef<HTMLDivElement>();
4849
private trackViewerContainerRef = createRef<HTMLDivElement>();
@@ -89,9 +90,11 @@ export class ArrangementViewer extends UIComponent<IArrangementViewerProps, IArr
8990
}
9091

9192
public override componentDidMount(): void {
92-
const { arrangementPlayer } = this.props;
93+
const { arrangementPlayer, services } = this.props;
9394
const { autoFollowIsOn, viewerZoom } = this.state;
9495

96+
services.selectionManager.setEventContainer(this.arrangementViewerRef.current!);
97+
9598
requisitions.register("settingsChanged", this.handleSettingsChanged);
9699
requisitions.register("trackViewModeToggled", this.handleTrackViewModeToggled);
97100
requisitions.register("timeParamsChanged", this.handleTimeParamsChange);
@@ -111,8 +114,8 @@ export class ArrangementViewer extends UIComponent<IArrangementViewerProps, IArr
111114
}
112115

113116
public override componentDidUpdate(prevProps: IArrangementViewerProps, prevState: IArrangementViewerState): void {
114-
const { arrangementPlayer } = this.props;
115-
const { autoFollowIsOn, viewerZoom } = this.state;
117+
const { arrangementPlayer, services } = this.props;
118+
const { autoFollowIsOn, viewerZoom, trackViewMode } = this.state;
116119

117120
if (prevProps.arrangementPlayer !== arrangementPlayer) {
118121
prevProps.arrangementPlayer.animationEngine.disconnect(this.autoFollow);
@@ -124,6 +127,11 @@ export class ArrangementViewer extends UIComponent<IArrangementViewerProps, IArr
124127
this.autoFollow(0);
125128
}
126129

130+
if (prevState.trackViewMode !== trackViewMode) {
131+
// View mode switched — newly mounted components need the current selection state.
132+
services.selectionManager.republishSelection();
133+
}
134+
127135
this.trackViewerContainerRef.current!.style.zoom = `${viewerZoom}%`;
128136
this.handleTrackViewerScroll();
129137
}
@@ -225,6 +233,7 @@ export class ArrangementViewer extends UIComponent<IArrangementViewerProps, IArr
225233
return (
226234
<Container
227235
className="arrangementViewer"
236+
innerRef={this.arrangementViewerRef}
228237
orientation={Orientation.TopDown}
229238
crossAlignment={ChildAlignment.Stretch}
230239
>
@@ -235,7 +244,8 @@ export class ArrangementViewer extends UIComponent<IArrangementViewerProps, IArr
235244
crossAlignment={ChildAlignment.Stretch}
236245
style={{ zoom: `${viewerZoom}%` }}
237246
>
238-
<TrackControls innerRef={this.trackControlsRef} tracks={arrangement.tracks} />
247+
<TrackControls innerRef={this.trackControlsRef} tracks={arrangement.tracks}
248+
services={services} />
239249
<Container
240250
id="trackViewerHost"
241251
innerRef={this.viewerRef}
@@ -258,6 +268,7 @@ export class ArrangementViewer extends UIComponent<IArrangementViewerProps, IArr
258268
ref={this.minimapRef}
259269
arrangement={arrangement}
260270
scoreMetrics={arrangementPlayer.scoreMetrics}
271+
services={services}
261272
onViewportMoved={this.handleViewportMoved}
262273
/>
263274
</Container >

src/components/ui/Arrangement/TrackControls.tsx

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,32 +3,41 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*/
55

6+
import { AppStorage } from "../../../core/AppStorage.js";
67
import type { ISbDmTrack } from "../../../core/ScoreBookDataModel.js";
78
import type { Mutable } from "../../../core/types/general.js";
8-
import { AppStorage } from "../../../core/AppStorage.js";
9+
import type { ScoreBookUiServices } from "../../../player/types.js";
910
import { requisitions } from "../../../supplement/Requisitions.js";
11+
import {
12+
SelectionGranularity, type ISelectionDelta, type ISelectionEntry, type ISelectionHitTester,
13+
} from "../../../ui/selection-types.js";
1014
import { Button } from "../framework/Button.js";
11-
import { CheckState, Toggle } from "../framework/Toggle.js";
1215
import { Codicon } from "../framework/Codicon.js";
1316
import { Container } from "../framework/Container.js";
1417
import { Icon } from "../framework/Icon.js";
1518
import { NoteImage, NoteLength } from "../framework/NoteImage.js";
1619
import { SplitSlider } from "../framework/SplitSlider.js";
20+
import { CheckState, Toggle } from "../framework/Toggle.js";
1721
import { ChildAlignment, Orientation } from "../framework/ui-types.js";
1822
import { UIComponent, type ICommonUIProperties } from "../framework/UIComponent.js";
1923

2024
export interface ITrackControlsProperties extends ICommonUIProperties {
2125
tracks: ISbDmTrack[];
26+
services: ScoreBookUiServices;
2227
innerRef?: preact.RefObject<HTMLDivElement>;
2328
}
2429

2530
interface ITrackControlsState {
2631
mixerExpanded: boolean;
2732
trackViewMode: "grid" | "staff";
33+
34+
/** Track IDs that are currently selected via Track granularity. */
35+
selectedTrackIds: ReadonlySet<number>;
2836
}
2937

3038
/** Icon and track-specific controls. */
31-
export class TrackControls extends UIComponent<ITrackControlsProperties, ITrackControlsState> {
39+
export class TrackControls extends UIComponent<ITrackControlsProperties, ITrackControlsState>
40+
implements ISelectionHitTester {
3241
public constructor(props: ITrackControlsProperties) {
3342
super(props);
3443

@@ -38,13 +47,23 @@ export class TrackControls extends UIComponent<ITrackControlsProperties, ITrackC
3847
this.state = {
3948
mixerExpanded: false,
4049
trackViewMode,
50+
selectedTrackIds: new Set(),
4151
};
4252
}
4353

4454
public override componentDidMount(): void {
55+
const { services } = this.props;
56+
services.selectionManager.registerHitTester(this);
57+
requisitions.register("selectionChanged", this.handleSelectionChanged);
4558
this.recomputeEffectiveVolumes();
4659
}
4760

61+
public override componentWillUnmount(): void {
62+
const { services } = this.props;
63+
services.selectionManager.unregisterHitTester(this);
64+
requisitions.unregister("selectionChanged", this.handleSelectionChanged);
65+
}
66+
4867
public override componentDidUpdate(prevProps: ITrackControlsProperties, prevState: ITrackControlsState): void {
4968

5069
const { tracks } = this.props;
@@ -53,9 +72,35 @@ export class TrackControls extends UIComponent<ITrackControlsProperties, ITrackC
5372
}
5473
}
5574

75+
public hitTest(rect: DOMRect): ISelectionEntry[] {
76+
const { tracks } = this.props;
77+
const element = this.base as HTMLElement | null;
78+
if (!element) {
79+
return [];
80+
}
81+
82+
const rows = element.querySelectorAll<HTMLElement>(".trackControls");
83+
const entries: ISelectionEntry[] = [];
84+
85+
for (let i = 0; i < rows.length; i++) {
86+
const rowRect = rows[i].getBoundingClientRect();
87+
if (rect.right >= rowRect.left && rect.left <= rowRect.right
88+
&& rect.bottom >= rowRect.top && rect.top <= rowRect.bottom) {
89+
const track = tracks[i];
90+
entries.push({
91+
granularity: SelectionGranularity.Track,
92+
bar: 0,
93+
trackId: track.id,
94+
});
95+
}
96+
}
97+
98+
return entries;
99+
}
100+
56101
public render() {
57102
const { tracks, innerRef } = this.props;
58-
const { mixerExpanded, trackViewMode } = this.state;
103+
const { mixerExpanded, trackViewMode, selectedTrackIds } = this.state;
59104

60105
const listClassName = this.generateFinalClassName([
61106
"trackControlsList",
@@ -66,11 +111,13 @@ export class TrackControls extends UIComponent<ITrackControlsProperties, ITrackC
66111
const controls = tracks.map((track) => {
67112
const instrumentName = track.instrument.displayName;
68113
const iconPath = track.instrument.image.filePath;
114+
const isSelected = selectedTrackIds.has(track.id);
69115

70116
return (
71117
<Container
72118
key={track.id}
73119
className="trackControls"
120+
data-track={track.id}
74121
orientation={Orientation.LeftToRight}
75122
crossAlignment={ChildAlignment.Center}
76123
>
@@ -103,6 +150,7 @@ export class TrackControls extends UIComponent<ITrackControlsProperties, ITrackC
103150
mainAlignment={ChildAlignment.Center}
104151
crossAlignment={ChildAlignment.Center}
105152
>
153+
{isSelected && <div className="track-controls-selection-overlay" />}
106154
<Icon
107155
className="trackInstrumentIcon"
108156
src={iconPath}
@@ -165,10 +213,11 @@ export class TrackControls extends UIComponent<ITrackControlsProperties, ITrackC
165213
);
166214
}
167215

168-
private toggleMixer = () => {
216+
private toggleMixer = (e: MouseEvent | KeyboardEvent) => {
169217
this.setState((previousState) => {
170218
return { mixerExpanded: !previousState.mixerExpanded };
171219
});
220+
e.stopPropagation();
172221
};
173222

174223
private handleTrackViewModeToggle = (_e: InputEvent, checkState: CheckState) => {
@@ -217,4 +266,20 @@ export class TrackControls extends UIComponent<ITrackControlsProperties, ITrackC
217266
mutableTrack.effectiveVolume = normalVolume * nonFocusAttenuation;
218267
});
219268
};
269+
270+
private handleSelectionChanged = (_delta: ISelectionDelta): Promise<boolean> => {
271+
const { services } = this.props;
272+
const selectedTrackIds = new Set<number>();
273+
274+
for (const entry of services.selectionManager.currentSelection.values()) {
275+
if (entry.granularity === SelectionGranularity.Track && entry.trackId > 0) {
276+
selectedTrackIds.add(entry.trackId);
277+
}
278+
}
279+
280+
this.setState({ selectedTrackIds });
281+
282+
return Promise.resolve(true);
283+
};
284+
220285
}

src/components/ui/Bar/Grid/GridMeasureRow.tsx

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ interface IRenderGroup {
4040

4141
export class GridMeasureRow extends UIComponent<IGridMeasureRowProperties> {
4242
public override render(): ComponentChild {
43-
const { measure, dataModel, pulsesPerBar } = this.props;
43+
const { measure, dataModel, pulsesPerBar, track } = this.props;
4444

4545
if (!dataModel.arrangement) {
4646
return null;
@@ -77,7 +77,8 @@ export class GridMeasureRow extends UIComponent<IGridMeasureRowProperties> {
7777
};
7878

7979
return (
80-
<Container className={className} style={rowStyle}>
80+
<Container className={className} style={rowStyle}
81+
data-track={track.id} {...this.dataAttributes}>
8182
{this.renderItems(group.items, 1, beatStartItemIndices)}
8283
</Container>
8384
);
@@ -122,7 +123,17 @@ export class GridMeasureRow extends UIComponent<IGridMeasureRowProperties> {
122123

123124
private renderItems(items: IRenderItem[], level = 1, beatStartItemIndices?: Set<number>,
124125
markFirst = false): ComponentChild[] {
125-
const { track } = this.props;
126+
const { track, measure } = this.props;
127+
128+
// Build step-index → event-id mapping for note identification.
129+
const stepToEventId = new Map<number, number>();
130+
let eventIndex = 0;
131+
for (const step of measure.steps) {
132+
if (step.noteStyleId !== undefined && eventIndex < measure.events.length) {
133+
stepToEventId.set(step.index, measure.events[eventIndex].id);
134+
eventIndex++;
135+
}
136+
}
126137

127138
return items.map((item, index) => {
128139
// A step or tuplet is a beat start if it is explicitly in beatStartItemIndices (top level)
@@ -140,10 +151,22 @@ export class GridMeasureRow extends UIComponent<IGridMeasureRowProperties> {
140151
? `color-mix(in srgb, ${color} 80%, var(--color-base-100))`
141152
: "transparent";
142153

154+
const noteId = stepToEventId.get(item.step.index);
155+
156+
const noteDivProps: Record<string, unknown> = {
157+
key: index,
158+
className: "note-viewer",
159+
"data-step-index": item.step.index,
160+
"data-beat-start": isBeatStart ? "true" : undefined,
161+
style: { minWidth: 0, backgroundColor },
162+
};
163+
164+
if (noteId !== undefined) {
165+
noteDivProps["data-note-id"] = noteId;
166+
}
167+
143168
return (
144-
<div key={index} className="note-viewer"
145-
data-beat-start={isBeatStart ? "true" : undefined}
146-
style={{ minWidth: 0, backgroundColor }}>
169+
<div {...noteDivProps}>
147170
<div className="note-details-viewer">
148171
<NoteStyleSymbolViewer noteStyle={noteStyle} />
149172
</div>

0 commit comments

Comments
 (0)