@helping-ai-workflow/md2doc 3.12.0 → 3.13.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.
@@ -1,331 +1,811 @@
1
1
  'use strict';
2
2
 
3
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.
4
+ * wave-panels — the pieces of the waveform editor's shell that are built from
5
+ * plain DOM: toolbar buttons, the small popovers the toolbar opens (menus and
6
+ * the grouped brush grid), and the notice card under the toolbar that says
7
+ * which parts of the document the editor cannot place.
7
8
  *
8
9
  * 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`.
10
+ * / `wave-draw.js` next to it: `wave-ui.js` is injected into the edit page as
11
+ * a bare `<script>` with no `require()` of its own, so it takes this module in
12
+ * as `opts.panels` (see `lib/editor/server.js`'s shim and
13
+ * `lib/editor/client.js`'s `openWaveEditor`), the same way it takes
14
+ * `opts.draw`.
16
15
  *
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`.
16
+ * Wave redesign Task 6b: the right-hand column (its five collapsible panels,
17
+ * the lane list beside the canvas and the preview) is gone. Spec section 4.9
18
+ * gives each of its parts a new home: a lane's period / phase and the group
19
+ * rename come back in Task 8's name column (`laneMenu`, `renameField`,
20
+ * `periodPhasePopover`), the edge inspector as the floating toolbar beside
21
+ * an edge's label in Task 9 (`edgeToolbar`, `noteToolbar`, `inlineInput`),
22
+ * and the settings fields as Task 10's ⚙ popover (`settingsPopover`).
23
+ * Task 11 adds the shortcut card (`shortcutCard`, spec section 4.8).
21
24
  *
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.
25
+ * `deps`:
26
+ * d, geometry, codec the document and the two modules, handed in
27
+ * overlay the dialog root (the notice publishes its counts there)
28
+ * actions.commit(name, fn) run one gesture (constraint 5 in wave-ui.js)
95
29
  */
96
30
 
