agent-canvas 0.2.0 → 0.6.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 (98) hide show
  1. package/README.md +42 -17
  2. package/bin/agent-canvas.js +7 -7
  3. package/bin/agent-canvas.ts +48 -37
  4. package/bin/lib/commands/daemon.ts +46 -43
  5. package/bin/lib/commands/exec.ts +100 -100
  6. package/bin/lib/commands/fetch.ts +15 -15
  7. package/bin/lib/commands/install.ts +36 -36
  8. package/bin/lib/commands/instructions.ts +51 -51
  9. package/bin/lib/commands/push.ts +115 -97
  10. package/bin/lib/commands/review.ts +190 -0
  11. package/bin/lib/commands/watch.ts +24 -24
  12. package/bin/lib/config.ts +12 -12
  13. package/bin/lib/daemon-lifecycle.ts +47 -51
  14. package/bin/lib/helpers.ts +82 -76
  15. package/bin/lib/instructions/app-review.md +88 -0
  16. package/bin/lib/instructions/component-checklist.md +7 -5
  17. package/bin/lib/instructions/component-custom.md +20 -14
  18. package/bin/lib/instructions/component-diff.md +4 -3
  19. package/bin/lib/instructions/component-item.md +18 -8
  20. package/bin/lib/instructions/component-markdown.md +4 -2
  21. package/bin/lib/instructions/component-mermaid.md +12 -6
  22. package/bin/lib/instructions/component-multichoice.md +6 -2
  23. package/bin/lib/instructions/component-note.md +1 -1
  24. package/bin/lib/instructions/component-priority.md +1 -1
  25. package/bin/lib/instructions/component-rangeinput.md +9 -2
  26. package/bin/lib/instructions/component-secretinput.md +12 -11
  27. package/bin/lib/instructions/component-svg.md +62 -16
  28. package/bin/lib/instructions/component-table.md +6 -6
  29. package/bin/lib/instructions/component-usefeedback.md +8 -8
  30. package/bin/lib/instructions/component-userinput.md +6 -2
  31. package/bin/lib/instructions/flow-feature.md +22 -5
  32. package/bin/lib/instructions/flow-plan.md +5 -5
  33. package/bin/lib/instructions/flow-runbook.md +28 -25
  34. package/bin/lib/instructions/overview.md +27 -18
  35. package/bin/lib/instructions/theming.md +5 -0
  36. package/daemon/build.ts +171 -152
  37. package/daemon/client/App.tsx +1205 -1077
  38. package/daemon/client/CompareView.tsx +478 -458
  39. package/daemon/client/FileBrowser.tsx +241 -203
  40. package/daemon/client/FileViewer.tsx +220 -211
  41. package/daemon/client/OverviewView.tsx +283 -267
  42. package/daemon/client/ReviewerIdentityDialog.tsx +72 -71
  43. package/daemon/client/RevisionSelect.tsx +111 -95
  44. package/daemon/client/SessionSwitcher.tsx +81 -81
  45. package/daemon/client/ShareDialog.tsx +211 -212
  46. package/daemon/client/apiSchemas.ts +78 -0
  47. package/daemon/client/canvas-runtime.ts +1 -1
  48. package/daemon/client/clientApi.ts +194 -206
  49. package/daemon/client/components/SecretInput.tsx +138 -138
  50. package/daemon/client/components/index.ts +2 -2
  51. package/daemon/client/domDiff.ts +341 -333
  52. package/daemon/client/errorMessage.ts +3 -0
  53. package/daemon/client/exportMarkdown.ts +222 -201
  54. package/daemon/client/lcs.ts +19 -14
  55. package/daemon/client/shareCrypto.ts +57 -57
  56. package/daemon/client/unifiedDiff.ts +660 -649
  57. package/daemon/dist/annotations-runtime.js +1 -1
  58. package/daemon/dist/client.css +2 -2
  59. package/daemon/dist/client.js +77 -80
  60. package/daemon/dist/components.js +59 -59
  61. package/daemon/dist/preact-compat.js +1 -1
  62. package/daemon/dist/review.js +361 -0
  63. package/daemon/dist/runtime.js +1 -1
  64. package/daemon/langMap.ts +24 -6
  65. package/daemon/review-widget/anchor.ts +187 -0
  66. package/daemon/review-widget/client.ts +95 -0
  67. package/daemon/review-widget/index.tsx +36 -0
  68. package/daemon/review-widget/markup.ts +71 -0
  69. package/daemon/review-widget/screenshot.ts +91 -0
  70. package/daemon/review-widget/source.ts +103 -0
  71. package/daemon/review-widget/styles.css +359 -0
  72. package/daemon/review-widget/text-modules.d.ts +4 -0
  73. package/daemon/review-widget/tsconfig.json +16 -0
  74. package/daemon/review-widget/widget.tsx +436 -0
  75. package/daemon/src/components.ts +3 -3
  76. package/daemon/src/handlers/api.ts +330 -328
  77. package/daemon/src/handlers/files.ts +112 -105
  78. package/daemon/src/handlers/static.ts +47 -46
  79. package/daemon/src/handlers/uploads.ts +61 -56
  80. package/daemon/src/paths.ts +13 -10
  81. package/daemon/src/remote-feedback.ts +192 -192
  82. package/daemon/src/review/markdown.ts +52 -0
  83. package/daemon/src/review/model.ts +132 -0
  84. package/daemon/src/review/proxy.ts +177 -0
  85. package/daemon/src/review/server.ts +199 -0
  86. package/daemon/src/review/store.ts +151 -0
  87. package/daemon/src/server.ts +127 -86
  88. package/daemon/src/share.ts +163 -169
  89. package/daemon/src/stubFetch.ts +7 -0
  90. package/daemon/tsconfig.json +19 -19
  91. package/package.json +47 -47
  92. package/skills/canvas/SKILL.md +9 -1
  93. package/bin/lib/commands/exec.test.ts +0 -27
  94. package/bin/lib/helpers.test.ts +0 -79
  95. package/daemon/src/remote-feedback.test.ts +0 -165
  96. package/daemon/src/secrets-api.test.ts +0 -123
  97. package/daemon/src/share-e2e.test.ts +0 -277
  98. package/daemon/src/share.test.ts +0 -289
@@ -6,383 +6,375 @@
6
6
  * 3. Build a single unified DOM with inline add/remove highlights
7
7
  */
8
8
 
9
- import { computeWordDiff } from "./domDiff";
10
- import { simpleLCS } from "./lcs";
9
+ import { computeWordDiff } from './domDiff'
10
+ import { simpleLCS } from './lcs'
11
11
 
12
12
  interface WordEntry {
13
- text: string;
14
- node: Text;
15
- offset: number;
13
+ text: string
14
+ node: Text
15
+ offset: number
16
16
  }
17
17
 
18
18
  interface BlockDescriptor {
19
- key: string;
20
- type: string;
21
- element: HTMLElement;
22
- children: BlockDescriptor[];
19
+ key: string
20
+ type: string
21
+ element: HTMLElement
22
+ children: BlockDescriptor[]
23
23
  }
24
24
 
25
25
  interface BlockMatch {
26
- kind: "matched" | "added" | "removed";
27
- oldBlock?: BlockDescriptor;
28
- newBlock?: BlockDescriptor;
29
- childMatches?: BlockMatch[];
26
+ kind: 'matched' | 'added' | 'removed'
27
+ oldBlock?: BlockDescriptor
28
+ newBlock?: BlockDescriptor
29
+ childMatches?: BlockMatch[]
30
30
  }
31
31
 
32
32
  /* ── Block tree extraction ── */
33
33
 
34
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;
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
56
  }
57
57
 
58
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 };
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
89
  }
90
90
 
