diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index f5b1617118..3a9511683b 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -176,22 +176,6 @@ "withLane", ], }, - // automationShapes is part of the audio-automation stack: its consumer is - // the UI layer that uses shape generators one PR upstack, so a per-PR audit - // diffing against the merge base sees these as unused. Consumed for real once - // the stack merges; safe to drop this entry then. - { - "file": "packages/studio/src/player/components/automationShapes.ts", - "exports": ["AUTOMATION_SHAPES"], - }, - // automationSimplify is part of the audio-automation stack: its consumer is - // the UI layer one PR upstack, so a per-PR audit diffing against the merge - // base sees these as unused. Consumed for real once the stack merges; safe - // to drop this entry then. - { - "file": "packages/studio/src/player/components/automationSimplify.ts", - "exports": ["simplifyPoints"], - }, // propertyPanelAutomation is the shared reader for both panel sections; the // FX group that consumes these two lands one PR upstack, so a per-PR audit // against the merge base sees them as unused. diff --git a/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx b/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx index 7966634485..25ab48c2fa 100644 --- a/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx +++ b/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx @@ -185,6 +185,40 @@ describe("useAutomationSelectionKeyboard", () => { }); }); + it("Cmd+V at a selection near the clip's end clamps the paste inside its duration", () => { + // The playhead branch already clamps to duration - span; the + // selection-start branch didn't, so pasting a 2s clip at a selection + // sitting at t0=5.5 on a 6s clip used to write points out to t=7.5 — + // past element.duration — and leave the selection itself out of bounds. + clearAutomationClipboard(); + usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" }); + usePlayerStore + .getState() + .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }); + const { onCommit } = setup({}); + combo("c"); + expect(readClipboard()?.span).toBe(2); + + // A 0.1s-wide selection right near the clip's 6s end. + usePlayerStore + .getState() + .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 5.5, t1: 5.6 }); + combo("v"); + const written = onCommit.mock.calls.at(-1)?.[0]; + const times = (written?.lanes?.[0]?.points ?? []).map((p: { t: number }) => p.t); + for (const t of times) { + expect(t).toBeGreaterThanOrEqual(0); + expect(t).toBeLessThanOrEqual(bgmElement.duration); + } + // Clamped to duration (6) - span (2) = 4, not the unclamped 5.5. + expect(usePlayerStore.getState().automationSelection).toEqual({ + elementKey: "bgm", + target: "volume", + t0: 4, + t1: 6, + }); + }); + it("Cmd+V with clipboard content but no resolvable element falls through", () => { clearAutomationClipboard(); copyRange({ target: "volume", points: [{ t: 0, v: 1 }] }, VOLUME_RANGE, 0, 1); diff --git a/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts b/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts index 4135afedcf..8fe20294ad 100644 --- a/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts +++ b/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts @@ -161,7 +161,7 @@ function handlePaste( const atT = sel && sel.elementKey === paste.elementKey - ? sel.t0 + ? clamp(sel.t0, 0, paste.element.duration - clip.span) : clamp(state.currentTime - paste.element.start, 0, paste.element.duration - clip.span); const t1 = atT + clip.span; const inner = pastePoints(clip, paste.range, atT); diff --git a/packages/studio/src/player/components/AutomationSelectionMenu.tsx b/packages/studio/src/player/components/AutomationSelectionMenu.tsx index 4a2bd0d057..f157f6e266 100644 --- a/packages/studio/src/player/components/AutomationSelectionMenu.tsx +++ b/packages/studio/src/player/components/AutomationSelectionMenu.tsx @@ -30,11 +30,19 @@ export const AutomationSelectionMenu = memo(function AutomationSelectionMenu({ const menuRef = useContextMenuDismiss(onClose); const row = "block w-full px-2 py-1 text-left text-[11px] text-panel-text-1 hover:bg-panel-bg-3 disabled:opacity-40"; + // Same edge-clamping precedent as TrackGapContextMenu: without it a + // right-click near the bottom/right of the timeline renders this menu + // partially off-screen. + const menuWidth = 140; + const menuHeight = AUTOMATION_SHAPES.length * 24 + 32; + const overflowY = y + menuHeight - window.innerHeight; + const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x; + const adjustedY = overflowY > 0 ? y - overflowY - 8 : y; return createPortal(
{AUTOMATION_SHAPES.map((shape) => (