@fabrika/canvas-kernel 0.1.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +74 -0
- package/annotate.ts +94 -0
- package/daemon.ts +46 -0
- package/index.ts +84 -0
- package/package.json +74 -0
- package/src/client/AnnotationDraftFooter.tsx +76 -0
- package/src/client/AnnotationEditor.tsx +320 -0
- package/src/client/AnnotationList.tsx +190 -0
- package/src/client/AnnotationProvider.tsx +294 -0
- package/src/client/AnnotationSidebar.tsx +374 -0
- package/src/client/FileIcon.tsx +76 -0
- package/src/client/PlanRenderer.tsx +587 -0
- package/src/client/Popover.tsx +361 -0
- package/src/client/RenderErrorContext.ts +11 -0
- package/src/client/ResponsePreview.tsx +186 -0
- package/src/client/SessionContext.tsx +3 -0
- package/src/client/annotationContext.ts +130 -0
- package/src/client/annotationDom.ts +115 -0
- package/src/client/annotationDraft.ts +130 -0
- package/src/client/annotationMarkdown.ts +102 -0
- package/src/client/annotationTarget.ts +73 -0
- package/src/client/appContext.tsx +61 -0
- package/src/client/blockTarget.ts +127 -0
- package/src/client/components/Callout.tsx +27 -0
- package/src/client/components/Checklist.tsx +37 -0
- package/src/client/components/Choice.tsx +178 -0
- package/src/client/components/CodeBlock.tsx +35 -0
- package/src/client/components/Diff.tsx +36 -0
- package/src/client/components/FilePreview.tsx +88 -0
- package/src/client/components/Image.tsx +48 -0
- package/src/client/components/Markdown.tsx +44 -0
- package/src/client/components/Mermaid.tsx +576 -0
- package/src/client/components/Note.tsx +14 -0
- package/src/client/components/Priority.tsx +19 -0
- package/src/client/components/ResponseNote.tsx +19 -0
- package/src/client/components/Section.tsx +33 -0
- package/src/client/components/Table.tsx +35 -0
- package/src/client/components/Task.tsx +76 -0
- package/src/client/components/UserInput.tsx +176 -0
- package/src/client/components/index.ts +18 -0
- package/src/client/components/renderMarkdownChildren.tsx +100 -0
- package/src/client/diffLines.ts +50 -0
- package/src/client/generateMarkdown.ts +250 -0
- package/src/client/highlightRange.ts +262 -0
- package/src/client/hostContext.tsx +55 -0
- package/src/client/index.ts +128 -0
- package/src/client/popoverPosition.ts +46 -0
- package/src/client/preact-all.ts +4 -0
- package/src/client/regionTarget.ts +180 -0
- package/src/client/runtime.ts +149 -0
- package/src/client/styles.css +492 -0
- package/src/client/testing/dom.ts +58 -0
- package/src/client/textTarget.ts +31 -0
- package/src/client/theme.css +66 -0
- package/src/client/useRegionAnnotation.tsx +188 -0
- package/src/client/useTextAnnotation.tsx +194 -0
- package/src/client/utils.ts +33 -0
- package/src/engine/channel.ts +278 -0
- package/src/engine/compiler.ts +415 -0
- package/src/engine/http-utils.ts +14 -0
- package/src/engine/lifecycle.ts +302 -0
- package/src/engine/paths.ts +63 -0
- package/src/engine/router.ts +16 -0
- package/src/engine/session.ts +678 -0
- package/src/engine/waiter.ts +188 -0
- package/src/engine/watcher.ts +84 -0
- package/src/engine/websocket.ts +159 -0
|
@@ -0,0 +1,320 @@
|
|
|
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
|
+
×
|
|
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
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
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
|
+
}
|