@fabrika/canvas-kernel 0.1.45 → 0.3.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.
Files changed (70) hide show
  1. package/README.md +33 -67
  2. package/index.ts +20 -83
  3. package/package.json +69 -72
  4. package/src/client/AnnotationProvider.tsx +218 -255
  5. package/src/client/AnnotationSidebar.tsx +393 -338
  6. package/src/client/FileIcon.tsx +105 -66
  7. package/src/client/PlanRenderer.tsx +607 -551
  8. package/src/client/RenderErrorContext.ts +7 -7
  9. package/src/client/ResponsePreview.tsx +192 -173
  10. package/src/client/annotationDom.ts +43 -97
  11. package/src/client/annotationDraft.ts +106 -96
  12. package/src/client/appContext.tsx +40 -40
  13. package/src/client/components/Callout.tsx +20 -20
  14. package/src/client/components/Checklist.tsx +27 -29
  15. package/src/client/components/Choice.tsx +178 -159
  16. package/src/client/components/CodeBlock.tsx +27 -26
  17. package/src/client/components/Diff.tsx +16 -17
  18. package/src/client/components/FilePreview.tsx +102 -74
  19. package/src/client/components/Image.tsx +41 -39
  20. package/src/client/components/Markdown.tsx +33 -32
  21. package/src/client/components/Mermaid.tsx +593 -542
  22. package/src/client/components/Note.tsx +8 -8
  23. package/src/client/components/Priority.tsx +10 -12
  24. package/src/client/components/ResponseNote.tsx +16 -15
  25. package/src/client/components/Section.tsx +28 -26
  26. package/src/client/components/Table.tsx +33 -30
  27. package/src/client/components/Task.tsx +58 -60
  28. package/src/client/components/UserInput.tsx +169 -152
  29. package/src/client/components/index.ts +16 -16
  30. package/src/client/components/renderMarkdownChildren.tsx +86 -68
  31. package/src/client/diffLines.ts +51 -39
  32. package/src/client/generateMarkdown.ts +197 -191
  33. package/src/client/hostContext.tsx +39 -34
  34. package/src/client/index.ts +44 -125
  35. package/src/client/pageGlobals.ts +25 -0
  36. package/src/client/persistenceSchemas.ts +41 -0
  37. package/src/client/preact-all.ts +4 -4
  38. package/src/client/runtime.ts +58 -111
  39. package/src/client/styles.css +1 -74
  40. package/src/engine/compiler.ts +411 -323
  41. package/src/engine/file-admission.ts +25 -0
  42. package/src/engine/paths.ts +44 -44
  43. package/src/engine/session.ts +515 -626
  44. package/src/engine/sessionSchemas.ts +71 -0
  45. package/src/engine/watcher.ts +70 -60
  46. package/src/engine/websocket.ts +134 -132
  47. package/annotate.ts +0 -94
  48. package/daemon.ts +0 -46
  49. package/src/client/AnnotationDraftFooter.tsx +0 -76
  50. package/src/client/AnnotationEditor.tsx +0 -320
  51. package/src/client/AnnotationList.tsx +0 -190
  52. package/src/client/Popover.tsx +0 -361
  53. package/src/client/SessionContext.tsx +0 -3
  54. package/src/client/annotationContext.ts +0 -130
  55. package/src/client/annotationMarkdown.ts +0 -102
  56. package/src/client/annotationTarget.ts +0 -73
  57. package/src/client/blockTarget.ts +0 -127
  58. package/src/client/highlightRange.ts +0 -262
  59. package/src/client/popoverPosition.ts +0 -46
  60. package/src/client/regionTarget.ts +0 -180
  61. package/src/client/testing/dom.ts +0 -74
  62. package/src/client/textTarget.ts +0 -31
  63. package/src/client/useRegionAnnotation.tsx +0 -188
  64. package/src/client/useTextAnnotation.tsx +0 -194
  65. package/src/client/utils.ts +0 -33
  66. package/src/engine/channel.ts +0 -278
  67. package/src/engine/http-utils.ts +0 -14
  68. package/src/engine/lifecycle.ts +0 -302
  69. package/src/engine/router.ts +0 -16
  70. package/src/engine/waiter.ts +0 -188
@@ -1,587 +1,643 @@
1
- import React, { useEffect, useState, useRef, useContext, useMemo } from "react";
2
- import { createPortal } from "react-dom";
3
- import { SessionContext, CanvasFileCtx } from "#canvas/runtime";
4
- import { useAnnotations } from "./AnnotationProvider";
5
- import { extractContext } from "./annotationContext";
6
- import { ANNOTATABLE_SELECTOR, BLOCK_SELECTOR, getBlockSnippet } from "./annotationDom";
7
- import { AnnotationCreatePopover, AnnotationEditPopover } from "./Popover";
8
- import { generateAnnotationId } from "./utils";
9
- import { useTextAnnotation } from "./useTextAnnotation";
10
- import { useRegionAnnotation } from "./useRegionAnnotation";
11
- import { useCanvasHost } from "./hostContext";
12
- import { RenderErrorContext, type CanvasRenderError } from "./RenderErrorContext";
1
+ import { CanvasFileCtx, SessionContext } from '#canvas/runtime'
2
+ import {
3
+ ANNOTATABLE_SELECTOR,
4
+ AnnotationCreatePopover,
5
+ AnnotationEditPopover,
6
+ BLOCK_SELECTOR,
7
+ extractContext,
8
+ generateAnnotationId,
9
+ getBlockSnippet,
10
+ useRegionAnnotation,
11
+ useTextAnnotation,
12
+ } from '@fabrika/annotations'
13
+ import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'
14
+ import { createPortal } from 'react-dom'
15
+ import { useAnnotations } from './AnnotationProvider'
16
+ import { useCanvasHost } from './hostContext'
17
+ import { type CanvasRenderError, RenderErrorContext } from './RenderErrorContext'
13
18
 
14
19
  interface PlanRendererProps {
15
- revision: number;
16
- filename: string;
20
+ revision: number
21
+ filename: string
17
22
  }
18
23
 
19
24
  interface CanvasErrorBoundaryProps {
20
- revision: number;
21
- filename: string;
22
- reportError: (error: CanvasRenderError) => void;
23
- children: React.ReactNode;
25
+ revision: number
26
+ filename: string
27
+ reportError: (error: CanvasRenderError) => void
28
+ children: React.ReactNode
24
29
  }
25
30
 
26
31
  interface CanvasErrorBoundaryState {
27
- error: Error | null;
32
+ error: Error | null
28
33
  }
29
34
 
