@fabrika/canvas-kernel 0.1.45 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +33 -67
  2. package/index.ts +19 -83
  3. package/package.json +68 -72
  4. package/src/client/AnnotationProvider.tsx +227 -268
  5. package/src/client/AnnotationSidebar.tsx +392 -338
  6. package/src/client/FileIcon.tsx +106 -65
  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 +98 -92
  12. package/src/client/appContext.tsx +40 -40
  13. package/src/client/components/Callout.tsx +20 -20
  14. package/src/client/components/Checklist.tsx +28 -28
  15. package/src/client/components/Choice.tsx +178 -159
  16. package/src/client/components/CodeBlock.tsx +26 -26
  17. package/src/client/components/Diff.tsx +17 -17
  18. package/src/client/components/FilePreview.tsx +99 -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 +592 -542
  22. package/src/client/components/Note.tsx +8 -8
  23. package/src/client/components/Priority.tsx +11 -11
  24. package/src/client/components/ResponseNote.tsx +17 -15
  25. package/src/client/components/Section.tsx +28 -26
  26. package/src/client/components/Table.tsx +34 -29
  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 +34 -34
  34. package/src/client/index.ts +43 -125
  35. package/src/client/preact-all.ts +4 -4
  36. package/src/client/runtime.ts +58 -111
  37. package/src/client/styles.css +1 -74
  38. package/src/engine/compiler.ts +316 -313
  39. package/src/engine/paths.ts +44 -44
  40. package/src/engine/session.ts +623 -618
  41. package/src/engine/watcher.ts +63 -60
  42. package/src/engine/websocket.ts +123 -121
  43. package/annotate.ts +0 -94
  44. package/daemon.ts +0 -46
  45. package/src/client/AnnotationDraftFooter.tsx +0 -76
  46. package/src/client/AnnotationEditor.tsx +0 -320
  47. package/src/client/AnnotationList.tsx +0 -190
  48. package/src/client/Popover.tsx +0 -361
  49. package/src/client/SessionContext.tsx +0 -3
  50. package/src/client/annotationContext.ts +0 -130
  51. package/src/client/annotationMarkdown.ts +0 -102
  52. package/src/client/annotationTarget.ts +0 -73
  53. package/src/client/blockTarget.ts +0 -127
  54. package/src/client/highlightRange.ts +0 -262
  55. package/src/client/popoverPosition.ts +0 -46
  56. package/src/client/regionTarget.ts +0 -180
  57. package/src/client/testing/dom.ts +0 -74
  58. package/src/client/textTarget.ts +0 -31
  59. package/src/client/useRegionAnnotation.tsx +0 -188
  60. package/src/client/useTextAnnotation.tsx +0 -194
  61. package/src/client/utils.ts +0 -33
  62. package/src/engine/channel.ts +0 -278
  63. package/src/engine/http-utils.ts +0 -14
  64. package/src/engine/lifecycle.ts +0 -302
  65. package/src/engine/router.ts +0 -16
  66. package/src/engine/waiter.ts +0 -188
