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>
This commit is contained in:
Miguel Ángel
2026-07-13 02:55:36 -04:00
committed by GitHub
co-authored by ukimsanov
parent 9940503102
commit df29fa7a5e
228 changed files with 18774 additions and 7332 deletions
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { DomEditSelection } from "./domEditing";
import type { OverlayRect } from "./domEditOverlayGeometry";
import { DomEditCropHandles } from "./DomEditCropHandles";
@@ -33,7 +33,10 @@ function makeEl(id: string, clip: string): HTMLElement {
return el;
}
function render(el: HTMLElement): { root: Root; rerender: (next: HTMLElement) => void } {
function render(
el: HTMLElement,
onStyleCommit: (property: string, value: string) => Promise<void> | void = () => undefined,
): { root: Root; rerender: (next: HTMLElement) => void } {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
@@ -43,7 +46,7 @@ function render(el: HTMLElement): { root: Root; rerender: (next: HTMLElement) =>
<DomEditCropHandles
selection={selectionFor(target)}
overlayRect={overlayRect}
onStyleCommit={() => undefined}
onStyleCommit={onStyleCommit}
/>,
);
});
@@ -83,4 +86,72 @@ describe("DomEditCropHandles clip lift/restore", () => {
act(() => root.unmount());
expect(a.style.getPropertyValue("clip-path")).toBe("circle(50% at 50% 50%)");
});
it("re-lifts synchronously after the commit path re-applies the cropped value", async () => {
const a = makeEl("a", "inset(10px)");
let resolveCommit: (() => void) | undefined;
const pendingCommit = new Promise<void>((resolve) => {
resolveCommit = resolve;
});
const onStyleCommit = vi.fn((property: string, value: string) => {
a.style.setProperty(property, value);
return pendingCommit;
});
const { root } = render(a, onStyleCommit);
const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]');
expect(handle).toBeTruthy();
act(() =>
handle!.dispatchEvent(
new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, clientX: 100 }),
),
);
act(() =>
handle!.dispatchEvent(
new PointerEvent("pointermove", { bubbles: true, pointerId: 1, clientX: 80 }),
),
);
act(() =>
handle!.dispatchEvent(
new PointerEvent("pointerup", { bubbles: true, pointerId: 1, clientX: 80 }),
),
);
expect(onStyleCommit).toHaveBeenCalledWith("clip-path", "inset(10px 30px 10px 10px)");
expect(a.style.getPropertyValue("clip-path")).toBe("none");
resolveCommit?.();
await act(async () => pendingCommit);
act(() => root.unmount());
expect(a.style.getPropertyValue("clip-path")).toBe("inset(10px 30px 10px 10px)");
});
it("re-lifts when the crop commit rejects", async () => {
const a = makeEl("a", "inset(10px)");
const onStyleCommit = vi.fn((property: string, value: string) => {
a.style.setProperty(property, value);
return Promise.reject(new Error("persist failed"));
});
const { root } = render(a, onStyleCommit);
const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]');
act(() =>
handle!.dispatchEvent(
new PointerEvent("pointerdown", { bubbles: true, pointerId: 2, clientX: 100 }),
),
);
act(() =>
handle!.dispatchEvent(
new PointerEvent("pointermove", { bubbles: true, pointerId: 2, clientX: 80 }),
),
);
await act(async () => {
handle!.dispatchEvent(
new PointerEvent("pointerup", { bubbles: true, pointerId: 2, clientX: 80 }),
);
await Promise.resolve();
});
expect(a.style.getPropertyValue("clip-path")).toBe("none");
act(() => root.unmount());
});
});
@@ -31,12 +31,16 @@ interface DomEditCropHandlesProps {
onStyleCommit?: (property: string, value: string) => Promise<void> | 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<CropGestureState | null>(null);
const [dragging, setDragging] = useState(false);
const [hotEdge, setHotEdge] = useState<CropEdge | null>(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
@@ -209,10 +223,9 @@ export function DomEditCropHandles({
setDragging(false);
if (!gesture.didMove) return;
// Commit to the file. The commit path re-applies the value to the live
// element, so re-lift afterwards to keep showing the full content + dim while
// the element stays selected. Re-lift on both fulfilment and rejection so a
// failed commit still restores the crop-mode presentation (and the rejection
// is handled rather than left unhandled).
// element synchronously, so re-lift in the same turn to keep showing the full
// content + dim while selected. Re-lift again on rejection so a failed commit
// still restores crop-mode presentation without an unhandled rejection.
const el = selection.element;
const reLift = () => {
if (liftedRef.current) el.style.setProperty("clip-path", "none");
@@ -223,12 +236,16 @@ export function DomEditCropHandles({
state.insets.right > 0 ||
state.insets.bottom > 0 ||
state.insets.left > 0;
void Promise.resolve(onStyleCommit?.("clip-path", committedValue)).then(() => {
const commit = onStyleCommit?.("clip-path", committedValue);
// handleDomStyleCommit applies the persisted value to the live element
// synchronously before its first await. Restore the crop-mode lift in this
// same turn so the browser never paints that intermediate cropped state.
reLift();
void Promise.resolve(commit).then(() => {
// Only a landed commit makes the rebuilt inset the restore value; a
// failed one keeps restoring the pre-lift clip. Store the value itself —
// by deselect time, render state describes the next selection.
committedClipRef.current = cropped ? committedValue : "";
reLift();
}, reLift);
};
@@ -304,6 +321,7 @@ export function DomEditCropHandles({
<button
type="button"
aria-label="Reposition crop"
title="Reposition crop"
data-dom-edit-crop-handle="true"
className="pointer-events-auto absolute rounded-full border-2 border-studio-accent bg-studio-accent/30 shadow-[0_0_0_1px_rgba(0,0,0,0.4)]"
style={{
@@ -323,31 +341,48 @@ export function DomEditCropHandles({
)}
{/* Edge handles — drag a side to crop it. Positioned just OUTSIDE the crop
edge (via edgeHandlePlacement) so they never overlap the element body:
dragging the body always MOVES, only a handle crops. */}
dragging the body always MOVES, only a handle crops. The pill is
hover-revealed (or shown while dragging / once a crop exists) so the
resting selection chrome stays uncluttered; the hit strip is always
live, and the title names the affordance. */}
{EDGES.map((edge) => {
const vertical = edge === "left" || edge === "right";
const place = edgeHandlePlacement(edge, cropRect);
const revealed = dragging || hasCrop || hotEdge === edge;
const m = edgeHandleMetrics(vertical);
return (
<button
key={edge}
type="button"
aria-label={`Crop ${edge}`}
title="Crop"
data-dom-edit-crop-handle="true"
className="pointer-events-auto absolute rounded-full bg-studio-accent shadow-[0_0_0_1px_rgba(0,0,0,0.4)]"
className="pointer-events-auto absolute flex items-center justify-center border-0 bg-transparent p-0"
style={{
left: place.left,
top: place.top,
width: vertical ? 5 : 26,
height: vertical ? 26 : 5,
width: m.hitWidth,
height: m.hitHeight,
transform: place.transform,
cursor: vertical ? "ew-resize" : "ns-resize",
cursor: m.cursor,
touchAction: "none",
}}
onPointerEnter={() => setHotEdge(edge)}
onPointerLeave={() => setHotEdge((prev) => (prev === edge ? null : prev))}
onPointerDown={(event) => startCropGesture(edge, event)}
onPointerMove={updateCropGesture}
onPointerUp={finishCropGesture}
onPointerCancel={cancelCropGesture}
/>
>
<span
className="pointer-events-none rounded-full bg-studio-accent/90 shadow-[0_0_0_1px_rgba(0,0,0,0.4)] transition-opacity duration-100"
style={{
width: m.pillWidth,
height: m.pillHeight,
opacity: revealed ? 1 : 0,
}}
/>
</button>
);
})}
</div>
@@ -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<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: () => ({
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<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> = [];
@@ -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<void>((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<void>((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 });
});
});
@@ -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";
@@ -73,6 +74,8 @@ interface DomEditOverlayProps {
onBoxSizeCommit: (
selection: DomEditSelection,
next: { width: number; height: number },
offset?: { x: number; y: number },
restore?: () => void,
) => Promise<void> | void;
onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise<void> | void;
onStyleCommit?: (property: string, value: string) => Promise<void> | void;
@@ -81,6 +84,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
@@ -105,6 +121,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({
onRotationCommit,
onStyleCommit,
onMarqueeSelect,
onDeleteSelection,
onApplyZIndex,
}: DomEditOverlayProps) {
const overlayRef = useRef<HTMLDivElement | null>(null);
const boxRef = useRef<HTMLDivElement | null>(null);
@@ -121,8 +139,31 @@ export const DomEditOverlay = memo(function DomEditOverlay({
const snapGuidesRef = useRef<SnapGuidesState | null>(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);
@@ -241,6 +282,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,
@@ -370,6 +428,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<HTMLDivElement>) => {
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<HTMLDivElement>;
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 (
<div
ref={overlayRef}
@@ -378,142 +468,59 @@ export const DomEditOverlay = memo(function DomEditOverlay({
aria-label="Composition canvas"
// Cursor follows marquee rect *state* (re-renders), not the mutable ref.
style={marquee.marqueeRect ? { cursor: "crosshair" } : undefined}
onPointerDownCapture={(event) =>
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 && (
<div
aria-hidden="true"
data-dom-edit-hover-box="true"
className="pointer-events-none absolute rounded-md border border-studio-accent/80 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
style={hugRectForElement(hoverRect, hoverSelection.element)}
style={{
...hugRectForElement(hoverRect, hoverSelection.element),
transform: hoverRect.angle ? `rotate(${hoverRect.angle}deg)` : undefined,
}}
/>
)}
{hasGroupSelection && groupOverlayItems.length > 1 && groupBounds && compRect.width > 0 && (
<>
{groupOverlayItems.map((item) => (
<div
key={item.key}
aria-hidden="true"
className="pointer-events-none absolute rounded-xl border border-studio-accent/70"
style={{
left: item.rect.left,
top: item.rect.top,
width: item.rect.width,
height: item.rect.height,
}}
/>
))}
<div
data-dom-edit-selection-box="true"
className="pointer-events-auto absolute rounded-xl border border-studio-accent shadow-[0_0_0_1px_rgba(60,230,172,0.3)]"
style={{
left: groupBounds.left,
top: groupBounds.top,
width: groupBounds.width,
height: groupBounds.height,
cursor: allowCanvasMovement && groupCanMove ? "move" : "default",
}}
onPointerDown={(e) => {
if (!allowCanvasMovement || !groupCanMove || e.shiftKey) return;
gestures.startGroupDrag(e);
}}
onMouseDown={suppressBoxMouseDown}
onClick={handleBoxClick}
/>
</>
<DomEditGroupChrome
groupOverlayItems={groupOverlayItems}
groupBounds={groupBounds}
allowCanvasMovement={allowCanvasMovement}
groupCanMove={groupCanMove}
gestures={gestures}
onBoxMouseDown={suppressBoxMouseDown}
onBoxClick={handleBoxClick}
/>
)}
{!hasGroupSelection && selection && overlayRect && compRect.width > 0 && (
<>
{allowCanvasMovement && selection.capabilities.canApplyManualRotation && (
<DomEditRotateHandle
overlayRect={overlayRect}
cropOutlineInsetPx={cropOutlineInsetPx}
onStartRotate={(e) => {
e.stopPropagation();
gestures.startGesture("rotate", e);
}}
/>
)}
<div
key={selectionKey}
ref={boxRef}
data-dom-edit-selection-box="true"
className={`pointer-events-auto absolute rounded-md ${boxChromeClass}`}
style={{
left: overlayRect.left,
top: overlayRect.top,
width: overlayRect.width,
height: overlayRect.height,
clipPath: boxClipPath,
cursor:
allowCanvasMovement && selection.capabilities.canApplyManualOffset
? "move"
: "default",
}}
onPointerDown={(e) => {
if (!allowCanvasMovement || e.shiftKey) return;
if (selection.capabilities.canApplyManualOffset) {
gestures.startGesture("drag", e);
return;
}
e.preventDefault();
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
blockedMoveRef.current = {
pointerId: e.pointerId,
startX: e.clientX,
startY: e.clientY,
notified: false,
};
}}
onMouseDown={suppressBoxMouseDown}
onClick={handleBoxClick}
>
{cropOutlineInsetPx && (
<div
className="pointer-events-none absolute rounded-md border border-studio-accent/80 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
style={{
left: cropOutlineInsetPx.left,
top: cropOutlineInsetPx.top,
right: cropOutlineInsetPx.right,
bottom: cropOutlineInsetPx.bottom,
}}
/>
)}
{allowCanvasMovement && selection.capabilities.canApplyManualSize && (
<div
className="absolute -right-1.5 -bottom-1.5 w-3 h-3 rounded-sm bg-studio-accent border border-studio-accent/60"
style={{
cursor: "se-resize",
touchAction: "none",
...(cropOutlineInsetPx && {
right: cropOutlineInsetPx.right - 6,
bottom: cropOutlineInsetPx.bottom - 6,
}),
}}
onPointerDown={(e) => {
e.stopPropagation();
gestures.startGesture("resize", e);
}}
/>
)}
</div>
{selection.capabilities.canCrop && groupSelections.length <= 1 && (
<DomEditCropHandles
selection={selection}
overlayRect={overlayRect}
onStyleCommit={onStyleCommitRef.current}
/>
)}
</>
<DomEditSelectionChrome
selection={selection}
overlayRect={overlayRect}
allowCanvasMovement={allowCanvasMovement}
cropOutlineInsetPx={cropOutlineInsetPx ?? undefined}
boxRef={boxRef}
boxChromeClass={boxChromeClass}
boxClipPath={boxClipPath}
selectionKey={selectionKey}
groupSelectionCount={groupSelections.length}
blockedMoveRef={blockedMoveRef}
gestures={gestures}
onStyleCommit={onStyleCommitRef.current}
onBoxMouseDown={suppressBoxMouseDown}
onBoxClick={handleBoxClick}
/>
)}
{childRects.length > 0 &&
compRect.width > 0 &&
@@ -539,6 +546,29 @@ export const DomEditOverlay = memo(function DomEditOverlay({
onSelectionChangeRef={onSelectionChangeRef}
/>
<MarqueeOverlay candidateRects={marquee.candidateRects} marqueeRect={marquee.marqueeRect} />
{contextMenu && (
<CanvasContextMenu
x={contextMenu.x}
y={contextMenu.y}
selection={contextMenu.sel}
onClose={() => setContextMenu(null)}
onDelete={
onDeleteSelection
? (sel) => {
setContextMenu(null);
onDeleteSelection(sel);
}
: undefined
}
onApplyZIndex={
onApplyZIndex
? (patches) => {
onApplyZIndex(contextMenu.sel, patches);
}
: undefined
}
/>
)}
<GridOverlay
visible={gridVisible}
spacing={gridSpacing}
@@ -551,8 +581,10 @@ export const DomEditOverlay = memo(function DomEditOverlay({
/>
<SnapGuideOverlay
snapGuidesRef={snapGuidesRef}
overlayWidth={compRect.width}
overlayHeight={compRect.height}
compositionLeft={compRect.left}
compositionTop={compRect.top}
compositionWidth={compRect.width}
compositionHeight={compRect.height}
/>
</div>
);
@@ -0,0 +1,90 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vitest";
import type { DomEditSelection } from "./domEditing";
import { DomEditOverlay } from "./DomEditOverlay";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const hoverState = vi.hoisted(() => ({ angle: 0 }));
vi.mock("./useDomEditOverlayRects", () => ({
useDomEditOverlayRects: ({ hoverSelectionRef }: { hoverSelectionRef: { current: unknown } }) => ({
overlayRect: null,
overlayRectRef: { current: null },
setOverlayRect: () => undefined,
hoverRect: hoverSelectionRef.current
? {
left: 20,
top: 30,
width: 100,
height: 40,
editScaleX: 1,
editScaleY: 1,
angle: hoverState.angle,
}
: null,
groupOverlayItems: [],
groupOverlayItemsRef: { current: [] },
setGroupOverlayItems: () => undefined,
childRects: [],
}),
}));
vi.mock("./useDomEditCompositionRect", () => ({
useDomEditCompositionRect: () => ({
left: 0,
top: 0,
width: 800,
height: 450,
scaleX: 1,
scaleY: 1,
}),
}));
vi.mock("./offCanvasIndicatorRefresh", () => ({
startOffCanvasIndicatorRefresh: () => () => undefined,
}));
function renderHover(angle: number): string {
hoverState.angle = angle;
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const element = document.createElement("div");
const hoverSelection = { element } as unknown as DomEditSelection;
act(() => {
root.render(
<DomEditOverlay
iframeRef={{ current: document.createElement("iframe") }}
activeCompositionPath={null}
selection={null}
hoverSelection={hoverSelection}
onCanvasMouseDown={() => undefined}
onCanvasPointerMove={() => Promise.resolve(null)}
onCanvasPointerLeave={() => undefined}
onSelectionChange={() => undefined}
onBlockedMove={() => undefined}
onPathOffsetCommit={() => undefined}
onGroupPathOffsetCommit={() => undefined}
onBoxSizeCommit={() => undefined}
onRotationCommit={() => undefined}
/>,
);
});
const box = host.querySelector<HTMLElement>('[data-dom-edit-hover-box="true"]');
const transform = box?.style.transform ?? "";
act(() => root.unmount());
host.remove();
return transform;
}
it("rotates the hover box with the element", () => {
expect(renderHover(30)).toBe("rotate(30deg)");
});
it("leaves the hover box untransformed at angle zero", () => {
expect(renderHover(0)).toBe("");
});
@@ -1,8 +1,12 @@
import type { PointerEvent as ReactPointerEvent } from "react";
import type { OverlayRect } from "./domEditOverlayGeometry";
/** Rotate grab-handle above the selection. Anchors to the crop outline when
* the element is cropped so it stays next to what's visible on screen. */
/** Rotate handle below the selection: an attached circular-arrows icon chip
* (no connecting stem). Anchors to the crop outline when the element is
* cropped so it stays next to what's visible on screen. Presentation only
* the rotation gesture measures pointer angles from the element CENTER
* (resolveDomEditRotationGesture), so the handle position doesn't affect the
* math. Sits 12px below the bbox, past the bottom crop handle's hit strip. */
export function DomEditRotateHandle({
overlayRect,
cropOutlineInsetPx,
@@ -15,27 +19,43 @@ export function DomEditRotateHandle({
const inset = cropOutlineInsetPx ?? { top: 0, right: 0, bottom: 0, left: 0 };
const visibleLeft = overlayRect.left + inset.left;
const visibleWidth = Math.max(0, overlayRect.width - inset.left - inset.right);
const visibleTop = overlayRect.top + inset.top;
const visibleBottom = overlayRect.top + overlayRect.height - inset.bottom;
return (
<div
className="pointer-events-none absolute"
<button
type="button"
className="pointer-events-auto absolute flex items-center justify-center border-0 bg-transparent p-0"
style={{
left: visibleLeft + visibleWidth / 2,
top: visibleTop - 34,
width: 28,
height: 34,
top: visibleBottom + 12,
width: 22,
height: 22,
transform: "translateX(-50%)",
touchAction: "none",
// Closed-hand grab cursor: this handle is grabbed and dragged to rotate.
cursor: "grabbing",
}}
title="Rotate"
aria-label="Rotate selection"
onPointerDown={onStartRotate}
>
<div className="absolute left-1/2 top-3 bottom-0 w-px -translate-x-1/2 bg-studio-accent/60" />
<button
type="button"
className="pointer-events-auto absolute left-1/2 top-0 h-3 w-3 -translate-x-1/2 rounded-full border border-studio-accent bg-studio-accent p-0 shadow-[0_0_0_2px_rgba(60,230,172,0.18)]"
style={{ cursor: "grab", touchAction: "none" }}
title="Rotate"
aria-label="Rotate selection"
onPointerDown={onStartRotate}
/>
</div>
<span className="pointer-events-none flex h-[18px] w-[18px] items-center justify-center rounded-full border border-studio-accent/70 bg-studio-surface text-studio-accent shadow-[0_0_3px_rgba(0,0,0,0.45)]">
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M8 16H3v5" />
</svg>
</span>
</button>
);
}
@@ -0,0 +1,80 @@
// @vitest-environment happy-dom
import React, { act, createRef } from "react";
import { createRoot } from "react-dom/client";
import { describe, expect, it, vi } from "vitest";
import type { DomEditSelection } from "./domEditing";
import { DomEditSelectionChrome } from "./DomEditSelectionChrome";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("DomEditSelectionChrome crop composition", () => {
it("places rotated crop UI in exactly one oriented coordinate plane", () => {
const element = document.createElement("div");
element.id = "clip";
element.style.clipPath = "inset(10px)";
Object.defineProperties(element, {
offsetWidth: { value: 200 },
offsetHeight: { value: 100 },
});
document.body.append(element);
vi.spyOn(window, "getComputedStyle").mockReturnValue({
clipPath: "inset(10px)",
transform: "matrix(0.8660254, 0.5, -0.5, 0.8660254, 0, 0)",
} as CSSStyleDeclaration);
const selection = {
element,
id: "clip",
selector: "#clip",
capabilities: {
canCrop: true,
canApplyManualOffset: true,
canApplyManualSize: true,
canApplyManualRotation: true,
},
} as unknown as DomEditSelection;
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<DomEditSelectionChrome
selection={selection}
overlayRect={{
left: 100,
top: 50,
width: 220,
height: 130,
editScaleX: 1,
editScaleY: 1,
angle: 30,
}}
allowCanvasMovement={true}
boxRef={createRef()}
boxChromeClass=""
boxClipPath={undefined}
selectionKey="clip"
groupSelectionCount={0}
blockedMoveRef={createRef()}
gestures={{ startGesture: vi.fn() } as never}
onStyleCommit={vi.fn()}
onBoxMouseDown={vi.fn()}
onBoxClick={vi.fn()}
/>,
);
});
const cropFrame = host.querySelector<HTMLElement>("[data-dom-edit-crop-frame]")!;
const rotations: string[] = [];
for (
let node: HTMLElement | null = cropFrame;
node && node !== host;
node = node.parentElement
) {
if (node.style.transform.includes("rotate(")) rotations.push(node.style.transform);
}
expect(rotations).toHaveLength(1);
expect(Number.parseFloat(rotations[0]!.slice("rotate(".length))).toBeCloseTo(30, 5);
act(() => root.unmount());
});
});
@@ -0,0 +1,257 @@
import type { RefObject } from "react";
import { type DomEditSelection } from "./domEditing";
import type { GroupOverlayItem, OverlayRect } from "./domEditOverlayGeometry";
import type { BlockedMoveState, ResizeHandle } from "./domEditOverlayGestures";
import type { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
import { DomEditCropHandles } from "./DomEditCropHandles";
import { DomEditRotateHandle } from "./DomEditRotateHandle";
import { resolveRotatedResizeCursor } from "./domEditResizeLocal";
// Corner resize handles, Canva-style: one per corner, diagonal cursors.
// Corners scale about the element center; the translate keeps the center
// planted, so they need the manual-offset capability in addition to manual-size.
const RESIZE_HANDLE_DEFS: Array<{
handle: ResizeHandle;
cursor: string;
x: "left" | "right";
y: "top" | "bottom";
}> = [
{ handle: "nw", cursor: "nwse-resize", x: "left", y: "top" },
{ handle: "ne", cursor: "nesw-resize", x: "right", y: "top" },
{ handle: "sw", cursor: "nesw-resize", x: "left", y: "bottom" },
{ handle: "se", cursor: "nwse-resize", x: "right", y: "bottom" },
];
// Visible dot is 9px; the pointer target is a 16px invisible square centered
// on the corner so click targets don't shrink with the smaller dot.
const RESIZE_HANDLE_HIT_PX = 16;
type CropInset = { top: number; right: number; bottom: number; left: number };
const NO_CROP_INSET: CropInset = { top: 0, right: 0, bottom: 0, left: 0 };
function resizeHandleStyle(
def: (typeof RESIZE_HANDLE_DEFS)[number],
overlayRect: { left: number; top: number; width: number; height: number },
cropInset?: CropInset,
): React.CSSProperties {
const half = RESIZE_HANDLE_HIT_PX / 2;
const inset = cropInset ?? NO_CROP_INSET;
const style: React.CSSProperties = { cursor: def.cursor, touchAction: "none" };
// Position relative to the overlay container (not the selection box).
// This ensures the dots render as siblings of the box border div — strictly
// above it — rather than as children where the parent border can visually
// overlap the dot circle at the corner.
style.left =
def.x === "left"
? overlayRect.left + inset.left - half
: overlayRect.left + overlayRect.width - inset.right - half;
style.top =
def.y === "top"
? overlayRect.top + inset.top - half
: overlayRect.top + overlayRect.height - inset.bottom - half;
return style;
}
type GestureHandlers = ReturnType<typeof createDomEditOverlayGestureHandlers>;
interface DomEditGroupChromeProps {
groupOverlayItems: GroupOverlayItem[];
groupBounds: OverlayRect;
allowCanvasMovement: boolean;
groupCanMove: boolean;
gestures: GestureHandlers;
onBoxMouseDown: (e: React.MouseEvent) => void;
onBoxClick: (event: React.MouseEvent<HTMLDivElement>) => void;
}
// Multi-selection chrome: per-member outlines plus a single draggable bounding
// box spanning the union of the members.
export function DomEditGroupChrome({
groupOverlayItems,
groupBounds,
allowCanvasMovement,
groupCanMove,
gestures,
onBoxMouseDown,
onBoxClick,
}: DomEditGroupChromeProps) {
return (
<>
{groupOverlayItems.map((item) => (
<div
key={item.key}
aria-hidden="true"
className="pointer-events-none absolute rounded-xl border border-studio-accent/70"
style={{
left: item.rect.left,
top: item.rect.top,
width: item.rect.width,
height: item.rect.height,
}}
/>
))}
<div
data-dom-edit-selection-box="true"
className="pointer-events-auto absolute rounded-xl border border-studio-accent shadow-[0_0_0_1px_rgba(60,230,172,0.3)]"
style={{
left: groupBounds.left,
top: groupBounds.top,
width: groupBounds.width,
height: groupBounds.height,
cursor: allowCanvasMovement && groupCanMove ? "move" : "default",
}}
onPointerDown={(e) => {
if (!allowCanvasMovement || !groupCanMove || e.shiftKey) return;
gestures.startGroupDrag(e);
}}
onMouseDown={onBoxMouseDown}
onClick={onBoxClick}
/>
</>
);
}
interface DomEditSelectionChromeProps {
selection: DomEditSelection;
overlayRect: OverlayRect;
allowCanvasMovement: boolean;
cropOutlineInsetPx?: { top: number; right: number; bottom: number; left: number };
boxRef: RefObject<HTMLDivElement | null>;
boxChromeClass: string;
boxClipPath: string | undefined;
selectionKey: string;
groupSelectionCount: number;
blockedMoveRef: RefObject<BlockedMoveState | null>;
gestures: GestureHandlers;
onStyleCommit?: (property: string, value: string) => Promise<void> | void;
onBoxMouseDown: (e: React.MouseEvent) => void;
onBoxClick: (event: React.MouseEvent<HTMLDivElement>) => void;
}
// Oriented selection chrome: a rotation wrapper spanning the overlay, rotated by
// the element's live angle about the selection box CENTER. Its children (border
// box, corner dots, and rotate handle) keep their existing
// overlay-absolute positions — rotating the whole plane about the box center
// lands them on the element's real transformed corners for free. At angle 0 the
// transform is a no-op, so the chrome is pixel-identical.
export function DomEditSelectionChrome({
selection,
overlayRect,
allowCanvasMovement,
cropOutlineInsetPx,
boxRef,
boxChromeClass,
boxClipPath,
selectionKey,
groupSelectionCount,
blockedMoveRef,
gestures,
onStyleCommit,
onBoxMouseDown,
onBoxClick,
}: DomEditSelectionChromeProps) {
return (
<>
<div
className="pointer-events-none absolute inset-0"
style={{
transformOrigin: `${overlayRect.left + overlayRect.width / 2}px ${overlayRect.top + overlayRect.height / 2}px`,
transform: overlayRect.angle ? `rotate(${overlayRect.angle}deg)` : undefined,
}}
>
{allowCanvasMovement && selection.capabilities.canApplyManualRotation && (
<DomEditRotateHandle
overlayRect={overlayRect}
cropOutlineInsetPx={cropOutlineInsetPx}
onStartRotate={(e) => {
e.stopPropagation();
gestures.startGesture("rotate", e);
}}
/>
)}
<div
key={selectionKey}
ref={boxRef}
data-dom-edit-selection-box="true"
className={`pointer-events-auto absolute rounded-md ${boxChromeClass}`}
style={{
left: overlayRect.left,
top: overlayRect.top,
width: overlayRect.width,
height: overlayRect.height,
clipPath: boxClipPath,
cursor:
allowCanvasMovement && selection.capabilities.canApplyManualOffset
? "move"
: "default",
}}
onPointerDown={(e) => {
if (!allowCanvasMovement || e.shiftKey) return;
if (selection.capabilities.canApplyManualOffset) {
gestures.startGesture("drag", e);
return;
}
e.preventDefault();
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
blockedMoveRef.current = {
pointerId: e.pointerId,
startX: e.clientX,
startY: e.clientY,
notified: false,
};
}}
onMouseDown={onBoxMouseDown}
onClick={onBoxClick}
>
{cropOutlineInsetPx && (
<div
className="pointer-events-none absolute rounded-md border border-studio-accent/80 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
style={{
left: cropOutlineInsetPx.left,
top: cropOutlineInsetPx.top,
right: cropOutlineInsetPx.right,
bottom: cropOutlineInsetPx.bottom,
}}
/>
)}
</div>
{/* Resize-handle dots rendered as siblings of the selection box, not
children, so they paint strictly above the box border. Each handle
is positioned relative to the overlay container using the
overlayRect origin, matching the old child-relative offsets. */}
{allowCanvasMovement &&
selection.capabilities.canApplyManualSize &&
RESIZE_HANDLE_DEFS.map((def) =>
def.handle !== "se" && !selection.capabilities.canApplyManualOffset ? null : (
<div
key={def.handle}
className="pointer-events-auto absolute flex h-4 w-4 items-center justify-center"
style={{
...resizeHandleStyle(def, overlayRect, cropOutlineInsetPx ?? undefined),
// Cursor rotates with the object: bucket the corner's base
// diagonal + element rotation into the 8 CSS resize cursors.
cursor: resolveRotatedResizeCursor(def.handle, overlayRect.angle ?? 0),
}}
onPointerDown={(e) => {
e.stopPropagation();
gestures.startGesture("resize", e, { resizeHandle: def.handle });
}}
>
<div className="pointer-events-none h-[12px] w-[12px] rounded-full border-[1.5px] border-studio-accent bg-white shadow-[0_0_3px_rgba(0,0,0,0.45)]" />
</div>
),
)}
</div>
{/* Crop owns its element-local oriented frame. Keep it outside the chrome's
rotated plane or a rotated selection applies the angle twice. */}
{selection.capabilities.canCrop && groupSelectionCount <= 1 && (
<DomEditCropHandles
selection={selection}
overlayRect={overlayRect}
onStyleCommit={onStyleCommit}
/>
)}
</>
);
}
@@ -1,10 +1,11 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { Window } from "happy-dom";
import type { DomEditLayerItem } from "./domEditingTypes";
import { sortLayersByZIndex } from "./LayersPanel";
import { createRafThrottle, sortLayersByZIndex } from "./LayersPanel";
import { isLayerDraggable } from "./useLayerDrag";
import { liveTime } from "../../player";
function makeLayer(
overrides: Partial<DomEditLayerItem> & { zIndex?: string; locked?: boolean },
@@ -133,3 +134,66 @@ describe("isLayerDraggable", () => {
expect(isLayerDraggable(layer)).toBe(true);
});
});
// ── liveTime throttle contract (mirrors the useEffect in LayersPanel) ──────
// The panel subscribes to liveTime with a rAF + 100 ms trailing throttle so
// it refreshes during scrubbing without a collectLayers call every frame.
// These tests exercise the subscribe/unsubscribe contract that the effect
// relies on.
describe("liveTime subscribe / unsubscribe (LayersPanel scrub contract)", () => {
let rafCallbacks: FrameRequestCallback[];
let originalRaf: typeof requestAnimationFrame;
let originalCancelRaf: typeof cancelAnimationFrame;
beforeEach(() => {
rafCallbacks = [];
originalRaf = globalThis.requestAnimationFrame;
originalCancelRaf = globalThis.cancelAnimationFrame;
let nextId = 1;
globalThis.requestAnimationFrame = (cb) => {
const id = nextId++;
rafCallbacks.push(cb);
return id;
};
globalThis.cancelAnimationFrame = vi.fn();
});
afterEach(() => {
globalThis.requestAnimationFrame = originalRaf;
globalThis.cancelAnimationFrame = originalCancelRaf;
});
it("unsubscribing stops the callback from receiving further notifications", () => {
const cb = vi.fn();
const unsubscribe = liveTime.subscribe(cb);
liveTime.notify(1);
expect(cb).toHaveBeenCalledTimes(1);
unsubscribe();
liveTime.notify(2);
expect(cb).toHaveBeenCalledTimes(1); // no new call after unsubscribe
});
it("queuing a rAF on liveTime notify then flushing calls the refresh exactly once", () => {
const refresh = vi.fn();
const throttle = createRafThrottle(refresh, 100);
const unsubscribe = liveTime.subscribe(throttle.invoke);
// First notify enqueues one rAF
liveTime.notify(0.1);
expect(rafCallbacks).toHaveLength(1);
expect(refresh).not.toHaveBeenCalled();
// Second notify before rAF flush is ignored (rafId is set)
liveTime.notify(0.2);
expect(rafCallbacks).toHaveLength(1);
// Flush the rAF
rafCallbacks[0](performance.now());
expect(refresh).toHaveBeenCalledTimes(1);
unsubscribe();
});
});
@@ -7,7 +7,7 @@ import {
} from "./domEditing";
import { useStudioPlaybackContext, useStudioShellContext } from "../../contexts/StudioContext";
import { useDomEditContext } from "../../contexts/DomEditContext";
import { usePlayerStore } from "../../player";
import { usePlayerStore, liveTime } from "../../player";
import {
findMatchingTimelineElementId,
resolveTimelineSelectionSeekTime,
@@ -49,6 +49,34 @@ function isCompositionHost(el: HTMLElement): boolean {
return el.hasAttribute("data-composition-src") || el.hasAttribute("data-composition-file");
}
/**
* A trailing-rAF + cooldown throttle: `invoke` runs `run` at most once per
* animation frame and no more often than `throttleMs`. `cancel` clears any
* pending frame (call on cleanup). Extracted so the throttle can be exercised
* directly in tests instead of being reconstructed there.
*/
export function createRafThrottle(
run: () => void,
throttleMs = 100,
): { invoke: () => void; cancel: () => void } {
let rafId: number | null = null;
let lastFired = 0;
return {
invoke: () => {
const now = performance.now();
if (rafId !== null || now - lastFired < throttleMs) return;
rafId = requestAnimationFrame(() => {
rafId = null;
lastFired = performance.now();
run();
});
},
cancel: () => {
if (rafId !== null) cancelAnimationFrame(rafId);
},
};
}
interface CollapsedState {
[key: string]: boolean;
}
@@ -122,6 +150,20 @@ export const LayersPanel = memo(function LayersPanel() {
}
}, [compositionLoading, collectLayers]);
// Subscribe to liveTime so the panel refreshes during scrubbing.
// liveTime bypasses React state (no re-renders per frame), so a plain
// usePlayerStore(s => s.currentTime) subscription never fires while the
// RAF loop is running. Throttle with a trailing rAF + 100 ms cooldown to
// avoid a collectLayers call on every animation frame.
useEffect(() => {
const throttle = createRafThrottle(collectLayers, 100);
const unsubscribe = liveTime.subscribe(throttle.invoke);
return () => {
unsubscribe();
throttle.cancel();
};
}, [collectLayers]);
const resolveSelection = useCallback(
(layer: DomEditLayerItem) => {
// Re-find the element from the live DOM — layer.element may be stale
@@ -0,0 +1,34 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it } from "vitest";
import { OffCanvasIndicators } from "./OffCanvasIndicators";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
it("rotates an off-canvas indicator with its element", () => {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<OffCanvasIndicators
rects={[{ key: "card", left: 80, top: 20, width: 40, height: 20, angle: 30 }]}
elements={{ current: new Map() }}
compRect={{ left: 0, top: 0, width: 100, height: 100 }}
selection={null}
groupSelections={[]}
activeCompositionPathRef={{ current: null }}
onSelectionChangeRef={{ current: () => undefined }}
/>,
);
});
const indicator = host.querySelector<HTMLElement>('[role="button"]');
expect(indicator?.parentElement?.style.transform).toBe("rotate(30deg)");
expect(indicator?.parentElement?.style.transformOrigin).toBe("center");
act(() => root.unmount());
host.remove();
});
@@ -7,6 +7,7 @@ export interface OffCanvasRect {
top: number;
width: number;
height: number;
angle?: number;
}
interface OffCanvasIndicatorsProps {
@@ -21,6 +22,44 @@ interface OffCanvasIndicatorsProps {
>;
}
function clipOutsideCanvas(
rect: OffCanvasRect,
compRect: { left: number; top: number; width: number; height: number },
): string | undefined {
const angle = rect.angle ?? 0;
if (!angle) {
const left = Math.max(0, compRect.left - rect.left);
const top = Math.max(0, compRect.top - rect.top);
const right = Math.min(rect.width, compRect.left + compRect.width - rect.left);
const bottom = Math.min(rect.height, compRect.top + compRect.height - rect.top);
if (left >= right || top >= bottom) return undefined;
return `polygon(evenodd, 0 0, ${rect.width}px 0, ${rect.width}px ${rect.height}px, 0 ${rect.height}px, 0 0, ${left}px ${top}px, ${right}px ${top}px, ${right}px ${bottom}px, ${left}px ${bottom}px, ${left}px ${top}px)`;
}
const radians = (-angle * Math.PI) / 180;
const cos = Math.cos(radians);
const sin = Math.sin(radians);
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const toLocal = (x: number, y: number) => {
const dx = x - centerX;
const dy = y - centerY;
return `${rect.width / 2 + dx * cos - dy * sin}px ${rect.height / 2 + dx * sin + dy * cos}px`;
};
const left = compRect.left;
const top = compRect.top;
const right = left + compRect.width;
const bottom = top + compRect.height;
const canvas = [
toLocal(left, top),
toLocal(right, top),
toLocal(right, bottom),
toLocal(left, bottom),
toLocal(left, top),
].join(", ");
return `polygon(evenodd, 0 0, ${rect.width}px 0, ${rect.width}px ${rect.height}px, 0 ${rect.height}px, 0 0, ${canvas})`;
}
/**
* Dashed teal indicators for elements whose bounds extend past the composition
* (the "gray zone"). The in-canvas portion is clipped away so only the
@@ -49,15 +88,15 @@ export function OffCanvasIndicators({
return !groupSelections.some((g) => g.element === el);
})
.map((r) => {
const pos = { left: r.left, top: r.top, width: r.width, height: r.height };
const cL = Math.max(0, compRect.left - r.left);
const cT = Math.max(0, compRect.top - r.top);
const cR = Math.min(r.width, compRect.left + compRect.width - r.left);
const cB = Math.min(r.height, compRect.top + compRect.height - r.top);
const hasInside = cL < cR && cT < cB;
const clipOutside = hasInside
? `polygon(evenodd, 0 0, ${r.width}px 0, ${r.width}px ${r.height}px, 0 ${r.height}px, 0 0, ${cL}px ${cT}px, ${cR}px ${cT}px, ${cR}px ${cB}px, ${cL}px ${cB}px, ${cL}px ${cT}px)`
: undefined;
const pos = {
left: r.left,
top: r.top,
width: r.width,
height: r.height,
transform: r.angle ? `rotate(${r.angle}deg)` : undefined,
transformOrigin: "center",
};
const clipOutside = clipOutsideCanvas(r, compRect);
const selectOffCanvas = async () => {
const el = elements.current.get(r.key);
if (!el) return;
@@ -86,7 +125,7 @@ export function OffCanvasIndicators({
role="button"
tabIndex={0}
aria-label={`Select off-canvas element ${r.key}`}
className="pointer-events-auto absolute inset-0 border-2 border-dashed border-studio-accent/60 rounded-md cursor-pointer hover:border-studio-accent hover:bg-studio-accent/10 transition-colors"
className="pointer-events-auto absolute inset-0 border-2 border-dashed border-studio-accent/10 rounded-md cursor-pointer hover:border-studio-accent hover:bg-studio-accent/10 transition-colors"
style={clipOutside ? { clipPath: clipOutside } : undefined}
title={`Off-canvas: ${r.key} — click to select`}
onClick={handleClick}
@@ -1,7 +1,6 @@
// fallow-ignore-file unused-file
import { memo, useRef, type RefObject } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
import type { SnapGuide, SpacingGuide } from "./snapEngine";
import { resolveGuideLineRect, type SnapGuide, type SpacingGuide } from "./snapEngine";
export interface SnapGuidesState {
guides: SnapGuide[];
@@ -17,22 +16,35 @@ const SPACING_BG = "rgba(255, 68, 204, 0.15)";
interface SnapGuideOverlayProps {
snapGuidesRef: RefObject<SnapGuidesState | null>;
overlayWidth: number;
overlayHeight: number;
/** Composition rect in overlay space — guide lines span exactly this rect. */
compositionLeft: number;
compositionTop: number;
compositionWidth: number;
compositionHeight: number;
}
export const SnapGuideOverlay = memo(function SnapGuideOverlay({
snapGuidesRef,
overlayWidth,
overlayHeight,
compositionLeft,
compositionTop,
compositionWidth,
compositionHeight,
}: SnapGuideOverlayProps) {
const guideElsRef = useRef<(HTMLDivElement | null)[]>([]);
const spacingElsRef = useRef<(HTMLDivElement | null)[]>([]);
const spacingLabelElsRef = useRef<(HTMLSpanElement | null)[]>([]);
const overlayWidthRef = useRef(overlayWidth);
overlayWidthRef.current = overlayWidth;
const overlayHeightRef = useRef(overlayHeight);
overlayHeightRef.current = overlayHeight;
const compositionRectRef = useRef({
left: compositionLeft,
top: compositionTop,
width: compositionWidth,
height: compositionHeight,
});
compositionRectRef.current = {
left: compositionLeft,
top: compositionTop,
width: compositionWidth,
height: compositionHeight,
};
useMountEffect(() => {
let frame = 0;
@@ -44,8 +56,7 @@ export const SnapGuideOverlay = memo(function SnapGuideOverlay({
const state = snapGuidesRef.current;
const guides = state?.guides ?? [];
const spacingGuides = state?.spacingGuides ?? [];
const w = overlayWidthRef.current;
const h = overlayHeightRef.current;
const composition = compositionRectRef.current;
for (let i = 0; i < MAX_GUIDES; i++) {
const el = guideElsRef.current[i];
@@ -58,17 +69,11 @@ export const SnapGuideOverlay = memo(function SnapGuideOverlay({
}
el.style.display = "";
if (guide.axis === "x") {
el.style.left = `${guide.position}px`;
el.style.top = "0";
el.style.width = "1px";
el.style.height = `${h}px`;
} else {
el.style.left = "0";
el.style.top = `${guide.position}px`;
el.style.width = `${w}px`;
el.style.height = "1px";
}
const line = resolveGuideLineRect(guide, composition);
el.style.left = `${line.left}px`;
el.style.top = `${line.top}px`;
el.style.width = `${line.width}px`;
el.style.height = `${line.height}px`;
}
for (let i = 0; i < MAX_SPACING_GUIDES; i++) {
@@ -34,7 +34,6 @@ function AppHotkeyHarness() {
const leftSidebarRef = useRef<LeftSidebarHandle | null>(null);
useAppHotkeys({
toggleTimelineVisibility: vi.fn(),
handleTimelineElementDelete: vi.fn(),
handleTimelineElementSplit: vi.fn(),
handleDomEditElementDelete: vi.fn(),
@@ -0,0 +1,214 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import type { DomEditSelection } from "./domEditing";
import type { GestureState, UseDomEditOverlayGesturesOptions } from "./domEditOverlayGestures";
// Origin box (overlay px). The gesture-start center is its centroid; the
// center-anchored resize must keep THIS point planted, which means the release
// must commit a nonzero offset equal to minus half the size growth.
const ORIGIN = { left: 0, top: 0, width: 200, height: 100 };
const ORIGIN_CENTER = {
x: ORIGIN.left + ORIGIN.width / 2,
y: ORIGIN.top + ORIGIN.height / 2,
};
// Consistent geometry stub: model the physical truth the real DOM would report.
// A CSS width/height change grows the box from its top-left, so the rendered
// center drifts by half the size delta; the manual offset the gesture applies
// (read back from the element's studio vars) pulls it back. `elementCornerOverlayPoints`
// returns the four corners of that drifted box; `overlayCornersCentroid` (kept
// real) averages them so the anchor loop can measure the true center each frame.
vi.mock("./domEditOverlayGeometry", async () => {
const actual = await vi.importActual<typeof import("./domEditOverlayGeometry")>(
"./domEditOverlayGeometry",
);
const { readStudioBoxSize, readStudioPathOffset } = await import("./manualEditsDom");
const physicalCenter = (element: HTMLElement) => {
const size = readStudioBoxSize(element);
const width = size.width > 0 ? size.width : ORIGIN.width;
const height = size.height > 0 ? size.height : ORIGIN.height;
const offset = readStudioPathOffset(element);
return {
x: ORIGIN_CENTER.x + (width - ORIGIN.width) / 2 + offset.x,
y: ORIGIN_CENTER.y + (height - ORIGIN.height) / 2 + offset.y,
width,
height,
};
};
return {
...actual,
elementCornerOverlayPoints: (_o: unknown, _i: unknown, element: HTMLElement) => {
const c = physicalCenter(element);
const hw = c.width / 2;
const hh = c.height / 2;
return {
nw: { x: c.x - hw, y: c.y - hh },
ne: { x: c.x + hw, y: c.y - hh },
sw: { x: c.x - hw, y: c.y + hh },
se: { x: c.x + hw, y: c.y + hh },
};
},
orientedOverlayRect: (_o: unknown, _i: unknown, element: HTMLElement) => {
const c = physicalCenter(element);
return {
left: c.x - c.width / 2,
top: c.y - c.height / 2,
width: c.width,
height: c.height,
editScaleX: 1,
editScaleY: 1,
angle: 0,
};
},
};
});
const { createDomEditOverlayGestureHandlers } = await import("./useDomEditOverlayGestures");
function ref<T>(current: T) {
return { current };
}
interface CommitCall {
size: { width: number; height: number };
offset: { x: number; y: number } | undefined;
}
function buildHarness() {
const element = document.createElement("div");
document.body.append(element);
const selection = {
element,
id: "box",
selector: "#box",
selectorIndex: 0,
sourceFile: "index.html",
tagName: "div",
label: "Box",
textContent: "",
textFields: [],
capabilities: {
canEditText: false,
canEditLayout: true,
canMove: true,
canApplyManualOffset: true,
canApplyManualSize: true,
canApplyManualRotation: false,
canAdjustOpacity: true,
canAdjustFill: true,
canAdjustBorderRadius: true,
canAdjustStroke: true,
canAdjustShadow: true,
canAdjustZIndex: true,
},
computedStyle: { display: "block", position: "absolute" },
} as unknown as DomEditSelection;
const commits: CommitCall[] = [];
const overlayEl = document.createElement("div");
const iframe = document.createElement("iframe");
const opts: UseDomEditOverlayGesturesOptions = {
overlayRef: ref<HTMLDivElement | null>(overlayEl),
iframeRef: ref<HTMLIFrameElement | null>(iframe),
boxRef: ref<HTMLDivElement | null>(document.createElement("div")),
selectionRef: ref<DomEditSelection | null>(selection),
hoverSelectionRef: ref<DomEditSelection | null>(null),
overlayRectRef: ref<OverlayRectLike | null>({
left: ORIGIN.left,
top: ORIGIN.top,
width: ORIGIN.width,
height: ORIGIN.height,
editScaleX: 1,
editScaleY: 1,
}) as never,
groupOverlayItemsRef: ref([]),
gestureRef: ref<GestureState | null>(null),
groupGestureRef: ref(null),
blockedMoveRef: ref(null),
rafPausedRef: ref(false),
suppressNextBoxClickRef: ref(false),
setOverlayRect: () => {},
setGroupOverlayItems: () => {},
onBlockedMoveRef: ref(() => {}),
onManualDragStartRef: ref(() => {}),
onPathOffsetCommitRef: ref(() => {}),
onGroupPathOffsetCommitRef: ref(() => {}),
onBoxSizeCommitRef: ref((_s, size, offset) => {
commits.push({ size, offset });
}),
onRotationCommitRef: ref(() => {}),
onCanvasPointerMoveRef: ref(() => Promise.resolve(null)),
onCanvasMouseDown: () => {},
snapGuidesRef: ref(null),
};
const handlers = createDomEditOverlayGestureHandlers(opts);
return { handlers, commits, selection };
}
type OverlayRectLike = {
left: number;
top: number;
width: number;
height: number;
editScaleX: number;
editScaleY: number;
};
function evt(clientX: number, clientY: number) {
return {
clientX,
clientY,
pointerId: 1,
button: 0,
altKey: false,
shiftKey: false,
preventDefault() {},
stopPropagation() {},
currentTarget: { setPointerCapture() {} },
} as unknown as React.PointerEvent<HTMLDivElement>;
}
afterEach(() => {
document.body.innerHTML = "";
});
describe("anchored corner resize — the release commit feeds the center-pin offset", () => {
it("onPointerUp passes a nonzero offset that keeps the center planted", () => {
const { handlers, commits } = buildHarness();
// Start an SE corner resize. Pointer starts 100px right of the center.
handlers.startGesture("resize", evt(ORIGIN_CENTER.x + 100, ORIGIN_CENTER.y), {
resizeHandle: "se",
});
// Drag outward to radial scale 1.5 (dist 150 / 100). Several frames so the
// per-frame center-pin anchor accumulates and converges into g.lastResizeAnchor.
for (let i = 0; i < 5; i++) {
handlers.onPointerMove(evt(ORIGIN_CENTER.x + 150, ORIGIN_CENTER.y));
}
handlers.onPointerUp(evt(ORIGIN_CENTER.x + 150, ORIGIN_CENTER.y));
expect(commits).toHaveLength(1);
const { size, offset } = commits[0]!;
// Proportional 1.5x growth of the 200x100 base.
expect(size.width).toBeCloseTo(300, 0);
expect(size.height).toBeCloseTo(150, 0);
// The committed offset must be present and nonzero — the open question.
expect(offset).toBeDefined();
if (!offset) return;
expect(offset.x).not.toBe(0);
expect(offset.y).not.toBe(0);
// And it must equal minus half the size growth, i.e. it re-pins the center to
// exactly the gesture-start center (offset = -(finalSize - origin)/2).
expect(offset.x).toBeCloseTo(-(size.width - ORIGIN.width) / 2, 0);
expect(offset.y).toBeCloseTo(-(size.height - ORIGIN.height) / 2, 0);
});
});
@@ -8,6 +8,7 @@ import {
} from "./manualEditsDom";
import { buildBoxSizePatches, buildPathOffsetPatches } from "./manualEditsDomPatches";
import { createManualOffsetDragMember, applyManualOffsetDragCommit } from "./manualOffsetDrag";
import { computeNextResizeAnchor } from "./domEditResizeLocal";
import type { PatchOperation } from "../../utils/sourcePatcher";
import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
@@ -16,15 +17,25 @@ import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
* 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.
* Root cause of the original release "shift" (proved with a real-layout Chromium
* replay, see the anchor-loop test below): during a resize drag the per-frame
* anchor is derived from the element's LIVE measured center which already carries
* the offset applied on the PREVIOUS frame while `applyManualOffsetDragDraft`
* treats that anchor as the ABSOLUTE offset. So `fixedStart - centerNow` is really
* only the RESIDUAL correction, and using it as the absolute value makes the anchor
* OSCILLATE between the correct value and zero every frame:
* frame 0: offset 0 center shifted by the resize anchor = full amount apply
* frame 1: offset applied center back at fixedStart anchor = 0 apply 0 (un-pin!)
* frame 2: offset 0 again anchor = full amount ...
* Release commits `g.lastResizeAnchor` from whichever parity the last pointermove
* landed on, so the element lands EITHER pinned OR un-pinned an unpredictable
* post-release "shift".
*
* Fix (useDomEditOverlayGestures pointermove, resize branch, fa4f39168): accumulate
* the residual onto the previously-applied anchor instead of using it as the
* absolute offset, so the loop converges to a stable value on every frame. The
* per-frame accumulation is the exported `computeNextResizeAnchor` helper (the one
* call site in the pointermove resize branch); tests 1 & 2 drive it directly.
*/
afterEach(() => {
@@ -66,10 +77,95 @@ function resolvedTranslatePx(el: HTMLElement): { x: number; y: number } {
}
describe("center-anchored corner resize — no shift after release", () => {
it("the per-frame center anchor converges (does NOT oscillate) — the release-shift root cause", () => {
// Model the pointermove anchor loop that pins the element's CENTER. The physical
// truth (confirmed in a real browser): the measured center sits at
// `fixedStart - appliedOffset` shifted by the resize — i.e. applying the offset
// moves the center back toward its gesture-start position. Here scale=1 so
// screen px == offset px. `trueAnchor` is the compensating offset that pins it.
const trueAnchor = { dx: -60, dy: -27 };
const fixedStart = { x: 500, y: 270 };
// `appliedOffset` mirrors what applyManualOffsetDragDraft set last frame.
let appliedOffset = { x: 0, y: 0 };
// `lastResizeAnchor` accumulator, exactly as g.lastResizeAnchor in the fix.
let lastResizeAnchor: { dx: number; dy: number } | undefined;
const anchorsSeen: Array<{ dx: number; dy: number }> = [];
for (let frame = 0; frame < 8; frame++) {
// Live measured center: the resize would put it at fixedStart + trueAnchor
// (un-anchored), and the currently-applied offset pulls it back by that
// offset. So centerNow = fixedStart - trueAnchor + appliedOffset.
const centerNow = {
x: fixedStart.x - trueAnchor.dx + appliedOffset.x,
y: fixedStart.y - trueAnchor.dy + appliedOffset.y,
};
// ── The fixed logic (accumulate residual onto the previous anchor) ──
const anchor = computeNextResizeAnchor(lastResizeAnchor, fixedStart, centerNow);
lastResizeAnchor = anchor;
anchorsSeen.push(anchor);
// applyManualOffsetDragDraft sets the absolute offset (scale 1) = anchor.
appliedOffset = { x: anchor.dx, y: anchor.dy };
}
// Every frame must report the same, correct anchor — no oscillation, so the
// committed value is parity-independent.
for (const a of anchorsSeen) {
expect(a).toEqual(trueAnchor);
}
// Guard against the OLD absolute formula regressing: with `anchor =
// fixedStart - centerNow` (no accumulation) the sequence would be
// [trueAnchor, 0, trueAnchor, 0, ...]; assert the last two frames agree.
expect(anchorsSeen.at(-1)).toEqual(anchorsSeen.at(-2));
});
it("the center stays fixed for every corner at any rotation (loop converges)", () => {
// The pin loop is handle- and rotation-independent: it always measures the
// element CENTER and drives it back to fixedStart. Simulate the loop for all
// four corners across unrotated + rotated gestures; the resize's raw center
// shift varies with corner/rotation (modelled as `rawShift`), but the loop must
// converge the measured center onto fixedStart every time.
const fixedStart = { x: 640, y: 360 };
const HANDLES = ["nw", "ne", "sw", "se"] as const;
const DEGS = [0, 30, 90, 137];
for (const handle of HANDLES) {
for (const deg of DEGS) {
const t = (deg * Math.PI) / 180;
// Raw (un-pinned) center shift the size write would cause this frame —
// a corner/rotation-dependent vector. Its exact value is irrelevant; the
// loop only needs to cancel it.
const seed = (HANDLES.indexOf(handle) + 1) * 11;
const rawShift = {
dx: Math.cos(t) * seed - Math.sin(t) * (seed / 2),
dy: Math.sin(t) * seed + Math.cos(t) * (seed / 2),
};
let appliedOffset = { x: 0, y: 0 };
let lastResizeAnchor: { dx: number; dy: number } | undefined;
let centerNow = { x: fixedStart.x, y: fixedStart.y };
for (let frame = 0; frame < 6; frame++) {
centerNow = {
x: fixedStart.x + rawShift.dx + appliedOffset.x,
y: fixedStart.y + rawShift.dy + appliedOffset.y,
};
const anchor = computeNextResizeAnchor(lastResizeAnchor, fixedStart, centerNow);
lastResizeAnchor = anchor;
appliedOffset = { x: anchor.dx, y: anchor.dy };
}
// After convergence the pinned center equals the gesture-start center.
const pinnedCenter = {
x: fixedStart.x + rawShift.dx + appliedOffset.x,
y: fixedStart.y + rawShift.dy + appliedOffset.y,
};
expect(pinnedCenter.x).toBeCloseTo(fixedStart.x, 9);
expect(pinnedCenter.y).toBeCloseTo(fixedStart.y, 9);
}
}
});
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).
// loop above, not in persistence).
const el = document.createElement("div");
el.style.setProperty("width", "200px");
el.style.setProperty("height", "100px");
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import {
CANVAS_NUDGE_SHIFT_STEP_PX,
CANVAS_NUDGE_STEP_PX,
canCanvasNudgeTargets,
resolveCanvasNudgeDelta,
} from "./domEditNudge";
function mockKeyboardEvent(
key: string,
overrides: Partial<Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "shiftKey">> = {},
): Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "shiftKey" | "key"> {
return {
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
key,
...overrides,
};
}
describe("resolveCanvasNudgeDelta", () => {
it("maps plain arrows to 1px composition deltas", () => {
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowLeft"))).toEqual({
dx: -CANVAS_NUDGE_STEP_PX,
dy: 0,
});
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowRight"))).toEqual({
dx: CANVAS_NUDGE_STEP_PX,
dy: 0,
});
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowUp"))).toEqual({
dx: 0,
dy: -CANVAS_NUDGE_STEP_PX,
});
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowDown"))).toEqual({
dx: 0,
dy: CANVAS_NUDGE_STEP_PX,
});
});
it("maps Shift+arrow to 10px deltas", () => {
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowRight", { shiftKey: true }))).toEqual({
dx: CANVAS_NUDGE_SHIFT_STEP_PX,
dy: 0,
});
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowUp", { shiftKey: true }))).toEqual({
dx: 0,
dy: -CANVAS_NUDGE_SHIFT_STEP_PX,
});
});
it("ignores browser and app shortcut chords", () => {
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowLeft", { altKey: true }))).toBeNull();
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowLeft", { ctrlKey: true }))).toBeNull();
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowLeft", { metaKey: true }))).toBeNull();
});
it("ignores non-arrow keys", () => {
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("Escape"))).toBeNull();
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("a"))).toBeNull();
});
});
describe("canCanvasNudgeTargets", () => {
const movable = { capabilities: { canApplyManualOffset: true } };
const locked = { capabilities: { canApplyManualOffset: false } };
it("requires at least one target", () => {
expect(canCanvasNudgeTargets([])).toBe(false);
});
it("allows only when every target accepts a manual offset", () => {
expect(canCanvasNudgeTargets([movable])).toBe(true);
expect(canCanvasNudgeTargets([movable, movable])).toBe(true);
expect(canCanvasNudgeTargets([locked])).toBe(false);
expect(canCanvasNudgeTargets([movable, locked])).toBe(false);
});
});
@@ -0,0 +1,44 @@
/**
* Pure helpers for the canvas arrow-key nudge (DomEditOverlay).
*
* Mirrors captions/keyboard.ts conventions; the two nudge surfaces can't
* double-fire because PreviewOverlays mounts CaptionOverlay and DomEditOverlay
* mutually exclusively (caption edit mode returns before the DOM overlay).
*/
const CANVAS_NUDGE_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]);
export const CANVAS_NUDGE_STEP_PX = 1;
export const CANVAS_NUDGE_SHIFT_STEP_PX = 10;
/** One undo entry per key burst: the commit fires after this idle gap. */
export const CANVAS_NUDGE_COMMIT_DEBOUNCE_MS = 400;
type CanvasNudgeKeyEvent = Pick<
KeyboardEvent,
"altKey" | "ctrlKey" | "metaKey" | "shiftKey" | "key"
>;
/**
* Arrow key composition-px delta (Shift = 10). Null when the key is not a
* plain/Shift arrow, so browser and app shortcut chords pass through.
*/
export function resolveCanvasNudgeDelta(
event: CanvasNudgeKeyEvent,
): { dx: number; dy: number } | null {
if (event.metaKey || event.ctrlKey || event.altKey) return null;
if (!CANVAS_NUDGE_KEYS.has(event.key)) return null;
const step = event.shiftKey ? CANVAS_NUDGE_SHIFT_STEP_PX : CANVAS_NUDGE_STEP_PX;
return {
dx: event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0,
dy: event.key === "ArrowUp" ? -step : event.key === "ArrowDown" ? step : 0,
};
}
export interface CanvasNudgeTarget {
capabilities: { canApplyManualOffset: boolean };
}
/** A nudge needs at least one target and every target must accept manual offsets. */
export function canCanvasNudgeTargets(targets: ReadonlyArray<CanvasNudgeTarget>): boolean {
return targets.length > 0 && targets.every((t) => t.capabilities.canApplyManualOffset);
}
@@ -1,6 +1,37 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { selectionCacheKey } from "./domEditOverlayGeometry";
import {
orientedOverlayRect,
orientedGroupAwareOverlayRect,
overlayCornersCentroid,
selectionCacheKey,
} from "./domEditOverlayGeometry";
describe("overlayCornersCentroid", () => {
it("averages the four corners (the rendered rotation center)", () => {
expect(
overlayCornersCentroid({
nw: { x: 10, y: 20 },
ne: { x: 110, y: 20 },
se: { x: 110, y: 80 },
sw: { x: 10, y: 80 },
}),
).toEqual({ x: 60, y: 50 });
});
it("is unchanged by rotation — a rotated square's corners average to its center", () => {
// Unit square centered at (5,5), rotated 45deg about its center: corners land
// on the axis midpoints, whose average is still the center.
const c = overlayCornersCentroid({
nw: { x: 5, y: 5 - Math.SQRT2 / 2 },
ne: { x: 5 + Math.SQRT2 / 2, y: 5 },
se: { x: 5, y: 5 + Math.SQRT2 / 2 },
sw: { x: 5 - Math.SQRT2 / 2, y: 5 },
});
expect(c.x).toBeCloseTo(5, 9);
expect(c.y).toBeCloseTo(5, 9);
});
});
describe("selectionCacheKey — hfId collision (R7)", () => {
it("produces distinct keys for two elements that differ only by hfId", () => {
@@ -11,3 +42,138 @@ describe("selectionCacheKey — hfId collision (R7)", () => {
expect(a).not.toBe(b);
});
});
describe("orientedOverlayRect — rotation gate (perf fix, V15 18a/18b)", () => {
// jsdom has no DOMMatrix/DOMPoint; a minimal stand-in is enough to exercise the
// real (unmocked) orientedOverlayRect, unlike DomEditOverlay.test.ts and
// anchoredResizeCommitFeedsOffset.test.ts, which mock this module entirely.
class FakeDOMMatrix {
a = 1;
b = 0;
c = 0;
d = 1;
e = 0;
f = 0;
constructor(init?: string) {
const m = init ? /matrix\(([^)]+)\)/.exec(init) : null;
if (!m) return;
const parts = m[1]!.split(",").map((s) => Number.parseFloat(s.trim()));
[this.a, this.b, this.c, this.d, this.e, this.f] = parts as [
number,
number,
number,
number,
number,
number,
];
}
transformPoint(pt: { x: number; y: number }) {
return {
x: this.a * pt.x + this.c * pt.y + this.e,
y: this.b * pt.x + this.d * pt.y + this.f,
};
}
}
class FakeDOMPoint {
constructor(
public x: number,
public y: number,
) {}
}
// matrix() form of rotate(30deg), so the module's `new DOMMatrix(cs.transform)`
// parse (which expects "matrix(...)", not "rotate(...)") resolves correctly.
const ROTATE_30DEG_MATRIX =
"matrix(0.8660254037844387, 0.49999999999999994, -0.49999999999999994, 0.8660254037844387, 0, 0)";
function stubRect(
el: Element,
rect: { left: number; top: number; width: number; height: number },
) {
(el as unknown as { getBoundingClientRect: () => DOMRect }).getBoundingClientRect = () =>
({
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
right: rect.left + rect.width,
bottom: rect.top + rect.height,
x: rect.left,
y: rect.top,
toJSON() {
return this;
},
}) as DOMRect;
}
function buildHarness() {
const overlayEl = document.createElement("div");
document.body.appendChild(overlayEl);
stubRect(overlayEl, { left: 0, top: 0, width: 1000, height: 1000 });
const iframe = document.createElement("iframe");
document.body.appendChild(iframe);
stubRect(iframe, { left: 0, top: 0, width: 1000, height: 1000 });
const doc = iframe.contentDocument!;
const root = doc.createElement("div");
root.setAttribute("data-composition-id", "root");
root.setAttribute("data-width", "1000");
root.setAttribute("data-height", "1000");
doc.body.appendChild(root);
const el = doc.createElement("div");
root.appendChild(el);
stubRect(el, { left: 400, top: 450, width: 200, height: 100 });
Object.defineProperty(el, "offsetWidth", { value: 200, configurable: true });
Object.defineProperty(el, "offsetHeight", { value: 100, configurable: true });
const win = iframe.contentWindow as unknown as Window & {
DOMMatrix: unknown;
DOMPoint: unknown;
};
win.DOMMatrix = FakeDOMMatrix;
win.DOMPoint = FakeDOMPoint;
return { overlayEl, iframe, el };
}
it("unrotated element takes the cheap AABB path — matches the raw bounding rect, angle 0", () => {
const { overlayEl, iframe, el } = buildHarness();
const rect = orientedOverlayRect(overlayEl, iframe, el);
expect(rect).not.toBeNull();
expect(rect!.left).toBeCloseTo(400, 5);
expect(rect!.top).toBeCloseTo(450, 5);
expect(rect!.width).toBeCloseTo(200, 5);
expect(rect!.height).toBeCloseTo(100, 5);
expect(rect!.angle ?? 0).toBe(0);
});
it("rotated element takes the corner-geometry path — reports the live angle", () => {
const { overlayEl, iframe, el } = buildHarness();
el.style.transform = ROTATE_30DEG_MATRIX;
const rect = orientedOverlayRect(overlayEl, iframe, el);
expect(rect).not.toBeNull();
expect(rect!.angle).toBeCloseTo(30, 3);
});
it("preserves an ordinary element's rotation through the group-aware entry point", () => {
const { overlayEl, iframe, el } = buildHarness();
el.style.transform = ROTATE_30DEG_MATRIX;
const rect = orientedGroupAwareOverlayRect(overlayEl, iframe, el);
expect(rect!.angle).toBeCloseTo(30, 3);
});
it("gate re-evaluates every call — editing an element to rotated mid-session flips the path immediately", () => {
const { overlayEl, iframe, el } = buildHarness();
const before = orientedOverlayRect(overlayEl, iframe, el);
expect(before?.angle ?? 0).toBe(0);
el.style.transform = ROTATE_30DEG_MATRIX;
const after = orientedOverlayRect(overlayEl, iframe, el);
expect(after!.angle).toBeCloseTo(30, 3);
el.style.transform = "";
const restored = orientedOverlayRect(overlayEl, iframe, el);
expect(restored?.angle ?? 0).toBe(0);
});
});
@@ -9,6 +9,14 @@ export interface OverlayRect {
height: number;
editScaleX: number;
editScaleY: number;
/**
* The element's live transform rotation in DEGREES (screen/CSS convention, CW
* positive), decomposed from its computed transform matrix. Present so the
* selection chrome can render as an oriented bounding box (OBB) that co-rotates
* with the element. Omitted (treated as 0) for group/union rects and when the
* transform is unmeasurable those render axis-aligned exactly as before.
*/
angle?: number;
}
export interface GroupOverlayItem {
@@ -98,31 +106,115 @@ export function toVisibleOverlayRect(
return rect ? { ...rect, ...hugRectForElement(rect, element) } : null;
}
export function toOverlayRect(
/**
* getComputedStyle(element).transform decomposed into a DOMMatrix, read ONCE.
* Shared by orientedOverlayRect's rotation gate and elementCornerOverlayPoints
* so a single measurement pass serves both constructing this twice per frame
* (one read per consumer) was redundant work; see orientedOverlayRect below.
*/
interface ElementTransformSnapshot {
matrix: DOMMatrix;
cs: CSSStyleDeclaration;
}
function readElementTransformSnapshot(
win: Window,
element: HTMLElement,
): ElementTransformSnapshot | null {
const DOMMatrixCtor = (win as Window & typeof globalThis).DOMMatrix;
if (!DOMMatrixCtor) return null;
const cs = win.getComputedStyle(element);
try {
const matrix = new DOMMatrixCtor(cs.transform === "none" ? "" : cs.transform);
return { matrix, cs };
} catch {
return null;
}
}
/**
* The element's live transform rotation, in DEGREES (screen/CSS convention, CW
* positive), decomposed from its transform matrix (rotation = atan2(b, a)).
* GSAP folds rotation and scale into the same matrix; this reads rotation only.
* Skew is ignored (does not affect atan2(b, a)).
*/
function rotationDegreesFromMatrix(matrix: DOMMatrix): number {
const a = Number.isFinite(matrix.a) ? matrix.a : 1;
const b = Number.isFinite(matrix.b) ? matrix.b : 0;
const deg = (Math.atan2(b, a) * 180) / Math.PI;
return Number.isFinite(deg) ? deg : 0;
}
/** Below this, orientedOverlayRect treats the element as unrotated and returns
* the AABB directly (see its doc comment) tight enough to only swallow
* matrix-decomposition floating-point noise, never an actual rotation. */
const ROTATION_GATE_EPSILON_DEG = 1e-4;
/** iframe→overlay mapping basis shared by every overlay-geometry function. */
interface OverlayRootScale {
iframeRect: DOMRect;
overlayRect: DOMRect;
rootScaleX: number;
rootScaleY: number;
}
/** The composition root element inside the preview doc (or null when absent). */
function findOverlayRootElement(doc: Document | null): HTMLElement | null {
return doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement ?? null;
}
/**
* The root's effective width/height for scaling: prefer the composition's
* declared dimensions (data-width/data-height), which stay fixed while GSAP
* transforms mutate the measured rect; fall back to the measured rect. Null when
* unmeasurable.
*/
function resolveRootDimensions(root: HTMLElement | null): { width: number; height: number } | null {
if (!root) return null;
const rootRect = root.getBoundingClientRect();
const width = readPositiveDimension(root.getAttribute("data-width")) ?? rootRect.width;
const height = readPositiveDimension(root.getAttribute("data-height")) ?? rootRect.height;
if (!width || !height) return null;
return { width, height };
}
/**
* The iframe/overlay client rects and the iframeroot scale factors. Uses the
* composition's declared dimensions (data-width/data-height) for the scale
* instead of rootRect.width/height: when GSAP applies transforms (scale,
* translate) to the root, rootRect dimensions change but the composition's
* canonical size stays fixed, and using rootRect misaligns the overlay during
* animated playback. Returns null when the geometry is unmeasurable.
*/
function computeOverlayRootScale(
overlayEl: HTMLDivElement,
iframe: HTMLIFrameElement,
doc: Document | null,
): OverlayRootScale | null {
const iframeRect = iframe.getBoundingClientRect();
const overlayRect = overlayEl.getBoundingClientRect();
const dims = resolveRootDimensions(findOverlayRootElement(doc));
if (!dims) return null;
return {
iframeRect,
overlayRect,
rootScaleX: iframeRect.width / dims.width,
rootScaleY: iframeRect.height / dims.height,
};
}
function toOverlayRect(
overlayEl: HTMLDivElement,
iframe: HTMLIFrameElement,
element: HTMLElement,
precomputedScale?: OverlayRootScale | null,
): OverlayRect | null {
const iframeRect = iframe.getBoundingClientRect();
const overlayRect = overlayEl.getBoundingClientRect();
const doc = iframe.contentDocument;
const root =
doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement ?? null;
const rootRect = root?.getBoundingClientRect();
// Use the composition's declared dimensions (data-width/data-height) for scale
// calculation instead of rootRect.width/height. When GSAP applies transforms
// (scale, translate) to the root element, rootRect dimensions change but the
// composition's canonical size stays the same. Using rootRect causes overlay
// misalignment during animated playback.
const declaredWidth = readPositiveDimension(root?.getAttribute("data-width") ?? null);
const declaredHeight = readPositiveDimension(root?.getAttribute("data-height") ?? null);
const rootWidth = declaredWidth ?? rootRect?.width;
const rootHeight = declaredHeight ?? rootRect?.height;
if (!rootWidth || !rootHeight || !rootRect) return null;
const scale =
precomputedScale ?? computeOverlayRootScale(overlayEl, iframe, iframe.contentDocument);
if (!scale) return null;
const { iframeRect, overlayRect, rootScaleX, rootScaleY } = scale;
const elementRect = element.getBoundingClientRect();
const rootScaleX = iframeRect.width / rootWidth;
const rootScaleY = iframeRect.height / rootHeight;
const sourceBoundary = findSourceBoundary(element);
const sourceBoundaryRect = sourceBoundary?.getBoundingClientRect();
const editScale = resolveDomEditCoordinateScale({
@@ -144,7 +236,163 @@ export function toOverlayRect(
};
}
/** Which physical corner of the (possibly rotated) element a resize handle keeps
* fixed: NW grabs the top-left, so the bottom-right (se) is the anchor, etc. */
export type FixedCorner = "nw" | "ne" | "sw" | "se";
/** Distance between two overlay-px corner points the edge-length math
* orientedOverlayRect uses to turn corners into a width/height. Exported so a
* caller already holding raw corners (e.g. a resize gesture mid-measurement)
* can derive the same dimensions without a second orientedOverlayRect call. */
export function cornerEdgeLength(a: { x: number; y: number }, b: { x: number; y: number }): number {
return Math.hypot(b.x - a.x, b.y - a.y);
}
/**
* The centroid (rendered center) of the four transformed corners from
* `elementCornerOverlayPoints`, in overlay px. This is the element's true rotation
* center the point a center-anchored resize keeps planted.
*/
export function overlayCornersCentroid(corners: Record<FixedCorner, { x: number; y: number }>): {
x: number;
y: number;
} {
return {
x: (corners.nw.x + corners.ne.x + corners.se.x + corners.sw.x) / 4,
y: (corners.nw.y + corners.ne.y + corners.se.y + corners.sw.y) / 4,
};
}
/**
* The element's border-box corners in OVERLAY coordinates, honoring its live
* transform (rotation/skew/scale) NOT the axis-aligned getBoundingClientRect.
* A rotated element's four visual corners are the transformed local box corners.
* Uses the same iframeoverlay root scale as toOverlayRect so the returned
* points share that function's coordinate space. Returns null when the
* geometry is unmeasurable.
*/
export function elementCornerOverlayPoints(
overlayEl: HTMLDivElement,
iframe: HTMLIFrameElement,
element: HTMLElement,
precomputedScale?: OverlayRootScale | null,
precomputedTransform?: ElementTransformSnapshot | null,
): Record<FixedCorner, { x: number; y: number }> | null {
const win = iframe.contentWindow;
const doc = iframe.contentDocument;
if (!win || !doc) return null;
const DOMPointCtor = (win as Window & typeof globalThis).DOMPoint;
if (!DOMPointCtor) return null;
const scale = precomputedScale ?? computeOverlayRootScale(overlayEl, iframe, doc);
if (!scale) return null;
const { iframeRect, overlayRect, rootScaleX, rootScaleY } = scale;
// The element's local border box maps to viewport coords by the SAME transform
// matrix the browser used for its BCR. We recover the transform's screen-space
// action from the BCR: transformPoint(localCorner - origin) gives a corner
// RELATIVE to the transformed origin. We anchor those relative corners to the
// BCR by matching the AABB of the transformed corners to the real BCR — the
// constant offset cancels in the before/after difference the caller takes, but
// we resolve it fully here so callers can also read absolute overlay positions.
const transform = precomputedTransform ?? readElementTransformSnapshot(win, element);
if (!transform) return null;
const { matrix, cs } = transform;
const w = element.offsetWidth;
const h = element.offsetHeight;
const originParts = cs.transformOrigin.split(" ").map((p) => Number.parseFloat(p));
const ox = Number.isFinite(originParts[0]!) ? originParts[0]! : w / 2;
const oy = Number.isFinite(originParts[1]!) ? originParts[1]! : h / 2;
const rel = (lx: number, ly: number): { x: number; y: number } => {
const p = matrix.transformPoint(new DOMPointCtor(lx - ox, ly - oy));
return { x: p.x, y: p.y };
};
const relCorners = {
nw: rel(0, 0),
ne: rel(w, 0),
se: rel(w, h),
sw: rel(0, h),
};
// Recover the absolute viewport position by matching to the element's BCR:
// the relative corners' AABB min corresponds to the BCR's top-left.
const xs = [relCorners.nw.x, relCorners.ne.x, relCorners.se.x, relCorners.sw.x];
const ys = [relCorners.nw.y, relCorners.ne.y, relCorners.se.y, relCorners.sw.y];
const bcr = element.getBoundingClientRect();
const dx = bcr.left - Math.min(...xs);
const dy = bcr.top - Math.min(...ys);
const toOverlay = (pt: { x: number; y: number }): { x: number; y: number } => ({
x: iframeRect.left - overlayRect.left + (pt.x + dx) * rootScaleX,
y: iframeRect.top - overlayRect.top + (pt.y + dy) * rootScaleY,
});
return {
nw: toOverlay(relCorners.nw),
ne: toOverlay(relCorners.ne),
se: toOverlay(relCorners.se),
sw: toOverlay(relCorners.sw),
};
}
/**
* The selection chrome's ORIENTED bounding box: the element's UNROTATED border box
* expressed in overlay coordinates (center-anchored left/top/width/height) plus the
* live rotation angle. Rendering that rect with `transform: rotate(angle)` about its
* center reproduces the element's real transformed corners exactly, so the border,
* corner dots, rotate handle, and crop pills all co-rotate with the object.
*
* Built from `elementCornerOverlayPoints` (the real transformed corners): the OBB
* center is the corner centroid, the unrotated width/height are the edge lengths, and
* left/top place the unrotated box so that rotating it about its center lands the
* corners back on the measured points. At angle 0 this equals `toOverlayRect` (the
* AABB and OBB coincide), so unrotated chrome is pixel-identical to today.
*
* Returns the plain AABB rect (angle 0) when the corner geometry can't be measured.
*
* Rotation gate: an unrotated element's OBB is numerically identical to its AABB
* (the comment above), so a cheap rotation read decides up front whether the
* (much pricier) corner-transform pass runs at all for the overwhelming
* majority of selections, which aren't rotated, this call is just `toOverlayRect`
* plus one getComputedStyle/DOMMatrix read. The root scale and the transform
* snapshot are each computed once per call and threaded into both the rotation
* read and the corner math, instead of every helper re-measuring independently.
*/
export function orientedOverlayRect(
overlayEl: HTMLDivElement,
iframe: HTMLIFrameElement,
element: HTMLElement,
): OverlayRect | null {
const scale = computeOverlayRootScale(overlayEl, iframe, iframe.contentDocument);
if (!scale) return null;
const base = toOverlayRect(overlayEl, iframe, element, scale);
if (!base) return null;
const win = iframe.contentWindow;
const transform = win ? readElementTransformSnapshot(win, element) : null;
const angle = transform ? rotationDegreesFromMatrix(transform.matrix) : 0;
if (Math.abs(angle) < ROTATION_GATE_EPSILON_DEG) return base;
const corners = elementCornerOverlayPoints(overlayEl, iframe, element, scale, transform);
if (!corners) return base;
// Unrotated edge lengths (in overlay px): nw→ne is the width, nw→sw the height.
const width = cornerEdgeLength(corners.nw, corners.ne);
const height = cornerEdgeLength(corners.nw, corners.sw);
const centerX = (corners.nw.x + corners.se.x) / 2;
const centerY = (corners.nw.y + corners.se.y) / 2;
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return base;
}
return {
left: centerX - width / 2,
top: centerY - height / 2,
width,
height,
editScaleX: base.editScaleX,
editScaleY: base.editScaleY,
angle,
};
}
const OVERLAY_RECT_EPSILON_PX = 0.5;
const OVERLAY_RECT_ANGLE_EPSILON_DEG = 0.1;
export function rectsEqual(a: OverlayRect | null, b: OverlayRect | null): boolean {
if (a === b) return true;
@@ -155,7 +403,8 @@ export function rectsEqual(a: OverlayRect | null, b: OverlayRect | null): boolea
Math.abs(a.width - b.width) < OVERLAY_RECT_EPSILON_PX &&
Math.abs(a.height - b.height) < OVERLAY_RECT_EPSILON_PX &&
Math.abs(a.editScaleX - b.editScaleX) < 0.001 &&
Math.abs(a.editScaleY - b.editScaleY) < 0.001
Math.abs(a.editScaleY - b.editScaleY) < 0.001 &&
Math.abs((a.angle ?? 0) - (b.angle ?? 0)) < OVERLAY_RECT_ANGLE_EPSILON_DEG
);
}
@@ -228,6 +477,17 @@ export function groupAwareOverlayRect(
return { ...union, editScaleX: rect.editScaleX, editScaleY: rect.editScaleY };
}
/** Groups stay axis-aligned unions; ordinary elements keep their oriented box. */
export function orientedGroupAwareOverlayRect(
overlayEl: HTMLDivElement,
iframe: HTMLIFrameElement,
el: HTMLElement,
): OverlayRect | null {
return el.hasAttribute("data-hf-group")
? groupAwareOverlayRect(overlayEl, iframe, el)
: orientedOverlayRect(overlayEl, iframe, el);
}
export function filterNestedDomEditGroupItems<T extends { element: HTMLElement }>(items: T[]): T[] {
return items.filter(
(item) => !items.some((other) => other !== item && other.element.contains(item.element)),
@@ -13,10 +13,20 @@ import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction"
export type GestureKind = "drag" | "resize" | "rotate";
/** Which corner handle initiated a resize gesture. */
export type ResizeHandle = "nw" | "ne" | "sw" | "se";
export const BLOCKED_MOVE_THRESHOLD_PX = 4;
const MIN_RESIZE_EDGE_PX = 20;
const ROTATION_COMMIT_EPSILON_DEGREES = 0.05;
const ROTATION_SNAP_DEGREES = 15;
/**
* Above this rotation, resize/move edge-snapping is bypassed. Industry editors
* (tldraw/Figma) don't edge-snap rotated boxes the snap targets are axis-aligned
* AABBs, so snapping a rotated box's AABB to them shifts the box in a way the user
* can't predict; a wrong snap is worse than none. Rotation ~0 keeps snapping exactly
* as before.
*/
export const ROTATED_SNAP_BYPASS_DEGREES = 0.5;
export interface GestureState {
kind: GestureKind;
@@ -62,6 +72,19 @@ export interface GestureState {
snapContext?: SnapContext;
lastSnappedDx?: number;
lastSnappedDy?: number;
/** Corner the resize gesture grabbed (resize gestures only). */
resizeHandle?: ResizeHandle;
/** Last anchoring translation applied during a corner resize (overlay px). */
lastResizeAnchor?: { dx: number; dy: number };
/**
* The element's rendered CENTER in overlay px at gesture start (the centroid of
* its four real possibly rotated corners). A center-anchored resize keeps this
* point pinned; the per-frame anchor translation is computed as the shift of this
* exact center, not an AABB width/height delta (which only holds the center still
* when the element grows symmetrically from an unrotated layout box). Undefined
* when the corner geometry can't be measured (member creation still succeeded).
*/
resizeFixedCenterStart?: { x: number; y: number };
}
export interface GroupGestureState {
@@ -89,48 +112,23 @@ export function focusDomEditOverlayElement(element: FocusableDomEditOverlay | nu
element?.focus({ preventScroll: true });
}
export function resolveDomEditResizeGesture(input: {
/**
* Overlay-px translation that keeps the element's CENTER fixed while a corner
* resizes: a CSS width/height change grows the layout box from its top-left, so
* the center drifts by half the size change on each axis; translating back by that
* half-delta re-pins the center. This is the UNROTATED (AABB) fallback used only
* when the element's real transformed corners can't be measured the primary path
* pins the measured center (rotation-safe) in useDomEditOverlayGestures.
*/
export function resolveResizeCenterAnchorOffset(input: {
originWidth: number;
originHeight: number;
actualWidth: number;
actualHeight: number;
scaleX: number;
scaleY: number;
// Rendered-per-CSS-pixel factor of the element itself (its live GSAP scale).
// The CSS width/height the draft writes get multiplied by this on screen, so
// the cursor delta must be divided by it — otherwise the box outruns the
// pointer on a rescaled element and snaps back on release. Defaults to 1.
contentScaleX?: number;
contentScaleY?: number;
dx: number;
dy: number;
uniform: boolean;
}): { overlayWidth: number; overlayHeight: number; width: number; height: number } {
const scaleX = input.scaleX > 0 ? input.scaleX : 1;
const scaleY = input.scaleY > 0 ? input.scaleY : 1;
const contentScaleX =
input.contentScaleX !== undefined && input.contentScaleX > 0 ? input.contentScaleX : 1;
const contentScaleY =
input.contentScaleY !== undefined && input.contentScaleY > 0 ? input.contentScaleY : 1;
if (input.uniform) {
const deltaX = input.dx / (scaleX * contentScaleX);
const deltaY = input.dy / (scaleY * contentScaleY);
const delta = Math.abs(deltaX) >= Math.abs(deltaY) ? deltaX : deltaY;
const side = Math.max(1, Math.max(input.actualWidth, input.actualHeight) + delta);
return {
overlayWidth: Math.max(MIN_RESIZE_EDGE_PX, side * scaleX * contentScaleX),
overlayHeight: Math.max(MIN_RESIZE_EDGE_PX, side * scaleY * contentScaleY),
width: side,
height: side,
};
}
overlayWidth: number;
overlayHeight: number;
}): { dx: number; dy: number } {
return {
overlayWidth: Math.max(MIN_RESIZE_EDGE_PX, input.originWidth + input.dx),
overlayHeight: Math.max(MIN_RESIZE_EDGE_PX, input.originHeight + input.dy),
width: Math.max(1, input.actualWidth + input.dx / (scaleX * contentScaleX)),
height: Math.max(1, input.actualHeight + input.dy / (scaleY * contentScaleY)),
dx: (input.originWidth - input.overlayWidth) / 2,
dy: (input.originHeight - input.overlayHeight) / 2,
};
}
@@ -214,7 +212,12 @@ export type UseDomEditOverlayGesturesOptions = {
(updates: DomEditGroupPathOffsetCommit[]) => Promise<void> | void
>;
onBoxSizeCommitRef: RefObject<
(s: DomEditSelection, n: { width: number; height: number }) => Promise<void> | void
(
s: DomEditSelection,
n: { width: number; height: number },
offset?: { x: number; y: number },
restore?: () => void,
) => Promise<void> | void
>;
onRotationCommitRef: RefObject<
(s: DomEditSelection, n: { angle: number }) => Promise<void> | void
@@ -20,15 +20,19 @@ import {
} from "./manualEdits";
import {
type OverlayRect,
elementCornerOverlayPoints,
filterNestedDomEditGroupItems,
overlayCornersCentroid,
selectionCacheKey,
} from "./domEditOverlayGeometry";
import {
type GestureKind,
type GestureState,
type ResizeHandle,
type UseDomEditOverlayGesturesOptions,
} from "./domEditOverlayGestures";
import { collectSnapContext, buildExcludeElements } from "./snapTargetCollection";
import { logResize, resetResizeMoveLog } from "../../utils/resizeDebug";
export function startGroupDrag(
e: React.PointerEvent<HTMLElement>,
@@ -100,7 +104,11 @@ export function startGesture(
kind: GestureKind,
e: React.PointerEvent<HTMLElement>,
opts: UseDomEditOverlayGesturesOptions,
options?: { selection?: DomEditSelection; rect?: OverlayRect | null },
options?: {
selection?: DomEditSelection;
rect?: OverlayRect | null;
resizeHandle?: ResizeHandle;
},
): boolean {
const sel = options?.selection ?? opts.selectionRef.current;
const rect = options?.rect ?? opts.overlayRectRef.current;
@@ -173,7 +181,29 @@ export function startGesture(
initialPathOffset = result.member.initialPathOffset;
manualEditDragToken = result.member.gestureToken;
} else {
manualEditDragToken = beginStudioManualEditGesture(sel.element);
// Center-anchored corner resize (CapCut model): the element scales about its
// CENTER, which stays planted. All four corners behave identically, so EVERY
// corner needs the manual-offset member that translates the element to re-pin
// its center per frame (the memberless else-branch is only a defensive fallback
// if member creation fails, e.g. the element can't take a manual offset).
const needsAnchorOffset = kind === "resize" && sel.capabilities.canApplyManualOffset;
if (needsAnchorOffset) {
const result = createManualOffsetDragMember({
key: selectionCacheKey(sel),
selection: sel,
element: sel.element,
rect,
});
if (result.ok) {
pathOffsetMember = result.member;
initialPathOffset = result.member.initialPathOffset;
manualEditDragToken = result.member.gestureToken;
} else {
manualEditDragToken = beginStudioManualEditGesture(sel.element);
}
} else {
manualEditDragToken = beginStudioManualEditGesture(sel.element);
}
}
const overlayBounds = overlayEl?.getBoundingClientRect();
@@ -181,6 +211,17 @@ export function startGesture(
const centerY = (overlayBounds?.top ?? 0) + rect.top + rect.height / 2;
const iframe = opts.iframeRef.current;
// For a center-anchored corner resize, capture the element's rendered CENTER (the
// centroid of its four real, rotation-aware corners) now, so per-frame anchoring
// can pin that exact point instead of an axis-aligned width/height delta (which
// only holds the center still when the element grows symmetrically from an
// unrotated layout box). Present whenever an anchor member exists (all corners).
let resizeFixedCenterStart: { x: number; y: number } | undefined;
if (kind === "resize" && pathOffsetMember && overlayEl && iframe) {
const corners = elementCornerOverlayPoints(overlayEl, iframe, sel.element);
if (corners) resizeFixedCenterStart = overlayCornersCentroid(corners);
}
const snapContext =
(kind === "drag" || kind === "resize") && overlayEl && iframe
? collectSnapContext({
@@ -219,6 +260,25 @@ export function startGesture(
resizeAnchor,
manualEditDragToken,
snapContext,
resizeHandle: kind === "resize" ? (options?.resizeHandle ?? "se") : undefined,
resizeFixedCenterStart,
};
if (kind === "resize") {
resetResizeMoveLog();
logResize("start", {
handle: options?.resizeHandle ?? "se",
pointer: { x: e.clientX, y: e.clientY },
center: { x: centerX, y: centerY },
origin: { left: rect.left, top: rect.top, w: rect.width, h: rect.height },
actual: { w: actualWidth, h: actualHeight },
editScale: { x: rect.editScaleX, y: rect.editScaleY },
contentScale: { x: contentScaleX, y: contentScaleY },
rotation: rotation.angle,
hasOffsetMember: !!pathOffsetMember,
fixedCenterStart: resizeFixedCenterStart ?? null,
initialBoxSize: opts.gestureRef.current?.initialBoxSize ?? null,
initialInlineStyle: sel.element.getAttribute("style"),
});
}
return true;
}
@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import {
resolveCenterResizeScale,
resolveCenterResizeSize,
resolveRotatedResizeCursor,
} from "./domEditResizeLocal";
const DEG = Math.PI / 180;
describe("resolveCenterResizeScale — radial distance from the center", () => {
it("scale is the ratio of pointer-to-center distances", () => {
// start 100px from center, now 150px from center → 1.5x.
expect(
resolveCenterResizeScale({
centerStart: { x: 100, y: 100 },
pointerStart: { x: 200, y: 100 },
pointer: { x: 250, y: 100 },
}),
).toBeCloseTo(1.5, 9);
});
it("shrinks as the pointer moves toward the center", () => {
expect(
resolveCenterResizeScale({
centerStart: { x: 0, y: 0 },
pointerStart: { x: 0, y: 200 },
pointer: { x: 0, y: 50 },
}),
).toBeCloseTo(0.25, 9);
});
it("bails to 1 when the gesture starts at (or ~at) the center (degenerate)", () => {
expect(
resolveCenterResizeScale({
centerStart: { x: 100, y: 100 },
pointerStart: { x: 101, y: 100 },
pointer: { x: 400, y: 400 },
}),
).toBe(1);
});
});
describe("resolveCenterResizeSize — rotation is a non-event (radial distance)", () => {
// The pure function takes no rotation argument: rotating the WHOLE gesture
// (center, pointer-down, pointer-now) about the center by any angle preserves
// every radial distance, so the returned size is identical at 0/37/90deg.
const base = { baseWidth: 240, baseHeight: 120 };
const centerStart = { x: 320, y: 180 };
// A pointer-down 100px right of center, dragged to 160px right of center (1.6x).
const p0 = { x: 100, y: 0 };
const p1 = { x: 160, y: 0 };
const rot = (v: { x: number; y: number }, t: number) => ({
x: centerStart.x + (Math.cos(t) * v.x - Math.sin(t) * v.y),
y: centerStart.y + (Math.sin(t) * v.x + Math.cos(t) * v.y),
});
for (const deg of [0, 37, 90]) {
it(`@${deg}deg: proportional 1.6x scale, same result as unrotated`, () => {
const t = deg * DEG;
const out = resolveCenterResizeSize({
...base,
centerStart,
pointerStart: rot(p0, t),
pointer: rot(p1, t),
});
expect(out.width).toBeCloseTo(240 * 1.6, 6);
expect(out.height).toBeCloseTo(120 * 1.6, 6);
expect(out.width / out.height).toBeCloseTo(2, 9);
});
}
it("shrink toward center keeps the aspect ratio", () => {
const out = resolveCenterResizeSize({
baseWidth: 300,
baseHeight: 180,
centerStart: { x: 0, y: 0 },
pointerStart: { x: 0, y: 200 },
pointer: { x: 0, y: 120 },
});
expect(out.width).toBeCloseTo(300 * 0.6, 6);
expect(out.height).toBeCloseTo(180 * 0.6, 6);
expect(out.width / out.height).toBeCloseTo(300 / 180, 9);
});
it("clamps at the local minimum, never mirroring through zero (drag past center)", () => {
// Pointer dragged to the exact center → raw scale 0; the smaller edge is
// clamped to MIN_RESIZE_LOCAL_PX and the aspect ratio holds at the clamp.
const out = resolveCenterResizeSize({
baseWidth: 200,
baseHeight: 100,
centerStart: { x: 100, y: 100 },
pointerStart: { x: 200, y: 100 },
pointer: { x: 100, y: 100 },
});
expect(out.width).toBeGreaterThan(0);
expect(out.height).toBeGreaterThan(0);
expect(Math.min(out.width, out.height)).toBeCloseTo(1, 9);
expect(out.width / out.height).toBeCloseTo(2, 9);
});
it("degenerate start-at-center returns the base size unchanged (scale 1)", () => {
const out = resolveCenterResizeSize({
baseWidth: 200,
baseHeight: 100,
centerStart: { x: 100, y: 100 },
pointerStart: { x: 100, y: 100 },
pointer: { x: 400, y: 400 },
});
expect(out).toEqual({ width: 200, height: 100 });
});
});
describe("resolveRotatedResizeCursor", () => {
it("returns the static diagonal cursors at rotation 0", () => {
expect(resolveRotatedResizeCursor("nw", 0)).toBe("nwse-resize");
expect(resolveRotatedResizeCursor("se", 0)).toBe("nwse-resize");
expect(resolveRotatedResizeCursor("ne", 0)).toBe("nesw-resize");
expect(resolveRotatedResizeCursor("sw", 0)).toBe("nesw-resize");
});
it("rotates the cursor with the element (90deg swaps the diagonals)", () => {
// NW base 315° + 90° = 45° → nesw-resize
expect(resolveRotatedResizeCursor("nw", 90)).toBe("nesw-resize");
// NW base 315° + 45° = 360°→0° → ns-resize
expect(resolveRotatedResizeCursor("nw", 45)).toBe("ns-resize");
});
it("wraps negative rotations", () => {
expect(resolveRotatedResizeCursor("se", -90)).toBe("nesw-resize");
});
});
@@ -0,0 +1,125 @@
/**
* Center-anchored corner-resize math (the CapCut model): the element scales
* proportionally about its CENTER, the center stays planted, and all four corners
* behave identically.
*
* The scale factor is the RADIAL distance from the element's center: how far the
* pointer is from the center now, divided by how far it was at gesture start. This
* is inherently proportional, continuous everywhere, and rotation-invariant a
* distance is a distance regardless of the element's angle, so there is no per-axis
* projection and no dominant-axis branch to jump across. Which corner was grabbed
* is irrelevant to the size (it only picks the resize cursor).
*
* All math here is pure and unit-tested; the live wiring (measuring the element's
* rendered center, feeding the center-pin translate through the manual-offset
* channel) lives in useDomEditOverlayGestures.ts.
*/
import type { ResizeHandle } from "./domEditOverlayGestures";
/** Minimum element edge in LOCAL px mirrors the old MIN_RESIZE_EDGE_PX clamp
* (no flip-through-zero: clamp, never mirror). */
const MIN_RESIZE_LOCAL_PX = 1;
/**
* Below this pointer-to-center distance (overlay px) the gesture started at (or
* effectively at) the center, so the ratio is degenerate (division by ~0). Bail to
* scale 1 rather than blow up.
*/
const DEGENERATE_START_DIST_PX = 3;
/**
* The proportional scale factor for a center-anchored resize: the ratio of the
* pointer's radial distance from the element center now to its distance at gesture
* start. Rotation-invariant (a radial distance ignores the element's angle) and
* continuous. Never negative dragging through the center just shrinks toward the
* clamp; the caller clamps the resulting size, this returns the raw ratio (guarded
* against a degenerate start-at-center gesture, which returns 1).
*/
export function resolveCenterResizeScale(input: {
pointer: { x: number; y: number };
pointerStart: { x: number; y: number };
centerStart: { x: number; y: number };
}): number {
const startDist = Math.hypot(
input.pointerStart.x - input.centerStart.x,
input.pointerStart.y - input.centerStart.y,
);
if (!Number.isFinite(startDist) || startDist < DEGENERATE_START_DIST_PX) return 1;
const nowDist = Math.hypot(
input.pointer.x - input.centerStart.x,
input.pointer.y - input.centerStart.y,
);
return nowDist / startDist;
}
/**
* The element's new LOCAL size for a center-anchored corner resize: base size
* scaled by `resolveCenterResizeScale`, clamped so the smaller edge never drops
* below MIN_RESIZE_LOCAL_PX (clamp small, never mirror through zero). The scale is
* a dimensionless ratio, so the base local size and the screen-space pointer
* distances live in different frames without any display-scale conversion the
* ratio cancels the scale.
*/
export function resolveCenterResizeSize(input: {
baseWidth: number;
baseHeight: number;
pointer: { x: number; y: number };
pointerStart: { x: number; y: number };
centerStart: { x: number; y: number };
}): { width: number; height: number } {
const baseWidth = Math.max(input.baseWidth, MIN_RESIZE_LOCAL_PX);
const baseHeight = Math.max(input.baseHeight, MIN_RESIZE_LOCAL_PX);
const rawScale = resolveCenterResizeScale({
pointer: input.pointer,
pointerStart: input.pointerStart,
centerStart: input.centerStart,
});
const minScale = MIN_RESIZE_LOCAL_PX / Math.min(baseWidth, baseHeight);
const scale = Math.max(minScale, rawScale);
return { width: baseWidth * scale, height: baseHeight * scale };
}
/**
* The eight CSS resize cursors, rotated with the object. A corner's base pointing
* direction (the diagonal it lives on) plus the element rotation, bucketed into
* 45° slots. So a 90°-rotated NW corner reads as a NE-diagonal cursor, etc.
*/
const CURSORS_8 = [
"ns-resize", // 0° (up)
"nesw-resize", // 45°
"ew-resize", // 90° (right)
"nwse-resize", // 135°
"ns-resize", // 180° (down)
"nesw-resize", // 225°
"ew-resize", // 270° (left)
"nwse-resize", // 315°
] as const;
/** Base outward diagonal angle of each corner, in degrees, screen convention
* (0° = up, clockwise). NW points up-left = 315°, NE up-right = 45°, etc. */
const CORNER_BASE_ANGLE_DEG: Record<ResizeHandle, number> = {
nw: 315,
ne: 45,
se: 135,
sw: 225,
};
/** Resize cursor for a corner handle on an element rotated by `rotationDeg`. */
export function resolveRotatedResizeCursor(handle: ResizeHandle, rotationDeg: number): string {
const angle = CORNER_BASE_ANGLE_DEG[handle] + rotationDeg;
const normalized = ((angle % 360) + 360) % 360;
const bucket = Math.round(normalized / 45) % 8;
return CURSORS_8[bucket]!;
}
/** Per-frame anchored-resize center accumulator: ADD the residual center correction
* (fixedStart fixedNow) onto the previous anchor so the pin CONVERGES instead of
* oscillating (fa4f39168). Pure; exported for the release-shift characterization tests. */
export function computeNextResizeAnchor(
prev: { dx: number; dy: number } | undefined,
fixedStart: { x: number; y: number },
fixedNow: { x: number; y: number },
): { dx: number; dy: number } {
const base = prev ?? { dx: 0, dy: 0 };
return { dx: base.dx + (fixedStart.x - fixedNow.x), dy: base.dy + (fixedStart.y - fixedNow.y) };
}
@@ -104,7 +104,7 @@ export function findClosestByAttribute(
// mounted root (it keeps `data-composition-id` but drops `data-composition-src`/
// `-file`), so a subcomp element's DOM ancestors no longer say which file it came
// from. This project-global map (composition-id → source file, built once from
// index.html's clips — see NLELayout) recovers it. The studio loads one project at a
// index.html's clips — see NLEContext/EditorShell) recovers it. The studio loads one project at a
// time, so module scope is the right lifetime; it's empty until set, in which case
// resolution falls back to the historical attribute-only behavior.
let compositionSourceMap: Map<string, string> = new Map();
@@ -49,6 +49,7 @@ import {
buildMotionPatches,
buildClearMotionPatches,
} from "./manualEditsDomPatches";
import { applyStudioBoxSize, applyStudioPathOffset } from "./manualEditsDom";
/* ── helpers ── */
@@ -267,6 +268,52 @@ describe("buildBoxSizePatches / buildClearBoxSizePatches", () => {
});
});
/* ── Combined box-size + path-offset (anchored-corner resize) ──────────────── */
describe("anchored-corner combined patch: [...buildBoxSizePatches, ...buildPathOffsetPatches]", () => {
// NW/NE/SW resize commits size AND anchor offset in ONE persist. The two
// builders read the same already-mutated element and are concatenated; this
// is only safe if their {type,property} keys are disjoint (no builder
// overwrites the other's op when the source patcher applies them in order).
it("concatenation of both builders emits disjoint {type,property} keys (no collision)", () => {
const e = div();
applyStudioBoxSize(e, { width: 300, height: 200 });
applyStudioPathOffset(e, { x: 10, y: 20 });
const combined = [...buildBoxSizePatches(e), ...buildPathOffsetPatches(e)];
const keys = combined.map(opKey);
expect(new Set(keys).size, `duplicate {type,property} key in combined patch: ${keys}`).toBe(
keys.length,
);
});
it("combined patch carries BOTH markers so a soft-reload re-hydrates size and offset together", () => {
const e = div();
applyStudioBoxSize(e, { width: 300, height: 200 });
applyStudioPathOffset(e, { x: 10, y: 20 });
const combined = [...buildBoxSizePatches(e), ...buildPathOffsetPatches(e)];
const has = (property: string) =>
combined.some((op) => op.type === "attribute" && op.property === property);
expect(has(STUDIO_BOX_SIZE_ATTR)).toBe(true);
expect(has(STUDIO_PATH_OFFSET_ATTR)).toBe(true);
});
it("order is size-first: every box-size op precedes every path-offset op", () => {
const e = div();
applyStudioBoxSize(e, { width: 300, height: 200 });
applyStudioPathOffset(e, { x: 10, y: 20 });
const boxKeys = new Set(buildBoxSizePatches(e).map(opKey));
const combined = [...buildBoxSizePatches(e), ...buildPathOffsetPatches(e)];
const lastBoxIdx = combined.reduce((acc, op, i) => (boxKeys.has(opKey(op)) ? i : acc), -1);
const firstOffsetIdx = combined.findIndex(
(op) => op.type === "attribute" && op.property === STUDIO_PATH_OFFSET_ATTR,
);
expect(firstOffsetIdx).toBeGreaterThan(lastBoxIdx);
});
});
/* ── Rotation ────────────────────────────────────────────────────────────── */
describe("buildRotationPatches / buildClearRotationPatches", () => {
@@ -441,22 +441,26 @@ export function applyManualOffsetDragDraft(
return offset;
}
export function applyManualOffsetDragCommit(
member: ManualOffsetDragMember,
dx: number,
dy: number,
): { x: number; y: number } {
// Re-stamp the STABLE gesture-start base/offset before the source commit reads
// them. A mid-drag re-render can wipe these attrs; the commit converts the drop
// offset → gsap x/y via computeDraggedGsapPosition, which without the base falls
// back to the live (already-dragged) transform and re-adds the delta — so the
// element flies off-screen the instant you drop it. The member holds the true
// gesture-start values in JS, immune to the re-render.
/**
* Re-stamp the STABLE gesture-start base/offset before the source commit reads
* them. A mid-gesture re-render can wipe these attrs; the commit converts the
* drop offset gsap x/y via computeDraggedGsapPosition, which without the base
* falls back to the live (already-dragged) transform and re-adds the delta so
* the element flies off-screen the instant you drop it. The member holds the
* true gesture-start values in JS, immune to the re-render.
*/
function restampManualOffsetDragGestureBase(member: ManualOffsetDragMember): void {
member.element.setAttribute("data-hf-drag-gsap-base-x", String(member.baseGsap.x));
member.element.setAttribute("data-hf-drag-gsap-base-y", String(member.baseGsap.y));
member.element.setAttribute("data-hf-drag-initial-offset-x", String(member.initialOffset.x));
member.element.setAttribute("data-hf-drag-initial-offset-y", String(member.initialOffset.y));
const offset = resolveManualOffsetDragMemberOffset(member, dx, dy);
}
function applyManualOffsetCommitValue(
member: ManualOffsetDragMember,
offset: { x: number; y: number },
): { x: number; y: number } {
restampManualOffsetDragGestureBase(member);
// Optimistic visual through the GSAP channel (same as the live draft and the
// committed `tl.set`), so the element holds its dropped position until the
// source mutation soft-reloads — no transient CSS `--hf-studio-offset` write.
@@ -467,6 +471,45 @@ export function applyManualOffsetDragCommit(
return offset;
}
export function applyManualOffsetDragCommit(
member: ManualOffsetDragMember,
dx: number,
dy: number,
): { x: number; y: number } {
return applyManualOffsetCommitValue(member, resolveManualOffsetDragMemberOffset(member, dx, dy));
}
/**
* Arrow-key nudge, in OFFSET units (composition px), not screen px "nudge
* 1px" means one composition pixel regardless of canvas zoom, so the delta
* adds to the gesture-start offset directly instead of going through the
* screenoffset matrix. Draft/commit land in the same GSAP channel (with the
* same CSS fallback) as the drag equivalents above.
*/
export function applyManualOffsetNudgeDraft(
member: ManualOffsetDragMember,
delta: { x: number; y: number },
): { x: number; y: number } {
const offset = {
x: member.initialOffset.x + delta.x,
y: member.initialOffset.y + delta.y,
};
if (!applyOffsetDragDraftViaGsap(member.element, offset, member.baseGsap)) {
applyStudioPathOffsetDraft(member.element, offset);
}
return offset;
}
export function applyManualOffsetNudgeCommit(
member: ManualOffsetDragMember,
delta: { x: number; y: number },
): { x: number; y: number } {
return applyManualOffsetCommitValue(member, {
x: member.initialOffset.x + delta.x,
y: member.initialOffset.y + delta.y,
});
}
function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void {
restoreStudioPathOffset(member.element, member.initialPathOffset);
endStudioManualEditGesture(member.element, member.gestureToken);
@@ -1,7 +1,7 @@
import type React from "react";
import type { OffCanvasRect } from "./OffCanvasIndicators";
import { hugRectForElement } from "./domEditOverlayCrop";
import { groupAwareOverlayRect } from "./domEditOverlayGeometry";
import { orientedGroupAwareOverlayRect } from "./domEditOverlayGeometry";
import { isElementComputedVisible } from "./domEditingElement";
import { collectDomEditLayerItems } from "./domEditingLayers";
@@ -13,11 +13,30 @@ function offCanvasSignature(rects: OffCanvasRect[]): string {
return rects
.map(
(rect) =>
`${rect.key}:${rounded(rect.left)},${rounded(rect.top)},${rounded(rect.width)},${rounded(rect.height)}`,
`${rect.key}:${rounded(rect.left)},${rounded(rect.top)},${rounded(rect.width)},${rounded(rect.height)},${rounded(rect.angle ?? 0)}`,
)
.join("|");
}
function extendsOutside(
rect: Omit<OffCanvasRect, "key">,
comp: { left: number; top: number; width: number; height: number },
): boolean {
const radians = ((rect.angle ?? 0) * Math.PI) / 180;
const halfWidth =
(Math.abs(Math.cos(radians)) * rect.width + Math.abs(Math.sin(radians)) * rect.height) / 2;
const halfHeight =
(Math.abs(Math.sin(radians)) * rect.width + Math.abs(Math.cos(radians)) * rect.height) / 2;
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
return (
centerX - halfWidth < comp.left ||
centerX + halfWidth > comp.left + comp.width ||
centerY - halfHeight < comp.top ||
centerY + halfHeight > comp.top + comp.height
);
}
// fallow-ignore-next-line complexity
export function recomputeOffCanvasIndicators(
iframe: HTMLIFrameElement,
@@ -50,18 +69,20 @@ export function recomputeOffCanvasIndicators(
// whose members sit inside the canvas isn't flagged off-canvas by a stale
// wrapper box. Crop-hug the result so an inset crop that keeps the visible
// part on-canvas doesn't flag the element either.
const base = groupAwareOverlayRect(overlay, iframe, item.element);
const base = orientedGroupAwareOverlayRect(overlay, iframe, item.element);
const r = base ? { ...base, ...hugRectForElement(base, item.element) } : null;
if (!r) continue;
// Any edge crossing the composition border → gray-zone indicator (the
// in-canvas portion is clipped away below, so only the sliver shows).
const extendsOutsideComp =
r.left < comp.left ||
r.left + r.width > comp.left + comp.width ||
r.top < comp.top ||
r.top + r.height > comp.top + comp.height;
if (extendsOutsideComp) {
rects.push({ key: item.key, left: r.left, top: r.top, width: r.width, height: r.height });
if (extendsOutside(r, comp)) {
rects.push({
key: item.key,
left: r.left,
top: r.top,
width: r.width,
height: r.height,
angle: r.angle,
});
elMap.set(item.key, item.element);
}
}
@@ -2,11 +2,35 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { describe, expect, it } from "vitest";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { DomEditOverlay } from "./DomEditOverlay";
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
// happy-dom (20.x) holds each MutationObserver's delivery callback ONLY via a
// WeakRef (MutationObserverListener: `callback: new WeakRef(...)` — the arrow
// has no strong referent). If V8 runs a GC between observe() and a mutation,
// deref() returns undefined and mutation delivery silently stops — the
// indicator-refresh loop never sees its dirty flag and these tests flake under
// full-suite memory pressure (passing in isolation). Pin WeakRef to a strong
// ref for this file so the real observer path stays deterministic.
const RealWeakRef = globalThis.WeakRef;
class StrongRef<T extends WeakKey> {
#value: T;
constructor(value: T) {
this.#value = value;
}
deref(): T {
return this.#value;
}
}
beforeAll(() => {
(globalThis as { WeakRef: unknown }).WeakRef = StrongRef;
});
afterAll(() => {
globalThis.WeakRef = RealWeakRef;
});
const INDICATOR = '[aria-label="Select off-canvas element index.html:headline:0"]';
function domRect(left: number, top: number, width: number, height: number): DOMRect {
@@ -0,0 +1,108 @@
import type { GestureState } from "./domEditOverlayGestures";
import { resolveResizeCenterAnchorOffset } from "./domEditOverlayGestures";
import type { OverlayRect } from "./domEditOverlayGeometry";
import {
cornerEdgeLength,
elementCornerOverlayPoints,
overlayCornersCentroid,
} from "./domEditOverlayGeometry";
import { computeNextResizeAnchor } from "./domEditResizeLocal";
import { applyManualOffsetDragDraft } from "./manualOffsetDrag";
type Corners = ReturnType<typeof elementCornerOverlayPoints>;
/**
* The residual center-pin offset for this frame. With measurable corners and a
* fixed-center start, accumulate `fixedStart - centerNow` onto the previous
* anchor so it CONVERGES rather than oscillating: `applyManualOffsetDragDraft`
* treats its argument as the absolute offset, and `centerNow` (measured on the
* live element) already carries the previous frame's offset, so the difference
* is only the residual correction. Using it absolutely would drop the offset
* every other frame and un-pin the center (fa4f39168). Memberless/unmeasurable
* geometry falls back to the AABB half-delta.
*/
function resolveResizeAnchor(
g: GestureState,
corners: Corners | null,
measureOrientedRect: () => OverlayRect | null,
): { dx: number; dy: number } {
const fixedStart = g.resizeFixedCenterStart;
if (corners && fixedStart) {
return computeNextResizeAnchor(g.lastResizeAnchor, fixedStart, overlayCornersCentroid(corners));
}
const fallbackRect = measureOrientedRect();
return resolveResizeCenterAnchorOffset({
originWidth: g.originWidth,
originHeight: g.originHeight,
overlayWidth: fallbackRect ? fallbackRect.width : g.originWidth,
overlayHeight: fallbackRect ? fallbackRect.height : g.originHeight,
});
}
/**
* Center-pinned draft rect: translate the element through the manual-offset
* channel to keep its gesture-start center planted (rotation-safe for any
* transform-origin), then hug its true rendered bounds. Mutates
* `g.lastResizeAnchor` and applies the offset draft as a side effect.
*/
function resolveAnchoredResizeDraft(
g: GestureState,
member: NonNullable<GestureState["pathOffsetMember"]>,
element: HTMLElement,
overlayEl: HTMLDivElement | null,
iframe: HTMLIFrameElement | null,
measureOrientedRect: () => OverlayRect | null,
): OverlayRect {
// Measure real corners ONCE — reused for the anchor and the fallback size.
const corners =
overlayEl && iframe ? elementCornerOverlayPoints(overlayEl, iframe, element) : null;
const anchor = resolveResizeAnchor(g, corners, measureOrientedRect);
g.lastResizeAnchor = anchor;
applyManualOffsetDragDraft(member, anchor.dx, anchor.dy);
// Re-measure AFTER the anchor translate so it hugs the element every frame.
return (
measureOrientedRect() ?? {
left: g.originLeft + anchor.dx,
top: g.originTop + anchor.dy,
width: corners ? cornerEdgeLength(corners.nw, corners.ne) : g.originWidth,
height: corners ? cornerEdgeLength(corners.nw, corners.sw) : g.originHeight,
editScaleX: g.editScaleX,
editScaleY: g.editScaleY,
angle: g.actualRotation,
}
);
}
/** The overlay rect to paint for the current resize pointer-move frame. */
export function resolveResizeDraftRect(
g: GestureState,
element: HTMLElement,
overlayEl: HTMLDivElement | null,
iframe: HTMLIFrameElement | null,
measureOrientedRect: () => OverlayRect | null,
): OverlayRect {
if (g.pathOffsetMember) {
return resolveAnchoredResizeDraft(
g,
g.pathOffsetMember,
element,
overlayEl,
iframe,
measureOrientedRect,
);
}
// Re-measure the element's oriented box AFTER the size write. The size draft
// rounds/clamps and (with a centered transform-origin + GSAP scale) the real
// rendered size diverges from the CSS size, so measure rather than trust math.
return (
measureOrientedRect() ?? {
left: g.originLeft,
top: g.originTop,
width: g.originWidth,
height: g.originHeight,
editScaleX: g.editScaleX,
editScaleY: g.editScaleY,
angle: g.actualRotation,
}
);
}
@@ -5,8 +5,8 @@ import {
buildCompositionSnapTarget,
buildGridSnapEdges,
resolveSnapAdjustment,
resolveResizeSnapAdjustment,
resolveEquidistanceGuides,
resolveGuideLineRect,
SNAP_THRESHOLD_PX,
type SnapTarget,
} from "./snapEngine";
@@ -385,88 +385,22 @@ describe("resolveSnapAdjustment", () => {
});
// ---------------------------------------------------------------------------
// resolveResizeSnapAdjustment
// resolveGuideLineRect
// ---------------------------------------------------------------------------
describe("resolveResizeSnapAdjustment", () => {
test("only right edge snaps on X", () => {
// Moving rect at (100, 100) size 50x50, right=150.
// Target left at 200. Propose dx=47 => proposed right=197. Dist to 200=3.
const t = target("a", 200, 100, 100, 100);
const result = resolveResizeSnapAdjustment({
movingRect: rect(100, 100, 50, 50),
proposedDx: 47,
proposedDy: 0,
targets: [t],
threshold: SNAP_THRESHOLD_PX,
disabled: false,
});
expect(result.dx).toBe(50); // right edge snaps to 200
describe("resolveGuideLineRect", () => {
const composition = rect(120, 80, 640, 360); // letterboxed inside the overlay
test("vertical guide (axis x) spans the composition's height at the snap x", () => {
expect(resolveGuideLineRect({ axis: "x", position: 440, from: 0, to: 0 }, composition)).toEqual(
{ left: 440, top: 80, width: 1, height: 360 },
);
});
test("only bottom edge snaps on Y", () => {
// Moving rect at (100, 100) size 50x50, bottom=150.
// Target top at 200. Propose dy=47 => proposed bottom=197. Dist to 200=3.
const t = target("a", 100, 200, 100, 100);
const result = resolveResizeSnapAdjustment({
movingRect: rect(100, 100, 50, 50),
proposedDx: 0,
proposedDy: 47,
targets: [t],
threshold: SNAP_THRESHOLD_PX,
disabled: false,
});
expect(result.dy).toBe(50); // bottom edge snaps to 200
});
test("left edge does NOT snap during resize", () => {
// Target right at 150. Moving rect left=100. If drag were active,
// left would snap. But during resize, only right edge snaps.
// Moving rect at (100, 100) size 200x200, right=300.
// Target right=150. Proposed dx=-153 => proposed right=147. Dist to 150=3.
// This SHOULD snap right to 150 (dx = -150). But left stays at 100.
const t = target("a", 50, 100, 100, 100); // right=150
const result = resolveResizeSnapAdjustment({
movingRect: rect(100, 100, 200, 200),
proposedDx: -153,
proposedDy: 0,
targets: [t],
threshold: SNAP_THRESHOLD_PX,
disabled: false,
});
// Right edge: 300 + (-153) = 147 => snaps to 150, adjustment = +3, dx = -150
expect(result.dx).toBe(-150);
});
test("disabled=true returns passthrough for resize", () => {
const t = target("a", 200, 200, 100, 100);
const result = resolveResizeSnapAdjustment({
movingRect: rect(100, 100, 50, 50),
proposedDx: 47,
proposedDy: 47,
targets: [t],
threshold: SNAP_THRESHOLD_PX,
disabled: true,
});
expect(result.dx).toBe(47);
expect(result.dy).toBe(47);
expect(result.guides).toHaveLength(0);
});
test("resize produces guide lines", () => {
const t = target("a", 200, 100, 100, 100);
const result = resolveResizeSnapAdjustment({
movingRect: rect(100, 100, 50, 50),
proposedDx: 47,
proposedDy: 0,
targets: [t],
threshold: SNAP_THRESHOLD_PX,
disabled: false,
});
expect(result.guides.length).toBeGreaterThanOrEqual(1);
const xGuide = result.guides.find((g) => g.axis === "x");
expect(xGuide).toBeDefined();
expect(xGuide!.position).toBe(200);
test("horizontal guide (axis y) spans the composition's width at the snap y", () => {
expect(resolveGuideLineRect({ axis: "y", position: 260, from: 0, to: 0 }, composition)).toEqual(
{ left: 120, top: 260, width: 640, height: 1 },
);
});
});
@@ -410,62 +410,22 @@ export function resolveSnapAdjustment(input: {
}
// ---------------------------------------------------------------------------
// resolveResizeSnapAdjustment — resize variant (only right/bottom snap)
// resolveGuideLineRect — screen rect for rendering a snap guide line
// ---------------------------------------------------------------------------
// fallow-ignore-next-line complexity
export function resolveResizeSnapAdjustment(input: {
movingRect: Rect;
proposedDx: number;
proposedDy: number;
targets: SnapTarget[];
gridEdges?: { x: SnapEdge[]; y: SnapEdge[] };
threshold: number;
disabled: boolean;
}): SnapResult {
if (input.disabled || input.threshold <= 0) {
return DISABLED_RESULT(input.proposedDx, input.proposedDy);
/**
* Full-length guide line spanning the composition: a vertical line (axis "x")
* runs the composition's height at the snapped x position; a horizontal line
* (axis "y") runs the composition's width. `composition` is the composition
* rect in overlay space guide positions are already overlay-space, so the
* line must be offset by the composition's left/top (the canvas is usually
* letterboxed inside the overlay).
*/
export function resolveGuideLineRect(guide: SnapGuide, composition: Rect): Rect {
if (guide.axis === "x") {
return { left: guide.position, top: composition.top, width: 1, height: composition.height };
}
const mr = input.movingRect;
const proposedRight = rectRight(mr) + input.proposedDx;
const proposedBottom = rectBottom(mr) + input.proposedDy;
const xCandidates = collectCandidates(
[proposedRight],
input.targets,
(t) => [t.left, t.centerX, t.right],
input.gridEdges?.x,
input.threshold,
);
const yCandidates = collectCandidates(
[proposedBottom],
input.targets,
(t) => [t.top, t.centerY, t.bottom],
input.gridEdges?.y,
input.threshold,
);
const bestX = pickBest(xCandidates);
const bestY = pickBest(yCandidates);
const adjustedDx = input.proposedDx + (bestX?.adjustment ?? 0);
const adjustedDy = input.proposedDy + (bestY?.adjustment ?? 0);
const adjustedRect: Rect = {
left: mr.left,
top: mr.top,
width: mr.width + adjustedDx,
height: mr.height + adjustedDy,
};
const targetMap = new Map(input.targets.map((t) => [t.id, t]));
return {
dx: adjustedDx,
dy: adjustedDy,
guides: buildGuidesFromMatches(bestX, bestY, adjustedRect, targetMap),
spacingGuides: [], // computed separately via resolveEquidistanceGuides
};
return { left: composition.left, top: guide.position, width: composition.width, height: 1 };
}
// ---------------------------------------------------------------------------
@@ -0,0 +1,228 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { installReactActEnvironment, makeSelection } from "../../hooks/domSelectionTestHarness";
import { useDomEditNudge, type UseDomEditNudgeParams } from "./useDomEditNudge";
import { CANVAS_NUDGE_COMMIT_DEBOUNCE_MS, CANVAS_NUDGE_STEP_PX } from "./domEditNudge";
import { __resetForTests } from "../../utils/canvasNudgeGate";
import type { DomEditSelection } from "./domEditing";
import type { OverlayRect } from "./domEditOverlayGeometry";
installReactActEnvironment();
function makeRef<T>(current: T): { current: T } {
return { current };
}
const REST_RECT: OverlayRect = {
left: 0,
top: 0,
width: 100,
height: 50,
editScaleX: 1,
editScaleY: 1,
};
// Stable across renders on purpose: the test targets the `selection` identity
// key specifically, so `groupSelections` must not itself be a source of churn.
const EMPTY_GROUP_SELECTIONS: DomEditSelection[] = [];
function Harness({
selection,
onPathOffsetCommit,
}: {
selection: DomEditSelection | null;
onPathOffsetCommit: UseDomEditNudgeParams["onPathOffsetCommitRef"]["current"];
}) {
useDomEditNudge({
selection,
groupSelections: EMPTY_GROUP_SELECTIONS,
allowCanvasMovement: true,
selectionRef: makeRef(selection),
overlayRectRef: makeRef(REST_RECT),
groupOverlayItemsRef: makeRef([]),
gestureRef: makeRef(null),
groupGestureRef: makeRef(null),
blockedMoveRef: makeRef(null),
onManualDragStartRef: makeRef(() => {}),
onPathOffsetCommitRef: makeRef(onPathOffsetCommit),
onGroupPathOffsetCommitRef: makeRef(async () => {}),
});
return null;
}
function dispatchArrowRight(): void {
window.dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true }),
);
}
describe("useDomEditNudge — selection cleanup keyed on stable identity", () => {
beforeEach(() => {
vi.useFakeTimers();
__resetForTests();
});
afterEach(() => {
vi.useRealTimers();
});
it("keeps a nudge burst alive when the parent hands down a new selection object for the same element", () => {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const element = document.createElement("div");
element.id = "dot-a";
document.body.append(element);
const commit = vi.fn();
const firstSelection = makeSelection("Dot", element);
act(() => {
root.render(
React.createElement(Harness, { selection: firstSelection, onPathOffsetCommit: commit }),
);
});
act(() => {
dispatchArrowRight();
});
expect(commit).not.toHaveBeenCalled();
// Re-render with a BRAND NEW selection object describing the SAME element
// (same id) — exactly what an un-memoized parent does on every render.
// Before the fix, the cleanup effect was keyed on this object's identity
// and would flush the burst right here, one arrow-press early.
const secondSelection = makeSelection("Dot", element);
act(() => {
root.render(
React.createElement(Harness, { selection: secondSelection, onPathOffsetCommit: commit }),
);
});
expect(commit).not.toHaveBeenCalled();
act(() => {
dispatchArrowRight();
});
expect(commit).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10);
});
// One combined commit for both presses, not two separate (premature) ones.
expect(commit).toHaveBeenCalledTimes(1);
const [, next] = commit.mock.calls[0] as [DomEditSelection, { x: number; y: number }];
expect(next.x).toBeCloseTo(2 * CANVAS_NUDGE_STEP_PX);
expect(next.y).toBeCloseTo(0);
act(() => root.unmount());
host.remove();
element.remove();
});
it("still flushes the burst when the selection actually changes to a different element", () => {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const elementA = document.createElement("div");
elementA.id = "dot-a";
document.body.append(elementA);
const elementB = document.createElement("div");
elementB.id = "dot-b";
document.body.append(elementB);
const commit = vi.fn();
const selectionA = makeSelection("Dot A", elementA);
const selectionB = makeSelection("Dot B", elementB);
act(() => {
root.render(
React.createElement(Harness, { selection: selectionA, onPathOffsetCommit: commit }),
);
});
act(() => {
dispatchArrowRight();
});
expect(commit).not.toHaveBeenCalled();
// A genuine selection change (different id) must still flush immediately —
// only same-identity re-renders should be ignored.
act(() => {
root.render(
React.createElement(Harness, { selection: selectionB, onPathOffsetCommit: commit }),
);
});
expect(commit).toHaveBeenCalledTimes(1);
const [committedSelection, next] = commit.mock.calls[0] as [
DomEditSelection,
{ x: number; y: number },
];
expect(committedSelection.element).toBe(elementA);
expect(next.x).toBeCloseTo(CANVAS_NUDGE_STEP_PX);
act(() => root.unmount());
host.remove();
elementA.remove();
elementB.remove();
});
it("flushes the burst when switching between two id-less siblings that share a selector", () => {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
// Two id-less siblings with the SAME selector — distinguished only by
// selectorIndex. Under the old `id ?? selector ?? label` key they shared
// one identity, so selecting B mid-burst didn't flush A and the next arrow
// kept moving A. The key now folds in selectorIndex, so they're distinct.
const elementA = document.createElement("div");
document.body.append(elementA);
const elementB = document.createElement("div");
document.body.append(elementB);
const commit = vi.fn();
const base = (label: string, element: HTMLElement) => ({
...makeSelection(label, element),
id: undefined,
selector: "div.row",
});
const selectionA: DomEditSelection = { ...base("Row", elementA), selectorIndex: 0 };
const selectionB: DomEditSelection = { ...base("Row", elementB), selectorIndex: 1 };
act(() => {
root.render(
React.createElement(Harness, { selection: selectionA, onPathOffsetCommit: commit }),
);
});
act(() => {
dispatchArrowRight();
});
expect(commit).not.toHaveBeenCalled();
act(() => {
root.render(
React.createElement(Harness, { selection: selectionB, onPathOffsetCommit: commit }),
);
});
// The sibling switch must flush A's pending burst exactly once, for A.
expect(commit).toHaveBeenCalledTimes(1);
const [committedSelection, next] = commit.mock.calls[0] as [
DomEditSelection,
{ x: number; y: number },
];
expect(committedSelection.element).toBe(elementA);
expect(next.x).toBeCloseTo(CANVAS_NUDGE_STEP_PX);
act(() => root.unmount());
host.remove();
elementA.remove();
elementB.remove();
});
});
@@ -0,0 +1,254 @@
/**
* Canvas arrow-key nudge for DomEditOverlay: arrows move the selected
* element(s) 1 composition px, Shift = 10. Each keypress previews through the
* same GSAP/CSS channel as a drag draft, and the burst commits ONCE through
* the same onPathOffsetCommit / onGroupPathOffsetCommit path a drag drop uses
* so a nudge burst is exactly a tiny drag: one source patch, one undo entry.
*/
import { useCallback, useEffect, useRef, type RefObject } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
import { isEditableTarget } from "../../utils/timelineDiscovery";
import { acquireCanvasNudgeKeys } from "../../utils/canvasNudgeGate";
import type { DomEditSelection } from "./domEditing";
import {
type GroupOverlayItem,
type OverlayRect,
filterNestedDomEditGroupItems,
} from "./domEditOverlayGeometry";
import type {
BlockedMoveState,
DomEditGroupPathOffsetCommit,
GestureState,
GroupGestureState,
} from "./domEditOverlayGestures";
import {
applyManualOffsetNudgeCommit,
applyManualOffsetNudgeDraft,
createManualOffsetDragMember,
endManualOffsetDragMembers,
restoreManualOffsetDragMembers,
type ManualOffsetDragMember,
} from "./manualOffsetDrag";
import { isStudioManualEditGestureCurrent, restoreStudioPathOffset } from "./manualEdits";
import {
CANVAS_NUDGE_COMMIT_DEBOUNCE_MS,
canCanvasNudgeTargets,
resolveCanvasNudgeDelta,
} from "./domEditNudge";
interface NudgeSession {
members: ManualOffsetDragMember[];
isGroup: boolean;
/** Accumulated delta of the burst, in composition px. */
accum: { x: number; y: number };
timer: ReturnType<typeof setTimeout> | null;
}
export interface UseDomEditNudgeParams {
selection: DomEditSelection | null;
groupSelections: DomEditSelection[];
allowCanvasMovement: boolean;
selectionRef: RefObject<DomEditSelection | null>;
overlayRectRef: RefObject<OverlayRect | null>;
groupOverlayItemsRef: RefObject<GroupOverlayItem[]>;
gestureRef: RefObject<GestureState | null>;
groupGestureRef: RefObject<GroupGestureState | null>;
blockedMoveRef: RefObject<BlockedMoveState | null>;
onManualDragStartRef: RefObject<(() => void) | undefined>;
onPathOffsetCommitRef: RefObject<
(
s: DomEditSelection,
n: { x: number; y: number },
m?: { altKey?: boolean },
) => Promise<void> | void
>;
onGroupPathOffsetCommitRef: RefObject<
(updates: DomEditGroupPathOffsetCommit[]) => Promise<void> | void
>;
}
type NudgeTarget = {
key: string;
selection: DomEditSelection;
element: HTMLElement;
rect: OverlayRect;
};
/**
* A selection's stable identity, not its object reference. A parent that
* doesn't memoize the selection it passes down hands us a new object every
* render for the SAME element keying an effect on the object itself would
* then re-fire on every render instead of on an actual selection change.
*
* All discriminators are folded in (id / hfId / selector / selectorIndex /
* label), not just the first present one: two id-less siblings can share a
* selector, so `id ?? selector ?? label` collapsed them to the same key
* selecting sibling B mid-burst then didn't flush A's pending nudge, so the
* next arrow kept moving A. selectorIndex is the discriminator that separates
* them. The parts are only ever compared for equality (never parsed back),
* so they're joined on a NUL delimiter a byte no discriminator can contain,
* so distinct selections stay distinct even when a value (e.g. a label) holds
* a space. It's written as the escape "\u0000", not a literal 0x00 byte, so
* this source file stays text (a raw NUL makes git treat it as binary).
*/
function selectionIdentityKey(selection: DomEditSelection | null): string {
if (!selection) return "";
return [
selection.id ?? "",
selection.hfId ?? "",
selection.selector ?? "",
selection.selectorIndex ?? "",
selection.label,
].join("\u0000");
}
function groupSelectionsIdentityKey(selections: DomEditSelection[]): string {
return selections.map(selectionIdentityKey).join(" ");
}
/** Drag members for a multi-selection nudge (same snapshot a group drag uses). */
function resolveGroupNudgeTargets(groupItems: GroupOverlayItem[]): NudgeTarget[] | null {
if (!canCanvasNudgeTargets(groupItems.map((item) => item.selection))) return null;
return filterNestedDomEditGroupItems(groupItems);
}
/** Drag member for a single-selection nudge, or null when it can't be moved. */
function resolveSingleNudgeTarget(
sel: DomEditSelection | null,
rect: OverlayRect | null,
): NudgeTarget[] | null {
if (!sel || !rect || !sel.capabilities.canApplyManualOffset || !sel.element.isConnected) {
return null;
}
return [{ key: sel.id ?? sel.selector ?? sel.label, selection: sel, element: sel.element, rect }];
}
/**
* True when a keydown must not start/extend a nudge: canvas movement disabled,
* a pointer gesture already owns the element, or the user is typing in a field.
*/
function shouldIgnoreNudgeKey(p: UseDomEditNudgeParams, event: KeyboardEvent): boolean {
if (!p.allowCanvasMovement || event.defaultPrevented) return true;
if (p.gestureRef.current || p.groupGestureRef.current || p.blockedMoveRef.current) return true;
return isEditableTarget(event.target);
}
export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: () => void } {
const sessionRef = useRef<NudgeSession | null>(null);
const paramsRef = useRef(params);
paramsRef.current = params;
// Commit the pending burst: one source write per burst = one undo entry.
// Mirrors the drag's onPointerUp — same commit callbacks, same failure
// restore, same member teardown.
const commitSession = () => {
const session = sessionRef.current;
if (!session) return;
sessionRef.current = null;
if (session.timer) clearTimeout(session.timer);
const updates: DomEditGroupPathOffsetCommit[] = session.members.map((member) => ({
selection: member.selection,
next: applyManualOffsetNudgeCommit(member, session.accum),
}));
const p = paramsRef.current;
const commit = session.isGroup
? p.onGroupPathOffsetCommitRef.current(updates)
: p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next);
void Promise.resolve(commit)
.catch(() => {
for (const member of session.members) {
if (isStudioManualEditGestureCurrent(member.element, member.gestureToken)) {
restoreStudioPathOffset(member.element, member.initialPathOffset);
}
}
})
.finally(() => endManualOffsetDragMembers(session.members));
};
const commitSessionRef = useRef(commitSession);
commitSessionRef.current = commitSession;
// Build drag members for the current target set — the same member snapshot a
// pointer drag starts from (startGesture / startGroupDrag), so the nudge
// commit converts offsets → GSAP x/y with identical math.
const beginSession = (): NudgeSession | null => {
const p = paramsRef.current;
const groupItems = p.groupOverlayItemsRef.current;
const isGroup = groupItems.length > 1;
const targets = isGroup
? resolveGroupNudgeTargets(groupItems)
: resolveSingleNudgeTarget(p.selectionRef.current, p.overlayRectRef.current);
if (!targets) return null;
const members: ManualOffsetDragMember[] = [];
for (const target of targets) {
const result = createManualOffsetDragMember(target);
if (!result.ok) {
restoreManualOffsetDragMembers(members);
return null;
}
members.push(result.member);
}
if (members.length === 0) return null;
// Same side effect a drag start has (pauses preview playback).
p.onManualDragStartRef.current?.();
return { members, isGroup, accum: { x: 0, y: 0 }, timer: null };
};
const handleKeyDown = (event: KeyboardEvent) => {
const p = paramsRef.current;
if (shouldIgnoreNudgeKey(p, event)) return;
const delta = resolveCanvasNudgeDelta(event);
if (!delta) return;
const session = sessionRef.current ?? beginSession();
if (!session) return;
sessionRef.current = session;
event.preventDefault();
session.accum = { x: session.accum.x + delta.dx, y: session.accum.y + delta.dy };
for (const member of session.members) applyManualOffsetNudgeDraft(member, session.accum);
if (session.timer) clearTimeout(session.timer);
session.timer = setTimeout(() => commitSessionRef.current(), CANVAS_NUDGE_COMMIT_DEBOUNCE_MS);
};
const handleKeyDownRef = useRef(handleKeyDown);
handleKeyDownRef.current = handleKeyDown;
useMountEffect(() => {
const listener = (event: KeyboardEvent) => handleKeyDownRef.current(event);
// Capture, like the other app-level key handlers, so a focused panel
// can't swallow the nudge before it reaches us.
window.addEventListener("keydown", listener, true);
return () => {
window.removeEventListener("keydown", listener, true);
commitSessionRef.current();
};
});
// Selection change ends the burst: commit to the OLD target before the
// arrows start moving the new one. Keyed on the selection's stable identity
// (id/selector/label), NOT the object reference — a parent that re-creates
// the selection object on every render (without memoizing it) must not
// flush a burst that's still in progress for the same element.
const selectionKey = selectionIdentityKey(params.selection);
const groupSelectionsKey = groupSelectionsIdentityKey(params.groupSelections);
// eslint-disable-next-line no-restricted-syntax
useEffect(() => () => commitSessionRef.current(), [selectionKey, groupSelectionsKey]);
// Claim the arrow keys from the playback frame-step while the selection is
// nudgeable (see canvasNudgeGate — listener order is mount-dependent, so
// preventDefault alone can't arbitrate).
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
const targets =
params.groupSelections.length > 1
? params.groupSelections
: params.selection
? [params.selection]
: [];
if (!params.allowCanvasMovement || !canCanvasNudgeTargets(targets)) return;
return acquireCanvasNudgeKeys();
}, [params.selection, params.groupSelections, params.allowCanvasMovement]);
// A pointer gesture supersedes a pending burst — DomEditOverlay flushes on
// pointerdown-capture so the drag's member snapshot starts from the nudged,
// committed position.
const flushNudge = useCallback(() => commitSessionRef.current(), []);
return { flushNudge };
}
@@ -4,7 +4,6 @@
* Owns: onPointerMove, onPointerUp, clearPointerState.
* startGesture and startGroupDrag live in domEditOverlayStartGesture.ts.
*/
import { setElementGsapPosition } from "../../utils/elementGsap";
import type { RefObject } from "react";
import { type DomEditSelection } from "./domEditing";
import {
@@ -30,36 +29,29 @@ import {
import {
type GroupOverlayItem,
type OverlayRect,
orientedOverlayRect,
resolveDomEditGroupOverlayRect,
toOverlayRect,
} from "./domEditOverlayGeometry";
import {
BLOCKED_MOVE_THRESHOLD_PX,
type GestureKind,
type GestureState,
type GroupGestureState,
type ResizeHandle,
type UseDomEditOverlayGesturesOptions,
ROTATED_SNAP_BYPASS_DEGREES,
hasDomEditRotationChanged,
resolveDomEditResizeGesture,
resolveDomEditRotationGesture,
} from "./domEditOverlayGestures";
import { resolveCenterResizeSize } from "./domEditResizeLocal";
import { resolveResizeDraftRect } from "./resizeDraft";
import {
startGesture as _startGesture,
startGroupDrag as _startGroupDrag,
} from "./domEditOverlayStartGesture";
import { hugRectForElement } from "./domEditOverlayCrop";
import {
resolveSnapAdjustment,
resolveResizeSnapAdjustment,
resolveEquidistanceGuides,
SNAP_THRESHOLD_PX,
} from "./snapEngine";
/** Undo the resize draft's anchor pin: snap GSAP x/y back to the gesture base. */
function restoreResizeAnchorPin(element: HTMLElement, g: GestureState): void {
const anchor = g.resizeAnchor;
if (!anchor || (anchor.pinX === 0 && anchor.pinY === 0)) return;
setElementGsapPosition(element, anchor.baseGsapX, anchor.baseGsapY);
}
import { resolveSnapAdjustment, resolveEquidistanceGuides, SNAP_THRESHOLD_PX } from "./snapEngine";
import { logResize, logResizeMove, logResizeSettle } from "../../utils/resizeDebug";
export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGesturesOptions) {
const setDraftOverlayRect = (next: OverlayRect) => {
@@ -73,6 +65,10 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
height: g.originHeight,
editScaleX: g.editScaleX,
editScaleY: g.editScaleY,
// Every draft rect must carry the element's rotation: the rotation wrapper
// renders rotate(overlayRect.angle), so an omitted angle straightens the
// chrome for the duration of the draft (the "straightens while moving" bug).
angle: g.actualRotation,
});
};
const setDraftGroupOverlayItems = (next: GroupOverlayItem[]) => {
@@ -88,7 +84,11 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
const startGesture = (
kind: GestureKind,
e: React.PointerEvent<HTMLElement>,
options?: { selection?: DomEditSelection; rect?: OverlayRect | null },
options?: {
selection?: DomEditSelection;
rect?: OverlayRect | null;
resizeHandle?: ResizeHandle;
},
) => _startGesture(kind, e, opts, options);
// fallow-ignore-next-line complexity
@@ -183,8 +183,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
actualAngle: g.actualRotation,
snap: e.shiftKey,
});
const draftViaGsap = applyRotationDraftViaGsap(sel.element, rotated.angle);
if (!draftViaGsap) {
if (!applyRotationDraftViaGsap(sel.element, rotated.angle)) {
applyStudioRotationDraft(sel.element, rotated);
}
return;
@@ -192,7 +191,11 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
if (g.kind === "drag") {
const sc = g.snapContext;
if (sc?.snapEnabled && sc.targets.length > 0) {
// Bypass edge-snapping for rotated elements — the snap targets and the
// snapped rect are axis-aligned, so snapping a rotated box's AABB shifts it
// unpredictably. Rotation ~0 keeps snapping exactly as before.
const dragRotated = Math.abs(g.actualRotation) >= ROTATED_SNAP_BYPASS_DEGREES;
if (!dragRotated && sc?.snapEnabled && sc.targets.length > 0) {
// Snap the element's VISIBLE (crop-hugged) edges, not the full bounds.
const movingRect = hugRectForElement(
{
@@ -246,6 +249,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
height: g.originHeight,
editScaleX: g.editScaleX,
editScaleY: g.editScaleY,
angle: g.actualRotation,
});
if (box) {
box.style.left = `${nextBoxLeft}px`;
@@ -255,90 +259,49 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
} else {
if (!box) return;
const sc = g.snapContext;
if (sc?.snapEnabled && sc.targets.length > 0) {
const movingRect = {
left: g.originLeft,
top: g.originTop,
width: g.originWidth,
height: g.originHeight,
};
const allTargets = sc.compositionTarget
? [...sc.targets, sc.compositionTarget]
: sc.targets;
const snap = resolveResizeSnapAdjustment({
movingRect,
proposedDx: dx,
proposedDy: dy,
targets: allTargets,
gridEdges: sc.gridEdges ?? undefined,
threshold: SNAP_THRESHOLD_PX,
disabled: e.altKey,
});
dx = snap.dx;
dy = snap.dy;
opts.snapGuidesRef.current = { guides: snap.guides, spacingGuides: [] };
}
const nextSize = resolveDomEditResizeGesture({
originWidth: g.originWidth,
originHeight: g.originHeight,
actualWidth: g.actualWidth,
actualHeight: g.actualHeight,
scaleX: g.editScaleX,
scaleY: g.editScaleY,
contentScaleX: g.contentScaleX,
contentScaleY: g.contentScaleY,
dx,
dy,
uniform: e.shiftKey,
// CENTER-ANCHORED size (CapCut model): the element scales proportionally
// about its CENTER — the scale is the pointer's RADIAL distance from the
// element center now over its distance at gesture start. Rotation-invariant
// (a distance ignores the angle) and continuous, so all four corners behave
// identically and there is no per-axis projection or edge-snapping. Base size
// is the element-local px size at gesture start (actualWidth/Height,
// GSAP-scale-aware). Corner drag is ALWAYS proportional; there is no
// free-form stretch gesture. Edge-snapping is intentionally NOT applied:
// with center anchoring both edges move symmetrically, so the corner-anchored
// snap math no longer holds — CapCut does not edge-snap during scale either.
const nextSize = resolveCenterResizeSize({
baseWidth: g.actualWidth,
baseHeight: g.actualHeight,
pointer: { x: e.clientX, y: e.clientY },
pointerStart: { x: g.startX, y: g.startY },
centerStart: { x: g.centerX, y: g.centerY },
});
applyStudioBoxSizeDraft(sel.element, nextSize);
// Pin the gesture anchor (top-left): with a live scale transform, the CSS
// size change shifts the rendered box around the element center. Measure
// the drift of the gesture-start corner and counter it via GSAP x/y —
// accumulated onto the previous pin so the correction converges instead
// of oscillating. The release-time position compensation re-measures the
// drop, so the pin composes with the commit.
const anchor = g.resizeAnchor;
if (anchor) {
const pinned = sel.element.getBoundingClientRect();
const nextPinX = anchor.pinX + (anchor.anchorX - pinned.x);
const nextPinY = anchor.pinY + (anchor.anchorY - pinned.y);
if (
setElementGsapPosition(
sel.element,
anchor.baseGsapX + nextPinX,
anchor.baseGsapY + nextPinY,
)
) {
anchor.pinX = nextPinX;
anchor.pinY = nextPinY;
}
}
// Re-read BCR after applying dimensions. For elements with a GSAP
// scale transform and centered transform-origin the visual top-left
// drifts and the visual size diverges from the raw CSS size, so BCR
// is the only accurate source for both.
const overlayEl = opts.overlayRef.current;
const iframe = opts.iframeRef.current;
const refreshed = overlayEl && iframe ? toOverlayRect(overlayEl, iframe, sel.element) : null;
const overlayLeft = refreshed ? refreshed.left : g.originLeft;
const overlayTop = refreshed ? refreshed.top : g.originTop;
const overlayWidth = refreshed ? refreshed.width : nextSize.overlayWidth;
const overlayHeight = refreshed ? refreshed.height : nextSize.overlayHeight;
box.style.left = `${overlayLeft}px`;
box.style.top = `${overlayTop}px`;
box.style.width = `${overlayWidth}px`;
box.style.height = `${overlayHeight}px`;
setDraftOverlayRect({
left: overlayLeft,
top: overlayTop,
width: overlayWidth,
height: overlayHeight,
editScaleX: g.editScaleX,
editScaleY: g.editScaleY,
const measureOrientedRect = () =>
overlayEl && iframe ? orientedOverlayRect(overlayEl, iframe, sel.element) : null;
const draftRect = resolveResizeDraftRect(
g,
sel.element,
overlayEl,
iframe,
measureOrientedRect,
);
logResizeMove({
pointer: { x: e.clientX, y: e.clientY },
nextSize,
anchor: g.lastResizeAnchor ?? null,
draftRect,
liveInlineStyle: sel.element.getAttribute("style"),
});
box.style.left = `${draftRect.left}px`;
box.style.top = `${draftRect.top}px`;
box.style.width = `${draftRect.width}px`;
box.style.height = `${draftRect.height}px`;
setDraftOverlayRect(draftRect);
}
};
@@ -414,9 +377,12 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
}
if (g.kind === "resize" && movedDistance < BLOCKED_MOVE_THRESHOLD_PX) {
restoreResizeAnchorPin(sel.element, g);
restoreStudioBoxSize(sel.element, g.initialBoxSize);
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
if (g.pathOffsetMember) {
restoreManualOffsetDragMembers([g.pathOffsetMember]);
} else {
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
}
if (box) {
box.style.width = `${g.originWidth}px`;
box.style.height = `${g.originHeight}px`;
@@ -444,8 +410,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
restoreStudioRotation(sel.element, g.initialRotation);
}
};
const rotationChanged = hasDomEditRotationChanged(g.actualRotation, finalRotation.angle);
if (!rotationChanged) {
if (!hasDomEditRotationChanged(g.actualRotation, finalRotation.angle)) {
restoreRotation();
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
return;
@@ -464,10 +429,13 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
)
restoreRotation();
})
.finally(() => {
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
});
.finally(() => endStudioManualEditGesture(sel.element, g.manualEditDragToken));
} else if (g.kind === "drag") {
// A moved drag (taps returned earlier) must not let the release click
// re-select whatever now sits under the pointer — dropping over a
// higher-z element should keep the dragged element selected, not select
// the drop target. Mirrors the resize branch below.
opts.suppressNextBoxClickRef.current = true;
const dx = g.lastSnappedDx ?? e.clientX - g.startX;
const dy = g.lastSnappedDy ?? e.clientY - g.startY;
if (!g.pathOffsetMember) {
@@ -483,6 +451,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
height: g.originHeight,
editScaleX: g.editScaleX,
editScaleY: g.editScaleY,
angle: g.actualRotation,
});
if (box) {
box.style.left = `${nextBoxLeft}px`;
@@ -505,18 +474,45 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
opts.suppressNextBoxClickRef.current = true;
const finalSize = readStudioBoxSize(sel.element);
applyStudioBoxSize(sel.element, finalSize);
void Promise.resolve(opts.onBoxSizeCommitRef.current(sel, finalSize))
// Anchored corner resize (NW/NE/SW) also moved the element to keep the
// center planted. Land the size AND the anchor offset in a SINGLE
// box-size commit (one persist, one undo entry). The prior two-commit
// sequence re-stamped the element from source after the size-only persist
// but before the offset persist landed — that one frame (new size, old
// offset) was the release "jump". SE has no anchor member → size only.
const member = g.pathOffsetMember;
const anchor = g.lastResizeAnchor;
const finalOffset =
member && anchor && (anchor.dx !== 0 || anchor.dy !== 0)
? applyManualOffsetDragCommit(member, anchor.dx, anchor.dy)
: null;
logResize("release", {
finalSize,
anchor: anchor ?? null,
finalOffset: finalOffset ?? null,
hasMember: !!member,
inlineStyle: sel.element.getAttribute("style"),
});
const restore = () => {
if (
!g.manualEditDragToken ||
!isStudioManualEditGestureCurrent(sel.element, g.manualEditDragToken)
)
return;
restoreStudioBoxSize(sel.element, g.initialBoxSize);
if (finalOffset) restoreStudioPathOffset(sel.element, g.initialPathOffset);
};
void Promise.resolve(
opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore),
)
.catch((error) => {
console.error("resize commit failed", error);
if (
g.manualEditDragToken &&
isStudioManualEditGestureCurrent(sel.element, g.manualEditDragToken)
) {
restoreResizeAnchorPin(sel.element, g);
restoreStudioBoxSize(sel.element, g.initialBoxSize);
}
})
.finally(() => endStudioManualEditGesture(sel.element, g.manualEditDragToken));
.finally(() => {
if (member) endManualOffsetDragMembers([member]);
else endStudioManualEditGesture(sel.element, g.manualEditDragToken);
});
logResizeSettle(sel.element, "post-release");
}
};
@@ -534,9 +530,12 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
restoreGestureOverlayRect(g);
}
if (g?.mode === "box-size" && sel) {
restoreResizeAnchorPin(sel.element, g);
restoreStudioBoxSize(sel.element, g.initialBoxSize);
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
if (g.pathOffsetMember) {
restoreManualOffsetDragMembers([g.pathOffsetMember]);
} else {
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
}
restoreGestureOverlayRect(g);
}
if (g?.mode === "rotation" && sel) {
@@ -13,6 +13,7 @@ import {
groupOverlayItemsEqual,
isElementVisibleForOverlay,
groupAwareOverlayRect,
orientedGroupAwareOverlayRect,
rectsEqual,
resolveElementForOverlay,
selectionCacheKey,
@@ -157,7 +158,13 @@ export function useDomEditOverlayRects({
// backgroundless full-bleed scene above a subcomposition), which would wrongly
// hide the selection box. Occlusion stays for hover, where a false hide is cheap.
if (el && isElementVisibleForOverlay(el)) {
const nextRect = groupAwareOverlayRect(overlayEl, iframe, el);
// Groups render as an AABB union of their members (a group OBB is out of
// scope); a single element renders as an oriented box that co-rotates
// with its transform. orientedOverlayRect gates on rotation internally
// (a cheap per-call check) and only pays for the full corner-transform
// measurement when the element is actually rotated — this RAF loop runs
// every frame for any single selection, so that gate matters here most.
const nextRect = orientedGroupAwareOverlayRect(overlayEl, iframe, el);
setOverlayRect(nextRect);
const descendants = el.querySelectorAll("*");
if (descendants.length > 0 && descendants.length <= 60) {
@@ -242,7 +249,7 @@ export function useDomEditOverlayRects({
return;
}
setHoverRect(groupAwareOverlayRect(overlayEl, iframe, hoverEl));
setHoverRect(orientedGroupAwareOverlayRect(overlayEl, iframe, hoverEl));
};
frame = requestAnimationFrame(update);