@1agh/maude 0.20.0 → 0.22.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 +361 -0
- package/cli/commands/doctor.test.mjs +185 -0
- package/cli/commands/help.mjs +24 -0
- package/cli/commands/hub.mjs +245 -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 +16 -8
- 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 +5 -5
- package/plugins/design/dev-server/annotations-layer.tsx +52 -12
- package/plugins/design/dev-server/api.ts +17 -1
- package/plugins/design/dev-server/artboard-marquee.tsx +2 -2
- 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/canvas-lib.tsx +33 -7
- package/plugins/design/dev-server/canvas-shell.tsx +127 -9
- package/plugins/design/dev-server/client/app.jsx +72 -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/comments-overlay.tsx +29 -0
- package/plugins/design/dev-server/contextual-toolbar.tsx +1 -1
- 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/lib0_decoding.js +624 -0
- package/plugins/design/dev-server/dist/runtime/lib0_encoding.js +608 -0
- package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +380 -0
- package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +104 -0
- package/plugins/design/dev-server/dist/runtime/yjs.js +6691 -0
- package/plugins/design/dev-server/export-dialog.tsx +1 -1
- package/plugins/design/dev-server/hmr-broadcast.ts +15 -2
- package/plugins/design/dev-server/http.ts +64 -1
- package/plugins/design/dev-server/marquee-overlay.tsx +2 -2
- package/plugins/design/dev-server/participants-chrome.tsx +261 -0
- package/plugins/design/dev-server/runtime-bundle.ts +19 -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/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/git-lifecycle.test.ts +101 -0
- package/plugins/design/dev-server/test/participants-chrome.test.ts +30 -0
- package/plugins/design/dev-server/test/use-collab.test.ts +71 -0
- package/plugins/design/dev-server/tool-palette.tsx +7 -7
- package/plugins/design/dev-server/use-annotation-resize.tsx +1 -1
- package/plugins/design/dev-server/use-collab.tsx +478 -0
- package/plugins/design/dev-server/ws.ts +123 -7
- package/plugins/design/templates/_shell.html +37 -1
- 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
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Per-canvas Y.Doc room — registry, connection set, persistence schedule.
|
|
2
|
+
//
|
|
3
|
+
// One Room per canvas slug. Holds the Y.Doc + Awareness + the set of connected
|
|
4
|
+
// peers, broadcasts updates, debounces JSON snapshot writes to disk
|
|
5
|
+
// (DDR-051 — JSON is canonical, `.ydoc.bin` is a cache).
|
|
6
|
+
//
|
|
7
|
+
// The Room is transport-agnostic — it accepts CollabConn objects from the WS
|
|
8
|
+
// layer (ws.ts). Tests construct rooms directly without booting Bun.serve.
|
|
9
|
+
|
|
10
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { Awareness, removeAwarenessStates } from 'y-protocols/awareness';
|
|
14
|
+
import * as Y from 'yjs';
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
type CollabConn,
|
|
18
|
+
encodeAwarenessFrame,
|
|
19
|
+
encodeHandshake,
|
|
20
|
+
encodeSyncUpdate,
|
|
21
|
+
handleMessage,
|
|
22
|
+
} from './protocol.ts';
|
|
23
|
+
|
|
24
|
+
export interface RoomConn extends CollabConn {
|
|
25
|
+
/** Stable id for this connection (UUID from ws.data.id). */
|
|
26
|
+
id: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface RoomCallbacks {
|
|
30
|
+
/**
|
|
31
|
+
* Persist the JSON projection of `doc` for this slug. Called debounced — the
|
|
32
|
+
* Room batches Y.Doc updates within 800 ms windows and fires this once at
|
|
33
|
+
* quiescence. Implementations MUST be idempotent.
|
|
34
|
+
*/
|
|
35
|
+
persistJson(slug: string, doc: Y.Doc): Promise<void> | void;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Persist the binary Y.Doc state cache (`.ydoc.bin`). Called on the same
|
|
39
|
+
* debounce schedule as persistJson. Separate hook so tests can stub one
|
|
40
|
+
* without the other.
|
|
41
|
+
*/
|
|
42
|
+
persistBinary(slug: string, state: Uint8Array): Promise<void> | void;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Seed the Y.Doc from on-disk persistence — `.ydoc.bin` first, JSON
|
|
46
|
+
* snapshots second. Called once at room construction. MUST be synchronous
|
|
47
|
+
* with respect to the Y.Doc passed in so the first peer's handshake sees
|
|
48
|
+
* the seeded state.
|
|
49
|
+
*/
|
|
50
|
+
seed(slug: string, doc: Y.Doc): Promise<void> | void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface Room {
|
|
54
|
+
readonly slug: string;
|
|
55
|
+
readonly doc: Y.Doc;
|
|
56
|
+
readonly awareness: Awareness;
|
|
57
|
+
connect(conn: RoomConn): Promise<void>;
|
|
58
|
+
disconnect(conn: RoomConn): void;
|
|
59
|
+
receive(conn: RoomConn, payload: Uint8Array): void;
|
|
60
|
+
/** Force the debounced flush to fire now. DDR-051 §3 (branch-switch). */
|
|
61
|
+
flush(): Promise<void>;
|
|
62
|
+
/** Tear down — clears timers + removes awareness. */
|
|
63
|
+
destroy(): Promise<void>;
|
|
64
|
+
/** Test/inspection: connection count. */
|
|
65
|
+
size(): number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const DEBOUNCE_MS = 800;
|
|
69
|
+
|
|
70
|
+
export function createRoom(slug: string, callbacks: RoomCallbacks): Room {
|
|
71
|
+
const doc = new Y.Doc();
|
|
72
|
+
// Awareness needs a clientID; reuse the Y.Doc's so peers can attribute updates.
|
|
73
|
+
const awareness = new Awareness(doc);
|
|
74
|
+
|
|
75
|
+
const conns = new Map<string, RoomConn>();
|
|
76
|
+
let dirty = false;
|
|
77
|
+
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
78
|
+
let destroyed = false;
|
|
79
|
+
let seedPromise: Promise<void> | null = null;
|
|
80
|
+
|
|
81
|
+
function scheduleFlush() {
|
|
82
|
+
dirty = true;
|
|
83
|
+
if (flushTimer) clearTimeout(flushTimer);
|
|
84
|
+
flushTimer = setTimeout(() => {
|
|
85
|
+
flushTimer = null;
|
|
86
|
+
void flush();
|
|
87
|
+
}, DEBOUNCE_MS);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function flush(): Promise<void> {
|
|
91
|
+
if (!dirty || destroyed) return;
|
|
92
|
+
dirty = false;
|
|
93
|
+
if (flushTimer) {
|
|
94
|
+
clearTimeout(flushTimer);
|
|
95
|
+
flushTimer = null;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
await callbacks.persistJson(slug, doc);
|
|
99
|
+
await callbacks.persistBinary(slug, Y.encodeStateAsUpdate(doc));
|
|
100
|
+
} catch (err) {
|
|
101
|
+
// Re-arm so the next mutation retries. Loud-log; persistence loss is a
|
|
102
|
+
// user-visible bug if it stays silent.
|
|
103
|
+
dirty = true;
|
|
104
|
+
console.error(`[collab/${slug}] flush failed:`, err);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function broadcast(payload: Uint8Array, except?: RoomConn) {
|
|
109
|
+
for (const c of conns.values()) {
|
|
110
|
+
if (except && c.id === except.id) continue;
|
|
111
|
+
try {
|
|
112
|
+
c.send(payload);
|
|
113
|
+
} catch {
|
|
114
|
+
/* close handler will clean up dead sockets */
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Y.Doc update -> broadcast to all peers + schedule debounced flush.
|
|
120
|
+
doc.on('update', (update: Uint8Array, origin: unknown) => {
|
|
121
|
+
if (destroyed) return;
|
|
122
|
+
// Don't echo back to the origin — they already have it; reduces noise.
|
|
123
|
+
const except =
|
|
124
|
+
origin && typeof origin === 'object' && 'id' in origin ? (origin as RoomConn) : undefined;
|
|
125
|
+
broadcast(encodeSyncUpdate(update), except);
|
|
126
|
+
scheduleFlush();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Awareness changes -> broadcast (NOT persisted; ephemeral by design).
|
|
130
|
+
awareness.on(
|
|
131
|
+
'update',
|
|
132
|
+
(
|
|
133
|
+
{ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },
|
|
134
|
+
origin: unknown
|
|
135
|
+
) => {
|
|
136
|
+
if (destroyed) return;
|
|
137
|
+
const changed = added.concat(updated, removed);
|
|
138
|
+
if (changed.length === 0) return;
|
|
139
|
+
const except =
|
|
140
|
+
origin && typeof origin === 'object' && 'id' in origin ? (origin as RoomConn) : undefined;
|
|
141
|
+
broadcast(encodeAwarenessFrame(awareness, changed), except);
|
|
142
|
+
}
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
function getOrStartSeed(): Promise<void> {
|
|
146
|
+
if (!seedPromise) {
|
|
147
|
+
const result = callbacks.seed(slug, doc);
|
|
148
|
+
seedPromise = Promise.resolve(result).catch((err) => {
|
|
149
|
+
console.error(`[collab/${slug}] seed failed:`, err);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return seedPromise;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function connect(conn: RoomConn): Promise<void> {
|
|
156
|
+
await getOrStartSeed();
|
|
157
|
+
conns.set(conn.id, conn);
|
|
158
|
+
const frames = encodeHandshake(doc, awareness);
|
|
159
|
+
for (const frame of frames) conn.send(frame);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function disconnect(conn: RoomConn): void {
|
|
163
|
+
conns.delete(conn.id);
|
|
164
|
+
// Awareness states keyed by the conn token; clean them up so other peers'
|
|
165
|
+
// cursor renderers can drop the avatar.
|
|
166
|
+
const states = awareness.getStates();
|
|
167
|
+
const stale: number[] = [];
|
|
168
|
+
for (const clientId of states.keys()) {
|
|
169
|
+
// Bridge: our Awareness state setters tag the state with `__connId`
|
|
170
|
+
// matching conn.id; remove states whose owning conn just left.
|
|
171
|
+
const state = states.get(clientId) as { __connId?: string } | undefined;
|
|
172
|
+
if (state && state.__connId === conn.id) stale.push(clientId);
|
|
173
|
+
}
|
|
174
|
+
if (stale.length) removeAwarenessStates(awareness, stale, conn);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function receive(conn: RoomConn, payload: Uint8Array): void {
|
|
178
|
+
const reply = handleMessage(payload, doc, awareness, conn);
|
|
179
|
+
if (reply) conn.send(reply);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function destroy(): Promise<void> {
|
|
183
|
+
destroyed = true;
|
|
184
|
+
if (flushTimer) {
|
|
185
|
+
clearTimeout(flushTimer);
|
|
186
|
+
flushTimer = null;
|
|
187
|
+
}
|
|
188
|
+
if (dirty) await flush();
|
|
189
|
+
conns.clear();
|
|
190
|
+
awareness.destroy();
|
|
191
|
+
doc.destroy();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
slug,
|
|
196
|
+
doc,
|
|
197
|
+
awareness,
|
|
198
|
+
connect,
|
|
199
|
+
disconnect,
|
|
200
|
+
receive,
|
|
201
|
+
flush,
|
|
202
|
+
destroy,
|
|
203
|
+
size: () => conns.size,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Ensure `<designRoot>/_state/` exists. Idempotent; safe to call on every
|
|
209
|
+
* room construction (mkdir recursive returns the path or noop).
|
|
210
|
+
*/
|
|
211
|
+
export function ensureStateDir(designRoot: string): string {
|
|
212
|
+
const dir = path.join(designRoot, '_state');
|
|
213
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
214
|
+
return dir;
|
|
215
|
+
}
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
|
|
35
35
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
36
36
|
|
|
37
|
+
import { useCollab } from './use-collab.tsx';
|
|
37
38
|
import { useSelectionSetOptional } from './use-selection-set.tsx';
|
|
38
39
|
|
|
39
40
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -230,6 +231,34 @@ export function CommentsOverlay(): React.ReactNode {
|
|
|
230
231
|
const commentsRef = useRef<OverlayComment[]>(comments);
|
|
231
232
|
commentsRef.current = comments;
|
|
232
233
|
|
|
234
|
+
// Phase 8 Task 3 — when a collab room is connected, the Y.Array of comments
|
|
235
|
+
// is the live source of truth. observe() fires on every remote mutation
|
|
236
|
+
// (added pins from another tab, resolved-from-inspector via the registry
|
|
237
|
+
// bridge, etc.) and on the local seed. Both paths converge on the same
|
|
238
|
+
// JSON projection — last-write-wins between Y.Array and postMessage is
|
|
239
|
+
// safe because they carry identical content; the Y.Array path just
|
|
240
|
+
// reaches us first (no 800 ms debounce delay).
|
|
241
|
+
const collab = useCollab();
|
|
242
|
+
useEffect(() => {
|
|
243
|
+
if (!collab) return;
|
|
244
|
+
const arr = collab.doc.getArray<OverlayComment>('comments');
|
|
245
|
+
const sync = () => {
|
|
246
|
+
// toArray() snapshot the current Y.Array into a plain JS list.
|
|
247
|
+
setComments(arr.toArray() as OverlayComment[]);
|
|
248
|
+
};
|
|
249
|
+
// Initial fill — covers the case where Y.Doc was already seeded by the
|
|
250
|
+
// time this overlay mounted.
|
|
251
|
+
if (arr.length > 0) sync();
|
|
252
|
+
arr.observe(sync);
|
|
253
|
+
return () => {
|
|
254
|
+
try {
|
|
255
|
+
arr.unobserve(sync);
|
|
256
|
+
} catch {
|
|
257
|
+
/* doc destroyed before unmount — observer already gone */
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
}, [collab]);
|
|
261
|
+
|
|
233
262
|
// Listen for the shell's broadcast channels. Schema matches the legacy
|
|
234
263
|
// overlay so the shell-side glue in client/app.jsx (~line 1672) keeps
|
|
235
264
|
// working without modification.
|
|
@@ -68,7 +68,7 @@ const CTX_TOOLBAR_CSS = `
|
|
|
68
68
|
transition: background-color 80ms linear;
|
|
69
69
|
}
|
|
70
70
|
.dc-elem-ctx-tb button:hover {
|
|
71
|
-
background: color-mix(in oklab, var(--accent, #d63b1f) 8%, transparent);
|
|
71
|
+
background: color-mix(in oklab, var(--maude-hud-accent, #d63b1f) 8%, transparent);
|
|
72
72
|
}
|
|
73
73
|
.dc-elem-ctx-tb .dc-elem-ctx-count {
|
|
74
74
|
padding: 4px 8px 4px 10px;
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file cursors-overlay.tsx — foreign peer cursors rendered on the canvas
|
|
3
|
+
* @scope plugins/design/dev-server/cursors-overlay.tsx
|
|
4
|
+
* @purpose Renders one colored SVG arrow + name label per foreign peer at
|
|
5
|
+
* their published world coords, transformed through the local
|
|
6
|
+
* viewport so the cursor anchors to canvas content (not to my
|
|
7
|
+
* screen) — the standard Excalidraw / tldraw multiplayer model.
|
|
8
|
+
*
|
|
9
|
+
* Render shape:
|
|
10
|
+
* <div position:fixed top:0 left:0 pointer-events:none>
|
|
11
|
+
* <Cursor for each foreign peer />
|
|
12
|
+
* </div>
|
|
13
|
+
*
|
|
14
|
+
* Each cursor is positioned via `transform: translate(screenX px, screenY px)`
|
|
15
|
+
* where (screenX, screenY) = world * viewport.zoom + viewport.{x,y}. No
|
|
16
|
+
* react-spring / lerp in this ship (just last-known-position render); the
|
|
17
|
+
* 30 Hz publish cadence keeps it smooth enough for the multiplayer demo.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { memo, useEffect, useState } from 'react';
|
|
21
|
+
|
|
22
|
+
import { useViewportControllerContext } from './canvas-lib.tsx';
|
|
23
|
+
import { type ForeignAwareness, useCollab, useForeignAwareness } from './use-collab.tsx';
|
|
24
|
+
|
|
25
|
+
const CURSOR_CSS = `
|
|
26
|
+
.dc-cursor-overlay {
|
|
27
|
+
position: fixed;
|
|
28
|
+
top: 0;
|
|
29
|
+
left: 0;
|
|
30
|
+
width: 100%;
|
|
31
|
+
height: 100%;
|
|
32
|
+
pointer-events: none;
|
|
33
|
+
z-index: 10000;
|
|
34
|
+
}
|
|
35
|
+
.dc-cursor {
|
|
36
|
+
position: absolute;
|
|
37
|
+
top: 0;
|
|
38
|
+
left: 0;
|
|
39
|
+
pointer-events: none;
|
|
40
|
+
will-change: transform;
|
|
41
|
+
/* The arrow tip is the (0,0) anchor of the SVG; the label hangs to the
|
|
42
|
+
bottom-right so it never occludes the tip itself. */
|
|
43
|
+
}
|
|
44
|
+
.dc-cursor-arrow {
|
|
45
|
+
display: block;
|
|
46
|
+
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.25));
|
|
47
|
+
}
|
|
48
|
+
.dc-cursor-label {
|
|
49
|
+
position: absolute;
|
|
50
|
+
top: 18px;
|
|
51
|
+
left: 14px;
|
|
52
|
+
padding: 2px 6px;
|
|
53
|
+
font-family: var(--font-sans, system-ui, -apple-system, sans-serif);
|
|
54
|
+
font-weight: 500;
|
|
55
|
+
font-size: 11px;
|
|
56
|
+
line-height: 1.2;
|
|
57
|
+
color: var(--maude-hud-accent-fg, #fff);
|
|
58
|
+
border-radius: var(--radius-sm, 2px);
|
|
59
|
+
white-space: nowrap;
|
|
60
|
+
max-width: 180px;
|
|
61
|
+
overflow: hidden;
|
|
62
|
+
text-overflow: ellipsis;
|
|
63
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
|
64
|
+
}
|
|
65
|
+
.dc-peer-selection {
|
|
66
|
+
position: absolute;
|
|
67
|
+
top: 0;
|
|
68
|
+
left: 0;
|
|
69
|
+
pointer-events: none;
|
|
70
|
+
border: 2px solid;
|
|
71
|
+
border-radius: var(--radius-sm, 2px);
|
|
72
|
+
box-sizing: border-box;
|
|
73
|
+
will-change: transform, width, height;
|
|
74
|
+
}
|
|
75
|
+
.dc-peer-selection__label {
|
|
76
|
+
position: absolute;
|
|
77
|
+
top: -18px;
|
|
78
|
+
left: -2px;
|
|
79
|
+
padding: 1px 5px;
|
|
80
|
+
font-family: var(--font-sans, system-ui, -apple-system, sans-serif);
|
|
81
|
+
font-weight: 600;
|
|
82
|
+
font-size: 10px;
|
|
83
|
+
line-height: 1.3;
|
|
84
|
+
color: var(--maude-hud-accent-fg, #fff);
|
|
85
|
+
border-radius: var(--radius-sm, 2px) var(--radius-sm, 2px) 0 0;
|
|
86
|
+
white-space: nowrap;
|
|
87
|
+
}
|
|
88
|
+
@media (prefers-reduced-motion: reduce) {
|
|
89
|
+
.dc-cursor { transition: none !important; }
|
|
90
|
+
}
|
|
91
|
+
`.trim();
|
|
92
|
+
|
|
93
|
+
function ensureCursorStyles(): void {
|
|
94
|
+
if (typeof document === 'undefined') return;
|
|
95
|
+
if (document.getElementById('dc-cursor-overlay-css')) return;
|
|
96
|
+
const s = document.createElement('style');
|
|
97
|
+
s.id = 'dc-cursor-overlay-css';
|
|
98
|
+
s.textContent = CURSOR_CSS;
|
|
99
|
+
document.head.appendChild(s);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface ViewportSnapshot {
|
|
103
|
+
x: number;
|
|
104
|
+
y: number;
|
|
105
|
+
zoom: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface CursorProps {
|
|
109
|
+
peer: ForeignAwareness;
|
|
110
|
+
viewport: ViewportSnapshot;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const Cursor = memo(function Cursor({ peer, viewport }: CursorProps): JSX.Element | null {
|
|
114
|
+
if (!peer.cursor) return null;
|
|
115
|
+
// world → screen: screen = world * zoom + viewport.{x,y}
|
|
116
|
+
const screenX = peer.cursor.x * viewport.zoom + viewport.x;
|
|
117
|
+
const screenY = peer.cursor.y * viewport.zoom + viewport.y;
|
|
118
|
+
return (
|
|
119
|
+
<div className="dc-cursor" style={{ transform: `translate(${screenX}px, ${screenY}px)` }}>
|
|
120
|
+
<svg
|
|
121
|
+
className="dc-cursor-arrow"
|
|
122
|
+
width="20"
|
|
123
|
+
height="22"
|
|
124
|
+
viewBox="0 0 20 22"
|
|
125
|
+
aria-hidden="true"
|
|
126
|
+
>
|
|
127
|
+
<path
|
|
128
|
+
d="M 2 2 L 2 18 L 6 14 L 9 21 L 12 20 L 9 13 L 14 13 Z"
|
|
129
|
+
fill={peer.color}
|
|
130
|
+
stroke="#fff"
|
|
131
|
+
strokeWidth="1.2"
|
|
132
|
+
strokeLinejoin="round"
|
|
133
|
+
/>
|
|
134
|
+
</svg>
|
|
135
|
+
<div className="dc-cursor-label" style={{ background: peer.color }}>
|
|
136
|
+
{peer.name}
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Foreign-selection halos for stamped annotation strokes. Each peer publishes
|
|
144
|
+
* `annotationSelection: string[]` — stroke `data-id` values from Phase 5.
|
|
145
|
+
* We resolve each via `[data-id="<id>"]` and render an outlined rect at the
|
|
146
|
+
* element's current screen bounds. Re-runs every render so geometry changes
|
|
147
|
+
* (resize, move) stay in sync without manual re-publish.
|
|
148
|
+
*/
|
|
149
|
+
interface PeerAnnotationSelectionProps {
|
|
150
|
+
peer: ForeignAwareness;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const PeerAnnotationSelection = memo(function PeerAnnotationSelection({
|
|
154
|
+
peer,
|
|
155
|
+
}: PeerAnnotationSelectionProps): JSX.Element | null {
|
|
156
|
+
if (!peer.annotationSelection || peer.annotationSelection.length === 0) return null;
|
|
157
|
+
if (typeof document === 'undefined') return null;
|
|
158
|
+
const rects: { id: string; x: number; y: number; w: number; h: number }[] = [];
|
|
159
|
+
for (const id of peer.annotationSelection) {
|
|
160
|
+
try {
|
|
161
|
+
const el = document.querySelector(`[data-id="${CSS.escape(id)}"]`);
|
|
162
|
+
if (!el) continue;
|
|
163
|
+
const r = (el as Element).getBoundingClientRect();
|
|
164
|
+
if (r.width <= 0 || r.height <= 0) continue;
|
|
165
|
+
// Pad by 3px so the halo sits OUTSIDE the stroke instead of clipping it.
|
|
166
|
+
rects.push({ id, x: r.left - 3, y: r.top - 3, w: r.width + 6, h: r.height + 6 });
|
|
167
|
+
} catch {
|
|
168
|
+
/* invalid id token — skip */
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (rects.length === 0) return null;
|
|
172
|
+
return (
|
|
173
|
+
<>
|
|
174
|
+
{rects.map((r, i) => (
|
|
175
|
+
<div
|
|
176
|
+
key={r.id}
|
|
177
|
+
className="dc-peer-selection"
|
|
178
|
+
style={{
|
|
179
|
+
transform: `translate(${r.x}px, ${r.y}px)`,
|
|
180
|
+
width: r.w,
|
|
181
|
+
height: r.h,
|
|
182
|
+
borderColor: peer.color,
|
|
183
|
+
}}
|
|
184
|
+
>
|
|
185
|
+
{i === 0 && (
|
|
186
|
+
<div className="dc-peer-selection__label" style={{ background: peer.color }}>
|
|
187
|
+
{peer.name}
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
</div>
|
|
191
|
+
))}
|
|
192
|
+
</>
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Foreign-selection halo for canvas-shell elements (cdId-based selSet).
|
|
198
|
+
* The peer publishes selection.cssPath; we re-resolve in the local DOM
|
|
199
|
+
* each render so pan / zoom / hydration changes don't desync. Falls back
|
|
200
|
+
* to the published `bounds` (screen-px at publish time) when cssPath
|
|
201
|
+
* can't be resolved locally.
|
|
202
|
+
*/
|
|
203
|
+
interface PeerSelectionProps {
|
|
204
|
+
peer: ForeignAwareness;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const PeerSelection = memo(function PeerSelection({
|
|
208
|
+
peer,
|
|
209
|
+
}: PeerSelectionProps): JSX.Element | null {
|
|
210
|
+
if (!peer.selection) return null;
|
|
211
|
+
const { cssPath, bounds } = peer.selection;
|
|
212
|
+
|
|
213
|
+
let rect: { x: number; y: number; w: number; h: number } | null = bounds ?? null;
|
|
214
|
+
if (cssPath && typeof document !== 'undefined') {
|
|
215
|
+
try {
|
|
216
|
+
const el = document.querySelector(cssPath);
|
|
217
|
+
if (el && el instanceof Element) {
|
|
218
|
+
const r = el.getBoundingClientRect();
|
|
219
|
+
if (r.width > 0 && r.height > 0) {
|
|
220
|
+
rect = { x: r.left, y: r.top, w: r.width, h: r.height };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} catch {
|
|
224
|
+
/* invalid selector — fall through to published bounds */
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (!rect) return null;
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<div
|
|
231
|
+
className="dc-peer-selection"
|
|
232
|
+
style={{
|
|
233
|
+
transform: `translate(${rect.x}px, ${rect.y}px)`,
|
|
234
|
+
width: rect.w,
|
|
235
|
+
height: rect.h,
|
|
236
|
+
borderColor: peer.color,
|
|
237
|
+
}}
|
|
238
|
+
>
|
|
239
|
+
<div className="dc-peer-selection__label" style={{ background: peer.color }}>
|
|
240
|
+
{peer.name}
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Subscribes to foreign awareness + the local viewport. Renders one Cursor per
|
|
248
|
+
* peer. Reads viewport from `useViewportControllerContext` — if the canvas
|
|
249
|
+
* doesn't expose a controller (rare edge case), falls back to identity.
|
|
250
|
+
*/
|
|
251
|
+
export function CursorsOverlay(): JSX.Element {
|
|
252
|
+
ensureCursorStyles();
|
|
253
|
+
const peers = useForeignAwareness();
|
|
254
|
+
const controller = useViewportControllerContext();
|
|
255
|
+
const liveVp = controller?.viewport ?? { x: 0, y: 0, zoom: 1 };
|
|
256
|
+
// Take a snapshot per render — controller.viewport already changes on
|
|
257
|
+
// throttle, so this stays smooth enough at 30 Hz.
|
|
258
|
+
const [vp, setVp] = useState<ViewportSnapshot>(liveVp);
|
|
259
|
+
useEffect(() => {
|
|
260
|
+
setVp({ x: liveVp.x, y: liveVp.y, zoom: liveVp.zoom });
|
|
261
|
+
}, [liveVp.x, liveVp.y, liveVp.zoom]);
|
|
262
|
+
|
|
263
|
+
// Bump a render tick whenever annotations change so PeerAnnotationSelection
|
|
264
|
+
// re-resolves [data-id] bounds. Without this, a peer resizing a stroke
|
|
265
|
+
// would propagate via Y.Map → local annotations-layer re-render — but the
|
|
266
|
+
// sibling CursorsOverlay wouldn't re-render, so the annotation halo would
|
|
267
|
+
// sit on the OLD bounds until awareness independently changed.
|
|
268
|
+
const collab = useCollab();
|
|
269
|
+
const [, bumpAnnotTick] = useState(0);
|
|
270
|
+
useEffect(() => {
|
|
271
|
+
if (!collab) return;
|
|
272
|
+
const map = collab.doc.getMap('annotations');
|
|
273
|
+
const onChange = () => bumpAnnotTick((n) => n + 1);
|
|
274
|
+
map.observe(onChange);
|
|
275
|
+
return () => {
|
|
276
|
+
try {
|
|
277
|
+
map.unobserve(onChange);
|
|
278
|
+
} catch {
|
|
279
|
+
/* doc destroyed */
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
}, [collab]);
|
|
283
|
+
return (
|
|
284
|
+
<div className="dc-cursor-overlay" aria-hidden="true">
|
|
285
|
+
{peers.map((peer) => (
|
|
286
|
+
<PeerSelection key={`sel-${peer.clientID}`} peer={peer} />
|
|
287
|
+
))}
|
|
288
|
+
{peers.map((peer) => (
|
|
289
|
+
<PeerAnnotationSelection key={`asel-${peer.clientID}`} peer={peer} />
|
|
290
|
+
))}
|
|
291
|
+
{peers.map((peer) => (
|
|
292
|
+
<Cursor key={peer.clientID} peer={peer} viewport={vp} />
|
|
293
|
+
))}
|
|
294
|
+
</div>
|
|
295
|
+
);
|
|
296
|
+
}
|