diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 3ac0850978..5b599aecab 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -44,7 +44,7 @@ // Keyframe UI components — wired dynamically via EaseCurveSection/MotionPanel. "packages/studio/src/components/editor/KeyframeDiamond.tsx", "packages/studio/src/components/editor/SpringEaseEditor.tsx", - // NLE notice — rendered conditionally via NLELayout when timeline is first shown. + // NLE notice — rendered conditionally via NLEContext/EditorShell when timeline is first shown. "packages/studio/src/components/nle/TimelineEditorNotice.tsx", // Zoom hook extracted for downstream razor-blade PRs (#1330, #1331). "packages/studio/src/player/components/useTimelineZoom.ts", @@ -53,39 +53,6 @@ "packages/studio/src/hooks/gsapTargetCache.ts", // Preview helper consumed dynamically from the studio iframe bridge. "packages/studio/src/hooks/gsapRuntimePreview.ts", - // TEMP(studio-dnd): shipped unwired ahead of the NLE integration; - // the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block. - "packages/studio/src/components/sidebar/AssetCard.tsx", - // TEMP(studio-dnd): shipped unwired ahead of the NLE integration; - // the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block. - "packages/studio/src/components/EditorShell.tsx", - "packages/studio/src/components/nle/TimelinePane.tsx", - "packages/studio/src/components/nle/useTimelineEditCallbacks.ts", - // TEMP(studio-dnd): shipped unwired ahead of the NLE integration; - // the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block. - "packages/studio/src/components/editor/DomEditSelectionChrome.tsx", - "packages/studio/src/components/editor/useDomEditNudge.ts", - "packages/studio/src/components/nle/PreviewOverlays.tsx", - // TEMP(studio-dnd): shipped unwired ahead of the NLE integration; - // the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block. - "packages/studio/src/player/components/TimelineLanes.tsx", - "packages/studio/src/player/components/useTimelineStackingSync.ts", - "packages/studio/src/player/components/useTimelineGeometry.ts", - "packages/studio/src/player/components/useTimelineEditPinning.ts", - "packages/studio/src/hooks/useTimelineEditingDrops.ts", - // TEMP(studio-dnd): shipped unwired ahead of the NLE integration; - // the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block. - "packages/studio/src/components/nle/NLEContext.tsx", - "packages/studio/src/components/nle/PreviewPane.tsx", - "packages/studio/src/components/nle/AssetPreviewOverlay.tsx", - "packages/studio/src/player/components/TimelineOverlays.tsx", - "packages/studio/src/player/components/timelineClipChildren.tsx", - "packages/studio/src/player/components/timelineClipDragTypes.ts", - "packages/studio/src/player/hooks/useTimelinePlayerLoop.ts", - "packages/studio/src/utils/assetPreviewStore.ts", - // TEMP(studio-dnd): shipped unwired ahead of the NLE integration; - // the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block. - "packages/studio/src/components/editor/CanvasContextMenu.tsx", ], "ignorePatterns": [ "docs/**", @@ -123,133 +90,6 @@ "packages/cli/src/cloud/_gen/**", ], "ignoreExports": [ - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/player/components/timelineEditing.ts", - "exports": [ - "selectTimelineElementsInMarquee", - "TimelineGroupResizeEdge", - "TimelineGroupTimingMember", - ], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/hooks/timelineElementsMove.ts", - "exports": ["useTimelineElementsMove"], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/player/components/timelineClipDragPreview.ts", - "exports": ["computeDragPreview", "computeResizePreview"], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/player/lib/timelineElementHelpers.ts", - "exports": ["furthestClipEndFromDocument"], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/hooks/timelineEditingHelpers.ts", - "exports": ["patchIframeRootDuration"], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/components/sidebar/AssetContextMenu.tsx", - "exports": ["DeleteConfirm"], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/components/editor/domEditOverlayGeometry.ts", - "exports": ["overlayCornersCentroid", "elementCornerOverlayPoints", "orientedOverlayRect"], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/contexts/DomEditContext.tsx", - "exports": ["useDomEditActionsContextOptional"], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/contexts/StudioContext.tsx", - "exports": ["useStudioShellContextOptional"], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/components/editor/domEditOverlayGestures.ts", - "exports": ["ResizeHandle"], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/components/sidebar/assetHelpers.ts", - "exports": ["truncateMiddle", "formatDuration"], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/utils/studioHelpers.ts", - "exports": ["resolveDroppedAssetDimensions"], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/player/lib/timelineElementHelpers.ts", - "exports": ["furthestClipEndFromSource", "readTimelineElementZIndex"], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/components/editor/manualOffsetDrag.ts", - "exports": ["applyManualOffsetNudgeDraft", "applyManualOffsetNudgeCommit"], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/player/components/timelineEditing.ts", - "exports": [ - "applyTimelineAutoScrollStep", - "resolveTimelineAutoScrollLoopAction", - "resolveTimelineDragEscape", - "TimelineDragEscapeInput", - ], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/hooks/timelineEditingHelpers.ts", - "exports": [ - "shiftGsapPositionsBatch", - "syncTimingEditPreview", - "GsapMutationOutcome", - "readGsapMutationScriptText", - ], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/utils/timelineAssetDrop.ts", - "exports": [ - "setCompositionDurationToContent", - "extendCompositionDurationIfNeeded", - "fitTimelineAssetGeometry", - "resolveTimelineAssetCompositionSize", - ], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/player/components/timelineLayout.ts", - "exports": [ - "getTimelineRowTop", - "getTimelineRowFromY", - "DRAG_EXTEND_MARGIN_PX", - "getTimelineFitPps", - "getTimelineDisplayContentWidth", - "TRACKS_TOP_PAD", - "MIN_TIMELINE_EXTENT_S", - ], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/components/editor/domEditNudge.ts", - "exports": ["CANVAS_NUDGE_COMMIT_DEBOUNCE_MS"], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/components/editor/canvasContextMenuZOrder.ts", - "exports": ["readEffectiveZIndex"], - }, // drawElementService is the bottom of the fast-capture Graphite stack // (#1917): its consumers (frameCapture in #1919) land two PRs upstack, so // a per-PR audit diffing against the merge base sees these exports as @@ -468,90 +308,6 @@ // require intrusive middleware changes beyond this PR's scope. "minLines": 6, "ignore": [ - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/hooks/useDomEditWiring.ts", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/hooks/useGsapSelectionHandlers.ts", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/components/StudioPreviewArea.tsx", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/components/nle/useTimelineEditCallbacks.ts", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/components/EditorShell.tsx", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/components/nle/PreviewOverlays.tsx", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/components/editor/DomEditOverlay.tsx", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/components/editor/DomEditSelectionChrome.tsx", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/hooks/useTimelineEditing.ts", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/hooks/useTimelineEditingDrops.ts", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/utils/timelineAssetDrop.ts", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/components/nle/NLEContext.test.ts", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/components/nle/NLELayout.test.ts", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/components/editor/domEditOverlayGeometry.ts", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/components/nle/NLEContext.tsx", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/components/nle/NLELayout.tsx", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/components/nle/PreviewPane.tsx", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/components/sidebar/AssetContextMenu.tsx", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/hooks/timelineEditingHelpers.ts", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/player/components/Timeline.tsx", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/player/components/TimelineCanvas.tsx", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/player/components/TimelineOverlays.tsx", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/player/components/timelineClipDragTypes.ts", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/player/components/useTimelineClipDrag.ts", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/player/components/useTimelineMarqueeSelection.ts", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/player/hooks/useTimelinePlayer.ts", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/player/hooks/useTimelinePlayerLoop.ts", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/player/lib/timelineElementHelpers.ts", // sourcePatcher.ts: pre-existing internal clones between the inline-style // and attribute tag-patchers; only the PatchOperation type gained two // optional fields here, but the line shift makes fallow re-flag them. @@ -677,37 +433,6 @@ // complexity pre-dates the computed-timeline work. Exempted at file level // rather than refactored as scope creep. "ignore": [ - // TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage); - // removed by studio-dnd/pr22 when the final config lands. - "packages/studio/src/components/editor/LayersPanel.test.ts", - "packages/studio/src/player/lib/timelineIframeHelpers.ts", - // TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage); - // removed by studio-dnd/pr22 when the final config lands. - "packages/studio/src/components/sidebar/AssetCard.tsx", - // TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage); - // removed by studio-dnd/pr22 when the final config lands. - "packages/studio/src/components/editor/DomEditSelectionChrome.tsx", - "packages/studio/src/components/editor/useDomEditNudge.ts", - // TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage); - // removed by studio-dnd/pr22 when the final config lands. - "packages/studio/src/player/components/useTimelineGeometry.ts", - "packages/studio/src/player/components/useTimelineStackingSync.ts", - // TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage); - // removed by studio-dnd/pr22 when the final config lands. - "packages/studio/src/components/editor/domEditOverlayGeometry.ts", - "packages/studio/src/components/editor/manualOffsetDrag.ts", - "packages/studio/src/player/components/timelineEditing.ts", - "packages/studio/src/player/lib/timelineElementHelpers.ts", - // TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage); - // removed by studio-dnd/pr22 when the final config lands. - "packages/studio/src/player/components/TimelineOverlays.tsx", - // TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage); - // removed by studio-dnd/pr22 when the final config lands. - "packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts", - "packages/studio/src/components/editor/CanvasContextMenu.tsx", - "packages/studio/src/components/editor/canvasContextMenuZOrder.test.ts", - "packages/studio/src/player/components/timelineCollision.test.ts", - "packages/studio/src/player/components/timelineStackingSync.test.ts", // sourcePatcher.ts: resolveSourceFile / splitInlineStyleDeclarations / // patch*InTag pre-date this PR; only the PatchOperation type gained two // optional fields, but the line-shift fingerprint re-flags the inherited @@ -847,14 +572,12 @@ "packages/studio/src/components/editor/Transform3DCube.tsx", "packages/studio/src/components/LintModal.tsx", "packages/studio/src/components/MediaPreview.tsx", - "packages/studio/src/components/nle/NLELayout.tsx", "packages/studio/src/components/nle/NLEPreview.tsx", "packages/studio/src/components/sidebar/AudioRow.tsx", "packages/studio/src/components/sidebar/BlocksTab.tsx", "packages/studio/src/components/sidebar/CompositionsTab.tsx", "packages/studio/src/components/sidebar/LeftSidebar.tsx", "packages/studio/src/components/storyboard/StoryboardLoaded.tsx", - "packages/studio/src/components/StudioPreviewArea.tsx", "packages/studio/src/components/StudioRightPanel.tsx", "packages/studio/src/components/StudioToast.tsx", "packages/studio/src/components/ui/Tooltip.tsx", diff --git a/docs/contributing/studio-manual-dom-editing.mdx b/docs/contributing/studio-manual-dom-editing.mdx index e2a343afe0..f76b5ac8af 100644 --- a/docs/contributing/studio-manual-dom-editing.mdx +++ b/docs/contributing/studio-manual-dom-editing.mdx @@ -274,7 +274,7 @@ The main implementation lives in: Supporting Studio shell changes also landed in: -- `packages/studio/src/components/nle/NLELayout.tsx` +- `packages/studio/src/components/EditorShell.tsx` - `packages/studio/src/components/nle/NLEPreview.tsx` - `packages/studio/src/components/sidebar/CompositionsTab.tsx` - `packages/studio/src/components/sidebar/LeftSidebar.tsx` diff --git a/docs/packages/studio.mdx b/docs/packages/studio.mdx index 358910d4e8..f07d58c8da 100644 --- a/docs/packages/studio.mdx +++ b/docs/packages/studio.mdx @@ -61,13 +61,20 @@ Peer dependencies: `react` (19), `react-dom` (19), `zustand` (4 or 5). ### Layout ```typescript -import { NLELayout, NLEPreview, CompositionBreadcrumb } from '@hyperframes/studio'; +import { EditorShell, NLEPreview, CompositionBreadcrumb } from '@hyperframes/studio'; import type { CompositionLevel } from '@hyperframes/studio'; -// Main NLE (Non-Linear Editor) layout container - - {/* Preview, timeline, and editor panels */} - +// Main NLE (Non-Linear Editor) layout container. It arranges the sidebar +// (`left`), inspector (`right`), preview, and timeline, and takes the editor +// callbacks (timeline edits, drops, clip rendering) as props — see StudioApp +// for a fully wired setup. + // Preview panel diff --git a/packages/core/src/runtime/timeline.ts b/packages/core/src/runtime/timeline.ts index d7ea927f7c..5c4c6b4552 100644 --- a/packages/core/src/runtime/timeline.ts +++ b/packages/core/src/runtime/timeline.ts @@ -51,60 +51,6 @@ function maxDefinedNumber(...values: Array): number | null { return Math.max(...finite); } -/** - * When multiple content kinds share the same track number, split them - * onto separate tracks so the timeline UI shows distinct rows. - * - * Preferred kind order (top → bottom): composition, video, image, element, audio. - * Tracks that contain only one kind are left untouched. - */ -const KIND_ORDER: Record = { - composition: 0, - video: 1, - image: 2, - element: 3, - audio: 4, -}; - -function normalizeTrackAssignments(clips: RuntimeTimelineClip[]): void { - if (clips.length === 0) return; - - // Group clips by their raw track number and detect which tracks have mixed kinds - const trackKinds = new Map>(); - for (const clip of clips) { - const kinds = trackKinds.get(clip.track) ?? new Set(); - kinds.add(clip.kind); - trackKinds.set(clip.track, kinds); - } - - const hasMixedTracks = Array.from(trackKinds.values()).some((kinds) => kinds.size > 1); - if (!hasMixedTracks) return; - - // Build new contiguous track numbers, splitting mixed tracks by kind - let nextTrack = 0; - const newTrackMap = new Map(); // "origTrack:kind" → newTrack - - const sortedTracks = [...trackKinds.keys()].sort((a, b) => a - b); - for (const track of sortedTracks) { - const kinds = trackKinds.get(track)!; - if (kinds.size === 1) { - newTrackMap.set(`${track}:${[...kinds][0]}`, nextTrack++); - } else { - // Split by kind in preferred order - const sorted = [...kinds].sort((a, b) => (KIND_ORDER[a] ?? 99) - (KIND_ORDER[b] ?? 99)); - for (const kind of sorted) { - newTrackMap.set(`${track}:${kind}`, nextTrack++); - } - } - } - - for (const clip of clips) { - const key = `${clip.track}:${clip.kind}`; - const newTrack = newTrackMap.get(key); - if (newTrack != null) clip.track = newTrack; - } -} - function toAbsoluteAssetUrl(rawValue: string | null | undefined): string | null { const raw = String(rawValue ?? "").trim(); if (!raw) return null; @@ -638,11 +584,12 @@ export function collectRuntimeTimelinePayload(params: { } } - // ── Track normalization ──────────────────────────────────────────────── - // When multiple content kinds (composition, audio, video, …) share the same - // data-track-index value, split them onto separate tracks so the timeline UI - // shows distinct rows for each kind. - normalizeTrackAssignments(clips); + // Track assignment honors the authored data-track-index verbatim: a clip stays + // on the track it was placed on, regardless of kind. (Previously mixed-kind + // tracks were split onto separate rows, but that renumbered tracks — breaking + // "drop a clip onto an existing track" and causing the written track to drift + // from the displayed one on every move. Track index is display-only; render + // never reads it, so honoring it verbatim is the correct NLE behavior.) for (const compositionNode of compositionNodes) { if (compositionNode === root) continue; diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 2bac2f857c..ef6b4375c2 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -10,18 +10,16 @@ import { usePersistentEditHistory } from "./hooks/usePersistentEditHistory"; import { usePanelLayout } from "./hooks/usePanelLayout"; import { useFileManager } from "./hooks/useFileManager"; import { usePreviewPersistence } from "./hooks/usePreviewPersistence"; +import { usePreviewDocumentVersion } from "./hooks/usePreviewDocumentVersion"; import { useTimelineEditing } from "./hooks/useTimelineEditing"; -import type { TimelineZIndexReorderCommit } from "./hooks/useTimelineEditingTypes"; import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab"; import { useDomEditSession } from "./hooks/useDomEditSession"; +import { useSdkSession } from "./hooks/useSdkSession"; import { useSdkSelectionSync } from "./hooks/useSdkSelectionSync"; -import { useStudioSdkSessions } from "./hooks/useStudioSdkSessions"; -import { usePreviewDocumentVersion } from "./hooks/usePreviewDocumentVersion"; import { useBlockHandlers } from "./hooks/useBlockHandlers"; import { useAppHotkeys } from "./hooks/useAppHotkeys"; import { useClipboard } from "./hooks/useClipboard"; -import { readStudioUiPreferences, writeStudioUiPreferences } from "./utils/studioUiPreferences"; -import { deleteSelectedKeyframes } from "./hooks/timelineEditingHelpers"; +import { selectedKeyframePercentagesForElement } from "./utils/keyframeSelection"; import { useCaptionDetection } from "./hooks/useCaptionDetection"; import { useRenderClipContent } from "./hooks/useRenderClipContent"; import { useConsoleErrorCapture } from "./hooks/useConsoleErrorCapture"; @@ -33,7 +31,7 @@ import { useCompositionContentLoader } from "./hooks/useCompositionContentLoader import { useStudioUrlState } from "./hooks/useStudioUrlState"; import { buildStudioContextValue, - useDragOverlay, + useGlobalFileDrop, useInspectorState, } from "./hooks/useStudioContextValue"; import type { DomEditSelection } from "./components/editor/domEditing"; @@ -42,7 +40,7 @@ import { useGestureCommit } from "./hooks/useGestureCommit"; import { STUDIO_KEYFRAMES_ENABLED } from "./components/editor/manualEditingAvailability"; import { GestureTrailOverlay } from "./components/editor/GestureTrailOverlay"; import { StudioLeftSidebar } from "./components/StudioLeftSidebar"; -import { StudioPreviewArea } from "./components/StudioPreviewArea"; +import { EditorShell } from "./components/EditorShell"; import { StudioRightPanel } from "./components/StudioRightPanel"; import { TimelineToolbar } from "./components/TimelineToolbar"; import { StudioPlaybackProvider, StudioShellProvider } from "./contexts/StudioContext"; @@ -56,7 +54,6 @@ import { useServerConnection } from "./hooks/useServerConnection"; import { normalizeStudioCompositionPath, readStudioUrlStateFromWindow, - resolveMasterCompositionPath, } from "./utils/studioUrlState"; import { trackStudioSessionStart } from "./telemetry/events"; import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config"; @@ -108,18 +105,6 @@ export function StudioApp() { : 0; return Math.max(timelineDuration, maxEnd); }, [timelineDuration, timelineElements]); - const [timelineVisible, setTimelineVisible] = useState( - () => - initialUrlStateRef.current.timelineVisible ?? - readStudioUiPreferences().timelineVisible ?? - true, - ); - const toggleTimelineVisibility = useCallback(() => { - setTimelineVisible((v) => { - writeStudioUiPreferences({ timelineVisible: !v }); - return !v; - }); - }, []); const { toasts, showToast, dismissToast } = useToast(); const panelLayout = usePanelLayout({ rightCollapsed: initialUrlStateRef.current.rightCollapsed, @@ -127,7 +112,6 @@ export function StudioApp() { }); const editHistory = usePersistentEditHistory({ projectId }); const domEditSaveTimestampRef = useRef(0); - const handleDomZIndexReorderCommitRef = useRef(null); const pendingTimelineEditPathRef = useRef(new Set()); const isGestureRecordingRef = useRef(false); const reloadPreview = useCallback(() => setRefreshKey((k) => k + 1), []); @@ -138,16 +122,7 @@ export function StudioApp() { domEditSaveTimestampRef, setRefreshKey, }); - const masterCompPath = useMemo( - () => resolveMasterCompositionPath(fileManager.fileTree), - [fileManager.fileTree], - ); - const { sdkHandle, editFlowSdkSession } = useStudioSdkSessions( - projectId, - activeCompPath, - domEditSaveTimestampRef, - masterCompPath, - ); + const sdkHandle = useSdkSession(projectId, activeCompPath, domEditSaveTimestampRef); useEffect(() => { if (activeCompPathHydrated) return; if (!fileManager.fileTreeLoaded) return; @@ -183,9 +158,8 @@ export function StudioApp() { pendingTimelineEditPathRef, uploadProjectFiles: fileManager.uploadProjectFiles, isRecordingRef: isGestureRecordingRef, - sdkSession: editFlowSdkSession, + sdkSession: sdkHandle.session, forceReloadSdkSession: sdkHandle.forceReload, - handleDomZIndexReorderCommitRef, }); const { activeBlockParams, @@ -232,7 +206,6 @@ export function StudioApp() { previewIframeRef, }); const appHotkeys = useAppHotkeys({ - toggleTimelineVisibility, handleTimelineElementDelete: timelineEditing.handleTimelineElementDelete, handleTimelineElementSplit: timelineEditing.handleTimelineElementSplit, handleDomEditElementDelete: domEditDeleteBridge, @@ -300,18 +273,25 @@ export function StudioApp() { openSourceForSelection: fileManager.openSourceForSelection, selectSidebarTab: sidebarTabRef.current.select, getSidebarTab: sidebarTabRef.current.get, - sdkSession: editFlowSdkSession, + sdkSession: sdkHandle.session, forceReloadSdkSession: sdkHandle.forceReload, }); domEditSelectionBridgeRef.current = domEditSession.domEditSelection; - handleDomZIndexReorderCommitRef.current = domEditSession.handleDomZIndexReorderCommit; clearDomSelectionRef.current = domEditSession.clearDomSelection; handleDomEditElementDeleteRef.current = domEditSession.handleDomEditElementDelete; resetKeyframesRef.current = domEditSession.handleResetSelectedElementKeyframes; invalidateGsapCacheRef.current = domEditSession.invalidateGsapCache; - deleteSelectedKeyframesRef.current = () => deleteSelectedKeyframes(domEditSession); + deleteSelectedKeyframesRef.current = () => { + const { selectedKeyframes, selectedElementId } = usePlayerStore.getState(); + const a = domEditSession.selectedGsapAnimations.find((x) => x.keyframes); + if (!a) return; + // Only the active element's keyframes; a stale cross-element selection must not delete here. + for (const p of selectedKeyframePercentagesForElement(selectedKeyframes, selectedElementId)) { + domEditSession.handleGsapRemoveKeyframe(a.id, p); + } + }; useSdkSelectionSync( - editFlowSdkSession, + sdkHandle.session, domEditSession.domEditSelection, domEditSession.domEditGroupSelections, ); @@ -348,7 +328,7 @@ export function StudioApp() { setConsoleErrors, resetErrors: resetConsoleErrors, } = useConsoleErrorCapture(previewIframe); - const dragOverlay = useDragOverlay(fileManager.handleImportFiles); + const dragOverlay = useGlobalFileDrop(timelineEditing.handleTimelineFileDrop); const handleToggleRecordingRef = useRef<() => void>(() => {}); const domEditSessionRef = useRef(domEditSession); domEditSessionRef.current = domEditSession; @@ -369,7 +349,6 @@ export function StudioApp() { const r = previewIframe.getBoundingClientRect(); canvasRectRef.current = { left: r.left, top: r.top, width: r.width, height: r.height }; }, [gestureState, previewIframe]); - const handlePreviewIframeRef = useCallback( (iframe: HTMLIFrameElement | null) => { previewIframeRef.current = iframe; @@ -410,7 +389,6 @@ export function StudioApp() { previewIframeRef, rightPanelTab: panelLayout.rightPanelTab, rightCollapsed: panelLayout.rightCollapsed, - timelineVisible, activeCompPathHydrated, domEditSelection: domEditSession.domEditSelection, buildDomSelectionFromTarget: domEditSession.buildDomSelectionFromTarget, @@ -438,18 +416,15 @@ export function StudioApp() { waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves, handlePreviewIframeRef, refreshPreviewDocumentVersion, - timelineVisible, - toggleTimelineVisibility, }); const timelineToolbar = useMemo( () => ( ), - [toggleTimelineVisibility, domEditSession, timelineEditing.handleTimelineElementSplit], + [domEditSession, timelineEditing.handleTimelineElementSplit], ); if (resolving || waitingForServer || !projectId) return ; @@ -494,78 +469,76 @@ export function StudioApp() { onSelectComposition={handleSelectComposition} /> )} -
- - - ) : undefined - } - /> - {!panelLayout.rightCollapsed && ( - { - setActiveBlockParams(null); - panelLayout.setRightPanelTab("design"); - }} - recordingState={gestureState} - recordingDuration={gestureRecording.recordingDuration} - onToggleRecording={recordingToggle} - sdkSession={sdkHandle.session} - reloadPreview={reloadPreview} - domEditSaveTimestampRef={domEditSaveTimestampRef} - recordEdit={editHistory.recordEdit} - onToggleElementHidden={timelineEditing.handleToggleElementHidden} +
+ } + right={ + panelLayout.rightCollapsed ? null : ( + { + setActiveBlockParams(null); + panelLayout.setRightPanelTab("design"); + }} + recordingState={gestureState} + recordingDuration={gestureRecording.recordingDuration} + onToggleRecording={recordingToggle} + sdkSession={sdkHandle.session} + reloadPreview={reloadPreview} + domEditSaveTimestampRef={domEditSaveTimestampRef} + recordEdit={editHistory.recordEdit} + onToggleElementHidden={timelineEditing.handleToggleElementHidden} + /> + ) + } + timelineToolbar={timelineToolbar} + renderClipContent={renderClipContent} + handleTimelineElementDelete={timelineEditing.handleTimelineElementDelete} + handleTimelineAssetDrop={timelineEditing.handleTimelineAssetDrop} + handleTimelineBlockDrop={handleTimelineBlockDrop} + handlePreviewBlockDrop={handlePreviewBlockDrop} + handleTimelineFileDrop={timelineEditing.handleTimelineFileDrop} + handleTimelineElementMove={timelineEditing.handleTimelineElementMove} + handleTimelineElementsMove={timelineEditing.handleTimelineElementsMove} + handleTimelineElementResize={timelineEditing.handleTimelineElementResize} + handleToggleTrackHidden={timelineEditing.handleToggleTrackHidden} + handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit} + handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit} + handleRazorSplit={timelineEditing.handleRazorSplit} + handleRazorSplitAll={timelineEditing.handleRazorSplitAll} + setCompIdToSrc={setCompIdToSrc} + setCompositionLoading={setCompositionLoading} + shouldShowSelectedDomBounds={shouldShowSelectedDomBounds} + isGestureRecording={gestureState === "recording"} + recordingState={gestureState} + onToggleRecording={recordingToggle} + blockPreview={blockPreview} + gestureOverlay={ + gestureState === "recording" && previewIframe ? ( + + ) : undefined + } + /> ; + onAddAssetToTimeline?: (path: string) => void; } // fallow-ignore-next-line complexity @@ -30,6 +31,7 @@ export function StudioLeftSidebar({ linting, lintFindingCount, lintFindingsByFile, + onAddAssetToTimeline, }: StudioLeftSidebarProps) { const { leftCollapsed, @@ -69,7 +71,7 @@ export function StudioLeftSidebar({ if (leftCollapsed) { return ( -
+
@@ -131,46 +154,65 @@ export function TimelineToolbar({ onClick={() => setActiveTool("razor")} aria-label="Razor tool" aria-pressed={activeTool === "razor"} - className={`flex h-6 w-6 items-center justify-center transition-colors active:scale-[0.98] ${ - activeTool === "razor" - ? "bg-neutral-700 text-neutral-200" - : "text-neutral-500 hover:text-neutral-300" - }`} + className={activeTool === "razor" ? flatActive : flatIdle} > - + -
+ {/* Divider: tool-mode | editing-actions */} + -
+
-
- {`${displayedTimelineZoomPercent}%`} -
+ { + setZoomMode("manual"); + setManualZoomPercent(timelineSliderToZoomPercent(Number(e.target.value))); + }} + className="mx-1 w-[96px] cursor-pointer appearance-none bg-transparent [&::-webkit-slider-runnable-track]:h-[2px] [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-neutral-700 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-[10px] [&::-webkit-slider-thumb]:h-[10px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:shadow-[0_0_0_2px_#0a0a0a,0_1px_3px_rgba(0,0,0,0.5)] [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb:active]:cursor-grabbing" + /> - - - + {/* Numeric zoom readout (main-parity): "Fit" in fit mode, N% in manual. */} + + {zoomMode === "fit" ? "Fit" : `${displayedTimelineZoomPercent}%`} +
diff --git a/packages/studio/src/components/editor/SnapToolbar.test.tsx b/packages/studio/src/components/editor/SnapToolbar.test.tsx index 9cac6538f1..29e73b463d 100644 --- a/packages/studio/src/components/editor/SnapToolbar.test.tsx +++ b/packages/studio/src/components/editor/SnapToolbar.test.tsx @@ -34,7 +34,6 @@ function AppHotkeyHarness() { const leftSidebarRef = useRef(null); useAppHotkeys({ - toggleTimelineVisibility: vi.fn(), handleTimelineElementDelete: vi.fn(), handleTimelineElementSplit: vi.fn(), handleDomEditElementDelete: vi.fn(), diff --git a/packages/studio/src/components/nle/NLELayout.test.ts b/packages/studio/src/components/nle/NLELayout.test.ts deleted file mode 100644 index c0aa410bbb..0000000000 --- a/packages/studio/src/components/nle/NLELayout.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { shouldDisableTimelineWhileCompositionLoading } from "./NLELayout"; - -describe("timeline loading disable state", () => { - it("disables the timeline while the composition loading overlay is visible", () => { - expect(shouldDisableTimelineWhileCompositionLoading(true)).toBe(true); - }); - - it("reenables the timeline after composition loading finishes", () => { - expect(shouldDisableTimelineWhileCompositionLoading(false)).toBe(false); - }); -}); diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx deleted file mode 100644 index e91d861a43..0000000000 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ /dev/null @@ -1,591 +0,0 @@ -import { - useState, - useCallback, - useRef, - useEffect, - useSyncExternalStore, - memo, - type ReactNode, -} from "react"; -import { useMountEffect } from "../../hooks/useMountEffect"; -import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player"; -import type { TimelineElement } from "../../player"; -import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing"; -import { NLEPreview } from "./NLEPreview"; -import { CompositionBreadcrumb } from "./CompositionBreadcrumb"; -import { TimelineResizeDivider, MIN_TIMELINE_H, MIN_PREVIEW_H } from "./TimelineResizeDivider"; -import { usePreviewBlockDrop } from "./usePreviewBlockDrop"; -import { useCompositionStack } from "./useCompositionStack"; -import { useTimelineEditContext } from "../../contexts/TimelineEditContext"; -import { setCompositionSourceMap } from "../editor/domEditingDom"; -import { trackStudioExpandedClipEdit } from "../../telemetry/events"; -import { - TIMELINE_TOGGLE_SHORTCUT_LABEL, - getTimelineToggleTitle, -} from "../../utils/timelineDiscovery"; -import { ensureMotionPathPluginLoaded } from "../../utils/gsapSoftReload"; -import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences"; - -interface NLELayoutProps { - projectId: string; - portrait?: boolean; - /** Slot for overlays rendered on top of the preview (cursors, highlights, etc.) */ - previewOverlay?: ReactNode; - /** Slot rendered above the timeline tracks (toolbar with split, delete, zoom) */ - timelineToolbar?: ReactNode; - /** Slot rendered below the timeline tracks */ - timelineFooter?: ReactNode; - /** Increment to force the preview to reload (e.g., after file writes) */ - refreshKey?: number; - /** Navigate to a specific composition path (e.g., "compositions/intro.html") */ - activeCompositionPath?: string | null; - /** Callback to expose the iframe ref (for element picker, etc.) */ - onIframeRef?: (iframe: HTMLIFrameElement | null) => void; - /** Callback when the viewed composition changes (drill-down/back) */ - onCompositionChange?: (compositionPath: string | null) => void; - /** Custom clip content renderer for timeline (thumbnails, waveforms, etc.) */ - renderClipContent?: ( - element: TimelineElement, - style: { clip: string; label: string }, - ) => ReactNode; - onFileDrop?: ( - files: File[], - placement?: Pick, - ) => Promise | void; - onDeleteElement?: (element: TimelineElement) => Promise | void; - onAssetDrop?: ( - assetPath: string, - placement: Pick, - ) => Promise | void; - onBlockDrop?: ( - blockName: string, - placement: Pick, - ) => Promise | void; - onPreviewBlockDrop?: ( - blockName: string, - position: { left: number; top: number }, - ) => Promise | void; - onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; - onSelectTimelineElement?: (element: TimelineElement | null) => void; - /** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */ - onCompIdToSrcChange?: (map: Map) => void; - /** Whether the timeline panel is visible (default: true) */ - timelineVisible?: boolean; - /** Callback to toggle timeline visibility */ - onToggleTimeline?: () => void; - /** Notifies parent when composition loading state changes */ - onCompositionLoadingChange?: (loading: boolean) => void; -} - -const DEFAULT_TIMELINE_H = 220; - -function subscribeFullscreen(cb: () => void) { - document.addEventListener("fullscreenchange", cb); - return () => document.removeEventListener("fullscreenchange", cb); -} - -function getFullscreenElement() { - return document.fullscreenElement; -} - -export function shouldDisableTimelineWhileCompositionLoading(compositionLoading: boolean): boolean { - return compositionLoading; -} - -// fallow-ignore-next-line complexity -export const NLELayout = memo(function NLELayout({ - projectId, - portrait, - previewOverlay, - timelineToolbar, - timelineFooter, - refreshKey, - activeCompositionPath, - onIframeRef, - onCompositionChange, - renderClipContent, - onFileDrop, - onDeleteElement, - onAssetDrop, - onBlockDrop, - onPreviewBlockDrop, - onBlockedEditAttempt, - onSelectTimelineElement, - onCompIdToSrcChange, - timelineVisible, - onToggleTimeline, - onCompositionLoadingChange: onCompositionLoadingChangeParent, -}: NLELayoutProps) { - const { - iframeRef, - togglePlay, - seek, - onIframeLoad: baseOnIframeLoad, - refreshPlayer, - } = useTimelinePlayer(); - - // Reset timeline state when the project changes. Done in an effect, not during - // render: reset() updates the player store, and updating another store/component - // mid-render triggers React's "Cannot update a component while rendering a - // different component" warning. The effect runs right after commit, so the new - // project's first frame may briefly show prior timeline state before it clears. - useEffect(() => { - usePlayerStore.getState().reset(); - }, [projectId]); - - const stageRefForDrop = useRef(null); - const handleStageRef = useCallback((ref: React.RefObject) => { - stageRefForDrop.current = ref.current; - }, []); - - // Authored composition size measured from the loaded preview — drives drop - // coordinate mapping so blocks land where the user pointed on any comp size. - const [previewCompositionSize, setPreviewCompositionSize] = useState<{ - width: number; - height: number; - } | null>(null); - - const { - isDragOver: previewDragOver, - handleDragEnter: handlePreviewDragEnter, - handleDragOver: handlePreviewDragOver, - handleDragLeave: handlePreviewDragLeave, - handleDrop: handlePreviewDrop, - } = usePreviewBlockDrop({ - portrait, - compositionSize: previewCompositionSize, - stageRef: stageRefForDrop as React.RefObject, - onBlockDrop: onPreviewBlockDrop, - }); - - // Lightweight reload: change iframe src instead of destroying the Player. - // refreshPlayer() saves the seek position and appends a cache-busting _t - // param — the Player instance stays alive so the adapter is available for - // saveSeekPosition() to read the current time before the reload. - const prevRefreshKeyRef = useRef(refreshKey); - useEffect(() => { - if (refreshKey === prevRefreshKeyRef.current) return; - prevRefreshKeyRef.current = refreshKey; - refreshPlayer(); - }, [refreshKey, refreshPlayer]); - - const onIframeLoad = useCallback(() => { - baseOnIframeLoad(); - // Pre-load + register MotionPathPlugin once so adding a motion path in the - // studio doesn't take the async plugin-load flash path on the first soft - // reload (the comp may not ship the plugin until it actually uses one). - ensureMotionPathPluginLoaded(iframeRef.current); - onIframeRef?.(iframeRef.current); - }, [baseOnIframeLoad, iframeRef, onIframeRef]); - - const { - compositionStack, - updateCompositionStack, - handleNavigateComposition, - handleDrillDown: drillDown, - masterSeekRef, - compIdToSrc, - setCompIdToSrc, - } = useCompositionStack({ - projectId, - activeCompositionPath, - onCompositionChange, - }); - - // Wrap handleDrillDown to also scan the iframe DOM for data-composition-src - const iframeRef_ = iframeRef; - const handleDrillDown = useCallback( - (element: TimelineElement) => { - if (!element.compositionSrc) return; - usePlayerStore.getState().setSelectedElementId(null); - // Check compIdToSrc map first; then scan iframe DOM; then fall through to drillDown - const compId = element.id; - let resolvedPath = compIdToSrc.get(compId); - if (!resolvedPath) { - try { - const doc = iframeRef_.current?.contentDocument; - if (doc) { - const host = doc.querySelector( - `[data-composition-id="${CSS.escape(compId)}"][data-composition-src]`, - ); - if (host) { - resolvedPath = host.getAttribute("data-composition-src") || undefined; - } - } - } catch { - /* cross-origin */ - } - } - // Delegate with the resolved compositionSrc (may be same as original) - drillDown({ - id: compId, - compositionSrc: resolvedPath ?? element.compositionSrc, - }); - }, - [compIdToSrc, drillDown, iframeRef_], - ); - - // Move/resize/split come from the timeline edit context, not props — the - // wrappers below intercept expanded clips and must call the *real* handlers. - // (Delete is a direct prop; it stays that way.) - const { onMoveElement, onResizeElement, onSplitElement } = useTimelineEditContext(); - - // An expanded sub-comp child reaches the normal edit handlers in its own - // local coordinates: addressed by its real DOM id, with timeline time rebased - // onto the sub-comp it lives in. The handlers then save + reloadPreview exactly - // as they do for top-level clips — no separate live-DOM path. - const toLocalElement = useCallback( - (element: TimelineElement, basis: number): TimelineElement => ({ - ...element, - id: element.domId ?? element.id, - start: element.start - basis, - }), - [], - ); - - const handleMoveElement = useCallback( - (element: TimelineElement, updates: Pick) => { - const basis = element.expandedParentStart; - if (basis === undefined) return onMoveElement?.(element, updates); - trackStudioExpandedClipEdit({ action: "move" }); - onMoveElement?.(toLocalElement(element, basis), { - ...updates, - start: Math.max(0, updates.start - basis), - }); - }, - [onMoveElement, toLocalElement], - ); - - const handleResizeElement = useCallback( - ( - element: TimelineElement, - updates: Pick, - ) => { - const basis = element.expandedParentStart; - if (basis === undefined) return onResizeElement?.(element, updates); - trackStudioExpandedClipEdit({ action: "resize" }); - onResizeElement?.(toLocalElement(element, basis), { - ...updates, - start: Math.max(0, updates.start - basis), - }); - }, - [onResizeElement, toLocalElement], - ); - - const handleDeleteElement = useCallback( - (element: TimelineElement) => { - const basis = element.expandedParentStart; - if (basis === undefined) return onDeleteElement?.(element); - trackStudioExpandedClipEdit({ action: "delete" }); - return onDeleteElement?.(toLocalElement(element, basis)); - }, - [onDeleteElement, toLocalElement], - ); - - const handleSplitElement = useCallback( - (element: TimelineElement, splitTime: number) => { - const basis = element.expandedParentStart; - if (basis === undefined) return onSplitElement?.(element, splitTime); - trackStudioExpandedClipEdit({ action: "split" }); - return onSplitElement?.(toLocalElement(element, basis), Math.max(0, splitTime - basis)); - }, - [onSplitElement, toLocalElement], - ); - - // Composition ID → file path map from raw index.html - const compIdToSrcRef = useRef(compIdToSrc); - compIdToSrcRef.current = compIdToSrc; - - useMountEffect(() => { - fetch(`/api/projects/${projectId}/files/index.html`) - .then((r) => { - if (!r.ok) throw new Error(`HTTP ${r.status}`); - return r.json(); - }) - .then((data: { content?: string }) => { - const html = data.content || ""; - const map = new Map(); - const re = - /data-composition-id=["']([^"']+)["'][^>]*data-composition-src=["']([^"']+)["']|data-composition-src=["']([^"']+)["'][^>]*data-composition-id=["']([^"']+)["']/g; - let match; - while ((match = re.exec(html)) !== null) { - const id = match[1] || match[4]; - const src = match[2] || match[3]; - if (id && src) map.set(id, src); - } - setCompIdToSrc(map); - // Let DOM source-resolution recover a subcomposition element's source file - // (the runtime drops the linkage when inlining — see getSourceFileForElement). - setCompositionSourceMap(map); - onCompIdToSrcChange?.(map); - }) - .catch((err: unknown) => { - // Non-fatal: drill-down still works via the iframe DOM scan; without - // the map only source-file resolution for sub-comps degrades. - console.warn("[studio] Couldn't load composition source map from index.html:", err); - }); - }); - - // Patch elements with compositionSrc whenever elements or compIdToSrc change. - // eslint-disable-next-line no-restricted-syntax - useEffect(() => { - if (compIdToSrc.size === 0) return; - const patchElements = (elements: TimelineElement[]): TimelineElement[] | null => { - const map = compIdToSrcRef.current; - if (map.size === 0) return null; - let patched = false; - const updated = elements.map((el) => { - if (el.compositionSrc) return el; - const src = map.get(el.id) ?? map.get(el.id.replace(/-(host|comp|layer)$/, "")); - if (src) { - patched = true; - return { ...el, compositionSrc: src }; - } - return el; - }); - return patched ? updated : null; - }; - const patched = patchElements(usePlayerStore.getState().elements); - if (patched) usePlayerStore.getState().setElements(patched); - let patching = false; - return usePlayerStore.subscribe((state, prev) => { - if (patching) return; - if (state.elements === prev.elements || state.elements.length === 0) return; - if (state.elements.every((el) => el.compositionSrc)) return; - patching = true; - const result = patchElements(state.elements); - if (result) state.setElements(result); - patching = false; - }); - }, [compIdToSrc]); - - // Resizable timeline height — persisted alongside zoom/pan so the user's - // workspace layout survives reloads. - const [timelineH, setTimelineH] = useState(() => { - const stored = readStudioUiPreferences().timelineHeight; - return stored !== undefined && stored >= MIN_TIMELINE_H ? stored : DEFAULT_TIMELINE_H; - }); - const persistTimelineH = useCallback((height: number) => { - writeStudioUiPreferences({ timelineHeight: Math.round(height) }); - }, []); - // A height persisted on a tall window can exceed this window's container and - // collapse the flex-1 preview to 0px — clamp once the container is measurable - // (the drag/keyboard paths already clamp; the restore path must too). - useEffect(() => { - const containerH = containerRef.current?.getBoundingClientRect().height; - if (!containerH) return; - const max = containerH - MIN_PREVIEW_H; - setTimelineH((prev) => (prev > max ? Math.max(MIN_TIMELINE_H, max) : prev)); - }, []); - const hasLoadedOnceRef = useRef(false); - const [compositionLoading, setCompositionLoadingRaw] = useState(true); - const setCompositionLoading = useCallback((loading: boolean) => { - if (!loading) hasLoadedOnceRef.current = true; - if (loading && hasLoadedOnceRef.current) return; - setCompositionLoadingRaw(loading); - }, []); - const timelineDisabled = shouldDisableTimelineWhileCompositionLoading(compositionLoading); - - useEffect(() => { - onCompositionLoadingChangeParent?.(compositionLoading); - }, [compositionLoading, onCompositionLoadingChangeParent]); - - const fullscreenElement = useSyncExternalStore(subscribeFullscreen, getFullscreenElement); - const isTimelineVisible = timelineVisible ?? true; - const containerRef = useRef(null); - const isFullscreen = fullscreenElement === containerRef.current && fullscreenElement != null; - - const toggleFullscreen = useCallback(() => { - if (!containerRef.current) return; - if (document.fullscreenElement) { - void document.exitFullscreen(); - } else { - void containerRef.current.requestFullscreen(); - } - }, []); - - const currentLevel = compositionStack[compositionStack.length - 1]; - const directUrl = compositionStack.length > 1 ? currentLevel.previewUrl : undefined; - - const onIframeRefStable = useRef(onIframeRef); - onIframeRefStable.current = onIframeRef; - useEffect(() => { - onIframeRefStable.current?.(iframeRef.current); - }, [compositionStack.length, refreshKey, iframeRef]); - - // Keyboard: Escape to pop composition level - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Escape" && compositionStack.length > 1) { - updateCompositionStack((prev) => prev.slice(0, -1)); - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [compositionStack.length], - ); - - // Suppress TS unused-var warning for masterSeekRef (used inside useCompositionStack) - void masterSeekRef; - - return ( -
- {/* Preview + player controls */} -
-
{ - const el = iframeRef.current?.parentElement ?? iframeRef.current; - if (!el) return; - const rect = el.getBoundingClientRect(); - const inside = - e.clientX >= rect.left && - e.clientX <= rect.right && - e.clientY >= rect.top && - e.clientY <= rect.bottom; - if (!inside) onSelectTimelineElement?.(null); - }} - onDragEnter={handlePreviewDragEnter} - onDragOver={handlePreviewDragOver} - onDragLeave={handlePreviewDragLeave} - onDrop={handlePreviewDrop} - > -
- - {previewDragOver && ( -
- )} -
- {!isFullscreen && previewOverlay} -
-
- {!isFullscreen && compositionStack.length > 1 && ( - - )} - -
-
- - {!isFullscreen && isTimelineVisible ? ( - <> - - - {/* Timeline section */} -
-
{ - if ((e.target as HTMLElement).closest("[data-clip]")) return; - if (timelineDisabled) return; - if (compositionStack.length > 1) { - updateCompositionStack((prev) => prev.slice(0, -1)); - } - }} - > -
{timelineToolbar}
- -
- {timelineFooter &&
{timelineFooter}
} - {timelineDisabled && ( -
event.preventDefault()} - onDragOver={(event) => event.preventDefault()} - onDrop={(event) => event.preventDefault()} - > - - Loading composition… - -
- )} -
- - ) : !isFullscreen && onToggleTimeline ? ( -
-
-
- Timeline -
- -
-
- ) : null} -
- ); -}); diff --git a/packages/studio/src/components/nle/NLEPreview.tsx b/packages/studio/src/components/nle/NLEPreview.tsx index 6c02dd6e05..8e634f7671 100644 --- a/packages/studio/src/components/nle/NLEPreview.tsx +++ b/packages/studio/src/components/nle/NLEPreview.tsx @@ -459,7 +459,7 @@ export const NLEPreview = memo(function NLEPreview({
diff --git a/packages/studio/src/components/nle/TimelinePane.tsx b/packages/studio/src/components/nle/TimelinePane.tsx index e82a64e691..b97cf7b161 100644 --- a/packages/studio/src/components/nle/TimelinePane.tsx +++ b/packages/studio/src/components/nle/TimelinePane.tsx @@ -95,8 +95,14 @@ export function TimelinePane({ const handleMoveElements = useCallback( ( edits: Array<{ element: TimelineElement; updates: Pick }>, - ) => - onMoveElements?.( + coalesceKey?: string, + ) => { + // Match the sibling handlers: report the telemetry when the batch touches at + // least one expanded sub-comp child (the clips being rebased to local coords). + if (edits.some(({ element }) => element.expandedParentStart !== undefined)) { + trackStudioExpandedClipEdit({ action: "move" }); + } + return onMoveElements?.( edits.map(({ element, updates }) => { const basis = element.expandedParentStart; if (basis === undefined) return { element, updates }; @@ -105,7 +111,9 @@ export function TimelinePane({ updates: { ...updates, start: Math.max(0, updates.start - basis) }, }; }), - ), + coalesceKey, + ); + }, [onMoveElements, toLocalElement], ); diff --git a/packages/studio/src/components/nle/TimelineResizeDivider.tsx b/packages/studio/src/components/nle/TimelineResizeDivider.tsx index d3bb1c84ee..fc27d76969 100644 --- a/packages/studio/src/components/nle/TimelineResizeDivider.tsx +++ b/packages/studio/src/components/nle/TimelineResizeDivider.tsx @@ -73,6 +73,9 @@ export function TimelineResizeDivider({ ); return ( + // Horizontal resize divider: 3px visible seam (h-[3px]), 8px pointer-capture + // zone via the absolutely-positioned inner hit area so the layout gap stays + // at 3px while draggability is preserved over the full 8px band.
-
+ {/* Expanded hit zone: 8px tall, centered on the 3px seam */} +
+ {/* Visible hairline — invisible at rest, subtle wash on hover/drag/focus */} +
); } diff --git a/packages/studio/src/components/nle/useCompositionStack.ts b/packages/studio/src/components/nle/useCompositionStack.ts index 5f7f7b8dcf..e7e0e2dc7f 100644 --- a/packages/studio/src/components/nle/useCompositionStack.ts +++ b/packages/studio/src/components/nle/useCompositionStack.ts @@ -1,4 +1,4 @@ -// Composition drill-down stack management for NLELayout +// Composition drill-down stack management for NLEContext/EditorShell import { useState, useCallback, useRef, useEffect } from "react"; import { usePlayerStore } from "../../player"; import type { CompositionLevel } from "./CompositionBreadcrumb"; diff --git a/packages/studio/src/components/nle/useTimelineEditCallbacks.ts b/packages/studio/src/components/nle/useTimelineEditCallbacks.ts index 169888b4cf..2f9cc4878f 100644 --- a/packages/studio/src/components/nle/useTimelineEditCallbacks.ts +++ b/packages/studio/src/components/nle/useTimelineEditCallbacks.ts @@ -19,6 +19,7 @@ export interface TimelineEditCallbackDeps { ) => Promise | void; handleTimelineElementsMove: ( edits: Array<{ element: TimelineElement; updates: Pick }>, + coalesceKey?: string, ) => Promise | void; handleTimelineElementResize: ( element: TimelineElement, diff --git a/packages/studio/src/components/sidebar/AssetContextMenu.tsx b/packages/studio/src/components/sidebar/AssetContextMenu.tsx index b23a74fbd0..d4e2e187c4 100644 --- a/packages/studio/src/components/sidebar/AssetContextMenu.tsx +++ b/packages/studio/src/components/sidebar/AssetContextMenu.tsx @@ -80,33 +80,3 @@ export function ContextMenu({
); } - -export function DeleteConfirm({ - name, - onConfirm, - onCancel, -}: { - name: string; - onConfirm: () => void; - onCancel: () => void; -}) { - return ( -
- Delete {name}? -
- - -
-
- ); -} diff --git a/packages/studio/src/components/sidebar/AssetsTab.test.ts b/packages/studio/src/components/sidebar/AssetsTab.test.ts index 74b5c916e5..2ed54db44c 100644 --- a/packages/studio/src/components/sidebar/AssetsTab.test.ts +++ b/packages/studio/src/components/sidebar/AssetsTab.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { filterByUsage, countUsage, deriveUsedPaths } from "./AssetsTab"; +import { truncateMiddle, formatDuration } from "./assetHelpers"; import { globalAssetRows } from "./GlobalAssetsView"; const assets = ["bgm.mp3", "logo.png", "orphan.wav"]; @@ -47,6 +48,39 @@ describe("deriveUsedPaths", () => { "assets/logo.png", ]); }); + + it("handles fully-absolute URLs produced by the core runtime (toAbsoluteAssetUrl)", () => { + // The runtime calls new URL(raw, document.baseURI).toString() which produces + // "http://localhost:3012/api/projects/demo/preview/assets/clip.mp4" + const used = deriveUsedPaths([ + { src: "http://localhost:3012/api/projects/demo/preview/assets/clip.mp4" }, + { src: "http://localhost:3012/api/projects/abc123/preview/assets/logo.png" }, + ]); + expect(used.has("assets/clip.mp4")).toBe(true); + expect(used.has("assets/logo.png")).toBe(true); + expect(used.size).toBe(2); + }); + + it("decodes percent-encoded filenames (spaces, parens) so they match the asset list", () => { + // Files with spaces/parens: "assets/my file (1).mp4" authored in HTML + // → runtime resolves to "http://…/assets/my%20file%20(1).mp4" + const used = deriveUsedPaths([ + { src: "http://localhost:3012/api/projects/p/preview/assets/my%20file%20(1).mp4" }, + { src: "/api/projects/p/preview/assets/track%20one.mp3" }, + ]); + expect(used.has("assets/my file (1).mp4")).toBe(true); + expect(used.has("assets/track one.mp3")).toBe(true); + expect(used.size).toBe(2); + }); + + it("round-trips: absolute URL with spaces matches filterByUsage against plain asset list", () => { + const used = deriveUsedPaths([ + { src: "http://localhost:3012/api/projects/demo/preview/assets/my%20video.mp4" }, + ]); + expect(filterByUsage(["assets/my video.mp4", "assets/other.png"], used, "used")).toEqual([ + "assets/my video.mp4", + ]); + }); }); describe("countUsage", () => { @@ -59,6 +93,68 @@ describe("countUsage", () => { }); }); +describe("truncateMiddle", () => { + it("returns the original string when it fits within maxLen", () => { + expect(truncateMiddle("short.mp4", 20)).toBe("short.mp4"); + expect(truncateMiddle("exact_length_str.mp4", 20)).toBe("exact_length_str.mp4"); + }); + + it("truncates longer strings with an ellipsis in the middle", () => { + const result = truncateMiddle("2a37eabf-long-uuid-887d8.mp4", 20); + expect(result.length).toBeLessThanOrEqual(20); + expect(result).toContain("…"); + // Preserves head + expect(result.startsWith("2a37eabf-long-uuid-8")).toBe(false); // head is shortened + expect(result.startsWith("2a37eabf")).toBe(true); + // Preserves tail + expect(result.endsWith("887d8.mp4")).toBe(false); // tail portion only + expect(result.endsWith(".mp4")).toBe(true); + }); + + it("preserves the full filename extension in the tail", () => { + const result = truncateMiddle("verylongnamehere12345.mp4", 14); + expect(result.endsWith(".mp4")).toBe(true); + expect(result.length).toBeLessThanOrEqual(14); + }); + + it("handles maxLen of 1 (degenerate)", () => { + const result = truncateMiddle("abcdef", 1); + // head = 0, tail = 0 → just the ellipsis + expect(result).toBe("…"); + }); + + it("handles a string of exactly maxLen+1 chars", () => { + const result = truncateMiddle("abcdefgh", 7); + expect(result.length).toBeLessThanOrEqual(7); + expect(result).toContain("…"); + }); +}); + +describe("formatDuration", () => { + it("formats whole seconds as MM:SS", () => { + expect(formatDuration(28)).toBe("00:28"); + expect(formatDuration(60)).toBe("01:00"); + expect(formatDuration(90)).toBe("01:30"); + expect(formatDuration(3661)).toBe("61:01"); + }); + + it("rounds fractional seconds to nearest whole", () => { + expect(formatDuration(28.4)).toBe("00:28"); + expect(formatDuration(28.6)).toBe("00:29"); + }); + + it("returns empty string for non-positive values", () => { + expect(formatDuration(0)).toBe(""); + expect(formatDuration(-1)).toBe(""); + }); + + it("returns empty string for non-finite values", () => { + expect(formatDuration(NaN)).toBe(""); + expect(formatDuration(Infinity)).toBe(""); + expect(formatDuration(-Infinity)).toBe(""); + }); +}); + describe("globalAssetRows", () => { const recs = [ { id: "bgm_001", type: "bgm", description: "calm ambient" }, diff --git a/packages/studio/src/components/sidebar/AssetsTab.tsx b/packages/studio/src/components/sidebar/AssetsTab.tsx index fb99cc3765..e1344c88e6 100644 --- a/packages/studio/src/components/sidebar/AssetsTab.tsx +++ b/packages/studio/src/components/sidebar/AssetsTab.tsx @@ -1,21 +1,12 @@ // fallow-ignore-file code-duplication import { memo, useState, useCallback, useRef, useMemo, useEffect } from "react"; -import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail"; -import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, FONT_EXT } from "../../utils/mediaTypes"; -import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop"; +import { MEDIA_EXT, FONT_EXT } from "../../utils/mediaTypes"; import { copyTextToClipboard } from "../../utils/clipboard"; -import { ContextMenu } from "./AssetContextMenu"; import { usePlayerStore } from "../../player/store/playerStore"; -import { - type MediaCategory, - getCategory, - basename, - ext, - CATEGORY_LABELS, - FILTER_ORDER, -} from "./assetHelpers"; +import { type MediaCategory, getCategory, CATEGORY_LABELS, FILTER_ORDER } from "./assetHelpers"; import { AudioRow } from "./AudioRow"; import { GlobalAssetsView } from "./GlobalAssetsView"; +import { AssetCard, FontRow } from "./AssetCard"; interface AssetsTabProps { projectId: string; @@ -23,155 +14,7 @@ interface AssetsTabProps { onImport?: (files: FileList) => void; onDelete?: (path: string) => void; onRename?: (oldPath: string, newPath: string) => void; -} - -// fallow-ignore-next-line complexity -function ImageCard({ - projectId, - asset, - used, - onCopy, - isCopied, - onDelete, - onRename, - size, -}: { - projectId: string; - asset: string; - used: boolean; - onCopy: (path: string) => void; - isCopied: boolean; - onDelete?: (path: string) => void; - onRename?: (oldPath: string, newPath: string) => void; - size: "large" | "small"; -}) { - const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); - const [hovered, setHovered] = useState(false); - const name = basename(asset); - const extension = ext(asset); - const serveUrl = `/api/projects/${projectId}/preview/${asset}`; - const isVideo = VIDEO_EXT.test(asset); - const isImage = IMAGE_EXT.test(asset); - - const thumbW = size === "large" ? "w-full" : "w-[50px]"; - const thumbH = size === "large" ? "h-[100px]" : "h-[32px]"; - - return ( - <> -
onCopy(asset)} - onDragStart={(e) => { - e.dataTransfer.effectAllowed = "copy"; - e.dataTransfer.setData(TIMELINE_ASSET_MIME, JSON.stringify({ path: asset })); - e.dataTransfer.setData("text/plain", asset); - }} - onContextMenu={(e) => { - e.preventDefault(); - setContextMenu({ x: e.clientX, y: e.clientY }); - }} - onPointerEnter={() => setHovered(true)} - onPointerLeave={() => setHovered(false)} - className={`transition-colors cursor-pointer ${ - size === "large" - ? `px-2.5 py-1 ${isCopied ? "bg-studio-accent/10" : "hover:bg-neutral-800/30"}` - : `px-2.5 py-1.5 flex items-center gap-2.5 ${ - isCopied - ? "bg-studio-accent/10 border-l-2 border-studio-accent" - : "border-l-2 border-transparent hover:bg-neutral-800/50" - }` - }`} - > - {size === "large" ? ( -
-
- {isImage && ( - {name} { - (e.target as HTMLImageElement).style.display = "none"; - }} - /> - )} - {isVideo && } - {isVideo && hovered && ( -
-
- - {name} - - {extension} - {used && ( - - in use - - )} -
-
- ) : ( - <> -
- {isImage && ( - {name} { - (e.target as HTMLImageElement).style.display = "none"; - }} - /> - )} - {!isImage && ( - {extension} - )} -
-
- - {name} - -
- {extension} - {used && ( - - in use - - )} -
-
- - )} -
- - {contextMenu && ( - setContextMenu(null)} - onCopy={onCopy} - onDelete={onDelete} - onRename={onRename} - /> - )} - - ); + onAddAssetToTimeline?: (path: string) => void; } export type UsageFilter = "all" | "used" | "unused"; @@ -200,20 +43,48 @@ export function countUsage( /** * Project-relative asset paths referenced by composition elements — the set the * "in use" badge, used-first sort, and usage filter all key on. Element src is - * the raw authored value (timelineElementHelpers sets entry.src = - * getAttribute("src")), so it can be a relative path ("assets/x.png"), a - * "./"-prefixed path, the served "/api/projects//preview/assets/x.png" form, - * or carry a ?query — normalize all of them to the bare project path so they - * match the asset-list entries. Pure — unit-tested. + * populated from the core runtime's `resolveNodeAssetUrl` which calls + * `new URL(raw, document.baseURI).toString()`, turning authored relative paths + * into fully-absolute URLs with percent-encoded characters, e.g. + * "assets/my file (1).mp4" + * → "http://localhost:3012/api/projects/demo/preview/assets/my%20file%20(1).mp4" + * + * This function normalizes every src shape to the bare project-relative path so + * it matches the asset-list entries: + * - Absolute URL → strip origin + /api/projects//preview/ prefix, decode %XX + * - Server-relative /api/…preview/… → same strip + decode + * - Relative "./"-prefixed or bare → strip leading ./ or / + * - ?query / #hash → dropped + * + * Pure — unit-tested. */ export function deriveUsedPaths(elements: Array<{ src?: string }>): Set { const paths = new Set(); for (const el of elements) { if (!el.src) continue; - const s = el.src + let s = el.src; + + // Strip absolute origin if present (http://host/path → /path) + try { + const u = new URL(s); + s = u.pathname + (u.search ? u.search : "") + (u.hash ? u.hash : ""); + } catch { + // Not a valid absolute URL — leave as-is (relative path) + } + + s = s .replace(/^\/api\/projects\/[^/]+\/preview\//, "") // strip the dev serve prefix .replace(/^\.?\//, "") // strip leading ./ or / .split(/[?#]/)[0]; // drop query / hash + + // Decode percent-encoded characters (spaces, parens, etc.) so the path + // matches the plain-text asset-list entries the server returns. + try { + s = decodeURIComponent(s); + } catch { + // Malformed encoding — use as-is + } + if (s) paths.add(s); } return paths; @@ -225,6 +96,7 @@ export const AssetsTab = memo(function AssetsTab({ onImport, onDelete, onRename, + onAddAssetToTimeline, }: AssetsTabProps) { const fileInputRef = useRef(null); const [dragOver, setDragOver] = useState(false); @@ -232,17 +104,11 @@ export const AssetsTab = memo(function AssetsTab({ const [activeFilter, setActiveFilter] = useState("all"); const [usageFilter, setUsageFilter] = useState<"all" | "used" | "unused">("all"); const [searchQuery, setSearchQuery] = useState(""); - // Cross-project view: the global media-use cache (~/.media). The view itself - // (GlobalAssetsView) owns its fetch — AssetsTab only tracks which scope is active. const [viewMode, setViewMode] = useState<"local" | "global">("local"); const [manifest, setManifest] = useState< Map >(new Map()); - // Projects whose media manifest 404'd — most don't have one. Cache the miss so - // we don't re-fetch (and spam the console) on every re-render; the effect was - // also keyed on the `assets` array reference, which changes each render, so it - // re-fired constantly. Key on a stable join + skip known-missing manifests. const manifest404Ref = useRef>(new Set()); const assetsKey = assets.join("|"); useEffect(() => { @@ -278,7 +144,6 @@ export const AssetsTab = memo(function AssetsTab({ cancelled = true; }; }, [projectId, assetsKey]); - const handleDrop = useCallback( (e: React.DragEvent) => { e.preventDefault(); @@ -287,7 +152,6 @@ export const AssetsTab = memo(function AssetsTab({ }, [onImport], ); - const handleCopyPath = useCallback(async (path: string) => { const copied = await copyTextToClipboard(path); if (copied) { @@ -295,22 +159,27 @@ export const AssetsTab = memo(function AssetsTab({ setTimeout(() => setCopiedPath(null), 1500); } }, []); - const elements = usePlayerStore((s) => s.elements); const usedPaths = useMemo(() => deriveUsedPaths(elements), [elements]); - const mediaAssets = useMemo(() => { const media = assets.filter((a) => MEDIA_EXT.test(a) || FONT_EXT.test(a)); const all = filterByUsage(media, usedPaths, usageFilter); if (!searchQuery) return all; const q = searchQuery.toLowerCase(); return all.filter((a) => { - if (basename(a).toLowerCase().includes(q)) return true; + if ( + a + .split("/") + .pop() + ?.replace(/\.[^.]*$/, "") + .toLowerCase() + .includes(q) + ) + return true; const rec = manifest.get(a); return rec?.description?.toLowerCase().includes(q); }); }, [assets, searchQuery, manifest, usageFilter, usedPaths]); - const categorized = useMemo(() => { const groups: Record = { audio: [], images: [], video: [], fonts: [] }; for (const a of mediaAssets) { @@ -327,15 +196,11 @@ export const AssetsTab = memo(function AssetsTab({ } return groups; }, [mediaAssets, usedPaths]); - const counts = useMemo(() => { const c: Record = { all: mediaAssets.length }; for (const cat of FILTER_ORDER) c[cat] = categorized[cat].length; return c; }, [mediaAssets, categorized]); - - // Usage counts over the full media set (independent of the active usage filter, - // so the chips don't show their own filtered totals). const usageCounts = useMemo( () => countUsage( @@ -344,12 +209,10 @@ export const AssetsTab = memo(function AssetsTab({ ), [assets, usedPaths], ); - const visibleCategories = activeFilter === "all" ? FILTER_ORDER.filter((c) => categorized[c].length > 0) : [activeFilter as MediaCategory].filter((c) => categorized[c].length > 0); - return (
{/* Header — matches design panel Section pattern */}
- {/* Scope toggle — this project's assets vs the global media-use cache */} + {/* Scope toggle */}
{(["local", "global"] as const).map((m) => (
- {/* Asset list */}
{viewMode === "global" ? ( @@ -553,34 +414,38 @@ export const AssetsTab = memo(function AssetsTab({ isCopied={copiedPath === a} onDelete={onDelete} onRename={onRename} + onAddAssetToTimeline={onAddAssetToTimeline} /> ))} - {(cat === "images" || cat === "video") && - categorized[cat].map((a) => ( - - ))} + {(cat === "images" || cat === "video") && ( +
+ {categorized[cat].map((a) => ( + + ))} +
+ )} {cat === "fonts" && categorized[cat].map((a) => ( - ))}
diff --git a/packages/studio/src/components/sidebar/AudioRow.tsx b/packages/studio/src/components/sidebar/AudioRow.tsx index 4c9b442d31..46532420a4 100644 --- a/packages/studio/src/components/sidebar/AudioRow.tsx +++ b/packages/studio/src/components/sidebar/AudioRow.tsx @@ -2,6 +2,9 @@ import { useState, useRef, useEffect, useCallback } from "react"; import { ContextMenu } from "./AssetContextMenu"; import { basename, getAudioSubtype } from "./assetHelpers"; import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop"; +import { usePlayerStore } from "../../player/store/playerStore"; +import { useAssetPreviewStore } from "../../utils/assetPreviewStore"; +import { findClipForAsset, isPointerClick } from "../../utils/assetClickBehavior"; export function AudioRow({ projectId, @@ -12,6 +15,7 @@ export function AudioRow({ isCopied, onDelete, onRename, + onAddAssetToTimeline, }: { projectId: string; asset: string; @@ -21,6 +25,7 @@ export function AudioRow({ isCopied: boolean; onDelete?: (path: string) => void; onRename?: (oldPath: string, newPath: string) => void; + onAddAssetToTimeline?: (path: string) => void; }) { const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const [playing, setPlaying] = useState(false); @@ -34,6 +39,35 @@ export function AudioRow({ const subtype = getAudioSubtype(asset); const serveUrl = `/api/projects/${projectId}/preview/${asset}`; + // CapCut-style click behavior: drag-threshold gate. + const pointerDownRef = useRef<{ x: number; y: number } | null>(null); + const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId); + const elements = usePlayerStore((s) => s.elements); + const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset); + + const handlePointerDown = useCallback((e: React.PointerEvent) => { + pointerDownRef.current = { x: e.clientX, y: e.clientY }; + }, []); + + const handlePointerUp = useCallback( + (e: React.PointerEvent) => { + const origin = pointerDownRef.current; + pointerDownRef.current = null; + if (!origin) return; + if (!isPointerClick(e.clientX - origin.x, e.clientY - origin.y)) return; + if (used) { + const clip = findClipForAsset(elements, asset); + if (clip) { + setSelectedElementId(clip.key ?? clip.id); + return; + } + } + // Not added → preview overlay (audio player) + setPreviewAsset(asset, projectId); + }, + [used, elements, asset, projectId, setSelectedElementId, setPreviewAsset], + ); + useEffect(() => { return () => { cancelAnimationFrame(animRef.current); @@ -109,7 +143,8 @@ export function AudioRow({ <>
onCopy(asset)} + onPointerDown={handlePointerDown} + onPointerUp={handlePointerUp} onDragStart={(e) => { e.dataTransfer.effectAllowed = "copy"; e.dataTransfer.setData(TIMELINE_ASSET_MIME, JSON.stringify({ path: asset })); @@ -195,6 +230,7 @@ export function AudioRow({ onCopy={onCopy} onDelete={onDelete} onRename={onRename} + onAddAtPlayhead={onAddAssetToTimeline} /> )} diff --git a/packages/studio/src/components/sidebar/LeftSidebar.tsx b/packages/studio/src/components/sidebar/LeftSidebar.tsx index 2ca8b4120f..acaed6cda6 100644 --- a/packages/studio/src/components/sidebar/LeftSidebar.tsx +++ b/packages/studio/src/components/sidebar/LeftSidebar.tsx @@ -60,6 +60,7 @@ interface LeftSidebarProps { onAddBlock?: (blockName: string) => void; onPreviewBlock?: (preview: BlockPreviewInfo | null) => void; takeoverContent?: ReactNode; + onAddAssetToTimeline?: (path: string) => void; } export const LeftSidebar = memo( @@ -92,6 +93,7 @@ export const LeftSidebar = memo( onAddBlock, onPreviewBlock, takeoverContent, + onAddAssetToTimeline, }, ref, ) { @@ -111,7 +113,7 @@ export const LeftSidebar = memo( return (
{takeoverContent ? ( @@ -230,6 +232,7 @@ export const LeftSidebar = memo( onImport={onImportFiles} onDelete={onDeleteFile} onRename={onRenameFile} + onAddAssetToTimeline={onAddAssetToTimeline} /> )} {tab === "code" && ( diff --git a/packages/studio/src/contexts/StudioContext.tsx b/packages/studio/src/contexts/StudioContext.tsx index 6cc56116ed..7c7e35b617 100644 --- a/packages/studio/src/contexts/StudioContext.tsx +++ b/packages/studio/src/contexts/StudioContext.tsx @@ -31,8 +31,6 @@ export interface StudioShellValue { compositionDimensions: CompositionDimensions | null; waitForPendingDomEditSaves: () => Promise; handlePreviewIframeRef: (iframe: HTMLIFrameElement | null) => void; - timelineVisible: boolean; - toggleTimelineVisibility: () => void; } export interface StudioPlaybackValue { @@ -99,8 +97,6 @@ export function StudioShellProvider({ compositionDimensions, waitForPendingDomEditSaves, handlePreviewIframeRef, - timelineVisible, - toggleTimelineVisibility, } = value; const stable = useMemo( @@ -117,14 +113,11 @@ export function StudioShellProvider({ compositionDimensions, waitForPendingDomEditSaves, handlePreviewIframeRef, - timelineVisible, - toggleTimelineVisibility, }), [ projectId, activeCompPath, compositionDimensions, - timelineVisible, editHistory, renderQueue, setActiveCompPath, @@ -134,7 +127,6 @@ export function StudioShellProvider({ handleRedo, waitForPendingDomEditSaves, handlePreviewIframeRef, - toggleTimelineVisibility, ], ); return {children}; diff --git a/packages/studio/src/contexts/TimelineEditContext.tsx b/packages/studio/src/contexts/TimelineEditContext.tsx index bfc47c6bd7..c2b6edc6ce 100644 --- a/packages/studio/src/contexts/TimelineEditContext.tsx +++ b/packages/studio/src/contexts/TimelineEditContext.tsx @@ -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,18 +26,14 @@ 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.onResizeElement, value.onToggleTrackHidden, - value.onToggleElementHidden, value.onBlockedEditAttempt, value.onSplitElement, value.onRazorSplit, diff --git a/packages/studio/src/hooks/domEditCommitTypes.ts b/packages/studio/src/hooks/domEditCommitTypes.ts index b55a18e32d..ffa41cb8f1 100644 --- a/packages/studio/src/hooks/domEditCommitTypes.ts +++ b/packages/studio/src/hooks/domEditCommitTypes.ts @@ -7,6 +7,7 @@ export type PersistDomEditOperations = ( options?: { label?: string; coalesceKey?: string; + coalesceMs?: number; skipRefresh?: boolean; prepareContent?: (html: string, sourceFile: string) => string; shouldSave?: () => boolean; diff --git a/packages/studio/src/hooks/timelineEditingHelpers.test.ts b/packages/studio/src/hooks/timelineEditingHelpers.test.ts index 0d31cb50cc..818cc17338 100644 --- a/packages/studio/src/hooks/timelineEditingHelpers.test.ts +++ b/packages/studio/src/hooks/timelineEditingHelpers.test.ts @@ -1,104 +1,138 @@ -// @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from "vitest"; -import { applyTimelineStackingReorder, extendRootDurationIfNeeded } from "./timelineEditingHelpers"; -import type { TimelineElement } from "../player/store/playerStore"; -import { usePlayerStore } from "../player/store/playerStore"; +import type { SoftReloadResult } from "../utils/gsapSoftReload"; + +// Mock the soft-reload primitive so we can assert syncTimingEditPreview's +// decision (soft-reload vs. escalate to full reload) without a live GSAP iframe. +const applySoftReloadMock = + vi.fn< + ( + iframe: HTMLIFrameElement | null, + scriptText: string, + onAsyncFailure?: () => void, + currentTimeOverride?: number, + ) => SoftReloadResult + >(); +vi.mock("../utils/gsapSoftReload", () => ({ + applySoftReload: (...args: Parameters) => + applySoftReloadMock(...args), +})); + +// Imported after the mock is registered. +const { syncTimingEditPreview, buildTimelineMoveTimingPatch } = + await import("./timelineEditingHelpers"); + +// A stand-in iframe — syncTimingEditPreview only forwards it to applySoftReload. +const fakeIframe = {} as HTMLIFrameElement; afterEach(() => { - usePlayerStore.getState().reset(); + applySoftReloadMock.mockReset(); }); -function makeIframeWith(html: string): HTMLIFrameElement { - const iframe = document.createElement("iframe"); - document.body.append(iframe); - const doc = iframe.contentDocument; - if (!doc) throw new Error("expected iframe document"); - doc.body.innerHTML = html; - return iframe; -} - -function el(input: Partial & { id: string; tag: string }): TimelineElement { - return { - label: input.id, - start: 0, - duration: 5, - track: 0, - zIndex: 0, - hasExplicitZIndex: false, - stackingContextId: null, - ...input, - }; -} +describe("syncTimingEditPreview (timing-only edit classifier)", () => { + it("full-reloads and never soft-reloads when the server returned no scriptText", () => { + const reloadPreview = vi.fn(); + syncTimingEditPreview(fakeIframe, { scriptText: null }, 1.5, reloadPreview); + expect(applySoftReloadMock).not.toHaveBeenCalled(); + expect(reloadPreview).toHaveBeenCalledTimes(1); + }); -describe("applyTimelineStackingReorder", () => { - it("commits via the change's own locator even when the element is not in timelineElements", () => { - // Sub-comp children live in the preview iframe but NOT in the top-level - // timelineElements list — the intent must be self-contained. - const iframe = makeIframeWith(`
`); - const commit = vi.fn<(entries: unknown[]) => void>(); + it("full-reloads when there is no iframe", () => { + const reloadPreview = vi.fn(); + syncTimingEditPreview(null, { scriptText: "gsap.timeline()" }, 0, reloadPreview); + expect(applySoftReloadMock).not.toHaveBeenCalled(); + expect(reloadPreview).toHaveBeenCalledTimes(1); + }); - applyTimelineStackingReorder({ - element: el({ id: "chip", tag: "div" }), - stackingReorder: { - contextKey: "scene", - placement: { type: "above", layerId: "layer:scene:x" }, - zIndexChanges: [ - { - key: "scenes/scene.html#chip", - zIndex: 5, - domId: "chip", - sourceFile: "scenes/scene.html", - }, - ], - }, - timelineElements: [], // element intentionally absent from the top-level list - iframe, - activeCompPath: "index.html", - commit, + it("soft-reloads (no full reload) on the applied result, forwarding the current time", () => { + applySoftReloadMock.mockReturnValue("applied"); + const reloadPreview = vi.fn(); + syncTimingEditPreview(fakeIframe, { scriptText: "gsap.timeline()" }, 2.25, reloadPreview); + expect(applySoftReloadMock).toHaveBeenCalledWith(fakeIframe, "gsap.timeline()", { + onAsyncFailure: reloadPreview, + currentTimeOverride: 2.25, }); - - expect(commit).toHaveBeenCalledTimes(1); - const entries = commit.mock.calls[0]![0] as Array<{ - zIndex: number; - id?: string; - sourceFile: string; - }>; - expect(entries).toHaveLength(1); - expect(entries[0]!.zIndex).toBe(5); - expect(entries[0]!.id).toBe("chip"); - expect(entries[0]!.sourceFile).toBe("scenes/scene.html"); + expect(reloadPreview).not.toHaveBeenCalled(); }); - it("never commits when the dragged clip is audio", () => { - const iframe = makeIframeWith(``); - const commit = vi.fn<(entries: unknown[]) => void>(); + it("BUG 2: a single-file active-comp timing move with a successful script swap does NOT full-reload (no blink)", () => { + // The live blink came from the move being (mis)routed through a reloading path. + // Once a plain horizontal move is a single-clip timing edit, the fallback shifts + // the GSAP script and swaps it in place — a successful swap ("applied") must NOT + // trigger reloadPreview(), so the iframe never remounts and the preview never + // blinks / re-fetches files/index.html. + applySoftReloadMock.mockReturnValue("applied"); + const reloadPreview = vi.fn(); + syncTimingEditPreview( + fakeIframe, + { scriptText: 'window.__timelines["main"] = tl;' }, + 3.2, + reloadPreview, + ); + expect(applySoftReloadMock).toHaveBeenCalledTimes(1); + expect(reloadPreview).not.toHaveBeenCalled(); + }); - applyTimelineStackingReorder({ - element: el({ id: "track", tag: "audio" }), - stackingReorder: { - contextKey: "main", - placement: { type: "above", layerId: "layer:main:x" }, - zIndexChanges: [{ key: "track", zIndex: 5, domId: "track" }], - }, - timelineElements: [], - iframe, - activeCompPath: "index.html", - commit, - }); + it("does NOT escalate on the transient verify-failed result (live state is already correct)", () => { + applySoftReloadMock.mockReturnValue("verify-failed"); + const reloadPreview = vi.fn(); + syncTimingEditPreview(fakeIframe, { scriptText: "gsap.timeline()" }, 0, reloadPreview); + expect(reloadPreview).not.toHaveBeenCalled(); + }); - expect(commit).not.toHaveBeenCalled(); + it("escalates to a full reload on the permanent cannot-soft-reload result", () => { + applySoftReloadMock.mockReturnValue("cannot-soft-reload"); + const reloadPreview = vi.fn(); + syncTimingEditPreview(fakeIframe, { scriptText: "gsap.timeline()" }, 0, reloadPreview); + // reloadPreview is passed as the async-failure callback AND invoked directly on + // the permanent result. The direct escalation is the one that matters here. + expect(reloadPreview).toHaveBeenCalledTimes(1); }); }); -describe("extendRootDurationIfNeeded", () => { - it("extends the player duration only when the new end is larger", () => { - usePlayerStore.getState().setDuration(4); +// ── ITEM 3 — #2212 NaN insurance ──────────────────────────────────────────── +describe("buildTimelineMoveTimingPatch (non-finite guard)", () => { + it("emits both timing patches for a finite move (start + track-index, both dialects)", () => { + expect(buildTimelineMoveTimingPatch({ start: 1.5, track: 2 })).toEqual([ + { property: "start", attr: "data-start", value: "1.5" }, + { property: "track-index", attr: "data-track-index", value: "2" }, + ]); + }); - expect(extendRootDurationIfNeeded(5)).toBe(true); - expect(usePlayerStore.getState().duration).toBe(5); + it("SKIPS a non-finite start (undefined→NaN) and warns once naming the field, keeping the track patch", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + // The mid-stack deploy shape: `start` arrives undefined. Number.isFinite(NaN) + // is false, so the start patch is dropped — never serialized as "NaN". + const patches = buildTimelineMoveTimingPatch({ + start: undefined as unknown as number, + track: 3, + }); + expect(patches).toEqual([{ property: "track-index", attr: "data-track-index", value: "3" }]); + expect(patches.some((p) => p.attr === "data-start")).toBe(false); + expect(warn.mock.calls.some((call) => String(call[0]).includes("start"))).toBe(true); + warn.mockRestore(); + }); + + it("SKIPS a non-finite track and keeps the start patch", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const patches = buildTimelineMoveTimingPatch({ start: 4, track: Number.NaN }); + expect(patches).toEqual([{ property: "start", attr: "data-start", value: "4" }]); + expect(patches.some((p) => p.attr === "data-track-index")).toBe(false); + warn.mockRestore(); + }); - expect(extendRootDurationIfNeeded(5)).toBe(false); - expect(extendRootDurationIfNeeded(3)).toBe(false); - expect(usePlayerStore.getState().duration).toBe(5); + it("never serializes a NaN string — the surviving field's value is byte-identical to the finite-only patch (#2212)", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + // Baseline: the track patch for a fully finite move. + const finiteTrackPatch = buildTimelineMoveTimingPatch({ start: 1, track: 3 }).find( + (p) => p.attr === "data-track-index", + ); + // Guard fires on the non-finite start: the surviving track patch's serialized + // string must be UNCHANGED (dropping start must not perturb it)... + const guarded = buildTimelineMoveTimingPatch({ start: Number.NaN, track: 3 }); + expect(guarded.find((p) => p.attr === "data-track-index")).toEqual(finiteTrackPatch); + // ...and no returned patch may ever carry the poison "NaN" string (the whole + // point of the guard — String(NaN)/formatTimelineAttributeNumber(NaN)="NaN"). + expect(guarded.every((p) => !p.value.includes("NaN"))).toBe(true); + warn.mockRestore(); }); }); diff --git a/packages/studio/src/hooks/timelineEditingHelpers.ts b/packages/studio/src/hooks/timelineEditingHelpers.ts index d3d7347f36..6d3160e05b 100644 --- a/packages/studio/src/hooks/timelineEditingHelpers.ts +++ b/packages/studio/src/hooks/timelineEditingHelpers.ts @@ -1,122 +1,9 @@ +import type { TimelineElement } from "../player/store/playerStore"; import { applySoftReload } from "../utils/gsapSoftReload"; -import { type TimelineElement, usePlayerStore } from "../player/store/playerStore"; import { applyPatchByTarget, readAttributeByTarget } from "../utils/sourcePatcher"; -import { - formatTimelineAttributeNumber, - type TimelineStackingReorderIntent, -} from "../player/components/timelineEditing"; -import { getElementZIndex } from "../player/lib/layerOrdering"; -import { getTimelineElementIdentity } from "../player/lib/timelineElementHelpers"; +import { formatTimelineAttributeNumber } from "../player/components/timelineEditing"; import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; -import { selectedKeyframePercentagesForElement } from "../utils/keyframeSelection"; import type { EditHistoryKind } from "../utils/editHistory"; -import type { TimelineZIndexReorderCommit } from "./useTimelineEditingTypes"; -import { extendRootDurationInSource } from "../utils/rootDuration"; - -function isHTMLElement(element: Element | null): element is HTMLElement { - if (!element) return false; - // Use the element's OWN realm's HTMLElement: timeline clips live in the preview - // iframe, and cross-realm `element instanceof HTMLElement` (main window) is - // always false — which silently dropped every timeline z-index commit. - const Ctor = element.ownerDocument?.defaultView?.HTMLElement ?? globalThis.HTMLElement; - return element instanceof Ctor; -} - -/** - * Resolve a timeline vertical move to a z-index stacking reorder and commit it - * through the shared layers-panel reorder path. Reads live sibling z-index from - * the preview DOM, remaps with the dup-preserving reorder math, and writes only - * z-index (never data-track-index). No-op when the move isn't a reorder, the - * dragged clip is audio (no visual layer to restack), or the live siblings can't - * be resolved. Extracted from StudioApp's timeline hook to keep it under the - * studio 600-LOC cap. - */ -// fallow-ignore-next-line complexity -export function applyTimelineStackingReorder(input: { - element: TimelineElement; - stackingReorder: TimelineStackingReorderIntent | null | undefined; - timelineElements: readonly TimelineElement[]; - iframe: HTMLIFrameElement | null; - activeCompPath: string | null; - commit: TimelineZIndexReorderCommit | null | undefined; -}): Promise { - // Audio has no visual stacking; a vertical drag on it must never write z-index. - if (input.element.tag === "audio") return Promise.resolve(); - - const intent = input.stackingReorder ?? null; - if (intent == null || intent.zIndexChanges.length === 0) return Promise.resolve(); - - // Resolve each change's live element from the change's OWN locator (the intent - // is self-contained), falling back to the top-level element list. Sub-comp - // children aren't in `timelineElements`, so a list-only lookup would miss them. - const siblingByKey = new Map( - input.timelineElements.map((el) => [getTimelineElementIdentity(el), el]), - ); - const doc = input.iframe?.contentDocument ?? null; - const findLive = (domId?: string, selector?: string, selectorIndex?: number): Element | null => { - if (!doc) return null; - if (domId) return doc.getElementById(domId); - if (selector) return doc.querySelectorAll(selector)[selectorIndex ?? 0] ?? null; - return null; - }; - const commitEntries: Array<{ - element: HTMLElement; - zIndex: number; - id?: string; - selector?: string; - selectorIndex?: number; - sourceFile: string; - key: string; - }> = []; - - for (const change of intent.zIndexChanges) { - const sibling = siblingByKey.get(change.key); - const domId = change.domId ?? sibling?.domId; - const selector = change.selector ?? sibling?.selector; - const selectorIndex = change.selectorIndex ?? sibling?.selectorIndex; - const element = findLive(domId, selector, selectorIndex); - if (!isHTMLElement(element)) return Promise.resolve(); - if (getElementZIndex(element) === change.zIndex) continue; - commitEntries.push({ - element, - zIndex: change.zIndex, - id: domId ?? sibling?.id ?? change.key, - selector, - selectorIndex, - sourceFile: change.sourceFile ?? sibling?.sourceFile ?? input.activeCompPath ?? "index.html", - key: change.key, - }); - } - - if (commitEntries.length === 0) return Promise.resolve(); - return input.commit?.(commitEntries) ?? Promise.resolve(); -} - -/** - * Remove the keyframes currently selected in the player store from the active - * element's GSAP animation. Reads selection lazily so it stays correct when - * invoked from a ref callback. Extracted from StudioApp to keep it under the - * studio 600-LOC cap. - */ -export function deleteSelectedKeyframes(session: { - selectedGsapAnimations: readonly { id: string; keyframes?: unknown }[]; - handleGsapRemoveKeyframe: (animId: string, pct: number) => void; -}): void { - const { selectedKeyframes, selectedElementId } = usePlayerStore.getState(); - const animation = session.selectedGsapAnimations.find((anim) => anim.keyframes); - if (!animation) return; - // Only the active element's keyframes; a stale cross-element selection must not delete here. - for (const pct of selectedKeyframePercentagesForElement(selectedKeyframes, selectedElementId)) { - session.handleGsapRemoveKeyframe(animation.id, pct); - } -} - -export function extendRootDurationIfNeeded(newEnd: number): boolean { - const store = usePlayerStore.getState(); - if (newEnd <= store.duration) return false; - store.setDuration(newEnd); - return true; -} // ── Types ── @@ -124,8 +11,6 @@ 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; } @@ -154,6 +39,56 @@ export function buildPatchTarget(element: { export type PatchTarget = NonNullable>; +/** One clip-timing attribute to write for a move, in both patch dialects. */ +export interface TimelineMoveTimingPatch { + /** Source-patcher attribute name (no `data-` prefix): fed to applyPatchByTarget. */ + property: "start" | "track-index"; + /** Live-DOM attribute name (`data-` prefixed): fed to patchIframeDomTiming. */ + attr: "data-start" | "data-track-index"; + value: string; +} + +let warnedNonFiniteMoveTiming = false; + +/** + * Build the timing-attribute patches (data-start + data-track-index) for one clip + * move, SKIPPING any field whose numeric value is non-finite. + * + * #2212 insurance: in a mid-stack deploy window a stale handler can receive a move + * whose `start` (or `track`) is `undefined`, and `formatTimelineAttributeNumber` + * / `String` would then serialize `NaN`/`"NaN"` straight into `data-start`, + * poisoning the source and the live DOM (the runtime re-reads the attribute and + * renders the clip at NaN). Dropping the non-finite field leaves the clip's prior + * value intact instead of persisting garbage, and warns once (naming the field) so + * the upstream shape bug is still visible. + */ +export function buildTimelineMoveTimingPatch( + updates: Pick, +): TimelineMoveTimingPatch[] { + const patches: TimelineMoveTimingPatch[] = []; + const push = ( + field: "start" | "track", + property: TimelineMoveTimingPatch["property"], + attr: TimelineMoveTimingPatch["attr"], + value: number, + format: (v: number) => string, + ): void => { + if (!Number.isFinite(value)) { + if (!warnedNonFiniteMoveTiming) { + warnedNonFiniteMoveTiming = true; + console.warn( + `[Timeline] Skipping non-finite move timing patch for "${field}" (value=${String(value)}) — not persisting NaN into ${attr}`, + ); + } + return; + } + patches.push({ property, attr, value: format(value) }); + }; + push("start", "start", "data-start", updates.start, formatTimelineAttributeNumber); + push("track", "track-index", "data-track-index", updates.track, String); + return patches; +} + // The runtime re-reads data-start/data-duration from the DOM on each sync tick // (packages/core/src/runtime/init.ts:1324-1368), so attribute mutations here are // picked up automatically on the next frame without a rebind call. @@ -188,25 +123,48 @@ export function patchIframeDomTiming( } } -function postRootDurationToPreview( +/** + * Pure: find the TOP-LEVEL composition root in `doc` (the `[data-composition-id]` + * with no ancestor composition, matching the runtime's own root resolution) and + * write `contentEnd` into its `data-duration`. Returns whether a write happened. + * + * A timing edit optimistically patches the moved clip's `data-start`/`-duration` + * in the live iframe, but NOT the root's `data-duration`. Timing edits now take + * the soft-reload path (no full iframe reload), which re-runs the GSAP script and + * lets the runtime recompute the composition length — it reads the root's + * `data-duration` as the authored floor (core/runtime/init.ts) and posts it back, + * so the studio store's duration is set from the STALE root and the readout + * reverts to the pre-edit length. Patching the root here keeps the runtime's + * post-soft-reload duration report in agreement with the optimistic readout, so + * the number stays live (grow AND shrink) instead of snapping back. + */ +export function patchDocumentRootDuration( + doc: Document | null | undefined, + contentEnd: number, +): boolean { + if (!doc || !Number.isFinite(contentEnd) || contentEnd <= 0) return false; + const nodes = Array.from(doc.querySelectorAll("[data-composition-id]")); + const root = + nodes.find((node) => !node.parentElement?.closest("[data-composition-id]")) ?? nodes[0] ?? null; + if (!root) return false; + root.setAttribute("data-duration", formatTimelineAttributeNumber(contentEnd)); + return true; +} + +/** Best-effort live-iframe wrapper for patchDocumentRootDuration (see above). */ +export function patchIframeRootDuration( iframe: HTMLIFrameElement | null, - durationSeconds: number, + contentEnd: number, ): void { - const duration = Number(durationSeconds); - if (!Number.isFinite(duration) || duration <= 0) return; - iframe?.contentWindow?.postMessage( - { - source: "hf-parent", - type: "control", - action: "set-root-duration", - durationSeconds: duration, - }, - "*", - ); + try { + patchDocumentRootDuration(iframe?.contentDocument ?? null, contentEnd); + } catch { + // Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort. + } } // fallow-ignore-next-line complexity -function resolveResizePlaybackStart( +export function resolveResizePlaybackStart( original: string, target: PatchTarget, element: TimelineElement, @@ -232,57 +190,6 @@ function resolveResizePlaybackStart( }; } -export function buildTimelineMoveTimingPatch( - original: string, - target: PatchTarget, - start: number, - duration: number, -): string { - // Coexistence-window guard: a caller resolving the legacy context handler can - // receive the new engine's {element, updates} change shape and read a field - // that doesn't exist — start arrives undefined/NaN and would be serialized as - // data-start="NaN" into the file. Never persist a non-finite timing value. - if (!Number.isFinite(start) || !Number.isFinite(duration)) { - console.warn( - `[Timeline] buildTimelineMoveTimingPatch: non-finite timing (start=${start}, duration=${duration}) — patch skipped`, - ); - return original; - } - const patched = applyPatchByTarget(original, target, { - type: "attribute", - property: "start", - value: formatTimelineAttributeNumber(start), - }); - return extendRootDurationInSource(patched, start + duration); -} - -export function buildTimelineResizeTimingPatch( - original: string, - target: PatchTarget, - element: TimelineElement, - updates: Pick, -): string { - const pbs = resolveResizePlaybackStart(original, target, element, updates); - let patched = applyPatchByTarget(original, target, { - type: "attribute", - property: "start", - value: formatTimelineAttributeNumber(updates.start), - }); - patched = applyPatchByTarget(patched, target, { - type: "attribute", - property: "duration", - value: formatTimelineAttributeNumber(updates.duration), - }); - if (pbs) { - patched = applyPatchByTarget(patched, target, { - type: "attribute", - property: pbs.attrName, - value: formatTimelineAttributeNumber(pbs.value), - }); - } - return extendRootDurationInSource(patched, updates.start + updates.duration); -} - export interface PersistTimelineEditInput { projectId: string; element: TimelineElement; @@ -325,66 +232,6 @@ 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; - recordEdit: (input: RecordEditInput) => Promise; - domEditSaveTimestampRef: React.MutableRefObject; - pendingTimelineEditPathRef: React.MutableRefObject>; - coalesceKey?: string; -} - -export async function persistTimelineBatchEdit( - input: PersistTimelineBatchEditInput, -): Promise { - const originals = new Map(); - const patchedByPath = new Map(); - - 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 { if (targetPath.includes("\0") || targetPath.includes("..")) { throw new Error(`Unsafe path: ${targetPath}`); @@ -402,105 +249,35 @@ export async function readFileContent(projectId: string, targetPath: string): Pr return data.content; } -export type GsapMutationStatus = { mutated: boolean }; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function readMutationStatus(value: unknown): GsapMutationStatus { - if (!isRecord(value)) return { mutated: false }; - return { mutated: value.mutated === true || value.changed === true }; -} - -function readMutationError(value: unknown, fallback: string): string { - if (isRecord(value) && typeof value.error === "string") return value.error; - return fallback; -} - -export async function finishTimelineTimingFallback(input: { - iframe: HTMLIFrameElement | null; - needsExtension: boolean; - rootDurationSeconds: number; - reloadPreview: () => void; - gsapMutation?: () => Promise; - onGsapError: (error: unknown) => void; -}): Promise { - let gsapMutated = false; - if (input.gsapMutation) { - try { - gsapMutated = (await input.gsapMutation()).mutated; - } catch (error) { - input.onGsapError(error); - return; - } - } - if (input.needsExtension) { - postRootDurationToPreview(input.iframe, input.rootDurationSeconds); - if (gsapMutated) input.reloadPreview(); - return; - } - 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. + * The bits of the server GSAP-mutation response the timeline edit path needs. + * `scriptText` is the rewritten root GSAP script — feeding it to `applySoftReload` + * swaps the runtime timeline in place (no iframe reload = no all-clips flash). Null + * when the endpoint didn't return one (older server, or a multi-script comp the + * soft path can't scope), in which case the caller full-reloads as before. */ -export async function foldGsapMutationIntoHistory(input: { - projectId: string; - paths: string[]; - label: string; - coalesceKey?: string; - recordEdit: (edit: RecordEditInput) => Promise; - gsapMutation: () => Promise; -}): Promise { - const uniquePaths = [...new Set(input.paths)]; - const before = new Map(); - for (const path of uniquePaths) { - before.set(path, await readFileContent(input.projectId, path)); - } - const status = await input.gsapMutation(); - if (status.mutated) { - const files: Record = {}; - 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; +export interface GsapMutationOutcome { + scriptText: string | null; +} + +function readGsapMutationScriptText(body: unknown): string | null { + if (typeof body !== "object" || body === null) return null; + const text = (body as { scriptText?: unknown }).scriptText; + return typeof text === "string" ? text : null; } /** * 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. + * Returns the rewritten script so the caller can soft-reload instead of full-reload. */ export async function shiftGsapPositions( projectId: string, filePath: string, elementId: string, delta: number, -): Promise { - if (delta === 0 || !elementId) return { mutated: false }; +): Promise { + if (delta === 0 || !elementId) return { scriptText: null }; const res = await fetch( `/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`, { @@ -515,138 +292,9 @@ export async function shiftGsapPositions( ); if (!res.ok) { const err = await res.json().catch(() => null); - throw new Error(readMutationError(err, "shift-positions failed")); - } - return readMutationStatus(await res.json().catch(() => null)); -} - -export async function scaleGsapPositions( - projectId: string, - filePath: string, - elementId: string, - oldStart: number, - oldDuration: number, - newStart: number, - newDuration: number, -): Promise { - if (!elementId || oldDuration <= 0 || newDuration <= 0) return { mutated: false }; - if (oldStart === newStart && oldDuration === newDuration) return { mutated: false }; - const res = await fetch( - `/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - type: "scale-positions", - targetSelector: `#${elementId}`, - oldStart, - oldDuration, - newStart, - newDuration, - }), - }, - ); - if (!res.ok) { - const err = await res.json().catch(() => null); - throw new Error(readMutationError(err, "scale-positions failed")); - } - 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; -}): () => Promise { - 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; -}): () => Promise { - 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 }; - -/** - * Pure: find the TOP-LEVEL composition root in `doc` (the `[data-composition-id]` - * with no ancestor composition, matching the runtime's own root resolution) and - * write `contentEnd` into its `data-duration`. Returns whether a write happened. - * - * A timing edit optimistically patches the moved clip's `data-start`/`-duration` - * in the live iframe, but NOT the root's `data-duration`. Timing edits now take - * the soft-reload path (no full iframe reload), which re-runs the GSAP script and - * lets the runtime recompute the composition length — it reads the root's - * `data-duration` as the authored floor (core/runtime/init.ts) and posts it back, - * so the studio store's duration is set from the STALE root and the readout - * reverts to the pre-edit length. Patching the root here keeps the runtime's - * post-soft-reload duration report in agreement with the optimistic readout, so - * the number stays live (grow AND shrink) instead of snapping back. - */ -export function patchDocumentRootDuration( - doc: Document | null | undefined, - contentEnd: number, -): boolean { - if (!doc || !Number.isFinite(contentEnd) || contentEnd <= 0) return false; - const nodes = Array.from(doc.querySelectorAll("[data-composition-id]")); - const root = - nodes.find((node) => !node.parentElement?.closest("[data-composition-id]")) ?? nodes[0] ?? null; - if (!root) return false; - root.setAttribute("data-duration", formatTimelineAttributeNumber(contentEnd)); - return true; -} - -/** Best-effort live-iframe wrapper for patchDocumentRootDuration (see above). */ -export function patchIframeRootDuration( - iframe: HTMLIFrameElement | null, - contentEnd: number, -): void { - try { - patchDocumentRootDuration(iframe?.contentDocument ?? null, contentEnd); - } catch { - // Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort. + throw new Error((err as { error?: string })?.error ?? "shift-positions failed"); } + return { scriptText: readGsapMutationScriptText(await res.json().catch(() => null)) }; } /** @@ -680,6 +328,39 @@ export async function shiftGsapPositionsBatch( return { scriptText: readGsapMutationScriptText(await res.json().catch(() => null)) }; } +export async function scaleGsapPositions( + projectId: string, + filePath: string, + elementId: string, + oldStart: number, + oldDuration: number, + newStart: number, + newDuration: number, +): Promise { + if (!elementId || oldDuration <= 0 || newDuration <= 0) return { scriptText: null }; + if (oldStart === newStart && oldDuration === newDuration) return { scriptText: null }; + const res = await fetch( + `/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "scale-positions", + targetSelector: `#${elementId}`, + oldStart, + oldDuration, + newStart, + newDuration, + }), + }, + ); + if (!res.ok) { + const err = await res.json().catch(() => null); + throw new Error((err as { error?: string })?.error ?? "scale-positions failed"); + } + return { scriptText: readGsapMutationScriptText(await res.json().catch(() => null)) }; +} + /** * Sync the live preview after a TIMING-ONLY edit (move / resize), preferring a * soft reload over the full iframe reload that flashes every clip. @@ -718,20 +399,4 @@ export function syncTimingEditPreview( } // Re-export applyPatchByTarget for use in the hook (avoids double import in callers) - -/** - * The bits of the server GSAP-mutation response the timeline edit path needs. - * `scriptText` is the rewritten root GSAP script — feeding it to `applySoftReload` - * swaps the runtime timeline in place (no iframe reload = no all-clips flash). Null - * when the endpoint didn't return one (older server, or a multi-script comp the - * soft path can't scope), in which case the caller full-reloads as before. - */ -export interface GsapMutationOutcome { - scriptText: string | null; -} - -function readGsapMutationScriptText(body: unknown): string | null { - if (typeof body !== "object" || body === null) return null; - const text = (body as { scriptText?: unknown }).scriptText; - return typeof text === "string" ? text : null; -} +export { applyPatchByTarget, formatTimelineAttributeNumber }; diff --git a/packages/studio/src/hooks/timelineElementsMove.test.ts b/packages/studio/src/hooks/timelineElementsMove.test.ts index 2aaa3e33f9..cc86b7f7d8 100644 --- a/packages/studio/src/hooks/timelineElementsMove.test.ts +++ b/packages/studio/src/hooks/timelineElementsMove.test.ts @@ -1,8 +1,10 @@ // @vitest-environment jsdom -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi, type Mock } from "vitest"; import type { TimelineElement } from "../player"; import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers"; import { persistTimelineElementsMove, type TimelineElementMoveEdit } from "./timelineElementsMove"; +import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; +import { shiftGsapPositionsBatch } from "./timelineEditingHelpers"; function el(over: Partial & Pick): TimelineElement { return { tag: "div", start: 0, duration: 1, track: 0, ...over }; @@ -52,10 +54,24 @@ describe("furthestClipEndFromSource", () => { // duration. Without the source-based calc this shrinks/stalls the composition. const h = vi.hoisted(() => ({ source: "", savedFiles: [] as Record[] })); -// Stub the player store so persist's optimistic setDuration() doesn't mutate the -// real store and leak duration into later suite tests (test isolation). +// Stateful player-store stub: records setDuration calls and exposes a settable +// `duration`/`currentTime` so the duration-rollback path (ITEM 11) is observable +// without touching the real store. +const storeState = vi.hoisted(() => { + const s = { + duration: 0, + currentTime: 0, + setDurationCalls: [] as number[], + setDuration: (value: number) => { + s.duration = value; + s.setDurationCalls.push(value); + }, + }; + return s; +}); + vi.mock("../player", () => ({ - usePlayerStore: { getState: () => ({ setDuration: () => {} }) }, + usePlayerStore: { getState: () => storeState }, })); vi.mock("../utils/studioFileHistory", () => ({ @@ -74,7 +90,7 @@ vi.mock("./timelineEditingHelpers", async (importActual) => { ...actual, readFileContent: readFileContentMock, patchIframeDomTiming: vi.fn(), - shiftGsapPositionsBatch: vi.fn(async () => {}), + shiftGsapPositionsBatch: vi.fn(async () => ({ scriptText: null })), }; }); @@ -86,6 +102,32 @@ function move( return { element, updates: { start, track } }; } +type PersistOpts = Parameters[1]; + +// The persist options are identical across tests except for `reloadPreview` +// (some tests spy on it). Build the shared defaults, allow per-test overrides. +function persistOpts(over: Partial = {}): PersistOpts { + return { + projectId: "p", + activeCompPath: "index.html", + previewIframe: null, + writeProjectFile: async () => {}, + recordEdit: async () => {}, + reloadPreview: () => {}, + domEditSaveTimestampRef: { current: 0 }, + ...over, + }; +} + +// A move spanning the root comp + a sub-comp: distinct source per path. +const twoFileSource: Record = { + "index.html": bed(15.18, 7.18), + "scenes/intro.html": bed(15.18, 2), +}; +function mockTwoFileSource(): void { + readFileContentMock.mockImplementation(async (_pid: string, path: string) => twoFileSource[path]); +} + describe("persistTimelineElementsMove — writes source-derived duration", () => { beforeEach(() => { h.savedFiles.length = 0; @@ -98,15 +140,7 @@ describe("persistTimelineElementsMove — writes source-derived duration", () => h.source = bed(15.18, 7.18); const music = el({ id: "hf-music", hfId: "hf-music", start: 7.18, duration: 8, track: 2 }); - await persistTimelineElementsMove([move(music, 11.53)], { - projectId: "p", - activeCompPath: "index.html", - previewIframe: null, - writeProjectFile: async () => {}, - recordEdit: async () => {}, - reloadPreview: () => {}, - domEditSaveTimestampRef: { current: 0 }, - }); + await persistTimelineElementsMove([move(music, 11.53)], persistOpts()); expect(h.savedFiles).toHaveLength(1); const saved = h.savedFiles[0]["index.html"]; @@ -122,11 +156,7 @@ describe("persistTimelineElementsMove — writes source-derived duration", () => // all-or-nothing on disk that matches the caller's all-or-nothing store rollback. it("folds clips from two source files into ONE atomic save (single history entry)", async () => { // Distinct source per path; a single move edit touches each file. - const perPath: Record = { - "index.html": bed(15.18, 7.18), - "scenes/intro.html": bed(15.18, 2), - }; - readFileContentMock.mockImplementation(async (_pid: string, path: string) => perPath[path]); + mockTwoFileSource(); const rootMusic = el({ id: "hf-music", hfId: "hf-music", start: 7.18, duration: 8, track: 2 }); const subMusic = el({ @@ -138,15 +168,7 @@ describe("persistTimelineElementsMove — writes source-derived duration", () => sourceFile: "scenes/intro.html", }); - await persistTimelineElementsMove([move(rootMusic, 9), move(subMusic, 4)], { - projectId: "p", - activeCompPath: "index.html", - previewIframe: null, - writeProjectFile: async () => {}, - recordEdit: async () => {}, - reloadPreview: () => {}, - domEditSaveTimestampRef: { current: 0 }, - }); + await persistTimelineElementsMove([move(rootMusic, 9), move(subMusic, 4)], persistOpts()); // ONE save call carrying BOTH files (not two per-file saves). expect(h.savedFiles).toHaveLength(1); @@ -155,3 +177,99 @@ describe("persistTimelineElementsMove — writes source-derived duration", () => expect(h.savedFiles[0]["scenes/intro.html"]).toContain('data-start="4"'); }); }); + +// ── ITEM 11 — duration rollback on a failed write ─────────────────────────── +describe("persistTimelineElementsMove — reverts optimistic duration on write failure", () => { + const save = saveProjectFilesWithHistory as unknown as Mock; + + beforeEach(() => { + readFileContentMock.mockReset(); + readFileContentMock.mockImplementation(async () => h.source); + storeState.setDurationCalls.length = 0; + }); + + it("restores the store/root duration to its previous value when the atomic save throws", async () => { + storeState.duration = 15.18; // the length before the move + h.source = bed(15.18, 7.18); // audio (8s) currently ends at 15.18 + save.mockImplementationOnce(async () => { + throw new Error("disk full"); + }); + const music = el({ id: "hf-music", hfId: "hf-music", start: 7.18, duration: 8, track: 2 }); + + await expect(persistTimelineElementsMove([move(music, 11.53)], persistOpts())).rejects.toThrow( + "disk full", + ); + + // Optimistically grew to the moved audio's 19.53 end, then rolled back to 15.18. + expect(storeState.setDurationCalls).toEqual([19.53, 15.18]); + }); +}); + +// ── ITEM 10 — multi-file move must not clobber the shared preview iframe ───── +describe("persistTimelineElementsMove — preview sync scopes soft-reload to the active comp", () => { + const shiftBatch = shiftGsapPositionsBatch as unknown as Mock; + + beforeEach(() => { + readFileContentMock.mockReset(); + shiftBatch.mockClear(); + shiftBatch.mockResolvedValue({ scriptText: null }); + }); + + it("runs the durable GSAP shift for EVERY changed group and full-reloads once when a non-active file also changed", async () => { + mockTwoFileSource(); + const reloadPreview = vi.fn(); + const rootMusic = el({ + id: "hf-music", + hfId: "hf-music", + domId: "bg-music", + start: 7.18, + duration: 8, + track: 2, + }); + const subMusic = el({ + id: "hf-music", + hfId: "hf-music", + domId: "bg-music", + start: 2, + duration: 8, + track: 2, + sourceFile: "scenes/intro.html", + }); + + await persistTimelineElementsMove( + [move(rootMusic, 9), move(subMusic, 4)], + persistOpts({ reloadPreview }), + ); + + // shiftGsapPositionsBatch is a DURABLE server file rewrite, not a cosmetic + // preview step — it must run once PER changed group (each against its own + // targetPath), not be skipped for the non-active sub-comp. Skipping it left the + // sub-comp's persisted tween positions desynced from its clip timings forever. + expect(shiftBatch).toHaveBeenCalledTimes(2); + const shiftedPaths = shiftBatch.mock.calls.map((c) => c[1]).sort(); + expect(shiftedPaths).toEqual(["index.html", "scenes/intro.html"]); + // The shared iframe still can't soft-reload a sub-comp, so exactly ONE full + // reload reflects every changed file. + expect(reloadPreview).toHaveBeenCalledTimes(1); + }); + + it("soft-reloads (batched GSAP shift) when only the active comp changed", async () => { + h.source = bed(15.18, 7.18); + readFileContentMock.mockImplementation(async () => h.source); + const reloadPreview = vi.fn(); + const music = el({ + id: "hf-music", + hfId: "hf-music", + domId: "bg-music", + start: 7.18, + duration: 8, + track: 2, + }); + + await persistTimelineElementsMove([move(music, 11.53)], persistOpts({ reloadPreview })); + + // Only the active comp changed → soft path: exactly one batched GSAP shift, for it. + expect(shiftBatch).toHaveBeenCalledTimes(1); + expect(shiftBatch.mock.calls[0][1]).toBe("index.html"); + }); +}); diff --git a/packages/studio/src/hooks/timelineElementsMove.ts b/packages/studio/src/hooks/timelineElementsMove.ts index cfd788261e..69026d12b6 100644 --- a/packages/studio/src/hooks/timelineElementsMove.ts +++ b/packages/studio/src/hooks/timelineElementsMove.ts @@ -9,7 +9,7 @@ import { setCompositionDurationToContent } from "../utils/timelineAssetDrop"; import { applyPatchByTarget, buildPatchTarget, - formatTimelineAttributeNumber, + buildTimelineMoveTimingPatch, patchIframeDomTiming, patchIframeRootDuration, readFileContent, @@ -69,16 +69,15 @@ function applyGroupTimingPatches(source: string, groupEdits: TimelineElementMove for (const { element, updates } of groupEdits) { const target = buildPatchTarget(element); if (!target) continue; - patched = applyPatchByTarget(patched, target, { - type: "attribute", - property: "start", - value: formatTimelineAttributeNumber(updates.start), - }); - patched = applyPatchByTarget(patched, target, { - type: "attribute", - property: "track-index", - value: String(updates.track), - }); + // buildTimelineMoveTimingPatch drops any non-finite field (#2212 NaN guard), + // so a bad {start|track} never gets serialized into data-* here. + for (const patch of buildTimelineMoveTimingPatch(updates)) { + patched = applyPatchByTarget(patched, target, { + type: "attribute", + property: patch.property, + value: patch.value, + }); + } } return patched; } @@ -97,10 +96,14 @@ async function patchTimelineMoveGroups( groupResults: MoveGroupResult[]; filesToSave: Record; originals: Map; + /** True if the optimistic root-duration set below ran (must be rolled back on a + * failed write, same as the caller rolls back start/track). */ + durationChanged: boolean; }> { const groupResults: MoveGroupResult[] = []; const filesToSave: Record = {}; const originals = new Map(); + let durationChanged = false; for (const [targetPath, groupEdits] of groups) { const original = await readFileContent(deps.projectId, targetPath); let patched = applyGroupTimingPatches(original, groupEdits); @@ -118,52 +121,86 @@ async function patchTimelineMoveGroups( if (contentEnd > 0 && targetPath === (deps.activeCompPath || "index.html")) { usePlayerStore.getState().setDuration(contentEnd); patchIframeRootDuration(deps.previewIframe, contentEnd); + durationChanged = true; } groupResults.push({ targetPath, original, patched, groupEdits }); filesToSave[targetPath] = patched; originals.set(targetPath, original); } - return { groupResults, filesToSave, originals }; + return { groupResults, filesToSave, originals, durationChanged }; +} + +/** The batched GSAP position shifts for one group's clips whose start changed. */ +function groupGsapShifts(group: MoveGroupResult): Array<{ elementId: string; delta: number }> { + return group.groupEdits + .filter((e) => e.element.domId && e.updates.start - e.element.start !== 0) + .map((e) => ({ + elementId: e.element.domId as string, + delta: e.updates.start - e.element.start, + })); } /** - * Phase 3 — post-save preview sync, per group. Non-fatal cosmetic steps (a GSAP - * shift miss re-syncs on the next reload), so they run after the durable write - * and never throw back into the caller's rollback path. + * Phase 3 — post-save preview sync. + * + * `shiftGsapPositionsBatch` is a DURABLE server mutation (shift-positions-batch → + * file write) that rewrites each file's GSAP tween positions to match the moved + * clip timings. It is NOT a cosmetic preview step, so it must run for EVERY changed + * group (against that group's own targetPath). Skipping it for a non-active group + * leaves that file's persisted clip timings out of sync with its GSAP script + * PERMANENTLY — a reload re-reads the same desynced file, so it never heals. (Only + * the endpoint FAILING is non-fatal and logged: the write simply didn't happen.) + * + * The preview is a SINGLE shared iframe showing the ACTIVE composition, so only the + * active comp's group can be soft-reloaded (its rewritten script swapped in place). + * If any OTHER file changed too — e.g. a sub-comp group in a multi-file move — a + * per-group soft-reload would either apply that file's script to the wrong (root) + * document or fire a second clobbering reloadPreview; do ONE full reload instead so + * every changed file is reflected. */ async function syncTimelineMovePreviews( groupResults: MoveGroupResult[], - deps: Pick, + deps: Pick< + PersistTimelineElementsMoveDeps, + "projectId" | "activeCompPath" | "previewIframe" | "reloadPreview" + >, ): Promise { - for (const { targetPath, groupEdits } of groupResults) { - // One batched GSAP position shift for every clip whose start changed. - const shifts = groupEdits - .filter((e) => e.element.domId && e.updates.start - e.element.start !== 0) - .map((e) => ({ - elementId: e.element.domId as string, - delta: e.updates.start - e.element.start, - })); - let shiftOutcome: GsapMutationOutcome = { scriptText: null }; - if (shifts.length > 0) { - shiftOutcome = await shiftGsapPositionsBatch(deps.projectId, targetPath, shifts).catch( - (err) => { - console.error("[Timeline] Failed to batch-shift GSAP positions", err); - return { scriptText: null }; - }, - ); - } + const activePath = deps.activeCompPath || "index.html"; + const activeGroup = groupResults.find((g) => g.targetPath === activePath); + const otherFileChanged = groupResults.some((g) => g.targetPath !== activePath); - // Soft-reload with the batch's rewritten script — a multi-clip move is - // timing-only (DOM + store already patched), so swap the script in place to - // avoid the all-clips flash; full reload is the fallback. - syncTimingEditPreview( - deps.previewIframe, - shiftOutcome, - usePlayerStore.getState().currentTime, - deps.reloadPreview, + // Run the durable batch shift for every changed group, each against its own file. + let activeShiftOutcome: GsapMutationOutcome = { scriptText: null }; + for (const group of groupResults) { + const shifts = groupGsapShifts(group); + if (shifts.length === 0) continue; + const outcome = await shiftGsapPositionsBatch(deps.projectId, group.targetPath, shifts).catch( + (err) => { + console.error("[Timeline] Failed to batch-shift GSAP positions", err); + return { scriptText: null }; + }, ); + if (group === activeGroup) activeShiftOutcome = outcome; + } + + if (otherFileChanged || !activeGroup) { + // A non-active file changed (or nothing touched the active comp) — the shared + // iframe can't soft-reload those, so full-reload once to reflect them all. The + // durable shifts above have already been written to every file. + deps.reloadPreview(); + return; } + + // Only the active comp changed → soft-reload with its rewritten script — a + // multi-clip move is timing-only (DOM + store already patched), so swap the script + // in place to avoid the all-clips flash; full reload is the fallback. + syncTimingEditPreview( + deps.previewIframe, + activeShiftOutcome, + usePlayerStore.getState().currentTime, + deps.reloadPreview, + ); } /** @@ -185,12 +222,16 @@ async function syncTimelineMovePreviews( * here for single-undo atomicity on that path too. * * Throws if a source write fails (so the caller can roll back its optimistic - * store update). GSAP-shift failures are logged, not thrown (matches the - * single-clip path — a shift miss is non-fatal, the next reload re-syncs). + * store update). A GSAP shift-batch ENDPOINT failure is logged, not thrown (matches + * the single-clip path) — but note it does NOT self-heal: shiftGsapPositionsBatch is + * a durable file rewrite, so if it fails the file's tween positions stay desynced + * from the clip timings until the next successful shift; a reload just re-reads the + * un-rewritten file. The console error is the signal. */ export async function persistTimelineElementsMove( edits: TimelineElementMoveEdit[], deps: PersistTimelineElementsMoveDeps, + coalesceKey?: string, ): Promise { if (edits.length === 0) return; const { @@ -205,20 +246,22 @@ export async function persistTimelineElementsMove( } = deps; // 1. Optimistic live DOM patch — instant feedback before the write lands. + // Non-finite fields are dropped by buildTimelineMoveTimingPatch (#2212). for (const { element, updates } of edits) { - patchIframeDomTiming(previewIframe, element, [ - ["data-start", formatTimelineAttributeNumber(updates.start)], - ["data-track-index", String(updates.track)], - ]); + patchIframeDomTiming( + previewIframe, + element, + buildTimelineMoveTimingPatch(updates).map((p): [string, string] => [p.attr, p.value]), + ); } // Phase 1 — group by owning file and patch each group's source in memory. const groups = groupEditsByFile(edits, activeCompPath); - const { groupResults, filesToSave, originals } = await patchTimelineMoveGroups(groups, { - projectId, - activeCompPath, - previewIframe, - }); + const previousDuration = usePlayerStore.getState().duration; + const { groupResults, filesToSave, originals, durationChanged } = await patchTimelineMoveGroups( + groups, + { projectId, activeCompPath, previewIframe }, + ); if (groupResults.length === 0) return; @@ -231,19 +274,56 @@ export async function persistTimelineElementsMove( // store all the way back. All-or-nothing on disk now matches the caller's // all-or-nothing store rollback, and undo is a single step. domEditSaveTimestampRef.current = Date.now(); - await saveProjectFilesWithHistory({ - projectId, - label: edits.length > 1 ? "Move timeline clips" : "Move timeline clip", - kind: "timeline", - files: filesToSave, - readFile: async (path) => originals.get(path) ?? "", - writeFile: writeProjectFile, - recordEdit, - }); + try { + await saveProjectFilesWithHistory({ + projectId, + label: edits.length > 1 ? "Move timeline clips" : "Move timeline clip", + kind: "timeline", + // Shared per-gesture key (from the drag commit) so a lane change's move + // entry merges with its follow-up z-reorder entry into one undo step. + coalesceKey, + // The z entry lands only after this persist's round-trip — the default + // 300ms merge window can miss it, silently splitting the gesture into two + // undo steps. A generous window keeps the pair one step (same key required). + coalesceMs: coalesceKey ? 5000 : undefined, + files: filesToSave, + readFile: async (path) => originals.get(path) ?? "", + writeFile: writeProjectFile, + recordEdit, + }); + } catch (error) { + // Revert the optimistic live-iframe timing attrs patched in step 1. The caller + // rolls back the store's {start,track}, but the iframe DOM would otherwise keep + // the un-persisted values (data-start/data-track-index the saved source never + // got), desyncing the preview from disk until the next reload. The element's own + // pre-move start/track are the restore values (always finite). + for (const { element } of edits) { + patchIframeDomTiming( + previewIframe, + element, + buildTimelineMoveTimingPatch({ start: element.start, track: element.track }).map( + (p): [string, string] => [p.attr, p.value], + ), + ); + } + // Roll back the optimistic root-duration set alongside the caller's start/track + // rollback — otherwise a failed write leaves the store + live root advertising a + // duration the saved source never got. + if (durationChanged) { + usePlayerStore.getState().setDuration(previousDuration); + patchIframeRootDuration(previewIframe, previousDuration); + } + throw error; + } - // Phase 3 — post-save preview sync, per group. + // Phase 3 — post-save preview sync. forceReloadSdkSession?.(); - await syncTimelineMovePreviews(groupResults, { projectId, previewIframe, reloadPreview }); + await syncTimelineMovePreviews(groupResults, { + projectId, + activeCompPath, + previewIframe, + reloadPreview, + }); } export interface UseTimelineElementsMoveDeps { @@ -274,23 +354,27 @@ export function useTimelineElementsMove(deps: UseTimelineElementsMoveDeps) { showToast, } = deps; return useCallback( - (edits: TimelineElementMoveEdit[]): Promise => { + (edits: TimelineElementMoveEdit[], coalesceKey?: string): Promise => { if (isRecordingRef?.current) { showToast("Cannot edit timeline while recording", "error"); return Promise.resolve(); } const pid = projectIdRef.current; if (!pid) return Promise.resolve(); - return persistTimelineElementsMove(edits, { - projectId: pid, - activeCompPath, - previewIframe: previewIframeRef.current, - writeProjectFile, - recordEdit, - reloadPreview, - forceReloadSdkSession, - domEditSaveTimestampRef, - }); + return persistTimelineElementsMove( + edits, + { + projectId: pid, + activeCompPath, + previewIframe: previewIframeRef.current, + writeProjectFile, + recordEdit, + reloadPreview, + forceReloadSdkSession, + domEditSaveTimestampRef, + }, + coalesceKey, + ); }, [ projectIdRef, diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index 77f6f57988..165bab8e69 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -4,7 +4,7 @@ import type { TimelineElement } from "../player"; import type { DomEditSelection } from "../components/editor/domEditing"; import type { LeftSidebarHandle } from "../components/sidebar/LeftSidebar"; import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion"; -import { shouldHandleTimelineToggleHotkey, isEditableTarget } from "../utils/timelineDiscovery"; +import { isEditableTarget } from "../utils/timelineDiscovery"; import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers"; import { canSplitElement } from "../utils/timelineElementSplit"; import { STUDIO_RAZOR_TOOL_ENABLED } from "../components/editor/manualEditingAvailability"; @@ -81,6 +81,8 @@ interface HistoryResult { reason?: string; label?: string; paths?: string[]; + /** Per-file restored/previous content, used to soft-apply the preview. */ + files?: Record; } interface HistoryFileCallbacks { readFile: (path: string) => Promise; @@ -96,7 +98,6 @@ interface EditHistoryHandle { } interface UseAppHotkeysParams { - toggleTimelineVisibility: () => void; handleTimelineElementDelete: (element: TimelineElement) => Promise; handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise; handleDomEditElementDelete: (selection: DomEditSelection) => Promise; @@ -108,7 +109,10 @@ interface UseAppHotkeysParams { writeProjectFile: (path: string, content: string) => Promise; domEditSaveTimestampRef: React.MutableRefObject; showToast: (message: string, tone?: "error" | "info") => void; - syncHistoryPreviewAfterApply: (paths: string[] | undefined) => Promise; + syncHistoryPreviewAfterApply: (restore: { + paths?: string[]; + files?: Record; + }) => Promise; waitForPendingDomEditSaves: () => Promise; leftSidebarRef: React.RefObject; handleCopy: () => boolean; @@ -135,7 +139,6 @@ interface UseAppHotkeysParams { // ── Extracted keydown dispatch (pure function, no hooks) ── interface HotkeyCallbacks { - toggleTimelineVisibility: () => void; handleTimelineElementDelete: (element: TimelineElement) => Promise; handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise; handleDomEditElementDelete: (selection: DomEditSelection) => Promise; @@ -291,9 +294,14 @@ function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks return; } } - const { selectedElementId, elements } = usePlayerStore.getState(); - if (selectedElementId) { - const el = elements.find((e) => (e.key ?? e.id) === selectedElementId); + // Delete acts on the primary selection OR the marquee multi-selection — + // the delete handler expands a clip that is part of the multi-selection + // into an atomic delete of the whole selection (single undo). + const { selectedElementId, selectedElementIds, elements } = usePlayerStore.getState(); + const selectionKeys = new Set(selectedElementIds); + if (selectedElementId) selectionKeys.add(selectedElementId); + if (selectionKeys.size > 0) { + const el = elements.find((e) => selectionKeys.has(e.key ?? e.id)); if (el) { event.preventDefault(); void cb.handleTimelineElementDelete(el); @@ -317,7 +325,6 @@ function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks // ── Hook ── export function useAppHotkeys({ - toggleTimelineVisibility, handleTimelineElementDelete, handleTimelineElementSplit, handleDomEditElementDelete, @@ -346,15 +353,6 @@ export function useAppHotkeys({ const previewHotkeyWindowRef = useRef(null); const previewHistoryCleanupRef = useRef<(() => void) | null>(null); - const handleTimelineToggleHotkey = useCallback( - (event: KeyboardEvent) => { - if (!shouldHandleTimelineToggleHotkey(event)) return; - event.preventDefault(); - toggleTimelineVisibility(); - }, - [toggleTimelineVisibility], - ); - // ── Undo / Redo ── const readHistoryFile = useCallback( @@ -397,7 +395,7 @@ export function useAppHotkeys({ if (activeCompPath && result.paths?.includes(activeCompPath)) { forceReloadSdkSession?.(); } - await syncHistoryPreviewAfterApply(result.paths); + await syncHistoryPreviewAfterApply({ paths: result.paths, files: result.files }); showToast(`${direction === "undo" ? "Undid" : "Redid"} ${result.label}`, "info"); } }, @@ -421,7 +419,6 @@ export function useAppHotkeys({ const cbRef = useRef(null!); cbRef.current = { - toggleTimelineVisibility, handleTimelineElementDelete, handleTimelineElementSplit, handleDomEditElementDelete, @@ -444,11 +441,6 @@ export function useAppHotkeys({ const handleAppKeyDown = useCallback((event: KeyboardEvent) => { const cb = cbRef.current; - if (shouldHandleTimelineToggleHotkey(event)) { - event.preventDefault(); - cb.toggleTimelineVisibility(); - return; - } const key = event.key.toLowerCase(); if (event.metaKey || event.ctrlKey) { dispatchModifierKey(event, key, cb); @@ -537,6 +529,5 @@ export function useAppHotkeys({ handleRedo, syncPreviewTimelineHotkey, syncPreviewHistoryHotkey, - handleTimelineToggleHotkey, }; } diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 3ae522f837..3c379896ec 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -48,6 +48,7 @@ interface RecordEditInput { label: string; kind: EditHistoryKind; coalesceKey?: string; + coalesceMs?: number; files: Record; } @@ -271,6 +272,7 @@ export function useDomEditCommits({ label: options?.label ?? "Edit layer", kind: "manual", coalesceKey: options?.coalesceKey, + coalesceMs: options?.coalesceMs, files: { [targetPath]: { before: originalContent, after: finalContent } }, }); forceReloadSdkSession?.(); diff --git a/packages/studio/src/hooks/useDomEditPositionPatchCommit.ts b/packages/studio/src/hooks/useDomEditPositionPatchCommit.ts index 335658011e..1141a91d7c 100644 --- a/packages/studio/src/hooks/useDomEditPositionPatchCommit.ts +++ b/packages/studio/src/hooks/useDomEditPositionPatchCommit.ts @@ -15,6 +15,7 @@ interface UseDomEditPositionPatchCommitParams { interface PositionPatchOptions { label: string; coalesceKey: string; + coalesceMs?: number; skipRefresh?: boolean; } @@ -30,6 +31,7 @@ export function useDomEditPositionPatchCommit({ await persistDomEditOperations(selection, patches, { label: options.label, coalesceKey: options.coalesceKey, + coalesceMs: options.coalesceMs, skipRefresh: options.skipRefresh ?? true, }); }).catch((error) => { diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 182cc5251e..f8cba7f987 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -1,6 +1,6 @@ import { useCallback } from "react"; import { trackStudioEvent } from "../utils/studioTelemetry"; -import type { TimelineElement } from "../player"; +import type { SelectElementOptions, TimelineElement } from "../player"; import type { ImportedFontAsset } from "../components/editor/fontAssets"; import type { EditHistoryKind } from "../utils/editHistory"; import type { RightPanelTab } from "../utils/studioHelpers"; @@ -38,7 +38,7 @@ export interface UseDomEditSessionParams { compositionLoading: boolean; previewIframeRef: React.MutableRefObject; timelineElements: TimelineElement[]; - setSelectedTimelineElementId: (id: string | null) => void; + setSelectedTimelineElementId: (id: string | null, options?: SelectElementOptions) => void; setRightCollapsed: (collapsed: boolean) => void; setRightPanelTab: (tab: RightPanelTab) => void; showToast: (message: string, tone?: "error" | "info") => void; diff --git a/packages/studio/src/hooks/useDomSelection.ts b/packages/studio/src/hooks/useDomSelection.ts index 72892dd744..83eced6cbb 100644 --- a/packages/studio/src/hooks/useDomSelection.ts +++ b/packages/studio/src/hooks/useDomSelection.ts @@ -1,5 +1,6 @@ import { useState, useCallback, useRef, useEffect } from "react"; -import type { TimelineElement } from "../player"; +import type { SelectElementOptions, TimelineElement } from "../player"; +import { usePlayerStore } from "../player"; import { getAllPreviewTargetsFromPointer, getPreviewTargetFromPointer, @@ -47,7 +48,7 @@ export interface UseDomSelectionParams { captionEditMode: boolean; previewIframeRef: React.MutableRefObject; timelineElements: TimelineElement[]; - setSelectedTimelineElementId: (id: string | null) => void; + setSelectedTimelineElementId: (id: string | null, options?: SelectElementOptions) => void; setRightCollapsed: (collapsed: boolean) => void; setRightPanelTab: (tab: RightPanelTab) => void; previewIframe: HTMLIFrameElement | null; @@ -126,11 +127,16 @@ export function useDomSelection({ // ── Refs ── + const rightPanelTabRef = useRef(rightPanelTab); + rightPanelTabRef.current = rightPanelTab; const domEditSelectionRef = useRef(domEditSelection); const domEditGroupSelectionsRef = useRef(domEditGroupSelections); const domEditHoverSelectionRef = useRef(domEditHoverSelection); const activeGroupElementRef = useRef(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; @@ -205,7 +211,11 @@ export function useDomSelection({ if (nextSelection) { if (options?.revealPanel !== false) { setRightCollapsed(false); - setRightPanelTab("design"); + // Keep the Variables tab in place — selecting elements is part of the bind + // flow there; yanking to Design would lose the context. + if (rightPanelTabRef.current !== "variables") { + setRightPanelTab("design"); + } } const nextSelectedTimelineId = findMatchingTimelineElementId(nextSelection, timelineElements) ?? @@ -214,7 +224,14 @@ export function useDomSelection({ timelineElements, nextSelection.sourceFile || "index.html", ); - setSelectedTimelineElementId(nextSelectedTimelineId); + // Late marquee notify: a primary already in the live set must not collapse it. + const inSet = + nextSelectedTimelineId !== null && + usePlayerStore.getState().selectedElementIds.has(nextSelectedTimelineId); + setSelectedTimelineElementId( + nextSelectedTimelineId, + inSet ? { preserveSet: true } : undefined, + ); return; } @@ -360,12 +377,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], @@ -496,8 +516,7 @@ export function useDomSelection({ const applyMarqueeSelection = useCallback( // fallow-ignore-next-line complexity (selections: DomEditSelection[], additive: boolean) => { - // Honor the inspector-panels kill switch like applyDomSelection does, so - // marquee can't land selections while the inspector UI is suppressed. + // Honor the inspector-panels kill switch like applyDomSelection does. if (!STUDIO_INSPECTOR_PANELS_ENABLED) { domEditSelectionRef.current = null; domEditGroupSelectionsRef.current = []; @@ -518,8 +537,7 @@ export function useDomSelection({ if (!domEditSelectionInGroup(nextGroup, s)) nextGroup = [...nextGroup, s]; } } else { - // Dedupe by target: under select-as-unit several marquee'd members collapse - // to the same group, which must count as one selection, not many duplicates. + // Dedupe by target: select-as-unit collapses marquee'd members to one group. nextGroup = []; for (const s of selections) { if (!domEditSelectionInGroup(nextGroup, s)) nextGroup.push(s); diff --git a/packages/studio/src/hooks/useDomSelectionSelectionGuards.test.ts b/packages/studio/src/hooks/useDomSelectionSelectionGuards.test.ts new file mode 100644 index 0000000000..dce0655946 --- /dev/null +++ b/packages/studio/src/hooks/useDomSelectionSelectionGuards.test.ts @@ -0,0 +1,276 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { installReactActEnvironment, makeSelection } from "./domSelectionTestHarness"; +import type { DomEditSelection } from "../components/editor/domEditing"; +import type { SelectElementOptions, TimelineElement } from "../player"; +import { usePlayerStore } from "../player"; + +installReactActEnvironment(); + +// ── Module mocks ── +// Control the async selection-resolution ordering so the race guard can be +// exercised deterministically, and neutralise the DOM re-apply side effect. +vi.mock("../components/editor/manualEdits", () => ({ + reapplyPositionEditsAfterSeek: () => undefined, +})); + +const deferreds = new Map; resolve: () => void }>(); + +function deferredFor(el: HTMLElement): Promise { + const id = el.id; + let resolveFn: () => void = () => undefined; + const promise = new Promise((resolve) => { + resolveFn = () => resolve(makeSelection(id.toUpperCase(), el)); + }); + deferreds.set(id, { promise, resolve: resolveFn }); + return promise; +} + +vi.mock("../components/editor/domEditing", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + findElementForTimelineElement: (doc: Document, element: { id?: string }) => + element.id ? doc.getElementById(element.id) : null, + resolveDomEditSelection: (startEl: HTMLElement | null) => + startEl ? deferredFor(startEl) : Promise.resolve(null), + }; +}); + +// Imported after the mocks so the hook picks up the mocked modules. +const { useDomSelection } = await import("./useDomSelection"); + +interface HarnessProps { + rightPanelTab: "design" | "variables"; + setRightPanelTab: (tab: "design" | "variables") => void; + iframe: HTMLIFrameElement | null; + timelineElements: TimelineElement[]; + setSelectedTimelineElementId?: (id: string | null, options?: SelectElementOptions) => void; +} + +function renderHarness(props: HarnessProps) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + let currentHook: ReturnType | null = null; + + function Harness() { + currentHook = useDomSelection({ + projectId: "project-1", + activeCompPath: "index.html", + isMasterView: false, + compIdToSrc: new Map(), + captionEditMode: false, + previewIframeRef: { current: props.iframe }, + timelineElements: props.timelineElements, + setSelectedTimelineElementId: props.setSelectedTimelineElementId ?? vi.fn(), + setRightCollapsed: vi.fn(), + setRightPanelTab: props.setRightPanelTab, + previewIframe: props.iframe, + refreshKey: 0, + rightPanelTab: props.rightPanelTab, + }); + return null; + } + + act(() => root.render(React.createElement(Harness))); + + return { + current: () => { + if (!currentHook) throw new Error("Expected hook result"); + return currentHook; + }, + cleanup: () => { + act(() => root.unmount()); + host.remove(); + }, + }; +} + +describe("useDomSelection — Variables tab preservation", () => { + it("does not yank the user off the Variables tab when selecting on canvas", () => { + const setRightPanelTab = vi.fn(); + const el = document.createElement("div"); + el.id = "headline"; + const harness = renderHarness({ + rightPanelTab: "variables", + setRightPanelTab, + iframe: null, + timelineElements: [], + }); + + act(() => harness.current().applyDomSelection(makeSelection("Headline", el))); + + expect(setRightPanelTab).not.toHaveBeenCalled(); + harness.cleanup(); + }); + + it("switches to the Design tab when not on Variables (control)", () => { + const setRightPanelTab = vi.fn(); + const el = document.createElement("div"); + el.id = "headline"; + const harness = renderHarness({ + rightPanelTab: "design", + setRightPanelTab, + iframe: null, + timelineElements: [], + }); + + act(() => harness.current().applyDomSelection(makeSelection("Headline", el))); + + expect(setRightPanelTab).toHaveBeenCalledWith("design"); + harness.cleanup(); + }); +}); + +describe("useDomSelection — timeline-select race guard", () => { + beforeEach(() => deferreds.clear()); + afterEach(() => deferreds.clear()); + + it("a stale async resolution never clobbers a newer selection", async () => { + const iframe = document.createElement("iframe"); + document.body.append(iframe); + const doc = iframe.contentDocument!; + const elA = doc.createElement("div"); + elA.id = "a"; + const elB = doc.createElement("div"); + elB.id = "b"; + doc.body.append(elA, elB); + + const elementA: TimelineElement = { id: "a", tag: "div", start: 0, duration: 1, track: 0 }; + const elementB: TimelineElement = { id: "b", tag: "div", start: 0, duration: 1, track: 0 }; + + const harness = renderHarness({ + rightPanelTab: "design", + setRightPanelTab: vi.fn(), + iframe, + timelineElements: [elementA, elementB], + }); + + // Fire A then B; both suspend on their (pending) resolveDomEditSelection. + let pA: Promise = Promise.resolve(); + let pB: Promise = Promise.resolve(); + act(() => { + pA = harness.current().handleTimelineElementSelect(elementA); + pB = harness.current().handleTimelineElementSelect(elementB); + }); + + // Resolve the NEWER select (B) first, then the older one (A) last. + await act(async () => { + deferreds.get("b")?.resolve(); + await pB; + }); + await act(async () => { + deferreds.get("a")?.resolve(); + await pA; + }); + + // The stale A resolution must be dropped: B wins. + expect(harness.current().domEditSelection?.id).toBe("b"); + harness.cleanup(); + iframe.remove(); + }); +}); + +describe("useDomSelection — marquee multi-select survives the late async primary", () => { + beforeEach(() => { + deferreds.clear(); + const store = usePlayerStore.getState(); + store.setSelectedElementId(null); + store.clearSelectedElementIds(); + }); + afterEach(() => { + deferreds.clear(); + const store = usePlayerStore.getState(); + store.setSelectedElementId(null); + store.clearSelectedElementIds(); + }); + + function timelineEl(id: string): TimelineElement { + return { id, domId: id, tag: "div", start: 0, duration: 1, track: 0 }; + } + + // Reproduces the reported regression: a marquee over N clips leaves N members in + // selectedElementIds, then the inspector-open notify (finishMarquee → + // handleTimelineElementSelect → applyDomSelection) resolves LATE and writes the + // primary again. Before the fix that late write collapsed the set to one clip. + it("keeps all N members when a late primary-set targets a set member", async () => { + const iframe = document.createElement("iframe"); + document.body.append(iframe); + const doc = iframe.contentDocument!; + for (const id of ["a", "b", "c"]) { + const el = doc.createElement("div"); + el.id = id; + doc.body.append(el); + } + + const store = usePlayerStore.getState(); + // Marquee end state: primary written first, then the full set (real ordering). + store.setSelectedElementId("a"); + store.setSelectedElementIds(new Set(["a", "b", "c"])); + + const harness = renderHarness({ + rightPanelTab: "design", + setRightPanelTab: vi.fn(), + iframe, + timelineElements: [timelineEl("a"), timelineEl("b"), timelineEl("c")], + // Wire the real store write so applyDomSelection's collapse decision is exercised. + setSelectedTimelineElementId: usePlayerStore.getState().setSelectedElementId, + }); + + let pending: Promise = Promise.resolve(); + act(() => { + pending = harness.current().handleTimelineElementSelect(timelineEl("a")); + }); + await act(async () => { + deferreds.get("a")?.resolve(); + await pending; + }); + + expect(usePlayerStore.getState().selectedElementIds.size).toBe(3); + expect(usePlayerStore.getState().selectedElementId).toBe("a"); + harness.cleanup(); + iframe.remove(); + }); + + it("collapses the set when a late primary-set targets a non-member (fresh click)", async () => { + const iframe = document.createElement("iframe"); + document.body.append(iframe); + const doc = iframe.contentDocument!; + for (const id of ["a", "b", "d"]) { + const el = doc.createElement("div"); + el.id = id; + doc.body.append(el); + } + + const store = usePlayerStore.getState(); + // Stale set left over from a previous gesture. + store.setSelectedElementId("a"); + store.setSelectedElementIds(new Set(["a", "b"])); + + const harness = renderHarness({ + rightPanelTab: "design", + setRightPanelTab: vi.fn(), + iframe, + timelineElements: [timelineEl("a"), timelineEl("b"), timelineEl("d")], + setSelectedTimelineElementId: usePlayerStore.getState().setSelectedElementId, + }); + + let pending: Promise = Promise.resolve(); + act(() => { + pending = harness.current().handleTimelineElementSelect(timelineEl("d")); + }); + await act(async () => { + deferreds.get("d")?.resolve(); + await pending; + }); + + expect(usePlayerStore.getState().selectedElementIds.size).toBe(0); + expect(usePlayerStore.getState().selectedElementId).toBe("d"); + harness.cleanup(); + iframe.remove(); + }); +}); diff --git a/packages/studio/src/hooks/useElementLifecycleOps.test.tsx b/packages/studio/src/hooks/useElementLifecycleOps.test.tsx new file mode 100644 index 0000000000..af7336c79b --- /dev/null +++ b/packages/studio/src/hooks/useElementLifecycleOps.test.tsx @@ -0,0 +1,119 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { DomEditSelection } from "../components/editor/domEditingTypes"; +import type { PatchOperation } from "../utils/sourcePatcher"; +import { useElementLifecycleOps } from "./useElementLifecycleOps"; +import { mountReactHarness } from "./domSelectionTestHarness"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +type CommitPositionPatch = ( + selection: DomEditSelection, + patches: PatchOperation[], + options: { label: string; coalesceKey: string; skipRefresh?: boolean }, +) => Promise; + +type ReorderCommit = ( + entries: Array<{ + element: HTMLElement; + zIndex: number; + id?: string; + selector?: string; + selectorIndex?: number; + sourceFile: string; + }>, + coalesceKeyOverride?: string, +) => void; + +/** Render the hook, capturing every selection handed to commitPositionPatchToHtml. */ +function renderReorderHook( + capturedSelections: DomEditSelection[], + onReady: (commit: ReorderCommit) => void, +) { + function Harness() { + const { handleDomZIndexReorderCommit } = useElementLifecycleOps({ + activeCompPath: "index.html", + showToast: vi.fn(), + writeProjectFile: vi.fn(async () => {}), + domEditSaveTimestampRef: { current: 0 }, + editHistory: { recordEdit: vi.fn(async () => {}) }, + projectIdRef: { current: "demo" }, + reloadPreview: vi.fn(), + clearDomSelection: vi.fn(), + commitPositionPatchToHtml: (async (selection: DomEditSelection) => { + capturedSelections.push(selection); + }) as CommitPositionPatch, + }); + onReady(handleDomZIndexReorderCommit); + return null; + } + return mountReactHarness(); +} + +/** Append the element, mount the reorder hook, and run one commit through act. */ +async function runReorderCommit(el: HTMLElement, entries: Parameters[0]) { + document.body.appendChild(el); + + const captured: DomEditSelection[] = []; + let commit: ReorderCommit | undefined; + const root = renderReorderHook(captured, (fn) => (commit = fn)); + + await act(async () => { + commit!(entries); + }); + + return { captured, root }; +} + +describe("useElementLifecycleOps — z-index reorder payload", () => { + // Regression: an id-less canvas element (e.g. a caption `.sub` div, which + // carries only data-hf-id + class) once had its absent id coerced to `null` + // (`entry.id ?? null`). The DOM-patch guard rejects a null `body.target.id`, + // so "move to back" toasted "unsafe values" and nothing persisted. The target + // id must be `undefined` (dropped on the wire), letting hfId / selector match. + it("never sends a null target id for an id-less element", async () => { + const el = document.createElement("div"); + el.className = "sub clip"; + el.setAttribute("data-hf-id", "hf-card"); + + const { captured, root } = await runReorderCommit(el, [ + { + element: el, + zIndex: 0, + // id intentionally absent — the id-less element case. + selector: ".sub.clip", + selectorIndex: 3, + sourceFile: "index.html", + }, + ]); + + expect(captured).toHaveLength(1); + expect(captured[0]!.id).toBeUndefined(); + expect(captured[0]!.id).not.toBeNull(); + // The element stays addressable via hfId (and selector) instead. + expect(captured[0]!.hfId).toBe("hf-card"); + + act(() => root.unmount()); + }); + + it("preserves a real id when the element has one", async () => { + const el = document.createElement("video"); + el.id = "v-hero"; + el.setAttribute("data-hf-id", "hf-ezl2"); + + const { captured, root } = await runReorderCommit(el, [ + { element: el, zIndex: 2, id: "v-hero", selector: "#v-hero", sourceFile: "index.html" }, + ]); + + expect(captured).toHaveLength(1); + expect(captured[0]!.id).toBe("v-hero"); + + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/hooks/useElementLifecycleOps.ts b/packages/studio/src/hooks/useElementLifecycleOps.ts index e59e1b042f..8109e03861 100644 --- a/packages/studio/src/hooks/useElementLifecycleOps.ts +++ b/packages/studio/src/hooks/useElementLifecycleOps.ts @@ -17,6 +17,29 @@ interface RecordEditInput { files: Record; } +/** + * Resolve the store timeline-element key for a z-reorder entry. Callers pass the + * live DOM node plus best-effort id/selector, not the store key, so match against + * the store's elements (hf-id first, then dom id, then selector+index). Returns + * null when the element isn't a tracked clip (e.g. a runtime-generated sibling). + */ +function resolveReorderStoreKey(entry: { + element: HTMLElement; + id?: string; + selector?: string; + selectorIndex?: number; +}): string | null { + const els = usePlayerStore.getState().elements; + const hfId = readHfId(entry.element); + const match = + (hfId ? els.find((el) => el.hfId === hfId) : undefined) ?? + (entry.id ? els.find((el) => el.domId === entry.id || el.id === entry.id) : undefined) ?? + (entry.selector + ? els.find((el) => el.selector === entry.selector && el.selectorIndex === entry.selectorIndex) + : undefined); + return match ? (match.key ?? match.id) : null; +} + interface UseElementLifecycleOpsParams { activeCompPath: string | null; showToast: (message: string, tone?: "error" | "info") => void; @@ -35,7 +58,7 @@ interface UseElementLifecycleOpsParams { commitPositionPatchToHtml: ( selection: DomEditSelection, patches: PatchOperation[], - options: { label: string; coalesceKey: string; skipRefresh?: boolean }, + options: { label: string; coalesceKey: string; coalesceMs?: number; skipRefresh?: boolean }, ) => Promise; /** Stage 7 Step 3b: called after a successful server-side element delete (shadow). */ onElementDeleted?: (selection: DomEditSelection) => void; @@ -159,98 +182,89 @@ export function useElementLifecycleOps({ // ponytail: z-index reorder writes inline-style patches via commitPositionPatchToHtml → // persistDomEditOperations → onTrySdkPersist, so it is already SDK-cut-over as setStyle. // No SDK reorder/reparent op exists; DOM sibling order stays server-authoritative if ever needed. - const handleDomZIndexReorderCommit = useCallback( - // fallow-ignore-next-line complexity + // Commit one z-reorder entry: mutate the live element's z (and `position`, so a + // static element can stack), sync the store z, then persist the inline-style + // patch. skipRefresh — the element is already restacked optimistically, so a + // reload would only blank the iframe (matches the property-panel convention). + const commitZReorderEntry = useCallback( ( - entries: Array<{ + entry: { element: HTMLElement; zIndex: number; id?: string; selector?: string; selectorIndex?: number; sourceFile: string; - key?: string; - }>, + }, + coalesceKey: string, + coalesceMs?: number, + ) => { + entry.element.style.zIndex = String(entry.zIndex); + const patches: Array<{ type: "inline-style"; property: string; value: string }> = [ + { type: "inline-style", property: "z-index", value: String(entry.zIndex) }, + ]; + try { + const win = entry.element.ownerDocument?.defaultView; + if (win && win.getComputedStyle(entry.element).position === "static") { + entry.element.style.position = "relative"; + patches.push({ type: "inline-style", property: "position", value: "relative" }); + } + } catch { + /* cross-origin or detached — skip */ + } + // Sync the store's z with the just-committed value. Display lanes are + // track-derived and unaffected, but the canvas paint order and the explicit + // vertical stacking sync read store z; skipRefresh skips the rediscover + // reload, so without this write the two sources disagree. + const storeKey = resolveReorderStoreKey(entry); + if (storeKey) { + usePlayerStore.getState().updateElement(storeKey, { zIndex: entry.zIndex }); + } + void commitPositionPatchToHtml( + { + element: entry.element, + // Absent id must stay `undefined`, never `null`: the DOM-patch value + // guard (findUnsafeDomPatchValues) rejects a null anywhere except an + // operation value, so coercing an id-less element (e.g. a caption + // `.sub` div, or a duplicate-id element that resolved by selector) to + // `null` here bricked the whole z-reorder. undefined is dropped on the + // wire and the server matches via hfId / selector + selectorIndex. + id: entry.id ?? undefined, + hfId: readHfId(entry.element), + selector: entry.selector, + selectorIndex: entry.selectorIndex, + sourceFile: entry.sourceFile, + } as unknown as DomEditSelection, + patches, + { label: "Reorder layers", coalesceKey, coalesceMs, skipRefresh: true }, + ).catch(() => undefined); + }, + [commitPositionPatchToHtml], + ); + + const handleDomZIndexReorderCommit = useCallback( + ( + entries: Array[0]>, + // Optional override: the timeline lane-change drag passes its shared + // per-gesture key so this z-reorder entry merges with the move entry into + // one undo step. Absent (canvas menu / LayersPanel) ⇒ derive the usual key. + coalesceKeyOverride?: string, ) => { - if (entries.length === 0) return Promise.resolve(); + if (entries.length === 0) return; // Resolver shadow (telemetry-only, decoupled from cutover): record whether // the SDK resolves each reordered element — the reorderElements op's targets. onReorderShadow?.( entries.map((e) => readHfId(e.element)).filter((id): id is string => id != null), ); - const coalesceKey = `z-reorder:${entries.map((e) => e.id ?? e.selector ?? e.element.getAttribute("data-hf-id") ?? "el").join(":")}`; - const saves: Array> = []; - const rollbacks: Array<() => void> = []; - for (let i = 0; i < entries.length; i++) { - const entry = entries[i]; - const priorZIndex = entry.element.style.zIndex; - const priorPosition = entry.element.style.position; - const priorStoreEntry = entry.key - ? usePlayerStore.getState().elements.find((el) => (el.key ?? el.id) === entry.key) - : undefined; - let positionChanged = false; - entry.element.style.zIndex = String(entry.zIndex); - const patches: Array<{ type: "inline-style"; property: string; value: string }> = [ - { type: "inline-style", property: "z-index", value: String(entry.zIndex) }, - ]; - try { - const win = entry.element.ownerDocument?.defaultView; - if (win && win.getComputedStyle(entry.element).position === "static") { - entry.element.style.position = "relative"; - positionChanged = true; - patches.push({ type: "inline-style", property: "position", value: "relative" }); - } - } catch { - /* cross-origin or detached — skip */ - } - if (entry.key) { - usePlayerStore - .getState() - .updateElement(entry.key, { zIndex: entry.zIndex, hasExplicitZIndex: true }); - } - rollbacks.push(() => { - entry.element.style.zIndex = priorZIndex; - if (positionChanged) entry.element.style.position = priorPosition; - if (entry.key && priorStoreEntry) { - usePlayerStore.getState().updateElement(entry.key, { - zIndex: priorStoreEntry.zIndex, - hasExplicitZIndex: priorStoreEntry.hasExplicitZIndex, - }); - } - }); - saves.push( - commitPositionPatchToHtml( - { - element: entry.element, - id: entry.id ?? null, - hfId: readHfId(entry.element), - selector: entry.selector, - selectorIndex: entry.selectorIndex, - sourceFile: entry.sourceFile, - } as unknown as DomEditSelection, - patches, - { - label: "Reorder layers", - coalesceKey, - skipRefresh: i < entries.length - 1, - }, - ), - ); + const coalesceKey = + coalesceKeyOverride ?? + `z-reorder:${entries.map((e) => e.id ?? e.selector ?? e.element.getAttribute("data-hf-id") ?? "el").join(":")}`; + for (const entry of entries) { + // Match the move side's widened window when merging a lane-change gesture. + commitZReorderEntry(entry, coalesceKey, coalesceKeyOverride ? 5000 : undefined); } - // Resolves once every z-index patch is persisted so a same-file timing write - // can be ordered after it (see applyTimelineStackingReorder callers). - return Promise.allSettled(saves).then((settled) => { - const rejected = settled.find( - (result): result is PromiseRejectedResult => result.status === "rejected", - ); - if (rejected) { - for (const rollback of rollbacks) rollback(); - return Promise.reject(rejected.reason); - } - return undefined; - }); }, - [commitPositionPatchToHtml, onReorderShadow], + [commitZReorderEntry, onReorderShadow], ); return { diff --git a/packages/studio/src/hooks/usePersistentEditHistory.test.ts b/packages/studio/src/hooks/usePersistentEditHistory.test.ts index 3128ab0bb1..8b14c4605f 100644 --- a/packages/studio/src/hooks/usePersistentEditHistory.test.ts +++ b/packages/studio/src/hooks/usePersistentEditHistory.test.ts @@ -213,6 +213,39 @@ describe("createPersistentEditHistoryController", () => { expect(store.snapshot().canRedo).toBe(true); }); + it("returns per-file restored/previous content so the preview can soft-apply", async () => { + const storage = createMemoryEditHistoryStorage(); + const store = createPersistentEditHistoryStore({ + projectId: "project-1", + storage, + initialState: createEmptyEditHistory(), + now: () => 100, + onChange: () => {}, + }); + await store.recordEdit({ + label: "Move layer", + kind: "manual", + files: { "index.html": { before: "OLD", after: "NEW" } }, + }); + const disk: Record = { "index.html": "NEW" }; + const undo = await store.undo({ + readFile: async (p) => disk[p], + writeFile: async (p, c) => { + disk[p] = c; + }, + }); + // `restored` = bytes written (the undo target), `previous` = the current live bytes. + expect(undo.files).toEqual({ "index.html": { previous: "NEW", restored: "OLD" } }); + + const redo = await store.redo({ + readFile: async (p) => disk[p], + writeFile: async (p, c) => { + disk[p] = c; + }, + }); + expect(redo.files).toEqual({ "index.html": { previous: "OLD", restored: "NEW" } }); + }); + it("rolls back files when an undo write fails partway through", async () => { const storage = createMemoryEditHistoryStorage(); const store = createPersistentEditHistoryStore({ diff --git a/packages/studio/src/hooks/usePersistentEditHistory.ts b/packages/studio/src/hooks/usePersistentEditHistory.ts index de1af2867c..61779eddec 100644 --- a/packages/studio/src/hooks/usePersistentEditHistory.ts +++ b/packages/studio/src/hooks/usePersistentEditHistory.ts @@ -7,8 +7,10 @@ import { redoEditHistory, undoEditHistory, type BuildEditHistoryEntryInput, + type EditHistoryEntry, type EditHistoryKind, type EditHistoryState, + type EditHistoryTransitionResult, } from "../utils/editHistory"; import { createIndexedDbEditHistoryStorage, @@ -21,6 +23,7 @@ interface RecordEditInput { label: string; kind: EditHistoryKind; coalesceKey?: string; + coalesceMs?: number; files: BuildEditHistoryEntryInput["files"]; } @@ -35,11 +38,24 @@ interface UsePersistentEditHistoryOptions { now?: () => number; } +/** + * Per-file content the restore just applied. `restored` is the bytes written to + * disk (the undo/redo target); `previous` is what was on disk immediately before + * (the current live preview state). The undo preview-sync diffs these to decide + * whether the restore is soft-reloadable (attributes/style/GSAP-script only) or + * needs a full iframe reload. + */ +interface ApplyRestoredFile { + previous: string; + restored: string; +} + interface ApplyResult { ok: boolean; reason?: "empty" | "content-mismatch"; label?: string; paths?: string[]; + files?: Record; } interface PersistentEditHistoryStoreOptions { @@ -55,6 +71,18 @@ type EditHistoryMutation = (state: EditHistoryState) => Promise<{ result: T; }>; +/** Pair the just-written (`restored`) bytes with the pre-write (`previous`) bytes per path. */ +function restoredFilesMap( + filesToWrite: Record, + currentFiles: Record, +): Record { + const out: Record = {}; + for (const [path, restored] of Object.entries(filesToWrite)) { + out[path] = { previous: currentFiles[path] ?? "", restored }; + } + return out; +} + function createEntryId(now: number): string { return `edit-${now.toString(36)}-${Math.random().toString(36).slice(2, 10)}`; } @@ -120,6 +148,52 @@ async function writeFilesWithRollback({ } } +/** + * Apply one undo/redo step: read current on-disk hashes, run the direction's + * transition, write the restored files with rollback, and shape the ApplyResult. + * `entry` is the stack top used to know which paths to hash before applying. + */ +async function applyHistoryStep( + currentState: EditHistoryState, + entry: EditHistoryEntry | undefined, + transition: ( + state: EditHistoryState, + currentHashes: Record, + now: number, + ) => EditHistoryTransitionResult, + now: () => number, + callbacks: ApplyCallbacks, +): Promise<{ state: EditHistoryState; result: ApplyResult }> { + if (!entry) { + return { state: currentState, result: { ok: false, reason: "empty" } }; + } + const { currentFiles, currentHashes } = await readCurrentFileHashes( + Object.keys(entry.files), + callbacks.readFile, + ); + const result = transition(currentState, currentHashes, now()); + if (!result.ok) { + return { + state: currentState, + result: { ok: false, reason: result.reason }, + }; + } + await writeFilesWithRollback({ + files: result.filesToWrite, + rollbackFiles: currentFiles, + writeFile: callbacks.writeFile, + }); + return { + state: result.state, + result: { + ok: true, + label: result.entry.label, + paths: Object.keys(result.entry.files), + files: restoredFilesMap(result.filesToWrite, currentFiles), + }, + }; +} + export function createPersistentEditHistoryStore({ projectId, storage, @@ -171,66 +245,26 @@ export function createPersistentEditHistoryStore({ }); }, async undo(callbacks: ApplyCallbacks): Promise { - return mutate(async (currentState) => { - const entry = currentState.undo[currentState.undo.length - 1]; - if (!entry) { - return { - state: currentState, - result: { ok: false, reason: "empty" }, - }; - } - const { currentFiles, currentHashes } = await readCurrentFileHashes( - Object.keys(entry.files), - callbacks.readFile, - ); - const result = undoEditHistory(currentState, currentHashes, now()); - if (!result.ok) { - return { - state: currentState, - result: { ok: false, reason: result.reason }, - }; - } - await writeFilesWithRollback({ - files: result.filesToWrite, - rollbackFiles: currentFiles, - writeFile: callbacks.writeFile, - }); - return { - state: result.state, - result: { ok: true, label: result.entry.label, paths: Object.keys(result.entry.files) }, - }; - }); + return mutate((currentState) => + applyHistoryStep( + currentState, + currentState.undo[currentState.undo.length - 1], + undoEditHistory, + now, + callbacks, + ), + ); }, async redo(callbacks: ApplyCallbacks): Promise { - return mutate(async (currentState) => { - const entry = currentState.redo[currentState.redo.length - 1]; - if (!entry) { - return { - state: currentState, - result: { ok: false, reason: "empty" }, - }; - } - const { currentFiles, currentHashes } = await readCurrentFileHashes( - Object.keys(entry.files), - callbacks.readFile, - ); - const result = redoEditHistory(currentState, currentHashes, now()); - if (!result.ok) { - return { - state: currentState, - result: { ok: false, reason: result.reason }, - }; - } - await writeFilesWithRollback({ - files: result.filesToWrite, - rollbackFiles: currentFiles, - writeFile: callbacks.writeFile, - }); - return { - state: result.state, - result: { ok: true, label: result.entry.label, paths: Object.keys(result.entry.files) }, - }; - }); + return mutate((currentState) => + applyHistoryStep( + currentState, + currentState.redo[currentState.redo.length - 1], + redoEditHistory, + now, + callbacks, + ), + ); }, }; } diff --git a/packages/studio/src/hooks/usePreviewPersistence.ts b/packages/studio/src/hooks/usePreviewPersistence.ts index 657fb5dad9..b6759485b9 100644 --- a/packages/studio/src/hooks/usePreviewPersistence.ts +++ b/packages/studio/src/hooks/usePreviewPersistence.ts @@ -10,6 +10,14 @@ import type { EditHistoryKind } from "../utils/editHistory"; import { createDomEditSaveQueue } from "../utils/domEditSaveQueue"; import { flushStudioPendingEdits } from "../utils/studioPendingEdits"; import { trackStudioEvent } from "../utils/studioTelemetry"; +import { applyUndoRestoreToPreview, type UndoRestoreFile } from "../utils/gsapSoftReload"; +import { usePlayerStore } from "../player"; + +/** The restore payload the undo/redo preview-sync consumes (from the history store). */ +interface HistoryPreviewRestore { + paths?: string[]; + files?: Record; +} // ── Types ── @@ -105,13 +113,12 @@ export function usePreviewPersistence({ writeProjectFile: _writeProjectFile, recordEdit: _recordEdit, previewIframeRef, - activeCompPathRef: _activeCompPathRef, + activeCompPathRef, domEditSaveTimestampRef, reloadPreview, pendingTimelineEditPathRef, }: UsePreviewPersistenceParams) { void _recordEdit; - void _activeCompPathRef; const [domEditSaveQueuePaused, setDomEditSaveQueuePaused] = useState(null); @@ -190,12 +197,23 @@ export function usePreviewPersistence({ // ── Sync preview after undo/redo ── const syncHistoryPreviewAfterApply = useCallback( - async (_paths: string[] | undefined) => { - // Motion data is now stored in HTML attributes — any undo/redo that touches HTML - // files triggers a full reload which picks up the changes automatically. - reloadPreview(); + async (restore: HistoryPreviewRestore) => { + // Prefer an in-place soft reload for a soft-reloadable restore (the change + // is confined to the active comp's element attributes / inline-style and/or + // its GSAP script) — a full iframe remount blanks the frame black and + // re-flashes the WebGL context. applyUndoRestoreToPreview syncs the reverted + // attributes onto the live DOM and re-runs the timeline at the SAME playhead, + // falling back to reloadPreview for anything structural (split/delete undo), + // multi-file, sub-comp, or a permanent soft-reload failure. + applyUndoRestoreToPreview( + previewIframeRef.current, + activeCompPathRef.current, + restore.files, + usePlayerStore.getState().currentTime, + reloadPreview, + ); }, - [reloadPreview], + [previewIframeRef, activeCompPathRef, reloadPreview], ); // ── Migrate legacy studio-motion.json ── diff --git a/packages/studio/src/hooks/useRazorSplit.test.ts b/packages/studio/src/hooks/useRazorSplit.test.ts new file mode 100644 index 0000000000..33b3934485 --- /dev/null +++ b/packages/studio/src/hooks/useRazorSplit.test.ts @@ -0,0 +1,330 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { TimelineElement } from "../player"; +import { usePlayerStore } from "../player"; +import { useRazorSplit } from "./useRazorSplit"; +import { createPersistentEditHistoryStore } from "./usePersistentEditHistory"; +import { createEmptyEditHistory } from "../utils/editHistory"; +import type { EditHistoryStorageAdapter } from "../utils/editHistoryStorage"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const ROOT_FILE = "index.html"; +const SUBCOMP_FILE = "scenes/intro.html"; + +// A root-level clip lives in index.html and is authored in local time already. +const rootElement: TimelineElement = { + id: "root-clip", + tag: "div", + start: 0, + duration: 10, + track: 0, + domId: "root-clip", + sourceFile: ROOT_FILE, + timingSource: "authored", +}; + +// An expanded sub-comp child: `start` is in MASTER coordinates (offset by the +// host's master start), `sourceFile` is the sub-comp, and `expandedParentStart` +// is that host master start. Its authored time in the file is start - basis. +const expandedChild: TimelineElement = { + id: "child-clip", + tag: "div", + start: 2, + duration: 6, + track: 1, + domId: "child-clip", + sourceFile: SUBCOMP_FILE, + timingSource: "authored", + expandedParentStart: 2, +}; + +interface SplitRequest { + path: string; + splitTime: number; + elementStart: number; + elementDuration: number; +} + +type SingleSplit = (element: TimelineElement, splitTime: number) => Promise; +type SplitAll = (splitTime: number) => Promise; + +interface Harness { + splitRequests: SplitRequest[]; + singleRef: { current: SingleSplit | undefined }; + allRef: { current: SplitAll | undefined }; + root: ReturnType; +} + +function decodePathFromUrl(url: string, marker: string): string { + const encoded = url.slice(url.indexOf(marker) + marker.length); + return decodeURIComponent(encoded); +} + +interface SplitBody { + splitTime: number; + elementStart: number; + elementDuration: number; +} + +/** + * Fetch mock shared by both harnesses: GSAP mutations 400 (no script in fixtures), + * split-element writes a `` marker so `changed` is true, and file reads + * echo the in-memory `disk`. `onSplit` (when set) records each split request's body. + */ +function createSplitFetchMock( + disk: Record, + onSplit?: (path: string, body: SplitBody) => void, +) { + return vi.fn(async (url: string, init?: RequestInit) => { + const u = String(url); + if (u.includes("/gsap-mutations/")) { + // No GSAP script in the fixtures — mirror the server's 400 response. + return new Response(JSON.stringify({ error: "no GSAP script found in file" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + if (u.includes("/file-mutations/split-element/")) { + const path = decodePathFromUrl(u, "/file-mutations/split-element/"); + if (onSplit) onSplit(path, JSON.parse(String(init?.body)) as SplitBody); + // Return content that differs from the original so `changed` is true. + const after = `${disk[path]}`; + disk[path] = after; // server writes the split to disk + return new Response(JSON.stringify({ ok: true, changed: true, content: after }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (u.includes("/files/")) { + const path = decodePathFromUrl(u, "/files/").replace(/\?.*$/, ""); + return new Response(JSON.stringify({ content: disk[path] ?? "" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + void init; + throw new Error(`unexpected fetch: ${u}`); + }); +} + +/** Mount a render-only probe component into a fresh host and return its root. */ +function mountComponent(Component: React.ComponentType): ReturnType { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => root.render(React.createElement(Component))); + return root; +} + +function mountRazorSplit(): Harness { + const disk: Record = { + [ROOT_FILE]: `
`, + [SUBCOMP_FILE]: `
`, + }; + const splitRequests: SplitRequest[] = []; + + const fetchMock = createSplitFetchMock(disk, (path, body) => { + splitRequests.push({ + path, + splitTime: body.splitTime, + elementStart: body.elementStart, + elementDuration: body.elementDuration, + }); + }); + vi.stubGlobal("fetch", fetchMock); + + const singleRef: { current: SingleSplit | undefined } = { current: undefined }; + const allRef: { current: SplitAll | undefined } = { current: undefined }; + + function Component() { + const { handleRazorSplit, handleRazorSplitAll } = useRazorSplit({ + projectId: "p1", + activeCompPath: ROOT_FILE, + showToast: () => {}, + writeProjectFile: async (path, content) => { + disk[path] = content; + }, + recordEdit: async () => {}, + domEditSaveTimestampRef: { current: 0 }, + reloadPreview: () => {}, + }); + singleRef.current = handleRazorSplit; + allRef.current = handleRazorSplitAll; + return null; + } + + const root = mountComponent(Component); + return { splitRequests, singleRef, allRef, root }; +} + +afterEach(() => { + document.body.innerHTML = ""; + usePlayerStore.setState({ elements: [] }); + vi.unstubAllGlobals(); +}); + +describe("useRazorSplit — sub-comp coordinate rebasing", () => { + let harness: Harness; + beforeEach(() => { + harness = mountRazorSplit(); + }); + afterEach(() => { + act(() => harness.root.unmount()); + }); + + it("rebases an expanded sub-comp child split into the sub-comp's local time", async () => { + // Master split time T = 5; host starts at 2, so local time is 3. + await act(async () => { + await harness.singleRef.current!(expandedChild, 5); + }); + + expect(harness.splitRequests).toHaveLength(1); + const req = harness.splitRequests[0]; + expect(req.path).toBe(SUBCOMP_FILE); + expect(req.splitTime).toBe(3); // 5 - expandedParentStart(2), NOT 5 + expect(req.elementStart).toBe(0); // 2 - 2, NOT the master start 2 + expect(req.elementDuration).toBe(6); + }); + + it("leaves a root-level clip's coordinates unchanged", async () => { + // Master split time T = 4; no expandedParentStart, so nothing is rebased. + await act(async () => { + await harness.singleRef.current!(rootElement, 4); + }); + + expect(harness.splitRequests).toHaveLength(1); + const req = harness.splitRequests[0]; + expect(req.path).toBe(ROOT_FILE); + expect(req.splitTime).toBe(4); + expect(req.elementStart).toBe(0); + expect(req.elementDuration).toBe(10); + }); + + it("rebases each element individually in a mixed razor-split-all gesture", async () => { + usePlayerStore.setState({ elements: [rootElement, expandedChild] }); + + // Master split time T = 3 lies inside both clips. + await act(async () => { + await harness.allRef.current!(3); + }); + + expect(harness.splitRequests).toHaveLength(2); + const rootReq = harness.splitRequests.find((r) => r.path === ROOT_FILE)!; + const childReq = harness.splitRequests.find((r) => r.path === SUBCOMP_FILE)!; + + // Root clip: already local — master coordinates pass through untouched. + expect(rootReq.splitTime).toBe(3); + expect(rootReq.elementStart).toBe(0); + + // Expanded child: rebased by its OWN expandedParentStart, not the root's. + expect(childReq.splitTime).toBe(1); // 3 - 2 + expect(childReq.elementStart).toBe(0); // 2 - 2 + }); +}); + +// ── Bug 1: split must resync the SDK session so undo isn't refused ──────────── + +const memoryStorage = (): EditHistoryStorageAdapter => { + const store = new Map(); + return { + load: async (k) => store.get(k) ?? null, + save: async (k, v) => { + store.set(k, v); + }, + } as unknown as EditHistoryStorageAdapter; +}; + +interface UndoHarness { + singleRef: { current: SingleSplit | undefined }; + disk: Record; + store: ReturnType; + forceReloadSdkSession: ReturnType; + root: ReturnType; +} + +function mountRazorSplitWithHistory(): UndoHarness { + const disk: Record = { + [ROOT_FILE]: `
`, + }; + const store = createPersistentEditHistoryStore({ + projectId: "p1", + storage: memoryStorage(), + initialState: createEmptyEditHistory(), + now: () => Date.now(), + onChange: () => {}, + }); + const forceReloadSdkSession = vi.fn(); + + const fetchMock = createSplitFetchMock(disk); + vi.stubGlobal("fetch", fetchMock); + + const singleRef: { current: SingleSplit | undefined } = { current: undefined }; + function Component() { + const { handleRazorSplit } = useRazorSplit({ + projectId: "p1", + activeCompPath: ROOT_FILE, + showToast: () => {}, + writeProjectFile: async (path, content) => { + disk[path] = content; + }, + recordEdit: (input) => store.recordEdit(input), + domEditSaveTimestampRef: { current: 0 }, + reloadPreview: () => {}, + forceReloadSdkSession, + }); + singleRef.current = handleRazorSplit; + return null; + } + const root = mountComponent(Component); + return { singleRef, disk, store, forceReloadSdkSession, root }; +} + +describe("useRazorSplit — undo integrity after split (Bug 1)", () => { + let h: UndoHarness; + beforeEach(() => { + h = mountRazorSplitWithHistory(); + }); + afterEach(() => { + act(() => h.root.unmount()); + }); + + const readFile = () => ({ + readFile: async (p: string) => h.disk[p], + writeFile: async (p: string, c: string) => { + h.disk[p] = c; + }, + }); + + it("resyncs the SDK session after a split (matches every other server-write path)", async () => { + await act(async () => { + await h.singleRef.current!(rootElement, 4); + }); + expect(h.forceReloadSdkSession).toHaveBeenCalledTimes(1); + }); + + it("applies undo after a split without an external-change refusal", async () => { + await act(async () => { + await h.singleRef.current!(rootElement, 4); + }); + const result = await h.store.undo(readFile()); + expect(result.ok).toBe(true); + expect(result.reason).toBeUndefined(); + // The file is restored to its pre-split bytes. + expect(h.disk[ROOT_FILE]).not.toContain(""); + }); + + it("still trips the guard when the file is edited externally after a split", async () => { + await act(async () => { + await h.singleRef.current!(rootElement, 4); + }); + // Simulate the user editing the file in their own editor after the split. + h.disk[ROOT_FILE] = `${h.disk[ROOT_FILE]}`; + const result = await h.store.undo(readFile()); + expect(result.ok).toBe(false); + expect(result.reason).toBe("content-mismatch"); + }); +}); diff --git a/packages/studio/src/hooks/useRazorSplit.ts b/packages/studio/src/hooks/useRazorSplit.ts index 7fd8a72a69..f0425ad55b 100644 --- a/packages/studio/src/hooks/useRazorSplit.ts +++ b/packages/studio/src/hooks/useRazorSplit.ts @@ -20,6 +20,16 @@ interface UseRazorSplitOptions { recordEdit: (input: RecordEditInput) => Promise; domEditSaveTimestampRef: React.MutableRefObject; reloadPreview: () => void; + /** + * Resync the in-memory SDK session after the server-side split write (the + * split-element / split-gsap endpoints write the file directly, so the SDK's + * linkedom doc is now stale). Without this, a later SDK-routed edit serializes + * the PRE-split doc and reverts the split on disk — the undo baseline recorded + * here then no longer matches disk, so Cmd+Z trips the "changed externally" + * guard. Every other server-side-write timeline path (move / resize / delete / + * drop / visibility) already calls this; the split path was the sole omission. + */ + forceReloadSdkSession?: () => void; isRecordingRef?: React.RefObject; } @@ -122,13 +132,23 @@ async function executeSplit( const originalContent = await readFileContent(pid, targetPath); const newId = generateSplitId(collectHtmlIds(originalContent), element.domId || "clip"); + // An expanded sub-comp child arrives in MASTER-timeline coordinates — both its + // `start` and the incoming `splitTime` are offset by the host's master start + // (expandedParentStart) — but its `sourceFile` is the sub-comp, whose clips are + // authored in LOCAL time. Rebase both onto local time before the server patches + // the file, exactly as TimelinePane.handleSplitElement does for non-razor edits. + // Root-level clips (no expandedParentStart) are already local, so pass through. + const basis = element.expandedParentStart; + const localSplitTime = basis === undefined ? splitTime : Math.max(0, splitTime - basis); + const localElementStart = basis === undefined ? element.start : element.start - basis; + const splitResult = await splitHtmlElement( pid, targetPath, patchTarget, - splitTime, + localSplitTime, newId, - element.start, + localElementStart, element.duration, ); if (!splitResult.ok) throw new Error("Failed to split clip."); @@ -147,8 +167,8 @@ async function executeSplit( targetPath, element.domId, newId, - splitTime, - element.start, + localSplitTime, + localElementStart, element.duration, ); if (gsapResult.content) patchedContent = gsapResult.content; @@ -172,6 +192,7 @@ export function useRazorSplit({ recordEdit, domEditSaveTimestampRef, reloadPreview, + forceReloadSdkSession, isRecordingRef, }: UseRazorSplitOptions) { const projectIdRef = useRef(projectId); @@ -208,6 +229,11 @@ export function useRazorSplit({ recordEdit, }); + // Server wrote the file; resync the stale in-memory SDK doc BEFORE the + // reload so a later SDK-routed edit can't serialize the pre-split doc and + // revert this split (which would desync disk from the undo baseline just + // recorded → Cmd+Z reports "changed externally"). + forceReloadSdkSession?.(); reloadPreview(); trackStudioRazorSplit({ mode: "single", count: 1 }); showToast(`Split ${getTimelineElementLabel(element)} at ${splitTime.toFixed(2)}s`, "info"); @@ -229,6 +255,7 @@ export function useRazorSplit({ writeProjectFile, domEditSaveTimestampRef, reloadPreview, + forceReloadSdkSession, isRecordingRef, ], ); @@ -288,6 +315,9 @@ export function useRazorSplit({ ), }); + // Resync the stale SDK doc after the batched server write (see the + // single-split path above for why this precedes the reload). + forceReloadSdkSession?.(); reloadPreview(); trackStudioRazorSplit({ mode: "all", count: splitCount }); showToast(`Split ${splitCount} clips at ${splitTime.toFixed(2)}s`, "info"); @@ -303,6 +333,7 @@ export function useRazorSplit({ writeProjectFile, domEditSaveTimestampRef, reloadPreview, + forceReloadSdkSession, isRecordingRef, ], ); diff --git a/packages/studio/src/hooks/useStudioContextValue.ts b/packages/studio/src/hooks/useStudioContextValue.ts index a8c5d0e0b8..460028bdfb 100644 --- a/packages/studio/src/hooks/useStudioContextValue.ts +++ b/packages/studio/src/hooks/useStudioContextValue.ts @@ -2,6 +2,8 @@ import { useCallback, useMemo, useRef, useState, type DragEvent } from "react"; import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability"; import type { StudioContextValue } from "../contexts/StudioContext"; import type { RightInspectorPanes } from "../utils/studioHelpers"; +import type { TimelineFileDropHandler } from "./useTimelineEditingTypes"; +import { usePlayerStore } from "../player"; interface StudioContextInput { projectId: string; @@ -34,8 +36,6 @@ interface StudioContextInput { waitForPendingDomEditSaves: () => Promise; handlePreviewIframeRef: (iframe: HTMLIFrameElement | null) => void; refreshPreviewDocumentVersion: () => void; - timelineVisible: boolean; - toggleTimelineVisibility: () => void; } // fallow-ignore-next-line complexity @@ -61,8 +61,6 @@ export function buildStudioContextValue(input: StudioContextInput): StudioContex waitForPendingDomEditSaves: input.waitForPendingDomEditSaves, handlePreviewIframeRef: input.handlePreviewIframeRef, refreshPreviewDocumentVersion: input.refreshPreviewDocumentVersion, - timelineVisible: input.timelineVisible, - toggleTimelineVisibility: input.toggleTimelineVisibility, }; } @@ -108,7 +106,7 @@ export function useInspectorState( } // fallow-ignore-next-line complexity -export function useDragOverlay(onImportFiles: (files: FileList) => void) { +function useDragOverlay(onImportFiles: (files: FileList) => void) { const [active, setActive] = useState(false); const counterRef = useRef(0); const onDragOver = useCallback((e: DragEvent) => { @@ -137,3 +135,15 @@ export function useDragOverlay(onImportFiles: (files: FileList) => void) { ); return { active, onDragOver, onDragEnter, onDragLeave, onDrop }; } + +/** Global OS file drop: imports and places at the playhead position. */ +export function useGlobalFileDrop(handleTimelineFileDrop: TimelineFileDropHandler) { + const onDrop = useCallback( + (files: FileList) => { + const start = usePlayerStore.getState().currentTime; + void handleTimelineFileDrop(Array.from(files), { start, track: 0 }); + }, + [handleTimelineFileDrop], + ); + return useDragOverlay(onDrop); +} diff --git a/packages/studio/src/hooks/useStudioUrlState.ts b/packages/studio/src/hooks/useStudioUrlState.ts index 7407c38e92..e67e6d346a 100644 --- a/packages/studio/src/hooks/useStudioUrlState.ts +++ b/packages/studio/src/hooks/useStudioUrlState.ts @@ -20,7 +20,6 @@ interface UseStudioUrlStateParams { previewIframeRef: React.MutableRefObject; rightPanelTab: RightPanelTab; rightCollapsed: boolean; - timelineVisible: boolean; activeCompPathHydrated: boolean; domEditSelection: DomEditSelection | null; buildDomSelectionFromTarget: ( @@ -66,7 +65,6 @@ export function useStudioUrlState({ previewIframeRef, rightPanelTab, rightCollapsed, - timelineVisible, activeCompPathHydrated, domEditSelection, buildDomSelectionFromTarget, @@ -91,12 +89,12 @@ export function useStudioUrlState({ currentTime: stableTimeRef.current, rightPanelTab, rightCollapsed, - timelineVisible, + timelineVisible: null, selection: hydratedSelectionRef.current ? toPersistedSelection(domEditSelection) : pendingSelectionRef.current, }), - [activeCompPath, domEditSelection, rightCollapsed, rightPanelTab, timelineVisible], + [activeCompPath, domEditSelection, rightCollapsed, rightPanelTab], ); // Resolve a URL selection to a live element and apply it. Shared by the initial diff --git a/packages/studio/src/hooks/useTimelineEditing.test.tsx b/packages/studio/src/hooks/useTimelineEditing.test.tsx deleted file mode 100644 index cd3f88fdc6..0000000000 --- a/packages/studio/src/hooks/useTimelineEditing.test.tsx +++ /dev/null @@ -1,963 +0,0 @@ -// @vitest-environment happy-dom - -import React, { act, useRef } from "react"; -import { createRoot } from "react-dom/client"; -import { openComposition } from "@hyperframes/sdk"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { usePlayerStore, type TimelineElement } from "../player"; -import { useElementLifecycleOps } from "./useElementLifecycleOps"; -import { useTimelineEditing } from "./useTimelineEditing"; - -(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - -type ZIndexEntry = { - element: HTMLElement; - zIndex: number; - id?: string; - selector?: string; - selectorIndex?: number; - sourceFile: string; -}; - -afterEach(() => { - document.body.innerHTML = ""; - usePlayerStore.getState().reset(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - -function createPreviewIframe( - clips: Array<{ - id: string; - track: number; - style?: string; - }> = [ - { id: "front", track: 0 }, - { id: "back", track: 1 }, - ], -): HTMLIFrameElement { - const iframe = document.createElement("iframe"); - document.body.append(iframe); - const doc = iframe.contentDocument; - if (!doc) throw new Error("Expected iframe document"); - doc.body.innerHTML = clips - .map( - (clip) => - `
`, - ) - .join("\n"); - return iframe; -} - -function timelineElement(input: { - id: string; - 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: input.start ?? 0, - duration: input.duration ?? 2, - track: input.track, - zIndex: input.zIndex, - stackingContextId: "root", - parentCompositionId: null, - compositionAncestors: ["root"], - sourceFile: input.sourceFile ?? "index.html", - timingSource: "authored", - }; -} - -function renderTimelineEditingHook(input: { - timelineElements: TimelineElement[]; - iframe: HTMLIFrameElement; - onZIndexCommit: (entries: ZIndexEntry[]) => Promise; - projectId?: string | null; - writeProjectFile?: (path: string, content: string) => Promise; - recordEdit?: (input: { - label: string; - kind: string; - coalesceKey?: string; - files: Record; - }) => Promise; - reloadPreview?: () => void; - sdkSession?: Awaited> | null; - forceReloadSdkSession?: () => void; -}): { - move: ReturnType["handleTimelineElementMove"]; - resize: ReturnType["handleTimelineElementResize"]; - groupMove: ReturnType["handleTimelineGroupMove"]; - groupResize: ReturnType["handleTimelineGroupResize"]; - unmount: () => void; -} { - let move: ReturnType["handleTimelineElementMove"] | null = null; - let resize: ReturnType["handleTimelineElementResize"] | null = null; - let groupMove: ReturnType["handleTimelineGroupMove"] | null = null; - let groupResize: ReturnType["handleTimelineGroupResize"] | null = null; - - function Harness() { - const commitRef = useRef(input.onZIndexCommit); - commitRef.current = input.onZIndexCommit; - const hook = useTimelineEditing({ - projectId: input.projectId ?? null, - activeCompPath: "index.html", - timelineElements: input.timelineElements, - showToast: vi.fn(), - writeProjectFile: input.writeProjectFile ?? vi.fn(), - recordEdit: input.recordEdit ?? vi.fn(), - domEditSaveTimestampRef: { current: 0 }, - reloadPreview: input.reloadPreview ?? vi.fn(), - previewIframeRef: { current: input.iframe }, - pendingTimelineEditPathRef: { current: new Set() }, - uploadProjectFiles: vi.fn(), - sdkSession: input.sdkSession, - forceReloadSdkSession: input.forceReloadSdkSession, - handleDomZIndexReorderCommitRef: commitRef, - }); - move = hook.handleTimelineElementMove; - resize = hook.handleTimelineElementResize; - groupMove = hook.handleTimelineGroupMove; - groupResize = hook.handleTimelineGroupResize; - return null; - } - - const host = document.createElement("div"); - document.body.append(host); - const root = createRoot(host); - act(() => { - root.render(); - }); - - 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[0]["recordEdit"] ->; - -function renderTimelineEditingHookWithLifecycle(input: { - timelineElements: TimelineElement[]; - iframe: HTMLIFrameElement; - commitPositionPatchToHtml: ReturnType Promise>>; -}): { - move: ReturnType["handleTimelineElementMove"]; - unmount: () => void; -} { - let move: ReturnType["handleTimelineElementMove"] | null = null; - - function Harness() { - const lifecycle = useElementLifecycleOps({ - activeCompPath: "index.html", - showToast: vi.fn(), - writeProjectFile: vi.fn(), - domEditSaveTimestampRef: { current: 0 }, - editHistory: { recordEdit: vi.fn() }, - projectIdRef: { current: "p1" }, - reloadPreview: vi.fn(), - clearDomSelection: vi.fn(), - commitPositionPatchToHtml: input.commitPositionPatchToHtml, - }); - const commitRef = useRef(lifecycle.handleDomZIndexReorderCommit); - commitRef.current = lifecycle.handleDomZIndexReorderCommit; - const hook = useTimelineEditing({ - projectId: null, - activeCompPath: "index.html", - timelineElements: input.timelineElements, - showToast: vi.fn(), - writeProjectFile: vi.fn(), - recordEdit: vi.fn(), - domEditSaveTimestampRef: { current: 0 }, - reloadPreview: vi.fn(), - previewIframeRef: { current: input.iframe }, - pendingTimelineEditPathRef: { current: new Set() }, - uploadProjectFiles: vi.fn(), - handleDomZIndexReorderCommitRef: commitRef, - }); - move = hook.handleTimelineElementMove; - return null; - } - - const host = document.createElement("div"); - document.body.append(host); - const root = createRoot(host); - act(() => { - root.render(); - }); - - if (!move) throw new Error("Expected hook to expose move handler"); - return { - move, - unmount: () => { - act(() => root.unmount()); - }, - }; -} - -function jsonResponse(body: unknown): Response { - return new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - }); -} - -function requestUrl(input: Parameters[0]): string { - if (typeof input === "string") return input; - if (input instanceof URL) return input.toString(); - return input.url; -} - -async function flushAsyncWork(): Promise { - for (let i = 0; i < 8; i += 1) { - await Promise.resolve(); - } -} - -describe("useTimelineEditing timeline z-index reorder", () => { - it("extends root duration through the fallback path when an SDK-backed move passes the end", async () => { - const source = [ - `
`, - `
`, - `
`, - ].join("\n"); - const iframe = createPreviewIframe([{ id: "clip", track: 0 }]); - const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 }); - const sdkSession = await openComposition(source); - const setTimingSpy = vi.spyOn(sdkSession, "setTiming"); - const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); - const recordEdit = vi.fn(async () => {}); - const forceReloadSdkSession = vi.fn(); - const reloadPreview = vi.fn(); - const iframeWindow = iframe.contentWindow; - if (!iframeWindow) throw new Error("Expected iframe window"); - const postMessageSpy = vi.spyOn(iframeWindow, "postMessage"); - vi.stubGlobal( - "fetch", - vi.fn(async (input: Parameters[0]): Promise => { - 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, mutated: false }); - } - throw new Error(`Unexpected fetch: ${url}`); - }), - ); - usePlayerStore.getState().setDuration(4); - const { move, unmount } = renderTimelineEditingHook({ - timelineElements: [clip], - iframe, - onZIndexCommit: vi.fn().mockResolvedValue(undefined), - projectId: "p1", - writeProjectFile, - recordEdit, - sdkSession, - forceReloadSdkSession, - reloadPreview, - }); - - await act(async () => { - await move(clip, { start: 3, track: clip.track }); - }); - - expect(setTimingSpy).not.toHaveBeenCalled(); - expect(writeProjectFile.mock.calls[0]![1]).toContain( - 'data-composition-id="main" data-duration="5"', - ); - expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="3"'); - expect(usePlayerStore.getState().duration).toBe(5); - expect(forceReloadSdkSession).toHaveBeenCalledTimes(1); - expect(reloadPreview).not.toHaveBeenCalled(); - expect(postMessageSpy).toHaveBeenCalledWith( - { - source: "hf-parent", - type: "control", - action: "set-root-duration", - durationSeconds: 5, - }, - "*", - ); - - unmount(); - }); - - it("extends root duration through the fallback path when an SDK-backed resize passes the end", async () => { - const source = [ - `
`, - `
`, - `
`, - ].join("\n"); - const iframe = createPreviewIframe([{ id: "clip", track: 0 }]); - const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 }); - const sdkSession = await openComposition(source); - const setTimingSpy = vi.spyOn(sdkSession, "setTiming"); - const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); - const recordEdit = vi.fn(async () => {}); - const forceReloadSdkSession = vi.fn(); - const reloadPreview = vi.fn(); - const iframeWindow = iframe.contentWindow; - if (!iframeWindow) throw new Error("Expected iframe window"); - const postMessageSpy = vi.spyOn(iframeWindow, "postMessage"); - vi.stubGlobal( - "fetch", - vi.fn(async (input: Parameters[0]): Promise => { - 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, mutated: false }); - } - throw new Error(`Unexpected fetch: ${url}`); - }), - ); - usePlayerStore.getState().setDuration(4); - const { resize, unmount } = renderTimelineEditingHook({ - timelineElements: [clip], - iframe, - onZIndexCommit: vi.fn().mockResolvedValue(undefined), - projectId: "p1", - writeProjectFile, - recordEdit, - sdkSession, - forceReloadSdkSession, - reloadPreview, - }); - - await act(async () => { - await resize(clip, { start: 0, duration: 5, playbackStart: undefined }); - }); - - expect(setTimingSpy).not.toHaveBeenCalled(); - expect(writeProjectFile.mock.calls[0]![1]).toContain( - 'data-composition-id="main" data-duration="5"', - ); - expect(writeProjectFile.mock.calls[0]![1]).toContain('data-duration="5">
'); - expect(usePlayerStore.getState().duration).toBe(5); - expect(forceReloadSdkSession).toHaveBeenCalledTimes(1); - expect(reloadPreview).not.toHaveBeenCalled(); - expect(postMessageSpy).toHaveBeenCalledWith( - { - source: "hf-parent", - type: "control", - action: "set-root-duration", - durationSeconds: 5, - }, - "*", - ); - - unmount(); - }); - - it("routes a vertical drag through the shared z-index commit without writing track-index", async () => { - const iframe = createPreviewIframe([ - { id: "front", track: 0, style: "position: relative; z-index: 10" }, - { id: "back", track: 2, style: "position: relative; z-index: 1" }, - ]); - const front = timelineElement({ id: "front", track: 0, zIndex: 10 }); - const back = timelineElement({ id: "back", track: 2, zIndex: 1 }); - const commit = vi.fn<(entries: ZIndexEntry[]) => Promise>().mockResolvedValue(undefined); - const { move, unmount } = renderTimelineEditingHook({ - timelineElements: [front, back], - iframe, - onZIndexCommit: commit, - }); - - await act(async () => { - await move(back, { - start: back.start, - track: back.track, - stackingReorder: { - contextKey: "root", - placement: { type: "onto", layerId: "layer-front" }, - zIndexChanges: [{ key: "back", zIndex: 10 }], - }, - }); - }); - - const doc = iframe.contentDocument; - if (!doc) throw new Error("Expected iframe document"); - - expect(commit).toHaveBeenCalledTimes(1); - expect(commit.mock.calls[0]![0].map((entry) => [entry.id, entry.zIndex])).toEqual([ - ["back", 10], - ]); - expect(doc.getElementById("back")?.getAttribute("data-track-index")).toBe("2"); - - unmount(); - }); - - it("never writes z-index when the dragged clip is audio (no visual layer)", async () => { - const iframe = createPreviewIframe([ - { id: "front", track: 0 }, - { id: "music", track: 1 }, - ]); - const front = timelineElement({ id: "front", track: 0, zIndex: 0 }); - const music = timelineElement({ id: "music", track: 1, zIndex: 0, tag: "audio" }); - const commit = vi.fn<(entries: ZIndexEntry[]) => Promise>().mockResolvedValue(undefined); - const { move, unmount } = renderTimelineEditingHook({ - timelineElements: [front, music], - iframe, - onZIndexCommit: commit, - }); - - await act(async () => { - await move(music, { - start: music.start, - track: music.track, - stackingReorder: { - contextKey: "root", - placement: { type: "onto", layerId: "layer-front" }, - zIndexChanges: [{ key: "music", zIndex: 2 }], - }, - }); - }); - - expect(commit).not.toHaveBeenCalled(); - - unmount(); - }); - - it("commits only the minimum z-index changes resolved by the timeline drag", async () => { - const iframe = createPreviewIframe([ - { id: "front", track: 0, style: "position: relative; z-index: 2" }, - { id: "back", track: 1, style: "position: relative; z-index: 1" }, - { id: "dragged", track: 2, style: "position: relative; z-index: 0" }, - ]); - const front = timelineElement({ id: "front", track: 0, zIndex: 2 }); - const back = timelineElement({ id: "back", track: 1, zIndex: 1 }); - const dragged = timelineElement({ id: "dragged", track: 2, zIndex: 0 }); - const commit = vi.fn<(entries: ZIndexEntry[]) => Promise>().mockResolvedValue(undefined); - const { move, unmount } = renderTimelineEditingHook({ - timelineElements: [front, back, dragged], - iframe, - onZIndexCommit: commit, - }); - - await act(async () => { - await move(dragged, { - start: dragged.start, - track: dragged.track, - stackingReorder: { - contextKey: "root", - placement: { type: "between", beforeLayerId: "front", afterLayerId: "back" }, - zIndexChanges: [ - { key: "dragged", zIndex: 2 }, - { key: "front", zIndex: 3 }, - ], - }, - }); - }); - - expect(commit.mock.calls[0]![0].map((entry) => [entry.id, entry.zIndex])).toEqual([ - ["dragged", 2], - ["front", 3], - ]); - - unmount(); - }); - - it("uses the shared lifecycle commit so static clips receive position relative", async () => { - const iframe = createPreviewIframe([ - { id: "front", track: 0, style: "position: static" }, - { id: "back", track: 1, style: "position: static" }, - ]); - const front = timelineElement({ id: "front", track: 0, zIndex: 0 }); - const back = timelineElement({ id: "back", track: 1, zIndex: 0 }); - const commitPositionPatchToHtml = vi.fn<(...args: unknown[]) => Promise>(async () => {}); - const { move, unmount } = renderTimelineEditingHookWithLifecycle({ - timelineElements: [front, back], - iframe, - commitPositionPatchToHtml, - }); - - await act(async () => { - await move(back, { - start: back.start, - track: back.track, - stackingReorder: { - contextKey: "root", - placement: { type: "above", layerId: "front" }, - zIndexChanges: [{ key: "back", zIndex: 2 }], - }, - }); - await flushAsyncWork(); - }); - - expect(commitPositionPatchToHtml).toHaveBeenCalled(); - expect(commitPositionPatchToHtml.mock.calls[0]![1]).toEqual([ - { type: "inline-style", property: "z-index", value: "2" }, - { type: "inline-style", property: "position", value: "relative" }, - ]); - - unmount(); - }); - - it("rejects and rolls back DOM and store z-index changes when a reorder save fails", async () => { - const iframe = createPreviewIframe([ - { id: "front", track: 0, style: "position: relative; z-index: 7" }, - { id: "back", track: 1, style: "position: static" }, - ]); - const front = timelineElement({ id: "front", track: 0, zIndex: 7 }); - const back = timelineElement({ id: "back", track: 1, zIndex: 0 }); - usePlayerStore.getState().setElements([ - { ...front, hasExplicitZIndex: true }, - { ...back, hasExplicitZIndex: false }, - ]); - const saveError = new Error("save failed"); - const commitPositionPatchToHtml = vi - .fn<(...args: unknown[]) => Promise>() - .mockResolvedValueOnce(undefined) - .mockRejectedValueOnce(saveError); - const { move, unmount } = renderTimelineEditingHookWithLifecycle({ - timelineElements: [front, back], - iframe, - commitPositionPatchToHtml, - }); - const doc = iframe.contentDocument; - if (!doc) throw new Error("Expected iframe document"); - const frontElement = doc.getElementById("front") as HTMLElement | null; - const backElement = doc.getElementById("back") as HTMLElement | null; - if (!frontElement || !backElement) throw new Error("Expected reordered elements"); - - let rejection: unknown; - await act(async () => { - try { - await move(back, { - start: back.start, - track: back.track, - stackingReorder: { - contextKey: "root", - placement: { type: "above", layerId: "front" }, - zIndexChanges: [ - { key: "front", zIndex: 2 }, - { key: "back", zIndex: 5 }, - ], - }, - }); - } catch (error) { - rejection = error; - } - await flushAsyncWork(); - }); - - expect(rejection).toBe(saveError); - expect(frontElement.style.zIndex).toBe("7"); - expect(frontElement.style.position).toBe("relative"); - expect(backElement.style.zIndex).toBe(""); - expect(backElement.style.position).toBe("static"); - const storeEntries = usePlayerStore.getState().elements; - expect(storeEntries.find((entry) => entry.id === "front")).toMatchObject({ - zIndex: 7, - hasExplicitZIndex: true, - }); - expect(storeEntries.find((entry) => entry.id === "back")).toMatchObject({ - zIndex: 0, - hasExplicitZIndex: false, - }); - - unmount(); - }); - - it("waits for every lifecycle z-index save before resolving a reorder", async () => { - const iframe = createPreviewIframe([ - { id: "front", track: 0, style: "position: relative; z-index: 1" }, - { id: "back", track: 1, style: "position: relative; z-index: 0" }, - ]); - const front = timelineElement({ id: "front", track: 0, zIndex: 1 }); - const back = timelineElement({ id: "back", track: 1, zIndex: 0 }); - let releaseFirst!: () => void; - let releaseSecond!: () => void; - const firstSave = new Promise((resolve) => { - releaseFirst = resolve; - }); - const secondSave = new Promise((resolve) => { - releaseSecond = resolve; - }); - const commitPositionPatchToHtml = vi - .fn<(...args: unknown[]) => Promise>() - .mockReturnValueOnce(firstSave) - .mockReturnValueOnce(secondSave); - const { move, unmount } = renderTimelineEditingHookWithLifecycle({ - timelineElements: [front, back], - iframe, - commitPositionPatchToHtml, - }); - let settled = false; - - let movePromise!: Promise; - await act(async () => { - movePromise = move(back, { - start: back.start, - track: back.track, - stackingReorder: { - contextKey: "root", - placement: { type: "above", layerId: "front" }, - zIndexChanges: [ - { key: "front", zIndex: 2 }, - { key: "back", zIndex: 3 }, - ], - }, - }).then(() => { - settled = true; - }); - await flushAsyncWork(); - }); - - expect(commitPositionPatchToHtml).toHaveBeenCalledTimes(2); - expect(settled).toBe(false); - - await act(async () => { - releaseFirst(); - await flushAsyncWork(); - }); - expect(settled).toBe(false); - - await act(async () => { - releaseSecond(); - await movePromise; - await flushAsyncWork(); - }); - expect(settled).toBe(true); - - unmount(); - }); - - it("keeps horizontal-only drag on the timing and GSAP shift path without z-index writes", async () => { - const iframe = createPreviewIframe([{ id: "clip", track: 0 }]); - const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 }); - const commit = vi.fn<(entries: ZIndexEntry[]) => Promise>().mockResolvedValue(undefined); - const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); - const recordEdit = vi.fn(async (_entry) => {}); - const reloadPreview = vi.fn(); - const fetchMock = vi.fn( - async ( - input: Parameters[0], - _init?: Parameters[1], - ): Promise => { - const url = requestUrl(input); - if (url.includes("/api/projects/p1/files/")) { - return jsonResponse({ - content: '
', - }); - } - if (url.includes("/api/projects/p1/gsap-mutations/")) { - return jsonResponse({ ok: true }); - } - throw new Error(`Unexpected fetch: ${url}`); - }, - ); - vi.stubGlobal("fetch", fetchMock); - const { move, unmount } = renderTimelineEditingHook({ - timelineElements: [clip], - iframe, - onZIndexCommit: commit, - projectId: "p1", - writeProjectFile, - recordEdit, - reloadPreview, - }); - - await act(async () => { - await move(clip, { start: 1.25, track: clip.track }); - }); - - const doc = iframe.contentDocument; - if (!doc) throw new Error("Expected iframe document"); - expect(doc.getElementById("clip")?.getAttribute("data-start")).toBe("1.25"); - expect(doc.getElementById("clip")?.getAttribute("data-track-index")).toBe("0"); - expect(commit).not.toHaveBeenCalled(); - expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="1.25"'); - expect(writeProjectFile.mock.calls[0]![1]).toContain('data-track-index="0"'); - expect(writeProjectFile.mock.calls[0]![1]).not.toContain("z-index"); - expect( - fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")), - ).toBe(true); - - unmount(); - }); - - it("orders the timing write after the z-index commit so a diagonal drag can't clobber the restack", async () => { - const iframe = createPreviewIframe([ - { id: "clip", track: 0, style: "position: relative; z-index: 0" }, - ]); - const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 }); - // Gate the z-index commit so we can observe whether the timing write waits. - let releaseCommit!: () => void; - const commitGate = new Promise((resolve) => { - releaseCommit = resolve; - }); - const commit = vi.fn<(entries: ZIndexEntry[]) => Promise>().mockReturnValue(commitGate); - const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); - const fetchMock = vi.fn(async (input: Parameters[0]): Promise => { - const url = requestUrl(input); - if (url.includes("/api/projects/p1/files/")) { - return jsonResponse({ - content: '
', - }); - } - if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true }); - throw new Error(`Unexpected fetch: ${url}`); - }); - vi.stubGlobal("fetch", fetchMock); - const { move, unmount } = renderTimelineEditingHook({ - timelineElements: [clip], - iframe, - onZIndexCommit: commit, - projectId: "p1", - writeProjectFile, - recordEdit: vi.fn(async () => {}), - }); - - // Diagonal drag: both a time move (start change) and a restack (z-index change). - let movePromise!: Promise; - await act(async () => { - movePromise = move(clip, { - start: 1.25, - track: clip.track, - stackingReorder: { - contextKey: "root", - placement: { type: "onto", layerId: "layer-clip" }, - zIndexChanges: [{ key: "clip", zIndex: 5 }], - }, - }); - await flushAsyncWork(); - }); - - // The z-index commit is in flight but gated; the full-file timing write must - // not have run yet, or it would overwrite the file without the z-index change. - expect(commit).toHaveBeenCalledTimes(1); - expect(writeProjectFile).not.toHaveBeenCalled(); - - // Release the z-index commit → the timing write now proceeds, on top of it. - await act(async () => { - releaseCommit(); - await movePromise; - await flushAsyncWork(); - }); - expect(writeProjectFile).toHaveBeenCalled(); - - unmount(); - }); - - it("persists a same-file group move with one write containing every clip timing", async () => { - const source = [ - '
', - '
', - '
', - ].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>(async () => {}); - const recordEdit = vi.fn(async (_entry) => {}); - vi.stubGlobal( - "fetch", - vi.fn(async (input: Parameters[0]): Promise => { - 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 = { - "index.html": '
', - "scene.html": '
', - }; - 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>(async () => {}); - const recordEdit = vi.fn(async (_entry) => {}); - vi.stubGlobal( - "fetch", - vi.fn(async (input: Parameters[0]): Promise => { - 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 = '
'; - 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((resolve) => { - releaseCommit = resolve; - }); - const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); - vi.stubGlobal( - "fetch", - vi.fn(async (input: Parameters[0]): Promise => { - 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; - 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 = '
'; - const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 0, duration: 1 }); - const fetchMock = vi.fn(async (input: Parameters[0]): Promise => { - 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>(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>(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(); - }); -}); diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index b873f85178..7ebc583312 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -1,51 +1,39 @@ +// Pre-existing-complex timeline hook (DOM patch + GSAP position shift/scale + pbs resolution). // fallow-ignore-file complexity import { useCallback, useRef } from "react"; import type { TimelineElement } from "../player"; import { usePlayerStore } from "../player"; -import { useRazorSplit } from "./useRazorSplit"; import { - buildTimelineAssetId, - buildTimelineAssetInsertHtml, - buildTimelineFileDropPlacements, - getTimelineAssetKind, - insertTimelineAssetIntoSource, - resolveTimelineAssetInitialGeometry, - resolveTimelineAssetSrc, -} from "../utils/timelineAssetDrop"; + furthestClipEndFromDocument, + furthestClipEndFromSource, +} from "../player/lib/timelineElementHelpers"; +import { useRazorSplit } from "./useRazorSplit"; +import { setCompositionDurationToContent } from "../utils/timelineAssetDrop"; import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; +import { getTimelineElementLabel } from "../utils/studioHelpers"; import { - getTimelineElementLabel, - collectHtmlIds, - resolveDroppedAssetDuration, -} from "../utils/studioHelpers"; -import { - applyTimelineStackingReorder, buildPatchTarget, patchIframeDomTiming, + patchIframeRootDuration, + resolveResizePlaybackStart, persistTimelineEdit, readFileContent, - foldedShiftGsapMutation, - foldedScaleGsapMutation, + applyPatchByTarget, formatTimelineAttributeNumber, - finishTimelineTimingFallback, - extendRootDurationIfNeeded, - buildTimelineMoveTimingPatch, - buildTimelineResizeTimingPatch, + shiftGsapPositions, + scaleGsapPositions, + syncTimingEditPreview, } from "./timelineEditingHelpers"; import type { PersistTimelineEditInput } from "./timelineEditingHelpers"; -import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing"; import { useTimelineElementVisibilityEditing, useTimelineTrackVisibilityEditing, } from "./timelineTrackVisibility"; -import { useTimelineGroupEditing } from "./useTimelineGroupEditing"; import { sdkTimingPersist } from "../utils/sdkCutover"; +import { useTimelineElementsMove } from "./timelineElementsMove"; +import { useTimelineEditingDrops } from "./useTimelineEditingDrops"; import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes"; -type TimelineMoveUpdates = Pick & { - stackingReorder?: TimelineStackingReorderIntent | null; -}; - export function useTimelineEditing({ projectId, activeCompPath, @@ -61,7 +49,6 @@ export function useTimelineEditing({ isRecordingRef, sdkSession, forceReloadSdkSession, - handleDomZIndexReorderCommitRef, }: UseTimelineEditingOptions) { const projectIdRef = useRef(projectId); projectIdRef.current = projectId; @@ -98,6 +85,7 @@ export function useTimelineEditing({ }), ) .then(() => { + // Server wrote the file; resync the stale in-memory SDK doc. forceReloadSdkSession?.(); }); editQueueRef.current = queued.catch((error) => { @@ -116,99 +104,105 @@ export function useTimelineEditing({ forceReloadSdkSession, ], ); - const groupEditing = useTimelineGroupEditing({ - activeCompPath, - domEditSaveTimestampRef, - editQueueRef, - forceReloadSdkSession, - isRecordingRef, - pendingTimelineEditPathRef, - previewIframeRef, - projectIdRef, - recordEdit, - reloadPreview, - sdkSession, - showToast, - writeProjectFile, - }); + // Optimistically push the composition's content-driven length into the player + // store right after the live DOM patch, so the duration readout + seek bar + // update immediately. The readout binds to store.duration (PlayerControls); + // edits only patched store.elements, so the number stayed frozen (esp. on + // shrink) until a manual refresh. Read from the just-patched preview DOM (raw + // data-duration) so it's immune to the runtime's truncated live durations. + const syncReadoutDurationFromPreview = useCallback(() => { + const end = furthestClipEndFromDocument(previewIframeRef.current?.contentDocument ?? null); + if (end > 0) { + usePlayerStore.getState().setDuration(end); + // Also write the content end into the live root's `data-duration`. Timing + // edits now take the soft-reload path (no full iframe reload), which lets + // the runtime recompute the length from the root's declared duration and + // post it back — reading the STALE root would revert this optimistic set. + patchIframeRootDuration(previewIframeRef.current, end); + } + }, [previewIframeRef]); + + // fallow-ignore-next-line complexity const handleTimelineElementMove = useCallback( // fallow-ignore-next-line complexity - (element: TimelineElement, updates: TimelineMoveUpdates) => { + (element: TimelineElement, updates: Pick) => { + patchIframeDomTiming(previewIframeRef.current, element, [ + ["data-start", formatTimelineAttributeNumber(updates.start)], + ["data-track-index", String(updates.track)], + ]); + syncReadoutDurationFromPreview(); const targetPath = element.sourceFile || activeCompPath || "index.html"; - const startChanged = updates.start !== element.start; - - if (startChanged) { - patchIframeDomTiming(previewIframeRef.current, element, [ - ["data-start", formatTimelineAttributeNumber(updates.start)], - ]); - } - - const reorderDone = applyTimelineStackingReorder({ - element, - stackingReorder: updates.stackingReorder, - timelineElements, - iframe: previewIframeRef.current, - activeCompPath, - commit: handleDomZIndexReorderCommitRef?.current, - }); - - if (!startChanged) return reorderDone; - const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => { - return buildTimelineMoveTimingPatch(original, target, updates.start, element.duration); + let patched = applyPatchByTarget(original, target, { + type: "attribute", + property: "start", + value: formatTimelineAttributeNumber(updates.start), + }); + patched = applyPatchByTarget(patched, target, { + type: "attribute", + property: "track-index", + value: String(updates.track), + }); + // Content-driven duration: sync data-duration to the furthest clip end + // read from the PATCHED SOURCE (raw data-duration), so it grows if a clip + // moved past the end and shrinks if the furthest clip moved left. Measured + // from the source, NOT the store — store durations are runtime-truncated + // to the current comp length, which would ratchet the duration down every + // move (HANDOFF-3 §6.1 feedback loop). + return setCompositionDurationToContent(patched, furthestClipEndFromSource(patched)); }; + // Server-path fallback (no SDK session): persist the attr patch, then + // shift GSAP tween positions on the server and reload the preview — the + // SDK path folds both into setTiming, but the fallback must do them + // explicitly or the clip moves while its GSAP tweens stay put + the + // preview never refreshes. coalesceKey mirrors the SDK branch so undo + // granularity is identical on either path. const coalesceKey = `timeline-move:${element.hfId ?? element.id}`; const moveFallback = () => enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(() => { const pid = projectIdRef.current; const delta = updates.start - element.start; - const domId = element.domId; - return finishTimelineTimingFallback({ - iframe: previewIframeRef.current, - needsExtension, - rootDurationSeconds: updates.start + element.duration, + if (delta !== 0 && element.domId && pid) { + // Soft-reload with the server's rewritten GSAP script instead of a full + // iframe reload — a timing-only move already patched the DOM + store, so + // swapping the script in place avoids the all-clips flash. Falls back to + // reloadPreview() when the soft path can't apply. (See syncTimingEditPreview.) + return shiftGsapPositions(pid, targetPath, element.domId, delta) + .then((outcome) => + syncTimingEditPreview( + previewIframeRef.current, + outcome, + usePlayerStore.getState().currentTime, + reloadPreview, + ), + ) + .catch((err) => console.error("[Timeline] Failed to shift GSAP positions", err)); + } + return reloadPreview(); + }); + if (sdkSession && element.hfId) { + return sdkTimingPersist( + element.hfId, + targetPath, + { start: updates.start, trackIndex: updates.track }, + sdkSession, + { + editHistory: { recordEdit }, + writeProjectFile, reloadPreview, - gsapMutation: - delta !== 0 && domId && pid - ? foldedShiftGsapMutation({ - projectId: pid, - targetPath, - domId, - delta, - label: "Move timeline clip", - coalesceKey, - recordEdit, - }) - : undefined, - onGsapError: (err) => console.error("[Timeline] Failed to shift GSAP positions", err), - }); + domEditSaveTimestampRef, + compositionPath: activeCompPath, + // Capture on-disk bytes as the undo `before` so undoing a timing move + // restores the file verbatim, not a normalized full-DOM re-emit. + readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path), + }, + { label: "Move timeline clip", coalesceKey }, + ).then((handled) => { + if (!handled) return moveFallback(); }); - const needsExtension = extendRootDurationIfNeeded(updates.start + element.duration); - return reorderDone.then(() => { - if (sdkSession && element.hfId && !needsExtension) { - return sdkTimingPersist( - element.hfId, - targetPath, - { start: updates.start }, - sdkSession, - { - editHistory: { recordEdit }, - writeProjectFile, - reloadPreview, - domEditSaveTimestampRef, - compositionPath: activeCompPath, - // Capture on-disk bytes as the undo `before` so undoing a timing move - // restores the file verbatim, not a normalized full-DOM re-emit. - readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path), - }, - { label: "Move timeline clip", coalesceKey }, - ).then((handled) => { - if (!handled) return moveFallback(); - }); - } - return moveFallback(); - }); + } + return moveFallback(); }, [ previewIframeRef, @@ -219,11 +213,27 @@ export function useTimelineEditing({ writeProjectFile, reloadPreview, domEditSaveTimestampRef, - timelineElements, - handleDomZIndexReorderCommitRef, + syncReadoutDurationFromPreview, ], ); + // Batched, atomic multi-clip move — one read/patch/write/GSAP-shift/reload for + // ALL edits (single undo). Used by the drag commit for main-track ripple and + // track-insert; single-clip moves keep the SDK-aware handler above. + const handleTimelineElementsMove = useTimelineElementsMove({ + projectIdRef, + activeCompPath, + previewIframeRef, + writeProjectFile, + recordEdit, + reloadPreview, + forceReloadSdkSession, + domEditSaveTimestampRef, + isRecordingRef, + showToast, + }); + + // fallow-ignore-next-line complexity const handleTimelineElementResize = useCallback( // fallow-ignore-next-line complexity ( @@ -234,9 +244,8 @@ export function useTimelineEditing({ ["data-start", formatTimelineAttributeNumber(updates.start)], ["data-duration", formatTimelineAttributeNumber(updates.duration)], ]; - // Patch the live playback-start/media-start attr too, or a resize that - // trims the playback start leaves the preview showing the old in-point - // until the next reload (the persisted patch handles it via pbs below). + // Patch the live playback-start/media-start attr too, or a start-trim shows + // the old in-point until the next reload (persisted patch handles pbs below). if (updates.playbackStart != null) { const liveAttr = element.playbackStartAttr === "playback-start" @@ -245,46 +254,75 @@ export function useTimelineEditing({ liveAttrs.push([liveAttr, formatTimelineAttributeNumber(updates.playbackStart)]); } patchIframeDomTiming(previewIframeRef.current, element, liveAttrs); + syncReadoutDurationFromPreview(); const targetPath = element.sourceFile || activeCompPath || "index.html"; const buildResizePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => { - return buildTimelineResizeTimingPatch(original, target, element, updates); + const pbs = resolveResizePlaybackStart(original, target, element, updates); + let patched = applyPatchByTarget(original, target, { + type: "attribute", + property: "start", + value: formatTimelineAttributeNumber(updates.start), + }); + patched = applyPatchByTarget(patched, target, { + type: "attribute", + property: "duration", + value: formatTimelineAttributeNumber(updates.duration), + }); + if (pbs) { + patched = applyPatchByTarget(patched, target, { + type: "attribute", + property: pbs.attrName, + value: formatTimelineAttributeNumber(pbs.value), + }); + } + // Content-driven duration from the PATCHED SOURCE (raw data-duration) — + // grows/shrinks to the furthest clip end. Not from the store, whose + // durations are runtime-truncated (HANDOFF-3 §6.1 feedback loop). + return setCompositionDurationToContent(patched, furthestClipEndFromSource(patched)); }; + // 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); // Server-path fallback: after persisting the attr patch, scale GSAP tween - // positions/durations on the server. Extending edits can keep the iframe - // live unless a GSAP source rewrite needs a fresh run. + // positions/durations on the server and reload the preview. The SDK path + // folds both into setTiming; the fallback must do them explicitly or the + // clip resizes while its GSAP tweens keep their old timing + the preview + // never refreshes. coalesceKey mirrors the SDK branch for undo parity. const coalesceKey = `timeline-resize:${element.hfId ?? element.id}`; const timingChanged = updates.start !== element.start || updates.duration !== element.duration; - const needsExtension = extendRootDurationIfNeeded(updates.start + updates.duration); const resizeFallback = () => enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(() => { const pid = projectIdRef.current; - const domId = element.domId; - return finishTimelineTimingFallback({ - iframe: previewIframeRef.current, - needsExtension, - rootDurationSeconds: updates.start + updates.duration, - reloadPreview, - gsapMutation: - timingChanged && domId && pid - ? 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), - }); + if (timingChanged && element.domId && pid) { + // Soft-reload with the rewritten script (timing-only resize) — same + // no-flash path as move; full reload is the fallback. + return scaleGsapPositions( + pid, + targetPath, + element.domId, + element.start, + element.duration, + updates.start, + updates.duration, + ) + .then((outcome) => + syncTimingEditPreview( + previewIframeRef.current, + outcome, + usePlayerStore.getState().currentTime, + reloadPreview, + ), + ) + .catch((err) => console.error("[Timeline] Failed to scale GSAP positions", err)); + } + return reloadPreview(); }); - if (sdkSession && element.hfId && !hasPbsAdjustment && !needsExtension) { + if (sdkSession && element.hfId && !hasPbsAdjustment) { return sdkTimingPersist( element.hfId, targetPath, @@ -316,9 +354,9 @@ export function useTimelineEditing({ writeProjectFile, reloadPreview, domEditSaveTimestampRef, + syncReadoutDurationFromPreview, ], ); - const handleToggleTrackHidden = useTimelineTrackVisibilityEditing({ projectIdRef, activeCompPath, @@ -332,7 +370,6 @@ export function useTimelineEditing({ isRecordingRef, forceReloadSdkSession, }); - const handleToggleElementHidden = useTimelineElementVisibilityEditing({ projectIdRef, activeCompPath, @@ -346,69 +383,100 @@ export function useTimelineEditing({ isRecordingRef, forceReloadSdkSession, }); - + // Atomic multi-clip delete: ONE pass that removes every element from the + // source (grouped per owning file), then ONE content-driven duration sync + + // ONE save (single undo entry) + one reload — mirroring the batched pattern + // in persistTimelineElementsMove. The single-clip delete is the same path + // with a one-element list. // fallow-ignore-next-line complexity - const handleTimelineElementDelete = useCallback( + const handleTimelineElementsDelete = useCallback( // fallow-ignore-next-line complexity - async (element: TimelineElement) => { + async (elementsToDelete: TimelineElement[]) => { if (isRecordingRef?.current) { showToast("Cannot edit timeline while recording", "error"); return; } const pid = projectIdRef.current; if (!pid) throw new Error("No active project"); - const label = getTimelineElementLabel(element); - - const targetPath = element.sourceFile || activeCompPath || "index.html"; + if (elementsToDelete.length === 0) return; + // Pin the zoom before a delete shrinks the composition (content-driven + // duration), so the reload doesn't re-fit and rescale every clip. Covers the + // keyboard-delete path too (the context-menu delete already pins in Timeline; + // pinTimelineZoom is idempotent, so a double-pin is harmless). + usePlayerStore.getState().pinTimelineZoomToCurrent(); + const count = elementsToDelete.length; + const label = count === 1 ? getTimelineElementLabel(elementsToDelete[0]) : `${count} clips`; try { - const originalContent = await readFileContent(pid, targetPath); - - const patchTarget = buildPatchTarget(element); - if (!patchTarget) { - throw new Error(`Timeline element ${element.id} is missing a patchable target`); + // Group by owning source file — one read → remove-all → duration-sync + // cycle per file, all folded into a single history entry below. + const groups = new Map(); + for (const element of elementsToDelete) { + const targetPath = element.sourceFile || activeCompPath || "index.html"; + const bucket = groups.get(targetPath); + if (bucket) bucket.push(element); + else groups.set(targetPath, [element]); } - - const removeResponse = await fetch( - `/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ target: patchTarget }), - }, - ); - if (!removeResponse.ok) { - throw new Error(`Failed to delete ${element.id} from ${targetPath}`); + const originals = new Map(); + const patchedFiles: Record = {}; + for (const [targetPath, groupElements] of groups) { + const originalContent = await readFileContent(pid, targetPath); + originals.set(targetPath, originalContent); + let content = originalContent; + for (const element of groupElements) { + const patchTarget = buildPatchTarget(element); + if (!patchTarget) { + throw new Error(`Timeline element ${element.id} is missing a patchable target`); + } + const removeResponse = await fetch( + `/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ target: patchTarget }), + }, + ); + if (!removeResponse.ok) { + throw new Error(`Failed to delete ${element.id} from ${targetPath}`); + } + const removeData = (await removeResponse.json()) as { + changed?: boolean; + content?: string; + }; + content = typeof removeData.content === "string" ? removeData.content : content; + } + // Content-driven duration: shrink the composition to the furthest + // remaining clip end, read from the post-removal SOURCE (raw + // data-duration), so deleting the last/longest clip removes trailing + // empty space. Measured from the source, not the store, whose + // durations are runtime-truncated (HANDOFF-3 §6.1 feedback loop). + const deleteContentEnd = furthestClipEndFromSource(content); + patchedFiles[targetPath] = setCompositionDurationToContent(content, deleteContentEnd); + // Optimistically reflect the shrunk length in the readout/seek bar. + if (deleteContentEnd > 0 && targetPath === (activeCompPath || "index.html")) { + usePlayerStore.getState().setDuration(deleteContentEnd); + } } - - const removeData = (await removeResponse.json()) as { - changed?: boolean; - content?: string; - }; - const patchedContent = - typeof removeData.content === "string" ? removeData.content : originalContent; - domEditSaveTimestampRef.current = Date.now(); await saveProjectFilesWithHistory({ projectId: pid, - label: "Delete timeline clip", + label: count === 1 ? "Delete timeline clip" : `Delete ${count} timeline clips`, kind: "timeline", - files: { [targetPath]: patchedContent }, - readFile: async () => originalContent, + files: patchedFiles, + readFile: async (path) => originals.get(path) ?? "", writeFile: writeProjectFile, recordEdit, }); - + const deletedKeys = new Set(elementsToDelete.map((e) => e.key ?? e.id)); usePlayerStore .getState() - .setElements( - timelineElements.filter((te) => (te.key ?? te.id) !== (element.key ?? element.id)), - ); + .setElements(timelineElements.filter((te) => !deletedKeys.has(te.key ?? te.id))); usePlayerStore.getState().setSelectedElementId(null); + usePlayerStore.getState().clearSelectedElementIds(); forceReloadSdkSession?.(); reloadPreview(); - showToast(`Deleted ${label}. Use Undo to restore it.`, "info"); + showToast(`Deleted ${label}. Use Undo to restore ${count === 1 ? "it" : "them"}.`, "info"); } catch (error) { - const message = error instanceof Error ? error.message : "Failed to delete timeline clip"; + const message = error instanceof Error ? error.message : "Failed to delete timeline clips"; showToast(message); } }, @@ -425,142 +493,41 @@ export function useTimelineEditing({ ], ); - // fallow-ignore-next-line complexity - const handleTimelineAssetDrop = useCallback( - // fallow-ignore-next-line complexity - async ( - assetPath: string, - placement: Pick, - durationOverride?: number, - ) => { - if (isRecordingRef?.current) { - showToast("Cannot edit timeline while recording", "error"); - return; - } - const pid = projectIdRef.current; - if (!pid) throw new Error("No active project"); - - const kind = getTimelineAssetKind(assetPath); - if (!kind) { - showToast("Only image, video, and audio assets can be dropped onto the timeline."); - return; - } - - const targetPath = activeCompPath || "index.html"; - try { - const originalContent = await readFileContent(pid, targetPath); - - const normalizedStart = Number(formatTimelineAttributeNumber(placement.start)); - const duration = - Number.isFinite(durationOverride) && durationOverride != null && durationOverride > 0 - ? durationOverride - : await resolveDroppedAssetDuration(pid, assetPath, kind); - const normalizedDuration = Number(formatTimelineAttributeNumber(duration)); - const newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent)); - const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath); - - const resolvedTargetPath = targetPath || "index.html"; - const relevantElements = timelineElements.filter( - (te) => (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath, - ); - const newElementZIndex = Math.max(1, relevantElements.length + 1); - - const patchedContent = insertTimelineAssetIntoSource( - originalContent, - buildTimelineAssetInsertHtml({ - id: newId, - assetPath: resolvedAssetSrc, - kind, - start: normalizedStart, - duration: normalizedDuration, - track: placement.track, - zIndex: newElementZIndex, - geometry: resolveTimelineAssetInitialGeometry(originalContent), - }), - ); - - domEditSaveTimestampRef.current = Date.now(); - await saveProjectFilesWithHistory({ - projectId: pid, - label: "Add timeline asset", - kind: "timeline", - files: { [targetPath]: patchedContent }, - readFile: async () => originalContent, - writeFile: writeProjectFile, - recordEdit, - }); - - forceReloadSdkSession?.(); - reloadPreview(); - } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to drop asset onto timeline"; - showToast(message); + const handleTimelineElementDelete = useCallback( + (element: TimelineElement) => { + // Deleting a clip that is part of the marquee multi-selection deletes the + // WHOLE selection (standard NLE behavior) — one atomic pass, single undo. + // A delete on a clip outside the selection only removes that clip. + const { selectedElementIds } = usePlayerStore.getState(); + const key = element.key ?? element.id; + if (selectedElementIds.size > 1 && selectedElementIds.has(key)) { + const byKey = new Map(timelineElements.map((te) => [te.key ?? te.id, te])); + const targets = [...selectedElementIds] + .map((id) => byKey.get(id)) + .filter((te): te is TimelineElement => te != null); + if (!targets.some((te) => (te.key ?? te.id) === key)) targets.push(element); + return handleTimelineElementsDelete(targets); } + return handleTimelineElementsDelete([element]); }, - [ + [handleTimelineElementsDelete, timelineElements], + ); + // Asset/file drop + add-at-playhead handlers live in a sub-hook, called + // unconditionally here so the parent's hook call order is unchanged. + const { handleTimelineAssetDrop, handleTimelineFileDrop, handleAddAssetAtPlayhead } = + useTimelineEditingDrops({ + projectIdRef, activeCompPath, - recordEdit, - showToast, timelineElements, + showToast, writeProjectFile, + recordEdit, domEditSaveTimestampRef, reloadPreview, - isRecordingRef, - forceReloadSdkSession, - ], - ); - - // fallow-ignore-next-line complexity - const handleTimelineFileDrop = useCallback( - // fallow-ignore-next-line complexity - async (files: File[], placement?: Pick) => { - if (isRecordingRef?.current) { - showToast("Cannot edit timeline while recording", "error"); - return; - } - const pid = projectIdRef.current; - if (!pid) return; - const uploaded = await uploadProjectFiles(files); - if (uploaded.length === 0) return; - const durations: number[] = []; - for (const assetPath of uploaded) { - const kind = getTimelineAssetKind(assetPath); - const duration = kind ? await resolveDroppedAssetDuration(pid, assetPath, kind) : 0; - durations.push(Number(formatTimelineAttributeNumber(duration))); - } - const placements = buildTimelineFileDropPlacements( - placement ?? { start: 0, track: 0 }, - durations, - timelineElements - .filter( - (te) => - (te.sourceFile || activeCompPath || "index.html") === - (activeCompPath || "index.html"), - ) - .map((te) => ({ - start: te.start, - duration: te.duration, - track: te.track, - })), - ); - for (const [index, assetPath] of uploaded.entries()) { - await handleTimelineAssetDrop( - assetPath, - placements[index] ?? placements[0], - durations[index], - ); - } - }, - [ - activeCompPath, - handleTimelineAssetDrop, - timelineElements, uploadProjectFiles, isRecordingRef, - showToast, - ], - ); + forceReloadSdkSession, + }); const handleBlockedTimelineEdit = useCallback( (_element: TimelineElement) => { @@ -580,12 +547,15 @@ export function useTimelineEditing({ recordEdit, domEditSaveTimestampRef, reloadPreview, + forceReloadSdkSession, isRecordingRef, }); return { handleTimelineElementMove, + handleTimelineElementsMove, handleTimelineElementResize, + handleTimelineElementsDelete, handleToggleTrackHidden, handleToggleElementHidden, handleTimelineElementDelete, @@ -594,7 +564,7 @@ export function useTimelineEditing({ handleRazorSplitAll, handleTimelineAssetDrop, handleTimelineFileDrop, + handleAddAssetAtPlayhead, handleBlockedTimelineEdit, - ...groupEditing, }; } diff --git a/packages/studio/src/hooks/useTimelineEditingTypes.ts b/packages/studio/src/hooks/useTimelineEditingTypes.ts index b8cc2c7538..da7927c9fa 100644 --- a/packages/studio/src/hooks/useTimelineEditingTypes.ts +++ b/packages/studio/src/hooks/useTimelineEditingTypes.ts @@ -43,3 +43,8 @@ export interface UseTimelineEditingOptions { forceReloadSdkSession?: () => void; handleDomZIndexReorderCommitRef?: MutableRefObject; } + +export type TimelineFileDropHandler = ( + files: File[], + placement?: { start: number; track: number }, +) => Promise; diff --git a/packages/studio/src/hooks/useTimelineGroupEditing.ts b/packages/studio/src/hooks/useTimelineGroupEditing.ts deleted file mode 100644 index 0ad9d365d7..0000000000 --- a/packages/studio/src/hooks/useTimelineGroupEditing.ts +++ /dev/null @@ -1,370 +0,0 @@ -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; - coalesceKey?: string; -} - -interface UseTimelineGroupEditingOptions { - activeCompPath: string | null; - domEditSaveTimestampRef: MutableRefObject; - editQueueRef: MutableRefObject>; - forceReloadSdkSession?: () => void; - isRecordingRef?: RefObject; - pendingTimelineEditPathRef: MutableRefObject>; - previewIframeRef: RefObject; - projectIdRef: MutableRefObject; - recordEdit: (input: RecordEditInput) => Promise; - reloadPreview: () => void; - sdkSession?: Composition | null; - showToast: (message: string, tone?: "error" | "info") => void; - writeProjectFile: (path: string, content: string) => Promise; -} - -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): Promise => { - 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 => 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 => 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 }; -} diff --git a/packages/studio/src/index.ts b/packages/studio/src/index.ts index b9a08e6628..1da7407a66 100644 --- a/packages/studio/src/index.ts +++ b/packages/studio/src/index.ts @@ -1,5 +1,5 @@ // NLE Layout -export { NLELayout } from "./components/nle/NLELayout"; +export { EditorShell } from "./components/EditorShell"; export { NLEPreview } from "./components/nle/NLEPreview"; export { CompositionBreadcrumb } from "./components/nle/CompositionBreadcrumb"; export type { CompositionLevel } from "./components/nle/CompositionBreadcrumb"; diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index 70ca13c7f3..9a2078a48b 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -375,6 +375,7 @@ export function TimelineLanes({ pointerOffsetY: e.clientY - rect.top, previewStart: el.start, previewTrack: el.track, + desiredTrack: el.track, insertRow: null, snapTime: null, snapType: null, diff --git a/packages/studio/src/player/components/timelineCallbacks.ts b/packages/studio/src/player/components/timelineCallbacks.ts index 1b68cc88ea..d209486c0b 100644 --- a/packages/studio/src/player/components/timelineCallbacks.ts +++ b/packages/studio/src/player/components/timelineCallbacks.ts @@ -1,12 +1,7 @@ // fallow-ignore-file code-duplication // 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"; +import type { BlockedTimelineEditIntent } from "./timelineEditing"; /** * Shared callback signatures for timeline editing operations. @@ -31,31 +26,20 @@ export interface TimelineDropCallbacks { export interface TimelineEditCallbacks { onMoveElement?: ( element: TimelineElement, - updates: Pick & { - stackingReorder?: TimelineStackingReorderIntent | null; - }, + updates: Pick, + ) => Promise | void; + /** Atomic multi-clip move (single undo) for main-track ripple + track-insert. + * `coalesceKey` (drag-commit gesture id) merges the move history entry with a + * lane change's follow-up z-reorder entry into one undo step. */ + onMoveElements?: ( + edits: Array<{ element: TimelineElement; updates: Pick }>, + coalesceKey?: string, ) => Promise | void; onResizeElement?: ( element: TimelineElement, updates: Pick, ) => Promise | void; - /** - * Batched move. Method syntax (bivariant) + union parameter so both the - * legacy group-editing shape (TimelineGroupMoveChange) and the NLE edit - * shape (TimelineGroupMoveEdit) type-check while both engines coexist. - */ - onMoveElements?( - changes: Array, - options?: TimelineGroupCommitOptions, - ): Promise | void; - onResizeElements?: ( - changes: TimelineGroupResizeChange[], - options?: TimelineGroupCommitOptions, - ) => Promise | void; - onPreviewMoveElements?: (changes: TimelineGroupMoveChange[]) => void; - onPreviewResizeElements?: (changes: TimelineGroupResizeChange[]) => void; onToggleTrackHidden?: (track: number, hidden: boolean) => Promise | void; - onToggleElementHidden?: (elementKey: string, hidden: boolean) => Promise | void; onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; onSplitElement?: (element: TimelineElement, splitTime: number) => Promise | void; onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise | void; @@ -71,13 +55,3 @@ export interface TimelineEditCallbacks { ) => void; onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void; } - -/** - * NLE batched-move edit: the element plus exactly the fields the move changes. - * The legacy engine's TimelineGroupMoveChange carries resolved absolute values - * instead; onMoveElements accepts either while the engines coexist. - */ -export interface TimelineGroupMoveEdit { - element: TimelineElement; - updates: Pick; -} diff --git a/packages/studio/src/player/components/timelineClipDragCommit.test.ts b/packages/studio/src/player/components/timelineClipDragCommit.test.ts index 72dbcaa82a..111fff02c2 100644 --- a/packages/studio/src/player/components/timelineClipDragCommit.test.ts +++ b/packages/studio/src/player/components/timelineClipDragCommit.test.ts @@ -6,6 +6,13 @@ import { type DragCommitDeps, type TimelineMoveEdit, } from "./timelineClipDragCommit"; +import { + buildEditHistoryEntry, + createEmptyEditHistory, + pushEditHistoryEntry, +} from "../../utils/editHistory"; +import { normalizeToZones } from "./timelineZones"; +import type { StackingPatch } from "./timelineStackingSync"; function el( id: string, @@ -14,12 +21,27 @@ function el( duration: number, tag = "video", ): TimelineElement { - return { id, key: id, tag, start, duration, track }; + // domId gives the row a patchable target so getTimelineEditCapabilities().canMove + // is true (the capabilities gate in commitDraggedClipMove filters on it). + return { id, key: id, tag, start, duration, track, domId: id }; +} + +/** Flush the microtask chain: the z-sync now fires only after the move persist + * promise resolves (serialized), so tests asserting on it must await. */ +async function flushMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); } function drag( element: TimelineElement, - opts: { previewStart: number; previewTrack: number; insertRow?: number | null }, + opts: { + previewStart: number; + previewTrack: number; + desiredTrack?: number; + insertRow?: number | null; + }, ): DraggedClipState { return { element, @@ -33,6 +55,9 @@ function drag( pointerOffsetY: 0, previewStart: opts.previewStart, previewTrack: opts.previewTrack, + // Defaults to previewTrack (a lane change) when a test doesn't distinguish a + // horizontal collision bump — the commit's `desiredTrack ?? previewTrack`. + desiredTrack: opts.desiredTrack, insertRow: opts.insertRow ?? null, snapTime: null, snapType: null, @@ -63,17 +88,78 @@ function runClipMove( return { updateElement, onMoveElement, onMoveElements }; } +/** A drag onto another lane where the two clips do NOT time-overlap must issue no + * stacking patch (the dragged clip is elements[0]). Awaits the serialized z-sync. */ +async function expectNoStackingPatchOnNonOverlapLaneChange( + elements: TimelineElement[], +): Promise { + const onStackingPatches = vi.fn(); + runClipMove(drag(elements[0], { previewStart: 0, previewTrack: 0 }), { + elements, + trackOrder: [0, 1], + readZIndex: () => 0, + onStackingPatches, + }); + await flushMicrotasks(); + expect(onStackingPatches).not.toHaveBeenCalled(); +} + +type MoveMap = Record; + /** Assert a lane change persisted atomically (single onMoveElements, no single * onMoveElement) and return the resulting id → {start, track} edit map. */ -function expectAtomicMoveMap(spies: { - onMoveElement: Mock; - onMoveElements: Mock; -}): Record { +function expectAtomicMoveMap(spies: { onMoveElement: Mock; onMoveElements: Mock }): MoveMap { expect(spies.onMoveElement).not.toHaveBeenCalled(); expect(spies.onMoveElements).toHaveBeenCalledTimes(1); return editMap(spies.onMoveElements.mock.calls[0][0]); } +// Two time-overlapping clips carrying authored z (a below at z=1, b on top at +// z=5) — the bed for the z-sync / lane-change tests. +const overlapping = (): TimelineElement[] => [ + { id: "a", key: "a", tag: "video", start: 0, duration: 10, track: 1, zIndex: 1, domId: "a" }, + { id: "b", key: "b", tag: "video", start: 0, duration: 10, track: 0, zIndex: 5, domId: "b" }, +]; +const zOf = (e: TimelineElement) => ({ a: 1, b: 5 })[e.key ?? e.id] ?? 0; + +// Commit an "insert a above b" lane change: the drop lifts a's z, so both the +// move persist and the z-sync fire. Tests drive onMoveElements differently +// (immediate / pending / rejecting), so the persist deps are supplied per call. +function commitInsertAbove( + elements: TimelineElement[], + deps: Partial & Pick, +): void { + commitDraggedClipMove(drag(elements[0], { previewStart: 0, previewTrack: 1, insertRow: 0 }), { + elements, + trackOrder: [0, 1], + updateElement: vi.fn(), + onMoveElement: vi.fn(), + readZIndex: zOf, + ...deps, + }); +} + +// The edited clip `a` is lifted above b(5) → 6, issued as one stacking patch. +function expectZLiftedToSix(onStackingPatches: Mock): void { + expect(onStackingPatches).toHaveBeenCalledTimes(1); + expect(onStackingPatches.mock.calls[0][0]).toEqual([{ key: "a", zIndex: 6 }]); +} + +// A marquee (a+b selected) time-move of the dragged clip; returns the persist +// spies plus the resulting id → {start, track} edit map. +function runMarqueeMove( + dragged: TimelineElement, + previewStart: number, + elements: TimelineElement[], +): { updateElement: Mock; onMoveElement: Mock; onMoveElements: Mock; map: MoveMap } { + const spies = runClipMove(drag(dragged, { previewStart, previewTrack: 0 }), { + elements, + trackOrder: [0, 1], + selectedKeys: new Set(["a", "b"]), + }); + return { ...spies, map: editMap(spies.onMoveElements.mock.calls[0][0]) }; +} + describe("commitDraggedClipMove", () => { it("pure time-move (same lane) persists just the dragged clip (single, SDK-aware)", () => { const elements = [el("v1", 1, 0, 5)]; @@ -87,17 +173,18 @@ describe("commitDraggedClipMove", () => { expect(onMoveElement.mock.calls[0][1]).toEqual({ start: 6, track: 1 }); }); - it("a lane change re-normalizes and persists EVERY clip atomically (fixes raw-vs-normalized collision)", () => { + it("a plain lane change persists ONLY the dragged clip (CapCut: never re-lanes others)", () => { // Move 'a' from lane 0 down onto lane 1 (b's lane) at a non-overlapping time. + // The CapCut rule: editing one clip must never rewrite another. Only 'a' is + // persisted — with its new lane (previewTrack 1) — and 'b' is left untouched. const elements = [el("a", 0, 0, 3), el("b", 1, 10, 3)]; - const { onMoveElement, onMoveElements } = runClipMove( - drag(elements[0], { previewStart: 20, previewTrack: 1 }), + const { onMoveElements } = runClipMove( + drag(elements[0], { previewStart: 20, previewTrack: 1, desiredTrack: 1 }), { elements, trackOrder: [0, 1] }, ); - // BOTH clips persist atomically with consistent normalized tracks (both visual → lane 0). - const map = expectAtomicMoveMap({ onMoveElement, onMoveElements }); - expect(map.a).toEqual({ start: 20, track: 0 }); - expect(map.b).toEqual({ start: 10, track: 0 }); + const map = editMap(onMoveElements.mock.calls[0][0]); + expect(map.a).toEqual({ start: 20, track: 1 }); + expect(map.b).toBeUndefined(); // the other clip is NOT rewritten }); it("multi-selection time-move shifts EVERY selected clip by the drag delta (atomic)", () => { @@ -116,11 +203,7 @@ describe("commitDraggedClipMove", () => { it("multi-selection move clamps shifted clips at 0 and applies the store update optimistically", () => { const elements = [el("a", 0, 6, 3), el("b", 1, 2, 3)]; // Drag 'a' −5s: b would land at −3 → clamps to 0. - const { updateElement, onMoveElements } = runClipMove( - drag(elements[0], { previewStart: 1, previewTrack: 0 }), - { elements, trackOrder: [0, 1], selectedKeys: new Set(["a", "b"]) }, - ); - const map = editMap(onMoveElements.mock.calls[0][0]); + const { updateElement, map } = runMarqueeMove(elements[0], 1, elements); expect(map.a).toEqual({ start: 1, track: 0 }); expect(map.b).toEqual({ start: 0, track: 1 }); expect(updateElement).toHaveBeenCalledWith("a", { start: 1, track: 0 }); @@ -138,31 +221,26 @@ describe("commitDraggedClipMove", () => { expect(onMoveElement.mock.calls[0][1]).toEqual({ start: 6, track: 0 }); }); - it("multi-selection lane change: dragged clip changes track, the rest of the selection shifts in time only", () => { + it("multi-selection lane change: dragged clip changes track, selection shifts in time, others untouched", () => { const elements = [el("a", 0, 0, 3), el("b", 1, 10, 3), el("c", 2, 20, 3)]; // Drag 'a' +4s down onto lane 1 (non-overlapping with b) while {a, c} selected. const { onMoveElements } = runClipMove( - drag(elements[0], { previewStart: 4, previewTrack: 1 }), + drag(elements[0], { previewStart: 4, previewTrack: 1, desiredTrack: 1 }), { elements, trackOrder: [0, 1, 2], selectedKeys: new Set(["a", "c"]) }, ); const map = editMap(onMoveElements.mock.calls[0][0]); - expect(map.a.start).toBe(4); // dragged: new time + new (normalized) lane - expect(map.c.start).toBe(24); // selected passenger: same +4 delta - expect(map.c.track).not.toBe(map.a.track); // passenger keeps its own lane - expect(map.b.start).toBe(10); // unselected: time untouched - }); - - it("inserting a new lane re-packs the whole set into contiguous lanes (single atomic persist)", () => { - // a,b,c all start=0 dur=5 → mutually overlapping, all equal z (absent ⇒ 0). - // The insert drops c at a fractional lane between a and b. Under the per-clip - // constrained pack, equal-z overlapping clips lay out by DOM order (later on - // top): c (last in DOM) → lane 0, b → lane 1, a → lane 2. (Was pinned to - // a=0/c=1/b=2 by the old whole-track packer, which used the fractional-track - // value for ordering; the new pack encodes insert INTENT via the z-sync path - // instead, and lanes reflect true canvas paint order.) Contiguous 0..2, one - // atomic persist for all three. + expect(map.a).toEqual({ start: 4, track: 1 }); // dragged: new time + new lane + expect(map.c).toEqual({ start: 24, track: 2 }); // passenger: same +4 delta, own lane + expect(map.b).toBeUndefined(); // unselected clip is NOT rewritten + }); + + it("inserting a new lane places the dragged clip at the aimed row and +1-shifts the clips below", () => { + // a,b,c all start=0 dur=5 → mutually overlapping. Insert drops c on a NEW lane + // at row 1 (between a and b). The CapCut renumber: c lands at row 1, a keeps + // row 0, and b (at/below the insert) shifts down by one to row 2. Lane order + // now follows track-index (a, c, b) — the ONLY sanctioned multi-clip write, + // index-only. Contiguous 0..2, one atomic persist. const elements = [el("a", 0, 0, 5), el("b", 1, 0, 5), el("c", 2, 0, 5)]; - // insert a new lane at row 1 (between a and b) with c. const { onMoveElements } = runClipMove( drag(elements[2], { previewStart: 0, previewTrack: 2, insertRow: 1 }), { elements, trackOrder: [0, 1, 2] }, @@ -171,13 +249,13 @@ describe("commitDraggedClipMove", () => { const map = editMap(onMoveElements.mock.calls[0][0]); // Lanes are contiguous and distinct (no two overlapping clips share a lane). expect(new Set([map.a.track, map.b.track, map.c.track])).toEqual(new Set([0, 1, 2])); - expect(map.c.track).toBe(0); // last in DOM → top lane - expect(map.b.track).toBe(1); - expect(map.a.track).toBe(2); + expect(map.a.track).toBe(0); // above the insert → unchanged + expect(map.c.track).toBe(1); // dragged clip lands on the new lane + expect(map.b.track).toBe(2); // at/below the insert → +1 shift }); describe("lane ↔ stacking sync", () => { - it("lane change raises the edited clip's z above a time-overlapping lower-lane clip", () => { + it("lane change raises the edited clip's z above a time-overlapping lower-lane clip", async () => { // a & b overlap in time. Elements carry their authored z (as real discovery // populates TimelineElement.zIndex from the DOM), so the per-clip pack lays // them out by z: b (z=5) tops, a (z=1) below. The user drags a UP onto the @@ -187,8 +265,26 @@ describe("commitDraggedClipMove", () => { // key-order tie-break placing a on top, which contradicted canvas paint for // equal z — the elements now carry z and the drop intent is an insert above.) const elements: TimelineElement[] = [ - { id: "a", key: "a", tag: "video", start: 0, duration: 10, track: 1, zIndex: 1 }, - { id: "b", key: "b", tag: "video", start: 0, duration: 10, track: 0, zIndex: 5 }, + { + id: "a", + key: "a", + tag: "video", + start: 0, + duration: 10, + track: 1, + zIndex: 1, + domId: "a", + }, + { + id: "b", + key: "b", + tag: "video", + start: 0, + duration: 10, + track: 0, + zIndex: 5, + domId: "b", + }, ]; const z: Record = { a: 1, b: 5 }; const onStackingPatches = vi.fn(); @@ -199,43 +295,540 @@ describe("commitDraggedClipMove", () => { readZIndex: (e) => z[e.key ?? e.id] ?? 0, onStackingPatches, }); + // z-sync is serialized after the move persist → deferred to a microtask. + await flushMicrotasks(); // Only `a` (the edited clip) is patched, lifted above b(5) → 6. - expect(onStackingPatches).toHaveBeenCalledTimes(1); - expect(onStackingPatches.mock.calls[0][0]).toEqual([{ key: "a", zIndex: 6 }]); + expectZLiftedToSix(onStackingPatches); }); - it("no z-sync deps → no stacking side-effects (pure time-move path safe)", () => { + it("partial z-sync deps (no readZIndex) → move persists but no stacking call", async () => { const elements = [el("a", 1, 0, 10), el("b", 0, 0, 10)]; - // No readZIndex/onStackingPatches supplied → must not throw, no patches. - runClipMove(drag(elements[0], { previewStart: 0, previewTrack: 0 }), { + // onStackingPatches present but readZIndex absent → syncStackingForEdit needs + // BOTH, so it must issue zero patches even though this IS a lane change. + const onStackingPatches = vi.fn(); + const { onMoveElements } = runClipMove( + drag(elements[0], { previewStart: 0, previewTrack: 0 }), + { elements, trackOrder: [0, 1], onStackingPatches }, + ); + await flushMicrotasks(); + // The move still persists atomically; no z patch is issued, no stacking call. + expect(onMoveElements).toHaveBeenCalledTimes(1); + expect(onStackingPatches).not.toHaveBeenCalled(); + }); + + it("no time overlap → no stacking patch even on a lane change", async () => { + await expectNoStackingPatchOnNonOverlapLaneChange([el("a", 1, 0, 5), el("b", 0, 10, 5)]); + }); + + it("pure time-move (no lane change) never triggers a stacking patch", () => { + const elements = [el("a", 0, 0, 10), el("b", 0, 0, 10)]; + const onStackingPatches = vi.fn(); + // same track → not a topology change → z-sync branch not reached. + runClipMove(drag(elements[0], { previewStart: 3, previewTrack: 0 }), { elements, - trackOrder: [0, 1], + trackOrder: [0], + readZIndex: () => 0, + onStackingPatches, }); - // (nothing to assert beyond "did not throw") + expect(onStackingPatches).not.toHaveBeenCalled(); }); + }); - it("no time overlap → no stacking patch even on a lane change", () => { - const elements = [el("a", 1, 0, 5), el("b", 0, 10, 5)]; + describe("drop-intent lane realization — the dragged clip DISPLAYS on the aimed lane", () => { + // CapCut-stable lanes follow the track-index, so a gap INSERT lands the dragged + // clip exactly where aimed: it takes the new lane at the insert row and the + // clips at/below shift down by one (the sanctioned renumber). z never enters + // lane assignment, so a low-z clip aimed at the top gets the top lane regardless + // of z. These are full preview→commit→normalize round trips: apply the persisted + // track edits (and any z patches), then re-normalize — the lanes come purely + // from the track-index renumber, never from z. + const vz = ( + id: string, + track: number, + start: number, + duration: number, + zIndex: number, + ): TimelineElement => ({ + id, + key: id, + tag: "video", + start, + duration, + track, + zIndex, + domId: id, + }); + + async function roundTripLane( + elements: TimelineElement[], + dragState: DraggedClipState, + trackOrder: number[], + ): Promise> { + let edits: TimelineMoveEdit[] = []; + let patches: StackingPatch[] = []; + const z = new Map(elements.map((e) => [e.key ?? e.id, (e.zIndex as number) ?? 0])); + commitDraggedClipMove(dragState, { + elements, + trackOrder, + updateElement: vi.fn(), + onMoveElement: vi.fn(), + onMoveElements: (e: TimelineMoveEdit[]) => { + edits = e; + }, + readZIndex: (el) => z.get(el.key ?? el.id) ?? 0, + onStackingPatches: (p: StackingPatch[]) => { + patches = p; + }, + }); + await flushMicrotasks(); + const te = new Map(edits.map((e) => [e.element.key ?? e.element.id, e.updates])); + const ze = new Map(patches.map((p) => [p.key, p.zIndex])); + const persisted = elements.map((e) => { + const k = e.key ?? e.id; + const t = te.get(k); + return { + ...e, + start: t ? t.start : e.start, + track: t ? t.track : e.track, + zIndex: ze.has(k) ? ze.get(k)! : e.zIndex, + }; + }); + const lane: Record = {}; + for (const e of normalizeToZones(persisted)) lane[e.key ?? e.id] = e.track; + return lane; + } + + it("TOP-insert of a clip that OVERLAPS NOTHING lands it on the top lane (not its z-rank)", async () => { + // top(z20)/mid(z15) share the upper lanes; dragged(z2) is sequential (no time + // overlap with anything) and low-z, so a global z-sort would sink it. Aim: top. + const elements = [vz("top", 0, 0, 3, 20), vz("mid", 5, 3, 1, 15), vz("dragged", 1, 30, 3, 2)]; + const lane = await roundTripLane( + elements, + drag(elements[2], { previewStart: 30, previewTrack: 1, insertRow: 0 }), + [0, 1], + ); + expect(lane.dragged).toBe(0); // aimed at the very top + expect(lane.top).toBe(1); + expect(lane.mid).toBe(2); + }); + + it("BETWEEN-insert of a non-overlapping clip lands it between its neighbours", async () => { + const elements = [vz("a", 0, 0, 3, 9), vz("b", 1, 0, 3, 5), vz("x", 2, 20, 5, 1)]; + const lane = await roundTripLane( + elements, + drag(elements[2], { previewStart: 20, previewTrack: 2, insertRow: 1 }), + [0, 1, 2], + ); + expect(lane.a).toBe(0); + expect(lane.x).toBe(1); // between a and b, as aimed + expect(lane.b).toBe(2); + }); + + it("TOP-insert clears a NON-overlapping clip that currently tops the timeline", async () => { + // X overlaps M but NOT T (T tops the timeline, disjoint in time). Aiming X at + // the top must lift it past T even though they never overlap. + const elements = [vz("T", 0, 0, 3, 9), vz("M", 1, 10, 5, 5), vz("X", 2, 10, 5, 1)]; + const lane = await roundTripLane( + elements, + drag(elements[2], { previewStart: 10, previewTrack: 2, insertRow: 0 }), + [0, 1, 2], + ); + expect(lane.X).toBe(0); // aimed top, cleared the non-overlapping T + }); + + it("dragging X among overlapping neighbours preserves the RELATIVE order of the others (symptom 2)", async () => { + // Four mutually-overlapping clips. Baseline lanes by z: n1,n2,x,n3. Drag x to + // the top. Everything above x shifts down by one to make room (unavoidable), + // but no TWO non-dragged clips may swap — a lane change of X must not reshuffle + // its neighbours among themselves. + const elements = [ + vz("n1", 0, 0, 10, 4), + vz("n2", 1, 0, 10, 3), + vz("x", 2, 0, 10, 2), + vz("n3", 3, 0, 10, 1), + ]; + const baseOrder = normalizeToZones(elements) + .slice() + .sort((a, b) => a.track - b.track) + .map((e) => e.id) + .filter((id) => id !== "x"); + const lane = await roundTripLane( + elements, + drag(elements[2], { previewStart: 0, previewTrack: 2, insertRow: 0 }), + [0, 1, 2, 3], + ); + expect(lane.x).toBe(0); // X lands where aimed + const finalOrder = Object.keys(lane) + .sort((a, b) => lane[a] - lane[b]) + .filter((id) => id !== "x"); + expect(finalOrder).toEqual(baseOrder); // neighbours keep their relative order + }); + + it("a drop that merely SHARES a lane with a non-overlapping neighbour issues no z nudge", async () => { + // a → b's lane; they don't overlap → they share the lane, aim already met, so + // no stacking patch (byte-identical to the pre-fix no-op path). + await expectNoStackingPatchOnNonOverlapLaneChange([ + vz("a", 1, 0, 5, 0), + vz("b", 0, 10, 5, 0), + ]); + }); + }); + + describe("BUG 1 — a plain horizontal move never patches z (fixture repro)", () => { + // Mirror the user's index.html: v-moodboard alone on the top display lane (0), + // highest z, over lower-lane clips it OVERLAPS in time. A horizontal drag (the + // preview yields insertRow=null, previewTrack unchanged) must be a pure time + // move: single-clip persist, and NOT a single z patch or history entry — even + // with the z-sync deps fully wired and time-overlapping neighbours present. + const fixture = (): TimelineElement[] => [ + { + id: "v-moodboard", + key: "v-moodboard", + tag: "video", + start: 19, + duration: 5.5, + track: 0, + zIndex: 37, + domId: "v-moodboard", + }, + { + id: "v-dashboard", + key: "v-dashboard", + tag: "video", + start: 19, + duration: 4, + track: 1, + zIndex: 16, + domId: "v-dashboard", + }, + { + id: "v-globe", + key: "v-globe", + tag: "video", + start: 23, + duration: 1.5, + track: 1, + zIndex: 17, + domId: "v-globe", + }, + { + id: "cap", + key: "cap", + tag: "text", + start: 20.82, + duration: 1.78, + track: 2, + zIndex: 0, + domId: "cap", + }, + ]; + + it("+2s on its own lane → single onMoveElement, zero stacking patches", async () => { + const elements = fixture(); + const z: Record = { "v-moodboard": 37, "v-dashboard": 16, "v-globe": 17 }; + const onStackingPatches = vi.fn(); + // previewTrack === element.track (0) and insertRow null → pure time move. + const { onMoveElement, onMoveElements } = runClipMove( + drag(elements[0], { previewStart: 21, previewTrack: 0 }), + { + elements, + trackOrder: [0, 1, 2], + readZIndex: (e) => z[e.key ?? e.id] ?? 0, + onStackingPatches, + }, + ); + await flushMicrotasks(); + expect(onMoveElement).toHaveBeenCalledTimes(1); + expect(onMoveElement.mock.calls[0][1]).toEqual({ start: 21, track: 0 }); + expect(onMoveElements).not.toHaveBeenCalled(); + expect(onStackingPatches).not.toHaveBeenCalled(); // zero z patches / history entries + }); + + it("guard: a topology call that AIMS at the clip's own display lane issues no z nudge", async () => { + // Belt-and-suspenders: even if a spurious insert whose boundary equals the + // clip's own lane slips into the topology branch, aiming at the current lane + // must never restack (syncStackingForEdit's aimedLane === currentLane no-op). + const elements = fixture(); + const z: Record = { "v-moodboard": 37, "v-dashboard": 16, "v-globe": 17 }; const onStackingPatches = vi.fn(); - runClipMove(drag(elements[0], { previewStart: 0, previewTrack: 0 }), { + // insertRow 0 === v-moodboard's own display lane (0). + runClipMove(drag(elements[0], { previewStart: 21, previewTrack: 0, insertRow: 0 }), { + elements, + trackOrder: [0, 1, 2], + readZIndex: (e) => z[e.key ?? e.id] ?? 0, + onStackingPatches, + }); + await flushMicrotasks(); + expect(onStackingPatches).not.toHaveBeenCalled(); + }); + }); + + describe("horizontal drag among overlapping neighbours touches exactly ONE clip (live repro)", () => { + // The disease: a plain horizontal drag of one caption rewrote its own track, + // added a z-index, and rewrote FOUR other clips' track-indexes. The cure: a + // horizontal move writes ONLY the dragged clip's start — no other clip's + // start/track, no z — even surrounded by time-overlapping clips. + it("writes only the dragged clip's start; no other clip, no z", async () => { + const elements: TimelineElement[] = [ + { + id: "cap", + key: "cap", + tag: "text", + start: 4.5, + duration: 1.2, + track: 0, + zIndex: 26, + domId: "cap", + }, + { + id: "n1", + key: "n1", + tag: "video", + start: 4, + duration: 3, + track: 1, + zIndex: 12, + domId: "n1", + }, + { + id: "n2", + key: "n2", + tag: "video", + start: 4, + duration: 3, + track: 2, + zIndex: 19, + domId: "n2", + }, + { + id: "n3", + key: "n3", + tag: "text", + start: 5, + duration: 2, + track: 3, + zIndex: 25, + domId: "n3", + }, + ]; + const onStackingPatches = vi.fn(); + // Pure horizontal: previewTrack === element.track (0), no insert. + const { updateElement, onMoveElement, onMoveElements } = runClipMove( + drag(elements[0], { previewStart: 5.5, previewTrack: 0, desiredTrack: 0 }), + { + elements, + trackOrder: [0, 1, 2, 3], + readZIndex: (e) => (e.zIndex as number) ?? 0, + onStackingPatches, + }, + ); + await flushMicrotasks(); + // Exactly one clip written, start only, no z entry. + expect(updateElement).toHaveBeenCalledTimes(1); + expect(updateElement).toHaveBeenCalledWith("cap", { start: 5.5, track: 0 }); + expect(onMoveElement).toHaveBeenCalledTimes(1); + expect(onMoveElements).not.toHaveBeenCalled(); + expect(onStackingPatches).not.toHaveBeenCalled(); + }); + }); + + describe("horizontal collision relocation (ITEM 2 — dragged clip only, never z)", () => { + it("a horizontal drag bumped to a free lane writes ONLY the dragged clip, and never z", async () => { + // The pointer stayed on lane 0 (desiredTrack === element.track), but the + // target span was occupied there, so the collision rules relocated the DRAGGED + // clip to lane 1 (previewTrack 1). That is not a deliberate vertical move: + // persist just the dragged clip (new start + relocated lane), rewrite no other + // clip, and issue zero z patches even though it now overlaps a neighbour. + const elements: TimelineElement[] = [ + { id: "a", key: "a", tag: "video", start: 0, duration: 5, track: 0, zIndex: 2, domId: "a" }, + { id: "d", key: "d", tag: "video", start: 0, duration: 5, track: 2, zIndex: 0, domId: "d" }, + ]; + const onStackingPatches = vi.fn(); + const { onMoveElements } = runClipMove( + // desiredTrack 0 (pointer never left lane 0) but previewTrack 1 (bumped). + drag(elements[0], { previewStart: 2, previewTrack: 1, desiredTrack: 0 }), + { + elements, + trackOrder: [0, 1, 2], + readZIndex: (e) => (e.zIndex as number) ?? 0, + onStackingPatches, + }, + ); + await flushMicrotasks(); + const map = editMap(onMoveElements.mock.calls[0][0]); + expect(map.a).toEqual({ start: 2, track: 1 }); // dragged clip relocated + expect(map.d).toBeUndefined(); // untouched + expect(onStackingPatches).not.toHaveBeenCalled(); // horizontal → never z + }); + }); + + describe("capabilities gate (ITEM 2)", () => { + const locked = ( + id: string, + track: number, + start: number, + duration: number, + ): TimelineElement => ({ + ...el(id, track, start, duration), + timelineLocked: true, + }); + + it("a marquee containing a locked clip never persists an edit for the locked clip", () => { + const dragged = el("a", 0, 2, 3); + const elements = [dragged, locked("b", 1, 10, 3)]; + // Pure time-move +5 on the same lane while {a, b} are marquee-selected. + const { map } = runMarqueeMove(dragged, 7, elements); + expect(map.a).toEqual({ start: 7, track: 0 }); + expect(map.b).toBeUndefined(); // locked → filtered out of the moving set + }); + + it("a plain lane change never persists a locked neighbour (only the dragged clip is written)", async () => { + const dragged = el("a", 0, 0, 3); + const elements = [dragged, locked("b", 1, 0, 3)]; + // Drag a onto b's lane. A plain lane change writes ONLY the dragged clip, so + // the locked neighbour b is inherently untouched (never even a candidate). + const { onMoveElements } = runClipMove(drag(dragged, { previewStart: 0, previewTrack: 1 }), { elements, trackOrder: [0, 1], + }); + await flushMicrotasks(); + const map = editMap(onMoveElements.mock.calls[0][0]); + expect(map.a).toBeDefined(); // movable dragged clip persists + expect(map.b).toBeUndefined(); // locked clip receives no patch + }); + + it("a lane change that produces ZERO move edits fires no z-sync (no orphaned z entry, ITEM 5)", async () => { + // Every clip in the set is locked (including the dragged one), so the moving + // set filters down to nothing. persistMoveEdits resolves true for the empty + // batch, but the z-sync must NOT fire — otherwise a "Reorder layers" history + // entry lands with no corresponding move. + const dragged = locked("a", 0, 0, 5); + const elements = [dragged, locked("b", 0, 0, 5)]; + const onMoveElements = vi.fn(); + const onStackingPatches = vi.fn(); + // Drag onto lane 1 (a topology change) so the lane-change branch runs. + commitDraggedClipMove(drag(dragged, { previewStart: 0, previewTrack: 1, insertRow: 0 }), { + elements, + trackOrder: [0], + updateElement: vi.fn(), + onMoveElement: vi.fn(), + onMoveElements, readZIndex: () => 0, onStackingPatches, }); + await flushMicrotasks(); + expect(onMoveElements).not.toHaveBeenCalled(); // empty batch → no persist call + expect(onStackingPatches).not.toHaveBeenCalled(); // and no orphaned z entry + }); + }); + + describe("z-sync serialization + rollback (ITEM 3)", () => { + it("defers the z-sync until the move persist resolves (no clobbering pre-write)", async () => { + const elements = overlapping(); + let resolveMove!: () => void; + const onMoveElements = vi.fn(() => new Promise((r) => (resolveMove = r))); + const onStackingPatches = vi.fn(); + commitInsertAbove(elements, { onMoveElements, onStackingPatches }); + await flushMicrotasks(); + // Move persist still pending → the z patch must NOT have been issued yet. + expect(onMoveElements).toHaveBeenCalledTimes(1); expect(onStackingPatches).not.toHaveBeenCalled(); + resolveMove(); + await flushMicrotasks(); + // Only after the write lands does the z patch fire — once, for the edited clip. + expectZLiftedToSix(onStackingPatches); }); - it("pure time-move (no lane change) never triggers a stacking patch", () => { - const elements = [el("a", 0, 0, 10), el("b", 0, 0, 10)]; + it("rolls back the move and skips the z-sync when the persist fails", async () => { + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const elements = overlapping(); + const onMoveElements = vi.fn(() => Promise.reject(new Error("write failed"))); const onStackingPatches = vi.fn(); - // same track → not a topology change → z-sync branch not reached. - runClipMove(drag(elements[0], { previewStart: 3, previewTrack: 0 }), { + const updateElement = vi.fn(); + commitInsertAbove(elements, { updateElement, onMoveElements, onStackingPatches }); + await flushMicrotasks(); + // Failed move → z patch never issued (no orphaned z change left behind)... + expect(onStackingPatches).not.toHaveBeenCalled(); + // ...and the optimistic start/track edit for the dragged clip is rolled back. + expect(updateElement).toHaveBeenCalledWith("a", { start: 0, track: 1 }); + errSpy.mockRestore(); + }); + }); + + describe("lane-change undo coalescing (ITEM 3c)", () => { + const commitLaneChange = (elements: TimelineElement[]) => { + const onMoveElements = vi.fn(); + const onStackingPatches = vi.fn(); + commitInsertAbove(elements, { onMoveElements, onStackingPatches }); + return { onMoveElements, onStackingPatches }; + }; + + it("threads ONE shared coalesceKey to both the move persist and the z-sync, so the two records merge into a single undo entry", async () => { + const { onMoveElements, onStackingPatches } = commitLaneChange(overlapping()); + await flushMicrotasks(); + + // Both sides receive the SAME non-empty gesture key (second arg). + const moveKey = onMoveElements.mock.calls[0][1]; + const zKey = onStackingPatches.mock.calls[0][1]; + expect(typeof moveKey).toBe("string"); + expect(moveKey).not.toBe(""); + expect(zKey).toBe(moveKey); + + // With that shared key, editHistory folds the two consecutive records (the + // "Move timeline clips" write + the "Reorder layers" z patch, same file, + // inside the coalesce window) into ONE undo entry spanning before→after. + const now = 1_000; + const moveEntry = buildEditHistoryEntry({ + id: "m", + projectId: "p", + label: "Move timeline clips", + kind: "timeline", + coalesceKey: moveKey, + now, + files: { "index.html": { before: "", after: "" } }, + }); + const zEntry = buildEditHistoryEntry({ + id: "z", + projectId: "p", + label: "Reorder layers", + kind: "timeline", + coalesceKey: zKey, + now: now + 50, + files: { "index.html": { before: "", after: "" } }, + }); + const state = pushEditHistoryEntry( + pushEditHistoryEntry(createEmptyEditHistory(), moveEntry), + zEntry, + ); + expect(state.undo).toHaveLength(1); + expect(state.undo[0].files["index.html"]).toMatchObject({ before: "", after: "" }); + }); + + it("distinct gestures get distinct keys (independent moves never cross-merge)", async () => { + const { onMoveElements: first } = commitLaneChange(overlapping()); + const { onMoveElements: second } = commitLaneChange(overlapping()); + await flushMicrotasks(); + expect(first.mock.calls[0][1]).not.toBe(second.mock.calls[0][1]); + }); + + it("a plain move that issues no z patch persists once and records no second entry (unchanged)", async () => { + // a & b do NOT overlap in time → the z-sync produces zero patches. + const elements = [el("a", 1, 0, 5), el("b", 0, 10, 5)]; + const onMoveElements = vi.fn(); + const onStackingPatches = vi.fn(); + commitDraggedClipMove(drag(elements[0], { previewStart: 0, previewTrack: 0 }), { elements, - trackOrder: [0], + trackOrder: [0, 1], + updateElement: vi.fn(), + onMoveElement: vi.fn(), + onMoveElements, readZIndex: () => 0, onStackingPatches, }); + await flushMicrotasks(); + // Move persists exactly once; no z entry is created → nothing to merge, so + // the single "Move timeline clips" entry stands alone as before. + expect(onMoveElements).toHaveBeenCalledTimes(1); expect(onStackingPatches).not.toHaveBeenCalled(); }); }); diff --git a/packages/studio/src/player/components/timelineClipDragCommit.ts b/packages/studio/src/player/components/timelineClipDragCommit.ts index b02ac1bb03..a36be7d1f4 100644 --- a/packages/studio/src/player/components/timelineClipDragCommit.ts +++ b/packages/studio/src/player/components/timelineClipDragCommit.ts @@ -2,6 +2,7 @@ import type { TimelineElement } from "../store/playerStore"; import type { DraggedClipState } from "./useTimelineClipDrag"; import { classifyZone, normalizeToZones } from "./timelineZones"; import { computeStackingPatches, type StackingPatch } from "./timelineStackingSync"; +import { getTimelineEditCapabilities } from "./timelineEditing"; type StartTrack = Pick; export interface TimelineMoveEdit { @@ -15,8 +16,11 @@ export interface DragCommitDeps { updateElement: (key: string, updates: Partial) => void; /** Single-clip, SDK-cutover-aware persist (pure time-moves keep this path). */ onMoveElement?: (element: TimelineElement, updates: StartTrack) => Promise | void; - /** Atomic multi-clip persist (single undo) for lane changes + track inserts. */ - onMoveElements?: (edits: TimelineMoveEdit[]) => Promise | void; + /** Atomic multi-clip persist (single undo) for lane changes + track inserts. + * `coalesceKey`, when supplied, tags the resulting "Move timeline clips" + * history entry so it merges with the lane change's z-reorder entry (see the + * lane-change branch below). */ + onMoveElements?: (edits: TimelineMoveEdit[], coalesceKey?: string) => Promise | void; /** * The current multi-selection (store.selectedElementIds). When the dragged * clip is part of a multi-selection (size > 1), the WHOLE selection moves by @@ -25,12 +29,13 @@ export interface DragCommitDeps { */ selectedKeys?: ReadonlySet | null; /** - * Lane ↔ stacking unification. When a lane change happens, the edited clip(s) - * get z-index patches so their stacking matches lane order (higher lane = on - * top) relative to time-overlapping clips — see timelineStackingSync. Both - * deps must be supplied to engage; if either is absent the z-sync is skipped - * (pure time-moves never restack). `readZIndex` returns the clip's current - * z-index (from the live DOM inline style / computed; "auto" ⇒ 0). + * Lane ↔ stacking unification. When a DELIBERATE vertical lane change happens, + * the edited clip(s) get z-index patches so their canvas stacking matches lane + * order (higher lane = on top) relative to time-overlapping clips — see + * timelineStackingSync. Both deps must be supplied to engage; if either is + * absent the z-sync is skipped (pure time-moves and horizontal collision bumps + * never restack). `readZIndex` returns the clip's current z-index (from the + * live DOM inline style / computed; "auto" ⇒ 0). */ readZIndex?: (element: TimelineElement) => number; /** @@ -39,22 +44,62 @@ export interface DragCommitDeps { * the canvas z-order commit uses (handleDomZIndexReorderCommit). Documented in * research/STAGE3-NEEDED-WIRING.md. */ - onStackingPatches?: (patches: StackingPatch[]) => void; + onStackingPatches?: (patches: StackingPatch[], coalesceKey?: string) => void; } const keyOf = (e: TimelineElement) => e.key ?? e.id; const round3 = (v: number) => Math.round(v * 1000) / 1000; +// One coalesce key per lane-change gesture, shared by the move-persist history +// entry ("Move timeline clips") and the follow-up z-reorder entry ("Reorder +// layers") so editHistory (pushEditHistoryEntry) folds the two consecutive +// records into a single undo step. A monotonic counter — NOT Date.now() / +// Math.random(), which the determinism rules forbid — suffices: the key only has +// to be unique per gesture and identical across the gesture's two records. +let laneChangeGestureSeq = 0; + +/** Whether Studio may write timing to this clip (false for locked/implicit rows). */ +function canMoveElement(element: TimelineElement): boolean { + return getTimelineEditCapabilities({ + tag: element.tag, + duration: element.duration, + domId: element.domId, + selector: element.selector, + compositionSrc: element.compositionSrc, + playbackStart: element.playbackStart, + playbackStartAttr: element.playbackStartAttr, + sourceDuration: element.sourceDuration, + timingSource: element.timingSource, + timelineLocked: element.timelineLocked, + }).canMove; +} + /** * Optimistically apply + persist a batch of moves with rollback on failure. * - * Fire-and-forget: the persistence promise is intentionally NOT awaited (the caller - * returns void). The DOM is updated synchronously up front and the `.catch` rolls the - * optimistic edits back if the async write rejects, so the UI never blocks on I/O. + * Returns a promise that resolves `true` once the write lands, or `false` after a + * rejected write has been rolled back. The caller uses this to SERIALIZE the + * lane→z stacking patch: the z-sync is a separate server style-patch, and firing + * it before this full-file write resolves let the move (computed from a pre-z + * snapshot) land after — and clobber — the z change. A failed move resolves + * `false` so the caller also skips the z-sync (no orphaned z patch). + * + * The DOM is updated synchronously up front; the returned promise never rejects. */ -function persistMoveEdits(edits: TimelineMoveEdit[], deps: DragCommitDeps): void { - if (edits.length === 0) return; +function persistMoveEdits( + edits: TimelineMoveEdit[], + deps: DragCommitDeps, + coalesceKey?: string, +): Promise { + if (edits.length === 0) return Promise.resolve(true); const { updateElement, onMoveElement, onMoveElements } = deps; + if (!onMoveElements) { + console.warn( + onMoveElement + ? `[Timeline] persistMoveEdits: only single-clip onMoveElement wired — this ${edits.length}-clip move degrades to a per-clip persist race (no atomic single-undo)` + : `[Timeline] persistMoveEdits: no move persist handler wired — ${edits.length} edit(s) applied to the store only, not saved`, + ); + } const prev = edits.map((e) => ({ key: keyOf(e.element), start: e.element.start, @@ -62,18 +107,23 @@ function persistMoveEdits(edits: TimelineMoveEdit[], deps: DragCommitDeps): void })); for (const e of edits) updateElement(keyOf(e.element), e.updates); const persisted = onMoveElements - ? onMoveElements(edits) + ? onMoveElements(edits, coalesceKey) : Promise.all(edits.map((e) => Promise.resolve(onMoveElement?.(e.element, e.updates)))); - Promise.resolve(persisted).catch((error) => { - for (const p of prev) updateElement(p.key, { start: p.start, track: p.track }); - console.error("[Timeline] Failed to persist clip edits", error); - }); + return Promise.resolve(persisted).then( + () => true, + (error) => { + for (const p of prev) updateElement(p.key, { start: p.start, track: p.track }); + console.error("[Timeline] Failed to persist clip edits", error); + return false; + }, + ); } /** * A fractional track value for a NEW lane inserted at boundary `insertRow` in * `trackOrder` (0 = above the top, `length` = below the bottom). normalizeToZones - * then compacts it to a distinct integer lane between its neighbours. + * then compacts it to a distinct integer lane between its neighbours, and the + * clips at/below the insert shift down by one — the sanctioned index-renumber. */ function insertTrackValue(trackOrder: number[], insertRow: number): number { if (trackOrder.length === 0) return 0; @@ -82,40 +132,73 @@ function insertTrackValue(trackOrder: number[], insertRow: number): number { return (trackOrder[insertRow - 1] + trackOrder[insertRow]) / 2; } +/** + * Build the time-shift resolver for a multi-selection drag: every member of the + * selection moves by the dragged clip's delta (clamped ≥ 0); non-members are + * untouched. Returns null when this is not a multi-selection drag. A locked / + * implicit member is dropped from the moving set (a marquee can sweep one in). + */ +function resolveMultiSelection( + drag: DraggedClipState, + deps: DragCommitDeps, +): { keys: ReadonlySet; movedStart: (e: TimelineElement) => number } | null { + const { elements, selectedKeys } = deps; + const dragKey = keyOf(drag.element); + if (!selectedKeys || selectedKeys.size <= 1 || !selectedKeys.has(dragKey)) return null; + const keys = new Set( + [...selectedKeys].filter((k) => { + const el = elements.find((e) => keyOf(e) === k); + return el ? canMoveElement(el) : false; + }), + ); + const delta = drag.previewStart - drag.element.start; + const movedStart = (e: TimelineElement): number => + keyOf(e) === dragKey ? drag.previewStart : Math.max(0, round3(e.start + delta)); + return { keys, movedStart }; +} + /** * Commit a finished clip drag. * - * - **Pure time-move** (same lane): persist just the dragged clip's start via the - * SDK-aware single-clip handler. - * - **Lane change / new track**: apply the move (a fractional track for an insert), - * RE-NORMALIZE the whole element set (normalizeToZones) so display track indices - * are contiguous + kind-grouped, and persist EVERY clip atomically (single undo). - * This is the fix for the raw-vs-normalized collision: persisting only the dragged - * clip left other clips' unchanged source indices to clash on reload → overlap. + * The lane model is CapCut-stable: a clip's display lane is its track, and editing + * ONE clip must never re-lane or rewrite OTHER clips. Three outcomes: + * + * - **Pure time-move** (dragged clip keeps its lane, no insert): persist just the + * dragged clip's start (multi-selection shifts every selected clip in time). + * - **Lane change / collision relocation** (the dragged clip's OWN lane changes, + * no new track): persist ONLY the dragged clip's start + lane. No other clip is + * touched. z is synced only when the gesture is a DELIBERATE vertical move + * (the pointer aimed at another lane) — a horizontal drag merely bumped to a + * free lane never restacks. + * - **Track insert** (a new lane at a gap boundary): the dragged clip lands on + * the new lane and the clips at/below the insert are renumbered by +1 (the ONLY + * permitted multi-clip write) via a whole-set re-normalize; persisted atomically. */ // fallow-ignore-next-line complexity export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDeps): void { - const { elements, trackOrder, updateElement, onMoveElement, selectedKeys } = deps; + const { elements, updateElement, onMoveElement } = deps; const dragKey = keyOf(drag.element); - const isTopologyChange = drag.insertRow != null || drag.previewTrack !== drag.element.track; - // Multi-selection drag: engaged only when the dragged clip is itself part of - // a multi-selection. Every selected clip shifts by the same time delta - // (clamped ≥ 0); only the dragged clip changes track. - const multiKeys = - selectedKeys && selectedKeys.size > 1 && selectedKeys.has(dragKey) ? selectedKeys : null; - const delta = drag.previewStart - drag.element.start; - const movedStart = (e: TimelineElement): number => - keyOf(e) === dragKey ? drag.previewStart : Math.max(0, round3(e.start + delta)); + const isInsert = drag.insertRow != null; + const laneChanged = drag.previewTrack !== drag.element.track; + // Deliberate VERTICAL gesture: the pointer aimed at a different lane, or at a + // gap boundary (insert). A plain HORIZONTAL drag whose target span is occupied + // gets the DRAGGED clip bumped to a free lane (previewTrack differs) while the + // pointer never left its lane (desiredTrack === element.track) — that is NOT a + // vertical move: it must neither rewrite other clips nor touch z. + const aimTrack = drag.desiredTrack ?? drag.previewTrack; + const isVertical = isInsert || aimTrack !== drag.element.track; + const multi = resolveMultiSelection(drag, deps); - // ── Pure time-move (same lane) ────────────────────────────────────────────── - if (!isTopologyChange) { + // ── Pure time-move (dragged clip keeps its lane, no insert) ───────────────── + if (!isInsert && !laneChanged) { + const delta = drag.previewStart - drag.element.start; if (delta === 0) return; - if (multiKeys) { + if (multi) { const edits: TimelineMoveEdit[] = elements - .filter((e) => multiKeys.has(keyOf(e))) - .map((e) => ({ element: e, updates: { start: movedStart(e), track: e.track } })) + .filter((e) => multi.keys.has(keyOf(e))) + .map((e) => ({ element: e, updates: { start: multi.movedStart(e), track: e.track } })) .filter((e) => e.updates.start !== e.element.start); - persistMoveEdits(edits, deps); + void persistMoveEdits(edits, deps); return; } const updates = { start: drag.previewStart, track: drag.element.track }; @@ -128,58 +211,148 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe return; } - // ── Lane change / new track: normalize the whole set, persist all atomically ─ - const targetTrack = - drag.insertRow != null ? insertTrackValue(trackOrder, drag.insertRow) : drag.previewTrack; + // ── Track insert: renumber the at/below clips by +1 (the one multi-clip write) ─ + if (isInsert) { + commitTrackInsert(drag, deps, multi); + return; + } + + // ── Lane change / collision relocation: persist ONLY the dragged clip ──────── + // CapCut invariant — one edit never re-lanes another clip. The dragged clip + // takes its new lane (previewTrack); the rest of any selection shifts in time + // only. Nothing else is written. + const dragEdit: TimelineMoveEdit = { + element: drag.element, + updates: { start: drag.previewStart, track: drag.previewTrack }, + }; + const coalesceKey = isVertical ? `clip-lane-move:${laneChangeGestureSeq++}` : undefined; + + const edits: TimelineMoveEdit[] = [dragEdit]; + if (multi) { + for (const e of elements) { + if (keyOf(e) === dragKey || !multi.keys.has(keyOf(e))) continue; + const start = multi.movedStart(e); + if (start !== e.start) edits.push({ element: e, updates: { start, track: e.track } }); + } + } + // The drop-intent set for the z-sync: the dragged clip at its new lane, others + // as-is. Reasoning on this (not a re-normalize) keeps the sync seeing the user's + // move; computeStackingPatches only compares lanes relatively. + const candidate = elements.map((e) => + keyOf(e) === dragKey ? { ...e, start: drag.previewStart, track: drag.previewTrack } : e, + ); + const multiKeys = multi ? multi.keys : null; + void persistMoveEdits(edits, deps, coalesceKey).then((moved) => { + if (moved && isVertical) { + syncStackingForEdit( + candidate, + dragKey, + drag.element.track, + drag.previewTrack, + multiKeys, + deps, + coalesceKey, + ); + } + }); +} + +/** + * Insert a new track at the drop's gap boundary. The dragged clip lands on the + * fractional insert lane; normalizeToZones then compacts every lane to a contiguous + * integer, which shifts the clips at/below the insert down by one. That +1 + * renumber is the ONLY sanctioned multi-clip write; it is index-only (never z). + * The whole affected set is persisted atomically (single undo), and the deliberate + * vertical move syncs the dragged clip's stacking afterwards. + */ +function commitTrackInsert( + drag: DraggedClipState, + deps: DragCommitDeps, + multi: { keys: ReadonlySet; movedStart: (e: TimelineElement) => number } | null, +): void { + const { elements, trackOrder } = deps; + const dragKey = keyOf(drag.element); + const targetTrack = insertTrackValue(trackOrder, drag.insertRow!); + // Drop-intent set: dragged clip at the fractional insert lane (so it sorts + // between its neighbours), selection members time-shifted, others as-is. const candidate = elements.map((e) => { if (keyOf(e) === dragKey) return { ...e, start: drag.previewStart, track: targetTrack }; - if (multiKeys?.has(keyOf(e))) return { ...e, start: movedStart(e) }; + if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) }; return e; }); + // normalizeToZones compacts the fractional lane to a contiguous integer, which + // shifts the at/below clips down by one — the sanctioned +1 index renumber. const normalized = normalizeToZones(candidate); const bySrc = new Map(elements.map((e) => [keyOf(e), e])); const edits: TimelineMoveEdit[] = []; for (const norm of normalized) { const src = bySrc.get(keyOf(norm)); if (!src) continue; + // Capabilities gate: never write a locked/implicit clip, even one only swept + // along by the renumber (not just a marquee member). + if (!canMoveElement(src)) continue; const start = - keyOf(norm) === dragKey || multiKeys?.has(keyOf(norm)) ? movedStart(src) : src.start; + keyOf(norm) === dragKey || multi?.keys.has(keyOf(norm)) + ? (multi?.movedStart(src) ?? drag.previewStart) + : src.start; edits.push({ element: src, updates: { start, track: norm.track } }); } - persistMoveEdits(edits, deps); - - // Lane ↔ stacking: patch the edited clip(s)' z-index so their stacking matches - // the user's DROP-INTENT lane order relative to time-overlapping clips. We must - // reason on `candidate` (the drop-intent tracks) — NOT `normalized` — because - // normalizeToZones is purely z/DOM-driven and re-packs a lane-drag that - // contradicts z straight back down; reading the post-normalize lane would make - // the sync see the reverted layout and never realise the user's move. The - // fractional drop track (e.g. −0.5 for "above the top lane") preserves the drop - // order against the other clips' tracks. Only the edited clip(s) change; the - // untouched clips' authored z stays sacred (unless a cascade is unavoidable). - syncStackingForEdit(candidate, dragKey, multiKeys, deps); + + const coalesceKey = `clip-lane-move:${laneChangeGestureSeq++}`; + void persistMoveEdits(edits, deps, coalesceKey).then((moved) => { + // Skip the z-sync when the insert produced NO move edits (e.g. every clip in + // the set is locked/implicit and gets filtered out). persistMoveEdits resolves + // `true` for an empty batch so the caller's serialization proceeds, but firing + // the z-sync here would record an orphaned z-only history entry for a move that + // never persisted. + if (moved && edits.length > 0) { + // Reason the z-sync on the drop-intent `candidate` (dragged clip at its + // fractional insert lane) — NOT the re-normalized lanes — so the sync sees + // the user's move. The guard lane is the aimed insert row (a boundary in + // display-lane space, comparable to the clip's contiguous current lane). + syncStackingForEdit( + candidate, + dragKey, + drag.element.track, + drag.insertRow!, + multi ? multi.keys : null, + deps, + coalesceKey, + ); + } + }); } /** - * Compute + apply z-index patches for the edited clip(s) after a lane change. - * Projects the DROP-INTENT element set (`candidate`: edited clip at its dropped - * fractional track, others at their current tracks) onto StackingElement using - * the caller-supplied live z-index reader, then delegates the minimal-z - * resolution to computeStackingPatches. No-op unless both z-sync deps are present. + * Compute + apply z-index patches for the edited clip(s) after a DELIBERATE + * vertical lane change. Projects the drop-intent element set (`candidate`: the + * dragged clip at its new / fractional-insert lane, others at their current tracks) + * onto StackingElement using the caller-supplied live z-index reader, then + * delegates the minimal-z resolution to computeStackingPatches — a clip on the + * upper lane paints above every clip it time-overlaps. No-op unless both z-sync + * deps are present, and never when the gesture aimed at the clip's OWN current + * lane (`aimedLane === currentLane` — not a relocation). */ function syncStackingForEdit( candidate: TimelineElement[], dragKey: string, + currentLane: number, + aimedLane: number, multiKeys: ReadonlySet | null, deps: DragCommitDeps, + coalesceKey?: string, ): void { const { readZIndex, onStackingPatches } = deps; if (!readZIndex || !onStackingPatches) return; + // Aiming at the clip's OWN current display lane is not a relocation — never + // touch z (guards the pure-time-move invariant even if a spurious topology call + // slips through). Every real lane-realization drop aims at a DIFFERENT lane. + if (aimedLane === currentLane) return; + // `candidate` is in discovery order, so its array index IS the DOM document // position. Equal-z clips paint by DOM order, so the sync needs it to decide - // "is A above B" (see StackingElement.domIndex) — without it a bottom-lane drag - // over an equal-z neighbour could no-op on canvas. + // "is A above B" (see StackingElement.domIndex). const stackingEls = candidate.map((el, domIndex) => ({ key: keyOf(el), start: el.start, @@ -194,5 +367,5 @@ function syncStackingForEdit( if (multiKeys) for (const k of multiKeys) if (k !== dragKey) editedKeys.push(k); const patches = computeStackingPatches(stackingEls, editedKeys); - if (patches.length > 0) onStackingPatches(patches); + if (patches.length > 0) onStackingPatches(patches, coalesceKey); } diff --git a/packages/studio/src/player/components/timelineClipDragPreview.test.ts b/packages/studio/src/player/components/timelineClipDragPreview.test.ts new file mode 100644 index 0000000000..fc1a4ca2bf --- /dev/null +++ b/packages/studio/src/player/components/timelineClipDragPreview.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; +import type { TimelineElement } from "../store/playerStore"; +import { computeDragPreview, type DragPreviewContext } from "./timelineClipDragPreview"; +import type { DraggedClipState } from "./timelineClipDragTypes"; +import { RULER_H, TRACKS_TOP_PAD, TRACK_H } from "./timelineLayout"; + +// ───────────────────────────────────────────────────────────────────────────── +// Regression bed for the live-reproduced BUG 1: a PLAIN HORIZONTAL drag of a clip +// on its own top lane armed a phantom new-track insert (the old 0.32 insert band +// reached deep into the clip body). That insert flipped the commit into the +// lane-change branch, which nudged the clip's z-index and re-sorted it off its +// lane. The invariant: a horizontal drag over a clip BODY → insertRow === null, +// previewTrack unchanged (a pure time move — zero topology change, zero z sync). +// +// Elements mirror the user's index.html shapes: a high-z "v-moodboard" alone on +// the top display lane, over several lower-lane video clips it overlaps in time, +// plus a caption. Tracks here are already the normalized DISPLAY lanes (the store +// runs normalizeToZones on discovery), matching what the drag hook passes in. +// ───────────────────────────────────────────────────────────────────────────── + +const PPS = 40; + +function clip( + id: string, + track: number, + start: number, + duration: number, + zIndex: number, + tag = "video", +): TimelineElement { + return { id, key: id, tag, start, duration, track, zIndex, domId: id }; +} + +// v-moodboard: own top lane (0). Lower lane (1) carries overlapping video clips; +// captions sit on lane 2. trackOrder = [0, 1, 2]. +const moodboard = clip("v-moodboard", 0, 19, 5.5, 37); +const fixtureElements: TimelineElement[] = [ + moodboard, + clip("v-dashboard", 1, 19, 4, 16), + clip("v-globe", 1, 23, 1.5, 17), + clip("cap", 2, 20.82, 1.78, 0, "text"), +]; + +// A scroll container whose content-space y equals clientY (rect top 0, no scroll). +function fakeScroll(): HTMLDivElement { + return { + getBoundingClientRect: () => ({ top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0 }), + scrollLeft: 0, + scrollTop: 0, + scrollWidth: 100000, + } as unknown as HTMLDivElement; +} + +function ctx(): DragPreviewContext { + return { + scroll: fakeScroll(), + pps: PPS, + duration: 44.5, + trackOrder: [0, 1, 2], + elements: fixtureElements, + selectedKeys: new Set(), + buildSnapTargets: () => [], + audioTracks: new Set(), + }; +} + +// content-space y for a fractional row index (inverse of getTimelineRowFromY). +const yForRow = (rowFloat: number) => RULER_H + TRACKS_TOP_PAD + rowFloat * TRACK_H; + +// A drag grabbing `element` at vertical position `grabRowFloat` within its lane. +function horizontalDrag( + element: TimelineElement, + grabRowFloat: number, + deltaSeconds: number, +): { drag: DraggedClipState; clientX: number; clientY: number } { + const originClientX = 800; + const originClientY = yForRow(grabRowFloat); + const drag: DraggedClipState = { + element, + originClientX, + originClientY, + originScrollLeft: 0, + originScrollTop: 0, + pointerClientX: originClientX, + pointerClientY: originClientY, + pointerOffsetX: 0, + pointerOffsetY: 0, + previewStart: element.start, + previewTrack: element.track, + insertRow: null, + snapTime: null, + snapType: null, + started: true, + }; + // Horizontal: clientY stays at the grab point; only x advances by the delta. + return { drag, clientX: originClientX + deltaSeconds * PPS, clientY: originClientY }; +} + +describe("computeDragPreview — plain horizontal drag never arms a phantom insert (BUG 1)", () => { + it("dragging v-moodboard +2s while grabbing its clip body keeps it a pure time move", () => { + const { drag, clientX, clientY } = horizontalDrag(moodboard, 0.5, 2); + const next = computeDragPreview(drag, clientX, clientY, ctx()); + expect(next.insertRow).toBeNull(); // no phantom new-track insert + expect(next.previewTrack).toBe(0); // stays on its own lane + expect(next.desiredTrack).toBe(0); // pointer never left lane 0 → not a vertical aim + expect(next.previewStart).toBeCloseTo(21, 5); // +2s moved + }); + + it("grabbing ANYWHERE across the clip body (not just dead-center) stays a pure time move", () => { + // Sweep the whole clip body of lane 0; a horizontal drag must never insert. + for (let grab = 0.1; grab <= 0.9 + 1e-9; grab += 0.1) { + const { drag, clientX, clientY } = horizontalDrag(moodboard, grab, 2); + const next = computeDragPreview(drag, clientX, clientY, ctx()); + expect(next.insertRow).toBeNull(); + expect(next.previewTrack).toBe(0); + } + }); + + it("aiming the gutter ABOVE the top lane arms a top insert (UX rule 2)", () => { + // Drag v-moodboard up into the top breathing pad → insert a new top track. + const originClientX = 800; + const originClientY = yForRow(0.5); + const drag: DraggedClipState = { + element: moodboard, + originClientX, + originClientY, + originScrollLeft: 0, + originScrollTop: 0, + pointerClientX: originClientX, + pointerClientY: originClientY, + pointerOffsetX: 0, + pointerOffsetY: 0, + previewStart: moodboard.start, + previewTrack: moodboard.track, + insertRow: null, + snapTime: null, + snapType: null, + started: true, + }; + // Pointer well above the first lane (into the top pad → rowFloat < 0). + const next = computeDragPreview(drag, originClientX, yForRow(-0.6), ctx()); + expect(next.insertRow).toBe(0); // a new TOP track will be created on drop + }); +}); diff --git a/packages/studio/src/player/components/timelineClipDragPreview.ts b/packages/studio/src/player/components/timelineClipDragPreview.ts index 4ba27f0164..30b06d3ea6 100644 --- a/packages/studio/src/player/components/timelineClipDragPreview.ts +++ b/packages/studio/src/player/components/timelineClipDragPreview.ts @@ -1,6 +1,6 @@ import { resolveTimelineMove, resolveTimelineResize } from "./timelineEditing"; import type { TimelineElement } from "../store/playerStore"; -import { TRACK_H, getTimelineRowFromY } from "./timelineLayout"; +import { TRACK_H, getTimelineRowFromY, INSERT_BOUNDARY_BAND } from "./timelineLayout"; import { isMusicTrack, isAudioTimelineElement } from "../../utils/timelineInspector"; import { TIMELINE_SNAP_PX, @@ -9,6 +9,10 @@ import { type TimelineSnapTarget, } from "./timelineSnapping"; import { resolveInsertRow, resolveZoneDropPlacement } from "./timelineCollision"; +import { + applyTimelineGroupResizePreview, + type TimelineGroupResizeSession, +} from "./timelineGroupEditing"; import { clampGroupMoveDelta } from "./timelineMultiDragPreview"; import type { DraggedClipState, ResizingClipState } from "./timelineClipDragTypes"; @@ -26,6 +30,13 @@ export interface DragPreviewContext { elements: TimelineElement[]; selectedKeys: ReadonlySet; buildSnapTargets: BuildSnapTargets; + /** + * The set of tracks that hold audio clips (drives zone-aware drop placement). + * Frozen for the whole gesture, so the hook builds it ONCE at drag start and + * passes it in — see useTimelineClipDrag. Absent (e.g. in unit tests) ⇒ built + * on demand from `elements`, so the result is identical either way. + */ + audioTracks?: ReadonlySet; } /** @@ -77,8 +88,15 @@ function resolveDropPlacement( const rowFloat = scroll ? getTimelineRowFromY(clientY - scroll.getBoundingClientRect().top + scroll.scrollTop) : 0; - const rawInsertRow = resolveInsertRow(rowFloat, trackOrder.length); - const audioTracks = new Set(elements.filter(isAudioTimelineElement).map((e) => e.track)); + // Geometry-exact band (the clip inset) so an insert only arms in the visible + // gutter BETWEEN clip bodies — dragging over a clip body is a lane move, never a + // phantom insert (the plain-horizontal-drag misfire). See INSERT_BOUNDARY_BAND. + const rawInsertRow = resolveInsertRow(rowFloat, trackOrder.length, INSERT_BOUNDARY_BAND); + // Pointer sub-row half: when a drop must auto-create a track (aimed span + // occupied, no free lane), open it on the side the pointer is nearer. + const preferInsertAbove = rowFloat - Math.floor(rowFloat) < 0.5; + const audioTracks = + ctx.audioTracks ?? new Set(elements.filter(isAudioTimelineElement).map((e) => e.track)); return resolveZoneDropPlacement({ order: trackOrder, audioTracks, @@ -89,6 +107,7 @@ function resolveDropPlacement( duration: drag.element.duration, dragKey: drag.element.key ?? drag.element.id, isAudio: isAudioTimelineElement(drag.element), + preferInsertAbove, }); } @@ -157,6 +176,9 @@ export function computeDragPreview( pointerClientY: clientY, previewStart, previewTrack, + // The lane the POINTER aims at (pre-collision): the commit reads it to tell a + // deliberate vertical lane change from a horizontal drag merely bumped sideways. + desiredTrack: nextMove.track, insertRow, snapTime: snap.snapTime, snapType: snap.snapType, @@ -284,3 +306,32 @@ export function computeResizePreview( previewPlaybackStart: nextResize.playbackStart, }; } + +/** + * Apply a rigid group-resize preview: fold the grabbed clip's raw delta into the + * session, preview every non-grabbed member through the store (`updateElement`), + * and set the grabbed clip's preview state (it renders from resizingClip state, so + * its store value stays pristine until commit — like the single-clip path). + */ +export function previewGroupResize( + session: TimelineGroupResizeSession, + next: ResizePreviewResult, + grabbedKey: string, + updateElement: ( + key: string, + patch: { start: number; duration: number; playbackStart?: number }, + ) => void, + setResizeState: (v: ResizePreviewResult) => void, +): void { + const grabbedChange = applyTimelineGroupResizePreview(session, next); + for (const c of session.changes) { + if (c.key === grabbedKey) continue; + updateElement(c.key, { start: c.start, duration: c.duration, playbackStart: c.playbackStart }); + } + setResizeState({ + originScrollLeft: next.originScrollLeft, + previewStart: grabbedChange?.start ?? next.previewStart, + previewDuration: grabbedChange?.duration ?? next.previewDuration, + previewPlaybackStart: grabbedChange?.playbackStart ?? next.previewPlaybackStart, + }); +} diff --git a/packages/studio/src/player/components/timelineEditing.test.ts b/packages/studio/src/player/components/timelineEditing.test.ts index f2c2913ead..44d9d79a52 100644 --- a/packages/studio/src/player/components/timelineEditing.test.ts +++ b/packages/studio/src/player/components/timelineEditing.test.ts @@ -4,19 +4,16 @@ import { buildPromptCopyText, buildTimelineElementAgentPrompt, buildTimelineAgentPrompt, - clampTimelineGroupResizeDelta, getTimelineEditCapabilities, hasPatchableTimelineTarget, resolveBlockedTimelineEditIntent, resolveTimelineAutoScroll, + resolveTimelineDragEscape, resolveTimelineMove, resolveTimelineResize, - resolveTimelineGroupMove, - resolveTimelineGroupResize, - snapKeyframePctToBeat, + MAGNETIC_TRACK_THRESHOLD, type TimelinePromptElement, } from "./timelineEditing"; -import { buildStackingTimelineLayers } from "./timelineTrackOrder"; describe("resolveTimelineMove", () => { it("moves timing based on horizontal drag and snaps to centiseconds", () => { @@ -161,217 +158,38 @@ describe("resolveTimelineMove", () => { ).toEqual({ start: 2, track: 2 }); }); - it("snaps conflicting vertical stacking movement to a new lane without changing data-track-index", () => { - const stackingElements = [ - { - id: "root-front", - tag: "div", - start: 0, - duration: 2, - track: 0, - zIndex: 2, - hasExplicitZIndex: true, - stackingContextId: "root", - parentCompositionId: null, - compositionAncestors: ["root"], - }, - { - id: "root-back", - tag: "div", - start: 0, - duration: 2, - track: 1, - zIndex: 1, - hasExplicitZIndex: true, - stackingContextId: "root", - parentCompositionId: null, - compositionAncestors: ["root"], - }, - ]; - const layers = buildStackingTimelineLayers(stackingElements).rows; - const result = resolveTimelineMove( - { - start: 0, - track: 1, - duration: 2, - originClientX: 0, - originClientY: 0, - pixelsPerSecond: 100, - trackHeight: 72, - maxStart: 8, - trackOrder: [0, 1], - layerOrder: layers.map((layer) => layer.id), - timelineLayers: layers, - stackingElement: stackingElements[1], - stackingElements, - }, - 0, - -72, - ); - - expect(result).toEqual({ - start: 0, + describe("magnetic vertical threshold", () => { + const base = { + start: 1, track: 1, - previewLayerId: `preview:root-back:above:${layers[0]!.id}`, - previewLayerIndex: 0, - stackingReorder: { - contextKey: "root", - placement: { type: "above", layerId: layers[0]!.id }, - zIndexChanges: [{ key: "root-back", zIndex: 3 }], - }, - }); - }); -}); - -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 }, - ], + duration: 2, + originClientX: 100, + originClientY: 200, + pixelsPerSecond: 100, + trackHeight: 100, + maxStart: 8, + trackOrder: [0, 1, 2, 3, 4], + }; + + it("defaults to 0.5 (midpoint parity with rounding)", () => { + expect(MAGNETIC_TRACK_THRESHOLD).toBe(0.5); }); - }); - 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 }, - ], + it("stays on the current track below the threshold", () => { + // 40px / 100px track = 0.4 < 0.5 → no lane change + expect(resolveTimelineMove(base, 100, 240).track).toBe(1); }); - 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 }, - ], + it("commits to the next track past the threshold", () => { + // 60px / 100px track = 0.6 > 0.5 → one lane down + expect(resolveTimelineMove(base, 100, 260).track).toBe(2); }); - 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 }, - ], + it("commits one lane at a time for larger deltas", () => { + // 160px / 100px = 1.6 → two lanes down + expect(resolveTimelineMove(base, 100, 360).track).toBe(3); + // upward: -160px → two lanes up (clamped by trackOrder) + expect(resolveTimelineMove({ ...base, track: 3 }, 100, 40).track).toBe(1); }); }); }); @@ -693,7 +511,7 @@ describe("buildTimelineAgentPrompt", () => { prompt: "Move the title later and lower the music", }); - expect(text).toContain("Time range: 00:01 - 00:04"); + expect(text).toContain("Time range: 00:01 — 00:04"); expect(text).toContain("#title (div)"); expect(text).toContain("#music (audio)"); expect(text).toContain("Move the title later and lower the music"); @@ -716,6 +534,26 @@ describe("buildTimelineElementAgentPrompt", () => { }); }); describe("resolveTimelineResize", () => { + it("extends past the composition end when maxEnd is the source limit only (Infinity)", () => { + // Content-driven duration: trimming the LAST clip rightward must be able to + // grow past the current composition end — the comp grows to fit on commit. + // The caller passes maxEnd = start + sourceRemaining (Infinity for images). + expect( + resolveTimelineResize( + { + start: 1, + duration: 3, + originClientX: 100, + pixelsPerSecond: 100, + minStart: 0, + maxEnd: Number.POSITIVE_INFINITY, + }, + "end", + 700, // +6s drag → duration 9, ends at 10 — far past any prior comp end + ), + ).toEqual({ start: 1, duration: 9, playbackStart: undefined }); + }); + it("shrinks clip duration from the right edge", () => { expect( resolveTimelineResize( @@ -833,33 +671,44 @@ describe("buildPromptCopyText", () => { }); }); -describe("snapKeyframePctToBeat", () => { - // el spans 0–10s, so clip-% maps to composition time as pct * 0.1s. - // At pps=100 the snap window is 8 / 100 = 0.08s. - const el = { start: 0, duration: 10 }; - const beats = [2, 5, 8]; +describe("resolveTimelineDragEscape", () => { + const started = { started: true }; + const pending = { started: false }; + const none = { drag: null, resize: null, blocked: null }; - it("snaps a keyframe within ~8px of a beat exactly onto it", () => { - // pct 50.5 → 5.05s, 0.05s from the beat at 5s (inside 0.08s window) → 50%. - expect(snapKeyframePctToBeat(el, 50.5, beats, 100)).toBe(50); - }); - - it("leaves a keyframe unchanged when no beat is within the window", () => { - // pct 55 → 5.5s, 0.5s from the nearest beat → free. - expect(snapKeyframePctToBeat(el, 55, beats, 100)).toBe(55); + it("cancels only on Escape", () => { + expect(resolveTimelineDragEscape({ key: "Enter", ...none, drag: started })).toEqual({ + cancel: false, + suppressClick: false, + }); + expect(resolveTimelineDragEscape({ key: "Escape", ...none, drag: started })).toEqual({ + cancel: true, + suppressClick: true, + }); }); - it("is a no-op when there are no beats", () => { - expect(snapKeyframePctToBeat(el, 50.5, [], 100)).toBe(50.5); - expect(snapKeyframePctToBeat(el, 50.5, undefined, 100)).toBe(50.5); + it("does nothing when no gesture is in progress", () => { + expect(resolveTimelineDragEscape({ key: "Escape", ...none })).toEqual({ + cancel: false, + suppressClick: false, + }); }); - it("is a no-op for a zero-duration clip", () => { - expect(snapKeyframePctToBeat({ start: 0, duration: 0 }, 50.5, beats, 100)).toBe(50.5); + it("cancels a resize or a pending blocked-drag", () => { + expect(resolveTimelineDragEscape({ key: "Escape", ...none, resize: started })).toEqual({ + cancel: true, + suppressClick: true, + }); + expect(resolveTimelineDragEscape({ key: "Escape", ...none, blocked: pending })).toEqual({ + cancel: true, + suppressClick: false, + }); }); - it("widens the snap window as zoom (pps) decreases", () => { - // pct 53 → 5.3s, 0.3s from the beat at 5s. At pps=20 the window is 0.4s → snaps to 50%. - expect(snapKeyframePctToBeat(el, 53, beats, 20)).toBe(50); + it("suppresses the pointerup click only for gestures past the drag threshold", () => { + expect(resolveTimelineDragEscape({ key: "Escape", ...none, drag: pending })).toEqual({ + cancel: true, + suppressClick: false, + }); }); }); diff --git a/packages/studio/src/player/components/timelineEditing.ts b/packages/studio/src/player/components/timelineEditing.ts index 685f96e7c4..23a6275868 100644 --- a/packages/studio/src/player/components/timelineEditing.ts +++ b/packages/studio/src/player/components/timelineEditing.ts @@ -1,28 +1,5 @@ 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, -} from "./timelineStacking"; const roundToCentiseconds = roundToCenti; @@ -31,6 +8,11 @@ function clamp(value: number, min: number, max: number): number { } const EDGE_TRACK_CREATE_THRESHOLD = 0.55; +// Magnetic vertical drag: the clip stays on its current track until the pointer +// is dragged past this fraction of a track height into a neighbor, then commits +// one lane at a time. 0.5 reproduces plain midpoint rounding (regression-safe +// default); lower = more eager lane changes, higher = stickier. Tuned by feel. +export const MAGNETIC_TRACK_THRESHOLD = 0.5; const AUTO_SCROLL_EDGE_ZONE = 40; const AUTO_SCROLL_MAX_SPEED = 12; @@ -48,12 +30,6 @@ export interface TimelineMoveInput { trackHeight: number; maxStart: number; trackOrder: number[]; - layerOrder?: TimelineLayerId[]; - timelineLayers?: StackingTimelineLayer[]; - /** When provided, vertical movement is resolved as a z-index stacking reorder - * within `stackingElement`'s context instead of a raw track change. */ - stackingElement?: TimelineStackingElement; - stackingElements?: TimelineStackingElement[]; } export interface TimelineResizeInput { @@ -98,54 +74,90 @@ export function resolveTimelineAutoScroll( }; } +/** + * Apply one edge auto-scroll step: scroll `scroll` toward the edge zone the + * pointer is in, clamped to the scrollable range. Returns true when the + * container actually moved (the caller keeps its RAF running and re-runs its + * live preview), false when the pointer is outside the edge zones or the scroll + * is already clamped (the caller stops). + */ +export function applyTimelineAutoScrollStep( + scroll: HTMLElement, + clientX: number, + clientY: number, +): boolean { + const delta = resolveTimelineAutoScroll(scroll.getBoundingClientRect(), clientX, clientY); + if (delta.x === 0 && delta.y === 0) return false; + 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 false; + scroll.scrollLeft = nextScrollLeft; + scroll.scrollTop = nextScrollTop; + return true; +} + +/** + * Decide whether an edge auto-scroll RAF loop should start, stop, or stay as-is + * for the current pointer: "start" when the pointer is in an edge zone and no + * loop is running, "stop" when it left the zones while a loop is running, + * "none" otherwise. + */ +export function resolveTimelineAutoScrollLoopAction( + scroll: HTMLElement | null, + clientX: number, + clientY: number, + rafActive: boolean, +): "start" | "stop" | "none" { + if (!scroll) return "none"; + const delta = resolveTimelineAutoScroll(scroll.getBoundingClientRect(), clientX, clientY); + if (delta.x === 0 && delta.y === 0) return rafActive ? "stop" : "none"; + return rafActive ? "none" : "start"; +} + +export interface TimelineDragEscapeInput { + key: string; + drag: { started: boolean } | null; + resize: { started: boolean } | null; + blocked: { started: boolean } | null; +} + +/** + * Escape cancels an in-progress clip drag / resize / blocked-drag: no commit, + * no undo entry — the previews live only in the gesture state, so clearing it + * restores the pre-drag timeline. `suppressClick` arms the click suppressor + * only when the gesture actually started, so the click generated by the + * eventual pointerup can't reselect or split the clip. + */ +export function resolveTimelineDragEscape(input: TimelineDragEscapeInput): { + cancel: boolean; + suppressClick: boolean; +} { + if (input.key !== "Escape" || (!input.drag && !input.resize && !input.blocked)) { + return { cancel: false, suppressClick: false }; + } + return { + cancel: true, + suppressClick: Boolean(input.drag?.started || input.resize?.started || input.blocked?.started), + }; +} + export function resolveTimelineMove( input: TimelineMoveInput, clientX: number, clientY: number, -): { - start: number; - track: number; - previewLayerId?: TimelineLayerId; - previewLayerIndex?: number; - stackingReorder?: TimelineStackingReorderIntent | null; -} { +): { start: number; track: number } { const scrollDeltaX = (input.currentScrollLeft ?? 0) - (input.originScrollLeft ?? 0); const scrollDeltaY = (input.currentScrollTop ?? 0) - (input.originScrollTop ?? 0); const deltaTime = (clientX - input.originClientX + scrollDeltaX) / Math.max(input.pixelsPerSecond, 1); const trackDeltaRaw = (clientY - input.originClientY + scrollDeltaY) / Math.max(input.trackHeight, 1); - const deltaTrack = Math.round(trackDeltaRaw); - const nextStart = clamp( - roundToCentiseconds(input.start + deltaTime), - 0, - Math.max(0, input.maxStart), - ); - - // 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 - // 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) { - const layerMove = - input.timelineLayers && input.layerOrder - ? resolveTimelineLayerStackingMove({ - element: { ...input.stackingElement, duration: input.duration }, - layers: input.timelineLayers, - layerOrder: input.layerOrder, - trackDeltaRaw, - }) - : null; - return { - start: nextStart, - track: input.track, - previewLayerId: layerMove?.previewLayerId, - previewLayerIndex: layerMove?.previewLayerIndex, - stackingReorder: layerMove?.stackingReorder ?? null, - }; - } - + // Magnetic commit: cross MAGNETIC_TRACK_THRESHOLD into a neighbor to move one + // lane. At 0.5 this equals Math.round(trackDeltaRaw). + const magnitude = Math.max(0, Math.floor(Math.abs(trackDeltaRaw) - MAGNETIC_TRACK_THRESHOLD) + 1); + const deltaTrack = trackDeltaRaw === 0 ? 0 : Math.sign(trackDeltaRaw) * magnitude; const currentTrackIndex = Math.max(0, input.trackOrder.indexOf(input.track)); const desiredTrackIndex = currentTrackIndex + deltaTrack; const nextTrackIndex = clamp(desiredTrackIndex, 0, Math.max(0, input.trackOrder.length - 1)); @@ -171,45 +183,17 @@ export function resolveTimelineMove( } return { - start: nextStart, + start: clamp(roundToCentiseconds(input.start + deltaTime), 0, Math.max(0, input.maxStart)), track: nextTrack, }; } -/** - * Snap a keyframe's clip-relative percentage to the nearest beat within ~8px, - * mapping through composition time (pct → time → nearest beat → pct). Returns - * the percentage unchanged when no beat is in range, so dragging stays free - * between beats. - */ -export function snapKeyframePctToBeat( - el: { start: number; duration: number }, - pct: number, - beatTimes: number[] | undefined, - pixelsPerSecond: number, -): number { - if (!beatTimes || beatTimes.length === 0 || el.duration <= 0) return pct; - const t = el.start + (pct / 100) * el.duration; - const snapSecs = 8 / Math.max(pixelsPerSecond, 1); - let best = t; - let bestDist = snapSecs; - for (const bt of beatTimes) { - const d = Math.abs(bt - t); - if (d < bestDist) { - bestDist = d; - best = bt; - } - } - if (best === t) return pct; - return Math.max(0, Math.min(100, ((best - el.start) / el.duration) * 100)); -} - export function resolveTimelineResize( input: TimelineResizeInput, edge: "start" | "end", clientX: number, ): { start: number; duration: number; playbackStart?: number } { - const minDuration = resolveTimelineMinDuration(input.minDuration); + const minDuration = Math.max(0.05, input.minDuration ?? 0.1); const deltaTime = (clientX - input.originClientX) / Math.max(input.pixelsPerSecond, 1); if (edge === "end") { @@ -225,15 +209,23 @@ export function resolveTimelineResize( }; } - const { minDelta, maxDelta } = clipStartTrimDeltaBounds(input, input.minStart, minDuration); + 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 clampedDelta = clamp(deltaTime, minDelta, maxDelta); - const trimmed = applyClipStartTrimDelta(input, clampedDelta); + 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; return { - start: roundToCentiseconds(trimmed.start), - duration: roundToCentiseconds(trimmed.duration), - playbackStart: - trimmed.playbackStart != null ? roundToCentiseconds(trimmed.playbackStart) : undefined, + start: nextStart, + duration: nextDuration, + playbackStart: nextPlaybackStart, }; } @@ -260,108 +252,6 @@ 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(); - 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; @@ -465,13 +355,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)"} @@ -525,72 +415,3 @@ export function buildTimelineElementAgentPrompt(element: { export function formatTimelineAttributeNumber(value: number): string { return Number(roundToCentiseconds(value).toFixed(2)).toString(); } - -/** - * Apply one edge auto-scroll step: scroll `scroll` toward the edge zone the - * pointer is in, clamped to the scrollable range. Returns true when the - * container actually moved (the caller keeps its RAF running and re-runs its - * live preview), false when the pointer is outside the edge zones or the scroll - * is already clamped (the caller stops). - */ -export function applyTimelineAutoScrollStep( - scroll: HTMLElement, - clientX: number, - clientY: number, -): boolean { - const delta = resolveTimelineAutoScroll(scroll.getBoundingClientRect(), clientX, clientY); - if (delta.x === 0 && delta.y === 0) return false; - 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 false; - scroll.scrollLeft = nextScrollLeft; - scroll.scrollTop = nextScrollTop; - return true; -} - -/** - * Decide whether an edge auto-scroll RAF loop should start, stop, or stay as-is - * for the current pointer: "start" when the pointer is in an edge zone and no - * loop is running, "stop" when it left the zones while a loop is running, - * "none" otherwise. - */ -export function resolveTimelineAutoScrollLoopAction( - scroll: HTMLElement | null, - clientX: number, - clientY: number, - rafActive: boolean, -): "start" | "stop" | "none" { - if (!scroll) return "none"; - const delta = resolveTimelineAutoScroll(scroll.getBoundingClientRect(), clientX, clientY); - if (delta.x === 0 && delta.y === 0) return rafActive ? "stop" : "none"; - return rafActive ? "none" : "start"; -} - -/** - * Escape cancels an in-progress clip drag / resize / blocked-drag: no commit, - * no undo entry — the previews live only in the gesture state, so clearing it - * restores the pre-drag timeline. `suppressClick` arms the click suppressor - * only when the gesture actually started, so the click generated by the - * eventual pointerup can't reselect or split the clip. - */ -export function resolveTimelineDragEscape(input: TimelineDragEscapeInput): { - cancel: boolean; - suppressClick: boolean; -} { - if (input.key !== "Escape" || (!input.drag && !input.resize && !input.blocked)) { - return { cancel: false, suppressClick: false }; - } - return { - cancel: true, - suppressClick: Boolean(input.drag?.started || input.resize?.started || input.blocked?.started), - }; -} - -export interface TimelineDragEscapeInput { - key: string; - drag: { started: boolean } | null; - resize: { started: boolean } | null; - blocked: { started: boolean } | null; -} diff --git a/packages/studio/src/player/components/timelineGroupEditing.test.ts b/packages/studio/src/player/components/timelineGroupEditing.test.ts new file mode 100644 index 0000000000..31c7ec2e51 --- /dev/null +++ b/packages/studio/src/player/components/timelineGroupEditing.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; +import type { TimelineElement } from "../store/playerStore"; +import { + buildTimelineGroupResizeMembers, + resolveTimelineGroupResize, + resolveTimelineGroupResizeChanges, +} from "./timelineGroupEditing"; + +function el(id: string, over: Partial = {}): TimelineElement { + // domId gives a patchable target so getTimelineEditCapabilities().canTrim* is true. + return { id, key: id, tag: "video", start: 0, duration: 2, track: 0, domId: id, ...over }; +} + +function keys(...ids: string[]): ReadonlySet { + return new Set(ids); +} + +describe("buildTimelineGroupResizeMembers (legacy 36413da7f semantics)", () => { + it("returns null for a single-clip selection (no group forms)", () => { + const a = el("a"); + expect(buildTimelineGroupResizeMembers([a], keys("a"), "a", "end")).toBeNull(); + expect(buildTimelineGroupResizeMembers([a], new Set(), "a", "end")).toBeNull(); + }); + + it("returns null when the grabbed clip is not part of the selection", () => { + const a = el("a"); + const b = el("b"); + expect(buildTimelineGroupResizeMembers([a, b], keys("a", "b"), "c", "end")).toBeNull(); + }); + + it("degrades to single-clip (null) when ANY member is locked — locked clip never patched", () => { + const a = el("a"); + const locked = el("b", { timelineLocked: true }); + expect(buildTimelineGroupResizeMembers([a, locked], keys("a", "b"), "a", "end")).toBeNull(); + }); + + it("degrades when a member is implicitly timed or has no patch target", () => { + const a = el("a"); + const implicit = el("b", { timingSource: "implicit" }); + const noTarget = el("c", { domId: undefined, selector: undefined }); + expect(buildTimelineGroupResizeMembers([a, implicit], keys("a", "b"), "a", "end")).toBeNull(); + expect(buildTimelineGroupResizeMembers([a, noTarget], keys("a", "c"), "a", "end")).toBeNull(); + }); + + it("snapshots members and seeds start-edge media playbackStart to 0", () => { + const grabbed = el("a", { start: 2, duration: 2 }); + const audio = el("b", { tag: "audio", start: 5, duration: 3 }); // playbackStart undefined + const members = buildTimelineGroupResizeMembers([grabbed, audio], keys("a", "b"), "a", "start"); + expect(members).not.toBeNull(); + expect(members!.map((m) => [m.key, m.start, m.duration, m.playbackStart])).toEqual([ + ["a", 2, 2, 0], // start-edge media seeds playbackStart to 0 + ["b", 5, 3, 0], + ]); + }); + + it("does not seed playbackStart on the END edge", () => { + const grabbed = el("a", { tag: "audio", start: 0, duration: 2 }); + const b = el("b", { tag: "audio", start: 3, duration: 2 }); + const members = buildTimelineGroupResizeMembers([grabbed, b], keys("a", "b"), "a", "end"); + expect(members!.every((m) => m.playbackStart === undefined)).toBe(true); + }); +}); + +describe("resolveTimelineGroupResizeChanges (rigid group patch set)", () => { + it("END edge: extends every member's duration by the shared delta", () => { + const members = buildTimelineGroupResizeMembers( + [el("a", { duration: 2 }), el("b", { duration: 3 })], + keys("a", "b"), + "a", + "end", + )!; + const changes = resolveTimelineGroupResizeChanges(members, "end", 0.5); + expect(changes.map((c) => [c.key, c.start, c.duration])).toEqual([ + ["a", 0, 2.5], + ["b", 0, 3.5], + ]); + }); + + it("END edge: rigid — the most-constrained member clamps the whole group", () => { + // b (0.2s) can only shrink 0.1 before hitting min duration, so the group as a + // whole shrinks 0.1 even though the grabbed clip asked for -0.5. + const members = buildTimelineGroupResizeMembers( + [el("a", { duration: 2 }), el("b", { duration: 0.2 })], + keys("a", "b"), + "a", + "end", + )!; + const changes = resolveTimelineGroupResizeChanges(members, "end", -0.5); + expect(changes.map((c) => c.duration)).toEqual([1.9, 0.1]); + }); + + it("START edge: shifts start + duration together across the group", () => { + const members = buildTimelineGroupResizeMembers( + [ + el("a", { tag: "text", start: 2, duration: 2 }), + el("b", { tag: "text", start: 5, duration: 3 }), + ], + keys("a", "b"), + "a", + "start", + )!; + const changes = resolveTimelineGroupResizeChanges(members, "start", -0.5); + expect(changes.map((c) => [c.key, c.start, c.duration])).toEqual([ + ["a", 1.5, 2.5], + ["b", 4.5, 3.5], + ]); + }); + + it("produces exactly the resolveTimelineGroupResize output, keyed per member", () => { + const members = buildTimelineGroupResizeMembers( + [el("a", { duration: 2 }), el("b", { duration: 4 })], + keys("a", "b"), + "a", + "end", + )!; + const raw = resolveTimelineGroupResize(members, "end", 0.75); + const changes = resolveTimelineGroupResizeChanges(members, "end", 0.75); + expect( + changes.map((c) => ({ + start: c.start, + duration: c.duration, + playbackStart: c.playbackStart, + })), + ).toEqual(raw.members); + }); +}); diff --git a/packages/studio/src/player/components/timelineGroupEditing.ts b/packages/studio/src/player/components/timelineGroupEditing.ts index 65533df135..13e5464ad8 100644 --- a/packages/studio/src/player/components/timelineGroupEditing.ts +++ b/packages/studio/src/player/components/timelineGroupEditing.ts @@ -1,4 +1,6 @@ import { roundToCenti } from "../../utils/rounding"; +import type { TimelineElement } from "../store/playerStore"; +import { getTimelineEditCapabilities } from "./timelineEditing"; const DEFAULT_TIMELINE_MIN_DURATION = 0.1; const ABSOLUTE_TIMELINE_MIN_DURATION = 0.05; @@ -11,7 +13,7 @@ function roundTimelineTime(value: number): number { return roundToCenti(value); } -export function resolveTimelineMinDuration(minDuration?: number): number { +function resolveTimelineMinDuration(minDuration?: number): number { return Math.max(ABSOLUTE_TIMELINE_MIN_DURATION, minDuration ?? DEFAULT_TIMELINE_MIN_DURATION); } @@ -33,7 +35,7 @@ interface TimelineStartTrimClip { * media in-point (`playbackStart / playbackRate`); right-bounded by `minDuration`. * Returned deltas are unrounded — callers round with their own centisecond helper. */ -export function clipStartTrimDeltaBounds( +function clipStartTrimDeltaBounds( clip: TimelineStartTrimClip, minStart: number, minDuration: number, @@ -52,7 +54,7 @@ export function clipStartTrimDeltaBounds( * 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( +function applyClipStartTrimDelta( clip: TimelineStartTrimClip, delta: number, ): { start: number; duration: number; playbackStart?: number } { @@ -76,40 +78,12 @@ export interface TimelineGroupTimingMember { export type TimelineGroupResizeEdge = "start" | "end"; -export interface TimelineGroupMoveResult { - delta: number; - members: Array>; -} - export interface TimelineGroupResizeResult { delta: number; members: Array>; } -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( +function clampTimelineGroupResizeDelta( rawDelta: number, members: readonly TimelineGroupTimingMember[], edge: TimelineGroupResizeEdge, @@ -157,3 +131,134 @@ export function resolveTimelineGroupResize( }), }; } + +/* ── Multi-select group resize wiring (restored from main 36413da7f) ───────── */ + +/** A selected clip snapshot captured at group-resize gesture start. */ +export interface TimelineGroupResizeMember extends TimelineGroupTimingMember { + element: TimelineElement; + key: string; +} + +/** Per-member timing patch produced by a group resize (grabbed clip included). */ +export interface TimelineGroupResizeChange { + element: TimelineElement; + key: string; + start: number; + duration: number; + playbackStart?: number; +} + +function elementKey(element: TimelineElement): string { + return element.key ?? element.id; +} + +function isMediaTimelineElement(element: TimelineElement): boolean { + const tag = element.tag.toLowerCase(); + return tag === "audio" || tag === "video"; +} + +function canTrimEdge(element: TimelineElement, edge: TimelineGroupResizeEdge): boolean { + const caps = getTimelineEditCapabilities({ + tag: element.tag, + duration: element.duration, + domId: element.domId, + selector: element.selector, + compositionSrc: element.compositionSrc, + playbackStart: element.playbackStart, + playbackStartAttr: element.playbackStartAttr, + sourceDuration: element.sourceDuration, + timingSource: element.timingSource, + timelineLocked: element.timelineLocked, + }); + return edge === "start" ? caps.canTrimStart : caps.canTrimEnd; +} + +/** + * Snapshot the members for a group resize, or `null` when the gesture must stay a + * single-clip resize. Legacy semantics (main 36413da7f): a group forms only when + * the grabbed clip is part of a multi-selection (> 1) AND EVERY selected member + * can take THIS edge's trim. It is all-or-nothing — if any member is locked or + * implicitly-timed the group does not form, so a locked member is never patched + * (the grabbed clip then resizes on its own). Start-edge media members seed + * `playbackStart` to 0 so the in-point trim math has a base, mirroring the legacy + * `resizeMember`. + */ +export function buildTimelineGroupResizeMembers( + elements: readonly TimelineElement[], + selectedKeys: ReadonlySet, + grabbedKey: string, + edge: TimelineGroupResizeEdge, +): TimelineGroupResizeMember[] | null { + if (selectedKeys.size <= 1 || !selectedKeys.has(grabbedKey)) return null; + const selected = elements.filter((element) => selectedKeys.has(elementKey(element))); + if (selected.length <= 1) return null; + if (!selected.every((element) => canTrimEdge(element, edge))) return null; + return selected.map((element) => ({ + element, + key: elementKey(element), + start: element.start, + duration: element.duration, + playbackStart: + edge === "start" && isMediaTimelineElement(element) + ? (element.playbackStart ?? 0) + : element.playbackStart, + playbackRate: element.playbackRate, + })); +} + +/** + * Resolve the per-member timing patches for a group resize from the grabbed + * clip's raw edge delta. Rigid group: one shared delta, clamped by the most + * constrained member (see resolveTimelineGroupResize), applied to every member. + */ +export function resolveTimelineGroupResizeChanges( + members: readonly TimelineGroupResizeMember[], + edge: TimelineGroupResizeEdge, + rawDelta: number, + minDuration = resolveTimelineMinDuration(), +): TimelineGroupResizeChange[] { + const result = resolveTimelineGroupResize(members, edge, rawDelta, minDuration); + return result.members.map((member, index) => ({ + element: members[index]!.element, + key: members[index]!.key, + start: member.start, + duration: member.duration, + playbackStart: member.playbackStart, + })); +} + +/** In-flight multi-select group-resize gesture (owned by the drag hook). */ +export interface TimelineGroupResizeSession { + grabbedKey: string; + edge: TimelineGroupResizeEdge; + members: TimelineGroupResizeMember[]; + changes: TimelineGroupResizeChange[]; + hasChanged: boolean; +} + +/** + * Fold the grabbed clip's single-clip preview into the group: derive the raw + * edge delta from it, resolve the rigid per-member changes onto the session + * (updating `changes` + `hasChanged`), and return the grabbed clip's own change + * so the caller can render it from the resize state. + */ +export function applyTimelineGroupResizePreview( + session: TimelineGroupResizeSession, + grabbedPreview: { previewStart: number; previewDuration: number }, +): TimelineGroupResizeChange | undefined { + const grabbed = session.members.find((m) => m.key === session.grabbedKey); + const rawDelta = + session.edge === "start" + ? grabbedPreview.previewStart - (grabbed?.start ?? grabbedPreview.previewStart) + : grabbedPreview.previewDuration - (grabbed?.duration ?? grabbedPreview.previewDuration); + const changes = resolveTimelineGroupResizeChanges(session.members, session.edge, rawDelta); + session.changes = changes; + session.hasChanged = changes.some( + (c, i) => + c.start !== session.members[i]!.start || + c.duration !== session.members[i]!.duration || + c.playbackStart !== session.members[i]!.playbackStart, + ); + return changes.find((c) => c.key === session.grabbedKey); +} diff --git a/packages/studio/src/player/components/timelineLayerDrag.test.ts b/packages/studio/src/player/components/timelineLayerDrag.test.ts deleted file mode 100644 index b325095a21..0000000000 --- a/packages/studio/src/player/components/timelineLayerDrag.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { TimelineElement } from "../store/playerStore"; -import type { StackingTimelineLayer } from "./timelineTrackOrder"; -import { - resolveTimelineLayerStackingMove, - resolveTimelineLayerZIndexChanges, -} from "./timelineLayerDrag"; - -function element(input: { - id: string; - zIndex: number; - tag?: string; - start?: number; - duration?: number; -}): TimelineElement { - return { - id: input.id, - tag: input.tag ?? "div", - start: input.start ?? 0, - duration: input.duration ?? 1, - track: 0, - zIndex: input.zIndex, - hasExplicitZIndex: true, - stackingContextId: "root", - parentCompositionId: null, - compositionAncestors: ["root"], - }; -} - -function layer( - id: string, - zIndex: number, - elements: TimelineElement[] = [element({ id, zIndex })], -): StackingTimelineLayer { - return { - id, - kind: "visual", - contextKey: "root", - zIndex, - placementTrack: 0, - elements, - }; -} - -describe("resolveTimelineLayerZIndexChanges", () => { - it("joins an existing lane by assigning the dragged clip that lane's z-index", () => { - const dragged = element({ id: "dragged", zIndex: 1, start: 2, duration: 1 }); - const front = element({ id: "front", zIndex: 10, start: 0, duration: 1 }); - - expect( - resolveTimelineLayerZIndexChanges({ - element: dragged, - layers: [layer("front", 10, [front]), layer("back", 1)], - placement: { type: "onto", layerId: "front" }, - })?.zIndexChanges, - ).toEqual([{ key: "dragged", zIndex: 10 }]); - }); - - it("rejects an onto-lane join when the dragged clip would overlap that lane", () => { - const dragged = element({ id: "dragged", zIndex: 1, start: 0.5, duration: 1 }); - const front = element({ id: "front", zIndex: 10, start: 0, duration: 1 }); - - expect( - resolveTimelineLayerZIndexChanges({ - element: dragged, - layers: [layer("front", 10, [front]), layer("back", 1)], - placement: { type: "onto", layerId: "front" }, - }), - ).toBeNull(); - }); - - it("interpolates a new integer z-index strictly between neighboring layers", () => { - const dragged = element({ id: "dragged", zIndex: 1 }); - - expect( - resolveTimelineLayerZIndexChanges({ - element: dragged, - layers: [layer("front", 10), layer("back", 4)], - placement: { type: "between", beforeLayerId: "front", afterLayerId: "back" }, - })?.zIndexChanges, - ).toEqual([{ key: "dragged", zIndex: 7 }]); - }); - - it("renumbers the minimum sibling set when adjacent layers leave no integer gap", () => { - const dragged = element({ id: "dragged", zIndex: 0 }); - - expect( - resolveTimelineLayerZIndexChanges({ - element: dragged, - layers: [layer("front", 2), layer("back", 1), layer("lower", 0)], - placement: { type: "between", beforeLayerId: "front", afterLayerId: "back" }, - })?.zIndexChanges, - ).toEqual([ - { key: "dragged", zIndex: 2 }, - { key: "front", zIndex: 3 }, - ]); - }); - - it("assigns new extreme z-index values above the top and below the bottom layer", () => { - const dragged = element({ id: "dragged", zIndex: 0 }); - const layers = [layer("front", 10), layer("back", -2)]; - - expect( - resolveTimelineLayerZIndexChanges({ - element: dragged, - layers, - placement: { type: "above", layerId: "front" }, - })?.zIndexChanges, - ).toEqual([{ key: "dragged", zIndex: 11 }]); - - expect( - resolveTimelineLayerZIndexChanges({ - element: dragged, - layers, - placement: { type: "below", layerId: "back" }, - })?.zIndexChanges, - ).toEqual([{ key: "dragged", zIndex: -3 }]); - }); - - it("does not resolve stacking z-index changes for audio clips", () => { - expect( - resolveTimelineLayerZIndexChanges({ - element: element({ id: "music", zIndex: 0, tag: "audio" }), - layers: [layer("front", 10)], - placement: { type: "onto", layerId: "front" }, - }), - ).toBeNull(); - }); -}); - -describe("resolveTimelineLayerStackingMove", () => { - it("places an upward onto-lane drop above the overlapping target lane", () => { - const front = element({ id: "front", zIndex: 10, start: 0, duration: 2 }); - const dragged = element({ id: "dragged", zIndex: 1, start: 0.5, duration: 1 }); - const layers = [layer("front", 10, [front]), layer("dragged", 1, [dragged])]; - - expect( - resolveTimelineLayerStackingMove({ - element: dragged, - layers, - layerOrder: layers.map((item) => item.id), - trackDeltaRaw: -0.8, - }), - ).toEqual({ - previewLayerId: "preview:dragged:above:front", - previewLayerIndex: 0, - stackingReorder: { - contextKey: "root", - placement: { type: "above", layerId: "front" }, - zIndexChanges: [{ key: "dragged", zIndex: 11 }], - }, - }); - }); - - it("places a downward onto-lane drop below the overlapping target lane", () => { - const dragged = element({ id: "dragged", zIndex: 10, start: 0.5, duration: 1 }); - const back = element({ id: "back", zIndex: 1, start: 0, duration: 2 }); - const layers = [layer("dragged", 10, [dragged]), layer("back", 1, [back])]; - - expect( - resolveTimelineLayerStackingMove({ - element: dragged, - layers, - layerOrder: layers.map((item) => item.id), - trackDeltaRaw: 0.8, - }), - ).toEqual({ - previewLayerId: "preview:dragged:below:back", - previewLayerIndex: 2, - stackingReorder: { - contextKey: "root", - placement: { type: "below", layerId: "back" }, - zIndexChanges: [{ key: "dragged", zIndex: 0 }], - }, - }); - }); - - it("keeps a non-overlapping onto-lane drop joined to the target lane", () => { - const front = element({ id: "front", zIndex: 10, start: 0, duration: 1 }); - const dragged = element({ id: "dragged", zIndex: 1, start: 2, duration: 1 }); - const layers = [layer("front", 10, [front]), layer("dragged", 1, [dragged])]; - - expect( - resolveTimelineLayerStackingMove({ - element: dragged, - layers, - layerOrder: layers.map((item) => item.id), - trackDeltaRaw: -0.8, - }), - ).toEqual({ - previewLayerId: "front", - previewLayerIndex: 0, - stackingReorder: { - contextKey: "root", - placement: { type: "onto", layerId: "front" }, - zIndexChanges: [{ key: "dragged", zIndex: 10 }], - }, - }); - }); -}); diff --git a/packages/studio/src/player/components/timelineLayerDrag.ts b/packages/studio/src/player/components/timelineLayerDrag.ts deleted file mode 100644 index 75e4d4c1bc..0000000000 --- a/packages/studio/src/player/components/timelineLayerDrag.ts +++ /dev/null @@ -1,383 +0,0 @@ -import { resolveStackingContextKey } from "../lib/layerOrdering"; -import { getTimelineElementIdentity } from "../lib/timelineElementHelpers"; -import { - timelineElementsOverlap, - type StackingTimelineLayer, - type TimelineLayerId, -} from "./timelineTrackOrder"; -import { - toStackingOrderItem, - type TimelineLayerDropPlacement, - type TimelineStackingElement, - type TimelineStackingReorderIntent, - type TimelineStackingZIndexChange, -} from "./timelineStacking"; - -const ONTO_ROW_THRESHOLD = 0.35; - -export interface TimelineLayerStackingMoveResolution { - previewLayerId: TimelineLayerId; - previewLayerIndex: number; - stackingReorder: TimelineStackingReorderIntent | null; -} - -function isAudioElement(element: TimelineStackingElement): boolean { - return element.tag?.toLowerCase() === "audio"; -} - -function layerContainsElement(layer: StackingTimelineLayer, key: string): boolean { - return layer.elements.some((element) => getTimelineElementIdentity(element) === key); -} - -function layerConflictsWithElement( - layer: StackingTimelineLayer, - element: TimelineStackingElement, - draggedKey: string, -): boolean { - return layer.elements.some( - (candidate) => - getTimelineElementIdentity(candidate) !== draggedKey && - timelineElementsOverlap(candidate, element), - ); -} - -function addElementChange( - changes: TimelineStackingZIndexChange[], - element: TimelineStackingElement, - zIndex: number, -): void { - if ((element.zIndex ?? 0) === zIndex) return; - changes.push({ - key: getTimelineElementIdentity(element), - zIndex, - domId: element.domId, - selector: element.selector, - selectorIndex: element.selectorIndex, - sourceFile: element.sourceFile, - }); -} - -function addLayerChanges( - changes: TimelineStackingZIndexChange[], - layer: StackingTimelineLayer, - zIndex: number, - excludedKey: string, -): number { - let count = 0; - for (const element of layer.elements) { - const key = getTimelineElementIdentity(element); - if (key === excludedKey) continue; - const before = changes.length; - addElementChange(changes, element, zIndex); - if (changes.length > before) count += 1; - } - return count; -} - -function getContextLayers( - layers: readonly StackingTimelineLayer[], - contextKey: string, -): StackingTimelineLayer[] { - return layers.filter((layer) => layer.kind === "visual" && layer.contextKey === contextKey); -} - -function findLayer( - layers: readonly StackingTimelineLayer[], - id: string, -): StackingTimelineLayer | null { - return layers.find((layer) => layer.id === id) ?? null; -} - -function resolvePlacementZIndexChanges(input: { - element: TimelineStackingElement; - targetZIndex: number; -}): TimelineStackingZIndexChange[] { - const changes: TimelineStackingZIndexChange[] = []; - addElementChange(changes, input.element, input.targetZIndex); - return changes; -} - -function buildPushUpCandidate(input: { - element: TimelineStackingElement; - layers: readonly StackingTimelineLayer[]; - beforeIndex: number; - bottomZIndex: number; -}): { changes: TimelineStackingZIndexChange[]; siblingChanges: number } { - const draggedKey = getTimelineElementIdentity(input.element); - const draggedZIndex = input.bottomZIndex + 1; - const changes = resolvePlacementZIndexChanges({ - element: input.element, - targetZIndex: draggedZIndex, - }); - let siblingChanges = 0; - let requiredZIndex = draggedZIndex + 1; - for (let index = input.beforeIndex; index >= 0; index -= 1) { - const layer = input.layers[index]; - if (!layer) continue; - const nextZIndex = layer.zIndex >= requiredZIndex ? layer.zIndex : requiredZIndex; - siblingChanges += addLayerChanges(changes, layer, nextZIndex, draggedKey); - requiredZIndex = nextZIndex + 1; - } - return { changes, siblingChanges }; -} - -function buildPushDownCandidate(input: { - element: TimelineStackingElement; - layers: readonly StackingTimelineLayer[]; - afterIndex: number; - topZIndex: number; -}): { changes: TimelineStackingZIndexChange[]; siblingChanges: number } { - const draggedKey = getTimelineElementIdentity(input.element); - const draggedZIndex = input.topZIndex - 1; - const changes = resolvePlacementZIndexChanges({ - element: input.element, - targetZIndex: draggedZIndex, - }); - let siblingChanges = 0; - let requiredZIndex = draggedZIndex - 1; - for (let index = input.afterIndex; index < input.layers.length; index += 1) { - const layer = input.layers[index]; - if (!layer) continue; - const nextZIndex = layer.zIndex <= requiredZIndex ? layer.zIndex : requiredZIndex; - siblingChanges += addLayerChanges(changes, layer, nextZIndex, draggedKey); - requiredZIndex = nextZIndex - 1; - } - return { changes, siblingChanges }; -} - -function resolveBetweenChanges(input: { - element: TimelineStackingElement; - layers: readonly StackingTimelineLayer[]; - beforeLayer: StackingTimelineLayer; - afterLayer: StackingTimelineLayer; -}): TimelineStackingZIndexChange[] { - const topZIndex = input.beforeLayer.zIndex; - const bottomZIndex = input.afterLayer.zIndex; - if (topZIndex - bottomZIndex > 1) { - return resolvePlacementZIndexChanges({ - element: input.element, - targetZIndex: Math.floor((topZIndex + bottomZIndex) / 2), - }); - } - - const beforeIndex = input.layers.findIndex((layer) => layer.id === input.beforeLayer.id); - const afterIndex = input.layers.findIndex((layer) => layer.id === input.afterLayer.id); - const pushUp = buildPushUpCandidate({ - element: input.element, - layers: input.layers, - beforeIndex, - bottomZIndex, - }); - const pushDown = buildPushDownCandidate({ - element: input.element, - layers: input.layers, - afterIndex, - topZIndex, - }); - return pushUp.siblingChanges <= pushDown.siblingChanges ? pushUp.changes : pushDown.changes; -} - -function resolveOntoChanges(input: { - element: TimelineStackingElement; - layers: readonly StackingTimelineLayer[]; - layerId: string; -}): TimelineStackingZIndexChange[] | null { - const target = findLayer(input.layers, input.layerId); - if (!target) return null; - if (layerConflictsWithElement(target, input.element, getTimelineElementIdentity(input.element))) { - return null; - } - return resolvePlacementZIndexChanges({ - element: input.element, - targetZIndex: target.zIndex, - }); -} - -function resolveEdgeChanges(input: { - element: TimelineStackingElement; - layers: readonly StackingTimelineLayer[]; - layerId: string; - offset: number; -}): TimelineStackingZIndexChange[] | null { - const target = findLayer(input.layers, input.layerId); - if (!target) return null; - return resolvePlacementZIndexChanges({ - element: input.element, - targetZIndex: target.zIndex + input.offset, - }); -} - -function resolvePlacementChanges(input: { - element: TimelineStackingElement; - layers: readonly StackingTimelineLayer[]; - placement: TimelineLayerDropPlacement; -}): TimelineStackingZIndexChange[] | null { - switch (input.placement.type) { - case "onto": - return resolveOntoChanges({ - element: input.element, - layers: input.layers, - layerId: input.placement.layerId, - }); - case "above": - return resolveEdgeChanges({ - element: input.element, - layers: input.layers, - layerId: input.placement.layerId, - offset: 1, - }); - case "below": - return resolveEdgeChanges({ - element: input.element, - layers: input.layers, - layerId: input.placement.layerId, - offset: -1, - }); - case "between": { - const beforeLayer = findLayer(input.layers, input.placement.beforeLayerId); - const afterLayer = findLayer(input.layers, input.placement.afterLayerId); - return beforeLayer && afterLayer - ? resolveBetweenChanges({ - element: input.element, - layers: input.layers, - beforeLayer, - afterLayer, - }) - : null; - } - } -} - -export function resolveTimelineLayerZIndexChanges(input: { - element: TimelineStackingElement; - layers: readonly StackingTimelineLayer[]; - placement: TimelineLayerDropPlacement; -}): TimelineStackingReorderIntent | null { - if (isAudioElement(input.element)) return null; - const contextKey = resolveStackingContextKey(toStackingOrderItem(input.element)); - const layers = getContextLayers(input.layers, contextKey); - const changes = resolvePlacementChanges({ - element: input.element, - layers, - placement: input.placement, - }); - - return changes && changes.length > 0 - ? { contextKey, placement: input.placement, zIndexChanges: changes } - : null; -} - -// fallow-ignore-next-line complexity -function resolveDragPlacement( - layers: readonly StackingTimelineLayer[], - targetPosition: number, -): TimelineLayerDropPlacement | null { - const first = layers[0]; - const last = layers[layers.length - 1]; - if (!first || !last) return null; - if (targetPosition < -ONTO_ROW_THRESHOLD) return { type: "above", layerId: first.id }; - if (targetPosition > layers.length - 1 + ONTO_ROW_THRESHOLD) { - return { type: "below", layerId: last.id }; - } - - const nearestIndex = Math.round(targetPosition); - const nearest = layers[nearestIndex]; - if (nearest && Math.abs(targetPosition - nearestIndex) <= ONTO_ROW_THRESHOLD) { - return { type: "onto", layerId: nearest.id }; - } - - const insertionIndex = Math.max(0, Math.min(layers.length, Math.ceil(targetPosition))); - if (insertionIndex <= 0) return { type: "above", layerId: first.id }; - if (insertionIndex >= layers.length) return { type: "below", layerId: last.id }; - const before = layers[insertionIndex - 1]; - const after = layers[insertionIndex]; - return before && after - ? { type: "between", beforeLayerId: before.id, afterLayerId: after.id } - : null; -} - -function resolveLaneAwareDragPlacement(input: { - layers: readonly StackingTimelineLayer[]; - element: TimelineStackingElement; - draggedKey: string; - placement: TimelineLayerDropPlacement; - targetPosition: number; - currentIndex: number; -}): TimelineLayerDropPlacement | null { - if (input.placement.type !== "onto") return input.placement; - const target = findLayer(input.layers, input.placement.layerId); - if (!target) return null; - if (!layerConflictsWithElement(target, input.element, input.draggedKey)) return input.placement; - if (input.targetPosition < input.currentIndex) { - return { type: "above", layerId: target.id }; - } - if (input.targetPosition > input.currentIndex) { - return { type: "below", layerId: target.id }; - } - return input.placement; -} - -function getPreviewLayerId( - draggedKey: string, - placement: TimelineLayerDropPlacement, -): TimelineLayerId { - if (placement.type === "onto") return placement.layerId; - if (placement.type === "between") { - return `preview:${draggedKey}:between:${placement.beforeLayerId}:${placement.afterLayerId}`; - } - return `preview:${draggedKey}:${placement.type}:${placement.layerId}`; -} - -function getPreviewLayerIndex( - layerOrder: readonly TimelineLayerId[], - placement: TimelineLayerDropPlacement, -): number { - if (placement.type === "onto") return Math.max(0, layerOrder.indexOf(placement.layerId)); - if (placement.type === "between") { - return Math.max(0, layerOrder.indexOf(placement.afterLayerId)); - } - const targetIndex = Math.max(0, layerOrder.indexOf(placement.layerId)); - return placement.type === "above" ? targetIndex : targetIndex + 1; -} - -export function resolveTimelineLayerStackingMove(input: { - element: TimelineStackingElement; - layers: readonly StackingTimelineLayer[]; - layerOrder: readonly TimelineLayerId[]; - trackDeltaRaw: number; -}): TimelineLayerStackingMoveResolution | null { - if (isAudioElement(input.element)) return null; - const contextKey = resolveStackingContextKey(toStackingOrderItem(input.element)); - const layerById = new Map(input.layers.map((layer) => [layer.id, layer])); - const contextLayers = input.layerOrder - .map((id) => layerById.get(id) ?? null) - .filter( - (layer): layer is StackingTimelineLayer => - layer != null && layer.kind === "visual" && layer.contextKey === contextKey, - ); - const draggedKey = getTimelineElementIdentity(input.element); - const currentIndex = contextLayers.findIndex((layer) => layerContainsElement(layer, draggedKey)); - if (currentIndex < 0) return null; - - const targetPosition = currentIndex + input.trackDeltaRaw; - const rawPlacement = resolveDragPlacement(contextLayers, targetPosition); - if (!rawPlacement) return null; - const placement = resolveLaneAwareDragPlacement({ - layers: contextLayers, - element: input.element, - draggedKey, - placement: rawPlacement, - targetPosition, - currentIndex, - }); - if (!placement) return null; - return { - previewLayerId: getPreviewLayerId(draggedKey, placement), - previewLayerIndex: getPreviewLayerIndex(input.layerOrder, placement), - stackingReorder: resolveTimelineLayerZIndexChanges({ - element: input.element, - layers: contextLayers, - placement, - }), - }; -} diff --git a/packages/studio/src/player/components/timelineZones.test.ts b/packages/studio/src/player/components/timelineZones.test.ts index 4e446b0568..02e721379e 100644 --- a/packages/studio/src/player/components/timelineZones.test.ts +++ b/packages/studio/src/player/components/timelineZones.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import type { TimelineElement } from "../store/playerStore"; import { classifyZone, normalizeToZones } from "./timelineZones"; -import { computeStackingPatches, type StackingElement } from "./timelineStackingSync"; function el(id: string, tag: string, track: number, duration = 2): TimelineElement { return { id, tag, start: 0, duration, track }; @@ -29,16 +28,6 @@ function expectZoningIdempotent(input: TimelineElement[]): void { for (const e of once) expect(trackOf(twice, e.id)).toBe(e.track); } -/** The exact qa-clean live repro (array order = DOM order); fresh objects per call. */ -function qaCleanRepro(): TimelineElement[] { - return [ - zClip("blue-logo", 6.37, 3, 0, 3, "img"), - zClip("ralu", 6.37, 3, 0, 0, "img"), - zClip("black-logo", 11.92, 3, 1, 1, "img"), - zClip("video", 0.84, 20, 3, 2, "video"), - ]; -} - describe("classifyZone", () => { it("audio → audio; video / image / everything else → visual", () => { expect(classifyZone(el("m", "audio", 3))).toBe("audio"); @@ -66,52 +55,77 @@ describe("classifyZone", () => { }); }); -describe("normalizeToZones", () => { - it("orders visual (top) → audio (bottom); equal-z overlap stacks by DOM order", () => { - // img and vid are both z=0, start=0 → they OVERLAP in time. CSS paints - // equal-z siblings by DOM order (later paints on top), so the later-in-array - // `vid` must own the upper lane. (Was: pinned img=0/vid=1 to authored order, - // which contradicts the canvas — updated for per-clip DOM-order tie-break.) +describe("normalizeToZones — CapCut-stable lanes follow the track-index (never z)", () => { + it("orders visual lanes by authored track-index (ascending), audio at the bottom", () => { + // img (track 0), vid (track 2), mus (audio, track 5). Lanes follow the track + // index: the LOWER visual track owns the upper lane. z is irrelevant (absent). const out = normalizeToZones([ el("img", "img", 0), el("vid", "video", 2), el("mus", "audio", 5), ]); - expect(trackOf(out, "vid")).toBe(0); // later in DOM → paints on top → upper lane - expect(trackOf(out, "img")).toBe(1); // earlier in DOM → below - expect(trackOf(out, "mus")).toBe(2); // audio (bottom) + expect(trackOf(out, "img")).toBe(0); // track 0 → top lane + expect(trackOf(out, "vid")).toBe(1); // track 2 → below it + expect(trackOf(out, "mus")).toBe(2); // audio → bottom }); - it("keeps all visual lanes together on top; equal-z overlap stacks by DOM order", () => { - // All three z=0, start=0 → mutually overlapping. DOM order (later on top) - // decides the stack: v3 (last) top, then i1, then v0. (Was: pinned to authored - // order v0=0/i1=1/v3=2, which the canvas contradicts for overlapping equal-z.) - const out = normalizeToZones([el("v0", "video", 0), el("i1", "img", 1), el("v3", "video", 3)]); - expect(trackOf(out, "v3")).toBe(0); - expect(trackOf(out, "i1")).toBe(1); - expect(trackOf(out, "v0")).toBe(2); + it("compacts sparse visual track-indexes to contiguous lanes, preserving ascending order", () => { + // Distinct authored tracks 0, 3, 7 → three adjacent lanes in the same order. + const out = normalizeToZones([el("a", "video", 7), el("b", "img", 0), el("c", "video", 3)]); + expect(trackOf(out, "b")).toBe(0); // track 0 + expect(trackOf(out, "c")).toBe(1); // track 3 + expect(trackOf(out, "a")).toBe(2); // track 7 + }); + + it("drops audio below the visual lanes even when it holds a LOWER authored index", () => { + // Audio authored at track 0, video at track 5 — audio must still sink below. + const out = normalizeToZones([zClip("a", 0, 10, 0, 0, "audio"), zClip("v", 0, 10, 5, 0)]); + expect(trackOf(out, "v")).toBe(0); // visual on top + expect(trackOf(out, "a")).toBe(1); // audio below, despite its lower track index }); it("drops audio below the visual lanes even when sharing a track index", () => { const out = normalizeToZones([el("v", "video", 0), el("a", "audio", 0)]); - expect(trackOf(out, "v")).toBe(0); // visual - expect(trackOf(out, "a")).toBe(1); // audio, below + expect(trackOf(out, "v")).toBe(0); + expect(trackOf(out, "a")).toBe(1); }); - it("groups multiple audio tracks at the bottom preserving relative order", () => { - const out = normalizeToZones([el("v", "video", 0), el("a1", "audio", 1), el("a2", "audio", 4)]); + it("groups multiple audio tracks at the bottom, ordered by their track index", () => { + const out = normalizeToZones([el("v", "video", 0), el("a2", "audio", 4), el("a1", "audio", 1)]); expect(trackOf(out, "v")).toBe(0); - expect(trackOf(out, "a1")).toBe(1); + expect(trackOf(out, "a1")).toBe(1); // audio track 1 above audio track 4 expect(trackOf(out, "a2")).toBe(2); }); + it("ignores z-index entirely — a high-z clip does NOT jump above a lower-track clip", () => { + // lo on track 0 with z=1; hi on track 1 with z=99. They fully overlap in time. + // The old z-rank pack lifted hi above lo; the CapCut rule keeps lane = track. + const out = normalizeToZones([zClip("lo", 0, 10, 0, 1), zClip("hi", 0, 10, 1, 99)]); + expect(trackOf(out, "lo")).toBe(0); // track 0 stays on top + expect(trackOf(out, "hi")).toBe(1); // higher z does NOT lift it + }); + + it("ignores z-index across authored tracks (scattered z, lanes still by track)", () => { + const out = normalizeToZones([ + zClip("t0", 0, 10, 0, 3), + zClip("t1", 0, 10, 1, 26), + zClip("t2", 0, 10, 2, 0), + ]); + expect(trackOf(out, "t0")).toBe(0); + expect(trackOf(out, "t1")).toBe(1); + expect(trackOf(out, "t2")).toBe(2); + }); + + it("sequential (non-overlapping) same-track clips share a lane", () => { + const out = normalizeToZones([zClip("a", 0, 5, 0, 1), zClip("c", 6, 3, 0, 9)]); + expect(trackOf(out, "a")).toBe(0); + expect(trackOf(out, "c")).toBe(0); // shares the lane regardless of z + }); + it("returns the same array (identity) when already zoned", () => { - // A fixed-point layout: the two overlapping equal-z visual clips are already - // in DOM-order-consistent lanes (later-in-array `v` on the upper lane 0), so - // re-zoning is a no-op and the SAME array reference comes back. (Was: i=0/v=1, - // which the new per-clip DOM-order pack would flip — so it wasn't a fixed - // point under the corrected canvas semantics; swapped to the stable order.) - const input = [el("v", "video", 1), el("i", "img", 0), el("a", "audio", 2)]; + // i on track 0, v on track 1, a (audio) on track 2 — already contiguous, visual + // above audio, so re-zoning is a no-op and the SAME reference comes back. + const input = [el("i", "img", 0), el("v", "video", 1), el("a", "audio", 2)]; expect(normalizeToZones(input)).toBe(input); }); @@ -125,301 +139,99 @@ describe("normalizeToZones", () => { expectZoningIdempotent(input); }); - it("splits time-overlapping clips on one track onto separate lanes (no visible overlap)", () => { + it("re-derives identical lanes from fresh objects carrying the same tracks (reload-stable)", () => { + const build = (): TimelineElement[] => [ + zClip("hi", 0, 10, 0, 9), + zClip("lo", 0, 10, 1, 1), + zClip("mid", 3, 5, 2, 5), + ]; + const first = normalizeToZones(build()); + const second = normalizeToZones(build()); + for (const e of first) expect(trackOf(second, e.id)).toBe(e.track); + }); +}); + +describe("normalizeToZones — legacy overlap spill (display-only, deterministic)", () => { + it("splits time-overlapping SAME-track clips onto adjacent sub-lanes (no visible overlap)", () => { + // a [0,5), b [2,7) overlaps a, c [6,9) sequential — all authored on track 1. + // The editor forbids per-track overlap, but a legacy file can carry it; the + // spill orders by stable id (a, b, c) and first-fits: a→lane0, b overlaps a→ + // lane1, c fits back on lane0 (no overlap with a). const clip = (id: string, start: number, duration: number): TimelineElement => ({ id, tag: "video", start, duration, - track: 1, // all authored on the SAME track, some overlapping in time + track: 1, }); - // a [0,5), b [2,7) overlaps a, c [6,9) fits after a. All equal-z (absent). - // Per-clip pack (DOM-order tie-break): c is last in DOM so it places on the - // top lane 0; b overlaps c and lands on lane 1; a overlaps b (and is earlier - // in DOM than b, so it must paint BELOW b) → lane 2. a can NOT drop onto c's - // lane 0 even though it doesn't overlap c, because that would place a ABOVE b - // in lane order while a paints below b — the canvas-correct constraint the old - // whole-track packer ignored (it shared a/c on lane 0, contradicting paint). const out = normalizeToZones([clip("a", 0, 5), clip("b", 2, 5), clip("c", 6, 3)]); - expect(trackOf(out, "c")).toBe(0); // last in DOM → top lane - expect(trackOf(out, "b")).toBe(1); // overlaps c → below it - expect(trackOf(out, "a")).toBe(2); // paints below b (earlier DOM, equal z) → lane below b - - // No two time-overlapping clips share a lane (the real NLE invariant). + expect(trackOf(out, "a")).toBe(0); + expect(trackOf(out, "b")).toBe(1); // overlaps a → adjacent sub-lane + expect(trackOf(out, "c")).toBe(0); // sequential to a → shares lane 0 + // No two time-overlapping clips share a lane. expect(trackOf(out, "a")).not.toBe(trackOf(out, "b")); - expect(trackOf(out, "b")).not.toBe(trackOf(out, "c")); - // Idempotent: re-laying the split result changes nothing. const twice = normalizeToZones(out); for (const e of out) expect(trackOf(twice, e.id)).toBe(e.track); }); -}); -describe("normalizeToZones — reverse z→lane mapping", () => { - it("orders overlapping same-zone clips by z: higher z → higher (upper) lane", () => { - // lo (z=1) and hi (z=9) fully overlap in time on the same authored track. - const out = normalizeToZones([zClip("lo", 0, 10, 0, 1), zClip("hi", 0, 10, 0, 9)]); - expect(trackOf(out, "hi")).toBe(0); // higher z → upper lane (top) - expect(trackOf(out, "lo")).toBe(1); // lower z → below - }); - - it("orders three overlapping clips strictly by descending z", () => { - const out = normalizeToZones([ - zClip("mid", 0, 10, 0, 5), - zClip("top", 0, 10, 0, 8), - zClip("bot", 0, 10, 0, 2), - ]); - expect(trackOf(out, "top")).toBe(0); - expect(trackOf(out, "mid")).toBe(1); - expect(trackOf(out, "bot")).toBe(2); - }); - - it("does NOT reorder non-overlapping (sequential) clips by z — they share a lane", () => { - // a [0,5) z=1 then c [6,9) z=9 — no time overlap, so z is irrelevant. - const out = normalizeToZones([zClip("a", 0, 5, 0, 1), zClip("c", 6, 3, 0, 9)]); - expect(trackOf(out, "a")).toBe(0); - expect(trackOf(out, "c")).toBe(0); // shares the lane regardless of higher z - }); - - it("leaves the audio zone unaffected by z", () => { - const out = normalizeToZones([ - zClip("v", 0, 10, 0, 1), - zClip("m1", 0, 10, 1, 99, "audio"), - zClip("m2", 0, 10, 1, 0, "audio"), - ]); - // Two overlapping audio clips split onto lanes below the visual clip; their - // relative z does not lift one above a visual clip. - expect(trackOf(out, "v")).toBe(0); - expect(trackOf(out, "m1")).toBeGreaterThan(trackOf(out, "v")); - expect(trackOf(out, "m2")).toBeGreaterThan(trackOf(out, "v")); - }); - - it("treats missing / auto z as 0 (undefined z clip sinks below a positive-z overlap)", () => { - const out = normalizeToZones([ - { id: "noz", tag: "video", start: 0, duration: 10, track: 0 }, // no zIndex - zClip("pos", 0, 10, 0, 3), - ]); - expect(trackOf(out, "pos")).toBe(0); // z=3 → upper - expect(trackOf(out, "noz")).toBe(1); // absent z ⇒ 0 → below - }); - - it("tie-breaks equal-z overlapping clips on the STABLE id, not the mutated lane", () => { - // Equal z + full overlap: order must be deterministic (id asc) and survive - // re-normalization — the historical oscillation bug tie-broke on the track. + it("spills two fully-overlapping same-track clips by stable id (a above b)", () => { const out = normalizeToZones([zClip("b", 0, 10, 0, 5), zClip("a", 0, 10, 0, 5)]); expect(trackOf(out, "a")).toBe(0); // "a" < "b" expect(trackOf(out, "b")).toBe(1); + // Survives re-normalization (stable id tie-break, never the mutated lane). const twice = normalizeToZones(out); for (const e of out) expect(trackOf(twice, e.id)).toBe(e.track); }); - - it("FIXED POINT: normalizeToZones(normalizeToZones(x)) === normalizeToZones(x) with z present", () => { - const input = [ - zClip("hi", 0, 10, 0, 9), - zClip("lo", 0, 10, 0, 1), - zClip("mid", 2, 6, 0, 5), - zClip("seq", 12, 4, 0, 7), - zClip("music", 0, 16, 1, 3, "audio"), - ]; - expectZoningIdempotent(input); - }); - - it("reload simulation: re-deriving lanes from the SAME z values yields identical lanes", () => { - // Simulate two independent discovery passes producing fresh element objects - // carrying the same z — lane assignment must be stable across reloads. - const build = (): TimelineElement[] => [ - zClip("hi", 0, 10, 0, 9), - zClip("lo", 0, 10, 0, 1), - zClip("mid", 3, 5, 0, 5), - ]; - const first = normalizeToZones(build()); - const second = normalizeToZones(build()); - for (const e of first) expect(trackOf(second, e.id)).toBe(e.track); - }); }); -describe("normalizeToZones — cross-track z→lane (real qa-clean shape)", () => { - // Derived from /tmp/hf-dnd-qa/qa-clean: a full-length video on authored track 0 - // (z=0), two logo SVGs on track 1 (z=26 and z=0), an icon on track 3 (z=5), and - // background music on track 2. In the canvas the z=26 / z=5 icons paint ON TOP of - // the z=0 video; the timeline must agree — the higher-z tracks sit on upper lanes. - const realProject = (): TimelineElement[] => [ - zClip("ralu", 6.14, 3, 3, 5, "img"), - zClip("video", 1, 20, 0, 0, "video"), - zClip("blueLogo", 5.93, 3, 1, 26, "img"), - zClip("blackLogo", 1, 3, 1, 0, "img"), - zClip("music", 8.93, 8, 2, 0, "audio"), +describe("normalizeToZones — legacy file with scattered z (requirement 6)", () => { + // Mirrors /tmp/hf-fixwave/userproj/index.html: many visual clips on contiguous + // authored tracks (0..17) each carrying an unrelated, scattered inline z-index, + // and audio on the highest tracks (18..). The display must follow the + // track-index, NOT the z, and a well-formed (contiguous, audio-last) legacy file + // must not be re-laned at all — normalize is the identity. + const legacy = (): TimelineElement[] => [ + zClip("sub-0", 3, 1.15, 0, 0, "div"), // no explicit z (0) + zClip("cap-hit", 4.51, 1.73, 3, 26, "div"), // scattered z + zClip("cap-send", 5.85, 1.27, 4, 25, "div"), + zClip("avatar", 6.4, 1.148, 1, 26), + zClip("v-opener", 0, 3, 15, 12), + zClip("v-letters", 30.08, 4.39, 5, 25), + zClip("music", 3, 42.95, 18, 10, "audio"), + zClip("vo", 3.2, 10.3, 19, 4, "audio"), ]; - it("stacks a higher-z track ABOVE a lower-z track on a different authored track", () => { - const out = normalizeToZones(realProject()); - // Track 1 (max z 26) tops the visual zone, then track 3 (z 5), then track 0 (z 0). - expect(trackOf(out, "blueLogo")).toBe(0); - expect(trackOf(out, "blackLogo")).toBe(0); // sequential to blueLogo → shares lane - expect(trackOf(out, "ralu")).toBe(1); - expect(trackOf(out, "video")).toBe(2); - // Audio stays at the very bottom regardless of its authored track index. - expect(trackOf(out, "music")).toBe(3); - }); - - it("the video (z=0) no longer sits above the z=26 / z=5 icons — canvas & timeline agree", () => { - const out = normalizeToZones(realProject()); - expect(trackOf(out, "video")).toBeGreaterThan(trackOf(out, "blueLogo")); - expect(trackOf(out, "video")).toBeGreaterThan(trackOf(out, "ralu")); - }); - - it("is idempotent on the real-project shape (no lane drift on re-discovery)", () => { - const once = normalizeToZones(realProject()); - const twice = normalizeToZones(once); - for (const e of once) expect(trackOf(twice, e.id)).toBe(e.track); - }); - - it("re-derives identical lanes from fresh objects carrying the same z (reload-stable)", () => { - const first = normalizeToZones(realProject()); - const second = normalizeToZones(realProject()); - for (const e of first) expect(trackOf(second, e.id)).toBe(e.track); - }); - - it("all-equal-z overlapping clips stack by DOM order (later on top), lanes contiguous", () => { - // Was: "keeps ascending authored track order when all tracks share z" — that - // pinned t0=0/t1=1/t3=2 to the authored track index. But these three all - // start at 0 and OVERLAP, all z=0, so CSS paints them by DOM order: t3 (last) - // on top. The per-clip pack now reflects that (t3 lane 0, then t1, then t0), - // which is what the canvas actually shows. Lanes stay contiguous 0..2. - const out = normalizeToZones([ - zClip("t0", 0, 2, 0, 0), - zClip("t1", 0, 2, 1, 0), - zClip("t3", 0, 2, 3, 0), - ]); - expect(trackOf(out, "t3")).toBe(0); // last in DOM → paints on top - expect(trackOf(out, "t1")).toBe(1); - expect(trackOf(out, "t0")).toBe(2); - }); -}); - -describe("normalizeToZones — EXACT qa-clean repro (per-clip constrained pack)", () => { - // The live repro from /tmp/hf-dnd-qa/qa-clean/index.html: - // blue-logo authored track 0, z=3, 6.37–9.37 - // ralu image authored track 0, z=0, 6.37–9.37 (shares track 0 with blue-logo) - // black-logo authored track 1, z=1, 11.92–14.92 - // video authored track 3, z=2, 0.84–20.84 - // Canvas truth: video (z=2) covers ralu (z=0). The OLD whole-track packer - // ordered track 0 by its MAX z (3, from blue-logo), so ralu rode above the - // z=2 video — the timeline↔canvas contradiction. Array order below = DOM order. - - it("ACCEPTANCE: lane order top→bottom is blue-logo, video, black-logo, ralu", () => { - const out = normalizeToZones(qaCleanRepro()); - expect(trackOf(out, "blue-logo")).toBe(0); // z=3 → top - expect(trackOf(out, "video")).toBe(1); // z=2, overlaps blue-logo → below it - expect(trackOf(out, "black-logo")).toBe(2); // z=1, overlaps video (11.92–14.92 ∩ 0.84–20.84) - expect(trackOf(out, "ralu")).toBe(3); // z=0, overlaps blue-logo AND video → bottom - }); - - it("REGRESSION: a low-z clip must not ride its authored trackmate's high z above a clip that covers it", () => { - const out = normalizeToZones(qaCleanRepro()); - // ralu (z=0) shares authored track 0 with blue-logo (z=3) but must sink BELOW - // the video (z=2) that overlaps and paints over it — the whole-track bug. - expect(trackOf(out, "ralu")).toBeGreaterThan(trackOf(out, "video")); - // black-logo (z=1) below video (z=2) because they overlap in time. - expect(trackOf(out, "black-logo")).toBeGreaterThan(trackOf(out, "video")); - }); - - it("FIXED POINT: running the NEW pack on its own output changes nothing", () => { - const once = normalizeToZones(qaCleanRepro()); - const twice = normalizeToZones(once); - for (const e of once) expect(trackOf(twice, e.id)).toBe(e.track); - // And a third pass, to be sure convergence is genuine. - const thrice = normalizeToZones(twice); - for (const e of twice) expect(trackOf(thrice, e.id)).toBe(e.track); - }); -}); - -describe("z ↔ lane round-trip convergence (both directions agree)", () => { - // Project a normalized TimelineElement onto the StackingElement view the - // forward (lane→z) mapping reasons over. - const toStacking = (els: TimelineElement[]): StackingElement[] => - els.map((e, domIndex) => ({ - key: e.key ?? e.id, - start: e.start, - duration: e.duration, - track: e.track, - zIndex: Number.isFinite(e.zIndex) ? (e.zIndex as number) : 0, - isAudio: classifyZone(e) === "audio", - domIndex, - })); - - it("lane-move → z patch → re-discovery orders lanes by that same z → identical lanes (no oscillation)", () => { - // Two fully-overlapping visual clips. Authored: a below (z=1), b above (z=5). - const authored: TimelineElement[] = [zClip("a", 0, 10, 0, 1), zClip("b", 0, 10, 0, 5)]; - const normalized = normalizeToZones(authored); - // z→lane placed b (z=5) on the upper lane 0, a on lane 1. - expect(trackOf(normalized, "b")).toBe(0); - expect(trackOf(normalized, "a")).toBe(1); - - // USER lane-move: drag a to the TOP (lane 0) and push b down (lane 1). - const afterMove = normalized.map((e) => - e.id === "a" ? { ...e, track: 0 } : e.id === "b" ? { ...e, track: 1 } : e, - ); - - // FORWARD: a lane-move writes the minimal z patch for the edited clip. - const patches = computeStackingPatches(toStacking(afterMove), ["a"]); - expect(patches).toEqual([{ key: "a", zIndex: 6 }]); // lifted above b (5) - - // Apply the z patch back onto the elements (what handleDomZIndexReorderCommit - // persists; next discovery re-reads it as TimelineElement.zIndex). - const rediscovered = afterMove.map((e) => { - const p = patches.find((pp) => pp.key === (e.key ?? e.id)); - return p ? { ...e, zIndex: p.zIndex } : e; - }); - - // REVERSE: re-normalize from the new z. a (z=6) must now own the upper lane — - // the same lane the user moved it to. Directions converge, they do not fight. - const renormalized = normalizeToZones(rediscovered); - expect(trackOf(renormalized, "a")).toBe(0); - expect(trackOf(renormalized, "b")).toBe(1); - - // FIXED POINT: forward on the converged state produces NO further patch, and - // reverse is idempotent — the round-trip is stable. - expect(computeStackingPatches(toStacking(renormalized), ["a"])).toEqual([]); - const twice = normalizeToZones(renormalized); - for (const e of renormalized) expect(trackOf(twice, e.id)).toBe(e.track); + it("lanes follow the track-index, not the scattered z", () => { + const out = normalizeToZones(legacy()); + // Visual tracks 0,1,3,4,5,15 compact to lanes 0..5 in ascending track order — + // z (0,26,25,26,12,25) is ignored. + expect(trackOf(out, "sub-0")).toBe(0); // track 0 + expect(trackOf(out, "avatar")).toBe(1); // track 1 + expect(trackOf(out, "cap-hit")).toBe(2); // track 3 + expect(trackOf(out, "cap-send")).toBe(3); // track 4 + expect(trackOf(out, "v-letters")).toBe(4); // track 5 + expect(trackOf(out, "v-opener")).toBe(5); // track 15 + // Audio stays below every visual lane. + expect(trackOf(out, "music")).toBe(6); + expect(trackOf(out, "vo")).toBe(7); + // The z=26 caption does NOT ride above the z=0 subtitle on track 0. + expect(trackOf(out, "cap-hit")).toBeGreaterThan(trackOf(out, "sub-0")); + }); + + it("does not rewrite a well-formed (contiguous, audio-last) legacy set — identity", () => { + // Same shape but authored tracks already contiguous 0..7 with audio last. + const input = [ + zClip("a", 0, 3, 0, 12), + zClip("b", 0, 3, 1, 26), + zClip("c", 0, 3, 2, 3), + zClip("m", 0, 3, 3, 9, "audio"), + ]; + // Every clip already sits on its track-index lane, so normalize is a no-op. + expect(normalizeToZones(input)).toBe(input); }); - it("qa-clean: drag video BELOW ralu → z patch → re-pack keeps it below, no oscillation", () => { - // EXACT repro fixture (array order = DOM order). - const normalized = normalizeToZones(qaCleanRepro()); - // Baseline lanes: blue-logo 0, video 1, black-logo 2, ralu 3. - expect(trackOf(normalized, "video")).toBe(1); - expect(trackOf(normalized, "ralu")).toBe(3); - - // USER lane-move: drag video to the lane BELOW ralu (bottom). ralu is at - // lane 3, so video goes to a lane strictly greater — model it as lane 4. - const afterMove = normalized.map((e) => (e.id === "video" ? { ...e, track: 4 } : e)); - - // FORWARD: video (z=2) must drop below ralu (z=0). No integer z ≥ 0 fits - // strictly below 0, so the tie-aware sync cascades: video→0 and the clips that - // must stay above it (ralu z=0, black-logo z=1, blue-logo z=3) are bumped as - // needed so video paints below ralu with all z ≥ 0. - const patches = computeStackingPatches(toStacking(afterMove), ["video"]); - const patchByKey = new Map(patches.map((p) => [p.key, p.zIndex])); - // Video was moved; it must now be strictly below ralu in paint order. - const zAfter = (id: string): number => - patchByKey.get(id) ?? (afterMove.find((e) => e.id === id)!.zIndex as number); - expect(zAfter("video")).toBeLessThan(zAfter("ralu")); - expect(zAfter("video")).toBeGreaterThanOrEqual(0); - expect(patchByKey.size).toBeGreaterThan(0); - - // Apply patches and re-pack: video's lane must now be BELOW ralu's. - const rediscovered = afterMove.map((e) => { - const z = patchByKey.get(e.id); - return z != null ? { ...e, zIndex: z } : e; - }); - const renormalized = normalizeToZones(rediscovered); - expect(trackOf(renormalized, "video")).toBeGreaterThan(trackOf(renormalized, "ralu")); - - // FIXED POINT: re-running BOTH directions on the converged state is a no-op. - expect(computeStackingPatches(toStacking(renormalized), ["video"])).toEqual([]); - const twice = normalizeToZones(renormalized); - for (const e of renormalized) expect(trackOf(twice, e.id)).toBe(e.track); + it("is idempotent on the scattered-z legacy shape (no drift on re-discovery)", () => { + expectZoningIdempotent(legacy()); }); }); diff --git a/packages/studio/src/player/components/timelineZones.ts b/packages/studio/src/player/components/timelineZones.ts index aed2a0a921..515555b3e1 100644 --- a/packages/studio/src/player/components/timelineZones.ts +++ b/packages/studio/src/player/components/timelineZones.ts @@ -3,8 +3,8 @@ import { isAudioTimelineElement } from "../../utils/timelineInspector"; /** * Free-form vertical zones, top → bottom: visual, audio. There is no "main track" - * — layering is CSS z-index (the renderer ignores track index), so the timeline's - * only job is to keep visual clips grouped above audio clips. + * — canvas layering is CSS z-index (the renderer ignores track index), so the + * timeline's only job is to keep visual clips grouped above audio clips. */ export type TrackZone = "visual" | "audio"; @@ -16,9 +16,6 @@ export function classifyZone(el: TimelineElement): TrackZone { const keyOf = (el: TimelineElement) => el.key ?? el.id; -/** Stacking order for a clip: missing / "auto" z is treated as 0. */ -const zOf = (el: TimelineElement) => (Number.isFinite(el.zIndex) ? (el.zIndex as number) : 0); - const EPS = 1e-6; /** Two clips overlap when their half-open [start, end) intervals intersect. */ @@ -26,166 +23,105 @@ function overlaps(a: TimelineElement, b: TimelineElement): boolean { return a.start < b.start + b.duration - EPS && b.start < a.start + a.duration - EPS; } -/** A clip paired with its position in the discovery/document (input) order. */ -interface IndexedClip { - el: TimelineElement; - /** Index in the input `elements` array = discovery/DOM order. */ - domIndex: number; -} - -/** One display lane: the clips packed onto it, in placement order. */ -interface Lane { - occupants: IndexedClip[]; - /** The single authored track all occupants share, or null once mixed (never - * happens — we only ever add same-track clips to an existing lane). */ - track: number; -} - -/** - * Lowest lane index a clip may occupy: strictly above every already-placed lane - * holding a clip it overlaps in time (all of which out-stack it by the z-desc - * placement order). - */ -function lowestAllowedLane(lanes: Lane[], item: IndexedClip): number { - let minLane = 0; - for (let i = 0; i < lanes.length; i++) { - if (lanes[i].occupants.some((o) => overlaps(o.el, item.el))) minLane = i + 1; - } - return minLane; +/** Deterministic order on the stable clip id (never the mutated lane/track). */ +function byStableId(a: TimelineElement, b: TimelineElement): number { + const ka = keyOf(a); + const kb = keyOf(b); + return ka < kb ? -1 : ka > kb ? 1 : 0; } /** - * First lane at index ≥ minLane that holds solely this clip's authored track and - * nothing overlapping (so sequential same-track clips share a lane); -1 when none - * qualifies and a fresh lane must open. - */ -function findReusableLane(lanes: Lane[], minLane: number, item: IndexedClip): number { - for (let i = minLane; i < lanes.length; i++) { - const lane = lanes[i]; - if (lane.track !== item.el.track) continue; - if (lane.occupants.some((o) => overlaps(o.el, item.el))) continue; - return i; - } - return -1; -} - -/** - * Pack a WHOLE zone's clips onto display lanes with a single constrained pass so - * that, for EVERY pair of time-overlapping clips, lane order (upper = lower index) - * equals canvas stacking order. This replaces the old two-stage - * `orderTrackBlocksByZ` + per-track `packTrackLanes`, which ordered whole authored - * tracks by their MAX z and so lifted a low-z clip above a clip that covers it - * whenever it shared a track with a high-z clip (the qa-clean ralu/video bug — a - * low-z image rode its z=3 trackmate above the z=2 video that paints over it). No - * whole-track mapping can fix that; the mapping must be per-clip. + * Pack ONE authored track's clips onto sub-lanes so no two time-overlapping clips + * share a lane. Clips are ordered by their STABLE id (a function of the input, not + * of the lane being computed — the historical oscillation bug tie-broke on the + * mutated track) and placed first-fit, so sequential (non-overlapping) clips + * collapse onto a single lane and only genuine time overlaps spill onto adjacent + * sub-lanes. Writes each clip's absolute display lane into `laneOf`; returns the + * number of lanes used (≥ 1 when non-empty). * - * Algorithm: - * 1. Order clips by z DESC; z-tie → INPUT-ARRAY-INDEX (DOM order) DESC (CSS - * paints equal-z siblings by DOM order — LATER in DOM paints on top, so it - * must place first / upper); final tie → stable key. NEVER tie-break on the - * mutated lane/track index (historic oscillation bug — the tie-break must be - * a stable function of the input, not of the output being computed). - * 2. Place each clip at lane ≥ (1 + highest lane among already-placed clips it - * OVERLAPS IN TIME). By z-desc placement every already-placed overlapping - * clip out-stacks this one (higher z, or equal z but later in DOM), so this - * guarantees lane order == stacking order for every overlapping pair. - * 3. To preserve the "distinct authored tracks stay distinct / sequential - * same-track clips share a lane" feel, reuse an existing lane at index ≥ that - * minimum ONLY when the lane's occupants are all from the SAME authored track - * AND none overlaps this clip in time; otherwise open a fresh lane. - * - * Writes each clip's absolute display lane (`base + laneIndex`) into `laneOf` and - * returns the number of lanes used (≥ 1 when non-empty). + * The editor enforces no per-track time overlap, so the spill only fires on legacy + * files. It is DISPLAY-ONLY — a drag commit persists just the dragged clip, never + * this re-lane — so it never rewrites the source. */ -function packZoneLanes(clips: IndexedClip[], base: number, laneOf: Map): number { - const ordered = [...clips].sort( - (a, b) => - zOf(b.el) - zOf(a.el) || b.domIndex - a.domIndex || (keyOf(a.el) < keyOf(b.el) ? -1 : 1), - ); - const lanes: Lane[] = []; - for (const item of ordered) { - const minLane = lowestAllowedLane(lanes, item); - let placed = findReusableLane(lanes, minLane, item); - if (placed === -1) { - placed = lanes.length; - lanes.push({ occupants: [], track: item.el.track }); +function packTrackLanes( + clips: TimelineElement[], + base: number, + laneOf: Map, +): number { + const ordered = [...clips].sort(byStableId); + const lanes: TimelineElement[][] = []; + for (const el of ordered) { + let sub = lanes.findIndex((occ) => occ.every((o) => !overlaps(o, el))); + if (sub === -1) { + sub = lanes.length; + lanes.push([]); } - lanes[placed].occupants.push(item); - laneOf.set(keyOf(item.el), base + placed); + lanes[sub].push(el); + laneOf.set(keyOf(el), base + sub); } - return lanes.length; + return Math.max(1, lanes.length); } /** - * Legacy per-track interval packing for the AUDIO zone (no z semantics): pack one - * authored track's clips onto sub-lanes so no two overlap in time — sequential - * clips share a lane, overlapping ones spill onto the next (first-fit). Ordered by - * start (then stable key) so the layout is deterministic and idempotent. Returns - * the number of lanes used (≥ 1 when non-empty). + * Pack a whole zone's clips onto contiguous display lanes, CapCut-stable: lanes + * follow the authored `data-track-index` (ASCENDING; ties by stable id) — NEVER a + * z-rank. Each distinct authored track owns its own lane (in ascending order); + * sequential same-track clips share it; time-overlapping same-track clips spill to + * adjacent sub-lanes (packTrackLanes). Returns the number of lanes used. + * + * This REPLACES the old global-z-rank interval pack. That pack ordered visual + * lanes by z-index and interval-packed overlaps, so editing one clip's z (or the + * whole-set re-pack a lane drag triggered) silently re-laned OTHER clips. The + * product decision is the opposite: a clip's lane is its track, period — z is + * canvas paint order only, and lane assignment must ignore it. */ -function packAudioTrackLanes( - clips: IndexedClip[], +function packZoneLanes( + clips: TimelineElement[], base: number, laneOf: Map, ): number { - const ordered = [...clips].sort( - (a, b) => a.el.start - b.el.start || (keyOf(a.el) < keyOf(b.el) ? -1 : 1), - ); - const lanes: IndexedClip[][] = []; - for (const item of ordered) { - let sub = lanes.findIndex((occ) => occ.every((o) => !overlaps(o.el, item.el))); - if (sub === -1) { - sub = lanes.length; - lanes.push([]); - } - lanes[sub].push(item); - laneOf.set(keyOf(item.el), base + sub); + const byTrack = new Map(); + for (const el of clips) { + const list = byTrack.get(el.track); + if (list) list.push(el); + else byTrack.set(el.track, [el]); } - return Math.max(1, lanes.length); + let used = 0; + for (const track of [...byTrack.keys()].sort((a, b) => a - b)) { + used += packTrackLanes(byTrack.get(track)!, base + used, laneOf); + } + return used; } /** * Assign display lanes for the timeline: visual lanes on top, audio lanes below. * - * The VISUAL zone is packed per-clip (packZoneLanes) so the timeline's vertical - * order matches the canvas's CSS stacking for EVERY time-overlapping pair — a - * low-z clip sinks below a clip that covers it even if it shares an authored track - * with a higher-z clip. Time-overlapping clips still split onto separate lanes - * (standard NLE), sequential same-track clips still share a lane, and distinct - * authored tracks stay distinct. - * - * The AUDIO zone keeps the original behavior — authored-track order, per-track - * interval packing — because audio has no z / stacking semantics. + * Both zones are packed the SAME way — by authored track-index, ascending (see + * packZoneLanes) — so the timeline's vertical order follows each clip's track and + * nothing else. z-index does not participate in lane assignment (it is canvas + * paint order only; the lane ↔ z stacking sync in timelineStackingSync runs the + * other direction, only on a deliberate vertical edit). Time-overlapping same-track + * clips still split onto separate sub-lanes (legacy files only — the editor forbids + * per-track overlap), and that split is display-only, never persisted. * * Pure — returns a new array; unchanged clips keep their identity. Display-only * (runs on discovery); it does not rewrite the source. Idempotent (running it on - * its own output is a fixed point). + * its own output is a fixed point): the lanes it emits are contiguous integers in + * ascending track order, and re-running groups by those same integers unchanged. */ export function normalizeToZones(elements: TimelineElement[]): TimelineElement[] { if (elements.length === 0) return elements; const laneOf = new Map(); - let nextLane = 0; - - const visual: IndexedClip[] = []; - const audio: IndexedClip[] = []; - elements.forEach((el, domIndex) => { - (classifyZone(el) === "audio" ? audio : visual).push({ el, domIndex }); - }); + const visual: TimelineElement[] = []; + const audio: TimelineElement[] = []; + for (const el of elements) { + (classifyZone(el) === "audio" ? audio : visual).push(el); + } + let nextLane = 0; nextLane += packZoneLanes(visual, nextLane, laneOf); - - // Audio: preserve legacy behavior — group by authored track (ascending), pack - // each track's overlapping clips onto sub-lanes. - const audioByTrack = new Map(); - for (const item of audio) { - const list = audioByTrack.get(item.el.track); - if (list) list.push(item); - else audioByTrack.set(item.el.track, [item]); - } - for (const track of [...audioByTrack.keys()].sort((a, b) => a - b)) { - nextLane += packAudioTrackLanes(audioByTrack.get(track)!, nextLane, laneOf); - } + packZoneLanes(audio, nextLane, laneOf); let changed = false; const remapped = elements.map((el) => { diff --git a/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx b/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx new file mode 100644 index 0000000000..8f426ebc05 --- /dev/null +++ b/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx @@ -0,0 +1,164 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { TimelineElement } from "../store/playerStore"; +import { usePlayerStore } from "../store/playerStore"; +import type { ResizingClipState } from "./useTimelineClipDrag"; +import { useTimelineClipDrag } from "./useTimelineClipDrag"; +import { mountReactHarness } from "../../hooks/domSelectionTestHarness"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function el(id: string, over: Partial = {}): TimelineElement { + return { id, key: id, tag: "video", start: 0, duration: 2, track: 0, domId: id, ...over }; +} + +afterEach(() => { + document.body.innerHTML = ""; + usePlayerStore.getState().reset(); +}); + +function renderResizeHarness(elements: TimelineElement[], selected: string[]) { + usePlayerStore.getState().setElements(elements); + usePlayerStore.setState({ timelineSnapEnabled: false }); + usePlayerStore.getState().setSelectedElementIds(new Set(selected)); + + const scroll = document.createElement("div"); + document.body.append(scroll); + const onResizeElement = vi.fn(); + let setResizingClip: ((s: ResizingClipState | null) => void) | null = null; + + function Harness() { + const hook = useTimelineClipDrag({ + scrollRef: { current: scroll }, + ppsRef: { current: 100 }, + durationRef: { current: 100 }, + trackOrderRef: { current: [0, 1] }, + onResizeElement, + setShowPopover: vi.fn(), + setRangeSelectionRef: { current: vi.fn() }, + }); + setResizingClip = hook.setResizingClip; + return null; + } + + const root = mountReactHarness(); + const apply = setResizingClip!; + + return { + onResizeElement, + storeById(id: string) { + return usePlayerStore.getState().elements.find((e) => e.id === id)!; + }, + startResize(element: TimelineElement, edge: "start" | "end") { + act(() => { + apply({ + element, + edge, + originClientX: 0, + previewStart: element.start, + previewDuration: element.duration, + previewPlaybackStart: element.playbackStart, + started: false, + }); + }); + }, + movePointer(clientX: number) { + act(() => { + window.dispatchEvent(new MouseEvent("pointermove", { bubbles: true, clientX, clientY: 0 })); + }); + }, + async dropPointer() { + await act(async () => { + window.dispatchEvent(new MouseEvent("pointerup", { bubbles: true })); + }); + }, + pressEscape() { + act(() => { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + }); + }, + unmount() { + act(() => root.unmount()); + }, + }; +} + +// Two clips a(0,2) + b(5,3) selected as a group, with a's END edge grabbed and +// dragged +0.5s (50px @ 100pps). `bOver` customizes b (e.g. locking it). +function startGroupResize(bOver: Partial = {}) { + const a = el("a", { start: 0, duration: 2 }); + const b = el("b", { start: 5, duration: 3, ...bOver }); + const h = renderResizeHarness([a, b], ["a", "b"]); + h.startResize(a, "end"); + h.movePointer(50); + return { a, b, h }; +} + +// Drop the pointer and assert exactly one resize persisted: the grabbed clip `a` +// grown to 2.5s (the +0.5 gesture), with no other member patched. +async function expectSingleResizeToA( + h: ReturnType, + a: TimelineElement, +): Promise { + await h.dropPointer(); + expect(h.onResizeElement).toHaveBeenCalledTimes(1); + expect(h.onResizeElement).toHaveBeenCalledWith(a, expect.objectContaining({ duration: 2.5 })); +} + +describe("useTimelineClipDrag — single-clip resize (unchanged path)", () => { + it("resizes only the grabbed clip and persists once", async () => { + const a = el("a", { start: 0, duration: 2 }); + const b = el("b", { start: 5, duration: 2 }); + const h = renderResizeHarness([a, b], []); // no multi-selection + + h.startResize(a, "end"); + h.movePointer(50); // +0.5s at 100 pps + await expectSingleResizeToA(h, a); + expect(h.storeById("a").duration).toBe(2.5); + expect(h.storeById("b").duration).toBe(2); // untouched + h.unmount(); + }); +}); + +describe("useTimelineClipDrag — multi-select group resize (restored)", () => { + it("previews the non-grabbed member through the store, grabbed stays out until commit", () => { + const { h } = startGroupResize(); // grabbed asks +0.5 + + // Non-grabbed member is previewed in the store; the grabbed clip renders from + // resizingClip state, so its store value is still the original until commit. + expect(h.storeById("b").duration).toBe(3.5); + expect(h.storeById("a").duration).toBe(2); + h.unmount(); + }); + + it("commits the whole group — persists every member by the shared delta", async () => { + const { a, b, h } = startGroupResize(); + await h.dropPointer(); + + expect(h.onResizeElement).toHaveBeenCalledTimes(2); + expect(h.onResizeElement).toHaveBeenCalledWith(a, expect.objectContaining({ duration: 2.5 })); + expect(h.onResizeElement).toHaveBeenCalledWith(b, expect.objectContaining({ duration: 3.5 })); + expect(h.storeById("a").duration).toBe(2.5); + expect(h.storeById("b").duration).toBe(3.5); + h.unmount(); + }); + + it("degrades to single-clip when a selected member is locked — locked clip untouched", async () => { + const { a, h } = startGroupResize({ timelineLocked: true }); + await expectSingleResizeToA(h, a); + expect(h.storeById("b").duration).toBe(3); // locked member never patched + h.unmount(); + }); + + it("Escape rolls back the previewed non-grabbed member and persists nothing", () => { + const { h } = startGroupResize(); + expect(h.storeById("b").duration).toBe(3.5); // previewed + + h.pressEscape(); + expect(h.storeById("b").duration).toBe(3); // restored + expect(h.onResizeElement).not.toHaveBeenCalled(); + h.unmount(); + }); +}); diff --git a/packages/studio/src/player/components/useTimelineClipDrag.ts b/packages/studio/src/player/components/useTimelineClipDrag.ts index fd6c1a8b4c..4e9fa2d831 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.ts +++ b/packages/studio/src/player/components/useTimelineClipDrag.ts @@ -7,12 +7,21 @@ import { } from "./timelineEditing"; import { usePlayerStore } from "../store/playerStore"; import type { TimelineElement } from "../store/playerStore"; -import { isMusicTrack } from "../../utils/timelineInspector"; +import { isMusicTrack, isAudioTimelineElement } from "../../utils/timelineInspector"; import { mergeUserBeats } from "../../utils/beatEditing"; +import { + buildTimelineGroupResizeMembers, + type TimelineGroupResizeSession, +} from "./timelineGroupEditing"; import { collectTimelineSnapTargets, type TimelineSnapTarget } from "./timelineSnapping"; import { commitDraggedClipMove } from "./timelineClipDragCommit"; import type { StackingPatch } from "./timelineStackingSync"; -import { computeDragPreview, computeResizePreview } from "./timelineClipDragPreview"; +import { + computeDragPreview, + computeResizePreview, + previewGroupResize, + type ResizePreviewResult, +} from "./timelineClipDragPreview"; import type { DraggedClipState, ResizingClipState, @@ -113,15 +122,30 @@ export function useTimelineClipDrag({ const elementsRef = useRef(elements); elementsRef.current = elements; + // Perf (frozen-per-gesture): the snap-target set and the audio-track set are + // fixed for the duration of one drag/resize (the store is not re-authored mid + // gesture), so build each ONCE and reuse it across every pointermove and every + // auto-scroll frame. Both caches are cleared at gesture teardown + // (stopClipDragAutoScroll), so the next gesture rebuilds against fresh state. + const snapTargetsCacheRef = useRef>(new Map()); + const dragAudioTracksRef = useRef | null>(null); + const buildSnapTargets = useCallback( (excludeElementKey: string | null, includeBeats: boolean): TimelineSnapTarget[] => { + // Magnet off ⇒ no targets and no scan; do NOT cache so a mid-gesture toggle + // back on starts scanning immediately (preserves the existing skip). if (!snapContextRef.current.enabled) return []; - return collectTimelineSnapTargets({ + const cacheKey = `${excludeElementKey ?? ""}|${includeBeats ? 1 : 0}`; + const cached = snapTargetsCacheRef.current.get(cacheKey); + if (cached) return cached; + const targets = collectTimelineSnapTargets({ elements: elementsRef.current, playheadTime: usePlayerStore.getState().currentTime, beatTimes: includeBeats ? snapContextRef.current.beatTimes : [], excludeElementKey, }); + snapTargetsCacheRef.current.set(cacheKey, targets); + return targets; }, [], ); @@ -137,6 +161,30 @@ export function useTimelineClipDrag({ const blockedClipRef = useRef(null); const suppressClickRef = useRef(false); + // Active multi-select group-resize session (restored from main 36413da7f): set + // lazily on the first resize pointermove when the grabbed clip is part of a + // capability-clean multi-selection (null ⇒ single-clip resize). Holds the + // pre-gesture snapshot so the non-grabbed members (previewed through the store) + // roll back on escape / cancel / failed persist. + const groupResizeRef = useRef(null); + + // Restore the non-grabbed group members to their pre-gesture timing (the + // grabbed clip renders from resizingClip state, so it is never written during + // preview). `all` also restores the grabbed clip after a committed persist fails. + const restoreGroupResizeMembers = useCallback( + (session: TimelineGroupResizeSession, all = false) => { + for (const m of session.members) { + if (!all && m.key === session.grabbedKey) continue; + updateElement(m.key, { + start: m.start, + duration: m.duration, + playbackStart: m.playbackStart, + }); + } + }, + [updateElement], + ); + const onMoveElementRef = useRef(onMoveElement); onMoveElementRef.current = onMoveElement; const onMoveElementsRef = useRef(onMoveElements); @@ -157,8 +205,15 @@ export function useTimelineClipDrag({ // (move + snap + group clamp + drop placement) is a tested pure function so // what runs here is what's verified — see timelineClipDragPreview. const updateDraggedClipPreview = useCallback( - (drag: DraggedClipState, clientX: number, clientY: number): DraggedClipState => - computeDragPreview(drag, clientX, clientY, { + (drag: DraggedClipState, clientX: number, clientY: number): DraggedClipState => { + // Build the audio-track set once per gesture (see snapTargetsCacheRef): it + // only feeds zone-aware drop placement and is frozen while dragging. + if (!dragAudioTracksRef.current) { + dragAudioTracksRef.current = new Set( + elementsRef.current.filter(isAudioTimelineElement).map((e) => e.track), + ); + } + return computeDragPreview(drag, clientX, clientY, { scroll: scrollRef.current, pps: ppsRef.current, duration: durationRef.current, @@ -166,14 +221,15 @@ export function useTimelineClipDrag({ elements: elementsRef.current, selectedKeys: usePlayerStore.getState().selectedElementIds, buildSnapTargets, - }), + audioTracks: dragAudioTracksRef.current, + }); + }, [scrollRef, ppsRef, durationRef, trackOrderRef, buildSnapTargets], ); // Recompute the trim preview for a pointer x. Shared by the pointermove resize - // branch and the edge auto-scroll stepper (which re-runs it as the content - // scrolls under a stationary pointer, so the trim keeps tracking). The compute - // is a pure function (timelineClipDragPreview); here we only apply the state. + // branch and the edge auto-scroll stepper (re-runs as content scrolls under a + // stationary pointer). computeResizePreview is pure; here we only apply state. const applyResizePointer = useCallback( (resize: ResizingClipState, clientX: number) => { const next = computeResizePreview(resize, clientX, { @@ -181,20 +237,35 @@ export function useTimelineClipDrag({ pps: ppsRef.current, buildSnapTargets, }); - setResizingClip((prev) => - prev - ? { - ...prev, - started: true, - originScrollLeft: next.originScrollLeft, - previewStart: next.previewStart, - previewDuration: next.previewDuration, - previewPlaybackStart: next.previewPlaybackStart, - } - : prev, - ); + const setResizeState = (v: ResizePreviewResult) => + setResizingClip((prev) => (prev ? { ...prev, started: true, ...v } : prev)); + + // Group resize: a capability-clean multi-selection resizes rigidly by one + // shared, member-clamped delta (legacy main 36413da7f). The grabbed clip + // drives the raw delta and renders from resizingClip state; non-grabbed + // members preview through the store (their store value stays pristine). + const grabbedKey = resize.element.key ?? resize.element.id; + let session = groupResizeRef.current; + if (!session || session.grabbedKey !== grabbedKey || session.edge !== resize.edge) { + const members = buildTimelineGroupResizeMembers( + elementsRef.current, + usePlayerStore.getState().selectedElementIds, + grabbedKey, + resize.edge, + ); + session = members + ? { grabbedKey, edge: resize.edge, members, changes: [], hasChanged: false } + : null; + groupResizeRef.current = session; + } + + if (!session) { + setResizeState(next); + return; + } + previewGroupResize(session, next, grabbedKey, updateElement, setResizeState); }, - [scrollRef, ppsRef, buildSnapTargets], + [scrollRef, ppsRef, buildSnapTargets, updateElement], ); const applyResizePointerRef = useRef(applyResizePointer); applyResizePointerRef.current = applyResizePointer; @@ -205,6 +276,11 @@ export function useTimelineClipDrag({ cancelAnimationFrame(clipDragScrollRaf.current); clipDragScrollRaf.current = 0; } + // Gesture teardown: drop the frozen-per-gesture perf caches so the next drag + // rebuilds them against fresh store state (see snapTargetsCacheRef). Does NOT + // touch groupResizeRef — commit reads it after this runs. + snapTargetsCacheRef.current.clear(); + dragAudioTracksRef.current = null; }, []); const stepClipDragAutoScroll = useCallback(() => { @@ -316,14 +392,59 @@ export function useTimelineClipDrag({ }; /* ── pointerup commit handlers (dispatched by drag/resize/blocked) ──── */ + const commitGroupResize = (session: TimelineGroupResizeSession) => { + if (!session.hasChanged) return; + const changes = session.changes; + // The grabbed clip's timing lived only in resizingClip state during preview, + // so write EVERY member (grabbed included) to the store now, then persist. + for (const c of changes) { + updateElement(c.key, { + start: c.start, + duration: c.duration, + playbackStart: c.playbackStart, + }); + } + const persist = onResizeElementRef.current; + if (!persist) return; + // No batch resize handler is wired to this hook (only single-clip + // onResizeElement), so each member persists on its own request — a group + // resize without atomic single-undo. Roll the WHOLE group back if any write + // rejects. + Promise.all( + changes.map((c) => + Promise.resolve( + persist(c.element, { + start: c.start, + duration: c.duration, + playbackStart: c.playbackStart, + }), + ), + ), + ).catch((error) => { + restoreGroupResizeMembers(session, true); + console.error("[Timeline] Failed to persist group clip resize", error); + }); + }; + const commitResizePointerUp = (resize: ResizingClipState) => { resizingClipRef.current = null; setResizingClip(null); - if (!resize.started) return; + const groupSession = groupResizeRef.current; + groupResizeRef.current = null; + if (!resize.started) { + // No preview ran, so no group store-mutation to undo; guard is defensive. + if (groupSession) restoreGroupResizeMembers(groupSession); + return; + } suppressClickRef.current = true; clearSuppressedClick(); + if (groupSession) { + commitGroupResize(groupSession); + return; + } + const hasChanged = resize.previewStart !== resize.element.start || resize.previewDuration !== resize.element.duration || @@ -423,6 +544,11 @@ export function useTimelineClipDrag({ setDraggedClip(null); resizingClipRef.current = null; setResizingClip(null); + // Undo any group-resize preview store-mutation (non-grabbed members) so the + // cancelled gesture restores the pre-drag timeline, like the single-clip path. + const groupSession = groupResizeRef.current; + groupResizeRef.current = null; + if (groupSession) restoreGroupResizeMembers(groupSession); blockedClipRef.current = null; // The pointer is usually still down; keep the suppressor armed until the // eventual pointerup (which disarms it) so its click can't reselect. diff --git a/packages/studio/src/player/components/useTimelineStackingSync.ts b/packages/studio/src/player/components/useTimelineStackingSync.ts index 7f2ec35dad..84e12a0abc 100644 --- a/packages/studio/src/player/components/useTimelineStackingSync.ts +++ b/packages/studio/src/player/components/useTimelineStackingSync.ts @@ -45,16 +45,22 @@ export function useTimelineStackingSync({ expandedElementsRef }: UseTimelineStac [zSyncPreviewIframeRef, zSyncActiveCompPath], ); + // NaN (NOT 0) when the element can't be resolved in the preview iframe — a + // nested / unmounted sub-comp node, or one outside the active file. Fabricating + // z=0 would enter computeStackingPatches as a real overlapping neighbour at the + // z-floor and skew the boundary math; a non-finite value tells it to EXCLUDE this + // clip instead. NaN (rather than null) keeps the return assignable to the + // `(el) => number` reader contract the drag hook / commit deps declare. const readClipZIndex = useCallback( (el: TimelineElement): number => { const node = resolveIframeElement(el); - return node ? readEffectiveZIndex(node) : 0; + return node ? readEffectiveZIndex(node) : Number.NaN; }, [resolveIframeElement], ); const applyStackingPatches = useCallback( - (patches: StackingPatch[]) => { + (patches: StackingPatch[], coalesceKey?: string) => { if (!handleDomZIndexReorderCommit) return; const entries = patches.flatMap((p) => { const el = expandedElementsRef.current.find((e) => (e.key ?? e.id) === p.key); @@ -71,7 +77,9 @@ export function useTimelineStackingSync({ expandedElementsRef }: UseTimelineStac }, ]; }); - if (entries.length) handleDomZIndexReorderCommit(entries); + // Forward the drag-commit's shared coalesce key so the z-reorder history + // entry merges with the lane change's move entry into one undo step. + if (entries.length) handleDomZIndexReorderCommit(entries, coalesceKey); }, [handleDomZIndexReorderCommit, resolveIframeElement, zSyncActiveCompPath, expandedElementsRef], ); diff --git a/packages/studio/src/player/index.ts b/packages/studio/src/player/index.ts index 592e83f309..72e50a2bcd 100644 --- a/packages/studio/src/player/index.ts +++ b/packages/studio/src/player/index.ts @@ -11,7 +11,7 @@ export { resolveIframe } from "./lib/timelineDOM"; // Store export { usePlayerStore, liveTime } from "./store/playerStore"; -export type { TimelineElement } from "./store/playerStore"; +export type { SelectElementOptions, TimelineElement } from "./store/playerStore"; // Utils export { formatTime } from "./lib/time"; diff --git a/packages/studio/src/player/store/playerStore.test.ts b/packages/studio/src/player/store/playerStore.test.ts index 8d70cc81ba..e6d9669b60 100644 --- a/packages/studio/src/player/store/playerStore.test.ts +++ b/packages/studio/src/player/store/playerStore.test.ts @@ -220,97 +220,58 @@ describe("usePlayerStore", () => { usePlayerStore.getState().setSelectedElementId(null); expect(usePlayerStore.getState().selectedElementId).toBeNull(); }); - }); - - 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", () => { + it("collapses a stale multi-select set when single-selecting", () => { 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"); + store.setSelectedElementIds(new Set(["a", "b", "c"])); + store.setSelectedElementId("a"); + expect(usePlayerStore.getState().selectedElementId).toBe("a"); + expect(usePlayerStore.getState().selectedElementIds.size).toBe(0); }); - it("setSelectedElementId collapses to a single element even for a current member", () => { + it("collapses the multi-select set even when re-selecting the same primary", () => { 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"); + store.setSelectedElementId("a"); + store.setSelectedElementIds(new Set(["a", "b"])); + store.setSelectedElementId("a"); + expect(usePlayerStore.getState().selectedElementIds.size).toBe(0); }); - it("setSelectionAnchor moves the anchor within a group without collapsing it", () => { + it("clears the multi-select set on deselect (canvas delete nulls the primary)", () => { 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"); + store.setSelectedElementId("a"); + store.setSelectedElementIds(new Set(["a", "b"])); + store.setSelectedElementId(null); + expect(usePlayerStore.getState().selectedElementId).toBeNull(); + expect(usePlayerStore.getState().selectedElementIds.size).toBe(0); }); - it("clearing single selection empties the set", () => { + it("keeps an additive set written AFTER the primary (marquee ordering)", () => { 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(); + // Marquee commits primary first (collapses), then repopulates the set. + store.setSelectedElementId("a"); + store.setSelectedElementIds(new Set(["a", "b", "c"])); + expect(usePlayerStore.getState().selectedElementId).toBe("a"); + expect(usePlayerStore.getState().selectedElementIds.size).toBe(3); }); - it("toggle adds and removes members while keeping the anchor in the set", () => { + it("preserves the multi-select set when preserveSet is passed (late marquee primary)", () => { 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"); + store.setSelectedElementId("a"); + store.setSelectedElementIds(new Set(["a", "b", "c"])); + // A late async primary resolution re-selects a MEMBER of the live set. + store.setSelectedElementId("a", { preserveSet: true }); + expect(usePlayerStore.getState().selectedElementId).toBe("a"); + expect(usePlayerStore.getState().selectedElementIds.size).toBe(3); }); - it("clearSelection empties the set and the anchor", () => { + it("still collapses without preserveSet even when the primary is a member", () => { 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(); + store.setSelectedElementId("a"); + store.setSelectedElementIds(new Set(["a", "b", "c"])); + // Default (no opt-out) must keep the data-loss guard intact. + store.setSelectedElementId("a"); + expect(usePlayerStore.getState().selectedElementIds.size).toBe(0); }); }); @@ -335,6 +296,17 @@ describe("usePlayerStore", () => { expect(usePlayerStore.getState().elements[0].start).toBe(0); }); + it("syncs zIndex so the reverse z→lane mapping reads fresh store z", () => { + usePlayerStore.getState().setElements([ + { id: "el-1", tag: "div", start: 0, duration: 5, track: 0, zIndex: 1 }, + { id: "el-2", tag: "div", start: 0, duration: 5, track: 1, zIndex: 2 }, + ]); + usePlayerStore.getState().updateElement("el-1", { zIndex: 9 }); + const elements = usePlayerStore.getState().elements; + expect(elements[0].zIndex).toBe(9); + expect(elements[1].zIndex).toBe(2); // unchanged + }); + it("prefers the stable element key when duplicate ids exist", () => { usePlayerStore.getState().setElements([ { id: "headline", key: "a", tag: "div", start: 0, duration: 5, track: 0 }, diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 73553e45c3..9727006e52 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -3,7 +3,7 @@ import type { MusicBeatAnalysis } from "@hyperframes/core/beats"; import type { BeatEditState } from "../../utils/beatEditing"; import type { ClipManifestClip } from "../lib/playbackTypes"; import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences"; -import { computePinnedZoomPercent } from "../components/timelineZoom"; +import { clampTimelineZoomPercent, computePinnedZoomPercent } from "../components/timelineZoom"; /** Minimal keyframe cache types — mirrors GsapKeyframesData without pulling in Node-only gsap-parser. */ export interface KeyframeCacheEntry { @@ -29,16 +29,6 @@ export interface TimelineElement { start: number; duration: number; track: number; - /** Resolved z-index for stacking-aware timeline ordering. */ - zIndex?: number; - /** True when the effective z-index was authored inline or through CSS, not auto. */ - hasExplicitZIndex?: boolean; - /** Stacking context this element belongs to; root clips use the root composition id. */ - stackingContextId?: string | null; - /** Nearest parent composition context, matching RuntimeTimelineClip. */ - parentCompositionId?: string | null; - /** Composition ancestry from root to nearest parent, matching RuntimeTimelineClip. */ - compositionAncestors?: string[]; domId?: string; /** Stable `data-hf-id` attribute value — used as primary patch target when present */ hfId?: string; @@ -64,6 +54,14 @@ export interface TimelineElement { hidden?: boolean; /** Value of data-timeline-role attribute — used to identify music vs. voiceover. */ timelineRole?: string; + /** + * Live CSS stacking order read from the host element during discovery + * (inline/computed z-index; "auto" ⇒ 0). Canvas paint order only — display + * lanes are track-derived (stable-track model); the explicit vertical + * stacking sync in timelineStackingSync updates this on deliberate lane + * moves (lower lane ⇒ higher z). Absent ⇒ treated as 0. + */ + zIndex?: number; /** * Set by useExpandedTimelineElements on an inline-expanded sub-composition * child: the absolute master-timeline start of the sub-comp host the child @@ -76,21 +74,19 @@ export interface TimelineElement { export type ZoomMode = "fit" | "manual"; type TimelineTool = "select" | "razor"; -function resolveElementSelection( - ids: Iterable, - anchor?: string | null, -): { selectedElementIds: Set; 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, - }; +/** Options for {@link PlayerState.setSelectedElementId}. */ +export interface SelectElementOptions { + /** + * Keep the current multi-selection set instead of collapsing it. Multi-select + * flows (marquee, additive click) build the set AFTER writing the primary, and a + * LATE async primary-selection resolution (e.g. the inspector-opening + * handleTimelineElementSelect → applyDomSelection path) can re-fire + * setSelectedElementId with a primary that is ALREADY a member of the live set. + * Passing preserveSet keeps the set intact in that case. The default (collapse) + * still guards the original data-loss bug: a fresh single click on a clip that is + * NOT in the set clears a stale set left over from a previous gesture. + */ + preserveSet?: boolean; } interface PlayerState { @@ -109,6 +105,12 @@ interface PlayerState { zoomMode: ZoomMode; /** Timeline zoom percent relative to the fit width when in manual mode */ manualZoomPercent: number; + /** Timeline magnet toggle — when false, clip drags/trims/drops never snap. */ + timelineSnapEnabled: boolean; + setTimelineSnapEnabled: (enabled: boolean) => void; + /** Transport + ruler readout: timecode ("time") or frame number ("frame"). */ + timeDisplayMode: "time" | "frame"; + setTimeDisplayMode: (mode: "time" | "frame") => void; /** Work-area in-point (seconds). When set, loop starts here and A jumps here. */ inPoint: number | null; /** Work-area out-point (seconds). When set, loop ends here and E jumps here. */ @@ -143,29 +145,10 @@ interface PlayerState { /** Multi-select: additional selected elements beyond selectedElementId. */ selectedElementIds: Set; - clearSelectedElementIds: () => void; + toggleSelectedElementId: (id: string) => void; /** Replace the whole multi-selection at once (marquee live updates). */ setSelectedElementIds: (ids: Set) => void; - /** Timeline magnet toggle — when false, clip drags/trims/drops never snap. */ - timelineSnapEnabled: boolean; - setTimelineSnapEnabled: (enabled: boolean) => void; - /** - * Pin the timeline zoom to its current visual scale before a duration-changing - * edit, so a subsequent duration change (which recomputes fit-pps) stops - * rescaling every clip. No-op once already pinned (mode is "manual"). - */ - pinTimelineZoom: (currentPixelsPerSecond: number, fitPixelsPerSecond: number) => void; - /** - * The timeline's live pixels-per-second + fit basis, published by on - * every render. Non-reactive scratch state (never read as a render input). - */ - timelinePps: number; - timelineFitPps: number; - setTimelineScale: (pps: number, fitPps: number) => void; - setSelection: (ids: Iterable, anchor?: string | null) => void; - addSelectedElementId: (id: string) => void; - toggleSelectedElementId: (id: string) => void; - clearSelection: () => void; + clearSelectedElementIds: () => void; /** Keyframe data per element id, populated from parsed GSAP animations. */ keyframeCache: Map; @@ -180,20 +163,34 @@ interface PlayerState { setTimelineReady: (ready: boolean) => void; 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; + setSelectedElementId: (id: string | null, options?: SelectElementOptions) => void; updateElement: ( elementId: string, updates: Partial< - Pick< - TimelineElement, - "start" | "duration" | "track" | "zIndex" | "hasExplicitZIndex" | "playbackStart" | "hidden" - > + Pick >, ) => void; setZoomMode: (mode: ZoomMode) => void; setManualZoomPercent: (percent: number) => void; + /** + * Pin the timeline zoom to the CURRENT on-screen pixels-per-second on the first + * edit, so a subsequent duration change (which recomputes fit-pps) stops + * rescaling every clip. No-op once already pinned (mode is "manual") so the + * user's own manual zoom is never overwritten by a later edit. + */ + pinTimelineZoom: (currentPixelsPerSecond: number, fitPixelsPerSecond: number) => void; + /** + * The timeline's live pixels-per-second + fit basis, published by on + * every render. Non-reactive scratch state (never read as a render input) so + * edit handlers OUTSIDE — e.g. the keyboard-delete path — can still + * pin the zoom via `pinTimelineZoomToCurrent` without threading viewport + * geometry down to them. + */ + timelinePps: number; + timelineFitPps: number; + setTimelineScale: (pps: number, fitPps: number) => void; + /** Pin using the last-published live scale (see `pinTimelineZoom`). */ + pinTimelineZoomToCurrent: () => void; setInPoint: (time: number | null) => void; setOutPoint: (time: number | null) => void; reset: () => void; @@ -278,10 +275,23 @@ export const usePlayerStore = create((set, get) => ({ playbackRate: readStudioUiPreferences().playbackRate ?? 1, audioMuted: readStudioUiPreferences().audioMuted ?? false, loopEnabled: false, - zoomMode: "fit", - manualZoomPercent: 100, + // Hydrate the pinned zoom from prefs so a zoom pinned on a prior edit survives + // the iframe reload that the edit triggers — the reload/store-init used to snap + // back to "fit" and rescale every clip when the duration changed. + zoomMode: readStudioUiPreferences().timelineZoomMode ?? "fit", + manualZoomPercent: readStudioUiPreferences().timelineManualZoomPercent ?? 100, timelinePps: 100, timelineFitPps: 100, + timeDisplayMode: readStudioUiPreferences().timeDisplayMode ?? "time", + setTimeDisplayMode: (mode) => { + writeStudioUiPreferences({ timeDisplayMode: mode }); + set({ timeDisplayMode: mode }); + }, + timelineSnapEnabled: readStudioUiPreferences().timelineSnapEnabled ?? true, + setTimelineSnapEnabled: (enabled) => { + writeStudioUiPreferences({ timelineSnapEnabled: enabled }); + set({ timelineSnapEnabled: enabled }); + }, inPoint: null, outPoint: null, @@ -308,21 +318,15 @@ export const usePlayerStore = create((set, get) => ({ setAutoKeyframeEnabled: (enabled) => set({ autoKeyframeEnabled: enabled }), selectedElementIds: new Set(), - 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 resolveElementSelection(next, s.selectedElementId); + return { selectedElementIds: next }; }), - clearSelection: () => set({ selectedElementId: null, selectedElementIds: new Set() }), + setSelectedElementIds: (ids: Set) => set({ selectedElementIds: new Set(ids) }), + clearSelectedElementIds: () => set({ selectedElementIds: new Set() }), keyframeCache: new Map(), setKeyframeCache: (elementId, data) => @@ -404,13 +408,9 @@ export const usePlayerStore = create((set, get) => ({ set({ audioMuted: muted }); }, setLoopEnabled: (enabled) => set({ loopEnabled: enabled }), - setZoomMode: (mode) => set({ zoomMode: mode }), - clearSelectedElementIds: () => set({ selectedElementIds: new Set() }), - setSelectedElementIds: (ids: Set) => set({ selectedElementIds: new Set(ids) }), - timelineSnapEnabled: readStudioUiPreferences().timelineSnapEnabled ?? true, - setTimelineSnapEnabled: (enabled) => { - writeStudioUiPreferences({ timelineSnapEnabled: enabled }); - set({ timelineSnapEnabled: enabled }); + setZoomMode: (mode) => { + writeStudioUiPreferences({ timelineZoomMode: mode }); + set({ zoomMode: mode }); }, pinTimelineZoom: (currentPixelsPerSecond, fitPixelsPerSecond) => set((s) => { @@ -426,11 +426,15 @@ export const usePlayerStore = create((set, get) => ({ setTimelineScale: (pps, fitPps) => { // Non-reactive publish: mutate in place + reuse the same object identity so no // subscriber re-renders (these fields are never a render input, only read - // imperatively before pinning). + // imperatively by pinTimelineZoomToCurrent). const state = get(); state.timelinePps = pps; state.timelineFitPps = fitPps; }, + pinTimelineZoomToCurrent: () => { + const { timelinePps, timelineFitPps, pinTimelineZoom } = get(); + pinTimelineZoom(timelinePps, timelineFitPps); + }, setInPoint: (time) => set((state) => { const t = time !== null && Number.isFinite(time) ? time : null; @@ -452,41 +456,37 @@ export const usePlayerStore = create((set, get) => ({ loopEnabled: t !== null ? true : state.loopEnabled, }; }), - setManualZoomPercent: (percent) => - set({ manualZoomPercent: Math.max(10, Math.min(2000, Math.round(percent))) }), + setManualZoomPercent: (percent) => { + const clamped = clampTimelineZoomPercent(percent); + writeStudioUiPreferences({ timelineManualZoomPercent: clamped }); + set({ manualZoomPercent: clamped }); + }, setCurrentTime: (time) => set({ currentTime: Number.isFinite(time) ? time : 0 }), setDuration: (duration) => set({ duration: Number.isFinite(duration) ? duration : 0 }), 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) => + setSelectedElementId: (id, options) => set((s) => { - const selectedElementIds = id ? new Set([id]) : new Set(); // 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. - 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() }; + const base = + id !== s.selectedElementId + ? { selectedElementId: id, activeKeyframePct: null, motionPathArmed: false } + : { selectedElementId: id }; + // A single-select must collapse any multi-selection: otherwise a stale + // selectedElementIds set survives and Delete/drag silently act on it (phantom + // group). Additive flows (marquee, Escape-restore) set the primary FIRST, then + // repopulate the set, so that ordering leaves their multi-selection intact. + // A LATE async primary resolution (inspector-open path) lands AFTER the set was + // written, though, so those flows pass preserveSet to opt out of the collapse + // when the incoming primary is already a live member of the set. + if (options?.preserveSet) return base; + return s.selectedElementIds.size > 0 + ? { ...base, selectedElementIds: new Set() } + : base; }), updateElement: (elementId, updates) => set((state) => ({ diff --git a/packages/studio/src/utils/blockInstaller.ts b/packages/studio/src/utils/blockInstaller.ts index daf636a7f1..89cd45b2eb 100644 --- a/packages/studio/src/utils/blockInstaller.ts +++ b/packages/studio/src/utils/blockInstaller.ts @@ -2,9 +2,10 @@ import type { RegistryItem } from "@hyperframes/core/registry"; import type { TimelineElement } from "../player"; import { insertTimelineAssetIntoSource, - resolveTimelineAssetInitialGeometry, + resolveTimelineAssetCompositionSize, } from "./timelineAssetDrop"; import { collectHtmlIds } from "./studioHelpers"; +import { generateId } from "./generateId"; import { formatTimelineAttributeNumber } from "../player/components/timelineEditing"; import { saveProjectFilesWithHistory } from "./studioFileHistory"; import type { EditHistoryKind } from "./editHistory"; @@ -120,7 +121,9 @@ export async function addBlockToProject( ); const isBlock = block.type === "hyperframes:block"; - const hostDims = resolveTimelineAssetInitialGeometry(originalContent); + const { width: hostWidth, height: hostHeight } = + resolveTimelineAssetCompositionSize(originalContent); + const hostDims = { left: 0, top: 0, width: hostWidth, height: hostHeight }; const currentTime = opts.currentTime ?? 0; const start = placement @@ -152,6 +155,11 @@ export async function addBlockToProject( const subCompHtml = [ ` { expect(state.undo[0].files["index.html"].after).toBe("c"); }); + it("merges a lane-change move with its z-reorder past the default window via entry coalesceMs", () => { + // The z entry records only after the move persist's round-trip — often >300ms. + // Both sides pass coalesceMs: 5000 with the shared gesture key so the pair + // still folds into ONE undo step. + const move = buildEditHistoryEntry({ + projectId: "project-1", + label: "Move timeline clips", + kind: "timeline", + coalesceKey: "clip-lane-move:1", + coalesceMs: 5000, + files: { "index.html": { before: "a", after: "b" } }, + now: 100, + id: "move-entry", + }); + const zReorder = buildEditHistoryEntry({ + projectId: "project-1", + label: "Reorder layers", + kind: "manual", + coalesceKey: "clip-lane-move:1", + coalesceMs: 5000, + files: { "index.html": { before: "b", after: "c" } }, + now: 500, + id: "z-entry", + }); + + const state = pushEditHistoryEntry( + pushEditHistoryEntry(createEmptyEditHistory(), move), + zReorder, + ); + + expect(state.undo).toHaveLength(1); + expect(state.undo[0].files["index.html"].before).toBe("a"); + 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", diff --git a/packages/studio/src/utils/gsapSoftReload.test.ts b/packages/studio/src/utils/gsapSoftReload.test.ts index 25e07edd97..6dae557369 100644 --- a/packages/studio/src/utils/gsapSoftReload.test.ts +++ b/packages/studio/src/utils/gsapSoftReload.test.ts @@ -1,7 +1,12 @@ // @vitest-environment happy-dom import { describe, it, expect, vi } from "vitest"; -import { applySoftReload, ensureMotionPathPluginLoaded } from "./gsapSoftReload"; +import { + applySoftReload, + ensureMotionPathPluginLoaded, + diffSoftReloadableRestore, + applyUndoRestoreToPreview, +} from "./gsapSoftReload"; const SCRIPT_TEXT = ` window.__timelines = window.__timelines || {}; @@ -447,3 +452,116 @@ describe("applySoftReload authored-opacity restore", () => { expect(restoreOpacity(el)).toBe(""); }); }); + +// ── Bug 2: undo/redo restore soft-apply ────────────────────────────────────── + +const wrap = (body: string) => `${body}`; + +describe("diffSoftReloadableRestore", () => { + it("reports the changed id for an attribute/inline-style-only diff", () => { + const prev = wrap(`
t
`); + const next = wrap(`
t
`); + expect(diffSoftReloadableRestore(prev, next)).toEqual({ changedElementIds: ["a"] }); + }); + + it("treats a structural change (added element) as NOT soft-reloadable", () => { + const prev = wrap(`
t
`); + const next = wrap(`
t
t
`); + expect(diffSoftReloadableRestore(prev, next)).toBeNull(); + }); + + it("treats an element text/child change as NOT soft-reloadable", () => { + const prev = wrap(`
one
`); + const next = wrap(`
two
`); + expect(diffSoftReloadableRestore(prev, next)).toBeNull(); + }); + + it("allows a GSAP-script-only change (no id'd-attribute diff)", () => { + const prev = wrap( + `
t
`, + ); + const next = wrap( + `
t
`, + ); + expect(diffSoftReloadableRestore(prev, next)).toEqual({ changedElementIds: [] }); + }); +}); + +function buildLiveIframe(bodyHtml: string) { + const doc = document.implementation.createHTMLDocument(""); + doc.body.innerHTML = bodyHtml; + const contentWindow = { + gsap: { timeline: () => {} }, + __hfForceTimelineRebind: () => {}, + __timelines: {} as Record, + __player: { getTime: () => 3, seek: vi.fn() }, + __hfStudioManualEditsApply: vi.fn(), + }; + return { + iframe: { contentWindow, contentDocument: doc } as unknown as HTMLIFrameElement, + contentWindow, + doc, + }; +} + +describe("applyUndoRestoreToPreview", () => { + const ROOT = "index.html"; + + it("soft-applies an attribute/style-only restore: syncs the live element, no full reload", () => { + const { iframe, contentWindow, doc } = buildLiveIframe( + `
t
`, + ); + const reloadPreview = vi.fn(); + const files = { + [ROOT]: { + previous: wrap( + `
t
`, + ), + restored: wrap(`
t
`), + }, + }; + const outcome = applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview); + expect(outcome).toBe("soft"); + expect(reloadPreview).not.toHaveBeenCalled(); + // Live element reverted to the restored inline style. + expect(doc.getElementById("a")!.getAttribute("style")).toBe("translate: 0px 0px"); + // No GSAP script in the restore → the manual-edit reapply runs, playhead held. + expect(contentWindow.__player.seek).toHaveBeenCalledWith(3); + expect(contentWindow.__hfStudioManualEditsApply).toHaveBeenCalled(); + }); + + it("full-reloads a multi-file restore", () => { + const { iframe } = buildLiveIframe(`
t
`); + const reloadPreview = vi.fn(); + const files = { + [ROOT]: { + previous: wrap(`
t
`), + restored: wrap(`
t
`), + }, + "scenes/intro.html": { previous: "a", restored: "b" }, + }; + expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full"); + expect(reloadPreview).toHaveBeenCalledTimes(1); + }); + + it("full-reloads a structural restore (split/delete undo)", () => { + const { iframe } = buildLiveIframe(`
t
t
`); + const reloadPreview = vi.fn(); + const files = { + [ROOT]: { + previous: wrap(`
t
t
`), + restored: wrap(`
t
`), + }, + }; + expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full"); + expect(reloadPreview).toHaveBeenCalledTimes(1); + }); + + it("full-reloads when the restore touches a sub-comp, not the active comp", () => { + const { iframe } = buildLiveIframe(`
t
`); + const reloadPreview = vi.fn(); + const files = { "scenes/intro.html": { previous: "a", restored: "b" } }; + expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full"); + expect(reloadPreview).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/studio/src/utils/gsapSoftReload.ts b/packages/studio/src/utils/gsapSoftReload.ts index 63012108ca..cf265b891a 100644 --- a/packages/studio/src/utils/gsapSoftReload.ts +++ b/packages/studio/src/utils/gsapSoftReload.ts @@ -183,6 +183,159 @@ export interface SoftReloadOptions { authoredHtml?: string; } +/** One file's restore from the edit-history store: before (live) / after (target) bytes. */ +export interface UndoRestoreFile { + previous: string; + restored: string; +} +function idElementMap(doc: Document): Map { + const map = new Map(); + for (const el of doc.querySelectorAll("[id]")) { + const id = el.getAttribute("id"); + if (id) map.set(id, el); + } + return map; +} + +// Strip id'd elements to bare `id` and blank GSAP scripts, in place: docs that +// differ only in id'd attributes/inline-style/script text normalize equal; any +// residual difference is beyond soft-reload's reach → caller full-reloads. +function normalizeSoftResidual(doc: Document): void { + for (const el of doc.querySelectorAll("[id]")) { + const id = el.getAttribute("id"); + for (const name of [...el.getAttributeNames()]) { + if (name !== "id") el.removeAttribute(name); + } + if (id) el.setAttribute("id", id); + } + for (const script of findGsapScriptElements(doc)) script.textContent = ""; +} + +// Soft-reloadable iff the docs differ SOLELY in id'd-element attributes/inline +// style and/or the GSAP script; returns the changed ids to sync onto the live +// DOM. Structural/text diffs → null → the caller full-reloads. Pure. +export function diffSoftReloadableRestore( + previous: string, + restored: string, +): { changedElementIds: string[] } | null { + let prevDoc: Document; + let nextDoc: Document; + try { + prevDoc = new DOMParser().parseFromString(previous, "text/html"); + nextDoc = new DOMParser().parseFromString(restored, "text/html"); + } catch { + return null; + } + const prevById = idElementMap(prevDoc); + const nextById = idElementMap(nextDoc); + // A different id set means an element was added or removed (e.g. a split, a + // delete) — structural, so soft-reload can't express it. + if (prevById.size !== nextById.size) return null; + const changedElementIds: string[] = []; + for (const [id, nextEl] of nextById) { + const prevEl = prevById.get(id); + if (!prevEl || prevEl.tagName !== nextEl.tagName) return null; + // A change inside the element (text / children) is out of soft scope; only + // its own attributes may differ. (GSAP scripts are handled via re-run.) + if (prevEl.innerHTML !== nextEl.innerHTML) return null; + if (prevEl.outerHTML !== nextEl.outerHTML) changedElementIds.push(id); + } + // Confirm nothing OUTSIDE id'd-element attributes and GSAP scripts changed. + normalizeSoftResidual(prevDoc); + normalizeSoftResidual(nextDoc); + if (prevDoc.documentElement.outerHTML !== nextDoc.documentElement.outerHTML) return null; + return { changedElementIds }; +} + +/** Copy every attribute from `source` onto the live `target`, dropping extras. */ +function syncElementAttributes(target: Element, source: Element): void { + for (const name of [...target.getAttributeNames()]) { + if (!source.hasAttribute(name)) target.removeAttribute(name); + } + for (const name of source.getAttributeNames()) { + target.setAttribute(name, source.getAttribute(name) ?? ""); + } +} + +/** + * Soft-apply an undo/redo restore to the live preview WITHOUT a full iframe + * remount (which blanks the frame black and re-flashes the WebGL context). Only + * the active composition — the document living in the root iframe — is eligible; + * a sub-comp or multi-file restore falls back to `reloadPreview`. + * + * The restore is soft-applied when its only differences are id'd-element + * attributes / inline-style and/or the GSAP script (see diffSoftReloadableRestore): + * 1. Each changed element's attribute surface (inline style, data-start / + * -duration, the studio manual-offset props + flags) is synced onto the live + * element — so a canvas-position revert lands on the live DOM the runtime's + * seek-reapply reads from, not just on disk. + * 2. The restored GSAP script is re-run in place via applySoftReload, which + * re-seeks to `currentTime` (playhead-invariant) and re-folds manual edits. + * With no single script, the manual-edit reapply is invoked directly. + * + * Returns "soft" when applied in place, "full" when it escalated to reloadPreview + * (ineligible restore, missing target, or a permanent soft-reload failure). + */ +export function applyUndoRestoreToPreview( + iframe: HTMLIFrameElement | null, + activeCompPath: string | null, + files: Record | undefined, + currentTime: number, + reloadPreview: () => void, +): "soft" | "full" { + const paths = files ? Object.keys(files) : []; + // Soft path only covers the single active-comp document in the root iframe. + if (!iframe || !activeCompPath || !files || paths.length !== 1 || paths[0] !== activeCompPath) { + reloadPreview(); + return "full"; + } + const doc = iframe.contentDocument; + const win = iframe.contentWindow as IframeWindow | null; + if (!doc || !win) { + reloadPreview(); + return "full"; + } + const { previous, restored } = files[activeCompPath]!; + const diff = diffSoftReloadableRestore(previous, restored); + if (!diff) { + reloadPreview(); + return "full"; + } + + // Sync each changed element's attributes onto the live DOM from the restored + // markup, so the runtime's seek-reapply (which reads inline offset props off + // the live element) folds the REVERTED values, not the stale current ones. + const restoredById = idElementMap(new DOMParser().parseFromString(restored, "text/html")); + for (const id of diff.changedElementIds) { + const liveEl = doc.getElementById(id); + const restoredEl = restoredById.get(id); + if (liveEl && restoredEl) syncElementAttributes(liveEl, restoredEl); + } + + const script = extractGsapScriptText(restored); + if (script) { + const result = applySoftReload(iframe, script, { + onAsyncFailure: reloadPreview, + currentTimeOverride: currentTime, + }); + if (result === "cannot-soft-reload") { + reloadPreview(); + return "full"; + } + return "soft"; + } + // No single GSAP script to re-run — the change was pure attribute/style. Re-fold + // manual edits and hold the playhead so the synced attributes take visible effect. + try { + win.__player?.seek?.(currentTime); + win.__hfStudioManualEditsApply?.(); + } catch { + reloadPreview(); + return "full"; + } + return "soft"; +} + export function applySoftReload( iframe: HTMLIFrameElement | null, scriptText: string, diff --git a/packages/studio/src/utils/mediaTypes.ts b/packages/studio/src/utils/mediaTypes.ts index 2bd97cf869..c6030bc41f 100644 --- a/packages/studio/src/utils/mediaTypes.ts +++ b/packages/studio/src/utils/mediaTypes.ts @@ -1,9 +1,10 @@ -export const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|svg|ico)$/i; +export const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|avif|svg|ico)$/i; export const VIDEO_EXT = /\.(mp4|webm|mov)$/i; export const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i; export const FONT_EXT = /\.(woff|woff2|ttf|ttc|otf|eot)$/i; export const LUT_EXT = /\.cube$/i; -export const MEDIA_EXT = /\.(mp4|webm|mov|mp3|wav|ogg|m4a|aac|jpg|jpeg|png|gif|webp|svg|ico)$/i; +export const MEDIA_EXT = + /\.(mp4|webm|mov|mp3|wav|ogg|m4a|aac|jpg|jpeg|png|gif|webp|avif|svg|ico)$/i; export function isMediaFile(path: string): boolean { return MEDIA_EXT.test(path); diff --git a/packages/studio/src/utils/timelineAssetDrop.test.ts b/packages/studio/src/utils/timelineAssetDrop.test.ts index 046c5eba9f..6d2aa49d11 100644 --- a/packages/studio/src/utils/timelineAssetDrop.test.ts +++ b/packages/studio/src/utils/timelineAssetDrop.test.ts @@ -1,13 +1,80 @@ +// @vitest-environment jsdom import { describe, expect, it } from "vitest"; import { buildTimelineFileDropPlacements, buildTimelineAssetInsertHtml, + extendCompositionDurationIfNeeded, + fitTimelineAssetGeometry, getTimelineAssetKind, insertTimelineAssetIntoSource, - resolveTimelineAssetInitialGeometry, + resolveTimelineAssetCompositionSize, resolveTimelineAssetSrc, + setCompositionDurationToContent, } from "./timelineAssetDrop"; +describe("setCompositionDurationToContent", () => { + const src = (dur: number) => + `
x
`; + + it("shrinks the root duration to the content end", () => { + expect(setCompositionDurationToContent(src(20), 8)).toContain('data-duration="8"'); + }); + + it("grows the root duration to the content end", () => { + expect(setCompositionDurationToContent(src(5), 12)).toContain('data-duration="12"'); + }); + + it("is a no-op when content end is 0 (empty timeline keeps its declared length)", () => { + expect(setCompositionDurationToContent(src(12), 0)).toBe(src(12)); + }); + + it("is a no-op when already equal", () => { + expect(setCompositionDurationToContent(src(9), 9)).toBe(src(9)); + }); + + // Reviewer round-2 finding #3: attribute-order and single-quote variants that + // the old order-dependent, double-quotes-only regex silently ignored. + it("patches when data-duration precedes data-composition-id", () => { + const source = `
x
`; + expect(setCompositionDurationToContent(source, 8)).toBe( + `
x
`, + ); + }); + + it("patches single-quoted attributes and keeps the quote style", () => { + const source = `
x
`; + expect(setCompositionDurationToContent(source, 8)).toBe( + `
x
`, + ); + }); +}); + +describe("extendCompositionDurationIfNeeded", () => { + it("grows the root duration when a clip lands past the end", () => { + const source = `
x
`; + expect(extendCompositionDurationIfNeeded(source, 8)).toBe( + `
x
`, + ); + }); + + it("is a no-op when the required end fits within the current duration", () => { + const source = `
x
`; + expect(extendCompositionDurationIfNeeded(source, 8)).toBe(source); + }); + + it("grows even when the attribute order is swapped and quotes are single", () => { + const source = `
x
`; + expect(extendCompositionDurationIfNeeded(source, 8)).toBe( + `
x
`, + ); + }); + + it("is a no-op when there is no composition root", () => { + const source = `
x
`; + expect(extendCompositionDurationIfNeeded(source, 8)).toBe(source); + }); +}); + describe("getTimelineAssetKind", () => { it("detects image, video, and audio assets", () => { expect(getTimelineAssetKind("assets/photo.png")).toBe("image"); @@ -16,12 +83,28 @@ describe("getTimelineAssetKind", () => { expect(getTimelineAssetKind("assets/music.mp3")).toBe("audio"); expect(getTimelineAssetKind("assets/music.wav")).toBe("audio"); }); + + it("classifies svg as image", () => { + expect(getTimelineAssetKind("assets/logo.svg")).toBe("image"); + expect(getTimelineAssetKind("assets/ICON.SVG")).toBe("image"); + }); + + it("classifies avif and webp as image", () => { + expect(getTimelineAssetKind("assets/photo.avif")).toBe("image"); + expect(getTimelineAssetKind("assets/photo.webp")).toBe("image"); + }); + + it("returns null for unknown extensions", () => { + expect(getTimelineAssetKind("assets/data.json")).toBeNull(); + expect(getTimelineAssetKind("assets/font.woff2")).toBeNull(); + }); }); describe("buildTimelineAssetInsertHtml", () => { it("builds an image clip with explicit timing and track", () => { const html = buildTimelineAssetInsertHtml({ id: "photo_asset", + hfId: "hf-abc123", assetPath: "assets/photo.png", kind: "image", start: 1.25, @@ -40,6 +123,7 @@ describe("buildTimelineAssetInsertHtml", () => { it("builds an audio clip without visual layout styles", () => { const html = buildTimelineAssetInsertHtml({ id: "music_asset", + hfId: "hf-xyz789", assetPath: "assets/music.wav", kind: "audio", start: 0.5, @@ -52,15 +136,13 @@ describe("buildTimelineAssetInsertHtml", () => { }); }); -describe("resolveTimelineAssetInitialGeometry", () => { +describe("resolveTimelineAssetCompositionSize", () => { it("uses the target composition dimensions for visual media", () => { expect( - resolveTimelineAssetInitialGeometry( + resolveTimelineAssetCompositionSize( `
`, ), ).toEqual({ - left: 0, - top: 0, width: 330, height: 228, }); @@ -84,7 +166,9 @@ describe("buildTimelineFileDropPlacements", () => { expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 }, [])).toEqual([]); }); - it("uses the dropped start and spaces multiple files by duration on the same track", () => { + it("spaces multiple files by duration and keeps every one on the dropped track", () => { + // A clip placed onto an occupied track stays there (overlap is allowed); it is + // NOT bumped to a new track — that produced surprise empty tracks for users. expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 }, [1.2, 1.6, 1.1])).toEqual([ { start: 1.5, track: 2 }, { start: 2.7, track: 2 }, @@ -99,52 +183,6 @@ describe("buildTimelineFileDropPlacements", () => { { start: 7.7, track: 2 }, ]); }); - - it("moves the spaced sequence to a clear track when the dropped row is occupied", () => { - expect( - buildTimelineFileDropPlacements( - { start: 1.5, track: 2 }, - [1.2, 1.6, 1.1], - [ - { start: 0, duration: 8, track: 2 }, - { start: 0, duration: 4, track: 5 }, - ], - ), - ).toEqual([ - { start: 1.5, track: 6 }, - { start: 2.7, track: 6 }, - { start: 4.3, track: 6 }, - ]); - }); - - it("keeps a requested track above occupied rows when that track is clear", () => { - expect( - buildTimelineFileDropPlacements( - { start: 1.5, track: 8 }, - [1.2, 1.6], - [ - { start: 0, duration: 8, track: 2 }, - { start: 0, duration: 4, track: 5 }, - ], - ), - ).toEqual([ - { start: 1.5, track: 8 }, - { start: 2.7, track: 8 }, - ]); - }); - - it("moves a default-track drop to a clear row when track 0 is occupied at time 0", () => { - expect( - buildTimelineFileDropPlacements( - { start: 0, track: 0 }, - [1.2, 1.6], - [{ start: 0, duration: 8, track: 0 }], - ), - ).toEqual([ - { start: 0, track: 1 }, - { start: 1.2, track: 1 }, - ]); - }); }); describe("insertTimelineAssetIntoSource", () => { @@ -159,3 +197,57 @@ describe("insertTimelineAssetIntoSource", () => { expect(html).toContain(''); }); }); + +describe("buildTimelineAssetInsertHtml markup quality", () => { + const base = { + id: "clip_1", + hfId: "hf-test-1", + assetPath: "assets/a.mp4", + start: 1, + duration: 4, + track: 2, + zIndex: 3, + }; + + it("stamps data-hf-id on all kinds", () => { + for (const kind of ["image", "video", "audio"] as const) { + expect(buildTimelineAssetInsertHtml({ ...base, kind })).toContain('data-hf-id="hf-test-1"'); + } + }); + + it("audio gets an explicit data-volume", () => { + expect(buildTimelineAssetInsertHtml({ ...base, kind: "audio" })).toContain('data-volume="1"'); + }); +}); + +describe("fitTimelineAssetGeometry", () => { + const comp = { width: 1920, height: 1080 }; + + it("centers a smaller-than-comp asset at natural size", () => { + expect(fitTimelineAssetGeometry({ width: 640, height: 360 }, comp)).toEqual({ + left: 640, + top: 360, + width: 640, + height: 360, + }); + }); + + it("scales an oversized asset down to fit, preserving aspect, centered", () => { + // 4000x1000 → capped to 1920 wide → 1920x480, centered vertically + expect(fitTimelineAssetGeometry({ width: 4000, height: 1000 }, comp)).toEqual({ + left: 0, + top: 300, + width: 1920, + height: 480, + }); + }); + + it("falls back to full-frame when natural size is unknown", () => { + expect(fitTimelineAssetGeometry(null, comp)).toEqual({ + left: 0, + top: 0, + width: 1920, + height: 1080, + }); + }); +}); diff --git a/packages/studio/src/utils/timelineAssetDrop.ts b/packages/studio/src/utils/timelineAssetDrop.ts index 5194f5899b..7015169b26 100644 --- a/packages/studio/src/utils/timelineAssetDrop.ts +++ b/packages/studio/src/utils/timelineAssetDrop.ts @@ -1,7 +1,7 @@ import { AUDIO_EXT, IMAGE_EXT, VIDEO_EXT } from "./mediaTypes"; -import { patchRootCompositionDuration, readRootCompositionDuration } from "./rootDuration"; import { roundToCenti } from "./rounding"; import { COMPOSITION_ROOT_OPEN_TAG_RE } from "./compositionPatterns"; +import { patchRootCompositionDuration, readRootCompositionDuration } from "./rootDuration"; export const TIMELINE_ASSET_MIME = "application/x-hyperframes-asset"; export const TIMELINE_BLOCK_MIME = "application/x-hyperframes-block"; @@ -49,58 +49,66 @@ export function resolveTimelineAssetSrc(targetPath: string, assetPath: string): return relative || assetPath.split("/").pop() || assetPath; } +/** + * Sequence one or more dropped files end-to-end starting at the drop point, all on + * the track the user dropped onto. The clip lands where the ghost showed it — we do + * NOT bump to a different track on overlap (that produced surprise "new tracks" and, + * because it jumped past high indices like a grain-overlay track, wild numbers). + * HyperFrames allows time-overlap on a track; the user can nudge if they want a gap. + */ export function buildTimelineFileDropPlacements( placement: { start: number; track: number }, durations: number[], - occupiedClips: Array<{ start: number; duration: number; track: number }> = [], ): Array<{ start: number; track: number }> { let nextStart = roundToCenti(Math.max(0, placement.start)); - const sequenceStart = nextStart; - const resolvedDurations = durations.map((duration) => - Number.isFinite(duration) && duration > 0 ? duration : FALLBACK_TIMELINE_FILE_DROP_DURATION, - ); - const sequenceEnd = resolvedDurations.reduce( - (end, duration) => roundToCenti(end + duration), - sequenceStart, - ); - const overlapsDropTrack = occupiedClips.some((clip) => { - if (clip.track !== placement.track) return false; - const clipStart = Math.max(0, clip.start); - const clipEnd = clipStart + Math.max(0, clip.duration); - return sequenceStart < clipEnd && sequenceEnd > clipStart; - }); - const track = overlapsDropTrack - ? Math.max(placement.track, ...occupiedClips.map((clip) => clip.track)) + 1 - : placement.track; - - return resolvedDurations.map((duration) => { + return durations.map((rawDuration) => { + const duration = + Number.isFinite(rawDuration) && rawDuration > 0 + ? rawDuration + : FALLBACK_TIMELINE_FILE_DROP_DURATION; const start = nextStart; nextStart = roundToCenti(nextStart + duration); - return { start, track }; + return { start, track: placement.track }; }); } -export function resolveTimelineAssetInitialGeometry(source: string): { - left: number; - top: number; +export function resolveTimelineAssetCompositionSize(source: string): { width: number; height: number; } { const width = Number.parseFloat(source.match(/\bdata-width=(["'])([^"']+)\1/i)?.[2] ?? ""); const height = Number.parseFloat(source.match(/\bdata-height=(["'])([^"']+)\1/i)?.[2] ?? ""); - return { - left: 0, - top: 0, width: Number.isFinite(width) && width > 0 ? Math.round(width) : 640, height: Number.isFinite(height) && height > 0 ? Math.round(height) : 360, }; } +/** + * CapCut-style placement: natural size when it fits, scaled-to-fit when + * oversized, always centered. Unknown natural size → full-frame. + */ +export function fitTimelineAssetGeometry( + natural: { width: number; height: number } | null, + comp: { width: number; height: number }, +): { left: number; top: number; width: number; height: number } { + if (!natural || natural.width <= 0 || natural.height <= 0) { + return { left: 0, top: 0, width: comp.width, height: comp.height }; + } + const scale = Math.min(1, comp.width / natural.width, comp.height / natural.height); + const width = Math.round(natural.width * scale); + const height = Math.round(natural.height * scale); + return { + left: Math.round((comp.width - width) / 2), + top: Math.round((comp.height - height) / 2), + width, + height, + }; +} + export function buildTimelineAssetInsertHtml(input: { id: string; - /** Stable hf-id stamped as data-hf-id by the NLE drop path (optional in the legacy path). */ - hfId?: string; + hfId: string; assetPath: string; kind: TimelineAssetKind; start: number; @@ -109,7 +117,7 @@ export function buildTimelineAssetInsertHtml(input: { zIndex: number; geometry?: { left: number; top: number; width: number; height: number }; }): string { - const sharedAttrs = `id="${input.id}" class="clip" src="${input.assetPath}" data-start="${input.start}" data-duration="${input.duration}" data-track-index="${input.track}"`; + const sharedAttrs = `id="${input.id}" data-hf-id="${input.hfId}" class="clip" src="${input.assetPath}" data-start="${input.start}" data-duration="${input.duration}" data-track-index="${input.track}"`; const geometry = input.geometry ?? { left: 0, top: 0, width: 640, height: 360 }; const visualStyles = `position: absolute; left: ${geometry.left}px; top: ${geometry.top}px; width: ${geometry.width}px; height: ${geometry.height}px; object-fit: contain; z-index: ${input.zIndex}`; @@ -121,23 +129,18 @@ export function buildTimelineAssetInsertHtml(input: { return ``; } - return ``; + return ``; } -export function insertTimelineAssetIntoSource(source: string, assetHtml: string): string { - const match = COMPOSITION_ROOT_OPEN_TAG_RE.exec(source); - if (!match || match.index == null) { - throw new Error("No composition root found in target source"); - } - const insertAt = match.index + match[0].length; - const lineStart = source.lastIndexOf("\n", match.index); - const leadingWhitespace = source.slice(lineStart + 1, match.index).match(/^(\s*)/)?.[1] ?? ""; - const childIndent = leadingWhitespace + " "; - const indented = assetHtml - .split("\n") - .map((line, i) => (i === 0 ? line : childIndent + line)) - .join("\n"); - return `${source.slice(0, insertAt)}\n${childIndent}${indented}${source.slice(insertAt)}`; +/** + * A clip inserted past the composition end would exist in the HTML but never + * appear on the timeline or in playback. Extend the root's data-duration to + * cover it (mirrors blockInstaller's behavior for installed blocks). + */ +export function extendCompositionDurationIfNeeded(source: string, requiredEnd: number): string { + const rootDur = readRootCompositionDuration(source); + if (rootDur == null || !Number.isFinite(rootDur) || requiredEnd <= rootDur) return source; + return patchRootCompositionDuration(source, String(roundToCenti(requiredEnd))); } /** @@ -156,45 +159,18 @@ export function setCompositionDurationToContent(source: string, contentEnd: numb return patchRootCompositionDuration(source, String(next)); } -export function extendCompositionDurationIfNeeded(source: string, requiredEnd: number): string { - const rootDur = readRootCompositionDuration(source); - if (rootDur == null || !Number.isFinite(rootDur) || requiredEnd <= rootDur) return source; - return patchRootCompositionDuration(source, String(roundToCenti(requiredEnd))); -} - -/** - * Set the composition root's `data-duration` to `contentEnd` (grow OR shrink) so the - * timeline length tracks content — the content-driven counterpart to - * extendCompositionDurationIfNeeded's grow-only ratchet. Used after edits that can - * reduce the furthest clip end (delete/trim). No-op when `contentEnd` is not > 0, so - * an empty timeline keeps its declared duration instead of collapsing to 0. - */ -export function fitTimelineAssetGeometry( - natural: { width: number; height: number } | null, - comp: { width: number; height: number }, -): { left: number; top: number; width: number; height: number } { - if (!natural || natural.width <= 0 || natural.height <= 0) { - return { left: 0, top: 0, width: comp.width, height: comp.height }; +export function insertTimelineAssetIntoSource(source: string, assetHtml: string): string { + const match = COMPOSITION_ROOT_OPEN_TAG_RE.exec(source); + if (!match || match.index == null) { + throw new Error("No composition root found in target source"); } - const scale = Math.min(1, comp.width / natural.width, comp.height / natural.height); - const width = Math.round(natural.width * scale); - const height = Math.round(natural.height * scale); - return { - left: Math.round((comp.width - width) / 2), - top: Math.round((comp.height - height) / 2), - width, - height, - }; -} - -export function resolveTimelineAssetCompositionSize(source: string): { - width: number; - height: number; -} { - const width = Number.parseFloat(source.match(/\bdata-width=(["'])([^"']+)\1/i)?.[2] ?? ""); - const height = Number.parseFloat(source.match(/\bdata-height=(["'])([^"']+)\1/i)?.[2] ?? ""); - return { - width: Number.isFinite(width) && width > 0 ? Math.round(width) : 640, - height: Number.isFinite(height) && height > 0 ? Math.round(height) : 360, - }; + const insertAt = match.index + match[0].length; + const lineStart = source.lastIndexOf("\n", match.index); + const leadingWhitespace = source.slice(lineStart + 1, match.index).match(/^(\s*)/)?.[1] ?? ""; + const childIndent = leadingWhitespace + " "; + const indented = assetHtml + .split("\n") + .map((line, i) => (i === 0 ? line : childIndent + line)) + .join("\n"); + return `${source.slice(0, insertAt)}\n${childIndent}${indented}${source.slice(insertAt)}`; } diff --git a/packages/studio/src/utils/timelineDiscovery.test.ts b/packages/studio/src/utils/timelineDiscovery.test.ts deleted file mode 100644 index 5174cb1d04..0000000000 --- a/packages/studio/src/utils/timelineDiscovery.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - TIMELINE_TOGGLE_SHORTCUT_LABEL, - getTimelineToggleTitle, - shouldHandleTimelineToggleHotkey, -} from "./timelineDiscovery"; - -describe("shouldHandleTimelineToggleHotkey", () => { - it("accepts Shift+T when focus is not inside an editor", () => { - expect( - shouldHandleTimelineToggleHotkey({ - key: "T", - shiftKey: true, - metaKey: false, - ctrlKey: false, - altKey: false, - target: { - tagName: "DIV", - isContentEditable: false, - closest: () => null, - }, - } as KeyboardEvent), - ).toBe(true); - }); - - it("ignores the shortcut inside text inputs", () => { - expect( - shouldHandleTimelineToggleHotkey({ - key: "t", - shiftKey: true, - metaKey: false, - ctrlKey: false, - altKey: false, - target: { - tagName: "TEXTAREA", - isContentEditable: false, - closest: () => null, - }, - } as KeyboardEvent), - ).toBe(false); - }); - - it("ignores the shortcut inside contenteditable editors", () => { - expect( - shouldHandleTimelineToggleHotkey({ - key: "t", - shiftKey: true, - metaKey: false, - ctrlKey: false, - altKey: false, - target: { - tagName: "DIV", - isContentEditable: true, - closest: () => null, - }, - } as KeyboardEvent), - ).toBe(false); - }); - - it("requires Shift without other modifiers", () => { - expect( - shouldHandleTimelineToggleHotkey({ - key: "t", - shiftKey: false, - metaKey: false, - ctrlKey: false, - altKey: false, - target: null, - } as KeyboardEvent), - ).toBe(false); - - expect( - shouldHandleTimelineToggleHotkey({ - key: "t", - shiftKey: true, - metaKey: true, - ctrlKey: false, - altKey: false, - target: null, - } as KeyboardEvent), - ).toBe(false); - }); -}); - -describe("getTimelineToggleTitle", () => { - it("includes the shortcut in both show and hide titles", () => { - expect(getTimelineToggleTitle(true)).toContain(TIMELINE_TOGGLE_SHORTCUT_LABEL); - expect(getTimelineToggleTitle(false)).toContain(TIMELINE_TOGGLE_SHORTCUT_LABEL); - }); -}); diff --git a/packages/studio/src/utils/timelineDiscovery.ts b/packages/studio/src/utils/timelineDiscovery.ts index f4027953e1..a6428ba0ce 100644 --- a/packages/studio/src/utils/timelineDiscovery.ts +++ b/packages/studio/src/utils/timelineDiscovery.ts @@ -1,9 +1,3 @@ -export const TIMELINE_TOGGLE_SHORTCUT_LABEL = "Shift+T"; -type TimelineToggleHotkeyEvent = Pick< - KeyboardEvent, - "key" | "shiftKey" | "metaKey" | "ctrlKey" | "altKey" | "target" ->; - interface EditableTargetLike { tagName?: string; isContentEditable?: boolean; @@ -28,14 +22,3 @@ export function isEditableTarget(target: EventTarget | null): boolean { ), ); } - -export function shouldHandleTimelineToggleHotkey(event: TimelineToggleHotkeyEvent): boolean { - if (event.metaKey || event.ctrlKey || event.altKey) return false; - if (!event.shiftKey) return false; - if (event.key.toLowerCase() !== "t") return false; - return !isEditableTarget(event.target); -} - -export function getTimelineToggleTitle(timelineVisible: boolean): string { - return `${timelineVisible ? "Hide" : "Show"} timeline editor (${TIMELINE_TOGGLE_SHORTCUT_LABEL})`; -} diff --git a/skills/website-to-video/references/capabilities.md b/skills/website-to-video/references/capabilities.md index f70e0e738e..f0de368536 100644 --- a/skills/website-to-video/references/capabilities.md +++ b/skills/website-to-video/references/capabilities.md @@ -554,7 +554,7 @@ mp4, webm, mov, png-sequence — with HDR (PQ / HLG / SDR / auto-detect), transp Full editor in packages/studio/: -- **NLELayout**: NLE preview + timeline + controls +- **EditorShell**: NLE preview + timeline + controls - **Timeline**: clip rendering, drag to move (`data-start`), resize (`data-duration`), `data-track-index` reassignment, asset drop, file drop - **PlayerControls**: scrub, play, pause, frame step (`stepFrameTime`), `STUDIO_PREVIEW_FPS` - **useTimelinePlayer**: resolves `__player` / `__timeline` / `__timelines`