@helping-ai-workflow/md2doc 3.4.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -57,8 +57,50 @@
57
57
 
58
58
  const SVGNS = 'http://www.w3.org/2000/svg';
59
59
 
60
- /** The brush palette, exactly the set the spec names, in that order. */
61
- const BRUSHES = ['0', '1', 'x', 'z', 'p', 'n', 'h', 'l', 'u', 'd', '='];
60
+ /**
61
+ * The brush palette, exactly the set the spec names, in that order.
62
+ *
63
+ * v3.5.0 Task 10 widened this from 11 to 22: `P`/`N` (explicit-edge
64
+ * clocks — see the arrow measurement in `drawLane`'s isClock branch),
65
+ * `2`-`9` (bus levels beyond the plain `=`, already understood by
66
+ * `brickOf` and `codec.levelsOf` but never offered as a brush), and `|`
67
+ * (the gap marker) placed last because it is not a level at all —
68
+ * `codec.levelsOf('=.|')` measured it as "carry the previous level", so
69
+ * painting it does not change what a cycle shows, only that it shows a
70
+ * discontinuity.
71
+ */
72
+ const BRUSHES = ['0', '1', 'x', 'z', 'p', 'n', 'P', 'N', 'h', 'l', 'u', 'd',
73
+ '=', '2', '3', '4', '5', '6', '7', '8', '9', '|'];
74
+
75
+ /**
76
+ * Whether a `KeyboardEvent.key` value both PICKS and PAINTS a brush —
77
+ * `handleDrawingKey` (below `enterDrawing`) tests this exact call, not a
78
+ * copy of it, so the admission rule and what the keyboard can actually
79
+ * reach cannot drift apart the way the module's own prologue warns about
80
+ * (rule 3: two beliefs about the same thing is the failure this batch
81
+ * keeps producing). Named and exported on its own so a test can pin
82
+ * "every brush is keyboard-reachable" against the real rule instead of a
83
+ * re-implementation of it — see `wave-codec.test.js`'s
84
+ * `isBrushKey`-roster assertion.
85
+ *
86
+ * `key.length === 1` is what actually excludes navigation: every
87
+ * `BRUSHES` entry is one character, while `ArrowLeft`/`ArrowRight`/
88
+ * `ArrowUp`/`ArrowDown`/`Home`/`End` are multi-character key NAMES, so
89
+ * `BRUSHES.indexOf(key)` is already `-1` for all six regardless of this
90
+ * length check — it is kept anyway so that invariant is spelled out at
91
+ * the point of use rather than left to rely on `BRUSHES` never growing a
92
+ * multi-character entry by mistake. Deliberately NOT keyed on
93
+ * `ev.shiftKey`: v3.5.0 Task 10 added `P`/`N` (and `|` sits next to `\`
94
+ * on a US layout) to `BRUSHES`, and all three are typed WITH Shift on an
95
+ * ordinary keyboard, so a shiftKey exclusion here would make them
96
+ * mouse-only — see Task 10's fix-round report for the full reasoning on
97
+ * why dropping it does not reopen the Shift+arrow risk the old comment
98
+ * named (that risk is excluded above, by `nav`, on `key` identity, not on
99
+ * `ev.shiftKey`).
100
+ */
101
+ function isBrushKey(key) {
102
+ return typeof key === 'string' && key.length === 1 && BRUSHES.indexOf(key) !== -1;
103
+ }
62
104
 
63
105
  /** Drawing sizes. Handed to `wave-geometry` so the hit test and the paint
64
106
  * cannot drift; nothing here re-derives a row position. */
@@ -227,6 +269,29 @@
227
269
  return out;
228
270
  }
229
271
 
272
+ /**
273
+ * v3.5.0 Task 12: the lowest display position inside `span`'s range whose
274
+ * lane is a DIRECT member of `span`'s own group array — one nested exactly
275
+ * one level deeper than `span.path` — rather than one that lives inside a
276
+ * sub-group `span` merely contains. This is the one lane `codec.ungroupLanes`
277
+ * can be asked about to name THIS group and not some inner one: a lane's
278
+ * immediate parent is unambiguous (see that function's own comment), so a
279
+ * group is addressable through it exactly when it has at least one lane
280
+ * that belongs to it directly.
281
+ *
282
+ * `null` when it does not — a group built entirely out of sub-groups, with
283
+ * no lane of its own at any depth of one. That group's tag still renders
284
+ * (it has a title, and `groupSpansOf` shows every titled one), but nothing
285
+ * in the rail offers to dissolve it, because there is no `at` this codec
286
+ * shape can express that names it and only it.
287
+ */
288
+ function directFirstLaneOf(layout, span) {
289
+ for (let i = span.from; i <= span.to; i++) {
290
+ if (layout.lanes[i].path.length === span.path.length + 1) return i;
291
+ }
292
+ return null;
293
+ }
294
+
230
295
  function valueAt(doc, path) {
231
296
  let node = doc;
232
297
  for (const seg of path) {
@@ -321,6 +386,102 @@
321
386
  // never end up pointing at different cells: painting with `1` after a click
322
387
  // paints the cell that was clicked.
323
388
  let cursor = null;
389
+ // The `edge` array entry the inspector row edits — the codec's ORIGINAL
390
+ // index into `doc.edge`, exactly what `geometry.edgeLayout` reports as
391
+ // `index` (NOT the filtered array position; a dangling entry ahead of it
392
+ // is skipped there and would otherwise shift everything after it out of
393
+ // step). Null means no edge is selected, and that is the whole of the
394
+ // inspector row's visibility rule — see `renderEdgeBar`. `render()`
395
+ // clamps it to null the moment the edge it names stops being drawable
396
+ // (deleted, or its entry no longer parses), so a stale index can never
397
+ // reach `renderEdgeBar` or a shape/label commit.
398
+ let selectedEdge = null;
399
+ // v3.5.0 Task 7: the edge-CREATION gesture's own state, shared verbatim
400
+ // by the pointer and the keyboard — one state machine, two input sources.
401
+ // `edgeMode` is explicit rather than inferred from "is a key held" or "is
402
+ // the mouse button down", for the same reason the brush canvas keeps
403
+ // `drag` as its own variable instead of asking `ev.buttons`: a painting
404
+ // drag and an edge-creation drag must never be read as each other, and a
405
+ // named mode is what lets `onCanvasDown` choose between them up front.
406
+ // 'idle' — the 關聯線 button is off; every press paints or selects.
407
+ // 'armed' — pressed once; the NEXT thing (a press, or a cell plus
408
+ // Enter) marks the start of a line.
409
+ // 'dragging' — the mouse is down after that press; every move only
410
+ // repaints a preview, the document is untouched.
411
+ // `pendingFrom` is the marked start cell, `{at, cell}` in the same two
412
+ // numbers `codec.ensureNode`/`geometry.cellRect` already take — the
413
+ // codec's flattened lane index and the cycle inside it. It is cleared
414
+ // only when `setEdgeMode` moves all the way back to 'idle': going back to
415
+ // 'armed' after a drop that produced nothing (rule 1 below) deliberately
416
+ // LEAVES it standing, so a keyboard user who marked a start and then
417
+ // pressed Enter over an empty row does not have to re-mark it — the next
418
+ // Enter still means "finish from where I already stood".
419
+ let edgeMode = 'idle';
420
+ let pendingFrom = null;
421
+ /** The one place `edgeMode` changes. Keeps the arm button's own visual
422
+ * state, the overlay's `data-wave-edgemode` (what a test or a future
423
+ * task reads instead of reaching into this closure) and the "is a start
424
+ * cell marked" invariant all moving together. */
425
+ function setEdgeMode(next) {
426
+ edgeMode = next;
427
+ edgeBtn.setAttribute('aria-pressed', next === 'idle' ? 'false' : 'true');
428
+ edgeBtn.classList.toggle('is-on', next !== 'idle');
429
+ overlay.setAttribute('data-wave-edgemode', next);
430
+ if (next === 'idle') pendingFrom = null;
431
+ }
432
+ // v3.5.0 Task 8: dragging either END of the SELECTED edge to another
433
+ // cell. `{index, end, at, cell} | null` — `index` is the same original
434
+ // `doc.edge` index `selectedEdge` names (a drag can only start from that
435
+ // edge's own drawn handle, see `onCanvasDown`), `end` is `'from'` or
436
+ // `'to'`, and `at`/`cell` are the live drop target, `null` until the
437
+ // first `pointermove` lands on a real cell.
438
+ //
439
+ // Unlike `edgeMode`/`pendingFrom` — two separate variables that Task 7
440
+ // spent three fix rounds re-synchronising after one could go stale while
441
+ // the other stood — this is ONE variable that is both "is a drag
442
+ // happening" and "what does it know so far". There is no second flag to
443
+ // fall out of step with it: `onCanvasMove`/`onCanvasUp` test this value
444
+ // itself, so nulling it is the whole of cancelling the gesture, in one
445
+ // place, atomically. It still has to be threaded through the same two
446
+ // places Task 7's marks are, though, because the hazard those rounds
447
+ // found is not particular to `pendingFrom` — it is "a mark naming a lane
448
+ // outlives the document that lane numbering was valid in":
449
+ // - `carryMarks` below carries `at` by lane IDENTITY, exactly like
450
+ // `pendingFrom.at`, for the same reason: a structural undo/redo can
451
+ // land while this drag is in flight (the keyboard stays live while a
452
+ // mouse button is held — Ctrl+Z does not care what the mouse is
453
+ // doing), and a raw row number surviving that would let the eventual
454
+ // drop's `moveEdgeEnd` bounds-check successfully against a lane the
455
+ // user was never actually pointing at.
456
+ // - `render()`'s own `selectedEdge` clamp is widened to drop this too:
457
+ // `index` can only ever be reached from `selectedEdge`'s own drawn
458
+ // handle, so the two may never disagree — a drag whose edge stops
459
+ // being drawable (deleted, or its selection cleared by that same
460
+ // mid-drag undo) has to die with it rather than commit a move onto
461
+ // whatever entry now happens to sit at that array position.
462
+ let endpointDrag = null;
463
+ // v3.5.0 Task 12: which lane rows are Shift+click multi-selected on the
464
+ // rail, for「建立群組」— an array of display-position integers, in
465
+ // whatever order the clicks added them, or `null` when nothing is
466
+ // picked (never `[]`; the empty case is collapsed to `null` the moment
467
+ // the last member is toggled off, so every other mark's "is anything
468
+ // standing" check — a plain `!== null` — stays true of this one too).
469
+ //
470
+ // This is a fourth mark of the exact family `pendingFrom`/`endpointDrag`
471
+ // already are: a set of lanes the user has committed to for an upcoming
472
+ // STRUCTURAL edit, named by row number, which a reorder or an undo/redo
473
+ // can invalidate out from under it. So it is carried the same way —
474
+ // widening `carryMarks`'s guard and its `rowOf` lookup rather than
475
+ // growing a second belief about lane identity — and dropped the same
476
+ // way: an entry that cannot be traced to a surviving lane is removed
477
+ // from the set rather than left pointing at whatever now sits at that
478
+ // row. `cursor`'s "leave the stale number, let `renderCanvas` clamp it"
479
+ // is the wrong instinct here for the reason `pendingFrom`'s own comment
480
+ // gives — this mark feeds a structural op (`groupLanes`) the moment the
481
+ // button is pressed, not a screen position, and a stale member silently
482
+ // renumbering onto an unrelated lane would group a lane the user never
483
+ // selected with nothing on screen saying so.
484
+ let laneMultiSelect = null;
324
485
  // The document the marks' lane numbers are numbers INTO.
325
486
  //
326
487
  // `cursor` and `selection` name a ROW, and a row number means a different
@@ -344,6 +505,14 @@
344
505
  // a rename field ran `close('escape')` and threw the WHOLE session away,
345
506
  // which is the most expensive answer available to「我改名改到一半不想改了」.
346
507
  let groupRename = null;
508
+ // v3.5.0 Task 11: the bus data-label field while it is open — this
509
+ // dialog's OTHER sub-panel, same shape as `groupRename` above (a
510
+ // `{cancel, retire}` pair) and layered the same way in `onKeyDown`'s
511
+ // Escape handling. Unlike the rail's rename field, this one's node lives
512
+ // beside the canvas SVG that `renderCanvas` rebuilds on every cursor
513
+ // move, not only on a structural edit — so `retire()` is called from
514
+ // THERE, not only from `render()` (see `renderCanvas`'s own first line).
515
+ let dataEdit = null;
347
516
 
348
517
  // ── the modal focus model ─────────────────────────────────────────────
349
518
  //
@@ -461,6 +630,7 @@
461
630
  if (closed) return;
462
631
  closed = true;
463
632
  drag = null;
633
+ endpointDrag = null;
464
634
  if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
465
635
  d.removeEventListener('keydown', onKeyDown, true);
466
636
  d.removeEventListener('mousemove', onCanvasMove, true);
@@ -530,6 +700,27 @@
530
700
  // user had made, and the one Ctrl+Z that takes a discard back is not
531
701
  // something the keystroke announces.
532
702
  if (groupRename !== null) { groupRename.cancel(); return; }
703
+ // v3.5.0 Task 11: the bus data-label field, this dialog's OTHER
704
+ // text sub-panel — same layering reason as `groupRename` just
705
+ // above: without this, Escape typed while labelling a bus value
706
+ // discarded the whole editing session instead of just the field.
707
+ if (dataEdit !== null) { dataEdit.cancel(); return; }
708
+ // v3.5.0 Task 7: the armed/dragging edge gesture is this dialog's
709
+ // OTHER sub-panel-shaped thing — not a text field, but still a
710
+ // half-done action standing in front of the dialog's own Escape.
711
+ // Cancelling it never touches the document (rule 2: nothing was
712
+ // written yet), so there is no undo step to leave behind either.
713
+ if (edgeMode !== 'idle') { setEdgeMode('idle'); render(); return; }
714
+ // v3.5.0 Task 8: a third sub-panel-shaped thing, same rule — the
715
+ // document is untouched during an endpoint drag (rule 3), so
716
+ // cancelling it is nulling the one variable that both is and knows
717
+ // the drag, then a repaint. The mousemove/mouseup listeners are left
718
+ // attached exactly as `edgeMode`'s own cancel above leaves them:
719
+ // `onCanvasUp` unconditionally removes both at its very first line
720
+ // whenever the button eventually comes up, so a stray move in
721
+ // between finds `endpointDrag === null`, falls through every other
722
+ // branch and repaints nothing — no listener leak, no second rule.
723
+ if (endpointDrag !== null) { endpointDrag = null; render(); return; }
533
724
  close('escape');
534
725
  return;
535
726
  }
@@ -756,6 +947,57 @@
756
947
  afterStoreMoved('redo');
757
948
  });
758
949
 
950
+ // v3.5.0 Task 12: wrap the current Shift+click multi-select (see
951
+ // `laneMultiSelect` above, and `laneRow` below for the gesture itself)
952
+ // into one new group. The codec is the one that decides contiguity —
953
+ // this button does not pre-filter what is selectable, it only decides
954
+ // whether there is enough of a selection to bother trying, so the
955
+ // refusal message the user actually needed ("these lanes are not next
956
+ // to each other") comes from `commit`'s own status line rather than
957
+ // being silently swallowed here.
958
+ //
959
+ // The group is created with an EMPTY label and the rename field is
960
+ // opened immediately — the same affordance `insertButton` already gives
961
+ // a brand new lane (empty `name`, keyboard straight into the field) —
962
+ // rather than a placeholder string the user has to notice and clear.
963
+ const groupBtn = toolButton('ed-wave-group-make', '建立群組',
964
+ '把左邊用 Shift+點 選起來的那幾條連續 lane 包成一個新群組', function () {
965
+ if (laneMultiSelect === null || laneMultiSelect.length < 2) {
966
+ say('先在左邊用 Shift+點 連續選至少兩條 lane,才有東西可以群組');
967
+ return;
968
+ }
969
+ const ats = laneMultiSelect.slice().sort(function (a, b) { return a - b; });
970
+ const minAt = ats[0];
971
+ const committed = commit('group-lanes', function (doc2) {
972
+ return codec.groupLanes(doc2, ats, '');
973
+ });
974
+ if (!committed) {
975
+ say('這幾條 lane 不連續(或選取已經失效),WaveJSON 的群組只能是連續的一段');
976
+ return;
977
+ }
978
+ laneMultiSelect = null;
979
+ // The commit above already repainted with the (now stale) highlight
980
+ // showing; one more repaint clears it before handing the keyboard to
981
+ // the rename field, the same two-step `renameGroup` itself already
982
+ // is — a rebuild first, then the sub-panel laid over what it built.
983
+ render();
984
+ const layout = geometry.layoutOf(store.doc, SIZES);
985
+ const spans = groupSpansOf(store.doc, layout);
986
+ for (const span of spans) {
987
+ if (span.from === minAt) { renameGroup(span); break; }
988
+ }
989
+ });
990
+
991
+ // v3.5.0 Task 7: arm the edge-creation gesture. One-shot in the sense
992
+ // that a drag which PRODUCES an edge disarms it (`finishEdgeDrag` below);
993
+ // pressing the button itself is a plain toggle, so a change of mind
994
+ // before ever pressing on the canvas costs one more click, not Escape.
995
+ const edgeBtn = toolButton('ed-wave-edge-arm', '關聯線',
996
+ '按一下武裝,然後從一格拖到另一格畫出關聯線(Escape 取消)', function () {
997
+ setEdgeMode(edgeMode === 'idle' ? 'armed' : 'idle');
998
+ });
999
+ setEdgeMode('idle');
1000
+
759
1001
  // head / foot — the document-level fields this drawing can honour.
