agent-canvas 0.2.1 → 0.7.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 (99) 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 -106
  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 +5 -1
  93. package/bin/lib/commands/exec.test.ts +0 -27
  94. package/bin/lib/helpers.test.ts +0 -79
  95. package/daemon/src/files-api.test.ts +0 -78
  96. package/daemon/src/remote-feedback.test.ts +0 -165
  97. package/daemon/src/secrets-api.test.ts +0 -123
  98. package/daemon/src/share-e2e.test.ts +0 -277
  99. package/daemon/src/share.test.ts +0 -289
@@ -7,50 +7,50 @@
7
7
  * 3. Inject highlight <span> elements into the DOM
8
8
  */
9
9
 
10
- import { simpleLCS } from "./lcs";
10
+ import { simpleLCS } from './lcs'
11
11
 
12
12
  interface WordEntry {
13
- text: string;
14
- node: Text;
15
- offset: number; // character offset within the Text node
13
+ text: string
14
+ node: Text
15
+ offset: number // character offset within the Text node
16
16
  }
17
17
 
18
18
  /** Walk all text nodes, tokenize into words+whitespace, record originating node+offset. */
19
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 };
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
49
  }
50
50
 
51
51
  interface DiffOp {
52
- type: "same" | "added" | "removed";
53
- index: number; // index in the original words array
52
+ type: 'same' | 'added' | 'removed'
53
+ index: number // index in the original words array
54
54
  }
55
55
 
56
56
  /**
@@ -59,51 +59,51 @@ interface DiffOp {
59
59
  * For large inputs (>5k tokens), falls back to chunked diffing.
60
60
  */
61
61
  export function computeWordDiff(
62
- oldWords: string[],
63
- newWords: string[]
62
+ oldWords: string[],
63
+ newWords: string[],
64
64
  ): { oldOps: DiffOp[]; newOps: DiffOp[] } {
65
- if (oldWords.length > 5000 || newWords.length > 5000) {
66
- return chunkedDiff(oldWords, newWords);
67
- }
68
- return directDiff(oldWords, newWords);
65
+ if (oldWords.length > 5000 || newWords.length > 5000) {
66
+ return chunkedDiff(oldWords, newWords)
67
+ }
68
+ return directDiff(oldWords, newWords)
69
69
  }
70
70
 
71
71
  function directDiff(
72
- oldWords: string[],
73
- newWords: string[]
72
+ oldWords: string[],
73
+ newWords: string[],
74
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 };
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
107
  }
108
108
 
109
109
  /**
@@ -111,132 +111,140 @@ function directDiff(
111
111
  * Recursively divides the problem, using two-row DP to find the split point.
112
112
  */
113
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)];
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) {
137
+ best = score
138
+ bestK = k
139
+ }
140
+ }
141
+
142
+ const bFirst = b.slice(0, bestK)
143
+ const bSecond = b.slice(bestK)
144
+
145
+ return [...computeLCS(aFirst, bFirst), ...computeLCS(aSecond, bSecond)]
143
146
  }
144
147
 
145
148
  /** Two-row DP: returns last row of LCS length table. O(n) space. */
146
149
  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;
150
+ const n = b.length
151
+ let prev = new Array(n + 1).fill(0)
152
+ let curr = new Array(n + 1).fill(0)
153
+ for (let i = 0; i < a.length; i++) {
154
+ for (let j = 1; j <= n; j++) {
155
+ curr[j] = a[i] === b[j - 1] ? prev[j - 1] + 1 : Math.max(prev[j], curr[j - 1])
156
+ }
157
+ ;[prev, curr] = [curr, prev]
158
+ curr.fill(0)
159
+ }
160
+ return prev
158
161
  }
159
162
 
160
-
161
163
  /**
162
164
  * Chunked fallback for very large plans.
163
165
  * Splits words into fixed-size chunks, aligns chunks via LCS,
164
166
  * then runs word-level diff within each aligned chunk pair.
165
167
  */
166
168
  function chunkedDiff(
167
- oldWords: string[],
168
- newWords: string[]
169
+ oldWords: string[],
170
+ newWords: string[],
169
171
  ): { 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 };
