mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
Merge pull request #2111 from heygen-com/feat/timeline-multiselect
feat(studio): timeline multi-select (marquee) + relative group time editing
This commit is contained in:
@@ -519,6 +519,8 @@ export function StudioApp() {
|
||||
handleTimelineFileDrop={timelineEditing.handleTimelineFileDrop}
|
||||
handleTimelineElementMove={timelineEditing.handleTimelineElementMove}
|
||||
handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
|
||||
handleTimelineGroupMove={timelineEditing.handleTimelineGroupMove}
|
||||
handleTimelineGroupResize={timelineEditing.handleTimelineGroupResize}
|
||||
handleToggleTrackHidden={timelineEditing.handleToggleTrackHidden}
|
||||
handleToggleElementHidden={timelineEditing.handleToggleElementHidden}
|
||||
handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
|
||||
|
||||
@@ -25,6 +25,15 @@ import { TimelineEditProvider } from "../contexts/TimelineEditContext";
|
||||
import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
|
||||
import { readStudioUiPreferences } from "../utils/studioUiPreferences";
|
||||
import type { GestureRecordingState } from "./editor/GestureRecordControl";
|
||||
import { useTimelineSelectionPreviewSync } from "../hooks/useTimelineSelectionPreviewSync";
|
||||
import type {
|
||||
TimelineGroupMoveChange,
|
||||
TimelineGroupResizeChange,
|
||||
} from "../hooks/useTimelineGroupEditing";
|
||||
import {
|
||||
formatTimelineAttributeNumber,
|
||||
patchIframeDomTiming,
|
||||
} from "../hooks/timelineEditingHelpers";
|
||||
|
||||
export interface StudioPreviewAreaProps {
|
||||
timelineToolbar: ReactNode;
|
||||
@@ -58,6 +67,8 @@ export interface StudioPreviewAreaProps {
|
||||
element: TimelineElement,
|
||||
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
||||
) => Promise<void> | void;
|
||||
handleTimelineGroupMove: (changes: TimelineGroupMoveChange[]) => Promise<void> | void;
|
||||
handleTimelineGroupResize: (changes: TimelineGroupResizeChange[]) => Promise<void> | void;
|
||||
handleToggleTrackHidden: (track: number, hidden: boolean) => Promise<void> | void;
|
||||
handleToggleElementHidden: (elementKey: string, hidden: boolean) => Promise<void> | void;
|
||||
handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
||||
@@ -85,6 +96,8 @@ export function StudioPreviewArea({
|
||||
handleTimelineFileDrop,
|
||||
handleTimelineElementMove,
|
||||
handleTimelineElementResize,
|
||||
handleTimelineGroupMove,
|
||||
handleTimelineGroupResize,
|
||||
handleToggleTrackHidden,
|
||||
handleToggleElementHidden,
|
||||
handleBlockedTimelineEdit,
|
||||
@@ -148,6 +161,9 @@ export function StudioPreviewArea({
|
||||
buildDomSelectionForTimelineElement,
|
||||
applyMarqueeSelection,
|
||||
} = useDomEditActionsContext();
|
||||
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
||||
const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
|
||||
const timelineElements = usePlayerStore((s) => s.elements);
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const [snapPrefs, setSnapPrefs] = useState(() => {
|
||||
@@ -160,6 +176,18 @@ export function StudioPreviewArea({
|
||||
};
|
||||
});
|
||||
|
||||
useTimelineSelectionPreviewSync({
|
||||
selectedElementId,
|
||||
selectedElementIds,
|
||||
timelineElements,
|
||||
domEditSelection,
|
||||
domEditGroupSelections,
|
||||
activeCompPath,
|
||||
buildDomSelectionForTimelineElement,
|
||||
applyDomSelection,
|
||||
applyMarqueeSelection,
|
||||
});
|
||||
|
||||
// Resolve a timeline-diamond callback's clip-% to the keyframe's anim id + its
|
||||
// tween-relative percentage (shared by the delete/move keyframe callbacks): the
|
||||
// diamond reports a clip-% but the script ops key on the tween-%. Prefers the
|
||||
@@ -178,11 +206,47 @@ export function StudioPreviewArea({
|
||||
[domEditSelection?.id, selectedGsapAnimations],
|
||||
);
|
||||
|
||||
const handleTimelineGroupMovePreview = useCallback(
|
||||
(changes: TimelineGroupMoveChange[]) => {
|
||||
for (const change of changes) {
|
||||
patchIframeDomTiming(previewIframeRef.current, change.element, [
|
||||
["data-start", formatTimelineAttributeNumber(change.start)],
|
||||
]);
|
||||
}
|
||||
},
|
||||
[previewIframeRef],
|
||||
);
|
||||
|
||||
const handleTimelineGroupResizePreview = useCallback(
|
||||
(changes: TimelineGroupResizeChange[]) => {
|
||||
for (const change of changes) {
|
||||
const attrs: Array<[string, string]> = [
|
||||
["data-start", formatTimelineAttributeNumber(change.start)],
|
||||
["data-duration", formatTimelineAttributeNumber(change.duration)],
|
||||
];
|
||||
if (change.playbackStart != null) {
|
||||
attrs.push([
|
||||
change.element.playbackStartAttr === "playback-start"
|
||||
? "data-playback-start"
|
||||
: "data-media-start",
|
||||
formatTimelineAttributeNumber(change.playbackStart),
|
||||
]);
|
||||
}
|
||||
patchIframeDomTiming(previewIframeRef.current, change.element, attrs);
|
||||
}
|
||||
},
|
||||
[previewIframeRef],
|
||||
);
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const timelineEditCallbacks = useMemo(
|
||||
() => ({
|
||||
onMoveElement: handleTimelineElementMove,
|
||||
onResizeElement: handleTimelineElementResize,
|
||||
onMoveElements: handleTimelineGroupMove,
|
||||
onResizeElements: handleTimelineGroupResize,
|
||||
onPreviewMoveElements: handleTimelineGroupMovePreview,
|
||||
onPreviewResizeElements: handleTimelineGroupResizePreview,
|
||||
onToggleTrackHidden: handleToggleTrackHidden,
|
||||
onToggleElementHidden: handleToggleElementHidden,
|
||||
onBlockedEditAttempt: handleBlockedTimelineEdit,
|
||||
@@ -191,7 +255,7 @@ export function StudioPreviewArea({
|
||||
onRazorSplitAll: handleRazorSplitAll,
|
||||
onDeleteAllKeyframes: () => {
|
||||
// Hold the element where it is (collapse keyframes to a static set) rather
|
||||
// than deleting the whole animation — deleting strands a stale GSAP base
|
||||
// than deleting the whole animation, deleting strands a stale GSAP base
|
||||
// that the next drag adds to, flinging the element off-screen.
|
||||
const anim = selectedGsapAnimations.find((a) => a.keyframes);
|
||||
if (!anim) return;
|
||||
@@ -211,7 +275,7 @@ export function StudioPreviewArea({
|
||||
// absolute time (via the clip's timing basis) and let resolveKeyframeRetime
|
||||
// decide: a drop inside the tween window is a plain move (re-key tween-%); a
|
||||
// drop past the boundary (last keyframe past the end, first before the start)
|
||||
// resizes the tween — position/duration grow so the dragged keyframe lands at
|
||||
// resizes the tween, position/duration grow so the dragged keyframe lands at
|
||||
// the drop while every other keyframe keeps its absolute time (value+ease too).
|
||||
// fallow-ignore-next-line complexity
|
||||
onMoveKeyframe: (_elId: string, fromClipPct: number, toClipPct: number) => {
|
||||
@@ -284,6 +348,10 @@ export function StudioPreviewArea({
|
||||
[
|
||||
handleTimelineElementMove,
|
||||
handleTimelineElementResize,
|
||||
handleTimelineGroupMove,
|
||||
handleTimelineGroupMovePreview,
|
||||
handleTimelineGroupResize,
|
||||
handleTimelineGroupResizePreview,
|
||||
handleToggleTrackHidden,
|
||||
handleToggleElementHidden,
|
||||
handleBlockedTimelineEdit,
|
||||
@@ -327,7 +395,7 @@ export function StudioPreviewArea({
|
||||
onCompositionLoadingChange={setCompositionLoading}
|
||||
onCompositionChange={(compPath) => {
|
||||
// Sync activeCompPath when user drills down via timeline double-click
|
||||
// or navigates back via breadcrumb — keeps sidebar + thumbnails in sync.
|
||||
// or navigates back via breadcrumb, keeps sidebar + thumbnails in sync.
|
||||
// Guard against no-op updates to prevent circular refresh cascades
|
||||
// between activeCompPath → compositionStack → onCompositionChange.
|
||||
if (compPath !== activeCompPath) {
|
||||
|
||||
@@ -10,7 +10,7 @@ export function useTimelineEditContext(): TimelineEditCallbacks {
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional access — returns an empty object when outside a provider.
|
||||
* Optional access, returns an empty object when outside a provider.
|
||||
* Useful in components that can render both inside and outside the NLE.
|
||||
*/
|
||||
export function useTimelineEditContextOptional(): TimelineEditCallbacks {
|
||||
@@ -26,12 +26,16 @@ export function TimelineEditProvider({
|
||||
}) {
|
||||
const memoized = useMemo(
|
||||
() => value,
|
||||
// Each callback is a stable reference from the parent — memoize the bag
|
||||
// Each callback is a stable reference from the parent, memoize the bag
|
||||
// so consumers don't re-render when unrelated parent state changes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
value.onMoveElement,
|
||||
value.onResizeElement,
|
||||
value.onMoveElements,
|
||||
value.onResizeElements,
|
||||
value.onPreviewMoveElements,
|
||||
value.onPreviewResizeElements,
|
||||
value.onToggleTrackHidden,
|
||||
value.onToggleElementHidden,
|
||||
value.onBlockedEditAttempt,
|
||||
|
||||
@@ -123,6 +123,8 @@ export interface RecordEditInput {
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
coalesceKey?: string;
|
||||
/** Per-entry coalesce window override (ms); lets a slow follow-up still merge. */
|
||||
coalesceMs?: number;
|
||||
files: Record<string, { before: string; after: string }>;
|
||||
}
|
||||
|
||||
@@ -312,6 +314,66 @@ export async function persistTimelineEdit(input: PersistTimelineEditInput): Prom
|
||||
input.domEditSaveTimestampRef.current = Date.now();
|
||||
}
|
||||
|
||||
export interface PersistTimelineBatchChange {
|
||||
element: TimelineElement;
|
||||
buildPatches: (original: string, target: PatchTarget) => string;
|
||||
}
|
||||
|
||||
export interface PersistTimelineBatchEditInput {
|
||||
projectId: string;
|
||||
activeCompPath: string | null;
|
||||
label: string;
|
||||
changes: PersistTimelineBatchChange[];
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
recordEdit: (input: RecordEditInput) => Promise<void>;
|
||||
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
||||
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
|
||||
coalesceKey?: string;
|
||||
}
|
||||
|
||||
export async function persistTimelineBatchEdit(
|
||||
input: PersistTimelineBatchEditInput,
|
||||
): Promise<void> {
|
||||
const originals = new Map<string, string>();
|
||||
const patchedByPath = new Map<string, string>();
|
||||
|
||||
for (const change of input.changes) {
|
||||
const targetPath = change.element.sourceFile || input.activeCompPath || "index.html";
|
||||
const original =
|
||||
originals.get(targetPath) ?? (await readFileContent(input.projectId, targetPath));
|
||||
originals.set(targetPath, original);
|
||||
|
||||
const patchTarget = buildPatchTarget(change.element);
|
||||
if (!patchTarget) {
|
||||
throw new Error(`Timeline element ${change.element.id} is missing a patchable target`);
|
||||
}
|
||||
|
||||
const current = patchedByPath.get(targetPath) ?? original;
|
||||
const patched = change.buildPatches(current, patchTarget);
|
||||
if (patched === current) {
|
||||
throw new Error(`Unable to patch timeline element ${change.element.id} in ${targetPath}`);
|
||||
}
|
||||
patchedByPath.set(targetPath, patched);
|
||||
}
|
||||
|
||||
const files = Object.fromEntries(patchedByPath);
|
||||
for (const targetPath of Object.keys(files)) {
|
||||
input.pendingTimelineEditPathRef.current.add(targetPath);
|
||||
}
|
||||
input.domEditSaveTimestampRef.current = Date.now();
|
||||
await saveProjectFilesWithHistory({
|
||||
projectId: input.projectId,
|
||||
label: input.label,
|
||||
kind: "timeline",
|
||||
coalesceKey: input.coalesceKey,
|
||||
files,
|
||||
readFile: async (path) => originals.get(path) ?? readFileContent(input.projectId, path),
|
||||
writeFile: input.writeProjectFile,
|
||||
recordEdit: input.recordEdit,
|
||||
});
|
||||
input.domEditSaveTimestampRef.current = Date.now();
|
||||
}
|
||||
|
||||
export async function readFileContent(projectId: string, targetPath: string): Promise<string> {
|
||||
if (targetPath.includes("\0") || targetPath.includes("..")) {
|
||||
throw new Error(`Unsafe path: ${targetPath}`);
|
||||
@@ -370,6 +432,53 @@ export async function finishTimelineTimingFallback(input: {
|
||||
input.reloadPreview();
|
||||
}
|
||||
|
||||
// Coalesce window for folding a GSAP mutation into the preceding timing edit; only has to
|
||||
// outlast one GSAP server round-trip, never a real second edit.
|
||||
const GSAP_HISTORY_COALESCE_MS = 10_000;
|
||||
|
||||
/**
|
||||
* A server GSAP rewrite mutates the same file the timing patch just wrote, but AFTER the
|
||||
* timing edit was recorded, leaving the recorded `after` stale so an undo hits a hash
|
||||
* conflict. This snapshots every touched file, runs the mutation, then records a follow-up
|
||||
* edit under the same coalesceKey with a window wide enough to survive the GSAP round-trip,
|
||||
* folding both writes into one undo step. Returns the mutation status for caller reloads.
|
||||
*/
|
||||
export async function foldGsapMutationIntoHistory(input: {
|
||||
projectId: string;
|
||||
paths: string[];
|
||||
label: string;
|
||||
coalesceKey?: string;
|
||||
recordEdit: (edit: RecordEditInput) => Promise<void>;
|
||||
gsapMutation: () => Promise<GsapMutationStatus>;
|
||||
}): Promise<GsapMutationStatus> {
|
||||
const uniquePaths = [...new Set(input.paths)];
|
||||
const before = new Map<string, string>();
|
||||
for (const path of uniquePaths) {
|
||||
before.set(path, await readFileContent(input.projectId, path));
|
||||
}
|
||||
const status = await input.gsapMutation();
|
||||
if (status.mutated) {
|
||||
const files: Record<string, { before: string; after: string }> = {};
|
||||
for (const path of uniquePaths) {
|
||||
const priorContent = before.get(path);
|
||||
const finalContent = await readFileContent(input.projectId, path);
|
||||
if (priorContent !== undefined && finalContent !== priorContent) {
|
||||
files[path] = { before: priorContent, after: finalContent };
|
||||
}
|
||||
}
|
||||
if (Object.keys(files).length > 0) {
|
||||
await input.recordEdit({
|
||||
label: input.label,
|
||||
kind: "timeline",
|
||||
coalesceKey: input.coalesceKey,
|
||||
coalesceMs: GSAP_HISTORY_COALESCE_MS,
|
||||
files,
|
||||
});
|
||||
}
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift all GSAP animation positions targeting a given element by a time delta.
|
||||
* Calls the server-side GSAP mutation endpoint which uses the AST-based parser.
|
||||
@@ -433,5 +542,58 @@ export async function scaleGsapPositions(
|
||||
return readMutationStatus(await res.json().catch(() => null));
|
||||
}
|
||||
|
||||
/** Single-clip move GSAP shift, folded into the timing edit's history entry (see above). */
|
||||
export function foldedShiftGsapMutation(input: {
|
||||
projectId: string;
|
||||
targetPath: string;
|
||||
domId: string;
|
||||
delta: number;
|
||||
label: string;
|
||||
coalesceKey?: string;
|
||||
recordEdit: (edit: RecordEditInput) => Promise<void>;
|
||||
}): () => Promise<GsapMutationStatus> {
|
||||
return () =>
|
||||
foldGsapMutationIntoHistory({
|
||||
projectId: input.projectId,
|
||||
paths: [input.targetPath],
|
||||
label: input.label,
|
||||
coalesceKey: input.coalesceKey,
|
||||
recordEdit: input.recordEdit,
|
||||
gsapMutation: () =>
|
||||
shiftGsapPositions(input.projectId, input.targetPath, input.domId, input.delta),
|
||||
});
|
||||
}
|
||||
|
||||
/** Single-clip resize GSAP scale, folded into the timing edit's history entry (see above). */
|
||||
export function foldedScaleGsapMutation(input: {
|
||||
projectId: string;
|
||||
targetPath: string;
|
||||
domId: string;
|
||||
from: { start: number; duration: number };
|
||||
to: { start: number; duration: number };
|
||||
label: string;
|
||||
coalesceKey?: string;
|
||||
recordEdit: (edit: RecordEditInput) => Promise<void>;
|
||||
}): () => Promise<GsapMutationStatus> {
|
||||
return () =>
|
||||
foldGsapMutationIntoHistory({
|
||||
projectId: input.projectId,
|
||||
paths: [input.targetPath],
|
||||
label: input.label,
|
||||
coalesceKey: input.coalesceKey,
|
||||
recordEdit: input.recordEdit,
|
||||
gsapMutation: () =>
|
||||
scaleGsapPositions(
|
||||
input.projectId,
|
||||
input.targetPath,
|
||||
input.domId,
|
||||
input.from.start,
|
||||
input.from.duration,
|
||||
input.to.start,
|
||||
input.to.duration,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Re-export applyPatchByTarget for use in the hook (avoids double import in callers)
|
||||
export { applyPatchByTarget, formatTimelineAttributeNumber };
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useCallback, useEffect, useRef } from "react";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import { STUDIO_GSAP_PANEL_ENABLED } from "../components/editor/manualEditingAvailability";
|
||||
import { usePlayerStore } from "../player";
|
||||
import { resolveTimelineIdForSelection } from "../utils/studioHelpers";
|
||||
import { useDomEditPreviewSync } from "./useDomEditPreviewSync";
|
||||
import { useGsapAnimationsForElement, usePopulateKeyframeCacheForFile } from "./useGsapTweenCache";
|
||||
import { useGsapAnimationFetchFallback } from "./useGsapAnimationFetchFallback";
|
||||
@@ -170,13 +171,14 @@ export function useDomEditWiring({
|
||||
|
||||
useEffect(() => {
|
||||
if (!domEditSelection?.id) return;
|
||||
const { selectedElementId, elements, setSelectedElementId } = usePlayerStore.getState();
|
||||
const matchKey = elements.find(
|
||||
(el) => el.domId === domEditSelection.id || el.id === domEditSelection.id,
|
||||
);
|
||||
const key = matchKey ? (matchKey.key ?? matchKey.id) : null;
|
||||
if (key && key !== selectedElementId) setSelectedElementId(key);
|
||||
}, [domEditSelection?.id]);
|
||||
const { selectedElementId, elements, setSelectionAnchor } = usePlayerStore.getState();
|
||||
// Resolve through the canonical resolver (source-file + ancestor + active-comp
|
||||
// fallback) rather than a narrow domId/id match, so a sub-composition selection
|
||||
// maps to the same clip the rest of the selection pipeline picks. Use the
|
||||
// anchor-only setter: this is a DOM->store echo and must not collapse a group.
|
||||
const key = resolveTimelineIdForSelection(domEditSelection, elements, activeCompPath);
|
||||
if (key && key !== selectedElementId) setSelectionAnchor(key);
|
||||
}, [domEditSelection, activeCompPath]);
|
||||
|
||||
// ── GSAP cache sync ──
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TimelineElement } from "../player";
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
import { installReactActEnvironment, makeSelection } from "./domSelectionTestHarness";
|
||||
import { useDomSelection } from "./useDomSelection";
|
||||
|
||||
@@ -12,6 +14,7 @@ interface HarnessProps {
|
||||
activeCompPath: string | null;
|
||||
projectId: string | null;
|
||||
refreshKey: number;
|
||||
timelineElements?: TimelineElement[];
|
||||
}
|
||||
|
||||
function renderHarness(initialProps: HarnessProps): {
|
||||
@@ -32,7 +35,7 @@ function renderHarness(initialProps: HarnessProps): {
|
||||
compIdToSrc: new Map(),
|
||||
captionEditMode: false,
|
||||
previewIframeRef: { current: null },
|
||||
timelineElements: [],
|
||||
timelineElements: props.timelineElements ?? [],
|
||||
setSelectedTimelineElementId: vi.fn(),
|
||||
setRightCollapsed: vi.fn(),
|
||||
setRightPanelTab: vi.fn(),
|
||||
@@ -64,6 +67,10 @@ function renderHarness(initialProps: HarnessProps): {
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
usePlayerStore.getState().reset();
|
||||
});
|
||||
|
||||
function setupSelectedHarness() {
|
||||
const element = document.createElement("div");
|
||||
element.id = "headline";
|
||||
@@ -131,4 +138,31 @@ describe("useDomSelection", () => {
|
||||
expect(harness.current().domEditSelection).toBe(selection);
|
||||
harness.cleanup();
|
||||
});
|
||||
|
||||
it("keeps preview marquee selections mirrored to the full timeline selection set", () => {
|
||||
const first = document.createElement("div");
|
||||
first.id = "clip-1";
|
||||
const second = document.createElement("div");
|
||||
second.id = "clip-2";
|
||||
const firstSelection = makeSelection("First", first);
|
||||
const secondSelection = makeSelection("Second", second);
|
||||
const harness = renderHarness({
|
||||
activeCompPath: "intro.html",
|
||||
projectId: "project-1",
|
||||
refreshKey: 0,
|
||||
timelineElements: [
|
||||
{ id: "clip-1", domId: "clip-1", tag: "div", start: 0, duration: 1, track: 0 },
|
||||
{ id: "clip-2", domId: "clip-2", tag: "div", start: 1, duration: 1, track: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
act(() => harness.current().applyMarqueeSelection([secondSelection, firstSelection], false));
|
||||
|
||||
const state = usePlayerStore.getState();
|
||||
expect([...state.selectedElementIds]).toEqual(["clip-2", "clip-1"]);
|
||||
expect(state.selectedElementId).toBe("clip-2");
|
||||
expect(harness.current().domEditGroupSelections).toHaveLength(2);
|
||||
expect(harness.current().domEditSelection).toBe(secondSelection);
|
||||
harness.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,11 +4,7 @@ import {
|
||||
getAllPreviewTargetsFromPointer,
|
||||
getPreviewTargetFromPointer,
|
||||
} from "../utils/studioPreviewHelpers";
|
||||
import {
|
||||
findMatchingTimelineElementId,
|
||||
findTimelineIdByAncestor,
|
||||
type RightPanelTab,
|
||||
} from "../utils/studioHelpers";
|
||||
import { resolveTimelineIdForSelection, type RightPanelTab } from "../utils/studioHelpers";
|
||||
import {
|
||||
domEditSelectionsTargetSame,
|
||||
domEditSelectionInGroup,
|
||||
@@ -24,6 +20,7 @@ import {
|
||||
type DomEditSelection,
|
||||
} from "../components/editor/domEditing";
|
||||
import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits";
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
@@ -95,7 +92,6 @@ export interface UseDomSelectionReturn {
|
||||
) => Promise<DomEditSelection | null>;
|
||||
handleTimelineElementSelect: (element: TimelineElement | null) => Promise<void>;
|
||||
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise<void>;
|
||||
refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
|
||||
applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -133,6 +129,9 @@ export function useDomSelection({
|
||||
const domEditHoverSelectionRef = useRef<DomEditSelection | null>(domEditHoverSelection);
|
||||
const activeGroupElementRef = useRef<HTMLElement | null>(activeGroupElement);
|
||||
const compositionIdentityRef = useRef({ activeCompPath, projectId });
|
||||
// Monotonic token so a rapid A->B timeline-clip select can't let A's slower async
|
||||
// resolution land after B and restore the wrong selection.
|
||||
const timelineSelectSeqRef = useRef(0);
|
||||
|
||||
// Keep refs in sync with state
|
||||
domEditSelectionRef.current = domEditSelection;
|
||||
@@ -213,20 +212,36 @@ export function useDomSelection({
|
||||
setRightPanelTab("design");
|
||||
}
|
||||
}
|
||||
const nextSelectedTimelineId =
|
||||
findMatchingTimelineElementId(nextSelection, timelineElements) ??
|
||||
findTimelineIdByAncestor(
|
||||
nextSelection.element,
|
||||
timelineElements,
|
||||
nextSelection.sourceFile || "index.html",
|
||||
);
|
||||
setSelectedTimelineElementId(nextSelectedTimelineId);
|
||||
// Mirror the whole DOM group to the store so it stays the single source of
|
||||
// truth: a single selection collapses to one id; a preserved group (echo
|
||||
// during a gesture) keeps every member instead of shrinking to the anchor.
|
||||
const anchorId = resolveTimelineIdForSelection(
|
||||
nextSelection,
|
||||
timelineElements,
|
||||
activeCompPath,
|
||||
);
|
||||
const groupIds = nextGroup
|
||||
.map((selection) =>
|
||||
resolveTimelineIdForSelection(selection, timelineElements, activeCompPath),
|
||||
)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
if (groupIds.length > 0) {
|
||||
usePlayerStore.getState().setSelection(groupIds, anchorId);
|
||||
} else {
|
||||
setSelectedTimelineElementId(anchorId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedTimelineElementId(null);
|
||||
},
|
||||
[setSelectedTimelineElementId, timelineElements, setRightCollapsed, setRightPanelTab],
|
||||
[
|
||||
setSelectedTimelineElementId,
|
||||
timelineElements,
|
||||
setRightCollapsed,
|
||||
setRightPanelTab,
|
||||
activeCompPath,
|
||||
],
|
||||
);
|
||||
|
||||
const clearDomSelection = useCallback(() => {
|
||||
@@ -366,12 +381,15 @@ export function useDomSelection({
|
||||
const handleTimelineElementSelect = useCallback(
|
||||
async (element: TimelineElement | null) => {
|
||||
if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
|
||||
const seq = ++timelineSelectSeqRef.current;
|
||||
if (!element) {
|
||||
applyDomSelection(null, { revealPanel: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = await buildDomSelectionForTimelineElement(element);
|
||||
// A newer selection superseded this one while we were resolving — drop the stale result.
|
||||
if (seq !== timelineSelectSeqRef.current) return;
|
||||
if (selection) applyDomSelection(selection);
|
||||
},
|
||||
[applyDomSelection, buildDomSelectionForTimelineElement],
|
||||
@@ -406,55 +424,6 @@ export function useDomSelection({
|
||||
[activeCompPath, applyDomSelection, buildDomSelectionFromTarget, previewIframeRef],
|
||||
);
|
||||
|
||||
const refreshDomEditGroupSelectionsFromPreview = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
async (selections: DomEditSelection[]) => {
|
||||
const iframe = previewIframeRef.current;
|
||||
let doc: Document | null = null;
|
||||
try {
|
||||
doc = iframe?.contentDocument ?? null;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!doc) return;
|
||||
|
||||
const nextGroup: DomEditSelection[] = [];
|
||||
for (const selection of selections) {
|
||||
const element = findElementForSelection(doc, selection, activeCompPath);
|
||||
if (!element) continue;
|
||||
const nextSelection = await buildDomSelectionFromTarget(element);
|
||||
if (nextSelection) nextGroup.push(nextSelection);
|
||||
}
|
||||
if (nextGroup.length === 0) return;
|
||||
|
||||
const currentSelection = domEditSelectionRef.current;
|
||||
const nextSelection =
|
||||
nextGroup.find((selection) => domEditSelectionsTargetSame(selection, currentSelection)) ??
|
||||
nextGroup[0] ??
|
||||
null;
|
||||
|
||||
domEditSelectionRef.current = nextSelection;
|
||||
domEditGroupSelectionsRef.current = nextGroup;
|
||||
setDomEditSelection(nextSelection);
|
||||
setDomEditGroupSelections(nextGroup);
|
||||
|
||||
if (nextSelection) {
|
||||
setSelectedTimelineElementId(
|
||||
findMatchingTimelineElementId(nextSelection, timelineElements),
|
||||
);
|
||||
} else {
|
||||
setSelectedTimelineElementId(null);
|
||||
}
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
buildDomSelectionFromTarget,
|
||||
setSelectedTimelineElementId,
|
||||
timelineElements,
|
||||
previewIframeRef,
|
||||
],
|
||||
);
|
||||
|
||||
// ── Effects ──
|
||||
|
||||
// Clear hover unconditionally on composition/project/preview change
|
||||
@@ -536,16 +505,23 @@ export function useDomSelection({
|
||||
domEditGroupSelectionsRef.current = nextGroup;
|
||||
setDomEditSelection(nextSelection);
|
||||
setDomEditGroupSelections(nextGroup);
|
||||
const nextTimelineId =
|
||||
findMatchingTimelineElementId(nextSelection, timelineElements) ??
|
||||
findTimelineIdByAncestor(
|
||||
nextSelection.element,
|
||||
timelineElements,
|
||||
nextSelection.sourceFile || "index.html",
|
||||
);
|
||||
setSelectedTimelineElementId(nextTimelineId);
|
||||
const nextTimelineId = resolveTimelineIdForSelection(
|
||||
nextSelection,
|
||||
timelineElements,
|
||||
activeCompPath,
|
||||
);
|
||||
const nextTimelineIds = nextGroup
|
||||
.map((selection) =>
|
||||
resolveTimelineIdForSelection(selection, timelineElements, activeCompPath),
|
||||
)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
if (nextTimelineIds.length > 0) {
|
||||
usePlayerStore.getState().setSelection(nextTimelineIds, nextTimelineId);
|
||||
} else {
|
||||
setSelectedTimelineElementId(null);
|
||||
}
|
||||
},
|
||||
[applyDomSelection, timelineElements, setSelectedTimelineElementId],
|
||||
[applyDomSelection, timelineElements, setSelectedTimelineElementId, activeCompPath],
|
||||
);
|
||||
|
||||
// Disabled inspector effect
|
||||
@@ -582,7 +558,6 @@ export function useDomSelection({
|
||||
buildDomSelectionForTimelineElement,
|
||||
handleTimelineElementSelect,
|
||||
refreshDomEditSelectionFromPreview,
|
||||
refreshDomEditGroupSelectionsFromPreview,
|
||||
applyMarqueeSelection,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,20 +56,23 @@ function timelineElement(input: {
|
||||
track: number;
|
||||
zIndex: number;
|
||||
tag?: string;
|
||||
start?: number;
|
||||
duration?: number;
|
||||
sourceFile?: string;
|
||||
}): TimelineElement {
|
||||
return {
|
||||
id: input.id,
|
||||
domId: input.id,
|
||||
hfId: `hf-${input.id}`,
|
||||
tag: input.tag ?? "div",
|
||||
start: 0,
|
||||
duration: 2,
|
||||
start: input.start ?? 0,
|
||||
duration: input.duration ?? 2,
|
||||
track: input.track,
|
||||
zIndex: input.zIndex,
|
||||
stackingContextId: "root",
|
||||
parentCompositionId: null,
|
||||
compositionAncestors: ["root"],
|
||||
sourceFile: "index.html",
|
||||
sourceFile: input.sourceFile ?? "index.html",
|
||||
timingSource: "authored",
|
||||
};
|
||||
}
|
||||
@@ -92,10 +95,14 @@ function renderTimelineEditingHook(input: {
|
||||
}): {
|
||||
move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
|
||||
resize: ReturnType<typeof useTimelineEditing>["handleTimelineElementResize"];
|
||||
groupMove: ReturnType<typeof useTimelineEditing>["handleTimelineGroupMove"];
|
||||
groupResize: ReturnType<typeof useTimelineEditing>["handleTimelineGroupResize"];
|
||||
unmount: () => void;
|
||||
} {
|
||||
let move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"] | null = null;
|
||||
let resize: ReturnType<typeof useTimelineEditing>["handleTimelineElementResize"] | null = null;
|
||||
let groupMove: ReturnType<typeof useTimelineEditing>["handleTimelineGroupMove"] | null = null;
|
||||
let groupResize: ReturnType<typeof useTimelineEditing>["handleTimelineGroupResize"] | null = null;
|
||||
|
||||
function Harness() {
|
||||
const commitRef = useRef(input.onZIndexCommit);
|
||||
@@ -118,6 +125,8 @@ function renderTimelineEditingHook(input: {
|
||||
});
|
||||
move = hook.handleTimelineElementMove;
|
||||
resize = hook.handleTimelineElementResize;
|
||||
groupMove = hook.handleTimelineGroupMove;
|
||||
groupResize = hook.handleTimelineGroupResize;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -130,15 +139,23 @@ function renderTimelineEditingHook(input: {
|
||||
|
||||
if (!move) throw new Error("Expected hook to expose move handler");
|
||||
if (!resize) throw new Error("Expected hook to expose resize handler");
|
||||
if (!groupMove) throw new Error("Expected hook to expose group move handler");
|
||||
if (!groupResize) throw new Error("Expected hook to expose group resize handler");
|
||||
return {
|
||||
move,
|
||||
resize,
|
||||
groupMove,
|
||||
groupResize,
|
||||
unmount: () => {
|
||||
act(() => root.unmount());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type TimelineRecordEdit = NonNullable<
|
||||
Parameters<typeof renderTimelineEditingHook>[0]["recordEdit"]
|
||||
>;
|
||||
|
||||
function renderTimelineEditingHookWithLifecycle(input: {
|
||||
timelineElements: TimelineElement[];
|
||||
iframe: HTMLIFrameElement;
|
||||
@@ -228,7 +245,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
||||
const sdkSession = await openComposition(source);
|
||||
const setTimingSpy = vi.spyOn(sdkSession, "setTiming");
|
||||
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
||||
const recordEdit = vi.fn(async () => {});
|
||||
const recordEdit = vi.fn<TimelineRecordEdit>(async () => {});
|
||||
const forceReloadSdkSession = vi.fn();
|
||||
const reloadPreview = vi.fn();
|
||||
const iframeWindow = iframe.contentWindow;
|
||||
@@ -294,7 +311,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
||||
const sdkSession = await openComposition(source);
|
||||
const setTimingSpy = vi.spyOn(sdkSession, "setTiming");
|
||||
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
||||
const recordEdit = vi.fn(async () => {});
|
||||
const recordEdit = vi.fn<TimelineRecordEdit>(async () => {});
|
||||
const forceReloadSdkSession = vi.fn();
|
||||
const reloadPreview = vi.fn();
|
||||
const iframeWindow = iframe.contentWindow;
|
||||
@@ -628,7 +645,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
||||
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
|
||||
const commit = vi.fn<(entries: ZIndexEntry[]) => Promise<void>>().mockResolvedValue(undefined);
|
||||
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
||||
const recordEdit = vi.fn(async () => {});
|
||||
const recordEdit = vi.fn<TimelineRecordEdit>(async (_entry) => {});
|
||||
const reloadPreview = vi.fn();
|
||||
const fetchMock = vi.fn(
|
||||
async (
|
||||
@@ -739,4 +756,208 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("persists a same-file group move with one write containing every clip timing", async () => {
|
||||
const source = [
|
||||
'<div id="a" data-start="0" data-duration="1"></div>',
|
||||
'<div id="b" data-start="1" data-duration="1"></div>',
|
||||
'<div id="c" data-start="2" data-duration="1"></div>',
|
||||
].join("\n");
|
||||
const iframe = createPreviewIframe([
|
||||
{ id: "a", track: 0 },
|
||||
{ id: "b", track: 1 },
|
||||
{ id: "c", track: 2 },
|
||||
]);
|
||||
const clips = [
|
||||
timelineElement({ id: "a", track: 0, zIndex: 0, start: 0, duration: 1 }),
|
||||
timelineElement({ id: "b", track: 1, zIndex: 0, start: 1, duration: 1 }),
|
||||
timelineElement({ id: "c", track: 2, zIndex: 0, start: 2, duration: 1 }),
|
||||
];
|
||||
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
||||
const recordEdit = vi.fn<TimelineRecordEdit>(async (_entry) => {});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
||||
const url = requestUrl(input);
|
||||
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
|
||||
if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
}),
|
||||
);
|
||||
const { groupMove, unmount } = renderTimelineEditingHook({
|
||||
timelineElements: clips,
|
||||
iframe,
|
||||
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
||||
projectId: "p1",
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await groupMove([
|
||||
{ element: clips[0], start: 0.5 },
|
||||
{ element: clips[1], start: 1.5 },
|
||||
{ element: clips[2], start: 2.5 },
|
||||
]);
|
||||
});
|
||||
|
||||
expect(writeProjectFile).toHaveBeenCalledTimes(1);
|
||||
const written = writeProjectFile.mock.calls[0]![1] as string;
|
||||
expect(written).toContain('id="a" data-start="0.5"');
|
||||
expect(written).toContain('id="b" data-start="1.5"');
|
||||
expect(written).toContain('id="c" data-start="2.5"');
|
||||
expect(recordEdit).toHaveBeenCalledTimes(1);
|
||||
expect(Object.keys(recordEdit.mock.calls[0]![0].files)).toEqual(["index.html"]);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("partitions a group move by source file while keeping one undo entry", async () => {
|
||||
const files: Record<string, string> = {
|
||||
"index.html": '<div id="a" data-start="0" data-duration="1"></div>',
|
||||
"scene.html": '<div id="b" data-start="1" data-duration="1"></div>',
|
||||
};
|
||||
const iframe = createPreviewIframe([
|
||||
{ id: "a", track: 0 },
|
||||
{ id: "b", track: 1 },
|
||||
]);
|
||||
const a = timelineElement({ id: "a", track: 0, zIndex: 0, start: 0, duration: 1 });
|
||||
const b = timelineElement({
|
||||
id: "b",
|
||||
track: 1,
|
||||
zIndex: 0,
|
||||
start: 1,
|
||||
duration: 1,
|
||||
sourceFile: "scene.html",
|
||||
});
|
||||
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
||||
const recordEdit = vi.fn<TimelineRecordEdit>(async (_entry) => {});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
||||
const url = requestUrl(input);
|
||||
if (url.includes("/api/projects/p1/files/")) {
|
||||
const path = decodeURIComponent(url.split("/files/")[1] ?? "index.html");
|
||||
return jsonResponse({ content: files[path] });
|
||||
}
|
||||
if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
}),
|
||||
);
|
||||
const { groupMove, unmount } = renderTimelineEditingHook({
|
||||
timelineElements: [a, b],
|
||||
iframe,
|
||||
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
||||
projectId: "p1",
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await groupMove([
|
||||
{ element: a, start: 0.25 },
|
||||
{ element: b, start: 1.25 },
|
||||
]);
|
||||
});
|
||||
|
||||
expect(writeProjectFile.mock.calls.map((call) => call[0])).toEqual([
|
||||
"index.html",
|
||||
"scene.html",
|
||||
]);
|
||||
expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="0.25"');
|
||||
expect(writeProjectFile.mock.calls[1]![1]).toContain('data-start="1.25"');
|
||||
expect(recordEdit).toHaveBeenCalledTimes(1);
|
||||
expect(Object.keys(recordEdit.mock.calls[0]![0].files).sort()).toEqual([
|
||||
"index.html",
|
||||
"scene.html",
|
||||
]);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("waits for a z-index commit before the group timing write", async () => {
|
||||
const source = '<div id="clip" data-start="0" data-duration="1"></div>';
|
||||
const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
|
||||
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 0, duration: 1 });
|
||||
let releaseCommit!: () => void;
|
||||
const zIndexCommit = new Promise<void>((resolve) => {
|
||||
releaseCommit = resolve;
|
||||
});
|
||||
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
||||
const url = requestUrl(input);
|
||||
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
|
||||
if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
}),
|
||||
);
|
||||
const { groupMove, unmount } = renderTimelineEditingHook({
|
||||
timelineElements: [clip],
|
||||
iframe,
|
||||
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
||||
projectId: "p1",
|
||||
writeProjectFile,
|
||||
recordEdit: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
let movePromise!: Promise<unknown>;
|
||||
await act(async () => {
|
||||
movePromise = groupMove([{ element: clip, start: 0.75 }], { beforeTiming: zIndexCommit });
|
||||
await flushAsyncWork();
|
||||
});
|
||||
expect(writeProjectFile).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
releaseCommit();
|
||||
await movePromise;
|
||||
await flushAsyncWork();
|
||||
});
|
||||
expect(writeProjectFile).toHaveBeenCalledTimes(1);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("matches the single-clip move output when a group move contains one clip", async () => {
|
||||
const source = '<div id="clip" data-start="0" data-duration="1"></div>';
|
||||
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 0, duration: 1 });
|
||||
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
||||
const url = requestUrl(input);
|
||||
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
|
||||
if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const singleWrite = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
||||
const single = renderTimelineEditingHook({
|
||||
timelineElements: [clip],
|
||||
iframe: createPreviewIframe([{ id: "clip", track: 0 }]),
|
||||
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
||||
projectId: "p1",
|
||||
writeProjectFile: singleWrite,
|
||||
recordEdit: vi.fn(async () => {}),
|
||||
});
|
||||
await act(async () => {
|
||||
await single.move(clip, { start: 0.5, track: clip.track });
|
||||
});
|
||||
single.unmount();
|
||||
|
||||
const groupWrite = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
||||
const group = renderTimelineEditingHook({
|
||||
timelineElements: [clip],
|
||||
iframe: createPreviewIframe([{ id: "clip", track: 0 }]),
|
||||
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
||||
projectId: "p1",
|
||||
writeProjectFile: groupWrite,
|
||||
recordEdit: vi.fn(async () => {}),
|
||||
});
|
||||
await act(async () => {
|
||||
await group.groupMove([{ element: clip, start: 0.5 }]);
|
||||
});
|
||||
|
||||
expect(groupWrite.mock.calls[0]![1]).toBe(singleWrite.mock.calls[0]![1]);
|
||||
group.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// Pre-existing-complex timeline hook (DOM patch + GSAP position shift/scale +
|
||||
// playback-start resolution).
|
||||
// fallow-ignore-file complexity
|
||||
import { useCallback, useRef } from "react";
|
||||
import type { TimelineElement } from "../player";
|
||||
@@ -26,9 +24,9 @@ import {
|
||||
patchIframeDomTiming,
|
||||
persistTimelineEdit,
|
||||
readFileContent,
|
||||
foldedShiftGsapMutation,
|
||||
foldedScaleGsapMutation,
|
||||
formatTimelineAttributeNumber,
|
||||
shiftGsapPositions,
|
||||
scaleGsapPositions,
|
||||
finishTimelineTimingFallback,
|
||||
extendRootDurationIfNeeded,
|
||||
buildTimelineMoveTimingPatch,
|
||||
@@ -40,6 +38,7 @@ import {
|
||||
useTimelineElementVisibilityEditing,
|
||||
useTimelineTrackVisibilityEditing,
|
||||
} from "./timelineTrackVisibility";
|
||||
import { useTimelineGroupEditing } from "./useTimelineGroupEditing";
|
||||
import { sdkTimingPersist } from "../utils/sdkCutover";
|
||||
import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes";
|
||||
|
||||
@@ -47,8 +46,6 @@ type TimelineMoveUpdates = Pick<TimelineElement, "start" | "track"> & {
|
||||
stackingReorder?: TimelineStackingReorderIntent | null;
|
||||
};
|
||||
|
||||
// ── Hook ──
|
||||
|
||||
export function useTimelineEditing({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
@@ -101,7 +98,6 @@ export function useTimelineEditing({
|
||||
}),
|
||||
)
|
||||
.then(() => {
|
||||
// Server wrote the file; resync the stale in-memory SDK doc.
|
||||
forceReloadSdkSession?.();
|
||||
});
|
||||
editQueueRef.current = queued.catch((error) => {
|
||||
@@ -120,8 +116,22 @@ export function useTimelineEditing({
|
||||
forceReloadSdkSession,
|
||||
],
|
||||
);
|
||||
const groupEditing = useTimelineGroupEditing({
|
||||
activeCompPath,
|
||||
domEditSaveTimestampRef,
|
||||
editQueueRef,
|
||||
forceReloadSdkSession,
|
||||
isRecordingRef,
|
||||
pendingTimelineEditPathRef,
|
||||
previewIframeRef,
|
||||
projectIdRef,
|
||||
recordEdit,
|
||||
reloadPreview,
|
||||
sdkSession,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
});
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const handleTimelineElementMove = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
(element: TimelineElement, updates: TimelineMoveUpdates) => {
|
||||
@@ -148,9 +158,6 @@ export function useTimelineEditing({
|
||||
const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
|
||||
return buildTimelineMoveTimingPatch(original, target, updates.start, element.duration);
|
||||
};
|
||||
// Server-path fallback (no SDK session): persist the attr patch, then
|
||||
// shift GSAP tween positions on the server. Extending edits can keep the
|
||||
// iframe live unless a GSAP source rewrite needs a fresh run.
|
||||
const coalesceKey = `timeline-move:${element.hfId ?? element.id}`;
|
||||
const moveFallback = () =>
|
||||
enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(() => {
|
||||
@@ -164,16 +171,20 @@ export function useTimelineEditing({
|
||||
reloadPreview,
|
||||
gsapMutation:
|
||||
delta !== 0 && domId && pid
|
||||
? () => shiftGsapPositions(pid, targetPath, domId, delta)
|
||||
? foldedShiftGsapMutation({
|
||||
projectId: pid,
|
||||
targetPath,
|
||||
domId,
|
||||
delta,
|
||||
label: "Move timeline clip",
|
||||
coalesceKey,
|
||||
recordEdit,
|
||||
})
|
||||
: undefined,
|
||||
onGsapError: (err) => console.error("[Timeline] Failed to shift GSAP positions", err),
|
||||
});
|
||||
});
|
||||
const needsExtension = extendRootDurationIfNeeded(updates.start + element.duration);
|
||||
// The z-index reorder above and this timing write target the same file on
|
||||
// separate save queues, and the timing write is a full-file overwrite. Order
|
||||
// it after the reorder so it reads disk with the z-index already applied and
|
||||
// can't clobber it — one ordered writer per gesture (diagonal move+restack).
|
||||
return reorderDone.then(() => {
|
||||
if (sdkSession && element.hfId && !needsExtension) {
|
||||
return sdkTimingPersist(
|
||||
@@ -213,7 +224,6 @@ export function useTimelineEditing({
|
||||
],
|
||||
);
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const handleTimelineElementResize = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
(
|
||||
@@ -239,10 +249,6 @@ export function useTimelineEditing({
|
||||
const buildResizePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
|
||||
return buildTimelineResizeTimingPatch(original, target, element, updates);
|
||||
};
|
||||
// SDK path: skip when a playback-start adjustment is needed (setTiming has no pbs field).
|
||||
// The second clause fires because trimming the start of a clip that has a
|
||||
// playback-start attribute implicitly shifts that in-point — which the SDK
|
||||
// setTiming op can't express — so those resizes must take the server path.
|
||||
const hasPbsAdjustment =
|
||||
updates.playbackStart != null ||
|
||||
(updates.start !== element.start && element.playbackStart != null);
|
||||
@@ -264,16 +270,16 @@ export function useTimelineEditing({
|
||||
reloadPreview,
|
||||
gsapMutation:
|
||||
timingChanged && domId && pid
|
||||
? () =>
|
||||
scaleGsapPositions(
|
||||
pid,
|
||||
targetPath,
|
||||
domId,
|
||||
element.start,
|
||||
element.duration,
|
||||
updates.start,
|
||||
updates.duration,
|
||||
)
|
||||
? foldedScaleGsapMutation({
|
||||
projectId: pid,
|
||||
targetPath,
|
||||
domId,
|
||||
from: { start: element.start, duration: element.duration },
|
||||
to: { start: updates.start, duration: updates.duration },
|
||||
label: "Resize timeline clip",
|
||||
coalesceKey,
|
||||
recordEdit,
|
||||
})
|
||||
: undefined,
|
||||
onGsapError: (err) => console.error("[Timeline] Failed to scale GSAP positions", err),
|
||||
});
|
||||
@@ -589,5 +595,6 @@ export function useTimelineEditing({
|
||||
handleTimelineAssetDrop,
|
||||
handleTimelineFileDrop,
|
||||
handleBlockedTimelineEdit,
|
||||
...groupEditing,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
import { useCallback, type MutableRefObject, type RefObject } from "react";
|
||||
import type { Composition } from "@hyperframes/sdk";
|
||||
import type { TimelineElement } from "../player";
|
||||
import { sdkTimingBatchPersist } from "../utils/sdkCutover";
|
||||
import {
|
||||
buildTimelineMoveTimingPatch,
|
||||
buildTimelineResizeTimingPatch,
|
||||
extendRootDurationIfNeeded,
|
||||
finishTimelineTimingFallback,
|
||||
foldGsapMutationIntoHistory,
|
||||
formatTimelineAttributeNumber,
|
||||
patchIframeDomTiming,
|
||||
persistTimelineBatchEdit,
|
||||
readFileContent,
|
||||
scaleGsapPositions,
|
||||
shiftGsapPositions,
|
||||
type PersistTimelineBatchChange,
|
||||
type RecordEditInput,
|
||||
} from "./timelineEditingHelpers";
|
||||
|
||||
export interface TimelineGroupMoveChange {
|
||||
element: TimelineElement;
|
||||
start: number;
|
||||
}
|
||||
|
||||
export interface TimelineGroupResizeChange {
|
||||
element: TimelineElement;
|
||||
start: number;
|
||||
duration: number;
|
||||
playbackStart?: number;
|
||||
}
|
||||
|
||||
export interface TimelineGroupCommitOptions {
|
||||
beforeTiming?: Promise<void>;
|
||||
coalesceKey?: string;
|
||||
}
|
||||
|
||||
interface UseTimelineGroupEditingOptions {
|
||||
activeCompPath: string | null;
|
||||
domEditSaveTimestampRef: MutableRefObject<number>;
|
||||
editQueueRef: MutableRefObject<Promise<unknown>>;
|
||||
forceReloadSdkSession?: () => void;
|
||||
isRecordingRef?: RefObject<boolean>;
|
||||
pendingTimelineEditPathRef: MutableRefObject<Set<string>>;
|
||||
previewIframeRef: RefObject<HTMLIFrameElement | null>;
|
||||
projectIdRef: MutableRefObject<string | null>;
|
||||
recordEdit: (input: RecordEditInput) => Promise<void>;
|
||||
reloadPreview: () => void;
|
||||
sdkSession?: Composition | null;
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function targetPathFor(element: TimelineElement, activeCompPath: string | null): string {
|
||||
return element.sourceFile || activeCompPath || "index.html";
|
||||
}
|
||||
|
||||
function allChangesSharePath(
|
||||
changes: readonly { element: TimelineElement }[],
|
||||
activeCompPath: string | null,
|
||||
): string | null {
|
||||
const firstPath = changes[0] ? targetPathFor(changes[0].element, activeCompPath) : null;
|
||||
if (!firstPath) return null;
|
||||
return changes.every((change) => targetPathFor(change.element, activeCompPath) === firstPath)
|
||||
? firstPath
|
||||
: null;
|
||||
}
|
||||
|
||||
function moveCoalesceKey(changes: readonly TimelineGroupMoveChange[]): string {
|
||||
return `timeline-group-move:${changes.map((change) => change.element.hfId ?? change.element.id).join(",")}`;
|
||||
}
|
||||
|
||||
function resizeCoalesceKey(changes: readonly TimelineGroupResizeChange[]): string {
|
||||
return `timeline-group-resize:${changes.map((change) => change.element.hfId ?? change.element.id).join(",")}`;
|
||||
}
|
||||
|
||||
function resizeHasPlaybackStartAdjustment(change: TimelineGroupResizeChange): boolean {
|
||||
return (
|
||||
change.playbackStart != null ||
|
||||
(change.start !== change.element.start && change.element.playbackStart != null)
|
||||
);
|
||||
}
|
||||
|
||||
export function useTimelineGroupEditing({
|
||||
activeCompPath,
|
||||
domEditSaveTimestampRef,
|
||||
editQueueRef,
|
||||
forceReloadSdkSession,
|
||||
isRecordingRef,
|
||||
pendingTimelineEditPathRef,
|
||||
previewIframeRef,
|
||||
projectIdRef,
|
||||
recordEdit,
|
||||
reloadPreview,
|
||||
sdkSession,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
}: UseTimelineGroupEditingOptions) {
|
||||
const enqueueGroupOperation = useCallback(
|
||||
(label: string, operation: (projectId: string) => Promise<void>): Promise<void> => {
|
||||
if (isRecordingRef?.current) {
|
||||
showToast("Cannot edit timeline while recording", "error");
|
||||
return Promise.reject(new Error(`${label}: blocked while recording`));
|
||||
}
|
||||
const projectId = projectIdRef.current;
|
||||
if (!projectId) return Promise.reject(new Error(`${label}: no active project`));
|
||||
const run = editQueueRef.current.then(() => operation(projectId));
|
||||
// Keep the shared edit queue from wedging on a rejection, but return the raw
|
||||
// (rejecting) promise so the gesture owner can roll back on a real failure.
|
||||
editQueueRef.current = run.then(
|
||||
() => undefined,
|
||||
(error) => {
|
||||
console.error(`[Timeline] Failed to persist: ${label}`, error);
|
||||
},
|
||||
);
|
||||
return run;
|
||||
},
|
||||
[editQueueRef, isRecordingRef, projectIdRef, showToast],
|
||||
);
|
||||
|
||||
const persistServerBatch = useCallback(
|
||||
async (
|
||||
projectId: string,
|
||||
label: string,
|
||||
batchChanges: PersistTimelineBatchChange[],
|
||||
coalesceKey: string,
|
||||
) => {
|
||||
await persistTimelineBatchEdit({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
label,
|
||||
changes: batchChanges,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
coalesceKey,
|
||||
});
|
||||
forceReloadSdkSession?.();
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
domEditSaveTimestampRef,
|
||||
forceReloadSdkSession,
|
||||
pendingTimelineEditPathRef,
|
||||
recordEdit,
|
||||
writeProjectFile,
|
||||
],
|
||||
);
|
||||
|
||||
const handleTimelineGroupMove = useCallback(
|
||||
(changes: TimelineGroupMoveChange[], options?: TimelineGroupCommitOptions) => {
|
||||
if (changes.length === 0) return Promise.resolve();
|
||||
for (const change of changes) {
|
||||
patchIframeDomTiming(previewIframeRef.current, change.element, [
|
||||
["data-start", formatTimelineAttributeNumber(change.start)],
|
||||
]);
|
||||
}
|
||||
|
||||
const maxEnd = Math.max(...changes.map((change) => change.start + change.element.duration));
|
||||
const needsExtension = extendRootDurationIfNeeded(maxEnd);
|
||||
const coalesceKey = options?.coalesceKey ?? moveCoalesceKey(changes);
|
||||
return enqueueGroupOperation("Move timeline clips", async (projectId) => {
|
||||
await options?.beforeTiming;
|
||||
const sharedPath = allChangesSharePath(changes, activeCompPath);
|
||||
const sdkChanges = changes.map((change) =>
|
||||
change.element.hfId
|
||||
? { hfId: change.element.hfId, timingUpdate: { start: change.start } }
|
||||
: null,
|
||||
);
|
||||
const canUseSdk =
|
||||
!needsExtension && sharedPath !== null && sdkChanges.every((change) => change !== null);
|
||||
if (canUseSdk) {
|
||||
const handled = await sdkTimingBatchPersist(
|
||||
sdkChanges.filter((change): change is NonNullable<typeof change> => change !== null),
|
||||
sharedPath,
|
||||
sdkSession,
|
||||
{
|
||||
editHistory: { recordEdit },
|
||||
writeProjectFile,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
compositionPath: activeCompPath,
|
||||
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
|
||||
},
|
||||
{ label: "Move timeline clips", coalesceKey },
|
||||
);
|
||||
if (handled) return;
|
||||
}
|
||||
|
||||
await persistServerBatch(
|
||||
projectId,
|
||||
"Move timeline clips",
|
||||
changes.map((change) => ({
|
||||
element: change.element,
|
||||
buildPatches: (original, target) =>
|
||||
buildTimelineMoveTimingPatch(original, target, change.start, change.element.duration),
|
||||
})),
|
||||
coalesceKey,
|
||||
);
|
||||
await finishTimelineTimingFallback({
|
||||
iframe: previewIframeRef.current,
|
||||
needsExtension,
|
||||
rootDurationSeconds: maxEnd,
|
||||
reloadPreview,
|
||||
gsapMutation: () =>
|
||||
foldGsapMutationIntoHistory({
|
||||
projectId,
|
||||
paths: changes.map((change) => targetPathFor(change.element, activeCompPath)),
|
||||
label: "Move timeline clips",
|
||||
coalesceKey,
|
||||
recordEdit,
|
||||
gsapMutation: async () => {
|
||||
let mutated = false;
|
||||
for (const change of changes) {
|
||||
const delta = change.start - change.element.start;
|
||||
const domId = change.element.domId;
|
||||
if (delta === 0 || !domId) continue;
|
||||
const status = await shiftGsapPositions(
|
||||
projectId,
|
||||
targetPathFor(change.element, activeCompPath),
|
||||
domId,
|
||||
delta,
|
||||
);
|
||||
mutated = mutated || status.mutated;
|
||||
}
|
||||
return { mutated };
|
||||
},
|
||||
}),
|
||||
onGsapError: (err) => console.error("[Timeline] Failed to shift GSAP positions", err),
|
||||
});
|
||||
});
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
domEditSaveTimestampRef,
|
||||
enqueueGroupOperation,
|
||||
persistServerBatch,
|
||||
previewIframeRef,
|
||||
projectIdRef,
|
||||
recordEdit,
|
||||
reloadPreview,
|
||||
sdkSession,
|
||||
writeProjectFile,
|
||||
],
|
||||
);
|
||||
|
||||
const handleTimelineGroupResize = useCallback(
|
||||
(changes: TimelineGroupResizeChange[], options?: TimelineGroupCommitOptions) => {
|
||||
if (changes.length === 0) return Promise.resolve();
|
||||
for (const change of changes) {
|
||||
const liveAttrs: Array<[string, string]> = [
|
||||
["data-start", formatTimelineAttributeNumber(change.start)],
|
||||
["data-duration", formatTimelineAttributeNumber(change.duration)],
|
||||
];
|
||||
if (change.playbackStart != null) {
|
||||
const liveAttr =
|
||||
change.element.playbackStartAttr === "playback-start"
|
||||
? "data-playback-start"
|
||||
: "data-media-start";
|
||||
liveAttrs.push([liveAttr, formatTimelineAttributeNumber(change.playbackStart)]);
|
||||
}
|
||||
patchIframeDomTiming(previewIframeRef.current, change.element, liveAttrs);
|
||||
}
|
||||
|
||||
const maxEnd = Math.max(...changes.map((change) => change.start + change.duration));
|
||||
const needsExtension = extendRootDurationIfNeeded(maxEnd);
|
||||
const coalesceKey = options?.coalesceKey ?? resizeCoalesceKey(changes);
|
||||
return enqueueGroupOperation("Resize timeline clips", async (projectId) => {
|
||||
await options?.beforeTiming;
|
||||
const sharedPath = allChangesSharePath(changes, activeCompPath);
|
||||
const sdkChanges = changes.map((change) =>
|
||||
change.element.hfId
|
||||
? {
|
||||
hfId: change.element.hfId,
|
||||
timingUpdate: { start: change.start, duration: change.duration },
|
||||
}
|
||||
: null,
|
||||
);
|
||||
const canUseSdk =
|
||||
!needsExtension &&
|
||||
sharedPath !== null &&
|
||||
changes.every((change) => !resizeHasPlaybackStartAdjustment(change)) &&
|
||||
sdkChanges.every((change) => change !== null);
|
||||
if (canUseSdk) {
|
||||
const handled = await sdkTimingBatchPersist(
|
||||
sdkChanges.filter((change): change is NonNullable<typeof change> => change !== null),
|
||||
sharedPath,
|
||||
sdkSession,
|
||||
{
|
||||
editHistory: { recordEdit },
|
||||
writeProjectFile,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
compositionPath: activeCompPath,
|
||||
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
|
||||
},
|
||||
{ label: "Resize timeline clips", coalesceKey },
|
||||
);
|
||||
if (handled) return;
|
||||
}
|
||||
|
||||
await persistServerBatch(
|
||||
projectId,
|
||||
"Resize timeline clips",
|
||||
changes.map((change) => ({
|
||||
element: change.element,
|
||||
buildPatches: (original, target) =>
|
||||
buildTimelineResizeTimingPatch(original, target, change.element, {
|
||||
start: change.start,
|
||||
duration: change.duration,
|
||||
playbackStart: change.playbackStart,
|
||||
}),
|
||||
})),
|
||||
coalesceKey,
|
||||
);
|
||||
await finishTimelineTimingFallback({
|
||||
iframe: previewIframeRef.current,
|
||||
needsExtension,
|
||||
rootDurationSeconds: maxEnd,
|
||||
reloadPreview,
|
||||
gsapMutation: () =>
|
||||
foldGsapMutationIntoHistory({
|
||||
projectId,
|
||||
paths: changes.map((change) => targetPathFor(change.element, activeCompPath)),
|
||||
label: "Resize timeline clips",
|
||||
coalesceKey,
|
||||
recordEdit,
|
||||
gsapMutation: async () => {
|
||||
let mutated = false;
|
||||
for (const change of changes) {
|
||||
const domId = change.element.domId;
|
||||
const timingChanged =
|
||||
change.start !== change.element.start ||
|
||||
change.duration !== change.element.duration;
|
||||
if (!timingChanged || !domId) continue;
|
||||
const status = await scaleGsapPositions(
|
||||
projectId,
|
||||
targetPathFor(change.element, activeCompPath),
|
||||
domId,
|
||||
change.element.start,
|
||||
change.element.duration,
|
||||
change.start,
|
||||
change.duration,
|
||||
);
|
||||
mutated = mutated || status.mutated;
|
||||
}
|
||||
return { mutated };
|
||||
},
|
||||
}),
|
||||
onGsapError: (err) => console.error("[Timeline] Failed to scale GSAP positions", err),
|
||||
});
|
||||
});
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
domEditSaveTimestampRef,
|
||||
enqueueGroupOperation,
|
||||
persistServerBatch,
|
||||
previewIframeRef,
|
||||
projectIdRef,
|
||||
recordEdit,
|
||||
reloadPreview,
|
||||
sdkSession,
|
||||
writeProjectFile,
|
||||
],
|
||||
);
|
||||
|
||||
return { handleTimelineGroupMove, handleTimelineGroupResize };
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TimelineElement } from "../player";
|
||||
import type { DomEditSelection } from "../components/editor/domEditing";
|
||||
import { installReactActEnvironment, makeSelection } from "./domSelectionTestHarness";
|
||||
import { useTimelineSelectionPreviewSync } from "./useTimelineSelectionPreviewSync";
|
||||
|
||||
installReactActEnvironment();
|
||||
|
||||
interface HarnessProps {
|
||||
selectedElementId: string | null;
|
||||
selectedElementIds: Set<string>;
|
||||
timelineElements: TimelineElement[];
|
||||
domEditSelection: DomEditSelection | null;
|
||||
domEditGroupSelections: DomEditSelection[];
|
||||
buildDomSelectionForTimelineElement: (
|
||||
element: TimelineElement,
|
||||
) => Promise<DomEditSelection | null>;
|
||||
applyDomSelection: (
|
||||
selection: DomEditSelection | null,
|
||||
options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
|
||||
) => void;
|
||||
applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
function renderHarness() {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
function Harness(nextProps: HarnessProps) {
|
||||
useTimelineSelectionPreviewSync({
|
||||
...nextProps,
|
||||
activeCompPath: "index.html",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const rerender = async (nextProps: HarnessProps) => {
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness, nextProps));
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
rerender,
|
||||
cleanup: () => {
|
||||
act(() => root.unmount());
|
||||
host.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeSyncFixture() {
|
||||
const firstElement = document.createElement("div");
|
||||
firstElement.id = "clip-1";
|
||||
const secondElement = document.createElement("div");
|
||||
secondElement.id = "clip-2";
|
||||
const firstSelection = makeSelection("First", firstElement);
|
||||
const secondSelection = makeSelection("Second", secondElement);
|
||||
const timelineElements: TimelineElement[] = [
|
||||
{ id: "clip-1", tag: "div", start: 0, duration: 1, track: 0 },
|
||||
{ id: "clip-2", tag: "div", start: 1, duration: 1, track: 1 },
|
||||
];
|
||||
const selectionById = new Map([
|
||||
["clip-1", firstSelection],
|
||||
["clip-2", secondSelection],
|
||||
]);
|
||||
return { firstSelection, secondSelection, timelineElements, selectionById };
|
||||
}
|
||||
|
||||
describe("useTimelineSelectionPreviewSync", () => {
|
||||
it("syncs a multi-id timeline selection into preview group selections", async () => {
|
||||
const { firstSelection, secondSelection, timelineElements, selectionById } = makeSyncFixture();
|
||||
const applyDomSelection = vi.fn();
|
||||
const applyMarqueeSelection = vi.fn();
|
||||
const buildDomSelectionForTimelineElement = vi.fn(async (element: TimelineElement) => {
|
||||
return selectionById.get(element.id) ?? null;
|
||||
});
|
||||
const harness = renderHarness();
|
||||
|
||||
await harness.rerender({
|
||||
selectedElementId: "clip-2",
|
||||
selectedElementIds: new Set(["clip-1", "clip-2"]),
|
||||
timelineElements,
|
||||
domEditSelection: null,
|
||||
domEditGroupSelections: [],
|
||||
buildDomSelectionForTimelineElement,
|
||||
applyDomSelection,
|
||||
applyMarqueeSelection,
|
||||
});
|
||||
|
||||
expect(applyMarqueeSelection).toHaveBeenCalledWith([secondSelection, firstSelection], false);
|
||||
expect(applyDomSelection).not.toHaveBeenCalled();
|
||||
harness.cleanup();
|
||||
});
|
||||
|
||||
it("clears preview selection when the timeline selection set is empty", async () => {
|
||||
const { firstSelection, timelineElements, selectionById } = makeSyncFixture();
|
||||
const applyDomSelection = vi.fn();
|
||||
const applyMarqueeSelection = vi.fn();
|
||||
const harness = renderHarness();
|
||||
|
||||
await harness.rerender({
|
||||
selectedElementId: null,
|
||||
selectedElementIds: new Set(),
|
||||
timelineElements,
|
||||
domEditSelection: firstSelection,
|
||||
domEditGroupSelections: [firstSelection],
|
||||
buildDomSelectionForTimelineElement: vi.fn(async (element: TimelineElement) => {
|
||||
return selectionById.get(element.id) ?? null;
|
||||
}),
|
||||
applyDomSelection,
|
||||
applyMarqueeSelection,
|
||||
});
|
||||
|
||||
expect(applyDomSelection).toHaveBeenCalledWith(null, { revealPanel: false });
|
||||
expect(applyMarqueeSelection).not.toHaveBeenCalled();
|
||||
harness.cleanup();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import type { TimelineElement } from "../player";
|
||||
import type { DomEditSelection } from "../components/editor/domEditing";
|
||||
import { resolveTimelineIdForSelection } from "../utils/studioHelpers";
|
||||
|
||||
interface UseTimelineSelectionPreviewSyncParams {
|
||||
selectedElementId: string | null;
|
||||
selectedElementIds: Set<string>;
|
||||
timelineElements: TimelineElement[];
|
||||
domEditSelection: DomEditSelection | null;
|
||||
domEditGroupSelections: DomEditSelection[];
|
||||
activeCompPath: string | null;
|
||||
buildDomSelectionForTimelineElement: (
|
||||
element: TimelineElement,
|
||||
) => Promise<DomEditSelection | null>;
|
||||
applyDomSelection: (
|
||||
selection: DomEditSelection | null,
|
||||
options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
|
||||
) => void;
|
||||
applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
|
||||
}
|
||||
|
||||
function orderSelectedIds(ids: Set<string>, anchor: string | null): string[] {
|
||||
const ordered = [...ids];
|
||||
if (!anchor || !ids.has(anchor)) return ordered;
|
||||
return [anchor, ...ordered.filter((id) => id !== anchor)];
|
||||
}
|
||||
|
||||
function selectionIdsMatch(
|
||||
currentIds: string[],
|
||||
selectedIds: string[],
|
||||
currentAnchor: string | null,
|
||||
wantedAnchor: string | null,
|
||||
): boolean {
|
||||
// Compare as sets in BOTH directions: length equality misreads duplicates (two DOM
|
||||
// children resolving to the same clip id) as a full match and skips mirroring the
|
||||
// members that never made it into the preview.
|
||||
const current = new Set(currentIds);
|
||||
const selected = new Set(selectedIds);
|
||||
if (current.size !== selected.size) return false;
|
||||
for (const id of selected) {
|
||||
if (!current.has(id)) return false;
|
||||
}
|
||||
// The primary/anchor must also agree, or a change of just the anchor within the
|
||||
// same set would never re-sync the preview's primary selection.
|
||||
return currentAnchor === wantedAnchor;
|
||||
}
|
||||
|
||||
export function useTimelineSelectionPreviewSync({
|
||||
selectedElementId,
|
||||
selectedElementIds,
|
||||
timelineElements,
|
||||
domEditSelection,
|
||||
domEditGroupSelections,
|
||||
activeCompPath,
|
||||
buildDomSelectionForTimelineElement,
|
||||
applyDomSelection,
|
||||
applyMarqueeSelection,
|
||||
}: UseTimelineSelectionPreviewSyncParams): void {
|
||||
const selectedIds = useMemo(
|
||||
() => orderSelectedIds(selectedElementIds, selectedElementId),
|
||||
[selectedElementId, selectedElementIds],
|
||||
);
|
||||
const selectedKey = selectedIds.join("\0");
|
||||
|
||||
useEffect(() => {
|
||||
const currentSelections =
|
||||
domEditGroupSelections.length > 1
|
||||
? domEditGroupSelections
|
||||
: domEditSelection
|
||||
? [domEditSelection]
|
||||
: [];
|
||||
const currentIds = currentSelections
|
||||
.map((selection) =>
|
||||
resolveTimelineIdForSelection(selection, timelineElements, activeCompPath),
|
||||
)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
const currentAnchor = domEditSelection
|
||||
? resolveTimelineIdForSelection(domEditSelection, timelineElements, activeCompPath)
|
||||
: null;
|
||||
|
||||
if (selectedIds.length === 0) {
|
||||
if (currentSelections.length > 0) applyDomSelection(null, { revealPanel: false });
|
||||
return;
|
||||
}
|
||||
if (selectionIdsMatch(currentIds, selectedIds, currentAnchor, selectedElementId)) return;
|
||||
|
||||
let cancelled = false;
|
||||
const syncSelection = async () => {
|
||||
const selections: DomEditSelection[] = [];
|
||||
let resolvableCount = 0;
|
||||
for (const id of selectedIds) {
|
||||
const element = timelineElements.find((item) => (item.key ?? item.id) === id);
|
||||
if (!element) continue;
|
||||
resolvableCount += 1;
|
||||
const selection = await buildDomSelectionForTimelineElement(element);
|
||||
if (selection) selections.push(selection);
|
||||
}
|
||||
if (cancelled) return;
|
||||
// The store is the source of truth: applying a partial set would write that
|
||||
// shrunk set back and silently drop the members whose DOM node was not ready.
|
||||
// Bail instead; a later effect run (on timelineElements/DOM change) applies the
|
||||
// full set once every resolvable member has a live node.
|
||||
if (selections.length < resolvableCount) return;
|
||||
if (selections.length === 0) {
|
||||
applyDomSelection(null, { revealPanel: false });
|
||||
} else if (selections.length === 1) {
|
||||
applyDomSelection(selections[0], { revealPanel: false });
|
||||
} else {
|
||||
applyMarqueeSelection(selections, false);
|
||||
}
|
||||
};
|
||||
|
||||
void syncSelection();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
activeCompPath,
|
||||
applyDomSelection,
|
||||
applyMarqueeSelection,
|
||||
buildDomSelectionForTimelineElement,
|
||||
domEditGroupSelections,
|
||||
domEditSelection,
|
||||
selectedElementId,
|
||||
selectedIds,
|
||||
selectedKey,
|
||||
timelineElements,
|
||||
]);
|
||||
}
|
||||
@@ -203,6 +203,40 @@ describe("Timeline provider boundary", () => {
|
||||
expect(onSeek).not.toHaveBeenCalled();
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("marks every clip in selectedElementIds as selected", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
Object.defineProperty(host, "clientWidth", {
|
||||
configurable: true,
|
||||
value: 720,
|
||||
});
|
||||
|
||||
usePlayerStore.setState({
|
||||
duration: 6,
|
||||
timelineReady: true,
|
||||
selectedElementId: "clip-2",
|
||||
selectedElementIds: new Set(["clip-1", "clip-2"]),
|
||||
elements: [
|
||||
{ id: "clip-1", tag: "div", start: 0, duration: 1, track: 0 },
|
||||
{ id: "clip-2", tag: "div", start: 1.5, duration: 1, track: 1 },
|
||||
{ id: "clip-3", tag: "div", start: 3, duration: 1, track: 2 },
|
||||
],
|
||||
});
|
||||
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(React.createElement(Timeline));
|
||||
});
|
||||
|
||||
const selectedClips = host.querySelectorAll(".timeline-clip.is-selected");
|
||||
expect(selectedClips).toHaveLength(2);
|
||||
expect(host.querySelector('[data-el-id="clip-3"]')?.classList.contains("is-selected")).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateTicks", () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { EditPopover } from "./EditModal";
|
||||
import { defaultTimelineTheme } from "./timelineTheme";
|
||||
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
|
||||
import { useTimelineMarqueeSelection } from "./useTimelineMarqueeSelection";
|
||||
import { useTimelinePlayhead } from "./useTimelinePlayhead";
|
||||
import { useTimelineActiveClips } from "./useTimelineActiveClips";
|
||||
import { type TrackVisualStyle, getTrackStyle } from "./timelineIcons";
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
type KeyframeDiamondContextMenuState,
|
||||
} from "./KeyframeDiamondContextMenu";
|
||||
import { useTimelineClipDrag } from "./useTimelineClipDrag";
|
||||
import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers";
|
||||
import { ClipContextMenu } from "./ClipContextMenu";
|
||||
import { TimelineShortcutHint } from "./TimelineShortcutHint";
|
||||
import { buildStackingTimelineLayers, insertPreviewTrackOrder } from "./timelineTrackOrder";
|
||||
@@ -36,7 +38,6 @@ import {
|
||||
import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
|
||||
import type { TimelineProps } from "./TimelineTypes";
|
||||
|
||||
// Re-export pure utilities so existing imports from "./Timeline" still resolve.
|
||||
export {
|
||||
generateTicks,
|
||||
formatTimelineTickLabel,
|
||||
@@ -70,6 +71,10 @@ export const Timeline = memo(function Timeline({
|
||||
const {
|
||||
onMoveElement,
|
||||
onResizeElement,
|
||||
onMoveElements,
|
||||
onResizeElements,
|
||||
onPreviewMoveElements,
|
||||
onPreviewResizeElements,
|
||||
onBlockedEditAttempt,
|
||||
onSplitElement,
|
||||
onRazorSplitAll,
|
||||
@@ -103,14 +108,12 @@ export const Timeline = memo(function Timeline({
|
||||
const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
|
||||
|
||||
const playheadRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const activeTool = usePlayerStore((s) => s.activeTool);
|
||||
const [hoveredClip, setHoveredClip] = useState<string | null>(null);
|
||||
const isDragging = useRef(false);
|
||||
const [shiftHeld, setShiftHeld] = useState(false);
|
||||
const [razorGuideX, setRazorGuideX] = useState<number | null>(null);
|
||||
|
||||
useMountEffect(() => {
|
||||
const down = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(true);
|
||||
const up = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(false);
|
||||
@@ -136,7 +139,6 @@ export const Timeline = memo(function Timeline({
|
||||
const [viewportWidth, setViewportWidth] = useState(0);
|
||||
const roRef = useRef<ResizeObserver | null>(null);
|
||||
const shortcutHintRafRef = useRef(0);
|
||||
|
||||
const syncShortcutHintVisibility = useCallback(() => {
|
||||
const scroll = scrollRef.current;
|
||||
setShowShortcutHint(
|
||||
@@ -152,10 +154,6 @@ export const Timeline = memo(function Timeline({
|
||||
});
|
||||
}, [syncShortcutHintVisibility]);
|
||||
|
||||
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
containerRef.current = el;
|
||||
}, []);
|
||||
|
||||
const setScrollRef = useCallback(
|
||||
(el: HTMLDivElement | null) => {
|
||||
if (roRef.current) {
|
||||
@@ -206,7 +204,6 @@ export const Timeline = memo(function Timeline({
|
||||
const ppsRef = useRef(100);
|
||||
const durationRef = useRef(Number.isFinite(duration) ? duration : 0);
|
||||
|
||||
// Stable ref so useTimelineClipDrag can clear rangeSelection without circular dep
|
||||
const setRangeSelectionRef = useRef<((sel: null) => void) | null>(null);
|
||||
|
||||
const {
|
||||
@@ -225,12 +222,15 @@ export const Timeline = memo(function Timeline({
|
||||
timelineElementsRef: expandedElementsRef,
|
||||
onMoveElement,
|
||||
onResizeElement,
|
||||
onMoveElements,
|
||||
onResizeElements,
|
||||
onPreviewMoveElements,
|
||||
onPreviewResizeElements,
|
||||
onBlockedEditAttempt,
|
||||
setShowPopover,
|
||||
setRangeSelectionRef,
|
||||
});
|
||||
|
||||
// basis drives the zoom (committed); effective adds the live preview (see timelineLayout).
|
||||
const basisDuration = useMemo(
|
||||
() =>
|
||||
computeTimelineBasisDuration(
|
||||
@@ -269,6 +269,15 @@ export const Timeline = memo(function Timeline({
|
||||
const keyframeCache = usePlayerStore((s) => s.keyframeCache);
|
||||
const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes);
|
||||
const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
|
||||
const keyframeHandlers = useTimelineKeyframeHandlers({
|
||||
expandedElements,
|
||||
keyframeCache,
|
||||
onSelectElement,
|
||||
onSeek,
|
||||
setSelectedElementId,
|
||||
setKfContextMenu,
|
||||
toggleSelectedKeyframe,
|
||||
});
|
||||
|
||||
const selectedElement = useMemo(
|
||||
() =>
|
||||
@@ -278,7 +287,6 @@ export const Timeline = memo(function Timeline({
|
||||
const selectedElementRef = useRef<TimelineElement | null>(selectedElement);
|
||||
selectedElementRef.current = selectedElement;
|
||||
|
||||
// Fit to basisDuration, not effectiveDuration, so a live drag can't rezoom.
|
||||
const fitPps =
|
||||
viewportWidth > GUTTER && basisDuration > 0
|
||||
? (viewportWidth - GUTTER - 2) / basisDuration
|
||||
@@ -346,8 +354,27 @@ export const Timeline = memo(function Timeline({
|
||||
isDragging,
|
||||
setShowPopover,
|
||||
});
|
||||
// Wire setRangeSelection into the stable ref consumed by useTimelineClipDrag
|
||||
const {
|
||||
marqueeRect,
|
||||
handlePointerDown: handleMarqueePointerDown,
|
||||
handlePointerMove: handleMarqueePointerMove,
|
||||
handlePointerUp: handleMarqueePointerUp,
|
||||
} = useTimelineMarqueeSelection({
|
||||
scrollRef,
|
||||
ppsRef,
|
||||
trackOrderRef,
|
||||
timelineLayersRef,
|
||||
disabled: activeTool === "razor",
|
||||
setShowPopover,
|
||||
setRangeSelectionRef,
|
||||
seekFromX,
|
||||
});
|
||||
setRangeSelectionRef.current = setRangeSelection;
|
||||
// Pointer-up and lost-capture end a gesture identically (marquee-claims-first).
|
||||
const releasePointer = (event: Parameters<typeof handleMarqueePointerUp>[0]) => {
|
||||
if (handleMarqueePointerUp(event)) return;
|
||||
handlePointerUp();
|
||||
};
|
||||
|
||||
const prevSelectedRef = useRef(selectedElementRef.current);
|
||||
// eslint-disable-next-line no-restricted-syntax, react-hooks/exhaustive-deps
|
||||
@@ -414,7 +441,6 @@ export const Timeline = memo(function Timeline({
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setContainerRef}
|
||||
aria-label="Timeline"
|
||||
className={`relative border-t select-none h-full overflow-hidden ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
|
||||
onMouseMove={(e) => {
|
||||
@@ -445,11 +471,15 @@ export const Timeline = memo(function Timeline({
|
||||
onRazorSplitAll?.(splitTime);
|
||||
return;
|
||||
}
|
||||
if (handleMarqueePointerDown(e)) return;
|
||||
handlePointerDown(e);
|
||||
}}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onLostPointerCapture={handlePointerUp}
|
||||
onPointerMove={(e) => {
|
||||
if (handleMarqueePointerMove(e)) return;
|
||||
handlePointerMove(e);
|
||||
}}
|
||||
onPointerUp={releasePointer}
|
||||
onLostPointerCapture={releasePointer}
|
||||
>
|
||||
<TimelineCanvas
|
||||
major={major}
|
||||
@@ -460,6 +490,7 @@ export const Timeline = memo(function Timeline({
|
||||
effectiveDuration={effectiveDuration}
|
||||
majorTickInterval={majorTickInterval}
|
||||
rangeSelection={rangeSelection}
|
||||
marqueeRect={marqueeRect}
|
||||
theme={theme}
|
||||
displayTrackOrder={displayTrackOrder}
|
||||
trackOrder={trackOrder}
|
||||
@@ -491,41 +522,10 @@ export const Timeline = memo(function Timeline({
|
||||
selectedKeyframes={selectedKeyframes}
|
||||
currentTime={currentTime}
|
||||
beatAnalysis={adjustedBeatAnalysis}
|
||||
onClickKeyframe={(el, pct) => {
|
||||
usePlayerStore.getState().clearSelectedKeyframes();
|
||||
const elKey = el.key ?? el.id;
|
||||
setSelectedElementId(elKey);
|
||||
onSelectElement?.(el);
|
||||
// Visually select the clicked diamond (matches shift-click / motion-path
|
||||
// selection); cleared above so this single-selects it.
|
||||
toggleSelectedKeyframe(`${elKey}:${pct}`);
|
||||
const absTime = el.start + (pct / 100) * el.duration;
|
||||
onSeek?.(absTime);
|
||||
const kfData = keyframeCache?.get(elKey);
|
||||
const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.5);
|
||||
usePlayerStore.getState().setActiveKeyframePct(kf?.tweenPercentage ?? null);
|
||||
}}
|
||||
onShiftClickKeyframe={(elId, pct) => {
|
||||
toggleSelectedKeyframe(`${elId}:${pct}`);
|
||||
}}
|
||||
onClickKeyframe={keyframeHandlers.onClickKeyframe}
|
||||
onShiftClickKeyframe={keyframeHandlers.onShiftClickKeyframe}
|
||||
onMoveKeyframe={onMoveKeyframe}
|
||||
onContextMenuKeyframe={(e, elId, pct) => {
|
||||
const el = expandedElements.find((x) => (x.key ?? x.id) === elId);
|
||||
if (el) {
|
||||
setSelectedElementId(elId);
|
||||
onSelectElement?.(el);
|
||||
}
|
||||
const kfData = keyframeCache.get(elId);
|
||||
const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
|
||||
setKfContextMenu({
|
||||
x: e.clientX + 4,
|
||||
y: e.clientY + 2,
|
||||
elementId: elId,
|
||||
percentage: pct,
|
||||
tweenPercentage: kf?.tweenPercentage,
|
||||
currentEase: kf?.ease ?? kfData?.ease,
|
||||
});
|
||||
}}
|
||||
onContextMenuKeyframe={keyframeHandlers.onContextMenuKeyframe}
|
||||
onContextMenuClip={(e, el) => {
|
||||
e.preventDefault();
|
||||
setSelectedElementId(el.key ?? el.id);
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type TimelineRangeSelection,
|
||||
} from "./timelineEditing";
|
||||
import { getRenderedTimelineElement, type TimelineTheme } from "./timelineTheme";
|
||||
import { GUTTER, TRACK_H, RULER_H, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout";
|
||||
import { GUTTER, TRACK_H, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout";
|
||||
import {
|
||||
usePlayerStore,
|
||||
type TimelineElement,
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
import { resolveTimelineDropIndicator } from "./timelineDropIndicator";
|
||||
import { TimelineDropInsertionLine } from "./TimelineDropInsertionLine";
|
||||
import { TimelineDragGhost } from "./TimelineDragGhost";
|
||||
import { TimelineSelectionOverlays } from "./TimelineSelectionOverlays";
|
||||
import type { TimelineMarqueeOverlayRect } from "./useTimelineMarqueeSelection";
|
||||
|
||||
function ClipLintDot({ element }: { element: TimelineElement }) {
|
||||
const lint = usePlayerStore((s) => s.lintFindingsByElement.get(element.key ?? element.id));
|
||||
@@ -54,6 +56,7 @@ interface TimelineCanvasProps {
|
||||
effectiveDuration: number;
|
||||
majorTickInterval: number;
|
||||
rangeSelection: TimelineRangeSelection | null;
|
||||
marqueeRect: TimelineMarqueeOverlayRect | null;
|
||||
theme: TimelineTheme;
|
||||
displayTrackOrder: TimelineLayerId[];
|
||||
trackOrder: TimelineLayerId[];
|
||||
@@ -112,6 +115,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
||||
effectiveDuration,
|
||||
majorTickInterval,
|
||||
rangeSelection,
|
||||
marqueeRect,
|
||||
theme,
|
||||
displayTrackOrder,
|
||||
trackOrder,
|
||||
@@ -158,6 +162,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
||||
onRazorSplitAll,
|
||||
} = useTimelineEditContextOptional();
|
||||
const beatDragging = usePlayerStore((s) => s.beatDragging);
|
||||
const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
|
||||
const activeSnapGuideTime = draggedClip?.started
|
||||
? (draggedClip.snapBeatTime ?? draggedClip.snapGuideTime)
|
||||
: resizingClip?.started
|
||||
@@ -359,7 +364,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
||||
const clipStyle = getTrackStyle(el.tag);
|
||||
const elementKey = el.key ?? el.id;
|
||||
const capabilities = getTimelineEditCapabilities(el);
|
||||
const isSelected = selectedElementId === elementKey;
|
||||
const isSelected = selectedElementIds.has(elementKey);
|
||||
const isComposition = !!el.compositionSrc;
|
||||
// elementKey (el.key ?? el.id) is already unique per clip; do NOT
|
||||
// fold in the map index, or a splice/reorder remounts every clip
|
||||
@@ -560,22 +565,12 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
||||
</TimelineDragGhost>
|
||||
)}
|
||||
|
||||
{/* Range highlight */}
|
||||
{rangeSelection && (
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
left: GUTTER + Math.min(rangeSelection.start, rangeSelection.end) * pps,
|
||||
width: Math.abs(rangeSelection.end - rangeSelection.start) * pps,
|
||||
top: RULER_H,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(59, 130, 246, 0.12)",
|
||||
borderLeft: "1px solid rgba(59, 130, 246, 0.4)",
|
||||
borderRight: "1px solid rgba(59, 130, 246, 0.4)",
|
||||
zIndex: 50,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<TimelineSelectionOverlays
|
||||
rangeSelection={rangeSelection}
|
||||
marqueeRect={marqueeRect}
|
||||
pps={pps}
|
||||
accentColor={getTrackStyle("").accent}
|
||||
/>
|
||||
|
||||
{/* Playhead — hidden while dragging a beat so its guideline doesn't
|
||||
track the scrub and clutter the beat being moved. */}
|
||||
|
||||
@@ -102,4 +102,15 @@ describe("TimelineClip", () => {
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("applies selected styling when rendered as selected", () => {
|
||||
const { host, root } = renderClip({
|
||||
element: { id: "selected", label: "Selected", tag: "div", start: 0, duration: 1, track: 0 },
|
||||
isSelected: true,
|
||||
});
|
||||
|
||||
expect(host.querySelector(".timeline-clip")?.classList.contains("is-selected")).toBe(true);
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { TimelineTheme } from "./timelineTheme";
|
||||
import { GUTTER } from "./timelineLayout";
|
||||
import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
|
||||
|
||||
const TIMELINE_LAYER_GROUP_HEADER_H = 18;
|
||||
export const TIMELINE_LAYER_GROUP_HEADER_H = 18;
|
||||
|
||||
export function shouldShowTimelineLayerGroupHeader(
|
||||
contextKey: string,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { TimelineRangeSelection } from "./timelineEditing";
|
||||
import { GUTTER, RULER_H } from "./timelineLayout";
|
||||
import type { TimelineMarqueeOverlayRect } from "./useTimelineMarqueeSelection";
|
||||
|
||||
interface TimelineSelectionOverlaysProps {
|
||||
rangeSelection: TimelineRangeSelection | null;
|
||||
marqueeRect: TimelineMarqueeOverlayRect | null;
|
||||
pps: number;
|
||||
/** Primary/accent color (hex) shared with the rest of the timeline chrome. */
|
||||
accentColor: string;
|
||||
}
|
||||
|
||||
export function TimelineSelectionOverlays({
|
||||
rangeSelection,
|
||||
marqueeRect,
|
||||
pps,
|
||||
accentColor,
|
||||
}: TimelineSelectionOverlaysProps) {
|
||||
return (
|
||||
<>
|
||||
{rangeSelection && (
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
left: GUTTER + Math.min(rangeSelection.start, rangeSelection.end) * pps,
|
||||
width: Math.abs(rangeSelection.end - rangeSelection.start) * pps,
|
||||
top: RULER_H,
|
||||
bottom: 0,
|
||||
backgroundColor: `${accentColor}1f`,
|
||||
borderLeft: `1px solid ${accentColor}`,
|
||||
borderRight: `1px solid ${accentColor}`,
|
||||
zIndex: 50,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{marqueeRect && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute pointer-events-none"
|
||||
data-timeline-marquee="true"
|
||||
style={{
|
||||
left: marqueeRect.left,
|
||||
top: marqueeRect.top,
|
||||
width: marqueeRect.width,
|
||||
height: marqueeRect.height,
|
||||
backgroundColor: `${accentColor}29`,
|
||||
border: `1px solid ${accentColor}`,
|
||||
boxShadow: "0 0 0 1px rgba(15, 23, 42, 0.35)",
|
||||
zIndex: 60,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,11 @@
|
||||
// fallow-ignore-file dead-code
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { BlockedTimelineEditIntent, TimelineStackingReorderIntent } from "./timelineEditing";
|
||||
import type {
|
||||
TimelineGroupCommitOptions,
|
||||
TimelineGroupMoveChange,
|
||||
TimelineGroupResizeChange,
|
||||
} from "../../hooks/useTimelineGroupEditing";
|
||||
|
||||
/**
|
||||
* Shared callback signatures for timeline editing operations.
|
||||
@@ -34,6 +39,16 @@ export interface TimelineEditCallbacks {
|
||||
element: TimelineElement,
|
||||
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
||||
) => Promise<void> | void;
|
||||
onMoveElements?: (
|
||||
changes: TimelineGroupMoveChange[],
|
||||
options?: TimelineGroupCommitOptions,
|
||||
) => Promise<void> | void;
|
||||
onResizeElements?: (
|
||||
changes: TimelineGroupResizeChange[],
|
||||
options?: TimelineGroupCommitOptions,
|
||||
) => Promise<void> | void;
|
||||
onPreviewMoveElements?: (changes: TimelineGroupMoveChange[]) => void;
|
||||
onPreviewResizeElements?: (changes: TimelineGroupResizeChange[]) => void;
|
||||
onToggleTrackHidden?: (track: number, hidden: boolean) => Promise<void> | void;
|
||||
onToggleElementHidden?: (elementKey: string, hidden: boolean) => Promise<void> | void;
|
||||
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { TimelineStackingReorderIntent } from "./timelineEditing";
|
||||
import type { TimelineLayerId } from "./timelineTrackOrder";
|
||||
|
||||
interface DragPreviewState {
|
||||
element: Pick<TimelineElement, "track">;
|
||||
previewLayerId: TimelineLayerId;
|
||||
previewLayerIndex: number;
|
||||
}
|
||||
|
||||
export interface TimelineMovePreview {
|
||||
start: number;
|
||||
track: number;
|
||||
previewLayerId?: TimelineLayerId;
|
||||
previewLayerIndex?: number;
|
||||
stackingReorder?: TimelineStackingReorderIntent | null;
|
||||
}
|
||||
|
||||
export interface TimelineGroupMovePreview {
|
||||
active: boolean;
|
||||
previewStart: number;
|
||||
}
|
||||
|
||||
export function resolveDragPreviewPlacement(
|
||||
drag: DragPreviewState,
|
||||
nextMove: TimelineMovePreview,
|
||||
groupMove: TimelineGroupMovePreview,
|
||||
): {
|
||||
previewStart: number;
|
||||
previewTrack: number;
|
||||
previewLayerId: TimelineLayerId;
|
||||
previewLayerIndex: number;
|
||||
previewStackingReorder: TimelineStackingReorderIntent | null;
|
||||
} {
|
||||
if (groupMove.active) {
|
||||
return {
|
||||
previewStart: groupMove.previewStart,
|
||||
previewTrack: drag.element.track,
|
||||
previewLayerId: drag.previewLayerId,
|
||||
previewLayerIndex: drag.previewLayerIndex,
|
||||
previewStackingReorder: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
previewStart: groupMove.previewStart,
|
||||
previewTrack: nextMove.track,
|
||||
previewLayerId: nextMove.previewLayerId ?? drag.previewLayerId,
|
||||
previewLayerIndex: nextMove.previewLayerIndex ?? drag.previewLayerIndex,
|
||||
previewStackingReorder: nextMove.stackingReorder ?? null,
|
||||
};
|
||||
}
|
||||
@@ -4,12 +4,15 @@ import {
|
||||
buildPromptCopyText,
|
||||
buildTimelineElementAgentPrompt,
|
||||
buildTimelineAgentPrompt,
|
||||
clampTimelineGroupResizeDelta,
|
||||
getTimelineEditCapabilities,
|
||||
hasPatchableTimelineTarget,
|
||||
resolveBlockedTimelineEditIntent,
|
||||
resolveTimelineAutoScroll,
|
||||
resolveTimelineMove,
|
||||
resolveTimelineResize,
|
||||
resolveTimelineGroupMove,
|
||||
resolveTimelineGroupResize,
|
||||
snapKeyframePctToBeat,
|
||||
type TimelinePromptElement,
|
||||
} from "./timelineEditing";
|
||||
@@ -220,6 +223,159 @@ describe("resolveTimelineMove", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTimelineGroupMove", () => {
|
||||
it("applies an unclamped delta uniformly", () => {
|
||||
const result = resolveTimelineGroupMove(
|
||||
[
|
||||
{ start: 1, duration: 2 },
|
||||
{ start: 4, duration: 3 },
|
||||
],
|
||||
1.25,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
delta: 1.25,
|
||||
members: [
|
||||
{ start: 2.25, duration: 2 },
|
||||
{ start: 5.25, duration: 3 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps the whole group when the earliest start reaches zero", () => {
|
||||
const result = resolveTimelineGroupMove(
|
||||
[
|
||||
{ start: 1, duration: 2 },
|
||||
{ start: 5, duration: 3 },
|
||||
],
|
||||
-3,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
delta: -1,
|
||||
members: [
|
||||
{ start: 0, duration: 2 },
|
||||
{ start: 4, duration: 3 },
|
||||
],
|
||||
});
|
||||
expect(result.members[1]!.start - result.members[0]!.start).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTimelineGroupResize", () => {
|
||||
it("returns the shared clamped delta without applying per-member starts", () => {
|
||||
expect(
|
||||
clampTimelineGroupResizeDelta(
|
||||
1,
|
||||
[
|
||||
{ start: 1, duration: 0.5 },
|
||||
{ start: 4, duration: 2 },
|
||||
],
|
||||
"start",
|
||||
),
|
||||
).toBe(0.4);
|
||||
});
|
||||
|
||||
it("applies an unclamped start-edge delta uniformly", () => {
|
||||
const result = resolveTimelineGroupResize(
|
||||
[
|
||||
{ start: 1, duration: 3 },
|
||||
{ start: 5, duration: 4 },
|
||||
],
|
||||
"start",
|
||||
1,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
delta: 1,
|
||||
members: [
|
||||
{ start: 2, duration: 2, playbackStart: undefined },
|
||||
{ start: 6, duration: 3, playbackStart: undefined },
|
||||
],
|
||||
});
|
||||
expect(result.members[1]!.start - result.members[0]!.start).toBe(4);
|
||||
});
|
||||
|
||||
it("clamps a start-edge delta when the earliest member reaches zero", () => {
|
||||
const result = resolveTimelineGroupResize(
|
||||
[
|
||||
{ start: 0.5, duration: 3 },
|
||||
{ start: 4, duration: 4 },
|
||||
],
|
||||
"start",
|
||||
-2,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
delta: -0.5,
|
||||
members: [
|
||||
{ start: 0, duration: 3.5, playbackStart: undefined },
|
||||
{ start: 3.5, duration: 4.5, playbackStart: undefined },
|
||||
],
|
||||
});
|
||||
expect(result.members[1]!.start - result.members[0]!.start).toBe(3.5);
|
||||
});
|
||||
|
||||
it("clamps a start-edge delta when any member reaches minimum duration", () => {
|
||||
const result = resolveTimelineGroupResize(
|
||||
[
|
||||
{ start: 1, duration: 0.5 },
|
||||
{ start: 4, duration: 2 },
|
||||
],
|
||||
"start",
|
||||
1,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
delta: 0.4,
|
||||
members: [
|
||||
{ start: 1.4, duration: 0.1, playbackStart: undefined },
|
||||
{ start: 4.4, duration: 1.6, playbackStart: undefined },
|
||||
],
|
||||
});
|
||||
expect(result.members[1]!.start - result.members[0]!.start).toBeCloseTo(3);
|
||||
});
|
||||
|
||||
it("clamps an end-edge delta when any member reaches minimum duration", () => {
|
||||
const result = resolveTimelineGroupResize(
|
||||
[
|
||||
{ start: 1, duration: 0.5 },
|
||||
{ start: 4, duration: 2 },
|
||||
],
|
||||
"end",
|
||||
-1,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
delta: -0.4,
|
||||
members: [
|
||||
{ start: 1, duration: 0.1, playbackStart: undefined },
|
||||
{ start: 4, duration: 1.6, playbackStart: undefined },
|
||||
],
|
||||
});
|
||||
expect(result.members[1]!.start - result.members[0]!.start).toBe(3);
|
||||
});
|
||||
|
||||
it("adjusts each start-edge playback start using the shared delta", () => {
|
||||
const result = resolveTimelineGroupResize(
|
||||
[
|
||||
{ start: 2, duration: 3, playbackStart: 1, playbackRate: 1 },
|
||||
{ start: 5, duration: 4, playbackStart: 2, playbackRate: 2 },
|
||||
],
|
||||
"start",
|
||||
0.5,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
delta: 0.5,
|
||||
members: [
|
||||
{ start: 2.5, duration: 2.5, playbackStart: 1.5 },
|
||||
{ start: 5.5, duration: 3.5, playbackStart: 3 },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasPatchableTimelineTarget", () => {
|
||||
it("returns true when the clip has a DOM id", () => {
|
||||
expect(hasPatchableTimelineTarget({ domId: "hero-card" })).toBe(true);
|
||||
@@ -537,7 +693,7 @@ describe("buildTimelineAgentPrompt", () => {
|
||||
prompt: "Move the title later and lower the music",
|
||||
});
|
||||
|
||||
expect(text).toContain("Time range: 0:01 — 0:04");
|
||||
expect(text).toContain("Time range: 0:01 - 0:04");
|
||||
expect(text).toContain("#title (div)");
|
||||
expect(text).toContain("#music (audio)");
|
||||
expect(text).toContain("Move the title later and lower the music");
|
||||
|
||||
@@ -2,8 +2,23 @@ import { formatTime } from "../lib/time";
|
||||
import { roundToCenti } from "../../utils/rounding";
|
||||
import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
|
||||
import { resolveTimelineLayerStackingMove } from "./timelineLayerDrag";
|
||||
import { shouldShowTimelineLayerGroupHeader } from "./TimelineLayerGroupHeader";
|
||||
import type { TimelineStackingElement, TimelineStackingReorderIntent } from "./timelineStacking";
|
||||
|
||||
import {
|
||||
applyClipStartTrimDelta,
|
||||
clipStartTrimDeltaBounds,
|
||||
resolveTimelineMinDuration,
|
||||
} from "./timelineGroupEditing";
|
||||
|
||||
export {
|
||||
clampTimelineGroupResizeDelta,
|
||||
resolveTimelineGroupMove,
|
||||
resolveTimelineGroupResize,
|
||||
type TimelineGroupResizeEdge,
|
||||
type TimelineGroupTimingMember,
|
||||
} from "./timelineGroupEditing";
|
||||
|
||||
export {
|
||||
type TimelineStackingElement,
|
||||
type TimelineStackingReorderIntent,
|
||||
@@ -109,7 +124,7 @@ export function resolveTimelineMove(
|
||||
|
||||
// Stacking mode: the two axes never fight. Horizontal movement writes time
|
||||
// (nextStart); vertical movement writes z-index. Lane/overlap resolution
|
||||
// uses the clip's authored time span, NOT the dragged start — otherwise a
|
||||
// uses the clip's authored time span, NOT the dragged start, otherwise a
|
||||
// diagonal drag that drifts the clip out of overlap silently flips the
|
||||
// placement from "restack" to "join lane" and cancels the reorder.
|
||||
if (input.stackingElement) {
|
||||
@@ -194,7 +209,7 @@ export function resolveTimelineResize(
|
||||
edge: "start" | "end",
|
||||
clientX: number,
|
||||
): { start: number; duration: number; playbackStart?: number } {
|
||||
const minDuration = Math.max(0.05, input.minDuration ?? 0.1);
|
||||
const minDuration = resolveTimelineMinDuration(input.minDuration);
|
||||
const deltaTime = (clientX - input.originClientX) / Math.max(input.pixelsPerSecond, 1);
|
||||
|
||||
if (edge === "end") {
|
||||
@@ -210,23 +225,15 @@ export function resolveTimelineResize(
|
||||
};
|
||||
}
|
||||
|
||||
const playbackRate = Math.max(0.1, input.playbackRate ?? 1);
|
||||
const maxLeftExtensionFromMedia =
|
||||
input.playbackStart != null ? input.playbackStart / playbackRate : Number.POSITIVE_INFINITY;
|
||||
const minDelta = -Math.min(input.start - input.minStart, maxLeftExtensionFromMedia);
|
||||
const maxDelta = input.duration - minDuration;
|
||||
const { minDelta, maxDelta } = clipStartTrimDeltaBounds(input, input.minStart, minDuration);
|
||||
const clampedDelta = clamp(deltaTime, minDelta, maxDelta);
|
||||
const nextStart = roundToCentiseconds(input.start + clampedDelta);
|
||||
const nextDuration = roundToCentiseconds(input.duration - clampedDelta);
|
||||
const nextPlaybackStart =
|
||||
input.playbackStart != null
|
||||
? roundToCentiseconds(Math.max(0, input.playbackStart + clampedDelta * playbackRate))
|
||||
: undefined;
|
||||
const trimmed = applyClipStartTrimDelta(input, clampedDelta);
|
||||
|
||||
return {
|
||||
start: nextStart,
|
||||
duration: nextDuration,
|
||||
playbackStart: nextPlaybackStart,
|
||||
start: roundToCentiseconds(trimmed.start),
|
||||
duration: roundToCentiseconds(trimmed.duration),
|
||||
playbackStart:
|
||||
trimmed.playbackStart != null ? roundToCentiseconds(trimmed.playbackStart) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -253,6 +260,108 @@ export interface TimelineRangeSelection {
|
||||
anchorY: number;
|
||||
}
|
||||
|
||||
export interface TimelineMarqueeSelectionRect {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
export interface TimelineMarqueeSelectionInput {
|
||||
rect: TimelineMarqueeSelectionRect;
|
||||
layers: readonly StackingTimelineLayer[];
|
||||
layerOrder: readonly TimelineLayerId[];
|
||||
rulerHeight: number;
|
||||
trackHeight: number;
|
||||
groupHeaderHeight?: number;
|
||||
}
|
||||
|
||||
interface NormalizedTimelineMarqueeRect {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
function timelineIntervalsOverlap(
|
||||
startA: number,
|
||||
endA: number,
|
||||
startB: number,
|
||||
endB: number,
|
||||
): boolean {
|
||||
return startA < endB && startB < endA;
|
||||
}
|
||||
|
||||
function normalizeTimelineMarqueeRect(
|
||||
rect: TimelineMarqueeSelectionRect,
|
||||
): NormalizedTimelineMarqueeRect | null {
|
||||
const normalized = {
|
||||
startTime: Math.max(0, Math.min(rect.startTime, rect.endTime)),
|
||||
endTime: Math.max(0, Math.max(rect.startTime, rect.endTime)),
|
||||
top: Math.min(rect.top, rect.bottom),
|
||||
bottom: Math.max(rect.top, rect.bottom),
|
||||
};
|
||||
if (normalized.endTime <= normalized.startTime || normalized.bottom <= normalized.top) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function buildTimelineLayerMap(layers: readonly StackingTimelineLayer[]) {
|
||||
const layerById = new Map<TimelineLayerId, StackingTimelineLayer>();
|
||||
for (const layer of layers) layerById.set(layer.id, layer);
|
||||
return layerById;
|
||||
}
|
||||
|
||||
function appendMarqueeLayerSelection(
|
||||
selected: string[],
|
||||
layer: StackingTimelineLayer,
|
||||
rect: NormalizedTimelineMarqueeRect,
|
||||
) {
|
||||
for (const element of layer.elements) {
|
||||
if (
|
||||
timelineIntervalsOverlap(
|
||||
rect.startTime,
|
||||
rect.endTime,
|
||||
element.start,
|
||||
element.start + element.duration,
|
||||
)
|
||||
) {
|
||||
selected.push(element.key ?? element.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function selectTimelineElementsInMarquee({
|
||||
rect,
|
||||
layers,
|
||||
layerOrder,
|
||||
rulerHeight,
|
||||
trackHeight,
|
||||
groupHeaderHeight = 0,
|
||||
}: TimelineMarqueeSelectionInput): string[] {
|
||||
const normalized = normalizeTimelineMarqueeRect(rect);
|
||||
if (!normalized) return [];
|
||||
const layerById = buildTimelineLayerMap(layers);
|
||||
const selected: string[] = [];
|
||||
let previousContextKey = "";
|
||||
let rowTop = rulerHeight;
|
||||
for (const layerId of layerOrder) {
|
||||
const layer = layerById.get(layerId);
|
||||
if (!layer) continue;
|
||||
if (shouldShowTimelineLayerGroupHeader(layer.contextKey, previousContextKey)) {
|
||||
rowTop += groupHeaderHeight;
|
||||
}
|
||||
const rowBottom = rowTop + trackHeight;
|
||||
if (timelineIntervalsOverlap(normalized.top, normalized.bottom, rowTop, rowBottom)) {
|
||||
appendMarqueeLayerSelection(selected, layer, normalized);
|
||||
}
|
||||
previousContextKey = layer.contextKey;
|
||||
rowTop = rowBottom;
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function isDeterministicTimelineWindow(input: {
|
||||
tag: string;
|
||||
compositionSrc?: string;
|
||||
@@ -356,13 +465,13 @@ export function buildTimelineAgentPrompt({
|
||||
const elementLines = elements
|
||||
.map(
|
||||
(el) =>
|
||||
`- #${el.id} (${el.tag}) — ${formatTime(el.start)} to ${formatTime(el.start + el.duration)}, track ${el.track}`,
|
||||
`- #${el.id} (${el.tag}) - ${formatTime(el.start)} to ${formatTime(el.start + el.duration)}, track ${el.track}`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
return `Edit the following HyperFrames composition:
|
||||
|
||||
Time range: ${formatTime(start)} — ${formatTime(end)}
|
||||
Time range: ${formatTime(start)} - ${formatTime(end)}
|
||||
|
||||
Elements in range:
|
||||
${elementLines || "(none)"}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { roundToCenti } from "../../utils/rounding";
|
||||
|
||||
const DEFAULT_TIMELINE_MIN_DURATION = 0.1;
|
||||
const ABSOLUTE_TIMELINE_MIN_DURATION = 0.05;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function roundTimelineTime(value: number): number {
|
||||
return roundToCenti(value);
|
||||
}
|
||||
|
||||
export function resolveTimelineMinDuration(minDuration?: number): number {
|
||||
return Math.max(ABSOLUTE_TIMELINE_MIN_DURATION, minDuration ?? DEFAULT_TIMELINE_MIN_DURATION);
|
||||
}
|
||||
|
||||
/** Playback rate never drops to zero (would make media-in-point math divide by ~0). */
|
||||
function resolveTimelinePlaybackRate(rate?: number): number {
|
||||
return Math.max(0.1, rate ?? 1);
|
||||
}
|
||||
|
||||
interface TimelineStartTrimClip {
|
||||
start: number;
|
||||
duration: number;
|
||||
playbackStart?: number;
|
||||
playbackRate?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delta bounds for trimming a clip's START edge (shared by single-clip and group
|
||||
* resize). Left-bounded by how far the start can move toward `minStart` and by the
|
||||
* media in-point (`playbackStart / playbackRate`); right-bounded by `minDuration`.
|
||||
* Returned deltas are unrounded — callers round with their own centisecond helper.
|
||||
*/
|
||||
export function clipStartTrimDeltaBounds(
|
||||
clip: TimelineStartTrimClip,
|
||||
minStart: number,
|
||||
minDuration: number,
|
||||
): { minDelta: number; maxDelta: number } {
|
||||
const playbackRate = resolveTimelinePlaybackRate(clip.playbackRate);
|
||||
const maxLeftExtensionFromMedia =
|
||||
clip.playbackStart != null ? clip.playbackStart / playbackRate : Number.POSITIVE_INFINITY;
|
||||
return {
|
||||
minDelta: -Math.min(clip.start - minStart, maxLeftExtensionFromMedia),
|
||||
maxDelta: clip.duration - minDuration,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a start-edge delta to one clip (unrounded): moves the start, shrinks the
|
||||
* duration by the same amount, and shifts the media in-point by the delta scaled to
|
||||
* the playback rate (clamped at 0).
|
||||
*/
|
||||
export function applyClipStartTrimDelta(
|
||||
clip: TimelineStartTrimClip,
|
||||
delta: number,
|
||||
): { start: number; duration: number; playbackStart?: number } {
|
||||
const playbackRate = resolveTimelinePlaybackRate(clip.playbackRate);
|
||||
return {
|
||||
start: clip.start + delta,
|
||||
duration: clip.duration - delta,
|
||||
playbackStart:
|
||||
clip.playbackStart != null
|
||||
? Math.max(0, clip.playbackStart + delta * playbackRate)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export interface TimelineGroupTimingMember {
|
||||
start: number;
|
||||
duration: number;
|
||||
playbackStart?: number;
|
||||
playbackRate?: number;
|
||||
}
|
||||
|
||||
export type TimelineGroupResizeEdge = "start" | "end";
|
||||
|
||||
export interface TimelineGroupMoveResult {
|
||||
delta: number;
|
||||
members: Array<Pick<TimelineGroupTimingMember, "start" | "duration">>;
|
||||
}
|
||||
|
||||
export interface TimelineGroupResizeResult {
|
||||
delta: number;
|
||||
members: Array<Pick<TimelineGroupTimingMember, "start" | "duration" | "playbackStart">>;
|
||||
}
|
||||
|
||||
function clampTimelineGroupMoveDelta(
|
||||
rawDelta: number,
|
||||
members: readonly TimelineGroupTimingMember[],
|
||||
): number {
|
||||
if (members.length === 0) return 0;
|
||||
const minDelta = Math.max(...members.map((member) => -member.start));
|
||||
return roundTimelineTime(Math.max(rawDelta, minDelta));
|
||||
}
|
||||
|
||||
export function resolveTimelineGroupMove(
|
||||
members: readonly TimelineGroupTimingMember[],
|
||||
rawDelta: number,
|
||||
): TimelineGroupMoveResult {
|
||||
const delta = clampTimelineGroupMoveDelta(rawDelta, members);
|
||||
return {
|
||||
delta,
|
||||
members: members.map((member) => ({
|
||||
start: roundTimelineTime(member.start + delta),
|
||||
duration: member.duration,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function clampTimelineGroupResizeDelta(
|
||||
rawDelta: number,
|
||||
members: readonly TimelineGroupTimingMember[],
|
||||
edge: TimelineGroupResizeEdge,
|
||||
minDuration = resolveTimelineMinDuration(),
|
||||
): number {
|
||||
if (members.length === 0) return 0;
|
||||
|
||||
if (edge === "end") {
|
||||
const minDelta = Math.max(...members.map((member) => minDuration - member.duration));
|
||||
return roundTimelineTime(Math.max(rawDelta, minDelta));
|
||||
}
|
||||
|
||||
// Rigid group: the applied delta is bounded by the most-constrained member.
|
||||
const bounds = members.map((member) => clipStartTrimDeltaBounds(member, 0, minDuration));
|
||||
const minDelta = Math.max(...bounds.map((b) => b.minDelta));
|
||||
const maxDelta = Math.min(...bounds.map((b) => b.maxDelta));
|
||||
return roundTimelineTime(clamp(rawDelta, minDelta, maxDelta));
|
||||
}
|
||||
|
||||
export function resolveTimelineGroupResize(
|
||||
members: readonly TimelineGroupTimingMember[],
|
||||
edge: TimelineGroupResizeEdge,
|
||||
rawDelta: number,
|
||||
minDuration = resolveTimelineMinDuration(),
|
||||
): TimelineGroupResizeResult {
|
||||
const delta = clampTimelineGroupResizeDelta(rawDelta, members, edge, minDuration);
|
||||
return {
|
||||
delta,
|
||||
members: members.map((member) => {
|
||||
if (edge === "end") {
|
||||
return {
|
||||
start: member.start,
|
||||
duration: roundTimelineTime(member.duration + delta),
|
||||
playbackStart: member.playbackStart,
|
||||
};
|
||||
}
|
||||
|
||||
const trimmed = applyClipStartTrimDelta(member, delta);
|
||||
return {
|
||||
start: roundTimelineTime(trimmed.start),
|
||||
duration: roundTimelineTime(trimmed.duration),
|
||||
playbackStart:
|
||||
trimmed.playbackStart != null ? roundTimelineTime(trimmed.playbackStart) : undefined,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { RULER_H, TRACK_H } from "./timelineLayout";
|
||||
import { selectTimelineElementsInMarquee } from "./timelineEditing";
|
||||
import type { StackingTimelineLayer } from "./timelineTrackOrder";
|
||||
|
||||
function element(id: string, start: number, duration: number, track: number): TimelineElement {
|
||||
return { id, tag: "div", start, duration, track };
|
||||
}
|
||||
|
||||
function layer(id: string, elements: TimelineElement[]): StackingTimelineLayer {
|
||||
return {
|
||||
id,
|
||||
kind: "visual",
|
||||
contextKey: "",
|
||||
zIndex: 0,
|
||||
placementTrack: elements[0]?.track ?? 0,
|
||||
elements,
|
||||
};
|
||||
}
|
||||
|
||||
describe("selectTimelineElementsInMarquee", () => {
|
||||
it("selects clips intersecting both the marquee time span and lane span", () => {
|
||||
const layers = [
|
||||
layer("lane-0", [element("first-hit", 1, 1, 0), element("time-miss", 5, 1, 0)]),
|
||||
layer("lane-1", [element("second-hit", 2.25, 1, 1)]),
|
||||
layer("lane-2", [element("lane-miss", 1.5, 1, 2)]),
|
||||
];
|
||||
|
||||
expect(
|
||||
selectTimelineElementsInMarquee({
|
||||
rect: {
|
||||
startTime: 0.5,
|
||||
endTime: 3,
|
||||
top: RULER_H,
|
||||
bottom: RULER_H + TRACK_H * 2,
|
||||
},
|
||||
layers,
|
||||
layerOrder: layers.map((item) => item.id),
|
||||
rulerHeight: RULER_H,
|
||||
trackHeight: TRACK_H,
|
||||
}),
|
||||
).toEqual(["first-hit", "second-hit"]);
|
||||
});
|
||||
|
||||
it("returns no ids when the marquee intersects no clip", () => {
|
||||
const layers = [layer("lane-0", [element("outside", 4, 1, 0)])];
|
||||
|
||||
expect(
|
||||
selectTimelineElementsInMarquee({
|
||||
rect: {
|
||||
startTime: 0,
|
||||
endTime: 1,
|
||||
top: RULER_H,
|
||||
bottom: RULER_H + TRACK_H,
|
||||
},
|
||||
layers,
|
||||
layerOrder: ["lane-0"],
|
||||
rulerHeight: RULER_H,
|
||||
trackHeight: TRACK_H,
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("accounts for context group headers before row hit testing", () => {
|
||||
const layers: StackingTimelineLayer[] = [
|
||||
{ ...layer("lane-0", [element("above", 0, 1, 0)]), contextKey: "root" },
|
||||
{ ...layer("lane-1", [element("below", 0, 1, 1)]), contextKey: "nested" },
|
||||
];
|
||||
|
||||
expect(
|
||||
selectTimelineElementsInMarquee({
|
||||
rect: {
|
||||
startTime: 0,
|
||||
endTime: 1,
|
||||
top: RULER_H + 18 + TRACK_H,
|
||||
bottom: RULER_H + 18 + TRACK_H + 18 + TRACK_H,
|
||||
},
|
||||
layers,
|
||||
layerOrder: ["lane-0", "lane-1"],
|
||||
rulerHeight: RULER_H,
|
||||
trackHeight: TRACK_H,
|
||||
groupHeaderHeight: 18,
|
||||
}),
|
||||
).toEqual(["below"]);
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,7 @@ describe("buildTimelineSnapTargets", () => {
|
||||
|
||||
const targets = buildTimelineSnapTargets({
|
||||
elements: [dragged, other],
|
||||
draggedKey: "dragged-key",
|
||||
excludedKeys: new Set(["dragged-key"]),
|
||||
playhead: 8,
|
||||
compDuration: 10,
|
||||
beats: [1.5],
|
||||
@@ -47,13 +47,36 @@ describe("buildTimelineSnapTargets", () => {
|
||||
expect(times).toContain(6);
|
||||
});
|
||||
|
||||
it("excludes every moving group member, not just the grabbed clip", () => {
|
||||
const a = timelineElement({ id: "a", start: 1, duration: 1 });
|
||||
const b = timelineElement({ id: "b", start: 3, duration: 1 });
|
||||
const other = timelineElement({ id: "other", start: 6, duration: 1 });
|
||||
|
||||
const targets = buildTimelineSnapTargets({
|
||||
elements: [a, b, other],
|
||||
excludedKeys: new Set(["a", "b"]),
|
||||
playhead: 9,
|
||||
compDuration: 10,
|
||||
beats: [],
|
||||
});
|
||||
|
||||
const times = targets.map((target) => target.time);
|
||||
// Both group members' edges are excluded; the non-member's edges remain.
|
||||
expect(times).not.toContain(1);
|
||||
expect(times).not.toContain(2);
|
||||
expect(times).not.toContain(3);
|
||||
expect(times).not.toContain(4);
|
||||
expect(times).toContain(6);
|
||||
expect(times).toContain(7);
|
||||
});
|
||||
|
||||
it("dedupes near-equal times from different sources", () => {
|
||||
const dragged = timelineElement({ id: "dragged", start: 2, duration: 2 });
|
||||
const other = timelineElement({ id: "other", start: 0.0004, duration: 10 });
|
||||
|
||||
const targets = buildTimelineSnapTargets({
|
||||
elements: [dragged, other],
|
||||
draggedKey: "dragged",
|
||||
excludedKeys: new Set(["dragged"]),
|
||||
playhead: 5,
|
||||
compDuration: 10,
|
||||
beats: [0.0002, 10.0002],
|
||||
|
||||
@@ -43,7 +43,8 @@ function addTarget(targets: TimelineSnapTarget[], candidate: TimelineSnapTarget)
|
||||
|
||||
export function buildTimelineSnapTargets(input: {
|
||||
elements: TimelineElement[];
|
||||
draggedKey: string;
|
||||
/** Keys of every clip moving in this gesture (the whole group), excluded as targets. */
|
||||
excludedKeys: ReadonlySet<string>;
|
||||
playhead: number;
|
||||
compDuration: number;
|
||||
beats: number[];
|
||||
@@ -56,7 +57,7 @@ export function buildTimelineSnapTargets(input: {
|
||||
|
||||
for (const element of input.elements) {
|
||||
const elementKey = element.key ?? element.id;
|
||||
if (elementKey === input.draggedKey || element.id === input.draggedKey) continue;
|
||||
if (input.excludedKeys.has(elementKey) || input.excludedKeys.has(element.id)) continue;
|
||||
addTarget(targets, { time: element.start, kind: "edge" });
|
||||
addTarget(targets, { time: element.start + element.duration, kind: "edge" });
|
||||
}
|
||||
|
||||
@@ -14,19 +14,25 @@ import { useTimelineClipDrag } from "./useTimelineClipDrag";
|
||||
|
||||
function timelineElement(input: {
|
||||
id: string;
|
||||
tag?: string;
|
||||
track: number;
|
||||
zIndex: number;
|
||||
start?: number;
|
||||
duration?: number;
|
||||
sourceDuration?: number;
|
||||
playbackStart?: number;
|
||||
playbackRate?: number;
|
||||
timelineLocked?: boolean;
|
||||
}): TimelineElement {
|
||||
return {
|
||||
id: input.id,
|
||||
domId: input.id,
|
||||
tag: "div",
|
||||
tag: input.tag ?? "div",
|
||||
start: input.start ?? 0,
|
||||
duration: input.duration ?? 2,
|
||||
sourceDuration: input.sourceDuration,
|
||||
playbackStart: input.playbackStart,
|
||||
playbackRate: input.playbackRate,
|
||||
track: input.track,
|
||||
zIndex: input.zIndex,
|
||||
stackingContextId: "root",
|
||||
@@ -34,6 +40,7 @@ function timelineElement(input: {
|
||||
compositionAncestors: ["root"],
|
||||
sourceFile: "index.html",
|
||||
timingSource: "authored",
|
||||
timelineLocked: input.timelineLocked,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,11 +50,16 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
function renderDragHarness(elements: TimelineElement[]) {
|
||||
usePlayerStore.getState().setElements(elements);
|
||||
const layers = buildStackingTimelineLayers(elements).rows;
|
||||
const scroll = document.createElement("div");
|
||||
document.body.append(scroll);
|
||||
const onMoveElement = vi.fn();
|
||||
const onResizeElement = vi.fn();
|
||||
const onMoveElements = vi.fn();
|
||||
const onResizeElements = vi.fn();
|
||||
const onPreviewMoveElements = vi.fn();
|
||||
const onPreviewResizeElements = vi.fn();
|
||||
let setDraggedClip: ((state: DraggedClipState | null) => void) | null = null;
|
||||
let setResizingClip: ((state: ResizingClipState | null) => void) | null = null;
|
||||
|
||||
@@ -60,6 +72,10 @@ function renderDragHarness(elements: TimelineElement[]) {
|
||||
timelineElementsRef: { current: elements },
|
||||
onMoveElement,
|
||||
onResizeElement,
|
||||
onMoveElements,
|
||||
onResizeElements,
|
||||
onPreviewMoveElements,
|
||||
onPreviewResizeElements,
|
||||
onBlockedEditAttempt: vi.fn(),
|
||||
setShowPopover: vi.fn(),
|
||||
setRangeSelectionRef: { current: vi.fn() },
|
||||
@@ -84,7 +100,20 @@ function renderDragHarness(elements: TimelineElement[]) {
|
||||
layers,
|
||||
onMoveElement,
|
||||
onResizeElement,
|
||||
onMoveElements,
|
||||
onResizeElements,
|
||||
onPreviewMoveElements,
|
||||
onPreviewResizeElements,
|
||||
storeElements() {
|
||||
return usePlayerStore.getState().elements;
|
||||
},
|
||||
startDrag(element: TimelineElement, layerIndex: number) {
|
||||
const layer =
|
||||
layers[layerIndex] ??
|
||||
layers.find((candidate) =>
|
||||
candidate.elements.some((candidateElement) => candidateElement.id === element.id),
|
||||
) ??
|
||||
layers[0]!;
|
||||
act(() => {
|
||||
applyDraggedClip({
|
||||
element,
|
||||
@@ -98,7 +127,7 @@ function renderDragHarness(elements: TimelineElement[]) {
|
||||
pointerOffsetY: 0,
|
||||
previewStart: element.start,
|
||||
previewTrack: element.track,
|
||||
previewLayerId: layers[layerIndex]!.id,
|
||||
previewLayerId: layer.id,
|
||||
previewLayerIndex: layerIndex,
|
||||
previewStackingReorder: null,
|
||||
snapBeatTime: null,
|
||||
@@ -162,6 +191,114 @@ describe("useTimelineClipDrag", () => {
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("moves every selected clip body by the same delta", async () => {
|
||||
const first = timelineElement({ id: "first", track: 0, zIndex: 1, start: 1, duration: 2 });
|
||||
const second = timelineElement({ id: "second", track: 1, zIndex: 1, start: 4, duration: 2 });
|
||||
const harness = renderDragHarness([first, second]);
|
||||
act(() => {
|
||||
usePlayerStore.getState().setSelection(["first", "second"], "first");
|
||||
});
|
||||
|
||||
harness.startDrag(first, 0);
|
||||
harness.movePointer(200, 0);
|
||||
|
||||
expect(harness.storeElements().map((element) => [element.id, element.start])).toEqual([
|
||||
["first", 3],
|
||||
["second", 6],
|
||||
]);
|
||||
expect(harness.onPreviewMoveElements).toHaveBeenLastCalledWith([
|
||||
{ element: first, start: 3 },
|
||||
{ element: second, start: 6 },
|
||||
]);
|
||||
|
||||
await harness.dropPointer();
|
||||
|
||||
expect(harness.onMoveElement).not.toHaveBeenCalled();
|
||||
expect(harness.onMoveElements).toHaveBeenCalledTimes(1);
|
||||
expect(harness.onMoveElements).toHaveBeenCalledWith([
|
||||
{ element: first, start: 3 },
|
||||
{ element: second, start: 6 },
|
||||
]);
|
||||
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("clamps a selected group move when the earliest member reaches zero", async () => {
|
||||
const early = timelineElement({ id: "early", track: 0, zIndex: 1, start: 1, duration: 2 });
|
||||
const grabbed = timelineElement({ id: "grabbed", track: 1, zIndex: 1, start: 4, duration: 2 });
|
||||
const harness = renderDragHarness([early, grabbed]);
|
||||
act(() => {
|
||||
usePlayerStore.getState().setSelection(["early", "grabbed"], "grabbed");
|
||||
});
|
||||
|
||||
harness.startDrag(grabbed, 1);
|
||||
harness.movePointer(-300, 0);
|
||||
await harness.dropPointer();
|
||||
|
||||
expect(harness.onMoveElements).toHaveBeenCalledWith([
|
||||
{ element: early, start: 0 },
|
||||
{ element: grabbed, start: 3 },
|
||||
]);
|
||||
expect(harness.onMoveElement).not.toHaveBeenCalled();
|
||||
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("keeps body drag single-clip when the grabbed clip is not in the multi-selection", async () => {
|
||||
const first = timelineElement({ id: "first", track: 0, zIndex: 1, start: 1, duration: 2 });
|
||||
const second = timelineElement({ id: "second", track: 1, zIndex: 1, start: 4, duration: 2 });
|
||||
const outside = timelineElement({ id: "outside", track: 2, zIndex: 1, start: 7, duration: 2 });
|
||||
const harness = renderDragHarness([first, second, outside]);
|
||||
act(() => {
|
||||
usePlayerStore.getState().setSelection(["first", "second"], "first");
|
||||
});
|
||||
|
||||
harness.startDrag(outside, 2);
|
||||
harness.movePointer(200, 0);
|
||||
await harness.dropPointer();
|
||||
|
||||
expect(harness.onMoveElements).not.toHaveBeenCalled();
|
||||
expect(harness.onMoveElement).toHaveBeenCalledTimes(1);
|
||||
expect(harness.onMoveElement).toHaveBeenCalledWith(
|
||||
outside,
|
||||
expect.objectContaining({ start: 9 }),
|
||||
);
|
||||
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("does not form a group when a selected member is locked (grabbed clip moves alone)", async () => {
|
||||
const first = timelineElement({ id: "first", track: 0, zIndex: 1, start: 1, duration: 2 });
|
||||
const locked = timelineElement({
|
||||
id: "locked",
|
||||
track: 1,
|
||||
zIndex: 1,
|
||||
start: 4,
|
||||
duration: 2,
|
||||
timelineLocked: true,
|
||||
});
|
||||
const harness = renderDragHarness([first, locked]);
|
||||
act(() => {
|
||||
usePlayerStore.getState().setSelection(["first", "locked"], "first");
|
||||
});
|
||||
|
||||
harness.startDrag(first, 0);
|
||||
harness.movePointer(200, 0);
|
||||
await harness.dropPointer();
|
||||
|
||||
// The locked member forbids the op, so no group forms: the grabbed clip moves
|
||||
// alone (single-clip path) and the locked clip is never touched.
|
||||
expect(harness.onMoveElements).not.toHaveBeenCalled();
|
||||
expect(harness.onMoveElement).toHaveBeenCalledTimes(1);
|
||||
expect(harness.onMoveElement).toHaveBeenCalledWith(
|
||||
first,
|
||||
expect.objectContaining({ start: 3 }),
|
||||
);
|
||||
expect(harness.storeElements().find((el) => el.id === "locked")?.start).toBe(4);
|
||||
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("allows right-edge resize past the current composition duration", async () => {
|
||||
const clip = timelineElement({ id: "clip", track: 0, zIndex: 1, start: 6, duration: 2 });
|
||||
const harness = renderDragHarness([clip]);
|
||||
@@ -178,6 +315,142 @@ describe("useTimelineClipDrag", () => {
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("resizes every selected start edge by the same delta", async () => {
|
||||
const first = timelineElement({ id: "first", track: 0, zIndex: 1, start: 1, duration: 4 });
|
||||
const second = timelineElement({ id: "second", track: 1, zIndex: 1, start: 5, duration: 3 });
|
||||
const harness = renderDragHarness([first, second]);
|
||||
act(() => {
|
||||
usePlayerStore.getState().setSelection(["first", "second"], "first");
|
||||
});
|
||||
|
||||
harness.startResize(first, "start");
|
||||
harness.movePointer(100, 0);
|
||||
|
||||
expect(
|
||||
harness.storeElements().map((element) => [element.id, element.start, element.duration]),
|
||||
).toEqual([
|
||||
["first", 2, 3],
|
||||
["second", 6, 2],
|
||||
]);
|
||||
expect(harness.onPreviewResizeElements).toHaveBeenLastCalledWith([
|
||||
{ element: first, start: 2, duration: 3, playbackStart: undefined },
|
||||
{ element: second, start: 6, duration: 2, playbackStart: undefined },
|
||||
]);
|
||||
|
||||
await harness.dropPointer();
|
||||
|
||||
expect(harness.onResizeElement).not.toHaveBeenCalled();
|
||||
expect(harness.onResizeElements).toHaveBeenCalledTimes(1);
|
||||
expect(harness.onResizeElements).toHaveBeenCalledWith([
|
||||
{ element: first, start: 2, duration: 3, playbackStart: undefined },
|
||||
{ element: second, start: 6, duration: 2, playbackStart: undefined },
|
||||
]);
|
||||
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("resizes every selected end edge by the same delta", async () => {
|
||||
const first = timelineElement({ id: "first", track: 0, zIndex: 1, start: 1, duration: 4 });
|
||||
const second = timelineElement({ id: "second", track: 1, zIndex: 1, start: 5, duration: 3 });
|
||||
const harness = renderDragHarness([first, second]);
|
||||
act(() => {
|
||||
usePlayerStore.getState().setSelection(["first", "second"], "first");
|
||||
});
|
||||
|
||||
harness.startResize(first, "end");
|
||||
harness.movePointer(100, 0);
|
||||
await harness.dropPointer();
|
||||
|
||||
expect(harness.onResizeElement).not.toHaveBeenCalled();
|
||||
expect(harness.onResizeElements).toHaveBeenCalledWith([
|
||||
{ element: first, start: 1, duration: 5, playbackStart: undefined },
|
||||
{ element: second, start: 5, duration: 4, playbackStart: undefined },
|
||||
]);
|
||||
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("clamps selected start-edge resize at the most constrained duration", async () => {
|
||||
const short = timelineElement({ id: "short", track: 0, zIndex: 1, start: 1, duration: 0.5 });
|
||||
const long = timelineElement({ id: "long", track: 1, zIndex: 1, start: 4, duration: 2 });
|
||||
const harness = renderDragHarness([short, long]);
|
||||
act(() => {
|
||||
usePlayerStore.getState().setSelection(["short", "long"], "short");
|
||||
});
|
||||
|
||||
harness.startResize(short, "start");
|
||||
harness.movePointer(100, 0);
|
||||
await harness.dropPointer();
|
||||
|
||||
expect(harness.onResizeElements).toHaveBeenCalledWith([
|
||||
{ element: short, start: 1.4, duration: 0.1, playbackStart: undefined },
|
||||
{ element: long, start: 4.4, duration: 1.6, playbackStart: undefined },
|
||||
]);
|
||||
expect(harness.onResizeElement).not.toHaveBeenCalled();
|
||||
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("adjusts every selected media playback start during start-edge resize", async () => {
|
||||
const audio = timelineElement({
|
||||
id: "audio",
|
||||
tag: "audio",
|
||||
track: 0,
|
||||
zIndex: 1,
|
||||
start: 2,
|
||||
duration: 3,
|
||||
playbackStart: 1,
|
||||
playbackRate: 1,
|
||||
});
|
||||
const video = timelineElement({
|
||||
id: "video",
|
||||
tag: "video",
|
||||
track: 1,
|
||||
zIndex: 1,
|
||||
start: 5,
|
||||
duration: 4,
|
||||
playbackStart: 2,
|
||||
playbackRate: 2,
|
||||
});
|
||||
const harness = renderDragHarness([audio, video]);
|
||||
act(() => {
|
||||
usePlayerStore.getState().setSelection(["audio", "video"], "audio");
|
||||
});
|
||||
|
||||
harness.startResize(audio, "start");
|
||||
harness.movePointer(50, 0);
|
||||
await harness.dropPointer();
|
||||
|
||||
expect(harness.onResizeElements).toHaveBeenCalledWith([
|
||||
{ element: audio, start: 2.5, duration: 2.5, playbackStart: 1.5 },
|
||||
{ element: video, start: 5.5, duration: 3.5, playbackStart: 3 },
|
||||
]);
|
||||
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("keeps handle resize single-clip when the grabbed clip is not in the multi-selection", async () => {
|
||||
const first = timelineElement({ id: "first", track: 0, zIndex: 1, start: 1, duration: 2 });
|
||||
const second = timelineElement({ id: "second", track: 1, zIndex: 1, start: 4, duration: 2 });
|
||||
const outside = timelineElement({ id: "outside", track: 2, zIndex: 1, start: 7, duration: 2 });
|
||||
const harness = renderDragHarness([first, second, outside]);
|
||||
act(() => {
|
||||
usePlayerStore.getState().setSelection(["first", "second"], "first");
|
||||
});
|
||||
|
||||
harness.startResize(outside, "end");
|
||||
harness.movePointer(100, 0);
|
||||
await harness.dropPointer();
|
||||
|
||||
expect(harness.onResizeElement).toHaveBeenCalledTimes(1);
|
||||
expect(harness.onResizeElement).toHaveBeenCalledWith(
|
||||
outside,
|
||||
expect.objectContaining({ start: 7, duration: 3 }),
|
||||
);
|
||||
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("passes a new-lane stacking intent when a vertical drag targets an overlapping lane", async () => {
|
||||
const front = timelineElement({ id: "front", track: 0, zIndex: 3 });
|
||||
const middle = timelineElement({ id: "middle", track: 1, zIndex: 2 });
|
||||
|
||||
@@ -13,12 +13,18 @@ import { TRACK_H } from "./timelineLayout";
|
||||
import { isMusicTrack } from "../../utils/timelineInspector";
|
||||
import { mergeUserBeats } from "../../utils/beatEditing";
|
||||
import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
|
||||
import type {
|
||||
TimelineGroupMoveChange,
|
||||
TimelineGroupResizeChange,
|
||||
} from "../../hooks/useTimelineGroupEditing";
|
||||
import {
|
||||
buildTimelineSnapTargets,
|
||||
snapEdgesToTargets,
|
||||
snapResizeEdgeToTargets,
|
||||
type TimelineSnapKind,
|
||||
} from "./timelineSnapTargets";
|
||||
import { resolveDragPreviewPlacement } from "./timelineClipDragPreview";
|
||||
import { useTimelineClipGroupDrag } from "./useTimelineClipGroupDrag";
|
||||
|
||||
const EMPTY_BEAT_TIMES: number[] = [];
|
||||
|
||||
@@ -83,9 +89,13 @@ interface UseTimelineClipDragInput {
|
||||
element: TimelineElement,
|
||||
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
||||
) => Promise<void> | void;
|
||||
onMoveElements?: (changes: TimelineGroupMoveChange[]) => Promise<void> | void;
|
||||
onResizeElements?: (changes: TimelineGroupResizeChange[]) => Promise<void> | void;
|
||||
onPreviewMoveElements?: (changes: TimelineGroupMoveChange[]) => void;
|
||||
onPreviewResizeElements?: (changes: TimelineGroupResizeChange[]) => void;
|
||||
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
||||
setShowPopover: (show: boolean) => void;
|
||||
/** Stable ref to the range selection setter — wired after mount to break circular dependency. */
|
||||
/** Stable ref to the range selection setter, wired after mount to break circular dependency. */
|
||||
setRangeSelectionRef: React.RefObject<((sel: null) => void) | null>;
|
||||
}
|
||||
|
||||
@@ -97,6 +107,10 @@ export function useTimelineClipDrag({
|
||||
timelineElementsRef,
|
||||
onMoveElement,
|
||||
onResizeElement,
|
||||
onMoveElements,
|
||||
onResizeElements,
|
||||
onPreviewMoveElements,
|
||||
onPreviewResizeElements,
|
||||
onBlockedEditAttempt,
|
||||
setShowPopover,
|
||||
setRangeSelectionRef,
|
||||
@@ -140,14 +154,21 @@ export function useTimelineClipDrag({
|
||||
compositionDurationRef.current = compositionDuration;
|
||||
|
||||
const buildSnapTargets = useCallback(
|
||||
(element: TimelineElement) =>
|
||||
buildTimelineSnapTargets({
|
||||
(element: TimelineElement) => {
|
||||
const draggedKey = element.key ?? element.id;
|
||||
const selected = selectedElementIdsRef.current;
|
||||
// In a group drag every selected clip moves together, so none of them may act
|
||||
// as a snap target for the others; exclude the whole set, not just the grabbed clip.
|
||||
const excludedKeys =
|
||||
selected.size > 1 && selected.has(draggedKey) ? selected : new Set([draggedKey]);
|
||||
return buildTimelineSnapTargets({
|
||||
elements: timelineElementsRef.current,
|
||||
draggedKey: element.key ?? element.id,
|
||||
excludedKeys,
|
||||
playhead: playheadRef.current,
|
||||
compDuration: compositionDurationRef.current,
|
||||
beats: isMusicTrack(element) ? EMPTY_BEAT_TIMES : beatTimesRef.current,
|
||||
}),
|
||||
});
|
||||
},
|
||||
[timelineElementsRef],
|
||||
);
|
||||
|
||||
@@ -166,10 +187,35 @@ export function useTimelineClipDrag({
|
||||
onMoveElementRef.current = onMoveElement;
|
||||
const onResizeElementRef = useRef(onResizeElement);
|
||||
onResizeElementRef.current = onResizeElement;
|
||||
const onMoveElementsRef = useRef(onMoveElements);
|
||||
onMoveElementsRef.current = onMoveElements;
|
||||
const onResizeElementsRef = useRef(onResizeElements);
|
||||
onResizeElementsRef.current = onResizeElements;
|
||||
const onPreviewMoveElementsRef = useRef(onPreviewMoveElements);
|
||||
onPreviewMoveElementsRef.current = onPreviewMoveElements;
|
||||
const onPreviewResizeElementsRef = useRef(onPreviewResizeElements);
|
||||
onPreviewResizeElementsRef.current = onPreviewResizeElements;
|
||||
const selectedElementIdsRef = useRef(usePlayerStore.getState().selectedElementIds);
|
||||
selectedElementIdsRef.current = usePlayerStore((s) => s.selectedElementIds);
|
||||
|
||||
const clipDragScrollRaf = useRef(0);
|
||||
const clipDragPointerRef = useRef<{ clientX: number; clientY: number } | null>(null);
|
||||
const {
|
||||
previewGroupMove,
|
||||
previewGroupResize,
|
||||
commitGroupMove,
|
||||
commitGroupResize,
|
||||
clearGroupDragSessions,
|
||||
} = useTimelineClipGroupDrag({
|
||||
timelineElementsRef,
|
||||
updateElement,
|
||||
onMoveElementsRef,
|
||||
onResizeElementsRef,
|
||||
onPreviewMoveElementsRef,
|
||||
onPreviewResizeElementsRef,
|
||||
});
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const updateDraggedClipPreview = useCallback(
|
||||
(drag: DraggedClipState, clientX: number, clientY: number): DraggedClipState => {
|
||||
const scroll = scrollRef.current;
|
||||
@@ -203,22 +249,28 @@ export function useTimelineClipDrag({
|
||||
ppsRef.current,
|
||||
{ maxStart: Number.POSITIVE_INFINITY },
|
||||
);
|
||||
const groupMove = previewGroupMove(drag.element, selectedElementIdsRef.current, snap.start);
|
||||
const placement = resolveDragPreviewPlacement(drag, nextMove, groupMove);
|
||||
return {
|
||||
...drag,
|
||||
started: true,
|
||||
pointerClientX: clientX,
|
||||
pointerClientY: clientY,
|
||||
previewStart: snap.start,
|
||||
previewTrack: nextMove.track,
|
||||
previewLayerId: nextMove.previewLayerId ?? drag.previewLayerId,
|
||||
previewLayerIndex: nextMove.previewLayerIndex ?? drag.previewLayerIndex,
|
||||
previewStackingReorder: nextMove.stackingReorder ?? null,
|
||||
...placement,
|
||||
snapBeatTime: snap.snapKind === "beat" ? snap.snapTime : null,
|
||||
snapGuideTime: snap.snapTime,
|
||||
snapGuideKind: snap.snapKind,
|
||||
};
|
||||
},
|
||||
[scrollRef, ppsRef, trackOrderRef, timelineLayersRef, timelineElementsRef, buildSnapTargets],
|
||||
[
|
||||
scrollRef,
|
||||
ppsRef,
|
||||
trackOrderRef,
|
||||
timelineLayersRef,
|
||||
timelineElementsRef,
|
||||
buildSnapTargets,
|
||||
previewGroupMove,
|
||||
],
|
||||
);
|
||||
|
||||
const stopClipDragAutoScroll = useCallback(() => {
|
||||
@@ -277,6 +329,12 @@ export function useTimelineClipDrag({
|
||||
|
||||
const updateDraggedClipPreviewRef = useRef(updateDraggedClipPreview);
|
||||
updateDraggedClipPreviewRef.current = updateDraggedClipPreview;
|
||||
const commitGroupMoveRef = useRef(commitGroupMove);
|
||||
commitGroupMoveRef.current = commitGroupMove;
|
||||
const commitGroupResizeRef = useRef(commitGroupResize);
|
||||
commitGroupResizeRef.current = commitGroupResize;
|
||||
const clearGroupDragSessionsRef = useRef(clearGroupDragSessions);
|
||||
clearGroupDragSessionsRef.current = clearGroupDragSessions;
|
||||
const syncClipDragAutoScrollRef = useRef(syncClipDragAutoScroll);
|
||||
syncClipDragAutoScrollRef.current = syncClipDragAutoScroll;
|
||||
const stopClipDragAutoScrollRef = useRef(stopClipDragAutoScroll);
|
||||
@@ -360,7 +418,15 @@ export function useTimelineClipDrag({
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const groupResize = previewGroupResize(
|
||||
resize.element,
|
||||
selectedElementIdsRef.current,
|
||||
resize.edge,
|
||||
nextResize,
|
||||
);
|
||||
if (groupResize.active) {
|
||||
nextResize = groupResize.updates;
|
||||
}
|
||||
setResizingClip((prev) =>
|
||||
prev
|
||||
? {
|
||||
@@ -421,6 +487,8 @@ export function useTimelineClipDrag({
|
||||
suppressClickRef.current = true;
|
||||
clearSuppressedClick();
|
||||
|
||||
if (commitGroupResizeRef.current(resize.element)) return;
|
||||
|
||||
const hasChanged =
|
||||
resize.previewStart !== resize.element.start ||
|
||||
resize.previewDuration !== resize.element.duration ||
|
||||
@@ -467,6 +535,8 @@ export function useTimelineClipDrag({
|
||||
suppressClickRef.current = true;
|
||||
clearSuppressedClick();
|
||||
|
||||
if (commitGroupMoveRef.current(drag.element)) return;
|
||||
|
||||
const hasStackingReorder =
|
||||
drag.previewStackingReorder != null && drag.previewStackingReorder.zIndexChanges.length > 0;
|
||||
const hasChanged = drag.previewStart !== drag.element.start || hasStackingReorder;
|
||||
@@ -494,6 +564,7 @@ export function useTimelineClipDrag({
|
||||
window.addEventListener("pointerup", handleWindowPointerUp);
|
||||
window.addEventListener("pointercancel", handleWindowPointerUp);
|
||||
return () => {
|
||||
clearGroupDragSessionsRef.current();
|
||||
stopClipDragAutoScrollRef.current();
|
||||
window.removeEventListener("pointermove", handleWindowPointerMove);
|
||||
window.removeEventListener("pointerup", handleWindowPointerUp);
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
import { useCallback, useRef, type RefObject } from "react";
|
||||
import type {
|
||||
TimelineGroupMoveChange,
|
||||
TimelineGroupResizeChange,
|
||||
} from "../../hooks/useTimelineGroupEditing";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import {
|
||||
getTimelineEditCapabilities,
|
||||
resolveTimelineGroupMove,
|
||||
resolveTimelineGroupResize,
|
||||
type TimelineGroupResizeEdge,
|
||||
type TimelineGroupTimingMember,
|
||||
} from "./timelineEditing";
|
||||
|
||||
type TimelineResizeUpdates = Pick<TimelineElement, "start" | "duration" | "playbackStart">;
|
||||
|
||||
type UpdateTimelineElement = (
|
||||
elementId: string,
|
||||
updates: Partial<Pick<TimelineElement, "start" | "duration" | "playbackStart">>,
|
||||
) => void;
|
||||
|
||||
interface GroupTimingMember extends TimelineGroupTimingMember {
|
||||
element: TimelineElement;
|
||||
key: string;
|
||||
}
|
||||
|
||||
interface MoveSession {
|
||||
grabbedKey: string;
|
||||
members: GroupTimingMember[];
|
||||
changes: TimelineGroupMoveChange[];
|
||||
hasChanged: boolean;
|
||||
}
|
||||
|
||||
interface ResizeSession {
|
||||
grabbedKey: string;
|
||||
edge: TimelineGroupResizeEdge;
|
||||
members: GroupTimingMember[];
|
||||
changes: TimelineGroupResizeChange[];
|
||||
hasChanged: boolean;
|
||||
}
|
||||
|
||||
interface UseTimelineClipGroupDragInput {
|
||||
timelineElementsRef: RefObject<TimelineElement[]>;
|
||||
updateElement: UpdateTimelineElement;
|
||||
onMoveElementsRef: RefObject<
|
||||
((changes: TimelineGroupMoveChange[]) => Promise<void> | void) | undefined
|
||||
>;
|
||||
onResizeElementsRef: RefObject<
|
||||
((changes: TimelineGroupResizeChange[]) => Promise<void> | void) | undefined
|
||||
>;
|
||||
onPreviewMoveElementsRef: RefObject<((changes: TimelineGroupMoveChange[]) => void) | undefined>;
|
||||
onPreviewResizeElementsRef: RefObject<
|
||||
((changes: TimelineGroupResizeChange[]) => void) | undefined
|
||||
>;
|
||||
}
|
||||
|
||||
interface PreviewGroupMoveResult {
|
||||
active: boolean;
|
||||
previewStart: number;
|
||||
}
|
||||
|
||||
interface PreviewGroupResizeResult {
|
||||
active: boolean;
|
||||
updates: TimelineResizeUpdates;
|
||||
}
|
||||
|
||||
function elementKey(element: TimelineElement): string {
|
||||
return element.key ?? element.id;
|
||||
}
|
||||
|
||||
function isMediaElement(element: TimelineElement): boolean {
|
||||
const normalizedTag = element.tag.toLowerCase();
|
||||
return normalizedTag === "audio" || normalizedTag === "video";
|
||||
}
|
||||
|
||||
function selectedElementSet(selectedElementIdsInput: Set<string>): Set<string> {
|
||||
return selectedElementIdsInput instanceof Set ? selectedElementIdsInput : new Set<string>();
|
||||
}
|
||||
|
||||
function selectedMembers(
|
||||
grabbedElement: TimelineElement,
|
||||
selectedElementIdsInput: Set<string>,
|
||||
timelineElements: readonly TimelineElement[],
|
||||
mapMember: (element: TimelineElement) => GroupTimingMember,
|
||||
canEdit: (element: TimelineElement) => boolean,
|
||||
): GroupTimingMember[] | null {
|
||||
const selectedElementIds = selectedElementSet(selectedElementIdsInput);
|
||||
const grabbedKey = elementKey(grabbedElement);
|
||||
if (selectedElementIds.size <= 1 || !selectedElementIds.has(grabbedKey)) return null;
|
||||
|
||||
const elements = timelineElements.filter((element) =>
|
||||
selectedElementIds.has(elementKey(element)),
|
||||
);
|
||||
// A group edit must not touch a member that individually forbids this operation
|
||||
// (e.g. a locked or implicitly-timed clip). If any member can't take it, don't form
|
||||
// a group; the gesture degrades to a normal single-clip edit of the grabbed clip.
|
||||
if (!elements.every(canEdit)) return null;
|
||||
const members = elements.map(mapMember);
|
||||
return members.length > 1 ? members : null;
|
||||
}
|
||||
|
||||
function moveMember(element: TimelineElement): GroupTimingMember {
|
||||
return {
|
||||
element,
|
||||
key: elementKey(element),
|
||||
start: element.start,
|
||||
duration: element.duration,
|
||||
};
|
||||
}
|
||||
|
||||
function resizeMember(edge: TimelineGroupResizeEdge, element: TimelineElement): GroupTimingMember {
|
||||
const shouldSeedPlaybackStart = edge === "start" && isMediaElement(element);
|
||||
return {
|
||||
element,
|
||||
key: elementKey(element),
|
||||
start: element.start,
|
||||
duration: element.duration,
|
||||
playbackStart: shouldSeedPlaybackStart ? (element.playbackStart ?? 0) : element.playbackStart,
|
||||
playbackRate: element.playbackRate,
|
||||
};
|
||||
}
|
||||
|
||||
function sameGesture(sessionKey: string, element: TimelineElement): boolean {
|
||||
return sessionKey === elementKey(element);
|
||||
}
|
||||
|
||||
function createMoveSession(
|
||||
element: TimelineElement,
|
||||
selectedElementIds: Set<string>,
|
||||
timelineElements: readonly TimelineElement[],
|
||||
): MoveSession | null {
|
||||
const members = selectedMembers(
|
||||
element,
|
||||
selectedElementIds,
|
||||
timelineElements,
|
||||
moveMember,
|
||||
(candidate) => getTimelineEditCapabilities(candidate).canMove,
|
||||
);
|
||||
if (!members) return null;
|
||||
return {
|
||||
grabbedKey: elementKey(element),
|
||||
members,
|
||||
changes: [],
|
||||
hasChanged: false,
|
||||
};
|
||||
}
|
||||
|
||||
function createResizeSession(
|
||||
element: TimelineElement,
|
||||
selectedElementIds: Set<string>,
|
||||
timelineElements: readonly TimelineElement[],
|
||||
edge: TimelineGroupResizeEdge,
|
||||
): ResizeSession | null {
|
||||
const members = selectedMembers(
|
||||
element,
|
||||
selectedElementIds,
|
||||
timelineElements,
|
||||
(member) => resizeMember(edge, member),
|
||||
(candidate) => {
|
||||
const caps = getTimelineEditCapabilities(candidate);
|
||||
return edge === "start" ? caps.canTrimStart : caps.canTrimEnd;
|
||||
},
|
||||
);
|
||||
if (!members) return null;
|
||||
return {
|
||||
grabbedKey: elementKey(element),
|
||||
edge,
|
||||
members,
|
||||
changes: [],
|
||||
hasChanged: false,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMoveChanges(
|
||||
session: MoveSession,
|
||||
previewStart: number,
|
||||
): TimelineGroupMoveChange[] | null {
|
||||
const grabbed = session.members.find((member) => member.key === session.grabbedKey);
|
||||
if (!grabbed) return null;
|
||||
const result = resolveTimelineGroupMove(session.members, previewStart - grabbed.start);
|
||||
return result.members.map((member, index) => ({
|
||||
element: session.members[index]!.element,
|
||||
start: member.start,
|
||||
}));
|
||||
}
|
||||
|
||||
function resizeRawDelta(session: ResizeSession, updates: TimelineResizeUpdates): number | null {
|
||||
const grabbed = session.members.find((member) => member.key === session.grabbedKey);
|
||||
if (!grabbed) return null;
|
||||
return session.edge === "start"
|
||||
? updates.start - grabbed.start
|
||||
: updates.duration - grabbed.duration;
|
||||
}
|
||||
|
||||
function resolveResizeChanges(
|
||||
session: ResizeSession,
|
||||
updates: TimelineResizeUpdates,
|
||||
): TimelineGroupResizeChange[] | null {
|
||||
const rawDelta = resizeRawDelta(session, updates);
|
||||
if (rawDelta == null) return null;
|
||||
const result = resolveTimelineGroupResize(session.members, session.edge, rawDelta);
|
||||
return result.members.map((member, index) => ({
|
||||
element: session.members[index]!.element,
|
||||
start: member.start,
|
||||
duration: member.duration,
|
||||
playbackStart: member.playbackStart,
|
||||
}));
|
||||
}
|
||||
|
||||
function moveSessionHasChanged(
|
||||
session: MoveSession,
|
||||
changes: readonly TimelineGroupMoveChange[],
|
||||
): boolean {
|
||||
return changes.some((change, index) => change.start !== session.members[index]!.start);
|
||||
}
|
||||
|
||||
function resizeSessionHasChanged(
|
||||
session: ResizeSession,
|
||||
changes: readonly TimelineGroupResizeChange[],
|
||||
): boolean {
|
||||
return changes.some((change, index) => {
|
||||
const member = session.members[index]!;
|
||||
return (
|
||||
change.start !== member.start ||
|
||||
change.duration !== member.duration ||
|
||||
change.playbackStart !== member.playbackStart
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function previewStartForGrabbed(
|
||||
session: MoveSession,
|
||||
changes: readonly TimelineGroupMoveChange[],
|
||||
fallback: number,
|
||||
): number {
|
||||
const change = changes.find((candidate) => elementKey(candidate.element) === session.grabbedKey);
|
||||
return change?.start ?? fallback;
|
||||
}
|
||||
|
||||
function resizeUpdatesForGrabbed(
|
||||
session: ResizeSession,
|
||||
changes: readonly TimelineGroupResizeChange[],
|
||||
fallback: TimelineResizeUpdates,
|
||||
): TimelineResizeUpdates {
|
||||
const change = changes.find((candidate) => elementKey(candidate.element) === session.grabbedKey);
|
||||
if (!change) return fallback;
|
||||
return {
|
||||
start: change.start,
|
||||
duration: change.duration,
|
||||
playbackStart: change.playbackStart,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTimelineClipGroupDrag({
|
||||
timelineElementsRef,
|
||||
updateElement,
|
||||
onMoveElementsRef,
|
||||
onResizeElementsRef,
|
||||
onPreviewMoveElementsRef,
|
||||
onPreviewResizeElementsRef,
|
||||
}: UseTimelineClipGroupDragInput) {
|
||||
const moveSessionRef = useRef<MoveSession | null>(null);
|
||||
const resizeSessionRef = useRef<ResizeSession | null>(null);
|
||||
|
||||
const rollbackMove = useCallback(
|
||||
(session: MoveSession) => {
|
||||
const changes = session.members.map((member) => ({
|
||||
element: member.element,
|
||||
start: member.start,
|
||||
}));
|
||||
for (const change of changes) {
|
||||
updateElement(elementKey(change.element), { start: change.start });
|
||||
}
|
||||
onPreviewMoveElementsRef.current?.(changes);
|
||||
},
|
||||
[onPreviewMoveElementsRef, updateElement],
|
||||
);
|
||||
|
||||
const rollbackResize = useCallback(
|
||||
(session: ResizeSession) => {
|
||||
const changes = session.members.map((member) => ({
|
||||
element: member.element,
|
||||
start: member.start,
|
||||
duration: member.duration,
|
||||
playbackStart: member.playbackStart,
|
||||
}));
|
||||
for (const change of changes) {
|
||||
updateElement(elementKey(change.element), {
|
||||
start: change.start,
|
||||
duration: change.duration,
|
||||
playbackStart: change.playbackStart,
|
||||
});
|
||||
}
|
||||
onPreviewResizeElementsRef.current?.(changes);
|
||||
},
|
||||
[onPreviewResizeElementsRef, updateElement],
|
||||
);
|
||||
|
||||
const previewGroupMove = useCallback(
|
||||
(
|
||||
element: TimelineElement,
|
||||
selectedElementIds: Set<string>,
|
||||
previewStart: number,
|
||||
): PreviewGroupMoveResult => {
|
||||
let session = moveSessionRef.current;
|
||||
if (!session || !sameGesture(session.grabbedKey, element)) {
|
||||
if (!onMoveElementsRef.current) return { active: false, previewStart };
|
||||
session = createMoveSession(element, selectedElementIds, timelineElementsRef.current);
|
||||
if (!session) return { active: false, previewStart };
|
||||
moveSessionRef.current = session;
|
||||
}
|
||||
|
||||
const changes = resolveMoveChanges(session, previewStart);
|
||||
if (!changes) return { active: false, previewStart };
|
||||
session.changes = changes;
|
||||
session.hasChanged = moveSessionHasChanged(session, changes);
|
||||
|
||||
for (const change of changes) {
|
||||
updateElement(elementKey(change.element), { start: change.start });
|
||||
}
|
||||
onPreviewMoveElementsRef.current?.(changes);
|
||||
|
||||
return {
|
||||
active: true,
|
||||
previewStart: previewStartForGrabbed(session, changes, previewStart),
|
||||
};
|
||||
},
|
||||
[onMoveElementsRef, onPreviewMoveElementsRef, timelineElementsRef, updateElement],
|
||||
);
|
||||
|
||||
const previewGroupResize = useCallback(
|
||||
(
|
||||
element: TimelineElement,
|
||||
selectedElementIds: Set<string>,
|
||||
edge: TimelineGroupResizeEdge,
|
||||
updates: TimelineResizeUpdates,
|
||||
): PreviewGroupResizeResult => {
|
||||
let session = resizeSessionRef.current;
|
||||
if (!session || !sameGesture(session.grabbedKey, element) || session.edge !== edge) {
|
||||
if (!onResizeElementsRef.current) return { active: false, updates };
|
||||
session = createResizeSession(
|
||||
element,
|
||||
selectedElementIds,
|
||||
timelineElementsRef.current,
|
||||
edge,
|
||||
);
|
||||
if (!session) return { active: false, updates };
|
||||
resizeSessionRef.current = session;
|
||||
}
|
||||
|
||||
const changes = resolveResizeChanges(session, updates);
|
||||
if (!changes) return { active: false, updates };
|
||||
session.changes = changes;
|
||||
session.hasChanged = resizeSessionHasChanged(session, changes);
|
||||
|
||||
for (const change of changes) {
|
||||
updateElement(elementKey(change.element), {
|
||||
start: change.start,
|
||||
duration: change.duration,
|
||||
playbackStart: change.playbackStart,
|
||||
});
|
||||
}
|
||||
onPreviewResizeElementsRef.current?.(changes);
|
||||
|
||||
return {
|
||||
active: true,
|
||||
updates: resizeUpdatesForGrabbed(session, changes, updates),
|
||||
};
|
||||
},
|
||||
[onPreviewResizeElementsRef, onResizeElementsRef, timelineElementsRef, updateElement],
|
||||
);
|
||||
|
||||
const commitGroupMove = useCallback(
|
||||
(element: TimelineElement): boolean => {
|
||||
const session = moveSessionRef.current;
|
||||
if (!session || !sameGesture(session.grabbedKey, element)) return false;
|
||||
moveSessionRef.current = null;
|
||||
if (!session.hasChanged) return true;
|
||||
|
||||
Promise.resolve(onMoveElementsRef.current?.(session.changes)).catch((error) => {
|
||||
rollbackMove(session);
|
||||
console.error("[Timeline] Failed to persist group clip move", error);
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[onMoveElementsRef, rollbackMove],
|
||||
);
|
||||
|
||||
const commitGroupResize = useCallback(
|
||||
(element: TimelineElement): boolean => {
|
||||
const session = resizeSessionRef.current;
|
||||
if (!session || !sameGesture(session.grabbedKey, element)) return false;
|
||||
resizeSessionRef.current = null;
|
||||
if (!session.hasChanged) return true;
|
||||
|
||||
Promise.resolve(onResizeElementsRef.current?.(session.changes)).catch((error) => {
|
||||
rollbackResize(session);
|
||||
console.error("[Timeline] Failed to persist group clip resize", error);
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[onResizeElementsRef, rollbackResize],
|
||||
);
|
||||
|
||||
const clearGroupDragSessions = useCallback(() => {
|
||||
moveSessionRef.current = null;
|
||||
resizeSessionRef.current = null;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
previewGroupMove,
|
||||
previewGroupResize,
|
||||
commitGroupMove,
|
||||
commitGroupResize,
|
||||
clearGroupDragSessions,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useCallback, type MouseEvent as ReactMouseEvent } from "react";
|
||||
import type { TimelineElement, KeyframeCacheEntry } from "../store/playerStore";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import type { KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
|
||||
|
||||
interface UseTimelineKeyframeHandlersInput {
|
||||
expandedElements: TimelineElement[];
|
||||
keyframeCache: Map<string, KeyframeCacheEntry>;
|
||||
onSelectElement?: (element: TimelineElement | null) => void;
|
||||
onSeek?: (time: number) => void;
|
||||
setSelectedElementId: (id: string | null) => void;
|
||||
setKfContextMenu: (state: KeyframeDiamondContextMenuState | null) => void;
|
||||
toggleSelectedKeyframe: (key: string) => void;
|
||||
}
|
||||
|
||||
export function useTimelineKeyframeHandlers({
|
||||
expandedElements,
|
||||
keyframeCache,
|
||||
onSelectElement,
|
||||
onSeek,
|
||||
setSelectedElementId,
|
||||
setKfContextMenu,
|
||||
toggleSelectedKeyframe,
|
||||
}: UseTimelineKeyframeHandlersInput) {
|
||||
const onClickKeyframe = useCallback(
|
||||
(el: TimelineElement, pct: number) => {
|
||||
usePlayerStore.getState().clearSelectedKeyframes();
|
||||
const elKey = el.key ?? el.id;
|
||||
setSelectedElementId(elKey);
|
||||
onSelectElement?.(el);
|
||||
toggleSelectedKeyframe(`${elKey}:${pct}`);
|
||||
onSeek?.(el.start + (pct / 100) * el.duration);
|
||||
const kfData = keyframeCache.get(elKey);
|
||||
const kf = kfData?.keyframes.find((item) => Math.abs(item.percentage - pct) < 0.5);
|
||||
usePlayerStore.getState().setActiveKeyframePct(kf?.tweenPercentage ?? null);
|
||||
},
|
||||
[keyframeCache, onSeek, onSelectElement, setSelectedElementId, toggleSelectedKeyframe],
|
||||
);
|
||||
|
||||
const onShiftClickKeyframe = useCallback(
|
||||
(elId: string, pct: number) => {
|
||||
toggleSelectedKeyframe(`${elId}:${pct}`);
|
||||
},
|
||||
[toggleSelectedKeyframe],
|
||||
);
|
||||
|
||||
const onContextMenuKeyframe = useCallback(
|
||||
(e: ReactMouseEvent, elId: string, pct: number) => {
|
||||
const el = expandedElements.find((item) => (item.key ?? item.id) === elId);
|
||||
if (el) {
|
||||
setSelectedElementId(elId);
|
||||
onSelectElement?.(el);
|
||||
}
|
||||
const kfData = keyframeCache.get(elId);
|
||||
const kf = kfData?.keyframes.find((item) => Math.abs(item.percentage - pct) < 0.2);
|
||||
setKfContextMenu({
|
||||
x: e.clientX + 4,
|
||||
y: e.clientY + 2,
|
||||
elementId: elId,
|
||||
percentage: pct,
|
||||
tweenPercentage: kf?.tweenPercentage,
|
||||
currentEase: kf?.ease ?? kfData?.ease,
|
||||
});
|
||||
},
|
||||
[expandedElements, keyframeCache, onSelectElement, setKfContextMenu, setSelectedElementId],
|
||||
);
|
||||
|
||||
return {
|
||||
onClickKeyframe,
|
||||
onShiftClickKeyframe,
|
||||
onContextMenuKeyframe,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import { GUTTER, RULER_H, TRACK_H } from "./timelineLayout";
|
||||
import type { StackingTimelineLayer } from "./timelineTrackOrder";
|
||||
import { useTimelineMarqueeSelection } from "./useTimelineMarqueeSelection";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function element(id: string, start: number, duration: number, track: number): TimelineElement {
|
||||
return { id, tag: "div", start, duration, track };
|
||||
}
|
||||
|
||||
function layer(id: string, elements: TimelineElement[]): StackingTimelineLayer {
|
||||
return {
|
||||
id,
|
||||
kind: "visual",
|
||||
contextKey: "",
|
||||
zIndex: 0,
|
||||
placementTrack: elements[0]?.track ?? 0,
|
||||
elements,
|
||||
};
|
||||
}
|
||||
|
||||
function pointerEvent(type: string, init: MouseEventInit & { pointerId?: number }) {
|
||||
const event = new MouseEvent(type, { bubbles: true, cancelable: true, ...init });
|
||||
Object.defineProperty(event, "pointerId", { value: init.pointerId ?? 1 });
|
||||
return event;
|
||||
}
|
||||
|
||||
function dispatchPointer(
|
||||
target: HTMLElement,
|
||||
type: "pointerdown" | "pointermove" | "pointerup",
|
||||
point: { x: number; y: number },
|
||||
) {
|
||||
target.dispatchEvent(pointerEvent(type, { button: 0, clientX: point.x, clientY: point.y }));
|
||||
}
|
||||
|
||||
function dragMarquee(
|
||||
harness: ReturnType<typeof renderMarqueeHarness>,
|
||||
start: { x: number; y: number },
|
||||
end: { x: number; y: number },
|
||||
downTarget: HTMLElement = harness.scroll,
|
||||
) {
|
||||
dispatchPointer(downTarget, "pointerdown", start);
|
||||
dispatchPointer(harness.scroll, "pointermove", end);
|
||||
dispatchPointer(harness.scroll, "pointerup", end);
|
||||
}
|
||||
|
||||
function renderMarqueeHarness(layers: StackingTimelineLayer[]) {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const layerOrder = layers.map((item) => item.id);
|
||||
const setShowPopover = () => {};
|
||||
const setRangeSelection = () => {};
|
||||
const seekedX: number[] = [];
|
||||
|
||||
function Harness() {
|
||||
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const hook = useTimelineMarqueeSelection({
|
||||
scrollRef,
|
||||
ppsRef: { current: 100 },
|
||||
trackOrderRef: { current: layerOrder },
|
||||
timelineLayersRef: { current: layers },
|
||||
setShowPopover,
|
||||
setRangeSelectionRef: { current: setRangeSelection },
|
||||
seekFromX: (clientX: number) => seekedX.push(clientX),
|
||||
});
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
data-scroll="true"
|
||||
onPointerDown={(event) => {
|
||||
hook.handlePointerDown(event);
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
hook.handlePointerMove(event);
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
hook.handlePointerUp(event);
|
||||
}}
|
||||
>
|
||||
<button data-clip="true">clip</button>
|
||||
{hook.marqueeRect && <span data-marquee="true" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(<Harness />);
|
||||
});
|
||||
const scroll = host.querySelector<HTMLElement>("[data-scroll]");
|
||||
if (!scroll) throw new Error("Expected scroll host");
|
||||
Object.defineProperty(scroll, "clientWidth", { configurable: true, value: 160 });
|
||||
Object.defineProperty(scroll, "clientHeight", { configurable: true, value: 160 });
|
||||
Object.defineProperty(scroll, "scrollWidth", { configurable: true, value: 600 });
|
||||
Object.defineProperty(scroll, "scrollHeight", { configurable: true, value: 260 });
|
||||
scroll.getBoundingClientRect = () =>
|
||||
({
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 160,
|
||||
bottom: 160,
|
||||
width: 160,
|
||||
height: 160,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
|
||||
return {
|
||||
host,
|
||||
scroll,
|
||||
root,
|
||||
seekedX,
|
||||
clip: host.querySelector<HTMLElement>("[data-clip]")!,
|
||||
unmount() {
|
||||
act(() => root.unmount());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
usePlayerStore.getState().reset();
|
||||
});
|
||||
|
||||
describe("useTimelineMarqueeSelection", () => {
|
||||
it("selects clips intersecting an empty-lane drag rectangle", () => {
|
||||
const layers = [
|
||||
layer("lane-0", [element("first", 0.5, 0.5, 0)]),
|
||||
layer("lane-1", [element("second", 2, 0.5, 1)]),
|
||||
layer("lane-2", [element("third", 0.5, 0.5, 2)]),
|
||||
];
|
||||
const harness = renderMarqueeHarness(layers);
|
||||
const start = { x: GUTTER + 10, y: RULER_H + 4 };
|
||||
const end = { x: GUTTER + 260, y: RULER_H + TRACK_H * 2 - 2 };
|
||||
|
||||
act(() => {
|
||||
dispatchPointer(harness.scroll, "pointerdown", start);
|
||||
dispatchPointer(harness.scroll, "pointermove", end);
|
||||
});
|
||||
expect(harness.host.querySelector("[data-marquee]")).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
dispatchPointer(harness.scroll, "pointerup", end);
|
||||
});
|
||||
|
||||
expect([...usePlayerStore.getState().selectedElementIds]).toEqual(["first", "second"]);
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("treats a sub-threshold empty-lane drag as a clear click that also seeks", () => {
|
||||
usePlayerStore.getState().setSelection(["selected"]);
|
||||
const harness = renderMarqueeHarness([layer("lane-0", [element("selected", 0, 1, 0)])]);
|
||||
|
||||
act(() => {
|
||||
dragMarquee(harness, { x: GUTTER + 20, y: RULER_H + 4 }, { x: GUTTER + 21, y: RULER_H + 5 });
|
||||
});
|
||||
|
||||
expect(usePlayerStore.getState().selectedElementIds.size).toBe(0);
|
||||
expect(harness.host.querySelector("[data-marquee]")).toBeNull();
|
||||
// A sub-threshold press still scrubs the playhead to the click, like a plain lane click.
|
||||
expect(harness.seekedX).toEqual([GUTTER + 20]);
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("does not start from clips or the ruler", () => {
|
||||
usePlayerStore.getState().setSelection(["kept"]);
|
||||
const harness = renderMarqueeHarness([layer("lane-0", [element("kept", 0, 1, 0)])]);
|
||||
|
||||
act(() => {
|
||||
dragMarquee(
|
||||
harness,
|
||||
{ x: GUTTER + 20, y: RULER_H + 4 },
|
||||
{ x: GUTTER + 80, y: RULER_H + 40 },
|
||||
harness.clip,
|
||||
);
|
||||
dragMarquee(harness, { x: GUTTER + 20, y: RULER_H - 2 }, { x: GUTTER + 80, y: RULER_H + 40 });
|
||||
});
|
||||
|
||||
expect([...usePlayerStore.getState().selectedElementIds]).toEqual(["kept"]);
|
||||
expect(harness.host.querySelector("[data-marquee]")).toBeNull();
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("clears selection when the released marquee hits no clips", () => {
|
||||
usePlayerStore.getState().setSelection(["selected"]);
|
||||
const harness = renderMarqueeHarness([layer("lane-0", [element("selected", 4, 1, 0)])]);
|
||||
|
||||
act(() => {
|
||||
dragMarquee(harness, { x: GUTTER + 10, y: RULER_H + 4 }, { x: GUTTER + 80, y: RULER_H + 40 });
|
||||
});
|
||||
|
||||
expect(usePlayerStore.getState().selectedElementIds.size).toBe(0);
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("uses autoscroll position when resolving the released marquee", () => {
|
||||
const originalRaf = window.requestAnimationFrame;
|
||||
const originalCancel = window.cancelAnimationFrame;
|
||||
const callbacks: FrameRequestCallback[] = [];
|
||||
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
||||
callbacks.push(callback);
|
||||
return callbacks.length;
|
||||
}) as typeof window.requestAnimationFrame;
|
||||
window.cancelAnimationFrame = (() => {}) as typeof window.cancelAnimationFrame;
|
||||
const harness = renderMarqueeHarness([layer("lane-0", [element("reachable", 1.28, 0.2, 0)])]);
|
||||
|
||||
try {
|
||||
act(() => {
|
||||
dispatchPointer(harness.scroll, "pointerdown", { x: GUTTER + 10, y: RULER_H + 4 });
|
||||
dispatchPointer(harness.scroll, "pointermove", { x: 155, y: RULER_H + 40 });
|
||||
callbacks.shift()?.(0);
|
||||
dispatchPointer(harness.scroll, "pointerup", { x: 155, y: RULER_H + 40 });
|
||||
});
|
||||
|
||||
expect(harness.scroll.scrollLeft).toBeGreaterThan(0);
|
||||
expect([...usePlayerStore.getState().selectedElementIds]).toEqual(["reachable"]);
|
||||
harness.unmount();
|
||||
} finally {
|
||||
window.requestAnimationFrame = originalRaf;
|
||||
window.cancelAnimationFrame = originalCancel;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import {
|
||||
resolveTimelineAutoScroll,
|
||||
selectTimelineElementsInMarquee,
|
||||
type TimelineMarqueeSelectionRect,
|
||||
} from "./timelineEditing";
|
||||
import { GUTTER, RULER_H, TRACK_H } from "./timelineLayout";
|
||||
import { TIMELINE_LAYER_GROUP_HEADER_H } from "./TimelineLayerGroupHeader";
|
||||
import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
|
||||
|
||||
const MARQUEE_THRESHOLD_PX = 4;
|
||||
|
||||
export interface TimelineMarqueeOverlayRect {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface ActiveMarqueeGesture {
|
||||
pointerId: number;
|
||||
anchorClientX: number;
|
||||
anchorClientY: number;
|
||||
anchorX: number;
|
||||
anchorY: number;
|
||||
lastClientX: number;
|
||||
lastClientY: number;
|
||||
started: boolean;
|
||||
}
|
||||
|
||||
interface UseTimelineMarqueeSelectionInput {
|
||||
scrollRef: RefObject<HTMLDivElement | null>;
|
||||
ppsRef: RefObject<number>;
|
||||
trackOrderRef: RefObject<TimelineLayerId[]>;
|
||||
timelineLayersRef: RefObject<StackingTimelineLayer[]>;
|
||||
disabled?: boolean;
|
||||
setShowPopover: (show: boolean) => void;
|
||||
setRangeSelectionRef: RefObject<((sel: null) => void) | null>;
|
||||
/** Canonical playhead seek, used to keep empty-lane clicks scrubbing the playhead. */
|
||||
seekFromX: (clientX: number) => void;
|
||||
}
|
||||
|
||||
function getCanvasPoint(scroll: HTMLDivElement, clientX: number, clientY: number) {
|
||||
const rect = scroll.getBoundingClientRect();
|
||||
return {
|
||||
x: clientX - rect.left + scroll.scrollLeft,
|
||||
y: clientY - rect.top + scroll.scrollTop,
|
||||
};
|
||||
}
|
||||
|
||||
function getMarqueeStartPoint(
|
||||
event: ReactPointerEvent<HTMLDivElement>,
|
||||
scroll: HTMLDivElement | null,
|
||||
disabled: boolean,
|
||||
) {
|
||||
if (disabled || event.button !== 0 || event.shiftKey || !scroll) return null;
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest("[data-clip]")) return null;
|
||||
const point = getCanvasPoint(scroll, event.clientX, event.clientY);
|
||||
if (point.x < GUTTER || point.y < RULER_H) return null;
|
||||
return point;
|
||||
}
|
||||
|
||||
function capturePointerIfAvailable(event: ReactPointerEvent<HTMLDivElement>) {
|
||||
const currentTarget = event.currentTarget as HTMLElement;
|
||||
if (typeof currentTarget.setPointerCapture === "function") {
|
||||
currentTarget.setPointerCapture(event.pointerId);
|
||||
}
|
||||
}
|
||||
|
||||
function buildSelectionRect(
|
||||
active: ActiveMarqueeGesture,
|
||||
scroll: HTMLDivElement,
|
||||
pps: number,
|
||||
): { overlay: TimelineMarqueeOverlayRect; selection: TimelineMarqueeSelectionRect } {
|
||||
const current = getCanvasPoint(scroll, active.lastClientX, active.lastClientY);
|
||||
const left = Math.min(active.anchorX, current.x);
|
||||
const right = Math.max(active.anchorX, current.x);
|
||||
const top = Math.min(active.anchorY, current.y);
|
||||
const bottom = Math.max(active.anchorY, current.y);
|
||||
const overlayLeft = Math.max(GUTTER, left);
|
||||
const overlayTop = Math.max(RULER_H, top);
|
||||
const overlayRight = Math.max(overlayLeft, right);
|
||||
const overlayBottom = Math.max(overlayTop, bottom);
|
||||
|
||||
// Hit-test must use the SAME pixels-per-second the overlay is drawn at, or the
|
||||
// selected time span diverges from the visible box at low zoom (pps < 1). Guard
|
||||
// only against a non-finite/zero pps (would yield NaN/Infinity), never floor it.
|
||||
const safePps = Number.isFinite(pps) && pps > 0 ? pps : 0;
|
||||
const timeFromX = (x: number) => (safePps > 0 ? Math.max(0, (x - GUTTER) / safePps) : 0);
|
||||
|
||||
return {
|
||||
overlay: {
|
||||
left: overlayLeft,
|
||||
top: overlayTop,
|
||||
width: overlayRight - overlayLeft,
|
||||
height: overlayBottom - overlayTop,
|
||||
},
|
||||
selection: {
|
||||
startTime: timeFromX(left),
|
||||
endTime: timeFromX(right),
|
||||
top,
|
||||
bottom,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useTimelineMarqueeSelection({
|
||||
scrollRef,
|
||||
ppsRef,
|
||||
trackOrderRef,
|
||||
timelineLayersRef,
|
||||
disabled = false,
|
||||
setShowPopover,
|
||||
setRangeSelectionRef,
|
||||
seekFromX,
|
||||
}: UseTimelineMarqueeSelectionInput) {
|
||||
const activeRef = useRef<ActiveMarqueeGesture | null>(null);
|
||||
const pointerRef = useRef<{ clientX: number; clientY: number } | null>(null);
|
||||
const scrollRafRef = useRef(0);
|
||||
const [marqueeRect, setMarqueeRect] = useState<TimelineMarqueeOverlayRect | null>(null);
|
||||
|
||||
const stopAutoScroll = useCallback(() => {
|
||||
pointerRef.current = null;
|
||||
if (scrollRafRef.current) {
|
||||
cancelAnimationFrame(scrollRafRef.current);
|
||||
scrollRafRef.current = 0;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateMarqueeRect = useCallback(() => {
|
||||
const active = activeRef.current;
|
||||
const scroll = scrollRef.current;
|
||||
if (!active || !scroll) return null;
|
||||
const rects = buildSelectionRect(active, scroll, ppsRef.current);
|
||||
setMarqueeRect(rects.overlay);
|
||||
return rects;
|
||||
}, [ppsRef, scrollRef]);
|
||||
|
||||
const stepAutoScroll = useCallback(() => {
|
||||
scrollRafRef.current = 0;
|
||||
const pointer = pointerRef.current;
|
||||
const scroll = scrollRef.current;
|
||||
if (!pointer || !scroll || !activeRef.current) return;
|
||||
|
||||
const delta = resolveTimelineAutoScroll(
|
||||
scroll.getBoundingClientRect(),
|
||||
pointer.clientX,
|
||||
pointer.clientY,
|
||||
);
|
||||
if (delta.x === 0 && delta.y === 0) return;
|
||||
|
||||
const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
|
||||
const maxScrollTop = Math.max(0, scroll.scrollHeight - scroll.clientHeight);
|
||||
const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, scroll.scrollLeft + delta.x));
|
||||
const nextScrollTop = Math.max(0, Math.min(maxScrollTop, scroll.scrollTop + delta.y));
|
||||
if (nextScrollLeft === scroll.scrollLeft && nextScrollTop === scroll.scrollTop) return;
|
||||
|
||||
scroll.scrollLeft = nextScrollLeft;
|
||||
scroll.scrollTop = nextScrollTop;
|
||||
updateMarqueeRect();
|
||||
scrollRafRef.current = requestAnimationFrame(stepAutoScroll);
|
||||
}, [scrollRef, updateMarqueeRect]);
|
||||
|
||||
const syncAutoScroll = useCallback(
|
||||
(clientX: number, clientY: number) => {
|
||||
pointerRef.current = { clientX, clientY };
|
||||
const scroll = scrollRef.current;
|
||||
if (!scroll) return;
|
||||
const delta = resolveTimelineAutoScroll(scroll.getBoundingClientRect(), clientX, clientY);
|
||||
if (delta.x === 0 && delta.y === 0) {
|
||||
if (scrollRafRef.current) {
|
||||
cancelAnimationFrame(scrollRafRef.current);
|
||||
scrollRafRef.current = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!scrollRafRef.current) {
|
||||
scrollRafRef.current = requestAnimationFrame(stepAutoScroll);
|
||||
}
|
||||
},
|
||||
[scrollRef, stepAutoScroll],
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const point = getMarqueeStartPoint(event, scrollRef.current, disabled);
|
||||
if (!point) return false;
|
||||
capturePointerIfAvailable(event);
|
||||
activeRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
anchorClientX: event.clientX,
|
||||
anchorClientY: event.clientY,
|
||||
anchorX: point.x,
|
||||
anchorY: point.y,
|
||||
lastClientX: event.clientX,
|
||||
lastClientY: event.clientY,
|
||||
started: false,
|
||||
};
|
||||
setShowPopover(false);
|
||||
setRangeSelectionRef.current?.(null);
|
||||
return true;
|
||||
},
|
||||
[disabled, scrollRef, setRangeSelectionRef, setShowPopover],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const active = activeRef.current;
|
||||
if (!active || active.pointerId !== event.pointerId) return false;
|
||||
active.lastClientX = event.clientX;
|
||||
active.lastClientY = event.clientY;
|
||||
const distance = Math.hypot(
|
||||
event.clientX - active.anchorClientX,
|
||||
event.clientY - active.anchorClientY,
|
||||
);
|
||||
if (!active.started && distance < MARQUEE_THRESHOLD_PX) return true;
|
||||
active.started = true;
|
||||
updateMarqueeRect();
|
||||
syncAutoScroll(event.clientX, event.clientY);
|
||||
return true;
|
||||
},
|
||||
[syncAutoScroll, updateMarqueeRect],
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(
|
||||
(event?: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const active = activeRef.current;
|
||||
if (!active || (event && active.pointerId !== event.pointerId)) return false;
|
||||
activeRef.current = null;
|
||||
stopAutoScroll();
|
||||
setMarqueeRect(null);
|
||||
|
||||
if (!active.started) {
|
||||
// A press that never crossed the marquee threshold is a plain empty-lane
|
||||
// click: clear the selection AND scrub the playhead, matching the seek that
|
||||
// the range/playhead handler would have run had the marquee not claimed it.
|
||||
usePlayerStore.getState().clearSelection();
|
||||
seekFromX(active.anchorClientX);
|
||||
return true;
|
||||
}
|
||||
|
||||
const scroll = scrollRef.current;
|
||||
if (!scroll) return true;
|
||||
const rects = buildSelectionRect(active, scroll, ppsRef.current);
|
||||
const selectedIds = selectTimelineElementsInMarquee({
|
||||
rect: rects.selection,
|
||||
layers: timelineLayersRef.current,
|
||||
layerOrder: trackOrderRef.current,
|
||||
rulerHeight: RULER_H,
|
||||
trackHeight: TRACK_H,
|
||||
groupHeaderHeight: TIMELINE_LAYER_GROUP_HEADER_H,
|
||||
});
|
||||
usePlayerStore.getState().setSelection(selectedIds);
|
||||
return true;
|
||||
},
|
||||
[ppsRef, scrollRef, seekFromX, stopAutoScroll, timelineLayersRef, trackOrderRef],
|
||||
);
|
||||
|
||||
useEffect(() => stopAutoScroll, [stopAutoScroll]);
|
||||
|
||||
return {
|
||||
marqueeRect,
|
||||
handlePointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
};
|
||||
}
|
||||
@@ -222,6 +222,98 @@ describe("usePlayerStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectedElementIds", () => {
|
||||
it("sets a multi-id selection with a coherent anchor", () => {
|
||||
usePlayerStore.getState().setSelection(["el-1", "el-2", "el-3"], "el-2");
|
||||
|
||||
const state = usePlayerStore.getState();
|
||||
expect([...state.selectedElementIds]).toEqual(["el-1", "el-2", "el-3"]);
|
||||
expect(state.selectedElementId).toBe("el-2");
|
||||
});
|
||||
|
||||
it("falls back to the first selected id when the anchor is outside the set", () => {
|
||||
usePlayerStore.getState().setSelection(["el-1", "el-2"], "missing");
|
||||
|
||||
const state = usePlayerStore.getState();
|
||||
expect([...state.selectedElementIds]).toEqual(["el-1", "el-2"]);
|
||||
expect(state.selectedElementId).toBe("el-1");
|
||||
});
|
||||
|
||||
it("single-click selection replaces the set with the selected id", () => {
|
||||
const store = usePlayerStore.getState();
|
||||
store.setSelection(["el-1", "el-2"], "el-2");
|
||||
store.setSelectedElementId("el-3");
|
||||
|
||||
const state = usePlayerStore.getState();
|
||||
expect([...state.selectedElementIds]).toEqual(["el-3"]);
|
||||
expect(state.selectedElementId).toBe("el-3");
|
||||
});
|
||||
|
||||
it("setSelectedElementId collapses to a single element even for a current member", () => {
|
||||
const store = usePlayerStore.getState();
|
||||
store.setSelection(["el-1", "el-2", "el-3"], "el-1");
|
||||
// A genuine single selection (click) collapses the set, even if the id was a member.
|
||||
store.setSelectedElementId("el-2");
|
||||
|
||||
const state = usePlayerStore.getState();
|
||||
expect([...state.selectedElementIds]).toEqual(["el-2"]);
|
||||
expect(state.selectedElementId).toBe("el-2");
|
||||
});
|
||||
|
||||
it("setSelectionAnchor moves the anchor within a group without collapsing it", () => {
|
||||
const store = usePlayerStore.getState();
|
||||
store.setSelection(["el-1", "el-2", "el-3"], "el-1");
|
||||
// A DOM->store echo during a group gesture only moves the anchor.
|
||||
store.setSelectionAnchor("el-2");
|
||||
|
||||
let state = usePlayerStore.getState();
|
||||
expect([...state.selectedElementIds]).toEqual(["el-1", "el-2", "el-3"]);
|
||||
expect(state.selectedElementId).toBe("el-2");
|
||||
|
||||
// A non-member anchor is a genuine new single selection.
|
||||
store.setSelectionAnchor("outside");
|
||||
state = usePlayerStore.getState();
|
||||
expect([...state.selectedElementIds]).toEqual(["outside"]);
|
||||
expect(state.selectedElementId).toBe("outside");
|
||||
});
|
||||
|
||||
it("clearing single selection empties the set", () => {
|
||||
const store = usePlayerStore.getState();
|
||||
store.setSelection(["el-1", "el-2"], "el-2");
|
||||
store.setSelectedElementId(null);
|
||||
|
||||
const state = usePlayerStore.getState();
|
||||
expect([...state.selectedElementIds]).toEqual([]);
|
||||
expect(state.selectedElementId).toBeNull();
|
||||
});
|
||||
|
||||
it("toggle adds and removes members while keeping the anchor in the set", () => {
|
||||
const store = usePlayerStore.getState();
|
||||
store.setSelectedElementId("el-1");
|
||||
store.toggleSelectedElementId("el-2");
|
||||
|
||||
let state = usePlayerStore.getState();
|
||||
expect([...state.selectedElementIds]).toEqual(["el-1", "el-2"]);
|
||||
expect(state.selectedElementId).toBe("el-1");
|
||||
|
||||
store.toggleSelectedElementId("el-1");
|
||||
|
||||
state = usePlayerStore.getState();
|
||||
expect([...state.selectedElementIds]).toEqual(["el-2"]);
|
||||
expect(state.selectedElementId).toBe("el-2");
|
||||
});
|
||||
|
||||
it("clearSelection empties the set and the anchor", () => {
|
||||
const store = usePlayerStore.getState();
|
||||
store.setSelection(["el-1", "el-2"], "el-2");
|
||||
store.clearSelection();
|
||||
|
||||
const state = usePlayerStore.getState();
|
||||
expect([...state.selectedElementIds]).toEqual([]);
|
||||
expect(state.selectedElementId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateElement", () => {
|
||||
it("updates the start time of a specific element", () => {
|
||||
usePlayerStore.getState().setElements([
|
||||
|
||||
@@ -75,6 +75,23 @@ export interface TimelineElement {
|
||||
export type ZoomMode = "fit" | "manual";
|
||||
type TimelineTool = "select" | "razor";
|
||||
|
||||
function resolveElementSelection(
|
||||
ids: Iterable<string>,
|
||||
anchor?: string | null,
|
||||
): { selectedElementIds: Set<string>; selectedElementId: string | null } {
|
||||
const selectedElementIds = new Set(ids);
|
||||
if (selectedElementIds.size === 0) {
|
||||
return { selectedElementIds, selectedElementId: null };
|
||||
}
|
||||
if (anchor && selectedElementIds.has(anchor)) {
|
||||
return { selectedElementIds, selectedElementId: anchor };
|
||||
}
|
||||
return {
|
||||
selectedElementIds,
|
||||
selectedElementId: selectedElementIds.values().next().value ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
interface PlayerState {
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
@@ -125,8 +142,10 @@ interface PlayerState {
|
||||
|
||||
/** Multi-select: additional selected elements beyond selectedElementId. */
|
||||
selectedElementIds: Set<string>;
|
||||
setSelection: (ids: Iterable<string>, anchor?: string | null) => void;
|
||||
addSelectedElementId: (id: string) => void;
|
||||
toggleSelectedElementId: (id: string) => void;
|
||||
clearSelectedElementIds: () => void;
|
||||
clearSelection: () => void;
|
||||
|
||||
/** Keyframe data per element id, populated from parsed GSAP animations. */
|
||||
keyframeCache: Map<string, KeyframeCacheEntry>;
|
||||
@@ -142,6 +161,8 @@ interface PlayerState {
|
||||
setBeatDragging: (dragging: boolean) => void;
|
||||
setElements: (elements: TimelineElement[]) => void;
|
||||
setSelectedElementId: (id: string | null) => void;
|
||||
/** Move the selection anchor within an active multi-selection without collapsing it. */
|
||||
setSelectionAnchor: (id: string | null) => void;
|
||||
updateElement: (
|
||||
elementId: string,
|
||||
updates: Partial<
|
||||
@@ -265,14 +286,21 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
setAutoKeyframeEnabled: (enabled) => set({ autoKeyframeEnabled: enabled }),
|
||||
|
||||
selectedElementIds: new Set<string>(),
|
||||
setSelection: (ids, anchor) => set(resolveElementSelection(ids, anchor)),
|
||||
addSelectedElementId: (id: string) =>
|
||||
set((s) => {
|
||||
const next = new Set(s.selectedElementIds);
|
||||
next.add(id);
|
||||
return resolveElementSelection(next, s.selectedElementId);
|
||||
}),
|
||||
toggleSelectedElementId: (id: string) =>
|
||||
set((s) => {
|
||||
const next = new Set(s.selectedElementIds);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return { selectedElementIds: next };
|
||||
return resolveElementSelection(next, s.selectedElementId);
|
||||
}),
|
||||
clearSelectedElementIds: () => set({ selectedElementIds: new Set() }),
|
||||
clearSelection: () => set({ selectedElementId: null, selectedElementIds: new Set() }),
|
||||
|
||||
keyframeCache: new Map(),
|
||||
setKeyframeCache: (elementId, data) =>
|
||||
@@ -383,16 +411,35 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
setTimelineReady: (ready) => set({ timelineReady: ready }),
|
||||
setBeatDragging: (dragging) => set({ beatDragging: dragging }),
|
||||
setElements: (elements) => set({ elements }),
|
||||
// A genuine single selection: always collapse the set to just this element. User
|
||||
// intent (timeline click, preview click via applyDomSelection) flows here; DOM sync
|
||||
// echoes that must preserve a group go through setSelectionAnchor instead.
|
||||
setSelectedElementId: (id) =>
|
||||
set((s) =>
|
||||
set((s) => {
|
||||
const selectedElementIds = id ? new Set([id]) : new Set<string>();
|
||||
// Selecting a different element drops any active keyframe selection — otherwise
|
||||
// a stale activeKeyframePct from a prior diamond click would force the next drag
|
||||
// to "modify" a keyframe on the new element. A diamond click sets the pct AFTER
|
||||
// calling setSelectedElementId, so this never clobbers a genuine keyframe select.
|
||||
id !== s.selectedElementId
|
||||
? { selectedElementId: id, activeKeyframePct: null, motionPathArmed: false }
|
||||
: { selectedElementId: id },
|
||||
),
|
||||
return id !== s.selectedElementId
|
||||
? {
|
||||
selectedElementId: id,
|
||||
selectedElementIds,
|
||||
activeKeyframePct: null,
|
||||
motionPathArmed: false,
|
||||
}
|
||||
: { selectedElementId: id, selectedElementIds };
|
||||
}),
|
||||
// Move the anchor within an active multi-selection WITHOUT collapsing it — used by
|
||||
// DOM->store sync echoes while a group gesture re-patches the preview. A non-member
|
||||
// id is treated as a genuine new single selection.
|
||||
setSelectionAnchor: (id) =>
|
||||
set((s) => {
|
||||
if (id != null && s.selectedElementIds.size > 1 && s.selectedElementIds.has(id)) {
|
||||
return { selectedElementId: id };
|
||||
}
|
||||
return { selectedElementId: id, selectedElementIds: id ? new Set([id]) : new Set<string>() };
|
||||
}),
|
||||
updateElement: (elementId, updates) =>
|
||||
set((state) => ({
|
||||
elements: state.elements.map((el) =>
|
||||
|
||||
@@ -209,6 +209,67 @@ describe("edit history", () => {
|
||||
expect(state.undo[0].files["index.html"].after).toBe("c");
|
||||
});
|
||||
|
||||
it("folds a slow GSAP follow-up into the timing edit via a per-entry coalesceMs override", () => {
|
||||
const timing = buildEditHistoryEntry({
|
||||
projectId: "project-1",
|
||||
label: "Resize timeline clip",
|
||||
kind: "timeline",
|
||||
coalesceKey: "timeline-resize:clip",
|
||||
files: { "index.html": { before: "orig", after: "timing" } },
|
||||
now: 0,
|
||||
id: "timing",
|
||||
});
|
||||
// The server GSAP rewrite lands ~2s later, past the 300ms default window, but the
|
||||
// follow-up carries a large coalesceMs so undo still collapses to a single step.
|
||||
const gsap = buildEditHistoryEntry({
|
||||
projectId: "project-1",
|
||||
label: "Resize timeline clip",
|
||||
kind: "timeline",
|
||||
coalesceKey: "timeline-resize:clip",
|
||||
coalesceMs: 10_000,
|
||||
files: { "index.html": { before: "timing", after: "timing+gsap" } },
|
||||
now: 2000,
|
||||
id: "gsap",
|
||||
});
|
||||
|
||||
const state = pushEditHistoryEntry(
|
||||
pushEditHistoryEntry(createEmptyEditHistory(), timing),
|
||||
gsap,
|
||||
);
|
||||
|
||||
expect(state.undo).toHaveLength(1);
|
||||
expect(state.undo[0].files["index.html"].before).toBe("orig");
|
||||
expect(state.undo[0].files["index.html"].after).toBe("timing+gsap");
|
||||
});
|
||||
|
||||
it("does not merge a slow follow-up without the coalesceMs override", () => {
|
||||
const timing = buildEditHistoryEntry({
|
||||
projectId: "project-1",
|
||||
label: "Resize timeline clip",
|
||||
kind: "timeline",
|
||||
coalesceKey: "timeline-resize:clip",
|
||||
files: { "index.html": { before: "orig", after: "timing" } },
|
||||
now: 0,
|
||||
id: "timing",
|
||||
});
|
||||
const late = buildEditHistoryEntry({
|
||||
projectId: "project-1",
|
||||
label: "Resize timeline clip",
|
||||
kind: "timeline",
|
||||
coalesceKey: "timeline-resize:clip",
|
||||
files: { "index.html": { before: "timing", after: "timing+gsap" } },
|
||||
now: 2000,
|
||||
id: "late",
|
||||
});
|
||||
|
||||
const state = pushEditHistoryEntry(
|
||||
pushEditHistoryEntry(createEmptyEditHistory(), timing),
|
||||
late,
|
||||
);
|
||||
|
||||
expect(state.undo).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("coalesces entries with the same coalesceKey within the window (prop: format)", () => {
|
||||
const first = buildEditHistoryEntry({
|
||||
projectId: "project-1",
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface EditHistoryEntry {
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
coalesceKey?: string;
|
||||
/** Per-entry coalesce window override (ms). Falls back to the reducer default. */
|
||||
coalesceMs?: number;
|
||||
createdAt: number;
|
||||
files: Record<string, EditHistoryFileSnapshot>;
|
||||
}
|
||||
@@ -35,6 +37,7 @@ export interface BuildEditHistoryEntryInput {
|
||||
label: string;
|
||||
kind?: EditHistoryKind;
|
||||
coalesceKey?: string;
|
||||
coalesceMs?: number;
|
||||
now: number;
|
||||
files: Record<string, { before: string; after: string }>;
|
||||
}
|
||||
@@ -99,6 +102,7 @@ export function buildEditHistoryEntry(input: BuildEditHistoryEntryInput): EditHi
|
||||
label: input.label,
|
||||
kind: input.kind ?? "manual",
|
||||
coalesceKey: input.coalesceKey,
|
||||
coalesceMs: input.coalesceMs,
|
||||
createdAt: input.now,
|
||||
files,
|
||||
};
|
||||
@@ -111,7 +115,9 @@ export function pushEditHistoryEntry(
|
||||
): EditHistoryState {
|
||||
if (Object.keys(entry.files).length === 0) return state;
|
||||
|
||||
const coalesceMs = options?.coalesceMs ?? DEFAULT_COALESCE_MS;
|
||||
// The incoming entry's own window wins so a caller can guarantee a merge even when a
|
||||
// slow async step (e.g. a server GSAP rewrite) sits between the two records.
|
||||
const coalesceMs = entry.coalesceMs ?? options?.coalesceMs ?? DEFAULT_COALESCE_MS;
|
||||
const maxEntries = options?.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
||||
const previous = state.undo[state.undo.length - 1];
|
||||
let undo = state.undo;
|
||||
|
||||
@@ -196,6 +196,51 @@ export async function sdkTimingPersist(
|
||||
}
|
||||
}
|
||||
|
||||
export async function sdkTimingBatchPersist(
|
||||
changes: Array<{
|
||||
hfId: string;
|
||||
timingUpdate: { start?: number; duration?: number; trackIndex?: number };
|
||||
}>,
|
||||
targetPath: string,
|
||||
sdkSession: Composition | null | undefined,
|
||||
deps: CutoverDeps,
|
||||
options?: CutoverOptions,
|
||||
): Promise<boolean> {
|
||||
const timingSrc = deps.readProjectFile;
|
||||
for (const change of changes) {
|
||||
void recordResolverParity(
|
||||
sdkSession,
|
||||
change.hfId,
|
||||
"setTiming",
|
||||
timingSrc ? () => timingSrc(targetPath) : undefined,
|
||||
);
|
||||
}
|
||||
if (!STUDIO_SDK_CUTOVER_ENABLED) return false;
|
||||
if (!sdkSession || wrongCompositionFile(deps, targetPath)) return false;
|
||||
if (changes.some((change) => !sdkSession.getElement(change.hfId))) return false;
|
||||
try {
|
||||
const serializedBefore = sdkSession.serialize();
|
||||
sdkSession.batch(() => {
|
||||
for (const change of changes) sdkSession.setTiming(change.hfId, change.timingUpdate);
|
||||
});
|
||||
const after = sdkSession.serialize();
|
||||
if (after === serializedBefore) return false;
|
||||
const undoBefore = await captureOnDiskBefore(deps, targetPath, serializedBefore);
|
||||
await persistSdkSerialize(after, targetPath, undoBefore, deps, options);
|
||||
trackStudioEvent("sdk_cutover_success", {
|
||||
hfId: changes[0]?.hfId ?? null,
|
||||
opCount: changes.length,
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
trackStudioEvent("sdk_cutover_fallback", {
|
||||
hfId: changes[0]?.hfId ?? null,
|
||||
error: String(err),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
type SdkGsapTweenOp =
|
||||
| { kind: "add"; target: string; spec: GsapTweenSpec }
|
||||
| { kind: "set"; animationId: string; properties: Partial<GsapTweenSpec> }
|
||||
|
||||
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
findMatchingTimelineElementId,
|
||||
findTimelineIdByAncestor,
|
||||
resolveTimelineIdForSelection,
|
||||
resolveTimelineSelectionSeekTime,
|
||||
} from "./studioHelpers";
|
||||
|
||||
@@ -72,3 +73,33 @@ describe("findTimelineIdByAncestor", () => {
|
||||
expect(findTimelineIdByAncestor(child, [], "index.html")).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTimelineIdForSelection", () => {
|
||||
const el = (over: Record<string, unknown>) =>
|
||||
({ id: "x", start: 0, duration: 1, track: 0, tag: "div", ...over }) as never;
|
||||
|
||||
it("resolves an ancestor clip against activeCompPath when the selection has no sourceFile", () => {
|
||||
// #card (a clip in a sub-composition) > .leaf (selected, not itself a clip)
|
||||
const card = document.createElement("div");
|
||||
card.id = "card";
|
||||
const leaf = document.createElement("span");
|
||||
leaf.className = "leaf";
|
||||
card.appendChild(leaf);
|
||||
|
||||
const els = [
|
||||
el({
|
||||
id: "card",
|
||||
domId: "card",
|
||||
key: "comps/panel.html#card",
|
||||
sourceFile: "comps/panel.html",
|
||||
}),
|
||||
];
|
||||
const selection = { element: leaf } as never;
|
||||
|
||||
// Falling back to the active comp matches; the old index.html-only fallback would miss.
|
||||
expect(resolveTimelineIdForSelection(selection, els, "comps/panel.html")).toBe(
|
||||
"comps/panel.html#card",
|
||||
);
|
||||
expect(resolveTimelineIdForSelection(selection, els, null)).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -215,6 +215,28 @@ export function findTimelineIdByAncestor(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the timeline element id for a DOM selection: direct match first, then
|
||||
* nearest clip ancestor. The ancestor lookup resolves against the selection's own
|
||||
* source file, falling back to the active composition path, then index.html — so a
|
||||
* sub-composition selection with no explicit sourceFile resolves against the comp
|
||||
* currently open, not always the root file.
|
||||
*/
|
||||
export function resolveTimelineIdForSelection(
|
||||
selection: DomEditSelection,
|
||||
elements: TimelineElement[],
|
||||
activeCompPath: string | null,
|
||||
): string | null {
|
||||
return (
|
||||
findMatchingTimelineElementId(selection, elements) ??
|
||||
findTimelineIdByAncestor(
|
||||
selection.element,
|
||||
elements,
|
||||
selection.sourceFile || activeCompPath || "index.html",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveTimelineSelectionSeekTime(
|
||||
currentTime: number,
|
||||
element: Pick<TimelineElement, "start" | "duration"> | null | undefined,
|
||||
|
||||
Reference in New Issue
Block a user