760
1002
  //
761
1003
  // `config.hscale` USED to be a third field here and has been removed. The
@@ -802,9 +1044,139 @@
802
1044
 
803
1045
  const headInput = metaField('ed-wave-head-text', 'head', '上方標題');
804
1046
  const footInput = metaField('ed-wave-foot-text', 'foot', '下方標題');
1047
+ // v3.5.0 Task 13, fix round 1: the ruler fields — the COMPLETE set, not
1048
+ // the three Task 13's own brief happened to name. Measured directly
1049
+ // against the pinned engine rather than recalled: `renderMarks` in
1050
+ // `node_modules/wavedrom/lib/render-marks.js` calls
1051
+ // `ticktock(lane, 'head', 'tick', ...)`, `ticktock(lane, 'head', 'tock',
1052
+ // ...)`, `ticktock(lane, 'foot', 'tick', ...)` and
1053
+ // `ticktock(lane, 'foot', 'tock', ...)` — all four, not just
1054
+ // `head.tick`/`foot.tock` — and inside `ticktock` itself,
1055
+ // `cxt[ref1].every` is read off whichever of `head`/`foot` `ref1` is, so
1056
+ // `every` applies under BOTH, not only `head`. That is eight keys —
1057
+ // `{head, foot} × {text, tick, tock, every}` — of which the first Task
1058
+ // 13 round shipped five (`head.text`/`foot.text` from v3.4.0, plus
1059
+ // `head.tick`/`head.every`/`foot.tock`). The three missing ones
1060
+ // (`head.tock`/`foot.tick`/`foot.every`) are added here so the set is
1061
+ // complete rather than an arbitrary subset a user cannot predict — "why
1062
+ // can I put a tick above the diagram but not below it" is exactly the
1063
+ // shape of bug five-of-eight invites.
1064
+ //
1065
+ // Labels are the literal WaveJSON PATH (`head.tick`, not bare `tick`),
1066
+ // not just the leaf key the first round used — with only one of
1067
+ // tick/tock/every ever existing under `head` (and the mirror one under
1068
+ // `foot`), a bare leaf label could not be confused for its sibling; with
1069
+ // all four now under both, `tick` alone would leave two fields on screen
1070
+ // both labelled `tick` with no way to tell which of them draws above the
1071
+ // diagram and which below. `head`/`foot` on the two `.text` fields stay
1072
+ // bare, since those never had a same-named sibling to begin with.
1073
+ // Grouped head-then-foot in DOM order (four fields, then four fields)
1074
+ // rather than the first round's ad hoc growth order, for the same
1075
+ // readability reason.
1076
+ const headTickInput = metaField('ed-wave-head-tick', 'head.tick', '起始刻度,例如 0');
1077
+ const headTockInput = metaField('ed-wave-head-tock', 'head.tock', '半格刻度起始值,例如 0');
1078
+ const headEveryInput = metaField('ed-wave-head-every', 'head.every', '每幾格顯示一次刻度');
1079
+ const footTickInput = metaField('ed-wave-foot-tick', 'foot.tick', '下方起始刻度,例如 0');
1080
+ const footTockInput = metaField('ed-wave-foot-tock', 'foot.tock', '下方半格刻度起始值,例如 0');
1081
+ const footEveryInput = metaField('ed-wave-foot-every', 'foot.every', '下方每幾格顯示一次刻度');
1082
+
1083
+ headInput.addEventListener('change', function () { commitBanner('head', 'text', headInput.value); });
1084
+ footInput.addEventListener('change', function () { commitBanner('foot', 'text', footInput.value); });
1085
+ headTickInput.addEventListener('change', function () { commitBanner('head', 'tick', headTickInput.value); });
1086
+ headTockInput.addEventListener('change', function () {
1087
+ commitBanner('head', 'tock', headTockInput.value);
1088
+ });
1089
+ headEveryInput.addEventListener('change', function () { commitBanner('head', 'every', headEveryInput.value); });
1090
+ footTickInput.addEventListener('change', function () {
1091
+ commitBanner('foot', 'tick', footTickInput.value);
1092
+ });
1093
+ footTockInput.addEventListener('change', function () { commitBanner('foot', 'tock', footTockInput.value); });
1094
+ footEveryInput.addEventListener('change', function () {
1095
+ commitBanner('foot', 'every', footEveryInput.value);
1096
+ });
805
1097
 
806
- headInput.addEventListener('change', function () { commitBanner('head', headInput.value); });
807
- footInput.addEventListener('change', function () { commitBanner('foot', footInput.value); });
1098
+ // ── the edge inspector row ──────────────────────────────────────────
1099
+ //
1100
+ // Same rule as `renderUnmodelled` below: HIDDEN, with no box on screen at
1101
+ // all, unless `selectedEdge` names one — `renderEdgeBar` is what flips
1102
+ // `edgeBar.hidden` and it is only ever called from `render()`, so the two
1103
+ // can never disagree. Built once here, like `headInput`/`footInput`
1104
+ // above; `render()` repaints its VALUES on every pass through
1105
+ // `renderEdgeBar`, and the usual `data-focus-key` / `restoreFocus` pair
1106
+ // is what keeps a half-typed label from losing the caret on that repaint.
1107
+ const edgeBar = d.createElement('div');
1108
+ edgeBar.className = 'ed-wave-edgebar';
1109
+ edgeBar.hidden = true;
1110
+ toolbar.appendChild(edgeBar);
1111
+
1112
+ const shapeWrap = d.createElement('label');
1113
+ shapeWrap.className = 'ed-wave-metafield';
1114
+ const shapeWrapText = d.createElement('span');
1115
+ shapeWrapText.textContent = '形狀';
1116
+ shapeWrap.appendChild(shapeWrapText);
1117
+ const shapeSel = d.createElement('select');
1118
+ shapeSel.className = 'ed-wave-edge-shape';
1119
+ shapeSel.setAttribute('data-focus-key', 'ed-wave-edge-shape');
1120
+ for (const s of codec.EDGE_SHAPES) {
1121
+ const o = d.createElement('option');
1122
+ o.value = s;
1123
+ o.textContent = s;
1124
+ shapeSel.appendChild(o);
1125
+ }
1126
+ shapeSel.addEventListener('change', function () {
1127
+ if (selectedEdge === null) return;
1128
+ commit('edge-shape', function (doc) {
1129
+ const cur = codec.parseEdge((doc.edge || [])[selectedEdge]);
1130
+ if (cur === null) return doc;
1131
+ return codec.updateEdge(doc, selectedEdge,
1132
+ { from: cur.from, to: cur.to, shape: shapeSel.value, label: cur.label });
1133
+ });
1134
+ });
1135
+ shapeWrap.appendChild(shapeSel);
1136
+ edgeBar.appendChild(shapeWrap);
1137
+
1138
+ const edgeLabelWrap = d.createElement('label');
1139
+ edgeLabelWrap.className = 'ed-wave-metafield';
1140
+ const edgeLabelWrapText = d.createElement('span');
1141
+ edgeLabelWrapText.textContent = '標籤';
1142
+ edgeLabelWrap.appendChild(edgeLabelWrapText);
1143
+ const edgeLabelInput = d.createElement('input');
1144
+ edgeLabelInput.type = 'text';
1145
+ edgeLabelInput.className = 'ed-wave-edge-label';
1146
+ edgeLabelInput.setAttribute('data-focus-key', 'ed-wave-edge-label');
1147
+ edgeLabelInput.placeholder = '這條線的標籤';
1148
+ edgeLabelInput.addEventListener('change', function () {
1149
+ if (selectedEdge === null) return;
1150
+ commit('edge-label', function (doc) {
1151
+ const cur = codec.parseEdge((doc.edge || [])[selectedEdge]);
1152
+ if (cur === null) return doc;
1153
+ return codec.updateEdge(doc, selectedEdge,
1154
+ { from: cur.from, to: cur.to, shape: cur.shape, label: edgeLabelInput.value });
1155
+ });
1156
+ });
1157
+ edgeLabelWrap.appendChild(edgeLabelInput);
1158
+ edgeBar.appendChild(edgeLabelWrap);
1159
+
1160
+ const edgeDeleteBtn = d.createElement('button');
1161
+ edgeDeleteBtn.type = 'button';
1162
+ edgeDeleteBtn.className = 'ed-wave-edge-delete';
1163
+ edgeDeleteBtn.setAttribute('data-focus-key', 'ed-wave-edge-delete');
1164
+ edgeDeleteBtn.textContent = '刪除這條線';
1165
+ edgeDeleteBtn.title = '刪掉這條關聯線(連帶清掉沒有其他 edge 再用的 anchor 字母)';
1166
+ edgeDeleteBtn.addEventListener('click', function () {
1167
+ if (selectedEdge === null) return;
1168
+ const at = selectedEdge;
1169
+ // Cleared BEFORE `commit`, not after: `commit` repaints synchronously,
1170
+ // and `removeEdge` shifts every later entry's index down by one — so an
1171
+ // index cleared only after `commit` returns would let `render()`'s
1172
+ // clamp see a DIFFERENT edge that happens to have landed on the same
1173
+ // number, and silently keep the inspector open on it.
1174
+ selectedEdge = null;
1175
+ commit('edge-remove', function (doc) {
1176
+ return codec.pruneNodes(codec.removeEdge(doc, at));
1177
+ });
1178
+ });
1179
+ edgeBar.appendChild(edgeDeleteBtn);
808
1180
 
809
1181
  // The "this drawing is not showing you everything" notice. Empty, and with
810
1182
  // no box on screen, for the documents that carry none of the three.
@@ -822,15 +1194,34 @@
822
1194
  noSignal.hidden = true;
823
1195
  toolbar.appendChild(noSignal);
824
1196
 
825
- function commitBanner(which, value) {
826
- commit(which, function (doc) {
827
- const had = doc[which];
828
- if (value === '' && (had === undefined || had === null)) return doc;
829
- const next = Object.assign({}, doc);
830
- next[which] = Object.assign({}, (had !== null && typeof had === 'object') ? had : {});
831
- next[which].text = value;
832
- return next;
833
- });
1197
+ // v3.5.0 Task 13: widened from `(which, value)` — always writing `.text`
1198
+ // — to `(which, key, value)` so the same function also carries the other
1199
+ // five ruler fields.
1200
+ //
1201
+ // Fix round 2: the write itself moved into `codec.setBannerField` rather
1202
+ // than staying inline here. The inline version's own no-op guard —
1203
+ // `value === '' && (hadObj[key] === undefined || hadObj[key] === null)`
1204
+ // — only refused to WRITE when the key had never been set; once a key
1205
+ // WAS set, clearing the field fell through to `next[which][key] = ''`,
1206
+ // an empty string rather than an absent key. `.text` hid that: the
1207
+ // engine's `captext` treats `''` the same as absent (a plain truthy
1208
+ // check). `tick`/`tock`/`every` do not — `ticktock` in
1209
+ // `node_modules/wavedrom/lib/render-marks.js` only skips a field that is
1210
+ // `=== undefined`, so `''` reached its array branch, `Number('')` is
1211
+ // `0`, and the engine drew a full default 0-based ruler on a field that
1212
+ // LOOKED empty on screen — the exact confident-wrong-state class this
1213
+ // file exists to avoid, reintroduced by the write side after the read
1214
+ // side (`tickTockText`) got it right. `setBannerField` deletes the key
1215
+ // (and the parent object, if that empties it) instead of writing `''`,
1216
+ // closing that gap once for all six fields rather than special-casing
1217
+ // `.text`'s accidental safety.
1218
+ //
1219
+ // This also gives these six fields their first test seam:
1220
+ // `commitBanner` itself is a closure with no unit-test access, but
1221
+ // `setBannerField` is a plain, exported, pure function — pinned in
1222
+ // `test/wave-codec.test.js`.
1223
+ function commitBanner(which, key, value) {
1224
+ commit(which + '.' + key, function (doc) { return codec.setBannerField(doc, which, key, value); });
834
1225
  }
835
1226
 
836
1227
  // ── body: lane list | drawing | preview ───────────────────────────────
@@ -932,10 +1323,55 @@
932
1323
  if (marksDoc === nextDoc) return;
933
1324
  const before = marksDoc;
934
1325
  marksDoc = nextDoc;
935
- if (before === null || (cursor === null && selection === null)) return;
1326
+ // Fix round 2: `pendingFrom` joins the guard's own "is there anything
1327
+ // to carry" question — widened rather than given a second guard of
1328
+ // its own, so there stays ONE call to `opts.laneTrace` and ONE
1329
+ // `rowOf` built from it, not a second belief about the lookup living
1330
+ // next to the first. Reachable with `cursor`/`selection` both null:
1331
+ // the keyboard's `armed` state is not locked to a document capture
1332
+ // the way a mouse drag is, so a user can mark a start cell with
1333
+ // Enter, delete every lane from the toolbar (which nulls `cursor`/
1334
+ // `selection` in `renderCanvas`'s own clamp but left `pendingFrom`
1335
+ // alone before this fix), add lanes back past the stale index, and
1336
+ // finish the gesture — silently anchoring an edge to a lane the
1337
+ // trace never actually confirmed, because the lookup below was
1338
+ // skipped entirely while `pendingFrom` was the only mark still
1339
+ // standing. `ensureNode` bounds-checks against whatever document is
1340
+ // in hand at THAT moment, not against the one `pendingFrom` was
1341
+ // marked in, so a stale-but-back-in-range index is silently valid to
1342
+ // it — this file's worst failure class, wrong data written with
1343
+ // nothing on screen that explains it.
1344
+ // v3.5.0 Task 8: `endpointDrag` widens this same guard rather than
1345
+ // getting one of its own, for the identical reason `pendingFrom` did
1346
+ // in fix round 2 — one call to `opts.laneTrace`, one `rowOf`, not a
1347
+ // second belief about the lookup. Reachable with the other three all
1348
+ // null: a keyboard undo/redo reached WHILE a mouse button is down
1349
+ // dragging an endpoint is exactly the same shape of gap `pendingFrom`
1350
+ // closed — the pointer holds no document capture of its own, only
1351
+ // this variable's `at` does.
1352
+ // v3.5.0 Task 12: `laneMultiSelect` joins the same guard for the same
1353
+ // reason again — a Shift+click multi-select standing while an unrelated
1354
+ // undo/redo or move lands underneath it is exactly this shape of gap,
1355
+ // just with a SET of row numbers instead of one.
1356
+ if (before === null ||
1357
+ (cursor === null && selection === null && pendingFrom === null &&
1358
+ endpointDrag === null && laneMultiSelect === null)) {
1359
+ return;
1360
+ }
936
1361
  const trace = typeof opts.laneTrace === 'function'
937
1362
  ? opts.laneTrace(before, nextDoc) : null;
938
- if (trace === null) { cursor = null; selection = null; return; }
1363
+ // No `laneTrace` injected: "I no longer know where you were" applies
1364
+ // to `pendingFrom` exactly as it already does to `cursor`/`selection`
1365
+ // — dropped, not guessed at. `endpointDrag` joins them: a drag naming
1366
+ // a lane nothing here can still vouch for is the same hazard either
1367
+ // way. `laneMultiSelect` is dropped wholesale here too, the same as
1368
+ // every other mark: with no trace at all there is nothing to check
1369
+ // any of its members against.
1370
+ if (trace === null) {
1371
+ cursor = null; selection = null; pendingFrom = null; endpointDrag = null;
1372
+ laneMultiSelect = null;
1373
+ return;
1374
+ }
939
1375
  // Structural or not: a content operation traces as the identity, so this
940
1376
  // is a no-op for a paint without having to know that it is one.
941
1377
  const rowOf = function (was) {
@@ -954,6 +1390,64 @@
954
1390
  selection = { laneIndex: at, from: selection.from, to: selection.to };
955
1391
  }
956
1392
  }
