@helping-ai-workflow/md2doc 3.5.0 → 3.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,9 +9,10 @@
9
9
  * inlined as a plain <script>, not bundled, so a bare require() here would be
10
10
  * undefined in the browser — every dependency arrives as a parameter instead
11
11
  * (`opts.codec`, `opts.geometry`, `opts.createStore`, `opts.wavedrom`,
12
- * `opts.document`). That is also why this file names no global of its own: the
13
- * layer that knows which `document` and which engine build are in play is
14
- * client.js, and it is the only one that should be deciding.
12
+ * `opts.document`, `opts.draw`, `opts.panels`). That is also why this file
13
+ * names no global of its own: the layer that knows which `document` and which
14
+ * engine build are in play is client.js, and it is the only one that should be
15
+ * deciding.
15
16
  *
16
17
  * FIVE things this file is not allowed to get wrong, each of them a ruling from
17
18
  * Tasks 1-5 rather than a preference:
@@ -103,8 +104,25 @@
103
104
  }
104
105
 
105
106
  /** Drawing sizes. Handed to `wave-geometry` so the hit test and the paint
106
- * cannot drift; nothing here re-derives a row position. */
107
- const SIZES = { laneHeight: 34, cycleWidth: 48, nameColWidth: 0 };
107
+ * cannot drift; nothing here re-derives a row position.
108
+ *
109
+ * v3.6.0 Task 6: nameColWidth was 0 — the canvas always supported a name
110
+ * column (`wave-geometry.DEFAULTS` carried 40), the UI was just passing 0.
111
+ * 120px: the canvas needs to say what a lane is called, not lean on the
112
+ * rail beside it. Task 8 re-measures this against the longest lane name in
113
+ * the repo's fixtures and may change it — this value is not re-derived
114
+ * here.
115
+ *
116
+ * v3.6.0 Task 8 — MEASURED: the longest `name:` fixture across
117
+ * test/editor-journey.test.js, test/wave-codec.test.js and
118
+ * test/wave-geometry.test.js is 12 chars (`he said "hi"`). Rendered as an
119
+ * SVG `<text>` at 11px (`wave-draw.js`'s `.ed-wave-lane-label`) it measures
120
+ * 54.9px in the app's default sans-serif stack and 79.5px in the worst
121
+ * case tried (monospace) — both comfortably under the 110px a top-level
122
+ * (depth 0) name gets (`nameColWidth 120 - NAME_GAP 10`), so 120 stays.
123
+ * See task-8-report.md for the full measurement (puppeteer,
124
+ * `getComputedTextLength`). */
125
+ const SIZES = { laneHeight: 34, cycleWidth: 48, nameColWidth: 120 };
108
126
 
109
127
  /**
110
128
  * The index the preview is rendered under, and why it is not 0.
@@ -131,52 +149,6 @@
131
149
  const PREVIEW_INDEX = 9000;
132
150
  const PREVIEW_ID_PREFIX = 'md2doc-wave-preview-';
133
151
 
134
- /**
135
- * What wavedrom 3.5.0 actually draws for one level, by its own brick id.
136
- *
137
- * Every row was MEASURED in this session against the pinned engine, by
138
- * rendering `wave: 'z' + c + c` (the `z` anchors the char so it is never at
139
- * cycle 0) and reading the second half-brick of each cycle out of the
140
- * rendered SVG's `<use xlink:href="#…">` list:
141
- *
142
- * 0 000 1 111 x xxx z zzz u uuu d ddd
143
- * h 111 l 000 H 111 L 000
144
- * p nclk P nclk n pclk N pclk
145
- * = vvv-2 2 vvv-2 3 vvv-3 … 9 vvv-9
146
- * anything else xxx (measured with 'Q' and 'ä')
147
- *
148
- * So this is not a re-implementation of the engine's level model — `levelsOf`
149
- * is that, and it lives in the codec. This is the last step: which PICTURE
150
- * the engine paints for a level it has already decided. Keeping it in
151
- * production code rather than in the test is deliberate: the drawing has to
152
- * make exactly this decision anyway (an `h` lane is drawn high, an `=` lane
153
- * is drawn as a bus), and a copy of it living only in a test would be a
154
- * second belief about the engine with nothing pinning it to the first.
155
- */
156
- function brickOf(level) {
157
- if (typeof level !== 'string' || level.length === 0) return 'xxx';
158
- if (level === '0' || level === 'l' || level === 'L') return '000';
159
- if (level === '1' || level === 'h' || level === 'H') return '111';
160
- if (level === 'z') return 'zzz';
161
- if (level === 'u') return 'uuu';
162
- if (level === 'd') return 'ddd';
163
- if (level === 'p' || level === 'P') return 'nclk';
164
- if (level === 'n' || level === 'N') return 'pclk';
165
- if (level === '=') return 'vvv-2';
166
- if (level >= '2' && level <= '9') return 'vvv-' + level;
167
- return 'xxx';
168
- }
169
-
170
- /** A brick that draws a whole clock period and therefore never merges with
171
- * its neighbour: two `p` cycles are two clock periods, not one long one. */
172
- function isClock(brick) {
173
- return brick === 'nclk' || brick === 'pclk';
174
- }
175
-
176
- function isBus(brick) {
177
- return brick.slice(0, 4) === 'vvv-';
178
- }
179
-
180
152
  /**
181
153
  * Whether `el` is inside the editor's serialisation surface.
182
154
  *
@@ -194,28 +166,6 @@
194
166
  return false;
195
167
  }
196
168
 
197
- /**
198
- * A plain, unfrozen copy of a document.
199
- *
200
- * The store's documents are deep-frozen — that is what stops a stray write
201
- * from corrupting an undo entry — and wavedrom's renderer WRITES INTO the
202
- * source object it is handed. Under this file's strict prologue that is a
203
- * throw, so the preview gets its own copy every time. Bounded by the codec's
204
- * own nesting ceiling so a pathological document cannot run the stack out
205
- * here either.
206
- */
207
- function plainCopy(value, depth) {
208
- if (depth > 64 || value === null || typeof value !== 'object') return value;
209
- if (Array.isArray(value)) {
210
- const out = [];
211
- for (let i = 0; i < value.length; i++) out.push(plainCopy(value[i], depth + 1));
212
- return out;
213
- }
214
- const out = {};
215
- for (const key of Object.keys(value)) out[key] = plainCopy(value[key], depth + 1);
216
- return out;
217
- }
218
-
219
169
  /** Line and column of a byte offset, 1-based — what a parse failure has to
220
170
  * say beyond its own message for the offset to mean anything to a person. */
221
171
  function whereIs(text, offset) {
@@ -224,82 +174,14 @@
224
174
  return { line: lines.length, column: lines[lines.length - 1].length + 1 };
225
175
  }
226
176
 
227
- /**
228
- * The group titles in the lane tree, each with the span of display rows it
229
- * covers.
230
- *
231
- * A group title occupies NO ROW — measured in Task 2 against the pinned
232
- * engine, and `wave-geometry` is built on it. What it occupies is horizontal
233
- * space in the name column, so that is where this puts it: a label in the
234
- * lane list's rail, spanning its lanes' rows. Drawing it as a row of its own
235
- * would put the lane list one row out of step with the waveform beside it for
236
- * every lane below the group, which is exactly the class of silent
237
- * disagreement this batch keeps producing.
238
- *
239
- * The span is derived from the lanes' own paths — `layout.lanes[i].path` is
240
- * the codec's — so a group with no lanes under it has no span and is not
241
- * shown here; it is not addressable through the bridge either.
242
- */
243
- function groupSpansOf(doc, layout) {
244
- const byKey = new Map();
245
- for (let i = 0; i < layout.lanes.length; i++) {
246
- const path = layout.lanes[i].path;
247
- // Every proper ancestor between `signal` and the lane itself is a group.
248
- for (let cut = 2; cut < path.length; cut++) {
249
- const prefix = path.slice(0, cut);
250
- const key = JSON.stringify(prefix);
251
- const seen = byKey.get(key);
252
- if (seen === undefined) {
253
- byKey.set(key, { path: prefix, depth: cut - 1, from: i, to: i });
254
- } else {
255
- seen.to = i;
256
- }
257
- }
258
- }
259
- const out = [];
260
- for (const span of byKey.values()) {
261
- const container = valueAt(doc, span.path);
262
- const title = Array.isArray(container) && typeof container[0] === 'string'
263
- ? container[0] : null;
264
- if (title === null) continue; // an untitled group has nothing to show
265
- out.push({ path: span.path, title: title, depth: span.depth,
266
- from: span.from, to: span.to });
267
- }
268
- out.sort(function (a, b) { return a.from - b.from || a.depth - b.depth; });
269
- return out;
270
- }
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
-
295
- function valueAt(doc, path) {
296
- let node = doc;
297
- for (const seg of path) {
298
- if (node === null || typeof node !== 'object') return undefined;
299
- node = node[seg];
300
- }
301
- return node;
302
- }
177
+ // `plainCopy`, `groupSpansOf`, `directFirstLaneOf` and `valueAt` used to
178
+ // live here. v3.6.0 Task 2 moved `plainCopy` and `directFirstLaneOf` to
179
+ // `wave-panels.js` with the rendering functions that were their only
180
+ // callers (and Task 14 then deleted `directFirstLaneOf` outright, with the
181
+ // per-row 解散群組 button that was its one caller); `groupSpansOf` and
182
+ // `valueAt` moved to `wave-geometry.js` per ruling R9, since both
183
+ // `wave-panels.js` and `wave-draw.js` need them and that module is the one
184
+ // place both already depend on.
303
185
 
304
186
  /** The labels a lane's `data` supplies, one per explicit value cycle. */
305
187
  function labelsOf(lane) {
@@ -350,9 +232,63 @@
350
232
  throw new Error('wave-ui: the overlay may not be mounted inside .content ' +
351
233
  '(its children are serialised back into the user markdown)');
352
234
  }
235
+ // `opts.draw` (wave-draw.js's `createDrawer`) arrives as a parameter like
236
+ // every other dependency here, per this file's own header comment — a
237
+ // bare `require('./wave-draw.js')` would be undefined in the browser,
238
+ // where wave-ui.js is injected as a plain <script> with no shim of its
239
+ // own (see lib/editor/server.js). Missing it fails LOUD rather than
240
+ // quietly drawing nothing: a diagram with no drawing layer behind it must
241
+ // never look like a diagram that simply has nothing to draw.
242
+ if (!opts.draw) {
243
+ throw new Error('wave-ui: opts.draw (wave-draw.js\'s createDrawer) is required');
244
+ }
245
+ // v3.6.0 Task 2: `opts.panels` (wave-panels.js's `createPanels`) is the
246
+ // toolbar/lane-rail/right-hand-panels counterpart to `opts.draw` just
247
+ // above, arriving as a parameter for the identical reason — no shim for
248
+ // a bare `require('./wave-panels.js')` in the browser injection path.
249
+ if (!opts.panels) {
250
+ throw new Error('wave-ui: opts.panels (wave-panels.js\'s createPanels) is required');
251
+ }
353
252
 
354
253
  const store = opts.createStore(opts.source);
355
254
 
255
+ /**
256
+ * v3.6.0 Task 13: the 原始碼 panel's whole reason to exist — the exact
257
+ * bytes 保留並關閉 would write right now, not a re-serialisation made
258
+ * for display. The SAME `store.toPatch()` path `afterStoreMoved` already
259
+ * runs after every gesture (`lastPatch`, below), recomputed fresh here
260
+ * rather than reusing that cached value: this can be asked before the
261
+ * very first gesture, while `lastPatch` is still `null`.
262
+ *
263
+ * A refusal is shown as a sentence, not thrown — the same "say what it
264
+ * cannot do" rule `renderUnmodelled` and the unreadable-source panel
265
+ * both already follow, rather than crashing a panel over a state the
266
+ * user can act on (undo one step, or make a smaller change).
267
+ */
268
+ function sourceText() {
269
+ const patch = store.toPatch();
270
+ return patch.ok === true ? patch.text
271
+ : '(這個改動目前沒辦法只改幾個位元組寫回去:' + patch.reason + ')';
272
+ }
273
+ // Test-visible, same convention `client.js` already uses for its own
274
+ // `window.__edTest*` probes: a plain global a Puppeteer test can call
275
+ // directly, assigned through `d.defaultView` rather than a bare
276
+ // `window` reference — this file names no global of its own (see its
277
+ // header) and is reachable from node with a fake `opts.document`, where
278
+ // a bare `window` would throw.
279
+ const probeWin = d.defaultView;
280
+ if (probeWin) probeWin.__edWaveSourceProbe = sourceText;
281
+
282
+ const drawer = opts.draw.createDrawer({
283
+ d: d,
284
+ geometry: geometry,
285
+ codec: codec,
286
+ SIZES: SIZES,
287
+ SVGNS: SVGNS,
288
+ BRUSHES: BRUSHES,
289
+ labelsOf: labelsOf,
290
+ });
291
+
356
292
  const overlay = d.createElement('div');
357
293
  overlay.className = 'ed-wave-overlay';
358
294
  overlay.setAttribute('role', 'dialog');
@@ -367,15 +303,32 @@
367
303
  // closing.
368
304
  let brush = '1';
369
305
  let clip = null;
370
- let selection = null; // {laneIndex, from, to} — the cycles a gesture acts on
306
+ // v3.6.0 Task 14: `{laneIndex, laneTo, from, to}` — THE selection, and
307
+ // the only one this dialog has. `laneIndex`..`laneTo` (inclusive,
308
+ // normalised so `laneTo >= laneIndex`) is the lane range the 訊號/群組
309
+ // sections act on; `from`..`to` is the cycle range on `laneIndex` that
310
+ // the 週期 section and the brushes act on. An ordinary selection has
311
+ // `laneTo === laneIndex`; only Shift+click on the rail widens it.
312
+ //
313
+ // Every producer writes all four keys. That is deliberate rather than
314
+ // "absent means single lane": this repo has already paid for one value
315
+ // whose two ends disagreed about what `undefined` meant (wavedrom's own
316
+ // `captext` vs `ticktock`), and a reader here does
317
+ // `i >= laneIndex && i <= laneTo` with no second rule to remember.
318
+ let selection = null;
371
319
  let drag = null; // live pointer drag, never pushed until mouseup
372
320
  let gestures = 0;
373
321
  let lastPatch = null;
374
322
  // Where the keyboard should end up after the NEXT repaint, when that is not
375
- // simply "wherever it was". A move button is the case that needs it: the
376
- // control the user pressed belongs to a row, and after the move the lane
377
- // they are pushing around is at a different row — so pressing ▲ twice has
378
- // to move the same lane twice, not two different ones.
323
+ // simply "wherever it was". A gesture that MOVES a lane is the case that
324
+ // needs it: whatever the keyboard was on belongs to a row, and after the
325
+ // move the lane the user is pushing around sits at a different row — so
326
+ // Alt+↓ twice has to move the same lane twice, not two different ones.
327
+ // (v3.6.0 Task 16 re-pointed this sentence: it used to name the rail's
328
+ // per-row ▲, which Task 14 removed; `moveSelectedLane` now carries the
329
+ // same fact and nominates the moved lane's own name field. The narrative
330
+ // further down about what ▲/▼ USED to do is a record of a past version
331
+ // and is deliberately left as it was.)
379
332
  let focusOverride = null;
380
333
  // Where the KEYBOARD is on the drawing: one cell, and the keyboard's whole
381
334
  // equivalent of the pointer. Null until the keyboard has actually entered
@@ -396,6 +349,12 @@
396
349
  // (deleted, or its entry no longer parses), so a stale index can never
397
350
  // reach `renderEdgeBar` or a shape/label commit.
398
351
  let selectedEdge = null;
352
+ // v3.6.0 Task 13 fix round 3: the PREVIOUS render's `selectedEdge`, so
353
+ // `render()` below can tell "just became selected" (null -> a number)
354
+ // apart from "still selected, repainting for some other reason" (a
355
+ // number -> the same or a different number) — see the comment at that
356
+ // check for why the distinction matters.
357
+ let lastSelectedEdge = null;
399
358
  // v3.5.0 Task 7: the edge-CREATION gesture's own state, shared verbatim
400
359
  // by the pointer and the keyboard — one state machine, two input sources.
401
360
  // `edgeMode` is explicit rather than inferred from "is a key held" or "is
@@ -418,6 +377,15 @@
418
377
  // Enter still means "finish from where I already stood".
419
378
  let edgeMode = 'idle';
420
379
  let pendingFrom = null;
380
+ // v3.6.0 Task 11: which transition anchor the pointer is nearest while
381
+ // armed — `{laneIndex, cell}`, `geometry.boundaryAt`'s own field names,
382
+ // never converted to `cellFromEvent`'s `cycle` (see that function's own
383
+ // comment). Cleared whenever `edgeMode` leaves 'armed' for any reason
384
+ // (idle or dragging) so a stale hot dot never survives past the state
385
+ // that produced it — set alongside `edgeMode` in `setEdgeMode` itself,
386
+ // the one place that already keeps every other edge-gesture mark
387
+ // (`pendingFrom`) in sync with it.
388
+ let hoverBoundary = null;
421
389
  /** The one place `edgeMode` changes. Keeps the arm button's own visual
422
390
  * state, the overlay's `data-wave-edgemode` (what a test or a future
423
391
  * task reads instead of reaching into this closure) and the "is a start
@@ -428,6 +396,13 @@
428
396
  edgeBtn.classList.toggle('is-on', next !== 'idle');
429
397
  overlay.setAttribute('data-wave-edgemode', next);
430
398
  if (next === 'idle') pendingFrom = null;
399
+ // v3.6.0 Task 11: the hover dot only ever means anything while
400
+ // 'armed' (dragging repaints its own hot dot via `onCanvasMove`'s
401
+ // `at`, computed fresh every move) — leaving 'armed' for either
402
+ // neighbour state without clearing this would let a stale hot dot
403
+ // from the last hover survive into a repaint nothing is tracking it
404
+ // for any more.
405
+ if (next !== 'armed') hoverBoundary = null;
431
406
  }
432
407
  // v3.5.0 Task 8: dragging either END of the SELECTED edge to another
433
408
  // cell. `{index, end, at, cell} | null` — `index` is the same original
@@ -460,28 +435,6 @@
460
435
  // mid-drag undo) has to die with it rather than commit a move onto
461
436
  // whatever entry now happens to sit at that array position.
462
437
  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;
485
438
  // The document the marks' lane numbers are numbers INTO.
486
439
  //
487
440
  // `cursor` and `selection` name a ROW, and a row number means a different
@@ -739,6 +692,114 @@
739
692
  next.focus();
740
693
  return;
741
694
  }
695
+ /**
696
+ * v3.6.0 Task 16: the dialog's four Alt+arrow chords.
697
+ *
698
+ * Alt+↑ / Alt+↓ move the SELECTED LANE up or down the list.
699
+ * Alt+← / Alt+→ move the CELL CURSOR to the next transition, while
700
+ * the 關聯線 gesture is armed.
701
+ *
702
+ * Alt is this dialog's "the arrow key, but coarser" modifier, and the
703
+ * two halves are honestly not the same KIND of operation — vertically
704
+ * it acts on the document (a lane changes place, `commit` runs),
705
+ * horizontally only on the cursor (nothing is written). What they share
706
+ * is that each is the larger-grained reading of the arrow beneath it,
707
+ * and that neither is reachable any other way from the keyboard.
708
+ *
709
+ * Alt+←/→ arrived here in fix round 3, moved off plain ←/→. See the
710
+ * long comment in `handleDrawingKey` for what that binding cost (T9b:
711
+ * a jump can only land on a transition, so it left several cells with
712
+ * no keyboard route at all, and the keyboard could no longer reproduce
713
+ * the mouse's edge byte for byte). This branch is the only home
714
+ * available to it: the call to `handleDrawingKey` below is gated on
715
+ * `!ev.altKey`, so no Alt-modified key can reach that function, and
716
+ * re-opening that gate is how `Ctrl+Z` routing would get disturbed.
717
+ *
718
+ * ALL FOUR ARE CLAIMED DIALOG-WIDE, including the two that then refuse.
719
+ * `Alt+←`/`Alt+→` is Back/Forward in a desktop browser, and an
720
+ * unclaimed one inside a modal editing session is a keystroke that can
721
+ * navigate the whole session away. Claiming it — `preventDefault()` in
722
+ * the capture phase, on every target inside this overlay — is the
723
+ * direction that reduces that risk whichever way a given browser
724
+ * behaves. MEASURED, and the measurement is INCONCLUSIVE rather than
725
+ * reassuring: this repo's headless Chrome does not fire back-navigation
726
+ * on a CDP-synthesised `Alt+ArrowLeft` at all — verified on a bare page
727
+ * with no key handler of any kind, history length 3, document unchanged
728
+ * — so the harness cannot answer the question either way, and a real
729
+ * desktop browser is where it would have to be confirmed. The cost of
730
+ * claiming it is one: Option+←/→ word navigation inside a lane's name
731
+ * field, a single-line input, stops working.
732
+ *
733
+ * Task 14 took the rail's per-row ▲/▼ off — one pair per lane, none of
734
+ * which fit in the slimmed row — and left the handle's drag-and-drop
735
+ * as the only way to reorder a lane at all. A gesture that exists only
736
+ * under a pointer is the gap v3.5.0 Task 10 spent a whole task closing
737
+ * on this same surface, so it does not get to reopen here. The
738
+ * reference fork never had a second door either: its shortcut parser
739
+ * returns null the moment it sees `altKey`, so `moveLane` is
740
+ * drag-only there. Ours has two doors and both go through the SAME
741
+ * `codec.moveLane`, with display positions — the rail's own drop
742
+ * handler in `wave-panels.js` is the other one — rather than growing a
743
+ * second opinion about lane order.
744
+ *
745
+ * The LANE half is claimed here rather than inside `handleDrawingKey`
746
+ * for two reasons that are both load-bearing. `handleDrawingKey`
747
+ * accepts exactly two targets (the canvas and the dialog) and returns
748
+ * false on anything else, while the place a person reordering lanes
749
+ * actually sits is the lane's own NAME FIELD — which is where
750
+ * `moveSelectedLane`'s `focusOverride` puts them and keeps them, so
751
+ * Alt+↓ twice moves the same lane twice instead of two different ones.
752
+ * And the call to it just below is gated on `!ev.altKey`, so an
753
+ * Alt-modified key cannot reach it at all.
754
+ *
755
+ * Above the `!(ctrl||meta) || alt` gate further down, which returns on
756
+ * any Alt key: everything Alt-modified this dialog claims has to be
757
+ * claimed before that line.
758
+ *
759
+ * TWO THINGS THE CONDITION DELIBERATELY DOES NOT SAY, both v3.6.0 Task
760
+ * 16 fix round 1, both documented rather than guarded:
761
+ *
762
+ * `ev.shiftKey` is not tested, so **Alt+Shift+↑/↓ moves the lane too**.
763
+ * That is the same shape `isBrushKey` settled on (see its own comment
764
+ * at the top of this file): keying an admission rule on Shift is what
765
+ * blocked `P`/`N`/`|` from the keyboard entirely in v3.5.0 Task 10.
766
+ * There is no second meaning for Shift here — a lane move has no range
767
+ * to extend — so the extra modifier is harmless rather than ambiguous,
768
+ * and refusing it would only make the key stop working for anyone
769
+ * whose hand is still on Shift from a Shift+click on the rail.
770
+ *
771
+ * No sub-panel gate either. A press taken while the rail's rename
772
+ * field or the bus data-label field is open moves the lane and lets
773
+ * the repaint retire that field, dropping whatever was half-typed in
774
+ * it — exactly what `Ctrl+Z` already does there (see `render()`'s own
775
+ * first line, and `renderCanvas`'s). Both text sub-panels behave the
776
+ * same way and neither wedges the keyboard: `moveSelectedLane` has
777
+ * already written `focusOverride`, so `dataEdit.retire`'s own
778
+ * `focusOverride === null` claim never fires and `restoreFocus` lands
779
+ * on a real element. Standing this gesture behind the sub-panels the
780
+ * way Escape does is a design call this task did not take.
781
+ */
782
+ if (store.ok === true && ev.altKey === true && !ev.ctrlKey && !ev.metaKey &&
783
+ (ev.key === 'ArrowUp' || ev.key === 'ArrowDown' ||
784
+ ev.key === 'ArrowLeft' || ev.key === 'ArrowRight')) {
785
+ ev.preventDefault();
786
+ ev.stopPropagation();
787
+ if (ev.key === 'ArrowUp' || ev.key === 'ArrowDown') {
788
+ moveSelectedLane(ev.key === 'ArrowUp' ? -1 : 1);
789
+ return;
790
+ }
791
+ // Armed only, and it REFUSES OUT LOUD otherwise rather than becoming
792
+ // a silent no-op. `jumpTransition` writes `hoverBoundary`, which
793
+ // `setEdgeMode` documents as meaningful only while 'armed' — a jump
794
+ // taken while idle would leave a hot dot behind that nothing is
795
+ // tracking, and it would light up the moment the user did arm.
796
+ if (edgeMode !== 'armed') {
797
+ panels.say('先按「關聯線」武裝,Alt+←/→ 才有轉態點可以跳');
798
+ return;
799
+ }
800
+ jumpTransition(ev.key === 'ArrowRight' ? 1 : -1);
801
+ return;
802
+ }
742
803
  // The drawing's own keys, claimed here rather than on the canvas element
743
804
  // so they are decided in the same place — and in the same phase — as
744
805
  // Escape, Tab and undo/redo. Deliberately BELOW those three (a cell
@@ -768,14 +829,14 @@
768
829
  ev.preventDefault();
769
830
  ev.stopPropagation();
770
831
  if (store.undo()) afterStoreMoved('undo');
771
- else say('沒有可以復原的動作');
832
+ else panels.say('沒有可以復原的動作');
772
833
  return;
773
834
  }
