@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.
@@ -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
- const STATE_CHANGING_POST_PATHS = new Set(['/api/render', '/api/save', '/api/ping']);
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 { bodyHtml, blocks } = await renderMarkdown(content, file, { editMode: true });
286
+ const { parts, blocks } = await renderMarkdown(content, file, { editMode: true });
196
287
  bumpIdle(server);
197
- return send(res, 200, { bodyHtml, blocks });
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);
@@ -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). A mismatch that is
369
- // simply a RENAMED header (or a dragged ROW promoting a different row
370
- // to "header" — the per-column-alignment-survives-header-change
371
- // scenario) is walked the exact same way as any other mismatch: it
372
- // doesn't advance `j`, so — per the all-or-nothing gate below — it
373
- // discards the width source for the WHOLE table, not just that one
374
- // column. That reflow-on-rename is TODAY'S shipped, pre-existing
375
- // behaviour (v3.0.0 already reflows the whole table on any edit; a
376
- // rename doing the same is not a regression) — deliberately NOT
377
- // "fixed" here per the 2026-09-02 review round-1 ruling: detecting
378
- // rename-vs-permutation is design work that doesn't belong in a patch
379
- // release. See test/table-width.test.js's "renamed header" case, which
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
- if (j < original.headerFields.length &&
421
- headerContents[i].trim() === original.headerFields[j].trim()) {
422
- matched[i] = original.widths[j];
423
- matchedSepFields[i] = original.sepFields[j];
424
- j++;
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
- // Only trust this walk as a width source AT ALL if it found EVERY
431
- // original column, in order, somewhere in the current header row (j
432
- // reached the end). A PARTIAL match — some columns matched, some
433
- // didn't — means the table's column identity changed in a way this
434
- // can't safely map: a column DRAG that reorders existing columns
435
- // (rather than just adding one) still finds two of three headers
436
- // byte-identical to their originals via this same greedy walk, but
437
- // "a column move must carry its alignment with it" requires ALL
438
- // THREE widths dropped, not just the moved one — a per-column guess
439
- // here would silently keep stale padding on a column that only
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) — moot in practice,
489
- // since the all-or-nothing gate above already discarded
490
- // `widths` entirely for those cases.
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
+ });