91
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 "markdown":
105
- return "markdown:" + (el.getAttribute("data-md-file") || "inline");
106
- case "mermaid":
107
- return "mermaid:" + (el.getAttribute("data-md-source") || "").slice(0, 80);
108
- case "callout":
109
- return "callout:" + (el.getAttribute("data-md-type") || "") + ":" + (el.textContent || "").slice(0, 60);
110
- default:
111
- return type + ":" + (el.getAttribute("data-md-label") || el.textContent?.slice(0, 60) || "");
112
- }
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 'markdown':
105
+ return 'markdown:' + (el.getAttribute('data-md-file') || 'inline')
106
+ case 'mermaid':
107
+ return 'mermaid:' + (el.getAttribute('data-md-source') || '').slice(0, 80)
108
+ case 'callout':
109
+ return 'callout:' + (el.getAttribute('data-md-type') || '') + ':' + (el.textContent || '').slice(0, 60)
110
+ default:
111
+ return type + ':' + (el.getAttribute('data-md-label') || el.textContent?.slice(0, 60) || '')
112
+ }
113
113
  }
114
114
 
115
115
  /* ── Block matching ── */
116
116
 
117
117
  export function matchBlocks(oldBlocks: BlockDescriptor[], newBlocks: BlockDescriptor[]): BlockMatch[] {
118
- const oldKeys = oldBlocks.map((b) => b.key);
119
- const newKeys = newBlocks.map((b) => b.key);
120
-
121
- const lcs = simpleLCS(oldKeys, newKeys);
122
- const matches: BlockMatch[] = [];
123
-
124
- let oi = 0, ni = 0, li = 0;
125
- while (oi < oldBlocks.length || ni < newBlocks.length) {
126
- if (
127
- li < lcs.length && oi < oldBlocks.length && ni < newBlocks.length &&
128
- oldKeys[oi] === lcs[li] && newKeys[ni] === lcs[li]
129
- ) {
130
- const ob = oldBlocks[oi], nb = newBlocks[ni];
131
- const childMatches = (ob.children.length > 0 || nb.children.length > 0)
132
- ? matchBlocks(ob.children, nb.children) : undefined;
133
- matches.push({ kind: "matched", oldBlock: ob, newBlock: nb, childMatches });
134
- oi++; ni++; li++;
135
- } else if (oi < oldBlocks.length && (li >= lcs.length || oldKeys[oi] !== lcs[li])) {
136
- matches.push({ kind: "removed", oldBlock: oldBlocks[oi] });
137
- oi++;
138
- } else if (ni < newBlocks.length) {
139
- matches.push({ kind: "added", newBlock: newBlocks[ni] });
140
- ni++;
141
- }
142
- }
143
-
144
- return matches;
118
+ const oldKeys = oldBlocks.map((b) => b.key)
119
+ const newKeys = newBlocks.map((b) => b.key)
120
+
121
+ const lcs = simpleLCS(oldKeys, newKeys)
122
+ const matches: BlockMatch[] = []
123
+
124
+ let oi = 0, ni = 0, li = 0
125
+ while (oi < oldBlocks.length || ni < newBlocks.length) {
126
+ if (
127
+ li < lcs.length && oi < oldBlocks.length && ni < newBlocks.length
128
+ && oldKeys[oi] === lcs[li] && newKeys[ni] === lcs[li]
129
+ ) {
130
+ const ob = oldBlocks[oi], nb = newBlocks[ni]
131
+ const childMatches = (ob.children.length > 0 || nb.children.length > 0)
132
+ ? matchBlocks(ob.children, nb.children)
133
+ : undefined
134
+ matches.push({ kind: 'matched', oldBlock: ob, newBlock: nb, childMatches })
135
+ oi++
136
+ ni++
137
+ li++
138
+ } else if (oi < oldBlocks.length && (li >= lcs.length || oldKeys[oi] !== lcs[li])) {
139
+ matches.push({ kind: 'removed', oldBlock: oldBlocks[oi] })
140
+ oi++
141
+ } else if (ni < newBlocks.length) {
142
+ matches.push({ kind: 'added', newBlock: newBlocks[ni] })
143
+ ni++
144
+ }
145
+ }
146
+
147
+ return matches
145
148
  }
146
149
 
147
150
  /* ── Unified DOM construction ── */
148
151
 
149
152
  export function buildUnifiedDom(matches: BlockMatch[]): { fragment: DocumentFragment; hasChanges: boolean } {
150
- const fragment = document.createDocumentFragment();
151
- let hasChanges = false;
152
-
153
- for (const match of matches) {
154
- if (match.kind === "removed") {
155
- hasChanges = true;
156
- fragment.appendChild(ghostBlock(match.oldBlock!.element));
157
- } else if (match.kind === "added") {
158
- hasChanges = true;
159
- fragment.appendChild(addedBlock(match.newBlock!.element));
160
- } else {
161
- const clone = match.newBlock!.element.cloneNode(true) as HTMLElement;
162
- const type = match.newBlock!.type;
163
-
164
- if (type === "mermaid") {
165
- if (applyMermaidDiff(match.oldBlock!.element, clone)) hasChanges = true;
166
- } else if (match.childMatches && match.childMatches.length > 0 && (type === "table" || type === "checklist")) {
167
- if (processListLike(clone, match.childMatches, type)) hasChanges = true;
168
- } else if (match.childMatches && match.childMatches.length > 0) {
169
- if (processBlockWithChildren(match.oldBlock!, clone, match.childMatches, type)) hasChanges = true;
170
- } else if (type === "markdown") {
171
- if (applyElementLevelDiff(match.oldBlock!.element, clone)) hasChanges = true;
172
- } else {
173
- if (applyWordDiff(match.oldBlock!.element, clone, false)) hasChanges = true;
174
- }
175
-
176
- fragment.appendChild(clone);
177
- }
178
- }
179
-
180
- return { fragment, hasChanges };
153
+ const fragment = document.createDocumentFragment()
154
+ let hasChanges = false
155
+
156
+ for (const match of matches) {
157
+ if (match.kind === 'removed') {
158
+ hasChanges = true
159
+ fragment.appendChild(ghostBlock(match.oldBlock!.element))
160
+ } else if (match.kind === 'added') {
161
+ hasChanges = true
162
+ fragment.appendChild(addedBlock(match.newBlock!.element))
163
+ } else {
164
+ const clone = match.newBlock!.element.cloneNode(true) as HTMLElement
165
+ const type = match.newBlock!.type
166
+
167
+ if (type === 'mermaid') {
168
+ if (applyMermaidDiff(match.oldBlock!.element, clone)) hasChanges = true
169
+ } else if (match.childMatches && match.childMatches.length > 0 && (type === 'table' || type === 'checklist')) {
170
+ if (processListLike(clone, match.childMatches, type)) hasChanges = true
171
+ } else if (match.childMatches && match.childMatches.length > 0) {
172
+ if (processBlockWithChildren(match.oldBlock!, clone, match.childMatches, type)) hasChanges = true
173
+ } else if (type === 'markdown') {
174
+ if (applyElementLevelDiff(match.oldBlock!.element, clone)) hasChanges = true
175
+ } else {
176
+ if (applyWordDiff(match.oldBlock!.element, clone, false)) hasChanges = true
177
+ }
178
+
179
+ fragment.appendChild(clone)
180
+ }
181
+ }
182
+
183
+ return { fragment, hasChanges }
181
184
  }
182
185
 
183
186
  function ghostBlock(sourceEl: HTMLElement): HTMLElement {
184
- const clone = sourceEl.cloneNode(true) as HTMLElement;
185
- clone.classList.add("diff-block-removed");
186
- clone.style.position = "relative";
187
- appendLabel(clone, "Removed", "var(--color-accent-red)");
188
- return clone;
187
+ const clone = sourceEl.cloneNode(true) as HTMLElement
188
+ clone.classList.add('diff-block-removed')
189
+ clone.style.position = 'relative'
190
+ appendLabel(clone, 'Removed', 'var(--color-accent-red)')
191
+ return clone
189
192
  }
190
193
 
191
194
  function addedBlock(sourceEl: HTMLElement): HTMLElement {
192
- const clone = sourceEl.cloneNode(true) as HTMLElement;
193
- clone.classList.add("diff-block-added");
194
- clone.style.position = "relative";
195
- appendLabel(clone, "Added", "var(--color-accent-green)");
196
- return clone;
195
+ const clone = sourceEl.cloneNode(true) as HTMLElement
196
+ clone.classList.add('diff-block-added')
197
+ clone.style.position = 'relative'
198
+ appendLabel(clone, 'Added', 'var(--color-accent-green)')
199
+ return clone
197
200
  }
