@helping-ai-workflow/md2doc 3.6.1 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -720,6 +720,7 @@
720
720
  const indentClamp = window.md2docIndentClamp;
721
721
  const convertMd = window.md2docConvertMd;
722
722
  const selectionLib = window.md2docSelection;
723
+ const cellRangeLib = window.md2docCellRange;
723
724
  const historyLib = window.md2docHistory;
724
725
  // v3.1.0 Task E: the four Phase-1 modules, injected by server.js ahead of
725
726
  // this file. Read the same way every earlier module is — off `window`, not
@@ -5370,6 +5371,9 @@
5370
5371
  const BATCH_GAP_MESSAGE = '選取範圍不連續,無法整批操作';
5371
5372
  const BATCH_MIXED_MESSAGE = '選取範圍同時含有清單項目與其他區塊,無法整批操作';
5372
5373
  const BATCH_MULTIRUN_MESSAGE = '選取範圍跨越兩個清單,無法整批操作';
5374
+ // 刪除's own answer to a mixed span: it accepts one whose lists are all
5375
+ // covered whole (see listsCoveredWhole()), so its refusal names the fix.
5376
+ const BATCH_PARTIAL_LIST_DELETE_MESSAGE = '刪除時清單只選到一部分:請把整個清單選進來,或只選清單項目';
5373
5377
  // S4 Task 5: the ⠿ drag's cross-boundary refusals, one per CAUSE.
5374
5378
  //
5375
5379
  // Task 3 raised all of these with a single provisional constant
@@ -5451,7 +5455,9 @@
5451
5455
  // splice. A span holding BOTH is neither: the run's survivors have to be
5452
5456
  // re-emitted at the same time as a line range outside the run is removed, and
5453
5457
  // the blank-line policy at the seam between them has no ruling in the spec.
5454
- // Refused with its own banner rather than guessed at.
5458
+ // Refused with its own banner rather than guessed at -- except by 刪除 when
5459
+ // every list is covered whole, where no survivor exists and the seam
5460
+ // question never arises (see listsCoveredWhole()).
5455
5461
  function spanListKinds(els) {
5456
5462
  const kinds = els.map((el) => el.getAttribute('data-block-type'));
5457
5463
  return {
@@ -5461,6 +5467,20 @@
5461
5467
  };
5462
5468
  }
5463
5469
 
5470
+ // True when every li in `els` belongs to a run (listRunOf(): the outermost
5471
+ // run plus every descendant) whose whole line range lies inside the span
5472
+ // `recs` covers. A nested child is part of its parent's run, so a span that
5473
+ // stops at the parent is partial.
5474
+ function listsCoveredWhole(els, recs) {
5475
+ const lo = recs[0].startLine;
5476
+ const hi = recs[recs.length - 1].endLine;
5477
+ return els.every((el) => {
5478
+ if (el.getAttribute('data-block-type') !== 'li') return true;
5479
+ const range = runRangeOfBlocks({ lines, blocks, stack }, listRunOf(el));
5480
+ return !!range && range.startLine >= lo && range.endLine <= hi;
5481
+ });
5482
+ }
5483
+
5464
5484
  // Every list batch is one run's problem: listRunOf() is the span the commit
5465
5485
  // re-serializes, and a member outside it would be rewritten by a range that
5466
5486
  // does not cover it. Contiguity in `blocks` does not imply one run — two
@@ -6184,7 +6204,15 @@
6184
6204
  const els = operands.els;
6185
6205
  const recs = operands.recs;
6186
6206
  const shape = spanListKinds(els);
6187
- if (shape.anyLi && !shape.allLi) { refuseStructuralListEdit(BATCH_MIXED_MESSAGE); return; }
6207
+ // A mixed span is deletable exactly when it covers every list it touches
6208
+ // WHOLE: nothing of those lists survives, so there is no run to renumber
6209
+ // or clamp, and the span is one contiguous line range like any non-list
6210
+ // span -- the commitRangeRemoval() branch below, one undo op. A list
6211
+ // covered only in PART would need its survivors re-serialized in the same
6212
+ // commit as the non-list removal, which is still refused.
6213
+ if (shape.anyLi && !shape.allLi && !listsCoveredWhole(els, recs)) {
6214
+ refuseStructuralListEdit(BATCH_PARTIAL_LIST_DELETE_MESSAGE); return;
6215
+ }
6188
6216
  // Fix round 1, item 2: the block immediately ABOVE the span, read out of
6189
6217
  // the PRE-commit `blocks` and carried to the wrapper. It cannot be
6190
6218
  // recovered afterwards from the hole's coordinate — see
@@ -6892,14 +6920,17 @@
6892
6920
  // clone re-serialises the class attribute joined by single spaces — byte-
6893
6921
  // identical to what the renderer emitted — and an attribute left empty is
6894
6922
  // dropped outright (the renderer never emits `class=""`).
6923
+ // .ed-cell-range (the cell-range paint) is stripped for the same reason.
6895
6924
  function burstBaselineHtml(editEl) {
6896
6925
  const html = editEl.innerHTML;
6897
- if (html.indexOf('ed-te-hl') === -1 && html.indexOf('ed-te-row-dragging') === -1) return html;
6926
+ if (html.indexOf('ed-te-hl') === -1 && html.indexOf('ed-te-row-dragging') === -1 &&
6927
+ html.indexOf('ed-cell-range') === -1) return html;
6898
6928
  const clone = editEl.cloneNode(true);
6899
- const marked = clone.querySelectorAll('.ed-te-hl, .ed-te-row-dragging');
6929
+ const marked = clone.querySelectorAll('.ed-te-hl, .ed-te-row-dragging, .ed-cell-range');
6900
6930
  Array.prototype.forEach.call(marked, (el) => {
6901
6931
  el.classList.remove('ed-te-hl');
6902
6932
  el.classList.remove('ed-te-row-dragging');
6933
+ el.classList.remove('ed-cell-range');
6903
6934
  if (!el.className) el.removeAttribute('class');
6904
6935
  });
6905
6936
  return clone.innerHTML;
@@ -6969,6 +7000,7 @@
6969
7000
  hideTableGrips();
6970
7001
  hideTableEdgeMenu();
6971
7002
  cancelTeDrag();
7003
+ clearCellRange();
6972
7004
  // S4 Task 2: and an in-flight ⠿ block drag, for the same reason — this
6973
7005
  // resolution is about to commit and re-render whichever block it holds an
6974
7006
  // ordinal into.
@@ -7471,6 +7503,11 @@
7471
7503
  setBlockSelection({ anchorLine: anchorLine, focusLine: focusLine });
7472
7504
  };
7473
7505
  window.__edTestClearSelection = function () { clearBlockSelection(); };
