@fabrika/canvas-kernel 0.1.44 → 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 -58
- 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/diffLines.ts
CHANGED
|
@@ -1,50 +1,62 @@
|
|
|
1
1
|
export interface DiffLine {
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
type: 'same' | 'removed' | 'added'
|
|
3
|
+
line: string
|
|
4
4
|
}
|
|
5
5
|
|
|
6
6
|
/** Line-level diff of two texts, longest-common-subsequence based. */
|
|
7
7
|
export function buildDiffLines(before: string, after: string): DiffLine[] {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
const beforeLines = before.split('\n')
|
|
9
|
+
const afterLines = after.split('\n')
|
|
10
|
+
const lcs = computeLCS(beforeLines, afterLines)
|
|
11
|
+
const diffLines: DiffLine[] = []
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
13
|
+
let bi = 0, ai = 0, li = 0
|
|
14
|
+
while (bi < beforeLines.length || ai < afterLines.length) {
|
|
15
|
+
// split() never leaves holes, so "in range" and "defined" are the same test.
|
|
16
|
+
const beforeLine = beforeLines[bi]
|
|
17
|
+
const afterLine = afterLines[ai]
|
|
18
|
+
if (
|
|
19
|
+
li < lcs.length && beforeLine !== undefined && afterLine !== undefined
|
|
20
|
+
&& beforeLine === lcs[li] && afterLine === lcs[li]
|
|
21
|
+
) {
|
|
22
|
+
diffLines.push({ type: 'same', line: beforeLine })
|
|
23
|
+
bi++
|
|
24
|
+
ai++
|
|
25
|
+
li++
|
|
26
|
+
} else if (beforeLine !== undefined && (li >= lcs.length || beforeLine !== lcs[li])) {
|
|
27
|
+
diffLines.push({ type: 'removed', line: beforeLine })
|
|
28
|
+
bi++
|
|
29
|
+
} else if (afterLine !== undefined) {
|
|
30
|
+
diffLines.push({ type: 'added', line: afterLine })
|
|
31
|
+
ai++
|
|
32
|
+
}
|
|
33
|
+
}
|
|
27
34
|
|
|
28
|
-
|
|
35
|
+
return diffLines
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
export function computeLCS(a: string[], b: string[]): string[] {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
39
|
+
const m = a.length, n = b.length
|
|
40
|
+
// Rows are fully filled before use; the `?? 0` fallbacks only satisfy the
|
|
41
|
+
// checker, which cannot see that every index here is in range.
|
|
42
|
+
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array<number>(n + 1).fill(0))
|
|
43
|
+
for (let i = 1; i <= m; i++) {
|
|
44
|
+
const row = dp[i] ?? []
|
|
45
|
+
const prev = dp[i - 1] ?? []
|
|
46
|
+
for (let j = 1; j <= n; j++) {
|
|
47
|
+
row[j] = a[i - 1] === b[j - 1] ? (prev[j - 1] ?? 0) + 1 : Math.max(prev[j] ?? 0, row[j - 1] ?? 0)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const result: string[] = []
|
|
51
|
+
let i = m, j = n
|
|
52
|
+
while (i > 0 && j > 0) {
|
|
53
|
+
const line = a[i - 1]
|
|
54
|
+
if (line !== undefined && line === b[j - 1]) {
|
|
55
|
+
result.unshift(line)
|
|
56
|
+
i--
|
|
57
|
+
j--
|
|
58
|
+
} else if ((dp[i - 1]?.[j] ?? 0) > (dp[i]?.[j - 1] ?? 0)) i--
|
|
59
|
+
else j--
|
|
60
|
+
}
|
|
61
|
+
return result
|
|
50
62
|
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// The canvas document format: revisions, responses, required fields, sections.
|
|
2
2
|
// Rendering one annotation is host-agnostic and lives in ./annotationMarkdown.
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import { RESPONSE_ANNOTATION_PATH } from "./utils";
|
|
3
|
+
import { renderAnnotation, RESPONSE_ANNOTATION_PATH } from '@fabrika/annotations'
|
|
4
|
+
import type { Annotation, FeedbackEntry, PlanResponse } from './AnnotationProvider'
|
|
6
5
|
|
|
7
6
|
/**
|
|
8
7
|
* Drop answers whose question is not in this revision's canvas. A draft carried
|
|
@@ -14,14 +13,14 @@ import { RESPONSE_ANNOTATION_PATH } from "./utils";
|
|
|
14
13
|
* point in this draft, never the currently-mounted ones — see canPruneResponses.
|
|
15
14
|
*/
|
|
16
15
|
export function pruneStaleResponses(
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
responses: Map<string, PlanResponse>,
|
|
17
|
+
seenResponseIds: ReadonlySet<string>,
|
|
19
18
|
): Map<string, PlanResponse> {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
const out = new Map<string, PlanResponse>()
|
|
20
|
+
for (const [id, r] of responses) {
|
|
21
|
+
if (seenResponseIds.has(id)) out.set(id, r)
|
|
22
|
+
}
|
|
23
|
+
return out
|
|
25
24
|
}
|
|
26
25
|
|
|
27
26
|
/**
|
|
@@ -33,218 +32,225 @@ export function pruneStaleResponses(
|
|
|
33
32
|
* pruning early would throw away every answer the author gave.
|
|
34
33
|
*/
|
|
35
34
|
export function canPruneResponses(
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
canvasFiles: readonly string[] | undefined,
|
|
36
|
+
renderedCanvases: ReadonlySet<string>,
|
|
38
37
|
): boolean {
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
if (!canvasFiles || canvasFiles.length === 0) return false
|
|
39
|
+
return canvasFiles.every((filename) => renderedCanvases.has(filename))
|
|
41
40
|
}
|
|
42
41
|
|
|
43
42
|
/**
|
|
44
43
|
* Generate human-readable markdown feedback from annotations and responses.
|
|
45
44
|
*/
|
|
46
45
|
export function generateMarkdown(
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
46
|
+
annotations: Annotation[],
|
|
47
|
+
generalNote: string,
|
|
48
|
+
responses?: Map<string, PlanResponse>,
|
|
49
|
+
feedbackEntries?: Map<string, FeedbackEntry>,
|
|
50
|
+
includedRemoteIds?: Set<string>,
|
|
52
51
|
): string {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
return parts.join("\n").trim() + "\n";
|
|
52
|
+
const parts: string[] = []
|
|
53
|
+
const included = annotations.filter((a) => a.source !== 'remote' || (includedRemoteIds && includedRemoteIds.has(a.id)))
|
|
54
|
+
const responseAnns = included.filter((a) => a.filePath === RESPONSE_ANNOTATION_PATH)
|
|
55
|
+
const canvasAnns = included.filter((a) => !a.filePath)
|
|
56
|
+
const fileAnns = included.filter((a) => a.filePath && a.filePath !== RESPONSE_ANNOTATION_PATH)
|
|
57
|
+
const resps = responses ? Array.from(responses.values()).filter((r) => hasValue(r)) : []
|
|
58
|
+
const fbEntries = feedbackEntries
|
|
59
|
+
? Array.from(feedbackEntries.values()).filter((e) => e.markdown.trim())
|
|
60
|
+
: []
|
|
61
|
+
|
|
62
|
+
// Responses section
|
|
63
|
+
const hasResponseSection = resps.length > 0 || fbEntries.length > 0
|
|
64
|
+
if (hasResponseSection) {
|
|
65
|
+
parts.push('## Responses')
|
|
66
|
+
parts.push('')
|
|
67
|
+
for (const r of resps) {
|
|
68
|
+
parts.push(renderResponse(r))
|
|
69
|
+
}
|
|
70
|
+
for (const entry of fbEntries) {
|
|
71
|
+
parts.push(entry.markdown.trim())
|
|
72
|
+
parts.push('')
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Response annotations (on agent response banner)
|
|
77
|
+
if (responseAnns.length > 0) {
|
|
78
|
+
parts.push('## Annotations on agent response')
|
|
79
|
+
parts.push('')
|
|
80
|
+
for (const ann of responseAnns) {
|
|
81
|
+
parts.push(renderAnnotation(ann))
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Canvas annotations grouped by canvas file, then hierarchy
|
|
86
|
+
if (canvasAnns.length > 0) {
|
|
87
|
+
parts.push('## Canvas annotations')
|
|
88
|
+
parts.push('')
|
|
89
|
+
|
|
90
|
+
// Group by canvasFile
|
|
91
|
+
const byCanvas = new Map<string, Annotation[]>()
|
|
92
|
+
for (const ann of canvasAnns) {
|
|
93
|
+
const key = ann.canvasFile || ''
|
|
94
|
+
if (!byCanvas.has(key)) byCanvas.set(key, [])
|
|
95
|
+
byCanvas.get(key)!.push(ann)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const canvasKeys = [...byCanvas.keys()].sort()
|
|
99
|
+
const multipleCanvases = canvasKeys.length > 1 || (canvasKeys.length === 1 && canvasKeys[0] !== '')
|
|
100
|
+
|
|
101
|
+
for (const canvasKey of canvasKeys) {
|
|
102
|
+
const anns = byCanvas.get(canvasKey)!
|
|
103
|
+
if (multipleCanvases && canvasKey) {
|
|
104
|
+
parts.push(`### ${canvasKey.replace(/\.jsx$/, '')}`)
|
|
105
|
+
parts.push('')
|
|
106
|
+
}
|
|
107
|
+
const grouped = groupByHierarchy(anns)
|
|
108
|
+
parts.push(renderHierarchyGroup(grouped))
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// File annotations grouped by path
|
|
113
|
+
if (fileAnns.length > 0) {
|
|
114
|
+
const groups: Record<string, Annotation[]> = {}
|
|
115
|
+
for (const ann of fileAnns) {
|
|
116
|
+
const key = ann.filePath!
|
|
117
|
+
if (!groups[key]) groups[key] = []
|
|
118
|
+
groups[key].push(ann)
|
|
119
|
+
}
|
|
120
|
+
parts.push('## File annotations')
|
|
121
|
+
parts.push('')
|
|
122
|
+
for (const [filePath, anns] of Object.entries(groups)) {
|
|
123
|
+
parts.push(`### ${filePath}`)
|
|
124
|
+
parts.push('')
|
|
125
|
+
for (const ann of anns) {
|
|
126
|
+
parts.push(renderAnnotation(ann))
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// General notes
|
|
132
|
+
if (generalNote.trim()) {
|
|
133
|
+
parts.push('## General notes')
|
|
134
|
+
parts.push('')
|
|
135
|
+
parts.push(generalNote.trim())
|
|
136
|
+
parts.push('')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return parts.join('\n').trim() + '\n'
|
|
143
140
|
}
|
|
144
141
|
|
|
145
142
|
export function hasValue(r: PlanResponse): boolean {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
143
|
+
if (r.note?.trim()) return true
|
|
144
|
+
if (r.value === null || r.value === undefined) return false
|
|
145
|
+
if (r.type === 'text' && !(r.value as string).trim()) return false
|
|
146
|
+
if (r.type === 'checkbox' && (r.value as string[]).length === 0) return false
|
|
147
|
+
return true
|
|
151
148
|
}
|
|
152
149
|
|
|
153
150
|
export function getMissingRequired(responses: Map<string, PlanResponse>): PlanResponse[] {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
151
|
+
const missing: PlanResponse[] = []
|
|
152
|
+
for (const r of responses.values()) {
|
|
153
|
+
if (!r.required) continue
|
|
154
|
+
if (r.value === null || r.value === undefined) {
|
|
155
|
+
missing.push(r)
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
if (r.type === 'text' && !(r.value as string).trim()) {
|
|
159
|
+
missing.push(r)
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
if (r.type === 'checkbox' && (r.value as string[]).length === 0) {
|
|
163
|
+
missing.push(r)
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return missing
|
|
162
168
|
}
|
|
163
169
|
|
|
164
170
|
export function getMissingRequiredFeedback(entries: Map<string, FeedbackEntry>): FeedbackEntry[] {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
171
|
+
const missing: FeedbackEntry[] = []
|
|
172
|
+
for (const entry of entries.values()) {
|
|
173
|
+
if (entry.required && !entry.markdown.trim()) {
|
|
174
|
+
missing.push(entry)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return missing
|
|
172
178
|
}
|
|
173
179
|
|
|
174
180
|
/** Returns list of missing required field labels, or empty if all valid */
|
|
175
181
|
export function getMissingRequiredLabels(responses: Map<string, PlanResponse>, feedbackEntries: Map<string, FeedbackEntry>): string[] {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
+
const missingResponses = getMissingRequired(responses)
|
|
183
|
+
const missingFeedback = getMissingRequiredFeedback(feedbackEntries)
|
|
184
|
+
return [
|
|
185
|
+
...missingResponses.map((r) => r.label),
|
|
186
|
+
...missingFeedback.map((e) => e.label || e.id),
|
|
187
|
+
]
|
|
182
188
|
}
|
|
183
189
|
|
|
184
190
|
function renderResponse(r: PlanResponse): string {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
191
|
+
const lines: string[] = []
|
|
192
|
+
lines.push(`**${r.label}**`)
|
|
193
|
+
|
|
194
|
+
switch (r.type) {
|
|
195
|
+
case 'radio':
|
|
196
|
+
case 'select':
|
|
197
|
+
lines.push(`Answer: ${r.value}`)
|
|
198
|
+
break
|
|
199
|
+
case 'checkbox':
|
|
200
|
+
for (const v of r.value as string[]) {
|
|
201
|
+
lines.push(`- [x] ${v}`)
|
|
202
|
+
}
|
|
203
|
+
break
|
|
204
|
+
case 'text':
|
|
205
|
+
lines.push('')
|
|
206
|
+
lines.push((r.value as string).trim())
|
|
207
|
+
break
|
|
208
|
+
case 'range':
|
|
209
|
+
lines.push(`Answer: ${r.value}`)
|
|
210
|
+
break
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (r.note?.trim()) {
|
|
214
|
+
lines.push('')
|
|
215
|
+
lines.push(`Note: ${r.note.trim()}`)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
lines.push('')
|
|
219
|
+
return lines.join('\n')
|
|
214
220
|
}
|
|
215
221
|
|
|
216
222
|
interface HierarchyNode {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
223
|
+
label: string
|
|
224
|
+
annotations: Annotation[]
|
|
225
|
+
children: Map<string, HierarchyNode>
|
|
220
226
|
}
|
|
221
227
|
|
|
222
228
|
function groupByHierarchy(annotations: Annotation[]): HierarchyNode {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
229
|
+
const root: HierarchyNode = { label: '', annotations: [], children: new Map() }
|
|
230
|
+
for (const ann of annotations) {
|
|
231
|
+
const path = ann.context?.hierarchy || []
|
|
232
|
+
let current = root
|
|
233
|
+
for (const segment of path) {
|
|
234
|
+
if (!current.children.has(segment)) {
|
|
235
|
+
current.children.set(segment, { label: segment, annotations: [], children: new Map() })
|
|
236
|
+
}
|
|
237
|
+
current = current.children.get(segment)!
|
|
238
|
+
}
|
|
239
|
+
current.annotations.push(ann)
|
|
240
|
+
}
|
|
241
|
+
return root
|
|
236
242
|
}
|
|
237
243
|
|
|
238
244
|
function renderHierarchyGroup(node: HierarchyNode, depth: number = 0): string {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
245
|
+
const parts: string[] = []
|
|
246
|
+
for (const ann of node.annotations) {
|
|
247
|
+
parts.push(renderAnnotation(ann))
|
|
248
|
+
}
|
|
249
|
+
for (const [, child] of node.children) {
|
|
250
|
+
const prefix = depth === 0 ? '###' : '####'
|
|
251
|
+
parts.push(`${prefix} ${child.label}`)
|
|
252
|
+
parts.push('')
|
|
253
|
+
parts.push(renderHierarchyGroup(child, depth + 1))
|
|
254
|
+
}
|
|
255
|
+
return parts.join('\n')
|
|
250
256
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createContext, useContext } from
|
|
1
|
+
import { createContext, useContext } from 'react'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* What the annotation surface needs from the host serving it.
|
|
@@ -9,47 +9,47 @@ import { createContext, useContext } from "react";
|
|
|
9
9
|
* provide their own.
|
|
10
10
|
*/
|
|
11
11
|
export interface CanvasHost {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
24
|
}
|
|
25
25
|
|
|
26
26
|
/** Local mode carries the session id as the `/s/:id` path suffix. */
|
|
27
27
|
function localSessionId(): string {
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
if (typeof window === 'undefined') return ''
|
|
29
|
+
return window.location.pathname.replace('/s/', '')
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export const localCanvasHost: CanvasHost = {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
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
50
|
|
|
51
|
-
export const CanvasHostContext = createContext<CanvasHost>(localCanvasHost)
|
|
51
|
+
export const CanvasHostContext = createContext<CanvasHost>(localCanvasHost)
|
|
52
52
|
|
|
53
53
|
export function useCanvasHost(): CanvasHost {
|
|
54
|
-
|
|
54
|
+
return useContext(CanvasHostContext)
|
|
55
55
|
}
|