31
+ /**
32
+ * The edge toolbar's two button groups (spec section 4.5): 線型 straight /
33
+ * curve / elbow by 箭頭 none / end / both, nine shapes. `SHAPE_ROWS` also
34
+ * carries the half-curve and elbow variants 更多 offers, each with the
35
+ * variant it becomes under each head (null: the variant has no such form),
36
+ * so changing the head of `-|-` gives `-|->`, not `-|>`.
37
+ */
38
+ const NINE = {
39
+ straight: { none: '-', end: '->', both: '<->' },
40
+ curve: { none: '~', end: '~>', both: '<~>' },
41
+ elbow: { none: '-|', end: '-|>', both: '<-|>' },
42
+ };
43
+ const SHAPE_ROWS = [
44
+ ['straight', ['-', '->', '<->']],
45
+ ['curve', ['~', '~>', '<~>']],
46
+ ['elbow', ['-|', '-|>', '<-|>']],
47
+ ['curve', ['-~', '-~>', '<-~>']],
48
+ ['curve', ['~-', '~->', null]],
49
+ ['elbow', ['-|-', '-|->', '<-|->']],
50
+ ['elbow', ['|-', '|->', null]],
51
+ ];
52
+ const HEADS = ['none', 'end', 'both'];
53
+
54
+ /** `shape` -> `{line, head}`; both null for `+` (a tee at each end, no line
55
+ * family and no arrow) and for anything unknown. */
56
+ function shapeParts(shape) {
57
+ for (const row of SHAPE_ROWS) {
58
+ const at = row[1].indexOf(shape);
59
+ if (at !== -1) return { line: row[0], head: HEADS[at] };
60
+ }
61
+ return { line: null, head: null };
62
+ }
63
+ /** The shape `shape` becomes when one of its parts changes: `{line}` picks
64
+ * from the nine and keeps the head; `{head}` keeps a 更多 variant when it
65
+ * has that head, else falls back to the nine. A part that `shape` does not
66
+ * have (`+`) reads as straight, no arrow. */
67
+ function shapeWith(shape, change) {
68
+ const parts = shapeParts(shape);
69
+ const line = parts.line === null ? 'straight' : parts.line;
70
+ const head = parts.head === null ? 'none' : parts.head;
71
+ if (change.line !== undefined) return NINE[change.line][head];
72
+ for (const row of SHAPE_ROWS) {
73
+ if (row[1].indexOf(shape) === -1) continue;
74
+ const v = row[1][HEADS.indexOf(change.head)];
75
+ if (v !== null && v !== undefined) return v;
76
+ }
77
+ return NINE[line][change.head];
78
+ }
79
+ const NINE_SHAPES = [].concat.apply([], Object.keys(NINE).map(function (k) {
80
+ return HEADS.map(function (h) { return NINE[k][h]; });
81
+ }));
82
+
97
83
  function createPanels(deps) {
98
84
  const d = deps.d;
99
85
  const geometry = deps.geometry;
100
86
  const codec = deps.codec;
101
- const SIZES = deps.SIZES;
102
87
  const actions = deps.actions;
103
88
  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;
89
+ /** 更多's list: every shape the engine draws that is not one of the nine. */
90
+ const MORE_SHAPES = codec.EDGE_SHAPES.filter(function (s) { return NINE_SHAPES.indexOf(s) === -1; });
91
+
92
+ /** One toolbar button. `parent` (last arg, required) is where it goes. */
93
+ function toolButton(cls, label, hint, fn, parent) {
94
+ const b = d.createElement('button');
95
+ b.type = 'button';
96
+ b.className = cls;
97
+ b.setAttribute('data-focus-key', cls);
98
+ b.textContent = label;
99
+ b.title = hint;
100
+ b.addEventListener('click', fn);
101
+ parent.appendChild(b);
102
+ return b;
103
+ }
104
+
105
+ /**
106
+ * A small popover a toolbar button opens: `div.ed-wave-pop` plus the given
107
+ * class. `wave-ui.js` places it under its button and owns opening and
108
+ * closing (only one is open at a time).
109
+ */
110
+ function popover(cls) {
111
+ const el = d.createElement('div');
112
+ el.className = 'ed-wave-pop ' + cls;
113
+ return el;
114
+ }
108
115
 
109
116
  /**
110
- * A plain, unfrozen copy of a document.
117
+ * A menu popover. `items` are `{ key, label, title, run, shortcut?, danger? }`;
118
+ * `key` becomes both the item's class and its `data-focus-key`, like
119
+ * `toolButton`. `shortcut` is the key the same action has elsewhere, shown
120
+ * on the item's right; `danger` marks an item that removes something (red).
121
+ */
122
+ function menu(cls, items) {
123
+ const el = popover('ed-wave-menu ' + cls);
124
+ el.setAttribute('role', 'menu');
125
+ for (const item of items) {
126
+ const b = d.createElement('button');
127
+ b.type = 'button';
128
+ b.className = 'ed-wave-menu-item ' + item.key + (item.danger ? ' is-danger' : '');
129
+ b.setAttribute('data-focus-key', item.key);
130
+ b.setAttribute('role', 'menuitem');
131
+ const label = d.createElement('span');
132
+ label.className = 'ed-wave-menu-label';
133
+ label.textContent = item.label;
134
+ b.appendChild(label);
135
+ if (item.shortcut) {
136
+ const k = d.createElement('span');
137
+ k.className = 'ed-wave-menu-key';
138
+ k.textContent = item.shortcut;
139
+ b.appendChild(k);
140
+ }
141
+ if (item.title) b.title = item.title;
142
+ b.addEventListener('click', item.run);
143
+ el.appendChild(b);
144
+ }
145
+ return el;
146
+ }
147
+
148
+ /**
149
+ * The ⠿ menu of one row in the name column (spec section 4.4). A signal's
150
+ * row: 改名 F2, 建立副本 Ctrl+D, 在下方新增訊號, 在下方新增空白列,
151
+ * 上移 Alt+↑, 下移 Alt+↓, 和下一條組成群組, 週期與相位…, 刪除 Del (red).
152
+ * A group's row (`isGroup`): 改名, 解散群組, 上移, 下移.
111
153
  *
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.
154
+ * `actions` holds what each item does, keyed by the item's suffix
155
+ * (`rename`, `copy`, `addBelow`, `blankBelow`, `up`, `down`, `group`,
156
+ * `period`, `remove`; for a group `rename`, `ungroup`, `up`, `down`). An
157
+ * action that is missing leaves its item out: a blank row (`{}`) has no
158
+ * name, so wave-ui hands it no `rename`. `anchor` is the grip that opens
159
+ * the menu; wave-ui places the menu under it, as it does every toolbar
160
+ * popover.
118
161
  */
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;
162
+ function laneMenu(anchor, opts) {
163
+ const a = opts.actions;
164
+ const spec = opts.isGroup ? [
165
+ ['rename', 'ed-wave-lane-rename', '改名', '', '改這個群組的名稱'],
166
+ ['ungroup', 'ed-wave-lane-ungroup', '解散群組', '', '拿掉這個群組,裡面的訊號留在原位'],
167
+ ['up', 'ed-wave-lane-up', '上移', '', '整個群組往上搬一格'],
168
+ ['down', 'ed-wave-lane-down', '下移', '', '整個群組往下搬一格'],
169
+ ] : [
170
+ ['rename', 'ed-wave-lane-rename', '改名', 'F2', '在名稱上直接改名'],
171
+ ['copy', 'ed-wave-lane-copy', '建立副本', 'Ctrl+D', '在下面放一條一樣的訊號'],
172
+ ['addBelow', 'ed-wave-lane-add-below', '在下方新增訊號', '', '在這一條下面加一條新訊號'],
173
+ ['blankBelow', 'ed-wave-lane-blank-below', '在下方新增空白列', '', '在這一條下面插入一個空白列(WaveJSON 的 {})'],
174
+ ['up', 'ed-wave-lane-up', '上移', 'Alt+↑', '往上搬一格'],
175
+ ['down', 'ed-wave-lane-down', '下移', 'Alt+↓', '往下搬一格'],
176
+ ['group', 'ed-wave-lane-group', '和下一條組成群組', '', '把這一條和下一條包成一個群組,接著替它取名'],
177
+ ['period', 'ed-wave-lane-period', '週期與相位…', '', '這一條的 period(每個字元佔幾拍)與 phase(往左移幾拍)'],
178
+ ['remove', 'ed-wave-lane-delete', '刪除', 'Del', '刪掉這一條(Ctrl+Z 可以拿回來)'],
179
+ ];
180
+ const items = [];
181
+ for (const s of spec) {
182
+ if (typeof a[s[0]] !== 'function') continue;
183
+ items.push({ key: s[1], label: s[2], shortcut: s[3], title: s[4], run: a[s[0]], danger: s[0] === 'remove' });
125
184
  }
126
- const out = {};
127
- for (const key of Object.keys(value)) out[key] = plainCopy(value[key], depth + 1);
128
- return out;
185
+ const el = menu('ed-wave-lane-menu', items);
186
+ if (anchor && typeof anchor.getAttribute === 'function') {
187
+ el.setAttribute('data-for', anchor.getAttribute('data-focus-key') || '');
188
+ }
189
+ return el;
129
190
  }
130
191
 
131
192
  /**
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.
193
+ * The in-place name field (spec section 4.4): a signal's name or a group's
194
+ * title, typed where it is drawn. wave-ui places it and decides what a
195
+ * commit writes; this owns the keys.
134
196
  *
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.
197
+ * `opts`: `value`, `focusKey`, `onCommit(value)`, `onCancel()`.
198
+ * Enter commits and Esc cancels; `change` and `blur` commit too — but
199
+ * never while an input method is composing (spec section 7.2): the Enter
200
+ * that picks a candidate, the Esc that drops a composition and the Tab
201
+ * some IMEs page with belong to the IME. wave-ui's dialog-level key
202
+ * handler already stands aside for those keys; the field's own handlers
203
+ * have to as well, and a `change` fired as a composition is committed is
204
+ * not a commit either.
142
205
  */
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;
206
+ function renameField(opts) {
207
+ const input = d.createElement('input');
208
+ input.type = 'text';
209
+ input.className = 'ed-wave-rename';
210
+ input.setAttribute('data-focus-key', opts.focusKey);
211
+ input.setAttribute('aria-label', '名稱');
212
+ input.value = opts.value;
213
+ let composing = false;
214
+ input.addEventListener('compositionstart', function () { composing = true; });
215
+ input.addEventListener('compositionend', function () { composing = false; });
216
+ input.addEventListener('keydown', function (ev) {
217
+ if (composing || ev.isComposing === true || ev.keyCode === 229) return;
218
+ if (ev.key === 'Enter') {
219
+ ev.preventDefault();
220
+ ev.stopPropagation();
221
+ opts.onCommit(input.value);
222
+ } else if (ev.key === 'Escape') {
223
+ ev.preventDefault();
224
+ ev.stopPropagation();
225
+ opts.onCancel();
226
+ }
227
+ });
228
+ input.addEventListener('change', function () { if (!composing) opts.onCommit(input.value); });
229
+ input.addEventListener('blur', function () { opts.onCommit(input.value); });
230
+ return input;
151
231
  }
152
232
 
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;
233
+ /**
234
+ * 週期與相位… (spec section 4.4): one lane's `period` and `phase`, the two
235
+ * fields the side rail's Lane section used to hold. Each is written when
236
+ * it is committed (Enter, or leaving it) through `commitLaneField`, so a
237
+ * numeral goes in as a number and a blank field removes the key; the
238
+ * panel stays open for the other field. Composition is respected the same
239
+ * way as in the name field.
240
+ *
241
+ * `el.flush()` writes every field whose text is not written yet — wave-ui
242
+ * calls it when Esc or a press outside closes the panel (Ruling R22, which
243
+ * the final review extended from ⚙ to every popover with text fields). A
244
+ * focused field taken out of the page fires `change` in Chromium and not
245
+ * in WebKit, so without it the typed text survived the close in one engine
246
+ * only. A field mid-composition is skipped, exactly as its Enter is.
247
+ *
248
+ * `lane` is the lane object (read for the current values), `at` its
249
+ * display position.
250
+ */
251
+ function periodPhasePopover(lane, at) {
252
+ const el = popover('ed-wave-period-pop');
253
+ el.setAttribute('role', 'group');
254
+ el.setAttribute('aria-label', '週期與相位');
255
+ const laneObj = lane !== null && typeof lane === 'object' ? lane : {};
256
+ const commits = [];
257
+ for (const key of ['period', 'phase']) {
258
+ const row = d.createElement('label');
259
+ row.className = 'ed-wave-period-row';
260
+ const cap = d.createElement('span');
261
+ cap.className = 'ed-wave-period-cap';
262
+ cap.textContent = key === 'period' ? '週期 period' : '相位 phase';
263
+ row.appendChild(cap);
264
+ const input = d.createElement('input');
265
+ input.type = 'text';
266
+ input.className = 'ed-wave-period-input';
267
+ input.setAttribute('data-focus-key', 'ed-wave-lane-' + key + '-input');
268
+ input.setAttribute('inputmode', 'decimal');
269
+ const now = laneObj[key];
270
+ input.value = now === undefined || now === null ? '' : String(now);
271
+ input.title = key === 'period'
272
+ ? '每個波形字元佔幾拍(空白=1)' : '整條往左移幾拍,可以是小數(空白=0)';
273
+ let composing = false;
274
+ let written = input.value;
275
+ const commitNow = function () {
276
+ if (composing || input.value === written) return;
277
+ written = input.value;
278
+ commitLaneField(at, key, input.value);
279
+ };
280
+ input.addEventListener('compositionstart', function () { composing = true; });
281
+ input.addEventListener('compositionend', function () { composing = false; });
282
+ input.addEventListener('keydown', function (ev) {
283
+ if (composing || ev.isComposing === true || ev.keyCode === 229) return;
284
+ if (ev.key !== 'Enter') return;
285
+ ev.preventDefault();
286
+ ev.stopPropagation();
287
+ commitNow();
288
+ });
289
+ input.addEventListener('change', commitNow);
290
+ commits.push(commitNow);
291
+ row.appendChild(input);
292
+ el.appendChild(row);
293
+ }
294
+ el.flush = function () { for (const c of commits) c(); };
295
+ return el;
166
296
  }
167
297
 
168
298
  /**
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.
299
+ * ⚙ 整張圖 (spec section 4.6): the document-wide settings the side rail's
300
+ * 文件 section used to hold. Top level: 標題 (`head.text`, a multi-line
301
+ * box), 每拍寬度 (`config.hscale` as 1× 2× 3× 4× — the engine rounds it to
302
+ * an integer, so only integers are offered), and, when a data label is
303
+ * wider than its segment, a card saying how many with 「改成每拍 K×」. Under
304
+ * 進階 (folded when the popover opens): `head.tick` / `tock` / `every`,
305
+ * `foot.text`, `foot.tick` / `tock` / `every`.
306
+ *
307
+ * `opts`:
308
+ * doc the document now (read for the current values)
309
+ * overflow `[{needScale}, ...]` — the labels wider than their segment
310
+ * scale the smallest scale that fits them all (K), when there are any
311
+ * actions `setHeadText(text)`, `setScale(k, fromCard)`, `setBanner(which, key, value)`
172
312
  *
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.
313
+ * A text field is written when it is committed: Enter (Shift+Enter is a new
314
+ * line in 標題), leaving it, or `flush()` — which wave-ui calls when the
315
+ * popover closes, because a field taken out of the page never fires
316
+ * `change` and 「改了立即生效」 must not lose what was typed. A field whose
317
+ * text has not changed since it was last written writes nothing. While an
318
+ * input method is composing, Enter belongs to it (spec section 7.2).
182
319
  *
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.
320
+ * Clearing a field writes `''`, which `codec.setBannerField` turns into a
321
+ * DELETED key. A numeral in tick / tock / every is written as a number:
322
+ * the pinned engine adds the hbounds offset to them with `+`
323
+ * (parse-config.js `source.head.tick + lane.xmin_cfg/2`), so a STRING
324
+ * `"5"` becomes `"50"` and the ruler starts at 50.
325
+ *
326
+ * Returns the popover element. `el.refresh({doc, overflow, scale})`
327
+ * brings it in line with a repaint in place — a field holding unwritten
328
+ * text keeps it — and `el.flush()` writes every field with unwritten text.
188
329
  */
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 };
330
+ function settingsPopover(anchor, opts) {
331
+ const actions = opts.actions;
332
+ const el = popover('ed-wave-settings-pop');
333
+ el.setAttribute('role', 'group');
334
+ el.setAttribute('aria-label', '整張圖');
335
+ if (anchor && typeof anchor.getAttribute === 'function') {
336
+ el.setAttribute('data-for', anchor.getAttribute('data-focus-key') || '');
337
+ }
338
+ const fields = [];
339
+
340
+ function caption(parent, text) {
341
+ const c = d.createElement('div');
342
+ c.className = 'ed-wave-set-cap';
343
+ c.textContent = text;
344
+ parent.appendChild(c);
345
+ return c;
346
+ }
347
+ /** One text field bound to `read(doc)` and written by `write(text)`. */
348
+ function textField(parent, key, label, read, write, multi, spoken) {
349
+ const row = d.createElement('label');
350
+ row.className = 'ed-wave-set-row';
351
+ const cap = d.createElement('span');
352
+ cap.className = 'ed-wave-set-label';
353
+ cap.textContent = label;
354
+ row.appendChild(cap);
355
+ const input = d.createElement(multi ? 'textarea' : 'input');
356
+ if (multi) input.rows = 2;
357
+ else input.type = 'text';
358
+ input.className = 'ed-wave-set-input';
359
+ input.setAttribute('data-focus-key', key);
360
+ input.setAttribute('aria-label', spoken || label);
361
+ row.appendChild(input);
362
+ parent.appendChild(row);
363
+ const f = { input: input, read: read, written: '' };
364
+ f.commit = function () {
365
+ if (input.value === f.written) return;
366
+ f.written = input.value;
367
+ write(input.value);
368
+ };
369
+ let composing = false;
370
+ input.addEventListener('compositionstart', function () { composing = true; });
371
+ input.addEventListener('compositionend', function () { composing = false; });
372
+ input.addEventListener('keydown', function (ev) {
373
+ if (composing || ev.isComposing === true || ev.keyCode === 229) return;
374
+ if (ev.key !== 'Enter' || ev.shiftKey) return;
375
+ ev.preventDefault();
376
+ ev.stopPropagation();
377
+ f.commit();
378
+ });
379
+ input.addEventListener('change', function () { if (!composing) f.commit(); });
380
+ fields.push(f);
381
+ return f;
382
+ }
383
+ function bannerValue(key, text) {
384
+ return key !== 'text' && /^-?\d+(\.\d+)?$/.test(text.trim()) ? Number(text) : text;
385
+ }
386
+ function bannerField(parent, which, key, label) {
387
+ // Spoken with its band: two fields on screen are both captioned 標題,
388
+ // and three pairs tick / tock / every.
389
+ const spoken = (which === 'head' ? '上方' : '下方') + (key === 'text' ? '標題' : '刻度 ' + key);
390
+ textField(parent, 'ed-wave-set-' + which + '-' + key, label,
391
+ function (doc) {
392
+ const b = doc[which];
393
+ return b !== null && typeof b === 'object' ? tickTockText(b[key]) : '';
394
+ },
395
+ function (text) { actions.setBanner(which, key, bannerValue(key, text)); }, false, spoken);
396
+ }
397
+
398
+ textField(el, 'ed-wave-set-head', '標題',
399
+ function (doc) { return doc.head && typeof doc.head === 'object' ? tickTockText(doc.head.text) : ''; },
400
+ function (text) { actions.setHeadText(text); }, true);
401
+
402
+ caption(el, '每拍寬度');
403
+ const scales = d.createElement('div');
404
+ scales.className = 'ed-wave-set-scales';
405
+ scales.setAttribute('role', 'group');
406
+ scales.setAttribute('aria-label', '每拍寬度');
407
+ const scaleBtns = [];
408
+ for (let k = 1; k <= 4; k++) {
409
+ const b = toolButton('ed-wave-set-scale', k + '×', '每拍寬度 ' + k + ' 倍(config.hscale)', function () {
410
+ actions.setScale(k);
411
+ }, scales);
412
+ b.setAttribute('data-focus-key', 'ed-wave-set-scale-' + k);
413
+ b.setAttribute('aria-pressed', 'false');
414
+ scaleBtns.push(b);
415
+ }
416
+ el.appendChild(scales);
417
+
418
+ // The overflow card (spec section 4.6, and section 6 for K > 4).
419
+ const card = d.createElement('div');
420
+ card.className = 'ed-wave-set-overflow';
421
+ card.hidden = true;
422
+ const cardText = d.createElement('span');
423
+ cardText.className = 'ed-wave-set-overflow-text';
424
+ card.appendChild(cardText);
425
+ const fix = toolButton('ed-wave-set-fix', '', '', function () {
426
+ const k = Number(fix.getAttribute('data-scale'));
427
+ // `true`: from the card, whose button goes away with what it fixes.
428
+ if (k > 0) actions.setScale(k, true);
429
+ }, card);
430
+ el.appendChild(card);
431
+
432
+ const advBtn = toolButton('ed-wave-set-advanced', '進階', '上方刻度、下方標題與下方刻度', function () {
433
+ const open = advBtn.getAttribute('aria-expanded') !== 'true';
434
+ advBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
435
+ adv.hidden = !open;
436
+ }, el);
437
+ advBtn.setAttribute('aria-expanded', 'false');
438
+ const adv = d.createElement('div');
439
+ adv.className = 'ed-wave-set-adv';
440
+ adv.hidden = true;
441
+ caption(adv, '上方刻度');
442
+ bannerField(adv, 'head', 'tick', 'tick');
443
+ bannerField(adv, 'head', 'tock', 'tock');
444
+ bannerField(adv, 'head', 'every', 'every');
445
+ caption(adv, '下方');
446
+ bannerField(adv, 'foot', 'text', '標題');
447
+ bannerField(adv, 'foot', 'tick', 'tick');
448
+ bannerField(adv, 'foot', 'tock', 'tock');
449
+ bannerField(adv, 'foot', 'every', 'every');
450
+ el.appendChild(adv);
451
+
452
+ function refresh(state) {
453
+ const doc = state.doc;
454
+ for (const f of fields) {
455
+ const now = f.read(doc);
456
+ // Text typed and not yet written stays; everything else follows the
457
+ // document (an undo, the other fields' writes).
458
+ if (f.input.value !== f.written) { f.written = now; continue; }
459
+ f.written = now;
460
+ if (f.input.value !== now) f.input.value = now;
461
+ }
462
+ const cfg = doc.config !== null && typeof doc.config === 'object' ? doc.config : {};
463
+ const hscale = typeof cfg.hscale === 'number' ? cfg.hscale : (cfg.hscale === undefined ? 1 : null);
464
+ scaleBtns.forEach(function (b, i) { b.setAttribute('aria-pressed', hscale === i + 1 ? 'true' : 'false'); });
465
+ const list = state.overflow || [];
466
+ const k = state.scale;
467
+ card.hidden = list.length === 0;
468
+ if (list.length === 0) {
469
+ fix.removeAttribute('data-scale');
470
+ return;
471
+ }
472
+ cardText.textContent = list.length + ' 個資料標籤比它那一段寬' +
473
+ (k > 4 ? ';需要每拍 ' + k + '×,圖會比內文寬並被縮小' : '');
474
+ fix.textContent = '改成每拍 ' + k + '×';
475
+ fix.title = '把每拍寬度改成 ' + k + ' 倍,讓每個標籤都放得下';
476
+ fix.setAttribute('data-scale', String(k));
477
+ }
478
+ for (const f of fields) {
479
+ f.written = f.read(opts.doc);
480
+ f.input.value = f.written;
481
+ }
482
+ refresh({ doc: opts.doc, overflow: opts.overflow, scale: opts.scale });
483
+ el.refresh = refresh;
484
+ el.flush = function () { for (const f of fields) f.commit(); };
485
+ return el;
225
486
  }
