@helping-ai-workflow/md2doc 3.13.0 → 3.13.1

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.
@@ -1110,7 +1110,9 @@
1110
1110
  let switching = null;
1111
1111
  function switchAwayFrom() {
1112
1112
  if (switching) return switching;
1113
- switching = resolveOpenSession().finally(() => { switching = null; });
1113
+ // v3.13.1 T1: a held-key replay that waited on this resolution resumes
1114
+ // once it is over (scheduleFlightFlush() is a no-op with nothing held).
1115
+ switching = resolveOpenSession().finally(() => { switching = null; scheduleFlightFlush(); });
1114
1116
  return switching;
1115
1117
  }
1116
1118
 
@@ -1685,6 +1687,11 @@
1685
1687
  // through explicitly. Passed positionally here and through
1686
1688
  // applyRenderResult() rather than folded into `j` (which is the
1687
1689
  // server's response payload, not render-context the CALLER derived).
1690
+ // v3.13.1 R11: the selection right before the DOM is replaced — a click,
1691
+ // drag or double-click into another block during this request has
1692
+ // already put it there. The focusin re-resolve and the flight replay
1693
+ // restore it, anchor and focus both.
1694
+ lastRenderSel = selectionSnapshotIn(activeFlightSurface());
1688
1695
  return applyRenderResult(j, editRange, scrollY, declaredRange);
1689
1696
  }
1690
1697
 
@@ -2664,14 +2671,457 @@
2664
2671
  // refusal, and commitListStructure()'s no-range exit) pass nothing, which
2665
2672
  // falls back to a full re-render by contract — patch can only compare
2666
2673
  // renderer output, never DOM the user has already touched.