1393
+ // `pendingFrom` is a mark of the exact same shape as `cursor` (a row
1394
+ // number plus a cycle), carried the same way — by lane IDENTITY
1395
+ // through this same trace, not by raw row number (see the guard
1396
+ // above for why it must reach here even when `cursor`/`selection`
1397
+ // cannot). It deliberately does NOT copy cursor's "leave the number
1398
+ // alone when untraceable" half, though: `cursor` gets away with that
1399
+ // because `renderCanvas` re-clamps it into range on every repaint, and
1400
+ // because a cursor landing on "whatever now occupies that visual
1401
+ // position" after its own lane vanished is the right instinct for a
1402
+ // position marker. `pendingFrom` has no such clamp anywhere, and it is
1403
+ // not a position — it is a lane the user already committed to as one
1404
+ // fixed end of an edge. Fix round 2: leaving it at a stale raw index
1405
+ // is exactly the hazard the coordinator's review walked to the end —
1406
+ // delete the marked lane, add unrelated lanes back until the count
1407
+ // passes the stale number again, and `ensureNode` would bounds-check
1408
+ // successfully against a lane the user never chose. So an untraceable
1409
+ // `pendingFrom` is DROPPED here, the same as `trace === null` above
1410
+ // drops it wholesale — never left standing on a number nothing
1411
+ // vouches for.
1412
+ if (pendingFrom !== null) {
1413
+ const at = rowOf(pendingFrom.at);
1414
+ pendingFrom = at === null ? null : { at: at, cell: pendingFrom.cell };
1415
+ }
1416
+ // `endpointDrag.at` is a mark of the exact same shape as
1417
+ // `pendingFrom.at` — a lane the user is currently pointing a live
1418
+ // gesture at, not a position `renderCanvas` re-clamps on every
1419
+ // repaint — so it is carried the same way and dropped the same way
1420
+ // when the lane it named cannot be traced. Left alone while `at` is
1421
+ // still `null` (mousedown fired, no `pointermove` has landed on a
1422
+ // real cell yet): there is no row number there yet to go stale.
1423
+ // `index`/`end`/`cell` are not row numbers and need no retracing
1424
+ // here — `index` is guarded separately by `render()`'s widened
1425
+ // `selectedEdge` clamp (the drag dies with the selection it drags).
1426
+ if (endpointDrag !== null && endpointDrag.at !== null) {
1427
+ const at = rowOf(endpointDrag.at);
1428
+ endpointDrag = at === null ? null
1429
+ : { index: endpointDrag.index, end: endpointDrag.end, at: at, cell: endpointDrag.cell };
1430
+ }
1431
+ // `laneMultiSelect` is a SET of marks of the same shape as
1432
+ // `pendingFrom.at` — each one a lane the user has already committed to
1433
+ // for the group they are about to build — carried and dropped the same
1434
+ // way, member by member rather than all-or-nothing: a reorder that
1435
+ // only invalidates ONE of several selected rows should not throw away
1436
+ // the rest of a selection the user is still building. An entry that
1437
+ // cannot be traced is removed rather than left on a stale number, for
1438
+ // the identical reason `pendingFrom` never keeps one either — a
1439
+ // structural op reads this set directly, not a screen position
1440
+ // `renderCanvas` re-clamps on every repaint. Emptied down to nothing,
1441
+ // it collapses to `null` so every other guard's plain `!== null` check
1442
+ // stays the whole truth about "is anything still selected".
1443
+ if (laneMultiSelect !== null) {
1444
+ const kept = [];
1445
+ for (let m = 0; m < laneMultiSelect.length; m++) {
1446
+ const at = rowOf(laneMultiSelect[m]);
1447
+ if (at !== null) kept.push(at);
1448
+ }
1449
+ laneMultiSelect = kept.length === 0 ? null : kept;
1450
+ }
957
1451
  }
958
1452
 
959
1453
  function afterStoreMoved(name) {
@@ -1008,13 +1502,59 @@
1008
1502
  const before = focusMark();
1009
1503
  const doc = store.doc;
1010
1504
  const layout = geometry.layoutOf(doc, SIZES);
1505
+ // Clamped BEFORE `renderCanvas`/`renderEdgeBar` read it, and against
1506
+ // `edgeLayout`'s own filtered list rather than `doc.edge.length` — an
1507
+ // edge whose anchor letter went missing is still an array element (so
1508
+ // a length check would miss it) but `edgeLayout` already will not draw
1509
+ // it, and an inspector row pointed at an edge nothing on screen shows
1510
+ // is exactly the stale-selection defect `carryMarks` above exists to
1511
+ // avoid for lanes.
1512
+ if (selectedEdge !== null && !edgeIsDrawable(doc, layout, selectedEdge)) {
1513
+ selectedEdge = null;
1514
+ }
1515
+ // v3.5.0 Task 8: `endpointDrag.index` can only ever be reached from
1516
+ // `selectedEdge`'s own drawn handle (see `onCanvasDown`), so the two
1517
+ // may never disagree — checked with `!==` rather than "is `index`
1518
+ // still drawable" on its own, because that would let a drag survive a
1519
+ // mid-drag undo that DESELECTS the edge without deleting it (the
1520
+ // clamp above nulls `selectedEdge` on its own timing). A drag that
1521
+ // outlives the selection it was dragging is exactly the class of
1522
+ // hazard Task 7 paid three rounds to close for `pendingFrom`/
1523
+ // `edgeMode`; this is the same rule applied to this task's own mark.
1524
+ if (endpointDrag !== null && endpointDrag.index !== selectedEdge) {
1525
+ endpointDrag = null;
1526
+ }
1527
+ // v3.5.0 Task 12: the same defensive clamp `selectedEdge` gets just
1528
+ // above, for a mark of its own row-number shape — belt to
1529
+ // `carryMarks`'s suspenders, not a replacement for it: `carryMarks`
1530
+ // is what keeps a member POINTING AT THE RIGHT LANE across an edit,
1531
+ // and runs before this on every path that reaches `render()`. This
1532
+ // only catches a member that is merely out of range for whatever
1533
+ // reason (there should not be one left standing; a repaint is cheap
1534
+ // insurance against a future caller of `render()` that forgets to
1535
+ // route through `afterStoreMoved` first).
1536
+ if (laneMultiSelect !== null) {
1537
+ const kept = [];
1538
+ for (let m = 0; m < laneMultiSelect.length; m++) {
1539
+ if (laneMultiSelect[m] >= 0 && laneMultiSelect[m] < layout.lanes.length) {
1540
+ kept.push(laneMultiSelect[m]);
1541
+ }
1542
+ }
1543
+ laneMultiSelect = kept.length === 0 ? null : kept;
1544
+ }
1011
1545
  renderLanes(doc, layout);
1012
1546
  renderCanvas(doc, layout);
1547
+ renderEdgeBar(doc);
1013
1548
  renderPreview(doc);
1014
1549
  renderMeta(doc);
1015
- renderUnmodelled(doc);
1550
+ renderUnmodelled(doc, layout);
1016
1551
  undoBtn.disabled = !store.canUndo();
1017
1552
  redoBtn.disabled = !store.canRedo();
1553
+ groupBtn.disabled = laneMultiSelect === null || laneMultiSelect.length < 2;
1554
+ overlay.setAttribute('data-wave-multiselect',
1555
+ laneMultiSelect === null ? '' : laneMultiSelect.slice().sort(function (a, b) {
1556
+ return a - b;
1557
+ }).join(','));
1018
1558
  overlay.setAttribute('data-wave-gestures', String(gestures));
1019
1559
  overlay.setAttribute('data-wave-lanes', String(layout.lanes.length));
1020
1560
  overlay.setAttribute('data-wave-cycles', String(layout.cycles));
@@ -1025,6 +1565,38 @@
1025
1565
  restoreFocus(before);
1026
1566
  }
1027
1567
 
1568
+ /** Whether `geometry.edgeLayout` would still draw the edge at `index` —
1569
+ * the same test the drawing itself applies, asked rather than repeated. */
1570
+ function edgeIsDrawable(doc, layout, index) {
1571
+ const edges = geometry.edgeLayout(doc, layout);
1572
+ for (const e of edges) {
1573
+ if (e.index === index) return true;
1574
+ }
1575
+ return false;
1576
+ }
1577
+
1578
+ /**
1579
+ * Show or hide the inspector row, and — when it is showing — fill it in
1580
+ * from the selected edge's own entry.
1581
+ *
1582
+ * The ONLY thing that may set `edgeBar.hidden`, so "is an edge selected"
1583
+ * and "is there a box on screen" cannot come apart: precondition T1 at
1584
+ * the top of this file's docstring for the overlay itself, applied to
1585
+ * this row. Called only from `render()`, after `selectedEdge` has already
1586
+ * been clamped, so the `codec.parseEdge` below is not expected to fail —
1587
+ * it is checked anyway because "expected to" is not "guaranteed to".
1588
+ */
1589
+ function renderEdgeBar(doc) {
1590
+ overlay.setAttribute('data-wave-selected-edge',
1591
+ selectedEdge === null ? '' : String(selectedEdge));
1592
+ edgeBar.hidden = selectedEdge === null;
1593
+ if (selectedEdge === null) return;
1594
+ const cur = codec.parseEdge((doc.edge || [])[selectedEdge]);
1595
+ if (cur === null) { edgeBar.hidden = true; return; }
1596
+ shapeSel.value = cur.shape;
1597
+ edgeLabelInput.value = cur.label;
1598
+ }
1599
+
1028
1600
  /** What has the keyboard now, as something that survives a rebuild. */
1029
1601
  function focusMark() {
1030
1602
  const el = d.activeElement;
@@ -1070,6 +1642,27 @@
1070
1642
  } catch (e) { /* not a text field */ }
1071
1643
  }
1072
1644
 
1645
+ /**
1646
+ * `tick`/`every`/`tock` are read by the pinned engine as a number, a
1647
+ * string, or an array of either (`ticktock` in
1648
+ * `node_modules/wavedrom/lib/render-marks.js`) — unlike `head`/`foot`'s
1649
+ * own `.text`, which is always a plain string. A text `<input>` can only
1650
+ * hold one shape, so a hand-authored numeric or array value is shown in
1651
+ * its string form (an array space-joined, the same shape the engine's
1652
+ * own `val.trim().split(/\s+/)` turns a plain string back into) rather
1653
+ * than blanked out — blanking a field that actually holds `tick: 0`
1654
+ * would show a confident wrong state, the same failure `renderUnmodelled`
1655
+ * exists to avoid elsewhere in this file. Editing the field always
1656
+ * commits a plain string back, which the engine treats identically to
1657
+ * whichever shape it replaced.
1658
+ */
1659
+ function tickTockText(v) {
1660
+ if (typeof v === 'string') return v;
1661
+ if (typeof v === 'number' || typeof v === 'boolean') return String(v);
1662
+ if (Array.isArray(v)) return v.join(' ');
1663
+ return '';
1664
+ }
1665
+
1073
1666
  function renderMeta(doc) {
1074
1667
  const h = doc.head;
1075
1668
  const f = doc.foot;
@@ -1077,6 +1670,12 @@
1077
1670
  ? h.text : '';
1078
1671
  footInput.value = (f !== null && typeof f === 'object' && typeof f.text === 'string')
1079
1672
  ? f.text : '';
1673
+ headTickInput.value = (h !== null && typeof h === 'object') ? tickTockText(h.tick) : '';
1674
+ headEveryInput.value = (h !== null && typeof h === 'object') ? tickTockText(h.every) : '';
1675
+ footTockInput.value = (f !== null && typeof f === 'object') ? tickTockText(f.tock) : '';
1676
+ headTockInput.value = (h !== null && typeof h === 'object') ? tickTockText(h.tock) : '';
1677
+ footTickInput.value = (f !== null && typeof f === 'object') ? tickTockText(f.tick) : '';
1678
+ footEveryInput.value = (f !== null && typeof f === 'object') ? tickTockText(f.every) : '';
1080
1679
  }
1081
1680
 
1082
1681
  /**
@@ -1089,8 +1688,35 @@
1089
1688
  * the failure this whole overlay is arranged to avoid, so a document that
1090
1689
  * carries any of them says so, names them, and points at the preview —
1091
1690
  * which IS faithful, because it is the engine.
1691
+ *
1692
+ * Edges join this same notice for the opposite reason. As of this task
1693
+ * they ARE drawn — `renderEdges` below — so the claim for them is not "the
1694
+ * drawing ignores this property" but "this specific edge cannot be
1695
+ * placed".
1696
+ *
1697
+ * final review finding 3: this used to re-derive only ONE of
1698
+ * `geometry.edgeLayout`'s three skip reasons (the dangling-letter one) by
1699
+ * walking `doc.edge` a second time with its own copy of that check —
1700
+ * which is also the one case the pinned engine agrees with us on
1701
+ * (measured: an edge naming an absent letter draws nothing there either).
1702
+ * The other two — an unparseable shape, and a `node` index that has
1703
+ * outlived its lane's length (reachable from the editor's own
1704
+ * insert/delete-cycles buttons, since neither reindexes `node`) — are
1705
+ * exactly where the engine DOES draw a line and we silently do not,
1706
+ * which is the one divergence this whole overlay exists to prevent, and
1707
+ * the old code said nothing about either.
1708
+ *
1709
+ * So this asks `geometry.edgeLayoutDetail` — the SAME walk `renderEdges`
1710
+ * draws from — which edges it could not place and why, instead of
1711
+ * re-deriving any check of its own. Skips are bucketed by whatever
1712
+ * `reason` string comes back, not by a hardcoded list of the two or three
1713
+ * reasons known today: a fourth `continue` added to `edgeLayoutDetail`
1714
+ * later shows up here as its own bucket (worded generically if
1715
+ * `REASON_TEXT` has not been taught a nicer sentence for it yet) with no
1716
+ * change needed in this function, rather than escaping the notice the way
1717
+ * the third one did until now.
1092
1718
  */
