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.
Files changed (65) hide show
  1. package/bin/agent-canvas.js +14 -0
  2. package/bin/agent-canvas.ts +377 -0
  3. package/daemon/build.ts +185 -0
  4. package/daemon/client/AnnotationProvider.tsx +185 -0
  5. package/daemon/client/AnnotationSidebar.tsx +314 -0
  6. package/daemon/client/App.tsx +611 -0
  7. package/daemon/client/CompareView.tsx +333 -0
  8. package/daemon/client/FileBrowser.tsx +188 -0
  9. package/daemon/client/FileIcon.tsx +76 -0
  10. package/daemon/client/FileViewer.tsx +207 -0
  11. package/daemon/client/PlanRenderer.tsx +665 -0
  12. package/daemon/client/Popover.tsx +285 -0
  13. package/daemon/client/ResponsePreview.tsx +260 -0
  14. package/daemon/client/RevisionSelect.tsx +97 -0
  15. package/daemon/client/SessionContext.tsx +3 -0
  16. package/daemon/client/SessionSwitcher.tsx +72 -0
  17. package/daemon/client/annotationContext.ts +114 -0
  18. package/daemon/client/components/Callout.tsx +25 -0
  19. package/daemon/client/components/Checklist.tsx +37 -0
  20. package/daemon/client/components/Choice.tsx +164 -0
  21. package/daemon/client/components/CodeBlock.tsx +35 -0
  22. package/daemon/client/components/Diff.tsx +65 -0
  23. package/daemon/client/components/FilePreview.tsx +88 -0
  24. package/daemon/client/components/Image.tsx +45 -0
  25. package/daemon/client/components/Mermaid.tsx +277 -0
  26. package/daemon/client/components/Note.tsx +13 -0
  27. package/daemon/client/components/Priority.tsx +19 -0
  28. package/daemon/client/components/ResponseNote.tsx +24 -0
  29. package/daemon/client/components/Section.tsx +32 -0
  30. package/daemon/client/components/Table.tsx +35 -0
  31. package/daemon/client/components/Task.tsx +75 -0
  32. package/daemon/client/components/UserInput.tsx +180 -0
  33. package/daemon/client/components/index.ts +15 -0
  34. package/daemon/client/domDiff.ts +336 -0
  35. package/daemon/client/exportMarkdown.ts +267 -0
  36. package/daemon/client/generateMarkdown.ts +194 -0
  37. package/daemon/client/highlightRange.ts +233 -0
  38. package/daemon/client/index.html +26 -0
  39. package/daemon/client/lcs.ts +19 -0
  40. package/daemon/client/popoverPosition.ts +31 -0
  41. package/daemon/client/runtime.ts +89 -0
  42. package/daemon/client/styles.css +306 -0
  43. package/daemon/client/theme.css +66 -0
  44. package/daemon/client/unifiedDiff.ts +604 -0
  45. package/daemon/dist/client.css +2 -0
  46. package/daemon/dist/client.js +68 -0
  47. package/daemon/dist/components.js +5 -0
  48. package/daemon/dist/index.html +108 -0
  49. package/daemon/dist/jsx-dev-runtime-shim.js +17 -0
  50. package/daemon/dist/jsx-runtime-shim.js +17 -0
  51. package/daemon/dist/react-dom-client-shim.js +4 -0
  52. package/daemon/dist/react-dom-shim.js +4 -0
  53. package/daemon/dist/react-shim.js +4 -0
  54. package/daemon/dist/runtime.js +1 -0
  55. package/daemon/dist/theme.css +66 -0
  56. package/daemon/src/compiler.ts +92 -0
  57. package/daemon/src/paths.ts +22 -0
  58. package/daemon/src/server.ts +448 -0
  59. package/daemon/src/session.ts +274 -0
  60. package/daemon/src/watcher.ts +59 -0
  61. package/hooks/session-start.sh +11 -0
  62. package/package.json +39 -0
  63. package/skills/canvas/SKILL.md +174 -0
  64. package/skills/canvas/components.md +349 -0
  65. package/skills/canvas/flows.md +406 -0