172
+ const CHUNK_SIZE = 200
173
+
174
+ const splitIntoChunks = (words: string[]) => {
175
+ const chunks: { start: number; end: number; text: string }[] = []
176
+ for (let i = 0; i < words.length; i += CHUNK_SIZE) {
177
+ const end = Math.min(i + CHUNK_SIZE, words.length)
178
+ chunks.push({ start: i, end, text: words.slice(i, end).join('') })
179
+ }
180
+ return chunks
181
+ }
182
+
183
+ const oldChunks = splitIntoChunks(oldWords)
184
+ const newChunks = splitIntoChunks(newWords)
185
+
186
+ const oldTexts = oldChunks.map((c) => c.text)
187
+ const newTexts = newChunks.map((c) => c.text)
188
+ const chunkLCS = computeLCS(oldTexts, newTexts)
189
+
190
+ const oldOps: DiffOp[] = []
191
+ const newOps: DiffOp[] = []
192
+
193
+ let oci = 0, nci = 0, cli = 0
194
+
195
+ while (oci < oldChunks.length || nci < newChunks.length) {
196
+ if (
197
+ cli < chunkLCS.length
198
+ && oci < oldChunks.length
199
+ && nci < newChunks.length
200
+ && oldTexts[oci] === chunkLCS[cli]
201
+ && newTexts[nci] === chunkLCS[cli]
202
+ ) {
203
+ // Identical chunks
204
+ for (let i = oldChunks[oci].start; i < oldChunks[oci].end; i++) {
205
+ oldOps.push({ type: 'same', index: i })
206
+ }
207
+ for (let i = newChunks[nci].start; i < newChunks[nci].end; i++) {
208
+ newOps.push({ type: 'same', index: i })
209
+ }
210
+ oci++
211
+ nci++
212
+ cli++
213
+ } else if (oci < oldChunks.length && (cli >= chunkLCS.length || oldTexts[oci] !== chunkLCS[cli])) {
214
+ if (
215
+ nci < newChunks.length
216
+ && (cli >= chunkLCS.length || newTexts[nci] !== chunkLCS[cli])
217
+ ) {
218
+ // Both sides changed — word-level diff within these chunks
219
+ const sub = directDiff(
220
+ oldWords.slice(oldChunks[oci].start, oldChunks[oci].end),
221
+ newWords.slice(newChunks[nci].start, newChunks[nci].end),
222
+ )
223
+ for (const op of sub.oldOps) {
224
+ oldOps.push({ type: op.type, index: op.index + oldChunks[oci].start })
225
+ }
226
+ for (const op of sub.newOps) {
227
+ newOps.push({ type: op.type, index: op.index + newChunks[nci].start })
228
+ }
229
+ oci++
230
+ nci++
231
+ } else {
232
+ // Only old side — removed
233
+ for (let i = oldChunks[oci].start; i < oldChunks[oci].end; i++) {
234
+ oldOps.push({ type: 'removed', index: i })
235
+ }
236
+ oci++
237
+ }
238
+ } else if (nci < newChunks.length) {
239
+ // Only new side — added
240
+ for (let i = newChunks[nci].start; i < newChunks[nci].end; i++) {
241
+ newOps.push({ type: 'added', index: i })
242
+ }
243
+ nci++
244
+ }
245
+ }
246
+
247
+ return { oldOps, newOps }
240
248
  }
241
249
 
242
250
  /**
@@ -247,70 +255,70 @@ function chunkedDiff(
247
255
  * removes the text node and breaks subsequent tokens from the same node.
248
256
  */
249
257
  function applyHighlightsByNode(
250
- ops: DiffOp[],
251
- map: WordEntry[],
252
- cssClass: string
258
+ ops: DiffOp[],
259
+ map: WordEntry[],
260
+ cssClass: string,
253
261
  ): 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
- }
262
+ // Collect changed indices grouped by source text node
263
+ const nodeGroups = new Map<Text, { offset: number; length: number }[]>()
264
+
265
+ for (const op of ops) {
266
+ if (op.type === 'same') continue
267
+ const entry = map[op.index]
268
+ if (!entry || !entry.node.parentNode) continue
269
+
270
+ let group = nodeGroups.get(entry.node)
271
+ if (!group) {
272
+ group = []
273
+ nodeGroups.set(entry.node, group)
274
+ }
275
+ group.push({ offset: entry.offset, length: entry.text.length })
276
+ }
277
+
278
+ // For each text node, rebuild it with highlight spans
279
+ for (const [textNode, ranges] of nodeGroups) {
280
+ if (!textNode.parentNode) continue
281
+ const content = textNode.nodeValue || ''
282
+
283
+ // Sort ranges by offset
284
+ ranges.sort((a, b) => a.offset - b.offset)
285
+
286
+ // Merge overlapping/adjacent ranges
287
+ const merged: { offset: number; length: number }[] = []
288
+ for (const r of ranges) {
289
+ const last = merged[merged.length - 1]
290
+ if (last && r.offset <= last.offset + last.length) {
291
+ last.length = Math.max(last.length, r.offset + r.length - last.offset)
292
+ } else {
293
+ merged.push({ ...r })
294
+ }
295
+ }
296
+
297
+ // Build replacement nodes: alternating text and highlighted spans
298
+ const parent = textNode.parentNode
299
+ const frag = document.createDocumentFragment()
300
+ let pos = 0
301
+
302
+ for (const r of merged) {
303
+ // Text before this highlight
304
+ if (r.offset > pos) {
305
+ frag.appendChild(document.createTextNode(content.slice(pos, r.offset)))
306
+ }
307
+ // Highlighted span
308
+ const span = document.createElement('span')
309
+ span.className = cssClass
310
+ span.textContent = content.slice(r.offset, r.offset + r.length)
311
+ frag.appendChild(span)
312
+ pos = r.offset + r.length
313
+ }
314
+
315
+ // Remaining text after last highlight
316
+ if (pos < content.length) {
317
+ frag.appendChild(document.createTextNode(content.slice(pos)))
318
+ }
319
+
320
+ parent.replaceChild(frag, textNode)
321
+ }
314
322
  }
