@helping-ai-workflow/md2doc 3.4.2 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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:
@@ -57,12 +58,71 @@
57
58
 
58
59
  const SVGNS = 'http://www.w3.org/2000/svg';
59
60
 
60
- /** The brush palette, exactly the set the spec names, in that order. */
61
- const BRUSHES = ['0', '1', 'x', 'z', 'p', 'n', 'h', 'l', 'u', 'd', '='];
61
+ /**
62
+ * The brush palette, exactly the set the spec names, in that order.
63
+ *
64
+ * v3.5.0 Task 10 widened this from 11 to 22: `P`/`N` (explicit-edge
65
+ * clocks — see the arrow measurement in `drawLane`'s isClock branch),
66
+ * `2`-`9` (bus levels beyond the plain `=`, already understood by
67
+ * `brickOf` and `codec.levelsOf` but never offered as a brush), and `|`
68
+ * (the gap marker) placed last because it is not a level at all —
69
+ * `codec.levelsOf('=.|')` measured it as "carry the previous level", so
70
+ * painting it does not change what a cycle shows, only that it shows a
71
+ * discontinuity.
72
+ */
73
+ const BRUSHES = ['0', '1', 'x', 'z', 'p', 'n', 'P', 'N', 'h', 'l', 'u', 'd',
74
+ '=', '2', '3', '4', '5', '6', '7', '8', '9', '|'];
75
+
76
+ /**
77
+ * Whether a `KeyboardEvent.key` value both PICKS and PAINTS a brush —
78
+ * `handleDrawingKey` (below `enterDrawing`) tests this exact call, not a
79
+ * copy of it, so the admission rule and what the keyboard can actually
80
+ * reach cannot drift apart the way the module's own prologue warns about
81
+ * (rule 3: two beliefs about the same thing is the failure this batch
82
+ * keeps producing). Named and exported on its own so a test can pin
83
+ * "every brush is keyboard-reachable" against the real rule instead of a
84
+ * re-implementation of it — see `wave-codec.test.js`'s
85
+ * `isBrushKey`-roster assertion.
86
+ *
87
+ * `key.length === 1` is what actually excludes navigation: every
88
+ * `BRUSHES` entry is one character, while `ArrowLeft`/`ArrowRight`/
89
+ * `ArrowUp`/`ArrowDown`/`Home`/`End` are multi-character key NAMES, so
90
+ * `BRUSHES.indexOf(key)` is already `-1` for all six regardless of this
91
+ * length check — it is kept anyway so that invariant is spelled out at
92
+ * the point of use rather than left to rely on `BRUSHES` never growing a
93
+ * multi-character entry by mistake. Deliberately NOT keyed on
94
+ * `ev.shiftKey`: v3.5.0 Task 10 added `P`/`N` (and `|` sits next to `\`
95
+ * on a US layout) to `BRUSHES`, and all three are typed WITH Shift on an
96
+ * ordinary keyboard, so a shiftKey exclusion here would make them
97
+ * mouse-only — see Task 10's fix-round report for the full reasoning on
98
+ * why dropping it does not reopen the Shift+arrow risk the old comment
99
+ * named (that risk is excluded above, by `nav`, on `key` identity, not on
100
+ * `ev.shiftKey`).
101
+ */
102
+ function isBrushKey(key) {
103
+ return typeof key === 'string' && key.length === 1 && BRUSHES.indexOf(key) !== -1;
104
+ }
62
105
 
63
106
  /** Drawing sizes. Handed to `wave-geometry` so the hit test and the paint
64
- * cannot drift; nothing here re-derives a row position. */
65
- 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 };
66
126
 
67
127
  /**
68
128
  * The index the preview is rendered under, and why it is not 0.
@@ -89,52 +149,6 @@
89
149
  const PREVIEW_INDEX = 9000;
90
150
  const PREVIEW_ID_PREFIX = 'md2doc-wave-preview-';
91
151
 
92
- /**
93
- * What wavedrom 3.5.0 actually draws for one level, by its own brick id.
94
- *
95
- * Every row was MEASURED in this session against the pinned engine, by
96
- * rendering `wave: 'z' + c + c` (the `z` anchors the char so it is never at
97
- * cycle 0) and reading the second half-brick of each cycle out of the
98
- * rendered SVG's `<use xlink:href="#…">` list:
99
- *
100
- * 0 000 1 111 x xxx z zzz u uuu d ddd
101
- * h 111 l 000 H 111 L 000
102
- * p nclk P nclk n pclk N pclk
103
- * = vvv-2 2 vvv-2 3 vvv-3 … 9 vvv-9
104
- * anything else xxx (measured with 'Q' and 'ä')
105
- *
106
- * So this is not a re-implementation of the engine's level model — `levelsOf`
107
- * is that, and it lives in the codec. This is the last step: which PICTURE
108
- * the engine paints for a level it has already decided. Keeping it in
109
- * production code rather than in the test is deliberate: the drawing has to
110
- * make exactly this decision anyway (an `h` lane is drawn high, an `=` lane
111
- * is drawn as a bus), and a copy of it living only in a test would be a
112
- * second belief about the engine with nothing pinning it to the first.
113
- */
114
- function brickOf(level) {
115
- if (typeof level !== 'string' || level.length === 0) return 'xxx';
116
- if (level === '0' || level === 'l' || level === 'L') return '000';
117
- if (level === '1' || level === 'h' || level === 'H') return '111';
118
- if (level === 'z') return 'zzz';
119
- if (level === 'u') return 'uuu';
120
- if (level === 'd') return 'ddd';
121
- if (level === 'p' || level === 'P') return 'nclk';
122
- if (level === 'n' || level === 'N') return 'pclk';
123
- if (level === '=') return 'vvv-2';
124
- if (level >= '2' && level <= '9') return 'vvv-' + level;
125
- return 'xxx';
126
- }
127
-
128
- /** A brick that draws a whole clock period and therefore never merges with
129
- * its neighbour: two `p` cycles are two clock periods, not one long one. */
130
- function isClock(brick) {
131
- return brick === 'nclk' || brick === 'pclk';
132
- }
133
-
134
- function isBus(brick) {
135
- return brick.slice(0, 4) === 'vvv-';
136
- }
137
-
138
152
  /**
139
153
  * Whether `el` is inside the editor's serialisation surface.
140
154
  *
@@ -152,28 +166,6 @@
152
166
  return false;
153
167
  }
154
168
 
155
- /**
156
- * A plain, unfrozen copy of a document.
157
- *
158
- * The store's documents are deep-frozen — that is what stops a stray write
159
- * from corrupting an undo entry — and wavedrom's renderer WRITES INTO the
160
- * source object it is handed. Under this file's strict prologue that is a
161
- * throw, so the preview gets its own copy every time. Bounded by the codec's
162
- * own nesting ceiling so a pathological document cannot run the stack out
163
- * here either.
164
- */
165
- function plainCopy(value, depth) {
166
- if (depth > 64 || value === null || typeof value !== 'object') return value;
167
- if (Array.isArray(value)) {
168
- const out = [];
169
- for (let i = 0; i < value.length; i++) out.push(plainCopy(value[i], depth + 1));
170
- return out;
171
- }
172
- const out = {};
173
- for (const key of Object.keys(value)) out[key] = plainCopy(value[key], depth + 1);
174
- return out;
175
- }
176
-
177
169
  /** Line and column of a byte offset, 1-based — what a parse failure has to
178
170
  * say beyond its own message for the offset to mean anything to a person. */
179
171
  function whereIs(text, offset) {
@@ -182,59 +174,14 @@
182
174
  return { line: lines.length, column: lines[lines.length - 1].length + 1 };
183
175
  }
184
176
 
185
- /**
186
- * The group titles in the lane tree, each with the span of display rows it
187
- * covers.
188
- *
189
- * A group title occupies NO ROW — measured in Task 2 against the pinned
190
- * engine, and `wave-geometry` is built on it. What it occupies is horizontal
191
- * space in the name column, so that is where this puts it: a label in the
192
- * lane list's rail, spanning its lanes' rows. Drawing it as a row of its own
193
- * would put the lane list one row out of step with the waveform beside it for
194
- * every lane below the group, which is exactly the class of silent
195
- * disagreement this batch keeps producing.
196
- *
197
- * The span is derived from the lanes' own paths — `layout.lanes[i].path` is
198
- * the codec's — so a group with no lanes under it has no span and is not
199
- * shown here; it is not addressable through the bridge either.
200
- */
201
- function groupSpansOf(doc, layout) {
202
- const byKey = new Map();
203
- for (let i = 0; i < layout.lanes.length; i++) {
204
- const path = layout.lanes[i].path;
205
- // Every proper ancestor between `signal` and the lane itself is a group.
206
- for (let cut = 2; cut < path.length; cut++) {
207
- const prefix = path.slice(0, cut);
208
- const key = JSON.stringify(prefix);
209
- const seen = byKey.get(key);
210
- if (seen === undefined) {
211
- byKey.set(key, { path: prefix, depth: cut - 1, from: i, to: i });
212
- } else {
213
- seen.to = i;
214
- }
215
- }
216
- }
217
- const out = [];
218
- for (const span of byKey.values()) {
219
- const container = valueAt(doc, span.path);
220
- const title = Array.isArray(container) && typeof container[0] === 'string'
221
- ? container[0] : null;
222
- if (title === null) continue; // an untitled group has nothing to show
223
- out.push({ path: span.path, title: title, depth: span.depth,
224
- from: span.from, to: span.to });
225
- }
226
- out.sort(function (a, b) { return a.from - b.from || a.depth - b.depth; });
227
- return out;
228
- }
229
-
230
- function valueAt(doc, path) {
231
- let node = doc;
232
- for (const seg of path) {
233
- if (node === null || typeof node !== 'object') return undefined;
234
- node = node[seg];
235
- }
236
- return node;
237
- }
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.
238
185
 
239
186
  /** The labels a lane's `data` supplies, one per explicit value cycle. */
240
187
  function labelsOf(lane) {
@@ -285,9 +232,63 @@
285
232
  throw new Error('wave-ui: the overlay may not be mounted inside .content ' +
286
233
  '(its children are serialised back into the user markdown)');
287
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
+ }
288
252
 
289
253
  const store = opts.createStore(opts.source);
290
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
+
291
292
  const overlay = d.createElement('div');
292
293
  overlay.className = 'ed-wave-overlay';
293
294
  overlay.setAttribute('role', 'dialog');
@@ -302,15 +303,32 @@
302
303
  // closing.
303
304
  let brush = '1';
304
305
  let clip = null;
305
- 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;
306
319
  let drag = null; // live pointer drag, never pushed until mouseup
307
320
  let gestures = 0;
308
321
  let lastPatch = null;
309
322
  // Where the keyboard should end up after the NEXT repaint, when that is not
310
- // simply "wherever it was". A move button is the case that needs it: the
311
- // control the user pressed belongs to a row, and after the move the lane
312
- // they are pushing around is at a different row — so pressing ▲ twice has
313
- // 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.)
314
332
  let focusOverride = null;
315
333
  // Where the KEYBOARD is on the drawing: one cell, and the keyboard's whole
316
334
  // equivalent of the pointer. Null until the keyboard has actually entered
@@ -321,6 +339,102 @@
321
339
  // never end up pointing at different cells: painting with `1` after a click
322
340
  // paints the cell that was clicked.
323
341
  let cursor = null;
342
+ // The `edge` array entry the inspector row edits — the codec's ORIGINAL
343
+ // index into `doc.edge`, exactly what `geometry.edgeLayout` reports as
344
+ // `index` (NOT the filtered array position; a dangling entry ahead of it
345
+ // is skipped there and would otherwise shift everything after it out of
346
+ // step). Null means no edge is selected, and that is the whole of the
347
+ // inspector row's visibility rule — see `renderEdgeBar`. `render()`
348
+ // clamps it to null the moment the edge it names stops being drawable
349
+ // (deleted, or its entry no longer parses), so a stale index can never
350
+ // reach `renderEdgeBar` or a shape/label commit.
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;
358
+ // v3.5.0 Task 7: the edge-CREATION gesture's own state, shared verbatim
359
+ // by the pointer and the keyboard — one state machine, two input sources.
360
+ // `edgeMode` is explicit rather than inferred from "is a key held" or "is
361
+ // the mouse button down", for the same reason the brush canvas keeps
362
+ // `drag` as its own variable instead of asking `ev.buttons`: a painting
363
+ // drag and an edge-creation drag must never be read as each other, and a
364
+ // named mode is what lets `onCanvasDown` choose between them up front.
365
+ // 'idle' — the 關聯線 button is off; every press paints or selects.
366
+ // 'armed' — pressed once; the NEXT thing (a press, or a cell plus
367
+ // Enter) marks the start of a line.
368
+ // 'dragging' — the mouse is down after that press; every move only
369
+ // repaints a preview, the document is untouched.
370
+ // `pendingFrom` is the marked start cell, `{at, cell}` in the same two
371
+ // numbers `codec.ensureNode`/`geometry.cellRect` already take — the
372
+ // codec's flattened lane index and the cycle inside it. It is cleared
373
+ // only when `setEdgeMode` moves all the way back to 'idle': going back to
374
+ // 'armed' after a drop that produced nothing (rule 1 below) deliberately
375
+ // LEAVES it standing, so a keyboard user who marked a start and then
376
+ // pressed Enter over an empty row does not have to re-mark it — the next
377
+ // Enter still means "finish from where I already stood".
378
+ let edgeMode = 'idle';
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;
389
+ /** The one place `edgeMode` changes. Keeps the arm button's own visual
390
+ * state, the overlay's `data-wave-edgemode` (what a test or a future
391
+ * task reads instead of reaching into this closure) and the "is a start
392
+ * cell marked" invariant all moving together. */
393
+ function setEdgeMode(next) {
394
+ edgeMode = next;
395
+ edgeBtn.setAttribute('aria-pressed', next === 'idle' ? 'false' : 'true');
396
+ edgeBtn.classList.toggle('is-on', next !== 'idle');
397
+ overlay.setAttribute('data-wave-edgemode', next);
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;
406
+ }
407
+ // v3.5.0 Task 8: dragging either END of the SELECTED edge to another
408
+ // cell. `{index, end, at, cell} | null` — `index` is the same original
409
+ // `doc.edge` index `selectedEdge` names (a drag can only start from that
410
+ // edge's own drawn handle, see `onCanvasDown`), `end` is `'from'` or
411
+ // `'to'`, and `at`/`cell` are the live drop target, `null` until the
412
+ // first `pointermove` lands on a real cell.
413
+ //
414
+ // Unlike `edgeMode`/`pendingFrom` — two separate variables that Task 7
415
+ // spent three fix rounds re-synchronising after one could go stale while
416
+ // the other stood — this is ONE variable that is both "is a drag
417
+ // happening" and "what does it know so far". There is no second flag to
418
+ // fall out of step with it: `onCanvasMove`/`onCanvasUp` test this value
419
+ // itself, so nulling it is the whole of cancelling the gesture, in one
420
+ // place, atomically. It still has to be threaded through the same two
421
+ // places Task 7's marks are, though, because the hazard those rounds
422
+ // found is not particular to `pendingFrom` — it is "a mark naming a lane
423
+ // outlives the document that lane numbering was valid in":
424
+ // - `carryMarks` below carries `at` by lane IDENTITY, exactly like
425
+ // `pendingFrom.at`, for the same reason: a structural undo/redo can
426
+ // land while this drag is in flight (the keyboard stays live while a
427
+ // mouse button is held — Ctrl+Z does not care what the mouse is
428
+ // doing), and a raw row number surviving that would let the eventual
429
+ // drop's `moveEdgeEnd` bounds-check successfully against a lane the
430
+ // user was never actually pointing at.
431
+ // - `render()`'s own `selectedEdge` clamp is widened to drop this too:
432
+ // `index` can only ever be reached from `selectedEdge`'s own drawn
433
+ // handle, so the two may never disagree — a drag whose edge stops
434
+ // being drawable (deleted, or its selection cleared by that same
435
+ // mid-drag undo) has to die with it rather than commit a move onto
436
+ // whatever entry now happens to sit at that array position.
437
+ let endpointDrag = null;
324
438
  // The document the marks' lane numbers are numbers INTO.
325
439
  //
326
440
  // `cursor` and `selection` name a ROW, and a row number means a different
@@ -344,6 +458,14 @@
344
458
  // a rename field ran `close('escape')` and threw the WHOLE session away,
345
459
  // which is the most expensive answer available to「我改名改到一半不想改了」.
346
460
  let groupRename = null;
461
+ // v3.5.0 Task 11: the bus data-label field while it is open — this
462
+ // dialog's OTHER sub-panel, same shape as `groupRename` above (a
463
+ // `{cancel, retire}` pair) and layered the same way in `onKeyDown`'s
464
+ // Escape handling. Unlike the rail's rename field, this one's node lives
465
+ // beside the canvas SVG that `renderCanvas` rebuilds on every cursor
466
+ // move, not only on a structural edit — so `retire()` is called from
467
+ // THERE, not only from `render()` (see `renderCanvas`'s own first line).
468
+ let dataEdit = null;
347
469
 
348
470
  // ── the modal focus model ─────────────────────────────────────────────
349
471
  //
@@ -461,6 +583,7 @@
461
583
  if (closed) return;
462
584
  closed = true;
463
585
  drag = null;
586
+ endpointDrag = null;
464
587
  if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
465
588
  d.removeEventListener('keydown', onKeyDown, true);
466
589
  d.removeEventListener('mousemove', onCanvasMove, true);
@@ -530,6 +653,27 @@
530
653
  // user had made, and the one Ctrl+Z that takes a discard back is not
531
654
  // something the keystroke announces.
532
655
  if (groupRename !== null) { groupRename.cancel(); return; }
