@helping-ai-workflow/md2doc 3.6.1 → 3.7.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.
- package/lib/editor/cell-range.js +85 -0
- package/lib/editor/client.js +809 -14
- package/lib/editor/server.js +5 -0
- package/lib/md2doc.js +13 -0
- package/package.json +2 -2
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* Table cell-range selection — the pure model.
|
|
3
|
+
Spec: docs/superpowers/specs/2026-09-24-editor-cell-range-design.md.
|
|
4
|
+
UMD, same shape as selection.js: require-able in node for the unit tests,
|
|
5
|
+
injected into the editor page as window.md2docCellRange. Points are
|
|
6
|
+
{r, c}: r indexes the table's rows with the header row as 0, c the cell
|
|
7
|
+
index within that row. */
|
|
8
|
+
(function (root, factory) {
|
|
9
|
+
if (typeof module === 'object' && module.exports) module.exports = factory();
|
|
10
|
+
else root.md2docCellRange = factory();
|
|
11
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
12
|
+
function clamp(n, lo, hi) { return Math.max(lo, Math.min(hi, n)); }
|
|
13
|
+
|
|
14
|
+
function normalize(a, f) {
|
|
15
|
+
return { top: Math.min(a.r, f.r), left: Math.min(a.c, f.c),
|
|
16
|
+
bottom: Math.max(a.r, f.r), right: Math.max(a.c, f.c) };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function cellsIn(rect) {
|
|
20
|
+
const out = [];
|
|
21
|
+
for (let r = rect.top; r <= rect.bottom; r++) {
|
|
22
|
+
for (let c = rect.left; c <= rect.right; c++) out.push({ r: r, c: c });
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isSingle(a, f) { return a.r === f.r && a.c === f.c; }
|
|
28
|
+
|
|
29
|
+
const DELTA = { up: [-1, 0], down: [1, 0], left: [0, -1], right: [0, 1] };
|
|
30
|
+
function step(p, dir, rowCount, colCount) {
|
|
31
|
+
const d = DELTA[dir];
|
|
32
|
+
return { r: clamp(p.r + d[0], 0, rowCount - 1), c: clamp(p.c + d[1], 0, colCount - 1) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Excel's TSV: TAB between fields, newline between records, a field that
|
|
36
|
+
// STARTS with a quote is quoted ("" inside is one quote, and it may hold
|
|
37
|
+
// TABs and newlines). A trailing newline does not make an empty record.
|
|
38
|
+
function parseTsv(text) {
|
|
39
|
+
const s = String(text).replace(/\r\n?/g, '\n');
|
|
40
|
+
const rows = [];
|
|
41
|
+
let row = [];
|
|
42
|
+
let field = '';
|
|
43
|
+
let atStart = true;
|
|
44
|
+
let i = 0;
|
|
45
|
+
while (i < s.length) {
|
|
46
|
+
const ch = s[i];
|
|
47
|
+
if (atStart && ch === '"') {
|
|
48
|
+
i++;
|
|
49
|
+
while (i < s.length) {
|
|
50
|
+
if (s[i] === '"') {
|
|
51
|
+
if (s[i + 1] === '"') { field += '"'; i += 2; continue; }
|
|
52
|
+
i++;
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
field += s[i];
|
|
56
|
+
i++;
|
|
57
|
+
}
|
|
58
|
+
atStart = false;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (ch === '\t') { row.push(field); field = ''; atStart = true; i++; continue; }
|
|
62
|
+
if (ch === '\n') { row.push(field); rows.push(row); row = []; field = ''; atStart = true; i++; continue; }
|
|
63
|
+
field += ch;
|
|
64
|
+
atStart = false;
|
|
65
|
+
i++;
|
|
66
|
+
}
|
|
67
|
+
if (field !== '' || row.length) { row.push(field); rows.push(row); }
|
|
68
|
+
return rows;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function quoteField(f) {
|
|
72
|
+
return /[\t\n"]/.test(f) ? '"' + f.replace(/"/g, '""') + '"' : f;
|
|
73
|
+
}
|
|
74
|
+
function toTsv(grid) {
|
|
75
|
+
return grid.map((row) => row.map(quoteField).join('\t')).join('\n');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function growthNeeded(origin, grid, rowCount, colCount) {
|
|
79
|
+
const width = grid.reduce((w, row) => Math.max(w, row.length), 0);
|
|
80
|
+
return { addRows: Math.max(0, origin.r + grid.length - rowCount),
|
|
81
|
+
addCols: Math.max(0, origin.c + width - colCount) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { normalize, cellsIn, isSingle, step, parseTsv, toTsv, growthNeeded };
|
|
85
|
+
});
|
package/lib/editor/client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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'))
|
|
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:
|
|
13188
|
-
// Ctrl/Cmd + b/i/u, so that keystroke reaches
|
|
13189
|
-
// browser's own execCommand and produces a <b
|
|
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
|
|
16469
|
-
// `ctrlKey || metaKey` branches act on are
|
|
16470
|
-
// `s` and shift+`v`/`V`: the raw editor's
|
|
16471
|
-
// plain-paste arming. `b`, `i` and `u` are not
|
|
16472
|
-
// reaches the contenteditable as the browser's own
|
|
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');
|
package/lib/editor/server.js
CHANGED
|
@@ -38,6 +38,10 @@ const PATCHMAP_SRC = fs.readFileSync(path.join(__dirname, 'patchmap.js'), 'utf8'
|
|
|
38
38
|
// property as indent-clamp.js and convert-md.js above; it only has to land
|
|
39
39
|
// before client.js reads window.md2docSelection.
|
|
40
40
|
const SELECTION_SRC = fs.readFileSync(path.join(__dirname, 'selection.js'), 'utf8');
|
|
41
|
+
// Table cell-range selection's pure model — same "no dependency on any other
|
|
42
|
+
// editor module" property as selection.js; it only has to land before
|
|
43
|
+
// client.js reads window.md2docCellRange.
|
|
44
|
+
const CELL_RANGE_SRC = fs.readFileSync(path.join(__dirname, 'cell-range.js'), 'utf8');
|
|
41
45
|
// v3.1.0 Task E: the four Phase-1 modules behind the visible toolbar, the
|
|
42
46
|
// paste pipeline, the image-drop asset endpoint and the whole-document
|
|
43
47
|
// source escape hatch. Same "pure module, no dependency on any other editor
|
|
@@ -731,6 +735,7 @@ async function createEditorServer({
|
|
|
731
735
|
`<script>${CONVERT_MD_SRC}</script>\n` +
|
|
732
736
|
`<script>${PATCHMAP_SRC}</script>\n` +
|
|
733
737
|
`<script>${SELECTION_SRC}</script>\n` +
|
|
738
|
+
`<script>${CELL_RANGE_SRC}</script>\n` +
|
|
734
739
|
`<script>${TOOLBAR_MODEL_SRC}</script>\n` +
|
|
735
740
|
`<script>${DOCSOURCE_SRC}</script>\n` +
|
|
736
741
|
`<script>${ASSET_SRC}</script>\n` +
|
package/lib/md2doc.js
CHANGED
|
@@ -1423,6 +1423,14 @@ ${itemsHtml}
|
|
|
1423
1423
|
/* min-width: 0 so an unbreakable token inside an item cannot widen the row
|
|
1424
1424
|
past its siblings — that would break the equal-width guarantee above. */
|
|
1425
1425
|
.ed-block[data-block-type="li"] > .ed-li-text { flex: 1 1 auto; min-width: 0; }
|
|
1426
|
+
/* The MD 原始碼 raw editor replaces the item's children with one .ed-editing
|
|
1427
|
+
wrap, which is then this flex row's only child. Without a grow it shrank
|
|
1428
|
+
to the textarea's intrinsic size and .ed-raw's width: 100% resolved
|
|
1429
|
+
against that: 189px at every viewport width (measured), where every other
|
|
1430
|
+
block's raw editor spans the column. The marker, and with it the indent,
|
|
1431
|
+
is gone while the textarea is open, so it spans the full column at every
|
|
1432
|
+
depth; the source line's own leading spaces show the nesting. */
|
|
1433
|
+
.ed-block[data-block-type="li"] > .ed-editing { flex: 1 1 auto; min-width: 0; }
|
|
1426
1434
|
/* column-gap already provides the marker/checkbox separation. */
|
|
1427
1435
|
.ed-block[data-block-type="li"] > .ed-li-check { flex: 0 0 auto; margin-right: 0; }
|
|
1428
1436
|
.ed-block[data-list-type="ul"] > .ed-li-marker::before { content: "\\2022"; }${edOlCounterCss}
|
|
@@ -3267,6 +3275,11 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
|
|
|
3267
3275
|
indicator' is the singleton line shown while dragging a row.
|
|
3268
3276
|
'.ed-te-row-dragging' dims the row actually being dragged. */
|
|
3269
3277
|
.ed-te-hl { background: rgba(59, 130, 246, 0.15) !important; }
|
|
3278
|
+
/* Table cell-range selection: the painted rectangle, and the table that
|
|
3279
|
+
holds one (its caret and native selection are hidden while it stands). */
|
|
3280
|
+
.ed-cell-range { background: rgba(59, 130, 246, 0.18) !important; }
|
|
3281
|
+
.ed-cell-range-active { caret-color: transparent; }
|
|
3282
|
+
.ed-cell-range-active ::selection { background: transparent; }
|
|
3270
3283
|
.ed-te-menu {
|
|
3271
3284
|
position: fixed; z-index: 12;
|
|
3272
3285
|
display: flex; align-items: center; gap: 4px;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@helping-ai-workflow/md2doc",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.7.0",
|
|
4
4
|
"description": "Markdown → HTML / PDF renderer with WaveDrom, Mermaid, Graphviz, and draw.io support",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"markdown",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
},
|
|
46
46
|
"scripts": {
|
|
47
47
|
"preinstall": "node scripts/preinstall.js",
|
|
48
|
-
"test": "node test/md2doc.test.js && node test/blockmap.test.js && node test/drawio.test.js && node test/images.test.js && node test/scroll-anchor.test.js && node test/lightbox.test.js && node test/lightbox-anno.test.js && node test/lightbox-anno-style.test.js && node test/reader-panels.test.js && node test/cli.test.js && node test/code-operator.test.js && node test/convert-md.test.js && node test/render-api.test.js && node test/lineops.test.js && node test/indent-clamp.test.js && node test/selection.test.js && node test/editmode-render.test.js && node test/editmode-wavedrom-reinit.test.js && node test/editor-server.test.js && node test/cli-edit.test.js && node test/open-viewer.test.js && node test/editor-client.test.js && node test/editor-client-runtime.test.js && node test/roundtrip.test.js && node test/byte-stability.test.js && node test/editor-server-throw.test.js && node test/editor-reader-rebind.test.js && node test/inline-md.test.js && node test/table-md.test.js && node test/table-anchor-recovery.test.js && node test/gate-compat.test.js && node test/history.test.js && node test/list-md.test.js && node test/table-width.test.js && node test/toolbar-model.test.js && node test/docsource.test.js && node test/asset.test.js && node test/paste-md.test.js && node test/patchmap.test.js && node test/wave-codec.test.js && node test/wave-draw.test.js && node test/wave-geometry.test.js && node test/wave-store.test.js && node test/wave-rail-clearance.test.js && node test/editor-journey.test.js && node test/detach-census.test.js"
|
|
48
|
+
"test": "node test/md2doc.test.js && node test/blockmap.test.js && node test/drawio.test.js && node test/images.test.js && node test/scroll-anchor.test.js && node test/lightbox.test.js && node test/lightbox-anno.test.js && node test/lightbox-anno-style.test.js && node test/reader-panels.test.js && node test/cli.test.js && node test/code-operator.test.js && node test/convert-md.test.js && node test/render-api.test.js && node test/lineops.test.js && node test/indent-clamp.test.js && node test/selection.test.js && node test/cell-range.test.js && node test/editmode-render.test.js && node test/editmode-wavedrom-reinit.test.js && node test/editor-server.test.js && node test/cli-edit.test.js && node test/open-viewer.test.js && node test/editor-client.test.js && node test/editor-client-runtime.test.js && node test/roundtrip.test.js && node test/byte-stability.test.js && node test/editor-server-throw.test.js && node test/editor-reader-rebind.test.js && node test/inline-md.test.js && node test/table-md.test.js && node test/table-anchor-recovery.test.js && node test/gate-compat.test.js && node test/history.test.js && node test/list-md.test.js && node test/table-width.test.js && node test/toolbar-model.test.js && node test/docsource.test.js && node test/asset.test.js && node test/paste-md.test.js && node test/patchmap.test.js && node test/wave-codec.test.js && node test/wave-draw.test.js && node test/wave-geometry.test.js && node test/wave-store.test.js && node test/wave-rail-clearance.test.js && node test/editor-journey.test.js && node test/detach-census.test.js && node test/editor-arrow-nav.test.js && node test/editor-cell-range.test.js"
|
|
49
49
|
},
|
|
50
50
|
"repository": {
|
|
51
51
|
"type": "git",
|