315
323
 
316
324
  /**
@@ -318,24 +326,23 @@ function applyHighlightsByNode(
318
326
  * Returns true if there were any changes.
319
327
  */
320
328
  export function runDomDiff(
321
- oldContainer: HTMLElement,
322
- newContainer: HTMLElement
329
+ oldContainer: HTMLElement,
330
+ newContainer: HTMLElement,
323
331
  ): boolean {
324
- // Handle mermaid blocks separately via data-md-source comparison
325
- const mermaidChanged = diffMermaidBlocks(oldContainer, newContainer);
332
+ // Handle mermaid blocks separately via data-md-source comparison
333
+ const mermaidChanged = diffMermaidBlocks(oldContainer, newContainer)
326
334
 
327
- const oldExtract = extractWords(oldContainer);
328
- const newExtract = extractWords(newContainer);
335
+ const oldExtract = extractWords(oldContainer)
336
+ const newExtract = extractWords(newContainer)
329
337
 
330
- const { oldOps, newOps } = computeWordDiff(oldExtract.words, newExtract.words);
338
+ const { oldOps, newOps } = computeWordDiff(oldExtract.words, newExtract.words)
331
339
 
332
- const hasWordChanges =
333
- oldOps.some((op) => op.type === "removed") || newOps.some((op) => op.type === "added");
340
+ const hasWordChanges = oldOps.some((op) => op.type === 'removed') || newOps.some((op) => op.type === 'added')
334
341
 
335
- applyHighlightsByNode(oldOps, oldExtract.map, "diff-removed");
336
- applyHighlightsByNode(newOps, newExtract.map, "diff-added");
342
+ applyHighlightsByNode(oldOps, oldExtract.map, 'diff-removed')
343
+ applyHighlightsByNode(newOps, newExtract.map, 'diff-added')
337
344
 
338
- return hasWordChanges || mermaidChanged;
345
+ return hasWordChanges || mermaidChanged
339
346
  }
340
347
 
341
348
  /**
@@ -343,74 +350,75 @@ export function runDomDiff(
343
350
  * Highlights changed mermaid blocks with a border and appends a source diff.
344
351
  */
