@1agh/maude 0.39.1 → 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.
Files changed (137) hide show
  1. package/README.md +1 -1
  2. package/apps/studio/ai-banner.tsx +2 -2
  3. package/apps/studio/annotations-context-toolbar.tsx +44 -1
  4. package/apps/studio/annotations-layer.tsx +306 -5
  5. package/apps/studio/annotations-model.ts +107 -6
  6. package/apps/studio/api.ts +1316 -67
  7. package/apps/studio/artboard-marquee.tsx +1 -1
  8. package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
  9. package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
  10. package/apps/studio/bin/_fetch-asset.mjs +556 -0
  11. package/apps/studio/bin/_html-playwright.mjs +9 -1
  12. package/apps/studio/bin/_pdf-playwright.mjs +8 -1
  13. package/apps/studio/bin/_png-playwright.mjs +8 -1
  14. package/apps/studio/bin/_pw-launch.mjs +54 -0
  15. package/apps/studio/bin/_svg-playwright.mjs +8 -1
  16. package/apps/studio/bin/_video-playwright.mjs +452 -0
  17. package/apps/studio/bin/annotate.mjs +576 -34
  18. package/apps/studio/bin/canvas-rects.sh +152 -0
  19. package/apps/studio/bin/fetch-asset.sh +34 -0
  20. package/apps/studio/bin/prep.sh +8 -1
  21. package/apps/studio/bin/read-annotations.mjs +138 -7
  22. package/apps/studio/bin/smoke.sh +42 -6
  23. package/apps/studio/build.ts +21 -0
  24. package/apps/studio/canvas-comment-mount.tsx +138 -4
  25. package/apps/studio/canvas-edit.ts +2625 -111
  26. package/apps/studio/canvas-lib.tsx +238 -2
  27. package/apps/studio/canvas-list-watch.ts +6 -2
  28. package/apps/studio/canvas-shell.tsx +514 -20
  29. package/apps/studio/client/app.jsx +2480 -52
  30. package/apps/studio/client/comments-overlay.css +130 -126
  31. package/apps/studio/client/github.js +15 -0
  32. package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
  33. package/apps/studio/client/panels/timeline-parse.js +229 -0
  34. package/apps/studio/client/panels/timeline-snap.js +55 -0
  35. package/apps/studio/client/styles/3-shell-maude.css +155 -0
  36. package/apps/studio/comments-overlay.tsx +148 -41
  37. package/apps/studio/config.schema.json +14 -0
  38. package/apps/studio/context-menu.tsx +16 -6
  39. package/apps/studio/context.ts +113 -1
  40. package/apps/studio/contextual-toolbar.tsx +262 -4
  41. package/apps/studio/cursors-overlay.tsx +4 -4
  42. package/apps/studio/dist/client.bundle.js +89 -17
  43. package/apps/studio/dist/comment-mount.js +59 -1
  44. package/apps/studio/dist/runtime/.min-sizes.json +11 -1
  45. package/apps/studio/dist/runtime/@remotion_media.js +491 -0
  46. package/apps/studio/dist/runtime/@remotion_player.js +56 -0
  47. package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
  48. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
  49. package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
  50. package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
  51. package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
  52. package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
  53. package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
  54. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
  55. package/apps/studio/dist/runtime/remotion.js +42 -0
  56. package/apps/studio/dist/styles.css +1 -1
  57. package/apps/studio/dom-selection.ts +127 -1
  58. package/apps/studio/drag-state.ts +24 -0
  59. package/apps/studio/equal-spacing-detector.ts +205 -0
  60. package/apps/studio/export-dialog.tsx +1 -1
  61. package/apps/studio/exporters/_browser-bundles.ts +117 -0
  62. package/apps/studio/exporters/_runtime.ts +69 -0
  63. package/apps/studio/exporters/html.ts +4 -3
  64. package/apps/studio/exporters/index.ts +28 -2
  65. package/apps/studio/exporters/pdf.ts +4 -3
  66. package/apps/studio/exporters/png.ts +5 -3
  67. package/apps/studio/exporters/pptx.ts +6 -4
  68. package/apps/studio/exporters/svg.ts +9 -5
  69. package/apps/studio/exporters/video-encode-lib.ts +200 -0
  70. package/apps/studio/exporters/video-render-lib.ts +108 -0
  71. package/apps/studio/exporters/video.ts +184 -0
  72. package/apps/studio/history.ts +47 -1
  73. package/apps/studio/http.ts +758 -27
  74. package/apps/studio/input-router.tsx +19 -1
  75. package/apps/studio/marquee-overlay.tsx +1 -1
  76. package/apps/studio/measure-overlay.tsx +241 -0
  77. package/apps/studio/participants-chrome.tsx +3 -3
  78. package/apps/studio/runtime-bundle.ts +30 -0
  79. package/apps/studio/server.ts +34 -9
  80. package/apps/studio/sizing-mode.ts +117 -0
  81. package/apps/studio/spacing-handles.ts +166 -0
  82. package/apps/studio/test/annotate-write.test.ts +890 -0
  83. package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
  84. package/apps/studio/test/camera-reveal.test.tsx +173 -0
  85. package/apps/studio/test/canvas-create-api.test.ts +76 -0
  86. package/apps/studio/test/canvas-edit.test.ts +140 -0
  87. package/apps/studio/test/canvas-list-watch.test.ts +49 -0
  88. package/apps/studio/test/canvas-media-drop.test.ts +30 -1
  89. package/apps/studio/test/canvas-origin-gate.test.ts +54 -0
  90. package/apps/studio/test/canvas-rects.test.ts +198 -0
  91. package/apps/studio/test/clip-addressing.test.ts +732 -0
  92. package/apps/studio/test/comments-overlay.test.ts +117 -0
  93. package/apps/studio/test/config-reload.test.ts +230 -0
  94. package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
  95. package/apps/studio/test/dom-selection.test.ts +130 -0
  96. package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
  97. package/apps/studio/test/edit-persistence.test.ts +91 -0
  98. package/apps/studio/test/edit-scope-api.test.ts +115 -0
  99. package/apps/studio/test/element-resize.test.ts +136 -0
  100. package/apps/studio/test/element-structural-api.test.ts +360 -0
  101. package/apps/studio/test/element-structural-edit.test.ts +233 -0
  102. package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
  103. package/apps/studio/test/exporters/runtime.test.ts +59 -0
  104. package/apps/studio/test/file-lock.test.ts +84 -0
  105. package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
  106. package/apps/studio/test/history-rollback.test.ts +26 -0
  107. package/apps/studio/test/knob-props-authored.test.ts +87 -0
  108. package/apps/studio/test/read-annotations.test.ts +154 -0
  109. package/apps/studio/test/sizing-mode.test.ts +102 -0
  110. package/apps/studio/test/spacing-handles.test.ts +138 -0
  111. package/apps/studio/test/specimen-select.test.ts +88 -0
  112. package/apps/studio/test/timeline-parse.test.ts +127 -0
  113. package/apps/studio/test/timeline-snap.test.ts +85 -0
  114. package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
  115. package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
  116. package/apps/studio/test/video-asset.test.ts +163 -0
  117. package/apps/studio/test/video-comp-fixture.test.ts +50 -0
  118. package/apps/studio/test/video-comp.test.ts +168 -0
  119. package/apps/studio/test/video-render-bridge.test.ts +149 -0
  120. package/apps/studio/tool-palette.tsx +122 -2
  121. package/apps/studio/undo-hud.tsx +2 -2
  122. package/apps/studio/use-annotation-resize.tsx +6 -3
  123. package/apps/studio/use-canvas-media-drop.tsx +66 -4
  124. package/apps/studio/use-element-resize.tsx +732 -0
  125. package/apps/studio/use-keyboard-discipline.tsx +205 -15
  126. package/apps/studio/use-selection-set.tsx +14 -0
  127. package/apps/studio/use-spacing-handles.tsx +388 -0
  128. package/apps/studio/video-comp.tsx +444 -0
  129. package/apps/studio/whats-new.json +55 -0
  130. package/apps/studio/ws.ts +5 -0
  131. package/cli/commands/design.mjs +6 -1
  132. package/cli/commands/design.test.mjs +49 -1
  133. package/cli/lib/fetch-asset.test.mjs +213 -0
  134. package/package.json +8 -8
  135. package/plugins/design/dependencies.json +10 -2
  136. package/plugins/design/templates/_shell.html +25 -2
  137. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +1 -1
@@ -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
+ }