@helping-ai-workflow/md2doc 3.17.0 → 3.17.2

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.
@@ -108,6 +108,35 @@
108
108
  return { lines: r.lines, blocks, op };
109
109
  }
110
110
 
111
+ // v3.17.2 A3 (R65, v3.15.0 N5): do two list lines open items of the SAME
112
+ // list kind, i.e. would markdown read them as one list with nothing but
113
+ // blank lines between them? Same bullet character, or both ordered with
114
+ // the same delimiter (`.` / `)`); `- a` then `* b` are two lists however
115
+ // they are spaced.
116
+ const LIST_KIND_RE = /^ {0,3}(?:([-*+])|\d{1,9}([.)]))(?:[ \t]|$)/;
117
+ function sameListKind(lineA, lineB) {
118
+ const a = LIST_KIND_RE.exec(String(lineA == null ? '' : lineA));
119
+ const b = LIST_KIND_RE.exec(String(lineB == null ? '' : lineB));
120
+ if (!a || !b) return false;
121
+ return a[1] ? a[1] === b[1] : (!!a[2] && a[2] === b[2]);
122
+ }
123
+ // v3.17.2 A3: commitRangeRemoval()'s twin for a block that stood between
124
+ // two lists of the same kind: its lines and EVERY blank line on both sides
125
+ // go, so the two lists join TIGHT (one blank between them would make the
126
+ // joined list loose). One op; the shiftBlocks anchor as commitRangeRemoval().
127
+ function commitTightRemoval(state, startLine, endLine) {
128
+ const refusal = refuseInvertedRange(state, 'commitTightRemoval', startLine, endLine);
129
+ if (refusal) return refusal;
130
+ const anchor = state.blocks.filter((b) => b.endLine <= endLine).pop();
131
+ const span = growOverBlanks(state.lines, startLine, endLine, 'both');
132
+ const before = state.lines.slice(span[0] - 1, span[1]);
133
+ const op = { startLine: span[0], endLine: span[1], before, after: [] };
134
+ const r = ops.replaceLines(state.lines, span[0], span[1], []);
135
+ const blocks = anchor ? ops.shiftBlocks(state.blocks, anchor.id, r.delta) : state.blocks;
136
+ state.stack.push(op);
137
+ return { lines: r.lines, blocks, op };
138
+ }
139
+
111
140
  // Task 4 fix (review, Important): removing the LAST remaining item of a
112
141
  // list block (empty-Enter on a list with exactly one item) serializes to
113
142
  // '' — committing that through commitEdit() would replace the block's
@@ -874,7 +903,7 @@
874
903
  }
875
904
 
876
905
  if (typeof module === 'object' && module.exports) {
877
- module.exports = { pasteIsAllListLines, LIST_LINE_RE, extractBlockSource, commitEdit, commitRangeEdit, commitRangeRemoval, commitListBlockRemoval, commitBlockInsertion, commitInsertionAt, commitSplit, commitShortcut, growOverBlanks, commitTightInsert, planBlockMove, commitBlockMove, reorderSpanRange, spanMoveRange, spanIndentsAreAnchored, blockMoveSeamRefusal, rollbackFailedRender, transientCommitMd, headingDepthOf, withHeadingDepth, resolveDrawioLanding, shiftBlocksAfterBodyEdit, waveDiscardIsSafe, waveOfferHolds, waveRedoHolds };
906
+ module.exports = { pasteIsAllListLines, LIST_LINE_RE, extractBlockSource, commitEdit, commitRangeEdit, commitRangeRemoval, sameListKind, commitTightRemoval, commitListBlockRemoval, commitBlockInsertion, commitInsertionAt, commitSplit, commitShortcut, growOverBlanks, commitTightInsert, planBlockMove, commitBlockMove, reorderSpanRange, spanMoveRange, spanIndentsAreAnchored, blockMoveSeamRefusal, rollbackFailedRender, transientCommitMd, headingDepthOf, withHeadingDepth, resolveDrawioLanding, shiftBlocksAfterBodyEdit, waveDiscardIsSafe, waveOfferHolds, waveRedoHolds };
878
907
  return; // node: pure core only
879
908
  }
880
909
 
@@ -1355,7 +1384,7 @@
1355
1384
  // rather than surface.
1356
1385
  function burstHasUncommittedEdit() {
1357
1386
  return !!(currentBurst && currentBurst.editEl &&
1358
- burstBaselineHtml(currentBurst.editEl) !== currentBurst.original);
1387
+ !sameBurstHtml(burstBaselineHtml(currentBurst.editEl), currentBurst.original));
1359
1388
  }
1360
1389
 
1361
1390
  // v3.4.0 §3: single source of truth for "does the document have anything
@@ -1389,8 +1418,18 @@
1389
1418
  // v3.17.0 T2 fix round 1 (R47): code typed into an open in-place edit is
