diff --git a/packages/app/studio/src/ui/timeline/tracks/audio-unit/regions/RegionsArea.tsx b/packages/app/studio/src/ui/timeline/tracks/audio-unit/regions/RegionsArea.tsx index 30a6b930..b627fe4f 100644 --- a/packages/app/studio/src/ui/timeline/tracks/audio-unit/regions/RegionsArea.tsx +++ b/packages/app/studio/src/ui/timeline/tracks/audio-unit/regions/RegionsArea.tsx @@ -86,7 +86,7 @@ export const RegionsArea = ({lifecycle, service, manager, scrollModel, scrollCon const regionLocator = createRegionLocator(manager, regionSelection) const dragAndDrop = new RegionDragAndDrop(service, capturing, timeline.snapping) const shortcuts = ShortcutManager.get().createContext(() => element.contains(document.activeElement), "Regions") - const {engine, boxGraph} = project + const {engine, boxGraph, overlapResolver} = project const clipboardHandler = RegionsClipboard.createHandler({ getEnabled: () => !engine.isPlaying.getValue(), getPosition: () => engine.position.getValue(), @@ -96,7 +96,8 @@ export const RegionsArea = ({lifecycle, service, manager, scrollModel, scrollCon boxGraph, boxAdapters, getTracks: () => manager.tracks().map(track => track.trackBoxAdapter), - getSelectedTrack: () => Option.wrap(regionSelection.selected()[0]).flatMap(region => region.trackBoxAdapter) + getSelectedTrack: () => Option.wrap(regionSelection.selected()[0]).flatMap(region => region.trackBoxAdapter), + overlapResolver }) lifecycle.ownAll( regionSelection.catchupAndSubscribe({ diff --git a/packages/studio/core/src/ui/clipboard/types/RegionsClipboardHandler.ts b/packages/studio/core/src/ui/clipboard/types/RegionsClipboardHandler.ts index be981be6..734e4dea 100644 --- a/packages/studio/core/src/ui/clipboard/types/RegionsClipboardHandler.ts +++ b/packages/studio/core/src/ui/clipboard/types/RegionsClipboardHandler.ts @@ -1,4 +1,4 @@ -import {ByteArrayInput, ByteArrayOutput, Option, Procedure, Provider, Selection, UUID} from "@opendaw/lib-std" +import {ByteArrayInput, ByteArrayOutput, Exec, Option, Procedure, Provider, Selection, UUID} from "@opendaw/lib-std" import {Box, BoxEditing, BoxGraph} from "@opendaw/lib-box" import {ppqn} from "@opendaw/lib-dsp" import {Pointers} from "@opendaw/studio-enums" @@ -12,6 +12,7 @@ import { } from "@opendaw/studio-adapters" import {ClipboardEntry, ClipboardHandler} from "../ClipboardManager" import {ClipboardUtils} from "../ClipboardUtils" +import {RegionOverlapResolver} from "../../timeline/RegionOverlapResolver" type ClipboardRegions = ClipboardEntry<"regions"> @@ -38,6 +39,7 @@ export namespace RegionsClipboard { readonly boxAdapters: BoxAdapters readonly getTracks: Provider> readonly getSelectedTrack: Provider> + readonly overlapResolver: RegionOverlapResolver } const encodeMetadata = (metadata: RegionsMetadata): ArrayBufferLike => { @@ -78,7 +80,8 @@ export namespace RegionsClipboard { boxGraph, boxAdapters, getTracks, - getSelectedTrack + getSelectedTrack, + overlapResolver }: Context): ClipboardHandler => { const copyRegions = (): Option => { const selected = selection.selected() @@ -147,6 +150,14 @@ export namespace RegionsClipboard { } editing.modify(() => { selection.deselectAll() + const pastePosition = Math.max(0, position) + const pasteComplete = pastePosition + (metadata.maxPosition - metadata.minPosition) + const overlapSolvers: Exec[] = [] + for (const {target} of sourceTrackToTarget.values()) { + if (target !== null) { + overlapSolvers.push(overlapResolver.fromRange(target, pastePosition, pasteComplete)) + } + } const boxes = ClipboardUtils.deserializeBoxes( entry.data, boxGraph, @@ -184,10 +195,11 @@ export namespace RegionsClipboard { } } ) + overlapSolvers.forEach(solver => solver()) const regionBoxes = boxes.filter(UnionBoxTypes.isRegionBox) const adapters = regionBoxes.map(box => RegionAdapters.for(boxAdapters, box)) selection.select(...adapters) - setPosition(Math.max(0, position) + (metadata.maxPosition - metadata.minPosition)) + setPosition(pasteComplete) }) } } diff --git a/plans/region-and-track-focus.md b/plans/region-and-track-focus.md new file mode 100644 index 00000000..019f6eba --- /dev/null +++ b/plans/region-and-track-focus.md @@ -0,0 +1,197 @@ +# Region and Track Focus System + +## Problem + +The current copy/paste implementation has two issues: + +1. **Selection order is undefined**: `regionSelection.selected()[0]` returns an arbitrary region, not the one the user interacted with +2. **Cut breaks paste**: After cut deletes regions, there's no way to determine the target track for paste + +## Concept: Focus vs Selection + +| Aspect | Selection | Focus | +|--------|-----------|-------| +| Cardinality | Multiple items | Single item | +| Purpose | Batch operations (delete, move, copy) | Anchor point for operations | +| Persistence | Cleared on click elsewhere | Persists until explicitly changed | +| Visual | Highlight/border | Different indicator (e.g., thicker border, glow) | + +## Design + +### Focus Hierarchy + +``` +TrackFocus (always set when in regions area) +└── RegionFocus (optional, set when a region is clicked) +``` + +- **TrackFocus**: The track that will receive pasted regions (offset 0 anchor) +- **RegionFocus**: The specific region that was last interacted with + +### Focus Behavior + +| Action | Track Focus | Region Focus | +|--------|-------------|--------------| +| Click on region | Set to region's track | Set to region | +| Click on track (empty area) | Set to track | Clear | +| Click on track header | Set to track | Clear | +| Delete focused region | Keep track focus | Clear | +| Cut regions | Keep track focus | Clear | +| Paste regions | Unchanged | Set to first pasted region | +| Click outside regions area | Clear | Clear | + +### Copy/Paste with Focus + +**Copy/Cut:** +- Uses `TrackFocus` as the anchor track (offset 0) +- All other tracks get offsets relative to the focused track +- After cut: `TrackFocus` remains, `RegionFocus` is cleared + +**Paste:** +- Uses `TrackFocus` to determine target track index +- Regions paste at `focusedTrackIndex + metadata.offset` +- After paste: First pasted region gets focus + +--- + +## Implementation + +### New Types + +```typescript +// In a new file: packages/studio/core/src/ui/timeline/TimelineFocus.ts + +import {DefaultObservableValue, Option, Terminable} from "@opendaw/lib-std" +import {AnyRegionBoxAdapter, TrackBoxAdapter} from "@opendaw/studio-adapters" + +export class TimelineFocus implements Terminable { + readonly #track = new DefaultObservableValue>(Option.None) + readonly #region = new DefaultObservableValue>(Option.None) + + get track(): Option {return this.#track.getValue()} + get region(): Option {return this.#region.getValue()} + + get trackObservable() {return this.#track} + get regionObservable() {return this.#region} + + focusTrack(track: TrackBoxAdapter): void { + this.#track.setValue(Option.wrap(track)) + this.#region.setValue(Option.None) + } + + focusRegion(region: AnyRegionBoxAdapter): void { + region.trackBoxAdapter.ifSome(track => this.#track.setValue(Option.wrap(track))) + this.#region.setValue(Option.wrap(region)) + } + + clearRegionFocus(): void { + this.#region.setValue(Option.None) + } + + clear(): void { + this.#track.setValue(Option.None) + this.#region.setValue(Option.None) + } + + terminate(): void { + this.#track.terminate() + this.#region.terminate() + } +} +``` + +### Integration Points + +#### 1. Timeline/Project Level +```typescript +// In Project or Timeline +readonly timelineFocus = new TimelineFocus() +``` + +#### 2. RegionsArea.tsx - Focus on Click +```typescript +// When clicking on a region (in Dragging.attach or similar) +timelineFocus.focusRegion(target.region) + +// When clicking on track empty area +timelineFocus.focusTrack(target.track.trackBoxAdapter) +``` + +#### 3. Track Header - Focus on Click +```typescript +// In TrackHeader.tsx +onClick={() => timelineFocus.focusTrack(trackBoxAdapter) +``` + +#### 4. RegionsClipboardHandler - Use Focus +```typescript +// Change context +readonly getFocusedTrack: Provider> + +// In copy: +const anchorTrack = getFocusedTrack() +if (anchorTrack.isEmpty()) {return Option.None} +// Calculate offsets relative to anchorTrack + +// In paste: +const targetTrack = getFocusedTrack() +if (targetTrack.isEmpty()) {return} +``` + +#### 5. Region Deletion - Clear Region Focus +```typescript +// When deleting regions +if (timelineFocus.region.contains(deletedRegion)) { + timelineFocus.clearRegionFocus() +} +``` + +### Visual Feedback + +#### Track Focus Indicator +```css +.track[data-focused="true"] { + /* Subtle left border or background tint */ + border-left: 2px solid var(--accent-color); +} +``` + +#### Region Focus Indicator +```css +.region[data-focused="true"] { + /* Distinct from selection - e.g., thicker border or glow */ + box-shadow: 0 0 0 2px var(--focus-color); +} +``` + +--- + +## Implementation Checklist + +- [ ] Create `TimelineFocus` class +- [ ] Add `timelineFocus` to Project or appropriate scope +- [ ] Update `RegionsArea.tsx` to set focus on region click +- [ ] Update `RegionsArea.tsx` to set focus on track click (empty area) +- [ ] Update track headers to set focus on click +- [ ] Update `RegionsClipboardHandler` to use `getFocusedTrack` instead of `getSelectedTrack` +- [ ] Clear region focus when focused region is deleted +- [ ] Add visual indicators for focused track and region +- [ ] Ensure focus persists after cut operation + +--- + +## Files to Create/Modify + +### New Files +``` +packages/studio/core/src/ui/timeline/TimelineFocus.ts +``` + +### Modify +``` +packages/studio/core/src/ui/index.ts - export TimelineFocus +packages/studio/core/src/project/Project.ts - add timelineFocus +packages/app/studio/src/ui/timeline/tracks/audio-unit/regions/RegionsArea.tsx - set focus on clicks +packages/app/studio/src/ui/timeline/tracks/audio-unit/headers/TrackHeader.tsx - set focus on click +packages/studio/core/src/ui/clipboard/types/RegionsClipboardHandler.ts - use getFocusedTrack +```