agent-canvas 0.1.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/bin/agent-canvas.js +14 -0
- package/bin/agent-canvas.ts +377 -0
- package/daemon/build.ts +185 -0
- package/daemon/client/AnnotationProvider.tsx +185 -0
- package/daemon/client/AnnotationSidebar.tsx +314 -0
- package/daemon/client/App.tsx +611 -0
- package/daemon/client/CompareView.tsx +333 -0
- package/daemon/client/FileBrowser.tsx +188 -0
- package/daemon/client/FileIcon.tsx +76 -0
- package/daemon/client/FileViewer.tsx +207 -0
- package/daemon/client/PlanRenderer.tsx +665 -0
- package/daemon/client/Popover.tsx +285 -0
- package/daemon/client/ResponsePreview.tsx +260 -0
- package/daemon/client/RevisionSelect.tsx +97 -0
- package/daemon/client/SessionContext.tsx +3 -0
- package/daemon/client/SessionSwitcher.tsx +72 -0
- package/daemon/client/annotationContext.ts +114 -0
- package/daemon/client/components/Callout.tsx +25 -0
- package/daemon/client/components/Checklist.tsx +37 -0
- package/daemon/client/components/Choice.tsx +164 -0
- package/daemon/client/components/CodeBlock.tsx +35 -0
- package/daemon/client/components/Diff.tsx +65 -0
- package/daemon/client/components/FilePreview.tsx +88 -0
- package/daemon/client/components/Image.tsx +45 -0
- package/daemon/client/components/Mermaid.tsx +277 -0
- package/daemon/client/components/Note.tsx +13 -0
- package/daemon/client/components/Priority.tsx +19 -0
- package/daemon/client/components/ResponseNote.tsx +24 -0
- package/daemon/client/components/Section.tsx +32 -0
- package/daemon/client/components/Table.tsx +35 -0
- package/daemon/client/components/Task.tsx +75 -0
- package/daemon/client/components/UserInput.tsx +180 -0
- package/daemon/client/components/index.ts +15 -0
- package/daemon/client/domDiff.ts +336 -0
- package/daemon/client/exportMarkdown.ts +267 -0
- package/daemon/client/generateMarkdown.ts +194 -0
- package/daemon/client/highlightRange.ts +233 -0
- package/daemon/client/index.html +26 -0
- package/daemon/client/lcs.ts +19 -0
- package/daemon/client/popoverPosition.ts +31 -0
- package/daemon/client/runtime.ts +89 -0
- package/daemon/client/styles.css +306 -0
- package/daemon/client/theme.css +66 -0
- package/daemon/client/unifiedDiff.ts +604 -0
- package/daemon/dist/client.css +2 -0
- package/daemon/dist/client.js +68 -0
- package/daemon/dist/components.js +5 -0
- package/daemon/dist/index.html +108 -0
- package/daemon/dist/jsx-dev-runtime-shim.js +17 -0
- package/daemon/dist/jsx-runtime-shim.js +17 -0
- package/daemon/dist/react-dom-client-shim.js +4 -0
- package/daemon/dist/react-dom-shim.js +4 -0
- package/daemon/dist/react-shim.js +4 -0
- package/daemon/dist/runtime.js +1 -0
- package/daemon/dist/theme.css +66 -0
- package/daemon/src/compiler.ts +92 -0
- package/daemon/src/paths.ts +22 -0
- package/daemon/src/server.ts +448 -0
- package/daemon/src/session.ts +274 -0
- package/daemon/src/watcher.ts +59 -0
- package/hooks/session-start.sh +11 -0
- package/package.json +39 -0
- package/skills/canvas/SKILL.md +174 -0
- package/skills/canvas/components.md +349 -0
- package/skills/canvas/flows.md +406 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Word-level DOM diff engine.
|
|
3
|
+
*
|
|
4
|
+
* Three stages:
|
|
5
|
+
* 1. Extract words from a DOM container with source-node mapping
|
|
6
|
+
* 2. Compute word-level LCS diff
|
|
7
|
+
* 3. Inject highlight <span> elements into the DOM
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { simpleLCS } from "./lcs";
|
|
11
|
+
|
|
12
|
+
interface WordEntry {
|
|
13
|
+
text: string;
|
|
14
|
+
node: Text;
|
|
15
|
+
offset: number; // character offset within the Text node
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Walk all text nodes, tokenize into words+whitespace, record originating node+offset. */
|
|
19
|
+
export function extractWords(container: HTMLElement): { words: string[]; map: WordEntry[] } {
|
|
20
|
+
const words: string[] = [];
|
|
21
|
+
const map: WordEntry[] = [];
|
|
22
|
+
|
|
23
|
+
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
|
|
24
|
+
acceptNode(node) {
|
|
25
|
+
const parent = node.parentElement;
|
|
26
|
+
if (!parent) return NodeFilter.FILTER_REJECT;
|
|
27
|
+
// Skip SVG, select-none, and data-no-diff elements
|
|
28
|
+
if (parent.closest("svg, .select-none, [data-no-diff]")) return NodeFilter.FILTER_REJECT;
|
|
29
|
+
return NodeFilter.FILTER_ACCEPT;
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
let textNode: Text | null;
|
|
34
|
+
while ((textNode = walker.nextNode() as Text | null)) {
|
|
35
|
+
const content = textNode.nodeValue || "";
|
|
36
|
+
// Tokenize: split on whitespace boundaries, keeping whitespace as tokens
|
|
37
|
+
const tokens = content.match(/\S+|\s+/g);
|
|
38
|
+
if (!tokens) continue;
|
|
39
|
+
|
|
40
|
+
let offset = 0;
|
|
41
|
+
for (const token of tokens) {
|
|
42
|
+
words.push(token);
|
|
43
|
+
map.push({ text: token, node: textNode, offset });
|
|
44
|
+
offset += token.length;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { words, map };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface DiffOp {
|
|
52
|
+
type: "same" | "added" | "removed";
|
|
53
|
+
index: number; // index in the original words array
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Compute word-level LCS and return diff ops for both sides.
|
|
58
|
+
*
|
|
59
|
+
* For large inputs (>5k tokens), falls back to chunked diffing.
|
|
60
|
+
*/
|
|
61
|
+
export function computeWordDiff(
|
|
62
|
+
oldWords: string[],
|
|
63
|
+
newWords: string[]
|
|
64
|
+
): { oldOps: DiffOp[]; newOps: DiffOp[] } {
|
|
65
|
+
if (oldWords.length > 5000 || newWords.length > 5000) {
|
|
66
|
+
return chunkedDiff(oldWords, newWords);
|
|
67
|
+
}
|
|
68
|
+
return directDiff(oldWords, newWords);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function directDiff(
|
|
72
|
+
oldWords: string[],
|
|
73
|
+
newWords: string[]
|
|
74
|
+
): { oldOps: DiffOp[]; newOps: DiffOp[] } {
|
|
75
|
+
const lcs = computeLCS(oldWords, newWords);
|
|
76
|
+
|
|
77
|
+
const oldOps: DiffOp[] = [];
|
|
78
|
+
const newOps: DiffOp[] = [];
|
|
79
|
+
|
|
80
|
+
let oi = 0,
|
|
81
|
+
ni = 0,
|
|
82
|
+
li = 0;
|
|
83
|
+
|
|
84
|
+
while (oi < oldWords.length || ni < newWords.length) {
|
|
85
|
+
if (
|
|
86
|
+
li < lcs.length &&
|
|
87
|
+
oi < oldWords.length &&
|
|
88
|
+
ni < newWords.length &&
|
|
89
|
+
oldWords[oi] === lcs[li] &&
|
|
90
|
+
newWords[ni] === lcs[li]
|
|
91
|
+
) {
|
|
92
|
+
oldOps.push({ type: "same", index: oi });
|
|
93
|
+
newOps.push({ type: "same", index: ni });
|
|
94
|
+
oi++;
|
|
95
|
+
ni++;
|
|
96
|
+
li++;
|
|
97
|
+
} else if (oi < oldWords.length && (li >= lcs.length || oldWords[oi] !== lcs[li])) {
|
|
98
|
+
oldOps.push({ type: "removed", index: oi });
|
|
99
|
+
oi++;
|
|
100
|
+
} else if (ni < newWords.length) {
|
|
101
|
+
newOps.push({ type: "added", index: ni });
|
|
102
|
+
ni++;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { oldOps, newOps };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Compute LCS using Hirschberg's algorithm — O(mn) time but only O(n) space.
|
|
111
|
+
* Recursively divides the problem, using two-row DP to find the split point.
|
|
112
|
+
*/
|
|
113
|
+
function computeLCS(a: string[], b: string[]): string[] {
|
|
114
|
+
const m = a.length, n = b.length;
|
|
115
|
+
if (m === 0 || n === 0) return [];
|
|
116
|
+
if (m === 1) return b.includes(a[0]) ? [a[0]] : [];
|
|
117
|
+
if (n === 1) return a.includes(b[0]) ? [b[0]] : [];
|
|
118
|
+
|
|
119
|
+
// For small inputs, use the simple DP approach
|
|
120
|
+
if (m * n <= 1_000_000) {
|
|
121
|
+
return simpleLCS(a, b);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Hirschberg divide-and-conquer
|
|
125
|
+
const mid = Math.floor(m / 2);
|
|
126
|
+
const aFirst = a.slice(0, mid);
|
|
127
|
+
const aSecond = a.slice(mid);
|
|
128
|
+
|
|
129
|
+
const scoreL = lcsLengthRow(aFirst, b);
|
|
130
|
+
const scoreR = lcsLengthRow([...aSecond].reverse(), [...b].reverse());
|
|
131
|
+
|
|
132
|
+
// Find optimal split in b
|
|
133
|
+
let best = 0, bestK = 0;
|
|
134
|
+
for (let k = 0; k <= n; k++) {
|
|
135
|
+
const score = scoreL[k] + scoreR[n - k];
|
|
136
|
+
if (score > best) { best = score; bestK = k; }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const bFirst = b.slice(0, bestK);
|
|
140
|
+
const bSecond = b.slice(bestK);
|
|
141
|
+
|
|
142
|
+
return [...computeLCS(aFirst, bFirst), ...computeLCS(aSecond, bSecond)];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Two-row DP: returns last row of LCS length table. O(n) space. */
|
|
146
|
+
function lcsLengthRow(a: string[], b: string[]): number[] {
|
|
147
|
+
const n = b.length;
|
|
148
|
+
let prev = new Array(n + 1).fill(0);
|
|
149
|
+
let curr = new Array(n + 1).fill(0);
|
|
150
|
+
for (let i = 0; i < a.length; i++) {
|
|
151
|
+
for (let j = 1; j <= n; j++) {
|
|
152
|
+
curr[j] = a[i] === b[j - 1] ? prev[j - 1] + 1 : Math.max(prev[j], curr[j - 1]);
|
|
153
|
+
}
|
|
154
|
+
[prev, curr] = [curr, prev];
|
|
155
|
+
curr.fill(0);
|
|
156
|
+
}
|
|
157
|
+
return prev;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Chunked fallback for very large plans.
|
|
163
|
+
* Splits words into fixed-size chunks, aligns chunks via LCS,
|
|
164
|
+
* then runs word-level diff within each aligned chunk pair.
|
|
165
|
+
*/
|
|
166
|
+
function chunkedDiff(
|
|
167
|
+
oldWords: string[],
|
|
168
|
+
newWords: string[]
|
|
169
|
+
): { oldOps: DiffOp[]; newOps: DiffOp[] } {
|
|
170
|
+
const CHUNK_SIZE = 200;
|
|
171
|
+
|
|
172
|
+
const splitIntoChunks = (words: string[]) => {
|
|
173
|
+
const chunks: { start: number; end: number; text: string }[] = [];
|
|
174
|
+
for (let i = 0; i < words.length; i += CHUNK_SIZE) {
|
|
175
|
+
const end = Math.min(i + CHUNK_SIZE, words.length);
|
|
176
|
+
chunks.push({ start: i, end, text: words.slice(i, end).join("") });
|
|
177
|
+
}
|
|
178
|
+
return chunks;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const oldChunks = splitIntoChunks(oldWords);
|
|
182
|
+
const newChunks = splitIntoChunks(newWords);
|
|
183
|
+
|
|
184
|
+
const oldTexts = oldChunks.map((c) => c.text);
|
|
185
|
+
const newTexts = newChunks.map((c) => c.text);
|
|
186
|
+
const chunkLCS = computeLCS(oldTexts, newTexts);
|
|
187
|
+
|
|
188
|
+
const oldOps: DiffOp[] = [];
|
|
189
|
+
const newOps: DiffOp[] = [];
|
|
190
|
+
|
|
191
|
+
let oci = 0, nci = 0, cli = 0;
|
|
192
|
+
|
|
193
|
+
while (oci < oldChunks.length || nci < newChunks.length) {
|
|
194
|
+
if (
|
|
195
|
+
cli < chunkLCS.length &&
|
|
196
|
+
oci < oldChunks.length &&
|
|
197
|
+
nci < newChunks.length &&
|
|
198
|
+
oldTexts[oci] === chunkLCS[cli] &&
|
|
199
|
+
newTexts[nci] === chunkLCS[cli]
|
|
200
|
+
) {
|
|
201
|
+
// Identical chunks
|
|
202
|
+
for (let i = oldChunks[oci].start; i < oldChunks[oci].end; i++)
|
|
203
|
+
oldOps.push({ type: "same", index: i });
|
|
204
|
+
for (let i = newChunks[nci].start; i < newChunks[nci].end; i++)
|
|
205
|
+
newOps.push({ type: "same", index: i });
|
|
206
|
+
oci++;
|
|
207
|
+
nci++;
|
|
208
|
+
cli++;
|
|
209
|
+
} else if (oci < oldChunks.length && (cli >= chunkLCS.length || oldTexts[oci] !== chunkLCS[cli])) {
|
|
210
|
+
if (
|
|
211
|
+
nci < newChunks.length &&
|
|
212
|
+
(cli >= chunkLCS.length || newTexts[nci] !== chunkLCS[cli])
|
|
213
|
+
) {
|
|
214
|
+
// Both sides changed — word-level diff within these chunks
|
|
215
|
+
const sub = directDiff(
|
|
216
|
+
oldWords.slice(oldChunks[oci].start, oldChunks[oci].end),
|
|
217
|
+
newWords.slice(newChunks[nci].start, newChunks[nci].end)
|
|
218
|
+
);
|
|
219
|
+
for (const op of sub.oldOps)
|
|
220
|
+
oldOps.push({ type: op.type, index: op.index + oldChunks[oci].start });
|
|
221
|
+
for (const op of sub.newOps)
|
|
222
|
+
newOps.push({ type: op.type, index: op.index + newChunks[nci].start });
|
|
223
|
+
oci++;
|
|
224
|
+
nci++;
|
|
225
|
+
} else {
|
|
226
|
+
// Only old side — removed
|
|
227
|
+
for (let i = oldChunks[oci].start; i < oldChunks[oci].end; i++)
|
|
228
|
+
oldOps.push({ type: "removed", index: i });
|
|
229
|
+
oci++;
|
|
230
|
+
}
|
|
231
|
+
} else if (nci < newChunks.length) {
|
|
232
|
+
// Only new side — added
|
|
233
|
+
for (let i = newChunks[nci].start; i < newChunks[nci].end; i++)
|
|
234
|
+
newOps.push({ type: "added", index: i });
|
|
235
|
+
nci++;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return { oldOps, newOps };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Apply highlights by grouping changed tokens per text node,
|
|
244
|
+
* then rebuilding each affected text node in a single pass.
|
|
245
|
+
*
|
|
246
|
+
* This avoids the stale-reference bug where wrapping one token
|
|
247
|
+
* removes the text node and breaks subsequent tokens from the same node.
|
|
248
|
+
*/
|
|
249
|
+
function applyHighlightsByNode(
|
|
250
|
+
ops: DiffOp[],
|
|
251
|
+
map: WordEntry[],
|
|
252
|
+
cssClass: string
|
|
253
|
+
): void {
|
|
254
|
+
// Collect changed indices grouped by source text node
|
|
255
|
+
const nodeGroups = new Map<Text, { offset: number; length: number }[]>();
|
|
256
|
+
|
|
257
|
+
for (const op of ops) {
|
|
258
|
+
if (op.type === "same") continue;
|
|
259
|
+
const entry = map[op.index];
|
|
260
|
+
if (!entry || !entry.node.parentNode) continue;
|
|
261
|
+
|
|
262
|
+
let group = nodeGroups.get(entry.node);
|
|
263
|
+
if (!group) {
|
|
264
|
+
group = [];
|
|
265
|
+
nodeGroups.set(entry.node, group);
|
|
266
|
+
}
|
|
267
|
+
group.push({ offset: entry.offset, length: entry.text.length });
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// For each text node, rebuild it with highlight spans
|
|
271
|
+
for (const [textNode, ranges] of nodeGroups) {
|
|
272
|
+
if (!textNode.parentNode) continue;
|
|
273
|
+
const content = textNode.nodeValue || "";
|
|
274
|
+
|
|
275
|
+
// Sort ranges by offset
|
|
276
|
+
ranges.sort((a, b) => a.offset - b.offset);
|
|
277
|
+
|
|
278
|
+
// Merge overlapping/adjacent ranges
|
|
279
|
+
const merged: { offset: number; length: number }[] = [];
|
|
280
|
+
for (const r of ranges) {
|
|
281
|
+
const last = merged[merged.length - 1];
|
|
282
|
+
if (last && r.offset <= last.offset + last.length) {
|
|
283
|
+
last.length = Math.max(last.length, r.offset + r.length - last.offset);
|
|
284
|
+
} else {
|
|
285
|
+
merged.push({ ...r });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Build replacement nodes: alternating text and highlighted spans
|
|
290
|
+
const parent = textNode.parentNode;
|
|
291
|
+
const frag = document.createDocumentFragment();
|
|
292
|
+
let pos = 0;
|
|
293
|
+
|
|
294
|
+
for (const r of merged) {
|
|
295
|
+
// Text before this highlight
|
|
296
|
+
if (r.offset > pos) {
|
|
297
|
+
frag.appendChild(document.createTextNode(content.slice(pos, r.offset)));
|
|
298
|
+
}
|
|
299
|
+
// Highlighted span
|
|
300
|
+
const span = document.createElement("span");
|
|
301
|
+
span.className = cssClass;
|
|
302
|
+
span.textContent = content.slice(r.offset, r.offset + r.length);
|
|
303
|
+
frag.appendChild(span);
|
|
304
|
+
pos = r.offset + r.length;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Remaining text after last highlight
|
|
308
|
+
if (pos < content.length) {
|
|
309
|
+
frag.appendChild(document.createTextNode(content.slice(pos)));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
parent.replaceChild(frag, textNode);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Main entry point: run the full diff pipeline on two rendered containers.
|
|
318
|
+
* Returns true if there were any changes.
|
|
319
|
+
*/
|
|
320
|
+
export function runDomDiff(
|
|
321
|
+
oldContainer: HTMLElement,
|
|
322
|
+
newContainer: HTMLElement
|
|
323
|
+
): boolean {
|
|
324
|
+
const oldExtract = extractWords(oldContainer);
|
|
325
|
+
const newExtract = extractWords(newContainer);
|
|
326
|
+
|
|
327
|
+
const { oldOps, newOps } = computeWordDiff(oldExtract.words, newExtract.words);
|
|
328
|
+
|
|
329
|
+
const hasChanges =
|
|
330
|
+
oldOps.some((op) => op.type === "removed") || newOps.some((op) => op.type === "added");
|
|
331
|
+
|
|
332
|
+
applyHighlightsByNode(oldOps, oldExtract.map, "diff-removed");
|
|
333
|
+
applyHighlightsByNode(newOps, newExtract.map, "diff-added");
|
|
334
|
+
|
|
335
|
+
return hasChanges;
|
|
336
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export the rendered plan canvas to markdown.
|
|
3
|
+
* Reads data-md attributes from known components, falls back to text extraction for unknown elements.
|
|
4
|
+
*/
|
|
5
|
+
import { LANG_MAP } from "../langMap";
|
|
6
|
+
export function exportCanvasToMarkdown(container: HTMLElement): string {
|
|
7
|
+
const lines: string[] = [];
|
|
8
|
+
walkChildren(container, lines, 0);
|
|
9
|
+
return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim() + "\n";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const INLINE_TAGS = new Set(["CODE", "SPAN", "STRONG", "EM", "A", "B", "I", "U", "SMALL", "SUB", "SUP", "MARK", "ABBR"]);
|
|
13
|
+
|
|
14
|
+
function walkChildren(el: HTMLElement, lines: string[], depth: number) {
|
|
15
|
+
// Detect if this element contains only inline content (text nodes + inline elements).
|
|
16
|
+
// If so, concatenate into a single paragraph instead of splitting across lines.
|
|
17
|
+
if (isInlineContainer(el)) {
|
|
18
|
+
const text = extractInlineText(el);
|
|
19
|
+
if (text) lines.push(text, "");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
for (const child of el.childNodes) {
|
|
24
|
+
if (child.nodeType === Node.ELEMENT_NODE) {
|
|
25
|
+
walkNode(child as HTMLElement, lines, depth);
|
|
26
|
+
} else if (child.nodeType === Node.TEXT_NODE) {
|
|
27
|
+
const text = child.textContent?.trim();
|
|
28
|
+
if (text) lines.push(text, "");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Check if an element contains only text nodes and inline elements (no block-level children) */
|
|
34
|
+
function isInlineContainer(el: HTMLElement): boolean {
|
|
35
|
+
for (const child of el.childNodes) {
|
|
36
|
+
if (child.nodeType === Node.TEXT_NODE) continue;
|
|
37
|
+
if (child.nodeType === Node.ELEMENT_NODE) {
|
|
38
|
+
const childEl = child as HTMLElement;
|
|
39
|
+
if (!INLINE_TAGS.has(childEl.tagName) || childEl.hasAttribute("data-md")) return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return el.childNodes.length > 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Extract text from an inline container, wrapping <code> in backticks */
|
|
46
|
+
function extractInlineText(el: HTMLElement): string {
|
|
47
|
+
let result = "";
|
|
48
|
+
for (const child of el.childNodes) {
|
|
49
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
|
50
|
+
result += child.textContent || "";
|
|
51
|
+
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
|
52
|
+
const childEl = child as HTMLElement;
|
|
53
|
+
if (childEl.tagName === "CODE") {
|
|
54
|
+
result += "`" + (childEl.textContent || "") + "`";
|
|
55
|
+
} else {
|
|
56
|
+
result += childEl.textContent || "";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return result.trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function walkNode(el: HTMLElement, lines: string[], depth: number) {
|
|
64
|
+
const md = el.getAttribute("data-md");
|
|
65
|
+
|
|
66
|
+
if (md) {
|
|
67
|
+
switch (md) {
|
|
68
|
+
case "section": return handleSection(el, lines, depth);
|
|
69
|
+
case "item": return handleItem(el, lines);
|
|
70
|
+
case "codeblock": return handleCodeBlock(el, lines);
|
|
71
|
+
case "callout": return handleCallout(el, lines);
|
|
72
|
+
case "note": return handleNote(el, lines);
|
|
73
|
+
case "table": return handleTable(el, lines);
|
|
74
|
+
case "checklist": return handleChecklist(el, lines);
|
|
75
|
+
case "diff": return handleDiff(el, lines);
|
|
76
|
+
case "mermaid": return handleMermaid(el, lines);
|
|
77
|
+
case "choice": return handleChoice(el, lines);
|
|
78
|
+
case "multichoice": return handleMultiChoice(el, lines);
|
|
79
|
+
case "choice-option":
|
|
80
|
+
case "multichoice-option": return; // handled by parent
|
|
81
|
+
case "userinput": return handleUserInput(el, lines);
|
|
82
|
+
case "rangeinput": return handleRangeInput(el, lines);
|
|
83
|
+
case "checklist-item": return; // handled by parent (checklist)
|
|
84
|
+
case "filepreview": return handleFilePreview(el, lines);
|
|
85
|
+
case "priority": return handlePriority(el, lines);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Skip interactive widgets (custom components with buttons/inputs/selects)
|
|
90
|
+
if (el.querySelector("button, input, select, textarea")) return;
|
|
91
|
+
|
|
92
|
+
// Unknown element — if it has text, extract it; if it has children, recurse
|
|
93
|
+
if (el.children.length > 0) {
|
|
94
|
+
walkChildren(el, lines, depth);
|
|
95
|
+
} else {
|
|
96
|
+
const text = el.textContent?.trim();
|
|
97
|
+
if (text) lines.push(text, "");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function handleSection(el: HTMLElement, lines: string[], depth: number) {
|
|
102
|
+
const title = el.getAttribute("data-md-title") || "Untitled";
|
|
103
|
+
const prefix = depth === 0 ? "##" : "###";
|
|
104
|
+
lines.push(`${prefix} ${title}`, "");
|
|
105
|
+
|
|
106
|
+
// Process section content (skip the heading div, process the content div)
|
|
107
|
+
// Use :last-of-type instead of :last-child — portaled <button> elements from
|
|
108
|
+
// BlockCommentButtons can be appended as the last child, breaking :last-child
|
|
109
|
+
const contentDiv = el.querySelector(":scope > div:last-of-type");
|
|
110
|
+
if (contentDiv) {
|
|
111
|
+
walkChildren(contentDiv as HTMLElement, lines, depth + 1);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function handleItem(el: HTMLElement, lines: string[]) {
|
|
116
|
+
const label = el.getAttribute("data-md-label") || "";
|
|
117
|
+
const status = el.getAttribute("data-md-status");
|
|
118
|
+
const badge = el.getAttribute("data-md-badge");
|
|
119
|
+
|
|
120
|
+
let prefix = "-";
|
|
121
|
+
if (status === "done") prefix = "- [x]";
|
|
122
|
+
else if (status) prefix = "- [ ]";
|
|
123
|
+
|
|
124
|
+
let line = `${prefix} **${label}**`;
|
|
125
|
+
if (badge) line += ` \`${badge}\``;
|
|
126
|
+
lines.push(line);
|
|
127
|
+
|
|
128
|
+
// Walk children content (description, nested CodeBlock, Callout, Priority, etc.)
|
|
129
|
+
const childrenDiv = el.querySelector(".text-text-secondary");
|
|
130
|
+
if (childrenDiv) {
|
|
131
|
+
const childLines: string[] = [];
|
|
132
|
+
walkChildren(childrenDiv as HTMLElement, childLines, 0);
|
|
133
|
+
// Indent child content under the list item
|
|
134
|
+
for (const cl of childLines) {
|
|
135
|
+
lines.push(cl ? ` ${cl}` : "");
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
lines.push("");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function handleCodeBlock(el: HTMLElement, lines: string[]) {
|
|
142
|
+
const language = el.getAttribute("data-md-language") || "";
|
|
143
|
+
const code = el.querySelector("code");
|
|
144
|
+
const text = code?.textContent || "";
|
|
145
|
+
lines.push(`\`\`\`${language}`, text, "```", "");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function handleCallout(el: HTMLElement, lines: string[]) {
|
|
149
|
+
const type = el.getAttribute("data-md-type") || "info";
|
|
150
|
+
const labels: Record<string, string> = { info: "Info", warning: "Warning", danger: "Danger", tip: "Tip" };
|
|
151
|
+
const content = extractText(el.querySelector(".text-text-secondary") as HTMLElement || el);
|
|
152
|
+
lines.push(`> **${labels[type] || type}:** ${content}`, "");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function handleNote(el: HTMLElement, lines: string[]) {
|
|
156
|
+
const text = el.textContent?.trim() || "";
|
|
157
|
+
lines.push(`> *${text}*`, "");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function handleTable(el: HTMLElement, lines: string[]) {
|
|
161
|
+
const table = el.querySelector("table");
|
|
162
|
+
if (!table) return;
|
|
163
|
+
|
|
164
|
+
const headers: string[] = [];
|
|
165
|
+
for (const th of table.querySelectorAll("thead th")) {
|
|
166
|
+
headers.push(th.textContent?.trim() || "");
|
|
167
|
+
}
|
|
168
|
+
if (headers.length === 0) return;
|
|
169
|
+
|
|
170
|
+
lines.push(`| ${headers.join(" | ")} |`);
|
|
171
|
+
lines.push(`| ${headers.map(() => "---").join(" | ")} |`);
|
|
172
|
+
|
|
173
|
+
for (const tr of table.querySelectorAll("tbody tr")) {
|
|
174
|
+
const cells: string[] = [];
|
|
175
|
+
for (const td of tr.querySelectorAll("td")) {
|
|
176
|
+
cells.push(td.textContent?.trim() || "");
|
|
177
|
+
}
|
|
178
|
+
lines.push(`| ${cells.join(" | ")} |`);
|
|
179
|
+
}
|
|
180
|
+
lines.push("");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function handleChecklist(el: HTMLElement, lines: string[]) {
|
|
184
|
+
for (const li of el.querySelectorAll("li")) {
|
|
185
|
+
const hasCheck = li.querySelector(".bg-accent-green") !== null;
|
|
186
|
+
const label = li.textContent?.trim() || "";
|
|
187
|
+
lines.push(`- [${hasCheck ? "x" : " "}] ${label}`);
|
|
188
|
+
}
|
|
189
|
+
lines.push("");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function handleDiff(el: HTMLElement, lines: string[]) {
|
|
193
|
+
const language = el.getAttribute("data-md-language") || "";
|
|
194
|
+
lines.push(`\`\`\`diff${language ? ` (${language})` : ""}`);
|
|
195
|
+
for (const row of el.querySelectorAll("[class*='flex']")) {
|
|
196
|
+
const prefix = row.querySelector(".text-accent-red") ? "-" :
|
|
197
|
+
row.querySelector(".text-accent-green") ? "+" : " ";
|
|
198
|
+
const code = row.querySelector("code");
|
|
199
|
+
if (code) lines.push(`${prefix} ${code.textContent}`);
|
|
200
|
+
}
|
|
201
|
+
lines.push("```", "");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function handleMermaid(el: HTMLElement, lines: string[]) {
|
|
205
|
+
const source = el.getAttribute("data-md-source") || "";
|
|
206
|
+
if (source) {
|
|
207
|
+
lines.push("```mermaid", source, "```", "");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function handleChoice(el: HTMLElement, lines: string[]) {
|
|
212
|
+
const label = el.getAttribute("data-md-label") || "";
|
|
213
|
+
lines.push(`**${label}:**`);
|
|
214
|
+
for (const opt of el.querySelectorAll("[data-md='choice-option']")) {
|
|
215
|
+
const optLabel = opt.getAttribute("data-md-label") || opt.textContent?.trim() || "";
|
|
216
|
+
const selected = opt.querySelector(".bg-accent-amber") !== null;
|
|
217
|
+
lines.push(`- ${selected ? "(x)" : "( )"} ${optLabel}`);
|
|
218
|
+
}
|
|
219
|
+
lines.push("");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function handleMultiChoice(el: HTMLElement, lines: string[]) {
|
|
223
|
+
const label = el.getAttribute("data-md-label") || "";
|
|
224
|
+
lines.push(`**${label}:**`);
|
|
225
|
+
for (const opt of el.querySelectorAll("[data-md='multichoice-option']")) {
|
|
226
|
+
const optLabel = opt.getAttribute("data-md-label") || opt.textContent?.trim() || "";
|
|
227
|
+
const checked = opt.querySelector(".bg-accent-amber") !== null;
|
|
228
|
+
lines.push(`- [${checked ? "x" : " "}] ${optLabel}`);
|
|
229
|
+
}
|
|
230
|
+
lines.push("");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function handleUserInput(el: HTMLElement, lines: string[]) {
|
|
234
|
+
const label = el.getAttribute("data-md-label") || "";
|
|
235
|
+
const textarea = el.querySelector("textarea") as HTMLTextAreaElement | null;
|
|
236
|
+
const value = textarea?.value?.trim() || "";
|
|
237
|
+
lines.push(`**${label}:** ${value || "_empty_"}`, "");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function handleRangeInput(el: HTMLElement, lines: string[]) {
|
|
241
|
+
const label = el.getAttribute("data-md-label") || "";
|
|
242
|
+
const input = el.querySelector("input[type='range']") as HTMLInputElement | null;
|
|
243
|
+
const display = el.querySelector(".font-mono");
|
|
244
|
+
const value = display?.textContent?.trim() || input?.value || "—";
|
|
245
|
+
lines.push(`**${label}:** ${value}`, "");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function handleFilePreview(el: HTMLElement, lines: string[]) {
|
|
249
|
+
const path = el.getAttribute("data-md-path") || "";
|
|
250
|
+
const linesAttr = el.getAttribute("data-md-lines") || "";
|
|
251
|
+
const code = el.querySelector("code");
|
|
252
|
+
const text = code?.textContent || "";
|
|
253
|
+
const ext = path.split(".").pop() || "";
|
|
254
|
+
const lang = LANG_MAP[ext] || "";
|
|
255
|
+
const header = linesAttr ? `${path} (L${linesAttr})` : path;
|
|
256
|
+
lines.push(header, "", `\`\`\`${lang}`, text, "```", "");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function handlePriority(_el: HTMLElement, _lines: string[]) {
|
|
260
|
+
// Priority is rendered inline within Task items — the Task handler already
|
|
261
|
+
// captures it as part of child content. Skip standalone rendering to avoid
|
|
262
|
+
// duplicating the bare level text (e.g. "high").
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function extractText(el: HTMLElement): string {
|
|
266
|
+
return el?.textContent?.trim() || "";
|
|
267
|
+
}
|