2674
+ //
2675
+ // v3.13.1 T1: every render is also a "commit in flight" for the input guard
2676
+ // below — see `commitFlight`. A render that lands re-points every held
2677
+ // entry's tag at the surface that now holds the same source.
2667
2678
  async function safeRerenderAll(editRange) {
2679
+ const blocksBefore = blocks;
2680
+ const lineMap = pendingFlightLineMap;
2681
+ pendingFlightLineMap = null;
2682
+ beginCommitFlight();
2683
+ let ok = false;
2668
2684
  try {
2669
- return await rerenderAll(editRange);
2685
+ ok = await rerenderAll(editRange);
2686
+ return ok;
2670
2687
  } catch (e) {
2671
2688
  showError('這次修改沒有套用:發生未預期的錯誤(' + describeFailure(e) +
2672
2689
  ')。文件維持修改前的樣子。', null, null);
2673
2690
  return false;
2691
+ } finally {
2692
+ if (ok) reanchorFlightBuffer(blocksBefore, editRange, lineMap);
2693
+ // A failed render: nothing places focus afterwards (commitListStructure()
2694
+ // skips its focusBlockAtLine(), and a failed blur-commit refocuses the
2695
+ // block it could not commit), so the opening run replays where it was
2696
+ // typed. Measured before round 3: edit A, click B@5, `ZZ`, render 500 —
2697
+ // `ZZ` went to the start of A.
2698
+ else pinFirstFlightRun(null);
2699
+ endCommitFlight();
2700
+ }
2701
+ }
2702
+
2703
+ // ── v3.13.1 T1: the commit-in-flight input guard ──────────────────────
2704
+ // A commit awaits /api/render and then rebuilds the surfaces it covers from
2705
+ // `lines`. Anything typed into a surface while that request is out lands in
2706
+ // DOM the render is about to throw away. Measured before this (audit
2707
+ // 2026-10-10): Enter then an immediate `type('new')` in a tight list lost
2708
+ // every character in Chromium and wrote an extra empty `-` item; with the
2709
+ // render held 800 ms, 100% loss in both engines.
2710
+ //
2711
+ // While a flight is active (flightActive(): a commit in flight, or held
2712
+ // entries still waiting to replay), what is typed into an armed surface is held in
2713
+ // `flightBuffer` instead of reaching the DOM — text, paste, and the keys
2714
+ // that edit or move: Enter, Tab, Backspace/Delete (and their word/line
2715
+ // chords), arrows, Home/End. Escape drops everything held (R12: Escape
2716
+ // means "throw away what I have not committed", in flight or not). Once
2717
+ // the render has landed and its caller has placed focus, the entries are
2718
+ // replayed in order, each through the path the live input would have taken
2719
+ // (ruling R13): text through the browser's own insertText (which replaces a
2720
+ // selection and rebalances whitespace; insertTextAtCaret() is the fallback),
2721
+ // paste through routePaste(), keys as a keydown through the
2722
+ // document handler — so ↑/↓ can still cross blocks and a table cell still
2723
+ // owns its keys — and, when no handler claims the key, its native default
2724
+ // (execCommand deletion, Selection.modify movement).
2725
+ //
2726
+ // WHERE an entry replays (fix rounds 1-3):
2727
+ // * A "continuation" entry (pos === null) replays at the selection the
2728
+ // replay has reached — wherever focus is once the caller placed it, or
2729
+ // wherever an earlier replayed key moved it (a held ArrowDown that walks
2730
+ // to the next block takes the keys typed after it there). A held key
2731
+ // never moves the DOM selection; a pointer press does, so the press
2732
+ // first PINS the run of continuation entries that opened the flight to
2733
+ // the selection it is about to move away from (pinFirstFlightRun(); a
2734
+ // failed render pins it too, since then no caller places focus). Every
2735
+ // other run already starts at a positioned entry. Measured before round
2736
+ // 3: click B@5, `xy`, click Omega, `Z` replayed `xy` at B's start.
2737
+ // * A "positioned" entry is the first one held after a pointer press into
2738
+ // a surface during the flight: it carries a tag (surface, block id,
2739
+ // table cell index) and the full selection (anchor and focus offsets),
2740
+ // restored before it replays. Offsets are shifted past the held text
2741
+ // that will be inserted before them in the same surface by the time it
2742
+ // replays (heldShiftBefore()). The tag is re-pointed when a render
2743
+ // replaces its surface: by the committed run's own line map for an item
2744
+ // of a list run the commit rewrote (an item the split pushed down a
2745
+ // line, the provisional item), otherwise by its block's start line,
2746
+ // shifted by the commit's line delta when it lay below the edited range.
2747
+ // Measured before round 2: Enter, `ab`, click `charlie`, `Z` saved
2748
+ // `- abZ` with charlie untouched.
2749
+ // * A pointer press during the flight with nothing typed after it becomes
2750
+ // a { kind: 'focus' } entry, so the click is not lost to the commit's
2751
+ // own focusBlockAtLine().
2752
+ //
2753
+ // A failed render keeps the buffer too: rerenderAll() leaves `.content`
2754
+ // untouched on failure, every surface is still connected, and the replay
2755
+ // lands where it was typed.
2756
+ let commitFlight = 0;
2757
+ const flightBuffer = [];
2758
+ // A pointer press into a surface during a flight: the next held entry is
2759
+ // positioned (see above).
2760
+ let flightSelMoved = false;
2761
+ // Set while an entry replays: the replay's own keydown / insertText must
2762
+ // not be held again.
2763
+ let flightReplaying = false;
2764
+ function flightActive() {
2765
+ return !flightReplaying && (commitFlight > 0 || flightBuffer.length > 0);
2766
+ }
2767
+ // The selection right before the latest render replaced the DOM (R11).
2768
+ let lastRenderSel = null;
2769
+ // Where continuation entries go when the render took focus away and no
2770
+ // caller put it anywhere (e.g. a Ctrl+S commit of the focused block).
2771
+ let flightFallback = null;
2772
+ // commitListStructure() -> safeRerenderAll(): the run's line map.
2773
+ let pendingFlightLineMap = null;
2774
+ const FLIGHT_DROPPED_MESSAGE = '重新整理期間打的字沒有地方放,已略過';
2775
+
2776
+ function beginCommitFlight() {
2777
+ commitFlight++;
2778
+ }
2779
+
2780
+ // Not a synchronous decrement. The caller's continuation after
2781
+ // `await safeRerenderAll(...)` — focusBlockAtLine(), an arrow walk's
2782
+ // landing — is a microtask, so it runs before this timer and focus is
2783
+ // placed before the replay. Keys that arrive between the render landing
2784
+ // and the timer are still held, behind the earlier ones.
2785
+ function endCommitFlight() {
2786
+ setTimeout(() => {
2787
+ commitFlight--;
2788
+ if (!commitFlight) flushFlightBuffer();
2789
+ }, 0);
2790
+ }
2791
+
2792
+ // A replay that had to wait for a focus change resumes from here (the
2793
+ // focusin handlers call it once their burst is open).
2794
+ function scheduleFlightFlush() {
2795
+ if (flightBuffer.length && !commitFlight) setTimeout(flushFlightBuffer, 0);
2796
+ }
2797
+
2798
+ function flightSurfaceOf(node) {
2799
+ const el = node && (node.nodeType === 1 ? node : node.parentElement);
2800
+ return el && el.closest ? el.closest('.ed-wys-armed, .ed-wys-cell') : null;
2801
+ }
2802
+ function activeFlightSurface() {
2803
+ return flightSurfaceOf(document.activeElement);
2804
+ }
2805
+
2806
+ // Selection points as character offsets inside a surface, counted over
2807
+ // text nodes only — the same count pointInSurface() walks back.
2808
+ function offsetInSurface(surface, node, offset) {
2809
+ const r = document.createRange();
2810
+ r.selectNodeContents(surface);
2811
+ r.setEnd(node, offset);
2812
+ return r.toString().length;
2813
+ }
2814
+ function pointInSurface(surface, offset) {
2815
+ let left = offset;
2816
+ let lastText = null;
2817
+ const walker = document.createTreeWalker(surface, NodeFilter.SHOW_TEXT);
2818
+ for (let t = walker.nextNode(); t; t = walker.nextNode()) {
2819
+ if (left <= t.data.length) return [t, left];
2820
+ left -= t.data.length;
2821
+ lastText = t;
2822
+ }
2823
+ return lastText ? [lastText, lastText.data.length] : [surface, 0];
2824
+ }
2825
+ // { surface, anchor, focus } for the selection in `surface`; offsets are
2826
+ // null when the selection is not inside it.
2827
+ function selectionSnapshotIn(surface) {
2828
+ if (!surface) return null;
2829
+ const sel = window.getSelection();
2830
+ if (!sel.rangeCount || !surface.contains(sel.anchorNode) || !surface.contains(sel.focusNode)) {
2831
+ return { surface, anchor: null, focus: null };
2832
+ }
2833
+ return { surface,
2834
+ anchor: offsetInSurface(surface, sel.anchorNode, sel.anchorOffset),
2835
+ focus: offsetInSurface(surface, sel.focusNode, sel.focusOffset) };
2836
+ }
2837
+ function restoreSelection(surface, anchor, focus) {
2838
+ if (anchor === null || anchor === undefined) return;
2839
+ const a = pointInSurface(surface, anchor);
2840
+ const f = pointInSurface(surface, focus === null || focus === undefined ? anchor : focus);
2841
+ window.getSelection().setBaseAndExtent(a[0], a[1], f[0], f[1]);
2842
+ }
2843
+
2844
+ function flightTagOf(snap) {
2845
+ if (!snap || !snap.surface) return null;
2846
+ const blockEl = snap.surface.closest('.ed-block');
2847
+ const isCell = snap.surface.classList.contains('ed-wys-cell');
2848
+ return {
2849
+ surface: snap.surface,
2850
+ blockId: blockEl ? blockEl.getAttribute('data-block-id') : null,
2851
+ cellIndex: isCell && blockEl
2852
+ ? Array.prototype.indexOf.call(blockEl.querySelectorAll('.ed-wys-cell'), snap.surface) : null,
2853
+ anchor: snap.anchor, focus: snap.focus,
2854
+ };
2855
+ }
2856
+
2857
+ // The committed run's lines after the commit: the serializer's own
2858
+ // line -> block map (`lineMeta`, one entry per emitted line), keyed by the
2859
+ // block ids the run had before it. The provisional item has no id.
2860
+ function flightLineMapOf(lineMeta, startLine) {
2861
+ const map = { ids: {}, provisional: null };
2862
+ (lineMeta || []).forEach((m, k) => {
2863
+ if (m.blank) return;
2864
+ if (m.blockId === null || m.blockId === undefined) {
2865
+ if (map.provisional === null) map.provisional = startLine + k;
2866
+ } else if (!(m.blockId in map.ids)) {
2867
+ map.ids[m.blockId] = startLine + k;
2868
+ }
2869
+ });
2870
+ return map;
2871
+ }
2872
+
2873
+ // How far held text will have pushed `offset` in `surface` by the time an
2874
+ // entry positioned there replays: the text of every run pinned (or, for
2875
+ // the opening run, typed) in the same surface at or before that offset.
2876
+ function heldShiftBefore(surface, offset) {
2877
+ let pin = null;
2878
+ let shift = 0;
2879
+ flightBuffer.forEach((en, i) => {
2880
+ if (en.pos || i === 0) pin = en.pos || en.hint;
2881
+ if (en.kind !== 'text' || !pin || pin.surface !== surface || pin.anchor === null) return;
2882
+ if (Math.min(pin.anchor, pin.focus === null ? pin.anchor : pin.focus) <= offset) shift += en.data.length;
2883
+ });
2884
+ return shift;
2885
+ }
2886
+ function shiftedTag(tag) {
2887
+ if (!tag || tag.anchor === null) return tag;
2888
+ tag.anchor += heldShiftBefore(tag.surface, tag.anchor);
2889
+ if (tag.focus !== null) tag.focus += heldShiftBefore(tag.surface, tag.focus);
2890
+ return tag;
2891
+ }
2892
+
2893
+ // Pins the run of continuation entries that opened this flight (if it is
2894
+ // still unpinned) to `snap`, or to its own first entry's hint — where it
2895
+ // was typed — when there is no usable selection.
2896
+ function pinFirstFlightRun(snap) {
2897
+ const first = flightBuffer[0];
2898
+ if (!first || first.pos) return;
2899
+ const tag = snap && snap.anchor !== null ? flightTagOf(snap) : null;
2900
+ first.pos = tag || (first.hint ? Object.assign({}, first.hint) : null);
2901
+ }
2902
+
2903
+ function holdFlightEntry(surface, entry) {
2904
+ entry.pos = null;
2905
+ entry.hint = flightTagOf(selectionSnapshotIn(surface));
2906
+ if (flightSelMoved) {
2907
+ flightSelMoved = false;
2908
+ entry.pos = shiftedTag(flightTagOf(selectionSnapshotIn(surface)));
2909
+ }
2910
+ const last = flightBuffer[flightBuffer.length - 1];
2911
+ if (entry.kind === 'text' && !entry.pos && last && last.kind === 'text') {
2912
+ last.data += entry.data;
2913
+ return;
2914
+ }
2915
+ flightBuffer.push(entry);
2916
+ }
2917
+
2918
+ // After a render lands: re-point every tag whose surface it replaced.
2919
+ function reanchorFlightBuffer(before, editRange, lineMap) {
2920
+ if (flightSelMoved && lastRenderSel) {
2921
+ flightSelMoved = false;
2922
+ flightBuffer.push({ kind: 'focus', pos: shiftedTag(flightTagOf(lastRenderSel)) });
2923
+ }
2924
+ flightFallback = lastRenderSel && !lastRenderSel.surface.isConnected
2925
+ ? flightTagOf(lastRenderSel) : null;
2926
+ const remap = (pos) => {
2927
+ if (!pos) return;
2928
+ if (pos.surface && pos.surface.isConnected) {
2929
+ const own = pos.surface.closest('.ed-block');
2930
+ pos.blockId = own ? own.getAttribute('data-block-id') : pos.blockId;
2931
+ return;
2932
+ }
2933
+ let line = null;
2934
+ if (lineMap) {
2935
+ if (pos.blockId === null) line = lineMap.provisional;
2936
+ else if (pos.blockId in lineMap.ids) line = lineMap.ids[pos.blockId];
2937
+ }
2938
+ if (line === null && pos.blockId !== null) {
2939
+ const rec = (before || []).find((b) => b.id === Number(pos.blockId));
2940
+ if (rec) {
2941
+ line = rec.startLine;
2942
+ if (editRange && line > editRange.endLine) line += editRange.delta;
2943
+ }
2944
+ }
2945
+ pos.surface = null;
2946
+ if (line === null) return;
2947
+ const nb = blocks.find((b) => b.startLine === line);
2948
+ const nEl = nb ? document.querySelector('.ed-block[data-block-id="' + nb.id + '"]') : null;
2949
+ if (!nEl) return;
2950
+ let surf = pos.cellIndex !== null && pos.cellIndex !== undefined
2951
+ ? nEl.querySelectorAll('.ed-wys-cell')[pos.cellIndex] || null
2952
+ : blockContentEl(nEl);
2953
+ if (surf && !surf.matches('.ed-wys-armed, .ed-wys-cell')) surf = null;
2954
+ pos.surface = surf;
2955
+ pos.blockId = String(nb.id);
2956
+ };
2957
+ flightBuffer.forEach((en) => { remap(en.pos); remap(en.hint); });
2958
+ remap(flightFallback);
2959
+ }
2960
+
2961
+ // A deletion chord's input type, and a caret chord's movement, per
2962
+ // platform (Ctrl on Linux/Windows, Alt and Cmd on a Mac).
2963
+ const FLIGHT_IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform);
2964
+ function deletionForKey(e) {
2965
+ const word = FLIGHT_IS_MAC ? (e.altKey && !e.metaKey && !e.ctrlKey) : (e.ctrlKey && !e.metaKey && !e.altKey);
2966
+ const line = FLIGHT_IS_MAC && e.metaKey && !e.altKey && !e.ctrlKey;
2967
+ const plain = !e.ctrlKey && !e.metaKey && !e.altKey;
2968
+ if (e.key === 'Backspace') {
2969
+ return plain ? 'deleteContentBackward' : word ? 'deleteWordBackward' : line ? 'deleteSoftLineBackward' : null;
2970
+ }
2971
+ if (e.key === 'Delete') {
2972
+ return plain ? 'deleteContentForward' : word ? 'deleteWordForward' : line ? 'deleteSoftLineForward' : null;
2973
+ }
2974
+ return null;
2975
+ }
2976
+ function caretMoveForKey(e) {
2977
+ const word = FLIGHT_IS_MAC ? (e.altKey && !e.metaKey && !e.ctrlKey) : (e.ctrlKey && !e.metaKey && !e.altKey);
2978
+ const plain = !e.ctrlKey && !e.metaKey && !e.altKey;
2979
+ const extend = !!e.shiftKey;
2980
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
2981
+ if (!plain && !word) return null;
2982
+ return { dir: e.key === 'ArrowLeft' ? 'backward' : 'forward', unit: word ? 'word' : 'character', extend };
2983
+ }
2984
+ if (!plain) return null;
2985
+ if (e.key === 'Home' || e.key === 'End') return { dir: e.key === 'Home' ? 'backward' : 'forward', unit: 'lineboundary', extend };
2986
+ if (e.key === 'ArrowUp' || e.key === 'ArrowDown') return { dir: e.key === 'ArrowUp' ? 'backward' : 'forward', unit: 'line', extend };
2987
+ return null;
2988
+ }
2989
+ function isHeldTyping(en) {
2990
+ return en.kind === 'text' || en.kind === 'paste' || en.kind === 'delete' ||
2991
+ (en.kind === 'key' && !caretMoveForKey(en));
2992
+ }
2993
+ function dropHeldTyping() {
2994
+ const kept = [];
2995
+ let carry = null;
2996
+ flightBuffer.forEach((en) => {
2997
+ if (isHeldTyping(en)) {
2998
+ if (en.pos) carry = en.pos;
2999
+ return;
3000
+ }
3001
+ if (en.pos) carry = null;
3002
+ else if (carry) { en.pos = carry; carry = null; }
3003
+ kept.push(en);
3004
+ });
3005
+ flightBuffer.length = 0;
3006
+ kept.forEach((en) => flightBuffer.push(en));
3007
+ }
3008
+ function flightHoldsKey(e) {
3009
+ if ((e.key === 'Enter' || e.key === 'Tab') && !e.ctrlKey && !e.metaKey && !e.altKey) return true;
3010
+ return !!(deletionForKey(e) || caretMoveForKey(e));
3011
+ }
3012
+
3013
+ // The native default of a deletion, against the restored selection: a
3014
+ // selection is deleted whole; a collapsed caret deletes one character, or
3015
+ // is first extended by a word / to the line boundary.
3016
+ function nativeFlightDeletion(t, target) {
3017
+ const sel = window.getSelection();
3018
+ if (!sel.rangeCount) return;
3019
+ const back = /Backward$/.test(t);
3020
+ const unit = /Word/.test(t) ? 'word' : (/Line/.test(t) ? 'lineboundary' : null);
3021
+ if (sel.isCollapsed && unit) {
3022
+ if (!sel.modify) return;
3023
+ sel.modify('extend', back ? 'backward' : 'forward', unit);
3024
+ // Nothing to delete in that direction: a collapsed 'delete' would take
3025
+ // a character instead.
3026
+ if (sel.isCollapsed) return;
3027
+ }
3028
+ snapBurstIfActive(target, 'replay-pre');
3029
+ document.execCommand(sel.isCollapsed && !back ? 'forwardDelete' : 'delete');
3030
+ snapBurstIfActive(target, 'replay');
3031
+ }
3032
+
3033
+ function replayFlightEntry(item, target) {
3034
+ if (item.kind === 'text') {
3035
+ // The browser's own insertText, not insertTextAtCaret(): it replaces a
3036
+ // selection the same way, and it also rebalances the whitespace around
3037
+ // the caret the way live typing does (measured: a held Ctrl+Backspace
3038
+ // leaves `ab` + NBSP, and only the native insertion of the next `e`
3039
+ // turns that back into `ab e`).
3040
+ snapBurstIfActive(target, 'replay-pre');
3041
+ if (/[\r\n]/.test(item.data) || !document.execCommand('insertText', false, item.data)) {
3042
+ insertTextAtCaret(item.data);
3043
+ }
3044
+ snapBurstIfActive(target, 'replay');
3045
+ } else if (item.kind === 'paste') {
3046
+ plainPasteArmed = item.plainOnly;
3047
+ routePaste(target, item.dt);
3048
+ } else if (item.kind === 'delete') {
3049
+ nativeFlightDeletion(item.inputType, target);
3050
+ } else if (item.kind === 'key') {
3051
+ const ev = new KeyboardEvent('keydown', { key: item.key, shiftKey: item.shiftKey,
3052
+ ctrlKey: item.ctrlKey, altKey: item.altKey, metaKey: item.metaKey, bubbles: true, cancelable: true });
3053
+ target.dispatchEvent(ev);
3054
+ if (ev.defaultPrevented) return;
3055
+ const del = deletionForKey(item);
3056
+ if (del) { nativeFlightDeletion(del, target); return; }
3057
+ const move = caretMoveForKey(item);
3058
+ const sel = window.getSelection();
3059
+ if (move && sel.modify) sel.modify(move.extend ? 'extend' : 'move', move.dir, move.unit);
3060
+ }
3061
+ }
3062
+
3063
+ function flushFlightBuffer() {
3064
+ if (commitFlight || switching) return;
3065
+ let dropped = false;
3066
+ // Replay in order. A replayed key, or the focus change to a positioned
3067
+ // entry's surface, can start a new commit (or wait on one); the rest
3068
+ // waits and resumes from its endCommitFlight() or the focusin handlers.
3069
+ while (flightBuffer.length && !commitFlight && !switching) {
3070
+ const item = flightBuffer[0];
3071
+ let target = null;
3072
+ if (item.pos) {
3073
+ target = item.pos.surface && item.pos.surface.isConnected ? item.pos.surface : null;
3074
+ if (!target) { flightBuffer.shift(); dropped = true; continue; }
3075
+ if (activeFlightSurface() !== target) {
3076
+ target.focus();
3077
+ if (commitFlight || switching) break;
3078
+ }
3079
+ restoreSelection(target, item.pos.anchor, item.pos.focus);
3080
+ } else {
3081
+ target = activeFlightSurface();
3082
+ const fb = flightFallback;
3083
+ if (!target && fb && fb.surface && fb.surface.isConnected) {
3084
+ fb.surface.focus();
3085
+ if (commitFlight || switching) break;
3086
+ restoreSelection(fb.surface, fb.anchor, fb.focus);
3087
+ flightFallback = null;
3088
+ target = activeFlightSurface();
3089
+ }
3090
+ if (!target) { flightBuffer.shift(); dropped = true; continue; }
3091
+ }
3092
+ flightBuffer.shift();
3093
+ if (item.kind !== 'focus') {
3094
+ flightReplaying = true;
3095
+ try { replayFlightEntry(item, target); } finally { flightReplaying = false; }
3096
+ }
2674
3097
  }
3098
+ if (!flightBuffer.length && !commitFlight) flightFallback = null;
3099
+ if (dropped) showNotice(FLIGHT_DROPPED_MESSAGE);
3100
+ }
3101
+
3102
+ // An IME composition cannot be cancelled (its beforeinput is not
3103
+ // cancelable), so the committed text is already in the DOM when
3104
+ // compositionend reports it. It is taken back out when it sits right
3105
+ // before the caret, which leaves the buffer its only owner — a render that
3106
+ // keeps this surface (a failed one, or a patch that does not cover it)
3107
+ // would otherwise show it twice once the replay lands. Returns false when
3108
+ // it cannot be found there; the caller holds it anyway, and the render
3109
+ // that replaces the surface is what removes the DOM copy.
3110
+ function takeBackComposedText(data) {
3111
+ const sel = window.getSelection();
3112
+ if (!data || !sel.rangeCount) return false;
3113
+ const r = sel.getRangeAt(0);
3114
+ if (!r.collapsed || r.startContainer.nodeType !== 3) return false;
3115
+ const node = r.startContainer;
3116
+ const start = r.startOffset - data.length;
3117
+ if (start < 0 || node.data.slice(start, r.startOffset) !== data) return false;
3118
+ node.deleteData(start, data.length);
3119
+ const c = document.createRange();
3120
+ c.setStart(node, start);
3121
+ c.collapse(true);
3122
+ sel.removeAllRanges();
3123
+ sel.addRange(c);
3124
+ return true;
2675
3125
  }
