From 48f2f89c7d0500704eb259b7679f3dca47f91af0 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 16:23:32 -0400 Subject: [PATCH 1/5] feat(studio): expose composable timeline parts --- packages/studio/src/index.ts | 16 +++++ .../studio/src/player/components/Timeline.tsx | 43 ++++++++--- .../src/player/components/TimelineCanvas.tsx | 16 +---- .../player/components/TimelineOverlays.tsx | 20 ++++-- .../src/player/components/TimelineParts.tsx | 72 +++++++++++++++++++ .../player/components/TimelineRulerPart.tsx | 23 ++++++ 6 files changed, 162 insertions(+), 28 deletions(-) create mode 100644 packages/studio/src/player/components/TimelineParts.tsx create mode 100644 packages/studio/src/player/components/TimelineRulerPart.tsx diff --git a/packages/studio/src/index.ts b/packages/studio/src/index.ts index 61941d01a7..6b4fd7351b 100644 --- a/packages/studio/src/index.ts +++ b/packages/studio/src/index.ts @@ -60,6 +60,22 @@ export type { TimelineElement, TimelineTimeRange, } from "./player"; +export { + TimelineFrame, + TimelineLanes, + TimelineOverlays, + TimelinePlayhead, + TimelineRazorGuide, + TimelineRuler, + TimelineEmptyStatePart, + TimelineEditPopover, + TimelineClipMenu, + TimelineGapMenu, + TimelineKeyframeMenu, + TimelineShortcutHint, +} from "./player/components/TimelineParts"; +export { TimelineProvider, useTimelineContext } from "./player/components/TimelineProvider"; +export type { TimelineTheme } from "./player/components/timelineTheme"; // Host overlays: draw over the preview in composition coordinates (see EditorShellProps.gestureOverlay) export { usePreviewCompositionRect } from "./components/editor/usePreviewCompositionRect"; diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 2ff1afa843..b6f44e351f 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -1,9 +1,20 @@ import { memo } from "react"; import type { TimelineProps } from "./TimelineTypes"; -import { TimelineEmptyState } from "./TimelineEmptyState"; -import { TimelineCanvas } from "./TimelineCanvas"; -import { TimelineOverlays } from "./TimelineOverlays"; import { TimelineProvider, useTimelineContext } from "./TimelineProvider"; +import { + TimelineEmptyStatePart, + TimelineEditPopover, + TimelineClipMenu, + TimelineFrame, + TimelineGapMenu, + TimelineKeyframeMenu, + TimelineLanes, + TimelineOverlaysPart, + TimelinePlayhead, + TimelineRazorGuide, + TimelineRuler, + TimelineShortcutHint, +} from "./TimelineParts"; export * from "./TimelineProvider"; export { @@ -28,23 +39,39 @@ function TimelineView() { const { state, meta } = useTimelineContext(); const { timelineReady, elements } = state; if (!timelineReady || elements.length === 0) { - return ; + return ; } return (
- - {meta.razorGuide} + +
- +
); } -export const Timeline = memo(function Timeline(props: TimelineProps = {}) { +const TimelineComposed = memo(function TimelineComposed(props: TimelineProps = {}) { return ( ); }); + +export const Timeline = Object.assign(TimelineComposed, { + Provider: TimelineProvider, + Frame: TimelineFrame, + Ruler: TimelineRuler, + Lanes: TimelineLanes, + Playhead: TimelinePlayhead, + RazorGuide: TimelineRazorGuide, + ShortcutHint: TimelineShortcutHint, + EditPopover: TimelineEditPopover, + ClipMenu: TimelineClipMenu, + KeyframeMenu: TimelineKeyframeMenu, + GapMenu: TimelineGapMenu, + EmptyState: TimelineEmptyStatePart, + Overlays: TimelineOverlaysPart, +}); diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index 1189c5a9c7..2b46db0993 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -1,5 +1,5 @@ import { memo } from "react"; -import { TimelineRuler } from "./TimelineRuler"; +import { TimelineRulerPart } from "./TimelineRulerPart"; import { PlayheadIndicator } from "./PlayheadIndicator"; import { RULER_H, @@ -79,19 +79,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() { className="relative" style={{ height: props.totalH, width: props.contentOrigin + props.trackContentWidth }} > - + {/* Breathing room between the sticky ruler and the first track lane — the top half of the CapCut-style padding (see TRACKS_TOP_PAD). */} diff --git a/packages/studio/src/player/components/TimelineOverlays.tsx b/packages/studio/src/player/components/TimelineOverlays.tsx index 85262f7c01..2ea415747a 100644 --- a/packages/studio/src/player/components/TimelineOverlays.tsx +++ b/packages/studio/src/player/components/TimelineOverlays.tsx @@ -34,7 +34,12 @@ export function resolveTimelineContextElement({ // The timeline's floating overlays, rendered as siblings above the scroll area: // the shortcut hint, the range-edit popover, the keyframe-diamond context menu, // and the clip context menu. -export function TimelineOverlays() { +export type TimelineOverlayPart = "all" | "shortcut" | "edit" | "clip" | "keyframe" | "gap"; + +// The composed overlay keeps target validation and action routing in one owner. +// fallow-ignore-next-line complexity +// fallow-ignore-next-line unit-size +export function TimelineOverlays({ part = "all" }: { part?: TimelineOverlayPart } = {}) { const { state, actions } = useTimelineContext(); const overlayProps = state.overlays; const { @@ -105,13 +110,16 @@ export function TimelineOverlays() { if (clipContextMenu && !clipElement) setClipContextMenu(null); }, [clipContextMenu, clipElement, setClipContextMenu]); + const includes = (candidate: Exclude) => + part === "all" || part === candidate; + return ( <> - {showShortcutHint && !showPopover && !rangeSelection && ( + {includes("shortcut") && showShortcutHint && !showPopover && !rangeSelection && ( )} - {showPopover && rangeSelection && ( + {includes("edit") && showPopover && rangeSelection && ( )} - {kfContextMenu && keyframeElement && ( + {includes("keyframe") && kfContextMenu && keyframeElement && ( setKfContextMenu(null)} @@ -179,7 +187,7 @@ export function TimelineOverlays() { /> )} - {clipContextMenu && clipElement && ( + {includes("clip") && clipContextMenu && clipElement && ( )} - {gapContextMenu && ( + {includes("gap") && gapContextMenu && ( ; +}); + +/** The provider-backed ruler. */ +export const TimelineRuler = TimelineRulerPart; + +/** + * The provider-backed lane renderer. TimelineFrame remains the composed Studio + * canvas; this part is exported for hosts that own the canvas arrangement. + */ +export const TimelineLanes = memo(function TimelineLanes() { + const { state, actions } = useTimelineContext(); + const props = state.canvas; + const laneProps = props as unknown as Parameters[0]; + return ( + + ); +}); + +/** The provider-backed playhead indicator. */ +export const TimelinePlayhead = memo(function TimelinePlayhead() { + const { state } = useTimelineContext(); + const props = state.canvas; + return ; +}); + +/** The provider-backed razor guide. */ +export const TimelineRazorGuide = memo(function TimelineRazorGuide() { + const { meta } = useTimelineContext(); + return meta.razorGuide; +}); + +/** The composed overlay surface used by the Studio variant. */ +export const TimelineOverlaysPart = memo(function TimelineOverlaysPart() { + return ; +}); + +export const TimelineEmptyStatePart = memo(function TimelineEmptyStatePart() { + const { meta } = useTimelineContext(); + return ; +}); + +export const TimelineShortcutHint = memo(function TimelineShortcutHint() { + return ; +}); +export const TimelineEditPopover = memo(function TimelineEditPopover() { + return ; +}); +export const TimelineClipMenu = memo(function TimelineClipMenu() { + return ; +}); +export const TimelineKeyframeMenu = memo(function TimelineKeyframeMenu() { + return ; +}); +export const TimelineGapMenu = memo(function TimelineGapMenu() { + return ; +}); diff --git a/packages/studio/src/player/components/TimelineRulerPart.tsx b/packages/studio/src/player/components/TimelineRulerPart.tsx new file mode 100644 index 0000000000..3f2758bbf2 --- /dev/null +++ b/packages/studio/src/player/components/TimelineRulerPart.tsx @@ -0,0 +1,23 @@ +import { memo } from "react"; +import { TimelineRuler as TimelineRulerImpl } from "./TimelineRuler"; +import { useTimelineContext } from "./TimelineProvider"; + +export const TimelineRulerPart = memo(function TimelineRulerPart() { + const { state } = useTimelineContext(); + const props = state.canvas; + return ( + + ); +}); From 82e9e0444c2ca337d04517e3dd34659e8f007e27 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 17:17:05 -0400 Subject: [PATCH 2/5] refactor(studio): split timeline overlay parts --- .../src/contexts/TimelineEditContext.tsx | 4 + .../src/player/components/Timeline.test.ts | 42 +++ .../studio/src/player/components/Timeline.tsx | 6 +- .../src/player/components/TimelineCanvas.tsx | 50 +-- .../player/components/TimelineOverlays.tsx | 335 +++++++++--------- .../player/components/TimelineParts.test.tsx | 101 ++++++ .../src/player/components/TimelineParts.tsx | 22 +- .../player/components/TimelineProvider.tsx | 25 ++ .../timelineProviderStateBuilders.ts | 28 ++ .../components/useTimelineProviderState.tsx | 27 +- 10 files changed, 405 insertions(+), 235 deletions(-) create mode 100644 packages/studio/src/player/components/TimelineParts.test.tsx diff --git a/packages/studio/src/contexts/TimelineEditContext.tsx b/packages/studio/src/contexts/TimelineEditContext.tsx index 4bdedcfbbc..bc117e8d0b 100644 --- a/packages/studio/src/contexts/TimelineEditContext.tsx +++ b/packages/studio/src/contexts/TimelineEditContext.tsx @@ -21,6 +21,10 @@ export function useTimelineEditContextOptional(): TimelineEditCallbacks { return useContext(TimelineEditContext) ?? {}; } +export function useTimelineEditContextValue(): TimelineEditCallbacks | null { + return useContext(TimelineEditContext); +} + export function TimelineEditProvider({ value, children, diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index d99fda1381..1f9b4c13c3 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -41,6 +41,7 @@ import { AUTOMATION_LANE_H } from "./automationLaneHeight"; import { formatTime } from "../lib/time"; import { usePlayerStore } from "../store/playerStore"; import { TimelineEditProvider } from "../../contexts/TimelineEditContext"; +import { useTimelineContext } from "./TimelineProvider"; vi.mock("./timelineRowVirtualizationFlag", () => ({ STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED: false, @@ -72,6 +73,21 @@ describe("timeline viewport geometry", () => { }); }); +function TimelinePartsVariant() { + const { meta } = useTimelineContext(); + return React.createElement( + "div", + meta.containerProps, + React.createElement( + "div", + meta.viewportProps, + React.createElement(Timeline.Frame), + React.createElement(Timeline.RazorGuide), + ), + React.createElement(Timeline.Overlays), + ); +} + function getHorizontalGeometry(host: HTMLElement, clipId: string, tickLabel: string) { const clip = host.querySelector(`[data-el-id="${clipId}"]`); if (!clip) throw new Error(`Missing timeline clip ${clipId}`); @@ -136,6 +152,32 @@ function renderBasicTimeline() { } describe("Timeline provider boundary", () => { + it("keeps the composed Timeline markup equal to its provider parts", () => { + usePlayerStore.setState({ + duration: 4, + timelineReady: true, + elements: [{ id: "parity-clip", tag: "div", start: 0, duration: 2, track: 0 }], + }); + const composedHost = document.createElement("div"); + const partsHost = document.createElement("div"); + document.body.append(composedHost, partsHost); + const composedRoot = createRoot(composedHost); + const partsRoot = createRoot(partsHost); + act(() => { + composedRoot.render(React.createElement(Timeline)); + partsRoot.render( + React.createElement(Timeline.Provider, null, React.createElement(TimelinePartsVariant)), + ); + }); + const normalizeMarkup = (markup: string) => + markup.replaceAll(/timeline-lanes_[^"]+/g, "timeline-lanes"); + expect(normalizeMarkup(partsHost.innerHTML)).toBe(normalizeMarkup(composedHost.innerHTML)); + act(() => { + composedRoot.unmount(); + partsRoot.unmount(); + }); + }); + it("keeps all-collapsed horizontal positions at the gutter plus the pre-t=0 pad", () => { usePlayerStore.setState({ duration: 11, diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index b6f44e351f..28a3b3ecee 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -9,7 +9,7 @@ import { TimelineGapMenu, TimelineKeyframeMenu, TimelineLanes, - TimelineOverlaysPart, + TimelineOverlays, TimelinePlayhead, TimelineRazorGuide, TimelineRuler, @@ -47,7 +47,7 @@ function TimelineView() { - + ); } @@ -73,5 +73,5 @@ export const Timeline = Object.assign(TimelineComposed, { KeyframeMenu: TimelineKeyframeMenu, GapMenu: TimelineGapMenu, EmptyState: TimelineEmptyStatePart, - Overlays: TimelineOverlaysPart, + Overlays: TimelineOverlays, }); diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index 2b46db0993..324fd6d372 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -12,12 +12,8 @@ import { getTimelineRowTop, getTimelineRowHeight, } from "./timelineLayout"; -import { type MultiDragPreviewInput } from "./timelineMultiDragPreview"; -import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext"; import { TimelineLanes } from "./TimelineLanes"; -import { getTimelineElementIdentity } from "../lib/timelineElementHelpers"; import { TimelineGestureOverlay } from "./TimelineGestureOverlay"; -import { resolveSnapGuide } from "./timelineSnapping"; import { useTimelineContext } from "./TimelineProvider"; // A dropped clip's length is unknown until it lands; the preview shows a default. @@ -26,8 +22,7 @@ const DROP_PREVIEW_SECONDS = 3; export const TimelineCanvas = memo(function TimelineCanvas() { const { state, actions } = useTimelineContext(); const props = state.canvas; - const { draggedClip, resizingClip, scrollRef, selectedElementIds, displayTrackOrder } = props; - const snapGuide = resolveSnapGuide(draggedClip, resizingClip); + const { draggedClip, scrollRef, displayTrackOrder } = props; const draggedRowIndex = draggedClip?.started === true ? displayTrackOrder.indexOf(draggedClip.previewTrack) : -1; const dropTrackIndex = props.dropPreview @@ -43,37 +38,8 @@ export const TimelineCanvas = memo(function TimelineCanvas() { // ghost and drop placeholder must clamp to it or they stretch to the full // expanded row height and stop matching the clip being dragged. const draggedClipHeight = Math.min(draggedRowHeight, TRACK_H) - CLIP_Y * 2; - const { - onResizeElement, - onMoveElement, - onToggleTrackHidden, - onTogglePropertyGroupKeyframe, - onRazorSplit, - onRazorSplitAll, - } = useTimelineEditContextOptional(); const beatDragging = props.beatDragging; - const draggedElement = draggedClip?.element ?? null; - const draggedElementIdentity = draggedElement ? getTimelineElementIdentity(draggedElement) : null; - // The drag ghost follows the cursor freely (both axes) — CapCut-style. The - // "magnetic" affordance is a highlight on the destination lane (draggedRowIndex), - // which flips at the MAGNETIC_TRACK_THRESHOLD point; the clip drops into it. - // Live multi-selection drag: while a selected clip is dragged, ALL selected - // clips move together as one rigid formation. The GRABBED clip is the free - // ghost below; its co-selected "passengers" slide by the SAME group-clamped - // delta (cheap translateX, no re-layout) — the delta is derived from the - // grabbed clip's ALREADY-clamped previewStart, so the whole formation stops at - // the wall together and never deforms. Matches what the commit will do — see - // timelineMultiDragPreview + commit. - const multiDragPreview: MultiDragPreviewInput | null = - draggedClip?.started === true && draggedElement && draggedElementIdentity - ? { - dragStarted: true, - draggedKey: draggedElementIdentity, - draggedOriginStart: draggedElement.start, - draggedPreviewStart: draggedClip.previewStart, - selectedKeys: selectedElementIds, - } - : null; + const { draggedElement, snapGuide, multiDragPreview } = props; return (
{/* Breathing room below the last track lane (~1.5 track heights) — a real diff --git a/packages/studio/src/player/components/TimelineOverlays.tsx b/packages/studio/src/player/components/TimelineOverlays.tsx index 2ea415747a..6e013dfba7 100644 --- a/packages/studio/src/player/components/TimelineOverlays.tsx +++ b/packages/studio/src/player/components/TimelineOverlays.tsx @@ -4,7 +4,7 @@ import { EditPopover } from "./EditModal"; import { KeyframeDiamondContextMenu } from "./KeyframeDiamondContextMenu"; import { ClipContextMenu } from "./ClipContextMenu"; import { TrackGapContextMenu } from "./TrackGapContextMenu"; -import { TimelineShortcutHint } from "./TimelineShortcutHint"; +import { TimelineShortcutHint as TimelineShortcutHintImpl } from "./TimelineShortcutHint"; import { copyTextToClipboard } from "../../utils/clipboard"; import { trackStudioSegmentEaseEdit } from "../../telemetry/events"; import { useTimelineContext } from "./TimelineProvider"; @@ -17,7 +17,6 @@ interface TimelineContextTargetInput { elements: readonly TimelineElement[]; } -/** The captured project session and current selection jointly own a context target. */ export function resolveTimelineContextElement({ capturedElement, targetSessionEpoch, @@ -31,200 +30,192 @@ export function resolveTimelineContextElement({ return elements.find((element) => (element.key ?? element.id) === identity) ?? null; } -// The timeline's floating overlays, rendered as siblings above the scroll area: -// the shortcut hint, the range-edit popover, the keyframe-diamond context menu, -// and the clip context menu. -export type TimelineOverlayPart = "all" | "shortcut" | "edit" | "clip" | "keyframe" | "gap"; +export function TimelineShortcutHintOverlay() { + const { state } = useTimelineContext(); + const { showShortcutHint, showPopover, rangeSelection, theme } = state.overlays; + if (!showShortcutHint || showPopover || rangeSelection) return null; + return ; +} + +export function TimelineEditPopoverOverlay() { + const { state } = useTimelineContext(); + const { showPopover, rangeSelection, setShowPopover, setRangeSelection } = state.overlays; + if (!showPopover || !rangeSelection) return null; + return ( + { + setShowPopover(false); + setRangeSelection(null); + }} + /> + ); +} -// The composed overlay keeps target validation and action routing in one owner. -// fallow-ignore-next-line complexity -// fallow-ignore-next-line unit-size -export function TimelineOverlays({ part = "all" }: { part?: TimelineOverlayPart } = {}) { +export function TimelineKeyframeMenuOverlay() { const { state, actions } = useTimelineContext(); - const overlayProps = state.overlays; - const { - elements, - elementsRef, - theme, - showShortcutHint, - showPopover, - rangeSelection, - setShowPopover, - setRangeSelection, - kfContextMenu, - setKfContextMenu, - onDeleteKeyframe, - onDeleteAllKeyframes, - onMoveKeyframeToPlayhead, - clipContextMenu, - setClipContextMenu, - currentTime, - onSplitElement, - pinZoomBeforeEdit, - onDeleteElement, - onCopyClip, - onPasteClip, - onDuplicateClip, - canPasteClip, - gapContextMenu, - onDismissGapContextMenu, - onCloseTrackGap, - onCloseAllTrackGaps, - onHoverGapAction, - } = overlayProps; + const overlay = state.overlays; + const { kfContextMenu, setKfContextMenu } = overlay; const { selectedElementId, sessionEpoch, keyframeCache } = state; - const kfTargetSessionEpoch = kfContextMenu?.sessionEpoch; - const clipTargetSessionEpoch = clipContextMenu?.sessionEpoch; - const keyframeElement = kfContextMenu + const targetEpoch = kfContextMenu?.sessionEpoch; + const element = kfContextMenu ? resolveTimelineContextElement({ capturedElement: kfContextMenu.element, - targetSessionEpoch: kfTargetSessionEpoch, + targetSessionEpoch: targetEpoch, sessionEpoch, selectedElementId, - elements, + elements: overlay.elements, }) : null; - const clipElement = clipContextMenu + useEffect(() => { + if (kfContextMenu && !element) setKfContextMenu(null); + }, [element, kfContextMenu, setKfContextMenu]); + if (!kfContextMenu || !element) return null; + const readCurrentElement = () => + resolveTimelineContextElement({ + capturedElement: element, + targetSessionEpoch: targetEpoch, + sessionEpoch, + selectedElementId, + elements: overlay.elementsRef.current, + }); + const menu = kfContextMenu; + return ( + setKfContextMenu(null)} + onDelete={(...args) => { + if (readCurrentElement()) overlay.onDeleteKeyframe?.(...args); + }} + onDeleteAll={(_element, animationId) => { + const current = readCurrentElement(); + if (current) overlay.onDeleteAllKeyframes?.(current, animationId); + }} + onMoveToPlayhead={ + overlay.onMoveKeyframeToPlayhead + ? (_element, ...args) => { + const current = readCurrentElement(); + if (current) overlay.onMoveKeyframeToPlayhead?.(current, ...args); + } + : undefined + } + onEditEase={ + // Routed to the same focused-ease-segment path a segment click takes, + // so the menu advertises the editor that exists rather than growing a + // second one. Offered only for a keyframe that names a tween to focus. + menu.animationId !== undefined && menu.tweenPercentage !== undefined + ? (elementId, keyframe) => { + if (keyframe.animationId === undefined || keyframe.tweenPercentage === undefined) + return; + actions.setFocusedEaseSegment({ + animationId: keyframe.animationId, + collidingAnimationTargets: keyframe.collidingAnimationTargets, + tweenPercentage: keyframe.tweenPercentage, + elementId, + }); + trackStudioSegmentEaseEdit({ action: "open" }); + } + : undefined + } + onCopyProperties={(elementId, keyframe) => { + const entry = keyframeCache.get(elementId); + // Match the existing keyframe lookup tolerance so copied properties + // follow the same nearby-keyframe selection as the editor. + const keyframeValue = entry?.keyframes.find( + (item) => Math.abs(item.percentage - keyframe.percentage) < 0.5, + ); + if (!keyframeValue) return false; + return copyTextToClipboard(JSON.stringify(keyframeValue.properties, null, 2)); + }} + /> + ); +} + +export function TimelineClipMenuOverlay() { + const { state } = useTimelineContext(); + const overlay = state.overlays; + const { clipContextMenu, setClipContextMenu } = overlay; + const { selectedElementId, sessionEpoch } = state; + const targetEpoch = clipContextMenu?.sessionEpoch; + const element = clipContextMenu ? resolveTimelineContextElement({ capturedElement: clipContextMenu.element, - targetSessionEpoch: clipTargetSessionEpoch, + targetSessionEpoch: targetEpoch, sessionEpoch, selectedElementId, - elements, + elements: overlay.elements, }) : null; - const readCurrentElement = (element: TimelineElement, targetSessionEpoch: number | undefined) => + useEffect(() => { + if (clipContextMenu && !element) setClipContextMenu(null); + }, [element, clipContextMenu, setClipContextMenu]); + if (!clipContextMenu || !element) return null; + const readCurrentElement = () => resolveTimelineContextElement({ capturedElement: element, - targetSessionEpoch, + targetSessionEpoch: targetEpoch, sessionEpoch, selectedElementId, - elements: elementsRef.current, + elements: overlay.elementsRef.current, }); + const menu = clipContextMenu; + return ( + setClipContextMenu(null)} + onSplit={(_element, time) => { + const current = readCurrentElement(); + if (current) overlay.onSplitElement?.(current, time); + }} + onDelete={() => { + const current = readCurrentElement(); + if (!current) return; + overlay.pinZoomBeforeEdit(); + overlay.onDeleteElement?.(current); + }} + onCopy={overlay.onCopyClip} + onPaste={overlay.onPasteClip} + onDuplicate={overlay.onDuplicateClip} + canPaste={overlay.canPasteClip?.() ?? false} + /> + ); +} - useEffect(() => { - if (kfContextMenu && !keyframeElement) setKfContextMenu(null); - }, [keyframeElement, kfContextMenu, setKfContextMenu]); - - useEffect(() => { - if (clipContextMenu && !clipElement) setClipContextMenu(null); - }, [clipContextMenu, clipElement, setClipContextMenu]); - - const includes = (candidate: Exclude) => - part === "all" || part === candidate; +export function TimelineGapMenuOverlay() { + const { state } = useTimelineContext(); + const menu = state.overlays.gapContextMenu; + const overlay = state.overlays; + if (!menu) return null; + return ( + + ); +} +export function TimelineOverlays() { return ( <> - {includes("shortcut") && showShortcutHint && !showPopover && !rangeSelection && ( - - )} - - {includes("edit") && showPopover && rangeSelection && ( - { - setShowPopover(false); - setRangeSelection(null); - }} - /> - )} - - {includes("keyframe") && kfContextMenu && keyframeElement && ( - setKfContextMenu(null)} - onDelete={(...args) => { - if (!readCurrentElement(keyframeElement, kfTargetSessionEpoch)) return; - onDeleteKeyframe?.(...args); - }} - onDeleteAll={(_element, animationId) => { - const element = readCurrentElement(keyframeElement, kfTargetSessionEpoch); - if (element) onDeleteAllKeyframes?.(element, animationId); - }} - onMoveToPlayhead={ - onMoveKeyframeToPlayhead - ? (_element, ...args) => { - const element = readCurrentElement(keyframeElement, kfTargetSessionEpoch); - if (element) onMoveKeyframeToPlayhead(element, ...args); - } - : undefined - } - // Routed to the same focused-ease-segment path a segment click takes, - // so the menu advertises the editor that exists rather than growing a - // second one. Offered only for a keyframe that names a tween to focus. - onEditEase={ - kfContextMenu.animationId !== undefined && kfContextMenu.tweenPercentage !== undefined - ? (elementId, keyframe) => { - if ( - keyframe.animationId === undefined || - keyframe.tweenPercentage === undefined - ) { - return; - } - actions.setFocusedEaseSegment({ - animationId: keyframe.animationId, - collidingAnimationTargets: keyframe.collidingAnimationTargets, - tweenPercentage: keyframe.tweenPercentage, - elementId, - }); - trackStudioSegmentEaseEdit({ action: "open" }); - } - : undefined - } - onCopyProperties={(elementId, keyframe) => { - const entry = keyframeCache.get(elementId); - // Tolerance match on clip-%, the same basis the cache is keyed on — - // an exact float compare misses a keyframe the menu just opened over. - const kf = entry?.keyframes.find( - (item) => Math.abs(item.percentage - keyframe.percentage) < 0.5, - ); - if (!kf) return false; - return copyTextToClipboard(JSON.stringify(kf.properties, null, 2)); - }} - /> - )} - - {includes("clip") && clipContextMenu && clipElement && ( - setClipContextMenu(null)} - onSplit={(_element, time) => { - const element = readCurrentElement(clipElement, clipTargetSessionEpoch); - if (element) onSplitElement?.(element, time); - }} - onDelete={() => { - const element = readCurrentElement(clipElement, clipTargetSessionEpoch); - if (!element) return; - pinZoomBeforeEdit(); - onDeleteElement?.(element); - }} - onCopy={onCopyClip} - onPaste={onPasteClip} - onDuplicate={onDuplicateClip} - canPaste={canPasteClip?.() ?? false} - /> - )} - - {includes("gap") && gapContextMenu && ( - - )} + + + + + ); } diff --git a/packages/studio/src/player/components/TimelineParts.test.tsx b/packages/studio/src/player/components/TimelineParts.test.tsx new file mode 100644 index 0000000000..22ecdc89ae --- /dev/null +++ b/packages/studio/src/player/components/TimelineParts.test.tsx @@ -0,0 +1,101 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { TimelineContextValue } from "./TimelineProvider"; +import { TimelineContextProvider, useTimelineContext } from "./TimelineProvider"; +import { TimelineClipMenu, TimelineLanes, TimelinePlayhead, TimelineRuler } from "./TimelineParts"; +import { createHappyDomRootHarness } from "./testRootHarness"; + +vi.mock("./TimelineRuler", () => ({ + TimelineRuler: () =>
, +})); +vi.mock("./TimelineLanes", () => ({ + TimelineLanes: ({ renderClipContent }: { renderClipContent?: unknown }) => { + const { state } = useTimelineContext(); + return ( +
+ {state.elements[0]?.id} + {renderClipContent ? : null} +
+ ); + }, +})); + +const { mount } = createHappyDomRootHarness(); + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("Timeline parts composition", () => { + it("reads a shared provider context when composed without Timeline or Frame", () => { + const host = document.createElement("div"); + document.body.append(host); + const root = mount(host); + const onSplitElement = vi.fn(); + const element = { id: "clip-a", key: "clip-a", tag: "video", start: 0, duration: 2, track: 0 }; + const value = { + state: { + timelineReady: true, + elements: [element], + selectedElementId: "clip-a", + sessionEpoch: 1, + keyframeCache: new Map(), + canvas: { isScrubbing: false } as TimelineContextValue["state"]["canvas"], + overlays: { + elements: [element], + elementsRef: { current: [element] }, + theme: {} as TimelineContextValue["state"]["overlays"]["theme"], + showShortcutHint: false, + showPopover: false, + rangeSelection: null, + setShowPopover: vi.fn(), + setRangeSelection: vi.fn(), + kfContextMenu: null, + setKfContextMenu: vi.fn(), + onDeleteKeyframe: vi.fn(), + onDeleteAllKeyframes: vi.fn(), + onMoveKeyframeToPlayhead: vi.fn(), + clipContextMenu: { x: 10, y: 10, element, sessionEpoch: 1 }, + setClipContextMenu: vi.fn(), + currentTime: 1, + onSplitElement, + pinZoomBeforeEdit: vi.fn(), + gapContextMenu: null, + onDismissGapContextMenu: vi.fn(), + onCloseTrackGap: vi.fn(), + onCloseAllTrackGaps: vi.fn(), + onHoverGapAction: vi.fn(), + }, + }, + actions: { + renderClipContent: vi.fn(), + renderClipOverlay: undefined, + setFocusedEaseSegment: vi.fn(), + }, + meta: { razorGuide: null } as TimelineContextValue["meta"], + } satisfies TimelineContextValue; + + try { + act(() => { + root.render( + + + + + + , + ); + }); + + expect(host.querySelector("[data-testid='timeline-ruler']")).not.toBeNull(); + expect(host.querySelector("[data-testid='timeline-clip']")?.textContent).toBe("clip-a"); + expect(host.querySelector("[data-testid='lane-render-bridge']")).not.toBeNull(); + expect(document.querySelector("[role='menu'][aria-label='Clip actions']")).not.toBeNull(); + } finally { + act(() => root.unmount()); + } + }); +}); diff --git a/packages/studio/src/player/components/TimelineParts.tsx b/packages/studio/src/player/components/TimelineParts.tsx index b30f2f62ca..abab4732c2 100644 --- a/packages/studio/src/player/components/TimelineParts.tsx +++ b/packages/studio/src/player/components/TimelineParts.tsx @@ -1,6 +1,13 @@ import { memo } from "react"; import { TimelineCanvas as TimelineCanvasImpl } from "./TimelineCanvas"; import { TimelineOverlays as TimelineOverlaysImpl } from "./TimelineOverlays"; +import { + TimelineClipMenuOverlay as TimelineClipMenuImpl, + TimelineEditPopoverOverlay as TimelineEditPopoverImpl, + TimelineGapMenuOverlay as TimelineGapMenuImpl, + TimelineKeyframeMenuOverlay as TimelineKeyframeMenuImpl, + TimelineShortcutHintOverlay as TimelineShortcutHintImpl, +} from "./TimelineOverlays"; import { TimelineLanes as TimelineLanesImpl } from "./TimelineLanes"; import { PlayheadIndicator } from "./PlayheadIndicator"; import { useTimelineContext } from "./TimelineProvider"; @@ -22,10 +29,9 @@ export const TimelineRuler = TimelineRulerPart; export const TimelineLanes = memo(function TimelineLanes() { const { state, actions } = useTimelineContext(); const props = state.canvas; - const laneProps = props as unknown as Parameters[0]; return ( @@ -46,7 +52,7 @@ export const TimelineRazorGuide = memo(function TimelineRazorGuide() { }); /** The composed overlay surface used by the Studio variant. */ -export const TimelineOverlaysPart = memo(function TimelineOverlaysPart() { +export const TimelineOverlays = memo(function TimelineOverlays() { return ; }); @@ -56,17 +62,17 @@ export const TimelineEmptyStatePart = memo(function TimelineEmptyStatePart() { }); export const TimelineShortcutHint = memo(function TimelineShortcutHint() { - return ; + return ; }); export const TimelineEditPopover = memo(function TimelineEditPopover() { - return ; + return ; }); export const TimelineClipMenu = memo(function TimelineClipMenu() { - return ; + return ; }); export const TimelineKeyframeMenu = memo(function TimelineKeyframeMenu() { - return ; + return ; }); export const TimelineGapMenu = memo(function TimelineGapMenu() { - return ; + return ; }); diff --git a/packages/studio/src/player/components/TimelineProvider.tsx b/packages/studio/src/player/components/TimelineProvider.tsx index c75efeaec5..b16f3cd551 100644 --- a/packages/studio/src/player/components/TimelineProvider.tsx +++ b/packages/studio/src/player/components/TimelineProvider.tsx @@ -17,6 +17,8 @@ import type { Rect } from "../../utils/marqueeGeometry"; import type { ResizingClipState } from "./useTimelineClipDrag"; import type { TimelineLaneBaseProps } from "./timelineLaneProps"; import type { TimelineLaneGapStrips } from "./useTimelineGapHighlights"; +import type { MultiDragPreviewInput } from "./timelineMultiDragPreview"; +import type { TimelineSnapTarget } from "./timelineSnapping"; import type { TimelineElement } from "../store/playerStore"; import type { KeyframeCacheEntry } from "../store/keyframeSlice"; import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth"; @@ -24,6 +26,10 @@ import type { TimelineTheme } from "./timelineTheme"; import type { TimelineEditCallbacks } from "./timelineCallbacks"; import type { KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu"; import { useTimelineProviderState } from "./useTimelineProviderState"; +import { + TimelineEditProvider, + useTimelineEditContextValue, +} from "../../contexts/TimelineEditContext"; export type TimelineCanvasState = Omit< TimelineLaneBaseProps, @@ -51,6 +57,13 @@ export type TimelineCanvasState = Omit< beatDragging: boolean; onResizeElement: TimelineEditCallbacks["onResizeElement"]; onMoveElement: TimelineEditCallbacks["onMoveElement"]; + draggedElement: TimelineElement | null; + snapGuide: TimelineSnapTarget | null; + multiDragPreview: MultiDragPreviewInput | null; + onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"]; + onTogglePropertyGroupKeyframe: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"]; + onRazorSplit: TimelineEditCallbacks["onRazorSplit"]; + onRazorSplitAll: TimelineEditCallbacks["onRazorSplitAll"]; }; export interface ClipContextMenuState { @@ -166,6 +179,18 @@ export interface TimelineContextValue { const TimelineContext = createContext(null); export function TimelineProvider({ children, ...props }: TimelineProps & { children: ReactNode }) { + const editContext = useTimelineEditContextValue(); + if (!editContext) { + return ( + + {children} + + ); + } + return {children}; +} + +function TimelineProviderState({ children, ...props }: TimelineProps & { children: ReactNode }) { const value = useTimelineProviderState(props); return {children}; } diff --git a/packages/studio/src/player/components/timelineProviderStateBuilders.ts b/packages/studio/src/player/components/timelineProviderStateBuilders.ts index 662ec4b8ad..f4c03b0de0 100644 --- a/packages/studio/src/player/components/timelineProviderStateBuilders.ts +++ b/packages/studio/src/player/components/timelineProviderStateBuilders.ts @@ -5,6 +5,34 @@ import type { } from "./TimelineProvider"; import type { ResizingClipState } from "./useTimelineClipDrag"; import type { TimelineLaneBaseProps } from "./timelineLaneProps"; +import type { DraggedClipState } from "./timelineClipDragTypes"; +import type { MultiDragPreviewInput } from "./timelineMultiDragPreview"; +import { getTimelineElementIdentity } from "../lib/timelineElementHelpers"; + +export function resolveMultiDragPreview( + draggedClip: DraggedClipState | null, + selectedKeys: ReadonlySet, +): MultiDragPreviewInput | null { + // The drag ghost follows the cursor freely (both axes) — CapCut-style. The + // "magnetic" affordance is a highlight on the destination lane (draggedRowIndex), + // which flips at the MAGNETIC_TRACK_THRESHOLD point; the clip drops into it. + // Live multi-selection drag: while a selected clip is dragged, ALL selected + // clips move together as one rigid formation. The GRABBED clip is the free + // ghost below; its co-selected "passengers" slide by the SAME group-clamped + // delta (cheap translateX, no re-layout) — the delta is derived from the + // grabbed clip's ALREADY-clamped previewStart, so the whole formation stops at + // the wall together and never deforms. Matches what the commit will do — see + // timelineMultiDragPreview + commit. + if (!draggedClip?.started) return null; + const draggedKey = getTimelineElementIdentity(draggedClip.element); + return { + dragStarted: true, + draggedKey, + draggedOriginStart: draggedClip.element.start, + draggedPreviewStart: draggedClip.previewStart, + selectedKeys, + }; +} export function resolveResizingElementIds( resizingClip: ResizingClipState | null, diff --git a/packages/studio/src/player/components/useTimelineProviderState.tsx b/packages/studio/src/player/components/useTimelineProviderState.tsx index a9687de6f0..32f7ee317d 100644 --- a/packages/studio/src/player/components/useTimelineProviderState.tsx +++ b/packages/studio/src/player/components/useTimelineProviderState.tsx @@ -10,6 +10,7 @@ import { useTimelineClipDrag } from "./useTimelineClipDrag"; import type { ClipContextMenuState, TimelineContextValue } from "./TimelineProvider"; import { buildTimelineMeta, + resolveMultiDragPreview, resolveRenderClipContent, resolveResizingElementIds, shouldIgnoreTimelinePointerDown, @@ -44,6 +45,8 @@ import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow"; import { useTimelineActiveClips } from "./useTimelineActiveClips"; import { useTimelineLaneMoveRefresh } from "./useTimelineLaneMoveRefresh"; import { useTimelineLogicalFocus } from "./useTimelineLogicalFocus"; +import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext"; +import { resolveSnapGuide } from "./timelineSnapping"; export function useTimelineProviderState({ onSeek, onDrillDown, @@ -90,10 +93,10 @@ export function useTimelineProviderState({ onSplitElement: onSplitElementOverride, }); const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]); + const editContext = useTimelineEditContextOptional(); const refreshAfterLaneMove = useTimelineLaneMoveRefresh(); useMusicBeatAnalysis(); - const rawElements = usePlayerStore((s) => s.elements); - const timelineElements = rawElements; + const timelineElements = usePlayerStore((s) => s.elements); const adjustedBeatAnalysis = useAdjustedBeatAnalysis(); const duration = usePlayerStore((s) => s.duration); const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode); @@ -108,7 +111,6 @@ export function useTimelineProviderState({ ); // The label column provides pre-t=0 space; otherwise keep TRACKS_LEFT_PAD after the gutter. const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER + TRACKS_LEFT_PAD; - const contentGutter = labelMode ? GUTTER : 0; const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId); const currentTime = usePlayerStore((s) => s.currentTime); const beatDragging = usePlayerStore((s) => s.beatDragging); @@ -130,8 +132,8 @@ export function useTimelineProviderState({ }, []); const lastScrollLeftRef = useRef(0); const effectiveDuration = useMemo( - () => getEffectiveTimelineDuration(duration, rawElements), - [duration, rawElements], + () => getEffectiveTimelineDuration(duration, timelineElements), + [duration, timelineElements], ); const keyframeCache = usePlayerStore((s) => s.keyframeCache); useAutoExpandKeyframedClips(gsapAnimations); @@ -406,7 +408,6 @@ export function useTimelineProviderState({ handlePointerCancel, } = overlaysProps; const { rangeSelection, setRangeSelection } = overlays; - const laneGapStrips = useTimelineGapHighlights({ gapHighlight, tracks, @@ -416,25 +417,24 @@ export function useTimelineProviderState({ dragActive: draggedClip?.started === true || resizingClip != null, displayDuration, }); - const { major, minor, majorTickInterval } = useTimelineTicks( displayDuration, pps, timeDisplayMode, timelineFocus.rowVirtualizationActive ? renderTimeRange : undefined, ); - const getPreviewElement = useCallback( (element: TimelineElement): TimelineElement => getTimelinePreviewElement(element, resizingClip), [resizingClip], ); - + const draggedElement = draggedClip?.element ?? null; + const multiDragPreview = resolveMultiDragPreview(draggedClip, selectedElementIds); const canvasProps = { major, minor, pps, contentOrigin, - contentGutter, + contentGutter: labelMode ? GUTTER : 0, trackContentWidth: displayContentWidth, totalH: displayLayout.totalH, effectiveDuration, @@ -501,6 +501,13 @@ export function useTimelineProviderState({ onResizeElement, onMoveElement, beatDragging, + draggedElement, + snapGuide: resolveSnapGuide(draggedClip, resizingClip), + multiDragPreview, + onToggleTrackHidden: editContext.onToggleTrackHidden, + onTogglePropertyGroupKeyframe: editContext.onTogglePropertyGroupKeyframe, + onRazorSplit: editContext.onRazorSplit, + onRazorSplitAll: editContext.onRazorSplitAll, }; const timelineRenderClipContent = resolveRenderClipContent( timelineFocus.rowVirtualizationActive, From 8c4f7f00f732656dff5ad8783654f45112592062 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 19:52:17 -0400 Subject: [PATCH 3/5] fix(studio): resolve timeline fallow findings --- .../src/player/components/Timeline.test.ts | 93 +++++-------------- .../src/player/components/TimelineParts.tsx | 5 +- .../components/TimelinePartsParity.test.tsx | 58 ++++++++++++ 3 files changed, 80 insertions(+), 76 deletions(-) create mode 100644 packages/studio/src/player/components/TimelinePartsParity.test.tsx diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index 1f9b4c13c3..e465dab913 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -41,7 +41,6 @@ import { AUTOMATION_LANE_H } from "./automationLaneHeight"; import { formatTime } from "../lib/time"; import { usePlayerStore } from "../store/playerStore"; import { TimelineEditProvider } from "../../contexts/TimelineEditContext"; -import { useTimelineContext } from "./TimelineProvider"; vi.mock("./timelineRowVirtualizationFlag", () => ({ STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED: false, @@ -73,21 +72,6 @@ describe("timeline viewport geometry", () => { }); }); -function TimelinePartsVariant() { - const { meta } = useTimelineContext(); - return React.createElement( - "div", - meta.containerProps, - React.createElement( - "div", - meta.viewportProps, - React.createElement(Timeline.Frame), - React.createElement(Timeline.RazorGuide), - ), - React.createElement(Timeline.Overlays), - ); -} - function getHorizontalGeometry(host: HTMLElement, clipId: string, tickLabel: string) { const clip = host.querySelector(`[data-el-id="${clipId}"]`); if (!clip) throw new Error(`Missing timeline clip ${clipId}`); @@ -151,33 +135,27 @@ function renderBasicTimeline() { return { host, root }; } -describe("Timeline provider boundary", () => { - it("keeps the composed Timeline markup equal to its provider parts", () => { - usePlayerStore.setState({ - duration: 4, - timelineReady: true, - elements: [{ id: "parity-clip", tag: "div", start: 0, duration: 2, track: 0 }], - }); - const composedHost = document.createElement("div"); - const partsHost = document.createElement("div"); - document.body.append(composedHost, partsHost); - const composedRoot = createRoot(composedHost); - const partsRoot = createRoot(partsHost); - act(() => { - composedRoot.render(React.createElement(Timeline)); - partsRoot.render( - React.createElement(Timeline.Provider, null, React.createElement(TimelinePartsVariant)), - ); - }); - const normalizeMarkup = (markup: string) => - markup.replaceAll(/timeline-lanes_[^"]+/g, "timeline-lanes"); - expect(normalizeMarkup(partsHost.innerHTML)).toBe(normalizeMarkup(composedHost.innerHTML)); - act(() => { - composedRoot.unmount(); - partsRoot.unmount(); - }); +function renderSharedAutomationTimeline(selectedElementId?: string) { + const host = createSizedTimelineHost(720); + const automation = JSON.stringify({ + version: 1, + lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }], }); + usePlayerStore.setState({ + duration: 8, + timelineReady: true, + ...(selectedElementId ? { selectedElementId } : {}), + elements: [ + { id: "narration-1", tag: "audio", start: 0, duration: 4, track: 0, automation }, + { id: "narration-2", tag: "audio", start: 4, duration: 4, track: 0, automation }, + ], + }); + const root = createRoot(host); + act(() => root.render(React.createElement(Timeline))); + return { host, root }; +} +describe("Timeline provider boundary", () => { it("keeps all-collapsed horizontal positions at the gutter plus the pre-t=0 pad", () => { usePlayerStore.setState({ duration: 11, @@ -663,21 +641,7 @@ describe("Timeline provider boundary", () => { // clip left the row's state depending on the selection, and a collapse that // only dropped the active clip left the row stuck open. it("expands and collapses every clip on a shared track together", () => { - const host = createSizedTimelineHost(720); - const automation = JSON.stringify({ - version: 1, - lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }], - }); - usePlayerStore.setState({ - duration: 8, - timelineReady: true, - elements: [ - { id: "narration-1", tag: "audio", start: 0, duration: 4, track: 0, automation }, - { id: "narration-2", tag: "audio", start: 4, duration: 4, track: 0, automation }, - ], - }); - const root = createRoot(host); - act(() => root.render(React.createElement(Timeline))); + const { host, root } = renderSharedAutomationTimeline(); const row = host.querySelector('[data-el-id="narration-1"]')?.parentElement ?.parentElement; @@ -710,22 +674,7 @@ describe("Timeline provider boundary", () => { // a lane to select its clip therefore made the handles vanish under the // pointer, which is the one gesture the read-only lane exists to support. it("keeps the automation lanes mounted when the selection moves along the row", () => { - const host = createSizedTimelineHost(720); - const automation = JSON.stringify({ - version: 1, - lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }], - }); - usePlayerStore.setState({ - duration: 8, - timelineReady: true, - selectedElementId: "narration-2", - elements: [ - { id: "narration-1", tag: "audio", start: 0, duration: 4, track: 0, automation }, - { id: "narration-2", tag: "audio", start: 4, duration: 4, track: 0, automation }, - ], - }); - const root = createRoot(host); - act(() => root.render(React.createElement(Timeline))); + const { host, root } = renderSharedAutomationTimeline("narration-2"); act(() => host.querySelector('button[aria-label$=" lanes"]')?.click()); const before = [...host.querySelectorAll(".hf-automation-lane")]; diff --git a/packages/studio/src/player/components/TimelineParts.tsx b/packages/studio/src/player/components/TimelineParts.tsx index abab4732c2..909a21a547 100644 --- a/packages/studio/src/player/components/TimelineParts.tsx +++ b/packages/studio/src/player/components/TimelineParts.tsx @@ -1,6 +1,5 @@ import { memo } from "react"; import { TimelineCanvas as TimelineCanvasImpl } from "./TimelineCanvas"; -import { TimelineOverlays as TimelineOverlaysImpl } from "./TimelineOverlays"; import { TimelineClipMenuOverlay as TimelineClipMenuImpl, TimelineEditPopoverOverlay as TimelineEditPopoverImpl, @@ -52,9 +51,7 @@ export const TimelineRazorGuide = memo(function TimelineRazorGuide() { }); /** The composed overlay surface used by the Studio variant. */ -export const TimelineOverlays = memo(function TimelineOverlays() { - return ; -}); +export { TimelineOverlays } from "./TimelineOverlays"; export const TimelineEmptyStatePart = memo(function TimelineEmptyStatePart() { const { meta } = useTimelineContext(); diff --git a/packages/studio/src/player/components/TimelinePartsParity.test.tsx b/packages/studio/src/player/components/TimelinePartsParity.test.tsx new file mode 100644 index 0000000000..8c53f71046 --- /dev/null +++ b/packages/studio/src/player/components/TimelinePartsParity.test.tsx @@ -0,0 +1,58 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; +import { Timeline } from "./Timeline"; +import { usePlayerStore } from "../store/playerStore"; +import { useTimelineContext } from "./TimelineProvider"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; + usePlayerStore.getState().reset(); +}); + +function TimelinePartsVariant() { + const { meta } = useTimelineContext(); + return React.createElement( + "div", + meta.containerProps, + React.createElement( + "div", + meta.viewportProps, + React.createElement(Timeline.Frame), + React.createElement(Timeline.RazorGuide), + ), + React.createElement(Timeline.Overlays), + ); +} + +describe("Timeline provider boundary", () => { + it("keeps the composed Timeline markup equal to its provider parts", () => { + usePlayerStore.setState({ + duration: 4, + timelineReady: true, + elements: [{ id: "parity-clip", tag: "div", start: 0, duration: 2, track: 0 }], + }); + const composedHost = document.createElement("div"); + const partsHost = document.createElement("div"); + document.body.append(composedHost, partsHost); + const composedRoot = createRoot(composedHost); + const partsRoot = createRoot(partsHost); + act(() => { + composedRoot.render(React.createElement(Timeline)); + partsRoot.render( + React.createElement(Timeline.Provider, null, React.createElement(TimelinePartsVariant)), + ); + }); + const normalizeMarkup = (markup: string) => + markup.replaceAll(/timeline-lanes_[^"]+/g, "timeline-lanes"); + expect(normalizeMarkup(partsHost.innerHTML)).toBe(normalizeMarkup(composedHost.innerHTML)); + act(() => { + composedRoot.unmount(); + partsRoot.unmount(); + }); + }); +}); From f72771d5457364cbc2f237385fc0b7c95ae2d140 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 22:00:17 -0400 Subject: [PATCH 4/5] fix(studio): clarify timeline part contracts --- .../src/player/components/TimelineParts.tsx | 41 +++++++++---------- .../components/TimelineRazorInteraction.tsx | 2 +- .../player/components/TimelineRulerPart.tsx | 5 +-- .../components/useTimelineProviderState.tsx | 4 +- 4 files changed, 25 insertions(+), 27 deletions(-) diff --git a/packages/studio/src/player/components/TimelineParts.tsx b/packages/studio/src/player/components/TimelineParts.tsx index 909a21a547..c8b072f3ad 100644 --- a/packages/studio/src/player/components/TimelineParts.tsx +++ b/packages/studio/src/player/components/TimelineParts.tsx @@ -1,4 +1,3 @@ -import { memo } from "react"; import { TimelineCanvas as TimelineCanvasImpl } from "./TimelineCanvas"; import { TimelineClipMenuOverlay as TimelineClipMenuImpl, @@ -14,9 +13,9 @@ import { TimelineEmptyState } from "./TimelineEmptyState"; import { TimelineRulerPart } from "./TimelineRulerPart"; /** The provider-backed canvas shell. */ -export const TimelineFrame = memo(function TimelineFrame() { +export function TimelineFrame() { return ; -}); +} /** The provider-backed ruler. */ export const TimelineRuler = TimelineRulerPart; @@ -25,7 +24,7 @@ export const TimelineRuler = TimelineRulerPart; * The provider-backed lane renderer. TimelineFrame remains the composed Studio * canvas; this part is exported for hosts that own the canvas arrangement. */ -export const TimelineLanes = memo(function TimelineLanes() { +export function TimelineLanes() { const { state, actions } = useTimelineContext(); const props = state.canvas; return ( @@ -35,41 +34,41 @@ export const TimelineLanes = memo(function TimelineLanes() { renderClipOverlay={actions.renderClipOverlay} /> ); -}); +} /** The provider-backed playhead indicator. */ -export const TimelinePlayhead = memo(function TimelinePlayhead() { +export function TimelinePlayhead() { const { state } = useTimelineContext(); const props = state.canvas; return ; -}); +} /** The provider-backed razor guide. */ -export const TimelineRazorGuide = memo(function TimelineRazorGuide() { +export function TimelineRazorGuide() { const { meta } = useTimelineContext(); return meta.razorGuide; -}); +} /** The composed overlay surface used by the Studio variant. */ export { TimelineOverlays } from "./TimelineOverlays"; -export const TimelineEmptyStatePart = memo(function TimelineEmptyStatePart() { +export function TimelineEmptyStatePart() { const { meta } = useTimelineContext(); return ; -}); +} -export const TimelineShortcutHint = memo(function TimelineShortcutHint() { +export function TimelineShortcutHint() { return ; -}); -export const TimelineEditPopover = memo(function TimelineEditPopover() { +} +export function TimelineEditPopover() { return ; -}); -export const TimelineClipMenu = memo(function TimelineClipMenu() { +} +export function TimelineClipMenu() { return ; -}); -export const TimelineKeyframeMenu = memo(function TimelineKeyframeMenu() { +} +export function TimelineKeyframeMenu() { return ; -}); -export const TimelineGapMenu = memo(function TimelineGapMenu() { +} +export function TimelineGapMenu() { return ; -}); +} diff --git a/packages/studio/src/player/components/TimelineRazorInteraction.tsx b/packages/studio/src/player/components/TimelineRazorInteraction.tsx index 881daa07a8..1d0d554b0b 100644 --- a/packages/studio/src/player/components/TimelineRazorInteraction.tsx +++ b/packages/studio/src/player/components/TimelineRazorInteraction.tsx @@ -50,7 +50,7 @@ export function useTimelineRazorInteraction({ return { razorGuideX, updateRazorGuide, clearRazorGuide, splitAllAtPointer }; } -export function TimelineRazorGuide({ x }: { x: number }) { +export function TimelineRazorGuideOverlay({ x }: { x: number }) { return (
); -}); +} diff --git a/packages/studio/src/player/components/useTimelineProviderState.tsx b/packages/studio/src/player/components/useTimelineProviderState.tsx index 32f7ee317d..03645bcbb7 100644 --- a/packages/studio/src/player/components/useTimelineProviderState.tsx +++ b/packages/studio/src/player/components/useTimelineProviderState.tsx @@ -31,7 +31,7 @@ import { } from "./useTimelineTrackLayout"; import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers"; import { useTimelineGapHighlights } from "./useTimelineGapHighlights"; -import { TimelineRazorGuide, useTimelineRazorInteraction } from "./TimelineRazorInteraction"; +import { TimelineRazorGuideOverlay, useTimelineRazorInteraction } from "./TimelineRazorInteraction"; import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemetry"; import { getEffectiveTimelineDuration, @@ -566,7 +566,7 @@ export function useTimelineProviderState({ labelColumnWidth: LABEL_COL_W, razorGuide: activeTool === "razor" && razorGuideX !== null ? ( - + ) : null, }); const contextValue: TimelineContextValue = { From 7a6b3a61747aac70f23c03d9c09f475896cd7374 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 08:48:59 -0400 Subject: [PATCH 5/5] style(studio): trim timeline drag comment --- .../components/timelineProviderStateBuilders.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/studio/src/player/components/timelineProviderStateBuilders.ts b/packages/studio/src/player/components/timelineProviderStateBuilders.ts index f4c03b0de0..d2c26e3a45 100644 --- a/packages/studio/src/player/components/timelineProviderStateBuilders.ts +++ b/packages/studio/src/player/components/timelineProviderStateBuilders.ts @@ -13,16 +13,8 @@ export function resolveMultiDragPreview( draggedClip: DraggedClipState | null, selectedKeys: ReadonlySet, ): MultiDragPreviewInput | null { - // The drag ghost follows the cursor freely (both axes) — CapCut-style. The - // "magnetic" affordance is a highlight on the destination lane (draggedRowIndex), - // which flips at the MAGNETIC_TRACK_THRESHOLD point; the clip drops into it. - // Live multi-selection drag: while a selected clip is dragged, ALL selected - // clips move together as one rigid formation. The GRABBED clip is the free - // ghost below; its co-selected "passengers" slide by the SAME group-clamped - // delta (cheap translateX, no re-layout) — the delta is derived from the - // grabbed clip's ALREADY-clamped previewStart, so the whole formation stops at - // the wall together and never deforms. Matches what the commit will do — see - // timelineMultiDragPreview + commit. + // The dragged clip is a free ghost; selected companions follow the same + // clamped delta so the formation stays rigid at the lane boundary. if (!draggedClip?.started) return null; const draggedKey = getTimelineElementIdentity(draggedClip.element); return {