30
35
  class CanvasErrorBoundary extends React.Component<CanvasErrorBoundaryProps, CanvasErrorBoundaryState> {
31
- state: CanvasErrorBoundaryState = { error: null };
32
-
33
- static getDerivedStateFromError(error: Error): CanvasErrorBoundaryState {
34
- return { error };
35
- }
36
-
37
- componentDidCatch(error: Error, info: React.ErrorInfo) {
38
- this.props.reportError({
39
- revision: this.props.revision,
40
- filename: this.props.filename,
41
- message: error.message,
42
- ...(error.stack ? { stack: error.stack } : {}),
43
- ...(info.componentStack ? { componentStack: info.componentStack } : {}),
44
- });
45
- }
46
-
47
- render() {
48
- if (!this.state.error) return this.props.children;
49
- return <CanvasErrorDisplay message={this.state.error.message} />;
50
- }
36
+ override state: CanvasErrorBoundaryState = { error: null }
37
+
38
+ static getDerivedStateFromError(error: Error): CanvasErrorBoundaryState {
39
+ return { error }
40
+ }
41
+
42
+ override componentDidCatch(error: Error, info: React.ErrorInfo) {
43
+ this.props.reportError({
44
+ revision: this.props.revision,
45
+ filename: this.props.filename,
46
+ message: error.message,
47
+ ...(error.stack ? { stack: error.stack } : {}),
48
+ ...(info.componentStack ? { componentStack: info.componentStack } : {}),
49
+ })
50
+ }
51
+
52
+ override render() {
53
+ if (!this.state.error) return this.props.children
54
+ return <CanvasErrorDisplay message={this.state.error.message} />
55
+ }
51
56
  }
52
57
 
53
58
  function CanvasErrorDisplay({ message }: { message: string }) {
54
- return (
55
- <div className="bg-accent-red-muted rounded-lg p-5">
56
- <h3 className="font-body font-semibold text-accent-red mb-2">Canvas render failed</h3>
57
- <pre className="text-code font-mono text-text-secondary whitespace-pre-wrap">{message}</pre>
58
- <p className="text-meta font-body text-text-tertiary mt-3">The error was sent back to the canvas author.</p>
59
- </div>
60
- );
59
+ return (
60
+ <div className="bg-accent-red-muted rounded-lg p-5">
61
+ <h3 className="font-body font-semibold text-accent-red mb-2">Canvas render failed</h3>
62
+ <pre className="text-code font-mono text-text-secondary whitespace-pre-wrap">{message}</pre>
63
+ <p className="text-meta font-body text-text-tertiary mt-3">The error was sent back to the canvas author.</p>
64
+ </div>
65
+ )
61
66
  }
62
67
 