1390
1419
  // unsaved work too — it reaches `lines` only when the edit commits. T5
1391
1420
  // (R56 (d)): so is the source typed into an open diagram panel.
1421
+ // v3.17.2 B1 (v3.17.0 final review T2 L74): and so is the text typed into
1422
+ // an open raw box. Measured before: type in the box, and the status
1423
+ // read clean and a tab close raised no dialog — the typing was lost.
1392
1424
  return stack.isDirty() || burstHasUncommittedEdit() || waveUnwrittenEdit || pendingStructure !== null ||
1393
- codeEditHasUncommittedText() || diagramPanelHasUncommittedText();
1425
+ codeEditHasUncommittedText() || diagramPanelHasUncommittedText() || rawEditHasUncommittedText();
1426
+ }
1427
+ // Guarded the same way, for the same reason (the beforeunload handler).
1428
+ function rawEditHasUncommittedText() {
1429
+ try {
1430
+ const ed = activeEditor;
1431
+ return !!ed && ed.kind === 'raw' && ed.blockEl.isConnected && ed.hasChanges();
1432
+ } catch (e) { return false; }
1394
1433
  }
1395
1434
  // Guarded the way burstHasUncommittedEdit() is: this also runs inside the
1396
1435
  // beforeunload handler, where a throw would drop the navigation guard.
@@ -3268,7 +3307,7 @@
3268
3307
  }
3269
3308
  function surfaceDrawnAs(original) {
3270
3309
  const found = Array.prototype.filter.call(contentEl.querySelectorAll('.ed-wys-armed'),
3271
- (el) => !el.closest('[data-ed-transient]') && burstBaselineHtml(el) === original);
3310
+ (el) => !el.closest('[data-ed-transient]') && sameBurstHtml(burstBaselineHtml(el), original));
3272
3311
  return found.length === 1 ? found[0] : null;
3273
3312
  }