198
201
 
199
202
  /** Process a matched block that has children (section, item with nested blocks). */
200
203
  function processBlockWithChildren(
201
- oldBlock: BlockDescriptor,
202
- newClone: HTMLElement,
203
- childMatches: BlockMatch[],
204
- type: string
204
+ oldBlock: BlockDescriptor,
205
+ newClone: HTMLElement,
206
+ childMatches: BlockMatch[],
207
+ type: string,
205
208
  ): boolean {
206
- let hasChanges = false;
207
-
208
- // Build a map from original new elements → cloned elements
209
- const cloneMap = buildCloneMap(oldBlock, newClone);
210
-
211
- // Determine content containers
212
- let oldContainer: HTMLElement;
213
- let newContainer: HTMLElement;
214
-
215
- if (type === "section") {
216
- // Diff the section title separately
217
- const oldHeader = oldBlock.element.querySelector<HTMLElement>(":scope > div:first-child");
218
- const newHeader = newClone.querySelector<HTMLElement>(":scope > div:first-child");
219
- if (oldHeader && newHeader) {
220
- if (applyWordDiff(oldHeader, newHeader, false)) hasChanges = true;
221
- }
222
-
223
- oldContainer = oldBlock.element.querySelector<HTMLElement>(":scope > div.mt-6, :scope > div:last-child")!;
224
- newContainer = newClone.querySelector<HTMLElement>(":scope > div.mt-6, :scope > div:last-child")!;
225
- } else {
226
- oldContainer = oldBlock.element;
227
- newContainer = newClone;
228
- }
229
-
230
- if (!oldContainer || !newContainer) return hasChanges;
231
-
232
- // Process child block matches in-place on the clone
233
- for (const cm of childMatches) {
234
- if (cm.kind === "matched") {
235
- const newChild = cloneMap.newMap.get(cm.newBlock!.element);
236
- if (newChild) {
237
- if (cm.newBlock!.type === "mermaid") {
238
- if (applyMermaidDiff(cm.oldBlock!.element, newChild)) hasChanges = true;
239
- } else if (cm.childMatches && cm.childMatches.length > 0 && (cm.newBlock!.type === "table" || cm.newBlock!.type === "checklist")) {
240
- if (processListLike(newChild, cm.childMatches, cm.newBlock!.type)) hasChanges = true;
241
- } else if (cm.childMatches && cm.childMatches.length > 0) {
242
- if (processBlockWithChildren(cm.oldBlock!, newChild, cm.childMatches, cm.newBlock!.type))
243
- hasChanges = true;
244
- } else if (cm.newBlock!.type === "markdown") {
245
- if (applyElementLevelDiff(cm.oldBlock!.element, newChild)) hasChanges = true;
246
- } else {
247
- if (applyWordDiff(cm.oldBlock!.element, newChild, false)) hasChanges = true;
248
- }
249
- }
250
- } else if (cm.kind === "removed") {
251
- hasChanges = true;
252
- const ghost = ghostBlock(cm.oldBlock!.element);
253
- const refNode = findInsertionPoint(childMatches, cm, cloneMap.newMap);
254
- // refNode may be deeply nested — walk up to find a direct child of the container
255
- const insertBefore = refNode ? directChildOf(newContainer, refNode) : null;
256
- newContainer.insertBefore(ghost, insertBefore);
257
- } else if (cm.kind === "added") {
258
- hasChanges = true;
259
- const newChild = cloneMap.newMap.get(cm.newBlock!.element);
260
- if (newChild) {
261
- newChild.classList.add("diff-block-added");
262
- newChild.style.position = "relative";
263
- appendLabel(newChild, "Added", "var(--color-accent-green)");
264
- }
265
- }
266
- }
267
-
268
- // Word-diff the non-block text (description, body text between blocks)
269
- if (applyWordDiff(oldContainer, newContainer, true)) hasChanges = true;
270
-
271
- return hasChanges;
209
+ let hasChanges = false
210
+
211
+ // Build a map from original new elements → cloned elements
212
+ const cloneMap = buildCloneMap(oldBlock, newClone)
213
+
214
+ // Determine content containers
215
+ let oldContainer: HTMLElement
216
+ let newContainer: HTMLElement
217
+
218
+ if (type === 'section') {
219
+ // Diff the section title separately
220
+ const oldHeader = oldBlock.element.querySelector<HTMLElement>(':scope > div:first-child')
221
+ const newHeader = newClone.querySelector<HTMLElement>(':scope > div:first-child')
222
+ if (oldHeader && newHeader) {
223
+ if (applyWordDiff(oldHeader, newHeader, false)) hasChanges = true
224
+ }
225
+
226
+ oldContainer = oldBlock.element.querySelector<HTMLElement>(':scope > div.mt-6, :scope > div:last-child')!
227
+ newContainer = newClone.querySelector<HTMLElement>(':scope > div.mt-6, :scope > div:last-child')!
228
+ } else {
229
+ oldContainer = oldBlock.element
230
+ newContainer = newClone
231
+ }
232
+
233
+ if (!oldContainer || !newContainer) return hasChanges
234
+
235
+ // Process child block matches in-place on the clone
236
+ for (const cm of childMatches) {
237
+ if (cm.kind === 'matched') {
238
+ const newChild = cloneMap.newMap.get(cm.newBlock!.element)
239
+ if (newChild) {
240
+ if (cm.newBlock!.type === 'mermaid') {
241
+ if (applyMermaidDiff(cm.oldBlock!.element, newChild)) hasChanges = true
242
+ } else if (cm.childMatches && cm.childMatches.length > 0 && (cm.newBlock!.type === 'table' || cm.newBlock!.type === 'checklist')) {
243
+ if (processListLike(newChild, cm.childMatches, cm.newBlock!.type)) hasChanges = true
244
+ } else if (cm.childMatches && cm.childMatches.length > 0) {
245
+ if (processBlockWithChildren(cm.oldBlock!, newChild, cm.childMatches, cm.newBlock!.type)) {
246
+ hasChanges = true
247
+ }
248
+ } else if (cm.newBlock!.type === 'markdown') {
249
+ if (applyElementLevelDiff(cm.oldBlock!.element, newChild)) hasChanges = true
250
+ } else {
251
+ if (applyWordDiff(cm.oldBlock!.element, newChild, false)) hasChanges = true
252
+ }
253
+ }
254
+ } else if (cm.kind === 'removed') {
255
+ hasChanges = true
256
+ const ghost = ghostBlock(cm.oldBlock!.element)
257
+ const refNode = findInsertionPoint(childMatches, cm, cloneMap.newMap)
258
+ // refNode may be deeply nested — walk up to find a direct child of the container
259
+ const insertBefore = refNode ? directChildOf(newContainer, refNode) : null
260
+ newContainer.insertBefore(ghost, insertBefore)
261
+ } else if (cm.kind === 'added') {
262
+ hasChanges = true
263
+ const newChild = cloneMap.newMap.get(cm.newBlock!.element)
264
+ if (newChild) {
265
+ newChild.classList.add('diff-block-added')
266
+ newChild.style.position = 'relative'
267
+ appendLabel(newChild, 'Added', 'var(--color-accent-green)')
268
+ }
269
+ }
270
+ }
271
+
272
+ // Word-diff the non-block text (description, body text between blocks)
273
+ if (applyWordDiff(oldContainer, newContainer, true)) hasChanges = true
274
+
275
+ return hasChanges
272
276
  }
273
277
 
274
278
  /** Process a matched table or checklist: rebuild children with item-level matches. */