2676
3126
 
2677
3127
  function autoSize(ta) {
@@ -7579,11 +8029,19 @@
7579
8029
  commitMd = runMd;
7580
8030
  commitStart = range.startLine;
7581
8031
  commitEnd = range.endLine;
8032
+ // v3.13.1 T1 fix round 3: the whole run is rewritten — including any
8033
+ // provisional item still in it, which then gets a line of its own and
8034
+ // pushes the items after it down one. Keys held for those items find
8035
+ // them by this map (see flightLineMapOf()). Measured without it: a
8036
+ // failed Enter render, then `ab` replayed into the new item, then the
8037
+ // blur commit of this run — `Z` held for charlie landed in `ab`.
8038
+ pendingFlightLineMap = flightLineMapOf(lineMeta, range.startLine);
7582
8039
  }
7583
8040
  const liCommitResult = (commitMd === '')
7584
8041
  ? commitRangeRemoval({ lines, blocks, stack }, commitStart, commitEnd)
7585
8042
  : commitRangeEdit({ lines, blocks, stack }, commitStart, commitEnd, commitMd);
7586
8043
  if (liCommitResult.op === null) {
8044
+ pendingFlightLineMap = null;
7587
8045
  endBurstWithoutResolve();
7588
8046
  return true;
7589
8047
  }