656
+ // v3.5.0 Task 11: the bus data-label field, this dialog's OTHER
657
+ // text sub-panel — same layering reason as `groupRename` just
658
+ // above: without this, Escape typed while labelling a bus value
659
+ // discarded the whole editing session instead of just the field.
660
+ if (dataEdit !== null) { dataEdit.cancel(); return; }
661
+ // v3.5.0 Task 7: the armed/dragging edge gesture is this dialog's
662
+ // OTHER sub-panel-shaped thing — not a text field, but still a
663
+ // half-done action standing in front of the dialog's own Escape.
664
+ // Cancelling it never touches the document (rule 2: nothing was
665
+ // written yet), so there is no undo step to leave behind either.
666
+ if (edgeMode !== 'idle') { setEdgeMode('idle'); render(); return; }
667
+ // v3.5.0 Task 8: a third sub-panel-shaped thing, same rule — the
668
+ // document is untouched during an endpoint drag (rule 3), so
669
+ // cancelling it is nulling the one variable that both is and knows
670
+ // the drag, then a repaint. The mousemove/mouseup listeners are left
671
+ // attached exactly as `edgeMode`'s own cancel above leaves them:
672
+ // `onCanvasUp` unconditionally removes both at its very first line
673
+ // whenever the button eventually comes up, so a stray move in
674
+ // between finds `endpointDrag === null`, falls through every other
675
+ // branch and repaints nothing — no listener leak, no second rule.
676
+ if (endpointDrag !== null) { endpointDrag = null; render(); return; }
533
677
  close('escape');
534
678
  return;
535
679
  }
@@ -548,6 +692,114 @@
548
692
  next.focus();
549
693
  return;
550
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
+ }
551
803
  // The drawing's own keys, claimed here rather than on the canvas element
552
804
  // so they are decided in the same place — and in the same phase — as
553
805
  // Escape, Tab and undo/redo. Deliberately BELOW those three (a cell
@@ -577,14 +829,14 @@
577
829
  ev.preventDefault();
578
830
  ev.stopPropagation();
579
831
  if (store.undo()) afterStoreMoved('undo');
580
- else say('沒有可以復原的動作');
832
+ else panels.say('沒有可以復原的動作');
581
833
  return;
582
834
  }
583
835
  if (key === 'y' || (key === 'z' && ev.shiftKey)) {
584
836
  ev.preventDefault();
585
837
  ev.stopPropagation();
586
838
  if (store.redo()) afterStoreMoved('redo');
587
- else say('沒有可以重做的動作');
839
+ else panels.say('沒有可以重做的動作');
588
840
  }
589
841
  }
590
842
  d.addEventListener('keydown', onKeyDown, true);
@@ -668,13 +920,101 @@
668
920
  marksDoc = store.doc;
669
921
 
670
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.
671
928
  const toolbar = d.createElement('div');
672
- toolbar.className = 'ed-wave-toolbar';
929
+ toolbar.className = 'ed-wave-toolbar ed-wave-tools';
673
930
  panel.appendChild(toolbar);
674
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);
675
1015
  const brushBar = d.createElement('div');
676
1016
  brushBar.className = 'ed-wave-brushes';
677
- toolbar.appendChild(brushBar);
1017
+ brushGroup.appendChild(brushBar);
678
1018
  const brushButtons = [];
679
1019
  for (const ch of BRUSHES) {
680
1020
  const b = d.createElement('button');
@@ -682,7 +1022,21 @@
682
1022
  b.className = 'ed-wave-brush';
683
1023
  b.setAttribute('data-brush', ch);
684
1024
  b.setAttribute('data-focus-key', 'brush-' + ch);
685
- 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);
686
1040
  b.addEventListener('click', function () {
687
1041
  brush = ch;
688
1042
  paintBrushState();
@@ -690,6 +1044,76 @@
690
1044
  brushBar.appendChild(b);
691
1045
  brushButtons.push(b);
692
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
+
693
1117
  function paintBrushState() {
694
1118
  for (const b of brushButtons) {
695
1119
  b.classList.toggle('is-on', b.getAttribute('data-brush') === brush);
@@ -697,114 +1121,796 @@
697
1121
  overlay.setAttribute('data-wave-brush', brush);
698
1122
  }
699
1123
 
700
- const cycleBar = d.createElement('div');
701
- cycleBar.className = 'ed-wave-cycleops';
702
- 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);
703
1142
 
704
- function toolButton(cls, label, hint, fn) {
705
- const b = d.createElement('button');
706
- b.type = 'button';
707
- b.className = cls;
708
- b.setAttribute('data-focus-key', cls);
709
- b.textContent = label;
710
- b.title = hint;
711
- b.addEventListener('click', fn);
712
- cycleBar.appendChild(b);
713
- 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);
714
1168
  }
715
1169
 
716
- toolButton('ed-wave-cycle-insert', '插入 cycle',
717
- '在選取的位置插入一個 cycle(每一條 lane 同時加寬,不會有人掉隊)', function () {
718
- const at = selection === null ? 0 : Math.min(selection.from, selection.to);
719
- commit('insert-cycle', function (doc) { return codec.insertCycles(doc, at, 1); });
720
- });
721
- toolButton('ed-wave-cycle-delete', '刪除 cycle',
722
- '刪掉選取的 cycle(每一條 lane 同時變窄)', function () {
723
- if (selection === null) { say('先選一段 cycle 再刪'); return; }
724
- const from = Math.min(selection.from, selection.to);
725
- const count = Math.abs(selection.to - selection.from) + 1;
726
- commit('delete-cycle', function (doc) { return codec.deleteCycles(doc, from, count); });
727
- });
728
- toolButton('ed-wave-cycle-copy', '複製',
729
- '把選取的 cycle 從每一條 lane 上拷一份', function () {
730
- if (selection === null) { say('先選一段 cycle 再複製'); return; }
731
- const from = Math.min(selection.from, selection.to);
732
- const count = Math.abs(selection.to - selection.from) + 1;
733
- clip = codec.copyCycles(store.doc, from, count);
734
- say('複製了 ' + count + ' 個 cycle');
735
- render();
736
- });
737
- toolButton('ed-wave-cycle-paste', '貼上(插入)',
738
- '把剪貼簿插進選取的位置', function () { pasteAt('insert'); });
739
- toolButton('ed-wave-cycle-paste-over', '貼上(覆蓋)',
740
- '用剪貼簿蓋掉選取位置起算的那幾個 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
+ }
741
1189
 
742
- function pasteAt(mode) {
743
- if (clip === null) { say('剪貼簿是空的'); return; }
744
- const at = selection === null ? 0 : Math.min(selection.from, selection.to);
745
- commit('paste-' + mode, function (doc) {
746
- return codec.pasteCycles(doc, at, clip, mode);
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);
1258
+
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);
747
1319
  });
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
+ }
748
1358
  }
749
1359
 
750
- const undoBtn = toolButton('ed-wave-undo', '復原', '退回上一個動作', function () {
751
- if (!store.undo()) { say('沒有可以復原的動作'); return; }
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.
1377
+ //
1378
+ // The group is created with an EMPTY label and the rename field is
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,才有東西可以群組');
1387
+ return;
1388
+ }
1389
+ const ats = [];
1390
+ for (let k = selection.laneIndex; k <= selection.laneTo; k += 1) ats.push(k);
1391
+ const minAt = ats[0];
1392
+ const committed = commit('group-lanes', function (doc2) {
1393
+ return codec.groupLanes(doc2, ats, '');
1394
+ });
1395
+ if (!committed) {
1396
+ panels.say('這幾條 lane 不連續(或選取已經失效),WaveJSON 的群組只能是連續的一段');
1397
+ return;
1398
+ }
1399
+ // The commit above already repainted with the (now stale) highlight
1400
+ // showing; one more repaint clears it before handing the keyboard to
1401
+ // the rename field, the same two-step `renameGroup` itself already
1402
+ // is — a rebuild first, then the sub-panel laid over what it built.
1403
+ selection = null;
1404
+ render();
1405
+ const layout = geometry.layoutOf(store.doc, SIZES);
1406
+ const spans = geometry.groupSpansOf(store.doc, layout);
1407
+ for (const span of spans) {
1408
+ if (span.from === minAt) { panels.renameGroup(span); break; }
1409
+ }
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);
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);
1444
+ // v3.5.0 Task 7: arm the edge-creation gesture. One-shot in the sense
1445
+ // that a drag which PRODUCES an edge disarms it (`finishEdgeDrag` below);
1446
+ // pressing the button itself is a plain toggle, so a change of mind
1447
+ // before ever pressing on the canvas costs one more click, not Escape.
1448
+ const edgeBtn = panels.toolButton('ed-wave-edge-arm', '關聯線',
1449
+ '按一下武裝,然後從一格拖到另一格畫出關聯線(Escape 取消)', function () {
1450
+ setEdgeMode(edgeMode === 'idle' ? 'armed' : 'idle');
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);
1459
+ setEdgeMode('idle');
1460
+
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; }
752
1466
  afterStoreMoved('undo');
753
- });
754
- const redoBtn = toolButton('ed-wave-redo', '重做', '再做一次剛剛復原掉的動作', function () {
755
- if (!store.redo()) { say('沒有可以重做的動作'); return; }
1467
+ }, historyGroup);
1468
+ const redoBtn = panels.toolButton('ed-wave-redo', '重做', '再做一次剛剛復原掉的動作', function () {
1469
+ if (!store.redo()) { panels.say('沒有可以重做的動作'); return; }
756
1470
  afterStoreMoved('redo');
757
- });
1471
+ }, historyGroup);
758
1472
 
759
- // head / foot — the document-level fields this drawing can honour.
760
- //
761
- // `config.hscale` USED to be a third field here and has been removed. The
762
- // drawing does not model `hscale`, `period` or `phase` at all (zero
763
- // occurrences in wave-codec, wave-geometry or this file), and measured
764
- // against the pinned engine each of them changes what the engine paints
765
- // while leaving the drawing exactly as it was:
1473
+ // ── 匯出 ──────────────────────────────────────────────────────────────
766
1474
  //
767
- // {wave:'0101'} engine 8 half-bricks drawing 4 cycles
768
- // {wave:'0101', period:2} engine 16 drawing 4, unchanged
769
- // {wave:'0101', phase:0.5} engine 7 drawing 4, unshifted
770
- // {wave:'0101'} config.hscale:2 engine 16 drawing 4, unchanged
1475
+ // v3.6.0 Task 15: the three buttons Task 12 created as shape.
771
1476
  //
772
- // Shipping a CONTROL for a property the canvas ignores is the worst of the
773
- // three options: it invites the user to make the two pictures disagree and
774
- // then shows them a confident wrong one. Modelling the three properly is
775
- // not a change this file may make on its own either — cycle width lives in
776
- // `wave-geometry`, which is closed and whose arithmetic Task 3 pinned, and
777
- // a half-model here would be a SECOND belief about cycle geometry in the
778
- // 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.
779
1482
  //
780
- // So: the control is gone, and a document that carries any of the three is
781
- // told so out loud by `renderUnmodelled()` below rather than drawn wrongly
782
- // in silence.
783
- const metaBar = d.createElement('div');
784
- metaBar.className = 'ed-wave-meta';
785
- toolbar.appendChild(metaBar);
786
-
787
- function metaField(cls, label, placeholder) {
788
- const wrap = d.createElement('label');
789
- wrap.className = 'ed-wave-metafield';
790
- const text = d.createElement('span');
791
- text.textContent = label;
792
- wrap.appendChild(text);
793
- const input = d.createElement('input');
794
- input.type = 'text';
795
- input.className = cls;
796
- input.setAttribute('data-focus-key', cls);
797
- input.placeholder = placeholder;
798
- wrap.appendChild(input);
799
- metaBar.appendChild(wrap);
800
- 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);
801
1546
  }
802
1547
 
803
- const headInput = metaField('ed-wave-head-text', 'head', '上方標題');
804
- const footInput = metaField('ed-wave-foot-text', 'foot', '下方標題');
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
+ }
805
1554
 
806
- headInput.addEventListener('change', function () { commitBanner('head', headInput.value); });
807
- footInput.addEventListener('change', function () { commitBanner('foot', footInput.value); });
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('這個瀏覽器不給用剪貼簿,可以從右欄的原始碼面板手動選取');
1608
+ }
1609
+
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);
1726
+ // v3.5.0 Task 13, fix round 1: the ruler fields — the COMPLETE set, not
1727
+ // the three Task 13's own brief happened to name. Measured directly
1728
+ // against the pinned engine rather than recalled: `renderMarks` in
1729
+ // `node_modules/wavedrom/lib/render-marks.js` calls
1730
+ // `ticktock(lane, 'head', 'tick', ...)`, `ticktock(lane, 'head', 'tock',
1731
+ // ...)`, `ticktock(lane, 'foot', 'tick', ...)` and
1732
+ // `ticktock(lane, 'foot', 'tock', ...)` — all four, not just
1733
+ // `head.tick`/`foot.tock` — and inside `ticktock` itself,
1734
+ // `cxt[ref1].every` is read off whichever of `head`/`foot` `ref1` is, so
1735
+ // `every` applies under BOTH, not only `head`. That is eight keys —
1736
+ // `{head, foot} × {text, tick, tock, every}` — of which the first Task
1737
+ // 13 round shipped five (`head.text`/`foot.text` from v3.4.0, plus
1738
+ // `head.tick`/`head.every`/`foot.tock`). The three missing ones
1739
+ // (`head.tock`/`foot.tick`/`foot.every`) are added here so the set is
1740
+ // complete rather than an arbitrary subset a user cannot predict — "why
1741
+ // can I put a tick above the diagram but not below it" is exactly the
1742
+ // shape of bug five-of-eight invites.
1743
+ //
1744
+ // Labels are the literal WaveJSON PATH (`head.tick`, not bare `tick`),
1745
+ // not just the leaf key the first round used — with only one of
1746
+ // tick/tock/every ever existing under `head` (and the mirror one under
1747
+ // `foot`), a bare leaf label could not be confused for its sibling; with
1748
+ // all four now under both, `tick` alone would leave two fields on screen
1749
+ // both labelled `tick` with no way to tell which of them draws above the
1750
+ // diagram and which below. `head`/`foot` on the two `.text` fields stay
1751
+ // bare, since those never had a same-named sibling to begin with.
1752
+ // Grouped head-then-foot in DOM order (four fields, then four fields)
1753
+ // rather than the first round's ad hoc growth order, for the same
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); });
1786
+ headTockInput.addEventListener('change', function () {
1787
+ panels.commitBanner('head', 'tock', headTockInput.value);
1788
+ });
1789
+ headEveryInput.addEventListener('change', function () { panels.commitBanner('head', 'every', headEveryInput.value); });
1790
+ footTickInput.addEventListener('change', function () {
1791
+ panels.commitBanner('foot', 'tick', footTickInput.value);
1792
+ });
1793
+ footTockInput.addEventListener('change', function () { panels.commitBanner('foot', 'tock', footTockInput.value); });
1794
+ footEveryInput.addEventListener('change', function () {
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);
1822
+ });
1823
+
1824
+ // ── the edge inspector row ──────────────────────────────────────────
1825
+ //
1826
+ // Same rule as `renderUnmodelled` below: HIDDEN, with no box on screen at
1827
+ // all, unless `selectedEdge` names one — `renderEdgeBar` is what flips
1828
+ // `edgeBar.hidden` and it is only ever called from `render()`, so the two
1829
+ // can never disagree. Built once here, like `headInput`/`footInput`
1830
+ // above; `render()` repaints its VALUES on every pass through
1831
+ // `renderEdgeBar`, and the usual `data-focus-key` / `restoreFocus` pair
1832
+ // is what keeps a half-typed label from losing the caret on that repaint.
1833
+ const edgeBar = d.createElement('div');
1834
+ edgeBar.className = 'ed-wave-edgebar';
1835
+ edgeBar.hidden = true;
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);
1840
+
1841
+ const shapeWrap = d.createElement('label');
1842
+ shapeWrap.className = 'ed-wave-metafield';
1843
+ const shapeWrapText = d.createElement('span');
1844
+ shapeWrapText.textContent = '形狀';
1845
+ shapeWrap.appendChild(shapeWrapText);
1846
+ const shapeSel = d.createElement('select');
1847
+ shapeSel.className = 'ed-wave-edge-shape';
1848
+ shapeSel.setAttribute('data-focus-key', 'ed-wave-edge-shape');
1849
+ for (const s of codec.EDGE_SHAPES) {
1850
+ const o = d.createElement('option');
1851
+ o.value = s;
1852
+ o.textContent = s;
1853
+ shapeSel.appendChild(o);
1854
+ }
1855
+ shapeSel.addEventListener('change', function () {
1856
+ if (selectedEdge === null) return;
1857
+ commit('edge-shape', function (doc) {
1858
+ const cur = codec.parseEdge((doc.edge || [])[selectedEdge]);
1859
+ if (cur === null) return doc;
1860
+ return codec.updateEdge(doc, selectedEdge,
1861
+ { from: cur.from, to: cur.to, shape: shapeSel.value, label: cur.label });
1862
+ });
1863
+ });
1864
+ shapeWrap.appendChild(shapeSel);
1865
+ edgeBar.appendChild(shapeWrap);
1866
+
1867
+ const edgeLabelWrap = d.createElement('label');
1868
+ edgeLabelWrap.className = 'ed-wave-metafield';
1869
+ const edgeLabelWrapText = d.createElement('span');
1870
+ edgeLabelWrapText.textContent = '標籤';
1871
+ edgeLabelWrap.appendChild(edgeLabelWrapText);
1872
+ const edgeLabelInput = d.createElement('input');
1873
+ edgeLabelInput.type = 'text';
1874
+ edgeLabelInput.className = 'ed-wave-edge-label';
1875
+ edgeLabelInput.setAttribute('data-focus-key', 'ed-wave-edge-label');
1876
+ edgeLabelInput.placeholder = '這條線的標籤';
1877
+ edgeLabelInput.addEventListener('change', function () {
1878
+ if (selectedEdge === null) return;
1879
+ commit('edge-label', function (doc) {
1880
+ const cur = codec.parseEdge((doc.edge || [])[selectedEdge]);
1881
+ if (cur === null) return doc;
1882
+ return codec.updateEdge(doc, selectedEdge,
1883
+ { from: cur.from, to: cur.to, shape: cur.shape, label: edgeLabelInput.value });
1884
+ });
1885
+ });
1886
+ edgeLabelWrap.appendChild(edgeLabelInput);
1887
+ edgeBar.appendChild(edgeLabelWrap);
1888
+
1889
+ const edgeDeleteBtn = d.createElement('button');
1890
+ edgeDeleteBtn.type = 'button';
1891
+ edgeDeleteBtn.className = 'ed-wave-edge-delete';
1892
+ edgeDeleteBtn.setAttribute('data-focus-key', 'ed-wave-edge-delete');
1893
+ edgeDeleteBtn.textContent = '刪除這條線';
1894
+ edgeDeleteBtn.title = '刪掉這條關聯線(連帶清掉沒有其他 edge 再用的 anchor 字母)';
1895
+ edgeDeleteBtn.addEventListener('click', function () {
1896
+ if (selectedEdge === null) return;
1897
+ const at = selectedEdge;
1898
+ // Cleared BEFORE `commit`, not after: `commit` repaints synchronously,
1899
+ // and `removeEdge` shifts every later entry's index down by one — so an
1900
+ // index cleared only after `commit` returns would let `render()`'s
1901
+ // clamp see a DIFFERENT edge that happens to have landed on the same
1902
+ // number, and silently keep the inspector open on it.
1903
+ selectedEdge = null;
1904
+ commit('edge-remove', function (doc) {
1905
+ return codec.pruneNodes(codec.removeEdge(doc, at));
1906
+ });
1907
+ });
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;
808
1914
 