275
279
  function processListLike(clone: HTMLElement, childMatches: BlockMatch[], type: string): boolean {
276
- // Find the container that holds the children
277
- const container = type === "table"
278
- ? clone.querySelector<HTMLElement>(":scope table > tbody")
279
- : clone; // checklist: <ul> is the clone itself
280
-
281
- if (!container) return false;
282
- let hasChanges = false;
283
-
284
- container.innerHTML = "";
285
- for (const cm of childMatches) {
286
- if (cm.kind === "removed") {
287
- hasChanges = true;
288
- const child = cm.oldBlock!.element.cloneNode(true) as HTMLElement;
289
- child.classList.add(type === "table" ? "diff-row-removed" : "diff-item-removed");
290
- container.appendChild(child);
291
- } else if (cm.kind === "added") {
292
- hasChanges = true;
293
- const child = cm.newBlock!.element.cloneNode(true) as HTMLElement;
294
- child.classList.add(type === "table" ? "diff-row-added" : "diff-item-added");
295
- container.appendChild(child);
296
- } else {
297
- const child = cm.newBlock!.element.cloneNode(true) as HTMLElement;
298
- if (applyWordDiff(cm.oldBlock!.element, child, false)) hasChanges = true;
299
- container.appendChild(child);
300
- }
301
- }
302
-
303
- return hasChanges;
280
+ // Find the container that holds the children
281
+ const container = type === 'table'
282
+ ? clone.querySelector<HTMLElement>(':scope table > tbody')
283
+ : clone // checklist: <ul> is the clone itself
284
+
285
+ if (!container) return false
286
+ let hasChanges = false
287
+
288
+ container.innerHTML = ''
289
+ for (const cm of childMatches) {
290
+ if (cm.kind === 'removed') {
291
+ hasChanges = true
292
+ const child = cm.oldBlock!.element.cloneNode(true) as HTMLElement
293
+ child.classList.add(type === 'table' ? 'diff-row-removed' : 'diff-item-removed')
294
+ container.appendChild(child)
295
+ } else if (cm.kind === 'added') {
296
+ hasChanges = true
297
+ const child = cm.newBlock!.element.cloneNode(true) as HTMLElement
298
+ child.classList.add(type === 'table' ? 'diff-row-added' : 'diff-item-added')
299
+ container.appendChild(child)
300
+ } else {
301
+ const child = cm.newBlock!.element.cloneNode(true) as HTMLElement
302
+ if (applyWordDiff(cm.oldBlock!.element, child, false)) hasChanges = true
303
+ container.appendChild(child)
304
+ }
305
+ }
306
+
307
+ return hasChanges
304
308
  }
305
309
 
306
310
  /** Build a bidirectional map between original and cloned [data-md] elements. */
307
311
  function buildCloneMap(oldBlock: BlockDescriptor, newClone: HTMLElement) {
308
- const newMap = new Map<HTMLElement, HTMLElement>();
309
-
310
- // Map new block's original elements to their clones by walking querySelectorAll in parallel
311
- // We need the original new block's element — but we only have the old block and the clone.
312
- // The clone IS the cloneNode of newBlock.element. So walk the old block's new counterpart
313
- // by using the children from childMatches.
314
- // Actually, we can map by matching data-md attributes + position.
315
- const origEls = oldBlock.element.querySelectorAll<HTMLElement>("[data-md]");
316
- const cloneEls = newClone.querySelectorAll<HTMLElement>("[data-md]");
317
-
318
- // For sections/items: the clone is from the NEW block. We need to map
319
- // new block's original children → cloned children. But we don't have newBlock.element here.
320
- // Instead, just map by querySelectorAll order on the clone's source structure.
321
- // Since we can't access the original new element, let's store the mapping differently.
322
-
323
- // We'll use a different approach: find elements in the clone by data-md attributes
324
- return {
325
- newMap: {
326
- get(originalNewEl: HTMLElement): HTMLElement | null {
327
- return findMatchingClonedElement(newClone, originalNewEl);
328
- }
329
- } as Map<HTMLElement, HTMLElement | null>
330
- };
312
+ // The original new block element is not available here, so clones are found by their data-md attributes.
313
+ return {
314
+ newMap: {
315
+ get(originalNewEl: HTMLElement): HTMLElement | null {
316
+ return findMatchingClonedElement(newClone, originalNewEl)
317
+ },
318
+ } as Map<HTMLElement, HTMLElement | null>,
319
+ }
331
320
  }
332
321
 
333
322
  /** Find the cloned version of an element by matching data-md attributes and position. */
334
323
  function findMatchingClonedElement(cloneRoot: HTMLElement, originalEl: HTMLElement): HTMLElement | null {
335
- const type = originalEl.getAttribute("data-md");
336
- if (!type) return null;
337
-
338
- const label = originalEl.getAttribute("data-md-label");
339
- const title = originalEl.getAttribute("data-md-title");
340
-
341
- let selector = `[data-md="${type}"]`;
342
- if (title) selector += `[data-md-title="${CSS.escape(title)}"]`;
343
- else if (label) selector += `[data-md-label="${CSS.escape(label)}"]`;
344
-
345
- const candidates = cloneRoot.querySelectorAll<HTMLElement>(selector);
346
- if (candidates.length === 1) return candidates[0];
347
-
348
- // Multiple matches — find by text similarity
349
- const targetText = (originalEl.textContent || "").slice(0, 100);
350
- let best: HTMLElement | null = null;
351
- let bestScore = -1;
352
- for (const c of candidates) {
353
- const cText = (c.textContent || "").slice(0, 100);
354
- let score = 0;
355
- for (let i = 0; i < Math.min(targetText.length, cText.length); i++) {
356
- if (targetText[i] === cText[i]) score++;
357
- }
358
- if (score > bestScore) { bestScore = score; best = c; }
359
- }
360
- return best;
324
+ const type = originalEl.getAttribute('data-md')
325
+ if (!type) return null
326
+
327
+ const label = originalEl.getAttribute('data-md-label')
328
+ const title = originalEl.getAttribute('data-md-title')
329
+
330
+ let selector = `[data-md="${type}"]`
331
+ if (title) selector += `[data-md-title="${CSS.escape(title)}"]`
332
+ else if (label) selector += `[data-md-label="${CSS.escape(label)}"]`
333
+
334
+ const candidates = cloneRoot.querySelectorAll<HTMLElement>(selector)
335
+ if (candidates.length === 1) return candidates[0]
336
+
337
+ // Multiple matches — find by text similarity
338
+ const targetText = (originalEl.textContent || '').slice(0, 100)
339
+ let best: HTMLElement | null = null
340
+ let bestScore = -1
341
+ for (const c of candidates) {
342
+ const cText = (c.textContent || '').slice(0, 100)
343
+ let score = 0
344
+ for (let i = 0; i < Math.min(targetText.length, cText.length); i++) {
345
+ if (targetText[i] === cText[i]) score++
346
+ }
347
+ if (score > bestScore) {
348
+ bestScore = score
349
+ best = c
350
+ }
351
+ }
352
+ return best
361
353
  }
362
354
 
363
355
  /** Find where to insert a ghost block: before the next matched/added sibling's clone. */
364
356
  function findInsertionPoint(
365
- childMatches: BlockMatch[],
366
- removedMatch: BlockMatch,
367
- cloneMap: Map<HTMLElement, HTMLElement | null>
357
+ childMatches: BlockMatch[],
358
+ removedMatch: BlockMatch,
359
+ cloneMap: Map<HTMLElement, HTMLElement | null>,
368
360
  ): HTMLElement | null {
369
- const idx = childMatches.indexOf(removedMatch);
370
- for (let i = idx + 1; i < childMatches.length; i++) {
371
- const cm = childMatches[i];
372
- if ((cm.kind === "matched" || cm.kind === "added") && cm.newBlock) {
373
- return cloneMap.get(cm.newBlock.element) || null;
374
- }
375
- }
376
- return null;
361
+ const idx = childMatches.indexOf(removedMatch)
362
+ for (let i = idx + 1; i < childMatches.length; i++) {
363
+ const cm = childMatches[i]
364
+ if ((cm.kind === 'matched' || cm.kind === 'added') && cm.newBlock) {
365
+ return cloneMap.get(cm.newBlock.element) || null
366
+ }
367
+ }
368
+ return null
377
369
  }
378
370
 
379
371
  /** Walk up from `el` to find the ancestor that is a direct child of `container`. */