7506
+ window.__edTestCaretAtEdge = function (dir) {
7507
+ const a = document.activeElement;
7508
+ const surface = a && a.closest && a.closest('.ed-wys-armed, .ed-wys-cell');
7509
+ return surface ? caretAtEdge(surface, dir) : null;
7510
+ };
7474
7511
  window.__edTestGetSelection = function () {
7475
7512
  if (!blockSelection) return null;
7476
7513
  const members = selectionLib ? selectionLib.membersOf(blockSelection, blocks) : [];
@@ -8425,6 +8462,24 @@
8425
8462
  // meant here (heading depth / list indent). Set BEFORE the li dispatch
8426
8463
  // below so both surfaces see the same cleared mark.
8427
8464
  if (e.key !== 'Tab' && e.key !== 'Shift') currentBurst.arrivedByTab = false;
8465
+ // Arrow walk: ↑ on the first line / ↓ on the last line crosses into the
8466
+ // neighbouring block. Anywhere else — and with any modifier, a
8467
+ // non-collapsed selection or an IME composition — the browser's own move.
8468
+ if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && !e.shiftKey && !e.ctrlKey &&
8469
+ !e.altKey && !e.metaKey && !e.isComposing) {
8470
+ const dir = e.key === 'ArrowUp' ? -1 : 1;
8471
+ if (caretAtEdge(editEl, dir < 0 ? 'up' : 'down')) {
8472
+ const fromBlockEl = editEl.closest('.ed-block');
8473
+ const step = (el) => (dir > 0 ? el.nextElementSibling : el.previousElementSibling);
8474
+ let probe = fromBlockEl ? step(fromBlockEl) : null;
8475
+ while (probe && !probe.classList.contains('ed-block')) probe = step(probe);
8476
+ if (probe) {
8477
+ e.preventDefault();
8478
+ walkToAdjacentBlockByArrow(fromBlockEl, dir, caretClientX());
8479
+ }
8480
+ return;
8481
+ }
8482
+ }
8428
8483
  // Task 8 (Phase 4): per-li burst — Enter / Shift+Enter / Tab / Shift+Tab
8429
8484
  // are owned by handleLiKeydown() below (spec §4's key semantics for li
8430
8485
  // surfaces, acceptance rows 1, 3, 5, 6, 7, 8). Every other key (Escape,
@@ -9724,6 +9779,656 @@
9724
9779
  await walkToAdjacentBlockByTab(tableEl ? tableEl.closest('.ed-block') : null, delta);
9725
9780
  }
9726
9781
 
