@wonderwhy-er/desktop-commander 0.2.39 → 0.2.40

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 (137) hide show
  1. package/dist/server.js +1 -1
  2. package/dist/ui/file-preview/preview-runtime.js +204 -153
  3. package/dist/ui/file-preview/src/markdown/controller.d.ts +7 -1
  4. package/dist/ui/file-preview/src/markdown/controller.js +135 -16
  5. package/dist/ui/file-preview/src/markdown/editor.d.ts +97 -1
  6. package/dist/ui/file-preview/src/markdown/editor.js +814 -26
  7. package/dist/ui/file-preview/src/model.d.ts +2 -1
  8. package/dist/utils/capture.js +1 -1
  9. package/dist/utils/toolHistory.d.ts +13 -0
  10. package/dist/utils/toolHistory.js +65 -0
  11. package/dist/version.d.ts +1 -1
  12. package/dist/version.js +1 -1
  13. package/package.json +7 -1
  14. package/dist/ui/config-editor/app.js +0 -840
  15. package/dist/ui/config-editor/array-modal.d.ts +0 -19
  16. package/dist/ui/config-editor/array-modal.js +0 -185
  17. package/dist/ui/config-editor/main.d.ts +0 -1
  18. package/dist/ui/config-editor/main.js +0 -2
  19. package/dist/ui/config-editor/src/App.d.ts +0 -43
  20. package/dist/ui/config-editor/src/components/layout.d.ts +0 -4
  21. package/dist/ui/config-editor/src/components/layout.js +0 -83
  22. package/dist/ui/config-editor/src/components/toolbar.d.ts +0 -1
  23. package/dist/ui/config-editor/src/components/toolbar.js +0 -21
  24. package/dist/ui/config-editor/src/config-values.d.ts +0 -6
  25. package/dist/ui/config-editor/src/config-values.js +0 -61
  26. package/dist/ui/config-editor/src/contracts.d.ts +0 -14
  27. package/dist/ui/config-editor/src/contracts.js +0 -3
  28. package/dist/ui/config-editor/src/directory-browser.d.ts +0 -6
  29. package/dist/ui/config-editor/src/directory-browser.js +0 -71
  30. package/dist/ui/config-editor/src/layout.d.ts +0 -5
  31. package/dist/ui/config-editor/src/layout.js +0 -90
  32. package/dist/ui/config-editor/src/parsing.d.ts +0 -5
  33. package/dist/ui/config-editor/src/parsing.js +0 -50
  34. package/dist/ui/config-editor/src/toolbar.d.ts +0 -1
  35. package/dist/ui/config-editor/src/toolbar.js +0 -18
  36. package/dist/ui/config-editor/src/types.d.ts +0 -17
  37. package/dist/ui/config-editor/src/types.js +0 -3
  38. package/dist/ui/config-editor/src/utils/config-values.d.ts +0 -9
  39. package/dist/ui/config-editor/src/utils/config-values.js +0 -61
  40. package/dist/ui/config-editor/src/utils/directory-browser.d.ts +0 -31
  41. package/dist/ui/config-editor/src/utils/directory-browser.js +0 -201
  42. package/dist/ui/config-editor/src/utils/parsing.d.ts +0 -8
  43. package/dist/ui/config-editor/src/utils/parsing.js +0 -50
  44. package/dist/ui/file-preview/app.d.ts +0 -8
  45. package/dist/ui/file-preview/app.js +0 -2020
  46. package/dist/ui/file-preview/components/code-viewer.d.ts +0 -6
  47. package/dist/ui/file-preview/components/code-viewer.js +0 -73
  48. package/dist/ui/file-preview/components/highlighting.d.ts +0 -2
  49. package/dist/ui/file-preview/components/highlighting.js +0 -54
  50. package/dist/ui/file-preview/components/html-renderer.d.ts +0 -5
  51. package/dist/ui/file-preview/components/html-renderer.js +0 -47
  52. package/dist/ui/file-preview/components/markdown-renderer.d.ts +0 -1
  53. package/dist/ui/file-preview/components/markdown-renderer.js +0 -67
  54. package/dist/ui/file-preview/components/toolbar.d.ts +0 -6
  55. package/dist/ui/file-preview/components/toolbar.js +0 -75
  56. package/dist/ui/file-preview/image-preview.d.ts +0 -3
  57. package/dist/ui/file-preview/image-preview.js +0 -21
  58. package/dist/ui/file-preview/main.d.ts +0 -1
  59. package/dist/ui/file-preview/main.js +0 -5
  60. package/dist/ui/file-preview/markdown/editor.d.ts +0 -36
  61. package/dist/ui/file-preview/markdown/editor.js +0 -643
  62. package/dist/ui/file-preview/markdown/linking.d.ts +0 -9
  63. package/dist/ui/file-preview/markdown/linking.js +0 -210
  64. package/dist/ui/file-preview/markdown/outline.d.ts +0 -7
  65. package/dist/ui/file-preview/markdown/outline.js +0 -40
  66. package/dist/ui/file-preview/markdown/preview.d.ts +0 -8
  67. package/dist/ui/file-preview/markdown/preview.js +0 -33
  68. package/dist/ui/file-preview/markdown/slugify.d.ts +0 -3
  69. package/dist/ui/file-preview/markdown/slugify.js +0 -31
  70. package/dist/ui/file-preview/markdown/toc.d.ts +0 -11
  71. package/dist/ui/file-preview/markdown/toc.js +0 -75
  72. package/dist/ui/file-preview/markdown/utils.d.ts +0 -1
  73. package/dist/ui/file-preview/markdown/utils.js +0 -15
  74. package/dist/ui/file-preview/markdown/workspace-controller.d.ts +0 -25
  75. package/dist/ui/file-preview/markdown/workspace-controller.js +0 -40
  76. package/dist/ui/file-preview/src/components/CodeViewer.d.ts +0 -6
  77. package/dist/ui/file-preview/src/components/CodeViewer.js +0 -60
  78. package/dist/ui/file-preview/src/components/HtmlRenderer.d.ts +0 -8
  79. package/dist/ui/file-preview/src/components/HtmlRenderer.js +0 -45
  80. package/dist/ui/file-preview/src/components/MarkdownRenderer.d.ts +0 -1
  81. package/dist/ui/file-preview/src/components/MarkdownRenderer.js +0 -15
  82. package/dist/ui/file-preview/src/components/Toolbar.d.ts +0 -6
  83. package/dist/ui/file-preview/src/components/Toolbar.js +0 -75
  84. package/dist/ui/file-preview/src/components/editor-toolbar.d.ts +0 -15
  85. package/dist/ui/file-preview/src/components/editor-toolbar.js +0 -384
  86. package/dist/ui/file-preview/src/components/markdown-editor.d.ts +0 -29
  87. package/dist/ui/file-preview/src/components/markdown-editor.js +0 -535
  88. package/dist/ui/file-preview/src/markdown/block-merge.d.ts +0 -25
  89. package/dist/ui/file-preview/src/markdown/block-merge.js +0 -86
  90. package/dist/ui/file-preview/src/markdown/link-modal.d.ts +0 -13
  91. package/dist/ui/file-preview/src/markdown/link-modal.js +0 -213
  92. package/dist/ui/file-preview/src/markdown/raw-editor.d.ts +0 -8
  93. package/dist/ui/file-preview/src/markdown/raw-editor.js +0 -61
  94. package/dist/ui/file-preview/src/markdown/selection-toolbar.d.ts +0 -14
  95. package/dist/ui/file-preview/src/markdown/selection-toolbar.js +0 -128
  96. package/dist/ui/file-preview/src/markdown/toc.d.ts +0 -11
  97. package/dist/ui/file-preview/src/markdown/toc.js +0 -75
  98. package/dist/ui/file-preview/src/markdown-workspace/editor.d.ts +0 -36
  99. package/dist/ui/file-preview/src/markdown-workspace/editor.js +0 -643
  100. package/dist/ui/file-preview/src/markdown-workspace/linking.d.ts +0 -9
  101. package/dist/ui/file-preview/src/markdown-workspace/linking.js +0 -210
  102. package/dist/ui/file-preview/src/markdown-workspace/outline.d.ts +0 -7
  103. package/dist/ui/file-preview/src/markdown-workspace/outline.js +0 -40
  104. package/dist/ui/file-preview/src/markdown-workspace/preview.d.ts +0 -8
  105. package/dist/ui/file-preview/src/markdown-workspace/preview.js +0 -33
  106. package/dist/ui/file-preview/src/markdown-workspace/slugify.d.ts +0 -3
  107. package/dist/ui/file-preview/src/markdown-workspace/slugify.js +0 -31
  108. package/dist/ui/file-preview/src/markdown-workspace/toc.d.ts +0 -11
  109. package/dist/ui/file-preview/src/markdown-workspace/toc.js +0 -75
  110. package/dist/ui/file-preview/src/markdown-workspace/utils.d.ts +0 -1
  111. package/dist/ui/file-preview/src/markdown-workspace/utils.js +0 -15
  112. package/dist/ui/file-preview/src/markdown-workspace/workspace-controller.d.ts +0 -25
  113. package/dist/ui/file-preview/src/markdown-workspace/workspace-controller.js +0 -40
  114. package/dist/ui/file-preview/types.d.ts +0 -1
  115. package/dist/ui/file-preview/types.js +0 -1
  116. package/dist/ui/server-integration.d.ts +0 -13
  117. package/dist/ui/server-integration.js +0 -31
  118. package/dist/ui/shared/ToolHeader.d.ts +0 -9
  119. package/dist/ui/shared/ToolHeader.js +0 -29
  120. package/dist/ui/shared/app-bootstrap.d.ts +0 -9
  121. package/dist/ui/shared/app-bootstrap.js +0 -15
  122. package/dist/ui/shared/guards.d.ts +0 -1
  123. package/dist/ui/shared/guards.js +0 -3
  124. package/dist/ui/shared/host-lifecycle.d.ts +0 -17
  125. package/dist/ui/shared/host-lifecycle.js +0 -41
  126. package/dist/ui/shared/rpc-client.d.ts +0 -14
  127. package/dist/ui/shared/rpc-client.js +0 -72
  128. package/dist/ui/shared/theme-adaptation.d.ts +0 -10
  129. package/dist/ui/shared/theme-adaptation.js +0 -118
  130. package/dist/ui/shared/tool-header.d.ts +0 -9
  131. package/dist/ui/shared/tool-header.js +0 -25
  132. package/dist/utils/ui-call-context.d.ts +0 -8
  133. package/dist/utils/ui-call-context.js +0 -72
  134. /package/dist/ui/config-editor/{app.d.ts → src/app.d.ts} +0 -0
  135. /package/dist/ui/config-editor/src/{App.js → app.js} +0 -0
  136. /package/dist/ui/file-preview/src/{App.d.ts → app.d.ts} +0 -0
  137. /package/dist/ui/file-preview/src/{App.js → app.js} +0 -0