380
372
  function directChildOf(container: HTMLElement, el: HTMLElement): HTMLElement | null {
381
- let node: HTMLElement | null = el;
382
- while (node && node.parentElement !== container) {
383
- node = node.parentElement;
384
- }
385
- return node;
373
+ let node: HTMLElement | null = el
374
+ while (node && node.parentElement !== container) {
375
+ node = node.parentElement
376
+ }
377
+ return node
386
378
  }
387
379
 
388
380
  /* ── Element-level diff for blocks with rich internal structure (e.g. markdown) ── */
@@ -393,80 +385,84 @@ function directChildOf(container: HTMLElement, el: HTMLElement): HTMLElement | n
393
385
  * within a pair is too high (>50%), show as removed+added instead.
394
386
  */
395
387
  function applyElementLevelDiff(oldEl: HTMLElement, newEl: HTMLElement): boolean {
396
- const oldChildren = Array.from(oldEl.children) as HTMLElement[];
397
- const newChildren = Array.from(newEl.children) as HTMLElement[];
398
-
399
- if (oldChildren.length === 0 && newChildren.length === 0) {
400
- return applyWordDiff(oldEl, newEl, false);
401
- }
402
-
403
- // Match by tag name — h3↔h3, p↔p, ul↔ul, etc.
404
- const oldKeys = oldChildren.map((el) => el.tagName.toLowerCase());
405
- const newKeys = newChildren.map((el) => el.tagName.toLowerCase());
406
- const lcs = simpleLCS(oldKeys, newKeys);
407
-
408
- interface ElemMatch {
409
- kind: "matched" | "added" | "removed";
410
- oldChild?: HTMLElement;
411
- newChild?: HTMLElement;
412
- }
413
-
414
- const matches: ElemMatch[] = [];
415
- let oi = 0, ni = 0, li = 0;
416
-
417
- while (oi < oldChildren.length || ni < newChildren.length) {
418
- if (li < lcs.length && oi < oldChildren.length && ni < newChildren.length &&
419
- oldKeys[oi] === lcs[li] && newKeys[ni] === lcs[li]) {
420
- matches.push({ kind: "matched", oldChild: oldChildren[oi], newChild: newChildren[ni] });
421
- oi++; ni++; li++;
422
- } else if (oi < oldChildren.length && (li >= lcs.length || oldKeys[oi] !== lcs[li])) {
423
- matches.push({ kind: "removed", oldChild: oldChildren[oi] });
424
- oi++;
425
- } else if (ni < newChildren.length) {
426
- matches.push({ kind: "added", newChild: newChildren[ni] });
427
- ni++;
428
- }
429
- }
430
-
431
- let hasChanges = false;
432
-
433
- // Detach all children from the clone, then re-append in match order
434
- while (newEl.firstChild) newEl.removeChild(newEl.firstChild);
435
-
436
- for (const m of matches) {
437
- if (m.kind === "matched") {
438
- // Check word-level churn before applying inline diff
439
- const oldWords = (m.oldChild!.textContent || "").match(/\S+/g) || [];
440
- const newWords = (m.newChild!.textContent || "").match(/\S+/g) || [];
441
- const wordLcs = simpleLCS(oldWords, newWords);
442
- const totalWords = Math.max(oldWords.length, newWords.length);
443
- const churn = totalWords > 0 ? 1 - wordLcs.length / totalWords : 0;
444
-
445
- if (churn > 0.5) {
446
- // Too different — show as removed + added
447
- hasChanges = true;
448
- const ghost = m.oldChild!.cloneNode(true) as HTMLElement;
449
- ghost.classList.add("diff-block-removed");
450
- newEl.appendChild(ghost);
451
- m.newChild!.classList.add("diff-block-added");
452
- newEl.appendChild(m.newChild!);
453
- } else {
454
- if (applyWordDiff(m.oldChild!, m.newChild!, false)) hasChanges = true;
455
- newEl.appendChild(m.newChild!);
456
- }
457
- } else if (m.kind === "removed") {
458
- hasChanges = true;
459
- const ghost = m.oldChild!.cloneNode(true) as HTMLElement;
460
- ghost.classList.add("diff-block-removed");
461
- newEl.appendChild(ghost);
462
- } else {
463
- hasChanges = true;
464
- m.newChild!.classList.add("diff-block-added");
465
- newEl.appendChild(m.newChild!);
466
- }
467
- }
468
-
469
- return hasChanges;
388
+ const oldChildren = Array.from(oldEl.children) as HTMLElement[]
389
+ const newChildren = Array.from(newEl.children) as HTMLElement[]
390
+
391
+ if (oldChildren.length === 0 && newChildren.length === 0) {
392
+ return applyWordDiff(oldEl, newEl, false)
393
+ }
394
+
395
+ // Match by tag name — h3↔h3, p↔p, ul↔ul, etc.
396
+ const oldKeys = oldChildren.map((el) => el.tagName.toLowerCase())
397
+ const newKeys = newChildren.map((el) => el.tagName.toLowerCase())
398
+ const lcs = simpleLCS(oldKeys, newKeys)
399
+
400
+ interface ElemMatch {
401
+ kind: 'matched' | 'added' | 'removed'
402
+ oldChild?: HTMLElement
403
+ newChild?: HTMLElement
404
+ }
405
+
406
+ const matches: ElemMatch[] = []
407
+ let oi = 0, ni = 0, li = 0
408
+
409
+ while (oi < oldChildren.length || ni < newChildren.length) {
410
+ if (
411
+ li < lcs.length && oi < oldChildren.length && ni < newChildren.length
412
+ && oldKeys[oi] === lcs[li] && newKeys[ni] === lcs[li]
413
+ ) {
414
+ matches.push({ kind: 'matched', oldChild: oldChildren[oi], newChild: newChildren[ni] })
415
+ oi++
416
+ ni++
417
+ li++
418
+ } else if (oi < oldChildren.length && (li >= lcs.length || oldKeys[oi] !== lcs[li])) {
419
+ matches.push({ kind: 'removed', oldChild: oldChildren[oi] })
420
+ oi++
421
+ } else if (ni < newChildren.length) {
422
+ matches.push({ kind: 'added', newChild: newChildren[ni] })
423
+ ni++
424
+ }
425
+ }
426
+
427
+ let hasChanges = false
428
+
429
+ // Detach all children from the clone, then re-append in match order
430
+ while (newEl.firstChild) newEl.removeChild(newEl.firstChild)
431
+
432
+ for (const m of matches) {
433
+ if (m.kind === 'matched') {
434
+ // Check word-level churn before applying inline diff
435
+ const oldWords = (m.oldChild!.textContent || '').match(/\S+/g) || []
436
+ const newWords = (m.newChild!.textContent || '').match(/\S+/g) || []
437
+ const wordLcs = simpleLCS(oldWords, newWords)
438
+ const totalWords = Math.max(oldWords.length, newWords.length)
439
+ const churn = totalWords > 0 ? 1 - wordLcs.length / totalWords : 0
440
+
441
+ if (churn > 0.5) {
442
+ // Too different — show as removed + added
443
+ hasChanges = true
444
+ const ghost = m.oldChild!.cloneNode(true) as HTMLElement
445
+ ghost.classList.add('diff-block-removed')
446
+ newEl.appendChild(ghost)
447
+ m.newChild!.classList.add('diff-block-added')
448
+ newEl.appendChild(m.newChild!)
449
+ } else {
450
+ if (applyWordDiff(m.oldChild!, m.newChild!, false)) hasChanges = true
451
+ newEl.appendChild(m.newChild!)
452
+ }
453
+ } else if (m.kind === 'removed') {
454
+ hasChanges = true
455
+ const ghost = m.oldChild!.cloneNode(true) as HTMLElement
456
+ ghost.classList.add('diff-block-removed')
457
+ newEl.appendChild(ghost)
458
+ } else {
459
+ hasChanges = true
460
+ m.newChild!.classList.add('diff-block-added')
461
+ newEl.appendChild(m.newChild!)
462
+ }
463
+ }
464
+
465
+ return hasChanges
470
466
  }
471
467
 
472
468
  /* ── Word-level diff with combined single-pass rebuild ── */
@@ -475,41 +471,41 @@ function applyElementLevelDiff(oldEl: HTMLElement, newEl: HTMLElement): boolean
475
471
  * Extract words from a container, optionally excluding text inside nested [data-md] elements.