1093
- function renderUnmodelled(doc) {
1719
+ function renderUnmodelled(doc, layout) {
1094
1720
  const found = [];
1095
1721
  const cfg = doc.config;
1096
1722
  if (cfg !== null && typeof cfg === 'object' && cfg.hscale !== undefined) {
@@ -1102,11 +1728,67 @@
1102
1728
  if (lane.period !== undefined && found.indexOf('period') === -1) found.push('period');
1103
1729
  if (lane.phase !== undefined && found.indexOf('phase') === -1) found.push('phase');
1104
1730
  }
1731
+
1732
+ const detail = geometry.edgeLayoutDetail(doc, layout);
1733
+ // Bucket every skip by its own reported `reason` — discovered from what
1734
+ // came back, not enumerated in advance, so nothing here has to change
1735
+ // for a reason this function has never heard of.
1736
+ const buckets = new Map();
1737
+ for (const skip of detail.skipped) {
1738
+ if (!buckets.has(skip.reason)) buckets.set(skip.reason, []);
1739
+ buckets.get(skip.reason).push(skip);
1740
+ }
1741
+
1742
+ function letters(list) {
1743
+ const out = [];
1744
+ for (const skip of list) {
1745
+ if (skip.edge === null) continue;
1746
+ if (out.indexOf(skip.edge.from) === -1) out.push(skip.edge.from);
1747
+ if (out.indexOf(skip.edge.to) === -1) out.push(skip.edge.to);
1748
+ }
1749
+ return out;
1750
+ }
1751
+
1752
+ // Known reasons get a sentence that says what a user can act on.
1753
+ // `dangling` matches the engine (nothing to reconcile against the
1754
+ // preview); `unparseable` and `out-of-range` are the two the engine
1755
+ // draws and we do not, so both point at the preview as the tie-breaker.
1756
+ const REASON_TEXT = {
1757
+ dangling: function (list) {
1758
+ return '這個區塊有 ' + list.length + ' 條關聯線引用了不存在的 anchor(' +
1759
+ letters(list).join('、') + '),左邊畫不出它們。';
1760
+ },
1761
+ unparseable: function (list) {
1762
+ return '這個區塊有 ' + list.length +
1763
+ ' 條關聯線的寫法連引擎也認不得,左邊畫不出它們(跟右邊的 WaveDrom 預覽一致)。';
1764
+ },
1765
+ 'out-of-range': function (list) {
1766
+ return '這個區塊有 ' + list.length + ' 條關聯線的 anchor(' +
1767
+ letters(list).join('、') + ')落在那條 lane 目前的長度之外,左邊畫不出它們' +
1768
+ '——但右邊的 WaveDrom 預覽仍然會畫,兩邊會不一致,以預覽為準。';
1769
+ },
1770
+ };
1771
+
1772
+ const sentences = [];
1773
+ if (found.length > 0) {
1774
+ sentences.push('這份文件用了 ' + found.join('、') +
1775
+ ',左邊的手繪波形不表現它們(寬度與相位會不一樣)——以右邊的 WaveDrom 預覽為準。');
1776
+ }
1777
+ for (const [reason, list] of buckets) {
1778
+ const text = REASON_TEXT[reason];
1779
+ sentences.push(text ? text(list) :
1780
+ '這個區塊有 ' + list.length + ' 條關聯線因為「' + reason + '」畫不出來,' +
1781
+ '以右邊的 WaveDrom 預覽為準。');
1782
+ }
1783
+
1784
+ const danglingCount = buckets.has('dangling') ? buckets.get('dangling').length : 0;
1105
1785
  overlay.setAttribute('data-wave-unmodelled', found.join(' '));
1106
- unmodelled.hidden = found.length === 0;
1107
- unmodelled.textContent = found.length === 0 ? '' :
1108
- '這份文件用了 ' + found.join('、') +
1109
- ',左邊的手繪波形不表現它們(寬度與相位會不一樣)——以右邊的 WaveDrom 預覽為準。';
1786
+ overlay.setAttribute('data-wave-dangling-edges', String(danglingCount));
1787
+ // The total the dangling-only count above cannot say: every edge entry
1788
+ // `edgeLayoutDetail` could not place, for any reason at all.
1789
+ overlay.setAttribute('data-wave-skipped-edges', String(detail.skipped.length));
1790
+ unmodelled.hidden = sentences.length === 0;
1791
+ unmodelled.textContent = sentences.join(' ');
1110
1792
 
1111
1793
  // …and the bigger version of the same honesty. This editor edits
1112
1794
  // `signal:` and nothing else, so a `reg:` or `assign:` block opens a
@@ -1145,9 +1827,19 @@
1145
1827
  rows.style.height = layout.height + 'px';
1146
1828
  laneCol.appendChild(rows);
1147
1829
 
1830
+ const spans = groupSpansOf(doc, layout);
1831
+ // v3.5.0 Task 12: which row, if any, hosts THIS group's own 解散
1832
+ // button — see `directFirstLaneOf`'s comment for why at most one row
1833
+ // can, and why some groups (made entirely of sub-groups) have none.
1834
+ const ungroupAt = new Map();
1835
+ for (const span of spans) {
1836
+ const at = directFirstLaneOf(layout, span);
1837
+ if (at !== null) ungroupAt.set(at, span);
1838
+ }
1839
+
1148
1840
  // Group titles first, so they sit UNDER the rows in paint order and a
1149
1841
  // lane's own controls stay clickable over them.
1150
- for (const span of groupSpansOf(doc, layout)) {
1842
+ for (const span of spans) {
1151
1843
  const tag = d.createElement('button');
1152
1844
  tag.type = 'button';
1153
1845
  tag.className = 'ed-wave-group';
@@ -1164,7 +1856,7 @@
1164
1856
  }
1165
1857
 
1166
1858
  for (let i = 0; i < layout.lanes.length; i++) {
1167
- rows.appendChild(laneRow(doc, layout, i));
1859
+ rows.appendChild(laneRow(doc, layout, i, ungroupAt.get(i)));
1168
1860
  }
1169
1861
  // The tail insert. `laneInsertPath(doc, laneCount)` goes on the end of
1170
1862
  // `signal` itself, which is what makes the group asymmetry visible here
@@ -1172,7 +1864,13 @@
1172
1864
  rows.appendChild(insertButton(doc, layout.lanes.length, layout, true));
1173
1865
  }
1174
1866
 
1175
- function laneRow(doc, layout, i) {
1867
+ /**
1868
+ * `ungroupSpan` is the group THIS row's 解散 button dissolves, or
1869
+ * `undefined` on every row that is not a group's own direct-first-lane —
1870
+ * `renderLanes` hands it over from the one `directFirstLaneOf` pass it
1871
+ * already made rather than this function re-deriving it per row.
1872
+ */
1873
+ function laneRow(doc, layout, i, ungroupSpan) {
1176
1874
  const row = d.createElement('div');
1177
1875
  row.className = 'ed-wave-lane-row';
1178
1876
  row.setAttribute('data-lane', String(i));
@@ -1180,6 +1878,54 @@
1180
1878
  row.style.height = layout.laneHeight + 'px';
1181
1879
  row.style.paddingLeft = (layout.lanes[i].depth * 16) + 'px';
1182
1880
  if (selection !== null && selection.laneIndex === i) row.classList.add('is-selected');
1881
+ if (laneMultiSelect !== null && laneMultiSelect.indexOf(i) !== -1) {
1882
+ row.classList.add('is-multi-selected');
1883
+ }
1884
+ // v3.5.0 Task 12: Shift+click anywhere on the row toggles it into or
1885
+ // out of `laneMultiSelect`, the set「建立群組」reads. Capture phase, so
1886
+ // this decides BEFORE the row's own children do — a Shift+click landing
1887
+ // on ▲/▼/✕/the name field is claimed here rather than moving the lane,
1888
+ // deleting it, or opening its name for editing, the same reason
1889
+ // `onKeyDown` claims Escape/Tab in the capture phase at the dialog
1890
+ // level. An ordinary click (no Shift) is NOT claimed at all: it falls
1891
+ // through untouched to whichever child the user actually pressed,
1892
+ // which is what keeps every existing gesture in this row working
1893
+ // exactly as it did before this task.
1894
+ //
1895
+ // Fix round 1 (item 1): the name `<input>` is excluded from that catch.
1896
+ // `render()` below rebuilds the WHOLE lane rail from `store.doc`, and a
1897
+ // Shift+click that reached this listener while the user was still
1898
+ // typing in that field — no `change` fired yet, so nothing had been
1899
+ // committed — rebuilt the input from the OLD value, discarding
1900
+ // whatever they had typed with nothing on screen explaining it. That
1901
+ // is the exact failure family this batch has already paid for
1902
+ // (`pendingFrom`/`endpointDrag`'s own fix rounds), arriving by a route
1903
+ // THIS task opened: before it, `render()` never ran while the keyboard
1904
+ // was still in a field with no blur. Shift+click inside a text field
1905
+ // is also, on every platform, understood as "extend the text
1906
+ // selection" — leaving it alone here is not just the safe choice, it
1907
+ // is the expected one. MEASURED (a real Puppeteer page, the nested-
1908
+ // group / long-name / ungroup-button-present row — the most crowded
1909
+ // this rail ever gets): the input covers at most 45 of the row's
1910
+ // 200px; 129–155px of every row stays outside it. So there is always a
1911
+ // large, ordinary Shift+click target left — the ▲/▼/✕/+/解散群組
1912
+ // buttons and the row's own padding — and excluding the input costs
1913
+ // nothing a user could actually reach for.
1914
+ row.addEventListener('click', function (ev) {
1915
+ if (ev.shiftKey !== true) return;
1916
+ if (ev.target !== null && typeof ev.target.classList === 'object' &&
1917
+ ev.target.classList.contains('ed-wave-lane-name')) {
1918
+ return;
1919
+ }
1920
+ ev.preventDefault();
1921
+ ev.stopPropagation();
1922
+ if (laneMultiSelect === null) laneMultiSelect = [];
1923
+ const at = laneMultiSelect.indexOf(i);
1924
+ if (at === -1) laneMultiSelect.push(i);
1925
+ else laneMultiSelect.splice(at, 1);
1926
+ if (laneMultiSelect.length === 0) laneMultiSelect = null;
1927
+ render();
1928
+ }, true);
1183
1929
 
1184
1930
  row.appendChild(insertButton(doc, i, layout, false));
1185
1931
 
@@ -1244,6 +1990,86 @@
1244
1990
  commit('remove-lane', function (doc2) { return codec.removeLane(doc2, i); });
1245
1991
  });
1246
1992
  row.appendChild(del);
1993
+
1994
+ // v3.5.0 Task 13: duplicate this lane — a full deep copy (`data` / any
1995
+ // other key, via `codec.duplicateLane`) landing right after it at
1996
+ // `i + 1`. Same landing-awareness `insertButton` below already gives
1997
+ // the user for an ordinary insert: `duplicateLane` is built on the very
1998
+ // `addLane`/`laneInsertPath` door that button uses, so it inherits the
1999
+ // identical group-tail asymmetry, and the status line says so the same
2000
+ // way.
2001
+ //
2002
+ // final review finding 2: `node` is deliberately NOT among the copied
2003
+ // keys. It names anchor letters that resolve to THIS lane; carrying it
2004
+ // onto the duplicate would give two lanes the same letters and silently
2005
+ // relocate every edge already anchored here onto the copy instead. The
2006
+ // duplicate starts with no anchors of its own.
2007
+ const dup = d.createElement('button');
2008
+ dup.type = 'button';
2009
+ dup.className = 'ed-wave-lane-duplicate';
2010
+ dup.setAttribute('data-focus-key', 'lane-duplicate-' + i);
2011
+ dup.textContent = '複製';
2012
+ dup.title = '複製這條 lane,接在它後面(data 等欄位一起複製;node 錨點不會跟著複製,避免兩條 lane 搶同一個字母)';
2013
+ dup.addEventListener('click', function () {
2014
+ const landing = landingOf(doc, i + 1);
2015
+ const where = landing.title === null ? '群組外' : '群組「' + landing.title + '」裡';
2016
+ // The copy's own name field is the one control a duplicate exists
2017
+ // for the user to reach next — same reasoning as `insertButton`'s
2018
+ // `focusOverride` below.
2019
+ focusOverride = 'lane-name-' + (i + 1);
2020
+ const didDup = commit('duplicate-lane', function (doc2) { return codec.duplicateLane(doc2, i); });
2021
+ if (didDup) say('複製的 lane 落在' + where);
2022
+ });
2023
+ row.appendChild(dup);
2024
+
2025
+ // v3.5.0 Task 13: insert a spacer — WaveDrom's own blank row, `{}` —
2026
+ // right after this lane. There is deliberately no dedicated "remove
2027
+ // spacer" control: a spacer is a lane like any other, so the ✕ button
2028
+ // every row already has removes it exactly as it removes any other
2029
+ // lane. `codec.addLane` already accepts `{}` (`isLane` only requires a
2030
+ // non-array object), so this needs no new codec function of its own —
2031
+ // unlike `duplicateLane`, which needed one because nothing in the
2032
+ // codec already answers "the lane at this position, copied".
2033
+ const spacer = d.createElement('button');
2034
+ spacer.type = 'button';
2035
+ spacer.className = 'ed-wave-lane-spacer';
2036
+ spacer.setAttribute('data-focus-key', 'lane-spacer-' + i);
2037
+ spacer.textContent = '空白列';
2038
+ spacer.title = '在這條後面插入一個空白列(WaveJSON 的 {},用來空出間距;' +
2039
+ '刪除跟刪一般的 lane 一樣,用 ✕)';
2040
+ spacer.addEventListener('click', function () {
2041
+ const landing = landingOf(doc, i + 1);
2042
+ const where = landing.title === null ? '群組外' : '群組「' + landing.title + '」裡';
2043
+ // A spacer has no name to type, so the control the user is most
2044
+ // likely to reach for next is ✕ — on the new row itself, at i + 1.
2045
+ focusOverride = 'lane-remove-' + (i + 1);
2046
+ const didAdd = commit('add-spacer', function (doc2) { return codec.addLane(doc2, i + 1, {}); });
2047
+ if (didAdd) say('空白列落在' + where);
2048
+ });
2049
+ row.appendChild(spacer);
2050
+
2051
+ // v3.5.0 Task 12: 解散, only on the one row `renderLanes` handed a
2052
+ // span for — the group's own direct first lane (see
2053
+ // `directFirstLaneOf`). The lanes it held stay exactly where they
2054
+ // were; only the wrapping array and its title go.
2055
+ if (ungroupSpan !== undefined) {
2056
+ const ungroup = d.createElement('button');
2057
+ ungroup.type = 'button';
2058
+ ungroup.className = 'ed-wave-lane-ungroup';
2059
+ ungroup.setAttribute('data-focus-key', 'lane-ungroup-' + i);
2060
+ ungroup.textContent = '解散群組';
2061
+ ungroup.title = '解散「' + ungroupSpan.title +
2062
+ '」這個群組(裡面的 lane 都留著,只是不再是一組)';
2063
+ ungroup.addEventListener('click', function () {
2064
+ // The lane at `i` survives ungrouping at the same display position
2065
+ // (see `ungroupLanes`'s own comment: the flattened order and count
2066
+ // never change), so its own name field is exactly where the
2067
+ // keyboard should land.
2068
+ focusOverride = 'lane-name-' + i;
2069
+ commit('ungroup', function (doc2) { return codec.ungroupLanes(doc2, i); });
2070
+ });
2071
+ row.appendChild(ungroup);
2072
+ }
1247
2073
  return row;
1248
2074
  }
1249
2075
 
@@ -1412,6 +2238,187 @@
1412
2238
  input.select();
1413
2239
  }
1414
2240
 
2241
+ /**
2242
+ * The in-place editor for one bus cell's `data` label — this dialog's
2243
+ * OTHER sub-panel, same `{cancel, retire}` shape as `renameGroup` just
2244
+ * above and opened the same two ways every other gesture in this file
2245
+ * is: a pointer press (`onCanvasDown`) or the keyboard's own Enter
2246
+ * (`handleDrawingKey`), both of which have already asked
2247
+ * `codec.dataSlotOf` before calling this, so `slot` below is never
2248
+ * recomputed from a belief of its own about which cycles consume one.
2249
+ *
2250
+ * Positioned with `geometry.cellRect` — the exact numbers the keyboard
2251
+ * cursor and the pointer's own hit test already share — offset by
2252
+ * `canvas.offsetLeft`/`offsetTop` rather than the wrap's own padding
2253
+ * written out as a constant: the field is a plain sibling of the SVG
2254
+ * inside `.ed-wave-canvas-wrap` (now `position: relative`, see
2255
+ * `lib/md2doc.js`), so reading the SVG's OWN measured offset is what
2256
+ * keeps the two from drifting apart if that padding ever changes.
2257
+ *
2258
+ * Unlike `renameGroup`'s field, this one's node sits beside a canvas
2259
+ * `renderCanvas` rebuilds on every cursor move — see that function's own
2260
+ * first line for why `retire()` has to be called from there too, not
2261
+ * only from `render()`.
2262
+ */
2263
+ function openDataEdit(laneIndex, cycle) {
2264
+ if (canvas === null) return;
2265
+ const layout = geometry.layoutOf(store.doc, SIZES);
2266
+ const row = layout.lanes[laneIndex];
2267
+ const lane = row === undefined ? null : row.lane;
2268
+ const slot = lane === null ? null : codec.dataSlotOf(lane, cycle);
2269
+ if (slot === null) return;
2270
+ const box = geometry.cellRect(layout, laneIndex, cycle);
2271
+ if (box === null) return;
2272
+ // A press or an Enter that lands on a second bus cell while the field
2273
+ // is still open on the first one: the open field never got the blur
2274
+ // that a plain click elsewhere would have given it (the keyboard's
2275
+ // Enter path never moves focus at all), so it is cancelled here,
2276
+ // by hand, before this one opens. Unsaved text in the first field is
2277
+ // discarded — the same trade `renameGroup` makes for its own field on
2278
+ // every route that is not `finish()` itself.
2279
+ if (dataEdit !== null) dataEdit.cancel();
2280
+
2281
+ const current = labelsOf(lane)[slot];
2282
+ const startValue = typeof current === 'string' ? current : '';
2283
+
2284
+ const input = d.createElement('input');
2285
+ input.type = 'text';
2286
+ input.className = 'ed-wave-data-input';
2287
+ input.setAttribute('data-focus-key', 'data-input-' + laneIndex + '-' + cycle);
2288
+ input.value = startValue;
2289
+ input.style.left = (canvas.offsetLeft + box.x) + 'px';
2290
+ input.style.top = (canvas.offsetTop + box.y) + 'px';
2291
+ input.style.width = box.width + 'px';
2292
+ input.style.height = box.height + 'px';
2293
+
2294
+ /** The field off. A no-op once a repaint has already taken it away. */
2295
+ const takeDown = function () {
2296
+ if (input.parentNode !== null) input.parentNode.removeChild(input);
2297
+ };
2298
+ /** Whether the keyboard is IN the field right now. False on the
2299
+ * `blur` path — the browser has already moved it by then. */
2300
+ const holdsKeyboard = function () { return d.activeElement === input; };
2301
+
2302
+ const finish = function () {
2303
+ // Once. `blur` and this file's own explicit Enter handler can both
2304
+ // reach here for the one commit (Enter removes the field, which
2305
+ // fires a `blur` of its own) — the latch is the same "whoever gets
2306
+ // here first owns the gesture" rule `renameGroup.finish` uses.
2307
+ if (dataEdit === null) return;
2308
+ dataEdit = null;
2309
+ const hadKeyboard = holdsKeyboard();
2310
+ const value = input.value;
2311
+ takeDown();
2312
+ if (value === startValue) {
2313
+ // Nothing to write, so nothing to repaint — `canvas` is already
2314
+ // the element it was before this field opened.
2315
+ if (hadKeyboard) canvas.focus();
2316
+ return;
2317
+ }
2318
+ // The keyboard is only NAMED when it would otherwise be lost — same
2319
+ // condition `renameGroup.finish` uses: either this field had it, or
2320
+ // whatever DOES have it right now is not even inside the overlay
2321
+ // (both mean the coming repaint would otherwise leave it on
2322
+ // `document.body`). `focusOverride` already set by someone else's
2323
+ // more specific promise is left alone.
2324
+ if (focusOverride === null && (hadKeyboard || !overlay.contains(d.activeElement))) {
2325
+ focusOverride = 'canvas';
2326
+ }
2327
+ commit('data-label', function (doc) {
2328
+ return codec.setDataAt(doc, laneIndex, slot, value);
2329
+ });
2330
+ };
2331
+ /** The canvas is about to be rebuilt underneath this field by
2332
+ * `renderCanvas` — an undo, a redo, any gesture that repaints while
2333
+ * it is open. The node goes with that rebuild; what matters is
2334
+ * dropping the latch and naming where the keyboard lands, exactly
2335
+ * the same reason `renameGroup.retire` exists. */
2336
+ const retire = function () {
2337
+ if (dataEdit === null) return;
2338
+ dataEdit = null;
2339
+ if (focusOverride === null && holdsKeyboard()) focusOverride = 'canvas';
2340
+ };
2341
+ /** Escape's half: the field goes, the label does not change, the
2342
+ * session stands, and the keyboard goes back to the drawing — never
2343
+ * to `document.body`. No repaint: the canvas was never touched. */
2344
+ const cancel = function () {
2345
+ if (dataEdit === null) return;
2346
+ dataEdit = null;
2347
+ takeDown();
2348
+ canvas.focus();
2349
+ };
2350
+ // The explicit commit key: Enter. Left to a plain native `change`
2351
+ // event the way `renameGroup`'s field is would depend on whether this
2352
+ // browser fires `change` for a lone text input on Enter alone, which
2353
+ // is not a promise this file makes elsewhere — asked for outright
2354
+ // instead, the same way Escape is asked for explicitly one level up
2355
+ // in `onKeyDown` rather than left to a default.
2356
+ input.addEventListener('keydown', function (ev) {
2357
+ if (ev.key !== 'Enter') return;
2358
+ ev.preventDefault();
2359
+ ev.stopPropagation();
2360
+ finish();
2361
+ });
2362
+ input.addEventListener('change', finish);
2363
+ input.addEventListener('blur', finish);
2364
+ canvasWrap.appendChild(input);
2365
+ dataEdit = { cancel: cancel, retire: retire };
2366
+ input.focus();
2367
+ input.select();
2368
+ }
2369
+
2370
+ /**
2371
+ * Where a press or an Enter on cycle `cycle` of `lane` should open the
2372
+ * data-label field — or whether it must refuse the gesture outright
2373
+ * instead of letting it fall through to an ordinary paint.
2374
+ *
2375
+ * v3.5.0 Task 11 fix round 1: a cycle INSIDE a bus run that itself owns
2376
+ * no slot (`codec.dataSlotOf` answers `null` — a `.` continuation or a
2377
+ * `|` gap) used to fall straight through to the paint-arming code with
2378
+ * nothing catching it. Reviewer-traced: the press silently repainted
2379
+ * that continuation with whatever brush was current, turning it into a
2380
+ * fresh EXPLICIT value cycle and shifting every LATER label in the lane
2381
+ * one slot to the right — silent data corruption, not an inert click,
2382
+ * because the box this cycle sits inside is exactly what this task now
2383
+ * invites the user to press.
2384
+ *
2385
+ * `isBus(brickOf(...))` is the same test `drawLane` uses to decide
2386
+ * whether a cycle is a bus box at all for the PICTURE, asked here to
2387
+ * decide it for the GESTURE — so a plain non-bus cycle (`0`, `1`, `x`, a
2388
+ * clock, …) is untouched and keeps painting exactly as it always has;
2389
+ * only a cycle INSIDE a bus box is ever diverted. Walking backward for
2390
+ * the owning cycle stops the moment the resolved LEVEL changes — two
2391
+ * adjacent bus runs of the identical value (`2.2.` — measured against
2392
+ * `codec`'s own doc comment: two separate labels, not one) must not
2393
+ * have a later cycle resolve into the EARLIER run's slot.
2394
+ *
2395
+ * Returns:
2396
+ * `{cycle, slot}` — open the field for THIS cycle's slot: either
2397
+ * `cycle` itself owns one, or the walk back to its
2398
+ * run's owning explicit cycle found one.
2399
+ * `'blocked'` — inside a bus box, but no owning cycle exists (a
2400
+ * leading repeater with nothing to continue —
2401
+ * MEASURED, `levelsOf` draws such a lane as `x`,
2402
+ * so this should be unreachable in practice, but
2403
+ * the check is kept rather than assumed so a
2404
+ * future change to that rule fails LOUD here
2405
+ * rather than silently reaching paint again).
2406
+ * Callers must refuse the gesture on this answer,
2407
+ * never fall through.
2408
+ * `null` — not a bus box; ordinary behaviour is unaffected.
2409
+ */
2410
+ function resolveBusTarget(lane, cycle) {
2411
+ const slot = codec.dataSlotOf(lane, cycle);
2412
+ if (slot !== null) return { cycle: cycle, slot: slot };
2413
+ const levels = codec.levelsOf(typeof lane.wave === 'string' ? lane.wave : '');
2414
+ if (!isBus(brickOf(levels[cycle]))) return null;
2415
+ for (let i = cycle - 1; i >= 0 && levels[i] === levels[cycle]; i--) {
2416
+ const s = codec.dataSlotOf(lane, i);
2417
+ if (s !== null) return { cycle: i, slot: s };
2418
+ }
2419
+ return 'blocked';
2420
+ }
2421
+
1415
2422
  /**
1416
2423
  * A copy of `doc` with one scalar replaced, for the two fields the codec's
1417
2424
  * lane operations do not cover (a group's own title).
@@ -1447,6 +2454,14 @@
1447
2454
  * names its bricks in its `<use xlink:href="#…">` list.
1448
2455
  */