@@ -0,0 +1,604 @@
1
+ /**
2
+ * Unified (inline) diff view: block-level matching + word-level inline highlights.
3
+ *
4
+ * 1. Extract block trees from rendered old/new containers using data-md attributes
5
+ * 2. Match blocks by key using LCS for ordering
6
+ * 3. Build a single unified DOM with inline add/remove highlights
7
+ */
8
+
9
+ import { computeWordDiff } from "./domDiff";
10
+ import { simpleLCS } from "./lcs";
11
+
12
+ interface WordEntry {
13
+ text: string;
14
+ node: Text;
15
+ offset: number;
16
+ }
17
+
18
+ interface BlockDescriptor {
19
+ key: string;
20
+ type: string;
21
+ element: HTMLElement;
22
+ children: BlockDescriptor[];
23
+ }
24
+
25
+ interface BlockMatch {
26
+ kind: "matched" | "added" | "removed";
27
+ oldBlock?: BlockDescriptor;
28
+ newBlock?: BlockDescriptor;
29
+ childMatches?: BlockMatch[];
30
+ }
31
+
32
+ /* ── Block tree extraction ── */
33
+
34
+ export function extractBlockTree(container: HTMLElement): BlockDescriptor[] {
35
+ const blocks: BlockDescriptor[] = [];
36
+ const els = container.querySelectorAll<HTMLElement>(":scope > [data-md]");
37
+
38
+ for (const el of els) {
39
+ blocks.push(buildDescriptor(el));
40
+ }
41
+
42
+ // If no direct children matched, try all top-level descendants
43
+ if (blocks.length === 0) {
44
+ const all = container.querySelectorAll<HTMLElement>("[data-md]");
45
+ const seen = new Set<HTMLElement>();
46
+ for (const el of all) {
47
+ const parentMd = el.parentElement?.closest("[data-md]");
48
+ if (parentMd && container.contains(parentMd)) continue;
49
+ if (seen.has(el)) continue;
50
+ seen.add(el);
51
+ blocks.push(buildDescriptor(el));
52
+ }
53
+ }
54
+
55
+ return blocks;
56
+ }
57
+
58
+ function buildDescriptor(el: HTMLElement): BlockDescriptor {
59
+ const type = el.getAttribute("data-md") || "unknown";
60
+ const key = blockKey(el, type);
61
+ const children: BlockDescriptor[] = [];
62
+
63
+ if (type === "section") {
64
+ const contentDiv = el.querySelector<HTMLElement>(":scope > div.mt-6, :scope > div:last-child");
65
+ if (contentDiv) {
66
+ for (const child of contentDiv.querySelectorAll<HTMLElement>(":scope > [data-md]")) {
67
+ children.push(buildDescriptor(child));
68
+ }
69
+ }
70
+ } else if (type === "table") {
71
+ for (const row of el.querySelectorAll<HTMLElement>(":scope table > tbody > tr")) {
72
+ children.push({ key: "tr:" + (row.textContent || "").slice(0, 80), type: "tr", element: row, children: [] });
73
+ }
74
+ } else if (type === "checklist") {
75
+ for (const item of el.querySelectorAll<HTMLElement>(":scope > [data-md='checklist-item']")) {
76
+ children.push(buildDescriptor(item));
77
+ }
78
+ } else {
79
+ // Any block with nested [data-md] children (e.g. items containing codeblocks)
80
+ for (const child of el.querySelectorAll<HTMLElement>("[data-md]")) {
81
+ const parentMd = child.parentElement?.closest("[data-md]");
82
+ if (parentMd === el) {
83
+ children.push(buildDescriptor(child));
84
+ }
85
+ }
86
+ }
87
+
88
+ return { key, type, element: el, children };
89
+ }
90
+
91
+ function blockKey(el: HTMLElement, type: string): string {
92
+ switch (type) {
93
+ case "section":
94
+ return "section:" + (el.getAttribute("data-md-title") || "");
95
+ case "item":
96
+ return "item:" + (el.getAttribute("data-task-id") || el.getAttribute("data-md-label") || "");
97
+ case "codeblock":
98
+ case "diff":
99
+ return type + ":" + (el.textContent || "").slice(0, 80);
100
+ case "checklist-item":
101
+ return "checklist-item:" + (el.getAttribute("data-md-label") || el.textContent?.slice(0, 60) || "");
102
+ case "checklist":
103
+ return "checklist:" + el.querySelectorAll("[data-md='checklist-item']").length;
104
+ case "callout":
105
+ return "callout:" + (el.getAttribute("data-md-type") || "") + ":" + (el.textContent || "").slice(0, 60);
106
+ default:
107
+ return type + ":" + (el.getAttribute("data-md-label") || el.textContent?.slice(0, 60) || "");
108
+ }
109
+ }
110
+
111
+ /* ── Block matching ── */
112
+
113
+ export function matchBlocks(oldBlocks: BlockDescriptor[], newBlocks: BlockDescriptor[]): BlockMatch[] {
114
+ const oldKeys = oldBlocks.map((b) => b.key);
115
+ const newKeys = newBlocks.map((b) => b.key);
116
+
117
+ const lcs = simpleLCS(oldKeys, newKeys);
118
+ const matches: BlockMatch[] = [];
119
+
120
+ let oi = 0, ni = 0, li = 0;
121
+ while (oi < oldBlocks.length || ni < newBlocks.length) {
122
+ if (
123
+ li < lcs.length && oi < oldBlocks.length && ni < newBlocks.length &&
124
+ oldKeys[oi] === lcs[li] && newKeys[ni] === lcs[li]
125
+ ) {
126
+ const ob = oldBlocks[oi], nb = newBlocks[ni];
127
+ const childMatches = (ob.children.length > 0 || nb.children.length > 0)
128
+ ? matchBlocks(ob.children, nb.children) : undefined;
129
+ matches.push({ kind: "matched", oldBlock: ob, newBlock: nb, childMatches });
130
+ oi++; ni++; li++;
131
+ } else if (oi < oldBlocks.length && (li >= lcs.length || oldKeys[oi] !== lcs[li])) {
132
+ matches.push({ kind: "removed", oldBlock: oldBlocks[oi] });
133
+ oi++;
134
+ } else if (ni < newBlocks.length) {
135
+ matches.push({ kind: "added", newBlock: newBlocks[ni] });
136
+ ni++;
137
+ }
138
+ }
139
+
140
+ return matches;
141
+ }
142
+
143
+ /* ── Unified DOM construction ── */
144
+
145
+ export function buildUnifiedDom(matches: BlockMatch[]): { fragment: DocumentFragment; hasChanges: boolean } {
146
+ const fragment = document.createDocumentFragment();
147
+ let hasChanges = false;
148
+
149
+ for (const match of matches) {
150
+ if (match.kind === "removed") {
151
+ hasChanges = true;
152
+ fragment.appendChild(ghostBlock(match.oldBlock!.element));
153
+ } else if (match.kind === "added") {
154
+ hasChanges = true;
155
+ fragment.appendChild(addedBlock(match.newBlock!.element));
156
+ } else {
157
+ const clone = match.newBlock!.element.cloneNode(true) as HTMLElement;
158
+ const type = match.newBlock!.type;
159
+
160
+ if (match.childMatches && match.childMatches.length > 0 && (type === "table" || type === "checklist")) {
161
+ if (processListLike(clone, match.childMatches, type)) hasChanges = true;
162
+ } else if (match.childMatches && match.childMatches.length > 0) {
163
+ if (processBlockWithChildren(match.oldBlock!, clone, match.childMatches, type)) hasChanges = true;
164
+ } else {
165
+ if (applyWordDiff(match.oldBlock!.element, clone, false)) hasChanges = true;
166
+ }
167
+
168
+ fragment.appendChild(clone);
169
+ }
170
+ }
171
+
172
+ return { fragment, hasChanges };
173
+ }
174
+
175
+ function ghostBlock(sourceEl: HTMLElement): HTMLElement {
176
+ const clone = sourceEl.cloneNode(true) as HTMLElement;
177
+ clone.classList.add("diff-block-removed");
178
+ clone.style.position = "relative";
179
+ appendLabel(clone, "Removed", "var(--color-accent-red)");
180
+ return clone;
181
+ }
182
+
183
+ function addedBlock(sourceEl: HTMLElement): HTMLElement {
184
+ const clone = sourceEl.cloneNode(true) as HTMLElement;
185
+ clone.classList.add("diff-block-added");
186
+ clone.style.position = "relative";
187
+ appendLabel(clone, "Added", "var(--color-accent-green)");
188
+ return clone;
189
+ }
190
+
191
+ /** Process a matched block that has children (section, item with nested blocks). */
192
+ function processBlockWithChildren(
193
+ oldBlock: BlockDescriptor,
194
+ newClone: HTMLElement,
195
+ childMatches: BlockMatch[],
196
+ type: string
197
+ ): boolean {
198
+ let hasChanges = false;
199
+
200
+ // Build a map from original new elements → cloned elements
201
+ const cloneMap = buildCloneMap(oldBlock, newClone);
202
+
203
+ // Determine content containers
204
+ let oldContainer: HTMLElement;
205
+ let newContainer: HTMLElement;
206
+
207
+ if (type === "section") {
208
+ // Diff the section title separately
209
+ const oldHeader = oldBlock.element.querySelector<HTMLElement>(":scope > div:first-child");
210
+ const newHeader = newClone.querySelector<HTMLElement>(":scope > div:first-child");
211
+ if (oldHeader && newHeader) {
212
+ if (applyWordDiff(oldHeader, newHeader, false)) hasChanges = true;
213
+ }
214
+
215
+ oldContainer = oldBlock.element.querySelector<HTMLElement>(":scope > div.mt-6, :scope > div:last-child")!;
216
+ newContainer = newClone.querySelector<HTMLElement>(":scope > div.mt-6, :scope > div:last-child")!;
217
+ } else {
218
+ oldContainer = oldBlock.element;
219
+ newContainer = newClone;
220
+ }
221
+
222
+ if (!oldContainer || !newContainer) return hasChanges;
223
+
224
+ // Process child block matches in-place on the clone
225
+ for (const cm of childMatches) {
226
+ if (cm.kind === "matched") {
227
+ const newChild = cloneMap.newMap.get(cm.newBlock!.element);
228
+ if (newChild) {
229
+ if (cm.childMatches && cm.childMatches.length > 0 && (cm.newBlock!.type === "table" || cm.newBlock!.type === "checklist")) {
230
+ if (processListLike(newChild, cm.childMatches, cm.newBlock!.type)) hasChanges = true;
231
+ } else if (cm.childMatches && cm.childMatches.length > 0) {
232
+ if (processBlockWithChildren(cm.oldBlock!, newChild, cm.childMatches, cm.newBlock!.type))
233
+ hasChanges = true;
234
+ } else {
235
+ if (applyWordDiff(cm.oldBlock!.element, newChild, false)) hasChanges = true;
236
+ }
237
+ }
238
+ } else if (cm.kind === "removed") {
239
+ hasChanges = true;
240
+ const ghost = ghostBlock(cm.oldBlock!.element);
241
+ const insertBefore = findInsertionPoint(childMatches, cm, cloneMap.newMap);
242
+ newContainer.insertBefore(ghost, insertBefore);
243
+ } else if (cm.kind === "added") {
244
+ hasChanges = true;
245
+ const newChild = cloneMap.newMap.get(cm.newBlock!.element);
246
+ if (newChild) {
247
+ newChild.classList.add("diff-block-added");
248
+ newChild.style.position = "relative";
249
+ appendLabel(newChild, "Added", "var(--color-accent-green)");
250
+ }
251
+ }
252
+ }
253
+
254
+ // Word-diff the non-block text (description, body text between blocks)
255
+ if (applyWordDiff(oldContainer, newContainer, true)) hasChanges = true;
256
+
257
+ return hasChanges;
258
+ }
259
+
260
+ /** Process a matched table or checklist: rebuild children with item-level matches. */
261
+ function processListLike(clone: HTMLElement, childMatches: BlockMatch[], type: string): boolean {
262
+ // Find the container that holds the children
263
+ const container = type === "table"
264
+ ? clone.querySelector<HTMLElement>(":scope table > tbody")
265
+ : clone; // checklist: <ul> is the clone itself
266
+
267
+ if (!container) return false;
268
+ let hasChanges = false;
269
+
270
+ container.innerHTML = "";
271
+ for (const cm of childMatches) {
272
+ if (cm.kind === "removed") {
273
+ hasChanges = true;
274
+ const child = cm.oldBlock!.element.cloneNode(true) as HTMLElement;
275
+ child.classList.add(type === "table" ? "diff-row-removed" : "diff-item-removed");
276
+ container.appendChild(child);
277
+ } else if (cm.kind === "added") {
278
+ hasChanges = true;
279
+ const child = cm.newBlock!.element.cloneNode(true) as HTMLElement;
280
+ child.classList.add(type === "table" ? "diff-row-added" : "diff-item-added");
281
+ container.appendChild(child);
282
+ } else {
283
+ const child = cm.newBlock!.element.cloneNode(true) as HTMLElement;
284
+ if (applyWordDiff(cm.oldBlock!.element, child, false)) hasChanges = true;
285
+ container.appendChild(child);
286
+ }
287
+ }
288
+
289
+ return hasChanges;
290
+ }
291
+
292
+ /** Build a bidirectional map between original and cloned [data-md] elements. */
293
+ function buildCloneMap(oldBlock: BlockDescriptor, newClone: HTMLElement) {
294
+ const newMap = new Map<HTMLElement, HTMLElement>();
295
+
296
+ // Map new block's original elements to their clones by walking querySelectorAll in parallel
297
+ // We need the original new block's element — but we only have the old block and the clone.
298
+ // The clone IS the cloneNode of newBlock.element. So walk the old block's new counterpart
299
+ // by using the children from childMatches.
300
+ // Actually, we can map by matching data-md attributes + position.
301
+ const origEls = oldBlock.element.querySelectorAll<HTMLElement>("[data-md]");
302
+ const cloneEls = newClone.querySelectorAll<HTMLElement>("[data-md]");
303
+
304
+ // For sections/items: the clone is from the NEW block. We need to map
305
+ // new block's original children → cloned children. But we don't have newBlock.element here.
306
+ // Instead, just map by querySelectorAll order on the clone's source structure.
307
+ // Since we can't access the original new element, let's store the mapping differently.
308
+
309
+ // We'll use a different approach: find elements in the clone by data-md attributes
310
+ return {
311
+ newMap: {
312
+ get(originalNewEl: HTMLElement): HTMLElement | null {
313
+ return findMatchingClonedElement(newClone, originalNewEl);
314
+ }
315
+ } as Map<HTMLElement, HTMLElement | null>
316
+ };
317
+ }
318
+
319
+ /** Find the cloned version of an element by matching data-md attributes and position. */
320
+ function findMatchingClonedElement(cloneRoot: HTMLElement, originalEl: HTMLElement): HTMLElement | null {
321
+ const type = originalEl.getAttribute("data-md");
322
+ if (!type) return null;
323
+
324
+ const label = originalEl.getAttribute("data-md-label");
325
+ const title = originalEl.getAttribute("data-md-title");
326
+
327
+ let selector = `[data-md="${type}"]`;
328
+ if (title) selector += `[data-md-title="${CSS.escape(title)}"]`;
329
+ else if (label) selector += `[data-md-label="${CSS.escape(label)}"]`;
330
+
331
+ const candidates = cloneRoot.querySelectorAll<HTMLElement>(selector);
332
+ if (candidates.length === 1) return candidates[0];
333
+
334
+ // Multiple matches — find by text similarity
335
+ const targetText = (originalEl.textContent || "").slice(0, 100);
336
+ let best: HTMLElement | null = null;
337
+ let bestScore = -1;
338
+ for (const c of candidates) {
339
+ const cText = (c.textContent || "").slice(0, 100);
340
+ let score = 0;
341
+ for (let i = 0; i < Math.min(targetText.length, cText.length); i++) {
342
+ if (targetText[i] === cText[i]) score++;
343
+ }
344
+ if (score > bestScore) { bestScore = score; best = c; }
345
+ }
346
+ return best;
347
+ }
348
+
349
+ /** Find where to insert a ghost block: before the next matched/added sibling's clone. */
350
+ function findInsertionPoint(
351
+ childMatches: BlockMatch[],
352
+ removedMatch: BlockMatch,
353
+ cloneMap: Map<HTMLElement, HTMLElement | null>
354
+ ): HTMLElement | null {
355
+ const idx = childMatches.indexOf(removedMatch);
356
+ for (let i = idx + 1; i < childMatches.length; i++) {
357
+ const cm = childMatches[i];
358
+ if ((cm.kind === "matched" || cm.kind === "added") && cm.newBlock) {
359
+ return cloneMap.get(cm.newBlock.element) || null;
360
+ }
361
+ }
362
+ return null;
363
+ }
364
+
365
+ /* ── Word-level diff with combined single-pass rebuild ── */
366
+
367
+ /**
368
+ * Extract words from a container, optionally excluding text inside nested [data-md] elements.
369
+ */
370
+ function doExtractWords(container: HTMLElement, excludeBlocks: boolean): { words: string[]; map: WordEntry[] } {
371
+ const blockEls = excludeBlocks
372
+ ? new Set(Array.from(container.querySelectorAll<HTMLElement>("[data-md]")))
373
+ : new Set<HTMLElement>();
374
+
375
+ const words: string[] = [];
376
+ const map: WordEntry[] = [];
377
+
378
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
379
+ acceptNode(node) {
380
+ const parent = node.parentElement;
381
+ if (!parent) return NodeFilter.FILTER_REJECT;
382
+ if (parent.closest("svg, .select-none, [data-no-diff]")) return NodeFilter.FILTER_REJECT;
383
+ if (excludeBlocks) {
384
+ for (const blockEl of blockEls) {
385
+ if (blockEl.contains(node)) return NodeFilter.FILTER_REJECT;
386
+ }
387
+ }
388
+ return NodeFilter.FILTER_ACCEPT;
389
+ },
390
+ });
391
+
392
+ let textNode: Text | null;
393
+ while ((textNode = walker.nextNode() as Text | null)) {
394
+ const content = textNode.nodeValue || "";
395
+ const tokens = content.match(/\S+|\s+/g);
396
+ if (!tokens) continue;
397
+ let offset = 0;
398
+ for (const token of tokens) {
399
+ words.push(token);
400
+ map.push({ text: token, node: textNode, offset });
401
+ offset += token.length;
402
+ }
403
+ }
404
+
405
+ return { words, map };
406
+ }
407
+
408
+ /**
409
+ * Compute word diff between old and new elements, then apply highlights and
410
+ * removed-word insertions in a single per-text-node pass (avoids stale references).
411
+ *
412
+ * @param excludeBlocks If true, skip text inside nested [data-md] elements.
413
+ */
414
+ function applyWordDiff(oldEl: HTMLElement, newEl: HTMLElement, excludeBlocks: boolean): boolean {
415
+ const oldExtract = doExtractWords(oldEl, excludeBlocks);
416
+ const newExtract = doExtractWords(newEl, excludeBlocks);
417
+
418
+ if (oldExtract.words.length === 0 && newExtract.words.length === 0) return false;
419
+
420
+ const { oldOps, newOps } = computeWordDiff(oldExtract.words, newExtract.words);
421
+
422
+ const hasRemoved = oldOps.some((op) => op.type === "removed");
423
+ const hasAdded = newOps.some((op) => op.type === "added");
424
+ if (!hasRemoved && !hasAdded) return false;
425
+
426
+ // Build same-word mapping: oldIndex → newIndex by position in same-sequence
427
+ const oldSameIndices: number[] = [];
428
+ const newSameIndices: number[] = [];
429
+ for (const op of oldOps) if (op.type === "same") oldSameIndices.push(op.index);
430
+ for (const op of newOps) if (op.type === "same") newSameIndices.push(op.index);
431
+ const sameMap = new Map<number, number>();
432
+ for (let i = 0; i < oldSameIndices.length && i < newSameIndices.length; i++) {
433
+ sameMap.set(oldSameIndices[i], newSameIndices[i]);
434
+ }
435
+
436
+ // Group consecutive removed words and map each group to its anchor in new
437
+ interface RemovedGroup { text: string; anchorNewIdx: number | null }
438
+ const removedGroups: RemovedGroup[] = [];
439
+ {
440
+ let group: string[] = [];
441
+ let lastSameOldIdx = -1;
442
+ for (const op of oldOps) {
443
+ if (op.type === "same") {
444
+ if (group.length > 0) {
445
+ removedGroups.push({ text: group.join(""), anchorNewIdx: lastSameOldIdx >= 0 ? (sameMap.get(lastSameOldIdx) ?? null) : null });
446
+ group = [];
447
+ }
448
+ lastSameOldIdx = op.index;
449
+ } else if (op.type === "removed") {
450
+ group.push(oldExtract.words[op.index]);
451
+ }
452
+ }
453
+ if (group.length > 0) {
454
+ removedGroups.push({ text: group.join(""), anchorNewIdx: lastSameOldIdx >= 0 ? (sameMap.get(lastSameOldIdx) ?? null) : null });
455
+ }
456
+ }
457
+
458
+ // Collect per-text-node edits
459
+ const nodeHighlights = new Map<Text, { start: number; end: number }[]>();
460
+ const nodeInserts = new Map<Text, { offset: number; text: string }[]>();
461
+
462
+ // Added-word highlights
463
+ for (const op of newOps) {
464
+ if (op.type !== "added") continue;
465
+ const entry = newExtract.map[op.index];
466
+ if (!entry?.node.parentNode) continue;
467
+ let list = nodeHighlights.get(entry.node);
468
+ if (!list) { list = []; nodeHighlights.set(entry.node, list); }
469
+ list.push({ start: entry.offset, end: entry.offset + entry.text.length });
470
+ }
471
+
472
+ // Removed-word insertions
473
+ for (const rg of removedGroups) {
474
+ if (rg.anchorNewIdx === null) {
475
+ // Removed at the very start — insert at offset 0 of first text node
476
+ if (newExtract.map.length > 0) {
477
+ const node = newExtract.map[0].node;
478
+ let list = nodeInserts.get(node);
479
+ if (!list) { list = []; nodeInserts.set(node, list); }
480
+ list.push({ offset: 0, text: rg.text });
481
+ }
482
+ } else {
483
+ const entry = newExtract.map[rg.anchorNewIdx];
484
+ if (!entry) continue;
485
+ let list = nodeInserts.get(entry.node);
486
+ if (!list) { list = []; nodeInserts.set(entry.node, list); }
487
+ list.push({ offset: entry.offset + entry.text.length, text: rg.text });
488
+ }
489
+ }
490
+
491
+ // Rebuild each affected text node in a single pass
492
+ const allNodes = new Set([...nodeHighlights.keys(), ...nodeInserts.keys()]);
493
+ for (const textNode of allNodes) {
494
+ rebuildTextNode(textNode, nodeHighlights.get(textNode) || [], nodeInserts.get(textNode) || []);
495
+ }
496
+
497
+ return true;
498
+ }
499
+
500
+ /**
501
+ * Rebuild a single text node with highlight spans and inserted removed-word spans.
502
+ * Processes everything in one pass — no stale text-node references.
503
+ */
504
+ function rebuildTextNode(
505
+ textNode: Text,
506
+ highlights: { start: number; end: number }[],
507
+ inserts: { offset: number; text: string }[]
508
+ ): void {
509
+ if (!textNode.parentNode) return;
510
+ const content = textNode.nodeValue || "";
511
+
512
+ // Merge overlapping/adjacent highlights
513
+ highlights.sort((a, b) => a.start - b.start);
514
+ const mergedHL: { start: number; end: number }[] = [];
515
+ for (const h of highlights) {
516
+ const last = mergedHL[mergedHL.length - 1];
517
+ if (last && h.start <= last.end) {
518
+ last.end = Math.max(last.end, h.end);
519
+ } else {
520
+ mergedHL.push({ ...h });
521
+ }
522
+ }
523
+
524
+ inserts.sort((a, b) => a.offset - b.offset);
525
+
526
+ const parent = textNode.parentNode;
527
+ const frag = document.createDocumentFragment();
528
+ let pos = 0;
529
+ let hlIdx = 0;
530
+ let insIdx = 0;
531
+
532
+ while (pos <= content.length) {
533
+ const nextHLStart = hlIdx < mergedHL.length ? mergedHL[hlIdx].start : Infinity;
534
+ const nextInsOff = insIdx < inserts.length ? inserts[insIdx].offset : Infinity;
535
+ const nextEvent = Math.min(nextHLStart, nextInsOff);
536
+
537
+ if (nextEvent === Infinity) {
538
+ // No more events — output remaining text
539
+ if (pos < content.length) {
540
+ frag.appendChild(document.createTextNode(content.slice(pos)));
541
+ }
542
+ break;
543
+ }
544
+
545
+ // Output plain text up to next event
546
+ const eventPos = Math.max(nextEvent, pos);
547
+ if (eventPos > pos) {
548
+ frag.appendChild(document.createTextNode(content.slice(pos, eventPos)));
549
+ pos = eventPos;
550
+ }
551
+
552
+ // Process inserts at this position (before highlights — removed text comes before added)
553
+ while (insIdx < inserts.length && inserts[insIdx].offset <= pos) {
554
+ const span = document.createElement("span");
555
+ span.className = "diff-removed";
556
+ span.textContent = inserts[insIdx].text;
557
+ frag.appendChild(span);
558
+ insIdx++;
559
+ }
560
+
561
+ // Process highlight starting at this position
562
+ if (hlIdx < mergedHL.length && mergedHL[hlIdx].start <= pos) {
563
+ const hl = mergedHL[hlIdx];
564
+ const span = document.createElement("span");
565
+ span.className = "diff-added";
566
+ span.textContent = content.slice(pos, hl.end);
567
+ frag.appendChild(span);
568
+ pos = hl.end;
569
+ hlIdx++;
570
+
571
+ // Process inserts right after highlight end
572
+ while (insIdx < inserts.length && inserts[insIdx].offset <= pos) {
573
+ const span = document.createElement("span");
574
+ span.className = "diff-removed";
575
+ span.textContent = inserts[insIdx].text;
576
+ frag.appendChild(span);
577
+ insIdx++;
578
+ }
579
+ }
580
+ }
581
+
582
+ // Remaining inserts past end of text
583
+ while (insIdx < inserts.length) {
584
+ const span = document.createElement("span");
585
+ span.className = "diff-removed";
586
+ span.textContent = inserts[insIdx].text;
587
+ frag.appendChild(span);
588
+ insIdx++;
589
+ }
590
+
591
+ parent.replaceChild(frag, textNode);
592
+ }
593
+
594
+ /* ── Helpers ── */
595
+
596
+ function appendLabel(el: HTMLElement, text: string, color: string): void {
597
+ const label = document.createElement("span");
598
+ label.className = "diff-block-label";
599
+ label.textContent = text;
600
+ label.style.color = color;
601
+ label.style.backgroundColor = `color-mix(in srgb, ${color} 12%, transparent)`;
602
+ el.appendChild(label);
603
+ }
604
+