9782
+ // ── Arrow-key navigation across blocks and table cells ──────────────────
9783
+ // Spec: docs/superpowers/specs/2026-09-23-editor-arrow-nav-design.md.
9784
+ // Every block is its own contenteditable, so the native caret cannot cross
9785
+ // into a neighbour; these helpers do that crossing once the caret is at the
9786
+ // text boundary, and leave every other arrow press to the browser.
9787
+
9788
+ // The x a walk aims for when it starts from a SELECTED block, which has no
9789
+ // caret to read one from: the x of the last walk that had one.
9790
+ let arrowGoalX = null;
9791
+
9792
+ function collapsedCaretRangeIn(surface) {
9793
+ const sel = window.getSelection();
9794
+ if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null;
9795
+ const r = sel.getRangeAt(0);
9796
+ return surface.contains(r.startContainer) ? r : null;
9797
+ }
9798
+
9799
+ // First line's top and last line's bottom, from the surface's own line
9800
+ // boxes. Null for a surface with nothing that paints (empty).
9801
+ function surfaceLineBounds(surface) {
9802
+ const all = document.createRange();
9803
+ all.selectNodeContents(surface);
9804
+ const rects = Array.prototype.filter.call(all.getClientRects(), (r) => r.height > 0);
9805
+ if (!rects.length) return null;
9806
+ let top = Infinity;
9807
+ let bottom = -Infinity;
9808
+ rects.forEach((r) => { top = Math.min(top, r.top); bottom = Math.max(bottom, r.bottom); });
9809
+ return { top: top, bottom: bottom,
9810
+ firstH: rects[0].height, lastH: rects[rects.length - 1].height };
9811
+ }
9812
+
9813
+ // A collapsed range's rect. A caret at an ELEMENT offset (right after a
9814
+ // <br>, which is where Shift+Enter leaves it) has no rect of its own, so
9815
+ // the character beside it is measured instead: the one after it when that
9816
+ // is text on the same line, else the one before it. Null when neither
9817
+ // side is text (an empty line between <br>s, or an empty surface).
9818
+ function caretRectOf(range) {
9819
+ const rects = range.getClientRects();
9820
+ if (rects.length && rects[0].height > 0) return rects[0];
9821
+ const b = range.getBoundingClientRect();
9822
+ if (b.height > 0) return b;
9823
+ const node = range.startContainer;
9824
+ const at = range.startOffset;
9825
+ const charRect = (text, i) => {
9826
+ const r = document.createRange();
9827
+ r.setStart(text, i);
9828
+ r.setEnd(text, i + 1);
9829
+ const cr = r.getClientRects();
9830
+ return cr.length && cr[0].height > 0 ? cr[0] : null;
9831
+ };
9832
+ if (node.nodeType === Node.TEXT_NODE) {
9833
+ if (at < node.data.length) return charRect(node, at);
9834
+ return at > 0 ? charRect(node, at - 1) : null;
9835
+ }
9836
+ const next = node.childNodes[at];
9837
+ if (next && next.nodeType === Node.TEXT_NODE && next.data.length) return charRect(next, 0);
9838
+ const prev = node.childNodes[at - 1];
9839
+ if (prev && prev.nodeType === Node.TEXT_NODE && prev.data.length) {
9840
+ return charRect(prev, prev.data.length - 1);
9841
+ }
9842
+ return null;
9843
+ }
9844
+
9845
+ function caretClientX() {
9846
+ const sel = window.getSelection();
9847
+ if (!sel || sel.rangeCount === 0) return null;
9848
+ const rect = caretRectOf(sel.getRangeAt(0));
9849
+ return rect ? rect.left : null;
9850
+ }
9851
+
9852
+ // Whether the collapsed caret in `surface` is on its first line ('up'),
9853
+ // last line ('down'), at its start ('left') or at its end ('right'). False
9854
+ // for a non-collapsed selection or a caret outside `surface`. A caret with
9855
+ // no rect falls back to the text test, which is right for the one shape
9856
+ // that produces it (an empty line, whose only neighbours are <br>s).
9857
+ function rangeAtEdge(surface, r, dir) {
9858
+ const before = document.createRange();
9859
+ before.setStart(surface, 0);
9860
+ before.setEnd(r.startContainer, r.startOffset);
9861
+ const after = document.createRange();
9862
+ after.setStart(r.startContainer, r.startOffset);
9863
+ after.setEnd(surface, surface.childNodes.length);
9864
+ const atStart = before.toString() === '';
9865
+ const atEnd = after.toString() === '';
9866
+ if (dir === 'left') return atStart;
9867
+ if (dir === 'right') return atEnd;
9868
+ const bounds = surfaceLineBounds(surface);
9869
+ const cr = caretRectOf(r);
9870
+ if (!bounds || !cr) return dir === 'up' ? atStart : atEnd;
9871
+ const half = cr.height / 2;
9872
+ return dir === 'up' ? cr.top - bounds.top < half : bounds.bottom - cr.bottom < half;
9873
+ }
9874
+
9875
+ function caretAtEdge(surface, dir) {
9876
+ const r = collapsedCaretRangeIn(surface);
9877
+ return r ? rangeAtEdge(surface, r, dir) : false;
9878
+ }
9879
+
9880
+ // The same edge test for the SELECTION'S FOCUS point, so Shift+Arrow can
9881
+ // tell when a text selection has reached the cell's boundary.
9882
+ function selectionFocusAtEdge(surface, dir) {
9883
+ const sel = window.getSelection();
9884
+ if (!sel || sel.rangeCount === 0 || !surface.contains(sel.focusNode)) return false;
9885
+ const r = document.createRange();
9886
+ r.setStart(sel.focusNode, sel.focusOffset);
9887
+ r.collapse(true);
9888
+ return rangeAtEdge(surface, r, dir);
9889
+ }
9890
+
9891
+ // caretPositionFromPoint() HIT-TESTS, so the target line must be on
9892
+ // screen and not under the fixed .ed-toolbar, or the point resolves to
9893
+ // whatever is painted there instead. With `onLastLine` given, the LINE is
9894
+ // what is brought into reach, not the surface's top: for a surface taller
9895
+ // than the viewport, revealing its top pushes its last line off-screen.
9896
+ // Without it (a selected block, a cell entered by ←/→), the top.
9897
+ function scrollSurfaceIntoReach(surface, onLastLine) {
9898
+ surface.scrollIntoView({ block: 'nearest' });
9899
+ const bar = document.querySelector('.ed-toolbar');
9900
+ const barBottom = bar ? bar.getBoundingClientRect().bottom : 0;
9901
+ const bounds = onLastLine === undefined ? null : surfaceLineBounds(surface);
9902
+ let top;
9903
+ let bottom;
9904
+ if (bounds) {
9905
+ top = onLastLine ? bounds.bottom - bounds.lastH : bounds.top;
9906
+ bottom = onLastLine ? bounds.bottom : bounds.top + bounds.firstH;
9907
+ } else {
9908
+ top = surface.getBoundingClientRect().top;
9909
+ bottom = top;
9910
+ }
9911
+ if (top < barBottom) window.scrollBy(0, top - barBottom - 8);
9912
+ else if (bottom > window.innerHeight) window.scrollBy(0, bottom - window.innerHeight + 8);
9913
+ }
9914
+
9915
+ // Collapsed caret on the surface's first line (or last, `onLastLine`) at
9916
+ // the character nearest `x`. No x, an empty surface, or a point that
9917
+ // resolves outside the surface → the line's start (first) / end (last).
9918
+ function landCaretAtX(surface, x, onLastLine) {
9919
+ scrollSurfaceIntoReach(surface, onLastLine);
9920
+ const fallback = () => {
9921
+ if (onLastLine) placeCaretAtEnd(surface); else placeCaretAtSurfaceStart(surface);
9922
+ };
9923
+ const bounds = surfaceLineBounds(surface);
9924
+ if (!bounds || x === null) { fallback(); return; }
9925
+ const box = surface.getBoundingClientRect();
9926
+ const y = onLastLine ? bounds.bottom - bounds.lastH / 2 : bounds.top + bounds.firstH / 2;
9927
+ const px = Math.min(Math.max(x, box.left + 1), box.right - 1);
9928
+ let node = null;
9929
+ let offset = 0;
9930
+ if (document.caretPositionFromPoint) {
9931
+ const p = document.caretPositionFromPoint(px, y);
9932
+ if (p) { node = p.offsetNode; offset = p.offset; }
9933
+ } else if (document.caretRangeFromPoint) {
9934
+ const rr = document.caretRangeFromPoint(px, y);
9935
+ if (rr) { node = rr.startContainer; offset = rr.startOffset; }
9936
+ }
9937
+ if (!node || !surface.contains(node)) { fallback(); return; }
9938
+ window.getSelection().setBaseAndExtent(node, offset, node, offset);
9939
+ }
9940
+
9941
+ // Puts the caret (or the block selection) on `blockEl` arriving from
9942
+ // direction `delta`: from above (+1) onto its first line, from below (-1)
9943
+ // onto its last.
9944
+ function arrowLandOn(blockEl, delta, x) {
9945
+ const surface = focusableSurfaceOf(blockEl);
9946
+ if (!surface) {
9947
+ // No typing surface (fenced code, blockquote, hr, a degraded block):
9948
+ // stop ON it as a one-block selection. setBlockSelection() moves the
9949
+ // roving focus to its wrapper, which is where the plain-arrow, Enter
9950
+ // and Delete branches of the document keydown pick it up.
9951
+ const rec = blockRecOf(blockEl);
9952
+ if (!rec || rec.endLine < rec.startLine) return;
9953
+ setBlockSelection({ anchorLine: rec.startLine, focusLine: rec.startLine });
9954
+ scrollSurfaceIntoReach(blockEl);
9955
+ return;
9956
+ }
9957
+ const tableEl = blockEl.querySelector('table');
9958
+ if (tableEl && surface.classList.contains('ed-wys-cell')) {
9959
+ // Entering a table: the header row from above, the last row from
9960
+ // below, in the column whose span holds x (nearest centre otherwise).
9961
+ const rows = allRowsOf(tableEl);
9962
+ const row = delta > 0 ? rows[0] : rows[rows.length - 1];
9963
+ const cells = Array.prototype.slice.call(row.cells);
9964
+ const ax = x !== null ? x : arrowGoalX;
9965
+ let cell = cells[0];
9966
+ if (ax !== null) {
9967
+ let best = Infinity;
9968
+ cells.forEach((c) => {
9969
+ const r = c.getBoundingClientRect();
9970
+ const d = ax >= r.left && ax <= r.right ? 0 : Math.abs((r.left + r.right) / 2 - ax);
9971
+ if (d < best) { best = d; cell = c; }
9972
+ });
9973
+ }
9974
+ cell.focus({ preventScroll: true });
9975
+ landCaretAtX(cell, ax, delta < 0);
9976
+ return;
9977
+ }
9978
+ surface.focus({ preventScroll: true });
9979
+ landCaretAtX(surface, x, delta < 0);
9980
+ }
9981
+
9982
+ // All four arrows inside a table cell, once the caret is at that text
9983
+ // boundary. Moving within the table is not a burst switch — the burst
9984
+ // spans the whole table and the cell's own focusin updates activeCellEl —
9985
+ // so nothing commits here. Leaving the table (header ↑, last row ↓) goes
9986
+ // through the block walk, which resolves the burst first.
9987
+ function moveTableCellByArrow(cellEl, key, x) {
9988
+ const tableEl = cellEl.closest('table');
9989
+ if (key === 'ArrowLeft' || key === 'ArrowRight') {
9990
+ const cells = tableCellsOf(tableEl);
9991
+ const i = cells.indexOf(cellEl) + (key === 'ArrowLeft' ? -1 : 1);
9992
+ if (i < 0 || i >= cells.length) return false;
9993
+ cells[i].focus({ preventScroll: true });
9994
+ scrollSurfaceIntoReach(cells[i]);
9995
+ if (key === 'ArrowLeft') placeCaretAtEnd(cells[i]); else placeCaretAtSurfaceStart(cells[i]);
9996
+ return true;
9997
+ }
9998
+ const delta = key === 'ArrowUp' ? -1 : 1;
9999
+ const rows = allRowsOf(tableEl);
10000
+ const r = rows.indexOf(cellEl.parentElement) + delta;
10001
+ if (r < 0 || r >= rows.length) {
10002
+ walkToAdjacentBlockByArrow(tableEl.closest('.ed-block'), delta, x);
10003
+ return true;
10004
+ }
10005
+ const target = rows[r].cells[Math.min(colIndexOf(cellEl), rows[r].cells.length - 1)];
10006
+ target.focus({ preventScroll: true });
10007
+ landCaretAtX(target, x, delta < 0);
10008
+ return true;
10009
+ }
10010
+
10011
+ // ── Table cell-range selection ───────────────────────────────────────────
10012
+ // Spec: docs/superpowers/specs/2026-09-24-editor-cell-range-design.md.
10013
+ // The range lives on the TABLE burst as row/column indices
10014
+ // (currentBurst.cellRange = { anchor: {r, c}, focus: {r, c} }), never as
10015
+ // cell nodes: undo/redo swap the table's innerHTML, and indices survive
10016
+ // that where nodes would not. A range over exactly one cell is no range.
10017
+ // The paint (.ed-cell-range) is stripped by burstBaselineHtml(), so it
10018
+ // never reaches a snapshot or a commit.
10019
+
10020
+ function cellRangeBurst() {
10021
+ return currentBurst && currentBurst.blockType === 'table' && currentBurst.cellRange
10022
+ ? currentBurst : null;
10023
+ }
10024
+
10025
+ function cellPointOf(cellEl) {
10026
+ const tableEl = cellEl && cellEl.closest('table');
10027
+ if (!tableEl) return null;
10028
+ const r = allRowsOf(tableEl).indexOf(cellEl.parentElement);
10029
+ return r < 0 ? null : { r: r, c: colIndexOf(cellEl) };
10030
+ }
10031
+
10032
+ function cellAtPoint(tableEl, p) {
10033
+ const row = allRowsOf(tableEl)[p.r];
10034
+ return row ? (row.cells[Math.min(p.c, row.cells.length - 1)] || null) : null;
10035
+ }
10036
+
10037
+ function cellRangeCells(burst) {
10038
+ const rect = cellRangeLib.normalize(burst.cellRange.anchor, burst.cellRange.focus);
10039
+ return cellRangeLib.cellsIn(rect).map((p) => cellAtPoint(burst.editEl, p)).filter(Boolean);
10040
+ }
10041
+
10042
+ function unpaintCellRange(tableEl) {
10043
+ tableEl.classList.remove('ed-cell-range-active');
10044
+ Array.prototype.forEach.call(tableEl.querySelectorAll('.ed-cell-range'),
10045
+ (el) => el.classList.remove('ed-cell-range'));
10046
+ }
10047
+
10048
+ // The toolbar is normally driven by a non-collapsed native selection; a
10049
+ // range keeps the native caret collapsed, so it is shown over the range.
10050
+ function showSelToolbarForCellRange() {
10051
+ const burst = cellRangeBurst();
10052
+ if (!burst) return;
10053
+ const cells = cellRangeCells(burst);
10054
+ if (!cells.length) return;
10055
+ const r = document.createRange();
10056
+ r.setStartBefore(cells[0]);
10057
+ r.setEndAfter(cells[cells.length - 1]);
10058
+ positionSelToolbar(r);
10059
+ }
10060
+
10061
+ // Re-applies the paint from the state, clamping the state to the table
10062
+ // first (a structural undo can leave fewer rows or columns).
10063
+ function paintCellRange() {
10064
+ const burst = currentBurst && currentBurst.blockType === 'table' ? currentBurst : null;
10065
+ if (!burst) return;
10066
+ unpaintCellRange(burst.editEl);
10067
+ if (!burst.cellRange) return;
10068
+ const rows = allRowsOf(burst.editEl);
10069
+ if (!rows.length) { burst.cellRange = null; return; }
10070
+ const clampP = (p) => {
10071
+ const r = Math.max(0, Math.min(rows.length - 1, p.r));
10072
+ return { r: r, c: Math.max(0, Math.min(rows[r].cells.length - 1, p.c)) };
10073
+ };
10074
+ const anchor = clampP(burst.cellRange.anchor);
10075
+ const focus = clampP(burst.cellRange.focus);
10076
+ if (cellRangeLib.isSingle(anchor, focus)) { burst.cellRange = null; return; }
10077
+ burst.cellRange = { anchor: anchor, focus: focus };
10078
+ burst.editEl.classList.add('ed-cell-range-active');
10079
+ cellRangeCells(burst).forEach((el) => el.classList.add('ed-cell-range'));
10080
+ const focusCell = cellAtPoint(burst.editEl, focus);
10081
+ if (focusCell) {
10082
+ if (document.activeElement !== focusCell) focusCell.focus({ preventScroll: true });
10083
+ placeCaretAtSurfaceStart(focusCell);
10084
+ }
10085
+ showSelToolbarForCellRange();
10086
+ }
10087
+
10088
+ function setCellRange(anchor, focus) {
10089
+ if (!currentBurst || currentBurst.blockType !== 'table' || !anchor || !focus) return;
10090
+ currentBurst.cellRange = { anchor: anchor, focus: focus };
10091
+ paintCellRange();
10092
+ }
10093
+
10094
+ function clearCellRange() {
10095
+ const burst = currentBurst && currentBurst.blockType === 'table' ? currentBurst : null;
10096
+ if (!burst || !burst.cellRange) return;
10097
+ burst.cellRange = null;
10098
+ unpaintCellRange(burst.editEl);
10099
+ hideSelToolbar();
10100
+ }
10101
+
10102
+ // Shift+click on another cell of the table whose burst is open. Shift+click
10103
+ // inside the active cell (with no range yet) stays a native text selection.
10104
+ function beginCellRangeShiftClick(e) {
10105
+ const cellEl = e.target.closest && e.target.closest('.ed-wys-cell');
10106
+ if (!cellEl) return false;
10107
+ const tableEl = cellEl.closest('table');
10108
+ const burst = currentBurst;
10109
+ if (!burst || burst.blockType !== 'table' || burst.editEl !== tableEl) return false;
10110
+ const base = burst.cellRange ? burst.cellRange.anchor
10111
+ : (burst.activeCellEl ? cellPointOf(burst.activeCellEl) : null);
10112
+ const target = cellPointOf(cellEl);
10113
+ if (!base || !target) return false;
10114
+ if (!burst.cellRange && cellRangeLib.isSingle(base, target)) return false;
10115
+ e.preventDefault();
10116
+ clearNativeTextSelection();
10117
+ setCellRange(base, target);
10118
+ return true;
10119
+ }
10120
+
10121
+ // Keys while a range stands. True = handled (the caller returns).
10122
+ function handleCellRangeKeydown(e, cellEl) {
10123
+ const plain = !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey;
10124
+ // Tab / Enter move or edit at the caret, not over the range: clear it and
10125
+ // let the key take its ordinary path. A range left standing would make
10126
+ // the next keystroke clear the RANGE and land in its focus cell.
10127
+ if (e.key === 'Tab' || e.key === 'Enter') { clearCellRange(); return false; }
10128
+ if (e.key === 'Escape') { e.preventDefault(); clearCellRange(); return true; }
10129
+ if (plain && /^Arrow(Up|Down|Left|Right)$/.test(e.key)) {
10130
+ e.preventDefault();
10131
+ clearCellRange();
10132
+ return true;
10133
+ }
10134
+ const RANGE_MARK_KEYS = { b: 'STRONG', i: 'EM', u: 'U' };
10135
+ if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && RANGE_MARK_KEYS[e.key.toLowerCase()]) {
10136
+ e.preventDefault();
10137
+ applyMarkToCellRange(RANGE_MARK_KEYS[e.key.toLowerCase()]);
10138
+ return true;
10139
+ }
10140
+ if (e.key === 'Delete' || e.key === 'Backspace') {
10141
+ e.preventDefault();
10142
+ clearCellRangeContents('range-clear');
10143
+ paintCellRange();
10144
+ return true;
10145
+ }
10146
+ // A printable character: clear the range, collapse to the focus cell and
10147
+ // let the browser insert the character there (no preventDefault).
10148
+ if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey && !e.isComposing) {
10149
+ const burst = cellRangeBurst();
10150
+ const focusCell = cellAtPoint(burst.editEl, burst.cellRange.focus);
10151
+ clearCellRangeContents('range-type', false);
10152
+ clearCellRange();
10153
+ if (focusCell) { focusCell.focus({ preventScroll: true }); placeCaretAtSurfaceStart(focusCell); }
10154
+ return true;
10155
+ }
10156
+ const SHIFT_DIR = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right' };
10157
+ if (e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey && SHIFT_DIR[e.key]) {
10158
+ e.preventDefault();
10159
+ const burst = cellRangeBurst();
10160
+ const rows = allRowsOf(burst.editEl);
10161
+ const focus = cellRangeLib.step(burst.cellRange.focus, SHIFT_DIR[e.key],
10162
+ rows.length, rows[0].cells.length);
10163
+ setCellRange(burst.cellRange.anchor, focus);
10164
+ if (!cellRangeBurst()) hideSelToolbar();
10165
+ return true;
10166
+ }
10167
+ return false;
10168
+ }
10169
+
10170
+ // Drag across cells. Armed on every press on a cell body (grips are
10171
+ // chrome: hitTestGrip() runs first and the pointerdown returns before
10172
+ // this). While the pointer stays in the origin cell the native text
10173
+ // selection is untouched; entering another cell of the SAME table starts
10174
+ // the range. Outside the table the block-selection drag owns the gesture.
10175
+ let cellDrag = null;
10176
+
10177
+ function armCellDrag(e) {
10178
+ const cellEl = e.target.closest && e.target.closest('.ed-wys-cell');
10179
+ cellDrag = cellEl
10180
+ ? { pointerId: e.pointerId, originCell: cellEl, tableEl: cellEl.closest('table'), active: false }
10181
+ : null;
10182
+ }
10183
+
10184
+ function updateCellDrag(e) {
10185
+ if (!cellDrag || e.pointerId !== cellDrag.pointerId || tePointer) return;
10186
+ if (typeof e.buttons === 'number' && e.buttons === 0) { cellDrag = null; return; }
10187
+ const overEl = document.elementFromPoint(e.clientX, e.clientY);
10188
+ const overCell = overEl && overEl.closest && overEl.closest('.ed-wys-cell');
10189
+ if (!overCell || overCell.closest('table') !== cellDrag.tableEl) return;
10190
+ if (!cellDrag.active && overCell === cellDrag.originCell) return;
10191
+ const burst = currentBurst;
10192
+ if (!burst || burst.blockType !== 'table' || burst.editEl !== cellDrag.tableEl) return;
10193
+ cellDrag.active = true;
10194
+ e.preventDefault();
10195
+ clearNativeTextSelection();
10196
+ setCellRange(cellPointOf(cellDrag.originCell), cellPointOf(overCell));
10197
+ }
10198
+
10199
+ function endCellDrag() {
10200
+ const st = cellDrag;
10201
+ cellDrag = null;
10202
+ // mouseup can leave a native selection behind; re-collapse onto the focus cell.
10203
+ if (st && st.active) paintCellRange();
10204
+ }
10205
+
10206
+ // A cell's text with its <br>s as newlines — what TSV and a grid paste
10207
+ // mean by "the cell's content".
10208
+ function cellPlainText(el) {
10209
+ const clone = el.cloneNode(true);
10210
+ Array.prototype.forEach.call(clone.querySelectorAll('br'),
10211
+ (br) => br.parentNode.replaceChild(document.createTextNode('\n'), br));
10212
+ return clone.textContent.replace(/\n$/, '');
10213
+ }
10214
+
10215
+ function cellRangeGrid() {
10216
+ const burst = cellRangeBurst();
10217
+ const rect = cellRangeLib.normalize(burst.cellRange.anchor, burst.cellRange.focus);
10218
+ const grid = [];
10219
+ for (let r = rect.top; r <= rect.bottom; r++) {
10220
+ const row = [];
10221
+ for (let c = rect.left; c <= rect.right; c++) {
10222
+ const cell = cellAtPoint(burst.editEl, { r: r, c: c });
10223
+ row.push(cell ? cellPlainText(cell) : '');
10224
+ }
10225
+ grid.push(row);
10226
+ }
10227
+ return grid;
10228
+ }
10229
+
10230
+ function cellRangeHtml() {
10231
+ const burst = cellRangeBurst();
10232
+ const rect = cellRangeLib.normalize(burst.cellRange.anchor, burst.cellRange.focus);
10233
+ let html = '<table>';
10234
+ for (let r = rect.top; r <= rect.bottom; r++) {
10235
+ html += '<tr>';
10236
+ for (let c = rect.left; c <= rect.right; c++) {
10237
+ const cell = cellAtPoint(burst.editEl, { r: r, c: c });
10238
+ html += '<td>' + (cell ? cell.innerHTML : '') + '</td>';
10239
+ }
10240
+ html += '</tr>';
10241
+ }
10242
+ return html + '</table>';
10243
+ }
10244
+
10245
+ // Empties every cell in the range as ONE burst-history step. Typing over
10246
+ // a range passes `snapAfter` false: the clear and the character typed into
10247
+ // the focus cell then land in the same step (the typing debounce snaps
10248
+ // them together), so one Ctrl+Z undoes both.
10249
+ function clearCellRangeContents(reason, snapAfter) {
10250
+ const burst = cellRangeBurst();
10251
+ if (!burst) return;
10252
+ snapBurstIfActive(burst.editEl, reason + '-pre');
10253
+ cellRangeCells(burst).forEach((el) => { el.innerHTML = ''; });
10254
+ if (snapAfter !== false) snapBurstIfActive(burst.editEl, reason);
10255
+ }
10256
+
10257
+ const GRID_PASTE_TOO_BIG_MESSAGE = '貼上的表格太大(上限 500 列 × 50 欄)';
10258
+
10259
+ // A clipboard is a GRID when its text holds a TAB or its HTML holds a
10260
+ // <table>. Anything else — including multi-line text — is not, and keeps
10261
+ // the single-cell paste. The TSV wins when both exist: Excel and Sheets
10262
+ // put both on the clipboard, and their HTML is pretty-printed (line breaks
10263
+ // and indentation inside cells) while their TSV is the cell values.
10264
+ function clipboardGrid(dt) {
10265
+ const text = dt.getData('text/plain');
10266
+ if (text && text.indexOf('\t') !== -1) return cellRangeLib.parseTsv(text);
10267
+ const html = dt.getData('text/html');
10268
+ if (!html || !/<table[\s>]/i.test(html)) return null;
10269
+ const table = new DOMParser().parseFromString(html, 'text/html').querySelector('table');
10270
+ if (!table || !table.rows.length) return null;
10271
+ return Array.prototype.map.call(table.rows, (row) => {
10272
+ const out = [];
10273
+ Array.prototype.forEach.call(row.cells, (cell) => {
10274
+ out.push(htmlCellText(cell));
10275
+ // colspan: the columns a spanning cell covers stay empty, so later
10276
+ // cells keep their own column instead of shifting left.
10277
+ for (let k = 1; k < (cell.colSpan || 1); k++) out.push('');
10278
+ });
10279
+ return out;
10280
+ });
10281
+ }
10282
+
10283
+ // An HTML cell's text as a browser would render it: runs of source
10284
+ // whitespace collapse to one space, <br> is a line break.
10285
+ function htmlCellText(cell) {
10286
+ const clone = cell.cloneNode(true);
10287
+ const walker = document.createTreeWalker(clone, NodeFilter.SHOW_TEXT);
10288
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
10289
+ n.data = n.data.replace(/[ \t\r\n]+/g, ' ');
10290
+ }
10291
+ Array.prototype.forEach.call(clone.querySelectorAll('br'),
10292
+ (br) => br.parentNode.replaceChild(document.createTextNode('\n'), br));
10293
+ return clone.textContent.split('\n').map((s) => s.trim()).join('\n').trim();
10294
+ }
10295
+
10296
+ function setCellText(cell, text) {
10297
+ cell.textContent = '';
10298
+ String(text).split('\n').forEach((part, i) => {
10299
+ if (i > 0) cell.appendChild(document.createElement('br'));
10300
+ if (part) cell.appendChild(document.createTextNode(part));
10301
+ });
10302
+ }
10303
+
10304
+ // Fills the grid from the range's top-left (or the caret's cell), growing
10305
+ // the table first, as ONE burst-history step; the pasted block is then the
10306
+ // range. False when it could not land (no open burst on this table), so the
10307
+ // caller falls back to the single-cell paste instead of eating the paste.
10308
+ function pasteGridIntoTable(cellEl, grid) {
10309
+ const tableEl = cellEl.closest('table');
10310
+ const burst = currentBurst;
10311
+ if (!burst || burst.blockType !== 'table' || burst.editEl !== tableEl || !grid.length) return false;
10312
+ const width = grid.reduce((w, row) => Math.max(w, row.length), 0);
10313
+ if (grid.length > 500 || width > 50) { showBanner(GRID_PASTE_TOO_BIG_MESSAGE); return true; }
10314
+ const origin = burst.cellRange
10315
+ ? (() => { const rc = cellRangeLib.normalize(burst.cellRange.anchor, burst.cellRange.focus);
10316
+ return { r: rc.top, c: rc.left }; })()
10317
+ : cellPointOf(cellEl);
10318
+ if (!origin) return false;
10319
+ const rows = allRowsOf(tableEl);
10320
+ const need = cellRangeLib.growthNeeded(origin, grid, rows.length, rows[0].cells.length);
10321
+ snapBurstIfActive(tableEl, 'grid-paste-pre');
10322
+ for (let i = 0; i < need.addCols; i++) insertColumn(tableEl, headerRowOf(tableEl).cells.length - 1);
10323
+ for (let i = 0; i < need.addRows; i++) {
10324
+ const body = bodyRowsOf(tableEl);
10325
+ insertRow(tableEl, body.length ? body[body.length - 1] : null);
10326
+ }
10327
+ armNewTableCells(tableEl);
10328
+ const all = allRowsOf(tableEl);
10329
+ grid.forEach((row, i) => row.forEach((text, j) => {
10330
+ const target = all[origin.r + i] && all[origin.r + i].cells[origin.c + j];
10331
+ if (target) setCellText(target, text);
10332
+ }));
10333
+ snapBurstIfActive(tableEl, 'grid-paste');
10334
+ setCellRange(origin, { r: origin.r + grid.length - 1, c: origin.c + width - 1 });
10335
+ return true;
10336
+ }
10337
+
10338
+ // A mark over a range applies to each cell's WHOLE content. The state is
10339
+ // "every non-empty cell is wholly inside `tag`": true removes it from every
10340
+ // cell, false wraps every cell (after unwrapping partial marks, so no
10341
+ // nesting). Empty cells are skipped.
10342
+ function applyMarkToCellRange(tag) {
10343
+ const burst = cellRangeBurst();
10344
+ if (!burst) return;
10345
+ const cells = cellRangeCells(burst).filter((el) => el.textContent.trim() !== '');
10346
+ if (!cells.length) return;
10347
+ const wholly = (cell) => {
10348
+ const walker = document.createTreeWalker(cell, NodeFilter.SHOW_TEXT);
10349
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
10350
+ if (n.data.trim() && !closestMarkAncestor(n, tag, cell)) return false;
10351
+ }
10352
+ return true;
10353
+ };
10354
+ const on = !cells.every(wholly);
10355
+ snapBurstIfActive(burst.editEl, 'range-mark-pre');
10356
+ cells.forEach((cell) => {
10357
+ Array.prototype.slice.call(cell.querySelectorAll(tag.toLowerCase()))
10358
+ .forEach((m) => unwrapElement(m));
10359
+ if (on) {
10360
+ // Trimmed to the text, as applyMarkToggle() does: a mark closing
10361
+ // after a space (`**a **`) is not a closing delimiter in markdown.
10362
+ const r = document.createRange();
10363
+ r.selectNodeContents(cell);
10364
+ if (trimRangeToText(r)) wrapRangeIn(r, tag, cell);
10365
+ }
10366
+ });
10367
+ snapBurstIfActive(burst.editEl, 'range-mark');
10368
+ paintCellRange();
10369
+ }
10370
+
10371
+ document.addEventListener('copy', (e) => {
10372
+ if (!cellRangeBurst() || !e.clipboardData) return;
10373
+ e.preventDefault();
10374
+ e.clipboardData.setData('text/plain', cellRangeLib.toTsv(cellRangeGrid()));
10375
+ e.clipboardData.setData('text/html', cellRangeHtml());
10376
+ });
10377
+
10378
+ document.addEventListener('cut', (e) => {
10379
+ if (!cellRangeBurst() || !e.clipboardData) return;
10380
+ e.preventDefault();
10381
+ e.clipboardData.setData('text/plain', cellRangeLib.toTsv(cellRangeGrid()));
10382
+ e.clipboardData.setData('text/html', cellRangeHtml());
10383
+ clearCellRangeContents('range-cut');
10384
+ paintCellRange();
10385
+ });
10386
+
10387
+ // IME: the first composition keystroke behaves like typing a character.
10388
+ document.addEventListener('compositionstart', () => {
10389
+ const burst = cellRangeBurst();
10390
+ if (!burst) return;
10391
+ const focusCell = cellAtPoint(burst.editEl, burst.cellRange.focus);
10392
+ clearCellRangeContents('range-type', false);
10393
+ clearCellRange();
10394
+ if (focusCell) placeCaretAtSurfaceStart(focusCell);
10395
+ }, true);
10396
+
10397
+ // The arrow sibling of walkToAdjacentBlockByTab(), with the same order:
10398
+ // resolve the open burst FIRST (its commit can re-render .content), then
10399
+ // find the destination in the document that exists afterwards.
10400
+ //
10401
+ // The leaving block's commit CAN change the source line count — a table row
10402
+ // inserted inside the burst adds one — which moves the destination's start
10403
+ // line when it lies below, so its identity alone stops re-resolving the
10404
+ // moment the commit was a full render (the first one after a page load
10405
+ // always is). The SOURCE block's own start line does not move under its
10406
+ // own edit, so the destination is re-derived as its neighbour first; the
10407
+ // identity is the fallback for a commit that removed the source block.
10408
+ async function walkToAdjacentBlockByArrow(fromBlockEl, delta, x) {
10409
+ const step = (el) => (delta > 0 ? el.nextElementSibling : el.previousElementSibling);
10410
+ const nextBlock = (el) => {
10411
+ let n = el ? step(el) : null;
10412
+ while (n && !n.classList.contains('ed-block')) n = step(n);
10413
+ return n;
10414
+ };
10415
+ let dest = nextBlock(fromBlockEl);
10416
+ if (!dest) return;
10417
+ const srcStartLine = (blockRecOf(fromBlockEl) || {}).startLine;
10418
+ const destIdentity = captureBlockIdentity(dest);
10419
+ const ok = await switchAwayFrom();
10420
+ if (!ok) return;
10421
+ if (!document.body.contains(dest)) {
10422
+ const srcRec = blocks.find((b) => b.startLine === srcStartLine);
10423
+ const liveSrc = srcRec
10424
+ ? document.querySelector('.ed-block[data-block-id="' + srcRec.id + '"]') : null;
10425
+ dest = (liveSrc && nextBlock(liveSrc)) || reresolveBlockEl(destIdentity);
10426
+ if (!dest) { showBanner(DROPPED_GESTURE_MESSAGE); return; }
10427
+ }
10428
+ if (x !== null) arrowGoalX = x;
10429
+ arrowLandOn(dest, delta, x !== null ? x : arrowGoalX);
10430
+ }
10431
+
9727
10432
  // Esc inside a table burst: revert to the pre-focus baseline and end the