@@ -7637,7 +8095,13 @@
7637
8095
  // commit '#'.repeat(depth) + ' ' with nothing after the space. Every
7638
8096
  // non-heading burst's `depth` is null (blockDepthOf() only computes it for
7639
8097
  // 'heading'), so it takes the plain result.md branch.
7640
- const newText = burst.depth === null ? result.md :
8098
+ // v3.13.1 T2 (F-1): a paragraph's text is escaped where it would read as
8099
+ // a block marker (`## `, `- `, `> `, `1. ` …), or the next render turns
8100
+ // the paragraph into that block. A heading's text sits after its own
8101
+ // `# ` and needs none; a table's rows START with `|`, so its markdown
8102
+ // must never reach the escape — hence the blockType test, not just depth.
8103
+ const newText = burst.depth === null
8104
+ ? (burst.blockType === 'paragraph' ? inlineMd.escapeBlockStarts(result.md) : result.md) :
7641
8105
  (result.md === '' ? '#'.repeat(burst.depth) : '#'.repeat(burst.depth) + ' ' + result.md);
7642
8106
  // S1 removed the pre-per-li "a whole-list burst that serialized to ''"
7643
8107
  // branch that lived here alongside the dead 'list' serializer arm above.
@@ -8354,7 +8818,7 @@
8354
8818
  async function commitListStructure(runEls, focusStartLine, caretToEnd, opts) {
8355
8819
  const span = runEls || [];
8356
8820
  const presetRange = opts && opts.presetRange;
8357
- const { md } = serializeListEls(span, { carryOver: (opts && opts.carryOver) || null });
8821
+ const { md, lineMeta } = serializeListEls(span, { carryOver: (opts && opts.carryOver) || null });
8358
8822
  // The run's line range is read back off its own li blocks' ids — which
8359
8823
  // requires at least one li to still BE there. A caller whose mutation
8360
8824
  // removed the run's last item therefore captures the range BEFORE mutating
@@ -8383,6 +8847,10 @@
8383
8847
  endLine: Math.max(result.op.startLine - 1, result.op.endLine),
8384
8848
  delta: result.op.after.length - result.op.before.length }
