/** * @file use-element-resize.tsx — feature-element-editing-robustness Stage D * @scope apps/studio/use-element-resize.tsx * @purpose On-canvas drag-resize for the single selected CANVAS ELEMENT * (an `[data-cd-id]` node in an artboard), the DOM counterpart of * `use-annotation-resize.tsx` (which targets the SVG annotation * model). Renders 8 screen-space handles (4 corner + 4 edge) that * stay a constant 8 px at any zoom, with FigJam modifier grammar: * Shift = lock aspect ratio, Alt = resize from center. On release it * commits `width`/`height` (+ `left`/`top` for a top/left-edge drag * on an out-of-flow element) through the SAME `reposition-request`- * style lane the coordinate drag uses (untrusted canvas REQUESTS, * main-origin shell WRITES via `/_api/edit-css` — DDR-054). * * Composition with ReorderDrag: the handles are `position:fixed` * overlay divs OUTSIDE the artboard, so ReorderDrag's `el.contains * (target)` gate (canvas-shell) never fires for a handle pointerdown * — a body drag still moves via reorder/reposition, a handle drag * resizes. * * In-flow elements resize by writing explicit `width`/`height` only * (a resize NEVER implicitly converts to absolute; the explicit * context-menu "Convert children to absolute" — feature-4 T8, * DDR-188 — is the opt-in flatten path); an out-of-flow * (absolute/fixed) element additionally moves `left`/`top` when the * top/left edge is dragged, so the resize feels origin-correct. */ import { type ReactNode, useCallback, useEffect, useRef } from 'react'; import { globalCdOccurrence, resolveSelectionEl } from './dom-selection.ts'; import { isElementDragActive } from './drag-state.ts'; import { useSelectionSet } from './use-selection-set.tsx'; import { useToolMode } from './use-tool-mode.tsx'; // ───────────────────────────────────────────────────────────────────────────── // Handle CSS — mirrors the `.dc-annot-resize-handle` recipe (DS `.sel-handle`: // accent square, accent-fg hairline border, radius-xs). Kept as its own class so // it doesn't depend on the annotation overlay being mounted. Comment must stay // backtick-free — it lives inside the template literal. const EL_RESIZE_CSS = ` .dc-el-resize-handle { position: fixed; width: 8px; height: 8px; background: var(--maude-hud-accent, oklch(0.680 0.180 268)); border: 1px solid var(--maude-hud-accent-fg, oklch(0.180 0.030 268)); border-radius: 3px; z-index: 6; pointer-events: auto; touch-action: none; opacity: 0; transition: opacity 120ms cubic-bezier(0.4, 0, 0.2, 1); } .dc-el-resize-handle[data-corner="nw"], .dc-el-resize-handle[data-corner="se"] { cursor: nwse-resize !important; } .dc-el-resize-handle[data-corner="ne"], .dc-el-resize-handle[data-corner="sw"] { cursor: nesw-resize !important; } .dc-el-resize-handle[data-corner="n"], .dc-el-resize-handle[data-corner="s"] { cursor: ns-resize !important; width: 14px; height: 6px; } .dc-el-resize-handle[data-corner="e"], .dc-el-resize-handle[data-corner="w"] { cursor: ew-resize !important; width: 6px; height: 14px; } /* Rotation lives in INVISIBLE zones just outside each CORNER (FigJam / mirrors the annotation .dc-annot-rotate-zone) — the cursor flips to a rotate glyph and dragging turns the element. z-index BELOW the corner squares so the resize square wins on the inner overlap; the surrounding ring rotates. */ .dc-el-resize-handle[data-corner^="rot-"] { width: 20px; height: 20px; background: transparent !important; border: none !important; border-radius: 0 !important; z-index: 5; cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='white' stroke-width='4' d='M4.8 13 A6 6 0 1 1 15.2 13 M2.2 10.8 L4.8 13 L7.4 10.9 M12.6 10.9 L15.2 13 L17.8 10.8'/%3E%3Cpath stroke='black' stroke-width='1.8' d='M4.8 13 A6 6 0 1 1 15.2 13 M2.2 10.8 L4.8 13 L7.4 10.9 M12.6 10.9 L15.2 13 L17.8 10.8'/%3E%3C/g%3E%3C/svg%3E") 10 10, alias !important; } /* Task L7 — live W×H (+ X,Y for an out-of-flow edge drag) readout pill, shown only WHILE a resize is in flight (see the tick() gate below). */ .dc-el-resize-readout { position: fixed; transform: translate(-50%, 12px); font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, monospace); font-size: 11px; padding: 3px 7px; background: var(--maude-hud-accent, oklch(0.680 0.180 268)); color: var(--maude-hud-accent-fg, oklch(0.180 0.030 268)); border-radius: 4px; letter-spacing: 0.02em; white-space: nowrap; font-variant-numeric: tabular-nums; z-index: 6; pointer-events: none; opacity: 0; transition: opacity 120ms cubic-bezier(0.4, 0, 0.2, 1); } /* INV-2 — reveal/settle collapses to 1ms, same idiom as every other overlay stylesheet in this codebase (equal-spacing-handles.tsx, etc). This overlay is mounted inside the canvas iframe's OWN document, which never loads the shell's 1-tokens-maude.css — the prefers-reduced-motion guard has to be re-declared per injected stylesheet, it doesn't cascade in from the shell. Comment stays backtick-free — it lives inside the template literal. */ @media (prefers-reduced-motion: reduce) { .dc-el-resize-handle, .dc-el-resize-readout { transition-duration: 1ms; } } `.trim(); function ensureElementResizeStyles(): void { if (typeof document === 'undefined') return; if (document.getElementById('dc-el-resize-css')) return; const s = document.createElement('style'); s.id = 'dc-el-resize-css'; s.textContent = EL_RESIZE_CSS; document.head.appendChild(s); } // ───────────────────────────────────────────────────────────────────────────── // Pure resize math — unit-tested without a DOM. export type ElResizeCorner = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'; /** The 8 handles in a stable render order. */ export const EL_RESIZE_CORNERS: ElResizeCorner[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']; export interface ElResizeStart { /** World-px border-box dimensions at drag start. */ w: number; h: number; /** Authored inline `left`/`top` (world px), or NaN when not present. */ left: number; top: number; } export interface ElResizeMods { aspect: boolean; // Shift → lock start ratio center: boolean; // Alt → resize from center } export interface ElResizeFlags { /** True only for an out-of-flow element with an inline `left` — so a west-edge * drag may move the origin. In-flow elements resize from their layout origin * (width/height only), never moving left/top. */ canMoveLeft: boolean; canMoveTop: boolean; } export interface ElResizeResult { width: number; height: number; left?: number; top?: number; } function edgeFlags(corner: ElResizeCorner) { return { movesLeft: corner === 'nw' || corner === 'w' || corner === 'sw', movesRight: corner === 'ne' || corner === 'e' || corner === 'se', movesTop: corner === 'nw' || corner === 'n' || corner === 'ne', movesBottom: corner === 'sw' || corner === 's' || corner === 'se', }; } const round = (n: number) => Math.round(n * 100) / 100; const MIN_SIZE = 1; /** * Compute the target box for a resize drag. `dxW`/`dyW` are the cursor deltas in * WORLD units (screen delta ÷ the element's own render zoom). The result carries * `left`/`top` ONLY when the corresponding edge moved AND the flag permits it * (out-of-flow with an inline value) — so an in-flow element never gets a left/ * top write. Left/top are derived by holding the OPPOSITE edge fixed (Figma * grammar), which is exact for both plain and aspect-locked resizes. */ export function computeElementResize( corner: ElResizeCorner, start: ElResizeStart, dxW: number, dyW: number, mods: ElResizeMods, flags: ElResizeFlags ): ElResizeResult { const { movesLeft, movesRight, movesTop, movesBottom } = edgeFlags(corner); const center = mods.center; let width = start.w; let height = start.h; if (movesRight) width = start.w + (center ? 2 * dxW : dxW); else if (movesLeft) width = start.w - (center ? 2 * dxW : dxW); if (movesBottom) height = start.h + (center ? 2 * dyW : dyW); else if (movesTop) height = start.h - (center ? 2 * dyW : dyW); // Aspect lock (Shift): keep the start ratio. Corner → drive by the axis with // the larger relative change; single edge → scale the other dimension. if (mods.aspect && start.w > 0 && start.h > 0) { const ratio = start.w / start.h; const isCorner = (movesLeft || movesRight) && (movesTop || movesBottom); if (isCorner) { const relW = Math.abs(width - start.w) / start.w; const relH = Math.abs(height - start.h) / start.h; if (relW >= relH) height = width / ratio; else width = height * ratio; } else if (movesLeft || movesRight) { height = width / ratio; } else if (movesTop || movesBottom) { width = height * ratio; } } if (width < MIN_SIZE) width = MIN_SIZE; if (height < MIN_SIZE) height = MIN_SIZE; const result: ElResizeResult = { width: round(width), height: round(height) }; // Derive left/top from the FINAL size by holding the opposite edge fixed. if (center) { if (flags.canMoveLeft && (movesLeft || movesRight)) result.left = round(start.left + (start.w - width) / 2); if (flags.canMoveTop && (movesTop || movesBottom)) result.top = round(start.top + (start.h - height) / 2); } else { if (flags.canMoveLeft && movesLeft) result.left = round(start.left + start.w - width); if (flags.canMoveTop && movesTop) result.top = round(start.top + start.h - height); } return result; } // ───────────────────────────────────────────────────────────────────────────── // Rotation math (Task L8) — free-hand rotate handle. All pure, unit-tested. /** Rotation angle (deg) from a CSS `transform` matrix (`none`/`matrix(...)`). */ export function rotationDegFromMatrix(transform: string | null | undefined): number { if (!transform || transform === 'none') return 0; const m = /matrix\(([^)]+)\)/.exec(transform); if (!m) return 0; const parts = m[1].split(',').map(Number); const a = parts[0] ?? 1; const b = parts[1] ?? 0; return Math.round(Math.atan2(b, a) * (180 / Math.PI) * 100) / 100; } /** Uniform scale from a CSS `transform` matrix (the `.dc-world` zoom). 1 when none. */ export function scaleFromMatrix(transform: string | null | undefined): number { if (!transform || transform === 'none') return 1; const m = /matrix\(([^)]+)\)/.exec(transform); if (!m) return 1; const parts = m[1].split(',').map(Number); const a = parts[0] ?? 1; const b = parts[1] ?? 0; const s = Math.hypot(a, b); return s > 0 ? s : 1; } /** * The `.dc-world` camera's effective render zoom for an element. Dogfood fix * (print-artboards round): the camera writes DIFFERENT properties per engine * (writeTransform's IS_WEBKIT split in canvas-lib) — WebKit gets * `transform: scale(z)`, Chromium gets the CSS `zoom` PROPERTY (whose * computed `transform` is a bare translate). Reading only the matrix * therefore returned 1 in every non-WebKit browser and the handle/spacing * overlays drew at world size instead of screen size, visibly scaling with * the camera (the desktop app is WKWebView, which is why dogfooding never * caught it). Multiplying both reads is exact on each engine: the unused * property computes to identity. */ export function worldZoomFor(el: Element): number { const world = el.closest('.dc-world') as HTMLElement | null; if (!world) return 1; const cs = getComputedStyle(world); const zoomProp = Number.parseFloat((cs as { zoom?: string }).zoom || '1') || 1; return scaleFromMatrix(cs.transform) * zoomProp; } /** Rotate a local offset (dx,dy) by `deg` — screen y is down, positive = CW. */ export function rotatePointDeg(dx: number, dy: number, deg: number): [number, number] { const r = (deg * Math.PI) / 180; const cos = Math.cos(r); const sin = Math.sin(r); return [dx * cos - dy * sin, dx * sin + dy * cos]; } /** Screen angle (radians) from the element center to a point. */ export function pointerAngleRad(cx: number, cy: number, px: number, py: number): number { return Math.atan2(py - cy, px - cx); } /** * RELATIVE rotate (FigJam corner-zone model): the element's new angle after * dragging from `startPointerRad` to `curPointerRad`, starting from * `startElementDeg`. Grab any corner zone and turn — the delta is the change in * the center→pointer angle, so the corner tracks the cursor. Snaps to 15° while * Shift is held. Normalized to (-180, 180]. */ export function rotateDeltaDeg( startElementDeg: number, startPointerRad: number, curPointerRad: number, snap: boolean ): number { let deg = startElementDeg + (curPointerRad - startPointerRad) * (180 / Math.PI); while (deg > 180) deg -= 360; while (deg <= -180) deg += 360; if (snap) deg = Math.round(deg / 15) * 15; return Math.round(deg * 100) / 100; } // ───────────────────────────────────────────────────────────────────────────── // Overlay component — mounted once in CanvasShell alongside SelectionHalos. type RotCorner = 'rot-nw' | 'rot-ne' | 'rot-sw' | 'rot-se'; interface ElResizeDrag { pointerId: number; corner: ElResizeCorner | RotCorner; el: HTMLElement; cdId: string; /** GLOBAL DOM-occurrence index of `el` among same-cd-id nodes (Stage H3). For a * reused component the server maps it to the dragged instance's `` * usage so resizing one instance stays local; 0 for a normal element. */ idIndex: number; /** Stage D4 — set when this drag targets a whole ARTBOARD frame (`[data-dc- * screen]`, no data-cd-id) instead of a canvas element. Commits via * `resize-artboard-request` (numeric width/height PROPS, DDR-027) instead of * `resize-request`. Null for an ordinary element drag. */ artboardId: string | null; startClientX: number; startClientY: number; elZoom: number; // rect.width / offsetWidth — the element's own render scale /** Element rotation (deg) at drag start — resize deltas are un-rotated by it, * and it's the base angle a rotate drag turns FROM. */ angle: number; /** Screen center of the element — the pivot for a rotate drag. */ cx: number; cy: number; /** center→pointer angle (rad) at rotate-drag start (relative rotation base). */ rotStartPointer: number; start: ElResizeStart; flags: ElResizeFlags; /** Inline style values BEFORE the drag — the undo `before` + failure restore. */ before: { width: string | null; height: string | null; left: string | null; top: string | null; transform: string | null; }; lastResult: ElResizeResult | null; /** Last previewed `transform` (rotate drag) — the commit value. */ lastTransform: string | null; } /** Post the committed resize to the main-origin shell. Split out so the message * shape stays in one place. `null` before = the prop was unset (reset on undo). */ function postResizeRequest(drag: ElResizeDrag, r: ElResizeResult): void { const patch: Record = { width: `${r.width}px`, height: `${r.height}px`, }; if (typeof r.left === 'number') patch.left = `${r.left}px`; if (typeof r.top === 'number') patch.top = `${r.top}px`; try { window.parent.postMessage( { dgn: 'resize-request', id: drag.cdId, patch, before: drag.before, idIndex: drag.idIndex }, '*' ); } catch { /* detached / cross-origin teardown */ } } /** Stage D4 — post a committed ARTBOARD resize. `width`/`height` are plain * NUMBERS (world px, not px strings) — the shell writes them as numeric JSX * attrs via `/_api/resize-artboard` (DDR-027: artboard size is JSX-authoritative, * not inline style). Never carries left/top — see the `artboardId` doc above. */ function postResizeArtboardRequest(drag: ElResizeDrag, r: ElResizeResult): void { if (!drag.artboardId) return; try { window.parent.postMessage( { dgn: 'resize-artboard-request', artboardId: drag.artboardId, width: r.width, height: r.height, }, '*' ); } catch { /* detached / cross-origin teardown */ } } /** Post a committed rotate (Task L8) — a `transform` patch on the same lane. */ function postRotateRequest(drag: ElResizeDrag, transform: string): void { try { window.parent.postMessage( { dgn: 'resize-request', id: drag.cdId, patch: { transform }, before: drag.before, idIndex: drag.idIndex, }, '*' ); } catch { /* detached / cross-origin teardown */ } } export function ElementResizeOverlay(): ReactNode { ensureElementResizeStyles(); const { selected } = useSelectionSet(); const { tool } = useToolMode(); const containerRef = useRef(null); const rafRef = useRef(null); const dragRef = useRef(null); // Last committed { el, before } — restored if the shell reports the write // failed (dgn:'resize-failed'), so a rejected resize doesn't leave a phantom // inline box that the (suppressed / absent) HMR reload never corrects. const lastCommitRef = useRef<{ el: HTMLElement; before: ElResizeDrag['before'] } | null>(null); const one = selected.length === 1 ? selected[0] : null; const cdId = one && typeof one.id === 'string' ? one.id : null; // Stage D4 — a whole-ARTBOARD selection (chrome click: no data-cd-id, just the // `data-dc-screen` host) also gets handles, restricted to E/S/SE (growth-only — // see the `artboardId` doc on ElResizeDrag for why left/top never move). const artboardOnly = one && !cdId && typeof one.artboardId === 'string' ? one.artboardId : null; // A real element OR artboard selection with the move tool active gets handles. const active = tool === 'move' && (!!cdId || !!artboardOnly); // rAF loop — follow the selected element's screen box (pan/zoom + layout). useEffect(() => { const c = containerRef.current; if (!c) return; // INV-2 — opacity+pointer-events, not display:none, so a fresh selection's // handles/readout FADE in rather than pop (the `transition` on both // classes above). Interactivity (pointer-events) flips instantly — a // handle is grabbable the moment it's selected, even mid-fade. const hideAll = () => { for (const child of Array.from(c.children)) { const h = child as HTMLElement; h.style.opacity = '0'; h.style.pointerEvents = 'none'; } }; if (!active || !one) { hideAll(); return; } const tick = () => { rafRef.current = null; // Dogfood 2026-07-07 — a live ReorderDrag is the canvas's most DOM-churn- // heavy gesture (never true for OUR own resize drag — a handle grab // stopPropagation()s before ReorderDrag's own pointerdown listener sees // it); skip this overlay's per-frame work while one is in flight so it // doesn't compound the jank ("hrozně zavaší ten toolbar"). if (isElementDragActive()) { hideAll(); rafRef.current = requestAnimationFrame(tick); return; } const el = resolveSelectionEl(document, one) as HTMLElement | null; // Skip inline text runs where width/height are meaningless (no explicit box). if (!el) { hideAll(); rafRef.current = requestAnimationFrame(tick); return; } const r = el.getBoundingClientRect(); if (r.width <= 0 && r.height <= 0) { hideAll(); rafRef.current = requestAnimationFrame(tick); return; } // Stage D4 — an artboard gets growth-only handles (E/S/SE) and NO rotate // zones (artboards don't rotate); an element gets the full 8 + 4 rotate set. const resizeCorners: ElResizeCorner[] = artboardOnly ? ['e', 's', 'se'] : EL_RESIZE_CORNERS; const rotateZones: RotCorner[] = artboardOnly ? [] : ['rot-nw', 'rot-ne', 'rot-sw', 'rot-se']; // +1 persistent slot (LAST child) for the Task L7 readout pill — always // present, hidden via opacity outside an active drag. const TOTAL = resizeCorners.length + rotateZones.length + 1; while (c.children.length < TOTAL) c.appendChild(document.createElement('div')); while (c.children.length > TOTAL) c.lastChild && c.removeChild(c.lastChild); // Rotation-aware placement (Task L8): the element's AABB center is its true // center (default transform-origin), so handles ride the ROTATED box. Read // the element's own rotation + the .dc-world zoom from computed style (DOM // reads — the overlay stays camera-agnostic, no viewport controller). const cx = r.left + r.width / 2; const cy = r.top + r.height / 2; const deg = rotationDegFromMatrix(getComputedStyle(el).transform); const zoom = worldZoomFor(el); const hw = (el.offsetWidth * zoom) / 2; const hh = (el.offsetHeight * zoom) / 2; // Rotate zones sit ON each corner (same offset), 20×20, z-index below the // 8×8 corner square — so the square wins the center + the ring rotates. const localOffset: Record = { nw: [-hw, -hh], n: [0, -hh], ne: [hw, -hh], e: [hw, 0], se: [hw, hh], s: [0, hh], sw: [-hw, hh], w: [-hw, 0], 'rot-nw': [-hw, -hh], 'rot-ne': [hw, -hh], 'rot-sw': [-hw, hh], 'rot-se': [hw, hh], }; // Rotate zones FIRST (lower in the DOM / z-index 5), corners LAST so the // 8×8 squares paint on top in the overlap. const handles = [...rotateZones, ...resizeCorners]; for (let i = 0; i < handles.length; i++) { const corner = handles[i]; const handle = c.children[i] as HTMLElement; const [ox, oy] = localOffset[corner] ?? [0, 0]; const [rx, ry] = rotatePointDeg(ox, oy, deg); const ax = cx + rx; const ay = cy + ry; const isRot = corner.startsWith('rot-'); const ns = corner === 'n' || corner === 's'; const ew = corner === 'e' || corner === 'w'; const halfW = isRot ? 10 : ns ? 7 : ew ? 3 : 4; const halfH = isRot ? 10 : ns ? 3 : ew ? 7 : 4; handle.className = 'dc-el-resize-handle'; handle.dataset.corner = corner; handle.style.opacity = '1'; handle.style.pointerEvents = 'auto'; handle.style.left = `${Math.round(ax - halfW)}px`; handle.style.top = `${Math.round(ay - halfH)}px`; // Orient the edge pills with the element; rotate zones stay unrotated. handle.style.transform = isRot ? '' : `rotate(${deg}deg)`; } // Task L7 — live W×H (+X,Y when an edge drag moves the origin) readout, // shown only while THIS element's resize handle is actually being // dragged (dragRef is set by the pointerdown handler below). World // units — offsetWidth/Height + the drag's own left/top, unaffected by // the .dc-world zoom transform (same invariant Stage A's camera fix // relies on), so the number reads correctly at any zoom. const readout = c.children[handles.length] as HTMLElement; const drag = dragRef.current; if (drag && drag.el === el && !drag.corner.startsWith('rot-')) { const w = drag.lastResult?.width ?? el.offsetWidth; const h = drag.lastResult?.height ?? el.offsetHeight; let label = `${Math.round(w)} × ${Math.round(h)}`; const lx = drag.lastResult?.left; const ly = drag.lastResult?.top; if (typeof lx === 'number' || typeof ly === 'number') { label += ` · ${Math.round(lx ?? drag.start.left)}, ${Math.round(ly ?? drag.start.top)}`; } readout.className = 'dc-el-resize-readout'; readout.textContent = label; readout.style.opacity = '1'; readout.style.left = `${Math.round(cx)}px`; readout.style.top = `${Math.round(cy + hh)}px`; } else { readout.style.opacity = '0'; } rafRef.current = requestAnimationFrame(tick); }; rafRef.current = requestAnimationFrame(tick); return () => { if (rafRef.current != null) cancelAnimationFrame(rafRef.current); }; }, [active, one, artboardOnly]); // Live-apply a resize result to the element's inline style (instant preview, // no source write). Commit on release. const applyPreview = useCallback((d: ElResizeDrag, r: ElResizeResult) => { d.el.style.width = `${r.width}px`; d.el.style.height = `${r.height}px`; if (typeof r.left === 'number') d.el.style.left = `${r.left}px`; if (typeof r.top === 'number') d.el.style.top = `${r.top}px`; d.lastResult = r; }, []); // Pointer handling — pointerdown on a handle starts the drag. useEffect(() => { const c = containerRef.current; if (!c) return; const computeFromEvent = (d: ElResizeDrag, ev: PointerEvent): ElResizeResult => { // Un-rotate the screen delta into the element's LOCAL frame (so dragging a // handle resizes along the element's own axes on a rotated element), then // ÷ render zoom → world units. const [ldx, ldy] = rotatePointDeg( ev.clientX - d.startClientX, ev.clientY - d.startClientY, -d.angle ); return computeElementResize( d.corner as ElResizeCorner, d.start, ldx / d.elZoom, ldy / d.elZoom, // Stage D4 — Alt/center is ignored for an artboard: without a left/top // write, "center" would just double the growth speed with no visual // recentering (confusing). Aspect-lock (Shift) still applies to both. { aspect: !!ev.shiftKey, center: !d.artboardId && !!ev.altKey }, d.flags ); }; const onDown = (e: PointerEvent) => { const t = e.target as HTMLElement | null; if (!t?.classList.contains('dc-el-resize-handle')) return; const corner = t.dataset.corner as ElResizeCorner | RotCorner | undefined; if (!corner || !one) return; const elCdId = typeof one.id === 'string' ? one.id : null; const elArtboardId = !elCdId && typeof one.artboardId === 'string' ? one.artboardId : null; if (!elCdId && !elArtboardId) return; const el = resolveSelectionEl(document, one) as HTMLElement | null; if (!el) return; e.preventDefault(); e.stopPropagation(); const rect = el.getBoundingClientRect(); const elZoom = el.offsetWidth ? rect.width / el.offsetWidth : 1; const cs = getComputedStyle(el); // Stage D4 — an artboard NEVER derives left/top from a resize drag (the // route only writes width/height PROPS; layout.artboards[] x/y stays // untouched — see the plan's Task D4 gotcha). Forcing outOfFlow=false here // (even though an artboard IS position:absolute in the DOM) is what makes // `flags.canMoveLeft/Top` false below; the growth-only E/S/SE handle set // rendered above is the visual half of the same guarantee. const outOfFlow = !elArtboardId && (cs.position === 'absolute' || cs.position === 'fixed'); const startLeft = elArtboardId ? Number.NaN : Number.parseFloat(el.style.left); const startTop = elArtboardId ? Number.NaN : Number.parseFloat(el.style.top); const angle = elArtboardId ? 0 : rotationDegFromMatrix(cs.transform); const z = elZoom > 0 ? elZoom : 1; const cx = rect.left + rect.width / 2; const cy = rect.top + rect.height / 2; const drag: ElResizeDrag = { pointerId: e.pointerId, corner, el, cdId: elCdId ?? '', artboardId: elArtboardId, // Stage H3 — which instance (global same-cd-id DOM occurrence) so a // reused-component resize routes to its own `` usage (local). // N/A (0) for an artboard drag. idIndex: elCdId ? globalCdOccurrence(document, elCdId, el) : 0, startClientX: e.clientX, startClientY: e.clientY, elZoom: z, angle, cx, cy, rotStartPointer: pointerAngleRad(cx, cy, e.clientX, e.clientY), start: { // World-px border box. rect is the AABB; for a rotated element that // over-states w/h, so derive from offset dims when rotated. w: (angle ? el.offsetWidth : rect.width / z) || rect.width / z, h: (angle ? el.offsetHeight : rect.height / z) || rect.height / z, left: startLeft, top: startTop, }, flags: { canMoveLeft: outOfFlow && Number.isFinite(startLeft), canMoveTop: outOfFlow && Number.isFinite(startTop), }, before: { width: el.style.width || null, height: el.style.height || null, left: el.style.left || null, top: el.style.top || null, transform: el.style.transform || null, }, lastResult: null, lastTransform: null, }; dragRef.current = drag; try { t.setPointerCapture(e.pointerId); } catch { /* synthetic events may reject capture */ } }; const onMove = (e: PointerEvent) => { const d = dragRef.current; if (!d || e.pointerId !== d.pointerId) return; e.preventDefault(); if (d.corner.startsWith('rot-')) { // Rotate RELATIVE to the grabbed corner: turn by the change in the // center→pointer angle from where the drag started. Shift snaps to 15°. const deg = rotateDeltaDeg( d.angle, d.rotStartPointer, pointerAngleRad(d.cx, d.cy, e.clientX, e.clientY), !!e.shiftKey ); const tf = `rotate(${deg}deg)`; d.el.style.transform = tf; d.lastTransform = tf; return; } applyPreview(d, computeFromEvent(d, e)); }; const onUp = (e: PointerEvent) => { const d = dragRef.current; if (!d || e.pointerId !== d.pointerId) return; dragRef.current = null; if (d.corner.startsWith('rot-')) { const tf = d.lastTransform; if (!tf || tf === d.before.transform) return; // no-op lastCommitRef.current = { el: d.el, before: d.before }; postRotateRequest(d, tf); return; } const r = d.lastResult ?? computeFromEvent(d, e); // No-op guard: nothing changed → don't churn a source write / undo entry. const changed = `${r.width}px` !== d.before.width || `${r.height}px` !== d.before.height || (typeof r.left === 'number' && `${r.left}px` !== d.before.left) || (typeof r.top === 'number' && `${r.top}px` !== d.before.top); if (!changed) return; lastCommitRef.current = { el: d.el, before: d.before }; if (d.artboardId) postResizeArtboardRequest(d, r); else postResizeRequest(d, r); }; // Restore the pre-drag inline box if the shell reports the write failed. // `resize-artboard-failed` (Stage D4) shares the same restore shape — an // artboard drag's `before` never carries left/top (canMoveLeft/Top forced // false), so the width/height-only branch below is already correct for it. const onFail = (e: MessageEvent) => { const m = e.data as { dgn?: string } | null; if (m?.dgn !== 'resize-failed' && m?.dgn !== 'resize-artboard-failed') return; if (e.source !== window.parent) return; // only the parent shell (DDR-054) const last = lastCommitRef.current; if (!last) return; last.el.style.width = last.before.width ?? ''; last.el.style.height = last.before.height ?? ''; if (last.before.left !== null || last.el.style.left) last.el.style.left = last.before.left ?? ''; if (last.before.top !== null || last.el.style.top) last.el.style.top = last.before.top ?? ''; if (last.before.transform !== null || last.el.style.transform) last.el.style.transform = last.before.transform ?? ''; lastCommitRef.current = null; }; c.addEventListener('pointerdown', onDown); window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onUp); window.addEventListener('pointercancel', onUp); window.addEventListener('message', onFail); return () => { c.removeEventListener('pointerdown', onDown); window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onUp); window.removeEventListener('pointercancel', onUp); window.removeEventListener('message', onFail); }; }, [one, applyPreview]); return