774
835
  if (key === 'y' || (key === 'z' && ev.shiftKey)) {
775
836
  ev.preventDefault();
776
837
  ev.stopPropagation();
777
838
  if (store.redo()) afterStoreMoved('redo');
778
- else say('沒有可以重做的動作');
839
+ else panels.say('沒有可以重做的動作');
779
840
  }
780
841
  }
781
842
  d.addEventListener('keydown', onKeyDown, true);
@@ -859,13 +920,101 @@
859
920
  marksDoc = store.doc;
860
921
 
861
922
  // ── toolbar ───────────────────────────────────────────────────────────
923
+ //
924
+ // v3.6.0 Task 12: eight captioned `.ed-wave-tool-group` sections
925
+ // (週期/電位/訊號/群組/關聯線/歷史/匯出/檔案) instead of the two flat bars
926
+ // this used to be. See `wave-panels.js`'s `toolGroup()` for why each
927
+ // caption sits to the LEFT of its buttons rather than above them.
862
928
  const toolbar = d.createElement('div');
863
- toolbar.className = 'ed-wave-toolbar';
929
+ toolbar.className = 'ed-wave-toolbar ed-wave-tools';
864
930
  panel.appendChild(toolbar);
865
931
 
932
+ // v3.6.0 Task 2/12: the toolbar/right-hand-panels/lane-rail factory.
933
+ // Called here, right after `panel` exists, which is BEFORE several
934
+ // elements its OTHER returned functions need (`metaBar`'s own fields,
935
+ // the edge inspector, `laneCol`, the preview host, `status`). `deps` is
936
+ // the same object referenced below at each of those elements' own
937
+ // creation — see `deps.metaBar =` etc. — and `wave-panels.js`'s own
938
+ // header explains why that is not a reorder. `actions` is the callback
939
+ // bag for the `wave-ui.js`-owned state a moved function still needs to
940
+ // read or write; see that same header for what each entry is for and
941
+ // why `close` is not among them (nothing that moved calls it).
942
+ const deps = {
943
+ d: d,
944
+ geometry: geometry,
945
+ codec: codec,
946
+ SIZES: SIZES,
947
+ actions: {
948
+ commit: commit,
949
+ render: render,
950
+ selectionSet: function (value) { selection = value; },
951
+ focusOverrideGet: function () { return focusOverride; },
952
+ focusOverrideSet: function (key) { focusOverride = key; },
953
+ groupRenameGet: function () { return groupRename; },
954
+ groupRenameSet: function (value) { groupRename = value; },
955
+ sourceText: sourceText,
956
+ },
957
+ overlay: overlay,
958
+ panel: panel,
959
+ PREVIEW_INDEX: PREVIEW_INDEX,
960
+ PREVIEW_ID_PREFIX: PREVIEW_ID_PREFIX,
961
+ wavedrom: opts.wavedrom,
962
+ };
963
+ const panels = opts.panels.createPanels(deps);
964
+
965
+ // v3.6.0 Task 15: the shared `NOT_YET_WIRED` title that used to stand
966
+ // here went out with its last three holders — 匯出's SVG / PNG /
967
+ // 複製 WaveJSON. Every button this toolbar builds now does something,
968
+ // and nothing is created disabled to make a section's shape look real.
969
+
970
+ // ── 週期 ──────────────────────────────────────────────────────────────
971
+ const cycleGroup = panels.toolGroup('週期');
972
+ toolbar.appendChild(cycleGroup);
973
+ panels.toolButton('ed-wave-cycle-insert', '插入 cycle',
974
+ '在選取的位置插入一個 cycle(每一條 lane 同時加寬,不會有人掉隊)', function () {
975
+ const at = selection === null ? 0 : Math.min(selection.from, selection.to);
976
+ commit('insert-cycle', function (doc) { return codec.insertCycles(doc, at, 1); });
977
+ }, cycleGroup);
978
+ panels.toolButton('ed-wave-cycle-delete', '刪除 cycle',
979
+ '刪掉選取的 cycle(每一條 lane 同時變窄)', function () {
980
+ if (selection === null) { panels.say('先選一段 cycle 再刪'); return; }
981
+ const from = Math.min(selection.from, selection.to);
982
+ const count = Math.abs(selection.to - selection.from) + 1;
983
+ commit('delete-cycle', function (doc) { return codec.deleteCycles(doc, from, count); });
984
+ }, cycleGroup);
985
+ panels.toolButton('ed-wave-cycle-copy', '複製',
986
+ '把選取的 cycle 從每一條 lane 上拷一份', function () {
987
+ if (selection === null) { panels.say('先選一段 cycle 再複製'); return; }
988
+ const from = Math.min(selection.from, selection.to);
989
+ const count = Math.abs(selection.to - selection.from) + 1;
990
+ clip = codec.copyCycles(store.doc, from, count);
991
+ panels.say('複製了 ' + count + ' 個 cycle');
992
+ render();
993
+ }, cycleGroup);
994
+ panels.toolButton('ed-wave-cycle-paste', '貼上(插入)',
995
+ '把剪貼簿插進選取的位置', function () { pasteAt('insert'); }, cycleGroup);
996
+ panels.toolButton('ed-wave-cycle-paste-over', '貼上(覆蓋)',
997
+ '用剪貼簿蓋掉選取位置起算的那幾個 cycle', function () { pasteAt('overwrite'); }, cycleGroup);
998
+
999
+ function pasteAt(mode) {
1000
+ if (clip === null) { panels.say('剪貼簿是空的'); return; }
1001
+ const at = selection === null ? 0 : Math.min(selection.from, selection.to);
1002
+ commit('paste-' + mode, function (doc) {
1003
+ return codec.pasteCycles(doc, at, clip, mode);
1004
+ });
1005
+ }
1006
+
1007
+ // ── 電位 ──────────────────────────────────────────────────────────────
1008
+ //
1009
+ // v3.6.0 Task 12: this section WRAPS the existing brush bar — the 22
1010
+ // brush buttons are still built inline by this same loop, with
1011
+ // `drawer.levelIcon` glyphs (Task 10) — `panels.toolButton` is not used
1012
+ // for them, so only the container they land in is new here.
1013
+ const brushGroup = panels.toolGroup('電位');
1014
+ toolbar.appendChild(brushGroup);
866
1015
  const brushBar = d.createElement('div');
867
1016
  brushBar.className = 'ed-wave-brushes';
868
- toolbar.appendChild(brushBar);
1017
+ brushGroup.appendChild(brushBar);
869
1018
  const brushButtons = [];
