@1agh/maude 0.40.0 → 0.42.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 -44
- 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/_html-playwright.mjs +26 -4
- package/apps/studio/bin/_pdf-playwright.mjs +13 -2
- package/apps/studio/bin/_png-playwright.mjs +15 -2
- package/apps/studio/bin/_pptx-playwright.mjs +17 -4
- package/apps/studio/bin/_screenshot-playwright.mjs +17 -2
- package/apps/studio/bin/_svg-playwright.mjs +26 -4
- 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/screenshot.sh +53 -4
- 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 +1476 -76
- package/apps/studio/client/comments-overlay.css +130 -126
- package/apps/studio/client/export-center.jsx +426 -0
- package/apps/studio/client/github.js +8 -0
- package/apps/studio/client/styles/3-shell-maude.css +65 -0
- package/apps/studio/client/styles/4-components.css +150 -0
- package/apps/studio/comments-overlay.tsx +148 -41
- package/apps/studio/config.schema.json +2 -2
- 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 +19 -26
- package/apps/studio/exporters/_runtime.ts +104 -0
- package/apps/studio/exporters/html.ts +12 -20
- package/apps/studio/exporters/index.ts +14 -2
- package/apps/studio/exporters/jobs.ts +334 -0
- package/apps/studio/exporters/pdf.ts +16 -20
- package/apps/studio/exporters/png.ts +12 -20
- package/apps/studio/exporters/pptx.ts +22 -23
- package/apps/studio/exporters/scope.ts +1 -0
- package/apps/studio/exporters/svg.ts +14 -22
- package/apps/studio/exporters/video.ts +15 -17
- package/apps/studio/git/service.ts +3 -1
- package/apps/studio/history.ts +47 -1
- package/apps/studio/http.ts +368 -50
- 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/server.ts +3 -1
- 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 +24 -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/dns-rebinding-guard.test.ts +27 -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/export-center.test.tsx +287 -0
- package/apps/studio/test/export-shim-multi-capture.test.ts +83 -0
- package/apps/studio/test/exporters/history.test.ts +32 -3
- package/apps/studio/test/exporters/jobs.test.ts +263 -0
- 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 +45 -0
- package/apps/studio/ws.ts +6 -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/cli/lib/gitignore-block.mjs +1 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +10 -2
|
@@ -6,19 +6,16 @@
|
|
|
6
6
|
* into the dev-server's own inputs (file tree filter, etc.)
|
|
7
7
|
* never collides.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* for this wave — the artboard channel + dragBus.commitPositions
|
|
16
|
-
* already exists, so we leverage that and document the
|
|
17
|
-
* limitation here.
|
|
9
|
+
* Arrow nudge applies to artboards AND to a single OUT-OF-FLOW
|
|
10
|
+
* element (position:absolute/fixed) — the latter added in Task L1
|
|
11
|
+
* once the `reposition-request` channel existed (the original
|
|
12
|
+
* "no live-position channel" limitation is resolved for out-of-flow
|
|
13
|
+
* elements: preview inline, commit the settle via reposition-request).
|
|
14
|
+
* In-flow elements stay a no-op (nudge is ambiguous with no left/top).
|
|
18
15
|
*
|
|
19
|
-
* Cmd+D duplicate
|
|
20
|
-
*
|
|
21
|
-
* onEscape callback, so we don't duplicate it
|
|
16
|
+
* Cmd+D duplicate (Task L3) + Cmd+Opt+C/V copy/paste-style (Task L4)
|
|
17
|
+
* are handled here too, relayed to the parent shell. Esc is owned by
|
|
18
|
+
* the input-router's onEscape callback, so we don't duplicate it.
|
|
22
19
|
*
|
|
23
20
|
* Distance:
|
|
24
21
|
* • Arrow → 1 world-unit step (DDR-028 world units, not
|
|
@@ -26,10 +23,10 @@
|
|
|
26
23
|
* • Shift+Arrow → 10 world-unit step.
|
|
27
24
|
*/
|
|
28
25
|
|
|
29
|
-
import { useEffect } from 'react';
|
|
26
|
+
import { useEffect, useRef } from 'react';
|
|
30
27
|
|
|
31
28
|
import { useArtboardsContext, useDragStateContext } from './canvas-lib.tsx';
|
|
32
|
-
import { scopedCdSelector, selectorIndex } from './dom-selection.ts';
|
|
29
|
+
import { resolveSelectionEl, scopedCdSelector, selectorIndex } from './dom-selection.ts';
|
|
33
30
|
import { isEditableTarget } from './input-router.tsx';
|
|
34
31
|
import { type Selection, useSelectionSet } from './use-selection-set.tsx';
|
|
35
32
|
|
|
@@ -65,6 +62,16 @@ export function useKeyboardDiscipline(): void {
|
|
|
65
62
|
const selSet = useSelectionSet();
|
|
66
63
|
const artboardsCtx = useArtboardsContext();
|
|
67
64
|
const dragBus = useDragStateContext();
|
|
65
|
+
// Element arrow-nudge burst (Task L1): accumulate left/top, preview inline, and
|
|
66
|
+
// commit once after a short pause (one undo per settle, not per keypress).
|
|
67
|
+
const nudgeRef = useRef<{
|
|
68
|
+
id: string;
|
|
69
|
+
beforeLeft: number;
|
|
70
|
+
beforeTop: number;
|
|
71
|
+
left: number;
|
|
72
|
+
top: number;
|
|
73
|
+
timer: ReturnType<typeof setTimeout>;
|
|
74
|
+
} | null>(null);
|
|
68
75
|
|
|
69
76
|
useEffect(() => {
|
|
70
77
|
if (typeof document === 'undefined') return;
|
|
@@ -101,10 +108,193 @@ export function useKeyboardDiscipline(): void {
|
|
|
101
108
|
return;
|
|
102
109
|
}
|
|
103
110
|
|
|
104
|
-
//
|
|
111
|
+
// Cmd/Ctrl+D → duplicate the single selected element (Task L3). Main-origin
|
|
112
|
+
// write, so post to the parent shell (pinned to the active canvas + undo).
|
|
113
|
+
// preventDefault so the browser doesn't bookmark the page.
|
|
114
|
+
if (isMeta && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'd') {
|
|
115
|
+
const one = selSet.selected.length === 1 ? selSet.selected[0] : null;
|
|
116
|
+
if (one?.id) {
|
|
117
|
+
e.preventDefault();
|
|
118
|
+
try {
|
|
119
|
+
window.parent.postMessage(
|
|
120
|
+
{ dgn: 'duplicate-request', id: one.id, idIndex: one.index },
|
|
121
|
+
'*'
|
|
122
|
+
);
|
|
123
|
+
} catch {
|
|
124
|
+
/* detached / cross-origin */
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Cmd/Ctrl+Opt+C / +V → copy-style / paste-style (Task L4). Copy captures the
|
|
131
|
+
// selection's authored appearance (shell-side); paste applies it to the
|
|
132
|
+
// currently selected element. The shell holds the clipboard + does the write.
|
|
133
|
+
if (isMeta && e.altKey && (e.key.toLowerCase() === 'c' || e.key.toLowerCase() === 'v')) {
|
|
134
|
+
const one = selSet.selected.length === 1 ? selSet.selected[0] : null;
|
|
135
|
+
if (one?.id) {
|
|
136
|
+
e.preventDefault();
|
|
137
|
+
try {
|
|
138
|
+
window.parent.postMessage(
|
|
139
|
+
e.key.toLowerCase() === 'c'
|
|
140
|
+
? { dgn: 'copy-style' }
|
|
141
|
+
: { dgn: 'paste-style', id: one.id },
|
|
142
|
+
'*'
|
|
143
|
+
);
|
|
144
|
+
} catch {
|
|
145
|
+
/* detached / cross-origin */
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Keyboard tree traversal (Task L2) — Enter = first child, Shift+Enter =
|
|
152
|
+
// parent, Tab / Shift+Tab = next / previous sibling. Mouse-free selection of
|
|
153
|
+
// the deeply-nested / overlapping elements the plan's predictability goal is
|
|
154
|
+
// about. Scoped to a single element selection + no meta modifier.
|
|
155
|
+
if (!isMeta && !e.altKey && (e.key === 'Enter' || e.key === 'Tab')) {
|
|
156
|
+
const one = selSet.selected.length === 1 ? selSet.selected[0] : null;
|
|
157
|
+
if (!one?.id) return;
|
|
158
|
+
const el = resolveSelectionEl(document, one) as HTMLElement | null;
|
|
159
|
+
if (!el) return;
|
|
160
|
+
let next: Element | null = null;
|
|
161
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
162
|
+
next = el.querySelector('[data-cd-id]'); // first descendant
|
|
163
|
+
} else if (e.key === 'Enter' && e.shiftKey) {
|
|
164
|
+
next = el.parentElement?.closest('[data-cd-id]') ?? null; // parent
|
|
165
|
+
} else if (e.key === 'Tab') {
|
|
166
|
+
// Next / previous stamped sibling in document order.
|
|
167
|
+
const forward = !e.shiftKey;
|
|
168
|
+
let s: Element | null = forward ? el.nextElementSibling : el.previousElementSibling;
|
|
169
|
+
while (s) {
|
|
170
|
+
if (s.hasAttribute('data-cd-id')) {
|
|
171
|
+
next = s;
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
const inner = s.querySelector('[data-cd-id]');
|
|
175
|
+
if (inner) {
|
|
176
|
+
next = inner;
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
s = forward ? s.nextElementSibling : s.previousElementSibling;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Don't leave the artboard body (a parent hop past the artboard root).
|
|
183
|
+
if (next?.closest('.dc-artboard-body') && next.getAttribute('data-cd-id')) {
|
|
184
|
+
e.preventDefault();
|
|
185
|
+
const cdId = next.getAttribute('data-cd-id') as string;
|
|
186
|
+
const artboardId =
|
|
187
|
+
next.closest('[data-dc-screen]')?.getAttribute('data-dc-screen') ?? null;
|
|
188
|
+
const sel = scopedCdSelector(cdId, artboardId);
|
|
189
|
+
selSet.replace([
|
|
190
|
+
{
|
|
191
|
+
id: cdId,
|
|
192
|
+
selector: sel,
|
|
193
|
+
artboardId,
|
|
194
|
+
index: selectorIndex(document, sel, next),
|
|
195
|
+
tag: next.tagName.toLowerCase(),
|
|
196
|
+
},
|
|
197
|
+
]);
|
|
198
|
+
} else if (e.key === 'Tab') {
|
|
199
|
+
// Swallow Tab even with no target, so focus doesn't jump out of the canvas.
|
|
200
|
+
e.preventDefault();
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Delete / Backspace → request a source delete of the single selected
|
|
206
|
+
// element (feature-element-editing-robustness Stage I). The write is
|
|
207
|
+
// main-origin-only (DDR-054), so we POST the request to the parent shell,
|
|
208
|
+
// which performs it (pinned to the active canvas) + records undo. Guarded
|
|
209
|
+
// against text-editing focus above; skip modifier combos + non-single /
|
|
210
|
+
// artboard-only selections (whole-artboard delete is out of scope here).
|
|
211
|
+
if ((e.key === 'Delete' || e.key === 'Backspace') && !isMeta && !e.altKey) {
|
|
212
|
+
const one = selSet.selected.length === 1 ? selSet.selected[0] : null;
|
|
213
|
+
if (one?.id) {
|
|
214
|
+
e.preventDefault();
|
|
215
|
+
try {
|
|
216
|
+
// `index` is the DOM occurrence among same-cd-id nodes, which equals
|
|
217
|
+
// the reused-component usage index — so a shared-instance delete
|
|
218
|
+
// targets the right <Component/> usage (resolveUsageId, canvas-edit).
|
|
219
|
+
window.parent.postMessage(
|
|
220
|
+
{ dgn: 'delete-request', id: one.id, idIndex: one.index },
|
|
221
|
+
'*'
|
|
222
|
+
);
|
|
223
|
+
} catch {
|
|
224
|
+
/* detached / cross-origin */
|
|
225
|
+
}
|
|
226
|
+
} else if (one?.artboardId && !one.id) {
|
|
227
|
+
// Whole-artboard delete — addressed by its `id` prop (no cd-id on the
|
|
228
|
+
// rendered <article>). Shell performs it main-origin-only + records undo.
|
|
229
|
+
e.preventDefault();
|
|
230
|
+
try {
|
|
231
|
+
window.parent.postMessage(
|
|
232
|
+
{ dgn: 'delete-artboard-request', artboardId: one.artboardId },
|
|
233
|
+
'*'
|
|
234
|
+
);
|
|
235
|
+
} catch {
|
|
236
|
+
/* detached / cross-origin */
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
105
242
|
const delta = nudgeDelta({ key: e.key, shift: e.shiftKey });
|
|
106
243
|
if (!delta) return;
|
|
107
244
|
if (isMeta || e.altKey) return; // modifier combos reserved for future
|
|
245
|
+
|
|
246
|
+
// Arrow nudge — a single OUT-OF-FLOW element (position:absolute/fixed).
|
|
247
|
+
// In-flow elements are a no-op (nudging a flow element is ambiguous — it has
|
|
248
|
+
// no free left/top). Preview inline, commit the settle via reposition-request.
|
|
249
|
+
{
|
|
250
|
+
const one = selSet.selected.length === 1 ? selSet.selected[0] : null;
|
|
251
|
+
if (one?.id) {
|
|
252
|
+
const el = resolveSelectionEl(document, one) as HTMLElement | null;
|
|
253
|
+
if (!el) return;
|
|
254
|
+
const cs = getComputedStyle(el);
|
|
255
|
+
if (cs.position !== 'absolute' && cs.position !== 'fixed') return; // no-op
|
|
256
|
+
e.preventDefault();
|
|
257
|
+
const prev = nudgeRef.current?.id === one.id ? nudgeRef.current : null;
|
|
258
|
+
const curLeft = prev
|
|
259
|
+
? prev.left
|
|
260
|
+
: Number.parseFloat(el.style.left) || Number.parseFloat(cs.left) || 0;
|
|
261
|
+
const curTop = prev
|
|
262
|
+
? prev.top
|
|
263
|
+
: Number.parseFloat(el.style.top) || Number.parseFloat(cs.top) || 0;
|
|
264
|
+
const beforeLeft = prev ? prev.beforeLeft : curLeft;
|
|
265
|
+
const beforeTop = prev ? prev.beforeTop : curTop;
|
|
266
|
+
const left = curLeft + delta.dx;
|
|
267
|
+
const top = curTop + delta.dy;
|
|
268
|
+
el.style.left = `${left}px`;
|
|
269
|
+
el.style.top = `${top}px`;
|
|
270
|
+
if (prev) clearTimeout(prev.timer);
|
|
271
|
+
const id = one.id;
|
|
272
|
+
const timer = setTimeout(() => {
|
|
273
|
+
const s = nudgeRef.current;
|
|
274
|
+
nudgeRef.current = null;
|
|
275
|
+
if (!s) return;
|
|
276
|
+
try {
|
|
277
|
+
window.parent.postMessage(
|
|
278
|
+
{
|
|
279
|
+
dgn: 'reposition-request',
|
|
280
|
+
id: s.id,
|
|
281
|
+
left: s.left,
|
|
282
|
+
top: s.top,
|
|
283
|
+
beforeLeft: s.beforeLeft,
|
|
284
|
+
beforeTop: s.beforeTop,
|
|
285
|
+
},
|
|
286
|
+
'*'
|
|
287
|
+
);
|
|
288
|
+
} catch {
|
|
289
|
+
/* detached / cross-origin */
|
|
290
|
+
}
|
|
291
|
+
}, 350);
|
|
292
|
+
nudgeRef.current = { id, beforeLeft, beforeTop, left, top, timer };
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Arrow nudge — artboards only (see file header for the why).
|
|
108
298
|
if (!artboardsCtx || !dragBus) return;
|
|
109
299
|
|
|
110
300
|
const artboardSelections = selSet.selected.filter((s) => !!s.artboardId && !s.id);
|
|
@@ -60,6 +60,12 @@ export interface Selection {
|
|
|
60
60
|
text?: string;
|
|
61
61
|
dom_path?: string[];
|
|
62
62
|
bounds?: { x: number; y: number; w: number; h: number } | null;
|
|
63
|
+
/** Stage D4 tail — WORLD-unit size (`offsetWidth`/`offsetHeight`, unaffected by
|
|
64
|
+
* the `.dc-world` zoom transform, unlike `bounds` which is the SCREEN rect).
|
|
65
|
+
* The Inspector's artboard-resize fields need the true JSX-authored size to
|
|
66
|
+
* pre-fill correctly regardless of zoom. */
|
|
67
|
+
worldW?: number;
|
|
68
|
+
worldH?: number;
|
|
63
69
|
html?: string;
|
|
64
70
|
/** Phase 12.2 — authored inline-style values (knob pre-fill) + resolved computed (placeholder hint). */
|
|
65
71
|
authored?: Record<string, string>;
|
|
@@ -70,6 +76,14 @@ export interface Selection {
|
|
|
70
76
|
/** Phase 12.3 — custom HTML attributes on the element (data-, aria-, role, title…),
|
|
71
77
|
* so the panel reflects a custom attribute the user added via the escape hatch. */
|
|
72
78
|
attrs?: Record<string, string>;
|
|
79
|
+
/** Stage M — the PARENT element's resolved `display` + `flex-direction`, captured
|
|
80
|
+
* at selection time (the shell can't reach the cross-origin iframe to read them
|
|
81
|
+
* later). Drives the Fixed/Hug/Fill sizing control: "Fill" is `flex-grow:1` on a
|
|
82
|
+
* flex child's MAIN axis, `align-self:stretch` on its cross axis, else `100%`. Also
|
|
83
|
+
* gates the flex-child (align-self / flex-grow…) rows so they don't show on a
|
|
84
|
+
* block child. Absent for a detached node / no parent. */
|
|
85
|
+
parentDisplay?: string;
|
|
86
|
+
parentFlexDirection?: string;
|
|
73
87
|
}
|
|
74
88
|
|
|
75
89
|
interface SelectionSetValue {
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file use-spacing-handles.tsx — Stage J (feature-element-editing-robustness)
|
|
3
|
+
* @scope apps/studio/use-spacing-handles.tsx
|
|
4
|
+
* @purpose On-canvas padding + gap drag. 4 small padding-edge handles (always,
|
|
5
|
+
* for the single selected element) + one dot per flex gap (only when
|
|
6
|
+
* the element is a flex/inline-flex container with ≥2 children).
|
|
7
|
+
* Live preview via direct inline-style writes (mirrors
|
|
8
|
+
* `use-element-resize.tsx`'s `applyPreview`); commit rides the SAME
|
|
9
|
+
* `resize-request` lane the element-resize overlay already uses —
|
|
10
|
+
* `padding-top/right/bottom/left`/`gap` were added to the shell's
|
|
11
|
+
* `resizeElement` allowed-prop list, so this needed no new write
|
|
12
|
+
* surface. A component-instance drag threads the Stage H3 occurrence
|
|
13
|
+
* index the same way (`globalCdOccurrence`) so it stays local.
|
|
14
|
+
*
|
|
15
|
+
* CSS-Grid gap editing is out of v1 scope — grid tracks are a
|
|
16
|
+
* separate follow-up plan (`feature-grid-track-editor.md`, Stage M3).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { type ReactNode, useCallback, useEffect, useRef } from 'react';
|
|
20
|
+
import { globalCdOccurrence, resolveSelectionEl } from './dom-selection.ts';
|
|
21
|
+
import { isElementDragActive } from './drag-state.ts';
|
|
22
|
+
import {
|
|
23
|
+
computeGapDrag,
|
|
24
|
+
computeGapMidpoints,
|
|
25
|
+
computePaddingDrag,
|
|
26
|
+
computePaddingLines,
|
|
27
|
+
flexMainAxis,
|
|
28
|
+
type PaddingSide,
|
|
29
|
+
paddingSideSet,
|
|
30
|
+
} from './spacing-handles.ts';
|
|
31
|
+
import { scaleFromMatrix } from './use-element-resize.tsx';
|
|
32
|
+
import { useSelectionSet } from './use-selection-set.tsx';
|
|
33
|
+
import { useToolMode } from './use-tool-mode.tsx';
|
|
34
|
+
|
|
35
|
+
const SPACING_CSS = `
|
|
36
|
+
.dc-spacing-handle {
|
|
37
|
+
position: fixed;
|
|
38
|
+
transform: translate(-50%, -50%);
|
|
39
|
+
/* Below the Stage-D resize handles (z-index 6) — at 0 padding a spacing
|
|
40
|
+
handle sits exactly on the border, where a resize mid-edge handle also
|
|
41
|
+
lives; the resize handle should win that hit-test tie. */
|
|
42
|
+
z-index: 4;
|
|
43
|
+
pointer-events: auto;
|
|
44
|
+
touch-action: none;
|
|
45
|
+
/* INV-2 — the idle-to-hover/dragging opacity bump (0.55/0.7 to 1, below) had
|
|
46
|
+
no transition, so the reduced-motion collapse further down was dead code
|
|
47
|
+
(nothing to collapse). Kept as a plain opacity transition, not a reveal/
|
|
48
|
+
unmount fade — this component still hard-toggles display to show/hide
|
|
49
|
+
the whole handle set (unlike the resize overlay's fade), so a handle is
|
|
50
|
+
grabbable the instant it appears, no half-visible window. */
|
|
51
|
+
transition: opacity 120ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
52
|
+
}
|
|
53
|
+
.dc-spacing-handle--padding {
|
|
54
|
+
width: 10px;
|
|
55
|
+
height: 10px;
|
|
56
|
+
border-radius: 2px;
|
|
57
|
+
background: var(--maude-hud-accent, oklch(0.680 0.180 268));
|
|
58
|
+
border: 1px solid var(--maude-hud-accent-fg, oklch(0.180 0.030 268));
|
|
59
|
+
opacity: 0.55;
|
|
60
|
+
}
|
|
61
|
+
.dc-spacing-handle--padding:hover,
|
|
62
|
+
.dc-spacing-handle--padding[data-dragging="true"] { opacity: 1; }
|
|
63
|
+
.dc-spacing-handle--padding[data-side="top"],
|
|
64
|
+
.dc-spacing-handle--padding[data-side="bottom"] { cursor: ns-resize; }
|
|
65
|
+
.dc-spacing-handle--padding[data-side="left"],
|
|
66
|
+
.dc-spacing-handle--padding[data-side="right"] { cursor: ew-resize; }
|
|
67
|
+
.dc-spacing-handle--gap {
|
|
68
|
+
width: 8px;
|
|
69
|
+
height: 8px;
|
|
70
|
+
border-radius: 50%;
|
|
71
|
+
background: #FF24BD;
|
|
72
|
+
border: 1px solid rgba(255, 255, 255, 0.85);
|
|
73
|
+
opacity: 0.7;
|
|
74
|
+
}
|
|
75
|
+
.dc-spacing-handle--gap:hover,
|
|
76
|
+
.dc-spacing-handle--gap[data-dragging="true"] { opacity: 1; }
|
|
77
|
+
.dc-spacing-handle--gap[data-axis="x"] { cursor: ew-resize; }
|
|
78
|
+
.dc-spacing-handle--gap[data-axis="y"] { cursor: ns-resize; }
|
|
79
|
+
@media (prefers-reduced-motion: reduce) {
|
|
80
|
+
.dc-spacing-handle { transition-duration: 1ms; }
|
|
81
|
+
}
|
|
82
|
+
`.trim();
|
|
83
|
+
|
|
84
|
+
function ensureSpacingStyles(): void {
|
|
85
|
+
if (typeof document === 'undefined') return;
|
|
86
|
+
if (document.getElementById('dc-spacing-css')) return;
|
|
87
|
+
const s = document.createElement('style');
|
|
88
|
+
s.id = 'dc-spacing-css';
|
|
89
|
+
s.textContent = SPACING_CSS;
|
|
90
|
+
document.head.appendChild(s);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function computedPx(cs: CSSStyleDeclaration, prop: string): number {
|
|
94
|
+
return Number.parseFloat(cs.getPropertyValue(prop)) || 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface SpacingDrag {
|
|
98
|
+
pointerId: number;
|
|
99
|
+
kind: 'padding' | 'gap';
|
|
100
|
+
side: PaddingSide | null;
|
|
101
|
+
axis: 'x' | 'y';
|
|
102
|
+
el: HTMLElement;
|
|
103
|
+
cdId: string;
|
|
104
|
+
idIndex: number;
|
|
105
|
+
startClientX: number;
|
|
106
|
+
startClientY: number;
|
|
107
|
+
zoom: number;
|
|
108
|
+
startValue: number;
|
|
109
|
+
/** The single CSS property this drag writes — `padding-<side>` or `gap`. */
|
|
110
|
+
prop: string;
|
|
111
|
+
/** Inline value BEFORE the drag (`el.style.getPropertyValue(prop)` or null). */
|
|
112
|
+
before: string | null;
|
|
113
|
+
/** All 4 padding sides' inline values BEFORE the drag — for the Alt (pair) /
|
|
114
|
+
* Alt+Shift (all) box-model modifiers (`paddingSideSet`), which can touch
|
|
115
|
+
* sides OTHER than the one grabbed. Undefined for a gap drag. */
|
|
116
|
+
paddingBefore?: Record<PaddingSide, string | null>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** One touched property's before/after — the shape `resizeElement`'s multi-prop
|
|
120
|
+
* `patch`/`before` objects already accept (Stage D3/L8 established this). */
|
|
121
|
+
interface PropEntry {
|
|
122
|
+
prop: string;
|
|
123
|
+
before: string | null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function SpacingHandlesOverlay(): ReactNode {
|
|
127
|
+
ensureSpacingStyles();
|
|
128
|
+
const { selected } = useSelectionSet();
|
|
129
|
+
const { tool } = useToolMode();
|
|
130
|
+
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
131
|
+
const rafRef = useRef<number | null>(null);
|
|
132
|
+
const dragRef = useRef<SpacingDrag | null>(null);
|
|
133
|
+
const lastCommitRef = useRef<{ el: HTMLElement; entries: PropEntry[] } | null>(null);
|
|
134
|
+
|
|
135
|
+
const one = selected.length === 1 ? selected[0] : null;
|
|
136
|
+
const cdId = one && typeof one.id === 'string' ? one.id : null;
|
|
137
|
+
const active = tool === 'move' && !!cdId;
|
|
138
|
+
|
|
139
|
+
// rAF loop — recompute + repaint every frame (pan/zoom/layout AND a live drag,
|
|
140
|
+
// since a gap drag reflows sibling children — re-deriving from the DOM each
|
|
141
|
+
// frame is simpler and more correct than hand-propagating the effect to every
|
|
142
|
+
// other gap handle).
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
const c = containerRef.current;
|
|
145
|
+
if (!c) return;
|
|
146
|
+
const hideAll = () => {
|
|
147
|
+
for (const child of Array.from(c.children)) (child as HTMLElement).style.display = 'none';
|
|
148
|
+
};
|
|
149
|
+
if (!active || !one) {
|
|
150
|
+
hideAll();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const tick = () => {
|
|
154
|
+
rafRef.current = null;
|
|
155
|
+
// Dogfood 2026-07-07 — a live ReorderDrag is the canvas's most DOM-churn-
|
|
156
|
+
// heavy gesture; skip this overlay's own getBoundingClientRect/
|
|
157
|
+
// getComputedStyle work while one is in flight (its handles are
|
|
158
|
+
// irrelevant mid-drag anyway — the drag has its own live-preview chrome).
|
|
159
|
+
if (isElementDragActive()) {
|
|
160
|
+
hideAll();
|
|
161
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const el = resolveSelectionEl(document, one) as HTMLElement | null;
|
|
165
|
+
if (!el) {
|
|
166
|
+
hideAll();
|
|
167
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const r = el.getBoundingClientRect();
|
|
171
|
+
if (r.width <= 0 && r.height <= 0) {
|
|
172
|
+
hideAll();
|
|
173
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const cs = getComputedStyle(el);
|
|
177
|
+
const world = el.closest('.dc-world') as HTMLElement | null;
|
|
178
|
+
const zoom = world ? scaleFromMatrix(getComputedStyle(world).transform) : 1;
|
|
179
|
+
const padding = {
|
|
180
|
+
top: computedPx(cs, 'padding-top'),
|
|
181
|
+
right: computedPx(cs, 'padding-right'),
|
|
182
|
+
bottom: computedPx(cs, 'padding-bottom'),
|
|
183
|
+
left: computedPx(cs, 'padding-left'),
|
|
184
|
+
};
|
|
185
|
+
const lines = computePaddingLines(
|
|
186
|
+
{ x: r.left, y: r.top, w: r.width, h: r.height },
|
|
187
|
+
padding,
|
|
188
|
+
zoom
|
|
189
|
+
);
|
|
190
|
+
const isFlex = cs.display === 'flex' || cs.display === 'inline-flex';
|
|
191
|
+
const axis = flexMainAxis(cs.flexDirection);
|
|
192
|
+
const gapPoints = isFlex
|
|
193
|
+
? computeGapMidpoints(
|
|
194
|
+
Array.from(el.children).map((child) => {
|
|
195
|
+
const cr = (child as HTMLElement).getBoundingClientRect();
|
|
196
|
+
return { x: cr.left, y: cr.top, w: cr.width, h: cr.height };
|
|
197
|
+
}),
|
|
198
|
+
axis
|
|
199
|
+
)
|
|
200
|
+
: [];
|
|
201
|
+
const TOTAL = lines.length + gapPoints.length;
|
|
202
|
+
while (c.children.length < TOTAL) c.appendChild(document.createElement('div'));
|
|
203
|
+
while (c.children.length > TOTAL) c.lastChild && c.removeChild(c.lastChild);
|
|
204
|
+
let i = 0;
|
|
205
|
+
for (const line of lines) {
|
|
206
|
+
const h = c.children[i] as HTMLElement;
|
|
207
|
+
h.className = 'dc-spacing-handle dc-spacing-handle--padding';
|
|
208
|
+
h.dataset.side = line.side;
|
|
209
|
+
h.dataset.prop = `padding-${line.side}`;
|
|
210
|
+
h.dataset.axis = line.axis;
|
|
211
|
+
h.title = `padding-${line.side} · drag to scrub · alt = symmetric pair · alt+shift = all sides`;
|
|
212
|
+
h.style.display = 'block';
|
|
213
|
+
h.style.left = `${Math.round(line.x)}px`;
|
|
214
|
+
h.style.top = `${Math.round(line.y)}px`;
|
|
215
|
+
i++;
|
|
216
|
+
}
|
|
217
|
+
for (const pt of gapPoints) {
|
|
218
|
+
const h = c.children[i] as HTMLElement;
|
|
219
|
+
h.className = 'dc-spacing-handle dc-spacing-handle--gap';
|
|
220
|
+
h.dataset.side = '';
|
|
221
|
+
h.dataset.prop = 'gap';
|
|
222
|
+
h.title = 'gap · drag to scrub';
|
|
223
|
+
h.dataset.axis = axis;
|
|
224
|
+
h.style.display = 'block';
|
|
225
|
+
h.style.left = `${Math.round(pt.x)}px`;
|
|
226
|
+
h.style.top = `${Math.round(pt.y)}px`;
|
|
227
|
+
i++;
|
|
228
|
+
}
|
|
229
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
230
|
+
};
|
|
231
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
232
|
+
return () => {
|
|
233
|
+
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
|
234
|
+
};
|
|
235
|
+
}, [active, one]);
|
|
236
|
+
|
|
237
|
+
/** Commit one or more touched properties in a SINGLE `resize-request` — a
|
|
238
|
+
* plain drag has one entry; an Alt (pair) / Alt+Shift (all sides) padding
|
|
239
|
+
* drag has 2 or 4, all set to the SAME value (mirrors the CSS panel's own
|
|
240
|
+
* `side()` scrub semantics — every touched side becomes the dragged value,
|
|
241
|
+
* not each grown independently). */
|
|
242
|
+
const commit = useCallback((d: SpacingDrag, entries: PropEntry[], value: number) => {
|
|
243
|
+
if (!entries.length) return;
|
|
244
|
+
const after = `${value}px`;
|
|
245
|
+
lastCommitRef.current = { el: d.el, entries };
|
|
246
|
+
const patch: Record<string, string> = {};
|
|
247
|
+
const before: Record<string, string | null> = {};
|
|
248
|
+
for (const e of entries) {
|
|
249
|
+
patch[e.prop] = after;
|
|
250
|
+
before[e.prop] = e.before;
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
window.parent.postMessage(
|
|
254
|
+
{ dgn: 'resize-request', id: d.cdId, patch, before, idIndex: d.idIndex },
|
|
255
|
+
'*'
|
|
256
|
+
);
|
|
257
|
+
} catch {
|
|
258
|
+
/* detached / cross-origin teardown */
|
|
259
|
+
}
|
|
260
|
+
}, []);
|
|
261
|
+
|
|
262
|
+
useEffect(() => {
|
|
263
|
+
const c = containerRef.current;
|
|
264
|
+
if (!c) return;
|
|
265
|
+
|
|
266
|
+
const onDown = (e: PointerEvent) => {
|
|
267
|
+
const t = e.target as HTMLElement | null;
|
|
268
|
+
if (!t?.classList.contains('dc-spacing-handle')) return;
|
|
269
|
+
if (!one || typeof one.id !== 'string') return;
|
|
270
|
+
const el = resolveSelectionEl(document, one) as HTMLElement | null;
|
|
271
|
+
if (!el) return;
|
|
272
|
+
e.preventDefault();
|
|
273
|
+
e.stopPropagation();
|
|
274
|
+
const prop = t.dataset.prop || '';
|
|
275
|
+
const isGap = prop === 'gap';
|
|
276
|
+
const cs = getComputedStyle(el);
|
|
277
|
+
const world = el.closest('.dc-world') as HTMLElement | null;
|
|
278
|
+
const zoom = world ? scaleFromMatrix(getComputedStyle(world).transform) : 1;
|
|
279
|
+
const drag: SpacingDrag = {
|
|
280
|
+
pointerId: e.pointerId,
|
|
281
|
+
kind: isGap ? 'gap' : 'padding',
|
|
282
|
+
side: isGap ? null : ((t.dataset.side as PaddingSide) ?? null),
|
|
283
|
+
axis: (t.dataset.axis as 'x' | 'y') ?? 'x',
|
|
284
|
+
el,
|
|
285
|
+
cdId: one.id,
|
|
286
|
+
idIndex: globalCdOccurrence(document, one.id, el),
|
|
287
|
+
startClientX: e.clientX,
|
|
288
|
+
startClientY: e.clientY,
|
|
289
|
+
zoom: zoom > 0 ? zoom : 1,
|
|
290
|
+
startValue: computedPx(cs, prop),
|
|
291
|
+
prop,
|
|
292
|
+
before: el.style.getPropertyValue(prop) || null,
|
|
293
|
+
// Alt (pair) / Alt+Shift (all sides) can touch sides OTHER than the one
|
|
294
|
+
// grabbed — capture every side's PRE-drag inline value up front so a
|
|
295
|
+
// late modifier press still gets the correct undo `before`.
|
|
296
|
+
paddingBefore: isGap
|
|
297
|
+
? undefined
|
|
298
|
+
: {
|
|
299
|
+
top: el.style.getPropertyValue('padding-top') || null,
|
|
300
|
+
right: el.style.getPropertyValue('padding-right') || null,
|
|
301
|
+
bottom: el.style.getPropertyValue('padding-bottom') || null,
|
|
302
|
+
left: el.style.getPropertyValue('padding-left') || null,
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
dragRef.current = drag;
|
|
306
|
+
t.dataset.dragging = 'true';
|
|
307
|
+
try {
|
|
308
|
+
t.setPointerCapture(e.pointerId);
|
|
309
|
+
} catch {
|
|
310
|
+
/* synthetic events may reject capture */
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// Alt (pair) / Alt+Shift (all sides) — the CSS panel's own box-model scrub
|
|
315
|
+
// grammar, matched exactly (`paddingSideSet`). Gap has no "opposite side"
|
|
316
|
+
// concept, so modifiers only ever apply to a padding drag.
|
|
317
|
+
const touchedEntries = (d: SpacingDrag, ev: PointerEvent): PropEntry[] => {
|
|
318
|
+
if (d.kind === 'gap' || !d.paddingBefore) return [{ prop: d.prop, before: d.before }];
|
|
319
|
+
const sides = paddingSideSet(d.side ?? 'top', ev.altKey, ev.shiftKey);
|
|
320
|
+
return sides.map((s) => ({ prop: `padding-${s}`, before: d.paddingBefore?.[s] ?? null }));
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const onMove = (e: PointerEvent) => {
|
|
324
|
+
const d = dragRef.current;
|
|
325
|
+
if (!d || e.pointerId !== d.pointerId) return;
|
|
326
|
+
e.preventDefault();
|
|
327
|
+
const dx = e.clientX - d.startClientX;
|
|
328
|
+
const dy = e.clientY - d.startClientY;
|
|
329
|
+
const next =
|
|
330
|
+
d.kind === 'gap'
|
|
331
|
+
? computeGapDrag(d.axis, d.startValue, dx, dy, d.zoom)
|
|
332
|
+
: computePaddingDrag(d.side ?? 'top', d.startValue, dx, dy, d.zoom);
|
|
333
|
+
for (const entry of touchedEntries(d, e)) d.el.style.setProperty(entry.prop, `${next}px`);
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const onUp = (e: PointerEvent) => {
|
|
337
|
+
const d = dragRef.current;
|
|
338
|
+
if (!d || e.pointerId !== d.pointerId) return;
|
|
339
|
+
dragRef.current = null;
|
|
340
|
+
const handle = containerRef.current?.querySelector<HTMLElement>(
|
|
341
|
+
`.dc-spacing-handle[data-prop="${d.prop}"][data-dragging="true"]`
|
|
342
|
+
);
|
|
343
|
+
if (handle) delete handle.dataset.dragging;
|
|
344
|
+
const dx = e.clientX - d.startClientX;
|
|
345
|
+
const dy = e.clientY - d.startClientY;
|
|
346
|
+
const next =
|
|
347
|
+
d.kind === 'gap'
|
|
348
|
+
? computeGapDrag(d.axis, d.startValue, dx, dy, d.zoom)
|
|
349
|
+
: computePaddingDrag(d.side ?? 'top', d.startValue, dx, dy, d.zoom);
|
|
350
|
+
const entries = touchedEntries(d, e);
|
|
351
|
+
const after = `${next}px`;
|
|
352
|
+
const changed = entries.some((entry) => after !== (entry.before ?? `${d.startValue}px`));
|
|
353
|
+
if (!changed) return; // no-op guard
|
|
354
|
+
commit(d, entries, next);
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
// Restore the pre-drag inline value(s) if the shell reports the write
|
|
358
|
+
// failed (shares `resize-failed` with the element-resize overlay — both
|
|
359
|
+
// restore from their OWN lastCommitRef, so they don't collide).
|
|
360
|
+
const onFail = (e: MessageEvent) => {
|
|
361
|
+
const m = e.data as { dgn?: string } | null;
|
|
362
|
+
if (m?.dgn !== 'resize-failed') return;
|
|
363
|
+
if (e.source !== window.parent) return; // only the parent shell (DDR-054)
|
|
364
|
+
const last = lastCommitRef.current;
|
|
365
|
+
if (!last) return;
|
|
366
|
+
for (const entry of last.entries) {
|
|
367
|
+
if (entry.before == null) last.el.style.removeProperty(entry.prop);
|
|
368
|
+
else last.el.style.setProperty(entry.prop, entry.before);
|
|
369
|
+
}
|
|
370
|
+
lastCommitRef.current = null;
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
c.addEventListener('pointerdown', onDown);
|
|
374
|
+
window.addEventListener('pointermove', onMove);
|
|
375
|
+
window.addEventListener('pointerup', onUp);
|
|
376
|
+
window.addEventListener('pointercancel', onUp);
|
|
377
|
+
window.addEventListener('message', onFail);
|
|
378
|
+
return () => {
|
|
379
|
+
c.removeEventListener('pointerdown', onDown);
|
|
380
|
+
window.removeEventListener('pointermove', onMove);
|
|
381
|
+
window.removeEventListener('pointerup', onUp);
|
|
382
|
+
window.removeEventListener('pointercancel', onUp);
|
|
383
|
+
window.removeEventListener('message', onFail);
|
|
384
|
+
};
|
|
385
|
+
}, [one, commit]);
|
|
386
|
+
|
|
387
|
+
return <div ref={containerRef} aria-hidden="true" />;
|
|
388
|
+
}
|