@helping-ai-workflow/md2doc 3.0.1 → 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 +2017 -85
- 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/table-md.js +66 -34
- package/lib/editor/toolbar-model.js +160 -0
- package/lib/md2doc.js +176 -4
- package/package.json +3 -2
package/lib/editor/server.js
CHANGED
|
@@ -23,12 +23,50 @@ const INDENT_CLAMP_SRC = fs.readFileSync(path.join(__dirname, 'indent-clamp.js')
|
|
|
23
23
|
// indent-clamp.js above — it only has to land before client.js reads
|
|
24
24
|
// window.md2docConvertMd.
|
|
25
25
|
const CONVERT_MD_SRC = fs.readFileSync(path.join(__dirname, 'convert-md.js'), 'utf8');
|
|
26
|
+
// v3.2.0: the pure string-diff module behind incremental DOM patching —
|
|
27
|
+
// stripBlockId()/patchmap() compare oldParts[i] against newParts[j] with only
|
|
28
|
+
// data-block-id normalized (see the global constraint on that). Same "no
|
|
29
|
+
// dependency on any other editor module" property as indent-clamp.js and
|
|
30
|
+
// convert-md.js above; it only has to land before client.js reads
|
|
31
|
+
// window.md2docPatchmap.
|
|
32
|
+
const PATCHMAP_SRC = fs.readFileSync(path.join(__dirname, 'patchmap.js'), 'utf8');
|
|
26
33
|
// S3 spec §3.3/§3.6/§4.4: the pure block multi-select model — line-range
|
|
27
34
|
// normalization, membership, Shift+arrow stepping, the §3.3 grip rule and the
|
|
28
35
|
// post-operation collapse. Same "no dependency on any other editor module"
|
|
29
36
|
// property as indent-clamp.js and convert-md.js above; it only has to land
|
|
30
37
|
// before client.js reads window.md2docSelection.
|
|
31
38
|
const SELECTION_SRC = fs.readFileSync(path.join(__dirname, 'selection.js'), 'utf8');
|
|
39
|
+
// v3.1.0 Task E: the four Phase-1 modules behind the visible toolbar, the
|
|
40
|
+
// paste pipeline, the image-drop asset endpoint and the whole-document
|
|
41
|
+
// source escape hatch. Same "pure module, no dependency on any other editor
|
|
42
|
+
// module" property as indent-clamp.js / convert-md.js / selection.js above —
|
|
43
|
+
// each only has to land before client.js reads its window global.
|
|
44
|
+
const TOOLBAR_MODEL_SRC = fs.readFileSync(path.join(__dirname, 'toolbar-model.js'), 'utf8');
|
|
45
|
+
const DOCSOURCE_SRC = fs.readFileSync(path.join(__dirname, 'docsource.js'), 'utf8');
|
|
46
|
+
const ASSET_SRC = fs.readFileSync(path.join(__dirname, 'asset.js'), 'utf8');
|
|
47
|
+
// turndown's BROWSER build. node's `require('turndown')` resolves to
|
|
48
|
+
// lib/turndown.cjs.js, which is not loadable as a plain <script> tag — the
|
|
49
|
+
// UMD build is, and it defines the global `TurndownService` that
|
|
50
|
+
// paste-md.js's getTurndownService() reads at CALL time (not factory time,
|
|
51
|
+
// so this tag's position relative to paste-md.js's is not load-bearing;
|
|
52
|
+
// it is injected first anyway, to keep the dependency reading left-to-right).
|
|
53
|
+
const TURNDOWN_SRC = fs.readFileSync(
|
|
54
|
+
require.resolve('turndown/lib/turndown.browser.umd.js'), 'utf8');
|
|
55
|
+
const PASTE_MD_SRC = fs.readFileSync(path.join(__dirname, 'paste-md.js'), 'utf8');
|
|
56
|
+
|
|
57
|
+
// The SAME pure module the page gets, required here for the node side of
|
|
58
|
+
// /api/asset. asset.js is deliberately free of `require('fs')`/`require('path')`
|
|
59
|
+
// (it is plain string arithmetic), so every filesystem decision below is made
|
|
60
|
+
// here — including the '/'-normalization isWithin's contract demands.
|
|
61
|
+
const assetLib = require('./asset.js');
|
|
62
|
+
|
|
63
|
+
// asset.js's isWithin() is POSIX-only BY CONTRACT (see its own comment): it
|
|
64
|
+
// does not understand '\\' as a separator and fails CLOSED on a native-Windows
|
|
65
|
+
// path, refusing every legitimate write. Normalizing at this boundary is the
|
|
66
|
+
// caller's job, and this is that boundary.
|
|
67
|
+
function toPosixPath(p) {
|
|
68
|
+
return String(p === null || p === undefined ? '' : p).split('\\').join('/');
|
|
69
|
+
}
|
|
32
70
|
|
|
33
71
|
function readJson(req, limitBytes = 50 * 1024 * 1024) {
|
|
34
72
|
return new Promise((resolve, reject) => {
|
|
@@ -51,6 +89,37 @@ function readJson(req, limitBytes = 50 * 1024 * 1024) {
|
|
|
51
89
|
});
|
|
52
90
|
}
|
|
53
91
|
|
|
92
|
+
// DNS-rebinding defense. The content-type gate below stops a cross-site
|
|
93
|
+
// "simple" POST, but it cannot stop a request the browser believes is
|
|
94
|
+
// SAME-origin: a page on evil.com served with a 1-second-TTL A record can
|
|
95
|
+
// rebind that name to 127.0.0.1 and then POST to http://evil.com:PORT/... with
|
|
96
|
+
// a real application/json body. No preflight fires, the content-type check is
|
|
97
|
+
// satisfied, and the attacker is inside.
|
|
98
|
+
//
|
|
99
|
+
// What made that reachable is that a route needs no unguessable precondition.
|
|
100
|
+
// /api/save happens to have one — `baseMtimeMs` must equal the file's actual
|
|
101
|
+
// mtimeMs, a float an attacker cannot guess — but /api/asset needs only a
|
|
102
|
+
// `fileId` inside [0, absFiles.length), which is guessable in one try, and it
|
|
103
|
+
// WRITES FILES. So the check belongs at the front door rather than on the one
|
|
104
|
+
// route that happened to lack a second lock: the Host header is attacker-
|
|
105
|
+
// controlled in name only, since a rebinding attack must send the name it
|
|
106
|
+
// rebound, and that name is never a loopback literal.
|
|
107
|
+
//
|
|
108
|
+
// A missing Host is rejected too (HTTP/1.0 clients): this server is only ever
|
|
109
|
+
// addressed by lib/editor/open.js and a browser, both of which send one.
|
|
110
|
+
function hostIsLoopback(hostHeader, port) {
|
|
111
|
+
if (typeof hostHeader !== 'string' || hostHeader === '') return false;
|
|
112
|
+
const h = hostHeader.toLowerCase();
|
|
113
|
+
const names = ['127.0.0.1', 'localhost', '[::1]'];
|
|
114
|
+
for (const n of names) {
|
|
115
|
+
if (h === n + ':' + port) return true;
|
|
116
|
+
// A bare Host (no ':port') means the scheme's default port. Only accept it
|
|
117
|
+
// when that is genuinely the port we are listening on.
|
|
118
|
+
if (port === 80 && h === n) return true;
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
54
123
|
function send(res, status, obj) {
|
|
55
124
|
const body = JSON.stringify(obj);
|
|
56
125
|
res.writeHead(status, { 'content-type': 'application/json' });
|
|
@@ -91,6 +160,16 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
|
|
|
91
160
|
const server = http.createServer(async (req, res) => {
|
|
92
161
|
try {
|
|
93
162
|
const url = new URL(req.url, 'http://127.0.0.1');
|
|
163
|
+
|
|
164
|
+
// FIRST, ahead of every route including GET /edit/:id — see
|
|
165
|
+
// hostIsLoopback() above for the attack this closes. Placed here rather
|
|
166
|
+
// than on /api/asset alone: that route is merely where it was reachable,
|
|
167
|
+
// not where the hole is.
|
|
168
|
+
const addr = server.address();
|
|
169
|
+
if (!hostIsLoopback(req.headers.host, addr && addr.port)) {
|
|
170
|
+
return send(res, 403, { error: 'forbidden host' });
|
|
171
|
+
}
|
|
172
|
+
|
|
94
173
|
const editMatch = url.pathname.match(/^\/edit\/(\d+)$/);
|
|
95
174
|
|
|
96
175
|
// Cross-origin defense for every state-changing POST route: a
|
|
@@ -101,7 +180,13 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
|
|
|
101
180
|
// browser would otherwise let the request fire-and-forget cross-origin.
|
|
102
181
|
// /api/ping is included; lib/editor/client.js's fetch calls already
|
|
103
182
|
// send this header for render, save, and ping.
|
|
104
|
-
|
|
183
|
+
// v3.1.0: '/api/asset' is in this set for the same reason the other
|
|
184
|
+
// three are, and more urgently — it is the only route that CREATES a
|
|
185
|
+
// file. A route left out of this set has NO cross-origin defense at
|
|
186
|
+
// all: any page the user happens to be browsing while `md2doc --edit`
|
|
187
|
+
// is running could fire a cross-site "simple" POST at every candidate
|
|
188
|
+
// localhost port and write to disk.
|
|
189
|
+
const STATE_CHANGING_POST_PATHS = new Set(['/api/render', '/api/save', '/api/ping', '/api/asset']);
|
|
105
190
|
if (req.method === 'POST' && STATE_CHANGING_POST_PATHS.has(url.pathname)) {
|
|
106
191
|
const contentType = String(req.headers['content-type'] || '');
|
|
107
192
|
if (!/^application\/json\b/i.test(contentType)) {
|
|
@@ -165,7 +250,13 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
|
|
|
165
250
|
`<script>${HISTORY_SRC}</script>\n` +
|
|
166
251
|
`<script>${INDENT_CLAMP_SRC}</script>\n` +
|
|
167
252
|
`<script>${CONVERT_MD_SRC}</script>\n` +
|
|
253
|
+
`<script>${PATCHMAP_SRC}</script>\n` +
|
|
168
254
|
`<script>${SELECTION_SRC}</script>\n` +
|
|
255
|
+
`<script>${TOOLBAR_MODEL_SRC}</script>\n` +
|
|
256
|
+
`<script>${DOCSOURCE_SRC}</script>\n` +
|
|
257
|
+
`<script>${ASSET_SRC}</script>\n` +
|
|
258
|
+
`<script>${TURNDOWN_SRC}</script>\n` +
|
|
259
|
+
`<script>${PASTE_MD_SRC}</script>\n` +
|
|
169
260
|
`<script>${clientJs}</script>\n`;
|
|
170
261
|
// Splice at the LAST "</body>" — the document's real closing tag.
|
|
171
262
|
// The first occurrence can sit inside an inlined diagram bundle's JS
|
|
@@ -192,9 +283,9 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
|
|
|
192
283
|
const { fileId, content } = await readJson(req);
|
|
193
284
|
const file = absFiles[fileId];
|
|
194
285
|
if (!file) return send(res, 404, { error: 'unknown file' });
|
|
195
|
-
const {
|
|
286
|
+
const { parts, blocks } = await renderMarkdown(content, file, { editMode: true });
|
|
196
287
|
bumpIdle(server);
|
|
197
|
-
return send(res, 200, {
|
|
288
|
+
return send(res, 200, { parts, blocks });
|
|
198
289
|
}
|
|
199
290
|
|
|
200
291
|
if (req.method === 'POST' && url.pathname === '/api/save') {
|
|
@@ -218,6 +309,107 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
|
|
|
218
309
|
return send(res, 200, { mtimeMs: fs.statSync(file).mtimeMs });
|
|
219
310
|
}
|
|
220
311
|
|
|
312
|
+
// v3.1.0 追加 3: the image-drop / image-paste asset endpoint. Writes ONE
|
|
313
|
+
// raster image next to the document under `assets/` and answers with the
|
|
314
|
+
// relative markdown path the client then inserts as source.
|
|
315
|
+
//
|
|
316
|
+
// Six guards, none of them optional:
|
|
317
|
+
// 0. '/api/asset' is in STATE_CHANGING_POST_PATHS above (the CSRF
|
|
318
|
+
// content-type gate). Without it this is a world-writable file
|
|
319
|
+
// drop for any page in the user's browser.
|
|
320
|
+
// 1. fileId is type-checked. `/api/save`'s `!file` guard alone lets
|
|
321
|
+
// `fileId: "length"` through (absFiles.length is a number, so the
|
|
322
|
+
// lookup succeeds); Number.isInteger + a range check does not.
|
|
323
|
+
// 2. An explicit 8 MB readJson limit, not the 50 MB default.
|
|
324
|
+
// 3. The extension comes ONLY from the MIME whitelist (extFor); a
|
|
325
|
+
// caller-supplied 'x.html' can never become the name on disk.
|
|
326
|
+
// 4. assets/ is created here — nothing else in this server creates
|
|
327
|
+
// directories, so the first drop would otherwise be a guaranteed
|
|
328
|
+
// ENOENT 500.
|
|
329
|
+
// 5. The string-level isWithin() check is followed by a realpath
|
|
330
|
+
// re-check (a literal comparison cannot see a symlinked assets/,
|
|
331
|
+
// and writeFileSync FOLLOWS symlinks), and the file itself is
|
|
332
|
+
// opened 'wx' — which refuses to follow an existing symlink AND
|
|
333
|
+
// closes uniqueName()'s check-then-write TOCTOU window.
|
|
334
|
+
if (req.method === 'POST' && url.pathname === '/api/asset') {
|
|
335
|
+
const body = await readJson(req, 8 * 1024 * 1024);
|
|
336
|
+
const fileId = body ? body.fileId : undefined;
|
|
337
|
+
if (!Number.isInteger(fileId) || fileId < 0 || fileId >= absFiles.length) {
|
|
338
|
+
return send(res, 400, { error: 'fileId must be an index into the open file list' });
|
|
339
|
+
}
|
|
340
|
+
const file = absFiles[fileId];
|
|
341
|
+
const ext = assetLib.extFor(body.mime);
|
|
342
|
+
if (!ext) return send(res, 400, { error: 'unsupported image type' });
|
|
343
|
+
const b64 = typeof body.data === 'string' ? body.data : '';
|
|
344
|
+
const buf = Buffer.from(b64, 'base64');
|
|
345
|
+
if (buf.length === 0) return send(res, 400, { error: 'empty image payload' });
|
|
346
|
+
|
|
347
|
+
const baseDir = path.dirname(file);
|
|
348
|
+
const assetsDir = path.join(baseDir, 'assets');
|
|
349
|
+
fs.mkdirSync(assetsDir, { recursive: true });
|
|
350
|
+
let existing;
|
|
351
|
+
try { existing = fs.readdirSync(assetsDir); } catch (e) { existing = []; }
|
|
352
|
+
|
|
353
|
+
// Ruling 10: the candidate is composed as a FULL filename before
|
|
354
|
+
// uniqueName sees it. `existing` is a raw readdir listing whose
|
|
355
|
+
// entries carry extensions, so a bare stem could never match one —
|
|
356
|
+
// a second same-named image would sail past collision detection and
|
|
357
|
+
// then hit the 'wx' flag as a hard EEXIST instead of becoming -2.
|
|
358
|
+
//
|
|
359
|
+
// The comparison is CASE-FOLDED, because the write below is not.
|
|
360
|
+
// macOS and Windows are both supported targets (it is why
|
|
361
|
+
// sanitizeName defends '\\' and ':'), and their filesystems are
|
|
362
|
+
// case-insensitive: `existing.indexOf('Shot.png')` misses an on-disk
|
|
363
|
+
// `shot.png`, so no '-2' is generated, open(..., 'wx') returns EEXIST
|
|
364
|
+
// and the user gets a generic 「圖片上傳失敗」 that repeats identically
|
|
365
|
+
// on every retry. asset.js is reviewed and unchanged — the numbering
|
|
366
|
+
// is still entirely its own; only the SPACE the question is asked in
|
|
367
|
+
// moves. The user's original stem casing is then restored, so a
|
|
368
|
+
// dropped `Shot.png` is still written as `Shot.png`.
|
|
369
|
+
const stem = assetLib.sanitizeName(body.name);
|
|
370
|
+
const desiredLower = (stem + ext).toLowerCase();
|
|
371
|
+
const pickedLower = assetLib.uniqueName(
|
|
372
|
+
desiredLower, existing.map((e) => String(e).toLowerCase()));
|
|
373
|
+
let name;
|
|
374
|
+
if (pickedLower === desiredLower) {
|
|
375
|
+
name = stem + ext;
|
|
376
|
+
} else {
|
|
377
|
+
// uniqueName only ever inserts '-<n>' immediately before the
|
|
378
|
+
// extension, so that suffix is what is lifted back across. Matched
|
|
379
|
+
// rather than sliced by length: toLowerCase() is not guaranteed to
|
|
380
|
+
// preserve length for every code point.
|
|
381
|
+
const m = /(-\d+)$/.exec(pickedLower.slice(0, pickedLower.length - ext.length));
|
|
382
|
+
name = stem + (m ? m[1] : '') + ext;
|
|
383
|
+
}
|
|
384
|
+
const target = path.join(assetsDir, name);
|
|
385
|
+
if (!assetLib.isWithin(toPosixPath(assetsDir), toPosixPath(target))) {
|
|
386
|
+
return send(res, 400, { error: 'refused: asset path escapes the assets directory' });
|
|
387
|
+
}
|
|
388
|
+
// Second boundary, against the filesystem rather than the string: a
|
|
389
|
+
// pre-existing `assets` SYMLINK pointing anywhere at all passes the
|
|
390
|
+
// check above unchanged, because that check only ever saw text.
|
|
391
|
+
let realBase, realAssets;
|
|
392
|
+
try {
|
|
393
|
+
realBase = fs.realpathSync(baseDir);
|
|
394
|
+
realAssets = fs.realpathSync(assetsDir);
|
|
395
|
+
} catch (e) {
|
|
396
|
+
return send(res, 500, { error: 'cannot resolve the assets directory' });
|
|
397
|
+
}
|
|
398
|
+
if (!assetLib.isWithin(toPosixPath(realBase), toPosixPath(realAssets))) {
|
|
399
|
+
return send(res, 400, { error: 'refused: assets directory escapes the document directory' });
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
fs.writeFileSync(path.join(realAssets, name), buf, { flag: 'wx' });
|
|
403
|
+
} catch (e) {
|
|
404
|
+
if (e && e.code === 'EEXIST') {
|
|
405
|
+
return send(res, 409, { error: 'asset already exists: ' + name });
|
|
406
|
+
}
|
|
407
|
+
throw e;
|
|
408
|
+
}
|
|
409
|
+
bumpIdle(server);
|
|
410
|
+
return send(res, 200, { name, path: assetLib.relPath(name) });
|
|
411
|
+
}
|
|
412
|
+
|
|
221
413
|
if (req.method === 'POST' && url.pathname === '/api/ping') {
|
|
222
414
|
bumpIdle(server);
|
|
223
415
|
res.writeHead(204);
|
package/lib/editor/table-md.js
CHANGED
|
@@ -365,19 +365,18 @@
|
|
|
365
365
|
// from the last position to the first and requires ALL THREE widths
|
|
366
366
|
// dropped even though "Name" and "Note" still sit at index-shifted but
|
|
367
367
|
// TEXT-unchanged positions; only text-matching, combined with the
|
|
368
|
-
// all-or-nothing gate below, gets that right).
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
// pins this so the next change to it is deliberate.
|
|
368
|
+
// all-or-nothing gate below, gets that right).
|
|
369
|
+
//
|
|
370
|
+
// v3.0.2 superseded the 2026-09-02 review round-1 ruling that a renamed
|
|
371
|
+
// header or a deleted column must discard the whole table's width memory:
|
|
372
|
+
// the forward scan below skips an original column that no longer appears,
|
|
373
|
+
// and only a genuine column REORDER still takes the whole-table refusal.
|
|
374
|
+
// What is still NOT handled — deliberately — is a table with DUPLICATE
|
|
375
|
+
// header names: the scan pairs by TEXT, so after a delete a survivor can
|
|
376
|
+
// inherit the same-named other column's width. Telling the instances
|
|
377
|
+
// apart needs identity matching, which is a larger change than this
|
|
378
|
+
// patch release carries. See test/table-width.test.js cases 14, 15, 20,
|
|
379
|
+
// 21, 23 and 24, which pin every branch of the above.
|
|
381
380
|
const original = columnWidthsOf(originalMd);
|
|
382
381
|
let widths = null;
|
|
383
382
|
// Set for a column that falls off the END of the original header list
|
|
@@ -415,31 +414,51 @@
|
|
|
415
414
|
const matchedSepFields = new Array(headerContents.length);
|
|
416
415
|
if (original) {
|
|
417
416
|
const matched = new Array(headerContents.length);
|
|
417
|
+
// v3.0.2: a FORWARD SCAN, not the pre-v3.0.2 lockstep compare. The
|
|
418
|
+
// lockstep version left `j` parked on the first original column that
|
|
419
|
+
// did not match, so it could never recover from one mismatch — and
|
|
420
|
+
// "some original column was never consumed" is the signature of a
|
|
421
|
+
// DELETE and a RENAME just as much as of the column REORDER the gate
|
|
422
|
+
// below actually exists to refuse. Scanning forward lets a delete or
|
|
423
|
+
// a rename skip the original it no longer has, and moves the refusal
|
|
424
|
+
// into an explicit test (`moved`) instead of a side effect of `j`.
|
|
425
|
+
const unmatchedNames = [];
|
|
418
426
|
let j = 0;
|
|
419
427
|
for (let i = 0; i < headerContents.length; i++) {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
428
|
+
const name = headerContents[i].trim();
|
|
429
|
+
let k = -1;
|
|
430
|
+
for (let q = j; q < original.headerFields.length; q++) {
|
|
431
|
+
if (original.headerFields[q].trim() === name) { k = q; break; }
|
|
432
|
+
}
|
|
433
|
+
if (k >= 0) {
|
|
434
|
+
matched[i] = original.widths[k];
|
|
435
|
+
matchedSepFields[i] = original.sepFields[k];
|
|
436
|
+
j = k + 1;
|
|
425
437
|
} else {
|
|
426
438
|
matched[i] = undefined;
|
|
439
|
+
// The EMPTY name is exempt from the reorder test below, and the
|
|
440
|
+
// exemption is load-bearing, not defensive: insertColumn()
|
|
441
|
+
// (lib/editor/client.js:6448-6465) creates a th (or td) with no
|
|
442
|
+
// content at all — the element itself at line 6451 — so every
|
|
443
|
+
// inserted column is named ''. A table that
|
|
444
|
+
// already holds one blank header would therefore see the new
|
|
445
|
+
// column's '' "found elsewhere in the original" and take the
|
|
446
|
+
// whole-table refusal — a straight regression against v3.0.1 on
|
|
447
|
+
// the ordinary insert-a-column-twice flow.
|
|
448
|
+
if (name !== '') unmatchedNames.push(name);
|
|
427
449
|
if (j >= original.headerFields.length) sepFallbackNatural[i] = true;
|
|
428
450
|
}
|
|
429
451
|
}
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
//
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
// happens to still have the same text. So a partial match discards
|
|
441
|
-
// the width source for the WHOLE table, same as no original at all.
|
|
442
|
-
if (j === original.headerFields.length) {
|
|
452
|
+
// A column that could not find itself in what remains of the original,
|
|
453
|
+
// yet whose name IS somewhere in the original, has MOVED relative to
|
|
454
|
+
// the others. That is the one shape a per-column width guess gets
|
|
455
|
+
// silently wrong ("a column move must carry its alignment with it"),
|
|
456
|
+
// so it still discards the width source for the WHOLE table. A name
|
|
457
|
+
// that is absent from the original entirely is an insert or a rename:
|
|
458
|
+
// nothing stale to carry, so its neighbours keep their widths.
|
|
459
|
+
const moved = unmatchedNames.some((n) =>
|
|
460
|
+
original.headerFields.some((f) => f.trim() === n));
|
|
461
|
+
if (!moved) {
|
|
443
462
|
widths = matched;
|
|
444
463
|
// A matched column is then widened, for every row of that column
|
|
445
464
|
// at once (header + separator + every body row), to whatever the
|
|
@@ -485,9 +504,22 @@
|
|
|
485
504
|
// (`widths` itself is null: cases 1 and 7 stay byte-identical to
|
|
486
505
|
// the pre-v3.0.1 minimal form), or a mismatch that is neither a
|
|
487
506
|
// matched-but-non-uniform column nor a trailing new one (a
|
|
488
|
-
// renamed header, or a row-drag promotion)
|
|
489
|
-
//
|
|
490
|
-
//
|
|
507
|
+
// renamed header, or a row-drag promotion). Since v3.0.2 the
|
|
508
|
+
// gate above no longer discards `widths` for a rename, so this
|
|
509
|
+
// branch is now the live path for a renamed column: its header
|
|
510
|
+
// and body rows take padField()'s `|| 0` natural width and its
|
|
511
|
+
// separator takes the bare 3-dash minimal. A row-drag promotion
|
|
512
|
+
// ONLY leaves every column unmatched — and so only produces the
|
|
513
|
+
// same all-undefined `widths`, byte-for-byte the `null` output,
|
|
514
|
+
// because every preference above falls through to this 0 — when
|
|
515
|
+
// none of the promoted row's cells collides with an ORIGINAL
|
|
516
|
+
// header name. A collision re-enters the by-text match above (the
|
|
517
|
+
// `k >= 0` branch), so that column inherits the OLD column's
|
|
518
|
+
// width and the row emits half-aligned rather than fully minimal
|
|
519
|
+
// (measured: promoting a 'Note' / 'zzz' row past an original
|
|
520
|
+
// 'Name | Note' header keeps column 0 at the original Note
|
|
521
|
+
// column's width while column 1 falls to 3-dash minimal). The two
|
|
522
|
+
// promotion scenarios this suite covers are both collision-free.
|
|
491
523
|
const origField = matchedSepFields[i];
|
|
492
524
|
const w = widths
|
|
493
525
|
? (widths[i] !== undefined ? widths[i]
|
|
@@ -0,0 +1,160 @@
|
|
|
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.md2docToolbarModel` (lib/editor/server.js). client.js is inlined
|
|
5
|
+
into the page as a plain <script>, not bundled, so a bare require(...) at
|
|
6
|
+
the factory's top level would be an undefined identifier in the browser.
|
|
7
|
+
This module is pure button-model arithmetic: no `document`, `window`, or
|
|
8
|
+
`navigator` reference anywhere below. */
|
|
9
|
+
(function (root, factory) {
|
|
10
|
+
if (typeof module === 'object' && module.exports) module.exports = factory();
|
|
11
|
+
else root.md2docToolbarModel = factory();
|
|
12
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
13
|
+
|
|
14
|
+
// The 22-button roster, spec §4 table (`export▾` moved out of scope — no
|
|
15
|
+
// browser-reachable export path). Six groups, in render order; group order
|
|
16
|
+
// here doubles as separator placement for the caller (a separator sits
|
|
17
|
+
// between consecutive groups).
|
|
18
|
+
const BUTTON_DEFS = [
|
|
19
|
+
// history
|
|
20
|
+
{ id: 'undo', group: 'history', label: 'Undo', icon: '↶', title: '復原 (Ctrl+Z)', toggle: false },
|
|
21
|
+
{ id: 'redo', group: 'history', label: 'Redo', icon: '↷', title: '重做 (Ctrl+Y)', toggle: false },
|
|
22
|
+
|
|
23
|
+
// block
|
|
24
|
+
{ id: 'headings', group: 'block', label: 'H', icon: 'H', title: '標題', toggle: true },
|
|
25
|
+
{ id: 'quote', group: 'block', label: '❝', icon: '❝', title: '引用', toggle: true },
|
|
26
|
+
{ id: 'code', group: 'block', label: '</>', icon: '</>', title: '程式碼區塊', toggle: true },
|
|
27
|
+
{ id: 'list', group: 'block', label: '•', icon: '•', title: '項目符號列表', toggle: true },
|
|
28
|
+
{ id: 'ordered-list', group: 'block', label: '1.', icon: '1.', title: '編號列表', toggle: true },
|
|
29
|
+
{ id: 'check', group: 'block', label: '☑', icon: '☑', title: '待辦清單', toggle: false },
|
|
30
|
+
|
|
31
|
+
// inline
|
|
32
|
+
{ id: 'bold', group: 'inline', label: 'B', icon: 'B', title: '粗體', toggle: false },
|
|
33
|
+
{ id: 'italic', group: 'inline', label: 'I', icon: 'I', title: '斜體', toggle: false },
|
|
34
|
+
{ id: 'strike', group: 'inline', label: 'S', icon: 'S', title: '刪除線', toggle: false },
|
|
35
|
+
{ id: 'inline-code', group: 'inline', label: '`', icon: '`', title: '行內程式碼', toggle: false },
|
|
36
|
+
{ id: 'link', group: 'inline', label: '🔗', icon: '🔗', title: '連結', toggle: false },
|
|
37
|
+
|
|
38
|
+
// indent
|
|
39
|
+
{ id: 'outdent', group: 'indent', label: '⇤', icon: '⇤', title: '減少縮排', toggle: false },
|
|
40
|
+
{ id: 'indent', group: 'indent', label: '⇥', icon: '⇥', title: '增加縮排', toggle: false },
|
|
41
|
+
|
|
42
|
+
// insert
|
|
43
|
+
{ id: 'table', group: 'insert', label: '⊞', icon: '⊞', title: '插入表格', toggle: false },
|
|
44
|
+
{ id: 'insert-before', group: 'insert', label: '⬆', icon: '⬆', title: '在上方插入區塊', toggle: false },
|
|
45
|
+
{ id: 'insert-after', group: 'insert', label: '⬇', icon: '⬇', title: '在下方插入區塊', toggle: false },
|
|
46
|
+
{ id: 'line', group: 'insert', label: '―', icon: '―', title: '插入分隔線', toggle: false },
|
|
47
|
+
{ id: 'image', group: 'insert', label: '🖼', icon: '🖼', title: '插入圖片', toggle: false },
|
|
48
|
+
|
|
49
|
+
// view
|
|
50
|
+
{ id: 'outline', group: 'view', label: '☰', icon: '☰', title: '大綱', toggle: false },
|
|
51
|
+
{ id: 'preview', group: 'view', label: '👁', icon: '👁', title: '預覽', toggle: true },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const BUTTONS = Object.freeze(BUTTON_DEFS.map((b) => Object.freeze(Object.assign({}, b))));
|
|
55
|
+
|
|
56
|
+
const GROUPS = Object.freeze(['history', 'block', 'inline', 'indent', 'insert', 'view']);
|
|
57
|
+
|
|
58
|
+
// Buttons that stay enabled even when there is no block at all (the zeroed
|
|
59
|
+
// state right after rerenderAll, before any block gets focus/selection).
|
|
60
|
+
const NO_BLOCK_ALLOWED = new Set(['undo', 'redo', 'outline', 'preview']);
|
|
61
|
+
|
|
62
|
+
// ctx: {blockType, indent, headingDepth, inList, listOrdered, hasSelection, mode}
|
|
63
|
+
// -> { [id]: {active: bool, disabled: bool, label?: string} }
|
|
64
|
+
function deriveState(ctx) {
|
|
65
|
+
const c = ctx || {};
|
|
66
|
+
const blockType = c.blockType;
|
|
67
|
+
const indent = c.indent;
|
|
68
|
+
const headingDepth = c.headingDepth;
|
|
69
|
+
const inList = !!c.inList;
|
|
70
|
+
const listOrdered = !!c.listOrdered;
|
|
71
|
+
const mode = c.mode;
|
|
72
|
+
const hasBlock = blockType !== null && blockType !== undefined;
|
|
73
|
+
|
|
74
|
+
const state = {};
|
|
75
|
+
for (const b of BUTTONS) {
|
|
76
|
+
state[b.id] = { active: false, disabled: false };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// preview is the tri-state edit/source/preview toggle: "pressed" whenever
|
|
80
|
+
// the current mode is not plain editing. It stays enabled in source mode
|
|
81
|
+
// via the mode==='source' override below (do not gate this on inList/
|
|
82
|
+
// hasBlock — it is the escape hatch OUT of those states).
|
|
83
|
+
state.preview.active = mode !== 'edit';
|
|
84
|
+
|
|
85
|
+
// headings: dropdown whose label tracks the current heading depth.
|
|
86
|
+
state.headings.label = blockType === 'heading' ? 'H' + headingDepth : 'H';
|
|
87
|
+
state.headings.active = blockType === 'heading';
|
|
88
|
+
|
|
89
|
+
// other block-type-driven active flags
|
|
90
|
+
state.quote.active = blockType === 'blockquote';
|
|
91
|
+
state.code.active = blockType === 'code';
|
|
92
|
+
|
|
93
|
+
// list vs ordered-list are mutually exclusive by construction: exactly one
|
|
94
|
+
// reflects the current list's ordered-ness, the other stays inactive.
|
|
95
|
+
if (inList && listOrdered) {
|
|
96
|
+
state['ordered-list'].active = true;
|
|
97
|
+
state.list.active = false;
|
|
98
|
+
} else if (inList && !listOrdered) {
|
|
99
|
+
state.list.active = true;
|
|
100
|
+
state['ordered-list'].active = false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// outdent/indent guard on list membership and current depth.
|
|
104
|
+
if (!inList) {
|
|
105
|
+
state.outdent.disabled = true;
|
|
106
|
+
state.indent.disabled = true;
|
|
107
|
+
}
|
|
108
|
+
if (indent === 0) {
|
|
109
|
+
state.outdent.disabled = true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// check is a `block`-group conversion target, same as quote/code/list/
|
|
113
|
+
// ordered-list: it turns the CURRENT block into a task-list item, so it is
|
|
114
|
+
// not gated on already being in a list — a bare paragraph is exactly the
|
|
115
|
+
// primary case (spec review ruling: gating it on inList made it dead for
|
|
116
|
+
// that case).
|
|
117
|
+
|
|
118
|
+
// No block at all (rerenderAll's zeroed state): everything disabled except
|
|
119
|
+
// the handful of buttons that don't depend on having a focused block.
|
|
120
|
+
if (!hasBlock) {
|
|
121
|
+
for (const b of BUTTONS) {
|
|
122
|
+
if (!NO_BLOCK_ALLOWED.has(b.id)) state[b.id].disabled = true;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// v3.2.0: the five inline-format buttons are dead unless there is a
|
|
127
|
+
// non-collapsed selection INSIDE the burst's own edit surface.
|
|
128
|
+
// applyMarkToggle() (client.js, `function applyMarkToggle`) and
|
|
129
|
+
// applyLinkToggle() (just below it) both return immediately when the root
|
|
130
|
+
// is null, when there is no range, when the range is collapsed, or when
|
|
131
|
+
// either of its containers lies outside that root — `link` included (it
|
|
132
|
+
// does not even open the prompt).
|
|
133
|
+
//
|
|
134
|
+
// `hasSelection` is that whole four-part refusal test, computed once on the
|
|
135
|
+
// client as hasFormattableSelection() and handed in here — so "no button is
|
|
136
|
+
// enabled that the action would silently refuse" holds for these five. It
|
|
137
|
+
// is NOT a claim about the model as a whole: every other enablement rule
|
|
138
|
+
// below is derived from the block record, and a runtime action can still
|
|
139
|
+
// refuse for reasons this ctx does not carry (a structural list gate, a
|
|
140
|
+
// block that owns no source line).
|
|
141
|
+
if (!c.hasSelection) {
|
|
142
|
+
for (const id of ['bold', 'italic', 'strike', 'inline-code', 'link']) {
|
|
143
|
+
state[id].disabled = true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// mode 'source' is the full-document raw-source escape hatch: everything
|
|
148
|
+
// is disabled except the toggle that got you there (preview). This
|
|
149
|
+
// overrides every disabled flag computed above.
|
|
150
|
+
if (mode === 'source') {
|
|
151
|
+
for (const b of BUTTONS) {
|
|
152
|
+
state[b.id].disabled = b.id !== 'preview';
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return state;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { BUTTONS, GROUPS, deriveState };
|
|
160
|
+
});
|