@1agh/maude 0.19.1 → 0.21.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/README.md +7 -0
- package/cli/bin/maude.mjs +5 -1
- package/cli/commands/design-link.test.mjs +207 -0
- package/cli/commands/design.mjs +42 -12
- package/cli/commands/doctor.mjs +350 -0
- package/cli/commands/doctor.test.mjs +185 -0
- package/cli/commands/help.mjs +24 -0
- package/cli/commands/hub.mjs +231 -0
- package/cli/commands/hub.test.mjs +87 -0
- package/cli/lib/config-lint.mjs +141 -0
- package/cli/lib/config-lint.test.mjs +117 -0
- package/cli/lib/design-link.mjs +216 -0
- package/cli/lib/hubs-config.mjs +123 -0
- package/cli/lib/hubs-config.test.mjs +100 -0
- package/cli/lib/preflight.mjs +232 -0
- package/cli/lib/stack-detect.mjs +344 -0
- package/cli/lib/stack-detect.test.mjs +121 -0
- package/package.json +17 -9
- package/plugins/design/dependencies.json +147 -0
- package/plugins/design/dependencies.schema.json +107 -0
- package/plugins/design/dev-server/ai-banner.tsx +188 -0
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +115 -41
- package/plugins/design/dev-server/annotations-layer.tsx +256 -107
- package/plugins/design/dev-server/api.ts +17 -1
- package/plugins/design/dev-server/artboard-marquee.tsx +10 -6
- package/plugins/design/dev-server/bin/asset-sweep.sh +180 -0
- package/plugins/design/dev-server/bin/preflight.sh +32 -0
- package/plugins/design/dev-server/bin/runtime-health.sh +169 -0
- package/plugins/design/dev-server/bin/visual-sanity.sh +221 -0
- package/plugins/design/dev-server/canvas-lib.tsx +534 -32
- package/plugins/design/dev-server/canvas-shell.tsx +479 -29
- package/plugins/design/dev-server/client/app.jsx +72 -0
- package/plugins/design/dev-server/client/hmr.mjs +28 -0
- package/plugins/design/dev-server/collab/ai-activity.ts +127 -0
- package/plugins/design/dev-server/collab/git-lifecycle.ts +124 -0
- package/plugins/design/dev-server/collab/index.ts +47 -0
- package/plugins/design/dev-server/collab/persistence.ts +123 -0
- package/plugins/design/dev-server/collab/protocol.ts +108 -0
- package/plugins/design/dev-server/collab/registry.ts +110 -0
- package/plugins/design/dev-server/collab/room.ts +215 -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 +41 -4
- package/plugins/design/dev-server/contextual-toolbar.tsx +241 -0
- package/plugins/design/dev-server/cursors-overlay.tsx +296 -0
- package/plugins/design/dev-server/dist/client.bundle.js +75 -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/dist/runtime/y-protocols_awareness.js +587 -0
- package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +257 -0
- package/plugins/design/dev-server/dist/runtime/yjs.js +7107 -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/export-dialog.tsx +1 -1
- package/plugins/design/dev-server/handoff.ts +24 -0
- package/plugins/design/dev-server/hmr-broadcast.ts +15 -2
- package/plugins/design/dev-server/http.ts +91 -1
- 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/participants-chrome.tsx +261 -0
- package/plugins/design/dev-server/runtime-bundle.ts +15 -0
- package/plugins/design/dev-server/server.ts +78 -11
- package/plugins/design/dev-server/test/ai-activity.test.ts +113 -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/collab-annotations-bridge.test.ts +55 -0
- package/plugins/design/dev-server/test/collab-bridge.test.ts +81 -0
- package/plugins/design/dev-server/test/collab-loopback.test.ts +63 -0
- package/plugins/design/dev-server/test/collab-protocol.test.ts +146 -0
- package/plugins/design/dev-server/test/collab-room.test.ts +182 -0
- package/plugins/design/dev-server/test/collab-stress.test.ts +121 -0
- package/plugins/design/dev-server/test/equal-spacing-detector.test.ts +93 -0
- package/plugins/design/dev-server/test/git-lifecycle.test.ts +101 -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/participants-chrome.test.ts +30 -0
- package/plugins/design/dev-server/test/undo-stack.test.ts +211 -0
- package/plugins/design/dev-server/test/use-collab.test.ts +71 -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 +7 -7
- 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 +1 -1
- package/plugins/design/dev-server/use-artboard-drag.tsx +6 -3
- package/plugins/design/dev-server/use-collab.tsx +478 -0
- 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/dev-server/ws.ts +123 -7
- package/plugins/design/templates/_shell.html +51 -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/flow/.claude-plugin/config.schema.json +12 -0
- package/plugins/flow/dependencies.json +143 -0
- package/plugins/flow/dependencies.schema.json +107 -0
- /package/plugins/design/templates/design-system-inspiration/core/preview/{motion.html → .archive/motion.html} +0 -0
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file use-collab.tsx — client-side Yjs collab provider for canvas iframes
|
|
3
|
+
* @scope plugins/design/dev-server/use-collab.tsx
|
|
4
|
+
* @purpose Mounts a single Y.Doc + Awareness per canvas iframe. Opens a
|
|
5
|
+
* WebSocket to `/_ws/collab/:slug`, speaks the y-websocket binary
|
|
6
|
+
* protocol, exposes hooks for the cursor overlay + Task 3 comments
|
|
7
|
+
* binding.
|
|
8
|
+
*
|
|
9
|
+
* Boundary:
|
|
10
|
+
* - Server-side equivalent is `collab/protocol.ts` + `collab/room.ts`.
|
|
11
|
+
* - This file mirrors the message framing (varint-prefixed sync + awareness
|
|
12
|
+
* frames) so the two sides converge over a binary WS without intermediate
|
|
13
|
+
* JSON.
|
|
14
|
+
* - Imports `yjs` + `y-protocols/{sync,awareness}` via the canvas-shell
|
|
15
|
+
* importmap (RUNTIME_PACKAGES additions). Canvas bundles that don't mount
|
|
16
|
+
* <CollabProvider> never resolve these specifiers and pay zero bundle cost.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
type ReactNode,
|
|
21
|
+
createContext,
|
|
22
|
+
useCallback,
|
|
23
|
+
useContext,
|
|
24
|
+
useEffect,
|
|
25
|
+
useMemo,
|
|
26
|
+
useRef,
|
|
27
|
+
useState,
|
|
28
|
+
} from 'react';
|
|
29
|
+
|
|
30
|
+
import * as decoding from 'lib0/decoding';
|
|
31
|
+
import * as encoding from 'lib0/encoding';
|
|
32
|
+
import { Awareness, applyAwarenessUpdate, encodeAwarenessUpdate } from 'y-protocols/awareness';
|
|
33
|
+
import { readSyncMessage, writeSyncStep1, writeUpdate } from 'y-protocols/sync';
|
|
34
|
+
import * as Y from 'yjs';
|
|
35
|
+
|
|
36
|
+
const MESSAGE_SYNC = 0;
|
|
37
|
+
const MESSAGE_AWARENESS = 1;
|
|
38
|
+
|
|
39
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
40
|
+
// Color hash — stable per peer identity.
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* djb2 string hash → 0xRRGGBB color in a curated palette. Determinism per
|
|
44
|
+
* input name is the load-bearing property: every peer hashing "Alice" must
|
|
45
|
+
* land on the SAME color. 12 hues spread evenly around the wheel; saturation
|
|
46
|
+
* + lightness fixed so all colors stay readable on light + dark surfaces.
|
|
47
|
+
*/
|
|
48
|
+
const COLOR_PALETTE = [
|
|
49
|
+
'#e91e63', // pink
|
|
50
|
+
'#f44336', // red
|
|
51
|
+
'#ff9800', // orange
|
|
52
|
+
'#ffc107', // amber
|
|
53
|
+
'#cddc39', // lime
|
|
54
|
+
'#4caf50', // green
|
|
55
|
+
'#009688', // teal
|
|
56
|
+
'#00bcd4', // cyan
|
|
57
|
+
'#03a9f4', // light blue
|
|
58
|
+
'#3f51b5', // indigo
|
|
59
|
+
'#673ab7', // deep purple
|
|
60
|
+
'#9c27b0', // purple
|
|
61
|
+
] as const;
|
|
62
|
+
|
|
63
|
+
export function colorForName(name: string): string {
|
|
64
|
+
// COLOR_PALETTE is a non-empty const tuple; the explicit `?? '#000'`
|
|
65
|
+
// fallback is unreachable but satisfies `noUncheckedIndexedAccess`.
|
|
66
|
+
const FALLBACK = '#000000';
|
|
67
|
+
if (!name) return COLOR_PALETTE[0] ?? FALLBACK;
|
|
68
|
+
let hash = 5381;
|
|
69
|
+
for (let i = 0; i < name.length; i++) {
|
|
70
|
+
hash = ((hash << 5) + hash + name.charCodeAt(i)) | 0;
|
|
71
|
+
}
|
|
72
|
+
const idx = ((hash % COLOR_PALETTE.length) + COLOR_PALETTE.length) % COLOR_PALETTE.length;
|
|
73
|
+
return COLOR_PALETTE[idx] ?? FALLBACK;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
77
|
+
// Awareness state shape.
|
|
78
|
+
|
|
79
|
+
export interface CollabAwarenessState {
|
|
80
|
+
name: string;
|
|
81
|
+
color: string;
|
|
82
|
+
/**
|
|
83
|
+
* Cursor position in **world coords** (canvas-lib viewport space) so foreign
|
|
84
|
+
* peers see the same conceptual point even when their local viewport is
|
|
85
|
+
* panned/zoomed differently. Null = peer is not over the canvas surface.
|
|
86
|
+
*/
|
|
87
|
+
cursor: { x: number; y: number } | null;
|
|
88
|
+
/**
|
|
89
|
+
* Most-recently selected element. `cssPath` is the locator id chain the
|
|
90
|
+
* canvas-shell already uses; `bounds` are screen-px rect at the moment of
|
|
91
|
+
* publish (so it's a hint, not a live ref). Null when nothing selected.
|
|
92
|
+
*/
|
|
93
|
+
selection: { cssPath: string; bounds: { x: number; y: number; w: number; h: number } } | null;
|
|
94
|
+
/**
|
|
95
|
+
* Currently-selected annotation stroke IDs (Phase 5). Strokes are addressed
|
|
96
|
+
* by their stable `data-id` attribute, so peers can resolve halos via
|
|
97
|
+
* `document.querySelectorAll('[data-id="<id>"]')`. Empty when nothing
|
|
98
|
+
* annotation-shaped is selected.
|
|
99
|
+
*/
|
|
100
|
+
annotationSelection: string[];
|
|
101
|
+
viewport: { x: number; y: number; zoom: number };
|
|
102
|
+
/**
|
|
103
|
+
* Server-side `disconnect` matches awareness states to outgoing peers by
|
|
104
|
+
* this token (must equal the ws.data.id the server assigns at upgrade).
|
|
105
|
+
* Until the server pushes the assigned id back to the client, we use a
|
|
106
|
+
* client-generated UUID — collisions are negligible and disconnect cleanup
|
|
107
|
+
* tolerates a stale state (the next awareness GC pass drops it).
|
|
108
|
+
*/
|
|
109
|
+
__connId: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export type ForeignAwareness = Omit<CollabAwarenessState, '__connId'> & { clientID: number };
|
|
113
|
+
|
|
114
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
115
|
+
// Context.
|
|
116
|
+
|
|
117
|
+
interface CollabValue {
|
|
118
|
+
doc: Y.Doc;
|
|
119
|
+
awareness: Awareness;
|
|
120
|
+
/** Local peer's session-stable color (derived from git user.name). */
|
|
121
|
+
myColor: string;
|
|
122
|
+
/** Local peer's display name (git user.name or anonymous fallback). */
|
|
123
|
+
myName: string;
|
|
124
|
+
/** Local peer's connection id (matches server-side ws.data.id pattern). */
|
|
125
|
+
myConnId: string;
|
|
126
|
+
/** True when the WS is OPEN. Cursor overlay can use this to gate rendering. */
|
|
127
|
+
connected: boolean;
|
|
128
|
+
/** Publish (debounce-coalesced) an updated local awareness state. */
|
|
129
|
+
publishAwareness: (patch: Partial<Omit<CollabAwarenessState, '__connId'>>) => void;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const CollabContext = createContext<CollabValue | null>(null);
|
|
133
|
+
|
|
134
|
+
export function useCollab(): CollabValue | null {
|
|
135
|
+
return useContext(CollabContext);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
139
|
+
// Hook: foreign awareness peers (the cursor overlay subscribes to this).
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Returns the current set of foreign peers (excludes the local client). The
|
|
143
|
+
* returned array is stable-reference between awareness updates — useful for
|
|
144
|
+
* downstream React.memo cursor components.
|
|
145
|
+
*/
|
|
146
|
+
export function useForeignAwareness(): ForeignAwareness[] {
|
|
147
|
+
const collab = useCollab();
|
|
148
|
+
const [peers, setPeers] = useState<ForeignAwareness[]>([]);
|
|
149
|
+
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
if (!collab) {
|
|
152
|
+
setPeers([]);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const { awareness } = collab;
|
|
156
|
+
function compute(): ForeignAwareness[] {
|
|
157
|
+
const out: ForeignAwareness[] = [];
|
|
158
|
+
const myId = awareness.clientID;
|
|
159
|
+
for (const [clientID, state] of awareness.getStates() as Map<number, unknown>) {
|
|
160
|
+
if (clientID === myId) continue;
|
|
161
|
+
if (!state || typeof state !== 'object') continue;
|
|
162
|
+
const s = state as Partial<CollabAwarenessState>;
|
|
163
|
+
// Guard against partial states from stale peers / older protocol versions.
|
|
164
|
+
if (typeof s.name !== 'string' || typeof s.color !== 'string') continue;
|
|
165
|
+
out.push({
|
|
166
|
+
clientID,
|
|
167
|
+
name: s.name,
|
|
168
|
+
color: s.color,
|
|
169
|
+
cursor: s.cursor ?? null,
|
|
170
|
+
selection: s.selection ?? null,
|
|
171
|
+
annotationSelection: Array.isArray(s.annotationSelection) ? s.annotationSelection : [],
|
|
172
|
+
viewport: s.viewport ?? { x: 0, y: 0, zoom: 1 },
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
setPeers(compute());
|
|
178
|
+
const onChange = () => setPeers(compute());
|
|
179
|
+
awareness.on('change', onChange);
|
|
180
|
+
return () => {
|
|
181
|
+
awareness.off('change', onChange);
|
|
182
|
+
};
|
|
183
|
+
}, [collab]);
|
|
184
|
+
|
|
185
|
+
return peers;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
189
|
+
// Slug derivation — must match `api.fileSlug` server-side.
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Mirror of server-side `api.fileSlug`. The input is the canvas path as the
|
|
193
|
+
* shell stored it on `window.__canvas_meta_file__` (e.g. `.design/ui/Foo.tsx`).
|
|
194
|
+
* Strip the designRel prefix (read from `window.__canvas_design_rel__`, set
|
|
195
|
+
* by _shell.html) so both sides land on the same slug — without this both
|
|
196
|
+
* tabs open a `design-ui-foo` room while the server's inspector bridge
|
|
197
|
+
* pushes into `ui-foo`, and the rooms never converge.
|
|
198
|
+
*/
|
|
199
|
+
export function canvasSlugFromPath(canvasRel: string | null | undefined): string | null {
|
|
200
|
+
if (!canvasRel) return null;
|
|
201
|
+
let p = canvasRel.replace(/^\/+|\/+$/g, '');
|
|
202
|
+
if (typeof window !== 'undefined') {
|
|
203
|
+
const w = window as unknown as { __canvas_design_rel__?: string };
|
|
204
|
+
const designRel = (w.__canvas_design_rel__ ?? '').replace(/^\/+|\/+$/g, '');
|
|
205
|
+
if (designRel && p.startsWith(`${designRel}/`)) p = p.slice(designRel.length + 1);
|
|
206
|
+
}
|
|
207
|
+
const slug = p
|
|
208
|
+
.replace(/\//g, '-')
|
|
209
|
+
.replace(/\s+/g, '_')
|
|
210
|
+
.replace(/\.(tsx|html)$/i, '')
|
|
211
|
+
.replace(/^\.+/, '')
|
|
212
|
+
.toLowerCase();
|
|
213
|
+
return /^[a-z0-9_-]+$/.test(slug) ? slug : null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
217
|
+
// Provider — opens WS, owns Y.Doc + Awareness lifecycle.
|
|
218
|
+
|
|
219
|
+
interface CollabProviderProps {
|
|
220
|
+
/** Canvas slug — must match server-side `parseCollabSlug`. */
|
|
221
|
+
slug: string;
|
|
222
|
+
children: ReactNode;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const AWARENESS_THROTTLE_MS = 33; // ~30 Hz
|
|
226
|
+
|
|
227
|
+
export function CollabProvider({ slug, children }: CollabProviderProps): JSX.Element {
|
|
228
|
+
// Y.Doc + Awareness are recreated whenever the slug changes (switching
|
|
229
|
+
// canvases tears down the prior session cleanly). The useMemo factory
|
|
230
|
+
// bodies don't read `slug` — slug IS the cache key, intentionally.
|
|
231
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: slug is the cache key
|
|
232
|
+
const doc = useMemo(() => new Y.Doc(), [slug]);
|
|
233
|
+
const awareness = useMemo(() => new Awareness(doc), [doc]);
|
|
234
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: slug is the cache key
|
|
235
|
+
const myConnId = useMemo(() => crypto.randomUUID(), [slug]);
|
|
236
|
+
|
|
237
|
+
const [myName, setMyName] = useState('anonymous');
|
|
238
|
+
const [myColor, setMyColor] = useState(colorForName('anonymous'));
|
|
239
|
+
const [connected, setConnected] = useState(false);
|
|
240
|
+
|
|
241
|
+
// Fetch git user.name once per slug; falls back to anonymous-<short id>.
|
|
242
|
+
useEffect(() => {
|
|
243
|
+
let cancelled = false;
|
|
244
|
+
fetch('/_api/git-user')
|
|
245
|
+
.then((r) => r.json())
|
|
246
|
+
.then((j) => {
|
|
247
|
+
if (cancelled) return;
|
|
248
|
+
const n = typeof j?.name === 'string' && j.name.trim() ? j.name.trim() : null;
|
|
249
|
+
const finalName = n ?? `anonymous-${myConnId.slice(0, 6)}`;
|
|
250
|
+
setMyName(finalName);
|
|
251
|
+
setMyColor(colorForName(finalName));
|
|
252
|
+
})
|
|
253
|
+
.catch(() => {
|
|
254
|
+
if (cancelled) return;
|
|
255
|
+
const fallback = `anonymous-${myConnId.slice(0, 6)}`;
|
|
256
|
+
setMyName(fallback);
|
|
257
|
+
setMyColor(colorForName(fallback));
|
|
258
|
+
});
|
|
259
|
+
return () => {
|
|
260
|
+
cancelled = true;
|
|
261
|
+
};
|
|
262
|
+
}, [myConnId]);
|
|
263
|
+
|
|
264
|
+
// Seed local awareness state immediately so foreign peers see our name even
|
|
265
|
+
// before the cursor moves. Update when myName/myColor settles from the fetch.
|
|
266
|
+
useEffect(() => {
|
|
267
|
+
awareness.setLocalState({
|
|
268
|
+
name: myName,
|
|
269
|
+
color: myColor,
|
|
270
|
+
cursor: null,
|
|
271
|
+
selection: null,
|
|
272
|
+
annotationSelection: [],
|
|
273
|
+
viewport: { x: 0, y: 0, zoom: 1 },
|
|
274
|
+
__connId: myConnId,
|
|
275
|
+
} satisfies CollabAwarenessState);
|
|
276
|
+
}, [awareness, myName, myColor, myConnId]);
|
|
277
|
+
|
|
278
|
+
// ── WebSocket lifecycle ──────────────────────────────────────────────────
|
|
279
|
+
const wsRef = useRef<WebSocket | null>(null);
|
|
280
|
+
|
|
281
|
+
useEffect(() => {
|
|
282
|
+
let cancelled = false;
|
|
283
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
284
|
+
|
|
285
|
+
function sendFrame(ws: WebSocket, payload: Uint8Array) {
|
|
286
|
+
try {
|
|
287
|
+
ws.send(payload);
|
|
288
|
+
} catch {
|
|
289
|
+
/* dead socket — close handler will reconnect */
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function broadcastAwareness(ws: WebSocket, changed: number[]) {
|
|
294
|
+
if (changed.length === 0) return;
|
|
295
|
+
const encoder = encoding.createEncoder();
|
|
296
|
+
encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
|
|
297
|
+
encoding.writeVarUint8Array(encoder, encodeAwarenessUpdate(awareness, changed));
|
|
298
|
+
sendFrame(ws, encoding.toUint8Array(encoder));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function broadcastSyncUpdate(ws: WebSocket, update: Uint8Array) {
|
|
302
|
+
const encoder = encoding.createEncoder();
|
|
303
|
+
encoding.writeVarUint(encoder, MESSAGE_SYNC);
|
|
304
|
+
writeUpdate(encoder, update);
|
|
305
|
+
sendFrame(ws, encoding.toUint8Array(encoder));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function connect() {
|
|
309
|
+
if (cancelled) return;
|
|
310
|
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
311
|
+
const ws = new WebSocket(`${proto}//${location.host}/_ws/collab/${slug}`);
|
|
312
|
+
ws.binaryType = 'arraybuffer';
|
|
313
|
+
wsRef.current = ws;
|
|
314
|
+
|
|
315
|
+
ws.addEventListener('open', () => {
|
|
316
|
+
setConnected(true);
|
|
317
|
+
// Sync step 1 — announce our state vector so the server can send the
|
|
318
|
+
// missing pieces (matches the encodeHandshake server path).
|
|
319
|
+
const encoder = encoding.createEncoder();
|
|
320
|
+
encoding.writeVarUint(encoder, MESSAGE_SYNC);
|
|
321
|
+
writeSyncStep1(encoder, doc);
|
|
322
|
+
sendFrame(ws, encoding.toUint8Array(encoder));
|
|
323
|
+
// Awareness initial state — fire our local state to the room.
|
|
324
|
+
broadcastAwareness(ws, [awareness.clientID]);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
ws.addEventListener('close', () => {
|
|
328
|
+
setConnected(false);
|
|
329
|
+
wsRef.current = null;
|
|
330
|
+
if (cancelled) return;
|
|
331
|
+
reconnectTimer = setTimeout(connect, 1000);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
ws.addEventListener('error', () => {
|
|
335
|
+
// Let close handler do the reconnect; error events without a close
|
|
336
|
+
// would just retry-spam.
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
ws.addEventListener('message', (evt) => {
|
|
340
|
+
const payload =
|
|
341
|
+
evt.data instanceof ArrayBuffer
|
|
342
|
+
? new Uint8Array(evt.data)
|
|
343
|
+
: evt.data instanceof Uint8Array
|
|
344
|
+
? evt.data
|
|
345
|
+
: null;
|
|
346
|
+
if (!payload) return;
|
|
347
|
+
const decoder = decoding.createDecoder(payload);
|
|
348
|
+
const messageType = decoding.readVarUint(decoder);
|
|
349
|
+
switch (messageType) {
|
|
350
|
+
case MESSAGE_SYNC: {
|
|
351
|
+
const encoder = encoding.createEncoder();
|
|
352
|
+
encoding.writeVarUint(encoder, MESSAGE_SYNC);
|
|
353
|
+
readSyncMessage(decoder, encoder, doc, ws);
|
|
354
|
+
if (encoding.length(encoder) > 1) sendFrame(ws, encoding.toUint8Array(encoder));
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
case MESSAGE_AWARENESS: {
|
|
358
|
+
applyAwarenessUpdate(awareness, decoding.readVarUint8Array(decoder), ws);
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
default:
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Wire doc updates → broadcast to server. Origin tagged with the ws ref so
|
|
368
|
+
// server-side updates we receive don't echo back.
|
|
369
|
+
const onDocUpdate = (update: Uint8Array, origin: unknown) => {
|
|
370
|
+
const ws = wsRef.current;
|
|
371
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
372
|
+
if (origin === ws) return; // came from server, don't echo
|
|
373
|
+
broadcastSyncUpdate(ws, update);
|
|
374
|
+
};
|
|
375
|
+
doc.on('update', onDocUpdate);
|
|
376
|
+
|
|
377
|
+
// Wire awareness changes → broadcast. Same origin guard.
|
|
378
|
+
const onAwarenessUpdate = (
|
|
379
|
+
{ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },
|
|
380
|
+
origin: unknown
|
|
381
|
+
) => {
|
|
382
|
+
const ws = wsRef.current;
|
|
383
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
384
|
+
if (origin === ws) return;
|
|
385
|
+
const changed = added.concat(updated, removed);
|
|
386
|
+
broadcastAwareness(ws, changed);
|
|
387
|
+
};
|
|
388
|
+
awareness.on('update', onAwarenessUpdate);
|
|
389
|
+
|
|
390
|
+
connect();
|
|
391
|
+
|
|
392
|
+
return () => {
|
|
393
|
+
cancelled = true;
|
|
394
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
395
|
+
doc.off('update', onDocUpdate);
|
|
396
|
+
awareness.off('update', onAwarenessUpdate);
|
|
397
|
+
const ws = wsRef.current;
|
|
398
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
399
|
+
try {
|
|
400
|
+
ws.close();
|
|
401
|
+
} catch {
|
|
402
|
+
/* ignore */
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
// Don't destroy doc/awareness here — the useMemo-tied lifetime handles
|
|
406
|
+
// that when the slug changes.
|
|
407
|
+
};
|
|
408
|
+
}, [slug, doc, awareness]);
|
|
409
|
+
|
|
410
|
+
// Per-slug cleanup of doc/awareness when slug changes (or provider unmounts).
|
|
411
|
+
useEffect(
|
|
412
|
+
() => () => {
|
|
413
|
+
try {
|
|
414
|
+
awareness.destroy();
|
|
415
|
+
} catch {
|
|
416
|
+
/* ignore */
|
|
417
|
+
}
|
|
418
|
+
try {
|
|
419
|
+
doc.destroy();
|
|
420
|
+
} catch {
|
|
421
|
+
/* ignore */
|
|
422
|
+
}
|
|
423
|
+
},
|
|
424
|
+
[doc, awareness]
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
// ── Throttled awareness publish ─────────────────────────────────────────
|
|
428
|
+
const pendingRef = useRef<Partial<Omit<CollabAwarenessState, '__connId'>> | null>(null);
|
|
429
|
+
const throttleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
430
|
+
|
|
431
|
+
const publishAwareness = useCallback(
|
|
432
|
+
(patch: Partial<Omit<CollabAwarenessState, '__connId'>>) => {
|
|
433
|
+
pendingRef.current = { ...(pendingRef.current ?? {}), ...patch };
|
|
434
|
+
if (throttleTimerRef.current) return;
|
|
435
|
+
throttleTimerRef.current = setTimeout(() => {
|
|
436
|
+
throttleTimerRef.current = null;
|
|
437
|
+
const next = pendingRef.current;
|
|
438
|
+
pendingRef.current = null;
|
|
439
|
+
if (!next) return;
|
|
440
|
+
const current = (awareness.getLocalState() ?? {}) as Partial<CollabAwarenessState>;
|
|
441
|
+
awareness.setLocalState({
|
|
442
|
+
name: current.name ?? myName,
|
|
443
|
+
color: current.color ?? myColor,
|
|
444
|
+
cursor: current.cursor ?? null,
|
|
445
|
+
selection: current.selection ?? null,
|
|
446
|
+
annotationSelection: current.annotationSelection ?? [],
|
|
447
|
+
viewport: current.viewport ?? { x: 0, y: 0, zoom: 1 },
|
|
448
|
+
__connId: myConnId,
|
|
449
|
+
...next,
|
|
450
|
+
} satisfies CollabAwarenessState);
|
|
451
|
+
}, AWARENESS_THROTTLE_MS);
|
|
452
|
+
},
|
|
453
|
+
[awareness, myName, myColor, myConnId]
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
// ── Cleanup throttle timer on unmount ───────────────────────────────────
|
|
457
|
+
useEffect(
|
|
458
|
+
() => () => {
|
|
459
|
+
if (throttleTimerRef.current) clearTimeout(throttleTimerRef.current);
|
|
460
|
+
},
|
|
461
|
+
[]
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
const value = useMemo<CollabValue>(
|
|
465
|
+
() => ({
|
|
466
|
+
doc,
|
|
467
|
+
awareness,
|
|
468
|
+
myColor,
|
|
469
|
+
myName,
|
|
470
|
+
myConnId,
|
|
471
|
+
connected,
|
|
472
|
+
publishAwareness,
|
|
473
|
+
}),
|
|
474
|
+
[doc, awareness, myColor, myName, myConnId, connected, publishAwareness]
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
return <CollabContext.Provider value={value}>{children}</CollabContext.Provider>;
|
|
478
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file use-cursor-modifiers.tsx — T28 (Wave 3)
|
|
3
|
+
* @scope plugins/design/dev-server/use-cursor-modifiers.tsx
|
|
4
|
+
* @purpose Modifier-aware cursor state machine. Tracks Alt / Shift / Meta
|
|
5
|
+
* modifier state globally and reflects it as `data-mod-*`
|
|
6
|
+
* attributes on `.dc-canvas`. CSS rules consume those attrs to
|
|
7
|
+
* flip cursors live:
|
|
8
|
+
*
|
|
9
|
+
* • Alt-held over a `[data-cd-id]` in Move tool → cursor: copy
|
|
10
|
+
* (preview of "Alt-drag duplicates this element").
|
|
11
|
+
* • Shift-held in Move tool over empty body padding → crosshair
|
|
12
|
+
* (preview of "marquee add mode").
|
|
13
|
+
*
|
|
14
|
+
* Per the research finding (Excalidraw #250): cursor changes on
|
|
15
|
+
* HOVER, not on drag-start. We track modifier state at the
|
|
16
|
+
* document level so the cursor updates the moment the user
|
|
17
|
+
* presses/releases without waiting for a pointermove.
|
|
18
|
+
*
|
|
19
|
+
* Resize-handle cursors (nw / ne / ns / ew) live next to the
|
|
20
|
+
* handle DOM in `use-annotation-resize.tsx` — those don't need
|
|
21
|
+
* modifier coupling; they fire on hover via their own CSS rules.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { type RefObject, useEffect } from 'react';
|
|
25
|
+
|
|
26
|
+
const STYLES = `
|
|
27
|
+
/* Alt + cd-id in move tool → copy cursor. The Alt-drag-duplicates affordance
|
|
28
|
+
is partial in v1 (no drag handler yet) — paint the cursor anyway so the
|
|
29
|
+
user discovers the gesture, and a future wave wires the actual duplication. */
|
|
30
|
+
.dc-canvas[data-active-tool="move"][data-mod-alt="true"] [data-cd-id],
|
|
31
|
+
.dc-canvas[data-active-tool="move"][data-mod-alt="true"] [data-dc-screen] {
|
|
32
|
+
cursor: copy !important;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* Shift over empty body padding in move tool → crosshair (marquee add mode
|
|
36
|
+
preview). Body padding = the .dc-artboard-body direct background, not
|
|
37
|
+
user content. */
|
|
38
|
+
.dc-canvas[data-active-tool="move"][data-mod-shift="true"] .dc-artboard-body {
|
|
39
|
+
cursor: crosshair;
|
|
40
|
+
}
|
|
41
|
+
`.trim();
|
|
42
|
+
|
|
43
|
+
function ensureStyles(): void {
|
|
44
|
+
if (typeof document === 'undefined') return;
|
|
45
|
+
if (document.getElementById('dc-cv-cursor-modifiers-css')) return;
|
|
46
|
+
const s = document.createElement('style');
|
|
47
|
+
s.id = 'dc-cv-cursor-modifiers-css';
|
|
48
|
+
s.textContent = STYLES;
|
|
49
|
+
document.head.appendChild(s);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ModifierState {
|
|
53
|
+
alt: boolean;
|
|
54
|
+
shift: boolean;
|
|
55
|
+
meta: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Reduce a key event into the modifier slice. Pulled out for unit tests —
|
|
60
|
+
* the actual hook is DOM-bound and harder to test directly.
|
|
61
|
+
*/
|
|
62
|
+
export function reduceModifiers(
|
|
63
|
+
prev: ModifierState,
|
|
64
|
+
e: { altKey: boolean; shiftKey: boolean; metaKey: boolean; ctrlKey: boolean }
|
|
65
|
+
): ModifierState {
|
|
66
|
+
// Treat Ctrl as Meta on non-mac so the cursor preview stays consistent with
|
|
67
|
+
// the input-router's `metaOrCtrl` gate.
|
|
68
|
+
const meta = e.metaKey || e.ctrlKey;
|
|
69
|
+
if (prev.alt === e.altKey && prev.shift === e.shiftKey && prev.meta === meta) {
|
|
70
|
+
return prev;
|
|
71
|
+
}
|
|
72
|
+
return { alt: e.altKey, shift: e.shiftKey, meta };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Attach modifier tracking to the canvas host element. Reflects the modifier
|
|
77
|
+
* state as `data-mod-alt` / `data-mod-shift` / `data-mod-meta` so cursor CSS
|
|
78
|
+
* rules can act on combinations without re-rendering React.
|
|
79
|
+
*
|
|
80
|
+
* Cleans up on unmount + on window blur (so leaving the tab with Alt held
|
|
81
|
+
* doesn't strand the cursor in copy mode).
|
|
82
|
+
*/
|
|
83
|
+
export function useCursorModifiers(hostRef: RefObject<HTMLElement | null>): void {
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (typeof document === 'undefined') return;
|
|
86
|
+
const host = hostRef.current;
|
|
87
|
+
if (!host) return;
|
|
88
|
+
ensureStyles();
|
|
89
|
+
|
|
90
|
+
let state: ModifierState = { alt: false, shift: false, meta: false };
|
|
91
|
+
|
|
92
|
+
const apply = (next: ModifierState) => {
|
|
93
|
+
if (next === state) return;
|
|
94
|
+
state = next;
|
|
95
|
+
host.setAttribute('data-mod-alt', next.alt ? 'true' : 'false');
|
|
96
|
+
host.setAttribute('data-mod-shift', next.shift ? 'true' : 'false');
|
|
97
|
+
host.setAttribute('data-mod-meta', next.meta ? 'true' : 'false');
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const onKeyChange = (e: KeyboardEvent) => apply(reduceModifiers(state, e));
|
|
101
|
+
const onBlur = () => apply({ alt: false, shift: false, meta: false });
|
|
102
|
+
// pointermove carries the modifier state too — handles the case where
|
|
103
|
+
// the user presses a modifier outside the focused window and then mouses
|
|
104
|
+
// back in (keydown/keyup never fires for the press).
|
|
105
|
+
const onPointer = (e: PointerEvent) => apply(reduceModifiers(state, e));
|
|
106
|
+
|
|
107
|
+
document.addEventListener('keydown', onKeyChange, true);
|
|
108
|
+
document.addEventListener('keyup', onKeyChange, true);
|
|
109
|
+
document.addEventListener('pointermove', onPointer, { passive: true });
|
|
110
|
+
window.addEventListener('blur', onBlur);
|
|
111
|
+
|
|
112
|
+
return () => {
|
|
113
|
+
document.removeEventListener('keydown', onKeyChange, true);
|
|
114
|
+
document.removeEventListener('keyup', onKeyChange, true);
|
|
115
|
+
document.removeEventListener('pointermove', onPointer);
|
|
116
|
+
window.removeEventListener('blur', onBlur);
|
|
117
|
+
host.removeAttribute('data-mod-alt');
|
|
118
|
+
host.removeAttribute('data-mod-shift');
|
|
119
|
+
host.removeAttribute('data-mod-meta');
|
|
120
|
+
};
|
|
121
|
+
}, [hostRef]);
|
|
122
|
+
}
|