9728
10433
  // burst WITHOUT committing — mirrors revertBurstAndEnd() above, but a
9729
10434
  // table burst has no single always-live element to blur() afterward (the
@@ -9739,6 +10444,7 @@
9739
10444
  // rewrite below fires that synchronous blur, there is no burst left to
9740
10445
  // mistakenly resolve — same "belt and braces" idiom rerenderAll() uses.
9741
10446
  async function revertTableBurstAndEnd(cellEl) {
10447
+ clearCellRange();
9742
10448
  const burst = currentBurst;
9743
10449
  if (!burst || burst.blockType !== 'table' || burst.editEl !== cellEl.closest('table')) return;
9744
10450
  const tableEl = burst.editEl;
@@ -9809,6 +10515,7 @@
9809
10515
  target.focus();
9810
10516
  placeCaretAtEnd(target);
9811
10517
  }
10518
+ paintCellRange();
9812
10519
  return;
9813
10520
  }
9814
10521
  // §10-gap fix (review): same reasoning as burstUndo()'s own guard above
@@ -9849,6 +10556,7 @@
9849
10556
  target.focus();
9850
10557
  placeCaretAtEnd(target);
9851
10558
  }
10559
+ paintCellRange();
9852
10560
  return;
9853
10561
  }
9854
10562
  switchAwayFrom().then((ok) => { if (ok) redo(); });
