@helping-ai-workflow/md2doc 3.3.0 → 3.4.2

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.
@@ -0,0 +1,2124 @@
1
+ 'use strict';
2
+ /*
3
+ * wave-ui — the wavedrom editing surface: the overlay, the hand-drawn waveform,
4
+ * the toolbar, the lane list and the live WaveDrom preview beside it.
5
+ *
6
+ * UMD, the same shape convert-md.js / patchmap.js use: require-able in node so
7
+ * the drawing decisions can be checked without a browser, and injected into the
8
+ * edit page as `window.md2docWaveUi` (lib/editor/server.js). client.js is
9
+ * inlined as a plain <script>, not bundled, so a bare require() here would be
10
+ * undefined in the browser — every dependency arrives as a parameter instead
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.
15
+ *
16
+ * FIVE things this file is not allowed to get wrong, each of them a ruling from
17
+ * Tasks 1-5 rather than a preference:
18
+ *
19
+ * 1. THE OVERLAY MOUNTS ON `document.body`, NEVER INSIDE `.content`.
20
+ * `.content`'s children are the editor's serialisation surface — client.js
21
+ * reads them back (`lastParts`) and the WYSIWYG path turns them into
22
+ * markdown. A panel parked in there ends up in the user's file. `mount()`
23
+ * takes the host as a parameter and `mountedInsideContent()` below is the
24
+ * check that a caller can run against its own DOM.
25
+ *
26
+ * 2. LANE NUMBERS ARE THE CODEC'S FLATTENED DISPLAY ORDER, and the only road
27
+ * from one to a path is the codec's bridge. Nothing here writes
28
+ * `['signal', k]`. `wave-geometry.cellAt` already answers in that index
29
+ * space, so a hit test and the edit it triggers are speaking the same
30
+ * language by construction.
31
+ *
32
+ * 3. `levelsOf` IS THE ONLY SOURCE OF TRUTH FOR WHAT A CYCLE SHOWS. The
33
+ * preview beside the drawing exists to catch a disagreement between the
34
+ * two; if the drawing computed its own levels there would be two beliefs on
35
+ * screen and the user would have no way to tell which one the file means.
36
+ * So `brickOf` maps a level to the engine's OWN brick symbol and the
37
+ * drawing is a rendering of those symbols — see `brickOf`'s comment for the
38
+ * measurement behind every row of that table.
39
+ *
40
+ * 4. A GROUP CAN BE JOINED AT ITS HEAD BUT NOT AT ITS TAIL. That is what
41
+ * `laneInsertPath` does, and it is a consequence of flattened indexing, not
42
+ * a bug to paper over. So every insert affordance ASKS the bridge where it
43
+ * would land and says so on its own label before it is pressed — see
44
+ * `landingOf`.
45
+ *
46
+ * 5. ONE GESTURE, ONE `apply`, ONE `toPatch`. Task 5 measured the store's
47
+ * refusal rate climbing with the number of structural operations stacked up
48
+ * before a write-back (8.0% at 1-3, 17.0% at 1-6). Those refusals are
49
+ * correct — they used to be silent corruption — so the way to keep them
50
+ * rare is to never let edits pile up. `commit()` below is the only way a
51
+ * gesture reaches the store, and it does both halves every time.
52
+ */
53
+ (function (root, factory) {
54
+ if (typeof module === 'object' && module.exports) module.exports = factory();
55
+ else root.md2docWaveUi = factory();
56
+ })(typeof self !== 'undefined' ? self : this, function () {
57
+
58
+ const SVGNS = 'http://www.w3.org/2000/svg';
59
+
60
+ /** The brush palette, exactly the set the spec names, in that order. */
61
+ const BRUSHES = ['0', '1', 'x', 'z', 'p', 'n', 'h', 'l', 'u', 'd', '='];
62
+
63
+ /** 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 };
66
+
67
+ /**
68
+ * The index the preview is rendered under, and why it is not 0.
69
+ *
70
+ * `RenderWaveForm(index, …)` names its structural ids after that index —
71
+ * `svgcontent_<i>`, `waves_<i>`, `lanes_<i>`, `gmarks_<i>`, `gmark_<lane>_<i>`
72
+ * — and the document's own diagram is already rendered at 0. MEASURED in a
73
+ * real page carrying one rendered diagram, counting duplicate `id`s across
74
+ * the whole document (0 before the preview existed):
75
+ *
76
+ * RenderWaveForm(0, …, notFirstSignal=false) 240 duplicate ids
77
+ * RenderWaveForm(0, …, notFirstSignal=true) 20 duplicate ids
78
+ * RenderWaveForm(9000, …, notFirstSignal=true) 0 duplicate ids
79
+ *
80
+ * In all three the preview came out with the same 18 `<use>` elements and the
81
+ * same 300px width, and with `notFirstSignal=true` a brick still measures
82
+ * 20x20 — the `<use>`s resolve against the skin the page already carries, so
83
+ * re-emitting the whole `<defs>` (`notFirstSignal=false`, 1 extra `<defs>`
84
+ * and a duplicate of every brick symbol id) buys nothing. A document using a
85
+ * non-default `config.skin` was the reason to be careful about sharing, and
86
+ * it is not a risk here: the preview renders THE SAME DOCUMENT as the diagram
87
+ * whose skin is on the page.
88
+ */
89
+ const PREVIEW_INDEX = 9000;
90
+ const PREVIEW_ID_PREFIX = 'md2doc-wave-preview-';
91
+
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
+ /**
139
+ * Whether `el` is inside the editor's serialisation surface.
140
+ *
141
+ * Constraint 1 at the top of this file, as something a caller can ask rather
142
+ * than as a rule it has to remember. `.content`'s own children are what
143
+ * client.js reads back as "the render I last knew about", so anything this
144
+ * module parks in there travels into the user's markdown.
145
+ */
146
+ function mountedInsideContent(el) {
147
+ let node = el;
148
+ while (node !== null && node !== undefined) {
149
+ if (node.classList !== undefined && node.classList.contains('content')) return true;
150
+ node = node.parentElement;
151
+ }
152
+ return false;
153
+ }
154
+
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
+ /** Line and column of a byte offset, 1-based — what a parse failure has to
178
+ * say beyond its own message for the offset to mean anything to a person. */
179
+ function whereIs(text, offset) {
180
+ const upto = String(text).slice(0, Math.max(0, offset | 0));
181
+ const lines = upto.split(/\r\n|\r|\n/);
182
+ return { line: lines.length, column: lines[lines.length - 1].length + 1 };
183
+ }
184
+
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
+ }
238
+
239
+ /** The labels a lane's `data` supplies, one per explicit value cycle. */
240
+ function labelsOf(lane) {
241
+ const raw = lane === null || typeof lane !== 'object' ? undefined : lane.data;
242
+ if (Array.isArray(raw)) return raw.map(function (x) { return String(x); });
243
+ if (typeof raw === 'string') return raw.split(/\s+/).filter(function (x) { return x !== ''; });
244
+ return [];
245
+ }
246
+
247
+ // -------------------------------------------------------------------------
248
+ // The editor
249
+ // -------------------------------------------------------------------------
250
+
251
+ /**
252
+ * Open the editing surface over one wavedrom block's source text.
253
+ *
254
+ * `opts`:
255
+ * document the DOM document to build in (required)
256
+ * host where the overlay is mounted; defaults to `document.body`.
257
+ * A host inside `.content` is REFUSED — see constraint 1.
258
+ * source the block's WaveJSON text
259
+ * codec, geometry, createStore the three modules, handed in
260
+ * wavedrom `window.WaveDrom` or null; null just means no live preview
261
+ * onGesture({name, patch, store}) called after EVERY gesture that changed
262
+ * the document, with that gesture's own patch already computed
263
+ * onClose(reason) called once, when the overlay goes away. `reason` is
264
+ * `'escape'` when the user pressed Escape and `'commit'` for
265
+ * every other route out (the 關閉 button, or a caller driving
266
+ * the returned `close()`). Task 7 needs the two apart because
267
+ * they mean opposite things to the document: a commit leaves
268
+ * this session's write-backs standing, an Escape takes them
269
+ * back off again. Passed as an argument rather than inferred
270
+ * by the caller from what the keyboard last did — that
271
+ * inference is exactly the kind this file has already paid
272
+ * for once (see the banner/Escape split in `onKeyDown`).
273
+ *
274
+ * Never throws on a source that cannot be read: `createStore` answers with a
275
+ * refusing store and this shows the parser's message and offset. A block that
276
+ * silently fails to open is the worst outcome there is — it still looks
277
+ * editable and simply is not.
278
+ */
279
+ function createWaveEditor(opts) {
280
+ const d = opts.document;
281
+ const codec = opts.codec;
282
+ const geometry = opts.geometry;
283
+ const host = opts.host || d.body;
284
+ if (mountedInsideContent(host)) {
285
+ throw new Error('wave-ui: the overlay may not be mounted inside .content ' +
286
+ '(its children are serialised back into the user markdown)');
287
+ }
288
+
289
+ const store = opts.createStore(opts.source);
290
+
291
+ const overlay = d.createElement('div');
292
+ overlay.className = 'ed-wave-overlay';
293
+ overlay.setAttribute('role', 'dialog');
294
+ overlay.setAttribute('aria-modal', 'true');
295
+ overlay.setAttribute('aria-label', '波形編輯器');
296
+
297
+ // ── state that is the UI's own, not the document's ────────────────────
298
+ // Declared HERE, above the unreadable-source return below, because
299
+ // `close()` clears `drag` and the unreadable panel has a 關閉 button too:
300
+ // a `let` declared past that return would still be in its temporal dead
301
+ // zone when that button is pressed, and close() would throw instead of
302
+ // closing.
303
+ let brush = '1';
304
+ let clip = null;
305
+ let selection = null; // {laneIndex, from, to} — the cycles a gesture acts on
306
+ let drag = null; // live pointer drag, never pushed until mouseup
307
+ let gestures = 0;
308
+ let lastPatch = null;
309
+ // 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.
314
+ let focusOverride = null;
315
+ // Where the KEYBOARD is on the drawing: one cell, and the keyboard's whole
316
+ // equivalent of the pointer. Null until the keyboard has actually entered
317
+ // the drawing — a cursor drawn before anyone asked for one is a second
318
+ // permanent highlight beside the selection with nothing behind it.
319
+ //
320
+ // A pointer press moves it too (see `onCanvasDown`), so the two devices can
321
+ // never end up pointing at different cells: painting with `1` after a click
322
+ // paints the cell that was clicked.
323
+ let cursor = null;
324
+ // The document the marks' lane numbers are numbers INTO.
325
+ //
326
+ // `cursor` and `selection` name a ROW, and a row number means a different
327
+ // lane after anything that reorders rows. The first cut answered that per
328
+ // operation — ▲ and ▼ swapped the two indexes themselves — and that is the
329
+ // shape that cannot be finished: it fixed the move and not the UNDO of the
330
+ // move, so ▲ then Ctrl+Z left the cursor and the selection sitting
331
+ // confidently on the neighbour, and the next brush key painted a lane the
332
+ // user was not looking at. Nothing on screen contradicted them. ✕ and +
333
+ // were the same defect before that, and something else would have been
334
+ // next.
335
+ //
336
+ // So the marks are carried by lane IDENTITY through one funnel instead:
337
+ // `afterStoreMoved` is the only road every store movement takes — apply,
338
+ // undo and redo all end there — and `carryMarks` asks the STORE which row
339
+ // each lane is on now. No operation has to remember anything, so no
340
+ // operation can forget.
341
+ let marksDoc = null;
342
+ // The rail's rename field while it is open — this editor's ONE sub-panel,
343
+ // and therefore what the first Escape closes. Before this, Escape typed at
344
+ // a rename field ran `close('escape')` and threw the WHOLE session away,
345
+ // which is the most expensive answer available to「我改名改到一半不想改了」.
346
+ let groupRename = null;
347
+
348
+ // ── the modal focus model ─────────────────────────────────────────────
349
+ //
350
+ // `role="dialog"` + `aria-modal="true"` is a promise, and before this the
351
+ // overlay kept none of it: nothing was focused on open (MEASURED:
352
+ // `document.activeElement` was `document.body` the instant the panel came
353
+ // up), `Tab` never reached any of its controls, and closing left the
354
+ // keyboard wherever the browser happened to drop it.
355
+ //
356
+ // It is also what makes the OWNERSHIP question answerable. The surrounding
357
+ // editor used to ask "did this event land inside the overlay?", which is a
358
+ // question about the event; with focus on body the honest answer was "no"
359
+ // and the document behind the modal kept the key. Ownership has to be true
360
+ // BEFORE any event arrives, so client.js now asks a state question — and
361
+ // this is the other half: the state is real, focus is actually in here.
362
+ const FOCUSABLE = 'button, input, select, textarea, a[href], ' +
363
+ '[tabindex]:not([tabindex="-1"])';
364
+ // Where the keyboard was before this opened, so it can be handed back.
365
+ const cameFrom = d.activeElement;
366
+
367
+ // ── R2: the document behind the modal does not scroll ─────────────────
368
+ //
369
+ // A state predicate over EVENTS cannot answer this one: native wheel
370
+ // scrolling is not delivered to a listener anyone guards, so measured, a
371
+ // single wheel gesture with the pointer over the panel took the document
372
+ // behind it from `scrollY 0` to `800` with the overlay still up.
373
+ //
374
+ // Nothing is mutated by that, so this is not a data question — it is the
375
+ // same question the V3 overlay census answers for `.ed-wave-edit-btn`,
376
+ // which is classified `gone` precisely because "a scroll makes its
377
+ // coordinates point at the wrong thing". The wave editor is anchored to one
378
+ // diagram in the document; scrolling the document out from under it moves
379
+ // where the user lands when it closes, silently.
380
+ //
381
+ // So it is locked, and restored exactly as it was found — the inline value
382
+ // is captured rather than assumed to be empty, so a page that sets its own
383
+ // is not quietly rewritten. `overflow: hidden` on the ROOT leaves
384
+ // `scrollTop` where it is and does not touch the panel's own three scroll
385
+ // columns, which keep working.
386
+ //
387
+ // `lockScroll()` is called from TWO places — the unreadable-source panel's
388
+ // own mount, and the ready panel's — and in each it is the statement
389
+ // immediately before that path's `host.appendChild(overlay)`. That, not the
390
+ // number of call sites, is the property: the lock is taken by the same
391
+ // statement that puts the overlay on screen, so "is it mounted" and "is it
392
+ // locked" cannot disagree, and a THIRD early-return panel has to do the same
393
+ // pairing rather than inherit it.
394
+ //
395
+ // Setting it once at the top, where it was, meant a throw anywhere in the
396
+ // construction below left the whole document permanently unscrollable with
397
+ // no overlay on screen and no way back but a reload — `openWaveEditor`'s own
398
+ // `catch` clears `waveEditor` and re-throws, and it does not know about the
399
+ // lock. Nothing between there and the mount realistically throws today; the
400
+ // point is that it no longer has to be true.
401
+ const rootEl = d.documentElement;
402
+ let rootOverflowWas = null;
403
+ function lockScroll() {
404
+ if (rootOverflowWas !== null) return;
405
+ rootOverflowWas = rootEl.style.overflow;
406
+ rootEl.style.overflow = 'hidden';
407
+ }
408
+ function unlockScroll() {
409
+ if (rootOverflowWas === null) return;
410
+ rootEl.style.overflow = rootOverflowWas;
411
+ rootOverflowWas = null;
412
+ }
413
+
414
+ /**
415
+ * Everything inside the modal layer that can take the keyboard.
416
+ *
417
+ * The layer is the overlay AND any conflict banner standing on screen, and
418
+ * that second part is not a detail. `.ed-conflict` is `z-index: 999` against
419
+ * this overlay's 998, so a banner raised while the panel is open PAINTS
420
+ * above it (measured) — but a focus trap scoped to the overlay alone would
421
+ * make its Dismiss and Reload buttons unreachable by keyboard, and that
422
+ * banner is the user's only route out of a disk conflict. This branch has
423
+ * already paid once for a fixed layer hiding that banner; it does not get
424
+ * to pay again by trapping the keyboard away from it.
425
+ */
426
+ function modalRoots() {
427
+ const out = [overlay];
428
+ for (const el of d.querySelectorAll('.ed-conflict')) out.push(el);
429
+ return out;
430
+ }
431
+
432
+ function focusables() {
433
+ const out = [];
434
+ for (const root of modalRoots()) {
435
+ for (const el of root.querySelectorAll(FOCUSABLE)) {
436
+ if (el.disabled === true || el.hidden === true) continue;
437
+ if (el.getClientRects().length === 0) continue;
438
+ out.push(el);
439
+ }
440
+ }
441
+ return out;
442
+ }
443
+
444
+ let closed = false;
445
+ /**
446
+ * Take the whole surface down, once, leaving nothing of this editor behind.
447
+ *
448
+ * Every listener installed on the DOCUMENT is removed here, and a drag in
449
+ * flight is cancelled rather than left armed. That is not tidiness: a drag
450
+ * installs `mousemove`/`mouseup` on the document, so an Escape pressed
451
+ * mid-drag used to leave both attached, and the eventual release still ran
452
+ * the paint — into a store that is no longer on screen, through an
453
+ * `onGesture` whose owner had already been torn down. Measured: a
454
+ * page-level `TypeError: Cannot set properties of null (setting
455
+ * 'lastGesture')`, plus two leaked listeners per cancelled drag.
456
+ *
457
+ * `closed` is also checked by the gesture path itself, so a callback
458
+ * already queued when this ran cannot reach the store either.
459
+ */
460
+ function close(reason) {
461
+ if (closed) return;
462
+ closed = true;
463
+ drag = null;
464
+ if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
465
+ d.removeEventListener('keydown', onKeyDown, true);
466
+ d.removeEventListener('mousemove', onCanvasMove, true);
467
+ d.removeEventListener('mouseup', onCanvasUp, true);
468
+ unlockScroll();
469
+ // Hand the keyboard back to whatever had it. In today's entry flow that
470
+ // is `document.body` — the entry button is hidden inside its own click
471
+ // handler, so the browser has already reset focus by the time this
472
+ // constructor runs — and restoring body is what the browser does anyway
473
+ // when the focused element is removed. It matters for the keyboard entry
474
+ // path Task 7 adds, and it costs one guarded call.
475
+ if (cameFrom !== null && cameFrom !== undefined && cameFrom !== d.body &&
476
+ d.contains(cameFrom) && typeof cameFrom.focus === 'function') {
477
+ cameFrom.focus();
478
+ }
479
+ // Anything that is not the Escape key is a commit. Spelled as a default
480
+ // here rather than at each call site so a future exit route that forgets
481
+ // to say which it is leaves the user's work standing instead of throwing
482
+ // it away — the safe direction of the two.
483
+ if (typeof opts.onClose === 'function') {
484
+ opts.onClose(reason === 'escape' ? 'escape' : 'commit');
485
+ }
486
+ }
487
+
488
+ /**
489
+ * While this overlay is up it owns its own keys.
490
+ *
491
+ * Escape closes it, and undo/redo go to the STORE — the waveform's stack,
492
+ * which is the one the user is looking at. Before this, Ctrl+Z inside the
493
+ * overlay reached the surrounding editor and rolled back the MARKDOWN
494
+ * DOCUMENT behind the modal (measured: the tail paragraph's edit
495
+ * disappeared while the overlay stayed up), which also rewrote `lines` and
496
+ * left the caller's line range stale. Capture phase, and
497
+ * `stopPropagation()` on every key this claims, so the decision is made
498
+ * here rather than by whichever listener happens to be registered first.
499
+ */
500
+ function onKeyDown(ev) {
501
+ // A key that came from the conflict banner is the banner's, not this
502
+ // dialog's. The banner is inside `modalRoots()` on purpose — it has to
503
+ // stay Tab-reachable — but that is a focus decision, not a claim on its
504
+ // keys. MEASURED before this: a user who Tab-walked over to read the
505
+ // banner and pressed Escape to back out lost the entire wave editing
506
+ // session while the banner they were looking at stayed up.
507
+ //
508
+ // Nothing is swallowed here: no `preventDefault`, no `stopPropagation`, so
509
+ // whatever else would handle that key still can. Today nothing does — the
510
+ // banner has no Escape of its own (see `dismissKeynavExitBanner()`'s
511
+ // comment) and client.js's own listener returns at the modal gate — so
512
+ // Escape there is a no-op. A no-op is the right answer; losing the session
513
+ // is not.
514
+ //
515
+ // Only ESCAPE is handed back, not every key: Tab has to stay trapped even
516
+ // while the cursor is on the banner, or the walk leaves the modal layer
517
+ // from its far end and the trap has a hole exactly where the banner is.
518
+ const fromBanner = ev.target !== null && ev.target !== undefined &&
519
+ typeof ev.target.closest === 'function' &&
520
+ ev.target.closest('.ed-conflict') !== null;
521
+ if (ev.key === 'Escape' && fromBanner) return;
522
+ if (ev.key === 'Escape') {
523
+ ev.preventDefault();
524
+ ev.stopPropagation();
525
+ // Escape is LAYERED: the innermost thing on screen first, this dialog
526
+ // second, and (above) the conflict banner's own. The rail's rename
527
+ // field is the only sub-panel this editor has, and before this its
528
+ // Escape reached `close('escape')` — which DISCARDS the session. So
529
+ // backing out of a half-typed group name threw away every gesture the
530
+ // user had made, and the one Ctrl+Z that takes a discard back is not
531
+ // something the keystroke announces.
532
+ if (groupRename !== null) { groupRename.cancel(); return; }
533
+ close('escape');
534
+ return;
535
+ }
536
+ if (ev.key === 'Tab') {
537
+ // The trap. Without it the surrounding editor's own「nothing focused」
538
+ // branch swallowed Tab and none of this dialog's controls could be
539
+ // reached from the keyboard at all.
540
+ const list = focusables();
541
+ ev.preventDefault();
542
+ ev.stopPropagation();
543
+ if (list.length === 0) return;
544
+ const at = list.indexOf(d.activeElement);
545
+ const next = ev.shiftKey
546
+ ? list[at <= 0 ? list.length - 1 : at - 1]
547
+ : list[(at === -1 || at === list.length - 1) ? 0 : at + 1];
548
+ next.focus();
549
+ return;
550
+ }
551
+ // The drawing's own keys, claimed here rather than on the canvas element
552
+ // so they are decided in the same place — and in the same phase — as
553
+ // Escape, Tab and undo/redo. Deliberately BELOW those three (a cell
554
+ // cursor may not outrank the way out of the dialog) and ABOVE the
555
+ // modifier gate below, so `Ctrl+Z` with the drawing focused still means
556
+ // undo and nothing here has to re-implement it.
557
+ if (store.ok === true && !ev.ctrlKey && !ev.metaKey && !ev.altKey &&
558
+ handleDrawingKey(ev)) {
559
+ return;
560
+ }
561
+ if (!(ev.ctrlKey || ev.metaKey) || ev.altKey) return;
562
+ const undoKey = typeof ev.key === 'string' && ev.key.toLowerCase() === 'z';
563
+ const redoKey = typeof ev.key === 'string' &&
564
+ (ev.key.toLowerCase() === 'y' || (ev.key.toLowerCase() === 'z' && ev.shiftKey));
565
+ // The unreadable panel has no store to move and no status line to write
566
+ // to — `say` and the toolbar below are never constructed on that path —
567
+ // but it still has to CLAIM undo and redo rather than let them past.
568
+ // MEASURED before this: Ctrl+Z pressed over the unreadable panel reached
569
+ // the surrounding editor and rolled the markdown document back behind a
570
+ // modal the user cannot see past.
571
+ if (store.ok !== true) {
572
+ if (undoKey || redoKey) { ev.preventDefault(); ev.stopPropagation(); }
573
+ return;
574
+ }
575
+ const key = typeof ev.key === 'string' ? ev.key.toLowerCase() : '';
576
+ if (key === 'z' && !ev.shiftKey) {
577
+ ev.preventDefault();
578
+ ev.stopPropagation();
579
+ if (store.undo()) afterStoreMoved('undo');
580
+ else say('沒有可以復原的動作');
581
+ return;
582
+ }
583
+ if (key === 'y' || (key === 'z' && ev.shiftKey)) {
584
+ ev.preventDefault();
585
+ ev.stopPropagation();
586
+ if (store.redo()) afterStoreMoved('redo');
587
+ else say('沒有可以重做的動作');
588
+ }
589
+ }
590
+ d.addEventListener('keydown', onKeyDown, true);
591
+
592
+ const panel = d.createElement('div');
593
+ panel.className = 'ed-wave-panel';
594
+ // The dialog itself is the initial focus holder — the ARIA pattern's own
595
+ // default, and the one that does not put a highlight on 關閉 the moment the
596
+ // panel opens. `tabindex="-1"` makes it focusable without putting it in the
597
+ // sequential tab order; the first Tab then lands on the first real control.
598
+ panel.setAttribute('tabindex', '-1');
599
+ overlay.appendChild(panel);
600
+
601
+ const head = d.createElement('div');
602
+ head.className = 'ed-wave-head';
603
+ const heading = d.createElement('span');
604
+ heading.className = 'ed-wave-title';
605
+ heading.textContent = '波形編輯器';
606
+ head.appendChild(heading);
607
+ // What the two ways out DO, said on screen rather than assumed.
608
+ //
609
+ // Escape is a modal's universal close key and here it DISCARDS: ten minutes
610
+ // of drawing goes, and the one keystroke that brings it back (`Ctrl+Z`) is
611
+ // not something the gesture announces. This batch's own rule — a
612
+ // destructive outcome and a safe one may not both be silent — is the rule
613
+ // behind the drawio「這一頁已經不在了」banner and behind the refusal
614
+ // notices, and it applies to the biggest surface in the product just as
615
+ // much. So the header says which key keeps the work, which key throws it
616
+ // away, and that throwing it away is undoable.
617
+ const escapeHint = d.createElement('span');
618
+ escapeHint.className = 'ed-wave-escape-hint';
619
+ escapeHint.setAttribute('data-wave-escape-hint', '');
620
+ escapeHint.textContent = 'Esc=放棄這次編輯(按一次 Ctrl+Z 可以拿回來)';
621
+ head.appendChild(escapeHint);
622
+ const closeBtn = d.createElement('button');
623
+ closeBtn.type = 'button';
624
+ closeBtn.className = 'ed-wave-close';
625
+ closeBtn.textContent = '關閉';
626
+ closeBtn.title = '保留這次編輯並關閉(Esc 則是放棄)';
627
+ // Wrapped rather than passed straight in: a bare `close` receives the
628
+ // MouseEvent as its first argument, and a reason parameter that is silently
629
+ // fed a DOM event is one rename away from meaning something.
630
+ closeBtn.addEventListener('click', function () { close('commit'); });
631
+ head.appendChild(closeBtn);
632
+ panel.appendChild(head);
633
+
634
+ if (store.ok !== true) {
635
+ // ── the block cannot be read back ──────────────────────────────────
636
+ // Ruling P3-6 parses at Edit-click time, so this is the first moment
637
+ // anyone could know. Say the message AND the offset, and turn the offset
638
+ // into a line/column plus the text around it — an offset on its own is
639
+ // not something a person can act on.
640
+ const bad = d.createElement('div');
641
+ bad.className = 'ed-wave-parse-error';
642
+ const at = whereIs(opts.source, store.error.offset);
643
+ const msg = d.createElement('p');
644
+ msg.className = 'ed-wave-parse-message';
645
+ msg.textContent = '這個 wavedrom 區塊讀不回來,所以沒有東西可以編輯:' +
646
+ store.error.message;
647
+ bad.appendChild(msg);
648
+ const where = d.createElement('p');
649
+ where.className = 'ed-wave-parse-where';
650
+ where.textContent = '位置:offset ' + store.error.offset +
651
+ '(第 ' + at.line + ' 行第 ' + at.column + ' 個字)';
652
+ where.setAttribute('data-wave-offset', String(store.error.offset));
653
+ bad.appendChild(where);
654
+ const excerpt = d.createElement('pre');
655
+ excerpt.className = 'ed-wave-parse-excerpt';
656
+ excerpt.textContent = String(opts.source).split(/\r\n|\r|\n/)[at.line - 1] || '';
657
+ bad.appendChild(excerpt);
658
+ panel.appendChild(bad);
659
+ overlay.setAttribute('data-wave-state', 'unreadable');
660
+ lockScroll();
661
+ host.appendChild(overlay);
662
+ panel.focus();
663
+ return { el: overlay, store: store, close: close, refresh: function () {} };
664
+ }
665
+
666
+ overlay.setAttribute('data-wave-state', 'ready');
667
+ // The marks start out addressing the document the store opened with.
668
+ marksDoc = store.doc;
669
+
670
+ // ── toolbar ───────────────────────────────────────────────────────────
671
+ const toolbar = d.createElement('div');
672
+ toolbar.className = 'ed-wave-toolbar';
673
+ panel.appendChild(toolbar);
674
+
675
+ const brushBar = d.createElement('div');
676
+ brushBar.className = 'ed-wave-brushes';
677
+ toolbar.appendChild(brushBar);
678
+ const brushButtons = [];
679
+ for (const ch of BRUSHES) {
680
+ const b = d.createElement('button');
681
+ b.type = 'button';
682
+ b.className = 'ed-wave-brush';
683
+ b.setAttribute('data-brush', ch);
684
+ b.setAttribute('data-focus-key', 'brush-' + ch);
685
+ b.textContent = ch;
686
+ b.addEventListener('click', function () {
687
+ brush = ch;
688
+ paintBrushState();
689
+ });
690
+ brushBar.appendChild(b);
691
+ brushButtons.push(b);
692
+ }
693
+ function paintBrushState() {
694
+ for (const b of brushButtons) {
695
+ b.classList.toggle('is-on', b.getAttribute('data-brush') === brush);
696
+ }
697
+ overlay.setAttribute('data-wave-brush', brush);
698
+ }
699
+
700
+ const cycleBar = d.createElement('div');
701
+ cycleBar.className = 'ed-wave-cycleops';
702
+ toolbar.appendChild(cycleBar);
703
+
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;
714
+ }
715
+
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'); });
741
+
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);
747
+ });
748
+ }
749
+
750
+ const undoBtn = toolButton('ed-wave-undo', '復原', '退回上一個動作', function () {
751
+ if (!store.undo()) { say('沒有可以復原的動作'); return; }
752
+ afterStoreMoved('undo');
753
+ });
754
+ const redoBtn = toolButton('ed-wave-redo', '重做', '再做一次剛剛復原掉的動作', function () {
755
+ if (!store.redo()) { say('沒有可以重做的動作'); return; }
756
+ afterStoreMoved('redo');
757
+ });
758
+
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:
766
+ //
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
771
+ //
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.
779
+ //
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;
801
+ }
802
+
803
+ const headInput = metaField('ed-wave-head-text', 'head', '上方標題');
804
+ const footInput = metaField('ed-wave-foot-text', 'foot', '下方標題');
805
+
806
+ headInput.addEventListener('change', function () { commitBanner('head', headInput.value); });
807
+ footInput.addEventListener('change', function () { commitBanner('foot', footInput.value); });
808
+
809
+ // The "this drawing is not showing you everything" notice. Empty, and with
810
+ // no box on screen, for the documents that carry none of the three.
811
+ const unmodelled = d.createElement('div');
812
+ unmodelled.className = 'ed-wave-unmodelled';
813
+ unmodelled.hidden = true;
814
+ toolbar.appendChild(unmodelled);
815
+
816
+ // The other notice: this block has nothing this editor can edit. Its own
817
+ // element rather than a second sentence in the one above, because the two
818
+ // are different claims — "the drawing is not showing you everything" and
819
+ // "there is nothing here to draw" — and a document can be in both states.
820
+ const noSignal = d.createElement('div');
821
+ noSignal.className = 'ed-wave-unmodelled ed-wave-nolanes';
822
+ noSignal.hidden = true;
823
+ toolbar.appendChild(noSignal);
824
+
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 ───────────────────────────────
837
+ const body = d.createElement('div');
838
+ body.className = 'ed-wave-body';
839
+ panel.appendChild(body);
840
+
841
+ const laneCol = d.createElement('div');
842
+ laneCol.className = 'ed-wave-lanes';
843
+ body.appendChild(laneCol);
844
+
845
+ const canvasWrap = d.createElement('div');
846
+ canvasWrap.className = 'ed-wave-canvas-wrap';
847
+ body.appendChild(canvasWrap);
848
+
849
+ const previewWrap = d.createElement('div');
850
+ previewWrap.className = 'ed-wave-preview';
851
+ const previewLabel = d.createElement('div');
852
+ previewLabel.className = 'ed-wave-preview-label';
853
+ previewLabel.textContent = 'WaveDrom 預覽';
854
+ previewWrap.appendChild(previewLabel);
855
+ const previewHost = d.createElement('div');
856
+ previewHost.className = 'ed-wave-preview-host';
857
+ // The output id prefix is deliberately NOT `WaveDrom_Display_`: md2doc's
858
+ // own init hook reclaims every id with that prefix before each
859
+ // ProcessAll(), and the reader's lightbox picks its click targets by it.
860
+ // This preview belongs to neither.
861
+ previewHost.id = PREVIEW_ID_PREFIX + PREVIEW_INDEX;
862
+ previewWrap.appendChild(previewHost);
863
+ body.appendChild(previewWrap);
864
+
865
+ const status = d.createElement('div');
866
+ status.className = 'ed-wave-status';
867
+ // Everything this dialog says back — where the cursor is, what a gesture
868
+ // did, why one was refused — is written here and nowhere else, so it is
869
+ // the one place that has to be announced rather than merely painted.
870
+ status.setAttribute('role', 'status');
871
+ panel.appendChild(status);
872
+
873
+ function say(text) {
874
+ status.textContent = text;
875
+ overlay.setAttribute('data-wave-status', text);
876
+ }
877
+
878
+ // ── the one road from a gesture to the document ───────────────────────
879
+ /**
880
+ * Run one gesture: one `apply`, then one `toPatch`, then repaint.
881
+ *
882
+ * Constraint 5. The patch is computed HERE, on the gesture, and not saved
883
+ * up for a session-ending write: Task 5 measured the store's refusal rate
884
+ * rising with the number of structural operations stacked before a
885
+ * write-back, and a refusal the user meets ten edits later is one they have
886
+ * to bisect themselves. Refusing early refuses small.
887
+ *
888
+ * A refusal is shown and nothing is written. It is NOT rolled back — the
889
+ * document on screen is still what the user asked for, and undoing their
890
+ * edit because the file cannot express it yet would be this layer deciding
891
+ * for them. Task 7 owns what happens to the patch; this owns that there is
892
+ * exactly one per gesture.
893
+ */
894
+ function commit(name, fn) {
895
+ // A gesture that arrives after the overlay came down has nowhere to land:
896
+ // the store is off screen and the caller's own state is already torn
897
+ // down. Cheap and load-bearing — an Escape mid-drag is exactly this.
898
+ if (closed) return false;
899
+ const changed = store.apply(name, fn);
900
+ if (!changed) {
901
+ say('這個動作沒有改變任何東西');
902
+ return false;
903
+ }
904
+ gestures++;
905
+ afterStoreMoved(name);
906
+ return true;
907
+ }
908
+
909
+ /**
910
+ * Move the marks from the document they were made in to the one the store
911
+ * is showing now, by asking who each lane IS rather than where it sat.
912
+ *
913
+ * `laneTrace(before, after)[j]` is the row that lane `j` occupied before,
914
+ * or null for a row that did not exist then — the store's own answer, the
915
+ * one the patch planner reads, injected rather than re-derived here (see
916
+ * `wave-store.laneTrace`). Inverting it answers the question a mark has:
917
+ * "the lane I was on — where is it now?"
918
+ *
919
+ * A lane that is GONE (its ✕, or the undo of an insert) has no answer, and
920
+ * the mark falls back to the old row number clamped into the new layout —
921
+ * which is what a person expects after deleting a row: the cursor lands on
922
+ * whatever took its place, rather than disappearing. The clamp in
923
+ * `renderCanvas` does that, so this simply leaves the number alone.
924
+ *
925
+ * With no `laneTrace` injected — an older page carrying an older
926
+ * wave-store — the marks are DROPPED on any structural change instead of
927
+ * being left pointing at whatever inherited the index. "I no longer know
928
+ * where you were" is a state the user can see; "here, except it is a
929
+ * different lane now" is the defect.
930
+ */
931
+ function carryMarks(nextDoc) {
932
+ if (marksDoc === nextDoc) return;
933
+ const before = marksDoc;
934
+ marksDoc = nextDoc;
935
+ if (before === null || (cursor === null && selection === null)) return;
936
+ const trace = typeof opts.laneTrace === 'function'
937
+ ? opts.laneTrace(before, nextDoc) : null;
938
+ if (trace === null) { cursor = null; selection = null; return; }
939
+ // Structural or not: a content operation traces as the identity, so this
940
+ // is a no-op for a paint without having to know that it is one.
941
+ const rowOf = function (was) {
942
+ for (let j = 0; j < trace.length; j++) {
943
+ if (trace[j] === was) return j;
944
+ }
945
+ return null;
946
+ };
947
+ if (cursor !== null) {
948
+ const at = rowOf(cursor.laneIndex);
949
+ if (at !== null) cursor = { laneIndex: at, cycle: cursor.cycle };
950
+ }
951
+ if (selection !== null) {
952
+ const at = rowOf(selection.laneIndex);
953
+ if (at !== null) {
954
+ selection = { laneIndex: at, from: selection.from, to: selection.to };
955
+ }
956
+ }
957
+ }
958
+
959
+ function afterStoreMoved(name) {
960
+ if (closed) return;
961
+ // Before anything reads or draws them. `commit`, the undo/redo buttons
962
+ // and the Ctrl+Z / Ctrl+Y keys all arrive here, and nothing else moves
963
+ // the store — so this is the whole of "the marks follow their lane".
964
+ carryMarks(store.doc);
965
+ lastPatch = store.toPatch();
966
+ render();
967
+ if (lastPatch.ok === true) {
968
+ say('已寫回:' + name);
969
+ } else {
970
+ say('這個改動沒辦法只改幾個位元組寫回去:' + lastPatch.reason);
971
+ }
972
+ if (typeof opts.onGesture === 'function') {
973
+ opts.onGesture({ name: name, patch: lastPatch, store: store });
974
+ }
975
+ }
976
+
977
+ // ── painting ──────────────────────────────────────────────────────────
978
+ let canvas = null;
979
+
980
+ /**
981
+ * Repaint everything, and give the keyboard cursor back where it was.
982
+ *
983
+ * `renderLanes` rebuilds the lane list from scratch, so without this a
984
+ * rename committed with Enter left `document.activeElement` on
985
+ * `document.body` — measured. That is the same defect class as four of the
986
+ * last five commits on this branch, and here it is also the ENABLING step
987
+ * for the block-selection data loss: focus on body plus a standing
988
+ * selection turns the next Backspace into "delete the selected blocks".
989
+ *
990
+ * Identity is carried by `data-focus-key`, not by element identity, because
991
+ * the element the user was typing in no longer exists after the rebuild.
992
+ * The text cursor inside a field is carried too; a field that came back
993
+ * with different text keeps the cursor clamped to what is there now.
994
+ *
995
+ * The rename field is the ONE `data-focus-key` that a repaint does not
996
+ * bring back — the rail only ever rebuilds the tag it replaced — so a
997
+ * repaint that runs while it is open has to retire it explicitly. Without
998
+ * that, `Ctrl+Z` typed into the field wedged the whole page: the undo
999
+ * repainted, `restoreFocus` looked for `group-input-<n>` and found
1000
+ * nothing, focus was left on a DETACHED element, and from then on NO key
1001
+ * reached the page at all — not `a`, not three Escapes, so the modal could
1002
+ * not even be closed without a mouse. That is the defect family this task
1003
+ * exists for, one worse than the six it cites, and it was reachable in two
1004
+ * keystrokes from the sub-panel the task itself redesigned.
1005
+ */
1006
+ function render() {
1007
+ if (groupRename !== null) groupRename.retire();
1008
+ const before = focusMark();
1009
+ const doc = store.doc;
1010
+ const layout = geometry.layoutOf(doc, SIZES);
1011
+ renderLanes(doc, layout);
1012
+ renderCanvas(doc, layout);
1013
+ renderPreview(doc);
1014
+ renderMeta(doc);
1015
+ renderUnmodelled(doc);
1016
+ undoBtn.disabled = !store.canUndo();
1017
+ redoBtn.disabled = !store.canRedo();
1018
+ overlay.setAttribute('data-wave-gestures', String(gestures));
1019
+ overlay.setAttribute('data-wave-lanes', String(layout.lanes.length));
1020
+ overlay.setAttribute('data-wave-cycles', String(layout.cycles));
1021
+ overlay.setAttribute('data-wave-dirty', store.isDirty() ? '1' : '0');
1022
+ overlay.setAttribute('data-wave-patch',
1023
+ lastPatch === null ? 'none' : (lastPatch.ok === true ? 'ok' : 'refused'));
1024
+ paintBrushState();
1025
+ restoreFocus(before);
1026
+ }
1027
+
1028
+ /** What has the keyboard now, as something that survives a rebuild. */
1029
+ function focusMark() {
1030
+ const el = d.activeElement;
1031
+ if (el === null || el === undefined || !overlay.contains(el)) return null;
1032
+ const key = el.getAttribute('data-focus-key');
1033
+ if (key === null) return null;
1034
+ const mark = { key: key, start: null, end: null };
1035
+ // `selectionStart` throws on input types that have no text selection; the
1036
+ // fields here are all type=text, but reading it defensively costs nothing
1037
+ // and keeps a future field from turning a repaint into an exception.
1038
+ try {
1039
+ mark.start = el.selectionStart;
1040
+ mark.end = el.selectionEnd;
1041
+ } catch (e) { /* not a text field */ }
1042
+ return mark;
1043
+ }
1044
+
1045
+ function restoreFocus(mark) {
1046
+ const want = focusOverride !== null ? focusOverride : (mark === null ? null : mark.key);
1047
+ focusOverride = null;
1048
+ if (want === null) return;
1049
+ let el = overlay.querySelector('[data-focus-key="' + want + '"]');
1050
+ // A named destination the repaint did not bring back. It happens: a
1051
+ // group's key is its first lane's row number, so inserting a lane above
1052
+ // it renumbers the key that a rename field had already nominated —
1053
+ // measured, `{overlay:1, input:0, key:null, tag:"BODY"}`. Fall back to
1054
+ // where the keyboard actually was, and failing that to the dialog
1055
+ // itself. Never to nothing: nothing is `document.body`, and this whole
1056
+ // task is about not ending there.
1057
+ if (el === null && mark !== null && mark.key !== want) {
1058
+ el = overlay.querySelector('[data-focus-key="' + mark.key + '"]');
1059
+ }
1060
+ if (el === null) {
1061
+ if (!overlay.contains(d.activeElement)) panel.focus();
1062
+ return;
1063
+ }
1064
+ el.focus();
1065
+ if (mark === null || mark.key !== want) return;
1066
+ if (mark.start === null || typeof el.setSelectionRange !== 'function') return;
1067
+ const n = typeof el.value === 'string' ? el.value.length : 0;
1068
+ try {
1069
+ el.setSelectionRange(Math.min(mark.start, n), Math.min(mark.end, n));
1070
+ } catch (e) { /* not a text field */ }
1071
+ }
1072
+
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
+ /**
1135
+ * The lane list.
1136
+ *
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.
1309
+ */
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;
1315
+
1316
+ const input = d.createElement('input');
1317
+ 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. */
1326
+ const takeDown = function () {
1327
+ if (input.parentNode !== null) input.parentNode.removeChild(input);
1328
+ if (d.contains(tag)) tag.hidden = false;
1329
+ };
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. */
1344
+ const holdsKeyboard = function () { return d.activeElement === input; };
1345
+
1346
+ 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;
1352
+ const hadKeyboard = holdsKeyboard();
1353
+ const value = input.value;
1354
+ 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();
1362
+ return;
1363
+ }
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;
1370
+ }
1371
+ commit('rename-group', function (doc2) {
1372
+ return replaceAt(doc2, span.path.concat([0]), value);
1373
+ });
1374
+ };
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.
1382
+ const retire = function () {
1383
+ if (groupRename === null) return;
1384
+ groupRename = null;
1385
+ if (focusOverride === null && holdsKeyboard()) {
1386
+ focusOverride = 'group-' + span.from;
1387
+ }
1388
+ };
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.
1394
+ const cancel = function () {
1395
+ if (groupRename === null) return;
1396
+ groupRename = null;
1397
+ takeDown();
1398
+ focusTag();
1399
+ };
1400
+
1401
+ input.addEventListener('change', finish);
1402
+ 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 };
1411
+ input.focus();
1412
+ input.select();
1413
+ }
1414
+
1415
+ /**
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.
1424
+ */
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);
1755
+ }
1756
+ }
1757
+
1758
+ // ── the keyboard on the drawing ───────────────────────────────────────
1759
+ /**
1760
+ * Every gesture the drawing has, without a pointer.
1761
+ *
1762
+ * The drawing was the one surface in this dialog a keyboard could not
1763
+ * operate AT ALL: painting was a mouse drag, and `selection` — which every
1764
+ * cycle operation reads — could only be made by dragging. There are FIVE
1765
+ * cycle buttons, and four of them refused outright for a keyboard user,
1766
+ * each with its own message a few lines above: 刪除 cycle and 複製 answer
1767
+ *「先選一段 cycle 再…」with no selection, and both 貼上 answer「剪貼簿是
1768
+ * 空的」because the only thing that fills the clipboard is 複製. The fifth,
1769
+ * 插入 cycle, is the one that worked — and `selection === null ? 0` means
1770
+ * it silently meant "at cycle 0" wherever the user was looking. This is
1771
+ * that `selection`, made with the arrow keys.
1772
+ *
1773
+ * The keymap is deliberately small and has no chords: arrows move,
1774
+ * Shift+arrow extends along the lane, Home/End jump to the ends of the
1775
+ * lane, a brush character paints, Space and Enter paint with whatever
1776
+ * brush is on. Everything else the dialog can do is a real `<button>` or
1777
+ * `<input>` that Tab already reaches.
1778
+ *
1779
+ * Two targets are accepted, and only two:
1780
+ * the canvas — the surface itself, `tabindex="0"`, so it is in the Tab
1781
+ * cycle and in the focus trap like every other control.
1782
+ * the panel — the dialog element, which holds the keyboard for exactly
1783
+ * one moment: right after it opens, where the ARIA pattern
1784
+ * puts it (T6k pins that). An arrow key there can only
1785
+ * mean the drawing, so it ENTERS the drawing — and does not
1786
+ * also move, because a cursor that appears one cell away
1787
+ * from where it was born never shows the user where it
1788
+ * started counting from.
1789
+ */
1790
+ function handleDrawingKey(ev) {
1791
+ const onCanvas = canvas !== null && ev.target === canvas;
1792
+ const onPanel = ev.target === panel;
1793
+ if (!onCanvas && !onPanel) return false;
1794
+ const key = ev.key;
1795
+ const nav = key === 'ArrowLeft' || key === 'ArrowRight' ||
1796
+ key === 'ArrowUp' || key === 'ArrowDown' || key === 'Home' || key === 'End';
1797
+ if (onPanel) {
1798
+ if (!nav) return false;
1799
+ ev.preventDefault();
1800
+ ev.stopPropagation();
1801
+ enterDrawing();
1802
+ return true;
1803
+ }
1804
+ if (nav) {
1805
+ ev.preventDefault();
1806
+ ev.stopPropagation();
1807
+ moveCursor(key, ev.shiftKey === true);
1808
+ return true;
1809
+ }
1810
+ // A brush character both PICKS the brush and paints with it. Picking
1811
+ // without painting would need a second keystroke to do the thing the
1812
+ // 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) {
1818
+ ev.preventDefault();
1819
+ ev.stopPropagation();
1820
+ brush = key;
1821
+ paintBrushState();
1822
+ paintAtCursor();
1823
+ return true;
1824
+ }
1825
+ if (key === ' ' || key === 'Enter') {
1826
+ ev.preventDefault();
1827
+ ev.stopPropagation();
1828
+ paintAtCursor();
1829
+ return true;
1830
+ }
1831
+ return false;
1832
+ }
1833
+
1834
+ /** Put the keyboard on the drawing, at the cell it last held (cell 0 of
1835
+ * lane 0 the first time). Never moves the cursor: entering and moving in
1836
+ * one keystroke hides where the cursor came from. */
1837
+ function enterDrawing() {
1838
+ if (canvas === null) return;
1839
+ const layout = geometry.layoutOf(store.doc, SIZES);
1840
+ if (layout.lanes.length === 0 || layout.cycles === 0) { canvas.focus(); return; }
1841
+ // A cursor, and NOT a selection. Arriving is not choosing: this fires on
1842
+ // a plain `Tab` onto the drawing, and a focus move that quietly arms
1843
+ // 刪除 cycle and 複製 with a range nobody drew is a destructive button
1844
+ // loaded by walking past it. The first arrow key — an act, not a
1845
+ // traversal — is what makes a selection, exactly as a pointer press
1846
+ // does, and `paintAtCursor` already falls back to the single cursor cell
1847
+ // when there is no selection to use.
1848
+ if (cursor === null) cursor = { laneIndex: 0, cycle: 0 };
1849
+ canvas.focus();
1850
+ repaintDrawing(layout);
1851
+ sayCursor(layout);
1852
+ }
1853
+
1854
+ /**
1855
+ * Move the cell cursor, and carry `selection` with it.
1856
+ *
1857
+ * The two are one thing on purpose. `selection` is what every cycle
1858
+ * operation acts on and what `setCellRange` is given, and its shape —
1859
+ * ONE lane, a run of cycles — is exactly what a cursor plus Shift can
1860
+ * describe. A pointer press already collapses it to the pressed cell; a
1861
+ * 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.
1865
+ */
1866
+ function moveCursor(key, extend) {
1867
+ const layout = geometry.layoutOf(store.doc, SIZES);
1868
+ if (layout.lanes.length === 0 || layout.cycles === 0) return;
1869
+ if (cursor === null) { enterDrawing(); return; }
1870
+ let lane = cursor.laneIndex;
1871
+ let cyc = cursor.cycle;
1872
+ if (key === 'ArrowLeft') cyc -= 1;
1873
+ else if (key === 'ArrowRight') cyc += 1;
1874
+ else if (key === 'ArrowUp') lane -= 1;
1875
+ else if (key === 'ArrowDown') lane += 1;
1876
+ else if (key === 'Home') cyc = 0;
1877
+ else if (key === 'End') cyc = layout.cycles - 1;
1878
+ lane = Math.max(0, Math.min(lane, layout.lanes.length - 1));
1879
+ cyc = Math.max(0, Math.min(cyc, layout.cycles - 1));
1880
+ const sameLane = lane === cursor.laneIndex;
1881
+ cursor = { laneIndex: lane, cycle: cyc };
1882
+ if (extend && sameLane && selection !== null && selection.laneIndex === lane) {
1883
+ selection = { laneIndex: lane, from: selection.from, to: cyc };
1884
+ } else {
1885
+ selection = { laneIndex: lane, from: cyc, to: cyc };
1886
+ }
1887
+ repaintDrawing(layout);
1888
+ scrollCursorIntoView(layout);
1889
+ sayCursor(layout);
1890
+ }
1891
+
1892
+ /** Paint the cursor's run with the current brush: one gesture, one
1893
+ * `commit`, exactly like the pointer's `mouseup`. The range comes from
1894
+ * `selection` when it belongs to the cursor's own lane — which every
1895
+ * keyboard move guarantees — and from the single cursor cell otherwise,
1896
+ * so a selection left behind by a drag on ANOTHER lane can never make a
1897
+ * keystroke paint somewhere the cursor is not. */
1898
+ function paintAtCursor() {
1899
+ if (cursor === null) return;
1900
+ const sel = (selection !== null && selection.laneIndex === cursor.laneIndex)
1901
+ ? selection : { laneIndex: cursor.laneIndex, from: cursor.cycle, to: cursor.cycle };
1902
+ const ch = brush;
1903
+ commit('paint', function (doc) {
1904
+ return codec.setCellRange(doc, sel.laneIndex, sel.from, sel.to, ch);
1905
+ });
1906
+ }
1907
+
1908
+ /**
1909
+ * Say where the cursor is, in words.
1910
+ *
1911
+ * The rectangle is the answer for someone who can see it; the status line
1912
+ * carries `role="status"`, so this is the same answer for someone who
1913
+ * cannot. It names the LANE rather than its index because the index is the
1914
+ * codec's flattened display order and means nothing to the person reading.
1915
+ *
1916
+ * POSITION ONLY — no instructions. A live region is re-announced in full
1917
+ * every time it changes, and this changes on every arrow key, so a hint
1918
+ * appended here is a hint read out on every press of a key the user is
1919
+ * holding down. The hint is already in the two places it belongs and is
1920
+ * announced once each: the canvas's own `aria-label`, read when the
1921
+ * keyboard arrives on the drawing, and the opening status line.
1922
+ */
1923
+ function sayCursor(layout) {
1924
+ if (cursor === null) return;
1925
+ const row = layout.lanes[cursor.laneIndex];
1926
+ const lane = row === undefined ? null : row.lane;
1927
+ const name = (lane !== null && typeof lane.name === 'string' && lane.name !== '')
1928
+ ? lane.name : '第 ' + (cursor.laneIndex + 1) + ' 條 lane';
1929
+ const from = selection === null ? cursor.cycle : Math.min(selection.from, selection.to);
1930
+ const to = selection === null ? cursor.cycle : Math.max(selection.from, selection.to);
1931
+ const span = (selection !== null && selection.laneIndex === cursor.laneIndex && to > from)
1932
+ ? ',選了 cycle ' + (from + 1) + '–' + (to + 1) : '';
1933
+ say('游標:' + name + ' cycle ' + (cursor.cycle + 1) + span);
1934
+ }
1935
+
1936
+ /** The lane list and the drawing, repainted together and without touching
1937
+ * the store or the preview: a cursor move changes no document content, so
1938
+ * re-rendering the engine's preview beside it would be a round trip to
1939
+ * paint the identical picture. The lane list IS repainted, because
1940
+ * `.is-selected` on its rows is where "which lane am I on" is shown. */
1941
+ function repaintDrawing(layout) {
1942
+ const l = layout === undefined || layout === null
1943
+ ? geometry.layoutOf(store.doc, SIZES) : layout;
1944
+ renderLanes(store.doc, l);
1945
+ renderCanvas(store.doc, l);
1946
+ }
1947
+
1948
+ /**
1949
+ * Keep the cursor inside the canvas column's clip.
1950
+ *
1951
+ * `.ed-wave-canvas-wrap` is a scroll container — that is how a diagram
1952
+ * wider than the column is reached at all — so a cursor walked off its
1953
+ * right-hand edge is a keyboard cursor the user cannot see, which is the
1954
+ * one thing this task may not produce. Not a corner: MEASURED in this
1955
+ * session on the 800x600 viewport the browser suites use, with the fixture
1956
+ * this file's journey rows use, the column's `clientWidth` is 230px and the
1957
+ * drawing is 240px wide at 5 cycles — so the LAST cycle is already off the
1958
+ * clip before anything is inserted.
1959
+ *
1960
+ * The arithmetic is against the wrap's PADDING BOX (`clientLeft` /
1961
+ * `clientWidth`), which is the box `scrollLeft` moves; measuring against
1962
+ * `getBoundingClientRect().width` instead counts the border and leaves the
1963
+ * cell short of where it was asked to go.
1964
+ */
1965
+ function scrollCursorIntoView(layout) {
1966
+ if (cursor === null || canvas === null) return;
1967
+ const box = geometry.cellRect(layout, cursor.laneIndex, cursor.cycle);
1968
+ if (box === null || typeof canvas.getBoundingClientRect !== 'function') return;
1969
+ const cbox = canvas.getBoundingClientRect();
1970
+ const wbox = canvasWrap.getBoundingClientRect();
1971
+ const left = (cbox.left + box.x) - (wbox.left + canvasWrap.clientLeft);
1972
+ const top = (cbox.top + box.y) - (wbox.top + canvasWrap.clientTop);
1973
+ const w = canvasWrap.clientWidth;
1974
+ const h = canvasWrap.clientHeight;
1975
+ if (left < 0) canvasWrap.scrollLeft += left;
1976
+ else if (left + box.width > w) canvasWrap.scrollLeft += left + box.width - w;
1977
+ if (top < 0) canvasWrap.scrollTop += top;
1978
+ else if (top + box.height > h) canvasWrap.scrollTop += top + box.height - h;
1979
+ }
1980
+
1981
+ // ── the pointer ───────────────────────────────────────────────────────
1982
+ /**
1983
+ * A paint is a real drag: `mousedown` picks the first cycle, `mousemove`
1984
+ * extends the run, `mouseup` is the one moment the document changes.
1985
+ *
1986
+ * Nothing is applied while the button is down. One gesture is one
1987
+ * `setCellRange`, which is also what the run means — a bus dragged across
1988
+ * four cycles is one value with three continuations, not four values.
1989
+ */
1990
+ function onCanvasDown(ev) {
1991
+ if (ev.button !== 0) return;
1992
+ const layout = geometry.layoutOf(store.doc, SIZES);
1993
+ const at = cellFromEvent(ev, layout);
1994
+ if (at === null) return;
1995
+ ev.preventDefault();
1996
+ drag = { laneIndex: at.laneIndex, from: at.cycle, to: at.cycle };
1997
+ selection = { laneIndex: at.laneIndex, from: at.cycle, to: at.cycle };
1998
+ // The keyboard follows the pointer, for two reasons that are really one:
1999
+ // the two cursors may not disagree about which cell is current, and
2000
+ // `preventDefault()` above suppresses the focus a press would otherwise
2001
+ // give a `tabindex` element — so without this, clicking the drawing and
2002
+ // then pressing `1` would paint wherever the keyboard had been left.
2003
+ cursor = { laneIndex: at.laneIndex, cycle: at.cycle };
2004
+ canvas.focus();
2005
+ renderCanvas(store.doc, layout);
2006
+ d.addEventListener('mousemove', onCanvasMove, true);
2007
+ d.addEventListener('mouseup', onCanvasUp, true);
2008
+ }
2009
+
2010
+ function onCanvasMove(ev) {
2011
+ if (drag === null) return;
2012
+ // The button came up somewhere this page never heard about — outside the
2013
+ // window, or over the browser's own chrome. Without this the drag stays
2014
+ // armed, the next pointer move across the canvas keeps extending a
2015
+ // selection with nothing held down, and the following press commits a
2016
+ // range the user never drew. Read from the code, not measured: puppeteer
2017
+ // cannot withhold a `mouseup`.
2018
+ if (ev.buttons === 0) { onCanvasUp(); return; }
2019
+ const layout = geometry.layoutOf(store.doc, SIZES);
2020
+ const at = cellFromEvent(ev, layout);
2021
+ if (at === null) return;
2022
+ // The lane is fixed by the press: a drag that strays onto a neighbour is
2023
+ // still painting the lane it started on.
2024
+ drag.to = at.cycle;
2025
+ selection = { laneIndex: drag.laneIndex, from: drag.from, to: drag.to };
2026
+ // The drag's live end is where the cursor is, so a paint continued with
2027
+ // Shift+←/→ after the button comes up carries on from the same cell.
2028
+ cursor = { laneIndex: drag.laneIndex, cycle: drag.to };
2029
+ renderCanvas(store.doc, layout);
2030
+ }
2031
+
2032
+ function onCanvasUp() {
2033
+ d.removeEventListener('mousemove', onCanvasMove, true);
2034
+ d.removeEventListener('mouseup', onCanvasUp, true);
2035
+ if (drag === null) return;
2036
+ const g = drag;
2037
+ drag = null;
2038
+ commit('paint', function (doc) {
2039
+ return codec.setCellRange(doc, g.laneIndex, g.from, g.to, brush);
2040
+ });
2041
+ }
2042
+
2043
+ /** Pointer coordinates in the drawing's own space, then the codec's cell. */
2044
+ function cellFromEvent(ev, layout) {
2045
+ if (canvas === null) return null;
2046
+ const box = canvas.getBoundingClientRect();
2047
+ return geometry.cellAt(layout, ev.clientX - box.left, ev.clientY - box.top);
2048
+ }
2049
+
2050
+ // ── the preview ───────────────────────────────────────────────────────
2051
+ /**
2052
+ * The engine's own rendering of the very same document.
2053
+ *
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.
2057
+ */
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;
2065
+ }
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');
2089
+ }
2090
+ }
2091
+
2092
+ // Mounted BEFORE the first paint, not after: the preview is rendered by the
2093
+ // engine's own `RenderWaveForm`, which resolves its target with
2094
+ // `getElementById` — an element that is not in the document yet is not
2095
+ // findable, and the engine's failure there is a null dereference several
2096
+ // frames away from the cause. Measured: rendering first left every preview
2097
+ // reading `failed` with "Cannot read properties of null".
2098
+ lockScroll();
2099
+ host.appendChild(overlay);
2100
+ panel.focus();
2101
+ render();
2102
+ // The opening line described a pointer and only a pointer, which is how a
2103
+ // surface ends up with no keyboard: the instructions never mentioned one.
2104
+ say('選一個電位,然後在波形上按住拖過去;或直接按方向鍵進波形,' +
2105
+ 'Shift+左右鍵選一段,再按電位字元塗上去。');
2106
+
2107
+ return {
2108
+ el: overlay,
2109
+ store: store,
2110
+ close: close,
2111
+ refresh: render,
2112
+ lastPatch: function () { return lastPatch; },
2113
+ };
2114
+ }
2115
+
2116
+ return {
2117
+ createWaveEditor: createWaveEditor,
2118
+ brickOf: brickOf,
2119
+ groupSpansOf: groupSpansOf,
2120
+ mountedInsideContent: mountedInsideContent,
2121
+ BRUSHES: BRUSHES,
2122
+ SIZES: SIZES,
2123
+ };
2124
+ });