809
1915
  // The "this drawing is not showing you everything" notice. Empty, and with
810
1916
  // no box on screen, for the documents that carry none of the three.
@@ -821,19 +1927,18 @@
821
1927
  noSignal.className = 'ed-wave-unmodelled ed-wave-nolanes';
822
1928
  noSignal.hidden = true;
823
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;
824
1934
 
825
- function commitBanner(which, value) {
826
- commit(which, function (doc) {
827
- const had = doc[which];
828
- if (value === '' && (had === undefined || had === null)) return doc;
829
- const next = Object.assign({}, doc);
830
- next[which] = Object.assign({}, (had !== null && typeof had === 'object') ? had : {});
831
- next[which].text = value;
832
- return next;
833
- });
834
- }
835
-
836
- // ── body: lane list | drawing | preview ───────────────────────────────
1935
+ // ── body: lane list | drawing | right-hand column ──────────────────────
1936
+ //
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`.
837
1942
  const body = d.createElement('div');
838
1943
  body.className = 'ed-wave-body';
839
1944
  panel.appendChild(body);
@@ -841,6 +1946,9 @@
841
1946
  const laneCol = d.createElement('div');
842
1947
  laneCol.className = 'ed-wave-lanes';
843
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;
844
1952
 
845
1953
  const canvasWrap = d.createElement('div');
846
1954
  canvasWrap.className = 'ed-wave-canvas-wrap';
@@ -860,7 +1968,15 @@
860
1968
  // This preview belongs to neither.
861
1969
  previewHost.id = PREVIEW_ID_PREFIX + PREVIEW_INDEX;
862
1970
  previewWrap.appendChild(previewHost);
863
- 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);
864
1980
 
865
1981
  const status = d.createElement('div');
866
1982
  status.className = 'ed-wave-status';
@@ -869,11 +1985,9 @@
869
1985
  // the one place that has to be announced rather than merely painted.
870
1986
  status.setAttribute('role', 'status');
871
1987
  panel.appendChild(status);
872
-
873
- function say(text) {
874
- status.textContent = text;
875
- overlay.setAttribute('data-wave-status', text);
876
- }
1988
+ // Published for `panels.say`'s lazy read, same reason as `deps.metaBar`
1989
+ // above.
1990
+ deps.status = status;
877
1991
 
878
1992
  // ── the one road from a gesture to the document ───────────────────────
879
1993
  /**
@@ -898,7 +2012,7 @@
898
2012
  if (closed) return false;
899
2013
  const changed = store.apply(name, fn);
900
2014
  if (!changed) {
901
- say('這個動作沒有改變任何東西');
2015
+ panels.say('這個動作沒有改變任何東西');
902
2016
  return false;
903
2017
  }
904
2018
  gestures++;
@@ -932,10 +2046,51 @@
932
2046
  if (marksDoc === nextDoc) return;
933
2047
  const before = marksDoc;
934
2048
  marksDoc = nextDoc;
935
- if (before === null || (cursor === null && selection === null)) return;
2049
+ // Fix round 2: `pendingFrom` joins the guard's own "is there anything
2050
+ // to carry" question — widened rather than given a second guard of
2051
+ // its own, so there stays ONE call to `opts.laneTrace` and ONE
2052
+ // `rowOf` built from it, not a second belief about the lookup living
2053
+ // next to the first. Reachable with `cursor`/`selection` both null:
2054
+ // the keyboard's `armed` state is not locked to a document capture
2055
+ // the way a mouse drag is, so a user can mark a start cell with
2056
+ // Enter, delete every lane from the toolbar (which nulls `cursor`/
2057
+ // `selection` in `renderCanvas`'s own clamp but left `pendingFrom`
2058
+ // alone before this fix), add lanes back past the stale index, and
2059
+ // finish the gesture — silently anchoring an edge to a lane the
2060
+ // trace never actually confirmed, because the lookup below was
2061
+ // skipped entirely while `pendingFrom` was the only mark still
2062
+ // standing. `ensureNode` bounds-checks against whatever document is
2063
+ // in hand at THAT moment, not against the one `pendingFrom` was
2064
+ // marked in, so a stale-but-back-in-range index is silently valid to
2065
+ // it — this file's worst failure class, wrong data written with
2066
+ // nothing on screen that explains it.
2067
+ // v3.5.0 Task 8: `endpointDrag` widens this same guard rather than
2068
+ // getting one of its own, for the identical reason `pendingFrom` did
2069
+ // in fix round 2 — one call to `opts.laneTrace`, one `rowOf`, not a
2070
+ // second belief about the lookup. Reachable with the other three all
2071
+ // null: a keyboard undo/redo reached WHILE a mouse button is down
2072
+ // dragging an endpoint is exactly the same shape of gap `pendingFrom`
2073
+ // closed — the pointer holds no document capture of its own, only
2074
+ // this variable's `at` does.
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.
2078
+ if (before === null ||
2079
+ (cursor === null && selection === null && pendingFrom === null &&
2080
+ endpointDrag === null)) {
2081
+ return;
2082
+ }
936
2083
  const trace = typeof opts.laneTrace === 'function'
937
2084
  ? opts.laneTrace(before, nextDoc) : null;
938
- if (trace === null) { cursor = null; selection = null; return; }
2085
+ // No `laneTrace` injected: "I no longer know where you were" applies
2086
+ // to `pendingFrom` exactly as it already does to `cursor`/`selection`
2087
+ // — dropped, not guessed at. `endpointDrag` joins them: a drag naming
2088
+ // a lane nothing here can still vouch for is the same hazard either
2089
+ // way.
2090
+ if (trace === null) {
2091
+ cursor = null; selection = null; pendingFrom = null; endpointDrag = null;
2092
+ return;
2093
+ }
939
2094
  // Structural or not: a content operation traces as the identity, so this
940
2095
  // is a no-op for a paint without having to know that it is one.
941
2096
  const rowOf = function (was) {
@@ -949,10 +2104,79 @@
949
2104
  if (at !== null) cursor = { laneIndex: at, cycle: cursor.cycle };
950
2105
  }
951
2106
  if (selection !== null) {
952
- const at = rowOf(selection.laneIndex);
953
- if (at !== null) {
954
- selection = { laneIndex: at, from: selection.from, to: selection.to };
955
- }
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
+ };
2142
+ }
2143
+ // `pendingFrom` is a mark of the exact same shape as `cursor` (a row
2144
+ // number plus a cycle), carried the same way — by lane IDENTITY
2145
+ // through this same trace, not by raw row number (see the guard
2146
+ // above for why it must reach here even when `cursor`/`selection`
2147
+ // cannot). It deliberately does NOT copy cursor's "leave the number
2148
+ // alone when untraceable" half, though: `cursor` gets away with that
2149
+ // because `renderCanvas` re-clamps it into range on every repaint, and
2150
+ // because a cursor landing on "whatever now occupies that visual
2151
+ // position" after its own lane vanished is the right instinct for a
2152
+ // position marker. `pendingFrom` has no such clamp anywhere, and it is
2153
+ // not a position — it is a lane the user already committed to as one
2154
+ // fixed end of an edge. Fix round 2: leaving it at a stale raw index
2155
+ // is exactly the hazard the coordinator's review walked to the end —
2156
+ // delete the marked lane, add unrelated lanes back until the count
2157
+ // passes the stale number again, and `ensureNode` would bounds-check
2158
+ // successfully against a lane the user never chose. So an untraceable
2159
+ // `pendingFrom` is DROPPED here, the same as `trace === null` above
2160
+ // drops it wholesale — never left standing on a number nothing
2161
+ // vouches for.
2162
+ if (pendingFrom !== null) {
2163
+ const at = rowOf(pendingFrom.at);
2164
+ pendingFrom = at === null ? null : { at: at, cell: pendingFrom.cell };
2165
+ }
2166
+ // `endpointDrag.at` is a mark of the exact same shape as
2167
+ // `pendingFrom.at` — a lane the user is currently pointing a live
2168
+ // gesture at, not a position `renderCanvas` re-clamps on every
2169
+ // repaint — so it is carried the same way and dropped the same way
2170
+ // when the lane it named cannot be traced. Left alone while `at` is
2171
+ // still `null` (mousedown fired, no `pointermove` has landed on a
2172
+ // real cell yet): there is no row number there yet to go stale.
2173
+ // `index`/`end`/`cell` are not row numbers and need no retracing
2174
+ // here — `index` is guarded separately by `render()`'s widened
2175
+ // `selectedEdge` clamp (the drag dies with the selection it drags).
2176
+ if (endpointDrag !== null && endpointDrag.at !== null) {
2177
+ const at = rowOf(endpointDrag.at);
2178
+ endpointDrag = at === null ? null
2179
+ : { index: endpointDrag.index, end: endpointDrag.end, at: at, cell: endpointDrag.cell };
956
2180
  }
957
2181
  }
958
2182
 
@@ -965,9 +2189,9 @@
965
2189
  lastPatch = store.toPatch();
966
2190
  render();
967
2191
  if (lastPatch.ok === true) {
968
- say('已寫回:' + name);
2192
+ panels.say('已寫回:' + name);
969
2193
  } else {
970
- say('這個改動沒辦法只改幾個位元組寫回去:' + lastPatch.reason);
2194
+ panels.say('這個改動沒辦法只改幾個位元組寫回去:' + lastPatch.reason);
971
2195
  }
972
2196
  if (typeof opts.onGesture === 'function') {
973
2197
  opts.onGesture({ name: name, patch: lastPatch, store: store });
@@ -977,6 +2201,192 @@
977
2201
  // ── painting ──────────────────────────────────────────────────────────
978
2202
  let canvas = null;
979
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
+
980
2390
  /**
981
2391
  * Repaint everything, and give the keyboard cursor back where it was.
982
2392
  *
@@ -1008,13 +2418,101 @@
1008
2418
  const before = focusMark();
1009
2419
  const doc = store.doc;
1010
2420
  const layout = geometry.layoutOf(doc, SIZES);
1011
- renderLanes(doc, layout);
1012
- renderCanvas(doc, layout);
1013
- renderPreview(doc);
1014
- renderMeta(doc);
1015
- renderUnmodelled(doc);
2421
+ // Clamped BEFORE `renderCanvas`/`renderEdgeBar` read it, and against
2422
+ // `edgeLayout`'s own filtered list rather than `doc.edge.length` — an
2423
+ // edge whose anchor letter went missing is still an array element (so
2424
+ // a length check would miss it) but `edgeLayout` already will not draw
2425
+ // it, and an inspector row pointed at an edge nothing on screen shows
2426
+ // is exactly the stale-selection defect `carryMarks` above exists to
2427
+ // avoid for lanes.
2428
+ if (selectedEdge !== null && !edgeIsDrawable(doc, layout, selectedEdge)) {
2429
+ selectedEdge = null;
2430
+ }
2431
+ // v3.5.0 Task 8: `endpointDrag.index` can only ever be reached from
2432
+ // `selectedEdge`'s own drawn handle (see `onCanvasDown`), so the two
2433
+ // may never disagree — checked with `!==` rather than "is `index`
2434
+ // still drawable" on its own, because that would let a drag survive a
2435
+ // mid-drag undo that DESELECTS the edge without deleting it (the
2436
+ // clamp above nulls `selectedEdge` on its own timing). A drag that
2437
+ // outlives the selection it was dragging is exactly the class of
2438
+ // hazard Task 7 paid three rounds to close for `pendingFrom`/
2439
+ // `edgeMode`; this is the same rule applied to this task's own mark.
2440
+ if (endpointDrag !== null && endpointDrag.index !== selectedEdge) {
2441
+ endpointDrag = null;
2442
+ }
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();
1016
2513
  undoBtn.disabled = !store.canUndo();
1017
2514
  redoBtn.disabled = !store.canRedo();
2515
+ paintSelectionState(doc, layout);
1018
2516
  overlay.setAttribute('data-wave-gestures', String(gestures));
1019
2517
  overlay.setAttribute('data-wave-lanes', String(layout.lanes.length));
1020
2518
  overlay.setAttribute('data-wave-cycles', String(layout.cycles));
@@ -1025,6 +2523,16 @@
1025
2523
  restoreFocus(before);
1026
2524
  }
1027
2525
 
2526
+ /** Whether `geometry.edgeLayout` would still draw the edge at `index` —
2527
+ * the same test the drawing itself applies, asked rather than repeated. */
2528
+ function edgeIsDrawable(doc, layout, index) {
2529
+ const edges = geometry.edgeLayout(doc, layout);
2530
+ for (const e of edges) {
2531
+ if (e.index === index) return true;
2532
+ }
2533
+ return false;
2534
+ }
2535
+
1028
2536
  /** What has the keyboard now, as something that survives a rebuild. */
1029
2537
  function focusMark() {
1030
2538
  const el = d.activeElement;
@@ -1046,6 +2554,16 @@
1046
2554
  const want = focusOverride !== null ? focusOverride : (mark === null ? null : mark.key);
1047
2555
  focusOverride = null;
1048
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; };
1049
2567
  let el = overlay.querySelector('[data-focus-key="' + want + '"]');
1050
2568
  // A named destination the repaint did not bring back. It happens: a
1051
2569
  // group's key is its first lane's row number, so inserting a lane above
@@ -1054,10 +2572,10 @@
1054
2572
  // where the keyboard actually was, and failing that to the dialog
1055
2573
  // itself. Never to nothing: nothing is `document.body`, and this whole
1056
2574
  // task is about not ending there.
1057
- if (el === null && mark !== null && mark.key !== want) {
2575
+ if (inert(el) && mark !== null && mark.key !== want) {
1058
2576
  el = overlay.querySelector('[data-focus-key="' + mark.key + '"]');
1059
2577
  }
1060
- if (el === null) {
2578
+ if (inert(el)) {
1061
2579
  if (!overlay.contains(d.activeElement)) panel.focus();
1062
2580
  return;
1063
2581
  }
@@ -1070,689 +2588,185 @@
1070
2588
  } catch (e) { /* not a text field */ }
1071
2589
  }
1072
2590
 
1073
- function renderMeta(doc) {
1074
- const h = doc.head;
1075
- const f = doc.foot;
1076
- headInput.value = (h !== null && typeof h === 'object' && typeof h.text === 'string')
1077
- ? h.text : '';
1078
- footInput.value = (f !== null && typeof f === 'object' && typeof f.text === 'string')
1079
- ? f.text : '';
1080
- }
1081
-
1082
- /**
1083
- * Say out loud which properties of this document the drawing is not
1084
- * showing.
1085
- *
1086
- * `config.hscale`, and a lane's `period` / `phase`, all change what the
1087
- * engine paints and none of them is modelled here (see the toolbar comment
1088
- * for the measurements). Drawing a confident picture that ignores them is
1089
- * the failure this whole overlay is arranged to avoid, so a document that
1090
- * carries any of them says so, names them, and points at the preview —
1091
- * which IS faithful, because it is the engine.
1092
- */
1093
- function renderUnmodelled(doc) {
1094
- const found = [];
1095
- const cfg = doc.config;
1096
- if (cfg !== null && typeof cfg === 'object' && cfg.hscale !== undefined) {
1097
- found.push('config.hscale');
1098
- }
1099
- for (const path of codec.lanePaths(doc)) {
1100
- const lane = valueAt(doc, path);
1101
- if (lane === null || typeof lane !== 'object') continue;
1102
- if (lane.period !== undefined && found.indexOf('period') === -1) found.push('period');
1103
- if (lane.phase !== undefined && found.indexOf('phase') === -1) found.push('phase');
1104
- }
1105
- overlay.setAttribute('data-wave-unmodelled', found.join(' '));
1106
- unmodelled.hidden = found.length === 0;
1107
- unmodelled.textContent = found.length === 0 ? '' :
1108
- '這份文件用了 ' + found.join('、') +
1109
- ',左邊的手繪波形不表現它們(寬度與相位會不一樣)——以右邊的 WaveDrom 預覽為準。';
1110
-
1111
- // …and the bigger version of the same honesty. This editor edits
1112
- // `signal:` and nothing else, so a `reg:` or `assign:` block opens a
1113
- // canvas with no lanes on it: `lanePaths()` is empty, `addLane()` hands
1114
- // the document straight back, and pressing + produced one status line
1115
- //「這個動作沒有改變任何東西」— true, and no explanation at all. An
1116
- // editor that looks broken is worse than one that says what it cannot
1117
- // do, and the notice above is the precedent: the drawing already tells
1118
- // the user which properties it is not showing them.
1119
- const kinds = [];
1120
- for (const key of ['reg', 'assign']) {
1121
- if (doc[key] !== undefined) kinds.push(key + ':');
1122
- }
1123
- const noLanes = codec.lanePaths(doc).length === 0;
1124
- overlay.setAttribute('data-wave-nolanes', noLanes ? '1' : '0');
1125
- noSignal.hidden = !noLanes;
1126
- noSignal.textContent = !noLanes ? '' :
1127
- (kinds.length === 0
1128
- ? '這個區塊裡沒有 signal:,所以沒有可以編輯的 lane。'
1129
- : '這個區塊用的是 ' + kinds.join(' / ') +
1130
- ',這個編輯器只編輯 signal: 的 lane。') +
1131
- '右邊的 WaveDrom 預覽仍然是引擎畫的原圖;要改這個區塊請用「⠿ → MD 原始碼」。';
1132
- }
1133
-
1134
2591
  /**
1135
- * The lane list.
2592
+ * The in-place editor for one bus cell's `data` label — this dialog's
2593
+ * OTHER sub-panel, same `{cancel, retire}` shape as `renameGroup` just
2594
+ * above and opened the same two ways every other gesture in this file
2595
+ * is: a pointer press (`onCanvasDown`) or the keyboard's own Enter
2596
+ * (`handleDrawingKey`), both of which have already asked
2597
+ * `codec.dataSlotOf` before calling this, so `slot` below is never
2598
+ * recomputed from a belief of its own about which cycles consume one.
1136
2599
  *
1137
- * Rows are `layout.laneHeight` tall and in `layout.lanes` order, so row k
1138
- * here and row k of the drawing are the same lane by construction rather
1139
- * than by a second traversal that could disagree.
1140
- */
1141
- function renderLanes(doc, layout) {
1142
- laneCol.textContent = '';
1143
- const rows = d.createElement('div');
1144
- rows.className = 'ed-wave-lane-rows';
1145
- rows.style.height = layout.height + 'px';
1146
- laneCol.appendChild(rows);
1147
-
1148
- // Group titles first, so they sit UNDER the rows in paint order and a
1149
- // lane's own controls stay clickable over them.
1150
- for (const span of groupSpansOf(doc, layout)) {
1151
- const tag = d.createElement('button');
1152
- tag.type = 'button';
1153
- tag.className = 'ed-wave-group';
1154
- tag.setAttribute('data-focus-key', 'group-' + span.from);
1155
- tag.textContent = span.title;
1156
- tag.title = '群組「' + span.title + '」——點一下改名';
1157
- tag.setAttribute('data-group-from', String(span.from));
1158
- tag.setAttribute('data-group-to', String(span.to));
1159
- tag.style.top = (span.from * layout.laneHeight) + 'px';
1160
- tag.style.height = ((span.to - span.from + 1) * layout.laneHeight) + 'px';
1161
- tag.style.left = ((span.depth - 1) * 16) + 'px';
1162
- tag.addEventListener('click', function () { renameGroup(span); });
1163
- rows.appendChild(tag);
1164
- }
1165
-
1166
- for (let i = 0; i < layout.lanes.length; i++) {
1167
- rows.appendChild(laneRow(doc, layout, i));
1168
- }
1169
- // The tail insert. `laneInsertPath(doc, laneCount)` goes on the end of
1170
- // `signal` itself, which is what makes the group asymmetry visible here
1171
- // rather than surprising afterwards.
1172
- rows.appendChild(insertButton(doc, layout.lanes.length, layout, true));
1173
- }
1174
-
1175
- function laneRow(doc, layout, i) {
1176
- const row = d.createElement('div');
1177
- row.className = 'ed-wave-lane-row';
1178
- row.setAttribute('data-lane', String(i));
1179
- row.style.top = (i * layout.laneHeight) + 'px';
1180
- row.style.height = layout.laneHeight + 'px';
1181
- row.style.paddingLeft = (layout.lanes[i].depth * 16) + 'px';
1182
- if (selection !== null && selection.laneIndex === i) row.classList.add('is-selected');
1183
-
1184
- row.appendChild(insertButton(doc, i, layout, false));
1185
-
1186
- const name = d.createElement('input');
1187
- name.type = 'text';
1188
- name.className = 'ed-wave-lane-name';
1189
- name.setAttribute('data-focus-key', 'lane-name-' + i);
1190
- const lane = layout.lanes[i].lane;
1191
- name.value = typeof lane.name === 'string' ? lane.name : '';
1192
- name.addEventListener('change', function () {
1193
- commit('rename', function (doc2) { return codec.renameLane(doc2, i, name.value); });
1194
- });
1195
- row.appendChild(name);
1196
-
1197
- const up = d.createElement('button');
1198
- up.type = 'button';
1199
- up.className = 'ed-wave-lane-up';
1200
- up.setAttribute('data-focus-key', 'lane-up-' + i);
1201
- up.textContent = '▲';
1202
- up.title = '往上移一格';
1203
- up.disabled = i === 0;
1204
- up.addEventListener('click', function () {
1205
- // The button this lane arrives at, EXCEPT at the top, where `lane-up-0`
1206
- // is disabled and `.focus()` on it is inert — measured, walking a lane
1207
- // to the top with three ▲ put the cursor on `lane-up-2`, `lane-up-1`
1208
- // and then BODY, which is the state F7 exists to prevent and the fourth
1209
- // press then did nothing at all. At the top the cursor goes to the one
1210
- // control on that row that is never disabled there: ▼.
1211
- focusOverride = i - 1 === 0 ? 'lane-down-0' : 'lane-up-' + (i - 1);
1212
- commit('move', function (doc2) { return codec.moveLane(doc2, i, i - 1); });
1213
- });
1214
- row.appendChild(up);
1215
-
1216
- const down = d.createElement('button');
1217
- down.type = 'button';
1218
- down.className = 'ed-wave-lane-down';
1219
- down.setAttribute('data-focus-key', 'lane-down-' + i);
1220
- down.textContent = '▼';
1221
- down.title = '往下移一格';
1222
- down.disabled = i === layout.lanes.length - 1;
1223
- down.addEventListener('click', function () {
1224
- // Mirror of ▲ above: `lane-down-<last>` is disabled, so the bottom row
1225
- // hands the cursor to ▲ instead.
1226
- focusOverride = i + 1 === layout.lanes.length - 1
1227
- ? 'lane-up-' + (i + 1) : 'lane-down-' + (i + 1);
1228
- commit('move', function (doc2) { return codec.moveLane(doc2, i, i + 1); });
1229
- });
1230
- row.appendChild(down);
1231
-
1232
- const del = d.createElement('button');
1233
- del.type = 'button';
1234
- del.className = 'ed-wave-lane-remove';
1235
- del.setAttribute('data-focus-key', 'lane-remove-' + i);
1236
- del.textContent = '✕';
1237
- del.title = '刪掉這條 lane(如果群組因此空掉,群組也會一起走)';
1238
- del.addEventListener('click', function () {
1239
- selection = null;
1240
- // The row this button belongs to is about to stop existing, so the
1241
- // keyboard goes to the + that occupies that position instead — the
1242
- // one control at that spot that survives every removal, tail included.
1243
- focusOverride = 'lane-add-' + i;
1244
- commit('remove-lane', function (doc2) { return codec.removeLane(doc2, i); });
1245
- });
1246
- row.appendChild(del);
1247
- return row;
1248
- }
1249
-
1250
- /**
1251
- * Where a lane inserted at display position `index` would actually land,
1252
- * asked of the bridge rather than guessed.
1253
- *
1254
- * This is constraint 4 made visible. `laneInsertPath` puts an insert at a
1255
- * group's FIRST lane inside that group, and an insert at the position just
1256
- * past its LAST lane outside it — a group can be joined at its head and
1257
- * never at its tail. That asymmetry is not something a label can argue
1258
- * away, so the label states it: the button says which container it will
1259
- * land in, before it is pressed, and the status line says it again after.
1260
- */
1261
- function landingOf(doc, index) {
1262
- const path = codec.laneInsertPath(doc, index);
1263
- if (path === null) return { path: null, title: null };
1264
- const container = path.slice(0, path.length - 1);
1265
- const held = valueAt(doc, container);
1266
- const title = Array.isArray(held) && typeof held[0] === 'string' ? held[0] : null;
1267
- return { path: path, title: title };
1268
- }
1269
-
1270
- function insertButton(doc, index, layout, isTail) {
1271
- const b = d.createElement('button');
1272
- b.type = 'button';
1273
- b.className = isTail ? 'ed-wave-lane-add ed-wave-lane-add-tail' : 'ed-wave-lane-add';
1274
- b.textContent = '+';
1275
- b.setAttribute('data-insert-at', String(index));
1276
- b.setAttribute('data-focus-key', 'lane-add-' + index);
1277
- const landing = landingOf(doc, index);
1278
- const where = landing.title === null ? '群組外' : '群組「' + landing.title + '」裡';
1279
- b.setAttribute('data-lands-in', landing.title === null ? '' : landing.title);
1280
- b.title = '在這裡加一條 lane —— 會落在' + where;
1281
- if (isTail) {
1282
- b.style.top = (layout.lanes.length * layout.laneHeight) + 'px';
1283
- }
1284
- b.addEventListener('click', function () {
1285
- // Straight into the new lane's name field: it is what the user is going
1286
- // to type next, and it is the only control the new row introduces.
1287
- focusOverride = 'lane-name-' + index;
1288
- const added = commit('add-lane', function (doc2) {
1289
- return codec.addLane(doc2, index, { name: '', wave: 'x' });
1290
- });
1291
- if (added) say('新增的 lane 落在' + where);
1292
- });
1293
- return b;
1294
- }
1295
-
1296
- /**
1297
- * The rail's rename field: this dialog's one sub-panel.
1298
- *
1299
- * The field is INSERTED beside its tag and the tag is hidden, rather than
1300
- * the two being swapped, and taking it down again touches only those two
1301
- * nodes. That is not tidiness. Taking it down used to mean a full
1302
- * `render()` — a rebuild of the entire rail — and a rebuild that runs
1303
- * inside a `blur` handler destroys the element the user is in the middle
1304
- * of pressing. Measured before this: open the field, then press any rail
1305
- * button; the mousedown blurred the field, the rail was rebuilt, the
1306
- * mouseup landed on a fresh element so no `click` ever fired, the press
1307
- * did nothing at all, and the keyboard ended on `document.body` inside a
1308
- * modal with no visible cursor.
2600
+ * Positioned with `geometry.cellRect` — the exact numbers the keyboard
2601
+ * cursor and the pointer's own hit test already share — offset by
2602
+ * `canvas.offsetLeft`/`offsetTop` rather than the wrap's own padding
2603
+ * written out as a constant: the field is a plain sibling of the SVG
2604
+ * inside `.ed-wave-canvas-wrap` (now `position: relative`, see
2605
+ * `lib/md2doc.js`), so reading the SVG's OWN measured offset is what
2606
+ * keeps the two from drifting apart if that padding ever changes.
2607
+ *
2608
+ * Unlike `renameGroup`'s field, this one's node sits beside a canvas
2609
+ * `renderCanvas` rebuilds on every cursor move — see that function's own
2610
+ * first line for why `retire()` has to be called from there too, not
2611
+ * only from `render()`.
1309
2612
  */
1310
- function renameGroup(span) {
1311
- const rows = laneCol.querySelector('.ed-wave-lane-rows');
1312
- const tag = rows === null ? null
1313
- : rows.querySelector('.ed-wave-group[data-group-from="' + span.from + '"]');
1314
- if (tag === null) return;
2613
+ function openDataEdit(laneIndex, cycle) {
2614
+ if (canvas === null) return;
2615
+ const layout = geometry.layoutOf(store.doc, SIZES);
2616
+ const row = layout.lanes[laneIndex];
2617
+ const lane = row === undefined ? null : row.lane;
2618
+ const slot = lane === null ? null : codec.dataSlotOf(lane, cycle);
2619
+ if (slot === null) return;
2620
+ const box = geometry.cellRect(layout, laneIndex, cycle);
2621
+ if (box === null) return;
2622
+ // A press or an Enter that lands on a second bus cell while the field
2623
+ // is still open on the first one: the open field never got the blur
2624
+ // that a plain click elsewhere would have given it (the keyboard's
2625
+ // Enter path never moves focus at all), so it is cancelled here,
2626
+ // by hand, before this one opens. Unsaved text in the first field is
2627
+ // discarded — the same trade `renameGroup` makes for its own field on
2628
+ // every route that is not `finish()` itself.
2629
+ if (dataEdit !== null) dataEdit.cancel();
2630
+
2631
+ const current = labelsOf(lane)[slot];
2632
+ const startValue = typeof current === 'string' ? current : '';
1315
2633
 
1316
2634
  const input = d.createElement('input');
1317
2635
  input.type = 'text';
1318
- input.className = 'ed-wave-group-input';
1319
- input.setAttribute('data-focus-key', 'group-input-' + span.from);
1320
- input.value = span.title;
1321
- input.style.top = tag.style.top;
1322
- input.style.left = tag.style.left;
1323
-
1324
- /** The field off, the tag back — and nothing else in the rail touched.
1325
- * A no-op for both once a repaint has taken them away. */
2636
+ input.className = 'ed-wave-data-input';
2637
+ input.setAttribute('data-focus-key', 'data-input-' + laneIndex + '-' + cycle);
2638
+ input.value = startValue;
2639
+ input.style.left = (canvas.offsetLeft + box.x) + 'px';
2640
+ input.style.top = (canvas.offsetTop + box.y) + 'px';
2641
+ input.style.width = box.width + 'px';
2642
+ input.style.height = box.height + 'px';
2643
+
2644
+ /** The field off. A no-op once a repaint has already taken it away. */
1326
2645
  const takeDown = function () {
1327
2646
  if (input.parentNode !== null) input.parentNode.removeChild(input);
1328
- if (d.contains(tag)) tag.hidden = false;
1329
2647
  };
1330
- /** The tag this field was covering, or — when a repaint has replaced it
1331
- * and moved it (a group's `from` is its first lane's row, so an insert
1332
- * above it renumbers the key) — whatever now carries that key, and
1333
- * failing that the dialog itself. Never nothing: nothing is
1334
- * `document.body`. */
1335
- const focusTag = function () {
1336
- const live = d.contains(tag) ? tag
1337
- : overlay.querySelector('[data-focus-key="group-' + span.from + '"]');
1338
- if (live !== null) live.focus();
1339
- else panel.focus();
1340
- };
1341
- /** Whether the keyboard is IN the field right now. False on the `blur`
1342
- * path — the browser has already moved it by then — which is how the
1343
- * two are told apart without asking which device did anything. */
2648
+ /** Whether the keyboard is IN the field right now. False on the
2649
+ * `blur` path — the browser has already moved it by then. */
1344
2650
  const holdsKeyboard = function () { return d.activeElement === input; };
1345
2651
 
1346
2652
  const finish = function () {
1347
- // Once. `change` and `blur` both fire for a field committed with Enter,
1348
- // and every teardown below is a blur too. Whoever gets here first owns
1349
- // the gesture; the latch is also how the other two say "not you".
1350
- if (groupRename === null) return;
1351
- groupRename = null;
2653
+ // Once. `blur` and this file's own explicit Enter handler can both
2654
+ // reach here for the one commit (Enter removes the field, which
2655
+ // fires a `blur` of its own) — the latch is the same "whoever gets
2656
+ // here first owns the gesture" rule `renameGroup.finish` uses.
2657
+ if (dataEdit === null) return;
2658
+ dataEdit = null;
1352
2659
  const hadKeyboard = holdsKeyboard();
1353
2660
  const value = input.value;
1354
2661
  takeDown();
1355
- if (value === span.title) {
1356
- // Nothing to write, so nothing to repaint — the tag is already back.
1357
- // The keyboard is only moved when it was in the field: on the mouse
1358
- // path the browser is already carrying it to the control that was
1359
- // pressed, and that control is still there to receive it now that
1360
- // this path no longer rebuilds the rail.
1361
- if (hadKeyboard) focusTag();
2662
+ if (value === startValue) {
2663
+ // Nothing to write, so nothing to repaint — `canvas` is already
2664
+ // the element it was before this field opened.
2665
+ if (hadKeyboard) canvas.focus();
1362
2666
  return;
1363
2667
  }
1364
- // A real commit, so a real repaint — and the keyboard has to be named
1365
- // if it would otherwise be lost. `focusOverride` is left alone when a
1366
- // caller has already made a more specific promise.
1367
- if (focusOverride === null &&
1368
- (hadKeyboard || !overlay.contains(d.activeElement))) {
1369
- focusOverride = 'group-' + span.from;
2668
+ // The keyboard is only NAMED when it would otherwise be lost — same
2669
+ // condition `renameGroup.finish` uses: either this field had it, or
2670
+ // whatever DOES have it right now is not even inside the overlay
2671
+ // (both mean the coming repaint would otherwise leave it on
2672
+ // `document.body`). `focusOverride` already set by someone else's
2673
+ // more specific promise is left alone.
2674
+ if (focusOverride === null && (hadKeyboard || !overlay.contains(d.activeElement))) {
2675
+ focusOverride = 'canvas';
1370
2676
  }
1371
- commit('rename-group', function (doc2) {
1372
- return replaceAt(doc2, span.path.concat([0]), value);
2677
+ commit('data-label', function (doc) {
2678
+ return codec.setDataAt(doc, laneIndex, slot, value);
1373
2679
  });
1374
2680
  };
1375
- // The rail is about to be rebuilt underneath this field by somebody
1376
- // else's repaint — an undo, a redo, any gesture that reaches `render()`
1377
- // while it is open. Both nodes go with that rebuild, so there is nothing
1378
- // to take down; what matters is dropping the latch and naming where the
1379
- // keyboard lands, because `group-input-<n>` will not exist afterwards and
1380
- // focus would be left on a node that is no longer in the document — which
1381
- // is the wedge that lost every subsequent keystroke.
2681
+ /** The canvas is about to be rebuilt underneath this field by
2682
+ * `renderCanvas` — an undo, a redo, any gesture that repaints while
2683
+ * it is open. The node goes with that rebuild; what matters is
2684
+ * dropping the latch and naming where the keyboard lands, exactly
2685
+ * the same reason `renameGroup.retire` exists. */
1382
2686
  const retire = function () {
1383
- if (groupRename === null) return;
1384
- groupRename = null;
1385
- if (focusOverride === null && holdsKeyboard()) {
1386
- focusOverride = 'group-' + span.from;
1387
- }
2687
+ if (dataEdit === null) return;
2688
+ dataEdit = null;
2689
+ if (focusOverride === null && holdsKeyboard()) focusOverride = 'canvas';
1388
2690
  };
1389
- // Escape's half of the layering: the field goes, the name does not
1390
- // change, the session stands, and the keyboard goes back to the tag the
1391
- // field was covering — never to `document.body`, which is the state the
1392
- // whole of this task exists to make unreachable. No repaint at all: the
1393
- // tag was never destroyed.
2691
+ /** Escape's half: the field goes, the label does not change, the
2692
+ * session stands, and the keyboard goes back to the drawing — never
2693
+ * to `document.body`. No repaint: the canvas was never touched. */
1394
2694
  const cancel = function () {
1395
- if (groupRename === null) return;
1396
- groupRename = null;
2695
+ if (dataEdit === null) return;
2696
+ dataEdit = null;
1397
2697
  takeDown();
1398
- focusTag();
2698
+ canvas.focus();
1399
2699
  };
1400
-
2700
+ // The explicit commit key: Enter. Left to a plain native `change`
2701
+ // event the way `renameGroup`'s field is would depend on whether this
2702
+ // browser fires `change` for a lone text input on Enter alone, which
2703
+ // is not a promise this file makes elsewhere — asked for outright
2704
+ // instead, the same way Escape is asked for explicitly one level up
2705
+ // in `onKeyDown` rather than left to a default.
2706
+ input.addEventListener('keydown', function (ev) {
2707
+ if (ev.key !== 'Enter') return;
2708
+ ev.preventDefault();
2709
+ ev.stopPropagation();
2710
+ finish();
2711
+ });
1401
2712
  input.addEventListener('change', finish);
1402
2713
  input.addEventListener('blur', finish);
1403
- tag.hidden = true;
1404
- rows.insertBefore(input, tag);
1405
- // Three facts, and only the three that are read: that a rename is
1406
- // standing (this being non-null), how to take it down on purpose, and how
1407
- // to let go of it when a repaint takes it down for us. A copy of the span
1408
- // or of the field itself would be a second record of something the
1409
- // closure above already holds.
1410
- groupRename = { cancel: cancel, retire: retire };
2714
+ canvasWrap.appendChild(input);
2715
+ dataEdit = { cancel: cancel, retire: retire };
1411
2716
  input.focus();
1412
2717
  input.select();
1413
2718
  }
1414
2719
 
1415
2720
  /**
1416
- * A copy of `doc` with one scalar replaced, for the two fields the codec's
1417
- * lane operations do not cover (a group's own title).
1418
- *
1419
- * Structure-sharing everywhere it does not have to copy, so the store's
1420
- * lane-identity bookkeeping still sees every untouched lane as the same
1421
- * object — `remapOrigins` answers a content edit by position and a
1422
- * structural one by identity, and a wholesale clone here would make an
1423
- * ordinary rename look like a structural change.
2721
+ * Where a press or an Enter on cycle `cycle` of `lane` should open the
2722
+ * data-label field — or whether it must refuse the gesture outright
2723
+ * instead of letting it fall through to an ordinary paint.
2724
+ *
2725
+ * v3.5.0 Task 11 fix round 1: a cycle INSIDE a bus run that itself owns
2726
+ * no slot (`codec.dataSlotOf` answers `null` — a `.` continuation or a
2727
+ * `|` gap) used to fall straight through to the paint-arming code with
2728
+ * nothing catching it. Reviewer-traced: the press silently repainted
2729
+ * that continuation with whatever brush was current, turning it into a
2730
+ * fresh EXPLICIT value cycle and shifting every LATER label in the lane
2731
+ * one slot to the right — silent data corruption, not an inert click,
2732
+ * because the box this cycle sits inside is exactly what this task now
2733
+ * invites the user to press.
2734
+ *
2735
+ * `isBus(brickOf(...))` is the same test `drawLane` uses to decide
2736
+ * whether a cycle is a bus box at all for the PICTURE, asked here to
2737
+ * decide it for the GESTURE — so a plain non-bus cycle (`0`, `1`, `x`, a
2738
+ * clock, …) is untouched and keeps painting exactly as it always has;
2739
+ * only a cycle INSIDE a bus box is ever diverted. Walking backward for
2740
+ * the owning cycle stops the moment the resolved LEVEL changes — two
2741
+ * adjacent bus runs of the identical value (`2.2.` — measured against
2742
+ * `codec`'s own doc comment: two separate labels, not one) must not
2743
+ * have a later cycle resolve into the EARLIER run's slot.
2744
+ *
2745
+ * Returns:
2746
+ * `{cycle, slot}` — open the field for THIS cycle's slot: either
2747
+ * `cycle` itself owns one, or the walk back to its
2748
+ * run's owning explicit cycle found one.
2749
+ * `'blocked'` — inside a bus box, but no owning cycle exists (a
2750
+ * leading repeater with nothing to continue —
2751
+ * MEASURED, `levelsOf` draws such a lane as `x`,
2752
+ * so this should be unreachable in practice, but
2753
+ * the check is kept rather than assumed so a
2754
+ * future change to that rule fails LOUD here
2755
+ * rather than silently reaching paint again).
2756
+ * Callers must refuse the gesture on this answer,
2757
+ * never fall through.
2758
+ * `null` — not a bus box; ordinary behaviour is unaffected.
1424
2759
  */
1425
- function replaceAt(node, path, value) {
1426
- if (path.length === 0) return value;
1427
- const seg = path[0];
1428
- const child = replaceAt(node[seg], path.slice(1), value);
1429
- if (Array.isArray(node)) {
1430
- const out = node.slice();
1431
- out[seg] = child;
1432
- return out;
1433
- }
1434
- const out = Object.assign({}, node);
1435
- out[seg] = child;
1436
- return out;
1437
- }
1438
-
1439
- /**
1440
- * The hand-drawn waveform.
1441
- *
1442
- * Every cycle's picture comes from `codec.levelsOf` through `brickOf`, and
1443
- * from nowhere else — constraint 3. The levels and the bricks are both
1444
- * published on the element (`data-levels-N`, `data-bricks-N`) so the
1445
- * agreement with the preview beside it is something a test can measure
1446
- * rather than something this comment can promise: the preview's own SVG
1447
- * names its bricks in its `<use xlink:href="#…">` list.
1448
- */
1449
- function renderCanvas(doc, layout) {
1450
- // Every repaint REPLACES the canvas element, so a canvas that had the
1451
- // keyboard loses it to `document.body` the instant it is detached —
1452
- // exactly the state F7 and T6h exist to prevent, and here it would fire
1453
- // on every arrow key. `render()`'s own `data-focus-key` restore covers
1454
- // the full-repaint path; this covers the three that call renderCanvas
1455
- // directly (a drag's two, and the keyboard's own), so the property holds
1456
- // for the canvas no matter which road the repaint came down.
1457
- const hadFocus = canvas !== null && d.activeElement === canvas;
1458
- // The cursor is an index pair, and the document under it can get shorter:
1459
- // deleting cycles or removing the bottom lane leaves it pointing past the
1460
- // end. Clamped HERE, where the layout that decides what "past the end"
1461
- // means is already in hand — so nothing downstream ever has to ask.
1462
- if (cursor !== null) {
1463
- if (layout.lanes.length === 0 || layout.cycles === 0) {
1464
- cursor = null;
1465
- } else {
1466
- cursor = {
1467
- laneIndex: Math.max(0, Math.min(cursor.laneIndex, layout.lanes.length - 1)),
1468
- cycle: Math.max(0, Math.min(cursor.cycle, layout.cycles - 1)),
1469
- };
1470
- }
1471
- }
1472
- // …and `selection` with it, which is the other half of the same fact and
1473
- // was left out of the first cut. Measured before this: Shift-select four
1474
- // cycles, press 刪除 cycle, and a one-cycle document was left with the
1475
- // cursor on cycle 1, no visible box, 複製 reporting「複製了 4 個 cycle」
1476
- // and 貼上(插入)growing the document back to five. The toolbar was
1477
- // acting on a range the drawing no longer had anywhere to put.
1478
- //
1479
- // The two axes are not treated alike, and that is the point: a CYCLE
1480
- // range that overshoots is the same range in a shorter document, so it
1481
- // clamps; a LANE that is gone is not a lane the user can have meant, so
1482
- // the selection goes rather than silently moving to whichever lane
1483
- // inherited the index. (`lane ✕` already nulls it on its own path; this
1484
- // catches the undo/redo of that.)
1485
- //
1486
- // It does NOT catch every route, and the earlier wording here said it
1487
- // did. MEASURED: `+` at 0, drag-select cycles 1-3 on the new lane 0,
1488
- // then Ctrl+Z — the selection box and the rail highlight both stay on
1489
- // row 0, which is now `clk`. The trace-derived marks above fix the
1490
- // CURSOR for that case; the selection is still carried by row number,
1491
- // so it lands on whoever inherited the row. The cost is confined to the
1492
- // highlight and to where the next Shift+arrow anchors, because every
1493
- // cycle operation is document-wide — but it is a real gap, not a
1494
- // covered one.
1495
- if (selection !== null) {
1496
- if (layout.lanes.length === 0 || layout.cycles === 0 ||
1497
- selection.laneIndex < 0 || selection.laneIndex >= layout.lanes.length) {
1498
- selection = null;
1499
- } else {
1500
- selection = {
1501
- laneIndex: selection.laneIndex,
1502
- from: Math.max(0, Math.min(selection.from, layout.cycles - 1)),
1503
- to: Math.max(0, Math.min(selection.to, layout.cycles - 1)),
1504
- };
1505
- }
1506
- }
1507
- canvasWrap.textContent = '';
1508
- canvas = d.createElementNS(SVGNS, 'svg');
1509
- canvas.setAttribute('class', 'ed-wave-canvas');
1510
- // In the Tab cycle, and therefore in the focus trap, like every other
1511
- // control in here: the drawing is this dialog's main surface and was the
1512
- // one thing in it a keyboard could not reach.
1513
- canvas.setAttribute('tabindex', '0');
1514
- canvas.setAttribute('data-focus-key', 'canvas');
1515
- canvas.setAttribute('aria-label',
1516
- '波形繪圖區:方向鍵移動游標,Shift+左右鍵選一段 cycle,' +
1517
- '按電位字元(' + BRUSHES.join(' ') + ')塗上去');
1518
- const width = Math.max(layout.width, SIZES.cycleWidth);
1519
- canvas.setAttribute('width', String(width));
1520
- canvas.setAttribute('height', String(Math.max(layout.height, layout.laneHeight)));
1521
- canvas.setAttribute('viewBox', '0 0 ' + width + ' ' +
1522
- Math.max(layout.height, layout.laneHeight));
1523
- canvas.setAttribute('data-lane-count', String(layout.lanes.length));
1524
- canvas.setAttribute('data-cycle-count', String(layout.cycles));
1525
-
1526
- // cycle grid
1527
- for (let c = 0; c <= layout.cycles; c++) {
1528
- const g = d.createElementNS(SVGNS, 'line');
1529
- g.setAttribute('class', 'ed-wave-grid');
1530
- g.setAttribute('x1', String(c * layout.cycleWidth));
1531
- g.setAttribute('x2', String(c * layout.cycleWidth));
1532
- g.setAttribute('y1', '0');
1533
- g.setAttribute('y2', String(layout.height));
1534
- canvas.appendChild(g);
1535
- }
1536
-
1537
- for (let i = 0; i < layout.lanes.length; i++) {
1538
- drawLane(canvas, layout, i);
1539
- }
1540
-
1541
- if (selection !== null) {
1542
- const from = Math.min(selection.from, selection.to);
1543
- const to = Math.max(selection.from, selection.to);
1544
- const box = d.createElementNS(SVGNS, 'rect');
1545
- box.setAttribute('class', 'ed-wave-selection');
1546
- box.setAttribute('x', String(from * layout.cycleWidth));
1547
- box.setAttribute('y', String(selection.laneIndex * layout.laneHeight));
1548
- box.setAttribute('width', String((to - from + 1) * layout.cycleWidth));
1549
- box.setAttribute('height', String(layout.laneHeight));
1550
- canvas.appendChild(box);
1551
- }
1552
-
1553
- // The keyboard's cell, drawn last so it is over the selection tint and
1554
- // over the trace. Its geometry comes from `wave-geometry.cellRect` — the
1555
- // inverse of the hit test the pointer uses — so the cell the cursor is
1556
- // DRAWN on and the cell an edit lands in cannot drift apart.
1557
- let cursorCell = '';
1558
- if (cursor !== null) {
1559
- const cell = geometry.cellRect(layout, cursor.laneIndex, cursor.cycle);
1560
- if (cell !== null) {
1561
- cursorCell = cursor.laneIndex + ',' + cursor.cycle;
1562
- const cur = d.createElementNS(SVGNS, 'rect');
1563
- cur.setAttribute('class', 'ed-wave-cursor');
1564
- cur.setAttribute('data-ed-wave-cursor', '');
1565
- cur.setAttribute('data-cell', cursorCell);
1566
- cur.setAttribute('x', String(cell.x));
1567
- cur.setAttribute('y', String(cell.y));
1568
- cur.setAttribute('width', String(cell.width));
1569
- cur.setAttribute('height', String(cell.height));
1570
- canvas.appendChild(cur);
1571
- }
1572
- }
1573
- // The same value on the overlay: what a scenario reads when the rect is
1574
- // not the thing under test. It is assigned INSIDE the branch that draws
1575
- // the rect and defaults to empty, so "there is no rect" and "the overlay
1576
- // says none" are one decision rather than two that happen to agree —
1577
- // they did not, in the first cut: this line read `cursor` directly, so a
1578
- // cursor the clamp had not caught published a cell that `cellRect`
1579
- // refused to give a rectangle to, and a build with the clamp removed
1580
- // reported `{cell: null, published: "5,4"}`.
1581
- overlay.setAttribute('data-wave-cursor', cursorCell);
1582
-
1583
- canvas.addEventListener('mousedown', onCanvasDown);
1584
- // Tabbing onto the drawing is an entry like any other, so it puts the
1585
- // cursor on screen rather than leaving a focused surface with no visible
1586
- // cursor in it. Re-entrant only once: `enterDrawing()` repaints, the
1587
- // repaint re-focuses the new canvas and fires this again, and by then
1588
- // `cursor` is set.
1589
- canvas.addEventListener('focus', function () {
1590
- if (cursor === null) enterDrawing();
1591
- });
1592
- canvasWrap.appendChild(canvas);
1593
- if (hadFocus) canvas.focus();
1594
- }
1595
-
1596
- function drawLane(svg, layout, i) {
1597
- const row = layout.lanes[i];
1598
- const lane = row.lane;
1599
- // An ABSENT `wave` and an EMPTY one are two different pictures, and
1600
- // collapsing them into one was the regression the previous round shipped
1601
- // on the very axis it was fixing. MEASURED against the pinned engine,
1602
- // second half-brick per cycle:
1603
- //
1604
- // {name:'a', wave:''} ["xxx","xxx"] ONE x cycle
1605
- // {name:'a'} [] NOTHING
1606
- // {} (spacer) [] NOTHING
1607
- // {name:'a', wave:1234} [] NOTHING
1608
- // {name:'a', wave:null} [] NOTHING
1609
- //
1610
- // and in a mixed diagram the engine simply emits no bricks for that row:
1611
- // `{signal:[{a,'0101'},{},{b,'1010'}]}` is 16 uses, i.e. 2 lanes × 4 × 2.
1612
- // So the rule is exactly "a lane draws cycles only when `wave` is a
1613
- // string"; `''` is a string, which is why it gets its one cycle, and
1614
- // `{}` — the canonical WaveDrom blank-row spacer — gets none.
1615
- //
1616
- // The empty-string case is not a corner either: `deleteCycles` covering
1617
- // every cycle sets every lane's wave to `''`, two clicks away.
1618
- const hasWave = typeof lane.wave === 'string';
1619
- const wave = hasWave ? lane.wave : '';
1620
- const cells = hasWave ? codec.expandWave(wave) : [];
1621
- const levels = !hasWave ? [] : (wave === '' ? ['x'] : codec.levelsOf(cells));
1622
- const bricks = levels.map(brickOf);
1623
- // Which cycles carry the engine's discontinuity marker. `levelsOf`
1624
- // deliberately erases `|` — it answers what a cycle SHOWS, and a gap
1625
- // shows whatever is in front of it — so the marker has to be read off the
1626
- // cells. Measured: `01|10` renders one `<use xlink:href="#gap">` at
1627
- // translate(100), which is the centre of cycle 2, the `|` cycle.
1628
- const gaps = [];
1629
- for (let c = 0; c < cells.length; c++) {
1630
- if (cells[c].held !== true && cells[c].ch === '|') gaps.push(c);
1631
- }
1632
- // `data-wave-<i>` cannot carry the difference between "no `wave` key" and
1633
- // `wave: ''` — both are the empty string — and that difference is exactly
1634
- // what the engine draws differently, so it gets its own attribute.
1635
- svg.setAttribute('data-haswave-' + i, hasWave ? '1' : '0');
1636
- svg.setAttribute('data-wave-' + i, wave);
1637
- svg.setAttribute('data-levels-' + i, levels.join(''));
1638
- svg.setAttribute('data-bricks-' + i, bricks.join(' '));
1639
- svg.setAttribute('data-gaps-' + i, gaps.join(' '));
1640
-
1641
- const hi = row.y + 7;
1642
- const lo = row.y + row.height - 9;
1643
- const mid = (hi + lo) / 2;
1644
- const cw = layout.cycleWidth;
1645
- const labels = labelsOf(lane);
1646
- // Which cycles carry a `data` label: the explicit value cycles, in order.
1647
- // `levelsOf` repeats a bus's level through its continuation cycles, so the
1648
- // label belongs to the cycle that STARTS the run, which is what the run
1649
- // grouping below already knows.
1650
- let labelSlot = 0;
1651
-
1652
- // Runs of one picture. Clock bricks never merge — each `p` cycle is a
1653
- // whole clock period, and a merged run would draw one long one.
1654
- const runs = [];
1655
- for (let c = 0; c < bricks.length; c++) {
1656
- const last = runs.length === 0 ? null : runs[runs.length - 1];
1657
- if (last !== null && last.brick === bricks[c] && !isClock(bricks[c])) last.to = c;
1658
- else runs.push({ brick: bricks[c], from: c, to: c });
1659
- }
1660
-
1661
- function bandOf(brick) {
1662
- if (brick === '111' || brick === 'uuu') return [hi, hi];
1663
- if (brick === '000' || brick === 'ddd') return [lo, lo];
1664
- if (brick === 'zzz') return [mid, mid];
1665
- return [hi, lo];
1666
- }
1667
-
1668
- for (let r = 0; r < runs.length; r++) {
1669
- const run = runs[r];
1670
- const x0 = run.from * cw;
1671
- const x1 = (run.to + 1) * cw;
1672
- const brick = run.brick;
1673
- if (isBus(brick)) {
1674
- const poly = d.createElementNS(SVGNS, 'polygon');
1675
- const s = Math.min(6, cw / 4);
1676
- poly.setAttribute('class', 'ed-wave-bus ed-wave-bus-' + brick.slice(4));
1677
- poly.setAttribute('points',
1678
- (x0 + s) + ',' + hi + ' ' + (x1 - s) + ',' + hi + ' ' +
1679
- x1 + ',' + mid + ' ' + (x1 - s) + ',' + lo + ' ' +
1680
- (x0 + s) + ',' + lo + ' ' + x0 + ',' + mid);
1681
- svg.appendChild(poly);
1682
- const text = labels[labelSlot];
1683
- if (typeof text === 'string' && text !== '') {
1684
- const t = d.createElementNS(SVGNS, 'text');
1685
- t.setAttribute('class', 'ed-wave-buslabel');
1686
- t.setAttribute('x', String((x0 + x1) / 2));
1687
- t.setAttribute('y', String(mid + 4));
1688
- t.setAttribute('text-anchor', 'middle');
1689
- t.textContent = text;
1690
- svg.appendChild(t);
1691
- }
1692
- labelSlot++;
1693
- } else if (brick === 'xxx') {
1694
- const rect = d.createElementNS(SVGNS, 'rect');
1695
- rect.setAttribute('class', 'ed-wave-x');
1696
- rect.setAttribute('x', String(x0));
1697
- rect.setAttribute('y', String(hi));
1698
- rect.setAttribute('width', String(x1 - x0));
1699
- rect.setAttribute('height', String(lo - hi));
1700
- svg.appendChild(rect);
1701
- } else if (isClock(brick)) {
1702
- const xm = (x0 + x1) / 2;
1703
- const p = d.createElementNS(SVGNS, 'path');
1704
- p.setAttribute('class', 'ed-wave-line ed-wave-clock');
1705
- p.setAttribute('d', brick === 'nclk'
1706
- ? 'M' + x0 + ',' + lo + ' L' + x0 + ',' + hi + ' L' + xm + ',' + hi +
1707
- ' L' + xm + ',' + lo + ' L' + x1 + ',' + lo
1708
- : 'M' + x0 + ',' + hi + ' L' + x0 + ',' + lo + ' L' + xm + ',' + lo +
1709
- ' L' + xm + ',' + hi + ' L' + x1 + ',' + hi);
1710
- svg.appendChild(p);
1711
- } else {
1712
- const y = bandOf(brick)[0];
1713
- const line = d.createElementNS(SVGNS, 'line');
1714
- line.setAttribute('class', brick === 'uuu' || brick === 'ddd'
1715
- ? 'ed-wave-line ed-wave-weak' : 'ed-wave-line');
1716
- line.setAttribute('x1', String(x0));
1717
- line.setAttribute('x2', String(x1));
1718
- line.setAttribute('y1', String(y));
1719
- line.setAttribute('y2', String(y));
1720
- svg.appendChild(line);
1721
- }
1722
-
1723
- // The edge into this run. Clock bricks draw their own.
1724
- if (r > 0 && !isClock(brick) && !isClock(runs[r - 1].brick)) {
1725
- const a = bandOf(runs[r - 1].brick);
1726
- const b = bandOf(brick);
1727
- const top = Math.min(a[0], a[1], b[0], b[1]);
1728
- const bot = Math.max(a[0], a[1], b[0], b[1]);
1729
- if (bot > top) {
1730
- const edge = d.createElementNS(SVGNS, 'line');
1731
- edge.setAttribute('class', 'ed-wave-edge');
1732
- edge.setAttribute('x1', String(x0));
1733
- edge.setAttribute('x2', String(x0));
1734
- edge.setAttribute('y1', String(top));
1735
- edge.setAttribute('y2', String(bot));
1736
- svg.appendChild(edge);
1737
- }
1738
- }
1739
- }
1740
-
1741
- // The gap marker, last so it sits over the trace it interrupts. The
1742
- // engine draws one per `|`; drawing none made the two pictures disagree
1743
- // on a lane whose levels agreed exactly, which is the quietest form of
1744
- // the disagreement this overlay exists to surface.
1745
- for (const c of gaps) {
1746
- const xm = (c + 0.5) * cw;
1747
- const mark = d.createElementNS(SVGNS, 'path');
1748
- mark.setAttribute('class', 'ed-wave-gap');
1749
- mark.setAttribute('d',
1750
- 'M' + (xm - 5) + ',' + (lo + 3) + ' L' + (xm + 1) + ',' + (mid) +
1751
- ' L' + (xm - 5) + ',' + (hi - 3) +
1752
- ' M' + (xm - 1) + ',' + (lo + 3) + ' L' + (xm + 5) + ',' + (mid) +
1753
- ' L' + (xm - 1) + ',' + (hi - 3));
1754
- svg.appendChild(mark);
2760
+ function resolveBusTarget(lane, cycle) {
2761
+ const slot = codec.dataSlotOf(lane, cycle);
2762
+ if (slot !== null) return { cycle: cycle, slot: slot };
2763
+ const levels = codec.levelsOf(typeof lane.wave === 'string' ? lane.wave : '');
2764
+ if (!drawer.isBus(drawer.brickOf(levels[cycle]))) return null;
2765
+ for (let i = cycle - 1; i >= 0 && levels[i] === levels[cycle]; i--) {
2766
+ const s = codec.dataSlotOf(lane, i);
2767
+ if (s !== null) return { cycle: i, slot: s };
1755
2768
  }
2769
+ return 'blocked';
1756
2770
  }
1757
2771
 
1758
2772
  // ── the keyboard on the drawing ───────────────────────────────────────
@@ -1776,6 +2790,17 @@
1776
2790
  * brush is on. Everything else the dialog can do is a real `<button>` or
1777
2791
  * `<input>` that Tab already reaches.
1778
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
+ *
1779
2804
  * Two targets are accepted, and only two:
1780
2805
  * the canvas — the surface itself, `tabindex="0"`, so it is in the Tab
1781
2806
  * cycle and in the focus trap like every other control.
@@ -1801,20 +2826,97 @@
1801
2826
  enterDrawing();
1802
2827
  return true;
1803
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.
1804
2851
  if (nav) {
1805
2852
  ev.preventDefault();
1806
2853
  ev.stopPropagation();
1807
2854
  moveCursor(key, ev.shiftKey === true);
1808
2855
  return true;
1809
2856
  }
2857
+ // v3.5.0 Task 7: the keyboard's two-step version of the drag, ahead of
2858
+ // the plain Enter-paints case below so an armed Enter never falls
2859
+ // through to it. First Enter marks `cursor` as `pendingFrom` (arrows
2860
+ // above already moved it, exactly as a mouse press would have);
2861
+ // second Enter — from wherever the cursor walked to since — is the
2862
+ // SAME `finishEdgeDrag` the mouse's `onCanvasUp` calls, so the three
2863
+ // no-op drops and the one-armed-if-nothing-happened rule are decided
2864
+ // in that one place, not re-decided here.
2865
+ if (edgeMode === 'armed' && key === 'Enter' && cursor !== null) {
2866
+ ev.preventDefault();
2867
+ ev.stopPropagation();
2868
+ if (pendingFrom === null) {
2869
+ pendingFrom = { at: cursor.laneIndex, cell: cursor.cycle };
2870
+ panels.say('起點已標記,移到終點再按一次 Enter');
2871
+ render();
2872
+ } else {
2873
+ finishEdgeDrag(cursor.laneIndex, cursor.cycle);
2874
+ }
2875
+ return true;
2876
+ }
2877
+ // v3.5.0 Task 11: Enter on a cell inside a bus box opens that box's
2878
+ // label field instead of painting over it with the brush — the
2879
+ // keyboard's own version of the pointer's click below in
2880
+ // `onCanvasDown`. `resolveBusTarget` is the SAME resolution
2881
+ // `onCanvasDown` uses (fix round 1: a cursor resting on a `.`/`|`
2882
+ // continuation inside a run used to fall straight through to
2883
+ // `paintAtCursor()` below with nothing catching it — the identical
2884
+ // silent-corruption hazard the pointer path had, closed the same way
2885
+ // here rather than only on the mouse), so the two input devices can
2886
+ // never disagree about which cells are labellable or where a press
2887
+ // on a continuation actually lands. Placed AFTER the armed-edge Enter
2888
+ // above (arming an edge outranks opening a label field — the same
2889
+ // press cannot mean both) and BEFORE the generic Enter-paints case
2890
+ // below, which it must pre-empt.
2891
+ if (key === 'Enter' && cursor !== null) {
2892
+ const dlLayout = geometry.layoutOf(store.doc, SIZES);
2893
+ const dlRow = dlLayout.lanes[cursor.laneIndex];
2894
+ const dlLane = dlRow === undefined ? null : dlRow.lane;
2895
+ if (dlLane !== null) {
2896
+ const dlTarget = resolveBusTarget(dlLane, cursor.cycle);
2897
+ if (dlTarget === 'blocked') {
2898
+ ev.preventDefault();
2899
+ ev.stopPropagation();
2900
+ panels.say('這一格接在某個 bus 值後面,但看不出屬於哪一個,沒有欄位可以編輯。');
2901
+ return true;
2902
+ }
2903
+ if (dlTarget !== null) {
2904
+ ev.preventDefault();
2905
+ ev.stopPropagation();
2906
+ cursor = { laneIndex: cursor.laneIndex, cycle: dlTarget.cycle };
2907
+ openDataEdit(cursor.laneIndex, dlTarget.cycle);
2908
+ return true;
2909
+ }
2910
+ }
2911
+ }
1810
2912
  // A brush character both PICKS the brush and paints with it. Picking
1811
2913
  // without painting would need a second keystroke to do the thing the
1812
2914
  // user already said, and the toolbar's own brush buttons are still there
1813
- // for「選起來待會再用」. `ev.shiftKey` is excluded so a shifted key can
1814
- // never paint: with Shift the browser reports `H`, not `h`, and a brush
1815
- // table that quietly lower-cased it would make Shift+arrow's neighbour
1816
- // keys destructive.
1817
- if (!ev.shiftKey && BRUSHES.indexOf(key) !== -1) {
2915
+ // for「選起來待會再用」. `isBrushKey` is the admission rule — see its
2916
+ // own comment (this file's top) for why it is not keyed on
2917
+ // `ev.shiftKey` (v3.5.0 Task 10 fix round 1: that used to block
2918
+ // `P`/`N`/`|`, all typed with Shift, from the keyboard entirely).
2919
+ if (isBrushKey(key)) {
1818
2920
  ev.preventDefault();
1819
2921
  ev.stopPropagation();
1820
2922
  brush = key;
@@ -1859,9 +2961,16 @@
1859
2961
  * ONE lane, a run of cycles — is exactly what a cursor plus Shift can
1860
2962
  * describe. A pointer press already collapses it to the pressed cell; a
1861
2963
  * plain arrow does the same, and Shift+←/→ keeps the anchor it was made
1862
- * with. Shift+↑/↓ collapses: a selection that spanned two lanes is not a
1863
- * thing this document model has, so pretending with a highlight would be
1864
- * 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.
1865
2974
  */
1866
2975
  function moveCursor(key, extend) {
1867
2976
  const layout = geometry.layoutOf(store.doc, SIZES);
@@ -1880,15 +2989,103 @@
1880
2989
  const sameLane = lane === cursor.laneIndex;
1881
2990
  cursor = { laneIndex: lane, cycle: cyc };
1882
2991
  if (extend && sameLane && selection !== null && selection.laneIndex === lane) {
1883
- selection = { laneIndex: lane, from: selection.from, to: cyc };
2992
+ selection = { laneIndex: lane, laneTo: lane, from: selection.from, to: cyc };
1884
2993
  } else {
1885
- selection = { laneIndex: lane, from: cyc, to: cyc };
2994
+ selection = { laneIndex: lane, laneTo: lane, from: cyc, to: cyc };
1886
2995
  }
1887
2996
  repaintDrawing(layout);
1888
2997
  scrollCursorIntoView(layout);
1889
2998
  sayCursor(layout);
1890
2999
  }
1891
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;
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 };
3084
+ repaintDrawing(layout);
3085
+ scrollCursorIntoView(layout);
3086
+ sayCursor(layout);
3087
+ }
3088
+
1892
3089
  /** Paint the cursor's run with the current brush: one gesture, one
1893
3090
  * `commit`, exactly like the pointer's `mouseup`. The range comes from
1894
3091
  * `selection` when it belongs to the cursor's own lane — which every
@@ -1930,7 +3127,7 @@
1930
3127
  const to = selection === null ? cursor.cycle : Math.max(selection.from, selection.to);
1931
3128
  const span = (selection !== null && selection.laneIndex === cursor.laneIndex && to > from)
1932
3129
  ? ',選了 cycle ' + (from + 1) + '–' + (to + 1) : '';
1933
- say('游標:' + name + ' cycle ' + (cursor.cycle + 1) + span);
3130
+ panels.say('游標:' + name + ' cycle ' + (cursor.cycle + 1) + span);
1934
3131
  }
1935
3132
 
1936
3133
  /** The lane list and the drawing, repainted together and without touching
@@ -1941,8 +3138,29 @@
1941
3138
  function repaintDrawing(layout) {
1942
3139
  const l = layout === undefined || layout === null
1943
3140
  ? geometry.layoutOf(store.doc, SIZES) : layout;
1944
- renderLanes(store.doc, l);
1945
- 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);
1946
3164
  }
1947
3165
 
1948
3166
  /**
@@ -1990,11 +3208,262 @@
1990
3208
  function onCanvasDown(ev) {
1991
3209
  if (ev.button !== 0) return;
1992
3210
  const layout = geometry.layoutOf(store.doc, SIZES);
3211
+ // v3.5.0 Task 7: armed edge-creation claims the press outright, before
3212
+ // the edge-select / deselect / paint dispatch below ever runs — the
3213
+ // whole point of arming is that the NEXT press starts the drag, so it
3214
+ // must not be shadowed by "did this land on an existing edge" or "did
3215
+ // this land on a cell to paint". A press that misses every lane
3216
+ // (`at === null`, e.g. the canvas's own padding) leaves the button
3217
+ // armed rather than eating it for nothing: missing the diagram is not
3218
+ // the same as aiming at the wrong cell, but the recovery is the same —
3219
+ // try the press again.
3220
+ if (edgeMode === 'armed') {
3221
+ const at = cellFromEvent(ev, layout);
3222
+ if (at === null) return;
3223
+ ev.preventDefault();
3224
+ pendingFrom = { at: at.laneIndex, cell: at.cycle };
3225
+ setEdgeMode('dragging');
3226
+ // The keyboard follows the pointer here for the same reason it does
3227
+ // in the paint path below: `cursor` is the ONE notion of "where is
3228
+ // the other end right now" that the mouse move handler, the preview
3229
+ // line and (on the keyboard's own path) `handleDrawingKey` all read,
3230
+ // so drag and keystroke can never disagree about the live endpoint.
3231
+ cursor = { laneIndex: at.laneIndex, cycle: at.cycle };
3232
+ canvas.focus();
3233
+ render();
3234
+ d.addEventListener('mousemove', onCanvasMove, true);
3235
+ d.addEventListener('mouseup', onCanvasUp, true);
3236
+ return;
3237
+ }
3238
+ // v3.5.0 Task 8: a press on the SELECTED edge's own handle starts an
3239
+ // endpoint drag, ahead of the plain select/deselect dispatch a few
3240
+ // lines down — that dispatch's own `edgeHitAt` would otherwise see the
3241
+ // exact same press and read it as "re-select the edge that is already
3242
+ // selected", which is a real answer but the wrong one for a press ON
3243
+ // its handle. Recomputed fresh here (`geometry.edgeLayout`, not a
3244
+ // cached list) for the same reason `cellFromEvent`/`edgeHitAt`
3245
+ // already are — a press can never be judged against a layout
3246
+ // `renderCanvas` has moved on from.
3247
+ //
3248
+ // Scoped to `handle.index === selectedEdge` on purpose:
3249
+ // `geometry.edgeHandleAt` answers for EVERY edge's endpoints
3250
+ // regardless of selection (see its own comment), but `renderEdges`
3251
+ // only ever DRAWS handles on the selected one — a press near a
3252
+ // DIFFERENT edge's endpoint has no handle on screen to have landed on,
3253
+ // and falls through to the ordinary select-by-path/point dispatch
3254
+ // below exactly as it always has.
3255
+ if (selectedEdge !== null) {
3256
+ const edges = geometry.edgeLayout(store.doc, layout);
3257
+ const box = canvas.getBoundingClientRect();
3258
+ const handle = geometry.edgeHandleAt(edges, ev.clientX - box.left, ev.clientY - box.top);
3259
+ if (handle !== null && handle.index === selectedEdge) {
3260
+ ev.preventDefault();
3261
+ // Fix round 1, item 1: a self-loop (`from === to`) puts both ends
3262
+ // at the exact same pixel, so `geometry.edgeHandleAt`'s own
3263
+ // `['to', 'from']` iteration always resolves the press to `'to'` —
3264
+ // there is no position this file (or the user) can use to tell
3265
+ // the two apart, and inferring one would just be a guess dressed
3266
+ // up as an answer. What was missing was not a fix, it was the
3267
+ // honest half: before this, the press silently moved `'to'` with
3268
+ // nothing on screen saying that is what happened, or that `'from'`
3269
+ // was never reachable at all.
3270
+ const dragged = edges.filter(function (e) { return e.index === handle.index; })[0];
3271
+ if (dragged !== undefined && dragged.edge.from === dragged.edge.to) {
3272
+ panels.say('這是一條 self-loop(起點跟終點在同一格),兩端的把手疊在同一個位置:' +
3273
+ '只有拖得到的這一端可以搬,另一端目前搬不到。');
3274
+ }
3275
+ endpointDrag = { index: handle.index, end: handle.end, at: null, cell: null };
3276
+ canvas.focus();
3277
+ // Fix round 1, item 2: without a repaint here the press looks
3278
+ // inert until the first `pointermove` — `.is-drag-source` (set
3279
+ // from this same `endpointDrag`, read by `renderEdges`) would not
3280
+ // fade the dragged edge until then, unlike the sibling
3281
+ // `edgeMode === 'armed'` branch above, which repaints immediately
3282
+ // on its own press. `renderCanvas`, not the full `render()`: this
3283
+ // state change touches only the canvas SVG (the faded path; the
3284
+ // dashed landing preview has nothing to draw yet since `at` is
3285
+ // still `null`) — `selectedEdge` is unchanged, so the edge
3286
+ // inspector row and the lane list are both already showing exactly
3287
+ // what they should, and paying for `renderLanes`/`renderEdgeBar`
3288
+ // again would be the same needless cost the existing
3289
+ // `hadSelectedEdge`-gated branch further down in this function
3290
+ // already avoids.
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);
3311
+ d.addEventListener('mousemove', onCanvasMove, true);
3312
+ d.addEventListener('mouseup', onCanvasUp, true);
3313
+ return; // 不塗、不選格
3314
+ }
3315
+ }
3316
+ // v3.5.0 Task 11 fix round 2 (widened in round 3): a press that lands
3317
+ // on the label's own mark — the `<text>` when the run has a label, or
3318
+ // (round 3) the small `<circle class="ed-wave-buslabel-empty">`
3319
+ // `drawLane` draws in its place when the run has none — opens the
3320
+ // data-label field instead of arming a paint drag, rather than merely
3321
+ // somewhere inside the box the mark sits in. Round 1 gated this on
3322
+ // the CELL (`codec.dataSlotOf`/`resolveBusTarget` alone, asked off
3323
+ // the pressed cycle with no regard to what was actually under the
3324
+ // pointer) and that was reviewer-verified to be its own regression:
3325
+ // T6b paints `N` onto a bus cell with the mouse, and painting a
3326
+ // bus cell is exactly what twenty-two brushes exist for — a gesture
3327
+ // that swallows every press anywhere in the box leaves the user no
3328
+ // pointer route to repaint one at all. The string is what a press on
3329
+ // its mark means to change, so the mark is the target; a press
3330
+ // anywhere else in the SAME box — the polygon, its bevelled ends, a
3331
+ // held continuation's own cell — falls straight through to the
3332
+ // ordinary paint dispatch below exactly as it always has, ROUND 1's
3333
+ // fix included: that fix's `resolveBusTarget` still runs, just gated
3334
+ // on the mark hit rather than on cell membership, so a press on the
3335
+ // label of a merged run still resolves to the run's owning explicit
3336
+ // cycle regardless of which cycle's pixels the mark happens to
3337
+ // straddle (see `resolveBusTarget`'s own comment — its answer is the
3338
+ // same whichever cycle a press on that shared mark geometrically
3339
+ // lands on). Both marks carry the SAME `ed-wave-buslabel` class —
3340
+ // round 3's placeholder ADDS `ed-wave-buslabel-empty` on top of it
3341
+ // rather than replacing it — so this check needed no change at all
3342
+ // to cover the empty case; it was already asking for the class, not
3343
+ // for an element type. `.ed-wave-buslabel`'s `pointer-events:
3344
+ // visiblePainted` (`lib/md2doc.js`) plus paint order — each mark is
3345
+ // appended AFTER its box's `<polygon>` in `drawLane`, so it sits on
3346
+ // top and wins the hit test in its own footprint — is what makes the
3347
+ // two targets disjoint instead of the mark being invisibly swallowed
3348
+ // by the polygon underneath it.
3349
+ //
3350
+ // final review finding 4: this block moved AHEAD of `edgeHitAt` below
3351
+ // (it used to run after it), and its condition now asks
3352
+ // `busLabelHitAt` instead of testing `ev.target` directly.
3353
+ //
3354
+ // Gating `edgeHandleAt` on `selectedEdge` (the change in `edgeHitAt`
3355
+ // itself, above) stops a PHANTOM, undrawn handle from claiming a press
3356
+ // by pure coordinate math — but MEASURED against a real page, that
3357
+ // alone does not make this block reachable: `renderEdges` draws
3358
+ // `.ed-wave-edge-hit` (a 10px-wide, invisible-but-hit-testable stroke,
3359
+ // `pointer-events: stroke`) AFTER every lane's own content, so for any
3360
+ // edge whose endpoint lands on a bus cell, that invisible stroke
3361
+ // paints ON TOP of the cell's `.ed-wave-buslabel` mark. `ev.target` —
3362
+ // the browser's own topmost-element answer — is `path.ed-wave-edge-
3363
+ // hit` at that pixel regardless of source order in THIS function:
3364
+ // moving this block ahead of `edgeHitAt` does not change what
3365
+ // `ev.target` IS, only when it gets asked. Reordering plus the
3366
+ // original `ev.target` check was tried first and measured to still
3367
+ // fail — `elementFromPoint` at the labelled cell's own centre resolved
3368
+ // to the hit path, not the label, exactly as before.
3369
+ //
3370
+ // `busLabelHitAt` answers a different question: not "what is the
3371
+ // TOPMOST element here" but "is there a bus-label mark ANYWHERE in the
3372
+ // stack of elements under this press", via `elementsFromPoint`
3373
+ // (plural) — the standard way to see past one hit-testable layer
3374
+ // sitting on top of another at the same point. That is a real,
3375
+ // measured difference in outcome, not a matter of code order.
3376
+ if (busLabelHitAt(ev) !== null) {
3377
+ const busAt = cellFromEvent(ev, layout);
3378
+ if (busAt !== null) {
3379
+ const busLane = layout.lanes[busAt.laneIndex].lane;
3380
+ const busTarget = resolveBusTarget(busLane, busAt.cycle);
3381
+ // `resolveBusTarget` should never actually answer `null` here —
3382
+ // the label only ever exists over a cycle the codec itself calls
3383
+ // a bus level — but the check is kept rather than assumed, same
3384
+ // reasoning as the `'blocked'` branch just below it.
3385
+ if (busTarget === 'blocked') {
3386
+ ev.preventDefault();
3387
+ panels.say('這一格接在某個 bus 值後面,但看不出屬於哪一個,沒有欄位可以編輯。');
3388
+ return;
3389
+ }
3390
+ if (busTarget !== null) {
3391
+ ev.preventDefault();
3392
+ const busHadSelectedEdge = selectedEdge !== null;
3393
+ selectedEdge = null;
3394
+ cursor = { laneIndex: busAt.laneIndex, cycle: busTarget.cycle };
3395
+ selection = {
3396
+ laneIndex: busAt.laneIndex, laneTo: busAt.laneIndex,
3397
+ from: busTarget.cycle, to: busTarget.cycle,
3398
+ };
3399
+ canvas.focus();
3400
+ if (busHadSelectedEdge) render();
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
+ }
3423
+ openDataEdit(busAt.laneIndex, busTarget.cycle);
3424
+ return;
3425
+ }
3426
+ }
3427
+ }
3428
+ // An edge — its endpoint handle first (it sits ON TOP of the cell it
3429
+ // anchors to, and a selected edge's handle must win the press even
3430
+ // though the cell underneath is perfectly paintable), then its path —
3431
+ // is asked BEFORE the cell/brush dispatch below (but AFTER the bus
3432
+ // label block above — see its own comment for why that one now goes
3433
+ // first). A hit selects and returns without arming `drag`: this
3434
+ // dispatch draws and selects edges; a press on the SELECTED edge's own
3435
+ // handle never reaches here at all (see the drag-start branch above),
3436
+ // so this remains a plain select — it does not itself drag anything.
3437
+ const edgeHit = edgeHitAt(ev, layout);
3438
+ if (edgeHit !== null) {
3439
+ ev.preventDefault();
3440
+ selectedEdge = edgeHit;
3441
+ canvas.focus();
3442
+ render();
3443
+ return;
3444
+ }
3445
+ // A press that hit neither an edge's handle nor its path deselects
3446
+ // whatever WAS selected. The inspector row is the only visible sign a
3447
+ // selection exists, and a selection that outlives a click elsewhere
3448
+ // would let a later 刪除這條線 act on an edge the user has stopped
3449
+ // looking at. This is IN ADDITION to the paint gesture below, on the
3450
+ // very same press — not instead of it.
3451
+ const hadSelectedEdge = selectedEdge !== null;
3452
+ selectedEdge = null;
1993
3453
  const at = cellFromEvent(ev, layout);
