Files
hyperframes/packages/studio/src/components/editor/DomEditOverlay.test.ts
T
Miguel Ángelandukimsanov df29fa7a5e feat(studio): revamps Studio + improves code quality (#2291)
* feat(studio): glue API coexistence layer for the NLE swap

What: extends 21 glue files so the OLD timeline/canvas engine and the NEW
NLE components type-check side by side: playerStore (multi-select setters,
zoom pin, snap toggle, non-reactive scale scratch), drag-state types gain
optional NLE fields, timelineLayout/timelineAssetDrop/timelineEditingHelpers/
timelineEditing/timelineElementHelpers/studioHelpers/assetHelpers gain the
NLE exports, DomEditOverlay + gestures + AssetContextMenu + Timeline props
gain optional callbacks/params, contexts gain *Optional hooks, and
TimelineEditCallbacks.onMoveElements becomes a bivariant method accepting
both engines' change shapes. patchDocumentRootDuration's test rides along.

Why: this is the keystone that dissolves the old "welded glue" problem —
every symbol the NLE components need is ADDED next to what the old engine
still uses, so the engine components and the swaps can land as separate
reviewable PRs.

How: 15 authored intermediate files (main content + additive symbols; no
behavior changes — new fields optional, new callbacks unused until wired)
plus 6 files whose final content is already purely additive. New exports
without consumers yet carry TEMP(studio-dnd) ignoreExports entries, removed
by the app-shell swap.

Test plan: tsc --noEmit in studio + studio-server (verifies BOTH engines
compile); bunx vitest run (full suite green incl. the 6 new
patchDocumentRootDuration tests); fallow audit clean.

* 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 <Timeline> 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.

* feat(studio): NLE shell assembly (unwired)

What: EditorShell (the full editor layout replacing NLELayout +
StudioPreviewArea), TimelinePane (timeline host with sub-comp rebasing) and
useTimelineEditCallbacks (the callback bag bridging store edits to the
timeline), all unwired.

Why: the shell that App swaps to in the final step; reviewing it standalone
keeps that swap PR small.

How: new files against the coexistence layer; TEMP(studio-dnd) entries
until App mounts EditorShell in the app-shell swap.

Test plan: tsc --noEmit; bunx vitest run (suite unchanged); fallow audit
clean.

* feat(studio): timeline glue swap — Timeline/TimelineCanvas onto the NLE engine

What: flips the timeline glue to its final form (23 files): Timeline and
TimelineCanvas rebuilt on TimelineLanes/TimelineOverlays, useTimelineClipDrag
drives preview/commit through the new drag engine, range selection goes
multi-select, playback loop moves to useTimelinePlayerLoop. Deletes the 9
old-engine files this orphans (group drag, marquee selection, snap targets,
layer gutter, selection overlays + their suites) — each is compile- or
gate-forced by this swap, verified by probe.

Why: second swap step; timeline-only, canvas and App untouched.

How: modified files to final content + forced deletions.
playerStore/timelineEditing/timelineCallbacks stay at their coexistence
form until the app swap (the old App still runs on them).

Test plan: tsc --noEmit; bunx vitest run (full suite); fallow audit clean.

* feat(studio): clip thumbnail modules

What: ImageThumbnail (+tests) and thumbnailUtils (+tests) — frame decode
with SVG/AVIF format fallbacks and rounded-corner clipping — plus
VideoThumbnail updates.

Why: the decode layer for timeline clip thumbnails, ahead of the visual
refresh that renders them.

How: new modules + one modified file; purely presentational.

Test plan: bunx vitest run on both test files; tsc --noEmit; fallow audit
clean.

* feat(studio): assets/blocks panel behaviors + preview helpers

What: blocks tab install flow, right-panel and global drag-overlay polish,
music beat analysis and clip-content rendering hooks, and the
preview-helper utilities backing asset preview.

Why: completes the studio NLE stack on top of the visual refresh.

How: modified files only (kept as one PR: splitting further would produce
sub-150-LOC fragments of interdependent panel glue).

Test plan: bunx vitest run studioPreviewHelpers/studioUrlState suites; tsc
--noEmit; fallow audit clean.

* fix(studio): restore timeline playback loop

* fix(studio): restore missing GSAP helpers module

* refactor(studio): split timeline GSAP helpers

* style(studio): keep timeline helper under size limit

* fix(studio): restore timeline overlays module

* fix(studio): remove stale GSAP import

* fix(studio): restore canonical timeline dependencies

* style(studio): format restored timeline helpers

* style(studio): satisfy helper line limit

* fix(studio): repair rebuilt timeline integration

* feat(studio): complete rebuilt NLE cutover

* fix(studio): guard project and timeline race boundaries

* fix(studio): preserve graded resize and crop geometry

* fix(studio): log resize/rotate commit failures, move anchor accumulator to resize-local

* fix(studio): treat duration-0 tweens as static holds and settle resize position before persist

Instant holds (to()/fromTo() with duration 0) were classified as animated
tweens by every commit route, so resizing or rotating them converted the
hold into a corrupt duration-0 keyframes tween (new value at 0%, old at
100%) that GSAP drops; panel edits appended a losing set. A shared
isInstantHold() now routes them through the static replace-in-place path,
and percentage math guards zero-duration windows.

Separately, anchored-corner resizes painted 3-5 frames at the new size but
old position while the offset persist round-tripped the server. The commit
path now applies the corrected GSAP position synchronously before awaiting
the offset persist, mirroring the scale route's settle.

* feat(studio): gesture-transaction seam with commit observability

Introduce runGestureTransaction — one owner for a gesture commit's
settle -> persist -> record lifecycle. It settles the live DOM
synchronously before any async persist, folds every mutation into one
undo entry via a per-transaction coalesceKey, restores pre-gesture state
exactly once on failure, and asserts (dev console) + reports (PostHog:
commit_transaction / commit_invariant_violation / commit_transaction_failed)
that a persist never changes pixels. The box-size resize path is migrated
onto it; the ad hoc per-route coalesceKey/reload handling is removed.

Extract the resize draft-rect math into resizeDraft.ts to keep the
gesture-handler file under the size cap.

Also: keep url_hash telemetry to the route slug only (drop the query
string, which carried the user's selected element id/selector), and gate
the [hf-resize] diagnostics behind localStorage hf-resize-debug so they
ship as opt-in tracing rather than console noise.

* fix(studio): transaction owns the undo label

The coalesced history entry took the last sub-mutation's label, so a
resize surfaced as "Move layer" (the offset persist) in undo/redo. The
seam now stamps tx.label on every wrapped mutation, so the folded entry
reads as the gesture.

* fix(studio): atomic static size/position commits (no data loss)

Static resize/position holds updated an existing set via delete+add — two
undo entries, and a delete that succeeded before a failed add lost the
hold on disk. Use one in-place update-properties mutation when a set
exists (one undo entry, no partial-failure window). The keyframed-hold
heal that can't be expressed as a property update now adds before it
deletes, so any single failure leaves a recoverable duplicate, never a
lost hold. Transaction-owned commits are tracked via a WeakSet so the
heal path never double-wraps an already-wrapped gesture.

* fix(core): restore timed-clip visibility after a forced timeline rebind

__hfForceTimelineRebind force-rendered the re-registered timeline but never
re-ran the per-[data-start] visibility pass, so after undo or soft reload
every clip rendered regardless of its time window until a full page reload.
Extract the visibility loop into syncTimedElementVisibility and call it from
both syncMediaForCurrentState (unchanged) and the rebind.

* fix(studio): atomic z-order/keyframe/split commits, one undo entry each

Three edit-commit paths hardened onto the one-transaction invariant:

- Z-order reorder (useElementLifecycleOps): N per-element writes now fold
  into one undo entry (coalesceMs Infinity) and, on a failed persist,
  restore already-written files to disk so no partial reorder survives.
- Enable-keyframes (useEnableKeyframes/useGsapKeyframeOps): the intermediate
  convert phase no longer full-reloads the preview (skipReload), killing the
  black-flash remount; convert + edit share one coalesce key = one undo entry.
- Razor split-all (useRazorSplit): snapshot before the batch and restore on
  any failure, so a mid-batch error never leaves un-revertable partial splits.

Shared file-history helpers (RecordEditInput, DomEditCommitBaseParams,
readProjectFileContent, restoreFilesToOriginal) dedupe the rollback/commit
logic across these paths. Commit options thread as one partial object rather
than field-by-field. Test setup extracted into colocated helpers.

* fix(studio): fold multi-step edits into one undo entry; guard text revert

- Gesture recording (useGestureCommit): the per-property-group commits now
  share one coalesce key and only the last reloads, so a recording is one
  undo entry and one preview reload instead of up to four.
- Delete selected keyframes (deleteSelectedKeyframes, split out of
  timelineEditingHelpers): N removals fold into one coalesced undo entry
  with a single reload.
- Text-field commit (useDomEditTextCommits): commitDomTextFields now uses
  the same version-guarded revert as handleDomTextCommit, so a stale failed
  commit can no longer stomp a newer successful one.

* feat(studio): batch a gesture's mutations into one atomic server write

A transaction that emits N mutations previously did N sequential POSTs,
each rewriting the file and soft-reloading — the root of the multi-phase
persist window. Add a gsap-mutations-batch endpoint that validates every
mutation up front, applies them in one in-memory rewrite chain, and writes
the file once (all-or-nothing: an invalid entry rejects the whole batch,
no partial write). The seam buffers a transaction's commits and, when more
than one targets the same file, dispatches a single batch — one write, one
history entry, one reload. The batch capability rides on the existing
commit-function reference; no option fields are threaded through callers.

* fix(studio): soften off-canvas indicator outline to 30% opacity

The dashed off-canvas selection outline at 60% was noisy with many
protruding elements on screen; drop the resting opacity to 30% (hover
still restores full opacity so it stays discoverable).

* fix(studio): drop off-canvas indicator outline to 10% opacity

Follow-up to the 30% softening — 10% resting opacity reads much calmer
with many protruding elements; hover still restores full opacity.

* fix(studio): gate [hf-commit] console traces to dev only

The start/settled/persisted/restore lifecycle traces logged on every
gesture commit in all environments — console noise for end users. Route
them through a dev-only traceCommit helper (matching the pixel-violation
error's existing DEV gate). The commit_* PostHog events stay always on;
they are the production observability, the console lines are a dev aid.

* fix(studio): count actual reloads, not softReload requests, in commit telemetry

A resize's size and offset persists both request softReload; the seam
counted each request, so a batched gesture reported reload_count 2 even
though the batch is one write and one reload. Compute the count from what
dispatchBufferedCommits actually did — one for a batch, the request count
for the sequential fallback.

* fix(studio): rotate hover + off-canvas overlays with the element; flicker-free crop

- Hover overlay applied the element's rotation only to the selection chrome,
  not the hover box; it now rotates about center like the selection, via a
  shared orientedGroupAwareOverlayRect router (one owner for rotation-aware
  overlay geometry across hover/selection/off-canvas).
- Off-canvas indicator was axis-aligned; it now rotates with the element and
  inverse-rotates the canvas-exclusion clip into the element's local frame,
  so the protruding-sliver clip stays correct for rotated elements.
- Crop commit re-lifted the element only in the commit's .then(), so one
  frame painted the cropped state (the flicker). Re-lift synchronously right
  after onStyleCommit (which applies the clip before its first await), so the
  cropped state never paints; the persisted file value is unchanged.

* fix(studio): address code-review findings across the commit-hardening campaign

Correctness (would ship green, bite under latency):
- Enable-keyframes phase 2 now carries coalesceMs: Infinity, so the convert
  folds into one undo entry instead of splitting past the 300ms default.
- The SDK keyframe persist path forwards coalesceMs (CutoverOptions gains the
  field); multi-keyframe delete and convert coalesce correctly when SDK-routed.
- Razor split-all's rollback is guarded so a failing restore can't swallow the
  error toast that tells the user the split failed.

Simplification (single source of truth / no dead flexibility):
- Decompose resolveResizeDraftRect (drops a fallow-ignore suppression).
- Delegate the third readProjectFileContent copy to the shared helper.
- Inline setPatchFromUpdateProperties (its only caller passes one mutation).
- One toSdkPersistOptions translates gesture overrides to SDK options.
- Bundle the reorder-rollback deps into one object (was 7-9 positional args).
- Dedupe the 'last group reloads' ternary; type gesture options as
  CommitMutationOptions; drop a Map+array wrapper around a single write.

* feat(studio): atomic z-order reorder via batch patch-element endpoint

Z-order reorder issued N per-element inline-style patches (one server
write each), so a mid-chain failure could leave a partial reorder on disk.
Add a patch-elements-batch endpoint that validates every patch, folds them
over the file in one in-memory rewrite, and writes once (all-or-nothing;
unsafe input rejects with no write). The reorder now sends one batch per
source file and records one undo entry. Because a failed atomic write
persists nothing, the interim disk-write-back rollback (restoreReorderedFile
/ restoreFulfilledReorderFiles / ReorderRollbackDeps) is deleted — failure
rolls back only live DOM/store state. Closes the last disk-atomicity gap.

* fix(studio): razor-split undo no longer silently no-ops

The split clone was written to disk without a data-hf-id, so the split
endpoint recorded that unstamped HTML as the undo entry's afterHash. The
next reloadPreview() ran the preview route's ensureHfIds write-back, which
minted a fresh id and persisted DIFFERENT bytes — so at undo time the disk
hash no longer matched afterHash and editHistory's content-mismatch guard
silently refused the undo (no write, no network, no error). Stamp the split
output via ensureHfIds in splitElementInHtml before it is written/returned,
so the preview write-back is a no-op and the recorded afterHash always
equals the final on-disk bytes. Fixes at the source rather than relaxing the
mismatch guard. Corrects the stale comment that credited forceReloadSdkSession.

* feat(studio): closed-hand grab cursor on the rotate handle

The rotate handle used the default arrow cursor; show a grabbing
(closed-hand) cursor on hover to signal it's grabbed and dragged to rotate.

* fix(studio): dropping a dragged element over another no longer selects it

A moved drag's release fired the box click, which re-selected whatever now
sat under the pointer via the hover cache — so dropping an element over a
higher-z one selected the drop target instead of keeping the dragged
element selected. The drag-move branch now suppresses the next box click,
mirroring the resize branch.

* fix(studio): group drag is one undo entry, not one per element

Dragging a multi-selected group committed each member's position write as
its own undo entry, so reverting took N Cmd+Z presses. Force a shared
coalesceKey (infinite window) across every member's commit so they fold
into a single undo entry, like the other multi-step commit paths.

* fix(studio): undo of a split no longer leaves a ghost clip in the timeline

The file and the composition iframe revert correctly on undo, but the
timeline panel kept a ghost node for the split clone. The element-merge
that repopulates the timeline preserves elements the fresh scan dropped —
intended for enriched sub-composition children a bare DOM re-scan misses,
but it also preserved a genuinely-removed TOP-LEVEL element (the split
clone after undo), leaving a phantom clip. Restrict the preserve to
elements with a compositionSrc (the enriched sub-comp children); a
top-level element missing from the fresh scan was truly removed.

---------

Co-authored-by: ukimsanov <ular.kimsanov@heygen.com>
2026-07-13 02:55:36 -04:00

670 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Window } from "happy-dom";
import {
DomEditOverlay,
filterNestedDomEditGroupItems,
focusDomEditOverlayElement,
hasDomEditRotationChanged,
resolveDomEditCoordinateScale,
resolveDomEditGroupOverlayRect,
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;
const gestureSpies = vi.hoisted(() => ({
startGesture: vi.fn(() => true),
startGroupDrag: vi.fn(),
onPointerMove: vi.fn(),
onPointerUp: vi.fn(),
clearPointerState: vi.fn(),
}));
vi.mock("./useDomEditOverlayGestures", () => ({
createDomEditOverlayGestureHandlers: () => ({
startGesture: gestureSpies.startGesture,
startGroupDrag: gestureSpies.startGroupDrag,
onPointerMove: gestureSpies.onPointerMove,
onPointerUp: gestureSpies.onPointerUp,
clearPointerState: gestureSpies.clearPointerState,
}),
}));
vi.mock("./useDomEditOverlayRects", async () => {
const React = await import("react");
const { rectsEqual } = await import("./domEditOverlayGeometry");
return {
useDomEditOverlayRects: (options: { selectionRef: { current: unknown } }) => {
const defaultSelectionRect = {
left: 24,
top: 36,
width: 180,
height: 72,
editScaleX: 1,
editScaleY: 1,
};
const initialOverlayRect = options.selectionRef.current ? defaultSelectionRect : null;
const [overlayRect, setOverlayRectState] = React.useState(initialOverlayRect);
const overlayRectRef = React.useRef(initialOverlayRect);
const [groupOverlayItems, setGroupOverlayItemsState] = React.useState([]);
const groupOverlayItemsRef = React.useRef([]);
const setOverlayRect = (next: unknown) => {
if (rectsEqual(overlayRectRef.current, next)) return;
overlayRectRef.current = next;
setOverlayRectState(next);
};
const setGroupOverlayItems = (next: unknown[]) => {
groupOverlayItemsRef.current = next;
setGroupOverlayItemsState(next);
};
return {
overlayRect,
overlayRectRef,
setOverlayRect,
hoverRect: null,
hoverRectRef: { current: null },
setHoverRect: () => {},
groupOverlayItems,
groupOverlayItemsRef,
setGroupOverlayItems,
childRects: [],
};
},
};
});
const previewHelperSpies = vi.hoisted(() => ({
getPreviewTargetFromPointer: vi.fn<() => HTMLElement | null>(() => null),
}));
vi.mock("../../utils/studioPreviewHelpers", async () => {
const actual = await vi.importActual<typeof import("../../utils/studioPreviewHelpers")>(
"../../utils/studioPreviewHelpers",
);
return {
...actual,
getPreviewTargetFromPointer: previewHelperSpies.getPreviewTargetFromPointer,
};
});
vi.mock("./domEditOverlayGeometry", async () => {
const actual = await vi.importActual<typeof import("./domEditOverlayGeometry")>(
"./domEditOverlayGeometry",
);
const stubRect = {
left: 24,
top: 36,
width: 180,
height: 72,
editScaleX: 1,
editScaleY: 1,
};
return {
...actual,
toOverlayRect: () => stubRect,
orientedOverlayRect: () => stubRect,
};
});
function createOverlayProps(args: {
iframeRef: { current: HTMLIFrameElement | null };
selection: DomEditSelection | null;
hoverSelection: DomEditSelection | null;
onSelectionChange: (next: DomEditSelection) => void;
}) {
return {
iframeRef: args.iframeRef,
activeCompositionPath: null,
selection: args.selection,
hoverSelection: args.hoverSelection,
groupSelections: [],
onCanvasMouseDown: () => {},
onCanvasPointerMove: () => Promise.resolve(args.hoverSelection ?? args.selection),
onCanvasPointerLeave: () => {},
onSelectionChange: args.onSelectionChange,
onBlockedMove: () => {},
onPathOffsetCommit: () => {},
onGroupPathOffsetCommit: () => {},
onBoxSizeCommit: () => {},
onRotationCommit: () => {},
};
}
/**
* 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<void> {
await act(async () => {
await new Promise<void>((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<DomEditSelection> = {},
capabilityOverrides: Partial<DomEditSelection["capabilities"]> = {},
): 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<HTMLDivElement>('[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<FocusOptions | undefined> = [];
focusDomEditOverlayElement({
focus: (options?: FocusOptions) => calls.push(options),
});
expect(calls).toEqual([{ preventScroll: true }]);
});
});
describe("DomEditOverlay", () => {
beforeEach(() => {
gestureSpies.startGesture.mockClear();
gestureSpies.startGroupDrag.mockClear();
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 = makeDomEditSelection({
id: "cta-label",
selector: ".cta-label",
tagName: "span",
label: "CTA Label",
textContent: "Add to basket",
computedStyle: { display: "inline", position: "static" },
});
let currentSelection: DomEditSelection | null = null;
const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null };
function Harness() {
const [selected, setSelected] = React.useState<DomEditSelection | null>(null);
currentSelection = selected;
return React.createElement(
DomEditOverlay,
createOverlayProps({
iframeRef,
selection: selected,
hoverSelection: selection,
onSelectionChange: (next: DomEditSelection) => setSelected(next),
}),
);
}
act(() => {
root.render(React.createElement(Harness));
});
const overlay = getOverlay(host);
dispatchOverlayPointerDown(overlay);
expect(gestureSpies.startGesture).not.toHaveBeenCalled();
expect(currentSelection).toBe(null);
act(() => {
root.unmount();
});
host.remove();
});
it("starts movement from the selected bounds", async () => {
// The overlay's compRect updates via a RAF loop reading iframe + overlay
// getBoundingClientRect. happy-dom returns all zeros for newly-created
// elements with no layout, so without stubs the RAF early-returns
// (iRect.width <= 0) and compRect.width stays 0 — gating the selection
// 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 restoreRect = stubViewportRect();
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const selection = makeDomEditSelection();
let currentSelection: DomEditSelection | null = selection;
const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null };
const originalPointerCapture = HTMLDivElement.prototype.setPointerCapture;
HTMLDivElement.prototype.setPointerCapture = () => {};
function Harness() {
const [selected, setSelected] = React.useState<DomEditSelection | null>(selection);
currentSelection = selected;
return React.createElement(DomEditOverlay, {
...createOverlayProps({
iframeRef,
selection: selected,
hoverSelection: null,
onSelectionChange: (next: DomEditSelection) => setSelected(next),
}),
});
}
act(() => {
root.render(React.createElement(Harness));
});
// 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 flushOverlayRaf();
getOverlay(host);
const selectionBox = host.querySelector(
'[data-dom-edit-selection-box="true"]',
) as HTMLDivElement;
expect(selectionBox).toBeTruthy();
dispatchOverlayPointerDown(selectionBox);
expect(currentSelection).toBe(selection);
expect(gestureSpies.startGesture).toHaveBeenCalledWith(
"drag",
expect.objectContaining({ button: 0 }),
);
act(() => {
root.unmount();
});
HTMLDivElement.prototype.setPointerCapture = originalPointerCapture;
restoreRect();
host.remove();
});
it("passes the tracked hover selection when clicking the existing selection box", async () => {
const restoreRect = stubViewportRect();
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
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 };
function Harness() {
return React.createElement(DomEditOverlay, {
...createOverlayProps({
iframeRef,
selection,
hoverSelection,
onSelectionChange: () => {},
}),
onCanvasMouseDown,
});
}
act(() => {
root.render(React.createElement(Harness));
});
await flushOverlayRaf();
const selectionBox = host.querySelector(
'[data-dom-edit-selection-box="true"]',
) as HTMLDivElement;
expect(selectionBox).toBeTruthy();
act(() => {
selectionBox.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onCanvasMouseDown).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ hoverSelection }),
);
act(() => {
root.unmount();
});
restoreRect();
host.remove();
});
});
describe("resolveDomEditCoordinateScale", () => {
it("uses the top-level preview scale when no source boundary dimensions are available", () => {
expect(
resolveDomEditCoordinateScale({
rootScaleX: 0.5,
rootScaleY: 0.5,
}),
).toEqual({
scaleX: 0.5,
scaleY: 0.5,
});
});
it("converts source-local pixels through a scaled nested composition host", () => {
expect(
resolveDomEditCoordinateScale({
rootScaleX: 0.5,
rootScaleY: 0.5,
sourceRectWidth: 960,
sourceRectHeight: 540,
sourceWidth: 1920,
sourceHeight: 1080,
}),
).toEqual({
scaleX: 0.25,
scaleY: 0.25,
});
});
});
describe("resolveDomEditGroupOverlayRect", () => {
it("returns a bounding box that contains every selected element", () => {
expect(
resolveDomEditGroupOverlayRect([
{ left: 40, top: 30, width: 80, height: 50, editScaleX: 1, editScaleY: 1 },
{ left: 150, top: 10, width: 30, height: 120, editScaleX: 0.5, editScaleY: 0.5 },
{ left: 20, top: 90, width: 50, height: 20, editScaleX: 2, editScaleY: 2 },
]),
).toEqual({
left: 20,
top: 10,
width: 160,
height: 120,
editScaleX: 1,
editScaleY: 1,
});
});
it("returns null for an empty group", () => {
expect(resolveDomEditGroupOverlayRect([])).toBeNull();
});
});
describe("filterNestedDomEditGroupItems", () => {
it("keeps top-level selected elements so descendants are not moved twice", () => {
const window = new Window();
const parent = window.document.createElement("div");
const child = window.document.createElement("div");
const sibling = window.document.createElement("div");
parent.append(child);
expect(
filterNestedDomEditGroupItems([
{ key: "parent", element: parent },
{ key: "child", element: child },
{ key: "sibling", element: sibling },
]).map((item) => item.key),
).toEqual(["parent", "sibling"]);
});
});
// 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", () => {
expect(
resolveDomEditRotationGesture({
centerX: 0,
centerY: 0,
startX: 0,
startY: -10,
currentX: 10,
currentY: 0,
actualAngle: 5,
snap: false,
}),
).toEqual({ angle: 95 });
});
it("uses the shortest delta across the 180 degree boundary", () => {
expect(
resolveDomEditRotationGesture({
centerX: 0,
centerY: 0,
startX: -10,
startY: 1.76,
currentX: -10,
currentY: -1.76,
actualAngle: 0,
snap: false,
}).angle,
).toBeCloseTo(20, 1);
});
it("snaps to 15 degree increments when requested", () => {
expect(
resolveDomEditRotationGesture({
centerX: 0,
centerY: 0,
startX: 10,
startY: 0,
currentX: 10,
currentY: 3.25,
actualAngle: 0,
snap: true,
}),
).toEqual({ angle: 15 });
});
it("allows small pointer movements when the rounded angle changes", () => {
const nextRotation = resolveDomEditRotationGesture({
centerX: 0,
centerY: 0,
startX: 0,
startY: -40,
currentX: 1,
currentY: -40,
actualAngle: 0,
snap: false,
});
expect(nextRotation.angle).toBe(1.4);
expect(hasDomEditRotationChanged(0, nextRotation.angle)).toBe(true);
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 });
});
});