@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
|
@@ -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.
|
|
@@ -1143,14 +1172,22 @@ function computeThreadAnchor(comment: OverlayComment): { x: number; y: number }
|
|
|
1143
1172
|
}
|
|
1144
1173
|
|
|
1145
1174
|
function computeAnchor(state: ComposerState): { x: number; y: number } {
|
|
1146
|
-
//
|
|
1147
|
-
//
|
|
1175
|
+
// G4 — anchor to the cursor click point first. Earlier versions anchored to
|
|
1176
|
+
// the selected element's bottom-left, which landed the composer flush in
|
|
1177
|
+
// the corner regardless of where the user clicked — surprising for the
|
|
1178
|
+
// common case of "I clicked the middle of an element, expecting the
|
|
1179
|
+
// composer to appear near my cursor". The element-rect path remains as a
|
|
1180
|
+
// fallback for entry points that don't carry a cursor (e.g. opening the
|
|
1181
|
+
// composer from a contextual toolbar button — those should set clientX/Y
|
|
1182
|
+
// to a sensible anchor before dispatching).
|
|
1183
|
+
if (state.clientX || state.clientY) {
|
|
1184
|
+
return { x: state.clientX, y: state.clientY + 8 };
|
|
1185
|
+
}
|
|
1148
1186
|
if (state.selection.selector) {
|
|
1149
1187
|
const rect = screenRectFor(state.selection.selector);
|
|
1150
1188
|
if (rect) {
|
|
1151
1189
|
return { x: rect.x, y: rect.y + rect.h + 8 };
|
|
1152
1190
|
}
|
|
1153
1191
|
}
|
|
1154
|
-
|
|
1155
|
-
return { x: state.clientX, y: state.clientY + 8 };
|
|
1192
|
+
return { x: 16, y: 16 };
|
|
1156
1193
|
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file contextual-toolbar.tsx — T30 (Wave 3)
|
|
3
|
+
* @scope plugins/design/dev-server/contextual-toolbar.tsx
|
|
4
|
+
* @purpose Selection-anchored floating chrome for ELEMENT selections
|
|
5
|
+
* (cd-id entries in the selection set). Mirrors the
|
|
6
|
+
* MultiArtboardToolbar pattern but scopes to user content inside
|
|
7
|
+
* artboards. Actions surface the existing right-click handlers
|
|
8
|
+
* one click closer:
|
|
9
|
+
*
|
|
10
|
+
* • Copy CSS path
|
|
11
|
+
* • Copy data-cd-id
|
|
12
|
+
* • Add comment
|
|
13
|
+
*
|
|
14
|
+
* Hides when:
|
|
15
|
+
* - no selection,
|
|
16
|
+
* - selection contains only artboards (MultiArtboardToolbar
|
|
17
|
+
* covers that),
|
|
18
|
+
* - selection contains only annotations (their own toolbar
|
|
19
|
+
* fires from `annotations-context-toolbar.tsx`).
|
|
20
|
+
*
|
|
21
|
+
* Anchored 14 px above the element selection union bbox; flips
|
|
22
|
+
* below when bbox top is < 60 px from the viewport edge — same
|
|
23
|
+
* contract as MultiArtboardToolbar so the two never collide.
|
|
24
|
+
*
|
|
25
|
+
* Selection-change tweens are NOT animated here in v1; the
|
|
26
|
+
* instant reposition is acceptable given the toolbar fades in
|
|
27
|
+
* only when stickily relevant. A future polish wave can add
|
|
28
|
+
* the 180 ms position tween per the plan note.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { useEffect, useRef } from 'react';
|
|
32
|
+
|
|
33
|
+
import { useSelectionSet } from './use-selection-set.tsx';
|
|
34
|
+
|
|
35
|
+
const CTX_TOOLBAR_CSS = `
|
|
36
|
+
.dc-elem-ctx-tb {
|
|
37
|
+
position: fixed;
|
|
38
|
+
pointer-events: auto;
|
|
39
|
+
z-index: 6;
|
|
40
|
+
display: none;
|
|
41
|
+
align-items: stretch;
|
|
42
|
+
gap: 2px;
|
|
43
|
+
padding: 4px;
|
|
44
|
+
background: var(--u-bg-0, var(--bg-0, #ffffff));
|
|
45
|
+
border: 1px solid var(--u-fg-0, #1c1917);
|
|
46
|
+
border-radius: 8px;
|
|
47
|
+
box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
|
|
48
|
+
font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
49
|
+
font-size: 11px;
|
|
50
|
+
letter-spacing: 0.02em;
|
|
51
|
+
color: var(--u-fg-0, #1a1a1a);
|
|
52
|
+
user-select: none;
|
|
53
|
+
opacity: 0;
|
|
54
|
+
transition: opacity 100ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
55
|
+
}
|
|
56
|
+
.dc-elem-ctx-tb[data-on="true"] {
|
|
57
|
+
opacity: 1;
|
|
58
|
+
}
|
|
59
|
+
.dc-elem-ctx-tb button {
|
|
60
|
+
appearance: none;
|
|
61
|
+
background: transparent;
|
|
62
|
+
border: 0;
|
|
63
|
+
border-radius: 6px;
|
|
64
|
+
padding: 4px 10px;
|
|
65
|
+
font: inherit;
|
|
66
|
+
cursor: pointer;
|
|
67
|
+
color: inherit;
|
|
68
|
+
transition: background-color 80ms linear;
|
|
69
|
+
}
|
|
70
|
+
.dc-elem-ctx-tb button:hover {
|
|
71
|
+
background: color-mix(in oklab, var(--maude-hud-accent, #d63b1f) 8%, transparent);
|
|
72
|
+
}
|
|
73
|
+
.dc-elem-ctx-tb .dc-elem-ctx-count {
|
|
74
|
+
padding: 4px 8px 4px 10px;
|
|
75
|
+
color: var(--fg-1, rgba(40,30,20,0.7));
|
|
76
|
+
border-right: 1px solid var(--u-border-subtle, rgba(0,0,0,0.08));
|
|
77
|
+
margin-right: 2px;
|
|
78
|
+
font-variant-numeric: tabular-nums;
|
|
79
|
+
}
|
|
80
|
+
@media (prefers-reduced-motion: reduce) {
|
|
81
|
+
.dc-elem-ctx-tb, .dc-elem-ctx-tb button { transition: none; }
|
|
82
|
+
}
|
|
83
|
+
`.trim();
|
|
84
|
+
|
|
85
|
+
function ensureStyles(): void {
|
|
86
|
+
if (typeof document === 'undefined') return;
|
|
87
|
+
if (document.getElementById('dc-elem-ctx-tb-css')) return;
|
|
88
|
+
const s = document.createElement('style');
|
|
89
|
+
s.id = 'dc-elem-ctx-tb-css';
|
|
90
|
+
s.textContent = CTX_TOOLBAR_CSS;
|
|
91
|
+
document.head.appendChild(s);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function copyText(text: string): void {
|
|
95
|
+
if (typeof navigator === 'undefined' || !navigator.clipboard) return;
|
|
96
|
+
void navigator.clipboard.writeText(text).catch(() => {
|
|
97
|
+
/* clipboard blocked */
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function openComposerForSelection(
|
|
102
|
+
sel: { id?: string; selector: string },
|
|
103
|
+
anchorEl: Element | null
|
|
104
|
+
): void {
|
|
105
|
+
if (typeof document === 'undefined') return;
|
|
106
|
+
// Anchor near the top-right of the targeted element so the composer drops
|
|
107
|
+
// into a clear area next to it. Matches the comment-tool click affordance
|
|
108
|
+
// post-G4 (cursor-anchored composer).
|
|
109
|
+
let clientX = 0;
|
|
110
|
+
let clientY = 0;
|
|
111
|
+
if (anchorEl) {
|
|
112
|
+
const r = (anchorEl as HTMLElement).getBoundingClientRect();
|
|
113
|
+
clientX = r.right - 8;
|
|
114
|
+
clientY = r.top + r.height / 2;
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
document.dispatchEvent(
|
|
118
|
+
new CustomEvent('cm:open-composer', {
|
|
119
|
+
detail: { selection: sel, clientX, clientY },
|
|
120
|
+
})
|
|
121
|
+
);
|
|
122
|
+
} catch {
|
|
123
|
+
/* CustomEvent unsupported — fall through */
|
|
124
|
+
}
|
|
125
|
+
// Back-compat parent post for legacy mocks.
|
|
126
|
+
if (typeof window !== 'undefined') {
|
|
127
|
+
try {
|
|
128
|
+
window.parent.postMessage({ dgn: 'comment-compose', selection: sel }, '*');
|
|
129
|
+
} catch {
|
|
130
|
+
/* parent detached */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function ContextualToolbar() {
|
|
136
|
+
ensureStyles();
|
|
137
|
+
const { selected } = useSelectionSet();
|
|
138
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
139
|
+
const rafRef = useRef<number | null>(null);
|
|
140
|
+
|
|
141
|
+
// Element selections = entries with `id` (data-cd-id) set. Pure artboard
|
|
142
|
+
// selections (artboardId-only, no id) and empty sets fall through.
|
|
143
|
+
const elementSelections = selected.filter((s) => !!s.id);
|
|
144
|
+
const visible = elementSelections.length > 0;
|
|
145
|
+
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
const div = ref.current;
|
|
148
|
+
if (!div) return;
|
|
149
|
+
if (!visible) {
|
|
150
|
+
div.style.display = 'none';
|
|
151
|
+
div.setAttribute('data-on', 'false');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const tick = () => {
|
|
155
|
+
rafRef.current = null;
|
|
156
|
+
let xMin = Number.POSITIVE_INFINITY;
|
|
157
|
+
let yMin = Number.POSITIVE_INFINITY;
|
|
158
|
+
let xMax = Number.NEGATIVE_INFINITY;
|
|
159
|
+
let yMax = Number.NEGATIVE_INFINITY;
|
|
160
|
+
let any = false;
|
|
161
|
+
for (const sel of elementSelections) {
|
|
162
|
+
const node = sel.id
|
|
163
|
+
? document.querySelector(`[data-cd-id="${sel.id}"]`)
|
|
164
|
+
: document.querySelector(sel.selector);
|
|
165
|
+
if (!node) continue;
|
|
166
|
+
const r = (node as HTMLElement).getBoundingClientRect();
|
|
167
|
+
if (r.width === 0 && r.height === 0) continue;
|
|
168
|
+
any = true;
|
|
169
|
+
if (r.left < xMin) xMin = r.left;
|
|
170
|
+
if (r.top < yMin) yMin = r.top;
|
|
171
|
+
if (r.right > xMax) xMax = r.right;
|
|
172
|
+
if (r.bottom > yMax) yMax = r.bottom;
|
|
173
|
+
}
|
|
174
|
+
if (!any) {
|
|
175
|
+
div.style.display = 'none';
|
|
176
|
+
div.setAttribute('data-on', 'false');
|
|
177
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
div.style.display = 'flex';
|
|
181
|
+
const tw = div.offsetWidth || 0;
|
|
182
|
+
const centerX = (xMin + xMax) / 2;
|
|
183
|
+
const top = yMin;
|
|
184
|
+
const gap = 14;
|
|
185
|
+
let anchorY = top - div.offsetHeight - gap;
|
|
186
|
+
if (anchorY < 60) anchorY = yMax + gap;
|
|
187
|
+
div.style.left = `${Math.round(centerX - tw / 2)}px`;
|
|
188
|
+
div.style.top = `${Math.round(anchorY)}px`;
|
|
189
|
+
div.setAttribute('data-on', 'true');
|
|
190
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
191
|
+
};
|
|
192
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
193
|
+
return () => {
|
|
194
|
+
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
|
195
|
+
};
|
|
196
|
+
}, [visible, elementSelections]);
|
|
197
|
+
|
|
198
|
+
if (!visible) {
|
|
199
|
+
return <div ref={ref} className="dc-elem-ctx-tb" aria-hidden="true" />;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// For multi-selection, Copy actions operate on the FIRST element — same
|
|
203
|
+
// convention as the right-click menu (single-target). Future polish: a
|
|
204
|
+
// mini-menu that lets the user pick which selection's CSS to copy.
|
|
205
|
+
const primary = elementSelections[0];
|
|
206
|
+
const count = elementSelections.length;
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<div ref={ref} className="dc-elem-ctx-tb" role="toolbar" aria-label="Element actions">
|
|
210
|
+
<span className="dc-elem-ctx-count">{count === 1 ? '1 element' : `${count} elements`}</span>
|
|
211
|
+
<button
|
|
212
|
+
type="button"
|
|
213
|
+
title="Copy CSS selector"
|
|
214
|
+
onClick={() => primary && copyText(primary.selector)}
|
|
215
|
+
>
|
|
216
|
+
Copy CSS
|
|
217
|
+
</button>
|
|
218
|
+
<button
|
|
219
|
+
type="button"
|
|
220
|
+
title="Copy data-cd-id"
|
|
221
|
+
disabled={!primary?.id}
|
|
222
|
+
onClick={() => primary?.id && copyText(primary.id)}
|
|
223
|
+
>
|
|
224
|
+
Copy ID
|
|
225
|
+
</button>
|
|
226
|
+
<button
|
|
227
|
+
type="button"
|
|
228
|
+
title="Add comment on this element"
|
|
229
|
+
onClick={() => {
|
|
230
|
+
if (!primary) return;
|
|
231
|
+
const node = primary.id
|
|
232
|
+
? document.querySelector(`[data-cd-id="${primary.id}"]`)
|
|
233
|
+
: document.querySelector(primary.selector);
|
|
234
|
+
openComposerForSelection(primary, node);
|
|
235
|
+
}}
|
|
236
|
+
>
|
|
237
|
+
Comment
|
|
238
|
+
</button>
|
|
239
|
+
</div>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
@@ -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
|
+
}
|