1994
- if (at === null) return;
3454
+ if (at === null) {
3455
+ // Missed the diagram entirely (e.g. the canvas's own padding). Only
3456
+ // the deselect, if there was one to make, needs a repaint; nothing
3457
+ // else here changed.
3458
+ if (hadSelectedEdge) render();
3459
+ return;
3460
+ }
1995
3461
  ev.preventDefault();
1996
3462
  drag = { laneIndex: at.laneIndex, from: at.cycle, to: at.cycle };
1997
- 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
+ };
1998
3467
  // The keyboard follows the pointer, for two reasons that are really one:
1999
3468
  // the two cursors may not disagree about which cell is current, and
2000
3469
  // `preventDefault()` above suppresses the focus a press would otherwise
@@ -2002,12 +3471,131 @@
2002
3471
  // then pressing `1` would paint wherever the keyboard had been left.
2003
3472
  cursor = { laneIndex: at.laneIndex, cycle: at.cycle };
2004
3473
  canvas.focus();
2005
- renderCanvas(store.doc, layout);
3474
+ // The common case — no edge was selected — keeps the cheap
3475
+ // `renderCanvas`-only repaint this always did. A full `render()` is
3476
+ // only paid on the transition that actually needs it: hiding the
3477
+ // inspector row, which lives in the toolbar `renderCanvas` never
3478
+ // touches.
3479
+ if (hadSelectedEdge) render();
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
+ }
2006
3502
  d.addEventListener('mousemove', onCanvasMove, true);
