@helping-ai-workflow/md2doc 3.5.0 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1102 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * wave-panels — the toolbar, the right-hand panels (meta fields, the edge
5
+ * inspector) and the lane rail. Moved out of `wave-ui.js`, behaviour
6
+ * unchanged line for line — this module is a pure move, not a rewrite.
7
+ *
8
+ * Plain CommonJS, like `wave-codec.js` / `wave-geometry.js` / `wave-store.js`
9
+ * / `wave-draw.js` next to it, and for the identical reason `wave-draw.js`'s
10
+ * own header already gives: `wave-ui.js` is injected into the edit page as a
11
+ * bare `<script>` with no `require()` of its own, so a UMD header here would
12
+ * be reaching for a `require` that is undefined in the browser. `wave-ui.js`
13
+ * takes this module in as `opts.panels` (see `lib/editor/server.js`'s shim
14
+ * and `lib/editor/client.js`'s `openWaveEditor`), the same way it already
15
+ * takes `opts.draw`.
16
+ *
17
+ * This layer does not require `wave-draw.js` — the dependency direction
18
+ * between the two extracted layers is one-way, and neither needs the other:
19
+ * anything both would need travels through `wave-ui.js` or through
20
+ * `wave-geometry.js`.
21
+ *
22
+ * `createPanels(deps)` is called from `wave-ui.js` right after `panel`
23
+ * exists, which is BEFORE several of the DOM elements the returned paint
24
+ * functions need (`metaBar`'s own input fields, the edge inspector's
25
+ * `shapeSel`/`edgeLabelInput`, the lane rail's `laneCol`, the preview's
26
+ * `previewHost`/`previewWrap`, the status line's `status`) exist. Rather
27
+ * than reorder any of `wave-ui.js`'s original DOM-scaffolding statements to
28
+ * satisfy this factory's own call site — the brief this module was built
29
+ * from forbids reordering, and scaffolding order is what keeps the
30
+ * toolbar's visual layout unchanged — `deps` is a single object
31
+ * `wave-ui.js` keeps a reference to and PUBLISHES more fields onto as it
32
+ * creates each element, in their original unchanged position. The functions
33
+ * below that need one of those later fields read it off `deps` directly at
34
+ * their own call time (`deps.laneCol`, `deps.edgeBar`, …) rather than
35
+ * caching it into a local at the top of this factory — by the time any of
36
+ * them actually runs (the first `render()`, or a click on an
37
+ * already-mounted control), every field they read has long since been
38
+ * published. Only what is genuinely ready the moment `createPanels()` runs
39
+ * (`d`, `geometry`, `codec`, `SIZES`, `actions`, `overlay`, `panel`, the
40
+ * preview's id constants, the wavedrom engine reference) is cached into a
41
+ * local up front.
42
+ *
43
+ * v3.6.0 Task 12: `toolButton()` used to always append into a single
44
+ * `cycleBar` div cached from `deps.cycleBar` — the toolbar's only button
45
+ * row at the time. Now that the toolbar is eight captioned
46
+ * `.ed-wave-tool-group` sections (`toolGroup()`, below), `toolButton()`
47
+ * takes the target group as its last argument instead, and `deps` no
48
+ * longer carries `cycleBar` at all — nothing in this file needs a single
49
+ * shared bar to exist before `createPanels()` runs anymore.
50
+ *
51
+ * `actions` is the callback bag for the handful of `wave-ui.js`-owned pieces
52
+ * of state a moved function still has to read or write, now that it is no
53
+ * longer a closure over `wave-ui.js`'s own variables:
54
+ *
55
+ * commit(name, fn) run one gesture (constraint 5 in wave-ui.js's
56
+ * own header)
57
+ * render() repaint the whole dialog
58
+ * selectionSet(v) write-only: `laneRow`'s own click handler
59
+ * writes the one selection this dialog has —
60
+ * `{laneIndex, laneTo, from, to}`, a lane
61
+ * range plus the cycle range on its first
62
+ * lane. Reads go through `state.selection`
63
+ * instead, since every one of them happens
64
+ * synchronously inside a `renderLanes` call
65
+ * and a getter would be one more indirection
66
+ * over the value `state` already carries
67
+ * focusOverrideGet()/Set(key) the one-shot "where should the keyboard land
68
+ * after this repaint" override — read AND
69
+ * written from inside `renameGroup`'s deferred
70
+ * `finish`/`retire`/`cancel` closures, so both
71
+ * halves are needed, not only the setter a
72
+ * write-only caller like `laneRow`'s drop
73
+ * handler uses
74
+ * groupRenameGet()/Set(v) the rail's one open rename session — a mutex
75
+ * `renameGroup`'s own deferred closures test
76
+ * and clear over time, and `wave-ui.js`'s
77
+ * `render()` also reads directly (unchanged,
78
+ * since `render()` itself did not move) to
79
+ * decide whether to retire it before a repaint
80
+ *
81
+ * `close` is not in `actions`: no function that moved here calls it (checked
82
+ * against every line in this file, not assumed).
83
+ *
84
+ * `commitBanner` is exported despite being internal to the eight meta-field
85
+ * change listeners that are its only callers — a deviation from this task's
86
+ * own brief, which called it an internal helper. Those eight listeners stay
87
+ * in `wave-ui.js`, at their original position, because the input elements
88
+ * they close over (`headInput`, `footInput`, …) are `wave-ui.js` locals a
89
+ * caller needs to go on holding for `deps.headInput` etc. below to work; the
90
+ * alternative — building those eight fields and their listeners inside this
91
+ * factory instead, so `commitBanner` could stay private — would mean
92
+ * reconstructing `wave-ui.js`'s own scaffolding order inside this file,
93
+ * which is the reordering this whole factory is built to avoid. Exporting
94
+ * one more trivial function was the smaller change.
95
+ */
96
+
97
+ function createPanels(deps) {
98
+ const d = deps.d;
99
+ const geometry = deps.geometry;
100
+ const codec = deps.codec;
101
+ const SIZES = deps.SIZES;
102
+ const actions = deps.actions;
103
+ const overlay = deps.overlay;
104
+ const panel = deps.panel;
105
+ const PREVIEW_INDEX = deps.PREVIEW_INDEX;
106
+ const PREVIEW_ID_PREFIX = deps.PREVIEW_ID_PREFIX;
107
+ const wavedrom = deps.wavedrom;
108
+
109
+ /**
110
+ * A plain, unfrozen copy of a document.
111
+ *
112
+ * The store's documents are deep-frozen — that is what stops a stray write
113
+ * from corrupting an undo entry — and wavedrom's renderer WRITES INTO the
114
+ * source object it is handed. Under this file's strict prologue that is a
115
+ * throw, so the preview gets its own copy every time. Bounded by the codec's
116
+ * own nesting ceiling so a pathological document cannot run the stack out
117
+ * here either.
118
+ */
119
+ function plainCopy(value, depth) {
120
+ if (depth > 64 || value === null || typeof value !== 'object') return value;
121
+ if (Array.isArray(value)) {
122
+ const out = [];
123
+ for (let i = 0; i < value.length; i++) out.push(plainCopy(value[i], depth + 1));
124
+ return out;
125
+ }
126
+ const out = {};
127
+ for (const key of Object.keys(value)) out[key] = plainCopy(value[key], depth + 1);
128
+ return out;
129
+ }
130
+
131
+ /**
132
+ * v3.6.0 Task 12: one captioned toolbar section — a `.ed-wave-tool-group`
133
+ * with a `.ed-wave-tool-caption` at the front.
134
+ *
135
+ * The caption sits to the LEFT of the section's buttons, not above them:
136
+ * the toolbar is a horizontal bar, and a caption row above each group
137
+ * would make it two lines tall on top of a row that already wraps — the
138
+ * 電位 group's 22 brushes alone wrap to two rows at this dialog's fixed
139
+ * viewport (MEASURED, Task 10: 22 × 38px against a 728px container).
140
+ * Vertical space is what a tall diagram (60+ cycles) is already short of,
141
+ * so this reorganisation may not spend more of it.
142
+ */
143
+ function toolGroup(caption) {
144
+ const g = d.createElement('div');
145
+ g.className = 'ed-wave-tool-group';
146
+ const cap = d.createElement('span');
147
+ cap.className = 'ed-wave-tool-caption';
148
+ cap.textContent = caption;
149
+ g.appendChild(cap);
150
+ return g;
151
+ }
152
+
153
+ // `group` (last arg, required) is the `.ed-wave-tool-group` this button
154
+ // belongs to — see `toolGroup()` above and this module's own header for
155
+ // why there is no longer a single shared bar this could default to.
156
+ function toolButton(cls, label, hint, fn, group) {
157
+ const b = d.createElement('button');
158
+ b.type = 'button';
159
+ b.className = cls;
160
+ b.setAttribute('data-focus-key', cls);
161
+ b.textContent = label;
162
+ b.title = hint;
163
+ b.addEventListener('click', fn);
164
+ group.appendChild(b);
165
+ return b;
166
+ }
167
+
168
+ /**
169
+ * v3.6.0 Task 13: one collapsible right-hand panel — a `.ed-wave-section`
170
+ * with its own `.ed-wave-section-title` header button and a
171
+ * `.ed-wave-section-body` the header toggles.
172
+ *
173
+ * `open` seeds `data-open` — the section's expanded state — but that state
174
+ * lives ONLY on the attribute for the life of this one editor session; it
175
+ * is never read back out of, or written into, the document. A session that
176
+ * opens with 關聯線/文件/Lane expanded and 預覽/原始碼 collapsed always
177
+ * starts that way again next time, by design: the three that default open
178
+ * are the ones a gesture on the canvas immediately needs (an edge just
179
+ * got selected, a lane just got selected, the ruler fields), and the two
180
+ * that default closed are read-only references a user opens on demand
181
+ * rather than panels they edit through.
182
+ *
183
+ * Returns `{el, body}` — the section element to place in the DOM, and the
184
+ * body div a caller fills in. Kept separate rather than handing back only
185
+ * `el` and making every caller `querySelector('.ed-wave-section-body')`
186
+ * back out of it: every call site already knows which body it wants the
187
+ * instant it asks for the section.
188
+ */
189
+ function section(key, title, open) {
190
+ const s = d.createElement('section');
191
+ s.className = 'ed-wave-section';
192
+ s.setAttribute('data-section', key);
193
+ if (open) s.setAttribute('data-open', '');
194
+ const h = d.createElement('button');
195
+ h.type = 'button';
196
+ h.className = 'ed-wave-section-title';
197
+ h.setAttribute('data-focus-key', 'section-' + key);
198
+ h.textContent = title;
199
+ h.addEventListener('click', function () {
200
+ // v3.6.0 Task 13: under the <1100px collapse (md2doc.js's own media
201
+ // query), the rail's section titles are the only thing still
202
+ // visible/clickable — without this, a narrow-viewport user could
203
+ // toggle `data-open` all day and never see a body, because
204
+ // `.ed-wave-side:not([data-expanded]) .ed-wave-section-body` stays
205
+ // `display:none` regardless. Harmless at a wide viewport: nothing
206
+ // there matches `[data-expanded]` outside that media query, so this
207
+ // is a no-op attribute there.
208
+ //
209
+ // The FIRST click while still collapsed only reveals the rail and
210
+ // does not also toggle the section it landed on: 關聯線/文件/Lane
211
+ // all default `data-open`, so a plain toggle here would make that
212
+ // very first click look like "the user just closed the panel they
213
+ // pressed" the instant the rail became wide enough to show it.
214
+ const wasExpanded = side.hasAttribute('data-expanded');
215
+ side.setAttribute('data-expanded', '');
216
+ if (!wasExpanded) return;
217
+ if (s.hasAttribute('data-open')) s.removeAttribute('data-open');
218
+ else s.setAttribute('data-open', '');
219
+ });
220
+ s.appendChild(h);
221
+ const body = d.createElement('div');
222
+ body.className = 'ed-wave-section-body';
223
+ s.appendChild(body);
224
+ return { el: s, body: body };
225
+ }
226
+
227
+ // v3.6.0 Task 13: the whole right-hand column, built eagerly here rather
228
+ // than incrementally published onto `deps` the way `metaBar`/`edgeBar`/
229
+ // `laneCol`/`previewWrap`/`status` are (see this module's own header for
230
+ // why THOSE are lazy): every section body a caller could want already
231
+ // exists the instant `createPanels()` returns, because `section()` above
232
+ // needs nothing but `d`. `wave-ui.js` appends `panels.side` into `.ed-wave-
233
+ // body` once, in the position the old `.ed-wave-preview` column used to
234
+ // occupy, and targets `panels.sections.<key>` for everything that used to
235
+ // go into `metaBar`/`edgeBar`/`previewWrap` directly.
236
+ //
237
+ // Order is the fixed, tested order: edge, document, lane, preview, source.
238
+ // Open by default: edge/document/lane (a gesture on the canvas — selecting
239
+ // an edge, selecting a lane, typing a ruler field — has to see its own
240
+ // panel without an extra click). Closed by default: preview/source (the
241
+ // engine's own picture and the exact bytes a save would write are both
242
+ // references a user consults on demand, not panels edited through).
243
+ const side = d.createElement('div');
244
+ side.className = 'ed-wave-side';
245
+
246
+ // v3.6.0 Task 13 fix round 1: a way back. Expanding the rail (a click on
247
+ // any section title, below) used to be a one-way door under the <1100px
248
+ // collapse — nothing ever removed `data-expanded` again, so the first
249
+ // click at a narrow viewport permanently forfeited canvas space for the
250
+ // rest of the session, which works directly against this whole version's
251
+ // own goal. `display: none` outside `[data-expanded]` (md2doc.js's CSS)
252
+ // keeps this invisible and out of the tab order everywhere else — it only
253
+ // ever matters in the one state it exists for.
254
+ const collapseBtn = d.createElement('button');
255
+ collapseBtn.type = 'button';
256
+ collapseBtn.className = 'ed-wave-side-collapse';
257
+ collapseBtn.setAttribute('data-focus-key', 'side-collapse');
258
+ collapseBtn.title = '收合右欄';
259
+ collapseBtn.textContent = '» 收合';
260
+ collapseBtn.addEventListener('click', function () {
261
+ side.removeAttribute('data-expanded');
262
+ });
263
+ side.appendChild(collapseBtn);
264
+
265
+ const sections = {};
266
+ for (const def of [
267
+ ['edge', '關聯線', true],
268
+ ['document', '文件', true],
269
+ ['lane', 'Lane', true],
270
+ ['preview', 'WaveDrom 預覽', false],
271
+ ['source', '原始碼', false],
272
+ ]) {
273
+ const built = section(def[0], def[1], def[2]);
274
+ side.appendChild(built.el);
275
+ sections[def[0]] = built.body;
276
+ }
277
+
278
+ /**
279
+ * v3.6.0 Task 13 fix round 3: expand the rail programmatically — the SAME
280
+ * `data-expanded` attribute a section title's first click (`section()`
281
+ * above) and the collapse button's own opposite (`collapseBtn` above)
282
+ * already use, called instead from `wave-ui.js`'s `render()` on the one
283
+ * transition that needs it without a click at all: selecting an edge at
284
+ * a narrow viewport, whose only feedback surface is a section this attribute
285
+ * governs. A no-op outside the `<1100px` media query, same as those two
286
+ * call sites — nothing there matches `[data-expanded]` at a wider one.
287
+ */
288
+ function expandSide() {
289
+ side.setAttribute('data-expanded', '');
290
+ }
291
+
292
+ // v3.6.0 Task 13: `container` is the section body a field belongs in —
293
+ // explicit at the call site, the same reasoning `toolButton`'s own
294
+ // trailing `group` argument (Task 12) already gives for why there is no
295
+ // default to fall back on now that this dialog has more than one place a
296
+ // field could land.
297
+ function metaField(cls, label, placeholder, field, container) {
298
+ const wrap = d.createElement('label');
299
+ wrap.className = 'ed-wave-metafield';
300
+ const text = d.createElement('span');
301
+ text.textContent = label;
302
+ wrap.appendChild(text);
303
+ const input = d.createElement('input');
304
+ input.type = 'text';
305
+ input.className = cls;
306
+ input.setAttribute('data-focus-key', cls);
307
+ input.setAttribute('data-field', field);
308
+ input.placeholder = placeholder;
309
+ wrap.appendChild(input);
310
+ container.appendChild(wrap);
311
+ return input;
312
+ }
313
+
314
+ /**
315
+ * A numeral typed into `hscale`/`period`/`phase` is committed as a real
316
+ * JS `number`, not as the string the `<input>` holds — unlike the eight
317
+ * banner fields' `commitBanner`, which always writes a string and is
318
+ * correct to (see its own header comment: the engine's `captext` and
319
+ * `ticktock` both accept a plain string). These three are different:
320
+ * `wave-geometry.hscaleOf`/`layoutOf` gate on `typeof … === 'number'`, so
321
+ * a STRING `"2"` would silently read as "unset" on THIS canvas — this
322
+ * batch's own default of 1/0 — while the pinned engine's own preview still
323
+ * honours it (its `tonumber()` coerces a string with `>`). That is the
324
+ * exact same-idea-two-definitions divergence this batch has already paid
325
+ * for three times elsewhere (see `md2doc`'s CLAUDE.md). Blank clears the
326
+ * field (the `undefined` sentinel `setConfigField`/`setLaneField` both
327
+ * read); anything that is not a plain, trimmed number is left as the raw
328
+ * string — both this canvas's `typeof` gate and the engine's own
329
+ * `tonumber()` already fall back to the SAME default (1 for hscale/period,
330
+ * 0 for phase) for a non-numeric value, so there is nothing to reconcile
331
+ * for that case, only for a well-formed one.
332
+ */
333
+ function parseNumericField(value) {
334
+ if (value === '') return undefined;
335
+ return /^-?\d+(\.\d+)?$/.test(value.trim()) ? Number(value) : value;
336
+ }
337
+
338
+ function commitConfigField(key, value) {
339
+ actions.commit('config.' + key, function (doc) {
340
+ return codec.setConfigField(doc, key, parseNumericField(value));
341
+ });
342
+ }
343
+
344
+ function commitLaneField(at, key, value) {
345
+ actions.commit('lane.' + key, function (doc) {
346
+ return codec.setLaneField(doc, at, key, parseNumericField(value));
347
+ });
348
+ }
349
+
350
+ // v3.5.0 Task 13: widened from `(which, value)` — always writing `.text`
351
+ // — to `(which, key, value)` so the same function also carries the other
352
+ // five ruler fields.
353
+ //
354
+ // Fix round 2: the write itself moved into `codec.setBannerField` rather
355
+ // than staying inline here. The inline version's own no-op guard —
356
+ // `value === '' && (hadObj[key] === undefined || hadObj[key] === null)`
357
+ // — only refused to WRITE when the key had never been set; once a key
358
+ // WAS set, clearing the field fell through to `next[which][key] = ''`,
359
+ // an empty string rather than an absent key. `.text` hid that: the
360
+ // engine's `captext` treats `''` the same as absent (a plain truthy
361
+ // check). `tick`/`tock`/`every` do not — `ticktock` in
362
+ // `node_modules/wavedrom/lib/render-marks.js` only skips a field that is
363
+ // `=== undefined`, so `''` reached its array branch, `Number('')` is
364
+ // `0`, and the engine drew a full default 0-based ruler on a field that
365
+ // LOOKED empty on screen — the exact confident-wrong-state class this
366
+ // file exists to avoid, reintroduced by the write side after the read
367
+ // side (`tickTockText`) got it right. `setBannerField` deletes the key
368
+ // (and the parent object, if that empties it) instead of writing `''`,
369
+ // closing that gap once for all six fields rather than special-casing
370
+ // `.text`'s accidental safety.
371
+ //
372
+ // This also gives these six fields their first test seam:
373
+ // `commitBanner` itself is a closure with no unit-test access, but
374
+ // `setBannerField` is a plain, exported, pure function — pinned in
375
+ // `test/wave-codec.test.js`.
376
+ function commitBanner(which, key, value) {
377
+ actions.commit(which + '.' + key, function (doc) { return codec.setBannerField(doc, which, key, value); });
378
+ }
379
+
380
+ function say(text) {
381
+ deps.status.textContent = text;
382
+ overlay.setAttribute('data-wave-status', text);
383
+ }
384
+
385
+ /**
386
+ * Show or hide the inspector row, and — when it is showing — fill it in
387
+ * from the selected edge's own entry.
388
+ *
389
+ * The ONLY thing that may set `edgeBar.hidden`, so "is an edge selected"
390
+ * and "is there a box on screen" cannot come apart: precondition T1 at
391
+ * the top of this file's docstring for the overlay itself, applied to
392
+ * this row. Called only from `render()`, after `selectedEdge` has already
393
+ * been clamped, so the `codec.parseEdge` below is not expected to fail —
394
+ * it is checked anyway because "expected to" is not "guaranteed to".
395
+ */
396
+ function renderEdgeBar(doc, state) {
397
+ const selectedEdge = state.selectedEdge;
398
+ overlay.setAttribute('data-wave-selected-edge',
399
+ selectedEdge === null ? '' : String(selectedEdge));
400
+ deps.edgeBar.hidden = selectedEdge === null;
401
+ if (selectedEdge === null) return;
402
+ const cur = codec.parseEdge((doc.edge || [])[selectedEdge]);
403
+ if (cur === null) { deps.edgeBar.hidden = true; return; }
404
+ deps.shapeSel.value = cur.shape;
405
+ deps.edgeLabelInput.value = cur.label;
406
+ }
407
+
408
+ /**
409
+ * `tick`/`every`/`tock` are read by the pinned engine as a number, a
410
+ * string, or an array of either (`ticktock` in
411
+ * `node_modules/wavedrom/lib/render-marks.js`) — unlike `head`/`foot`'s
412
+ * own `.text`, which is always a plain string. A text `<input>` can only
413
+ * hold one shape, so a hand-authored numeric or array value is shown in
414
+ * its string form (an array space-joined, the same shape the engine's
415
+ * own `val.trim().split(/\s+/)` turns a plain string back into) rather
416
+ * than blanked out — blanking a field that actually holds `tick: 0`
417
+ * would show a confident wrong state, the same failure `renderUnmodelled`
418
+ * exists to avoid elsewhere in this file. Editing the field always
419
+ * commits a plain string back, which the engine treats identically to
420
+ * whichever shape it replaced.
421
+ */
422
+ function tickTockText(v) {
423
+ if (typeof v === 'string') return v;
424
+ if (typeof v === 'number' || typeof v === 'boolean') return String(v);
425
+ if (Array.isArray(v)) return v.join(' ');
426
+ return '';
427
+ }
428
+
429
+ function renderMeta(doc) {
430
+ const h = doc.head;
431
+ const f = doc.foot;
432
+ deps.headInput.value = (h !== null && typeof h === 'object' && typeof h.text === 'string')
433
+ ? h.text : '';
434
+ deps.footInput.value = (f !== null && typeof f === 'object' && typeof f.text === 'string')
435
+ ? f.text : '';
436
+ deps.headTickInput.value = (h !== null && typeof h === 'object') ? tickTockText(h.tick) : '';
437
+ deps.headEveryInput.value = (h !== null && typeof h === 'object') ? tickTockText(h.every) : '';
438
+ deps.footTockInput.value = (f !== null && typeof f === 'object') ? tickTockText(f.tock) : '';
439
+ deps.headTockInput.value = (h !== null && typeof h === 'object') ? tickTockText(h.tock) : '';
440
+ deps.footTickInput.value = (f !== null && typeof f === 'object') ? tickTockText(f.tick) : '';
441
+ deps.footEveryInput.value = (f !== null && typeof f === 'object') ? tickTockText(f.every) : '';
442
+ // v3.6.0 Task 13: the document panel's ninth field. `config.hscale` is
443
+ // meant to hold a number (see `commitConfigField`'s own comment), but a
444
+ // hand-authored document can carry anything — `tickTockText` already
445
+ // does the identical "show it as a string either way" job for the six
446
+ // ruler fields, so it is reused here rather than a near-duplicate.
447
+ const cfg = doc.config;
448
+ deps.hscaleInput.value = (cfg !== null && typeof cfg === 'object')
449
+ ? tickTockText(cfg.hscale) : '';
450
+ }
451
+
452
+ /**
453
+ * The Lane panel's two fields — `period`/`phase` for whichever lane the
454
+ * CYCLE selection currently names (`state.selection.laneIndex`, the same
455
+ * mark the brush/cycle toolbar already acts on — there is no separate
456
+ * "selected lane" concept in this dialog). Disabled, and blank, whenever
457
+ * nothing is selected: a control a gesture cannot currently reach is
458
+ * disabled rather than left to silently do nothing, the same rule
459
+ * `groupBtn`'s own disabled state already follows in `wave-ui.js`.
460
+ */
461
+ function renderLaneField(doc, layout, state) {
462
+ const sel = state.selection;
463
+ const row = (sel !== null) ? layout.lanes[sel.laneIndex] : undefined;
464
+ if (row === undefined) {
465
+ deps.lanePeriodInput.disabled = true;
466
+ deps.lanePhaseInput.disabled = true;
467
+ deps.lanePeriodInput.value = '';
468
+ deps.lanePhaseInput.value = '';
469
+ return;
470
+ }
471
+ deps.lanePeriodInput.disabled = false;
472
+ deps.lanePhaseInput.disabled = false;
473
+ const lane = (row.lane !== null && typeof row.lane === 'object') ? row.lane : {};
474
+ deps.lanePeriodInput.value = tickTockText(lane.period);
475
+ deps.lanePhaseInput.value = tickTockText(lane.phase);
476
+ }
477
+
478
+ /**
479
+ * The 原始碼 panel: `writeBack(originalText, doc).text` — the actual bytes
480
+ * a save would write — not a re-serialisation made for display. Reads
481
+ * `actions.sourceText()`, which walks the SAME `store.toPatch()` path
482
+ * `afterStoreMoved` already runs after every gesture (`wave-ui.js`), so
483
+ * this panel can never show a different answer than what 保留並關閉 would
484
+ * actually write.
485
+ */
486
+ function renderSource() {
487
+ const pre = d.createElement('pre');
488
+ pre.className = 'ed-wave-source';
489
+ pre.textContent = actions.sourceText();
490
+ sections.source.replaceChildren(pre);
491
+ }
492
+
493
+ /**
494
+ * Say out loud which properties of this document the drawing is not
495
+ * showing.
496
+ *
497
+ * v3.6.0 Task 18: `config.hscale` and a lane's `period` / `phase` used to
498
+ * head this list — three properties that change what the engine paints and
499
+ * that the drawing ignored. Task 4 made all three part of `layoutOf`'s
500
+ * per-lane geometry (`cycleWidth` carries `period * hscale`, `originX`
501
+ * carries `phase`), and `test/wave-draw.test.js`'s `period: 4` and
502
+ * `hscale: 2` fixtures pin the ruler and grid pitch against the engine's
503
+ * own, so naming them as "not shown" is now the false statement. They are
504
+ * gone from the list; `found` stays as the place the NEXT such property
505
+ * lands, and is empty today.
506
+ *
507
+ * The brief for this task asked instead for a notice when the skin ratios
508
+ * could not be derived (`SKIN_METRICS.derived !== true`). That field does
509
+ * not exist: controller ruling R12 (Task 3) cancelled the module-load
510
+ * derivation, because the shipped `wavedrom.min.js` attaches no `waveSkin`
511
+ * to the page global, so a page-time derivation would read `undefined`
512
+ * every time and the notice would be permanent wallpaper rather than a
513
+ * signal. The ratios are pinned from a measurement, and
514
+ * `test/wave-geometry.test.js` re-derives them from the real package on
515
+ * every run — a wavedrom bump that moves the skin turns that test red and
516
+ * names the number to re-pin. That is where the failure is reported,
517
+ * because it is a build-time fact and there is no runtime state for a
518
+ * notice to read.
519
+ *
520
+ * Edges join this same notice for the opposite reason. As of this task
521
+ * they ARE drawn — `renderEdges` below — so the claim for them is not "the
522
+ * drawing ignores this property" but "this specific edge cannot be
523
+ * placed".
524
+ *
525
+ * final review finding 3: this used to re-derive only ONE of
526
+ * `geometry.edgeLayout`'s three skip reasons (the dangling-letter one) by
527
+ * walking `doc.edge` a second time with its own copy of that check —
528
+ * which is also the one case the pinned engine agrees with us on
529
+ * (measured: an edge naming an absent letter draws nothing there either).
530
+ * The other two — an unparseable shape, and a `node` index that has
531
+ * outlived its lane's length (reachable from the editor's own
532
+ * insert/delete-cycles buttons, since neither reindexes `node`) — are
533
+ * exactly where the engine DOES draw a line and we silently do not,
534
+ * which is the one divergence this whole overlay exists to prevent, and
535
+ * the old code said nothing about either.
536
+ *
537
+ * So this asks `geometry.edgeLayoutDetail` — the SAME walk `renderEdges`
538
+ * draws from — which edges it could not place and why, instead of
539
+ * re-deriving any check of its own. Skips are bucketed by whatever
540
+ * `reason` string comes back, not by a hardcoded list of the two or three
541
+ * reasons known today: a fourth `continue` added to `edgeLayoutDetail`
542
+ * later shows up here as its own bucket (worded generically if
543
+ * `REASON_TEXT` has not been taught a nicer sentence for it yet) with no
544
+ * change needed in this function, rather than escaping the notice the way
545
+ * the third one did until now.
546
+ */
547
+ function renderUnmodelled(doc, layout) {
548
+ // Empty today — see the block comment above for what used to be pushed
549
+ // here and why it no longer is. Still assembled (and still published as
550
+ // `data-wave-unmodelled`) so the next genuinely-unmodelled property has
551
+ // somewhere to land, and so a test can tell "nothing to report" from
552
+ // "this notice was never rendered at all".
553
+ const found = [];
554
+
555
+ const detail = geometry.edgeLayoutDetail(doc, layout);
556
+ // Bucket every skip by its own reported `reason` — discovered from what
557
+ // came back, not enumerated in advance, so nothing here has to change
558
+ // for a reason this function has never heard of.
559
+ const buckets = new Map();
560
+ for (const skip of detail.skipped) {
561
+ if (!buckets.has(skip.reason)) buckets.set(skip.reason, []);
562
+ buckets.get(skip.reason).push(skip);
563
+ }
564
+
565
+ function letters(list) {
566
+ const out = [];
567
+ for (const skip of list) {
568
+ if (skip.edge === null) continue;
569
+ if (out.indexOf(skip.edge.from) === -1) out.push(skip.edge.from);
570
+ if (out.indexOf(skip.edge.to) === -1) out.push(skip.edge.to);
571
+ }
572
+ return out;
573
+ }
574
+
575
+ // Known reasons get a sentence that says what a user can act on.
576
+ // `dangling` matches the engine (nothing to reconcile against the
577
+ // preview); `unparseable` and `out-of-range` are the two the engine
578
+ // draws and we do not, so both point at the preview as the tie-breaker.
579
+ const REASON_TEXT = {
580
+ dangling: function (list) {
581
+ return '這個區塊有 ' + list.length + ' 條關聯線引用了不存在的 anchor(' +
582
+ letters(list).join('、') + '),左邊畫不出它們。';
583
+ },
584
+ unparseable: function (list) {
585
+ return '這個區塊有 ' + list.length +
586
+ ' 條關聯線的寫法連引擎也認不得,左邊畫不出它們(跟右邊的 WaveDrom 預覽一致)。';
587
+ },
588
+ 'out-of-range': function (list) {
589
+ return '這個區塊有 ' + list.length + ' 條關聯線的 anchor(' +
590
+ letters(list).join('、') + ')落在那條 lane 目前的長度之外,左邊畫不出它們' +
591
+ '——但右邊的 WaveDrom 預覽仍然會畫,兩邊會不一致,以預覽為準。';
592
+ },
593
+ };
594
+
595
+ const sentences = [];
596
+ if (found.length > 0) {
597
+ sentences.push('這份文件用了 ' + found.join('、') +
598
+ ',左邊的手繪波形不表現它們(寬度與相位會不一樣)——以右邊的 WaveDrom 預覽為準。');
599
+ }
600
+ for (const [reason, list] of buckets) {
601
+ const text = REASON_TEXT[reason];
602
+ sentences.push(text ? text(list) :
603
+ '這個區塊有 ' + list.length + ' 條關聯線因為「' + reason + '」畫不出來,' +
604
+ '以右邊的 WaveDrom 預覽為準。');
605
+ }
606
+
607
+ const danglingCount = buckets.has('dangling') ? buckets.get('dangling').length : 0;
608
+ overlay.setAttribute('data-wave-unmodelled', found.join(' '));
609
+ overlay.setAttribute('data-wave-dangling-edges', String(danglingCount));
610
+ // The total the dangling-only count above cannot say: every edge entry
611
+ // `edgeLayoutDetail` could not place, for any reason at all.
612
+ overlay.setAttribute('data-wave-skipped-edges', String(detail.skipped.length));
613
+ deps.unmodelled.hidden = sentences.length === 0;
614
+ deps.unmodelled.textContent = sentences.join(' ');
615
+
616
+ // …and the bigger version of the same honesty. This editor edits
617
+ // `signal:` and nothing else, so a `reg:` or `assign:` block opens a
618
+ // canvas with no lanes on it: `lanePaths()` is empty, `addLane()` hands
619
+ // the document straight back, and pressing + produced one status line
620
+ //「這個動作沒有改變任何東西」— true, and no explanation at all. An
621
+ // editor that looks broken is worse than one that says what it cannot
622
+ // do, and the notice above is the precedent: the drawing already tells
623
+ // the user which properties it is not showing them.
624
+ const kinds = [];
625
+ for (const key of ['reg', 'assign']) {
626
+ if (doc[key] !== undefined) kinds.push(key + ':');
627
+ }
628
+ const noLanes = codec.lanePaths(doc).length === 0;
629
+ overlay.setAttribute('data-wave-nolanes', noLanes ? '1' : '0');
630
+ deps.noSignal.hidden = !noLanes;
631
+ deps.noSignal.textContent = !noLanes ? '' :
632
+ (kinds.length === 0
633
+ ? '這個區塊裡沒有 signal:,所以沒有可以編輯的 lane。'
634
+ : '這個區塊用的是 ' + kinds.join(' / ') +
635
+ ',這個編輯器只編輯 signal: 的 lane。') +
636
+ '右邊的 WaveDrom 預覽仍然是引擎畫的原圖;要改這個區塊請用「⠿ → MD 原始碼」。';
637
+ }
638
+
639
+ /**
640
+ * The lane list.
641
+ *
642
+ * Rows are `layout.laneHeight` tall and in `layout.lanes` order, so row k
643
+ * here and row k of the drawing are the same lane by construction rather
644
+ * than by a second traversal that could disagree.
645
+ */
646
+ function renderLanes(doc, layout, state) {
647
+ deps.laneCol.textContent = '';
648
+ const rows = d.createElement('div');
649
+ rows.className = 'ed-wave-lane-rows';
650
+ rows.style.height = layout.height + 'px';
651
+ deps.laneCol.appendChild(rows);
652
+
653
+ const spans = geometry.groupSpansOf(doc, layout);
654
+
655
+ // Group titles first, so they sit UNDER the rows in paint order and a
656
+ // lane's own controls stay clickable over them.
657
+ for (const span of spans) {
658
+ const tag = d.createElement('button');
659
+ tag.type = 'button';
660
+ tag.className = 'ed-wave-group';
661
+ tag.setAttribute('data-focus-key', 'group-' + span.from);
662
+ tag.textContent = span.title;
663
+ tag.title = '群組「' + span.title + '」——點一下改名';
664
+ tag.setAttribute('data-group-from', String(span.from));
665
+ tag.setAttribute('data-group-to', String(span.to));
666
+ // v3.6.0 Task 6: `layout.lanes[i].y`, not `i * layout.laneHeight` — lane
667
+ // 0 no longer starts at y=0 (the ruler/head-title band sits above it),
668
+ // and this rail is a separate scroll-synced column from the canvas that
669
+ // must line up row-for-row with it. `span.from` is always a real lane
670
+ // index (a group always has at least one lane under it), so
671
+ // `layout.lanes[span.from]` is never absent here.
672
+ tag.style.top = layout.lanes[span.from].y + 'px';
673
+ tag.style.height = ((span.to - span.from + 1) * layout.laneHeight) + 'px';
674
+ tag.style.left = ((span.depth - 1) * 16) + 'px';
675
+ tag.addEventListener('click', function () { renameGroup(span); });
676
+ rows.appendChild(tag);
677
+ }
678
+
679
+ for (let i = 0; i < layout.lanes.length; i++) {
680
+ rows.appendChild(laneRow(doc, layout, i, state));
681
+ }
682
+ }
683
+
684
+ /**
685
+ * Which row a lane-handle drag started on, for the life of that one drag.
686
+ *
687
+ * NOT read out of the drag's own `DataTransfer`: that payload is writable
688
+ * by any page, so a drop handler trusting it would accept a drag begun in
689
+ * another window as a lane reorder. This is a closure variable, set by the
690
+ * one `dragstart` in this file and cleared by the matching `dragend` and
691
+ * by every `drop` that acts on it, so a drag that never started here is
692
+ * `null` here and every drop is a no-op.
693
+ */
694
+ let laneDragFrom = null;
695
+
696
+ /**
697
+ * The rail's one row: a drag handle, a name field, and — on a lane that is
698
+ * WaveDrom's own blank spacer — a badge saying so.
699
+ *
700
+ * v3.6.0 Task 14. Every row used to carry six controls (+ ▲ ▼ ✕ 複製
701
+ * 空白列), and the one row that hosted its group's 解散群組 carried seven.
702
+ * They did not fit: the rail is 200px, the name field was flexed down to
703
+ * 45px on the most crowded row (MEASURED in Task 12's own Shift+click
704
+ * comment, since deleted with the gesture it described), and a three-
705
+ * character Chinese caption in a 24px button is laid out vertically by the
706
+ * browser — the editor shipped a column of stacked glyphs where a label was
707
+ * meant. All six moved to the toolbar's 訊號/群組 sections, where they act
708
+ * on `selection`.
709
+ *
710
+ * That move is what retires the rail's SECOND selection — the set of row
711
+ * numbers Task 12 added to serve one button (建立群組), which then had to
712
+ * be carried across undo/redo beside the other four marks, clamped again
713
+ * in `render()`, and drawn with a tint of its own so a user could tell it
714
+ * from the cycle selection standing at the same time. There is one
715
+ * selection now: `selection`, whose `laneIndex`..`laneTo` is the lane range
716
+ * and whose `from`..`to` is the cycle range on `laneIndex`. Shift+click
717
+ * here and Shift+arrow on the canvas write the same mark.
718
+ *
719
+ * Clicking the row selects its lane; clicking the NAME FIELD does not.
720
+ * That exclusion is inherited from the gesture this replaces and is the
721
+ * same fact: `render()` rebuilds the whole rail, so a repaint fired from
722
+ * inside a field the user is still typing in rebuilds that field from the
723
+ * document — discarding uncommitted text with nothing on screen to explain
724
+ * it. The handle is always present and never flexes away, so there is
725
+ * always a click target for "select this lane" even on a row whose name
726
+ * fills the field.
727
+ */
728
+ function laneRow(doc, layout, i, state) {
729
+ const row = d.createElement('div');
730
+ row.className = 'ed-wave-lane-row';
731
+ row.setAttribute('data-lane', String(i));
732
+ // v3.6.0 Task 6: `layout.lanes[i].y` carries the ruler/head-title band's
733
+ // offset, `i * layout.laneHeight` does not. Left as the latter this row
734
+ // would sit `layout.originY` px too high, out of step with its own
735
+ // canvas row.
736
+ row.style.top = layout.lanes[i].y + 'px';
737
+ row.style.height = layout.laneHeight + 'px';
738
+ row.style.paddingLeft = (layout.lanes[i].depth * 16) + 'px';
739
+ if (state.selection !== null &&
740
+ i >= state.selection.laneIndex && i <= state.selection.laneTo) {
741
+ row.classList.add('is-selected');
742
+ }
743
+
744
+ row.addEventListener('click', function (ev) {
745
+ if (ev.target !== null && typeof ev.target.classList === 'object' &&
746
+ ev.target.classList.contains('ed-wave-lane-name')) {
747
+ return;
748
+ }
749
+ ev.preventDefault();
750
+ ev.stopPropagation();
751
+ const sel = state.selection;
752
+ if (ev.shiftKey === true && sel !== null) {
753
+ // Extend, normalised so `laneIndex <= laneTo` always holds and no
754
+ // reader has to re-derive which end is which. Extending UPWARDS
755
+ // therefore moves `laneIndex` — and with it the row the cycle box
756
+ // sits on — to the new top of the range; the cycle numbers
757
+ // themselves are untouched.
758
+ const anchor = sel.laneIndex;
759
+ actions.selectionSet({
760
+ laneIndex: Math.min(anchor, i), laneTo: Math.max(anchor, i),
761
+ from: sel.from, to: sel.to,
762
+ });
763
+ } else {
764
+ // A plain click selects this lane at its first cycle. It IS a cycle
765
+ // selection, deliberately: there is one selection, and picking a
766
+ // lane from the rail has to leave the toolbar's cycle section
767
+ // pointing somewhere real rather than at a lane range with no
768
+ // cycles named.
769
+ actions.selectionSet({ laneIndex: i, laneTo: i, from: 0, to: 0 });
770
+ }
771
+ actions.render();
772
+ }, true);
773
+
774
+ // Reordering. `codec.moveLane` is the one place that knows what a
775
+ // destination position means once the lane has been lifted out (and that
776
+ // it may cross a group boundary on the way) — this gesture hands it two
777
+ // display positions and nothing else, rather than growing a second
778
+ // opinion about lane order in the rail.
779
+ const handle = d.createElement('span');
780
+ handle.className = 'ed-wave-lane-handle';
781
+ handle.setAttribute('draggable', 'true');
782
+ // Deliberately NO `data-focus-key`. `restoreFocus` nominates a key and
783
+ // calls `.focus()` on whatever carries it; a <span> with no tabindex
784
+ // cannot take focus, so a key here would be a silent route to
785
+ // `document.body` the moment anything nominated it — the exact state
786
+ // that turns the next Backspace into「delete the selected blocks」.
787
+ handle.textContent = '⠿';
788
+ // v3.6.0 Task 16: this tooltip is the reorder affordance's own description
789
+ // of how reordering is done, so it names BOTH doors — the drag below and
790
+ // `wave-ui.js`'s Alt+↑/↓, which reach the same `codec.moveLane`.
791
+ //
792
+ // fix round 1 WITHDRAWS the reason the first cut gave for this line
793
+ // ("leaving it drag-only would leave the keyboard route discoverable
794
+ // nowhere"). That was false six lines above its own head: this is a
795
+ // HOVER tooltip on a `<span>` that deliberately carries no
796
+ // `data-focus-key` and cannot take focus, so a keyboard-only user never
797
+ // reads a character of it. What the line is actually worth runs the
798
+ // other way — the person already reaching for the handle with a mouse
799
+ // learns there is a cheaper route. The three surfaces a KEYBOARD user
800
+ // does read are the canvas's `aria-label` (`wave-draw.js`'s
801
+ // `renderCanvas`), the opening status line, and `handleDrawingKey`'s own
802
+ // docstring (both in `wave-ui.js`); fix round 1 is where those three
803
+ // were made true.
804
+ handle.title = '拖曳這裡可以調整這條 lane 的順序(或選起來按 Alt+↑/Alt+↓ 搬)';
805
+ handle.addEventListener('dragstart', function (ev) {
806
+ laneDragFrom = i;
807
+ // The row index also goes into the DataTransfer, but it is NOT what
808
+ // the drop reads: a drop handler that trusted it would accept a drag
809
+ // that started anywhere on the page, including another window. The
810
+ // payload is set because a drag with no data attached does not start
811
+ // at all in some browsers, and for nothing else.
812
+ if (ev.dataTransfer !== null && ev.dataTransfer !== undefined) {
813
+ ev.dataTransfer.effectAllowed = 'move';
814
+ try { ev.dataTransfer.setData('text/plain', String(i)); } catch (e) { /* nothing to do */ }
815
+ }
816
+ });
817
+ handle.addEventListener('dragend', function () { laneDragFrom = null; });
818
+ row.appendChild(handle);
819
+
820
+ row.addEventListener('dragover', function (ev) {
821
+ if (laneDragFrom === null) return;
822
+ // preventDefault is what MAKES this a drop target; without it the
823
+ // browser never fires `drop` at all and the gesture dies silently.
824
+ ev.preventDefault();
825
+ if (ev.dataTransfer !== null && ev.dataTransfer !== undefined) {
826
+ ev.dataTransfer.dropEffect = 'move';
827
+ }
828
+ });
829
+ row.addEventListener('drop', function (ev) {
830
+ if (laneDragFrom === null) return;
831
+ ev.preventDefault();
832
+ const from = laneDragFrom;
833
+ laneDragFrom = null;
834
+ if (from === i) return;
835
+ // The lane arrives at this row, so the keyboard goes to the field it
836
+ // arrives in — the same rule every gesture in this dialog follows, and
837
+ // the reason F7/T6h exist.
838
+ actions.focusOverrideSet('lane-name-' + i);
839
+ actions.commit('move-lane', function (doc2) { return codec.moveLane(doc2, from, i); });
840
+ });
841
+
842
+ const name = d.createElement('input');
843
+ name.type = 'text';
844
+ name.className = 'ed-wave-lane-name';
845
+ name.setAttribute('data-focus-key', 'lane-name-' + i);
846
+ const lane = layout.lanes[i].lane;
847
+ name.value = typeof lane.name === 'string' ? lane.name : '';
848
+ name.addEventListener('change', function () {
849
+ actions.commit('rename', function (doc2) { return codec.renameLane(doc2, i, name.value); });
850
+ });
851
+ row.appendChild(name);
852
+
853
+ // WaveDrom's own blank row, `{}` — no `wave`, no `name`. It draws as
854
+ // nothing, so without this the rail shows an empty name field on a row
855
+ // the canvas leaves blank and there is no way to tell it from a lane
856
+ // someone simply has not named yet.
857
+ if (lane.wave === undefined && lane.name === undefined) {
858
+ const badge = d.createElement('span');
859
+ badge.className = 'ed-wave-lane-badge';
860
+ badge.textContent = '空白列';
861
+ badge.title = 'WaveJSON 的 {},用來空出間距';
862
+ row.appendChild(badge);
863
+ }
864
+ return row;
865
+ }
866
+
867
+ /**
868
+ * Where a lane inserted at display position `index` would actually land,
869
+ * asked of the bridge rather than guessed.
870
+ *
871
+ * This is constraint 4 made visible. `laneInsertPath` puts an insert at a
872
+ * group's FIRST lane inside that group, and an insert at the position just
873
+ * past its LAST lane outside it — a group can be joined at its head and
874
+ * never at its tail. That asymmetry is not something a label can argue
875
+ * away, so the label states it: the 訊號 section's 新增 says which
876
+ * container it will land in before it is pressed (`data-lands-in`, kept
877
+ * current by `wave-ui.js`'s `render()`), and the status line says it again
878
+ * after.
879
+ *
880
+ * v3.6.0 Task 14: exported, because the caller that needs it is now the
881
+ * toolbar in `wave-ui.js` rather than a button this file builds.
882
+ */
883
+ function landingOf(doc, index) {
884
+ const path = codec.laneInsertPath(doc, index);
885
+ if (path === null) return { path: null, title: null };
886
+ const container = path.slice(0, path.length - 1);
887
+ const held = geometry.valueAt(doc, container);
888
+ const title = Array.isArray(held) && typeof held[0] === 'string' ? held[0] : null;
889
+ return { path: path, title: title };
890
+ }
891
+
892
+ /**
893
+ * The rail's rename field: this dialog's one sub-panel.
894
+ *
895
+ * The field is INSERTED beside its tag and the tag is hidden, rather than
896
+ * the two being swapped, and taking it down again touches only those two
897
+ * nodes. That is not tidiness. Taking it down used to mean a full
898
+ * `render()` — a rebuild of the entire rail — and a rebuild that runs
899
+ * inside a `blur` handler destroys the element the user is in the middle
900
+ * of pressing. Measured before this: open the field, then press any rail
901
+ * button; the mousedown blurred the field, the rail was rebuilt, the
902
+ * mouseup landed on a fresh element so no `click` ever fired, the press
903
+ * did nothing at all, and the keyboard ended on `document.body` inside a
904
+ * modal with no visible cursor.
905
+ */
906
+ function renameGroup(span) {
907
+ const rows = deps.laneCol.querySelector('.ed-wave-lane-rows');
908
+ const tag = rows === null ? null
909
+ : rows.querySelector('.ed-wave-group[data-group-from="' + span.from + '"]');
910
+ if (tag === null) return;
911
+
912
+ const input = d.createElement('input');
913
+ input.type = 'text';
914
+ input.className = 'ed-wave-group-input';
915
+ input.setAttribute('data-focus-key', 'group-input-' + span.from);
916
+ input.value = span.title;
917
+ input.style.top = tag.style.top;
918
+ input.style.left = tag.style.left;
919
+
920
+ /** The field off, the tag back — and nothing else in the rail touched.
921
+ * A no-op for both once a repaint has taken them away. */
922
+ const takeDown = function () {
923
+ if (input.parentNode !== null) input.parentNode.removeChild(input);
924
+ if (d.contains(tag)) tag.hidden = false;
925
+ };
926
+ /** The tag this field was covering, or — when a repaint has replaced it
927
+ * and moved it (a group's `from` is its first lane's row, so an insert
928
+ * above it renumbers the key) — whatever now carries that key, and
929
+ * failing that the dialog itself. Never nothing: nothing is
930
+ * `document.body`. */
931
+ const focusTag = function () {
932
+ const live = d.contains(tag) ? tag
933
+ : overlay.querySelector('[data-focus-key="group-' + span.from + '"]');
934
+ if (live !== null) live.focus();
935
+ else panel.focus();
936
+ };
937
+ /** Whether the keyboard is IN the field right now. False on the `blur`
938
+ * path — the browser has already moved it by then — which is how the
939
+ * two are told apart without asking which device did anything. */
940
+ const holdsKeyboard = function () { return d.activeElement === input; };
941
+
942
+ const finish = function () {
943
+ // Once. `change` and `blur` both fire for a field committed with Enter,
944
+ // and every teardown below is a blur too. Whoever gets here first owns
945
+ // the gesture; the latch is also how the other two say "not you".
946
+ if (actions.groupRenameGet() === null) return;
947
+ actions.groupRenameSet(null);
948
+ const hadKeyboard = holdsKeyboard();
949
+ const value = input.value;
950
+ takeDown();
951
+ if (value === span.title) {
952
+ // Nothing to write, so nothing to repaint — the tag is already back.
953
+ // The keyboard is only moved when it was in the field: on the mouse
954
+ // path the browser is already carrying it to the control that was
955
+ // pressed, and that control is still there to receive it now that
956
+ // this path no longer rebuilds the rail.
957
+ if (hadKeyboard) focusTag();
958
+ return;
959
+ }
960
+ // A real commit, so a real repaint — and the keyboard has to be named
961
+ // if it would otherwise be lost. `focusOverride` is left alone when a
962
+ // caller has already made a more specific promise.
963
+ if (actions.focusOverrideGet() === null &&
964
+ (hadKeyboard || !overlay.contains(d.activeElement))) {
965
+ actions.focusOverrideSet('group-' + span.from);
966
+ }
967
+ actions.commit('rename-group', function (doc2) {
968
+ return replaceAt(doc2, span.path.concat([0]), value);
969
+ });
970
+ };
971
+ // The rail is about to be rebuilt underneath this field by somebody
972
+ // else's repaint — an undo, a redo, any gesture that reaches `render()`
973
+ // while it is open. Both nodes go with that rebuild, so there is nothing
974
+ // to take down; what matters is dropping the latch and naming where the
975
+ // keyboard lands, because `group-input-<n>` will not exist afterwards and
976
+ // focus would be left on a node that is no longer in the document — which
977
+ // is the wedge that lost every subsequent keystroke.
978
+ const retire = function () {
979
+ if (actions.groupRenameGet() === null) return;
980
+ actions.groupRenameSet(null);
981
+ if (actions.focusOverrideGet() === null && holdsKeyboard()) {
982
+ actions.focusOverrideSet('group-' + span.from);
983
+ }
984
+ };
985
+ // Escape's half of the layering: the field goes, the name does not
986
+ // change, the session stands, and the keyboard goes back to the tag the
987
+ // field was covering — never to `document.body`, which is the state the
988
+ // whole of this task exists to make unreachable. No repaint at all: the
989
+ // tag was never destroyed.
990
+ const cancel = function () {
991
+ if (actions.groupRenameGet() === null) return;
992
+ actions.groupRenameSet(null);
993
+ takeDown();
994
+ focusTag();
995
+ };
996
+
997
+ input.addEventListener('change', finish);
998
+ input.addEventListener('blur', finish);
999
+ tag.hidden = true;
1000
+ rows.insertBefore(input, tag);
1001
+ // Three facts, and only the three that are read: that a rename is
1002
+ // standing (this being non-null), how to take it down on purpose, and how
1003
+ // to let go of it when a repaint takes it down for us. A copy of the span
1004
+ // or of the field itself would be a second record of something the
1005
+ // closure above already holds.
1006
+ actions.groupRenameSet({ cancel: cancel, retire: retire });
1007
+ input.focus();
1008
+ input.select();
1009
+ }
1010
+
1011
+ /**
1012
+ * A copy of `doc` with one scalar replaced, for the two fields the codec's
1013
+ * lane operations do not cover (a group's own title).
1014
+ *
1015
+ * Structure-sharing everywhere it does not have to copy, so the store's
1016
+ * lane-identity bookkeeping still sees every untouched lane as the same
1017
+ * object — `remapOrigins` answers a content edit by position and a
1018
+ * structural one by identity, and a wholesale clone here would make an
1019
+ * ordinary rename look like a structural change.
1020
+ */
1021
+ function replaceAt(node, path, value) {
1022
+ if (path.length === 0) return value;
1023
+ const seg = path[0];
1024
+ const child = replaceAt(node[seg], path.slice(1), value);
1025
+ if (Array.isArray(node)) {
1026
+ const out = node.slice();
1027
+ out[seg] = child;
1028
+ return out;
1029
+ }
1030
+ const out = Object.assign({}, node);
1031
+ out[seg] = child;
1032
+ return out;
1033
+ }
1034
+
1035
+ function renderPreview(doc) {
1036
+ const engine = wavedrom;
1037
+ if (engine === null || engine === undefined ||
1038
+ typeof engine.RenderWaveForm !== 'function') {
1039
+ deps.previewHost.textContent = '(這一頁沒有載入 WaveDrom,預覽從缺)';
1040
+ deps.previewWrap.setAttribute('data-wave-preview', 'absent');
1041
+ return;
1042
+ }
1043
+ try {
1044
+ // `notFirstSignal` means "the skin is already on this page, do not
1045
+ // emit it again". True whenever a WaveDrom render has already run here,
1046
+ // which is always in the flow that reaches this editor — the entry
1047
+ // affordance only appears over a RENDERED diagram. The fallback to
1048
+ // `false` is for the page where it somehow has not: emitting the skin
1049
+ // is only wasteful, while omitting one that is not there leaves every
1050
+ // `<use>` unresolvable and the preview blank, which is the one state
1051
+ // this panel may never be in.
1052
+ // `socket` is a bare, global, un-namespaced id that the engine's own
1053
+ // skin `<defs>` defines — so ask for it INSIDE a WaveDrom-rendered SVG's
1054
+ // defs rather than anywhere on the page. A document carrying inline
1055
+ // HTML with `id="socket"` would otherwise convince this that the skin is
1056
+ // present on a page where no diagram had rendered, and the preview would
1057
+ // come up blank: the one state this panel may never be in.
1058
+ const skinOnPage = d.querySelector('svg.WaveDrom defs #socket') !== null;
1059
+ engine.RenderWaveForm(PREVIEW_INDEX, plainCopy(doc, 0),
1060
+ PREVIEW_ID_PREFIX, skinOnPage);
1061
+ deps.previewWrap.setAttribute('data-wave-preview', 'ok');
1062
+ deps.previewWrap.setAttribute('data-wave-preview-skin', skinOnPage ? 'shared' : 'own');
1063
+ } catch (e) {
1064
+ deps.previewHost.textContent = 'WaveDrom 畫不出這份文件:' + String(e && e.message);
1065
+ deps.previewWrap.setAttribute('data-wave-preview', 'failed');
1066
+ }
1067
+ }
1068
+
1069
+ return {
1070
+ toolGroup: toolGroup,
1071
+ toolButton: toolButton,
1072
+ metaField: metaField,
1073
+ commitBanner: commitBanner,
1074
+ commitConfigField: commitConfigField,
1075
+ commitLaneField: commitLaneField,
1076
+ renderMeta: renderMeta,
1077
+ renderLaneField: renderLaneField,
1078
+ renderSource: renderSource,
1079
+ renderLanes: renderLanes,
1080
+ // v3.6.0 Task 14: the 訊號 section's 新增/空白列/複製 live in
1081
+ // `wave-ui.js` now, and each of them has to say which container its
1082
+ // insert lands in — before the press (the button's own `data-lands-in`)
1083
+ // and after it (the status line). One answer, from the bridge.
1084
+ landingOf: landingOf,
1085
+ renderEdgeBar: renderEdgeBar,
1086
+ renderPreview: renderPreview,
1087
+ renderUnmodelled: renderUnmodelled,
1088
+ renameGroup: renameGroup,
1089
+ say: say,
1090
+ // v3.6.0 Task 13: the right-hand column and its five section bodies —
1091
+ // see the block above `metaField` for why these are built eagerly
1092
+ // rather than published lazily onto `deps` the way `metaBar`/`edgeBar`/
1093
+ // etc. used to be. `wave-ui.js` appends `side` once into `.ed-wave-body`
1094
+ // and targets `sections.<key>` for every field/notice/preview that used
1095
+ // to go straight into the toolbar or the body row.
1096
+ side: side,
1097
+ sections: sections,
1098
+ expandSide: expandSide,
1099
+ };
1100
+ }
1101
+
1102
+ module.exports = { createPanels: createPanels };