From ae914334f279ee53b3aebd0437ef7a9c4d20f8b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 30 Sep 2026 05:32:47 +0000 Subject: [PATCH] feat(studio): let a host make the timeline read-only while seek, scroll and zoom stay live (#4749) * feat(studio): let a host make the timeline read-only while seek, scroll and zoom stay live Timeline takes readOnly and onReadOnlyPress. In read-only the subtree sees no edit callbacks, every clip is locked, and each refused press (clip drag, clip/lane/keyframe menu, drop, eye, razor, beat dot, FX) reports once through onReadOnlyPress. * refactor(studio): route read-only refusals through one helper and share the keyframed test fixture Keeps the provider hook under the complexity limit, drops the comments the ratchet counted, and reuses the ruler test's keyframed card instead of copying it. The provider keeps one tree shape in both modes so toggling read-only keeps scroll and zoom. * fix(studio): switching the timeline to read-only drops edits already in flight On the switch into read-only the timeline cancels an active clip drag or resize, beat drag and keyframe retime without committing, and closes the clip, gap and keyframe menus. A keyframe-diamond drag and an automation-lane point drag or double-click now report through onReadOnlyPress too, and the refusal stays one stable function however the host writes its callback. --- .../src/player/components/BeatStrip.tsx | 9 +- .../components/Timeline.readOnly.test.tsx | 670 ++++++++++++++++++ .../components/Timeline.rulerSeek.test.tsx | 26 +- .../TimelineAutomationLane.test.tsx | 23 + .../components/TimelineClipDiamonds.tsx | 9 +- .../player/components/TimelineFxButton.tsx | 3 + .../src/player/components/TimelineLanes.tsx | 5 +- .../player/components/TimelineProvider.tsx | 30 +- .../src/player/components/TimelineTypes.ts | 2 + .../components/timelineClipGestureHandlers.ts | 4 +- .../src/player/components/timelineDragDrop.ts | 12 +- .../components/timelineEditCapabilities.ts | 1 + .../player/components/timelineMountTestEnv.ts | 24 + .../src/player/components/timelineReadOnly.ts | 64 ++ .../components/useAutomationLaneGestures.ts | 15 +- .../components/useTimelineKeyframeHandlers.ts | 7 + .../components/useTimelineProviderState.tsx | 56 +- 17 files changed, 903 insertions(+), 57 deletions(-) create mode 100644 packages/studio/src/player/components/Timeline.readOnly.test.tsx create mode 100644 packages/studio/src/player/components/timelineReadOnly.ts diff --git a/packages/studio/src/player/components/BeatStrip.tsx b/packages/studio/src/player/components/BeatStrip.tsx index 596d086dbb..b9c0b69228 100644 --- a/packages/studio/src/player/components/BeatStrip.tsx +++ b/packages/studio/src/player/components/BeatStrip.tsx @@ -12,6 +12,7 @@ import { resolveTimelineAutoScrollLoopAction, } from "./timelineEditing"; import { getTimelineElementIndexes } from "../lib/timelineElementIndexes"; +import { useTimelineReadOnlyPress } from "./timelineReadOnly"; export const BEAT_BAND_H = 14; // dark band height at top of track const BEAT_HIT_W = 24; // grab width per beat (px) — ≥24px pointer target @@ -88,7 +89,7 @@ function claimBeatDrag(pointerId?: number): BeatDragActor | null { return actor; } -function cancelBeatDrag(): void { +export function cancelBeatDrag(): void { claimBeatDrag(); } @@ -377,6 +378,7 @@ export const BeatStrip = memo(function BeatStrip({ ); const sessionEpoch = usePlayerStore((state) => state.timelineSessionEpoch); const projectId = usePlayerStore((state) => state.timelineProjectId); + const readOnlyPress = useTimelineReadOnlyPress(); if (!beatTimes || beatsTooDense(beatTimes, pps)) return null; const activeBeatIndex = activeActor @@ -420,7 +422,7 @@ export const BeatStrip = memo(function BeatStrip({ top: 0, width: BEAT_HIT_W, height: BEAT_BAND_H, - cursor: "ew-resize", + cursor: readOnlyPress ? "default" : "ew-resize", pointerEvents: "auto", touchAction: "none", }} @@ -429,13 +431,14 @@ export const BeatStrip = memo(function BeatStrip({ // selection (which otherwise "selects" the whole panel mid-drag). e.preventDefault(); e.stopPropagation(); + if (readOnlyPress) return readOnlyPress(); // ⌥ starts no drag: the delete lands on release below, so a // slipped ⌥-drag abandons instead of destroying the beat. if (e.altKey) return; beginBeatDrag(e, t, pps); }} onClick={(e) => { - if (!e.altKey) return; + if (!e.altKey || readOnlyPress) return; // ⌥-click deletes. Deliberately NOT double-click: a stuttered drag // attempt reads as a double-click and would destroy the beat. e.stopPropagation(); diff --git a/packages/studio/src/player/components/Timeline.readOnly.test.tsx b/packages/studio/src/player/components/Timeline.readOnly.test.tsx new file mode 100644 index 0000000000..bb020fcd1a --- /dev/null +++ b/packages/studio/src/player/components/Timeline.readOnly.test.tsx @@ -0,0 +1,670 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { Timeline } from "./Timeline"; +import { installTimelineMountEnv, KEYFRAMED_CARD } from "./timelineMountTestEnv"; +import { usePlayerStore, type TimelineElement } from "../store/playerStore"; +import { TimelineEditProvider } from "../../contexts/TimelineEditContext"; +import type { TimelineProps } from "./TimelineTypes"; +import { TimelineReadOnlyContext } from "./timelineReadOnly"; +import { BeatStrip } from "./BeatStrip"; +import { TimelineFxButton } from "./TimelineFxButton"; +import { TimelineDiamondLane } from "./TimelineClipDiamonds"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +installTimelineMountEnv(); + +beforeAll(() => { + HTMLElement.prototype.setPointerCapture = vi.fn(); + HTMLElement.prototype.releasePointerCapture = vi.fn(); +}); + +let root: Root | null = null; +afterEach(() => { + if (root) act(() => root!.unmount()); + root = null; + document.body.innerHTML = ""; + usePlayerStore.getState().reset(); +}); + +const CLIPS: TimelineElement[] = [ + { id: "a", key: "a", domId: "a", tag: "video", start: 0, duration: 2, track: 0 }, + { id: "b", key: "b", domId: "b", tag: "video", start: 5, duration: 3, track: 0 }, +]; + +function mount(props: TimelineProps, elements: TimelineElement[] = CLIPS, wired = true) { + usePlayerStore.setState({ duration: 10, currentTime: 0, timelineReady: true, elements }); + const edits = { + onMoveElement: vi.fn(), + onMoveElements: vi.fn(), + onResizeElement: vi.fn(), + onBlockedEditAttempt: vi.fn(), + onToggleTrackHidden: vi.fn(), + onSetElementAttributeLive: vi.fn(), + onSetElementAttributeQuiet: vi.fn(), + onRazorSplit: vi.fn(), + onRazorSplitAll: vi.fn(), + onTogglePropertyGroupKeyframe: vi.fn(), + onMoveKeyframe: vi.fn().mockResolvedValue(true), + onDeleteElement: vi.fn(), + }; + const onSeek = vi.fn(); + const onFileDrop = vi.fn(); + const host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + const render = (next: TimelineProps) => + act(() => + root!.render( + !wired ? ( + + ) : ( + + {/* The host's edits arrive both ways: through the edit context and as Timeline props. */} + + + ), + ), + ); + render(props); + const viewport = host.querySelector("[data-timeline-scroll-viewport]")!; + if (elements.length > 0) { + viewport.getBoundingClientRect = () => + ({ left: 0, top: 0, right: 900, bottom: 400, width: 900, height: 400 }) as DOMRect; + } + const clip = (id: string) => host.querySelector(`[data-clip][data-el-id="${id}"]`); + const pointer = (clientX: number, clientY: number) => ({ + bubbles: true, + button: 0, + pointerId: 1, + clientX, + clientY, + }); + return { + host, + edits, + onSeek, + onFileDrop, + clip, + rerender: render, + /** Press clip `a`, travel past the drag threshold, and report whether a drag had started. */ + dragClip() { + const target = clip("a")!; + const rect = target.getBoundingClientRect(); + const x = rect.left + rect.width / 2; + const y = rect.top + rect.height / 2; + act(() => { + target.dispatchEvent(new PointerEvent("pointerdown", pointer(x, y))); + }); + act(() => { + window.dispatchEvent(new PointerEvent("pointermove", pointer(x + 30, y))); + window.dispatchEvent(new PointerEvent("pointermove", pointer(x + 60, y))); + }); + const dragging = clip("a") === null; + act(() => { + window.dispatchEvent(new PointerEvent("pointerup", pointer(x + 60, y))); + }); + return dragging; + }, + pressRuler() { + const at = pointer(400, 4); + act(() => viewport.dispatchEvent(new PointerEvent("pointerdown", at))); + act(() => viewport.dispatchEvent(new PointerEvent("pointerup", at))); + }, + contextMenu(target: HTMLElement, clientX: number) { + act(() => { + target.dispatchEvent( + new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX, clientY: 40 }), + ); + }); + }, + /** Right-click the first lane's empty space, past clip `a`. */ + laneMenu() { + const cell = host.querySelector('[role="gridcell"][aria-colindex="2"]')!; + cell.getBoundingClientRect = () => + ({ left: 0, top: 0, right: 900, bottom: 40, width: 900, height: 40 }) as DOMRect; + this.contextMenu(cell, 350); + }, + dragFileOver() { + const dataTransfer = { + types: ["Files"], + dropEffect: "copy", + files: [new File(["x"], "clip.mp4", { type: "video/mp4" })], + items: [], + getData: () => "", + }; + const fire = (type: string) => { + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.assign(event, { dataTransfer, clientX: 400, clientY: 40 }); + act(() => { + viewport.dispatchEvent(event); + }); + return event; + }; + fire("dragover"); + const second = fire("dragover"); + // The shell rings itself while a drop would land (the drag-over preview). + const preview = host + .querySelector('[aria-label="Timeline track view"]')! + .className.includes("ring-studio-accent"); + fire("drop"); + return { dropEffect: dataTransfer.dropEffect, accepted: second.defaultPrevented, preview }; + }, + }; +} + +describe("Timeline readOnly", () => { + it("still scrubs from the ruler", () => { + const onReadOnlyPress = vi.fn(); + const t = mount({ readOnly: true, onReadOnlyPress }); + t.pressRuler(); + expect(t.onSeek).toHaveBeenCalled(); + expect(onReadOnlyPress).not.toHaveBeenCalled(); + }); + + it("refuses a clip drag before it starts and reports it once", () => { + const onReadOnlyPress = vi.fn(); + const t = mount({ readOnly: true, onReadOnlyPress }); + expect(t.dragClip()).toBe(false); + expect(onReadOnlyPress).toHaveBeenCalledTimes(1); + expect(t.edits.onMoveElement).not.toHaveBeenCalled(); + expect(t.edits.onMoveElements).not.toHaveBeenCalled(); + expect(t.edits.onBlockedEditAttempt).not.toHaveBeenCalled(); + }); + + it("reports a clip drag even when the host wires no edit callbacks", () => { + const onReadOnlyPress = vi.fn(); + const t = mount({ readOnly: true, onReadOnlyPress }, CLIPS, false); + expect(t.dragClip()).toBe(false); + expect(onReadOnlyPress).toHaveBeenCalledTimes(1); + }); + + it("offers no trim handle and still selects on a plain click", () => { + const onReadOnlyPress = vi.fn(); + const t = mount({ readOnly: true, onReadOnlyPress }); + act(() => t.clip("a")!.click()); + expect(usePlayerStore.getState().selectedElementId).toBe("a"); + expect(t.clip("a")!.querySelector('[style*="col-resize"]')).toBeNull(); + expect(onReadOnlyPress).not.toHaveBeenCalled(); + }); + + it("opens no clip menu on right-click and reports it once", () => { + const onReadOnlyPress = vi.fn(); + const t = mount({ readOnly: true, onReadOnlyPress }); + t.contextMenu(t.clip("a")!, 20); + expect(document.querySelector('[role="menu"][aria-label="Clip actions"]')).toBeNull(); + expect(onReadOnlyPress).toHaveBeenCalledTimes(1); + }); + + it("opens no gap menu on an empty lane and reports it once", () => { + const onReadOnlyPress = vi.fn(); + const t = mount({ readOnly: true, onReadOnlyPress }); + t.laneMenu(); + expect(document.body.textContent).not.toContain("Close gap"); + expect(onReadOnlyPress).toHaveBeenCalledTimes(1); + }); + + it("refuses a file drag with no preview or commit and reports it once", () => { + const onReadOnlyPress = vi.fn(); + const t = mount({ readOnly: true, onReadOnlyPress }); + const drag = t.dragFileOver(); + expect(drag).toEqual({ dropEffect: "none", accepted: false, preview: false }); + expect(t.onFileDrop).not.toHaveBeenCalled(); + expect(onReadOnlyPress).toHaveBeenCalledTimes(1); + }); + + it("reports to the host's latest callback after it re-renders with a new one", () => { + const first = vi.fn(); + const latest = vi.fn(); + const t = mount({ readOnly: true, onReadOnlyPress: first }); + t.rerender({ readOnly: true, onReadOnlyPress: latest }); + act(() => t.host.querySelector('button[aria-label^="Hide track"]')!.click()); + expect([first.mock.calls.length, latest.mock.calls.length]).toEqual([0, 1]); + }); + + it("reports the track eye instead of toggling it", () => { + const onReadOnlyPress = vi.fn(); + const t = mount({ readOnly: true, onReadOnlyPress }); + act(() => t.host.querySelector('button[aria-label^="Hide track"]')!.click()); + expect(t.edits.onToggleTrackHidden).not.toHaveBeenCalled(); + expect(onReadOnlyPress).toHaveBeenCalledTimes(1); + }); +}); + +const FADED_AUDIO: TimelineElement[] = [ + { + id: "vo", + key: "vo", + domId: "vo", + tag: "audio", + src: "vo.mp3", + start: 0, + duration: 4, + track: 0, + fadeIn: 1, + }, +]; + +const CARD: TimelineElement[] = [ + { + id: "card", + key: "card", + domId: "card", + label: "Hero card", + tag: "div", + start: 0, + duration: 4, + track: 0, + }, +]; +const CARD_KEYFRAMES = new Map([ + [ + "card", + { + format: "percentage" as const, + keyframes: [{ percentage: 50, properties: { x: 100 }, tweenPercentage: 50 }], + }, + ], +]); + +/** Every one-shot gesture routed through the refusal, run against a mount with or without readOnly. */ +function oneShotGestures(readOnly: boolean) { + const onReadOnlyPress = vi.fn(); + const props = readOnly ? { readOnly, onReadOnlyPress } : {}; + const remount = (elements: TimelineElement[]) => { + if (root) act(() => root!.unmount()); + root = null; + document.body.innerHTML = ""; + return mount(props, elements); + }; + const reported = () => onReadOnlyPress.mock.calls.length; + + usePlayerStore.setState({ activeTool: "razor" }); + let t = remount(CLIPS); + act(() => t.clip("a")!.click()); + const razor = { reported: reported(), split: t.edits.onRazorSplit.mock.calls.length }; + act(() => t.clip("b")!.dispatchEvent(new MouseEvent("click", { bubbles: true, shiftKey: true }))); + const razorAll = { + reported: reported() - razor.reported, + split: t.edits.onRazorSplitAll.mock.calls.length, + }; + const beforeShift = reported(); + const viewport = t.host.querySelector("[data-timeline-scroll-viewport]")!; + act(() => { + viewport.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + button: 0, + pointerId: 2, + clientX: 700, + clientY: 60, + shiftKey: true, + }), + ); + }); + const shiftSplitAll = { + reported: reported() - beforeShift, + split: t.edits.onRazorSplitAll.mock.calls.length - razorAll.split, + }; + usePlayerStore.setState({ activeTool: "select" }); + + usePlayerStore.setState({ gsapAnimations: new Map(), keyframeCache: CARD_KEYFRAMES }); + t = remount(CARD); + const beforeMenu = reported(); + t.contextMenu(t.host.querySelector('button[title="50%"]')!, 60); + const keyframeMenu = { + reported: reported() - beforeMenu, + open: document.querySelector('[role="menu"][aria-label="Keyframe actions"]') !== null, + }; + + usePlayerStore.setState({ selectedElementId: "card", gsapAnimations: KEYFRAMED_CARD }); + t = remount(CARD); + const beforeToggle = reported(); + act(() => + t.host.querySelector('button[aria-pressed][aria-label$=" keyframe"]')!.click(), + ); + const propertyToggle = { + reported: reported() - beforeToggle, + toggled: t.edits.onTogglePropertyGroupKeyframe.mock.calls.length, + }; + return { razor, razorAll, shiftSplitAll, keyframeMenu, propertyToggle }; +} + +describe("Timeline readOnly one-shot edits", () => { + it("refuses razor splits, the keyframe menu and the property keyframe toggle, reporting each once", () => { + expect(oneShotGestures(true)).toEqual({ + razor: { reported: 1, split: 0 }, + razorAll: { reported: 1, split: 0 }, + shiftSplitAll: { reported: 1, split: 0 }, + keyframeMenu: { reported: 1, open: false }, + propertyToggle: { reported: 1, toggled: 0 }, + }); + }); + + it("performs every one of them without readOnly", () => { + expect(oneShotGestures(false)).toEqual({ + razor: { reported: 0, split: 1 }, + razorAll: { reported: 0, split: 1 }, + shiftSplitAll: { reported: 0, split: 1 }, + keyframeMenu: { reported: 0, open: true }, + propertyToggle: { reported: 0, toggled: 1 }, + }); + }); +}); + +const LOCKED: TimelineProps = { readOnly: true, onReadOnlyPress: vi.fn() }; +const press = (x: number, y = 10) => ({ + bubbles: true, + button: 0, + pointerId: 1, + clientX: x, + clientY: y, +}); +const onWindow = (type: string, x: number) => + act(() => { + window.dispatchEvent(new PointerEvent(type, press(x))); + }); +const pressOn = (el: Element, x: number) => + act(() => { + el.dispatchEvent(new PointerEvent("pointerdown", press(x))); + }); + +describe("Timeline switched to readOnly mid-edit", () => { + it("drops a clip drag without committing it", () => { + const t = mount({}); + pressOn(t.clip("a")!, 0); + onWindow("pointermove", 30); + onWindow("pointermove", 60); + expect(t.clip("a")).toBeNull(); + t.rerender(LOCKED); + onWindow("pointerup", 60); + expect(t.edits.onMoveElement).not.toHaveBeenCalled(); + expect(t.edits.onMoveElements).not.toHaveBeenCalled(); + }); + + it("drops a clip resize without committing it", () => { + const t = mount({}); + act(() => t.clip("a")!.click()); + pressOn(t.clip("a")!.querySelector('[style*="col-resize"]')!, 0); + onWindow("pointermove", 20); + onWindow("pointermove", 40); + t.rerender(LOCKED); + onWindow("pointerup", 40); + expect(t.edits.onResizeElement).not.toHaveBeenCalled(); + }); + + it("drops a beat drag without committing it", () => { + const commitBeatEdits = vi.fn(); + const t = mount({}, [ + { + id: "music", + tag: "audio", + src: "m.mp3", + start: 0, + duration: 10, + track: 0, + timelineRole: "music", + }, + ]); + // The music analysis resets the beats on mount, so they arrive afterwards. + act(() => + usePlayerStore.setState({ + beatAnalysis: { + beatTimes: [1, 3], + beatStrengths: [0.5, 0.8], + bpm: 120, + bpmConfidence: "high", + channelData: null, + sampleRate: 48_000, + peak: 1, + }, + commitBeatEdits, + }), + ); + pressOn(t.host.querySelector('[title="Drag to move · ⌥-click to delete"]')!, 100); + expect(usePlayerStore.getState().beatDragging).toBe(true); + onWindow("pointermove", 160); + t.rerender(LOCKED); + expect(usePlayerStore.getState().beatDragging).toBe(false); + onWindow("pointerup", 160); + expect(commitBeatEdits).not.toHaveBeenCalled(); + }); + + it("drops a keyframe retime without committing it", () => { + usePlayerStore.setState({ selectedElementId: "card", gsapAnimations: KEYFRAMED_CARD }); + const t = mount({}, CARD); + pressOn(t.host.querySelector('button[title="25%"]')!, 80); + t.rerender(LOCKED); + onWindow("pointerup", 120); + expect(t.edits.onMoveKeyframe).not.toHaveBeenCalled(); + }); + + it("closes an open clip menu, gap menu and keyframe menu", () => { + const t = mount({}); + t.contextMenu(t.clip("a")!, 20); + t.rerender(LOCKED); + const clipMenu = document.querySelector('[role="menu"][aria-label="Clip actions"]'); + t.rerender({}); + t.laneMenu(); + t.rerender(LOCKED); + const gapMenu = document.body.textContent?.includes("Close gap"); + act(() => root!.unmount()); + root = null; + usePlayerStore.setState({ gsapAnimations: new Map(), keyframeCache: CARD_KEYFRAMES }); + const k = mount({}, CARD); + k.contextMenu(k.host.querySelector('button[title="50%"]')!, 60); + k.rerender(LOCKED); + const keyframeMenu = document.querySelector('[role="menu"][aria-label="Keyframe actions"]'); + expect({ clipMenu, gapMenu, keyframeMenu }).toEqual({ + clipMenu: null, + gapMenu: false, + keyframeMenu: null, + }); + expect(t.edits.onDeleteElement).not.toHaveBeenCalled(); + }); +}); + +describe("Timeline readOnly with a host edit context", () => { + it("draws no fade handle, so the context's fade writers are never reached", () => { + const t = mount({ readOnly: true, onReadOnlyPress: vi.fn() }, FADED_AUDIO); + act(() => t.clip("vo")!.click()); + expect(t.host.querySelector('[role="slider"]')).toBeNull(); + expect(t.edits.onSetElementAttributeLive).not.toHaveBeenCalled(); + }); + + it("says nothing about dropping media on an empty timeline", () => { + const t = mount({ readOnly: true, onReadOnlyPress: vi.fn() }, []); + expect(t.host.textContent).not.toContain("Drop media"); + }); +}); + +describe("Timeline without readOnly (unchanged)", () => { + it("scrubs, drags, opens both menus, previews and commits a drop, toggles the eye", () => { + const t = mount({}); + t.pressRuler(); + expect(t.onSeek).toHaveBeenCalled(); + + expect(t.dragClip()).toBe(true); + expect(t.edits.onMoveElement.mock.calls.length + t.edits.onMoveElements.mock.calls.length).toBe( + 1, + ); + + t.contextMenu(t.clip("a")!, 20); + expect(document.querySelector('[role="menu"][aria-label="Clip actions"]')).not.toBeNull(); + act(() => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }))); + + t.laneMenu(); + expect(document.body.textContent).toContain("Close gap"); + + const drag = t.dragFileOver(); + expect(drag.dropEffect).toBe("copy"); + expect(drag.accepted).toBe(true); + expect(drag.preview).toBe(true); + expect(t.onFileDrop).toHaveBeenCalledTimes(1); + + act(() => t.host.querySelector('button[aria-label^="Hide track"]')!.click()); + expect(t.edits.onToggleTrackHidden).toHaveBeenCalledTimes(1); + }); + + it("draws the fade handles from the edit context and offers media drops", () => { + const t = mount({}, FADED_AUDIO); + act(() => t.clip("vo")!.click()); + expect(t.host.querySelector('[role="slider"]')).not.toBeNull(); + act(() => root!.unmount()); + root = null; + expect(mount({}, []).host.textContent).toContain("Drop media"); + }); + + it("draws the trim handles on a selected clip", () => { + const t = mount({}); + act(() => t.clip("a")!.click()); + expect(t.clip("a")!.querySelector('[style*="col-resize"]')).not.toBeNull(); + }); +}); + +function mountReadOnly(node: React.ReactElement) { + const onReadOnlyPress = vi.fn(); + const host = document.createElement("div"); + host.setAttribute("data-timeline-scroll-viewport", ""); + document.body.append(host); + root = createRoot(host); + act(() => + root!.render( + + {node} + , + ), + ); + return { host, onReadOnlyPress }; +} + +describe("read-only children", () => { + it("a beat dot starts no drag, deletes nothing on alt-click, and reports once per press", () => { + const commitBeatEdits = vi.fn(); + usePlayerStore.setState({ + timelineSessionEpoch: 1, + timelineProjectId: "project-a", + elements: [ + { + id: "music", + tag: "audio", + src: "m.mp3", + start: 0, + duration: 10, + track: 0, + timelineRole: "music", + }, + ], + beatAnalysis: { + beatTimes: [1, 3], + beatStrengths: [0.5, 0.8], + bpm: 120, + bpmConfidence: "high", + channelData: null, + sampleRate: 48_000, + peak: 1, + }, + commitBeatEdits, + }); + const { host, onReadOnlyPress } = mountReadOnly( + , + ); + host.getBoundingClientRect = () => new DOMRect(0, 0, 1_000, 500); + const beat = host.querySelector('[title="Drag to move · ⌥-click to delete"]')!; + const at = { bubbles: true, button: 0, pointerId: 1, clientX: 100, clientY: 5 }; + act(() => beat.dispatchEvent(new PointerEvent("pointerdown", at))); + expect(usePlayerStore.getState().beatDragging).toBe(false); + act(() => { + beat.dispatchEvent(new PointerEvent("pointerdown", { ...at, altKey: true })); + beat.dispatchEvent(new MouseEvent("click", { bubbles: true, altKey: true })); + }); + expect(commitBeatEdits).not.toHaveBeenCalled(); + expect(onReadOnlyPress).toHaveBeenCalledTimes(2); + }); + + it("the FX button opens no popover and reports the click", () => { + const { host, onReadOnlyPress } = mountReadOnly( + , + ); + act(() => host.querySelector("button")!.click()); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect(onReadOnlyPress).toHaveBeenCalledTimes(1); + }); + + it("a keyframe diamond is not retimed by a drag, reports the drag once, and still selects", () => { + const onMoveKeyframe = vi.fn().mockResolvedValue(true); + const onClickKeyframe = vi.fn(); + const { host, onReadOnlyPress } = mountReadOnly( + , + ); + const diamond = host.querySelector('button[title="40%"]')!; + act(() => { + diamond.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 80 }), + ); + window.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, clientX: 90 })); + window.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, clientX: 100 })); + diamond.dispatchEvent( + new PointerEvent("pointerup", { bubbles: true, button: 0, clientX: 100 }), + ); + }); + expect(onMoveKeyframe).not.toHaveBeenCalled(); + expect(onClickKeyframe).toHaveBeenCalledTimes(1); + expect(onReadOnlyPress).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/studio/src/player/components/Timeline.rulerSeek.test.tsx b/packages/studio/src/player/components/Timeline.rulerSeek.test.tsx index a5c1e85211..5532fb162f 100644 --- a/packages/studio/src/player/components/Timeline.rulerSeek.test.tsx +++ b/packages/studio/src/player/components/Timeline.rulerSeek.test.tsx @@ -4,7 +4,7 @@ import React, { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { Timeline } from "./Timeline"; -import { installTimelineMountEnv } from "./timelineMountTestEnv"; +import { installTimelineMountEnv, KEYFRAMED_CARD as KEYFRAMED } from "./timelineMountTestEnv"; import { usePlayerStore } from "../store/playerStore"; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -20,30 +20,6 @@ afterEach(() => { document.body.innerHTML = ""; }); -const KEYFRAMED = new Map([ - [ - "card", - [ - { - id: "card-position", - targetSelector: "#card", - method: "to" as const, - position: 0, - duration: 2, - properties: {}, - propertyGroup: "position" as const, - keyframes: { - format: "percentage" as const, - keyframes: [ - { percentage: 0, properties: { x: 0 } }, - { percentage: 50, properties: { x: 100 } }, - ], - }, - }, - ], - ], -]); - /** Presses the ruler exactly on the first drawn tick after 0 and returns its time and the seek. */ async function seekOnFirstTick( props: { showKeyframes?: boolean }, diff --git a/packages/studio/src/player/components/TimelineAutomationLane.test.tsx b/packages/studio/src/player/components/TimelineAutomationLane.test.tsx index a764c881e4..92e9df1b0e 100644 --- a/packages/studio/src/player/components/TimelineAutomationLane.test.tsx +++ b/packages/studio/src/player/components/TimelineAutomationLane.test.tsx @@ -3,6 +3,7 @@ import { act } from "react"; import { describe, expect, it, vi, type Mock } from "vitest"; import { createRoot } from "react-dom/client"; import { TimelineAutomationLane } from "./TimelineAutomationLane"; +import { TimelineReadOnlyContext } from "./timelineReadOnly"; import { PAD_X } from "./automationLaneGeometry"; import { AUTOMATION_LANE_H } from "./automationLaneHeight"; import type { HfAudioFxChain } from "@hyperframes/core/audio-fx"; @@ -1965,3 +1966,25 @@ describe("TimelineAutomationLane — a read-only lane offers nothing to grab", ( expect(container.querySelector("[data-automation-readonly-note]")).toBeNull(); }); }); + +describe("TimelineAutomationLane inside a read-only timeline", () => { + it("reports a point drag and a double-click once each, and writes nothing", () => { + const onReadOnlyPress = vi.fn(); + const props = laneProps({ automation: ramp, readOnly: true }); + const { container } = render( + + + , + ); + const svg = container.querySelector("svg")!; + stubBox(svg, BOX); + fire(svg, "pointerdown", at(0, 1)); + fire(svg, "pointermove", at(1, 0.6)); + fire(svg, "pointermove", at(2, 0.4)); + fire(svg, "pointerup", at(2, 0.4)); + expect(onReadOnlyPress).toHaveBeenCalledTimes(1); + fire(svg, "dblclick", at(2, 0.4)); + expect(onReadOnlyPress).toHaveBeenCalledTimes(2); + expect(props.onCommit).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.tsx index a56fce5b95..88a43e2f49 100644 --- a/packages/studio/src/player/components/TimelineClipDiamonds.tsx +++ b/packages/studio/src/player/components/TimelineClipDiamonds.tsx @@ -11,6 +11,7 @@ import { type TimelineKeyframeRetimeHandle, } from "./useTimelineKeyframeHandlers"; import { timelineKeyframeFocusId } from "./timelineNavigationIdentity"; +import { reportPressOnTravel, useTimelineReadOnlyPress } from "./timelineReadOnly"; import { DIAMOND_RATIO, keyframeTimeLabel, @@ -92,6 +93,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({ // This lane only arms it and renders the preview it publishes. const rootRef = useRef(null); const retimeHandleRef = useRef(null); + const readOnlyPress = useTimelineReadOnlyPress(); // Retime destinations already dispatched but not yet in the keyframe cache, so // a rapid second drag composes from where the first move left the keyframe // instead of the stale rendered value. @@ -227,7 +229,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({ : null; const baseColor = isSelected ? accentColor : "var(--timeline-diamond-muted)"; const baseOpacity = isSelected ? 0.4 : 0.25; - const canDrag = isSelected && !!onMoveKeyframe; + const canDrag = isSelected && !!onMoveKeyframe && !readOnlyPress; return (
) => { if (e.button !== 0) return; e.stopPropagation(); - if (!canDrag) return; + if (!canDrag) { + if (isSelected) reportPressOnTravel(e, readOnlyPress); + return; + } retimeHandleRef.current = beginTimelineKeyframeRetime({ event: e, elementId, diff --git a/packages/studio/src/player/components/TimelineFxButton.tsx b/packages/studio/src/player/components/TimelineFxButton.tsx index 75af8ce56e..8039303436 100644 --- a/packages/studio/src/player/components/TimelineFxButton.tsx +++ b/packages/studio/src/player/components/TimelineFxButton.tsx @@ -11,6 +11,7 @@ import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; +import { useTimelineReadOnlyPress } from "./timelineReadOnly"; import { enabledAudioFxNodes, parseAudioFxChain, @@ -197,8 +198,10 @@ export function TimelineFxButton(props: TimelineFxButtonProps) { const transport = useAuditionTransport(); /** Whether THIS audition lifted a mute, so only it puts one back. */ const borrowedMute = useRef(false); + const readOnlyPress = useTimelineReadOnlyPress(); const openAt = () => { + if (readOnlyPress) return readOnlyPress(); setAnchorRect(buttonRef.current?.getBoundingClientRect() ?? null); setOpen(true); }; diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index b57bd0e003..3d63d5b540 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -17,7 +17,7 @@ import { } from "./useTimelineTrackLayout"; import { trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay"; import { clipTimingStart } from "../../hooks/gsapShared"; -import { getTimelineEditCapabilities } from "./timelineEditing"; +import { useTimelineClipCapabilities } from "./timelineReadOnly"; import { CLIP_Y, TRACK_H } from "./timelineLayout"; import { usePlayerStore } from "../store/playerStore"; import { isMultiDragPassenger, multiDragPassengerOffsetPx } from "./timelineMultiDragPreview"; @@ -107,6 +107,7 @@ export function TimelineLanes({ const { collapsedGroupIds, expandedLaneOwnerIds, toggleGroupExpanded, toggleLaneOwnerExpanded } = useTimelineGroupDisclosure(); const automationLanes = useAutomationLanes(); + const getClipCapabilities = useTimelineClipCapabilities(); const transitionSeamsByTrack = useMemo( () => deriveTimelineTransitionSeamsByTrack(tracks.flatMap(([, els]) => els.map(getPreviewElement))), @@ -368,7 +369,7 @@ export function TimelineLanes({ // diamonds on their own bar instead. const isTrackKeyframeClip = elementKey === keyframeClipKey; const showsLanes = isTrackKeyframeClip && rowExpanded; - const capabilities = getTimelineEditCapabilities(el); + const capabilities = getClipCapabilities(el); const isSelected = selectedElementId === elementKey || selectedElementIds.has(elementKey); const isComposition = !!el.compositionSrc; diff --git a/packages/studio/src/player/components/TimelineProvider.tsx b/packages/studio/src/player/components/TimelineProvider.tsx index 5e549b09d2..b3b3c096d0 100644 --- a/packages/studio/src/player/components/TimelineProvider.tsx +++ b/packages/studio/src/player/components/TimelineProvider.tsx @@ -1,6 +1,9 @@ import { createContext, + useCallback, useContext, + useLayoutEffect, + useRef, type ComponentProps, type CSSProperties, type MouseEvent, @@ -26,6 +29,7 @@ import type { TimelineTheme } from "./timelineTheme"; import type { TimelineEditCallbacks } from "./timelineCallbacks"; import type { KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu"; import { useTimelineProviderState } from "./useTimelineProviderState"; +import { TimelineReadOnlyContext } from "./timelineReadOnly"; import { TimelineEditProvider, useTimelineEditContextValue, @@ -180,16 +184,28 @@ export interface TimelineContextValue { const TimelineContext = createContext(null); -export function TimelineProvider({ children, ...props }: TimelineProps & { children: ReactNode }) { +const NO_EDITS: TimelineEditCallbacks = {}; + +export function TimelineProvider({ + children, + readOnly = false, + onReadOnlyPress, + ...props +}: TimelineProps & { children: ReactNode }) { const editContext = useTimelineEditContextValue(); - if (!editContext) { - return ( - + const onReadOnlyPressRef = useRef(onReadOnlyPress); + useLayoutEffect(() => { + onReadOnlyPressRef.current = onReadOnlyPress; + }); + const refuse = useCallback(() => onReadOnlyPressRef.current?.(), []); + const readOnlyPress = readOnly ? refuse : null; + return ( + + {children} - ); - } - return {children}; + + ); } function TimelineProviderState({ children, ...props }: TimelineProps & { children: ReactNode }) { diff --git a/packages/studio/src/player/components/TimelineTypes.ts b/packages/studio/src/player/components/TimelineTypes.ts index c8abc30106..3af0d3c9b4 100644 --- a/packages/studio/src/player/components/TimelineTypes.ts +++ b/packages/studio/src/player/components/TimelineTypes.ts @@ -37,4 +37,6 @@ export interface TimelineProps showAudioEffects?: boolean; showKeyframes?: boolean; trackPadding?: TimelineTrackPadding; + readOnly?: boolean; + onReadOnlyPress?: () => void; } diff --git a/packages/studio/src/player/components/timelineClipGestureHandlers.ts b/packages/studio/src/player/components/timelineClipGestureHandlers.ts index 42d4f4dd56..4dc1671c95 100644 --- a/packages/studio/src/player/components/timelineClipGestureHandlers.ts +++ b/packages/studio/src/player/components/timelineClipGestureHandlers.ts @@ -58,10 +58,12 @@ type PointerDownAction = */ function isIntentBlocked( intent: BlockedTimelineEditIntent | null, + capabilities: TimelineEditCapabilities, onResizeElement: ClipGestureDeps["onResizeElement"], onMoveElement: ClipGestureDeps["onMoveElement"], ): intent is BlockedTimelineEditIntent { if (!intent) return false; + if (capabilities.readOnly) return true; return intent === "move" ? Boolean(onMoveElement) : Boolean(onResizeElement); } @@ -88,7 +90,7 @@ function resolvePointerDownAction( handleWidth: CLIP_HANDLE_W, capabilities, }); - if (isIntentBlocked(intent, onResizeElement, onMoveElement)) { + if (isIntentBlocked(intent, capabilities, onResizeElement, onMoveElement)) { return { kind: "block", intent, rect }; } diff --git a/packages/studio/src/player/components/timelineDragDrop.ts b/packages/studio/src/player/components/timelineDragDrop.ts index 3b50756142..352c39d7fd 100644 --- a/packages/studio/src/player/components/timelineDragDrop.ts +++ b/packages/studio/src/player/components/timelineDragDrop.ts @@ -23,6 +23,7 @@ interface UseTimelineAssetDropOptions extends TimelineDropCallbacks { rowGeometryRef: RefObject; contentOrigin: number; sessionEpoch: number; + readOnlyPress?: (() => void) | null; } /** @@ -132,6 +133,7 @@ export function useTimelineAssetDrop({ onBlockDrop, onCompositionDrop, sessionEpoch, + readOnlyPress, }: UseTimelineAssetDropOptions) { const [isDragOver, setIsDragOver] = useState(false); const [dropPreview, setDropPreview] = useState(null); @@ -140,6 +142,7 @@ export function useTimelineAssetDrop({ ); const autoScrollRafRef = useRef(0); const activeDropEpochRef = useRef(null); + const refusedDragRef = useRef(false); const stopAutoScroll = useCallback(() => { dragPointerRef.current = null; @@ -210,6 +213,12 @@ export function useTimelineAssetDrop({ const hasBlock = types.includes(TIMELINE_BLOCK_MIME); const hasComposition = types.includes(TIMELINE_COMPOSITION_MIME); if (!hasFiles && !hasAsset && !hasBlock && !hasComposition) return; + if (readOnlyPress) { + e.dataTransfer.dropEffect = "none"; + if (!refusedDragRef.current) readOnlyPress(); + refusedDragRef.current = true; + return; + } e.preventDefault(); e.dataTransfer.dropEffect = "copy"; activeDropEpochRef.current = sessionEpoch; @@ -222,11 +231,12 @@ export function useTimelineAssetDrop({ ); syncAutoScroll(e.clientX, e.clientY); }, - [resolveDropPlacement, sessionEpoch, syncAutoScroll], + [readOnlyPress, resolveDropPlacement, sessionEpoch, syncAutoScroll], ); const clearDropPreview = useCallback(() => { activeDropEpochRef.current = null; + refusedDragRef.current = false; stopAutoScroll(); setIsDragOver(false); setDropPreview(null); diff --git a/packages/studio/src/player/components/timelineEditCapabilities.ts b/packages/studio/src/player/components/timelineEditCapabilities.ts index ff3c55fe12..5587da9ec8 100644 --- a/packages/studio/src/player/components/timelineEditCapabilities.ts +++ b/packages/studio/src/player/components/timelineEditCapabilities.ts @@ -2,6 +2,7 @@ export interface TimelineEditCapabilities { canMove: boolean; canTrimStart: boolean; canTrimEnd: boolean; + readOnly?: boolean; } function isDeterministicTimelineWindow(input: { diff --git a/packages/studio/src/player/components/timelineMountTestEnv.ts b/packages/studio/src/player/components/timelineMountTestEnv.ts index 076164562b..a313b4718d 100644 --- a/packages/studio/src/player/components/timelineMountTestEnv.ts +++ b/packages/studio/src/player/components/timelineMountTestEnv.ts @@ -27,3 +27,27 @@ export function installTimelineMountEnv(): void { document.body.innerHTML = ""; }); } + +export const KEYFRAMED_CARD = new Map([ + [ + "card", + [ + { + id: "card-position", + targetSelector: "#card", + method: "to" as const, + position: 0, + duration: 2, + properties: {}, + propertyGroup: "position" as const, + keyframes: { + format: "percentage" as const, + keyframes: [ + { percentage: 0, properties: { x: 0 } }, + { percentage: 50, properties: { x: 100 } }, + ], + }, + }, + ], + ], +]); diff --git a/packages/studio/src/player/components/timelineReadOnly.ts b/packages/studio/src/player/components/timelineReadOnly.ts new file mode 100644 index 0000000000..651cf8f9fd --- /dev/null +++ b/packages/studio/src/player/components/timelineReadOnly.ts @@ -0,0 +1,64 @@ +import { createContext, useContext, useEffect, useEffectEvent } from "react"; +import { + getTimelineEditCapabilities, + type TimelineEditCapabilities, +} from "./timelineEditCapabilities"; + +export const TimelineReadOnlyContext = createContext<(() => void) | null>(null); + +/** The refusal to report while the timeline is read-only; null while it is editable. */ +export function useTimelineReadOnlyPress(): (() => void) | null { + return useContext(TimelineReadOnlyContext); +} + +const READ_ONLY_CLIP: TimelineEditCapabilities = { + canMove: false, + canTrimStart: false, + canTrimEnd: false, + readOnly: true, +}; + +/** Read-only locks every clip; its presses then take the blocked-edit path to the refusal. */ +export function useTimelineClipCapabilities(): typeof getTimelineEditCapabilities { + return useTimelineReadOnlyPress() ? () => READ_ONLY_CLIP : getTimelineEditCapabilities; +} + +/** Read-only swaps each callback for the refusal: the press is reported instead of acted on. */ +export function refuseWhenReadOnly( + readOnlyPress: (() => void) | null, + callbacks: T, +): T { + if (!readOnlyPress) return callbacks; + return Object.fromEntries(Object.keys(callbacks).map((key) => [key, readOnlyPress])) as T; +} + +/** Once the switch into read-only lands, drop every edit already in flight without committing it. */ +export function useAbandonEditsOnReadOnly(readOnly: boolean, abandon: () => void): void { + const abandonNow = useEffectEvent(abandon); + useEffect(() => { + if (readOnly) abandonNow(); + }, [readOnly]); +} + +/** Reports once if the press that started at `start` travels far enough to have been a drag. */ +export function reportPressOnTravel( + start: { clientX: number; clientY: number }, + refuse: (() => void) | null, + threshold = 4, +): void { + if (!refuse) return; + const stop = () => { + window.removeEventListener("pointermove", onMove, true); + window.removeEventListener("pointerup", stop, true); + window.removeEventListener("pointercancel", stop, true); + }; + const onMove = (event: PointerEvent) => { + if (Math.hypot(event.clientX - start.clientX, event.clientY - start.clientY) < threshold) + return; + stop(); + refuse(); + }; + window.addEventListener("pointermove", onMove, true); + window.addEventListener("pointerup", stop, true); + window.addEventListener("pointercancel", stop, true); +} diff --git a/packages/studio/src/player/components/useAutomationLaneGestures.ts b/packages/studio/src/player/components/useAutomationLaneGestures.ts index ec5a0314a5..efda980552 100644 --- a/packages/studio/src/player/components/useAutomationLaneGestures.ts +++ b/packages/studio/src/player/components/useAutomationLaneGestures.ts @@ -26,6 +26,7 @@ import { type ShiftAxis, } from "./automationLaneDragMath"; import { useAutomationSegmentDrag } from "./useAutomationSegmentDrag"; +import { reportPressOnTravel, useTimelineReadOnlyPress } from "./timelineReadOnly"; /** How far a press may travel and still count as a click rather than a drag. */ const CLICK_SLOP_PX = 3; @@ -109,6 +110,7 @@ export function useAutomationLaneGestures({ duration, rangeSelection, }: UseAutomationLaneGesturesInput): UseAutomationLaneGesturesResult { + const timelineReadOnlyPress = useTimelineReadOnlyPress(); const [dragIndex, setDragIndex] = useState(null); const [curveIndex, setCurveIndex] = useState(null); const [hint, setHint] = useState(null); @@ -267,6 +269,13 @@ export function useAutomationLaneGestures({ [hitIndex, segmentDrag, segmentIndex], ); + const reportLaneEditPress = useCallback( + (e: ReactPointerEvent) => { + if (gestureAt(e)) reportPressOnTravel(e, timelineReadOnlyPress); + }, + [gestureAt, timelineReadOnlyPress], + ); + const onPointerDown = useCallback( (e: ReactPointerEvent): void => { if (e.button !== 0) return; @@ -281,6 +290,7 @@ export function useAutomationLaneGestures({ // so the field sat open until Enter however far away the next click landed. if (editing) commitEdit(); if (readOnly) { + reportLaneEditPress(e); // The lane sits below the clip bar, so the timeline's selection handler // never sees this press; selecting here is the only way in. The press then // goes on to arm a range drag rather than being spent on the selection: a @@ -323,6 +333,7 @@ export function useAutomationLaneGestures({ gestureAt, lane, readOnly, + reportLaneEditPress, onSelect, rangeDrag, editing, @@ -526,7 +537,7 @@ export function useAutomationLaneGestures({ const onDoubleClick = useCallback( (e: ReactPointerEvent): void => { - if (readOnly) return; + if (readOnly) return timelineReadOnlyPress?.(); e.stopPropagation(); e.preventDefault(); const onPoint = hitIndex(e.clientX, e.clientY); @@ -550,7 +561,7 @@ export function useAutomationLaneGestures({ const { t, v } = pointAt(e.clientX, e.clientY); commitPoints(mergeInsertPoint(lane.points, t, v), true); }, - [lane, pointAt, commitPoints, readOnly, hitIndex, segmentIndex], + [lane, pointAt, commitPoints, readOnly, timelineReadOnlyPress, hitIndex, segmentIndex], ); return { diff --git a/packages/studio/src/player/components/useTimelineKeyframeHandlers.ts b/packages/studio/src/player/components/useTimelineKeyframeHandlers.ts index 08955ff4bd..9430ac493d 100644 --- a/packages/studio/src/player/components/useTimelineKeyframeHandlers.ts +++ b/packages/studio/src/player/components/useTimelineKeyframeHandlers.ts @@ -93,6 +93,7 @@ interface TimelineKeyframeRetimeCoordinator { * is a viewport-wide question, not a per-keyframe one. */ latest: PendingTimelineKeyframeRetime | null; + cancelActive: () => void; } type TimelineRetimePointerEvent = Pick< @@ -115,6 +116,7 @@ function getRetimeCoordinator(owner: EventTarget): TimelineKeyframeRetimeCoordin preview: null, previewListeners: new Set(), latest: null, + cancelActive: () => {}, }; keyframeRetimeCoordinators.set(owner, coordinator); return coordinator; @@ -186,6 +188,10 @@ export interface TimelineKeyframeRetimeHandle { * Starts a keyframe retime on the stable timeline viewport. The row/button is * only an entry point: window listeners own the gesture through virtualization. */ +export function cancelTimelineKeyframeRetime(viewport: HTMLElement | null): void { + if (viewport) keyframeRetimeCoordinators.get(viewport)?.cancelActive(); +} + export function beginTimelineKeyframeRetime( input: TimelineKeyframeRetimeInput, ): TimelineKeyframeRetimeHandle { @@ -256,6 +262,7 @@ export function beginTimelineKeyframeRetime( teardownListeners: null, }; coordinator.actor = actor; + coordinator.cancelActive = () => cancel(actor); const matchesPointer = (event: TimelineRetimePointerEvent) => actor.pointerId === null || event.pointerId === actor.pointerId; diff --git a/packages/studio/src/player/components/useTimelineProviderState.tsx b/packages/studio/src/player/components/useTimelineProviderState.tsx index df18b5cbbe..becf422f47 100644 --- a/packages/studio/src/player/components/useTimelineProviderState.tsx +++ b/packages/studio/src/player/components/useTimelineProviderState.tsx @@ -27,7 +27,10 @@ import { useTimelineDisplayLayout, useTimelineTrackLayout, } from "./useTimelineTrackLayout"; -import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers"; +import { + cancelTimelineKeyframeRetime, + useTimelineKeyframeHandlers, +} from "./useTimelineKeyframeHandlers"; import { useTimelineGapHighlights } from "./useTimelineGapHighlights"; import { useTimelineRazorInteraction } from "./TimelineRazorInteraction"; import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemetry"; @@ -40,6 +43,12 @@ import { useTimelineActiveClips } from "./useTimelineActiveClips"; import { useTimelineLaneMoveRefresh } from "./useTimelineLaneMoveRefresh"; import { useTimelineLogicalFocus } from "./useTimelineLogicalFocus"; import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext"; +import { + refuseWhenReadOnly, + useAbandonEditsOnReadOnly, + useTimelineReadOnlyPress, +} from "./timelineReadOnly"; +import { cancelBeatDrag } from "./BeatStrip"; export function useTimelineProviderState({ onSeek, onDrillDown, @@ -92,6 +101,14 @@ export function useTimelineProviderState({ }); const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]); const editContext = useTimelineEditContextOptional(); + const readOnlyPress = useTimelineReadOnlyPress(); + const oneShotEdits = refuseWhenReadOnly(readOnlyPress, { + onBlockedEditAttempt, + onRazorSplitAll, + onToggleTrackHidden: editContext.onToggleTrackHidden, + onTogglePropertyGroupKeyframe: editContext.onTogglePropertyGroupKeyframe, + onRazorSplit: editContext.onRazorSplit, + }); const refreshAfterLaneMove = useTimelineLaneMoveRefresh(); useMusicBeatAnalysis(); const timelineElements = usePlayerStore((s) => s.elements); @@ -198,7 +215,7 @@ export function useTimelineProviderState({ onMoveElements: pinnedOnMoveElements, onResizeElement: pinnedOnResizeElement, onResizeElements: pinnedOnResizeElements, - onBlockedEditAttempt, + onBlockedEditAttempt: oneShotEdits.onBlockedEditAttempt, onSeek, setShowPopover, setRangeSelectionRef, @@ -218,6 +235,7 @@ export function useTimelineProviderState({ onBlockDrop: pinnedOnBlockDrop, onCompositionDrop: pinnedOnCompositionDrop, sessionEpoch, + readOnlyPress, }); const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowGeometry); const { resizingElementIds, getPreviewElement, draggedElement, multiDragPreview, snapGuide } = @@ -337,7 +355,7 @@ export function useTimelineProviderState({ scrollRef, contentOrigin, pixelsPerSecond: pps, - onSplitAll: onRazorSplitAll, + onSplitAll: oneShotEdits.onRazorSplitAll, }); const overlaysProps = useTimelineOverlaysState({ elements: timelineElements, @@ -405,6 +423,14 @@ export function useTimelineProviderState({ handlePointerCancel, } = overlaysProps; const { rangeSelection, setRangeSelection } = overlays; + useAbandonEditsOnReadOnly(readOnlyPress !== null, () => { + setDraggedClip(null); + cancelBeatDrag(); + cancelTimelineKeyframeRetime(scrollRef.current); + setClipContextMenu(null); + setKfContextMenu(null); + overlays.onDismissGapContextMenu(); + }); const laneGapStrips = useTimelineGapHighlights({ gapHighlight, tracks, @@ -484,22 +510,24 @@ export function useTimelineProviderState({ onClickKeyframe, onShiftClickKeyframe, onMoveKeyframe, - onContextMenuKeyframe, - onContextMenuClip, - onContextMenuLane: (e: React.MouseEvent, track: number, time: number) => { - if (draggedClip?.started || resizingClip) return; - setClipContextMenu(null); - openGapMenu({ x: e.clientX, y: e.clientY, track, time }); - }, + ...refuseWhenReadOnly(readOnlyPress, { + onContextMenuKeyframe, + onContextMenuClip, + onContextMenuLane: (e: React.MouseEvent, track: number, time: number) => { + if (draggedClip?.started || resizingClip) return; + setClipContextMenu(null); + openGapMenu({ x: e.clientX, y: e.clientY, track, time }); + }, + }), onResizeElement, onMoveElement, beatDragging, draggedElement, snapGuide, multiDragPreview, - onToggleTrackHidden: editContext.onToggleTrackHidden, - onTogglePropertyGroupKeyframe: editContext.onTogglePropertyGroupKeyframe, - onRazorSplit: editContext.onRazorSplit, + onToggleTrackHidden: oneShotEdits.onToggleTrackHidden, + onTogglePropertyGroupKeyframe: oneShotEdits.onTogglePropertyGroupKeyframe, + onRazorSplit: oneShotEdits.onRazorSplit, onRazorSplitAll: editContext.onRazorSplitAll, }; const holdNewClipContent = timelineFocus.rowVirtualizationActive && viewport.isScrolling; @@ -515,7 +543,7 @@ export function useTimelineProviderState({ ); const timelineMeta = buildTimelineShellMeta({ isDragOver: assetDrop.isDragOver, - hasFileDrop: !!onFileDrop, + hasFileDrop: !!onFileDrop && !readOnlyPress, drop: { onDragOver: assetDrop.handleAssetDragOver, onDragLeave: assetDrop.handleAssetDragLeave,