@helping-ai-workflow/md2doc 3.0.2 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/editor/asset.js +235 -0
- package/lib/editor/client.js +1907 -83
- package/lib/editor/docsource.js +105 -0
- package/lib/editor/paste-md.js +275 -0
- package/lib/editor/patchmap.js +83 -0
- package/lib/editor/server.js +195 -3
- package/lib/editor/toolbar-model.js +160 -0
- package/lib/md2doc.js +165 -4
- package/package.json +3 -2
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* UMD, same shape as convert-md.js / lineops.js: `require`-able in node for
|
|
3
|
+
the unit tests, and injected into the editor page as
|
|
4
|
+
`window.md2docDocSource` (lib/editor/server.js). client.js is inlined
|
|
5
|
+
into the page as a plain <script>, not bundled, so a bare top-level
|
|
6
|
+
require('./docsource.js') there would be an undefined identifier in the
|
|
7
|
+
browser and would kill the whole injected script. */
|
|
8
|
+
(function (root, factory) {
|
|
9
|
+
if (typeof module === 'object' && module.exports) module.exports = factory();
|
|
10
|
+
else root.md2docDocSource = factory();
|
|
11
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
12
|
+
|
|
13
|
+
// Pure state machine + lines[]<->string conversion for the "追加 4 全文原始碼
|
|
14
|
+
// 逃生口" (whole-document source escape hatch): 編輯 ⇄ 原始碼 ⇄ 預覽.
|
|
15
|
+
//
|
|
16
|
+
// EOL representation: `eol` is always a single literal separator string --
|
|
17
|
+
// '\n', '\r\n' or '\r' -- the same convention server.js already establishes
|
|
18
|
+
// at load time (majority-vote across the file, ties go to '\n'; see
|
|
19
|
+
// server.js's `eol` derivation next to its own `mdText.split(/\r\n|\r|\n/)`).
|
|
20
|
+
// This module does not re-derive eol and does not guess per-line; it is
|
|
21
|
+
// handed the file's one canonical eol and uses it for every join.
|
|
22
|
+
//
|
|
23
|
+
// Splitting, though, is always done with the UNIVERSAL three-way regex
|
|
24
|
+
// `/\r\n|\r|\n/`, never with a literal `eol`-only split. Two reasons:
|
|
25
|
+
// 1. Symmetry with how `lines[]` was produced in the first place
|
|
26
|
+
// (server.js:154) -- if load used the universal regex, from-source
|
|
27
|
+
// must use the same one, or a file whose canonical eol is '\r\n' but
|
|
28
|
+
// whose textarea content picked up a stray '\n' (browsers normalize
|
|
29
|
+
// textarea .value to '\n' on some paste paths) would silently lose a
|
|
30
|
+
// line instead of being read back correctly.
|
|
31
|
+
// 2. `\r\n` is listed before the two single-character alternatives, so a
|
|
32
|
+
// CRLF pair is always consumed as ONE separator, never split into a
|
|
33
|
+
// spurious blank line between a lone \r and a lone \n.
|
|
34
|
+
const MODES = ['edit', 'source', 'preview'];
|
|
35
|
+
|
|
36
|
+
// Three-state cycle: edit -> source -> preview -> edit. An unrecognized
|
|
37
|
+
// mode (defensive: a caller passing something stale) falls back to the
|
|
38
|
+
// first state rather than throwing, matching how the rest of this module
|
|
39
|
+
// prefers a safe default over a thrown exception for malformed input.
|
|
40
|
+
function next(mode) {
|
|
41
|
+
const idx = MODES.indexOf(mode);
|
|
42
|
+
return MODES[(idx + 1) % MODES.length];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// lines[] -> the whole document as one string, for seeding the source
|
|
46
|
+
// textarea. `lines` mirrors the app's canonical model (server.js's
|
|
47
|
+
// `mdText.split(/\r\n|\r|\n/)`): a trailing '' entry represents a final
|
|
48
|
+
// trailing newline in the source file, no trailing '' entry represents a
|
|
49
|
+
// file with no trailing newline. Plain join reproduces exactly that.
|
|
50
|
+
function toSource(lines, eol) {
|
|
51
|
+
const arr = Array.isArray(lines) ? lines : [];
|
|
52
|
+
const sep = typeof eol === 'string' && eol !== '' ? eol : '\n';
|
|
53
|
+
return arr.join(sep);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function linesEqual(a, b) {
|
|
57
|
+
if (a.length !== b.length) return false;
|
|
58
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
59
|
+
if (a[i] !== b[i]) return false;
|
|
60
|
+
}
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// string -> {lines, changed}, the inverse of toSource. `lines` is always a
|
|
65
|
+
// faithful parse of `text` (universal-regex split, see the module header),
|
|
66
|
+
// independent of `prevLines` -- what `prevLines` controls is only the
|
|
67
|
+
// `changed` flag's meaning, per the plan-owner ruling below.
|
|
68
|
+
//
|
|
69
|
+
// CONTRACT (plan-owner ruling, fix round 1 -- do not "optimize" this back):
|
|
70
|
+
// `prevLines` is the lines[] the source textarea was seeded from -- the
|
|
71
|
+
// pre-edit state a real caller (client.js) always has on hand when it
|
|
72
|
+
// calls this on the way OUT of source mode. `prevLines` is optional in
|
|
73
|
+
// ARITY ONLY, so the name `fromSource(text, eol)` and every two-arg call
|
|
74
|
+
// site keep parsing correctly; it is NOT optional in the sense of "safe to
|
|
75
|
+
// omit and get a sensible answer for `changed`":
|
|
76
|
+
//
|
|
77
|
+
// - `prevLines` GIVEN (array): `changed` is a real content diff against
|
|
78
|
+
// it (`!linesEqual(lines, prevLines)`). This is the only form that can
|
|
79
|
+
// tell "the user edited a line" apart from "untouched," and it is the
|
|
80
|
+
// form the integration task (client.js) must call.
|
|
81
|
+
// - `prevLines` OMITTED: `changed` is unconditionally `true`.
|
|
82
|
+
//
|
|
83
|
+
// Why `true` and not a "did this round-trip cleanly" fidelity check (the
|
|
84
|
+
// round 1 defect): a pure function of `(text, eol)` alone has nothing to
|
|
85
|
+
// diff an edit against, and a same-eol in-place character edit -- the
|
|
86
|
+
// overwhelming majority of real edits -- round-trips through split+join
|
|
87
|
+
// exactly as cleanly as untouched text does. A fidelity-based `changed`
|
|
88
|
+
// therefore reports `false` (unchanged) for most real edits too, and the
|
|
89
|
+
// caller's contract is "changed: false means do not write the file" --
|
|
90
|
+
// which silently discards the user's edit. That is a data-loss default.
|
|
91
|
+
// `changed: true` is the fail-safe direction: the unknown case reads as
|
|
92
|
+
// "assume it was edited," so the caller writes; the worst case is one
|
|
93
|
+
// needless write of byte-identical content, never a dropped edit.
|
|
94
|
+
function fromSource(text, eol, prevLines) {
|
|
95
|
+
// `eol` is accepted for API symmetry with `toSource(lines, eol)` and is
|
|
96
|
+
// NOT used here: parsing is always the universal three-way split (module
|
|
97
|
+
// header), never a literal-`eol` split, so there is nothing for it to do.
|
|
98
|
+
const s = typeof text === 'string' ? text : String(text == null ? '' : text);
|
|
99
|
+
const lines = s.split(/\r\n|\r|\n/);
|
|
100
|
+
const changed = Array.isArray(prevLines) ? !linesEqual(lines, prevLines) : true;
|
|
101
|
+
return { lines, changed };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { MODES, next, toSource, fromSource };
|
|
105
|
+
});
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* UMD, same shape as lineops.js / indent-clamp.js / convert-md.js:
|
|
3
|
+
`require`-able in node for the unit tests, and injected into the editor
|
|
4
|
+
page as `window.md2docPasteMd` (lib/editor/server.js). client.js is
|
|
5
|
+
inlined into the page as a plain <script>, not bundled, so a bare
|
|
6
|
+
require('./paste-md.js') there would be an undefined identifier in the
|
|
7
|
+
browser. */
|
|
8
|
+
(function (root, factory) {
|
|
9
|
+
if (typeof module === 'object' && module.exports) module.exports = factory();
|
|
10
|
+
else root.md2docPasteMd = factory();
|
|
11
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
12
|
+
|
|
13
|
+
// This module has one external dependency: turndown (HTML -> Markdown).
|
|
14
|
+
// server.js injects every editor module into the page as a plain <script>
|
|
15
|
+
// tag, in a fixed order (lib/editor/server.js) -- turndown's browser UMD
|
|
16
|
+
// build (node_modules/turndown/lib/turndown.browser.umd.js, which defines
|
|
17
|
+
// the global `TurndownService`) is one more such tag. Resolving turndown at
|
|
18
|
+
// FACTORY time -- a top-level `require('turndown')`, or reading
|
|
19
|
+
// `TurndownService` at module-eval time -- would silently coupled this
|
|
20
|
+
// module's *load order* to turndown's <script> tag landing before this
|
|
21
|
+
// one; reorder server.js's injection list and the factory throws before a
|
|
22
|
+
// single test runs, or before the page even paints. Resolving lazily,
|
|
23
|
+
// inside htmlToMarkdown, defers that lookup to the first call, by which
|
|
24
|
+
// point both node's `require` cache (node side) and the browser page's
|
|
25
|
+
// <script> tags (browser side) have already settled. There is no existing
|
|
26
|
+
// precedent for an external-module lookup in this codebase's editor
|
|
27
|
+
// modules (convert-md.js / lineops.js / indent-clamp.js / selection.js all
|
|
28
|
+
// have zero external dependencies), so this is a fresh pattern, not a
|
|
29
|
+
// copy of one.
|
|
30
|
+
function getTurndownService() {
|
|
31
|
+
// Recomputed here (not read from the outer UMD wrapper's `root` param)
|
|
32
|
+
// because that param belongs to the wrapper function, not to this
|
|
33
|
+
// factory's lexical scope -- the factory is a sibling argument at the
|
|
34
|
+
// IIFE call site, so it does not close over it.
|
|
35
|
+
var root = typeof self !== 'undefined' ? self : this;
|
|
36
|
+
return typeof require === 'function' ? require('turndown') : root.TurndownService;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// --- GFM extras turndown's core does not ship ------------------------------
|
|
40
|
+
//
|
|
41
|
+
// turndown's core rule set has no rule for <del>/<s>/<strike> or for
|
|
42
|
+
// <table>. The obvious fix is the `turndown-plugin-gfm` package, but the
|
|
43
|
+
// brief scopes package.json to exactly one new dependency (turndown) --
|
|
44
|
+
// adding a second package is out of scope for this track. These two rules
|
|
45
|
+
// are a deliberately small, from-scratch reimplementation of the same
|
|
46
|
+
// GFM shapes (not a copy of turndown-plugin-gfm's source), added via
|
|
47
|
+
// turndownService.addRule the same way any consumer of turndown extends it.
|
|
48
|
+
|
|
49
|
+
function addStrikethroughRule(service) {
|
|
50
|
+
service.addRule('strikethrough', {
|
|
51
|
+
filter: ['del', 's', 'strike'],
|
|
52
|
+
replacement: function (content) {
|
|
53
|
+
return '~~' + content + '~~';
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isFirstTbody(el) {
|
|
59
|
+
var prev = el.previousSibling;
|
|
60
|
+
return (
|
|
61
|
+
el.nodeName === 'TBODY' &&
|
|
62
|
+
(!prev || (prev.nodeName === 'THEAD' && /^\s*$/.test(prev.textContent)))
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isHeadingRow(tr) {
|
|
67
|
+
var parent = tr.parentNode;
|
|
68
|
+
if (!parent) return false;
|
|
69
|
+
if (parent.nodeName === 'THEAD') return true;
|
|
70
|
+
if (parent.firstChild !== tr) return false;
|
|
71
|
+
if (parent.nodeName !== 'TABLE' && !isFirstTbody(parent)) return false;
|
|
72
|
+
for (var i = 0; i < tr.childNodes.length; i += 1) {
|
|
73
|
+
if (tr.childNodes[i].nodeName !== 'TH') return false;
|
|
74
|
+
}
|
|
75
|
+
return tr.childNodes.length > 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Fix-round-1 (2026-09-03), Critical: a literal `|` in cell content used to
|
|
79
|
+
// pass through unescaped, so e.g. a Windows path or a bitwise-or expression
|
|
80
|
+
// silently added a phantom column -- every column to its right misaligned
|
|
81
|
+
// against the header. Escape any `|` that isn't already an escaped `\|`
|
|
82
|
+
// (the latter can arrive when a nested table's own already-escaped cell
|
|
83
|
+
// content is flattened into its parent cell, see cellMarkdown below).
|
|
84
|
+
function escapePipes(s) {
|
|
85
|
+
return String(s).replace(/(?<!\\)\|/g, '\\|');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Only the ROWS THIS TABLE OWNS -- direct <tr> children, and <tr> children
|
|
89
|
+
// one level down inside a <thead>/<tbody>/<tfoot>. A nested <table> sitting
|
|
90
|
+
// inside one of this table's <td> cells is two levels further down (tr ->
|
|
91
|
+
// td -> table -> tbody -> tr), so it is never reached by this walk and its
|
|
92
|
+
// rows can never be mistaken for this table's own rows.
|
|
93
|
+
function collectRows(table) {
|
|
94
|
+
var rows = [];
|
|
95
|
+
for (var i = 0; i < table.childNodes.length; i += 1) {
|
|
96
|
+
var child = table.childNodes[i];
|
|
97
|
+
if (child.nodeName === 'TR') {
|
|
98
|
+
rows.push(child);
|
|
99
|
+
} else if (child.nodeName === 'THEAD' || child.nodeName === 'TBODY' || child.nodeName === 'TFOOT') {
|
|
100
|
+
for (var j = 0; j < child.childNodes.length; j += 1) {
|
|
101
|
+
if (child.childNodes[j].nodeName === 'TR') rows.push(child.childNodes[j]);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return rows;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function cellColSpan(cellNode) {
|
|
109
|
+
var n = typeof cellNode.colSpan === 'number' ? cellNode.colSpan
|
|
110
|
+
: parseInt(cellNode.getAttribute && cellNode.getAttribute('colspan'), 10);
|
|
111
|
+
return n > 0 ? n : 1;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Converts ONE cell's own content (recursing through `service`, so a
|
|
115
|
+
// nested <table> inside this cell is itself converted by this same table
|
|
116
|
+
// rule -- see the module comment above addTableRules). The result is
|
|
117
|
+
// flattened to one line and pipe-escaped, so a nested table collapses into
|
|
118
|
+
// one escaped, single-line cell instead of leaking a stream of stray pipes
|
|
119
|
+
// into the parent row.
|
|
120
|
+
function cellMarkdown(cellNode, service) {
|
|
121
|
+
var md = service.turndown(cellNode);
|
|
122
|
+
return escapePipes(String(md).trim().replace(/\s*\n+\s*/g, ' '));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Fix-round-1, Important: a colspan="N" cell used to emit as a single
|
|
126
|
+
// cell, leaving the row N-1 columns short of every other row it should
|
|
127
|
+
// align with. Ruling: expand it into N cells (content in the first, empty
|
|
128
|
+
// in the rest) rather than modeling a real merged-cell grid. rowspan is
|
|
129
|
+
// deliberately NOT expanded (no full grid model) -- a row it shortens is
|
|
130
|
+
// simply padded to the table's width by padRow below. Lossy, but every row
|
|
131
|
+
// still lexes as a well-formed GFM row.
|
|
132
|
+
function expandRowCells(row, service) {
|
|
133
|
+
var out = [];
|
|
134
|
+
for (var i = 0; i < row.childNodes.length; i += 1) {
|
|
135
|
+
var cellNode = row.childNodes[i];
|
|
136
|
+
if (cellNode.nodeName !== 'TH' && cellNode.nodeName !== 'TD') continue;
|
|
137
|
+
var span = cellColSpan(cellNode);
|
|
138
|
+
out.push(cellMarkdown(cellNode, service));
|
|
139
|
+
for (var s = 1; s < span; s += 1) out.push('');
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function renderRowLine(cells) {
|
|
145
|
+
return '| ' + cells.join(' | ') + ' |';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function padRow(cells, width) {
|
|
149
|
+
var out = cells.slice();
|
|
150
|
+
while (out.length < width) out.push('');
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Fix-round-1, Important: rebuilds the WHOLE table from the DOM directly
|
|
155
|
+
// (rather than composing per-cell/per-row replacement strings the way the
|
|
156
|
+
// pre-fix version did) so column count can be normalized across every row
|
|
157
|
+
// before any markdown is emitted -- expanding colspan and padding every
|
|
158
|
+
// row, header included, out to the table's widest row (see
|
|
159
|
+
// expandRowCells/padRow above). That decision needs the full row set in
|
|
160
|
+
// hand; it cannot be made one row/cell at a time.
|
|
161
|
+
function renderTable(table, service) {
|
|
162
|
+
var rows = collectRows(table);
|
|
163
|
+
if (rows.length === 0) return '';
|
|
164
|
+
|
|
165
|
+
var expanded = rows.map(function (row) {
|
|
166
|
+
return { row: row, cells: expandRowCells(row, service) };
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
var width = 0;
|
|
170
|
+
expanded.forEach(function (r) { if (r.cells.length > width) width = r.cells.length; });
|
|
171
|
+
if (width === 0) return '';
|
|
172
|
+
|
|
173
|
+
var headerIdx = -1;
|
|
174
|
+
for (var i = 0; i < expanded.length; i += 1) {
|
|
175
|
+
if (isHeadingRow(expanded[i].row)) { headerIdx = i; break; }
|
|
176
|
+
}
|
|
177
|
+
// No <thead>/<th> anywhere: promote the first row to the header slot so
|
|
178
|
+
// the table still lexes as GFM, same as the pre-fix behavior.
|
|
179
|
+
if (headerIdx === -1) headerIdx = 0;
|
|
180
|
+
|
|
181
|
+
var lines = [];
|
|
182
|
+
lines.push(renderRowLine(padRow(expanded[headerIdx].cells, width)));
|
|
183
|
+
lines.push(renderRowLine(new Array(width).fill('---')));
|
|
184
|
+
expanded.forEach(function (r, idx) {
|
|
185
|
+
if (idx === headerIdx) return;
|
|
186
|
+
lines.push(renderRowLine(padRow(r.cells, width)));
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
return '\n\n' + lines.join('\n') + '\n\n';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function addTableRules(service) {
|
|
193
|
+
service.addRule('table', {
|
|
194
|
+
filter: function (node) {
|
|
195
|
+
return node.nodeName === 'TABLE';
|
|
196
|
+
},
|
|
197
|
+
replacement: function (content, node) {
|
|
198
|
+
return renderTable(node, service);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function htmlToMarkdown(html) {
|
|
204
|
+
var TurndownService = getTurndownService();
|
|
205
|
+
var service = new TurndownService({
|
|
206
|
+
headingStyle: 'atx',
|
|
207
|
+
bulletListMarker: '-',
|
|
208
|
+
codeBlockStyle: 'fenced',
|
|
209
|
+
emDelimiter: '*'
|
|
210
|
+
});
|
|
211
|
+
addStrikethroughRule(service);
|
|
212
|
+
addTableRules(service);
|
|
213
|
+
return service.turndown(html == null ? '' : String(html));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// --- clipboard payload selection --------------------------------------------
|
|
217
|
+
//
|
|
218
|
+
// Pure decision logic, no DOM/ClipboardEvent access here -- the caller
|
|
219
|
+
// (client.js, a later track) reads the real `ClipboardEvent#clipboardData`
|
|
220
|
+
// / `navigator.clipboard.read()` result into this plain
|
|
221
|
+
// { 'mime/type': string | Blob } shape and hands it in.
|
|
222
|
+
//
|
|
223
|
+
// Fix-round-1 (2026-09-03), ruling on the priority order: `plainOnly`
|
|
224
|
+
// (Ctrl+Shift+V, an explicit "give me text" gesture) wins over an image
|
|
225
|
+
// whenever the clipboard carries ANY text flavour (`text/plain` OR
|
|
226
|
+
// `text/html`) -- honour the gesture whenever there is text to honour it
|
|
227
|
+
// with. Fall through to the image only when there is no text flavour at
|
|
228
|
+
// all, since there is then no text to honour the gesture with.
|
|
229
|
+
//
|
|
230
|
+
// Fix-round-2 (2026-09-03), ruling extended to the default (non-plainOnly)
|
|
231
|
+
// path: `text/html` now beats an image when both are present. Round 1 left
|
|
232
|
+
// an unconditional image-first rule there. But a `text/html` clipboard is a
|
|
233
|
+
// rich-text copy (Word / a browser / Confluence), and those sources
|
|
234
|
+
// routinely put a bitmap of the SAME content alongside the HTML -- with
|
|
235
|
+
// image-first, the headline case this track exists for (paste from Word,
|
|
236
|
+
// get markdown) instead landed a screenshot of the Word content, which is
|
|
237
|
+
// worse than the plain-text behavior it was meant to replace. `text/html`
|
|
238
|
+
// -> `<img src="...">` converts losslessly to ``, keeping a remote
|
|
239
|
+
// URL instead of re-uploading a copy of it. A screenshot from a snipping
|
|
240
|
+
// tool carries no `text/html` at all, so it is unaffected and still takes
|
|
241
|
+
// the image branch (reaching the asset endpoint, a later track's concern,
|
|
242
|
+
// not this module's).
|
|
243
|
+
function pickPayload(clipboardItems, plainOnly) {
|
|
244
|
+
var items = clipboardItems || {};
|
|
245
|
+
|
|
246
|
+
var imageKey = null;
|
|
247
|
+
for (var k in items) {
|
|
248
|
+
if (Object.prototype.hasOwnProperty.call(items, k) &&
|
|
249
|
+
k.indexOf('image/') === 0 && items[k] != null) {
|
|
250
|
+
imageKey = k;
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
var html = items['text/html'];
|
|
256
|
+
var text = items['text/plain'];
|
|
257
|
+
var hasText = (typeof text === 'string' && text !== '') || (typeof html === 'string' && html !== '');
|
|
258
|
+
|
|
259
|
+
if (plainOnly) {
|
|
260
|
+
if (hasText) return { kind: 'text', value: typeof text === 'string' ? text : '' };
|
|
261
|
+
if (imageKey) return { kind: 'image', blob: items[imageKey] };
|
|
262
|
+
return { kind: 'text', value: '' };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (typeof html === 'string' && html !== '') {
|
|
266
|
+
return { kind: 'markdown', value: htmlToMarkdown(html) };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (imageKey) return { kind: 'image', blob: items[imageKey] };
|
|
270
|
+
|
|
271
|
+
return { kind: 'text', value: typeof text === 'string' ? text : '' };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return { htmlToMarkdown: htmlToMarkdown, pickPayload: pickPayload };
|
|
275
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* UMD, same shape as convert-md.js / lineops.js: require-able in node for the
|
|
3
|
+
unit tests, and injected into the editor page as `window.md2docPatchmap`
|
|
4
|
+
(lib/editor/server.js). client.js is inlined as a plain <script>, not
|
|
5
|
+
bundled, so a bare require() here would be undefined in the browser. */
|
|
6
|
+
(function (root, factory) {
|
|
7
|
+
if (typeof module === 'object' && module.exports) module.exports = factory();
|
|
8
|
+
else root.md2docPatchmap = factory();
|
|
9
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
10
|
+
|
|
11
|
+
// The ONLY normalisation applied before comparing two part strings.
|
|
12
|
+
//
|
|
13
|
+
// `data-block-id` is a POSITIONAL ordinal — blockmap.js:170 starts a shared
|
|
14
|
+
// { v: 0 } box and :141/:179 do `id: nextId.v++`, so `blocks[i].id === i` is
|
|
15
|
+
// an identity. Any edit that changes the BLOCK COUNT therefore rewrites the
|
|
16
|
+
// id of every part after the edit point, and without this strip the suffix
|
|
17
|
+
// never matches and the widen swallows the document to its end (measured:
|
|
18
|
+
// keepSuffix 0 vs 14 on the same edit).
|
|
19
|
+
//
|
|
20
|
+
// It is the ONLY field that may be normalised away: no CSS rule anywhere
|
|
21
|
+
// keys on it (grep data-block-id lib/md2doc.js hits only the two emit sites,
|
|
22
|
+
// :303 and :911), so its value changing does not change how the node paints.
|
|
23
|
+
// The other renderer-owned positional attributes — data-list-start,
|
|
24
|
+
// data-run-start, data-indent, data-list-type — DO change the rendering when
|
|
25
|
+
// their value changes, so a mismatch there must widen. Do not extend this.
|
|
26
|
+
const BLOCK_ID_RE = / data-block-id="\d+"/g;
|
|
27
|
+
function stripBlockId(partStr) {
|
|
28
|
+
return String(partStr).replace(BLOCK_ID_RE, '');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function patchmap(input) {
|
|
32
|
+
const oldBlocks = input && input.oldBlocks;
|
|
33
|
+
const newBlocks = input && input.newBlocks;
|
|
34
|
+
const oldParts = input && input.oldParts;
|
|
35
|
+
const newParts = input && input.newParts;
|
|
36
|
+
|
|
37
|
+
// Structural guards. `oldParts` is absent on the first render of a session
|
|
38
|
+
// (the bootstrap payload carries no parts — see the plan's Task C), which
|
|
39
|
+
// is a legitimate fallback, not an error.
|
|
40
|
+
if (!Array.isArray(oldParts) || !Array.isArray(newParts)) return null;
|
|
41
|
+
if (!Array.isArray(oldBlocks) || !Array.isArray(newBlocks)) return null;
|
|
42
|
+
if (oldParts.length !== oldBlocks.length) return null;
|
|
43
|
+
if (newParts.length !== newBlocks.length) return null;
|
|
44
|
+
|
|
45
|
+
const oldN = oldParts.length;
|
|
46
|
+
const newN = newParts.length;
|
|
47
|
+
const o = oldParts.map(stripBlockId);
|
|
48
|
+
const n = newParts.map(stripBlockId);
|
|
49
|
+
|
|
50
|
+
let keepPrefix = 0;
|
|
51
|
+
while (keepPrefix < oldN && keepPrefix < newN && o[keepPrefix] === n[keepPrefix]) keepPrefix++;
|
|
52
|
+
|
|
53
|
+
let keepSuffix = 0;
|
|
54
|
+
while (
|
|
55
|
+
keepSuffix < oldN - keepPrefix &&
|
|
56
|
+
keepSuffix < newN - keepPrefix &&
|
|
57
|
+
o[oldN - 1 - keepSuffix] === n[newN - 1 - keepSuffix]
|
|
58
|
+
) keepSuffix++;
|
|
59
|
+
|
|
60
|
+
// The middle is a single contiguous covering interval by construction:
|
|
61
|
+
// prefix and suffix are a true prefix and a true suffix, so everything
|
|
62
|
+
// between them is replaced even if some of it happened to be unchanged.
|
|
63
|
+
// An insertion yields oldEnd === oldStart - 1 (a zero-width span).
|
|
64
|
+
const oldStart = keepPrefix;
|
|
65
|
+
const oldEnd = oldN - keepSuffix - 1;
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
keepPrefix: keepPrefix,
|
|
69
|
+
keepSuffix: keepSuffix,
|
|
70
|
+
replaceSpan: {
|
|
71
|
+
oldStart: oldStart,
|
|
72
|
+
oldEnd: oldEnd,
|
|
73
|
+
newParts: newParts.slice(keepPrefix, newN - keepSuffix),
|
|
74
|
+
},
|
|
75
|
+
// Applied by the caller to every KEPT SUFFIX node, rewriting its
|
|
76
|
+
// data-block-id to oldId + idDelta. The prefix never needs it: id is the
|
|
77
|
+
// index, and the prefix occupies 0..k-1 in both arrays.
|
|
78
|
+
idDelta: newN - oldN,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { stripBlockId: stripBlockId, patchmap: patchmap };
|
|
83
|
+
});
|