mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
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.
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
|||||||
resolveTimelineAutoScrollLoopAction,
|
resolveTimelineAutoScrollLoopAction,
|
||||||
} from "./timelineEditing";
|
} from "./timelineEditing";
|
||||||
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
|
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
|
||||||
|
import { useTimelineReadOnlyPress } from "./timelineReadOnly";
|
||||||
|
|
||||||
export const BEAT_BAND_H = 14; // dark band height at top of track
|
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
|
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;
|
return actor;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelBeatDrag(): void {
|
export function cancelBeatDrag(): void {
|
||||||
claimBeatDrag();
|
claimBeatDrag();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,6 +378,7 @@ export const BeatStrip = memo(function BeatStrip({
|
|||||||
);
|
);
|
||||||
const sessionEpoch = usePlayerStore((state) => state.timelineSessionEpoch);
|
const sessionEpoch = usePlayerStore((state) => state.timelineSessionEpoch);
|
||||||
const projectId = usePlayerStore((state) => state.timelineProjectId);
|
const projectId = usePlayerStore((state) => state.timelineProjectId);
|
||||||
|
const readOnlyPress = useTimelineReadOnlyPress();
|
||||||
|
|
||||||
if (!beatTimes || beatsTooDense(beatTimes, pps)) return null;
|
if (!beatTimes || beatsTooDense(beatTimes, pps)) return null;
|
||||||
const activeBeatIndex = activeActor
|
const activeBeatIndex = activeActor
|
||||||
@@ -420,7 +422,7 @@ export const BeatStrip = memo(function BeatStrip({
|
|||||||
top: 0,
|
top: 0,
|
||||||
width: BEAT_HIT_W,
|
width: BEAT_HIT_W,
|
||||||
height: BEAT_BAND_H,
|
height: BEAT_BAND_H,
|
||||||
cursor: "ew-resize",
|
cursor: readOnlyPress ? "default" : "ew-resize",
|
||||||
pointerEvents: "auto",
|
pointerEvents: "auto",
|
||||||
touchAction: "none",
|
touchAction: "none",
|
||||||
}}
|
}}
|
||||||
@@ -429,13 +431,14 @@ export const BeatStrip = memo(function BeatStrip({
|
|||||||
// selection (which otherwise "selects" the whole panel mid-drag).
|
// selection (which otherwise "selects" the whole panel mid-drag).
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
if (readOnlyPress) return readOnlyPress();
|
||||||
// ⌥ starts no drag: the delete lands on release below, so a
|
// ⌥ starts no drag: the delete lands on release below, so a
|
||||||
// slipped ⌥-drag abandons instead of destroying the beat.
|
// slipped ⌥-drag abandons instead of destroying the beat.
|
||||||
if (e.altKey) return;
|
if (e.altKey) return;
|
||||||
beginBeatDrag(e, t, pps);
|
beginBeatDrag(e, t, pps);
|
||||||
}}
|
}}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (!e.altKey) return;
|
if (!e.altKey || readOnlyPress) return;
|
||||||
// ⌥-click deletes. Deliberately NOT double-click: a stuttered drag
|
// ⌥-click deletes. Deliberately NOT double-click: a stuttered drag
|
||||||
// attempt reads as a double-click and would destroy the beat.
|
// attempt reads as a double-click and would destroy the beat.
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
<Timeline {...next} onSeek={onSeek} />
|
||||||
|
) : (
|
||||||
|
<TimelineEditProvider value={edits}>
|
||||||
|
{/* The host's edits arrive both ways: through the edit context and as Timeline props. */}
|
||||||
|
<Timeline
|
||||||
|
{...next}
|
||||||
|
onSeek={onSeek}
|
||||||
|
onFileDrop={onFileDrop}
|
||||||
|
onMoveElement={edits.onMoveElement}
|
||||||
|
onMoveElements={edits.onMoveElements}
|
||||||
|
onResizeElement={edits.onResizeElement}
|
||||||
|
onBlockedEditAttempt={edits.onBlockedEditAttempt}
|
||||||
|
onDeleteElement={edits.onDeleteElement}
|
||||||
|
/>
|
||||||
|
</TimelineEditProvider>
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
render(props);
|
||||||
|
const viewport = host.querySelector<HTMLElement>("[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<HTMLElement>(`[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<HTMLElement>('[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<HTMLElement>('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<HTMLElement>('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<HTMLElement>("[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<HTMLElement>('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<HTMLElement>('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<HTMLElement>('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<HTMLElement>('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(
|
||||||
|
<TimelineReadOnlyContext.Provider value={onReadOnlyPress}>
|
||||||
|
{node}
|
||||||
|
</TimelineReadOnlyContext.Provider>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<BeatStrip beatTimes={[1, 3]} beatStrengths={[0.5, 0.8]} pps={100} />,
|
||||||
|
);
|
||||||
|
host.getBoundingClientRect = () => new DOMRect(0, 0, 1_000, 500);
|
||||||
|
const beat = host.querySelector<HTMLElement>('[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(
|
||||||
|
<TimelineFxButton
|
||||||
|
variant="chain"
|
||||||
|
fxChainRaw={undefined}
|
||||||
|
onChainChange={vi.fn()}
|
||||||
|
onOpenRack={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
act(() => host.querySelector<HTMLButtonElement>("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(
|
||||||
|
<TimelineDiamondLane
|
||||||
|
keyframesData={{
|
||||||
|
format: "percentage",
|
||||||
|
keyframes: [
|
||||||
|
{
|
||||||
|
percentage: 20,
|
||||||
|
tweenPercentage: 0,
|
||||||
|
propertyGroup: "position",
|
||||||
|
animationId: "a",
|
||||||
|
properties: { x: 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
percentage: 40,
|
||||||
|
tweenPercentage: 50,
|
||||||
|
propertyGroup: "position",
|
||||||
|
animationId: "a",
|
||||||
|
properties: { x: 1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
percentage: 60,
|
||||||
|
tweenPercentage: 100,
|
||||||
|
propertyGroup: "position",
|
||||||
|
animationId: "a",
|
||||||
|
properties: { x: 2 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
clipWidthPx={200}
|
||||||
|
clipHeightPx={48}
|
||||||
|
clipDuration={10}
|
||||||
|
accentColor="#4ba3d2"
|
||||||
|
isSelected
|
||||||
|
currentPercentage={0}
|
||||||
|
elementId="clip-1"
|
||||||
|
selectedKeyframes={new Set()}
|
||||||
|
onClickKeyframe={onClickKeyframe}
|
||||||
|
onMoveKeyframe={onMoveKeyframe}
|
||||||
|
groupAware
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const diamond = host.querySelector<HTMLButtonElement>('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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@ import React, { act } from "react";
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
import { Timeline } from "./Timeline";
|
import { Timeline } from "./Timeline";
|
||||||
import { installTimelineMountEnv } from "./timelineMountTestEnv";
|
import { installTimelineMountEnv, KEYFRAMED_CARD as KEYFRAMED } from "./timelineMountTestEnv";
|
||||||
import { usePlayerStore } from "../store/playerStore";
|
import { usePlayerStore } from "../store/playerStore";
|
||||||
|
|
||||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
@@ -20,30 +20,6 @@ afterEach(() => {
|
|||||||
document.body.innerHTML = "";
|
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. */
|
/** Presses the ruler exactly on the first drawn tick after 0 and returns its time and the seek. */
|
||||||
async function seekOnFirstTick(
|
async function seekOnFirstTick(
|
||||||
props: { showKeyframes?: boolean },
|
props: { showKeyframes?: boolean },
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { act } from "react";
|
|||||||
import { describe, expect, it, vi, type Mock } from "vitest";
|
import { describe, expect, it, vi, type Mock } from "vitest";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { TimelineAutomationLane } from "./TimelineAutomationLane";
|
import { TimelineAutomationLane } from "./TimelineAutomationLane";
|
||||||
|
import { TimelineReadOnlyContext } from "./timelineReadOnly";
|
||||||
import { PAD_X } from "./automationLaneGeometry";
|
import { PAD_X } from "./automationLaneGeometry";
|
||||||
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
||||||
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
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();
|
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(
|
||||||
|
<TimelineReadOnlyContext.Provider value={onReadOnlyPress}>
|
||||||
|
<TimelineAutomationLane {...props} />
|
||||||
|
</TimelineReadOnlyContext.Provider>,
|
||||||
|
);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type TimelineKeyframeRetimeHandle,
|
type TimelineKeyframeRetimeHandle,
|
||||||
} from "./useTimelineKeyframeHandlers";
|
} from "./useTimelineKeyframeHandlers";
|
||||||
import { timelineKeyframeFocusId } from "./timelineNavigationIdentity";
|
import { timelineKeyframeFocusId } from "./timelineNavigationIdentity";
|
||||||
|
import { reportPressOnTravel, useTimelineReadOnlyPress } from "./timelineReadOnly";
|
||||||
import {
|
import {
|
||||||
DIAMOND_RATIO,
|
DIAMOND_RATIO,
|
||||||
keyframeTimeLabel,
|
keyframeTimeLabel,
|
||||||
@@ -92,6 +93,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|||||||
// This lane only arms it and renders the preview it publishes.
|
// This lane only arms it and renders the preview it publishes.
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const retimeHandleRef = useRef<TimelineKeyframeRetimeHandle | null>(null);
|
const retimeHandleRef = useRef<TimelineKeyframeRetimeHandle | null>(null);
|
||||||
|
const readOnlyPress = useTimelineReadOnlyPress();
|
||||||
// Retime destinations already dispatched but not yet in the keyframe cache, so
|
// 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
|
// a rapid second drag composes from where the first move left the keyframe
|
||||||
// instead of the stale rendered value.
|
// instead of the stale rendered value.
|
||||||
@@ -227,7 +229,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|||||||
: null;
|
: null;
|
||||||
const baseColor = isSelected ? accentColor : "var(--timeline-diamond-muted)";
|
const baseColor = isSelected ? accentColor : "var(--timeline-diamond-muted)";
|
||||||
const baseOpacity = isSelected ? 0.4 : 0.25;
|
const baseOpacity = isSelected ? 0.4 : 0.25;
|
||||||
const canDrag = isSelected && !!onMoveKeyframe;
|
const canDrag = isSelected && !!onMoveKeyframe && !readOnlyPress;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -285,7 +287,10 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|||||||
const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
|
const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!canDrag) return;
|
if (!canDrag) {
|
||||||
|
if (isSelected) reportPressOnTravel(e, readOnlyPress);
|
||||||
|
return;
|
||||||
|
}
|
||||||
retimeHandleRef.current = beginTimelineKeyframeRetime({
|
retimeHandleRef.current = beginTimelineKeyframeRetime({
|
||||||
event: e,
|
event: e,
|
||||||
elementId,
|
elementId,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { useTimelineReadOnlyPress } from "./timelineReadOnly";
|
||||||
import {
|
import {
|
||||||
enabledAudioFxNodes,
|
enabledAudioFxNodes,
|
||||||
parseAudioFxChain,
|
parseAudioFxChain,
|
||||||
@@ -197,8 +198,10 @@ export function TimelineFxButton(props: TimelineFxButtonProps) {
|
|||||||
const transport = useAuditionTransport();
|
const transport = useAuditionTransport();
|
||||||
/** Whether THIS audition lifted a mute, so only it puts one back. */
|
/** Whether THIS audition lifted a mute, so only it puts one back. */
|
||||||
const borrowedMute = useRef(false);
|
const borrowedMute = useRef(false);
|
||||||
|
const readOnlyPress = useTimelineReadOnlyPress();
|
||||||
|
|
||||||
const openAt = () => {
|
const openAt = () => {
|
||||||
|
if (readOnlyPress) return readOnlyPress();
|
||||||
setAnchorRect(buttonRef.current?.getBoundingClientRect() ?? null);
|
setAnchorRect(buttonRef.current?.getBoundingClientRect() ?? null);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
} from "./useTimelineTrackLayout";
|
} from "./useTimelineTrackLayout";
|
||||||
import { trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay";
|
import { trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay";
|
||||||
import { clipTimingStart } from "../../hooks/gsapShared";
|
import { clipTimingStart } from "../../hooks/gsapShared";
|
||||||
import { getTimelineEditCapabilities } from "./timelineEditing";
|
import { useTimelineClipCapabilities } from "./timelineReadOnly";
|
||||||
import { CLIP_Y, TRACK_H } from "./timelineLayout";
|
import { CLIP_Y, TRACK_H } from "./timelineLayout";
|
||||||
import { usePlayerStore } from "../store/playerStore";
|
import { usePlayerStore } from "../store/playerStore";
|
||||||
import { isMultiDragPassenger, multiDragPassengerOffsetPx } from "./timelineMultiDragPreview";
|
import { isMultiDragPassenger, multiDragPassengerOffsetPx } from "./timelineMultiDragPreview";
|
||||||
@@ -107,6 +107,7 @@ export function TimelineLanes({
|
|||||||
const { collapsedGroupIds, expandedLaneOwnerIds, toggleGroupExpanded, toggleLaneOwnerExpanded } =
|
const { collapsedGroupIds, expandedLaneOwnerIds, toggleGroupExpanded, toggleLaneOwnerExpanded } =
|
||||||
useTimelineGroupDisclosure();
|
useTimelineGroupDisclosure();
|
||||||
const automationLanes = useAutomationLanes();
|
const automationLanes = useAutomationLanes();
|
||||||
|
const getClipCapabilities = useTimelineClipCapabilities();
|
||||||
const transitionSeamsByTrack = useMemo(
|
const transitionSeamsByTrack = useMemo(
|
||||||
() =>
|
() =>
|
||||||
deriveTimelineTransitionSeamsByTrack(tracks.flatMap(([, els]) => els.map(getPreviewElement))),
|
deriveTimelineTransitionSeamsByTrack(tracks.flatMap(([, els]) => els.map(getPreviewElement))),
|
||||||
@@ -368,7 +369,7 @@ export function TimelineLanes({
|
|||||||
// diamonds on their own bar instead.
|
// diamonds on their own bar instead.
|
||||||
const isTrackKeyframeClip = elementKey === keyframeClipKey;
|
const isTrackKeyframeClip = elementKey === keyframeClipKey;
|
||||||
const showsLanes = isTrackKeyframeClip && rowExpanded;
|
const showsLanes = isTrackKeyframeClip && rowExpanded;
|
||||||
const capabilities = getTimelineEditCapabilities(el);
|
const capabilities = getClipCapabilities(el);
|
||||||
const isSelected =
|
const isSelected =
|
||||||
selectedElementId === elementKey || selectedElementIds.has(elementKey);
|
selectedElementId === elementKey || selectedElementIds.has(elementKey);
|
||||||
const isComposition = !!el.compositionSrc;
|
const isComposition = !!el.compositionSrc;
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
type ComponentProps,
|
type ComponentProps,
|
||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
type MouseEvent,
|
type MouseEvent,
|
||||||
@@ -26,6 +29,7 @@ import type { TimelineTheme } from "./timelineTheme";
|
|||||||
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
||||||
import type { KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
|
import type { KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
|
||||||
import { useTimelineProviderState } from "./useTimelineProviderState";
|
import { useTimelineProviderState } from "./useTimelineProviderState";
|
||||||
|
import { TimelineReadOnlyContext } from "./timelineReadOnly";
|
||||||
import {
|
import {
|
||||||
TimelineEditProvider,
|
TimelineEditProvider,
|
||||||
useTimelineEditContextValue,
|
useTimelineEditContextValue,
|
||||||
@@ -180,16 +184,28 @@ export interface TimelineContextValue {
|
|||||||
|
|
||||||
const TimelineContext = createContext<TimelineContextValue | null>(null);
|
const TimelineContext = createContext<TimelineContextValue | null>(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();
|
const editContext = useTimelineEditContextValue();
|
||||||
if (!editContext) {
|
const onReadOnlyPressRef = useRef(onReadOnlyPress);
|
||||||
return (
|
useLayoutEffect(() => {
|
||||||
<TimelineEditProvider value={props}>
|
onReadOnlyPressRef.current = onReadOnlyPress;
|
||||||
|
});
|
||||||
|
const refuse = useCallback(() => onReadOnlyPressRef.current?.(), []);
|
||||||
|
const readOnlyPress = readOnly ? refuse : null;
|
||||||
|
return (
|
||||||
|
<TimelineReadOnlyContext.Provider value={readOnlyPress}>
|
||||||
|
<TimelineEditProvider value={readOnly ? NO_EDITS : (editContext ?? props)}>
|
||||||
<TimelineProviderState {...props}>{children}</TimelineProviderState>
|
<TimelineProviderState {...props}>{children}</TimelineProviderState>
|
||||||
</TimelineEditProvider>
|
</TimelineEditProvider>
|
||||||
);
|
</TimelineReadOnlyContext.Provider>
|
||||||
}
|
);
|
||||||
return <TimelineProviderState {...props}>{children}</TimelineProviderState>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function TimelineProviderState({ children, ...props }: TimelineProps & { children: ReactNode }) {
|
function TimelineProviderState({ children, ...props }: TimelineProps & { children: ReactNode }) {
|
||||||
|
|||||||
@@ -37,4 +37,6 @@ export interface TimelineProps
|
|||||||
showAudioEffects?: boolean;
|
showAudioEffects?: boolean;
|
||||||
showKeyframes?: boolean;
|
showKeyframes?: boolean;
|
||||||
trackPadding?: TimelineTrackPadding;
|
trackPadding?: TimelineTrackPadding;
|
||||||
|
readOnly?: boolean;
|
||||||
|
onReadOnlyPress?: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,10 +58,12 @@ type PointerDownAction =
|
|||||||
*/
|
*/
|
||||||
function isIntentBlocked(
|
function isIntentBlocked(
|
||||||
intent: BlockedTimelineEditIntent | null,
|
intent: BlockedTimelineEditIntent | null,
|
||||||
|
capabilities: TimelineEditCapabilities,
|
||||||
onResizeElement: ClipGestureDeps["onResizeElement"],
|
onResizeElement: ClipGestureDeps["onResizeElement"],
|
||||||
onMoveElement: ClipGestureDeps["onMoveElement"],
|
onMoveElement: ClipGestureDeps["onMoveElement"],
|
||||||
): intent is BlockedTimelineEditIntent {
|
): intent is BlockedTimelineEditIntent {
|
||||||
if (!intent) return false;
|
if (!intent) return false;
|
||||||
|
if (capabilities.readOnly) return true;
|
||||||
return intent === "move" ? Boolean(onMoveElement) : Boolean(onResizeElement);
|
return intent === "move" ? Boolean(onMoveElement) : Boolean(onResizeElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +90,7 @@ function resolvePointerDownAction(
|
|||||||
handleWidth: CLIP_HANDLE_W,
|
handleWidth: CLIP_HANDLE_W,
|
||||||
capabilities,
|
capabilities,
|
||||||
});
|
});
|
||||||
if (isIntentBlocked(intent, onResizeElement, onMoveElement)) {
|
if (isIntentBlocked(intent, capabilities, onResizeElement, onMoveElement)) {
|
||||||
return { kind: "block", intent, rect };
|
return { kind: "block", intent, rect };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
|
|||||||
rowGeometryRef: RefObject<TimelineRowGeometry>;
|
rowGeometryRef: RefObject<TimelineRowGeometry>;
|
||||||
contentOrigin: number;
|
contentOrigin: number;
|
||||||
sessionEpoch: number;
|
sessionEpoch: number;
|
||||||
|
readOnlyPress?: (() => void) | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -132,6 +133,7 @@ export function useTimelineAssetDrop({
|
|||||||
onBlockDrop,
|
onBlockDrop,
|
||||||
onCompositionDrop,
|
onCompositionDrop,
|
||||||
sessionEpoch,
|
sessionEpoch,
|
||||||
|
readOnlyPress,
|
||||||
}: UseTimelineAssetDropOptions) {
|
}: UseTimelineAssetDropOptions) {
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const [dropPreview, setDropPreview] = useState<TimelineDropPlacement | null>(null);
|
const [dropPreview, setDropPreview] = useState<TimelineDropPlacement | null>(null);
|
||||||
@@ -140,6 +142,7 @@ export function useTimelineAssetDrop({
|
|||||||
);
|
);
|
||||||
const autoScrollRafRef = useRef(0);
|
const autoScrollRafRef = useRef(0);
|
||||||
const activeDropEpochRef = useRef<number | null>(null);
|
const activeDropEpochRef = useRef<number | null>(null);
|
||||||
|
const refusedDragRef = useRef(false);
|
||||||
|
|
||||||
const stopAutoScroll = useCallback(() => {
|
const stopAutoScroll = useCallback(() => {
|
||||||
dragPointerRef.current = null;
|
dragPointerRef.current = null;
|
||||||
@@ -210,6 +213,12 @@ export function useTimelineAssetDrop({
|
|||||||
const hasBlock = types.includes(TIMELINE_BLOCK_MIME);
|
const hasBlock = types.includes(TIMELINE_BLOCK_MIME);
|
||||||
const hasComposition = types.includes(TIMELINE_COMPOSITION_MIME);
|
const hasComposition = types.includes(TIMELINE_COMPOSITION_MIME);
|
||||||
if (!hasFiles && !hasAsset && !hasBlock && !hasComposition) return;
|
if (!hasFiles && !hasAsset && !hasBlock && !hasComposition) return;
|
||||||
|
if (readOnlyPress) {
|
||||||
|
e.dataTransfer.dropEffect = "none";
|
||||||
|
if (!refusedDragRef.current) readOnlyPress();
|
||||||
|
refusedDragRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = "copy";
|
e.dataTransfer.dropEffect = "copy";
|
||||||
activeDropEpochRef.current = sessionEpoch;
|
activeDropEpochRef.current = sessionEpoch;
|
||||||
@@ -222,11 +231,12 @@ export function useTimelineAssetDrop({
|
|||||||
);
|
);
|
||||||
syncAutoScroll(e.clientX, e.clientY);
|
syncAutoScroll(e.clientX, e.clientY);
|
||||||
},
|
},
|
||||||
[resolveDropPlacement, sessionEpoch, syncAutoScroll],
|
[readOnlyPress, resolveDropPlacement, sessionEpoch, syncAutoScroll],
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearDropPreview = useCallback(() => {
|
const clearDropPreview = useCallback(() => {
|
||||||
activeDropEpochRef.current = null;
|
activeDropEpochRef.current = null;
|
||||||
|
refusedDragRef.current = false;
|
||||||
stopAutoScroll();
|
stopAutoScroll();
|
||||||
setIsDragOver(false);
|
setIsDragOver(false);
|
||||||
setDropPreview(null);
|
setDropPreview(null);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export interface TimelineEditCapabilities {
|
|||||||
canMove: boolean;
|
canMove: boolean;
|
||||||
canTrimStart: boolean;
|
canTrimStart: boolean;
|
||||||
canTrimEnd: boolean;
|
canTrimEnd: boolean;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDeterministicTimelineWindow(input: {
|
function isDeterministicTimelineWindow(input: {
|
||||||
|
|||||||
@@ -27,3 +27,27 @@ export function installTimelineMountEnv(): void {
|
|||||||
document.body.innerHTML = "";
|
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 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|||||||
@@ -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<T extends object>(
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
type ShiftAxis,
|
type ShiftAxis,
|
||||||
} from "./automationLaneDragMath";
|
} from "./automationLaneDragMath";
|
||||||
import { useAutomationSegmentDrag } from "./useAutomationSegmentDrag";
|
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. */
|
/** How far a press may travel and still count as a click rather than a drag. */
|
||||||
const CLICK_SLOP_PX = 3;
|
const CLICK_SLOP_PX = 3;
|
||||||
@@ -109,6 +110,7 @@ export function useAutomationLaneGestures({
|
|||||||
duration,
|
duration,
|
||||||
rangeSelection,
|
rangeSelection,
|
||||||
}: UseAutomationLaneGesturesInput): UseAutomationLaneGesturesResult {
|
}: UseAutomationLaneGesturesInput): UseAutomationLaneGesturesResult {
|
||||||
|
const timelineReadOnlyPress = useTimelineReadOnlyPress();
|
||||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||||
const [curveIndex, setCurveIndex] = useState<number | null>(null);
|
const [curveIndex, setCurveIndex] = useState<number | null>(null);
|
||||||
const [hint, setHint] = useState<string | null>(null);
|
const [hint, setHint] = useState<string | null>(null);
|
||||||
@@ -267,6 +269,13 @@ export function useAutomationLaneGestures({
|
|||||||
[hitIndex, segmentDrag, segmentIndex],
|
[hitIndex, segmentDrag, segmentIndex],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const reportLaneEditPress = useCallback(
|
||||||
|
(e: ReactPointerEvent<SVGSVGElement>) => {
|
||||||
|
if (gestureAt(e)) reportPressOnTravel(e, timelineReadOnlyPress);
|
||||||
|
},
|
||||||
|
[gestureAt, timelineReadOnlyPress],
|
||||||
|
);
|
||||||
|
|
||||||
const onPointerDown = useCallback(
|
const onPointerDown = useCallback(
|
||||||
(e: ReactPointerEvent<SVGSVGElement>): void => {
|
(e: ReactPointerEvent<SVGSVGElement>): void => {
|
||||||
if (e.button !== 0) return;
|
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.
|
// so the field sat open until Enter however far away the next click landed.
|
||||||
if (editing) commitEdit();
|
if (editing) commitEdit();
|
||||||
if (readOnly) {
|
if (readOnly) {
|
||||||
|
reportLaneEditPress(e);
|
||||||
// The lane sits below the clip bar, so the timeline's selection handler
|
// 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
|
// 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
|
// goes on to arm a range drag rather than being spent on the selection: a
|
||||||
@@ -323,6 +333,7 @@ export function useAutomationLaneGestures({
|
|||||||
gestureAt,
|
gestureAt,
|
||||||
lane,
|
lane,
|
||||||
readOnly,
|
readOnly,
|
||||||
|
reportLaneEditPress,
|
||||||
onSelect,
|
onSelect,
|
||||||
rangeDrag,
|
rangeDrag,
|
||||||
editing,
|
editing,
|
||||||
@@ -526,7 +537,7 @@ export function useAutomationLaneGestures({
|
|||||||
|
|
||||||
const onDoubleClick = useCallback(
|
const onDoubleClick = useCallback(
|
||||||
(e: ReactPointerEvent<SVGSVGElement>): void => {
|
(e: ReactPointerEvent<SVGSVGElement>): void => {
|
||||||
if (readOnly) return;
|
if (readOnly) return timelineReadOnlyPress?.();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const onPoint = hitIndex(e.clientX, e.clientY);
|
const onPoint = hitIndex(e.clientX, e.clientY);
|
||||||
@@ -550,7 +561,7 @@ export function useAutomationLaneGestures({
|
|||||||
const { t, v } = pointAt(e.clientX, e.clientY);
|
const { t, v } = pointAt(e.clientX, e.clientY);
|
||||||
commitPoints(mergeInsertPoint(lane.points, t, v), true);
|
commitPoints(mergeInsertPoint(lane.points, t, v), true);
|
||||||
},
|
},
|
||||||
[lane, pointAt, commitPoints, readOnly, hitIndex, segmentIndex],
|
[lane, pointAt, commitPoints, readOnly, timelineReadOnlyPress, hitIndex, segmentIndex],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ interface TimelineKeyframeRetimeCoordinator {
|
|||||||
* is a viewport-wide question, not a per-keyframe one.
|
* is a viewport-wide question, not a per-keyframe one.
|
||||||
*/
|
*/
|
||||||
latest: PendingTimelineKeyframeRetime | null;
|
latest: PendingTimelineKeyframeRetime | null;
|
||||||
|
cancelActive: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TimelineRetimePointerEvent = Pick<
|
type TimelineRetimePointerEvent = Pick<
|
||||||
@@ -115,6 +116,7 @@ function getRetimeCoordinator(owner: EventTarget): TimelineKeyframeRetimeCoordin
|
|||||||
preview: null,
|
preview: null,
|
||||||
previewListeners: new Set(),
|
previewListeners: new Set(),
|
||||||
latest: null,
|
latest: null,
|
||||||
|
cancelActive: () => {},
|
||||||
};
|
};
|
||||||
keyframeRetimeCoordinators.set(owner, coordinator);
|
keyframeRetimeCoordinators.set(owner, coordinator);
|
||||||
return coordinator;
|
return coordinator;
|
||||||
@@ -186,6 +188,10 @@ export interface TimelineKeyframeRetimeHandle {
|
|||||||
* Starts a keyframe retime on the stable timeline viewport. The row/button is
|
* Starts a keyframe retime on the stable timeline viewport. The row/button is
|
||||||
* only an entry point: window listeners own the gesture through virtualization.
|
* 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(
|
export function beginTimelineKeyframeRetime(
|
||||||
input: TimelineKeyframeRetimeInput,
|
input: TimelineKeyframeRetimeInput,
|
||||||
): TimelineKeyframeRetimeHandle {
|
): TimelineKeyframeRetimeHandle {
|
||||||
@@ -256,6 +262,7 @@ export function beginTimelineKeyframeRetime(
|
|||||||
teardownListeners: null,
|
teardownListeners: null,
|
||||||
};
|
};
|
||||||
coordinator.actor = actor;
|
coordinator.actor = actor;
|
||||||
|
coordinator.cancelActive = () => cancel(actor);
|
||||||
|
|
||||||
const matchesPointer = (event: TimelineRetimePointerEvent) =>
|
const matchesPointer = (event: TimelineRetimePointerEvent) =>
|
||||||
actor.pointerId === null || event.pointerId === actor.pointerId;
|
actor.pointerId === null || event.pointerId === actor.pointerId;
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ import {
|
|||||||
useTimelineDisplayLayout,
|
useTimelineDisplayLayout,
|
||||||
useTimelineTrackLayout,
|
useTimelineTrackLayout,
|
||||||
} from "./useTimelineTrackLayout";
|
} from "./useTimelineTrackLayout";
|
||||||
import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers";
|
import {
|
||||||
|
cancelTimelineKeyframeRetime,
|
||||||
|
useTimelineKeyframeHandlers,
|
||||||
|
} from "./useTimelineKeyframeHandlers";
|
||||||
import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
|
import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
|
||||||
import { useTimelineRazorInteraction } from "./TimelineRazorInteraction";
|
import { useTimelineRazorInteraction } from "./TimelineRazorInteraction";
|
||||||
import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemetry";
|
import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemetry";
|
||||||
@@ -40,6 +43,12 @@ import { useTimelineActiveClips } from "./useTimelineActiveClips";
|
|||||||
import { useTimelineLaneMoveRefresh } from "./useTimelineLaneMoveRefresh";
|
import { useTimelineLaneMoveRefresh } from "./useTimelineLaneMoveRefresh";
|
||||||
import { useTimelineLogicalFocus } from "./useTimelineLogicalFocus";
|
import { useTimelineLogicalFocus } from "./useTimelineLogicalFocus";
|
||||||
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
||||||
|
import {
|
||||||
|
refuseWhenReadOnly,
|
||||||
|
useAbandonEditsOnReadOnly,
|
||||||
|
useTimelineReadOnlyPress,
|
||||||
|
} from "./timelineReadOnly";
|
||||||
|
import { cancelBeatDrag } from "./BeatStrip";
|
||||||
export function useTimelineProviderState({
|
export function useTimelineProviderState({
|
||||||
onSeek,
|
onSeek,
|
||||||
onDrillDown,
|
onDrillDown,
|
||||||
@@ -92,6 +101,14 @@ export function useTimelineProviderState({
|
|||||||
});
|
});
|
||||||
const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
|
const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
|
||||||
const editContext = useTimelineEditContextOptional();
|
const editContext = useTimelineEditContextOptional();
|
||||||
|
const readOnlyPress = useTimelineReadOnlyPress();
|
||||||
|
const oneShotEdits = refuseWhenReadOnly(readOnlyPress, {
|
||||||
|
onBlockedEditAttempt,
|
||||||
|
onRazorSplitAll,
|
||||||
|
onToggleTrackHidden: editContext.onToggleTrackHidden,
|
||||||
|
onTogglePropertyGroupKeyframe: editContext.onTogglePropertyGroupKeyframe,
|
||||||
|
onRazorSplit: editContext.onRazorSplit,
|
||||||
|
});
|
||||||
const refreshAfterLaneMove = useTimelineLaneMoveRefresh();
|
const refreshAfterLaneMove = useTimelineLaneMoveRefresh();
|
||||||
useMusicBeatAnalysis();
|
useMusicBeatAnalysis();
|
||||||
const timelineElements = usePlayerStore((s) => s.elements);
|
const timelineElements = usePlayerStore((s) => s.elements);
|
||||||
@@ -198,7 +215,7 @@ export function useTimelineProviderState({
|
|||||||
onMoveElements: pinnedOnMoveElements,
|
onMoveElements: pinnedOnMoveElements,
|
||||||
onResizeElement: pinnedOnResizeElement,
|
onResizeElement: pinnedOnResizeElement,
|
||||||
onResizeElements: pinnedOnResizeElements,
|
onResizeElements: pinnedOnResizeElements,
|
||||||
onBlockedEditAttempt,
|
onBlockedEditAttempt: oneShotEdits.onBlockedEditAttempt,
|
||||||
onSeek,
|
onSeek,
|
||||||
setShowPopover,
|
setShowPopover,
|
||||||
setRangeSelectionRef,
|
setRangeSelectionRef,
|
||||||
@@ -218,6 +235,7 @@ export function useTimelineProviderState({
|
|||||||
onBlockDrop: pinnedOnBlockDrop,
|
onBlockDrop: pinnedOnBlockDrop,
|
||||||
onCompositionDrop: pinnedOnCompositionDrop,
|
onCompositionDrop: pinnedOnCompositionDrop,
|
||||||
sessionEpoch,
|
sessionEpoch,
|
||||||
|
readOnlyPress,
|
||||||
});
|
});
|
||||||
const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowGeometry);
|
const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowGeometry);
|
||||||
const { resizingElementIds, getPreviewElement, draggedElement, multiDragPreview, snapGuide } =
|
const { resizingElementIds, getPreviewElement, draggedElement, multiDragPreview, snapGuide } =
|
||||||
@@ -337,7 +355,7 @@ export function useTimelineProviderState({
|
|||||||
scrollRef,
|
scrollRef,
|
||||||
contentOrigin,
|
contentOrigin,
|
||||||
pixelsPerSecond: pps,
|
pixelsPerSecond: pps,
|
||||||
onSplitAll: onRazorSplitAll,
|
onSplitAll: oneShotEdits.onRazorSplitAll,
|
||||||
});
|
});
|
||||||
const overlaysProps = useTimelineOverlaysState({
|
const overlaysProps = useTimelineOverlaysState({
|
||||||
elements: timelineElements,
|
elements: timelineElements,
|
||||||
@@ -405,6 +423,14 @@ export function useTimelineProviderState({
|
|||||||
handlePointerCancel,
|
handlePointerCancel,
|
||||||
} = overlaysProps;
|
} = overlaysProps;
|
||||||
const { rangeSelection, setRangeSelection } = overlays;
|
const { rangeSelection, setRangeSelection } = overlays;
|
||||||
|
useAbandonEditsOnReadOnly(readOnlyPress !== null, () => {
|
||||||
|
setDraggedClip(null);
|
||||||
|
cancelBeatDrag();
|
||||||
|
cancelTimelineKeyframeRetime(scrollRef.current);
|
||||||
|
setClipContextMenu(null);
|
||||||
|
setKfContextMenu(null);
|
||||||
|
overlays.onDismissGapContextMenu();
|
||||||
|
});
|
||||||
const laneGapStrips = useTimelineGapHighlights({
|
const laneGapStrips = useTimelineGapHighlights({
|
||||||
gapHighlight,
|
gapHighlight,
|
||||||
tracks,
|
tracks,
|
||||||
@@ -484,22 +510,24 @@ export function useTimelineProviderState({
|
|||||||
onClickKeyframe,
|
onClickKeyframe,
|
||||||
onShiftClickKeyframe,
|
onShiftClickKeyframe,
|
||||||
onMoveKeyframe,
|
onMoveKeyframe,
|
||||||
onContextMenuKeyframe,
|
...refuseWhenReadOnly(readOnlyPress, {
|
||||||
onContextMenuClip,
|
onContextMenuKeyframe,
|
||||||
onContextMenuLane: (e: React.MouseEvent, track: number, time: number) => {
|
onContextMenuClip,
|
||||||
if (draggedClip?.started || resizingClip) return;
|
onContextMenuLane: (e: React.MouseEvent, track: number, time: number) => {
|
||||||
setClipContextMenu(null);
|
if (draggedClip?.started || resizingClip) return;
|
||||||
openGapMenu({ x: e.clientX, y: e.clientY, track, time });
|
setClipContextMenu(null);
|
||||||
},
|
openGapMenu({ x: e.clientX, y: e.clientY, track, time });
|
||||||
|
},
|
||||||
|
}),
|
||||||
onResizeElement,
|
onResizeElement,
|
||||||
onMoveElement,
|
onMoveElement,
|
||||||
beatDragging,
|
beatDragging,
|
||||||
draggedElement,
|
draggedElement,
|
||||||
snapGuide,
|
snapGuide,
|
||||||
multiDragPreview,
|
multiDragPreview,
|
||||||
onToggleTrackHidden: editContext.onToggleTrackHidden,
|
onToggleTrackHidden: oneShotEdits.onToggleTrackHidden,
|
||||||
onTogglePropertyGroupKeyframe: editContext.onTogglePropertyGroupKeyframe,
|
onTogglePropertyGroupKeyframe: oneShotEdits.onTogglePropertyGroupKeyframe,
|
||||||
onRazorSplit: editContext.onRazorSplit,
|
onRazorSplit: oneShotEdits.onRazorSplit,
|
||||||
onRazorSplitAll: editContext.onRazorSplitAll,
|
onRazorSplitAll: editContext.onRazorSplitAll,
|
||||||
};
|
};
|
||||||
const holdNewClipContent = timelineFocus.rowVirtualizationActive && viewport.isScrolling;
|
const holdNewClipContent = timelineFocus.rowVirtualizationActive && viewport.isScrolling;
|
||||||
@@ -515,7 +543,7 @@ export function useTimelineProviderState({
|
|||||||
);
|
);
|
||||||
const timelineMeta = buildTimelineShellMeta({
|
const timelineMeta = buildTimelineShellMeta({
|
||||||
isDragOver: assetDrop.isDragOver,
|
isDragOver: assetDrop.isDragOver,
|
||||||
hasFileDrop: !!onFileDrop,
|
hasFileDrop: !!onFileDrop && !readOnlyPress,
|
||||||
drop: {
|
drop: {
|
||||||
onDragOver: assetDrop.handleAssetDragOver,
|
onDragOver: assetDrop.handleAssetDragOver,
|
||||||
onDragLeave: assetDrop.handleAssetDragLeave,
|
onDragLeave: assetDrop.handleAssetDragLeave,
|
||||||
|
|||||||
Reference in New Issue
Block a user