@1agh/maude 0.40.0 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/studio/ai-banner.tsx +2 -2
- package/apps/studio/annotations-layer.tsx +53 -2
- package/apps/studio/api.ts +504 -3
- package/apps/studio/artboard-marquee.tsx +1 -1
- package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
- package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
- package/apps/studio/bin/_fetch-asset.mjs +556 -0
- package/apps/studio/bin/annotate.mjs +576 -34
- package/apps/studio/bin/canvas-rects.sh +152 -0
- package/apps/studio/bin/fetch-asset.sh +34 -0
- package/apps/studio/bin/read-annotations.mjs +138 -7
- package/apps/studio/bin/smoke.sh +42 -6
- package/apps/studio/build.ts +21 -0
- package/apps/studio/canvas-comment-mount.tsx +138 -4
- package/apps/studio/canvas-edit.ts +744 -11
- package/apps/studio/canvas-lib.tsx +219 -2
- package/apps/studio/canvas-shell.tsx +487 -20
- package/apps/studio/client/app.jsx +1451 -22
- package/apps/studio/client/comments-overlay.css +130 -126
- package/apps/studio/client/github.js +8 -0
- package/apps/studio/client/styles/3-shell-maude.css +48 -0
- package/apps/studio/comments-overlay.tsx +148 -41
- package/apps/studio/context-menu.tsx +15 -5
- package/apps/studio/contextual-toolbar.tsx +262 -4
- package/apps/studio/cursors-overlay.tsx +4 -4
- package/apps/studio/dist/client.bundle.js +20 -20
- package/apps/studio/dist/comment-mount.js +59 -1
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +127 -1
- package/apps/studio/drag-state.ts +24 -0
- package/apps/studio/equal-spacing-detector.ts +205 -0
- package/apps/studio/export-dialog.tsx +1 -1
- package/apps/studio/history.ts +47 -1
- package/apps/studio/http.ts +223 -0
- package/apps/studio/input-router.tsx +12 -0
- package/apps/studio/marquee-overlay.tsx +1 -1
- package/apps/studio/measure-overlay.tsx +241 -0
- package/apps/studio/participants-chrome.tsx +3 -3
- package/apps/studio/sizing-mode.ts +117 -0
- package/apps/studio/spacing-handles.ts +166 -0
- package/apps/studio/test/annotate-write.test.ts +890 -0
- package/apps/studio/test/camera-reveal.test.tsx +173 -0
- package/apps/studio/test/canvas-edit.test.ts +50 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +18 -0
- package/apps/studio/test/canvas-rects.test.ts +198 -0
- package/apps/studio/test/comments-overlay.test.ts +117 -0
- package/apps/studio/test/dom-selection.test.ts +130 -0
- package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
- package/apps/studio/test/edit-scope-api.test.ts +115 -0
- package/apps/studio/test/element-resize.test.ts +136 -0
- package/apps/studio/test/element-structural-api.test.ts +360 -0
- package/apps/studio/test/element-structural-edit.test.ts +233 -0
- package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
- package/apps/studio/test/history-rollback.test.ts +26 -0
- package/apps/studio/test/knob-props-authored.test.ts +87 -0
- package/apps/studio/test/read-annotations.test.ts +154 -0
- package/apps/studio/test/sizing-mode.test.ts +102 -0
- package/apps/studio/test/spacing-handles.test.ts +138 -0
- package/apps/studio/test/specimen-select.test.ts +88 -0
- package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
- package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
- package/apps/studio/tool-palette.tsx +122 -2
- package/apps/studio/undo-hud.tsx +2 -2
- package/apps/studio/use-element-resize.tsx +732 -0
- package/apps/studio/use-keyboard-discipline.tsx +205 -15
- package/apps/studio/use-selection-set.tsx +14 -0
- package/apps/studio/use-spacing-handles.tsx +388 -0
- package/apps/studio/whats-new.json +28 -0
- package/cli/commands/design.mjs +6 -1
- package/cli/commands/design.test.mjs +49 -1
- package/cli/lib/fetch-asset.test.mjs +213 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +10 -2
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file use-element-resize.tsx — feature-element-editing-robustness Stage D
|
|
3
|
+
* @scope apps/studio/use-element-resize.tsx
|
|
4
|
+
* @purpose On-canvas drag-resize for the single selected CANVAS ELEMENT
|
|
5
|
+
* (an `[data-cd-id]` node in an artboard), the DOM counterpart of
|
|
6
|
+
* `use-annotation-resize.tsx` (which targets the SVG annotation
|
|
7
|
+
* model). Renders 8 screen-space handles (4 corner + 4 edge) that
|
|
8
|
+
* stay a constant 8 px at any zoom, with FigJam modifier grammar:
|
|
9
|
+
* Shift = lock aspect ratio, Alt = resize from center. On release it
|
|
10
|
+
* commits `width`/`height` (+ `left`/`top` for a top/left-edge drag
|
|
11
|
+
* on an out-of-flow element) through the SAME `reposition-request`-
|
|
12
|
+
* style lane the coordinate drag uses (untrusted canvas REQUESTS,
|
|
13
|
+
* main-origin shell WRITES via `/_api/edit-css` — DDR-054).
|
|
14
|
+
*
|
|
15
|
+
* Composition with ReorderDrag: the handles are `position:fixed`
|
|
16
|
+
* overlay divs OUTSIDE the artboard, so ReorderDrag's `el.contains
|
|
17
|
+
* (target)` gate (canvas-shell) never fires for a handle pointerdown
|
|
18
|
+
* — a body drag still moves via reorder/reposition, a handle drag
|
|
19
|
+
* resizes.
|
|
20
|
+
*
|
|
21
|
+
* In-flow elements resize by writing explicit `width`/`height` only
|
|
22
|
+
* (NEVER convert-to-absolute — out of scope); an out-of-flow
|
|
23
|
+
* (absolute/fixed) element additionally moves `left`/`top` when the
|
|
24
|
+
* top/left edge is dragged, so the resize feels origin-correct.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { type ReactNode, useCallback, useEffect, useRef } from 'react';
|
|
28
|
+
import { globalCdOccurrence, resolveSelectionEl } from './dom-selection.ts';
|
|
29
|
+
import { isElementDragActive } from './drag-state.ts';
|
|
30
|
+
import { useSelectionSet } from './use-selection-set.tsx';
|
|
31
|
+
import { useToolMode } from './use-tool-mode.tsx';
|
|
32
|
+
|
|
33
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
// Handle CSS — mirrors the `.dc-annot-resize-handle` recipe (DS `.sel-handle`:
|
|
35
|
+
// accent square, accent-fg hairline border, radius-xs). Kept as its own class so
|
|
36
|
+
// it doesn't depend on the annotation overlay being mounted. Comment must stay
|
|
37
|
+
// backtick-free — it lives inside the template literal.
|
|
38
|
+
|
|
39
|
+
const EL_RESIZE_CSS = `
|
|
40
|
+
.dc-el-resize-handle {
|
|
41
|
+
position: fixed;
|
|
42
|
+
width: 8px;
|
|
43
|
+
height: 8px;
|
|
44
|
+
background: var(--maude-hud-accent, oklch(0.680 0.180 268));
|
|
45
|
+
border: 1px solid var(--maude-hud-accent-fg, oklch(0.180 0.030 268));
|
|
46
|
+
border-radius: 3px;
|
|
47
|
+
z-index: 6;
|
|
48
|
+
pointer-events: auto;
|
|
49
|
+
touch-action: none;
|
|
50
|
+
opacity: 0;
|
|
51
|
+
transition: opacity 120ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
52
|
+
}
|
|
53
|
+
.dc-el-resize-handle[data-corner="nw"], .dc-el-resize-handle[data-corner="se"] { cursor: nwse-resize !important; }
|
|
54
|
+
.dc-el-resize-handle[data-corner="ne"], .dc-el-resize-handle[data-corner="sw"] { cursor: nesw-resize !important; }
|
|
55
|
+
.dc-el-resize-handle[data-corner="n"], .dc-el-resize-handle[data-corner="s"] { cursor: ns-resize !important; width: 14px; height: 6px; }
|
|
56
|
+
.dc-el-resize-handle[data-corner="e"], .dc-el-resize-handle[data-corner="w"] { cursor: ew-resize !important; width: 6px; height: 14px; }
|
|
57
|
+
/* Rotation lives in INVISIBLE zones just outside each CORNER (FigJam / mirrors
|
|
58
|
+
the annotation .dc-annot-rotate-zone) — the cursor flips to a rotate glyph and
|
|
59
|
+
dragging turns the element. z-index BELOW the corner squares so the resize
|
|
60
|
+
square wins on the inner overlap; the surrounding ring rotates. */
|
|
61
|
+
.dc-el-resize-handle[data-corner^="rot-"] {
|
|
62
|
+
width: 20px; height: 20px;
|
|
63
|
+
background: transparent !important;
|
|
64
|
+
border: none !important;
|
|
65
|
+
border-radius: 0 !important;
|
|
66
|
+
z-index: 5;
|
|
67
|
+
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;
|
|
68
|
+
}
|
|
69
|
+
/* Task L7 — live W×H (+ X,Y for an out-of-flow edge drag) readout pill, shown
|
|
70
|
+
only WHILE a resize is in flight (see the tick() gate below). */
|
|
71
|
+
.dc-el-resize-readout {
|
|
72
|
+
position: fixed;
|
|
73
|
+
transform: translate(-50%, 12px);
|
|
74
|
+
font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, monospace);
|
|
75
|
+
font-size: 11px;
|
|
76
|
+
padding: 3px 7px;
|
|
77
|
+
background: var(--maude-hud-accent, oklch(0.680 0.180 268));
|
|
78
|
+
color: var(--maude-hud-accent-fg, oklch(0.180 0.030 268));
|
|
79
|
+
border-radius: 4px;
|
|
80
|
+
letter-spacing: 0.02em;
|
|
81
|
+
white-space: nowrap;
|
|
82
|
+
font-variant-numeric: tabular-nums;
|
|
83
|
+
z-index: 6;
|
|
84
|
+
pointer-events: none;
|
|
85
|
+
opacity: 0;
|
|
86
|
+
transition: opacity 120ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
87
|
+
}
|
|
88
|
+
/* INV-2 — reveal/settle collapses to 1ms, same idiom as every other overlay
|
|
89
|
+
stylesheet in this codebase (equal-spacing-handles.tsx, etc). This overlay
|
|
90
|
+
is mounted inside the canvas iframe's OWN document, which never loads the
|
|
91
|
+
shell's 1-tokens-maude.css — the prefers-reduced-motion guard has to be
|
|
92
|
+
re-declared per injected stylesheet, it doesn't cascade in from the shell.
|
|
93
|
+
Comment stays backtick-free — it lives inside the template literal. */
|
|
94
|
+
@media (prefers-reduced-motion: reduce) {
|
|
95
|
+
.dc-el-resize-handle, .dc-el-resize-readout { transition-duration: 1ms; }
|
|
96
|
+
}
|
|
97
|
+
`.trim();
|
|
98
|
+
|
|
99
|
+
function ensureElementResizeStyles(): void {
|
|
100
|
+
if (typeof document === 'undefined') return;
|
|
101
|
+
if (document.getElementById('dc-el-resize-css')) return;
|
|
102
|
+
const s = document.createElement('style');
|
|
103
|
+
s.id = 'dc-el-resize-css';
|
|
104
|
+
s.textContent = EL_RESIZE_CSS;
|
|
105
|
+
document.head.appendChild(s);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
109
|
+
// Pure resize math — unit-tested without a DOM.
|
|
110
|
+
|
|
111
|
+
export type ElResizeCorner = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w';
|
|
112
|
+
|
|
113
|
+
/** The 8 handles in a stable render order. */
|
|
114
|
+
export const EL_RESIZE_CORNERS: ElResizeCorner[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
|
|
115
|
+
|
|
116
|
+
export interface ElResizeStart {
|
|
117
|
+
/** World-px border-box dimensions at drag start. */
|
|
118
|
+
w: number;
|
|
119
|
+
h: number;
|
|
120
|
+
/** Authored inline `left`/`top` (world px), or NaN when not present. */
|
|
121
|
+
left: number;
|
|
122
|
+
top: number;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface ElResizeMods {
|
|
126
|
+
aspect: boolean; // Shift → lock start ratio
|
|
127
|
+
center: boolean; // Alt → resize from center
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface ElResizeFlags {
|
|
131
|
+
/** True only for an out-of-flow element with an inline `left` — so a west-edge
|
|
132
|
+
* drag may move the origin. In-flow elements resize from their layout origin
|
|
133
|
+
* (width/height only), never moving left/top. */
|
|
134
|
+
canMoveLeft: boolean;
|
|
135
|
+
canMoveTop: boolean;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface ElResizeResult {
|
|
139
|
+
width: number;
|
|
140
|
+
height: number;
|
|
141
|
+
left?: number;
|
|
142
|
+
top?: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function edgeFlags(corner: ElResizeCorner) {
|
|
146
|
+
return {
|
|
147
|
+
movesLeft: corner === 'nw' || corner === 'w' || corner === 'sw',
|
|
148
|
+
movesRight: corner === 'ne' || corner === 'e' || corner === 'se',
|
|
149
|
+
movesTop: corner === 'nw' || corner === 'n' || corner === 'ne',
|
|
150
|
+
movesBottom: corner === 'sw' || corner === 's' || corner === 'se',
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const round = (n: number) => Math.round(n * 100) / 100;
|
|
155
|
+
const MIN_SIZE = 1;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Compute the target box for a resize drag. `dxW`/`dyW` are the cursor deltas in
|
|
159
|
+
* WORLD units (screen delta ÷ the element's own render zoom). The result carries
|
|
160
|
+
* `left`/`top` ONLY when the corresponding edge moved AND the flag permits it
|
|
161
|
+
* (out-of-flow with an inline value) — so an in-flow element never gets a left/
|
|
162
|
+
* top write. Left/top are derived by holding the OPPOSITE edge fixed (Figma
|
|
163
|
+
* grammar), which is exact for both plain and aspect-locked resizes.
|
|
164
|
+
*/
|
|
165
|
+
export function computeElementResize(
|
|
166
|
+
corner: ElResizeCorner,
|
|
167
|
+
start: ElResizeStart,
|
|
168
|
+
dxW: number,
|
|
169
|
+
dyW: number,
|
|
170
|
+
mods: ElResizeMods,
|
|
171
|
+
flags: ElResizeFlags
|
|
172
|
+
): ElResizeResult {
|
|
173
|
+
const { movesLeft, movesRight, movesTop, movesBottom } = edgeFlags(corner);
|
|
174
|
+
const center = mods.center;
|
|
175
|
+
|
|
176
|
+
let width = start.w;
|
|
177
|
+
let height = start.h;
|
|
178
|
+
if (movesRight) width = start.w + (center ? 2 * dxW : dxW);
|
|
179
|
+
else if (movesLeft) width = start.w - (center ? 2 * dxW : dxW);
|
|
180
|
+
if (movesBottom) height = start.h + (center ? 2 * dyW : dyW);
|
|
181
|
+
else if (movesTop) height = start.h - (center ? 2 * dyW : dyW);
|
|
182
|
+
|
|
183
|
+
// Aspect lock (Shift): keep the start ratio. Corner → drive by the axis with
|
|
184
|
+
// the larger relative change; single edge → scale the other dimension.
|
|
185
|
+
if (mods.aspect && start.w > 0 && start.h > 0) {
|
|
186
|
+
const ratio = start.w / start.h;
|
|
187
|
+
const isCorner = (movesLeft || movesRight) && (movesTop || movesBottom);
|
|
188
|
+
if (isCorner) {
|
|
189
|
+
const relW = Math.abs(width - start.w) / start.w;
|
|
190
|
+
const relH = Math.abs(height - start.h) / start.h;
|
|
191
|
+
if (relW >= relH) height = width / ratio;
|
|
192
|
+
else width = height * ratio;
|
|
193
|
+
} else if (movesLeft || movesRight) {
|
|
194
|
+
height = width / ratio;
|
|
195
|
+
} else if (movesTop || movesBottom) {
|
|
196
|
+
width = height * ratio;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (width < MIN_SIZE) width = MIN_SIZE;
|
|
201
|
+
if (height < MIN_SIZE) height = MIN_SIZE;
|
|
202
|
+
|
|
203
|
+
const result: ElResizeResult = { width: round(width), height: round(height) };
|
|
204
|
+
// Derive left/top from the FINAL size by holding the opposite edge fixed.
|
|
205
|
+
if (center) {
|
|
206
|
+
if (flags.canMoveLeft && (movesLeft || movesRight))
|
|
207
|
+
result.left = round(start.left + (start.w - width) / 2);
|
|
208
|
+
if (flags.canMoveTop && (movesTop || movesBottom))
|
|
209
|
+
result.top = round(start.top + (start.h - height) / 2);
|
|
210
|
+
} else {
|
|
211
|
+
if (flags.canMoveLeft && movesLeft) result.left = round(start.left + start.w - width);
|
|
212
|
+
if (flags.canMoveTop && movesTop) result.top = round(start.top + start.h - height);
|
|
213
|
+
}
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
218
|
+
// Rotation math (Task L8) — free-hand rotate handle. All pure, unit-tested.
|
|
219
|
+
|
|
220
|
+
/** Rotation angle (deg) from a CSS `transform` matrix (`none`/`matrix(...)`). */
|
|
221
|
+
export function rotationDegFromMatrix(transform: string | null | undefined): number {
|
|
222
|
+
if (!transform || transform === 'none') return 0;
|
|
223
|
+
const m = /matrix\(([^)]+)\)/.exec(transform);
|
|
224
|
+
if (!m) return 0;
|
|
225
|
+
const parts = m[1].split(',').map(Number);
|
|
226
|
+
const a = parts[0] ?? 1;
|
|
227
|
+
const b = parts[1] ?? 0;
|
|
228
|
+
return Math.round(Math.atan2(b, a) * (180 / Math.PI) * 100) / 100;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Uniform scale from a CSS `transform` matrix (the `.dc-world` zoom). 1 when none. */
|
|
232
|
+
export function scaleFromMatrix(transform: string | null | undefined): number {
|
|
233
|
+
if (!transform || transform === 'none') return 1;
|
|
234
|
+
const m = /matrix\(([^)]+)\)/.exec(transform);
|
|
235
|
+
if (!m) return 1;
|
|
236
|
+
const parts = m[1].split(',').map(Number);
|
|
237
|
+
const a = parts[0] ?? 1;
|
|
238
|
+
const b = parts[1] ?? 0;
|
|
239
|
+
const s = Math.hypot(a, b);
|
|
240
|
+
return s > 0 ? s : 1;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Rotate a local offset (dx,dy) by `deg` — screen y is down, positive = CW. */
|
|
244
|
+
export function rotatePointDeg(dx: number, dy: number, deg: number): [number, number] {
|
|
245
|
+
const r = (deg * Math.PI) / 180;
|
|
246
|
+
const cos = Math.cos(r);
|
|
247
|
+
const sin = Math.sin(r);
|
|
248
|
+
return [dx * cos - dy * sin, dx * sin + dy * cos];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Screen angle (radians) from the element center to a point. */
|
|
252
|
+
export function pointerAngleRad(cx: number, cy: number, px: number, py: number): number {
|
|
253
|
+
return Math.atan2(py - cy, px - cx);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* RELATIVE rotate (FigJam corner-zone model): the element's new angle after
|
|
258
|
+
* dragging from `startPointerRad` to `curPointerRad`, starting from
|
|
259
|
+
* `startElementDeg`. Grab any corner zone and turn — the delta is the change in
|
|
260
|
+
* the center→pointer angle, so the corner tracks the cursor. Snaps to 15° while
|
|
261
|
+
* Shift is held. Normalized to (-180, 180].
|
|
262
|
+
*/
|
|
263
|
+
export function rotateDeltaDeg(
|
|
264
|
+
startElementDeg: number,
|
|
265
|
+
startPointerRad: number,
|
|
266
|
+
curPointerRad: number,
|
|
267
|
+
snap: boolean
|
|
268
|
+
): number {
|
|
269
|
+
let deg = startElementDeg + (curPointerRad - startPointerRad) * (180 / Math.PI);
|
|
270
|
+
while (deg > 180) deg -= 360;
|
|
271
|
+
while (deg <= -180) deg += 360;
|
|
272
|
+
if (snap) deg = Math.round(deg / 15) * 15;
|
|
273
|
+
return Math.round(deg * 100) / 100;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
277
|
+
// Overlay component — mounted once in CanvasShell alongside SelectionHalos.
|
|
278
|
+
|
|
279
|
+
type RotCorner = 'rot-nw' | 'rot-ne' | 'rot-sw' | 'rot-se';
|
|
280
|
+
|
|
281
|
+
interface ElResizeDrag {
|
|
282
|
+
pointerId: number;
|
|
283
|
+
corner: ElResizeCorner | RotCorner;
|
|
284
|
+
el: HTMLElement;
|
|
285
|
+
cdId: string;
|
|
286
|
+
/** GLOBAL DOM-occurrence index of `el` among same-cd-id nodes (Stage H3). For a
|
|
287
|
+
* reused component the server maps it to the dragged instance's `<Component/>`
|
|
288
|
+
* usage so resizing one instance stays local; 0 for a normal element. */
|
|
289
|
+
idIndex: number;
|
|
290
|
+
/** Stage D4 — set when this drag targets a whole ARTBOARD frame (`[data-dc-
|
|
291
|
+
* screen]`, no data-cd-id) instead of a canvas element. Commits via
|
|
292
|
+
* `resize-artboard-request` (numeric width/height PROPS, DDR-027) instead of
|
|
293
|
+
* `resize-request`. Null for an ordinary element drag. */
|
|
294
|
+
artboardId: string | null;
|
|
295
|
+
startClientX: number;
|
|
296
|
+
startClientY: number;
|
|
297
|
+
elZoom: number; // rect.width / offsetWidth — the element's own render scale
|
|
298
|
+
/** Element rotation (deg) at drag start — resize deltas are un-rotated by it,
|
|
299
|
+
* and it's the base angle a rotate drag turns FROM. */
|
|
300
|
+
angle: number;
|
|
301
|
+
/** Screen center of the element — the pivot for a rotate drag. */
|
|
302
|
+
cx: number;
|
|
303
|
+
cy: number;
|
|
304
|
+
/** center→pointer angle (rad) at rotate-drag start (relative rotation base). */
|
|
305
|
+
rotStartPointer: number;
|
|
306
|
+
start: ElResizeStart;
|
|
307
|
+
flags: ElResizeFlags;
|
|
308
|
+
/** Inline style values BEFORE the drag — the undo `before` + failure restore. */
|
|
309
|
+
before: {
|
|
310
|
+
width: string | null;
|
|
311
|
+
height: string | null;
|
|
312
|
+
left: string | null;
|
|
313
|
+
top: string | null;
|
|
314
|
+
transform: string | null;
|
|
315
|
+
};
|
|
316
|
+
lastResult: ElResizeResult | null;
|
|
317
|
+
/** Last previewed `transform` (rotate drag) — the commit value. */
|
|
318
|
+
lastTransform: string | null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Post the committed resize to the main-origin shell. Split out so the message
|
|
322
|
+
* shape stays in one place. `null` before = the prop was unset (reset on undo). */
|
|
323
|
+
function postResizeRequest(drag: ElResizeDrag, r: ElResizeResult): void {
|
|
324
|
+
const patch: Record<string, string> = {
|
|
325
|
+
width: `${r.width}px`,
|
|
326
|
+
height: `${r.height}px`,
|
|
327
|
+
};
|
|
328
|
+
if (typeof r.left === 'number') patch.left = `${r.left}px`;
|
|
329
|
+
if (typeof r.top === 'number') patch.top = `${r.top}px`;
|
|
330
|
+
try {
|
|
331
|
+
window.parent.postMessage(
|
|
332
|
+
{ dgn: 'resize-request', id: drag.cdId, patch, before: drag.before, idIndex: drag.idIndex },
|
|
333
|
+
'*'
|
|
334
|
+
);
|
|
335
|
+
} catch {
|
|
336
|
+
/* detached / cross-origin teardown */
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Stage D4 — post a committed ARTBOARD resize. `width`/`height` are plain
|
|
341
|
+
* NUMBERS (world px, not px strings) — the shell writes them as numeric JSX
|
|
342
|
+
* attrs via `/_api/resize-artboard` (DDR-027: artboard size is JSX-authoritative,
|
|
343
|
+
* not inline style). Never carries left/top — see the `artboardId` doc above. */
|
|
344
|
+
function postResizeArtboardRequest(drag: ElResizeDrag, r: ElResizeResult): void {
|
|
345
|
+
if (!drag.artboardId) return;
|
|
346
|
+
try {
|
|
347
|
+
window.parent.postMessage(
|
|
348
|
+
{
|
|
349
|
+
dgn: 'resize-artboard-request',
|
|
350
|
+
artboardId: drag.artboardId,
|
|
351
|
+
width: r.width,
|
|
352
|
+
height: r.height,
|
|
353
|
+
},
|
|
354
|
+
'*'
|
|
355
|
+
);
|
|
356
|
+
} catch {
|
|
357
|
+
/* detached / cross-origin teardown */
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Post a committed rotate (Task L8) — a `transform` patch on the same lane. */
|
|
362
|
+
function postRotateRequest(drag: ElResizeDrag, transform: string): void {
|
|
363
|
+
try {
|
|
364
|
+
window.parent.postMessage(
|
|
365
|
+
{
|
|
366
|
+
dgn: 'resize-request',
|
|
367
|
+
id: drag.cdId,
|
|
368
|
+
patch: { transform },
|
|
369
|
+
before: drag.before,
|
|
370
|
+
idIndex: drag.idIndex,
|
|
371
|
+
},
|
|
372
|
+
'*'
|
|
373
|
+
);
|
|
374
|
+
} catch {
|
|
375
|
+
/* detached / cross-origin teardown */
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function ElementResizeOverlay(): ReactNode {
|
|
380
|
+
ensureElementResizeStyles();
|
|
381
|
+
const { selected } = useSelectionSet();
|
|
382
|
+
const { tool } = useToolMode();
|
|
383
|
+
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
384
|
+
const rafRef = useRef<number | null>(null);
|
|
385
|
+
const dragRef = useRef<ElResizeDrag | null>(null);
|
|
386
|
+
// Last committed { el, before } — restored if the shell reports the write
|
|
387
|
+
// failed (dgn:'resize-failed'), so a rejected resize doesn't leave a phantom
|
|
388
|
+
// inline box that the (suppressed / absent) HMR reload never corrects.
|
|
389
|
+
const lastCommitRef = useRef<{ el: HTMLElement; before: ElResizeDrag['before'] } | null>(null);
|
|
390
|
+
|
|
391
|
+
const one = selected.length === 1 ? selected[0] : null;
|
|
392
|
+
const cdId = one && typeof one.id === 'string' ? one.id : null;
|
|
393
|
+
// Stage D4 — a whole-ARTBOARD selection (chrome click: no data-cd-id, just the
|
|
394
|
+
// `data-dc-screen` host) also gets handles, restricted to E/S/SE (growth-only —
|
|
395
|
+
// see the `artboardId` doc on ElResizeDrag for why left/top never move).
|
|
396
|
+
const artboardOnly = one && !cdId && typeof one.artboardId === 'string' ? one.artboardId : null;
|
|
397
|
+
// A real element OR artboard selection with the move tool active gets handles.
|
|
398
|
+
const active = tool === 'move' && (!!cdId || !!artboardOnly);
|
|
399
|
+
|
|
400
|
+
// rAF loop — follow the selected element's screen box (pan/zoom + layout).
|
|
401
|
+
useEffect(() => {
|
|
402
|
+
const c = containerRef.current;
|
|
403
|
+
if (!c) return;
|
|
404
|
+
// INV-2 — opacity+pointer-events, not display:none, so a fresh selection's
|
|
405
|
+
// handles/readout FADE in rather than pop (the `transition` on both
|
|
406
|
+
// classes above). Interactivity (pointer-events) flips instantly — a
|
|
407
|
+
// handle is grabbable the moment it's selected, even mid-fade.
|
|
408
|
+
const hideAll = () => {
|
|
409
|
+
for (const child of Array.from(c.children)) {
|
|
410
|
+
const h = child as HTMLElement;
|
|
411
|
+
h.style.opacity = '0';
|
|
412
|
+
h.style.pointerEvents = 'none';
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
if (!active || !one) {
|
|
416
|
+
hideAll();
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
const tick = () => {
|
|
420
|
+
rafRef.current = null;
|
|
421
|
+
// Dogfood 2026-07-07 — a live ReorderDrag is the canvas's most DOM-churn-
|
|
422
|
+
// heavy gesture (never true for OUR own resize drag — a handle grab
|
|
423
|
+
// stopPropagation()s before ReorderDrag's own pointerdown listener sees
|
|
424
|
+
// it); skip this overlay's per-frame work while one is in flight so it
|
|
425
|
+
// doesn't compound the jank ("hrozně zavaší ten toolbar").
|
|
426
|
+
if (isElementDragActive()) {
|
|
427
|
+
hideAll();
|
|
428
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const el = resolveSelectionEl(document, one) as HTMLElement | null;
|
|
432
|
+
// Skip inline text runs where width/height are meaningless (no explicit box).
|
|
433
|
+
if (!el) {
|
|
434
|
+
hideAll();
|
|
435
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
const r = el.getBoundingClientRect();
|
|
439
|
+
if (r.width <= 0 && r.height <= 0) {
|
|
440
|
+
hideAll();
|
|
441
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
// Stage D4 — an artboard gets growth-only handles (E/S/SE) and NO rotate
|
|
445
|
+
// zones (artboards don't rotate); an element gets the full 8 + 4 rotate set.
|
|
446
|
+
const resizeCorners: ElResizeCorner[] = artboardOnly ? ['e', 's', 'se'] : EL_RESIZE_CORNERS;
|
|
447
|
+
const rotateZones: RotCorner[] = artboardOnly ? [] : ['rot-nw', 'rot-ne', 'rot-sw', 'rot-se'];
|
|
448
|
+
// +1 persistent slot (LAST child) for the Task L7 readout pill — always
|
|
449
|
+
// present, hidden via opacity outside an active drag.
|
|
450
|
+
const TOTAL = resizeCorners.length + rotateZones.length + 1;
|
|
451
|
+
while (c.children.length < TOTAL) c.appendChild(document.createElement('div'));
|
|
452
|
+
while (c.children.length > TOTAL) c.lastChild && c.removeChild(c.lastChild);
|
|
453
|
+
|
|
454
|
+
// Rotation-aware placement (Task L8): the element's AABB center is its true
|
|
455
|
+
// center (default transform-origin), so handles ride the ROTATED box. Read
|
|
456
|
+
// the element's own rotation + the .dc-world zoom from computed style (DOM
|
|
457
|
+
// reads — the overlay stays camera-agnostic, no viewport controller).
|
|
458
|
+
const cx = r.left + r.width / 2;
|
|
459
|
+
const cy = r.top + r.height / 2;
|
|
460
|
+
const deg = rotationDegFromMatrix(getComputedStyle(el).transform);
|
|
461
|
+
const world = el.closest('.dc-world') as HTMLElement | null;
|
|
462
|
+
const zoom = world ? scaleFromMatrix(getComputedStyle(world).transform) : 1;
|
|
463
|
+
const hw = (el.offsetWidth * zoom) / 2;
|
|
464
|
+
const hh = (el.offsetHeight * zoom) / 2;
|
|
465
|
+
// Rotate zones sit ON each corner (same offset), 20×20, z-index below the
|
|
466
|
+
// 8×8 corner square — so the square wins the center + the ring rotates.
|
|
467
|
+
const localOffset: Record<string, [number, number]> = {
|
|
468
|
+
nw: [-hw, -hh],
|
|
469
|
+
n: [0, -hh],
|
|
470
|
+
ne: [hw, -hh],
|
|
471
|
+
e: [hw, 0],
|
|
472
|
+
se: [hw, hh],
|
|
473
|
+
s: [0, hh],
|
|
474
|
+
sw: [-hw, hh],
|
|
475
|
+
w: [-hw, 0],
|
|
476
|
+
'rot-nw': [-hw, -hh],
|
|
477
|
+
'rot-ne': [hw, -hh],
|
|
478
|
+
'rot-sw': [-hw, hh],
|
|
479
|
+
'rot-se': [hw, hh],
|
|
480
|
+
};
|
|
481
|
+
// Rotate zones FIRST (lower in the DOM / z-index 5), corners LAST so the
|
|
482
|
+
// 8×8 squares paint on top in the overlap.
|
|
483
|
+
const handles = [...rotateZones, ...resizeCorners];
|
|
484
|
+
for (let i = 0; i < handles.length; i++) {
|
|
485
|
+
const corner = handles[i];
|
|
486
|
+
const handle = c.children[i] as HTMLElement;
|
|
487
|
+
const [ox, oy] = localOffset[corner] ?? [0, 0];
|
|
488
|
+
const [rx, ry] = rotatePointDeg(ox, oy, deg);
|
|
489
|
+
const ax = cx + rx;
|
|
490
|
+
const ay = cy + ry;
|
|
491
|
+
const isRot = corner.startsWith('rot-');
|
|
492
|
+
const ns = corner === 'n' || corner === 's';
|
|
493
|
+
const ew = corner === 'e' || corner === 'w';
|
|
494
|
+
const halfW = isRot ? 10 : ns ? 7 : ew ? 3 : 4;
|
|
495
|
+
const halfH = isRot ? 10 : ns ? 3 : ew ? 7 : 4;
|
|
496
|
+
handle.className = 'dc-el-resize-handle';
|
|
497
|
+
handle.dataset.corner = corner;
|
|
498
|
+
handle.style.opacity = '1';
|
|
499
|
+
handle.style.pointerEvents = 'auto';
|
|
500
|
+
handle.style.left = `${Math.round(ax - halfW)}px`;
|
|
501
|
+
handle.style.top = `${Math.round(ay - halfH)}px`;
|
|
502
|
+
// Orient the edge pills with the element; rotate zones stay unrotated.
|
|
503
|
+
handle.style.transform = isRot ? '' : `rotate(${deg}deg)`;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Task L7 — live W×H (+X,Y when an edge drag moves the origin) readout,
|
|
507
|
+
// shown only while THIS element's resize handle is actually being
|
|
508
|
+
// dragged (dragRef is set by the pointerdown handler below). World
|
|
509
|
+
// units — offsetWidth/Height + the drag's own left/top, unaffected by
|
|
510
|
+
// the .dc-world zoom transform (same invariant Stage A's camera fix
|
|
511
|
+
// relies on), so the number reads correctly at any zoom.
|
|
512
|
+
const readout = c.children[handles.length] as HTMLElement;
|
|
513
|
+
const drag = dragRef.current;
|
|
514
|
+
if (drag && drag.el === el && !drag.corner.startsWith('rot-')) {
|
|
515
|
+
const w = drag.lastResult?.width ?? el.offsetWidth;
|
|
516
|
+
const h = drag.lastResult?.height ?? el.offsetHeight;
|
|
517
|
+
let label = `${Math.round(w)} × ${Math.round(h)}`;
|
|
518
|
+
const lx = drag.lastResult?.left;
|
|
519
|
+
const ly = drag.lastResult?.top;
|
|
520
|
+
if (typeof lx === 'number' || typeof ly === 'number') {
|
|
521
|
+
label += ` · ${Math.round(lx ?? drag.start.left)}, ${Math.round(ly ?? drag.start.top)}`;
|
|
522
|
+
}
|
|
523
|
+
readout.className = 'dc-el-resize-readout';
|
|
524
|
+
readout.textContent = label;
|
|
525
|
+
readout.style.opacity = '1';
|
|
526
|
+
readout.style.left = `${Math.round(cx)}px`;
|
|
527
|
+
readout.style.top = `${Math.round(cy + hh)}px`;
|
|
528
|
+
} else {
|
|
529
|
+
readout.style.opacity = '0';
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
533
|
+
};
|
|
534
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
535
|
+
return () => {
|
|
536
|
+
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
|
537
|
+
};
|
|
538
|
+
}, [active, one, artboardOnly]);
|
|
539
|
+
|
|
540
|
+
// Live-apply a resize result to the element's inline style (instant preview,
|
|
541
|
+
// no source write). Commit on release.
|
|
542
|
+
const applyPreview = useCallback((d: ElResizeDrag, r: ElResizeResult) => {
|
|
543
|
+
d.el.style.width = `${r.width}px`;
|
|
544
|
+
d.el.style.height = `${r.height}px`;
|
|
545
|
+
if (typeof r.left === 'number') d.el.style.left = `${r.left}px`;
|
|
546
|
+
if (typeof r.top === 'number') d.el.style.top = `${r.top}px`;
|
|
547
|
+
d.lastResult = r;
|
|
548
|
+
}, []);
|
|
549
|
+
|
|
550
|
+
// Pointer handling — pointerdown on a handle starts the drag.
|
|
551
|
+
useEffect(() => {
|
|
552
|
+
const c = containerRef.current;
|
|
553
|
+
if (!c) return;
|
|
554
|
+
|
|
555
|
+
const computeFromEvent = (d: ElResizeDrag, ev: PointerEvent): ElResizeResult => {
|
|
556
|
+
// Un-rotate the screen delta into the element's LOCAL frame (so dragging a
|
|
557
|
+
// handle resizes along the element's own axes on a rotated element), then
|
|
558
|
+
// ÷ render zoom → world units.
|
|
559
|
+
const [ldx, ldy] = rotatePointDeg(
|
|
560
|
+
ev.clientX - d.startClientX,
|
|
561
|
+
ev.clientY - d.startClientY,
|
|
562
|
+
-d.angle
|
|
563
|
+
);
|
|
564
|
+
return computeElementResize(
|
|
565
|
+
d.corner as ElResizeCorner,
|
|
566
|
+
d.start,
|
|
567
|
+
ldx / d.elZoom,
|
|
568
|
+
ldy / d.elZoom,
|
|
569
|
+
// Stage D4 — Alt/center is ignored for an artboard: without a left/top
|
|
570
|
+
// write, "center" would just double the growth speed with no visual
|
|
571
|
+
// recentering (confusing). Aspect-lock (Shift) still applies to both.
|
|
572
|
+
{ aspect: !!ev.shiftKey, center: !d.artboardId && !!ev.altKey },
|
|
573
|
+
d.flags
|
|
574
|
+
);
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
const onDown = (e: PointerEvent) => {
|
|
578
|
+
const t = e.target as HTMLElement | null;
|
|
579
|
+
if (!t?.classList.contains('dc-el-resize-handle')) return;
|
|
580
|
+
const corner = t.dataset.corner as ElResizeCorner | RotCorner | undefined;
|
|
581
|
+
if (!corner || !one) return;
|
|
582
|
+
const elCdId = typeof one.id === 'string' ? one.id : null;
|
|
583
|
+
const elArtboardId = !elCdId && typeof one.artboardId === 'string' ? one.artboardId : null;
|
|
584
|
+
if (!elCdId && !elArtboardId) return;
|
|
585
|
+
const el = resolveSelectionEl(document, one) as HTMLElement | null;
|
|
586
|
+
if (!el) return;
|
|
587
|
+
e.preventDefault();
|
|
588
|
+
e.stopPropagation();
|
|
589
|
+
const rect = el.getBoundingClientRect();
|
|
590
|
+
const elZoom = el.offsetWidth ? rect.width / el.offsetWidth : 1;
|
|
591
|
+
const cs = getComputedStyle(el);
|
|
592
|
+
// Stage D4 — an artboard NEVER derives left/top from a resize drag (the
|
|
593
|
+
// route only writes width/height PROPS; layout.artboards[] x/y stays
|
|
594
|
+
// untouched — see the plan's Task D4 gotcha). Forcing outOfFlow=false here
|
|
595
|
+
// (even though an artboard IS position:absolute in the DOM) is what makes
|
|
596
|
+
// `flags.canMoveLeft/Top` false below; the growth-only E/S/SE handle set
|
|
597
|
+
// rendered above is the visual half of the same guarantee.
|
|
598
|
+
const outOfFlow = !elArtboardId && (cs.position === 'absolute' || cs.position === 'fixed');
|
|
599
|
+
const startLeft = elArtboardId ? Number.NaN : Number.parseFloat(el.style.left);
|
|
600
|
+
const startTop = elArtboardId ? Number.NaN : Number.parseFloat(el.style.top);
|
|
601
|
+
const angle = elArtboardId ? 0 : rotationDegFromMatrix(cs.transform);
|
|
602
|
+
const z = elZoom > 0 ? elZoom : 1;
|
|
603
|
+
const cx = rect.left + rect.width / 2;
|
|
604
|
+
const cy = rect.top + rect.height / 2;
|
|
605
|
+
const drag: ElResizeDrag = {
|
|
606
|
+
pointerId: e.pointerId,
|
|
607
|
+
corner,
|
|
608
|
+
el,
|
|
609
|
+
cdId: elCdId ?? '',
|
|
610
|
+
artboardId: elArtboardId,
|
|
611
|
+
// Stage H3 — which instance (global same-cd-id DOM occurrence) so a
|
|
612
|
+
// reused-component resize routes to its own `<Component/>` usage (local).
|
|
613
|
+
// N/A (0) for an artboard drag.
|
|
614
|
+
idIndex: elCdId ? globalCdOccurrence(document, elCdId, el) : 0,
|
|
615
|
+
startClientX: e.clientX,
|
|
616
|
+
startClientY: e.clientY,
|
|
617
|
+
elZoom: z,
|
|
618
|
+
angle,
|
|
619
|
+
cx,
|
|
620
|
+
cy,
|
|
621
|
+
rotStartPointer: pointerAngleRad(cx, cy, e.clientX, e.clientY),
|
|
622
|
+
start: {
|
|
623
|
+
// World-px border box. rect is the AABB; for a rotated element that
|
|
624
|
+
// over-states w/h, so derive from offset dims when rotated.
|
|
625
|
+
w: (angle ? el.offsetWidth : rect.width / z) || rect.width / z,
|
|
626
|
+
h: (angle ? el.offsetHeight : rect.height / z) || rect.height / z,
|
|
627
|
+
left: startLeft,
|
|
628
|
+
top: startTop,
|
|
629
|
+
},
|
|
630
|
+
flags: {
|
|
631
|
+
canMoveLeft: outOfFlow && Number.isFinite(startLeft),
|
|
632
|
+
canMoveTop: outOfFlow && Number.isFinite(startTop),
|
|
633
|
+
},
|
|
634
|
+
before: {
|
|
635
|
+
width: el.style.width || null,
|
|
636
|
+
height: el.style.height || null,
|
|
637
|
+
left: el.style.left || null,
|
|
638
|
+
top: el.style.top || null,
|
|
639
|
+
transform: el.style.transform || null,
|
|
640
|
+
},
|
|
641
|
+
lastResult: null,
|
|
642
|
+
lastTransform: null,
|
|
643
|
+
};
|
|
644
|
+
dragRef.current = drag;
|
|
645
|
+
try {
|
|
646
|
+
t.setPointerCapture(e.pointerId);
|
|
647
|
+
} catch {
|
|
648
|
+
/* synthetic events may reject capture */
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
const onMove = (e: PointerEvent) => {
|
|
653
|
+
const d = dragRef.current;
|
|
654
|
+
if (!d || e.pointerId !== d.pointerId) return;
|
|
655
|
+
e.preventDefault();
|
|
656
|
+
if (d.corner.startsWith('rot-')) {
|
|
657
|
+
// Rotate RELATIVE to the grabbed corner: turn by the change in the
|
|
658
|
+
// center→pointer angle from where the drag started. Shift snaps to 15°.
|
|
659
|
+
const deg = rotateDeltaDeg(
|
|
660
|
+
d.angle,
|
|
661
|
+
d.rotStartPointer,
|
|
662
|
+
pointerAngleRad(d.cx, d.cy, e.clientX, e.clientY),
|
|
663
|
+
!!e.shiftKey
|
|
664
|
+
);
|
|
665
|
+
const tf = `rotate(${deg}deg)`;
|
|
666
|
+
d.el.style.transform = tf;
|
|
667
|
+
d.lastTransform = tf;
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
applyPreview(d, computeFromEvent(d, e));
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
const onUp = (e: PointerEvent) => {
|
|
674
|
+
const d = dragRef.current;
|
|
675
|
+
if (!d || e.pointerId !== d.pointerId) return;
|
|
676
|
+
dragRef.current = null;
|
|
677
|
+
if (d.corner.startsWith('rot-')) {
|
|
678
|
+
const tf = d.lastTransform;
|
|
679
|
+
if (!tf || tf === d.before.transform) return; // no-op
|
|
680
|
+
lastCommitRef.current = { el: d.el, before: d.before };
|
|
681
|
+
postRotateRequest(d, tf);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
const r = d.lastResult ?? computeFromEvent(d, e);
|
|
685
|
+
// No-op guard: nothing changed → don't churn a source write / undo entry.
|
|
686
|
+
const changed =
|
|
687
|
+
`${r.width}px` !== d.before.width ||
|
|
688
|
+
`${r.height}px` !== d.before.height ||
|
|
689
|
+
(typeof r.left === 'number' && `${r.left}px` !== d.before.left) ||
|
|
690
|
+
(typeof r.top === 'number' && `${r.top}px` !== d.before.top);
|
|
691
|
+
if (!changed) return;
|
|
692
|
+
lastCommitRef.current = { el: d.el, before: d.before };
|
|
693
|
+
if (d.artboardId) postResizeArtboardRequest(d, r);
|
|
694
|
+
else postResizeRequest(d, r);
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
// Restore the pre-drag inline box if the shell reports the write failed.
|
|
698
|
+
// `resize-artboard-failed` (Stage D4) shares the same restore shape — an
|
|
699
|
+
// artboard drag's `before` never carries left/top (canMoveLeft/Top forced
|
|
700
|
+
// false), so the width/height-only branch below is already correct for it.
|
|
701
|
+
const onFail = (e: MessageEvent) => {
|
|
702
|
+
const m = e.data as { dgn?: string } | null;
|
|
703
|
+
if (m?.dgn !== 'resize-failed' && m?.dgn !== 'resize-artboard-failed') return;
|
|
704
|
+
if (e.source !== window.parent) return; // only the parent shell (DDR-054)
|
|
705
|
+
const last = lastCommitRef.current;
|
|
706
|
+
if (!last) return;
|
|
707
|
+
last.el.style.width = last.before.width ?? '';
|
|
708
|
+
last.el.style.height = last.before.height ?? '';
|
|
709
|
+
if (last.before.left !== null || last.el.style.left)
|
|
710
|
+
last.el.style.left = last.before.left ?? '';
|
|
711
|
+
if (last.before.top !== null || last.el.style.top) last.el.style.top = last.before.top ?? '';
|
|
712
|
+
if (last.before.transform !== null || last.el.style.transform)
|
|
713
|
+
last.el.style.transform = last.before.transform ?? '';
|
|
714
|
+
lastCommitRef.current = null;
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
c.addEventListener('pointerdown', onDown);
|
|
718
|
+
window.addEventListener('pointermove', onMove);
|
|
719
|
+
window.addEventListener('pointerup', onUp);
|
|
720
|
+
window.addEventListener('pointercancel', onUp);
|
|
721
|
+
window.addEventListener('message', onFail);
|
|
722
|
+
return () => {
|
|
723
|
+
c.removeEventListener('pointerdown', onDown);
|
|
724
|
+
window.removeEventListener('pointermove', onMove);
|
|
725
|
+
window.removeEventListener('pointerup', onUp);
|
|
726
|
+
window.removeEventListener('pointercancel', onUp);
|
|
727
|
+
window.removeEventListener('message', onFail);
|
|
728
|
+
};
|
|
729
|
+
}, [one, applyPreview]);
|
|
730
|
+
|
|
731
|
+
return <div ref={containerRef} aria-hidden="true" />;
|
|
732
|
+
}
|