@@ -1,320 +0,0 @@
1
- import React, { useState, useRef, useCallback, useEffect } from "react";
2
- import { autoResizeTextarea, hostAcceptsUploads } from "./utils";
3
- import { useCanvasHost, type CanvasHost } from "./hostContext";
4
-
5
- interface AnnotationEditorProps {
6
- note: string;
7
- onNoteChange: (note: string) => void;
8
- images: string[];
9
- onAddImage: (path: string) => void;
10
- onRemoveImage: (path: string) => void;
11
- sessionId: string;
12
-
13
- placeholder?: string;
14
- autoFocus?: boolean;
15
- autoResize?: boolean;
16
- readOnly?: boolean;
17
- onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
18
- onTextareaClick?: (e: React.MouseEvent<HTMLTextAreaElement>) => void;
19
- textareaRef?: React.MutableRefObject<HTMLTextAreaElement | null>;
20
- textareaClassName?: string;
21
- textareaStyle?: React.CSSProperties;
22
- minHeight?: number;
23
- /** "always" (default), "on-focus" (sidebar), or "none" (popover — use openFilePickerRef) */
24
- attachButton?: "always" | "on-focus" | "none";
25
- /** Parent can store the open-file-picker function for external trigger */
26
- openFilePickerRef?: React.MutableRefObject<(() => void) | null>;
27
- }
28
-
29
- export function imageToUrl(path: string): string {
30
- // Shared-mode uploads come back from the worker as absolute URLs under
31
- // /s/:shareId/uploads/... — return them as-is. Local daemon uploads are
32
- // stored as absolute filesystem paths; translate to the served URL.
33
- if (path.startsWith("/s/") || path.startsWith("http://") || path.startsWith("https://")) {
34
- return path;
35
- }
36
- const filename = path.split("/").pop();
37
- return `/api/uploads/${filename}`;
38
- }
39
-
40
- async function uploadImage(host: CanvasHost, file: File): Promise<string | null> {
41
- const endpoint = host.uploadUrl;
42
- if (!endpoint) return null;
43
- const formData = new FormData();
44
- formData.append("image", file);
45
- try {
46
- const resp = await fetch(endpoint(), { method: "POST", body: formData });
47
- const data = await resp.json();
48
- // Local daemon returns { path }; worker returns { url }. Normalise to
49
- // a single string that `imageToUrl` can resolve.
50
- if (host.isShared) return data.url || null;
51
- return data.path || null;
52
- } catch {
53
- return null;
54
- }
55
- }
56
-
57
- export function AnnotationEditor({
58
- note, onNoteChange, images, onAddImage, onRemoveImage, sessionId,
59
- placeholder = "Add your note...", autoFocus = false, autoResize = false,
60
- readOnly = false, onKeyDown, onTextareaClick, textareaRef: externalRef,
61
- textareaClassName, textareaStyle, minHeight,
62
- attachButton = "always", openFilePickerRef,
63
- }: AnnotationEditorProps) {
64
- const host = useCanvasHost();
65
- // Button, paste and drop all end in the same upload; a host with no endpoint
66
- // for it gets none of them rather than three ways to fail silently.
67
- const canAttach = !readOnly && hostAcceptsUploads(host);
68
- const [isDragging, setIsDragging] = useState(false);
69
- const [isUploading, setIsUploading] = useState(false);
70
- const [isFocused, setIsFocused] = useState(false);
71
- const dragCounter = useRef(0);
72
- const internalRef = useRef<HTMLTextAreaElement | null>(null);
73
- const fileInputRef = useRef<HTMLInputElement | null>(null);
74
-
75
- const openFilePicker = useCallback(() => fileInputRef.current?.click(), []);
76
-
77
- // Expose file picker to parent
78
- if (openFilePickerRef) openFilePickerRef.current = openFilePicker;
79
-
80
- const setRef = useCallback((el: HTMLTextAreaElement | null) => {
81
- internalRef.current = el;
82
- if (externalRef) externalRef.current = el;
83
- if (el && autoResize) autoResizeTextarea(el, minHeight);
84
- }, [externalRef, autoResize, minHeight]);
85
-
86
- useEffect(() => {
87
- if (autoFocus && internalRef.current) {
88
- const ta = internalRef.current;
89
- ta.focus();
90
- if (ta.value) ta.setSelectionRange(ta.value.length, ta.value.length);
91
- }
92
- }, [autoFocus]);
93
-
94
- const doUpload = useCallback(async (file: File) => {
95
- if (!file.type.startsWith("image/")) return;
96
- setIsUploading(true);
97
- try {
98
- const path = await uploadImage(host, file);
99
- if (path) onAddImage(path);
100
- } finally {
101
- setIsUploading(false);
102
- }
103
- }, [host, onAddImage]);
104
-
105
- const handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
106
- for (const item of e.clipboardData?.items ?? []) {
107
- if (item.type.startsWith("image/")) {
108
- e.preventDefault();
109
- const file = item.getAsFile();
110
- if (file) doUpload(file);
111
- return;
112
- }
113
- }
114
- }, [doUpload]);
115
-
116
- const handleDragEnter = useCallback((e: React.DragEvent<HTMLDivElement>) => {
117
- e.preventDefault();
118
- dragCounter.current++;
119
- if (dragCounter.current === 1) setIsDragging(true);
120
- }, []);
121
-
122
- const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
123
- e.preventDefault();
124
- }, []);
125
-
126
- const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
127
- e.preventDefault();
128
- dragCounter.current--;
129
- if (dragCounter.current === 0) setIsDragging(false);
130
- }, []);
131
-
132
- const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
133
- e.preventDefault();
134
- dragCounter.current = 0;
135
- setIsDragging(false);
136
- for (const file of e.dataTransfer?.files ?? []) {
137
- if (file.type.startsWith("image/")) doUpload(file);
138
- }
139
- }, [doUpload]);
140
-
141
- const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
142
- onNoteChange(e.currentTarget.value);
143
- if (autoResize) {
144
- e.currentTarget.style.height = "auto";
145
- e.currentTarget.style.height = (minHeight ? Math.max(minHeight, e.currentTarget.scrollHeight) : e.currentTarget.scrollHeight) + "px";
146
- }
147
- }, [onNoteChange, autoResize, minHeight]);
148
-
149
- const defaultClassName = "w-full bg-transparent text-[13px] font-body text-text-primary resize-none focus:outline-none leading-relaxed p-0 border-none";
150
- const defaultStyle: React.CSSProperties = {
151
- width: "100%", background: "transparent", border: "none",
152
- color: "var(--color-text-primary)", fontFamily: "'Inter', sans-serif",
153
- fontSize: "13px", lineHeight: "1.5", outline: "none",
154
- ...(autoResize ? { resize: "none" as const, overflow: "hidden" } : { resize: "vertical" as const }),
155
- };
156
-
157
- const showButton = canAttach && (
158
- attachButton === "always" ||
159
- (attachButton === "on-focus" && isFocused)
160
- );
161
-
162
- return (
163
- <div
164
- style={{ position: "relative" }}
165
- onDragEnter={canAttach ? handleDragEnter : undefined}
166
- onDragOver={canAttach ? handleDragOver : undefined}
167
- onDragLeave={canAttach ? handleDragLeave : undefined}
168
- onDrop={canAttach ? handleDrop : undefined}
169
- >
170
- <textarea
171
- ref={setRef}
172
- value={note}
173
- onChange={readOnly ? undefined : handleChange}
174
- onPaste={canAttach ? handlePaste : undefined}
175
- onKeyDown={onKeyDown}
176
- onClick={onTextareaClick}
177
- onFocus={() => setIsFocused(true)}
178
- onBlur={() => {
179
- // Delay so click on attach button registers before hiding
180
- setTimeout(() => setIsFocused(false), 150);
181
- }}
182
- readOnly={readOnly}
183
- placeholder={placeholder}
184
- className={textareaClassName ?? defaultClassName}
185
- style={textareaStyle ?? defaultStyle}
186
- rows={autoResize ? 1 : undefined}
187
- />
188
-
189
- {/* Hidden file input — always rendered for paste/drop + external trigger */}
190
- {canAttach && (
191
- <input
192
- ref={fileInputRef}
193
- type="file"
194
- accept="image/*"
195
- multiple
196
- style={{ display: "none" }}
197
- onChange={(e) => {
198
- const files = e.currentTarget.files;
199
- if (files) for (const f of files) doUpload(f);
200
- e.currentTarget.value = "";
201
- }}
202
- />
203
- )}
204
-
205
- {/* Image thumbnails + inline attach button */}
206
- {(images.length > 0 || showButton) && (
207
- <div style={{ display: "flex", gap: "6px", flexWrap: "wrap", marginTop: "6px", alignItems: "center" }}>
208
- {images.map((img) => (
209
- <div
210
- key={img}
211
- style={{
212
- position: "relative", width: "56px", height: "56px",
213
- borderRadius: "6px", overflow: "hidden",
214
- border: "1px solid var(--color-border-subtle)",
215
- flexShrink: 0,
216
- }}
217
- >
218
- <img
219
- src={imageToUrl(img)}
220
- alt=""
221
- style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
222
- />
223
- {!readOnly && (
224
- <button
225
- onClick={(e) => { e.stopPropagation(); onRemoveImage(img); }}
226
- style={{
227
- position: "absolute", top: "2px", right: "2px",
228
- width: "16px", height: "16px", borderRadius: "50%",
229
- background: "rgba(0,0,0,0.6)", color: "white",
230
- border: "none", cursor: "pointer", fontSize: "11px",
231
- display: "flex", alignItems: "center", justifyContent: "center",
232
- lineHeight: 1, padding: 0,
233
- }}
234
- title="Remove image"
235
- >
236
- &times;
237
- </button>
238
- )}
239
- </div>
240
- ))}
241
- {showButton && <AttachButton onClick={openFilePicker} />}
242
- </div>
243
- )}
244
-
245
- {isUploading && (
246
- <div style={{
247
- fontSize: "11px", color: "var(--color-text-tertiary)",
248
- fontFamily: "'Inter', sans-serif", marginTop: "4px",
249
- }}>
250
- Uploading...
251
- </div>
252
- )}
253
-
254
- {isDragging && (
255
- <div style={{
256
- position: "absolute", inset: 0, display: "flex",
257
- alignItems: "center", justifyContent: "center",
258
- background: "var(--color-highlight-bg)", opacity: 0.9,
259
- border: "2px dashed var(--color-highlight-border)",
260
- borderRadius: "6px", zIndex: 10, pointerEvents: "none",
261
- fontSize: "12px", fontFamily: "'Inter', sans-serif",
262
- color: "var(--color-text-secondary)",
263
- }}>
264
- Drop image
265
- </div>
266
- )}
267
- </div>
268
- );
269
- }
270
-
271
- /** Small attach-image button — reusable in editor and popover button bar */
272
- export function AttachButton({ onClick, size = 16 }: { onClick: () => void; size?: number }) {
273
- return (
274
- <button
275
- onClick={(e) => { e.stopPropagation(); onClick(); }}
276
- style={{
277
- width: `${size * 2}px`, height: `${size * 2}px`, borderRadius: "6px",
278
- border: "none",
279
- background: "transparent", cursor: "pointer",
280
- display: "flex", alignItems: "center", justifyContent: "center",
281
- color: "var(--color-text-tertiary)", flexShrink: 0,
282
- transition: "color 0.15s",
283
- }}
284
- title="Attach image"
285
- onMouseEnter={(e) => { e.currentTarget.style.color = "var(--color-text-secondary)"; }}
286
- onMouseLeave={(e) => { e.currentTarget.style.color = "var(--color-text-tertiary)"; }}
287
- >
288
- <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
289
- <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
290
- <circle cx="8.5" cy="8.5" r="1.5" />
291
- <polyline points="21 15 16 10 5 21" />
292
- </svg>
293
- </button>
294
- );
295
- }
296
-
297
- /** Read-only image thumbnails for display in read-only annotations */
298
- export function ImageThumbnails({ images }: { images: string[] }) {
299
- if (!images.length) return null;
300
- return (
301
- <div style={{ display: "flex", gap: "6px", flexWrap: "wrap", marginTop: "6px" }}>
302
- {images.map((img) => (
303
- <div
304
- key={img}
305
- style={{
306
- width: "56px", height: "56px", borderRadius: "6px",
307
- overflow: "hidden", border: "1px solid var(--color-border-subtle)",
308
- flexShrink: 0,
309
- }}
310
- >
311
- <img
312
- src={imageToUrl(img)}
313
- alt=""
314
- style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
315
- />
316
- </div>
317
- ))}
318
- </div>
319
- );
320
- }
@@ -1,190 +0,0 @@
1
- import { useContext, useEffect, useRef } from "react";
2
- import { useAnnotations } from "./AnnotationProvider";
3
- import type { Annotation } from "./AnnotationProvider";
4
- import { SessionContext } from "#canvas/runtime";
5
- import { setMarkActive } from "./highlightRange";
6
- import { describeSnippet } from "./annotationDom";
7
- import { AnnotationEditor, ImageThumbnails } from "./AnnotationEditor";
8
-
9
- /**
10
- * The annotations a host has collected, as a list of editable cards.
11
- *
12
- * Flat by design: what an annotation belongs to — a file, a screen, a section —
13
- * is the host's taxonomy, so the host slices the annotations into groups and
14
- * renders one list per group. The card itself is the same everywhere.
15
- */
16
-
17
- /** The one line a card shows for what its annotation points at. */
18
- function snippetLabel(snippet: string): string {
19
- const label = describeSnippet(snippet);
20
- return label.length > 80 ? label.slice(0, 80) + "..." : label;
21
- }
22
-
23
- export interface AnnotationListProps {
24
- annotations: Annotation[];
25
- /** Frozen view: notes read as text, nothing is deletable, and the list does
26
- * not chase the active card. */
27
- readOnly?: boolean;
28
- /** A card just became the active one — hosts reveal what it points at here. */
29
- onSelect?: (ann: Annotation) => void;
30
- /** Backs the reveal button. Omit it and no button is drawn. */
31
- onReveal?: (ann: Annotation) => void;
32
- /** Remote annotations ticked for submission. Pass both or neither — a host
33
- * with no notion of remote annotations passes neither and gets none of the
34
- * remote affordances. */
35
- includedRemoteIds?: Set<string>;
36
- onToggleRemoteId?: (id: string) => void;
37
- }
38
-
39
- export function AnnotationList({
40
- annotations, readOnly = false, onSelect, onReveal,
41
- includedRemoteIds, onToggleRemoteId,
42
- }: AnnotationListProps) {
43
- const {
44
- updateAnnotation, removeAnnotation,
45
- addAnnotationImage, removeAnnotationImage,
46
- activeAnnotationId, setActiveAnnotationId,
47
- } = useAnnotations();
48
- const sessionId = useContext(SessionContext);
49
- const annRefs = useRef<Map<string, HTMLDivElement>>(new Map());
50
-
51
- // Follow the active annotation, which is as often picked in the document as
52
- // in this list.
53
- useEffect(() => {
54
- if (readOnly || !activeAnnotationId) return;
55
- const el = annRefs.current.get(activeAnnotationId);
56
- if (el) el.scrollIntoView({ behavior: "smooth", block: "nearest" });
57
- }, [activeAnnotationId, readOnly]);
58
-
59
- // Hover on a card → highlight the mark it points at.
60
- const handleMouseEnter = (annId: string) => {
61
- setActiveAnnotationId(annId);
62
- setMarkActive(annId, true);
63
- };
64
-
65
- const handleMouseLeave = (annId: string) => {
66
- setActiveAnnotationId(null);
67
- setMarkActive(annId, false);
68
- };
69
-
70
- const handleClick = (ann: Annotation) => {
71
- if (ann.id === activeAnnotationId) {
72
- setActiveAnnotationId(null);
73
- return;
74
- }
75
- setActiveAnnotationId(ann.id);
76
- onSelect?.(ann);
77
- };
78
-
79
- // Remote is only a concept for hosts that handle it.
80
- const handlesRemote = !!includedRemoteIds && !!onToggleRemoteId;
81
-
82
- const renderAnnotation = (ann: Annotation) => {
83
- const isRemote = handlesRemote && ann.source === "remote";
84
- return (
85
- <div
86
- key={ann.id}
87
- ref={(el) => { if (el) annRefs.current.set(ann.id, el); else annRefs.current.delete(ann.id); }}
88
- className={`group/ann relative px-3 py-2.5 transition-colors duration-150 ${readOnly ? "cursor-pointer " : ""}${
89
- activeAnnotationId === ann.id
90
- ? "bg-highlight-selected"
91
- : isRemote
92
- ? "bg-accent-purple-muted/30 hover:bg-accent-purple-muted/50"
93
- : "odd:bg-bg-elevated-half hover:bg-bg-input"
94
- }`}
95
- onMouseEnter={() => handleMouseEnter(ann.id)}
96
- onMouseLeave={() => handleMouseLeave(ann.id)}
97
- onClick={() => handleClick(ann)}
98
- >
99
- {isRemote && ann.author && (
100
- <div className="flex items-center gap-1.5 mb-1.5">
101
- <input
102
- type="checkbox"
103
- checked={includedRemoteIds.has(ann.id)}
104
- onChange={(e) => { e.stopPropagation(); onToggleRemoteId(ann.id); }}
105
- onClick={(e) => e.stopPropagation()}
106
- className="accent-accent-purple flex-shrink-0"
107
- title="Include in submission"
108
- />
109
- <span className="inline-flex items-center justify-center w-4 h-4 rounded-full bg-accent-purple text-white text-[9px] font-medium uppercase">
110
- {ann.author.name.charAt(0)}
111
- </span>
112
- <span className="text-[11px] font-medium text-accent-purple font-body">
113
- {ann.author.name}
114
- </span>
115
- <span className="text-[10px] text-text-tertiary font-body uppercase tracking-widest">
116
- Remote
117
- </span>
118
- </div>
119
- )}
120
-
121
- {/* Snippet quote */}
122
- <div className="text-[11px] text-text-tertiary italic line-clamp-2 mb-1.5 leading-snug font-body border-l-2 border-border-medium pl-2">
123
- {snippetLabel(ann.snippet)}
124
- </div>
125
-
126
- {/* Editable note + images — only the reader's own live annotations. */}
127
- {readOnly ? (
128
- <>
129
- {ann.note.trim() && (
130
- <div className="text-[13px] font-body text-text-primary leading-relaxed">
131
- {ann.note}
132
- </div>
133
- )}
134
- <ImageThumbnails images={ann.images || []} />
135
- </>
136
- ) : isRemote ? (
137
- <div className="text-[13px] font-body text-text-primary leading-relaxed whitespace-pre-wrap">
138
- {ann.note}
139
- </div>
140
- ) : (
141
- <div onClick={(e) => e.stopPropagation()}>
142
- <AnnotationEditor
143
- note={ann.note}
144
- onNoteChange={(note) => updateAnnotation(ann.id, note)}
145
- images={ann.images || []}
146
- onAddImage={(path) => addAnnotationImage(ann.id, path)}
147
- onRemoveImage={(path) => removeAnnotationImage(ann.id, path)}
148
- sessionId={sessionId}
149
- autoResize
150
- minHeight={20}
151
- textareaClassName="w-full bg-transparent text-[13px] font-body text-text-primary resize-none focus:outline-none leading-relaxed p-0 border-none min-h-[20px]"
152
- textareaStyle={{ height: "auto", overflow: "hidden" }}
153
- placeholder="Add your note..."
154
- attachButton="on-focus"
155
- />
156
- </div>
157
- )}
158
-
159
- {/* Actions — top right on hover */}
160
- <div className="absolute top-1.5 right-1.5 opacity-0 group-hover/ann:opacity-100 transition-opacity duration-100 flex items-center gap-0.5">
161
- {onReveal && (
162
- <button
163
- onClick={(e) => { e.stopPropagation(); onReveal(ann); }}
164
- className="w-5 h-5 flex items-center justify-center rounded text-text-tertiary hover:text-text-secondary hover:bg-bg-input transition-colors"
165
- title="Scroll to annotation"
166
- >
167
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
168
- <circle cx="12" cy="12" r="3" /><path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
169
- </svg>
170
- </button>
171
- )}
172
- {!readOnly && !isRemote && (
173
- <button
174
- onClick={(e) => { e.stopPropagation(); removeAnnotation(ann.id); }}
175
- className="w-5 h-5 flex items-center justify-center rounded text-text-tertiary hover:text-accent-red hover:bg-accent-red-muted transition-colors"
176
- title="Delete annotation"
177
- >
178
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
179
- <polyline points="3 6 5 6 21 6"/>
180
- <path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
181
- </svg>
182
- </button>
183
- )}
184
- </div>
185
- </div>
186
- );
187
- };
188
-
189
- return <>{annotations.map(renderAnnotation)}</>;
190
- }