2007
3503
  d.addEventListener('mouseup', onCanvasUp, true);
2008
3504
  }
2009
3505
 
2010
3506
  function onCanvasMove(ev) {
3507
+ if (endpointDrag !== null) {
3508
+ // Same abandoned-button recovery as the two drags below: the button
3509
+ // came up somewhere this page never heard about.
3510
+ if (ev.buttons === 0) { onCanvasUp(); return; }
3511
+ const layout = geometry.layoutOf(store.doc, SIZES);
3512
+ const at = cellFromEvent(ev, layout);
3513
+ // A move that strays off the diagram leaves `at`/`cell` exactly
3514
+ // where they last were, same recovery as `edgeMode === 'dragging'`
3515
+ // below and for the same reason: the drop target the user is
3516
+ // currently over is still the last cell the pointer was actually on
3517
+ // top of, not "nowhere".
3518
+ if (at !== null) {
3519
+ endpointDrag = { index: endpointDrag.index, end: endpointDrag.end,
3520
+ at: at.laneIndex, cell: at.cycle };
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.
3531
+ // The document is untouched during the drag (rule 3): this repaints
3532
+ // the preview only, never a `commit`.
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);
3553
+ return;
3554
+ }
3555
+ if (edgeMode === 'dragging') {
3556
+ // Same abandoned-button recovery as the paint drag below: the button
3557
+ // came up somewhere this page never heard about.
3558
+ if (ev.buttons === 0) { onCanvasUp(); return; }
3559
+ const layout = geometry.layoutOf(store.doc, SIZES);
3560
+ const at = cellFromEvent(ev, layout);
3561
+ // A move that strays off the diagram leaves `cursor` — and so the
3562
+ // preview line — exactly where it last was, rather than snapping the
3563
+ // line away: the drop target the user is currently over is still the
3564
+ // last cell the pointer was actually on top of.
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
+ }
3575
+ // The document is untouched during the drag (rule 2): this repaints
3576
+ // the preview line only, never a `commit`.
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);
3597
+ return;
3598
+ }
2011
3599
  if (drag === null) return;
