@1agh/maude 0.19.1 → 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 +112 -38
- package/plugins/design/dev-server/annotations-layer.tsx +204 -95
- package/plugins/design/dev-server/artboard-marquee.tsx +8 -4
- 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 +506 -30
- package/plugins/design/dev-server/canvas-shell.tsx +352 -20
- package/plugins/design/dev-server/client/hmr.mjs +28 -0
- 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/contextual-toolbar.tsx +241 -0
- package/plugins/design/dev-server/dist/client.bundle.js +3 -3
- 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/equal-spacing-detector.ts +98 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +289 -0
- package/plugins/design/dev-server/handoff.ts +24 -0
- package/plugins/design/dev-server/http.ts +27 -0
- package/plugins/design/dev-server/input-router.tsx +52 -2
- 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/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/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/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/undo-hud.tsx +95 -0
- package/plugins/design/dev-server/undo-stack.ts +240 -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-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,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 };
|
|
@@ -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();
|
|
@@ -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);
|