870
1019
  for (const ch of BRUSHES) {
871
1020
  const b = d.createElement('button');
@@ -873,7 +1022,21 @@
873
1022
  b.className = 'ed-wave-brush';
874
1023
  b.setAttribute('data-brush', ch);
875
1024
  b.setAttribute('data-focus-key', 'brush-' + ch);
876
- b.textContent = ch;
1025
+ // v3.6.0 Task 10: the glyph is built by `drawer.levelIcon` — the SAME
1026
+ // brick-drawing code `renderCanvas` paints the diagram with, not a
1027
+ // second hand-drawn set (see wave-draw.js's own `levelIcon` comment).
1028
+ // `panels.toolButton` is NOT used for these buttons: brush buttons are
1029
+ // built here, inline, not through that helper, so wiring the icon
1030
+ // where `toolButton` lives would put glyphs on the cycle-operation
1031
+ // buttons instead and leave every brush bare. The character stays on
1032
+ // the button too, as a caption next to the icon rather than instead
1033
+ // of it — a user who already knows the wavedrom letter should not
1034
+ // have to relearn a picture-only toolbar.
1035
+ b.appendChild(drawer.levelIcon(ch));
1036
+ const cap = d.createElement('span');
1037
+ cap.className = 'ed-wave-brush-char';
1038
+ cap.textContent = ch;
1039
+ b.appendChild(cap);
877
1040
  b.addEventListener('click', function () {
878
1041
  brush = ch;
879
1042
  paintBrushState();
@@ -881,6 +1044,76 @@
881
1044
  brushBar.appendChild(b);
882
1045
  brushButtons.push(b);
883
1046
  }
1047
+ /**
1048
+ * The toolbar sections that act on `selection`, repainted from it.
1049
+ *
1050
+ * v3.6.0 Task 14. Called from `render()`, from `repaintDrawing()`, and
1051
+ * from every INLINE `drawer.renderCanvas` site in this file — which is
1052
+ * not a set of duplicate calls. `render()` is the slow path; every other
1053
+ * one of them both writes `selection` and reads it back out of the
1054
+ * drawer's own clamp, and none of them rebuilds the toolbar. Wired only
1055
+ * into `render()`, these buttons kept whatever state the LAST full
1056
+ * repaint left them in.
1057
+ *
1058
+ * Two rescues used to hide most of that, and fix round 1 is the gesture
1059
+ * where both are shut at once:
1060
+ *
1061
+ * - `onCanvasUp`'s `commit('paint', …)` re-renders — but `commit`
1062
+ * returns false without calling `afterStoreMoved` when the gesture
1063
+ * changed nothing, and pressing a cell that already holds the
1064
+ * brush's own value changes nothing.
1065
+ * - `onCanvasDown`'s `canvas.focus()` fires the drawer-wired
1066
+ * `enterDrawing` → `repaintDrawing` — but only when the canvas did
1067
+ * not already have the keyboard, i.e. never on the second press in
1068
+ * a row.
1069
+ *
1070
+ * MEASURED, both symptoms: (a) open the dialog (no selection, 刪除
1071
+ * disabled), walk to a lane with the arrow keys, and 刪除 was still
1072
+ * disabled although the lane was selected and highlighted on both the
1073
+ * rail and the canvas; (b) press req's cycle 2, then ack's cycle 4 —
1074
+ * `data-insert-at`/`data-lands-in` stayed on 2/`bus` while the button,
1075
+ * which reads the LIVE `selection`, would have inserted at 4 outside
1076
+ * every group. (b) is the worse of the two: it is this task's own
1077
+ * contract — the button says which container it is about to use BEFORE
1078
+ * it is pressed — broken by the button itself.
1079
+ *
1080
+ * So it is called at every writer of the state it paints, not at the
1081
+ * one whose symptom was reported. The rail's own `.is-selected` is a
1082
+ * separate, pre-existing staleness on those same inline paths (they do
1083
+ * not call `panels.renderLanes` either) and is deliberately NOT fixed
1084
+ * here.
1085
+ *
1086
+ * 新增 and 空白列 stay enabled with nothing selected — that case has a
1087
+ * real answer of its own (position 0; see `signalInsertAt`) — and they
1088
+ * publish WHERE the insert lands before they are pressed, which is the
1089
+ * property the rail's per-row + used to carry in `data-lands-in` and
1090
+ * its tooltip. The other three need a lane named, and 解散群組 needs
1091
+ * that lane to be inside a group; a button that looks pressable and
1092
+ * then answers「先選一條 lane」is the affordance this batch is
1093
+ * removing, not reproducing.
1094
+ */
1095
+ function paintSelectionState(doc, layout) {
1096
+ const insertAt = signalInsertAt(layout);
1097
+ const insertLanding = panels.landingOf(doc, insertAt);
1098
+ const insertWhere = insertLanding.title === null
1099
+ ? '群組外' : '群組「' + insertLanding.title + '」裡';
1100
+ for (const b of [signalAddBtn, signalBlankBtn]) {
1101
+ b.setAttribute('data-insert-at', String(insertAt));
1102
+ b.setAttribute('data-lands-in',
1103
+ insertLanding.title === null ? '' : insertLanding.title);
1104
+ }
1105
+ signalAddBtn.title = '在選取的 lane 後面加一條(沒有選取就加在最前面)—— 會落在' + insertWhere;
1106
+ signalBlankBtn.title = '在選取的 lane 後面插入一個空白列(WaveJSON 的 {},' +
1107
+ '用來空出間距;刪除跟刪一般的 lane 一樣)—— 會落在' + insertWhere;
1108
+ signalDeleteBtn.disabled = selection === null;
1109
+ signalCopyBtn.disabled = selection === null;
1110
+ groupBtn.disabled = selection === null || selection.laneTo === selection.laneIndex;
1111
+ groupDissolveBtn.disabled = selection === null ||
1112
+ !laneIsInGroup(layout, selection.laneIndex);
1113
+ overlay.setAttribute('data-wave-lane-range', selection === null ? ''
1114
+ : selection.laneIndex + ',' + selection.laneTo);
1115
+ }
1116
+
884
1117
  function paintBrushState() {
885
1118
  for (const b of brushButtons) {
886
1119
  b.classList.toggle('is-on', b.getAttribute('data-brush') === brush);
@@ -888,162 +1121,608 @@
888
1121
  overlay.setAttribute('data-wave-brush', brush);
889
1122
  }
890
1123
 
891
- const cycleBar = d.createElement('div');
892
- cycleBar.className = 'ed-wave-cycleops';
893
- toolbar.appendChild(cycleBar);
1124
+ // ── 訊號 ──────────────────────────────────────────────────────────────
1125
+ //
1126
+ // v3.6.0 Task 14: these four came off the lane rail, where every row
1127
+ // carried its own copy and none of them fit (see `wave-panels.js`'s
1128
+ // `laneRow`). One copy each, here, acting on `selection` — the same mark
1129
+ // the 週期 section and the brushes read, not a second one kept only for
1130
+ // them.
1131
+ //
1132
+ // WHERE an insert lands is `laneInsertPath`'s asymmetry and cannot be
1133
+ // argued away: an insert at a group's first lane joins that group, an
1134
+ // insert just past its last lane does not. So the button says which
1135
+ // container it is about to use BEFORE it is pressed — `data-lands-in`
1136
+ // and the tooltip, both refreshed by `paintSelectionState` below on
1137
+ // every repaint, full or fast — and the status line says it again
1138
+ // after. That is the property the rail's per-row + used to carry,
1139
+ // moved rather than dropped.
1140
+ const signalGroup = panels.toolGroup('訊號');
1141
+ toolbar.appendChild(signalGroup);
894
1142
 
895
- function toolButton(cls, label, hint, fn) {
896
- const b = d.createElement('button');
897
- b.type = 'button';
898
- b.className = cls;
899
- b.setAttribute('data-focus-key', cls);
900
- b.textContent = label;
901
- b.title = hint;
902
- b.addEventListener('click', fn);
903
- cycleBar.appendChild(b);
904
- return b;
1143
+ /**
1144
+ * Where 新增 / 空白列 put their new lane: right after the selection, and
1145
+ * at the very top when there is nothing selected.
1146
+ *
1147
+ * "After the selection" is `laneTo + 1`, not `laneIndex + 1`: with a
1148
+ * whole range picked, adding below the range is the only reading that
1149
+ * does not silently cut it in half.
1150
+ *
1151
+ * The no-selection answer is 0 rather than the end, and that is the one
1152
+ * choice here that is not obvious. The rail used to carry a + at every
1153
+ * position — one per row plus a tail — so every insert position from 0
1154
+ * to n was reachable in one press. One button that only ever inserts
1155
+ * AFTER something cannot offer position 0 at all: there is no lane to
1156
+ * be after. Pairing "after the selection" with "0 when there is no
1157
+ * selection" restores the full range — 0 with nothing picked, k+1 with
1158
+ * lane k picked, the end with the last lane picked — and it costs one
1159
+ * surprising default instead of one unreachable position. The button
1160
+ * says which it is about to do before it is pressed (`data-lands-in`
1161
+ * and the tooltip), so the surprise is visible rather than discovered.
1162
+ */
1163
+ function signalInsertAt(layout) {
1164
+ const l = layout === undefined || layout === null
1165
+ ? geometry.layoutOf(store.doc, SIZES) : layout;
1166
+ if (selection === null) return 0;
1167
+ return Math.min(selection.laneTo + 1, l.lanes.length);
905
1168
  }
906
1169
 
907
- toolButton('ed-wave-cycle-insert', '插入 cycle',
908
- '在選取的位置插入一個 cycle(每一條 lane 同時加寬,不會有人掉隊)', function () {
909
- const at = selection === null ? 0 : Math.min(selection.from, selection.to);
910
- commit('insert-cycle', function (doc) { return codec.insertCycles(doc, at, 1); });
911
- });
912
- toolButton('ed-wave-cycle-delete', '刪除 cycle',
913
- '刪掉選取的 cycle(每一條 lane 同時變窄)', function () {
914
- if (selection === null) { say('先選一段 cycle 再刪'); return; }
915
- const from = Math.min(selection.from, selection.to);
916
- const count = Math.abs(selection.to - selection.from) + 1;
917
- commit('delete-cycle', function (doc) { return codec.deleteCycles(doc, from, count); });
918
- });
919
- toolButton('ed-wave-cycle-copy', '複製',
920
- '把選取的 cycle 從每一條 lane 上拷一份', function () {
921
- if (selection === null) { say('先選一段 cycle 再複製'); return; }
922
- const from = Math.min(selection.from, selection.to);
923
- const count = Math.abs(selection.to - selection.from) + 1;
924
- clip = codec.copyCycles(store.doc, from, count);
925
- say('複製了 ' + count + ' 個 cycle');
926
- render();
927
- });
928
- toolButton('ed-wave-cycle-paste', '貼上(插入)',
929
- '把剪貼簿插進選取的位置', function () { pasteAt('insert'); });
930
- toolButton('ed-wave-cycle-paste-over', '貼上(覆蓋)',
931
- '用剪貼簿蓋掉選取位置起算的那幾個 cycle', function () { pasteAt('overwrite'); });
1170
+ function addLaneFromToolbar(name, lane) {
1171
+ const at = signalInsertAt();
1172
+ const landing = panels.landingOf(store.doc, at);
1173
+ const where = landing.title === null ? '群組外' : '群組「' + landing.title + '」裡';
1174
+ // Straight into the new lane's own name field — it is what the user
1175
+ // types next, and on a spacer (no name to type) there is nothing
1176
+ // else on that row to land on either. The lane arrives at display
1177
+ // position `at`, so that is the key.
1178
+ focusOverride = 'lane-name-' + at;
1179
+ const added = commit(name, function (doc2) { return codec.addLane(doc2, at, lane); });
1180
+ // fix round 1 (F6): the whole prefix per branch, not one concatenation
1181
+ // with a shared 落在. The two bases this replaced differ by exactly one
1182
+ // space — `say('新增的 lane 落在' + …)` has it because the phrase ends
1183
+ // in a Latin word (so does 複製's message further down), and
1184
+ // `say('空白列落在' + …)` does not because it ends in a CJK one. A
1185
+ // single shared suffix has to get one of them wrong.
1186
+ const lead = lane.wave === undefined ? '空白列落在' : '新增的 lane 落在';
1187
+ if (added) panels.say(lead + where);
1188
+ }
1189
+
1190
+ const signalAddBtn = panels.toolButton('ed-wave-signal-add', '新增',
1191
+ '在選取的 lane 後面加一條(沒有選取就加在最前面)', function () {
1192
+ addLaneFromToolbar('add-lane', { name: '', wave: 'x' });
1193
+ }, signalGroup);
1194
+ // v3.5.0 Task 13: WaveDrom's own blank row, `{}`. There is deliberately
1195
+ // no dedicated "remove spacer" control: a spacer is a lane like any
1196
+ // other, so 刪除 below removes it exactly as it removes anything else.
1197
+ const signalBlankBtn = panels.toolButton('ed-wave-signal-blank', '空白列',
1198
+ '在選取的 lane 後面插入一個空白列(WaveJSON 的 {},用來空出間距;刪除跟刪一般的 lane 一樣)',
1199
+ function () { addLaneFromToolbar('add-spacer', {}); }, signalGroup);
1200
+ const signalDeleteBtn = panels.toolButton('ed-wave-signal-delete', '刪除',
1201
+ '刪掉選取的那幾條 lane(如果群組因此空掉,群組也會一起走)', function () {
1202
+ if (selection === null) { panels.say('先選一條 lane 再刪'); return; }
1203
+ const lo = selection.laneIndex;
1204
+ const hi = selection.laneTo;
1205
+ const n = hi - lo + 1;
1206
+ // The rows about to stop existing are exactly the ones the marks
1207
+ // name, so the selection goes FIRST — a selection naming cycles on
1208
+ // a lane that is about to disappear is stale the instant `commit`
1209
+ // runs, and that staleness is what `carryMarks` exists to prevent
1210
+ // everywhere else.
1211
+ selection = null;
1212
+ // The rows about to vanish are also where the keyboard is allowed to
1213
+ // land, and the button the user pressed is about to disable itself
1214
+ // (no selection left), so naming a destination here is not a nicety:
1215
+ // `restoreFocus`'s fallback would otherwise be a disabled button.
1216
+ // Display position `lo` is whatever lane inherits the place the
1217
+ // deleted range occupied — on a document emptied down to nothing the
1218
+ // key simply is not there and `restoreFocus` falls back to the
1219
+ // dialog, which is the right answer for「there is no row left」.
1220
+ focusOverride = 'lane-name-' + lo;
1221
+ // Bottom-up: every removal renumbers the rows BELOW it, so taking
1222
+ // the highest index first leaves the ones still to go untouched.
1223
+ //
1224
+ // fix round 1: an earlier draft of this comment claimed a top-down
1225
+ // loop would delete a DIFFERENT set of lanes. It would not, and the
1226
+ // claim is withdrawn rather than reworded — checked against the
1227
+ // codec over five ranges, group-spanning ones included: deleting
1228
+ // `lo` n times and deleting hi..lo give the identical document,
1229
+ // because whatever shifts into `lo` is the next member of a
1230
+ // CONTIGUOUS range. What bottom-up actually buys is that each index
1231
+ // still means the lane it meant when the range was measured, so the
1232
+ // loop stays correct if a range that is not contiguous ever reaches
1233
+ // it; top-down would be silently relying on the contiguity.
1234
+ const removed = commit('remove-lane', function (doc2) {
1235
+ let out = doc2;
1236
+ for (let k = hi; k >= lo; k -= 1) out = codec.removeLane(out, k);
1237
+ return out;
1238
+ });
1239
+ if (removed) panels.say('已刪掉 ' + n + ' 條 lane —— Ctrl+Z 可以拿回來');
1240
+ }, signalGroup);
1241
+ const signalCopyBtn = panels.toolButton('ed-wave-signal-copy', '複製',
1242
+ '複製選取的 lane,接在它後面(data 等欄位一起複製;node 錨點不會跟著複製,' +
1243
+ '避免兩條 lane 搶同一個字母)', function () {
1244
+ if (selection === null) { panels.say('先選一條 lane 再複製'); return; }
1245
+ // One lane, the range's first: `duplicateLane` copies one lane and
1246
+ // lands it at `at + 1`, and a loop over a range would interleave
1247
+ // each copy with the original it renumbers. The range's own far end
1248
+ // is left alone rather than guessed at.
1249
+ const at = selection.laneIndex;
1250
+ const landing = panels.landingOf(store.doc, at + 1);
1251
+ const where = landing.title === null ? '群組外' : '群組「' + landing.title + '」裡';
1252
+ focusOverride = 'lane-name-' + (at + 1);
1253
+ const didDup = commit('duplicate-lane', function (doc2) {
1254
+ return codec.duplicateLane(doc2, at);
1255
+ });
1256
+ if (didDup) panels.say('複製的 lane 落在' + where);
1257
+ }, signalGroup);
932
1258
 
933
- function pasteAt(mode) {
934
- if (clip === null) { say('剪貼簿是空的'); return; }
935
- const at = selection === null ? 0 : Math.min(selection.from, selection.to);
936
- commit('paste-' + mode, function (doc) {
937
- return codec.pasteCycles(doc, at, clip, mode);
1259
+ /**
1260
+ * v3.6.0 Task 16: move the selected lane one row up (`step === -1`) or
1261
+ * down (`step === 1`) — the keyboard's half of the rail handle's drag,
1262
+ * reached by Alt+↑ / Alt+↓ in `onKeyDown`.
1263
+ *
1264
+ * ONE lane, the range's first, exactly as 複製 just above: `moveLane`
1265
+ * moves one lane, and a loop over a range would renumber each member out
1266
+ * from under the next. The range's far end is collapsed rather than
1267
+ * guessed at, so what moved and what is selected afterwards are the same
1268
+ * one lane.
1269
+ *
1270
+ * Both ends of the list REFUSE OUT LOUD instead of silently doing
1271
+ * nothing. A key that does nothing and says nothing is, to the person
1272
+ * pressing it, indistinguishable from a key this dialog never bound —
1273
+ * and the status line is where every other refusal in here already goes.
1274
+ *
1275
+ * WHERE THE MARKS END UP. They are collapsed onto the moved lane BEFORE
1276
+ * the commit, at its OLD row, and `carryMarks` — this file's single
1277
+ * mechanism for "a mark follows its lane", by lane identity through
1278
+ * `wave-store.laneTrace` — is what carries them to the new row.
1279
+ * Re-pointing them by hand afterwards would cost a second `render()`
1280
+ * (the one inside `commit` has already run and already consumed
1281
+ * `focusOverride`) and would stand a second belief about where the lane
1282
+ * went beside the trace's, which is the shape of defect this batch has
1283
+ * paid for repeatedly. MEASURED in a real browser on the journey
1284
+ * fixture, both from the trace alone: Alt+↓ on the lane selected at row
1285
+ * 0 with no cell cursor yet took `data-wave-lane-range` 0,0 → 1,1 and
1286
+ * left `data-wave-cursor` empty (no cursor is invented for a gesture
1287
+ * that never entered the drawing); Alt+↑ with the cell cursor sitting on
1288
+ * the moved lane at row 2 took `data-wave-cursor` 2,0 → 1,0 and
1289
+ * `data-wave-lane-range` 2,2 → 1,1.
1290
+ *
1291
+ * The invariant 訊號's 刪除 quietly depends on — a non-null `selection`
1292
+ * always names a lane that exists, so its `commit` always returns true —
1293
+ * survives every exit here. The two refusals leave `selection` exactly
1294
+ * as they found it. The commit path writes `{laneIndex: at, laneTo: at}`
1295
+ * with `at` an index `renderCanvas`'s clamp has already vouched for, and
1296
+ * a move changes no lane's EXISTENCE, so whether `carryMarks` then
1297
+ * traces it to `to` (both ends of a collapsed range trace together) or
1298
+ * drops it to null for want of an injected `laneTrace`, what is left is
1299
+ * either a real row or nothing at all — never a row number with no lane
1300
+ * under it.
1301
+ */
1302
+ function moveSelectedLane(step) {
1303
+ if (selection === null) { panels.say('先選一條 lane 再搬'); return; }
1304
+ const layout = geometry.layoutOf(store.doc, SIZES);
1305
+ const at = selection.laneIndex;
1306
+ const to = at + step;
1307
+ if (to < 0) { panels.say('這已經是第一條 lane 了,沒有更上面的位置'); return; }
1308
+ if (to >= layout.lanes.length) {
1309
+ panels.say('這已經是最後一條 lane 了,沒有更下面的位置');
1310
+ return;
1311
+ }
1312
+ selection = { laneIndex: at, laneTo: at, from: selection.from, to: selection.to };
1313
+ // The lane arrives at row `to`, so the keyboard goes to the field it
1314
+ // arrives in — the same rule the rail's own `drop` handler follows,
1315
+ // and the whole reason `focusOverride` exists (see its declaration).
1316
+ focusOverride = 'lane-name-' + to;
1317
+ const didMove = commit('move-lane', function (doc2) {
1318
+ return codec.moveLane(doc2, at, to);
938
1319
  });
939
- }
940
-
941
- const undoBtn = toolButton('ed-wave-undo', '復原', '退回上一個動作', function () {
942
- if (!store.undo()) { say('沒有可以復原的動作'); return; }
943
- afterStoreMoved('undo');
944
- });
945
- const redoBtn = toolButton('ed-wave-redo', '重做', '再做一次剛剛復原掉的動作', function () {
946
- if (!store.redo()) { say('沒有可以重做的動作'); return; }
947
- afterStoreMoved('redo');
948
- });
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.
1320
+ // v3.6.0 Task 16 fix round 1: SAY WHICH CONTAINER IT LANDED IN.
1321
+ //
1322
+ // `moveLane` may cross a group boundary — `laneInsertPath`'s asymmetry,
1323
+ // which the whole 訊號 section already announces before and after every
1324
+ // insert. MEASURED against the real codec on the journey fixture:
1325
+ // Alt+↓ on `clk` at row 0 moves it INTO the group `bus`
1326
+ // (`["clk",["bus",…]]` → `[["bus",["req","clk","dat"]],…]`), and the
1327
+ // flattened name order is the only thing that changes on screen. Before
1328
+ // this, the single word the user got back was `afterStoreMoved`'s
1329
+ // generic「已寫回:move-lane」, so the one case in this function that
1330
+ // re-parents a lane was also its quietest — while BOTH refusals spoke.
1331
+ //
1332
+ // Asked of the document AFTER the move, unlike 複製 above, which asks
1333
+ // before because its lane does not exist yet. `laneInsertPath(doc, to)`
1334
+ // means "in front of whichever lane sits at `to`, in THAT lane's own
1335
+ // container", and after the move the lane at `to` is the moved one — so
1336
+ // its answer is the moved lane's own container, including the
1337
+ // group's-first-lane edge. Verified against the codec for all four
1338
+ // moves the journey scenario makes: bus / 群組外 / bus / bus.
1339
+ //
1340
+ // ONE shared lead — `'搬動的 lane 落在'` — with only the container name
1341
+ // branching. That is what the line below does, and the whole of what it
1342
+ // needs to do.
1343
+ //
1344
+ // fix round 2 (N2) WITHDRAWS the claim that stood here ("the whole
1345
+ // prefix per branch rather than one shared 落在, the same rule
1346
+ // 新增/空白列 above record"), which said the opposite of the code
1347
+ // directly beneath it. That rule exists because 新增 and 空白列 are TWO
1348
+ // gestures sharing ONE handler whose leads differ by a space —
1349
+ // `'新增的 lane 落在'` ends in a Latin word, `'空白列落在'` in a CJK one
1350
+ // — and this function has no such axis: one gesture, one lead. The
1351
+ // space after `lane` here is that typographic rule on its own, and the
1352
+ // 群組外 / 群組「…」裡 tail is the identical two-way split 複製 uses.
1353
+ if (didMove) {
1354
+ const landing = panels.landingOf(store.doc, to);
1355
+ panels.say('搬動的 lane 落在' +
1356
+ (landing.title === null ? '群組外' : '群組「' + landing.title + '」裡'));
1357
+ }
1358
+ }
1359
+
1360
+ // ── 群組 ──────────────────────────────────────────────────────────────
1361
+ const groupGroup = panels.toolGroup('群組');
1362
+ toolbar.appendChild(groupGroup);
1363
+ // Wrap the selected lane range into one new group. The codec is the one
1364
+ // that decides contiguity — this button does not pre-filter what is
1365
+ // selectable, it only decides whether there is enough of a selection to
1366
+ // bother trying, so the refusal message the user actually needed
1367
+ // ("these lanes are not next to each other") comes from `commit`'s own
1368
+ // status line rather than being silently swallowed here.
1369
+ //
1370
+ // v3.6.0 Task 14: the range is `selection`'s, not a second mark's — so
1371
+ //「選起來的那幾條」names the same highlight the canvas and the Lane
1372
+ // panel are showing, rather than a second one only this button could
1373
+ // see. Shift+click on the rail is the one gesture that WIDENS it;
1374
+ // Shift+↑/↓ on the canvas still collapses onto the lane it lands on
1375
+ // (see `moveCursor`), which is why the tooltip names the rail and not
1376
+ // the keyboard.
958
1377
  //
959
1378
  // 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,才有東西可以群組');
1379
+ // opened immediately — the same affordance 新增 gives a brand new lane
1380
+ // (empty `name`, keyboard straight into the field) — rather than a
1381
+ // placeholder string the user has to notice and clear.
1382
+ const groupBtn = panels.toolButton('ed-wave-group-make', '建立群組',
1383
+ '把選起來的那幾條連續 lane 包成一個新群組(在左邊的 lane 列表上 Shift+點選一段)',
1384
+ function () {
1385
+ if (selection === null || selection.laneTo === selection.laneIndex) {
1386
+ panels.say('先選至少兩條連續的 lane,才有東西可以群組');
967
1387
  return;
968
1388
  }
969
- const ats = laneMultiSelect.slice().sort(function (a, b) { return a - b; });
1389
+ const ats = [];
1390
+ for (let k = selection.laneIndex; k <= selection.laneTo; k += 1) ats.push(k);
970
1391
  const minAt = ats[0];
971
1392
  const committed = commit('group-lanes', function (doc2) {
972
1393
  return codec.groupLanes(doc2, ats, '');
973
1394
  });
974
1395
  if (!committed) {
975
- say('這幾條 lane 不連續(或選取已經失效),WaveJSON 的群組只能是連續的一段');
1396
+ panels.say('這幾條 lane 不連續(或選取已經失效),WaveJSON 的群組只能是連續的一段');
976
1397
  return;
977
1398
  }
978
- laneMultiSelect = null;
979
1399
  // The commit above already repainted with the (now stale) highlight
980
1400
  // showing; one more repaint clears it before handing the keyboard to
981
1401
  // the rename field, the same two-step `renameGroup` itself already
982
1402
  // is — a rebuild first, then the sub-panel laid over what it built.
1403
+ selection = null;
983
1404
  render();
984
1405
  const layout = geometry.layoutOf(store.doc, SIZES);
985
- const spans = groupSpansOf(store.doc, layout);
1406
+ const spans = geometry.groupSpansOf(store.doc, layout);
986
1407
  for (const span of spans) {
987
- if (span.from === minAt) { renameGroup(span); break; }
1408
+ if (span.from === minAt) { panels.renameGroup(span); break; }
988
1409
  }
989
- });
1410
+ }, groupGroup);
1411
+ // v3.6.0 Task 14: 解散 the group the selected lane is a DIRECT member
1412
+ // of. The lanes it held stay exactly where they were; only the wrapping
1413
+ // array and its title go. `ungroupLanes`'s own docstring explains why
1414
+ // "direct" is the only thing a display position can name — and why a
1415
+ // group built entirely out of sub-groups therefore has no lane that
1416
+ // names it, which is exactly the case `laneIsInGroup` below reports as
1417
+ // "nothing to dissolve here" by disabling this button.
1418
+ const groupDissolveBtn = panels.toolButton('ed-wave-group-dissolve', '解散群組',
1419
+ '解散選取的 lane 所屬的那個群組(裡面的 lane 都留著,只是不再是一組)', function () {
1420
+ if (selection === null) { panels.say('先選一條群組裡的 lane'); return; }
1421
+ const at = selection.laneIndex;
1422
+ // The lane at `at` survives ungrouping at the same display position
1423
+ // (`ungroupLanes`: the flattened order and count never change), so
1424
+ // its own name field is exactly where the keyboard should land.
1425
+ focusOverride = 'lane-name-' + at;
1426
+ const done = commit('ungroup', function (doc2) { return codec.ungroupLanes(doc2, at); });
1427
+ if (!done) panels.say('這條 lane 不在任何群組裡');
1428
+ }, groupGroup);
990
1429
 
1430
+ /**
1431
+ * Whether display position `at` is a direct member of some group —
1432
+ * `ungroupLanes`'s own precondition, asked of the layout rather than
1433
+ * re-derived. A lane sitting straight under `signal` has a path of
1434
+ * length 2 (`['signal', k]`); anything longer is inside at least one
1435
+ * group.
1436
+ */
1437
+ function laneIsInGroup(layout, at) {
1438
+ if (at < 0 || at >= layout.lanes.length) return false;
1439
+ return layout.lanes[at].path.length > 2;
1440
+ }
1441
+ // ── 關聯線 ────────────────────────────────────────────────────────────
1442
+ const edgeGroup = panels.toolGroup('關聯線');
1443
+ toolbar.appendChild(edgeGroup);
991
1444
  // v3.5.0 Task 7: arm the edge-creation gesture. One-shot in the sense
992
1445
  // that a drag which PRODUCES an edge disarms it (`finishEdgeDrag` below);
993
1446
  // pressing the button itself is a plain toggle, so a change of mind
994
1447
  // before ever pressing on the canvas costs one more click, not Escape.
995
- const edgeBtn = toolButton('ed-wave-edge-arm', '關聯線',
1448
+ const edgeBtn = panels.toolButton('ed-wave-edge-arm', '關聯線',
996
1449
  '按一下武裝,然後從一格拖到另一格畫出關聯線(Escape 取消)', function () {
997
1450
  setEdgeMode(edgeMode === 'idle' ? 'armed' : 'idle');
998
- });
1451
+ // v3.6.0 Task 11: the transition dots (and their removal) are the
1452
+ // only on-canvas evidence arming happened — `setEdgeMode` above
1453
+ // only touches the button/overlay attributes, so without this the
1454
+ // dots would not appear until some OTHER repaint happened to run
1455
+ // first (the next cursor move, say), which is not "the instant you
1456
+ // press the button".
1457
+ render();
1458
+ }, edgeGroup);
999
1459
  setEdgeMode('idle');
1000
1460
 
1001
- // head / foot — the document-level fields this drawing can honour.
1002
- //
1003
- // `config.hscale` USED to be a third field here and has been removed. The
1004
- // drawing does not model `hscale`, `period` or `phase` at all (zero
1005
- // occurrences in wave-codec, wave-geometry or this file), and measured
1006
- // against the pinned engine each of them changes what the engine paints
1007
- // while leaving the drawing exactly as it was:
1461
+ // ── 歷史 ──────────────────────────────────────────────────────────────
1462
+ const historyGroup = panels.toolGroup('歷史');
1463
+ toolbar.appendChild(historyGroup);
1464
+ const undoBtn = panels.toolButton('ed-wave-undo', '復原', '退回上一個動作', function () {
1465
+ if (!store.undo()) { panels.say('沒有可以復原的動作'); return; }
1466
+ afterStoreMoved('undo');
1467
+ }, historyGroup);
1468
+ const redoBtn = panels.toolButton('ed-wave-redo', '重做', '再做一次剛剛復原掉的動作', function () {
1469
+ if (!store.redo()) { panels.say('沒有可以重做的動作'); return; }
1470
+ afterStoreMoved('redo');
1471
+ }, historyGroup);
1472
+
1473
+ // ── 匯出 ──────────────────────────────────────────────────────────────
1008
1474
  //
1009
- // {wave:'0101'} engine 8 half-bricks drawing 4 cycles
1010
- // {wave:'0101', period:2} engine 16 drawing 4, unchanged
1011
- // {wave:'0101', phase:0.5} engine 7 drawing 4, unshifted
1012
- // {wave:'0101'} config.hscale:2 engine 16 drawing 4, unchanged
1475
+ // v3.6.0 Task 15: the three buttons Task 12 created as shape.
1013
1476
  //
1014
- // Shipping a CONTROL for a property the canvas ignores is the worst of the
1015
- // three options: it invites the user to make the two pictures disagree and
1016
- // then shows them a confident wrong one. Modelling the three properly is
1017
- // not a change this file may make on its own either — cycle width lives in
1018
- // `wave-geometry`, which is closed and whose arithmetic Task 3 pinned, and
1019
- // a half-model here would be a SECOND belief about cycle geometry in the
1020
- // same tree, which is the exact failure this batch keeps producing.
1477
+ // THE SOURCE IS THE PREVIEW'S SVG, never the canvas. The preview is the
1478
+ // ENGINE'S OWN OUTPUT, so what a user exports is the same picture the
1479
+ // rendered document shows; exporting the canvas would export this file's
1480
+ // second implementation of that picture — the one precondition 3 in this
1481
+ // module's header exists to stop from becoming a second belief on screen.
1021
1482
  //
1022
- // So: the control is gone, and a document that carries any of the three is
1023
- // told so out loud by `renderUnmodelled()` below rather than drawn wrongly
1024
- // in silence.
1025
- const metaBar = d.createElement('div');
1026
- metaBar.className = 'ed-wave-meta';
1027
- toolbar.appendChild(metaBar);
1028
-
1029
- function metaField(cls, label, placeholder) {
1030
- const wrap = d.createElement('label');
1031
- wrap.className = 'ed-wave-metafield';
1032
- const text = d.createElement('span');
1033
- text.textContent = label;
1034
- wrap.appendChild(text);
1035
- const input = d.createElement('input');
1036
- input.type = 'text';
1037
- input.className = cls;
1038
- input.setAttribute('data-focus-key', cls);
1039
- input.placeholder = placeholder;
1040
- wrap.appendChild(input);
1041
- metaBar.appendChild(wrap);
1042
- return input;
1483
+ // Every browser global the three need — `XMLSerializer`, `Blob`, `URL`,
1484
+ // `Image`, `setTimeout`, `navigator` — is reached through `probeWin`
1485
+ // (`d.defaultView`, declared beside `sourceText` above), never as a bare
1486
+ // identifier: this file is require-able in node against a fake
1487
+ // `opts.document` (see its header) and names no global of its own.
1488
+
1489
+ /**
1490
+ * The preview's SVG, serialised so that it still draws OUTSIDE this page.
1491
+ *
1492
+ * The plain `serializeToString(svg)` that this obviously wants to be is a
1493
+ * trap, and a quiet one. `renderPreview` renders with `notFirstSignal`
1494
+ * true whenever a WaveDrom skin is already on the page — which is ALWAYS
1495
+ * in the flow that reaches this editor, because the entry affordance only
1496
+ * appears over a rendered diagram — so the preview SVG carries neither the
1497
+ * skin's `<defs>` nor its `<style>`. MEASURED on this repo's own wave
1498
+ * fixture: the preview SVG is 5057 bytes with 51 `<use>` elements, 0
1499
+ * `<defs>` and 0 `<style>`, every one of those 51 pointing at an id that
1500
+ * lives in a DIFFERENT SVG on the page. Serialised as-is it opens as half
1501
+ * a picture — the whole clock lane and every brick shape gone, all text in
1502
+ * the browser's default font — and it opens that way without erroring, so
1503
+ * a check that the file was written, or that the click did not throw, sees
1504
+ * nothing at all. Rasterised: 1409 non-white pixels of 54000 against 5693
1505
+ * for the self-contained file.
1506
+ *
1507
+ * So: clone the preview, and if the clone has no `<defs>` of its own,
1508
+ * prepend a CLONE of the page skin's `<style>` and `<defs>`.
1509
+ */
1510
+ function exportSvgText() {
1511
+ const host = deps.previewHost;
1512
+ const svg = (host === null || host === undefined) ? null : host.querySelector('svg');
1513
+ if (svg === null || svg === undefined) return '';
1514
+ const clone = svg.cloneNode(true);
1515
+ if (clone.querySelector('defs') === null) {
1516
+ // `#socket` is the skin's own first entry, asked for INSIDE a
1517
+ // WaveDrom-rendered SVG's defs — the identical question, asked the
1518
+ // identical way, that `renderPreview` asks to decide whether to emit a
1519
+ // skin at all. Two different answers to "is the skin on this page"
1520
+ // would be exactly the disagreement that leaves this export broken.
1521
+ const socket = d.querySelector('svg.WaveDrom defs #socket');
1522
+ const skin = socket === null ? null : socket.closest('svg');
1523
+ if (skin !== null) {
1524
+ const defs = skin.querySelector('defs');
1525
+ const style = skin.querySelector('style');
1526
+ // CLONED, never moved. These nodes are what every diagram ON THE
1527
+ // PAGE draws from; an export that took them would blank the whole
1528
+ // document behind the dialog the first time anyone pressed 匯出.
1529
+ if (defs !== null) clone.insertBefore(defs.cloneNode(true), clone.firstChild);
1530
+ if (style !== null) clone.insertBefore(style.cloneNode(true), clone.firstChild);
1531
+ }
1532
+ }
1533
+ return new probeWin.XMLSerializer().serializeToString(clone);
1534
+ }
1535
+
1536
+ function downloadBlob(blob, filename) {
1537
+ const url = probeWin.URL.createObjectURL(blob);
1538
+ const a = d.createElement('a');
1539
+ a.href = url;
1540
+ a.download = filename;
1541
+ d.body.appendChild(a);
1542
+ a.click();
1543
+ a.remove();
1544
+ // revoke 延後一拍:立刻 revoke 會讓某些瀏覽器在下載開始前就失去來源。
1545
+ probeWin.setTimeout(function () { probeWin.URL.revokeObjectURL(url); }, 1000);
1546
+ }
1547
+
1548
+ function exportSvg() {
1549
+ const text = exportSvgText();
1550
+ if (text === '') { panels.say('預覽還沒畫出來,沒有東西可以匯出'); return; }
1551
+ downloadBlob(new probeWin.Blob([text], { type: 'image/svg+xml' }), exportName('svg'));
1552
+ panels.say('已匯出 ' + exportName('svg'));
1553
+ }
1554
+
1555
+ function exportPng() {
1556
+ const text = exportSvgText();
1557
+ if (text === '') { panels.say('預覽還沒畫出來,沒有東西可以匯出'); return; }
1558
+ const svg = deps.previewHost.querySelector('svg');
1559
+ const w = Number(svg.getAttribute('width'));
1560
+ const h = Number(svg.getAttribute('height'));
1561
+ // A zero-sized canvas is not an error to `toBlob`: it hands back a
1562
+ // perfectly valid, perfectly empty PNG. Refuse it in words rather than
1563
+ // let the user save a file that is a picture of nothing.
1564
+ if (!(w > 0) || !(h > 0)) {
1565
+ panels.say('預覽的尺寸讀不出來,PNG 匯不出去');
1566
+ return;
1567
+ }
1568
+ const img = d.createElement('img');
1569
+ img.onload = function () {
1570
+ const cv = d.createElement('canvas');
1571
+ cv.width = w; cv.height = h;
1572
+ const ctx2 = cv.getContext('2d');
1573
+ // The SVG's own background rect is white, but only over its own box;
1574
+ // painting white first means a PNG dropped into a dark-themed document
1575
+ // does not show that document's background through the margins.
1576
+ ctx2.fillStyle = '#ffffff';
1577
+ ctx2.fillRect(0, 0, w, h);
1578
+ ctx2.drawImage(img, 0, 0);
1579
+ cv.toBlob(function (blob) {
1580
+ if (blob === null) { panels.say('PNG 轉檔失敗'); return; }
1581
+ downloadBlob(blob, exportName('png'));
1582
+ panels.say('已匯出 ' + exportName('png'));
1583
+ }, 'image/png');
1584
+ };
1585
+ img.onerror = function () { panels.say('PNG 轉檔失敗'); };
1586
+ // A data URI, not an object URL: an SVG loaded into an `<img>` renders
1587
+ // in the browser's secure static mode either way, and a data URI needs
1588
+ // no revoke to pair with it on a path that can end in `onerror`.
1589
+ img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(text);
1590
+ }
1591
+
1592
+ function exportJson() {
1593
+ // `sourceText()`, not a second serialisation: this is the same walk of
1594
+ // `store.toPatch()` that the 原始碼 panel shows and that 保留並關閉
1595
+ // would write, so what lands on the clipboard can never disagree with
1596
+ // either of them.
1597
+ const text = sourceText();
1598
+ const clip = probeWin.navigator && probeWin.navigator.clipboard;
1599
+ if (clip && typeof clip.writeText === 'function') {
1600
+ clip.writeText(text).then(function () {
1601
+ panels.say('WaveJSON 已複製到剪貼簿');
1602
+ }, function () {
1603
+ panels.say('複製失敗,可以從右欄的原始碼面板手動選取');
1604
+ });
1605
+ return;
1606
+ }
1607
+ panels.say('這個瀏覽器不給用剪貼簿,可以從右欄的原始碼面板手動選取');
1043
1608
  }
1044
1609
 
1045
- const headInput = metaField('ed-wave-head-text', 'head', '上方標題');
1046
- const footInput = metaField('ed-wave-foot-text', 'foot', '下方標題');
1610
+ /**
1611
+ * 檔名:markdown 檔名 + block 序號 + 副檔名。
1612
+ *
1613
+ * `opts.fileLabel` IS the source, and the caller has to pass it. The
1614
+ * sentence that used to stand here — that `d.title` is "the same answer by
1615
+ * another road", because md2doc writes `path.basename(src, '.md')` into
1616
+ * `<title>` — was false the moment anyone edited anything. client.js owns
1617
+ * the page title while a session is open and decorates it:
1618
+ * `document.title = (documentIsDirty() ? '● ' : '') + baseTitle`. MEASURED
1619
+ * on this repo's wave fixture, one lane-copy before pressing 匯出:
1620
+ * `document.title` is `'● doc'` and the export lands as
1621
+ * `●-doc-wave-1.svg`. The marker survives the sanitiser (`●` is not in its
1622
+ * class, and stripping leading `-` runs does not reach it), and it only
1623
+ * appears for a user who actually edited — so an export tried straight
1624
+ * after opening gives the clean name and hides it.
1625
+ *
1626
+ * The fix is not to strip `'● '` here. That would put client.js's
1627
+ * decoration format into a second file, and the next change to the marker
1628
+ * would rot it silently. client.js passes `baseTitle` instead — the title
1629
+ * as it was BEFORE it began decorating, which is the one value in this
1630
+ * system that is exactly the markdown file's basename.
1631
+ *
1632
+ * `d.title` therefore stays only as a last resort for a host that passes
1633
+ * no `fileLabel` at all (today there is none). It is NOT equivalent: it is
1634
+ * whatever that host has written into the title by the time 匯出 is
1635
+ * pressed.
1636
+ *
1637
+ * The sanitiser is not cosmetic: a filename with a `/` in it would
1638
+ * otherwise reach `a.download` as a path separator.
1639
+ */
1640
+ function exportName(ext) {
1641
+ const raw = String(opts.fileLabel || d.title || 'wave');
1642
+ const label = raw.replace(/[\\/:*?"<>|\s]+/g, '-').replace(/^-+|-+$/g, '') || 'wave';
1643
+ return label + '-wave-' + Number(opts.blockIndex || 0) + '.' + ext;
1644
+ }
1645
+
1646
+ const exportGroup = panels.toolGroup('匯出');
1647
+ toolbar.appendChild(exportGroup);
1648
+ panels.toolButton('ed-wave-export-svg', 'SVG',
1649
+ '把預覽那張圖存成 SVG 檔(含 WaveDrom 的 skin,離開這一頁也畫得出來)',
1650
+ exportSvg, exportGroup);
1651
+ panels.toolButton('ed-wave-export-png', 'PNG',
1652
+ '把預覽那張圖存成 PNG 檔(白底,尺寸跟預覽一樣)',
1653
+ exportPng, exportGroup);
1654
+ panels.toolButton('ed-wave-export-wavejson', '複製 WaveJSON',
1655
+ '把右欄原始碼面板上那份 WaveJSON 複製到剪貼簿',
1656
+ exportJson, exportGroup);
1657
+
1658
+ // ── 檔案 ──────────────────────────────────────────────────────────────
1659
+ //
1660
+ // v3.6.0 Task 12: both wired now, unlike every disabled section above —
1661
+ // neither waits on a later task. 保留並關閉 runs the SAME commit path the
1662
+ // header's 關閉 button already runs (`close('commit')`); 放棄 is new:
1663
+ // before this, discarding an edit was keyboard-only (Escape), so a
1664
+ // pointer user had no way to abandon one. `close`'s own contract makes
1665
+ // "anything that is not `'escape'` is a commit" the default, so wiring
1666
+ // 放棄 is one line.
1667
+ const fileGroup = panels.toolGroup('檔案');
1668
+ toolbar.appendChild(fileGroup);
1669
+ panels.toolButton('ed-wave-file-close', '保留並關閉',
1670
+ '保留這次編輯並關閉(跟標題列的「關閉」是同一個動作)', function () {
1671
+ close('commit');
1672
+ }, fileGroup);
1673
+ panels.toolButton('ed-wave-file-discard', '放棄',
1674
+ '放棄這次編輯(跟 Esc 是同一個動作;按一次 Ctrl+Z 可以拿回來)', function () {
1675
+ close('escape');
1676
+ }, fileGroup);
1677
+
1678
+ // head / foot / hscale — the document-level fields the 文件 panel
1679
+ // exposes, plus (below, in the Lane panel) a per-lane period/phase.
1680
+ //
1681
+ // v3.6.0 Task 8 fix round 2: the sentence that used to stand here —
1682
+ // "the drawing does not model `hscale`, `period` or `phase` at all
1683
+ // (zero occurrences in wave-codec, wave-geometry or this file)" — went
1684
+ // false at Task 4 and nobody caught it until a review two tasks later.
1685
+ // `wave-geometry.js`'s `layoutOf` reads a lane's own `period`/`phase`
1686
+ // into `originX`/`cycleWidth`, and `hscaleOf(cfg)` reads `config.hscale`
1687
+ // globally; `wave-draw.js` uses those per-lane values for every brick's
1688
+ // x-coordinate. That is real modelling, not zero occurrences.
1689
+ //
1690
+ // v3.6.0 Task 9 fix round 2 (ruling R30) then closed the half that was
1691
+ // still open when the paragraph above was written: the shared cycle grid
1692
+ // and the ruler are DIAGRAM-wide and now derive from the same
1693
+ // `hscale`-scaled diagram pitch the bricks are painted at, so they move
1694
+ // with `period` and `config.hscale` too. `test/wave-draw.test.js` pins
1695
+ // both fixtures value-by-value against the engine's own render —
1696
+ // `period: 4` draws the engine's 17 ticks (0..16), not `cycles + 1`;
1697
+ // `hscale: 2` draws them 96 apart (48 × 2), the case whose tick COUNT is
1698
+ // unchanged and whose pitch is the only tell.
1699
+ //
1700
+ // So as of Task 18 `panels.renderUnmodelled()` no longer fires for any of
1701
+ // the three: they are modelled, and saying otherwise is now the false
1702
+ // statement. See that function's own block comment.
1703
+ //
1704
+ // v3.6.0 Task 13: a CONTROL for these three is back — `config.hscale`
1705
+ // here, `period`/`phase` in the Lane panel below — because what made
1706
+ // shipping one "the worst of the three options" (see the Task 8 comment
1707
+ // this replaces) was a control that INVITES the two pictures to
1708
+ // disagree while showing a confident wrong one. Relocating the fields
1709
+ // into named, individually-labelled panel rows does not change that risk
1710
+ // by itself. What closed it is Tasks 4 and 9 actually MODELLING the
1711
+ // three — in `wave-geometry.js`'s `layoutOf`/`hscaleOf` and
1712
+ // `wave-draw.js`'s diagram pitch, the one place cycle geometry is
1713
+ // allowed to live, so this file never grew a second belief about it.
1714
+ // A control that sets a value the canvas then draws is not the failure
1715
+ // the Task 8 comment was guarding against.
1716
+ const headInput = panels.metaField(
1717
+ 'ed-wave-head-text', 'head', '上方標題', 'head.text', panels.sections.document);
1718
+ const headTickInput = panels.metaField('ed-wave-head-tick', 'head.tick',
1719
+ '起始刻度,例如 0', 'head.tick', panels.sections.document);
1720
+ const headTockInput = panels.metaField('ed-wave-head-tock', 'head.tock',
1721
+ '半格刻度起始值,例如 0', 'head.tock', panels.sections.document);
1722
+ const headEveryInput = panels.metaField('ed-wave-head-every', 'head.every',
1723
+ '每幾格顯示一次刻度', 'head.every', panels.sections.document);
1724
+ const footInput = panels.metaField(
1725
+ 'ed-wave-foot-text', 'foot', '下方標題', 'foot.text', panels.sections.document);
1047
1726
  // v3.5.0 Task 13, fix round 1: the ruler fields — the COMPLETE set, not
1048
1727
  // the three Task 13's own brief happened to name. Measured directly
1049
1728
  // against the pinned engine rather than recalled: `renderMarks` in
@@ -1072,27 +1751,74 @@
1072
1751
  // bare, since those never had a same-named sibling to begin with.
1073
1752
  // Grouped head-then-foot in DOM order (four fields, then four fields)
1074
1753
  // 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); });
1754
+ // readability reason. v3.6.0 Task 13: head/tick/tock/every are now
1755
+ // created ABOVE, before `footInput`, so the document panel's DOM order
1756
+ // is the full `head.*` group followed by the full `foot.*` group —
1757
+ // `test/editor-journey.test.js`'s five-panel scenario pins this exact
1758
+ // order.
1759
+ const footTickInput = panels.metaField('ed-wave-foot-tick', 'foot.tick',
1760
+ '下方起始刻度,例如 0', 'foot.tick', panels.sections.document);
1761
+ const footTockInput = panels.metaField('ed-wave-foot-tock', 'foot.tock',
1762
+ '下方半格刻度起始值,例如 0', 'foot.tock', panels.sections.document);
1763
+ const footEveryInput = panels.metaField('ed-wave-foot-every', 'foot.every',
1764
+ '下方每幾格顯示一次刻度', 'foot.every', panels.sections.document);
1765
+ // v3.6.0 Task 13: the document panel's ninth field — see the block
1766
+ // comment above for why a control for this is back at all.
1767
+ const hscaleInput = panels.metaField('ed-wave-config-hscale', 'hscale',
1768
+ '整份圖的水平縮放倍率(config.hscale,1-100 的整數;空白=不設定)',
1769
+ 'config.hscale', panels.sections.document);
1770
+ // Published for `panels.renderMeta`'s lazy read, same reason as
1771
+ // `deps.headInput` etc. below — see `wave-panels.js`'s own header for
1772
+ // why these stay a lazy `deps` read rather than a return value.
1773
+ deps.headInput = headInput;
1774
+ deps.footInput = footInput;
1775
+ deps.headTickInput = headTickInput;
1776
+ deps.headTockInput = headTockInput;
1777
+ deps.headEveryInput = headEveryInput;
1778
+ deps.footTickInput = footTickInput;
1779
+ deps.footTockInput = footTockInput;
1780
+ deps.footEveryInput = footEveryInput;
1781
+ deps.hscaleInput = hscaleInput;
1782
+
1783
+ headInput.addEventListener('change', function () { panels.commitBanner('head', 'text', headInput.value); });
1784
+ footInput.addEventListener('change', function () { panels.commitBanner('foot', 'text', footInput.value); });
1785
+ headTickInput.addEventListener('change', function () { panels.commitBanner('head', 'tick', headTickInput.value); });
1086
1786
  headTockInput.addEventListener('change', function () {
1087
- commitBanner('head', 'tock', headTockInput.value);
1787
+ panels.commitBanner('head', 'tock', headTockInput.value);
1088
1788
  });
1089
- headEveryInput.addEventListener('change', function () { commitBanner('head', 'every', headEveryInput.value); });
1789
+ headEveryInput.addEventListener('change', function () { panels.commitBanner('head', 'every', headEveryInput.value); });
1090
1790
  footTickInput.addEventListener('change', function () {
1091
- commitBanner('foot', 'tick', footTickInput.value);
1791
+ panels.commitBanner('foot', 'tick', footTickInput.value);
1092
1792
  });
1093
- footTockInput.addEventListener('change', function () { commitBanner('foot', 'tock', footTockInput.value); });
1793
+ footTockInput.addEventListener('change', function () { panels.commitBanner('foot', 'tock', footTockInput.value); });
1094
1794
  footEveryInput.addEventListener('change', function () {
1095
- commitBanner('foot', 'every', footEveryInput.value);
1795
+ panels.commitBanner('foot', 'every', footEveryInput.value);
1796
+ });
1797
+ hscaleInput.addEventListener('change', function () {
1798
+ panels.commitConfigField('hscale', hscaleInput.value);
1799
+ });
1800
+
1801
+ // ── the Lane panel: period / phase for the currently selected lane ─────
1802
+ //
1803
+ // v3.6.0 Task 13: new. "Selected lane" here is the CYCLE selection's own
1804
+ // `laneIndex` (`selection`, below) — this dialog has never had a
1805
+ // separate "selected lane" concept, and inventing a second one only for
1806
+ // these two fields would be a second belief about what "selected" means
1807
+ // in the same file. Disabled whenever `selection` is null; see
1808
+ // `panels.renderLaneField`'s own comment for the render side.
1809
+ const lanePeriodInput = panels.metaField('ed-wave-lane-period', 'period',
1810
+ '先選一段 cycle 才能設定', 'period', panels.sections.lane);
1811
+ const lanePhaseInput = panels.metaField('ed-wave-lane-phase', 'phase',
1812
+ '先選一段 cycle 才能設定', 'phase', panels.sections.lane);
1813
+ deps.lanePeriodInput = lanePeriodInput;
1814
+ deps.lanePhaseInput = lanePhaseInput;
1815
+ lanePeriodInput.addEventListener('change', function () {
1816
+ if (selection === null) return;
1817
+ panels.commitLaneField(selection.laneIndex, 'period', lanePeriodInput.value);
1818
+ });
1819
+ lanePhaseInput.addEventListener('change', function () {
1820
+ if (selection === null) return;
1821
+ panels.commitLaneField(selection.laneIndex, 'phase', lanePhaseInput.value);
1096
1822
  });
1097
1823
 
1098
1824
  // ── the edge inspector row ──────────────────────────────────────────
@@ -1107,7 +1833,10 @@
1107
1833
  const edgeBar = d.createElement('div');
1108
1834
  edgeBar.className = 'ed-wave-edgebar';
1109
1835
  edgeBar.hidden = true;
1110
- toolbar.appendChild(edgeBar);
1836
+ // v3.6.0 Task 13: the 關聯線 panel's body, not the toolbar — see
1837
+ // `wave-panels.js`'s `sections` for why the container is available
1838
+ // synchronously here.
1839
+ panels.sections.edge.appendChild(edgeBar);
1111
1840
 
1112
1841
  const shapeWrap = d.createElement('label');
1113
1842
  shapeWrap.className = 'ed-wave-metafield';
@@ -1177,6 +1906,11 @@
1177
1906
  });
1178
1907
  });
1179
1908
  edgeBar.appendChild(edgeDeleteBtn);
1909
+ // Published for `panels.renderEdgeBar`'s lazy read, same reason as
1910
+ // `deps.metaBar` above.
1911
+ deps.edgeBar = edgeBar;
1912
+ deps.shapeSel = shapeSel;
1913
+ deps.edgeLabelInput = edgeLabelInput;
1180
1914
 
1181
1915
  // The "this drawing is not showing you everything" notice. Empty, and with
1182
1916
  // no box on screen, for the documents that carry none of the three.
@@ -1193,38 +1927,18 @@
1193
1927
  noSignal.className = 'ed-wave-unmodelled ed-wave-nolanes';
1194
1928
  noSignal.hidden = true;
1195
1929
  toolbar.appendChild(noSignal);
1930
+ // Published for `panels.renderUnmodelled`'s lazy read, same reason as
1931
+ // `deps.headInput` etc. above.
1932
+ deps.unmodelled = unmodelled;
1933
+ deps.noSignal = noSignal;
1196
1934
 
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.
1935
+ // ── body: lane list | drawing | right-hand column ──────────────────────
1218
1936
  //
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); });
1225
- }
1226
-
1227
- // ── body: lane list | drawing | preview ───────────────────────────────
1937
+ // v3.6.0 Task 13: the body's third column USED to be `previewWrap`
1938
+ // directly; it is now `panels.side`, the five-section right-hand column
1939
+ // built inside `createPanels()` (see `wave-panels.js`'s own comment
1940
+ // above `side`/`sections`), and `previewWrap` moves one level deeper,
1941
+ // into `panels.sections.preview`.
1228
1942
  const body = d.createElement('div');
