@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,250 @@
|
|
|
1
|
+
// The canvas document format: revisions, responses, required fields, sections.
|
|
2
|
+
// Rendering one annotation is host-agnostic and lives in ./annotationMarkdown.
|
|
3
|
+
import type { Annotation, PlanResponse, FeedbackEntry } from "./AnnotationProvider";
|
|
4
|
+
import { renderAnnotation } from "./annotationMarkdown";
|
|
5
|
+
import { RESPONSE_ANNOTATION_PATH } from "./utils";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Drop answers whose question is not in this revision's canvas. A draft carried
|
|
9
|
+
* forward by carryUnsubmittedDraft() can hold an answer to a question the new
|
|
10
|
+
* revision removed; submitting that would report an answer the author was never
|
|
11
|
+
* asked for.
|
|
12
|
+
*
|
|
13
|
+
* `seenResponseIds` must be the sticky set of controls that have mounted at any
|
|
14
|
+
* point in this draft, never the currently-mounted ones — see canPruneResponses.
|
|
15
|
+
*/
|
|
16
|
+
export function pruneStaleResponses(
|
|
17
|
+
responses: Map<string, PlanResponse>,
|
|
18
|
+
seenResponseIds: ReadonlySet<string>,
|
|
19
|
+
): Map<string, PlanResponse> {
|
|
20
|
+
const out = new Map<string, PlanResponse>();
|
|
21
|
+
for (const [id, r] of responses) {
|
|
22
|
+
if (seenResponseIds.has(id)) out.set(id, r);
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Whether the set of seen response ids is complete enough to prune against.
|
|
29
|
+
*
|
|
30
|
+
* An id is only evidence of absence once every canvas that could host it has
|
|
31
|
+
* rendered. Until then "not seen" means "not looked at yet" — the host opens
|
|
32
|
+
* multi-canvas sessions on the overview, where no canvas is mounted at all, so
|
|
33
|
+
* pruning early would throw away every answer the author gave.
|
|
34
|
+
*/
|
|
35
|
+
export function canPruneResponses(
|
|
36
|
+
canvasFiles: readonly string[] | undefined,
|
|
37
|
+
renderedCanvases: ReadonlySet<string>,
|
|
38
|
+
): boolean {
|
|
39
|
+
if (!canvasFiles || canvasFiles.length === 0) return false;
|
|
40
|
+
return canvasFiles.every((filename) => renderedCanvases.has(filename));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Generate human-readable markdown feedback from annotations and responses.
|
|
45
|
+
*/
|
|
46
|
+
export function generateMarkdown(
|
|
47
|
+
annotations: Annotation[],
|
|
48
|
+
generalNote: string,
|
|
49
|
+
responses?: Map<string, PlanResponse>,
|
|
50
|
+
feedbackEntries?: Map<string, FeedbackEntry>,
|
|
51
|
+
includedRemoteIds?: Set<string>,
|
|
52
|
+
): string {
|
|
53
|
+
const parts: string[] = [];
|
|
54
|
+
const included = annotations.filter((a) =>
|
|
55
|
+
a.source !== "remote" || (includedRemoteIds && includedRemoteIds.has(a.id))
|
|
56
|
+
);
|
|
57
|
+
const responseAnns = included.filter((a) => a.filePath === RESPONSE_ANNOTATION_PATH);
|
|
58
|
+
const canvasAnns = included.filter((a) => !a.filePath);
|
|
59
|
+
const fileAnns = included.filter((a) => a.filePath && a.filePath !== RESPONSE_ANNOTATION_PATH);
|
|
60
|
+
const resps = responses ? Array.from(responses.values()).filter((r) => hasValue(r)) : [];
|
|
61
|
+
const fbEntries = feedbackEntries
|
|
62
|
+
? Array.from(feedbackEntries.values()).filter((e) => e.markdown.trim())
|
|
63
|
+
: [];
|
|
64
|
+
|
|
65
|
+
// Responses section
|
|
66
|
+
const hasResponseSection = resps.length > 0 || fbEntries.length > 0;
|
|
67
|
+
if (hasResponseSection) {
|
|
68
|
+
parts.push("## Responses");
|
|
69
|
+
parts.push("");
|
|
70
|
+
for (const r of resps) {
|
|
71
|
+
parts.push(renderResponse(r));
|
|
72
|
+
}
|
|
73
|
+
for (const entry of fbEntries) {
|
|
74
|
+
parts.push(entry.markdown.trim());
|
|
75
|
+
parts.push("");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Response annotations (on agent response banner)
|
|
80
|
+
if (responseAnns.length > 0) {
|
|
81
|
+
parts.push("## Annotations on agent response");
|
|
82
|
+
parts.push("");
|
|
83
|
+
for (const ann of responseAnns) {
|
|
84
|
+
parts.push(renderAnnotation(ann));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Canvas annotations grouped by canvas file, then hierarchy
|
|
89
|
+
if (canvasAnns.length > 0) {
|
|
90
|
+
parts.push("## Canvas annotations");
|
|
91
|
+
parts.push("");
|
|
92
|
+
|
|
93
|
+
// Group by canvasFile
|
|
94
|
+
const byCanvas = new Map<string, Annotation[]>();
|
|
95
|
+
for (const ann of canvasAnns) {
|
|
96
|
+
const key = ann.canvasFile || "";
|
|
97
|
+
if (!byCanvas.has(key)) byCanvas.set(key, []);
|
|
98
|
+
byCanvas.get(key)!.push(ann);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const canvasKeys = [...byCanvas.keys()].sort();
|
|
102
|
+
const multipleCanvases = canvasKeys.length > 1 || (canvasKeys.length === 1 && canvasKeys[0] !== "");
|
|
103
|
+
|
|
104
|
+
for (const canvasKey of canvasKeys) {
|
|
105
|
+
const anns = byCanvas.get(canvasKey)!;
|
|
106
|
+
if (multipleCanvases && canvasKey) {
|
|
107
|
+
parts.push(`### ${canvasKey.replace(/\.jsx$/, "")}`);
|
|
108
|
+
parts.push("");
|
|
109
|
+
}
|
|
110
|
+
const grouped = groupByHierarchy(anns);
|
|
111
|
+
parts.push(renderHierarchyGroup(grouped));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// File annotations grouped by path
|
|
116
|
+
if (fileAnns.length > 0) {
|
|
117
|
+
const groups: Record<string, Annotation[]> = {};
|
|
118
|
+
for (const ann of fileAnns) {
|
|
119
|
+
const key = ann.filePath!;
|
|
120
|
+
if (!groups[key]) groups[key] = [];
|
|
121
|
+
groups[key].push(ann);
|
|
122
|
+
}
|
|
123
|
+
parts.push("## File annotations");
|
|
124
|
+
parts.push("");
|
|
125
|
+
for (const [filePath, anns] of Object.entries(groups)) {
|
|
126
|
+
parts.push(`### ${filePath}`);
|
|
127
|
+
parts.push("");
|
|
128
|
+
for (const ann of anns) {
|
|
129
|
+
parts.push(renderAnnotation(ann));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// General notes
|
|
135
|
+
if (generalNote.trim()) {
|
|
136
|
+
parts.push("## General notes");
|
|
137
|
+
parts.push("");
|
|
138
|
+
parts.push(generalNote.trim());
|
|
139
|
+
parts.push("");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return parts.join("\n").trim() + "\n";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function hasValue(r: PlanResponse): boolean {
|
|
146
|
+
if (r.note?.trim()) return true;
|
|
147
|
+
if (r.value === null || r.value === undefined) return false;
|
|
148
|
+
if (r.type === "text" && !(r.value as string).trim()) return false;
|
|
149
|
+
if (r.type === "checkbox" && (r.value as string[]).length === 0) return false;
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function getMissingRequired(responses: Map<string, PlanResponse>): PlanResponse[] {
|
|
154
|
+
const missing: PlanResponse[] = [];
|
|
155
|
+
for (const r of responses.values()) {
|
|
156
|
+
if (!r.required) continue;
|
|
157
|
+
if (r.value === null || r.value === undefined) { missing.push(r); continue; }
|
|
158
|
+
if (r.type === "text" && !(r.value as string).trim()) { missing.push(r); continue; }
|
|
159
|
+
if (r.type === "checkbox" && (r.value as string[]).length === 0) { missing.push(r); continue; }
|
|
160
|
+
}
|
|
161
|
+
return missing;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function getMissingRequiredFeedback(entries: Map<string, FeedbackEntry>): FeedbackEntry[] {
|
|
165
|
+
const missing: FeedbackEntry[] = [];
|
|
166
|
+
for (const entry of entries.values()) {
|
|
167
|
+
if (entry.required && !entry.markdown.trim()) {
|
|
168
|
+
missing.push(entry);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return missing;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Returns list of missing required field labels, or empty if all valid */
|
|
175
|
+
export function getMissingRequiredLabels(responses: Map<string, PlanResponse>, feedbackEntries: Map<string, FeedbackEntry>): string[] {
|
|
176
|
+
const missingResponses = getMissingRequired(responses);
|
|
177
|
+
const missingFeedback = getMissingRequiredFeedback(feedbackEntries);
|
|
178
|
+
return [
|
|
179
|
+
...missingResponses.map((r) => r.label),
|
|
180
|
+
...missingFeedback.map((e) => e.label || e.id),
|
|
181
|
+
];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function renderResponse(r: PlanResponse): string {
|
|
185
|
+
const lines: string[] = [];
|
|
186
|
+
lines.push(`**${r.label}**`);
|
|
187
|
+
|
|
188
|
+
switch (r.type) {
|
|
189
|
+
case "radio":
|
|
190
|
+
case "select":
|
|
191
|
+
lines.push(`Answer: ${r.value}`);
|
|
192
|
+
break;
|
|
193
|
+
case "checkbox":
|
|
194
|
+
for (const v of r.value as string[]) {
|
|
195
|
+
lines.push(`- [x] ${v}`);
|
|
196
|
+
}
|
|
197
|
+
break;
|
|
198
|
+
case "text":
|
|
199
|
+
lines.push("");
|
|
200
|
+
lines.push((r.value as string).trim());
|
|
201
|
+
break;
|
|
202
|
+
case "range":
|
|
203
|
+
lines.push(`Answer: ${r.value}`);
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (r.note?.trim()) {
|
|
208
|
+
lines.push("");
|
|
209
|
+
lines.push(`Note: ${r.note.trim()}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
lines.push("");
|
|
213
|
+
return lines.join("\n");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
interface HierarchyNode {
|
|
217
|
+
label: string;
|
|
218
|
+
annotations: Annotation[];
|
|
219
|
+
children: Map<string, HierarchyNode>;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function groupByHierarchy(annotations: Annotation[]): HierarchyNode {
|
|
223
|
+
const root: HierarchyNode = { label: "", annotations: [], children: new Map() };
|
|
224
|
+
for (const ann of annotations) {
|
|
225
|
+
const path = ann.context?.hierarchy || [];
|
|
226
|
+
let current = root;
|
|
227
|
+
for (const segment of path) {
|
|
228
|
+
if (!current.children.has(segment)) {
|
|
229
|
+
current.children.set(segment, { label: segment, annotations: [], children: new Map() });
|
|
230
|
+
}
|
|
231
|
+
current = current.children.get(segment)!;
|
|
232
|
+
}
|
|
233
|
+
current.annotations.push(ann);
|
|
234
|
+
}
|
|
235
|
+
return root;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function renderHierarchyGroup(node: HierarchyNode, depth: number = 0): string {
|
|
239
|
+
const parts: string[] = [];
|
|
240
|
+
for (const ann of node.annotations) {
|
|
241
|
+
parts.push(renderAnnotation(ann));
|
|
242
|
+
}
|
|
243
|
+
for (const [, child] of node.children) {
|
|
244
|
+
const prefix = depth === 0 ? "###" : "####";
|
|
245
|
+
parts.push(`${prefix} ${child.label}`);
|
|
246
|
+
parts.push("");
|
|
247
|
+
parts.push(renderHierarchyGroup(child, depth + 1));
|
|
248
|
+
}
|
|
249
|
+
return parts.join("\n");
|
|
250
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wraps the current browser Range in <mark> elements with the given annotation ID.
|
|
3
|
+
* Works for multiline / cross-element selections.
|
|
4
|
+
*/
|
|
5
|
+
export function wrapRangeWithMark(range: Range, annotationId: string): HTMLElement[] {
|
|
6
|
+
const marks: HTMLElement[] = [];
|
|
7
|
+
|
|
8
|
+
// Collect all text nodes within the range
|
|
9
|
+
const textNodes = getTextNodesInRange(range);
|
|
10
|
+
|
|
11
|
+
for (const tn of textNodes) {
|
|
12
|
+
const nodeRange = document.createRange();
|
|
13
|
+
nodeRange.selectNodeContents(tn);
|
|
14
|
+
|
|
15
|
+
// Clamp to the actual selection range
|
|
16
|
+
if (tn === range.startContainer) {
|
|
17
|
+
nodeRange.setStart(tn, range.startOffset);
|
|
18
|
+
}
|
|
19
|
+
if (tn === range.endContainer) {
|
|
20
|
+
nodeRange.setEnd(tn, range.endOffset);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const text = nodeRange.toString();
|
|
24
|
+
if (!text || !text.trim()) continue;
|
|
25
|
+
|
|
26
|
+
// Wrap this portion in a <mark>
|
|
27
|
+
const mark = document.createElement("mark");
|
|
28
|
+
mark.setAttribute("data-annotation-id", annotationId);
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
nodeRange.surroundContents(mark);
|
|
32
|
+
} catch {
|
|
33
|
+
// surroundContents fails if range crosses element boundaries within a single text node
|
|
34
|
+
// (shouldn't happen for a single text node, but handle gracefully)
|
|
35
|
+
const fragment = nodeRange.extractContents();
|
|
36
|
+
mark.appendChild(fragment);
|
|
37
|
+
nodeRange.insertNode(mark);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
marks.push(mark);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return marks;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether the annotation index carries this text. Line-number gutters and text
|
|
48
|
+
* that already belongs to another annotation are outside it. Everything that
|
|
49
|
+
* records a snippet or its context is matched back against that index later, so
|
|
50
|
+
* all of them have to skip exactly the same runs.
|
|
51
|
+
*/
|
|
52
|
+
export function isIndexedText(node: Text): boolean {
|
|
53
|
+
const parent = node.parentElement;
|
|
54
|
+
if (parent?.classList.contains("select-none")) return false;
|
|
55
|
+
if (parent?.closest("[data-annotation-id]")) return false;
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getTextNodesInRange(range: Range): Text[] {
|
|
60
|
+
const nodes: Text[] = [];
|
|
61
|
+
const ancestor = range.commonAncestorContainer;
|
|
62
|
+
|
|
63
|
+
if (ancestor.nodeType === Node.TEXT_NODE) {
|
|
64
|
+
nodes.push(ancestor as Text);
|
|
65
|
+
return nodes;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const walker = document.createTreeWalker(ancestor, NodeFilter.SHOW_TEXT);
|
|
69
|
+
let node: Text | null;
|
|
70
|
+
while ((node = walker.nextNode() as Text | null)) {
|
|
71
|
+
if (!isIndexedText(node)) continue;
|
|
72
|
+
if (range.intersectsNode(node)) {
|
|
73
|
+
nodes.push(node);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return nodes;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The part of a text node that falls inside the range. */
|
|
81
|
+
function clampToRange(node: Text, range: Range): string {
|
|
82
|
+
const nodeRange = document.createRange();
|
|
83
|
+
nodeRange.selectNodeContents(node);
|
|
84
|
+
if (node === range.startContainer) nodeRange.setStart(node, range.startOffset);
|
|
85
|
+
if (node === range.endContainer) nodeRange.setEnd(node, range.endOffset);
|
|
86
|
+
return nodeRange.toString();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A selection's text as the annotation index will read it back. The raw
|
|
91
|
+
* selection is not that: it carries the gutter and any text already inside
|
|
92
|
+
* another annotation, none of which the index holds — so a snippet taken from
|
|
93
|
+
* it names something that cannot be found again.
|
|
94
|
+
*/
|
|
95
|
+
export function rangeIndexText(range: Range): string {
|
|
96
|
+
let text = "";
|
|
97
|
+
for (const node of getTextNodesInRange(range)) {
|
|
98
|
+
text += clampToRange(node, range);
|
|
99
|
+
}
|
|
100
|
+
return text;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Update the visual state of marks for a given annotation ID.
|
|
105
|
+
*/
|
|
106
|
+
export function setMarkActive(annotationId: string, active: boolean) {
|
|
107
|
+
const marks = document.querySelectorAll(`[data-annotation-id="${annotationId}"]`);
|
|
108
|
+
for (const mark of marks) {
|
|
109
|
+
(mark as HTMLElement).classList.toggle("mark-active", active);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Update active state for annotation marks.
|
|
115
|
+
* Only touches marks for the previous and current active IDs to avoid full DOM scans.
|
|
116
|
+
*/
|
|
117
|
+
export function updateAllMarkStates(activeId: string | null, prevActiveId?: string | null) {
|
|
118
|
+
if (prevActiveId != null && prevActiveId !== activeId) {
|
|
119
|
+
setMarkActive(prevActiveId, false);
|
|
120
|
+
}
|
|
121
|
+
if (activeId != null) {
|
|
122
|
+
setMarkActive(activeId, true);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Rename annotation ID on existing marks (e.g. temp → real ID).
|
|
128
|
+
*/
|
|
129
|
+
export function renameMarkId(oldId: string, newId: string) {
|
|
130
|
+
const marks = document.querySelectorAll(`[data-annotation-id="${oldId}"]`);
|
|
131
|
+
for (const mark of marks) {
|
|
132
|
+
mark.setAttribute("data-annotation-id", newId);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Restore marks for persisted annotations by finding their snippet text in the DOM.
|
|
138
|
+
* Uses before/after context for disambiguation when the same text appears multiple times.
|
|
139
|
+
*/
|
|
140
|
+
export function restoreMarks(
|
|
141
|
+
container: HTMLElement,
|
|
142
|
+
annotations: { id: string; snippet: string; filePath?: string; context?: { before: string; after: string; hierarchy: string[] } }[]
|
|
143
|
+
) {
|
|
144
|
+
for (const ann of annotations) {
|
|
145
|
+
// A canvas can be rendered more than once (overview, compare, hidden
|
|
146
|
+
// source panes), so restore decisions must be scoped to this container.
|
|
147
|
+
if (container.querySelector(`[data-annotation-id="${ann.id}"]`)) continue;
|
|
148
|
+
|
|
149
|
+
const snippet = ann.snippet;
|
|
150
|
+
if (!snippet) continue;
|
|
151
|
+
|
|
152
|
+
// Rebuild after every inserted mark. Wrapping text splits and moves text
|
|
153
|
+
// nodes, so a cached offset map can point at stale DOM after the first hit.
|
|
154
|
+
const { fullText, textNodes } = buildTextIndex(container);
|
|
155
|
+
|
|
156
|
+
// Find all occurrences of the snippet in the full text
|
|
157
|
+
const occurrences: number[] = [];
|
|
158
|
+
let searchFrom = 0;
|
|
159
|
+
while (true) {
|
|
160
|
+
const idx = fullText.indexOf(snippet, searchFrom);
|
|
161
|
+
if (idx === -1) break;
|
|
162
|
+
occurrences.push(idx);
|
|
163
|
+
searchFrom = idx + 1;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const [firstOccurrence] = occurrences;
|
|
167
|
+
if (firstOccurrence === undefined) continue;
|
|
168
|
+
|
|
169
|
+
// Pick the best occurrence using context
|
|
170
|
+
let bestIdx = firstOccurrence;
|
|
171
|
+
if (occurrences.length > 1 && ann.context) {
|
|
172
|
+
let bestScore = -1;
|
|
173
|
+
for (const idx of occurrences) {
|
|
174
|
+
let score = 0;
|
|
175
|
+
if (ann.context.before) {
|
|
176
|
+
const preceding = fullText.slice(Math.max(0, idx - 80), idx);
|
|
177
|
+
if (preceding.includes(ann.context.before)) score += 2;
|
|
178
|
+
else if (ann.context.before.length > 10 && preceding.includes(ann.context.before.slice(-10))) score += 1;
|
|
179
|
+
}
|
|
180
|
+
if (ann.context.after) {
|
|
181
|
+
const following = fullText.slice(idx + snippet.length, idx + snippet.length + 80);
|
|
182
|
+
if (following.includes(ann.context.after)) score += 2;
|
|
183
|
+
else if (ann.context.after.length > 10 && following.includes(ann.context.after.slice(0, 10))) score += 1;
|
|
184
|
+
}
|
|
185
|
+
if (score > bestScore) {
|
|
186
|
+
bestScore = score;
|
|
187
|
+
bestIdx = idx;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Map text offsets to DOM positions
|
|
193
|
+
const startOffset = bestIdx;
|
|
194
|
+
const endOffset = bestIdx + snippet.length;
|
|
195
|
+
|
|
196
|
+
const startPos = offsetToNode(textNodes, startOffset);
|
|
197
|
+
const endPos = offsetToNode(textNodes, endOffset);
|
|
198
|
+
if (!startPos || !endPos) continue;
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
const range = document.createRange();
|
|
202
|
+
range.setStart(startPos.node, startPos.offset);
|
|
203
|
+
range.setEnd(endPos.node, endPos.offset);
|
|
204
|
+
wrapRangeWithMark(range, ann.id);
|
|
205
|
+
} catch {}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function buildTextIndex(container: HTMLElement): {
|
|
210
|
+
fullText: string;
|
|
211
|
+
textNodes: { node: Text; start: number }[];
|
|
212
|
+
} {
|
|
213
|
+
const textNodes: { node: Text; start: number }[] = [];
|
|
214
|
+
let fullText = "";
|
|
215
|
+
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
|
|
216
|
+
let tn: Text | null;
|
|
217
|
+
while ((tn = walker.nextNode() as Text | null)) {
|
|
218
|
+
if (!isIndexedText(tn)) continue;
|
|
219
|
+
textNodes.push({ node: tn, start: fullText.length });
|
|
220
|
+
fullText += tn.textContent || "";
|
|
221
|
+
}
|
|
222
|
+
return { fullText, textNodes };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function offsetToNode(
|
|
226
|
+
textNodes: { node: Text; start: number }[],
|
|
227
|
+
offset: number
|
|
228
|
+
): { node: Text; offset: number } | null {
|
|
229
|
+
for (let i = textNodes.length - 1; i >= 0; i--) {
|
|
230
|
+
const entry = textNodes[i];
|
|
231
|
+
if (!entry || entry.start > offset) continue;
|
|
232
|
+
const localOffset = offset - entry.start;
|
|
233
|
+
const nodeLen = entry.node.textContent?.length ?? 0;
|
|
234
|
+
if (localOffset <= nodeLen) {
|
|
235
|
+
return { node: entry.node, offset: localOffset };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Remove the decoration for a given annotation ID, restoring the original text
|
|
243
|
+
* nodes an inline mark wrapped.
|
|
244
|
+
*/
|
|
245
|
+
export function unwrapMarks(annotationId: string) {
|
|
246
|
+
const marks = document.querySelectorAll(`[data-annotation-id="${annotationId}"]`);
|
|
247
|
+
for (const mark of marks) {
|
|
248
|
+
const parent = mark.parentNode;
|
|
249
|
+
if (!parent) continue;
|
|
250
|
+
// Decoration that is not an inline mark — a region overlay — wraps no text
|
|
251
|
+
// and is simply taken out.
|
|
252
|
+
if (mark.tagName !== "MARK") {
|
|
253
|
+
parent.removeChild(mark);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
while (mark.firstChild) {
|
|
257
|
+
parent.insertBefore(mark.firstChild, mark);
|
|
258
|
+
}
|
|
259
|
+
parent.removeChild(mark);
|
|
260
|
+
parent.normalize();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What the annotation surface needs from the host serving it.
|
|
5
|
+
*
|
|
6
|
+
* The kernel ships a local-daemon implementation, so a host that serves
|
|
7
|
+
* canvases straight off its own daemon needs no provider. Hosts with extra
|
|
8
|
+
* delivery modes (agent-canvas also serves read-only encrypted share links)
|
|
9
|
+
* provide their own.
|
|
10
|
+
*/
|
|
11
|
+
export interface CanvasHost {
|
|
12
|
+
/** Session the canvas belongs to. */
|
|
13
|
+
sessionId: string;
|
|
14
|
+
/** Read-only share view — local-only affordances stay hidden. */
|
|
15
|
+
isShared: boolean;
|
|
16
|
+
/** Host can read project files (feedback bodies, file browser). */
|
|
17
|
+
fsAvailable: boolean;
|
|
18
|
+
/** Endpoint accepting annotation image uploads, or `null` for a host that
|
|
19
|
+
* has none — the attach affordances then stay hidden rather than posting
|
|
20
|
+
* into the void. Required, not optional, so no host can be silent about it. */
|
|
21
|
+
uploadUrl: (() => string) | null;
|
|
22
|
+
/** Fetch and `import()` a compiled canvas module. */
|
|
23
|
+
loadCanvasModule: (filename: string, revision?: number) => Promise<any>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Local mode carries the session id as the `/s/:id` path suffix. */
|
|
27
|
+
function localSessionId(): string {
|
|
28
|
+
if (typeof window === "undefined") return "";
|
|
29
|
+
return window.location.pathname.replace("/s/", "");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const localCanvasHost: CanvasHost = {
|
|
33
|
+
get sessionId() {
|
|
34
|
+
return localSessionId();
|
|
35
|
+
},
|
|
36
|
+
isShared: false,
|
|
37
|
+
fsAvailable: true,
|
|
38
|
+
// No endpoint by default. The only hosts that reach this fallback are the
|
|
39
|
+
// ones that configured nothing, which are exactly the ones least likely to
|
|
40
|
+
// serve an upload route — claiming one on their behalf is how every host got
|
|
41
|
+
// a paperclip that posts into the void. A host with uploads names its own.
|
|
42
|
+
uploadUrl: null,
|
|
43
|
+
loadCanvasModule: (filename, revision) => {
|
|
44
|
+
const jsName = filename.replace(/\.jsx$/, ".js");
|
|
45
|
+
const query = revision ? `?rev=${revision}&t=${Date.now()}` : `?t=${Date.now()}`;
|
|
46
|
+
const url = `/api/session/${localSessionId()}/canvas/${encodeURIComponent(jsName)}${query}`;
|
|
47
|
+
return import(/* @vite-ignore */ url);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const CanvasHostContext = createContext<CanvasHost>(localCanvasHost);
|
|
52
|
+
|
|
53
|
+
export function useCanvasHost(): CanvasHost {
|
|
54
|
+
return useContext(CanvasHostContext);
|
|
55
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// @fabrika/canvas-kernel/client — the reusable annotation surface.
|
|
2
|
+
//
|
|
3
|
+
// Everything a host needs to render a compiled canvas and collect feedback on
|
|
4
|
+
// it, minus the app shell (navigation, file browser, revision chrome), which
|
|
5
|
+
// each host builds itself.
|
|
6
|
+
|
|
7
|
+
// --- Annotation state ------------------------------------------------------
|
|
8
|
+
// Everything that lives in runtime.ts is re-exported through `#canvas/runtime`,
|
|
9
|
+
// never through a relative path. The runtime holds the React contexts and ships
|
|
10
|
+
// as its own bundle shared with the compiled canvas; importing it relatively
|
|
11
|
+
// would bundle a second copy, and the provider and its consumers would then be
|
|
12
|
+
// writing to and reading from different contexts.
|
|
13
|
+
export { AnnotationProvider } from "./AnnotationProvider";
|
|
14
|
+
export type { PersistedState } from "./AnnotationProvider";
|
|
15
|
+
export {
|
|
16
|
+
useAnnotations,
|
|
17
|
+
useFeedback,
|
|
18
|
+
useCanvasFile,
|
|
19
|
+
useResponseRegistration,
|
|
20
|
+
AnnotationCtx,
|
|
21
|
+
SessionContext,
|
|
22
|
+
ActiveViewCtx,
|
|
23
|
+
CanvasFileCtx,
|
|
24
|
+
} from "#canvas/runtime";
|
|
25
|
+
export type {
|
|
26
|
+
Annotation,
|
|
27
|
+
AnnotationAttachment,
|
|
28
|
+
AnnotationAuthor,
|
|
29
|
+
AnnotationContext,
|
|
30
|
+
AnnotationContextValue,
|
|
31
|
+
ActiveView,
|
|
32
|
+
FeedbackEntry,
|
|
33
|
+
PlanResponse,
|
|
34
|
+
} from "#canvas/runtime";
|
|
35
|
+
|
|
36
|
+
// --- Surface ---------------------------------------------------------------
|
|
37
|
+
export { AnnotationSidebar } from "./AnnotationSidebar";
|
|
38
|
+
// The sidebar's reusable halves. Grouping stayed with the sidebar — what an
|
|
39
|
+
// annotation belongs to is the host's taxonomy, so the host slices the list.
|
|
40
|
+
export { AnnotationList } from "./AnnotationList";
|
|
41
|
+
export type { AnnotationListProps } from "./AnnotationList";
|
|
42
|
+
export { AnnotationDraftFooter } from "./AnnotationDraftFooter";
|
|
43
|
+
export type { AnnotationDraftFooterProps } from "./AnnotationDraftFooter";
|
|
44
|
+
export { AnnotationEditor, imageToUrl } from "./AnnotationEditor";
|
|
45
|
+
export { PlanRenderer } from "./PlanRenderer";
|
|
46
|
+
export { Popover, AnnotationPopover, AnnotationCreatePopover, AnnotationEditPopover } from "./Popover";
|
|
47
|
+
export { ResponsePreview, MarkdownPreview } from "./ResponsePreview";
|
|
48
|
+
export { FileIcon } from "./FileIcon";
|
|
49
|
+
|
|
50
|
+
/** The kernel component set, as a namespace for hosts that inject it wholesale. */
|
|
51
|
+
export * as components from "./components";
|
|
52
|
+
export { useTextAnnotation } from "./useTextAnnotation";
|
|
53
|
+
export { useRegionAnnotation } from "./useRegionAnnotation";
|
|
54
|
+
|
|
55
|
+
// --- Host-provided context -------------------------------------------------
|
|
56
|
+
export { CanvasHostContext, localCanvasHost, useCanvasHost } from "./hostContext";
|
|
57
|
+
export type { CanvasHost } from "./hostContext";
|
|
58
|
+
export { ActiveViewContext, RevisionContext } from "./appContext";
|
|
59
|
+
export type { CanvasFileInfo, RevisionInfo } from "./appContext";
|
|
60
|
+
export { RenderErrorContext } from "./RenderErrorContext";
|
|
61
|
+
export type { CanvasRenderError } from "./RenderErrorContext";
|
|
62
|
+
|
|
63
|
+
// --- Annotation plumbing ---------------------------------------------------
|
|
64
|
+
export { extractContext, formatSnippetInContext } from "./annotationContext";
|
|
65
|
+
export {
|
|
66
|
+
ANNOTATION_TARGETS,
|
|
67
|
+
ANNOTATABLE_SELECTOR,
|
|
68
|
+
BLOCK_SELECTOR,
|
|
69
|
+
describeSnippet,
|
|
70
|
+
findAnnotationElement,
|
|
71
|
+
findSnippetElement,
|
|
72
|
+
getBlockSnippet,
|
|
73
|
+
restoreAnnotationTargets,
|
|
74
|
+
scrollToAnnotation,
|
|
75
|
+
} from "./annotationDom";
|
|
76
|
+
|
|
77
|
+
// --- Locator strategies ----------------------------------------------------
|
|
78
|
+
// What a snippet points at is the one thing in an annotation that is domain
|
|
79
|
+
// specific. A host that needs to annotate something the kernel has no target
|
|
80
|
+
// for writes one of these; nothing else has to change.
|
|
81
|
+
export { sealTarget } from "./annotationTarget";
|
|
82
|
+
export type { AnnotationTarget, SealedAnnotationTarget, TargetAnnotation } from "./annotationTarget";
|
|
83
|
+
export { blockTarget } from "./blockTarget";
|
|
84
|
+
export type { BlockLocator } from "./blockTarget";
|
|
85
|
+
export { textTarget } from "./textTarget";
|
|
86
|
+
export type { TextLocator } from "./textTarget";
|
|
87
|
+
export {
|
|
88
|
+
createRegionOverlay,
|
|
89
|
+
describeRegion,
|
|
90
|
+
findRegionHost,
|
|
91
|
+
isDrawableRegion,
|
|
92
|
+
regionBetween,
|
|
93
|
+
regionPointIn,
|
|
94
|
+
regionStyle,
|
|
95
|
+
regionTarget,
|
|
96
|
+
REGION_HOST_ATTR,
|
|
97
|
+
REGION_UNITS,
|
|
98
|
+
} from "./regionTarget";
|
|
99
|
+
export type { RegionBox, RegionLocator, RegionPoint, RegionShape } from "./regionTarget";
|
|
100
|
+
export {
|
|
101
|
+
annotationDraftKey,
|
|
102
|
+
carryUnsubmittedDraft,
|
|
103
|
+
clearPersistedDraft,
|
|
104
|
+
} from "./annotationDraft";
|
|
105
|
+
export type { AnnotationDraftPhase } from "./annotationDraft";
|
|
106
|
+
export {
|
|
107
|
+
restoreMarks,
|
|
108
|
+
renameMarkId,
|
|
109
|
+
setMarkActive,
|
|
110
|
+
unwrapMarks,
|
|
111
|
+
updateAllMarkStates,
|
|
112
|
+
wrapRangeWithMark,
|
|
113
|
+
} from "./highlightRange";
|
|
114
|
+
// One annotation as markdown — the half of feedback that belongs to no
|
|
115
|
+
// particular document format. `generateMarkdown` below is the canvas format
|
|
116
|
+
// built on it; a host with its own headings calls this directly.
|
|
117
|
+
export { renderAnnotation } from "./annotationMarkdown";
|
|
118
|
+
export type { RenderableAnnotation } from "./annotationMarkdown";
|
|
119
|
+
export {
|
|
120
|
+
canPruneResponses,
|
|
121
|
+
generateMarkdown,
|
|
122
|
+
getMissingRequired,
|
|
123
|
+
getMissingRequiredFeedback,
|
|
124
|
+
getMissingRequiredLabels,
|
|
125
|
+
hasValue,
|
|
126
|
+
pruneStaleResponses,
|
|
127
|
+
} from "./generateMarkdown";
|
|
128
|
+
export { autoResizeTextarea, fileAnnotationPath, generateAnnotationId, hostAcceptsUploads, RESPONSE_ANNOTATION_PATH } from "./utils";
|