@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.
@@ -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 burst's tableEl.innerHTML REASSIGNMENT (revert /
214
- // undo / redo the only mutations that replace the WHOLE table, unlike
215
- // insertRow()/insertColumn() which patch it in place) removes whichever
216
- // cell currently has focus. Chromium runs the focus-fixup "unfocus"
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 innerHTML reassignment (see
230
- // tableBurstUndo()/tableBurstRedo()/revertTableBurstAndEnd() below); the
231
- // focusout listener checks it FIRST and no-ops the whole branch while set.
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.innerHTML, { debounceMs: 400 });
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.innerHTML,
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.innerHTML` captured at
1549
- // focus time (startBurst()/startTableBurst() above, for every block
1550
- // type this burst substrate covers — paragraph/heading/list/table all
1551
- // store it the same way) a byte-identical innerHTML means the DOM
1552
- // genuinely never changed, so drop the burst here exactly like the
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.innerHTML === burst.original) {
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.innerHTML === burst.original);
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.innerHTML, { debounceMs: 400 });
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.innerHTML, history,
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.innerHTML === burst.original);
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
- // block id FIRST and, same stale-node recovery the focusin listener uses
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 = blockId != null ? document.querySelector('.ed-block[data-block-id="' + blockId + '"]') : null;
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 just left of
3191
- // the row while hovering it) selects the row the same way, with a
3192
- // delete-only menu. User-acceptance feedback on the ORIGINAL design (an
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 OUTSIDE the table
3198
- // (never inside a contenteditable cell), so unlike the old zones, which
3309
+ // user can actually see and aim for. Both grips are overlay elements
3310
+ // `position: fixed`-appended to document.bodynever 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 (body rows only the header
3207
- // <tr> is never draggable, per the brief, and never gets a grip at all):
3208
- // after a small movement threshold (distinguishing "click to open the
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 body rows; releasing performs the reorder via a plain
3211
- // `insertBefore()` on the SAME <tr> node (never a clone/innerHTML-
3212
- // replace). The DOM's "insert" algorithm reparents a node in one
3213
- // synchronous step without an observable disconnected state, so unlike
3214
- // tableBurstUndo()/tableBurstRedo()'s innerHTML-snapshot restore just
3215
- // abovemoving the dragged row this way never blurs it even if it
3216
- // happened to contain the active cell, so this needs no
3217
- // suppressTableFocusout guard. The menu's delete ops (which DO remove
3218
- // nodes) sidestep the same hazard a different way — see
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-mutationexactly 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
- teHighlightEls.forEach((el) => el.classList.remove('ed-te-hl'));
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.classList.add('ed-te-hl');
3265
- teHighlightEls.push(rowEl);
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 just LEFT of whichever BODY row (never the header it
3464
- // isn't deletable/draggable, so it never gets one) the pointer is
3465
- // currently hovering any cell of; `colGrip` is a horizontal 6-dot handle
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): both grips sit ON the table border — the grip's
3474
- // centerline coincides with the table's left/top edge, so its hit rect
3475
- // straddles the border by ~half its own width/height on each side.
3476
- // This means the grip's hit rect DOES overlap the insert bubble's hit
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
- // (outside the table) stealing focus on mousedown would fire a focusout
3505
- // on the currently-focused cell BEFORE this gesture's own `pointerdown`
3506
- // handler below even runs.
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
- // Bug fix (user acceptance): grips are visible on hover but were
3537
- // UNREACHABLE by a real pointer. Root cause — a pointer travelling from
3538
- // inside a cell toward a grip necessarily crosses a ~10px corridor
3539
- // OUTSIDE the table's border on the way (the grip's own left/top half,
3540
- // since the grip now straddles the border — P0-a border-centred
3541
- // geometry). The naive hit test below ("on a cell, or hide") hid the
3542
- // grip the instant the pointer left the table/cell — BEFORE it ever
3543
- // reached the grip — so only a teleporting click (every existing test
3544
- // used pressReleaseAt()/gripCenter(), which jump straight to the grip's
3545
- // own coordinates) could ever land on it; a real mouse gesture could not.
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: the union of
3557
- // (the grip's own rect, padded by TE_GRIP_ZONE_PAD_PX for sub-pixel
3558
- // rounding at the very corner) and (the straight corridor between the
3559
- // grip's left/top edge and the table's left/top border — for the row
3560
- // grip, x between the grip's own left edge and the table's left edge,
3561
- // y clamped to the ANCHOR ROW's own vertical extent, padded; the column
3562
- // grip is symmetric against its anchor column's horizontal extent).
3563
- // A pointer at the SAME x/y-corridor position but outside the anchor
3564
- // row/column's own extent is a genuine exit and still hides the grip via
3565
- // hideTableGrips(), same as before. Neither fix touches either grip's
3566
- // position/size or z-index, so the click-priority guarantee (bubble
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
- const tableRect = gripRowTableEl.getBoundingClientRect();
3774
+ // grip 現在在表格內側,所以「從儲存格走向 grip」全程都在表格上,由
3775
+ // updateTableEdgeGrips() 的 onValidCell 分支處理。這裡只需要涵蓋
3776
+ // grip 自己的矩形、以及它與所錨定那一列之間的垂直落差(表頭 grip 被
3777
+ // 往下偏移,可能超出該列的上下緣)。
3586
3778
  const rowRect = gripRowEl.getBoundingClientRect();
3587
- return x >= gr.left && x <= tableRect.left &&
3588
- y >= rowRect.top - TE_GRIP_ZONE_PAD_PX && y <= rowRect.bottom + TE_GRIP_ZONE_PAD_PX;
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 sit OUTSIDE the table (position: fixed, appended to
3616
- // document.body — same as the hover-insert bubbles), so the moment the
3617
- // real pointer crosses from a cell onto the grip itself, `target` is no
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: body rows only — the header row is never deletable/
3649
- // draggable (same rule the retired edge-zone drag gate applied).
3650
- if (rowEl && rowEl !== headerRow) {
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 gw = rowGrip.offsetWidth || 20;
3660
- rowGrip.style.left = (tableRect.left - gw / 2) + 'px';
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, isHeader: false };
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
- if (!gripColTableEl || gripColIndex == null) return null;
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 body-row boundary to `clientY` — header excluded (drops always
3714
- // clamp to the body, per the brief), same "boundary per row" shape
3715
- // insertRow()'s hover-boundary geometry above uses, just decided by
3716
- // proximity (a drag always has SOME nearest boundary) rather than a fixed
3717
- // threshold.
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) return { beforeRow: rows[i], y: r.top };
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 { beforeRow: null, y };
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
- // `dropBeforeRow` is filled in by updateDropIndicator() as the pointer
3736
- // moves while dragging. `pointerId`/`captureEl` back the pointer-capture
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.dropBeforeRow = target.beforeRow;
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.classList.remove('ed-te-row-dragging');
3790
- }
3791
- // The row grip may still be wearing its "active drag handle" visual
3792
- // (see the pointermove listener below) strip it unconditionally, same
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
- async function performRowDrop(tableEl, rowEl, beforeRow) {
3800
- // Final-review Finding 6: `rowEl`/`beforeRow` are DOM nodes captured at
3801
- // pointerdown/during the drag same staleness hazard runDeleteRow()
3802
- // now guards against (a dirty burst on a DIFFERENT block, resolved
3803
- // inside ensureTableBurstOpen() below, swaps `.content` and detaches
3804
- // every node this table's drag was tracking, not just the ones on the
3805
- // block that committed). Snapshot both as ORDINAL row positions before
3806
- // that can happen, then re-locate them by position in the live table
3807
- // afterward, mirroring runDeleteRow()'s own index-based recovery.
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
- const liveRowEl = rowIndex >= 0 ? liveRows[rowIndex] : null;
3814
- const liveBeforeRow = beforeRowIndex >= 0 ? liveRows[beforeRowIndex] : null;
3815
- if (!liveRowEl) return;
3816
- const tbody = liveTableEl.tBodies[0];
3817
- if (!tbody || liveRowEl.parentElement !== tbody) return;
3818
- const prevNext = liveRowEl.nextSibling;
3819
- if (liveBeforeRow && liveBeforeRow.parentElement === tbody) tbody.insertBefore(liveRowEl, liveBeforeRow);
3820
- else tbody.appendChild(liveRowEl);
3821
- // Skip the snap when the drop landed exactly where the row already was
3822
- // (e.g. dropped back onto itself) no actual reorder happened, so
3823
- // there's nothing worth adding to the burst's undo history.
3824
- if (liveRowEl.nextSibling !== prevNext) currentBurst.history.snap('drag-row');
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
- if (teMenuKind && !isSameSelection) hideTableEdgeMenu();
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' || tePointer.hit.isHeader) return; // only draggable body-row zones arm a drag
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
- // The column grip hides like the insert bubbles above (it isn't
3877
- // meaningful mid row-drag); the ROW grip stays visible and switches to
3878
- // its "dragging" visual (grabbing cursor) it IS the drag handle the
3879
- // user is holding, per the brief ("the active grip may stay as the
3880
- // drag handle visual").
3881
- colGrip.hidden = true;
3882
- rowGrip.classList.add('ed-te-grip-dragging');
3883
- tePointer.hit.rowEl.classList.add('ed-te-row-dragging');
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.rowEl.classList.remove('ed-te-row-dragging');
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.dropBeforeRow);
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 showRowMenu(st.hit.tableEl, st.hit.rowEl);
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('\n'), baseMtimeMs: mtimeMs }),
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
- if (teMenuKind && e.key === 'Escape') {
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 column
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. The ROW grip is deliberately left
4709
- // untouched here the pointermove listener above already switched it
4710
- // to its "dragging" visual (see `ed-te-grip-dragging`) as the drag's
4711
- // own handle, and this gate must not fight that by hiding it or
4712
- // repositioning it onto whatever row the cursor happens to be over.
4713
- if (tePointer && tePointer.dragging) { hideTableInsertBubbles(); colGrip.hidden = true; return; }
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
  });
@@ -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('\n'), blocks,
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` +
@@ -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 cells (th+td) or row
1829
- (the <tr> itself) !important because the sticky-first-column rules
1830
- below (tbody td:first-child / thead th:first-child) carry higher
1831
- specificity (0,1,2 vs this class's 0,1,0) and would otherwise win over
1832
- a first-column/first-row highlight despite this rule appearing later in
1833
- source order. '.ed-te-menu' is a SINGLETON floating menu (position:
1834
- fixed, same viewport-relative idiom as .ed-seltb/.ed-tb-insert above),
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 just LEFT
1866
- of the hovered BODY row (never the header); '.ed-te-grip-col' is a
1867
- horizontal 6-dot handle shown just ABOVE the hovered column (every
1868
- column). Dots are plain <span>s laid out via CSS grid with
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 the row
1872
- grip's own "active drag handle" visual (grabbing cursor) while a row
1873
- drag is in flight see cancelTeDrag()/the pointermove listener in the
1874
- client runtime. */
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: pointer; z-index: 7;
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:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helping-ai-workflow/md2doc",
3
- "version": "2.10.0",
3
+ "version": "2.10.1",
4
4
  "description": "Markdown → HTML / PDF renderer with WaveDrom, Mermaid, and Graphviz support",
5
5
  "keywords": [
6
6
  "markdown",