1449
2456
  function renderCanvas(doc, layout) {
2457
+ // v3.5.0 Task 11: the data-label field's node sits beside the canvas
2458
+ // SVG inside `canvasWrap` (see `openDataEdit`), and the wipe two lines
2459
+ // below (`canvasWrap.textContent = ''`) would tear it out from under
2460
+ // the user on the very next cursor move if this did not retire it
2461
+ // first — every path that reaches this function counts, not only
2462
+ // `render()`, which is why this lives HERE and not only next to
2463
+ // `groupRename.retire()` in `render()` itself.
2464
+ if (dataEdit !== null) dataEdit.retire();
1450
2465
  // Every repaint REPLACES the canvas element, so a canvas that had the
1451
2466
  // keyboard loses it to `document.body` the instant it is detached —
1452
2467
  // exactly the state F7 and T6h exist to prevent, and here it would fire
@@ -1538,6 +2553,55 @@
1538
2553
  drawLane(canvas, layout, i);
1539
2554
  }
1540
2555
 
2556
+ const edges = renderEdges(canvas, doc, layout);
2557
+
2558
+ // v3.5.0 Task 8: the in-flight endpoint drag's own preview — UI state
2559
+ // only, never a document read (rule 3: nothing is written until
2560
+ // `finishEndpointDrag` commits it, and Escape mid-drag never reaches
2561
+ // that far). `renderEdges` above already faded the dragged edge's OWN,
2562
+ // still-current path (`.is-drag-source`, read from this same
2563
+ // `endpointDrag`); this draws the other half — a dashed line from the
2564
+ // edge's UNMOVED end to wherever the drag is currently over. Reuses
2565
+ // `.ed-wave-edge-pending`, the exact dashed style Task 7's own
2566
+ // not-yet-written preview line already uses, for the identical
2567
+ // reason: this line is not `doc.edge` either, until the drop.
2568
+ //
2569
+ // Looked up in `edges` — the SAME array `renderEdges` just built this
2570
+ // pass, not a second call to `geometry.edgeLayout` — so the two can
2571
+ // never draw from different snapshots of the layout. `render()`'s own
2572
+ // clamp guarantees `endpointDrag.index === selectedEdge` whenever
2573
+ // `endpointDrag` is non-null, so a lookup miss here would mean that
2574
+ // invariant broke, not that the edge is merely unselected — the `!==
2575
+ // undefined` check is defensive rather than expected to matter.
2576
+ let endpointDragMark = '';
2577
+ if (endpointDrag !== null) {
2578
+ const dragged = edges.filter(function (e) { return e.index === endpointDrag.index; })[0];
2579
+ if (dragged !== undefined) {
2580
+ const otherEnd = endpointDrag.end === 'from' ? 'to' : 'from';
2581
+ const from = dragged[otherEnd];
2582
+ // Falls back to the dragged end's OWN current position — the
2583
+ // degenerate "no visible change yet" line — until a `pointermove`
2584
+ // has actually landed on a real cell (`endpointDrag.at !== null`).
2585
+ let to = dragged[endpointDrag.end];
2586
+ if (endpointDrag.at !== null) {
2587
+ const cell = geometry.cellRect(layout, endpointDrag.at, endpointDrag.cell);
2588
+ if (cell !== null) {
2589
+ to = { x: cell.x + cell.width / 2, y: cell.y + cell.height / 2 };
2590
+ endpointDragMark = endpointDrag.index + ':' + endpointDrag.end + '@' +
2591
+ endpointDrag.at + ',' + endpointDrag.cell;
2592
+ }
2593
+ }
2594
+ const line = d.createElementNS(SVGNS, 'line');
2595
+ line.setAttribute('class', 'ed-wave-edge-pending');
2596
+ line.setAttribute('x1', String(from.x));
2597
+ line.setAttribute('y1', String(from.y));
2598
+ line.setAttribute('x2', String(to.x));
2599
+ line.setAttribute('y2', String(to.y));
2600
+ canvas.appendChild(line);
2601
+ }
2602
+ }
2603
+ overlay.setAttribute('data-wave-endpoint-drag', endpointDragMark);
2604
+
1541
2605
  if (selection !== null) {
1542
2606
  const from = Math.min(selection.from, selection.to);
1543
2607
  const to = Math.max(selection.from, selection.to);
@@ -1550,6 +2614,33 @@
1550
2614
  canvas.appendChild(box);
1551
2615
  }
1552
2616
 
2617
+ // v3.5.0 Task 7: the pending edge — `pendingFrom` to wherever `cursor`
2618
+ // is now — drawn from UI state alone, never from the document (rule 2:
2619
+ // nothing about the doc changes until the drop). `cursor` is shared
2620
+ // with the keyboard cell below it in paint order for the same reason
2621
+ // the two are drawn from the same variable: a mouse drag and the
2622
+ // keyboard's two-Enter version are one gesture wearing two input
2623
+ // devices, so there is exactly one line to preview, however it is
2624
+ // being aimed at this instant. Skipped once `pendingFrom` is null,
2625
+ // which is every state except "armed with a marked start" and
2626
+ // "dragging" — see `setEdgeMode`.
2627
+ let pendingCell = '';
2628
+ if (pendingFrom !== null && cursor !== null) {
2629
+ const from = geometry.cellRect(layout, pendingFrom.at, pendingFrom.cell);
2630
+ const to = geometry.cellRect(layout, cursor.laneIndex, cursor.cycle);
2631
+ if (from !== null && to !== null) {
2632
+ pendingCell = pendingFrom.at + ',' + pendingFrom.cell;
2633
+ const line = d.createElementNS(SVGNS, 'line');
2634
+ line.setAttribute('class', 'ed-wave-edge-pending');
2635
+ line.setAttribute('x1', String(from.x + from.width / 2));
2636
+ line.setAttribute('y1', String(from.y + from.height / 2));
2637
+ line.setAttribute('x2', String(to.x + to.width / 2));
2638
+ line.setAttribute('y2', String(to.y + to.height / 2));
2639
+ canvas.appendChild(line);
2640
+ }
2641
+ }
2642
+ overlay.setAttribute('data-wave-edge-pending', pendingCell);
2643
+
1553
2644
  // The keyboard's cell, drawn last so it is over the selection tint and
1554
2645
  // over the trace. Its geometry comes from `wave-geometry.cellRect` — the
1555
2646
  // inverse of the hit test the pointer uses — so the cell the cursor is
@@ -1629,6 +2720,25 @@
1629
2720
  for (let c = 0; c < cells.length; c++) {
1630
2721
  if (cells[c].held !== true && cells[c].ch === '|') gaps.push(c);
1631
2722
  }
2723
+ // v3.5.0 Task 10 fix round 2: which cycles carry the P/N explicit-edge
2724
+ // arrow, and which one. Read off `levels` directly rather than off
2725
+ // `bricks`, for the same reason `gaps` above reads off `cells` rather
2726
+ // than off `bricks`: `brickOf` collapses `P` into the SAME brick id as
2727
+ // `p` (that is what makes the arrow the one thing `brickOf`'s own
2728
+ // model cannot carry — see its comment, and the measurement above the
2729
+ // arrow's own drawing code below), so asking `bricks` here would
2730
+ // always come back empty. One entry per cycle that has an arrow,
2731
+ // `"<cycle>:<P|N>"`, in the same index-not-one-slot-per-cycle shape
2732
+ // `data-gaps-<i>` already uses — an empty lane (no `P`/`N` anywhere,
2733
+ // e.g. every existing fixture's plain `p`/`n` clocks) reads as `''`,
2734
+ // which is the ABSENCE half of the contract this attribute exists to
2735
+ // let a test pin: a `P` lane must show an entry here, a `p` lane must
2736
+ // not, and drawing the arrow on every clock (the regression this
2737
+ // attribute is for) would make the second half false.
2738
+ const clockEdges = [];
2739
+ for (let c = 0; c < levels.length; c++) {
2740
+ if (levels[c] === 'P' || levels[c] === 'N') clockEdges.push(c + ':' + levels[c]);
2741
+ }
1632
2742
  // `data-wave-<i>` cannot carry the difference between "no `wave` key" and
1633
2743
  // `wave: ''` — both are the empty string — and that difference is exactly
1634
2744
  // what the engine draws differently, so it gets its own attribute.
@@ -1637,6 +2747,7 @@
1637
2747
  svg.setAttribute('data-levels-' + i, levels.join(''));
1638
2748
  svg.setAttribute('data-bricks-' + i, bricks.join(' '));
1639
2749
  svg.setAttribute('data-gaps-' + i, gaps.join(' '));
2750
+ svg.setAttribute('data-clock-edge-' + i, clockEdges.join(' '));
1640
2751
 
1641
2752
  const hi = row.y + 7;
1642
2753
  const lo = row.y + row.height - 9;
@@ -1688,6 +2799,31 @@
1688
2799
  t.setAttribute('text-anchor', 'middle');
1689
2800
  t.textContent = text;
1690
2801
  svg.appendChild(t);
2802
+ } else {
2803
+ // v3.5.0 Task 11 fix round 3: an UNLABELLED bus run has no
2804
+ // `<text>` for a press to land on — fix round 2's own rule
2805
+ // ("open the field only on a press that hits the label text")
2806
+ // otherwise leaves the single most ordinary flow in this editor
2807
+ // (paint a `=`, then name it) mouse-unreachable. This dot is
2808
+ // that run's hit target when there is no label yet: same
2809
+ // class as the text (`ed-wave-buslabel`, so `onCanvasDown`'s
2810
+ // existing hit test needs no change at all — it already asks
2811
+ // for that one class, not for an element type) plus
2812
+ // `ed-wave-buslabel-empty` for the dim, small styling
2813
+ // (`lib/md2doc.js`) that keeps a diagram full of unlabelled
2814
+ // buses from turning into a field of marks. Radius bounded by
2815
+ // the run's own box on both axes, not a bare constant, so the
2816
+ // box stays MOSTLY paintable even at the narrowest cycle width
2817
+ // this editor draws, and a press anywhere else in the box still
2818
+ // falls through to the ordinary paint dispatch exactly as
2819
+ // round 2 established.
2820
+ const r = Math.min(3.5, (x1 - x0) / 6, (lo - hi) / 6);
2821
+ const dot = d.createElementNS(SVGNS, 'circle');
2822
+ dot.setAttribute('class', 'ed-wave-buslabel ed-wave-buslabel-empty');
2823
+ dot.setAttribute('cx', String((x0 + x1) / 2));
2824
+ dot.setAttribute('cy', String(mid));
2825
+ dot.setAttribute('r', String(r));
2826
+ svg.appendChild(dot);
1691
2827
  }
1692
2828
  labelSlot++;
1693
2829
  } else if (brick === 'xxx') {
@@ -1708,6 +2844,70 @@
1708
2844
  : 'M' + x0 + ',' + hi + ' L' + x0 + ',' + lo + ' L' + xm + ',' + lo +
1709
2845
  ' L' + xm + ',' + hi + ' L' + x1 + ',' + hi);
1710
2846
  svg.appendChild(p);