8385
8849
  : undefined;
8850
+ // v3.13.1 T1 fix round 2: where each item of the run will start once this
8851
+ // commit lands, so a key held for an item of this very run — one the
8852
+ // split pushed down a line, or the provisional item itself — finds it.
8853
+ if (result.op !== null) pendingFlightLineMap = flightLineMapOf(lineMeta, range.startLine);
8386
8854
  const ok = await safeRerenderAll(editRange);
8387
8855
  if (!ok) {
8388
8856
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
@@ -9131,6 +9599,28 @@
9131
9599
  //
9132
9600
  // Returns the new block element, or null when the caret is not inside
9133
9601
  // `blockEl`'s own surface (nothing mutated).
9602
+ // v3.13.1 T1: the item splitListItemAtCaret() just built is a plain div
9603
+ // until the commit's render replaces it — not editable, so the caret stayed
9604
+ // in the OLD item and anything typed before the render landed went there.
9605
+ // Arm it the way armEditables() arms an li (the same two writes), focus it
9606
+ // and put the caret at its start, where the render's own
9607
+ // focusBlockAtLine() will put it again.
9608
+ // The focusin this raises starts no burst: Enter's burst is still current
9609
+ // here, so that handler's `if (currentBurst) return;` returns.
9610
+ function armProvisionalLi(newLi) {
9611
+ const textEl = liTextEl(newLi);
9612
+ if (!textEl) return;
9613
+ textEl.setAttribute('contenteditable', 'true');
9614
+ textEl.classList.add('ed-wys-armed');
9615
+ textEl.focus();
9616
+ const range = document.createRange();
9617
+ range.selectNodeContents(textEl);
9618
+ range.collapse(true);
9619
+ const sel = window.getSelection();
9620
+ sel.removeAllRanges();
9621
+ sel.addRange(range);
9622
+ }
9623
+
9134
9624
  function splitListItemAtCaret(blockEl) {
9135
9625
  const sel = window.getSelection();
9136
9626
  if (!sel.rangeCount) return null;
@@ -10018,7 +10508,14 @@
10018
10508
  // splitListItemAtCaret() keeps `li` itself in place (only `newLi` is a
10019
10509
  // freshly-inserted sibling) and never touches the focused node's
10020
10510
  // attachment.
10021
- const newLi = mutateListRun(() => splitListItemAtCaret(li));
10511
+ // v3.13.1 T1: the new item is armed and focused inside the same span, so
10512
+ // the old item's focusout stays suppressed and the next keystroke is
10513
+ // already aimed at the new item while the commit below is in flight.
10514
+ const newLi = mutateListRun(() => {
10515
+ const n = splitListItemAtCaret(li);
10516
+ if (n) armProvisionalLi(n);
10517
+ return n;
10518
+ });
10022
10519
  if (!newLi) return true;
10023
10520
  run = listRunOf(newLi);
10024
10521
  // `li` had its text CUT IN TWO in the DOM; replaying its source would put
@@ -17550,6 +18047,38 @@
17550
18047
  e.target.classList.contains('ed-raw');
17551
18048
  if (inTextarea) return; // Ctrl+Enter/Esc handled by openRawEditor()'s own per-instance listener
17552
18049
 
18050
+ // v3.13.1 T1: a key that edits or moves, pressed while a commit is in
18051
+ // flight, would act on DOM the render is about to replace. Held, in order
18052
+ // with the text around it, and replayed by flushFlightBuffer() through
18053
+ // this same handler (see `commitFlight`). An IME's own keys (isComposing)
18054
+ // are left alone. Escape drops everything held (R12) and then carries on
18055
+ // to its usual meaning. A standing table cell range keeps its own keys —
18056
+ // its Delete clears the range, which a held character delete would not.
18057
+ if (flightActive() && !e.isComposing && !cellRangeBurst()) {
18058
+ const flightSurface = flightSurfaceOf(e.target);
18059
+ if (flightSurface && e.key === 'Escape') { flightBuffer.length = 0; flightSelMoved = false; }
18060
+ // Ctrl/Cmd+Z with keys held: the most recent thing to undo is that
18061
+ // typing, which never reached the document — dropping it IS the undo,
18062
+ // and the commit in flight stays (the next Ctrl+Z, with nothing held,
18063
+ // undoes it as usual). Measured before: the undo reverted the Enter
18064
+ // underneath the held keys, and they replayed into the next item
18065
+ // (`abccharlie`). Held navigation (and a click's focus entry) is not
18066
+ // typing and stays; a dropped entry's position passes to the next kept
18067
+ // entry of its run.
18068
+ if (flightSurface && flightBuffer.some(isHeldTyping) && (e.ctrlKey || e.metaKey) && !e.shiftKey &&
18069
+ !e.altKey && (e.key === 'z' || e.key === 'Z')) {
18070
+ e.preventDefault();
18071
+ dropHeldTyping();
18072
+ return;
18073
+ }
18074
+ if (flightSurface && flightHoldsKey(e)) {
18075
+ e.preventDefault();
18076
+ holdFlightEntry(flightSurface, { kind: 'key', key: e.key, shiftKey: e.shiftKey,
18077
+ ctrlKey: e.ctrlKey, altKey: e.altKey, metaKey: e.metaKey });
18078
+ return;
18079
+ }
18080
+ }
18081
+
17553
18082
  // Task 5: a table cell (class 'ed-wys-cell') owns its own Enter/Tab/
17554
18083
  // Escape/Ctrl+Z/Ctrl+Y contract, materially different from paragraph/
17555
18084
  // heading/list (Enter is an UNCONDITIONAL <br>, Tab moves the active
@@ -17790,7 +18319,7 @@
17790
18319
  // top of each listener below — see handleTableCellFocusIn() above.
17791
18320
  document.addEventListener('focusin', async (e) => {
17792
18321
  const cellEl = e.target && e.target.closest && e.target.closest('.ed-wys-cell');
17793
- if (cellEl) { await handleTableCellFocusIn(cellEl); return; }
18322
+ if (cellEl) { await handleTableCellFocusIn(cellEl); scheduleFlightFlush(); return; }
17794
18323
  const editEl = e.target && e.target.closest && e.target.closest('.ed-wys-armed');
17795
18324
  if (!editEl) return;
17796
18325
  if (currentBurst && currentBurst.editEl === editEl) return; // already tracking
@@ -17801,7 +18330,17 @@
17801
18330
  // switchAwayFrom()'s single-flight `switching`. Await it before
17802
18331
  // starting a new burst so the two never race a concurrent commit.
17803
18332
  if (switching) await switching;
17804
- if (currentBurst) return; // a concurrent focusin already won the race
18333
+ // v3.13.1 T1 fix round 2: focus may have moved on while this waited — an
18334
+ // arrow walk lands on the NEXT block from inside that same resolution, and
18335
+ // its own focusin is queued behind this one. Starting a burst here would
18336
+ // open it on a surface that no longer has focus and leave the one that
18337
+ // does without one: measured on base and head, Enter, `ab`, ArrowDown, `Z`
18338
+ // put `Z` in charlie's DOM, and the blur that followed committed nothing.
18339
+ if (document.body.contains(editEl) && !editEl.contains(document.activeElement)) {
18340
+ scheduleFlightFlush();
18341
+ return;
18342
+ }
18343
+ if (currentBurst) { scheduleFlightFlush(); return; } // a concurrent focusin already won the race
17805
18344
  let liveEditEl = editEl;
17806
18345
  if (!document.body.contains(editEl)) {
17807
18346
  // The awaited resolution above committed successfully and swapped the
@@ -17813,9 +18352,19 @@
17813
18352
  liveEditEl = liveBlockEl ? blockContentEl(liveBlockEl) : null;
17814
18353
  if (!liveEditEl || !liveEditEl.classList.contains('ed-wys-armed')) return;
17815
18354
  liveEditEl.focus();
18355
+ // v3.13.1 R11: focus() alone puts the caret at the surface's start, not
18356
+ // where the click that brought focus here put it. Measured on base and
18357
+ // head, both engines: edit A, click mid-B, type — the characters landed
18358
+ // at B's start; a drag or double-click selection collapsed. The
18359
+ // selection was read just before the render replaced B (lastRenderSel).
18360
+ if (lastRenderSel && lastRenderSel.surface === editEl) {
18361
+ restoreSelection(liveEditEl, lastRenderSel.anchor, lastRenderSel.focus);
18362
+ }
18363
+ scheduleFlightFlush();
17816
18364
  return;
17817
18365
  }
17818
18366
  startBurst(liveEditEl);
18367
+ scheduleFlightFlush();
17819
18368
  });
17820
18369
 
17821
18370
  document.addEventListener('focusout', (e) => {
@@ -17956,6 +18505,79 @@
17956
18505
  return currentBurst.history;
17957
18506
  }
17958
18507
 
18508
+ // v3.13.1 T1: a pointer press into a surface during a flight moves the
18509
+ // selection the user means the next held key for (see `flightSelMoved`).
18510
+ // A press anywhere else — the toolbar, the margin — is not one.
18511
+ // Capture phase: this runs before the press moves the selection, which is
18512
+ // what pins the opening run to where it was typed (see pinFirstFlightRun()).
18513
+ document.addEventListener('pointerdown', (e) => {
18514
+ if (!flightActive()) return;
18515
+ pinFirstFlightRun(selectionSnapshotIn(activeFlightSurface()));
18516
+ if (flightSurfaceOf(e.target)) flightSelMoved = true;
18517
+ }, true);
18518
+
18519
+ // v3.13.1 T1: the input half of the commit-in-flight guard (see
18520
+ // `commitFlight`). Registered ahead of the native-format listener below,
18521
+ // both in the capture phase, so a held keystroke never reaches it.
18522
+ //
18523
+ // `isComposing` insertText is skipped: an engine that reports a
18524
+ // composition's commit that way also reports it to compositionend below,
18525
+ // and the text must be held once.
18526
+ function flightSurfaceOfEvent(e) {
18527
+ return flightActive() ? flightSurfaceOf(e.target) : null;
18528
+ }
18529
+ // The text compositionend just held. An engine that inserts a
18530
+ // composition's commit AFTER compositionend (WebKit's
18531
+ // insertFromComposition) would put it in the DOM a second time; that
18532
+ // insertion is cancelled, or taken back out on its input event when it
18533
+ // cannot be cancelled. Cleared on the next task: the follow-up arrives in
18534
+ // the same one or not at all.
18535
+ let composedHeld = null;
18536
+ let composedTakeBack = null;
18537
+ document.addEventListener('beforeinput', (e) => {
18538
+ if (composedHeld !== null && e.data === composedHeld &&
18539
+ (e.inputType === 'insertFromComposition' || e.inputType === 'insertText')) {
18540
+ composedHeld = null;
18541
+ if (e.cancelable) e.preventDefault(); else composedTakeBack = e.data;
18542
+ return;
18543
+ }
18544
+ const surface = flightSurfaceOfEvent(e);
18545
+ if (!surface) return;
18546
+ const t = e.inputType;
18547
+ if ((t === 'insertText' || t === 'insertReplacementText') && !e.isComposing) {
18548
+ // insertReplacementText (autocorrect, spellcheck) carries its text in
18549
+ // dataTransfer in some engines and in `data` in others.
18550
+ const data = e.data != null ? e.data
18551
+ : (e.dataTransfer && e.dataTransfer.getData('text/plain')) || '';
18552
+ e.preventDefault();
18553
+ if (data) holdFlightEntry(surface, { kind: 'text', data });
18554
+ return;
18555
+ }
18556
+ // A deletion that did not come from a held key (the keyboard ones are
18557
+ // held at keydown — WebKit fires no beforeinput at all for Backspace on
18558
+ // an empty surface, measured): a context-menu delete, say.
18559
+ if (/^delete/.test(t) && t !== 'deleteByCut' && t !== 'deleteByDrag' && t !== 'deleteCompositionText') {
18560
+ e.preventDefault();
18561
+ holdFlightEntry(surface, { kind: 'delete', inputType: t });
18562
+ }
18563
+ }, true);
18564
+ document.addEventListener('input', (e) => {
18565
+ if (composedTakeBack !== null && e.data === composedTakeBack) takeBackComposedText(e.data);
18566
+ composedTakeBack = null;
18567
+ }, true);
18568
+ // Known limit: a composition still open when the flight ends is left to
18569
+ // the browser — its compositionend arrives with no flight to hold it, and
18570
+ // if the render replaced the surface mid-composition, what the engine does
18571
+ // with the rest of it is its own.
18572
+ document.addEventListener('compositionend', (e) => {
18573
+ const surface = flightSurfaceOfEvent(e);
18574
+ if (!surface || !e.data) return;
18575
+ takeBackComposedText(e.data);
18576
+ holdFlightEntry(surface, { kind: 'text', data: e.data });
18577
+ composedHeld = e.data;
18578
+ setTimeout(() => { composedHeld = null; }, 0);
18579
+ }, true);
18580
+
17959
18581
  // Capture phase, matching the phase the ordering above was measured in.
17960
18582
  document.addEventListener('beforeinput', (e) => {
17961
18583
  if (!NATIVE_FORMAT_INPUT_TYPES.has(e.inputType)) return;
@@ -18022,10 +18644,35 @@
18022
18644
  // wave field is not — this is here so the boundary is one predicate rather
18023
18645
  // than a property of which targets those two selectors happen to match.
18024
18646
  if (waveEditorIsModal()) return;
18025
- const cellEl = e.target && e.target.closest && e.target.closest('.ed-wys-cell');
18026
- if (cellEl) {
18647
+ // v3.13.1 T1 fix round 1: a paste while a commit is in flight is held like
18648
+ // typing (measured before: lost outright) and replayed through
18649
+ // routePaste(), the path a live paste takes. The clipboard is readable
18650
+ // only during the event, so its flavours are copied out here.
18651
+ const flightSurface = flightSurfaceOfEvent(e);
18652
+ if (flightSurface) {
18027
18653
  e.preventDefault();
18028
- const dt = e.clipboardData || window.clipboardData;
18654
+ holdFlightEntry(flightSurface, { kind: 'paste',
18655
+ dt: snapshotClipboard(e.clipboardData || window.clipboardData), plainOnly: plainPasteArmed });
18656
+ plainPasteArmed = false;
18657
+ return;
18658
+ }
18659
+ const target = e.target && e.target.closest &&
18660
+ (e.target.closest('.ed-wys-cell') || e.target.closest('.ed-wys-armed'));
18661
+ if (!target) return;
18662
+ e.preventDefault();
18663
+ routePaste(target, e.clipboardData || window.clipboardData);
18664
+ });
18665
+ function snapshotClipboard(dt) {
18666
+ const data = {};
18667
+ ['text/plain', 'text/html'].forEach((k) => {
18668
+ try { data[k] = dt ? dt.getData(k) || '' : ''; } catch (err) { data[k] = ''; }
18669
+ });
18670
+ const files = dt && dt.files ? Array.prototype.slice.call(dt.files) : [];
18671
+ return { files, types: Object.keys(data).filter((k) => data[k]), getData: (k) => data[k] || '' };
18672
+ }
18673
+ function routePaste(target, dt) {
18674
+ const cellEl = target.closest('.ed-wys-cell');
18675
+ if (cellEl) {
18029
18676
  const grid = dt ? clipboardGrid(dt) : null;
18030
18677
  if (grid && pasteGridIntoTable(cellEl, grid)) return;
18031
18678
  snapBurstIfActive(cellEl.closest('table'), 'paste-pre');
@@ -18033,10 +18680,8 @@
18033
18680
  snapBurstIfActive(cellEl.closest('table'), 'paste');
18034
18681
  return;
18035
18682
  }
18036
- const editEl = e.target && e.target.closest && e.target.closest('.ed-wys-armed');
18683
+ const editEl = target.closest('.ed-wys-armed');
18037
18684
  if (!editEl) return;
18038
- e.preventDefault();
18039
- const dt = e.clipboardData || window.clipboardData;
18040
18685
  // v3.1.0 追加 2: paste-md.js decides what the clipboard actually holds
18041
18686
  // (text/html -> markdown, an image, or plain text) and this routes the
18042
18687
  // answer. The TABLE-CELL branch above deliberately keeps its plain-text
@@ -18044,7 +18689,7 @@
18044
18689
  // pasted into one cell would be a nested structure the serializer has no
18045
18690
  // way to represent.
18046
18691
  handleArmedPaste(dt, editEl);
18047
- });
18692
+ }
18048
18693
 
18049
18694
  // Task 5: hover-edge insert bubbles — one delegated, rAF-throttled
18050
18695
  // mousemove listener (never a per-block/per-boundary listener) drives