63
68
  export function PlanRenderer({ revision, filename }: PlanRendererProps) {
64
- const sessionId = useContext(SessionContext);
65
- const host = useCanvasHost();
66
- const reportError = useContext(RenderErrorContext);
67
- const [PlanComponent, setPlanComponent] = useState<React.ComponentType | null>(null);
68
- const [error, setError] = useState<string | null>(null);
69
- const [loading, setLoading] = useState(true);
70
- const containerRef = useRef<HTMLDivElement>(null);
71
- const { annotations: allAnnotations, addAnnotationWithId, removeAnnotation, updateAnnotation, addAnnotationImage, removeAnnotationImage, activeAnnotationId, setActiveAnnotationId, registerCanvasRendered } = useAnnotations();
72
- // Filter annotations to this canvas file
73
- const annotations = useMemo(() => allAnnotations.filter(a => !a.filePath && (!a.canvasFile || a.canvasFile === filename)), [allAnnotations, filename]);
74
- const [editingAnn, setEditingAnn] = useState<{ id: string; note: string } | null>(null);
75
-
76
- // Block annotation hover state
77
- const [hoveredBlock, setHoveredBlock] = useState<HTMLElement | null>(null);
78
- const [blockPopover, setBlockPopover] = useState<{ anchorEl: HTMLElement; snippet: string; annId?: string } | null>(null);
79
-
80
- // Keyboard navigation state
81
- const [focusedBlockIndex, setFocusedBlockIndex] = useState<number | null>(null);
82
- const [keyboardNav, setKeyboardNav] = useState(false);
83
-
84
- useEffect(() => {
85
- setLoading(true);
86
- setError(null);
87
- setFocusedBlockIndex(null);
88
- void sessionId; // the host resolves the session from the URL (shared or local)
89
- host.loadCanvasModule(filename, revision)
90
- .then((mod) => { setPlanComponent(() => mod.default); setLoading(false); })
91
- .catch((cause: unknown) => {
92
- const moduleError = cause instanceof Error ? cause : new Error(String(cause));
93
- setError(moduleError.message);
94
- setLoading(false);
95
- reportError({
96
- revision,
97
- filename,
98
- message: moduleError.message,
99
- ...(moduleError.stack ? { stack: moduleError.stack } : {}),
100
- });
101
- });
102
- }, [sessionId, revision, filename, reportError]);
103
-
104
- // Report the canvas only once its tree is committed — the response controls
105
- // inside register from their own effects, which React runs before this one.
106
- useEffect(() => {
107
- if (PlanComponent) registerCanvasRendered(filename);
108
- }, [PlanComponent, filename, registerCanvasRendered]);
109
-
110
- const scrollContainer = document.getElementById("plan-scroll-container");
111
-
112
- const { popovers: textAnnotationPopovers, isPopoverOpen: textPopoverOpen } = useTextAnnotation({
113
- containerRef,
114
- restoreKey: PlanComponent,
115
- restoreAnnotations: annotations,
116
- extractContext: (range) => extractContext(range, containerRef.current!),
117
- canvasFile: filename,
118
- scrollContainer,
119
- });
120
-
121
- const { popovers: regionPopovers, isDrawing, isPopoverOpen: regionPopoverOpen } = useRegionAnnotation({
122
- containerRef,
123
- restoreKey: PlanComponent,
124
- canvasFile: filename,
125
- scrollContainer,
126
- });
127
-
128
- // Text selection and region drawing both raise a popover; the block layer
129
- // stands down for either.
130
- const annotationPopoverOpen = textPopoverOpen || regionPopoverOpen;
131
-
132
- // Build a map of block snippets to annotation IDs for quick lookup
133
- const blockAnnotationMap = useMemo(() => {
134
- const map = new Map<string, string>();
135
- for (const ann of annotations) {
136
- if (ann.snippet.startsWith("[")) {
137
- map.set(ann.snippet, ann.id);
138
- }
139
- }
140
- return map;
141
- }, [annotations]);
142
-
143
- // Block annotation: hover detection
144
- useEffect(() => {
145
- const container = containerRef.current;
146
- if (!container) return;
147
-
148
- const handleMove = (e: MouseEvent) => {
149
- // Don't change hover when a popover is open or a region is being drawn
150
- if (blockPopover || annotationPopoverOpen || isDrawing) return;
151
- const target = e.target as HTMLElement;
152
- // Ignore events on the block comment button itself
153
- if (target.closest("[data-block-comment-btn]")) return;
154
- setKeyboardNav(false);
155
- // Track keyboard nav position on any navigable block
156
- const navBlock = target.closest(BLOCK_SELECTOR) as HTMLElement | null;
157
- if (navBlock && container.contains(navBlock)) {
158
- const blocks = getOrderedBlocks(container);
159
- const idx = blocks.indexOf(navBlock);
160
- setFocusedBlockIndex(idx >= 0 ? idx : null);
161
- } else {
162
- setFocusedBlockIndex(null);
163
- }
164
- // Only show comment icons on annotatable blocks
165
- const block = target.closest(ANNOTATABLE_SELECTOR) as HTMLElement | null;
166
- if (block && container.contains(block)) {
167
- setHoveredBlock(block);
168
- const snippet = getBlockSnippet(block);
169
- const annId = snippet ? blockAnnotationMap.get(snippet) : undefined;
170
- if (annId && annId !== activeAnnotationId) {
171
- setActiveAnnotationId(annId);
172
- }
173
- } else {
174
- setHoveredBlock(null);
175
- }
176
- };
177
-
178
- const handleLeave = (e: MouseEvent) => {
179
- if (blockPopover || annotationPopoverOpen) return;
180
- // Don't clear if moving onto the block comment button
181
- const related = e.relatedTarget as HTMLElement | null;
182
- if (related?.closest("[data-block-comment-btn]")) return;
183
- setHoveredBlock(null);
184
- setActiveAnnotationId(null);
185
- };
186
-
187
- container.addEventListener("mousemove", handleMove);
188
- container.addEventListener("mouseleave", handleLeave);
189
- return () => {
190
- container.removeEventListener("mousemove", handleMove);
191
- container.removeEventListener("mouseleave", handleLeave);
192
- };
193
- }, [PlanComponent, blockPopover, annotationPopoverOpen, isDrawing, blockAnnotationMap, activeAnnotationId]);
194
-
195
- // Keyboard navigation: document-level key listener
196
- useEffect(() => {
197
- const handleKeyDown = (e: KeyboardEvent) => {
198
- // Guard: skip if input/textarea focused or popover open
199
- const tag = (e.target as HTMLElement).tagName;
200
- if (tag === "INPUT" || tag === "TEXTAREA" || (e.target as HTMLElement).isContentEditable) return;
201
- if (blockPopover || annotationPopoverOpen) return;
202
-
203
- const container = containerRef.current;
204
- if (!container) return;
205
-
206
- if (e.key === "ArrowDown") {
207
- e.preventDefault();
208
- setKeyboardNav(true);
209
- const blocks = getOrderedBlocks(container);
210
- if (blocks.length === 0) return;
211
- setFocusedBlockIndex((prev) => {
212
- if (prev === null) return 0;
213
- return Math.min(prev + 1, blocks.length - 1);
214
- });
215
- } else if (e.key === "ArrowUp") {
216
- e.preventDefault();
217
- setKeyboardNav(true);
218
- const blocks = getOrderedBlocks(container);
219
- if (blocks.length === 0) return;
220
- setFocusedBlockIndex((prev) => {
221
- if (prev === null) return blocks.length - 1;
222
- return Math.max(prev - 1, 0);
223
- });
224
- } else if (e.key === "a" || e.key === "A") {
225
- if (focusedBlockIndex === null || !container) return;
226
- const blocks = getOrderedBlocks(container);
227
- const block = blocks[focusedBlockIndex];
228
- if (!block) return;
229
- // The arrows walk every block, but only an annotatable one can be found
230
- // again — minting a snippet for anything else strands the annotation.
231
- if (!block.matches(ANNOTATABLE_SELECTOR)) return;
232
- e.preventDefault();
233
- const snippet = getBlockSnippet(block);
234
- if (!snippet) return;
235
- const existingAnnId = blockAnnotationMap.get(snippet);
236
- if (existingAnnId) {
237
- setActiveAnnotationId(existingAnnId);
238
- setBlockPopover({ anchorEl: block, snippet, annId: existingAnnId });
239
- } else {
240
- setBlockPopover({ anchorEl: block, snippet });
241
- }
242
- } else if (e.key === " " || e.key === "Enter") {
243
- if (focusedBlockIndex === null) return;
244
- const blocks = getOrderedBlocks(container);
245
- const block = blocks[focusedBlockIndex];
246
- if (!block) return;
247
- const md = block.getAttribute("data-md");
248
- if (md === "choice-option" || md === "multichoice-option") {
249
- e.preventDefault();
250
- block.dispatchEvent(new CustomEvent("kb-select", { bubbles: true }));
251
- } else if (md === "userinput") {
252
- e.preventDefault();
253
- const textarea = block.querySelector("textarea");
254
- if (textarea) {
255
- textarea.focus();
256
- const exitHandler = (te: KeyboardEvent) => {
257
- if (te.key === "Escape") {
258
- te.preventDefault();
259
- textarea.blur();
260
- setKeyboardNav(true);
261
- }
262
- };
263
- textarea.addEventListener("keydown", exitHandler);
264
- textarea.addEventListener("blur", () => textarea.removeEventListener("keydown", exitHandler), { once: true });
265
- }
266
- } else if (md === "rangeinput") {
267
- e.preventDefault();
268
- const input = block.querySelector("input[type='range']") as HTMLInputElement | null;
269
- if (input) {
270
- input.focus();
271
- const exitHandler = (re: KeyboardEvent) => {
272
- if (re.key === "Escape" || re.key === "ArrowUp" || re.key === "ArrowDown") {
273
- re.preventDefault();
274
- input.blur();
275
- setKeyboardNav(true);
276
- }
277
- };
278
- input.addEventListener("keydown", exitHandler);
279
- input.addEventListener("blur", () => input.removeEventListener("keydown", exitHandler), { once: true });
280
- }
281
- }
282
- } else if (e.key === "Escape") {
283
- setFocusedBlockIndex(null);
284
- setKeyboardNav(false);
285
- setActiveAnnotationId(null);
286
- }
287
- };
288
-
289
- document.addEventListener("keydown", handleKeyDown);
290
- return () => document.removeEventListener("keydown", handleKeyDown);
291
- }, [focusedBlockIndex, blockPopover, annotationPopoverOpen, blockAnnotationMap]);
292
-
293
- // Keyboard navigation: visual focus outline + scroll into view + sidebar sync
294
- useEffect(() => {
295
- const container = containerRef.current;
296
- if (!container || focusedBlockIndex === null || !keyboardNav) return;
297
- const blocks = getOrderedBlocks(container);
298
- const block = blocks[focusedBlockIndex];
299
- if (!block) return;
300
-
301
- block.style.outline = "2px solid var(--color-accent-blue)";
302
- block.style.outlineOffset = "-2px";
303
- block.style.borderRadius = "8px";
304
- block.scrollIntoView({ block: "nearest", behavior: "smooth" });
305
-
306
- // Sync sidebar if block has an annotation
307
- const snippet = getBlockSnippet(block);
308
- const annId = snippet ? blockAnnotationMap.get(snippet) : undefined;
309
- if (annId) setActiveAnnotationId(annId);
310
-
311
- return () => {
312
- block.style.outline = "";
313
- block.style.outlineOffset = "";
314
- block.style.borderRadius = "";
315
- };
316
- }, [focusedBlockIndex, keyboardNav, blockAnnotationMap]);
317
-
318
- // Memoize so context-triggered re-renders of PlanRenderer don't re-render the plan tree.
319
- // This prevents remounting custom components defined inside the Plan function.
320
- const planElement = useMemo(() => PlanComponent ? <PlanComponent /> : null, [PlanComponent]);
321
-
322
- if (loading) {
323
- return <div className="flex items-center justify-center h-64 text-text-tertiary font-body text-body">Loading canvas...</div>;
324
- }
325
-
326
- if (error) {
327
- return <CanvasErrorDisplay message={error} />;
328
- }
329
-
330
- if (!PlanComponent) {
331
- return <div className="text-text-tertiary text-center py-8 font-body text-body">No canvas loaded</div>;
332
- }
333
-
334
- return (
335
- <CanvasFileCtx.Provider value={filename}>
336
- {/* Names the canvas a block annotation belongs to, so looking one up
337
- cannot wander into another canvas mounted beside it. */}
338
- <div ref={containerRef} className="plan-content plan-updated" data-canvas-file={filename}>
339
- <CanvasErrorBoundary
340
- key={`${revision}:${filename}`}
341
- revision={revision}
342
- filename={filename}
343
- reportError={reportError}
344
- >
345
- {planElement}
346
- </CanvasErrorBoundary>
347
- </div>
348
- {editingAnn && (
349
- <EditAnnotationModal
350
- note={editingAnn.note}
351
- onSave={(note) => { updateAnnotation(editingAnn.id, note); setEditingAnn(null); }}
352
- onCancel={() => setEditingAnn(null)}
353
- />
354
- )}
355
-
356
- {textAnnotationPopovers}
357
- {regionPopovers}
358
-
359
- {/* Block annotation floating buttons */}
360
- <BlockCommentButtons
361
- containerRef={containerRef}
362
- hoveredBlock={hoveredBlock}
363
- focusedBlockIndex={focusedBlockIndex}
364
- blockPopover={blockPopover}
365
- blockAnnotationMap={blockAnnotationMap}
366
- activeAnnotationId={activeAnnotationId}
367
- onOpen={(anchorEl, snippet, annId) => {
368
- if (annId) {
369
- setActiveAnnotationId(annId);
370
- setBlockPopover({ anchorEl, snippet, annId });
371
- } else {
372
- setBlockPopover({ anchorEl, snippet });
373
- }
374
- }}
375
- />
376
-
377
- {/* Block annotation popover */}
378
- {blockPopover && !blockPopover.annId && (
379
- <AnnotationCreatePopover
380
- anchorEl={blockPopover.anchorEl}
381
- scrollContainer={scrollContainer}
382
- snippet={blockPopover.snippet}
383
- truncateAt={80}
384
- onAdd={(note, images) => {
385
- const id = generateAnnotationId();
386
- addAnnotationWithId(id, blockPopover.snippet, note, undefined, undefined, images, filename);
387
- setBlockPopover(null);
388
- }}
389
- onCancel={() => setBlockPopover(null)}
390
- />
391
- )}
392
- {blockPopover?.annId && (() => {
393
- const ann = annotations.find((a) => a.id === blockPopover.annId);
394
- if (!ann) return null;
395
- return (
396
- <AnnotationEditPopover
397
- anchorEl={blockPopover.anchorEl}
398
- scrollContainer={scrollContainer}
399
- initialNote={ann.note}
400
- initialImages={ann.images}
401
- onUpdate={(note, images) => {
402
- updateAnnotation(blockPopover.annId!, note);
403
- const current = ann.images || [];
404
- for (const img of images) {
405
- if (!current.includes(img)) addAnnotationImage(blockPopover.annId!, img);
406
- }
407
- for (const img of current) {
408
- if (!images.includes(img)) removeAnnotationImage(blockPopover.annId!, img);
409
- }
410
- }}
411
- onDelete={() => { removeAnnotation(blockPopover.annId!); setActiveAnnotationId(null); setBlockPopover(null); }}
412
- onClose={() => setBlockPopover(null)}
413
- />
414
- );
415
- })()}
416
- </CanvasFileCtx.Provider>
417
- );
69
+ const sessionId = useContext(SessionContext)
70
+ const host = useCanvasHost()
71
+ const reportError = useContext(RenderErrorContext)
72
+ const [PlanComponent, setPlanComponent] = useState<React.ComponentType | null>(null)
73
+ const [error, setError] = useState<string | null>(null)
74
+ const [loading, setLoading] = useState(true)
75
+ const containerRef = useRef<HTMLDivElement>(null)
76
+ const {
77
+ annotations: allAnnotations,
78
+ addAnnotationWithId,
79
+ removeAnnotation,
80
+ updateAnnotation,
81
+ addAnnotationImage,
82
+ removeAnnotationImage,
83
+ activeAnnotationId,
84
+ setActiveAnnotationId,
85
+ registerCanvasRendered,
86
+ } = useAnnotations()
87
+ // Filter annotations to this canvas file
88
+ const annotations = useMemo(() => allAnnotations.filter(a => !a.filePath && (!a.canvasFile || a.canvasFile === filename)), [
89
+ allAnnotations,
90
+ filename,
91
+ ])
92
+ const [editingAnn, setEditingAnn] = useState<{ id: string; note: string } | null>(null)
93
+
94
+ // Block annotation hover state
95
+ const [hoveredBlock, setHoveredBlock] = useState<HTMLElement | null>(null)
96
+ const [blockPopover, setBlockPopover] = useState<{ anchorEl: HTMLElement; snippet: string; annId?: string } | null>(null)
97
+
98
+ // Keyboard navigation state
99
+ const [focusedBlockIndex, setFocusedBlockIndex] = useState<number | null>(null)
100
+ const [keyboardNav, setKeyboardNav] = useState(false)
101
+
102
+ useEffect(() => {
103
+ setLoading(true)
104
+ setError(null)
105
+ setFocusedBlockIndex(null)
106
+ void sessionId // the host resolves the session from the URL (shared or local)
107
+ host.loadCanvasModule(filename, revision)
108
+ .then((mod) => {
109
+ setPlanComponent(() => mod.default)
110
+ setLoading(false)
111
+ })
112
+ .catch((cause: unknown) => {
113
+ const moduleError = cause instanceof Error ? cause : new Error(String(cause))
114
+ setError(moduleError.message)
115
+ setLoading(false)
116
+ reportError({
117
+ revision,
118
+ filename,
119
+ message: moduleError.message,
120
+ ...(moduleError.stack ? { stack: moduleError.stack } : {}),
121
+ })
122
+ })
123
+ }, [sessionId, revision, filename, reportError])
124
+
125
+ // Report the canvas only once its tree is committed — the response controls
126
+ // inside register from their own effects, which React runs before this one.
127
+ useEffect(() => {
128
+ if (PlanComponent) registerCanvasRendered(filename)
129
+ }, [PlanComponent, filename, registerCanvasRendered])
130
+
131
+ const scrollContainer = document.getElementById('plan-scroll-container')
132
+
133
+ const { popovers: textAnnotationPopovers, isPopoverOpen: textPopoverOpen } = useTextAnnotation({
134
+ containerRef,
135
+ restoreKey: PlanComponent,
136
+ restoreAnnotations: annotations,
137
+ extractContext: (range) => extractContext(range, containerRef.current!),
138
+ canvasFile: filename,
139
+ scrollContainer,
140
+ })
141
+
142
+ const { popovers: regionPopovers, isDrawing, isPopoverOpen: regionPopoverOpen } = useRegionAnnotation({
143
+ containerRef,
144
+ restoreKey: PlanComponent,
145
+ canvasFile: filename,
146
+ scrollContainer,
147
+ })
148
+
149
+ // Text selection and region drawing both raise a popover; the block layer
150
+ // stands down for either.
151
+ const annotationPopoverOpen = textPopoverOpen || regionPopoverOpen
152
+
153
+ // Build a map of block snippets to annotation IDs for quick lookup
154
+ const blockAnnotationMap = useMemo(() => {
155
+ const map = new Map<string, string>()
156
+ for (const ann of annotations) {
157
+ if (ann.snippet.startsWith('[')) {
158
+ map.set(ann.snippet, ann.id)
159
+ }
160
+ }
161
+ return map
162
+ }, [annotations])
163
+
164
+ // Block annotation: hover detection
165
+ useEffect(() => {
166
+ const container = containerRef.current
167
+ if (!container) return
168
+
169
+ const handleMove = (e: MouseEvent) => {
170
+ // Don't change hover when a popover is open or a region is being drawn
171
+ if (blockPopover || annotationPopoverOpen || isDrawing) return
172
+ const target = e.target as HTMLElement
173
+ // Ignore events on the block comment button itself
174
+ if (target.closest('[data-block-comment-btn]')) return
175
+ setKeyboardNav(false)
176
+ // Track keyboard nav position on any navigable block
177
+ const navBlock = target.closest(BLOCK_SELECTOR) as HTMLElement | null
178
+ if (navBlock && container.contains(navBlock)) {
179
+ const blocks = getOrderedBlocks(container)
180
+ const idx = blocks.indexOf(navBlock)
181
+ setFocusedBlockIndex(idx >= 0 ? idx : null)
182
+ } else {
183
+ setFocusedBlockIndex(null)
184
+ }
185
+ // Only show comment icons on annotatable blocks
186
+ const block = target.closest(ANNOTATABLE_SELECTOR) as HTMLElement | null
187
+ if (block && container.contains(block)) {
188
+ setHoveredBlock(block)
189
+ const snippet = getBlockSnippet(block)
190
+ const annId = snippet ? blockAnnotationMap.get(snippet) : undefined
191
+ if (annId && annId !== activeAnnotationId) {
192
+ setActiveAnnotationId(annId)
193
+ }
194
+ } else {
195
+ setHoveredBlock(null)
196
+ }
197
+ }
198
+
199
+ const handleLeave = (e: MouseEvent) => {
200
+ if (blockPopover || annotationPopoverOpen) return
201
+ // Don't clear if moving onto the block comment button
202
+ const related = e.relatedTarget as HTMLElement | null
203
+ if (related?.closest('[data-block-comment-btn]')) return
204
+ setHoveredBlock(null)
205
+ setActiveAnnotationId(null)
206
+ }
207
+
208
+ container.addEventListener('mousemove', handleMove)
209
+ container.addEventListener('mouseleave', handleLeave)
210
+ return () => {
211
+ container.removeEventListener('mousemove', handleMove)
212
+ container.removeEventListener('mouseleave', handleLeave)
213
+ }
214
+ }, [PlanComponent, blockPopover, annotationPopoverOpen, isDrawing, blockAnnotationMap, activeAnnotationId])
215
+
216
+ // Keyboard navigation: document-level key listener
217
+ useEffect(() => {
218
+ const handleKeyDown = (e: KeyboardEvent) => {
219
+ // Guard: skip if input/textarea focused or popover open
220
+ const tag = (e.target as HTMLElement).tagName
221
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement).isContentEditable) return
222
+ if (blockPopover || annotationPopoverOpen) return
223
+
224
+ const container = containerRef.current
225
+ if (!container) return
226
+
227
+ if (e.key === 'ArrowDown') {
228
+ e.preventDefault()
229
+ setKeyboardNav(true)
230
+ const blocks = getOrderedBlocks(container)
231
+ if (blocks.length === 0) return
232
+ setFocusedBlockIndex((prev) => {
233
+ if (prev === null) return 0
234
+ return Math.min(prev + 1, blocks.length - 1)
235
+ })
236
+ } else if (e.key === 'ArrowUp') {
237
+ e.preventDefault()
238
+ setKeyboardNav(true)
239
+ const blocks = getOrderedBlocks(container)
240
+ if (blocks.length === 0) return
241
+ setFocusedBlockIndex((prev) => {
242
+ if (prev === null) return blocks.length - 1
243
+ return Math.max(prev - 1, 0)
244
+ })
245
+ } else if (e.key === 'a' || e.key === 'A') {
246
+ if (focusedBlockIndex === null || !container) return
247
+ const blocks = getOrderedBlocks(container)
248
+ const block = blocks[focusedBlockIndex]
249
+ if (!block) return
250
+ // The arrows walk every block, but only an annotatable one can be found
251
+ // again — minting a snippet for anything else strands the annotation.
252
+ if (!block.matches(ANNOTATABLE_SELECTOR)) return
253
+ e.preventDefault()
254
+ const snippet = getBlockSnippet(block)
255
+ if (!snippet) return
256
+ const existingAnnId = blockAnnotationMap.get(snippet)
257
+ if (existingAnnId) {
258
+ setActiveAnnotationId(existingAnnId)
259
+ setBlockPopover({ anchorEl: block, snippet, annId: existingAnnId })
260
+ } else {
261
+ setBlockPopover({ anchorEl: block, snippet })
262
+ }
263
+ } else if (e.key === ' ' || e.key === 'Enter') {
264
+ if (focusedBlockIndex === null) return
265
+ const blocks = getOrderedBlocks(container)
266
+ const block = blocks[focusedBlockIndex]
267
+ if (!block) return
268
+ const md = block.getAttribute('data-md')
269
+ if (md === 'choice-option' || md === 'multichoice-option') {
270
+ e.preventDefault()
271
+ block.dispatchEvent(new CustomEvent('kb-select', { bubbles: true }))
272
+ } else if (md === 'userinput') {
273
+ e.preventDefault()
274
+ const textarea = block.querySelector('textarea')
275
+ if (textarea) {
276
+ textarea.focus()
277
+ const exitHandler = (te: KeyboardEvent) => {
278
+ if (te.key === 'Escape') {
279
+ te.preventDefault()
280
+ textarea.blur()
281
+ setKeyboardNav(true)
282
+ }
283
+ }
284
+ textarea.addEventListener('keydown', exitHandler)
285
+ textarea.addEventListener('blur', () => textarea.removeEventListener('keydown', exitHandler), { once: true })
286
+ }
287
+ } else if (md === 'rangeinput') {
288
+ e.preventDefault()
289
+ const input = block.querySelector("input[type='range']") as HTMLInputElement | null
290
+ if (input) {
291
+ input.focus()
292
+ const exitHandler = (re: KeyboardEvent) => {
293
+ if (re.key === 'Escape' || re.key === 'ArrowUp' || re.key === 'ArrowDown') {
294
+ re.preventDefault()
295
+ input.blur()
296
+ setKeyboardNav(true)
297
+ }
298
+ }
299
+ input.addEventListener('keydown', exitHandler)
300
+ input.addEventListener('blur', () => input.removeEventListener('keydown', exitHandler), { once: true })
301
+ }
302
+ }
303
+ } else if (e.key === 'Escape') {
304
+ setFocusedBlockIndex(null)
305
+ setKeyboardNav(false)
306
+ setActiveAnnotationId(null)
307
+ }
308
+ }
309
+
310
+ document.addEventListener('keydown', handleKeyDown)
311
+ return () => document.removeEventListener('keydown', handleKeyDown)
312
+ }, [focusedBlockIndex, blockPopover, annotationPopoverOpen, blockAnnotationMap])
313
+
314
+ // Keyboard navigation: visual focus outline + scroll into view + sidebar sync
315
+ useEffect(() => {
316
+ const container = containerRef.current
317
+ if (!container || focusedBlockIndex === null || !keyboardNav) return
318
+ const blocks = getOrderedBlocks(container)
319
+ const block = blocks[focusedBlockIndex]
320
+ if (!block) return
321
+
322
+ block.style.outline = '2px solid var(--color-accent-blue)'
323
+ block.style.outlineOffset = '-2px'
324
+ block.style.borderRadius = '8px'
325
+ block.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
326
+
327
+ // Sync sidebar if block has an annotation
328
+ const snippet = getBlockSnippet(block)
329
+ const annId = snippet ? blockAnnotationMap.get(snippet) : undefined
330
+ if (annId) setActiveAnnotationId(annId)
331
+
332
+ return () => {
333
+ block.style.outline = ''
334
+ block.style.outlineOffset = ''
335
+ block.style.borderRadius = ''
336
+ }
337
+ }, [focusedBlockIndex, keyboardNav, blockAnnotationMap])
338
+
339
+ // Memoize so context-triggered re-renders of PlanRenderer don't re-render the plan tree.
340
+ // This prevents remounting custom components defined inside the Plan function.
341
+ const planElement = useMemo(() => PlanComponent ? <PlanComponent /> : null, [PlanComponent])
342
+
343
+ if (loading) {
344
+ return <div className="flex items-center justify-center h-64 text-text-tertiary font-body text-body">Loading canvas...</div>
345
+ }
346
+
347
+ if (error) {
348
+ return <CanvasErrorDisplay message={error} />
349
+ }
350
+
351
+ if (!PlanComponent) {
352
+ return <div className="text-text-tertiary text-center py-8 font-body text-body">No canvas loaded</div>
353
+ }
354
+
355
+ return (
356
+ <CanvasFileCtx.Provider value={filename}>
357
+ {
358
+ /* Names the canvas a block annotation belongs to, so looking one up
359
+ cannot wander into another canvas mounted beside it. */
360
+ }
361
+ <div ref={containerRef} className="plan-content plan-updated" data-canvas-file={filename}>
362
+ <CanvasErrorBoundary
363
+ key={`${revision}:${filename}`}
364
+ revision={revision}
365
+ filename={filename}
366
+ reportError={reportError}
367
+ >
368
+ {planElement}
369
+ </CanvasErrorBoundary>
370
+ </div>
371
+ {editingAnn && (
372
+ <EditAnnotationModal
373
+ note={editingAnn.note}
374
+ onSave={(note) => {
375
+ updateAnnotation(editingAnn.id, note)
376
+ setEditingAnn(null)
377
+ }}
378
+ onCancel={() => setEditingAnn(null)}
379
+ />
380
+ )}
381
+
382
+ {textAnnotationPopovers}
383
+ {regionPopovers}
384
+
385
+ {/* Block annotation floating buttons */}
386
+ <BlockCommentButtons
387
+ containerRef={containerRef}
388
+ hoveredBlock={hoveredBlock}
389
+ focusedBlockIndex={focusedBlockIndex}
390
+ blockPopover={blockPopover}
391
+ blockAnnotationMap={blockAnnotationMap}
392
+ activeAnnotationId={activeAnnotationId}
393
+ onOpen={(anchorEl, snippet, annId) => {
394
+ if (annId) {
395
+ setActiveAnnotationId(annId)
396
+ setBlockPopover({ anchorEl, snippet, annId })
397
+ } else {
398
+ setBlockPopover({ anchorEl, snippet })
399
+ }
400
+ }}
401
+ />
402
+
403
+ {/* Block annotation popover */}
404
+ {blockPopover && !blockPopover.annId && (
405
+ <AnnotationCreatePopover
406
+ anchorEl={blockPopover.anchorEl}
407
+ scrollContainer={scrollContainer}
408
+ snippet={blockPopover.snippet}
409
+ truncateAt={80}
410
+ onAdd={(note, images) => {
411
+ const id = generateAnnotationId()
412
+ addAnnotationWithId(id, blockPopover.snippet, note, undefined, undefined, images, filename)
413
+ setBlockPopover(null)
414
+ }}
415
+ onCancel={() => setBlockPopover(null)}
416
+ />
417
+ )}
418
+ {blockPopover?.annId && (() => {
419
+ const ann = annotations.find((a) => a.id === blockPopover.annId)
420
+ if (!ann) return null
421
+ return (
422
+ <AnnotationEditPopover
423
+ anchorEl={blockPopover.anchorEl}
424
+ scrollContainer={scrollContainer}
425
+ initialNote={ann.note}
426
+ initialImages={ann.images}
427
+ onUpdate={(note, images) => {
428
+ updateAnnotation(blockPopover.annId!, note)
429
+ const current = ann.images || []
430
+ for (const img of images) {
431
+ if (!current.includes(img)) addAnnotationImage(blockPopover.annId!, img)
432
+ }
433
+ for (const img of current) {
434
+ if (!images.includes(img)) removeAnnotationImage(blockPopover.annId!, img)
435
+ }
436
+ }}
437
+ onDelete={() => {
438
+ removeAnnotation(blockPopover.annId!)
439
+ setActiveAnnotationId(null)
440
+ setBlockPopover(null)
441
+ }}
442
+ onClose={() => setBlockPopover(null)}
443
+ />
444
+ )
445
+ })()}
446
+ </CanvasFileCtx.Provider>
447
+ )
418
448
  }
