@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.
- package/lib/editor/client.js +205 -34
- package/lib/editor/code-edit.js +21 -9
- package/lib/editor/wave-codec.js +15 -5
- package/lib/editor/wave-ui.js +11 -3
- package/package.json +1 -1
package/lib/editor/client.js
CHANGED
|
@@ -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)
|
|
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)
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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)
|
|
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)
|
|
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 =
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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
|
|
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
|
|
21734
|
-
// way
|
|
21735
|
-
// second Ctrl+S while the first one's
|
|
21736
|
-
//
|
|
21737
|
-
//
|
|
21738
|
-
//
|
|
21739
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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();
|
package/lib/editor/code-edit.js
CHANGED
|
@@ -34,10 +34,18 @@ function firstWord(info) {
|
|
|
34
34
|
return String(info || '').trim().split(/\s+/)[0] || '';
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
//
|
|
38
|
-
//
|
|
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 =
|
|
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
|
|
59
|
-
// kept
|
|
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
|
|
62
|
-
const
|
|
63
|
-
|
|
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
|
});
|
package/lib/editor/wave-codec.js
CHANGED
|
@@ -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.
|
|
2507
|
-
*
|
|
2508
|
-
*
|
|
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 (
|
|
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
|
-
|
|
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();
|
package/lib/editor/wave-ui.js
CHANGED
|
@@ -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',
|
|
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',
|
|
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',
|
|
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({
|