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) => ( +