@1agh/maude 0.19.0 → 0.20.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/package.json +9 -9
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +126 -44
- package/plugins/design/dev-server/annotations-layer.tsx +343 -112
- package/plugins/design/dev-server/api.ts +70 -17
- package/plugins/design/dev-server/artboard-marquee.tsx +174 -0
- package/plugins/design/dev-server/bin/asset-sweep.sh +180 -0
- package/plugins/design/dev-server/bin/visual-sanity.sh +221 -0
- package/plugins/design/dev-server/canvas-lib.tsx +691 -119
- package/plugins/design/dev-server/canvas-shell.tsx +818 -42
- package/plugins/design/dev-server/client/app.jsx +173 -52
- package/plugins/design/dev-server/client/hmr.mjs +28 -0
- 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/commands/annotation-strokes-command.ts +127 -0
- package/plugins/design/dev-server/commands/equal-spacing-command.ts +28 -0
- package/plugins/design/dev-server/commands/move-artboards-command.ts +158 -0
- package/plugins/design/dev-server/comments-overlay.tsx +12 -4
- 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/contextual-toolbar.tsx +241 -0
- package/plugins/design/dev-server/dist/client.bundle.js +212 -103
- package/plugins/design/dev-server/dist/runtime/motion.js +165 -0
- package/plugins/design/dev-server/dist/runtime/motion_react.js +409 -0
- package/plugins/design/dev-server/dist/styles.css +38 -2
- package/plugins/design/dev-server/equal-spacing-detector.ts +98 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +289 -0
- package/plugins/design/dev-server/export-dialog.tsx +3 -3
- package/plugins/design/dev-server/handoff.ts +24 -0
- package/plugins/design/dev-server/http.ts +41 -2
- package/plugins/design/dev-server/input-router.tsx +74 -10
- package/plugins/design/dev-server/marquee-overlay.tsx +282 -0
- package/plugins/design/dev-server/runtime-bundle.ts +7 -0
- package/plugins/design/dev-server/server.mjs +84 -18
- package/plugins/design/dev-server/test/annotation-strokes-command.test.ts +149 -0
- package/plugins/design/dev-server/test/annotations-layer.test.ts +44 -0
- package/plugins/design/dev-server/test/canvas-lib-motion.test.ts +131 -0
- package/plugins/design/dev-server/test/context-resolve-tokens.test.ts +97 -0
- package/plugins/design/dev-server/test/equal-spacing-detector.test.ts +93 -0
- package/plugins/design/dev-server/test/input-router.test.ts +87 -1
- package/plugins/design/dev-server/test/marquee-overlay.test.ts +94 -0
- package/plugins/design/dev-server/test/move-artboards-command.test.ts +108 -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/test/undo-stack.test.ts +211 -0
- package/plugins/design/dev-server/test/use-cursor-modifiers.test.ts +59 -0
- package/plugins/design/dev-server/test/use-keyboard-discipline.test.ts +27 -0
- package/plugins/design/dev-server/test/use-undo-stack.test.tsx +193 -0
- package/plugins/design/dev-server/tool-palette.tsx +71 -15
- package/plugins/design/dev-server/undo-hud.tsx +95 -0
- package/plugins/design/dev-server/undo-stack.ts +240 -0
- package/plugins/design/dev-server/use-annotation-resize.tsx +295 -0
- package/plugins/design/dev-server/use-artboard-drag.tsx +6 -3
- package/plugins/design/dev-server/use-cursor-modifiers.tsx +122 -0
- package/plugins/design/dev-server/use-keyboard-discipline.tsx +125 -0
- package/plugins/design/dev-server/use-snap-guides.tsx +25 -1
- package/plugins/design/dev-server/use-tool-mode.tsx +31 -2
- package/plugins/design/dev-server/use-undo-stack.tsx +355 -0
- package/plugins/design/templates/_shell.html +17 -6
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +245 -0
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +2 -2
- package/plugins/design/templates/design-system-inspiration/core/preview/_components.css.tpl +129 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +63 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/motion.css.tpl +106 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/motion.tsx.tpl +208 -0
- /package/plugins/design/templates/design-system-inspiration/core/preview/{motion.html → .archive/motion.html} +0 -0
|
@@ -37,11 +37,16 @@ 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';
|
|
49
|
+
import { type AlignMode, alignLabel, equalSpacingLabel } from './commands/equal-spacing-command.ts';
|
|
45
50
|
import { CommentsOverlay } from './comments-overlay.tsx';
|
|
46
51
|
import {
|
|
47
52
|
ContextMenuProvider,
|
|
@@ -51,16 +56,23 @@ import {
|
|
|
51
56
|
type MenuItem,
|
|
52
57
|
useContextMenu,
|
|
53
58
|
} from './context-menu.tsx';
|
|
59
|
+
import { ContextualToolbar } from './contextual-toolbar.tsx';
|
|
60
|
+
import { EqualSpacingHandles } from './equal-spacing-handles.tsx';
|
|
54
61
|
import { ExportDialogProvider } from './export-dialog.tsx';
|
|
55
62
|
import { type HoverTarget, resolveHoverTarget, useInputRouter } from './input-router.tsx';
|
|
63
|
+
import { ElementMarqueeOverlay } from './marquee-overlay.tsx';
|
|
56
64
|
import { ToolPalette } from './tool-palette.tsx';
|
|
65
|
+
import { UndoHud } from './undo-hud.tsx';
|
|
57
66
|
import {
|
|
58
67
|
AnnotationSelectionProvider,
|
|
59
68
|
useAnnotationSelection,
|
|
60
69
|
} from './use-annotation-selection.tsx';
|
|
61
70
|
import { AnnotationsVisibilityProvider } from './use-annotations-visibility.tsx';
|
|
71
|
+
import { useCursorModifiers } from './use-cursor-modifiers.tsx';
|
|
72
|
+
import { useKeyboardDiscipline } from './use-keyboard-discipline.tsx';
|
|
62
73
|
import { type Selection, SelectionSetProvider, useSelectionSet } from './use-selection-set.tsx';
|
|
63
74
|
import { useToolMode } from './use-tool-mode.tsx';
|
|
75
|
+
import { useUndoStack } from './use-undo-stack.tsx';
|
|
64
76
|
|
|
65
77
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
66
78
|
// Styles — halos render as `position: fixed` siblings of the canvas. Reading
|
|
@@ -69,43 +81,100 @@ import { useToolMode } from './use-tool-mode.tsx';
|
|
|
69
81
|
// plane would otherwise scale a 2 px outline to 0.84 px at 42 % zoom (subpixel
|
|
70
82
|
// = invisible). No per-element class stamping is used.
|
|
71
83
|
|
|
84
|
+
// DDR-046 — Three-state halo language. Each state has its own border weight,
|
|
85
|
+
// color treatment, and geometric idiom so 8+ semantic states (hover / selected
|
|
86
|
+
// / member-of-multi / group / snap-sibling / snap-grid / marquee / annotation
|
|
87
|
+
// / active-artboard) stay visually distinct. Painting one with another's
|
|
88
|
+
// idiom is a regression.
|
|
72
89
|
const HALO_CSS = `
|
|
73
90
|
.dc-cv-halo {
|
|
74
91
|
position: fixed;
|
|
75
92
|
pointer-events: none;
|
|
76
93
|
z-index: 5;
|
|
77
|
-
border: 2px solid var(--accent, #d63b1f);
|
|
78
94
|
box-sizing: border-box;
|
|
79
95
|
border-radius: 2px;
|
|
80
96
|
transition: opacity 60ms linear;
|
|
81
97
|
}
|
|
98
|
+
/* Hover — lighter 1.5px tinted line + white inner ring for contrast on dark
|
|
99
|
+
elements. NO ring, NO ticks. Synchronous paint (no debounce). */
|
|
82
100
|
.dc-cv-halo--hover {
|
|
83
|
-
border
|
|
84
|
-
|
|
101
|
+
border: 1.5px solid color-mix(in oklab, var(--accent, #0d99ff) 60%, transparent);
|
|
102
|
+
box-shadow: inset 0 0 0 1px var(--bg-0, #ffffff);
|
|
85
103
|
}
|
|
104
|
+
/* Selected (single) — 2px solid + 18% ring halo + 4 filled corner ticks.
|
|
105
|
+
Ticks are <i class="tick tick-*"> children at inset:-3px, 8x8, accent fill. */
|
|
86
106
|
.dc-cv-halo--selected {
|
|
87
|
-
border
|
|
88
|
-
box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent, #
|
|
107
|
+
border: 2px solid var(--accent, #0d99ff);
|
|
108
|
+
box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent, #0d99ff) 18%, transparent);
|
|
89
109
|
}
|
|
110
|
+
.dc-cv-halo--selected .tick {
|
|
111
|
+
position: absolute;
|
|
112
|
+
width: 8px;
|
|
113
|
+
height: 8px;
|
|
114
|
+
background: var(--accent, #0d99ff);
|
|
115
|
+
border-radius: 1px;
|
|
116
|
+
box-shadow: 0 0 0 1px var(--bg-0, #ffffff);
|
|
117
|
+
}
|
|
118
|
+
.dc-cv-halo--selected .tick-tl { top: -3px; left: -3px; }
|
|
119
|
+
.dc-cv-halo--selected .tick-tr { top: -3px; right: -3px; }
|
|
120
|
+
.dc-cv-halo--selected .tick-bl { bottom: -3px; left: -3px; }
|
|
121
|
+
.dc-cv-halo--selected .tick-br { bottom: -3px; right: -3px; }
|
|
122
|
+
/* Selected (member of multi-selection) — 1.5 px solid full-accent outline.
|
|
123
|
+
No ring, no ticks (the group bbox above carries the container signal).
|
|
124
|
+
T16 / DDR-046 rev 2 — full opacity instead of 50%-tinted: tinted lines read
|
|
125
|
+
as "draft / placeholder" and members would melt away inside artboards once
|
|
126
|
+
the artboard border itself is 22%-tinted (T15). */
|
|
127
|
+
.dc-cv-halo--selected-member {
|
|
128
|
+
border: 1.5px solid var(--accent, #0d99ff);
|
|
129
|
+
}
|
|
130
|
+
/* Group bbox — 1 px DASHED full accent + four 6 × 6 square corner handles.
|
|
131
|
+
T16 / DDR-046 rev 2 — dashed is the canonical group-container affordance
|
|
132
|
+
(Figma group bbox, FigJam Section drag-state, Photoshop marching ants).
|
|
133
|
+
Dashed reads "ambient binding" without claiming subject-ness; the loud
|
|
134
|
+
solid outlines on each MEMBER carry the active-selection signal. Corner
|
|
135
|
+
handles are 6 × 6 (vs single-select's 8 × 8 ticks) so the group idiom
|
|
136
|
+
reads as "thinner authority" than single-select. */
|
|
90
137
|
.dc-cv-group-bbox {
|
|
91
138
|
position: fixed;
|
|
92
139
|
pointer-events: none;
|
|
93
140
|
z-index: 5;
|
|
94
|
-
border: 1px dashed var(--accent, #
|
|
141
|
+
border: 1px dashed var(--accent, #0d99ff);
|
|
95
142
|
border-radius: 2px;
|
|
96
|
-
opacity: 0.85;
|
|
97
143
|
}
|
|
144
|
+
.dc-cv-group-bbox .tick {
|
|
145
|
+
position: absolute;
|
|
146
|
+
width: 6px;
|
|
147
|
+
height: 6px;
|
|
148
|
+
background: var(--accent, #0d99ff);
|
|
149
|
+
border-radius: 1px;
|
|
150
|
+
box-shadow: 0 0 0 1px var(--bg-0, #ffffff);
|
|
151
|
+
}
|
|
152
|
+
.dc-cv-group-bbox .tick-tl { top: -3px; left: -3px; }
|
|
153
|
+
.dc-cv-group-bbox .tick-tr { top: -3px; right: -3px; }
|
|
154
|
+
.dc-cv-group-bbox .tick-bl { bottom: -3px; left: -3px; }
|
|
155
|
+
.dc-cv-group-bbox .tick-br { bottom: -3px; right: -3px; }
|
|
98
156
|
/*
|
|
99
157
|
* Active-artboard indicator — the artboard whose center sits closest to the
|
|
100
158
|
* viewport midpoint after pan settles is "active" (DesignCanvas tracks this
|
|
101
|
-
* for keyboard jumps + the /design:edit context anchor).
|
|
102
|
-
*
|
|
103
|
-
*
|
|
159
|
+
* for keyboard jumps + the /design:edit context anchor). DDR-046 — ring sits
|
|
160
|
+
* OUTSIDE the hard drop-shadow so it's visible at any pan distance / zoom.
|
|
161
|
+
* 120 ms ease-out so activation is felt, not invisible.
|
|
104
162
|
*/
|
|
105
163
|
.dc-canvas .dc-artboard[aria-current="true"] {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
164
|
+
/* T15 — quiet frame, single 2 px accent ring. The previous double-shadow
|
|
165
|
+
(3 px ring + hard 6×6×0 offset) was readable but visually expensive once
|
|
166
|
+
the frame itself lost its brutalist treatment. A 2 px ring on a 22 %
|
|
167
|
+
tinted hairline reads unambiguous without claiming subject-ness. */
|
|
168
|
+
box-shadow: 0 0 0 2px var(--accent, #0d99ff);
|
|
169
|
+
transition: box-shadow 120ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
170
|
+
}
|
|
171
|
+
/* Respect prefers-reduced-motion across all chrome transitions. */
|
|
172
|
+
@media (prefers-reduced-motion: reduce) {
|
|
173
|
+
.dc-cv-halo,
|
|
174
|
+
.dc-cv-group-bbox,
|
|
175
|
+
.dc-canvas .dc-artboard[aria-current="true"] {
|
|
176
|
+
transition: none !important;
|
|
177
|
+
}
|
|
109
178
|
}
|
|
110
179
|
/*
|
|
111
180
|
* Force tool cursor across the canvas tree in comment / hand modes. Without
|
|
@@ -114,17 +183,37 @@ const HALO_CSS = `
|
|
|
114
183
|
* the user hovers an interactive element — wrong signal when native
|
|
115
184
|
* interactions are suppressed by the router anyway.
|
|
116
185
|
*/
|
|
186
|
+
/* T22 — per-tool SVG cursors. Each cursor is a 16 × 16 SVG data-URI with a
|
|
187
|
+
declared hotspot. The crosshair / cell / grab fallbacks remain in the
|
|
188
|
+
chain so older browsers still get a recognisable affordance. SVGs are
|
|
189
|
+
utf-8 encoded inline (Chromium / Safari 16+ / Firefox 117+ all accept). */
|
|
190
|
+
.dc-canvas {
|
|
191
|
+
--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;
|
|
192
|
+
--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;
|
|
193
|
+
--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;
|
|
194
|
+
--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;
|
|
195
|
+
--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;
|
|
196
|
+
--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;
|
|
197
|
+
}
|
|
117
198
|
.dc-canvas[data-active-tool="comment"],
|
|
118
|
-
.dc-canvas[data-active-tool="comment"]
|
|
199
|
+
.dc-canvas[data-active-tool="comment"] * {
|
|
200
|
+
cursor: var(--cursor-comment), crosshair !important;
|
|
201
|
+
}
|
|
119
202
|
.dc-canvas[data-active-tool="pen"],
|
|
120
|
-
.dc-canvas[data-active-tool="pen"]
|
|
203
|
+
.dc-canvas[data-active-tool="pen"] * {
|
|
204
|
+
cursor: var(--cursor-pen), crosshair !important;
|
|
205
|
+
}
|
|
121
206
|
.dc-canvas[data-active-tool="rect"],
|
|
122
|
-
.dc-canvas[data-active-tool="rect"]
|
|
207
|
+
.dc-canvas[data-active-tool="rect"] * {
|
|
208
|
+
cursor: var(--cursor-rect), crosshair !important;
|
|
209
|
+
}
|
|
123
210
|
.dc-canvas[data-active-tool="ellipse"],
|
|
124
|
-
.dc-canvas[data-active-tool="ellipse"]
|
|
211
|
+
.dc-canvas[data-active-tool="ellipse"] * {
|
|
212
|
+
cursor: var(--cursor-ellipse), crosshair !important;
|
|
213
|
+
}
|
|
125
214
|
.dc-canvas[data-active-tool="arrow"],
|
|
126
215
|
.dc-canvas[data-active-tool="arrow"] * {
|
|
127
|
-
cursor: crosshair !important;
|
|
216
|
+
cursor: var(--cursor-arrow), crosshair !important;
|
|
128
217
|
}
|
|
129
218
|
.dc-canvas[data-active-tool="hand"],
|
|
130
219
|
.dc-canvas[data-active-tool="hand"] * {
|
|
@@ -132,7 +221,27 @@ const HALO_CSS = `
|
|
|
132
221
|
}
|
|
133
222
|
.dc-canvas[data-active-tool="eraser"],
|
|
134
223
|
.dc-canvas[data-active-tool="eraser"] * {
|
|
135
|
-
cursor: cell !important;
|
|
224
|
+
cursor: var(--cursor-eraser), cell !important;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/* T31 — Level of detail. Below 0.35 zoom we hide pre-attentive chrome that
|
|
228
|
+
becomes visual noise (corner ticks, distance pills, active-artboard ring,
|
|
229
|
+
snap pills). Above 4.0 we coax the browser into crisper text rendering.
|
|
230
|
+
The "normal" band 0.35..4.0 carries the full chrome. */
|
|
231
|
+
.dc-canvas[data-cv-zoom-lod="low"] .dc-cv-halo .tick,
|
|
232
|
+
.dc-canvas[data-cv-zoom-lod="low"] .dc-cv-group-bbox .tick {
|
|
233
|
+
display: none;
|
|
234
|
+
}
|
|
235
|
+
.dc-canvas[data-cv-zoom-lod="low"] .dc-snap-pill,
|
|
236
|
+
.dc-canvas[data-cv-zoom-lod="low"] .dc-cv-eq-pill {
|
|
237
|
+
display: none;
|
|
238
|
+
}
|
|
239
|
+
.dc-canvas[data-cv-zoom-lod="low"] .dc-artboard[aria-current="true"] {
|
|
240
|
+
box-shadow: none;
|
|
241
|
+
}
|
|
242
|
+
.dc-canvas[data-cv-zoom-lod="crisp"] {
|
|
243
|
+
-webkit-font-smoothing: subpixel-antialiased;
|
|
244
|
+
font-smooth: always;
|
|
136
245
|
}
|
|
137
246
|
`.trim();
|
|
138
247
|
|
|
@@ -198,15 +307,223 @@ function CanvasCore({
|
|
|
198
307
|
};
|
|
199
308
|
}, [hostRef, tool]);
|
|
200
309
|
|
|
310
|
+
// T28 — modifier-aware cursor (Alt → copy on cd-id, Shift → crosshair on
|
|
311
|
+
// body padding). CSS-driven once data-mod-* is reflected on the host.
|
|
312
|
+
useCursorModifiers(hostRef);
|
|
313
|
+
// T29 — arrow nudge (artboards) + Cmd+A select-all (active artboard).
|
|
314
|
+
// Cmd+D duplicate deferred; no live duplicate channel for either artboards
|
|
315
|
+
// or stamped elements yet.
|
|
316
|
+
useKeyboardDiscipline();
|
|
317
|
+
|
|
318
|
+
const artboardsCtx = useArtboardsContext();
|
|
319
|
+
const dragBus = useDragStateContext();
|
|
320
|
+
|
|
321
|
+
// T33 — programmatic-zoom easing via double-click on empty world only.
|
|
322
|
+
// Per post-Wave-3 user feedback, dblclick-on-artboard auto-zoom was
|
|
323
|
+
// surprising (interfered with native dblclick text-select inside chrome
|
|
324
|
+
// and felt magnetic). We keep the dblclick-empty → `fit()` path because
|
|
325
|
+
// it's a discoverable "back to overview" gesture; artboard zoom is still
|
|
326
|
+
// reachable via Cmd+1 and the zoom HUD.
|
|
327
|
+
useEffect(() => {
|
|
328
|
+
if (!controller) return;
|
|
329
|
+
const host = hostRef.current;
|
|
330
|
+
if (!host) return;
|
|
331
|
+
const onDbl = (e: MouseEvent) => {
|
|
332
|
+
const t = e.target as Element | null;
|
|
333
|
+
if (!t || !t.closest) return;
|
|
334
|
+
// Floating chrome / overlays / drawn user content / any artboard
|
|
335
|
+
// surface → leave alone. Only dblclick that lands on the canvas
|
|
336
|
+
// background outside every artboard triggers `fit()`.
|
|
337
|
+
if (
|
|
338
|
+
t.closest(
|
|
339
|
+
'.dc-mm, .dc-zoom-tb, .dc-tool-palette, .dc-context-menu, .dc-annot-svg, .dc-annot-ctx, .cm-composer, .cm-thread, .cm-mention-popup, .cm-pin'
|
|
340
|
+
)
|
|
341
|
+
) {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (t.closest('[data-dc-screen]')) return; // any part of an artboard
|
|
345
|
+
e.preventDefault();
|
|
346
|
+
controller.fit();
|
|
347
|
+
};
|
|
348
|
+
host.addEventListener('dblclick', onDbl);
|
|
349
|
+
return () => host.removeEventListener('dblclick', onDbl);
|
|
350
|
+
}, [controller, hostRef]);
|
|
351
|
+
|
|
352
|
+
// T31 — level-of-detail attribute on `.dc-canvas`. CSS rules hide
|
|
353
|
+
// pre-attentive chrome (ticks, distance pills, accent ring) below 0.35
|
|
354
|
+
// zoom and sharpen text above 4.0. tldraw's textShadowLod = 0.35 is the
|
|
355
|
+
// canonical threshold. Reads the published viewport (settle-cadence) so
|
|
356
|
+
// the LOD doesn't flicker between bands mid-zoom — chrome should settle
|
|
357
|
+
// once per gesture, not on every frame.
|
|
358
|
+
const publishedZoom = artboardsCtx?.viewport?.zoom ?? 1;
|
|
359
|
+
useEffect(() => {
|
|
360
|
+
const host = hostRef.current;
|
|
361
|
+
if (!host) return;
|
|
362
|
+
const lod = publishedZoom < 0.35 ? 'low' : publishedZoom > 4 ? 'crisp' : 'normal';
|
|
363
|
+
host.setAttribute('data-cv-zoom-lod', lod);
|
|
364
|
+
return () => host.removeAttribute('data-cv-zoom-lod');
|
|
365
|
+
}, [hostRef, publishedZoom]);
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* T24 — distribute the currently-selected artboards evenly on the given
|
|
369
|
+
* axis. Requires ≥ 3 selected artboards. Sort by leading edge, hold the
|
|
370
|
+
* first + last in place, and reposition the middle artboards so the gaps
|
|
371
|
+
* between trailing edge → next leading edge are equal.
|
|
372
|
+
*/
|
|
373
|
+
const distributeArtboards = useCallback(
|
|
374
|
+
(axis: 'x' | 'y') => {
|
|
375
|
+
if (!artboardsCtx || !dragBus) return;
|
|
376
|
+
const ids = new Set(
|
|
377
|
+
selSet.selected.filter((s) => !!s.artboardId).map((s) => s.artboardId as string)
|
|
378
|
+
);
|
|
379
|
+
if (ids.size < 3) return;
|
|
380
|
+
const targets: ArtboardRect[] = artboardsCtx.artboards.filter((r) => ids.has(r.id));
|
|
381
|
+
if (targets.length < 3) return;
|
|
382
|
+
const sorted = [...targets].sort((a, b) => (axis === 'x' ? a.x - b.x : a.y - b.y));
|
|
383
|
+
const first = sorted[0];
|
|
384
|
+
const last = sorted[sorted.length - 1];
|
|
385
|
+
if (!first || !last) return;
|
|
386
|
+
// Total content extent (sum of side-lengths on the axis) + the span
|
|
387
|
+
// between first leading edge and last trailing edge gives us the gap
|
|
388
|
+
// budget to divide equally between (n-1) inter-artboard slots.
|
|
389
|
+
const sideLen = (r: ArtboardRect) => (axis === 'x' ? r.w : r.h);
|
|
390
|
+
const totalSides = sorted.reduce((acc, r) => acc + sideLen(r), 0);
|
|
391
|
+
const span =
|
|
392
|
+
(axis === 'x' ? last.x + last.w - first.x : last.y + last.h - first.y) - totalSides;
|
|
393
|
+
const gap = span / (sorted.length - 1);
|
|
394
|
+
const moved: { id: string; x: number; y: number }[] = [];
|
|
395
|
+
let cursor = axis === 'x' ? first.x + first.w + gap : first.y + first.h + gap;
|
|
396
|
+
for (let i = 1; i < sorted.length - 1; i++) {
|
|
397
|
+
const r = sorted[i];
|
|
398
|
+
if (!r) continue;
|
|
399
|
+
if (axis === 'x') {
|
|
400
|
+
moved.push({ id: r.id, x: Math.round(cursor), y: r.y });
|
|
401
|
+
cursor += r.w + gap;
|
|
402
|
+
} else {
|
|
403
|
+
moved.push({ id: r.id, x: r.x, y: Math.round(cursor) });
|
|
404
|
+
cursor += r.h + gap;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (moved.length === 0) return;
|
|
408
|
+
dragBus.commitPositions(moved, { label: equalSpacingLabel(sorted.length) });
|
|
409
|
+
},
|
|
410
|
+
[artboardsCtx, dragBus, selSet.selected]
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* G7 — align selected artboards to a common edge / midline. Requires ≥ 2
|
|
415
|
+
* selected artboards. Six modes:
|
|
416
|
+
* - 'left' → all artboards share the minimum x of the set
|
|
417
|
+
* - 'right' → all artboards share the maximum (x + w) edge
|
|
418
|
+
* - 'center-x' → all artboards share the midpoint of the set's x extent
|
|
419
|
+
* - 'top' → all artboards share the minimum y
|
|
420
|
+
* - 'bottom' → all artboards share the maximum (y + h) edge
|
|
421
|
+
* - 'center-y' → all artboards share the midpoint of the set's y extent
|
|
422
|
+
*
|
|
423
|
+
* Holds the reference edge constant; only the perpendicular axis stays as
|
|
424
|
+
* each artboard already was (alignment doesn't relocate on the orthogonal
|
|
425
|
+
* axis). This matches Figma / Sketch / FigJam align semantics.
|
|
426
|
+
*/
|
|
427
|
+
const alignArtboards = useCallback(
|
|
428
|
+
(mode: AlignMode) => {
|
|
429
|
+
if (!artboardsCtx || !dragBus) return;
|
|
430
|
+
const ids = new Set(
|
|
431
|
+
selSet.selected.filter((s) => !!s.artboardId).map((s) => s.artboardId as string)
|
|
432
|
+
);
|
|
433
|
+
if (ids.size < 2) return;
|
|
434
|
+
const targets: ArtboardRect[] = artboardsCtx.artboards.filter((r) => ids.has(r.id));
|
|
435
|
+
if (targets.length < 2) return;
|
|
436
|
+
|
|
437
|
+
// Union bbox for center modes.
|
|
438
|
+
let xMin = Number.POSITIVE_INFINITY;
|
|
439
|
+
let yMin = Number.POSITIVE_INFINITY;
|
|
440
|
+
let xMax = Number.NEGATIVE_INFINITY;
|
|
441
|
+
let yMax = Number.NEGATIVE_INFINITY;
|
|
442
|
+
for (const r of targets) {
|
|
443
|
+
if (r.x < xMin) xMin = r.x;
|
|
444
|
+
if (r.y < yMin) yMin = r.y;
|
|
445
|
+
if (r.x + r.w > xMax) xMax = r.x + r.w;
|
|
446
|
+
if (r.y + r.h > yMax) yMax = r.y + r.h;
|
|
447
|
+
}
|
|
448
|
+
const cx = (xMin + xMax) / 2;
|
|
449
|
+
const cy = (yMin + yMax) / 2;
|
|
450
|
+
|
|
451
|
+
const moved: { id: string; x: number; y: number }[] = [];
|
|
452
|
+
for (const r of targets) {
|
|
453
|
+
let nx = r.x;
|
|
454
|
+
let ny = r.y;
|
|
455
|
+
switch (mode) {
|
|
456
|
+
case 'left':
|
|
457
|
+
nx = xMin;
|
|
458
|
+
break;
|
|
459
|
+
case 'right':
|
|
460
|
+
nx = xMax - r.w;
|
|
461
|
+
break;
|
|
462
|
+
case 'center-x':
|
|
463
|
+
nx = cx - r.w / 2;
|
|
464
|
+
break;
|
|
465
|
+
case 'top':
|
|
466
|
+
ny = yMin;
|
|
467
|
+
break;
|
|
468
|
+
case 'bottom':
|
|
469
|
+
ny = yMax - r.h;
|
|
470
|
+
break;
|
|
471
|
+
case 'center-y':
|
|
472
|
+
ny = cy - r.h / 2;
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
if (Math.round(nx) === r.x && Math.round(ny) === r.y) continue;
|
|
476
|
+
moved.push({ id: r.id, x: Math.round(nx), y: Math.round(ny) });
|
|
477
|
+
}
|
|
478
|
+
if (moved.length === 0) return;
|
|
479
|
+
dragBus.commitPositions(moved, { label: alignLabel(mode, targets.length) });
|
|
480
|
+
},
|
|
481
|
+
[artboardsCtx, dragBus, selSet.selected]
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* G2v2 — "Fit just this artboard" context-menu entry previously bridged
|
|
486
|
+
* via a synthetic click on the label button (which used to call
|
|
487
|
+
* controller.jumpTo). Now that the label is purely a11y, the menu entry
|
|
488
|
+
* needs a direct path: look the rect up from the live artboards list and
|
|
489
|
+
* call jumpTo straight on the controller.
|
|
490
|
+
*/
|
|
491
|
+
const focusArtboard = useCallback(
|
|
492
|
+
(artboardId: string) => {
|
|
493
|
+
if (!controller || !artboardsCtx) return;
|
|
494
|
+
const rect = artboardsCtx.artboards.find((r) => r.id === artboardId);
|
|
495
|
+
if (rect) controller.jumpTo(rect);
|
|
496
|
+
},
|
|
497
|
+
[controller, artboardsCtx]
|
|
498
|
+
);
|
|
499
|
+
|
|
201
500
|
const registry = useMemo<ContextRegistry>(
|
|
202
|
-
() =>
|
|
203
|
-
|
|
501
|
+
() =>
|
|
502
|
+
buildRegistry({
|
|
503
|
+
controller,
|
|
504
|
+
clearSelection: selSet.clear,
|
|
505
|
+
selSet,
|
|
506
|
+
distributeArtboards,
|
|
507
|
+
alignArtboards,
|
|
508
|
+
focusArtboard,
|
|
509
|
+
}),
|
|
510
|
+
[controller, selSet, distributeArtboards, alignArtboards, focusArtboard]
|
|
204
511
|
);
|
|
205
512
|
|
|
513
|
+
// Distribute is reached via the MultiArtboardToolbar (floating chrome
|
|
514
|
+
// anchored above the group bbox) + context menu. No keyboard shortcut —
|
|
515
|
+
// user feedback (post-Wave-2) preferred the toolbar over global hotkeys.
|
|
516
|
+
|
|
206
517
|
return (
|
|
207
518
|
<ExportDialogProvider>
|
|
208
519
|
<ContextMenuProvider registry={registry}>
|
|
209
|
-
<CanvasRouter
|
|
520
|
+
<CanvasRouter
|
|
521
|
+
hostRef={hostRef}
|
|
522
|
+
distributeArtboards={distributeArtboards}
|
|
523
|
+
alignArtboards={alignArtboards}
|
|
524
|
+
>
|
|
525
|
+
{children}
|
|
526
|
+
</CanvasRouter>
|
|
210
527
|
</ContextMenuProvider>
|
|
211
528
|
</ExportDialogProvider>
|
|
212
529
|
);
|
|
@@ -218,8 +535,21 @@ function CanvasCore({
|
|
|
218
535
|
function buildRegistry(deps: {
|
|
219
536
|
controller: ViewportControllerHandle | null;
|
|
220
537
|
clearSelection: () => void;
|
|
538
|
+
selSet: { selected: Selection[] };
|
|
539
|
+
distributeArtboards: (axis: 'x' | 'y') => void;
|
|
540
|
+
alignArtboards: (mode: 'left' | 'right' | 'center-x' | 'top' | 'bottom' | 'center-y') => void;
|
|
541
|
+
focusArtboard: (artboardId: string) => void;
|
|
221
542
|
}): ContextRegistry {
|
|
222
|
-
const { controller, clearSelection } =
|
|
543
|
+
const { controller, clearSelection, selSet, distributeArtboards, alignArtboards, focusArtboard } =
|
|
544
|
+
deps;
|
|
545
|
+
|
|
546
|
+
// T24 — distribute commands are only enabled when ≥ 3 artboards are
|
|
547
|
+
// selected. Below that, the menu items render as `disabled` so the user
|
|
548
|
+
// sees the affordance but understands the precondition.
|
|
549
|
+
// G7 — align commands are enabled at ≥ 2 (alignment is well-defined with 2).
|
|
550
|
+
const selectedArtboardCount = selSet.selected.filter((s) => !!s.artboardId).length;
|
|
551
|
+
const distributeEnabled = selectedArtboardCount >= 3;
|
|
552
|
+
const alignEnabled = selectedArtboardCount >= 2;
|
|
223
553
|
|
|
224
554
|
const copy = (text: string): void => {
|
|
225
555
|
if (typeof navigator === 'undefined' || !navigator.clipboard) return;
|
|
@@ -342,19 +672,72 @@ function buildRegistry(deps: {
|
|
|
342
672
|
id: 'fit-one',
|
|
343
673
|
label: 'Fit just this artboard',
|
|
344
674
|
onSelect: (target) => {
|
|
345
|
-
if (!
|
|
346
|
-
|
|
347
|
-
// same pattern. The artboard label button already wires to
|
|
348
|
-
// onFocus; dispatch a synthetic click as the simplest bridge.
|
|
349
|
-
const btn = (target.el ?? document)
|
|
350
|
-
.closest?.('[data-dc-screen]')
|
|
351
|
-
?.querySelector('button.dc-artboard-label');
|
|
352
|
-
(btn as HTMLButtonElement | null)?.click();
|
|
675
|
+
if (!target.artboardId) return;
|
|
676
|
+
focusArtboard(target.artboardId);
|
|
353
677
|
},
|
|
354
678
|
},
|
|
355
679
|
fitItem,
|
|
356
680
|
resetItem,
|
|
357
681
|
],
|
|
682
|
+
[
|
|
683
|
+
// G7 — align commands. Six modes; gated on ≥ 2 selected artboards
|
|
684
|
+
// (alignment is well-defined with 2). Primary surface is the
|
|
685
|
+
// MultiArtboardToolbar; menu entries are discoverability backup.
|
|
686
|
+
{
|
|
687
|
+
id: 'align-left',
|
|
688
|
+
label: 'Align left',
|
|
689
|
+
disabled: !alignEnabled,
|
|
690
|
+
onSelect: () => alignArtboards('left'),
|
|
691
|
+
},
|
|
692
|
+
{
|
|
693
|
+
id: 'align-center-x',
|
|
694
|
+
label: 'Align center (horizontal)',
|
|
695
|
+
disabled: !alignEnabled,
|
|
696
|
+
onSelect: () => alignArtboards('center-x'),
|
|
697
|
+
},
|
|
698
|
+
{
|
|
699
|
+
id: 'align-right',
|
|
700
|
+
label: 'Align right',
|
|
701
|
+
disabled: !alignEnabled,
|
|
702
|
+
onSelect: () => alignArtboards('right'),
|
|
703
|
+
},
|
|
704
|
+
{
|
|
705
|
+
id: 'align-top',
|
|
706
|
+
label: 'Align top',
|
|
707
|
+
disabled: !alignEnabled,
|
|
708
|
+
onSelect: () => alignArtboards('top'),
|
|
709
|
+
},
|
|
710
|
+
{
|
|
711
|
+
id: 'align-center-y',
|
|
712
|
+
label: 'Align center (vertical)',
|
|
713
|
+
disabled: !alignEnabled,
|
|
714
|
+
onSelect: () => alignArtboards('center-y'),
|
|
715
|
+
},
|
|
716
|
+
{
|
|
717
|
+
id: 'align-bottom',
|
|
718
|
+
label: 'Align bottom',
|
|
719
|
+
disabled: !alignEnabled,
|
|
720
|
+
onSelect: () => alignArtboards('bottom'),
|
|
721
|
+
},
|
|
722
|
+
],
|
|
723
|
+
[
|
|
724
|
+
// T24 — distribute commands. Primary surface is the floating
|
|
725
|
+
// MultiArtboardToolbar above the group bbox; the menu entries are
|
|
726
|
+
// a discoverability backup. Disabled when fewer than 3 artboards
|
|
727
|
+
// are selected.
|
|
728
|
+
{
|
|
729
|
+
id: 'distribute-h',
|
|
730
|
+
label: 'Distribute horizontally',
|
|
731
|
+
disabled: !distributeEnabled,
|
|
732
|
+
onSelect: () => distributeArtboards('x'),
|
|
733
|
+
},
|
|
734
|
+
{
|
|
735
|
+
id: 'distribute-v',
|
|
736
|
+
label: 'Distribute vertically',
|
|
737
|
+
disabled: !distributeEnabled,
|
|
738
|
+
onSelect: () => distributeArtboards('y'),
|
|
739
|
+
},
|
|
740
|
+
],
|
|
358
741
|
[exportItem('export-artboard', 'Export this artboard…', 'artboard')],
|
|
359
742
|
],
|
|
360
743
|
world: [
|
|
@@ -384,14 +767,19 @@ function boundsOf(el: HTMLElement) {
|
|
|
384
767
|
function CanvasRouter({
|
|
385
768
|
hostRef,
|
|
386
769
|
children,
|
|
770
|
+
distributeArtboards,
|
|
771
|
+
alignArtboards,
|
|
387
772
|
}: {
|
|
388
773
|
hostRef: RefObject<HTMLDivElement | null>;
|
|
389
774
|
children: ReactNode;
|
|
775
|
+
distributeArtboards: (axis: 'x' | 'y') => void;
|
|
776
|
+
alignArtboards: (mode: 'left' | 'right' | 'center-x' | 'top' | 'bottom' | 'center-y') => void;
|
|
390
777
|
}) {
|
|
391
|
-
const { tool, setTool } = useToolMode();
|
|
778
|
+
const { tool, setTool, clearSticky } = useToolMode();
|
|
392
779
|
const selSet = useSelectionSet();
|
|
393
780
|
const annotSel = useAnnotationSelection();
|
|
394
781
|
const ctxMenu = useContextMenu();
|
|
782
|
+
const undoStack = useUndoStack();
|
|
395
783
|
|
|
396
784
|
// Hover state drives the floating .dc-cv-halo--hover overlay. The overlay
|
|
397
785
|
// itself reads getBoundingClientRect on every rAF tick to follow pan/zoom.
|
|
@@ -468,9 +856,11 @@ function CanvasRouter({
|
|
|
468
856
|
onSelect: ({ mode, deep, clientX, clientY }) => {
|
|
469
857
|
const target = resolveHoverTarget(document, clientX, clientY, { deep });
|
|
470
858
|
if (!target) {
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
|
|
859
|
+
// No-target click (canvas chrome / dead space) — DO NOT auto-clear
|
|
860
|
+
// the selection. Esc is the canonical deselect gesture; click-to-
|
|
861
|
+
// clear loses the user's selection on accidental misses. The empty
|
|
862
|
+
// marquee path (artboard-marquee.tsx) likewise never clears unless
|
|
863
|
+
// it actually captured something.
|
|
474
864
|
return;
|
|
475
865
|
}
|
|
476
866
|
const sel = hoverTargetToSelection(target);
|
|
@@ -491,7 +881,25 @@ function CanvasRouter({
|
|
|
491
881
|
ctxMenu.open(ctxTarget);
|
|
492
882
|
},
|
|
493
883
|
onTool: ({ tool: t }) => setTool(t),
|
|
884
|
+
onUndo: () => {
|
|
885
|
+
void undoStack.undo();
|
|
886
|
+
},
|
|
887
|
+
onRedo: () => {
|
|
888
|
+
void undoStack.redo();
|
|
889
|
+
},
|
|
494
890
|
onEscape: () => {
|
|
891
|
+
// T21 — abort any mid-stroke draw FIRST. The annotations layer
|
|
892
|
+
// listens for `maude:cancel-stroke` and drops the in-progress
|
|
893
|
+
// shape without committing.
|
|
894
|
+
try {
|
|
895
|
+
document.dispatchEvent(new CustomEvent('maude:cancel-stroke'));
|
|
896
|
+
} catch {
|
|
897
|
+
/* non-DOM env */
|
|
898
|
+
}
|
|
899
|
+
// T19 — clear sticky-tool lock + flip back to Move. Esc is the
|
|
900
|
+
// canonical "back to default state" gesture across canvas apps.
|
|
901
|
+
clearSticky();
|
|
902
|
+
if (tool !== 'move') setTool('move');
|
|
495
903
|
ctxMenu.close();
|
|
496
904
|
selSet.clear();
|
|
497
905
|
annotSel.clear();
|
|
@@ -596,14 +1004,321 @@ function CanvasRouter({
|
|
|
596
1004
|
<CommentsOverlay />
|
|
597
1005
|
<AnnotationsLayer />
|
|
598
1006
|
<ToolPalette />
|
|
1007
|
+
<ArtboardMarqueeOverlay />
|
|
1008
|
+
<ElementMarqueeOverlay />
|
|
599
1009
|
<HoverHalo el={hoverEl} />
|
|
600
1010
|
<SelectionHalos />
|
|
601
1011
|
<GroupBbox />
|
|
1012
|
+
<EqualSpacingHandles />
|
|
1013
|
+
<ContextualToolbar />
|
|
1014
|
+
<MultiArtboardToolbar
|
|
1015
|
+
distributeArtboards={distributeArtboards}
|
|
1016
|
+
alignArtboards={alignArtboards}
|
|
1017
|
+
/>
|
|
602
1018
|
<SnapGuideOverlay />
|
|
1019
|
+
<UndoHud />
|
|
603
1020
|
</>
|
|
604
1021
|
);
|
|
605
1022
|
}
|
|
606
1023
|
|
|
1024
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1025
|
+
// MultiArtboardToolbar — floating chrome anchored above the group bbox when
|
|
1026
|
+
// ≥ 2 artboards are multi-selected. Primary surface for the distribute
|
|
1027
|
+
// commands (post-Wave-2 feedback preferred this over global ⌘⌥H / ⌘⌥V
|
|
1028
|
+
// hotkeys). Disabled state when fewer than 3 artboards are selected — the
|
|
1029
|
+
// math is undefined for 2.
|
|
1030
|
+
|
|
1031
|
+
const MULTI_TOOLBAR_CSS = `
|
|
1032
|
+
.dc-multi-artboard-tb {
|
|
1033
|
+
position: fixed;
|
|
1034
|
+
pointer-events: auto;
|
|
1035
|
+
z-index: 6;
|
|
1036
|
+
display: none;
|
|
1037
|
+
align-items: stretch;
|
|
1038
|
+
gap: 2px;
|
|
1039
|
+
padding: 4px;
|
|
1040
|
+
background: var(--u-bg-0, var(--bg-0, #ffffff));
|
|
1041
|
+
border: 1px solid var(--u-fg-0, #1c1917);
|
|
1042
|
+
border-radius: 8px;
|
|
1043
|
+
box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
|
|
1044
|
+
font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
1045
|
+
font-size: 11px;
|
|
1046
|
+
letter-spacing: 0.02em;
|
|
1047
|
+
color: var(--u-fg-0, #1a1a1a);
|
|
1048
|
+
user-select: none;
|
|
1049
|
+
}
|
|
1050
|
+
.dc-multi-artboard-tb button {
|
|
1051
|
+
appearance: none;
|
|
1052
|
+
background: transparent;
|
|
1053
|
+
border: 0;
|
|
1054
|
+
border-radius: 6px;
|
|
1055
|
+
padding: 4px 10px;
|
|
1056
|
+
font: inherit;
|
|
1057
|
+
cursor: pointer;
|
|
1058
|
+
color: inherit;
|
|
1059
|
+
display: inline-flex;
|
|
1060
|
+
align-items: center;
|
|
1061
|
+
gap: 6px;
|
|
1062
|
+
transition: background-color 80ms linear;
|
|
1063
|
+
}
|
|
1064
|
+
.dc-multi-artboard-tb button:hover:not(:disabled) {
|
|
1065
|
+
background: color-mix(in oklab, var(--accent, #d63b1f) 8%, transparent);
|
|
1066
|
+
}
|
|
1067
|
+
.dc-multi-artboard-tb button:disabled {
|
|
1068
|
+
cursor: default;
|
|
1069
|
+
opacity: 0.4;
|
|
1070
|
+
}
|
|
1071
|
+
.dc-multi-artboard-tb .dc-mab-count {
|
|
1072
|
+
padding: 4px 8px 4px 10px;
|
|
1073
|
+
color: var(--fg-1, rgba(40,30,20,0.7));
|
|
1074
|
+
border-right: 1px solid var(--u-border-subtle, rgba(0,0,0,0.08));
|
|
1075
|
+
margin-right: 2px;
|
|
1076
|
+
font-variant-numeric: tabular-nums;
|
|
1077
|
+
}
|
|
1078
|
+
.dc-multi-artboard-tb .dc-mab-icon {
|
|
1079
|
+
display: inline-flex;
|
|
1080
|
+
align-items: center;
|
|
1081
|
+
justify-content: center;
|
|
1082
|
+
}
|
|
1083
|
+
.dc-multi-artboard-tb .dc-mab-divider {
|
|
1084
|
+
width: 1px;
|
|
1085
|
+
align-self: stretch;
|
|
1086
|
+
background: var(--u-border-subtle, rgba(0,0,0,0.10));
|
|
1087
|
+
margin: 0 4px;
|
|
1088
|
+
}
|
|
1089
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1090
|
+
.dc-multi-artboard-tb button { transition: none; }
|
|
1091
|
+
}
|
|
1092
|
+
`.trim();
|
|
1093
|
+
|
|
1094
|
+
function ensureMultiToolbarStyles(): void {
|
|
1095
|
+
if (typeof document === 'undefined') return;
|
|
1096
|
+
if (document.getElementById('dc-multi-artboard-tb-css')) return;
|
|
1097
|
+
const s = document.createElement('style');
|
|
1098
|
+
s.id = 'dc-multi-artboard-tb-css';
|
|
1099
|
+
s.textContent = MULTI_TOOLBAR_CSS;
|
|
1100
|
+
document.head.appendChild(s);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function MultiArtboardToolbar({
|
|
1104
|
+
distributeArtboards,
|
|
1105
|
+
alignArtboards,
|
|
1106
|
+
}: {
|
|
1107
|
+
distributeArtboards: (axis: 'x' | 'y') => void;
|
|
1108
|
+
alignArtboards: (mode: 'left' | 'right' | 'center-x' | 'top' | 'bottom' | 'center-y') => void;
|
|
1109
|
+
}) {
|
|
1110
|
+
ensureMultiToolbarStyles();
|
|
1111
|
+
const { selected } = useSelectionSet();
|
|
1112
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
1113
|
+
const rafRef = useRef<number | null>(null);
|
|
1114
|
+
|
|
1115
|
+
const artboardSelections = useMemo(() => selected.filter((s) => !!s.artboardId), [selected]);
|
|
1116
|
+
const artboardCount = artboardSelections.length;
|
|
1117
|
+
const distributeOk = artboardCount >= 3;
|
|
1118
|
+
const alignOk = artboardCount >= 2;
|
|
1119
|
+
|
|
1120
|
+
useEffect(() => {
|
|
1121
|
+
const div = ref.current;
|
|
1122
|
+
if (!div) return;
|
|
1123
|
+
if (artboardCount < 2) {
|
|
1124
|
+
div.style.display = 'none';
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
// Track the screen-coord union bbox of all selected artboards each
|
|
1128
|
+
// frame. Anchor toolbar centered horizontally above the bbox top
|
|
1129
|
+
// edge with a 14 px gap; flip BELOW if the top edge is < 60 px from
|
|
1130
|
+
// the viewport top.
|
|
1131
|
+
const tick = () => {
|
|
1132
|
+
rafRef.current = null;
|
|
1133
|
+
let xMin = Number.POSITIVE_INFINITY;
|
|
1134
|
+
let yMin = Number.POSITIVE_INFINITY;
|
|
1135
|
+
let xMax = Number.NEGATIVE_INFINITY;
|
|
1136
|
+
let yMax = Number.NEGATIVE_INFINITY;
|
|
1137
|
+
let any = false;
|
|
1138
|
+
for (const sel of artboardSelections) {
|
|
1139
|
+
const el = document.querySelector(`[data-dc-screen="${sel.artboardId}"]`);
|
|
1140
|
+
if (!el) continue;
|
|
1141
|
+
const r = (el as HTMLElement).getBoundingClientRect();
|
|
1142
|
+
if (r.width === 0 && r.height === 0) continue;
|
|
1143
|
+
any = true;
|
|
1144
|
+
if (r.left < xMin) xMin = r.left;
|
|
1145
|
+
if (r.top < yMin) yMin = r.top;
|
|
1146
|
+
if (r.right > xMax) xMax = r.right;
|
|
1147
|
+
if (r.bottom > yMax) yMax = r.bottom;
|
|
1148
|
+
}
|
|
1149
|
+
if (!any) {
|
|
1150
|
+
div.style.display = 'none';
|
|
1151
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
// Make the toolbar visible OFFSCREEN first to measure its own
|
|
1155
|
+
// width, then move it to the anchor. (Cheap: only re-anchors on
|
|
1156
|
+
// every frame after the first.)
|
|
1157
|
+
div.style.display = 'flex';
|
|
1158
|
+
const tw = div.offsetWidth || 0;
|
|
1159
|
+
const centerX = (xMin + xMax) / 2;
|
|
1160
|
+
const top = yMin;
|
|
1161
|
+
const gap = 14;
|
|
1162
|
+
let anchorY = top - div.offsetHeight - gap;
|
|
1163
|
+
if (anchorY < 60) anchorY = yMax + gap; // flip below
|
|
1164
|
+
div.style.left = `${Math.round(centerX - tw / 2)}px`;
|
|
1165
|
+
div.style.top = `${Math.round(anchorY)}px`;
|
|
1166
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
1167
|
+
};
|
|
1168
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
1169
|
+
return () => {
|
|
1170
|
+
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
|
1171
|
+
};
|
|
1172
|
+
}, [artboardCount, artboardSelections]);
|
|
1173
|
+
|
|
1174
|
+
if (artboardCount < 2) {
|
|
1175
|
+
return <div ref={ref} className="dc-multi-artboard-tb" aria-hidden="true" />;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
return (
|
|
1179
|
+
<div
|
|
1180
|
+
ref={ref}
|
|
1181
|
+
className="dc-multi-artboard-tb"
|
|
1182
|
+
role="toolbar"
|
|
1183
|
+
aria-label="Multi-artboard actions"
|
|
1184
|
+
>
|
|
1185
|
+
<span className="dc-mab-count">{artboardCount} artboards</span>
|
|
1186
|
+
{/* G7 — align cluster. Icon-only to keep the toolbar narrow; full
|
|
1187
|
+
label lives in the tooltip + the right-click menu. Enabled at ≥ 2
|
|
1188
|
+
artboards (alignment is well-defined with 2). */}
|
|
1189
|
+
<button
|
|
1190
|
+
type="button"
|
|
1191
|
+
disabled={!alignOk}
|
|
1192
|
+
title={alignOk ? 'Align left' : 'Select at least 2 artboards to align'}
|
|
1193
|
+
aria-label="Align left"
|
|
1194
|
+
onClick={() => alignArtboards('left')}
|
|
1195
|
+
>
|
|
1196
|
+
<span className="dc-mab-icon" aria-hidden="true">
|
|
1197
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
1198
|
+
<line x1="0.5" y1="0.5" x2="0.5" y2="13.5" stroke="currentColor" />
|
|
1199
|
+
<rect x="1" y="2" width="7" height="3" fill="currentColor" />
|
|
1200
|
+
<rect x="1" y="9" width="11" height="3" fill="currentColor" />
|
|
1201
|
+
</svg>
|
|
1202
|
+
</span>
|
|
1203
|
+
</button>
|
|
1204
|
+
<button
|
|
1205
|
+
type="button"
|
|
1206
|
+
disabled={!alignOk}
|
|
1207
|
+
title={alignOk ? 'Align center (horizontal)' : 'Select at least 2 artboards to align'}
|
|
1208
|
+
aria-label="Align center horizontally"
|
|
1209
|
+
onClick={() => alignArtboards('center-x')}
|
|
1210
|
+
>
|
|
1211
|
+
<span className="dc-mab-icon" aria-hidden="true">
|
|
1212
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
1213
|
+
<line x1="7" y1="0.5" x2="7" y2="13.5" stroke="currentColor" />
|
|
1214
|
+
<rect x="3.5" y="2" width="7" height="3" fill="currentColor" />
|
|
1215
|
+
<rect x="1.5" y="9" width="11" height="3" fill="currentColor" />
|
|
1216
|
+
</svg>
|
|
1217
|
+
</span>
|
|
1218
|
+
</button>
|
|
1219
|
+
<button
|
|
1220
|
+
type="button"
|
|
1221
|
+
disabled={!alignOk}
|
|
1222
|
+
title={alignOk ? 'Align right' : 'Select at least 2 artboards to align'}
|
|
1223
|
+
aria-label="Align right"
|
|
1224
|
+
onClick={() => alignArtboards('right')}
|
|
1225
|
+
>
|
|
1226
|
+
<span className="dc-mab-icon" aria-hidden="true">
|
|
1227
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
1228
|
+
<line x1="13.5" y1="0.5" x2="13.5" y2="13.5" stroke="currentColor" />
|
|
1229
|
+
<rect x="6" y="2" width="7" height="3" fill="currentColor" />
|
|
1230
|
+
<rect x="2" y="9" width="11" height="3" fill="currentColor" />
|
|
1231
|
+
</svg>
|
|
1232
|
+
</span>
|
|
1233
|
+
</button>
|
|
1234
|
+
<button
|
|
1235
|
+
type="button"
|
|
1236
|
+
disabled={!alignOk}
|
|
1237
|
+
title={alignOk ? 'Align top' : 'Select at least 2 artboards to align'}
|
|
1238
|
+
aria-label="Align top"
|
|
1239
|
+
onClick={() => alignArtboards('top')}
|
|
1240
|
+
>
|
|
1241
|
+
<span className="dc-mab-icon" aria-hidden="true">
|
|
1242
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
1243
|
+
<line x1="0.5" y1="0.5" x2="13.5" y2="0.5" stroke="currentColor" />
|
|
1244
|
+
<rect x="2" y="1" width="3" height="7" fill="currentColor" />
|
|
1245
|
+
<rect x="9" y="1" width="3" height="11" fill="currentColor" />
|
|
1246
|
+
</svg>
|
|
1247
|
+
</span>
|
|
1248
|
+
</button>
|
|
1249
|
+
<button
|
|
1250
|
+
type="button"
|
|
1251
|
+
disabled={!alignOk}
|
|
1252
|
+
title={alignOk ? 'Align center (vertical)' : 'Select at least 2 artboards to align'}
|
|
1253
|
+
aria-label="Align center vertically"
|
|
1254
|
+
onClick={() => alignArtboards('center-y')}
|
|
1255
|
+
>
|
|
1256
|
+
<span className="dc-mab-icon" aria-hidden="true">
|
|
1257
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
1258
|
+
<line x1="0.5" y1="7" x2="13.5" y2="7" stroke="currentColor" />
|
|
1259
|
+
<rect x="2" y="3.5" width="3" height="7" fill="currentColor" />
|
|
1260
|
+
<rect x="9" y="1.5" width="3" height="11" fill="currentColor" />
|
|
1261
|
+
</svg>
|
|
1262
|
+
</span>
|
|
1263
|
+
</button>
|
|
1264
|
+
<button
|
|
1265
|
+
type="button"
|
|
1266
|
+
disabled={!alignOk}
|
|
1267
|
+
title={alignOk ? 'Align bottom' : 'Select at least 2 artboards to align'}
|
|
1268
|
+
aria-label="Align bottom"
|
|
1269
|
+
onClick={() => alignArtboards('bottom')}
|
|
1270
|
+
>
|
|
1271
|
+
<span className="dc-mab-icon" aria-hidden="true">
|
|
1272
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
1273
|
+
<line x1="0.5" y1="13.5" x2="13.5" y2="13.5" stroke="currentColor" />
|
|
1274
|
+
<rect x="2" y="6" width="3" height="7" fill="currentColor" />
|
|
1275
|
+
<rect x="9" y="2" width="3" height="11" fill="currentColor" />
|
|
1276
|
+
</svg>
|
|
1277
|
+
</span>
|
|
1278
|
+
</button>
|
|
1279
|
+
<span className="dc-mab-divider" aria-hidden="true" />
|
|
1280
|
+
<button
|
|
1281
|
+
type="button"
|
|
1282
|
+
disabled={!distributeOk}
|
|
1283
|
+
title={
|
|
1284
|
+
distributeOk
|
|
1285
|
+
? 'Distribute horizontally — equal gaps between artboards'
|
|
1286
|
+
: 'Select at least 3 artboards to distribute'
|
|
1287
|
+
}
|
|
1288
|
+
aria-label="Distribute horizontally"
|
|
1289
|
+
onClick={() => distributeArtboards('x')}
|
|
1290
|
+
>
|
|
1291
|
+
<span className="dc-mab-icon" aria-hidden="true">
|
|
1292
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
1293
|
+
<rect x="0.5" y="3" width="3" height="8" fill="currentColor" />
|
|
1294
|
+
<rect x="5.5" y="3" width="3" height="8" fill="currentColor" />
|
|
1295
|
+
<rect x="10.5" y="3" width="3" height="8" fill="currentColor" />
|
|
1296
|
+
</svg>
|
|
1297
|
+
</span>
|
|
1298
|
+
</button>
|
|
1299
|
+
<button
|
|
1300
|
+
type="button"
|
|
1301
|
+
disabled={!distributeOk}
|
|
1302
|
+
title={
|
|
1303
|
+
distributeOk
|
|
1304
|
+
? 'Distribute vertically — equal gaps between artboards'
|
|
1305
|
+
: 'Select at least 3 artboards to distribute'
|
|
1306
|
+
}
|
|
1307
|
+
aria-label="Distribute vertically"
|
|
1308
|
+
onClick={() => distributeArtboards('y')}
|
|
1309
|
+
>
|
|
1310
|
+
<span className="dc-mab-icon" aria-hidden="true">
|
|
1311
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
1312
|
+
<rect x="3" y="0.5" width="8" height="3" fill="currentColor" />
|
|
1313
|
+
<rect x="3" y="5.5" width="8" height="3" fill="currentColor" />
|
|
1314
|
+
<rect x="3" y="10.5" width="8" height="3" fill="currentColor" />
|
|
1315
|
+
</svg>
|
|
1316
|
+
</span>
|
|
1317
|
+
</button>
|
|
1318
|
+
</div>
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
|
|
607
1322
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
608
1323
|
// HoverHalo — single floating overlay tracking the hovered element's screen
|
|
609
1324
|
// bounds. Updates on every animation frame while mounted. Position: fixed so
|
|
@@ -654,6 +1369,27 @@ function HoverHalo({ el }: { el: Element | null }) {
|
|
|
654
1369
|
// SelectionHalos — N floating overlays, one per selected element. Resolves
|
|
655
1370
|
// elements by `data-cd-id` when present, falling back to the selector path.
|
|
656
1371
|
|
|
1372
|
+
// DDR-046 — single-select halo carries 4 corner ticks; multi-select members get
|
|
1373
|
+
// the lighter 1px tinted outline (no ticks). The GroupBbox renders the loud
|
|
1374
|
+
// signal when selected.length > 1.
|
|
1375
|
+
const TICK_CLASSES = ['tick-tl', 'tick-tr', 'tick-bl', 'tick-br'] as const;
|
|
1376
|
+
|
|
1377
|
+
function makeSelectedNode(withTicks: boolean): HTMLDivElement {
|
|
1378
|
+
const child = document.createElement('div');
|
|
1379
|
+
child.className = withTicks
|
|
1380
|
+
? 'dc-cv-halo dc-cv-halo--selected'
|
|
1381
|
+
: 'dc-cv-halo dc-cv-halo--selected-member';
|
|
1382
|
+
child.setAttribute('aria-hidden', 'true');
|
|
1383
|
+
if (withTicks) {
|
|
1384
|
+
for (const cls of TICK_CLASSES) {
|
|
1385
|
+
const tick = document.createElement('i');
|
|
1386
|
+
tick.className = `tick ${cls}`;
|
|
1387
|
+
child.appendChild(tick);
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
return child;
|
|
1391
|
+
}
|
|
1392
|
+
|
|
657
1393
|
function SelectionHalos() {
|
|
658
1394
|
const { selected } = useSelectionSet();
|
|
659
1395
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
@@ -669,16 +1405,34 @@ function SelectionHalos() {
|
|
|
669
1405
|
rafRef.current = null;
|
|
670
1406
|
const c = containerRef.current;
|
|
671
1407
|
if (!c) return;
|
|
672
|
-
|
|
1408
|
+
const wantsTicks = selected.length === 1;
|
|
1409
|
+
const wantClass = wantsTicks
|
|
1410
|
+
? 'dc-cv-halo dc-cv-halo--selected'
|
|
1411
|
+
: 'dc-cv-halo dc-cv-halo--selected-member';
|
|
1412
|
+
// Match rendered halo count to selected.length; reuse DOM nodes.
|
|
673
1413
|
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);
|
|
1414
|
+
c.appendChild(makeSelectedNode(wantsTicks));
|
|
678
1415
|
}
|
|
679
1416
|
while (c.children.length > selected.length) {
|
|
680
1417
|
c.removeChild(c.lastChild as Node);
|
|
681
1418
|
}
|
|
1419
|
+
// When selection size crosses 1↔N+, swap class + tick children on each
|
|
1420
|
+
// existing node so reused nodes adopt the new visual idiom.
|
|
1421
|
+
for (let i = 0; i < c.children.length; i++) {
|
|
1422
|
+
const child = c.children[i] as HTMLDivElement;
|
|
1423
|
+
if (child.className !== wantClass) {
|
|
1424
|
+
child.className = wantClass;
|
|
1425
|
+
// Strip any existing ticks, then re-append if needed.
|
|
1426
|
+
while (child.firstChild) child.removeChild(child.firstChild);
|
|
1427
|
+
if (wantsTicks) {
|
|
1428
|
+
for (const cls of TICK_CLASSES) {
|
|
1429
|
+
const t = document.createElement('i');
|
|
1430
|
+
t.className = `tick ${cls}`;
|
|
1431
|
+
child.appendChild(t);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
682
1436
|
for (let i = 0; i < selected.length; i++) {
|
|
683
1437
|
const sel = selected[i];
|
|
684
1438
|
const child = c.children[i] as HTMLDivElement;
|
|
@@ -691,6 +1445,9 @@ function SelectionHalos() {
|
|
|
691
1445
|
child.style.display = 'none';
|
|
692
1446
|
continue;
|
|
693
1447
|
}
|
|
1448
|
+
// Post-Wave-2: artboard drag is now direct (article updates its own
|
|
1449
|
+
// `left/top` in real-time), so the halo can follow via
|
|
1450
|
+
// getBoundingClientRect — no special drag suppression needed.
|
|
694
1451
|
const r = (el as HTMLElement).getBoundingClientRect();
|
|
695
1452
|
if (r.width === 0 && r.height === 0) {
|
|
696
1453
|
child.style.display = 'none';
|
|
@@ -740,6 +1497,9 @@ function GroupBbox() {
|
|
|
740
1497
|
? document.querySelector(`[data-cd-id="${cssEscape(sel.id)}"]`)
|
|
741
1498
|
: safeQuery(sel.selector);
|
|
742
1499
|
if (!el) continue;
|
|
1500
|
+
// Post-Wave-2: direct artboard drag — the article updates its own
|
|
1501
|
+
// `left/top` during drag, so the group bbox just follows via
|
|
1502
|
+
// getBoundingClientRect with no special-casing.
|
|
743
1503
|
const r = (el as HTMLElement).getBoundingClientRect();
|
|
744
1504
|
if (r.width === 0 && r.height === 0) continue;
|
|
745
1505
|
anyHit = true;
|
|
@@ -767,7 +1527,13 @@ function GroupBbox() {
|
|
|
767
1527
|
}, [selected]);
|
|
768
1528
|
|
|
769
1529
|
if (selected.length < 2) return null;
|
|
770
|
-
return
|
|
1530
|
+
return (
|
|
1531
|
+
<div ref={ref} className="dc-cv-group-bbox" aria-hidden="true">
|
|
1532
|
+
{TICK_CLASSES.map((cls) => (
|
|
1533
|
+
<i key={cls} className={`tick ${cls}`} />
|
|
1534
|
+
))}
|
|
1535
|
+
</div>
|
|
1536
|
+
);
|
|
771
1537
|
}
|
|
772
1538
|
|
|
773
1539
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -795,10 +1561,20 @@ function hoverTargetToSelection(target: HoverTarget): Selection {
|
|
|
795
1561
|
// climbs to an ancestor. Falls back to cssPath of the hit when no stable
|
|
796
1562
|
// anchor exists.
|
|
797
1563
|
const cdId = target.cdId;
|
|
1564
|
+
// Selector resolution order:
|
|
1565
|
+
// 1. data-cd-id anchor — stable pipeline-stamped id (preferred).
|
|
1566
|
+
// 2. data-dc-screen — chrome click promoted to whole-artboard select
|
|
1567
|
+
// (T24.5 G8 multi-artboard gesture).
|
|
1568
|
+
// 3. cssPath of the hit — last-resort path string.
|
|
1569
|
+
const selector = cdId
|
|
1570
|
+
? `[data-cd-id="${cdId}"]`
|
|
1571
|
+
: !cdId && target.artboardId
|
|
1572
|
+
? `[data-dc-screen="${target.artboardId}"]`
|
|
1573
|
+
: cssPath(el);
|
|
798
1574
|
return {
|
|
799
1575
|
file: typeof window !== 'undefined' ? deriveFile() : undefined,
|
|
800
1576
|
id: cdId ?? undefined,
|
|
801
|
-
selector
|
|
1577
|
+
selector,
|
|
802
1578
|
artboardId: target.artboardId,
|
|
803
1579
|
tag: el?.tagName.toLowerCase() ?? '',
|
|
804
1580
|
classes: realClasses(el),
|