@helping-ai-workflow/md2doc 3.3.0 → 3.4.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.
@@ -2,6 +2,8 @@
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
4
  const http = require('http');
5
+ const crypto = require('crypto');
6
+ const drawioLib = require('../drawio.js');
5
7
  const { renderMarkdown } = require('../md2doc.js');
6
8
 
7
9
  const LINEOPS_SRC = fs.readFileSync(path.join(__dirname, 'lineops.js'), 'utf8');
@@ -54,6 +56,50 @@ const TURNDOWN_SRC = fs.readFileSync(
54
56
  require.resolve('turndown/lib/turndown.browser.umd.js'), 'utf8');
55
57
  const PASTE_MD_SRC = fs.readFileSync(path.join(__dirname, 'paste-md.js'), 'utf8');
56
58
 
59
+ // v3.4.0 batch3 Task 6: the wavedrom GUI's four modules.
60
+ //
61
+ // wave-codec.js / wave-geometry.js / wave-store.js are plain CommonJS ON
62
+ // PURPOSE, and rewriting them into the UMD header every other module here uses
63
+ // is not available: test/wave-codec.test.js bans the spelling `require(` in the
64
+ // codec outright, test/wave-geometry.test.js bans every host-object read a UMD
65
+ // header performs (`typeof self`, `root.x = …`), and test/wave-store.test.js
66
+ // pins the store's require list to exactly `["require('./wave-codec.js')"]`. So
67
+ // the browser gets them through the small CommonJS shim below instead —
68
+ // the module text itself is inlined byte for byte.
69
+ //
70
+ // wave-ui.js is UMD like its neighbours and needs no shim; in the page it
71
+ // defines `window.md2docWaveUi` and takes the other three as parameters.
72
+ const WAVE_CODEC_SRC = fs.readFileSync(path.join(__dirname, 'wave-codec.js'), 'utf8');
73
+ const WAVE_GEOMETRY_SRC = fs.readFileSync(path.join(__dirname, 'wave-geometry.js'), 'utf8');
74
+ const WAVE_STORE_SRC = fs.readFileSync(path.join(__dirname, 'wave-store.js'), 'utf8');
75
+ const WAVE_UI_SRC = fs.readFileSync(path.join(__dirname, 'wave-ui.js'), 'utf8');
76
+
77
+ // One CommonJS module, wrapped so a plain <script> can carry it.
78
+ //
79
+ // The `'use strict'` is the FIRST statement of the script, not of the wrapper
80
+ // function, so the whole tag — module body included — is strict. That is
81
+ // load-bearing rather than tidy: the store deep-freezes every document it
82
+ // keeps, and a frozen object only BITES in strict mode. test/wave-store.test.js
83
+ // T24 measured the sloppy-mode alternative — four of five writes to a frozen
84
+ // document were silent — so a wrapper that lost the prologue would put the
85
+ // original corruption back and make it quieter.
86
+ //
87
+ // `require` here resolves by basename against what has already been shimmed,
88
+ // which is enough for the only two edges that exist (both './wave-codec.js')
89
+ // and stays a total function for anything else: an unknown name answers
90
+ // undefined and the module that asked fails where it asked.
91
+ function waveModuleTag(name, src) {
92
+ return '<script>\'use strict\';\n' +
93
+ 'window.__md2docWave = window.__md2docWave || {};\n' +
94
+ '(function () {\n' +
95
+ 'var module = { exports: {} };\n' +
96
+ 'var exports = module.exports;\n' +
97
+ 'function require(p) { return window.__md2docWave[String(p).split(\'/\').pop()]; }\n' +
98
+ src + '\n' +
99
+ 'window.__md2docWave[' + JSON.stringify(name) + '] = module.exports;\n' +
100
+ '})();</script>\n';
101
+ }
102
+
57
103
  // The SAME pure module the page gets, required here for the node side of
58
104
  // /api/asset. asset.js is deliberately free of `require('fs')`/`require('path')`
59
105
  // (it is plain string arithmetic), so every filesystem decision below is made
