From e474a3b9eec0c5d5457489c26d08401e453323ea Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Sat, 11 Jul 2026 13:55:19 -0700 Subject: [PATCH 1/2] feat(studio): sidebar asset card (unwired) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What: AssetCard — the sidebar asset tile (thumbnail, hover preview, click policy, context menu incl. add-at-playhead), unwired. Why: standalone sidebar concept; AssetsTab adopts it in the app-shell swap. How: new file against the coexistence layer (assetHelpers, AssetContextMenu, assetClickBehavior, assetPreviewStore); one TEMP(studio-dnd) entry until AssetsTab wires it. Test plan: tsc --noEmit; fallow audit clean. --- .fallowrc.jsonc | 6 + .../src/components/sidebar/AssetCard.tsx | 316 ++++++++++++++++++ 2 files changed, 322 insertions(+) create mode 100644 packages/studio/src/components/sidebar/AssetCard.tsx diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index ba95b95ebe..543f806d80 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -55,6 +55,9 @@ "packages/studio/src/hooks/gsapRuntimePreview.ts", // TEMP(studio-dnd): shipped unwired ahead of the NLE integration; // the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block. + "packages/studio/src/components/sidebar/AssetCard.tsx", + // TEMP(studio-dnd): shipped unwired ahead of the NLE integration; + // the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block. "packages/studio/src/components/EditorShell.tsx", "packages/studio/src/components/nle/TimelinePane.tsx", "packages/studio/src/components/nle/useTimelineEditCallbacks.ts", @@ -659,6 +662,9 @@ // 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/sidebar/AssetCard.tsx", // TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage); // removed by studio-dnd/pr22 when the final config lands. "packages/studio/src/components/editor/DomEditSelectionChrome.tsx", diff --git a/packages/studio/src/components/sidebar/AssetCard.tsx b/packages/studio/src/components/sidebar/AssetCard.tsx new file mode 100644 index 0000000000..e2ef1bf4f6 --- /dev/null +++ b/packages/studio/src/components/sidebar/AssetCard.tsx @@ -0,0 +1,316 @@ +/** + * AssetCard and FontRow — visual asset tile / row components for the Assets panel. + * Extracted from AssetsTab.tsx to keep that file under the 600-line CI gate. + */ +import { useState, useEffect, useRef, useCallback } from "react"; +import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail"; +import { VIDEO_EXT, IMAGE_EXT } from "../../utils/mediaTypes"; +import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop"; +import { ContextMenu } from "./AssetContextMenu"; +import { usePlayerStore } from "../../player/store/playerStore"; +import { useAssetPreviewStore } from "../../utils/assetPreviewStore"; +import { findClipForAsset, isPointerClick } from "../../utils/assetClickBehavior"; +import { basename, ext, truncateMiddle, formatDuration } from "./assetHelpers"; + +/** Drag payload writer shared by the asset tile and the font row: copy effect + * plus the timeline-asset MIME and a plain-text path fallback. */ +function writeAssetDragData(e: React.DragEvent, asset: string): void { + e.dataTransfer.effectAllowed = "copy"; + e.dataTransfer.setData(TIMELINE_ASSET_MIME, JSON.stringify({ path: asset })); + e.dataTransfer.setData("text/plain", asset); +} + +/** Open the row/tile context menu at the pointer, shared by asset tile + font row. */ +function openAssetContextMenu( + e: React.MouseEvent, + setContextMenu: (menu: { x: number; y: number }) => void, +): void { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY }); +} + +/** + * Lazily probe a video/audio URL for its duration via a hidden HTMLVideoElement + * (`preload="metadata"`). The manifest only covers ~/.media assets, so project + * assets in assets/ have no manifest entry — this fills the gap. + * Returns `undefined` until the probe completes; `null` if it failed. + */ +function useProbedDuration(src: string, skip: boolean): number | null | undefined { + const [duration, setDuration] = useState(undefined); + useEffect(() => { + if (skip) return; + let cancelled = false; + + function probe(attempt: number) { + if (cancelled) return; + const vid = document.createElement("video"); + vid.preload = "metadata"; + vid.muted = true; + vid.onloadedmetadata = () => { + const d = Number.isFinite(vid.duration) && vid.duration > 0 ? vid.duration : null; + if (!cancelled) setDuration(d); + vid.onloadedmetadata = null; + vid.onerror = null; + vid.src = ""; + }; + vid.onerror = () => { + vid.onloadedmetadata = null; + vid.onerror = null; + vid.src = ""; + if (!cancelled) { + if (attempt < 1) setTimeout(() => probe(attempt + 1), 50); + else setDuration(null); + } + }; + vid.src = src; + } + + probe(0); + return () => { + cancelled = true; + }; + }, [src, skip]); + return duration; +} + +export interface AssetCardProps { + projectId: string; + asset: string; + used: boolean; + duration?: number; + onCopy: (path: string) => void; + isCopied: boolean; + onDelete?: (path: string) => void; + onRename?: (oldPath: string, newPath: string) => void; + onAddAssetToTimeline?: (path: string) => void; +} + +/** + * Thumbnail card for images and video assets. Renders in a 2-col grid. + * + * Click behaviour (CapCut-style): + * - Already added → selects the clip on the timeline (setSelectedElementId). + * - Not yet added → opens the asset preview overlay over the canvas. + * Drag behaviour is preserved: a pointer movement exceeding DRAG_THRESHOLD_PX + * before pointerup is treated as drag-start, not a click. + */ +// fallow-ignore-next-line complexity +export function AssetCard({ + projectId, + asset, + used, + duration, + onCopy, + isCopied, + onDelete, + onRename, + onAddAssetToTimeline, +}: AssetCardProps) { + const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); + const [hovered, setHovered] = useState(false); + const fullName = asset.split("/").pop() ?? asset; + const name = basename(asset); + const extension = ext(asset); + const serveUrl = `/api/projects/${projectId}/preview/${asset}`; + const isVideo = VIDEO_EXT.test(asset); + const isImage = IMAGE_EXT.test(asset); + const probedDuration = useProbedDuration(serveUrl, !isVideo || duration != null); + const resolvedDuration = duration ?? probedDuration ?? undefined; + const durationLabel = formatDuration(resolvedDuration ?? 0); + + // Drag-threshold click gate: track pointer-down position so we can ignore + // pointer-up events that followed a real drag gesture. + const pointerDownRef = useRef<{ x: number; y: number } | null>(null); + + const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId); + const elements = usePlayerStore((s) => s.elements); + const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset); + + const handlePointerDown = useCallback((e: React.PointerEvent) => { + pointerDownRef.current = { x: e.clientX, y: e.clientY }; + }, []); + + const handlePointerUp = useCallback( + (e: React.PointerEvent) => { + const origin = pointerDownRef.current; + pointerDownRef.current = null; + if (!origin) return; + if (!isPointerClick(e.clientX - origin.x, e.clientY - origin.y)) return; + // Treat as click + if (used) { + const clip = findClipForAsset(elements, asset); + if (clip) { + setSelectedElementId(clip.key ?? clip.id); + return; + } + } + // Not added (or no matching clip found) → preview overlay + setPreviewAsset(asset, projectId); + }, + [used, elements, asset, projectId, setSelectedElementId, setPreviewAsset], + ); + + return ( + <> +
writeAssetDragData(e, asset)} + onContextMenu={(e) => openAssetContextMenu(e, setContextMenu)} + onPointerEnter={() => setHovered(true)} + onPointerLeave={() => setHovered(false)} + className={`flex flex-col gap-1 cursor-pointer rounded-md p-1 transition-colors ${ + isCopied ? "bg-studio-accent/10" : "hover:bg-neutral-800/40" + }`} + > + {/* Thumbnail */} +
+ {isImage && ( + {name} { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + )} + {isVideo && ( + <> + + {hovered && ( +
+ + {/* Filename caption */} + + {truncateMiddle(fullName, 22)} + +
+ + {contextMenu && ( + setContextMenu(null)} + onCopy={onCopy} + onDelete={onDelete} + onRename={onRename} + onAddAtPlayhead={onAddAssetToTimeline} + /> + )} + + ); +} + +export interface FontRowProps { + asset: string; + used: boolean; + onCopy: (path: string) => void; + isCopied: boolean; + onDelete?: (path: string) => void; + onRename?: (oldPath: string, newPath: string) => void; + onAddAssetToTimeline?: (path: string) => void; +} + +/** + * Compact row for font assets (no meaningful thumbnail; show ext badge + name). + */ +export function FontRow({ + asset, + used, + onCopy, + isCopied, + onDelete, + onRename, + onAddAssetToTimeline, +}: FontRowProps) { + const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); + const name = basename(asset); + const extension = ext(asset); + + return ( + <> +
onCopy(asset)} + onDragStart={(e) => writeAssetDragData(e, asset)} + onContextMenu={(e) => openAssetContextMenu(e, setContextMenu)} + className={`px-2.5 py-1.5 flex items-center gap-2.5 cursor-pointer transition-colors ${ + isCopied + ? "bg-studio-accent/10 border-l-2 border-studio-accent" + : "border-l-2 border-transparent hover:bg-neutral-800/50" + }`} + > +
+ {extension} +
+
+ + {name} + +
+ {extension} + {used && ( + + in use + + )} +
+
+
+ + {contextMenu && ( + setContextMenu(null)} + onCopy={onCopy} + onDelete={onDelete} + onRename={onRename} + onAddAtPlayhead={onAddAssetToTimeline} + /> + )} + + ); +} From 9f3e9ac51cc4a6996ca02b64dd5329317e64c3f2 Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Sat, 11 Jul 2026 13:55:20 -0700 Subject: [PATCH 2/2] =?UTF-8?q?feat(studio):=20canvas=20glue=20swap=20?= =?UTF-8?q?=E2=80=94=20overlay,=20gestures,=20selection=20to=20final=20for?= =?UTF-8?q?m?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What: flips the canvas editing glue to its final NLE form (25 files): DomEditOverlay renders DomEditSelectionChrome/CanvasContextMenu, the gesture pipeline gains center-anchored corner resize (CapCut model) + z-order commit, dom selection/wiring/geometry-commit hooks move to the multi-select store API. Adds the anchored-resize characterization test. Why: first of three swap steps; the coexistence layer makes it compile-green without touching timeline or App glue. How: modified files to final content; no deletions (nothing is orphaned by this step — verified). Both characterization suites from the earlier PR stay green. Test plan: tsc --noEmit; bunx vitest run (full suite; anchored-resize suite passes from this PR on); fallow audit clean. --- .fallowrc.jsonc | 6 + .../components/editor/DomEditCropHandles.tsx | 78 ++- .../components/editor/DomEditOverlay.test.ts | 524 ++++++++---------- .../src/components/editor/DomEditOverlay.tsx | 277 ++++----- .../components/editor/DomEditRotateHandle.tsx | 55 +- .../src/components/editor/LayersPanel.test.ts | 68 ++- .../src/components/editor/LayersPanel.tsx | 44 +- .../components/editor/SnapGuideOverlay.tsx | 51 +- .../anchoredResizeCommitFeedsOffset.test.ts | 214 +++++++ .../editor/anchoredResizeReleaseShift.test.ts | 116 +++- .../editor/domEditOverlayGeometry.test.ts | 160 +++++- .../editor/domEditOverlayGeometry.ts | 102 ++-- .../editor/domEditOverlayGestures.ts | 91 ++- .../editor/domEditOverlayStartGesture.ts | 39 +- .../src/components/editor/domEditingDom.ts | 2 +- .../editor/manualEditsDomPatches.test.ts | 47 ++ .../editor/offCanvasIndicatorRefresh.test.tsx | 26 +- .../src/components/editor/snapEngine.test.ts | 90 +-- .../src/components/editor/snapEngine.ts | 66 +-- .../editor/useDomEditOverlayGestures.ts | 266 +++++---- .../editor/useDomEditOverlayRects.ts | 11 +- .../studio/src/hooks/useContextMenuDismiss.ts | 38 +- .../studio/src/hooks/useDomEditTextCommits.ts | 6 +- packages/studio/src/hooks/useDomEditWiring.ts | 16 +- .../studio/src/hooks/useDomGeometryCommits.ts | 19 +- .../studio/src/hooks/useDomSelection.test.ts | 38 +- packages/studio/src/hooks/useDomSelection.ts | 131 +++-- 27 files changed, 1662 insertions(+), 919 deletions(-) create mode 100644 packages/studio/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 543f806d80..7a5ebe8228 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -459,6 +459,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/useDomEditWiring.ts", + // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); + // studio-dnd pr22 removes this with the final config. + "packages/studio/src/hooks/useGsapSelectionHandlers.ts", // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); // studio-dnd pr22 removes this with the final config. "packages/studio/src/components/StudioPreviewArea.tsx", diff --git a/packages/studio/src/components/editor/DomEditCropHandles.tsx b/packages/studio/src/components/editor/DomEditCropHandles.tsx index 7ac3b485b0..ff90de33af 100644 --- a/packages/studio/src/components/editor/DomEditCropHandles.tsx +++ b/packages/studio/src/components/editor/DomEditCropHandles.tsx @@ -31,12 +31,16 @@ interface DomEditCropHandlesProps { onStyleCommit?: (property: string, value: string) => Promise | void; } -// Gap (px) between an edge handle and the element edge, so the handle sits -// clear of the element body and can't intercept a move-drag. -const EDGE_HANDLE_GAP = 8; +// Hit-strip size (px) for an edge crop handle: THICKNESS extends outward from +// the crop edge (flush against it, never over the element body, so a body +// drag always MOVES), LENGTH runs along the edge. The visible pill is smaller +// and centered inside the strip. +const EDGE_HIT_THICKNESS = 12; +const EDGE_HIT_LENGTH = 32; -/** Place an edge handle just OUTSIDE the given crop edge (translate pushes it - * fully past the boundary). Keeps the element body free for moving. */ +/** Place an edge handle's hit strip just OUTSIDE the given crop edge + * (translate pushes it fully past the boundary). Keeps the element body free + * for moving. Corners stay free for the selection's own resize handles. */ function edgeHandlePlacement( edge: CropEdge, rect: { left: number; top: number; width: number; height: number }, @@ -44,27 +48,36 @@ function edgeHandlePlacement( const cx = rect.left + rect.width / 2; const cy = rect.top + rect.height / 2; if (edge === "top") { - return { left: cx, top: rect.top - EDGE_HANDLE_GAP, transform: "translate(-50%, -100%)" }; + return { left: cx, top: rect.top, transform: "translate(-50%, -100%)" }; } if (edge === "bottom") { - return { - left: cx, - top: rect.top + rect.height + EDGE_HANDLE_GAP, - transform: "translate(-50%, 0)", - }; + return { left: cx, top: rect.top + rect.height, transform: "translate(-50%, 0)" }; } if (edge === "left") { - return { left: rect.left - EDGE_HANDLE_GAP, top: cy, transform: "translate(-100%, -50%)" }; + return { left: rect.left, top: cy, transform: "translate(-100%, -50%)" }; } - return { - left: rect.left + rect.width + EDGE_HANDLE_GAP, - top: cy, - transform: "translate(0, -50%)", - }; + return { left: rect.left + rect.width, top: cy, transform: "translate(0, -50%)" }; } const EDGES: CropEdge[] = ["top", "right", "bottom", "left"]; +/** Hit-strip + pill dimensions for an edge handle, keyed on its orientation. */ +function edgeHandleMetrics(vertical: boolean): { + hitWidth: number; + hitHeight: number; + cursor: string; + pillWidth: number; + pillHeight: number; +} { + return { + hitWidth: vertical ? EDGE_HIT_THICKNESS : EDGE_HIT_LENGTH, + hitHeight: vertical ? EDGE_HIT_LENGTH : EDGE_HIT_THICKNESS, + cursor: vertical ? "ew-resize" : "ns-resize", + pillWidth: vertical ? 4 : 24, + pillHeight: vertical ? 24 : 4, + }; +} + /** * Always-on crop, integrated with the selection (no crop "mode"): while a * croppable element is selected its clip is lifted so the FULL content shows and @@ -83,6 +96,7 @@ export function DomEditCropHandles({ }: DomEditCropHandlesProps) { const gestureRef = useRef(null); const [dragging, setDragging] = useState(false); + const [hotEdge, setHotEdge] = useState(null); // readElementCropInsets returns null for a clip this tool can't represent // (circle/polygon/non-px inset): the crop UI must fully stand down for that // element — no lift, no handles — or select+deselect replaces the authored @@ -304,6 +318,7 @@ export function DomEditCropHandles({ ); })} diff --git a/packages/studio/src/components/editor/DomEditOverlay.test.ts b/packages/studio/src/components/editor/DomEditOverlay.test.ts index fbc8eccb26..736bd7ecb2 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.test.ts +++ b/packages/studio/src/components/editor/DomEditOverlay.test.ts @@ -11,10 +11,10 @@ import { hasDomEditRotationChanged, resolveDomEditCoordinateScale, resolveDomEditGroupOverlayRect, - resolveDomEditResizeGesture, resolveDomEditRotationGesture, } from "./DomEditOverlay"; import type { DomEditSelection } from "./domEditing"; +import { resolveResizeCenterAnchorOffset } from "./domEditOverlayGestures"; // React 19 warns unless the test environment opts into act(). globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -84,21 +84,37 @@ vi.mock("./useDomEditOverlayRects", async () => { }; }); +const previewHelperSpies = vi.hoisted(() => ({ + getPreviewTargetFromPointer: vi.fn<() => HTMLElement | null>(() => null), +})); + +vi.mock("../../utils/studioPreviewHelpers", async () => { + const actual = await vi.importActual( + "../../utils/studioPreviewHelpers", + ); + return { + ...actual, + getPreviewTargetFromPointer: previewHelperSpies.getPreviewTargetFromPointer, + }; +}); + vi.mock("./domEditOverlayGeometry", async () => { const actual = await vi.importActual( "./domEditOverlayGeometry", ); + const stubRect = { + left: 24, + top: 36, + width: 180, + height: 72, + editScaleX: 1, + editScaleY: 1, + }; return { ...actual, - toOverlayRect: () => ({ - left: 24, - top: 36, - width: 180, - height: 72, - editScaleX: 1, - editScaleY: 1, - }), + toOverlayRect: () => stubRect, + orientedOverlayRect: () => stubRect, }; }); @@ -126,6 +142,103 @@ function createOverlayProps(args: { }; } +/** + * Stub element-level getBoundingClientRect to a fixed 800×450 rect (happy-dom + * returns all-zeros for unlaid-out elements, which gates the RAF compRect + * update). Returns a restore function to call in teardown. + */ +function stubViewportRect(): () => void { + const original = Element.prototype.getBoundingClientRect; + Element.prototype.getBoundingClientRect = function (): DOMRect { + return { + left: 0, + top: 0, + right: 800, + bottom: 450, + width: 800, + height: 450, + x: 0, + y: 0, + toJSON: () => ({}), + }; + }; + return () => { + Element.prototype.getBoundingClientRect = original; + }; +} + +/** + * Flush the mount's RAF ticks so the compRect update lands. Two animation-frame + * ticks: the first scheduled by useMountEffect's update(), the second by + * update()'s tail recursion. + */ +async function flushOverlayRaf(): Promise { + await act(async () => { + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + }); +} + +/** A fully-populated DomEditSelection with per-test overrides (capabilities are + * merged so a test can flip a single flag without restating the whole set). */ +function makeDomEditSelection( + overrides: Partial = {}, + capabilityOverrides: Partial = {}, +): DomEditSelection { + const base: DomEditSelection = { + element: document.createElement("div"), + id: "hero-title", + selector: ".hero-title", + selectorIndex: 0, + sourceFile: "index.html", + tagName: "div", + label: "Hero Title", + textContent: "Hello", + textFields: [], + capabilities: { + canEditText: true, + canEditLayout: true, + canMove: true, + canApplyManualOffset: true, + canApplyManualSize: false, + canApplyManualRotation: false, + canAdjustOpacity: true, + canAdjustFill: true, + canAdjustBorderRadius: true, + canAdjustStroke: true, + canAdjustShadow: true, + canAdjustZIndex: true, + }, + computedStyle: { + display: "block", + position: "absolute", + }, + }; + return { + ...base, + ...overrides, + capabilities: { ...base.capabilities, ...capabilityOverrides }, + }; +} + +/** Query the composition-canvas overlay and assert it mounted. */ +function getOverlay(host: HTMLElement): HTMLDivElement { + const overlay = host.querySelector('[aria-label="Composition canvas"]'); + expect(overlay).toBeTruthy(); + if (!overlay) throw new Error("Expected composition canvas overlay"); + return overlay; +} + +/** Dispatch a left-button pointerdown at (clientX, clientY) inside act(). */ +function dispatchOverlayPointerDown(target: Element, clientX = 120, clientY = 80): void { + act(() => { + target.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, button: 0, clientX, clientY }), + ); + }); +} + describe("focusDomEditOverlayElement", () => { it("focuses the canvas overlay without scrolling", () => { const calls: Array = []; @@ -144,41 +257,84 @@ describe("DomEditOverlay", () => { gestureSpies.onPointerMove.mockClear(); gestureSpies.onPointerUp.mockClear(); gestureSpies.clearPointerState.mockClear(); + previewHelperSpies.getPreviewTargetFromPointer.mockReset(); + previewHelperSpies.getPreviewTargetFromPointer.mockReturnValue(null); + }); + + it("selects on the first click over an element even before a hover is resolved", async () => { + // Regression: this used to start a marquee whenever hoverSelectionRef was null. + // The RAF hover loop populates that ref ASYNCHRONOUSLY, so a genuine first + // click over an element read null and was misread as empty canvas — the + // marquee swallowed the selecting onMouseDown, so nothing selected until the + // SECOND click. With a synchronous pointer hit-test finding an element, the + // marquee must NOT start and onCanvasMouseDown must fire on the first click. + const restoreRect = stubViewportRect(); + const originalPointerCapture = HTMLDivElement.prototype.setPointerCapture; + HTMLDivElement.prototype.setPointerCapture = () => {}; + + // An element IS under the pointer, but no hover has been resolved yet. + previewHelperSpies.getPreviewTargetFromPointer.mockReturnValue(document.createElement("div")); + + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null }; + const onCanvasMouseDown = vi.fn(); + const onMarqueeSelect = vi.fn(); + + function Harness() { + return React.createElement(DomEditOverlay, { + ...createOverlayProps({ + iframeRef, + selection: null, + hoverSelection: null, + onSelectionChange: () => {}, + }), + onCanvasMouseDown, + onMarqueeSelect, + }); + } + + act(() => { + root.render(React.createElement(Harness)); + }); + await flushOverlayRaf(); + + const overlay = getOverlay(host); + + act(() => { + overlay.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 120, clientY: 80 }), + ); + overlay.dispatchEvent( + new MouseEvent("mousedown", { bubbles: true, button: 0, clientX: 120, clientY: 80 }), + ); + }); + + // No marquee started; the click reached the selecting mouse-down handler. + expect(onMarqueeSelect).not.toHaveBeenCalled(); + expect(onCanvasMouseDown).toHaveBeenCalledTimes(1); + + act(() => { + root.unmount(); + }); + HTMLDivElement.prototype.setPointerCapture = originalPointerCapture; + restoreRect(); + host.remove(); }); it("does not start a drag from a stale hover target on canvas pointer-down", () => { const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); - const selection: DomEditSelection = { - element: document.createElement("div"), + const selection = makeDomEditSelection({ id: "cta-label", selector: ".cta-label", - selectorIndex: 0, - sourceFile: "index.html", tagName: "span", label: "CTA Label", textContent: "Add to basket", - textFields: [], - capabilities: { - canEditText: true, - canEditLayout: true, - canMove: true, - canApplyManualOffset: true, - canApplyManualSize: false, - canApplyManualRotation: false, - canAdjustOpacity: true, - canAdjustFill: true, - canAdjustBorderRadius: true, - canAdjustStroke: true, - canAdjustShadow: true, - canAdjustZIndex: true, - }, - computedStyle: { - display: "inline", - position: "static", - }, - }; + computedStyle: { display: "inline", position: "static" }, + }); let currentSelection: DomEditSelection | null = null; const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null }; @@ -202,19 +358,9 @@ describe("DomEditOverlay", () => { root.render(React.createElement(Harness)); }); - const overlay = host.querySelector('[aria-label="Composition canvas"]') as HTMLDivElement; - expect(overlay).toBeTruthy(); + const overlay = getOverlay(host); - act(() => { - overlay.dispatchEvent( - new PointerEvent("pointerdown", { - bubbles: true, - button: 0, - clientX: 120, - clientY: 80, - }), - ); - }); + dispatchOverlayPointerDown(overlay); expect(gestureSpies.startGesture).not.toHaveBeenCalled(); expect(currentSelection).toBe(null); @@ -233,53 +379,12 @@ describe("DomEditOverlay", () => { // box (and other bounded UI) behind `compRect.width > 0` (added in the // keyframes PR a468550f). Stub element-level getBoundingClientRect for // the test so the RAF compRect update produces a real width. - const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect; - Element.prototype.getBoundingClientRect = function (): DOMRect { - return { - left: 0, - top: 0, - right: 800, - bottom: 450, - width: 800, - height: 450, - x: 0, - y: 0, - toJSON: () => ({}), - }; - }; + const restoreRect = stubViewportRect(); const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); - const selection: DomEditSelection = { - element: document.createElement("div"), - id: "hero-title", - selector: ".hero-title", - selectorIndex: 0, - sourceFile: "index.html", - tagName: "div", - label: "Hero Title", - textContent: "Hello", - textFields: [], - capabilities: { - canEditText: true, - canEditLayout: true, - canMove: true, - canApplyManualOffset: true, - canApplyManualSize: false, - canApplyManualRotation: false, - canAdjustOpacity: true, - canAdjustFill: true, - canAdjustBorderRadius: true, - canAdjustStroke: true, - canAdjustShadow: true, - canAdjustZIndex: true, - }, - computedStyle: { - display: "block", - position: "absolute", - }, - }; + const selection = makeDomEditSelection(); let currentSelection: DomEditSelection | null = selection; const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null }; @@ -307,30 +412,16 @@ describe("DomEditOverlay", () => { // Flush the mount's RAF tick so the compRect update lands before the // pointer-down. Two animation-frame ticks: the first scheduled by // useMountEffect's update(), the second by update()'s tail recursion. - await act(async () => { - await new Promise((resolve) => { - requestAnimationFrame(() => requestAnimationFrame(() => resolve())); - }); - }); + await flushOverlayRaf(); - const overlay = host.querySelector('[aria-label="Composition canvas"]') as HTMLDivElement; - expect(overlay).toBeTruthy(); + getOverlay(host); const selectionBox = host.querySelector( '[data-dom-edit-selection-box="true"]', ) as HTMLDivElement; expect(selectionBox).toBeTruthy(); - act(() => { - selectionBox.dispatchEvent( - new PointerEvent("pointerdown", { - bubbles: true, - button: 0, - clientX: 120, - clientY: 80, - }), - ); - }); + dispatchOverlayPointerDown(selectionBox); expect(currentSelection).toBe(selection); expect(gestureSpies.startGesture).toHaveBeenCalledWith( @@ -342,58 +433,17 @@ describe("DomEditOverlay", () => { root.unmount(); }); HTMLDivElement.prototype.setPointerCapture = originalPointerCapture; - Element.prototype.getBoundingClientRect = originalGetBoundingClientRect; + restoreRect(); host.remove(); }); it("passes the tracked hover selection when clicking the existing selection box", async () => { - const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect; - Element.prototype.getBoundingClientRect = function (): DOMRect { - return { - left: 0, - top: 0, - right: 800, - bottom: 450, - width: 800, - height: 450, - x: 0, - y: 0, - toJSON: () => ({}), - }; - }; + const restoreRect = stubViewportRect(); const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); - const selection: DomEditSelection = { - element: document.createElement("div"), - id: "hero-title", - selector: ".hero-title", - selectorIndex: 0, - sourceFile: "index.html", - tagName: "div", - label: "Hero Title", - textContent: "Hello", - textFields: [], - capabilities: { - canEditText: true, - canEditLayout: true, - canMove: false, - canApplyManualOffset: false, - canApplyManualSize: false, - canApplyManualRotation: false, - canAdjustOpacity: true, - canAdjustFill: true, - canAdjustBorderRadius: true, - canAdjustStroke: true, - canAdjustShadow: true, - canAdjustZIndex: true, - }, - computedStyle: { - display: "block", - position: "absolute", - }, - }; + const selection = makeDomEditSelection({}, { canMove: false, canApplyManualOffset: false }); const hoverSelection: DomEditSelection = { ...selection, id: "hovered-sibling" }; const onCanvasMouseDown = vi.fn(); const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null }; @@ -414,11 +464,7 @@ describe("DomEditOverlay", () => { root.render(React.createElement(Harness)); }); - await act(async () => { - await new Promise((resolve) => { - requestAnimationFrame(() => requestAnimationFrame(() => resolve())); - }); - }); + await flushOverlayRaf(); const selectionBox = host.querySelector( '[data-dom-edit-selection-box="true"]', @@ -437,7 +483,7 @@ describe("DomEditOverlay", () => { act(() => { root.unmount(); }); - Element.prototype.getBoundingClientRect = originalGetBoundingClientRect; + restoreRect(); host.remove(); }); }); @@ -513,130 +559,10 @@ describe("filterNestedDomEditGroupItems", () => { }); }); -describe("resolveDomEditResizeGesture", () => { - it("resizes width and height independently by default", () => { - expect( - resolveDomEditResizeGesture({ - originWidth: 240, - originHeight: 120, - actualWidth: 240, - actualHeight: 120, - scaleX: 1, - scaleY: 1, - dx: 30, - dy: 12, - uniform: false, - }), - ).toEqual({ - overlayWidth: 270, - overlayHeight: 132, - width: 270, - height: 132, - }); - }); - - it("divides the cursor delta by the element's content scale (rescaled element)", () => { - // Element renders at 2x via a GSAP scale: a 30px cursor delta must grow the - // CSS box by only 15px so the RENDERED box tracks the pointer 1:1. - const next = resolveDomEditResizeGesture({ - originWidth: 480, // 240 css x 2 content scale (overlay px at editScale 1) - originHeight: 240, - actualWidth: 240, - actualHeight: 120, - scaleX: 1, - scaleY: 1, - contentScaleX: 2, - contentScaleY: 2, - dx: 30, - dy: 12, - uniform: false, - }); - expect(next.width).toBe(255); - expect(next.height).toBe(126); - // The overlay box keeps tracking the raw cursor. - expect(next.overlayWidth).toBe(510); - expect(next.overlayHeight).toBe(252); - }); - - it("treats a missing/invalid content scale as 1 (unscaled element)", () => { - const next = resolveDomEditResizeGesture({ - originWidth: 240, - originHeight: 120, - actualWidth: 240, - actualHeight: 120, - scaleX: 1, - scaleY: 1, - contentScaleX: 0, - contentScaleY: Number.NaN, - dx: 30, - dy: 12, - uniform: false, - }); - expect(next.width).toBe(270); - expect(next.height).toBe(132); - }); - - it("snaps width and height to the same value when Shift is held", () => { - expect( - resolveDomEditResizeGesture({ - originWidth: 240, - originHeight: 120, - actualWidth: 240, - actualHeight: 120, - scaleX: 1, - scaleY: 1, - dx: 30, - dy: 12, - uniform: true, - }), - ).toEqual({ - overlayWidth: 270, - overlayHeight: 270, - width: 270, - height: 270, - }); - }); - - it("uses the dominant pointer delta for uniform shrink", () => { - expect( - resolveDomEditResizeGesture({ - originWidth: 300, - originHeight: 180, - actualWidth: 300, - actualHeight: 180, - scaleX: 1, - scaleY: 1, - dx: 8, - dy: -40, - uniform: true, - }), - ).toMatchObject({ - width: 260, - height: 260, - }); - }); - - it("writes source-local dimensions when the edited source is scaled down in master view", () => { - expect( - resolveDomEditResizeGesture({ - originWidth: 100, - originHeight: 50, - actualWidth: 400, - actualHeight: 200, - scaleX: 0.25, - scaleY: 0.25, - dx: 25, - dy: 10, - uniform: false, - }), - ).toEqual({ - overlayWidth: 125, - overlayHeight: 60, - width: 500, - height: 240, - }); - }); -}); +// Note: the resize SIZE math moved from the AABB screen-space +// resolveDomEditResizeGesture (removed) to the local-space (OBB) model in +// domEditResizeLocal.ts — see domEditResizeLocal.test.ts, which re-covers the +// independent-axis, aspect-lock, and scaled-master-view cases plus rotated axes. describe("resolveDomEditRotationGesture", () => { it("rotates by the pointer angle around the element center", () => { @@ -701,3 +627,43 @@ describe("resolveDomEditRotationGesture", () => { expect(hasDomEditRotationChanged(0, 0)).toBe(false); }); }); + +// resolveResizeCenterAnchorOffset is the UNROTATED (AABB) fallback used only when +// the element's real transformed corners can't be measured. Center-anchored: a +// width/height change grows the box from its top-left, drifting the center by half +// the size change per axis, so the pin translates back by that half-delta. It is +// handle-independent — all four corners scale about the same center. +describe("resolveResizeCenterAnchorOffset", () => { + it("grow: translates back by half the size change on both axes", () => { + expect( + resolveResizeCenterAnchorOffset({ + originWidth: 200, + originHeight: 100, + overlayWidth: 230, + overlayHeight: 112, + }), + ).toEqual({ dx: -15, dy: -6 }); + }); + + it("shrink: translates forward by half the (positive) size change", () => { + expect( + resolveResizeCenterAnchorOffset({ + originWidth: 200, + originHeight: 100, + overlayWidth: 160, + overlayHeight: 80, + }), + ).toEqual({ dx: 20, dy: 10 }); + }); + + it("no size change: zero offset", () => { + expect( + resolveResizeCenterAnchorOffset({ + originWidth: 200, + originHeight: 100, + overlayWidth: 200, + overlayHeight: 100, + }), + ).toEqual({ dx: 0, dy: 0 }); + }); +}); diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index 8d89a3637d..e80799f315 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -1,5 +1,4 @@ -import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "react"; -import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers"; +import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { type DomEditSelection } from "./domEditing"; import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction"; import { useMarqueeGestures } from "./marqueeCommit"; @@ -16,17 +15,20 @@ import { import { useDomEditOverlayRects } from "./useDomEditOverlayRects"; import { OffCanvasIndicators, type OffCanvasRect } from "./OffCanvasIndicators"; import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures"; +import { useDomEditNudge } from "./useDomEditNudge"; import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay"; import { GridOverlay } from "./GridOverlay"; import type { GestureRecordingState } from "./GestureRecordControl"; -import { DomEditCropHandles } from "./DomEditCropHandles"; -import { DomEditRotateHandle } from "./DomEditRotateHandle"; +import { DomEditGroupChrome, DomEditSelectionChrome } from "./DomEditSelectionChrome"; import { hugRectForElement } from "./domEditOverlayCrop"; import { useCropOverlay } from "../../hooks/useCropOverlay"; import { readDomEditSelectionShapeStyles, resolveBoxChromeClass } from "./domEditOverlayShape"; import { useDomEditCompositionRect } from "./useDomEditCompositionRect"; import { useMountEffect } from "../../hooks/useMountEffect"; import { startOffCanvasIndicatorRefresh } from "./offCanvasIndicatorRefresh"; +import { CanvasContextMenu } from "./CanvasContextMenu"; +import type { ZOrderPatch } from "./canvasContextMenuZOrder"; +import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers"; // Re-exports for external consumers — preserving existing import paths. export { @@ -37,7 +39,6 @@ export { export { focusDomEditOverlayElement, hasDomEditRotationChanged, - resolveDomEditResizeGesture, resolveDomEditRotationGesture, } from "./domEditOverlayGestures"; export type { DomEditGroupPathOffsetCommit } from "./domEditOverlayGestures"; @@ -82,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 @@ -106,6 +120,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({ onRotationCommit, onStyleCommit, onMarqueeSelect, + onDeleteSelection, + onApplyZIndex, }: DomEditOverlayProps) { const overlayRef = useRef(null); const boxRef = useRef(null); @@ -122,8 +138,31 @@ export const DomEditOverlay = memo(function DomEditOverlay({ const snapGuidesRef = useRef(null); const rafPausedRef = useRef(false); + // Context menu state: position of the right-click that opened it. + // contextMenuSelection is the element the menu targets — captured at right-click + // time so the menu can open even before the React selection state settles. + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + sel: DomEditSelection; + } | null>(null); + const selectionRef = useRef(selection); selectionRef.current = selection; + + // Close the context menu whenever the selection moves off the element the menu + // targets (a click that reselects elsewhere, a deselect, or a preview reload + // that rebuilds the selection). Without this the menu can linger — orphaned — + // over a stale target after the underlying element is gone. A right-click that + // OPENS the menu also selects its target, so the common open path keeps the + // menu (same element) rather than immediately dismissing it. + useEffect(() => { + if (!contextMenu) return; + if (!selection || selection.element !== contextMenu.sel.element) { + setContextMenu(null); + } + }, [selection, contextMenu]); + const activeCompositionPathRef = useRef(activeCompositionPath); activeCompositionPathRef.current = activeCompositionPath; const groupSelectionsRef = useRef(groupSelections); @@ -242,6 +281,23 @@ export const DomEditOverlay = memo(function DomEditOverlay({ snapGuidesRef, }); + // Arrow-key nudge (1px, Shift = 10px) — commits through the same + // path-offset callbacks as a drag, one undo entry per key burst. + const { flushNudge } = useDomEditNudge({ + selection, + groupSelections, + allowCanvasMovement, + selectionRef, + overlayRectRef, + groupOverlayItemsRef, + gestureRef, + groupGestureRef, + blockedMoveRef, + onManualDragStartRef, + onPathOffsetCommitRef, + onGroupPathOffsetCommitRef, + }); + const marquee = useMarqueeGestures({ iframeRef, overlayRef, @@ -371,6 +427,38 @@ export const DomEditOverlay = memo(function DomEditOverlay({ e.stopPropagation(); }; + // Right-click: select element first (if not already selected), then open menu. + const handleContextMenu = useCallback( + async (event: React.MouseEvent) => { + event.preventDefault(); + + // If no element is selected yet, resolve it from the pointer position first. + const currentSel = selectionRef.current; + let activeSel: DomEditSelection | null = currentSel; + if (!currentSel) { + const pointerEvent = event as unknown as React.PointerEvent; + const resolved = await onCanvasPointerMoveRef.current(pointerEvent); + if (!resolved) return; // Nothing under the cursor — skip menu. + onSelectionChangeRef.current(resolved, { revealPanel: true }); + // Use `resolved` directly: React state (and therefore selectionRef) won't + // update synchronously after onSelectionChange — we'd be reading stale null. + activeSel = resolved; + } else { + // Check if the user right-clicked on an unselected element (hover target). + const hover = hoverSelectionRef.current; + if (hover && hover.element !== currentSel.element) { + onSelectionChangeRef.current(hover, { revealPanel: true }); + activeSel = hover; + } + } + + if (!activeSel) return; + setContextMenu({ x: event.clientX, y: event.clientY, sel: activeSel }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + return (
- focusDomEditOverlayElement(event.currentTarget as FocusableDomEditOverlay) - } + onPointerDownCapture={(event) => { + // A pointer gesture supersedes a pending nudge burst — commit it first + // so the gesture's member snapshot starts from the nudged position. + flushNudge(); + focusDomEditOverlayElement(event.currentTarget as FocusableDomEditOverlay); + }} onPointerDown={handleOverlayPointerDown} onMouseDown={handleOverlayMouseDown} onPointerMove={marquee.onPointerMove} onPointerLeave={() => onCanvasPointerLeaveRef.current()} onPointerUp={marquee.onPointerUp} onPointerCancel={marquee.onPointerCancel} + onContextMenu={handleContextMenu} > {hoverSelection && hoverRect && compRect.width > 0 && (
)} {hasGroupSelection && groupOverlayItems.length > 1 && groupBounds && compRect.width > 0 && ( - <> - {groupOverlayItems.map((item) => ( -