From 3fdfc5404627efc436281c353ae5d4000f97c44d Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Sat, 11 Jul 2026 13:55:16 -0700 Subject: [PATCH 1/3] =?UTF-8?q?feat(studio):=20clip=20drag=20engine=20?= =?UTF-8?q?=E2=80=94=20preview=20and=20commit=20(unwired)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What: the NLE clip-drag engine core: timelineClipDragPreview (zone-aware preview placement; keeps the legacy resolveDragPreviewPlacement export until the timeline swap), timelineClipDragCommit (drop → move/insert commit, stacking-synced) and timelineElementsMove (batched ripple/insert move with GSAP position shifting), with test suites. Why: the drag-drop brain, reviewable as functions before any component mounts it. How: new files plus one authored intermediate (preview file = final content + legacy export the old engine still imports); consumes the collision/ snapping/zones/stacking modules and the coexistence layer. Test plan: bunx vitest run on both test suites; tsc --noEmit; fallow audit clean. --- .fallowrc.jsonc | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index c6eb57fc1c..e96e35e470 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -55,13 +55,6 @@ "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/player/components/TimelineLanes.tsx", - "packages/studio/src/player/components/useTimelineStackingSync.ts", - "packages/studio/src/player/components/useTimelineGeometry.ts", - "packages/studio/src/player/components/useTimelineEditPinning.ts", - "packages/studio/src/hooks/useTimelineEditingDrops.ts", - // TEMP(studio-dnd): shipped unwired ahead of the NLE integration; - // the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block. "packages/studio/src/components/nle/NLEContext.tsx", "packages/studio/src/components/nle/PreviewPane.tsx", "packages/studio/src/components/nle/AssetPreviewOverlay.tsx", @@ -446,12 +439,6 @@ // require intrusive middleware changes beyond this PR's scope. "minLines": 6, "ignore": [ - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/hooks/useTimelineEditing.ts", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/hooks/useTimelineEditingDrops.ts", // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); // studio-dnd pr22 removes this with the final config. "packages/studio/src/utils/timelineAssetDrop.ts", @@ -631,10 +618,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/components/useTimelineGeometry.ts", - "packages/studio/src/player/components/useTimelineStackingSync.ts", // TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage); // removed by studio-dnd/pr22 when the final config lands. "packages/studio/src/components/editor/domEditOverlayGeometry.ts", From 84b040ce8a5b6c0b08ee31bdcd59e58669f50e07 Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Sat, 11 Jul 2026 13:55:17 -0700 Subject: [PATCH 2/3] feat(studio): timeline interaction hooks and lanes component (unwired) What: the timeline-side wiring layer, unwired: TimelineLanes (the lane renderer driving drag/resize/marquee), timelineMarquee (+tests), useTimelineStackingSync, useTimelineGeometry, useTimelineEditPinning, useTimelineEditingDrops. Why: everything between the pure drag math and itself; the timeline-glue swap PR then only rewires Timeline/TimelineCanvas onto these. How: new files, tsc-clean against the coexistence layer. Unwired components carry TEMP(studio-dnd) entry registrations, removed at the app-shell swap. Test plan: bunx vitest run timelineMarquee.test.ts; tsc --noEmit; fallow audit clean. --- .fallowrc.jsonc | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index e96e35e470..c6eb57fc1c 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -55,6 +55,13 @@ "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/player/components/TimelineLanes.tsx", + "packages/studio/src/player/components/useTimelineStackingSync.ts", + "packages/studio/src/player/components/useTimelineGeometry.ts", + "packages/studio/src/player/components/useTimelineEditPinning.ts", + "packages/studio/src/hooks/useTimelineEditingDrops.ts", + // TEMP(studio-dnd): shipped unwired ahead of the NLE integration; + // the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block. "packages/studio/src/components/nle/NLEContext.tsx", "packages/studio/src/components/nle/PreviewPane.tsx", "packages/studio/src/components/nle/AssetPreviewOverlay.tsx", @@ -439,6 +446,12 @@ // require intrusive middleware changes beyond this PR's scope. "minLines": 6, "ignore": [ + // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); + // studio-dnd pr22 removes this with the final config. + "packages/studio/src/hooks/useTimelineEditing.ts", + // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); + // studio-dnd pr22 removes this with the final config. + "packages/studio/src/hooks/useTimelineEditingDrops.ts", // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); // studio-dnd pr22 removes this with the final config. "packages/studio/src/utils/timelineAssetDrop.ts", @@ -618,6 +631,10 @@ // 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/components/useTimelineGeometry.ts", + "packages/studio/src/player/components/useTimelineStackingSync.ts", // TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage); // removed by studio-dnd/pr22 when the final config lands. "packages/studio/src/components/editor/domEditOverlayGeometry.ts", From 412c2ebd807cc0bc744f72839a97d8f88413dbca Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Sat, 11 Jul 2026 13:55:18 -0700 Subject: [PATCH 3/3] feat(studio): canvas chrome components (unwired) What: the canvas-side NLE chrome, unwired: DomEditSelectionChrome (rotated selection chrome + corner handles), domEditResizeLocal (+tests, local-space resize math), useDomEditNudge (arrow-key nudge hook), PreviewOverlays (the canvas overlay stack for the NLE shell). Why: the visible canvas editing layer, reviewable before the overlay swap rewires DomEditOverlay onto it. How: new files compiled against the coexistence layer; TEMP(studio-dnd) entry registrations for the unwired components, removed at the app-shell swap. Test plan: bunx vitest run domEditResizeLocal.test.ts; tsc --noEmit; fallow audit clean. --- .fallowrc.jsonc | 15 ++ .../editor/DomEditSelectionChrome.tsx | 251 +++++++++++++++++ .../editor/domEditResizeLocal.test.ts | 131 +++++++++ .../components/editor/domEditResizeLocal.ts | 113 ++++++++ .../editor/useDomEditNudge.test.tsx | 228 ++++++++++++++++ .../src/components/editor/useDomEditNudge.ts | 254 ++++++++++++++++++ .../src/components/nle/PreviewOverlays.tsx | 240 +++++++++++++++++ 7 files changed, 1232 insertions(+) create mode 100644 packages/studio/src/components/editor/DomEditSelectionChrome.tsx create mode 100644 packages/studio/src/components/editor/domEditResizeLocal.test.ts create mode 100644 packages/studio/src/components/editor/domEditResizeLocal.ts create mode 100644 packages/studio/src/components/editor/useDomEditNudge.test.tsx create mode 100644 packages/studio/src/components/editor/useDomEditNudge.ts create mode 100644 packages/studio/src/components/nle/PreviewOverlays.tsx diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index c6eb57fc1c..6a66d50816 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -55,6 +55,11 @@ "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/DomEditSelectionChrome.tsx", + "packages/studio/src/components/editor/useDomEditNudge.ts", + "packages/studio/src/components/nle/PreviewOverlays.tsx", + // TEMP(studio-dnd): shipped unwired ahead of the NLE integration; + // the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block. "packages/studio/src/player/components/TimelineLanes.tsx", "packages/studio/src/player/components/useTimelineStackingSync.ts", "packages/studio/src/player/components/useTimelineGeometry.ts", @@ -446,6 +451,12 @@ // require intrusive middleware changes beyond this PR's scope. "minLines": 6, "ignore": [ + // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); + // studio-dnd pr22 removes this with the final config. + "packages/studio/src/components/editor/DomEditOverlay.tsx", + // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); + // studio-dnd pr22 removes this with the final config. + "packages/studio/src/components/editor/DomEditSelectionChrome.tsx", // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); // studio-dnd pr22 removes this with the final config. "packages/studio/src/hooks/useTimelineEditing.ts", @@ -631,6 +642,10 @@ // complexity pre-dates the computed-timeline work. Exempted at file level // rather than refactored as scope creep. "ignore": [ + // TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage); + // removed by studio-dnd/pr22 when the final config lands. + "packages/studio/src/components/editor/DomEditSelectionChrome.tsx", + "packages/studio/src/components/editor/useDomEditNudge.ts", // TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage); // removed by studio-dnd/pr22 when the final config lands. "packages/studio/src/player/components/useTimelineGeometry.ts", diff --git a/packages/studio/src/components/editor/DomEditSelectionChrome.tsx b/packages/studio/src/components/editor/DomEditSelectionChrome.tsx new file mode 100644 index 0000000000..6ecdacf137 --- /dev/null +++ b/packages/studio/src/components/editor/DomEditSelectionChrome.tsx @@ -0,0 +1,251 @@ +import type { RefObject } from "react"; +import { type DomEditSelection } from "./domEditing"; +import type { GroupOverlayItem, OverlayRect } from "./domEditOverlayGeometry"; +import type { BlockedMoveState, ResizeHandle } from "./domEditOverlayGestures"; +import type { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures"; +import { DomEditCropHandles } from "./DomEditCropHandles"; +import { DomEditRotateHandle } from "./DomEditRotateHandle"; +import { resolveRotatedResizeCursor } from "./domEditResizeLocal"; + +// Corner resize handles, Canva-style: one per corner, diagonal cursors. +// Corners scale about the element center; the translate keeps the center +// planted, so they need the manual-offset capability in addition to manual-size. +const RESIZE_HANDLE_DEFS: Array<{ + handle: ResizeHandle; + cursor: string; + x: "left" | "right"; + y: "top" | "bottom"; +}> = [ + { handle: "nw", cursor: "nwse-resize", x: "left", y: "top" }, + { handle: "ne", cursor: "nesw-resize", x: "right", y: "top" }, + { handle: "sw", cursor: "nesw-resize", x: "left", y: "bottom" }, + { handle: "se", cursor: "nwse-resize", x: "right", y: "bottom" }, +]; + +// Visible dot is 9px; the pointer target is a 16px invisible square centered +// on the corner so click targets don't shrink with the smaller dot. +const RESIZE_HANDLE_HIT_PX = 16; + +type CropInset = { top: number; right: number; bottom: number; left: number }; +const NO_CROP_INSET: CropInset = { top: 0, right: 0, bottom: 0, left: 0 }; + +function resizeHandleStyle( + def: (typeof RESIZE_HANDLE_DEFS)[number], + overlayRect: { left: number; top: number; width: number; height: number }, + cropInset?: CropInset, +): React.CSSProperties { + const half = RESIZE_HANDLE_HIT_PX / 2; + const inset = cropInset ?? NO_CROP_INSET; + const style: React.CSSProperties = { cursor: def.cursor, touchAction: "none" }; + // Position relative to the overlay container (not the selection box). + // This ensures the dots render as siblings of the box border div — strictly + // above it — rather than as children where the parent border can visually + // overlap the dot circle at the corner. + style.left = + def.x === "left" + ? overlayRect.left + inset.left - half + : overlayRect.left + overlayRect.width - inset.right - half; + style.top = + def.y === "top" + ? overlayRect.top + inset.top - half + : overlayRect.top + overlayRect.height - inset.bottom - half; + return style; +} + +type GestureHandlers = ReturnType; + +interface DomEditGroupChromeProps { + groupOverlayItems: GroupOverlayItem[]; + groupBounds: OverlayRect; + allowCanvasMovement: boolean; + groupCanMove: boolean; + gestures: GestureHandlers; + onBoxMouseDown: (e: React.MouseEvent) => void; + onBoxClick: (event: React.MouseEvent) => void; +} + +// Multi-selection chrome: per-member outlines plus a single draggable bounding +// box spanning the union of the members. +export function DomEditGroupChrome({ + groupOverlayItems, + groupBounds, + allowCanvasMovement, + groupCanMove, + gestures, + onBoxMouseDown, + onBoxClick, +}: DomEditGroupChromeProps) { + return ( + <> + {groupOverlayItems.map((item) => ( +