@helping-ai-workflow/md2doc 2.10.0 → 2.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/editor/client.js +590 -149
- package/lib/editor/server.js +15 -1
- package/lib/editor/table-md.js +13 -1
- package/lib/md2doc.js +22 -16
- package/package.json +1 -1
package/lib/editor/client.js
CHANGED
|
@@ -152,6 +152,9 @@
|
|
|
152
152
|
const listMd = window.md2docListMd;
|
|
153
153
|
const historyLib = window.md2docHistory;
|
|
154
154
|
let lines = ED.lines, blocks = ED.blocks, mtimeMs = ED.mtimeMs;
|
|
155
|
+
// 檔案原本的換行符。lines 內部永遠是不含 \r 的純內容行;只有 save()
|
|
156
|
+
// 會把它接回這個 EOL,render 一律用 \n(spec §3.11)。
|
|
157
|
+
const EOL = ED.eol || '\n';
|
|
155
158
|
const stack = new ops.UndoStack();
|
|
156
159
|
const baseTitle = document.title;
|
|
157
160
|
const contentEl = document.querySelector('.content');
|
|
@@ -210,10 +213,10 @@
|
|
|
210
213
|
let pristineInsert = null; // { blockId } | null
|
|
211
214
|
|
|
212
215
|
// Task 5 fix (found via a standalone repro harness — see the task-5
|
|
213
|
-
// report): a table
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
216
|
+
// report): a table mutation that DETACHES the focused cell — whether by
|
|
217
|
+
// reassigning tableEl.innerHTML wholesale or by re-parenting the cell/row
|
|
218
|
+
// nodes — removes whichever cell currently has focus. Chromium runs the
|
|
219
|
+
// focus-fixup "unfocus"
|
|
217
220
|
// step (firing a synchronous blur/focusout) BEFORE the node is actually
|
|
218
221
|
// detached — NOT after, as a naive reading of "removed nodes lose focus"
|
|
219
222
|
// would suggest — so at the moment that focusout's handler runs,
|
|
@@ -226,9 +229,29 @@
|
|
|
226
229
|
// called switchAwayFrom() — silently RE-COMMITTING the very state the
|
|
227
230
|
// revert/undo/redo was in the middle of discarding, then wiping focus to
|
|
228
231
|
// <body> once the resulting rerenderAll() swapped .content. Set true for
|
|
229
|
-
// the exact synchronous span of each such
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
+
// the exact synchronous span of each such mutation; the focusout listener
|
|
233
|
+
// checks it FIRST and no-ops the whole branch while set.
|
|
234
|
+
//
|
|
235
|
+
// There are exactly FOUR set-to-true sites, and test/editor-client.test.js
|
|
236
|
+
// asserts that count (plus that every one of them is wrapped in a
|
|
237
|
+
// try/finally that clears the flag even on a throw — a latched-true flag
|
|
238
|
+
// silently disables blur-commits for EVERY block type until reload):
|
|
239
|
+
// 1. tableBurstUndo() — `tableEl.innerHTML = state`
|
|
240
|
+
// 2. tableBurstRedo() — `tableEl.innerHTML = state`
|
|
241
|
+
// 3. performRowDrop() — rebuildTableSections(): a row drop is a PURE
|
|
242
|
+
// MOVE across thead/tbody (any row dragged to the top becomes the
|
|
243
|
+
// header), so the rebuild detaches the focused cell.
|
|
244
|
+
// 4. performColDrop() — the per-row cell-reorder loop, which appendChild()s
|
|
245
|
+
// every row's cells back in the new order, detaching the focused one.
|
|
246
|
+
// revertTableBurstAndEnd() is deliberately NOT on this list, and NOT
|
|
247
|
+
// because anything else guards it: it needs no flag at all because it
|
|
248
|
+
// nulls `currentBurst` and disposes the burst history BEFORE it touches
|
|
249
|
+
// innerHTML — so by the time that rewrite fires Chromium's synchronous
|
|
250
|
+
// blur/focusout, the focusout handler's table branch finds no burst left
|
|
251
|
+
// to resolve and no-ops on its own. See its own comment for the full
|
|
252
|
+
// story. If a fifth site is ever added,
|
|
253
|
+
// update the count in editor-client.test.js deliberately and audit the new
|
|
254
|
+
// site for the same try/finally.
|
|
232
255
|
let suppressTableFocusout = false;
|
|
233
256
|
|
|
234
257
|
// Task 8: the SAME Chromium behaviour, one substrate over — see
|
|
@@ -1469,6 +1492,47 @@
|
|
|
1469
1492
|
// from the delegated `focusin` listener below. captureFn snapshots the
|
|
1470
1493
|
// surface's innerHTML; history.start() records snapshot 0 (the pre-edit
|
|
1471
1494
|
// baseline Esc reverts to).
|
|
1495
|
+
// S2 (Important): the "did the user actually change anything?" baseline —
|
|
1496
|
+
// `editEl.innerHTML` with SELECTION CHROME stripped. Every burst-level
|
|
1497
|
+
// comparison against `burst.original` (resolveBurst()'s zero-edit guard,
|
|
1498
|
+
// burstUndo()/tableBurstUndo()'s pristine-insert probes) and every
|
|
1499
|
+
// burst-history snapshot goes through here, so both sides of every such
|
|
1500
|
+
// comparison are normalised the same way.
|
|
1501
|
+
//
|
|
1502
|
+
// Why it has to exist: showRowMenu()/showColumnMenu() add '.ed-te-hl' to
|
|
1503
|
+
// LIVE cells before any burst exists, and the delete handler then opens
|
|
1504
|
+
// the burst — so a raw `tableEl.innerHTML` baseline BAKES THE HIGHLIGHT
|
|
1505
|
+
// IN. A refused delete ("無法刪除最後一列/欄") leaves that highlight
|
|
1506
|
+
// standing; the next click elsewhere strips it, and the baseline no longer
|
|
1507
|
+
// matches an untouched table. resolveBurst() then re-serialises the whole
|
|
1508
|
+
// thing through table-md.js's canonical form, silently destroying hand
|
|
1509
|
+
// padding and hand-written alignment in a table the user never edited.
|
|
1510
|
+
// Fixing only the refusal path (hideTableEdgeMenu() there) does not help:
|
|
1511
|
+
// stripping the class is itself the diff, whichever code path does it.
|
|
1512
|
+
// The same reasoning covers '.ed-te-row-dragging', which a drag adds and
|
|
1513
|
+
// pointerup/cancelTeDrag() removes.
|
|
1514
|
+
//
|
|
1515
|
+
// Fast path first: the overwhelming majority of calls (every paragraph /
|
|
1516
|
+
// heading / list burst, and any table with no selection on it) carry no
|
|
1517
|
+
// chrome at all, and must not pay for a full subtree clone. When chrome IS
|
|
1518
|
+
// present the clone is mutated instead of the live DOM, so the user's
|
|
1519
|
+
// visible selection survives the measurement. classList.remove() on the
|
|
1520
|
+
// clone re-serialises the class attribute joined by single spaces — byte-
|
|
1521
|
+
// identical to what the renderer emitted — and an attribute left empty is
|
|
1522
|
+
// dropped outright (the renderer never emits `class=""`).
|
|
1523
|
+
function burstBaselineHtml(editEl) {
|
|
1524
|
+
const html = editEl.innerHTML;
|
|
1525
|
+
if (html.indexOf('ed-te-hl') === -1 && html.indexOf('ed-te-row-dragging') === -1) return html;
|
|
1526
|
+
const clone = editEl.cloneNode(true);
|
|
1527
|
+
const marked = clone.querySelectorAll('.ed-te-hl, .ed-te-row-dragging');
|
|
1528
|
+
Array.prototype.forEach.call(marked, (el) => {
|
|
1529
|
+
el.classList.remove('ed-te-hl');
|
|
1530
|
+
el.classList.remove('ed-te-row-dragging');
|
|
1531
|
+
if (!el.className) el.removeAttribute('class');
|
|
1532
|
+
});
|
|
1533
|
+
return clone.innerHTML;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1472
1536
|
function startBurst(editEl) {
|
|
1473
1537
|
const blockEl = editEl.closest('.ed-block');
|
|
1474
1538
|
if (!blockEl) return;
|
|
@@ -1476,12 +1540,12 @@
|
|
|
1476
1540
|
const block = blocks.find((b) => b.id === blockId);
|
|
1477
1541
|
if (!block) return;
|
|
1478
1542
|
const blockType = blockEl.getAttribute('data-block-type');
|
|
1479
|
-
const history = historyLib.createBurstHistory(() => editEl
|
|
1543
|
+
const history = historyLib.createBurstHistory(() => burstBaselineHtml(editEl), { debounceMs: 400 });
|
|
1480
1544
|
history.start();
|
|
1481
1545
|
currentBurst = {
|
|
1482
1546
|
blockEl, editEl, blockId, blockType,
|
|
1483
1547
|
depth: blockDepthOf(blockType, editEl),
|
|
1484
|
-
original: editEl
|
|
1548
|
+
original: burstBaselineHtml(editEl),
|
|
1485
1549
|
history,
|
|
1486
1550
|
};
|
|
1487
1551
|
selToolbarEditEl = editEl;
|
|
@@ -1545,15 +1609,19 @@
|
|
|
1545
1609
|
// otherwise-untouched source (hundreds of hand-formatted tables exist
|
|
1546
1610
|
// in real corpora) silently REWROTE it to the serializer's minimal form
|
|
1547
1611
|
// and marked the document dirty even though the user typed nothing.
|
|
1548
|
-
// `burst.original` is exactly `burst.editEl
|
|
1549
|
-
// focus time (startBurst()/startTableBurst() above, for
|
|
1550
|
-
// type this burst substrate covers — paragraph/heading/
|
|
1551
|
-
// store it the same way)
|
|
1552
|
-
//
|
|
1612
|
+
// `burst.original` is exactly `burstBaselineHtml(burst.editEl)`
|
|
1613
|
+
// captured at focus time (startBurst()/startTableBurst() above, for
|
|
1614
|
+
// every block type this burst substrate covers — paragraph/heading/
|
|
1615
|
+
// list/table all store it the same way): the surface's innerHTML with
|
|
1616
|
+
// table SELECTION CHROME normalised away, so a highlight that was
|
|
1617
|
+
// already standing when the burst opened (S2 — see burstBaselineHtml()
|
|
1618
|
+
// above) can neither be baked into the baseline nor show up as an edit
|
|
1619
|
+
// when a later click strips it. A byte-identical normalised innerHTML
|
|
1620
|
+
// means the DOM genuinely never changed, so drop the burst here like the
|
|
1553
1621
|
// `commitResult.op === null` no-op path below, without ever reaching
|
|
1554
1622
|
// the serializer (and therefore without ever risking a canonicalizing
|
|
1555
1623
|
// rewrite of untouched content).
|
|
1556
|
-
if (burst.editEl
|
|
1624
|
+
if (burstBaselineHtml(burst.editEl) === burst.original) {
|
|
1557
1625
|
endBurstWithoutResolve();
|
|
1558
1626
|
// §10-gap fix (review): untouched AND was pristine — an ordinary
|
|
1559
1627
|
// "insert +, click away without typing" changed-my-mind. Auto-remove
|
|
@@ -1934,7 +2002,7 @@
|
|
|
1934
2002
|
// to resolve — nothing can change either condition between this check
|
|
1935
2003
|
// and that resolution running.
|
|
1936
2004
|
const willAutoRemove = !!(pristineInsert && pristineInsert.blockId === burst.blockId &&
|
|
1937
|
-
burst.editEl
|
|
2005
|
+
burstBaselineHtml(burst.editEl) === burst.original);
|
|
1938
2006
|
switchAwayFrom().then((ok) => { if (ok && !willAutoRemove) undo(); });
|
|
1939
2007
|
}
|
|
1940
2008
|
|
|
@@ -2646,11 +2714,11 @@
|
|
|
2646
2714
|
const blockId = Number(blockEl.getAttribute('data-block-id'));
|
|
2647
2715
|
const block = blocks.find((b) => b.id === blockId);
|
|
2648
2716
|
if (!block) return;
|
|
2649
|
-
const history = historyLib.createBurstHistory(() => tableEl
|
|
2717
|
+
const history = historyLib.createBurstHistory(() => burstBaselineHtml(tableEl), { debounceMs: 400 });
|
|
2650
2718
|
history.start();
|
|
2651
2719
|
currentBurst = {
|
|
2652
2720
|
blockEl, editEl: tableEl, blockId, blockType: 'table',
|
|
2653
|
-
depth: null, original: tableEl
|
|
2721
|
+
depth: null, original: burstBaselineHtml(tableEl), history,
|
|
2654
2722
|
activeCellEl: cellEl,
|
|
2655
2723
|
};
|
|
2656
2724
|
selToolbarEditEl = cellEl;
|
|
@@ -2787,7 +2855,7 @@
|
|
|
2787
2855
|
// auto-remove an untouched pristine table insert, so a chained undo()
|
|
2788
2856
|
// must be skipped or it cascades one op too far.
|
|
2789
2857
|
const willAutoRemove = !!(pristineInsert && pristineInsert.blockId === burst.blockId &&
|
|
2790
|
-
tableEl
|
|
2858
|
+
burstBaselineHtml(tableEl) === burst.original);
|
|
2791
2859
|
switchAwayFrom().then((ok) => { if (ok && !willAutoRemove) undo(); });
|
|
2792
2860
|
}
|
|
2793
2861
|
|
|
@@ -2935,9 +3003,19 @@
|
|
|
2935
3003
|
allRowsOf(tableEl).forEach((row) => {
|
|
2936
3004
|
const isHeader = row === headerRowOf(tableEl);
|
|
2937
3005
|
const cell = document.createElement(isHeader ? 'th' : 'td');
|
|
3006
|
+
// 新欄是空的,narrow 與空儲存格一致;classifyColumns() 是 render 時
|
|
3007
|
+
// 的啟發式,編輯器無法重跑,下一次 commit 全量重繪時才會重算。
|
|
3008
|
+
cell.className = 'cell-narrow';
|
|
2938
3009
|
const ref = row.cells[colIndex];
|
|
2939
3010
|
row.insertBefore(cell, ref ? ref.nextSibling : null);
|
|
2940
3011
|
});
|
|
3012
|
+
const cg = tableEl.querySelector('colgroup');
|
|
3013
|
+
if (cg) {
|
|
3014
|
+
const col = document.createElement('col');
|
|
3015
|
+
col.className = 'col-narrow';
|
|
3016
|
+
const ref = cg.children[colIndex];
|
|
3017
|
+
cg.insertBefore(col, ref ? ref.nextSibling : null);
|
|
3018
|
+
}
|
|
2941
3019
|
}
|
|
2942
3020
|
|
|
2943
3021
|
function deleteColumn(tableEl, colIndex) {
|
|
@@ -2945,6 +3023,8 @@
|
|
|
2945
3023
|
const cell = row.cells[colIndex];
|
|
2946
3024
|
if (cell) row.removeChild(cell);
|
|
2947
3025
|
});
|
|
3026
|
+
const cg = tableEl.querySelector('colgroup');
|
|
3027
|
+
if (cg && cg.children[colIndex]) cg.removeChild(cg.children[colIndex]);
|
|
2948
3028
|
}
|
|
2949
3029
|
|
|
2950
3030
|
// Mirrors table-md.js's own cellAlign() (that file can't require this one
|
|
@@ -3043,23 +3123,52 @@
|
|
|
3043
3123
|
// The old `!document.body.contains(tableEl)` check treated that as
|
|
3044
3124
|
// "table's gone" and bailed out, silently discarding the insert/delete/
|
|
3045
3125
|
// align/drop the caller was trying to perform. Capture this table's OWN
|
|
3046
|
-
//
|
|
3126
|
+
// identity FIRST and, same stale-node recovery the focusin listener uses
|
|
3047
3127
|
// above, re-resolve the LIVE table by it when the original reference no
|
|
3048
3128
|
// longer resolves — returning that live element (which callers below now
|
|
3049
3129
|
// use in place of their own now-possibly-stale `tableEl`) instead of a
|
|
3050
3130
|
// bare boolean, so a caller can never accidentally keep operating on the
|
|
3051
3131
|
// detached node it started with.
|
|
3132
|
+
//
|
|
3133
|
+
// S1 (Critical): that recovery used to key on `data-block-id`, which is
|
|
3134
|
+
// NOT stable across the very commit it is recovering from. blockmap.js
|
|
3135
|
+
// renumbers ids 0..n-1 in document order on EVERY render (`nextId =
|
|
3136
|
+
// {v:0}`), so a resolved burst that changes the NUMBER of blocks before
|
|
3137
|
+
// this table (e.g. a dirty raw-edit textarea whose source splits one
|
|
3138
|
+
// paragraph into two) shifts every later id down — and the id captured
|
|
3139
|
+
// here then names a DIFFERENT block. `classList.contains('ed-wys-table')`
|
|
3140
|
+
// was the only guard, and every table passes it, so the gesture silently
|
|
3141
|
+
// rewrote an untouched neighbouring table (a row drag even promoted one of
|
|
3142
|
+
// its body rows to its header). Re-resolve by `startLine` instead — see
|
|
3143
|
+
// blockElAtLine()'s own comment: a startLine captured before a commit is
|
|
3144
|
+
// the only stable way back to a specific block afterwards — and then
|
|
3145
|
+
// verify the block we landed on really is the same table (header cell
|
|
3146
|
+
// count, header cell text, row count) before handing it to a caller that
|
|
3147
|
+
// is about to mutate it. A commit that shifts this table's own start line
|
|
3148
|
+
// (an insert/delete ABOVE it) leaves nothing to resolve; returning null
|
|
3149
|
+
// drops the gesture, which is the conservative half of the trade — never
|
|
3150
|
+
// mutating the wrong table beats completing every gesture.
|
|
3151
|
+
function tableIdentityOf(tableEl) {
|
|
3152
|
+
const headerRow = tableEl ? headerRowOf(tableEl) : null;
|
|
3153
|
+
if (!headerRow) return null;
|
|
3154
|
+
return allRowsOf(tableEl).length + '' +
|
|
3155
|
+
Array.prototype.slice.call(headerRow.cells).map((c) => c.textContent).join('');
|
|
3156
|
+
}
|
|
3052
3157
|
async function ensureTableBurstOpen(tableEl) {
|
|
3053
3158
|
if (currentBurst && currentBurst.blockType === 'table' && currentBurst.editEl === tableEl) return tableEl;
|
|
3054
3159
|
const blockEl = tableEl.closest('.ed-block');
|
|
3055
|
-
const blockId = blockEl ? blockEl.getAttribute('data-block-id') : null;
|
|
3160
|
+
const blockId = blockEl ? Number(blockEl.getAttribute('data-block-id')) : null;
|
|
3161
|
+
const block = blockId != null ? blocks.find((b) => b.id === blockId) : null;
|
|
3162
|
+
const startLine = block ? block.startLine : null;
|
|
3163
|
+
const identity = tableIdentityOf(tableEl);
|
|
3056
3164
|
const ok = await switchAwayFrom();
|
|
3057
3165
|
if (!ok) return null;
|
|
3058
3166
|
let liveTableEl = tableEl;
|
|
3059
3167
|
if (!document.body.contains(tableEl)) {
|
|
3060
|
-
const liveBlockEl =
|
|
3168
|
+
const liveBlockEl = startLine != null ? blockElAtLine(startLine) : null;
|
|
3061
3169
|
liveTableEl = liveBlockEl ? blockContentEl(liveBlockEl) : null;
|
|
3062
|
-
if (!liveTableEl || !liveTableEl.classList.contains('ed-wys-table')) return null;
|
|
3170
|
+
if (!liveTableEl || !liveTableEl.classList || !liveTableEl.classList.contains('ed-wys-table')) return null;
|
|
3171
|
+
if (identity == null || tableIdentityOf(liveTableEl) !== identity) return null;
|
|
3063
3172
|
}
|
|
3064
3173
|
if (currentBurst && currentBurst.blockType === 'table' && currentBurst.editEl === liveTableEl) return liveTableEl;
|
|
3065
3174
|
const cell = tableCellsOf(liveTableEl)[0];
|
|
@@ -3187,15 +3296,22 @@
|
|
|
3187
3296
|
// just above the column while hovering it — see the "Notion-style grip
|
|
3188
3297
|
// handles" section below) selects the column: every th/td in it gets
|
|
3189
3298
|
// '.ed-te-hl' and a floating menu (delete / align-cycle) appears. Clicking
|
|
3190
|
-
// a row's grip handle (the vertical 6-dot affordance shown
|
|
3191
|
-
//
|
|
3192
|
-
// delete-only menu
|
|
3299
|
+
// a row's grip handle (the vertical 6-dot affordance shown at the row's
|
|
3300
|
+
// own left edge while hovering it) selects the row the same way, with a
|
|
3301
|
+
// delete-only menu — except on the HEADER row, whose grip click only
|
|
3302
|
+
// highlights (a header can never be deleted, so its menu would be empty;
|
|
3303
|
+
// see the header-grip branch in the pointerup handler below).
|
|
3304
|
+
// User-acceptance feedback on the ORIGINAL design (an
|
|
3193
3305
|
// invisible TE_EDGE_PX=8 proximity zone hugging the table's raw top/left
|
|
3194
3306
|
// pixel edge, with no visible affordance at all) was that it was
|
|
3195
3307
|
// unusably small — pixel-hunting a click target with no visual cue. The
|
|
3196
3308
|
// grips below are the fix: real, adequately-sized (≥18×24px) elements the
|
|
3197
|
-
// user can actually see and aim for. Both grips are
|
|
3198
|
-
//
|
|
3309
|
+
// user can actually see and aim for. Both grips are overlay elements
|
|
3310
|
+
// `position: fixed`-appended to document.body — never DOM CHILDREN of a
|
|
3311
|
+
// contenteditable cell, even though the row grip now visually overlaps one
|
|
3312
|
+
// (P0-a moved it just INSIDE the table's left border; see the geometry
|
|
3313
|
+
// note in the "Notion-style row/column grip handles" section below). So —
|
|
3314
|
+
// unlike the old zones, which
|
|
3199
3315
|
// sat INSIDE an already-permanently-contenteditable cell and needed the
|
|
3200
3316
|
// delegated `pointerdown` listener below to preventDefault() there to
|
|
3201
3317
|
// stop native caret placement from stealing the click — a grip's own
|
|
@@ -3203,19 +3319,28 @@
|
|
|
3203
3319
|
// focus put now (same "keep focus put" idiom buildTableInsertBubble()
|
|
3204
3320
|
// documents for the hover-insert bubbles above).
|
|
3205
3321
|
//
|
|
3206
|
-
// Row drag starts from the SAME row grip
|
|
3207
|
-
//
|
|
3208
|
-
//
|
|
3322
|
+
// Row drag starts from the SAME row grip. EVERY row is draggable and every
|
|
3323
|
+
// row gets a grip, the header <tr> included (spec §3.10/§4.6: in markdown a
|
|
3324
|
+
// table's first row IS its header, so position alone decides header
|
|
3325
|
+
// identity — dragging a data row above the header PROMOTES it, and the old
|
|
3326
|
+
// header becomes a data row). The header's grip is offset DOWN by
|
|
3327
|
+
// TE_HEADER_GRIP_DY_PX so it stays clear of the column-grip band.
|
|
3328
|
+
//
|
|
3329
|
+
// After a small movement threshold (distinguishing "click to open the
|
|
3209
3330
|
// menu" from "press-and-drag"), a drop-indicator line tracks the pointer
|
|
3210
|
-
// between
|
|
3211
|
-
//
|
|
3212
|
-
//
|
|
3213
|
-
//
|
|
3214
|
-
//
|
|
3215
|
-
//
|
|
3216
|
-
//
|
|
3217
|
-
// suppressTableFocusout
|
|
3218
|
-
//
|
|
3331
|
+
// between rows — including an "above the header" boundary; releasing
|
|
3332
|
+
// performs a PURE MOVE via rebuildTableSections(), which re-lays the same
|
|
3333
|
+
// <tr>/<th>/<td> nodes across thead/tbody so whichever row ended up first
|
|
3334
|
+
// becomes the header row. That rebuild necessarily DETACHES the cell that
|
|
3335
|
+
// currently holds focus, so Chromium fires a synchronous focusout
|
|
3336
|
+
// mid-mutation — exactly the quirk tableBurstUndo()/tableBurstRedo()'s
|
|
3337
|
+
// innerHTML-snapshot restore has to guard. performRowDrop() therefore
|
|
3338
|
+
// wraps it in `suppressTableFocusout` (the third of the flag's four sites)
|
|
3339
|
+
// and puts focus back explicitly via restoreTableFocus(). An earlier
|
|
3340
|
+
// revision moved the <tr> with a plain `insertBefore()`, which reparents
|
|
3341
|
+
// in one synchronous step, never blurs, and needed no guard — the header
|
|
3342
|
+
// promotion requirement is what retired that. The menu's delete ops (which
|
|
3343
|
+
// DO remove nodes) sidestep the same hazard a different way — see
|
|
3219
3344
|
// refocusAwayFromColumn()/refocusAwayFromRow() below.
|
|
3220
3345
|
//
|
|
3221
3346
|
// Both the menu (delete/align) and the drag's DOM move are burst
|
|
@@ -3247,7 +3372,18 @@
|
|
|
3247
3372
|
let teHighlightEls = [];
|
|
3248
3373
|
|
|
3249
3374
|
function clearEdgeHighlight() {
|
|
3250
|
-
|
|
3375
|
+
// classList.remove() leaves a dangling `class=""` behind on any element
|
|
3376
|
+
// the renderer/armEditables() emitted with NO class of its own — a
|
|
3377
|
+
// col-default cell in a table that was never armed — and
|
|
3378
|
+
// that residue is a real innerHTML diff that defeats resolveBurst()'s
|
|
3379
|
+
// zero-edit guard, canonically rewriting a table the user only
|
|
3380
|
+
// highlighted. Same fix cancelTeDrag()/the pointerup drag branch already
|
|
3381
|
+
// apply to the dragged row's own class — drop the attribute when it
|
|
3382
|
+
// goes empty, here too.
|
|
3383
|
+
teHighlightEls.forEach((el) => {
|
|
3384
|
+
el.classList.remove('ed-te-hl');
|
|
3385
|
+
if (!el.className) el.removeAttribute('class');
|
|
3386
|
+
});
|
|
3251
3387
|
teHighlightEls = [];
|
|
3252
3388
|
}
|
|
3253
3389
|
|
|
@@ -3259,10 +3395,22 @@
|
|
|
3259
3395
|
});
|
|
3260
3396
|
}
|
|
3261
3397
|
|
|
3398
|
+
// S3 (Important): the class goes on the row's CELLS, exactly like
|
|
3399
|
+
// highlightColumn() above — never on the `<tr>`. A `<tr>` highlight paints
|
|
3400
|
+
// nothing the user can see: `th { background: #f6f8fa }` and the sticky
|
|
3401
|
+
// first column's `background: #ffffff` are painted by the CELLS, which sit
|
|
3402
|
+
// ABOVE the row box, and `!important` does not let a rule on one element
|
|
3403
|
+
// beat an opaque background painted by a different element on top of it.
|
|
3404
|
+
// The header row (all-`<th>`) rendered as ZERO pixels changed, and the
|
|
3405
|
+
// body-row case lost its first cell to the sticky rule for the same
|
|
3406
|
+
// reason — while the Esc gate below still counted that invisible state as
|
|
3407
|
+
// "a selection is open" and ate the user's next Escape.
|
|
3262
3408
|
function highlightRow(rowEl) {
|
|
3263
3409
|
clearEdgeHighlight();
|
|
3264
|
-
rowEl.
|
|
3265
|
-
|
|
3410
|
+
Array.prototype.slice.call(rowEl.cells).forEach((cell) => {
|
|
3411
|
+
cell.classList.add('ed-te-hl');
|
|
3412
|
+
teHighlightEls.push(cell);
|
|
3413
|
+
});
|
|
3266
3414
|
}
|
|
3267
3415
|
|
|
3268
3416
|
function hideTableEdgeMenu() {
|
|
@@ -3460,9 +3608,13 @@
|
|
|
3460
3608
|
// Two singleton overlay elements — same "one shared node, repositioned
|
|
3461
3609
|
// via getBoundingClientRect(), never one per row/column" Global Constraint
|
|
3462
3610
|
// the hover-insert bubbles above follow. `rowGrip` is a vertical 6-dot
|
|
3463
|
-
// handle shown
|
|
3464
|
-
//
|
|
3465
|
-
//
|
|
3611
|
+
// handle shown at the LEFT EDGE of whichever row the pointer is currently
|
|
3612
|
+
// hovering any cell of — EVERY row, the header included (spec §3.10: the
|
|
3613
|
+
// header is draggable too, since position alone decides header identity;
|
|
3614
|
+
// only its CLICK differs, highlighting instead of opening the
|
|
3615
|
+
// delete-only menu). On the header row the grip is additionally offset
|
|
3616
|
+
// DOWN by TE_HEADER_GRIP_DY_PX so it does not collide with the column
|
|
3617
|
+
// grip's own band above the table; `colGrip` is a horizontal 6-dot handle
|
|
3466
3618
|
// shown just ABOVE whichever column the pointer is hovering (every
|
|
3467
3619
|
// column, header included — the column menu's delete/align both apply to
|
|
3468
3620
|
// header cells too). Built once by buildTableGrip() below and driven by
|
|
@@ -3470,10 +3622,16 @@
|
|
|
3470
3622
|
// listener (wired near the bottom of this file) that already drives
|
|
3471
3623
|
// updateTableInsertBubbles() — see its own comment for the coalescing
|
|
3472
3624
|
// contract this reuses.
|
|
3473
|
-
// Review fix (P0-a):
|
|
3474
|
-
// centerline coincides with the table's
|
|
3475
|
-
// straddles
|
|
3476
|
-
//
|
|
3625
|
+
// Review fix (P0-a): neither grip is separated from the table any more.
|
|
3626
|
+
// The COLUMN grip's centerline coincides with the table's top edge, so its
|
|
3627
|
+
// hit rect straddles that border by ~half its own height on each side. The
|
|
3628
|
+
// ROW grip sits fully INSIDE the table instead — its LEFT edge on the
|
|
3629
|
+
// table's left border, extending inward — because the space just outside
|
|
3630
|
+
// that border belongs to the block's own gutter ⠿ (spec §4.2 衝突 2). The
|
|
3631
|
+
// visible consequence of that choice is that the leftmost ~20px (the row
|
|
3632
|
+
// grip's own CSS width) of the first column is covered by the grip, so a
|
|
3633
|
+
// click there does not place the caret; it is a known, accepted trade.
|
|
3634
|
+
// Either way the grip's hit rect DOES overlap the insert bubble's hit
|
|
3477
3635
|
// rect (the bubble extends TB_BUBBLE_SIZE/2 = 9px past the edge on its
|
|
3478
3636
|
// own axis). Non-intersection via rect separation is no longer possible
|
|
3479
3637
|
// or required. Instead, "insert-bubble click is never eaten by the grip"
|
|
@@ -3501,15 +3659,16 @@
|
|
|
3501
3659
|
b.hidden = true;
|
|
3502
3660
|
// Same "keep the burst's focus/selection intact across the click" idiom
|
|
3503
3661
|
// buildTableInsertBubble() above documents — without this, the grip
|
|
3504
|
-
// (
|
|
3505
|
-
//
|
|
3506
|
-
//
|
|
3662
|
+
// (a document.body child, never a descendant of the table it is pinned
|
|
3663
|
+
// to, however far inside the table's own edge it is drawn) stealing
|
|
3664
|
+
// focus on mousedown would fire a focusout on the currently-focused
|
|
3665
|
+
// cell BEFORE this gesture's own `pointerdown` handler below even runs.
|
|
3507
3666
|
b.addEventListener('mousedown', (e) => e.preventDefault());
|
|
3508
3667
|
document.body.appendChild(b);
|
|
3509
3668
|
return b;
|
|
3510
3669
|
}
|
|
3511
3670
|
const rowGrip = buildTableGrip('ed-te-grip-row', '列選項 / 拖曳排序');
|
|
3512
|
-
const colGrip = buildTableGrip('ed-te-grip-col', '欄選項');
|
|
3671
|
+
const colGrip = buildTableGrip('ed-te-grip-col', '欄選項 / 拖曳排序');
|
|
3513
3672
|
|
|
3514
3673
|
// Which table/row/column the two grips are CURRENTLY pinned to — updated
|
|
3515
3674
|
// by updateTableEdgeGrips() below, read back by hitTestGrip() at
|
|
@@ -3526,23 +3685,50 @@
|
|
|
3526
3685
|
function hideTableGrips() {
|
|
3527
3686
|
rowGrip.hidden = true;
|
|
3528
3687
|
colGrip.hidden = true;
|
|
3688
|
+
// Task 8 fix round 1 (Minor 4): clear BOTH grips' dragging visual — a
|
|
3689
|
+
// row and a column can each wear `ed-te-grip-dragging` mid-drag, and
|
|
3690
|
+
// this is reachable while one is in flight (e.g. a burst resolution
|
|
3691
|
+
// calling hideTableGrips() mid-gesture), so leaving colGrip out was
|
|
3692
|
+
// exactly the row/col asymmetry this task exists to remove.
|
|
3529
3693
|
rowGrip.classList.remove('ed-te-grip-dragging');
|
|
3694
|
+
colGrip.classList.remove('ed-te-grip-dragging');
|
|
3530
3695
|
gripRowTableEl = null;
|
|
3531
3696
|
gripRowEl = null;
|
|
3532
3697
|
gripColTableEl = null;
|
|
3533
3698
|
gripColIndex = null;
|
|
3534
3699
|
}
|
|
3535
3700
|
|
|
3536
|
-
//
|
|
3537
|
-
//
|
|
3538
|
-
//
|
|
3539
|
-
//
|
|
3540
|
-
//
|
|
3541
|
-
//
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3701
|
+
// spec §4.2 衝突 2/3:row grip 一律落在表格**內側**(dx=0,左緣貼齊表格
|
|
3702
|
+
// 左邊界向內延伸),避開頁面 gutter 的 ⠿;表頭列的 grip 額外**往下**
|
|
3703
|
+
// 偏移,避開 colGrip 佔住的 [tableTop−12, tableTop+12] 帶。往左或往上
|
|
3704
|
+
// 都會重新製造衝突,所以只有這一個方向。
|
|
3705
|
+
// updateTableEdgeGrips() 與 pointInRowGripZone() 共用這張表——兩邊各算
|
|
3706
|
+
// 一次就是 grip「看得到但按不到」的經典成因。
|
|
3707
|
+
const TE_HEADER_GRIP_DY_PX = 16;
|
|
3708
|
+
function rowGripOffsetFor(rowEl, tableEl) {
|
|
3709
|
+
return { dx: 0, dy: rowEl === headerRowOf(tableEl) ? TE_HEADER_GRIP_DY_PX : 0 };
|
|
3710
|
+
}
|
|
3711
|
+
|
|
3712
|
+
// Bug fix (user acceptance) — history: grips were originally BOTH
|
|
3713
|
+
// border-straddling (P0-a), and were visible on hover but UNREACHABLE by a
|
|
3714
|
+
// real pointer. Root cause — a pointer travelling from inside a cell
|
|
3715
|
+
// toward a grip necessarily crossed a ~10px corridor OUTSIDE the table's
|
|
3716
|
+
// border on the way (the grip's own left/top half). The naive hit test
|
|
3717
|
+
// below ("on a cell, or hide") hid the grip the instant the pointer left
|
|
3718
|
+
// the table/cell — BEFORE it ever reached the grip — so only a
|
|
3719
|
+
// teleporting click (every existing test used pressReleaseAt()/
|
|
3720
|
+
// gripCenter(), which jump straight to the grip's own coordinates) could
|
|
3721
|
+
// ever land on it; a real mouse gesture could not.
|
|
3722
|
+
//
|
|
3723
|
+
// Current geometry (spec §4.2 衝突 2, this file's row-grip-inside-table
|
|
3724
|
+
// change above): the ROW grip no longer straddles the border — its left
|
|
3725
|
+
// edge sits ON the table's left border, extending INWARD, fully inside the
|
|
3726
|
+
// table (see rowGripOffsetFor() above). A pointer travelling from a cell to
|
|
3727
|
+
// the row grip's centre therefore never leaves the table, so the
|
|
3728
|
+
// corridor-crossing bug described above no longer applies to the row grip.
|
|
3729
|
+
// The COLUMN grip is UNCHANGED and still straddles the table's top border
|
|
3730
|
+
// (half above the border, half below), so the corridor-crossing bug above
|
|
3731
|
+
// still applies to it exactly as originally described.
|
|
3546
3732
|
//
|
|
3547
3733
|
// Review fix (Important, first pass over-permissive): the first version of
|
|
3548
3734
|
// this fix kept a grip visible while the pointer was ANYWHERE within the
|
|
@@ -3553,17 +3739,20 @@
|
|
|
3553
3739
|
// reviewer live-reproduced) kept row 1's grip visible at its now-stale
|
|
3554
3740
|
// position instead of hiding it. Fixed by gating the keep-zone on the
|
|
3555
3741
|
// SPECIFIC shown grip's own anchor (pointInRowGripZone()/
|
|
3556
|
-
// pointInColGripZone() below) instead of the whole table
|
|
3557
|
-
//
|
|
3558
|
-
//
|
|
3559
|
-
// grip's
|
|
3560
|
-
//
|
|
3561
|
-
//
|
|
3562
|
-
//
|
|
3563
|
-
//
|
|
3564
|
-
//
|
|
3565
|
-
//
|
|
3566
|
-
//
|
|
3742
|
+
// pointInColGripZone() below) instead of the whole table.
|
|
3743
|
+
//
|
|
3744
|
+
// What each keep-zone covers TODAY: pointInRowGripZone() is the union of
|
|
3745
|
+
// (the row grip's own rect, padded by TE_GRIP_ZONE_PAD_PX for sub-pixel
|
|
3746
|
+
// rounding) and (the vertical span between the grip and its anchor row —
|
|
3747
|
+
// needed because a header-row grip, offset DOWN by TE_HEADER_GRIP_DY_PX,
|
|
3748
|
+
// can sit below the row's own bottom edge; see rowGripOffsetFor() above).
|
|
3749
|
+
// pointInColGripZone() keeps the ORIGINAL corridor shape: the straight
|
|
3750
|
+
// strip between the grip's own top edge and the table's top border, x
|
|
3751
|
+
// clamped to the anchor column's own horizontal extent, padded — because
|
|
3752
|
+
// the column grip still straddles the border.
|
|
3753
|
+
// A pointer outside either grip's own zone is a genuine exit and still
|
|
3754
|
+
// hides the grip via hideTableGrips(), same as before. Neither fix touches
|
|
3755
|
+
// either grip's size or z-index, so the click-priority guarantee (bubble
|
|
3567
3756
|
// z-index:8 > grip z-index:7 — see the comment above buildTableGrip())
|
|
3568
3757
|
// is unaffected — this only changes how long an already-shown grip STAYS
|
|
3569
3758
|
// visible, never where it sits. See
|
|
@@ -3582,10 +3771,15 @@
|
|
|
3582
3771
|
!document.body.contains(gripRowEl) || !document.body.contains(gripRowTableEl)) return false;
|
|
3583
3772
|
const gr = rowGrip.getBoundingClientRect();
|
|
3584
3773
|
if (pointInPaddedRect(x, y, gr, TE_GRIP_ZONE_PAD_PX)) return true;
|
|
3585
|
-
|
|
3774
|
+
// grip 現在在表格內側,所以「從儲存格走向 grip」全程都在表格上,由
|
|
3775
|
+
// updateTableEdgeGrips() 的 onValidCell 分支處理。這裡只需要涵蓋
|
|
3776
|
+
// grip 自己的矩形、以及它與所錨定那一列之間的垂直落差(表頭 grip 被
|
|
3777
|
+
// 往下偏移,可能超出該列的上下緣)。
|
|
3586
3778
|
const rowRect = gripRowEl.getBoundingClientRect();
|
|
3587
|
-
|
|
3588
|
-
|
|
3779
|
+
const top = Math.min(rowRect.top, gr.top) - TE_GRIP_ZONE_PAD_PX;
|
|
3780
|
+
const bottom = Math.max(rowRect.bottom, gr.bottom) + TE_GRIP_ZONE_PAD_PX;
|
|
3781
|
+
return x >= gr.left - TE_GRIP_ZONE_PAD_PX && x <= gr.right + TE_GRIP_ZONE_PAD_PX &&
|
|
3782
|
+
y >= top && y <= bottom;
|
|
3589
3783
|
}
|
|
3590
3784
|
|
|
3591
3785
|
function pointInColGripZone(x, y) {
|
|
@@ -3612,9 +3806,13 @@
|
|
|
3612
3806
|
// pointer (Event#target) at those coordinates, same contract
|
|
3613
3807
|
// updateTableInsertBubbles() above uses.
|
|
3614
3808
|
function updateTableEdgeGrips(x, y, target) {
|
|
3615
|
-
// Both grips
|
|
3616
|
-
//
|
|
3617
|
-
//
|
|
3809
|
+
// Both grips are `position: fixed` overlays appended to document.body
|
|
3810
|
+
// (same as the hover-insert bubbles) rather than descendants of the
|
|
3811
|
+
// table — regardless of whether they PAINT outside it (the column grip,
|
|
3812
|
+
// straddling the top border) or inside it (the row grip, whose left edge
|
|
3813
|
+
// sits on the table's left border and which extends inward over the
|
|
3814
|
+
// first column). So the moment the real pointer crosses from a cell onto
|
|
3815
|
+
// the grip itself, `target` is the grip and is no
|
|
3618
3816
|
// longer inside any '.ed-block[data-block-type="table"]' or 'th, td'.
|
|
3619
3817
|
// Without this guard, that transition would hit the "nothing found"
|
|
3620
3818
|
// branches below and hide the very grip the pointer just moved onto —
|
|
@@ -3645,9 +3843,15 @@
|
|
|
3645
3843
|
const headerRow = headerRowOf(tableEl);
|
|
3646
3844
|
const colIndex = colIndexOf(cellEl);
|
|
3647
3845
|
|
|
3648
|
-
// Row grip:
|
|
3649
|
-
//
|
|
3650
|
-
|
|
3846
|
+
// Row grip: every row, including the header — the first row of a
|
|
3847
|
+
// markdown table IS the header, so any row must be draggable to the
|
|
3848
|
+
// top to become it (rowGripOffsetFor() shifts the header's own grip
|
|
3849
|
+
// down, clear of the column-grip band, so the two never collide). The
|
|
3850
|
+
// one exception is a header-only table (no body rows): its single row
|
|
3851
|
+
// is thead's only row, and dragging it away would empty the thead —
|
|
3852
|
+
// serializeTable() would degrade it and the user's table would vanish
|
|
3853
|
+
// from the page. Withhold the grip there instead.
|
|
3854
|
+
if (rowEl && (rowEl !== headerRow || bodyRowsOf(tableEl).length > 0)) {
|
|
3651
3855
|
gripRowTableEl = tableEl;
|
|
3652
3856
|
gripRowEl = rowEl;
|
|
3653
3857
|
const r = rowEl.getBoundingClientRect();
|
|
@@ -3656,9 +3860,9 @@
|
|
|
3656
3860
|
// is still true on the FIRST show of a hover session, before the
|
|
3657
3861
|
// `hidden = false` assignment below takes effect.
|
|
3658
3862
|
const gh = rowGrip.offsetHeight || 28;
|
|
3659
|
-
const
|
|
3660
|
-
rowGrip.style.left = (tableRect.left
|
|
3661
|
-
rowGrip.style.top = (r.top + r.height / 2 - gh / 2) + 'px';
|
|
3863
|
+
const off = rowGripOffsetFor(rowEl, tableEl);
|
|
3864
|
+
rowGrip.style.left = (tableRect.left + off.dx) + 'px';
|
|
3865
|
+
rowGrip.style.top = (r.top + r.height / 2 - gh / 2 + off.dy) + 'px';
|
|
3662
3866
|
rowGrip.hidden = false;
|
|
3663
3867
|
} else {
|
|
3664
3868
|
gripRowTableEl = null;
|
|
@@ -3693,10 +3897,16 @@
|
|
|
3693
3897
|
if (!target || !target.closest) return null;
|
|
3694
3898
|
if (target.closest('.ed-te-grip-row')) {
|
|
3695
3899
|
if (!gripRowTableEl || !gripRowEl || !document.body.contains(gripRowEl)) return null;
|
|
3696
|
-
return { kind: 'row', tableEl: gripRowTableEl, rowEl: gripRowEl,
|
|
3900
|
+
return { kind: 'row', tableEl: gripRowTableEl, rowEl: gripRowEl,
|
|
3901
|
+
isHeader: gripRowEl === headerRowOf(gripRowTableEl) };
|
|
3697
3902
|
}
|
|
3698
3903
|
if (target.closest('.ed-te-grip-col')) {
|
|
3699
|
-
|
|
3904
|
+
// The `document.body.contains()` detach check mirrors the row branch
|
|
3905
|
+
// above: the two axes are symmetric gestures now (Task 8 gave the
|
|
3906
|
+
// column its own drag), so a stale `gripColTableEl` left pointing at a
|
|
3907
|
+
// table that a rerenderAll()/burst-resolution already swapped out must
|
|
3908
|
+
// fail the hit-test rather than hand a detached node to performColDrop().
|
|
3909
|
+
if (!gripColTableEl || gripColIndex == null || !document.body.contains(gripColTableEl)) return null;
|
|
3700
3910
|
return { kind: 'col', tableEl: gripColTableEl, colIndex: gripColIndex };
|
|
3701
3911
|
}
|
|
3702
3912
|
return null;
|
|
@@ -3710,30 +3920,46 @@
|
|
|
3710
3920
|
teDropIndicator.hidden = true;
|
|
3711
3921
|
document.body.appendChild(teDropIndicator);
|
|
3712
3922
|
|
|
3713
|
-
// Nearest
|
|
3714
|
-
//
|
|
3715
|
-
//
|
|
3716
|
-
//
|
|
3717
|
-
//
|
|
3923
|
+
// Nearest row-drop target for `clientY` — a discriminated union:
|
|
3924
|
+
// {mode:'above-header', y} | {mode:'before-row', rowIndex, y} |
|
|
3925
|
+
// {mode:'append', y}. `rowIndex` is an ordinal into allRowsOf(). The
|
|
3926
|
+
// header row is now itself a candidate boundary (spec §4.6: the first row
|
|
3927
|
+
// of a markdown table IS its header, so promoting any row to first place
|
|
3928
|
+
// has to go through an explicit "above the header" target) — `<=` on the
|
|
3929
|
+
// header's own midline gives "released exactly on the header's centre" an
|
|
3930
|
+
// unambiguous home in `above-header` rather than leaving it to float
|
|
3931
|
+
// between two branches.
|
|
3932
|
+
//
|
|
3933
|
+
// spec §4.6:`<=` 讓「釋放點恰在表頭正中央」有明確歸屬(above-header)。
|
|
3934
|
+
// 既有那條綠測試釋放在表頭列的 bottom,落在 FALSE 側,仍走下面的
|
|
3935
|
+
// body 中線鏈、仍得 3,1,2,因此不需要改它的期望值。
|
|
3718
3936
|
function nearestRowDropTarget(tableEl, clientY) {
|
|
3937
|
+
const headerRow = headerRowOf(tableEl);
|
|
3938
|
+
if (headerRow) {
|
|
3939
|
+
const hr = headerRow.getBoundingClientRect();
|
|
3940
|
+
if (clientY <= hr.top + hr.height / 2) return { mode: 'above-header', y: hr.top };
|
|
3941
|
+
}
|
|
3942
|
+
const all = allRowsOf(tableEl);
|
|
3719
3943
|
const rows = bodyRowsOf(tableEl);
|
|
3720
3944
|
for (let i = 0; i < rows.length; i++) {
|
|
3721
3945
|
const r = rows[i].getBoundingClientRect();
|
|
3722
|
-
if (clientY < r.top + r.height / 2)
|
|
3946
|
+
if (clientY < r.top + r.height / 2) {
|
|
3947
|
+
return { mode: 'before-row', rowIndex: all.indexOf(rows[i]), y: r.top };
|
|
3948
|
+
}
|
|
3723
3949
|
}
|
|
3724
3950
|
const last = rows[rows.length - 1];
|
|
3725
|
-
const headerRow = headerRowOf(tableEl);
|
|
3726
3951
|
const y = last ? last.getBoundingClientRect().bottom
|
|
3727
3952
|
: (headerRow ? headerRow.getBoundingClientRect().bottom : tableEl.getBoundingClientRect().top);
|
|
3728
|
-
return {
|
|
3953
|
+
return { mode: 'append', y };
|
|
3729
3954
|
}
|
|
3730
3955
|
|
|
3731
3956
|
// The in-flight edge-zone pointer gesture (press-then-either-click-or-
|
|
3732
3957
|
// drag), or null between gestures. `hit` is whatever hitTestGrip()
|
|
3733
3958
|
// returned at pointerdown; `dragging` flips true once TE_DRAG_THRESHOLD_PX
|
|
3734
3959
|
// is crossed (row zones only — see the pointermove listener below);
|
|
3735
|
-
// `
|
|
3736
|
-
// moves while dragging
|
|
3960
|
+
// `dropTarget` is filled in by updateDropIndicator() as the pointer
|
|
3961
|
+
// moves while dragging (the nearestRowDropTarget() union — see its own
|
|
3962
|
+
// comment above). `pointerId`/`captureEl` back the pointer-capture
|
|
3737
3963
|
// review fix below — see cancelTeDrag()'s comment for why this gesture
|
|
3738
3964
|
// needs it at all.
|
|
3739
3965
|
let tePointer = null;
|
|
@@ -3741,11 +3967,30 @@
|
|
|
3741
3967
|
function updateDropIndicator(clientY) {
|
|
3742
3968
|
const tableEl = tePointer.hit.tableEl;
|
|
3743
3969
|
const target = nearestRowDropTarget(tableEl, clientY);
|
|
3744
|
-
tePointer.
|
|
3970
|
+
tePointer.dropTarget = target;
|
|
3745
3971
|
const tableRect = tableEl.getBoundingClientRect();
|
|
3746
3972
|
teDropIndicator.style.left = tableRect.left + 'px';
|
|
3747
3973
|
teDropIndicator.style.width = tableRect.width + 'px';
|
|
3748
3974
|
teDropIndicator.style.top = (target.y - 1) + 'px';
|
|
3975
|
+
// The indicator is a shared singleton with the COLUMN drop indicator
|
|
3976
|
+
// (updateColDropIndicator() below), which drives a vertical line and
|
|
3977
|
+
// therefore sets `height` itself — a row drag must write its own back
|
|
3978
|
+
// every time or a prior column drag's height would leak into this one.
|
|
3979
|
+
teDropIndicator.style.height = '3px';
|
|
3980
|
+
}
|
|
3981
|
+
|
|
3982
|
+
// Column-drop counterpart of updateDropIndicator() above: a vertical line
|
|
3983
|
+
// spanning the table's full height at the nearest column boundary, rather
|
|
3984
|
+
// than a horizontal line spanning its width.
|
|
3985
|
+
function updateColDropIndicator(clientX) {
|
|
3986
|
+
const tableEl = tePointer.hit.tableEl;
|
|
3987
|
+
const target = nearestColDropTarget(tableEl, clientX);
|
|
3988
|
+
tePointer.dropTarget = target;
|
|
3989
|
+
const tableRect = tableEl.getBoundingClientRect();
|
|
3990
|
+
teDropIndicator.style.left = (target.x - 1) + 'px';
|
|
3991
|
+
teDropIndicator.style.width = '3px';
|
|
3992
|
+
teDropIndicator.style.top = tableRect.top + 'px';
|
|
3993
|
+
teDropIndicator.style.height = tableRect.height + 'px';
|
|
3749
3994
|
}
|
|
3750
3995
|
|
|
3751
3996
|
// Review fix (Critical): best-effort releasePointerCapture() — a no-op
|
|
@@ -3786,42 +4031,188 @@
|
|
|
3786
4031
|
if (!tePointer) return;
|
|
3787
4032
|
releaseTeCapture(tePointer);
|
|
3788
4033
|
if (tePointer.dragging && tePointer.hit && tePointer.hit.rowEl) {
|
|
3789
|
-
tePointer.hit.rowEl
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
4034
|
+
const draggedRowEl = tePointer.hit.rowEl;
|
|
4035
|
+
draggedRowEl.classList.remove('ed-te-row-dragging');
|
|
4036
|
+
// classList.add() CREATED the attribute on a renderer-emitted `<tr>`
|
|
4037
|
+
// (marked's table renderer emits rows with no class at all), and
|
|
4038
|
+
// classList.remove() leaves `class=""` behind rather than dropping
|
|
4039
|
+
// it. That residue is a real innerHTML diff, so resolveBurst()'s
|
|
4040
|
+
// zero-edit guard (`burst.editEl.innerHTML === burst.original`) would
|
|
4041
|
+
// see "edited" for a gesture that changed nothing and canonically
|
|
4042
|
+
// rewrite a hand-padded table. Drop the attribute when it went empty.
|
|
4043
|
+
if (!draggedRowEl.className) draggedRowEl.removeAttribute('class');
|
|
4044
|
+
}
|
|
4045
|
+
// Either grip may still be wearing its "active drag handle" visual (see
|
|
4046
|
+
// the pointermove listener below) — strip both unconditionally, same
|
|
3793
4047
|
// belt-and-braces reasoning as the `ed-te-row-dragging` removal above.
|
|
3794
4048
|
rowGrip.classList.remove('ed-te-grip-dragging');
|
|
4049
|
+
colGrip.classList.remove('ed-te-grip-dragging');
|
|
3795
4050
|
teDropIndicator.hidden = true;
|
|
3796
4051
|
tePointer = null;
|
|
3797
4052
|
}
|
|
3798
4053
|
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
4054
|
+
// 對齊是**欄**屬性(分隔列由表頭 cells 的 style 合成),所以重建前先讀
|
|
4055
|
+
// 出來、重建後套回新的表頭列。只在 align 非 null 時寫,否則會把原本
|
|
4056
|
+
// 沒有 style 的欄寫成 ':---'(spec §4.6 的 attribute-byte 冪等要求)。
|
|
4057
|
+
function columnAlignsOf(tableEl) {
|
|
4058
|
+
const headerRow = headerRowOf(tableEl);
|
|
4059
|
+
if (!headerRow) return [];
|
|
4060
|
+
return Array.prototype.slice.call(headerRow.cells).map(cellStyleAlign);
|
|
4061
|
+
}
|
|
4062
|
+
|
|
4063
|
+
// TH ↔ TD 改名。tag 已經正確就原樣返回——**不重造**,否則屬性落地順序
|
|
4064
|
+
// 會與 armEditables() 不同,innerHTML 隨之改變,於是「原地放回」也會
|
|
4065
|
+
// 被 zero-edit guard 判定為有編輯而 commit(整表 canonical 重寫)。
|
|
4066
|
+
//
|
|
4067
|
+
// 需要重造時,屬性一律照 cell.attributes 的**原順序**逐一複製,不做任何
|
|
4068
|
+
// 特例、也不對「當初是怎麼 arm 的」做任何假設:重造出來的 cell 屬性序列
|
|
4069
|
+
// 與原本那顆逐字相同,byte-identity 要的就只是這個。
|
|
4070
|
+
//
|
|
4071
|
+
// 之所以不能寫死順序(連「contenteditable 一律擺最後」都不行):arm 當下
|
|
4072
|
+
// 的順序**逐欄不同**,取決於 classifyColumns()(lib/md2doc.js)給那一欄的
|
|
4073
|
+
// 等級。col-narrow / col-prose 的 cell renderer 會給 class,armEditables()
|
|
4074
|
+
// 再把 contenteditable 接在後面 → `class, style, contenteditable`;但
|
|
4075
|
+
// col-default 的 cell renderer **完全不給 class**,於是
|
|
4076
|
+
// setAttribute('contenteditable') 先落地、classList.add('ed-wys-cell') 才
|
|
4077
|
+
// 把 class 建出來 → `style, contenteditable, class`。任何固定順序都會弄壞
|
|
4078
|
+
// 其中一種,讓「拖下去再拖回來」列序還原、位元卻沒還原,觸發整表
|
|
4079
|
+
// canonical 重寫。逐字複製對三種等級同時成立。
|
|
4080
|
+
function retagCell(cell, tagName) {
|
|
4081
|
+
if (cell.nodeName === tagName) return cell;
|
|
4082
|
+
const next = document.createElement(tagName.toLowerCase());
|
|
4083
|
+
Array.prototype.slice.call(cell.attributes).forEach((attr) => {
|
|
4084
|
+
next.setAttribute(attr.name, attr.value);
|
|
4085
|
+
});
|
|
4086
|
+
while (cell.firstChild) next.appendChild(cell.firstChild);
|
|
4087
|
+
cell.parentElement.replaceChild(next, cell);
|
|
4088
|
+
return next;
|
|
4089
|
+
}
|
|
4090
|
+
|
|
4091
|
+
// 依 orderedRows 重建 thead/tbody:第一列進 thead(cells 轉 th),其餘
|
|
4092
|
+
// 進 tbody(cells 轉 td)。不變式「thead 恰有一列」由此保證。
|
|
4093
|
+
function rebuildTableSections(tableEl, orderedRows, aligns) {
|
|
4094
|
+
const thead = tableEl.tHead;
|
|
4095
|
+
const tbody = tableEl.tBodies[0];
|
|
4096
|
+
if (!thead || !tbody || orderedRows.length === 0) return;
|
|
4097
|
+
orderedRows.forEach((row, i) => {
|
|
4098
|
+
const wantTag = i === 0 ? 'TH' : 'TD';
|
|
4099
|
+
Array.prototype.slice.call(row.cells).forEach((cell) => retagCell(cell, wantTag));
|
|
4100
|
+
(i === 0 ? thead : tbody).appendChild(row);
|
|
4101
|
+
});
|
|
4102
|
+
const newHeader = orderedRows[0];
|
|
4103
|
+
aligns.forEach((align, i) => {
|
|
4104
|
+
const cell = newHeader.cells[i];
|
|
4105
|
+
if (cell && align) cell.setAttribute('style', 'text-align:' + align);
|
|
4106
|
+
});
|
|
4107
|
+
}
|
|
4108
|
+
|
|
4109
|
+
// 重建必然 detach 持有焦點的儲存格;focusout 被 suppressTableFocusout
|
|
4110
|
+
// 吃掉之後沒有人會把焦點放回去,document.activeElement 會落到 <body>,
|
|
4111
|
+
// 於是 keydown 走不到 handleTableCellKeydown,Ctrl+Z 會落到全域 undo()
|
|
4112
|
+
// 而先 commit 再退。用序位重新解析目標格並真的 focus。
|
|
4113
|
+
function restoreTableFocus(tableEl, cellIndex) {
|
|
4114
|
+
const cells = tableCellsOf(tableEl);
|
|
4115
|
+
const cell = cells[cellIndex >= 0 ? Math.min(cellIndex, cells.length - 1) : 0];
|
|
4116
|
+
if (!cell) return;
|
|
4117
|
+
if (currentBurst && currentBurst.blockType === 'table') currentBurst.activeCellEl = cell;
|
|
4118
|
+
selToolbarEditEl = cell;
|
|
4119
|
+
cell.focus();
|
|
4120
|
+
placeCaretAtEnd(cell);
|
|
4121
|
+
}
|
|
4122
|
+
|
|
4123
|
+
async function performRowDrop(tableEl, rowEl, dropTarget) {
|
|
4124
|
+
// rowEl/dropTarget 都是 pointerdown/拖曳期間抓的;ensureTableBurstOpen()
|
|
4125
|
+
// 可能 resolve 掉別的 block 的 dirty burst 並換掉整片 .content,所以
|
|
4126
|
+
// 先轉成 allRowsOf() 的 ordinal,之後在 live table 上重新定位。
|
|
3808
4127
|
const rowIndex = allRowsOf(tableEl).indexOf(rowEl);
|
|
3809
|
-
const beforeRowIndex = beforeRow ? allRowsOf(tableEl).indexOf(beforeRow) : -1;
|
|
3810
4128
|
const liveTableEl = await ensureTableBurstOpen(tableEl);
|
|
3811
4129
|
if (!liveTableEl) return;
|
|
3812
4130
|
const liveRows = allRowsOf(liveTableEl);
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
if (
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
4131
|
+
if (rowIndex < 0 || !liveRows[rowIndex]) return;
|
|
4132
|
+
|
|
4133
|
+
let toIndex;
|
|
4134
|
+
if (dropTarget.mode === 'above-header') toIndex = 0;
|
|
4135
|
+
else if (dropTarget.mode === 'append') toIndex = liveRows.length;
|
|
4136
|
+
else toIndex = dropTarget.rowIndex;
|
|
4137
|
+
|
|
4138
|
+
const order = liveRows.slice();
|
|
4139
|
+
const moved = order.splice(rowIndex, 1)[0];
|
|
4140
|
+
order.splice(toIndex > rowIndex ? toIndex - 1 : toIndex, 0, moved);
|
|
4141
|
+
if (order.every((row, i) => row === liveRows[i])) return; // 原地放回:不動 DOM、不 snap
|
|
4142
|
+
|
|
4143
|
+
const aligns = columnAlignsOf(liveTableEl);
|
|
4144
|
+
const activeIndex = (currentBurst && currentBurst.activeCellEl)
|
|
4145
|
+
? tableCellsOf(liveTableEl).indexOf(currentBurst.activeCellEl) : -1;
|
|
4146
|
+
suppressTableFocusout = true;
|
|
4147
|
+
try {
|
|
4148
|
+
rebuildTableSections(liveTableEl, order, aligns);
|
|
4149
|
+
} finally {
|
|
4150
|
+
suppressTableFocusout = false;
|
|
4151
|
+
}
|
|
4152
|
+
armNewTableCells(liveTableEl);
|
|
4153
|
+
restoreTableFocus(liveTableEl, activeIndex);
|
|
4154
|
+
// 插入路徑早就這樣做了,drop 路徑一直沒有:不清的話 grip 還釘在舊
|
|
4155
|
+
// 座標、teMenuColIndex 指向已經換位的欄。
|
|
4156
|
+
hideTableGrips();
|
|
4157
|
+
hideTableEdgeMenu();
|
|
4158
|
+
// 一律 snap(去重交給 history 自己)。舊碼用 nextSibling 比對判斷
|
|
4159
|
+
// 「有沒有動」,對「第一列與唯一 body 列對調」永遠回 false。
|
|
4160
|
+
currentBurst.history.snap('drag-row');
|
|
4161
|
+
}
|
|
4162
|
+
|
|
4163
|
+
// 欄落點:以表頭各 cell 的中線決定要插到哪個 ordinal 之前。
|
|
4164
|
+
function nearestColDropTarget(tableEl, clientX) {
|
|
4165
|
+
const headerRow = headerRowOf(tableEl);
|
|
4166
|
+
if (!headerRow) return { index: 0, x: tableEl.getBoundingClientRect().left };
|
|
4167
|
+
const cells = Array.prototype.slice.call(headerRow.cells);
|
|
4168
|
+
for (let i = 0; i < cells.length; i++) {
|
|
4169
|
+
const r = cells[i].getBoundingClientRect();
|
|
4170
|
+
if (clientX < r.left + r.width / 2) return { index: i, x: r.left };
|
|
4171
|
+
}
|
|
4172
|
+
const last = cells[cells.length - 1].getBoundingClientRect();
|
|
4173
|
+
return { index: cells.length, x: last.right };
|
|
4174
|
+
}
|
|
4175
|
+
|
|
4176
|
+
// <colgroup> 決定欄寬,且編輯器至今從沒碰過它;不跟著搬的話欄寬會錯位,
|
|
4177
|
+
// 而 table-md.js 不看 colgroup ⇒ 純 markdown 斷言抓不到這個 bug。
|
|
4178
|
+
function reorderColgroup(tableEl, fromIndex, toIndex) {
|
|
4179
|
+
const cg = tableEl.querySelector('colgroup');
|
|
4180
|
+
if (!cg) return;
|
|
4181
|
+
const cols = Array.prototype.slice.call(cg.children);
|
|
4182
|
+
if (!cols[fromIndex]) return;
|
|
4183
|
+
const moved = cols.splice(fromIndex, 1)[0];
|
|
4184
|
+
cols.splice(toIndex > fromIndex ? toIndex - 1 : toIndex, 0, moved);
|
|
4185
|
+
cols.forEach((c) => cg.appendChild(c));
|
|
4186
|
+
}
|
|
4187
|
+
|
|
4188
|
+
async function performColDrop(tableEl, fromIndex, toIndex) {
|
|
4189
|
+
const liveTableEl = await ensureTableBurstOpen(tableEl);
|
|
4190
|
+
if (!liveTableEl) return;
|
|
4191
|
+
if (toIndex === fromIndex || toIndex === fromIndex + 1) return; // 原地放回
|
|
4192
|
+
const activeIndex = (currentBurst && currentBurst.activeCellEl)
|
|
4193
|
+
? tableCellsOf(liveTableEl).indexOf(currentBurst.activeCellEl) : -1;
|
|
4194
|
+
// 短列必須讓整個操作放棄,不能只跳過那一列(final review M4):原本
|
|
4195
|
+
// `if (!moved) return;` 在 forEach 裡面,短列會被略過、其他列照搬 —— 結果
|
|
4196
|
+
// 是欄位彼此錯位,而每一列的 cell 數量都跟原本一樣,ragged-table guard
|
|
4197
|
+
// 看不出任何異常。寧可整個不動。
|
|
4198
|
+
const dropRows = allRowsOf(liveTableEl);
|
|
4199
|
+
if (!dropRows.length || dropRows.some((row) => !row.cells[fromIndex])) return;
|
|
4200
|
+
suppressTableFocusout = true;
|
|
4201
|
+
try {
|
|
4202
|
+
dropRows.forEach((row) => {
|
|
4203
|
+
const cells = Array.prototype.slice.call(row.cells);
|
|
4204
|
+
const moved = cells.splice(fromIndex, 1)[0];
|
|
4205
|
+
cells.splice(toIndex > fromIndex ? toIndex - 1 : toIndex, 0, moved);
|
|
4206
|
+
cells.forEach((c) => row.appendChild(c));
|
|
4207
|
+
});
|
|
4208
|
+
reorderColgroup(liveTableEl, fromIndex, toIndex);
|
|
4209
|
+
} finally {
|
|
4210
|
+
suppressTableFocusout = false;
|
|
4211
|
+
}
|
|
4212
|
+
restoreTableFocus(liveTableEl, activeIndex);
|
|
4213
|
+
hideTableGrips();
|
|
4214
|
+
hideTableEdgeMenu();
|
|
4215
|
+
currentBurst.history.snap('drag-col');
|
|
3825
4216
|
}
|
|
3826
4217
|
|
|
3827
4218
|
document.addEventListener('pointerdown', (e) => {
|
|
@@ -3844,7 +4235,11 @@
|
|
|
3844
4235
|
// reopening it here.
|
|
3845
4236
|
const isSameSelection = hit && teMenuKind === hit.kind && teMenuTableEl === hit.tableEl &&
|
|
3846
4237
|
(hit.kind === 'col' ? teMenuColIndex === hit.colIndex : teMenuRowEl === hit.rowEl);
|
|
3847
|
-
|
|
4238
|
+
// The highlight can now exist WITHOUT a menu (a header grip's plain
|
|
4239
|
+
// click, above) — so the dismiss condition can no longer gate on
|
|
4240
|
+
// teMenuKind alone, or that highlight would survive until
|
|
4241
|
+
// resolveBurst() instead of clearing on the next click.
|
|
4242
|
+
if ((teMenuKind || teHighlightEls.length) && !isSameSelection) hideTableEdgeMenu();
|
|
3848
4243
|
if (!hit) return;
|
|
3849
4244
|
e.preventDefault();
|
|
3850
4245
|
tePointer = { hit, startX: e.clientX, startY: e.clientY, dragging: false,
|
|
@@ -3866,25 +4261,33 @@
|
|
|
3866
4261
|
|
|
3867
4262
|
document.addEventListener('pointermove', (e) => {
|
|
3868
4263
|
if (!tePointer) return;
|
|
3869
|
-
if (tePointer.hit.kind !== 'row'
|
|
4264
|
+
if (tePointer.hit.kind !== 'row' && tePointer.hit.kind !== 'col') return;
|
|
3870
4265
|
if (!tePointer.dragging) {
|
|
3871
4266
|
const dx = e.clientX - tePointer.startX, dy = e.clientY - tePointer.startY;
|
|
3872
4267
|
if (Math.hypot(dx, dy) < TE_DRAG_THRESHOLD_PX) return;
|
|
3873
4268
|
tePointer.dragging = true;
|
|
3874
4269
|
hideTableEdgeMenu();
|
|
3875
4270
|
hideTableInsertBubbles();
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
4271
|
+
if (tePointer.hit.kind === 'row') {
|
|
4272
|
+
// The column grip hides like the insert bubbles above (it isn't
|
|
4273
|
+
// meaningful mid row-drag); the ROW grip stays visible and switches
|
|
4274
|
+
// to its "dragging" visual (grabbing cursor) — it IS the drag
|
|
4275
|
+
// handle the user is holding, per the brief ("the active grip may
|
|
4276
|
+
// stay as the drag handle visual").
|
|
4277
|
+
colGrip.hidden = true;
|
|
4278
|
+
rowGrip.classList.add('ed-te-grip-dragging');
|
|
4279
|
+
tePointer.hit.rowEl.classList.add('ed-te-row-dragging');
|
|
4280
|
+
} else {
|
|
4281
|
+
// Symmetric for a column drag: the row grip hides, the column grip
|
|
4282
|
+
// itself becomes the drag handle visual.
|
|
4283
|
+
rowGrip.hidden = true;
|
|
4284
|
+
colGrip.classList.add('ed-te-grip-dragging');
|
|
4285
|
+
}
|
|
3884
4286
|
teDropIndicator.hidden = false;
|
|
3885
4287
|
}
|
|
3886
4288
|
e.preventDefault();
|
|
3887
|
-
updateDropIndicator(e.clientY);
|
|
4289
|
+
if (tePointer.hit.kind === 'row') updateDropIndicator(e.clientY);
|
|
4290
|
+
else updateColDropIndicator(e.clientX);
|
|
3888
4291
|
});
|
|
3889
4292
|
|
|
3890
4293
|
document.addEventListener('pointerup', async (e) => {
|
|
@@ -3893,16 +4296,35 @@
|
|
|
3893
4296
|
releaseTeCapture(st);
|
|
3894
4297
|
tePointer = null;
|
|
3895
4298
|
if (st.dragging) {
|
|
3896
|
-
st.hit.
|
|
4299
|
+
if (st.hit.kind === 'row') {
|
|
4300
|
+
st.hit.rowEl.classList.remove('ed-te-row-dragging');
|
|
4301
|
+
// Drop a now-empty `class=""` — see cancelTeDrag()'s own comment
|
|
4302
|
+
// above for why the residue alone defeats the zero-edit guard. (The
|
|
4303
|
+
// grips below need no such treatment: they are OUR elements and
|
|
4304
|
+
// always carry at least 'ed-te-grip-row'/'ed-te-grip-col', so
|
|
4305
|
+
// removing one class can never empty their attribute.)
|
|
4306
|
+
if (!st.hit.rowEl.className) st.hit.rowEl.removeAttribute('class');
|
|
4307
|
+
}
|
|
3897
4308
|
rowGrip.classList.remove('ed-te-grip-dragging');
|
|
4309
|
+
colGrip.classList.remove('ed-te-grip-dragging');
|
|
3898
4310
|
teDropIndicator.hidden = true;
|
|
3899
|
-
await performRowDrop(st.hit.tableEl, st.hit.rowEl, st.
|
|
4311
|
+
if (st.hit.kind === 'row') await performRowDrop(st.hit.tableEl, st.hit.rowEl, st.dropTarget);
|
|
4312
|
+
else await performColDrop(st.hit.tableEl, st.hit.colIndex, st.dropTarget.index);
|
|
3900
4313
|
return;
|
|
3901
4314
|
}
|
|
3902
4315
|
// A plain press-release with no drag threshold crossed: open the menu
|
|
3903
4316
|
// for whatever zone was hit at pointerdown.
|
|
3904
4317
|
if (st.hit.kind === 'col') showColumnMenu(st.hit.tableEl, st.hit.colIndex);
|
|
3905
|
-
else
|
|
4318
|
+
else if (st.hit.isHeader) {
|
|
4319
|
+
// The row menu's only item is "delete row", and the header row can
|
|
4320
|
+
// never be deleted — showing it would just be an empty box. A plain
|
|
4321
|
+
// click on the header grip highlights the row instead.
|
|
4322
|
+
// clearEdgeHighlight() runs first so re-clicking a different header
|
|
4323
|
+
// (or a different row's menu having been open) doesn't stack
|
|
4324
|
+
// highlights within the same session.
|
|
4325
|
+
clearEdgeHighlight();
|
|
4326
|
+
highlightRow(st.hit.rowEl);
|
|
4327
|
+
} else showRowMenu(st.hit.tableEl, st.hit.rowEl);
|
|
3906
4328
|
});
|
|
3907
4329
|
|
|
3908
4330
|
// Review fix (Critical): the browser/OS can ABORT a gesture outright —
|
|
@@ -4381,7 +4803,7 @@
|
|
|
4381
4803
|
try {
|
|
4382
4804
|
res = await fetch('/api/save', {
|
|
4383
4805
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
4384
|
-
body: JSON.stringify({ fileId: ED.fileId, content: lines.join(
|
|
4806
|
+
body: JSON.stringify({ fileId: ED.fileId, content: lines.join(EOL), baseMtimeMs: mtimeMs }),
|
|
4385
4807
|
});
|
|
4386
4808
|
} catch (e) {
|
|
4387
4809
|
showBanner('Save failed — network error (' + describeFailure(e) +
|
|
@@ -4467,7 +4889,17 @@
|
|
|
4467
4889
|
// since the cell that was focused (if any) before the menu opened is
|
|
4468
4890
|
// still focused underneath it (the menu's own mousedown preventDefault()
|
|
4469
4891
|
// never stole focus).
|
|
4470
|
-
|
|
4892
|
+
// Final review I1: the condition is `(teMenuKind || teHighlightEls.length)`,
|
|
4893
|
+
// NOT `teMenuKind` alone — the exact same widening the pointerdown dismiss
|
|
4894
|
+
// gate already got, and for the same reason: a header grip's click
|
|
4895
|
+
// deliberately produces a highlight with NO menu (its only menu item,
|
|
4896
|
+
// "delete row", cannot apply to a header), so teMenuKind stays null. Gated
|
|
4897
|
+
// on teMenuKind alone, that Esc fell through to handleTableCellKeydown()'s
|
|
4898
|
+
// own Escape branch -> revertTableBurstAndEnd(), throwing away everything
|
|
4899
|
+
// typed into the burst. With a BODY row's menu open the identical keypress
|
|
4900
|
+
// merely closes the menu, so the header row would have been the one place
|
|
4901
|
+
// where dismissing a selection is destructive.
|
|
4902
|
+
if ((teMenuKind || teHighlightEls.length) && e.key === 'Escape') {
|
|
4471
4903
|
e.preventDefault();
|
|
4472
4904
|
hideTableEdgeMenu();
|
|
4473
4905
|
return;
|
|
@@ -4702,15 +5134,24 @@
|
|
|
4702
5134
|
// without this gate, an active row drag would repaint the + bubble
|
|
4703
5135
|
// (or reposition/re-show the grips over some OTHER row/column the
|
|
4704
5136
|
// cursor is currently dragging across) on TOP of the drop indicator on
|
|
4705
|
-
// every real drag. Explicitly HIDE the insert bubbles and the
|
|
5137
|
+
// every real drag. Explicitly HIDE the insert bubbles and the OTHER
|
|
4706
5138
|
// grip (not just skip recomputing) so anything already showing from
|
|
4707
5139
|
// the moment just before the drag threshold was crossed doesn't linger
|
|
4708
|
-
// stale for the rest of the gesture.
|
|
4709
|
-
//
|
|
4710
|
-
//
|
|
4711
|
-
//
|
|
4712
|
-
//
|
|
4713
|
-
|
|
5140
|
+
// stale for the rest of the gesture. Task 8 fix round 1 (Important 1):
|
|
5141
|
+
// a column drag is now possible too, so "the other grip" is no longer
|
|
5142
|
+
// always the column grip — hide `colGrip` during a row drag, `rowGrip`
|
|
5143
|
+
// during a column drag. The ACTIVE grip (whichever kind is being
|
|
5144
|
+
// dragged) is deliberately left untouched here — the pointermove
|
|
5145
|
+
// listener above already switched it to its "dragging" visual (see
|
|
5146
|
+
// `ed-te-grip-dragging`) as the drag's own handle, and this gate must
|
|
5147
|
+
// not fight that by hiding it or repositioning it onto whatever
|
|
5148
|
+
// row/column the cursor happens to be over.
|
|
5149
|
+
if (tePointer && tePointer.dragging) {
|
|
5150
|
+
hideTableInsertBubbles();
|
|
5151
|
+
if (tePointer.hit.kind === 'row') colGrip.hidden = true;
|
|
5152
|
+
else rowGrip.hidden = true;
|
|
5153
|
+
return;
|
|
5154
|
+
}
|
|
4714
5155
|
updateTableInsertBubbles(tbMoveX, tbMoveY, tbMoveTarget);
|
|
4715
5156
|
updateTableEdgeGrips(tbMoveX, tbMoveY, tbMoveTarget);
|
|
4716
5157
|
});
|
package/lib/editor/server.js
CHANGED
|
@@ -81,9 +81,23 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
|
|
|
81
81
|
if (!file || !fs.existsSync(file)) return send(res, 404, { error: 'unknown file' });
|
|
82
82
|
const mdText = fs.readFileSync(file, 'utf8');
|
|
83
83
|
const mtimeMs = fs.statSync(file).mtimeMs;
|
|
84
|
+
// EOL 偵測與拆行:lines 內部一律不含 \r(spec §3.11)。只有
|
|
85
|
+
// /api/save 會把它接回檔案原本的 EOL;/api/render 一律用 \n。
|
|
86
|
+
//
|
|
87
|
+
// 多數決,不是「有 CRLF 就算 CRLF」(final review I3):save 會把
|
|
88
|
+
// `lines` 全部用同一個 eol 接回去,所以一萬行的 LF 檔裡混進一行
|
|
89
|
+
// CRLF,舊式偵測會在第一次存檔時把一萬行全部改寫成 CRLF ——
|
|
90
|
+
// 直接違反 spec §3.11 第 4 點「commit 範圍以外的行保留原位元組」。
|
|
91
|
+
// 多數決把損害限制在少數派那幾行。平手時取 LF(git / POSIX 預設)。
|
|
92
|
+
// 用「\n 總數 − CRLF 數」算裸 LF,而不是 /(^|[^\r])\n/g:後者是
|
|
93
|
+
// non-overlapping 比對,連續空行的第二個 \n 會被前一次比對吃掉的
|
|
94
|
+
// 字元擋掉而漏數。減法沒有這個誤差。
|
|
95
|
+
const lfTotal = (mdText.match(/\n/g) || []).length;
|
|
96
|
+
const crlfCount = (mdText.match(/\r\n/g) || []).length;
|
|
97
|
+
const eol = crlfCount > (lfTotal - crlfCount) ? '\r\n' : '\n';
|
|
84
98
|
const { html, blocks } = await renderMarkdown(mdText, file, { editMode: true });
|
|
85
99
|
const payload = JSON.stringify({
|
|
86
|
-
fileId, mtimeMs, lines: mdText.split(
|
|
100
|
+
fileId, mtimeMs, eol, lines: mdText.split(/\r\n|\n/), blocks,
|
|
87
101
|
});
|
|
88
102
|
const inject =
|
|
89
103
|
`<script>window.__ED__ = ${payload.replace(/</g, '\\u003c')}</script>\n` +
|
package/lib/editor/table-md.js
CHANGED
|
@@ -152,11 +152,23 @@
|
|
|
152
152
|
const headerRow = thead ? firstChildNamed(thead, 'TR') : null;
|
|
153
153
|
const headerCells = headerRow ? elementChildren(headerRow).filter(isCell) : [];
|
|
154
154
|
|
|
155
|
+
const bodyRows = tbody ? childrenNamed(tbody, 'TR') : [];
|
|
156
|
+
// degrade-never-lose:這兩種形狀序列化出去就回不來了。
|
|
157
|
+
// 空表頭會輸出 '| |' + '||',re-lex 成 paragraph(整張表消失);
|
|
158
|
+
// 比表頭寬的 body 列,重讀時多出來的欄會被直接丟掉。
|
|
159
|
+
if (headerCells.length === 0) {
|
|
160
|
+
return { md: '', unsupported: ['TABLE_NO_HEADER'] };
|
|
161
|
+
}
|
|
162
|
+
const ragged = bodyRows.some((tr) =>
|
|
163
|
+
elementChildren(tr).filter(isCell).length !== headerCells.length);
|
|
164
|
+
if (ragged) {
|
|
165
|
+
return { md: '', unsupported: ['TABLE_RAGGED'] };
|
|
166
|
+
}
|
|
167
|
+
|
|
155
168
|
const lines = [];
|
|
156
169
|
lines.push(serializeRow(headerCells, unsupported));
|
|
157
170
|
lines.push('|' + headerCells.map((c) => sepCellFor(cellAlign(c))).join('|') + '|');
|
|
158
171
|
|
|
159
|
-
const bodyRows = tbody ? childrenNamed(tbody, 'TR') : [];
|
|
160
172
|
bodyRows.forEach((tr) => {
|
|
161
173
|
const cells = elementChildren(tr).filter(isCell);
|
|
162
174
|
lines.push(serializeRow(cells, unsupported));
|
package/lib/md2doc.js
CHANGED
|
@@ -1825,13 +1825,15 @@ ${itemsHtml}
|
|
|
1825
1825
|
.ed-tb-insert:hover { background: #3b82f6; color: #fff; }
|
|
1826
1826
|
.ed-tb-insert[hidden] { display: none; }
|
|
1827
1827
|
/* Task 6: table edge-click menus (delete/align) + row drag-reorder.
|
|
1828
|
-
'.ed-te-hl' marks the currently-selected column's
|
|
1829
|
-
(the <tr
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1828
|
+
'.ed-te-hl' marks the currently-selected column's or row's CELLS
|
|
1829
|
+
(th+td) — never the <tr>, which paints nothing the user can see once a
|
|
1830
|
+
th/td above it carries its own opaque background (S3). !important
|
|
1831
|
+
because the sticky-first-column rules below (tbody td:first-child /
|
|
1832
|
+
thead th:first-child) carry higher specificity (0,1,2 vs this class's
|
|
1833
|
+
0,1,0) and would otherwise win over a first-column/first-row highlight
|
|
1834
|
+
despite this rule appearing later in source order; the plain
|
|
1835
|
+
th-background rule above needs it too. '.ed-te-menu' is a SINGLETON
|
|
1836
|
+
floating menu (position: fixed, same viewport-relative idiom as .ed-seltb/.ed-tb-insert above),
|
|
1835
1837
|
relabeled/repositioned per click rather than rebuilt. '.ed-te-drop-
|
|
1836
1838
|
indicator' is the singleton line shown while dragging a row.
|
|
1837
1839
|
'.ed-te-row-dragging' dims the row actually being dragged. */
|
|
@@ -1862,16 +1864,19 @@ ${itemsHtml}
|
|
|
1862
1864
|
/* Notion-style grip handles: replace the original invisible TE_EDGE_PX=8
|
|
1863
1865
|
proximity zone (user-acceptance feedback: unusably small, no visible
|
|
1864
1866
|
affordance) with two real, adequately-sized (>=18x24px) click/drag
|
|
1865
|
-
targets. '.ed-te-grip-row' is a vertical 6-dot handle shown
|
|
1866
|
-
of the hovered
|
|
1867
|
-
|
|
1868
|
-
|
|
1867
|
+
targets. '.ed-te-grip-row' is a vertical 6-dot handle shown at the LEFT
|
|
1868
|
+
EDGE of the hovered row -- every row, the HEADER included (spec 3.10:
|
|
1869
|
+
the header is draggable too), and sitting just INSIDE the table's left
|
|
1870
|
+
border rather than outside it, because the space outside belongs to the
|
|
1871
|
+
block's own gutter; '.ed-te-grip-col' is a horizontal 6-dot handle shown
|
|
1872
|
+
just ABOVE the hovered column (every column). Dots are plain <span>s laid out via CSS grid with
|
|
1869
1873
|
place-content: center, so the dot cluster stays compact/centered
|
|
1870
1874
|
regardless of the button's own (larger, hit-target-sized) box — no
|
|
1871
|
-
images, no background gradients. '.ed-te-grip-dragging' is
|
|
1872
|
-
grip's own "active drag handle" visual (grabbing cursor) while a row
|
|
1873
|
-
drag is in flight
|
|
1874
|
-
|
|
1875
|
+
images, no background gradients. '.ed-te-grip-dragging' is EITHER
|
|
1876
|
+
grip's own "active drag handle" visual (grabbing cursor) while a row OR
|
|
1877
|
+
column drag is in flight (Task 8: columns became draggable too, same
|
|
1878
|
+
as rows) — see cancelTeDrag()/the pointermove listener in the client
|
|
1879
|
+
runtime. */
|
|
1875
1880
|
.ed-te-grip {
|
|
1876
1881
|
position: fixed; z-index: 9; padding: 0; margin: 0; border: none;
|
|
1877
1882
|
border-radius: 4px; background: transparent;
|
|
@@ -1889,10 +1894,11 @@ ${itemsHtml}
|
|
|
1889
1894
|
}
|
|
1890
1895
|
.ed-te-grip-row.ed-te-grip-dragging { cursor: grabbing; }
|
|
1891
1896
|
.ed-te-grip-col {
|
|
1892
|
-
width: 28px; height: 24px; cursor:
|
|
1897
|
+
width: 28px; height: 24px; cursor: grab; z-index: 7;
|
|
1893
1898
|
grid-template-columns: repeat(3, 3px); grid-template-rows: repeat(2, 3px);
|
|
1894
1899
|
gap: 4px 3px;
|
|
1895
1900
|
}
|
|
1901
|
+
.ed-te-grip-col.ed-te-grip-dragging { cursor: grabbing; }
|
|
1896
1902
|
/* Task 4: floating selection toolbar (bold/italic/strikethrough/underline/
|
|
1897
1903
|
code/link), shown over a
|
|
1898
1904
|
non-collapsed selection inside an active WYSIWYG session. position:
|