226
487
 
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';
488
+ /**
489
+ * 匯出 ⌄ (spec section 4.6): 複製為圖片, 下載 PNG, 下載 SVG, 複製 WaveJSON,
490
+ * and the line that says every export is light on white. `acts` holds
491
+ * what each does (`copyImage`, `downloadPng`, `downloadSvg`, `copyJson`);
492
+ * wave-ui closes the menu and runs it in the same click, which 複製為圖片
493
+ * needs (the clipboard item has to be made inside the press).
494
+ */
495
+ function exportMenu(anchor, acts) {
496
+ const el = menu('ed-wave-export-pop', [
497
+ { key: 'ed-wave-export-copyimg', label: '複製為圖片', title: '把這張波形的 PNG(2 倍大小)放進剪貼簿', run: acts.copyImage },
498
+ { key: 'ed-wave-export-png', label: '下載 PNG', title: '存成 PNG 檔(白底,2 倍大小)', run: acts.downloadPng },
499
+ { key: 'ed-wave-export-svg', label: '下載 SVG', title: '存成 SVG 檔(自帶 WaveDrom 的 skin,離開這一頁也畫得出來)', run: acts.downloadSvg },
500
+ { key: 'ed-wave-export-json', label: '複製 WaveJSON', title: '把這張波形的 WaveJSON 複製到剪貼簿', run: acts.copyJson },
501
+ ]);
502
+ const note = d.createElement('div');
503
+ note.className = 'ed-wave-export-note';
504
+ note.textContent = '匯出一律是淺色、白底';
505
+ el.appendChild(note);
506
+ if (anchor && typeof anchor.getAttribute === 'function') {
507
+ el.setAttribute('data-for', anchor.getAttribute('data-focus-key') || '');
508
+ }
509
+ return el;
510
+ }
245
511
 
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);
512
+ /**
513
+ * The grid 更多 opens (spec section 4.2): one titled row per group.
514
+ * `groups` is `[[title, [ch, ...]], ...]`; `makeBrush(ch)` builds the same
515
+ * button the toolbar's resident brushes use, so a brush looks and acts the
516
+ * same wherever it is picked from.
517
+ */
518
+ function brushGrid(groups, makeBrush) {
519
+ const el = popover('ed-wave-brush-grid');
520
+ for (const g of groups) {
521
+ const row = d.createElement('div');
522
+ row.className = 'ed-wave-brush-group';
523
+ const title = d.createElement('span');
524
+ title.className = 'ed-wave-brush-group-title';
525
+ title.textContent = g[0];
526
+ row.appendChild(title);
527
+ const list = d.createElement('div');
528
+ list.className = 'ed-wave-brushes';
529
+ for (const ch of g[1]) list.appendChild(makeBrush(ch));
530
+ row.appendChild(list);
531
+ el.appendChild(row);
532
+ }
533
+ return el;
534
+ }
535
+
536
+ /**
537
+ * The shortcut card (spec section 4.8, Task 11): a titled card with one
538
+ * section per group. `groups` is `[[title, [[[key, ...], what], ...]], ...]`
539
+ * (wave-ui.js `shortcutGroups`); each key is a `kbd`. `onClose` runs
540
+ * from its ✕. wave-ui.js places it in the middle of the editor and owns
541
+ * opening and closing it, like every other popover.
542
+ */
543
+ function shortcutCard(groups, onClose) {
544
+ const el = popover('ed-wave-shortcuts');
545
+ el.setAttribute('role', 'dialog');
546
+ el.setAttribute('aria-label', '全部快捷鍵');
547
+ el.setAttribute('tabindex', '-1');
548
+ const head = d.createElement('div');
549
+ head.className = 'ed-wave-shortcuts-head';
550
+ const title = d.createElement('span');
551
+ title.className = 'ed-wave-shortcuts-title';
552
+ title.textContent = '全部快捷鍵';
553
+ head.appendChild(title);
554
+ const x = toolButton('ed-wave-shortcuts-close', '✕', '關閉(Esc)', onClose, head);
555
+ x.setAttribute('aria-label', '關閉快捷鍵');
556
+ el.appendChild(head);
557
+ const body = d.createElement('div');
558
+ body.className = 'ed-wave-shortcuts-body';
559
+ for (const g of groups) {
560
+ const sec = d.createElement('section');
561
+ sec.className = 'ed-wave-shortcut-group';
562
+ const h = d.createElement('div');
563
+ h.className = 'ed-wave-shortcut-title';
564
+ h.textContent = g[0];
565
+ sec.appendChild(h);
566
+ for (const row of g[1]) {
567
+ const r = d.createElement('div');
568
+ r.className = 'ed-wave-shortcut-row';
569
+ const keys = d.createElement('span');
570
+ keys.className = 'ed-wave-shortcut-keys';
571
+ for (const k of row[0]) {
572
+ const kbd = d.createElement('kbd');
573
+ kbd.textContent = k;
574
+ keys.appendChild(kbd);
575
+ }
576
+ r.appendChild(keys);
577
+ const what = d.createElement('span');
578
+ what.className = 'ed-wave-shortcut-what';
579
+ what.textContent = row[1];
580
+ r.appendChild(what);
581
+ sec.appendChild(r);
582
+ }
583
+ body.appendChild(sec);
584
+ }
585
+ el.appendChild(body);
586
+ return el;
587
+ }
264
588
 
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;
589
+ /**
590
+ * The floating toolbar over a selected run of cycles (spec section 4.3):
591
+ * 電位 ⌄, 複製, 刪除 N 拍, 前插, 後插, 關聯線 (A), 標註 (T), in that order.
592
+ * The last two are W5's, folded in here; like the rest, what they do
593
+ * arrives from wave-ui.js in `actions`.
594
+ *
595
+ * `anchor` is `{left, top, width}`: where the toolbar goes, in its
596
+ * container's coordinates. wave-ui.js measures the toolbar once it is on
597
+ * screen and moves it (above the run, or below it when there is no room),
598
+ * so this only sets the first position.
599
+ *
600
+ * A press with the pointer does not take the keyboard off the canvas
601
+ * (`mousedown` is cancelled), so the arrows, Del and Ctrl+V keep working
602
+ * on the drawing after a button is clicked. Tab still reaches every
603
+ * button, and a button pressed from the keyboard keeps it.
604
+ */
605
+ function rangeToolbar(anchor, opts) {
606
+ const count = opts.count;
607
+ const actions = opts.actions;
608
+ const el = d.createElement('div');
609
+ el.className = 'ed-wave-range';
610
+ el.setAttribute('role', 'toolbar');
611
+ el.setAttribute('aria-label', '選取的 ' + count + ' 拍');
612
+ el.style.left = anchor.left + 'px';
613
+ el.style.top = anchor.top + 'px';
614
+ const items = [
615
+ ['ed-wave-range-level', '電位 ⌄', '把這 ' + count + ' 拍改成同一個電位', actions.level],
616
+ ['ed-wave-range-copy', '複製', '複製這 ' + count + ' 拍(在畫布上按 Ctrl+V 貼到選取的位置前面)', actions.copy],
617
+ ['ed-wave-range-delete', '刪除 ' + count + ' 拍', '刪掉這 ' + count + ' 拍(每一條訊號同時變窄;Del 也可以)', actions.remove],
618
+ ['ed-wave-range-before', '前插', '在這一段前面插入一拍', actions.insertBefore],
619
+ ['ed-wave-range-after', '後插', '在這一段後面插入一拍', actions.insertAfter],
620
+ ['ed-wave-range-edge', '關聯線 A', '從這一拍拉一條關聯線(A)', actions.edge],
621
+ ['ed-wave-range-note', '標註 T', '在這一拍加一個標註(T)', actions.note],
622
+ ];
623
+ for (const it of items) {
624
+ const run = it[3];
625
+ const b = toolButton(it[0], it[1], it[2], function () { run(b); }, el);
626
+ b.addEventListener('mousedown', function (ev) { ev.preventDefault(); });
627
+ if (it[0] === 'ed-wave-range-level') {
628
+ b.setAttribute('aria-haspopup', 'true');
629
+ b.setAttribute('aria-expanded', 'false');
630
+ }
631
+ }
632
+ return el;
276
633
  }
