mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
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:
co-authored by
ukimsanov
parent
9940503102
commit
df29fa7a5e
@@ -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 iframe→root 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 iframe→overlay 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
|
||||
* screen→offset 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);
|
||||
|
||||
Reference in New Issue
Block a user