@1agh/maude 0.22.2 → 0.23.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/cli/commands/design-link.test.mjs +53 -1
- package/cli/commands/hub.test.mjs +10 -9
- package/cli/lib/design-link.mjs +154 -7
- package/cli/lib/hubs-config.mjs +42 -4
- package/package.json +8 -8
- package/plugins/design/dev-server/build.ts +69 -3
- package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
- package/plugins/design/dev-server/canvas-lib.tsx +22 -6
- package/plugins/design/dev-server/canvas-shell.tsx +25 -226
- package/plugins/design/dev-server/client/app.jsx +37 -15
- package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
- package/plugins/design/dev-server/collab/registry.ts +51 -0
- package/plugins/design/dev-server/config.schema.json +20 -0
- package/plugins/design/dev-server/context.ts +7 -0
- package/plugins/design/dev-server/dist/client.bundle.js +21 -12
- package/plugins/design/dev-server/dist/comment-mount.js +1801 -0
- package/plugins/design/dev-server/dom-selection.ts +156 -0
- package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
- package/plugins/design/dev-server/input-router.tsx +99 -61
- package/plugins/design/dev-server/server.ts +18 -0
- package/plugins/design/dev-server/sync/agent.ts +323 -0
- package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
- package/plugins/design/dev-server/sync/codec.ts +169 -0
- package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
- package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
- package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
- package/plugins/design/dev-server/sync/index.ts +474 -0
- package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
- package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
- package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
- package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
- package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
- package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
- package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
- package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
- package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
- package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +285 -0
- package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
- package/plugins/design/dev-server/use-collab.tsx +157 -13
- package/plugins/design/dev-server/use-selection-set.tsx +12 -0
- package/plugins/design/dev-server/use-tool-mode.tsx +12 -0
- package/plugins/design/templates/_shell.html +15 -5
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file dom-selection.ts — selection-from-DOM helpers (leaf module)
|
|
3
|
+
* @scope plugins/design/dev-server/dom-selection.ts
|
|
4
|
+
* @purpose Pure DOM → Selection builders shared by the canvas chrome
|
|
5
|
+
* (canvas-shell.tsx) and the shell-owned comment mount layer
|
|
6
|
+
* (canvas-comment-mount.tsx). Lives in its own leaf module — no
|
|
7
|
+
* React, no canvas-lib import — so both consumers can lift the
|
|
8
|
+
* same `hoverTargetToSelection` / `deriveFile` logic without a
|
|
9
|
+
* cycle and without bundling the heavy DesignCanvas tree into the
|
|
10
|
+
* lite comment mount.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { HoverTarget } from './input-router.tsx';
|
|
14
|
+
import type { Selection } from './use-selection-set.tsx';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Canvas file path for the current page. Under the mount harness the page is
|
|
18
|
+
* `/_canvas-shell.html?canvas=<rel>&designRel=<root>`; for legacy `.html`
|
|
19
|
+
* mocks it's the served file path itself.
|
|
20
|
+
*/
|
|
21
|
+
export function deriveFile(): string | undefined {
|
|
22
|
+
if (typeof window === 'undefined') return undefined;
|
|
23
|
+
try {
|
|
24
|
+
const p = window.location.pathname;
|
|
25
|
+
if (p === '/_canvas-shell.html' || p === '/_canvas-shell') {
|
|
26
|
+
const qs = new URLSearchParams(window.location.search);
|
|
27
|
+
const canvas = qs.get('canvas') ?? '';
|
|
28
|
+
const designRel = (qs.get('designRel') ?? '.design').replace(/^\/+|\/+$/g, '');
|
|
29
|
+
return canvas ? `${designRel}/${canvas}` : undefined;
|
|
30
|
+
}
|
|
31
|
+
return decodeURIComponent(p).replace(/^\//, '');
|
|
32
|
+
} catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function realClasses(el: Element | null): string {
|
|
38
|
+
if (!el) return '';
|
|
39
|
+
return (el.getAttribute('class') ?? '')
|
|
40
|
+
.trim()
|
|
41
|
+
.split(/\s+/)
|
|
42
|
+
.filter((c) => c && !c.startsWith('dgn-') && !c.startsWith('dc-cv-'))
|
|
43
|
+
.join(' ');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function shortText(el: Element | null, max: number): string {
|
|
47
|
+
if (!el) return '';
|
|
48
|
+
const t = ((el as HTMLElement).innerText || el.textContent || '').replace(/\s+/g, ' ').trim();
|
|
49
|
+
return t.length > max ? `${t.slice(0, max - 1)}…` : t;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function cssPath(el: Element | null): string {
|
|
53
|
+
if (!el) return '';
|
|
54
|
+
const path: string[] = [];
|
|
55
|
+
let cur: Element | null = el;
|
|
56
|
+
while (cur && cur.nodeType === 1 && path.length < 8) {
|
|
57
|
+
const dscEl = cur.getAttribute?.('data-dc-element');
|
|
58
|
+
if (dscEl) {
|
|
59
|
+
path.unshift(`[data-dc-element="${dscEl}"]`);
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
const dscSc = cur.getAttribute?.('data-dc-screen');
|
|
63
|
+
if (dscSc) {
|
|
64
|
+
path.unshift(`[data-dc-screen="${dscSc}"]`);
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
let sel = cur.nodeName.toLowerCase();
|
|
68
|
+
if (cur.id) {
|
|
69
|
+
sel = `#${cur.id}`;
|
|
70
|
+
path.unshift(sel);
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
const cls = realClasses(cur).split(/\s+/).filter(Boolean).slice(0, 2);
|
|
74
|
+
if (cls.length) sel += `.${cls.join('.')}`;
|
|
75
|
+
let sib = 1;
|
|
76
|
+
let n: Element | null = cur.previousElementSibling;
|
|
77
|
+
while (n) {
|
|
78
|
+
sib++;
|
|
79
|
+
n = n.previousElementSibling;
|
|
80
|
+
}
|
|
81
|
+
sel += `:nth-child(${sib})`;
|
|
82
|
+
path.unshift(sel);
|
|
83
|
+
cur = cur.parentElement;
|
|
84
|
+
}
|
|
85
|
+
return path.join(' > ');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function domPath(el: Element | null): string[] {
|
|
89
|
+
const hops: string[] = [];
|
|
90
|
+
let cur = el;
|
|
91
|
+
while (cur && cur.nodeType === 1 && hops.length < 8) {
|
|
92
|
+
let label = cur.nodeName.toLowerCase();
|
|
93
|
+
const dEl = cur.getAttribute?.('data-dc-element');
|
|
94
|
+
const dSc = cur.getAttribute?.('data-dc-screen');
|
|
95
|
+
if (dEl) label += `[data-dc-element="${dEl}"]`;
|
|
96
|
+
else if (dSc) label += `[data-dc-screen="${dSc}"]`;
|
|
97
|
+
else if (cur.id) label += `#${cur.id}`;
|
|
98
|
+
const cls = realClasses(cur).split(/\s+/).filter(Boolean).slice(0, 2);
|
|
99
|
+
if (cls.length && !dEl && !dSc) label += `.${cls.join('.')}`;
|
|
100
|
+
hops.unshift(label);
|
|
101
|
+
cur = cur.parentElement;
|
|
102
|
+
}
|
|
103
|
+
return hops;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function cssEscape(s: string): string {
|
|
107
|
+
// Minimal CSS.escape polyfill — only handles chars actually present in
|
|
108
|
+
// pipeline-stamped IDs (alphanumerics + `-` + `_`).
|
|
109
|
+
return s.replace(/[^a-zA-Z0-9_-]/g, (c) => `\\${c}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Build the wire-shape `Selection` for a resolved hover target. `file`
|
|
114
|
+
* defaults to `deriveFile()`; the comment mount layer passes it explicitly
|
|
115
|
+
* so all three consumers (router, overlay, mount) agree on the same key.
|
|
116
|
+
*/
|
|
117
|
+
export function hoverTargetToSelection(target: HoverTarget, file?: string): Selection {
|
|
118
|
+
const el = target.el;
|
|
119
|
+
const rect =
|
|
120
|
+
el && (el as HTMLElement).getBoundingClientRect
|
|
121
|
+
? (el as HTMLElement).getBoundingClientRect()
|
|
122
|
+
: null;
|
|
123
|
+
// `cdId` is the hit element's OWN data-cd-id (deep mode); resolver never
|
|
124
|
+
// climbs to an ancestor. Falls back to cssPath of the hit when no stable
|
|
125
|
+
// anchor exists.
|
|
126
|
+
const cdId = target.cdId;
|
|
127
|
+
// Selector resolution order:
|
|
128
|
+
// 1. data-cd-id anchor — stable pipeline-stamped id (preferred).
|
|
129
|
+
// 2. data-dc-screen — chrome click promoted to whole-artboard select
|
|
130
|
+
// (T24.5 G8 multi-artboard gesture).
|
|
131
|
+
// 3. cssPath of the hit — last-resort path string.
|
|
132
|
+
const selector = cdId
|
|
133
|
+
? `[data-cd-id="${cdId}"]`
|
|
134
|
+
: !cdId && target.artboardId
|
|
135
|
+
? `[data-dc-screen="${target.artboardId}"]`
|
|
136
|
+
: cssPath(el);
|
|
137
|
+
return {
|
|
138
|
+
file: file ?? deriveFile(),
|
|
139
|
+
id: cdId ?? undefined,
|
|
140
|
+
selector,
|
|
141
|
+
artboardId: target.artboardId,
|
|
142
|
+
tag: el?.tagName.toLowerCase() ?? '',
|
|
143
|
+
classes: realClasses(el),
|
|
144
|
+
text: shortText(el, 240),
|
|
145
|
+
dom_path: domPath(el),
|
|
146
|
+
bounds: rect
|
|
147
|
+
? {
|
|
148
|
+
x: Math.round(rect.left),
|
|
149
|
+
y: Math.round(rect.top),
|
|
150
|
+
w: Math.round(rect.width),
|
|
151
|
+
h: Math.round(rect.height),
|
|
152
|
+
}
|
|
153
|
+
: null,
|
|
154
|
+
html: el ? (el.outerHTML ?? '').slice(0, 4000) : '',
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// loaded via the importmap + Bun.build-produced ESM — there's no React Fast
|
|
18
18
|
// Refresh runtime to register with. Full-reload is the reliable path.
|
|
19
19
|
|
|
20
|
+
import { existsSync } from 'node:fs';
|
|
20
21
|
import path from 'node:path';
|
|
21
22
|
|
|
22
23
|
import type { Context } from './context.ts';
|
|
@@ -64,26 +65,11 @@ export function createHmrBroadcaster(
|
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
function classify(filename: string): HmrMessage | null {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
if (ext === '.css') {
|
|
74
|
-
return { type: 'canvas-hmr', mode: 'css', file: rel, version, scope: 'canvas' };
|
|
75
|
-
}
|
|
76
|
-
// Phase 8 — canvas-meta sidecar (`<base>.meta.json`) carries the
|
|
77
|
-
// artboard layout / viewport. Emit a `meta` mode so foreign tabs can
|
|
78
|
-
// re-fetch + re-apply the layout WITHOUT a full reload (which would
|
|
79
|
-
// lose React state like tool mode, undo stack, scroll position).
|
|
80
|
-
if (rel.endsWith('.meta.json')) {
|
|
81
|
-
return { type: 'canvas-hmr', mode: 'meta', file: rel, version, scope: 'canvas' };
|
|
82
|
-
}
|
|
83
|
-
if (ext === '.tsx' || ext === '.jsx' || ext === '.ts' || ext === '.js') {
|
|
84
|
-
return { type: 'canvas-hmr', mode: 'module', file: rel, version, scope: 'canvas' };
|
|
85
|
-
}
|
|
86
|
-
return null;
|
|
68
|
+
return classifyChange(filename, (cssRel) => {
|
|
69
|
+
const root = ctx.paths?.designRoot;
|
|
70
|
+
if (!root) return false; // no root resolved → can't probe; keep css swap
|
|
71
|
+
return existsSync(path.join(root, cssRel.replace(/\.css$/i, '.tsx')));
|
|
72
|
+
});
|
|
87
73
|
}
|
|
88
74
|
|
|
89
75
|
function enqueue(msg: HmrMessage) {
|
|
@@ -125,3 +111,48 @@ export function createHmrBroadcaster(
|
|
|
125
111
|
// Helpers — exported for tests.
|
|
126
112
|
|
|
127
113
|
export const HMR_DEBOUNCE_MS = DEBOUNCE_MS;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Classify a changed design-root-relative path into an HMR message. Pure +
|
|
117
|
+
* fs-injected (`hasSiblingTsx`) so it unit-tests without touching disk.
|
|
118
|
+
*
|
|
119
|
+
* CSS routing is the load-bearing part. A canvas/specimen sibling stylesheet
|
|
120
|
+
* (e.g. `system/x/preview/motion.css` next to `motion.tsx`, pulled in via
|
|
121
|
+
* `import './motion.css'`) is INLINED into the built module as a `<style>` tag
|
|
122
|
+
* by canvas-build.ts — there is NO `<link>` for it. The iframe's `mode:'css'`
|
|
123
|
+
* handler swaps `<link href>` only, so a link swap for inlined CSS is a silent
|
|
124
|
+
* no-op and the edit never reaches the browser (the bug this fixes). For those
|
|
125
|
+
* we emit `mode:'module'` keyed on the sibling `.tsx`, so the open canvas
|
|
126
|
+
* reloads and re-inlines the fresh CSS via the existing module-reload path.
|
|
127
|
+
* Link-mounted CSS (DS tokens, `_components.css`, `_layout.css` — no sibling
|
|
128
|
+
* `.tsx`) keeps the fast, state-preserving css swap.
|
|
129
|
+
*/
|
|
130
|
+
export function classifyChange(
|
|
131
|
+
filename: string,
|
|
132
|
+
hasSiblingTsx: (cssRel: string) => boolean
|
|
133
|
+
): HmrMessage | null {
|
|
134
|
+
const rel = filename.replace(/\\/g, '/');
|
|
135
|
+
const ext = path.extname(rel).toLowerCase();
|
|
136
|
+
const version = Date.now();
|
|
137
|
+
if (rel.startsWith('_lib/')) {
|
|
138
|
+
return { type: 'canvas-hmr', mode: 'hard', scope: 'lib', version };
|
|
139
|
+
}
|
|
140
|
+
if (ext === '.css') {
|
|
141
|
+
if (hasSiblingTsx(rel)) {
|
|
142
|
+
const siblingTsx = rel.replace(/\.css$/i, '.tsx');
|
|
143
|
+
return { type: 'canvas-hmr', mode: 'module', file: siblingTsx, version, scope: 'canvas' };
|
|
144
|
+
}
|
|
145
|
+
return { type: 'canvas-hmr', mode: 'css', file: rel, version, scope: 'canvas' };
|
|
146
|
+
}
|
|
147
|
+
// Phase 8 — canvas-meta sidecar (`<base>.meta.json`) carries the
|
|
148
|
+
// artboard layout / viewport. Emit a `meta` mode so foreign tabs can
|
|
149
|
+
// re-fetch + re-apply the layout WITHOUT a full reload (which would
|
|
150
|
+
// lose React state like tool mode, undo stack, scroll position).
|
|
151
|
+
if (rel.endsWith('.meta.json')) {
|
|
152
|
+
return { type: 'canvas-hmr', mode: 'meta', file: rel, version, scope: 'canvas' };
|
|
153
|
+
}
|
|
154
|
+
if (ext === '.tsx' || ext === '.jsx' || ext === '.ts' || ext === '.js') {
|
|
155
|
+
return { type: 'canvas-hmr', mode: 'module', file: rel, version, scope: 'canvas' };
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
@@ -268,6 +268,16 @@ export interface UseInputRouterOptions {
|
|
|
268
268
|
callbacks: RouterCallbacks;
|
|
269
269
|
/** When false, listeners are not attached. Defaults to true. */
|
|
270
270
|
enabled?: boolean;
|
|
271
|
+
/**
|
|
272
|
+
* Allowlist of action kinds this router is permitted to CLAIM (preventDefault
|
|
273
|
+
* + stopImmediatePropagation + dispatch). Any classified action outside the
|
|
274
|
+
* set is downgraded to `no-op` so it propagates untouched to other listeners.
|
|
275
|
+
* Omit to claim everything (the default — used by the full DesignCanvas
|
|
276
|
+
* router). The shell-owned comment mount layer passes a narrow set so it can
|
|
277
|
+
* coexist as an ANCESTOR capture-listener over a UI canvas's own router
|
|
278
|
+
* without swallowing select / context-menu / undo gestures it doesn't own.
|
|
279
|
+
*/
|
|
280
|
+
claimableActions?: ReadonlySet<RouterAction['kind']>;
|
|
271
281
|
}
|
|
272
282
|
|
|
273
283
|
export function isEditableTarget(t: EventTarget | null): boolean {
|
|
@@ -295,13 +305,21 @@ export function isOverlayTarget(t: EventTarget | null): boolean {
|
|
|
295
305
|
}
|
|
296
306
|
|
|
297
307
|
export function useInputRouter(opts: UseInputRouterOptions): void {
|
|
298
|
-
const { hostRef, getActiveTool, isSpaceHeld, callbacks, enabled = true } = opts;
|
|
308
|
+
const { hostRef, getActiveTool, isSpaceHeld, callbacks, enabled = true, claimableActions } = opts;
|
|
299
309
|
|
|
300
310
|
useEffect(() => {
|
|
301
311
|
if (!enabled) return;
|
|
302
312
|
const host = hostRef.current;
|
|
303
313
|
if (!host) return;
|
|
304
314
|
|
|
315
|
+
// Downgrade any action this router isn't permitted to claim to no-op so it
|
|
316
|
+
// propagates untouched (no preventDefault / no dispatch). Identity pass-
|
|
317
|
+
// through when no allowlist is configured.
|
|
318
|
+
const claim = (action: RouterAction): RouterAction =>
|
|
319
|
+
claimableActions && action.kind !== 'no-op' && !claimableActions.has(action.kind)
|
|
320
|
+
? { kind: 'no-op' }
|
|
321
|
+
: action;
|
|
322
|
+
|
|
305
323
|
const dispatch = (action: RouterAction): void => {
|
|
306
324
|
switch (action.kind) {
|
|
307
325
|
case 'hover':
|
|
@@ -334,18 +352,20 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
|
|
|
334
352
|
};
|
|
335
353
|
|
|
336
354
|
const onPointerMove = (e: PointerEvent): void => {
|
|
337
|
-
const action =
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
355
|
+
const action = claim(
|
|
356
|
+
classify({
|
|
357
|
+
type: 'pointermove',
|
|
358
|
+
button: e.button,
|
|
359
|
+
metaKey: e.metaKey,
|
|
360
|
+
ctrlKey: e.ctrlKey,
|
|
361
|
+
shiftKey: e.shiftKey,
|
|
362
|
+
altKey: e.altKey,
|
|
363
|
+
clientX: e.clientX,
|
|
364
|
+
clientY: e.clientY,
|
|
365
|
+
spaceHeld: isSpaceHeld?.() ?? false,
|
|
366
|
+
activeTool: getActiveTool(),
|
|
367
|
+
})
|
|
368
|
+
);
|
|
349
369
|
dispatch(action);
|
|
350
370
|
};
|
|
351
371
|
|
|
@@ -354,18 +374,20 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
|
|
|
354
374
|
// their own clicks. The router is in capture phase, so we have to
|
|
355
375
|
// bail HERE before classify can claim the event.
|
|
356
376
|
if (isOverlayTarget(e.target)) return;
|
|
357
|
-
const action =
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
377
|
+
const action = claim(
|
|
378
|
+
classify({
|
|
379
|
+
type: 'pointerdown',
|
|
380
|
+
button: e.button,
|
|
381
|
+
metaKey: e.metaKey,
|
|
382
|
+
ctrlKey: e.ctrlKey,
|
|
383
|
+
shiftKey: e.shiftKey,
|
|
384
|
+
altKey: e.altKey,
|
|
385
|
+
clientX: e.clientX,
|
|
386
|
+
clientY: e.clientY,
|
|
387
|
+
spaceHeld: isSpaceHeld?.() ?? false,
|
|
388
|
+
activeTool: getActiveTool(),
|
|
389
|
+
})
|
|
390
|
+
);
|
|
369
391
|
if (action.kind !== 'no-op') {
|
|
370
392
|
// Suppress native behavior on every event the router claims —
|
|
371
393
|
// button presses don't fire, inputs don't focus, the canvas
|
|
@@ -386,18 +408,20 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
|
|
|
386
408
|
*/
|
|
387
409
|
const onMouseDown = (e: MouseEvent): void => {
|
|
388
410
|
if (isOverlayTarget(e.target)) return;
|
|
389
|
-
const action =
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
411
|
+
const action = claim(
|
|
412
|
+
classify({
|
|
413
|
+
type: 'pointerdown',
|
|
414
|
+
button: e.button,
|
|
415
|
+
metaKey: e.metaKey,
|
|
416
|
+
ctrlKey: e.ctrlKey,
|
|
417
|
+
shiftKey: e.shiftKey,
|
|
418
|
+
altKey: e.altKey,
|
|
419
|
+
clientX: e.clientX,
|
|
420
|
+
clientY: e.clientY,
|
|
421
|
+
spaceHeld: isSpaceHeld?.() ?? false,
|
|
422
|
+
activeTool: getActiveTool(),
|
|
423
|
+
})
|
|
424
|
+
);
|
|
401
425
|
if (action.kind !== 'no-op') {
|
|
402
426
|
e.preventDefault();
|
|
403
427
|
e.stopImmediatePropagation();
|
|
@@ -414,41 +438,55 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
|
|
|
414
438
|
if (isOverlayTarget(e.target)) return;
|
|
415
439
|
const tool = getActiveTool();
|
|
416
440
|
const mod = e.metaKey || e.ctrlKey;
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
441
|
+
// Map the click to the action kind the matching pointerdown would have
|
|
442
|
+
// produced, then honor the claim allowlist so a scoped router (the
|
|
443
|
+
// comment mount layer) doesn't suppress clicks it never claimed.
|
|
444
|
+
const wouldRouteKind: RouterAction['kind'] | null =
|
|
445
|
+
tool === 'comment'
|
|
446
|
+
? 'drop-comment'
|
|
447
|
+
: tool === 'move' && mod && e.button === 0
|
|
448
|
+
? 'select'
|
|
449
|
+
: e.button === 2
|
|
450
|
+
? 'context-menu'
|
|
451
|
+
: null;
|
|
452
|
+
if (wouldRouteKind && (!claimableActions || claimableActions.has(wouldRouteKind))) {
|
|
420
453
|
e.preventDefault();
|
|
421
454
|
e.stopImmediatePropagation();
|
|
422
455
|
}
|
|
423
456
|
};
|
|
424
457
|
|
|
425
458
|
const onContextMenu = (e: MouseEvent): void => {
|
|
459
|
+
const action = claim(
|
|
460
|
+
classify({
|
|
461
|
+
type: 'contextmenu',
|
|
462
|
+
clientX: e.clientX,
|
|
463
|
+
clientY: e.clientY,
|
|
464
|
+
metaKey: e.metaKey,
|
|
465
|
+
ctrlKey: e.ctrlKey,
|
|
466
|
+
shiftKey: e.shiftKey,
|
|
467
|
+
altKey: e.altKey,
|
|
468
|
+
activeTool: getActiveTool(),
|
|
469
|
+
})
|
|
470
|
+
);
|
|
471
|
+
if (action.kind === 'no-op') return; // not ours to claim — let it bubble
|
|
426
472
|
e.preventDefault();
|
|
427
473
|
e.stopImmediatePropagation();
|
|
428
|
-
const action = classify({
|
|
429
|
-
type: 'contextmenu',
|
|
430
|
-
clientX: e.clientX,
|
|
431
|
-
clientY: e.clientY,
|
|
432
|
-
metaKey: e.metaKey,
|
|
433
|
-
ctrlKey: e.ctrlKey,
|
|
434
|
-
shiftKey: e.shiftKey,
|
|
435
|
-
altKey: e.altKey,
|
|
436
|
-
activeTool: getActiveTool(),
|
|
437
|
-
});
|
|
438
474
|
dispatch(action);
|
|
439
475
|
};
|
|
440
476
|
|
|
441
477
|
const onKeyDown = (e: KeyboardEvent): void => {
|
|
442
|
-
const action =
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
478
|
+
const action = claim(
|
|
479
|
+
classify({
|
|
480
|
+
type: 'keydown',
|
|
481
|
+
key: e.key,
|
|
482
|
+
metaKey: e.metaKey,
|
|
483
|
+
ctrlKey: e.ctrlKey,
|
|
484
|
+
shiftKey: e.shiftKey,
|
|
485
|
+
altKey: e.altKey,
|
|
486
|
+
isEditable: isEditableTarget(e.target),
|
|
487
|
+
activeTool: getActiveTool(),
|
|
488
|
+
})
|
|
489
|
+
);
|
|
452
490
|
if (
|
|
453
491
|
action.kind === 'tool' ||
|
|
454
492
|
action.kind === 'escape' ||
|
|
@@ -486,7 +524,7 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
|
|
|
486
524
|
} as EventListenerOptions);
|
|
487
525
|
doc.removeEventListener('keydown', onKeyDown, true);
|
|
488
526
|
};
|
|
489
|
-
}, [enabled, hostRef, getActiveTool, isSpaceHeld, callbacks]);
|
|
527
|
+
}, [enabled, hostRef, getActiveTool, isSpaceHeld, callbacks, claimableActions]);
|
|
490
528
|
}
|
|
491
529
|
|
|
492
530
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -26,6 +26,7 @@ import { createFsWatch } from './fs-watch.ts';
|
|
|
26
26
|
import { createHttp } from './http.ts';
|
|
27
27
|
import { createInspect } from './inspect.ts';
|
|
28
28
|
import { startHeapWatch } from './mem.ts';
|
|
29
|
+
import { createSyncRuntime } from './sync/index.ts';
|
|
29
30
|
import { type WsData, createWs, isLoopbackHost, parseCollabSlug } from './ws.ts';
|
|
30
31
|
|
|
31
32
|
// Phase 19 / DDR-044 — covers the marketplace-cache-install gap where
|
|
@@ -199,6 +200,18 @@ await Bun.write(
|
|
|
199
200
|
fsWatch.start();
|
|
200
201
|
startHeapWatch();
|
|
201
202
|
|
|
203
|
+
// Phase 9 Task 4 — bidirectional sync agent. No-op when the project isn't
|
|
204
|
+
// linked to a hub (`.design/config.json` has no `linkedHub` field). Kicked
|
|
205
|
+
// off after fsWatch so the agent's bus subscription receives every fs event.
|
|
206
|
+
const syncRuntime = createSyncRuntime(ctx, collab ? { registry: collab.registry } : {});
|
|
207
|
+
if (syncRuntime) {
|
|
208
|
+
try {
|
|
209
|
+
await syncRuntime.start();
|
|
210
|
+
} catch (err) {
|
|
211
|
+
console.error('[sync] startup failed — continuing in solo mode:', err);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
202
215
|
const url = `http://localhost:${server.port}`;
|
|
203
216
|
console.log(`\n ${ctx.projectLabel} — local browser`);
|
|
204
217
|
console.log(' ─────────────────────────────');
|
|
@@ -219,6 +232,11 @@ if (!process.env.NO_OPEN) {
|
|
|
219
232
|
async function shutdown() {
|
|
220
233
|
console.log('\n Stopping…');
|
|
221
234
|
fsWatch.stop();
|
|
235
|
+
try {
|
|
236
|
+
if (syncRuntime) await syncRuntime.stop();
|
|
237
|
+
} catch {
|
|
238
|
+
/* best-effort — provider sockets will be closed by process exit anyway */
|
|
239
|
+
}
|
|
222
240
|
try {
|
|
223
241
|
if (collab) await collab.registry.destroyAll();
|
|
224
242
|
} catch {
|