@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.
- package/README.md +33 -67
- package/index.ts +19 -83
- package/package.json +68 -72
- package/src/client/AnnotationProvider.tsx +227 -268
- package/src/client/AnnotationSidebar.tsx +392 -338
- package/src/client/FileIcon.tsx +106 -65
- package/src/client/PlanRenderer.tsx +607 -551
- package/src/client/RenderErrorContext.ts +7 -7
- package/src/client/ResponsePreview.tsx +192 -173
- package/src/client/annotationDom.ts +43 -97
- package/src/client/annotationDraft.ts +98 -92
- package/src/client/appContext.tsx +40 -40
- package/src/client/components/Callout.tsx +20 -20
- package/src/client/components/Checklist.tsx +28 -28
- package/src/client/components/Choice.tsx +178 -159
- package/src/client/components/CodeBlock.tsx +26 -26
- package/src/client/components/Diff.tsx +17 -17
- package/src/client/components/FilePreview.tsx +99 -74
- package/src/client/components/Image.tsx +41 -39
- package/src/client/components/Markdown.tsx +33 -32
- package/src/client/components/Mermaid.tsx +592 -542
- package/src/client/components/Note.tsx +8 -8
- package/src/client/components/Priority.tsx +11 -11
- package/src/client/components/ResponseNote.tsx +17 -15
- package/src/client/components/Section.tsx +28 -26
- package/src/client/components/Table.tsx +34 -29
- package/src/client/components/Task.tsx +58 -60
- package/src/client/components/UserInput.tsx +169 -152
- package/src/client/components/index.ts +16 -16
- package/src/client/components/renderMarkdownChildren.tsx +86 -68
- package/src/client/diffLines.ts +51 -39
- package/src/client/generateMarkdown.ts +197 -191
- package/src/client/hostContext.tsx +34 -34
- package/src/client/index.ts +43 -125
- package/src/client/preact-all.ts +4 -4
- package/src/client/runtime.ts +58 -111
- package/src/client/styles.css +1 -74
- package/src/engine/compiler.ts +316 -313
- package/src/engine/paths.ts +44 -44
- package/src/engine/session.ts +623 -618
- package/src/engine/watcher.ts +63 -60
- package/src/engine/websocket.ts +123 -121
- package/annotate.ts +0 -94
- package/daemon.ts +0 -46
- package/src/client/AnnotationDraftFooter.tsx +0 -76
- package/src/client/AnnotationEditor.tsx +0 -320
- package/src/client/AnnotationList.tsx +0 -190
- package/src/client/Popover.tsx +0 -361
- package/src/client/SessionContext.tsx +0 -3
- package/src/client/annotationContext.ts +0 -130
- package/src/client/annotationMarkdown.ts +0 -102
- package/src/client/annotationTarget.ts +0 -73
- package/src/client/blockTarget.ts +0 -127
- package/src/client/highlightRange.ts +0 -262
- package/src/client/popoverPosition.ts +0 -46
- package/src/client/regionTarget.ts +0 -180
- package/src/client/testing/dom.ts +0 -74
- package/src/client/textTarget.ts +0 -31
- package/src/client/useRegionAnnotation.tsx +0 -188
- package/src/client/useTextAnnotation.tsx +0 -194
- package/src/client/utils.ts +0 -33
- package/src/engine/channel.ts +0 -278
- package/src/engine/http-utils.ts +0 -14
- package/src/engine/lifecycle.ts +0 -302
- package/src/engine/router.ts +0 -16
- package/src/engine/waiter.ts +0 -188
package/src/client/Popover.tsx
DELETED
|
@@ -1,361 +0,0 @@
|
|
|
1
|
-
import React, { useEffect, useRef, useState, useCallback, useMemo, useContext } from "react";
|
|
2
|
-
import { createPortal } from "react-dom";
|
|
3
|
-
import { getPopoverPosition, pxWidth } from "./popoverPosition";
|
|
4
|
-
import { AnnotationEditor, AttachButton } from "./AnnotationEditor";
|
|
5
|
-
import { SessionContext } from "#canvas/runtime";
|
|
6
|
-
import { useCanvasHost } from "./hostContext";
|
|
7
|
-
import { hostAcceptsUploads } from "./utils";
|
|
8
|
-
|
|
9
|
-
interface PopoverProps {
|
|
10
|
-
anchorEl: HTMLElement;
|
|
11
|
-
scrollContainer?: HTMLElement | null;
|
|
12
|
-
onClose: () => void;
|
|
13
|
-
/** Selectors that should not trigger outside-click close */
|
|
14
|
-
ignoreSelector?: string;
|
|
15
|
-
zIndex?: string;
|
|
16
|
-
padding?: string;
|
|
17
|
-
width?: string;
|
|
18
|
-
children: React.ReactNode;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function Popover({ anchorEl, scrollContainer, onClose, ignoreSelector, zIndex = "50", padding = "12px", width = "280px", children }: PopoverProps) {
|
|
22
|
-
const popRef = useRef<HTMLDivElement>(null);
|
|
23
|
-
const onCloseRef = useRef(onClose);
|
|
24
|
-
onCloseRef.current = onClose;
|
|
25
|
-
// Placed by the width it is about to be rendered at, or it overflows the edge
|
|
26
|
-
// it was supposed to be clamped away from.
|
|
27
|
-
const { style: posStyle, parent } = getPopoverPosition(anchorEl, scrollContainer, pxWidth(width));
|
|
28
|
-
|
|
29
|
-
useEffect(() => {
|
|
30
|
-
const timer = setTimeout(() => {
|
|
31
|
-
const handler = (e: MouseEvent) => {
|
|
32
|
-
if (popRef.current && !popRef.current.contains(e.target as Node)) {
|
|
33
|
-
if (ignoreSelector && (e.target as HTMLElement).closest?.(ignoreSelector)) return;
|
|
34
|
-
onCloseRef.current();
|
|
35
|
-
document.removeEventListener("mousedown", handler);
|
|
36
|
-
}
|
|
37
|
-
};
|
|
38
|
-
document.addEventListener("mousedown", handler);
|
|
39
|
-
return () => document.removeEventListener("mousedown", handler);
|
|
40
|
-
}, 0);
|
|
41
|
-
return () => clearTimeout(timer);
|
|
42
|
-
}, [ignoreSelector]);
|
|
43
|
-
|
|
44
|
-
return createPortal(
|
|
45
|
-
<div
|
|
46
|
-
ref={popRef}
|
|
47
|
-
style={{
|
|
48
|
-
...posStyle,
|
|
49
|
-
zIndex,
|
|
50
|
-
width,
|
|
51
|
-
background: "var(--color-bg-elevated)",
|
|
52
|
-
border: "1px solid var(--color-border-hover)",
|
|
53
|
-
borderRadius: "8px",
|
|
54
|
-
boxShadow: "0 4px 12px var(--color-shadow)",
|
|
55
|
-
padding,
|
|
56
|
-
fontFamily: "'Inter', sans-serif",
|
|
57
|
-
}}
|
|
58
|
-
>
|
|
59
|
-
{children}
|
|
60
|
-
</div>,
|
|
61
|
-
parent,
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/* ── Create/Edit annotation popover ────────────────────────── */
|
|
66
|
-
|
|
67
|
-
interface AnnotationPopoverProps {
|
|
68
|
-
anchorEl: HTMLElement;
|
|
69
|
-
scrollContainer?: HTMLElement | null;
|
|
70
|
-
snippet: string;
|
|
71
|
-
/** Max length for displayed snippet (default 100) */
|
|
72
|
-
truncateAt?: number;
|
|
73
|
-
/** Custom header instead of the default quoted snippet */
|
|
74
|
-
header?: React.ReactNode;
|
|
75
|
-
/** Initial note text (for edit mode) */
|
|
76
|
-
initialNote?: string;
|
|
77
|
-
/** Initial images (for edit mode) */
|
|
78
|
-
initialImages?: string[];
|
|
79
|
-
/** Called on confirm — passes note and images */
|
|
80
|
-
onConfirm: (note: string, images: string[]) => void;
|
|
81
|
-
/** Called on cancel / close without saving */
|
|
82
|
-
onCancel: () => void;
|
|
83
|
-
/** Called when annotation should be deleted (edit mode) */
|
|
84
|
-
onDelete?: () => void;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const SUGGESTION_GROUPS: { label: string; items: string[] }[] = [
|
|
88
|
-
{ label: "Clarity", items: ["Unclear", "Why?", "Needs example", "Missing context"] },
|
|
89
|
-
{ label: "Scope", items: ["Overkill", "Out of scope", "Important", "Risky"] },
|
|
90
|
-
{ label: "Action", items: ["Simplify", "Rethink", "Expand on this", "Skip this"] },
|
|
91
|
-
];
|
|
92
|
-
const ALL_SUGGESTIONS = SUGGESTION_GROUPS.flatMap((g) => g.items);
|
|
93
|
-
|
|
94
|
-
export function AnnotationPopover({
|
|
95
|
-
anchorEl, scrollContainer, snippet, truncateAt = 100, header,
|
|
96
|
-
initialNote = "", initialImages, onConfirm, onCancel, onDelete,
|
|
97
|
-
}: AnnotationPopoverProps) {
|
|
98
|
-
const sessionId = useContext(SessionContext);
|
|
99
|
-
// The footer draws the editor's attach button itself, so it owes the same
|
|
100
|
-
// check the editor makes: no upload endpoint, no button.
|
|
101
|
-
const host = useCanvasHost();
|
|
102
|
-
const canAttach = hostAcceptsUploads(host);
|
|
103
|
-
const isEditMode = initialNote !== "" || !!onDelete;
|
|
104
|
-
const [note, setNote] = useState(initialNote);
|
|
105
|
-
const [images, setImages] = useState<string[]>(initialImages || []);
|
|
106
|
-
const [selectedIdx, setSelectedIdx] = useState<number | null>(null);
|
|
107
|
-
const [suppressed, setSuppressed] = useState(isEditMode);
|
|
108
|
-
const [deleteHover, setDeleteHover] = useState(false);
|
|
109
|
-
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
|
110
|
-
const openFilePickerRef = useRef<(() => void) | null>(null);
|
|
111
|
-
|
|
112
|
-
const submit = useCallback(() => {
|
|
113
|
-
if (note.trim() || images.length > 0) onConfirm(note.trim(), images);
|
|
114
|
-
else if (isEditMode && onDelete) onDelete();
|
|
115
|
-
else onCancel();
|
|
116
|
-
}, [note, images, onConfirm, onCancel, isEditMode, onDelete]);
|
|
117
|
-
|
|
118
|
-
const handleClose = useCallback(() => {
|
|
119
|
-
if (isEditMode) {
|
|
120
|
-
if (!note.trim() && images.length === 0 && onDelete) { onDelete(); onCancel(); return; }
|
|
121
|
-
onConfirm(note, images);
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
onCancel();
|
|
125
|
-
}, [note, images, isEditMode, onConfirm, onCancel, onDelete]);
|
|
126
|
-
|
|
127
|
-
// Suggestions (only in create mode)
|
|
128
|
-
const showSuggestions = !isEditMode;
|
|
129
|
-
const matches = note.trim().length > 0
|
|
130
|
-
? ALL_SUGGESTIONS.filter((s) => s.toLowerCase().startsWith(note.trim().toLowerCase()))
|
|
131
|
-
: ALL_SUGGESTIONS;
|
|
132
|
-
const filtered = !showSuggestions || suppressed ? [] : matches.length === 1 && matches[0]?.toLowerCase() === note.trim().toLowerCase() ? [] : matches;
|
|
133
|
-
const matchSet = new Set(filtered);
|
|
134
|
-
|
|
135
|
-
const filteredGroups = SUGGESTION_GROUPS
|
|
136
|
-
.map((g) => ({ label: g.label, items: g.items.filter((s) => matchSet.has(s)) }))
|
|
137
|
-
.filter((g) => g.items.length > 0);
|
|
138
|
-
|
|
139
|
-
const colItems = filteredGroups.map((g) => g.items);
|
|
140
|
-
/** All the arrow-key math indexes columns; out of range simply means empty. */
|
|
141
|
-
const colLength = (c: number) => colItems[c]?.length ?? 0;
|
|
142
|
-
const flatFiltered = colItems.flat();
|
|
143
|
-
|
|
144
|
-
const selectedCol = useMemo(() => {
|
|
145
|
-
if (selectedIdx === null) return 0;
|
|
146
|
-
let count = 0;
|
|
147
|
-
for (let c = 0; c < colItems.length; c++) {
|
|
148
|
-
if (selectedIdx < count + colLength(c)) return c;
|
|
149
|
-
count += colLength(c);
|
|
150
|
-
}
|
|
151
|
-
return 0;
|
|
152
|
-
}, [selectedIdx, colItems]);
|
|
153
|
-
const selectedRow = useMemo(() => {
|
|
154
|
-
if (selectedIdx === null) return 0;
|
|
155
|
-
let count = 0;
|
|
156
|
-
for (let c = 0; c < selectedCol; c++) count += colLength(c);
|
|
157
|
-
return selectedIdx - count;
|
|
158
|
-
}, [selectedIdx, selectedCol, colItems]);
|
|
159
|
-
|
|
160
|
-
const colRowToFlat = (col: number, row: number) => {
|
|
161
|
-
let idx = 0;
|
|
162
|
-
for (let c = 0; c < col; c++) idx += colLength(c);
|
|
163
|
-
return idx + row;
|
|
164
|
-
};
|
|
165
|
-
|
|
166
|
-
const [prevNote, setPrevNote] = useState(note);
|
|
167
|
-
if (note !== prevNote) {
|
|
168
|
-
setPrevNote(note);
|
|
169
|
-
setSelectedIdx(null);
|
|
170
|
-
setSuppressed(isEditMode);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const applySuggestion = useCallback((s: string) => {
|
|
174
|
-
setNote(s);
|
|
175
|
-
}, []);
|
|
176
|
-
|
|
177
|
-
const truncated = snippet.length > truncateAt ? snippet.slice(0, truncateAt) + "..." : snippet;
|
|
178
|
-
|
|
179
|
-
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
180
|
-
if (flatFiltered.length > 0 && (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "ArrowLeft" || e.key === "ArrowRight")) {
|
|
181
|
-
e.preventDefault();
|
|
182
|
-
if (selectedIdx === null) { setSelectedIdx(0); return; }
|
|
183
|
-
if (e.key === "ArrowDown") {
|
|
184
|
-
const newRow = Math.min(selectedRow + 1, colLength(selectedCol) - 1);
|
|
185
|
-
setSelectedIdx(colRowToFlat(selectedCol, newRow));
|
|
186
|
-
} else if (e.key === "ArrowUp") {
|
|
187
|
-
const newRow = Math.max(selectedRow - 1, 0);
|
|
188
|
-
setSelectedIdx(colRowToFlat(selectedCol, newRow));
|
|
189
|
-
} else if (e.key === "ArrowRight") {
|
|
190
|
-
const newCol = Math.min(selectedCol + 1, colItems.length - 1);
|
|
191
|
-
const newRow = Math.min(selectedRow, colLength(newCol) - 1);
|
|
192
|
-
setSelectedIdx(colRowToFlat(newCol, newRow));
|
|
193
|
-
} else if (e.key === "ArrowLeft") {
|
|
194
|
-
const newCol = Math.max(selectedCol - 1, 0);
|
|
195
|
-
const newRow = Math.min(selectedRow, colLength(newCol) - 1);
|
|
196
|
-
setSelectedIdx(colRowToFlat(newCol, newRow));
|
|
197
|
-
}
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
const selected = selectedIdx === null ? undefined : flatFiltered[selectedIdx];
|
|
201
|
-
if (selected !== undefined) {
|
|
202
|
-
if (e.key === "Tab") { e.preventDefault(); applySuggestion(selected); return; }
|
|
203
|
-
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); onConfirm(selected, images); return; }
|
|
204
|
-
}
|
|
205
|
-
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); return; }
|
|
206
|
-
if (e.key === "Escape") {
|
|
207
|
-
if (flatFiltered.length > 0 && !suppressed) { setSuppressed(true); }
|
|
208
|
-
else if (isEditMode) { handleClose(); }
|
|
209
|
-
else { onCancel(); }
|
|
210
|
-
}
|
|
211
|
-
}, [flatFiltered, selectedIdx, selectedRow, selectedCol, colItems, colRowToFlat, applySuggestion, onConfirm, images, submit, suppressed, isEditMode, handleClose, onCancel]);
|
|
212
|
-
|
|
213
|
-
return (
|
|
214
|
-
<Popover
|
|
215
|
-
anchorEl={anchorEl}
|
|
216
|
-
scrollContainer={scrollContainer}
|
|
217
|
-
onClose={isEditMode ? handleClose : onCancel}
|
|
218
|
-
ignoreSelector={isEditMode ? "[data-annotation-id]" : undefined}
|
|
219
|
-
zIndex={isEditMode ? "60" : "50"}
|
|
220
|
-
padding={isEditMode ? "10px 12px" : "12px"}
|
|
221
|
-
width="420px"
|
|
222
|
-
>
|
|
223
|
-
{!isEditMode && (header ?? (
|
|
224
|
-
<div style={{ fontSize: "11px", color: "var(--color-text-tertiary)", fontStyle: "italic", marginBottom: "8px", lineHeight: "1.4", display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden" }}>
|
|
225
|
-
“{truncated}”
|
|
226
|
-
</div>
|
|
227
|
-
))}
|
|
228
|
-
|
|
229
|
-
<AnnotationEditor
|
|
230
|
-
note={note}
|
|
231
|
-
onNoteChange={setNote}
|
|
232
|
-
images={images}
|
|
233
|
-
onAddImage={(path) => setImages((prev) => [...prev, path])}
|
|
234
|
-
onRemoveImage={(path) => setImages((prev) => prev.filter((p) => p !== path))}
|
|
235
|
-
sessionId={sessionId}
|
|
236
|
-
autoFocus
|
|
237
|
-
autoResize={isEditMode}
|
|
238
|
-
textareaRef={textareaRef}
|
|
239
|
-
onKeyDown={handleKeyDown}
|
|
240
|
-
attachButton="none"
|
|
241
|
-
openFilePickerRef={openFilePickerRef}
|
|
242
|
-
textareaStyle={isEditMode ? {
|
|
243
|
-
width: "100%", background: "transparent", border: "none",
|
|
244
|
-
color: "var(--color-text-primary)", fontFamily: "'Inter', sans-serif",
|
|
245
|
-
fontSize: "13px", lineHeight: "1.5", resize: "none", outline: "none",
|
|
246
|
-
overflow: "hidden",
|
|
247
|
-
} : {
|
|
248
|
-
width: "100%", minHeight: "60px", background: "transparent", border: "none",
|
|
249
|
-
color: "var(--color-text-primary)", fontFamily: "'Inter', sans-serif",
|
|
250
|
-
fontSize: "13px", lineHeight: "1.5", resize: "vertical", outline: "none",
|
|
251
|
-
}}
|
|
252
|
-
placeholder="Add your note..."
|
|
253
|
-
/>
|
|
254
|
-
|
|
255
|
-
{/* Action buttons */}
|
|
256
|
-
{/* Action buttons */}
|
|
257
|
-
<div style={{ display: "flex", alignItems: "center", gap: "8px", marginTop: isEditMode ? "6px" : "8px" }}>
|
|
258
|
-
{canAttach && <AttachButton onClick={() => openFilePickerRef.current?.()} size={14} />}
|
|
259
|
-
<div style={{ flex: 1 }} />
|
|
260
|
-
{isEditMode ? (
|
|
261
|
-
<button
|
|
262
|
-
onClick={() => { if (onDelete) onDelete(); onCancel(); }}
|
|
263
|
-
onMouseEnter={() => setDeleteHover(true)}
|
|
264
|
-
onMouseLeave={() => setDeleteHover(false)}
|
|
265
|
-
style={{ fontSize: "11px", color: deleteHover ? "var(--color-accent-red)" : "var(--color-text-tertiary)", background: "none", border: "none", cursor: "pointer", padding: "2px 0", fontFamily: "'Inter', sans-serif" }}
|
|
266
|
-
>Delete</button>
|
|
267
|
-
) : (
|
|
268
|
-
<>
|
|
269
|
-
<button onClick={onCancel} style={{ fontSize: "11px", color: "var(--color-text-tertiary)", background: "none", border: "none", cursor: "pointer", padding: "4px 12px", fontFamily: "'Inter', sans-serif" }}>Cancel</button>
|
|
270
|
-
<button onClick={submit} style={{ fontSize: "11px", fontWeight: 500, padding: "4px 12px", borderRadius: "6px", background: "var(--color-highlight-bg)", color: "var(--color-text-primary)", border: "1px solid var(--color-highlight-border)", cursor: "pointer", fontFamily: "'Inter', sans-serif" }}>Add</button>
|
|
271
|
-
</>
|
|
272
|
-
)}
|
|
273
|
-
</div>
|
|
274
|
-
|
|
275
|
-
{/* Suggestions (create mode only) */}
|
|
276
|
-
{flatFiltered.length > 0 && (
|
|
277
|
-
<div style={{ marginTop: "6px", borderTop: "1px solid var(--color-border-subtle)", paddingTop: "8px", display: "flex", gap: "4px" }}>
|
|
278
|
-
{filteredGroups.map((group) => (
|
|
279
|
-
<div key={group.label} style={{ flex: 1 }}>
|
|
280
|
-
<div style={{ fontSize: "10px", fontWeight: 600, color: "var(--color-text-disabled)", textTransform: "uppercase", letterSpacing: "0.05em", padding: "2px 6px", marginBottom: "2px", fontFamily: "'Inter', sans-serif" }}>
|
|
281
|
-
{group.label}
|
|
282
|
-
</div>
|
|
283
|
-
{group.items.map((s) => {
|
|
284
|
-
const flatIdx = flatFiltered.indexOf(s);
|
|
285
|
-
return (
|
|
286
|
-
<div
|
|
287
|
-
key={s}
|
|
288
|
-
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); applySuggestion(s); }}
|
|
289
|
-
onMouseEnter={() => setSelectedIdx(flatIdx)}
|
|
290
|
-
style={{
|
|
291
|
-
padding: "3px 6px",
|
|
292
|
-
fontSize: "12px",
|
|
293
|
-
borderRadius: "4px",
|
|
294
|
-
fontFamily: "'Inter', sans-serif",
|
|
295
|
-
color: flatIdx === selectedIdx ? "var(--color-text-primary)" : "var(--color-text-secondary)",
|
|
296
|
-
background: flatIdx === selectedIdx ? "var(--color-highlight-selected)" : "transparent",
|
|
297
|
-
cursor: "pointer",
|
|
298
|
-
}}
|
|
299
|
-
>
|
|
300
|
-
<span style={{ fontWeight: 500 }}>{s.slice(0, note.trim().length)}</span>{s.slice(note.trim().length)}
|
|
301
|
-
</div>
|
|
302
|
-
);
|
|
303
|
-
})}
|
|
304
|
-
</div>
|
|
305
|
-
))}
|
|
306
|
-
</div>
|
|
307
|
-
)}
|
|
308
|
-
</Popover>
|
|
309
|
-
);
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
/* ── Legacy exports for backwards compatibility ───────────── */
|
|
313
|
-
|
|
314
|
-
interface AnnotationCreatePopoverProps {
|
|
315
|
-
anchorEl: HTMLElement;
|
|
316
|
-
scrollContainer?: HTMLElement | null;
|
|
317
|
-
snippet: string;
|
|
318
|
-
truncateAt?: number;
|
|
319
|
-
header?: React.ReactNode;
|
|
320
|
-
onAdd: (note: string, images?: string[]) => void;
|
|
321
|
-
onCancel: () => void;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
export function AnnotationCreatePopover({ anchorEl, scrollContainer, snippet, truncateAt, header, onAdd, onCancel }: AnnotationCreatePopoverProps) {
|
|
325
|
-
return (
|
|
326
|
-
<AnnotationPopover
|
|
327
|
-
anchorEl={anchorEl}
|
|
328
|
-
scrollContainer={scrollContainer}
|
|
329
|
-
snippet={snippet}
|
|
330
|
-
truncateAt={truncateAt}
|
|
331
|
-
header={header}
|
|
332
|
-
onConfirm={(note, images) => onAdd(note, images)}
|
|
333
|
-
onCancel={onCancel}
|
|
334
|
-
/>
|
|
335
|
-
);
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
interface AnnotationEditPopoverProps {
|
|
339
|
-
anchorEl: HTMLElement;
|
|
340
|
-
scrollContainer?: HTMLElement | null;
|
|
341
|
-
initialNote: string;
|
|
342
|
-
initialImages?: string[];
|
|
343
|
-
onUpdate: (note: string, images: string[]) => void;
|
|
344
|
-
onDelete: () => void;
|
|
345
|
-
onClose: () => void;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
export function AnnotationEditPopover({ anchorEl, scrollContainer, initialNote, initialImages, onUpdate, onDelete, onClose }: AnnotationEditPopoverProps) {
|
|
349
|
-
return (
|
|
350
|
-
<AnnotationPopover
|
|
351
|
-
anchorEl={anchorEl}
|
|
352
|
-
scrollContainer={scrollContainer}
|
|
353
|
-
snippet=""
|
|
354
|
-
initialNote={initialNote}
|
|
355
|
-
initialImages={initialImages}
|
|
356
|
-
onConfirm={(note, images) => { onUpdate(note, images); onClose(); }}
|
|
357
|
-
onDelete={onDelete}
|
|
358
|
-
onCancel={onClose}
|
|
359
|
-
/>
|
|
360
|
-
);
|
|
361
|
-
}
|
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
import type { AnnotationContext } from "./AnnotationProvider";
|
|
2
|
-
import { isIndexedText } from "./highlightRange";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Extract disambiguation context from a Range:
|
|
6
|
-
* - surrounding text (before/after the selection)
|
|
7
|
-
* - hierarchy path (Section title → Task label)
|
|
8
|
-
*/
|
|
9
|
-
export function extractContext(range: Range, planContainer: HTMLElement): AnnotationContext {
|
|
10
|
-
const before = getSurroundingText(range, "before", 60);
|
|
11
|
-
const after = getSurroundingText(range, "after", 60);
|
|
12
|
-
const hierarchy = getHierarchy(range.startContainer, planContainer);
|
|
13
|
-
|
|
14
|
-
return { before, after, hierarchy };
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function getSurroundingText(range: Range, direction: "before" | "after", maxChars: number): string {
|
|
18
|
-
const block = getBlockParent(direction === "before" ? range.startContainer : range.endContainer);
|
|
19
|
-
if (!block) return "";
|
|
20
|
-
|
|
21
|
-
let text = "";
|
|
22
|
-
|
|
23
|
-
if (direction === "before") {
|
|
24
|
-
const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
|
|
25
|
-
let node: Text | null;
|
|
26
|
-
while ((node = walker.nextNode() as Text | null)) {
|
|
27
|
-
if (node === range.startContainer) {
|
|
28
|
-
text += (node.textContent || "").slice(0, range.startOffset);
|
|
29
|
-
break;
|
|
30
|
-
}
|
|
31
|
-
if (!isIndexedText(node)) continue;
|
|
32
|
-
text += node.textContent || "";
|
|
33
|
-
}
|
|
34
|
-
if (text.length > maxChars) {
|
|
35
|
-
text = text.slice(-maxChars);
|
|
36
|
-
const spaceIdx = text.indexOf(" ");
|
|
37
|
-
if (spaceIdx > 0) text = text.slice(spaceIdx + 1);
|
|
38
|
-
}
|
|
39
|
-
} else {
|
|
40
|
-
const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
|
|
41
|
-
let found = false;
|
|
42
|
-
let node: Text | null;
|
|
43
|
-
while ((node = walker.nextNode() as Text | null)) {
|
|
44
|
-
if (node === range.endContainer) {
|
|
45
|
-
text += (node.textContent || "").slice(range.endOffset);
|
|
46
|
-
found = true;
|
|
47
|
-
continue;
|
|
48
|
-
}
|
|
49
|
-
if (found) {
|
|
50
|
-
if (!isIndexedText(node)) continue;
|
|
51
|
-
text += node.textContent || "";
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
if (text.length > maxChars) {
|
|
55
|
-
text = text.slice(0, maxChars);
|
|
56
|
-
const spaceIdx = text.lastIndexOf(" ");
|
|
57
|
-
if (spaceIdx > 0) text = text.slice(0, spaceIdx);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
return text.trim();
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function getBlockParent(node: Node): Element | null {
|
|
65
|
-
const blockTags = new Set(["DIV", "P", "LI", "PRE", "BLOCKQUOTE", "SECTION", "ARTICLE"]);
|
|
66
|
-
let el = node.parentElement;
|
|
67
|
-
while (el) {
|
|
68
|
-
if (blockTags.has(el.tagName)) return el;
|
|
69
|
-
el = el.parentElement;
|
|
70
|
-
}
|
|
71
|
-
return null;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function getHierarchy(node: Node, planContainer: HTMLElement): string[] {
|
|
75
|
-
const path: string[] = [];
|
|
76
|
-
let el = node instanceof Element ? node : node.parentElement;
|
|
77
|
-
|
|
78
|
-
while (el && el !== planContainer) {
|
|
79
|
-
const taskId = el.getAttribute?.("data-task-id");
|
|
80
|
-
if (taskId) {
|
|
81
|
-
const label = el.querySelector(".font-semibold");
|
|
82
|
-
if (label) path.unshift(label.textContent?.trim() || taskId);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const heading = ownHeading(el);
|
|
86
|
-
if (heading) path.unshift(heading);
|
|
87
|
-
|
|
88
|
-
el = el.parentElement;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
return path;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* The heading this block owns, if any. Section carries its title as an
|
|
96
|
-
* attribute; hosts that render their own headings only have the markup, and
|
|
97
|
-
* there the heading must sit directly under the block's own header row —
|
|
98
|
-
* anything deeper belongs to a nested block. Detecting one heading and then
|
|
99
|
-
* reading another is what put a neighbouring section's title in the path.
|
|
100
|
-
*/
|
|
101
|
-
function ownHeading(el: Element): string | null {
|
|
102
|
-
if (el.getAttribute("data-md") === "section") {
|
|
103
|
-
const title = el.getAttribute("data-md-title");
|
|
104
|
-
if (title) return title;
|
|
105
|
-
}
|
|
106
|
-
if (el.tagName !== "DIV") return null;
|
|
107
|
-
const heading = el.querySelector(":scope > button > .font-heading, :scope > div > .font-heading");
|
|
108
|
-
return heading?.textContent?.trim() || null;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Format a snippet with surrounding context for disambiguation.
|
|
113
|
-
* Short snippets get before/after context.
|
|
114
|
-
*/
|
|
115
|
-
export function formatSnippetInContext(ann: { snippet: string; context?: AnnotationContext }): string {
|
|
116
|
-
const snippet = ann.snippet.trim();
|
|
117
|
-
const ctx = ann.context;
|
|
118
|
-
|
|
119
|
-
const isShort = snippet.length < 30;
|
|
120
|
-
|
|
121
|
-
if (ctx && isShort && (ctx.before || ctx.after)) {
|
|
122
|
-
const parts: string[] = [];
|
|
123
|
-
if (ctx.before) parts.push(`...${ctx.before} `);
|
|
124
|
-
parts.push(`**${snippet}**`);
|
|
125
|
-
if (ctx.after) parts.push(` ${ctx.after}...`);
|
|
126
|
-
return parts.join("");
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
return snippet;
|
|
130
|
-
}
|
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
import type { AnnotationAttachment, AnnotationContext } from "./runtime";
|
|
2
|
-
import { formatSnippetInContext } from "./annotationContext";
|
|
3
|
-
import { describeSnippet } from "./annotationDom";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* What rendering one annotation reads off it. Every `Annotation` satisfies this,
|
|
7
|
-
* and so can a record that is not one: how a single annotation reads as markdown
|
|
8
|
-
* is the half of feedback that belongs to no particular document format.
|
|
9
|
-
*/
|
|
10
|
-
export interface RenderableAnnotation {
|
|
11
|
-
snippet: string;
|
|
12
|
-
note: string;
|
|
13
|
-
/** Set when the annotation points into a file. With `context.lineStart` it
|
|
14
|
-
* switches the quote to numbered source lines. */
|
|
15
|
-
filePath?: string;
|
|
16
|
-
context?: AnnotationContext;
|
|
17
|
-
/** Attached images as bare paths/URLs — what the local editor writes. */
|
|
18
|
-
images?: string[];
|
|
19
|
-
/** Attached files carried on the wire — what a shared canvas sends back.
|
|
20
|
-
* A host may populate either field; both are rendered. */
|
|
21
|
-
attachments?: AnnotationAttachment[];
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* One annotation as markdown: what was annotated, quoted, then the note and any
|
|
26
|
-
* attachments under it. Ends on a blank line so entries stack.
|
|
27
|
-
*
|
|
28
|
-
* The caller owns everything around it — headings, grouping, ordering.
|
|
29
|
-
*/
|
|
30
|
-
export function renderAnnotation(ann: RenderableAnnotation): string {
|
|
31
|
-
const lines: string[] = [];
|
|
32
|
-
const snippet = ann.snippet.trim();
|
|
33
|
-
const ctx = ann.context;
|
|
34
|
-
// A snippet that does not read as text — a drawn region — says what it points
|
|
35
|
-
// at in words instead, since quoting its encoding tells an agent nothing.
|
|
36
|
-
const described = describeSnippet(snippet);
|
|
37
|
-
|
|
38
|
-
if (described !== snippet) {
|
|
39
|
-
lines.push(`> ${described}`);
|
|
40
|
-
} else if (ann.filePath && ctx && ctx.lineStart != null) {
|
|
41
|
-
// File annotation with line numbers
|
|
42
|
-
const lineStart = ctx.lineStart;
|
|
43
|
-
const lineEnd = ctx.lineEnd ?? lineStart;
|
|
44
|
-
const snippetLines = snippet.split("\n");
|
|
45
|
-
const isShort = snippet.length < 30 && lineStart === lineEnd;
|
|
46
|
-
|
|
47
|
-
if (isShort && (ctx.before || ctx.after)) {
|
|
48
|
-
// Short snippet on single line — show full line context
|
|
49
|
-
const expanded = formatSnippetInContext(ann);
|
|
50
|
-
lines.push(`> L${lineStart}: ${expanded}`);
|
|
51
|
-
} else if (snippetLines.length <= 6) {
|
|
52
|
-
for (let i = 0; i < snippetLines.length; i++) {
|
|
53
|
-
lines.push(`> ${lineStart + i} | ${snippetLines[i]}`);
|
|
54
|
-
}
|
|
55
|
-
} else {
|
|
56
|
-
for (let i = 0; i < 3; i++) {
|
|
57
|
-
lines.push(`> ${lineStart + i} | ${snippetLines[i]}`);
|
|
58
|
-
}
|
|
59
|
-
lines.push(`> ... (${snippetLines.length} lines)`);
|
|
60
|
-
for (let i = snippetLines.length - 3; i < snippetLines.length; i++) {
|
|
61
|
-
lines.push(`> ${lineStart + i} | ${snippetLines[i]}`);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
} else {
|
|
65
|
-
// Plan annotations or file annotations without line info
|
|
66
|
-
const context = formatSnippetInContext(ann);
|
|
67
|
-
if (snippet.split("\n").length <= 3) {
|
|
68
|
-
lines.push(`> ${context.split("\n").join("\n> ")}`);
|
|
69
|
-
} else {
|
|
70
|
-
const snippetLines = snippet.split("\n");
|
|
71
|
-
lines.push(`> ${snippetLines[0]}`);
|
|
72
|
-
lines.push(`> ... (${snippetLines.length} lines)`);
|
|
73
|
-
lines.push(`> ${snippetLines[snippetLines.length - 1]}`);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Comment
|
|
78
|
-
if (ann.note.trim()) {
|
|
79
|
-
lines.push("");
|
|
80
|
-
lines.push(ann.note.trim());
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// Attached images. Both fields are read: the local editor writes `images`,
|
|
84
|
-
// a shared canvas's reviewer comes back with `attachments`, and an agent that
|
|
85
|
-
// sees only one of them is looking at feedback with a hole in it.
|
|
86
|
-
const attached = [...(ann.images ?? [])];
|
|
87
|
-
const seen = new Set(attached);
|
|
88
|
-
for (const attachment of ann.attachments ?? []) {
|
|
89
|
-
if (seen.has(attachment.url)) continue;
|
|
90
|
-
seen.add(attachment.url);
|
|
91
|
-
attached.push(attachment.url);
|
|
92
|
-
}
|
|
93
|
-
if (attached.length) {
|
|
94
|
-
lines.push("");
|
|
95
|
-
for (const url of attached) {
|
|
96
|
-
lines.push(``);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
lines.push("");
|
|
101
|
-
return lines.join("\n");
|
|
102
|
-
}
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* How a snippet names something on the page.
|
|
3
|
-
*
|
|
4
|
-
* `Annotation.snippet` is the only field that ties an annotation to a domain —
|
|
5
|
-
* everything else (draft persistence, sidebar, popover, notes, attachments,
|
|
6
|
-
* markdown, submit) works on any annotation whatever it points at. A target is
|
|
7
|
-
* that one tie, pulled out as a strategy: give the kernel a new target and it
|
|
8
|
-
* can annotate a new kind of thing without another line changing.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
/** What a target may read off the annotation it resolves — DOM identity, not feedback. */
|
|
12
|
-
export interface TargetAnnotation {
|
|
13
|
-
id: string;
|
|
14
|
-
snippet: string;
|
|
15
|
-
context?: { before: string; after: string; hierarchy: string[] };
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export interface AnnotationTarget<L extends object> {
|
|
19
|
-
/** Names the strategy. */
|
|
20
|
-
readonly kind: string;
|
|
21
|
-
/** Read a snippet this target wrote. Null means the snippet is not its own. */
|
|
22
|
-
parse(snippet: string): L | null;
|
|
23
|
-
/** Write the locator back into the snippet key. The exact inverse of `parse`. */
|
|
24
|
-
format(locator: L): string;
|
|
25
|
-
/**
|
|
26
|
-
* The element the locator names, searched inside `root`. Targets whose
|
|
27
|
-
* annotation lives in DOM this target itself drew — an inline mark, an
|
|
28
|
-
* overlay box — return null here and are resolved by their annotation id
|
|
29
|
-
* instead; see findAnnotationElement.
|
|
30
|
-
*/
|
|
31
|
-
find(locator: L, root: ParentNode): HTMLElement | null;
|
|
32
|
-
/**
|
|
33
|
-
* Put this annotation's own decoration back into `root`, if it is not there
|
|
34
|
-
* already. Called after every render, for every annotation, so it must be
|
|
35
|
-
* idempotent and cheap on a snippet it does not own.
|
|
36
|
-
*/
|
|
37
|
-
restore?(locator: L, root: HTMLElement, ann: TargetAnnotation): void;
|
|
38
|
-
/** A one-line label a reader (or a coding agent) can act on. */
|
|
39
|
-
describe?(locator: L): string;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** An AnnotationTarget with its locator type sealed inside, so targets whose
|
|
43
|
-
* locators have nothing in common can share one list. */
|
|
44
|
-
export interface SealedAnnotationTarget {
|
|
45
|
-
readonly kind: string;
|
|
46
|
-
owns(snippet: string): boolean;
|
|
47
|
-
find(snippet: string, root: ParentNode): HTMLElement | null;
|
|
48
|
-
restore(root: HTMLElement, ann: TargetAnnotation): void;
|
|
49
|
-
describe(snippet: string): string | null;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Close over the locator type, so parse and find can never be called with
|
|
53
|
-
* a locator the other one did not produce. */
|
|
54
|
-
export function sealTarget<L extends object>(target: AnnotationTarget<L>): SealedAnnotationTarget {
|
|
55
|
-
return {
|
|
56
|
-
kind: target.kind,
|
|
57
|
-
owns: (snippet) => target.parse(snippet) !== null,
|
|
58
|
-
find: (snippet, root) => {
|
|
59
|
-
const locator = target.parse(snippet);
|
|
60
|
-
return locator === null ? null : target.find(locator, root);
|
|
61
|
-
},
|
|
62
|
-
restore: (root, ann) => {
|
|
63
|
-
const locator = target.parse(ann.snippet);
|
|
64
|
-
if (locator === null) return;
|
|
65
|
-
target.restore?.(locator, root, ann);
|
|
66
|
-
},
|
|
67
|
-
describe: (snippet) => {
|
|
68
|
-
const locator = target.parse(snippet);
|
|
69
|
-
if (locator === null || !target.describe) return null;
|
|
70
|
-
return target.describe(locator);
|
|
71
|
-
},
|
|
72
|
-
};
|
|
73
|
-
}
|