1229
1943
  body.className = 'ed-wave-body';
1230
1944
  panel.appendChild(body);
@@ -1232,6 +1946,9 @@
1232
1946
  const laneCol = d.createElement('div');
1233
1947
  laneCol.className = 'ed-wave-lanes';
1234
1948
  body.appendChild(laneCol);
1949
+ // Published for `panels.renderLanes`/`panels.renameGroup`'s lazy read,
1950
+ // same reason as `deps.headInput` etc. above.
1951
+ deps.laneCol = laneCol;
1235
1952
 
1236
1953
  const canvasWrap = d.createElement('div');
1237
1954
  canvasWrap.className = 'ed-wave-canvas-wrap';
@@ -1251,7 +1968,15 @@
1251
1968
  // This preview belongs to neither.
1252
1969
  previewHost.id = PREVIEW_ID_PREFIX + PREVIEW_INDEX;
1253
1970
  previewWrap.appendChild(previewHost);
1254
- body.appendChild(previewWrap);
1971
+ panels.sections.preview.appendChild(previewWrap);
1972
+ // Published for `panels.renderPreview`'s lazy read, same reason as
1973
+ // `deps.headInput` etc. above.
1974
+ deps.previewWrap = previewWrap;
1975
+ deps.previewHost = previewHost;
1976
+
1977
+ // The right-hand column itself, appended once — the position the old
1978
+ // `previewWrap` occupied directly in `body`.
1979
+ body.appendChild(panels.side);
1255
1980
 
1256
1981
  const status = d.createElement('div');
1257
1982
  status.className = 'ed-wave-status';
@@ -1260,11 +1985,9 @@
1260
1985
  // the one place that has to be announced rather than merely painted.
1261
1986
  status.setAttribute('role', 'status');
1262
1987
  panel.appendChild(status);
1263
-
1264
- function say(text) {
1265
- status.textContent = text;
1266
- overlay.setAttribute('data-wave-status', text);
1267
- }
1988
+ // Published for `panels.say`'s lazy read, same reason as `deps.metaBar`
1989
+ // above.
1990
+ deps.status = status;
1268
1991
 
1269
1992
  // ── the one road from a gesture to the document ───────────────────────
