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.
- package/README.md +42 -17
- package/bin/agent-canvas.js +7 -7
- package/bin/agent-canvas.ts +48 -37
- package/bin/lib/commands/daemon.ts +46 -43
- package/bin/lib/commands/exec.ts +100 -100
- package/bin/lib/commands/fetch.ts +15 -15
- package/bin/lib/commands/install.ts +36 -36
- package/bin/lib/commands/instructions.ts +51 -51
- package/bin/lib/commands/push.ts +115 -97
- package/bin/lib/commands/review.ts +190 -0
- package/bin/lib/commands/watch.ts +24 -24
- package/bin/lib/config.ts +12 -12
- package/bin/lib/daemon-lifecycle.ts +47 -51
- package/bin/lib/helpers.ts +82 -76
- package/bin/lib/instructions/app-review.md +88 -0
- package/bin/lib/instructions/component-checklist.md +7 -5
- package/bin/lib/instructions/component-custom.md +20 -14
- package/bin/lib/instructions/component-diff.md +4 -3
- package/bin/lib/instructions/component-item.md +18 -8
- package/bin/lib/instructions/component-markdown.md +4 -2
- package/bin/lib/instructions/component-mermaid.md +12 -6
- package/bin/lib/instructions/component-multichoice.md +6 -2
- package/bin/lib/instructions/component-note.md +1 -1
- package/bin/lib/instructions/component-priority.md +1 -1
- package/bin/lib/instructions/component-rangeinput.md +9 -2
- package/bin/lib/instructions/component-secretinput.md +12 -11
- package/bin/lib/instructions/component-svg.md +62 -16
- package/bin/lib/instructions/component-table.md +6 -6
- package/bin/lib/instructions/component-usefeedback.md +8 -8
- package/bin/lib/instructions/component-userinput.md +6 -2
- package/bin/lib/instructions/flow-feature.md +22 -5
- package/bin/lib/instructions/flow-plan.md +5 -5
- package/bin/lib/instructions/flow-runbook.md +28 -25
- package/bin/lib/instructions/overview.md +27 -18
- package/bin/lib/instructions/theming.md +5 -0
- package/daemon/build.ts +171 -152
- package/daemon/client/App.tsx +1205 -1077
- package/daemon/client/CompareView.tsx +478 -458
- package/daemon/client/FileBrowser.tsx +241 -203
- package/daemon/client/FileViewer.tsx +220 -211
- package/daemon/client/OverviewView.tsx +283 -267
- package/daemon/client/ReviewerIdentityDialog.tsx +72 -71
- package/daemon/client/RevisionSelect.tsx +111 -95
- package/daemon/client/SessionSwitcher.tsx +81 -81
- package/daemon/client/ShareDialog.tsx +211 -212
- package/daemon/client/apiSchemas.ts +78 -0
- package/daemon/client/canvas-runtime.ts +1 -1
- package/daemon/client/clientApi.ts +194 -206
- package/daemon/client/components/SecretInput.tsx +138 -138
- package/daemon/client/components/index.ts +2 -2
- package/daemon/client/domDiff.ts +341 -333
- package/daemon/client/errorMessage.ts +3 -0
- package/daemon/client/exportMarkdown.ts +222 -201
- package/daemon/client/lcs.ts +19 -14
- package/daemon/client/shareCrypto.ts +57 -57
- package/daemon/client/unifiedDiff.ts +660 -649
- package/daemon/dist/annotations-runtime.js +1 -1
- package/daemon/dist/client.css +2 -2
- package/daemon/dist/client.js +77 -80
- package/daemon/dist/components.js +59 -59
- package/daemon/dist/preact-compat.js +1 -1
- package/daemon/dist/review.js +361 -0
- package/daemon/dist/runtime.js +1 -1
- package/daemon/langMap.ts +24 -6
- package/daemon/review-widget/anchor.ts +187 -0
- package/daemon/review-widget/client.ts +95 -0
- package/daemon/review-widget/index.tsx +36 -0
- package/daemon/review-widget/markup.ts +71 -0
- package/daemon/review-widget/screenshot.ts +91 -0
- package/daemon/review-widget/source.ts +103 -0
- package/daemon/review-widget/styles.css +359 -0
- package/daemon/review-widget/text-modules.d.ts +4 -0
- package/daemon/review-widget/tsconfig.json +16 -0
- package/daemon/review-widget/widget.tsx +436 -0
- package/daemon/src/components.ts +3 -3
- package/daemon/src/handlers/api.ts +330 -328
- package/daemon/src/handlers/files.ts +112 -106
- package/daemon/src/handlers/static.ts +47 -46
- package/daemon/src/handlers/uploads.ts +61 -56
- package/daemon/src/paths.ts +13 -10
- package/daemon/src/remote-feedback.ts +192 -192
- package/daemon/src/review/markdown.ts +52 -0
- package/daemon/src/review/model.ts +132 -0
- package/daemon/src/review/proxy.ts +177 -0
- package/daemon/src/review/server.ts +199 -0
- package/daemon/src/review/store.ts +151 -0
- package/daemon/src/server.ts +127 -86
- package/daemon/src/share.ts +163 -169
- package/daemon/src/stubFetch.ts +7 -0
- package/daemon/tsconfig.json +19 -19
- package/package.json +47 -47
- package/skills/canvas/SKILL.md +5 -1
- package/bin/lib/commands/exec.test.ts +0 -27
- package/bin/lib/helpers.test.ts +0 -79
- package/daemon/src/files-api.test.ts +0 -78
- package/daemon/src/remote-feedback.test.ts +0 -165
- package/daemon/src/secrets-api.test.ts +0 -123
- package/daemon/src/share-e2e.test.ts +0 -277
- 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
|
|
10
|
-
import { simpleLCS } from
|
|
9
|
+
import { computeWordDiff } from './domDiff'
|
|
10
|
+
import { simpleLCS } from './lcs'
|
|
11
11
|
|
|
12
12
|
interface WordEntry {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
text: string
|
|
14
|
+
node: Text
|
|
15
|
+
offset: number
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
interface BlockDescriptor {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
key: string
|
|
20
|
+
type: string
|
|
21
|
+
element: HTMLElement
|
|
22
|
+
children: BlockDescriptor[]
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
interface BlockMatch {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
204
|
+
oldBlock: BlockDescriptor,
|
|
205
|
+
newClone: HTMLElement,
|
|
206
|
+
childMatches: BlockMatch[],
|
|
207
|
+
type: string,
|
|
205
208
|
): boolean {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
357
|
+
childMatches: BlockMatch[],
|
|
358
|
+
removedMatch: BlockMatch,
|
|
359
|
+
cloneMap: Map<HTMLElement, HTMLElement | null>,
|
|
368
360
|
): HTMLElement | null {
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
|
|
613
|
-
|
|
614
|
-
|
|
620
|
+
textNode: Text,
|
|
621
|
+
highlights: { start: number; end: number }[],
|
|
622
|
+
inserts: { offset: number; text: string }[],
|
|
615
623
|
): void {
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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
|
-
|