@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
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file equal-spacing-detector.ts — T27 (Wave 3)
|
|
3
|
+
* @scope plugins/design/dev-server/equal-spacing-detector.ts
|
|
4
|
+
* @purpose Pure detector for Figma "Smart Selection" pink-dot affordance
|
|
5
|
+
* (Rasmus Andersson 2018). Given 3+ rects on a single axis,
|
|
6
|
+
* returns the equal gap + the screen-coord midpoints between
|
|
7
|
+
* adjacent pairs IF all pairwise gaps are within `tolerancePx`
|
|
8
|
+
* of each other. Returns `null` when the rects are not equally
|
|
9
|
+
* distributed.
|
|
10
|
+
*
|
|
11
|
+
* Pure / DOM-free / framework-free — same convention as
|
|
12
|
+
* `computeSnap` in `use-snap-guides.tsx`. The overlay layer
|
|
13
|
+
* consumes the result and paints the pink dots.
|
|
14
|
+
*
|
|
15
|
+
* Coordinate space is the caller's choice — pass screen-space
|
|
16
|
+
* rects for live overlay rendering, or world-space rects for
|
|
17
|
+
* distribute-command verification. The math is uniform either
|
|
18
|
+
* way; only the unit of `tolerancePx` differs.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { Rect } from './use-snap-guides.tsx';
|
|
22
|
+
|
|
23
|
+
export type SpacingAxis = 'x' | 'y';
|
|
24
|
+
|
|
25
|
+
export interface EqualSpacingResult {
|
|
26
|
+
axis: SpacingAxis;
|
|
27
|
+
/** The gap measured between adjacent rects (post-sort by leading edge).
|
|
28
|
+
* Caller renders this in the distance pill above each pink dot. */
|
|
29
|
+
gapPx: number;
|
|
30
|
+
/** Midpoint coords between consecutive rects. Length = rects.length - 1.
|
|
31
|
+
* Each entry is in caller's coord space; renderers anchor pink dots here. */
|
|
32
|
+
midpoints: Array<{ x: number; y: number }>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface DetectOptions {
|
|
36
|
+
/** How close gaps must be to count as "equal", in caller's unit (default 1). */
|
|
37
|
+
tolerancePx?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Detect equal spacing along one axis. Returns `null` when:
|
|
42
|
+
* - fewer than 3 rects (2 rects trivially have "equal" spacing — undefined).
|
|
43
|
+
* - any pairwise gap differs from the median by more than `tolerancePx`.
|
|
44
|
+
* - rects overlap on the spacing axis (gap < 0 anywhere).
|
|
45
|
+
*
|
|
46
|
+
* The midpoint y (for axis='x') or x (for axis='y') is the average of the
|
|
47
|
+
* adjacent rects' center on the perpendicular axis — this places the pink
|
|
48
|
+
* dot vertically centered between the two siblings, which is where the
|
|
49
|
+
* distance pill anchors above.
|
|
50
|
+
*/
|
|
51
|
+
export function detectEqualSpacing(
|
|
52
|
+
rects: Rect[],
|
|
53
|
+
axis: SpacingAxis,
|
|
54
|
+
opts: DetectOptions = {}
|
|
55
|
+
): EqualSpacingResult | null {
|
|
56
|
+
if (rects.length < 3) return null;
|
|
57
|
+
const tol = opts.tolerancePx ?? 1;
|
|
58
|
+
|
|
59
|
+
// Sort by leading edge on the spacing axis.
|
|
60
|
+
const sorted = [...rects].sort((a, b) => (axis === 'x' ? a.x - b.x : a.y - b.y));
|
|
61
|
+
|
|
62
|
+
// Compute pairwise gaps + midpoints.
|
|
63
|
+
const gaps: number[] = [];
|
|
64
|
+
const midpoints: Array<{ x: number; y: number }> = [];
|
|
65
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
66
|
+
const prev = sorted[i - 1];
|
|
67
|
+
const cur = sorted[i];
|
|
68
|
+
if (!prev || !cur) return null;
|
|
69
|
+
if (axis === 'x') {
|
|
70
|
+
const gap = cur.x - (prev.x + prev.w);
|
|
71
|
+
if (gap < 0) return null; // overlap; not a distributed set
|
|
72
|
+
gaps.push(gap);
|
|
73
|
+
midpoints.push({
|
|
74
|
+
x: prev.x + prev.w + gap / 2,
|
|
75
|
+
y: (prev.y + prev.h / 2 + (cur.y + cur.h / 2)) / 2,
|
|
76
|
+
});
|
|
77
|
+
} else {
|
|
78
|
+
const gap = cur.y - (prev.y + prev.h);
|
|
79
|
+
if (gap < 0) return null;
|
|
80
|
+
gaps.push(gap);
|
|
81
|
+
midpoints.push({
|
|
82
|
+
x: (prev.x + prev.w / 2 + (cur.x + cur.w / 2)) / 2,
|
|
83
|
+
y: prev.y + prev.h + gap / 2,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// All gaps must be within tolerance of each other. Use the median as anchor
|
|
89
|
+
// to be robust against a single outlier — though if any gap is outside the
|
|
90
|
+
// band we return null, so median vs mean is academic here.
|
|
91
|
+
const sortedGaps = [...gaps].sort((a, b) => a - b);
|
|
92
|
+
const median = sortedGaps[Math.floor(sortedGaps.length / 2)] ?? 0;
|
|
93
|
+
for (const g of gaps) {
|
|
94
|
+
if (Math.abs(g - median) > tol) return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { axis, gapPx: median, midpoints };
|
|
98
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file equal-spacing-handles.tsx — T27 (Wave 3)
|
|
3
|
+
* @scope plugins/design/dev-server/equal-spacing-handles.tsx
|
|
4
|
+
* @purpose Figma Smart Selection pink-dot affordance. When 3+ elements are
|
|
5
|
+
* selected AND their bounding rects are equally distributed on an
|
|
6
|
+
* axis, render small pink dots at the midpoints between adjacent
|
|
7
|
+
* pairs, each with a gap-distance pill above. Hover-gated:
|
|
8
|
+
* dots are visible only while the cursor is within the union
|
|
9
|
+
* bbox + 40 px padding — discoverable but not noisy.
|
|
10
|
+
*
|
|
11
|
+
* Drag-to-adjust the gap is a Wave 3.x follow-up; for v1 we
|
|
12
|
+
* paint the affordance only.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useEffect, useRef, useState } from 'react';
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
type EqualSpacingResult,
|
|
19
|
+
type SpacingAxis,
|
|
20
|
+
detectEqualSpacing,
|
|
21
|
+
} from './equal-spacing-detector.ts';
|
|
22
|
+
import { useSelectionSet } from './use-selection-set.tsx';
|
|
23
|
+
|
|
24
|
+
const HOVER_PADDING_PX = 40;
|
|
25
|
+
/** Equal-spacing tolerance in screen pixels — distribute leaves 0–1 px
|
|
26
|
+
* rounding error; 2 px is comfortably above noise without flagging
|
|
27
|
+
* intentionally-uneven sets. */
|
|
28
|
+
const EQUAL_TOLERANCE_PX = 2;
|
|
29
|
+
|
|
30
|
+
const STYLES = `
|
|
31
|
+
.dc-cv-eq-spacing-layer {
|
|
32
|
+
position: fixed;
|
|
33
|
+
inset: 0;
|
|
34
|
+
pointer-events: none;
|
|
35
|
+
z-index: 5;
|
|
36
|
+
}
|
|
37
|
+
.dc-cv-eq-dot {
|
|
38
|
+
position: absolute;
|
|
39
|
+
width: 6px;
|
|
40
|
+
height: 6px;
|
|
41
|
+
border-radius: 50%;
|
|
42
|
+
background: #FF24BD;
|
|
43
|
+
transform: translate(-50%, -50%);
|
|
44
|
+
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.85);
|
|
45
|
+
opacity: 0;
|
|
46
|
+
transition: opacity 120ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
47
|
+
}
|
|
48
|
+
.dc-cv-eq-pill {
|
|
49
|
+
position: absolute;
|
|
50
|
+
transform: translate(-50%, calc(-100% - 8px));
|
|
51
|
+
font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace);
|
|
52
|
+
font-size: 10px;
|
|
53
|
+
padding: 2px 5px;
|
|
54
|
+
background: #FF24BD;
|
|
55
|
+
color: #ffffff;
|
|
56
|
+
border-radius: 2px;
|
|
57
|
+
letter-spacing: 0.02em;
|
|
58
|
+
white-space: nowrap;
|
|
59
|
+
opacity: 0;
|
|
60
|
+
transition: opacity 120ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
61
|
+
}
|
|
62
|
+
.dc-cv-eq-spacing-layer[data-visible="true"] .dc-cv-eq-dot,
|
|
63
|
+
.dc-cv-eq-spacing-layer[data-visible="true"] .dc-cv-eq-pill {
|
|
64
|
+
opacity: 1;
|
|
65
|
+
}
|
|
66
|
+
@media (prefers-reduced-motion: reduce) {
|
|
67
|
+
.dc-cv-eq-dot, .dc-cv-eq-pill { transition-duration: 1ms; }
|
|
68
|
+
}
|
|
69
|
+
`.trim();
|
|
70
|
+
|
|
71
|
+
function ensureStyles(): void {
|
|
72
|
+
if (typeof document === 'undefined') return;
|
|
73
|
+
if (document.getElementById('dc-cv-eq-spacing-css')) return;
|
|
74
|
+
const s = document.createElement('style');
|
|
75
|
+
s.id = 'dc-cv-eq-spacing-css';
|
|
76
|
+
s.textContent = STYLES;
|
|
77
|
+
document.head.appendChild(s);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function cssEscape(s: string): string {
|
|
81
|
+
// CSS.escape is available everywhere we care; manual fallback handles the
|
|
82
|
+
// jsdom test path where it may be missing.
|
|
83
|
+
// biome-ignore lint/suspicious/noExplicitAny: feature detect
|
|
84
|
+
const ce = (typeof CSS !== 'undefined' ? (CSS as any).escape : null) as
|
|
85
|
+
| ((v: string) => string)
|
|
86
|
+
| null;
|
|
87
|
+
if (ce) return ce(s);
|
|
88
|
+
return s.replace(/(["\\\]])/g, '\\$1');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function safeQuery(selector: string): Element | null {
|
|
92
|
+
try {
|
|
93
|
+
return document.querySelector(selector);
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface ScreenRect {
|
|
100
|
+
x: number;
|
|
101
|
+
y: number;
|
|
102
|
+
w: number;
|
|
103
|
+
h: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function resolveSelectionRects(sels: Array<{ id?: string; selector: string }>): ScreenRect[] {
|
|
107
|
+
const rects: ScreenRect[] = [];
|
|
108
|
+
for (const s of sels) {
|
|
109
|
+
const el = s.id
|
|
110
|
+
? document.querySelector(`[data-cd-id="${cssEscape(s.id)}"]`)
|
|
111
|
+
: safeQuery(s.selector);
|
|
112
|
+
if (!el) continue;
|
|
113
|
+
const b = (el as HTMLElement).getBoundingClientRect();
|
|
114
|
+
if (b.width === 0 && b.height === 0) continue;
|
|
115
|
+
rects.push({ x: b.left, y: b.top, w: b.width, h: b.height });
|
|
116
|
+
}
|
|
117
|
+
return rects;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function unionBbox(rects: ScreenRect[]): ScreenRect | null {
|
|
121
|
+
if (rects.length === 0) return null;
|
|
122
|
+
let left = Number.POSITIVE_INFINITY;
|
|
123
|
+
let top = Number.POSITIVE_INFINITY;
|
|
124
|
+
let right = Number.NEGATIVE_INFINITY;
|
|
125
|
+
let bottom = Number.NEGATIVE_INFINITY;
|
|
126
|
+
for (const r of rects) {
|
|
127
|
+
if (r.x < left) left = r.x;
|
|
128
|
+
if (r.y < top) top = r.y;
|
|
129
|
+
if (r.x + r.w > right) right = r.x + r.w;
|
|
130
|
+
if (r.y + r.h > bottom) bottom = r.y + r.h;
|
|
131
|
+
}
|
|
132
|
+
return { x: left, y: top, w: right - left, h: bottom - top };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function pointInPaddedBox(px: number, py: number, b: ScreenRect, pad: number): boolean {
|
|
136
|
+
return px >= b.x - pad && px <= b.x + b.w + pad && py >= b.y - pad && py <= b.y + b.h + pad;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function EqualSpacingHandles() {
|
|
140
|
+
ensureStyles();
|
|
141
|
+
const { selected } = useSelectionSet();
|
|
142
|
+
const layerRef = useRef<HTMLDivElement | null>(null);
|
|
143
|
+
const rafRef = useRef<number | null>(null);
|
|
144
|
+
const [hovered, setHovered] = useState(false);
|
|
145
|
+
|
|
146
|
+
// Track cursor position to gate dot visibility. We hide handles whenever
|
|
147
|
+
// the cursor leaves the (union bbox + padding) zone — discoverable on
|
|
148
|
+
// approach, invisible while the user is doing unrelated work elsewhere.
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (typeof document === 'undefined') return;
|
|
151
|
+
const onMove = (e: PointerEvent) => {
|
|
152
|
+
const layer = layerRef.current;
|
|
153
|
+
if (!layer) return;
|
|
154
|
+
const dotsZone = layer.getBoundingClientRect();
|
|
155
|
+
// Layer is inset:0 in screen-fixed coords. We compare against the
|
|
156
|
+
// currently painted union bbox stored on a data attr each rAF tick.
|
|
157
|
+
const ubStr = layer.getAttribute('data-union-bbox');
|
|
158
|
+
if (!ubStr) {
|
|
159
|
+
setHovered(false);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
// Avoid TS-touchy split — manual parse.
|
|
163
|
+
const parts = ubStr.split(',');
|
|
164
|
+
const x = Number(parts[0]);
|
|
165
|
+
const y = Number(parts[1]);
|
|
166
|
+
const w = Number(parts[2]);
|
|
167
|
+
const h = Number(parts[3]);
|
|
168
|
+
if (Number.isNaN(x) || Number.isNaN(y)) return;
|
|
169
|
+
const inZone = pointInPaddedBox(e.clientX, e.clientY, { x, y, w, h }, HOVER_PADDING_PX);
|
|
170
|
+
if (inZone !== hovered) setHovered(inZone);
|
|
171
|
+
// Touch `dotsZone` to keep the ref-read in the dependency graph.
|
|
172
|
+
void dotsZone;
|
|
173
|
+
};
|
|
174
|
+
document.addEventListener('pointermove', onMove, { passive: true });
|
|
175
|
+
return () => document.removeEventListener('pointermove', onMove);
|
|
176
|
+
}, [hovered]);
|
|
177
|
+
|
|
178
|
+
// Resolve current selection → DOM rects → equal-spacing detector → paint.
|
|
179
|
+
// rAF loop so the affordance follows zoom + pan smoothly without React
|
|
180
|
+
// re-renders per frame.
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
if (selected.length < 3) {
|
|
183
|
+
// Clear layer when selection drops below the affordance threshold.
|
|
184
|
+
const layer = layerRef.current;
|
|
185
|
+
if (layer) {
|
|
186
|
+
while (layer.firstChild) layer.removeChild(layer.firstChild);
|
|
187
|
+
layer.removeAttribute('data-union-bbox');
|
|
188
|
+
}
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
let alive = true;
|
|
192
|
+
|
|
193
|
+
const tick = () => {
|
|
194
|
+
if (!alive) return;
|
|
195
|
+
rafRef.current = null;
|
|
196
|
+
const layer = layerRef.current;
|
|
197
|
+
if (!layer) return;
|
|
198
|
+
const rects = resolveSelectionRects(selected);
|
|
199
|
+
if (rects.length < 3) {
|
|
200
|
+
while (layer.firstChild) layer.removeChild(layer.firstChild);
|
|
201
|
+
layer.removeAttribute('data-union-bbox');
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
// Detect on both axes; prefer the axis with the smaller relative spread
|
|
205
|
+
// (the "more confidently distributed" axis). Ties = horizontal wins.
|
|
206
|
+
const detX = detectEqualSpacing(rects, 'x', { tolerancePx: EQUAL_TOLERANCE_PX });
|
|
207
|
+
const detY = detectEqualSpacing(rects, 'y', { tolerancePx: EQUAL_TOLERANCE_PX });
|
|
208
|
+
const result = pickDetection(detX, detY);
|
|
209
|
+
if (!result) {
|
|
210
|
+
while (layer.firstChild) layer.removeChild(layer.firstChild);
|
|
211
|
+
layer.removeAttribute('data-union-bbox');
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const ub = unionBbox(rects);
|
|
216
|
+
if (ub) layer.setAttribute('data-union-bbox', `${ub.x},${ub.y},${ub.w},${ub.h}`);
|
|
217
|
+
|
|
218
|
+
paintHandles(layer, result);
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const onFrame = () => {
|
|
222
|
+
if (rafRef.current != null) return;
|
|
223
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
// Initial paint + continuous follow on pan/zoom/scroll.
|
|
227
|
+
onFrame();
|
|
228
|
+
const recheck = () => onFrame();
|
|
229
|
+
window.addEventListener('scroll', recheck, { passive: true, capture: true });
|
|
230
|
+
window.addEventListener('resize', recheck, { passive: true });
|
|
231
|
+
const observer = new MutationObserver(() => onFrame());
|
|
232
|
+
observer.observe(document.body, { attributes: true, subtree: true, childList: true });
|
|
233
|
+
|
|
234
|
+
return () => {
|
|
235
|
+
alive = false;
|
|
236
|
+
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
|
237
|
+
window.removeEventListener('scroll', recheck, { capture: true } as EventListenerOptions);
|
|
238
|
+
window.removeEventListener('resize', recheck);
|
|
239
|
+
observer.disconnect();
|
|
240
|
+
};
|
|
241
|
+
}, [selected]);
|
|
242
|
+
|
|
243
|
+
return (
|
|
244
|
+
<div
|
|
245
|
+
ref={layerRef}
|
|
246
|
+
className="dc-cv-eq-spacing-layer"
|
|
247
|
+
data-visible={hovered ? 'true' : 'false'}
|
|
248
|
+
aria-hidden="true"
|
|
249
|
+
/>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function pickDetection(
|
|
254
|
+
detX: EqualSpacingResult | null,
|
|
255
|
+
detY: EqualSpacingResult | null
|
|
256
|
+
): EqualSpacingResult | null {
|
|
257
|
+
if (detX && detY) return detX.midpoints.length >= detY.midpoints.length ? detX : detY;
|
|
258
|
+
return detX ?? detY;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function paintHandles(layer: HTMLDivElement, result: EqualSpacingResult): void {
|
|
262
|
+
// Match child count to 2× midpoints (each midpoint = one dot + one pill).
|
|
263
|
+
const want = result.midpoints.length * 2;
|
|
264
|
+
while (layer.children.length < want) {
|
|
265
|
+
const isDot = layer.children.length % 2 === 0;
|
|
266
|
+
const node = document.createElement('div');
|
|
267
|
+
node.className = isDot ? 'dc-cv-eq-dot' : 'dc-cv-eq-pill';
|
|
268
|
+
if (!isDot) node.textContent = '';
|
|
269
|
+
layer.appendChild(node);
|
|
270
|
+
}
|
|
271
|
+
while (layer.children.length > want) {
|
|
272
|
+
layer.removeChild(layer.lastChild as Node);
|
|
273
|
+
}
|
|
274
|
+
const label = `${Math.round(result.gapPx)}`;
|
|
275
|
+
for (let i = 0; i < result.midpoints.length; i++) {
|
|
276
|
+
const m = result.midpoints[i];
|
|
277
|
+
if (!m) continue;
|
|
278
|
+
const dot = layer.children[i * 2] as HTMLDivElement;
|
|
279
|
+
const pill = layer.children[i * 2 + 1] as HTMLDivElement;
|
|
280
|
+
dot.style.left = `${m.x}px`;
|
|
281
|
+
dot.style.top = `${m.y}px`;
|
|
282
|
+
pill.style.left = `${m.x}px`;
|
|
283
|
+
pill.style.top = `${m.y}px`;
|
|
284
|
+
pill.textContent = label;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Re-export the picker for tests.
|
|
289
|
+
export { pickDetection };
|
|
@@ -115,9 +115,9 @@ const DIALOG_CSS = `
|
|
|
115
115
|
.dc-export-dialog {
|
|
116
116
|
border: 1px solid var(--u-fg-0, #1c1917);
|
|
117
117
|
padding: 0;
|
|
118
|
-
border-radius:
|
|
119
|
-
background: var(--u-bg-
|
|
120
|
-
box-shadow:
|
|
118
|
+
border-radius: 8px;
|
|
119
|
+
background: var(--u-bg-0, var(--bg-0, #fff));
|
|
120
|
+
box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
|
|
121
121
|
font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
122
122
|
color: var(--u-fg-0, var(--fg-0, #1a1a1a));
|
|
123
123
|
width: min(640px, 100vw - 48px);
|
|
@@ -554,6 +554,16 @@ export async function emitRegistryItem(opts: EmitOptions): Promise<RegistryItem>
|
|
|
554
554
|
// The trick: replace `DesignCanvas`, `DCArtboard`, `DCSection` in the
|
|
555
555
|
// libMap with their static-frame variants before BFS. The static variants
|
|
556
556
|
// have empty deps, so the transitive walk never reaches the engine code.
|
|
557
|
+
//
|
|
558
|
+
// Phase 3.7 / DDR-049 — motion helpers (MotionDemo, MotionTrack,
|
|
559
|
+
// TokenPlayback, ReducedMotionToggle, useMotionTokens, easingFromToken)
|
|
560
|
+
// depend on aliased imports from motion/react (_motionImpl,
|
|
561
|
+
// _useReducedMotion, _MotionAnimatePresence). When any of those land in the
|
|
562
|
+
// inlined output, splice the matching motion/react import line at the file
|
|
563
|
+
// head AND force-add "motion" to the registry-item's dependencies. The
|
|
564
|
+
// consumer's npm install + Next.js bundler resolves motion → bunx shadcn
|
|
565
|
+
// add lands an animated component with zero manual wiring.
|
|
566
|
+
let motionUsed = false;
|
|
557
567
|
if (opts.designRoot) {
|
|
558
568
|
const libPath = canvasLibPath(opts.designRoot);
|
|
559
569
|
const libFile = Bun.file(libPath);
|
|
@@ -563,6 +573,19 @@ export async function emitRegistryItem(opts: EmitOptions): Promise<RegistryItem>
|
|
|
563
573
|
applyHandoffStaticOverrides(libMap);
|
|
564
574
|
const inlined = inlineUsedExports(tsx, libMap);
|
|
565
575
|
tsx = inlined.content;
|
|
576
|
+
// Detect motion-helper usage from the inlined surface (the body refs
|
|
577
|
+
// _motionImpl / _useReducedMotion / _MotionAnimatePresence). We probe
|
|
578
|
+
// the post-inline source so we don't false-positive on a canvas that
|
|
579
|
+
// imports a non-motion helper sharing a name prefix.
|
|
580
|
+
motionUsed =
|
|
581
|
+
/\b_motionImpl\b/.test(tsx) ||
|
|
582
|
+
/\b_useReducedMotion\b/.test(tsx) ||
|
|
583
|
+
/\b_MotionAnimatePresence\b/.test(tsx);
|
|
584
|
+
if (motionUsed) {
|
|
585
|
+
const motionImport =
|
|
586
|
+
"import { motion as _motionImpl, useReducedMotion as _useReducedMotion, AnimatePresence as _MotionAnimatePresence } from 'motion/react';\n";
|
|
587
|
+
tsx = `${motionImport}${tsx}`;
|
|
588
|
+
}
|
|
566
589
|
}
|
|
567
590
|
}
|
|
568
591
|
|
|
@@ -578,6 +601,7 @@ export async function emitRegistryItem(opts: EmitOptions): Promise<RegistryItem>
|
|
|
578
601
|
const depSet = new Set(depsFiltered);
|
|
579
602
|
depSet.add('react');
|
|
580
603
|
depSet.add('react-dom');
|
|
604
|
+
if (motionUsed) depSet.add('motion');
|
|
581
605
|
const finalDeps = [...depSet].sort();
|
|
582
606
|
|
|
583
607
|
// Compute slug for `name` field — kebab-case of the file stem.
|
|
@@ -192,6 +192,33 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect): Http {
|
|
|
192
192
|
}
|
|
193
193
|
void libWatcher;
|
|
194
194
|
|
|
195
|
+
// G7v2 — canvas-lib.tsx transitively imports many dev-server siblings
|
|
196
|
+
// (canvas-shell, contextual-toolbar, equal-spacing-handles, ...). Editing
|
|
197
|
+
// any of them invalidates the bundled canvas output. Without watching them
|
|
198
|
+
// the mtime-keyed `canvasCache` keeps serving the stale bundle and the
|
|
199
|
+
// user sees pre-edit behaviour even after a hard iframe reload.
|
|
200
|
+
//
|
|
201
|
+
// Recursive watch over DEV_SERVER_ROOT, filtered to .tsx — server-only .ts
|
|
202
|
+
// (api / http / context / etc.) doesn't reach the canvas. Test files
|
|
203
|
+
// (`test/`) and built output (`dist/`, `client/`) also skipped.
|
|
204
|
+
let devSrcWatcher: ReturnType<typeof watch> | null = null;
|
|
205
|
+
try {
|
|
206
|
+
devSrcWatcher = watch(DEV_SERVER_ROOT, { recursive: true }, (_evt, filename) => {
|
|
207
|
+
if (!filename) return;
|
|
208
|
+
if (!filename.endsWith('.tsx')) return;
|
|
209
|
+
if (filename.startsWith('test/') || filename.startsWith('test\\')) return;
|
|
210
|
+
if (filename.startsWith('dist/') || filename.startsWith('client/')) return;
|
|
211
|
+
canvasCache.clear();
|
|
212
|
+
ctx.bus.emit('fs:any', `_lib/${filename}`);
|
|
213
|
+
});
|
|
214
|
+
} catch (err) {
|
|
215
|
+
console.warn(
|
|
216
|
+
'[dev-server-src] failed to watch source tree:',
|
|
217
|
+
err instanceof Error ? err.message : err
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
void devSrcWatcher;
|
|
221
|
+
|
|
195
222
|
async function readJson<T = unknown>(req: Request, max = 256 * 1024): Promise<T | null> {
|
|
196
223
|
try {
|
|
197
224
|
const text = await req.text();
|
|
@@ -218,8 +245,20 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect): Http {
|
|
|
218
245
|
'/_index-data': async () =>
|
|
219
246
|
Response.json(await api.buildIndexData(), { headers: { 'Cache-Control': 'no-store' } }),
|
|
220
247
|
|
|
221
|
-
'/_system-data': async () =>
|
|
222
|
-
|
|
248
|
+
'/_system-data': async (req: Request) => {
|
|
249
|
+
// DDR-048 — `?ds=<name>` scopes to one design system (per-DS tokens,
|
|
250
|
+
// per-DS preview gallery). Omitted = legacy top-level scan for
|
|
251
|
+
// backwards compat with single-DS projects.
|
|
252
|
+
const dsName = new URL(req.url).searchParams.get('ds');
|
|
253
|
+
const data = await api.buildSystemData(dsName);
|
|
254
|
+
if (data === null) {
|
|
255
|
+
return Response.json(
|
|
256
|
+
{ error: 'unknown design system', ds: dsName },
|
|
257
|
+
{ status: 404, headers: { 'Cache-Control': 'no-store' } }
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
return Response.json(data, { headers: { 'Cache-Control': 'no-store' } });
|
|
261
|
+
},
|
|
223
262
|
|
|
224
263
|
'/_comments-all': async () =>
|
|
225
264
|
Response.json(await api.loadAllComments(), { headers: { 'Cache-Control': 'no-store' } }),
|
|
@@ -29,6 +29,30 @@
|
|
|
29
29
|
|
|
30
30
|
import { type RefObject, useEffect } from 'react';
|
|
31
31
|
|
|
32
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
33
|
+
// Drag-vs-click threshold (T25)
|
|
34
|
+
//
|
|
35
|
+
// 4 px screen-pixel hypot separates "click" from "drag" — Microsoft Win32
|
|
36
|
+
// canonical (`SM_CXDRAG`/`SM_CYDRAG` default), also d3-drag and tldraw default.
|
|
37
|
+
// Owned here so artboard-drag, artboard-marquee, element-marquee, annotation-
|
|
38
|
+
// drag-vs-tap, and any future drag-class gesture all read the same constant.
|
|
39
|
+
// Wheel + pinch-zoom are EXEMPT — threshold is for `pointerdown → pointermove`
|
|
40
|
+
// drag classification only.
|
|
41
|
+
|
|
42
|
+
export const DRAG_THRESHOLD_PX = 4;
|
|
43
|
+
|
|
44
|
+
/** True once the pointer has moved ≥ DRAG_THRESHOLD_PX from its start. */
|
|
45
|
+
export function crossedDragThreshold(
|
|
46
|
+
startX: number,
|
|
47
|
+
startY: number,
|
|
48
|
+
curX: number,
|
|
49
|
+
curY: number
|
|
50
|
+
): boolean {
|
|
51
|
+
const dx = curX - startX;
|
|
52
|
+
const dy = curY - startY;
|
|
53
|
+
return Math.hypot(dx, dy) >= DRAG_THRESHOLD_PX;
|
|
54
|
+
}
|
|
55
|
+
|
|
32
56
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
33
57
|
// Types
|
|
34
58
|
|
|
@@ -68,7 +92,9 @@ export type RouterAction =
|
|
|
68
92
|
| { kind: 'drop-comment'; clientX: number; clientY: number }
|
|
69
93
|
| { kind: 'context-menu'; clientX: number; clientY: number }
|
|
70
94
|
| { kind: 'tool'; tool: Tool }
|
|
71
|
-
| { kind: 'escape' }
|
|
95
|
+
| { kind: 'escape' }
|
|
96
|
+
| { kind: 'undo' }
|
|
97
|
+
| { kind: 'redo' };
|
|
72
98
|
|
|
73
99
|
export interface ClassifyInput {
|
|
74
100
|
type: 'pointermove' | 'pointerdown' | 'contextmenu' | 'keydown';
|
|
@@ -101,6 +127,15 @@ export function classify(input: ClassifyInput): RouterAction {
|
|
|
101
127
|
if (input.metaKey || input.ctrlKey || input.altKey) {
|
|
102
128
|
// Esc with modifiers still dismisses.
|
|
103
129
|
if (input.key === 'Escape') return { kind: 'escape' };
|
|
130
|
+
// Undo / redo (Phase 20). Alt is reserved — Cmd+Opt+Z is a browser
|
|
131
|
+
// text-input gesture we don't claim. `metaKey || ctrlKey` covers both
|
|
132
|
+
// mac and Windows / Linux without a platform sniff.
|
|
133
|
+
const k = (input.key || '').toLowerCase();
|
|
134
|
+
if (!input.altKey && (input.metaKey || input.ctrlKey)) {
|
|
135
|
+
if (k === 'z' && input.shiftKey) return { kind: 'redo' };
|
|
136
|
+
if (k === 'z') return { kind: 'undo' };
|
|
137
|
+
if (k === 'y' && !input.shiftKey) return { kind: 'redo' };
|
|
138
|
+
}
|
|
104
139
|
return { kind: 'no-op' };
|
|
105
140
|
}
|
|
106
141
|
const k = (input.key || '').toLowerCase();
|
|
@@ -218,6 +253,10 @@ export interface RouterCallbacks {
|
|
|
218
253
|
onContextMenu?: (a: Extract<RouterAction, { kind: 'context-menu' }>) => void;
|
|
219
254
|
onTool?: (a: Extract<RouterAction, { kind: 'tool' }>) => void;
|
|
220
255
|
onEscape?: () => void;
|
|
256
|
+
/** Phase 20 — Cmd+Z / Ctrl+Z. */
|
|
257
|
+
onUndo?: () => void;
|
|
258
|
+
/** Phase 20 — Cmd+Shift+Z / Ctrl+Y / Cmd+Y. */
|
|
259
|
+
onRedo?: () => void;
|
|
221
260
|
}
|
|
222
261
|
|
|
223
262
|
export interface UseInputRouterOptions {
|
|
@@ -283,6 +322,12 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
|
|
|
283
322
|
case 'escape':
|
|
284
323
|
callbacks.onEscape?.();
|
|
285
324
|
break;
|
|
325
|
+
case 'undo':
|
|
326
|
+
callbacks.onUndo?.();
|
|
327
|
+
break;
|
|
328
|
+
case 'redo':
|
|
329
|
+
callbacks.onRedo?.();
|
|
330
|
+
break;
|
|
286
331
|
case 'no-op':
|
|
287
332
|
break;
|
|
288
333
|
}
|
|
@@ -404,7 +449,12 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
|
|
|
404
449
|
isEditable: isEditableTarget(e.target),
|
|
405
450
|
activeTool: getActiveTool(),
|
|
406
451
|
});
|
|
407
|
-
if (
|
|
452
|
+
if (
|
|
453
|
+
action.kind === 'tool' ||
|
|
454
|
+
action.kind === 'escape' ||
|
|
455
|
+
action.kind === 'undo' ||
|
|
456
|
+
action.kind === 'redo'
|
|
457
|
+
) {
|
|
408
458
|
e.preventDefault();
|
|
409
459
|
}
|
|
410
460
|
dispatch(action);
|
|
@@ -469,16 +519,30 @@ export function resolveHoverTarget(
|
|
|
469
519
|
const artboardEl = hit.closest?.('[data-dc-screen]') ?? null;
|
|
470
520
|
const artboardId = artboardEl?.getAttribute('data-dc-screen') ?? null;
|
|
471
521
|
|
|
472
|
-
// Hover-target hard ceiling = `.dc-artboard-body`.
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
//
|
|
522
|
+
// Hover-target hard ceiling = `.dc-artboard-body`. Inner DOM content lives
|
|
523
|
+
// there; chrome lives outside (label, header, article root). The two paths
|
|
524
|
+
// diverge from here:
|
|
525
|
+
// * hit ∈ body → resolve to the deepest stamped element (existing
|
|
526
|
+
// deep/top logic below).
|
|
527
|
+
// * hit ∈ chrome (label/header/article-root) → the user wants to select
|
|
528
|
+
// the WHOLE artboard. Return the article element itself with no cdId;
|
|
529
|
+
// consumers (hoverTargetToSelection) fall back to a
|
|
530
|
+
// `[data-dc-screen="…"]` selector that wraps the whole frame. This is
|
|
531
|
+
// what enables Cmd+Shift+Click multi-select of artboards (T24 / G8).
|
|
478
532
|
const bodyEl = hit.closest?.('.dc-artboard-body') ?? null;
|
|
479
|
-
if (!bodyEl)
|
|
533
|
+
if (!bodyEl) {
|
|
534
|
+
if (artboardEl && artboardId) {
|
|
535
|
+
return { el: artboardEl, cdId: null, artboardId };
|
|
536
|
+
}
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
480
539
|
if (hit === bodyEl) {
|
|
481
|
-
// Clicked
|
|
540
|
+
// Clicked the body wrapper itself (empty padding inside an artboard, no
|
|
541
|
+
// user content under the cursor). Promote to "select whole artboard" so
|
|
542
|
+
// the gesture stays consistent with chrome clicks above.
|
|
543
|
+
if (artboardEl && artboardId) {
|
|
544
|
+
return { el: artboardEl, cdId: null, artboardId };
|
|
545
|
+
}
|
|
482
546
|
return null;
|
|
483
547
|
}
|
|
484
548
|
|