2012
3600
  // The button came up somewhere this page never heard about — outside the
2013
3601
  // window, or over the browser's own chrome. Without this the drag stays
@@ -2022,16 +3610,53 @@
2022
3610
  // The lane is fixed by the press: a drag that strays onto a neighbour is
2023
3611
  // still painting the lane it started on.
2024
3612
  drag.to = at.cycle;
2025
- 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
+ };
2026
3617
  // The drag's live end is where the cursor is, so a paint continued with
2027
3618
  // Shift+←/→ after the button comes up carries on from the same cell.
2028
3619
  cursor = { laneIndex: drag.laneIndex, cycle: drag.to };
2029
- 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);
2030
3640
  }
2031
3641
 
2032
3642
  function onCanvasUp() {
2033
3643
  d.removeEventListener('mousemove', onCanvasMove, true);
2034
3644
  d.removeEventListener('mouseup', onCanvasUp, true);
3645
+ if (endpointDrag !== null) {
3646
+ finishEndpointDrag();
3647
+ return;
3648
+ }
3649
+ if (edgeMode === 'dragging') {
3650
+ // `cursor` is never null here: `onCanvasDown` set it to the press
3651
+ // cell before this drag was ever armed, and every move since then
3652
+ // only ever moved it to another real cell (see `onCanvasMove`
3653
+ // above). The null-safe form costs nothing and matches the
3654
+ // `finishEdgeDrag`/`codec.ensureNode` contract exactly rather than
3655
+ // asserting a fact this file does not have to assert.
3656
+ finishEdgeDrag(cursor === null ? null : cursor.laneIndex,
3657
+ cursor === null ? null : cursor.cycle);
3658
+ return;
3659
+ }
2035
3660
  if (drag === null) return;
2036
3661
  const g = drag;
2037
3662
  drag = null;
@@ -2040,53 +3665,347 @@
2040
3665
  });
