@1agh/maude 0.18.2 → 0.19.1
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/cli/bin/maude.mjs +16 -0
- package/cli/lib/update-check.mjs +145 -0
- package/cli/lib/update-check.test.mjs +32 -0
- package/package.json +8 -8
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +14 -6
- package/plugins/design/dev-server/annotations-layer.tsx +144 -22
- package/plugins/design/dev-server/api.ts +72 -19
- package/plugins/design/dev-server/artboard-marquee.tsx +170 -0
- package/plugins/design/dev-server/canvas-lib-resolver.ts +10 -1
- package/plugins/design/dev-server/canvas-lib.tsx +190 -94
- package/plugins/design/dev-server/canvas-shell.tsx +478 -34
- package/plugins/design/dev-server/client/app.jsx +177 -56
- package/plugins/design/dev-server/client/styles/1-tokens.css +15 -8
- package/plugins/design/dev-server/client/styles/4-components.css +32 -2
- package/plugins/design/dev-server/config.schema.json +31 -5
- package/plugins/design/dev-server/context-menu.tsx +3 -3
- package/plugins/design/dev-server/context.ts +37 -3
- package/plugins/design/dev-server/dist/client.bundle.js +219 -107
- package/plugins/design/dev-server/dist/styles.css +38 -2
- package/plugins/design/dev-server/export-dialog.tsx +3 -3
- package/plugins/design/dev-server/http.ts +14 -2
- package/plugins/design/dev-server/input-router.tsx +22 -8
- package/plugins/design/dev-server/server.mjs +86 -20
- package/plugins/design/dev-server/server.ts +76 -30
- package/plugins/design/dev-server/test/context-resolve-tokens.test.ts +97 -0
- package/plugins/design/dev-server/test/snap-distance-pill.test.ts +68 -0
- package/plugins/design/dev-server/test/system-endpoint.test.ts +144 -0
- package/plugins/design/dev-server/tool-palette.tsx +71 -15
- package/plugins/design/dev-server/use-annotation-resize.tsx +295 -0
- package/plugins/design/dev-server/use-snap-guides.tsx +25 -1
- package/plugins/design/dev-server/use-tool-mode.tsx +31 -2
|
@@ -37,9 +37,13 @@ import {
|
|
|
37
37
|
} from 'react';
|
|
38
38
|
|
|
39
39
|
import { AnnotationsLayer } from './annotations-layer.tsx';
|
|
40
|
+
import { ArtboardMarqueeOverlay } from './artboard-marquee.tsx';
|
|
40
41
|
import {
|
|
42
|
+
type ArtboardRect,
|
|
41
43
|
SnapGuideOverlay,
|
|
42
44
|
type ViewportControllerHandle,
|
|
45
|
+
useArtboardsContext,
|
|
46
|
+
useDragStateContext,
|
|
43
47
|
useViewportControllerContext,
|
|
44
48
|
} from './canvas-lib.tsx';
|
|
45
49
|
import { CommentsOverlay } from './comments-overlay.tsx';
|
|
@@ -69,43 +73,100 @@ import { useToolMode } from './use-tool-mode.tsx';
|
|
|
69
73
|
// plane would otherwise scale a 2 px outline to 0.84 px at 42 % zoom (subpixel
|
|
70
74
|
// = invisible). No per-element class stamping is used.
|
|
71
75
|
|
|
76
|
+
// DDR-046 — Three-state halo language. Each state has its own border weight,
|
|
77
|
+
// color treatment, and geometric idiom so 8+ semantic states (hover / selected
|
|
78
|
+
// / member-of-multi / group / snap-sibling / snap-grid / marquee / annotation
|
|
79
|
+
// / active-artboard) stay visually distinct. Painting one with another's
|
|
80
|
+
// idiom is a regression.
|
|
72
81
|
const HALO_CSS = `
|
|
73
82
|
.dc-cv-halo {
|
|
74
83
|
position: fixed;
|
|
75
84
|
pointer-events: none;
|
|
76
85
|
z-index: 5;
|
|
77
|
-
border: 2px solid var(--accent, #d63b1f);
|
|
78
86
|
box-sizing: border-box;
|
|
79
87
|
border-radius: 2px;
|
|
80
88
|
transition: opacity 60ms linear;
|
|
81
89
|
}
|
|
90
|
+
/* Hover — lighter 1.5px tinted line + white inner ring for contrast on dark
|
|
91
|
+
elements. NO ring, NO ticks. Synchronous paint (no debounce). */
|
|
82
92
|
.dc-cv-halo--hover {
|
|
83
|
-
border
|
|
84
|
-
|
|
93
|
+
border: 1.5px solid color-mix(in oklab, var(--accent, #0d99ff) 60%, transparent);
|
|
94
|
+
box-shadow: inset 0 0 0 1px var(--bg-0, #ffffff);
|
|
85
95
|
}
|
|
96
|
+
/* Selected (single) — 2px solid + 18% ring halo + 4 filled corner ticks.
|
|
97
|
+
Ticks are <i class="tick tick-*"> children at inset:-3px, 8x8, accent fill. */
|
|
86
98
|
.dc-cv-halo--selected {
|
|
87
|
-
border
|
|
88
|
-
box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent, #
|
|
99
|
+
border: 2px solid var(--accent, #0d99ff);
|
|
100
|
+
box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent, #0d99ff) 18%, transparent);
|
|
89
101
|
}
|
|
102
|
+
.dc-cv-halo--selected .tick {
|
|
103
|
+
position: absolute;
|
|
104
|
+
width: 8px;
|
|
105
|
+
height: 8px;
|
|
106
|
+
background: var(--accent, #0d99ff);
|
|
107
|
+
border-radius: 1px;
|
|
108
|
+
box-shadow: 0 0 0 1px var(--bg-0, #ffffff);
|
|
109
|
+
}
|
|
110
|
+
.dc-cv-halo--selected .tick-tl { top: -3px; left: -3px; }
|
|
111
|
+
.dc-cv-halo--selected .tick-tr { top: -3px; right: -3px; }
|
|
112
|
+
.dc-cv-halo--selected .tick-bl { bottom: -3px; left: -3px; }
|
|
113
|
+
.dc-cv-halo--selected .tick-br { bottom: -3px; right: -3px; }
|
|
114
|
+
/* Selected (member of multi-selection) — 1.5 px solid full-accent outline.
|
|
115
|
+
No ring, no ticks (the group bbox above carries the container signal).
|
|
116
|
+
T16 / DDR-046 rev 2 — full opacity instead of 50%-tinted: tinted lines read
|
|
117
|
+
as "draft / placeholder" and members would melt away inside artboards once
|
|
118
|
+
the artboard border itself is 22%-tinted (T15). */
|
|
119
|
+
.dc-cv-halo--selected-member {
|
|
120
|
+
border: 1.5px solid var(--accent, #0d99ff);
|
|
121
|
+
}
|
|
122
|
+
/* Group bbox — 1 px DASHED full accent + four 6 × 6 square corner handles.
|
|
123
|
+
T16 / DDR-046 rev 2 — dashed is the canonical group-container affordance
|
|
124
|
+
(Figma group bbox, FigJam Section drag-state, Photoshop marching ants).
|
|
125
|
+
Dashed reads "ambient binding" without claiming subject-ness; the loud
|
|
126
|
+
solid outlines on each MEMBER carry the active-selection signal. Corner
|
|
127
|
+
handles are 6 × 6 (vs single-select's 8 × 8 ticks) so the group idiom
|
|
128
|
+
reads as "thinner authority" than single-select. */
|
|
90
129
|
.dc-cv-group-bbox {
|
|
91
130
|
position: fixed;
|
|
92
131
|
pointer-events: none;
|
|
93
132
|
z-index: 5;
|
|
94
|
-
border: 1px dashed var(--accent, #
|
|
133
|
+
border: 1px dashed var(--accent, #0d99ff);
|
|
95
134
|
border-radius: 2px;
|
|
96
|
-
opacity: 0.85;
|
|
97
135
|
}
|
|
136
|
+
.dc-cv-group-bbox .tick {
|
|
137
|
+
position: absolute;
|
|
138
|
+
width: 6px;
|
|
139
|
+
height: 6px;
|
|
140
|
+
background: var(--accent, #0d99ff);
|
|
141
|
+
border-radius: 1px;
|
|
142
|
+
box-shadow: 0 0 0 1px var(--bg-0, #ffffff);
|
|
143
|
+
}
|
|
144
|
+
.dc-cv-group-bbox .tick-tl { top: -3px; left: -3px; }
|
|
145
|
+
.dc-cv-group-bbox .tick-tr { top: -3px; right: -3px; }
|
|
146
|
+
.dc-cv-group-bbox .tick-bl { bottom: -3px; left: -3px; }
|
|
147
|
+
.dc-cv-group-bbox .tick-br { bottom: -3px; right: -3px; }
|
|
98
148
|
/*
|
|
99
149
|
* Active-artboard indicator — the artboard whose center sits closest to the
|
|
100
150
|
* viewport midpoint after pan settles is "active" (DesignCanvas tracks this
|
|
101
|
-
* for keyboard jumps + the /design:edit context anchor).
|
|
102
|
-
*
|
|
103
|
-
*
|
|
151
|
+
* for keyboard jumps + the /design:edit context anchor). DDR-046 — ring sits
|
|
152
|
+
* OUTSIDE the hard drop-shadow so it's visible at any pan distance / zoom.
|
|
153
|
+
* 120 ms ease-out so activation is felt, not invisible.
|
|
104
154
|
*/
|
|
105
155
|
.dc-canvas .dc-artboard[aria-current="true"] {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
156
|
+
/* T15 — quiet frame, single 2 px accent ring. The previous double-shadow
|
|
157
|
+
(3 px ring + hard 6×6×0 offset) was readable but visually expensive once
|
|
158
|
+
the frame itself lost its brutalist treatment. A 2 px ring on a 22 %
|
|
159
|
+
tinted hairline reads unambiguous without claiming subject-ness. */
|
|
160
|
+
box-shadow: 0 0 0 2px var(--accent, #0d99ff);
|
|
161
|
+
transition: box-shadow 120ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
162
|
+
}
|
|
163
|
+
/* Respect prefers-reduced-motion across all chrome transitions. */
|
|
164
|
+
@media (prefers-reduced-motion: reduce) {
|
|
165
|
+
.dc-cv-halo,
|
|
166
|
+
.dc-cv-group-bbox,
|
|
167
|
+
.dc-canvas .dc-artboard[aria-current="true"] {
|
|
168
|
+
transition: none !important;
|
|
169
|
+
}
|
|
109
170
|
}
|
|
110
171
|
/*
|
|
111
172
|
* Force tool cursor across the canvas tree in comment / hand modes. Without
|
|
@@ -114,17 +175,37 @@ const HALO_CSS = `
|
|
|
114
175
|
* the user hovers an interactive element — wrong signal when native
|
|
115
176
|
* interactions are suppressed by the router anyway.
|
|
116
177
|
*/
|
|
178
|
+
/* T22 — per-tool SVG cursors. Each cursor is a 16 × 16 SVG data-URI with a
|
|
179
|
+
declared hotspot. The crosshair / cell / grab fallbacks remain in the
|
|
180
|
+
chain so older browsers still get a recognisable affordance. SVGs are
|
|
181
|
+
utf-8 encoded inline (Chromium / Safari 16+ / Firefox 117+ all accept). */
|
|
182
|
+
.dc-canvas {
|
|
183
|
+
--cursor-pen: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'><path d='M14 1.5 L9 6.5 L2.5 13.5 L1.5 14.5 L2 13 L8.5 6 L13.5 1.5 Z' fill='%23111' stroke='white' stroke-width='0.7' stroke-linejoin='round'/></svg>") 2 14;
|
|
184
|
+
--cursor-rect: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'><line x1='0.5' y1='8' x2='15.5' y2='8' stroke='%23111' stroke-width='1'/><line x1='8' y1='0.5' x2='8' y2='15.5' stroke='%23111' stroke-width='1'/><rect x='10.5' y='10.5' width='4' height='3' fill='white' stroke='%23111' stroke-width='1'/></svg>") 8 8;
|
|
185
|
+
--cursor-ellipse: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'><line x1='0.5' y1='8' x2='15.5' y2='8' stroke='%23111' stroke-width='1'/><line x1='8' y1='0.5' x2='8' y2='15.5' stroke='%23111' stroke-width='1'/><ellipse cx='12.5' cy='12' rx='2.5' ry='1.5' fill='white' stroke='%23111' stroke-width='1'/></svg>") 8 8;
|
|
186
|
+
--cursor-arrow: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'><line x1='0.5' y1='8' x2='15.5' y2='8' stroke='%23111' stroke-width='1'/><line x1='8' y1='0.5' x2='8' y2='15.5' stroke='%23111' stroke-width='1'/><path d='M10 13 L15 13 M12.5 11 L15 13 L12.5 15' fill='none' stroke='%23111' stroke-width='1' stroke-linecap='round' stroke-linejoin='round'/></svg>") 8 8;
|
|
187
|
+
--cursor-eraser: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'><rect x='3' y='9' width='8' height='5' rx='1' fill='%23ffb3da' stroke='%23111' stroke-width='1' transform='rotate(-20 7 11.5)'/><rect x='3' y='9' width='8' height='2' fill='%23d63b6e' transform='rotate(-20 7 10)' opacity='0.6'/></svg>") 8 14;
|
|
188
|
+
--cursor-comment: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'><path d='M3 3 L14 3 L14 11 L8 11 L4 14 L4 11 L3 11 Z' fill='white' stroke='%23111' stroke-width='1' stroke-linejoin='round'/></svg>") 4 4;
|
|
189
|
+
}
|
|
117
190
|
.dc-canvas[data-active-tool="comment"],
|
|
118
|
-
.dc-canvas[data-active-tool="comment"]
|
|
191
|
+
.dc-canvas[data-active-tool="comment"] * {
|
|
192
|
+
cursor: var(--cursor-comment), crosshair !important;
|
|
193
|
+
}
|
|
119
194
|
.dc-canvas[data-active-tool="pen"],
|
|
120
|
-
.dc-canvas[data-active-tool="pen"]
|
|
195
|
+
.dc-canvas[data-active-tool="pen"] * {
|
|
196
|
+
cursor: var(--cursor-pen), crosshair !important;
|
|
197
|
+
}
|
|
121
198
|
.dc-canvas[data-active-tool="rect"],
|
|
122
|
-
.dc-canvas[data-active-tool="rect"]
|
|
199
|
+
.dc-canvas[data-active-tool="rect"] * {
|
|
200
|
+
cursor: var(--cursor-rect), crosshair !important;
|
|
201
|
+
}
|
|
123
202
|
.dc-canvas[data-active-tool="ellipse"],
|
|
124
|
-
.dc-canvas[data-active-tool="ellipse"]
|
|
203
|
+
.dc-canvas[data-active-tool="ellipse"] * {
|
|
204
|
+
cursor: var(--cursor-ellipse), crosshair !important;
|
|
205
|
+
}
|
|
125
206
|
.dc-canvas[data-active-tool="arrow"],
|
|
126
207
|
.dc-canvas[data-active-tool="arrow"] * {
|
|
127
|
-
cursor: crosshair !important;
|
|
208
|
+
cursor: var(--cursor-arrow), crosshair !important;
|
|
128
209
|
}
|
|
129
210
|
.dc-canvas[data-active-tool="hand"],
|
|
130
211
|
.dc-canvas[data-active-tool="hand"] * {
|
|
@@ -132,7 +213,7 @@ const HALO_CSS = `
|
|
|
132
213
|
}
|
|
133
214
|
.dc-canvas[data-active-tool="eraser"],
|
|
134
215
|
.dc-canvas[data-active-tool="eraser"] * {
|
|
135
|
-
cursor: cell !important;
|
|
216
|
+
cursor: var(--cursor-eraser), cell !important;
|
|
136
217
|
}
|
|
137
218
|
`.trim();
|
|
138
219
|
|
|
@@ -198,15 +279,76 @@ function CanvasCore({
|
|
|
198
279
|
};
|
|
199
280
|
}, [hostRef, tool]);
|
|
200
281
|
|
|
282
|
+
const artboardsCtx = useArtboardsContext();
|
|
283
|
+
const dragBus = useDragStateContext();
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* T24 — distribute the currently-selected artboards evenly on the given
|
|
287
|
+
* axis. Requires ≥ 3 selected artboards. Sort by leading edge, hold the
|
|
288
|
+
* first + last in place, and reposition the middle artboards so the gaps
|
|
289
|
+
* between trailing edge → next leading edge are equal.
|
|
290
|
+
*/
|
|
291
|
+
const distributeArtboards = useCallback(
|
|
292
|
+
(axis: 'x' | 'y') => {
|
|
293
|
+
if (!artboardsCtx || !dragBus) return;
|
|
294
|
+
const ids = new Set(
|
|
295
|
+
selSet.selected.filter((s) => !!s.artboardId).map((s) => s.artboardId as string)
|
|
296
|
+
);
|
|
297
|
+
if (ids.size < 3) return;
|
|
298
|
+
const targets: ArtboardRect[] = artboardsCtx.artboards.filter((r) => ids.has(r.id));
|
|
299
|
+
if (targets.length < 3) return;
|
|
300
|
+
const sorted = [...targets].sort((a, b) => (axis === 'x' ? a.x - b.x : a.y - b.y));
|
|
301
|
+
const first = sorted[0];
|
|
302
|
+
const last = sorted[sorted.length - 1];
|
|
303
|
+
if (!first || !last) return;
|
|
304
|
+
// Total content extent (sum of side-lengths on the axis) + the span
|
|
305
|
+
// between first leading edge and last trailing edge gives us the gap
|
|
306
|
+
// budget to divide equally between (n-1) inter-artboard slots.
|
|
307
|
+
const sideLen = (r: ArtboardRect) => (axis === 'x' ? r.w : r.h);
|
|
308
|
+
const totalSides = sorted.reduce((acc, r) => acc + sideLen(r), 0);
|
|
309
|
+
const span =
|
|
310
|
+
(axis === 'x' ? last.x + last.w - first.x : last.y + last.h - first.y) - totalSides;
|
|
311
|
+
const gap = span / (sorted.length - 1);
|
|
312
|
+
const moved: { id: string; x: number; y: number }[] = [];
|
|
313
|
+
let cursor = axis === 'x' ? first.x + first.w + gap : first.y + first.h + gap;
|
|
314
|
+
for (let i = 1; i < sorted.length - 1; i++) {
|
|
315
|
+
const r = sorted[i];
|
|
316
|
+
if (!r) continue;
|
|
317
|
+
if (axis === 'x') {
|
|
318
|
+
moved.push({ id: r.id, x: Math.round(cursor), y: r.y });
|
|
319
|
+
cursor += r.w + gap;
|
|
320
|
+
} else {
|
|
321
|
+
moved.push({ id: r.id, x: r.x, y: Math.round(cursor) });
|
|
322
|
+
cursor += r.h + gap;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (moved.length === 0) return;
|
|
326
|
+
dragBus.commitPositions(moved);
|
|
327
|
+
},
|
|
328
|
+
[artboardsCtx, dragBus, selSet.selected]
|
|
329
|
+
);
|
|
330
|
+
|
|
201
331
|
const registry = useMemo<ContextRegistry>(
|
|
202
|
-
() =>
|
|
203
|
-
|
|
332
|
+
() =>
|
|
333
|
+
buildRegistry({
|
|
334
|
+
controller,
|
|
335
|
+
clearSelection: selSet.clear,
|
|
336
|
+
selSet,
|
|
337
|
+
distributeArtboards,
|
|
338
|
+
}),
|
|
339
|
+
[controller, selSet, distributeArtboards]
|
|
204
340
|
);
|
|
205
341
|
|
|
342
|
+
// Distribute is reached via the MultiArtboardToolbar (floating chrome
|
|
343
|
+
// anchored above the group bbox) + context menu. No keyboard shortcut —
|
|
344
|
+
// user feedback (post-Wave-2) preferred the toolbar over global hotkeys.
|
|
345
|
+
|
|
206
346
|
return (
|
|
207
347
|
<ExportDialogProvider>
|
|
208
348
|
<ContextMenuProvider registry={registry}>
|
|
209
|
-
<CanvasRouter hostRef={hostRef}
|
|
349
|
+
<CanvasRouter hostRef={hostRef} distributeArtboards={distributeArtboards}>
|
|
350
|
+
{children}
|
|
351
|
+
</CanvasRouter>
|
|
210
352
|
</ContextMenuProvider>
|
|
211
353
|
</ExportDialogProvider>
|
|
212
354
|
);
|
|
@@ -218,8 +360,16 @@ function CanvasCore({
|
|
|
218
360
|
function buildRegistry(deps: {
|
|
219
361
|
controller: ViewportControllerHandle | null;
|
|
220
362
|
clearSelection: () => void;
|
|
363
|
+
selSet: { selected: Selection[] };
|
|
364
|
+
distributeArtboards: (axis: 'x' | 'y') => void;
|
|
221
365
|
}): ContextRegistry {
|
|
222
|
-
const { controller, clearSelection } = deps;
|
|
366
|
+
const { controller, clearSelection, selSet, distributeArtboards } = deps;
|
|
367
|
+
|
|
368
|
+
// T24 — distribute commands are only enabled when ≥ 3 artboards are
|
|
369
|
+
// selected. Below that, the menu items render as `disabled` so the user
|
|
370
|
+
// sees the affordance but understands the precondition.
|
|
371
|
+
const selectedArtboardCount = selSet.selected.filter((s) => !!s.artboardId).length;
|
|
372
|
+
const distributeEnabled = selectedArtboardCount >= 3;
|
|
223
373
|
|
|
224
374
|
const copy = (text: string): void => {
|
|
225
375
|
if (typeof navigator === 'undefined' || !navigator.clipboard) return;
|
|
@@ -355,6 +505,24 @@ function buildRegistry(deps: {
|
|
|
355
505
|
fitItem,
|
|
356
506
|
resetItem,
|
|
357
507
|
],
|
|
508
|
+
[
|
|
509
|
+
// T24 — distribute commands. Primary surface is the floating
|
|
510
|
+
// MultiArtboardToolbar above the group bbox; the menu entries are
|
|
511
|
+
// a discoverability backup. Disabled when fewer than 3 artboards
|
|
512
|
+
// are selected.
|
|
513
|
+
{
|
|
514
|
+
id: 'distribute-h',
|
|
515
|
+
label: 'Distribute horizontally',
|
|
516
|
+
disabled: !distributeEnabled,
|
|
517
|
+
onSelect: () => distributeArtboards('x'),
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
id: 'distribute-v',
|
|
521
|
+
label: 'Distribute vertically',
|
|
522
|
+
disabled: !distributeEnabled,
|
|
523
|
+
onSelect: () => distributeArtboards('y'),
|
|
524
|
+
},
|
|
525
|
+
],
|
|
358
526
|
[exportItem('export-artboard', 'Export this artboard…', 'artboard')],
|
|
359
527
|
],
|
|
360
528
|
world: [
|
|
@@ -384,11 +552,13 @@ function boundsOf(el: HTMLElement) {
|
|
|
384
552
|
function CanvasRouter({
|
|
385
553
|
hostRef,
|
|
386
554
|
children,
|
|
555
|
+
distributeArtboards,
|
|
387
556
|
}: {
|
|
388
557
|
hostRef: RefObject<HTMLDivElement | null>;
|
|
389
558
|
children: ReactNode;
|
|
559
|
+
distributeArtboards: (axis: 'x' | 'y') => void;
|
|
390
560
|
}) {
|
|
391
|
-
const { tool, setTool } = useToolMode();
|
|
561
|
+
const { tool, setTool, clearSticky } = useToolMode();
|
|
392
562
|
const selSet = useSelectionSet();
|
|
393
563
|
const annotSel = useAnnotationSelection();
|
|
394
564
|
const ctxMenu = useContextMenu();
|
|
@@ -468,9 +638,11 @@ function CanvasRouter({
|
|
|
468
638
|
onSelect: ({ mode, deep, clientX, clientY }) => {
|
|
469
639
|
const target = resolveHoverTarget(document, clientX, clientY, { deep });
|
|
470
640
|
if (!target) {
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
|
|
641
|
+
// No-target click (canvas chrome / dead space) — DO NOT auto-clear
|
|
642
|
+
// the selection. Esc is the canonical deselect gesture; click-to-
|
|
643
|
+
// clear loses the user's selection on accidental misses. The empty
|
|
644
|
+
// marquee path (artboard-marquee.tsx) likewise never clears unless
|
|
645
|
+
// it actually captured something.
|
|
474
646
|
return;
|
|
475
647
|
}
|
|
476
648
|
const sel = hoverTargetToSelection(target);
|
|
@@ -492,6 +664,18 @@ function CanvasRouter({
|
|
|
492
664
|
},
|
|
493
665
|
onTool: ({ tool: t }) => setTool(t),
|
|
494
666
|
onEscape: () => {
|
|
667
|
+
// T21 — abort any mid-stroke draw FIRST. The annotations layer
|
|
668
|
+
// listens for `maude:cancel-stroke` and drops the in-progress
|
|
669
|
+
// shape without committing.
|
|
670
|
+
try {
|
|
671
|
+
document.dispatchEvent(new CustomEvent('maude:cancel-stroke'));
|
|
672
|
+
} catch {
|
|
673
|
+
/* non-DOM env */
|
|
674
|
+
}
|
|
675
|
+
// T19 — clear sticky-tool lock + flip back to Move. Esc is the
|
|
676
|
+
// canonical "back to default state" gesture across canvas apps.
|
|
677
|
+
clearSticky();
|
|
678
|
+
if (tool !== 'move') setTool('move');
|
|
495
679
|
ctxMenu.close();
|
|
496
680
|
selSet.clear();
|
|
497
681
|
annotSel.clear();
|
|
@@ -596,14 +780,213 @@ function CanvasRouter({
|
|
|
596
780
|
<CommentsOverlay />
|
|
597
781
|
<AnnotationsLayer />
|
|
598
782
|
<ToolPalette />
|
|
783
|
+
<ArtboardMarqueeOverlay />
|
|
599
784
|
<HoverHalo el={hoverEl} />
|
|
600
785
|
<SelectionHalos />
|
|
601
786
|
<GroupBbox />
|
|
787
|
+
<MultiArtboardToolbar distributeArtboards={distributeArtboards} />
|
|
602
788
|
<SnapGuideOverlay />
|
|
603
789
|
</>
|
|
604
790
|
);
|
|
605
791
|
}
|
|
606
792
|
|
|
793
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
794
|
+
// MultiArtboardToolbar — floating chrome anchored above the group bbox when
|
|
795
|
+
// ≥ 2 artboards are multi-selected. Primary surface for the distribute
|
|
796
|
+
// commands (post-Wave-2 feedback preferred this over global ⌘⌥H / ⌘⌥V
|
|
797
|
+
// hotkeys). Disabled state when fewer than 3 artboards are selected — the
|
|
798
|
+
// math is undefined for 2.
|
|
799
|
+
|
|
800
|
+
const MULTI_TOOLBAR_CSS = `
|
|
801
|
+
.dc-multi-artboard-tb {
|
|
802
|
+
position: fixed;
|
|
803
|
+
pointer-events: auto;
|
|
804
|
+
z-index: 6;
|
|
805
|
+
display: none;
|
|
806
|
+
align-items: stretch;
|
|
807
|
+
gap: 2px;
|
|
808
|
+
padding: 4px;
|
|
809
|
+
background: var(--u-bg-0, var(--bg-0, #ffffff));
|
|
810
|
+
border: 1px solid var(--u-fg-0, #1c1917);
|
|
811
|
+
border-radius: 8px;
|
|
812
|
+
box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
|
|
813
|
+
font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
814
|
+
font-size: 11px;
|
|
815
|
+
letter-spacing: 0.02em;
|
|
816
|
+
color: var(--u-fg-0, #1a1a1a);
|
|
817
|
+
user-select: none;
|
|
818
|
+
}
|
|
819
|
+
.dc-multi-artboard-tb button {
|
|
820
|
+
appearance: none;
|
|
821
|
+
background: transparent;
|
|
822
|
+
border: 0;
|
|
823
|
+
border-radius: 6px;
|
|
824
|
+
padding: 4px 10px;
|
|
825
|
+
font: inherit;
|
|
826
|
+
cursor: pointer;
|
|
827
|
+
color: inherit;
|
|
828
|
+
display: inline-flex;
|
|
829
|
+
align-items: center;
|
|
830
|
+
gap: 6px;
|
|
831
|
+
transition: background-color 80ms linear;
|
|
832
|
+
}
|
|
833
|
+
.dc-multi-artboard-tb button:hover:not(:disabled) {
|
|
834
|
+
background: color-mix(in oklab, var(--accent, #d63b1f) 8%, transparent);
|
|
835
|
+
}
|
|
836
|
+
.dc-multi-artboard-tb button:disabled {
|
|
837
|
+
cursor: default;
|
|
838
|
+
opacity: 0.4;
|
|
839
|
+
}
|
|
840
|
+
.dc-multi-artboard-tb .dc-mab-count {
|
|
841
|
+
padding: 4px 8px 4px 10px;
|
|
842
|
+
color: var(--fg-1, rgba(40,30,20,0.7));
|
|
843
|
+
border-right: 1px solid var(--u-border-subtle, rgba(0,0,0,0.08));
|
|
844
|
+
margin-right: 2px;
|
|
845
|
+
font-variant-numeric: tabular-nums;
|
|
846
|
+
}
|
|
847
|
+
.dc-multi-artboard-tb .dc-mab-icon {
|
|
848
|
+
display: inline-flex;
|
|
849
|
+
align-items: center;
|
|
850
|
+
justify-content: center;
|
|
851
|
+
}
|
|
852
|
+
@media (prefers-reduced-motion: reduce) {
|
|
853
|
+
.dc-multi-artboard-tb button { transition: none; }
|
|
854
|
+
}
|
|
855
|
+
`.trim();
|
|
856
|
+
|
|
857
|
+
function ensureMultiToolbarStyles(): void {
|
|
858
|
+
if (typeof document === 'undefined') return;
|
|
859
|
+
if (document.getElementById('dc-multi-artboard-tb-css')) return;
|
|
860
|
+
const s = document.createElement('style');
|
|
861
|
+
s.id = 'dc-multi-artboard-tb-css';
|
|
862
|
+
s.textContent = MULTI_TOOLBAR_CSS;
|
|
863
|
+
document.head.appendChild(s);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function MultiArtboardToolbar({
|
|
867
|
+
distributeArtboards,
|
|
868
|
+
}: {
|
|
869
|
+
distributeArtboards: (axis: 'x' | 'y') => void;
|
|
870
|
+
}) {
|
|
871
|
+
ensureMultiToolbarStyles();
|
|
872
|
+
const { selected } = useSelectionSet();
|
|
873
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
874
|
+
const rafRef = useRef<number | null>(null);
|
|
875
|
+
|
|
876
|
+
const artboardSelections = useMemo(() => selected.filter((s) => !!s.artboardId), [selected]);
|
|
877
|
+
const artboardCount = artboardSelections.length;
|
|
878
|
+
const enabled = artboardCount >= 3;
|
|
879
|
+
|
|
880
|
+
useEffect(() => {
|
|
881
|
+
const div = ref.current;
|
|
882
|
+
if (!div) return;
|
|
883
|
+
if (artboardCount < 2) {
|
|
884
|
+
div.style.display = 'none';
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
// Track the screen-coord union bbox of all selected artboards each
|
|
888
|
+
// frame. Anchor toolbar centered horizontally above the bbox top
|
|
889
|
+
// edge with a 14 px gap; flip BELOW if the top edge is < 60 px from
|
|
890
|
+
// the viewport top.
|
|
891
|
+
const tick = () => {
|
|
892
|
+
rafRef.current = null;
|
|
893
|
+
let xMin = Number.POSITIVE_INFINITY;
|
|
894
|
+
let yMin = Number.POSITIVE_INFINITY;
|
|
895
|
+
let xMax = Number.NEGATIVE_INFINITY;
|
|
896
|
+
let yMax = Number.NEGATIVE_INFINITY;
|
|
897
|
+
let any = false;
|
|
898
|
+
for (const sel of artboardSelections) {
|
|
899
|
+
const el = document.querySelector(`[data-dc-screen="${sel.artboardId}"]`);
|
|
900
|
+
if (!el) continue;
|
|
901
|
+
const r = (el as HTMLElement).getBoundingClientRect();
|
|
902
|
+
if (r.width === 0 && r.height === 0) continue;
|
|
903
|
+
any = true;
|
|
904
|
+
if (r.left < xMin) xMin = r.left;
|
|
905
|
+
if (r.top < yMin) yMin = r.top;
|
|
906
|
+
if (r.right > xMax) xMax = r.right;
|
|
907
|
+
if (r.bottom > yMax) yMax = r.bottom;
|
|
908
|
+
}
|
|
909
|
+
if (!any) {
|
|
910
|
+
div.style.display = 'none';
|
|
911
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
// Make the toolbar visible OFFSCREEN first to measure its own
|
|
915
|
+
// width, then move it to the anchor. (Cheap: only re-anchors on
|
|
916
|
+
// every frame after the first.)
|
|
917
|
+
div.style.display = 'flex';
|
|
918
|
+
const tw = div.offsetWidth || 0;
|
|
919
|
+
const centerX = (xMin + xMax) / 2;
|
|
920
|
+
const top = yMin;
|
|
921
|
+
const gap = 14;
|
|
922
|
+
let anchorY = top - div.offsetHeight - gap;
|
|
923
|
+
if (anchorY < 60) anchorY = yMax + gap; // flip below
|
|
924
|
+
div.style.left = `${Math.round(centerX - tw / 2)}px`;
|
|
925
|
+
div.style.top = `${Math.round(anchorY)}px`;
|
|
926
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
927
|
+
};
|
|
928
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
929
|
+
return () => {
|
|
930
|
+
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
|
931
|
+
};
|
|
932
|
+
}, [artboardCount, artboardSelections]);
|
|
933
|
+
|
|
934
|
+
if (artboardCount < 2) {
|
|
935
|
+
return <div ref={ref} className="dc-multi-artboard-tb" aria-hidden="true" />;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
return (
|
|
939
|
+
<div
|
|
940
|
+
ref={ref}
|
|
941
|
+
className="dc-multi-artboard-tb"
|
|
942
|
+
role="toolbar"
|
|
943
|
+
aria-label="Multi-artboard actions"
|
|
944
|
+
>
|
|
945
|
+
<span className="dc-mab-count">{artboardCount} artboards</span>
|
|
946
|
+
<button
|
|
947
|
+
type="button"
|
|
948
|
+
disabled={!enabled}
|
|
949
|
+
title={
|
|
950
|
+
enabled
|
|
951
|
+
? 'Distribute horizontally — equal gaps between artboards'
|
|
952
|
+
: 'Select at least 3 artboards to distribute'
|
|
953
|
+
}
|
|
954
|
+
aria-label="Distribute horizontally"
|
|
955
|
+
onClick={() => distributeArtboards('x')}
|
|
956
|
+
>
|
|
957
|
+
<span className="dc-mab-icon" aria-hidden="true">
|
|
958
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
959
|
+
<rect x="0.5" y="3" width="3" height="8" fill="currentColor" />
|
|
960
|
+
<rect x="5.5" y="3" width="3" height="8" fill="currentColor" />
|
|
961
|
+
<rect x="10.5" y="3" width="3" height="8" fill="currentColor" />
|
|
962
|
+
</svg>
|
|
963
|
+
</span>
|
|
964
|
+
Distribute H
|
|
965
|
+
</button>
|
|
966
|
+
<button
|
|
967
|
+
type="button"
|
|
968
|
+
disabled={!enabled}
|
|
969
|
+
title={
|
|
970
|
+
enabled
|
|
971
|
+
? 'Distribute vertically — equal gaps between artboards'
|
|
972
|
+
: 'Select at least 3 artboards to distribute'
|
|
973
|
+
}
|
|
974
|
+
aria-label="Distribute vertically"
|
|
975
|
+
onClick={() => distributeArtboards('y')}
|
|
976
|
+
>
|
|
977
|
+
<span className="dc-mab-icon" aria-hidden="true">
|
|
978
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
979
|
+
<rect x="3" y="0.5" width="8" height="3" fill="currentColor" />
|
|
980
|
+
<rect x="3" y="5.5" width="8" height="3" fill="currentColor" />
|
|
981
|
+
<rect x="3" y="10.5" width="8" height="3" fill="currentColor" />
|
|
982
|
+
</svg>
|
|
983
|
+
</span>
|
|
984
|
+
Distribute V
|
|
985
|
+
</button>
|
|
986
|
+
</div>
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
|
|
607
990
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
608
991
|
// HoverHalo — single floating overlay tracking the hovered element's screen
|
|
609
992
|
// bounds. Updates on every animation frame while mounted. Position: fixed so
|
|
@@ -654,6 +1037,27 @@ function HoverHalo({ el }: { el: Element | null }) {
|
|
|
654
1037
|
// SelectionHalos — N floating overlays, one per selected element. Resolves
|
|
655
1038
|
// elements by `data-cd-id` when present, falling back to the selector path.
|
|
656
1039
|
|
|
1040
|
+
// DDR-046 — single-select halo carries 4 corner ticks; multi-select members get
|
|
1041
|
+
// the lighter 1px tinted outline (no ticks). The GroupBbox renders the loud
|
|
1042
|
+
// signal when selected.length > 1.
|
|
1043
|
+
const TICK_CLASSES = ['tick-tl', 'tick-tr', 'tick-bl', 'tick-br'] as const;
|
|
1044
|
+
|
|
1045
|
+
function makeSelectedNode(withTicks: boolean): HTMLDivElement {
|
|
1046
|
+
const child = document.createElement('div');
|
|
1047
|
+
child.className = withTicks
|
|
1048
|
+
? 'dc-cv-halo dc-cv-halo--selected'
|
|
1049
|
+
: 'dc-cv-halo dc-cv-halo--selected-member';
|
|
1050
|
+
child.setAttribute('aria-hidden', 'true');
|
|
1051
|
+
if (withTicks) {
|
|
1052
|
+
for (const cls of TICK_CLASSES) {
|
|
1053
|
+
const tick = document.createElement('i');
|
|
1054
|
+
tick.className = `tick ${cls}`;
|
|
1055
|
+
child.appendChild(tick);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
return child;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
657
1061
|
function SelectionHalos() {
|
|
658
1062
|
const { selected } = useSelectionSet();
|
|
659
1063
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
@@ -669,16 +1073,34 @@ function SelectionHalos() {
|
|
|
669
1073
|
rafRef.current = null;
|
|
670
1074
|
const c = containerRef.current;
|
|
671
1075
|
if (!c) return;
|
|
672
|
-
|
|
1076
|
+
const wantsTicks = selected.length === 1;
|
|
1077
|
+
const wantClass = wantsTicks
|
|
1078
|
+
? 'dc-cv-halo dc-cv-halo--selected'
|
|
1079
|
+
: 'dc-cv-halo dc-cv-halo--selected-member';
|
|
1080
|
+
// Match rendered halo count to selected.length; reuse DOM nodes.
|
|
673
1081
|
while (c.children.length < selected.length) {
|
|
674
|
-
|
|
675
|
-
child.className = 'dc-cv-halo dc-cv-halo--selected';
|
|
676
|
-
child.setAttribute('aria-hidden', 'true');
|
|
677
|
-
c.appendChild(child);
|
|
1082
|
+
c.appendChild(makeSelectedNode(wantsTicks));
|
|
678
1083
|
}
|
|
679
1084
|
while (c.children.length > selected.length) {
|
|
680
1085
|
c.removeChild(c.lastChild as Node);
|
|
681
1086
|
}
|
|
1087
|
+
// When selection size crosses 1↔N+, swap class + tick children on each
|
|
1088
|
+
// existing node so reused nodes adopt the new visual idiom.
|
|
1089
|
+
for (let i = 0; i < c.children.length; i++) {
|
|
1090
|
+
const child = c.children[i] as HTMLDivElement;
|
|
1091
|
+
if (child.className !== wantClass) {
|
|
1092
|
+
child.className = wantClass;
|
|
1093
|
+
// Strip any existing ticks, then re-append if needed.
|
|
1094
|
+
while (child.firstChild) child.removeChild(child.firstChild);
|
|
1095
|
+
if (wantsTicks) {
|
|
1096
|
+
for (const cls of TICK_CLASSES) {
|
|
1097
|
+
const t = document.createElement('i');
|
|
1098
|
+
t.className = `tick ${cls}`;
|
|
1099
|
+
child.appendChild(t);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
682
1104
|
for (let i = 0; i < selected.length; i++) {
|
|
683
1105
|
const sel = selected[i];
|
|
684
1106
|
const child = c.children[i] as HTMLDivElement;
|
|
@@ -691,6 +1113,9 @@ function SelectionHalos() {
|
|
|
691
1113
|
child.style.display = 'none';
|
|
692
1114
|
continue;
|
|
693
1115
|
}
|
|
1116
|
+
// Post-Wave-2: artboard drag is now direct (article updates its own
|
|
1117
|
+
// `left/top` in real-time), so the halo can follow via
|
|
1118
|
+
// getBoundingClientRect — no special drag suppression needed.
|
|
694
1119
|
const r = (el as HTMLElement).getBoundingClientRect();
|
|
695
1120
|
if (r.width === 0 && r.height === 0) {
|
|
696
1121
|
child.style.display = 'none';
|
|
@@ -740,6 +1165,9 @@ function GroupBbox() {
|
|
|
740
1165
|
? document.querySelector(`[data-cd-id="${cssEscape(sel.id)}"]`)
|
|
741
1166
|
: safeQuery(sel.selector);
|
|
742
1167
|
if (!el) continue;
|
|
1168
|
+
// Post-Wave-2: direct artboard drag — the article updates its own
|
|
1169
|
+
// `left/top` during drag, so the group bbox just follows via
|
|
1170
|
+
// getBoundingClientRect with no special-casing.
|
|
743
1171
|
const r = (el as HTMLElement).getBoundingClientRect();
|
|
744
1172
|
if (r.width === 0 && r.height === 0) continue;
|
|
745
1173
|
anyHit = true;
|
|
@@ -767,7 +1195,13 @@ function GroupBbox() {
|
|
|
767
1195
|
}, [selected]);
|
|
768
1196
|
|
|
769
1197
|
if (selected.length < 2) return null;
|
|
770
|
-
return
|
|
1198
|
+
return (
|
|
1199
|
+
<div ref={ref} className="dc-cv-group-bbox" aria-hidden="true">
|
|
1200
|
+
{TICK_CLASSES.map((cls) => (
|
|
1201
|
+
<i key={cls} className={`tick ${cls}`} />
|
|
1202
|
+
))}
|
|
1203
|
+
</div>
|
|
1204
|
+
);
|
|
771
1205
|
}
|
|
772
1206
|
|
|
773
1207
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -795,10 +1229,20 @@ function hoverTargetToSelection(target: HoverTarget): Selection {
|
|
|
795
1229
|
// climbs to an ancestor. Falls back to cssPath of the hit when no stable
|
|
796
1230
|
// anchor exists.
|
|
797
1231
|
const cdId = target.cdId;
|
|
1232
|
+
// Selector resolution order:
|
|
1233
|
+
// 1. data-cd-id anchor — stable pipeline-stamped id (preferred).
|
|
1234
|
+
// 2. data-dc-screen — chrome click promoted to whole-artboard select
|
|
1235
|
+
// (T24.5 G8 multi-artboard gesture).
|
|
1236
|
+
// 3. cssPath of the hit — last-resort path string.
|
|
1237
|
+
const selector = cdId
|
|
1238
|
+
? `[data-cd-id="${cdId}"]`
|
|
1239
|
+
: !cdId && target.artboardId
|
|
1240
|
+
? `[data-dc-screen="${target.artboardId}"]`
|
|
1241
|
+
: cssPath(el);
|
|
798
1242
|
return {
|
|
799
1243
|
file: typeof window !== 'undefined' ? deriveFile() : undefined,
|
|
800
1244
|
id: cdId ?? undefined,
|
|
801
|
-
selector
|
|
1245
|
+
selector,
|
|
802
1246
|
artboardId: target.artboardId,
|
|
803
1247
|
tag: el?.tagName.toLowerCase() ?? '',
|
|
804
1248
|
classes: realClasses(el),
|