@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.
- package/dist/server.js +1 -1
- package/dist/ui/file-preview/preview-runtime.js +204 -153
- package/dist/ui/file-preview/src/markdown/controller.d.ts +7 -1
- package/dist/ui/file-preview/src/markdown/controller.js +135 -16
- package/dist/ui/file-preview/src/markdown/editor.d.ts +97 -1
- package/dist/ui/file-preview/src/markdown/editor.js +814 -26
- package/dist/ui/file-preview/src/model.d.ts +2 -1
- package/dist/utils/capture.js +1 -1
- package/dist/utils/toolHistory.d.ts +13 -0
- package/dist/utils/toolHistory.js +65 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +7 -1
- package/dist/ui/config-editor/app.js +0 -840
- package/dist/ui/config-editor/array-modal.d.ts +0 -19
- package/dist/ui/config-editor/array-modal.js +0 -185
- package/dist/ui/config-editor/main.d.ts +0 -1
- package/dist/ui/config-editor/main.js +0 -2
- package/dist/ui/config-editor/src/App.d.ts +0 -43
- package/dist/ui/config-editor/src/components/layout.d.ts +0 -4
- package/dist/ui/config-editor/src/components/layout.js +0 -83
- package/dist/ui/config-editor/src/components/toolbar.d.ts +0 -1
- package/dist/ui/config-editor/src/components/toolbar.js +0 -21
- package/dist/ui/config-editor/src/config-values.d.ts +0 -6
- package/dist/ui/config-editor/src/config-values.js +0 -61
- package/dist/ui/config-editor/src/contracts.d.ts +0 -14
- package/dist/ui/config-editor/src/contracts.js +0 -3
- package/dist/ui/config-editor/src/directory-browser.d.ts +0 -6
- package/dist/ui/config-editor/src/directory-browser.js +0 -71
- package/dist/ui/config-editor/src/layout.d.ts +0 -5
- package/dist/ui/config-editor/src/layout.js +0 -90
- package/dist/ui/config-editor/src/parsing.d.ts +0 -5
- package/dist/ui/config-editor/src/parsing.js +0 -50
- package/dist/ui/config-editor/src/toolbar.d.ts +0 -1
- package/dist/ui/config-editor/src/toolbar.js +0 -18
- package/dist/ui/config-editor/src/types.d.ts +0 -17
- package/dist/ui/config-editor/src/types.js +0 -3
- package/dist/ui/config-editor/src/utils/config-values.d.ts +0 -9
- package/dist/ui/config-editor/src/utils/config-values.js +0 -61
- package/dist/ui/config-editor/src/utils/directory-browser.d.ts +0 -31
- package/dist/ui/config-editor/src/utils/directory-browser.js +0 -201
- package/dist/ui/config-editor/src/utils/parsing.d.ts +0 -8
- package/dist/ui/config-editor/src/utils/parsing.js +0 -50
- package/dist/ui/file-preview/app.d.ts +0 -8
- package/dist/ui/file-preview/app.js +0 -2020
- package/dist/ui/file-preview/components/code-viewer.d.ts +0 -6
- package/dist/ui/file-preview/components/code-viewer.js +0 -73
- package/dist/ui/file-preview/components/highlighting.d.ts +0 -2
- package/dist/ui/file-preview/components/highlighting.js +0 -54
- package/dist/ui/file-preview/components/html-renderer.d.ts +0 -5
- package/dist/ui/file-preview/components/html-renderer.js +0 -47
- package/dist/ui/file-preview/components/markdown-renderer.d.ts +0 -1
- package/dist/ui/file-preview/components/markdown-renderer.js +0 -67
- package/dist/ui/file-preview/components/toolbar.d.ts +0 -6
- package/dist/ui/file-preview/components/toolbar.js +0 -75
- package/dist/ui/file-preview/image-preview.d.ts +0 -3
- package/dist/ui/file-preview/image-preview.js +0 -21
- package/dist/ui/file-preview/main.d.ts +0 -1
- package/dist/ui/file-preview/main.js +0 -5
- package/dist/ui/file-preview/markdown/editor.d.ts +0 -36
- package/dist/ui/file-preview/markdown/editor.js +0 -643
- package/dist/ui/file-preview/markdown/linking.d.ts +0 -9
- package/dist/ui/file-preview/markdown/linking.js +0 -210
- package/dist/ui/file-preview/markdown/outline.d.ts +0 -7
- package/dist/ui/file-preview/markdown/outline.js +0 -40
- package/dist/ui/file-preview/markdown/preview.d.ts +0 -8
- package/dist/ui/file-preview/markdown/preview.js +0 -33
- package/dist/ui/file-preview/markdown/slugify.d.ts +0 -3
- package/dist/ui/file-preview/markdown/slugify.js +0 -31
- package/dist/ui/file-preview/markdown/toc.d.ts +0 -11
- package/dist/ui/file-preview/markdown/toc.js +0 -75
- package/dist/ui/file-preview/markdown/utils.d.ts +0 -1
- package/dist/ui/file-preview/markdown/utils.js +0 -15
- package/dist/ui/file-preview/markdown/workspace-controller.d.ts +0 -25
- package/dist/ui/file-preview/markdown/workspace-controller.js +0 -40
- package/dist/ui/file-preview/src/components/CodeViewer.d.ts +0 -6
- package/dist/ui/file-preview/src/components/CodeViewer.js +0 -60
- package/dist/ui/file-preview/src/components/HtmlRenderer.d.ts +0 -8
- package/dist/ui/file-preview/src/components/HtmlRenderer.js +0 -45
- package/dist/ui/file-preview/src/components/MarkdownRenderer.d.ts +0 -1
- package/dist/ui/file-preview/src/components/MarkdownRenderer.js +0 -15
- package/dist/ui/file-preview/src/components/Toolbar.d.ts +0 -6
- package/dist/ui/file-preview/src/components/Toolbar.js +0 -75
- package/dist/ui/file-preview/src/components/editor-toolbar.d.ts +0 -15
- package/dist/ui/file-preview/src/components/editor-toolbar.js +0 -384
- package/dist/ui/file-preview/src/components/markdown-editor.d.ts +0 -29
- package/dist/ui/file-preview/src/components/markdown-editor.js +0 -535
- package/dist/ui/file-preview/src/markdown/block-merge.d.ts +0 -25
- package/dist/ui/file-preview/src/markdown/block-merge.js +0 -86
- package/dist/ui/file-preview/src/markdown/link-modal.d.ts +0 -13
- package/dist/ui/file-preview/src/markdown/link-modal.js +0 -213
- package/dist/ui/file-preview/src/markdown/raw-editor.d.ts +0 -8
- package/dist/ui/file-preview/src/markdown/raw-editor.js +0 -61
- package/dist/ui/file-preview/src/markdown/selection-toolbar.d.ts +0 -14
- package/dist/ui/file-preview/src/markdown/selection-toolbar.js +0 -128
- package/dist/ui/file-preview/src/markdown/toc.d.ts +0 -11
- package/dist/ui/file-preview/src/markdown/toc.js +0 -75
- package/dist/ui/file-preview/src/markdown-workspace/editor.d.ts +0 -36
- package/dist/ui/file-preview/src/markdown-workspace/editor.js +0 -643
- package/dist/ui/file-preview/src/markdown-workspace/linking.d.ts +0 -9
- package/dist/ui/file-preview/src/markdown-workspace/linking.js +0 -210
- package/dist/ui/file-preview/src/markdown-workspace/outline.d.ts +0 -7
- package/dist/ui/file-preview/src/markdown-workspace/outline.js +0 -40
- package/dist/ui/file-preview/src/markdown-workspace/preview.d.ts +0 -8
- package/dist/ui/file-preview/src/markdown-workspace/preview.js +0 -33
- package/dist/ui/file-preview/src/markdown-workspace/slugify.d.ts +0 -3
- package/dist/ui/file-preview/src/markdown-workspace/slugify.js +0 -31
- package/dist/ui/file-preview/src/markdown-workspace/toc.d.ts +0 -11
- package/dist/ui/file-preview/src/markdown-workspace/toc.js +0 -75
- package/dist/ui/file-preview/src/markdown-workspace/utils.d.ts +0 -1
- package/dist/ui/file-preview/src/markdown-workspace/utils.js +0 -15
- package/dist/ui/file-preview/src/markdown-workspace/workspace-controller.d.ts +0 -25
- package/dist/ui/file-preview/src/markdown-workspace/workspace-controller.js +0 -40
- package/dist/ui/file-preview/types.d.ts +0 -1
- package/dist/ui/file-preview/types.js +0 -1
- package/dist/ui/server-integration.d.ts +0 -13
- package/dist/ui/server-integration.js +0 -31
- package/dist/ui/shared/ToolHeader.d.ts +0 -9
- package/dist/ui/shared/ToolHeader.js +0 -29
- package/dist/ui/shared/app-bootstrap.d.ts +0 -9
- package/dist/ui/shared/app-bootstrap.js +0 -15
- package/dist/ui/shared/guards.d.ts +0 -1
- package/dist/ui/shared/guards.js +0 -3
- package/dist/ui/shared/host-lifecycle.d.ts +0 -17
- package/dist/ui/shared/host-lifecycle.js +0 -41
- package/dist/ui/shared/rpc-client.d.ts +0 -14
- package/dist/ui/shared/rpc-client.js +0 -72
- package/dist/ui/shared/theme-adaptation.d.ts +0 -10
- package/dist/ui/shared/theme-adaptation.js +0 -118
- package/dist/ui/shared/tool-header.d.ts +0 -9
- package/dist/ui/shared/tool-header.js +0 -25
- package/dist/utils/ui-call-context.d.ts +0 -8
- package/dist/utils/ui-call-context.js +0 -72
- /package/dist/ui/config-editor/{app.d.ts → src/app.d.ts} +0 -0
- /package/dist/ui/config-editor/src/{App.js → app.js} +0 -0
- /package/dist/ui/file-preview/src/{App.d.ts → app.d.ts} +0 -0
- /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 `<`. 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 `<` / `>` / `&` 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 `< $0.01`. We undo the escape only when the
|
|
471
|
+
* surrounding context proves it can't be markup:
|
|
472
|
+
*
|
|
473
|
+
* - `<` followed by space, digit, `$`, end-of-line, or a punctuation
|
|
474
|
+
* character that can't begin an HTML tag name.
|
|
475
|
+
* - `>` likewise; in CommonMark `>` only has block-level meaning at
|
|
476
|
+
* the start of a line (blockquote), and we never produce that here.
|
|
477
|
+
* - `&` 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(/</g, '<')
|
|
524
|
+
.replace(/>/g, '>')
|
|
525
|
+
.replace(/&/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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) => {
|