From fa0c8a694b75629d57cb7ead3738a603db44f41d Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Sat, 11 Jul 2026 13:55:15 -0700 Subject: [PATCH 01/15] feat(studio): glue API coexistence layer for the NLE swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What: extends 21 glue files so the OLD timeline/canvas engine and the NEW NLE components type-check side by side: playerStore (multi-select setters, zoom pin, snap toggle, non-reactive scale scratch), drag-state types gain optional NLE fields, timelineLayout/timelineAssetDrop/timelineEditingHelpers/ timelineEditing/timelineElementHelpers/studioHelpers/assetHelpers gain the NLE exports, DomEditOverlay + gestures + AssetContextMenu + Timeline props gain optional callbacks/params, contexts gain *Optional hooks, and TimelineEditCallbacks.onMoveElements becomes a bivariant method accepting both engines' change shapes. patchDocumentRootDuration's test rides along. Why: this is the keystone that dissolves the old "welded glue" problem — every symbol the NLE components need is ADDED next to what the old engine still uses, so the engine components and the swaps can land as separate reviewable PRs. How: 15 authored intermediate files (main content + additive symbols; no behavior changes — new fields optional, new callbacks unused until wired) plus 6 files whose final content is already purely additive. New exports without consumers yet carry TEMP(studio-dnd) ignoreExports entries, removed by the app-shell swap. Test plan: tsc --noEmit in studio + studio-server (verifies BOTH engines compile); bunx vitest run (full suite green incl. the 6 new patchDocumentRootDuration tests); fallow audit clean. --- .fallowrc.jsonc | 19 +- .../src/components/editor/DomEditOverlay.tsx | 15 ++ .../editor/domEditOverlayGeometry.ts | 247 ++++++++++++++++-- .../editor/domEditOverlayGestures.ts | 7 + .../editor/domEditOverlayStartGesture.ts | 7 +- .../src/components/editor/manualOffsetDrag.ts | 67 ++++- .../editor/useDomEditOverlayGestures.ts | 7 +- .../components/sidebar/AssetContextMenu.tsx | 15 ++ .../src/components/sidebar/assetHelpers.ts | 29 ++ .../studio/src/contexts/DomEditContext.tsx | 9 + .../studio/src/contexts/StudioContext.tsx | 9 + .../hooks/patchDocumentRootDuration.test.ts | 71 +++++ .../src/hooks/timelineEditingHelpers.ts | 138 ++++++++++ .../studio/src/player/components/Timeline.tsx | 2 + .../player/components/timelineCallbacks.ts | 21 +- .../components/timelineClipDragPreview.ts | 10 +- .../src/player/components/timelineEditing.ts | 69 +++++ .../src/player/components/timelineLayout.ts | 88 +++++++ .../useResolvedTimelineEditCallbacks.ts | 8 +- .../player/components/useTimelineClipDrag.ts | 31 ++- .../src/player/lib/timelineElementHelpers.ts | 59 ++++- .../studio/src/player/store/playerStore.ts | 48 ++++ .../studio/src/utils/rootDuration.test.ts | 91 ++++++- packages/studio/src/utils/rootDuration.ts | 89 ++++++- packages/studio/src/utils/studioHelpers.ts | 62 +++++ .../studio/src/utils/timelineAssetDrop.ts | 62 +++++ 26 files changed, 1192 insertions(+), 88 deletions(-) create mode 100644 packages/studio/src/hooks/patchDocumentRootDuration.test.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 7fbf76be91..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,9 +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/editor/CanvasContextMenu.tsx", ], "ignorePatterns": [ "docs/**", @@ -93,11 +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/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 @@ -441,13 +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/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 @@ -587,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/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index c0adaef18c..39a1233df8 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -27,6 +27,7 @@ import { readDomEditSelectionShapeStyles, resolveBoxChromeClass } from "./domEdi import { useDomEditCompositionRect } from "./useDomEditCompositionRect"; import { useMountEffect } from "../../hooks/useMountEffect"; import { startOffCanvasIndicatorRefresh } from "./offCanvasIndicatorRefresh"; +import type { ZOrderPatch } from "./canvasContextMenuZOrder"; // Re-exports for external consumers — preserving existing import paths. export { @@ -73,6 +74,7 @@ interface DomEditOverlayProps { onBoxSizeCommit: ( selection: DomEditSelection, next: { width: number; height: number }, + offset?: { x: number; y: number }, ) => Promise | void; onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise | void; onStyleCommit?: (property: string, value: string) => Promise | void; @@ -81,6 +83,19 @@ interface DomEditOverlayProps { recordingState?: GestureRecordingState; onToggleRecording?: () => void; onMarqueeSelect?: (selections: DomEditSelection[], additive: boolean) => void; + /** + * Delete the selected canvas element. + * Wire to handleDomEditElementDelete from useDomEditActionsContext — + * same handler the Delete/Backspace hotkey uses. + */ + onDeleteSelection?: (selection: DomEditSelection) => void; + /** + * Called with the resolved z-order patch list after an optimistic DOM update. + * The patch list is tie-aware and may include sibling elements (see + * canvasContextMenuZOrder). Wire to handleDomZIndexReorderCommit from + * useDomEditActionsContext. See CanvasContextMenu.tsx module comment. + */ + onApplyZIndex?: (selection: DomEditSelection, patches: ZOrderPatch[]) => void; } // fallow-ignore-next-line complexity diff --git a/packages/studio/src/components/editor/domEditOverlayGeometry.ts b/packages/studio/src/components/editor/domEditOverlayGeometry.ts index 31af58f644..11bcf811a0 100644 --- a/packages/studio/src/components/editor/domEditOverlayGeometry.ts +++ b/packages/studio/src/components/editor/domEditOverlayGeometry.ts @@ -9,6 +9,14 @@ export interface OverlayRect { height: number; editScaleX: number; editScaleY: number; + /** + * The element's live transform rotation in DEGREES (screen/CSS convention, CW + * positive), decomposed from its computed transform matrix. Present so the + * selection chrome can render as an oriented bounding box (OBB) that co-rotates + * with the element. Omitted (treated as 0) for group/union rects and when the + * transform is unmeasurable — those render axis-aligned exactly as before. + */ + angle?: number; } export interface GroupOverlayItem { @@ -98,31 +106,95 @@ export function toVisibleOverlayRect( return rect ? { ...rect, ...hugRectForElement(rect, element) } : null; } +/** + * The element's live transform rotation, in DEGREES (screen/CSS convention, CW + * positive), decomposed from its computed transform matrix (rotation = atan2(b, a)). + * GSAP folds rotation and scale into the same matrix; this reads rotation only. + * Returns 0 when the transform is "none" or unmeasurable — callers treat 0 as + * axis-aligned. Skew is ignored (does not affect atan2(b, a)). + */ +function readElementRotationDegrees(iframe: HTMLIFrameElement, element: HTMLElement): number { + const win = iframe.contentWindow; + if (!win) return 0; + const DOMMatrixCtor = (win as Window & typeof globalThis).DOMMatrix; + if (!DOMMatrixCtor) return 0; + const transform = win.getComputedStyle(element).transform; + if (!transform || transform === "none") return 0; + let matrix: DOMMatrix; + try { + matrix = new DOMMatrixCtor(transform); + } catch { + return 0; + } + const a = Number.isFinite(matrix.a) ? matrix.a : 1; + const b = Number.isFinite(matrix.b) ? matrix.b : 0; + const deg = (Math.atan2(b, a) * 180) / Math.PI; + return Number.isFinite(deg) ? deg : 0; +} + +/** iframe→overlay mapping basis shared by every overlay-geometry function. */ +interface OverlayRootScale { + iframeRect: DOMRect; + overlayRect: DOMRect; + rootScaleX: number; + rootScaleY: number; +} + +/** The composition root element inside the preview doc (or null when absent). */ +function findOverlayRootElement(doc: Document | null): HTMLElement | null { + return doc?.querySelector("[data-composition-id]") ?? doc?.documentElement ?? null; +} + +/** + * The root's effective width/height for scaling: prefer the composition's + * declared dimensions (data-width/data-height), which stay fixed while GSAP + * transforms mutate the measured rect; fall back to the measured rect. Null when + * unmeasurable. + */ +function resolveRootDimensions(root: HTMLElement | null): { width: number; height: number } | null { + if (!root) return null; + const rootRect = root.getBoundingClientRect(); + const width = readPositiveDimension(root.getAttribute("data-width")) ?? rootRect.width; + const height = readPositiveDimension(root.getAttribute("data-height")) ?? rootRect.height; + if (!width || !height) return null; + return { width, height }; +} + +/** + * The iframe/overlay client rects and the iframe→root scale factors. Uses the + * composition's declared dimensions (data-width/data-height) for the scale + * instead of rootRect.width/height: when GSAP applies transforms (scale, + * translate) to the root, rootRect dimensions change but the composition's + * canonical size stays fixed, and using rootRect misaligns the overlay during + * animated playback. Returns null when the geometry is unmeasurable. + */ +function computeOverlayRootScale( + overlayEl: HTMLDivElement, + iframe: HTMLIFrameElement, + doc: Document | null, +): OverlayRootScale | null { + const iframeRect = iframe.getBoundingClientRect(); + const overlayRect = overlayEl.getBoundingClientRect(); + const dims = resolveRootDimensions(findOverlayRootElement(doc)); + if (!dims) return null; + return { + iframeRect, + overlayRect, + rootScaleX: iframeRect.width / dims.width, + rootScaleY: iframeRect.height / dims.height, + }; +} + export function toOverlayRect( overlayEl: HTMLDivElement, iframe: HTMLIFrameElement, element: HTMLElement, ): OverlayRect | null { - const iframeRect = iframe.getBoundingClientRect(); - const overlayRect = overlayEl.getBoundingClientRect(); - const doc = iframe.contentDocument; - const root = - doc?.querySelector("[data-composition-id]") ?? doc?.documentElement ?? null; - const rootRect = root?.getBoundingClientRect(); - // Use the composition's declared dimensions (data-width/data-height) for scale - // calculation instead of rootRect.width/height. When GSAP applies transforms - // (scale, translate) to the root element, rootRect dimensions change but the - // composition's canonical size stays the same. Using rootRect causes overlay - // misalignment during animated playback. - const declaredWidth = readPositiveDimension(root?.getAttribute("data-width") ?? null); - const declaredHeight = readPositiveDimension(root?.getAttribute("data-height") ?? null); - const rootWidth = declaredWidth ?? rootRect?.width; - const rootHeight = declaredHeight ?? rootRect?.height; - if (!rootWidth || !rootHeight || !rootRect) return null; + const scale = computeOverlayRootScale(overlayEl, iframe, iframe.contentDocument); + if (!scale) return null; + const { iframeRect, overlayRect, rootScaleX, rootScaleY } = scale; const elementRect = element.getBoundingClientRect(); - const rootScaleX = iframeRect.width / rootWidth; - const rootScaleY = iframeRect.height / rootHeight; const sourceBoundary = findSourceBoundary(element); const sourceBoundaryRect = sourceBoundary?.getBoundingClientRect(); const editScale = resolveDomEditCoordinateScale({ @@ -144,7 +216,143 @@ export function toOverlayRect( }; } +/** Which physical corner of the (possibly rotated) element a resize handle keeps + * fixed: NW grabs the top-left, so the bottom-right (se) is the anchor, etc. */ +export type FixedCorner = "nw" | "ne" | "sw" | "se"; + +/** + * The centroid (rendered center) of the four transformed corners from + * `elementCornerOverlayPoints`, in overlay px. This is the element's true rotation + * center — the point a center-anchored resize keeps planted. + */ +export function overlayCornersCentroid(corners: Record): { + x: number; + y: number; +} { + return { + x: (corners.nw.x + corners.ne.x + corners.se.x + corners.sw.x) / 4, + y: (corners.nw.y + corners.ne.y + corners.se.y + corners.sw.y) / 4, + }; +} + +/** + * The element's border-box corners in OVERLAY coordinates, honoring its live + * transform (rotation/skew/scale) — NOT the axis-aligned getBoundingClientRect. + * A rotated element's four visual corners are the transformed local box corners. + * Uses the same iframe→overlay root scale as toOverlayRect so the returned + * points share that function's coordinate space. Returns null when the + * geometry is unmeasurable. + */ +export function elementCornerOverlayPoints( + overlayEl: HTMLDivElement, + iframe: HTMLIFrameElement, + element: HTMLElement, +): Record | null { + const win = iframe.contentWindow; + const doc = iframe.contentDocument; + if (!win || !doc) return null; + const DOMMatrixCtor = (win as Window & typeof globalThis).DOMMatrix; + const DOMPointCtor = (win as Window & typeof globalThis).DOMPoint; + if (!DOMMatrixCtor || !DOMPointCtor) return null; + + const scale = computeOverlayRootScale(overlayEl, iframe, doc); + if (!scale) return null; + const { iframeRect, overlayRect, rootScaleX, rootScaleY } = scale; + + // The element's local border box maps to viewport coords by the SAME transform + // matrix the browser used for its BCR. We recover the transform's screen-space + // action from the BCR: transformPoint(localCorner - origin) gives a corner + // RELATIVE to the transformed origin. We anchor those relative corners to the + // BCR by matching the AABB of the transformed corners to the real BCR — the + // constant offset cancels in the before/after difference the caller takes, but + // we resolve it fully here so callers can also read absolute overlay positions. + const cs = win.getComputedStyle(element); + const w = element.offsetWidth; + const h = element.offsetHeight; + const originParts = cs.transformOrigin.split(" ").map((p) => Number.parseFloat(p)); + const ox = Number.isFinite(originParts[0]!) ? originParts[0]! : w / 2; + const oy = Number.isFinite(originParts[1]!) ? originParts[1]! : h / 2; + let matrix: DOMMatrix; + try { + matrix = new DOMMatrixCtor(cs.transform === "none" ? "" : cs.transform); + } catch { + return null; + } + const rel = (lx: number, ly: number): { x: number; y: number } => { + const p = matrix.transformPoint(new DOMPointCtor(lx - ox, ly - oy)); + return { x: p.x, y: p.y }; + }; + const relCorners = { + nw: rel(0, 0), + ne: rel(w, 0), + se: rel(w, h), + sw: rel(0, h), + }; + // Recover the absolute viewport position by matching to the element's BCR: + // the relative corners' AABB min corresponds to the BCR's top-left. + const xs = [relCorners.nw.x, relCorners.ne.x, relCorners.se.x, relCorners.sw.x]; + const ys = [relCorners.nw.y, relCorners.ne.y, relCorners.se.y, relCorners.sw.y]; + const bcr = element.getBoundingClientRect(); + const dx = bcr.left - Math.min(...xs); + const dy = bcr.top - Math.min(...ys); + const toOverlay = (pt: { x: number; y: number }): { x: number; y: number } => ({ + x: iframeRect.left - overlayRect.left + (pt.x + dx) * rootScaleX, + y: iframeRect.top - overlayRect.top + (pt.y + dy) * rootScaleY, + }); + return { + nw: toOverlay(relCorners.nw), + ne: toOverlay(relCorners.ne), + se: toOverlay(relCorners.se), + sw: toOverlay(relCorners.sw), + }; +} + +/** + * The selection chrome's ORIENTED bounding box: the element's UNROTATED border box + * expressed in overlay coordinates (center-anchored left/top/width/height) plus the + * live rotation angle. Rendering that rect with `transform: rotate(angle)` about its + * center reproduces the element's real transformed corners exactly, so the border, + * corner dots, rotate handle, and crop pills all co-rotate with the object. + * + * Built from `elementCornerOverlayPoints` (the real transformed corners): the OBB + * center is the corner centroid, the unrotated width/height are the edge lengths, and + * left/top place the unrotated box so that rotating it about its center lands the + * corners back on the measured points. At angle 0 this equals `toOverlayRect` (the + * AABB and OBB coincide), so unrotated chrome is pixel-identical to today. + * + * Returns the plain AABB rect (angle 0) when the corner geometry can't be measured. + */ +export function orientedOverlayRect( + overlayEl: HTMLDivElement, + iframe: HTMLIFrameElement, + element: HTMLElement, +): OverlayRect | null { + const base = toOverlayRect(overlayEl, iframe, element); + if (!base) return null; + const corners = elementCornerOverlayPoints(overlayEl, iframe, element); + if (!corners) return base; + const angle = readElementRotationDegrees(iframe, element); + // Unrotated edge lengths (in overlay px): nw→ne is the width, nw→sw the height. + const width = Math.hypot(corners.ne.x - corners.nw.x, corners.ne.y - corners.nw.y); + const height = Math.hypot(corners.sw.x - corners.nw.x, corners.sw.y - corners.nw.y); + const centerX = (corners.nw.x + corners.se.x) / 2; + const centerY = (corners.nw.y + corners.se.y) / 2; + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + return base; + } + return { + left: centerX - width / 2, + top: centerY - height / 2, + width, + height, + editScaleX: base.editScaleX, + editScaleY: base.editScaleY, + angle, + }; +} + const OVERLAY_RECT_EPSILON_PX = 0.5; +const OVERLAY_RECT_ANGLE_EPSILON_DEG = 0.1; export function rectsEqual(a: OverlayRect | null, b: OverlayRect | null): boolean { if (a === b) return true; @@ -155,7 +363,8 @@ export function rectsEqual(a: OverlayRect | null, b: OverlayRect | null): boolea Math.abs(a.width - b.width) < OVERLAY_RECT_EPSILON_PX && Math.abs(a.height - b.height) < OVERLAY_RECT_EPSILON_PX && Math.abs(a.editScaleX - b.editScaleX) < 0.001 && - Math.abs(a.editScaleY - b.editScaleY) < 0.001 + Math.abs(a.editScaleY - b.editScaleY) < 0.001 && + Math.abs((a.angle ?? 0) - (b.angle ?? 0)) < OVERLAY_RECT_ANGLE_EPSILON_DEG ); } diff --git a/packages/studio/src/components/editor/domEditOverlayGestures.ts b/packages/studio/src/components/editor/domEditOverlayGestures.ts index 2abc793b65..8f6949a549 100644 --- a/packages/studio/src/components/editor/domEditOverlayGestures.ts +++ b/packages/studio/src/components/editor/domEditOverlayGestures.ts @@ -228,3 +228,10 @@ export type UseDomEditOverlayGesturesOptions = { onCanvasMouseDown: (e: React.MouseEvent, o?: PreviewMouseDownOptions) => void; snapGuidesRef: RefObject; }; + +/** + * Corner identity for a chrome resize handle. Consumed by the selection + * chrome + resize-local math; the gesture pipeline threads it through + * startGesture options. + */ +export type ResizeHandle = "nw" | "ne" | "sw" | "se"; diff --git a/packages/studio/src/components/editor/domEditOverlayStartGesture.ts b/packages/studio/src/components/editor/domEditOverlayStartGesture.ts index 636265e206..826c0a26dd 100644 --- a/packages/studio/src/components/editor/domEditOverlayStartGesture.ts +++ b/packages/studio/src/components/editor/domEditOverlayStartGesture.ts @@ -26,6 +26,7 @@ import { import { type GestureKind, type GestureState, + type ResizeHandle, type UseDomEditOverlayGesturesOptions, } from "./domEditOverlayGestures"; import { collectSnapContext, buildExcludeElements } from "./snapTargetCollection"; @@ -100,7 +101,11 @@ export function startGesture( kind: GestureKind, e: React.PointerEvent, opts: UseDomEditOverlayGesturesOptions, - options?: { selection?: DomEditSelection; rect?: OverlayRect | null }, + options?: { + selection?: DomEditSelection; + rect?: OverlayRect | null; + resizeHandle?: ResizeHandle; + }, ): boolean { const sel = options?.selection ?? opts.selectionRef.current; const rect = options?.rect ?? opts.overlayRectRef.current; diff --git a/packages/studio/src/components/editor/manualOffsetDrag.ts b/packages/studio/src/components/editor/manualOffsetDrag.ts index 6151a09790..a808c792fb 100644 --- a/packages/studio/src/components/editor/manualOffsetDrag.ts +++ b/packages/studio/src/components/editor/manualOffsetDrag.ts @@ -441,22 +441,26 @@ export function applyManualOffsetDragDraft( return offset; } -export function applyManualOffsetDragCommit( - member: ManualOffsetDragMember, - dx: number, - dy: number, -): { x: number; y: number } { - // Re-stamp the STABLE gesture-start base/offset before the source commit reads - // them. A mid-drag re-render can wipe these attrs; the commit converts the drop - // offset → gsap x/y via computeDraggedGsapPosition, which without the base falls - // back to the live (already-dragged) transform and re-adds the delta — so the - // element flies off-screen the instant you drop it. The member holds the true - // gesture-start values in JS, immune to the re-render. +/** + * Re-stamp the STABLE gesture-start base/offset before the source commit reads + * them. A mid-gesture re-render can wipe these attrs; the commit converts the + * drop offset → gsap x/y via computeDraggedGsapPosition, which without the base + * falls back to the live (already-dragged) transform and re-adds the delta — so + * the element flies off-screen the instant you drop it. The member holds the + * true gesture-start values in JS, immune to the re-render. + */ +function restampManualOffsetDragGestureBase(member: ManualOffsetDragMember): void { member.element.setAttribute("data-hf-drag-gsap-base-x", String(member.baseGsap.x)); member.element.setAttribute("data-hf-drag-gsap-base-y", String(member.baseGsap.y)); member.element.setAttribute("data-hf-drag-initial-offset-x", String(member.initialOffset.x)); member.element.setAttribute("data-hf-drag-initial-offset-y", String(member.initialOffset.y)); - const offset = resolveManualOffsetDragMemberOffset(member, dx, dy); +} + +function applyManualOffsetCommitValue( + member: ManualOffsetDragMember, + offset: { x: number; y: number }, +): { x: number; y: number } { + restampManualOffsetDragGestureBase(member); // Optimistic visual through the GSAP channel (same as the live draft and the // committed `tl.set`), so the element holds its dropped position until the // source mutation soft-reloads — no transient CSS `--hf-studio-offset` write. @@ -467,6 +471,45 @@ export function applyManualOffsetDragCommit( return offset; } +export function applyManualOffsetDragCommit( + member: ManualOffsetDragMember, + dx: number, + dy: number, +): { x: number; y: number } { + return applyManualOffsetCommitValue(member, resolveManualOffsetDragMemberOffset(member, dx, dy)); +} + +/** + * Arrow-key nudge, in OFFSET units (composition px), not screen px — "nudge + * 1px" means one composition pixel regardless of canvas zoom, so the delta + * adds to the gesture-start offset directly instead of going through the + * screen→offset matrix. Draft/commit land in the same GSAP channel (with the + * same CSS fallback) as the drag equivalents above. + */ +export function applyManualOffsetNudgeDraft( + member: ManualOffsetDragMember, + delta: { x: number; y: number }, +): { x: number; y: number } { + const offset = { + x: member.initialOffset.x + delta.x, + y: member.initialOffset.y + delta.y, + }; + if (!applyOffsetDragDraftViaGsap(member.element, offset, member.baseGsap)) { + applyStudioPathOffsetDraft(member.element, offset); + } + return offset; +} + +export function applyManualOffsetNudgeCommit( + member: ManualOffsetDragMember, + delta: { x: number; y: number }, +): { x: number; y: number } { + return applyManualOffsetCommitValue(member, { + x: member.initialOffset.x + delta.x, + y: member.initialOffset.y + delta.y, + }); +} + function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void { restoreStudioPathOffset(member.element, member.initialPathOffset); endStudioManualEditGesture(member.element, member.gestureToken); diff --git a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts index 83da0418e6..3bd0909ab5 100644 --- a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts +++ b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts @@ -42,6 +42,7 @@ import { hasDomEditRotationChanged, resolveDomEditResizeGesture, resolveDomEditRotationGesture, + type ResizeHandle, } from "./domEditOverlayGestures"; import { startGesture as _startGesture, @@ -88,7 +89,11 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu const startGesture = ( kind: GestureKind, e: React.PointerEvent, - options?: { selection?: DomEditSelection; rect?: OverlayRect | null }, + options?: { + selection?: DomEditSelection; + rect?: OverlayRect | null; + resizeHandle?: ResizeHandle; + }, ) => _startGesture(kind, e, opts, options); // fallow-ignore-next-line complexity diff --git a/packages/studio/src/components/sidebar/AssetContextMenu.tsx b/packages/studio/src/components/sidebar/AssetContextMenu.tsx index 07b5417a80..b23a74fbd0 100644 --- a/packages/studio/src/components/sidebar/AssetContextMenu.tsx +++ b/packages/studio/src/components/sidebar/AssetContextMenu.tsx @@ -6,6 +6,7 @@ export function ContextMenu({ onCopy, onDelete, onRename, + onAddAtPlayhead, }: { x: number; y: number; @@ -14,6 +15,7 @@ export function ContextMenu({ onCopy: (path: string) => void; onDelete?: (path: string) => void; onRename?: (oldPath: string, newPath: string) => void; + onAddAtPlayhead?: (path: string) => void; }) { return (
+ {onAddAtPlayhead && ( + + )} +
+
+ {/* Faint beat lines in every track's background (behind the clips); + the active move-snap target is highlighted. */} + + {/* Beat dots on the active track (the one holding the selection), + falling back to the music track when nothing is selected. */} + {beatStripOnTrack && ( + + )} + {isPendingTrack && ( +
+ New track +
+ )} + { + // fallow-ignore-next-line complexity + els.map((el) => { + const clipStyle = getTrackStyle(el.tag); + const elementKey = el.key ?? el.id; + const capabilities = getTimelineEditCapabilities(el); + const isSelected = + selectedElementId === elementKey || selectedElementIds.has(elementKey); + const isComposition = !!el.compositionSrc; + // elementKey (el.key ?? el.id) is already unique per clip; do NOT + // fold in the map index, or a splice/reorder remounts every clip + // at/after the change (DOM flash, drag interruption). + const clipKey = elementKey; + const isDraggingClip = + draggedClip?.started === true && + (draggedElement?.key ?? draggedElement?.id) === elementKey; + if (isDraggingClip) return null; + const previewElement = getPreviewElement(el); + // Passenger of a live multi-drag: slide by the SAME formation + // delta (the grabbed clip's group-clamped delta) via a + // compositor transform on a same-geometry wrapper (absolute + // inset-0 → identical offset parent, so the clip's own + // left/top are preserved), plus the ghost's elevated z/opacity. + const isPassenger = + multiDragPreview != null && isMultiDragPassenger(clipKey, multiDragPreview); + const passengerOffsetPx = isPassenger + ? multiDragPassengerOffsetPx(clipKey, pps, multiDragPreview) + : 0; + const clip = ( + { + e.preventDefault(); + onContextMenuClip?.(e, el); + }} + el={previewElement} + pps={pps} + clipY={CLIP_Y} + isSelected={isSelected} + isHovered={hoveredClip === clipKey} + isDragging={false} + hasCustomContent={!!renderClipContent} + capabilities={capabilities} + theme={theme} + isComposition={isComposition} + onHoverStart={() => setHoveredClip(clipKey)} + onHoverEnd={() => setHoveredClip(null)} + onResizeStart={ + // fallow-ignore-next-line complexity + (edge, e) => { + if (e.button !== 0 || e.shiftKey || !onResizeElement) return; + if (edge === "start" && !capabilities.canTrimStart) return; + if (edge === "end" && !capabilities.canTrimEnd) return; + e.stopPropagation(); + blockedClipRef.current = null; + setShowPopover(false); + setRangeSelection(null); + setResizingClip({ + element: el, + edge, + originClientX: e.clientX, + originScrollLeft: scrollRef.current?.scrollLeft ?? 0, + previewStart: el.start, + previewDuration: el.duration, + previewPlaybackStart: el.playbackStart, + started: false, + }); + } + } + onPointerDown={ + // fallow-ignore-next-line complexity + (e) => { + if (e.button !== 0) return; + if (usePlayerStore.getState().activeTool === "razor") return; + if (e.shiftKey) { + shiftClickClipRef.current = { + element: el, + anchorX: e.clientX, + anchorY: e.clientY, + }; + return; + } + const target = e.currentTarget as HTMLElement; + const rect = target.getBoundingClientRect(); + const blockedIntent = resolveBlockedTimelineEditIntent({ + width: rect.width, + offsetX: e.clientX - rect.left, + handleWidth: CLIP_HANDLE_W, + capabilities, + }); + if ( + blockedIntent && + ((blockedIntent === "move" && onMoveElement) || + (blockedIntent !== "move" && onResizeElement)) + ) { + blockedClipRef.current = { + element: el, + intent: blockedIntent, + originClientX: e.clientX, + originClientY: e.clientY, + started: false, + }; + return; + } + if (!onMoveElement || !capabilities.canMove) return; + blockedClipRef.current = null; + setShowPopover(false); + setRangeSelection(null); + setDraggedClip({ + element: el, + originClientX: e.clientX, + originClientY: e.clientY, + originScrollLeft: scrollRef.current?.scrollLeft ?? 0, + originScrollTop: scrollRef.current?.scrollTop ?? 0, + pointerClientX: e.clientX, + pointerClientY: e.clientY, + pointerOffsetX: e.clientX - rect.left, + pointerOffsetY: e.clientY - rect.top, + previewStart: el.start, + previewTrack: el.track, + insertRow: null, + snapTime: null, + snapType: null, + started: false, + }); + syncClipDragAutoScroll(e.clientX, e.clientY); + } + } + onClick={ + // fallow-ignore-next-line complexity + (e) => { + e.stopPropagation(); + if (suppressClickRef.current) return; + const { activeTool } = usePlayerStore.getState(); + if (activeTool === "razor" && onRazorSplit) { + const clipRect = ( + e.currentTarget as HTMLElement + ).getBoundingClientRect(); + const clickOffsetX = e.clientX - clipRect.left; + const splitTime = previewElement.start + clickOffsetX / pps; + const clampedTime = Math.max( + previewElement.start + SPLIT_BOUNDARY_EPSILON_S, + Math.min( + previewElement.start + + previewElement.duration - + SPLIT_BOUNDARY_EPSILON_S, + splitTime, + ), + ); + if (e.shiftKey && onRazorSplitAll) { + onRazorSplitAll(clampedTime); + } else { + onRazorSplit(el, clampedTime); + } + return; + } + // Plain click single-selects: drop any marquee multi-selection. + // Only a click on the PRIMARY selection toggles it off — a click + // on a marquee-selected clip narrows the selection to that clip. + const hadMultiSelection = selectedElementIds.size > 0; + usePlayerStore.getState().clearSelectedElementIds(); + const nextElement = + selectedElementId === elementKey && !hadMultiSelection ? null : el; + setSelectedElementId(nextElement ? elementKey : null); + onSelectElement?.(nextElement); + } + } + onDoubleClick={(e) => { + e.stopPropagation(); + if (suppressClickRef.current) return; + if (isComposition && onDrillDown) onDrillDown(el); + }} + > + {renderClipChildren( + previewElement, + clipStyle, + renderClipContent, + renderClipOverlay, + )} + {STUDIO_KEYFRAMES_ENABLED && keyframeCache?.get(elementKey) && ( + 0 + ? ((currentTime - previewElement.start) / previewElement.duration) * + 100 + : 0 + } + elementId={elementKey} + selectedKeyframes={selectedKeyframes} + onClickKeyframe={(pct) => onClickKeyframe?.(previewElement, pct)} + onShiftClickKeyframe={onShiftClickKeyframe} + onContextMenuKeyframe={onContextMenuKeyframe} + onMoveKeyframe={onMoveKeyframe} + suppressClickRef={suppressClickRef} + /> + )} + + ); + if (!isPassenger) return clip; + return ( +
+ {clip} +
+ ); + }) + } +
+ + ); + }) + } + + ); +} diff --git a/packages/studio/src/player/components/timelineMarquee.test.ts b/packages/studio/src/player/components/timelineMarquee.test.ts new file mode 100644 index 0000000000..b185841b23 --- /dev/null +++ b/packages/studio/src/player/components/timelineMarquee.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect } from "vitest"; +import { + MARQUEE_DRAG_THRESHOLD_PX, + isMarqueeDrag, + isTimelineRulerPress, + getMarqueeRect, + getTimelineClipRect, + computeMarqueeSelection, +} from "./timelineMarquee"; +import { GUTTER, TRACK_H, RULER_H, CLIP_Y, getTimelineRowTop } from "./timelineLayout"; + +describe("isTimelineRulerPress", () => { + const rectTop = 500; // scroll container's viewport top + + it("treats a press inside the ruler band as a ruler press (unscrolled)", () => { + expect(isTimelineRulerPress(rectTop, rectTop)).toBe(true); + expect(isTimelineRulerPress(rectTop + RULER_H - 1, rectTop)).toBe(true); + }); + + it("treats a press below the ruler band as a body press", () => { + expect(isTimelineRulerPress(rectTop + RULER_H, rectTop)).toBe(false); + expect(isTimelineRulerPress(rectTop + RULER_H + 100, rectTop)).toBe(false); + }); + + it("stays a ruler press when the body is scrolled down (sticky ruler)", () => { + // The ruler is position:sticky — scrolled down, its VISUAL band is still + // the top RULER_H px of the container. Content-space math + // (clientY - rectTop + scrollTop) would report y = 10 + 300 = 310 ≥ RULER_H + // and misclassify this as a body/marquee press; viewport-space math must + // still classify it as a ruler press regardless of scrollTop. + const scrollTop = 300; + const clientY = rectTop + 10; // visually on the stuck ruler + const contentSpaceY = clientY - rectTop + scrollTop; + expect(contentSpaceY).toBeGreaterThanOrEqual(RULER_H); // the old, broken signal + expect(isTimelineRulerPress(clientY, rectTop)).toBe(true); + }); + + it("honors a custom ruler height", () => { + expect(isTimelineRulerPress(rectTop + 30, rectTop, 32)).toBe(true); + expect(isTimelineRulerPress(rectTop + 33, rectTop, 32)).toBe(false); + }); +}); + +describe("isMarqueeDrag", () => { + it("treats sub-threshold movement as a click, not a drag", () => { + expect(isMarqueeDrag(100, 50, 100, 50)).toBe(false); + expect( + isMarqueeDrag( + 100, + 50, + 100 + MARQUEE_DRAG_THRESHOLD_PX - 1, + 50 + MARQUEE_DRAG_THRESHOLD_PX - 1, + ), + ).toBe(false); + }); + + it("becomes a drag once either axis passes the threshold", () => { + expect(isMarqueeDrag(100, 50, 100 + MARQUEE_DRAG_THRESHOLD_PX, 50)).toBe(true); + expect(isMarqueeDrag(100, 50, 100, 50 + MARQUEE_DRAG_THRESHOLD_PX)).toBe(true); + expect(isMarqueeDrag(100, 50, 100 - MARQUEE_DRAG_THRESHOLD_PX, 50)).toBe(true); + }); +}); + +describe("getMarqueeRect", () => { + it("builds a rect from origin to current for a down-right drag", () => { + expect(getMarqueeRect(10, 20, 110, 70)).toEqual({ left: 10, top: 20, width: 100, height: 50 }); + }); + + it("normalizes negative drags (up-left) into a positive rect", () => { + expect(getMarqueeRect(110, 70, 10, 20)).toEqual({ left: 10, top: 20, width: 100, height: 50 }); + }); + + it("normalizes mixed-direction drags (down-left / up-right)", () => { + expect(getMarqueeRect(110, 20, 10, 70)).toEqual({ left: 10, top: 20, width: 100, height: 50 }); + expect(getMarqueeRect(10, 70, 110, 20)).toEqual({ left: 10, top: 20, width: 100, height: 50 }); + }); + + it("yields a zero-size rect when the pointer has not moved", () => { + expect(getMarqueeRect(42, 42, 42, 42)).toEqual({ left: 42, top: 42, width: 0, height: 0 }); + }); +}); + +describe("getTimelineClipRect", () => { + const trackOrder = [0, 2, 5]; + + it("maps start/duration to x via pps and the track row to y via the shared row→y helper", () => { + const rect = getTimelineClipRect({ start: 2, duration: 3, track: 2 }, trackOrder, 100); + expect(rect).toEqual({ + left: GUTTER + 200, + top: getTimelineRowTop(1) + CLIP_Y, + width: 300, + height: TRACK_H - CLIP_Y * 2, + }); + }); + + it("places the first visible track below the ruler + top breathing pad", () => { + const rect = getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, 50); + expect(rect?.top).toBe(getTimelineRowTop(0) + CLIP_Y); + expect(rect?.left).toBe(GUTTER); + }); + + it("uses the row index in trackOrder, not the raw track number", () => { + const rect = getTimelineClipRect({ start: 0, duration: 1, track: 5 }, trackOrder, 50); + expect(rect?.top).toBe(getTimelineRowTop(2) + CLIP_Y); + }); + + it("enforces the 4px minimum rendered width", () => { + const rect = getTimelineClipRect({ start: 0, duration: 0.01, track: 0 }, trackOrder, 10); + expect(rect?.width).toBe(4); + }); + + it("returns null for a track that is not displayed or an invalid pps", () => { + expect(getTimelineClipRect({ start: 0, duration: 1, track: 9 }, trackOrder, 100)).toBeNull(); + expect(getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, 0)).toBeNull(); + expect(getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, NaN)).toBeNull(); + }); +}); + +describe("computeMarqueeSelection", () => { + // Two visible tracks: row 0 = track 0, row 1 = track 1. pps 100. + const trackOrder = [0, 1]; + const pps = 100; + const clips = [ + { id: "a", start: 0, duration: 1, track: 0 }, // x [32,132], row 0 + { id: "b", start: 2, duration: 1, track: 0 }, // x [232,332], row 0 + { id: "c", start: 0.5, duration: 1, track: 1 }, // x [82,182], row 1 + ]; + const row0Top = getTimelineRowTop(0) + CLIP_Y; + const row1Top = getTimelineRowTop(1) + CLIP_Y; + + it("selects only the clips the marquee rect intersects", () => { + const marquee = { left: GUTTER, top: row0Top, width: 50, height: 10 }; + const { ids, primaryId } = computeMarqueeSelection({ clips, trackOrder, pps, marquee }); + expect(ids).toEqual(new Set(["a"])); + expect(primaryId).toBe("a"); + }); + + it("selects across tracks when the rect spans multiple rows", () => { + const marquee = { left: GUTTER, top: row0Top, width: 60, height: row1Top - row0Top + 5 }; + const { ids } = computeMarqueeSelection({ clips, trackOrder, pps, marquee }); + expect(ids).toEqual(new Set(["a", "c"])); + }); + + it("excludes clips outside the rect horizontally", () => { + const marquee = { left: GUTTER + 140, top: row0Top, width: 50, height: 10 }; + const { ids } = computeMarqueeSelection({ clips, trackOrder, pps, marquee }); + expect(ids).toEqual(new Set()); + }); + + it("returns null primaryId and keeps the base when nothing is hit (additive)", () => { + const marquee = { left: GUTTER + 140, top: row0Top, width: 50, height: 10 }; + const { ids, primaryId } = computeMarqueeSelection({ + clips, + trackOrder, + pps, + marquee, + baseSelection: ["b"], + }); + expect(ids).toEqual(new Set(["b"])); + expect(primaryId).toBeNull(); + }); + + it("unions additive base selection with new hits; primary comes from the marquee", () => { + const marquee = { left: GUTTER, top: row1Top, width: 100, height: 10 }; + const { ids, primaryId } = computeMarqueeSelection({ + clips, + trackOrder, + pps, + marquee, + baseSelection: ["b"], + }); + expect(ids).toEqual(new Set(["b", "c"])); + expect(primaryId).toBe("c"); + }); + + it("shrinking the rect live drops clips it no longer covers", () => { + const wide = { left: GUTTER, top: row0Top, width: 320, height: 10 }; + const narrow = { left: GUTTER, top: row0Top, width: 80, height: 10 }; + expect(computeMarqueeSelection({ clips, trackOrder, pps, marquee: wide }).ids).toEqual( + new Set(["a", "b"]), + ); + expect(computeMarqueeSelection({ clips, trackOrder, pps, marquee: narrow }).ids).toEqual( + new Set(["a"]), + ); + }); + + it("ignores clips on hidden/undisplayed tracks", () => { + const marquee = { left: 0, top: 0, width: 10000, height: 10000 }; + const { ids } = computeMarqueeSelection({ + clips: [{ id: "x", start: 0, duration: 1, track: 7 }], + trackOrder, + pps, + marquee, + }); + expect(ids).toEqual(new Set()); + }); +}); diff --git a/packages/studio/src/player/components/timelineMarquee.ts b/packages/studio/src/player/components/timelineMarquee.ts new file mode 100644 index 0000000000..718dd86594 --- /dev/null +++ b/packages/studio/src/player/components/timelineMarquee.ts @@ -0,0 +1,112 @@ +import { GUTTER, TRACK_H, RULER_H, CLIP_Y, getTimelineRowTop } from "./timelineLayout"; +import { rectsOverlap, type Rect } from "../../utils/marqueeGeometry"; + +/** Pointer must travel at least this far (either axis) before a pointerdown on + * the empty timeline body becomes a marquee drag instead of a plain click. */ +export const MARQUEE_DRAG_THRESHOLD_PX = 4; + +/** Minimum rendered clip width, mirrors TimelineClip's `Math.max(w, 4)`. */ +const MIN_CLIP_W = 4; + +export interface MarqueeClipInput { + id: string; + start: number; + duration: number; + track: number; +} + +/** + * Ruler-vs-body decision for a pointerdown on the timeline scroll container. + * + * The ruler is `position: sticky; top: 0` — once the body is scrolled down its + * VISUAL position stays pinned to the container top while its LAYOUT position + * scrolls away. The hit test must therefore use VIEWPORT-space y (clientY + * relative to the scroll container's bounding rect), NOT content-space y + * (clientY - rect.top + scrollTop), which misclassifies a press on the stuck + * ruler as a body/marquee press whenever scrollTop > 0. + */ +export function isTimelineRulerPress( + clientY: number, + scrollRectTop: number, + rulerHeight: number = RULER_H, +): boolean { + return clientY - scrollRectTop < rulerHeight; +} + +export function isMarqueeDrag( + originX: number, + originY: number, + currentX: number, + currentY: number, + threshold: number = MARQUEE_DRAG_THRESHOLD_PX, +): boolean { + return Math.abs(currentX - originX) >= threshold || Math.abs(currentY - originY) >= threshold; +} + +/** Normalized marquee rect (canvas/content coordinates) from the drag origin and + * the current pointer — handles drags in any direction (negative deltas). */ +export function getMarqueeRect( + originX: number, + originY: number, + currentX: number, + currentY: number, +): Rect { + return { + left: Math.min(originX, currentX), + top: Math.min(originY, currentY), + width: Math.abs(currentX - originX), + height: Math.abs(currentY - originY), + }; +} + +/** + * A clip's rendered rect in canvas/content coordinates (the same space the + * marquee rect lives in): x from GUTTER + start * pps, y from the clip's row + * index within the visible track order (RULER_H + row * TRACK_H + CLIP_Y). + * Returns null when the clip's track is not currently displayed. + */ +export function getTimelineClipRect( + clip: Pick, + trackOrder: number[], + pps: number, +): Rect | null { + const row = trackOrder.indexOf(clip.track); + if (row < 0 || !Number.isFinite(pps) || pps <= 0) return null; + return { + left: GUTTER + clip.start * pps, + top: getTimelineRowTop(row) + CLIP_Y, + width: Math.max(clip.duration * pps, MIN_CLIP_W), + height: TRACK_H - CLIP_Y * 2, + }; +} + +export interface MarqueeSelectionResult { + /** Every clip id the marquee currently covers (plus the additive base). */ + ids: Set; + /** The last marquee-hit clip in element order — the primary selection. + * Null when the marquee covers nothing new (caller keeps its current primary). */ + primaryId: string | null; +} + +/** + * Live marquee selection: every clip whose rendered rect intersects the marquee. + * `baseSelection` (shift/cmd-additive) is unioned in but never affects primaryId. + */ +export function computeMarqueeSelection(input: { + clips: MarqueeClipInput[]; + trackOrder: number[]; + pps: number; + marquee: Rect; + baseSelection?: Iterable; +}): MarqueeSelectionResult { + const ids = new Set(input.baseSelection ?? []); + let primaryId: string | null = null; + for (const clip of input.clips) { + const rect = getTimelineClipRect(clip, input.trackOrder, input.pps); + if (rect && rectsOverlap(rect, input.marquee)) { + ids.add(clip.id); + primaryId = clip.id; + } + } + return { ids, primaryId }; +} diff --git a/packages/studio/src/player/components/useTimelineEditPinning.ts b/packages/studio/src/player/components/useTimelineEditPinning.ts new file mode 100644 index 0000000000..7c2cb6df19 --- /dev/null +++ b/packages/studio/src/player/components/useTimelineEditPinning.ts @@ -0,0 +1,109 @@ +import { useCallback, useMemo, useRef, type RefObject } from "react"; +import { usePlayerStore } from "../store/playerStore"; +import type { TimelineDropCallbacks, TimelineEditCallbacks } from "./timelineCallbacks"; + +interface UseTimelineEditPinningInput { + ppsRef: RefObject; + fitPpsRef: RefObject; + onMoveElement: TimelineEditCallbacks["onMoveElement"]; + onMoveElements: TimelineEditCallbacks["onMoveElements"]; + onResizeElement: TimelineEditCallbacks["onResizeElement"]; + onFileDrop: TimelineDropCallbacks["onFileDrop"]; + onAssetDrop: TimelineDropCallbacks["onAssetDrop"]; + onBlockDrop: TimelineDropCallbacks["onBlockDrop"]; +} + +// Wrap every mutating timeline edit so the zoom pins to the current on-screen +// scale right before the edit commits — the reload the edit triggers then keeps +// the current scale instead of rescaling every clip (the blink-fix symptom). +// Each wrapper forwards its args unchanged and preserves the original's absence +// (an unset callback stays unset → the timeline's own fallbacks kick in). +export function useTimelineEditPinning({ + ppsRef, + fitPpsRef, + onMoveElement, + onMoveElements, + onResizeElement, + onFileDrop, + onAssetDrop, + onBlockDrop, +}: UseTimelineEditPinningInput) { + const pinTimelineZoom = usePlayerStore((s) => s.pinTimelineZoom); + // Pin the timeline zoom to the current on-screen scale on the FIRST edit, so a + // duration change from that edit (drops/moves/deletes recompute the fit basis) + // stops rescaling every clip. `pinTimelineZoom` is a no-op once already pinned + // (or after a manual zoom), so the user's own zoom is never clobbered; Fit + // re-fits. Reads refs at call time for the latest scale. + const pinZoomBeforeEdit = useCallback(() => { + pinTimelineZoom(ppsRef.current, fitPpsRef.current); + }, [pinTimelineZoom, ppsRef, fitPpsRef]); + + // Stable ref so useTimelineClipDrag can clear rangeSelection without circular dep + const setRangeSelectionRef = useRef<((sel: null) => void) | null>(null); + + const pinnedOnMoveElement = useMemo( + () => + onMoveElement && + ((...args: Parameters) => { + pinZoomBeforeEdit(); + return onMoveElement(...args); + }), + [onMoveElement, pinZoomBeforeEdit], + ); + const pinnedOnMoveElements = useMemo( + () => + onMoveElements && + ((...args: Parameters) => { + pinZoomBeforeEdit(); + return onMoveElements(...args); + }), + [onMoveElements, pinZoomBeforeEdit], + ); + const pinnedOnResizeElement = useMemo( + () => + onResizeElement && + ((...args: Parameters) => { + pinZoomBeforeEdit(); + return onResizeElement(...args); + }), + [onResizeElement, pinZoomBeforeEdit], + ); + const pinnedOnFileDrop = useMemo( + () => + onFileDrop && + ((...args: Parameters) => { + pinZoomBeforeEdit(); + return onFileDrop(...args); + }), + [onFileDrop, pinZoomBeforeEdit], + ); + const pinnedOnAssetDrop = useMemo( + () => + onAssetDrop && + ((...args: Parameters) => { + pinZoomBeforeEdit(); + return onAssetDrop(...args); + }), + [onAssetDrop, pinZoomBeforeEdit], + ); + const pinnedOnBlockDrop = useMemo( + () => + onBlockDrop && + ((...args: Parameters) => { + pinZoomBeforeEdit(); + return onBlockDrop(...args); + }), + [onBlockDrop, pinZoomBeforeEdit], + ); + + return { + pinZoomBeforeEdit, + setRangeSelectionRef, + pinnedOnMoveElement, + pinnedOnMoveElements, + pinnedOnResizeElement, + pinnedOnFileDrop, + pinnedOnAssetDrop, + pinnedOnBlockDrop, + }; +} diff --git a/packages/studio/src/player/components/useTimelineGeometry.ts b/packages/studio/src/player/components/useTimelineGeometry.ts new file mode 100644 index 0000000000..fb18810902 --- /dev/null +++ b/packages/studio/src/player/components/useTimelineGeometry.ts @@ -0,0 +1,129 @@ +import { useEffect, useMemo, useRef, type RefObject } from "react"; +import { usePlayerStore, type TimelineElement, type ZoomMode } from "../store/playerStore"; +import { getTimelinePixelsPerSecond } from "./timelineZoom"; +import { + DRAG_EXTEND_MARGIN_PX, + getTimelineDisplayContentWidth, + getTimelineFitPps, +} from "./timelineLayout"; +import type { DraggedClipState, ResizingClipState } from "./useTimelineClipDrag"; + +interface UseTimelineGeometryInput { + viewportWidth: number; + effectiveDuration: number; + zoomMode: ZoomMode; + manualZoomPercent: number; + ppsRef: RefObject; + fitPpsRef: RefObject; + draggedClip: DraggedClipState | null; + resizingClip: ResizingClipState | null; + expandedElements: TimelineElement[]; + isDragging: RefObject; + scrollRef: RefObject; + lastScrollLeftRef: RefObject; +} + +// Derive the timeline's horizontal geometry from the viewport, zoom, and any live +// drag/resize preview: the pixels-per-second scale, the rendered content width +// (CapCut-style over-extension while dragging/trimming), and the version key that +// re-triggers dependent effects after an edit re-derives the elements. +export function useTimelineGeometry({ + viewportWidth, + effectiveDuration, + zoomMode, + manualZoomPercent, + ppsRef, + fitPpsRef, + draggedClip, + resizingClip, + expandedElements, + isDragging, + scrollRef, + lastScrollLeftRef, +}: UseTimelineGeometryInput) { + // Fit pps maps at least MIN_TIMELINE_EXTENT_S onto the viewport, so short + // comps show a 60s ruler with usable empty space (see getTimelineFitPps). + const fitPps = getTimelineFitPps(viewportWidth, effectiveDuration); + const pps = getTimelinePixelsPerSecond(fitPps, zoomMode, manualZoomPercent); + ppsRef.current = pps; + const trackContentWidth = Math.max(0, effectiveDuration * pps); + // Drag-to-extend: while a clip is dragged, keep the rendered extent a margin + // past the ghost's end. Holding the pointer in the right edge zone then keeps + // auto-scroll stepping (scrollWidth grows with the ghost), so the timeline + // extends at auto-scroll pace — placing a clip farther than the timeline + // currently shows. Growth is bounded per frame by AUTO_SCROLL_MAX_SPEED (no + // fling); leaving the edge zone stops it; the extra width collapses when the + // drag ends (the composition itself only grows on commit, content-driven). + const dragGhostEndPx = draggedClip?.started + ? (draggedClip.previewStart + draggedClip.element.duration) * pps + DRAG_EXTEND_MARGIN_PX + : 0; + // Trim-to-extend: same mechanic for a right-edge RESIZE — the rendered extent + // tracks the trim preview's end so the edge auto-scroll zone always has room + // to keep stepping while the trim grows past the current timeline width. + const resizeGhostEndPx = resizingClip?.started + ? (resizingClip.previewStart + resizingClip.previewDuration) * pps + DRAG_EXTEND_MARGIN_PX + : 0; + // The timeline canvas always fills at least the viewport width AND the + // MIN_TIMELINE_EXTENT_S floor: the ruler + empty track lanes keep going into + // the space instead of leaving dead black — CapCut-style. Only the RENDERED + // extent grows; clip positions/durations are untouched. + const displayContentWidth = getTimelineDisplayContentWidth({ + trackContentWidth, + viewportWidth, + pps, + dragGhostEndPx, + resizeGhostEndPx, + }); + const displayDuration = pps > 0 ? displayContentWidth / pps : effectiveDuration; + const clipStateVersion = useMemo( + () => + expandedElements + .map((el) => `${el.key ?? el.id}:${el.start}:${el.duration}:${el.track}`) + .join("|"), + [expandedElements], + ); + const zoomModeRef = useRef(zoomMode); + zoomModeRef.current = zoomMode; + const manualZoomPercentRef = useRef(manualZoomPercent); + manualZoomPercentRef.current = manualZoomPercent; + fitPpsRef.current = fitPps; + + // Restore the horizontal scroll offset after an edit re-derives the elements + // (clipStateVersion changes) so the reload doesn't jump the view. Only in manual + // (pinned) mode — fit mode hides the x-scrollbar (scrollLeft is always 0) — and + // never mid-drag (auto-scroll owns the offset then). rAF waits for the new layout + // so the clamp reads the post-resync scrollWidth. zoomMode is a legitimate dep: + // re-running on a mode flip is a no-op thanks to the guard. + useEffect(() => { + if (zoomMode !== "manual" || isDragging.current) return; + const el = scrollRef.current; + const target = lastScrollLeftRef.current; + if (!el || target <= 0) return; + const raf = requestAnimationFrame(() => { + const max = Math.max(0, el.scrollWidth - el.clientWidth); + const next = Math.min(target, max); + if (Math.abs(el.scrollLeft - next) > 0.5) el.scrollLeft = next; + }); + return () => cancelAnimationFrame(raf); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [clipStateVersion, zoomMode]); + // Publish the live scale so edit handlers OUTSIDE (the keyboard-delete + // path) can pin the zoom via pinTimelineZoomToCurrent without threading geometry. + // In a useEffect (not the render body) so React-18 concurrent replay — Suspense + // retry, transitions, StrictMode double-invoke — can't double-publish. The write is + // idempotent (same pps/fitPps → same fields), so this is behavior-preserving; the + // effect placement is just the strictly-correct shape. + useEffect(() => { + usePlayerStore.getState().setTimelineScale(pps, fitPps); + }, [pps, fitPps]); + + return { + pps, + fitPps, + displayContentWidth, + displayDuration, + clipStateVersion, + zoomModeRef, + manualZoomPercentRef, + }; +} diff --git a/packages/studio/src/player/components/useTimelineStackingSync.ts b/packages/studio/src/player/components/useTimelineStackingSync.ts new file mode 100644 index 0000000000..7f2ec35dad --- /dev/null +++ b/packages/studio/src/player/components/useTimelineStackingSync.ts @@ -0,0 +1,83 @@ +import { useCallback, type RefObject } from "react"; +import type { TimelineElement } from "../store/playerStore"; +import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext"; +import { useStudioShellContextOptional } from "../../contexts/StudioContext"; +import { findElementForSelection } from "../../components/editor/domEditingElement"; +import { readEffectiveZIndex } from "../../components/editor/canvasContextMenuZOrder"; +import type { StackingPatch } from "./timelineStackingSync"; + +interface UseTimelineStackingSyncInput { + expandedElementsRef: RefObject; +} + +// Lane ↔ stacking unification (research/STAGE3-NEEDED-WIRING.md). Provision the +// two deps commitDraggedClipMove accepts so a lane-change drag also patches the +// edited clip's z-index. Both read the SAME preview iframe + z-order persist path +// the canvas right-click menu / LayersPanel use, so a timeline lane move and a +// menu z-edit produce one shared inline-style commit shape. Optional contexts: +// outside the NLE (standalone ) these are null ⇒ deps undefined ⇒ the +// commit's z-sync is a no-op (backward compatible). +export function useTimelineStackingSync({ expandedElementsRef }: UseTimelineStackingSyncInput) { + const domEditActions = useDomEditActionsContextOptional(); + const shell = useStudioShellContextOptional(); + const zSyncPreviewIframeRef = domEditActions?.previewIframeRef ?? null; + const handleDomZIndexReorderCommit = domEditActions?.handleDomZIndexReorderCommit; + const zSyncActiveCompPath = shell?.activeCompPath ?? null; + + // Resolve a TimelineElement to its live iframe HTMLElement via the same + // hfId ?? id ?? selector[selectorIndex] resolver the timeline's DOM patches use. + const resolveIframeElement = useCallback( + (el: TimelineElement): HTMLElement | null => { + const doc = zSyncPreviewIframeRef?.current?.contentDocument ?? null; + if (!doc) return null; + return findElementForSelection( + doc, + { + hfId: el.hfId, + id: el.domId ?? el.id, + selector: el.selector, + selectorIndex: el.selectorIndex, + sourceFile: el.sourceFile, + }, + zSyncActiveCompPath, + ); + }, + [zSyncPreviewIframeRef, zSyncActiveCompPath], + ); + + const readClipZIndex = useCallback( + (el: TimelineElement): number => { + const node = resolveIframeElement(el); + return node ? readEffectiveZIndex(node) : 0; + }, + [resolveIframeElement], + ); + + const applyStackingPatches = useCallback( + (patches: StackingPatch[]) => { + if (!handleDomZIndexReorderCommit) return; + const entries = patches.flatMap((p) => { + const el = expandedElementsRef.current.find((e) => (e.key ?? e.id) === p.key); + const node = el && resolveIframeElement(el); + if (!el || !node) return []; + return [ + { + element: node, + zIndex: p.zIndex, + id: el.domId ?? el.id, + selector: el.selector, + selectorIndex: el.selectorIndex, + sourceFile: el.sourceFile ?? zSyncActiveCompPath ?? "index.html", + }, + ]; + }); + if (entries.length) handleDomZIndexReorderCommit(entries); + }, + [handleDomZIndexReorderCommit, resolveIframeElement, zSyncActiveCompPath, expandedElementsRef], + ); + + // Engage the z-sync only when the persist path is present (inside the NLE). + const zSyncEnabled = Boolean(handleDomZIndexReorderCommit && zSyncPreviewIframeRef); + + return { readClipZIndex, applyStackingPatches, zSyncEnabled }; +} From a496db2a12af30ab696bc30f6d57cc73f587dde3 Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Sat, 11 Jul 2026 13:55:18 -0700 Subject: [PATCH 03/15] feat(studio): NLE shell assembly (unwired) What: EditorShell (the full editor layout replacing NLELayout + StudioPreviewArea), TimelinePane (timeline host with sub-comp rebasing) and useTimelineEditCallbacks (the callback bag bridging store edits to the timeline), all unwired. Why: the shell that App swaps to in the final step; reviewing it standalone keeps that swap PR small. How: new files against the coexistence layer; TEMP(studio-dnd) entries until App mounts EditorShell in the app-shell swap. Test plan: tsc --noEmit; bunx vitest run (suite unchanged); fallow audit clean. --- .../studio/src/components/EditorShell.tsx | 251 ++++++++++++++++++ .../src/components/nle/TimelinePane.tsx | 210 +++++++++++++++ .../nle/useTimelineEditCallbacks.ts | 210 +++++++++++++++ 3 files changed, 671 insertions(+) create mode 100644 packages/studio/src/components/EditorShell.tsx create mode 100644 packages/studio/src/components/nle/TimelinePane.tsx create mode 100644 packages/studio/src/components/nle/useTimelineEditCallbacks.ts diff --git a/packages/studio/src/components/EditorShell.tsx b/packages/studio/src/components/EditorShell.tsx new file mode 100644 index 0000000000..9e968babf4 --- /dev/null +++ b/packages/studio/src/components/EditorShell.tsx @@ -0,0 +1,251 @@ +import { useCallback, type ReactNode } from "react"; +import { PreviewPane } from "./nle/PreviewPane"; +import { TimelinePane } from "./nle/TimelinePane"; +import { PreviewOverlays } from "./nle/PreviewOverlays"; +import { + useTimelineEditCallbacks, + type TimelineEditCallbackDeps, +} from "./nle/useTimelineEditCallbacks"; +import { NLEProvider, useNLEContext } from "./nle/NLEContext"; +import { CaptionTimeline } from "../captions/components/CaptionTimeline"; +import { StudioFeedbackBar } from "./StudioFeedbackBar"; +import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext"; +import { useDomEditActionsContext } from "../contexts/DomEditContext"; +import { TimelineEditProvider } from "../contexts/TimelineEditContext"; +import type { TimelineElement } from "../player"; +import type { BlockPreviewInfo } from "./sidebar/BlocksTab"; +import type { GestureRecordingState } from "./editor/GestureRecordControl"; + +type RenderClipContent = ( + element: TimelineElement, + style: { clip: string; label: string }, +) => ReactNode; +type TimelineDropPlacement = Pick; + +// The seven move/resize/split/razor handlers come from TimelineEditCallbackDeps +// (shared with useTimelineEditCallbacks); the rest are drop + wiring props. +export interface EditorShellProps extends TimelineEditCallbackDeps { + /** Left sidebar (media/library), rendered in the top row. */ + left: ReactNode; + /** Right panel (inspector/design) or null when collapsed, in the top row. */ + right: ReactNode; + /** Hide the whole shell (e.g. while the storyboard view is active). */ + hidden?: boolean; + timelineToolbar: ReactNode; + renderClipContent: RenderClipContent; + handleTimelineElementDelete: (element: TimelineElement) => Promise | void; + handleTimelineAssetDrop: ( + assetPath: string, + placement: TimelineDropPlacement, + ) => Promise | void; + handleTimelineBlockDrop?: ( + blockName: string, + placement: TimelineDropPlacement, + ) => Promise | void; + handlePreviewBlockDrop?: ( + blockName: string, + position: { left: number; top: number }, + ) => Promise | void; + handleTimelineFileDrop: ( + files: File[], + placement?: TimelineDropPlacement, + ) => Promise | void; + setCompIdToSrc: (map: Map) => void; + setCompositionLoading: (loading: boolean) => void; + shouldShowSelectedDomBounds: boolean; + blockPreview?: BlockPreviewInfo | null; + isGestureRecording?: boolean; + recordingState?: GestureRecordingState; + onToggleRecording?: () => void; + gestureOverlay?: ReactNode; +} + +// The CapCut-style shell: [left | preview | right] in a top row, with a +// full-width timeline spanning the bottom. Owns the shared player + +// composition-stack state via NLEProvider so both rows share one player. +export function EditorShell({ + left, + right, + hidden, + timelineToolbar, + renderClipContent, + handleTimelineElementDelete, + handleTimelineAssetDrop, + handleTimelineBlockDrop, + handlePreviewBlockDrop, + handleTimelineFileDrop, + handleTimelineElementMove, + handleTimelineElementsMove, + handleTimelineElementResize, + handleToggleTrackHidden, + handleBlockedTimelineEdit, + handleTimelineElementSplit, + handleRazorSplit, + handleRazorSplitAll, + setCompIdToSrc, + setCompositionLoading, + shouldShowSelectedDomBounds, + isGestureRecording, + recordingState, + onToggleRecording, + blockPreview, + gestureOverlay, +}: EditorShellProps) { + const { projectId, activeCompPath, setActiveCompPath, handlePreviewIframeRef } = + useStudioShellContext(); + const { refreshKey, captionEditMode, refreshPreviewDocumentVersion } = useStudioPlaybackContext(); + const { handleTimelineElementSelect } = useDomEditActionsContext(); + + const timelineEditCallbacks = useTimelineEditCallbacks({ + handleTimelineElementMove, + handleTimelineElementsMove, + handleTimelineElementResize, + handleToggleTrackHidden, + handleBlockedTimelineEdit, + handleTimelineElementSplit, + handleRazorSplit, + handleRazorSplitAll, + }); + + return ( +
+ + { + // Sync activeCompPath when the user drills down via the timeline or + // navigates back — keeps sidebar + thumbnails in sync. Guard no-ops to + // avoid circular refresh cascades (activeCompPath → stack → onChange). + if (compPath !== activeCompPath) { + setActiveCompPath(compPath); + refreshPreviewDocumentVersion(); + } + }} + > + + } + /> + + + +
+ ); +} + +interface EditorShellBodyProps { + left: ReactNode; + right: ReactNode; + captionEditMode: boolean; + previewOverlay: ReactNode; + onSelectTimelineElement: (element: TimelineElement | null) => void; + onPreviewBlockDrop?: ( + blockName: string, + position: { left: number; top: number }, + ) => Promise | void; + timelineToolbar: ReactNode; + renderClipContent: RenderClipContent; + onFileDrop: (files: File[], placement?: TimelineDropPlacement) => Promise | void; + onAssetDrop: (assetPath: string, placement: TimelineDropPlacement) => Promise | void; + onBlockDrop?: (blockName: string, placement: TimelineDropPlacement) => Promise | void; + onDeleteElement: (element: TimelineElement) => Promise | void; +} + +function EditorShellBody({ + left, + right, + captionEditMode, + previewOverlay, + onSelectTimelineElement, + onPreviewBlockDrop, + timelineToolbar, + renderClipContent, + onFileDrop, + onAssetDrop, + onBlockDrop, + onDeleteElement, +}: EditorShellBodyProps) { + const { compositionStack, updateCompositionStack, containerRef } = useNLEContext(); + + // 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)); + } + }, + [compositionStack.length, updateCompositionStack], + ); + + return ( +
+ {/* Top row: [left | preview | right] — outer padding + the 8px resize + seams give the panels CapCut-style separation on the dark canvas. */} +
+ {left} +
+ +
+ {right} +
+ + {/* Full-width timeline row */} + +
+ + Captions + +
+ +
+ ) : undefined + } + /> + + ); +} diff --git a/packages/studio/src/components/nle/TimelinePane.tsx b/packages/studio/src/components/nle/TimelinePane.tsx new file mode 100644 index 0000000000..e82a64e691 --- /dev/null +++ b/packages/studio/src/components/nle/TimelinePane.tsx @@ -0,0 +1,210 @@ +import { useCallback, type ReactNode } from "react"; +import { Timeline } from "../../player"; +import type { TimelineElement } from "../../player"; +import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing"; +import { TimelineResizeDivider } from "./TimelineResizeDivider"; +import { useTimelineEditContext } from "../../contexts/TimelineEditContext"; +import { trackStudioExpandedClipEdit } from "../../telemetry/events"; +import { useNLEContext } from "./NLEContext"; + +export interface TimelinePaneProps { + /** Slot rendered above the timeline tracks (toolbar with split, delete, zoom) */ + timelineToolbar?: ReactNode; + /** Slot rendered below the timeline tracks */ + timelineFooter?: ReactNode; + /** 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; + onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; + onSelectTimelineElement?: (element: TimelineElement | null) => void; +} + +// fallow-ignore-next-line complexity +export function TimelinePane({ + timelineToolbar, + timelineFooter, + renderClipContent, + onFileDrop, + onDeleteElement, + onAssetDrop, + onBlockDrop, + onBlockedEditAttempt, + onSelectTimelineElement, +}: TimelinePaneProps) { + const { + seek, + handleDrillDown, + compositionStack, + updateCompositionStack, + timelineH, + setTimelineH, + persistTimelineH, + containerRef, + timelineDisabled, + } = useNLEContext(); + + // 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, onMoveElements, 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], + ); + + // Batched move (ripple / insert): rebase each expanded sub-comp child to its + // local coords, exactly as handleMoveElement does for a single clip. + const handleMoveElements = useCallback( + ( + edits: Array<{ element: TimelineElement; updates: Pick }>, + ) => + onMoveElements?.( + edits.map(({ element, updates }) => { + const basis = element.expandedParentStart; + if (basis === undefined) return { element, updates }; + return { + element: toLocalElement(element, basis), + updates: { ...updates, start: Math.max(0, updates.start - basis) }, + }; + }), + ), + [onMoveElements, 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], + ); + + return ( + <> + + + {/* Timeline section — inner padding (not margin) keeps the divider's + height math exact while giving the panel a gap from the shell edges. */} +
+
{ + 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… + +
+ )} +
+ + ); +} diff --git a/packages/studio/src/components/nle/useTimelineEditCallbacks.ts b/packages/studio/src/components/nle/useTimelineEditCallbacks.ts new file mode 100644 index 0000000000..169888b4cf --- /dev/null +++ b/packages/studio/src/components/nle/useTimelineEditCallbacks.ts @@ -0,0 +1,210 @@ +import { useCallback, useMemo } from "react"; +import type { TimelineElement } from "../../player"; +import { usePlayerStore } from "../../player/store/playerStore"; +import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing"; +import type { TimelineEditCallbacks } from "../../player/components/timelineCallbacks"; +import { useStudioShellContext } from "../../contexts/StudioContext"; +import { + useDomEditActionsContext, + useDomEditSelectionContext, +} from "../../contexts/DomEditContext"; +import { resolveTweenStart, resolveTweenDuration } from "../../utils/globalTimeCompiler"; +import { resolveClipTimingBasis } from "../../hooks/useGsapTweenCache"; +import { resolveKeyframeRetime } from "../editor/keyframeRetime"; + +export interface TimelineEditCallbackDeps { + handleTimelineElementMove: ( + element: TimelineElement, + updates: Pick, + ) => Promise | void; + handleTimelineElementsMove: ( + edits: Array<{ element: TimelineElement; updates: Pick }>, + ) => Promise | void; + handleTimelineElementResize: ( + element: TimelineElement, + updates: Pick, + ) => Promise | void; + handleToggleTrackHidden: (track: number, hidden: boolean) => Promise | void; + handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; + handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise | void; + handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise | void; + handleRazorSplitAll: (splitTime: number) => Promise | void; +} + +/** + * Builds the timeline edit callback bag (move/resize/split/razor plus the + * keyframe-diamond callbacks) provided to `` via TimelineEditProvider. + * The keyframe callbacks resolve the dragged diamond back to its GSAP anim id + + * tween-relative percentage, reading DOM-edit selection state from context. + */ +// fallow-ignore-next-line complexity +export function useTimelineEditCallbacks({ + handleTimelineElementMove, + handleTimelineElementsMove, + handleTimelineElementResize, + handleToggleTrackHidden, + handleBlockedTimelineEdit, + handleTimelineElementSplit, + handleRazorSplit, + handleRazorSplitAll, +}: TimelineEditCallbackDeps): TimelineEditCallbacks { + const { projectId, activeCompPath } = useStudioShellContext(); + const { domEditSelection, selectedGsapAnimations } = useDomEditSelectionContext(); + const { + handleGsapRemoveKeyframe, + handleGsapMoveKeyframeToPlayhead, + handleGsapMoveKeyframe, + handleGsapResizeKeyframedTween, + handleGsapUpdateMeta, + handleGsapAddKeyframe, + handleGsapConvertToKeyframes, + handleGsapRemoveAllKeyframes, + buildDomSelectionForTimelineElement, + } = useDomEditActionsContext(); + + // Resolve a timeline-diamond callback's clip-% to the keyframe's anim id + its + // tween-relative percentage (shared by the delete/move keyframe callbacks): the + // diamond reports a clip-% but the script ops key on the tween-%. Prefers the + // anim in the keyframe's property group, falling back to the first keyframed one. + const resolveKeyframeTarget = useCallback( + // fallow-ignore-next-line complexity + (pct: number): { animId: string; tweenPct: number } | null => { + const cached = usePlayerStore.getState().keyframeCache.get(domEditSelection?.id ?? ""); + const kf = cached?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2); + const group = kf?.propertyGroup; + const anim = + (group ? selectedGsapAnimations.find((a) => a.propertyGroup === group) : undefined) ?? + selectedGsapAnimations.find((a) => a.keyframes); + return anim ? { animId: anim.id, tweenPct: kf?.tweenPercentage ?? pct } : null; + }, + [domEditSelection?.id, selectedGsapAnimations], + ); + + return useMemo( + () => ({ + onMoveElement: handleTimelineElementMove, + onMoveElements: handleTimelineElementsMove, + onResizeElement: handleTimelineElementResize, + onToggleTrackHidden: handleToggleTrackHidden, + onBlockedEditAttempt: handleBlockedTimelineEdit, + onSplitElement: handleTimelineElementSplit, + onRazorSplit: handleRazorSplit, + onRazorSplitAll: handleRazorSplitAll, + onDeleteAllKeyframes: () => { + // Hold the element where it is (collapse keyframes to a static set) rather + // than deleting the whole animation — deleting strands a stale GSAP base + // that the next drag adds to, flinging the element off-screen. + const anim = selectedGsapAnimations.find((a) => a.keyframes); + if (!anim) return; + handleGsapRemoveAllKeyframes(anim.id); + }, + onDeleteKeyframe: (_elId: string, pct: number) => { + const target = resolveKeyframeTarget(pct); + if (target) handleGsapRemoveKeyframe(target.animId, target.tweenPct); + }, + // Retime the keyframe to the playhead, preserving its value + ease. + onMoveKeyframeToPlayhead: (_elId: string, pct: number) => { + const target = resolveKeyframeTarget(pct); + if (target) handleGsapMoveKeyframeToPlayhead(target.animId, target.tweenPct); + }, + // Drag-to-retime. The diamond reports clip-%s; resolveKeyframeTarget gives + // the dragged keyframe's anim + tween-%. We convert the clip-% drop to an + // absolute time (via the clip's timing basis) and let resolveKeyframeRetime + // decide: a drop inside the tween window is a plain move (re-key tween-%); a + // drop past the boundary (last keyframe past the end, first before the start) + // resizes the tween — position/duration grow so the dragged keyframe lands at + // the drop while every other keyframe keeps its absolute time (value+ease too). + // fallow-ignore-next-line complexity + onMoveKeyframe: (_elId: string, fromClipPct: number, toClipPct: number) => { + const target = resolveKeyframeTarget(fromClipPct); + const sel = domEditSelection; + if (!target || !sel) return; + const anim = selectedGsapAnimations.find((a) => a.id === target.animId); + const tweenStart = anim ? resolveTweenStart(anim) : null; + if (!anim || tweenStart === null) return; + const tweenDuration = anim.duration ?? resolveTweenDuration(anim); + const sourceFile = sel.sourceFile || activeCompPath || "index.html"; + const { elements, domClipChildren } = usePlayerStore.getState(); + const { elStart, elDuration } = resolveClipTimingBasis( + sel.id ?? "", + sourceFile, + elements, + domClipChildren, + ); + const dropAbsTime = elStart + (toClipPct / 100) * elDuration; + const decision = resolveKeyframeRetime({ + keyframes: anim.keyframes?.keyframes ?? [], + draggedTweenPct: target.tweenPct, + tweenStart, + tweenDuration, + dropAbsTime, + }); + if (decision.kind === "move" && decision.toTweenPct != null) { + handleGsapMoveKeyframe(target.animId, target.tweenPct, decision.toTweenPct); + } else if ( + decision.kind === "resize" && + decision.pctRemap && + decision.position != null && + decision.duration != null + ) { + handleGsapResizeKeyframedTween( + target.animId, + decision.position, + decision.duration, + decision.pctRemap, + ); + } + }, + onChangeKeyframeEase: (_elId: string, _pct: number, ease: string) => { + for (const anim of selectedGsapAnimations) { + if (anim.keyframes) handleGsapUpdateMeta(anim.id, { ease }); + } + }, + // fallow-ignore-next-line complexity + onToggleKeyframeAtPlayhead: (el: TimelineElement) => { + const currentTime = usePlayerStore.getState().currentTime; + const pct = + el.duration > 0 + ? Math.max(0, Math.min(100, Math.round(((currentTime - el.start) / el.duration) * 100))) + : 0; + const anim = selectedGsapAnimations.find((a) => a.keyframes); + if (anim?.keyframes) { + const existing = anim.keyframes.keyframes.find((k) => Math.abs(k.percentage - pct) <= 1); + if (existing) { + handleGsapRemoveKeyframe(anim.id, existing.percentage); + } else { + handleGsapAddKeyframe(anim.id, pct, "x", 0); + } + } else { + const flatAnim = selectedGsapAnimations.find((a) => !a.keyframes); + if (flatAnim) handleGsapConvertToKeyframes(flatAnim.id); + } + }, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + handleTimelineElementMove, + handleTimelineElementsMove, + handleTimelineElementResize, + handleToggleTrackHidden, + handleBlockedTimelineEdit, + handleTimelineElementSplit, + handleRazorSplit, + handleRazorSplitAll, + handleGsapRemoveAllKeyframes, + resolveKeyframeTarget, + selectedGsapAnimations, + handleGsapRemoveKeyframe, + handleGsapMoveKeyframeToPlayhead, + handleGsapMoveKeyframe, + handleGsapResizeKeyframedTween, + handleGsapUpdateMeta, + handleGsapAddKeyframe, + handleGsapConvertToKeyframes, + buildDomSelectionForTimelineElement, + projectId, + activeCompPath, + domEditSelection, + ], + ); +} From 5762018c9bca80648f0784ae24de5b6baddd1606 Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Sat, 11 Jul 2026 13:55:21 -0700 Subject: [PATCH 04/15] =?UTF-8?q?feat(studio):=20timeline=20glue=20swap=20?= =?UTF-8?q?=E2=80=94=20Timeline/TimelineCanvas=20onto=20the=20NLE=20engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What: flips the timeline glue to its final form (23 files): Timeline and TimelineCanvas rebuilt on TimelineLanes/TimelineOverlays, useTimelineClipDrag drives preview/commit through the new drag engine, range selection goes multi-select, playback loop moves to useTimelinePlayerLoop. Deletes the 9 old-engine files this orphans (group drag, marquee selection, snap targets, layer gutter, selection overlays + their suites) — each is compile- or gate-forced by this swap, verified by probe. Why: second swap step; timeline-only, canvas and App untouched. How: modified files to final content + forced deletions. playerStore/timelineEditing/timelineCallbacks stay at their coexistence form until the app swap (the old App still runs on them). Test plan: tsc --noEmit; bunx vitest run (full suite); fallow audit clean. --- .../src/hooks/domSelectionTestHarness.ts | 14 + .../hooks/useAnimatedPropertyCommit.test.tsx | 10 +- .../player/components/PlayheadIndicator.tsx | 55 +- .../src/player/components/Timeline.test.ts | 121 ++- .../studio/src/player/components/Timeline.tsx | 385 +++++---- .../src/player/components/TimelineCanvas.tsx | 728 +++++------------- .../components/TimelineClipDiamonds.tsx | 49 +- .../player/components/TimelineLayerGutter.tsx | 61 -- .../components/TimelineSelectionOverlays.tsx | 55 -- .../components/timelineClipDragPreview.ts | 312 +++++++- .../components/timelineCollision.test.ts | 44 +- .../src/player/components/timelineDragDrop.ts | 129 ++-- .../player/components/timelineEditing.test.ts | 2 +- .../player/components/timelineLayout.test.ts | 118 ++- .../src/player/components/timelineLayout.ts | 269 +++---- .../timelineMarqueeSelection.test.ts | 87 --- .../components/timelineSnapTargets.test.ts | 144 ---- .../player/components/timelineSnapTargets.ts | 164 ---- .../components/useTimelineClipDrag.test.tsx | 506 ------------ .../player/components/useTimelineClipDrag.ts | 657 ++++++---------- .../components/useTimelineClipGroupDrag.ts | 417 ---------- .../useTimelineMarqueeSelection.test.tsx | 231 ------ .../components/useTimelineMarqueeSelection.ts | 276 ------- .../components/useTimelineRangeSelection.ts | 391 ++++++++-- .../hooks/useExpandedTimelineElements.ts | 3 - .../src/player/hooks/usePlaybackKeyboard.ts | 5 + .../src/player/hooks/useTimelinePlayer.ts | 101 ++- packages/studio/src/player/index.ts | 5 +- packages/studio/src/player/lib/time.test.ts | 38 +- packages/studio/src/player/lib/time.ts | 8 +- .../studio/src/player/lib/timelineDOM.test.ts | 103 --- packages/studio/src/player/lib/timelineDOM.ts | 84 +- .../src/player/lib/timelineIframeHelpers.ts | 2 + 33 files changed, 1856 insertions(+), 3718 deletions(-) delete mode 100644 packages/studio/src/player/components/TimelineLayerGutter.tsx delete mode 100644 packages/studio/src/player/components/TimelineSelectionOverlays.tsx delete mode 100644 packages/studio/src/player/components/timelineMarqueeSelection.test.ts delete mode 100644 packages/studio/src/player/components/timelineSnapTargets.test.ts delete mode 100644 packages/studio/src/player/components/timelineSnapTargets.ts delete mode 100644 packages/studio/src/player/components/useTimelineClipDrag.test.tsx delete mode 100644 packages/studio/src/player/components/useTimelineClipGroupDrag.ts delete mode 100644 packages/studio/src/player/components/useTimelineMarqueeSelection.test.tsx delete mode 100644 packages/studio/src/player/components/useTimelineMarqueeSelection.ts diff --git a/packages/studio/src/hooks/domSelectionTestHarness.ts b/packages/studio/src/hooks/domSelectionTestHarness.ts index 78810ca5c8..affd703311 100644 --- a/packages/studio/src/hooks/domSelectionTestHarness.ts +++ b/packages/studio/src/hooks/domSelectionTestHarness.ts @@ -1,5 +1,8 @@ // Shared harness helpers for selection hook tests (useDomSelection, // usePreviewInteraction). Test-only module. +import type React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; import type { DomEditSelection } from "../components/editor/domEditing"; export function installReactActEnvironment(): void { @@ -9,6 +12,17 @@ export function installReactActEnvironment(): void { }); } +/** Mount a React element into a fresh detached host and return its root. */ +export function mountReactHarness(node: React.ReactElement): Root { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(node); + }); + return root; +} + export function makeSelection(label: string, element: HTMLElement): DomEditSelection { return { element, diff --git a/packages/studio/src/hooks/useAnimatedPropertyCommit.test.tsx b/packages/studio/src/hooks/useAnimatedPropertyCommit.test.tsx index f5eeed3d35..7e75c84320 100644 --- a/packages/studio/src/hooks/useAnimatedPropertyCommit.test.tsx +++ b/packages/studio/src/hooks/useAnimatedPropertyCommit.test.tsx @@ -1,12 +1,12 @@ // @vitest-environment happy-dom import React, { act } from "react"; -import { createRoot } from "react-dom/client"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { usePlayerStore } from "../player/store/playerStore"; import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit"; +import { mountReactHarness } from "./domSelectionTestHarness"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -58,13 +58,7 @@ function renderHookWith( onReady(commitAnimatedProperties); return null; } - const host = document.createElement("div"); - document.body.append(host); - const root = createRoot(host); - act(() => { - root.render(); - }); - return root; + return mountReactHarness(); } function renderCommitHook( diff --git a/packages/studio/src/player/components/PlayheadIndicator.tsx b/packages/studio/src/player/components/PlayheadIndicator.tsx index 85a0457913..d2b8a7f690 100644 --- a/packages/studio/src/player/components/PlayheadIndicator.tsx +++ b/packages/studio/src/player/components/PlayheadIndicator.tsx @@ -2,20 +2,44 @@ /** * Shared playhead visual used by TimelineCanvas (real playhead) and * TimelineEditorNotice (animated illustration). + * + * The vertical line + glow span the full track height; the grab-handle HEAD is + * `position: sticky; top: 0` so it pins to the top of the (vertically) scrolling + * track area — the ruler is sticky too, so the head stays visible and grabbable + * no matter how far the tracks are scrolled. The head is OUTLINE-only at rest and + * FILLED while the playhead is actively held/scrubbed (`scrubbing`). */ interface PlayheadIndicatorProps { /** CSS color, defaults to the HF accent variable */ color?: string; /** Glow shadow color, defaults to translucent accent */ glowColor?: string; + /** Whether the playhead is being actively scrubbed — fills the head. */ + scrubbing?: boolean; + /** + * When false, the head chip is rendered in normal flow (top:1) instead of the + * sticky pin — used by the static illustration where there is no scroll area. + */ + stickyHead?: boolean; } export function PlayheadIndicator({ color = "var(--hf-accent, #3CE6AC)", glowColor = "rgba(60,230,172,0.14)", + scrubbing = false, + stickyHead = true, }: PlayheadIndicatorProps) { + // Head chip dimensions — used to compute the centering offset and the + // point where the vertical line starts (so it begins at the head's bottom + // edge rather than running through the hollow diamond center). + const HEAD_W = 9; + const HEAD_H = 9; + // marginTop(1) + HEAD_H = where the line should start. + const HEAD_TOTAL_H = 1 + HEAD_H; + return ( <> + {/* Glow — spans full height, centered on the line. */}