277
634
 
278
635
  /**
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.
636
+ * The floating toolbar beside a selected edge's label (spec section 4.5):
637
+ * 線型 (直 曲 折) and 箭頭 (無 單向 雙向) as two button groups, 更多 (the
638
+ * other eleven shapes, listed inside the toolbar when it is opened),
639
+ * 改標籤 and 刪除. `opts.shape` is the edge's shape now, which decides the
640
+ * pressed line and head (`shapeParts`); every press goes to wave-ui.js
641
+ * through `opts.actions`: `setLine(line)`, `setHead(head)`, `more(shape)`,
642
+ * `editLabel()`, `remove()`, and `resized()` (optional) after 更多 opens
643
+ * or folds, so wave-ui.js can move the taller or shorter toolbar.
644
+ *
645
+ * Like the range toolbar, a press with the pointer does not take the
646
+ * keyboard off the canvas, so Del and Enter keep meaning the selected
647
+ * edge; Tab still reaches every button.
287
648
  */
288
- function expandSide() {
289
- side.setAttribute('data-expanded', '');
649
+ function edgeToolbar(anchor, opts) {
650
+ const actions = opts.actions;
651
+ const parts = shapeParts(opts.shape);
652
+ const el = barShell(anchor, '關聯線');
653
+ const group = function (label) {
654
+ const g = d.createElement('div');
655
+ g.className = 'ed-wave-edge-group';
656
+ g.setAttribute('role', 'group');
657
+ g.setAttribute('aria-label', label);
658
+ el.appendChild(g);
659
+ return g;
660
+ };
661
+ const lines = group('線型');
662
+ for (const [line, text, title] of [['straight', '直', '直線'], ['curve', '曲', '曲線'], ['elbow', '折', '折線']]) {
663
+ const b = barButton('ed-wave-edge-line-' + line, text, title, function () { actions.setLine(line); }, lines);
664
+ b.setAttribute('aria-pressed', parts.line === line ? 'true' : 'false');
665
+ }
666
+ const heads = group('箭頭');
667
+ for (const [head, text, title] of [['none', '無', '沒有箭頭'], ['end', '單向', '終點一個箭頭'], ['both', '雙向', '兩端都有箭頭']]) {
668
+ const b = barButton('ed-wave-edge-head-' + head, text, title, function () { actions.setHead(head); }, heads);
669
+ b.setAttribute('aria-pressed', parts.head === head ? 'true' : 'false');
670
+ }
671
+ const list = d.createElement('div');
672
+ list.className = 'ed-wave-edge-more-list';
673
+ list.setAttribute('role', 'group');
674
+ list.setAttribute('aria-label', '其他形狀');
675
+ list.hidden = true;
676
+ const moreBtn = barButton('ed-wave-edge-more', '更多 ⌄', '其他 11 種形狀(半曲、中折、先直後橫、+)', function () {
677
+ list.hidden = !list.hidden;
678
+ moreBtn.setAttribute('aria-expanded', list.hidden ? 'false' : 'true');
679
+ if (typeof actions.resized === 'function') actions.resized();
680
+ }, el);
681
+ moreBtn.setAttribute('aria-expanded', 'false');
682
+ barButton('ed-wave-edge-label', '改標籤', '在線上改標籤(Enter 或雙擊標籤也可以)', function () { actions.editLabel(); }, el);
683
+ barButton('ed-wave-edge-delete', '刪除', '刪掉這條關聯線(Del 也可以)', function () { actions.remove(); }, el)
684
+ .classList.add('is-danger');
685
+ MORE_SHAPES.forEach(function (shape, i) {
686
+ const b = barButton('ed-wave-edge-shape-' + i, shape, '形狀 ' + shape, function () { actions.more(shape); }, list);
687
+ b.setAttribute('data-shape', shape);
688
+ b.setAttribute('aria-pressed', opts.shape === shape ? 'true' : 'false');
689
+ });
690
+ el.appendChild(list);
691
+ return el;
290
692
  }
291
693
 
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);
694
+ /**
695
+ * The same floating toolbar for a selected point note (spec section 4.5):
696
+ * 改文字, 拉成關聯線, 刪除 — `opts.actions.editText()`, `toEdge()`,
697
+ * `remove()`.
698
+ */
699
+ function noteToolbar(anchor, opts) {
700
+ const actions = opts.actions;
701
+ const el = barShell(anchor, '標註');
702
+ barButton('ed-wave-note-text', '改文字', '在標註上改文字(Enter 或雙擊也可以)', function () { actions.editText(); }, el);
703
+ barButton('ed-wave-note-to-edge', '拉成關聯線', '從這個標註的位置拉一條關聯線,文字變成線的標籤', function () { actions.toEdge(); }, el);
704
+ barButton('ed-wave-note-delete', '刪除', '刪掉這個標註(Del 也可以)', function () { actions.remove(); }, el)
705
+ .classList.add('is-danger');
706
+ return el;
707
+ }
708
+
709
+ function barShell(anchor, label) {
710
+ const el = d.createElement('div');
711
+ el.className = 'ed-wave-edge-bar';
712
+ el.setAttribute('role', 'toolbar');
713
+ el.setAttribute('aria-label', label);
714
+ el.style.left = anchor.left + 'px';
715
+ el.style.top = anchor.top + 'px';
716
+ return el;
717
+ }
718
+ function barButton(key, text, title, run, parent) {
719
+ const b = toolButton(key, text, title, run, parent);
720
+ b.addEventListener('mousedown', function (ev) { ev.preventDefault(); });
721
+ return b;
722
+ }
723
+
724
+ /**
725
+ * One line of text typed where it is drawn (spec section 4.5): an edge's
726
+ * label or a point note's text, `input.ed-wave-edge-input[data-kind]`.
727
+ * wave-ui.js places it and decides what a commit writes — and what
728
+ * Escape means, because for a label field opened on a brand-new edge
729
+ * Escape takes the edge back off (the dialog's own Escape layering). This
730
+ * owns Enter and the composition: Enter commits, but never while an input
731
+ * method is composing (spec section 7.2), and neither does the `change` or
732
+ * `blur` a committed composition can fire.
733
+ *
734
+ * `opts`: `kind` ('label' | 'note'), `value`, `focusKey`, `label` (the
735
+ * field's accessible name), `onCommit(value)`.
736
+ */
737
+ function inlineInput(opts) {
303
738
  const input = d.createElement('input');
304
739
  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);
