@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
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);
|
|
@@ -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
|
+
});
|
package/lib/md2doc.js
CHANGED
|
@@ -313,7 +313,7 @@ function renderEditModeList(listToken, blocks, biRef, out) {
|
|
|
313
313
|
item.tokens.filter((tk) => tk.type === 'list')
|
|
314
314
|
.forEach((ct) => renderEditModeList(ct, blocks, biRef, parts));
|
|
315
315
|
});
|
|
316
|
-
return parts
|
|
316
|
+
return parts;
|
|
317
317
|
}
|
|
318
318
|
|
|
319
319
|
async function renderMarkdown(mdText, srcPath, opts = {}) {
|
|
@@ -487,6 +487,10 @@ async function renderMarkdown(mdText, srcPath, opts = {}) {
|
|
|
487
487
|
}
|
|
488
488
|
|
|
489
489
|
let bodyHtml;
|
|
490
|
+
// v3.2.0: the per-block edit-mode parts (1:1 with `blocks`), already baked.
|
|
491
|
+
// Stays null outside editMode — the reader/PDF path has no per-block DOM to
|
|
492
|
+
// patch, only the whole-document `bodyHtml`.
|
|
493
|
+
let parts = null;
|
|
490
494
|
let tocHtml = '';
|
|
491
495
|
let serializedSections = '[]';
|
|
492
496
|
// NOTE: intentionally no try/catch here. A throw from marked/katex parsing
|
|
@@ -893,7 +897,7 @@ ${itemsHtml}
|
|
|
893
897
|
// exactly what the whole-document parse would emit for that token,
|
|
894
898
|
// using the same renderer/options set above.
|
|
895
899
|
const tokens = marked.lexer(mdPre);
|
|
896
|
-
|
|
900
|
+
parts = [];
|
|
897
901
|
// `v` is the block cursor; `prevLi`/`types` are the §3.8 run state the
|
|
898
902
|
// flat list renderer above threads through its own recursion (see
|
|
899
903
|
// liRunStartsHere()).
|
|
@@ -901,7 +905,7 @@ ${itemsHtml}
|
|
|
901
905
|
for (const t of tokens) {
|
|
902
906
|
if (t.type === 'space') continue;
|
|
903
907
|
if (t.type === 'list') {
|
|
904
|
-
parts.push(renderEditModeList(t, blocks, biRef));
|
|
908
|
+
parts.push(...renderEditModeList(t, blocks, biRef));
|
|
905
909
|
continue;
|
|
906
910
|
}
|
|
907
911
|
resetLiRunState(biRef); // §3.8 rule (c): a non-li block ends every run
|
|
@@ -920,6 +924,14 @@ ${itemsHtml}
|
|
|
920
924
|
throw new Error('edit-mode render consumed ' + biRef.v +
|
|
921
925
|
' blocks but the block map has ' + blocks.length);
|
|
922
926
|
}
|
|
927
|
+
// v3.2.0: bake EACH part, then join — not join-then-bake. The patch path
|
|
928
|
+
// inserts parts[i] straight into the DOM and the client has no graphviz
|
|
929
|
+
// renderer (__md2docInitDiagrams handles only mermaid and WaveDrom), so
|
|
930
|
+
// an unbaked part lands as an empty <div class="graphviz">. bakeGraphviz
|
|
931
|
+
// short-circuits on parts with no data-graphviz-src, and each placeholder
|
|
932
|
+
// falls entirely inside one part, so this is byte-identical to baking the
|
|
933
|
+
// joined string.
|
|
934
|
+
parts = await Promise.all(parts.map(bakeGraphviz));
|
|
923
935
|
bodyHtml = parts.join('\n');
|
|
924
936
|
} else {
|
|
925
937
|
bodyHtml = marked.parse(mdPre);
|
|
@@ -1328,6 +1340,48 @@ ${itemsHtml}
|
|
|
1328
1340
|
content: ""; position: absolute;
|
|
1329
1341
|
left: calc(-1 * (var(--ed-gutter-w) + var(--ed-gutter-shift))); top: 0;
|
|
1330
1342
|
width: calc(var(--ed-gutter-w) + var(--ed-gutter-shift)); height: 100%;
|
|
1343
|
+
}
|
|
1344
|
+
/* v3.1.0 §4: room for the fixed toolbar (.ed-toolbar, further up this
|
|
1345
|
+
stylesheet). The bar is position:fixed — NOT sticky — because
|
|
1346
|
+
lib/editor/client.js mounts it on document.body: rerenderAll() replaces
|
|
1347
|
+
.content's innerHTML wholesale, so a toolbar living inside the content
|
|
1348
|
+
column would be destroyed by the first commit with nothing to rebuild
|
|
1349
|
+
it. Fixed chrome takes no layout space, so the page has to make its
|
|
1350
|
+
own: this is the only place that offset is written. The mobile rule at
|
|
1351
|
+
max-width:1080px above already reserves 60px for .sidebar-toggle; the
|
|
1352
|
+
bar sits above it, so the two offsets ADD there.
|
|
1353
|
+
.reader-sidebar is sticky at top: 24px in the base stylesheet — pushed
|
|
1354
|
+
down by the same amount here so the outline does not slide under the
|
|
1355
|
+
bar, with its height reduced to match so it still ends at the viewport
|
|
1356
|
+
bottom. */
|
|
1357
|
+
.page-layout { padding-top: var(--ed-toolbar-h); }
|
|
1358
|
+
.reader-sidebar {
|
|
1359
|
+
top: calc(24px + var(--ed-toolbar-h));
|
|
1360
|
+
height: calc(100vh - 48px - var(--ed-toolbar-h));
|
|
1361
|
+
}
|
|
1362
|
+
@media (max-width: 1080px) {
|
|
1363
|
+
.page-layout { padding-top: calc(60px + var(--ed-toolbar-h)); }
|
|
1364
|
+
.reader-sidebar { top: 0; height: 100vh; padding-top: calc(16px + var(--ed-toolbar-h)); }
|
|
1365
|
+
}
|
|
1366
|
+
/* The 大綱 button's DESKTOP half, on an attribute of its OWN.
|
|
1367
|
+
⚠ It deliberately does NOT reuse body[data-sidebar-open], and the reason
|
|
1368
|
+
was measured rather than reasoned: the rule
|
|
1369
|
+
'body[data-sidebar-open] .sidebar-scrim { display: block; }' sits at BASE scope in this
|
|
1370
|
+
stylesheet, not inside the max-width:1080px drawer block. Giving that
|
|
1371
|
+
same attribute a second, desktop meaning therefore also paints
|
|
1372
|
+
.sidebar-scrim (position:fixed; inset:0; z-index:98; rgba(0,0,0,.35))
|
|
1373
|
+
across the whole viewport — the desktop page went grey and
|
|
1374
|
+
click-through-dead, and the scrim's own click handler (which calls
|
|
1375
|
+
setSidebarOpen(false)) meant the next click anywhere silently undid it.
|
|
1376
|
+
A private attribute, written by toggleOutlineSidebar() in
|
|
1377
|
+
lib/editor/client.js, leaves data-sidebar-open to the mobile drawer
|
|
1378
|
+
alone and lets #sidebar-toggle's aria-expanded stay truthful.
|
|
1379
|
+
The attribute names the non-default state ("hidden") because on desktop
|
|
1380
|
+
the outline is a sticky column that is SHOWN by default; the mobile
|
|
1381
|
+
drawer keeps its own "open" attribute for the same reason in reverse. */
|
|
1382
|
+
@media (min-width: 1081px) {
|
|
1383
|
+
body[data-ed-outline-hidden] .reader-sidebar,
|
|
1384
|
+
body[data-ed-outline-hidden] .sidebar-splitter { display: none; }
|
|
1331
1385
|
}`;
|
|
1332
1386
|
|
|
1333
1387
|
const html = `<!DOCTYPE html>
|
|
@@ -2480,6 +2534,110 @@ ${itemsHtml}
|
|
|
2480
2534
|
.ed-seltb-i { font-style: italic; }
|
|
2481
2535
|
.ed-seltb-s { text-decoration: line-through; }
|
|
2482
2536
|
.ed-seltb-u { text-decoration: underline; }
|
|
2537
|
+
/* v3.1.0 §4: the always-visible editing toolbar. Its numeric language is
|
|
2538
|
+
.lightbox-bar's (8px/12px padding, 6px gap, 13px type) over .ed-seltb's
|
|
2539
|
+
dark pill, so the two floating surfaces read as one family.
|
|
2540
|
+
position:fixed, NOT sticky: lib/editor/client.js mounts this on
|
|
2541
|
+
document.body because rerenderAll() replaces .content's innerHTML
|
|
2542
|
+
wholesale and would silently destroy a toolbar mounted inside it.
|
|
2543
|
+
Deliberately does NOT read any --ed-gutter-* token — those describe the
|
|
2544
|
+
⠿/+ pair's geometry and this is a new, unrelated scale (the height is
|
|
2545
|
+
its own token because the edit-mode layout chunk at the end of this
|
|
2546
|
+
stylesheet has to reserve exactly that much room for it).
|
|
2547
|
+
z-index sits above the mobile drawer (99) and .sidebar-toggle (100) but
|
|
2548
|
+
below .ed-conflict (999): a save conflict must always be reachable. */
|
|
2549
|
+
:root { --ed-toolbar-h: 44px; }
|
|
2550
|
+
.ed-toolbar {
|
|
2551
|
+
position: fixed; top: 0; left: 0; right: 0; z-index: 101;
|
|
2552
|
+
box-sizing: border-box; height: var(--ed-toolbar-h);
|
|
2553
|
+
display: flex; align-items: center; justify-content: center;
|
|
2554
|
+
flex-wrap: nowrap; overflow-x: auto; overflow-y: hidden;
|
|
2555
|
+
gap: 6px; padding: 8px 12px;
|
|
2556
|
+
background: rgba(16, 18, 21, 0.92); color: #e6edf3;
|
|
2557
|
+
font-size: 13px;
|
|
2558
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
|
2559
|
+
}
|
|
2560
|
+
.ed-toolbar-sep {
|
|
2561
|
+
flex: 0 0 auto; width: 1px; height: 18px; margin: 0 2px;
|
|
2562
|
+
background: rgba(255, 255, 255, 0.25);
|
|
2563
|
+
}
|
|
2564
|
+
.ed-toolbar-btn {
|
|
2565
|
+
flex: 0 0 auto;
|
|
2566
|
+
min-width: 28px; height: 26px; padding: 0 8px;
|
|
2567
|
+
border: 1px solid rgba(255, 255, 255, 0.25); border-radius: 6px;
|
|
2568
|
+
background: rgba(255, 255, 255, 0.08); color: inherit;
|
|
2569
|
+
font: inherit; font-size: 13px; line-height: 1;
|
|
2570
|
+
white-space: nowrap; cursor: pointer;
|
|
2571
|
+
}
|
|
2572
|
+
.ed-toolbar-btn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.18); }
|
|
2573
|
+
.ed-toolbar-btn:disabled { opacity: 0.38; cursor: default; }
|
|
2574
|
+
.ed-toolbar-btn[aria-pressed="true"] {
|
|
2575
|
+
background: #e6edf3; color: #101215; border-color: #e6edf3;
|
|
2576
|
+
}
|
|
2577
|
+
.ed-toolbar-b { font-weight: bold; }
|
|
2578
|
+
.ed-toolbar-i { font-style: italic; }
|
|
2579
|
+
.ed-toolbar-s { text-decoration: line-through; }
|
|
2580
|
+
/* H1 fix: the brief's literal margin-left: auto (a flex-flow item) was
|
|
2581
|
+
measured to break .ed-toolbar's justify-content: center — an auto
|
|
2582
|
+
margin on any flex item absorbs ALL positive free space before
|
|
2583
|
+
justify-content sees any, so the ~22-button group snapped flush left on
|
|
2584
|
+
every viewport wide enough not to overflow. position: fixed removes
|
|
2585
|
+
the slot from the flex layout entirely (buttons measured byte-identical
|
|
2586
|
+
to a build with the slot absent, at both a 1400px and a 420px viewport),
|
|
2587
|
+
and — unlike position: absolute against .ed-toolbar's own
|
|
2588
|
+
position: fixed box — it anchors to the browser viewport, not to
|
|
2589
|
+
.ed-toolbar's scrolled content, so it does not drift as the button
|
|
2590
|
+
row's overflow-x: auto scrolls (measured identical rect at scrollLeft
|
|
2591
|
+
0 / mid / max, where the absolute alternative moved by the exact scroll
|
|
2592
|
+
delta). References no gutter-alignment custom property.
|
|
2593
|
+
H2 fix: pointer-events: none below is LOAD-BEARING while this slot
|
|
2594
|
+
ships empty, not decorative. At narrow viewports .ed-toolbar's own
|
|
2595
|
+
overflow-x: auto lets the button row scroll to a rest position where
|
|
2596
|
+
this fixed 7rem box's footprint physically covers three real buttons
|
|
2597
|
+
(image / outline / preview, measured at 420px width, scrollLeft at its
|
|
2598
|
+
max 221) — document.elementFromPoint() over any of them resolves to
|
|
2599
|
+
this span, not the button underneath, and a real click on it never
|
|
2600
|
+
reaches the button's listener. Harmless today only because the span
|
|
2601
|
+
carries no content to interact with. WHOEVER WIRES UP TWO-WAY SYNC
|
|
2602
|
+
(§3) AND PUTS REAL CONTENT OR CONTROLS HERE MUST remove or scope this
|
|
2603
|
+
rule AND independently solve the overlap (e.g. reflow the slot in-flow
|
|
2604
|
+
at narrow widths, or shrink/hide it below some width) — simply
|
|
2605
|
+
deleting this line without doing that reopens the swallowed-click
|
|
2606
|
+
regression, this time on content someone can actually see and try to
|
|
2607
|
+
click. */
|
|
2608
|
+
.ed-toolbar-status { position: fixed; top: 0; right: 12px;
|
|
2609
|
+
height: var(--ed-toolbar-h); line-height: var(--ed-toolbar-h);
|
|
2610
|
+
min-width: 7rem; text-align: right; font-size: 12px; opacity: .8;
|
|
2611
|
+
pointer-events: none; }
|
|
2612
|
+
/* The H▾ dropdown: same singleton-panel idiom as .ed-insert-menu, but
|
|
2613
|
+
anchored to a fixed bar rather than moved into a block, so it is
|
|
2614
|
+
positioned in viewport coordinates by the client runtime. */
|
|
2615
|
+
.ed-toolbar-menu {
|
|
2616
|
+
position: fixed; z-index: 102;
|
|
2617
|
+
display: flex; flex-direction: column; gap: 2px;
|
|
2618
|
+
padding: 4px; border-radius: 8px;
|
|
2619
|
+
background: rgba(16, 18, 21, 0.96); color: #e6edf3;
|
|
2620
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
|
|
2621
|
+
}
|
|
2622
|
+
.ed-toolbar-menu[hidden] { display: none; }
|
|
2623
|
+
.ed-toolbar-menu-btn {
|
|
2624
|
+
min-width: 120px; padding: 4px 10px; text-align: left;
|
|
2625
|
+
border: none; border-radius: 4px; background: transparent; color: inherit;
|
|
2626
|
+
font: inherit; font-size: 13px; line-height: 1.4; cursor: pointer;
|
|
2627
|
+
}
|
|
2628
|
+
.ed-toolbar-menu-btn:hover { background: rgba(255, 255, 255, 0.18); }
|
|
2629
|
+
/* 追加 4: the whole-document source escape hatch. Replaces the rendered
|
|
2630
|
+
content column for the duration of source mode; the document's own
|
|
2631
|
+
lines[] is untouched until the mode is left (see docsource.js). */
|
|
2632
|
+
.ed-source {
|
|
2633
|
+
display: block; width: 100%; box-sizing: border-box;
|
|
2634
|
+
min-height: 70vh; padding: 12px;
|
|
2635
|
+
font-family: monospace; font-size: 13px; line-height: 1.6;
|
|
2636
|
+
border: 1px solid #d0d7de; border-radius: 8px; resize: vertical;
|
|
2637
|
+
white-space: pre; overflow-wrap: normal;
|
|
2638
|
+
}
|
|
2639
|
+
body[data-ed-mode="preview"] .ed-handle,
|
|
2640
|
+
body[data-ed-mode="preview"] .ed-insert { display: none !important; }
|
|
2483
2641
|
.ed-editing { position: relative; }
|
|
2484
2642
|
.ed-raw {
|
|
2485
2643
|
display: block; width: 100%; font-family: monospace; font-size: 13px;
|
|
@@ -4132,8 +4290,11 @@ ${mermaidInitTag}`)
|
|
|
4132
4290
|
</html>`;
|
|
4133
4291
|
|
|
4134
4292
|
const bakedHtml = await bakeGraphviz(html);
|
|
4293
|
+
// editMode's bodyHtml is already baked per-part above; re-baking here is a
|
|
4294
|
+
// short-circuit no-op (bakeGraphviz bails out when there's no
|
|
4295
|
+
// data-graphviz-src left). Non-editMode bodyHtml is baked here as before.
|
|
4135
4296
|
const bakedBody = await bakeGraphviz(bodyHtml);
|
|
4136
|
-
return { html: bakedHtml, bodyHtml: bakedBody, blocks };
|
|
4297
|
+
return { html: bakedHtml, bodyHtml: bakedBody, parts, blocks };
|
|
4137
4298
|
}
|
|
4138
4299
|
module.exports = { renderMarkdown };
|
|
4139
4300
|
|