@1agh/maude 0.15.0 → 0.17.1
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 +4 -2
- package/cli/commands/design.mjs +108 -2
- package/package.json +12 -18
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +8 -8
- package/plugins/design/dev-server/annotations-layer.tsx +8 -10
- package/plugins/design/dev-server/api.ts +227 -3
- package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +40 -0
- package/plugins/design/dev-server/bin/_html-playwright.mjs +129 -0
- package/plugins/design/dev-server/bin/_pdf-playwright.mjs +105 -0
- package/plugins/design/dev-server/bin/_png-playwright.mjs +143 -0
- package/plugins/design/dev-server/bin/_pptx-playwright.mjs +98 -0
- package/plugins/design/dev-server/bin/_svg-playwright.mjs +141 -0
- package/plugins/design/dev-server/canvas-lib.tsx +12 -13
- package/plugins/design/dev-server/canvas-shell.tsx +111 -9
- package/plugins/design/dev-server/client/app.jsx +71 -143
- package/plugins/design/dev-server/client/comments-overlay.css +381 -0
- package/plugins/design/dev-server/client/styles/3-shell.css +1 -10
- package/plugins/design/dev-server/client/styles/4-components.css +5 -161
- package/plugins/design/dev-server/client/styles.css +5 -160
- package/plugins/design/dev-server/comments-overlay.tsx +1156 -0
- package/plugins/design/dev-server/context-menu.tsx +36 -9
- package/plugins/design/dev-server/dist/client.bundle.js +52 -211
- package/plugins/design/dev-server/dist/styles.css +1 -218
- package/plugins/design/dev-server/export-dialog.tsx +401 -0
- package/plugins/design/dev-server/exporters/_browser-bundles.ts +89 -0
- package/plugins/design/dev-server/exporters/canva-handoff-prompt.ts +74 -0
- package/plugins/design/dev-server/exporters/canva.ts +126 -0
- package/plugins/design/dev-server/exporters/html.ts +103 -0
- package/plugins/design/dev-server/exporters/index.ts +135 -0
- package/plugins/design/dev-server/exporters/pdf.ts +109 -0
- package/plugins/design/dev-server/exporters/png.ts +136 -0
- package/plugins/design/dev-server/exporters/pptx.ts +263 -0
- package/plugins/design/dev-server/exporters/scope.ts +196 -0
- package/plugins/design/dev-server/exporters/svg.ts +122 -0
- package/plugins/design/dev-server/exporters/zip.ts +109 -0
- package/plugins/design/dev-server/http.ts +109 -0
- package/plugins/design/dev-server/input-router.tsx +21 -0
- package/plugins/design/dev-server/inspect.ts +1 -1
- package/plugins/design/dev-server/server.mjs +1 -1
- package/plugins/design/dev-server/test/canvas-meta-api.test.ts +0 -10
- package/plugins/design/dev-server/test/comments-api.test.ts +229 -0
- package/plugins/design/dev-server/test/exporters/canva.test.ts +64 -0
- package/plugins/design/dev-server/test/exporters/endpoint.test.ts +121 -0
- package/plugins/design/dev-server/test/exporters/history.test.ts +79 -0
- package/plugins/design/dev-server/test/exporters/html.test.ts +26 -0
- package/plugins/design/dev-server/test/exporters/pdf.test.ts +53 -0
- package/plugins/design/dev-server/test/exporters/png.test.ts +32 -0
- package/plugins/design/dev-server/test/exporters/pptx.test.ts +31 -0
- package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
- package/plugins/design/dev-server/test/exporters/svg.test.ts +29 -0
- package/plugins/design/dev-server/test/exporters/zip.test.ts +105 -0
- package/plugins/design/dev-server/tool-palette.tsx +34 -16
- package/plugins/design/templates/_shell.html +33 -0
|
@@ -0,0 +1,1156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file comments-overlay.tsx — FigJam-style in-place comments overlay
|
|
3
|
+
* @scope plugins/design/dev-server/comments-overlay.tsx
|
|
4
|
+
* @purpose Renders DS-styled comment pins (Phase 6 Task 2), the in-place
|
|
5
|
+
* composer bubble (Task 3), and the thread popover (Task 4)
|
|
6
|
+
* inside the canvas iframe. Sibling to `annotations-layer` —
|
|
7
|
+
* portals into `.dc-world` so CSS zoom + translate on the world
|
|
8
|
+
* plane scale every pin/popover uniformly with the artboards.
|
|
9
|
+
*
|
|
10
|
+
* Data flow (Phase 6 Task 2 — pins only; composer + thread land in Task 3/4):
|
|
11
|
+
* 1. Shell (`client/app.jsx`) pushes `{ dgn: 'comments-set', comments }`
|
|
12
|
+
* into the iframe whenever its `commentsByFile[activePath]` changes.
|
|
13
|
+
* 2. Overlay also fetches `/_comments?file=...` on mount as a self-heal —
|
|
14
|
+
* lets the overlay render even if the shell hasn't broadcast yet
|
|
15
|
+
* (race on first iframe load).
|
|
16
|
+
* 3. Shell pushes `{ dgn: 'comment-focus', id }` when the user clicks a row
|
|
17
|
+
* in the comments panel — overlay highlights the matching pin.
|
|
18
|
+
* 4. Overlay posts `{ dgn: 'comment-click', id }` back to the shell when
|
|
19
|
+
* the user clicks a pin — same channel the legacy `dgn-pin` overlay
|
|
20
|
+
* used; the shell already routes it to `setFocusedCommentId`.
|
|
21
|
+
*
|
|
22
|
+
* Filter respect — Phase 6 Task 2 default is "hide resolved". The shell will
|
|
23
|
+
* gain a `comments-filter` channel in Task 6; until then the overlay always
|
|
24
|
+
* hides resolved pins. Plan-aligned.
|
|
25
|
+
*
|
|
26
|
+
* Pin position math — see `offsetWithinWorld` below. We walk offsetParent up
|
|
27
|
+
* to `.dc-world` to get pre-zoom world coords directly; CSS zoom on the world
|
|
28
|
+
* plane then renders the pin at the right scale without us doing zoom math.
|
|
29
|
+
*
|
|
30
|
+
* The legacy vanilla-JS `#dgn-pin-layer` injected by `inspect.ts` is hidden
|
|
31
|
+
* on mount to avoid double-pins inside TSX canvases. The legacy layer still
|
|
32
|
+
* renders for `.html` mocks where this React overlay never mounts.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
36
|
+
|
|
37
|
+
import { useSelectionSetOptional } from './use-selection-set.tsx';
|
|
38
|
+
|
|
39
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
40
|
+
// Types — kept in sync with `Comment` in `api.ts`. We mirror the shape rather
|
|
41
|
+
// than import to avoid pulling server types into the canvas runtime bundle.
|
|
42
|
+
|
|
43
|
+
interface OverlayBounds {
|
|
44
|
+
x: number;
|
|
45
|
+
y: number;
|
|
46
|
+
w: number;
|
|
47
|
+
h: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Selection payload posted by canvas-shell's `onDropComment`. Mirrors the
|
|
51
|
+
// shape `hoverTargetToSelection` returns; we keep it loose so the overlay
|
|
52
|
+
// doesn't depend on canvas-shell types.
|
|
53
|
+
interface ComposeSelection {
|
|
54
|
+
file?: string;
|
|
55
|
+
id?: string;
|
|
56
|
+
selector: string;
|
|
57
|
+
artboardId?: string | null;
|
|
58
|
+
tag: string;
|
|
59
|
+
classes: string;
|
|
60
|
+
text: string;
|
|
61
|
+
dom_path: string[];
|
|
62
|
+
bounds: OverlayBounds | null;
|
|
63
|
+
html: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface ComposerState {
|
|
67
|
+
selection: ComposeSelection;
|
|
68
|
+
clientX: number;
|
|
69
|
+
clientY: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface OverlayReply {
|
|
73
|
+
id: string;
|
|
74
|
+
author: string;
|
|
75
|
+
body: string;
|
|
76
|
+
created: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface OverlayComment {
|
|
80
|
+
id: string;
|
|
81
|
+
file: string;
|
|
82
|
+
selector: string;
|
|
83
|
+
bounds: OverlayBounds | null;
|
|
84
|
+
text: string;
|
|
85
|
+
status: 'open' | 'resolved';
|
|
86
|
+
created: string;
|
|
87
|
+
resolved_at: string | null;
|
|
88
|
+
author?: string;
|
|
89
|
+
thread?: OverlayReply[];
|
|
90
|
+
mentions?: string[];
|
|
91
|
+
// dom_path / tag / classes / html_excerpt unused at overlay layer; kept off
|
|
92
|
+
// the type to keep the surface tight.
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
96
|
+
// CSS load — sibling stylesheet, fetched once per session via a <link> tag.
|
|
97
|
+
// Inlining the file at build time would cost an extra bundler config; the
|
|
98
|
+
// overlay is internal-only so a runtime <link> is fine.
|
|
99
|
+
|
|
100
|
+
const CSS_HREF = '/_client/comments-overlay.css';
|
|
101
|
+
|
|
102
|
+
function ensureOverlayStyles(): void {
|
|
103
|
+
if (typeof document === 'undefined') return;
|
|
104
|
+
if (document.getElementById('cm-overlay-css')) return;
|
|
105
|
+
const link = document.createElement('link');
|
|
106
|
+
link.id = 'cm-overlay-css';
|
|
107
|
+
link.rel = 'stylesheet';
|
|
108
|
+
link.href = CSS_HREF;
|
|
109
|
+
document.head.appendChild(link);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
113
|
+
// File derivation — same logic as canvas-shell.tsx::deriveFile(). Duplicated
|
|
114
|
+
// here so the overlay can fetch its own comments on mount without importing
|
|
115
|
+
// from canvas-shell (which would create a cycle).
|
|
116
|
+
|
|
117
|
+
function deriveFile(): string | null {
|
|
118
|
+
if (typeof window === 'undefined') return null;
|
|
119
|
+
try {
|
|
120
|
+
const p = window.location.pathname;
|
|
121
|
+
if (p === '/_canvas-shell.html' || p === '/_canvas-shell') {
|
|
122
|
+
const qs = new URLSearchParams(window.location.search);
|
|
123
|
+
const canvas = qs.get('canvas') ?? '';
|
|
124
|
+
const designRel = (qs.get('designRel') ?? '.design').replace(/^\/+|\/+$/g, '');
|
|
125
|
+
return canvas ? `${designRel}/${canvas}` : null;
|
|
126
|
+
}
|
|
127
|
+
return decodeURIComponent(p).replace(/^\//, '');
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
134
|
+
// Position resolvers — screen coords via getBoundingClientRect. Mirrors the
|
|
135
|
+
// `SelectionHalos` / `HoverHalo` pattern in canvas-shell.tsx so the comments
|
|
136
|
+
// layer can render as a fixed-position sibling of `.dc-canvas` (above the
|
|
137
|
+
// halo chrome at z-index 5) instead of being portaled into `.dc-world` where
|
|
138
|
+
// it would lose the stacking battle.
|
|
139
|
+
|
|
140
|
+
function screenRectFor(selector: string): {
|
|
141
|
+
x: number;
|
|
142
|
+
y: number;
|
|
143
|
+
w: number;
|
|
144
|
+
h: number;
|
|
145
|
+
} | null {
|
|
146
|
+
if (!selector) return null;
|
|
147
|
+
let el: HTMLElement | null = null;
|
|
148
|
+
try {
|
|
149
|
+
el = document.querySelector(selector) as HTMLElement | null;
|
|
150
|
+
} catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
if (!el || !el.isConnected) return null;
|
|
154
|
+
const r = el.getBoundingClientRect();
|
|
155
|
+
if (r.width === 0 && r.height === 0) return null;
|
|
156
|
+
return { x: r.left, y: r.top, w: r.width, h: r.height };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
160
|
+
// Public component — mounted from canvas-shell.tsx alongside ToolPalette /
|
|
161
|
+
// AnnotationsLayer / SnapGuideOverlay.
|
|
162
|
+
|
|
163
|
+
export function CommentsOverlay(): React.ReactNode {
|
|
164
|
+
ensureOverlayStyles();
|
|
165
|
+
|
|
166
|
+
// Optional — CommentsOverlay is mounted inside SelectionSetProvider in the
|
|
167
|
+
// standard CanvasShell tree, but stays usable if a host ever embeds it
|
|
168
|
+
// outside that provider (returns null instead of throwing).
|
|
169
|
+
const selSet = useSelectionSetOptional();
|
|
170
|
+
const [comments, setComments] = useState<OverlayComment[]>([]);
|
|
171
|
+
const [focusedId, setFocusedId] = useState<string | null>(null);
|
|
172
|
+
const [composer, setComposer] = useState<ComposerState | null>(null);
|
|
173
|
+
const file = useMemo(() => deriveFile(), []);
|
|
174
|
+
|
|
175
|
+
// Drop the legacy `#dgn-pin-layer` so we don't render duplicate pins inside
|
|
176
|
+
// TSX canvases. The layer ships in every served HTML page via inspect.ts;
|
|
177
|
+
// for `.html` mocks (no canvas-shell mount) it still does its job.
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
if (typeof document === 'undefined') return;
|
|
180
|
+
const legacy = document.getElementById('dgn-pin-layer') as HTMLElement | null;
|
|
181
|
+
if (!legacy) return;
|
|
182
|
+
const prev = legacy.style.display;
|
|
183
|
+
legacy.style.display = 'none';
|
|
184
|
+
return () => {
|
|
185
|
+
legacy.style.display = prev;
|
|
186
|
+
};
|
|
187
|
+
}, []);
|
|
188
|
+
|
|
189
|
+
// Mirror a comment into the canvas selection set so SelectionHalos paints
|
|
190
|
+
// the same halo Cmd-click would. Called from both the in-iframe pin click
|
|
191
|
+
// AND the inbound `comment-focus` postMessage so jumping from the shell's
|
|
192
|
+
// Comments panel produces the same visual feedback as clicking the pin.
|
|
193
|
+
const mirrorSelection = useCallback(
|
|
194
|
+
(comment: OverlayComment | undefined) => {
|
|
195
|
+
if (!selSet) return;
|
|
196
|
+
if (!comment || !comment.selector) {
|
|
197
|
+
selSet.clear();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const cdMatch = comment.selector.match(/data-cd-id="([^"]+)"/);
|
|
201
|
+
const cdId = cdMatch ? cdMatch[1] : undefined;
|
|
202
|
+
let tag: string | undefined;
|
|
203
|
+
let classes: string | undefined;
|
|
204
|
+
try {
|
|
205
|
+
const el = document.querySelector(comment.selector) as HTMLElement | null;
|
|
206
|
+
if (el) {
|
|
207
|
+
tag = el.tagName.toLowerCase();
|
|
208
|
+
classes = (el.getAttribute('class') ?? '')
|
|
209
|
+
.split(/\s+/)
|
|
210
|
+
.filter((cls) => cls && !cls.startsWith('dgn-') && !cls.startsWith('dc-cv-'))
|
|
211
|
+
.join(' ');
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
/* unresolvable selector — fall through with no fresh metadata */
|
|
215
|
+
}
|
|
216
|
+
selSet.replace({
|
|
217
|
+
file: file ?? undefined,
|
|
218
|
+
id: cdId,
|
|
219
|
+
selector: comment.selector,
|
|
220
|
+
tag,
|
|
221
|
+
classes,
|
|
222
|
+
bounds: comment.bounds ?? undefined,
|
|
223
|
+
});
|
|
224
|
+
},
|
|
225
|
+
[selSet, file]
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
// Keep the latest comments list reachable from the message handler without
|
|
229
|
+
// re-attaching the listener on every comments mutation.
|
|
230
|
+
const commentsRef = useRef<OverlayComment[]>(comments);
|
|
231
|
+
commentsRef.current = comments;
|
|
232
|
+
|
|
233
|
+
// Listen for the shell's broadcast channels. Schema matches the legacy
|
|
234
|
+
// overlay so the shell-side glue in client/app.jsx (~line 1672) keeps
|
|
235
|
+
// working without modification.
|
|
236
|
+
useEffect(() => {
|
|
237
|
+
if (typeof window === 'undefined') return;
|
|
238
|
+
const onMessage = (e: MessageEvent) => {
|
|
239
|
+
const m = e.data as { dgn?: string; comments?: unknown; id?: string } | null;
|
|
240
|
+
if (!m || typeof m !== 'object' || !m.dgn) return;
|
|
241
|
+
if (m.dgn === 'comments-set' && Array.isArray(m.comments)) {
|
|
242
|
+
setComments(m.comments as OverlayComment[]);
|
|
243
|
+
} else if (m.dgn === 'comment-focus') {
|
|
244
|
+
const id = typeof m.id === 'string' ? m.id : null;
|
|
245
|
+
setFocusedId(id);
|
|
246
|
+
const target = id ? commentsRef.current.find((c) => c.id === id) : undefined;
|
|
247
|
+
mirrorSelection(target);
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
window.addEventListener('message', onMessage);
|
|
251
|
+
return () => window.removeEventListener('message', onMessage);
|
|
252
|
+
}, [mirrorSelection]);
|
|
253
|
+
|
|
254
|
+
// canvas-shell's `onDropComment` dispatches `cm:open-composer` on the iframe
|
|
255
|
+
// document. Open the composer pinned to that click point.
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
if (typeof document === 'undefined') return;
|
|
258
|
+
const onOpen = (e: Event) => {
|
|
259
|
+
const detail = (
|
|
260
|
+
e as CustomEvent<{ selection?: ComposeSelection; clientX?: number; clientY?: number }>
|
|
261
|
+
).detail;
|
|
262
|
+
if (!detail || !detail.selection) return;
|
|
263
|
+
setComposer({
|
|
264
|
+
selection: detail.selection,
|
|
265
|
+
clientX: typeof detail.clientX === 'number' ? detail.clientX : 0,
|
|
266
|
+
clientY: typeof detail.clientY === 'number' ? detail.clientY : 0,
|
|
267
|
+
});
|
|
268
|
+
};
|
|
269
|
+
document.addEventListener('cm:open-composer', onOpen);
|
|
270
|
+
return () => document.removeEventListener('cm:open-composer', onOpen);
|
|
271
|
+
}, []);
|
|
272
|
+
|
|
273
|
+
const closeComposer = useCallback(() => {
|
|
274
|
+
setComposer(null);
|
|
275
|
+
if (typeof window === 'undefined') return;
|
|
276
|
+
try {
|
|
277
|
+
window.parent.postMessage({ dgn: 'force-clear' }, '*');
|
|
278
|
+
} catch {
|
|
279
|
+
/* parent detached */
|
|
280
|
+
}
|
|
281
|
+
}, []);
|
|
282
|
+
|
|
283
|
+
const submitComposer = useCallback(
|
|
284
|
+
(text: string) => {
|
|
285
|
+
if (!composer) return;
|
|
286
|
+
const sel = composer.selection;
|
|
287
|
+
const payload = {
|
|
288
|
+
file: sel.file,
|
|
289
|
+
selector: sel.selector,
|
|
290
|
+
dom_path: sel.dom_path,
|
|
291
|
+
tag: sel.tag,
|
|
292
|
+
classes: sel.classes,
|
|
293
|
+
bounds: sel.bounds,
|
|
294
|
+
html_excerpt: sel.html,
|
|
295
|
+
text,
|
|
296
|
+
};
|
|
297
|
+
if (typeof window === 'undefined') return;
|
|
298
|
+
// Shell relays into the WS `comments-add` channel and persists.
|
|
299
|
+
try {
|
|
300
|
+
window.parent.postMessage({ dgn: 'comment-submit', payload }, '*');
|
|
301
|
+
} catch {
|
|
302
|
+
/* parent detached */
|
|
303
|
+
}
|
|
304
|
+
closeComposer();
|
|
305
|
+
},
|
|
306
|
+
[composer, closeComposer]
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
// Self-heal fetch — covers the race where the iframe loads before the shell
|
|
310
|
+
// pushes `comments-set` (e.g. first hydration on cold open).
|
|
311
|
+
useEffect(() => {
|
|
312
|
+
if (!file) return;
|
|
313
|
+
let cancelled = false;
|
|
314
|
+
(async () => {
|
|
315
|
+
try {
|
|
316
|
+
const r = await fetch(`/_comments?file=${encodeURIComponent(file)}`);
|
|
317
|
+
if (!r.ok) return;
|
|
318
|
+
const data = (await r.json()) as { comments?: OverlayComment[] };
|
|
319
|
+
if (cancelled) return;
|
|
320
|
+
if (Array.isArray(data.comments)) {
|
|
321
|
+
// Only set when we haven't received a shell broadcast yet; the
|
|
322
|
+
// shell is authoritative once it kicks in.
|
|
323
|
+
setComments((prev) => (prev.length === 0 ? (data.comments ?? []) : prev));
|
|
324
|
+
}
|
|
325
|
+
} catch {
|
|
326
|
+
/* offline / dev-server restart — silently no-op */
|
|
327
|
+
}
|
|
328
|
+
})();
|
|
329
|
+
return () => {
|
|
330
|
+
cancelled = true;
|
|
331
|
+
};
|
|
332
|
+
}, [file]);
|
|
333
|
+
|
|
334
|
+
// Sorted by `created` asc so sequence numbers are stable per canvas across
|
|
335
|
+
// reloads. Resolved comments are hidden by default (Task 2 spec).
|
|
336
|
+
const visible = useMemo(() => {
|
|
337
|
+
const list = comments.slice().sort((a, b) => a.created.localeCompare(b.created));
|
|
338
|
+
return list.filter((c) => c.status !== 'resolved');
|
|
339
|
+
}, [comments]);
|
|
340
|
+
|
|
341
|
+
// Sequence index lookup — built off the FULL sorted list so a resolved-then-
|
|
342
|
+
// reopened pin keeps its original number.
|
|
343
|
+
const indexById = useMemo(() => {
|
|
344
|
+
const m = new Map<string, number>();
|
|
345
|
+
const all = comments.slice().sort((a, b) => a.created.localeCompare(b.created));
|
|
346
|
+
all.forEach((c, i) => m.set(c.id, i + 1));
|
|
347
|
+
return m;
|
|
348
|
+
}, [comments]);
|
|
349
|
+
|
|
350
|
+
const handlePinClick = useCallback(
|
|
351
|
+
(id: string) => {
|
|
352
|
+
setFocusedId(id);
|
|
353
|
+
mirrorSelection(comments.find((c) => c.id === id));
|
|
354
|
+
if (typeof window === 'undefined') return;
|
|
355
|
+
try {
|
|
356
|
+
window.parent.postMessage({ dgn: 'comment-click', id }, '*');
|
|
357
|
+
} catch {
|
|
358
|
+
/* parent detached */
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
[comments, mirrorSelection]
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
const handlePatch = useCallback((id: string, patch: Record<string, unknown>) => {
|
|
365
|
+
if (typeof window === 'undefined') return;
|
|
366
|
+
try {
|
|
367
|
+
window.parent.postMessage({ dgn: 'comment-patch', id, patch }, '*');
|
|
368
|
+
} catch {
|
|
369
|
+
/* parent detached */
|
|
370
|
+
}
|
|
371
|
+
}, []);
|
|
372
|
+
|
|
373
|
+
const handleDelete = useCallback((id: string) => {
|
|
374
|
+
if (typeof window === 'undefined') return;
|
|
375
|
+
try {
|
|
376
|
+
window.parent.postMessage({ dgn: 'comment-delete', id }, '*');
|
|
377
|
+
} catch {
|
|
378
|
+
/* parent detached */
|
|
379
|
+
}
|
|
380
|
+
setFocusedId((prev) => (prev === id ? null : prev));
|
|
381
|
+
}, []);
|
|
382
|
+
|
|
383
|
+
const handleReply = useCallback(async (id: string, body: string): Promise<boolean> => {
|
|
384
|
+
if (typeof fetch === 'undefined') return false;
|
|
385
|
+
try {
|
|
386
|
+
const r = await fetch(`/_api/comments/${encodeURIComponent(id)}/reply`, {
|
|
387
|
+
method: 'POST',
|
|
388
|
+
headers: { 'Content-Type': 'application/json' },
|
|
389
|
+
body: JSON.stringify({ body }),
|
|
390
|
+
});
|
|
391
|
+
if (!r.ok) return false;
|
|
392
|
+
const updated = (await r.json()) as OverlayComment;
|
|
393
|
+
// Optimistic local merge — the shell will broadcast `comments-set`
|
|
394
|
+
// shortly after as the WS fans out the change, but applying it now
|
|
395
|
+
// avoids the popover flickering empty between submit + broadcast.
|
|
396
|
+
setComments((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
|
|
397
|
+
return true;
|
|
398
|
+
} catch {
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
}, []);
|
|
402
|
+
|
|
403
|
+
return (
|
|
404
|
+
<div className="cm-layer" aria-hidden={false}>
|
|
405
|
+
{visible.map((c) => {
|
|
406
|
+
const n = indexById.get(c.id) ?? 0;
|
|
407
|
+
return (
|
|
408
|
+
<CommentPin
|
|
409
|
+
key={c.id}
|
|
410
|
+
comment={c}
|
|
411
|
+
sequence={n}
|
|
412
|
+
focused={focusedId === c.id}
|
|
413
|
+
onClick={handlePinClick}
|
|
414
|
+
/>
|
|
415
|
+
);
|
|
416
|
+
})}
|
|
417
|
+
{composer ? (
|
|
418
|
+
<CommentComposer state={composer} onSubmit={submitComposer} onCancel={closeComposer} />
|
|
419
|
+
) : null}
|
|
420
|
+
{(() => {
|
|
421
|
+
if (!focusedId) return null;
|
|
422
|
+
const focused = visible.find((c) => c.id === focusedId);
|
|
423
|
+
if (!focused) return null;
|
|
424
|
+
return (
|
|
425
|
+
<CommentThread
|
|
426
|
+
comment={focused}
|
|
427
|
+
onClose={() => {
|
|
428
|
+
setFocusedId(null);
|
|
429
|
+
// Drop the canvas halo when the thread closes — symmetric with
|
|
430
|
+
// `handlePinClick` which paints it on open.
|
|
431
|
+
selSet?.clear();
|
|
432
|
+
}}
|
|
433
|
+
onPatch={(patch) => handlePatch(focused.id, patch)}
|
|
434
|
+
onDelete={() => handleDelete(focused.id)}
|
|
435
|
+
onReply={(body) => handleReply(focused.id, body)}
|
|
436
|
+
/>
|
|
437
|
+
);
|
|
438
|
+
})()}
|
|
439
|
+
</div>
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
444
|
+
// MentionAwareTextarea + popup — Task 5
|
|
445
|
+
//
|
|
446
|
+
// Wraps a textarea and surfaces an autocomplete popup when the caret sits
|
|
447
|
+
// inside an `@<query>` token (no whitespace between `@` and cursor). The
|
|
448
|
+
// committer list is fetched once on first focus and cached for the session.
|
|
449
|
+
// Keyboard: ↑↓ move, ↵/Tab insert (`@firstname `), Esc dismiss.
|
|
450
|
+
|
|
451
|
+
interface CommitterRow {
|
|
452
|
+
name: string;
|
|
453
|
+
email: string;
|
|
454
|
+
commits: number;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
let committerCache: Promise<CommitterRow[]> | null = null;
|
|
458
|
+
async function loadCommitters(): Promise<CommitterRow[]> {
|
|
459
|
+
if (!committerCache) {
|
|
460
|
+
committerCache = (async () => {
|
|
461
|
+
try {
|
|
462
|
+
const r = await fetch('/_api/git-committers');
|
|
463
|
+
if (!r.ok) return [];
|
|
464
|
+
const data = (await r.json()) as { committers?: CommitterRow[] };
|
|
465
|
+
return Array.isArray(data.committers) ? data.committers : [];
|
|
466
|
+
} catch {
|
|
467
|
+
return [];
|
|
468
|
+
}
|
|
469
|
+
})();
|
|
470
|
+
}
|
|
471
|
+
return committerCache;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function firstNameSlug(name: string): string {
|
|
475
|
+
// `@firstname` is what we insert on accept. Strip surnames + punctuation.
|
|
476
|
+
const first = name.trim().split(/\s+/)[0] ?? '';
|
|
477
|
+
// Keep alphanum + `.` `-` `_` (matches the parseMentions regex on the server).
|
|
478
|
+
return first.replace(/[^\w.-]/g, '').toLowerCase();
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
interface MentionToken {
|
|
482
|
+
start: number; // index of `@`
|
|
483
|
+
end: number; // exclusive — current caret
|
|
484
|
+
query: string; // chars between `@` and caret (excluding `@`)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function detectMentionToken(text: string, caret: number): MentionToken | null {
|
|
488
|
+
if (caret <= 0 || caret > text.length) return null;
|
|
489
|
+
// Walk backwards from caret; the token starts at `@` and ends at the caret.
|
|
490
|
+
// Aborts on whitespace, newline, or any non-mention char so a stray `@` in
|
|
491
|
+
// an email is ignored.
|
|
492
|
+
let i = caret - 1;
|
|
493
|
+
while (i >= 0) {
|
|
494
|
+
const ch = text[i] ?? '';
|
|
495
|
+
if (ch === '@') {
|
|
496
|
+
// Token must be word-leading: previous char is start-of-string or whitespace.
|
|
497
|
+
const prev = i > 0 ? text[i - 1] : '';
|
|
498
|
+
if (i === 0 || /\s/.test(prev ?? '')) {
|
|
499
|
+
const query = text.slice(i + 1, caret);
|
|
500
|
+
return { start: i, end: caret, query };
|
|
501
|
+
}
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
if (!/[\w.-]/.test(ch)) return null;
|
|
505
|
+
i -= 1;
|
|
506
|
+
}
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function MentionAwareTextarea({
|
|
511
|
+
className,
|
|
512
|
+
value,
|
|
513
|
+
onChange,
|
|
514
|
+
onKeyDown,
|
|
515
|
+
placeholder,
|
|
516
|
+
rows,
|
|
517
|
+
disabled,
|
|
518
|
+
textareaRef,
|
|
519
|
+
ariaLabel,
|
|
520
|
+
}: {
|
|
521
|
+
className: string;
|
|
522
|
+
value: string;
|
|
523
|
+
onChange: (next: string) => void;
|
|
524
|
+
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
|
525
|
+
placeholder?: string;
|
|
526
|
+
rows?: number;
|
|
527
|
+
disabled?: boolean;
|
|
528
|
+
textareaRef?: React.MutableRefObject<HTMLTextAreaElement | null>;
|
|
529
|
+
ariaLabel?: string;
|
|
530
|
+
}): React.ReactElement {
|
|
531
|
+
const internalRef = useRef<HTMLTextAreaElement | null>(null);
|
|
532
|
+
const setRef = useCallback(
|
|
533
|
+
(el: HTMLTextAreaElement | null) => {
|
|
534
|
+
internalRef.current = el;
|
|
535
|
+
if (textareaRef) textareaRef.current = el;
|
|
536
|
+
},
|
|
537
|
+
[textareaRef]
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
const [committers, setCommitters] = useState<CommitterRow[]>([]);
|
|
541
|
+
const [token, setToken] = useState<MentionToken | null>(null);
|
|
542
|
+
const [highlight, setHighlight] = useState(0);
|
|
543
|
+
|
|
544
|
+
// Lazy-load committers on first focus.
|
|
545
|
+
const onFocus = useCallback(() => {
|
|
546
|
+
if (committers.length > 0) return;
|
|
547
|
+
void loadCommitters().then((list) => setCommitters(list));
|
|
548
|
+
}, [committers.length]);
|
|
549
|
+
|
|
550
|
+
const filtered = useMemo(() => {
|
|
551
|
+
if (!token) return [] as CommitterRow[];
|
|
552
|
+
const q = token.query.toLowerCase();
|
|
553
|
+
const list = !q
|
|
554
|
+
? committers
|
|
555
|
+
: committers.filter(
|
|
556
|
+
(c) => c.name.toLowerCase().includes(q) || c.email.toLowerCase().includes(q)
|
|
557
|
+
);
|
|
558
|
+
return list.slice(0, 8);
|
|
559
|
+
}, [token, committers]);
|
|
560
|
+
|
|
561
|
+
const refreshToken = useCallback((textarea: HTMLTextAreaElement) => {
|
|
562
|
+
const caret = textarea.selectionStart ?? textarea.value.length;
|
|
563
|
+
const t = detectMentionToken(textarea.value, caret);
|
|
564
|
+
setToken(t);
|
|
565
|
+
setHighlight(0);
|
|
566
|
+
}, []);
|
|
567
|
+
|
|
568
|
+
const handleChange = useCallback(
|
|
569
|
+
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
570
|
+
onChange(e.target.value);
|
|
571
|
+
refreshToken(e.target);
|
|
572
|
+
},
|
|
573
|
+
[onChange, refreshToken]
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
const insertMention = useCallback(
|
|
577
|
+
(committer: CommitterRow) => {
|
|
578
|
+
if (!token) return;
|
|
579
|
+
const ta = internalRef.current;
|
|
580
|
+
if (!ta) return;
|
|
581
|
+
const tag = `@${firstNameSlug(committer.name)}`;
|
|
582
|
+
const next = `${value.slice(0, token.start)}${tag} ${value.slice(token.end)}`;
|
|
583
|
+
onChange(next);
|
|
584
|
+
setToken(null);
|
|
585
|
+
// Restore caret just past the inserted token + trailing space.
|
|
586
|
+
const newCaret = token.start + tag.length + 1;
|
|
587
|
+
requestAnimationFrame(() => {
|
|
588
|
+
ta.focus();
|
|
589
|
+
ta.setSelectionRange(newCaret, newCaret);
|
|
590
|
+
});
|
|
591
|
+
},
|
|
592
|
+
[token, value, onChange]
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
const handleKeyDown = useCallback(
|
|
596
|
+
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
597
|
+
if (token && filtered.length > 0) {
|
|
598
|
+
if (e.key === 'ArrowDown') {
|
|
599
|
+
e.preventDefault();
|
|
600
|
+
setHighlight((h) => (h + 1) % filtered.length);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (e.key === 'ArrowUp') {
|
|
604
|
+
e.preventDefault();
|
|
605
|
+
setHighlight((h) => (h - 1 + filtered.length) % filtered.length);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
if (e.key === 'Enter' || e.key === 'Tab') {
|
|
609
|
+
e.preventDefault();
|
|
610
|
+
const pick = filtered[highlight] ?? filtered[0];
|
|
611
|
+
if (pick) insertMention(pick);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
if (e.key === 'Escape') {
|
|
615
|
+
e.preventDefault();
|
|
616
|
+
setToken(null);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
onKeyDown?.(e);
|
|
621
|
+
},
|
|
622
|
+
[token, filtered, highlight, insertMention, onKeyDown]
|
|
623
|
+
);
|
|
624
|
+
|
|
625
|
+
const handleSelect = useCallback(
|
|
626
|
+
(e: React.SyntheticEvent<HTMLTextAreaElement>) => {
|
|
627
|
+
refreshToken(e.currentTarget);
|
|
628
|
+
},
|
|
629
|
+
[refreshToken]
|
|
630
|
+
);
|
|
631
|
+
|
|
632
|
+
return (
|
|
633
|
+
<div style={{ position: 'relative' }}>
|
|
634
|
+
<textarea
|
|
635
|
+
ref={setRef}
|
|
636
|
+
className={className}
|
|
637
|
+
value={value}
|
|
638
|
+
placeholder={placeholder}
|
|
639
|
+
rows={rows}
|
|
640
|
+
disabled={disabled}
|
|
641
|
+
aria-label={ariaLabel}
|
|
642
|
+
onChange={handleChange}
|
|
643
|
+
onKeyDown={handleKeyDown}
|
|
644
|
+
onFocus={onFocus}
|
|
645
|
+
onSelect={handleSelect}
|
|
646
|
+
onClick={handleSelect}
|
|
647
|
+
/>
|
|
648
|
+
{/* Combobox pattern — `role="listbox"` + `role="option"` is the canonical
|
|
649
|
+
* ARIA shape for a single-select autocomplete. Keyboard navigation
|
|
650
|
+
* (↑ ↓ Enter Esc) lives on the parent textarea per the combobox spec,
|
|
651
|
+
* so the popup itself stays inert. Same pattern applies to the composer
|
|
652
|
+
* + thread popovers below (`role="dialog"` on a positioned <div>).
|
|
653
|
+
* Biome's a11y rules want semantic HTML primitives, but none match
|
|
654
|
+
* "non-focusable listbox under a textarea" or "anchored non-modal popover".
|
|
655
|
+
* The four affected rules are scoped off for this file in biome.json. */}
|
|
656
|
+
{token && filtered.length > 0 ? (
|
|
657
|
+
<ul
|
|
658
|
+
className="cm-mention-popup"
|
|
659
|
+
role="listbox"
|
|
660
|
+
aria-label="Mention suggestions"
|
|
661
|
+
style={{ left: 0, top: '100%' }}
|
|
662
|
+
>
|
|
663
|
+
{filtered.map((c, i) => {
|
|
664
|
+
const selected = i === highlight;
|
|
665
|
+
return (
|
|
666
|
+
<li
|
|
667
|
+
key={`${c.name}-${c.email}`}
|
|
668
|
+
role="option"
|
|
669
|
+
aria-selected={selected}
|
|
670
|
+
className="cm-mention-popup__item"
|
|
671
|
+
onMouseEnter={() => setHighlight(i)}
|
|
672
|
+
// Use mousedown so the textarea doesn't blur before the
|
|
673
|
+
// selection registers.
|
|
674
|
+
onMouseDown={(ev) => {
|
|
675
|
+
ev.preventDefault();
|
|
676
|
+
insertMention(c);
|
|
677
|
+
}}
|
|
678
|
+
>
|
|
679
|
+
<span className="cm-mention-popup__name">@{firstNameSlug(c.name)}</span>
|
|
680
|
+
<span className="cm-mention-popup__email">{c.email}</span>
|
|
681
|
+
</li>
|
|
682
|
+
);
|
|
683
|
+
})}
|
|
684
|
+
</ul>
|
|
685
|
+
) : null}
|
|
686
|
+
</div>
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
691
|
+
// CommentPin — single 24×24 badge anchored to top-right of its target element.
|
|
692
|
+
// Resolves target on every animation frame to track layout shifts (drag,
|
|
693
|
+
// reflow, font load). Falls back to the stored `bounds` when the target is
|
|
694
|
+
// gone from the DOM.
|
|
695
|
+
|
|
696
|
+
function CommentPin({
|
|
697
|
+
comment,
|
|
698
|
+
sequence,
|
|
699
|
+
focused,
|
|
700
|
+
onClick,
|
|
701
|
+
}: {
|
|
702
|
+
comment: OverlayComment;
|
|
703
|
+
sequence: number;
|
|
704
|
+
focused: boolean;
|
|
705
|
+
onClick: (id: string) => void;
|
|
706
|
+
}) {
|
|
707
|
+
const ref = useRef<HTMLButtonElement | null>(null);
|
|
708
|
+
const rafRef = useRef<number | null>(null);
|
|
709
|
+
|
|
710
|
+
useEffect(() => {
|
|
711
|
+
const tick = () => {
|
|
712
|
+
rafRef.current = null;
|
|
713
|
+
const pin = ref.current;
|
|
714
|
+
if (!pin) return;
|
|
715
|
+
|
|
716
|
+
// Live screen-coord lookup mirrors SelectionHalos in canvas-shell.tsx.
|
|
717
|
+
// Falls back to stored bounds (a screen-coord capture at create time)
|
|
718
|
+
// when the target element is gone — better than vanishing entirely.
|
|
719
|
+
let pos = screenRectFor(comment.selector);
|
|
720
|
+
if (!pos && comment.bounds) {
|
|
721
|
+
pos = {
|
|
722
|
+
x: comment.bounds.x,
|
|
723
|
+
y: comment.bounds.y,
|
|
724
|
+
w: comment.bounds.w,
|
|
725
|
+
h: comment.bounds.h,
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
if (!pos) {
|
|
729
|
+
pin.style.display = 'none';
|
|
730
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
pin.style.display = 'grid';
|
|
734
|
+
// Position the pin's center at (right - 12, top - 12) — the FigJam
|
|
735
|
+
// convention. 12 = half of 24 (the pin's own size).
|
|
736
|
+
const left = Math.round(pos.x + pos.w - 12);
|
|
737
|
+
const top = Math.round(pos.y - 12);
|
|
738
|
+
pin.style.left = `${left}px`;
|
|
739
|
+
pin.style.top = `${top}px`;
|
|
740
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
741
|
+
};
|
|
742
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
743
|
+
return () => {
|
|
744
|
+
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
|
745
|
+
};
|
|
746
|
+
}, [comment.selector, comment.bounds]);
|
|
747
|
+
|
|
748
|
+
const author = comment.author?.trim() || 'unknown';
|
|
749
|
+
const label = `Comment ${sequence} by ${author}`;
|
|
750
|
+
|
|
751
|
+
return (
|
|
752
|
+
<button
|
|
753
|
+
ref={ref}
|
|
754
|
+
type="button"
|
|
755
|
+
className="cm-pin"
|
|
756
|
+
data-resolved={comment.status === 'resolved' ? 'true' : 'false'}
|
|
757
|
+
data-focused={focused ? 'true' : 'false'}
|
|
758
|
+
data-comment-pin={comment.id}
|
|
759
|
+
aria-label={label}
|
|
760
|
+
aria-expanded={focused}
|
|
761
|
+
title={comment.text.slice(0, 200)}
|
|
762
|
+
onClick={(e) => {
|
|
763
|
+
e.preventDefault();
|
|
764
|
+
e.stopPropagation();
|
|
765
|
+
onClick(comment.id);
|
|
766
|
+
}}
|
|
767
|
+
>
|
|
768
|
+
{sequence}
|
|
769
|
+
</button>
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
774
|
+
// CommentComposer — DS-styled card anchored just under the clicked element
|
|
775
|
+
// (or at the click point if the click hit empty canvas). Edge-clamp is the
|
|
776
|
+
// pragmatic kind: position is computed once on open against the world layout,
|
|
777
|
+
// not chased on pan/zoom — the user is actively typing.
|
|
778
|
+
|
|
779
|
+
function CommentComposer({
|
|
780
|
+
state,
|
|
781
|
+
onSubmit,
|
|
782
|
+
onCancel,
|
|
783
|
+
}: {
|
|
784
|
+
state: ComposerState;
|
|
785
|
+
onSubmit: (text: string) => void;
|
|
786
|
+
onCancel: () => void;
|
|
787
|
+
}) {
|
|
788
|
+
const [text, setText] = useState('');
|
|
789
|
+
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
|
790
|
+
const cardRef = useRef<HTMLDivElement | null>(null);
|
|
791
|
+
const rafRef = useRef<number | null>(null);
|
|
792
|
+
|
|
793
|
+
// Live anchor — composer tracks the target element via rAF so pan/zoom
|
|
794
|
+
// while typing keeps the card glued to its anchor. Writes directly to the
|
|
795
|
+
// DOM so we don't re-render every frame.
|
|
796
|
+
useEffect(() => {
|
|
797
|
+
const tick = () => {
|
|
798
|
+
rafRef.current = null;
|
|
799
|
+
const node = cardRef.current;
|
|
800
|
+
if (!node) return;
|
|
801
|
+
const anchor = computeAnchor(state);
|
|
802
|
+
node.style.left = `${Math.round(anchor.x)}px`;
|
|
803
|
+
node.style.top = `${Math.round(anchor.y)}px`;
|
|
804
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
805
|
+
};
|
|
806
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
807
|
+
return () => {
|
|
808
|
+
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
|
809
|
+
};
|
|
810
|
+
}, [state]);
|
|
811
|
+
|
|
812
|
+
useEffect(() => {
|
|
813
|
+
textareaRef.current?.focus();
|
|
814
|
+
}, []);
|
|
815
|
+
|
|
816
|
+
const trySubmit = useCallback(() => {
|
|
817
|
+
const v = text.trim();
|
|
818
|
+
if (!v) return;
|
|
819
|
+
onSubmit(v);
|
|
820
|
+
}, [text, onSubmit]);
|
|
821
|
+
|
|
822
|
+
const onKeyDown = useCallback(
|
|
823
|
+
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
824
|
+
if (e.key === 'Escape') {
|
|
825
|
+
e.preventDefault();
|
|
826
|
+
onCancel();
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
|
830
|
+
e.preventDefault();
|
|
831
|
+
trySubmit();
|
|
832
|
+
}
|
|
833
|
+
},
|
|
834
|
+
[onCancel, trySubmit]
|
|
835
|
+
);
|
|
836
|
+
|
|
837
|
+
// Compact selector hint — strip noisy structural bits so the head stays
|
|
838
|
+
// tight inside the 300px card.
|
|
839
|
+
const selectorChip = useMemo(() => {
|
|
840
|
+
const s = state.selection.selector || '';
|
|
841
|
+
if (!s) return state.selection.tag || 'canvas';
|
|
842
|
+
// [data-cd-id="…"] → cd:<id> · keeps the chip readable when stable ids
|
|
843
|
+
// are present.
|
|
844
|
+
const cd = s.match(/data-cd-id="([^"]+)"/);
|
|
845
|
+
if (cd) return `cd:${cd[1]}`;
|
|
846
|
+
return s.length > 36 ? `${s.slice(0, 33)}…` : s;
|
|
847
|
+
}, [state.selection]);
|
|
848
|
+
|
|
849
|
+
return (
|
|
850
|
+
<div
|
|
851
|
+
ref={cardRef}
|
|
852
|
+
className="cm-composer"
|
|
853
|
+
role="dialog"
|
|
854
|
+
aria-label="New comment"
|
|
855
|
+
onClick={(e) => e.stopPropagation()}
|
|
856
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
857
|
+
>
|
|
858
|
+
<div className="cm-composer__head">
|
|
859
|
+
<span>New comment</span>
|
|
860
|
+
<span className="cm-composer__selector">{selectorChip}</span>
|
|
861
|
+
</div>
|
|
862
|
+
<MentionAwareTextarea
|
|
863
|
+
textareaRef={textareaRef}
|
|
864
|
+
className="cm-composer__textarea"
|
|
865
|
+
value={text}
|
|
866
|
+
placeholder="Type a comment. ⌘↵ to save · Esc to cancel · @name to tag"
|
|
867
|
+
onChange={setText}
|
|
868
|
+
onKeyDown={onKeyDown}
|
|
869
|
+
rows={3}
|
|
870
|
+
ariaLabel="Comment body"
|
|
871
|
+
/>
|
|
872
|
+
<div className="cm-composer__actions">
|
|
873
|
+
<button type="button" className="cm-btn" onClick={onCancel}>
|
|
874
|
+
Cancel
|
|
875
|
+
</button>
|
|
876
|
+
<button
|
|
877
|
+
type="button"
|
|
878
|
+
className="cm-btn cm-btn--primary"
|
|
879
|
+
disabled={!text.trim()}
|
|
880
|
+
onClick={trySubmit}
|
|
881
|
+
>
|
|
882
|
+
Save
|
|
883
|
+
</button>
|
|
884
|
+
</div>
|
|
885
|
+
</div>
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
890
|
+
// CommentThread — popover anchored to the focused pin. Shows author + relative
|
|
891
|
+
// time + selector chip, the original body with @mentions bolded, replies, a
|
|
892
|
+
// reply textarea, and resolve/reopen/delete actions. Patches + deletes route
|
|
893
|
+
// through the shell's existing WS channel via postMessage; replies POST
|
|
894
|
+
// directly to `/_api/comments/<id>/reply` because that endpoint exists only on
|
|
895
|
+
// Bun runtime and lives in `http.ts`.
|
|
896
|
+
|
|
897
|
+
function CommentThread({
|
|
898
|
+
comment,
|
|
899
|
+
onClose,
|
|
900
|
+
onPatch,
|
|
901
|
+
onDelete,
|
|
902
|
+
onReply,
|
|
903
|
+
}: {
|
|
904
|
+
comment: OverlayComment;
|
|
905
|
+
onClose: () => void;
|
|
906
|
+
onPatch: (patch: Record<string, unknown>) => void;
|
|
907
|
+
onDelete: () => void;
|
|
908
|
+
onReply: (body: string) => Promise<boolean>;
|
|
909
|
+
}) {
|
|
910
|
+
const dialogRef = useRef<HTMLDivElement | null>(null);
|
|
911
|
+
const replyRef = useRef<HTMLTextAreaElement | null>(null);
|
|
912
|
+
const rafRef = useRef<number | null>(null);
|
|
913
|
+
const [reply, setReply] = useState('');
|
|
914
|
+
const [sending, setSending] = useState(false);
|
|
915
|
+
|
|
916
|
+
// Live anchor — popover tracks the pin via rAF so it stays glued to its
|
|
917
|
+
// target through pan / zoom (FigJam parity). Writing to the dialog style
|
|
918
|
+
// directly avoids re-rendering every frame.
|
|
919
|
+
useEffect(() => {
|
|
920
|
+
const tick = () => {
|
|
921
|
+
rafRef.current = null;
|
|
922
|
+
const node = dialogRef.current;
|
|
923
|
+
if (!node) return;
|
|
924
|
+
const anchor = computeThreadAnchor(comment);
|
|
925
|
+
node.style.left = `${Math.round(anchor.x)}px`;
|
|
926
|
+
node.style.top = `${Math.round(anchor.y)}px`;
|
|
927
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
928
|
+
};
|
|
929
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
930
|
+
return () => {
|
|
931
|
+
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
|
932
|
+
};
|
|
933
|
+
}, [comment]);
|
|
934
|
+
|
|
935
|
+
useEffect(() => {
|
|
936
|
+
// Move focus into the dialog on open. Per WCAG 2.1 the dialog should own
|
|
937
|
+
// initial focus; we put it on the dialog root (focusable via tabindex)
|
|
938
|
+
// so screen readers announce the header before the body. On close,
|
|
939
|
+
// return focus to the pin so keyboard users land where they started.
|
|
940
|
+
dialogRef.current?.focus();
|
|
941
|
+
const pinId = comment.id;
|
|
942
|
+
return () => {
|
|
943
|
+
const pin = document.querySelector<HTMLButtonElement>(`[data-comment-pin="${pinId}"]`);
|
|
944
|
+
pin?.focus();
|
|
945
|
+
};
|
|
946
|
+
}, [comment.id]);
|
|
947
|
+
|
|
948
|
+
// Esc-to-close while focus is anywhere inside the popover.
|
|
949
|
+
useEffect(() => {
|
|
950
|
+
if (typeof document === 'undefined') return;
|
|
951
|
+
const onKey = (e: KeyboardEvent) => {
|
|
952
|
+
if (e.key !== 'Escape') return;
|
|
953
|
+
const root = dialogRef.current;
|
|
954
|
+
if (!root) return;
|
|
955
|
+
if (root.contains(e.target as Node) || document.activeElement === root) {
|
|
956
|
+
e.preventDefault();
|
|
957
|
+
onClose();
|
|
958
|
+
}
|
|
959
|
+
};
|
|
960
|
+
document.addEventListener('keydown', onKey);
|
|
961
|
+
return () => document.removeEventListener('keydown', onKey);
|
|
962
|
+
}, [onClose]);
|
|
963
|
+
|
|
964
|
+
const trySendReply = useCallback(async () => {
|
|
965
|
+
const v = reply.trim();
|
|
966
|
+
if (!v || sending) return;
|
|
967
|
+
setSending(true);
|
|
968
|
+
const ok = await onReply(v);
|
|
969
|
+
setSending(false);
|
|
970
|
+
if (ok) {
|
|
971
|
+
setReply('');
|
|
972
|
+
replyRef.current?.focus();
|
|
973
|
+
}
|
|
974
|
+
}, [reply, sending, onReply]);
|
|
975
|
+
|
|
976
|
+
const onReplyKeyDown = useCallback(
|
|
977
|
+
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
978
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
|
979
|
+
e.preventDefault();
|
|
980
|
+
void trySendReply();
|
|
981
|
+
}
|
|
982
|
+
},
|
|
983
|
+
[trySendReply]
|
|
984
|
+
);
|
|
985
|
+
|
|
986
|
+
const headId = `cm-thread-head-${comment.id}`;
|
|
987
|
+
const selectorChip = formatSelectorChip(comment.selector, '');
|
|
988
|
+
|
|
989
|
+
return (
|
|
990
|
+
<div
|
|
991
|
+
ref={dialogRef}
|
|
992
|
+
className="cm-thread"
|
|
993
|
+
role="dialog"
|
|
994
|
+
aria-labelledby={headId}
|
|
995
|
+
tabIndex={-1}
|
|
996
|
+
onClick={(e) => e.stopPropagation()}
|
|
997
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
998
|
+
>
|
|
999
|
+
<div className="cm-thread__head" id={headId}>
|
|
1000
|
+
<div className="cm-thread__head-row">
|
|
1001
|
+
<span className="cm-thread__author">{comment.author?.trim() || 'unknown'}</span>
|
|
1002
|
+
<span className="cm-thread__time">{formatRelativeTime(comment.created)}</span>
|
|
1003
|
+
<button
|
|
1004
|
+
type="button"
|
|
1005
|
+
className="cm-thread__close"
|
|
1006
|
+
aria-label="Close thread"
|
|
1007
|
+
title="Close · Esc"
|
|
1008
|
+
onClick={onClose}
|
|
1009
|
+
>
|
|
1010
|
+
×
|
|
1011
|
+
</button>
|
|
1012
|
+
</div>
|
|
1013
|
+
{selectorChip ? <code className="cm-thread__selector">{selectorChip}</code> : null}
|
|
1014
|
+
</div>
|
|
1015
|
+
|
|
1016
|
+
<div className="cm-thread__body">{renderBodyWithMentions(comment.text)}</div>
|
|
1017
|
+
|
|
1018
|
+
{(comment.thread ?? []).map((r) => (
|
|
1019
|
+
<div className="cm-thread__reply" key={r.id}>
|
|
1020
|
+
<div className="cm-thread__reply-head">
|
|
1021
|
+
<span className="cm-thread__reply-author">{r.author?.trim() || 'unknown'}</span>
|
|
1022
|
+
<span className="cm-thread__reply-time">{formatRelativeTime(r.created)}</span>
|
|
1023
|
+
</div>
|
|
1024
|
+
<div className="cm-thread__reply-body">{renderBodyWithMentions(r.body)}</div>
|
|
1025
|
+
</div>
|
|
1026
|
+
))}
|
|
1027
|
+
|
|
1028
|
+
<div className="cm-thread__reply-form">
|
|
1029
|
+
<MentionAwareTextarea
|
|
1030
|
+
textareaRef={replyRef}
|
|
1031
|
+
className="cm-thread__reply-textarea"
|
|
1032
|
+
value={reply}
|
|
1033
|
+
placeholder="Reply… ⌘↵ to send · @name to tag"
|
|
1034
|
+
onChange={setReply}
|
|
1035
|
+
onKeyDown={onReplyKeyDown}
|
|
1036
|
+
rows={2}
|
|
1037
|
+
ariaLabel="Reply"
|
|
1038
|
+
disabled={sending}
|
|
1039
|
+
/>
|
|
1040
|
+
<div className="cm-thread__reply-actions">
|
|
1041
|
+
<button
|
|
1042
|
+
type="button"
|
|
1043
|
+
className="cm-btn cm-btn--primary"
|
|
1044
|
+
disabled={!reply.trim() || sending}
|
|
1045
|
+
onClick={() => void trySendReply()}
|
|
1046
|
+
>
|
|
1047
|
+
Send
|
|
1048
|
+
</button>
|
|
1049
|
+
</div>
|
|
1050
|
+
</div>
|
|
1051
|
+
|
|
1052
|
+
<div className="cm-thread__actions">
|
|
1053
|
+
{comment.status === 'resolved' ? (
|
|
1054
|
+
<button type="button" className="cm-btn" onClick={() => onPatch({ status: 'open' })}>
|
|
1055
|
+
↺ Reopen
|
|
1056
|
+
</button>
|
|
1057
|
+
) : (
|
|
1058
|
+
<button
|
|
1059
|
+
type="button"
|
|
1060
|
+
className="cm-btn cm-btn--primary"
|
|
1061
|
+
onClick={() => {
|
|
1062
|
+
onPatch({ status: 'resolved' });
|
|
1063
|
+
onClose();
|
|
1064
|
+
}}
|
|
1065
|
+
>
|
|
1066
|
+
✓ Resolve
|
|
1067
|
+
</button>
|
|
1068
|
+
)}
|
|
1069
|
+
<button
|
|
1070
|
+
type="button"
|
|
1071
|
+
className="cm-btn cm-btn--danger"
|
|
1072
|
+
onClick={() => {
|
|
1073
|
+
onDelete();
|
|
1074
|
+
onClose();
|
|
1075
|
+
}}
|
|
1076
|
+
>
|
|
1077
|
+
Delete
|
|
1078
|
+
</button>
|
|
1079
|
+
</div>
|
|
1080
|
+
</div>
|
|
1081
|
+
);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1085
|
+
// Body renderer — splits text on @-handles, wraps each in <strong>. Anything
|
|
1086
|
+
// not matching the mention regex stays plain text (newlines preserved by CSS
|
|
1087
|
+
// `white-space: pre-wrap`).
|
|
1088
|
+
|
|
1089
|
+
function renderBodyWithMentions(text: string): React.ReactNode {
|
|
1090
|
+
if (!text) return null;
|
|
1091
|
+
const re = /(@[\w][\w.-]*)/g;
|
|
1092
|
+
const parts = text.split(re);
|
|
1093
|
+
return parts.map((part, i) => {
|
|
1094
|
+
// The split positions ARE the identity here — for the same `text` input,
|
|
1095
|
+
// index `i` always maps to the same fragment. Compose key from index +
|
|
1096
|
+
// content so biome's array-index-key heuristic is satisfied AND reorder
|
|
1097
|
+
// resistance is intact if `text` mutates mid-render.
|
|
1098
|
+
const key = `${i}:${part}`;
|
|
1099
|
+
if (i % 2 === 1) {
|
|
1100
|
+
// Odd parts are the captured @handles thanks to the parenthesized split.
|
|
1101
|
+
return (
|
|
1102
|
+
<strong key={key} data-mention="true">
|
|
1103
|
+
{part}
|
|
1104
|
+
</strong>
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
return <span key={key}>{part}</span>;
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function formatRelativeTime(iso: string): string {
|
|
1112
|
+
if (!iso) return '';
|
|
1113
|
+
const t = Date.parse(iso);
|
|
1114
|
+
if (!Number.isFinite(t)) return '';
|
|
1115
|
+
const diffSec = Math.round((Date.now() - t) / 1000);
|
|
1116
|
+
if (diffSec < 60) return `${Math.max(diffSec, 0)}s ago`;
|
|
1117
|
+
if (diffSec < 3600) return `${Math.round(diffSec / 60)}m ago`;
|
|
1118
|
+
if (diffSec < 86_400) return `${Math.round(diffSec / 3600)}h ago`;
|
|
1119
|
+
return `${Math.round(diffSec / 86_400)}d ago`;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function formatSelectorChip(selector: string, fallback: string): string {
|
|
1123
|
+
if (!selector) return fallback;
|
|
1124
|
+
const cd = selector.match(/data-cd-id="([^"]+)"/);
|
|
1125
|
+
if (cd) return `cd:${cd[1]}`;
|
|
1126
|
+
return selector.length > 36 ? `${selector.slice(0, 33)}…` : selector;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function computeThreadAnchor(comment: OverlayComment): { x: number; y: number } {
|
|
1130
|
+
// Resolve target's live screen rect; popover drops below the pin with small
|
|
1131
|
+
// breathing room. Stored bounds (capture-time screen coords) are the
|
|
1132
|
+
// last-resort fallback for orphaned pins.
|
|
1133
|
+
const rect = comment.selector ? screenRectFor(comment.selector) : null;
|
|
1134
|
+
if (rect) {
|
|
1135
|
+
// Pin sits at (rect.right - 12, rect.top - 12). Place popover at the same
|
|
1136
|
+
// x for visual continuity, 16px below the top so it clears the pin.
|
|
1137
|
+
return { x: rect.x + rect.w - 12, y: rect.y + 16 };
|
|
1138
|
+
}
|
|
1139
|
+
if (comment.bounds) {
|
|
1140
|
+
return { x: comment.bounds.x + comment.bounds.w - 12, y: comment.bounds.y + 16 };
|
|
1141
|
+
}
|
|
1142
|
+
return { x: 16, y: 16 };
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function computeAnchor(state: ComposerState): { x: number; y: number } {
|
|
1146
|
+
// Try the selected element first — its live screen rect gives the most
|
|
1147
|
+
// natural anchor (composer sits flush under the element the user clicked).
|
|
1148
|
+
if (state.selection.selector) {
|
|
1149
|
+
const rect = screenRectFor(state.selection.selector);
|
|
1150
|
+
if (rect) {
|
|
1151
|
+
return { x: rect.x, y: rect.y + rect.h + 8 };
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
// Fall back to the raw click point — composer drops 8px below the cursor.
|
|
1155
|
+
return { x: state.clientX, y: state.clientY + 8 };
|
|
1156
|
+
}
|