740
+ input.className = 'ed-wave-edge-input';
741
+ input.setAttribute('data-kind', opts.kind);
742
+ input.setAttribute('data-focus-key', opts.focusKey);
743
+ input.setAttribute('aria-label', opts.label);
744
+ input.value = opts.value;
745
+ let composing = false;
746
+ input.addEventListener('compositionstart', function () { composing = true; });
747
+ input.addEventListener('compositionend', function () { composing = false; });
748
+ input.addEventListener('keydown', function (ev) {
749
+ if (composing || ev.isComposing === true || ev.keyCode === 229) return;
750
+ if (ev.key !== 'Enter') return;
751
+ ev.preventDefault();
752
+ ev.stopPropagation();
753
+ opts.onCommit(input.value);
754
+ });
755
+ input.addEventListener('change', function () { if (!composing) opts.onCommit(input.value); });
756
+ input.addEventListener('blur', function () { if (!composing) opts.onCommit(input.value); });
311
757
  return input;
312
758
  }
313
759
 
760
+ /**
761
+ * The notice card under the toolbar (spec section 4.9): neutral, and it can
762
+ * be folded away. `show(sentences)` fills it; no sentences hides it. The
763
+ * folded state lives on the card for the rest of the session, so a repaint
764
+ * does not unfold what the user folded.
765
+ */
766
+ function noticeCard() {
767
+ const el = d.createElement('div');
768
+ el.className = 'ed-wave-notice';
769
+ el.hidden = true;
770
+ const text = d.createElement('div');
771
+ text.className = 'ed-wave-notice-text';
772
+ el.appendChild(text);
773
+ const fold = d.createElement('button');
774
+ fold.type = 'button';
775
+ fold.className = 'ed-wave-notice-fold';
776
+ fold.setAttribute('data-focus-key', 'ed-wave-notice-fold');
777
+ fold.textContent = '收起';
778
+ fold.addEventListener('click', function () {
779
+ const folded = el.hasAttribute('data-folded');
780
+ if (folded) el.removeAttribute('data-folded');
781
+ else el.setAttribute('data-folded', '');
782
+ fold.textContent = folded ? '收起' : '展開';
783
+ fold.setAttribute('aria-expanded', folded ? 'true' : 'false');
784
+ });
785
+ fold.setAttribute('aria-expanded', 'true');
786
+ el.appendChild(fold);
787
+ function show(sentences) {
788
+ el.hidden = sentences.length === 0;
789
+ text.textContent = sentences.join(' ');
790
+ }
791
+ return { el: el, show: show };
792
+ }
793
+
314
794
  /**
315
795
  * 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:
796
+ * JS `number`, not as the string the `<input>` holds. (The banner fields
797
+ * do the same for tick / tock / every since Task 10 — see `bannerValue`
798
+ * in `settingsPopover` for the engine's `+` that turns a string `"5"`
799
+ * into `"50"`; their `.text` stays a string.) For these three:
320
800
  * `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
801
+ * a STRING `"2"` would silently read as "unset" to the editor's geometry —
802
+ * its default of 1/0 — while the engine that draws the canvas still
323
803
  * honours it (its `tonumber()` coerces a string with `>`). That is the
324
804
  * exact same-idea-two-definitions divergence this batch has already paid
325
805
  * for three times elsewhere (see `md2doc`'s CLAUDE.md). Blank clears the
326
806
  * field (the `undefined` sentinel `setConfigField`/`setLaneField` both
327
807
  * 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
808
+ * string — both the geometry's `typeof` gate and the engine's own
329
809
  * `tonumber()` already fall back to the SAME default (1 for hscale/period,
330
810
  * 0 for phase) for a non-numeric value, so there is nothing to reconcile
331
811
  * for that case, only for a well-formed one.
@@ -336,7 +816,7 @@ function createPanels(deps) {
336
816
  }
337
817
 
338
818
  function commitConfigField(key, value) {
339
- actions.commit('config.' + key, function (doc) {
819
+ return actions.commit('config.' + key, function (doc) {
340
820
  return codec.setConfigField(doc, key, parseNumericField(value));
341
821
  });
342
822
  }
@@ -347,6 +827,65 @@ function createPanels(deps) {
347
827
  });
348
828
  }
349
829
 
830
+ /**
831
+ * A copy of `node` with the value at `path` replaced, sharing every subtree
832
+ * it does not have to copy — so the store's lane-identity bookkeeping
833
+ * (`remapOrigins` matches lanes by identity across a structural edit) still
834
+ * sees each untouched lane as the same object. The codec's lane operations
835
+ * cover lanes; a group's own title and its place among its siblings are
836
+ * the two writes they do not, and both go through this.
837
+ */
838
+ function replaceAt(node, path, value) {
839
+ if (path.length === 0) return value;
840
+ const seg = path[0];
841
+ const child = replaceAt(node[seg], path.slice(1), value);
842
+ const out = Array.isArray(node) ? node.slice() : Object.assign({}, node);
843
+ out[seg] = child;
844
+ return out;
845
+ }
846
+
847
+ /** The group at `path` gets the title `value` (spec section 4.4, 改名). */
848
+ function commitGroupTitle(path, value) {
849
+ return actions.commit('rename-group', function (doc) {
850
+ const group = geometry.valueAt(doc, path);
851
+ if (!Array.isArray(group) || typeof group[0] !== 'string' || group[0] === value) return doc;
852
+ return replaceAt(doc, path.concat([0]), value);
853
+ });
854
+ }
855
+
856
+ /**
857
+ * Where the group at `path` lands when it trades places with its neighbour
858
+ * one step `dir` (-1 up, 1 down) in the array that holds it — a lane, a
859
+ * blank row or another group — or null when there is none that way. The
860
+ * title at index 0 of a titled parent group is not a neighbour.
861
+ */
862
+ function groupStepPath(doc, path, dir) {
863
+ const parentPath = path.slice(0, -1);
864
+ const parent = geometry.valueAt(doc, parentPath);
865
+ if (!Array.isArray(parent)) return null;
866
+ const first = parentPath.length > 1 && typeof parent[0] === 'string' ? 1 : 0;
867
+ const at = path[path.length - 1];
868
+ const to = at + dir;
869
+ if (to < first || to >= parent.length) return null;
870
+ return parentPath.concat([to]);
871
+ }
872
+
873
+ /** 上移 / 下移 for a whole group: swap it with that neighbour. */
874
+ function commitGroupMove(path, dir) {
875
+ return actions.commit('move-group', function (doc) {
876
+ const dest = groupStepPath(doc, path, dir);
877
+ if (dest === null) return doc;
878
+ const parentPath = path.slice(0, -1);
879
+ const next = geometry.valueAt(doc, parentPath).slice();
880
+ const a = path[path.length - 1];
881
+ const b = dest[dest.length - 1];
882
+ const held = next[a];
883
+ next[a] = next[b];
884
+ next[b] = held;
885
+ return replaceAt(doc, parentPath, next);
886
+ });
887
+ }
888
+
350
889
  // v3.5.0 Task 13: widened from `(which, value)` — always writing `.text`