1270
1993
  /**
@@ -1289,7 +2012,7 @@
1289
2012
  if (closed) return false;
1290
2013
  const changed = store.apply(name, fn);
1291
2014
  if (!changed) {
1292
- say('這個動作沒有改變任何東西');
2015
+ panels.say('這個動作沒有改變任何東西');
1293
2016
  return false;
1294
2017
  }
1295
2018
  gestures++;
@@ -1349,13 +2072,12 @@
1349
2072
  // dragging an endpoint is exactly the same shape of gap `pendingFrom`
1350
2073
  // closed — the pointer holds no document capture of its own, only
1351
2074
  // 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.
2075
+ // v3.6.0 Task 14: `selection` carries the rail's lane range itself
2076
+ // now, so these three are the whole list again — there is no fourth
2077
+ // set of row numbers standing beside them to ask about.
1356
2078
  if (before === null ||
1357
2079
  (cursor === null && selection === null && pendingFrom === null &&
1358
- endpointDrag === null && laneMultiSelect === null)) {
2080
+ endpointDrag === null)) {
1359
2081
  return;
1360
2082
  }
1361
2083
  const trace = typeof opts.laneTrace === 'function'
@@ -1364,12 +2086,9 @@
1364
2086
  // to `pendingFrom` exactly as it already does to `cursor`/`selection`
1365
2087
  // — dropped, not guessed at. `endpointDrag` joins them: a drag naming
1366
2088
  // 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.
2089
+ // way.
1370
2090
  if (trace === null) {
1371
2091
  cursor = null; selection = null; pendingFrom = null; endpointDrag = null;
1372
- laneMultiSelect = null;
1373
2092
  return;
1374
2093
  }
1375
2094
  // Structural or not: a content operation traces as the identity, so this
@@ -1385,10 +2104,41 @@
1385
2104
  if (at !== null) cursor = { laneIndex: at, cycle: cursor.cycle };
1386
2105
  }
1387
2106
  if (selection !== null) {
1388
- const at = rowOf(selection.laneIndex);
1389
- if (at !== null) {
1390
- selection = { laneIndex: at, from: selection.from, to: selection.to };
1391
- }
2107
+ // v3.6.0 Task 14, tightened in fix round 1: BOTH ends of the lane
2108
+ // range are retraced, independently, and an end the trace cannot
2109
+ // vouch for is DROPPED rather than left on a stale number. That is
2110
+ // the member-by-member rule the retired lane multi-select used
2111
+ // ("an entry that cannot be traced is removed"), applied to a pair.
2112
+ //
2113
+ // Round 0 put this whole block inside `if (rowOf(laneIndex) !== null)`,
2114
+ // which reads like a guard and is not one: when the range's FIRST
2115
+ // lane was the one that vanished, nothing ran and BOTH numbers were
2116
+ // left standing. `renderCanvas`'s clamp does not save it — that
2117
+ // only pulls `laneTo` down to the last row and never collapses a
2118
+ // range — so 建立群組 and 訊號's 刪除 acted on a span the user never
2119
+ // selected. Reachable in three gestures: insert a lane, Shift-select
2120
+ // from it downwards, undo the insert.
2121
+ //
2122
+ // What survives, by case:
2123
+ // both ends traced → the traced range (re-normalised: a reorder
2124
+ // can swap which end is lower)
2125
+ // one end traced → collapsed onto that end — the one lane still
2126
+ // vouched for
2127
+ // neither traced → collapsed onto `laneIndex`'s OLD number. The
2128
+ // number is kept because `laneIndex` doubles as
2129
+ // a screen position `renderCanvas` re-clamps and
2130
+ // `cursor`'s own "land on whoever took the
2131
+ // place" instinct is right for that; the SPAN is
2132
+ // not kept, so a structural op can at worst
2133
+ // touch that one row, never a range.
2134
+ const near = rowOf(selection.laneIndex);
2135
+ const far = rowOf(selection.laneTo);
2136
+ const a = near !== null ? near : (far !== null ? far : selection.laneIndex);
2137
+ const b = far !== null ? far : a;
2138
+ selection = {
2139
+ laneIndex: Math.min(a, b), laneTo: Math.max(a, b),
2140
+ from: selection.from, to: selection.to,
2141
+ };
1392
2142
  }
1393
2143
  // `pendingFrom` is a mark of the exact same shape as `cursor` (a row
1394
2144
  // number plus a cycle), carried the same way — by lane IDENTITY
@@ -1428,26 +2178,6 @@
1428
2178
  endpointDrag = at === null ? null
1429
2179
  : { index: endpointDrag.index, end: endpointDrag.end, at: at, cell: endpointDrag.cell };
1430
2180
  }
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
- }
1451
2181
  }
1452
2182
 
1453
2183
  function afterStoreMoved(name) {
@@ -1459,9 +2189,9 @@
1459
2189
  lastPatch = store.toPatch();
1460
2190
  render();
1461
2191
  if (lastPatch.ok === true) {
1462
- say('已寫回:' + name);
2192
+ panels.say('已寫回:' + name);
1463
2193
  } else {
1464
- say('這個改動沒辦法只改幾個位元組寫回去:' + lastPatch.reason);
2194
+ panels.say('這個改動沒辦法只改幾個位元組寫回去:' + lastPatch.reason);
1465
2195
  }
1466
2196
  if (typeof opts.onGesture === 'function') {
1467
2197
  opts.onGesture({ name: name, patch: lastPatch, store: store });
@@ -1471,6 +2201,192 @@
1471
2201
  // ── painting ──────────────────────────────────────────────────────────
1472
2202
  let canvas = null;
1473
2203
 
2204
+ /**
2205
+ * v3.6.0 Task 13 fix round 4: keep the CURRENTLY selected edge's own
2206
+ * drawn handles clear of the expanded side rail — by narrowing the
2207
+ * RAIL, never by moving the canvas.
2208
+ *
2209
+ * Diagnosed against a real page before writing this (see task-13-
2210
+ * report.md, fix round 4), and the first two things tried both failed
2211
+ * against that diagnosis before this one was chosen:
2212
+ *
2213
+ * 1. Hypothesis (mine and the reviewer's, both wrong): the expand
2214
+ * reflows the canvas mid-gesture, so a point resolved before it
2215
+ * goes stale. MEASURED false — `.ed-wave-canvas`'s own
2216
+ * `getBoundingClientRect()` is IDENTICAL before, during and after
2217
+ * the whole gesture. Nothing moves.
2218
+ * 2. First fix tried: scroll the canvas horizontally to carry the
2219
+ * handle out from under the rail (`canvasWrap.scrollLeft`), the
2220
+ * same technique `pointInCanvas` uses in the test suite. MEASURED
2221
+ * to do nothing on the fixture that actually fails: a short
2222
+ * document's drawn content is NARROWER than the wrap's own
2223
+ * `clientWidth`, so `scrollWidth === clientWidth` — there is no
2224
+ * scrollABLE range at all, and setting `scrollLeft` past 0 is
2225
+ * silently clamped straight back to 0. The rail's expansion does
2226
+ * not add any scrollable width to the wrap (it is an absolutely
2227
+ * positioned overlay, outside the wrap's own box model), so a
2228
+ * short document can never be scrolled clear of it.
2229
+ *
2230
+ * The actual defect both of those diagnosed correctly but did not fix
2231
+ * is occlusion, not displacement: the expanded rail is `position:
2232
+ * absolute`, OPAQUE, `z-index: 3`, painted ON TOP of the canvas at a
2233
+ * narrow viewport (round 1's own deliberate design — the rail overlays
2234
+ * rather than squeezes, so the canvas keeps its width-budget win).
2235
+ * `elementFromPoint` at the handle's own coordinates resolved to a
2236
+ * `<span>` INSIDE the rail, not the handle — because the rail is what
2237
+ * is actually painted there now, regardless of scroll position.
2238
+ *
2239
+ * So this narrows the rail instead: its `width` (normally 340px, the
2240
+ * CSS default) is set only as far down as needed to keep the selected
2241
+ * edge's rightmost handle clear, floored at `MIN_WIDTH` so the panel
2242
+ * never shrinks to something unusable. `right: 0` stays anchored (the
2243
+ * CSS is untouched), so shrinking width moves the rail's LEFT edge
2244
+ * right, away from the handle, while the canvas itself never moves at
2245
+ * all — satisfying the same invariant the "reflow mid-gesture"
2246
+ * hypothesis was chasing (`.ed-wave-canvas`'s rect never changes
2247
+ * between a press and its release) for a different, correct reason.
2248
+ *
2249
+ * Recomputed from scratch on every call (the leading `style.width =
2250
+ * ''` is not an optimization to skip) rather than only on a change of
2251
+ * `selectedEdge` — an unrelated repaint can still move a lane's own
2252
+ * geometry (hscale, a resize) while the SAME edge stays selected, and
2253
+ * a stale width would either pointlessly over-narrow the rail or,
2254
+ * worse, under-narrow it and silently reopen the exact defect this
2255
+ * exists to close.
2256
+ *
2257
+ * Selecting an edge is very often immediately followed by dragging the
2258
+ * very handle selection just exposed — it is the ONLY way to adjust an
2259
+ * endpoint (`onCanvasDown`'s drag-start branch only ever fires for the
2260
+ * SELECTED edge's own handle), so select-then-drag is not an edge
2261
+ * case, it is the whole mechanism this protects.
2262
+ *
2263
+ * v3.6.0 final fix (M2): every handle is measured against the WRAP's own
2264
+ * visible rect before it is allowed to move anything. `.ed-wave-canvas-
2265
+ * wrap` is `overflow: auto`, and `getBoundingClientRect()` on a child it
2266
+ * has scrolled or clipped out of sight still returns a full viewport
2267
+ * rect for a box that is painted NOWHERE — this repo's own documented
2268
+ * trap, which the test helper `pointInCanvas` already applies and this
2269
+ * function did not. Two things follow, and both are load-bearing:
2270
+ *
2271
+ * - A handle with no intersection with the wrap's rect is SKIPPED
2272
+ * outright. It cannot be overlapped by the rail, because it is not
2273
+ * on screen to overlap.
2274
+ * - A handle that is only PARTLY visible contributes the right edge of
2275
+ * its visible part (`min(r.right, wrapRect.right)`), not its full
2276
+ * rect's right. Clamping alone would not do: a fully clipped handle
2277
+ * clamps to `wrapRect.right`, which is the largest value in the
2278
+ * whole calculation and would narrow the rail harder than the real
2279
+ * defect did. The skip is what makes the clamp safe.
2280
+ *
2281
+ * MEASURED on the fixture `test/wave-rail-clearance.test.js` now carries
2282
+ * (24 cycles, `edge: ['a~>b']` from cycle 1 to cycle 20, a real press on
2283
+ * the drawn `.ed-wave-edge-hit` stroke), at 1000x900 — the viewport this
2284
+ * whole version exists for, since `.ed-wave-side[data-expanded]` is
2285
+ * `position: absolute` below 1100px:
2286
+ *
2287
+ * to-handle right 1332.2 wrap right 970 visible false
2288
+ * before: side.style.width '160px' rendered 160px label input 23px
2289
+ * after: side.style.width '' rendered 340px label input 203px
2290
+ *
2291
+ * The rail was being collapsed to its floor by a handle drawn 362px
2292
+ * past the clip. The edge-shape `<select>` measures 50px in BOTH states
2293
+ * and is deliberately NOT quoted as a symptom — it is a fixed-width
2294
+ * control, so it survives the squeeze that guts the `<input>` beside it,
2295
+ * and quoting it would read as damage that was never there.
2296
+ *
2297
+ * The wrap's border box is used for that visibility test rather than its
2298
+ * padding box, matching `pointInCanvas`'s own visibility check (which
2299
+ * reserves the padding box for scroll ARITHMETIC). The wrap carries
2300
+ * padding and no border, and content is visible inside padding, so the
2301
+ * two boxes are the same rect here anyway.
2302
+ *
2303
+ * Where this genuinely does nothing, stated as the code behaves rather
2304
+ * than as round 4 hoped: an inline width IS still applied at a wide
2305
+ * viewport — MEASURED 160px at 1280x900 and 198px at 1600x900 on that
2306
+ * same 24-cycle document before this fix, i.e. the old "no inline width
2307
+ * is ever applied at a wide viewport" claim was simply false. It was
2308
+ * harmless there for a DIFFERENT reason than the one claimed:
2309
+ * `.ed-wave-side`'s `flex: 0 0 340px` beats an inline `width` for a flex
2310
+ * item's main size, so the rendered column stayed 340px regardless. The
2311
+ * same mechanism covers the collapsed 28px rail (`flex: 0 0 28px`), so a
2312
+ * leftover inline style from a PRIOR expansion cannot keep it
2313
+ * artificially wide. Only the `position: absolute` expanded state below
2314
+ * 1100px takes the inline width for real — which is exactly why that is
2315
+ * the state the defect showed up in.
2316
+ *
2317
+ * Residual, accepted limit — and it IS reached, including by this
2318
+ * function's own test fixture. When `bodyRight - rightmost - MARGIN`
2319
+ * falls below `MIN_WIDTH`, this clamps to `MIN_WIDTH` rather than
2320
+ * shrinking further, and the selected handle stays covered by the rail.
2321
+ *
2322
+ * MEASURED at HEAD on `MEDIUM_MD` — the 12-cycle fixture in
2323
+ * `test/wave-rail-clearance.test.js`'s own scenario 2 — at 1000x900:
2324
+ *
2325
+ * to-handle 888.2–900.2, painted and not clipped at all
2326
+ * bodyRight 970 -> fits = 970 - 900.2 - 4 = 65.8 (< MIN_WIDTH)
2327
+ * side.style.width '160px', so the rail's left edge lands at 810
2328
+ * => 90.2px of the handle is still under the rail, and
2329
+ * `elementFromPoint` at the handle's own centre returns a
2330
+ * `<span>` INSIDE `.ed-wave-side`, not the handle
2331
+ *
2332
+ * It needs no contrived fixture: 16 cycles with the `to` end at cycle
2333
+ * 12, at 970x900, leaves 166.4px of overlap the same way.
2334
+ *
2335
+ * The sentence that stood here — "a small residual overlap in a
2336
+ * genuinely too-narrow-to-satisfy-both-at-once case … which no fixture
2337
+ * here produces" — was therefore wrong three times: the overlap is most
2338
+ * of the handle rather than small, the case is ordinary rather than
2339
+ * genuinely-too-narrow, and the fixture that produces it shipped in the
2340
+ * SAME commit as the sentence denying it. It is recorded rather than
2341
+ * quietly replaced because it is this batch's signature defect (a true
2342
+ * piece of code with a false sentence beside it) landing inside the
2343
+ * delivery that exists to fix that defect — one clause after the
2344
+ * warning that "no fixture here reaches it" is a claim about fixtures,
2345
+ * never about what a user can reach.
2346
+ *
2347
+ * Why it is accepted rather than fixed here: below 1100px the rail is
2348
+ * `position: absolute` and `bodyRight === wrapRight` (MEASURED: both
2349
+ * 970 above), so a handle drawn near the right-hand end of a wide
2350
+ * diagram cannot be cleared by narrowing the rail AT ALL — no width
2351
+ * satisfies both, and `MIN_WIDTH` is the floor precisely so the panel
2352
+ * does not become unusable while trying. Clearing it needs a different
2353
+ * mechanism (scroll the wrap, or anchor the overlay to the other edge),
2354
+ * which is a design change and not a bound to re-tune.
2355
+ *
2356
+ * One more thing this does not do, in the opposite direction, and it
2357
+ * behaves identically before and after the fix above so it is not a
2358
+ * regression: the wrap has no scroll listener, so scrolling AFTER an
2359
+ * edge is selected does not re-run the verdict. MEASURED on the
2360
+ * 24-cycle fixture at 1000x900 — `scrollLeft = 356` brings the `to`
2361
+ * handle back to 964.2–976.2, i.e. painted and squarely under the rail,
2362
+ * while `side.style.width` stays `''` from the reading taken at
2363
+ * `scrollLeft 0`. That is the only measurable way the new
2364
+ * skip-what-the-wrap-clipped rule can be wrong, and it is a STALE
2365
+ * verdict rather than a wrong one.
2366
+ */
2367
+ function clearSelectedEdgeOfSide() {
2368
+ panels.side.style.width = '';
2369
+ if (selectedEdge === null) return;
2370
+ const bodyRight = body.getBoundingClientRect().right;
2371
+ const wrapRect = canvasWrap.getBoundingClientRect();
2372
+ let rightmost = null;
2373
+ for (const end of ['from', 'to']) {
2374
+ const h = d.querySelector('.ed-wave-edge-handle[data-edge-index="' +
2375
+ selectedEdge + '"][data-edge-handle="' + end + '"]');
2376
+ if (h === null) continue;
2377
+ const r = h.getBoundingClientRect();
2378
+ if (r.right <= wrapRect.left || r.left >= wrapRect.right) continue;
2379
+ if (r.bottom <= wrapRect.top || r.top >= wrapRect.bottom) continue;
2380
+ const painted = Math.min(r.right, wrapRect.right);
2381
+ if (rightmost === null || painted > rightmost) rightmost = painted;
2382
+ }
2383
+ if (rightmost === null) return;
2384
+ const MARGIN = 4;
2385
+ const MIN_WIDTH = 160;
2386
+ const fits = bodyRight - rightmost - MARGIN;
2387
+ if (fits < 340) panels.side.style.width = Math.max(MIN_WIDTH, Math.round(fits)) + 'px';
2388
+ }
2389
+
1474
2390
  /**
1475
2391
  * Repaint everything, and give the keyboard cursor back where it was.
1476
2392
  *
@@ -1524,37 +2440,79 @@
1524
2440
  if (endpointDrag !== null && endpointDrag.index !== selectedEdge) {
1525
2441
  endpointDrag = null;
1526
2442
  }
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
- }
1545
- renderLanes(doc, layout);
1546
- renderCanvas(doc, layout);
1547
- renderEdgeBar(doc);
1548
- renderPreview(doc);
1549
- renderMeta(doc);
1550
- renderUnmodelled(doc, layout);
2443
+ panels.renderLanes(doc, layout, { selection: selection });
2444
+ // v3.6.0 Task 13: the Lane panel's period/phase — same `selection` mark
2445
+ // `renderLanes` above already reads for the CYCLE highlight, not a
2446
+ // second "selected lane" concept.
2447
+ panels.renderLaneField(doc, layout, { selection: selection });
2448
+ const wds = {
2449
+ cursor: cursor,
2450
+ selection: selection,
2451
+ pendingFrom: pendingFrom,
2452
+ endpointDrag: endpointDrag,
2453
+ selectedEdge: selectedEdge,
2454
+ overlay: overlay,
2455
+ canvasWrap: canvasWrap,
2456
+ dataEdit: dataEdit,
2457
+ enterDrawing: enterDrawing,
2458
+ onCanvasDown: onCanvasDown,
2459
+ onCanvasHover: onCanvasHover,
2460
+ edgeMode: edgeMode,
2461
+ hoverBoundary: hoverBoundary,
2462
+ };
2463
+ canvas = drawer.renderCanvas(canvas, doc, layout, wds);
2464
+ cursor = wds.cursor;
2465
+ selection = wds.selection;
2466
+ panels.renderEdgeBar(doc, { selectedEdge: selectedEdge });
2467
+ // v3.6.0 Task 13 fix round 3: selecting an edge is an action whose
2468
+ // ENTIRE feedback surface is the 關聯線 panel just painted above —
2469
+ // at a narrow viewport (<1100px) that panel sits inside the
2470
+ // collapsed rail, where every one of its controls renders at zero
2471
+ // size (`wave-panels.js`'s `section()` docstring and `lib/md2doc.js`'s
2472
+ // `.ed-wave-side` media query: `data-open` is the SECTION's own
2473
+ // toggle state and says nothing about whether the RAIL is currently
2474
+ // showing it). Left alone, a narrow-viewport user who selects an edge
2475
+ // gets no hint that the only surface to edit or delete it exists at
2476
+ // all. So this expands the rail — but only on the TRANSITION into
2477
+ // having a selection (`lastSelectedEdge === null`, checked against
2478
+ // the PREVIOUS render, not "selectedEdge !== null" on its own):
2479
+ // expanding on every repaint would fight a user who deliberately
2480
+ // collapses the rail back (the fix-round-1 button) while still
2481
+ // working on the same selected edge — the very next keystroke would
2482
+ // pop it back open. Firing once, at the moment of selection, is the
2483
+ // honest response without becoming a second one-way door.
2484
+ //
2485
+ // Lane/cycle selection does NOT get the same treatment, by deliberate
2486
+ // choice, not oversight: unlike an edge, selecting a lane already has
2487
+ // visible feedback OUTSIDE the side column — `renderLanes` highlights
2488
+ // the row (`.is-selected`) and the canvas itself draws the cycle
2489
+ // selection — so a narrow-viewport user who selects a lane is never
2490
+ // left looking at nothing the way an edge-select would leave them.
2491
+ if (selectedEdge !== null && lastSelectedEdge === null) {
2492
+ panels.expandSide();
2493
+ }
2494
+ lastSelectedEdge = selectedEdge;
2495
+ // v3.6.0 Task 13 fix round 4: re-checked on EVERY render while an
2496
+ // edge is selected, not only on the transition that just (maybe)
2497
+ // expanded the rail above — see `clearSelectedEdgeOfSide`'s own
2498
+ // comment for the mechanism. Unlike `expandSide()`, this has no
2499
+ // "only once" concern: it recomputes and resets from scratch each
2500
+ // call, so it stays correct across switching directly from one
2501
+ // selected edge to another, an unrelated repaint moving the SAME
2502
+ // edge's own handle (a resize, an hscale change), or the rail
2503
+ // collapsing back (where it correctly clears its own inline style).
2504
+ clearSelectedEdgeOfSide();
2505
+ panels.renderPreview(doc);
2506
+ panels.renderMeta(doc);
2507
+ panels.renderUnmodelled(doc, layout);
2508
+ // v3.6.0 Task 13: the 原始碼 panel. Repainted every pass, like every
2509
+ // other panel above — cheap (one `store.toPatch()`, already run once
2510
+ // per gesture by `afterStoreMoved`) and it means the panel can never
2511
+ // show a patch older than what is on screen.
2512
+ panels.renderSource();
1551
2513
  undoBtn.disabled = !store.canUndo();
1552
2514
  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(','));
2515
+ paintSelectionState(doc, layout);
1558
2516
  overlay.setAttribute('data-wave-gestures', String(gestures));
1559
2517
  overlay.setAttribute('data-wave-lanes', String(layout.lanes.length));
1560
2518
  overlay.setAttribute('data-wave-cycles', String(layout.cycles));
@@ -1575,28 +2533,6 @@
1575
2533
  return false;
1576
2534
  }
1577
2535
 
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
-
1600
2536
  /** What has the keyboard now, as something that survives a rebuild. */