@@ -9863,6 +10571,20 @@
9863
10571
  function handleTableCellKeydown(e, cellEl) {
9864
10572
  const tableEl = cellEl.closest('table');
9865
10573
  if (!currentBurst || currentBurst.blockType !== 'table' || currentBurst.editEl !== tableEl) return;
10574
+ if (cellRangeBurst() && handleCellRangeKeydown(e, cellEl)) return;
10575
+ // Shift+Arrow at the text boundary starts a cell range with the neighbour.
10576
+ const SHIFT_EDGE = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right' };
10577
+ if (e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey && !e.isComposing &&
10578
+ SHIFT_EDGE[e.key] && selectionFocusAtEdge(cellEl, SHIFT_EDGE[e.key])) {
10579
+ const from = cellPointOf(cellEl);
10580
+ const rows = allRowsOf(tableEl);
10581
+ const to = cellRangeLib.step(from, SHIFT_EDGE[e.key], rows.length, rows[0].cells.length);
10582
+ if (!cellRangeLib.isSingle(from, to)) {
10583
+ e.preventDefault();
10584
+ setCellRange(from, to);
10585
+ }
10586
+ return;
10587
+ }
9866
10588
  if (e.key === 'Enter') {
9867
10589
  e.preventDefault();
9868
10590
  snapBurstIfActive(tableEl, 'br-pre');
@@ -9880,6 +10602,26 @@
9880
10602
  revertTableBurstAndEnd(cellEl);
9881
10603
  return;
9882
10604
  }
10605
+ if ((e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'ArrowLeft' ||
10606
+ e.key === 'ArrowRight') && !e.shiftKey && !e.ctrlKey && !e.altKey &&
10607
+ !e.metaKey && !e.isComposing) {
10608
+ const edge = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right' }[e.key];
10609
+ if (!caretAtEdge(cellEl, edge)) return;
10610
+ const x = caretClientX();
10611
+ if (x !== null) arrowGoalX = x;
10612
+ const tableBlockEl = tableEl.closest('.ed-block');
10613
+ const step = (el) => (e.key === 'ArrowUp' ? el.previousElementSibling : el.nextElementSibling);
10614
+ const rows = allRowsOf(tableEl);
10615
+ const leavesTable = (e.key === 'ArrowUp' && rows.indexOf(cellEl.parentElement) === 0) ||
10616
+ (e.key === 'ArrowDown' && rows.indexOf(cellEl.parentElement) === rows.length - 1);
10617
+ if (leavesTable) {
10618
+ let probe = step(tableBlockEl);
10619
+ while (probe && !probe.classList.contains('ed-block')) probe = step(probe);
10620
+ if (!probe) return;
10621
+ }
10622
+ if (moveTableCellByArrow(cellEl, e.key, x)) e.preventDefault();
10623
+ return;
10624
+ }
9883
10625
  if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === 'z') {
9884
10626
  e.preventDefault();
9885
10627
  tableBurstUndo(cellEl);
@@ -12723,7 +13465,12 @@
12723
13465
 
12724
13466
  document.addEventListener('pointerdown', (e) => {
12725
13467
  if (e.button !== 0) return;
12726
- if (e.target.closest && e.target.closest('.ed-te-menu')) return; // the menu's own buttons handle themselves
13468
+ if (e.target.closest && e.target.closest('.ed-te-menu')) {
13469
+ // The menu's own buttons handle themselves. A standing cell range is
13470
+ // cleared first: a row/column op shifts the cells its indices name.
13471
+ clearCellRange();
13472
+ return;
13473
+ }
12727
13474
  // Review fix (Critical): a PRIOR gesture's pointerup/pointercancel may
12728
13475
  // never have reached us at all (release over browser chrome / the
12729
13476
  // window edge, the window losing focus without a pointercancel, ...) —
@@ -12747,6 +13494,11 @@
12747
13494
  // and a press-drag on the backdrop both leave the selection null); it is
12748
13495
  // here so that stays true if the backdrop ever stops being `inset: 0`.
12749
13496
  if (waveEditorIsModal()) return;
13497
+ // A plain press clears a standing cell range — except on the selection
13498
+ // toolbar, whose buttons act ON the range.
13499
+ if (!e.shiftKey && cellRangeBurst() && !(e.target.closest && e.target.closest('.ed-seltb'))) {
13500
+ clearCellRange();
13501
+ }
12750
13502
  const hit = hitTestGrip(e.target);
12751
13503
  // A click on a DIFFERENT zone (or entirely outside any zone) dismisses
12752
13504
  // whatever menu is already open, same "any other click closes the ⠿
@@ -12790,7 +13542,9 @@
12790
13542
  // crosses the threshold still opens the menu, so nothing was taken away.
12791
13543
  // Pinned by the RV5 scenario in test/editor-client-runtime.test.js.
12792
13544
  if (armBlockDrag(e)) return;
13545
+ if (e.shiftKey && beginCellRangeShiftClick(e)) return;
12793
13546
  if (e.shiftKey) { beginShiftClickSelection(e); return; }
13547
+ armCellDrag(e);
12794
13548
  armBlockSelDrag(e);
12795
13549
  return;
12796
13550
  }
@@ -12843,6 +13597,14 @@
12843
13597
  else updateColDropIndicator(e.clientX);
12844
13598
  });