2847
+
2848
+ // v3.5.0 Task 10: `P`/`N` are explicit-edge clocks. `brickOf` maps
2849
+ // both to the SAME brick id as `p`/`n` (`nclk`/`pclk` — see its own
2850
+ // comment), which is correct for the level the engine SHOWS but
2851
+ // erases the one thing that makes `P`/`N` look different: an
2852
+ // arrowhead. MEASURED against the pinned wavedrom 3.5.0 — rendered
2853
+ // `{signal:[{name:'a',wave:'zpp'}]}` next to `{wave:'zPP'}` via
2854
+ // `wd.renderAny(0, doc, wd.waveSkin)` and diffed the `<use
2855
+ // xlink:href>` list:
2856
+ //
2857
+ // p: #zzz #zzz #pclk #nclk #pclk #nclk
2858
+ // P: #zzz #zzz #Pclk #nclk #Pclk #nclk
2859
+ // n: #zzz #zzz #nclk #pclk #nclk #pclk
2860
+ // N: #zzz #zzz #Nclk #pclk #Nclk #pclk
2861
+ //
2862
+ // — same SECOND half-brick every time (that is what `brickOf`'s own
2863
+ // measurement already pins), but the FIRST half-brick gets its own
2864
+ // symbol. Read straight out of `node_modules/wavedrom/skins/default.js`:
2865
+ //
2866
+ // pclk 'M0,20 0,0 20,0' (just the edge)
2867
+ // Pclk same edge PLUS 'M-3,12 0,3 3,12 C 1,11 -1,11 -3,12 z' (filled, class s6)
2868
+ // nclk 'm0,0 0,20 20,0'
2869
+ // Nclk same edge PLUS 'M-3,8 0,17 3,8 C 1,9 -1,9 -3,8 z' (filled, class s6)
2870
+ //
2871
+ // i.e. in the engine's own 20-unit symbol space (y=0 top, y=20
2872
+ // bottom, matching this file's hi/lo), a small filled triangle
2873
+ // straddling local x=0 — `P`'s apex at local y=3 pointing UP
2874
+ // (toward hi), `N`'s apex at local y=17 pointing DOWN (toward lo).
2875
+ // Both sit on the FIRST half-brick, which in this drawing's
2876
+ // one-brick-per-period model (a whole `p`/`P` cycle is ONE `nclk`
2877
+ // path, not two half-bricks — see `isClock`'s own comment) is
2878
+ // always the vertical stroke at x0, the edge INTO this cycle. So
2879
+ // the level char (not the brick id, which is identical for
2880
+ // `p`/`P`) is what has to be read to know an arrow is owed, and
2881
+ // `levels[run.from]` is it — clock bricks never merge (the `!
2882
+ // isClock` guard below), so `run.from === run.to` and this is
2883
+ // exactly the cycle being drawn. `data-clock-edge-<i>`, set above
2884
+ // (this function's own top, next to `data-gaps-<i>`) from the same
2885
+ // `levels` array, is the one place this fact is exposed for a test
2886
+ // to pin — nothing here duplicates it onto the shape itself, so
2887
+ // there is one belief about which cycles have an arrow, not two.
2888
+ //
2889
+ // Reproduced as a plain filled triangle, not the engine's
2890
+ // bezier-curved one: every other brick this file draws is already
2891
+ // a simplified redraw (the bus is a hexagon, the gap is a pair of
2892
+ // chevrons), not a pixel copy of wavedrom's own glyphs. Scaled out
2893
+ // of the engine's 20-unit symbol into this cell's own geometry —
2894
+ // local x by (xm - x0) / 20, local y by (lo - hi) / 20, both
2895
+ // anchored at (x0, hi) — rather than hand-picked pixel offsets, so
2896
+ // it tracks `SIZES.cycleWidth`/`laneHeight` if those ever change.
2897
+ const edgeLevel = levels[run.from];
2898
+ if (edgeLevel === 'P' || edgeLevel === 'N') {
2899
+ const sx = (xm - x0) / 20;
2900
+ const sy = (lo - hi) / 20;
2901
+ const apexY = edgeLevel === 'P' ? hi + 3 * sy : hi + 17 * sy;
2902
+ const baseY = edgeLevel === 'P' ? hi + 12 * sy : hi + 8 * sy;
2903
+ const arrow = d.createElementNS(SVGNS, 'path');
2904
+ arrow.setAttribute('class', 'ed-wave-clock-arrow');
2905
+ arrow.setAttribute('d',
2906
+ 'M' + (x0 - 3 * sx) + ',' + baseY +
2907
+ ' L' + x0 + ',' + apexY +
2908
+ ' L' + (x0 + 3 * sx) + ',' + baseY + ' Z');
2909
+ svg.appendChild(arrow);
2910
+ }
1711
2911
  } else {
1712
2912
  const y = bandOf(brick)[0];
1713
2913
  const line = d.createElementNS(SVGNS, 'line');
@@ -1755,6 +2955,161 @@
1755
2955
  }
1756
2956
  }
1757
2957
 
2958
+ /**
2959
+ * Every `edge` entry, drawn after every lane: one `<path>` (`d` from
2960
+ * `geometry.edgeLayout`), a label `<text>` when the entry has one, and —
2961
+ * for `selectedEdge` only — two endpoint handles from
2962
+ * `geometry.edgeHandleRect`, exactly the rectangles `onCanvasDown` below
2963
+ * hit-tests against, so the drawn handle and the clickable one cannot
2964
+ * drift apart.
2965
+ *
2966
+ * A second, invisible, wide-stroke `<path>` (`class="ed-wave-edge-hit"`)
2967
+ * is layered over each visible one purely so it can be clicked: the
2968
+ * visible path is 1px wide, and `document.elementFromPoint` (what
2969
+ * `onCanvasDown` reads to answer "did this press land on a line") would
2970
+ * otherwise miss any press that is not on that exact pixel.
2971
+ * `lib/md2doc.js`'s `.ed-wave-edge-hit` rule sets `pointer-events: stroke`
2972
+ * — what makes a `stroke: transparent` path hit-testable at all, without
2973
+ * which a transparent stroke paints nothing and "nothing painted" is what
2974
+ * `visiblePainted` (the SVG default) refuses to hit.
2975
+ *
2976
+ * Arrow markers are picked from the shape string, MEASURED against
2977
+ * `node_modules/wavedrom/lib/arc-shape.js`'s own `style` table (every
2978
+ * `case` there that sets a `style` string): a shape containing `<` sets
2979
+ * `marker-start:url(#arrowtail)`, one containing `>` sets
2980
+ * `marker-end:url(#arrowhead)`, and `+` is the one shape that sets BOTH
2981
+ * markers, to `url(#tee)` — the seven plain shapes (`-`, `~`, `-~`, `~-`,
2982
+ * `-|`, `|-`, `-|-`) set no `style` at all and draw no arrowhead.
2983
+ * (`applyEdgeMarkers` below is unaffected by the exact count either way —
2984
+ * it tests `indexOf('<')` / `indexOf('>')` rather than enumerating a
2985
+ * list — but this comment's own count should still match the pinned
2986
+ * engine.) `edgeArrowMarker` below reproduces the head/tail split with
2987
+ * ONE shared marker definition rather than two: `orient="auto-start-reverse"`
2988
+ * is what flips a `marker-start` 180° from a `marker-end` automatically,
2989
+ * so the single triangle already points the right way at both ends.
2990
+ */
2991
+ function renderEdges(svg, doc, layout) {
2992
+ const edges = geometry.edgeLayout(doc, layout);
2993
+ overlay.setAttribute('data-wave-edge-count', String(edges.length));
2994
+ if (edges.length === 0) return edges;
2995
+
2996
+ const defs = d.createElementNS(SVGNS, 'defs');
2997
+ defs.appendChild(edgeArrowMarker());
2998
+ defs.appendChild(edgeTeeMarker());
2999
+ svg.appendChild(defs);
3000
+
3001
+ for (const e of edges) {
3002
+ const isSelected = e.index === selectedEdge;
3003
+ // v3.5.0 Task 8: the edge an in-flight endpoint drag is dragging
3004
+ // keeps drawing at its ACTUAL (still-committed) position — the
3005
+ // document is untouched until the drop (rule 3) — but is faded so
3006
+ // it reads as "not where this will end up" once the dashed preview
3007
+ // (`renderCanvas`, below `renderEdges`'s own call) is standing next
3008
+ // to it. `endpointDrag` can only ever name `selectedEdge` itself
3009
+ // (`render()`'s own clamp), so `isSelected` is already true here too
3010
+ // and this only ever adds a class, never substitutes for `.is-on`.
3011
+ const isDragSource = endpointDrag !== null && e.index === endpointDrag.index;
3012
+ const path = d.createElementNS(SVGNS, 'path');
3013
+ // Every visual property (stroke, fill, width) lives in
3014
+ // `lib/md2doc.js`'s `.ed-wave-edge-path` / `.is-on` CSS, the same
3015
+ // mechanism the rest of this drawing's colours already go through
3016
+ // (`.ed-wave-line`, `.ed-wave-selection`, `.ed-wave-cursor`, …) —
3017
+ // one place to repaint the editor, not two. Only genuinely geometric
3018
+ // attributes (`d`, the marker refs) stay here as attributes.
3019
+ path.setAttribute('class', 'ed-wave-edge-path' + (isSelected ? ' is-on' : '') +
3020
+ (isDragSource ? ' is-drag-source' : ''));
3021
+ path.setAttribute('data-edge-index', String(e.index));
3022
+ path.setAttribute('d', e.d);
3023
+ applyEdgeMarkers(path, e.edge.shape);
3024
+ svg.appendChild(path);
3025
+
3026
+ const hit = d.createElementNS(SVGNS, 'path');
3027
+ hit.setAttribute('class', 'ed-wave-edge-hit');
3028
+ hit.setAttribute('data-edge-index', String(e.index));
3029
+ hit.setAttribute('d', e.d);
3030
+ svg.appendChild(hit);
3031
+
3032
+ if (e.edge.label !== '') {
3033
+ const t = d.createElementNS(SVGNS, 'text');
3034
+ t.setAttribute('class', 'ed-wave-edge-label');
3035
+ t.setAttribute('x', String(e.labelPos.x));
3036
+ t.setAttribute('y', String(e.labelPos.y - 4));
3037
+ // `text-anchor` positions the glyphs against (x, y) — geometry, not
3038
+ // colour — so it stays an attribute, exactly as the existing
3039
+ // `.ed-wave-buslabel` text already does a few lines up.
3040
+ t.setAttribute('text-anchor', 'middle');
3041
+ t.textContent = e.edge.label;
3042
+ svg.appendChild(t);
3043
+ }
3044
+
3045
+ if (isSelected) {
3046
+ for (const end of ['from', 'to']) {
3047
+ const r = geometry.edgeHandleRect(e[end]);
3048
+ const h = d.createElementNS(SVGNS, 'rect');
3049
+ h.setAttribute('class', 'ed-wave-edge-handle');
3050
+ h.setAttribute('data-edge-handle', end);
3051
+ h.setAttribute('data-edge-index', String(e.index));
3052
+ h.setAttribute('x', String(r.x));
3053
+ h.setAttribute('y', String(r.y));
3054
+ h.setAttribute('width', String(r.width));
3055
+ h.setAttribute('height', String(r.height));
3056
+ svg.appendChild(h);
3057
+ }
3058
+ }
3059
+ }
3060
+ return edges;
3061
+ }
3062
+
3063
+ /** `marker-start` / `marker-end` for one shape — see `renderEdges`'s own
3064
+ * comment for the measurement behind this split. */
3065
+ function applyEdgeMarkers(path, shape) {
3066
+ if (shape === '+') {
3067
+ path.setAttribute('marker-start', 'url(#ed-wave-tee)');
3068
+ path.setAttribute('marker-end', 'url(#ed-wave-tee)');
3069
+ return;
3070
+ }
3071
+ if (shape.indexOf('<') !== -1) path.setAttribute('marker-start', 'url(#ed-wave-arrowhead)');
3072
+ if (shape.indexOf('>') !== -1) path.setAttribute('marker-end', 'url(#ed-wave-arrowhead)');
3073
+ }
3074
+
3075
+ /** One triangle, reused for both ends via `auto-start-reverse`. */
3076
+ function edgeArrowMarker() {
3077
+ const m = d.createElementNS(SVGNS, 'marker');
3078
+ m.setAttribute('id', 'ed-wave-arrowhead');
3079
+ m.setAttribute('viewBox', '0 0 8 8');
3080
+ m.setAttribute('markerWidth', '8');
3081
+ m.setAttribute('markerHeight', '8');
3082
+ m.setAttribute('refX', '7');
3083
+ m.setAttribute('refY', '4');
3084
+ m.setAttribute('orient', 'auto-start-reverse');
3085
+ const poly = d.createElementNS(SVGNS, 'polygon');
3086
+ poly.setAttribute('points', '0,0 8,4 0,8');
3087
+ poly.setAttribute('class', 'ed-wave-edge-arrow');
3088
+ m.appendChild(poly);
3089
+ return m;
3090
+ }
3091
+
3092
+ /** The `+` shape's tee: a crossbar perpendicular to the path at either
3093
+ * end, symmetric under a 180° flip so `orient="auto"` needs no reverse. */
3094
+ function edgeTeeMarker() {
3095
+ const m = d.createElementNS(SVGNS, 'marker');
3096
+ m.setAttribute('id', 'ed-wave-tee');
3097
+ m.setAttribute('viewBox', '0 0 8 8');
3098
+ m.setAttribute('markerWidth', '8');
3099
+ m.setAttribute('markerHeight', '8');
3100
+ m.setAttribute('refX', '4');
3101
+ m.setAttribute('refY', '4');
3102
+ m.setAttribute('orient', 'auto');
3103
+ const line = d.createElementNS(SVGNS, 'line');
3104
+ line.setAttribute('x1', '4');
3105
+ line.setAttribute('y1', '0');
3106
+ line.setAttribute('x2', '4');
3107
+ line.setAttribute('y2', '8');
3108
+ line.setAttribute('class', 'ed-wave-edge-tee');
3109
+ m.appendChild(line);
3110
+ return m;
3111
+ }
3112
+
1758
3113
  // ── the keyboard on the drawing ───────────────────────────────────────