1601
2537
  function focusMark() {
1602
2538
  const el = d.activeElement;
@@ -1618,6 +2554,16 @@
1618
2554
  const want = focusOverride !== null ? focusOverride : (mark === null ? null : mark.key);
1619
2555
  focusOverride = null;
1620
2556
  if (want === null) return;
2557
+ // v3.6.0 Task 14: "did not come back" includes "came back DISABLED".
2558
+ // `.focus()` on a disabled control is inert — the fact T6h pinned for
2559
+ // the rail's ▲ at the top of the list — and the toolbar is now where
2560
+ // the gestures that disable their own button live: 訊號's 刪除 empties
2561
+ // the selection, so the very button the user pressed is disabled by
2562
+ // the repaint that press caused. Found-but-inert fell straight through
2563
+ // to `document.body`, which is the state this whole function exists to
2564
+ // prevent, and which turns the next Backspace into「delete the
2565
+ // selected blocks」.
2566
+ const inert = function (node) { return node === null || node.disabled === true; };
1621
2567
  let el = overlay.querySelector('[data-focus-key="' + want + '"]');
1622
2568
  // A named destination the repaint did not bring back. It happens: a
1623
2569
  // group's key is its first lane's row number, so inserting a lane above
@@ -1626,10 +2572,10 @@
1626
2572
  // where the keyboard actually was, and failing that to the dialog
1627
2573
  // itself. Never to nothing: nothing is `document.body`, and this whole
1628
2574
  // task is about not ending there.
1629
- if (el === null && mark !== null && mark.key !== want) {
2575
+ if (inert(el) && mark !== null && mark.key !== want) {
1630
2576
  el = overlay.querySelector('[data-focus-key="' + mark.key + '"]');
1631
2577
  }
1632
- if (el === null) {
2578
+ if (inert(el)) {
1633
2579
  if (!overlay.contains(d.activeElement)) panel.focus();
1634
2580
  return;
1635
2581
  }
@@ -1642,602 +2588,6 @@
1642
2588
  } catch (e) { /* not a text field */ }
1643
2589
  }
1644
2590
 
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
-
1666
- function renderMeta(doc) {
1667
- const h = doc.head;
1668
- const f = doc.foot;
1669
- headInput.value = (h !== null && typeof h === 'object' && typeof h.text === 'string')
1670
- ? h.text : '';
1671
- footInput.value = (f !== null && typeof f === 'object' && typeof f.text === 'string')
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) : '';
1679
- }
1680
-
1681
- /**
1682
- * Say out loud which properties of this document the drawing is not
1683
- * showing.
1684
- *
1685
- * `config.hscale`, and a lane's `period` / `phase`, all change what the
1686
- * engine paints and none of them is modelled here (see the toolbar comment
1687
- * for the measurements). Drawing a confident picture that ignores them is
1688
- * the failure this whole overlay is arranged to avoid, so a document that
1689
- * carries any of them says so, names them, and points at the preview —
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.
1718
- */
1719
- function renderUnmodelled(doc, layout) {
1720
- const found = [];
1721
- const cfg = doc.config;
1722
- if (cfg !== null && typeof cfg === 'object' && cfg.hscale !== undefined) {
1723
- found.push('config.hscale');
1724
- }
1725
- for (const path of codec.lanePaths(doc)) {
1726
- const lane = valueAt(doc, path);
1727
- if (lane === null || typeof lane !== 'object') continue;
1728
- if (lane.period !== undefined && found.indexOf('period') === -1) found.push('period');
1729
- if (lane.phase !== undefined && found.indexOf('phase') === -1) found.push('phase');
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;
1785
- overlay.setAttribute('data-wave-unmodelled', found.join(' '));
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(' ');
1792
-
1793
- // …and the bigger version of the same honesty. This editor edits
1794
- // `signal:` and nothing else, so a `reg:` or `assign:` block opens a
1795
- // canvas with no lanes on it: `lanePaths()` is empty, `addLane()` hands
1796
- // the document straight back, and pressing + produced one status line
1797
- //「這個動作沒有改變任何東西」— true, and no explanation at all. An
1798
- // editor that looks broken is worse than one that says what it cannot
1799
- // do, and the notice above is the precedent: the drawing already tells
1800
- // the user which properties it is not showing them.
1801
- const kinds = [];
1802
- for (const key of ['reg', 'assign']) {
1803
- if (doc[key] !== undefined) kinds.push(key + ':');
1804
- }
1805
- const noLanes = codec.lanePaths(doc).length === 0;
1806
- overlay.setAttribute('data-wave-nolanes', noLanes ? '1' : '0');
1807
- noSignal.hidden = !noLanes;
1808
- noSignal.textContent = !noLanes ? '' :
1809
- (kinds.length === 0
1810
- ? '這個區塊裡沒有 signal:,所以沒有可以編輯的 lane。'
1811
- : '這個區塊用的是 ' + kinds.join(' / ') +
1812
- ',這個編輯器只編輯 signal: 的 lane。') +
1813
- '右邊的 WaveDrom 預覽仍然是引擎畫的原圖;要改這個區塊請用「⠿ → MD 原始碼」。';
1814
- }
1815
-
1816
- /**
1817
- * The lane list.
1818
- *
1819
- * Rows are `layout.laneHeight` tall and in `layout.lanes` order, so row k
1820
- * here and row k of the drawing are the same lane by construction rather
1821
- * than by a second traversal that could disagree.
1822
- */
1823
- function renderLanes(doc, layout) {
1824
- laneCol.textContent = '';
1825
- const rows = d.createElement('div');
1826
- rows.className = 'ed-wave-lane-rows';
1827
- rows.style.height = layout.height + 'px';
1828
- laneCol.appendChild(rows);
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
-
1840
- // Group titles first, so they sit UNDER the rows in paint order and a
1841
- // lane's own controls stay clickable over them.
1842
- for (const span of spans) {
1843
- const tag = d.createElement('button');
1844
- tag.type = 'button';
1845
- tag.className = 'ed-wave-group';
1846
- tag.setAttribute('data-focus-key', 'group-' + span.from);
1847
- tag.textContent = span.title;
1848
- tag.title = '群組「' + span.title + '」——點一下改名';
1849
- tag.setAttribute('data-group-from', String(span.from));
1850
- tag.setAttribute('data-group-to', String(span.to));
1851
- tag.style.top = (span.from * layout.laneHeight) + 'px';
1852
- tag.style.height = ((span.to - span.from + 1) * layout.laneHeight) + 'px';
1853
- tag.style.left = ((span.depth - 1) * 16) + 'px';
1854
- tag.addEventListener('click', function () { renameGroup(span); });
1855
- rows.appendChild(tag);
1856
- }
1857
-
1858
- for (let i = 0; i < layout.lanes.length; i++) {
1859
- rows.appendChild(laneRow(doc, layout, i, ungroupAt.get(i)));
1860
- }
1861
- // The tail insert. `laneInsertPath(doc, laneCount)` goes on the end of
1862
- // `signal` itself, which is what makes the group asymmetry visible here
1863
- // rather than surprising afterwards.
1864
- rows.appendChild(insertButton(doc, layout.lanes.length, layout, true));
1865
- }
1866
-
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) {
1874
- const row = d.createElement('div');
1875
- row.className = 'ed-wave-lane-row';
1876
- row.setAttribute('data-lane', String(i));
1877
- row.style.top = (i * layout.laneHeight) + 'px';
1878
- row.style.height = layout.laneHeight + 'px';
1879
- row.style.paddingLeft = (layout.lanes[i].depth * 16) + 'px';
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);
1929
-
1930
- row.appendChild(insertButton(doc, i, layout, false));
1931
-
1932
- const name = d.createElement('input');
1933
- name.type = 'text';
1934
- name.className = 'ed-wave-lane-name';
1935
- name.setAttribute('data-focus-key', 'lane-name-' + i);
1936
- const lane = layout.lanes[i].lane;
1937
- name.value = typeof lane.name === 'string' ? lane.name : '';
1938
- name.addEventListener('change', function () {
1939
- commit('rename', function (doc2) { return codec.renameLane(doc2, i, name.value); });
1940
- });
1941
- row.appendChild(name);
1942
-
1943
- const up = d.createElement('button');
1944
- up.type = 'button';
1945
- up.className = 'ed-wave-lane-up';
1946
- up.setAttribute('data-focus-key', 'lane-up-' + i);
1947
- up.textContent = '▲';
1948
- up.title = '往上移一格';
1949
- up.disabled = i === 0;
1950
- up.addEventListener('click', function () {
1951
- // The button this lane arrives at, EXCEPT at the top, where `lane-up-0`
1952
- // is disabled and `.focus()` on it is inert — measured, walking a lane
1953
- // to the top with three ▲ put the cursor on `lane-up-2`, `lane-up-1`
1954
- // and then BODY, which is the state F7 exists to prevent and the fourth
1955
- // press then did nothing at all. At the top the cursor goes to the one
1956
- // control on that row that is never disabled there: ▼.
1957
- focusOverride = i - 1 === 0 ? 'lane-down-0' : 'lane-up-' + (i - 1);
1958
- commit('move', function (doc2) { return codec.moveLane(doc2, i, i - 1); });
1959
- });
1960
- row.appendChild(up);
1961
-
1962
- const down = d.createElement('button');
1963
- down.type = 'button';
1964
- down.className = 'ed-wave-lane-down';
1965
- down.setAttribute('data-focus-key', 'lane-down-' + i);
1966
- down.textContent = '▼';
1967
- down.title = '往下移一格';
1968
- down.disabled = i === layout.lanes.length - 1;
1969
- down.addEventListener('click', function () {
1970
- // Mirror of ▲ above: `lane-down-<last>` is disabled, so the bottom row
1971
- // hands the cursor to ▲ instead.
1972
- focusOverride = i + 1 === layout.lanes.length - 1
1973
- ? 'lane-up-' + (i + 1) : 'lane-down-' + (i + 1);
1974
- commit('move', function (doc2) { return codec.moveLane(doc2, i, i + 1); });
1975
- });
1976
- row.appendChild(down);
1977
-
1978
- const del = d.createElement('button');
1979
- del.type = 'button';
1980
- del.className = 'ed-wave-lane-remove';
1981
- del.setAttribute('data-focus-key', 'lane-remove-' + i);
1982
- del.textContent = '✕';
1983
- del.title = '刪掉這條 lane(如果群組因此空掉,群組也會一起走)';
1984
- del.addEventListener('click', function () {
1985
- selection = null;
1986
- // The row this button belongs to is about to stop existing, so the
1987
- // keyboard goes to the + that occupies that position instead — the
1988
- // one control at that spot that survives every removal, tail included.
1989
- focusOverride = 'lane-add-' + i;
1990
- commit('remove-lane', function (doc2) { return codec.removeLane(doc2, i); });
1991
- });
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
- }
2073
- return row;
2074
- }
2075
-
2076
- /**
2077
- * Where a lane inserted at display position `index` would actually land,
2078
- * asked of the bridge rather than guessed.
2079
- *
2080
- * This is constraint 4 made visible. `laneInsertPath` puts an insert at a
2081
- * group's FIRST lane inside that group, and an insert at the position just
2082
- * past its LAST lane outside it — a group can be joined at its head and
2083
- * never at its tail. That asymmetry is not something a label can argue
2084
- * away, so the label states it: the button says which container it will
2085
- * land in, before it is pressed, and the status line says it again after.
2086
- */
2087
- function landingOf(doc, index) {
2088
- const path = codec.laneInsertPath(doc, index);
2089
- if (path === null) return { path: null, title: null };
2090
- const container = path.slice(0, path.length - 1);
2091
- const held = valueAt(doc, container);
2092
- const title = Array.isArray(held) && typeof held[0] === 'string' ? held[0] : null;
2093
- return { path: path, title: title };
2094
- }
2095
-
2096
- function insertButton(doc, index, layout, isTail) {
2097
- const b = d.createElement('button');
2098
- b.type = 'button';
2099
- b.className = isTail ? 'ed-wave-lane-add ed-wave-lane-add-tail' : 'ed-wave-lane-add';
2100
- b.textContent = '+';
2101
- b.setAttribute('data-insert-at', String(index));
2102
- b.setAttribute('data-focus-key', 'lane-add-' + index);
2103
- const landing = landingOf(doc, index);
2104
- const where = landing.title === null ? '群組外' : '群組「' + landing.title + '」裡';
2105
- b.setAttribute('data-lands-in', landing.title === null ? '' : landing.title);
2106
- b.title = '在這裡加一條 lane —— 會落在' + where;
2107
- if (isTail) {
2108
- b.style.top = (layout.lanes.length * layout.laneHeight) + 'px';
2109
- }
2110
- b.addEventListener('click', function () {
2111
- // Straight into the new lane's name field: it is what the user is going
2112
- // to type next, and it is the only control the new row introduces.
2113
- focusOverride = 'lane-name-' + index;
2114
- const added = commit('add-lane', function (doc2) {
2115
- return codec.addLane(doc2, index, { name: '', wave: 'x' });
2116
- });
2117
- if (added) say('新增的 lane 落在' + where);
2118
- });
2119
- return b;
2120
- }
2121
-
2122
- /**
2123
- * The rail's rename field: this dialog's one sub-panel.
2124
- *
2125
- * The field is INSERTED beside its tag and the tag is hidden, rather than
2126
- * the two being swapped, and taking it down again touches only those two
2127
- * nodes. That is not tidiness. Taking it down used to mean a full
2128
- * `render()` — a rebuild of the entire rail — and a rebuild that runs
2129
- * inside a `blur` handler destroys the element the user is in the middle
2130
- * of pressing. Measured before this: open the field, then press any rail
2131
- * button; the mousedown blurred the field, the rail was rebuilt, the
2132
- * mouseup landed on a fresh element so no `click` ever fired, the press
2133
- * did nothing at all, and the keyboard ended on `document.body` inside a
2134
- * modal with no visible cursor.
2135
- */
2136
- function renameGroup(span) {
2137
- const rows = laneCol.querySelector('.ed-wave-lane-rows');
2138
- const tag = rows === null ? null
2139
- : rows.querySelector('.ed-wave-group[data-group-from="' + span.from + '"]');
2140
- if (tag === null) return;
2141
-
2142
- const input = d.createElement('input');
2143
- input.type = 'text';
2144
- input.className = 'ed-wave-group-input';
2145
- input.setAttribute('data-focus-key', 'group-input-' + span.from);
2146
- input.value = span.title;
2147
- input.style.top = tag.style.top;
2148
- input.style.left = tag.style.left;
2149
-
2150
- /** The field off, the tag back — and nothing else in the rail touched.
2151
- * A no-op for both once a repaint has taken them away. */
2152
- const takeDown = function () {
2153
- if (input.parentNode !== null) input.parentNode.removeChild(input);
2154
- if (d.contains(tag)) tag.hidden = false;
2155
- };
2156
- /** The tag this field was covering, or — when a repaint has replaced it
2157
- * and moved it (a group's `from` is its first lane's row, so an insert
2158
- * above it renumbers the key) — whatever now carries that key, and
2159
- * failing that the dialog itself. Never nothing: nothing is
2160
- * `document.body`. */
2161
- const focusTag = function () {
2162
- const live = d.contains(tag) ? tag
2163
- : overlay.querySelector('[data-focus-key="group-' + span.from + '"]');
2164
- if (live !== null) live.focus();
2165
- else panel.focus();
2166
- };
2167
- /** Whether the keyboard is IN the field right now. False on the `blur`
2168
- * path — the browser has already moved it by then — which is how the
2169
- * two are told apart without asking which device did anything. */
2170
- const holdsKeyboard = function () { return d.activeElement === input; };
2171
-
2172
- const finish = function () {
2173
- // Once. `change` and `blur` both fire for a field committed with Enter,
2174
- // and every teardown below is a blur too. Whoever gets here first owns
2175
- // the gesture; the latch is also how the other two say "not you".
2176
- if (groupRename === null) return;
2177
- groupRename = null;
2178
- const hadKeyboard = holdsKeyboard();
2179
- const value = input.value;
2180
- takeDown();
2181
- if (value === span.title) {
2182
- // Nothing to write, so nothing to repaint — the tag is already back.
2183
- // The keyboard is only moved when it was in the field: on the mouse
2184
- // path the browser is already carrying it to the control that was
2185
- // pressed, and that control is still there to receive it now that
2186
- // this path no longer rebuilds the rail.
2187
- if (hadKeyboard) focusTag();
2188
- return;
2189
- }
2190
- // A real commit, so a real repaint — and the keyboard has to be named
2191
- // if it would otherwise be lost. `focusOverride` is left alone when a
2192
- // caller has already made a more specific promise.
2193
- if (focusOverride === null &&
2194
- (hadKeyboard || !overlay.contains(d.activeElement))) {
2195
- focusOverride = 'group-' + span.from;
2196
- }
2197
- commit('rename-group', function (doc2) {
2198
- return replaceAt(doc2, span.path.concat([0]), value);
2199
- });
2200
- };
2201
- // The rail is about to be rebuilt underneath this field by somebody
2202
- // else's repaint — an undo, a redo, any gesture that reaches `render()`
2203
- // while it is open. Both nodes go with that rebuild, so there is nothing
2204
- // to take down; what matters is dropping the latch and naming where the
2205
- // keyboard lands, because `group-input-<n>` will not exist afterwards and
2206
- // focus would be left on a node that is no longer in the document — which
2207
- // is the wedge that lost every subsequent keystroke.
2208
- const retire = function () {
2209
- if (groupRename === null) return;
2210
- groupRename = null;
2211
- if (focusOverride === null && holdsKeyboard()) {
2212
- focusOverride = 'group-' + span.from;
2213
- }
2214
- };
2215
- // Escape's half of the layering: the field goes, the name does not
2216
- // change, the session stands, and the keyboard goes back to the tag the
2217
- // field was covering — never to `document.body`, which is the state the
2218
- // whole of this task exists to make unreachable. No repaint at all: the
2219
- // tag was never destroyed.
2220
- const cancel = function () {
2221
- if (groupRename === null) return;
2222
- groupRename = null;
2223
- takeDown();
2224
- focusTag();
2225
- };
2226
-
2227
- input.addEventListener('change', finish);
2228
- input.addEventListener('blur', finish);
2229
- tag.hidden = true;
2230
- rows.insertBefore(input, tag);
2231
- // Three facts, and only the three that are read: that a rename is
2232
- // standing (this being non-null), how to take it down on purpose, and how
2233
- // to let go of it when a repaint takes it down for us. A copy of the span
2234
- // or of the field itself would be a second record of something the
2235
- // closure above already holds.
2236
- groupRename = { cancel: cancel, retire: retire };
2237
- input.focus();
2238
- input.select();
2239
- }
2240
-
2241
2591
  /**
2242
2592
  * The in-place editor for one bus cell's `data` label — this dialog's
2243
2593
  * OTHER sub-panel, same `{cancel, retire}` shape as `renameGroup` just
@@ -2411,7 +2761,7 @@
2411
2761
  const slot = codec.dataSlotOf(lane, cycle);
2412
2762
  if (slot !== null) return { cycle: cycle, slot: slot };
2413
2763
  const levels = codec.levelsOf(typeof lane.wave === 'string' ? lane.wave : '');
2414
- if (!isBus(brickOf(levels[cycle]))) return null;
2764
+ if (!drawer.isBus(drawer.brickOf(levels[cycle]))) return null;
2415
2765
  for (let i = cycle - 1; i >= 0 && levels[i] === levels[cycle]; i--) {
2416
2766
  const s = codec.dataSlotOf(lane, i);
2417
2767
  if (s !== null) return { cycle: i, slot: s };
@@ -2419,697 +2769,6 @@
2419
2769
  return 'blocked';
2420
2770
  }
2421
2771
 
2422
- /**
2423
- * A copy of `doc` with one scalar replaced, for the two fields the codec's
2424
- * lane operations do not cover (a group's own title).
2425
- *
2426
- * Structure-sharing everywhere it does not have to copy, so the store's
2427
- * lane-identity bookkeeping still sees every untouched lane as the same
2428
- * object — `remapOrigins` answers a content edit by position and a
2429
- * structural one by identity, and a wholesale clone here would make an
2430
- * ordinary rename look like a structural change.
2431
- */
2432
- function replaceAt(node, path, value) {
2433
- if (path.length === 0) return value;
2434
- const seg = path[0];
2435
- const child = replaceAt(node[seg], path.slice(1), value);
2436
- if (Array.isArray(node)) {
2437
- const out = node.slice();
2438
- out[seg] = child;
2439
- return out;
2440
- }
2441
- const out = Object.assign({}, node);
2442
- out[seg] = child;
2443
- return out;
2444
- }
2445
-
2446
- /**
2447
- * The hand-drawn waveform.
2448
- *
2449
- * Every cycle's picture comes from `codec.levelsOf` through `brickOf`, and
2450
- * from nowhere else — constraint 3. The levels and the bricks are both
2451
- * published on the element (`data-levels-N`, `data-bricks-N`) so the
2452
- * agreement with the preview beside it is something a test can measure
2453
- * rather than something this comment can promise: the preview's own SVG
2454
- * names its bricks in its `<use xlink:href="#…">` list.
2455
- */
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();
2465
- // Every repaint REPLACES the canvas element, so a canvas that had the
2466
- // keyboard loses it to `document.body` the instant it is detached —
2467
- // exactly the state F7 and T6h exist to prevent, and here it would fire
2468
- // on every arrow key. `render()`'s own `data-focus-key` restore covers
2469
- // the full-repaint path; this covers the three that call renderCanvas
2470
- // directly (a drag's two, and the keyboard's own), so the property holds
2471
- // for the canvas no matter which road the repaint came down.
2472
- const hadFocus = canvas !== null && d.activeElement === canvas;
2473
- // The cursor is an index pair, and the document under it can get shorter:
2474
- // deleting cycles or removing the bottom lane leaves it pointing past the
2475
- // end. Clamped HERE, where the layout that decides what "past the end"
2476
- // means is already in hand — so nothing downstream ever has to ask.
2477
- if (cursor !== null) {
2478
- if (layout.lanes.length === 0 || layout.cycles === 0) {
2479
- cursor = null;
2480
- } else {
2481
- cursor = {
2482
- laneIndex: Math.max(0, Math.min(cursor.laneIndex, layout.lanes.length - 1)),
2483
- cycle: Math.max(0, Math.min(cursor.cycle, layout.cycles - 1)),
2484
- };
2485
- }
2486
- }
2487
- // …and `selection` with it, which is the other half of the same fact and
2488
- // was left out of the first cut. Measured before this: Shift-select four
2489
- // cycles, press 刪除 cycle, and a one-cycle document was left with the
2490
- // cursor on cycle 1, no visible box, 複製 reporting「複製了 4 個 cycle」
2491
- // and 貼上(插入)growing the document back to five. The toolbar was
2492
- // acting on a range the drawing no longer had anywhere to put.
2493
- //
2494
- // The two axes are not treated alike, and that is the point: a CYCLE
2495
- // range that overshoots is the same range in a shorter document, so it
2496
- // clamps; a LANE that is gone is not a lane the user can have meant, so
2497
- // the selection goes rather than silently moving to whichever lane
2498
- // inherited the index. (`lane ✕` already nulls it on its own path; this
2499
- // catches the undo/redo of that.)
2500
- //
2501
- // It does NOT catch every route, and the earlier wording here said it
2502
- // did. MEASURED: `+` at 0, drag-select cycles 1-3 on the new lane 0,
2503
- // then Ctrl+Z — the selection box and the rail highlight both stay on
2504
- // row 0, which is now `clk`. The trace-derived marks above fix the
2505
- // CURSOR for that case; the selection is still carried by row number,
2506
- // so it lands on whoever inherited the row. The cost is confined to the
2507
- // highlight and to where the next Shift+arrow anchors, because every
2508
- // cycle operation is document-wide — but it is a real gap, not a
2509
- // covered one.
2510
- if (selection !== null) {
2511
- if (layout.lanes.length === 0 || layout.cycles === 0 ||
2512
- selection.laneIndex < 0 || selection.laneIndex >= layout.lanes.length) {
2513
- selection = null;
2514
- } else {
2515
- selection = {
2516
- laneIndex: selection.laneIndex,
2517
- from: Math.max(0, Math.min(selection.from, layout.cycles - 1)),
2518
- to: Math.max(0, Math.min(selection.to, layout.cycles - 1)),
2519
- };
2520
- }
2521
- }
2522
- canvasWrap.textContent = '';
2523
- canvas = d.createElementNS(SVGNS, 'svg');
2524
- canvas.setAttribute('class', 'ed-wave-canvas');
2525
- // In the Tab cycle, and therefore in the focus trap, like every other
2526
- // control in here: the drawing is this dialog's main surface and was the
2527
- // one thing in it a keyboard could not reach.
2528
- canvas.setAttribute('tabindex', '0');
2529
- canvas.setAttribute('data-focus-key', 'canvas');
2530
- canvas.setAttribute('aria-label',
2531
- '波形繪圖區:方向鍵移動游標,Shift+左右鍵選一段 cycle,' +
2532
- '按電位字元(' + BRUSHES.join(' ') + ')塗上去');
2533
- const width = Math.max(layout.width, SIZES.cycleWidth);
2534
- canvas.setAttribute('width', String(width));
2535
- canvas.setAttribute('height', String(Math.max(layout.height, layout.laneHeight)));
2536
- canvas.setAttribute('viewBox', '0 0 ' + width + ' ' +
2537
- Math.max(layout.height, layout.laneHeight));
2538
- canvas.setAttribute('data-lane-count', String(layout.lanes.length));
2539
- canvas.setAttribute('data-cycle-count', String(layout.cycles));
2540
-
2541
- // cycle grid
2542
- for (let c = 0; c <= layout.cycles; c++) {
2543
- const g = d.createElementNS(SVGNS, 'line');
2544
- g.setAttribute('class', 'ed-wave-grid');
2545
- g.setAttribute('x1', String(c * layout.cycleWidth));
2546
- g.setAttribute('x2', String(c * layout.cycleWidth));
2547
- g.setAttribute('y1', '0');
2548
- g.setAttribute('y2', String(layout.height));
2549
- canvas.appendChild(g);
2550
- }
2551
-
2552
- for (let i = 0; i < layout.lanes.length; i++) {
2553
- drawLane(canvas, layout, i);
2554
- }
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
-
2605
- if (selection !== null) {
2606
- const from = Math.min(selection.from, selection.to);
2607
- const to = Math.max(selection.from, selection.to);
2608
- const box = d.createElementNS(SVGNS, 'rect');
2609
- box.setAttribute('class', 'ed-wave-selection');
2610
- box.setAttribute('x', String(from * layout.cycleWidth));
2611
- box.setAttribute('y', String(selection.laneIndex * layout.laneHeight));
2612
- box.setAttribute('width', String((to - from + 1) * layout.cycleWidth));
2613
- box.setAttribute('height', String(layout.laneHeight));
2614
- canvas.appendChild(box);
2615
- }
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
-
2644
- // The keyboard's cell, drawn last so it is over the selection tint and
2645
- // over the trace. Its geometry comes from `wave-geometry.cellRect` — the
2646
- // inverse of the hit test the pointer uses — so the cell the cursor is
2647
- // DRAWN on and the cell an edit lands in cannot drift apart.
2648
- let cursorCell = '';
2649
- if (cursor !== null) {
2650
- const cell = geometry.cellRect(layout, cursor.laneIndex, cursor.cycle);
2651
- if (cell !== null) {
2652
- cursorCell = cursor.laneIndex + ',' + cursor.cycle;
2653
- const cur = d.createElementNS(SVGNS, 'rect');
2654
- cur.setAttribute('class', 'ed-wave-cursor');
2655
- cur.setAttribute('data-ed-wave-cursor', '');
2656
- cur.setAttribute('data-cell', cursorCell);
2657
- cur.setAttribute('x', String(cell.x));
2658
- cur.setAttribute('y', String(cell.y));
2659
- cur.setAttribute('width', String(cell.width));
2660
- cur.setAttribute('height', String(cell.height));
2661
- canvas.appendChild(cur);
2662
- }
2663
- }
2664
- // The same value on the overlay: what a scenario reads when the rect is
2665
- // not the thing under test. It is assigned INSIDE the branch that draws
2666
- // the rect and defaults to empty, so "there is no rect" and "the overlay
2667
- // says none" are one decision rather than two that happen to agree —
2668
- // they did not, in the first cut: this line read `cursor` directly, so a
2669
- // cursor the clamp had not caught published a cell that `cellRect`
2670
- // refused to give a rectangle to, and a build with the clamp removed
2671
- // reported `{cell: null, published: "5,4"}`.
2672
- overlay.setAttribute('data-wave-cursor', cursorCell);
2673
-
2674
- canvas.addEventListener('mousedown', onCanvasDown);
2675
- // Tabbing onto the drawing is an entry like any other, so it puts the
2676
- // cursor on screen rather than leaving a focused surface with no visible
2677
- // cursor in it. Re-entrant only once: `enterDrawing()` repaints, the
2678
- // repaint re-focuses the new canvas and fires this again, and by then
2679
- // `cursor` is set.
2680
- canvas.addEventListener('focus', function () {
2681
- if (cursor === null) enterDrawing();
2682
- });
2683
- canvasWrap.appendChild(canvas);
2684
- if (hadFocus) canvas.focus();
2685
- }
2686
-
2687
- function drawLane(svg, layout, i) {
2688
- const row = layout.lanes[i];
2689
- const lane = row.lane;
2690
- // An ABSENT `wave` and an EMPTY one are two different pictures, and
2691
- // collapsing them into one was the regression the previous round shipped
2692
- // on the very axis it was fixing. MEASURED against the pinned engine,
2693
- // second half-brick per cycle:
2694
- //
2695
- // {name:'a', wave:''} ["xxx","xxx"] ONE x cycle
2696
- // {name:'a'} [] NOTHING
2697
- // {} (spacer) [] NOTHING
2698
- // {name:'a', wave:1234} [] NOTHING
2699
- // {name:'a', wave:null} [] NOTHING
2700
- //
2701
- // and in a mixed diagram the engine simply emits no bricks for that row:
2702
- // `{signal:[{a,'0101'},{},{b,'1010'}]}` is 16 uses, i.e. 2 lanes × 4 × 2.
2703
- // So the rule is exactly "a lane draws cycles only when `wave` is a
2704
- // string"; `''` is a string, which is why it gets its one cycle, and
2705
- // `{}` — the canonical WaveDrom blank-row spacer — gets none.
2706
- //
2707
- // The empty-string case is not a corner either: `deleteCycles` covering
2708
- // every cycle sets every lane's wave to `''`, two clicks away.
2709
- const hasWave = typeof lane.wave === 'string';
2710
- const wave = hasWave ? lane.wave : '';
2711
- const cells = hasWave ? codec.expandWave(wave) : [];
2712
- const levels = !hasWave ? [] : (wave === '' ? ['x'] : codec.levelsOf(cells));
2713
- const bricks = levels.map(brickOf);
2714
- // Which cycles carry the engine's discontinuity marker. `levelsOf`
2715
- // deliberately erases `|` — it answers what a cycle SHOWS, and a gap
2716
- // shows whatever is in front of it — so the marker has to be read off the
2717
- // cells. Measured: `01|10` renders one `<use xlink:href="#gap">` at
2718
- // translate(100), which is the centre of cycle 2, the `|` cycle.
2719
- const gaps = [];
2720
- for (let c = 0; c < cells.length; c++) {
2721
- if (cells[c].held !== true && cells[c].ch === '|') gaps.push(c);
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
- }
2742
- // `data-wave-<i>` cannot carry the difference between "no `wave` key" and
2743
- // `wave: ''` — both are the empty string — and that difference is exactly
2744
- // what the engine draws differently, so it gets its own attribute.
2745
- svg.setAttribute('data-haswave-' + i, hasWave ? '1' : '0');
2746
- svg.setAttribute('data-wave-' + i, wave);
2747
- svg.setAttribute('data-levels-' + i, levels.join(''));
2748
- svg.setAttribute('data-bricks-' + i, bricks.join(' '));
2749
- svg.setAttribute('data-gaps-' + i, gaps.join(' '));
2750
- svg.setAttribute('data-clock-edge-' + i, clockEdges.join(' '));
2751
-
2752
- const hi = row.y + 7;
2753
- const lo = row.y + row.height - 9;
2754
- const mid = (hi + lo) / 2;
2755
- const cw = layout.cycleWidth;
2756
- const labels = labelsOf(lane);
2757
- // Which cycles carry a `data` label: the explicit value cycles, in order.
2758
- // `levelsOf` repeats a bus's level through its continuation cycles, so the
2759
- // label belongs to the cycle that STARTS the run, which is what the run
2760
- // grouping below already knows.
2761
- let labelSlot = 0;
2762
-
2763
- // Runs of one picture. Clock bricks never merge — each `p` cycle is a
2764
- // whole clock period, and a merged run would draw one long one.
2765
- const runs = [];
2766
- for (let c = 0; c < bricks.length; c++) {
2767
- const last = runs.length === 0 ? null : runs[runs.length - 1];
2768
- if (last !== null && last.brick === bricks[c] && !isClock(bricks[c])) last.to = c;
2769
- else runs.push({ brick: bricks[c], from: c, to: c });
2770
- }
2771
-
2772
- function bandOf(brick) {
2773
- if (brick === '111' || brick === 'uuu') return [hi, hi];
2774
- if (brick === '000' || brick === 'ddd') return [lo, lo];
2775
- if (brick === 'zzz') return [mid, mid];
2776
- return [hi, lo];
2777
- }
2778
-
2779
- for (let r = 0; r < runs.length; r++) {
2780
- const run = runs[r];
2781
- const x0 = run.from * cw;
2782
- const x1 = (run.to + 1) * cw;
2783
- const brick = run.brick;
2784
- if (isBus(brick)) {
2785
- const poly = d.createElementNS(SVGNS, 'polygon');
2786
- const s = Math.min(6, cw / 4);
2787
- poly.setAttribute('class', 'ed-wave-bus ed-wave-bus-' + brick.slice(4));
2788
- poly.setAttribute('points',
2789
- (x0 + s) + ',' + hi + ' ' + (x1 - s) + ',' + hi + ' ' +
2790
- x1 + ',' + mid + ' ' + (x1 - s) + ',' + lo + ' ' +
2791
- (x0 + s) + ',' + lo + ' ' + x0 + ',' + mid);
2792
- svg.appendChild(poly);
2793
- const text = labels[labelSlot];
2794
- if (typeof text === 'string' && text !== '') {
2795
- const t = d.createElementNS(SVGNS, 'text');
2796
- t.setAttribute('class', 'ed-wave-buslabel');
2797
- t.setAttribute('x', String((x0 + x1) / 2));
2798
- t.setAttribute('y', String(mid + 4));
2799
- t.setAttribute('text-anchor', 'middle');
2800
- t.textContent = text;
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);
2827
- }
2828
- labelSlot++;
2829
- } else if (brick === 'xxx') {
2830
- const rect = d.createElementNS(SVGNS, 'rect');
2831
- rect.setAttribute('class', 'ed-wave-x');
2832
- rect.setAttribute('x', String(x0));
2833
- rect.setAttribute('y', String(hi));
2834
- rect.setAttribute('width', String(x1 - x0));
2835
- rect.setAttribute('height', String(lo - hi));
2836
- svg.appendChild(rect);
2837
- } else if (isClock(brick)) {
2838
- const xm = (x0 + x1) / 2;
2839
- const p = d.createElementNS(SVGNS, 'path');
2840
- p.setAttribute('class', 'ed-wave-line ed-wave-clock');
2841
- p.setAttribute('d', brick === 'nclk'
2842
- ? 'M' + x0 + ',' + lo + ' L' + x0 + ',' + hi + ' L' + xm + ',' + hi +
2843
- ' L' + xm + ',' + lo + ' L' + x1 + ',' + lo
2844
- : 'M' + x0 + ',' + hi + ' L' + x0 + ',' + lo + ' L' + xm + ',' + lo +
2845
- ' L' + xm + ',' + hi + ' L' + x1 + ',' + hi);
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
- }
2911
- } else {
2912
- const y = bandOf(brick)[0];
2913
- const line = d.createElementNS(SVGNS, 'line');
2914
- line.setAttribute('class', brick === 'uuu' || brick === 'ddd'
2915
- ? 'ed-wave-line ed-wave-weak' : 'ed-wave-line');
2916
- line.setAttribute('x1', String(x0));
2917
- line.setAttribute('x2', String(x1));
2918
- line.setAttribute('y1', String(y));
2919
- line.setAttribute('y2', String(y));
2920
- svg.appendChild(line);
2921
- }
2922
-
2923
- // The edge into this run. Clock bricks draw their own.
2924
- if (r > 0 && !isClock(brick) && !isClock(runs[r - 1].brick)) {
2925
- const a = bandOf(runs[r - 1].brick);
2926
- const b = bandOf(brick);
2927
- const top = Math.min(a[0], a[1], b[0], b[1]);
2928
- const bot = Math.max(a[0], a[1], b[0], b[1]);
2929
- if (bot > top) {
2930
- const edge = d.createElementNS(SVGNS, 'line');
2931
- edge.setAttribute('class', 'ed-wave-edge');
2932
- edge.setAttribute('x1', String(x0));
2933
- edge.setAttribute('x2', String(x0));
2934
- edge.setAttribute('y1', String(top));
2935
- edge.setAttribute('y2', String(bot));
2936
- svg.appendChild(edge);
2937
- }
2938
- }
2939
- }
2940
-
2941
- // The gap marker, last so it sits over the trace it interrupts. The
2942
- // engine draws one per `|`; drawing none made the two pictures disagree
2943
- // on a lane whose levels agreed exactly, which is the quietest form of
2944
- // the disagreement this overlay exists to surface.
2945
- for (const c of gaps) {
2946
- const xm = (c + 0.5) * cw;
2947
- const mark = d.createElementNS(SVGNS, 'path');
2948
- mark.setAttribute('class', 'ed-wave-gap');
2949
- mark.setAttribute('d',
2950
- 'M' + (xm - 5) + ',' + (lo + 3) + ' L' + (xm + 1) + ',' + (mid) +
2951
- ' L' + (xm - 5) + ',' + (hi - 3) +
2952
- ' M' + (xm - 1) + ',' + (lo + 3) + ' L' + (xm + 5) + ',' + (mid) +
2953
- ' L' + (xm - 1) + ',' + (hi - 3));
2954
- svg.appendChild(mark);
2955
- }
2956
- }
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
-
3113
2772
  // ── the keyboard on the drawing ───────────────────────────────────────