419
449
 
420
450
  /** Get all navigable blocks in DOM order */
421
451
  function getOrderedBlocks(container: HTMLElement): HTMLElement[] {
422
- return Array.from(container.querySelectorAll(BLOCK_SELECTOR)) as HTMLElement[];
452
+ return Array.from(container.querySelectorAll(BLOCK_SELECTOR)) as HTMLElement[]
423
453
  }
424
454
 
425
455
  /** Renders comment icons: always visible on annotated blocks, on hover for others */
426
456
  function BlockCommentButtons({
427
- containerRef,
428
- hoveredBlock,
429
- focusedBlockIndex,
430
- blockPopover,
431
- blockAnnotationMap,
432
- activeAnnotationId,
433
- onOpen,
457
+ containerRef,
458
+ hoveredBlock,
459
+ focusedBlockIndex,
460
+ blockPopover,
461
+ blockAnnotationMap,
462
+ activeAnnotationId,
463
+ onOpen,
434
464
  }: {
435
- containerRef: React.RefObject<HTMLDivElement | null>;
436
- hoveredBlock: HTMLElement | null;
437
- focusedBlockIndex: number | null;
438
- blockPopover: { anchorEl: HTMLElement; snippet: string; annId?: string } | null;
439
- blockAnnotationMap: Map<string, string>;
440
- activeAnnotationId: string | null;
441
- onOpen: (anchorEl: HTMLElement, snippet: string, annId?: string) => void;
465
+ containerRef: React.RefObject<HTMLDivElement | null>
466
+ hoveredBlock: HTMLElement | null
467
+ focusedBlockIndex: number | null
468
+ blockPopover: { anchorEl: HTMLElement; snippet: string; annId?: string } | null
469
+ blockAnnotationMap: Map<string, string>
470
+ activeAnnotationId: string | null
471
+ onOpen: (anchorEl: HTMLElement, snippet: string, annId?: string) => void
442
472
  }) {
443
- // Find all blocks that have annotations
444
- const [annotatedBlocks, setAnnotatedBlocks] = useState<HTMLElement[]>([]);
445
-
446
- useEffect(() => {
447
- const container = containerRef.current;
448
- if (!container || blockAnnotationMap.size === 0) { setAnnotatedBlocks([]); return; }
449
- const blocks: HTMLElement[] = [];
450
- for (const el of container.querySelectorAll(ANNOTATABLE_SELECTOR)) {
451
- const snippet = getBlockSnippet(el as HTMLElement);
452
- if (snippet && blockAnnotationMap.has(snippet)) {
453
- blocks.push(el as HTMLElement);
454
- }
455
- }
456
- setAnnotatedBlocks(blocks);
457
- }, [blockAnnotationMap, containerRef.current]);
458
-
459
- // Also highlight block when its annotation is active from sidebar hover
460
- const [activeBlock, setActiveBlock] = useState<HTMLElement | null>(null);
461
- useEffect(() => {
462
- if (!activeAnnotationId || !containerRef.current) { setActiveBlock(null); return; }
463
- // Find block matching active annotation
464
- for (const el of containerRef.current.querySelectorAll(BLOCK_SELECTOR)) {
465
- const snippet = getBlockSnippet(el as HTMLElement);
466
- if (snippet && blockAnnotationMap.get(snippet) === activeAnnotationId) {
467
- setActiveBlock(el as HTMLElement);
468
- return;
469
- }
470
- }
471
- setActiveBlock(null);
472
- }, [activeAnnotationId, blockAnnotationMap]);
473
-
474
- // Apply highlight style to active block
475
- useEffect(() => {
476
- if (!activeBlock) return;
477
- activeBlock.style.boxShadow = "inset 0 0 0 1.5px var(--color-highlight-bg)";
478
- activeBlock.style.borderRadius = "8px";
479
- return () => {
480
- activeBlock.style.boxShadow = "";
481
- activeBlock.style.borderRadius = "";
482
- };
483
- }, [activeBlock]);
484
-
485
- // Resolve keyboard-focused block element (only annotatable blocks get comment icons)
486
- const focusedBlock = useMemo(() => {
487
- if (focusedBlockIndex === null || !containerRef.current) return null;
488
- const block = getOrderedBlocks(containerRef.current)[focusedBlockIndex];
489
- if (!block) return null;
490
- // Only show comment icon on annotatable blocks, not interactive controls
491
- return block.matches(ANNOTATABLE_SELECTOR) ? block : null;
492
- }, [focusedBlockIndex, containerRef.current]);
493
-
494
- // Collect all blocks that need a button: annotated + hovered + focused + popover target
495
- const targets = useMemo(() => {
496
- const set = new Set<HTMLElement>(annotatedBlocks);
497
- if (hoveredBlock) set.add(hoveredBlock);
498
- if (focusedBlock) set.add(focusedBlock);
499
- if (blockPopover?.anchorEl) set.add(blockPopover.anchorEl);
500
- return Array.from(set);
501
- }, [annotatedBlocks, hoveredBlock, focusedBlock, blockPopover]);
502
-
503
- return (
504
- <>
505
- {targets.map((block) => (
506
- <BlockCommentIcon
507
- key={block.getAttribute("data-task-id") || block.getAttribute("data-md-title") || block.textContent?.slice(0, 30) || "block"}
508
- block={block}
509
- isHovered={block === hoveredBlock || block === focusedBlock || block === blockPopover?.anchorEl}
510
- blockAnnotationMap={blockAnnotationMap}
511
- activeAnnotationId={activeAnnotationId}
512
- onOpen={onOpen}
513
- />
514
- ))}
515
- </>
516
- );
473
+ // Find all blocks that have annotations
474
+ const [annotatedBlocks, setAnnotatedBlocks] = useState<HTMLElement[]>([])
475
+
476
+ useEffect(() => {
477
+ const container = containerRef.current
478
+ if (!container || blockAnnotationMap.size === 0) {
479
+ setAnnotatedBlocks([])
480
+ return
481
+ }
482
+ const blocks: HTMLElement[] = []
483
+ for (const el of container.querySelectorAll(ANNOTATABLE_SELECTOR)) {
484
+ const snippet = getBlockSnippet(el as HTMLElement)
485
+ if (snippet && blockAnnotationMap.has(snippet)) {
486
+ blocks.push(el as HTMLElement)
487
+ }
488
+ }
489
+ setAnnotatedBlocks(blocks)
490
+ }, [blockAnnotationMap, containerRef.current])
491
+
492
+ // Also highlight block when its annotation is active from sidebar hover
493
+ const [activeBlock, setActiveBlock] = useState<HTMLElement | null>(null)
494
+ useEffect(() => {
495
+ if (!activeAnnotationId || !containerRef.current) {
496
+ setActiveBlock(null)
497
+ return
498
+ }
499
+ // Find block matching active annotation
500
+ for (const el of containerRef.current.querySelectorAll(BLOCK_SELECTOR)) {
501
+ const snippet = getBlockSnippet(el as HTMLElement)
502
+ if (snippet && blockAnnotationMap.get(snippet) === activeAnnotationId) {
503
+ setActiveBlock(el as HTMLElement)
504
+ return
505
+ }
506
+ }
507
+ setActiveBlock(null)
508
+ }, [activeAnnotationId, blockAnnotationMap])
509
+
510
+ // Apply highlight style to active block
511
+ useEffect(() => {
512
+ if (!activeBlock) return
513
+ activeBlock.style.boxShadow = 'inset 0 0 0 1.5px var(--color-highlight-bg)'
514
+ activeBlock.style.borderRadius = '8px'
515
+ return () => {
516
+ activeBlock.style.boxShadow = ''
517
+ activeBlock.style.borderRadius = ''
518
+ }
519
+ }, [activeBlock])
520
+
521
+ // Resolve keyboard-focused block element (only annotatable blocks get comment icons)
522
+ const focusedBlock = useMemo(() => {
523
+ if (focusedBlockIndex === null || !containerRef.current) return null
524
+ const block = getOrderedBlocks(containerRef.current)[focusedBlockIndex]
525
+ if (!block) return null
526
+ // Only show comment icon on annotatable blocks, not interactive controls
527
+ return block.matches(ANNOTATABLE_SELECTOR) ? block : null
528
+ }, [focusedBlockIndex, containerRef.current])
529
+
530
+ // Collect all blocks that need a button: annotated + hovered + focused + popover target
531
+ const targets = useMemo(() => {
532
+ const set = new Set<HTMLElement>(annotatedBlocks)
533
+ if (hoveredBlock) set.add(hoveredBlock)
534
+ if (focusedBlock) set.add(focusedBlock)
535
+ if (blockPopover?.anchorEl) set.add(blockPopover.anchorEl)
536
+ return Array.from(set)
537
+ }, [annotatedBlocks, hoveredBlock, focusedBlock, blockPopover])
538
+
539
+ return (
540
+ <>
541
+ {targets.map((block) => (
542
+ <BlockCommentIcon
543
+ key={block.getAttribute('data-task-id') || block.getAttribute('data-md-title') || block.textContent?.slice(0, 30) || 'block'}
544
+ block={block}
545
+ isHovered={block === hoveredBlock || block === focusedBlock || block === blockPopover?.anchorEl}
546
+ blockAnnotationMap={blockAnnotationMap}
547
+ activeAnnotationId={activeAnnotationId}
548
+ onOpen={onOpen}
549
+ />
550
+ ))}
551
+ </>
552
+ )
517
553
  }