2041
3666
  }
2042
3667
 
2043
- /** Pointer coordinates in the drawing's own space, then the codec's cell. */
3668
+ /**
3669
+ * The end of an endpoint drag on the SELECTED edge — one `commit`, so one
3670
+ * undo step, matching every other gesture in this file (constraint 5).
3671
+ *
3672
+ * Unlike `finishEdgeDrag` below, this has no keyboard-driven twin and
3673
+ * takes no parameters: there is no second input device sharing `cursor`
3674
+ * to aim it, so everything the drop needs already lives in `endpointDrag`
3675
+ * itself, read and cleared in the same first two lines the same way
3676
+ * `finishEdgeDrag` clears `pendingFrom`'s owning mode up front.
3677
+ *
3678
+ * `drag.at === null` means the pointer never landed on a real cell for
3679
+ * the whole gesture (pressed the handle, released off the diagram or
3680
+ * without ever moving) — nothing to drop onto, so this only repaints to
3681
+ * take the (already absent) preview line off screen and does not call
3682
+ * `commit` at all; a `commit` with a non-integer `at` would just be
3683
+ * `codec.moveEdgeEnd`'s own bounds check refusing it one function later
3684
+ * for no benefit.
3685
+ *
3686
+ * Every other landing — including the three no-ops `moveEdgeEnd` itself
3687
+ * refuses (dropping back on the end's own cell, dropping on the cell
3688
+ * holding the edge's OTHER end, and an out-of-range target) — reaches
3689
+ * `commit` exactly once and is judged by `commit`'s own return value,
3690
+ * the same "one test of did-anything-change" rule `finishEdgeDrag`
3691
+ * documents at length below: nothing here re-derives any of those three
3692
+ * checks, that would be a second belief about the same refusal living
3693
+ * next to `moveEdgeEnd`'s own.
3694
+ */
3695
+ function finishEndpointDrag() {
3696
+ const drag = endpointDrag;
3697
+ endpointDrag = null;
3698
+ if (drag === null) return;
3699
+ if (drag.at === null) { render(); return; }
3700
+ commit('edge-move', function (doc) {
3701
+ return codec.moveEdgeEnd(doc, drag.index, drag.end, drag.at, drag.cell);
3702
+ });
3703
+ render();
3704
+ }
3705
+
3706
+ /**
3707
+ * The end of an edge-creation gesture, reached from both input devices —
3708
+ * the mouse's `onCanvasUp` above and the keyboard's second Enter in
3709
+ * `handleDrawingKey` below. Constraint 4: one function, not two
3710
+ * histories of the same decision.
3711
+ *
3712
+ * `at`/`cell` name the drop cell, or `null`/`null` when the gesture did
3713
+ * not land on any lane at all. Nothing below special-cases the no-op
3714
+ * drops rule 3 lists — dropping back on the start cell, dropping on a
3715
+ * row with no waveform (a spacer — a group's own title is never a row
3716
+ * to drop on in the first place: `codec.isLane` rejects the title
3717
+ * string, so `flattenLanes` never allocates one, and there is no
3718
+ * coordinate here to mis-hit), dropping on the cell that already holds
3719
+ * this edge's OTHER end, and — fix round 1 — repeating the exact same
3720
+ * drag a second time:
3721
+ *
3722
+ * - a spacer's row IS a real, hittable cell (`geometry.cellAt` answers
3723
+ * it like any other), but `codec.ensureNode`'s own lane-local bound
3724
+ * refuses it: an empty `wave` reads as zero cells, so no cycle is
3725
+ * ever in range. The "missed the diagram entirely" case
3726
+ * (`at === null`) falls through the SAME check for a different
3727
+ * reason — neither `at` nor `cell` is an integer `ensureNode`'s
3728
+ * bounds test accepts;
3729
+ * - the start cell and the drop cell landing on the same anchor —
3730
+ * whether because they truly are the same cell, or because the drop
3731
+ * cell is wherever this edge's other end already lives — is exactly
3732
+ * `a.letter === b.letter` below;
3733
+ * - a repeat of this exact gesture between the same two cells would,
3734
+ * without the check below, silently push a byte-identical second
3735
+ * `doc.edge` entry — two arrows drawn exactly on top of each other,
3736
+ * indistinguishable on screen, plus a second undo step for nothing
3737
+ * visible (MEASURED against the real store: drag (0,1)->(0,3) twice,
3738
+ * `doc.edge` becomes `["a~>b","a~>b"]`). Refused here rather than
3739
+ * before `commit`, same as the other three: an entry with the same
3740
+ * four fields (`from`, `to`, `shape`, `label`) already in `doc.edge`
3741
+ * makes this call's own output redundant. This is narrower than
3742
+ * "refuse any second edge between these two anchors" — two
3743
+ * DIFFERENT annotations between the same pair (`a~>b setup` next to
3744
+ * `a-b hold`) are legitimate and stay possible; only a verbatim
3745
+ * repeat of THIS gesture's own fixed output (`shape: '~>'`,
3746
+ * `label: ''`) is refused.
3747
+ *
3748
+ * So there is one test of "did anything change", not four: `commit`'s
3749
+ * own return value. A refused/no-op commit pushes no undo step (`commit`
3750
+ * already guarantees that) and — rule 1 — leaves the gesture `'armed'`
3751
+ * rather than disarming it, because the user pressed the button and got
3752
+ * nothing for it; a real edge disarms back to `'idle'`. Either way this
3753
+ * repaints once more on its own, because `commit` only repaints when it
3754
+ * changed something, and the pending-line preview has to come off the
3755
+ * screen in the no-op case too.
3756
+ *
3757
+ * Fix round 3: `pendingFrom === null` on entry is no longer read as
3758
+ * "nothing to do" and left at that. `carryMarks` can now drop
3759
+ * `pendingFrom` to `null` on its own (round 2, both when a structural
3760
+ * change makes it untraceable and when no `laneTrace` is injected at
3761
+ * all) WITHOUT touching `edgeMode` — before round 2 the only code path
3762
+ * that ever nulled `pendingFrom` was `setEdgeMode('idle')` itself, so
3763
+ * the two could never come apart. Reached from `onCanvasUp` while
3764
+ * `edgeMode === 'dragging'` (the mouse button was down when the
3765
+ * document changed out from under it), leaving `edgeMode` untouched
3766
+ * here would strand it at `'dragging'` forever: `onCanvasMove` and
3767
+ * `onCanvasUp` both check that mode FIRST, so every later paint drag's
3768
+ * `mousemove`/`mouseup` would be swallowed by this function instead of
3769
+ * painting, silently and with no error. So this resyncs the mode to
3770
+ * the mark it depends on before doing anything else.
3771
+ */
3772
+ function finishEdgeDrag(at, cell) {
3773
+ if (pendingFrom === null) { setEdgeMode('idle'); render(); return; }
3774
+ const from = pendingFrom;
3775
+ const changed = commit('edge-add', function (doc) {
3776
+ const a = codec.ensureNode(doc, from.at, from.cell);
3777
+ if (a.letter === null) return doc;
3778
+ const b = codec.ensureNode(a.doc, at, cell);
3779
+ if (b.letter === null) return doc;
3780
+ if (a.letter === b.letter) return doc; // same anchor both ends
3781
+ const dup = (b.doc.edge || []).some(function (entry) {
3782
+ const e = codec.parseEdge(entry);
3783
+ return e !== null && e.from === a.letter && e.to === b.letter &&
3784
+ e.shape === '~>' && e.label === '';
3785
+ });
3786
+ if (dup) return doc; // byte-identical repeat of this gesture
3787
+ return codec.addEdge(b.doc, { from: a.letter, to: b.letter, shape: '~>', label: '' });
3788
+ });
3789
+ setEdgeMode(changed ? 'idle' : 'armed');
3790
+ render();
3791
+ }
3792
+
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
+ */
2044
3812
  function cellFromEvent(ev, layout) {
2045
3813
  if (canvas === null) return null;
2046
3814
  const box = canvas.getBoundingClientRect();
2047
- 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);
2048
3822
  }
