@helping-ai-workflow/md2doc 3.3.0 → 3.4.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/README.md +33 -8
- package/THIRD-PARTY-NOTICES.md +44 -0
- package/lib/drawio.js +178 -0
- package/lib/editor/blockmap.js +79 -6
- package/lib/editor/client.js +3353 -157
- package/lib/editor/inline-md.js +122 -12
- package/lib/editor/lineops.js +190 -3
- package/lib/editor/paste-md.js +5 -1
- package/lib/editor/server.js +359 -5
- package/lib/editor/toolbar-model.js +51 -14
- package/lib/editor/wave-codec.js +2631 -0
- package/lib/editor/wave-geometry.js +320 -0
- package/lib/editor/wave-store.js +950 -0
- package/lib/editor/wave-ui.js +2124 -0
- package/lib/md2doc.js +677 -14
- package/package.json +14 -6
- package/vendor/drawio/LICENSE +201 -0
- package/vendor/drawio/NOTICE +109 -0
- package/vendor/drawio/README.md +25 -0
- package/vendor/drawio/viewer-static.min.js +8596 -0
package/lib/editor/client.js
CHANGED
|
@@ -145,7 +145,63 @@
|
|
|
145
145
|
// in the opposite direction (a zero-width "after" range) to let the
|
|
146
146
|
// existing UndoStack undo()/redo() pair (lib/editor/lineops.js) handle a
|
|
147
147
|
// pure insertion/pure removal without a third op shape.
|
|
148
|
-
|
|
148
|
+
/**
|
|
149
|
+
* Is every line of this paste part of a list?
|
|
150
|
+
*
|
|
151
|
+
* "Part of a list" is a list line OR an indented continuation of one — a
|
|
152
|
+
* wrapped sentence, a nested block, a fenced snippet inside an item. Blank
|
|
153
|
+
* lines inside the paste are allowed (a list may be loose on purpose; what
|
|
154
|
+
* this decides is only whether to ADD one at the seam).
|
|
155
|
+
*
|
|
156
|
+
* A paste that is a list FOLLOWED BY a paragraph answers false, and should:
|
|
157
|
+
* the blank line before that paragraph is what keeps it out of the last
|
|
158
|
+
* item, and the seam's own blank is the lesser problem of the two.
|
|
159
|
+
*/
|
|
160
|
+
function pasteIsAllListLines(pastedLines) {
|
|
161
|
+
let sawListLine = false;
|
|
162
|
+
for (const raw of pastedLines) {
|
|
163
|
+
const line = String(raw == null ? '' : raw);
|
|
164
|
+
if (line.trim() === '') continue;
|
|
165
|
+
if (LIST_LINE_RE.test(line)) { sawListLine = true; continue; }
|
|
166
|
+
// A continuation has to be indented AND it has to belong to something.
|
|
167
|
+
if (sawListLine && /^[ \t]/.test(line)) continue;
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
return sawListLine;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Does this line open a list item? Deliberately the same shape as
|
|
174
|
+
// list-md.js's own SRC_MARKER_RE (`^([ \t]*)(?:[-*+]|\d{1,9}[.)])([ \t]+)…`),
|
|
175
|
+
// which is function-local there and answers a different question — it
|
|
176
|
+
// PARSES a marker, this only CLASSIFIES a line. The two are kept in
|
|
177
|
+
// agreement by a test that runs both over the same table of lines rather
|
|
178
|
+
// than by sharing one object: making that one exportable would drag a
|
|
179
|
+
// browser-side module into commitBlockInsertion(), whose whole value is
|
|
180
|
+
// that it is a pure function node can call directly.
|
|
181
|
+
const LIST_LINE_RE = /^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]+/;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* `opts.tight` — insert with NO leading blank line, and skip the trailing
|
|
185
|
+
* one when what follows is itself a list line.
|
|
186
|
+
*
|
|
187
|
+
* The always-on blank is right for a paragraph and wrong for a list item:
|
|
188
|
+
* a blank line between items makes the list LOOSE, every item then renders
|
|
189
|
+
* as <p>, serializeBlocks() answers 'P' per item, and the run degrades
|
|
190
|
+
* READ-ONLY with no banner. A user meets that as "I pasted an item beside
|
|
191
|
+
* my list and now Tab does nothing" — no error, no explanation, nothing to
|
|
192
|
+
* search for.
|
|
193
|
+
*
|
|
194
|
+
* It is why insertListItemAfter() and duplicateListItems() bypass this
|
|
195
|
+
* function. Paste could not: it carries arbitrary pasted lines, and
|
|
196
|
+
* re-serialising a run to absorb them would REWRITE the user's bytes —
|
|
197
|
+
* marker widths, ordinals, and the escaping trap duplicateListItems()
|
|
198
|
+
* records ('~5px' coming back as '\~5px'). So paste keeps the raw-line
|
|
199
|
+
* insert and turns the leading blank off instead.
|
|
200
|
+
*
|
|
201
|
+
* Opt-in, decided by the caller: every other call site wants the blank.
|
|
202
|
+
*/
|
|
203
|
+
function commitBlockInsertion(state, blockId, newBlockLines, opts) {
|
|
204
|
+
const tight = !!(opts && opts.tight);
|
|
149
205
|
const block = state.blocks.find((b) => b.id === blockId);
|
|
150
206
|
// T8 item 1, third helper in the same family: the anchor's range is read as
|
|
151
207
|
// an interval here too (`endLine + 1` is the insertion point, `lines[endLine]`
|
|
@@ -157,20 +213,26 @@
|
|
|
157
213
|
if (anchorRefusal) return anchorRefusal;
|
|
158
214
|
const endLine = block.endLine;
|
|
159
215
|
const nextLine = state.lines[endLine]; // 0-indexed: line right after the block, or undefined at EOF
|
|
160
|
-
|
|
216
|
+
// A list line following a tight insert needs no separator: emitting one
|
|
217
|
+
// would divorce THAT item from the run instead — the same defect, one
|
|
218
|
+
// item further down.
|
|
219
|
+
const needsTrailingBlank = nextLine !== undefined && nextLine.trim() !== '' &&
|
|
220
|
+
!(tight && LIST_LINE_RE.test(nextLine));
|
|
221
|
+
const lead = tight ? [] : [''];
|
|
161
222
|
const after = needsTrailingBlank
|
|
162
|
-
? [
|
|
163
|
-
: [
|
|
223
|
+
? [...lead, ...newBlockLines, '']
|
|
224
|
+
: [...lead, ...newBlockLines];
|
|
164
225
|
const op = { startLine: endLine + 1, endLine, before: [], after };
|
|
165
226
|
const r = ops.insertLines(state.lines, endLine, after);
|
|
166
227
|
const blocks = ops.shiftBlocks(state.blocks, blockId, r.delta);
|
|
167
228
|
state.stack.push(op);
|
|
168
229
|
// The new block's own content starts one line after the leading blank
|
|
169
|
-
// this function
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
230
|
+
// this function inserts by default (see `after` above) — callers use this
|
|
231
|
+
// to locate the freshly-inserted block in the blocks array a subsequent
|
|
232
|
+
// full rerenderAll() (which recomputes blocks server-side) hands back.
|
|
233
|
+
// A tight insert emits no leading blank, so its content begins on the
|
|
234
|
+
// first inserted line itself.
|
|
235
|
+
const newStartLine = tight ? op.startLine : op.startLine + 1;
|
|
174
236
|
return { lines: r.lines, blocks, op, newStartLine };
|
|
175
237
|
}
|
|
176
238
|
|
|
@@ -506,8 +568,147 @@
|
|
|
506
568
|
return rest === '' ? '#'.repeat(newDepth) : '#'.repeat(newDepth) + ' ' + rest;
|
|
507
569
|
}
|
|
508
570
|
|
|
571
|
+
// v3.4.0 batch2 Task 6 fix round 1 (review F6) — which page of a re-baked
|
|
572
|
+
// multi-page drawio box the reader should land on, and whether we are
|
|
573
|
+
// entitled to tell them their page is gone.
|
|
574
|
+
//
|
|
575
|
+
// Page names are NOT identities. lib/drawio.js's pageNamesOf() pushes '' for
|
|
576
|
+
// any <diagram> with no name attribute (normal in hand-written and
|
|
577
|
+
// tool-exported files) and nothing de-duplicates the list, so the old
|
|
578
|
+
// `newNames.indexOf(keepName)` had two opposite failure modes on perfectly
|
|
579
|
+
// ordinary input: an all-unnamed file always resolved to index 0, silently
|
|
580
|
+
// moving the reader back to page 1 with no banner; and a `keepName` of null
|
|
581
|
+
// (nothing identifiable was open) resolved to -1, raising a banner claiming
|
|
582
|
+
// a deletion that never happened. A false banner is worse than no banner —
|
|
583
|
+
// it is what teaches a user to ignore the true one.
|
|
584
|
+
//
|
|
585
|
+
// So: use the name only where the name IS an identity (non-empty AND unique
|
|
586
|
+
// in the old list), fall back to position otherwise, and claim deletion only
|
|
587
|
+
// where it is actually established — a once-unique name that is now absent,
|
|
588
|
+
// or a position the document no longer reaches.
|
|
589
|
+
//
|
|
590
|
+
// `gone: false` with a moved `landOn` is not silence: the caller lands the
|
|
591
|
+
// reader on a page that is genuinely the same one (renumbered by a reorder),
|
|
592
|
+
// which is the outcome Ruling B2-P1's "page still there" branch describes.
|
|
593
|
+
function resolveDrawioLanding(oldNames, activeIdx, newNames) {
|
|
594
|
+
const olds = Array.isArray(oldNames) ? oldNames : [];
|
|
595
|
+
const news = Array.isArray(newNames) ? newNames : [];
|
|
596
|
+
if (news.length === 0) return { landOn: 0, gone: false };
|
|
597
|
+
// Nothing identifiable was open (no visible .drawio-page, or an index the
|
|
598
|
+
// old name list never had). We know nothing, so we claim nothing.
|
|
599
|
+
if (!(activeIdx >= 0) || activeIdx >= olds.length) return { landOn: 0, gone: false };
|
|
600
|
+
const name = olds[activeIdx];
|
|
601
|
+
let oldCount = 0;
|
|
602
|
+
for (let i = 0; i < olds.length; i++) if (olds[i] === name) oldCount++;
|
|
603
|
+
if (name !== '' && oldCount === 1) {
|
|
604
|
+
const hits = [];
|
|
605
|
+
for (let i = 0; i < news.length; i++) if (news[i] === name) hits.push(i);
|
|
606
|
+
// Exactly one match: a rename-free reorder, resolved exactly.
|
|
607
|
+
// More than one: the name stopped being an identity between the two
|
|
608
|
+
// bakes — land on the first bearer, but do NOT claim a deletion we
|
|
609
|
+
// cannot establish.
|
|
610
|
+
if (hits.length > 0) return { landOn: hits[0], gone: false };
|
|
611
|
+
return { landOn: 0, gone: true };
|
|
612
|
+
}
|
|
613
|
+
// Empty or duplicated name: position is the only sound identity left.
|
|
614
|
+
if (activeIdx < news.length) return { landOn: activeIdx, gone: false };
|
|
615
|
+
return { landOn: 0, gone: true };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// v3.4.0 batch3 Task 7 fix 1 (F2) — the block map after an edit that changed
|
|
619
|
+
// the line count INSIDE one block and nowhere else.
|
|
620
|
+
//
|
|
621
|
+
// This is not `ops.shiftBlocks()` on its own, and the difference is the whole
|
|
622
|
+
// point. `commitRangeEdit()` anchors its shift on "the last block ENDING at
|
|
623
|
+
// or before the edited range", and for an edit to a fenced block's BODY that
|
|
624
|
+
// anchor is the block BEFORE the fence — so its answer moves the fenced
|
|
625
|
+
// block's own `startLine`, which did not move, and leaves its `endLine`
|
|
626
|
+
// where it was, which did. Here the edited block is named outright: it keeps
|
|
627
|
+
// its `startLine`, its `endLine` absorbs the delta, every block after it
|
|
628
|
+
// moves by the delta, and every block before it is untouched.
|
|
629
|
+
//
|
|
630
|
+
// Why it exists at all: the wave editor defers the block map to the render at
|
|
631
|
+
// the end of a session, and a render can FAIL. Before this, a failed close
|
|
632
|
+
// render left `blocks` addressing the pre-session document while `lines`
|
|
633
|
+
// carried the session's delta — MEASURED, the next ordinary paragraph edit
|
|
634
|
+
// then committed over the blank line after the closing fence, eating the
|
|
635
|
+
// separator and duplicating the paragraph, with no warning of any kind. The
|
|
636
|
+
// rule this restores is that no ordinary edit may ever land against a block
|
|
637
|
+
// map the renderer has not confirmed: where the renderer cannot confirm it,
|
|
638
|
+
// the session's own arithmetic settles it instead.
|
|
639
|
+
function shiftBlocksAfterBodyEdit(blocks, blockId, delta) {
|
|
640
|
+
if (delta === 0) return blocks;
|
|
641
|
+
return blocks.map((b) => {
|
|
642
|
+
if (b.id === blockId) return Object.assign({}, b, { endLine: b.endLine + delta });
|
|
643
|
+
if (b.id > blockId) {
|
|
644
|
+
return Object.assign({}, b, { startLine: b.startLine + delta, endLine: b.endLine + delta });
|
|
645
|
+
}
|
|
646
|
+
return b;
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// v3.4.0 batch3 Task 7 fix 2 (MUST-FIX 1) — may a wave session's own commits
|
|
651
|
+
// simply be popped off the document stack?
|
|
652
|
+
//
|
|
653
|
+
// Two questions, asked separately, each answered by the stack itself rather
|
|
654
|
+
// than by arithmetic over a quantity that can be wrong:
|
|
655
|
+
//
|
|
656
|
+
// `depth` are there still exactly `ops` entries above where this
|
|
657
|
+
// session started? The pop removes that many, so anything
|
|
658
|
+
// else pushing inside the session (see openWaveEditor()'s
|
|
659
|
+
// `switchAwayFrom()` guard) would make it reverse somebody
|
|
660
|
+
// else's edit.
|
|
661
|
+
// ⚠ This proves the COUNT, not the identity. A foreign push
|
|
662
|
+
// paired with a foreign pop inside one session satisfies it —
|
|
663
|
+
// `discardPristineInsert()` is exactly that shape. It is not
|
|
664
|
+
// reachable today for one reason and it is worth naming: the
|
|
665
|
+
// modal returns on every non-save key, and the only path that
|
|
666
|
+
// could open a block editor mid-session is Ctrl+S's
|
|
667
|
+
// `switchAwayFrom()`, which openWaveEditor() now resolves
|
|
668
|
+
// BEFORE the overlay opens. If that guard ever moves, this
|
|
669
|
+
// check has to become identity-based.
|
|
670
|
+
// `savedDepth` would the pop destroy the state disk was written from? It
|
|
671
|
+
// takes the stack from `baseDepth + ops` back to `baseDepth`,
|
|
672
|
+
// so the answer is yes exactly when the save marker sits
|
|
673
|
+
// ABOVE `baseDepth` — i.e. names a state only reachable
|
|
674
|
+
// through this session's own ops. `null` (the marker
|
|
675
|
+
// invalidated because history diverged past it) is safe: it
|
|
676
|
+
// names nothing, and the document reads dirty either way.
|
|
677
|
+
// ⚠ fix 4 (R4): this question is answered with the marker as
|
|
678
|
+
// it stands NOW, and a save that is still in flight has not
|
|
679
|
+
// moved it yet. MEASURED: paint twice, Ctrl+S inside the
|
|
680
|
+
// session, Escape before the reply — this says the pop is
|
|
681
|
+
// safe and pops a drawing the server has already written. It
|
|
682
|
+
// is not a hole, because the receipt closes it from the other
|
|
683
|
+
// end: when that reply lands, `markSaved()` finds the
|
|
684
|
+
// timeline at its boundary gone, invalidates the marker, and
|
|
685
|
+
// the document reads dirty. But this function cannot see it,
|
|
686
|
+
// and the honest reading of its answer is "safe as far as any
|
|
687
|
+
// save that has REPLIED is concerned".
|
|
688
|
+
//
|
|
689
|
+
// Both are POSITIONS compared against the session's own baseline, which is
|
|
690
|
+
// what makes them sign-safe. What they REPLACE: fix 1 derived "no save
|
|
691
|
+
// happened" from `stack.dirtyDepth === baseDirtyDepth + ops`. That
|
|
692
|
+
// implication only runs the way it was used when `baseDirtyDepth >= 0`;
|
|
693
|
+
// algebraically it is equally satisfied when
|
|
694
|
+
// `baseDirtyDepth === -(ops taken before the save)`, and a user reaches a
|
|
695
|
+
// negative baseline by pressing Ctrl+Z once after a save. MEASURED: edit,
|
|
696
|
+
// Ctrl+S, Ctrl+Z, open the editor, paint, Ctrl+S, paint, Escape — the
|
|
697
|
+
// predicate read `1 === -1 + 2`, answered "no save", and popped blind,
|
|
698
|
+
// reproducing the original defect through the fixed code.
|
|
699
|
+
//
|
|
700
|
+
// Deliberately NOT "did the marker move": the marker legitimately goes
|
|
701
|
+
// 1 → null → 1 inside one session (the first wave commit invalidates it, a
|
|
702
|
+
// mid-session Ctrl+S establishes it again), so watching it for change both
|
|
703
|
+
// misses that case and refuses the harmless one where only the invalidation
|
|
704
|
+
// happened.
|
|
705
|
+
function waveDiscardIsSafe(stack, seam) {
|
|
706
|
+
if (stack.depth !== seam.baseDepth + seam.ops) return false;
|
|
707
|
+
return stack.savedDepth === null || stack.savedDepth <= seam.baseDepth;
|
|
708
|
+
}
|
|
709
|
+
|
|
509
710
|
if (typeof module === 'object' && module.exports) {
|
|
510
|
-
module.exports = { extractBlockSource, commitEdit, commitRangeEdit, commitRangeRemoval, commitListBlockRemoval, commitBlockInsertion, planBlockMove, commitBlockMove, reorderSpanRange, spanMoveRange, spanIndentsAreAnchored, blockMoveSeamRefusal, rollbackFailedRender, headingDepthOf, withHeadingDepth };
|
|
711
|
+
module.exports = { pasteIsAllListLines, LIST_LINE_RE, extractBlockSource, commitEdit, commitRangeEdit, commitRangeRemoval, commitListBlockRemoval, commitBlockInsertion, planBlockMove, commitBlockMove, reorderSpanRange, spanMoveRange, spanIndentsAreAnchored, blockMoveSeamRefusal, rollbackFailedRender, headingDepthOf, withHeadingDepth, resolveDrawioLanding, shiftBlocksAfterBodyEdit, waveDiscardIsSafe };
|
|
511
712
|
return; // node: pure core only
|
|
512
713
|
}
|
|
513
714
|
|
|
@@ -532,6 +733,20 @@
|
|
|
532
733
|
const docSourceLib = window.md2docDocSource;
|
|
533
734
|
let lines = ED.lines, blocks = ED.blocks, mtimeMs = ED.mtimeMs;
|
|
534
735
|
let lastParts = null;
|
|
736
|
+
// v3.4.0 batch2 Task 6 fix round 1 (review F1) — true while `lastParts` is
|
|
737
|
+
// the bootstrap seed taken off the server-rendered DOM (see just below
|
|
738
|
+
// contentEl) rather than a `parts` array a render actually handed back.
|
|
739
|
+
// The seed is byte-for-byte the DOM's OWN serialization, which is not the
|
|
740
|
+
// same string the server emitted (the HTML parser normalizes attribute
|
|
741
|
+
// quoting, `hidden` becomes `hidden=""`, and so on), so it is fit for the
|
|
742
|
+
// positional/id checks domMatchesLastRender() makes and for "did this
|
|
743
|
+
// block's baked output change at all", but NOT for patchmap(), which
|
|
744
|
+
// diffs the strings themselves. It is also what keeps the documented
|
|
745
|
+
// "the first commit after a page load is ALWAYS a full render" behaviour
|
|
746
|
+
// exactly as it was — test/editor-journey.test.js's primed/unprimed axis
|
|
747
|
+
// is built on that property and would silently lose half its coverage if
|
|
748
|
+
// seeding `lastParts` quietly enabled the patch route one commit earlier.
|
|
749
|
+
let lastPartsIsBootstrapSeed = false;
|
|
535
750
|
// F10: 上一次【成功套用】的 render 留下來的東西 —— 當時的整份 `lines`,以及
|
|
536
751
|
// 當時的文件是不是以一道沒閉合的圍欄收尾。
|
|
537
752
|
//
|
|
@@ -552,6 +767,28 @@
|
|
|
552
767
|
const stack = new ops.UndoStack();
|
|
553
768
|
const baseTitle = document.title;
|
|
554
769
|
const contentEl = document.querySelector('.content');
|
|
770
|
+
// v3.4.0 batch2 Task 6 fix round 1 (review F1, BLOCKING) — establish, at
|
|
771
|
+
// first paint, the knowledge domMatchesLastRender() is a check ON.
|
|
772
|
+
//
|
|
773
|
+
// `lastParts` used to stay null until this tab's FIRST COMMIT, because the
|
|
774
|
+
// bootstrap payload carries no `parts` and the initial DOM is rendered
|
|
775
|
+
// server-side (no client render runs at load). refreshStaleDrawio()'s
|
|
776
|
+
// "does the DOM still match the render I last knew about" guard is
|
|
777
|
+
// therefore unconditionally false on a freshly opened tab — and "open a
|
|
778
|
+
// document, don't edit it, change the .drawio in Draw.io" is the exact
|
|
779
|
+
// story this whole feature exists for. The re-bake was computed, paid for
|
|
780
|
+
// (a headless-Chromium bake) and thrown away, while the server's baseline
|
|
781
|
+
// advanced so the signal never came again.
|
|
782
|
+
//
|
|
783
|
+
// The guard is right; the bootstrap simply never established what it
|
|
784
|
+
// checks. It does now: at this point .content's children ARE the render
|
|
785
|
+
// the server just sent, one element per block, so reading them back is not
|
|
786
|
+
// an approximation of that knowledge — it is that knowledge. Taken here,
|
|
787
|
+
// before armEditables() (called at the very bottom of this IIFE) adds its
|
|
788
|
+
// own attributes, so the seed describes the render rather than the armed
|
|
789
|
+
// document.
|
|
790
|
+
lastParts = Array.prototype.map.call(contentEl.children, (el) => el.outerHTML);
|
|
791
|
+
lastPartsIsBootstrapSeed = true;
|
|
555
792
|
|
|
556
793
|
// Click-to-switch substrate (Phase-2 Task 1; replaces the old "refuse a
|
|
557
794
|
// second block's editor outright" policy). At most one block editor is
|
|
@@ -620,6 +857,90 @@
|
|
|
620
857
|
discardedBurst = null;
|
|
621
858
|
}
|
|
622
859
|
|
|
860
|
+
// v3.4.0 (Task 7 item 5) — the raw-editor twin of `discardedBurst` just
|
|
861
|
+
// above. Same one-shot contract (answers exactly one Ctrl+Z, the one right
|
|
862
|
+
// after the Escape/✕ that created it) and the SAME invalidation rule
|
|
863
|
+
// (dropped the moment any new burst/raw session opens, or the page
|
|
864
|
+
// re-renders) — see discardedBurst's own comment for why that window has
|
|
865
|
+
// to be this narrow. It cannot REUSE discardedBurst's mechanism, though:
|
|
866
|
+
// that one parks a live `history` object keyed on the burst's own DOM node,
|
|
867
|
+
// and openRawEditor() tears its OWN surface down on open
|
|
868
|
+
// (`blockEl.innerHTML = ''`) before a textarea ever exists — there is no
|
|
869
|
+
// node to park a history against. So this parks a plain VALUE snapshot of
|
|
870
|
+
// the textarea instead (what was typed, where the caret was, and enough of
|
|
871
|
+
// the session's own addressing — blockId/range/startLine/endLine/source —
|
|
872
|
+
// to re-derive the same block and verify nothing else moved underneath it)
|
|
873
|
+
// and Ctrl+Z's job is to RE-OPEN the raw editor and put that value back —
|
|
874
|
+
// not to commit it. Escape's semantics are unchanged: it still discards,
|
|
875
|
+
// still leaves. What changes is that the discard is no longer permanent.
|
|
876
|
+
//
|
|
877
|
+
// `discardedBurst` and this are mutually exclusive by construction (only
|
|
878
|
+
// one editing session — a WYSIWYG burst or a raw editor — is ever open at
|
|
879
|
+
// once): cancelAndMaybeDiscard() below drops any live `discardedBurst` the
|
|
880
|
+
// moment IT stashes here, and revertBurstAndEnd()/stashDiscardedTableBurst()
|
|
881
|
+
// drop this the moment THEY stash into `discardedBurst` — so undo()'s
|
|
882
|
+
// "check the burst stash, then the raw stash" order never answers a stale
|
|
883
|
+
// one while a fresher discard from the OTHER substrate is sitting unused.
|
|
884
|
+
let discardedRawEdit = null; // { blockId, range, startLine, endLine, source, value, caret } | null
|
|
885
|
+
|
|
886
|
+
function dropDiscardedRawEdit() {
|
|
887
|
+
discardedRawEdit = null;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// v3.4.0 batch3 Task 7 — the wave-editor member of the same family as the
|
|
891
|
+
// two stashes above. Escape out of the wave overlay still DISCARDS, and this
|
|
892
|
+
// is what makes the discard recoverable by exactly one document-level
|
|
893
|
+
// Ctrl+Z, the same one-shot contract `discardedBurst` spells out.
|
|
894
|
+
//
|
|
895
|
+
// Which of discardedBurst's rules are reused here, and which cannot be:
|
|
896
|
+
//
|
|
897
|
+
// REUSED — the one-shot window (the only Ctrl+Z it answers is the one
|
|
898
|
+
// right after the Escape that created it), the drop-on-any-render rule
|
|
899
|
+
// (rerenderAll() clears all three at its top), mutual exclusivity with the
|
|
900
|
+
// other two, and a staleness guard that refuses rather than applying a
|
|
901
|
+
// restore over a document that moved underneath it.
|
|
902
|
+
//
|
|
903
|
+
// NOT APPLICABLE — parking a live `history` object keyed on the burst's
|
|
904
|
+
// own DOM node. discardedBurst can do that because the burst's
|
|
905
|
+
// contenteditable survives its own Escape; the wave overlay is mounted on
|
|
906
|
+
// `document.body` and `close()` REMOVES it (and every document listener it
|
|
907
|
+
// installed) before onClose ever runs, so there is no node and no live
|
|
908
|
+
// store left to park. Nor is `editEl`/`burstBaselineHtml()` meaningful:
|
|
909
|
+
// nothing the wave editor produces was ever HTML in `.content`.
|
|
910
|
+
//
|
|
911
|
+
// NOT APPLICABLE — "the burst never committed, so `lines` never saw it".
|
|
912
|
+
// The wave editor writes back PER GESTURE, so by the time Escape is
|
|
913
|
+
// pressed `lines` has seen every one of them and the document stack is
|
|
914
|
+
// carrying them. That inverts what Escape has to do: it is the one stash
|
|
915
|
+
// in this file whose creation REMOVES ops from the stack (see
|
|
916
|
+
// finishWaveSession()) rather than never having put any there.
|
|
917
|
+
//
|
|
918
|
+
// So this parks a plain VALUE pair — the block body as it stood before the
|
|
919
|
+
// session (`source`, which is what `lines` holds again once the Escape has
|
|
920
|
+
// run) and as it stood at the moment of the Escape (`painted`) — plus enough
|
|
921
|
+
// addressing to prove nothing else moved. Ctrl+Z's job is to put `painted`
|
|
922
|
+
// back through an ordinary commit and RE-OPEN the editor over it.
|
|
923
|
+
let discardedWaveEdit = null; // { blockId, blockStartLine, blockEndLine, startLine, endLine, source, painted } | null
|
|
924
|
+
|
|
925
|
+
function dropDiscardedWaveEdit() {
|
|
926
|
+
discardedWaveEdit = null;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// v3.4.0 batch3 Task 7 — a wave gesture whose patch was REFUSED, i.e. work
|
|
930
|
+
// the user can see in the overlay that `lines` does not hold and cannot be
|
|
931
|
+
// made to hold by a local patch. Declared up here beside the stashes rather
|
|
932
|
+
// than down with the rest of the wave wiring (~16900) for one reason:
|
|
933
|
+
// documentIsDirty() reads it, documentIsDirty() is reachable from the very
|
|
934
|
+
// first setDirty(), and a `let` declared further down would still be in its
|
|
935
|
+
// temporal dead zone then.
|
|
936
|
+
//
|
|
937
|
+
// This is the second half of the Reload gate. The first half is free —
|
|
938
|
+
// every write-back that SUCCEEDS goes through commitRangeEdit(), so
|
|
939
|
+
// `stack.dirtyDepth` already counts it and the beforeunload guard already
|
|
940
|
+
// covers it. A refusal is the case that guard cannot see, because by
|
|
941
|
+
// definition nothing reached `lines`.
|
|
942
|
+
let waveUnwrittenEdit = false;
|
|
943
|
+
|
|
623
944
|
// §10-gap fix (review): the block insertBlockBelow() most recently
|
|
624
945
|
// inserted, tracked from the moment its edit surface is first focused
|
|
625
946
|
// until its FIRST resolution (blur, Escape, explicit commit, or Ctrl+Z) —
|
|
@@ -772,9 +1093,13 @@
|
|
|
772
1093
|
return await activeEditor.commitNow(); // false → editor stays open, banner already shown
|
|
773
1094
|
}
|
|
774
1095
|
|
|
775
|
-
// `stack.
|
|
776
|
-
//
|
|
777
|
-
//
|
|
1096
|
+
// `stack.isDirty()` asks whether the undo stack is standing on the state the
|
|
1097
|
+
// last save wrote (lineops.js). `dirtyDepth` is the same question as a
|
|
1098
|
+
// distance, and only while that distance exists at all: once a push has
|
|
1099
|
+
// diverged past the save marker the marker is invalidated, and the getter
|
|
1100
|
+
// then answers `_done.length + 1` — never 0. Either way the answer moves
|
|
1101
|
+
// when an op is pushed onto or popped off the stack, or when a save
|
|
1102
|
+
// re-baselines it. The burst substrate keeps what the user typed in
|
|
778
1103
|
// the DOM until the burst resolves, and none of those has happened yet at
|
|
779
1104
|
// that point — so a block that has been typed into but not yet left reads
|
|
780
1105
|
// as clean. MEASURED on `# Doc\n\nAlpha paragraph.\n` (click the
|
|
@@ -791,9 +1116,35 @@
|
|
|
791
1116
|
burstBaselineHtml(currentBurst.editEl) !== currentBurst.original);
|
|
792
1117
|
}
|
|
793
1118
|
|
|
1119
|
+
// v3.4.0 §3: single source of truth for "does the document have anything
|
|
1120
|
+
// unsaved" — setDirty() (the title dot), toolbarContext() (the `save`
|
|
1121
|
+
// button's own lit/grey state), and the beforeunload guard further below
|
|
1122
|
+
// all ask this instead of each carrying its own copy of the expression
|
|
1123
|
+
// (review I1: the beforeunload handler used to be a second copy — found
|
|
1124
|
+
// and fixed). Do not inline this back into any call site; that is exactly
|
|
1125
|
+
// the drift this function exists to prevent.
|
|
1126
|
+
// v3.4.0 batch3 Task 7 adds the third term. MEASURED before it existed, with
|
|
1127
|
+
// the wave editor open over a real paint: `document.title` was 'doc', the
|
|
1128
|
+
// file on disk was byte-identical, and pressing the conflict banner's Reload
|
|
1129
|
+
// raised NO beforeunload dialog at all — because this predicate is about
|
|
1130
|
+
// `lines`, and a waveform had never been in `lines`. Two thirds of that gap
|
|
1131
|
+
// close by themselves the moment write-back goes through commitRangeEdit()
|
|
1132
|
+
// (`stack.dirtyDepth` counts it like any other commit); `waveUnwrittenEdit`
|
|
1133
|
+
// is the remaining third — a gesture the store could not express as a local
|
|
1134
|
+
// patch, which the user can see on screen and `lines` does not hold.
|
|
1135
|
+
function documentIsDirty() {
|
|
1136
|
+
// fix 2: `stack.isDirty()`, not `stack.dirtyDepth !== 0`. MEASURED on the
|
|
1137
|
+
// shipped behaviour, with no wave editor involved: edit, Ctrl+S, Ctrl+Z,
|
|
1138
|
+
// one more ordinary edit — the depth came back to 0 FROM BELOW and this
|
|
1139
|
+
// answered false over a document that differed from disk in both edits.
|
|
1140
|
+
// The distance was a second record of "are we standing where we saved";
|
|
1141
|
+
// `UndoStack` now answers that question itself, and a boolean has no zero
|
|
1142
|
+
// to walk through. See lineops.js's own comment for the full story.
|
|
1143
|
+
return stack.isDirty() || burstHasUncommittedEdit() || waveUnwrittenEdit;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
794
1146
|
function setDirty() {
|
|
795
|
-
|
|
796
|
-
document.title = (dirty ? '● ' : '') + baseTitle;
|
|
1147
|
+
document.title = (documentIsDirty() ? '● ' : '') + baseTitle;
|
|
797
1148
|
}
|
|
798
1149
|
|
|
799
1150
|
// setDirty() is push-driven: something has to call it. MEASURED by keeping
|
|
@@ -865,6 +1216,12 @@
|
|
|
865
1216
|
// refusal 一樣需要一個「後來的事件可以把它收掉」的記號,但收掉它的條件不
|
|
866
1217
|
// 同(見 dismissSwallowBanner()),所以是另一個旗標而不是共用那一個。
|
|
867
1218
|
let activeBannerIsSwallow = false;
|
|
1219
|
+
// Task 9 review I3: the same shape as activeBannerIsRefusal/
|
|
1220
|
+
// activeBannerIsSwallow above, for the keynav-exit banner backlog #10
|
|
1221
|
+
// added. It needs its own dismiss condition too — see
|
|
1222
|
+
// dismissKeynavExitBanner()'s own comment for why Escape, specifically,
|
|
1223
|
+
// is the only OTHER thing allowed to clear it.
|
|
1224
|
+
let activeBannerIsKeynavExit = false;
|
|
868
1225
|
function showBanner(message, actionLabel, onAction) {
|
|
869
1226
|
if (activeBanner) { activeBanner.remove(); activeBanner = null; }
|
|
870
1227
|
// Reset unconditionally: refuseStructuralListEdit() sets it back to true
|
|
@@ -872,6 +1229,39 @@
|
|
|
872
1229
|
// having to know this flag exists.
|
|
873
1230
|
activeBannerIsRefusal = false;
|
|
874
1231
|
activeBannerIsSwallow = false;
|
|
1232
|
+
activeBannerIsKeynavExit = false;
|
|
1233
|
+
// backlog #8 fix: `.ed-conflict` now sits at `top: var(--ed-toolbar-h)`
|
|
1234
|
+
// (lib/md2doc.js) — the SAME floor `.ed-te-menu` (placeTeMenuAboveCell())
|
|
1235
|
+
// explicitly clamps to when there is no room above (`if (top < topInset)
|
|
1236
|
+
// top = r.bottom + …`, `topInset` read straight from --ed-toolbar-h).
|
|
1237
|
+
// Final-review I1 correction: an earlier draft of this comment also named
|
|
1238
|
+
// `.ed-toolbar-menu` here (`toggleToolbarHeadingMenu(): top = r.bottom +
|
|
1239
|
+
// 4`) as if that `+ 4` were the same floor. Retracted — MEASURED:
|
|
1240
|
+
// toggleToolbarHeadingMenu() never reads --ed-toolbar-h at all; its `r` is
|
|
1241
|
+
// the heading-dropdown BUTTON's own rect (a toolbar button, so already
|
|
1242
|
+
// near the band), not a clamp against the token. It lands near the same
|
|
1243
|
+
// band as a side effect of where its trigger sits, not because of a
|
|
1244
|
+
// shared floor. (positionSelToolbar()'s `.ed-seltb` is the same story,
|
|
1245
|
+
// for the same reason — see the parallel correction on the `.ed-conflict`
|
|
1246
|
+
// CSS comment in lib/md2doc.js.)
|
|
1247
|
+
// Any of the four dynamic fixed overlays below could nonetheless be
|
|
1248
|
+
// showing in exactly the band the banner is about to claim; without this,
|
|
1249
|
+
// the banner (z-index 999, above all four) would paint over whichever one
|
|
1250
|
+
// is open, reproducing backlog #8's own defect one layer down instead of
|
|
1251
|
+
// fixing it. Same four functions onAnyScroll() already calls for an
|
|
1252
|
+
// analogous reason (something big just happened; these four don't
|
|
1253
|
+
// outlive it) — reused rather than re-invented — but NOT the identical
|
|
1254
|
+
// call: onAnyScroll() guards its hideTableEdgeMenu() with
|
|
1255
|
+
// `if (!suppressEdgeMenuAutoHide)`, this call is unconditional. The one
|
|
1256
|
+
// window that guard exists for is runCycleAlign()'s ensureTableBurstOpen()
|
|
1257
|
+
// span, which can itself raise a banner (a structural refusal) — but that
|
|
1258
|
+
// branch bails immediately and resets the flag to false before doing
|
|
1259
|
+
// anything the menu's reposition-in-progress state could be corrupted
|
|
1260
|
+
// by, so an unconditional close here has no real target to clobber.
|
|
1261
|
+
hideTableGrips();
|
|
1262
|
+
hideTableInsertBubbles();
|
|
1263
|
+
hideTableEdgeMenu();
|
|
1264
|
+
closeToolbarMenu();
|
|
875
1265
|
const el = document.createElement('div');
|
|
876
1266
|
el.className = 'ed-conflict';
|
|
877
1267
|
const msg = document.createElement('span');
|
|
@@ -894,6 +1284,7 @@
|
|
|
894
1284
|
activeBanner = null;
|
|
895
1285
|
activeBannerIsRefusal = false;
|
|
896
1286
|
activeBannerIsSwallow = false;
|
|
1287
|
+
activeBannerIsKeynavExit = false;
|
|
897
1288
|
}
|
|
898
1289
|
});
|
|
899
1290
|
el.appendChild(dismissBtn);
|
|
@@ -902,6 +1293,34 @@
|
|
|
902
1293
|
return el;
|
|
903
1294
|
}
|
|
904
1295
|
|
|
1296
|
+
// Task 9 review I3: MEASURED AT THE TIME — `.ed-conflict` was
|
|
1297
|
+
// `position:fixed; top:0; z-index:999` against `.ed-toolbar`'s
|
|
1298
|
+
// `top:0; height:44px; z-index:101`, so the keynav-exit banner sat OVER
|
|
1299
|
+
// the entire bar (23/23 buttons' centre points hit-tested to the banner,
|
|
1300
|
+
// not their own button) the moment it appeared. Task 10 (backlog #8)
|
|
1301
|
+
// since moved `.ed-conflict` to `top: var(--ed-toolbar-h)` — it no longer
|
|
1302
|
+
// covers the bar at all, for any banner, not just this one — so that
|
|
1303
|
+
// geometry claim is HISTORICAL, kept here only because it is what
|
|
1304
|
+
// motivated wiring Escape below; do not read it as the current state of
|
|
1305
|
+
// `.ed-conflict`. What is still current: a keyboard user had no way to
|
|
1306
|
+
// take this banner down other than a mouse click on ✕ — Escape did not
|
|
1307
|
+
// dismiss it (showBanner() only wires a click handler onto ✕) and a
|
|
1308
|
+
// bare Tab is swallowed by this file's own §3.5 contract whenever nothing
|
|
1309
|
+
// real is focused, which is exactly the state right after this banner's
|
|
1310
|
+
// own cause (a stray key that just exited keynav). Escape is the one key
|
|
1311
|
+
// safe to wire this to: every OTHER banner this file raises (conflict /
|
|
1312
|
+
// save-failed / a structural refusal) describes something that must
|
|
1313
|
+
// survive an incidental Escape elsewhere on the page, but THIS banner's
|
|
1314
|
+
// entire subject is "you just left keyboard mode" — Escape asking to
|
|
1315
|
+
// leave a mode a second time is the same request, not a different one.
|
|
1316
|
+
function dismissKeynavExitBanner() {
|
|
1317
|
+
if (!activeBanner || !activeBannerIsKeynavExit) return false;
|
|
1318
|
+
activeBanner.remove();
|
|
1319
|
+
activeBanner = null;
|
|
1320
|
+
activeBannerIsKeynavExit = false;
|
|
1321
|
+
return true;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
905
1324
|
// Review recommendation 5: a refusal banner must not outlive the gesture that
|
|
906
1325
|
// raised it. refuseStructuralListEdit() has been dismiss-only since S1, and
|
|
907
1326
|
// S3 made it far more reachable (7 of the T8 sweep's 13 selection shapes
|
|
@@ -1104,6 +1523,13 @@
|
|
|
1104
1523
|
// guard in restoreDiscardedBurst() would refuse it anyway; dropping it at
|
|
1105
1524
|
// the source is what keeps a dead history object from being held.
|
|
1106
1525
|
dropDiscardedBurst();
|
|
1526
|
+
// v3.4.0 (Task 7 item 5): same reasoning, same source, for the raw-editor
|
|
1527
|
+
// twin — this render is about to replace `lines`' rendering of whatever
|
|
1528
|
+
// discardedRawEdit's stashed `source` names.
|
|
1529
|
+
dropDiscardedRawEdit();
|
|
1530
|
+
// v3.4.0 batch3 Task 7: and the wave twin — its staleness guard compares
|
|
1531
|
+
// `lines` at a span, which this render is about to repaint from.
|
|
1532
|
+
dropDiscardedWaveEdit();
|
|
1107
1533
|
const scrollY = window.scrollY;
|
|
1108
1534
|
// S3 Task 5 (§4.4 step 2): the line range this render's operation declared
|
|
1109
1535
|
// for the rebuilt selection. Consumed HERE, before the first failure exit,
|
|
@@ -1182,10 +1608,17 @@
|
|
|
1182
1608
|
// Its VALUE is forwarded to patchmap(), which never reads it (決議 4(b) — it
|
|
1183
1609
|
// is advisory, and seeding the span from an op's line range measurably
|
|
1184
1610
|
// widens the answer), so neither the direction nor the width of the range
|
|
1185
|
-
// affects the plan.
|
|
1186
|
-
//
|
|
1187
|
-
//
|
|
1188
|
-
//
|
|
1611
|
+
// affects the plan. The first commit after a page load is ALWAYS a full
|
|
1612
|
+
// render. That is specified behaviour, not a bug — but fix round 2
|
|
1613
|
+
// (re-review G2) corrects HOW it is carried: it used to be that `lastParts`
|
|
1614
|
+
// stayed null until a render landed, and null is what made this gate fall
|
|
1615
|
+
// through. Since the F1 fix, `lastParts` is seeded off the server-rendered
|
|
1616
|
+
// DOM at first paint and is NEVER null after that; the property is carried
|
|
1617
|
+
// by `lastPartsIsBootstrapSeed` instead (the seed is the HTML parser's
|
|
1618
|
+
// re-serialisation, not the server's own `parts` strings, so it must not
|
|
1619
|
+
// reach patchmap(), which diffs those strings). Do NOT use
|
|
1620
|
+
// `lastParts === null` as an "unprimed" test anywhere — it is now always
|
|
1621
|
+
// false.
|
|
1189
1622
|
// Fix round 2: applyPatch()'s index arithmetic addresses `contentEl`'s
|
|
1190
1623
|
// element children POSITIONALLY against `lastParts`. That correspondence is
|
|
1191
1624
|
// not free — several gestures mutate .content's child list DIRECTLY, in the
|
|
@@ -1319,7 +1752,7 @@
|
|
|
1319
1752
|
absorbedOutsideContent(lastRenderLines, blocks,
|
|
1320
1753
|
lines, j.blocks,
|
|
1321
1754
|
j.blocks[j.blocks.length - 1]);
|
|
1322
|
-
const plan = (editRange && window.md2docPatchmap && domMatchesLastRender())
|
|
1755
|
+
const plan = (editRange && window.md2docPatchmap && !lastPartsIsBootstrapSeed && domMatchesLastRender())
|
|
1323
1756
|
? window.md2docPatchmap.patchmap({
|
|
1324
1757
|
oldBlocks: blocks, newBlocks: j.blocks,
|
|
1325
1758
|
oldParts: lastParts, newParts: j.parts, editRange: editRange,
|
|
@@ -1424,6 +1857,51 @@
|
|
|
1424
1857
|
const ci = focusedBlockEl ? children.indexOf(focusedBlockEl) : -1;
|
|
1425
1858
|
const focusWasInSpan = ci >= 0 &&
|
|
1426
1859
|
ci >= plan.replaceSpan.oldStart && ci <= plan.replaceSpan.oldEnd;
|
|
1860
|
+
// v3.4.0 (Task 7 item 1) — the raw-editor twin of applyFullRender()'s
|
|
1861
|
+
// v3.2.1 R2 `activeEditor = null` fix (see its comment), narrowed to the
|
|
1862
|
+
// one case that fix actually needs to cover here. That fix cannot be
|
|
1863
|
+
// copied verbatim: the whole point of the patch path is to let a raw
|
|
1864
|
+
// editor OUTSIDE replaceSpan survive (see "決議 3(1)" below) — nulling
|
|
1865
|
+
// activeEditor unconditionally here would re-orphan exactly the session
|
|
1866
|
+
// item1-scenarioB measured. The hazard is narrower: when activeEditor's
|
|
1867
|
+
// OWN block falls INSIDE replaceSpan, the removeChild loop below is
|
|
1868
|
+
// about to detach it out from under itself, and Chromium's synchronous
|
|
1869
|
+
// focusout for that detach finds `activeEditor` still set (commit()'s own
|
|
1870
|
+
// post-await `activeEditor = null` hasn't run yet — this render IS that
|
|
1871
|
+
// commit's own safeRerenderAll(), still in flight) — reentering
|
|
1872
|
+
// switchAwayFrom() -> resolveOpenSession() -> commitNow() a SECOND time.
|
|
1873
|
+
//
|
|
1874
|
+
// MEASURED (runs/task7-probe2-item1.js + task7-probe4-isolate.js, on a
|
|
1875
|
+
// 2nd-commit-in-session PATCH render that repairs a swallowed fence's
|
|
1876
|
+
// closing marker — the repair spawns a NEW block, same "衍生新 block"
|
|
1877
|
+
// shape the v3.2.1 R2 comment names): the reentrant commit() sees
|
|
1878
|
+
// `blocks` already reassigned to `j.blocks` (this function reassigns it
|
|
1879
|
+
// above, before the mutation below) — i.e. the block's NEW, shrunk
|
|
1880
|
+
// post-split range — but reuses `ta.value`, the STALE, un-shrunk text
|
|
1881
|
+
// that still carries the split-off tail. commitEdit() diffs that against
|
|
1882
|
+
// the (already-correct) `lines` and, since they no longer match, commits
|
|
1883
|
+
// a real op writing the stale tail-bearing text over the shrunk range —
|
|
1884
|
+
// duplicating the tail the FIRST (outer) commit had just placed in its
|
|
1885
|
+
// own new block a moment earlier. Three /api/render calls for one
|
|
1886
|
+
// Ctrl+Enter; the file gained a second 'Trailing paragraph text.'.
|
|
1887
|
+
// Nulling activeEditor here, before the mutation, for exactly the
|
|
1888
|
+
// falls-inside-replaceSpan case closes the hole the same way v3.2.1 R2
|
|
1889
|
+
// closed it for the fallback path, without touching the survives case.
|
|
1890
|
+
//
|
|
1891
|
+
// PRECONDITION worth stating explicitly: this can only fire on a PATCH
|
|
1892
|
+
// render, and patchmap() only runs once `lastParts` is populated — the
|
|
1893
|
+
// very first commit of a session is ALWAYS a full render (see
|
|
1894
|
+
// applyRenderResult()'s own comment). So the defect does NOT reproduce
|
|
1895
|
+
// on a freshly opened document with no prior commit in the same
|
|
1896
|
+
// session; repairing a swallowed fence as literally the first edit
|
|
1897
|
+
// after page load is safe (fallback path). See task-7-report.md and
|
|
1898
|
+
// this fix's test scenario for the measured before/after.
|
|
1899
|
+
if (activeEditor && activeEditor.blockEl) {
|
|
1900
|
+
const aci = children.indexOf(activeEditor.blockEl);
|
|
1901
|
+
if (aci >= plan.replaceSpan.oldStart && aci <= plan.replaceSpan.oldEnd) {
|
|
1902
|
+
activeEditor = null;
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1427
1905
|
// Fix round 1 (review Important 1): `blocks`/`lastParts` are the JOIN KEY
|
|
1428
1906
|
// between the module's model and the DOM, and they are overwritten BEFORE
|
|
1429
1907
|
// the DOM is touched — armEditables() reads the new `blocks` (see its call
|
|
@@ -1534,8 +2012,15 @@
|
|
|
1534
2012
|
// 所以在 `burstSurvived` 那裡也留了一行對照註解。不搬 `burstSurvived`
|
|
1535
2013
|
// 到還原之前,是刻意的取捨:搬了會讓剛開的 burst 先被 dispose 再由收尾
|
|
1536
2014
|
// 重開一次,並把每次 undo 的工具列 degrade 原封不動地留著。
|
|
1537
|
-
// teardown
|
|
1538
|
-
//
|
|
2015
|
+
// teardown 各項彼此的相對順序沒有被動到,**條件式**那一發 activeEditor
|
|
2016
|
+
// 歸零仍然在 DOM 手術之後求值——但這不再是這次 render 唯一可能歸零
|
|
2017
|
+
// `activeEditor` 的地方:v3.4.0 (Task 7 item 1) 在 DOM 手術**之前**另外
|
|
2018
|
+
// 補了一道 pre-mutation 的窄範圍歸零(見 `focusWasInSpan` 正下方那段
|
|
2019
|
+
// `if (activeEditor && activeEditor.blockEl) { … }`),只在
|
|
2020
|
+
// `activeEditor.blockEl` 落在 `plan.replaceSpan` 內時才提前清空。兩者
|
|
2021
|
+
// 不衝突(提前清過的話這裡條件式那段的 `activeEditor &&` 直接短路),但
|
|
2022
|
+
// 讀這段時不要再假設「這次 render 裡 activeEditor 的歸零只有一處、而且
|
|
2023
|
+
// 一定在 DOM 手術之後」。
|
|
1539
2024
|
const newLen = plan.replaceSpan.newParts.length;
|
|
1540
2025
|
if (newLen > 0) {
|
|
1541
2026
|
// landing 恆落在新中段內:ci >= oldStart 保證非負,min() 夾住上界,
|
|
@@ -1821,6 +2306,23 @@
|
|
|
1821
2306
|
// Never let a reader-runtime rebind failure break block selection/save/undo.
|
|
1822
2307
|
}
|
|
1823
2308
|
}
|
|
2309
|
+
// v3.4.0 batch2 Task 6 fix round 1 (review F9): sibling to
|
|
2310
|
+
// __md2docInitDiagrams above, and for the same reason — the swap just
|
|
2311
|
+
// detached every node the multi-page drawio navigation was mounted on,
|
|
2312
|
+
// and nothing else in the tree re-creates it. Left unwired, a document
|
|
2313
|
+
// with a multi-page diagram loses that navigation permanently on the
|
|
2314
|
+
// user's FIRST edit commit of the session, which makes preserving the
|
|
2315
|
+
// reader's open page across a background re-bake largely academic.
|
|
2316
|
+
// Defined only when the document was multi-page at load time (the
|
|
2317
|
+
// lib/md2doc.js gate a byte-exact guard test pins), hence the check.
|
|
2318
|
+
if (window.__md2docInitDrawioSheetBar) {
|
|
2319
|
+
try {
|
|
2320
|
+
window.__md2docInitDrawioSheetBar(contentEl);
|
|
2321
|
+
} catch (e) {
|
|
2322
|
+
// Same contract as the two rebinds around it: never let a
|
|
2323
|
+
// navigation-rebuild failure break block selection / save / undo.
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
1824
2326
|
// Fix round 1 (review, comment-only): whenever rebuildBlockSelection()
|
|
1825
2327
|
// above does NOT take its early-return path it has already called
|
|
1826
2328
|
// applySelectionClasses() once, via setBlockSelection()/
|
|
@@ -1887,6 +2389,7 @@
|
|
|
1887
2389
|
async function applyFullRender(j, scrollY, declaredRange) {
|
|
1888
2390
|
blocks = j.blocks;
|
|
1889
2391
|
lastParts = j.parts; // v3.2.0: 供下一次 patch 當 oldParts
|
|
2392
|
+
lastPartsIsBootstrapSeed = false;
|
|
1890
2393
|
// v3.2.1 R2: 必須先於 innerHTML 寫入。這個寫入 detach 掉聚焦的 raw
|
|
1891
2394
|
// textarea,同步 focusout 重入 switchAwayFrom() → resolveOpenSession() →
|
|
1892
2395
|
// commitNow(),於是同一次編輯提交兩次(實測衍生新 block 時磁碟上文字出現
|
|
@@ -2029,6 +2532,23 @@
|
|
|
2029
2532
|
// Never let a reader-runtime rebind failure break block selection/save/undo.
|
|
2030
2533
|
}
|
|
2031
2534
|
}
|
|
2535
|
+
// v3.4.0 batch2 Task 6 fix round 1 (review F9): sibling to
|
|
2536
|
+
// __md2docInitDiagrams above, and for the same reason — the swap just
|
|
2537
|
+
// detached every node the multi-page drawio navigation was mounted on,
|
|
2538
|
+
// and nothing else in the tree re-creates it. Left unwired, a document
|
|
2539
|
+
// with a multi-page diagram loses that navigation permanently on the
|
|
2540
|
+
// user's FIRST edit commit of the session, which makes preserving the
|
|
2541
|
+
// reader's open page across a background re-bake largely academic.
|
|
2542
|
+
// Defined only when the document was multi-page at load time (the
|
|
2543
|
+
// lib/md2doc.js gate a byte-exact guard test pins), hence the check.
|
|
2544
|
+
if (window.__md2docInitDrawioSheetBar) {
|
|
2545
|
+
try {
|
|
2546
|
+
window.__md2docInitDrawioSheetBar(contentEl);
|
|
2547
|
+
} catch (e) {
|
|
2548
|
+
// Same contract as the two rebinds around it: never let a
|
|
2549
|
+
// navigation-rebuild failure break block selection / save / undo.
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2032
2552
|
setDirty();
|
|
2033
2553
|
return true;
|
|
2034
2554
|
}
|
|
@@ -2440,6 +2960,47 @@
|
|
|
2440
2960
|
async function cancelAndMaybeDiscard() {
|
|
2441
2961
|
const wasPristineForThisBlock = !!(pristineInsert && pristineInsert.blockId === blockId);
|
|
2442
2962
|
if (wasPristineForThisBlock) pristineInsert = null;
|
|
2963
|
+
// v3.4.0 (Task 7 item 5): stash what is about to be thrown away, keyed
|
|
2964
|
+
// by VALUE rather than by node — see discardedRawEdit's own comment for
|
|
2965
|
+
// why a node-keyed stash (discardedBurst's own approach) cannot survive
|
|
2966
|
+
// this function's own restore() just below: for the non-rangeMode
|
|
2967
|
+
// branch it does `blockEl.innerHTML = original`.
|
|
2968
|
+
//
|
|
2969
|
+
// ⚠ rangeMode (a multi-block span, or any li) is NOT covered by this
|
|
2970
|
+
// stash, and that is a known, unfixed gap rather than something this
|
|
2971
|
+
// branch happens to also solve — review round 2 measured it on HEAD:
|
|
2972
|
+
// restore()'s rangeMode branch calls `await safeRerenderAll()`, which
|
|
2973
|
+
// reaches rerenderAll(), which calls dropDiscardedRawEdit()
|
|
2974
|
+
// UNCONDITIONALLY a couple of lines into the very Escape gesture that
|
|
2975
|
+
// just created the stash below. So the `discardedRawEdit = {...}`
|
|
2976
|
+
// assignment two lines down is live code but its EFFECT is dead for
|
|
2977
|
+
// every rangeMode session — the stash exists for one statement and is
|
|
2978
|
+
// wiped by its own creator's next `await`. A single li ( -item one ->
|
|
2979
|
+
// ⠿ -> MD 原始碼 -> type -> Escape -> Ctrl+Z) and a multi-block span
|
|
2980
|
+
// both measure `rawAfterUndo: {present:false}` — Ctrl+Z does not bring
|
|
2981
|
+
// the text back on either shape. Fixing this is out of this task's
|
|
2982
|
+
// scope (parked to backlog); this comment exists so nobody reads the
|
|
2983
|
+
// code below as covering rangeMode when it structurally cannot.
|
|
2984
|
+
// Skipped for a pristine insert: discardPristineInsert() (below)
|
|
2985
|
+
// REMOVES the block outright, so there is no surface left to restore a
|
|
2986
|
+
// stash INTO — mirrors revertBurstAndEnd()'s identical
|
|
2987
|
+
// wasPristineForThisBlock exception for the WYSIWYG burst twin.
|
|
2988
|
+
// Skipped when nothing was actually typed (ta.value === seed.value):
|
|
2989
|
+
// the zero-edit auto-cancel-on-unchanged-blur path in
|
|
2990
|
+
// resolveOpenSession() reaches this same function, and there is
|
|
2991
|
+
// nothing to lose there.
|
|
2992
|
+
if (!wasPristineForThisBlock && ta.value !== seed.value) {
|
|
2993
|
+
// Mutual exclusivity with the WYSIWYG twin — see discardedRawEdit's
|
|
2994
|
+
// own comment for why only the more recent of the two stashes may
|
|
2995
|
+
// ever answer the next Ctrl+Z.
|
|
2996
|
+
dropDiscardedBurst();
|
|
2997
|
+
// …and with the wave twin, for the same reason.
|
|
2998
|
+
dropDiscardedWaveEdit();
|
|
2999
|
+
discardedRawEdit = {
|
|
3000
|
+
blockId, range, startLine, endLine, source,
|
|
3001
|
+
value: ta.value, caret: ta.selectionStart,
|
|
3002
|
+
};
|
|
3003
|
+
}
|
|
2443
3004
|
await restore();
|
|
2444
3005
|
if (wasPristineForThisBlock) return await discardPristineInsert();
|
|
2445
3006
|
return true;
|
|
@@ -4346,6 +4907,10 @@
|
|
|
4346
4907
|
await openRawEditor(blockEl);
|
|
4347
4908
|
return;
|
|
4348
4909
|
}
|
|
4910
|
+
// NOTE: `kind === 'line'` never reaches here — insertBlockBelow()'s own
|
|
4911
|
+
// caller excludes it (`if (!customLines && kind !== 'line')`) and
|
|
4912
|
+
// handles the hr's caret landing itself, right where it computes
|
|
4913
|
+
// `result.newStartLine`; see that branch's own backlog #6 comment.
|
|
4349
4914
|
if (kind === 'table') {
|
|
4350
4915
|
const tableEl = blockContentEl(blockEl);
|
|
4351
4916
|
const firstBodyRow = tableEl ? bodyRowsOf(tableEl)[0] : null;
|
|
@@ -4481,7 +5046,14 @@
|
|
|
4481
5046
|
const liveBlockId = Number(anchorEl.getAttribute('data-block-id'));
|
|
4482
5047
|
const block = blocks.find((b) => b.id === liveBlockId);
|
|
4483
5048
|
if (!block) return;
|
|
4484
|
-
|
|
5049
|
+
// `tight` is only ever honoured when the anchor really is a list item:
|
|
5050
|
+
// the caller decides the CONTENT is list-shaped, this decides the
|
|
5051
|
+
// DESTINATION is a list. Both halves are needed — tight-inserting a list
|
|
5052
|
+
// under a paragraph would glue it to that paragraph's last line.
|
|
5053
|
+
const tight = !!(opts && opts.tight) &&
|
|
5054
|
+
anchorEl.getAttribute('data-block-type') === 'li';
|
|
5055
|
+
const result = commitBlockInsertion({ lines, blocks, stack }, liveBlockId, newLines,
|
|
5056
|
+
{ tight });
|
|
4485
5057
|
const prevLines = lines;
|
|
4486
5058
|
lines = result.lines;
|
|
4487
5059
|
const editRange = result.op
|
|
@@ -4537,7 +5109,24 @@
|
|
|
4537
5109
|
// no declareCollapse(), so re-aiming the bar at the hr cannot contradict
|
|
4538
5110
|
// a tinted set the way a forced caret would — but "the one site that does
|
|
4539
5111
|
// not ask the question" is how the next divergence gets in.
|
|
4540
|
-
if (!blockSelection)
|
|
5112
|
+
if (!blockSelection) {
|
|
5113
|
+
reaimToolbarBlockAtLine(result.newStartLine);
|
|
5114
|
+
// backlog #6: the "restore the caret too" decision the comment above
|
|
5115
|
+
// deferred. armEditables() has no 'line' branch (same degraded shape
|
|
5116
|
+
// as 'code' — see focusInsertedBlock()'s own branch for the
|
|
5117
|
+
// established precedent), so an <hr> has no contenteditable surface
|
|
5118
|
+
// to land a caret in at all; its own raw editor is the only real
|
|
5119
|
+
// landing. MEASURED (v3.3.0, this comment's own log above):
|
|
5120
|
+
// activeElement stayed BODY whether this branch ran or not — this
|
|
5121
|
+
// closes exactly that gap rather than picking "the anchor block" or
|
|
5122
|
+
// "the block after the hr", the two options the deferred comment
|
|
5123
|
+
// named: neither is where the user's attention actually is right
|
|
5124
|
+
// after pressing 分隔線, which is the hr itself.
|
|
5125
|
+
const target = blocks.find((b) => b.startLine === result.newStartLine);
|
|
5126
|
+
const hrEl = target &&
|
|
5127
|
+
document.querySelector('.ed-block[data-block-id="' + target.id + '"]');
|
|
5128
|
+
if (hrEl) await openRawEditor(hrEl);
|
|
5129
|
+
}
|
|
4541
5130
|
}
|
|
4542
5131
|
}
|
|
4543
5132
|
|
|
@@ -4926,7 +5515,32 @@
|
|
|
4926
5515
|
// this does NOT cover is a THROW inside applyPatch()/applyFullRender(),
|
|
4927
5516
|
// caught by safeRerenderAll(): that can leave the DOM half-swapped, and
|
|
4928
5517
|
// neither the old behaviour nor this one has an answer for it.
|
|
4929
|
-
|
|
5518
|
+
// `allowRawEditRescue` (Task 9 review I4): the backlog #6 raw-editor
|
|
5519
|
+
// fallback below is opt-in per CALLER, not a blanket behaviour of this
|
|
5520
|
+
// shared landing function. restoreAfterStructuralOp() has four callers —
|
|
5521
|
+
// changeHeadingDepth / convertBlockViaMenu / duplicateBlockViaMenu /
|
|
5522
|
+
// deleteBlockViaGutter — and backlog #6's own text names exactly one
|
|
5523
|
+
// gesture family (轉換成 quote/code), reached only through
|
|
5524
|
+
// convertBlockViaMenu(). MEASURED (review): the guard as first written
|
|
5525
|
+
// was unconditional and DID also fire for 建立副本 (duplicate) on a code
|
|
5526
|
+
// block or a blockquote, and for 刪除 (delete) when the surviving
|
|
5527
|
+
// neighbour was a blockquote — BODY → TEXTAREA.ed-raw in all three,
|
|
5528
|
+
// philosophically consistent (armEditables() never arms quote/code
|
|
5529
|
+
// regardless of which gesture produced one) but never asked for, never
|
|
5530
|
+
// reported, and never tested.
|
|
5531
|
+
// Final-review I2 correction: this paragraph used to also cite backlog #5
|
|
5532
|
+
// (the raw editor's own Escape discarding an uncommitted edit) as a
|
|
5533
|
+
// reason auto-opening one right after a DESTRUCTIVE gesture
|
|
5534
|
+
// (duplicate/delete) would not be free the way it is right after a plain
|
|
5535
|
+
// conversion. Backlog #5 was fixed by 3eb36f9, which landed BEFORE this
|
|
5536
|
+
// very commit on this branch — Escape out of a raw editor no longer
|
|
5537
|
+
// discards the edit, so that risk no longer applies. Retracted rather than
|
|
5538
|
+
// left to mislead whoever next considers widening this rescue. The
|
|
5539
|
+
// narrowing itself still stands on its own merits above (never asked for,
|
|
5540
|
+
// never reported, never tested for duplicate/delete) — widening past
|
|
5541
|
+
// convertBlockViaMenu() to the other three callers remains a separate
|
|
5542
|
+
// decision this task did not make and should not make silently.
|
|
5543
|
+
function restoreAfterStructuralOp(focusLine, anchorLine, allowRawEditRescue) {
|
|
4930
5544
|
// 有 blockSelection 站著時什麼都不做 —— 見 convertBlockViaMenu() 底下那段
|
|
4931
5545
|
// 註解:強行還原 caret 會做出「底色仍說已選取、Delete 卻是死鍵」的混種狀態。
|
|
4932
5546
|
if (blockSelection) return;
|
|
@@ -4935,6 +5549,27 @@
|
|
|
4935
5549
|
// 降級目標(quote / code)沒有可聚焦編輯面,focusBlockAtLine 會安靜
|
|
4936
5550
|
// no-op;把 toolbarBlockEl 指回那個 block,工具列才不會塌成 4 顆。
|
|
4937
5551
|
reaimToolbarBlockAtLine(focusLine);
|
|
5552
|
+
// backlog #6: focusBlockAtLine()'s no-op above is exactly the failure
|
|
5553
|
+
// mode this closes — armEditables() never arms quote/code (no branch
|
|
5554
|
+
// for either in its own condition), so blockContentEl() there hands
|
|
5555
|
+
// back a plain, non-focusable <blockquote>/<pre> and .focus() on it
|
|
5556
|
+
// is silently inert. MEASURED (v3.3.0): 轉換成 quote/code/line left
|
|
5557
|
+
// the caret on BODY with NOTHING to click into — "a mouse user climbs
|
|
5558
|
+
// out by clicking into the text" (activateToolbarCursor()'s own
|
|
5559
|
+
// comment) does not even apply here, since there is no editable text
|
|
5560
|
+
// to click into. The established way to edit either type at all is
|
|
5561
|
+
// the ⠿ menu's own MD 原始碼 raw editor (openRawEditor(), the same
|
|
5562
|
+
// one focusInsertedBlock()'s `kind === 'code'` branch already opens
|
|
5563
|
+
// for a freshly INSERTED code block) — reused here rather than
|
|
5564
|
+
// invented, so the landing this action leaves someone in is the
|
|
5565
|
+
// block's real, only editing surface, not a caret parked over
|
|
5566
|
+
// uneditable prose.
|
|
5567
|
+
if (allowRawEditRescue && document.activeElement === document.body) {
|
|
5568
|
+
const target = blocks.find((b) => b.startLine === focusLine);
|
|
5569
|
+
const blockEl = target &&
|
|
5570
|
+
document.querySelector('.ed-block[data-block-id="' + target.id + '"]');
|
|
5571
|
+
if (blockEl) openRawEditor(blockEl).catch(() => {});
|
|
5572
|
+
}
|
|
4938
5573
|
return;
|
|
4939
5574
|
}
|
|
4940
5575
|
if (anchorLine == null) return;
|
|
@@ -5041,7 +5676,10 @@
|
|
|
5041
5676
|
// 在全新 DOM 上把 roving focus 裝回 .ed-block wrapper(9 種轉換形狀全部
|
|
5042
5677
|
// 如此),這時強行還原 caret 會製造混種狀態:底色仍說「已選取」,Delete
|
|
5043
5678
|
// 卻變成死鍵、Shift+↓ 降級成移動文字游標。所以有選取時什麼都不做。
|
|
5044
|
-
|
|
5679
|
+
// Task 9 review I4: `true` — this is the ONE call site backlog #6's
|
|
5680
|
+
// raw-editor rescue is scoped to. See restoreAfterStructuralOp()'s
|
|
5681
|
+
// own `allowRawEditRescue` comment.
|
|
5682
|
+
restoreAfterStructuralOp(focusLine, anchor.line, true);
|
|
5045
5683
|
}
|
|
5046
5684
|
}
|
|
5047
5685
|
|
|
@@ -6269,6 +6907,13 @@
|
|
|
6269
6907
|
|
|
6270
6908
|
function startBurst(editEl) {
|
|
6271
6909
|
dropDiscardedBurst();
|
|
6910
|
+
// v3.4.0 (Task 7 item 5): opening ANY new session — burst or raw —
|
|
6911
|
+
// invalidates a still-parked raw-edit discard from a different block; see
|
|
6912
|
+
// discardedRawEdit's own comment for why the two stashes must stay
|
|
6913
|
+
// mutually exclusive.
|
|
6914
|
+
dropDiscardedRawEdit();
|
|
6915
|
+
// v3.4.0 batch3 Task 7: …and the wave twin, same rule, three stashes.
|
|
6916
|
+
dropDiscardedWaveEdit();
|
|
6272
6917
|
const blockEl = editEl.closest('.ed-block');
|
|
6273
6918
|
if (!blockEl) return;
|
|
6274
6919
|
const blockId = Number(blockEl.getAttribute('data-block-id'));
|
|
@@ -6714,6 +7359,19 @@
|
|
|
6714
7359
|
}
|
|
6715
7360
|
function clearBlockSelection() { blockSelection = null; applySelectionClasses(); }
|
|
6716
7361
|
|
|
7362
|
+
// Whether a key event landed in something that takes typed characters —
|
|
7363
|
+
// ANY such thing, wherever it is mounted. The block-selection key branch
|
|
7364
|
+
// documents "focus is on a block wrapper and not on any text surface" as its
|
|
7365
|
+
// precondition; this is that sentence as a check. `isContentEditable` covers
|
|
7366
|
+
// the WYSIWYG surfaces without naming their classes, and the three tag names
|
|
7367
|
+
// cover controls mounted anywhere, `.content` or not.
|
|
7368
|
+
function isTextEntryTarget(el) {
|
|
7369
|
+
if (el === null || el === undefined) return false;
|
|
7370
|
+
if (el.isContentEditable === true) return true;
|
|
7371
|
+
const tag = el.tagName;
|
|
7372
|
+
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
|
|
7373
|
+
}
|
|
7374
|
+
|
|
6717
7375
|
// ── S3 Task 5: surviving rerenderAll() (§4.4's ordered three steps) ────
|
|
6718
7376
|
// `contentEl.innerHTML` is replaced wholesale on every commit, so the
|
|
6719
7377
|
// painted tint and the roving focus holder are destroyed by definition —
|
|
@@ -7493,6 +8151,10 @@
|
|
|
7493
8151
|
burst.history.flushTyping();
|
|
7494
8152
|
editEl.innerHTML = burst.original;
|
|
7495
8153
|
burst.history.snap('escape-discard');
|
|
8154
|
+
// v3.4.0 (Task 7 item 5): mutual exclusivity with the raw-editor twin —
|
|
8155
|
+
// see discardedRawEdit's own comment.
|
|
8156
|
+
dropDiscardedRawEdit();
|
|
8157
|
+
dropDiscardedWaveEdit();
|
|
7496
8158
|
discardedBurst = {
|
|
7497
8159
|
history: burst.history, editEl, blockType: burst.blockType, cellIdx: -1,
|
|
7498
8160
|
reverted: burstBaselineHtml(editEl),
|
|
@@ -7511,6 +8173,10 @@
|
|
|
7511
8173
|
burst.history.flushTyping();
|
|
7512
8174
|
revertFn();
|
|
7513
8175
|
burst.history.snap('escape-discard');
|
|
8176
|
+
// v3.4.0 (Task 7 item 5): mutual exclusivity with the raw-editor twin —
|
|
8177
|
+
// see discardedRawEdit's own comment.
|
|
8178
|
+
dropDiscardedRawEdit();
|
|
8179
|
+
dropDiscardedWaveEdit();
|
|
7514
8180
|
discardedBurst = {
|
|
7515
8181
|
history: burst.history, editEl: tableEl, blockType: 'table', cellIdx,
|
|
7516
8182
|
reverted: burstBaselineHtml(tableEl),
|
|
@@ -7580,6 +8246,62 @@
|
|
|
7580
8246
|
return true;
|
|
7581
8247
|
}
|
|
7582
8248
|
|
|
8249
|
+
// v3.4.0 (Task 7 item 5) — the raw-editor twin of restoreDiscardedBurst()
|
|
8250
|
+
// just above. Ctrl+Z with no burst/raw editor open, when the last thing
|
|
8251
|
+
// that happened was an Escape/✕ out of a raw editor: RE-OPEN that same raw
|
|
8252
|
+
// editor and put the discarded text back into it — a live, dirty edit
|
|
8253
|
+
// again (hasChanges() reads true against the fresh seed, so the next
|
|
8254
|
+
// blur/Ctrl+Enter commits it, and a further Ctrl+Z steps back through the
|
|
8255
|
+
// reopened session same as any other raw edit). Escape's own semantics —
|
|
8256
|
+
// discard, and leave — are unchanged; only "gone forever" is.
|
|
8257
|
+
//
|
|
8258
|
+
// Staleness guard is asked at the `lines`/`blocks` level rather than the
|
|
8259
|
+
// DOM level restoreDiscardedBurst() uses (`document.body.contains(editEl)`
|
|
8260
|
+
// + a baseline-HTML comparison) — openRawEditor()'s own restore() tears
|
|
8261
|
+
// the textarea's DOM down unconditionally (see discardedRawEdit's own
|
|
8262
|
+
// comment), so there is no live node whose presence would even answer the
|
|
8263
|
+
// question. What has to hold instead is SIMILAR IN SPIRIT to what
|
|
8264
|
+
// reanchor() (this same file, openRawEditor()'s own re-derivation for a
|
|
8265
|
+
// session that survives a patch) asks — same two questions, "does the
|
|
8266
|
+
// block id still name a block?" and "are the bytes at its span still what
|
|
8267
|
+
// this session last saw?" — but it is NOT the same predicate, and the two
|
|
8268
|
+
// must not be conflated: reanchor()'s very first line is
|
|
8269
|
+
// `if (range) return false;`, i.e. it refuses EVERY rangeMode session
|
|
8270
|
+
// outright. The check below has no such refusal — for a single-block
|
|
8271
|
+
// (non-range) discard it asks the two questions above and nothing else.
|
|
8272
|
+
// In practice this asymmetry is moot for rangeMode: discardedRawEdit is
|
|
8273
|
+
// never actually populated for a rangeMode session in the first place
|
|
8274
|
+
// (see cancelAndMaybeDiscard()'s own comment — restore()'s rangeMode
|
|
8275
|
+
// branch wipes the stash via rerenderAll()'s unconditional
|
|
8276
|
+
// dropDiscardedRawEdit() before a Ctrl+Z could ever reach here), so this
|
|
8277
|
+
// function's range-shaped `d.range` field is carried for openRawEditor()'s
|
|
8278
|
+
// signature and is never exercised by a live stash today. Returns false
|
|
8279
|
+
// whenever there is nothing to restore or the stash has gone stale; the
|
|
8280
|
+
// caller then falls through to the ordinary document undo, same contract
|
|
8281
|
+
// as restoreDiscardedBurst().
|
|
8282
|
+
async function restoreDiscardedRawEdit() {
|
|
8283
|
+
const d = discardedRawEdit;
|
|
8284
|
+
discardedRawEdit = null;
|
|
8285
|
+
if (!d) return false;
|
|
8286
|
+
const b = blocks.find((x) => x.id === d.blockId);
|
|
8287
|
+
if (!b || b.startLine !== d.startLine || b.endLine !== d.endLine) return false;
|
|
8288
|
+
if (lines.slice(d.startLine - 1, d.endLine).join('\n') !== d.source) return false;
|
|
8289
|
+
const blockEl = blockElById(d.blockId);
|
|
8290
|
+
if (!blockEl) return false;
|
|
8291
|
+
await openRawEditor(blockEl, d.range);
|
|
8292
|
+
// Re-queried rather than reusing `blockEl` above: openRawEditor()'s own
|
|
8293
|
+
// guard may have resolved a DIFFERENT open session first (switchAwayFrom()),
|
|
8294
|
+
// which can re-render and hand out a fresh node for the same block id.
|
|
8295
|
+
const liveBlockEl = blockElById(d.blockId);
|
|
8296
|
+
const ta = liveBlockEl && liveBlockEl.querySelector('.ed-raw');
|
|
8297
|
+
if (!ta) return false; // a refusal (or a lost race) beat us to it
|
|
8298
|
+
ta.value = d.value;
|
|
8299
|
+
const caret = Math.max(0, Math.min(d.value.length, d.caret));
|
|
8300
|
+
ta.setSelectionRange(caret, caret);
|
|
8301
|
+
autoSize(ta);
|
|
8302
|
+
return true;
|
|
8303
|
+
}
|
|
8304
|
+
|
|
7583
8305
|
// Ctrl+Z inside a burst: step the burst-local history first; only once
|
|
7584
8306
|
// it's exhausted (atBottom — the surface is already back to its pre-focus
|
|
7585
8307
|
// baseline) does this cascade OUT to the document-level undo() stack,
|
|
@@ -8757,6 +9479,9 @@
|
|
|
8757
9479
|
// re-armed table, not a plain static one).
|
|
8758
9480
|
function startTableBurst(cellEl) {
|
|
8759
9481
|
dropDiscardedBurst();
|
|
9482
|
+
// v3.4.0 (Task 7 item 5): see startBurst()'s own comment just above.
|
|
9483
|
+
dropDiscardedRawEdit();
|
|
9484
|
+
dropDiscardedWaveEdit();
|
|
8760
9485
|
const tableEl = cellEl.closest('table');
|
|
8761
9486
|
const blockEl = tableEl && tableEl.closest('.ed-block');
|
|
8762
9487
|
if (!blockEl) return;
|
|
@@ -8823,9 +9548,13 @@
|
|
|
8823
9548
|
// time this focusin resumes. Instrumented on the F8 fixture (a
|
|
8824
9549
|
// paragraph left dirty, then a press into the table): the mousedown's
|
|
8825
9550
|
// focusout resolves that paragraph, and applyRenderResult()'s patch
|
|
8826
|
-
// gate consults domMatchesLastRender()
|
|
8827
|
-
// `lastParts`
|
|
8828
|
-
// the
|
|
9551
|
+
// gate consults domMatchesLastRender(). MEASURED AT THE TIME, when
|
|
9552
|
+
// `lastParts` was still null until a render landed and that is what made
|
|
9553
|
+
// the gate report false. Fix round 2 (re-review G2): `lastParts` is
|
|
9554
|
+
// seeded at first paint now and is never null, so the route below is
|
|
9555
|
+
// reached through `lastPartsIsBootstrapSeed` instead — the ROUTE and the
|
|
9556
|
+
// symptom are unchanged, only the reason the gate declines. The render
|
|
9557
|
+
// goes to applyFullRender(), whose `contentEl.innerHTML =`
|
|
8829
9558
|
// rewrites .content wholesale. A probe around the `await` above
|
|
8830
9559
|
// recorded route ["applyFullRender"] together with
|
|
8831
9560
|
// `document.body.contains(cellEl)` false, on each reproduction driven.
|
|
@@ -9372,28 +10101,104 @@
|
|
|
9372
10101
|
// here then names a DIFFERENT block. `classList.contains('ed-wys-table')`
|
|
9373
10102
|
// was the only guard, and every table passes it, so the gesture silently
|
|
9374
10103
|
// rewrote an untouched neighbouring table (a row drag even promoted one of
|
|
9375
|
-
// its body rows to its header).
|
|
9376
|
-
// blockElAtLine()'s own comment
|
|
9377
|
-
//
|
|
9378
|
-
//
|
|
9379
|
-
//
|
|
9380
|
-
//
|
|
9381
|
-
//
|
|
9382
|
-
//
|
|
9383
|
-
//
|
|
10104
|
+
// its body rows to its header). The fix added `startLine` — see
|
|
10105
|
+
// blockElAtLine()'s own comment — and a real identity check (header cell
|
|
10106
|
+
// count, header cell text, row count) before handing the block to a caller
|
|
10107
|
+
// that is about to mutate it.
|
|
10108
|
+
//
|
|
10109
|
+
// v3.4.0 Task 12: `startLine` ALONE carries the same hazard, one shape
|
|
10110
|
+
// over. DRIVEN, not argued (the v3.3.0 final review tried and could not,
|
|
10111
|
+
// because its fixture's commit never detached the table, so this branch
|
|
10112
|
+
// never ran at all): a six-line paragraph above the table, its raw editor
|
|
10113
|
+
// left dirty with a ONE-line replacement, then a row drag on the table.
|
|
10114
|
+
// The drop's own switchAwayFrom() commits that raw edit — the session's
|
|
10115
|
+
// first render, so it is a full `.content` rewrite and `tableEl` really is
|
|
10116
|
+
// detached here — and every block below moves up 5 lines. With the two
|
|
10117
|
+
// tables 5 lines apart, the SECOND table's new startLine is exactly the
|
|
10118
|
+
// first one's captured one; with matching header cells and row counts,
|
|
10119
|
+
// tableIdentityOf() agrees; and the drag reordered the neighbour's rows on
|
|
10120
|
+
// screen and on disk while the dragged table sat untouched. Instrumented
|
|
10121
|
+
// run and assertion: test/table-anchor-recovery.test.js.
|
|
10122
|
+
//
|
|
10123
|
+
// So this asks the SAME composite anchor handleTableCellFocusIn() above
|
|
10124
|
+
// asks, for the same reason its comment writes down — the two handles fail
|
|
10125
|
+
// on DIFFERENT commits (a line added ahead of the table with the block
|
|
10126
|
+
// count unchanged breaks startLine but not the id; a block added ahead of
|
|
10127
|
+
// it with the line count unchanged breaks the id but not startLine), so
|
|
10128
|
+
// neither subsumes the other and the id is asked first. Whichever answers,
|
|
10129
|
+
// tableIdentityOf() still has to agree. A commit that defeats BOTH handles
|
|
10130
|
+
// leaves nothing to resolve; returning null drops the gesture, which is
|
|
10131
|
+
// the conservative half of the trade — never mutating the wrong table
|
|
10132
|
+
// beats completing every gesture.
|
|
10133
|
+
// v3.4.0 Task 12: this used to be row count + HEADER cell text only, and
|
|
10134
|
+
// that is exactly as much as two different tables in one document can
|
|
10135
|
+
// share by accident — "| A | B |" over two rows describes an enormous
|
|
10136
|
+
// number of real tables, and a document that repeats a header (a register
|
|
10137
|
+
// map, a pin list, one table per block) has several. Both call sites use
|
|
10138
|
+
// this to decide whether a block they re-resolved by a possibly-stale
|
|
10139
|
+
// handle is the table the gesture started on, so a collision there is not
|
|
10140
|
+
// a near miss: the row drag Task 12 drove reordered the NEIGHBOUR's rows,
|
|
10141
|
+
// on screen and on disk, while the dragged table sat untouched. Every
|
|
10142
|
+
// row's cells go in, so two tables collide only when their rendered text
|
|
10143
|
+
// is identical cell for cell.
|
|
10144
|
+
//
|
|
10145
|
+
// The commit being recovered from is always some OTHER block's (a burst on
|
|
10146
|
+
// THIS table early-returns before any of this), so this table's own
|
|
10147
|
+
// rendered text is what it was when the handle was captured — the stronger
|
|
10148
|
+
// identity refuses collisions without refusing the table itself.
|
|
10149
|
+
//
|
|
10150
|
+
// The delimiters are written as ESCAPES, not as literal control bytes.
|
|
10151
|
+
// They used to be literal, which made grep classify this whole 9643-line
|
|
10152
|
+
// file as binary and SILENTLY TRUNCATE its output — a real investigation
|
|
10153
|
+
// was misled by it. The runtime value is identical.
|
|
9384
10154
|
function tableIdentityOf(tableEl) {
|
|
9385
10155
|
const headerRow = tableEl ? headerRowOf(tableEl) : null;
|
|
9386
10156
|
if (!headerRow) return null;
|
|
9387
|
-
|
|
9388
|
-
|
|
9389
|
-
|
|
9390
|
-
|
|
9391
|
-
|
|
9392
|
-
|
|
9393
|
-
|
|
9394
|
-
|
|
10157
|
+
const rows = allRowsOf(tableEl);
|
|
10158
|
+
return rows.length + '\x01' + rows.map((r) =>
|
|
10159
|
+
Array.prototype.slice.call(r.cells).map((c) => c.textContent).join('\x00')).join('\x02');
|
|
10160
|
+
}
|
|
10161
|
+
// v3.4.0 §2: `forDrag` narrows the policy above for the ROW/COLUMN DRAG
|
|
10162
|
+
// callers only (performRowDrop()/performColDrop() below) — every other
|
|
10163
|
+
// caller (the insert-row/insert-column hover bubbles, cycleAlign(),
|
|
10164
|
+
// deleteRow/deleteColumn's own callers) keeps focusing cells[0] with a
|
|
10165
|
+
// normal, scrolling focus() exactly as before, unchanged.
|
|
10166
|
+
//
|
|
10167
|
+
// Why a drag needs a DIFFERENT policy, not just a smaller guard elsewhere:
|
|
10168
|
+
// a drag gesture never had a focused cell of its own, yet THIS function's
|
|
10169
|
+
// own `cell.focus()` a few lines down is what actually opens the burst in
|
|
10170
|
+
// the first place — the delegated focusin listener
|
|
10171
|
+
// (handleTableCellFocusIn() above) is the ONLY path that calls
|
|
10172
|
+
// startTableBurst(), so the call can't simply be skipped for a drag; the
|
|
10173
|
+
// burst would never open at all, and every existing row/column drag
|
|
10174
|
+
// scenario that asserts "a drop auto-starts a burst" (a cell must be
|
|
10175
|
+
// focused afterward) would break. What can change for a drag, since it
|
|
10176
|
+
// never had an opinion about WHICH cell should end up focused, is WHICH
|
|
10177
|
+
// cell this picks and HOW it focuses it:
|
|
10178
|
+
// - `{ preventScroll: true }` — a plain focus() with no options scrolls
|
|
10179
|
+
// its target into view. A stack-traced probe (measured, not guessed —
|
|
10180
|
+
// see performRowDrop()'s own comment below for the full write-up)
|
|
10181
|
+
// found this is only HALF the "drag jumps to the header" symptom's
|
|
10182
|
+
// origin: restoreTableFocus() (below) always re-resolves and
|
|
10183
|
+
// re-focuses the SAME cell this call already focused — there is no
|
|
10184
|
+
// way to skip that second focus() by checking an index, since the
|
|
10185
|
+
// index it would check is always valid by the time it's read (same
|
|
10186
|
+
// comment). So restoreTableFocus()'s own focus() ALSO needs
|
|
10187
|
+
// `preventScroll: true` — this call alone was never going to be
|
|
10188
|
+
// enough.
|
|
10189
|
+
// - the first BODY cell (a <td>), not cells[0] (the header <th>) — a
|
|
10190
|
+
// table drag's own regression scenario asserts the resulting active
|
|
10191
|
+
// element is not a header cell. Every cell (header or body) already
|
|
10192
|
+
// carries 'ed-wys-cell' (armEditables()'s table branch, arm time), so
|
|
10193
|
+
// a body cell satisfies "a drop auto-starts a burst" exactly as well
|
|
10194
|
+
// as the header did — and restoreTableFocus() re-focuses whichever
|
|
10195
|
+
// cell THIS picks, so picking a body cell here is what keeps its own
|
|
10196
|
+
// re-focus off the header too. Falls back to cells[0] only when the
|
|
10197
|
+
// table has no body row at all (nothing to drag in that case anyway).
|
|
10198
|
+
async function ensureTableBurstOpen(tableEl, forDrag) {
|
|
9395
10199
|
if (currentBurst && currentBurst.blockType === 'table' && currentBurst.editEl === tableEl) return tableEl;
|
|
9396
10200
|
const blockEl = tableEl.closest('.ed-block');
|
|
10201
|
+
const blockIdAttr = blockEl ? blockEl.getAttribute('data-block-id') : null;
|
|
9397
10202
|
const blockId = blockEl ? Number(blockEl.getAttribute('data-block-id')) : null;
|
|
9398
10203
|
const block = blockId != null ? blocks.find((b) => b.id === blockId) : null;
|
|
9399
10204
|
const startLine = block ? block.startLine : null;
|
|
@@ -9402,24 +10207,56 @@
|
|
|
9402
10207
|
if (!ok) return null;
|
|
9403
10208
|
let liveTableEl = tableEl;
|
|
9404
10209
|
if (!document.body.contains(tableEl)) {
|
|
9405
|
-
|
|
9406
|
-
|
|
9407
|
-
//
|
|
10210
|
+
// Both handles are gated by the SAME identity check, so a candidate
|
|
10211
|
+
// that is not a live armed table, or is a table this gesture never
|
|
10212
|
+
// touched, simply does not answer and the next handle gets its turn —
|
|
10213
|
+
// rather than each handle carrying its own copy of the two refusals.
|
|
10214
|
+
const isThisTable = (t) => !!(t && t.classList &&
|
|
10215
|
+
t.classList.contains('ed-wys-table') && identity != null &&
|
|
10216
|
+
tableIdentityOf(t) === identity);
|
|
10217
|
+
const liveTableFrom = (candidateBlockEl) => {
|
|
10218
|
+
const t = candidateBlockEl ? blockContentEl(candidateBlockEl) : null;
|
|
10219
|
+
return isThisTable(t) ? t : null;
|
|
10220
|
+
};
|
|
10221
|
+
liveTableEl =
|
|
10222
|
+
liveTableFrom(blockIdAttr != null
|
|
10223
|
+
? document.querySelector('.ed-block[data-block-id="' + blockIdAttr + '"]') : null) ||
|
|
10224
|
+
liveTableFrom(startLine != null ? blockElAtLine(startLine) : null);
|
|
10225
|
+
// v3.4.0 Task 12, second half: identity says "this LOOKS like the table
|
|
10226
|
+
// the gesture started on", and a handle says "and it is at the place I
|
|
10227
|
+
// remember". Neither says the answer was the only one available. DRIVEN
|
|
10228
|
+
// on two byte-identical tables (a shape a register map or a pin list
|
|
10229
|
+
// produces by itself) with the gesture on the SECOND one: the commit
|
|
10230
|
+
// renumbered the ids, the captured id landed on the FIRST twin, and
|
|
10231
|
+
// identity — which cannot tell twins apart, however much of the table
|
|
10232
|
+
// it hashes — waved it through. The drag reordered the first table's
|
|
10233
|
+
// rows and left the dragged one alone, on screen and on disk.
|
|
10234
|
+
//
|
|
10235
|
+
// So when the document holds more than one table this gesture could
|
|
10236
|
+
// have started on, no handle is allowed to break the tie: nothing here
|
|
10237
|
+
// knows which twin the user pressed, and the wrong guess rewrites a
|
|
10238
|
+
// table they never touched. Refuse instead — the same conservative half
|
|
10239
|
+
// of the trade this whole recovery already takes. Counted over
|
|
10240
|
+
// `contentEl` (not the whole document) so the grip/bubble chrome
|
|
10241
|
+
// outside it can never be mistaken for a candidate.
|
|
10242
|
+
if (liveTableEl && Array.prototype.slice.call(
|
|
10243
|
+
contentEl.querySelectorAll('.ed-wys-table')).filter(isThisTable).length !== 1) {
|
|
10244
|
+
liveTableEl = null;
|
|
10245
|
+
}
|
|
10246
|
+
// T7: the refusal used to `return null` in silence — the drag, the
|
|
9408
10247
|
// insert, the alignment change simply did not happen and nothing on
|
|
9409
10248
|
// screen said why, which is indistinguishable from a broken control.
|
|
9410
10249
|
// Dropping is still the right answer (see the S1 comment above); saying
|
|
9411
10250
|
// nothing was not.
|
|
9412
|
-
if (!liveTableEl
|
|
9413
|
-
showBanner(DROPPED_GESTURE_MESSAGE, null, null); return null;
|
|
9414
|
-
}
|
|
9415
|
-
if (identity == null || tableIdentityOf(liveTableEl) !== identity) {
|
|
10251
|
+
if (!liveTableEl) {
|
|
9416
10252
|
showBanner(DROPPED_GESTURE_MESSAGE, null, null); return null;
|
|
9417
10253
|
}
|
|
9418
10254
|
}
|
|
9419
10255
|
if (currentBurst && currentBurst.blockType === 'table' && currentBurst.editEl === liveTableEl) return liveTableEl;
|
|
9420
|
-
const
|
|
10256
|
+
const cells = tableCellsOf(liveTableEl);
|
|
10257
|
+
const cell = forDrag ? (cells.find((c) => c.tagName !== 'TH') || cells[0]) : cells[0];
|
|
9421
10258
|
if (!cell) return null;
|
|
9422
|
-
cell.focus();
|
|
10259
|
+
if (forDrag) cell.focus({ preventScroll: true }); else cell.focus();
|
|
9423
10260
|
return (currentBurst && currentBurst.blockType === 'table' && currentBurst.editEl === liveTableEl)
|
|
9424
10261
|
? liveTableEl : null;
|
|
9425
10262
|
}
|
|
@@ -9449,8 +10286,40 @@
|
|
|
9449
10286
|
// resolves to (which may differ from `tableEl` if a dirty burst
|
|
9450
10287
|
// elsewhere just committed and swapped `.content` out from under this
|
|
9451
10288
|
// click) — never the possibly-now-detached `tableEl` itself.
|
|
10289
|
+
//
|
|
10290
|
+
// v3.4.0 backlog #2 sister fix: same root cause as
|
|
10291
|
+
// onRowInsertBubbleClick() below — `colIndex` is set by
|
|
10292
|
+
// updateTableInsertBubbles() at an EARLIER hover and never re-validated
|
|
10293
|
+
// against the live table here. Unlike `afterRowIndex`, `colIndex` has
|
|
10294
|
+
// NO legal sentinel value at all (no "-1" case; updateTableInsertBubbles()
|
|
10295
|
+
// only ever assigns 0..headerCells.length-1, each meaning "insert after
|
|
10296
|
+
// column i"), so every value this dataset can legitimately hold must
|
|
10297
|
+
// name a real column on `liveTableEl`. A stale colIndex that no longer
|
|
10298
|
+
// does doesn't crash — insertColumn()'s `row.cells[colIndex]` lookup
|
|
10299
|
+
// silently comes back undefined, and `row.insertBefore(cell, ref ?
|
|
10300
|
+
// ref.nextSibling : null)` falls to its `null` branch, silently
|
|
10301
|
+
// APPENDING the new column at the very END of the table instead of
|
|
10302
|
+
// dropping the gesture. MEASURED (task-6-report.md): unreachable via
|
|
10303
|
+
// real gesture today, guarded by the exact same two independent
|
|
10304
|
+
// mechanisms as the row sibling (updateTableInsertBubbles()'s own
|
|
10305
|
+
// proximity-hide, and rerenderAll()'s unconditional
|
|
10306
|
+
// hideTableInsertBubbles()/hideTableGrips() on every full render) — kept
|
|
10307
|
+
// as defense-in-depth against the day either one is weakened, same
|
|
10308
|
+
// reasoning as the row fix.
|
|
9452
10309
|
const liveTableEl = await ensureTableBurstOpen(tableEl);
|
|
9453
10310
|
if (!liveTableEl) return;
|
|
10311
|
+
const headerRow = headerRowOf(liveTableEl);
|
|
10312
|
+
const targetCell = headerRow ? headerRow.cells[colIndex] : null;
|
|
10313
|
+
// No sentinel to exclude here (contrast afterRowIndex: -1 above) — any
|
|
10314
|
+
// colIndex that doesn't name a real column on the live table is stale.
|
|
10315
|
+
// Same policy as the DROPPED_GESTURE_MESSAGE precedent this file already
|
|
10316
|
+
// uses throughout (performBlockDrop()/performListItemDrop() et al.,
|
|
10317
|
+
// 15 call sites): can't relocate it on the live table -> drop the
|
|
10318
|
+
// gesture and say so, never guess.
|
|
10319
|
+
if (!targetCell) {
|
|
10320
|
+
showBanner(DROPPED_GESTURE_MESSAGE, null, null);
|
|
10321
|
+
return;
|
|
10322
|
+
}
|
|
9454
10323
|
currentBurst.history.snap('insert-col-pre');
|
|
9455
10324
|
insertColumn(liveTableEl, colIndex);
|
|
9456
10325
|
armNewTableCells(liveTableEl);
|
|
@@ -9467,22 +10336,42 @@
|
|
|
9467
10336
|
// Final-review Finding 6: same live-table swap as onColInsertBubbleClick()
|
|
9468
10337
|
// above — the table element reference has to be re-resolved.
|
|
9469
10338
|
//
|
|
9470
|
-
// ⚠ RETRACTION. An earlier revision of
|
|
9471
|
-
// `afterRowIndex`, being a plain
|
|
9472
|
-
//
|
|
9473
|
-
//
|
|
9474
|
-
//
|
|
9475
|
-
//
|
|
9476
|
-
//
|
|
9477
|
-
// `
|
|
9478
|
-
//
|
|
9479
|
-
//
|
|
9480
|
-
//
|
|
9481
|
-
//
|
|
9482
|
-
//
|
|
10339
|
+
// ⚠ RETRACTION, resolved in v3.4.0 (backlog #2). An earlier revision of
|
|
10340
|
+
// this comment went on to say that `afterRowIndex`, being a plain
|
|
10341
|
+
// integer, stays valid across that swap. Ruling T3-9 measured the
|
|
10342
|
+
// opposite: `afterRowIndex` is set by updateTableInsertBubbles() at
|
|
10343
|
+
// HOVER time, which can be arbitrarily earlier than this click — the
|
|
10344
|
+
// table's row count can shrink in between (its own delete-row, an undo,
|
|
10345
|
+
// anything) without a fresh mousemove ever repositioning the bubble to
|
|
10346
|
+
// notice. `tableIdentityOf()`'s row-count component (used by
|
|
10347
|
+
// ensureTableBurstOpen()'s own detached-table guard below) does NOT
|
|
10348
|
+
// catch this: identity is captured from `tableEl` at THIS click, in the
|
|
10349
|
+
// same synchronous prefix as `afterRowIndex` was just read above — it
|
|
10350
|
+
// always matches whatever the table looks like right now, so a handover
|
|
10351
|
+
// whose SOURCE the row count came from is unrelated to this table passes
|
|
10352
|
+
// it clean. `bodyRowsOf(liveTableEl)[afterRowIndex]` is the only place
|
|
10353
|
+
// that can see the stale index was for a row that no longer exists — a
|
|
10354
|
+
// naive `afterRow` falsy check does not distinguish "no row after this
|
|
10355
|
+
// one" (afterRowIndex: -1, `null` deliberately) from "this index no
|
|
10356
|
+
// longer names anything" (`undefined`), and insertRow() treats both
|
|
10357
|
+
// identically: `tbody.insertBefore(newRow, tbody.firstChild)`, i.e. the
|
|
10358
|
+
// row lands at the TOP of the table instead of the gesture being
|
|
10359
|
+
// dropped. Task 14's hide-on-approach fix (see updateTableInsertBubbles()
|
|
10360
|
+
// below) made this bubble reliably pressable, which widened the exposure
|
|
10361
|
+
// of this deferred defect rather than narrowing it.
|
|
9483
10362
|
const liveTableEl = await ensureTableBurstOpen(tableEl);
|
|
9484
10363
|
if (!liveTableEl) return;
|
|
9485
10364
|
const afterRow = afterRowIndex >= 0 ? bodyRowsOf(liveTableEl)[afterRowIndex] : null;
|
|
10365
|
+
// afterRowIndex >= 0 names a SPECIFIC row that must still exist on the
|
|
10366
|
+
// live table; afterRowIndex === -1 deliberately produces a `null`
|
|
10367
|
+
// afterRow (the header-boundary/first-body-row case) and must NOT be
|
|
10368
|
+
// caught here. Same policy as performRowDrop()/runDeleteRow() above:
|
|
10369
|
+
// can't relocate it on the live table -> drop the gesture and say so,
|
|
10370
|
+
// never guess.
|
|
10371
|
+
if (afterRowIndex >= 0 && !afterRow) {
|
|
10372
|
+
showBanner(DROPPED_GESTURE_MESSAGE, null, null);
|
|
10373
|
+
return;
|
|
10374
|
+
}
|
|
9486
10375
|
currentBurst.history.snap('insert-row-pre');
|
|
9487
10376
|
insertRow(liveTableEl, afterRow);
|
|
9488
10377
|
armNewTableCells(liveTableEl);
|
|
@@ -9816,9 +10705,32 @@
|
|
|
9816
10705
|
// ~104px to ~60px, which puts the menu under the toolbar while the grip that
|
|
9817
10706
|
// opens it is still comfortably clickable. Both numbers were measured on
|
|
9818
10707
|
// this branch, on either side of that change.
|
|
9819
|
-
|
|
9820
|
-
|
|
10708
|
+
/**
|
|
10709
|
+
* The y a viewport-anchored affordance may not go above.
|
|
10710
|
+
*
|
|
10711
|
+
* The toolbar owns the top of the viewport and it owns it at `z-index: 101`,
|
|
10712
|
+
* which is above every floating thing this file positions — so an affordance
|
|
10713
|
+
* placed at a smaller `top` is not merely close to the toolbar, it is UNDER
|
|
10714
|
+
* it: painted, hit-tested and clicked by the bar instead. `--ed-toolbar-h` is
|
|
10715
|
+
* the height the bar reserves, and it is the floor `.ed-te-menu` and
|
|
10716
|
+
* `.ed-seltb` already use; v3.4.0 batch 1 moved `.ed-conflict` to the same
|
|
10717
|
+
* value for the same reason.
|
|
10718
|
+
*
|
|
10719
|
+
* It is a FUNCTION and not a copy of the number in three places because the
|
|
10720
|
+
* number was copied in three places: `.ed-wave-edit-btn` floored at `4`
|
|
10721
|
+
* instead, and measured at 1200x800 with a 180px diagram scrolled to
|
|
10722
|
+
* `r.top = -60` the button painted at `top: 4`, `elementFromPoint` on its own
|
|
10723
|
+
* centre returned `DIV.ed-toolbar`, and a real click left the editor closed —
|
|
10724
|
+
* with no keyboard entry to fall back on, that scroll position had no way in
|
|
10725
|
+
* at all. The next affordance reads this instead of rediscovering it.
|
|
10726
|
+
*/
|
|
10727
|
+
function toolbarTopInset() {
|
|
10728
|
+
return parseFloat(getComputedStyle(document.documentElement)
|
|
9821
10729
|
.getPropertyValue('--ed-toolbar-h')) || 0;
|
|
10730
|
+
}
|
|
10731
|
+
|
|
10732
|
+
function placeTeMenuAboveCell(r) {
|
|
10733
|
+
const topInset = toolbarTopInset();
|
|
9822
10734
|
let top = r.top - teEdgeMenu.offsetHeight - TE_MENU_GAP_PX;
|
|
9823
10735
|
if (top < topInset) top = r.bottom + TE_MENU_GAP_PX;
|
|
9824
10736
|
teEdgeMenu.style.top = top + 'px';
|
|
@@ -10390,21 +11302,82 @@
|
|
|
10390
11302
|
// from table-leave, burst-end, and drag-start elsewhere) already covers
|
|
10391
11303
|
// every path that actually needs to clear it.
|
|
10392
11304
|
//
|
|
10393
|
-
//
|
|
10394
|
-
//
|
|
10395
|
-
//
|
|
10396
|
-
//
|
|
10397
|
-
//
|
|
10398
|
-
//
|
|
10399
|
-
//
|
|
10400
|
-
//
|
|
10401
|
-
//
|
|
10402
|
-
|
|
11305
|
+
// v3.4.0 (backlog #11, closing Ruling T14-5's deferral): the list below
|
|
11306
|
+
// now also names '.ed-tb-insert' — the hover-insert + bubble is the same
|
|
11307
|
+
// kind of `position: fixed` overlay painted over the same band as the two
|
|
11308
|
+
// grips, and without this the pointer standing on it fell through to the
|
|
11309
|
+
// branches beneath and took the ⠿ grips down (hideTableGrips() nulling
|
|
11310
|
+
// gripRowTableEl/gripRowEl/gripColTableEl/gripColIndex, not just hiding
|
|
11311
|
+
// the DOM).
|
|
11312
|
+
//
|
|
11313
|
+
// MEASURED effect on setGutterKeep(), which T14-5 left unmeasured: on a
|
|
11314
|
+
// 2-row/2-col fixture, hovering the header row raised the row grip at
|
|
11315
|
+
// rect {left:402, top:175.7, right:422, bottom:203.7}. The row-insert
|
|
11316
|
+
// bubble for "after the header row" rendered at {left:403, top:199.4,
|
|
11317
|
+
// right:421, bottom:217.4} — the bubble's OWN vertical centre (208.4)
|
|
11318
|
+
// already sits 0.6px below pointInRowGripZone()'s padded zone (bottom
|
|
11319
|
+
// 207.7 = grip bottom + the 4px pad), so a pointer landing there hit the
|
|
11320
|
+
// "not in zone" branch and called hideTableGrips() even before this fix.
|
|
11321
|
+
// Only the bubble's own top ~8px sliver (199.4-207.7) actually overlaps
|
|
11322
|
+
// the zone. Review round 2, M2: that 8.4px is this fixture's number, not
|
|
11323
|
+
// a constant — the general form (derived from the row grip's fixed
|
|
11324
|
+
// CSS height 28px, vertically centred on its own row per
|
|
11325
|
+
// buildTableGrip()/the row-grip positioning above, TE_GRIP_ZONE_PAD_PX=4,
|
|
11326
|
+
// and the bubble's own half-height 9px) is
|
|
11327
|
+
// `overlap = max(0, 27 - headerRowHeight / 2)` for any header row
|
|
11328
|
+
// taller than 18px (shorter than that and the whole 18px bubble sits
|
|
11329
|
+
// inside the zone — unreachable in practice, since padding alone
|
|
11330
|
+
// ('th, td { padding: 7px 14px }') already puts a real header row above
|
|
11331
|
+
// it). It hits 0 — no overlap at all — once the header row is >=54px
|
|
11332
|
+
// tall. This fixture's 37.3px header row gives 27 - 18.6 = 8.4, matching
|
|
11333
|
+
// the measured number above.
|
|
11334
|
+
//
|
|
11335
|
+
// The column-insert bubble does NOT overlap the row grip's
|
|
11336
|
+
// zone on the fixture measured above (x=[884.6, 902.6] against the
|
|
11337
|
+
// grip's padded x=[398,426]) — review round 2 corrected an earlier draft
|
|
11338
|
+
// of this comment that called that "structural", i.e. impossible in
|
|
11339
|
+
// general. It is not: the column bubble sits at `cellRight - half`
|
|
11340
|
+
// (half = TB_BUBBLE_SIZE/2 = 9), so it overlaps the row grip's own
|
|
11341
|
+
// padded zone (right edge at tableLeft + TE_GRIP_ZONE_PAD_PX = tableLeft
|
|
11342
|
+
// + 14, see pointInRowGripZone()) whenever `cellRight <= tableLeft + 23`.
|
|
11343
|
+
// The FIRST column is the only one close enough to matter, and its
|
|
11344
|
+
// MINIMUM width from `th, td { padding: 7px 14px; border: 1px solid … }`
|
|
11345
|
+
// alone (an empty or near-empty first cell) is ~30px — a 7px margin
|
|
11346
|
+
// above the 23px threshold, not a structural gap. That margin is a CSS
|
|
11347
|
+
// padding constant, not a geometric guarantee, and this repo's own
|
|
11348
|
+
// `table { overflow-x: auto }` can erode it directly: scrolling a wide
|
|
11349
|
+
// table's inner content moves `cellRect.right` toward (and, scrolled far
|
|
11350
|
+
// enough, past) `tableRect.left` while the row grip stays anchored to
|
|
11351
|
+
// the table's own (unscrolled) left edge, so the same overlap this
|
|
11352
|
+
// function already tolerates for the row-insert bubble becomes reachable
|
|
11353
|
+
// for the column-insert bubble too on a sufficiently scrolled table. The
|
|
11354
|
+
// BEHAVIOUR conclusion is unaffected either way — that path's delta is
|
|
11355
|
+
// still only the same cosmetic keep-lit loss described above, since
|
|
11356
|
+
// '.ed-tb-insert' never matches '.ed-te-grip-row' regardless of which
|
|
11357
|
+
// bubble the pointer is on — only the "never" in this comment was wrong.
|
|
11358
|
+
//
|
|
11359
|
+
// So the only real change is: for the narrow case where (a) the header
|
|
11360
|
+
// row's own grip is the one currently shown AND (b) the pointer is in
|
|
11361
|
+
// that ~8px sliver of the header row's insert bubble, the old fallback
|
|
11362
|
+
// branch below (pointInRowGripZone()/pointInColGripZone(), reached by
|
|
11363
|
+
// NOT matching this guard) could call setGutterKeep(headerGripBlock())
|
|
11364
|
+
// and light the gutter; the widened guard here calls setGutterKeep(null)
|
|
11365
|
+
// unconditionally instead (same as the existing column-grip branch),
|
|
11366
|
+
// because '.ed-tb-insert' never matches '.ed-te-grip-row'. The ⠿'s
|
|
11367
|
+
// keep-lit assist is not applied while the pointer sits in that sliver.
|
|
11368
|
+
// Accepted: it is a purely cosmetic loss (the block's own :hover keeps
|
|
11369
|
+
// the ⠿ visible whenever the pointer is actually over the block; keep-lit
|
|
11370
|
+
// only covers the pointer being OUTSIDE it) confined to an ~8px band on
|
|
11371
|
+
// one bubble, against hideTableGrips() tearing down all four module-level
|
|
11372
|
+
// refs and both grips' DOM visibility on every approach to any bubble —
|
|
11373
|
+
// the defect this task exists to close.
|
|
11374
|
+
if (target && target.closest &&
|
|
11375
|
+
(target.closest('.ed-te-grip-row') || target.closest('.ed-te-grip-col') || target.closest('.ed-tb-insert'))) {
|
|
10403
11376
|
// v3.0.2: only the ROW grip is the one that covers the ⠿'s approach.
|
|
10404
11377
|
// Testing the hit rather than reusing the branch condition matters —
|
|
10405
|
-
// the column grip
|
|
10406
|
-
//
|
|
10407
|
-
// fix is scoped to avoid.
|
|
11378
|
+
// the column grip (and, as of v3.4.0, the insert bubble) sits nowhere
|
|
11379
|
+
// near a position lighting the gutter should follow, and lighting it
|
|
11380
|
+
// from there is the behaviour change this fix is scoped to avoid.
|
|
10408
11381
|
setGutterKeep(target.closest('.ed-te-grip-row') ? headerGripBlock() : null);
|
|
10409
11382
|
return;
|
|
10410
11383
|
}
|
|
@@ -10715,13 +11688,47 @@
|
|
|
10715
11688
|
// 吃掉之後沒有人會把焦點放回去,document.activeElement 會落到 <body>,
|
|
10716
11689
|
// 於是 keydown 走不到 handleTableCellKeydown,Ctrl+Z 會落到全域 undo()
|
|
10717
11690
|
// 而先 commit 再退。用序位重新解析目標格並真的 focus。
|
|
11691
|
+
// v3.4.0 §2 (measured, not guessed — see the two call sites' own comment
|
|
11692
|
+
// below for the full chain): by the time this runs, BOTH callers' drags
|
|
11693
|
+
// already have SOME cell focused — either the burst was already open
|
|
11694
|
+
// (the user had genuinely clicked/edited a cell themselves;
|
|
11695
|
+
// ensureTableBurstOpen() early-returns without calling focus() at all in
|
|
11696
|
+
// that case) or ensureTableBurstOpen() just opened a fresh one via its
|
|
11697
|
+
// own `forDrag` branch (which DOES call focus(), on the first body
|
|
11698
|
+
// cell). Either way, a plain focus() here was re-focusing that SAME cell
|
|
11699
|
+
// a second time — a stack-traced probe caught exactly that: two focus()
|
|
11700
|
+
// calls on the identical element, the second one (this one) with no
|
|
11701
|
+
// options, immediately followed by the page scrolling. `preventScroll`
|
|
11702
|
+
// stops that second, redundant scroll without changing which cell ends
|
|
11703
|
+
// up focused.
|
|
11704
|
+
// v3.4.0 §2: `cellIndex < 0`'s fallback used to be `cells[0]` — the
|
|
11705
|
+
// header cell — inherited from before this function had a `forDrag`-style
|
|
11706
|
+
// "avoid the header" policy anywhere. It stayed reachable in theory (a
|
|
11707
|
+
// negative index means "no cell to re-resolve"), but the row/col-drop
|
|
11708
|
+
// stale-index defect fixed below (both call sites now re-resolve against
|
|
11709
|
+
// a rebuild-stable coordinate rather than a cell reference/ordinal that
|
|
11710
|
+
// may have moved) removes every realistic path that could still produce
|
|
11711
|
+
// one here: a drag never deletes the active row/cell, only reorders it,
|
|
11712
|
+
// so a valid `activeCellEl` captured before the drop always resolves to
|
|
11713
|
+
// SOME live cell afterward. Changed anyway, defensively, since both of
|
|
11714
|
+
// this function's only two callers are drag paths (same fact the
|
|
11715
|
+
// `forDrag` policy above is built on): prefer the first BODY cell over
|
|
11716
|
+
// the header, same as `ensureTableBurstOpen()`'s own `forDrag` pick — a
|
|
11717
|
+
// drag landing on the header is exactly the symptom this whole task
|
|
11718
|
+
// fixed, so a fallback that still could is worth closing even if nothing
|
|
11719
|
+
// currently reaches it. Falls through to `cells[0]` only when the table
|
|
11720
|
+
// has no body row at all; `if (!cell) return` below still means "don't
|
|
11721
|
+
// focus anything" for the fully-empty case, covering that alternative
|
|
11722
|
+
// too.
|
|
10718
11723
|
function restoreTableFocus(tableEl, cellIndex) {
|
|
10719
11724
|
const cells = tableCellsOf(tableEl);
|
|
10720
|
-
const cell =
|
|
11725
|
+
const cell = cellIndex >= 0
|
|
11726
|
+
? cells[Math.min(cellIndex, cells.length - 1)]
|
|
11727
|
+
: (cells.find((c) => c.tagName !== 'TH') || cells[0]);
|
|
10721
11728
|
if (!cell) return;
|
|
10722
11729
|
if (currentBurst && currentBurst.blockType === 'table') currentBurst.activeCellEl = cell;
|
|
10723
11730
|
selToolbarEditEl = cell;
|
|
10724
|
-
cell.focus();
|
|
11731
|
+
cell.focus({ preventScroll: true });
|
|
10725
11732
|
placeCaretAtEnd(cell);
|
|
10726
11733
|
}
|
|
10727
11734
|
|
|
@@ -10730,7 +11737,11 @@
|
|
|
10730
11737
|
// 可能 resolve 掉別的 block 的 dirty burst 並換掉整片 .content,所以
|
|
10731
11738
|
// 先轉成 allRowsOf() 的 ordinal,之後在 live table 上重新定位。
|
|
10732
11739
|
const rowIndex = allRowsOf(tableEl).indexOf(rowEl);
|
|
10733
|
-
|
|
11740
|
+
// v3.4.0 §2: `forDrag=true` — see ensureTableBurstOpen()'s own comment.
|
|
11741
|
+
// A row drag never had a focused cell of its own, so if this call is
|
|
11742
|
+
// the one that opens the burst, it must neither scroll the page nor
|
|
11743
|
+
// land on the header cell.
|
|
11744
|
+
const liveTableEl = await ensureTableBurstOpen(tableEl, true);
|
|
10734
11745
|
if (!liveTableEl) return;
|
|
10735
11746
|
const liveRows = allRowsOf(liveTableEl);
|
|
10736
11747
|
if (rowIndex < 0 || !liveRows[rowIndex]) return;
|
|
@@ -10747,8 +11758,55 @@
|
|
|
10747
11758
|
|
|
10748
11759
|
currentBurst.history.snap('drag-row-pre');
|
|
10749
11760
|
const aligns = columnAlignsOf(liveTableEl);
|
|
10750
|
-
|
|
10751
|
-
|
|
11761
|
+
// v3.4.0 §2(量出來的,不是猜的):這裡曾經寫過
|
|
11762
|
+
// `if (activeIndex >= 0) restoreTableFocus(...)`,想法是拖曳沒點過任何
|
|
11763
|
+
// 儲存格時 activeIndex 應該是 -1、藉此跳過這次呼叫。實測證明那個模型
|
|
11764
|
+
// 錯了——`activeIndex` 在這裡結構上永遠 >= 0:ensureTableBurstOpen()
|
|
11765
|
+
// 只有在 currentBurst 確實是這個表格時才會回傳非 null,而
|
|
11766
|
+
// `currentBurst.activeCellEl` 在 startTableBurst() 建立 currentBurst
|
|
11767
|
+
// 的同一個賦值式裡就一定被設好(不管 burst 是本來就開著、還是這次
|
|
11768
|
+
// ensureTableBurstOpen() 的 forDrag 分支剛開的)。所以「用 index 判斷
|
|
11769
|
+
// 要不要呼叫 restoreTableFocus()」這條路走不通,不要再試。
|
|
11770
|
+
//
|
|
11771
|
+
// 真正的捲動來源是 restoreTableFocus() 自己那次 focus():它一定會把
|
|
11772
|
+
// ensureTableBurstOpen() 剛剛(forDrag、preventScroll、挑 body 儲存格)
|
|
11773
|
+
// 已經對好焦的那顆儲存格,重新 focus 一次——用一支外接的 stack-trace
|
|
11774
|
+
// 探針量到兩次 focus() 打在同一顆元素上,第二次沒帶 preventScroll,
|
|
11775
|
+
// 緊接著就是那次把畫面拉回去的捲動。修法落在 restoreTableFocus() 自己
|
|
11776
|
+
// 身上(它的 cell.focus() 現在帶 { preventScroll: true }),這裡維持
|
|
11777
|
+
// 無條件呼叫。
|
|
11778
|
+
//
|
|
11779
|
+
// 另一個既存缺陷(跟以上兩件事無關,重排「之前」量、「之後」用同一個
|
|
11780
|
+
// 序位):原本 `activeIndex` 在這裡(rebuildTableSections() 之前)用
|
|
11781
|
+
// `tableCellsOf(liveTableEl).indexOf(currentBurst.activeCellEl)` 算好,
|
|
11782
|
+
// 卻在下面 restoreTableFocus() 裡對「重排之後」的 cell 清單使用——重排
|
|
11783
|
+
// 只要讓被拖那列插到 activeCellEl 那列前面,中間隔的 cell 數就變了,
|
|
11784
|
+
// 舊序位對到的會是別的儲存格(使用者正在打字那格,焦點卻被丟到別的
|
|
11785
|
+
// TD,接下來每一鍵都寫進錯的儲存格)。用一支 stack-trace 探針量過:
|
|
11786
|
+
// rebuildTableSections() 本身只搬 <tr>(appendChild,never 重建),但
|
|
11787
|
+
// retagCell() 在該列跨越表頭/本文邊界(被升格成表頭或被降成本文)時
|
|
11788
|
+
// **會**造出全新的 <td>/<th>(複製屬性、不是同一顆物件)——用 JS
|
|
11789
|
+
// expando 屬性(不是 HTML attribute,因為 attribute 會被複製過去,測
|
|
11790
|
+
// 不出是不是同一顆)量過:不跨表頭時標記存活,跨表頭時標記消失。
|
|
11791
|
+
// <tr> 元素本身兩種情況都不變(只有 appendChild 搬過),所以「列+欄」
|
|
11792
|
+
// 這組座標在重排前後都穩,序位不穩。改成:重排前記下 activeRowEl(那
|
|
11793
|
+
// 顆 <tr>)與 activeColIdx(欄序,colIndexOf() 已有);重排、
|
|
11794
|
+
// armNewTableCells() 都跑完之後,用 `activeRowEl.cells[activeColIdx]`
|
|
11795
|
+
// 重新取得那顆儲存格(不管 retagCell() 換過沒有,同一列同一欄序就是
|
|
11796
|
+
// 使用者原本在打字的那顆),再轉回 restoreTableFocus() 要的序位。
|
|
11797
|
+
// Review fix (M2): `document.body.contains()` first — same guard
|
|
11798
|
+
// refocusAwayFromColumn()/refocusAwayFromRow() above both use before
|
|
11799
|
+
// their own colIndexOf() call. `activeCellEl` DETACHED (not merely
|
|
11800
|
+
// stale) is a state this file already treats as reachable there;
|
|
11801
|
+
// colIndexOf() reads `cellEl.parentElement.cells`, which throws on a
|
|
11802
|
+
// detached node whose parentElement is null — unlike the old
|
|
11803
|
+
// `tableCellsOf(...).indexOf(...)` this replaced, which just returned
|
|
11804
|
+
// -1 for a detached cell. Thrown here (after `history.snap('drag-row-pre')`,
|
|
11805
|
+
// before the reorder) would silently drop the whole gesture.
|
|
11806
|
+
const activeCellBefore = (currentBurst && currentBurst.activeCellEl &&
|
|
11807
|
+
document.body.contains(currentBurst.activeCellEl)) ? currentBurst.activeCellEl : null;
|
|
11808
|
+
const activeRowEl = activeCellBefore ? activeCellBefore.parentElement : null;
|
|
11809
|
+
const activeColIdx = activeCellBefore ? colIndexOf(activeCellBefore) : -1;
|
|
10752
11810
|
suppressTableFocusout = true;
|
|
10753
11811
|
try {
|
|
10754
11812
|
rebuildTableSections(liveTableEl, order, aligns);
|
|
@@ -10756,6 +11814,8 @@
|
|
|
10756
11814
|
suppressTableFocusout = false;
|
|
10757
11815
|
}
|
|
10758
11816
|
armNewTableCells(liveTableEl);
|
|
11817
|
+
const restoredCell = (activeRowEl && activeColIdx >= 0) ? activeRowEl.cells[activeColIdx] : null;
|
|
11818
|
+
const activeIndex = restoredCell ? tableCellsOf(liveTableEl).indexOf(restoredCell) : -1;
|
|
10759
11819
|
restoreTableFocus(liveTableEl, activeIndex);
|
|
10760
11820
|
// 插入路徑早就這樣做了,drop 路徑一直沒有:不清的話 grip 還釘在舊
|
|
10761
11821
|
// 座標、teMenuColIndex 指向已經換位的欄。
|
|
@@ -10792,11 +11852,10 @@
|
|
|
10792
11852
|
}
|
|
10793
11853
|
|
|
10794
11854
|
async function performColDrop(tableEl, fromIndex, toIndex) {
|
|
10795
|
-
|
|
11855
|
+
// v3.4.0 §2: `forDrag=true` — same reasoning as performRowDrop() above.
|
|
11856
|
+
const liveTableEl = await ensureTableBurstOpen(tableEl, true);
|
|
10796
11857
|
if (!liveTableEl) return;
|
|
10797
11858
|
if (toIndex === fromIndex || toIndex === fromIndex + 1) return; // 原地放回
|
|
10798
|
-
const activeIndex = (currentBurst && currentBurst.activeCellEl)
|
|
10799
|
-
? tableCellsOf(liveTableEl).indexOf(currentBurst.activeCellEl) : -1;
|
|
10800
11859
|
// 短列必須讓整個操作放棄,不能只跳過那一列(final review M4):原本
|
|
10801
11860
|
// `if (!moved) return;` 在 forEach 裡面,短列會被略過、其他列照搬 —— 結果
|
|
10802
11861
|
// 是欄位彼此錯位,而每一列的 cell 數量都跟原本一樣,ragged-table guard
|
|
@@ -10816,6 +11875,27 @@
|
|
|
10816
11875
|
} finally {
|
|
10817
11876
|
suppressTableFocusout = false;
|
|
10818
11877
|
}
|
|
11878
|
+
// v3.4.0 §2(量出來的,不是猜的——完整說明見 performRowDrop() 對應處的
|
|
11879
|
+
// 同一段註解):這裡曾經寫過 `if (activeIndex >= 0) restoreTableFocus(...)`,
|
|
11880
|
+
// 想靠 index 擋掉這次呼叫,但 `activeIndex` 在這裡結構上永遠 >= 0
|
|
11881
|
+
// (ensureTableBurstOpen() 只有在 currentBurst 確實是這個表格時才回傳
|
|
11882
|
+
// 非 null,而 currentBurst.activeCellEl 在 startTableBurst() 建立
|
|
11883
|
+
// currentBurst 的同一個賦值式裡就一定被設好)——這條路走不通,不要
|
|
11884
|
+
// 再試。真正的捲動來源是 restoreTableFocus() 自己那次 focus(),修法
|
|
11885
|
+
// 落在它自己身上(`{ preventScroll: true }`),這裡維持無條件呼叫。
|
|
11886
|
+
//
|
|
11887
|
+
// 另一個既存缺陷,跟列拖曳同根、但失效方式不同(欄重排動的是「同一列
|
|
11888
|
+
// 裡 cell 的順序」,不是列本身):這裡原本也是在上面重排「之前」用
|
|
11889
|
+
// `tableCellsOf(liveTableEl).indexOf(currentBurst.activeCellEl)` 算
|
|
11890
|
+
// `activeIndex`,重排之後才用——欄一旦搬過,每一列裡 cell 的順序都變
|
|
11891
|
+
// 了,`tableCellsOf()`(跨全表攤平)算出來的序位一樣是舊的。用同一支
|
|
11892
|
+
// 探針量過:欄重排的 forEach 是 `row.appendChild(c)`,對已經是那個
|
|
11893
|
+
// row 子節點的 cell 只是搬動、從不建立新節點(跟列拖曳的 retagCell()
|
|
11894
|
+
// 不同,欄拖曳完全不牽涉 TH/TD 轉換),所以不需要「列+欄」那組座標
|
|
11895
|
+
// ——重排後直接用同一顆 `currentBurst.activeCellEl` 物件重算一次
|
|
11896
|
+
// indexOf() 就對了,物件本身沒被換過。
|
|
11897
|
+
const activeIndex = (currentBurst && currentBurst.activeCellEl)
|
|
11898
|
+
? tableCellsOf(liveTableEl).indexOf(currentBurst.activeCellEl) : -1;
|
|
10819
11899
|
restoreTableFocus(liveTableEl, activeIndex);
|
|
10820
11900
|
hideTableGrips();
|
|
10821
11901
|
hideTableEdgeMenu();
|
|
@@ -11661,6 +12741,12 @@
|
|
|
11661
12741
|
// next one.
|
|
11662
12742
|
blockSelClickSuppressed = false;
|
|
11663
12743
|
if (blockSelDrag) endBlockSelDrag();
|
|
12744
|
+
// Every defensive teardown above still runs — they are no-ops that must not
|
|
12745
|
+
// be skipped — but nothing behind the modal ARMS a new gesture. The backdrop
|
|
12746
|
+
// already makes this unreachable (measured: a Shift+press-drag on the canvas
|
|
12747
|
+
// and a press-drag on the backdrop both leave the selection null); it is
|
|
12748
|
+
// here so that stays true if the backdrop ever stops being `inset: 0`.
|
|
12749
|
+
if (waveEditorIsModal()) return;
|
|
11664
12750
|
const hit = hitTestGrip(e.target);
|
|
11665
12751
|
// A click on a DIFFERENT zone (or entirely outside any zone) dismisses
|
|
11666
12752
|
// whatever menu is already open, same "any other click closes the ⠿
|
|
@@ -12440,6 +13526,15 @@
|
|
|
12440
13526
|
snapBurstIfActive(root, 'mark');
|
|
12441
13527
|
return false; // unwrapped in place — no modal ever opened
|
|
12442
13528
|
}
|
|
13529
|
+
// v3.4.0 / v3.3.0 backlog 4: applyMarkToggle() guards its wrap branch with
|
|
13530
|
+
// trimRangeToText() before wrapping; this new-link branch never had the
|
|
13531
|
+
// matching guard. A whitespace-only selection (the space between two
|
|
13532
|
+
// words, say) reached window.prompt() and, on accept, extractRangeInto()
|
|
13533
|
+
// wrapped that bare space in an <a> — an anchor whose entire content was
|
|
13534
|
+
// one space, written straight to disk. Trim first: an all-whitespace
|
|
13535
|
+
// range trims to nothing and trimRangeToText() returns false, so bail
|
|
13536
|
+
// before ever opening the prompt.
|
|
13537
|
+
if (!trimRangeToText(range)) return false;
|
|
12443
13538
|
const url = window.prompt('連結網址:', 'https://');
|
|
12444
13539
|
if (url === null || url.trim() === '') return true; // cancelled or empty — no-op
|
|
12445
13540
|
snapBurstIfActive(root, 'mark-pre');
|
|
@@ -12539,8 +13634,7 @@
|
|
|
12539
13634
|
const rect = range.getBoundingClientRect();
|
|
12540
13635
|
// v3.2.1: 原本的零矩形檢查是死碼 —— 實測捲出視野的 Range 仍保有
|
|
12541
13636
|
// 151x17 的矩形,永遠到不了這一支。改成真正的視窗外檢查。
|
|
12542
|
-
const topInset =
|
|
12543
|
-
.getPropertyValue('--ed-toolbar-h')) || 0;
|
|
13637
|
+
const topInset = toolbarTopInset();
|
|
12544
13638
|
if (rect.bottom <= topInset || rect.top >= window.innerHeight ||
|
|
12545
13639
|
rect.right <= 0 || rect.left >= window.innerWidth) {
|
|
12546
13640
|
// Task 16: of the exits that take this bar down, this is the one that
|
|
@@ -12573,8 +13667,25 @@
|
|
|
12573
13667
|
// the raw textarea directly on click, no bar/menu step) — see
|
|
12574
13668
|
// armEditables() above. What's left to route here is: the lightbox
|
|
12575
13669
|
// exclusion, the ⠿ handle/menu, and opening a degraded block's raw editor.
|
|
13670
|
+
// Fix round 3 (re-review2 Q2): `.drawio` belongs here for the same reason
|
|
13671
|
+
// every other entry does — it is a lightbox target in the reader runtime
|
|
13672
|
+
// (`LIGHTBOX_TARGETS` in lib/md2doc.js), and a click that opens the lightbox
|
|
13673
|
+
// must not ALSO be routed as a click on the block.
|
|
13674
|
+
//
|
|
13675
|
+
// MEASURED before adding it, in a real editor page with a diagram in an
|
|
13676
|
+
// armed paragraph and the same diagram in a DEGRADED table:
|
|
13677
|
+
// paragraph lightbox flex/visible, `.ed-raw` count 0, activeElement BODY
|
|
13678
|
+
// (the armed branch already returns above — no consequence)
|
|
13679
|
+
// table lightbox flex/visible, `.ed-raw` count 1,
|
|
13680
|
+
// activeElement TEXTAREA.ed-raw
|
|
13681
|
+
// i.e. on a degraded block the raw markdown editor silently opened BEHIND
|
|
13682
|
+
// the lightbox. With `.drawio` listed, the diagram behaves exactly like the
|
|
13683
|
+
// `img` / `.mermaid` / `.graphviz` / WaveDrom entries beside it: clicking
|
|
13684
|
+
// the picture opens the picture and nothing else. No capability is lost
|
|
13685
|
+
// that those four do not already lack — the block's source is still one
|
|
13686
|
+
// click away anywhere else in the block.
|
|
12576
13687
|
const ED_LIGHTBOX_TARGETS =
|
|
12577
|
-
'img, .mermaid, .graphviz, [id^="WaveDrom_Display_"], .wavedrom-diagram';
|
|
13688
|
+
'img, .mermaid, .graphviz, .drawio, [id^="WaveDrom_Display_"], .wavedrom-diagram';
|
|
12578
13689
|
|
|
12579
13690
|
// Single delegated listener, wired once at the bottom of this file. Async
|
|
12580
13691
|
// because clicking another block (or outside any block) while some
|
|
@@ -12687,6 +13798,14 @@
|
|
|
12687
13798
|
// would also fall through to "clicked outside any block" below and
|
|
12688
13799
|
// fire an unwanted switchAwayFrom() right after the menu just opened.
|
|
12689
13800
|
if (e.target.closest('.ed-te-grip')) return;
|
|
13801
|
+
// v3.4.0 batch3 Task 6: while the wave editor is the modal surface,
|
|
13802
|
+
// nothing behind it takes clicks. This replaced a pair of `closest()`
|
|
13803
|
+
// exclusions for the overlay and its entry button: those were right about
|
|
13804
|
+
// the two cases they named — a click on either used to fall through to
|
|
13805
|
+
// "clicked outside any block" and fire switchAwayFrom() on the document
|
|
13806
|
+
// behind the modal — and wrong about being a list at all.
|
|
13807
|
+
if (waveEditorIsModal()) return;
|
|
13808
|
+
if (e.target.closest('.ed-wave-edit-btn')) return;
|
|
12690
13809
|
// Task 9: task-list checkbox toggle. The .ed-li-check span is
|
|
12691
13810
|
// non-focusable chrome — no per-node listener, routed here by
|
|
12692
13811
|
// delegation. Gate BEFORE mutation (same contract as the structural
|
|
@@ -12828,6 +13947,39 @@
|
|
|
12828
13947
|
|
|
12829
13948
|
// ── save ───────────────────────────────────────────────────────────────
|
|
12830
13949
|
async function save() {
|
|
13950
|
+
// fix 3: the receipt for the bytes about to leave, taken BEFORE the request
|
|
13951
|
+
// and not after it. `markSaved()` used to read the stack depth when the
|
|
13952
|
+
// RESPONSE landed, and a save round trip is hundreds of milliseconds —
|
|
13953
|
+
// easily long enough to press Ctrl+Z in. MEASURED: type, commit, Ctrl+S,
|
|
13954
|
+
// Ctrl+Z inside the round trip — the 200 marked the post-undo depth as the
|
|
13955
|
+
// saved point, so the title dot went out, the save button greyed,
|
|
13956
|
+
// `beforeunload` stopped firing, AND `mtimeMs` had just advanced so the
|
|
13957
|
+
// conflict check would not catch it either. All three nets failed at once,
|
|
13958
|
+
// and the tab closed leaving on disk an edit the user had explicitly undone.
|
|
13959
|
+
// See UndoStack.saveToken()/markSaved() for why the receipt carries an op
|
|
13960
|
+
// identity and not just a depth.
|
|
13961
|
+
const token = stack.saveToken();
|
|
13962
|
+
//
|
|
13963
|
+
// ⚠ There is deliberately NO in-flight lock and no supersede guard here, and
|
|
13964
|
+
// that is a correction of something this file briefly carried. The premise
|
|
13965
|
+
// for dropping an "older" reply was that a later request's bytes are what
|
|
13966
|
+
// disk ends up holding. That premise is FALSE: the server 409s whenever
|
|
13967
|
+
// `baseMtimeMs` does not match the file (server.js's /api/save), and
|
|
13968
|
+
// `mtimeMs` has exactly one writer — the 200 branch below. So a second save
|
|
13969
|
+
// sent while the first is still in flight necessarily carries a stale
|
|
13970
|
+
// baseline, necessarily 409s, and necessarily writes nothing. The FIRST
|
|
13971
|
+
// reply is the one that describes what is on disk, and dropping it is the
|
|
13972
|
+
// defect, not the fix. MEASURED with the guard in place: type, Ctrl+S,
|
|
13973
|
+
// Ctrl+Z, Ctrl+S — the first 200 was discarded, `markSaved()` never ran, and
|
|
13974
|
+
// the document read CLEAN with the edit still on disk; and without any undo
|
|
13975
|
+
// at all, two Ctrl+S in a row left `mtimeMs` stale forever, so every later
|
|
13976
|
+
// save 409'd and the only way out was the conflict banner's reload. An
|
|
13977
|
+
// exhaustive depth-6 sweep counted 48 settled under-warns with the guard and
|
|
13978
|
+
// 0 without it.
|
|
13979
|
+
//
|
|
13980
|
+
// The receipt above is what makes a late reply safe without any of that: it
|
|
13981
|
+
// describes the bytes it was taken for, so a reply that no longer matches
|
|
13982
|
+
// the timeline refuses itself.
|
|
12831
13983
|
let res;
|
|
12832
13984
|
try {
|
|
12833
13985
|
res = await fetch('/api/save', {
|
|
@@ -12848,8 +14000,17 @@
|
|
|
12848
14000
|
return;
|
|
12849
14001
|
}
|
|
12850
14002
|
mtimeMs = j.mtimeMs;
|
|
12851
|
-
stack.markSaved();
|
|
14003
|
+
stack.markSaved(token);
|
|
12852
14004
|
setDirty();
|
|
14005
|
+
// v3.4.0 §3: markSaved() just moved documentIsDirty() from true to
|
|
14006
|
+
// false, and the `save` button's own lit/grey state is read off
|
|
14007
|
+
// exactly that value (toolbarContext()'s `dirty` field) — without this
|
|
14008
|
+
// the button stays lit after a successful save until some UNRELATED
|
|
14009
|
+
// gesture happens to repaint the bar. Same repaint entry point
|
|
14010
|
+
// setDirty()'s own callers already share (updateToolbar() diffs its
|
|
14011
|
+
// own signature, so this is cheap on the common "toolbar was already
|
|
14012
|
+
// caught up" case).
|
|
14013
|
+
updateToolbar();
|
|
12853
14014
|
return;
|
|
12854
14015
|
}
|
|
12855
14016
|
if (res.status === 409) {
|
|
@@ -12863,6 +14024,140 @@
|
|
|
12863
14024
|
showBanner('Save failed — ' + reason + '; changes NOT saved.', null, null);
|
|
12864
14025
|
}
|
|
12865
14026
|
|
|
14027
|
+
// v3.4.0 §3: the "resolve whatever is open, THEN save" precondition —
|
|
14028
|
+
// Ctrl+S's own keydown handler already carries the full explanation (a
|
|
14029
|
+
// mid-burst keystroke has not reached `lines` yet, so save() must not run
|
|
14030
|
+
// before switchAwayFrom() does). The toolbar `save` button needs the
|
|
14031
|
+
// identical precondition, so both callers share this one function instead
|
|
14032
|
+
// of each spelling out `switchAwayFrom().then(...)` on its own — not a
|
|
14033
|
+
// copy of save()'s own body, just its one caller-side precondition named
|
|
14034
|
+
// once.
|
|
14035
|
+
async function commitThenSave() {
|
|
14036
|
+
const ok = await switchAwayFrom();
|
|
14037
|
+
if (ok) return save();
|
|
14038
|
+
}
|
|
14039
|
+
|
|
14040
|
+
// v3.4.0 §3: the save button's own caret rescue. commitThenSave() above
|
|
14041
|
+
// may resolve (commit + re-render) a still-open burst before save() ever
|
|
14042
|
+
// runs, and that render is exactly the class of render that strands the
|
|
14043
|
+
// caret on BODY when the caret was sitting inside the block being
|
|
14044
|
+
// replaced — recorded, for undo/redo/image, in activateToolbarCursor()'s
|
|
14045
|
+
// own comment ("join them ONLY when the surface has an uncommitted
|
|
14046
|
+
// edit"), and unconditionally for quote/code/line (backlog #6, still
|
|
14047
|
+
// open). `save` must not become a fourth name on that list — and
|
|
14048
|
+
// deliberately does NOT reuse undo()/redo()'s own path to avoid it:
|
|
14049
|
+
// neither of them has any rescue at all (that gap is recorded as
|
|
14050
|
+
// deferred, not fixed), so there is nothing there worth copying.
|
|
14051
|
+
//
|
|
14052
|
+
// What captureCaretForSave() saves: which `.ed-block` the caret's FOCUS
|
|
14053
|
+
// end was in (`data-block-id` — stable across a same-shape commit, since
|
|
14054
|
+
// buildBlockMap() numbers every block by document order starting at 0 on
|
|
14055
|
+
// every render, and a text-only edit that neither adds nor removes a
|
|
14056
|
+
// block leaves every block's id exactly where it was) and how many
|
|
14057
|
+
// characters into that block's own rendered text that end sat (every text
|
|
14058
|
+
// node before it, concatenated — the same measure
|
|
14059
|
+
// `Range.toString().length` gives over a selectNodeContents() range
|
|
14060
|
+
// clipped to that point). restoreCaretForSave() reverses it: walk the
|
|
14061
|
+
// (possibly brand-new) block's text nodes until that many characters are
|
|
14062
|
+
// consumed, and collapse a Range there. It is a no-op whenever the caret
|
|
14063
|
+
// was never stranded in the first place (the common case — a clean
|
|
14064
|
+
// document, or a burst with nothing uncommitted, never touches the DOM at
|
|
14065
|
+
// all) — checked by asking whether focus is still on BODY afterwards, not
|
|
14066
|
+
// by trying to predict which of the paths above ran.
|
|
14067
|
+
//
|
|
14068
|
+
// Review C1: `sel.focusNode`/`focusOffset`, not `anchorNode`/
|
|
14069
|
+
// `anchorOffset`, and no `!sel.isCollapsed` bail-out. The first version
|
|
14070
|
+
// gave up on any non-collapsed selection entirely — MEASURED: type at the
|
|
14071
|
+
// end of a block without committing, extend the selection left with
|
|
14072
|
+
// Shift+ArrowLeft, click save — restoreCaretForSave() never ran at all
|
|
14073
|
+
// (captureCaretForSave() had already returned null), and the caret was
|
|
14074
|
+
// left exactly where quote/code/line leave it: on BODY, typing silently
|
|
14075
|
+
// discarded. That is backlog #6's own symptom, reached through a keyboard
|
|
14076
|
+
// path that is not remotely rare (fix a typo with Shift+Arrow, then Ctrl+
|
|
14077
|
+
// S/Alt+F10+Enter on `save`). A collapsed caret is only the special case
|
|
14078
|
+
// where anchor and focus already coincide; using focus and dropping the
|
|
14079
|
+
// guard restores BOTH cases through one path. Collapsing the RESTORED
|
|
14080
|
+
// caret at that single point (rather than reconstructing the whole
|
|
14081
|
+
// pre-click range) is deliberate — landing at the focus end, one keyboard
|
|
14082
|
+
// Shift+Arrow removed from BODY rather than on it, is what backlog #6
|
|
14083
|
+
// asks for; there is no saved anchor position left to rebuild a
|
|
14084
|
+
// non-collapsed selection FROM once the block has been re-rendered.
|
|
14085
|
+
function captureCaretForSave() {
|
|
14086
|
+
const sel = typeof window.getSelection === 'function' ? window.getSelection() : null;
|
|
14087
|
+
if (!sel || sel.rangeCount === 0) return null;
|
|
14088
|
+
const focus = sel.focusNode;
|
|
14089
|
+
if (!focus) return null;
|
|
14090
|
+
const host = focus.nodeType === 1 ? focus : focus.parentElement;
|
|
14091
|
+
const blockEl = host && host.closest ? host.closest('.ed-block') : null;
|
|
14092
|
+
if (!blockEl) return null;
|
|
14093
|
+
const blockId = blockEl.getAttribute('data-block-id');
|
|
14094
|
+
if (blockId === null) return null;
|
|
14095
|
+
const measure = document.createRange();
|
|
14096
|
+
measure.selectNodeContents(blockEl);
|
|
14097
|
+
measure.setEnd(focus, sel.focusOffset);
|
|
14098
|
+
return { blockId: blockId, offset: measure.toString().length };
|
|
14099
|
+
}
|
|
14100
|
+
|
|
14101
|
+
function restoreCaretForSave(saved) {
|
|
14102
|
+
// Nothing to do when the caret was never stranded — leave whatever is
|
|
14103
|
+
// focused (including a genuinely different block the user clicked into
|
|
14104
|
+
// meanwhile) alone.
|
|
14105
|
+
if (!saved || document.activeElement !== document.body) return;
|
|
14106
|
+
const blockEl = contentEl.querySelector(
|
|
14107
|
+
'.ed-block[data-block-id="' + saved.blockId + '"]');
|
|
14108
|
+
// Review M1: `blockEl` is null whenever the commit added or removed
|
|
14109
|
+
// blocks (buildBlockMap() renumbers by document order, so `save`'s own
|
|
14110
|
+
// commit — a plain text edit — cannot do this, but a future caller of
|
|
14111
|
+
// captureCaretForSave()/restoreCaretForSave() might run them around
|
|
14112
|
+
// something structural). There is no corresponding block left to place
|
|
14113
|
+
// a caret in at all, so this silently leaves the caret on BODY — a
|
|
14114
|
+
// strict improvement over guessing at a different block, but not a full
|
|
14115
|
+
// rescue. Falling back to `document.body.focus()` would not help either
|
|
14116
|
+
// (BODY is not focusable), so this is left as a documented, narrower
|
|
14117
|
+
// gap rather than a fixed one.
|
|
14118
|
+
const surface = blockEl && blockContentEl(blockEl);
|
|
14119
|
+
if (!surface) return;
|
|
14120
|
+
const walker = document.createTreeWalker(surface, NodeFilter.SHOW_TEXT);
|
|
14121
|
+
let remaining = saved.offset;
|
|
14122
|
+
let node = walker.nextNode();
|
|
14123
|
+
let target = null;
|
|
14124
|
+
let targetOffset = 0;
|
|
14125
|
+
while (node) {
|
|
14126
|
+
if (remaining <= node.nodeValue.length) { target = node; targetOffset = remaining; break; }
|
|
14127
|
+
remaining -= node.nodeValue.length;
|
|
14128
|
+
node = walker.nextNode();
|
|
14129
|
+
}
|
|
14130
|
+
if (!target) { placeCaretAtEnd(surface); return; }
|
|
14131
|
+
const range = document.createRange();
|
|
14132
|
+
range.setStart(target, targetOffset);
|
|
14133
|
+
range.collapse(true);
|
|
14134
|
+
const sel = window.getSelection();
|
|
14135
|
+
sel.removeAllRanges();
|
|
14136
|
+
sel.addRange(range);
|
|
14137
|
+
}
|
|
14138
|
+
|
|
14139
|
+
// The one entry point the toolbar/keyboard-cursor dispatch calls — wraps
|
|
14140
|
+
// commitThenSave() with the capture/restore pair above. Ctrl+S itself is
|
|
14141
|
+
// untouched: it is not one of the three buttons backlog #6 names, driving it
|
|
14142
|
+
// was out of that task's scope, and wrapping it here too would risk
|
|
14143
|
+
// perturbing the Finding-1/Finding-3 Ctrl+S coverage this branch already has
|
|
14144
|
+
// for reasons that have nothing to do with the caret.
|
|
14145
|
+
//
|
|
14146
|
+
// fix 5 / R9: the sentence here used to say Ctrl+S "still calls
|
|
14147
|
+
// commitThenSave() directly". It does not and has not — the keydown branch
|
|
14148
|
+
// spells the same precondition out inline as
|
|
14149
|
+
// `switchAwayFrom().then((ok) => { if (ok) save(); })`, deliberately, because
|
|
14150
|
+
// that exact line is pinned verbatim by shortcutOrderChecks in
|
|
14151
|
+
// test/editor-client.test.js (its own comment there says so). The two
|
|
14152
|
+
// spellings are behaviourally identical; the claim about which function is
|
|
14153
|
+
// called was simply wrong, and a reader following it would go looking for a
|
|
14154
|
+
// call site that does not exist.
|
|
14155
|
+
async function saveViaToolbar() {
|
|
14156
|
+
const saved = captureCaretForSave();
|
|
14157
|
+
await commitThenSave();
|
|
14158
|
+
restoreCaretForSave(saved);
|
|
14159
|
+
}
|
|
14160
|
+
|
|
12866
14161
|
// ── undo / redo ───────────────────────────────────────────────────────
|
|
12867
14162
|
async function undo() {
|
|
12868
14163
|
// T21 item 1: an Escape that discarded a burst leaves exactly one thing
|
|
@@ -12871,6 +14166,16 @@
|
|
|
12871
14166
|
// answers false whenever there is no such thing (which is almost always),
|
|
12872
14167
|
// and the ordinary document undo below runs untouched.
|
|
12873
14168
|
if (restoreDiscardedBurst()) return;
|
|
14169
|
+
// v3.4.0 (Task 7 item 5): the raw-editor twin — see restoreDiscardedRawEdit()'s
|
|
14170
|
+
// own comment. Mutually exclusive with the burst stash above (see
|
|
14171
|
+
// discardedRawEdit's own comment), so checking both in sequence never
|
|
14172
|
+
// answers a stale one while the other substrate holds the fresher discard.
|
|
14173
|
+
if (await restoreDiscardedRawEdit()) return;
|
|
14174
|
+
// v3.4.0 batch3 Task 7: the wave twin. Third in the sequence for no reason
|
|
14175
|
+
// other than that it is the newest; all three are mutually exclusive by
|
|
14176
|
+
// construction (one editing session at a time) and each nulls its own slot
|
|
14177
|
+
// before deciding, so order cannot make a stale one answer.
|
|
14178
|
+
if (await restoreDiscardedWaveEdit()) return;
|
|
12874
14179
|
// Resolve any open editor BEFORE the .content-replacing swap below —
|
|
12875
14180
|
// see the `activeEditor` / switchAwayFrom() comments near its
|
|
12876
14181
|
// declaration for why this is required (undo/redo silently detaching an
|
|
@@ -12944,12 +14249,44 @@
|
|
|
12944
14249
|
}
|
|
12945
14250
|
}
|
|
12946
14251
|
|
|
14252
|
+
// backlog #6 — the toolbar's own caret rescue for undo/redo/image, over
|
|
14253
|
+
// the SAME captureCaretForSave()/restoreCaretForSave() pair saveViaToolbar()
|
|
14254
|
+
// above uses, not a copy of it. activateToolbarCursor()'s own landing-note
|
|
14255
|
+
// comment records exactly when this bites: "undo, redo and image join
|
|
14256
|
+
// [quote/code/line, permanently stranded] ONLY when the surface has an
|
|
14257
|
+
// uncommitted edit — the action resolves that burst first, and the
|
|
14258
|
+
// re-render is what drops the caret." That resolve-then-render shape is
|
|
14259
|
+
// undo()/redo()'s own switchAwayFrom() call and pickAndInsertImage()'s own
|
|
14260
|
+
// — identical to what saveViaToolbar() wraps commitThenSave() around — so
|
|
14261
|
+
// the same capture-before/restore-after pair applies unchanged: capture
|
|
14262
|
+
// reads the CURRENT (possibly mid-edit) DOM before anything commits,
|
|
14263
|
+
// restore is a no-op whenever focus never left the DOM in the first place
|
|
14264
|
+
// (the common case, nothing dirty).
|
|
14265
|
+
//
|
|
14266
|
+
// Scoped to the TOOLBAR path ONLY, deliberately: Ctrl+Z/Ctrl+Y (the global
|
|
14267
|
+
// keydown handler further down) still call undo()/redo() directly, and a
|
|
14268
|
+
// focused burst's own Ctrl+Z is `burstUndo()` — an entirely different,
|
|
14269
|
+
// intra-burst mechanism that never reaches this file's undo()/redo() at
|
|
14270
|
+
// all. Widening either of those was out of this task's scope, the same
|
|
14271
|
+
// boundary saveViaToolbar()'s own comment draws around Ctrl+S.
|
|
14272
|
+
async function undoViaToolbar() {
|
|
14273
|
+
const saved = captureCaretForSave();
|
|
14274
|
+
await undo();
|
|
14275
|
+
restoreCaretForSave(saved);
|
|
14276
|
+
}
|
|
14277
|
+
|
|
14278
|
+
async function redoViaToolbar() {
|
|
14279
|
+
const saved = captureCaretForSave();
|
|
14280
|
+
await redo();
|
|
14281
|
+
restoreCaretForSave(saved);
|
|
14282
|
+
}
|
|
14283
|
+
|
|
12947
14284
|
// ── v3.1.0 §4: the always-visible editing toolbar ──────────────────────
|
|
12948
14285
|
// The version's headline feature: before this there was no toolbar at all,
|
|
12949
14286
|
// and every editing affordance was hidden behind a hover gutter, a caret
|
|
12950
14287
|
// position or a text selection. window.md2docToolbarModel (injected by
|
|
12951
14288
|
// server.js, built and reviewed as lib/editor/toolbar-model.js) owns the
|
|
12952
|
-
//
|
|
14289
|
+
// 23-button roster, the group order that decides separator placement, and
|
|
12953
14290
|
// the whole active/disabled derivation; everything below is mounting and
|
|
12954
14291
|
// wiring. Nothing here re-derives a button's state by hand — a second
|
|
12955
14292
|
// opinion about when ⇥ is legal is exactly what the pure-module split
|
|
@@ -12974,8 +14311,36 @@
|
|
|
12974
14311
|
// intent has to be captured on the keystroke that produced it.
|
|
12975
14312
|
let plainPasteArmed = false;
|
|
12976
14313
|
|
|
12977
|
-
const TOOLBAR_UNSUPPORTED_IMAGE_MESSAGE =
|
|
14314
|
+
const TOOLBAR_UNSUPPORTED_IMAGE_MESSAGE =
|
|
14315
|
+
'只接受 PNG / JPEG / GIF / WebP 圖片,或 draw.io 的 .drawio / .xml';
|
|
14316
|
+
// The one place the two accepted diagram extensions are written down on
|
|
14317
|
+
// this side. /api/asset owns the same pair plus the content check.
|
|
14318
|
+
const DRAWIO_NAME_RE = /\.(drawio|xml)$/i;
|
|
14319
|
+
|
|
14320
|
+
/**
|
|
14321
|
+
* Is this File something the insert path will even try to send?
|
|
14322
|
+
*
|
|
14323
|
+
* ONE predicate with three call sites -- the `drop` filter,
|
|
14324
|
+
* `clipboardItemsOf()` and `uploadImageBlob()` -- because three copies of
|
|
14325
|
+
* "is this insertable" is three chances to disagree. v3.4.1's first cut
|
|
14326
|
+
* widened only two of them and the `drop` filter kept dropping .drawio
|
|
14327
|
+
* files on the floor: the filter runs BEFORE insertImages(), so the file
|
|
14328
|
+
* never entered the pipeline and there was no banner, no request, no
|
|
14329
|
+
* failure -- nothing at all happened, which is indistinguishable from the
|
|
14330
|
+
* feature not existing.
|
|
14331
|
+
*
|
|
14332
|
+
* "Will try to send" is the whole claim. Whether anything is WRITTEN is
|
|
14333
|
+
* decided by /api/asset, which re-checks the extension against its own two
|
|
14334
|
+
* literals and then parses the bytes.
|
|
14335
|
+
*/
|
|
14336
|
+
function insertableFile(f) {
|
|
14337
|
+
if (!f || !assetLib) return false;
|
|
14338
|
+
return !!(assetLib.extFor(f.type) || DRAWIO_NAME_RE.test(f.name || ''));
|
|
14339
|
+
}
|
|
12978
14340
|
const TOOLBAR_NO_ANCHOR_MESSAGE = '找不到可以插入圖片的位置';
|
|
14341
|
+
// backlog #10 — see the keydown handler's own comment for why every
|
|
14342
|
+
// stray-key hand-back raises this rather than only some of them.
|
|
14343
|
+
const TOOLBAR_KEYNAV_EXIT_MESSAGE = '已離開鍵盤游標,按 Alt+F10 可以再進入工具列';
|
|
12979
14344
|
|
|
12980
14345
|
// Extra visual classes for the three inline buttons whose own glyph is the
|
|
12981
14346
|
// affordance (B is bold, I is italic, S is struck through) — the same
|
|
@@ -13155,6 +14520,11 @@
|
|
|
13155
14520
|
// .ed-seltb is painted from the same measurement — see updateToolbar().
|
|
13156
14521
|
marks: selectionMarkStates(sel),
|
|
13157
14522
|
mode: docMode,
|
|
14523
|
+
// v3.4.0 §3: the `save` button's own state. documentIsDirty() (just
|
|
14524
|
+
// above burstHasUncommittedEdit()'s own comment) is the SAME
|
|
14525
|
+
// expression setDirty() paints the title dot from — not a second copy
|
|
14526
|
+
// of it.
|
|
14527
|
+
dirty: documentIsDirty(),
|
|
13158
14528
|
};
|
|
13159
14529
|
}
|
|
13160
14530
|
|
|
@@ -13170,7 +14540,7 @@
|
|
|
13170
14540
|
paintSelToolbarMarks(ctx.marks);
|
|
13171
14541
|
const state = toolbarModel.deriveState(ctx);
|
|
13172
14542
|
// selectionchange fires on every caret move, so the state is diffed
|
|
13173
|
-
// before any DOM is written —
|
|
14543
|
+
// before any DOM is written — 23 attribute writes per keystroke is a
|
|
13174
14544
|
// cost with no matching benefit.
|
|
13175
14545
|
const sig = JSON.stringify(state);
|
|
13176
14546
|
if (sig === toolbarStateSig) return;
|
|
@@ -13181,7 +14551,7 @@
|
|
|
13181
14551
|
if (!st || !btn) return;
|
|
13182
14552
|
btn.disabled = !!st.disabled;
|
|
13183
14553
|
// aria-pressed ONLY on the buttons that can actually be pressed-in.
|
|
13184
|
-
// Writing it to all
|
|
14554
|
+
// Writing it to all 23 made a screen reader announce `undo`, `image`
|
|
13185
14555
|
// and `table` as toggle buttons stuck in the "not pressed" state,
|
|
13186
14556
|
// which is a lie about what they do.
|
|
13187
14557
|
if (TOOLBAR_TOGGLE_IDS.has(id)) {
|
|
@@ -13225,11 +14595,15 @@
|
|
|
13225
14595
|
// The one-pixel margin is a deliberate cushion and NOT a measured
|
|
13226
14596
|
// requirement — say so rather than inventing a reason for it. Driven at 900
|
|
13227
14597
|
// and at 420 px wide, the largest scrollLeft the bar accepts came back equal
|
|
13228
|
-
// to scrollWidth minus clientWidth to the pixel (
|
|
13229
|
-
//
|
|
13230
|
-
//
|
|
13231
|
-
//
|
|
13232
|
-
//
|
|
14598
|
+
// to scrollWidth minus clientWidth to the pixel (42 and 522 — re-measured
|
|
14599
|
+
// for v3.4.0 §3, a one-off puppeteer script, not assumed: .ed-toolbar's gap
|
|
14600
|
+
// 6px -> 3px changed scrollWidth at EVERY width, not just the 420px one a
|
|
14601
|
+
// different scenario already re-pinned, so 900px needed its own fresh
|
|
14602
|
+
// number rather than inheriting the old 75), so a strict comparison would
|
|
14603
|
+
// have been correct at both. The cushion is there because scrollWidth is
|
|
14604
|
+
// reported as a whole number over a row whose own width is not, and what it
|
|
14605
|
+
// costs when the two agree is that the gradient goes out a pixel before the
|
|
14606
|
+
// travel ends.
|
|
13233
14607
|
function paintToolbarOverflow() {
|
|
13234
14608
|
if (!toolbarEl) return;
|
|
13235
14609
|
const max = toolbarEl.scrollWidth - toolbarEl.clientWidth;
|
|
@@ -13335,6 +14709,28 @@
|
|
|
13335
14709
|
}
|
|
13336
14710
|
}
|
|
13337
14711
|
|
|
14712
|
+
// backlog #10: the ARIA toolbar pattern's own "jump to the edge" keys —
|
|
14713
|
+
// Home to the first enabled button, End to the last. `edge` is -1 for
|
|
14714
|
+
// Home, +1 for End. Unlike moveToolbarCursor()'s relative step, this walks
|
|
14715
|
+
// from the bar's own edge inward rather than from wherever the cursor
|
|
14716
|
+
// currently sits, so it always lands on the true first/last enabled
|
|
14717
|
+
// button regardless of where the cursor started.
|
|
14718
|
+
function moveToolbarCursorToEdge(edge) {
|
|
14719
|
+
const btns = toolbarRovingBtns();
|
|
14720
|
+
if (!btns.length) return;
|
|
14721
|
+
const step = edge < 0 ? 1 : -1;
|
|
14722
|
+
let i = edge < 0 ? 0 : btns.length - 1;
|
|
14723
|
+
for (let count = 0; count < btns.length; count++) {
|
|
14724
|
+
if (!btns[i].disabled) {
|
|
14725
|
+
toolbarRovingIndex = i;
|
|
14726
|
+
paintToolbarRoving();
|
|
14727
|
+
revealToolbarCursor(btns[i]);
|
|
14728
|
+
return;
|
|
14729
|
+
}
|
|
14730
|
+
i += step;
|
|
14731
|
+
}
|
|
14732
|
+
}
|
|
14733
|
+
|
|
13338
14734
|
// No "already in the mode" guard, because the key branch cannot deliver one:
|
|
13339
14735
|
// while the mode is on, the entry chord's own key is not one of the keys the
|
|
13340
14736
|
// mode names, so it takes the hand-back path and ends the mode instead. The
|
|
@@ -13353,37 +14749,107 @@
|
|
|
13353
14749
|
return true;
|
|
13354
14750
|
}
|
|
13355
14751
|
|
|
14752
|
+
// Task 9 review I2: real DOM focus can land on a genuine standalone
|
|
14753
|
+
// control — `#doc-search-input` (backlog #9's own fix moves focus there
|
|
14754
|
+
// on purpose) or a `textarea.ed-raw` (backlog #6's quote/code/line fix
|
|
14755
|
+
// opens one) — while toolbarRovingIndex is STILL >= 0, because neither
|
|
14756
|
+
// fix touches it (the F12 cursor is virtual by design: "no toolbar
|
|
14757
|
+
// button ever takes DOM focus", see F12's own load-bearing comment
|
|
14758
|
+
// above). MEASURED (review): with focus in `#doc-search-input` holding
|
|
14759
|
+
// 'needle' at selectionStart 6, `Home`/`End`/`ArrowLeft` were ALL still
|
|
14760
|
+
// being read as toolbar-cursor commands (Home/End jumped the BAR to its
|
|
14761
|
+
// first/last button, ArrowLeft moved the bar cursor one step) instead of
|
|
14762
|
+
// moving the text cursor inside the input — the exact new keyboard
|
|
14763
|
+
// dead-end backlog #9 was supposed to fix, now reachable through #9 and
|
|
14764
|
+
// #10 together.
|
|
14765
|
+
//
|
|
14766
|
+
// Scoped to genuine standalone controls only — INPUT/TEXTAREA/SELECT —
|
|
14767
|
+
// not to `.ed-wys-armed`/`.ed-wys-cell`: wireToolbarTracking()'s own
|
|
14768
|
+
// focusin comment is explicit that SOME toolbar activations (list,
|
|
14769
|
+
// ordered-list, check, table, insert-before, insert-after, preview)
|
|
14770
|
+
// deliberately land a focusin on an editable block WITHOUT ending keynav,
|
|
14771
|
+
// so a real block surface must not trip this bail.
|
|
14772
|
+
//
|
|
14773
|
+
// ⚠ FIX ROUND 1 (self-review, caught before ship): `preview`'s own landing
|
|
14774
|
+
// is `sourceTextarea` — a real `<textarea>`, not a `.ed-wys-armed` block —
|
|
14775
|
+
// so the exemption above named it but the first cut of this function did
|
|
14776
|
+
// not actually implement it, and the omission is load-bearing: source
|
|
14777
|
+
// mode's model disables every button except outline/preview (this file's
|
|
14778
|
+
// own "S/4.1 Source mode" comment — the cursor is meant to be walked with
|
|
14779
|
+
// Left/Right/Home/End between those two, THEN activated), and MEASURED,
|
|
14780
|
+
// that walk is exactly what this function's first cut broke — the very
|
|
14781
|
+
// next arrow key after landing in the source textarea silently exited
|
|
14782
|
+
// keynav instead of moving between the two enabled buttons, closing off
|
|
14783
|
+
// the one route backlog #6's own still-open `preview` round-trip gap (see
|
|
14784
|
+
// activateToolbarCursor()'s comment) names as "THE WAY OUT". `sourceTextarea`
|
|
14785
|
+
// is therefore excluded by reference (not by tag/class — an ordinary
|
|
14786
|
+
// `textarea.ed-raw`, e.g. backlog #6's own quote/code rescue, still trips
|
|
14787
|
+
// this bail exactly as intended, since typing there IS the user's own
|
|
14788
|
+
// text, not bar navigation).
|
|
14789
|
+
function toolbarKeynavBlockedByRealControl() {
|
|
14790
|
+
const ae = document.activeElement;
|
|
14791
|
+
if (!ae || ae === sourceTextarea) return false;
|
|
14792
|
+
return !!(ae.tagName && (ae.tagName === 'INPUT' ||
|
|
14793
|
+
ae.tagName === 'TEXTAREA' || ae.tagName === 'SELECT'));
|
|
14794
|
+
}
|
|
14795
|
+
|
|
13356
14796
|
// Activation goes through the same call the click handler makes, so a
|
|
13357
14797
|
// keyboard press and a mouse press cannot drift apart.
|
|
13358
14798
|
//
|
|
13359
14799
|
// WHERE THE CARET ENDS UP AFTERWARDS, driven button by button over the
|
|
13360
14800
|
// enabled row in a plain paragraph, pressing Enter on the cursor and then
|
|
13361
|
-
// typing to see whether the characters land anywhere
|
|
13362
|
-
//
|
|
13363
|
-
//
|
|
13364
|
-
//
|
|
13365
|
-
// *
|
|
13366
|
-
//
|
|
13367
|
-
//
|
|
13368
|
-
//
|
|
13369
|
-
//
|
|
13370
|
-
//
|
|
13371
|
-
//
|
|
13372
|
-
//
|
|
13373
|
-
//
|
|
13374
|
-
//
|
|
13375
|
-
//
|
|
13376
|
-
//
|
|
13377
|
-
//
|
|
13378
|
-
//
|
|
13379
|
-
//
|
|
13380
|
-
//
|
|
13381
|
-
//
|
|
13382
|
-
//
|
|
14801
|
+
// typing to see whether the characters land anywhere — UPDATED for Task 9
|
|
14802
|
+
// (backlog #6), which fixed the first two bullets below; only the third
|
|
14803
|
+
// remains an open gap, tracked below rather than left unstated:
|
|
14804
|
+
//
|
|
14805
|
+
// * FIXED: quote, code and line used to end on BODY with typing going
|
|
14806
|
+
// nowhere, clean burst and dirty one alike. restoreAfterStructuralOp()'s
|
|
14807
|
+
// `allowRawEditRescue` (convertBlockViaMenu() only — see its own
|
|
14808
|
+
// comment) and insertBlockBelow()'s `kind === 'line'` branch now open
|
|
14809
|
+
// that block's raw editor instead, so typing lands there.
|
|
14810
|
+
// * FIXED: undo, redo and image used to join them ONLY when the surface
|
|
14811
|
+
// had an uncommitted edit (switchAwayFrom()'s own commit-and-render is
|
|
14812
|
+
// what dropped the caret). undoViaToolbar()/redoViaToolbar()/
|
|
14813
|
+
// imageViaToolbar() now wrap the same captureCaretForSave()/
|
|
14814
|
+
// restoreCaretForSave() pair saveViaToolbar() uses.
|
|
14815
|
+
// * STILL OPEN: `preview` (cycleDocMode(), the document-wide edit↔source
|
|
14816
|
+
// toggle) is UNCHANGED — going INTO source mode focuses the source
|
|
14817
|
+
// textarea correctly, but the RETURN trip (source → edit) still leaves
|
|
14818
|
+
// the caret on BODY, MEASURED unchanged by this task's own review.
|
|
14819
|
+
// Deliberately left open rather than rushed: unlike the two bullets
|
|
14820
|
+
// above, there is no established rescue to reuse here —
|
|
14821
|
+
// captureCaretForSave()'s block-id + text-offset coordinate system has
|
|
14822
|
+
// no meaning for a source-textarea cursor position (a raw character
|
|
14823
|
+
// offset into the WHOLE document string, not one block's rendered
|
|
14824
|
+
// text), so a correct fix needs its own coordinate mapping and its own
|
|
14825
|
+
// tests, not a copy-paste of this task's other two fixes. Backlog #6's
|
|
14826
|
+
// first two clauses no longer make this escape hatch NECESSARY the way
|
|
14827
|
+
// they used to (quote/code/line now have their own direct rescue,
|
|
14828
|
+
// rather than needing the preview round-trip as their only way out),
|
|
14829
|
+
// which is why this is deferred rather than treated as still blocking.
|
|
14830
|
+
//
|
|
14831
|
+
// A mouse click on those same buttons does exactly the same thing, so none
|
|
14832
|
+
// of this is something the keyboard route introduced — a mouse user can
|
|
14833
|
+
// always climb out of the still-open `preview` case by clicking into any
|
|
14834
|
+
// OTHER editable text on the page, where a keyboard user cannot, because Tab
|
|
14835
|
+
// from BODY goes nowhere (this file's own §3.5 contract).
|
|
14836
|
+
//
|
|
14837
|
+
// THE WAY OUT for the still-open case, driven end to end from a stranded
|
|
14838
|
+
// BODY: the entry chord still works there (the bar's own state survives,
|
|
14839
|
+
// and its row is fully enabled), so walking to the mode button and pressing
|
|
14840
|
+
// Enter puts the caret in the source textarea, where typing lands. Coming
|
|
14841
|
+
// back the same way returns the document to edit mode but leaves the caret
|
|
14842
|
+
// on BODY again — the hatch is somewhere to keep typing, not (yet) a way
|
|
14843
|
+
// back to the WYSIWYG caret.
|
|
13383
14844
|
function activateToolbarCursor() {
|
|
13384
14845
|
const btn = toolbarRovingBtns()[toolbarRovingIndex];
|
|
13385
14846
|
if (!btn || btn.disabled) return;
|
|
13386
|
-
|
|
14847
|
+
// Task 9 review M3: `true` — this IS the keyboard activation path (the
|
|
14848
|
+
// only caller of runToolbarAction() that is), threaded through so
|
|
14849
|
+
// toggleOutlineSidebar() can tell a keyboard Enter/Space apart from a
|
|
14850
|
+
// mouse click on the identical button. See its own comment for why
|
|
14851
|
+
// `toolbarRovingIndex >= 0` alone was the wrong test.
|
|
14852
|
+
Promise.resolve(runToolbarAction(btn.getAttribute('data-ed-tb'), btn, true))
|
|
13387
14853
|
.catch(() => {}).then(updateToolbar);
|
|
13388
14854
|
}
|
|
13389
14855
|
|
|
@@ -13479,17 +14945,22 @@
|
|
|
13479
14945
|
return bar;
|
|
13480
14946
|
}
|
|
13481
14947
|
|
|
13482
|
-
async function runToolbarAction(id, btnEl) {
|
|
14948
|
+
async function runToolbarAction(id, btnEl, viaKeyboard) {
|
|
13483
14949
|
const blockEl = liveToolbarBlock();
|
|
13484
14950
|
switch (id) {
|
|
14951
|
+
// v3.4.0 §3: saveViaToolbar() — not save() directly. See its own
|
|
14952
|
+
// comment for why (commitThenSave()'s precondition and its own caret
|
|
14953
|
+
// rescue). backlog #6 gave undo()/redo() the same rescue below, via
|
|
14954
|
+
// undoViaToolbar()/redoViaToolbar() — see their shared comment.
|
|
14955
|
+
case 'save': return saveViaToolbar();
|
|
13485
14956
|
// ⚠ The BROWSER-side zero-argument undo()/redo(), never `stack.undo` /
|
|
13486
14957
|
// `stack.redo`. Those are the pure core's helpers (they are what
|
|
13487
14958
|
// rollbackFailedRender() reverses an op with) and calling them from
|
|
13488
14959
|
// here would skip switchAwayFrom() and safeRerenderAll() — the exact
|
|
13489
14960
|
// shape this file records as having produced a silent total lockout
|
|
13490
14961
|
// recoverable only by reloading the page.
|
|
13491
|
-
case 'undo': return
|
|
13492
|
-
case 'redo': return
|
|
14962
|
+
case 'undo': return undoViaToolbar();
|
|
14963
|
+
case 'redo': return redoViaToolbar();
|
|
13493
14964
|
case 'headings': return toggleToolbarHeadingMenu(btnEl);
|
|
13494
14965
|
// The five conversion targets are convert-md.js's own ids, so the
|
|
13495
14966
|
// gesture goes through convertBlockViaMenu()'s §3.7 / §4.3 gates
|
|
@@ -13513,24 +14984,76 @@
|
|
|
13513
14984
|
case 'line': return blockEl ? insertBlockBelow(blockEl, 'line') : undefined;
|
|
13514
14985
|
case 'insert-after': return blockEl ? insertBlockBelow(blockEl, 'paragraph') : undefined;
|
|
13515
14986
|
case 'insert-before': return blockEl ? insertBlockAbove(blockEl) : undefined;
|
|
13516
|
-
|
|
13517
|
-
|
|
14987
|
+
// backlog #6: imageViaToolbar() — not pickAndInsertImage() directly.
|
|
14988
|
+
// See its own comment (shared with undoViaToolbar()/redoViaToolbar())
|
|
14989
|
+
// for the caret rescue.
|
|
14990
|
+
case 'image': return imageViaToolbar();
|
|
14991
|
+
case 'outline': return toggleOutlineSidebar(!!viaKeyboard);
|
|
13518
14992
|
case 'preview': return cycleDocMode();
|
|
13519
14993
|
default: return undefined;
|
|
13520
14994
|
}
|
|
13521
14995
|
}
|
|
13522
14996
|
|
|
13523
14997
|
// ── headings ▾ ─────────────────────────────────────────────────────────
|
|
13524
|
-
//
|
|
13525
|
-
//
|
|
13526
|
-
//
|
|
13527
|
-
//
|
|
14998
|
+
// backlog #7 fix: the panel now carries its OWN roving cursor
|
|
14999
|
+
// (toolbarMenuIndex), a second index alongside the bar's toolbarRovingIndex
|
|
15000
|
+
// rather than a repurposing of it — the bar's cursor stays parked on
|
|
15001
|
+
// 'headings' the whole time the menu is open (K6's own pinned assertion:
|
|
15002
|
+
// Escape's first press takes the menu down and LEAVES `at: 'headings'`),
|
|
15003
|
+
// so the panel needed a cursor of its own rather than borrowing the bar's.
|
|
15004
|
+
// -1 means "nothing in the panel highlighted yet" — the state right after
|
|
15005
|
+
// opening, and the state K6 already pins Enter/Space's behaviour against
|
|
15006
|
+
// (see activateToolbarMenuCursor()'s own comment for why that case is not
|
|
15007
|
+
// reinterpreted as "select the first item").
|
|
15008
|
+
let toolbarMenuIndex = -1;
|
|
15009
|
+
|
|
15010
|
+
function toolbarMenuItems() {
|
|
15011
|
+
return toolbarMenuEl
|
|
15012
|
+
? Array.prototype.slice.call(toolbarMenuEl.querySelectorAll('.ed-toolbar-menu-btn')) : [];
|
|
15013
|
+
}
|
|
15014
|
+
|
|
15015
|
+
function paintToolbarMenuCursor() {
|
|
15016
|
+
toolbarMenuItems().forEach((b, i) => {
|
|
15017
|
+
if (i === toolbarMenuIndex) b.setAttribute('data-ed-tb-menu-cursor', '');
|
|
15018
|
+
else b.removeAttribute('data-ed-tb-menu-cursor');
|
|
15019
|
+
});
|
|
15020
|
+
}
|
|
15021
|
+
|
|
15022
|
+
// None of the six items are ever disabled (every heading depth 1-6 is
|
|
15023
|
+
// always a legal target), so this is a plain wrapping step — no
|
|
15024
|
+
// moveToolbarCursor()-style "skip disabled" walk is needed.
|
|
15025
|
+
function moveToolbarMenuCursor(delta) {
|
|
15026
|
+
const items = toolbarMenuItems();
|
|
15027
|
+
if (!items.length) return;
|
|
15028
|
+
toolbarMenuIndex = ((toolbarMenuIndex + delta) % items.length + items.length) % items.length;
|
|
15029
|
+
paintToolbarMenuCursor();
|
|
15030
|
+
items[toolbarMenuIndex].scrollIntoView({ block: 'nearest' });
|
|
15031
|
+
}
|
|
15032
|
+
|
|
15033
|
+
// K6 (test/editor-journey.test.js) pins Enter/Space, pressed with NOTHING
|
|
15034
|
+
// in the panel highlighted, as the H▾ button's own open/close toggle — the
|
|
15035
|
+
// same activateToolbarCursor() the bar's Enter/Space already runs for
|
|
15036
|
+
// every other button. Only once the user has actually moved the panel
|
|
15037
|
+
// cursor (toolbarMenuIndex >= 0, via ArrowUp/ArrowDown below) does
|
|
15038
|
+
// Enter/Space switch to activating that ITEM instead — reusing the item's
|
|
15039
|
+
// own click handler via .click() so keyboard and mouse activation are the
|
|
15040
|
+
// same code path, the same discipline activateToolbarCursor() itself
|
|
15041
|
+
// documents for the bar's buttons.
|
|
15042
|
+
function activateToolbarMenuCursor() {
|
|
15043
|
+
const items = toolbarMenuItems();
|
|
15044
|
+
const item = toolbarMenuIndex >= 0 ? items[toolbarMenuIndex] : null;
|
|
15045
|
+
if (!item) { activateToolbarCursor(); return; }
|
|
15046
|
+
item.click();
|
|
15047
|
+
}
|
|
15048
|
+
|
|
13528
15049
|
function closeToolbarMenu() {
|
|
13529
15050
|
if (toolbarMenuEl) { toolbarMenuEl.remove(); toolbarMenuEl = null; }
|
|
15051
|
+
toolbarMenuIndex = -1;
|
|
13530
15052
|
}
|
|
13531
15053
|
|
|
13532
15054
|
function toggleToolbarHeadingMenu(btnEl) {
|
|
13533
15055
|
if (toolbarMenuEl) { closeToolbarMenu(); return; }
|
|
15056
|
+
toolbarMenuIndex = -1;
|
|
13534
15057
|
const menu = document.createElement('div');
|
|
13535
15058
|
menu.className = 'ed-toolbar-menu';
|
|
13536
15059
|
[1, 2, 3, 4, 5, 6].forEach((n) => {
|
|
@@ -13683,15 +15206,28 @@
|
|
|
13683
15206
|
// lib/md2doc.js. Named for the non-default state because the desktop
|
|
13684
15207
|
// outline is a sticky column shown by default, which is also what lets
|
|
13685
15208
|
// aria-expanded below stay truthful on both viewports.
|
|
13686
|
-
function toggleOutlineSidebar() {
|
|
15209
|
+
function toggleOutlineSidebar(viaKeyboard) {
|
|
13687
15210
|
// The same 1080px breakpoint the stylesheet's own drawer block uses.
|
|
13688
15211
|
const mobile = typeof window.matchMedia === 'function' &&
|
|
13689
15212
|
window.matchMedia('(max-width: 1080px)').matches;
|
|
13690
15213
|
let expanded;
|
|
13691
15214
|
if (mobile) {
|
|
15215
|
+
// Mirror onto documentElement too, exactly as lib/md2doc.js's own
|
|
15216
|
+
// setSidebarOpen() does — the scroll lock is `html[data-sidebar-open]
|
|
15217
|
+
// { overflow: hidden }` (documentElement, not body, is what a real
|
|
15218
|
+
// gesture actually scrolls; see that rule's comment in lib/md2doc.js).
|
|
15219
|
+
// Final-review C1: this toolbar entry point set only `body`, so the
|
|
15220
|
+
// drawer visually opened but the scroll lock never engaged — the ONLY
|
|
15221
|
+
// way into the drawer in edit mode (`.sidebar-toggle` is display:none
|
|
15222
|
+
// there, see T11-2) left backlog #12 unfixed for edit mode specifically.
|
|
13692
15223
|
expanded = !document.body.hasAttribute('data-sidebar-open');
|
|
13693
|
-
if (expanded)
|
|
13694
|
-
|
|
15224
|
+
if (expanded) {
|
|
15225
|
+
document.body.setAttribute('data-sidebar-open', '');
|
|
15226
|
+
document.documentElement.setAttribute('data-sidebar-open', '');
|
|
15227
|
+
} else {
|
|
15228
|
+
document.body.removeAttribute('data-sidebar-open');
|
|
15229
|
+
document.documentElement.removeAttribute('data-sidebar-open');
|
|
15230
|
+
}
|
|
13695
15231
|
} else {
|
|
13696
15232
|
expanded = document.body.hasAttribute('data-ed-outline-hidden');
|
|
13697
15233
|
if (expanded) document.body.removeAttribute('data-ed-outline-hidden');
|
|
@@ -13699,6 +15235,42 @@
|
|
|
13699
15235
|
}
|
|
13700
15236
|
const toggle = document.getElementById('sidebar-toggle');
|
|
13701
15237
|
if (toggle) toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
|
15238
|
+
// backlog #9: v3.3.0 gave a keyboard route to OPEN the drawer (this very
|
|
15239
|
+
// button, reachable through the toolbar's F12 keynav) but none to
|
|
15240
|
+
// OPERATE what is inside it — the TOC links and the search box
|
|
15241
|
+
// (`#doc-search-input`, lib/md2doc.js's reader-sidebar markup) were
|
|
15242
|
+
// unreachable by keyboard once the drawer was open, because F12's
|
|
15243
|
+
// cursor is VIRTUAL (see its own load-bearing comment: "no toolbar
|
|
15244
|
+
// button ever takes DOM focus") and a bare Tab with NOTHING focused is
|
|
15245
|
+
// deliberately swallowed elsewhere in this file (§3.5's block-indent
|
|
15246
|
+
// contract, a few hundred lines down) — so a keyboard user who had
|
|
15247
|
+
// never clicked into a real control was left with no Tab destination at
|
|
15248
|
+
// all, drawer open or not.
|
|
15249
|
+
//
|
|
15250
|
+
// Scoped to the KEYBOARD path only: a mouse click reaches this same
|
|
15251
|
+
// function, and buildToolbar()'s own mousedown-preventDefault() note is
|
|
15252
|
+
// explicit that a toolbar press must NOT steal focus from whatever
|
|
15253
|
+
// burst the mouse user has open — forcing focus into the search box on
|
|
15254
|
+
// every click would blur that burst (committing it) for no reason a
|
|
15255
|
+
// mouse user asked for. Also scoped to `expanded`: a toggle that just
|
|
15256
|
+
// HID the drawer has nowhere sensible to send focus.
|
|
15257
|
+
//
|
|
15258
|
+
// Task 9 review M3: `viaKeyboard`, an explicit parameter threaded from
|
|
15259
|
+
// activateToolbarCursor() — NOT `toolbarRovingIndex >= 0`, which was
|
|
15260
|
+
// the first cut's test and is wrong: toolbarRovingIndex says whether
|
|
15261
|
+
// keynav is CURRENTLY on, not which INPUT DEVICE fired this particular
|
|
15262
|
+
// activation. MEASURED: press Alt+F10 (keynav now on), then click ☰
|
|
15263
|
+
// with the MOUSE — toolbarRovingIndex is still >= 0 from the earlier
|
|
15264
|
+
// keypress, so the old test treated a plain mouse click as "keyboard",
|
|
15265
|
+
// stealing focus into the search box exactly as the commit message for
|
|
15266
|
+
// backlog #9 says it must not ("a mouse click on the same button must
|
|
15267
|
+
// not steal focus"). `viaKeyboard` is true on ONE call path only —
|
|
15268
|
+
// activateToolbarCursor(), which IS the keyboard route — regardless of
|
|
15269
|
+
// whatever toolbarRovingIndex happens to hold at the time.
|
|
15270
|
+
if (expanded && viaKeyboard) {
|
|
15271
|
+
const search = document.getElementById('doc-search-input');
|
|
15272
|
+
if (search) search.focus();
|
|
15273
|
+
}
|
|
13702
15274
|
}
|
|
13703
15275
|
|
|
13704
15276
|
// ── 追加 3: images ─────────────────────────────────────────────────────
|
|
@@ -13730,7 +15302,13 @@
|
|
|
13730
15302
|
// authoritative.
|
|
13731
15303
|
async function uploadImageBlob(blob) {
|
|
13732
15304
|
if (!blob) return null;
|
|
13733
|
-
|
|
15305
|
+
// A .drawio arrives with `type === ''` -- browsers have no media type for
|
|
15306
|
+
// it -- so `extFor()` alone answered "unsupported" without ever asking the
|
|
15307
|
+
// server. The name is enough to get the request SENT; it is not enough to
|
|
15308
|
+
// get anything written: /api/asset re-checks the extension against its own
|
|
15309
|
+
// two literals and then parses the bytes. Keep the two rules the same
|
|
15310
|
+
// shape, and let the server's refusal be the one the user sees.
|
|
15311
|
+
if (!insertableFile(blob)) {
|
|
13734
15312
|
showBanner(TOOLBAR_UNSUPPORTED_IMAGE_MESSAGE, null, null);
|
|
13735
15313
|
return null;
|
|
13736
15314
|
}
|
|
@@ -13760,6 +15338,15 @@
|
|
|
13760
15338
|
}
|
|
13761
15339
|
let j;
|
|
13762
15340
|
try { j = await res.json(); } catch (e) { j = null; }
|
|
15341
|
+
// A refusal carries a reason; saying "伺服器回應格式不正確" instead is a
|
|
15342
|
+
// second wrong answer on top of the first. The content gate added in
|
|
15343
|
+
// v3.4.1 makes this reachable for an ordinary mistake -- picking an .xml
|
|
15344
|
+
// that is not a draw.io diagram -- so the reason has to arrive.
|
|
15345
|
+
if (!res.ok) {
|
|
15346
|
+
const why = (j && typeof j.error === 'string' && j.error) ? j.error : ('HTTP ' + res.status);
|
|
15347
|
+
showBanner('檔案上傳失敗 — ' + why, null, null);
|
|
15348
|
+
return null;
|
|
15349
|
+
}
|
|
13763
15350
|
if (!j || typeof j.path !== 'string') {
|
|
13764
15351
|
showBanner('圖片上傳失敗 — 伺服器回應格式不正確。', null, null);
|
|
13765
15352
|
return null;
|
|
@@ -13873,7 +15460,13 @@
|
|
|
13873
15460
|
const dialogIdentity = captureBlockIdentity(anchorEl);
|
|
13874
15461
|
const input = document.createElement('input');
|
|
13875
15462
|
input.type = 'file';
|
|
13876
|
-
|
|
15463
|
+
// v3.4.1: extension tokens sit beside the MIME tokens deliberately.
|
|
15464
|
+
// `.drawio` has no registered media type, so a MIME-only accept list
|
|
15465
|
+
// cannot name it and the dialog greys the file out -- which is how
|
|
15466
|
+
// v3.4.0 shipped a renderer that embeds .drawio/.xml next to an insert
|
|
15467
|
+
// button that could not pick one. Mixing the two token kinds is what
|
|
15468
|
+
// the attribute is specified for.
|
|
15469
|
+
input.accept = 'image/png,image/jpeg,image/gif,image/webp,.drawio,.xml';
|
|
13877
15470
|
input.multiple = true;
|
|
13878
15471
|
input.hidden = true;
|
|
13879
15472
|
// (a) above.
|
|
@@ -13897,6 +15490,21 @@
|
|
|
13897
15490
|
input.click();
|
|
13898
15491
|
}
|
|
13899
15492
|
|
|
15493
|
+
// backlog #6 — see undoViaToolbar()/redoViaToolbar()'s shared comment.
|
|
15494
|
+
// pickAndInsertImage() resolves (its own switchAwayFrom() included) well
|
|
15495
|
+
// before the user has picked a file — the `input.click()` above only OPENS
|
|
15496
|
+
// the native file dialog, it does not await a selection — so capturing
|
|
15497
|
+
// before and restoring right after this call lands at exactly the same
|
|
15498
|
+
// point in the gesture saveViaToolbar()'s pair does for `save`: after the
|
|
15499
|
+
// commit-and-render that can strand the caret, before the (much later,
|
|
15500
|
+
// asynchronous, maybe-never-arrives) file selection that focuses somewhere
|
|
15501
|
+
// new anyway.
|
|
15502
|
+
async function imageViaToolbar() {
|
|
15503
|
+
const saved = captureCaretForSave();
|
|
15504
|
+
await pickAndInsertImage();
|
|
15505
|
+
restoreCaretForSave(saved);
|
|
15506
|
+
}
|
|
15507
|
+
|
|
13900
15508
|
function dragCarriesFiles(e) {
|
|
13901
15509
|
const dt = e.dataTransfer;
|
|
13902
15510
|
if (!dt || !dt.types) return false;
|
|
@@ -13924,18 +15532,26 @@
|
|
|
13924
15532
|
// user is looking at is not the content a drop would write into" — a
|
|
13925
15533
|
// future third mode inherits the refusal rather than the hole.
|
|
13926
15534
|
document.addEventListener('dragover', (e) => {
|
|
15535
|
+
// Nothing is dropped into a document behind a modal. Returning WITHOUT
|
|
15536
|
+
// preventDefault() is what makes the browser refuse the drop, so the
|
|
15537
|
+
// gesture is declined visibly rather than swallowed.
|
|
15538
|
+
if (waveEditorIsModal()) return;
|
|
13927
15539
|
if (docMode !== 'edit') return;
|
|
13928
15540
|
if (!dragCarriesFiles(e)) return;
|
|
13929
15541
|
e.preventDefault();
|
|
13930
15542
|
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
|
13931
15543
|
});
|
|
13932
15544
|
document.addEventListener('drop', (e) => {
|
|
15545
|
+
// MEASURED before this line existed: a PNG dropped on the open wave panel
|
|
15546
|
+
// took the document behind it from 4 blocks to 5 and put an `<img>` into
|
|
15547
|
+
// `.content`, with the overlay still up and saying nothing.
|
|
15548
|
+
if (waveEditorIsModal()) return;
|
|
13933
15549
|
if (docMode !== 'edit') return;
|
|
13934
15550
|
if (!dragCarriesFiles(e)) return;
|
|
13935
15551
|
const files = e.dataTransfer
|
|
13936
15552
|
? Array.prototype.slice.call(e.dataTransfer.files || [])
|
|
13937
15553
|
: [];
|
|
13938
|
-
const images = files.filter(
|
|
15554
|
+
const images = files.filter(insertableFile);
|
|
13939
15555
|
if (!images.length) return; // not ours: let the page do whatever it would
|
|
13940
15556
|
e.preventDefault();
|
|
13941
15557
|
const anchorEl = (e.target && e.target.closest) ? e.target.closest('.ed-block') : null;
|
|
@@ -13955,8 +15571,15 @@
|
|
|
13955
15571
|
try { items['text/html'] = dt.getData('text/html') || ''; } catch (err) { items['text/html'] = ''; }
|
|
13956
15572
|
const files = dt.files ? Array.prototype.slice.call(dt.files) : [];
|
|
13957
15573
|
for (let i = 0; i < files.length; i += 1) {
|
|
13958
|
-
|
|
13959
|
-
|
|
15574
|
+
// This map is keyed by MIME, and a .drawio has no MIME at all, so a
|
|
15575
|
+
// diagram pasted as a FILE goes under the fixed 'file' key that
|
|
15576
|
+
// pickPayload() also looks at. The key is a protocol constant between
|
|
15577
|
+
// these two modules -- the RULE for which files are insertable stays
|
|
15578
|
+
// in insertableFile() alone, so paste-md.js never grows a second
|
|
15579
|
+
// opinion about extensions.
|
|
15580
|
+
if (files[i] && insertableFile(files[i])) {
|
|
15581
|
+
const mime = String(files[i].type || '');
|
|
15582
|
+
items[mime.indexOf('image/') === 0 ? mime : 'file'] = files[i];
|
|
13960
15583
|
break;
|
|
13961
15584
|
}
|
|
13962
15585
|
}
|
|
@@ -14023,7 +15646,9 @@
|
|
|
14023
15646
|
// commitBlockInsertion()/replaceLines() and therefore through
|
|
14024
15647
|
// serializeBlocks()'s existing gates, instead of arriving as a pile of
|
|
14025
15648
|
// foreign nodes the serializer would have to make sense of afterwards.
|
|
14026
|
-
|
|
15649
|
+
const pastedLines = md.split('\n');
|
|
15650
|
+
insertBlockBelow(editEl.closest('.ed-block'), 'paragraph',
|
|
15651
|
+
{ lines: pastedLines, tight: pasteIsAllListLines(pastedLines) });
|
|
14027
15652
|
return;
|
|
14028
15653
|
}
|
|
14029
15654
|
snapBurstIfActive(editEl, 'paste-pre');
|
|
@@ -14035,6 +15660,7 @@
|
|
|
14035
15660
|
// Capture phase: this only records intent, and must do so before any
|
|
14036
15661
|
// other keydown handler can preventDefault() its way past it.
|
|
14037
15662
|
document.addEventListener('keydown', (e) => {
|
|
15663
|
+
if (waveEditorIsModal()) return;
|
|
14038
15664
|
plainPasteArmed = !!((e.ctrlKey || e.metaKey) && e.shiftKey &&
|
|
14039
15665
|
(e.key === 'v' || e.key === 'V'));
|
|
14040
15666
|
}, true);
|
|
@@ -14144,6 +15770,15 @@
|
|
|
14144
15770
|
updateToolbar();
|
|
14145
15771
|
});
|
|
14146
15772
|
document.addEventListener('click', (e) => {
|
|
15773
|
+
// v3.4.0 batch3 Task 6: this file has TWO document-level `click`
|
|
15774
|
+
// delegates, and for one round only the other one asked. Nothing behind
|
|
15775
|
+
// the modal takes clicks — measured benign here today (the toolbar menu
|
|
15776
|
+
// cannot be open behind a modal, keynav cannot be entered while the main
|
|
15777
|
+
// keydown returns first, and `closest('.ed-block')` is null for anything
|
|
15778
|
+
// in the overlay), but "benign today" is a property of the three branches
|
|
15779
|
+
// below rather than of the boundary, and the next branch added here would
|
|
15780
|
+
// inherit an unguarded seat behind a modal.
|
|
15781
|
+
if (waveEditorIsModal()) return;
|
|
14147
15782
|
if (!e.target || !e.target.closest) return;
|
|
14148
15783
|
if (e.target.closest('.ed-toolbar') || e.target.closest('.ed-toolbar-menu')) return;
|
|
14149
15784
|
closeToolbarMenu();
|
|
@@ -14262,24 +15897,134 @@
|
|
|
14262
15897
|
// keydown ends the mode and the arrow behind it falls through to
|
|
14263
15898
|
// the block-selection extension instead.
|
|
14264
15899
|
//
|
|
14265
|
-
// A menu raised from this bar goes down on the way past
|
|
14266
|
-
//
|
|
14267
|
-
//
|
|
14268
|
-
//
|
|
14269
|
-
//
|
|
14270
|
-
//
|
|
14271
|
-
|
|
14272
|
-
|
|
14273
|
-
|
|
15900
|
+
// A menu raised from this bar goes down on the way past — UNLESS the key
|
|
15901
|
+
// is one the panel itself now claims (backlog #7, see the nested
|
|
15902
|
+
// `if (toolbarMenuEl)` just below). Before that fix ALL of
|
|
15903
|
+
// Up/Down/Enter/Space fell through this whole file with the menu open
|
|
15904
|
+
// and its six items unreachable by keyboard; without the panel going
|
|
15905
|
+
// down too on everything else, the hand-back cleared the cursor cue and
|
|
15906
|
+
// left the H▾ dropdown standing, so the two disagreed and only Escape
|
|
15907
|
+
// was any use. Escape is excluded because the ordered prologue below
|
|
15908
|
+
// owns it and takes the menu down there, in rank order.
|
|
15909
|
+
// Task 9 review I2: a real control (search box, raw editor textarea)
|
|
15910
|
+
// holding focus quietly ends keynav and hands EVERY key straight to it
|
|
15911
|
+
// — see toolbarKeynavBlockedByRealControl()'s own comment. No banner:
|
|
15912
|
+
// this is not a stray key with nowhere to go, it is the user typing
|
|
15913
|
+
// into something they can see is focused.
|
|
15914
|
+
// ── v3.4.0 batch3 Task 6: the wave editor owns the keyboard, EXCEPT ──
|
|
15915
|
+
// Above everything, including the toolbar's keyboard mode. While that
|
|
15916
|
+
// overlay is up it is modal over the document, and almost every branch
|
|
15917
|
+
// below this line is about a document the user cannot see or reach —
|
|
15918
|
+
// including the `Tab` branch, which used to swallow the key and leave all
|
|
15919
|
+
// of an `aria-modal` dialog's controls unreachable. The overlay's own
|
|
15920
|
+
// capture-phase handler owns Escape, Tab and undo/redo and stops them
|
|
15921
|
+
// before they get here; this is the other half of that deal, stated on this
|
|
15922
|
+
// side so the two cannot drift into disagreeing about who owns what.
|
|
15923
|
+
//
|
|
15924
|
+
// SAVE IS THE EXCEPTION, and it is the exception because ownership is about
|
|
15925
|
+
// which gestures, not only about who. Ctrl+S is not a gesture aimed at the
|
|
15926
|
+
// document the modal is covering — it is the reflex that protects work, and
|
|
15927
|
+
// the wave editor has nothing of its own to save in this task. Swallowing
|
|
15928
|
+
// it cost twice over, both measured: the keystroke did nothing and said
|
|
15929
|
+
// nothing (`disk has MARKA? false` with the overlay open, `true` after
|
|
15930
|
+
// Escape), and — worse — `showConflictBanner()` has exactly ONE producer,
|
|
15931
|
+
// the 409 branch of `save()`, while the toolbar's save control sits under an
|
|
15932
|
+
// `inset: 0` backdrop and outside the focus trap. So with Ctrl+S swallowed,
|
|
15933
|
+
// a disk conflict arising DURING a wave session could not raise a banner at
|
|
15934
|
+
// all: measured, file changed underneath, overlay open, Ctrl+S, banner
|
|
15935
|
+
// `null`. All the work keeping `.ed-conflict` above the modal and inside the
|
|
15936
|
+
// focus trap only ever fired for a banner that predated the overlay.
|
|
15937
|
+
//
|
|
15938
|
+
// ⚠ fix 4 (R1): something else now DEPENDS on this line being as narrow as
|
|
15939
|
+
// it is. `waveDiscardIsSafe()` decides whether a wave session's commits can
|
|
15940
|
+
// be popped off the document stack by comparing a COUNT
|
|
15941
|
+
// (`depth === baseDepth + ops`), which proves only that the same number of
|
|
15942
|
+
// entries sit above where the session started — a foreign push paired with
|
|
15943
|
+
// a foreign pop inside one session satisfies it, and
|
|
15944
|
+
// `discardPristineInsert()` is exactly that shape. It is unreachable today
|
|
15945
|
+
// because this gate returns on every non-save key and because
|
|
15946
|
+
// `openWaveEditor()` resolves the one remaining path (Ctrl+S's
|
|
15947
|
+
// `switchAwayFrom()`) BEFORE the overlay opens. Letting one more gesture
|
|
15948
|
+
// through here re-opens that hole silently; if that is ever the right call,
|
|
15949
|
+
// waveDiscardIsSafe()'s check has to become identity-based first.
|
|
15950
|
+
if (waveEditorIsModal() && !isSaveGesture(e)) return;
|
|
15951
|
+
if (toolbarRovingIndex >= 0 && toolbarKeynavBlockedByRealControl()) {
|
|
15952
|
+
exitToolbarKeynav();
|
|
15953
|
+
} else if (toolbarRovingIndex >= 0) {
|
|
15954
|
+
// backlog #7: the H▾ panel claims Up/Down/Enter/Space for its OWN
|
|
15955
|
+
// roving cursor before anything below gets a look at them — see
|
|
15956
|
+
// toolbarMenuIndex's own comment for why that is a second index
|
|
15957
|
+
// rather than a repurposing of toolbarRovingIndex. Every other key
|
|
15958
|
+
// (Left/Right/Home/End/Tab/…) still closes the panel exactly as
|
|
15959
|
+
// before, which is why the fallback line at the end here is the
|
|
15960
|
+
// ORIGINAL condition with the now-claimed keys still excluded from it
|
|
15961
|
+
// (Escape stays excluded too — the ordered prologue below owns it, in
|
|
15962
|
+
// rank order, same as always).
|
|
15963
|
+
if (toolbarMenuEl) {
|
|
15964
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); moveToolbarMenuCursor(1); return; }
|
|
15965
|
+
if (e.key === 'ArrowUp') { e.preventDefault(); moveToolbarMenuCursor(-1); return; }
|
|
15966
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
15967
|
+
e.preventDefault(); activateToolbarMenuCursor(); return;
|
|
15968
|
+
}
|
|
15969
|
+
if (e.key !== 'Escape') closeToolbarMenu();
|
|
14274
15970
|
}
|
|
14275
15971
|
if (e.key === 'ArrowRight') { e.preventDefault(); moveToolbarCursor(1); return; }
|
|
14276
15972
|
if (e.key === 'ArrowLeft') { e.preventDefault(); moveToolbarCursor(-1); return; }
|
|
15973
|
+
// backlog #10: Home/End are the ARIA toolbar pattern's own "jump to
|
|
15974
|
+
// the first/last item" keys — a natural extension of the roving pair
|
|
15975
|
+
// just above, not a new idiom. Naming them here (instead of letting
|
|
15976
|
+
// them fall to the hand-back branch below) turns two of the seven
|
|
15977
|
+
// keys MEASURED to silently drop the cursor into something USEFUL.
|
|
15978
|
+
if (e.key === 'Home') { e.preventDefault(); moveToolbarCursorToEdge(-1); return; }
|
|
15979
|
+
if (e.key === 'End') { e.preventDefault(); moveToolbarCursorToEdge(1); return; }
|
|
14277
15980
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
14278
15981
|
e.preventDefault(); activateToolbarCursor(); return;
|
|
14279
15982
|
}
|
|
15983
|
+
// backlog #10: everything else hands the key back ON PURPOSE — Tab
|
|
15984
|
+
// moves real DOM focus per the document's own tab order (see
|
|
15985
|
+
// buildToolbar()'s own "Tab key belongs to the document's own
|
|
15986
|
+
// structure" note), and a plain character or Backspace has nowhere to
|
|
15987
|
+
// land while the virtual cursor sits over a non-editable BODY, so
|
|
15988
|
+
// letting them fall through is harmless. What is NOT harmless,
|
|
15989
|
+
// MEASURED (v3.3.0): the hand-back left NOTHING on screen to mark it —
|
|
15990
|
+
// `[data-ed-tb-cursor]` and `data-ed-tb-keynav` both went null with the
|
|
15991
|
+
// bar looking exactly as it did the instant before Alt+F10 was first
|
|
15992
|
+
// pressed, so a user who does not catch the outline vanishing has no
|
|
15993
|
+
// way to tell they fell out of keyboard mode. Two fixes were on the
|
|
15994
|
+
// table (v3.3.0's own ruling: 「一併處理,加上可見的信號」 — do both):
|
|
15995
|
+
// teach the specific keys that have somewhere useful to go (Home/End,
|
|
15996
|
+
// above) to stop exiting, AND raise a visible banner on every hand-back
|
|
15997
|
+
// that still exits. Enumerating "every key with nowhere useful to go"
|
|
15998
|
+
// was rejected as the SOLE fix — that list is open-ended and this file
|
|
15999
|
+
// cannot audit it for completeness, while a banner on every hand-back
|
|
16000
|
+
// is correct for any key this branch is ever reached by, including
|
|
16001
|
+
// ones nobody has measured yet.
|
|
16002
|
+
//
|
|
16003
|
+
// Task 9 review I3: `Tab` is excluded from the banner here (though it
|
|
16004
|
+
// still exits keynav, same as before) — it is the ARIA toolbar
|
|
16005
|
+
// pattern's OWN "leave the toolbar" gesture, a legitimate exit rather
|
|
16006
|
+
// than a stray key with nowhere to go, and raising a full-width red
|
|
16007
|
+
// banner over the whole bar for a correct keystroke was the wrong
|
|
16008
|
+
// direction. MEASURED AT THE TIME: dismissKeynavExitBanner() was the
|
|
16009
|
+
// only way a keyboard user could take the earlier, unconditional
|
|
16010
|
+
// banner back down at all (`.ed-conflict` was `z-index:999` over the
|
|
16011
|
+
// bar's own `z-index:101` AND `top:0` against the bar's own `top:0`,
|
|
16012
|
+
// covering all 23 buttons; a bare Tab with nothing focused is
|
|
16013
|
+
// swallowed by this file's own §3.5 contract, so Escape was the only
|
|
16014
|
+
// route left) — excluding Tab here removes most of the cases that
|
|
16015
|
+
// needed that route in the first place, and Escape's own rung below
|
|
16016
|
+
// closes the rest. Task 10 (backlog #8) has since moved `.ed-conflict`
|
|
16017
|
+
// to `top: var(--ed-toolbar-h)`, so it no longer covers the bar for
|
|
16018
|
+
// ANY banner — the "covering all 23 buttons" clause above is
|
|
16019
|
+
// historical (what justified wiring Escape at the time), not the
|
|
16020
|
+
// current geometry.
|
|
14280
16021
|
if (e.key !== 'Escape' && e.key !== 'Alt' && e.key !== 'Shift' &&
|
|
14281
16022
|
e.key !== 'Control' && e.key !== 'Meta') {
|
|
14282
16023
|
exitToolbarKeynav();
|
|
16024
|
+
if (e.key !== 'Tab') {
|
|
16025
|
+
showBanner(TOOLBAR_KEYNAV_EXIT_MESSAGE, null, null);
|
|
16026
|
+
activeBannerIsKeynavExit = true;
|
|
16027
|
+
}
|
|
14283
16028
|
}
|
|
14284
16029
|
} else if (e.altKey && e.key === 'F10') {
|
|
14285
16030
|
e.preventDefault();
|
|
@@ -14353,6 +16098,13 @@
|
|
|
14353
16098
|
// bar, so it goes out before the bar's keyboard mode does, matching the
|
|
14354
16099
|
// way the menu rungs above rank against the selection rung below.
|
|
14355
16100
|
if (toolbarMenuEl) { e.preventDefault(); closeToolbarMenu(); return; }
|
|
16101
|
+
// Task 9 review I3: the keynav-exit banner (backlog #10) is only ever
|
|
16102
|
+
// up once keynav has ALREADY exited, so this and the
|
|
16103
|
+
// exitToolbarKeynav() rung right below it never both apply to the
|
|
16104
|
+
// same Escape — order between the two is not load-bearing, grouped
|
|
16105
|
+
// here only for readability. See dismissKeynavExitBanner()'s own
|
|
16106
|
+
// comment for why Escape, specifically, is safe to wire to it.
|
|
16107
|
+
if (dismissKeynavExitBanner()) { e.preventDefault(); return; }
|
|
14356
16108
|
if (exitToolbarKeynav()) { e.preventDefault(); return; }
|
|
14357
16109
|
if (blockSelection) { e.preventDefault(); clearBlockSelection(); return; }
|
|
14358
16110
|
// Nothing above owns this Escape — fall through to the burst
|
|
@@ -14372,6 +16124,17 @@
|
|
|
14372
16124
|
// commit failure switchAwayFrom() returns false — the banner it
|
|
14373
16125
|
// already showed is the visible reason save() is skipped; the burst
|
|
14374
16126
|
// stays open with the user's text intact rather than saving nothing.
|
|
16127
|
+
//
|
|
16128
|
+
// v3.4.0 §3: this exact precondition — resolve first, save second —
|
|
16129
|
+
// is now also named once as commitThenSave(), for the toolbar `save`
|
|
16130
|
+
// button (see its own comment). Left spelled out here rather than
|
|
16131
|
+
// switched to call it: this line is pinned verbatim by a static
|
|
16132
|
+
// source-shape check in test/editor-client.test.js's
|
|
16133
|
+
// shortcutOrderChecks, which is outside this task's file scope, and
|
|
16134
|
+
// the two spellings are behaviourally identical fire-and-forget calls
|
|
16135
|
+
// — nothing is duplicated here beyond this one line shape, and
|
|
16136
|
+
// save()'s own body is still called exactly once, from inside
|
|
16137
|
+
// commitThenSave() when the toolbar path runs it.
|
|
14375
16138
|
switchAwayFrom().then((ok) => { if (ok) save(); });
|
|
14376
16139
|
return;
|
|
14377
16140
|
}
|
|
@@ -14447,7 +16210,19 @@
|
|
|
14447
16210
|
// `blockSelection` is the gate, and it is the whole guard: with no set
|
|
14448
16211
|
// standing there is no focus holder either, and every key below behaves
|
|
14449
16212
|
// exactly as it did pre-S3.
|
|
14450
|
-
|
|
16213
|
+
//
|
|
16214
|
+
// v3.4.0 batch3 Task 6: that invariant is now CHECKED and not assumed. It
|
|
16215
|
+
// was true only because nothing had ever mounted a text field outside
|
|
16216
|
+
// `.content`; the wave editor puts real `<input>`s on document.body, which
|
|
16217
|
+
// are neither `.ed-wys-armed` nor `.ed-wys-cell` either, and a Backspace
|
|
16218
|
+
// typed into one ran deleteSelection() — MEASURED: 3 blocks became 2, the
|
|
16219
|
+
// tail paragraph was gone from the document, and the keystroke never
|
|
16220
|
+
// reached the input at all. Silent data loss behind a modal. A guard that
|
|
16221
|
+
// holds only because nobody has done the thing it forbids is not a guard,
|
|
16222
|
+
// so the text-surface test is spelled out rather than inferred from two
|
|
16223
|
+
// class names, and it protects every future surface too.
|
|
16224
|
+
if (blockSelection && !e.ctrlKey && !e.metaKey && !e.altKey &&
|
|
16225
|
+
!isTextEntryTarget(e.target)) {
|
|
14451
16226
|
if (e.key === 'Tab') {
|
|
14452
16227
|
// §3.5, in as many words: 必須 preventDefault(),否則 Tab 在 body 上是
|
|
14453
16228
|
// 瀏覽器焦點巡覽.
|
|
@@ -14468,6 +16243,52 @@
|
|
|
14468
16243
|
}
|
|
14469
16244
|
}
|
|
14470
16245
|
|
|
16246
|
+
// v3.4.0 batch3 Task 8: the wave editor's KEYBOARD entry.
|
|
16247
|
+
//
|
|
16248
|
+
// Until this, the only way into it was the `編輯波形` affordance, which is
|
|
16249
|
+
// raised by a `mouseover` and positioned over the diagram — so a keyboard
|
|
16250
|
+
// user could not open it at all. That is the same defect this file has
|
|
16251
|
+
// already paid for twice on other surfaces: an affordance only a pointer
|
|
16252
|
+
// can raise (v3.3.0's `H▾` dropdown) and a panel with no keyboard route
|
|
16253
|
+
// into its contents (the outline drawer, backlog #9).
|
|
16254
|
+
//
|
|
16255
|
+
// Gated on the BLOCK WRAPPER ITSELF, not on `blockSelection` and not on
|
|
16256
|
+
// `closest()`: the focus holder is the same `.ed-block` element in both of
|
|
16257
|
+
// the states that can get here — a standing selection (whose roving
|
|
16258
|
+
// `tabindex` focuses it) and the focus this file hands back when a wave
|
|
16259
|
+
// session closes — so one branch serves the way in and the way back in.
|
|
16260
|
+
//
|
|
16261
|
+
// `closest('.ed-block')` is what the first cut used and it was wrong by
|
|
16262
|
+
// exactly this defect family: EVERY block contains an `.ed-insert` and an
|
|
16263
|
+
// `.ed-handle` <button>, so Enter on the ⠿ of a wavedrom block opened the
|
|
16264
|
+
// wave editor and `preventDefault()`ed the block menu, while SPACE on the
|
|
16265
|
+
// same button still opened the menu — one button, two devices'
|
|
16266
|
+
// conventions, two answers, which is v3.3.0 backlog #4 with the devices
|
|
16267
|
+
// swapped. That path also has no `blockSelection`, so closing the session
|
|
16268
|
+
// it wrongly opened would have ended on `document.body`. It was latent
|
|
16269
|
+
// only because nothing can put the keyboard on those buttons today, and it
|
|
16270
|
+
// would have gone live with the very follow-up this task is asking for.
|
|
16271
|
+
//
|
|
16272
|
+
// `isTextEntryTarget` keeps it away from every editable, where Enter
|
|
16273
|
+
// already means a paragraph break, and the modal gate far above means it
|
|
16274
|
+
// cannot fire while the overlay is up.
|
|
16275
|
+
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey &&
|
|
16276
|
+
!isTextEntryTarget(e.target) && e.target && e.target.classList &&
|
|
16277
|
+
e.target.classList.contains('ed-block')) {
|
|
16278
|
+
const blockEl = e.target;
|
|
16279
|
+
const diagram = waveDiagramInside(blockEl);
|
|
16280
|
+
if (diagram !== null) {
|
|
16281
|
+
e.preventDefault();
|
|
16282
|
+
// Same shape as the affordance's own click handler: reported, not
|
|
16283
|
+
// rethrown. An unhandled rejection out of a keydown listener is a
|
|
16284
|
+
// page-level error, and openWaveEditor() unwinds its own state.
|
|
16285
|
+
openWaveEditor(blockEl).catch((err) => {
|
|
16286
|
+
console.error('md2doc: the wave editor could not open', err);
|
|
16287
|
+
});
|
|
16288
|
+
return;
|
|
16289
|
+
}
|
|
16290
|
+
}
|
|
16291
|
+
|
|
14471
16292
|
// v2.11.1 acceptance, escape class A: Tab with NOTHING focused. Every
|
|
14472
16293
|
// branch above is keyed on the event target being some edit surface, and
|
|
14473
16294
|
// after a commit / Escape / Ctrl+Z, or a click on a bullet marker or in
|
|
@@ -14761,6 +16582,12 @@
|
|
|
14761
16582
|
});
|
|
14762
16583
|
|
|
14763
16584
|
document.addEventListener('paste', (e) => {
|
|
16585
|
+
// Same question as `drop` above, and for the same reason: what the document
|
|
16586
|
+
// behind a modal receives is nothing. Today the two branches below already
|
|
16587
|
+
// require a `.ed-wys-cell` / `.ed-wys-armed` target, which a paste into a
|
|
16588
|
+
// wave field is not — this is here so the boundary is one predicate rather
|
|
16589
|
+
// than a property of which targets those two selectors happen to match.
|
|
16590
|
+
if (waveEditorIsModal()) return;
|
|
14764
16591
|
const cellEl = e.target && e.target.closest && e.target.closest('.ed-wys-cell');
|
|
14765
16592
|
if (cellEl) {
|
|
14766
16593
|
e.preventDefault();
|
|
@@ -14930,25 +16757,1394 @@
|
|
|
14930
16757
|
document.addEventListener('scroll', onAnyScroll, { passive: true, capture: true });
|
|
14931
16758
|
|
|
14932
16759
|
window.addEventListener('beforeunload', (e) => {
|
|
14933
|
-
|
|
16760
|
+
// Review I1: documentIsDirty(), not a second copy of the expression it
|
|
16761
|
+
// wraps — this was the second caller still spelling it out by hand.
|
|
16762
|
+
if (documentIsDirty()) {
|
|
14934
16763
|
e.preventDefault();
|
|
14935
16764
|
e.returnValue = '';
|
|
14936
16765
|
return '';
|
|
14937
16766
|
}
|
|
14938
16767
|
});
|
|
14939
16768
|
|
|
16769
|
+
// v3.4.0 batch2 Task 6 (Ruling B2-2) — background refresh for a referenced
|
|
16770
|
+
// `.drawio`/`.xml` file that changed on disk, triggered by the ping loop
|
|
16771
|
+
// below seeing `{stale:true}`.
|
|
16772
|
+
//
|
|
16773
|
+
// Deliberately NOT rerenderAll(): that function carries edit-commit
|
|
16774
|
+
// semantics nobody asked for here (dropDiscardedBurst/dropDiscardedRawEdit,
|
|
16775
|
+
// the swallowed-fence banner, `pendingSelectionRange` consumption) — this
|
|
16776
|
+
// only ever touches blocks whose OWN baked diagram actually differs, never
|
|
16777
|
+
// reassigns `blocks`/`lines` (the source text did not change, only a
|
|
16778
|
+
// referenced file's bytes did), and never moves focus out of an editor.
|
|
16779
|
+
//
|
|
16780
|
+
// ── Fix round 2 (re-review G1): ONE criterion for "is there a diagram here"
|
|
16781
|
+
//
|
|
16782
|
+
// The first cut asked two different questions. The pre-fetch guard asked the
|
|
16783
|
+
// DOM (`querySelector('.drawio')`); the per-block loop asked a text regex
|
|
16784
|
+
// over the part strings. They disagree on a block whose rendered markup
|
|
16785
|
+
// merely CONTAINS the literal — a fenced code block or a table cell
|
|
16786
|
+
// documenting this very feature, which this repo's own docs plausibly hold.
|
|
16787
|
+
// Three consequences, all of them real:
|
|
16788
|
+
// * a fenced code block selected as a diagram block and was replaced
|
|
16789
|
+
// wholesale on every re-bake (G3),
|
|
16790
|
+
// * the pre-fetch guard could not see a bail the loop would take, so with
|
|
16791
|
+
// F2's retry in place the caret resting in that code block cost a full
|
|
16792
|
+
// /api/render — headless Chromium, every page of every referenced
|
|
16793
|
+
// diagram — every 10 seconds, forever, with the diagram never updating,
|
|
16794
|
+
// * and because the bootstrap seed is the HTML parser's re-serialisation
|
|
16795
|
+
// rather than the server's own strings, the cheap `parts[i] ===
|
|
16796
|
+
// lastParts[i]` equality never fired on an unedited tab, so ONE external
|
|
16797
|
+
// write re-swapped EVERY diagram block on the page (G4).
|
|
16798
|
+
//
|
|
16799
|
+
// Both sides now ask the same question, and it is the structural one: does
|
|
16800
|
+
// this markup contain a real element with class `drawio`? The old side reads
|
|
16801
|
+
// the live node; the new side parses the part and reads that. Text that
|
|
16802
|
+
// merely quotes the markup parses to a text node inside <code> and is not
|
|
16803
|
+
// selected by anything. DRAWIO_TEXT_HINT survives only as a cheap SUPERSET
|
|
16804
|
+
// prefilter so the parse is skipped for the overwhelming majority of blocks
|
|
16805
|
+
// — it never decides anything on its own.
|
|
16806
|
+
//
|
|
16807
|
+
// And "did it actually change" is answered by a fingerprint of the baked
|
|
16808
|
+
// diagram taken through the SAME parser on both sides (so the seed's
|
|
16809
|
+
// non-byte-identity cannot leak in), deliberately ignoring `hidden` — that
|
|
16810
|
+
// is the reader's page selection, not part of the bake.
|
|
16811
|
+
const DRAWIO_TEXT_HINT = /class="[^"]*\bdrawio\b/;
|
|
16812
|
+
const DRAWIO_FP_SEP = '';
|
|
16813
|
+
|
|
16814
|
+
// Fix round 3 (re-review2 H2): ALL of them, not the first.
|
|
16815
|
+
// ` ` on one markdown line is ONE block holding
|
|
16816
|
+
// TWO `.drawio` boxes — verified by render, and a table row of diagrams does
|
|
16817
|
+
// the same. Round 2 compared only `querySelector('.drawio')`, so a change to
|
|
16818
|
+
// the SECOND file left the comparison equal, the loop `continue`d, `applied`
|
|
16819
|
+
// stayed true, the tab ACKED, and the server advanced its baseline past an
|
|
16820
|
+
// update the DOM never took: silent, permanent, and exactly the invariant
|
|
16821
|
+
// F2 exists to hold. Everything below therefore works on the whole list.
|
|
16822
|
+
function drawioBoxesIn(node) {
|
|
16823
|
+
return (node && node.querySelectorAll)
|
|
16824
|
+
? Array.prototype.slice.call(node.querySelectorAll('.drawio'))
|
|
16825
|
+
: [];
|
|
16826
|
+
}
|
|
16827
|
+
function drawioBoxIn(node) {
|
|
16828
|
+
return (node && node.querySelector) ? node.querySelector('.drawio') : null;
|
|
16829
|
+
}
|
|
16830
|
+
function parsePartMarkup(part) {
|
|
16831
|
+
const t = document.createElement('template');
|
|
16832
|
+
t.innerHTML = part;
|
|
16833
|
+
return t;
|
|
16834
|
+
}
|
|
16835
|
+
// Fix round 3 (re-review2 H3): the reader's annotation feature mutates the
|
|
16836
|
+
// live `.drawio-page` IN PLACE — `annoSyncInline()` (lib/md2doc.js) wraps the
|
|
16837
|
+
// visible <svg> in `<span class="anno-inline-wrap">` and appends an
|
|
16838
|
+
// `<svg class="anno-inline">` overlay beside it, and the wrap is never
|
|
16839
|
+
// removed again. The fresh parse of a part never carries either, so an
|
|
16840
|
+
// annotated diagram could NEVER fingerprint equal, and G4's whole point —
|
|
16841
|
+
// "a write to a.drawio must not touch the block holding b.drawio" — failed
|
|
16842
|
+
// for exactly the blocks carrying the reader's own work.
|
|
16843
|
+
//
|
|
16844
|
+
// Normalising undoes the wrap on a CLONE so the comparison sees the bake
|
|
16845
|
+
// again. MEASURED: normalising an annotated page yields a string byte-equal
|
|
16846
|
+
// to an unannotated page baked from the same file (1170 chars vs 2171 raw).
|
|
16847
|
+
// The clone only happens when the cheap `querySelector` says there is
|
|
16848
|
+
// something to undo, so an unannotated document pays one selector per page.
|
|
16849
|
+
function drawioPageMarkup(pageEl) {
|
|
16850
|
+
if (!pageEl.querySelector('.anno-inline-wrap')) return pageEl.innerHTML;
|
|
16851
|
+
const c = pageEl.cloneNode(true);
|
|
16852
|
+
Array.prototype.forEach.call(c.querySelectorAll('svg.anno-inline'),
|
|
16853
|
+
function (o) { if (o.parentNode) o.parentNode.removeChild(o); });
|
|
16854
|
+
Array.prototype.forEach.call(c.querySelectorAll('.anno-inline-wrap'),
|
|
16855
|
+
function (w) {
|
|
16856
|
+
const parent = w.parentNode;
|
|
16857
|
+
if (!parent) return;
|
|
16858
|
+
while (w.firstChild) parent.insertBefore(w.firstChild, w);
|
|
16859
|
+
parent.removeChild(w);
|
|
16860
|
+
});
|
|
16861
|
+
return c.innerHTML;
|
|
16862
|
+
}
|
|
16863
|
+
// Identity of what was BAKED into one `.drawio` box. `hidden` is excluded on
|
|
16864
|
+
// purpose (page selection is view state this function must not react to),
|
|
16865
|
+
// and so is the page-navigation node the init hook appends — it is a child
|
|
16866
|
+
// of the box, never of a `.drawio-page`. A failed bake has no pages, so its
|
|
16867
|
+
// rendered error text is its identity instead.
|
|
16868
|
+
function drawioFingerprint(box) {
|
|
16869
|
+
if (!box) return null;
|
|
16870
|
+
const pages = box.querySelectorAll('.drawio-page');
|
|
16871
|
+
if (!pages.length) return 'raw' + DRAWIO_FP_SEP + box.innerHTML;
|
|
16872
|
+
const out = ['pages' + DRAWIO_FP_SEP + (box.getAttribute('data-drawio-pages') || '')];
|
|
16873
|
+
for (let i = 0; i < pages.length; i++) out.push(drawioPageMarkup(pages[i]));
|
|
16874
|
+
return out.join(DRAWIO_FP_SEP);
|
|
16875
|
+
}
|
|
16876
|
+
// Identity of every diagram in one block. The count leads, so a block that
|
|
16877
|
+
// gained or lost a box can never collide with one that did not.
|
|
16878
|
+
function drawioBlockFingerprint(boxes) {
|
|
16879
|
+
const out = [String(boxes.length)];
|
|
16880
|
+
for (let i = 0; i < boxes.length; i++) out.push(drawioFingerprint(boxes[i]));
|
|
16881
|
+
return out.join(DRAWIO_FP_SEP + DRAWIO_FP_SEP);
|
|
16882
|
+
}
|
|
16883
|
+
|
|
16884
|
+
let drawioRefreshInFlight = false;
|
|
16885
|
+
// Fix round 1 (review F2): the token from the last refresh that ACTUALLY
|
|
16886
|
+
// landed in the DOM, to be handed back on the next heartbeat. The server
|
|
16887
|
+
// advances that tab's staleness baseline on this and on nothing else, so
|
|
16888
|
+
// every bail path below — by simply not setting it — is genuinely retried
|
|
16889
|
+
// on the next tick instead of losing the change for the rest of the
|
|
16890
|
+
// session.
|
|
16891
|
+
let drawioAckPending = null;
|
|
16892
|
+
|
|
16893
|
+
// ── Fix round 2 (re-review G1b): a persistent bail must not cost a bake ──
|
|
16894
|
+
//
|
|
16895
|
+
// The three pre-fetch bails below make the long-lived conditions free, and
|
|
16896
|
+
// G1a removes the one post-fetch bail whose condition the pre-fetch side
|
|
16897
|
+
// could not see. What this backs up is everything that cannot be enumerated
|
|
16898
|
+
// in advance: /api/render answering 500 deterministically, a server whose
|
|
16899
|
+
// ref list flaps between two tabs, a future bail nobody thought about here.
|
|
16900
|
+
// Any of those would otherwise re-issue a full render + Chromium bake on
|
|
16901
|
+
// every 10s beat for the life of the session.
|
|
16902
|
+
//
|
|
16903
|
+
// Only a bail that already PAID for a fetch counts. A pre-fetch bail is free
|
|
16904
|
+
// and must stay instant, or a reader who simply rested their caret in a
|
|
16905
|
+
// diagram block would be punished with minutes of latency once they moved.
|
|
16906
|
+
//
|
|
16907
|
+
// The update is delayed, never dropped: the server keeps re-reporting the
|
|
16908
|
+
// same staleness (F2's invariant is untouched), and the next attempt after
|
|
16909
|
+
// the blocking condition clears applies it. DOWNSIDE, stated plainly: after
|
|
16910
|
+
// a run of paid bails, a refresh that WOULD now succeed can wait up to
|
|
16911
|
+
// DRAWIO_BACKOFF_CAP_MS before it is tried. That is the price of bounding
|
|
16912
|
+
// the bake rate to once per that interval instead of once per beat.
|
|
16913
|
+
const DRAWIO_BACKOFF_BASE_MS = 10000;
|
|
16914
|
+
const DRAWIO_BACKOFF_CAP_MS = 160000;
|
|
16915
|
+
const DRAWIO_FETCH_TIMEOUT_MS = 120000;
|
|
16916
|
+
let drawioBailStreak = 0;
|
|
16917
|
+
let drawioNextAttemptAt = 0;
|
|
16918
|
+
|
|
16919
|
+
// Fix round 2 (re-review G6): the deferred notice now carries the moment it
|
|
16920
|
+
// was raised. A banner that surfaces minutes late describes a landing the
|
|
16921
|
+
// reader has long since navigated away from — worse than no banner, and the
|
|
16922
|
+
// same "teaches the user to ignore it" failure F6 was about.
|
|
16923
|
+
const DRAWIO_NOTICE_TTL_MS = 60000;
|
|
16924
|
+
// Fix round 1 (review F5): a notice this background path wants to show but
|
|
16925
|
+
// must not show YET. showBanner() is destructive by construction — its
|
|
16926
|
+
// first statement removes whatever banner is up — and every other caller in
|
|
16927
|
+
// this file is a response to a user gesture. This one fires from a timer,
|
|
16928
|
+
// and the banner it would remove can be the disk-conflict one, which is the
|
|
16929
|
+
// user's only route to resolving a conflict on the one data-safety-critical
|
|
16930
|
+
// path this editor has (it carries the Reload button). So the notice waits
|
|
16931
|
+
// for an empty slot instead of taking one.
|
|
16932
|
+
let drawioDeferredNotice = null;
|
|
16933
|
+
// showBanner() also tears down these four overlays unconditionally. That is
|
|
16934
|
+
// right for a gesture-driven caller and wrong for a timer: firing it while
|
|
16935
|
+
// the reader has a grip, an insert bubble, the table edge menu or the H▾
|
|
16936
|
+
// dropdown open closes it under them at a moment nothing on screen
|
|
16937
|
+
// explains. So the flush waits for those too (re-review G6).
|
|
16938
|
+
function noticeTeardownWouldDisrupt() {
|
|
16939
|
+
if (toolbarMenuEl) return true;
|
|
16940
|
+
if (teEdgeMenu && !teEdgeMenu.hidden) return true;
|
|
16941
|
+
if (rowGrip && !rowGrip.hidden) return true;
|
|
16942
|
+
if (colGrip && !colGrip.hidden) return true;
|
|
16943
|
+
if (rowInsertBubble && !rowInsertBubble.hidden) return true;
|
|
16944
|
+
if (colInsertBubble && !colInsertBubble.hidden) return true;
|
|
16945
|
+
return false;
|
|
16946
|
+
}
|
|
16947
|
+
function noteDrawioNotice(message) {
|
|
16948
|
+
if (activeBanner || noticeTeardownWouldDisrupt()) {
|
|
16949
|
+
drawioDeferredNotice = { message: message, at: Date.now() };
|
|
16950
|
+
return;
|
|
16951
|
+
}
|
|
16952
|
+
drawioDeferredNotice = null;
|
|
16953
|
+
showBanner(message, null, null);
|
|
16954
|
+
}
|
|
16955
|
+
function flushDrawioNotice() {
|
|
16956
|
+
if (!drawioDeferredNotice) return;
|
|
16957
|
+
if (Date.now() - drawioDeferredNotice.at > DRAWIO_NOTICE_TTL_MS) {
|
|
16958
|
+
drawioDeferredNotice = null; // stale: it no longer describes the page
|
|
16959
|
+
return;
|
|
16960
|
+
}
|
|
16961
|
+
if (activeBanner || noticeTeardownWouldDisrupt()) return;
|
|
16962
|
+
const m = drawioDeferredNotice.message;
|
|
16963
|
+
drawioDeferredNotice = null;
|
|
16964
|
+
showBanner(m, null, null);
|
|
16965
|
+
}
|
|
16966
|
+
const DRAWIO_PAGE_GONE_MSG =
|
|
16967
|
+
'目前檢視的 drawio 分頁已不存在(可能已刪除或改名),已切回第一頁。';
|
|
16968
|
+
// Fix round 3 (re-review2 H3 / Q1 ruling). Annotations are reading-session
|
|
16969
|
+
// scratch — lib/md2doc.js keeps them in a WeakMap keyed by the source node
|
|
16970
|
+
// and says so ("In-memory only — gone on reload"; nothing writes them to
|
|
16971
|
+
// localStorage or to disk) — but they are still the reader's own work, and
|
|
16972
|
+
// a re-bake changes the geometry they were drawn over, so carrying them
|
|
16973
|
+
// across would place marks on shapes that moved. They are dropped, and the
|
|
16974
|
+
// reader is told which diagram lost them and why.
|
|
16975
|
+
const DRAWIO_ANNO_DROPPED_MSG =
|
|
16976
|
+
'這張 drawio 圖在磁碟上被改過,已重新繪製;畫在舊版上的註記無法對應到新的圖形,已移除。';
|
|
16977
|
+
|
|
16978
|
+
// What "never yank focus out from under the user" actually has to mean here.
|
|
16979
|
+
//
|
|
16980
|
+
// An EDITING SURFACE — a contenteditable, a raw-source textarea, an input —
|
|
16981
|
+
// is something the user is working IN; replacing the node under it destroys
|
|
16982
|
+
// work in progress and is never acceptable for a refresh nobody asked for.
|
|
16983
|
+
// Anything else that merely happens to hold focus is not: MEASURED — a
|
|
16984
|
+
// click on the multi-page navigation leaves that <button> as
|
|
16985
|
+
// document.activeElement, and it sits inside the very block a re-bake
|
|
16986
|
+
// replaces. Treating that as "the user is busy here" means the reader who
|
|
16987
|
+
// just picked a page, alt-tabbed to Draw.io and edited the file comes back
|
|
16988
|
+
// to a diagram that (with fix round 1's retry in place) refuses to update
|
|
16989
|
+
// for the whole session instead of losing the update once. So the guard is
|
|
16990
|
+
// scoped to surfaces, and a focused non-surface is allowed to be replaced.
|
|
16991
|
+
function focusIsEditingSurface(node) {
|
|
16992
|
+
if (!node) return false;
|
|
16993
|
+
if (node.isContentEditable) return true;
|
|
16994
|
+
const tag = node.tagName;
|
|
16995
|
+
return tag === 'TEXTAREA' || tag === 'INPUT' || tag === 'SELECT';
|
|
16996
|
+
}
|
|
16997
|
+
|
|
16998
|
+
// Is the user editing inside a block that carries a diagram we may be about
|
|
16999
|
+
// to replace? Checked BEFORE the fetch as well as per block after it: with
|
|
17000
|
+
// F2's retry in place an unconditional fetch would otherwise re-run a full
|
|
17001
|
+
// headless-Chromium bake every 10 seconds for as long as that editor stays
|
|
17002
|
+
// open. G1a is what makes this pre-check and the per-block skip agree: both
|
|
17003
|
+
// now ask drawioBoxIn() about the same live node.
|
|
17004
|
+
function drawioBlockHoldsEditingFocus() {
|
|
17005
|
+
const ae = document.activeElement;
|
|
17006
|
+
if (!ae || !contentEl.contains(ae)) return false;
|
|
17007
|
+
if (!focusIsEditingSurface(ae)) return false;
|
|
17008
|
+
const kids = contentEl.children;
|
|
17009
|
+
for (let i = 0; i < kids.length; i++) {
|
|
17010
|
+
if (!kids[i].contains(ae)) continue;
|
|
17011
|
+
return !!drawioBoxIn(kids[i]);
|
|
17012
|
+
}
|
|
17013
|
+
return false;
|
|
17014
|
+
}
|
|
17015
|
+
|
|
17016
|
+
async function refreshStaleDrawio(token) {
|
|
17017
|
+
if (drawioRefreshInFlight) return;
|
|
17018
|
+
// Review question Q2 / G1b: the cheap "there is nothing on screen this
|
|
17019
|
+
// could possibly change" and "the user is in the middle of something"
|
|
17020
|
+
// answers are given BEFORE the fetch, so a condition that can hold
|
|
17021
|
+
// indefinitely never costs a bake. None of them acks — nothing was
|
|
17022
|
+
// applied, so the next tick must ask again.
|
|
17023
|
+
if (!contentEl.querySelector('.drawio')) return;
|
|
17024
|
+
if (!domMatchesLastRender()) return;
|
|
17025
|
+
if (drawioBlockHoldsEditingFocus()) return;
|
|
17026
|
+
// Fix round 2 (re-review G7): a block drag in flight addresses every block
|
|
17027
|
+
// by ordinal across .content. applyPatch()/applyFullRender() cancel it
|
|
17028
|
+
// because they are gesture-driven and the gesture is already over; a
|
|
17029
|
+
// background timer has no such licence, so it waits the drag out instead.
|
|
17030
|
+
// Free and self-clearing — a drag is a pointer gesture, it cannot last.
|
|
17031
|
+
if (blockDragState) return;
|
|
17032
|
+
// Fix round 3 (re-review2 H1): a `.drawio` inside a table cell makes the
|
|
17033
|
+
// whole TABLE block replaceable by this path (verified by render — round
|
|
17034
|
+
// 2's report claimed the opposite). A live row/column pointer gesture
|
|
17035
|
+
// addresses rows and cells inside that table, so the refresh waits it out
|
|
17036
|
+
// rather than cancelling it from a timer — the same construction, and the
|
|
17037
|
+
// same reasoning, as `blockDragState` just above.
|
|
17038
|
+
if (tePointer) return;
|
|
17039
|
+
if (Date.now() < drawioNextAttemptAt) return;
|
|
17040
|
+
drawioRefreshInFlight = true;
|
|
17041
|
+
// Fix round 2 (re-review G8): without a deadline, one request that never
|
|
17042
|
+
// settles (a server wedged inside a bake — bakeDrawioSvg() has no
|
|
17043
|
+
// per-page timeout of its own) leaves this flag true forever, every later
|
|
17044
|
+
// {stale:true} returns at the line above, and the whole feature is
|
|
17045
|
+
// silently and permanently dead until the user reloads, with no signal at
|
|
17046
|
+
// all. An abort turns that into an ordinary paid bail: backed off, retried.
|
|
17047
|
+
const ctl = (typeof AbortController === 'function') ? new AbortController() : null;
|
|
17048
|
+
const abortTimer = ctl
|
|
17049
|
+
? setTimeout(function () { try { ctl.abort(); } catch (e) { /* already settled */ } },
|
|
17050
|
+
DRAWIO_FETCH_TIMEOUT_MS)
|
|
17051
|
+
: null;
|
|
17052
|
+
let paidForAFetch = false;
|
|
17053
|
+
let acked = false;
|
|
17054
|
+
try {
|
|
17055
|
+
const startContent = lines.join('\n');
|
|
17056
|
+
let res;
|
|
17057
|
+
paidForAFetch = true;
|
|
17058
|
+
try {
|
|
17059
|
+
const init = {
|
|
17060
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
17061
|
+
body: JSON.stringify({ fileId: ED.fileId, content: startContent }),
|
|
17062
|
+
};
|
|
17063
|
+
if (ctl) init.signal = ctl.signal;
|
|
17064
|
+
res = await fetch('/api/render', init);
|
|
17065
|
+
} catch (e) { return; }
|
|
17066
|
+
if (!res.ok) return;
|
|
17067
|
+
let j;
|
|
17068
|
+
try { j = await res.json(); } catch (e) { return; }
|
|
17069
|
+
if (!Array.isArray(j.parts) || !Array.isArray(j.blocks)) return;
|
|
17070
|
+
// A real edit may have landed (and its own rerenderAll() already read
|
|
17071
|
+
// the file fresh) while this request was in flight — `lines` no
|
|
17072
|
+
// longer matches what this response was computed from, so there is
|
|
17073
|
+
// nothing left to apply; the document already shows current content.
|
|
17074
|
+
if (lines.join('\n') !== startContent) return;
|
|
17075
|
+
if (!Array.isArray(lastParts) || lastParts.length !== j.parts.length ||
|
|
17076
|
+
!Array.isArray(blocks) || blocks.length !== j.blocks.length) return;
|
|
17077
|
+
// Same precondition applyPatch()'s own plan requires (see
|
|
17078
|
+
// domMatchesLastRender()'s comment): several list gestures mutate
|
|
17079
|
+
// .content's child list directly, between renders, without going
|
|
17080
|
+
// through a commit yet. If the DOM does not positionally match
|
|
17081
|
+
// `lastParts` right now, a mid-gesture edit is in flight — bail rather
|
|
17082
|
+
// than swap the wrong child. Re-read after the await, because the
|
|
17083
|
+
// pre-fetch check above cannot speak for the time the render took; same
|
|
17084
|
+
// for the block drag.
|
|
17085
|
+
if (!domMatchesLastRender()) return;
|
|
17086
|
+
if (blockDragState) return;
|
|
17087
|
+
if (tePointer) return;
|
|
17088
|
+
// Every block this refresh was responsible for has to land before the
|
|
17089
|
+
// server is told the tab is up to date. One skipped block means the
|
|
17090
|
+
// user is still looking at stale bytes, which is exactly the state F2
|
|
17091
|
+
// says must keep being retried.
|
|
17092
|
+
let applied = true;
|
|
17093
|
+
for (let i = 0; i < j.parts.length; i++) {
|
|
17094
|
+
const el = contentEl.children[i];
|
|
17095
|
+
const oldBoxes = drawioBoxesIn(el);
|
|
17096
|
+
// The prefilter is a superset test and decides nothing: it only says
|
|
17097
|
+
// "parsing this part might be worth it".
|
|
17098
|
+
if (!oldBoxes.length && !DRAWIO_TEXT_HINT.test(j.parts[i])) continue;
|
|
17099
|
+
const tpl = parsePartMarkup(j.parts[i]);
|
|
17100
|
+
const newBoxes = drawioBoxesIn(tpl.content);
|
|
17101
|
+
// Neither side holds a real diagram — the hint matched text that only
|
|
17102
|
+
// quotes this markup (a fenced code block, a table cell). Not a
|
|
17103
|
+
// candidate for anything, by either criterion.
|
|
17104
|
+
if (!oldBoxes.length && !newBoxes.length) continue;
|
|
17105
|
+
// Every diagram in the block is compared, and the bake is identical:
|
|
17106
|
+
// nothing to do. This is what keeps one external write from re-swapping
|
|
17107
|
+
// every OTHER diagram on the page (G4) — while H2 is why it has to be
|
|
17108
|
+
// every box and not just the first.
|
|
17109
|
+
if (drawioBlockFingerprint(oldBoxes) === drawioBlockFingerprint(newBoxes)) continue;
|
|
17110
|
+
|
|
17111
|
+
if (!el) { applied = false; continue; }
|
|
17112
|
+
// Never destroy an editor the user has open in this block — see
|
|
17113
|
+
// focusIsEditingSurface() for why it is surfaces and not "anything
|
|
17114
|
+
// focused". Not applied, so not acked: the next tick tries again, and
|
|
17115
|
+
// because the pre-fetch guard asks drawioBoxIn() about the same live
|
|
17116
|
+
// node, that next tick bails BEFORE paying for a render.
|
|
17117
|
+
if (el.contains(document.activeElement) && focusIsEditingSurface(document.activeElement)) {
|
|
17118
|
+
applied = false; continue;
|
|
17119
|
+
}
|
|
17120
|
+
// Fix round 2 (re-review G7): `gutterMenu` and `insertMenu` are ONE
|
|
17121
|
+
// shared node each, appended as a CHILD of whichever block they are
|
|
17122
|
+
// open for. Replacing that block detaches the singleton with no close
|
|
17123
|
+
// path run, leaving `gutterMenuBlockEl`/`insertMenuBlockEl` pointing
|
|
17124
|
+
// at a node no future click can reach — the exact hazard
|
|
17125
|
+
// applyPatch()/applyFullRender() call these two for. Scoped to the
|
|
17126
|
+
// block actually being replaced rather than called unconditionally
|
|
17127
|
+
// the way those two do, and the reason is the one F5 turns on: they
|
|
17128
|
+
// run on a user gesture that is already over, this runs on a 10s
|
|
17129
|
+
// timer, and closing a menu the reader has open on an UNRELATED block
|
|
17130
|
+
// would be a fresh instance of exactly the defect F5 closed.
|
|
17131
|
+
if (gutterMenuBlockEl === el) closeGutterMenu();
|
|
17132
|
+
if (insertMenuBlockEl === el) closeInsertMenu();
|
|
17133
|
+
// Fix round 3 (re-review2 H1). Round 2's report said 「一個 drawio 區塊
|
|
17134
|
+
// 永遠不是表格」 and refused the table resets on that basis. The claim is
|
|
17135
|
+
// FALSE — rendering `|  | x |` yields a
|
|
17136
|
+
// `data-block-type="table"` block whose first cell holds the box, so
|
|
17137
|
+
// this path really can replace a whole table. The grips and the edge
|
|
17138
|
+
// menu live on document.body, so the swap does not detach them: they
|
|
17139
|
+
// stay visibly floating while `teMenuTableEl` / `teMenuRowEl` /
|
|
17140
|
+
// `gripRowTableEl` / `gripRowEl` / `gripColTableEl` point into a tree
|
|
17141
|
+
// nothing can reach. Scoped exactly like the two menus above, and for
|
|
17142
|
+
// the same reason. (A live row/column POINTER gesture is not handled
|
|
17143
|
+
// here at all — `if (tePointer) return;` above waits it out rather
|
|
17144
|
+
// than killing it from a timer, the same construction `blockDragState`
|
|
17145
|
+
// uses. The insert bubbles hold no element reference of their own —
|
|
17146
|
+
// hideTableInsertBubbles() only flips two `hidden` flags — so nothing
|
|
17147
|
+
// of theirs can dangle; the next mousemove re-derives them.)
|
|
17148
|
+
if ((teMenuTableEl && el.contains(teMenuTableEl)) ||
|
|
17149
|
+
(teMenuRowEl && el.contains(teMenuRowEl))) hideTableEdgeMenu();
|
|
17150
|
+
if ((gripRowTableEl && el.contains(gripRowTableEl)) ||
|
|
17151
|
+
(gripRowEl && el.contains(gripRowEl)) ||
|
|
17152
|
+
(gripColTableEl && el.contains(gripColTableEl))) hideTableGrips();
|
|
17153
|
+
// A non-surface node inside this block (the roving-tabindex holder of
|
|
17154
|
+
// a block selection, a page button just clicked) is about to stop
|
|
17155
|
+
// existing. Put focus back on the block wrapper afterwards when the
|
|
17156
|
+
// wrapper is a legitimate focus holder, so a keyboard user keeps
|
|
17157
|
+
// their place instead of being dropped at <body>.
|
|
17158
|
+
const refocusWrapper = el.contains(document.activeElement) && el.hasAttribute('tabindex');
|
|
17159
|
+
|
|
17160
|
+
// Ruling B2-2 (c): preserve the reader's currently-open page across
|
|
17161
|
+
// the swap, falling back to the first page with a visible banner when
|
|
17162
|
+
// it genuinely no longer exists. resolveDrawioLanding() (near the top
|
|
17163
|
+
// of this file, unit-tested) owns the "is a page name an identity
|
|
17164
|
+
// here at all" question — see its comment.
|
|
17165
|
+
//
|
|
17166
|
+
// Fix round 3 (re-review2 H2, second half): captured for EVERY box in
|
|
17167
|
+
// the block, not just the first, so a second multi-page diagram in one
|
|
17168
|
+
// block keeps the reader's page too.
|
|
17169
|
+
const oldLandings = oldBoxes.map(function (box) {
|
|
17170
|
+
if (!box.hasAttribute('data-drawio-pages')) return null;
|
|
17171
|
+
let names = [];
|
|
17172
|
+
try { names = JSON.parse(atob(box.getAttribute('data-drawio-pages') || '')); }
|
|
17173
|
+
catch (e2) { names = []; }
|
|
17174
|
+
if (!Array.isArray(names) || names.length < 2) return null;
|
|
17175
|
+
const pages = box.querySelectorAll('.drawio-page');
|
|
17176
|
+
const activePage = box.querySelector('.drawio-page:not([hidden])');
|
|
17177
|
+
return {
|
|
17178
|
+
names: names,
|
|
17179
|
+
activeIdx: activePage ? Array.prototype.indexOf.call(pages, activePage) : -1,
|
|
17180
|
+
};
|
|
17181
|
+
});
|
|
17182
|
+
|
|
17183
|
+
// Fix round 3 (re-review2 H3, Q1 ruling). The reader's annotations are
|
|
17184
|
+
// drawn INTO this block's `.drawio-page` and cannot be carried onto a
|
|
17185
|
+
// re-baked diagram whose geometry is different — but dropping the
|
|
17186
|
+
// user's own work silently is not something this codebase does. The
|
|
17187
|
+
// unrelated-write case never reaches here at all (drawioPageMarkup()
|
|
17188
|
+
// normalises the annotation subtree out of the fingerprint, so such a
|
|
17189
|
+
// block compares equal and is not swapped); what is left is the case
|
|
17190
|
+
// where the annotated diagram's OWN bytes changed, and that one is
|
|
17191
|
+
// told. Same principle as B2-P1: the two outcomes must not both be
|
|
17192
|
+
// silent.
|
|
17193
|
+
const hadAnnotations = !!(el.querySelector && el.querySelector('.anno-inline-wrap'));
|
|
17194
|
+
|
|
17195
|
+
const fresh = tpl.content.firstElementChild;
|
|
17196
|
+
if (!fresh) { applied = false; continue; }
|
|
17197
|
+
armEditables(tpl.content);
|
|
17198
|
+
// Best-effort carry-over of the wrapper's own visual state. Never
|
|
17199
|
+
// reaches into blockSelection / applySelectionClasses() here — that
|
|
17200
|
+
// call also moves focus, which a background refresh must never do.
|
|
17201
|
+
if (el.classList.contains('ed-selected')) fresh.classList.add('ed-selected');
|
|
17202
|
+
if (el.hasAttribute('tabindex')) fresh.setAttribute('tabindex', el.getAttribute('tabindex'));
|
|
17203
|
+
|
|
17204
|
+
el.replaceWith(fresh);
|
|
17205
|
+
lastParts[i] = j.parts[i];
|
|
17206
|
+
// preventScroll: this is a background refresh — .focus()'s default
|
|
17207
|
+
// scroll-into-view would yank the reader's viewport to a block they
|
|
17208
|
+
// merely left focused before scrolling away.
|
|
17209
|
+
if (refocusWrapper && fresh.focus) fresh.focus({ preventScroll: true });
|
|
17210
|
+
if (hadAnnotations) noteDrawioNotice(DRAWIO_ANNO_DROPPED_MSG);
|
|
17211
|
+
|
|
17212
|
+
// Fix round 1 (review F5 second half): the page landing and its
|
|
17213
|
+
// notice happen whether or not the page-navigation hook exists.
|
|
17214
|
+
// They used to sit INSIDE the hook check, so a document that was
|
|
17215
|
+
// single-page when the tab opened (the hook is gated on that, by a
|
|
17216
|
+
// byte-exact guard test) lost the page preservation AND the
|
|
17217
|
+
// "your page is gone" notice together — both branches of Ruling
|
|
17218
|
+
// B2-P1 going silent at once. Only the navigation rebuild itself
|
|
17219
|
+
// genuinely depends on the hook.
|
|
17220
|
+
if (window.__md2docInitDrawioSheetBar) {
|
|
17221
|
+
try { window.__md2docInitDrawioSheetBar(fresh); } catch (e4) { /* navigation only */ }
|
|
17222
|
+
}
|
|
17223
|
+
const freshBoxes = drawioBoxesIn(fresh);
|
|
17224
|
+
// Align old boxes to new ones BY POSITION, and only when the block
|
|
17225
|
+
// still holds the same number of diagrams. A block whose box count
|
|
17226
|
+
// changed has had one reference stop (or start) being bakeable, and
|
|
17227
|
+
// position no longer identifies anything — landing every box on the
|
|
17228
|
+
// baked default is then the only answer we can stand behind. Claiming
|
|
17229
|
+
// a page deletion there would be the same false-banner mistake F6 was
|
|
17230
|
+
// about, so that case is deliberately quiet.
|
|
17231
|
+
if (freshBoxes.length !== oldBoxes.length) continue;
|
|
17232
|
+
for (let b = 0; b < freshBoxes.length; b++) {
|
|
17233
|
+
const prev = oldLandings[b];
|
|
17234
|
+
if (!prev) continue; // was not a multi-page box
|
|
17235
|
+
const box = freshBoxes[b];
|
|
17236
|
+
if (!box.hasAttribute('data-drawio-pages')) continue;
|
|
17237
|
+
let newNames = [];
|
|
17238
|
+
try { newNames = JSON.parse(atob(box.getAttribute('data-drawio-pages') || '')); }
|
|
17239
|
+
catch (e3) { newNames = []; }
|
|
17240
|
+
if (!Array.isArray(newNames)) newNames = [];
|
|
17241
|
+
const newPages = box.querySelectorAll('.drawio-page');
|
|
17242
|
+
const landing = resolveDrawioLanding(prev.names, prev.activeIdx, newNames);
|
|
17243
|
+
// Review Q1: clamp. `data-drawio-pages` and the .drawio-page children
|
|
17244
|
+
// are emitted together by lib/md2doc.js so they cannot disagree
|
|
17245
|
+
// today, but an out-of-range index here hides EVERY page and paints a
|
|
17246
|
+
// blank box — one line is cheaper than that failure.
|
|
17247
|
+
let landOn = landing.landOn;
|
|
17248
|
+
if (!(landOn >= 0) || landOn >= newPages.length) landOn = 0;
|
|
17249
|
+
for (let p = 0; p < newPages.length; p++) newPages[p].hidden = p !== landOn;
|
|
17250
|
+
const bar = box.querySelector('.drawio-sheetbar');
|
|
17251
|
+
if (bar) {
|
|
17252
|
+
bar.querySelectorAll('button').forEach(function (btn, bidx) {
|
|
17253
|
+
btn.setAttribute('aria-pressed', bidx === landOn ? 'true' : 'false');
|
|
17254
|
+
});
|
|
17255
|
+
}
|
|
17256
|
+
if (landing.gone) noteDrawioNotice(DRAWIO_PAGE_GONE_MSG);
|
|
17257
|
+
}
|
|
17258
|
+
}
|
|
17259
|
+
if (applied && typeof token === 'string' && token !== '') {
|
|
17260
|
+
drawioAckPending = token;
|
|
17261
|
+
acked = true;
|
|
17262
|
+
}
|
|
17263
|
+
} finally {
|
|
17264
|
+
if (abortTimer) clearTimeout(abortTimer);
|
|
17265
|
+
drawioRefreshInFlight = false;
|
|
17266
|
+
if (acked) {
|
|
17267
|
+
drawioBailStreak = 0;
|
|
17268
|
+
drawioNextAttemptAt = 0;
|
|
17269
|
+
} else if (paidForAFetch) {
|
|
17270
|
+
drawioBailStreak += 1;
|
|
17271
|
+
const wait = Math.min(DRAWIO_BACKOFF_BASE_MS * Math.pow(2, drawioBailStreak),
|
|
17272
|
+
DRAWIO_BACKOFF_CAP_MS);
|
|
17273
|
+
drawioNextAttemptAt = Date.now() + wait;
|
|
17274
|
+
}
|
|
17275
|
+
}
|
|
17276
|
+
}
|
|
17277
|
+
|
|
14940
17278
|
setInterval(() => {
|
|
17279
|
+
// A notice this refresh could not show earlier because the slot was
|
|
17280
|
+
// taken (see noteDrawioNotice) gets its next chance here, on the same
|
|
17281
|
+
// clock — or is dropped if it has gone stale.
|
|
17282
|
+
flushDrawioNotice();
|
|
14941
17283
|
// /api/ping requires content-type: application/json like the other
|
|
14942
17284
|
// state-changing POST routes (415 otherwise) — see server.js's CORS
|
|
14943
17285
|
// defense. A body is included so the header is meaningful, not just
|
|
14944
17286
|
// present on an otherwise-empty request.
|
|
17287
|
+
//
|
|
17288
|
+
// v3.4.0 batch2 Task 6 (Ruling B2-2): `fileId` + this tab's own
|
|
17289
|
+
// `drawioClientId` let server.js answer whether any `.drawio`/`.xml`
|
|
17290
|
+
// this document references changed on disk since the bytes THIS TAB is
|
|
17291
|
+
// showing — see its own comment on /api/ping. A 204 (no body) means
|
|
17292
|
+
// "nothing stale", the same outcome this heartbeat always had; a 200
|
|
17293
|
+
// with `{stale:true, token}` is new and triggers a background refresh.
|
|
17294
|
+
// `drawioAck` carries back the token of the last refresh that actually
|
|
17295
|
+
// landed, and is the ONLY thing that advances this tab's baseline.
|
|
17296
|
+
// Every failure mode (network error, non-200/204 status, malformed
|
|
17297
|
+
// JSON) is swallowed exactly like the old bare ping already swallowed
|
|
17298
|
+
// everything — a missed signal just waits for the next 10s tick, which
|
|
17299
|
+
// is the accepted worst-case latency for this whole feature.
|
|
17300
|
+
const ack = drawioAckPending;
|
|
17301
|
+
drawioAckPending = null;
|
|
14945
17302
|
fetch('/api/ping', {
|
|
14946
|
-
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
17303
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
17304
|
+
body: JSON.stringify({ fileId: ED.fileId, drawioClientId: ED.drawioClientId, drawioAck: ack }),
|
|
17305
|
+
}).then((res) => {
|
|
17306
|
+
if (res.status !== 200) return null;
|
|
17307
|
+
return res.json().catch(() => null);
|
|
17308
|
+
}).then((j) => {
|
|
17309
|
+
if (j && j.stale) refreshStaleDrawio(j.token).catch(() => {});
|
|
14947
17310
|
}).catch(() => {});
|
|
14948
17311
|
}, 10000);
|
|
14949
17312
|
|
|
14950
17313
|
armEditables(contentEl);
|
|
14951
17314
|
wireBlockSelection();
|
|
17315
|
+
|
|
17316
|
+
// ── v3.4.0 batch3 Task 6: the wavedrom editing surface ─────────────────
|
|
17317
|
+
//
|
|
17318
|
+
// Entry is a hover affordance over an already-rendered diagram, and the
|
|
17319
|
+
// affordance itself lives on document.body — not in .content. Everything in
|
|
17320
|
+
// .content is read back as this tab's own render (see `lastParts` above) and
|
|
17321
|
+
// the WYSIWYG path turns it into markdown, so a control parked in there would
|
|
17322
|
+
// travel into the user's file. The table grips already sit on body for the
|
|
17323
|
+
// same reason; this follows them rather than inventing a second policy.
|
|
17324
|
+
//
|
|
17325
|
+
// The block is NOT parsed here. Ruling P3-6 parses at Edit-click time, so a
|
|
17326
|
+
// document with forty diagrams pays nothing for thirty-nine of them, and a
|
|
17327
|
+
// block whose WaveJSON cannot be read still gets an editor — one that says
|
|
17328
|
+
// what the parser said and where — instead of a button that silently does
|
|
17329
|
+
// nothing.
|
|
17330
|
+
let waveDiagramEl = null;
|
|
17331
|
+
let waveEditor = null;
|
|
17332
|
+
// The seam Task 7 writes through: which block the open editor came from, and
|
|
17333
|
+
// the line range of the fence's BODY (the fence markers themselves are not
|
|
17334
|
+
// the editor's to rewrite). `lastGesture` is refreshed on every gesture, not
|
|
17335
|
+
// accumulated — wave-ui computes one patch per gesture precisely so a
|
|
17336
|
+
// write-back never has to replay a session's worth of them.
|
|
17337
|
+
//
|
|
17338
|
+
// Task 7's own fields, and why each is here rather than derived:
|
|
17339
|
+
// ops how many commits this session has pushed onto the document
|
|
17340
|
+
// stack. Escape reverses exactly this many — see
|
|
17341
|
+
// finishWaveSession().
|
|
17342
|
+
// baseLines `lines` as it stood when the editor opened, and
|
|
17343
|
+
// baseBlocks `blocks` likewise. Escape restores both BY VALUE instead of
|
|
17344
|
+
// trusting the reversal's arithmetic to land back where it
|
|
17345
|
+
// started; the two are the same answer, and one of them cannot
|
|
17346
|
+
// be off by a line.
|
|
17347
|
+
// baseSource the block body the store was opened over, i.e. what `lines`
|
|
17348
|
+
// holds again once an Escape has run. The stash's staleness
|
|
17349
|
+
// guard compares against it.
|
|
17350
|
+
// unwritten this session has a gesture the file could not express (the
|
|
17351
|
+
// document-level flag `waveUnwrittenEdit` is the same fact; the
|
|
17352
|
+
// flag is what documentIsDirty() reads, this is what the
|
|
17353
|
+
// session's own bookkeeping reads).
|
|
17354
|
+
// baseDepth how deep the document's undo stack was at open — the one
|
|
17355
|
+
// baseline both of waveDiscardIsSafe()'s questions are asked
|
|
17356
|
+
// against. See that function for the measured sequence that
|
|
17357
|
+
// killed the arithmetic it replaces (fix 2 / MUST-FIX 1).
|
|
17358
|
+
// baseRedoTail the redo branch that existed before the session (fix 1 /
|
|
17359
|
+
// F3). The session's first commit clears it like any other edit;
|
|
17360
|
+
// a session that is DISCARDED has to hand it back, or the user
|
|
17361
|
+
// silently loses a redo to a document that ends up
|
|
17362
|
+
// byte-identical.
|
|
17363
|
+
let waveSeam = null;
|
|
17364
|
+
// Gestures that arrived with no editor left to own them. Zero is the
|
|
17365
|
+
// invariant, not a tolerance: a paint that lands after the overlay came down
|
|
17366
|
+
// is one the user cancelled, applied to a store nobody can see. It used to be
|
|
17367
|
+
// reachable — an Escape pressed mid-drag left the drag armed and the document
|
|
17368
|
+
// listeners attached, so the eventual release still ran the paint. Counted
|
|
17369
|
+
// rather than merely guarded against, because "it cannot happen" is what the
|
|
17370
|
+
// last version of this said.
|
|
17371
|
+
let waveStrayGestures = 0;
|
|
17372
|
+
const waveEditBtn = document.createElement('button');
|
|
17373
|
+
waveEditBtn.type = 'button';
|
|
17374
|
+
waveEditBtn.className = 'ed-wave-edit-btn';
|
|
17375
|
+
waveEditBtn.textContent = '編輯波形';
|
|
17376
|
+
waveEditBtn.title = '用圖形介面編輯這張時序圖';
|
|
17377
|
+
waveEditBtn.hidden = true;
|
|
17378
|
+
document.body.appendChild(waveEditBtn);
|
|
17379
|
+
|
|
17380
|
+
/**
|
|
17381
|
+
* Whether the wave editor is the modal surface right now.
|
|
17382
|
+
*
|
|
17383
|
+
* The one question every global handler in this file asks about it, and it is
|
|
17384
|
+
* deliberately a question about STATE and not about the event.
|
|
17385
|
+
*
|
|
17386
|
+
* The previous version asked "did this event land inside the overlay?", and
|
|
17387
|
+
* that cannot answer "who owns the screen" — the answer has to be true before
|
|
17388
|
+
* any event arrives. MEASURED: right after the overlay opens,
|
|
17389
|
+
* `document.activeElement` is `document.body`, so every key targeted body and
|
|
17390
|
+
* the editor behind the modal still owned it. Three things fell out of that
|
|
17391
|
+
* one wrong question: Ctrl+Z in the unreadable panel rolled back the markdown
|
|
17392
|
+
* document behind the modal, `Tab` was swallowed by the "nothing focused"
|
|
17393
|
+
* branch so none of the dialog's controls could be reached by keyboard, and
|
|
17394
|
+
* the block-selection delete path stayed live for a body target.
|
|
17395
|
+
*
|
|
17396
|
+
* A target test also means a LIST — one `closest()` per event someone thought
|
|
17397
|
+
* of — and `drop` was the one nobody thought of: a PNG dragged onto the panel
|
|
17398
|
+
* went into the document behind it. A state question has no list.
|
|
17399
|
+
*
|
|
17400
|
+
* A function declaration on purpose: it is hoisted, so the listeners
|
|
17401
|
+
* registered far above can call it, and `waveEditor` is a `let` in this same
|
|
17402
|
+
* scope that is initialised long before any event fires.
|
|
17403
|
+
*/
|
|
17404
|
+
function waveEditorIsModal() {
|
|
17405
|
+
return waveEditor !== null;
|
|
17406
|
+
}
|
|
17407
|
+
|
|
17408
|
+
/**
|
|
17409
|
+
* The one gesture the modal does not get to swallow.
|
|
17410
|
+
*
|
|
17411
|
+
* Kept beside the predicate rather than inlined at the call site, because
|
|
17412
|
+
* "which gestures the modal owns" is part of the same answer as "who owns the
|
|
17413
|
+
* screen", and the two belong in one place. Spelled exactly as the save branch
|
|
17414
|
+
* — which is ~1230 lines ABOVE this, in the main `keydown` listener (measured:
|
|
17415
|
+
* this declaration is at 16989, that branch at 15732) — so the two cannot drift
|
|
17416
|
+
* into disagreeing about what a save keystroke is. The direction matters: a
|
|
17417
|
+
* reader who goes looking DOWNWARD finds only the wave wiring, concludes this
|
|
17418
|
+
* comment is stale, and edits one predicate without the other, which is exactly
|
|
17419
|
+
* how the modal starts swallowing Ctrl+S again.
|
|
17420
|
+
*
|
|
17421
|
+
* ── Why this is the only exemption, as of v3.4.0 batch3 ─────────────────
|
|
17422
|
+
*
|
|
17423
|
+
* The question was asked of every branch of that listener rather than answered
|
|
17424
|
+
* for Ctrl+S alone, and the answer is recorded here so the next person adding
|
|
17425
|
+
* a global shortcut can see that it was asked:
|
|
17426
|
+
*
|
|
17427
|
+
* - Everything about EDITING the document behind the modal — the toolbar's
|
|
17428
|
+
* keyboard mode, block selection's Tab/Delete/Backspace, the burst
|
|
17429
|
+
* short-circuits, undo/redo, Escape — is exactly what the modal is for.
|
|
17430
|
+
* Those stay swallowed.
|
|
17431
|
+
* - Escape, Tab and undo/redo are additionally CLAIMED by the overlay's own
|
|
17432
|
+
* capture-phase handler, which stops them before they reach here, so the
|
|
17433
|
+
* gate is their second line and not their first.
|
|
17434
|
+
* - `Ctrl+P` / `Ctrl+F` / `Ctrl+A` / `F5` have no handler in this file at
|
|
17435
|
+
* all, so the gate cannot swallow them: it is a bare `return` with no
|
|
17436
|
+
* `preventDefault()`, and the browser's own behaviour is untouched.
|
|
17437
|
+
* - `Ctrl+S` is the only global gesture this file implements that is not
|
|
17438
|
+
* about the covered document. It is the reflex that protects work, the
|
|
17439
|
+
* wave editor has nothing of its own to save in this task, and it is the
|
|
17440
|
+
* only producer of the 409 that raises the disk-conflict banner.
|
|
17441
|
+
*
|
|
17442
|
+
* So: a new global shortcut added below the gate is DEAD while the overlay is
|
|
17443
|
+
* up. If it is about the document, that is correct. If it is not — a help
|
|
17444
|
+
* panel, a command palette, anything the user reaches for regardless of what
|
|
17445
|
+
* is on screen — it belongs in this function.
|
|
17446
|
+
*/
|
|
17447
|
+
function isSaveGesture(e) {
|
|
17448
|
+
return (e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === 's';
|
|
17449
|
+
}
|
|
17450
|
+
|
|
17451
|
+
function hideWaveEditBtn() {
|
|
17452
|
+
// Already hidden is the overwhelmingly common case: this is called from a
|
|
17453
|
+
// CAPTURE-phase `mouseover` on the document (once per element the pointer
|
|
17454
|
+
// crosses) and a capture-phase `scroll` on the window (inner scrollers
|
|
17455
|
+
// included). Writing `hidden` unconditionally made every pointer move over
|
|
17456
|
+
// the page a DOM write on document.body's own subtree; the early return
|
|
17457
|
+
// makes both free.
|
|
17458
|
+
if (waveDiagramEl === null && waveEditBtn.hidden) return;
|
|
17459
|
+
waveDiagramEl = null;
|
|
17460
|
+
waveEditBtn.hidden = true;
|
|
17461
|
+
}
|
|
17462
|
+
|
|
17463
|
+
function showWaveEditBtnFor(el) {
|
|
17464
|
+
// Same diagram, already showing: nothing to write. The listener that calls
|
|
17465
|
+
// this fires once per element the pointer crosses, and a diagram is a tree
|
|
17466
|
+
// of them.
|
|
17467
|
+
if (waveDiagramEl === el && !waveEditBtn.hidden) return;
|
|
17468
|
+
const r = el.getBoundingClientRect();
|
|
17469
|
+
if (r.width === 0 && r.height === 0) return hideWaveEditBtn();
|
|
17470
|
+
waveDiagramEl = el;
|
|
17471
|
+
waveEditBtn.hidden = false;
|
|
17472
|
+
// Top-right of the diagram, nudged inside it so it never hangs over the
|
|
17473
|
+
// block gutter on the left or off the viewport on the right — and never
|
|
17474
|
+
// above the toolbar's own band, which would put this button UNDER the bar
|
|
17475
|
+
// (z-index 12 against 101) at every scroll position where the diagram's
|
|
17476
|
+
// top edge has gone past it. See `toolbarTopInset()` for the measurement:
|
|
17477
|
+
// the affordance was drawn, was invisible, was not clickable, and was the
|
|
17478
|
+
// feature's only entry point.
|
|
17479
|
+
waveEditBtn.style.top =
|
|
17480
|
+
Math.max(toolbarTopInset() + 4, r.top + 6) + 'px';
|
|
17481
|
+
waveEditBtn.style.left = Math.max(4,
|
|
17482
|
+
Math.min(r.right - waveEditBtn.offsetWidth - 6,
|
|
17483
|
+
window.innerWidth - waveEditBtn.offsetWidth - 6)) + 'px';
|
|
17484
|
+
}
|
|
17485
|
+
|
|
17486
|
+
// The class the diagram init hook tags every rendered display div with; the
|
|
17487
|
+
// id prefix is the same signal for a page that predates the class.
|
|
17488
|
+
const WAVE_DIAGRAM_SEL = '.wavedrom-diagram, [id^="WaveDrom_Display_"]';
|
|
17489
|
+
|
|
17490
|
+
function waveDiagramAt(node) {
|
|
17491
|
+
if (node === null || node === undefined || typeof node.closest !== 'function') return null;
|
|
17492
|
+
return node.closest(WAVE_DIAGRAM_SEL);
|
|
17493
|
+
}
|
|
17494
|
+
|
|
17495
|
+
/**
|
|
17496
|
+
* The rendered diagram INSIDE a block, if it holds one.
|
|
17497
|
+
*
|
|
17498
|
+
* `waveDiagramAt()` answers the pointer's question — "what is under this
|
|
17499
|
+
* element?" — by walking UP. The keyboard's question is the other direction:
|
|
17500
|
+
* the user is standing on a whole block and wants to know whether it is a
|
|
17501
|
+
* waveform. Same selector, deliberately shared, so the affordance a mouse
|
|
17502
|
+
* user gets and the one a keyboard user gets can never come to disagree
|
|
17503
|
+
* about which blocks are editable.
|
|
17504
|
+
*
|
|
17505
|
+
* A function declaration rather than an arrow const: the keydown listener
|
|
17506
|
+
* that calls it is registered hundreds of lines above this point.
|
|
17507
|
+
*/
|
|
17508
|
+
function waveDiagramInside(blockEl) {
|
|
17509
|
+
if (blockEl === null || blockEl === undefined ||
|
|
17510
|
+
typeof blockEl.querySelector !== 'function') return null;
|
|
17511
|
+
return blockEl.querySelector(WAVE_DIAGRAM_SEL);
|
|
17512
|
+
}
|
|
17513
|
+
|
|
17514
|
+
document.addEventListener('mouseover', (ev) => {
|
|
17515
|
+
if (waveEditor !== null) return;
|
|
17516
|
+
const t = ev.target;
|
|
17517
|
+
if (t !== null && typeof t.closest === 'function' && t.closest('.ed-wave-edit-btn')) return;
|
|
17518
|
+
const diagram = waveDiagramAt(t);
|
|
17519
|
+
if (diagram === null) { hideWaveEditBtn(); return; }
|
|
17520
|
+
showWaveEditBtnFor(diagram);
|
|
17521
|
+
}, true);
|
|
17522
|
+
window.addEventListener('scroll', () => { if (waveEditor === null) hideWaveEditBtn(); }, true);
|
|
17523
|
+
|
|
17524
|
+
// The fence BODY of one block: its source lines with the opening fence, and
|
|
17525
|
+
// the closing fence when there is one, taken off. An unterminated fence at the
|
|
17526
|
+
// end of the document has no closing line to drop, and dropping the last line
|
|
17527
|
+
// anyway would silently eat a cycle of the user's diagram.
|
|
17528
|
+
function waveBodyOf(block) {
|
|
17529
|
+
const src = lines.slice(block.startLine - 1, block.endLine);
|
|
17530
|
+
let end = src.length;
|
|
17531
|
+
if (end > 1 && /^\s*(?:```+|~~~+)\s*$/.test(src[end - 1])) end--;
|
|
17532
|
+
return {
|
|
17533
|
+
text: src.slice(1, end).join('\n'),
|
|
17534
|
+
startLine: block.startLine + 1,
|
|
17535
|
+
endLine: block.startLine + end - 1,
|
|
17536
|
+
};
|
|
17537
|
+
}
|
|
17538
|
+
|
|
17539
|
+
async function openWaveEditor(diagram) {
|
|
17540
|
+
const ui = window.md2docWaveUi;
|
|
17541
|
+
const mods = window.__md2docWave;
|
|
17542
|
+
// An older cached page that predates the injection must degrade to "the
|
|
17543
|
+
// button does nothing visible", never to a throw out of a click handler.
|
|
17544
|
+
if (!ui || !mods || !mods['wave-store.js'] || !mods['wave-codec.js'] ||
|
|
17545
|
+
!mods['wave-geometry.js']) return;
|
|
17546
|
+
if (waveEditor !== null) return;
|
|
17547
|
+
hideWaveEditBtn();
|
|
17548
|
+
|
|
17549
|
+
// Settle the document BEFORE reading a single line out of it. An open
|
|
17550
|
+
// burst or block editor is committed here, deliberately in front of the
|
|
17551
|
+
// overlay rather than behind it: the commit rewrites `lines` and can
|
|
17552
|
+
// re-render `.content`, and a line range read before that is stale the
|
|
17553
|
+
// moment it is written. A standing block selection goes too — the wave
|
|
17554
|
+
// editor is about to take the keyboard, and a selection the user can no
|
|
17555
|
+
// longer see is a selection they cannot mean.
|
|
17556
|
+
// fix 2 (R2): the return value is not decoration. When it is false an editor
|
|
17557
|
+
// is still open BEHIND the overlay, and the session's own Ctrl+S — which
|
|
17558
|
+
// runs `switchAwayFrom().then(ok => ok && save())` — then commits it,
|
|
17559
|
+
// pushing a NON-wave op inside the session. That op breaks both of the
|
|
17560
|
+
// session's bookkeeping assumptions at once: `seam.ops` no longer counts the
|
|
17561
|
+
// top of the stack (so a discard would reverse somebody else's edit — now
|
|
17562
|
+
// caught by waveDiscardIsSafe()'s depth question), and its line delta would
|
|
17563
|
+
// be attributed to the wave block by settleWaveBlocks(), which is F2's own
|
|
17564
|
+
// failure shape through a door F2 did not close. Every other caller in this
|
|
17565
|
+
// file already treats false as "do not proceed"; so does this one.
|
|
17566
|
+
// Was the keyboard standing ON this block when the editor opened? Asked
|
|
17567
|
+
// BEFORE `switchAwayFrom()`, because that can re-render `.content` and
|
|
17568
|
+
// detach the very elements the question is about.
|
|
17569
|
+
//
|
|
17570
|
+
// A state question, not a「which device opened it」one: the keyboard entry
|
|
17571
|
+
// above always satisfies it, and so does a mouse user who selected the
|
|
17572
|
+
// block first and then pressed the affordance — in both cases the editor
|
|
17573
|
+
// is about to take a keyboard that was somewhere visible, and closing owes
|
|
17574
|
+
// it back. When it is false (the ordinary hover-and-click), the keyboard
|
|
17575
|
+
// was on `document.body` before the overlay opened and this changes
|
|
17576
|
+
// nothing about where it ends up.
|
|
17577
|
+
const cameFromBlockSelection = blockSelection !== null &&
|
|
17578
|
+
typeof diagram.closest === 'function' &&
|
|
17579
|
+
selectionFocusBlockEl() === diagram.closest('.ed-block');
|
|
17580
|
+
if (!(await switchAwayFrom())) return;
|
|
17581
|
+
clearBlockSelection();
|
|
17582
|
+
|
|
17583
|
+
// The re-render `switchAwayFrom()` may have run replaces `.content`
|
|
17584
|
+
// wholesale, so the element that was hovered can be detached and its block
|
|
17585
|
+
// id can have shifted. Re-resolve both, and give up quietly if the diagram
|
|
17586
|
+
// is no longer on screen — the user hovers again, which is cheaper than
|
|
17587
|
+
// opening an editor over a block that is not the one they pointed at.
|
|
17588
|
+
if (!document.contains(diagram)) return;
|
|
17589
|
+
const blockEl = diagram.closest('.ed-block');
|
|
17590
|
+
if (blockEl === null) return;
|
|
17591
|
+
const id = Number(blockEl.getAttribute('data-block-id'));
|
|
17592
|
+
const block = blocks.find((b) => b.id === id);
|
|
17593
|
+
if (!block) return;
|
|
17594
|
+
const body = waveBodyOf(block);
|
|
17595
|
+
|
|
17596
|
+
// The seam is written only once the editor really exists. createWaveEditor
|
|
17597
|
+
// can throw (the `.content` mount guard is a throw by design), and a seam
|
|
17598
|
+
// left standing with no editor behind it is a lie the next gesture would
|
|
17599
|
+
// read.
|
|
17600
|
+
let editor = null;
|
|
17601
|
+
try {
|
|
17602
|
+
editor = ui.createWaveEditor({
|
|
17603
|
+
document: document,
|
|
17604
|
+
host: document.body,
|
|
17605
|
+
source: body.text,
|
|
17606
|
+
codec: mods['wave-codec.js'],
|
|
17607
|
+
geometry: mods['wave-geometry.js'],
|
|
17608
|
+
createStore: mods['wave-store.js'].createStore,
|
|
17609
|
+
// The store's own answer to「哪一條 lane 是哪一條」across one operation,
|
|
17610
|
+
// handed over rather than re-derived: the editor's cell cursor and its
|
|
17611
|
+
// selection are row indexes, and the patch planner reads the identical
|
|
17612
|
+
// function to decide which lane moved. Two implementations of that
|
|
17613
|
+
// question would let a mark paint one lane while the patch wrote
|
|
17614
|
+
// another.
|
|
17615
|
+
laneTrace: mods['wave-store.js'].laneTrace,
|
|
17616
|
+
wavedrom: (typeof WaveDrom !== 'undefined') ? WaveDrom : null,
|
|
17617
|
+
onGesture: (info) => {
|
|
17618
|
+
if (waveSeam === null) { waveStrayGestures++; return; }
|
|
17619
|
+
waveSeam.lastGesture = info;
|
|
17620
|
+
writeWaveGestureBack(info);
|
|
17621
|
+
},
|
|
17622
|
+
onClose: (reason) => {
|
|
17623
|
+
// Read the seam out and null BOTH before doing anything slow: the
|
|
17624
|
+
// predicate every global listener asks (`waveEditorIsModal()`) has to
|
|
17625
|
+
// answer "no" from this instant, and finishWaveSession() below awaits
|
|
17626
|
+
// a render. A seam still standing across that await is one a stray
|
|
17627
|
+
// late gesture could write through.
|
|
17628
|
+
const seam = waveSeam;
|
|
17629
|
+
waveEditor = null;
|
|
17630
|
+
waveSeam = null;
|
|
17631
|
+
finishWaveSession(seam, reason)
|
|
17632
|
+
// …and only then the keyboard, because the commit path RE-RENDERS
|
|
17633
|
+
// `.content` and every block element that existed when the overlay
|
|
17634
|
+
// was up is gone by the time that resolves. Focusing before the
|
|
17635
|
+
// render would put the cursor on a detached node — which is
|
|
17636
|
+
// `document.body` with extra steps.
|
|
17637
|
+
.then(() => { giveKeyboardBackToWaveBlock(seam); })
|
|
17638
|
+
.catch((err) => {
|
|
17639
|
+
console.error('md2doc: the wave editor could not settle its ' +
|
|
17640
|
+
'write-back', err);
|
|
17641
|
+
});
|
|
17642
|
+
},
|
|
17643
|
+
});
|
|
17644
|
+
} catch (err) {
|
|
17645
|
+
waveEditor = null;
|
|
17646
|
+
waveSeam = null;
|
|
17647
|
+
throw err;
|
|
17648
|
+
}
|
|
17649
|
+
waveEditor = editor;
|
|
17650
|
+
waveSeam = { blockId: id, startLine: body.startLine, endLine: body.endLine,
|
|
17651
|
+
lastGesture: null, ops: 0, unwritten: false,
|
|
17652
|
+
blockStartLine: block.startLine, blockEndLine: block.endLine,
|
|
17653
|
+
baseSource: body.text, baseLines: lines, baseBlocks: blocks,
|
|
17654
|
+
baseDepth: stack.depth, baseRedoTail: stack.redoTail(),
|
|
17655
|
+
reselect: cameFromBlockSelection };
|
|
17656
|
+
// A fresh session cannot inherit the previous one's unwritten work: that
|
|
17657
|
+
// session is over, and whatever it could not express went with it.
|
|
17658
|
+
waveUnwrittenEdit = false;
|
|
17659
|
+
}
|
|
17660
|
+
|
|
17661
|
+
// ── Task 7: one gesture, one write-back ────────────────────────────────
|
|
17662
|
+
//
|
|
17663
|
+
// Not one write-back per SESSION. Task 5 measured the store's refusal rate
|
|
17664
|
+
// climbing with the number of structural operations stacked up before a
|
|
17665
|
+
// patch is asked for — 8.0% at 1-3 operations, 17.0% at 1-6, and an
|
|
17666
|
+
// independently generated corpus agreed on the shape (6.6% / 18.8%). Those
|
|
17667
|
+
// refusals are correct; they are what replaced a class of silent corruption.
|
|
17668
|
+
// But a refusal the user meets at the END of a session is one they have to
|
|
17669
|
+
// bisect out of their own work, so the patch is computed and applied on the
|
|
17670
|
+
// gesture that caused it. wave-ui already computes exactly one patch per
|
|
17671
|
+
// gesture for this reason; this is the other end of that seam.
|
|
17672
|
+
//
|
|
17673
|
+
// Everything here goes through commitRangeEdit() — the same helper every
|
|
17674
|
+
// other structural gesture in this file commits through — so the write-back
|
|
17675
|
+
// lands on the document undo stack like any other edit. `lines` is never
|
|
17676
|
+
// written directly. Two things fall out of that for free and are load-
|
|
17677
|
+
// bearing, not incidental: `stack.dirtyDepth` counts the waveform (so the
|
|
17678
|
+
// title dot, the save button and the beforeunload guard all see it without
|
|
17679
|
+
// knowing what a waveform is), and Escape can take the session back off the
|
|
17680
|
+
// stack rather than needing a stash of its own for the part that committed.
|
|
17681
|
+
//
|
|
17682
|
+
// What is deliberately NOT done here: re-rendering `.content`. The overlay
|
|
17683
|
+
// covers the document, a render replaces `.content` wholesale, and doing it
|
|
17684
|
+
// per paint would tear down and re-run WaveDrom on every gesture for a
|
|
17685
|
+
// picture nobody can see. `.content` therefore lags `lines` for exactly as
|
|
17686
|
+
// long as the modal is up, and the session's close settles it — a commit by
|
|
17687
|
+
// rendering, an Escape by putting `lines` back to what `.content` is still
|
|
17688
|
+
// showing.
|
|
17689
|
+
function writeWaveGestureBack(info) {
|
|
17690
|
+
const seam = waveSeam;
|
|
17691
|
+
if (seam === null || !info || !info.patch) return;
|
|
17692
|
+
if (info.patch.ok !== true) {
|
|
17693
|
+
waveRefuseGesture(seam, info.patch.reason);
|
|
17694
|
+
return;
|
|
17695
|
+
}
|
|
17696
|
+
const result = commitRangeEdit({ lines, blocks, stack },
|
|
17697
|
+
seam.startLine, seam.endLine, info.patch.text);
|
|
17698
|
+
if (result.op === null) {
|
|
17699
|
+
// `commitRangeEdit()` returns `op: null` for two different reasons, and
|
|
17700
|
+
// only one of them is "nothing to do": text that is already
|
|
17701
|
+
// byte-identical, and — tagged `refused` — a line range it will not touch
|
|
17702
|
+
// at all (an inverted one, i.e. a block that owns no source line).
|
|
17703
|
+
//
|
|
17704
|
+
// The `refused` arm is DEFENSIVE and is not known to be reachable from
|
|
17705
|
+
// here (fix 2 / R3, correcting an earlier claim that it was): the only
|
|
17706
|
+
// inverted seam this path can build comes from `waveBodyOf()` on an empty
|
|
17707
|
+
// fence body, and an empty wavedrom fence renders no `.wavedrom-diagram`,
|
|
17708
|
+
// so there is no hover affordance and no session. It is handled anyway
|
|
17709
|
+
// because the cost of being wrong about that is the silent drop this
|
|
17710
|
+
// whole layer exists to prevent, and the cost of the branch is three
|
|
17711
|
+
// lines.
|
|
17712
|
+
//
|
|
17713
|
+
// fix 1 (F6): the sentence that stood here said this branch catches "an
|
|
17714
|
+
// edit and its inverse inside one session". Re-derived, that is false
|
|
17715
|
+
// under per-gesture write-back — `lines` already holds the forward edit,
|
|
17716
|
+
// so the inverse produces a patch that DIFFERS from the current bytes and
|
|
17717
|
+
// pushes a real second op (MEASURED: paint then the overlay's own undo
|
|
17718
|
+
// gives `ops` 1 → 2, not `op === null`). The net-zero session is handled
|
|
17719
|
+
// further down instead, by unwinding the ops rather than by never having
|
|
17720
|
+
// pushed them.
|
|
17721
|
+
if (result.refused) {
|
|
17722
|
+
waveRefuseGesture(seam,
|
|
17723
|
+
'這個區塊的行範圍不合法(' + result.refused + '),所以沒有東西可以改');
|
|
17724
|
+
return;
|
|
17725
|
+
}
|
|
17726
|
+
seam.unwritten = false;
|
|
17727
|
+
waveUnwrittenEdit = false;
|
|
17728
|
+
setDirty();
|
|
17729
|
+
return;
|
|
17730
|
+
}
|
|
17731
|
+
lines = result.lines;
|
|
17732
|
+
// `blocks` is deliberately left alone until the session settles.
|
|
17733
|
+
// commitRangeEdit()'s shift anchor is the last block ENDING at or before
|
|
17734
|
+
// the edited range, and this range is the fence's BODY — strictly inside
|
|
17735
|
+
// the code block — so the anchor is the block BEFORE it and the shift would
|
|
17736
|
+
// move the wavedrom block's own startLine, which did not move. Leaving
|
|
17737
|
+
// `blocks` at its opening value keeps it merely STALE (every id present,
|
|
17738
|
+
// every span the one `.content` was rendered from) instead of wrong, and
|
|
17739
|
+
// nothing reads it while the modal owns the screen.
|
|
17740
|
+
//
|
|
17741
|
+
// fix 1 (F2): "the close settles it" is now true on BOTH close paths, not
|
|
17742
|
+
// only the one where the render succeeds — see settleWaveBlocks().
|
|
17743
|
+
seam.ops++;
|
|
17744
|
+
seam.endLine = seam.startLine + info.patch.text.split('\n').length - 1;
|
|
17745
|
+
seam.unwritten = false;
|
|
17746
|
+
waveUnwrittenEdit = false;
|
|
17747
|
+
// fix 1 (F5): a session that has come back to the bytes it started from
|
|
17748
|
+
// takes its own ops off the stack again, so the dirty dot goes out instead
|
|
17749
|
+
// of standing for the rest of the session over a byte-identical file.
|
|
17750
|
+
// MEASURED before this: paint, then the overlay's own undo — the document
|
|
17751
|
+
// was back to the original and the title stayed `● doc` permanently.
|
|
17752
|
+
if (bodyNow(seam) === seam.baseSource) unwindWaveOps(seam);
|
|
17753
|
+
setDirty();
|
|
17754
|
+
}
|
|
17755
|
+
|
|
17756
|
+
/** The block body as `lines` holds it right now. */
|
|
17757
|
+
function bodyNow(seam) {
|
|
17758
|
+
return lines.slice(seam.startLine - 1, seam.endLine).join('\n');
|
|
17759
|
+
}
|
|
17760
|
+
|
|
17761
|
+
/** One refusal, recorded and raised. The only producer of `waveUnwrittenEdit`. */
|
|
17762
|
+
function waveRefuseGesture(seam, reason) {
|
|
17763
|
+
// A refusal is not papered over and not rolled back. wave-ui has already
|
|
17764
|
+
// said so on the status line, which is where the user's eyes are, but a
|
|
17765
|
+
// status line is not a decision — this raises it as a banner naming the
|
|
17766
|
+
// lines it could not write and waits for the user to acknowledge it.
|
|
17767
|
+
seam.unwritten = true;
|
|
17768
|
+
waveUnwrittenEdit = true;
|
|
17769
|
+
setDirty();
|
|
17770
|
+
showWaveRefusal(seam, reason);
|
|
17771
|
+
}
|
|
17772
|
+
|
|
17773
|
+
/**
|
|
17774
|
+
* Take this session's own commits back off the document stack — bytes, stack
|
|
17775
|
+
* depth and redo branch — and say whether that was possible.
|
|
17776
|
+
*
|
|
17777
|
+
* What it does NOT give back is the SAVE MARKER. `push()` invalidates that
|
|
17778
|
+
* when history diverges past it, and a withdrawal cannot un-invalidate: the
|
|
17779
|
+
* ops that produced the saved bytes are gone from this stack whether or not
|
|
17780
|
+
* this session later changed its mind. MEASURED: edit, commit, Ctrl+S,
|
|
17781
|
+
* Ctrl+Z, open the editor, paint once, Escape, Ctrl+Y — the session is fully
|
|
17782
|
+
* withdrawn and the document is byte-identical to disk, and `●` still stands
|
|
17783
|
+
* until the next Ctrl+S. That is over-warning, which is the safe direction
|
|
17784
|
+
* and is deliberately not traded for a marker that could be wrong in the
|
|
17785
|
+
* other one.
|
|
17786
|
+
*
|
|
17787
|
+
* Returns false when it was NOT — see waveDiscardIsSafe() for the two
|
|
17788
|
+
* questions that decide it and for why neither is arithmetic over the dirty
|
|
17789
|
+
* depth any more. When it answers false a Ctrl+S landed in the middle (the
|
|
17790
|
+
* one gesture the modal deliberately does not swallow), or something other
|
|
17791
|
+
* than this session pushed, and `discardTop()` must not run: it would either
|
|
17792
|
+
* destroy the state disk was written from, or reverse somebody else's edit.
|
|
17793
|
+
*
|
|
17794
|
+
* The caller decides what to do about a false; this function never leaves the
|
|
17795
|
+
* stack half-unwound.
|
|
17796
|
+
*/
|
|
17797
|
+
function unwindWaveOps(seam) {
|
|
17798
|
+
if (seam.ops === 0) return true;
|
|
17799
|
+
if (!waveDiscardIsSafe(stack, seam)) return false;
|
|
17800
|
+
for (let i = 0; i < seam.ops; i++) {
|
|
17801
|
+
if (stack.discardTop(lines) === null) break;
|
|
17802
|
+
}
|
|
17803
|
+
// fix 1 (F3): the session's first commit cleared the redo branch that
|
|
17804
|
+
// existed before it, and `discardTop()` has no way to put that back. A
|
|
17805
|
+
// session that is being withdrawn has to, or the comment below would be
|
|
17806
|
+
// claiming something that is measurably false.
|
|
17807
|
+
stack.setRedoTail(seam.baseRedoTail);
|
|
17808
|
+
// Restored BY VALUE rather than from discardTop()'s own returned arrays:
|
|
17809
|
+
// the two are the same answer, and only one of them can be off by a line.
|
|
17810
|
+
// discardTop() is still called for every op, because popping the stack is
|
|
17811
|
+
// the half `baseLines` cannot do.
|
|
17812
|
+
lines = seam.baseLines;
|
|
17813
|
+
blocks = seam.baseBlocks;
|
|
17814
|
+
seam.ops = 0;
|
|
17815
|
+
seam.endLine = seam.startLine + seam.baseSource.split('\n').length - 1;
|
|
17816
|
+
return true;
|
|
17817
|
+
}
|
|
17818
|
+
|
|
17819
|
+
/**
|
|
17820
|
+
* Make `blocks` agree with `lines` from the session's own arithmetic.
|
|
17821
|
+
*
|
|
17822
|
+
* Only ever needed because the session defers the block map to one render at
|
|
17823
|
+
* the end, and that render can fail. See shiftBlocksAfterBodyEdit()'s own
|
|
17824
|
+
* comment for what a stale `blocks` costs.
|
|
17825
|
+
*/
|
|
17826
|
+
function settleWaveBlocks(seam) {
|
|
17827
|
+
blocks = shiftBlocksAfterBodyEdit(seam.baseBlocks, seam.blockId,
|
|
17828
|
+
lines.length - seam.baseLines.length);
|
|
17829
|
+
}
|
|
17830
|
+
|
|
17831
|
+
/**
|
|
17832
|
+
* Say, visibly, that a gesture could not be written — and name where.
|
|
17833
|
+
*
|
|
17834
|
+
* The status line inside the overlay already says it, and that is not
|
|
17835
|
+
* enough: a status line is read while you are looking for it, and this is
|
|
17836
|
+
* the one message in the feature that means "what you are looking at is not
|
|
17837
|
+
* what the file has". So it is raised as a banner — the same `.ed-conflict`
|
|
17838
|
+
* layer the disk-conflict notice uses, which is already painted ABOVE the
|
|
17839
|
+
* modal and already inside the dialog's focus trap (see `modalRoots()` in
|
|
17840
|
+
* wave-ui.js), so it is both visible and Tab-reachable without this having
|
|
17841
|
+
* to invent a second modal layer.
|
|
17842
|
+
*
|
|
17843
|
+
* Nothing is rewritten and nothing is rolled back: `store.toPatch()`'s
|
|
17844
|
+
* refusal travels to the user unchanged, which is what the whole codec layer
|
|
17845
|
+
* exists to make possible.
|
|
17846
|
+
*
|
|
17847
|
+
* ── One coordinate, in the frame the user is looking at (fix 1 / F4) ─────
|
|
17848
|
+
*
|
|
17849
|
+
* The first cut named two: the block's current document line range, and a
|
|
17850
|
+
* line number taken from `rewroteRange`'s offsets. The second was mapped
|
|
17851
|
+
* through the store's own `sourceText` — the body AS IT WAS WHEN THE EDITOR
|
|
17852
|
+
* OPENED, which is the only frame those offsets are exact in, since the store
|
|
17853
|
+
* is created once per session. That mapping was right and the REPORTING was
|
|
17854
|
+
* wrong: nothing in the sentence said the number belonged to a snapshot.
|
|
17855
|
+
* MEASURED after a rename plus two lane-adds — the banner said 「這個區塊自己的
|
|
17856
|
+
* 第 2 行」, which was the `clk` lane at open and is the just-added lane in the
|
|
17857
|
+
* file the user now has, with `clk` one line further down. A user who counts
|
|
17858
|
+
* two lines into the range the same sentence just gave them lands on the
|
|
17859
|
+
* wrong lane.
|
|
17860
|
+
*
|
|
17861
|
+
* It is dropped rather than labelled. A number that needs a paragraph of
|
|
17862
|
+
* frame-of-reference to be safe is not a coordinate, and the store's own
|
|
17863
|
+
* `reason` already names the path it is about — which is the thing that
|
|
17864
|
+
* survives a body whose line count has moved.
|
|
17865
|
+
*/
|
|
17866
|
+
function showWaveRefusal(seam, reason) {
|
|
17867
|
+
const range = seam.startLine === seam.endLine
|
|
17868
|
+
? ('第 ' + seam.startLine + ' 行')
|
|
17869
|
+
: ('第 ' + seam.startLine + '–' + seam.endLine + ' 行');
|
|
17870
|
+
// 知道了 is the confirmation the refusal waits for, and it is a SECOND
|
|
17871
|
+
// button rather than a relabelled ✕: the ✕ on this banner means the same
|
|
17872
|
+
// thing, but a dismiss glyph reads as "hide this" and the whole point of
|
|
17873
|
+
// this banner is that the user has registered what it says. Acknowledging
|
|
17874
|
+
// deliberately does NOT clear `waveUnwrittenEdit` — the work is still
|
|
17875
|
+
// missing from the file after the user has read about it, and the dirty
|
|
17876
|
+
// net is about the work, not about the notice.
|
|
17877
|
+
let el = null;
|
|
17878
|
+
el = showBanner(
|
|
17879
|
+
'這個改動沒辦法只改幾個位元組寫回檔案,所以【沒有】寫回去 —— 編輯器上看到的' +
|
|
17880
|
+
'比檔案裡多一步。受影響的是這個 wavedrom 區塊的內容,' + range +
|
|
17881
|
+
'(不含前後兩道圍欄)。原因:' + reason,
|
|
17882
|
+
'知道了',
|
|
17883
|
+
() => {
|
|
17884
|
+
if (el === null) return;
|
|
17885
|
+
el.remove();
|
|
17886
|
+
// Scoped to OUR element: a newer banner may have replaced this one on
|
|
17887
|
+
// screen between the refusal and the acknowledgement, and taking that
|
|
17888
|
+
// one down would hide something the user has not read.
|
|
17889
|
+
if (activeBanner === el) {
|
|
17890
|
+
activeBanner = null;
|
|
17891
|
+
activeBannerIsRefusal = false;
|
|
17892
|
+
activeBannerIsSwallow = false;
|
|
17893
|
+
activeBannerIsKeynavExit = false;
|
|
17894
|
+
}
|
|
17895
|
+
});
|
|
17896
|
+
}
|
|
17897
|
+
|
|
17898
|
+
/**
|
|
17899
|
+
* Settle a wave session that has just gone off screen.
|
|
17900
|
+
*
|
|
17901
|
+
* `reason` is wave-ui's, not inferred here: 'escape' means the user asked for
|
|
17902
|
+
* the session to be thrown away, anything else means they kept it.
|
|
17903
|
+
*/
|
|
17904
|
+
/**
|
|
17905
|
+
* Give the keyboard back to the block the editor was opened over.
|
|
17906
|
+
*
|
|
17907
|
+
* v3.3.0's backlog had FOUR accessibility defects and this batch produced two
|
|
17908
|
+
* more, all of them the same sentence: a gesture ended and left
|
|
17909
|
+
* `document.activeElement` on `document.body`, where the user can see
|
|
17910
|
+
* nothing, Tab is deliberately swallowed, and the next Backspace means
|
|
17911
|
+
* whatever the last selection meant. The wave editor is the biggest surface
|
|
17912
|
+
* in the product to close, so it is the worst place to end there.
|
|
17913
|
+
*
|
|
17914
|
+
* The mark it leaves is a single-block SELECTION and not a bare `.focus()`,
|
|
17915
|
+
* because a bare focus is invisible: `.ed-block.ed-selected:focus` has
|
|
17916
|
+
* `outline: none` and the TINT is this editor's affordance for「鍵盤在這裡」
|
|
17917
|
+
* (see that rule's own comment in lib/md2doc.js). It also puts the user back
|
|
17918
|
+
* exactly where the keyboard entry above starts from, so Enter re-opens the
|
|
17919
|
+
* editor they just left.
|
|
17920
|
+
*
|
|
17921
|
+
* Scoped to `seam.reselect` — the sessions that took a keyboard which was
|
|
17922
|
+
* standing somewhere visible. A hover-and-click session is not made worse by
|
|
17923
|
+
* this and is not given a selection it never asked for.
|
|
17924
|
+
*/
|
|
17925
|
+
function giveKeyboardBackToWaveBlock(seam) {
|
|
17926
|
+
if (seam === null || seam === undefined || seam.reselect !== true) return;
|
|
17927
|
+
// Resolved by id against the CURRENT map: the commit path has re-rendered
|
|
17928
|
+
// and re-numbered by now, and settleWaveBlocks() has already moved the
|
|
17929
|
+
// session's own line arithmetic into `blocks`. A block that is no longer
|
|
17930
|
+
// there (a render that failed and left the map short) simply gets nothing —
|
|
17931
|
+
// guessing at a neighbour is how a keyboard ends up selecting a block the
|
|
17932
|
+
// user never pointed at.
|
|
17933
|
+
const b = blocks.find((x) => x.id === seam.blockId);
|
|
17934
|
+
if (!b || !blockElById(seam.blockId)) return;
|
|
17935
|
+
setBlockSelection({ anchorLine: b.startLine, focusLine: b.startLine });
|
|
17936
|
+
}
|
|
17937
|
+
|
|
17938
|
+
async function finishWaveSession(seam, reason) {
|
|
17939
|
+
if (seam === null) return;
|
|
17940
|
+
if (reason === 'escape') {
|
|
17941
|
+
// Escape DISCARDS — the same word it means everywhere else in v3.3.0 —
|
|
17942
|
+
// and here that has to reach the document stack, because the session has
|
|
17943
|
+
// been committing all along.
|
|
17944
|
+
const painted = bodyNow(seam);
|
|
17945
|
+
const hadOps = seam.ops > 0;
|
|
17946
|
+
if (!unwindWaveOps(seam)) {
|
|
17947
|
+
// A Ctrl+S landed inside this session, so its commits cannot simply be
|
|
17948
|
+
// popped — see unwindWaveOps() for the measured failure that produces.
|
|
17949
|
+
// A revert COMMIT is the honest answer instead: the document goes back
|
|
17950
|
+
// to where the session found it, and the stack only ever grows, so
|
|
17951
|
+
// `dirtyDepth` stays strictly positive and can never walk back through
|
|
17952
|
+
// zero over a document that differs from disk. The cost is a `●` the
|
|
17953
|
+
// user clears with one Ctrl+S — which is TRUE, because disk is still
|
|
17954
|
+
// holding the drawing they just discarded.
|
|
17955
|
+
const result = commitRangeEdit({ lines, blocks, stack },
|
|
17956
|
+
seam.startLine, seam.endLine, seam.baseSource);
|
|
17957
|
+
if (result.op !== null) lines = result.lines;
|
|
17958
|
+
blocks = seam.baseBlocks;
|
|
17959
|
+
seam.endLine = seam.startLine + seam.baseSource.split('\n').length - 1;
|
|
17960
|
+
// fix 2 (R1): the redo branch, on THIS path too. Both Escape branches
|
|
17961
|
+
// end with `lines` byte-identical to `seam.baseLines` — only the seam
|
|
17962
|
+
// range is ever written during a session — so the pre-session redo ops
|
|
17963
|
+
// still address the document they were recorded against, and a user who
|
|
17964
|
+
// lands here is otherwise in exactly F3's position: a byte-identical
|
|
17965
|
+
// document with their redo silently eaten. AFTER the commit, because
|
|
17966
|
+
// `push()` is what cleared it.
|
|
17967
|
+
stack.setRedoTail(seam.baseRedoTail);
|
|
17968
|
+
}
|
|
17969
|
+
if (hadOps && painted !== seam.baseSource) {
|
|
17970
|
+
// …and the discard is recoverable by exactly one Ctrl+Z. See
|
|
17971
|
+
// discardedWaveEdit's own comment for which of discardedBurst's rules
|
|
17972
|
+
// this reuses and which of them cannot apply here.
|
|
17973
|
+
dropDiscardedBurst();
|
|
17974
|
+
dropDiscardedRawEdit();
|
|
17975
|
+
discardedWaveEdit = {
|
|
17976
|
+
blockId: seam.blockId,
|
|
17977
|
+
blockStartLine: seam.blockStartLine, blockEndLine: seam.blockEndLine,
|
|
17978
|
+
startLine: seam.startLine, endLine: seam.endLine,
|
|
17979
|
+
source: seam.baseSource, painted: painted,
|
|
17980
|
+
};
|
|
17981
|
+
}
|
|
17982
|
+
// Work the file could not hold goes with the session it belonged to.
|
|
17983
|
+
// The banner that said so is deliberately NOT dismissed here: it is the
|
|
17984
|
+
// only record the user has that something did not survive.
|
|
17985
|
+
waveUnwrittenEdit = false;
|
|
17986
|
+
setDirty();
|
|
17987
|
+
// No render. `.content` was never repainted while the overlay was up, so
|
|
17988
|
+
// it is still showing exactly the base document just restored — rendering
|
|
17989
|
+
// would be a round trip to say nothing.
|
|
17990
|
+
return;
|
|
17991
|
+
}
|
|
17992
|
+
if (seam.unwritten) {
|
|
17993
|
+
// Kept, with a hole in it. The user is told once more on the way out,
|
|
17994
|
+
// because the status line they were reading has just been removed from
|
|
17995
|
+
// the screen along with the rest of the dialog.
|
|
17996
|
+
showBanner(
|
|
17997
|
+
'這次波形編輯有一個改動沒辦法寫回檔案,它沒有留下來 —— 檔案裡是其餘' +
|
|
17998
|
+
'寫得回去的部分(這個區塊的內容在第 ' + seam.startLine + '–' +
|
|
17999
|
+
seam.endLine + ' 行)。',
|
|
18000
|
+
null, null);
|
|
18001
|
+
waveUnwrittenEdit = false;
|
|
18002
|
+
setDirty();
|
|
18003
|
+
}
|
|
18004
|
+
if (seam.ops === 0) return;
|
|
18005
|
+
// fix 1 (F2): `blocks` is settled from the session's own arithmetic BEFORE
|
|
18006
|
+
// the render, not instead of it. A successful render overwrites it with the
|
|
18007
|
+
// server's own map a moment later; a FAILED one — which this path
|
|
18008
|
+
// deliberately does not roll back — leaves this value standing instead of a
|
|
18009
|
+
// map addressing a document that no longer exists. It also closes the
|
|
18010
|
+
// window in which the overlay is reopened while the settling render is
|
|
18011
|
+
// still in flight, which reads `blocks` through waveBodyOf().
|
|
18012
|
+
settleWaveBlocks(seam);
|
|
18013
|
+
// The one render this session pays for. `.content` has been lagging
|
|
18014
|
+
// `lines` since the first gesture; this is where it catches up.
|
|
18015
|
+
// fix 1 (F7): the delta is the session's real line-count change, not 0.
|
|
18016
|
+
// Advisory today (patchmap() reads only its truthiness), wrong regardless.
|
|
18017
|
+
const editRange = { startLine: seam.startLine, endLine: seam.endLine,
|
|
18018
|
+
delta: lines.length - seam.baseLines.length };
|
|
18019
|
+
const ok = await safeRerenderAll(editRange);
|
|
18020
|
+
if (!ok) {
|
|
18021
|
+
// Deliberately NOT rollbackFailedRender(). Every other commit in this
|
|
18022
|
+
// file rolls back a failed render because the edit it is undoing is one
|
|
18023
|
+
// keystroke; this one can be ten minutes of drawing, and rerenderAll()'s
|
|
18024
|
+
// failure is a round trip to /api/render, not a refusal of the edit. The
|
|
18025
|
+
// work stays in `lines` — dirty, saveable with Ctrl+S, and recoverable by
|
|
18026
|
+
// an ordinary Ctrl+Z — and the banner is corrected, because
|
|
18027
|
+
// rerenderAll()'s own 'Your edit was not applied' would be false here.
|
|
18028
|
+
//
|
|
18029
|
+
// What makes that safe is settleWaveBlocks() above: keeping the work is
|
|
18030
|
+
// only defensible while `blocks` still addresses the document that holds
|
|
18031
|
+
// it. Losing a session is bad; silently corrupting the document the user
|
|
18032
|
+
// keeps editing afterwards is worse, and before fix 1 this path risked
|
|
18033
|
+
// both at once.
|
|
18034
|
+
showBanner(
|
|
18035
|
+
'畫面沒有重畫成功,但你畫的波形還在文件裡(按 Ctrl+S 可以存檔,' +
|
|
18036
|
+
'Ctrl+Z 可以撤銷)。', null, null);
|
|
18037
|
+
}
|
|
18038
|
+
}
|
|
18039
|
+
|
|
18040
|
+
/**
|
|
18041
|
+
* Ctrl+Z straight after an Escape out of the wave editor: put the discarded
|
|
18042
|
+
* drawing back and re-open the editor over it.
|
|
18043
|
+
*
|
|
18044
|
+
* The restore is an ordinary commit, not a special case — so the reopened
|
|
18045
|
+
* session opens over a document that already holds the drawing, one more
|
|
18046
|
+
* Ctrl+Z steps back over the restore itself, and Ctrl+S saves it. That is
|
|
18047
|
+
* the difference from restoreDiscardedRawEdit(), whose stashed value lives
|
|
18048
|
+
* in a textarea until a later blur commits it: there is no "uncommitted
|
|
18049
|
+
* surface" shape available here, because the wave editor's surface IS the
|
|
18050
|
+
* document.
|
|
18051
|
+
*
|
|
18052
|
+
* Staleness is asked at the `lines`/`blocks` level, the same level
|
|
18053
|
+
* restoreDiscardedRawEdit() asks at and for the same reason — the overlay
|
|
18054
|
+
* was removed from the DOM by `close()`, so there is no node whose presence
|
|
18055
|
+
* could answer it.
|
|
18056
|
+
*/
|
|
18057
|
+
async function restoreDiscardedWaveEdit() {
|
|
18058
|
+
const d = discardedWaveEdit;
|
|
18059
|
+
discardedWaveEdit = null;
|
|
18060
|
+
if (!d) return false;
|
|
18061
|
+
const b = blocks.find((x) => x.id === d.blockId);
|
|
18062
|
+
if (!b || b.startLine !== d.blockStartLine || b.endLine !== d.blockEndLine) return false;
|
|
18063
|
+
if (lines.slice(d.startLine - 1, d.endLine).join('\n') !== d.source) return false;
|
|
18064
|
+
const prevLines = lines;
|
|
18065
|
+
const result = commitRangeEdit({ lines, blocks, stack },
|
|
18066
|
+
d.startLine, d.endLine, d.painted);
|
|
18067
|
+
if (result.op === null) return false;
|
|
18068
|
+
lines = result.lines;
|
|
18069
|
+
// `blocks` deliberately not reassigned — the same shape every other
|
|
18070
|
+
// commit-then-render site in this file has: the render that follows is what
|
|
18071
|
+
// replaces it, and rollbackFailedRender() below restores `lines` only, so
|
|
18072
|
+
// an assignment here would survive a failed render as the one value nothing
|
|
18073
|
+
// puts back.
|
|
18074
|
+
setDirty();
|
|
18075
|
+
const ok = await safeRerenderAll({
|
|
18076
|
+
startLine: d.startLine,
|
|
18077
|
+
endLine: d.startLine + d.painted.split('\n').length - 1,
|
|
18078
|
+
delta: result.op.after.length - result.op.before.length,
|
|
18079
|
+
});
|
|
18080
|
+
if (!ok) {
|
|
18081
|
+
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
18082
|
+
setDirty();
|
|
18083
|
+
return false;
|
|
18084
|
+
}
|
|
18085
|
+
// Re-open over the block the render just rebuilt. `openWaveEditor()` wants
|
|
18086
|
+
// something INSIDE the block and resolves the block with `closest()`, and
|
|
18087
|
+
// `closest()` matches the element itself — so the block element is a valid
|
|
18088
|
+
// argument and does not depend on WaveDrom having finished painting a
|
|
18089
|
+
// `.wavedrom-diagram` into it yet.
|
|
18090
|
+
const blockEl = blockElById(d.blockId);
|
|
18091
|
+
if (!blockEl) return true; // the drawing is back in the document either way
|
|
18092
|
+
await openWaveEditor(blockEl);
|
|
18093
|
+
return true;
|
|
18094
|
+
}
|
|
18095
|
+
|
|
18096
|
+
// Test-only seam, the same shape and for the same reason as
|
|
18097
|
+
// `__edTestGetSelection` above: the two invariants this feature has that a
|
|
18098
|
+
// scenario cannot otherwise see — whether the overlay is open, and whether
|
|
18099
|
+
// anything ever reached the seam after it closed. Nothing in the product
|
|
18100
|
+
// calls it.
|
|
18101
|
+
window.__edTestWaveState = function () {
|
|
18102
|
+
return {
|
|
18103
|
+
open: waveEditor !== null,
|
|
18104
|
+
stray: waveStrayGestures,
|
|
18105
|
+
// Task 7's three: whether the document currently believes it has
|
|
18106
|
+
// anything unsaved (the thing the Reload button's beforeunload guard
|
|
18107
|
+
// asks), whether this session has work the file could not hold, and
|
|
18108
|
+
// whether a discarded session is sitting there waiting for a Ctrl+Z.
|
|
18109
|
+
dirty: documentIsDirty(),
|
|
18110
|
+
unwritten: waveUnwrittenEdit,
|
|
18111
|
+
stashed: discardedWaveEdit !== null,
|
|
18112
|
+
seam: waveSeam === null ? null : {
|
|
18113
|
+
blockId: waveSeam.blockId,
|
|
18114
|
+
startLine: waveSeam.startLine,
|
|
18115
|
+
endLine: waveSeam.endLine,
|
|
18116
|
+
gestured: waveSeam.lastGesture !== null,
|
|
18117
|
+
ops: waveSeam.ops,
|
|
18118
|
+
},
|
|
18119
|
+
};
|
|
18120
|
+
};
|
|
18121
|
+
|
|
18122
|
+
// fix 1 (F2): whether the block map still addresses the document. A failed
|
|
18123
|
+
// close render used to leave the two disagreeing by the session's line delta,
|
|
18124
|
+
// and the only way a scenario could see it was to make an ordinary edit
|
|
18125
|
+
// afterwards and read the corrupted file — a real assertion, but one that
|
|
18126
|
+
// cannot say WHICH of the two moved. Nothing in the product calls this.
|
|
18127
|
+
window.__edTestBlockSpan = function (id) {
|
|
18128
|
+
const b = blocks.find((x) => x.id === id);
|
|
18129
|
+
return b === undefined ? null : {
|
|
18130
|
+
startLine: b.startLine, endLine: b.endLine, docLines: lines.length,
|
|
18131
|
+
text: lines.slice(b.startLine - 1, b.endLine).join('\n'),
|
|
18132
|
+
};
|
|
18133
|
+
};
|
|
18134
|
+
|
|
18135
|
+
waveEditBtn.addEventListener('click', () => {
|
|
18136
|
+
if (waveDiagramEl === null) return;
|
|
18137
|
+
// The only way this rejects is a programming error — the `.content` mount
|
|
18138
|
+
// guard, which throws by design, or a store that threw where it promised
|
|
18139
|
+
// not to. Reported rather than swallowed, and NOT rethrown: an unhandled
|
|
18140
|
+
// rejection out of a click handler is a page-level error, and the state
|
|
18141
|
+
// this needs to keep consistent (`waveEditor` / `waveSeam`) is already
|
|
18142
|
+
// unwound above.
|
|
18143
|
+
openWaveEditor(waveDiagramEl).catch((err) => {
|
|
18144
|
+
console.error('md2doc: the wave editor could not open', err);
|
|
18145
|
+
});
|
|
18146
|
+
});
|
|
18147
|
+
|
|
14952
18148
|
// v3.1.0 §4: last, so every const it closes over (stack, BLOCK_SKELETONS,
|
|
14953
18149
|
// selToolbar, …) is initialised and the bar's first deriveState() sees the
|
|
14954
18150
|
// armed document rather than an empty one.
|