@@ -1,9 +1,779 @@
1
1
  import { Editor } from '@tiptap/core';
2
2
  import StarterKit from '@tiptap/starter-kit';
3
3
  import Image from '@tiptap/extension-image';
4
+ import { Table } from '@tiptap/extension-table';
5
+ import { TableRow } from '@tiptap/extension-table-row';
6
+ import { TableHeader } from '@tiptap/extension-table-header';
7
+ import { TableCell } from '@tiptap/extension-table-cell';
4
8
  import { Markdown } from 'tiptap-markdown';
5
9
  import { restoreWikiLinks, rewriteWikiLinks } from './linking.js';
6
10
  import { createSlugTracker } from './slugify.js';
11
+ const FRONTMATTER_RE = /^(---\r?\n[\s\S]*?\r?\n---\r?\n)/;
12
+ // Match any markdown inline link: `[text](url)`. We don't restrict the
13
+ // text or URL further at the regex level — instead, isFragileLink()
14
+ // inspects each match to decide whether Tiptap would mangle it.
15
+ const INLINE_LINK_RE = /\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
16
+ // Match a `**…**` bold span whose contents contain at least one inline
17
+ // code segment. ProseMirror's flat-mark schema can't cleanly represent a
18
+ // bold wrapping inline code, so Tiptap shifts the bold delimiters around
19
+ // the code in non-obvious ways on serialize. We placeholder these spans
20
+ // during preprocess and restore them after.
21
+ //
22
+ // Pattern detail:
23
+ // \*\* opening **
24
+ // ([^*\n]*? any non-`*`, non-newline chars, lazy
25
+ // `[^`\n]+` at least one `` `inline code` `` segment
26
+ // [^*\n]*?) then more non-`*` chars (lazy)
27
+ // \*\* closing **
28
+ //
29
+ // The lazy quantifiers keep us from spanning multiple bold groups.
30
+ const BOLD_AROUND_CODE_RE = /\*\*([^*\n]*?`[^`\n]+`[^*\n]*?)\*\*/g;
31
+ // Token used to placeholder `\|` escapes. Chosen so it's:
32
+ // - ASCII letters/digits only (survives Tiptap's parse/serialize round trip)
33
+ // - distinctive enough to never collide with real document content
34
+ const PIPE_ESCAPE_TOKEN = 'TIPTAPPIPEESCX';
35
+ /**
36
+ * Decide whether a markdown inline link will be mangled by Tiptap, in
37
+ * which case we should placeholder it during preprocess.
38
+ *
39
+ * Two failure modes are known:
40
+ *
41
+ * 1. Link text is purely inline code (`[\`x\`](url)`). tiptap-markdown
42
+ * drops the surrounding `[...](url)` and leaves just `\`x\``.
43
+ *
44
+ * 2. URL is a relative path with subdirectory but no leading prefix
45
+ * (`scripts/foo.mjs`, `references/output.md`). The Link extension's
46
+ * URL validator rejects these as non-URLs; the link is silently
47
+ * dropped on parse and the text alone survives.
48
+ *
49
+ * URLs Tiptap accepts and we leave alone:
50
+ * - Absolute URLs (`https://`, `http://`, `mailto:`, `tel:`, `ftp:`)
51
+ * - Anchors (`#section`)
52
+ * - Single-segment relative paths (`file.md`, `file.md#section`)
53
+ * - Explicitly-relative paths (`./foo`, `../foo`, `/foo`)
54
+ */
55
+ function isFragileLink(text, url) {
56
+ // Code-text link: text is exactly `` `...` `` with nothing else.
57
+ if (/^`[^`]+`$/.test(text))
58
+ return true;
59
+ // URL has no scheme prefix and no leading-slash / relative-prefix
60
+ // and contains at least one path separator → Tiptap rejects it.
61
+ const hasScheme = /^[a-z][a-z0-9+.-]*:/i.test(url);
62
+ const hasRelativePrefix = url.startsWith('./') || url.startsWith('../') || url.startsWith('/') || url.startsWith('#');
63
+ if (!hasScheme && !hasRelativePrefix && url.includes('/'))
64
+ return true;
65
+ return false;
66
+ }
67
+ /**
68
+ * Pre-process a document before handing it to Tiptap. Returns a context
69
+ * object that `applyPostProcess` uses to restore stripped portions.
70
+ */
71
+ export function preprocessForEditor(input) {
72
+ const eol = input.includes('\r\n') ? '\r\n' : '\n';
73
+ // Normalise to LF for the editor — Tiptap's parser doesn't reliably
74
+ // preserve CRLF, and we'll re-introduce it on output.
75
+ const lf = eol === '\r\n' ? input.replace(/\r\n/g, '\n') : input;
76
+ const frontMatch = lf.match(FRONTMATTER_RE);
77
+ const frontmatter = frontMatch ? frontMatch[0] : '';
78
+ let afterFront = frontmatter ? lf.slice(frontmatter.length) : lf;
79
+ // Capture leading blank lines that appeared AFTER the frontmatter so
80
+ // we can put them back. Tiptap's parser strips them.
81
+ const gap = afterFront.match(/^\n*/)?.[0] ?? '';
82
+ afterFront = afterFront.slice(gap.length);
83
+ const trailingNewline = afterFront.endsWith('\n') ? '\n' : '';
84
+ // tiptap-markdown drops the URL on certain link shapes (see
85
+ // isFragileLink — currently code-text links and bare-relative-subpath
86
+ // links). Replace those with ASCII placeholders that survive the
87
+ // parse-and-serialize round-trip unchanged; we restore them in
88
+ // applyPostProcess.
89
+ const codeLinks = [];
90
+ let withPlaceholders = afterFront;
91
+ let codeLinkIndex = 0;
92
+ withPlaceholders = withPlaceholders.replace(INLINE_LINK_RE, (match, text, url) => {
93
+ if (!isFragileLink(text, url))
94
+ return match;
95
+ const placeholder = `TIPTAPCODELINK${String(codeLinkIndex).padStart(4, '0')}`;
96
+ codeLinks.push({ placeholder, original: match });
97
+ codeLinkIndex += 1;
98
+ return placeholder;
99
+ });
100
+ // Bold spans containing inline code are restructured by Tiptap on
101
+ // round-trip (the bold mark gets shifted around the code in ways
102
+ // ProseMirror's flat-mark schema can express). Placeholder them
103
+ // alongside fragile links — same trick, same restore pass.
104
+ const boldCodeRuns = [];
105
+ let boldCodeIndex = 0;
106
+ withPlaceholders = withPlaceholders.replace(BOLD_AROUND_CODE_RE, (match) => {
107
+ const placeholder = `TIPTAPBOLDCODE${String(boldCodeIndex).padStart(4, '0')}`;
108
+ boldCodeRuns.push({ placeholder, original: match });
109
+ boldCodeIndex += 1;
110
+ return placeholder;
111
+ });
112
+ // Authors escape `|` as `\|` inside table cells when the cell
113
+ // contains literal pipes (Mermaid edge labels in code, shell
114
+ // pipelines, etc.) — bare `|` would split the cell. Tiptap's
115
+ // serializer drops the backslash and the table re-parses with a
116
+ // different shape next time. Replace with an ASCII token; restore
117
+ // after serialize.
118
+ let pipeEscapeCount = 0;
119
+ withPlaceholders = withPlaceholders.replace(/\\\|/g, () => {
120
+ pipeEscapeCount += 1;
121
+ return PIPE_ESCAPE_TOKEN;
122
+ });
123
+ // Tiptap mutates trailing newlines — we trim and put it back. Wikilinks
124
+ // are rewritten to a placeholder shape that survives Tiptap.
125
+ const editorInput = rewriteWikiLinks(withPlaceholders);
126
+ return {
127
+ editorInput,
128
+ context: {
129
+ originalInput: input,
130
+ frontmatter,
131
+ frontmatterGap: gap,
132
+ trailingNewline,
133
+ eol,
134
+ codeLinks,
135
+ boldCodeRuns,
136
+ pipeEscapeCount,
137
+ },
138
+ };
139
+ }
140
+ /**
141
+ * Post-process the markdown Tiptap emits back into the user's expected
142
+ * form: re-attach frontmatter, restore wikilink syntax, restore trailing
143
+ * newline, undo unnecessary character escapes, and re-apply the original
144
+ * EOL convention.
145
+ */
146
+ export function applyPostProcess(serialized, context) {
147
+ let out = restoreWikiLinks(serialized);
148
+ // Restore code-text links replaced with placeholders during preprocess.
149
+ // Done before any other repair so subsequent text-shape fixups operate
150
+ // on the original markdown form.
151
+ for (const { placeholder, original } of context.codeLinks) {
152
+ out = out.split(placeholder).join(original);
153
+ }
154
+ // Restore `**…\`code\`…**` placeholder runs alongside the link
155
+ // restore — same shape, different schema-level reason for needing it.
156
+ for (const { placeholder, original } of context.boldCodeRuns) {
157
+ out = out.split(placeholder).join(original);
158
+ }
159
+ // Restore escaped pipe placeholders. Each token unconditionally maps
160
+ // back to `\|` regardless of position — the user's escape is
161
+ // syntactically required wherever it appears.
162
+ if (context.pipeEscapeCount > 0) {
163
+ out = out.split(PIPE_ESCAPE_TOKEN).join('\\|');
164
+ }
165
+ // Tiptap's serializer over-escapes characters that have no syntactic
166
+ // meaning in the position they appear. We selectively unescape:
167
+ // - `\[` and `\]` outside link constructs (so `- [x] task` stays `- [x] task`)
168
+ // - `\~` (we already disabled strike, but tiptap-markdown's
169
+ // escape pass can still emit `\~` for any `~` it wasn't sure
170
+ // about — reverse it).
171
+ // We do this with conservative regexes that don't touch valid escapes
172
+ // inside fenced code blocks or inline code.
173
+ out = unescapeSafeChars(out, context.originalInput);
174
+ // Tiptap's HTML output path HTML-escapes bare `<` characters in
175
+ // prose because they could in theory open a tag. tiptap-markdown
176
+ // then serialises the entity as a literal `&lt;`. Reverse the
177
+ // entity in positions where CommonMark says `<` could not have been
178
+ // a tag opener (followed by space, digit, `$`, etc.) — preserves
179
+ // the source bytes without changing parser interpretation.
180
+ out = unescapeHtmlEntitiesInProse(out, context.originalInput);
181
+ // Tiptap serialises CommonMark hard breaks (two trailing spaces in
182
+ // the source) either as a `\` line-continuation or by dropping them
183
+ // entirely (inside list items). Restore the original two-space form
184
+ // wherever the source used it.
185
+ out = restoreTrailingHardBreaks(out, context.originalInput);
186
+ // Tiptap normalises GFM table separator rows to a spaced form
187
+ // (`| --- | --- |`) regardless of input shape. If the original used
188
+ // a more compact form (`|---|---|`), restore it line-by-line.
189
+ out = restoreTableSeparatorStyle(out, context.originalInput);
190
+ // tiptap-markdown is configured with `bulletListMarker: '-'` so every
191
+ // bullet is emitted as `- `. If the source used `*` (or a mix), we'd
192
+ // overwrite the user's preference on every save. Restore the original
193
+ // marker by mapping output bullet lines onto their corresponding
194
+ // source bullet lines positionally.
195
+ out = restoreBulletMarkers(out, context.originalInput);
196
+ // Tiptap inserts a leading blank line when the document starts with
197
+ // a block element. Strip it so we can re-attach the original
198
+ // post-frontmatter spacing exactly.
199
+ out = out.replace(/^\n+/, '');
200
+ // Tiptap (with `breaks: false`) joins consecutive non-blank lines
201
+ // inside a paragraph with a space — that's CommonMark's soft-break
202
+ // semantics. The user's source had them as separate lines, so the
203
+ // file has been "modified" even though the visible content is the
204
+ // same. Restore the original line breaks where Tiptap collapsed them.
205
+ // This MUST run before collapseBlockSeparators because the latter
206
+ // matches the surrounding lines against pairs from the original — and
207
+ // those pairs are line-wise, not paragraph-wise.
208
+ out = restoreSoftBreaks(out, context.originalInput);
209
+ // Tiptap normalises block separators to a blank line. If the user
210
+ // authored adjacent blocks with single-line separators, restore the
211
+ // original single-line spacing.
212
+ out = collapseBlockSeparators(out, context.originalInput);
213
+ // Tiptap's serializer can leave its own trailing newline; normalise to
214
+ // exactly the trailing-newline state the original had.
215
+ out = out.replace(/\n+$/, '') + context.trailingNewline;
216
+ // Re-attach frontmatter at the very top, with the original gap.
217
+ if (context.frontmatter) {
218
+ out = context.frontmatter + context.frontmatterGap + out;
219
+ }
220
+ // Apply original EOL convention.
221
+ if (context.eol === '\r\n') {
222
+ out = out.replace(/\n/g, '\r\n');
223
+ }
224
+ return out;
225
+ }
226
+ /**
227
+ * Tiptap's table serializer always outputs separator rows in the spaced
228
+ * form `| --- | --- |`. If the source document used a more compact form
229
+ * (`|---|---|`), or any other consistent form, restore that style by
230
+ * collecting the separator rows from the original and matching them
231
+ * positionally to the separators in the output. Both forms are valid GFM
232
+ * and parse identically — this is purely cosmetic and keeps autosave from
233
+ * emitting one-line edit_block calls just because of whitespace.
234
+ */
235
+ function restoreTableSeparatorStyle(serialized, originalInput) {
236
+ // Identify separator rows. A separator row matches /^\|([:\-\s|]+)\|$/
237
+ // — only `:`, `-`, `|`, and whitespace.
238
+ const SEP_RE = /^\|[\s:\-|]+\|$/;
239
+ const origSeparators = originalInput
240
+ .replace(/\r\n/g, '\n')
241
+ .split('\n')
242
+ .filter((line) => SEP_RE.test(line));
243
+ if (origSeparators.length === 0)
244
+ return serialized;
245
+ const outLines = serialized.split('\n');
246
+ let sepIndex = 0;
247
+ for (let i = 0; i < outLines.length; i += 1) {
248
+ if (SEP_RE.test(outLines[i]) && sepIndex < origSeparators.length) {
249
+ // Confirm the column count matches before substituting; if it
250
+ // doesn't, the table has been edited and we leave the new
251
+ // form alone (otherwise we'd corrupt the user's structural
252
+ // changes).
253
+ const origCols = origSeparators[sepIndex].split('|').length;
254
+ const outCols = outLines[i].split('|').length;
255
+ if (origCols === outCols) {
256
+ outLines[i] = origSeparators[sepIndex];
257
+ }
258
+ sepIndex += 1;
259
+ }
260
+ }
261
+ return outLines.join('\n');
262
+ }
263
+ /**
264
+ * Restore the user's original bullet-list marker style.
265
+ *
266
+ * tiptap-markdown's serializer has a single `bulletListMarker` config
267
+ * (we set it to `-`). That means a source file written with `*` bullets
268
+ * comes back with `-` bullets — no data loss, but the file diff is full
269
+ * of one-character changes the user didn't make.
270
+ *
271
+ * Strategy: collect every "bullet line" from the original (lines starting
272
+ * with optional indent + `*`/`-`/`+` + space), in order. Walk the output;
273
+ * for each bullet line, restore the marker style at the same ordinal
274
+ * position. If the structure shifted (the user added a bullet that wasn't
275
+ * in the source), trailing extra bullets keep the editor's `-` style —
276
+ * that's correct for new content.
277
+ */
278
+ function restoreBulletMarkers(serialized, originalInput) {
279
+ const BULLET_RE = /^(\s*)([*\-+])(\s)/;
280
+ const origLines = originalInput.replace(/\r\n/g, '\n').split('\n');
281
+ // Collect markers in source order. We index purely by position in
282
+ // the bullet sequence — no attempt to match by content, so re-ordered
283
+ // bullets still get sensible markers.
284
+ const origMarkers = [];
285
+ for (const line of origLines) {
286
+ const m = line.match(BULLET_RE);
287
+ if (m)
288
+ origMarkers.push(m[2]);
289
+ }
290
+ if (origMarkers.length === 0)
291
+ return serialized;
292
+ const outLines = serialized.split('\n');
293
+ let bulletIdx = 0;
294
+ for (let i = 0; i < outLines.length; i += 1) {
295
+ const m = outLines[i].match(BULLET_RE);
296
+ if (!m)
297
+ continue;
298
+ const wanted = origMarkers[bulletIdx];
299
+ if (wanted && wanted !== m[2]) {
300
+ outLines[i] = m[1] + wanted + m[3] + outLines[i].slice(m[0].length);
301
+ }
302
+ bulletIdx += 1;
303
+ }
304
+ return outLines.join('\n');
305
+ }
306
+ /**
307
+ * Restore soft line-breaks Tiptap collapsed.
308
+ *
309
+ * tiptap-markdown is configured with `breaks: false`, which matches
310
+ * CommonMark's default: a single newline inside a paragraph is treated as
311
+ * a soft break and rendered/serialised as a single space. So an input of
312
+ *
313
+ * First line.
314
+ * Second line.
315
+ *
316
+ * comes back as `First line. Second line.` — same visible content, but
317
+ * the file on disk now differs from what the user authored. This function
318
+ * walks pairs of adjacent non-blank lines from the original and, where
319
+ * Tiptap joined them with a space, restores the original line break.
320
+ *
321
+ * Limitations: if the user actually had `First line. Second line.` on a
322
+ * single line in the source, we won't break it (we only re-introduce
323
+ * breaks that existed in the source). If the same `A` line appears
324
+ * multiple times in the source followed by different `B` lines, we
325
+ * conservatively only repair the FIRST match — the rest are left as
326
+ * Tiptap emitted them (rare in practice).
327
+ */
328
+ function restoreSoftBreaks(serialized, originalInput) {
329
+ const origLines = originalInput.replace(/\r\n/g, '\n').split('\n');
330
+ let out = serialized;
331
+ for (let i = 0; i < origLines.length - 1; i += 1) {
332
+ const a = origLines[i];
333
+ const b = origLines[i + 1];
334
+ // Only consider pairs where BOTH lines are non-blank prose. A
335
+ // blank line means the pair was paragraph-separated, which Tiptap
336
+ // already serialises as `\n\n` — handled elsewhere.
337
+ if (!a || !b)
338
+ continue;
339
+ // Skip lines that look like markdown structure: list markers,
340
+ // headings, fences, table rows, blockquotes. Tiptap handles those
341
+ // as their own block kinds; we don't want to break list items in
342
+ // half — EXCEPT for the specific case of a list item followed by
343
+ // its 2-space-indented lazy continuation. CommonMark joins those
344
+ // into one paragraph too, and Tiptap collapses them into a single
345
+ // line. The source authored them as separate lines so we must
346
+ // restore the break.
347
+ const aIsListHeader = /^\s*([-*+]|\d+\.)\s/.test(a);
348
+ const bIsIndentedCont = /^ +\S/.test(b) && !/^\s*([-*+]|\d+\.)\s/.test(b);
349
+ const isListContinuation = aIsListHeader && bIsIndentedCont;
350
+ if (!isListContinuation) {
351
+ if (looksStructural(a) || looksStructural(b))
352
+ continue;
353
+ }
354
+ const broken = `${a}\n${b}`;
355
+ if (out.indexOf(broken) !== -1)
356
+ continue;
357
+ // Tiptap joins paragraph-internal lines with EITHER a space (the
358
+ // common case for prose) OR no separator at all (when the
359
+ // boundary is between punctuation like `)` and a non-letter
360
+ // character like an emoji). For list-item lazy continuations,
361
+ // Tiptap STRIPS the leading whitespace from the second line and
362
+ // then joins with a single space, so we have to compare against
363
+ // the de-indented form of `b`.
364
+ const candidates = [
365
+ { joiner: ' ', b },
366
+ { joiner: '', b },
367
+ ];
368
+ if (isListContinuation) {
369
+ const deindented = b.replace(/^\s+/, '');
370
+ candidates.push({ joiner: ' ', b: deindented });
371
+ }
372
+ for (const { joiner, b: bForm } of candidates) {
373
+ const joined = `${a}${joiner}${bForm}`;
374
+ const idx = out.indexOf(joined);
375
+ if (idx === -1)
376
+ continue;
377
+ out = out.slice(0, idx) + broken + out.slice(idx + joined.length);
378
+ break;
379
+ }
380
+ }
381
+ return out;
382
+ }
383
+ /**
384
+ * Heuristic: does this line look like markdown structure (heading, list,
385
+ * fence, table, blockquote) rather than plain prose? Used by
386
+ * restoreSoftBreaks to avoid mangling structural content.
387
+ */
388
+ function looksStructural(line) {
389
+ return /^\s*(#{1,6}\s|[-*+]\s|\d+\.\s|>\s|```|\|.*\|\s*$|---|\s*$)/.test(line);
390
+ }
391
+ /**
392
+ * Unescape characters that tiptap-markdown's serializer over-escapes.
393
+ * We only undo escapes for characters that are NEVER syntactically active
394
+ * in plain prose: brackets in body text, tildes outside strikethrough,
395
+ * etc.
396
+ *
397
+ * Round-trip safety: only undo an escape if the SAME escape was not
398
+ * already present in the original source. If the user's file had `\~190M`
399
+ * literally (e.g. left over from a previous Tiptap save before we
400
+ * disabled strike), we leave it alone. If the editor introduced a NEW
401
+ * escape that wasn't in the source, we remove it. This preserves the
402
+ * file-on-disk vs. cleaning-up tension on the safe side.
403
+ *
404
+ * Code fences are skipped so language-internal escapes survive.
405
+ */
406
+ function unescapeSafeChars(md, originalInput) {
407
+ // The fix is per-line, not per-document. For each output line, find a
408
+ // matching source line by stripping all `\X` escapes from candidates;
409
+ // if a stripped source line equals the output line (after also
410
+ // stripping the same escapes), the user did NOT author those escapes
411
+ // in this region and we may safely remove them. If no source line
412
+ // matches even after stripping, we err on the safe side and keep the
413
+ // escapes (they may be intentional).
414
+ const origLines = originalInput.replace(/\r\n/g, '\n').split('\n');
415
+ let insideFence = false;
416
+ const lines = md.split('\n');
417
+ for (let i = 0; i < lines.length; i += 1) {
418
+ const line = lines[i];
419
+ if (/^\s*```/.test(line)) {
420
+ insideFence = !insideFence;
421
+ continue;
422
+ }
423
+ if (insideFence)
424
+ continue;
425
+ // Quick check: if no candidate escapes are even present in this
426
+ // output line, nothing to do.
427
+ if (!/\\[\[\]~]/.test(line))
428
+ continue;
429
+ const stripped = stripSafeEscapes(line);
430
+ // Does ANY source line match this output line, with both sides
431
+ // stripped of safe escapes? If yes, the source had this content
432
+ // without those escapes, so Tiptap added them — strip them.
433
+ const sourceHasEquivalent = origLines.some((origLine) => stripSafeEscapes(origLine) === stripped);
434
+ if (sourceHasEquivalent) {
435
+ // Look for an exact source line match (escapes intact). If
436
+ // there's an exact match, use it to know which escapes were
437
+ // authored vs added.
438
+ const exact = origLines.find((origLine) => origLine === line);
439
+ if (exact !== undefined) {
440
+ // Source had this exact line including escapes — preserve.
441
+ continue;
442
+ }
443
+ // Source had the equivalent without authoring these escapes —
444
+ // strip them.
445
+ lines[i] = stripped;
446
+ }
447
+ // Otherwise: source line is genuinely different from output. Could
448
+ // be an edit, could be a region we don't have a per-line match
449
+ // for. Leave the escapes alone — round-trip safety wins over
450
+ // cleanup.
451
+ }
452
+ return lines.join('\n');
453
+ }
454
+ /**
455
+ * Remove the safe-escape prefixes (`\[`, `\]`, `\~`) from a line. Used to
456
+ * compare an output line against source lines after both have been
457
+ * normalised — if they then match, neither side had user-authored escapes
458
+ * for these specific characters.
459
+ */
460
+ function stripSafeEscapes(line) {
461
+ return line.replace(/\\([\[\]~])/g, '$1');
462
+ }
463
+ /**
464
+ * Replace `&lt;` / `&gt;` / `&amp;` HTML entities with their literal
465
+ * characters in positions where they cannot be HTML or markdown syntax.
466
+ *
467
+ * Tiptap's HTML output path escapes bare `<` and `&` in prose because
468
+ * the characters could in theory open a tag or entity. tiptap-markdown
469
+ * then serialises those entities verbatim, so a source like `< $0.01`
470
+ * round-trips as `&lt; $0.01`. We undo the escape only when the
471
+ * surrounding context proves it can't be markup:
472
+ *
473
+ * - `&lt;` followed by space, digit, `$`, end-of-line, or a punctuation
474
+ * character that can't begin an HTML tag name.
475
+ * - `&gt;` likewise; in CommonMark `>` only has block-level meaning at
476
+ * the start of a line (blockquote), and we never produce that here.
477
+ * - `&amp;` always — `&` followed by anything that isn't a known entity
478
+ * prefix wouldn't survive parsing as a real entity anyway.
479
+ *
480
+ * Code fences and inline code are skipped so that intentionally-escaped
481
+ * entities inside code samples are left intact.
482
+ *
483
+ * Round-trip safety: if the same entity appears in the source on a
484
+ * matching line, we leave it alone (the user authored the entity and we
485
+ * mustn't strip it). This mirrors the line-aligned rule in
486
+ * unescapeSafeChars.
487
+ */
488
+ function unescapeHtmlEntitiesInProse(md, originalInput) {
489
+ const origLines = originalInput.replace(/\r\n/g, '\n').split('\n');
490
+ let insideFence = false;
491
+ const lines = md.split('\n');
492
+ for (let i = 0; i < lines.length; i += 1) {
493
+ const line = lines[i];
494
+ if (/^\s*```/.test(line)) {
495
+ insideFence = !insideFence;
496
+ continue;
497
+ }
498
+ if (insideFence)
499
+ continue;
500
+ if (!/&(?:lt|gt|amp);/.test(line))
501
+ continue;
502
+ // Only act if there's a source line that, when both are stripped
503
+ // of these specific entities, matches this output line. Otherwise
504
+ // we don't have enough confidence the entity was Tiptap's doing.
505
+ const stripped = stripHtmlEntities(line);
506
+ const sourceMatches = origLines.some((src) => stripHtmlEntities(src) === stripped);
507
+ if (!sourceMatches)
508
+ continue;
509
+ const exact = origLines.find((src) => src === line);
510
+ if (exact !== undefined) {
511
+ // Source had this exact line including entities — preserve.
512
+ continue;
513
+ }
514
+ // Otherwise the source had the equivalent without entities;
515
+ // Tiptap added them — strip.
516
+ lines[i] = stripped;
517
+ }
518
+ return lines.join('\n');
519
+ }
520
+ function stripHtmlEntities(line) {
521
+ // Conservative replacements — only the three Tiptap actually emits.
522
+ return line
523
+ .replace(/&lt;/g, '<')
524
+ .replace(/&gt;/g, '>')
525
+ .replace(/&amp;/g, '&');
526
+ }
527
+ /**
528
+ * Restore CommonMark hard-break syntax (two trailing spaces at end of
529
+ * line) where Tiptap stripped or rewrote it.
530
+ *
531
+ * Tiptap's serializer represents a hard break either as `\` followed by
532
+ * a newline (paragraphs) or by silently dropping it (list items). The
533
+ * source convention is two trailing spaces; we honour the source.
534
+ *
535
+ * Strategy: collect every source line that ends in ` ` (exactly two
536
+ * spaces). For each, find a matching output line — either:
537
+ * - same content with no trailing whitespace (the dropped case), or
538
+ * - same content followed by `\\` line continuation (the rewritten
539
+ * case — `expand left\\\nleft`).
540
+ * Replace with the source's two-space form.
541
+ */
542
+ function restoreTrailingHardBreaks(serialized, originalInput) {
543
+ const origLines = originalInput.replace(/\r\n/g, '\n').split('\n');
544
+ // Lines that ended in exactly two trailing spaces — paired with
545
+ // their content sans the trailing spaces, for cheap matching.
546
+ const hardBreakSources = [];
547
+ for (const line of origLines) {
548
+ if (/[^ ] $/.test(line)) {
549
+ hardBreakSources.push(line.slice(0, -2));
550
+ }
551
+ }
552
+ if (hardBreakSources.length === 0)
553
+ return serialized;
554
+ let out = serialized;
555
+ for (const stem of hardBreakSources) {
556
+ // Case 1: paragraph hard break — `stem\\\nNEXT` → `stem \nNEXT`.
557
+ const backslashForm = `${stem}\\\n`;
558
+ if (out.includes(backslashForm)) {
559
+ out = out.replace(backslashForm, `${stem} \n`);
560
+ continue;
561
+ }
562
+ // Case 2: silently dropped (list-item case). Look for the bare
563
+ // `stem\n` and re-introduce the two trailing spaces. We only
564
+ // repair the FIRST match — adding a hard break to the wrong
565
+ // duplicate is worse than missing one.
566
+ const bareForm = `${stem}\n`;
567
+ const idx = out.indexOf(bareForm);
568
+ if (idx !== -1) {
569
+ out = out.slice(0, idx) + `${stem} \n` + out.slice(idx + bareForm.length);
570
+ }
571
+ }
572
+ return out;
573
+ }
574
+ /**
575
+ * If the user's original document used single-line separators between
576
+ * adjacent block elements (e.g. `### A\nBody.\n### B\n`), Tiptap will
577
+ * normalise those to blank-line separators (`\n\n`). Compare structure
578
+ * pairwise and put back the original spacing wherever Tiptap diverged.
579
+ *
580
+ * This is a "best effort" fixup: it doesn't try to rewrite content, only
581
+ * to remove spurious blank lines that Tiptap injected between block
582
+ * elements that were adjacent in the source.
583
+ */
584
+ function collapseBlockSeparators(serialized, originalInput) {
585
+ // Tokenise both into "block" units separated by blank-line vs single-
586
+ // newline boundaries. If the original had no blank line between two
587
+ // adjacent block lines that match (heading -> body, body -> heading,
588
+ // etc.), strip the blank line Tiptap inserted between the same pair.
589
+ const origLines = originalInput.replace(/\r\n/g, '\n').split('\n');
590
+ const adjacentPairs = new Set();
591
+ for (let i = 0; i < origLines.length - 1; i += 1) {
592
+ const a = origLines[i];
593
+ const b = origLines[i + 1];
594
+ if (a && b) {
595
+ // Both non-empty consecutive lines — adjacent in the original.
596
+ adjacentPairs.add(`${a}\u0001${b}`);
597
+ }
598
+ }
599
+ const outLines = serialized.split('\n');
600
+ const result = [];
601
+ for (let i = 0; i < outLines.length; i += 1) {
602
+ const cur = outLines[i];
603
+ // If this is a blank line and the lines around it were adjacent
604
+ // in the original, drop the blank.
605
+ if (cur === '' && i > 0 && i < outLines.length - 1) {
606
+ const prev = outLines[i - 1];
607
+ const next = outLines[i + 1];
608
+ if (prev && next && adjacentPairs.has(`${prev}\u0001${next}`)) {
609
+ continue;
610
+ }
611
+ }
612
+ result.push(cur);
613
+ }
614
+ return result.join('\n');
615
+ }
616
+ /**
617
+ * Build the Tiptap extension array used by both production and the test
618
+ * suite. Centralising this means the regression tests exercise the exact
619
+ * configuration that ships, so any fix here flows through to autosave too.
620
+ *
621
+ * Notable choices:
622
+ * - StarterKit's strike extension is DISABLED. The default behaviour
623
+ * escapes literal `~` to `\~` (and breaks `~/path`) on serialize,
624
+ * because tiptap-markdown configures markdown-it with the strike
625
+ * plugin enabled, which in turn enables `~` as an escape target.
626
+ * Disabling strike costs us nothing visible (the editor never offered
627
+ * a strike button) and unblocks two #440 corruption modes.
628
+ */
629
+ export function buildTiptapExtensions() {
630
+ return [
631
+ StarterKit.configure({
632
+ heading: { levels: [1, 2, 3, 4, 5, 6] },
633
+ codeBlock: { HTMLAttributes: { class: 'code-viewer' } },
634
+ link: {
635
+ openOnClick: false,
636
+ autolink: true,
637
+ HTMLAttributes: { 'data-markdown-link': 'true' },
638
+ },
639
+ // Disable strikethrough — see comment above. The serializer
640
+ // would otherwise treat `~` as a strike delimiter character
641
+ // and emit `\~` to escape it.
642
+ strike: false,
643
+ }),
644
+ Image.configure({ allowBase64: true, inline: true }),
645
+ // GFM pipe table support. Without these four extensions Tiptap's
646
+ // parser sees `| A | B |` rows as plain paragraphs and concatenates
647
+ // the cell text — the canonical #437 corruption pattern. With them,
648
+ // tiptap-markdown round-trips tables correctly.
649
+ Table.configure({ resizable: false, HTMLAttributes: { class: 'markdown-table' } }),
650
+ TableRow,
651
+ TableHeader,
652
+ TableCell,
653
+ Markdown.configure({
654
+ html: true,
655
+ tightLists: true,
656
+ bulletListMarker: '-',
657
+ // `linkify: true` made tiptap-markdown auto-wrap bare URLs in
658
+ // <…> autolink brackets on serialize, even when the source had
659
+ // them as bare URLs. The editor still recognises pasted URLs
660
+ // as clickable via Tiptap's link extension; this only affects
661
+ // the parser's "treat any URL-shaped string as a Link node"
662
+ // behaviour, which is what was rewriting `https://...` to
663
+ // `<https://...>` on round-trip.
664
+ linkify: false,
665
+ breaks: false,
666
+ transformPastedText: true,
667
+ transformCopiedText: false,
668
+ }),
669
+ ];
670
+ }
671
+ /**
672
+ * Convenience wrapper for tests and tools that want to mount the editor,
673
+ * call getMarkdown(), tear down, all in one shot. Production uses the
674
+ * pieces individually (preprocessForEditor at mount time, getMarkdown
675
+ * during autosave, applyPostProcess before writing to disk).
676
+ */
677
+ export function roundTripMarkdown(input) {
678
+ const { editorInput, context } = preprocessForEditor(input);
679
+ const target = document.createElement('div');
680
+ const editor = new Editor({
681
+ element: target,
682
+ extensions: buildTiptapExtensions(),
683
+ content: editorInput,
684
+ });
685
+ const storage = editor.storage;
686
+ const serialized = storage.markdown?.getMarkdown() ?? '';
687
+ editor.destroy();
688
+ return applyPostProcess(serialized, context);
689
+ }
690
+ function computeSerializedEditRanges(before, after) {
691
+ if (before === after) {
692
+ return [];
693
+ }
694
+ const beforeLines = before.split('\n');
695
+ const afterLines = after.split('\n');
696
+ const beforeLength = beforeLines.length;
697
+ const afterLength = afterLines.length;
698
+ const ranges = [];
699
+ if (beforeLength * afterLength > 1000000) {
700
+ return computeAnchoredSerializedEditRanges(beforeLines, afterLines, 0, beforeLength, 0, afterLength);
701
+ }
702
+ const dp = Array.from({ length: beforeLength + 1 }, () => Array(afterLength + 1).fill(0));
703
+ for (let beforeIndex = 1; beforeIndex <= beforeLength; beforeIndex += 1) {
704
+ for (let afterIndex = 1; afterIndex <= afterLength; afterIndex += 1) {
705
+ dp[beforeIndex][afterIndex] = beforeLines[beforeIndex - 1] === afterLines[afterIndex - 1]
706
+ ? dp[beforeIndex - 1][afterIndex - 1] + 1
707
+ : Math.max(dp[beforeIndex - 1][afterIndex], dp[beforeIndex][afterIndex - 1]);
708
+ }
709
+ }
710
+ const matches = [];
711
+ let beforeIndex = beforeLength;
712
+ let afterIndex = afterLength;
713
+ while (beforeIndex > 0 && afterIndex > 0) {
714
+ if (beforeLines[beforeIndex - 1] === afterLines[afterIndex - 1]) {
715
+ matches.unshift([beforeIndex - 1, afterIndex - 1]);
716
+ beforeIndex -= 1;
717
+ afterIndex -= 1;
718
+ }
719
+ else if (dp[beforeIndex - 1][afterIndex] >= dp[beforeIndex][afterIndex - 1]) {
720
+ beforeIndex -= 1;
721
+ }
722
+ else {
723
+ afterIndex -= 1;
724
+ }
725
+ }
726
+ let previousBefore = 0;
727
+ let previousAfter = 0;
728
+ for (const [matchBefore, matchAfter] of matches) {
729
+ if (matchBefore > previousBefore || matchAfter > previousAfter) {
730
+ ranges.push({ fromLine: Math.max(1, previousAfter - 3), toLine: Math.max(previousAfter + 1, matchAfter + 3) });
731
+ }
732
+ previousBefore = matchBefore + 1;
733
+ previousAfter = matchAfter + 1;
734
+ }
735
+ if (previousBefore < beforeLength || previousAfter < afterLength) {
736
+ ranges.push({ fromLine: Math.max(1, previousAfter - 3), toLine: Math.max(previousAfter + 1, afterLength + 3) });
737
+ }
738
+ return ranges;
739
+ }
740
+ function computeAnchoredSerializedEditRanges(beforeLines, afterLines, beforeStart, beforeEnd, afterStart, afterEnd) {
741
+ while (beforeStart < beforeEnd && afterStart < afterEnd && beforeLines[beforeStart] === afterLines[afterStart]) {
742
+ beforeStart++;
743
+ afterStart++;
744
+ }
745
+ while (beforeStart < beforeEnd && afterStart < afterEnd && beforeLines[beforeEnd - 1] === afterLines[afterEnd - 1]) {
746
+ beforeEnd--;
747
+ afterEnd--;
748
+ }
749
+ if (beforeStart === beforeEnd && afterStart === afterEnd) {
750
+ return [];
751
+ }
752
+ const beforeLineCounts = new Map();
753
+ const afterLineCounts = new Map();
754
+ for (let index = beforeStart; index < beforeEnd; index += 1) {
755
+ const current = beforeLineCounts.get(beforeLines[index]);
756
+ beforeLineCounts.set(beforeLines[index], { count: (current?.count ?? 0) + 1, index });
757
+ }
758
+ for (let index = afterStart; index < afterEnd; index += 1) {
759
+ const current = afterLineCounts.get(afterLines[index]);
760
+ afterLineCounts.set(afterLines[index], { count: (current?.count ?? 0) + 1, index });
761
+ }
762
+ for (let beforeIndex = beforeStart; beforeIndex < beforeEnd; beforeIndex += 1) {
763
+ const beforeEntry = beforeLineCounts.get(beforeLines[beforeIndex]);
764
+ const afterEntry = afterLineCounts.get(beforeLines[beforeIndex]);
765
+ if (beforeEntry?.count === 1 && afterEntry?.count === 1) {
766
+ return [
767
+ ...computeAnchoredSerializedEditRanges(beforeLines, afterLines, beforeStart, beforeIndex, afterStart, afterEntry.index),
768
+ ...computeAnchoredSerializedEditRanges(beforeLines, afterLines, beforeIndex + 1, beforeEnd, afterEntry.index + 1, afterEnd),
769
+ ];
770
+ }
771
+ }
772
+ return [{
773
+ fromLine: Math.max(1, afterStart - 3),
774
+ toLine: Math.max(afterStart + 1, afterEnd + 3),
775
+ }];
776
+ }
7
777
  function shouldIgnoreBlur(shell, event) {
8
778
  const nextTarget = event.relatedTarget;
9
779
  const widgetShell = shell?.closest('.tool-shell');
@@ -128,34 +898,24 @@ export function mountMarkdownEditor(options) {
128
898
  let linkHeadingRequestId = 0;
129
899
  if (options.view === 'markdown') {
130
900
  options.target.replaceChildren();
901
+ let hasUserEdited = false;
902
+ const markUserEdit = () => {
903
+ hasUserEdited = true;
904
+ };
905
+ // Pre-process the input once at mount; the captured context is
906
+ // mirrored back into output by getTiptapMarkdown so trailing
907
+ // newline / frontmatter / EOL are preserved.
908
+ const { editorInput, context } = preprocessForEditor(options.value);
131
909
  const getTiptapMarkdown = () => {
132
910
  const storage = tiptap.storage;
133
- return restoreWikiLinks(storage.markdown?.getMarkdown() ?? '');
911
+ const serialized = storage.markdown?.getMarkdown() ?? '';
912
+ return applyPostProcess(serialized, context);
134
913
  };
914
+ let previousSerializedValue = '';
135
915
  const tiptap = new Editor({
136
916
  element: options.target,
137
- extensions: [
138
- StarterKit.configure({
139
- heading: { levels: [1, 2, 3, 4, 5, 6] },
140
- codeBlock: { HTMLAttributes: { class: 'code-viewer' } },
141
- link: {
142
- openOnClick: false,
143
- autolink: true,
144
- HTMLAttributes: { 'data-markdown-link': 'true' },
145
- },
146
- }),
147
- Image.configure({ allowBase64: true, inline: true }),
148
- Markdown.configure({
149
- html: true,
150
- tightLists: true,
151
- bulletListMarker: '-',
152
- linkify: true,
153
- breaks: false,
154
- transformPastedText: true,
155
- transformCopiedText: false,
156
- }),
157
- ],
158
- content: rewriteWikiLinks(options.value),
917
+ extensions: buildTiptapExtensions(),
918
+ content: editorInput,
159
919
  editorProps: {
160
920
  attributes: {
161
921
  class: 'markdown-editor-surface markdown-editor-surface--markdown markdown markdown-doc',
@@ -165,7 +925,13 @@ export function mountMarkdownEditor(options) {
165
925
  },
166
926
  onUpdate: ({ editor }) => {
167
927
  syncHeadingIds(editor.view.dom);
168
- options.onChange(getTiptapMarkdown());
928
+ if (!hasUserEdited) {
929
+ return;
930
+ }
931
+ const value = getTiptapMarkdown();
932
+ const editRanges = computeSerializedEditRanges(previousSerializedValue, value);
933
+ previousSerializedValue = value;
934
+ options.onChange(value, editRanges);
169
935
  },
170
936
  onSelectionUpdate: () => {
171
937
  updateContextMenu();
@@ -180,6 +946,7 @@ export function mountMarkdownEditor(options) {
180
946
  options.onBlur?.();
181
947
  },
182
948
  });
949
+ previousSerializedValue = getTiptapMarkdown();
183
950
  const editorDom = tiptap.view.dom;
184
951
  syncHeadingIds(editorDom);
185
952
  const updateContextMenu = () => {
@@ -369,6 +1136,7 @@ export function mountMarkdownEditor(options) {
369
1136
  renderLinkResults();
370
1137
  };
371
1138
  const handleLinkApply = () => {
1139
+ markUserEdit();
372
1140
  if (linkMode === 'url') {
373
1141
  const href = linkInput?.value?.trim();
374
1142
  if (!href) {
@@ -429,6 +1197,7 @@ export function mountMarkdownEditor(options) {
429
1197
  if (!format) {
430
1198
  return;
431
1199
  }
1200
+ markUserEdit();
432
1201
  switch (format) {
433
1202
  case 'bold':
434
1203
  tiptap.chain().focus().toggleBold().run();
@@ -459,11 +1228,13 @@ export function mountMarkdownEditor(options) {
459
1228
  return;
460
1229
  }
461
1230
  if (value === 'p') {
1231
+ markUserEdit();
462
1232
  tiptap.chain().focus().setParagraph().run();
463
1233
  return;
464
1234
  }
465
1235
  const match = /^h([1-6])$/.exec(value);
466
1236
  if (match) {
1237
+ markUserEdit();
467
1238
  const level = Number.parseInt(match[1], 10);
468
1239
  tiptap.chain().focus().toggleHeading({ level }).run();
469
1240
  }
@@ -555,6 +1326,10 @@ export function mountMarkdownEditor(options) {
555
1326
  closeLinkModal();
556
1327
  }
557
1328
  };
1329
+ editorDom.addEventListener('beforeinput', markUserEdit);
1330
+ editorDom.addEventListener('paste', markUserEdit);
1331
+ editorDom.addEventListener('cut', markUserEdit);
1332
+ editorDom.addEventListener('drop', markUserEdit);
558
1333
  editorDom.addEventListener('mouseover', handleMouseOver);
559
1334
  editorDom.addEventListener('mouseout', handleMouseOut);
560
1335
  linkPopover.addEventListener('mouseenter', handlePopoverEnter);
@@ -573,6 +1348,10 @@ export function mountMarkdownEditor(options) {
573
1348
  renderLinkResults();
574
1349
  return {
575
1350
  destroy: () => {
1351
+ editorDom.removeEventListener('beforeinput', markUserEdit);
1352
+ editorDom.removeEventListener('paste', markUserEdit);
1353
+ editorDom.removeEventListener('cut', markUserEdit);
1354
+ editorDom.removeEventListener('drop', markUserEdit);
576
1355
  editorDom.removeEventListener('mouseover', handleMouseOver);
577
1356
  editorDom.removeEventListener('mouseout', handleMouseOut);
578
1357
  linkPopover.removeEventListener('mouseenter', handlePopoverEnter);
@@ -598,6 +1377,7 @@ export function mountMarkdownEditor(options) {
598
1377
  getValue: () => getTiptapMarkdown(),
599
1378
  setValue: (value) => {
600
1379
  tiptap.commands.setContent(rewriteWikiLinks(value), { emitUpdate: false });
1380
+ previousSerializedValue = getTiptapMarkdown();
601
1381
  syncHeadingIds(editorDom);
602
1382
  },
603
1383
  revealLine: (_lineNumber, headingId) => {
@@ -626,14 +1406,21 @@ export function mountMarkdownEditor(options) {
626
1406
  textarea.setAttribute('autocapitalize', 'off');
627
1407
  textarea.placeholder = 'Edit raw markdown...';
628
1408
  textarea.value = options.value;
1409
+ let previousTextareaValue = textarea.value;
629
1410
  options.target.replaceChildren(textarea);
630
1411
  const autosize = () => {
631
1412
  textarea.style.height = 'auto';
632
1413
  textarea.style.height = `${Math.max(textarea.scrollHeight, 640)}px`;
633
1414
  };
1415
+ const emitRawChange = () => {
1416
+ const value = textarea.value;
1417
+ const editRanges = computeSerializedEditRanges(previousTextareaValue, value);
1418
+ previousTextareaValue = value;
1419
+ options.onChange(value, editRanges);
1420
+ };
634
1421
  const handleInput = () => {
635
1422
  autosize();
636
- options.onChange(textarea.value);
1423
+ emitRawChange();
637
1424
  };
638
1425
  const handleFocusOut = (event) => {
639
1426
  if (shouldIgnoreBlur(shell, event)) {
@@ -648,7 +1435,7 @@ export function mountMarkdownEditor(options) {
648
1435
  event.preventDefault();
649
1436
  applyRawTab(textarea);
650
1437
  autosize();
651
- options.onChange(textarea.value);
1438
+ emitRawChange();
652
1439
  };
653
1440
  textarea.addEventListener('input', handleInput);
654
1441
  textarea.addEventListener('keydown', handleKeyDown);
@@ -670,6 +1457,7 @@ export function mountMarkdownEditor(options) {
670
1457
  getValue: () => textarea.value,
671
1458
  setValue: (value) => {
672
1459
  textarea.value = value;
1460
+ previousTextareaValue = value;
673
1461
  autosize();
674
1462
  },
675
1463
  revealLine: (lineNumber) => {