476
472
  */
477
473
  function doExtractWords(container: HTMLElement, excludeBlocks: boolean): { words: string[]; map: WordEntry[] } {
478
- const blockEls = excludeBlocks
479
- ? new Set(Array.from(container.querySelectorAll<HTMLElement>("[data-md]")))
480
- : new Set<HTMLElement>();
481
-
482
- const words: string[] = [];
483
- const map: WordEntry[] = [];
484
-
485
- const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
486
- acceptNode(node) {
487
- const parent = node.parentElement;
488
- if (!parent) return NodeFilter.FILTER_REJECT;
489
- if (parent.closest("svg, .select-none, [data-no-diff]")) return NodeFilter.FILTER_REJECT;
490
- if (excludeBlocks) {
491
- for (const blockEl of blockEls) {
492
- if (blockEl.contains(node)) return NodeFilter.FILTER_REJECT;
493
- }
494
- }
495
- return NodeFilter.FILTER_ACCEPT;
496
- },
497
- });
498
-
499
- let textNode: Text | null;
500
- while ((textNode = walker.nextNode() as Text | null)) {
501
- const content = textNode.nodeValue || "";
502
- const tokens = content.match(/\S+|\s+/g);
503
- if (!tokens) continue;
504
- let offset = 0;
505
- for (const token of tokens) {
506
- words.push(token);
507
- map.push({ text: token, node: textNode, offset });
508
- offset += token.length;
509
- }
510
- }
511
-
512
- return { words, map };
474
+ const blockEls = excludeBlocks
475
+ ? new Set(Array.from(container.querySelectorAll<HTMLElement>('[data-md]')))
476
+ : new Set<HTMLElement>()
477
+
478
+ const words: string[] = []
479
+ const map: WordEntry[] = []
480
+
481
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
482
+ acceptNode(node) {
483
+ const parent = node.parentElement
484
+ if (!parent) return NodeFilter.FILTER_REJECT
485
+ if (parent.closest('svg, .select-none, [data-no-diff]')) return NodeFilter.FILTER_REJECT
486
+ if (excludeBlocks) {
487
+ for (const blockEl of blockEls) {
488
+ if (blockEl.contains(node)) return NodeFilter.FILTER_REJECT
489
+ }
490
+ }
491
+ return NodeFilter.FILTER_ACCEPT
492
+ },
493
+ })
494
+
495
+ let textNode: Text | null
496
+ while ((textNode = walker.nextNode() as Text | null)) {
497
+ const content = textNode.nodeValue || ''
498
+ const tokens = content.match(/\S+|\s+/g)
499
+ if (!tokens) continue
500
+ let offset = 0
501
+ for (const token of tokens) {
502
+ words.push(token)
503
+ map.push({ text: token, node: textNode, offset })
504
+ offset += token.length
505
+ }
506
+ }
507
+
508
+ return { words, map }
513
509
  }
514
510
 
515
511
  /**
@@ -519,89 +515,101 @@ function doExtractWords(container: HTMLElement, excludeBlocks: boolean): { words
519
515
  * @param excludeBlocks If true, skip text inside nested [data-md] elements.
520
516
  */
521
517
  function applyWordDiff(oldEl: HTMLElement, newEl: HTMLElement, excludeBlocks: boolean): boolean {
522
- const oldExtract = doExtractWords(oldEl, excludeBlocks);
523
- const newExtract = doExtractWords(newEl, excludeBlocks);
524
-
525
- if (oldExtract.words.length === 0 && newExtract.words.length === 0) return false;
526
-
527
- const { oldOps, newOps } = computeWordDiff(oldExtract.words, newExtract.words);
528
-
529
- const hasRemoved = oldOps.some((op) => op.type === "removed");
530
- const hasAdded = newOps.some((op) => op.type === "added");
531
- if (!hasRemoved && !hasAdded) return false;
532
-
533
- // Build same-word mapping: oldIndex → newIndex by position in same-sequence
534
- const oldSameIndices: number[] = [];
535
- const newSameIndices: number[] = [];
536
- for (const op of oldOps) if (op.type === "same") oldSameIndices.push(op.index);
537
- for (const op of newOps) if (op.type === "same") newSameIndices.push(op.index);
538
- const sameMap = new Map<number, number>();
539
- for (let i = 0; i < oldSameIndices.length && i < newSameIndices.length; i++) {
540
- sameMap.set(oldSameIndices[i], newSameIndices[i]);
541
- }
542
-
543
- // Group consecutive removed words and map each group to its anchor in new
544
- interface RemovedGroup { text: string; anchorNewIdx: number | null }
545
- const removedGroups: RemovedGroup[] = [];
546
- {
547
- let group: string[] = [];
548
- let lastSameOldIdx = -1;
549
- for (const op of oldOps) {
550
- if (op.type === "same") {
551
- if (group.length > 0) {
552
- removedGroups.push({ text: group.join(""), anchorNewIdx: lastSameOldIdx >= 0 ? (sameMap.get(lastSameOldIdx) ?? null) : null });
553
- group = [];
554
- }
555
- lastSameOldIdx = op.index;
556
- } else if (op.type === "removed") {
557
- group.push(oldExtract.words[op.index]);
558
- }
559
- }
560
- if (group.length > 0) {
561
- removedGroups.push({ text: group.join(""), anchorNewIdx: lastSameOldIdx >= 0 ? (sameMap.get(lastSameOldIdx) ?? null) : null });
562
- }
563
- }
564
-
565
- // Collect per-text-node edits
566
- const nodeHighlights = new Map<Text, { start: number; end: number }[]>();
567
- const nodeInserts = new Map<Text, { offset: number; text: string }[]>();
568
-
569
- // Added-word highlights
570
- for (const op of newOps) {
571
- if (op.type !== "added") continue;
572
- const entry = newExtract.map[op.index];
573
- if (!entry?.node.parentNode) continue;
574
- let list = nodeHighlights.get(entry.node);
575
- if (!list) { list = []; nodeHighlights.set(entry.node, list); }
576
- list.push({ start: entry.offset, end: entry.offset + entry.text.length });
577
- }
578
-
579
- // Removed-word insertions
580
- for (const rg of removedGroups) {
581
- if (rg.anchorNewIdx === null) {
582
- // Removed at the very start — insert at offset 0 of first text node
583
- if (newExtract.map.length > 0) {
584
- const node = newExtract.map[0].node;
585
- let list = nodeInserts.get(node);
586
- if (!list) { list = []; nodeInserts.set(node, list); }
587
- list.push({ offset: 0, text: rg.text });
588
- }
589
- } else {
590
- const entry = newExtract.map[rg.anchorNewIdx];
591
- if (!entry) continue;
592
- let list = nodeInserts.get(entry.node);
593
- if (!list) { list = []; nodeInserts.set(entry.node, list); }
594
- list.push({ offset: entry.offset + entry.text.length, text: rg.text });
595
- }
596
- }
597
-
598
- // Rebuild each affected text node in a single pass
599
- const allNodes = new Set([...nodeHighlights.keys(), ...nodeInserts.keys()]);
600
- for (const textNode of allNodes) {
601
- rebuildTextNode(textNode, nodeHighlights.get(textNode) || [], nodeInserts.get(textNode) || []);
602
- }
603
-
604
- return true;
518
+ const oldExtract = doExtractWords(oldEl, excludeBlocks)
519
+ const newExtract = doExtractWords(newEl, excludeBlocks)
520
+
521
+ if (oldExtract.words.length === 0 && newExtract.words.length === 0) return false
522
+
523
+ const { oldOps, newOps } = computeWordDiff(oldExtract.words, newExtract.words)
524
+
525
+ const hasRemoved = oldOps.some((op) => op.type === 'removed')
526
+ const hasAdded = newOps.some((op) => op.type === 'added')
527
+ if (!hasRemoved && !hasAdded) return false
528
+
529
+ // Build same-word mapping: oldIndex → newIndex by position in same-sequence
530
+ const oldSameIndices: number[] = []
531
+ const newSameIndices: number[] = []
532
+ for (const op of oldOps) if (op.type === 'same') oldSameIndices.push(op.index)
533
+ for (const op of newOps) if (op.type === 'same') newSameIndices.push(op.index)
534
+ const sameMap = new Map<number, number>()
535
+ for (let i = 0; i < oldSameIndices.length && i < newSameIndices.length; i++) {
536
+ sameMap.set(oldSameIndices[i], newSameIndices[i])
537
+ }
538
+
539
+ // Group consecutive removed words and map each group to its anchor in new
540
+ interface RemovedGroup {
541
+ text: string
542
+ anchorNewIdx: number | null
543
+ }
544
+ const removedGroups: RemovedGroup[] = []
545
+ {
546
+ let group: string[] = []
547
+ let lastSameOldIdx = -1
548
+ for (const op of oldOps) {
549
+ if (op.type === 'same') {
550
+ if (group.length > 0) {
551
+ removedGroups.push({ text: group.join(''), anchorNewIdx: lastSameOldIdx >= 0 ? (sameMap.get(lastSameOldIdx) ?? null) : null })
552
+ group = []
553
+ }
554
+ lastSameOldIdx = op.index
555
+ } else if (op.type === 'removed') {
556
+ group.push(oldExtract.words[op.index])
557
+ }
558
+ }
559
+ if (group.length > 0) {
560
+ removedGroups.push({ text: group.join(''), anchorNewIdx: lastSameOldIdx >= 0 ? (sameMap.get(lastSameOldIdx) ?? null) : null })
561
+ }
562
+ }
563
+
564
+ // Collect per-text-node edits
565
+ const nodeHighlights = new Map<Text, { start: number; end: number }[]>()
566
+ const nodeInserts = new Map<Text, { offset: number; text: string }[]>()
567
+
568
+ // Added-word highlights
569
+ for (const op of newOps) {
570
+ if (op.type !== 'added') continue
571
+ const entry = newExtract.map[op.index]
572
+ if (!entry?.node.parentNode) continue
573
+ let list = nodeHighlights.get(entry.node)
574
+ if (!list) {
575
+ list = []
576
+ nodeHighlights.set(entry.node, list)
577
+ }
578
+ list.push({ start: entry.offset, end: entry.offset + entry.text.length })
579
+ }
580
+
581
+ // Removed-word insertions
582
+ for (const rg of removedGroups) {
583
+ if (rg.anchorNewIdx === null) {
584
+ // Removed at the very start — insert at offset 0 of first text node
585
+ if (newExtract.map.length > 0) {
586
+ const node = newExtract.map[0].node
587
+ let list = nodeInserts.get(node)
588
+ if (!list) {
589
+ list = []
590
+ nodeInserts.set(node, list)
591
+ }
592
+ list.push({ offset: 0, text: rg.text })
593
+ }
594
+ } else {
595
+ const entry = newExtract.map[rg.anchorNewIdx]
596
+ if (!entry) continue
597
+ let list = nodeInserts.get(entry.node)
598
+ if (!list) {
599
+ list = []
600
+ nodeInserts.set(entry.node, list)
601
+ }
602
+ list.push({ offset: entry.offset + entry.text.length, text: rg.text })
603
+ }
604
+ }
605
+
606
+ // Rebuild each affected text node in a single pass
607
+ const allNodes = new Set([...nodeHighlights.keys(), ...nodeInserts.keys()])
608
+ for (const textNode of allNodes) {
609
+ rebuildTextNode(textNode, nodeHighlights.get(textNode) || [], nodeInserts.get(textNode) || [])
610
+ }
611
+
612
+ return true
605
613
  }