3114
2773
  /**
3115
2774
  * Every gesture the drawing has, without a pointer.
@@ -3131,6 +2790,17 @@
3131
2790
  * brush is on. Everything else the dialog can do is a real `<button>` or
3132
2791
  * `<input>` that Tab already reaches.
3133
2792
  *
2793
+ * That sentence has NO exceptions and no mode-dependence, which is a
2794
+ * property v3.6.0 Task 16 briefly broke and fix round 3 restored. Round 0
2795
+ * bound the transition jump over plain ←/→ while the 關聯線 gesture was
2796
+ * armed, so the keymap above became false for two keys at a state this
2797
+ * function itself decides — and, worse than the documentation problem,
2798
+ * several cells stopped being reachable from the keyboard at all (T9b).
2799
+ * Round 3 moved that jump to Alt+←/→, which cannot arrive here: the call
2800
+ * site is gated on `!ev.altKey`. So every Alt chord this dialog has —
2801
+ * Alt+↑/↓ for the lane, Alt+←/→ for the jump — is claimed one level up in
2802
+ * `onKeyDown` and is deliberately NOT part of this keymap.
2803
+ *
3134
2804
  * Two targets are accepted, and only two:
3135
2805
  * the canvas — the surface itself, `tabindex="0"`, so it is in the Tab
3136
2806
  * cycle and in the focus trap like every other control.
@@ -3156,6 +2826,28 @@
3156
2826
  enterDrawing();
3157
2827
  return true;
3158
2828
  }
2829
+ // v3.6.0 Task 16 fix round 3: THE ARROWS ARE PER-CELL IN EVERY STATE,
2830
+ // and the transition jump no longer lives here.
2831
+ //
2832
+ // Round 0 bound the jump over plain ← / → while armed. A jump can only
2833
+ // ever land on a transition, so that removed the keyboard's only
2834
+ // cell-precise positioning on the one surface that needs it: on
2835
+ // `p....` (transitions `[0]`) cell 1 became unreachable, and on
2836
+ // `0.1.0` (`[0,2,4]`) cells 1 and 3 did. Home/End do not rescue it —
2837
+ // they reach the two ends and nothing in between. T9b in
2838
+ // `test/editor-journey.test.js` is the invariant that caught it: the
2839
+ // keyboard's Enter/Enter route must write a BYTE-IDENTICAL source to
2840
+ // the mouse drag, and the mouse aims at exactly those cells. MEASURED
2841
+ // with the jump bound here — mouse `node: '.a'` / `node: '...b'`
2842
+ // against keyboard `node: 'a'` / `node: '....b'`: different cells,
2843
+ // different file, invariant gone.
2844
+ //
2845
+ // The jump is a convenience; per-cell movement is the primitive
2846
+ // underneath it, and a convenience may not be bound over a primitive
2847
+ // when that leaves no way back to it. So the jump moved to Alt+←/→,
2848
+ // claimed in `onKeyDown` beside Alt+↑/↓ — see that branch for why that
2849
+ // is the only home available to it (this function cannot be reached
2850
+ // with Alt held) and for what Alt means across the four arrows.
3159
2851
  if (nav) {
3160
2852
  ev.preventDefault();
3161
2853
  ev.stopPropagation();
@@ -3175,7 +2867,7 @@
3175
2867
  ev.stopPropagation();
3176
2868
  if (pendingFrom === null) {
3177
2869
  pendingFrom = { at: cursor.laneIndex, cell: cursor.cycle };
3178
- say('起點已標記,移到終點再按一次 Enter');
2870
+ panels.say('起點已標記,移到終點再按一次 Enter');
3179
2871
  render();
3180
2872
  } else {
3181
2873
  finishEdgeDrag(cursor.laneIndex, cursor.cycle);
@@ -3205,7 +2897,7 @@
3205
2897
  if (dlTarget === 'blocked') {
3206
2898
  ev.preventDefault();
3207
2899
  ev.stopPropagation();
3208
- say('這一格接在某個 bus 值後面,但看不出屬於哪一個,沒有欄位可以編輯。');
2900
+ panels.say('這一格接在某個 bus 值後面,但看不出屬於哪一個,沒有欄位可以編輯。');
3209
2901
  return true;
3210
2902
  }
3211
2903
  if (dlTarget !== null) {
@@ -3269,9 +2961,16 @@
3269
2961
  * ONE lane, a run of cycles — is exactly what a cursor plus Shift can
3270
2962
  * describe. A pointer press already collapses it to the pressed cell; a
3271
2963
  * plain arrow does the same, and Shift+←/→ keeps the anchor it was made
3272
- * with. Shift+↑/↓ collapses: a selection that spanned two lanes is not a
3273
- * thing this document model has, so pretending with a highlight would be
3274
- * the drawing telling the user something the codec cannot honour.
2964
+ * with. Shift+↑/↓ collapses the CYCLE range onto the lane it lands on.
2965
+ *
2966
+ * v3.6.0 Task 14 narrowed what that sentence claims. It used to say a
2967
+ * two-lane selection was not a thing this document model had; that is
2968
+ * no longer true — `selection.laneIndex`..`laneTo` is a lane range, and
2969
+ * 建立群組 / 訊號's 刪除 act on it. What is still true is that the
2970
+ * cycle range belongs to ONE lane (`laneIndex`), so an arrow key that
2971
+ * changes lanes has no cycle anchor left to extend from. Widening the
2972
+ * LANE range from the keyboard is a gesture this dialog does not have
2973
+ * yet; the rail's Shift+click is the one that makes it.
3275
2974
  */
3276
2975
  function moveCursor(key, extend) {
3277
2976
  const layout = geometry.layoutOf(store.doc, SIZES);
@@ -3290,10 +2989,98 @@
3290
2989
  const sameLane = lane === cursor.laneIndex;
3291
2990
  cursor = { laneIndex: lane, cycle: cyc };
3292
2991
  if (extend && sameLane && selection !== null && selection.laneIndex === lane) {
3293
- selection = { laneIndex: lane, from: selection.from, to: cyc };
2992
+ selection = { laneIndex: lane, laneTo: lane, from: selection.from, to: cyc };
3294
2993
  } else {
3295
- selection = { laneIndex: lane, from: cyc, to: cyc };
2994
+ selection = { laneIndex: lane, laneTo: lane, from: cyc, to: cyc };
2995
+ }
2996
+ repaintDrawing(layout);
2997
+ scrollCursorIntoView(layout);
2998
+ sayCursor(layout);
2999
+ }
3000
+
3001
+ /**
3002
+ * v3.6.0 Task 16: while armed, walk the cursor between this lane's
3003
+ * TRANSITIONS — `dir === 1` rightwards, `dir === -1` leftwards. Reached
3004
+ * by **Alt+←/→**, claimed in `onKeyDown`; fix round 3 moved it off plain
3005
+ * ←/→, which it had been bound over.
3006
+ *
3007
+ * What Alt+← / Alt+→ is for is picking the cell an edge will anchor to,
3008
+ * and an edge anchors at a transition. Walking one cycle at a time makes
3009
+ * a user press the key eight times to cross an eight-cycle flat run and
3010
+ * land on seven cells they cannot usefully aim at.
3011
+ *
3012
+ * It is an ADDITION to per-cell movement, never a replacement for it —
3013
+ * that distinction is the whole of fix round 3. Plain ←/→ still walks one
3014
+ * cell in every state, armed included, because a jump can only land on a
3015
+ * transition and any cell that is not one would otherwise have no
3016
+ * keyboard route at all. The POINTER could always drop an endpoint
3017
+ * anywhere along a flat run (hanging a label in the middle of a bus needs
3018
+ * exactly that); after round 3 the keyboard can too, which is what T9b
3019
+ * pins byte for byte.
3020
+ *
3021
+ * Which cells count is `geometry.transitionsOf`'s answer, the SAME one
3022
+ * `wave-draw.js` paints the dots from, so a key can never stop somewhere
3023
+ * the drawing did not put a dot. It is not re-derived here, and it is not
3024
+ * "a cycle whose level differs from the one before" — see that function's
3025
+ * own docstring for why that is a different (and wrong) rule.
3026
+ *
3027
+ * THE TWO ENDS CLAMP, and that has one consequence worth stating because
3028
+ * it looks backwards for one press: the armed cursor only ever rests on a
3029
+ * transition, so → with nothing further right goes to the RIGHTMOST
3030
+ * transition and ← with nothing further left goes to the leftmost. When
3031
+ * the cursor was parked to the right of every transition — arming does
3032
+ * not move it, so that is reachable in one step — the first → therefore
3033
+ * snaps LEFT, onto the rightmost anchor. Every press after that is a
3034
+ * no-op at the same cell. Pinned both ways in `test/editor-journey.test.js`.
3035
+ *
3036
+ * `selection` is collapsed onto the landing cell exactly as `moveCursor`'s
3037
+ * own non-extend branch does it: `paintAtCursor` paints `selection`'s run
3038
+ * whenever it belongs to the cursor's lane, so a jump that left a stale
3039
+ * run standing would make the next brush key paint a stretch of cycles
3040
+ * the cursor is no longer anywhere near. Alt+Shift+←/→ does NOT extend
3041
+ * the selection — an edge endpoint is one cell, so a jump has no range to
3042
+ * grow. Plain Shift+←/→ still extends, exactly as it always did; round 3
3043
+ * gave that back by taking the jump off the unmodified arrows.
3044
+ *
3045
+ * Ends in the same three calls `moveCursor` does, for the same reason: no
3046
+ * document content changed, so this is a repaint of the drawing and the
3047
+ * rail, not a `render()` that would also rebuild the preview to paint the
3048
+ * identical picture.
3049
+ */
3050
+ function jumpTransition(dir) {
3051
+ const layout = geometry.layoutOf(store.doc, SIZES);
3052
+ if (layout.lanes.length === 0 || layout.cycles === 0) return;
3053
+ // Arriving is not choosing — the same rule `moveCursor` and
3054
+ // `enterDrawing` already state. A first keystroke that both entered
3055
+ // the drawing and jumped somewhere would never show the user which
3056
+ // cell it started counting from.
3057
+ if (cursor === null) { enterDrawing(); return; }
3058
+ const lane = cursor.laneIndex;
3059
+ const cells = geometry.transitionsOf(store.doc, lane);
3060
+ // A lane with nothing to anchor to: WaveDrom's `{}` spacer is the
3061
+ // fixture case, and a lane whose wave is all continuations is the
3062
+ // other. Neither is an error, and neither may move the cursor to a
3063
+ // cell this rule says it is not allowed to rest on.
3064
+ if (cells.length === 0) {
3065
+ panels.say('這條 lane 上沒有轉態點可以停,關聯線接不上去');
3066
+ return;
3296
3067
  }
3068
+ const now = cursor.cycle;
3069
+ let next;
3070
+ if (dir > 0) {
3071
+ next = cells.find(function (c) { return c > now; });
3072
+ if (next === undefined) next = cells[cells.length - 1];
3073
+ } else {
3074
+ const before = cells.filter(function (c) { return c < now; });
3075
+ next = before.length === 0 ? cells[0] : before[before.length - 1];
3076
+ }
3077
+ cursor = { laneIndex: lane, cycle: next };
3078
+ selection = { laneIndex: lane, laneTo: lane, from: next, to: next };
3079
+ // `cell`, not `cycle`: `hoverBoundary` carries `geometry.boundaryAt`'s
3080
+ // own field names all the way to `drawTransitions`, which compares
3081
+ // `.cell` — the two spellings are deliberately different and a mix-up
3082
+ // fails silently there, as that function's own comment says.
3083
+ hoverBoundary = { laneIndex: lane, cell: next };
3297
3084
  repaintDrawing(layout);
3298
3085
  scrollCursorIntoView(layout);
3299
3086
  sayCursor(layout);
@@ -3340,7 +3127,7 @@
3340
3127
  const to = selection === null ? cursor.cycle : Math.max(selection.from, selection.to);
3341
3128
  const span = (selection !== null && selection.laneIndex === cursor.laneIndex && to > from)
3342
3129
  ? ',選了 cycle ' + (from + 1) + '–' + (to + 1) : '';
3343
- say('游標:' + name + ' cycle ' + (cursor.cycle + 1) + span);
3130
+ panels.say('游標:' + name + ' cycle ' + (cursor.cycle + 1) + span);
3344
3131
  }
3345
3132
 