351
890
  // — to `(which, key, value)` so the same function also carries the other
352
891
  // five ruler fields.
@@ -374,35 +913,7 @@ function createPanels(deps) {
374
913
  // `setBannerField` is a plain, exported, pure function — pinned in
375
914
  // `test/wave-codec.test.js`.
376
915
  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;
916
+ return actions.commit(which + '.' + key, function (doc) { return codec.setBannerField(doc, which, key, value); });
406
917
  }
407
918
 
408
919
  /**
@@ -415,9 +926,10 @@ function createPanels(deps) {
415
926
  * own `val.trim().split(/\s+/)` turns a plain string back into) rather
416
927
  * than blanked out — blanking a field that actually holds `tick: 0`
417
928
  * 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.
929
+ * exists to avoid elsewhere in this file. Editing the field commits a
930
+ * number for a plain numeral and the typed string otherwise (Task 10's
931
+ * `settingsPopover`): the engine adds the hbounds offset to tick / tock
932
+ * with `+`, so the STRING `"5"` would start the ruler at 50.
421
933
  */
422
934
  function tickTockText(v) {
423
935
  if (typeof v === 'string') return v;
@@ -426,142 +938,56 @@ function createPanels(deps) {
426
938
  return '';
427
939
  }
428
940
 
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
941
  /**
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.
942
+ * Where a lane inserted at display position `index` would actually land,
943
+ * asked of the bridge rather than guessed.
944
+ *
945
+ * This is constraint 4 made visible. `laneInsertPath` puts an insert at a
946
+ * group's FIRST lane inside that group, and an insert at the position just
947
+ * past its LAST lane outside it — a group can be joined at its head and
948
+ * never at its tail. That asymmetry is not something a label can argue
949
+ * away, so the label states it: the 訊號 menu's items say which container
950
+ * they will land in before they are pressed (`data-lands-in` and the
951
+ * title, computed when `wave-ui.js` opens the menu), and the notice says
952
+ * it again after.
485
953
  */
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);
954
+ function landingOf(doc, index) {
955
+ const path = codec.laneInsertPath(doc, index);
956
+ if (path === null) return { path: null, title: null };
957
+ const container = path.slice(0, path.length - 1);
958
+ const held = geometry.valueAt(doc, container);
959
+ const title = Array.isArray(held) && typeof held[0] === 'string' ? held[0] : null;
960
+ return { path: path, title: title };
491
961
  }