@@ -194,6 +240,235 @@ async function createEditorServer({
194
240
  let neverOpenedTimer = null;
195
241
  let started = false;
196
242
 
243
+ // v3.4.0 batch2 Task 6 — Ruling B2-2: watch every `.drawio`/`.xml` file the
244
+ // CURRENTLY OPEN document references, and let /api/ping's already-firing
245
+ // 10s heartbeat (see its own comment below) double as the staleness check,
246
+ // rather than inventing a push transport server.js has never had (no
247
+ // WebSocket/SSE/fs.watch anywhere in this stack — see this task's
248
+ // investigation note).
249
+ //
250
+ // TWO maps, and the split is the whole point of fix round 1:
251
+ //
252
+ // drawioWatch fileId -> { refs }. WHAT THE LAST BAKE RESOLVED.
253
+ // The list of absolute paths that render actually resolved,
254
+ // i.e. the only paths worth stat'ing for this document.
255
+ // Fix round 2 (re-review G13): this used to carry a
256
+ // `stamps` Map as well, which nothing ever read — the new
257
+ // tab's baseline comes from trackDrawioRefs()'s RETURN
258
+ // value, handed straight to registerDrawioClient() in the
259
+ // same expression. Dead state in a freshly written
260
+ // mechanism is a maintenance trap, so it is gone; the stamp
261
+ // lib/md2doc.js takes before the bake (review F4) still
262
+ // travels, just not through here.
263
+ //
264
+ // drawioClients clientKey -> { fileId, stamps, pending, lastSeen }.
265
+ // WHAT ONE BROWSER TAB IS ACTUALLY SHOWING. Review F3: the
266
+ // baseline used to be per-file, so with two tabs open on one
267
+ // document the first to ping consumed the one signal and the
268
+ // second sat on a stale diagram looking perfectly healthy.
269
+ // A tab gets its id minted by GET /edit/:id — the same
270
+ // response that paints its DOM — so its baseline is exactly
271
+ // the bytes it is displaying, with no window in which
272
+ // another tab's render could seed it wrong.
273
+ //
274
+ // Review F2's invariant: a client's `stamps` advances ONLY when that client
275
+ // has told us its DOM now shows those bytes. Until then every heartbeat
276
+ // keeps reporting the same staleness, so every one of the client's bail
277
+ // paths (network error, mid-gesture DOM, focus inside the block, …) really
278
+ // does get retried on the next tick instead of losing the change forever.
279
+ const drawioWatch = new Map();
280
+ const drawioClients = new Map();
281
+ // The token that identifies a CONTENT VERSION, not a ping. Minted once per
282
+ // (client, observed stamp set) and repeated verbatim on every later ping
283
+ // that still sees the same bytes — if it were per-ping, a refresh that took
284
+ // longer than one 10s beat would always ack a token the server had already
285
+ // superseded, and the pair would never converge.
286
+ let drawioTokenSeq = 0;
287
+ let drawioLastSweep = 0;
288
+ // 10 minutes = 60 missed heartbeats. A tab that has not pinged in that long
289
+ // is closed (or its machine slept); keeping its entry would leak one small
290
+ // object per tab ever opened for the life of the server process.
291
+ const DRAWIO_CLIENT_IDLE_MS = 10 * 60 * 1000;
292
+
293
+ function drawioClientKey(fileId, clientId) {
294
+ if (typeof clientId !== 'string' || clientId === '') return null;
295
+ if (typeof fileId !== 'number' || !isFinite(fileId)) return null;
296
+ return String(fileId) + '\u0000' + clientId;
297
+ }
298
+
299
+ function sweepDrawioClients(now) {
300
+ if (now - drawioLastSweep < 60 * 1000) return;
301
+ drawioLastSweep = now;
302
+ for (const [k, c] of drawioClients) {
303
+ if (now - c.lastSeen > DRAWIO_CLIENT_IDLE_MS) drawioClients.delete(k);
304
+ }
305
+ }
306
+
307
+ // Fix round 2 (re-review G5). A tab that is still talking to us is alive by
308
+ // definition, so a well-formed clientId with no entry gets one back rather
309
+ // than being answered 204 forever with no signal and no route back but a
310
+ // reload. Reachable the moment a laptop sleeps or a background tab's timers
311
+ // are frozen for longer than the sweep window — and before this, the tab's
312
+ // own returning heartbeat was what deleted it (the sweep ran BEFORE the
313
+ // lastSeen refresh), so the failure mode was silent, permanent, and caused
314
+ // by the recovery attempt itself.
315
+ //
316
+ // Seeded with an EMPTY stamp map on purpose, not with the last bake's: we
317
+ // do not know what that tab's DOM is showing, and an empty baseline makes
318
+ // the next check report stale, which costs one re-render the client then
319
+ // acks. Wrong in the safe direction (one redundant bake) instead of the
320
+ // unsafe one (a tab that silently never updates again).
321
+ //
322
+ // Fix round 3 (re-review2 H4): the bound is EVICT-OLDEST, applied at BOTH
323
+ // doors. Round 2 capped only the ping door and refused past the cap, which
324
+ // was wrong twice over: `GET /edit/:id` wrote into the same map with no
325
+ // check at all, and refusing meant that once 64 entries accumulated, a
326
+ // genuinely swept live tab could never re-register — G5's own guard rail
327
+ // reintroducing exactly the silent deafness G5 was raised about. Dropping
328
+ // the least-recently-seen entry instead keeps the map at <= MAX from both
329
+ // doors AND never turns a live tab away: the entry we drop is by
330
+ // construction the one that has gone quietest.
331
+ //
332
+ // WHAT THIS BOUNDS, EXACTLY (fix round 4, re-review3 M3 — the sentence this
333
+ // replaces claimed more than the code delivers): **the map, not the render
334
+ // load.** Round 2's refuse-at-cap did cap the bakes a fresh-id-per-ping
335
+ // caller could provoke — past 64 ids it answered a bare 204, so no token and
336
+ // no `/api/render` followed. Evict-oldest removes that ceiling on purpose:
337
+ // a caller rotating a new id every ping now gets `{stale, token}` every
338
+ // time and can drive `/api/render` — and therefore headless-Chromium bakes
339
+ // — without bound. That trade is deliberate, because the refusal it removes
340
+ // is exactly what made a swept live tab permanently deaf, and availability
341
+ // for the real tab is worth more here than a ceiling on a caller that is
342
+ // already inside the loopback-only, Host-checked front door (see the
343
+ // DNS-rebinding note above `hostIsLoopback`). Recorded as a known
344
+ // limitation rather than guarded: every guard added to this mechanism so
345
+ // far has produced the next round's finding.
346
+ const DRAWIO_CLIENT_MAX = 64;
347
+ function makeRoomForDrawioClient() {
348
+ while (drawioClients.size >= DRAWIO_CLIENT_MAX) {
349
+ let oldestKey = null, oldestSeen = Infinity;
350
+ for (const [k, c] of drawioClients) {
351
+ if (c.lastSeen < oldestSeen) { oldestSeen = c.lastSeen; oldestKey = k; }
352
+ }
353
+ if (oldestKey === null) return; // unreachable while size > 0
354
+ drawioClients.delete(oldestKey);
355
+ }
356
+ }
357
+ function ensureDrawioClient(fileId, clientId, now) {
358
+ const key = drawioClientKey(fileId, clientId);
359
+ if (key === null) return null;
360
+ let c = drawioClients.get(key);
361
+ if (c) { c.lastSeen = now; return c; }
362
+ if (!drawioWatch.has(fileId)) return null;
363
+ makeRoomForDrawioClient();
364
+ c = { fileId, stamps: new Map(), pending: null, lastSeen: now };
365
+ drawioClients.set(key, c);
366
+ return c;
367
+ }
368
+
369
+ // Called after every render that actually re-reads the source markdown
370
+ // (GET /edit/:id and POST /api/render both call renderMarkdown() fresh).
371
+ // Records WHAT WAS BAKED; deliberately does NOT touch any client's
372
+ // baseline — that is review F2's invariant, and the trap F2 names is
373
+ // exactly this function: /api/render runs BEFORE the client has decided
374
+ // whether it can apply the result, so advancing anything here would lose
375
+ // every post-response bail.
376
+ function trackDrawioRefs(fileId, refs) {
377
+ const list = Array.isArray(refs) ? refs : [];
378
+ const paths = [];
379
+ const stamps = new Map();
380
+ for (const r of list) {
381
+ const abs = r && typeof r.path === 'string' ? r.path : null;
382
+ if (!abs || stamps.has(abs)) continue;
383
+ paths.push(abs);
384
+ stamps.set(abs, r.stamp === undefined ? null : r.stamp);
385
+ }
386
+ drawioWatch.set(fileId, { refs: paths });
387
+ return stamps;
388
+ }
389
+
390
+ function registerDrawioClient(fileId, stamps) {
391
+ const clientId = crypto.randomBytes(12).toString('hex');
392
+ const key = drawioClientKey(fileId, clientId);
393
+ if (key === null) return clientId; // unreachable today; never key on null
394
+ const now = Date.now();
395
+ makeRoomForDrawioClient(); // H4: this door is bounded too
396
+ drawioClients.set(key, {
397
+ fileId, stamps: new Map(stamps), pending: null, lastSeen: now,
398
+ });
399
+ sweepDrawioClients(now); // G5: after the write, never before
400
+ return clientId;
401
+ }
402
+
403
+ function sameStamps(a, b) {
404
+ if (a.size !== b.size) return false;
405
+ for (const [k, v] of a) { if (!b.has(k) || b.get(k) !== v) return false; }
406
+ return true;
407
+ }
408
+
409
+ // (a) requirement: a document with no drawio references must cost this
410
+ // heartbeat NOTHING beyond the Map lookups below — zero fs.statSync calls,
411
+ // same as the bare 204 this replaces. Only a document that actually has
412
+ // `refs.length > 0` pays for the stat loop that follows.
413
+ //
414
+ // Returns the token to hand the client, or null for "nothing stale".
415
+ function checkDrawioStale(fileId, clientId) {
416
+ const state = drawioWatch.get(fileId);
417
+ if (!state || state.refs.length === 0) return null;
418
+ const key = drawioClientKey(fileId, clientId);
419
+ if (key === null) return null;
420
+ const c = drawioClients.get(key);
421
+ // An unknown client here is one touchDrawioClient() declined to
422
+ // (re-)register: a page from before this feature (no clientId at all), a
423
+ // malformed id, or a fileId this server never rendered. It gets the old
424
+ // bare 204 rather than a signal it has no baseline to interpret. Note
425
+ // that hitting DRAWIO_CLIENT_MAX is NOT one of those cases any more —
426
+ // see makeRoomForDrawioClient() (re-review2 H4).
427
+ if (!c) return null;
428
+ const now = new Map();
429
+ let changed = false;
430
+ for (const p of state.refs) {
431
+ const st = drawioLib.drawioStampOf(p);
432
+ now.set(p, st);
433
+ if (c.stamps.get(p) !== st) changed = true;
434
+ }
435
+ if (!changed) { c.pending = null; return null; }
436
+ if (c.pending && sameStamps(c.pending.stamps, now)) return c.pending.id;
437
+ drawioTokenSeq += 1;
438
+ c.pending = { id: 'd' + drawioTokenSeq, stamps: now };
439
+ return c.pending.id;
440
+ }
441
+
442
+ // Keeps a live tab out of the idle sweep above (re-registering it if the
443
+ // sweep already got there — G5), and is the one place the sweep is actually
444
+ // driven from: the heartbeat is the only thing that fires often enough to
445
+ // be worth hanging it on, and it is throttled to at most once a minute
446
+ // inside sweepDrawioClients().
447
+ //
448
+ // ORDER IS LOAD-BEARING: ensure/touch first, sweep second. The other way
449
+ // round, a tab returning from a >10-minute freeze is deleted by the very
450
+ // ping it sent to say it was back.
451
+ function touchDrawioClient(fileId, clientId) {
452
+ const now = Date.now();
453
+ ensureDrawioClient(fileId, clientId, now);
454
+ sweepDrawioClients(now);
455
+ }
456
+
457
+ // The client's half of F2's invariant: it sends back the token it was given
458
+ // ONLY after its DOM really carries that render. Anything less than a
459
+ // complete apply (a block skipped because the user's focus was inside it,
460
+ // a mid-gesture DOM, a failed fetch) sends nothing, so `stamps` stays put
461
+ // and the very next heartbeat reports the same staleness again.
462
+ function ackDrawioApplied(fileId, clientId, token) {
463
+ if (typeof token !== 'string' || token === '') return;
464
+ const key = drawioClientKey(fileId, clientId);
465
+ if (key === null) return;
466
+ const c = drawioClients.get(key);
467
+ if (!c || !c.pending || c.pending.id !== token) return;
468
+ c.stamps = c.pending.stamps;
469
+ c.pending = null;
470
+ }
471
+
197
472
  function bumpIdle(server) {
198
473
  if (!started) return;
199
474
  if (idleTimer) clearTimeout(idleTimer);
@@ -281,11 +556,15 @@ async function createEditorServer({
281
556
  const eol = (crlfCount > bareLf && crlfCount > bareCr) ? '\r\n'
282
557
  : (bareCr > bareLf && bareCr > crlfCount) ? '\r'
283
558
  : '\n';
284
- const { html, blocks } = await renderMarkdown(mdText, file, { editMode: true });
559
+ const { html, blocks, drawioRefs } = await renderMarkdown(mdText, file, { editMode: true });
560
+ // Fix round 1 (review F3): mint this TAB's own staleness baseline from
561
+ // the very render that is about to paint it. Per-tab, not per-file —
562
+ // two tabs on one document must each get told about an external edit.
563
+ const drawioClientId = registerDrawioClient(fileId, trackDrawioRefs(fileId, drawioRefs));
285
564
  const lines = mdText.split(/\r\n|\r|\n/);
286
565
  assertBlockRangesFit(blocks, lines);
287
566
  const payload = JSON.stringify({
288
- fileId, mtimeMs, eol, lines, blocks,
567
+ fileId, mtimeMs, eol, lines, blocks, drawioClientId,
289
568
  });
290
569
  const inject =
291
570
  `<script>window.__ED__ = ${payload.replace(/</g, '\\u003c')}</script>\n` +
@@ -303,6 +582,10 @@ async function createEditorServer({
303
582
  `<script>${ASSET_SRC}</script>\n` +
304
583
  `<script>${TURNDOWN_SRC}</script>\n` +
305
584
  `<script>${PASTE_MD_SRC}</script>\n` +
585
+ waveModuleTag('wave-codec.js', WAVE_CODEC_SRC) +
586
+ waveModuleTag('wave-geometry.js', WAVE_GEOMETRY_SRC) +
587
+ waveModuleTag('wave-store.js', WAVE_STORE_SRC) +
588
+ `<script>${WAVE_UI_SRC}</script>\n` +
306
589
  `<script>${clientJs}</script>\n`;
307
590
  // Splice at the LAST "</body>" — the document's real closing tag.
308
591
  // The first occurrence can sit inside an inlined diagram bundle's JS
@@ -329,7 +612,10 @@ async function createEditorServer({
329
612
  const { fileId, content } = await readJson(req);
330
613
  const file = absFiles[fileId];
331
614
  if (!file) return send(res, 404, { error: 'unknown file' });
332
- const { parts, blocks } = await renderMarkdown(content, file, { editMode: true });
615
+ const { parts, blocks, drawioRefs } = await renderMarkdown(content, file, { editMode: true });
616
+ // Refs only — NOT any client's baseline. See trackDrawioRefs()'s own
617
+ // comment: the client has not yet decided whether it can apply this.
618
+ trackDrawioRefs(fileId, drawioRefs);
333
619
  bumpIdle(server);
334
620
  return send(res, 200, { parts, blocks });
335
621
  }
@@ -458,6 +744,40 @@ async function createEditorServer({
458
744
 
459
745
  if (req.method === 'POST' && url.pathname === '/api/ping') {
460
746
  bumpIdle(server);
747
+ // v3.4.0 batch2 Task 6: fileId / drawioClientId / drawioAck are NEW,
748
+ // all optional, on the body this heartbeat already sent every 10s
749
+ // (old clients / the existing test in test/editor-server.test.js send
750
+ // `{}` — that still resolves to "no tracked state", the same
751
+ // zero-cost 204 as before). A body that fails to parse degrades the
752
+ // same way: no staleness check, plain 204 — a malformed ping body
753
+ // must never be the thing that breaks the idle-keepalive contract.
754
+ //
755
+ // Fix round 1 (review F8): an explicit 8 KB readJson limit, not the
756
+ // 50 MB default — the same precedent /api/save set for its own body,
757
+ // for the same reason. This body is always three short scalars, and
758
+ // this is the ONE route that fires unconditionally every 10 seconds
759
+ // from every open tab; it has no business also being the most
760
+ // permissive one in the server.
761
+ let fileId, drawioClientId, drawioAck;
762
+ try {
763
+ const body = await readJson(req, 8 * 1024);
764
+ if (body) {
765
+ fileId = body.fileId;
766
+ drawioClientId = body.drawioClientId;
767
+ drawioAck = body.drawioAck;
768
+ }
769
+ } catch (e) {
770
+ fileId = undefined; drawioClientId = undefined; drawioAck = undefined;
771
+ }
772
+ // Ack FIRST, then re-check: a client that has just confirmed it
773
+ // applied version N must get a 204 on this same beat unless the file
774
+ // has moved on past N since.
775
+ touchDrawioClient(fileId, drawioClientId);
776
+ ackDrawioApplied(fileId, drawioClientId, drawioAck);
777
+ const staleToken = checkDrawioStale(fileId, drawioClientId);
778
+ if (staleToken) {
779
+ return send(res, 200, { stale: true, token: staleToken });
780
+ }
461
781
  res.writeHead(204);
462
782
  return res.end();
463
783
  }
@@ -11,11 +11,14 @@
11
11
  else root.md2docToolbarModel = factory();
12
12
  })(typeof self !== 'undefined' ? self : this, function () {
13
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).
14
+ // The 23-button roster, spec §4 table (`export▾` moved out of scope — no
15
+ // browser-reachable export path) plus the v3.4.0 §3 `save` button. Seven
16
+ // groups, in render order; group order here doubles as separator placement
17
+ // for the caller (a separator sits between consecutive groups).
18
18
  const BUTTON_DEFS = [
19
+ // file
20
+ { id: 'save', group: 'file', label: '💾', icon: '💾', title: '儲存 (Ctrl+S)', toggle: false },
21
+
19
22
  // history
20
23
  { id: 'undo', group: 'history', label: 'Undo', icon: '↶', title: '復原 (Ctrl+Z)', toggle: false },
21
24
  { id: 'redo', group: 'history', label: 'Redo', icon: '↷', title: '重做 (Ctrl+Y)', toggle: false },
@@ -59,11 +62,22 @@ const BUTTON_DEFS = [
59
62
 
60
63
  const BUTTONS = Object.freeze(BUTTON_DEFS.map((b) => Object.freeze(Object.assign({}, b))));
61
64
 
62
- const GROUPS = Object.freeze(['history', 'block', 'inline', 'indent', 'insert', 'view']);
65
+ const GROUPS = Object.freeze(['file', 'history', 'block', 'inline', 'indent', 'insert', 'view']);
63
66
 
64
67
  // Buttons that stay enabled even when there is no block at all (the zeroed
65
68
  // state right after rerenderAll, before any block gets focus/selection).
66
- const NO_BLOCK_ALLOWED = new Set(['undo', 'redo', 'outline', 'preview']);
69
+ // v3.4.0 §3: 'save' is here because unsaved changes have nothing to do with
70
+ // where the caret is — a document can be dirty with no block focused at all.
71
+ // NOTE (ruling P2): this entry does NOT currently change save's behaviour.
72
+ // `state.save.disabled = !dirty` below runs AFTER the no-block loop that
73
+ // reads this set, so it unconditionally overwrites whatever the no-block
74
+ // loop decided for 'save' — today the two never disagree in a way that
75
+ // shows through. This entry is kept anyway because it records the intent
76
+ // ("save must not be gateable on hasBlock") independently of where the
77
+ // `!dirty` line happens to sit; if that line is ever moved above the
78
+ // no-block loop, this set becomes the only thing still enforcing it. Do not
79
+ // delete it as "dead code" without re-checking that ordering.
80
+ const NO_BLOCK_ALLOWED = new Set(['undo', 'redo', 'outline', 'preview', 'save']);
67
81
 
68
82
  // The mark element each inline button toggles. This module cannot ask the
69
83
  // DOM which marks a selection sits in, so the client measures that and hands
@@ -103,6 +117,7 @@ function deriveState(ctx) {
103
117
  const inList = !!c.inList;
104
118
  const listOrdered = !!c.listOrdered;
105
119
  const mode = c.mode;
120
+ const dirty = !!c.dirty;
106
121
  const hasBlock = blockType !== null && blockType !== undefined;
107
122
 
108
123
  const state = {};
@@ -211,21 +226,43 @@ function deriveState(ctx) {
211
226
  }
212
227
  }
213
228
 
229
+ // v3.4.0 §3: the save button IS the save-state indicator — grey when the
230
+ // document matches disk, lit when it does not. The truth stays
231
+ // single-sourced: client.js's `documentIsDirty()` — since v3.4.0 batch3
232
+ // Task 7 that is `stack.isDirty() || burstHasUncommittedEdit() ||
233
+ // waveUnwrittenEdit` — arrives as ctx.dirty and is not recomputed here.
234
+ // This runs before the source-mode override below so that override can
235
+ // still give save its own exemption.
236
+ state.save.disabled = !dirty;
237
+
214
238
  // mode 'source' is the full-document raw-source escape hatch: everything
215
239
  // is disabled except the mode button that got you there (id 'preview',
216
- // which in v3.2.1 is the edit <-> source toggle) and, since T11-2,
217
- // 'outline' (☰). The mode button is still the ONLY way back to 'edit' —
218
- // that has not changed. 'outline' stays reachable because it is what
219
- // opens the sidebar drawer (toggleOutlineSidebar() in
240
+ // which in v3.2.1 is the edit <-> source toggle), since T11-2 'outline'
241
+ // (☰), and since v3.4.0 §3 'save'. The mode button is still the ONLY way
242
+ // back to 'edit' — that has not changed. 'outline' stays reachable because
243
+ // it is what opens the sidebar drawer (toggleOutlineSidebar() in
220
244
  // lib/editor/client.js), and the fixed `.sidebar-toggle` button that used
221
245
  // to reach the drawer another way is hidden in edit mode entirely (see the
222
246
  // T11-2 comment in lib/md2doc.js) — a disabled ☰ here would leave source
223
- // mode with no way to open that drawer at all. This override still runs
224
- // last and still overrides every disabled flag computed above for every
225
- // other button; do not narrow it for anything but these two ids.
247
+ // mode with no way to open that drawer at all. 'save' stays reachable
248
+ // because source mode can still hold unsaved changes (the user can type
249
+ // into the source textarea) — see the per-button comment below for the
250
+ // exact shape of the exemption. This override still runs last and still
251
+ // overrides every disabled flag computed above for every other button; do
252
+ // not narrow it for anything but these three ids.
226
253
  if (mode === 'source') {
227
254
  for (const b of BUTTONS) {
228
- state[b.id].disabled = b.id !== 'preview' && b.id !== 'outline';
255
+ if (b.id === 'preview' || b.id === 'outline') { state[b.id].disabled = false; continue; }
256
+ // v3.4.0 §3: save is a third exemption, for the same shape of reason
257
+ // outline already had one — source mode can still hold unsaved
258
+ // changes (the user can type into the source textarea), so forcing
259
+ // the only save button grey here would make a mode that holds
260
+ // unsaved work with no way out of it. The exemption is from the MODE
261
+ // forcing it grey, not from being grey — a clean document still shows
262
+ // it disabled in source mode (see the `state.save.disabled = !dirty`
263
+ // line above, which already ran and is left untouched here).
264
+ if (b.id === 'save') continue;
265
+ state[b.id].disabled = true;
229
266
  }
230
267
  }
231
268