3274
3313
  async function restoreAfterReconcile(carry, cap) {
@@ -4209,6 +4248,8 @@
4209
4248
  // 一行空白是這個編輯器自己放的,不是使用者打的,把它算成「有變更」
4210
4249
  // 會讓「開起來看一眼就關掉」變成一次靜默的寫檔。
4211
4250
  hasChanges: () => ta.value !== seed.value,
4251
+ // v3.17.2 B1: what rawEditHasUncommittedText() asks for.
4252
+ kind: 'raw',
4212
4253
  // v3.15.0 B5 fix round 2 (R80): the session by value, in the
4213
4254
  // discardedRawEdit shape, for reconcileScreen() to reopen it.
4214
4255
  snapshot: () => ({ blockId, range, startLine, endLine, source,
@@ -4251,6 +4292,7 @@
4251
4292
  // of repainting the same bytes, not a different edit.
4252
4293
  if (rangeMode) {
4253
4294
  if (activeEditor && activeEditor.blockEl === blockEl) activeEditor = null;
4295
+ setDirty(); // v3.17.2 B1: the box's typing is no longer unsaved work
4254
4296
  await safeRerenderAll();
4255
4297
  return;
4256
4298
  }
@@ -4265,6 +4307,7 @@
4265
4307
  // never stomp some OTHER block's activeEditor set after this one.
4266
4308
  if (activeEditor && activeEditor.blockEl === blockEl) activeEditor = null;
4267
4309
  blockEl.innerHTML = original;
4310
+ setDirty(); // v3.17.2 B1: the box's typing is no longer unsaved work
4268
4311
  // No re-wiring needed here (unlike the old per-block gutter): block
4269
4312
  // selection / the edit bar are handled by ONE delegated `document`
4270
4313
  // click listener (see wireBlockSelection() below), so this
@@ -4460,7 +4503,9 @@
4460
4503
  return leave().then((ok) => { if (ok) handCaretOnBelow(tail); }).catch(showUnexpectedFailure);
4461
4504
  }
4462
4505
 
4463
- ta.addEventListener('input', () => autoSize(ta));
4506
+ // v3.17.2 B1: setDirty() too, as the code edit and the diagram panel do
4507
+ // (rawEditHasUncommittedText() is the predicate's half).
4508
+ ta.addEventListener('input', () => { autoSize(ta); setDirty(); });
4464
4509
  ta.addEventListener('keydown', (e) => {
4465
4510
  if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
4466
4511
  e.preventDefault();
@@ -4807,7 +4852,8 @@
4807
4852
  const s = {
4808
4853
  blockEl, host, fence, lang: fence.info, body, source,
4809
4854
  blockId: rec.id, startLine: rec.startLine, endLine: rec.endLine,
4810
- history: new codeEditLib.TextHistory({ text: body, selStart: caret, selEnd: caret }),
4855
+ history: carriedCodeEditHistory(o, body, caret) ||
4856
+ new codeEditLib.TextHistory({ text: body, selStart: caret, selEnd: caret }),
4811
4857
  originalHtml: host.innerHTML, shown: null, timer: null, composing: false,
4812
4858
  plainOnly: true, inflight: null, listeners: new AbortController(), editor: null,
4813
4859
  };
@@ -4855,6 +4901,18 @@
4855
4901
  return true;
4856
4902
  }
4857
4903
 
4904
+ // v3.17.2 B4 (v3.17.0 final review M1): the Ctrl+S / language re-entry
4905
+ // hands the closing edit's own history to the one it opens, while the block
4906
+ // holds exactly the text that history ends on. Measured before: type, Ctrl+S,
4907
+ // and Ctrl+Z inside the reopened edit did nothing (a fresh history had
4908
+ // nothing to step back to).
4909
+ function carriedCodeEditHistory(o, body, caret) {
4910
+ const h = o.history;
4911
+ if (!h || o.historyText !== body) return null;
4912
+ h.push({ text: body, selStart: caret, selEnd: caret });
4913
+ return h;
4914
+ }
4915
+
4858
4916
  function codeEditSnapshot(s) {
4859
4917
  const text = codeEditText(s);
4860
4918
  const sel = codeEditSel(s);
@@ -5013,6 +5071,17 @@
5013
5071
  const caret = sel ? sel[0] : codeEditText(s).length;
5014
5072
  const startLine = s.startLine;
5015
5073
  if (opts.lang !== undefined) s.lang = codeEditLib.setFenceLang(s.lang, opts.lang);
5074
+ const history = s.history;
5075
+ const historyText = codeEditText(s);
5076
+ // v3.17.2 B5 (v3.17.0 final review M2): the block opened again is the one
5077
+ // this commit leaves at `startLine` — its lines read as what was written
5078
+ // (or, unchanged, as what was there). An untouched "/" insert is
5079
+ // discarded on save (R19), so nothing of its own is left to open, and the
5080
+ // block that moved up into its line is someone else's. Measured before:
5081
+ // Ctrl+S on the untouched insert opened the code block below it, and the
5082
+ // next keys went into that block.
5083
+ const discarding = !!(pristineInsert && pristineInsert.blockId === s.blockId) && !s.editor.hasChanges();
5084
+ const reopenAs = [codeEditSnapshot(s).value, s.source];
5016
5085
  const hold = { keys: [], clickedAway: false };
5017
5086
  codeEditReenterHold = hold;
5018
5087
  let ok;
@@ -5023,9 +5092,11 @@
5023
5092
  }
5024
5093
  const keys = (opts.carry || []).concat(hold.keys);
5025
5094
  let target = liveCodeEdit() === s ? s : null;
5026
- if (!target && ok && !hold.clickedAway && s.lastResult !== RENDER_SUPERSEDED) {
5095
+ if (!target && ok && !hold.clickedAway && s.lastResult !== RENDER_SUPERSEDED && !discarding) {
5027
5096
  const el = blockElAtLine(startLine);
5028
- if (el && await editCodeInPlace(el, { offset: caret, keepScroll: true })) target = liveCodeEdit();
5097
+ const rec = el ? blockRecOf(el) : null;
5098
+ const reads = rec && rec.endLine >= rec.startLine ? lines.slice(rec.startLine - 1, rec.endLine).join('\n') : null;
5099
+ if (el && reopenAs.indexOf(reads) !== -1 && await editCodeInPlace(el, { offset: caret, keepScroll: true, history, historyText })) target = liveCodeEdit();
5029
5100
  }
5030
5101
  if (ok && opts.save) saveAfterLastSave();
5031
5102
  if (!target || hold.clickedAway) {
@@ -5378,7 +5449,7 @@
5378
5449
  // The CODE_LANGS entry id an info string resolves to ('' for none), or null
5379
5450
  // for a word the table does not know.
5380
5451
  function codeLangIdOf(info) {
5381
- const word = String(info || '').trim().split(/\s+/)[0].toLowerCase();
5452
+ const word = codeEditLib.langWord(info).toLowerCase(); // v3.17.2 A2: an attribute is no language
5382
5453
  if (!word) return '';
5383
5454
  const hit = codeEditLib.CODE_LANGS.find((l) => (l.id && l.id === word) || l.aliases.indexOf(word) !== -1);
5384
5455
  return hit ? hit.id : null;
@@ -9857,7 +9928,7 @@
9857
9928
  }
9858
9929
 
9859
9930
  function typedInBurst(burst) {
9860
- if (burst.transient || burstBaselineHtml(burst.editEl) === burst.original) return null;
9931
+ if (burst.transient || sameBurstHtml(burstBaselineHtml(burst.editEl), burst.original)) return null;
9861
9932
  burst.history.flushTyping();
9862
9933
  return { blockId: burst.blockId, text: burstCommitText(burst) };
9863
9934
  }
@@ -12242,6 +12313,56 @@
12242
12313
  return clone.innerHTML;
12243
12314
  }
12244
12315
 
12316
+ // v3.17.2 A6: "is this surface edited?" asks this, never `===` on two
12317
+ // burstBaselineHtml() strings. An inline style is markup any page script or
12318
+ // extension may write on an editable element, and only two of its
12319
+ // declarations are the document's: `text-align` (a column's alignment,
12320
+ // renderer.table and setColumnAlign()) and the editor's own `--ed-*`
12321
+ // custom properties. MEASURED before: Playwright's screenshot (caret
12322
+ // 'hide') writes `style="caret-color: transparent !important;"` on every
12323
+ // editable element and leaves `style=""` behind; with the caret in a table
12324
+ // cell the status went dirty and leaving the cell committed the table
12325
+ // through its serializer, rewriting `|a|b|` as `| a | b |`. The baseline
12326
+ // strings themselves are untouched: burst histories restore them as HTML.
12327
+ // An allow-list of the declarations that count, so a style nobody has
12328
+ // thought about reads as not an edit only when it is a style; any other
12329
+ // foreign attribute still counts, as before.
12330
+ const BURST_KEPT_STYLE = /^(?:text-align|--ed-[\w-]+)$/;
12331
+ const burstFingerprints = new Map();
12332
+ function burstEditFingerprint(html) {
12333
+ const hit = burstFingerprints.get(html);
12334
+ if (hit !== undefined) return hit;
12335
+ const tpl = document.createElement('template');
12336
+ tpl.innerHTML = html;
12337
+ Array.prototype.forEach.call(tpl.content.querySelectorAll('[style]'), (el) => {
12338
+ const kept = [];
12339
+ String(el.getAttribute('style')).split(';').forEach((decl) => {
12340
+ const at = decl.indexOf(':');
12341
+ if (at === -1) return;
12342
+ const prop = decl.slice(0, at).trim();
12343
+ const lower = prop.toLowerCase();
12344
+ if (BURST_KEPT_STYLE.test(prop.indexOf('--') === 0 ? prop : lower)) {
12345
+ kept.push((prop.indexOf('--') === 0 ? prop : lower) + ':' + decl.slice(at + 1).trim());
12346
+ }
12347
+ });
12348
+ if (kept.length) el.setAttribute('style', kept.join(';'));
12349
+ else el.removeAttribute('style');
12350
+ });
12351
+ const out = tpl.innerHTML;
12352
+ if (burstFingerprints.size >= 4) burstFingerprints.clear();
12353
+ burstFingerprints.set(html, out);
12354
+ return out;
12355
+ }
12356
+ // Both sides go through the same parse and serialize whenever either holds
12357
+ // a style, so a string the page serialized and one re-serialized here are
12358
+ // never compared with each other.
12359
+ function sameBurstHtml(a, b) {
12360
+ if (a === b) return true;
12361
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
12362
+ if (a.indexOf('style=') === -1 && b.indexOf('style=') === -1) return false;
12363
+ return burstEditFingerprint(a) === burstEditFingerprint(b);
12364
+ }
12365
+
12245
12366
  function startBurst(editEl) {
12246
12367
  // v3.15.0 B5 fix round 4 (R86 (2)): never on a screen that is not `lines`.
12247
12368
  if (burstsLocked()) return;
@@ -12379,7 +12500,7 @@
12379
12500
  // `commitResult.op === null` no-op path below, without ever reaching
12380
12501
  // the serializer (and therefore without ever risking a canonicalizing
12381
12502
  // rewrite of untouched content).
12382
- if (burstBaselineHtml(burst.editEl) === burst.original) {
12503
+ if (sameBurstHtml(burstBaselineHtml(burst.editEl), burst.original)) {
12383
12504
  endBurstWithoutResolve();
12384
12505
  // §10-gap fix (review): untouched AND was pristine — an ordinary
12385
12506
  // "insert +, click away without typing" changed-my-mind. Auto-remove
@@ -12645,6 +12766,29 @@
12645
12766
  return true;
12646
12767
  }
12647
12768
 
12769
+ // v3.17.2 A3 (R65, v3.15.0 N5): does the emptied paragraph stand between
12770
+ // two tight lists of the same kind? Then its removal joins them tight, as
12771
+ // Notion does. Measured before: `- a\n\nMid.\n\n- b` with `Mid.` emptied
12772
+ // became `- a\n\n- b`, one LOOSE list. A loose list on either side keeps
12773
+ // the blank line (the joined list is loose either way, and its spacing is
12774
+ // the user's); lists of different kinds stay two lists however they are
12775
+ // spaced, so they keep it too.
12776
+ function emptiedParagraphJoinsLists(rec, above) {
12777
+ const below = blocks.find((b) => b.startLine > rec.endLine) || null;
12778
+ if (!above || !below) return false;
12779
+ const upEl = blockElById(above.id);
12780
+ const downEl = blockElById(below.id);
12781
+ if (!upEl || !downEl || upEl.getAttribute('data-block-type') !== 'li' ||
12782
+ downEl.getAttribute('data-block-type') !== 'li') return false;
12783
+ const up = listRunOf(upEl);
12784
+ const down = listRunOf(downEl);
12785
+ if (!up.length || !down.length) return false;
12786
+ if (up.concat(down).some((el) => el.getAttribute('data-list-loose') === '1')) return false;
12787
+ const a = blockRecOf(up[0]);
12788
+ const b = blockRecOf(down[0]);
12789
+ return !!a && !!b && sameListKind(lines[a.startLine - 1], lines[b.startLine - 1]);
12790
+ }
12791
+
12648
12792
  // R95: the emptied paragraph's removal, one op. Same failure contract as the
12649
12793
  // paragraph commit below: a failed render rolls the op back and leaves the
12650
12794
  // burst open. When focus is still inside (Ctrl+S, a toolbar press) a
@@ -12655,7 +12799,9 @@
12655
12799
  if (!rec) { endBurstWithoutResolve(); return true; }
12656
12800
  const focusInside = burst.blockEl.contains(document.activeElement);
12657
12801
  const preceding = blocks.filter((b) => b.id !== rec.id && b.endLine < rec.startLine).pop() || null;
12658
- const result = commitRangeRemoval({ lines, blocks, stack }, rec.startLine, rec.endLine);
12802
+ const result = emptiedParagraphJoinsLists(rec, preceding)
12803
+ ? commitTightRemoval({ lines, blocks, stack }, rec.startLine, rec.endLine)
12804
+ : commitRangeRemoval({ lines, blocks, stack }, rec.startLine, rec.endLine);
12659
12805
  if (result.op === null) { endBurstWithoutResolve(); return true; }
12660
12806
  const prevLines = lines;
12661
12807
  lines = result.lines;
@@ -13508,7 +13654,7 @@
13508
13654
  // here too.
13509
13655
  function bystanderCarryOver(span, mutatedEl) {
13510
13656
  const dirtyId = (currentBurst && currentBurst.editEl &&
13511
- burstBaselineHtml(currentBurst.editEl) !== currentBurst.original)
13657
+ !sameBurstHtml(burstBaselineHtml(currentBurst.editEl), currentBurst.original))
13512
13658
  ? String(currentBurst.blockId) : null;
13513
13659
  const out = {};
13514
13660
  let any = false;
@@ -13935,7 +14081,7 @@
13935
14081
  const d = discardedBurst;
13936
14082
  discardedBurst = null;
13937
14083
  if (!d) return false;
13938
- if (!document.body.contains(d.editEl) || burstBaselineHtml(d.editEl) !== d.reverted) {
14084
+ if (!document.body.contains(d.editEl) || !sameBurstHtml(burstBaselineHtml(d.editEl), d.reverted)) {
13939
14085
  d.history.dispose();
13940
14086
  return false;
13941
14087
  }
@@ -14035,6 +14181,24 @@
14035
14181
  }
14036
14182
  const blockEl = blockElById(d.blockId);
14037
14183
  if (!blockEl) return false;
14184
+ // v3.17.2 B3 (R75): a diagram panel's Esc opens the panel again, with
14185
+ // what was typed and the preview it had, so the edit goes on where it
14186
+ // stopped. Measured before: the raw box over the whole fence, no preview.
14187
+ // A panel that does not open (the block no longer reads as that diagram)
14188
+ // falls back to the raw box below.
14189
+ if (d.diagram) {
14190
+ const opened = await openDiagramPanel(blockEl, { kind: d.diagram.kind, seed: {
14191
+ value: d.diagram.value, selStart: d.diagram.selStart, selEnd: d.diagram.selEnd,
14192
+ previewHtml: d.diagram.previewHtml } });
14193
+ const live = blockElById(d.blockId);
14194
+ const panel = liveDiagramPanel();
14195
+ if (opened && panel && live && panel.blockEl === live) {
14196
+ scheduleDiagramPreview(panel);
14197
+ setDirty();
14198
+ return true;
14199
+ }
14200
+ if (!live || live.querySelector('.ed-diagram-panel')) return false;
14201
+ }
14038
14202
  await openRawEditor(blockEl, d.range);
14039
14203
  // Re-queried rather than reusing `blockEl` above: openRawEditor()'s own
14040
14204
  // guard may have resolved a DIFFERENT open session first (switchAwayFrom()),
@@ -14122,7 +14286,7 @@
14122
14286
  // to resolve — nothing can change either condition between this check
14123
14287
  // and that resolution running.
14124
14288
  const willAutoRemove = !!(pristineInsert && pristineInsert.blockId === burst.blockId &&
14125
- burstBaselineHtml(burst.editEl) === burst.original);
14289
+ sameBurstHtml(burstBaselineHtml(burst.editEl), burst.original));
14126
14290
  switchAwayFrom().then((ok) => { if (ok && !willAutoRemove) undo(); });
14127
14291
  }
14128
14292
 
@@ -15240,7 +15404,7 @@
15240
15404
  async function liBackspaceStructure(li) {
15241
15405
  const burst = currentBurst;
15242
15406
  if (burst && burst.blockType === 'li' && burst.blockEl === li &&
15243
- burstBaselineHtml(burst.editEl) !== burst.original) {
15407
+ !sameBurstHtml(burstBaselineHtml(burst.editEl), burst.original)) {
15244
15408
  const rec = blockRecOf(li);
15245
15409
  if (!rec) return;
15246
15410
  if (!(await switchAwayFrom())) return;
@@ -16553,7 +16717,7 @@
16553
16717
  // auto-remove an untouched pristine table insert, so a chained undo()
16554
16718
  // must be skipped or it cascades one op too far.
16555
16719
  const willAutoRemove = !!(pristineInsert && pristineInsert.blockId === burst.blockId &&
16556
- burstBaselineHtml(tableEl) === burst.original);
16720
+ sameBurstHtml(burstBaselineHtml(tableEl), burst.original));
16557
16721
  switchAwayFrom().then((ok) => { if (ok && !willAutoRemove) undo(); });
16558
16722
  }
16559
16723
 
@@ -21623,10 +21787,19 @@
21623
21787
  let saveGen = 0;
21624
21788
  let savesInFlight = 0;
21625
21789
  let lastSave = null; // v3.17.0 T9b: the newest save's round trip (saveAfterLastSave())
21790
+ // v3.17.2 B2 (R66): every save goes out after the one before it has
21791
+ // answered, never alongside it. Sent alongside, it carried the baseline
21792
+ // the first reply was about to replace, the server answered 409 (sendSave()
21793
+ // says why) and the conflict card went up over a file this page had just
21794
+ // saved. MEASURED before: two Ctrl+S 50 ms apart with a slow reply — saves
21795
+ // [200, 409], status failed, the conflict card up. The queued save reads
21796
+ // `lines` and `mtimeMs` when it is sent, so it saves whatever is on screen
21797
+ // by then, against the baseline the first reply left.
21626
21798
  async function save() {
21627
21799
  saveGen++;
21628
21800
  savesInFlight++;
21629
- const sending = sendSave();
21801
+ const prev = lastSave;
21802
+ const sending = (prev ? prev.then(() => {}, () => {}) : Promise.resolve()).then(() => sendSave());
21630
21803
  lastSave = sending;
21631
21804
  try { return await sending; } finally { savesInFlight--; }
21632
21805
  }
@@ -21730,22 +21903,15 @@
21730
21903
  if (ok) return save();
21731
21904
  }
21732
21905
 
21733
- // v3.17.0 T9b: a save asked for while an earlier one may still be on its
21734
- // way — a Ctrl+S held through a code edit's or diagram panel's re-entry, a
21735
- // second Ctrl+S while the first one's resolution is out — is sent once that
21736
- // one has answered. Sent alongside it, its request carried the baseline the
21737
- // first reply was about to replace, the server answered 409 (sendSave()'s
21738
- // note says why) and the conflict card went up over a file this page had
21739
- // just saved. A queue in the caller, not a lock in sendSave(): those callers
21740
- // asked for their save while the first was not even sent yet, whereas a
21741
- // Ctrl+S pressed on its own while a save is out still sends at once
21742
- // (test/editor-journey.test.js's N5-two-saves drives that path), and no
21743
- // save waits behind a request that never answers unless it was queued here.
21906
+ // v3.17.0 T9b added this queue for the callers that ask for a save while an
21907
+ // earlier one may still be on its way (a Ctrl+S held through a code edit's
21908
+ // or diagram panel's re-entry, a second Ctrl+S while the first one's
21909
+ // resolution is out). v3.17.2 B2 moved the queue into save() itself, so a
21910
+ // Ctrl+S pressed on its own while a save is out waits too (EJ
21911
+ // N5-two-saves); this is save() under the name those callers use. A save
21912
+ // queued behind a request that never answers waits with it.
21744
21913
  function saveAfterLastSave() {
21745
- const prev = lastSave;
21746
- const next = (prev ? prev.then(() => {}, () => {}) : Promise.resolve()).then(() => save());
21747
- lastSave = next;
21748
- return next;
21914
+ return save();
21749
21915
  }
21750
21916
 
21751
21917
  // v3.4.0 §3: the save button's own caret rescue. commitThenSave() above
@@ -26341,7 +26507,12 @@
26341
26507
  if (wasPristine) pristineInsert = null;
26342
26508
  if (!wasPristine && s.ta.value !== s.body) {
26343
26509
  dropDiscardedBurst();
26344
- discardedRawEdit = diagramPanelSnapshot(s);
26510
+ // v3.17.2 B3 (R75): plus what the panel needs to open again on it
26511
+ // (restoreDiscardedRawEdit()); the raw-box shape stays the fallback.
26512
+ discardedRawEdit = Object.assign(diagramPanelSnapshot(s), { diagram: {
26513
+ kind: s.kind, value: s.ta.value, selStart: s.ta.selectionStart, selEnd: s.ta.selectionEnd,
26514
+ previewHtml: s.preview.innerHTML,
26515
+ } });
26345
26516
  }
26346
26517
  endDiagramPanelVisual(s);
26347
26518
  if (wasPristine) return await discardPristineInsert();
@@ -34,10 +34,18 @@ function firstWord(info) {
34
34
  return String(info || '').trim().split(/\s+/)[0] || '';
35
35
  }
36
36
 
37
- // The chip's text for a fence info string: the first word's language name,
38
- // aliases resolved; empty is 純文字; an unknown word is shown as written.
37
+ // v3.17.2 A2 (R53): the info string's language word — its first word, unless
38
+ // that word holds `=`: then it is an attribute (`title="a"`), and the fence
39
+ // has no language. '' when there is none.
40
+ function langWord(info) {
41
+ const w = firstWord(info);
42
+ return w.indexOf('=') === -1 ? w : '';
43
+ }
44
+
45
+ // The chip's text for a fence info string: the language word's name, aliases
46
+ // resolved; none is 純文字; an unknown word is shown as written.
39
47
  function langLabel(info) {
40
- const word = firstWord(info);
48
+ const word = langWord(info);
41
49
  if (!word) return '純文字';
42
50
  const w = word.toLowerCase();
43
51
  for (const l of CODE_LANGS) {
@@ -55,12 +63,16 @@ function filterLangs(query) {
55
63
  l.aliases.some((a) => a.indexOf(q) === 0));
56
64
  }
57
65
 
58
- // Replace the info string's first word; '' removes it. The other words are
59
- // kept, single-spaced.
66
+ // Replace the info string's language word; '' removes it. What follows it
67
+ // is kept as written (v3.17.2 A2: a quoted value's inner spaces included;
68
+ // before, the words were re-joined single-spaced). An info string with no
69
+ // language word (empty, or starting with an attribute) gets `lang` in front.
60
70
  function setFenceLang(info, lang) {
61
- const words = String(info || '').trim().split(/\s+/).filter(Boolean);
62
- const rest = words.slice(1);
63
- return (lang ? [lang].concat(rest) : rest).join(' ');
71
+ const t = String(info || '').trim();
72
+ const word = langWord(t);
73
+ const rest = word ? t.slice(word.length).replace(/^\s+/, '') : t;
74
+ if (!lang) return rest;
75
+ return rest ? lang + ' ' + rest : lang;
64
76
  }
65
77
 
66
78
  // blockLines[0] must open a fence (up to 3 spaces, then 3+ backticks or
@@ -179,6 +191,6 @@ class TextHistory {
179
191
  }
180
192
  }
181
193
 
182
- return { CODE_LANGS, langLabel, filterLangs, parseFence, buildFence, setFenceLang,
194
+ return { CODE_LANGS, langWord, langLabel, filterLangs, parseFence, buildFence, setFenceLang,
183
195
  newlineWithIndent, indentLines, TextHistory };
184
196
  });
@@ -2503,9 +2503,11 @@ function sameLane(a, b) {
2503
2503
  * Paint one cycle of one lane, addressed by display index.
2504
2504
  *
2505
2505
  * `ch` is a single character: a level, a value character, `|` for a gap, or `.`
2506
- * to make the cycle continue the one in front of it. Anything else — a longer
2507
- * string, a cycle outside the lane, a `.` or `|` at cycle 0 where there is
2508
- * nothing to continue or to draw over — answers the doc unchanged.
2506
+ * to make the cycle continue the one in front of it. A cycle past the lane's
2507
+ * end grows the lane first, continuing its last level up to that cycle (see
2508
+ * `paint`). Anything else — a longer string, a negative or fractional cycle, a
2509
+ * `.` or `|` at cycle 0 where there is nothing to continue or to draw over —
2510
+ * answers the doc unchanged.
2509
2511
  *
2510
2512
  * A newly painted value cell keeps the label of the value cell it replaced and
2511
2513
  * otherwise starts blank; a cell painted with something that is not a value
@@ -2542,12 +2544,20 @@ function paint(doc, laneIndex, from, to, ch, asRun) {
2542
2544
  lo = hi;
2543
2545
  hi = swap;
2544
2546
  }
2545
- if (!isIndex(lo, n) || !isIndex(hi, n)) return null;
2547
+ if (lo < 0) return null;
2546
2548
  // A repeater at cycle 0 has nothing to repeat, and the engine answers that
2547
2549
  // by drawing the run and the cycle after it as x. Refusing is the honest
2548
2550
  // reply to a brush that cannot mean anything where it was aimed.
2549
2551
  if (lo === 0 && (ch === HELD_CHAR || ch === GAP_CHAR)) return null;
2550
- const anchored = anchorAfter(model.cells, hi);
2552
+ // Past the lane's end: continue its last level up to `hi` first, then
2553
+ // paint. A lane shorter than the diagram is ordinary (新增訊號 writes one
2554
+ // cycle, hand-written lanes differ in length) and the editor's grid is as
2555
+ // wide as the whole diagram, so `geometry.cellAt` hands out these cells on
2556
+ // purpose; refusing them here left every click past a short lane's end
2557
+ // silently doing nothing. How far past is bounded by the caller (the
2558
+ // grid), not here.
2559
+ const grown = hi < n ? model.cells : model.cells.concat(fillerCells(model.cells, n, hi + 1 - n));
2560
+ const anchored = anchorAfter(grown, hi);
2551
2561
  const before = resolveCells(anchored);
2552
2562
  const heldLevel = lo > 0 ? before.levels[lo - 1] : '';
2553
2563
  const cells = anchored.slice();
@@ -1690,6 +1690,14 @@
1690
1690
  if (added) notify('notice', lead + where);
1691
1691
  return added;
1692
1692
  }
1693
+ /** The lane 新增訊號 writes: `x` across the whole diagram, not one cycle
1694
+ * of it. A one-cycle lane draws a single x and then nothing, which read
1695
+ * as a broken signal (user report 2026-10-28), and every cycle after it
1696
+ * had to be grown by painting before it showed anything. */
1697
+ function newSignalLane() {
1698
+ const cycles = layoutNow().cycles;
1699
+ return { name: '', wave: 'x' + '.'.repeat(Math.max(0, cycles - 1)) };
1700
+ }
1693
1701
  function addLaneFromToolbar(name, lane) {
1694
1702
  addLaneAt(signalInsertAt(), name, lane);
1695
1703
  }
@@ -1705,7 +1713,7 @@
1705
1713
  const el = panels.menu('ed-wave-signal-pop', [
1706
1714
  { key: 'ed-wave-signal-add', label: '新增訊號',
1707
1715
  title: '在選取的 lane 後面加一條(沒有選取就加在最前面)—— 會落在' + where,
1708
- run: function () { closePopover(true); addLaneFromToolbar('add-lane', { name: '', wave: 'x' }); } },
1716
+ run: function () { closePopover(true); addLaneFromToolbar('add-lane', newSignalLane()); } },
1709
1717
  { key: 'ed-wave-signal-blank', label: '新增空白列',
1710
1718
  title: '在選取的 lane 後面插入一個空白列(WaveJSON 的 {},用來空出間距)—— 會落在' + where,
1711
1719
  run: function () { closePopover(true); addLaneFromToolbar('add-spacer', {}); } },
@@ -1950,7 +1958,7 @@
1950
1958
  const row = layoutNow().lanes[i];
1951
1959
  const actions = {
1952
1960
  copy: picked(function () { duplicateLaneAt(i); }),
1953
- addBelow: picked(function () { addLaneAt(i + 1, 'add-lane', { name: '', wave: 'x' }); }),
1961
+ addBelow: picked(function () { addLaneAt(i + 1, 'add-lane', newSignalLane()); }),
1954
1962
  blankBelow: picked(function () { addLaneAt(i + 1, 'add-spacer', {}); }),
1955
1963
  up: picked(function () { moveLaneBy(i, -1); }),
1956
1964
  down: picked(function () { moveLaneBy(i, 1); }),
@@ -2473,7 +2481,7 @@
2473
2481
  // signal at the end — the one insert position the ⠿ menus (which add
2474
2482
  // BELOW a row) and 訊號 (after the selection) do not make obvious.
2475
2483
  panels.toolButton('ed-wave-add-lane', '+ 新增訊號', '在最後面加一條訊號', function () {
2476
- addLaneAt(layoutNow().lanes.length, 'add-lane', { name: '', wave: 'x' });
2484
+ addLaneAt(layoutNow().lanes.length, 'add-lane', newSignalLane());
2477
2485
  }, stage);
2478
2486
  // One canvas per editor session (spec §3.2); `close()` destroys it.
2479
2487
  engineCanvas = opts.canvas.createCanvas({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helping-ai-workflow/md2doc",
3
- "version": "3.17.0",
3
+ "version": "3.17.2",
4
4
  "description": "Markdown → HTML / PDF renderer with WaveDrom, Mermaid, Graphviz, and draw.io support",
5
5
  "keywords": [
6
6
  "markdown",