345
352
  function diffMermaidBlocks(oldContainer: HTMLElement, newContainer: HTMLElement): boolean {
346
- const oldMermaids = Array.from(oldContainer.querySelectorAll<HTMLElement>('[data-md="mermaid"]'));
347
- const newMermaids = Array.from(newContainer.querySelectorAll<HTMLElement>('[data-md="mermaid"]'));
348
- let changed = false;
349
-
350
- // Match by position (simple 1:1 pairing)
351
- const count = Math.max(oldMermaids.length, newMermaids.length);
352
- for (let i = 0; i < count; i++) {
353
- const oldEl = oldMermaids[i];
354
- const newEl = newMermaids[i];
355
-
356
- if (!oldEl && newEl) {
357
- // Added
358
- newEl.classList.add("diff-block-added");
359
- newEl.style.position = "relative";
360
- changed = true;
361
- continue;
362
- }
363
- if (oldEl && !newEl) {
364
- // Removed
365
- oldEl.classList.add("diff-block-removed");
366
- oldEl.style.position = "relative";
367
- changed = true;
368
- continue;
369
- }
370
- if (!oldEl || !newEl) continue;
371
-
372
- const oldSource = (oldEl.getAttribute("data-md-source") || "").trim();
373
- const newSource = (newEl.getAttribute("data-md-source") || "").trim();
374
-
375
- if (oldSource === newSource) continue;
376
- changed = true;
377
-
378
- // Highlight containers
379
- oldEl.style.borderLeft = "3px solid var(--color-accent-red)";
380
- oldEl.style.paddingLeft = "8px";
381
- newEl.style.borderLeft = "3px solid var(--color-accent-green)";
382
- newEl.style.paddingLeft = "8px";
383
-
384
- // Append source diffs
385
- const oldLines = oldSource.split("\n");
386
- const newLines = newSource.split("\n");
387
- const lcs = simpleLCS(oldLines, newLines);
388
-
389
- const buildDiffEl = (lines: string[], lcsArr: string[], side: "old" | "new") => {
390
- const container = document.createElement("div");
391
- container.className = "mt-2 bg-bg-code rounded-md overflow-hidden text-tiny font-mono";
392
- container.setAttribute("data-no-diff", "true");
393
-
394
- let li = 0, si = 0;
395
- while (si < lines.length) {
396
- const row = document.createElement("div");
397
- row.className = "px-3 py-0.5 whitespace-pre-wrap";
398
- if (li < lcsArr.length && lines[si] === lcsArr[li]) {
399
- row.textContent = " " + lines[si];
400
- si++; li++;
401
- } else {
402
- row.classList.add(side === "old" ? "diff-removed" : "diff-added");
403
- row.textContent = (side === "old" ? "- " : "+ ") + lines[si];
404
- si++;
405
- }
406
- container.appendChild(row);
407
- }
408
- return container;
409
- };
410
-
411
- oldEl.appendChild(buildDiffEl(oldLines, lcs, "old"));
412
- newEl.appendChild(buildDiffEl(newLines, lcs, "new"));
413
- }
414
-
415
- return changed;
353
+ const oldMermaids = Array.from(oldContainer.querySelectorAll<HTMLElement>('[data-md="mermaid"]'))
354
+ const newMermaids = Array.from(newContainer.querySelectorAll<HTMLElement>('[data-md="mermaid"]'))
355
+ let changed = false
356
+
357
+ // Match by position (simple 1:1 pairing)
358
+ const count = Math.max(oldMermaids.length, newMermaids.length)
359
+ for (let i = 0; i < count; i++) {
360
+ const oldEl = oldMermaids[i]
361
+ const newEl = newMermaids[i]
362
+
363
+ if (!oldEl && newEl) {
364
+ // Added
365
+ newEl.classList.add('diff-block-added')
366
+ newEl.style.position = 'relative'
367
+ changed = true
368
+ continue
369
+ }
370
+ if (oldEl && !newEl) {
371
+ // Removed
372
+ oldEl.classList.add('diff-block-removed')
373
+ oldEl.style.position = 'relative'
374
+ changed = true
375
+ continue
376
+ }
377
+ if (!oldEl || !newEl) continue
378
+
379
+ const oldSource = (oldEl.getAttribute('data-md-source') || '').trim()
380
+ const newSource = (newEl.getAttribute('data-md-source') || '').trim()
381
+
382
+ if (oldSource === newSource) continue
383
+ changed = true
384
+
385
+ // Highlight containers
386
+ oldEl.style.borderLeft = '3px solid var(--color-accent-red)'
387
+ oldEl.style.paddingLeft = '8px'
388
+ newEl.style.borderLeft = '3px solid var(--color-accent-green)'
389
+ newEl.style.paddingLeft = '8px'
390
+
391
+ // Append source diffs
392
+ const oldLines = oldSource.split('\n')
393
+ const newLines = newSource.split('\n')
394
+ const lcs = simpleLCS(oldLines, newLines)
395
+
396
+ const buildDiffEl = (lines: string[], lcsArr: string[], side: 'old' | 'new') => {
397
+ const container = document.createElement('div')
398
+ container.className = 'mt-2 bg-bg-code rounded-md overflow-hidden text-tiny font-mono'
399
+ container.setAttribute('data-no-diff', 'true')
400
+
401
+ let li = 0, si = 0
402
+ while (si < lines.length) {
403
+ const row = document.createElement('div')
404
+ row.className = 'px-3 py-0.5 whitespace-pre-wrap'
405
+ if (li < lcsArr.length && lines[si] === lcsArr[li]) {
406
+ row.textContent = ' ' + lines[si]
407
+ si++
408
+ li++
409
+ } else {
410
+ row.classList.add(side === 'old' ? 'diff-removed' : 'diff-added')
411
+ row.textContent = (side === 'old' ? '- ' : '+ ') + lines[si]
412
+ si++
413
+ }
414
+ container.appendChild(row)
415
+ }
416
+ return container
417
+ }
418
+
419
+ oldEl.appendChild(buildDiffEl(oldLines, lcs, 'old'))
420
+ newEl.appendChild(buildDiffEl(newLines, lcs, 'new'))
421
+ }
422
+
423
+ return changed
416
424
  }