518
554
 
519
555
  function BlockCommentIcon({
520
- block,
521
- isHovered,
522
- blockAnnotationMap,
523
- activeAnnotationId,
524
- onOpen,
556
+ block,
557
+ isHovered,
558
+ blockAnnotationMap,
559
+ activeAnnotationId,
560
+ onOpen,
525
561
  }: {
526
- block: HTMLElement;
527
- isHovered: boolean;
528
- blockAnnotationMap: Map<string, string>;
529
- activeAnnotationId: string | null;
530
- onOpen: (anchorEl: HTMLElement, snippet: string, annId?: string) => void;
562
+ block: HTMLElement
563
+ isHovered: boolean
564
+ blockAnnotationMap: Map<string, string>
565
+ activeAnnotationId: string | null
566
+ onOpen: (anchorEl: HTMLElement, snippet: string, annId?: string) => void
531
567
  }) {
532
- const snippet = getBlockSnippet(block);
533
- if (!snippet) return null;
534
-
535
- const existingAnnId = blockAnnotationMap.get(snippet);
536
- const hasAnnotation = !!existingAnnId;
537
- const isActive = existingAnnId === activeAnnotationId;
538
-
539
- return createPortal(
540
- <button
541
- data-block-comment-btn
542
- onClick={(e) => {
543
- e.stopPropagation();
544
- onOpen(e.currentTarget as HTMLElement, snippet, existingAnnId);
545
- }}
546
- className={`absolute top-1 right-1 z-10 w-6 h-6 flex items-center justify-center rounded transition-all duration-150 ${
547
- hasAnnotation
548
- ? isActive
549
- ? "text-accent-amber bg-accent-amber-muted"
550
- : "text-accent-amber opacity-60 hover:opacity-100"
551
- : isHovered
552
- ? "text-text-disabled hover:text-text-tertiary opacity-50 hover:opacity-100"
553
- : "text-text-disabled opacity-0 group-hover/section:opacity-20 hover:!opacity-80"
554
- }`}
555
- title={hasAnnotation ? "Edit annotation" : "Add annotation"}
556
- >
557
- <svg width="13" height="13" viewBox="0 0 24 24" fill={hasAnnotation ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
558
- <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
559
- </svg>
560
- </button>,
561
- block,
562
- );
568
+ const snippet = getBlockSnippet(block)
569
+ if (!snippet) return null
570
+
571
+ const existingAnnId = blockAnnotationMap.get(snippet)
572
+ const hasAnnotation = !!existingAnnId
573
+ const isActive = existingAnnId === activeAnnotationId
574
+
575
+ return createPortal(
576
+ <button
577
+ data-block-comment-btn
578
+ onClick={(e) => {
579
+ e.stopPropagation()
580
+ onOpen(e.currentTarget as HTMLElement, snippet, existingAnnId)
581
+ }}
582
+ className={`absolute top-1 right-1 z-10 w-6 h-6 flex items-center justify-center rounded transition-all duration-150 ${
583
+ hasAnnotation
584
+ ? isActive
585
+ ? 'text-accent-amber bg-accent-amber-muted'
586
+ : 'text-accent-amber opacity-60 hover:opacity-100'
587
+ : isHovered
588
+ ? 'text-text-disabled hover:text-text-tertiary opacity-50 hover:opacity-100'
589
+ : 'text-text-disabled opacity-0 group-hover/section:opacity-20 hover:!opacity-80'
590
+ }`}
591
+ title={hasAnnotation ? 'Edit annotation' : 'Add annotation'}
592
+ >
593
+ <svg
594
+ width="13"
595
+ height="13"
596
+ viewBox="0 0 24 24"
597
+ fill={hasAnnotation ? 'currentColor' : 'none'}
598
+ stroke="currentColor"
599
+ strokeWidth="2"
600
+ strokeLinecap="round"
601
+ strokeLinejoin="round"
602
+ >
603
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
604
+ </svg>
605
+ </button>,
606
+ block,
607
+ )
563
608
  }
564
609
 
565
610
  function EditAnnotationModal({ note, onSave, onCancel }: { note: string; onSave: (n: string) => void; onCancel: () => void }) {
566
- const [text, setText] = React.useState(note);
567
- const ref = React.useRef<HTMLTextAreaElement>(null);
568
- React.useEffect(() => { ref.current?.focus(); ref.current?.select(); }, []);
569
- return (
570
- <div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center" onClick={onCancel}>
571
- <div className="bg-bg-elevated border border-border-hover rounded-lg shadow-md p-4 w-80" onClick={(e) => e.stopPropagation()}>
572
- <div className="text-tiny uppercase tracking-widest text-text-tertiary font-body mb-2">Edit annotation</div>
573
- <textarea ref={ref} value={text} onChange={(e) => setText(e.currentTarget.value)}
574
- className="w-full bg-transparent border-none text-body font-body text-text-primary resize-vertical focus:outline-none min-h-[60px]"
575
- onKeyDown={(e) => {
576
- if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) onSave(text.trim());
577
- if (e.key === "Escape") onCancel();
578
- }}
579
- />
580
- <div className="flex justify-end gap-2 mt-2">
581
- <button onClick={onCancel} className="text-meta text-text-tertiary font-body px-3 py-1">Cancel</button>
582
- <button onClick={() => onSave(text.trim())} className="text-meta font-medium font-body px-3 py-1 rounded-md bg-highlight-bg text-text-primary border border-highlight-border">Save</button>
583
- </div>
584
- </div>
585
- </div>
586
- );
611
+ const [text, setText] = React.useState(note)
612
+ const ref = React.useRef<HTMLTextAreaElement>(null)
613
+ React.useEffect(() => {
614
+ ref.current?.focus()
615
+ ref.current?.select()
616
+ }, [])
617
+ return (
618
+ <div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center" onClick={onCancel}>
619
+ <div className="bg-bg-elevated border border-border-hover rounded-lg shadow-md p-4 w-80" onClick={(e) => e.stopPropagation()}>
620
+ <div className="text-tiny uppercase tracking-widest text-text-tertiary font-body mb-2">Edit annotation</div>
621
+ <textarea
622
+ ref={ref}
623
+ value={text}
624
+ onChange={(e) => setText(e.currentTarget.value)}
625
+ className="w-full bg-transparent border-none text-body font-body text-text-primary resize-vertical focus:outline-none min-h-[60px]"
626
+ onKeyDown={(e) => {
627
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) onSave(text.trim())
628
+ if (e.key === 'Escape') onCancel()
629
+ }}
630
+ />
631
+ <div className="flex justify-end gap-2 mt-2">
632
+ <button onClick={onCancel} className="text-meta text-text-tertiary font-body px-3 py-1">Cancel</button>
633
+ <button
634
+ onClick={() => onSave(text.trim())}
635
+ className="text-meta font-medium font-body px-3 py-1 rounded-md bg-highlight-bg text-text-primary border border-highlight-border"
636
+ >
637
+ Save
638
+ </button>
639
+ </div>
640
+ </div>
641
+ </div>
642
+ )
587
643
  }