606
614
 
607
615
  /**
@@ -609,93 +617,93 @@ function applyWordDiff(oldEl: HTMLElement, newEl: HTMLElement, excludeBlocks: bo
609
617
  * Processes everything in one pass — no stale text-node references.
610
618
  */
611
619
  function rebuildTextNode(
612
- textNode: Text,
613
- highlights: { start: number; end: number }[],
614
- inserts: { offset: number; text: string }[]
620
+ textNode: Text,
621
+ highlights: { start: number; end: number }[],
622
+ inserts: { offset: number; text: string }[],
615
623
  ): void {
616
- if (!textNode.parentNode) return;
617
- const content = textNode.nodeValue || "";
618
-
619
- // Merge overlapping/adjacent highlights
620
- highlights.sort((a, b) => a.start - b.start);
621
- const mergedHL: { start: number; end: number }[] = [];
622
- for (const h of highlights) {
623
- const last = mergedHL[mergedHL.length - 1];
624
- if (last && h.start <= last.end) {
625
- last.end = Math.max(last.end, h.end);
626
- } else {
627
- mergedHL.push({ ...h });
628
- }
629
- }
630
-
631
- inserts.sort((a, b) => a.offset - b.offset);
632
-
633
- const parent = textNode.parentNode;
634
- const frag = document.createDocumentFragment();
635
- let pos = 0;
636
- let hlIdx = 0;
637
- let insIdx = 0;
638
-
639
- while (pos <= content.length) {
640
- const nextHLStart = hlIdx < mergedHL.length ? mergedHL[hlIdx].start : Infinity;
641
- const nextInsOff = insIdx < inserts.length ? inserts[insIdx].offset : Infinity;
642
- const nextEvent = Math.min(nextHLStart, nextInsOff);
643
-
644
- if (nextEvent === Infinity) {
645
- // No more events — output remaining text
646
- if (pos < content.length) {
647
- frag.appendChild(document.createTextNode(content.slice(pos)));
648
- }
649
- break;
650
- }
651
-
652
- // Output plain text up to next event
653
- const eventPos = Math.max(nextEvent, pos);
654
- if (eventPos > pos) {
655
- frag.appendChild(document.createTextNode(content.slice(pos, eventPos)));
656
- pos = eventPos;
657
- }
658
-
659
- // Process inserts at this position (before highlights — removed text comes before added)
660
- while (insIdx < inserts.length && inserts[insIdx].offset <= pos) {
661
- const span = document.createElement("span");
662
- span.className = "diff-removed";
663
- span.textContent = inserts[insIdx].text;
664
- frag.appendChild(span);
665
- insIdx++;
666
- }
667
-
668
- // Process highlight starting at this position
669
- if (hlIdx < mergedHL.length && mergedHL[hlIdx].start <= pos) {
670
- const hl = mergedHL[hlIdx];
671
- const span = document.createElement("span");
672
- span.className = "diff-added";
673
- span.textContent = content.slice(pos, hl.end);
674
- frag.appendChild(span);
675
- pos = hl.end;
676
- hlIdx++;
677
-
678
- // Process inserts right after highlight end
679
- while (insIdx < inserts.length && inserts[insIdx].offset <= pos) {
680
- const span = document.createElement("span");
681
- span.className = "diff-removed";
682
- span.textContent = inserts[insIdx].text;
683
- frag.appendChild(span);
684
- insIdx++;
685
- }
686
- }
687
- }
688
-
689
- // Remaining inserts past end of text
690
- while (insIdx < inserts.length) {
691
- const span = document.createElement("span");
692
- span.className = "diff-removed";
693
- span.textContent = inserts[insIdx].text;
694
- frag.appendChild(span);
695
- insIdx++;
696
- }
697
-
698
- parent.replaceChild(frag, textNode);
624
+ if (!textNode.parentNode) return
625
+ const content = textNode.nodeValue || ''
626
+
627
+ // Merge overlapping/adjacent highlights
628
+ highlights.sort((a, b) => a.start - b.start)
629
+ const mergedHL: { start: number; end: number }[] = []
630
+ for (const h of highlights) {
631
+ const last = mergedHL[mergedHL.length - 1]
632
+ if (last && h.start <= last.end) {
633
+ last.end = Math.max(last.end, h.end)
634
+ } else {
635
+ mergedHL.push({ ...h })
636
+ }
637
+ }
638
+
639
+ inserts.sort((a, b) => a.offset - b.offset)
640
+
641
+ const parent = textNode.parentNode
642
+ const frag = document.createDocumentFragment()
643
+ let pos = 0
644
+ let hlIdx = 0
645
+ let insIdx = 0
646
+
647
+ while (pos <= content.length) {
648
+ const nextHLStart = hlIdx < mergedHL.length ? mergedHL[hlIdx].start : Infinity
649
+ const nextInsOff = insIdx < inserts.length ? inserts[insIdx].offset : Infinity
650
+ const nextEvent = Math.min(nextHLStart, nextInsOff)
651
+
652
+ if (nextEvent === Infinity) {
653
+ // No more events — output remaining text
654
+ if (pos < content.length) {
655
+ frag.appendChild(document.createTextNode(content.slice(pos)))
656
+ }
657
+ break
658
+ }
659
+
660
+ // Output plain text up to next event
661
+ const eventPos = Math.max(nextEvent, pos)
662
+ if (eventPos > pos) {
663
+ frag.appendChild(document.createTextNode(content.slice(pos, eventPos)))
664
+ pos = eventPos
665
+ }
666
+
667
+ // Process inserts at this position (before highlights — removed text comes before added)
668
+ while (insIdx < inserts.length && inserts[insIdx].offset <= pos) {
669
+ const span = document.createElement('span')
670
+ span.className = 'diff-removed'
671
+ span.textContent = inserts[insIdx].text
672
+ frag.appendChild(span)
673
+ insIdx++
674
+ }
675
+
676
+ // Process highlight starting at this position
677
+ if (hlIdx < mergedHL.length && mergedHL[hlIdx].start <= pos) {
678
+ const hl = mergedHL[hlIdx]
679
+ const span = document.createElement('span')
680
+ span.className = 'diff-added'
681
+ span.textContent = content.slice(pos, hl.end)
682
+ frag.appendChild(span)
683
+ pos = hl.end
684
+ hlIdx++
685
+
686
+ // Process inserts right after highlight end
687
+ while (insIdx < inserts.length && inserts[insIdx].offset <= pos) {
688
+ const span = document.createElement('span')
689
+ span.className = 'diff-removed'
690
+ span.textContent = inserts[insIdx].text
691
+ frag.appendChild(span)
692
+ insIdx++
693
+ }
694
+ }
695
+ }
696
+
697
+ // Remaining inserts past end of text
698
+ while (insIdx < inserts.length) {
699
+ const span = document.createElement('span')
700
+ span.className = 'diff-removed'
701
+ span.textContent = inserts[insIdx].text
702
+ frag.appendChild(span)
703
+ insIdx++
704
+ }
705
+
706
+ parent.replaceChild(frag, textNode)
699
707
  }