492
962
 
493
963
  /**
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.
964
+ * Say which parts of this document the editor cannot place, in the notice
965
+ * card (spec section 4.9), and what to do about each: the next step is the
966
+ * block's own MD source, reached by closing the editor.
506
967
  *
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.
968
+ * Edges are asked of `geometry.edgeLayoutDetail` — the walk that places
969
+ * them — rather than re-derived here. Skips are bucketed by whatever
970
+ * `reason` comes back, not by a list of the reasons known today, so a new
971
+ * reason shows up as its own (generically worded) bucket instead of
972
+ * escaping the notice. A point note (`<letter> <text>`) is not a skip:
973
+ * the engine draws it (spec section 5-3).
519
974
  *
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.
975
+ * `found` is where a document property the canvas does not show would be
976
+ * listed. It is empty today (period, phase and hscale are modelled) and is
977
+ * still published as `data-wave-unmodelled`, so a test can tell "nothing to
978
+ * report" from "never rendered".
546
979
  */
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".
980
+ function renderUnmodelled(doc, layout, card) {
553
981
  const found = [];
554
-
555
982
  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
983
  const buckets = new Map();
560
984
  for (const skip of detail.skipped) {
561
985
  if (!buckets.has(skip.reason)) buckets.set(skip.reason, []);
562
986
  buckets.get(skip.reason).push(skip);
563
987
  }
564
-
988
+ function which(list) {
989
+ return '第 ' + list.map(function (s) { return s.index + 1; }).join('、') + ' 條關聯線';
990
+ }
565
991
  function letters(list) {
566
992
  const out = [];
567
993
  for (const skip of list) {
@@ -571,532 +997,95 @@ function createPanels(deps) {
571
997
  }
572
998
  return out;
573
999
  }
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.
1000
+ // Only the letters no lane carries: a dangling edge's other end is real.
1001
+ const nodes = codec.nodesOf(doc);
1002
+ function missing(list) {
1003
+ return letters(list).filter(function (l) { return nodes[l] === undefined; });
1004
+ }
1005
+ const NEXT = '關閉後用區塊 ⠿ →「MD 原始碼」修改。';
1006
+ // `dangling` and `unparseable` are not drawn by the engine either;
1007
+ // `out-of-range` is drawn by the engine but the editor cannot place its
1008
+ // ends, so it cannot be selected or dragged here.
579
1009
  const REASON_TEXT = {
580
1010
  dangling: function (list) {
581
- return '這個區塊有 ' + list.length + ' 條關聯線引用了不存在的 anchor(' +
582
- letters(list).join('、') + '),左邊畫不出它們。';
1011
+ return which(list) + '用到不存在的錨點字母(' + missing(list).join('、') + '),畫不出來;' + NEXT;
583
1012
  },
584
1013
  unparseable: function (list) {
585
- return '這個區塊有 ' + list.length +
586
- ' 條關聯線的寫法連引擎也認不得,左邊畫不出它們(跟右邊的 WaveDrom 預覽一致)。';
1014
+ return which(list) + '的寫法認不得,畫不出來;' + NEXT;
587
1015
  },
588
1016
  'out-of-range': function (list) {
589
- return '這個區塊有 ' + list.length + ' 條關聯線的 anchor(' +
590
- letters(list).join('、') + ')落在那條 lane 目前的長度之外,左邊畫不出它們' +
591
- '——但右邊的 WaveDrom 預覽仍然會畫,兩邊會不一致,以預覽為準。';
1017
+ return which(list) + '的錨點(' + letters(list).join('、') +
1018
+ ')落在那條訊號目前的長度之外,在這裡選不到也拖不動;' + NEXT;
592
1019
  },
593
1020
  };
594
-
595
1021
  const sentences = [];
596
1022
  if (found.length > 0) {
597
- sentences.push('這份文件用了 ' + found.join('、') +
598
- ',左邊的手繪波形不表現它們(寬度與相位會不一樣)——以右邊的 WaveDrom 預覽為準。');
1023
+ sentences.push('這份文件用了 ' + found.join('、') + ',編輯器沒有把它們畫出來;' + NEXT);
599
1024
  }
600
1025
  for (const [reason, list] of buckets) {
601
1026
  const text = REASON_TEXT[reason];
602
- sentences.push(text ? text(list) :
603
- '這個區塊有 ' + list.length + ' 條關聯線因為「' + reason + '」畫不出來,' +
604
- '以右邊的 WaveDrom 預覽為準。');
1027
+ sentences.push(text ? text(list) : which(list) + '因為「' + reason + '」畫不出來;' + NEXT);
605
1028
  }
606
1029
 
607
1030
  const danglingCount = buckets.has('dangling') ? buckets.get('dangling').length : 0;
608
1031
  overlay.setAttribute('data-wave-unmodelled', found.join(' '));
609
1032
  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
1033
  overlay.setAttribute('data-wave-skipped-edges', String(detail.skipped.length));
613
- deps.unmodelled.hidden = sentences.length === 0;
614
- deps.unmodelled.textContent = sentences.join(' ');
615
1034
 
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.
1035
+ // This editor edits `signal:` and nothing else, so a `reg:` or `assign:`
1036
+ // block opens with no lanes. Say so rather than look broken.
624
1037
  const kinds = [];
625
1038
  for (const key of ['reg', 'assign']) {
626
1039
  if (doc[key] !== undefined) kinds.push(key + ':');
627
1040
  }
628
1041
  const noLanes = codec.lanePaths(doc).length === 0;
629
1042
  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');
1043
+ if (noLanes) {
1044
+ sentences.unshift((kinds.length === 0
1045
+ ? '這個區塊裡沒有 signal:,所以沒有可以編輯的訊號;'
1046
+ : '這個區塊用的是 ' + kinds.join(' / ') + ',這個編輯器只編輯 signal: 的訊號;') +
1047
+ '要改這個區塊,' + NEXT);
1066
1048
  }
1049
+ card.show(sentences);
1067
1050
  }
1068
1051
 
1069
1052
  return {
1070
- toolGroup: toolGroup,
1071
1053
  toolButton: toolButton,
1072
- metaField: metaField,
1054
+ popover: popover,
1055
+ menu: menu,
1056
+ // Task 8 (spec section 4.4): the name column's ⠿ menu, its in-place
1057
+ // name field and 週期與相位…, and the two group writes.
1058
+ laneMenu: laneMenu,
1059
+ renameField: renameField,
1060
+ periodPhasePopover: periodPhasePopover,
1061
+ commitGroupTitle: commitGroupTitle,
1062
+ groupStepPath: groupStepPath,
1063
+ commitGroupMove: commitGroupMove,
1064
+ brushGrid: brushGrid,
1065
+ shortcutCard: shortcutCard,
1066
+ // Task 10 (spec section 4.6): ⚙ 整張圖 and 匯出 ⌄.
1067
+ settingsPopover: settingsPopover,
1068
+ exportMenu: exportMenu,
1069
+ rangeToolbar: rangeToolbar,
1070
+ // Task 9 (spec section 4.5): the edge and note toolbars, their shape
1071
+ // arithmetic, and the inline label / note field.
1072
+ edgeToolbar: edgeToolbar,
1073
+ noteToolbar: noteToolbar,
1074
+ inlineInput: inlineInput,
1075
+ shapeParts: shapeParts,
1076
+ shapeWith: shapeWith,
1077
+ noticeCard: noticeCard,
1078
+ renderUnmodelled: renderUnmodelled,
1079
+ // Where an insert lands, asked of the bridge (constraint 4 in
1080
+ // wave-ui.js); the 訊號 menu says it before and after.
1081
+ landingOf: landingOf,
1082
+ // The settings and lane fields' write side (spec section 4.9): the
1083
+ // lane fields are Task 8's 週期與相位…, the settings Task 10's.
1073
1084
  commitBanner: commitBanner,
1074
1085
  commitConfigField: commitConfigField,
1075
1086
  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,
1087
+ tickTockText: tickTockText,
1099
1088
  };
1100
1089
  }
1101
1090
 
1102
- module.exports = { createPanels: createPanels };
1091
+ module.exports = { createPanels: createPanels, shapeParts: shapeParts, shapeWith: shapeWith, NINE: NINE };