12845
13599
 
13600
+ // The table cell-range drag's pointermove (updateCellDrag()). It acts only
13601
+ // while the pointer is over another cell of the origin table, and never
13602
+ // calls preventDefault() outside it, so the block-selection drag below
13603
+ // still takes over once the pointer leaves the table.
13604
+ document.addEventListener('pointermove', (e) => {
13605
+ updateCellDrag(e);
13606
+ });
13607
+
12846
13608
  // S3 Task 4: the block-selection drag's own pointermove. Registered after
12847
13609
  // the table drag's (above), and bails while `tePointer` is set, so a grip
12848
13610
  // gesture is never fought over. Not folded into that listener because its
@@ -12861,6 +13623,7 @@
12861
13623
  });
12862
13624
 
12863
13625
  document.addEventListener('pointerup', async (e) => {
13626
+ endCellDrag();
12864
13627
  // S3 Task 4: recon hazard 2 — there is no `mouseup` listener anywhere in
12865
13628
  // this file, so the block-selection drag ends on the pointer events, the
12866
13629
  // same skeleton the table drag uses. Before the `!tePointer` bail: a
@@ -13072,6 +13835,7 @@
13072
13835
  // same way activateCell() used to — brief: "wire your cell edit root the
13073
13836
  // same way paragraph editing does (reuse, don't fork)").
13074
13837
  function onSelectionChangeForToolbar() {
13838
+ if (cellRangeBurst()) { showSelToolbarForCellRange(); return; }
13075
13839
  // v3.1.0: the fixed toolbar's five inline-format buttons enable/disable on
13076
13840
  // exactly the same question this listener already answers ("is there a
13077
13841
  // non-collapsed selection inside `selToolbarEditEl`?"), so it piggybacks
@@ -13184,9 +13948,10 @@
13184
13948
  // collectLeftovers() + dropEmptied() above, which is all an extractContents()
13185
13949
  // call site needs. applyMarkToggle()'s wrap branch reaches it through
13186
13950
  // wrapRangeIn() below; applyLinkToggleBody()'s new-link path calls it
13187
- // directly. A native Ctrl+B does not arrive here at all: client.js binds no
13188
- // Ctrl/Cmd + b/i/u, so that keystroke reaches the contenteditable as the
13189
- // browser's own execCommand and produces a <b>. The journey suite drives
13951
+ // directly. A native Ctrl+B does not arrive here at all: outside a table
13952
+ // cell range client.js binds no Ctrl/Cmd + b/i/u, so that keystroke reaches
13953
+ // the contenteditable as the browser's own execCommand and produces a <b>
13954
+ // (with a range standing, handleCellRangeKeydown() takes it instead). The journey suite drives
13190
13955
  // that keystroke and the toolbar button side by side, each with its own tag
13191
13956
  // pattern, for exactly that reason.
13192
13957
  function extractRangeInto(range, el, root) {
@@ -13295,6 +14060,7 @@
13295
14060
  // Applies the toggle policy for a plain mark type (STRONG/EM/CODE/DEL/U —
13296
14061
  // link has its own entry point below because it also needs a URL prompt).
13297
14062
  function applyMarkToggle(tag) {
14063
+ if (cellRangeBurst()) { applyMarkToCellRange(tag); return; }
13298
14064
  const root = selToolbarEditEl;
13299
14065
  if (!root) return;
13300
14066
  const sel = window.getSelection();
@@ -16223,6 +16989,31 @@
16223
16989
  // class names, and it protects every future surface too.
16224
16990
  if (blockSelection && !e.ctrlKey && !e.metaKey && !e.altKey &&
16225
16991
  !isTextEntryTarget(e.target)) {
16992
+ // Arrow walk from a selected block: plain ↑/↓ clears the selection and
16993
+ // continues from its focus block, aiming at the x the walk last had.
16994
+ if (!e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
16995
+ const from = selectionFocusBlockEl();
16996
+ if (from) {
16997
+ e.preventDefault();
16998
+ clearBlockSelection();
16999
+ walkToAdjacentBlockByArrow(from, e.key === 'ArrowUp' ? -1 : 1, null);
17000
+ }
17001
+ return;
17002
+ }
17003
+ // Enter on ONE selected block opens its MD 原始碼 — the keyboard route
17004
+ // into a block that has no typing surface. A wavedrom block is excluded
17005
+ // and every other case falls through: Enter on a standing selection
17006
+ // over a wave diagram already opens the wave editor further down.
17007
+ if (!e.shiftKey && e.key === 'Enter') {
17008
+ const from = selectionFocusBlockEl();
17009
+ const members = selectionLib ? selectionLib.membersOf(blockSelection, blocks) : [];
17010
+ if (from && members.length === 1 && !waveDiagramInside(from)) {
17011
+ e.preventDefault();
17012
+ clearBlockSelection();
17013
+ openRawViaGutter(from);
17014
+ return;
17015
+ }
17016
+ }
16226
17017
  if (e.key === 'Tab') {
16227
17018
  // §3.5, in as many words: 必須 preventDefault(),否則 Tab 在 body 上是
16228
17019
  // 瀏覽器焦點巡覽.
@@ -16465,12 +17256,14 @@
16465
17256
  });
16466
17257
 
16467
17258
  // The `inputType` values Chromium reports for the BROWSER's own formatting
16468
- // commands. md2doc binds no formatting shortcut of its own — the keys its
16469
- // `ctrlKey || metaKey` branches act on are `Enter`, `z`, `y`, shift+`Z`,
16470
- // `s` and shift+`v`/`V`: the raw editor's commit, undo, redo, save and
16471
- // plain-paste arming. `b`, `i` and `u` are not among them, so Ctrl+B
16472
- // reaches the contenteditable as the browser's own execCommand and does not
16473
- // pass through any snapBurstIfActive() call site.
17259
+ // commands. Outside a table cell range md2doc binds no formatting shortcut
17260
+ // of its own — the keys its `ctrlKey || metaKey` branches act on are
17261
+ // `Enter`, `z`, `y`, shift+`Z`, `s` and shift+`v`/`V`: the raw editor's
17262
+ // commit, undo, redo, save and plain-paste arming. `b`, `i` and `u` are not
17263
+ // among them, so Ctrl+B reaches the contenteditable as the browser's own
17264
+ // execCommand and does not pass through any snapBurstIfActive() call site.
17265
+ // (With a cell range standing, handleCellRangeKeydown() takes b/i/u and
17266
+ // applies the mark to every cell — see applyMarkToCellRange().)
16474
17267
  //
16475
17268
  // MEASURED (puppeteer 24.42.0 / headless Chromium, an `.ed-wys-armed`
16476
17269
  // paragraph, ' typed sentence' typed then a five-character selection
@@ -16592,6 +17385,8 @@
16592
17385
  if (cellEl) {
16593
17386
  e.preventDefault();
16594
17387
  const dt = e.clipboardData || window.clipboardData;
17388
+ const grid = dt ? clipboardGrid(dt) : null;
17389
+ if (grid && pasteGridIntoTable(cellEl, grid)) return;
16595
17390
  snapBurstIfActive(cellEl.closest('table'), 'paste-pre');
16596
17391
  insertTextAtCaret(dt ? dt.getData('text/plain') : '');
16597
17392
  snapBurstIfActive(cellEl.closest('table'), 'paste');