700
708
 
701
709
  /* ── Mermaid source diff ── */
@@ -705,64 +713,67 @@ function rebuildTextNode(
705
713
  * If sources differ, show a line-level text diff of the mermaid source code.
706
714
  */
707
715
  function applyMermaidDiff(oldEl: HTMLElement, newEl: HTMLElement): boolean {
708
- const oldSource = (oldEl.getAttribute("data-md-source") || "").trim();
709
- const newSource = (newEl.getAttribute("data-md-source") || "").trim();
710
-
711
- if (oldSource === newSource) return false;
712
-
713
- // Compute line-level diff of the source
714
- const oldLines = oldSource.split("\n");
715
- const newLines = newSource.split("\n");
716
- const lcs = simpleLCS(oldLines, newLines);
717
-
718
- const diffLines: Array<{ type: "same" | "added" | "removed"; line: string }> = [];
719
- let oi = 0, ni = 0, li = 0;
720
- while (oi < oldLines.length || ni < newLines.length) {
721
- if (li < lcs.length && oi < oldLines.length && ni < newLines.length &&
722
- oldLines[oi] === lcs[li] && newLines[ni] === lcs[li]) {
723
- diffLines.push({ type: "same", line: oldLines[oi] });
724
- oi++; ni++; li++;
725
- } else if (oi < oldLines.length && (li >= lcs.length || oldLines[oi] !== lcs[li])) {
726
- diffLines.push({ type: "removed", line: oldLines[oi] });
727
- oi++;
728
- } else if (ni < newLines.length) {
729
- diffLines.push({ type: "added", line: newLines[ni] });
730
- ni++;
731
- }
732
- }
733
-
734
- // Append a diff view after the diagram
735
- const diffContainer = document.createElement("div");
736
- diffContainer.className = "mt-2 bg-bg-code rounded-md overflow-hidden text-tiny font-mono";
737
- diffContainer.setAttribute("data-no-diff", "true");
738
-
739
- for (const dl of diffLines) {
740
- const row = document.createElement("div");
741
- row.className = "px-3 py-0.5 whitespace-pre-wrap";
742
- if (dl.type === "removed") {
743
- row.classList.add("diff-removed");
744
- row.textContent = "- " + dl.line;
745
- } else if (dl.type === "added") {
746
- row.classList.add("diff-added");
747
- row.textContent = "+ " + dl.line;
748
- } else {
749
- row.textContent = " " + dl.line;
750
- }
751
- diffContainer.appendChild(row);
752
- }
753
-
754
- newEl.appendChild(diffContainer);
755
- return true;
716
+ const oldSource = (oldEl.getAttribute('data-md-source') || '').trim()
717
+ const newSource = (newEl.getAttribute('data-md-source') || '').trim()
718
+
719
+ if (oldSource === newSource) return false
720
+
721
+ // Compute line-level diff of the source
722
+ const oldLines = oldSource.split('\n')
723
+ const newLines = newSource.split('\n')
724
+ const lcs = simpleLCS(oldLines, newLines)
725
+
726
+ const diffLines: Array<{ type: 'same' | 'added' | 'removed'; line: string }> = []
727
+ let oi = 0, ni = 0, li = 0
728
+ while (oi < oldLines.length || ni < newLines.length) {
729
+ if (
730
+ li < lcs.length && oi < oldLines.length && ni < newLines.length
731
+ && oldLines[oi] === lcs[li] && newLines[ni] === lcs[li]
732
+ ) {
733
+ diffLines.push({ type: 'same', line: oldLines[oi] })
734
+ oi++
735
+ ni++
736
+ li++
737
+ } else if (oi < oldLines.length && (li >= lcs.length || oldLines[oi] !== lcs[li])) {
738
+ diffLines.push({ type: 'removed', line: oldLines[oi] })
739
+ oi++
740
+ } else if (ni < newLines.length) {
741
+ diffLines.push({ type: 'added', line: newLines[ni] })
742
+ ni++
743
+ }
744
+ }
745
+
746
+ // Append a diff view after the diagram
747
+ const diffContainer = document.createElement('div')
748
+ diffContainer.className = 'mt-2 bg-bg-code rounded-md overflow-hidden text-tiny font-mono'
749
+ diffContainer.setAttribute('data-no-diff', 'true')
750
+
751
+ for (const dl of diffLines) {
752
+ const row = document.createElement('div')
753
+ row.className = 'px-3 py-0.5 whitespace-pre-wrap'
754
+ if (dl.type === 'removed') {
755
+ row.classList.add('diff-removed')
756
+ row.textContent = '- ' + dl.line
757
+ } else if (dl.type === 'added') {
758
+ row.classList.add('diff-added')
759
+ row.textContent = '+ ' + dl.line
760
+ } else {
761
+ row.textContent = ' ' + dl.line
762
+ }
763
+ diffContainer.appendChild(row)
764
+ }
765
+
766
+ newEl.appendChild(diffContainer)
767
+ return true
756
768
  }
757
769
 
758
770
  /* ── Helpers ── */
759
771
 
760
772
  function appendLabel(el: HTMLElement, text: string, color: string): void {
761
- const label = document.createElement("span");
762
- label.className = "diff-block-label";
763
- label.textContent = text;
764
- label.style.color = color;
765
- label.style.backgroundColor = `color-mix(in srgb, ${color} 12%, transparent)`;
766
- el.appendChild(label);
773
+ const label = document.createElement('span')
774
+ label.className = 'diff-block-label'
775
+ label.textContent = text
776
+ label.style.color = color
777
+ label.style.backgroundColor = `color-mix(in srgb, ${color} 12%, transparent)`
778
+ el.appendChild(label)
767
779
  }
768
-