1759
3114
  /**
1760
3115
  * Every gesture the drawing has, without a pointer.
@@ -1807,14 +3162,69 @@
1807
3162
  moveCursor(key, ev.shiftKey === true);
1808
3163
  return true;
1809
3164
  }
3165
+ // v3.5.0 Task 7: the keyboard's two-step version of the drag, ahead of
3166
+ // the plain Enter-paints case below so an armed Enter never falls
3167
+ // through to it. First Enter marks `cursor` as `pendingFrom` (arrows
3168
+ // above already moved it, exactly as a mouse press would have);
3169
+ // second Enter — from wherever the cursor walked to since — is the
3170
+ // SAME `finishEdgeDrag` the mouse's `onCanvasUp` calls, so the three
3171
+ // no-op drops and the one-armed-if-nothing-happened rule are decided
3172
+ // in that one place, not re-decided here.
3173
+ if (edgeMode === 'armed' && key === 'Enter' && cursor !== null) {
3174
+ ev.preventDefault();
3175
+ ev.stopPropagation();
3176
+ if (pendingFrom === null) {
3177
+ pendingFrom = { at: cursor.laneIndex, cell: cursor.cycle };
3178
+ say('起點已標記,移到終點再按一次 Enter');
3179
+ render();
3180
+ } else {
3181
+ finishEdgeDrag(cursor.laneIndex, cursor.cycle);
3182
+ }
3183
+ return true;
3184
+ }
3185
+ // v3.5.0 Task 11: Enter on a cell inside a bus box opens that box's
3186
+ // label field instead of painting over it with the brush — the
3187
+ // keyboard's own version of the pointer's click below in
3188
+ // `onCanvasDown`. `resolveBusTarget` is the SAME resolution
3189
+ // `onCanvasDown` uses (fix round 1: a cursor resting on a `.`/`|`
3190
+ // continuation inside a run used to fall straight through to
3191
+ // `paintAtCursor()` below with nothing catching it — the identical
3192
+ // silent-corruption hazard the pointer path had, closed the same way
3193
+ // here rather than only on the mouse), so the two input devices can
3194
+ // never disagree about which cells are labellable or where a press
3195
+ // on a continuation actually lands. Placed AFTER the armed-edge Enter
3196
+ // above (arming an edge outranks opening a label field — the same
3197
+ // press cannot mean both) and BEFORE the generic Enter-paints case
3198
+ // below, which it must pre-empt.
3199
+ if (key === 'Enter' && cursor !== null) {
3200
+ const dlLayout = geometry.layoutOf(store.doc, SIZES);
3201
+ const dlRow = dlLayout.lanes[cursor.laneIndex];
3202
+ const dlLane = dlRow === undefined ? null : dlRow.lane;
3203
+ if (dlLane !== null) {
3204
+ const dlTarget = resolveBusTarget(dlLane, cursor.cycle);
3205
+ if (dlTarget === 'blocked') {
3206
+ ev.preventDefault();
3207
+ ev.stopPropagation();
3208
+ say('這一格接在某個 bus 值後面,但看不出屬於哪一個,沒有欄位可以編輯。');
3209
+ return true;
3210
+ }
3211
+ if (dlTarget !== null) {
3212
+ ev.preventDefault();
3213
+ ev.stopPropagation();
3214
+ cursor = { laneIndex: cursor.laneIndex, cycle: dlTarget.cycle };
3215
+ openDataEdit(cursor.laneIndex, dlTarget.cycle);
3216
+ return true;
3217
+ }
3218
+ }
3219
+ }
1810
3220
  // A brush character both PICKS the brush and paints with it. Picking
1811
3221
  // without painting would need a second keystroke to do the thing the
1812
3222
  // user already said, and the toolbar's own brush buttons are still there
1813
- // for「選起來待會再用」. `ev.shiftKey` is excluded so a shifted key can
1814
- // never paint: with Shift the browser reports `H`, not `h`, and a brush
1815
- // table that quietly lower-cased it would make Shift+arrow's neighbour
1816
- // keys destructive.
1817
- if (!ev.shiftKey && BRUSHES.indexOf(key) !== -1) {
3223
+ // for「選起來待會再用」. `isBrushKey` is the admission rule — see its
3224
+ // own comment (this file's top) for why it is not keyed on
3225
+ // `ev.shiftKey` (v3.5.0 Task 10 fix round 1: that used to block
3226
+ // `P`/`N`/`|`, all typed with Shift, from the keyboard entirely).
3227
+ if (isBrushKey(key)) {
1818
3228
  ev.preventDefault();
1819
3229
  ev.stopPropagation();
1820
3230
  brush = key;
@@ -1990,8 +3400,213 @@
1990
3400
  function onCanvasDown(ev) {
1991
3401
  if (ev.button !== 0) return;
1992
3402
  const layout = geometry.layoutOf(store.doc, SIZES);
3403
+ // v3.5.0 Task 7: armed edge-creation claims the press outright, before
3404
+ // the edge-select / deselect / paint dispatch below ever runs — the
3405
+ // whole point of arming is that the NEXT press starts the drag, so it
3406
+ // must not be shadowed by "did this land on an existing edge" or "did
3407
+ // this land on a cell to paint". A press that misses every lane
3408
+ // (`at === null`, e.g. the canvas's own padding) leaves the button
3409
+ // armed rather than eating it for nothing: missing the diagram is not
3410
+ // the same as aiming at the wrong cell, but the recovery is the same —
3411
+ // try the press again.
3412
+ if (edgeMode === 'armed') {
3413
+ const at = cellFromEvent(ev, layout);
3414
+ if (at === null) return;
3415
+ ev.preventDefault();
3416
+ pendingFrom = { at: at.laneIndex, cell: at.cycle };
3417
+ setEdgeMode('dragging');
3418
+ // The keyboard follows the pointer here for the same reason it does
3419
+ // in the paint path below: `cursor` is the ONE notion of "where is
3420
+ // the other end right now" that the mouse move handler, the preview
3421
+ // line and (on the keyboard's own path) `handleDrawingKey` all read,
3422
+ // so drag and keystroke can never disagree about the live endpoint.
3423
+ cursor = { laneIndex: at.laneIndex, cycle: at.cycle };
3424
+ canvas.focus();
3425
+ render();
3426
+ d.addEventListener('mousemove', onCanvasMove, true);
3427
+ d.addEventListener('mouseup', onCanvasUp, true);
3428
+ return;
3429
+ }
3430
+ // v3.5.0 Task 8: a press on the SELECTED edge's own handle starts an
3431
+ // endpoint drag, ahead of the plain select/deselect dispatch a few
3432
+ // lines down — that dispatch's own `edgeHitAt` would otherwise see the
3433
+ // exact same press and read it as "re-select the edge that is already
3434
+ // selected", which is a real answer but the wrong one for a press ON
3435
+ // its handle. Recomputed fresh here (`geometry.edgeLayout`, not a
3436
+ // cached list) for the same reason `cellFromEvent`/`edgeHitAt`
3437
+ // already are — a press can never be judged against a layout
3438
+ // `renderCanvas` has moved on from.
3439
+ //
3440
+ // Scoped to `handle.index === selectedEdge` on purpose:
3441
+ // `geometry.edgeHandleAt` answers for EVERY edge's endpoints
3442
+ // regardless of selection (see its own comment), but `renderEdges`
3443
+ // only ever DRAWS handles on the selected one — a press near a
3444
+ // DIFFERENT edge's endpoint has no handle on screen to have landed on,
3445
+ // and falls through to the ordinary select-by-path/point dispatch
3446
+ // below exactly as it always has.
3447
+ if (selectedEdge !== null) {
3448
+ const edges = geometry.edgeLayout(store.doc, layout);
3449
+ const box = canvas.getBoundingClientRect();
3450
+ const handle = geometry.edgeHandleAt(edges, ev.clientX - box.left, ev.clientY - box.top);
3451
+ if (handle !== null && handle.index === selectedEdge) {
3452
+ ev.preventDefault();
3453
+ // Fix round 1, item 1: a self-loop (`from === to`) puts both ends
3454
+ // at the exact same pixel, so `geometry.edgeHandleAt`'s own
3455
+ // `['to', 'from']` iteration always resolves the press to `'to'` —
3456
+ // there is no position this file (or the user) can use to tell
3457
+ // the two apart, and inferring one would just be a guess dressed
3458
+ // up as an answer. What was missing was not a fix, it was the
3459
+ // honest half: before this, the press silently moved `'to'` with
3460
+ // nothing on screen saying that is what happened, or that `'from'`
3461
+ // was never reachable at all.
3462
+ const dragged = edges.filter(function (e) { return e.index === handle.index; })[0];
3463
+ if (dragged !== undefined && dragged.edge.from === dragged.edge.to) {
3464
+ say('這是一條 self-loop(起點跟終點在同一格),兩端的把手疊在同一個位置:' +
3465
+ '只有拖得到的這一端可以搬,另一端目前搬不到。');
3466
+ }
3467
+ endpointDrag = { index: handle.index, end: handle.end, at: null, cell: null };
3468
+ canvas.focus();
3469
+ // Fix round 1, item 2: without a repaint here the press looks
3470
+ // inert until the first `pointermove` — `.is-drag-source` (set
3471
+ // from this same `endpointDrag`, read by `renderEdges`) would not
3472
+ // fade the dragged edge until then, unlike the sibling
3473
+ // `edgeMode === 'armed'` branch above, which repaints immediately
3474
+ // on its own press. `renderCanvas`, not the full `render()`: this
3475
+ // state change touches only the canvas SVG (the faded path; the
3476
+ // dashed landing preview has nothing to draw yet since `at` is
3477
+ // still `null`) — `selectedEdge` is unchanged, so the edge
3478
+ // inspector row and the lane list are both already showing exactly
3479
+ // what they should, and paying for `renderLanes`/`renderEdgeBar`
3480
+ // again would be the same needless cost the existing
3481
+ // `hadSelectedEdge`-gated branch further down in this function
3482
+ // already avoids.
3483
+ renderCanvas(store.doc, layout);
3484
+ d.addEventListener('mousemove', onCanvasMove, true);
3485
+ d.addEventListener('mouseup', onCanvasUp, true);
3486
+ return; // 不塗、不選格
3487
+ }
3488
+ }
3489
+ // v3.5.0 Task 11 fix round 2 (widened in round 3): a press that lands
3490
+ // on the label's own mark — the `<text>` when the run has a label, or
3491
+ // (round 3) the small `<circle class="ed-wave-buslabel-empty">`
3492
+ // `drawLane` draws in its place when the run has none — opens the
3493
+ // data-label field instead of arming a paint drag, rather than merely
3494
+ // somewhere inside the box the mark sits in. Round 1 gated this on
3495
+ // the CELL (`codec.dataSlotOf`/`resolveBusTarget` alone, asked off
3496
+ // the pressed cycle with no regard to what was actually under the
3497
+ // pointer) and that was reviewer-verified to be its own regression:
3498
+ // T6b paints `N` onto a bus cell with the mouse, and painting a
3499
+ // bus cell is exactly what twenty-two brushes exist for — a gesture
3500
+ // that swallows every press anywhere in the box leaves the user no
3501
+ // pointer route to repaint one at all. The string is what a press on
3502
+ // its mark means to change, so the mark is the target; a press
3503
+ // anywhere else in the SAME box — the polygon, its bevelled ends, a
3504
+ // held continuation's own cell — falls straight through to the
3505
+ // ordinary paint dispatch below exactly as it always has, ROUND 1's
3506
+ // fix included: that fix's `resolveBusTarget` still runs, just gated
3507
+ // on the mark hit rather than on cell membership, so a press on the
3508
+ // label of a merged run still resolves to the run's owning explicit
3509
+ // cycle regardless of which cycle's pixels the mark happens to
3510
+ // straddle (see `resolveBusTarget`'s own comment — its answer is the
3511
+ // same whichever cycle a press on that shared mark geometrically
3512
+ // lands on). Both marks carry the SAME `ed-wave-buslabel` class —
3513
+ // round 3's placeholder ADDS `ed-wave-buslabel-empty` on top of it
3514
+ // rather than replacing it — so this check needed no change at all
3515
+ // to cover the empty case; it was already asking for the class, not
3516
+ // for an element type. `.ed-wave-buslabel`'s `pointer-events:
3517
+ // visiblePainted` (`lib/md2doc.js`) plus paint order — each mark is
3518
+ // appended AFTER its box's `<polygon>` in `drawLane`, so it sits on
3519
+ // top and wins the hit test in its own footprint — is what makes the
3520
+ // two targets disjoint instead of the mark being invisibly swallowed
3521
+ // by the polygon underneath it.
3522
+ //
3523
+ // final review finding 4: this block moved AHEAD of `edgeHitAt` below
3524
+ // (it used to run after it), and its condition now asks
3525
+ // `busLabelHitAt` instead of testing `ev.target` directly.
3526
+ //
3527
+ // Gating `edgeHandleAt` on `selectedEdge` (the change in `edgeHitAt`
3528
+ // itself, above) stops a PHANTOM, undrawn handle from claiming a press
3529
+ // by pure coordinate math — but MEASURED against a real page, that
3530
+ // alone does not make this block reachable: `renderEdges` draws
3531
+ // `.ed-wave-edge-hit` (a 10px-wide, invisible-but-hit-testable stroke,
3532
+ // `pointer-events: stroke`) AFTER every lane's own content, so for any
3533
+ // edge whose endpoint lands on a bus cell, that invisible stroke
3534
+ // paints ON TOP of the cell's `.ed-wave-buslabel` mark. `ev.target` —
3535
+ // the browser's own topmost-element answer — is `path.ed-wave-edge-
3536
+ // hit` at that pixel regardless of source order in THIS function:
3537
+ // moving this block ahead of `edgeHitAt` does not change what
3538
+ // `ev.target` IS, only when it gets asked. Reordering plus the
3539
+ // original `ev.target` check was tried first and measured to still
3540
+ // fail — `elementFromPoint` at the labelled cell's own centre resolved
3541
+ // to the hit path, not the label, exactly as before.
3542
+ //
3543
+ // `busLabelHitAt` answers a different question: not "what is the
3544
+ // TOPMOST element here" but "is there a bus-label mark ANYWHERE in the
3545
+ // stack of elements under this press", via `elementsFromPoint`
3546
+ // (plural) — the standard way to see past one hit-testable layer
3547
+ // sitting on top of another at the same point. That is a real,
3548
+ // measured difference in outcome, not a matter of code order.
3549
+ if (busLabelHitAt(ev) !== null) {
3550
+ const busAt = cellFromEvent(ev, layout);
3551
+ if (busAt !== null) {
3552
+ const busLane = layout.lanes[busAt.laneIndex].lane;
3553
+ const busTarget = resolveBusTarget(busLane, busAt.cycle);
3554
+ // `resolveBusTarget` should never actually answer `null` here —
3555
+ // the label only ever exists over a cycle the codec itself calls
3556
+ // a bus level — but the check is kept rather than assumed, same
3557
+ // reasoning as the `'blocked'` branch just below it.
3558
+ if (busTarget === 'blocked') {
3559
+ ev.preventDefault();
3560
+ say('這一格接在某個 bus 值後面,但看不出屬於哪一個,沒有欄位可以編輯。');
3561
+ return;
3562
+ }
3563
+ if (busTarget !== null) {
3564
+ ev.preventDefault();
3565
+ const busHadSelectedEdge = selectedEdge !== null;
3566
+ selectedEdge = null;
3567
+ cursor = { laneIndex: busAt.laneIndex, cycle: busTarget.cycle };
3568
+ selection = { laneIndex: busAt.laneIndex, from: busTarget.cycle, to: busTarget.cycle };
3569
+ canvas.focus();
3570
+ if (busHadSelectedEdge) render();
3571
+ else renderCanvas(store.doc, layout);
3572
+ openDataEdit(busAt.laneIndex, busTarget.cycle);
3573
+ return;
3574
+ }
3575
+ }
3576
+ }
3577
+ // An edge — its endpoint handle first (it sits ON TOP of the cell it
3578
+ // anchors to, and a selected edge's handle must win the press even
3579
+ // though the cell underneath is perfectly paintable), then its path —
3580
+ // is asked BEFORE the cell/brush dispatch below (but AFTER the bus
3581
+ // label block above — see its own comment for why that one now goes
3582
+ // first). A hit selects and returns without arming `drag`: this
3583
+ // dispatch draws and selects edges; a press on the SELECTED edge's own
3584
+ // handle never reaches here at all (see the drag-start branch above),
3585
+ // so this remains a plain select — it does not itself drag anything.
3586
+ const edgeHit = edgeHitAt(ev, layout);
3587
+ if (edgeHit !== null) {
3588
+ ev.preventDefault();
3589
+ selectedEdge = edgeHit;
3590
+ canvas.focus();
3591
+ render();
3592
+ return;
3593
+ }
3594
+ // A press that hit neither an edge's handle nor its path deselects
3595
+ // whatever WAS selected. The inspector row is the only visible sign a
3596
+ // selection exists, and a selection that outlives a click elsewhere
3597
+ // would let a later 刪除這條線 act on an edge the user has stopped
3598
+ // looking at. This is IN ADDITION to the paint gesture below, on the
3599
+ // very same press — not instead of it.
3600
+ const hadSelectedEdge = selectedEdge !== null;
3601
+ selectedEdge = null;
1993
3602
  const at = cellFromEvent(ev, layout);
1994
- if (at === null) return;
3603
+ if (at === null) {
3604
+ // Missed the diagram entirely (e.g. the canvas's own padding). Only
3605
+ // the deselect, if there was one to make, needs a repaint; nothing
3606
+ // else here changed.
3607
+ if (hadSelectedEdge) render();
3608
+ return;
3609
+ }
1995
3610
  ev.preventDefault();
1996
3611
  drag = { laneIndex: at.laneIndex, from: at.cycle, to: at.cycle };
1997
3612
  selection = { laneIndex: at.laneIndex, from: at.cycle, to: at.cycle };
@@ -2002,12 +3617,54 @@
2002
3617
  // then pressing `1` would paint wherever the keyboard had been left.
2003
3618
  cursor = { laneIndex: at.laneIndex, cycle: at.cycle };
2004
3619
  canvas.focus();
2005
- renderCanvas(store.doc, layout);
3620
+ // The common case — no edge was selected — keeps the cheap
3621
+ // `renderCanvas`-only repaint this always did. A full `render()` is
3622
+ // only paid on the transition that actually needs it: hiding the
3623
+ // inspector row, which lives in the toolbar `renderCanvas` never
3624
+ // touches.
3625
+ if (hadSelectedEdge) render();
3626
+ else renderCanvas(store.doc, layout);
2006
3627
  d.addEventListener('mousemove', onCanvasMove, true);
2007
3628
  d.addEventListener('mouseup', onCanvasUp, true);
2008
3629
  }
2009
3630
 
2010
3631
  function onCanvasMove(ev) {
3632
+ if (endpointDrag !== null) {
3633
+ // Same abandoned-button recovery as the two drags below: the button
3634
+ // came up somewhere this page never heard about.
3635
+ if (ev.buttons === 0) { onCanvasUp(); return; }
3636
+ const layout = geometry.layoutOf(store.doc, SIZES);
3637
+ const at = cellFromEvent(ev, layout);
3638
+ // A move that strays off the diagram leaves `at`/`cell` exactly
3639
+ // where they last were, same recovery as `edgeMode === 'dragging'`
3640
+ // below and for the same reason: the drop target the user is
3641
+ // currently over is still the last cell the pointer was actually on
3642
+ // top of, not "nowhere".
3643
+ if (at !== null) {
3644
+ endpointDrag = { index: endpointDrag.index, end: endpointDrag.end,
3645
+ at: at.laneIndex, cell: at.cycle };
3646
+ }
3647
+ // The document is untouched during the drag (rule 3): this repaints
3648
+ // the preview only, never a `commit`.
3649
+ renderCanvas(store.doc, layout);
3650
+ return;
3651
+ }
3652
+ if (edgeMode === 'dragging') {
3653
+ // Same abandoned-button recovery as the paint drag below: the button
3654
+ // came up somewhere this page never heard about.
3655
+ if (ev.buttons === 0) { onCanvasUp(); return; }
3656
+ const layout = geometry.layoutOf(store.doc, SIZES);
3657
+ const at = cellFromEvent(ev, layout);
3658
+ // A move that strays off the diagram leaves `cursor` — and so the
3659
+ // preview line — exactly where it last was, rather than snapping the
3660
+ // line away: the drop target the user is currently over is still the
3661
+ // last cell the pointer was actually on top of.
3662
+ if (at !== null) cursor = { laneIndex: at.laneIndex, cycle: at.cycle };
3663
+ // The document is untouched during the drag (rule 2): this repaints
3664
+ // the preview line only, never a `commit`.
3665
+ renderCanvas(store.doc, layout);
3666
+ return;
3667
+ }
2011
3668
  if (drag === null) return;
2012
3669
  // The button came up somewhere this page never heard about — outside the
2013
3670
  // window, or over the browser's own chrome. Without this the drag stays
@@ -2032,6 +3689,21 @@
2032
3689
  function onCanvasUp() {
2033
3690
  d.removeEventListener('mousemove', onCanvasMove, true);
2034
3691
  d.removeEventListener('mouseup', onCanvasUp, true);
3692
+ if (endpointDrag !== null) {
3693
+ finishEndpointDrag();
3694
+ return;
3695
+ }
3696
+ if (edgeMode === 'dragging') {
3697
+ // `cursor` is never null here: `onCanvasDown` set it to the press
3698
+ // cell before this drag was ever armed, and every move since then
3699
+ // only ever moved it to another real cell (see `onCanvasMove`
3700
+ // above). The null-safe form costs nothing and matches the
3701
+ // `finishEdgeDrag`/`codec.ensureNode` contract exactly rather than
3702
+ // asserting a fact this file does not have to assert.
3703
+ finishEdgeDrag(cursor === null ? null : cursor.laneIndex,
3704
+ cursor === null ? null : cursor.cycle);
3705
+ return;
3706
+ }
2035
3707
  if (drag === null) return;
2036
3708
  const g = drag;
2037
3709
  drag = null;
@@ -2040,6 +3712,131 @@
2040
3712
  });
2041
3713
  }
2042
3714
 
3715
+ /**
3716
+ * The end of an endpoint drag on the SELECTED edge — one `commit`, so one
3717
+ * undo step, matching every other gesture in this file (constraint 5).
3718
+ *
3719
+ * Unlike `finishEdgeDrag` below, this has no keyboard-driven twin and
3720
+ * takes no parameters: there is no second input device sharing `cursor`
3721
+ * to aim it, so everything the drop needs already lives in `endpointDrag`
3722
+ * itself, read and cleared in the same first two lines the same way
3723
+ * `finishEdgeDrag` clears `pendingFrom`'s owning mode up front.
3724
+ *
3725
+ * `drag.at === null` means the pointer never landed on a real cell for
3726
+ * the whole gesture (pressed the handle, released off the diagram or
3727
+ * without ever moving) — nothing to drop onto, so this only repaints to
3728
+ * take the (already absent) preview line off screen and does not call
3729
+ * `commit` at all; a `commit` with a non-integer `at` would just be
3730
+ * `codec.moveEdgeEnd`'s own bounds check refusing it one function later
3731
+ * for no benefit.
3732
+ *
3733
+ * Every other landing — including the three no-ops `moveEdgeEnd` itself
3734
+ * refuses (dropping back on the end's own cell, dropping on the cell
3735
+ * holding the edge's OTHER end, and an out-of-range target) — reaches
3736
+ * `commit` exactly once and is judged by `commit`'s own return value,
3737
+ * the same "one test of did-anything-change" rule `finishEdgeDrag`
3738
+ * documents at length below: nothing here re-derives any of those three
3739
+ * checks, that would be a second belief about the same refusal living
3740
+ * next to `moveEdgeEnd`'s own.
3741
+ */
3742
+ function finishEndpointDrag() {
3743
+ const drag = endpointDrag;
3744
+ endpointDrag = null;
3745
+ if (drag === null) return;
3746
+ if (drag.at === null) { render(); return; }
3747
+ commit('edge-move', function (doc) {
3748
+ return codec.moveEdgeEnd(doc, drag.index, drag.end, drag.at, drag.cell);
3749
+ });
3750
+ render();
3751
+ }
3752
+
3753
+ /**
3754
+ * The end of an edge-creation gesture, reached from both input devices —
3755
+ * the mouse's `onCanvasUp` above and the keyboard's second Enter in
3756
+ * `handleDrawingKey` below. Constraint 4: one function, not two
3757
+ * histories of the same decision.
3758
+ *
3759
+ * `at`/`cell` name the drop cell, or `null`/`null` when the gesture did
3760
+ * not land on any lane at all. Nothing below special-cases the no-op
3761
+ * drops rule 3 lists — dropping back on the start cell, dropping on a
3762
+ * row with no waveform (a spacer — a group's own title is never a row
3763
+ * to drop on in the first place: `codec.isLane` rejects the title
3764
+ * string, so `flattenLanes` never allocates one, and there is no
3765
+ * coordinate here to mis-hit), dropping on the cell that already holds
3766
+ * this edge's OTHER end, and — fix round 1 — repeating the exact same
3767
+ * drag a second time:
3768
+ *
3769
+ * - a spacer's row IS a real, hittable cell (`geometry.cellAt` answers
3770
+ * it like any other), but `codec.ensureNode`'s own lane-local bound
3771
+ * refuses it: an empty `wave` reads as zero cells, so no cycle is
3772
+ * ever in range. The "missed the diagram entirely" case
3773
+ * (`at === null`) falls through the SAME check for a different
3774
+ * reason — neither `at` nor `cell` is an integer `ensureNode`'s
3775
+ * bounds test accepts;
3776
+ * - the start cell and the drop cell landing on the same anchor —
3777
+ * whether because they truly are the same cell, or because the drop
3778
+ * cell is wherever this edge's other end already lives — is exactly
3779
+ * `a.letter === b.letter` below;
3780
+ * - a repeat of this exact gesture between the same two cells would,
3781
+ * without the check below, silently push a byte-identical second
3782
+ * `doc.edge` entry — two arrows drawn exactly on top of each other,
3783
+ * indistinguishable on screen, plus a second undo step for nothing
3784
+ * visible (MEASURED against the real store: drag (0,1)->(0,3) twice,
3785
+ * `doc.edge` becomes `["a~>b","a~>b"]`). Refused here rather than
3786
+ * before `commit`, same as the other three: an entry with the same
3787
+ * four fields (`from`, `to`, `shape`, `label`) already in `doc.edge`
3788
+ * makes this call's own output redundant. This is narrower than
3789
+ * "refuse any second edge between these two anchors" — two
3790
+ * DIFFERENT annotations between the same pair (`a~>b setup` next to
3791
+ * `a-b hold`) are legitimate and stay possible; only a verbatim
3792
+ * repeat of THIS gesture's own fixed output (`shape: '~>'`,
3793
+ * `label: ''`) is refused.
3794
+ *
3795
+ * So there is one test of "did anything change", not four: `commit`'s
3796
+ * own return value. A refused/no-op commit pushes no undo step (`commit`
3797
+ * already guarantees that) and — rule 1 — leaves the gesture `'armed'`
3798
+ * rather than disarming it, because the user pressed the button and got
3799
+ * nothing for it; a real edge disarms back to `'idle'`. Either way this
3800
+ * repaints once more on its own, because `commit` only repaints when it
3801
+ * changed something, and the pending-line preview has to come off the
3802
+ * screen in the no-op case too.
3803
+ *
3804
+ * Fix round 3: `pendingFrom === null` on entry is no longer read as
3805
+ * "nothing to do" and left at that. `carryMarks` can now drop
3806
+ * `pendingFrom` to `null` on its own (round 2, both when a structural
3807
+ * change makes it untraceable and when no `laneTrace` is injected at
3808
+ * all) WITHOUT touching `edgeMode` — before round 2 the only code path
3809
+ * that ever nulled `pendingFrom` was `setEdgeMode('idle')` itself, so
3810
+ * the two could never come apart. Reached from `onCanvasUp` while
3811
+ * `edgeMode === 'dragging'` (the mouse button was down when the
3812
+ * document changed out from under it), leaving `edgeMode` untouched
3813
+ * here would strand it at `'dragging'` forever: `onCanvasMove` and
3814
+ * `onCanvasUp` both check that mode FIRST, so every later paint drag's
3815
+ * `mousemove`/`mouseup` would be swallowed by this function instead of
3816
+ * painting, silently and with no error. So this resyncs the mode to
3817
+ * the mark it depends on before doing anything else.
3818
+ */
3819
+ function finishEdgeDrag(at, cell) {
3820
+ if (pendingFrom === null) { setEdgeMode('idle'); render(); return; }
3821
+ const from = pendingFrom;
3822
+ const changed = commit('edge-add', function (doc) {
3823
+ const a = codec.ensureNode(doc, from.at, from.cell);
3824
+ if (a.letter === null) return doc;
3825
+ const b = codec.ensureNode(a.doc, at, cell);
3826
+ if (b.letter === null) return doc;
3827
+ if (a.letter === b.letter) return doc; // same anchor both ends
3828
+ const dup = (b.doc.edge || []).some(function (entry) {
3829
+ const e = codec.parseEdge(entry);
3830
+ return e !== null && e.from === a.letter && e.to === b.letter &&
3831
+ e.shape === '~>' && e.label === '';
3832
+ });
3833
+ if (dup) return doc; // byte-identical repeat of this gesture
3834
+ return codec.addEdge(b.doc, { from: a.letter, to: b.letter, shape: '~>', label: '' });
3835
+ });
3836
+ setEdgeMode(changed ? 'idle' : 'armed');
3837
+ render();
3838
+ }
3839
+
2043
3840
  /** Pointer coordinates in the drawing's own space, then the codec's cell. */