3346
3133
  /** The lane list and the drawing, repainted together and without touching
@@ -3351,8 +3138,29 @@
3351
3138
  function repaintDrawing(layout) {
3352
3139
  const l = layout === undefined || layout === null
3353
3140
  ? geometry.layoutOf(store.doc, SIZES) : layout;
3354
- renderLanes(store.doc, l);
3355
- renderCanvas(store.doc, l);
3141
+ panels.renderLanes(store.doc, l, { selection: selection });
3142
+ const wds = {
3143
+ cursor: cursor,
3144
+ selection: selection,
3145
+ pendingFrom: pendingFrom,
3146
+ endpointDrag: endpointDrag,
3147
+ selectedEdge: selectedEdge,
3148
+ overlay: overlay,
3149
+ canvasWrap: canvasWrap,
3150
+ dataEdit: dataEdit,
3151
+ enterDrawing: enterDrawing,
3152
+ onCanvasDown: onCanvasDown,
3153
+ onCanvasHover: onCanvasHover,
3154
+ edgeMode: edgeMode,
3155
+ hoverBoundary: hoverBoundary,
3156
+ };
3157
+ canvas = drawer.renderCanvas(canvas, store.doc, l, wds);
3158
+ cursor = wds.cursor;
3159
+ selection = wds.selection;
3160
+ // AFTER the drawer, which is what clamps `selection` into the document
3161
+ // it just drew — reading it before would arm a button against a range
3162
+ // the drawing has already refused.
3163
+ paintSelectionState(store.doc, l);
3356
3164
  }
3357
3165
 
3358
3166
  /**
@@ -3461,7 +3269,7 @@
3461
3269
  // was never reachable at all.
3462
3270
  const dragged = edges.filter(function (e) { return e.index === handle.index; })[0];
3463
3271
  if (dragged !== undefined && dragged.edge.from === dragged.edge.to) {
3464
- say('這是一條 self-loop(起點跟終點在同一格),兩端的把手疊在同一個位置:' +
3272
+ panels.say('這是一條 self-loop(起點跟終點在同一格),兩端的把手疊在同一個位置:' +
3465
3273
  '只有拖得到的這一端可以搬,另一端目前搬不到。');
3466
3274
  }
3467
3275
  endpointDrag = { index: handle.index, end: handle.end, at: null, cell: null };
@@ -3480,7 +3288,26 @@
3480
3288
  // again would be the same needless cost the existing
3481
3289
  // `hadSelectedEdge`-gated branch further down in this function
3482
3290
  // already avoids.
3483
- renderCanvas(store.doc, layout);
3291
+ const wds = {
3292
+ cursor: cursor,
3293
+ selection: selection,
3294
+ pendingFrom: pendingFrom,
3295
+ endpointDrag: endpointDrag,
3296
+ selectedEdge: selectedEdge,
3297
+ overlay: overlay,
3298
+ canvasWrap: canvasWrap,
3299
+ dataEdit: dataEdit,
3300
+ enterDrawing: enterDrawing,
3301
+ onCanvasDown: onCanvasDown,
3302
+ onCanvasHover: onCanvasHover,
3303
+ edgeMode: edgeMode,
3304
+ hoverBoundary: hoverBoundary,
3305
+ };
3306
+ canvas = drawer.renderCanvas(canvas, store.doc, layout, wds);
3307
+ cursor = wds.cursor;
3308
+ selection = wds.selection;
3309
+ // The toolbar reads `selection`; this repaint can change it (fix round 1).
3310
+ paintSelectionState(store.doc, layout);
3484
3311
  d.addEventListener('mousemove', onCanvasMove, true);
3485
3312
  d.addEventListener('mouseup', onCanvasUp, true);
3486
3313
  return; // 不塗、不選格
@@ -3557,7 +3384,7 @@
3557
3384
  // reasoning as the `'blocked'` branch just below it.
3558
3385
  if (busTarget === 'blocked') {
3559
3386
  ev.preventDefault();
3560
- say('這一格接在某個 bus 值後面,但看不出屬於哪一個,沒有欄位可以編輯。');
3387
+ panels.say('這一格接在某個 bus 值後面,但看不出屬於哪一個,沒有欄位可以編輯。');
3561
3388
  return;
3562
3389
  }
3563
3390
  if (busTarget !== null) {
@@ -3565,10 +3392,34 @@
3565
3392
  const busHadSelectedEdge = selectedEdge !== null;
3566
3393
  selectedEdge = null;
3567
3394
  cursor = { laneIndex: busAt.laneIndex, cycle: busTarget.cycle };
3568
- selection = { laneIndex: busAt.laneIndex, from: busTarget.cycle, to: busTarget.cycle };
3395
+ selection = {
3396
+ laneIndex: busAt.laneIndex, laneTo: busAt.laneIndex,
3397
+ from: busTarget.cycle, to: busTarget.cycle,
3398
+ };
3569
3399
  canvas.focus();
3570
3400
  if (busHadSelectedEdge) render();
3571
- else renderCanvas(store.doc, layout);
3401
+ else {
3402
+ const wds = {
3403
+ cursor: cursor,
3404
+ selection: selection,
3405
+ pendingFrom: pendingFrom,
3406
+ endpointDrag: endpointDrag,
3407
+ selectedEdge: selectedEdge,
3408
+ overlay: overlay,
3409
+ canvasWrap: canvasWrap,
3410
+ dataEdit: dataEdit,
3411
+ enterDrawing: enterDrawing,
3412
+ onCanvasDown: onCanvasDown,
3413
+ onCanvasHover: onCanvasHover,
3414
+ edgeMode: edgeMode,
3415
+ hoverBoundary: hoverBoundary,
3416
+ };
3417
+ canvas = drawer.renderCanvas(canvas, store.doc, layout, wds);
3418
+ cursor = wds.cursor;
3419
+ selection = wds.selection;
3420
+ // The toolbar reads `selection`; this repaint can change it (fix round 1).
3421
+ paintSelectionState(store.doc, layout);
3422
+ }
3572
3423
  openDataEdit(busAt.laneIndex, busTarget.cycle);
3573
3424
  return;
3574
3425
  }
@@ -3609,7 +3460,10 @@
3609
3460
  }
3610
3461
  ev.preventDefault();
3611
3462
  drag = { laneIndex: at.laneIndex, from: at.cycle, to: at.cycle };
3612
- selection = { laneIndex: at.laneIndex, from: at.cycle, to: at.cycle };
3463
+ selection = {
3464
+ laneIndex: at.laneIndex, laneTo: at.laneIndex,
3465
+ from: at.cycle, to: at.cycle,
3466
+ };
3613
3467
  // The keyboard follows the pointer, for two reasons that are really one:
3614
3468
  // the two cursors may not disagree about which cell is current, and
3615
3469
  // `preventDefault()` above suppresses the focus a press would otherwise
@@ -3623,7 +3477,28 @@
3623
3477
  // inspector row, which lives in the toolbar `renderCanvas` never
3624
3478
  // touches.
3625
3479
  if (hadSelectedEdge) render();
3626
- else renderCanvas(store.doc, layout);
3480
+ else {
3481
+ const wds = {
3482
+ cursor: cursor,
3483
+ selection: selection,
3484
+ pendingFrom: pendingFrom,
3485
+ endpointDrag: endpointDrag,
3486
+ selectedEdge: selectedEdge,
3487
+ overlay: overlay,
3488
+ canvasWrap: canvasWrap,
3489
+ dataEdit: dataEdit,
3490
+ enterDrawing: enterDrawing,
3491
+ onCanvasDown: onCanvasDown,
3492
+ onCanvasHover: onCanvasHover,
3493
+ edgeMode: edgeMode,
3494
+ hoverBoundary: hoverBoundary,
3495
+ };
3496
+ canvas = drawer.renderCanvas(canvas, store.doc, layout, wds);
3497
+ cursor = wds.cursor;
3498
+ selection = wds.selection;
3499
+ // The toolbar reads `selection`; this repaint can change it (fix round 1).
3500
+ paintSelectionState(store.doc, layout);
3501
+ }
3627
3502
  d.addEventListener('mousemove', onCanvasMove, true);
3628
3503
  d.addEventListener('mouseup', onCanvasUp, true);
3629
3504
  }
@@ -3644,9 +3519,37 @@
3644
3519
  endpointDrag = { index: endpointDrag.index, end: endpointDrag.end,
3645
3520
  at: at.laneIndex, cell: at.cycle };
3646
3521
  }
3522
+ // `hoverBoundary` is left alone here: `drawTransitions` only ever
3523
+ // draws while `edgeMode !== 'idle'` (see its own guard, unchanged
3524
+ // by this task), and an endpoint drag never moves `edgeMode` away
3525
+ // from 'idle' (it is a wholly separate gesture from edge-creation —
3526
+ // see `endpointDrag`'s own declaration comment), so there is no dot
3527
+ // on screen for a hot mark to name here. `cellFromEvent` still
3528
+ // snaps this drag's OWN drop target to the nearest anchor via
3529
+ // `boundaryAt` — that part of Task 11 applies regardless — this is
3530
+ // only about the separate hover-dot chrome.
3647
3531
  // The document is untouched during the drag (rule 3): this repaints
3648
3532
  // the preview only, never a `commit`.
3649
- renderCanvas(store.doc, layout);
3533
+ const wds = {
3534
+ cursor: cursor,
3535
+ selection: selection,
3536
+ pendingFrom: pendingFrom,
3537
+ endpointDrag: endpointDrag,
3538
+ selectedEdge: selectedEdge,
3539
+ overlay: overlay,
3540
+ canvasWrap: canvasWrap,
3541
+ dataEdit: dataEdit,
3542
+ enterDrawing: enterDrawing,
3543
+ onCanvasDown: onCanvasDown,
3544
+ onCanvasHover: onCanvasHover,
3545
+ edgeMode: edgeMode,
3546
+ hoverBoundary: hoverBoundary,
3547
+ };
3548
+ canvas = drawer.renderCanvas(canvas, store.doc, layout, wds);
3549
+ cursor = wds.cursor;
3550
+ selection = wds.selection;
3551
+ // The toolbar reads `selection`; this repaint can change it (fix round 1).
3552
+ paintSelectionState(store.doc, layout);
3650
3553
  return;
3651
3554
  }
3652
3555
  if (edgeMode === 'dragging') {
@@ -3659,10 +3562,38 @@
3659
3562
  // preview line — exactly where it last was, rather than snapping the
3660
3563
  // line away: the drop target the user is currently over is still the
3661
3564
  // last cell the pointer was actually on top of.
3662
- if (at !== null) cursor = { laneIndex: at.laneIndex, cycle: at.cycle };
3565
+ if (at !== null) {
3566
+ cursor = { laneIndex: at.laneIndex, cycle: at.cycle };
3567
+ // v3.6.0 Task 11: the hot dot keeps following the drag once it
3568
+ // starts, rather than freezing at whatever it last showed during
3569
+ // the pre-press hover (or never lighting up at all, if the drag
3570
+ // started from a keyboard Enter with no hover first) — same
3571
+ // `cycle`-to-`cell` conversion as `onCanvasHover`, at this same
3572
+ // boundary.
3573
+ hoverBoundary = { laneIndex: at.laneIndex, cell: at.cycle };
3574
+ }
3663
3575
  // The document is untouched during the drag (rule 2): this repaints
3664
3576
  // the preview line only, never a `commit`.
3665
- renderCanvas(store.doc, layout);
3577
+ const wds = {
3578
+ cursor: cursor,
3579
+ selection: selection,
3580
+ pendingFrom: pendingFrom,
3581
+ endpointDrag: endpointDrag,
3582
+ selectedEdge: selectedEdge,
3583
+ overlay: overlay,
3584
+ canvasWrap: canvasWrap,
3585
+ dataEdit: dataEdit,
3586
+ enterDrawing: enterDrawing,
3587
+ onCanvasDown: onCanvasDown,
3588
+ onCanvasHover: onCanvasHover,
3589
+ edgeMode: edgeMode,
3590
+ hoverBoundary: hoverBoundary,
3591
+ };
3592
+ canvas = drawer.renderCanvas(canvas, store.doc, layout, wds);
3593
+ cursor = wds.cursor;
3594
+ selection = wds.selection;
3595
+ // The toolbar reads `selection`; this repaint can change it (fix round 1).
3596
+ paintSelectionState(store.doc, layout);
3666
3597
  return;
3667
3598
  }
3668
3599
  if (drag === null) return;
@@ -3679,11 +3610,33 @@
3679
3610
  // The lane is fixed by the press: a drag that strays onto a neighbour is
3680
3611
  // still painting the lane it started on.
3681
3612
  drag.to = at.cycle;
3682
- selection = { laneIndex: drag.laneIndex, from: drag.from, to: drag.to };
3613
+ selection = {
3614
+ laneIndex: drag.laneIndex, laneTo: drag.laneIndex,
3615
+ from: drag.from, to: drag.to,
3616
+ };
3683
3617
  // The drag's live end is where the cursor is, so a paint continued with
3684
3618
  // Shift+←/→ after the button comes up carries on from the same cell.
3685
3619
  cursor = { laneIndex: drag.laneIndex, cycle: drag.to };
3686
- renderCanvas(store.doc, layout);
3620
+ const wds = {
3621
+ cursor: cursor,
3622
+ selection: selection,
3623
+ pendingFrom: pendingFrom,
3624
+ endpointDrag: endpointDrag,
3625
+ selectedEdge: selectedEdge,
3626
+ overlay: overlay,
3627
+ canvasWrap: canvasWrap,
3628
+ dataEdit: dataEdit,
3629
+ enterDrawing: enterDrawing,
3630
+ onCanvasDown: onCanvasDown,
3631
+ onCanvasHover: onCanvasHover,
3632
+ edgeMode: edgeMode,
3633
+ hoverBoundary: hoverBoundary,
3634
+ };
3635
+ canvas = drawer.renderCanvas(canvas, store.doc, layout, wds);
3636
+ cursor = wds.cursor;
3637
+ selection = wds.selection;
3638
+ // The toolbar reads `selection`; this repaint can change it (fix round 1).
3639
+ paintSelectionState(store.doc, layout);
3687
3640
  }
3688
3641
 
3689
3642
  function onCanvasUp() {
@@ -3837,11 +3790,85 @@
3837
3790
  render();
3838
3791
  }
3839
3792
 
3840
- /** Pointer coordinates in the drawing's own space, then the codec's cell. */
3793
+ /**
3794
+ * Pointer coordinates in the drawing's own space, then the codec's cell.
3795
+ *
3796
+ * v3.6.0 Task 11: while an edge gesture is live — `edgeMode !== 'idle'`
3797
+ * (armed, marking a start; or dragging, aiming the other end) or an
3798
+ * `endpointDrag` is in flight — the target is the ANCHOR the engine
3799
+ * actually draws the line to, not the cell under the pointer.
3800
+ * `geometry.boundaryAt` rounds to the nearest one; `geometry.cellAt`
3801
+ * floors to the cell the pointer is over. Painting still wants the
3802
+ * floor: the brush's target is the cell you are over, and unifying the
3803
+ * two here is exactly the regression this task exists to prevent (see
3804
+ * `boundaryAt`'s own comment in `wave-geometry.js`).
3805
+ *
3806
+ * `boundaryAt` returns `{laneIndex, cell}`; every caller of this
3807
+ * function already destructures `{laneIndex, cycle}` from `cellAt`'s
3808
+ * shape, so the field is renamed HERE, at the one place the two
3809
+ * definitions meet, rather than letting a `cell` leak into a `cycle`
3810
+ * slot downstream.
3811
+ */
3841
3812
  function cellFromEvent(ev, layout) {
3842
3813
  if (canvas === null) return null;
3843
3814
  const box = canvas.getBoundingClientRect();
3844
- return geometry.cellAt(layout, ev.clientX - box.left, ev.clientY - box.top);
3815
+ const x = ev.clientX - box.left;
3816
+ const y = ev.clientY - box.top;
3817
+ if (edgeMode !== 'idle' || endpointDrag !== null) {
3818
+ const b = geometry.boundaryAt(layout, x, y);
3819
+ return b === null ? null : { laneIndex: b.laneIndex, cycle: b.cell };
3820
+ }
3821
+ return geometry.cellAt(layout, x, y);
3822
+ }
3823
+
3824
+ /**
3825
+ * Pure hover, no button down, nothing committed — which anchor the
3826
+ * pointer is nearest while armed and not yet dragging, purely so
3827
+ * `drawTransitions`'s hot dot can light up before the user presses.
3828
+ *
3829
+ * A canvas-own listener rather than the document-level
3830
+ * `onCanvasMove`/`onCanvasUp` pair: those two are only added once a
3831
+ * drag actually starts (`onCanvasDown`'s 'armed' and endpoint-drag
3832
+ * branches), so a plain hover before the first press has nothing else
3833
+ * watching it. Once a drag IS live, `onCanvasMove`'s own branches
3834
+ * already repaint on every move and set `hoverBoundary` themselves
3835
+ * (below), so this steps out of the way rather than doing the same
3836
+ * work under a second name — the `edgeMode !== 'armed'` guard covers
3837
+ * both: `edgeMode === 'dragging'` fails it directly, and an
3838
+ * `endpointDrag` never moves `edgeMode` away from 'idle' in the first
3839
+ * place (it is a wholly separate gesture from edge-creation), so it
3840
+ * fails the same guard too.
3841
+ */
3842
+ function onCanvasHover(ev) {
3843
+ if (edgeMode !== 'armed') return;
3844
+ const layout = geometry.layoutOf(store.doc, SIZES);
3845
+ const at = cellFromEvent(ev, layout);
3846
+ const next = at === null ? null : { laneIndex: at.laneIndex, cell: at.cycle };
3847
+ const same = (hoverBoundary === null && next === null) ||
3848
+ (hoverBoundary !== null && next !== null &&
3849
+ hoverBoundary.laneIndex === next.laneIndex && hoverBoundary.cell === next.cell);
3850
+ if (same) return;
3851
+ hoverBoundary = next;
3852
+ const wds = {
3853
+ cursor: cursor,
3854
+ selection: selection,
3855
+ pendingFrom: pendingFrom,
3856
+ endpointDrag: endpointDrag,
3857
+ selectedEdge: selectedEdge,
3858
+ overlay: overlay,
3859
+ canvasWrap: canvasWrap,
3860
+ dataEdit: dataEdit,
3861
+ enterDrawing: enterDrawing,
3862
+ onCanvasDown: onCanvasDown,
3863
+ onCanvasHover: onCanvasHover,
3864
+ edgeMode: edgeMode,
3865
+ hoverBoundary: hoverBoundary,
3866
+ };
3867
+ canvas = drawer.renderCanvas(canvas, store.doc, layout, wds);
3868
+ cursor = wds.cursor;
3869
+ selection = wds.selection;
3870
+ // The toolbar reads `selection`; this repaint can change it (fix round 1).
3871
+ paintSelectionState(store.doc, layout);
3845
3872
  }
3846
3873
 
3847
3874
  /**
@@ -3854,9 +3881,15 @@
3854
3881
  *
3855
3882
  * 1. `geometry.edgeHandleAt` — a handle is a small rect centred on an
3856
3883
  * 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
3884
+ * uses (the canvas's own `getBoundingClientRect()`). v3.6.0 Task 6:
3885
+ * `SIZES.nameColWidth` is 120, not 0 — but `ev.clientX - box.left`
3886
+ * still needs nothing added or subtracted for it, because the name
3887
+ * column's width is already baked into every cell's own `originX`
3888
+ * (`wave-geometry.layoutOf`), the same layout-local coordinate
3889
+ * `geometry.edgeHandleAt`/`cellAt` read. One coordinate space, not
3890
+ * two reconciled by an offset — a nonzero name column moves where
3891
+ * cycle 0 STARTS in that space, it does not add a second term on
3892
+ * top of it. final review finding 4: trusted when it names the
3860
3893
  * edge that is actually SELECTED — `renderEdges` only DRAWS a
3861
3894
  * handle on the selected edge, so its answer for any OTHER edge is a
3862
3895
  * rectangle with nothing on screen, and letting that phantom
@@ -3975,48 +4008,6 @@
3975
4008
  return isLabel(ev.target) ? ev.target : null;
3976
4009
  }
3977
4010
 
3978
- // ── the preview ───────────────────────────────────────────────────────
3979
- /**
3980
- * The engine's own rendering of the very same document.
3981
- *
3982
- * This is the check on the drawing, which is the only reason it is on
3983
- * screen. It is rendered from a plain copy because wavedrom writes into the
3984
- * source object it is handed and the store's documents are frozen.
3985
- */
3986
- function renderPreview(doc) {
3987
- const engine = opts.wavedrom;
3988
- if (engine === null || engine === undefined ||
3989
- typeof engine.RenderWaveForm !== 'function') {
3990
- previewHost.textContent = '(這一頁沒有載入 WaveDrom,預覽從缺)';
3991
- previewWrap.setAttribute('data-wave-preview', 'absent');
3992
- return;
3993
- }
3994
- try {
3995
- // `notFirstSignal` means "the skin is already on this page, do not
3996
- // emit it again". True whenever a WaveDrom render has already run here,
3997
- // which is always in the flow that reaches this editor — the entry
3998
- // affordance only appears over a RENDERED diagram. The fallback to
3999
- // `false` is for the page where it somehow has not: emitting the skin
4000
- // is only wasteful, while omitting one that is not there leaves every
4001
- // `<use>` unresolvable and the preview blank, which is the one state
4002
- // this panel may never be in.
4003
- // `socket` is a bare, global, un-namespaced id that the engine's own
4004
- // skin `<defs>` defines — so ask for it INSIDE a WaveDrom-rendered SVG's
4005
- // defs rather than anywhere on the page. A document carrying inline
4006
- // HTML with `id="socket"` would otherwise convince this that the skin is
4007
- // present on a page where no diagram had rendered, and the preview would
4008
- // come up blank: the one state this panel may never be in.
4009
- const skinOnPage = d.querySelector('svg.WaveDrom defs #socket') !== null;
4010
- engine.RenderWaveForm(PREVIEW_INDEX, plainCopy(doc, 0),
4011
- PREVIEW_ID_PREFIX, skinOnPage);
4012
- previewWrap.setAttribute('data-wave-preview', 'ok');
4013
- previewWrap.setAttribute('data-wave-preview-skin', skinOnPage ? 'shared' : 'own');
4014
- } catch (e) {
4015
- previewHost.textContent = 'WaveDrom 畫不出這份文件:' + String(e && e.message);
4016
- previewWrap.setAttribute('data-wave-preview', 'failed');
4017
- }
4018
- }
4019
-
4020
4011
  // Mounted BEFORE the first paint, not after: the preview is rendered by the
4021
4012
  // engine's own `RenderWaveForm`, which resolves its target with
4022
4013
  // `getElementById` — an element that is not in the document yet is not
@@ -4029,8 +4020,24 @@
4029
4020
  render();
4030
4021
  // The opening line described a pointer and only a pointer, which is how a
4031
4022
  // surface ends up with no keyboard: the instructions never mentioned one.
4032
- say('選一個電位,然後在波形上按住拖過去;或直接按方向鍵進波形,' +
4033
- 'Shift+左右鍵選一段,再按電位字元塗上去。');
4023
+ //
4024
+ // v3.6.0 Task 16 fix round 1 applies that same reasoning to this task's
4025
+ // own gesture. Reordering a lane became keyboard-reachable here, and the
4026
+ // only place that said so was a HOVER tooltip on the rail's drag handle
4027
+ // — a `<span>` with deliberately no focus key, i.e. the one surface a
4028
+ // keyboard user can never read. This is a one-shot line, not the
4029
+ // per-move announcement `sayCursor` owns, so a hint belongs in it (see
4030
+ // `sayCursor`'s own docstring for why the same hint may NOT go there).
4031
+ //
4032
+ // fix round 3 re-points the last clause. Round 1 wrote it as an EXCEPTION
4033
+ // ("武裝關聯線之後,左右鍵改成在轉態點之間跳") because round 0's binding
4034
+ // really did change what two keys this same sentence had just promised.
4035
+ // Round 3 moved the jump to Alt+←/→, so the promise above holds in every
4036
+ // state and the clause is now an extra key rather than a retraction.
4037
+ panels.say('選一個電位,然後在波形上按住拖過去;或直接按方向鍵進波形,' +
4038
+ 'Shift+左右鍵選一段,再按電位字元塗上去。' +
4039
+ 'Alt+↑/Alt+↓ 把選取的 lane 往上下搬;' +
4040
+ '武裝關聯線之後,Alt+左右鍵可以在轉態點之間跳。');
4034
4041
 
4035
4042
  return {
4036
4043
  el: overlay,
@@ -4043,8 +4050,6 @@
4043
4050
 
4044
4051
  return {
4045
4052
  createWaveEditor: createWaveEditor,
4046
- brickOf: brickOf,
4047
- groupSpansOf: groupSpansOf,
4048
4053
  mountedInsideContent: mountedInsideContent,
4049
4054
  BRUSHES: BRUSHES,
4050
4055
  isBrushKey: isBrushKey,