diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 32a6d5bc85..b56180bcf4 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -53,6 +53,19 @@ "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/nle/NLEContext.tsx", + "packages/studio/src/components/nle/PreviewPane.tsx", + "packages/studio/src/components/nle/AssetPreviewOverlay.tsx", + "packages/studio/src/player/components/TimelineOverlays.tsx", + "packages/studio/src/player/components/timelineClipChildren.tsx", + "packages/studio/src/player/components/timelineClipDragTypes.ts", + "packages/studio/src/player/hooks/useTimelinePlayerLoop.ts", + "packages/studio/src/utils/assetPreviewStore.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/**", @@ -90,6 +103,114 @@ "packages/cli/src/cloud/_gen/**", ], "ignoreExports": [ + // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. + { + "file": "packages/studio/src/player/lib/timelineElementHelpers.ts", + "exports": ["furthestClipEndFromDocument"], + }, + // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. + { + "file": "packages/studio/src/hooks/timelineEditingHelpers.ts", + "exports": ["patchIframeRootDuration"], + }, + // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. + { + "file": "packages/studio/src/components/sidebar/AssetContextMenu.tsx", + "exports": ["DeleteConfirm"], + }, + // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. + { + "file": "packages/studio/src/components/editor/domEditOverlayGeometry.ts", + "exports": ["overlayCornersCentroid", "elementCornerOverlayPoints", "orientedOverlayRect"], + }, + // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. + { + "file": "packages/studio/src/contexts/DomEditContext.tsx", + "exports": ["useDomEditActionsContextOptional"], + }, + // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. + { + "file": "packages/studio/src/contexts/StudioContext.tsx", + "exports": ["useStudioShellContextOptional"], + }, + // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. + { + "file": "packages/studio/src/components/editor/domEditOverlayGestures.ts", + "exports": ["ResizeHandle"], + }, + // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. + { + "file": "packages/studio/src/components/sidebar/assetHelpers.ts", + "exports": ["truncateMiddle", "formatDuration"], + }, + // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. + { + "file": "packages/studio/src/utils/studioHelpers.ts", + "exports": ["resolveDroppedAssetDimensions"], + }, + // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. + { + "file": "packages/studio/src/player/lib/timelineElementHelpers.ts", + "exports": ["furthestClipEndFromSource", "readTimelineElementZIndex"], + }, + // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. + { + "file": "packages/studio/src/components/editor/manualOffsetDrag.ts", + "exports": ["applyManualOffsetNudgeDraft", "applyManualOffsetNudgeCommit"], + }, + // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. + { + "file": "packages/studio/src/player/components/timelineEditing.ts", + "exports": [ + "applyTimelineAutoScrollStep", + "resolveTimelineAutoScrollLoopAction", + "resolveTimelineDragEscape", + "TimelineDragEscapeInput", + ], + }, + // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. + { + "file": "packages/studio/src/hooks/timelineEditingHelpers.ts", + "exports": [ + "shiftGsapPositionsBatch", + "syncTimingEditPreview", + "GsapMutationOutcome", + "readGsapMutationScriptText", + ], + }, + // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. + { + "file": "packages/studio/src/utils/timelineAssetDrop.ts", + "exports": [ + "setCompositionDurationToContent", + "extendCompositionDurationIfNeeded", + "fitTimelineAssetGeometry", + "resolveTimelineAssetCompositionSize", + ], + }, + // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. + { + "file": "packages/studio/src/player/components/timelineLayout.ts", + "exports": [ + "getTimelineRowTop", + "getTimelineRowFromY", + "DRAG_EXTEND_MARGIN_PX", + "getTimelineFitPps", + "getTimelineDisplayContentWidth", + "TRACKS_TOP_PAD", + "MIN_TIMELINE_EXTENT_S", + ], + }, + // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. + { + "file": "packages/studio/src/components/editor/domEditNudge.ts", + "exports": ["CANVAS_NUDGE_COMMIT_DEBOUNCE_MS"], + }, + // 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 @@ -308,6 +429,51 @@ // 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/domEditOverlayGeometry.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/nle/NLEContext.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/nle/NLELayout.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/nle/PreviewPane.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/sidebar/AssetContextMenu.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/timelineEditingHelpers.ts", + // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); + // studio-dnd pr22 removes this with the final config. + "packages/studio/src/player/components/Timeline.tsx", + // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); + // studio-dnd pr22 removes this with the final config. + "packages/studio/src/player/components/TimelineCanvas.tsx", + // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); + // studio-dnd pr22 removes this with the final config. + "packages/studio/src/player/components/TimelineOverlays.tsx", + // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); + // studio-dnd pr22 removes this with the final config. + "packages/studio/src/player/components/timelineClipDragTypes.ts", + // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); + // studio-dnd pr22 removes this with the final config. + "packages/studio/src/player/components/useTimelineClipDrag.ts", + // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); + // studio-dnd pr22 removes this with the final config. + "packages/studio/src/player/components/useTimelineMarqueeSelection.ts", + // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); + // studio-dnd pr22 removes this with the final config. + "packages/studio/src/player/hooks/useTimelinePlayer.ts", + // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); + // studio-dnd pr22 removes this with the final config. + "packages/studio/src/player/hooks/useTimelinePlayerLoop.ts", + // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); + // studio-dnd pr22 removes this with the final config. + "packages/studio/src/player/lib/timelineElementHelpers.ts", // sourcePatcher.ts: pre-existing internal clones between the inline-style // and attribute tag-patchers; only the PatchOperation type gained two // optional fields here, but the line shift makes fallow re-flag them. @@ -433,6 +599,22 @@ // 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/domEditOverlayGeometry.ts", + "packages/studio/src/components/editor/manualOffsetDrag.ts", + "packages/studio/src/player/components/timelineEditing.ts", + "packages/studio/src/player/lib/timelineElementHelpers.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/TimelineOverlays.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/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 diff --git a/packages/studio/src/components/editor/CanvasContextMenu.test.tsx b/packages/studio/src/components/editor/CanvasContextMenu.test.tsx new file mode 100644 index 0000000000..9039863213 --- /dev/null +++ b/packages/studio/src/components/editor/CanvasContextMenu.test.tsx @@ -0,0 +1,115 @@ +// @vitest-environment happy-dom +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { installReactActEnvironment, makeSelection } from "../../hooks/domSelectionTestHarness"; +import { CanvasContextMenu } from "./CanvasContextMenu"; +import type { DomEditSelection } from "./domEditing"; + +installReactActEnvironment(); + +let host: HTMLDivElement; +let root: Root | null = null; + +beforeEach(() => { + host = document.createElement("div"); + document.body.append(host); +}); + +afterEach(() => { + act(() => root?.unmount()); + root = null; + document.body.innerHTML = ""; +}); + +function renderMenu(props: { + selection: DomEditSelection; + onApplyZIndex?: () => void; + onDelete?: (selection: DomEditSelection) => void; +}) { + root = createRoot(host); + act(() => { + root!.render( + React.createElement(CanvasContextMenu, { + x: 10, + y: 10, + selection: props.selection, + onClose: () => {}, + onApplyZIndex: props.onApplyZIndex, + onDelete: props.onDelete, + }), + ); + }); +} + +/** All menu buttons live in the portal under document.body. */ +function menuButtons(): HTMLButtonElement[] { + return [...document.body.querySelectorAll("button")]; +} + +function hasDeleteItem(): boolean { + return menuButtons().some((b) => b.textContent?.includes("Delete")); +} + +function zOrderButtons(): HTMLButtonElement[] { + return menuButtons().filter((b) => !b.textContent?.includes("Delete")); +} + +describe("CanvasContextMenu — handler gating", () => { + it("renders all four z-order items, a divider, and Delete when both handlers are present", () => { + const el = document.createElement("div"); + el.id = "target"; + document.body.append(el); + + renderMenu({ + selection: makeSelection("Target", el), + onApplyZIndex: vi.fn(), + onDelete: vi.fn(), + }); + + expect(zOrderButtons()).toHaveLength(4); + expect(hasDeleteItem()).toBe(true); + // The divider only appears between the two groups. + expect(document.body.querySelector(".border-t")).not.toBeNull(); + }); + + it("hides every item and does NOT render the menu when no handlers are present", () => { + const el = document.createElement("div"); + el.id = "target"; + // A z-index that a stray optimistic write would clobber — assert it is + // untouched, since the menu must not mutate the DOM without a persist path. + el.style.zIndex = "3"; + document.body.append(el); + + renderMenu({ selection: makeSelection("Target", el) }); + + // No menu opened at all — no buttons, no dead-end items, no DOM mutation. + expect(menuButtons()).toHaveLength(0); + expect(document.body.querySelector(".fixed.z-50")).toBeNull(); + expect(el.style.zIndex).toBe("3"); + }); + + it("shows only the z-order items (no Delete, no divider) when onDelete is absent", () => { + const el = document.createElement("div"); + el.id = "target"; + document.body.append(el); + + renderMenu({ selection: makeSelection("Target", el), onApplyZIndex: vi.fn() }); + + expect(zOrderButtons()).toHaveLength(4); + expect(hasDeleteItem()).toBe(false); + expect(document.body.querySelector(".border-t")).toBeNull(); + }); + + it("shows only Delete (no z-order items, no divider) when onApplyZIndex is absent", () => { + const el = document.createElement("div"); + el.id = "target"; + document.body.append(el); + + renderMenu({ selection: makeSelection("Target", el), onDelete: vi.fn() }); + + expect(zOrderButtons()).toHaveLength(0); + expect(hasDeleteItem()).toBe(true); + expect(document.body.querySelector(".border-t")).toBeNull(); + }); +}); diff --git a/packages/studio/src/components/editor/CanvasContextMenu.tsx b/packages/studio/src/components/editor/CanvasContextMenu.tsx new file mode 100644 index 0000000000..a7a124128f --- /dev/null +++ b/packages/studio/src/components/editor/CanvasContextMenu.tsx @@ -0,0 +1,198 @@ +/** + * Right-click context menu for a selected canvas element. + * + * Mirrors the look, positioning, and dismiss behavior of + * player/components/ClipContextMenu.tsx — portaled to document.body, + * overflow-adjusted, dismissed on outside-click or Escape via + * useContextMenuDismiss. + * + * ── Wiring (z-order persistence) ───────────────────────────────────────────── + * Z-index changes are applied optimistically to the live iframe element(s) via + * `resolveZOrderChange`, which returns a MULTI-element patch list (tie-aware: + * moving a target past an equal-z sibling can require renumbering the affected + * set). The patches are surfaced through the `onApplyZIndex` prop. + * + * The prop MUST be wired at the call site to route through the full persist + * path. PreviewOverlays.tsx builds the per-patch PatchTargets (the selected + * element carries its full selection identity; sibling elements are iframe DOM + * nodes, so their id / selector are derived from the node and they share the + * selection's sourceFile) and forwards them to handleDomZIndexReorderCommit. + * ───────────────────────────────────────────────────────────────────────────── + */ + +import { memo } from "react"; +import { createPortal } from "react-dom"; +import type { DomEditSelection } from "./domEditing"; +import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss"; +import { + isZOrderActionEnabled, + resolveZOrderChange, + type ZOrderPatch, +} from "./canvasContextMenuZOrder"; + +interface CanvasContextMenuProps { + /** Viewport x of the right-click event. */ + x: number; + /** Viewport y of the right-click event. */ + y: number; + selection: DomEditSelection; + onClose: () => void; + /** + * Called with the resolved z-order patch list after an optimistic DOM update. + * Each patch is an { element, zIndex } pair (the target and, when a renumber + * is needed, affected siblings). Wire to handleDomZIndexReorderCommit (see + * module-level wiring comment). + */ + onApplyZIndex?: (patches: ZOrderPatch[]) => void; + /** + * Delete the selected element. Wire to handleDomEditElementDelete from + * useDomEditActionsContext — same path as the Delete/Backspace hotkey. + * Absent when the caller wires no delete persist path (e.g. a legacy mount): + * the Delete item is then hidden rather than shown as a silent no-op. + */ + onDelete?: (selection: DomEditSelection) => void; +} + +type ZAction = "bring-forward" | "send-backward" | "bring-to-front" | "send-to-back"; + +const Z_ACTIONS: Array<{ action: ZAction; label: string }> = [ + { action: "bring-forward", label: "Bring forward" }, + { action: "send-backward", label: "Send backward" }, + { action: "bring-to-front", label: "Bring to front" }, + { action: "send-to-back", label: "Send to back" }, +]; + +export const CanvasContextMenu = memo(function CanvasContextMenu({ + x, + y, + selection, + onClose, + onApplyZIndex, + onDelete, +}: CanvasContextMenuProps) { + const menuRef = useContextMenuDismiss(onClose); + + // Gate each item group on the presence of its persist handler. Without the + // handler the action can't be persisted, so showing it would be a dead-end: + // a z-write reverts on reload and Delete silently no-ops. Hide the group + // instead. If nothing is actionable (a legacy mount with no handlers at all), + // don't render the menu — an empty menu is itself a dead-end. + const hasZActions = Boolean(onApplyZIndex); + const hasDelete = Boolean(onDelete); + const hasDivider = hasZActions && hasDelete; + + // Overflow correction — match ClipContextMenu approach. Only the rendered + // groups contribute height (keeps positioning correct when a group is hidden). + const menuWidth = 200; + const menuHeight = + 8 + (hasZActions ? Z_ACTIONS.length * 28 : 0) + (hasDivider ? 1 : 0) + (hasDelete ? 28 : 0) + 8; // padding + items + divider + delete + padding + const overflowY = y + menuHeight - window.innerHeight; + const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x; + const adjustedY = overflowY > 0 ? y - overflowY - 8 : y; + + const el = selection.element; + + function handleZAction(action: ZAction) { + // No persist handler → do NOT touch the live iframe DOM. An optimistic + // write with nothing to persist just reverts on the next reload. + if (!onApplyZIndex) return; + const patches = resolveZOrderChange(el, action); + if (patches === null) return; + // Optimistic update — visible immediately even before persist completes. + for (const patch of patches) { + patch.element.style.zIndex = String(patch.zIndex); + const view = patch.element.ownerDocument?.defaultView; + if (view && view.getComputedStyle(patch.element).position === "static") { + patch.element.style.position = "relative"; + } + } + onApplyZIndex(patches); + onClose(); + } + + function handleDelete() { + if (!onDelete) return; + onDelete(selection); + onClose(); + } + + if (!hasZActions && !hasDelete) return null; + + // The menu is portaled to document.body, but in the React tree it is still a + // child of the DomEditOverlay
. React synthetic events bubble through the + // REACT tree (not the DOM tree), so a click on any menu control would otherwise + // bubble into the overlay's onPointerDown / onMouseDown handlers — which + // preventDefault() to start a marquee and re-resolve the selection. That + // preventDefault cancels the button's own click and the item action never runs. + // + // Stop pointer/mouse propagation at the menu root so overlay gesture handlers + // never see these events, and drive the item actions on pointerDown (which + // fires before any outside-click / dismiss logic can unmount the menu). + const stopBubble = (e: React.SyntheticEvent) => { + e.stopPropagation(); + }; + + return createPortal( +
{ + // Keep a right-click on the menu itself from re-opening / bubbling. + e.preventDefault(); + e.stopPropagation(); + }} + > + {hasZActions && + Z_ACTIONS.map(({ action, label }) => { + const enabled = isZOrderActionEnabled(el, action); + return ( + + ); + })} + + {hasDivider &&
} + + {hasDelete && ( + + )} +
, + document.body, + ); +}); diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index c0adaef18c..8d89a3637d 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -73,6 +73,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; diff --git a/packages/studio/src/components/editor/anchoredResizeReleaseShift.test.ts b/packages/studio/src/components/editor/anchoredResizeReleaseShift.test.ts new file mode 100644 index 0000000000..7e97d65d33 --- /dev/null +++ b/packages/studio/src/components/editor/anchoredResizeReleaseShift.test.ts @@ -0,0 +1,112 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from "vitest"; +import { + applyStudioBoxSize, + applyStudioPathOffset, + readStudioBoxSize, + reapplyPositionEditsAfterSeek, +} from "./manualEditsDom"; +import { buildBoxSizePatches, buildPathOffsetPatches } from "./manualEditsDomPatches"; +import { createManualOffsetDragMember, applyManualOffsetDragCommit } from "./manualOffsetDrag"; +import type { PatchOperation } from "../../utils/sourcePatcher"; +import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers"; + +/** + * Center-anchored corner resize (CapCut model): the element scales about its + * CENTER, which must stay planted across the whole gesture — including after + * release, on every corner and at any rotation. + * + * This file lands here with ONLY the persist round-trip test, which exercises the + * real apply → persist → reload symbols that already exist at this point in the + * stack. The two center-anchor CONVERGENCE tests (the release-shift root cause) + * drive the exported `computeNextResizeAnchor` accumulator, which is extracted from + * the resize pointermove branch of `useDomEditOverlayGestures.ts`. That gesture code + * lands later in the stack (with the canvas glue swap), so those two tests are added + * to this file at that point — importing the real production helper rather than a + * test-local copy, so they can never pass against a stand-in that drifts from the + * shipped math. + */ + +afterEach(() => { + document.body.innerHTML = ""; +}); + +/** Apply a built PatchOperation[] to a live element, mirroring sourcePatcher's + * inline-style / attribute application — i.e. what the persisted source carries + * when it is re-parsed into the DOM on the next preview load. */ +function applyPatchesToElement(el: HTMLElement, ops: PatchOperation[]): void { + for (const op of ops) { + if (op.type === "inline-style") { + if (op.value === null) el.style.removeProperty(op.property); + else el.style.setProperty(op.property, op.value); + } else if (op.type === "attribute") { + if (op.value === null) el.removeAttribute(op.property); + else el.setAttribute(op.property, op.value); + } + } +} + +/** Net translate applied to an element, resolving the studio offset var() + * expression to its px value so we compare the actually-rendered translation. */ +function resolvedTranslatePx(el: HTMLElement): { x: number; y: number } { + const raw = el.style.getPropertyValue("translate").trim(); + if (!raw || raw === "none") return { x: 0, y: 0 }; + const vx = Number.parseFloat(el.style.getPropertyValue("--hf-studio-offset-x")) || 0; + const vy = Number.parseFloat(el.style.getPropertyValue("--hf-studio-offset-y")) || 0; + const parts = splitTopLevelWhitespace(raw); + const parseAxis = (part: string, varVal: number): number => { + if (part && part.includes("--hf-studio-offset")) return varVal; + const n = Number.parseFloat(part); + return Number.isFinite(n) ? n : 0; + }; + return { + x: parseAxis(parts[0] ?? "", vx), + y: parseAxis(parts[1] ?? "", vy), + }; +} + +describe("center-anchored corner resize — no shift after release", () => { + it("net translate after persist+reload equals the committed anchor offset (non-GSAP)", () => { + // The committed offset flows through the real apply → persist → reload chain + // unchanged (this hop was proved clean; the shift is upstream in the anchor + // loop, tested with the gesture code, not in persistence). + const el = document.createElement("div"); + el.style.setProperty("width", "200px"); + el.style.setProperty("height", "100px"); + document.body.appendChild(el); + + const anchorDx = -30; + const anchorDy = -18; + const finalSize = { width: 240, height: 130 }; + + applyStudioBoxSize(el, finalSize); + const memberResult = createManualOffsetDragMember({ + key: "k", + selection: { element: el } as never, + element: el, + rect: { left: 0, top: 0, width: 240, height: 130, editScaleX: 1, editScaleY: 1 }, + }); + expect(memberResult.ok).toBe(true); + if (!memberResult.ok) return; + + const finalOffset = applyManualOffsetDragCommit(memberResult.member, anchorDx, anchorDy); + + applyStudioBoxSize(el, finalSize); + const patches = buildBoxSizePatches(el); + applyStudioPathOffset(el, finalOffset); + patches.push(...buildPathOffsetPatches(el)); + + expect(resolvedTranslatePx(el)).toEqual({ x: anchorDx, y: anchorDy }); + + // Persist → fresh element re-parsed from source → reload re-stamp. + const reloaded = document.createElement("div"); + reloaded.style.setProperty("width", "200px"); + reloaded.style.setProperty("height", "100px"); + document.body.appendChild(reloaded); + applyPatchesToElement(reloaded, patches); + reapplyPositionEditsAfterSeek(reloaded.ownerDocument); + + expect(resolvedTranslatePx(reloaded)).toEqual({ x: anchorDx, y: anchorDy }); + expect(readStudioBoxSize(reloaded)).toEqual(finalSize); + }); +}); diff --git a/packages/studio/src/components/editor/canvasContextMenuZOrder.test.ts b/packages/studio/src/components/editor/canvasContextMenuZOrder.test.ts new file mode 100644 index 0000000000..a0ae6b4f71 --- /dev/null +++ b/packages/studio/src/components/editor/canvasContextMenuZOrder.test.ts @@ -0,0 +1,435 @@ +// @vitest-environment jsdom +import { describe, expect, it } from "vitest"; +import { + isZOrderActionEnabled, + parseZIndex, + resolveZOrderChange, + type ZOrderAction, + type ZOrderPatch, +} from "./canvasContextMenuZOrder"; + +// ── helpers ─────────────────────────────────────────────────────────────────── +// +// In jsdom getBoundingClientRect returns all zeros, so the target rect is 0×0 +// and getOverlappingFamily returns the whole family — i.e. every sibling is +// treated as overlapping. That makes forward/backward and front/back exercise +// the same scoped set here, which is exactly what we want for order logic. + +function makeEl(id: string, zIndex?: string): HTMLElement { + const el = document.createElement("div"); + el.id = id; + if (zIndex !== undefined) el.style.zIndex = zIndex; + return el; +} + +/** + * Build a parent and append `[target, ...siblings]` in DOM order. Each spec is + * [id, z]. The target's z is `targetZ`; it is appended FIRST unless + * `targetLast` is set, in which case it is appended LAST (later in DOM). + */ +function makeFamily( + targetZ: string, + siblingSpecs: Array<[string, string]>, + opts: { targetLast?: boolean } = {}, +): { target: HTMLElement; parent: HTMLElement; byId: Record } { + const parent = document.createElement("div"); + const target = makeEl("target", targetZ); + const siblings = siblingSpecs.map(([id, z]) => makeEl(id, z)); + const byId: Record = { target }; + for (const s of siblings) byId[s.id] = s; + if (opts.targetLast) { + for (const s of siblings) parent.appendChild(s); + parent.appendChild(target); + } else { + parent.appendChild(target); + for (const s of siblings) parent.appendChild(s); + } + return { target, parent, byId }; +} + +/** Resolve a z-order change and assert it produced patches (fails otherwise). */ +function resolveZOrderPatches(target: HTMLElement, action: ZOrderAction): ZOrderPatch[] { + const patches = resolveZOrderChange(target, action); + expect(patches).not.toBeNull(); + if (!patches) throw new Error("expected z-order patches"); + return patches; +} + +/** Look up a patch for a given element id in a patch list. */ +function patchFor(patches: ZOrderPatch[], byId: Record, id: string) { + return patches.find((p) => p.element === byId[id]); +} + +/** Apply patches, then return the render-ordered ids (bottom→top). */ +function renderOrderIds( + parent: HTMLElement, + byId: Record, + patches: ZOrderPatch[], +): string[] { + for (const p of patches) p.element.style.zIndex = String(p.zIndex); + const children = Array.from(parent.children) as HTMLElement[]; + const withPos = children.map((el, domIndex) => ({ + id: Object.keys(byId).find((k) => byId[k] === el) ?? el.id, + z: parseZIndex(el.style.zIndex || "0"), + domIndex, + })); + withPos.sort((a, b) => a.z - b.z || a.domIndex - b.domIndex); + return withPos.map((e) => e.id); +} + +// ── parseZIndex ─────────────────────────────────────────────────────────────── + +describe("parseZIndex", () => { + it("parses integers", () => { + expect(parseZIndex("5")).toBe(5); + expect(parseZIndex("0")).toBe(0); + expect(parseZIndex("-3")).toBe(-3); + }); + + it("treats 'auto' / null / undefined / empty as 0", () => { + expect(parseZIndex("auto")).toBe(0); + expect(parseZIndex(null)).toBe(0); + expect(parseZIndex(undefined)).toBe(0); + expect(parseZIndex("")).toBe(0); + }); +}); + +// ── distinct-z fast path (single-element patch) ──────────────────────────────── + +describe("resolveZOrderChange – distinct z values (fast path)", () => { + it("bring-to-front moves target above all with a single patch", () => { + const { target, byId } = makeFamily("2", [ + ["a", "1"], + ["b", "5"], + ["c", "3"], + ]); + const patches = resolveZOrderPatches(target, "bring-to-front"); + expect(patches).toHaveLength(1); + expect(patchFor(patches, byId, "target")?.zIndex).toBe(6); + }); + + it("bring-to-front returns null when already on top", () => { + const { target } = makeFamily("6", [ + ["a", "1"], + ["b", "5"], + ["c", "3"], + ]); + expect(resolveZOrderChange(target, "bring-to-front")).toBeNull(); + }); + + it("send-to-back moves target below all", () => { + const { target, byId, parent } = makeFamily("3", [ + ["a", "1"], + ["b", "5"], + ["c", "2"], + ]); + const patches = resolveZOrderPatches(target, "send-to-back"); + // target must end up strictly below the current min (1) in render order. + expect(renderOrderIds(parent, byId, patches)[0]).toBe("target"); + }); + + it("send-to-back returns null when already at back", () => { + const { target } = makeFamily("0", [ + ["a", "1"], + ["b", "5"], + ["c", "3"], + ]); + expect(resolveZOrderChange(target, "send-to-back")).toBeNull(); + }); + + it("bring-forward steps up exactly one in render order", () => { + const { target, byId, parent } = makeFamily("2", [ + ["a", "1"], + ["b", "4"], + ["c", "7"], + ]); + // render order bottom→top: a(1), target(2), b(4), c(7). forward → above b. + const patches = resolveZOrderPatches(target, "bring-forward"); + expect(renderOrderIds(parent, byId, patches)).toEqual(["a", "b", "target", "c"]); + }); + + it("send-backward steps down exactly one in render order", () => { + const { target, byId, parent } = makeFamily("5", [ + ["a", "1"], + ["b", "3"], + ["c", "8"], + ]); + // bottom→top: a(1), b(3), target(5), c(8). backward → below b. + const patches = resolveZOrderPatches(target, "send-backward"); + expect(renderOrderIds(parent, byId, patches)).toEqual(["a", "target", "b", "c"]); + }); + + it("bring-forward returns null when already top of set", () => { + const { target } = makeFamily("8", [ + ["a", "1"], + ["b", "4"], + ["c", "7"], + ]); + expect(resolveZOrderChange(target, "bring-forward")).toBeNull(); + }); + + it("send-backward returns null when already bottom of set", () => { + const { target } = makeFamily("0", [ + ["a", "1"], + ["b", "3"], + ["c", "8"], + ]); + expect(resolveZOrderChange(target, "send-backward")).toBeNull(); + }); + + it("returns null when no siblings", () => { + const target = makeEl("solo", "2"); + document.createElement("div").appendChild(target); + for (const action of [ + "bring-forward", + "send-backward", + "bring-to-front", + "send-to-back", + ] as ZOrderAction[]) { + expect(resolveZOrderChange(target, action)).toBeNull(); + } + }); +}); + +// ── DOM-order ties (the repro) ───────────────────────────────────────────────── + +describe("resolveZOrderChange – DOM-order ties (repro: equal z)", () => { + it("send-backward: tied target LATER in DOM (visually on top) can go below", () => { + // img#a (z=0, earlier in DOM) then video#target (z=0, later) → video paints + // on top. send-backward must put target below the image. + const { target, byId, parent } = makeFamily("0", [["a", "0"]], { targetLast: true }); + const patches = resolveZOrderPatches(target, "send-backward"); + expect(renderOrderIds(parent, byId, patches)).toEqual(["target", "a"]); + // target ends strictly below the image. + const tz = patchFor(patches, byId, "target")?.zIndex ?? 0; + expect(tz).toBeGreaterThanOrEqual(0); + }); + + it("send-to-back: tied target LATER in DOM goes to the very back", () => { + const { target, byId, parent } = makeFamily("0", [["a", "0"]], { targetLast: true }); + const patches = resolveZOrderPatches(target, "send-to-back"); + expect(renderOrderIds(parent, byId, patches)[0]).toBe("target"); + }); + + it("bring-forward: tied target EARLIER in DOM (visually below) can go above", () => { + // target#target (z=0, earlier) then #a (z=0, later) → a paints on top. + // bring-forward on target must lift it above a. + const { target, byId, parent } = makeFamily("0", [["a", "0"]]); + const patches = resolveZOrderPatches(target, "bring-forward"); + expect(renderOrderIds(parent, byId, patches)).toEqual(["a", "target"]); + }); + + it("bring-to-front: tied target EARLIER in DOM goes to the very front", () => { + const { target, byId, parent } = makeFamily("0", [["a", "0"]]); + const patches = resolveZOrderPatches(target, "bring-to-front"); + const order = renderOrderIds(parent, byId, patches); + expect(order[order.length - 1]).toBe("target"); + }); + + it("send-backward: tied target EARLIER in DOM is already at back → null", () => { + // target earlier + a later, both z=0. target already paints below a. + const { target } = makeFamily("0", [["a", "0"]]); + expect(resolveZOrderChange(target, "send-backward")).toBeNull(); + expect(resolveZOrderChange(target, "send-to-back")).toBeNull(); + }); + + it("bring-forward: tied target LATER in DOM is already on top → null", () => { + const { target } = makeFamily("0", [["a", "0"]], { targetLast: true }); + expect(resolveZOrderChange(target, "bring-forward")).toBeNull(); + expect(resolveZOrderChange(target, "bring-to-front")).toBeNull(); + }); + + it("renumber emits a real patch per changed element and none for the unchanged (minimal, no no-ops)", () => { + // Three tied at z=0, target in the middle of DOM order. Sending it back must + // renumber to distinct values but leave the target (which keeps its bottom + // slot's value 0) unpatched — and every emitted patch must be a genuine change. + const parent = document.createElement("div"); + const a = makeEl("a", "0"); + const target = makeEl("target", "0"); + const b = makeEl("b", "0"); + parent.append(a, target, b); + const originalZ = new Map([ + [a, 0], + [target, 0], + [b, 0], + ]); + // render order bottom→top by (z, dom): a, target, b. send-backward → below a. + const patches = resolveZOrderPatches(target, "send-backward"); + // Every emitted patch is a REAL change: its new z differs from the old z. + for (const p of patches) expect(p.zIndex).not.toBe(originalZ.get(p.element)); + // target renumbers to 0 (its existing value) → it must NOT be in the patch set. + expect(patchFor(patches, { a, target, b }, "target")).toBeUndefined(); + // No two patches collide on the same element (a well-formed minimal set). + expect(new Set(patches.map((p) => p.element)).size).toBe(patches.length); + for (const p of patches) p.element.style.zIndex = String(p.zIndex); + expect(renderOrderIds(parent, { a, target, b }, [])).toEqual(["target", "a", "b"]); + }); +}); + +// ── overlap scoping (real getBoundingClientRect) ──────────────────────────────── +// +// jsdom's getBoundingClientRect is 0×0, so getOverlappingFamily keeps the whole +// family and the SCOPED (overlapping-only) path is never exercised above. These +// mock rects so a sibling can be genuinely NON-overlapping and thus non-scoped. + +interface Rect { + left: number; + top: number; + right: number; + bottom: number; +} +function setRect(el: HTMLElement, r: Rect): void { + el.getBoundingClientRect = (): DOMRect => + ({ + left: r.left, + top: r.top, + right: r.right, + bottom: r.bottom, + width: r.right - r.left, + height: r.bottom - r.top, + x: r.left, + y: r.top, + toJSON: () => ({}), + }) as DOMRect; +} + +describe("resolveZOrderChange – overlap scoping preserves untouched non-scoped pairs (#2202)", () => { + it("send-backward renumber keeps a scoped sibling above an untouched NON-overlapping one", () => { + // A (z5, overlaps target) and target (z5) are tied and overlap; C (z3) does NOT + // overlap target, so it is non-scoped. Old renumber sent the scoped set to + // 0..n-1 (A→1), dropping A BELOW C (z3) — an untouched (A, C) pair inverting. + // The band-preserving renumber keeps the scoped block above C: A→6, C untouched. + const parent = document.createElement("div"); + const a = makeEl("a", "5"); + const target = makeEl("target", "5"); + const c = makeEl("c", "3"); + parent.append(a, target, c); + setRect(a, { left: 0, top: 0, right: 10, bottom: 10 }); + setRect(target, { left: 0, top: 0, right: 10, bottom: 10 }); + setRect(c, { left: 100, top: 100, right: 110, bottom: 110 }); // disjoint → non-scoped + const byId = { a, target, c }; + + const patches = resolveZOrderPatches(target, "send-backward"); + // C (untouched, non-scoped) is never patched. + expect(patchFor(patches, byId, "c")).toBeUndefined(); + for (const p of patches) p.element.style.zIndex = String(p.zIndex); + const order = renderOrderIds(parent, byId, []); + // Deliberate move: target below a. Preserved untouched pair: a stays above c. + expect(order.indexOf("target")).toBeLessThan(order.indexOf("a")); + expect(order.indexOf("a")).toBeGreaterThan(order.indexOf("c")); + }); + + it("scopes forward/backward to the overlapping set (a non-overlapping sibling is ignored)", () => { + // target (z1) overlaps a (z2) only; far (z5) does not overlap target. bring- + // forward must step target above a (its sole overlapping neighbour), NOT chase + // the non-overlapping far — proving the scoping actually runs with real rects. + const parent = document.createElement("div"); + const target = makeEl("target", "1"); + const a = makeEl("a", "2"); + const far = makeEl("far", "5"); + parent.append(target, a, far); + setRect(target, { left: 0, top: 0, right: 10, bottom: 10 }); + setRect(a, { left: 5, top: 5, right: 15, bottom: 15 }); // overlaps target + setRect(far, { left: 200, top: 200, right: 210, bottom: 210 }); // disjoint + const byId = { target, a, far }; + + const patches = resolveZOrderPatches(target, "bring-forward"); + // far is untouched (not in the overlapping scope). + expect(patchFor(patches, byId, "far")).toBeUndefined(); + for (const p of patches) p.element.style.zIndex = String(p.zIndex); + const order = renderOrderIds(parent, byId, []); + // target rose just above its overlapping neighbour a, staying below far. + expect(order.indexOf("target")).toBeGreaterThan(order.indexOf("a")); + expect(order.indexOf("target")).toBeLessThan(order.indexOf("far")); + }); +}); + +// ── non-painting sibling hygiene ─────────────────────────────────────────────── + +describe("resolveZOrderChange – excludes non-painting siblings", () => { + it("ignores