2044
3841
  function cellFromEvent(ev, layout) {
2045
3842
  if (canvas === null) return null;
@@ -2047,6 +3844,137 @@
2047
3844
  return geometry.cellAt(layout, ev.clientX - box.left, ev.clientY - box.top);
2048
3845
  }
2049
3846
 
3847
+ /**
3848
+ * The `doc.edge` index the press landed on, or `null`.
3849
+ *
3850
+ * Two questions, in order, both against `geometry.edgeLayout(store.doc,
3851
+ * layout)` computed fresh here — the same recompute-don't-cache rule
3852
+ * `cellFromEvent`/`geometry.cellAt` already follow, so a press can never
3853
+ * be judged against a layout `renderCanvas` has moved on from:
3854
+ *
3855
+ * 1. `geometry.edgeHandleAt` — a handle is a small rect centred on an
3856
+ * endpoint, asked in the SAME layout-local space `cellFromEvent`
3857
+ * uses (the canvas's own `getBoundingClientRect()`; `SIZES.nameColWidth`
3858
+ * is 0 in this file, so that space starts at cycle 0 with no offset
3859
+ * to subtract). final review finding 4: trusted when it names the
3860
+ * edge that is actually SELECTED — `renderEdges` only DRAWS a
3861
+ * handle on the selected edge, so its answer for any OTHER edge is a
3862
+ * rectangle with nothing on screen, and letting that phantom
3863
+ * rectangle claim the press is what used to swallow a bus label's
3864
+ * press whenever the label's cell doubled as an unselected edge's
3865
+ * endpoint (see `onCanvasDown`'s `busLabelHitAt` check, which now
3866
+ * runs before this function is even called, for the other half of
3867
+ * that fix). A press within a handle-sized target of the SELECTED
3868
+ * edge's own endpoint still selects it rather than painting the
3869
+ * cell under it. final review re-review, item 1: ALSO trusted when
3870
+ * the named edge is a self-loop (`from === to`), selected or not —
3871
+ * a self-loop's `pathFor` output is a zero-length path (every
3872
+ * command's endpoint equals its start), which Chrome gives no hit
3873
+ * area at all, so #2 below can never find one; the handle is the
3874
+ * only thing a self-loop can ever be selected by, and a self-loop
3875
+ * the user hand-writes into the markdown must stay selectable and
3876
+ * deletable even before it has ever been clicked once.
3877
+ * 2. The `<path class="ed-wave-edge-hit">` twin `renderEdges` layers
3878
+ * over each visible edge, read back through `document.elementFromPoint`
3879
+ * at the press's SCREEN coordinates (no conversion needed — that is
3880
+ * what `elementFromPoint` already takes).
3881
+ */
3882
+ function edgeHitAt(ev, layout) {
3883
+ if (canvas === null) return null;
3884
+ const edges = geometry.edgeLayout(store.doc, layout);
3885
+ if (edges.length === 0) return null;
3886
+ const box = canvas.getBoundingClientRect();
3887
+ const handle = geometry.edgeHandleAt(edges, ev.clientX - box.left, ev.clientY - box.top);
3888
+ // final review finding 4: only trust the handle answer when it names
3889
+ // the edge that is actually SELECTED — the same scoping the
3890
+ // endpoint-drag branch in `onCanvasDown` already applies, for the same
3891
+ // reason (see its comment): `renderEdges` only ever DRAWS a handle on
3892
+ // the selected edge, so `geometry.edgeHandleAt`'s answer for any OTHER
3893
+ // edge index is a rectangle with nothing on screen at all. Before this,
3894
+ // that undrawn rectangle still won the press here — a bus label whose
3895
+ // cell happened to double as an unselected edge's endpoint could never
3896
+ // open its label editor, because this branch claimed the press first.
3897
+ // An edge stays selectable regardless: the fall-through below still
3898
+ // hit-tests every edge's own 10px `.ed-wave-edge-hit` path.
3899
+ //
3900
+ // final review re-review, item 1: EXCEPT a self-loop (`from === to`),
3901
+ // which this "an edge stays selectable regardless" promise does not
3902
+ // actually cover — MEASURED regression, not a hypothetical: `pathFor`
3903
+ // degenerates to a zero-length path for every shape family when
3904
+ // `from === to` (every `L`/`C` command's endpoint equals its start),
3905
+ // and Chrome gives a zero-length, butt-cap path NO hit area at all —
3906
+ // `elementsFromPoint` returns nothing there, so the `.ed-wave-edge-hit`
3907
+ // fallback below can never find it either. The phantom handle this
3908
+ // finding gates off was the ONLY thing that could ever select a
3909
+ // self-loop that has never been selected before (`selectedEdge` is set
3910
+ // from exactly this function's return value, nowhere else) — a
3911
+ // self-loop the user hand-writes into the markdown (the UI itself
3912
+ // never creates one; `finishEdgeDrag` refuses a same-letter drag) would
3913
+ // otherwise be permanently unselectable and undeletable by mouse. So a
3914
+ // handle match is trusted when it is drawn (`handle.index ===
3915
+ // selectedEdge`, as before) OR when the edge it names is a self-loop —
3916
+ // a self-loop's handle is the one hit target it has, selected or not.
3917
+ if (handle !== null) {
3918
+ if (handle.index === selectedEdge) return handle.index;
3919
+ const named = edges.filter(function (e) { return e.index === handle.index; })[0];
3920
+ if (named !== undefined && named.edge.from === named.edge.to) return handle.index;
3921
+ }
3922
+ if (typeof d.elementFromPoint !== 'function') return null;
3923
+ const el = d.elementFromPoint(ev.clientX, ev.clientY);
3924
+ const hitPath = el !== null && el !== undefined && typeof el.closest === 'function'
3925
+ ? el.closest('.ed-wave-edge-hit') : null;
3926
+ if (hitPath === null || !canvas.contains(hitPath)) return null;
3927
+ const idx = hitPath.getAttribute('data-edge-index');
3928
+ return idx === null ? null : Number(idx);
3929
+ }
3930
+
3931
+ /**
3932
+ * The `.ed-wave-buslabel` mark under a press, or `null` — final review
3933
+ * finding 4.
3934
+ *
3935
+ * NOT `ev.target`: `renderEdges` draws `.ed-wave-edge-hit` (invisible,
3936
+ * `pointer-events: stroke`, 10px wide) AFTER every lane's own content, so
3937
+ * whenever an edge's endpoint lands on a bus cell, that stroke paints ON
3938
+ * TOP of the cell's own label mark — `ev.target` at that pixel is the
3939
+ * edge-hit path, never the label underneath it, and no amount of
3940
+ * reordering the CALLS in `onCanvasDown` changes that, because `ev.target`
3941
+ * is the browser's own topmost-element answer, decided at dispatch time
3942
+ * by paint order, not by which `if` this file happens to ask first.
3943
+ * MEASURED against a real page: moving the bus-label check ahead of
3944
+ * `edgeHitAt` while still reading `ev.target` left the label unreachable,
3945
+ * identically to before.
3946
+ *
3947
+ * `document.elementsFromPoint` (plural — distinct from the singular
3948
+ * `elementFromPoint` `edgeHitAt` above reads) answers a different
3949
+ * question: the WHOLE stack of elements under the point, in z-order, not
3950
+ * only the topmost one. Walking it for a `.ed-wave-buslabel` finds the
3951
+ * mark even when something else is painted over it, which is exactly
3952
+ * this finding's fix. The walk stops at `canvas` itself so a label-shaped
3953
+ * class elsewhere in the document (outside the canvas this press is
3954
+ * even inside) can never match.
3955
+ *
3956
+ * Falls back to the old `ev.target`-only check when `elementsFromPoint`
3957
+ * is not available at all (older engines) — that keeps every OTHER bus
3958
+ * label (the overwhelming majority, which do not sit under an edge's
3959
+ * endpoint) working exactly as it always has; only the overlap case this
3960
+ * finding is about stays unfixed on such an engine.
3961
+ */
3962
+ function busLabelHitAt(ev) {
3963
+ function isLabel(el) {
3964
+ return el !== null && el !== undefined && typeof el.classList === 'object' &&
3965
+ el.classList !== null && el.classList.contains('ed-wave-buslabel');
3966
+ }
3967
+ if (typeof d.elementsFromPoint === 'function') {
3968
+ const stack = d.elementsFromPoint(ev.clientX, ev.clientY);
3969
+ for (const el of stack) {
3970
+ if (isLabel(el)) return el;
3971
+ if (el === canvas) break;
3972
+ }
3973
+ return null;
3974
+ }
3975
+ return isLabel(ev.target) ? ev.target : null;
3976
+ }
3977
+
2050
3978
  // ── the preview ───────────────────────────────────────────────────────
2051
3979
  /**
2052
3980
  * The engine's own rendering of the very same document.
@@ -2119,6 +4047,7 @@
2119
4047
  groupSpansOf: groupSpansOf,
2120
4048
  mountedInsideContent: mountedInsideContent,
2121
4049
  BRUSHES: BRUSHES,
4050
+ isBrushKey: isBrushKey,
2122
4051
  SIZES: SIZES,
2123
4052
  };
2124
4053
  });