@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
package/daemon.ts DELETED
@@ -1,46 +0,0 @@
1
- // @fabrika/canvas-kernel/daemon — the host-agnostic daemon runtime.
2
- //
3
- // Ring 1: everything a local daemon needs that knows nothing about canvases —
4
- // the WebSocket notification channel, the CLI waiter, process lifecycle, route
5
- // dispatch, and the on-disk layout. A host whose domain is not canvases (Figma
6
- // screens, say) takes this entry and none of the canvas engine behind `/server`.
7
-
8
- // --- WebSocket notification channel ----------------------------------------
9
- export { createNotificationChannel, browserFrame } from "./src/engine/channel";
10
- export type {
11
- ChannelRole,
12
- ChannelSocket,
13
- TopicChannel,
14
- RelayOptions,
15
- BrowserFrameHandler,
16
- NotificationChannel,
17
- NotificationChannelOptions,
18
- } from "./src/engine/channel";
19
-
20
- // --- CLI waiter ------------------------------------------------------------
21
- export { waitForEvent, WaitError } from "./src/engine/waiter";
22
- export type { WaiterSocket, WaitVerdict, WaitOptions, WaitMessages, WaitFailure } from "./src/engine/waiter";
23
-
24
- // --- Daemon process lifecycle ----------------------------------------------
25
- export { createDaemonLifecycle, DaemonStartError } from "./src/engine/lifecycle";
26
- export type {
27
- DaemonLifecycle,
28
- DaemonLifecycleConfig,
29
- DaemonHealth,
30
- DaemonHealthUp,
31
- DaemonHealthDown,
32
- DaemonPid,
33
- DaemonStopResult,
34
- DaemonStopReason,
35
- DaemonEvent,
36
- DaemonTimeouts,
37
- } from "./src/engine/lifecycle";
38
-
39
- // --- Router / HTTP ---------------------------------------------------------
40
- export { dispatch } from "./src/engine/router";
41
- export type { Route, RouteHandler } from "./src/engine/router";
42
- export { jsonResponse, corsHeaders } from "./src/engine/http-utils";
43
-
44
- // --- On-disk layout --------------------------------------------------------
45
- export { createCanvasPaths } from "./src/engine/paths";
46
- export type { CanvasPaths, CanvasPathsConfig } from "./src/engine/paths";
@@ -1,76 +0,0 @@
1
- import type React from "react";
2
- import { useAnnotations } from "./AnnotationProvider";
3
- import { autoResizeTextarea } from "./utils";
4
-
5
- /**
6
- * The foot of a draft: the note that belongs to no one annotation, and the
7
- * action that sends the draft off.
8
- *
9
- * What counts as content, and what the payload looks like, is the host's —
10
- * hence `hasContent` and a bare `onSubmit`. The two slots are where a host
11
- * hangs whatever else its submit step needs (a preview button, a validation
12
- * notice) without this component learning about it.
13
- */
14
- export interface AnnotationDraftFooterProps {
15
- /** Whether the draft says anything. Hosts count more than annotations and
16
- * the general note, so they decide. */
17
- hasContent: boolean;
18
- onSubmit: () => void;
19
- /** Submitting an empty draft — an explicit "nothing to say", not a no-op. */
20
- onSubmitEmpty: () => void;
21
- /** Sits between the note and the actions: validation, status, a warning. */
22
- notice?: React.ReactNode;
23
- /** Sits left of the submit button, sharing the row. */
24
- secondaryAction?: React.ReactNode;
25
- placeholder?: string;
26
- submitLabel?: string;
27
- emptyLabel?: string;
28
- }
29
-
30
- export function AnnotationDraftFooter({
31
- hasContent, onSubmit, onSubmitEmpty, notice, secondaryAction,
32
- placeholder = "General notes...",
33
- submitLabel = "Submit",
34
- emptyLabel = "Submit without feedback",
35
- }: AnnotationDraftFooterProps) {
36
- const { generalNote, setGeneralNote } = useAnnotations();
37
-
38
- return (
39
- <>
40
- {/* General note — seamless */}
41
- <div className="border-t border-border-subtle px-4 py-3 flex-shrink-0">
42
- <textarea
43
- value={generalNote}
44
- onChange={(e) => setGeneralNote(e.currentTarget.value)}
45
- className="w-full bg-transparent text-[13px] font-body text-text-primary resize-none leading-relaxed p-0 border-none ring-0 shadow-none outline-none focus:outline-none focus:ring-0 focus:border-none placeholder:text-text-disabled min-h-[40px]"
46
- placeholder={placeholder}
47
- onInput={(e) => autoResizeTextarea(e.currentTarget, 40)}
48
- ref={(el) => { if (el) autoResizeTextarea(el, 40); }}
49
- />
50
- </div>
51
-
52
- {notice}
53
-
54
- <div className="px-4 py-3 border-t border-border-subtle flex-shrink-0">
55
- {hasContent ? (
56
- <div className="flex gap-2">
57
- {secondaryAction}
58
- <button
59
- onClick={onSubmit}
60
- className="flex-1 py-2 rounded-lg font-body text-[13px] font-medium transition-all bg-btn-primary text-btn-primary-text hover:opacity-90 hover:-translate-y-px shadow-sm"
61
- >
62
- {submitLabel}
63
- </button>
64
- </div>
65
- ) : (
66
- <button
67
- onClick={onSubmitEmpty}
68
- className="w-full py-2 rounded-lg font-body text-[13px] font-medium border border-border-medium text-text-secondary hover:text-text-primary hover:border-border-hover hover:bg-bg-input transition-all"
69
- >
70
- {emptyLabel}
71
- </button>
72
- )}
73
- </div>
74
- </>
75
- );
76
- }
@@ -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
- }