2049
3823
 
2050
- // ── the preview ───────────────────────────────────────────────────────
2051
3824
  /**
2052
- * The engine's own rendering of the very same document.
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.
2053
3828
  *
2054
- * This is the check on the drawing, which is the only reason it is on
2055
- * screen. It is rendered from a plain copy because wavedrom writes into the
2056
- * source object it is handed and the store's documents are frozen.
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.
2057
3841
  */
2058
- function renderPreview(doc) {
2059
- const engine = opts.wavedrom;
2060
- if (engine === null || engine === undefined ||
2061
- typeof engine.RenderWaveForm !== 'function') {
2062
- previewHost.textContent = '(這一頁沒有載入 WaveDrom,預覽從缺)';
2063
- previewWrap.setAttribute('data-wave-preview', 'absent');
2064
- return;
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);
3872
+ }
3873
+
3874
+ /**
3875
+ * The `doc.edge` index the press landed on, or `null`.
3876
+ *
3877
+ * Two questions, in order, both against `geometry.edgeLayout(store.doc,
3878
+ * layout)` computed fresh here — the same recompute-don't-cache rule
3879
+ * `cellFromEvent`/`geometry.cellAt` already follow, so a press can never
3880
+ * be judged against a layout `renderCanvas` has moved on from:
3881
+ *
3882
+ * 1. `geometry.edgeHandleAt` — a handle is a small rect centred on an
3883
+ * endpoint, asked in the SAME layout-local space `cellFromEvent`
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
3893
+ * edge that is actually SELECTED — `renderEdges` only DRAWS a
3894
+ * handle on the selected edge, so its answer for any OTHER edge is a
3895
+ * rectangle with nothing on screen, and letting that phantom
3896
+ * rectangle claim the press is what used to swallow a bus label's
3897
+ * press whenever the label's cell doubled as an unselected edge's
3898
+ * endpoint (see `onCanvasDown`'s `busLabelHitAt` check, which now
3899
+ * runs before this function is even called, for the other half of
3900
+ * that fix). A press within a handle-sized target of the SELECTED
3901
+ * edge's own endpoint still selects it rather than painting the
3902
+ * cell under it. final review re-review, item 1: ALSO trusted when
3903
+ * the named edge is a self-loop (`from === to`), selected or not —
3904
+ * a self-loop's `pathFor` output is a zero-length path (every
3905
+ * command's endpoint equals its start), which Chrome gives no hit
3906
+ * area at all, so #2 below can never find one; the handle is the
3907
+ * only thing a self-loop can ever be selected by, and a self-loop
3908
+ * the user hand-writes into the markdown must stay selectable and
3909
+ * deletable even before it has ever been clicked once.
3910
+ * 2. The `<path class="ed-wave-edge-hit">` twin `renderEdges` layers
3911
+ * over each visible edge, read back through `document.elementFromPoint`
3912
+ * at the press's SCREEN coordinates (no conversion needed — that is
3913
+ * what `elementFromPoint` already takes).
3914
+ */
3915
+ function edgeHitAt(ev, layout) {
3916
+ if (canvas === null) return null;
3917
+ const edges = geometry.edgeLayout(store.doc, layout);
3918
+ if (edges.length === 0) return null;
3919
+ const box = canvas.getBoundingClientRect();
3920
+ const handle = geometry.edgeHandleAt(edges, ev.clientX - box.left, ev.clientY - box.top);
3921
+ // final review finding 4: only trust the handle answer when it names
3922
+ // the edge that is actually SELECTED — the same scoping the
3923
+ // endpoint-drag branch in `onCanvasDown` already applies, for the same
3924
+ // reason (see its comment): `renderEdges` only ever DRAWS a handle on
3925
+ // the selected edge, so `geometry.edgeHandleAt`'s answer for any OTHER
3926
+ // edge index is a rectangle with nothing on screen at all. Before this,
3927
+ // that undrawn rectangle still won the press here — a bus label whose
3928
+ // cell happened to double as an unselected edge's endpoint could never
3929
+ // open its label editor, because this branch claimed the press first.
3930
+ // An edge stays selectable regardless: the fall-through below still
3931
+ // hit-tests every edge's own 10px `.ed-wave-edge-hit` path.
3932
+ //
3933
+ // final review re-review, item 1: EXCEPT a self-loop (`from === to`),
3934
+ // which this "an edge stays selectable regardless" promise does not
3935
+ // actually cover — MEASURED regression, not a hypothetical: `pathFor`
3936
+ // degenerates to a zero-length path for every shape family when
3937
+ // `from === to` (every `L`/`C` command's endpoint equals its start),
3938
+ // and Chrome gives a zero-length, butt-cap path NO hit area at all —
3939
+ // `elementsFromPoint` returns nothing there, so the `.ed-wave-edge-hit`
3940
+ // fallback below can never find it either. The phantom handle this
3941
+ // finding gates off was the ONLY thing that could ever select a
3942
+ // self-loop that has never been selected before (`selectedEdge` is set
3943
+ // from exactly this function's return value, nowhere else) — a
3944
+ // self-loop the user hand-writes into the markdown (the UI itself
3945
+ // never creates one; `finishEdgeDrag` refuses a same-letter drag) would
3946
+ // otherwise be permanently unselectable and undeletable by mouse. So a
3947
+ // handle match is trusted when it is drawn (`handle.index ===
3948
+ // selectedEdge`, as before) OR when the edge it names is a self-loop —
3949
+ // a self-loop's handle is the one hit target it has, selected or not.
3950
+ if (handle !== null) {
3951
+ if (handle.index === selectedEdge) return handle.index;
3952
+ const named = edges.filter(function (e) { return e.index === handle.index; })[0];
3953
+ if (named !== undefined && named.edge.from === named.edge.to) return handle.index;
2065
3954
  }
2066
- try {
2067
- // `notFirstSignal` means "the skin is already on this page, do not
2068
- // emit it again". True whenever a WaveDrom render has already run here,
2069
- // which is always in the flow that reaches this editor — the entry
2070
- // affordance only appears over a RENDERED diagram. The fallback to
2071
- // `false` is for the page where it somehow has not: emitting the skin
2072
- // is only wasteful, while omitting one that is not there leaves every
2073
- // `<use>` unresolvable and the preview blank, which is the one state
2074
- // this panel may never be in.
2075
- // `socket` is a bare, global, un-namespaced id that the engine's own
2076
- // skin `<defs>` defines — so ask for it INSIDE a WaveDrom-rendered SVG's
2077
- // defs rather than anywhere on the page. A document carrying inline
2078
- // HTML with `id="socket"` would otherwise convince this that the skin is
2079
- // present on a page where no diagram had rendered, and the preview would
2080
- // come up blank: the one state this panel may never be in.
2081
- const skinOnPage = d.querySelector('svg.WaveDrom defs #socket') !== null;
2082
- engine.RenderWaveForm(PREVIEW_INDEX, plainCopy(doc, 0),
2083
- PREVIEW_ID_PREFIX, skinOnPage);
2084
- previewWrap.setAttribute('data-wave-preview', 'ok');
2085
- previewWrap.setAttribute('data-wave-preview-skin', skinOnPage ? 'shared' : 'own');
2086
- } catch (e) {
2087
- previewHost.textContent = 'WaveDrom 畫不出這份文件:' + String(e && e.message);
2088
- previewWrap.setAttribute('data-wave-preview', 'failed');
3955
+ if (typeof d.elementFromPoint !== 'function') return null;
3956
+ const el = d.elementFromPoint(ev.clientX, ev.clientY);
3957
+ const hitPath = el !== null && el !== undefined && typeof el.closest === 'function'
3958
+ ? el.closest('.ed-wave-edge-hit') : null;
3959
+ if (hitPath === null || !canvas.contains(hitPath)) return null;
3960
+ const idx = hitPath.getAttribute('data-edge-index');
3961
+ return idx === null ? null : Number(idx);
3962
+ }
3963
+
3964
+ /**
3965
+ * The `.ed-wave-buslabel` mark under a press, or `null` — final review
3966
+ * finding 4.
3967
+ *
3968
+ * NOT `ev.target`: `renderEdges` draws `.ed-wave-edge-hit` (invisible,
3969
+ * `pointer-events: stroke`, 10px wide) AFTER every lane's own content, so
3970
+ * whenever an edge's endpoint lands on a bus cell, that stroke paints ON
3971
+ * TOP of the cell's own label mark — `ev.target` at that pixel is the
3972
+ * edge-hit path, never the label underneath it, and no amount of
3973
+ * reordering the CALLS in `onCanvasDown` changes that, because `ev.target`
3974
+ * is the browser's own topmost-element answer, decided at dispatch time
3975
+ * by paint order, not by which `if` this file happens to ask first.
3976
+ * MEASURED against a real page: moving the bus-label check ahead of
3977
+ * `edgeHitAt` while still reading `ev.target` left the label unreachable,
3978
+ * identically to before.
3979
+ *
3980
+ * `document.elementsFromPoint` (plural — distinct from the singular
3981
+ * `elementFromPoint` `edgeHitAt` above reads) answers a different
3982
+ * question: the WHOLE stack of elements under the point, in z-order, not
3983
+ * only the topmost one. Walking it for a `.ed-wave-buslabel` finds the
3984
+ * mark even when something else is painted over it, which is exactly
3985
+ * this finding's fix. The walk stops at `canvas` itself so a label-shaped
3986
+ * class elsewhere in the document (outside the canvas this press is
3987
+ * even inside) can never match.
3988
+ *
3989
+ * Falls back to the old `ev.target`-only check when `elementsFromPoint`
3990
+ * is not available at all (older engines) — that keeps every OTHER bus
3991
+ * label (the overwhelming majority, which do not sit under an edge's
3992
+ * endpoint) working exactly as it always has; only the overlap case this
3993
+ * finding is about stays unfixed on such an engine.
3994
+ */
3995
+ function busLabelHitAt(ev) {
3996
+ function isLabel(el) {
3997
+ return el !== null && el !== undefined && typeof el.classList === 'object' &&
3998
+ el.classList !== null && el.classList.contains('ed-wave-buslabel');
2089
3999
  }
4000
+ if (typeof d.elementsFromPoint === 'function') {
4001
+ const stack = d.elementsFromPoint(ev.clientX, ev.clientY);
4002
+ for (const el of stack) {
4003
+ if (isLabel(el)) return el;
4004
+ if (el === canvas) break;
4005
+ }
4006
+ return null;
4007
+ }
4008
+ return isLabel(ev.target) ? ev.target : null;
2090
4009
  }
2091
4010
 
2092
4011
  // Mounted BEFORE the first paint, not after: the preview is rendered by the
@@ -2101,8 +4020,24 @@
2101
4020
  render();
2102
4021
  // The opening line described a pointer and only a pointer, which is how a
2103
4022
  // surface ends up with no keyboard: the instructions never mentioned one.
2104
- say('選一個電位,然後在波形上按住拖過去;或直接按方向鍵進波形,' +
2105
- '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+左右鍵可以在轉態點之間跳。');
2106
4041
 
2107
4042
  return {
2108
4043
  el: overlay,
@@ -2115,10 +4050,9 @@
2115
4050
 
2116
4051
  return {
2117
4052
  createWaveEditor: createWaveEditor,
2118
- brickOf: brickOf,
2119
- groupSpansOf: groupSpansOf,
2120
4053
  mountedInsideContent: mountedInsideContent,
2121
4054
  BRUSHES: BRUSHES,
4055
+ isBrushKey: isBrushKey,
2122
4056
  SIZES: SIZES,
2123
4057
  };
2124
4058
  });