@helping-ai-workflow/md2doc 3.3.0 → 3.4.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.
- package/README.md +33 -8
- package/THIRD-PARTY-NOTICES.md +44 -0
- package/lib/drawio.js +178 -0
- package/lib/editor/blockmap.js +79 -6
- package/lib/editor/client.js +3212 -140
- package/lib/editor/inline-md.js +122 -12
- package/lib/editor/lineops.js +190 -3
- package/lib/editor/server.js +323 -3
- package/lib/editor/toolbar-model.js +51 -14
- package/lib/editor/wave-codec.js +2631 -0
- package/lib/editor/wave-geometry.js +320 -0
- package/lib/editor/wave-store.js +950 -0
- package/lib/editor/wave-ui.js +2124 -0
- package/lib/md2doc.js +677 -14
- package/package.json +14 -6
- package/vendor/drawio/LICENSE +201 -0
- package/vendor/drawio/NOTICE +109 -0
- package/vendor/drawio/README.md +25 -0
- package/vendor/drawio/viewer-static.min.js +8596 -0
|
@@ -0,0 +1,2124 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* wave-ui — the wavedrom editing surface: the overlay, the hand-drawn waveform,
|
|
4
|
+
* the toolbar, the lane list and the live WaveDrom preview beside it.
|
|
5
|
+
*
|
|
6
|
+
* UMD, the same shape convert-md.js / patchmap.js use: require-able in node so
|
|
7
|
+
* the drawing decisions can be checked without a browser, and injected into the
|
|
8
|
+
* edit page as `window.md2docWaveUi` (lib/editor/server.js). client.js is
|
|
9
|
+
* inlined as a plain <script>, not bundled, so a bare require() here would be
|
|
10
|
+
* undefined in the browser — every dependency arrives as a parameter instead
|
|
11
|
+
* (`opts.codec`, `opts.geometry`, `opts.createStore`, `opts.wavedrom`,
|
|
12
|
+
* `opts.document`). That is also why this file names no global of its own: the
|
|
13
|
+
* layer that knows which `document` and which engine build are in play is
|
|
14
|
+
* client.js, and it is the only one that should be deciding.
|
|
15
|
+
*
|
|
16
|
+
* FIVE things this file is not allowed to get wrong, each of them a ruling from
|
|
17
|
+
* Tasks 1-5 rather than a preference:
|
|
18
|
+
*
|
|
19
|
+
* 1. THE OVERLAY MOUNTS ON `document.body`, NEVER INSIDE `.content`.
|
|
20
|
+
* `.content`'s children are the editor's serialisation surface — client.js
|
|
21
|
+
* reads them back (`lastParts`) and the WYSIWYG path turns them into
|
|
22
|
+
* markdown. A panel parked in there ends up in the user's file. `mount()`
|
|
23
|
+
* takes the host as a parameter and `mountedInsideContent()` below is the
|
|
24
|
+
* check that a caller can run against its own DOM.
|
|
25
|
+
*
|
|
26
|
+
* 2. LANE NUMBERS ARE THE CODEC'S FLATTENED DISPLAY ORDER, and the only road
|
|
27
|
+
* from one to a path is the codec's bridge. Nothing here writes
|
|
28
|
+
* `['signal', k]`. `wave-geometry.cellAt` already answers in that index
|
|
29
|
+
* space, so a hit test and the edit it triggers are speaking the same
|
|
30
|
+
* language by construction.
|
|
31
|
+
*
|
|
32
|
+
* 3. `levelsOf` IS THE ONLY SOURCE OF TRUTH FOR WHAT A CYCLE SHOWS. The
|
|
33
|
+
* preview beside the drawing exists to catch a disagreement between the
|
|
34
|
+
* two; if the drawing computed its own levels there would be two beliefs on
|
|
35
|
+
* screen and the user would have no way to tell which one the file means.
|
|
36
|
+
* So `brickOf` maps a level to the engine's OWN brick symbol and the
|
|
37
|
+
* drawing is a rendering of those symbols — see `brickOf`'s comment for the
|
|
38
|
+
* measurement behind every row of that table.
|
|
39
|
+
*
|
|
40
|
+
* 4. A GROUP CAN BE JOINED AT ITS HEAD BUT NOT AT ITS TAIL. That is what
|
|
41
|
+
* `laneInsertPath` does, and it is a consequence of flattened indexing, not
|
|
42
|
+
* a bug to paper over. So every insert affordance ASKS the bridge where it
|
|
43
|
+
* would land and says so on its own label before it is pressed — see
|
|
44
|
+
* `landingOf`.
|
|
45
|
+
*
|
|
46
|
+
* 5. ONE GESTURE, ONE `apply`, ONE `toPatch`. Task 5 measured the store's
|
|
47
|
+
* refusal rate climbing with the number of structural operations stacked up
|
|
48
|
+
* before a write-back (8.0% at 1-3, 17.0% at 1-6). Those refusals are
|
|
49
|
+
* correct — they used to be silent corruption — so the way to keep them
|
|
50
|
+
* rare is to never let edits pile up. `commit()` below is the only way a
|
|
51
|
+
* gesture reaches the store, and it does both halves every time.
|
|
52
|
+
*/
|
|
53
|
+
(function (root, factory) {
|
|
54
|
+
if (typeof module === 'object' && module.exports) module.exports = factory();
|
|
55
|
+
else root.md2docWaveUi = factory();
|
|
56
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
57
|
+
|
|
58
|
+
const SVGNS = 'http://www.w3.org/2000/svg';
|
|
59
|
+
|
|
60
|
+
/** The brush palette, exactly the set the spec names, in that order. */
|
|
61
|
+
const BRUSHES = ['0', '1', 'x', 'z', 'p', 'n', 'h', 'l', 'u', 'd', '='];
|
|
62
|
+
|
|
63
|
+
/** Drawing sizes. Handed to `wave-geometry` so the hit test and the paint
|
|
64
|
+
* cannot drift; nothing here re-derives a row position. */
|
|
65
|
+
const SIZES = { laneHeight: 34, cycleWidth: 48, nameColWidth: 0 };
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The index the preview is rendered under, and why it is not 0.
|
|
69
|
+
*
|
|
70
|
+
* `RenderWaveForm(index, …)` names its structural ids after that index —
|
|
71
|
+
* `svgcontent_<i>`, `waves_<i>`, `lanes_<i>`, `gmarks_<i>`, `gmark_<lane>_<i>`
|
|
72
|
+
* — and the document's own diagram is already rendered at 0. MEASURED in a
|
|
73
|
+
* real page carrying one rendered diagram, counting duplicate `id`s across
|
|
74
|
+
* the whole document (0 before the preview existed):
|
|
75
|
+
*
|
|
76
|
+
* RenderWaveForm(0, …, notFirstSignal=false) 240 duplicate ids
|
|
77
|
+
* RenderWaveForm(0, …, notFirstSignal=true) 20 duplicate ids
|
|
78
|
+
* RenderWaveForm(9000, …, notFirstSignal=true) 0 duplicate ids
|
|
79
|
+
*
|
|
80
|
+
* In all three the preview came out with the same 18 `<use>` elements and the
|
|
81
|
+
* same 300px width, and with `notFirstSignal=true` a brick still measures
|
|
82
|
+
* 20x20 — the `<use>`s resolve against the skin the page already carries, so
|
|
83
|
+
* re-emitting the whole `<defs>` (`notFirstSignal=false`, 1 extra `<defs>`
|
|
84
|
+
* and a duplicate of every brick symbol id) buys nothing. A document using a
|
|
85
|
+
* non-default `config.skin` was the reason to be careful about sharing, and
|
|
86
|
+
* it is not a risk here: the preview renders THE SAME DOCUMENT as the diagram
|
|
87
|
+
* whose skin is on the page.
|
|
88
|
+
*/
|
|
89
|
+
const PREVIEW_INDEX = 9000;
|
|
90
|
+
const PREVIEW_ID_PREFIX = 'md2doc-wave-preview-';
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* What wavedrom 3.5.0 actually draws for one level, by its own brick id.
|
|
94
|
+
*
|
|
95
|
+
* Every row was MEASURED in this session against the pinned engine, by
|
|
96
|
+
* rendering `wave: 'z' + c + c` (the `z` anchors the char so it is never at
|
|
97
|
+
* cycle 0) and reading the second half-brick of each cycle out of the
|
|
98
|
+
* rendered SVG's `<use xlink:href="#…">` list:
|
|
99
|
+
*
|
|
100
|
+
* 0 000 1 111 x xxx z zzz u uuu d ddd
|
|
101
|
+
* h 111 l 000 H 111 L 000
|
|
102
|
+
* p nclk P nclk n pclk N pclk
|
|
103
|
+
* = vvv-2 2 vvv-2 3 vvv-3 … 9 vvv-9
|
|
104
|
+
* anything else xxx (measured with 'Q' and 'ä')
|
|
105
|
+
*
|
|
106
|
+
* So this is not a re-implementation of the engine's level model — `levelsOf`
|
|
107
|
+
* is that, and it lives in the codec. This is the last step: which PICTURE
|
|
108
|
+
* the engine paints for a level it has already decided. Keeping it in
|
|
109
|
+
* production code rather than in the test is deliberate: the drawing has to
|
|
110
|
+
* make exactly this decision anyway (an `h` lane is drawn high, an `=` lane
|
|
111
|
+
* is drawn as a bus), and a copy of it living only in a test would be a
|
|
112
|
+
* second belief about the engine with nothing pinning it to the first.
|
|
113
|
+
*/
|
|
114
|
+
function brickOf(level) {
|
|
115
|
+
if (typeof level !== 'string' || level.length === 0) return 'xxx';
|
|
116
|
+
if (level === '0' || level === 'l' || level === 'L') return '000';
|
|
117
|
+
if (level === '1' || level === 'h' || level === 'H') return '111';
|
|
118
|
+
if (level === 'z') return 'zzz';
|
|
119
|
+
if (level === 'u') return 'uuu';
|
|
120
|
+
if (level === 'd') return 'ddd';
|
|
121
|
+
if (level === 'p' || level === 'P') return 'nclk';
|
|
122
|
+
if (level === 'n' || level === 'N') return 'pclk';
|
|
123
|
+
if (level === '=') return 'vvv-2';
|
|
124
|
+
if (level >= '2' && level <= '9') return 'vvv-' + level;
|
|
125
|
+
return 'xxx';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A brick that draws a whole clock period and therefore never merges with
|
|
129
|
+
* its neighbour: two `p` cycles are two clock periods, not one long one. */
|
|
130
|
+
function isClock(brick) {
|
|
131
|
+
return brick === 'nclk' || brick === 'pclk';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function isBus(brick) {
|
|
135
|
+
return brick.slice(0, 4) === 'vvv-';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Whether `el` is inside the editor's serialisation surface.
|
|
140
|
+
*
|
|
141
|
+
* Constraint 1 at the top of this file, as something a caller can ask rather
|
|
142
|
+
* than as a rule it has to remember. `.content`'s own children are what
|
|
143
|
+
* client.js reads back as "the render I last knew about", so anything this
|
|
144
|
+
* module parks in there travels into the user's markdown.
|
|
145
|
+
*/
|
|
146
|
+
function mountedInsideContent(el) {
|
|
147
|
+
let node = el;
|
|
148
|
+
while (node !== null && node !== undefined) {
|
|
149
|
+
if (node.classList !== undefined && node.classList.contains('content')) return true;
|
|
150
|
+
node = node.parentElement;
|
|
151
|
+
}
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A plain, unfrozen copy of a document.
|
|
157
|
+
*
|
|
158
|
+
* The store's documents are deep-frozen — that is what stops a stray write
|
|
159
|
+
* from corrupting an undo entry — and wavedrom's renderer WRITES INTO the
|
|
160
|
+
* source object it is handed. Under this file's strict prologue that is a
|
|
161
|
+
* throw, so the preview gets its own copy every time. Bounded by the codec's
|
|
162
|
+
* own nesting ceiling so a pathological document cannot run the stack out
|
|
163
|
+
* here either.
|
|
164
|
+
*/
|
|
165
|
+
function plainCopy(value, depth) {
|
|
166
|
+
if (depth > 64 || value === null || typeof value !== 'object') return value;
|
|
167
|
+
if (Array.isArray(value)) {
|
|
168
|
+
const out = [];
|
|
169
|
+
for (let i = 0; i < value.length; i++) out.push(plainCopy(value[i], depth + 1));
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
const out = {};
|
|
173
|
+
for (const key of Object.keys(value)) out[key] = plainCopy(value[key], depth + 1);
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Line and column of a byte offset, 1-based — what a parse failure has to
|
|
178
|
+
* say beyond its own message for the offset to mean anything to a person. */
|
|
179
|
+
function whereIs(text, offset) {
|
|
180
|
+
const upto = String(text).slice(0, Math.max(0, offset | 0));
|
|
181
|
+
const lines = upto.split(/\r\n|\r|\n/);
|
|
182
|
+
return { line: lines.length, column: lines[lines.length - 1].length + 1 };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The group titles in the lane tree, each with the span of display rows it
|
|
187
|
+
* covers.
|
|
188
|
+
*
|
|
189
|
+
* A group title occupies NO ROW — measured in Task 2 against the pinned
|
|
190
|
+
* engine, and `wave-geometry` is built on it. What it occupies is horizontal
|
|
191
|
+
* space in the name column, so that is where this puts it: a label in the
|
|
192
|
+
* lane list's rail, spanning its lanes' rows. Drawing it as a row of its own
|
|
193
|
+
* would put the lane list one row out of step with the waveform beside it for
|
|
194
|
+
* every lane below the group, which is exactly the class of silent
|
|
195
|
+
* disagreement this batch keeps producing.
|
|
196
|
+
*
|
|
197
|
+
* The span is derived from the lanes' own paths — `layout.lanes[i].path` is
|
|
198
|
+
* the codec's — so a group with no lanes under it has no span and is not
|
|
199
|
+
* shown here; it is not addressable through the bridge either.
|
|
200
|
+
*/
|
|
201
|
+
function groupSpansOf(doc, layout) {
|
|
202
|
+
const byKey = new Map();
|
|
203
|
+
for (let i = 0; i < layout.lanes.length; i++) {
|
|
204
|
+
const path = layout.lanes[i].path;
|
|
205
|
+
// Every proper ancestor between `signal` and the lane itself is a group.
|
|
206
|
+
for (let cut = 2; cut < path.length; cut++) {
|
|
207
|
+
const prefix = path.slice(0, cut);
|
|
208
|
+
const key = JSON.stringify(prefix);
|
|
209
|
+
const seen = byKey.get(key);
|
|
210
|
+
if (seen === undefined) {
|
|
211
|
+
byKey.set(key, { path: prefix, depth: cut - 1, from: i, to: i });
|
|
212
|
+
} else {
|
|
213
|
+
seen.to = i;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const out = [];
|
|
218
|
+
for (const span of byKey.values()) {
|
|
219
|
+
const container = valueAt(doc, span.path);
|
|
220
|
+
const title = Array.isArray(container) && typeof container[0] === 'string'
|
|
221
|
+
? container[0] : null;
|
|
222
|
+
if (title === null) continue; // an untitled group has nothing to show
|
|
223
|
+
out.push({ path: span.path, title: title, depth: span.depth,
|
|
224
|
+
from: span.from, to: span.to });
|
|
225
|
+
}
|
|
226
|
+
out.sort(function (a, b) { return a.from - b.from || a.depth - b.depth; });
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function valueAt(doc, path) {
|
|
231
|
+
let node = doc;
|
|
232
|
+
for (const seg of path) {
|
|
233
|
+
if (node === null || typeof node !== 'object') return undefined;
|
|
234
|
+
node = node[seg];
|
|
235
|
+
}
|
|
236
|
+
return node;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The labels a lane's `data` supplies, one per explicit value cycle. */
|
|
240
|
+
function labelsOf(lane) {
|
|
241
|
+
const raw = lane === null || typeof lane !== 'object' ? undefined : lane.data;
|
|
242
|
+
if (Array.isArray(raw)) return raw.map(function (x) { return String(x); });
|
|
243
|
+
if (typeof raw === 'string') return raw.split(/\s+/).filter(function (x) { return x !== ''; });
|
|
244
|
+
return [];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// -------------------------------------------------------------------------
|
|
248
|
+
// The editor
|
|
249
|
+
// -------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Open the editing surface over one wavedrom block's source text.
|
|
253
|
+
*
|
|
254
|
+
* `opts`:
|
|
255
|
+
* document the DOM document to build in (required)
|
|
256
|
+
* host where the overlay is mounted; defaults to `document.body`.
|
|
257
|
+
* A host inside `.content` is REFUSED — see constraint 1.
|
|
258
|
+
* source the block's WaveJSON text
|
|
259
|
+
* codec, geometry, createStore the three modules, handed in
|
|
260
|
+
* wavedrom `window.WaveDrom` or null; null just means no live preview
|
|
261
|
+
* onGesture({name, patch, store}) called after EVERY gesture that changed
|
|
262
|
+
* the document, with that gesture's own patch already computed
|
|
263
|
+
* onClose(reason) called once, when the overlay goes away. `reason` is
|
|
264
|
+
* `'escape'` when the user pressed Escape and `'commit'` for
|
|
265
|
+
* every other route out (the 關閉 button, or a caller driving
|
|
266
|
+
* the returned `close()`). Task 7 needs the two apart because
|
|
267
|
+
* they mean opposite things to the document: a commit leaves
|
|
268
|
+
* this session's write-backs standing, an Escape takes them
|
|
269
|
+
* back off again. Passed as an argument rather than inferred
|
|
270
|
+
* by the caller from what the keyboard last did — that
|
|
271
|
+
* inference is exactly the kind this file has already paid
|
|
272
|
+
* for once (see the banner/Escape split in `onKeyDown`).
|
|
273
|
+
*
|
|
274
|
+
* Never throws on a source that cannot be read: `createStore` answers with a
|
|
275
|
+
* refusing store and this shows the parser's message and offset. A block that
|
|
276
|
+
* silently fails to open is the worst outcome there is — it still looks
|
|
277
|
+
* editable and simply is not.
|
|
278
|
+
*/
|
|
279
|
+
function createWaveEditor(opts) {
|
|
280
|
+
const d = opts.document;
|
|
281
|
+
const codec = opts.codec;
|
|
282
|
+
const geometry = opts.geometry;
|
|
283
|
+
const host = opts.host || d.body;
|
|
284
|
+
if (mountedInsideContent(host)) {
|
|
285
|
+
throw new Error('wave-ui: the overlay may not be mounted inside .content ' +
|
|
286
|
+
'(its children are serialised back into the user markdown)');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const store = opts.createStore(opts.source);
|
|
290
|
+
|
|
291
|
+
const overlay = d.createElement('div');
|
|
292
|
+
overlay.className = 'ed-wave-overlay';
|
|
293
|
+
overlay.setAttribute('role', 'dialog');
|
|
294
|
+
overlay.setAttribute('aria-modal', 'true');
|
|
295
|
+
overlay.setAttribute('aria-label', '波形編輯器');
|
|
296
|
+
|
|
297
|
+
// ── state that is the UI's own, not the document's ────────────────────
|
|
298
|
+
// Declared HERE, above the unreadable-source return below, because
|
|
299
|
+
// `close()` clears `drag` and the unreadable panel has a 關閉 button too:
|
|
300
|
+
// a `let` declared past that return would still be in its temporal dead
|
|
301
|
+
// zone when that button is pressed, and close() would throw instead of
|
|
302
|
+
// closing.
|
|
303
|
+
let brush = '1';
|
|
304
|
+
let clip = null;
|
|
305
|
+
let selection = null; // {laneIndex, from, to} — the cycles a gesture acts on
|
|
306
|
+
let drag = null; // live pointer drag, never pushed until mouseup
|
|
307
|
+
let gestures = 0;
|
|
308
|
+
let lastPatch = null;
|
|
309
|
+
// Where the keyboard should end up after the NEXT repaint, when that is not
|
|
310
|
+
// simply "wherever it was". A move button is the case that needs it: the
|
|
311
|
+
// control the user pressed belongs to a row, and after the move the lane
|
|
312
|
+
// they are pushing around is at a different row — so pressing ▲ twice has
|
|
313
|
+
// to move the same lane twice, not two different ones.
|
|
314
|
+
let focusOverride = null;
|
|
315
|
+
// Where the KEYBOARD is on the drawing: one cell, and the keyboard's whole
|
|
316
|
+
// equivalent of the pointer. Null until the keyboard has actually entered
|
|
317
|
+
// the drawing — a cursor drawn before anyone asked for one is a second
|
|
318
|
+
// permanent highlight beside the selection with nothing behind it.
|
|
319
|
+
//
|
|
320
|
+
// A pointer press moves it too (see `onCanvasDown`), so the two devices can
|
|
321
|
+
// never end up pointing at different cells: painting with `1` after a click
|
|
322
|
+
// paints the cell that was clicked.
|
|
323
|
+
let cursor = null;
|
|
324
|
+
// The document the marks' lane numbers are numbers INTO.
|
|
325
|
+
//
|
|
326
|
+
// `cursor` and `selection` name a ROW, and a row number means a different
|
|
327
|
+
// lane after anything that reorders rows. The first cut answered that per
|
|
328
|
+
// operation — ▲ and ▼ swapped the two indexes themselves — and that is the
|
|
329
|
+
// shape that cannot be finished: it fixed the move and not the UNDO of the
|
|
330
|
+
// move, so ▲ then Ctrl+Z left the cursor and the selection sitting
|
|
331
|
+
// confidently on the neighbour, and the next brush key painted a lane the
|
|
332
|
+
// user was not looking at. Nothing on screen contradicted them. ✕ and +
|
|
333
|
+
// were the same defect before that, and something else would have been
|
|
334
|
+
// next.
|
|
335
|
+
//
|
|
336
|
+
// So the marks are carried by lane IDENTITY through one funnel instead:
|
|
337
|
+
// `afterStoreMoved` is the only road every store movement takes — apply,
|
|
338
|
+
// undo and redo all end there — and `carryMarks` asks the STORE which row
|
|
339
|
+
// each lane is on now. No operation has to remember anything, so no
|
|
340
|
+
// operation can forget.
|
|
341
|
+
let marksDoc = null;
|
|
342
|
+
// The rail's rename field while it is open — this editor's ONE sub-panel,
|
|
343
|
+
// and therefore what the first Escape closes. Before this, Escape typed at
|
|
344
|
+
// a rename field ran `close('escape')` and threw the WHOLE session away,
|
|
345
|
+
// which is the most expensive answer available to「我改名改到一半不想改了」.
|
|
346
|
+
let groupRename = null;
|
|
347
|
+
|
|
348
|
+
// ── the modal focus model ─────────────────────────────────────────────
|
|
349
|
+
//
|
|
350
|
+
// `role="dialog"` + `aria-modal="true"` is a promise, and before this the
|
|
351
|
+
// overlay kept none of it: nothing was focused on open (MEASURED:
|
|
352
|
+
// `document.activeElement` was `document.body` the instant the panel came
|
|
353
|
+
// up), `Tab` never reached any of its controls, and closing left the
|
|
354
|
+
// keyboard wherever the browser happened to drop it.
|
|
355
|
+
//
|
|
356
|
+
// It is also what makes the OWNERSHIP question answerable. The surrounding
|
|
357
|
+
// editor used to ask "did this event land inside the overlay?", which is a
|
|
358
|
+
// question about the event; with focus on body the honest answer was "no"
|
|
359
|
+
// and the document behind the modal kept the key. Ownership has to be true
|
|
360
|
+
// BEFORE any event arrives, so client.js now asks a state question — and
|
|
361
|
+
// this is the other half: the state is real, focus is actually in here.
|
|
362
|
+
const FOCUSABLE = 'button, input, select, textarea, a[href], ' +
|
|
363
|
+
'[tabindex]:not([tabindex="-1"])';
|
|
364
|
+
// Where the keyboard was before this opened, so it can be handed back.
|
|
365
|
+
const cameFrom = d.activeElement;
|
|
366
|
+
|
|
367
|
+
// ── R2: the document behind the modal does not scroll ─────────────────
|
|
368
|
+
//
|
|
369
|
+
// A state predicate over EVENTS cannot answer this one: native wheel
|
|
370
|
+
// scrolling is not delivered to a listener anyone guards, so measured, a
|
|
371
|
+
// single wheel gesture with the pointer over the panel took the document
|
|
372
|
+
// behind it from `scrollY 0` to `800` with the overlay still up.
|
|
373
|
+
//
|
|
374
|
+
// Nothing is mutated by that, so this is not a data question — it is the
|
|
375
|
+
// same question the V3 overlay census answers for `.ed-wave-edit-btn`,
|
|
376
|
+
// which is classified `gone` precisely because "a scroll makes its
|
|
377
|
+
// coordinates point at the wrong thing". The wave editor is anchored to one
|
|
378
|
+
// diagram in the document; scrolling the document out from under it moves
|
|
379
|
+
// where the user lands when it closes, silently.
|
|
380
|
+
//
|
|
381
|
+
// So it is locked, and restored exactly as it was found — the inline value
|
|
382
|
+
// is captured rather than assumed to be empty, so a page that sets its own
|
|
383
|
+
// is not quietly rewritten. `overflow: hidden` on the ROOT leaves
|
|
384
|
+
// `scrollTop` where it is and does not touch the panel's own three scroll
|
|
385
|
+
// columns, which keep working.
|
|
386
|
+
//
|
|
387
|
+
// `lockScroll()` is called from TWO places — the unreadable-source panel's
|
|
388
|
+
// own mount, and the ready panel's — and in each it is the statement
|
|
389
|
+
// immediately before that path's `host.appendChild(overlay)`. That, not the
|
|
390
|
+
// number of call sites, is the property: the lock is taken by the same
|
|
391
|
+
// statement that puts the overlay on screen, so "is it mounted" and "is it
|
|
392
|
+
// locked" cannot disagree, and a THIRD early-return panel has to do the same
|
|
393
|
+
// pairing rather than inherit it.
|
|
394
|
+
//
|
|
395
|
+
// Setting it once at the top, where it was, meant a throw anywhere in the
|
|
396
|
+
// construction below left the whole document permanently unscrollable with
|
|
397
|
+
// no overlay on screen and no way back but a reload — `openWaveEditor`'s own
|
|
398
|
+
// `catch` clears `waveEditor` and re-throws, and it does not know about the
|
|
399
|
+
// lock. Nothing between there and the mount realistically throws today; the
|
|
400
|
+
// point is that it no longer has to be true.
|
|
401
|
+
const rootEl = d.documentElement;
|
|
402
|
+
let rootOverflowWas = null;
|
|
403
|
+
function lockScroll() {
|
|
404
|
+
if (rootOverflowWas !== null) return;
|
|
405
|
+
rootOverflowWas = rootEl.style.overflow;
|
|
406
|
+
rootEl.style.overflow = 'hidden';
|
|
407
|
+
}
|
|
408
|
+
function unlockScroll() {
|
|
409
|
+
if (rootOverflowWas === null) return;
|
|
410
|
+
rootEl.style.overflow = rootOverflowWas;
|
|
411
|
+
rootOverflowWas = null;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Everything inside the modal layer that can take the keyboard.
|
|
416
|
+
*
|
|
417
|
+
* The layer is the overlay AND any conflict banner standing on screen, and
|
|
418
|
+
* that second part is not a detail. `.ed-conflict` is `z-index: 999` against
|
|
419
|
+
* this overlay's 998, so a banner raised while the panel is open PAINTS
|
|
420
|
+
* above it (measured) — but a focus trap scoped to the overlay alone would
|
|
421
|
+
* make its Dismiss and Reload buttons unreachable by keyboard, and that
|
|
422
|
+
* banner is the user's only route out of a disk conflict. This branch has
|
|
423
|
+
* already paid once for a fixed layer hiding that banner; it does not get
|
|
424
|
+
* to pay again by trapping the keyboard away from it.
|
|
425
|
+
*/
|
|
426
|
+
function modalRoots() {
|
|
427
|
+
const out = [overlay];
|
|
428
|
+
for (const el of d.querySelectorAll('.ed-conflict')) out.push(el);
|
|
429
|
+
return out;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function focusables() {
|
|
433
|
+
const out = [];
|
|
434
|
+
for (const root of modalRoots()) {
|
|
435
|
+
for (const el of root.querySelectorAll(FOCUSABLE)) {
|
|
436
|
+
if (el.disabled === true || el.hidden === true) continue;
|
|
437
|
+
if (el.getClientRects().length === 0) continue;
|
|
438
|
+
out.push(el);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return out;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
let closed = false;
|
|
445
|
+
/**
|
|
446
|
+
* Take the whole surface down, once, leaving nothing of this editor behind.
|
|
447
|
+
*
|
|
448
|
+
* Every listener installed on the DOCUMENT is removed here, and a drag in
|
|
449
|
+
* flight is cancelled rather than left armed. That is not tidiness: a drag
|
|
450
|
+
* installs `mousemove`/`mouseup` on the document, so an Escape pressed
|
|
451
|
+
* mid-drag used to leave both attached, and the eventual release still ran
|
|
452
|
+
* the paint — into a store that is no longer on screen, through an
|
|
453
|
+
* `onGesture` whose owner had already been torn down. Measured: a
|
|
454
|
+
* page-level `TypeError: Cannot set properties of null (setting
|
|
455
|
+
* 'lastGesture')`, plus two leaked listeners per cancelled drag.
|
|
456
|
+
*
|
|
457
|
+
* `closed` is also checked by the gesture path itself, so a callback
|
|
458
|
+
* already queued when this ran cannot reach the store either.
|
|
459
|
+
*/
|
|
460
|
+
function close(reason) {
|
|
461
|
+
if (closed) return;
|
|
462
|
+
closed = true;
|
|
463
|
+
drag = null;
|
|
464
|
+
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
|
465
|
+
d.removeEventListener('keydown', onKeyDown, true);
|
|
466
|
+
d.removeEventListener('mousemove', onCanvasMove, true);
|
|
467
|
+
d.removeEventListener('mouseup', onCanvasUp, true);
|
|
468
|
+
unlockScroll();
|
|
469
|
+
// Hand the keyboard back to whatever had it. In today's entry flow that
|
|
470
|
+
// is `document.body` — the entry button is hidden inside its own click
|
|
471
|
+
// handler, so the browser has already reset focus by the time this
|
|
472
|
+
// constructor runs — and restoring body is what the browser does anyway
|
|
473
|
+
// when the focused element is removed. It matters for the keyboard entry
|
|
474
|
+
// path Task 7 adds, and it costs one guarded call.
|
|
475
|
+
if (cameFrom !== null && cameFrom !== undefined && cameFrom !== d.body &&
|
|
476
|
+
d.contains(cameFrom) && typeof cameFrom.focus === 'function') {
|
|
477
|
+
cameFrom.focus();
|
|
478
|
+
}
|
|
479
|
+
// Anything that is not the Escape key is a commit. Spelled as a default
|
|
480
|
+
// here rather than at each call site so a future exit route that forgets
|
|
481
|
+
// to say which it is leaves the user's work standing instead of throwing
|
|
482
|
+
// it away — the safe direction of the two.
|
|
483
|
+
if (typeof opts.onClose === 'function') {
|
|
484
|
+
opts.onClose(reason === 'escape' ? 'escape' : 'commit');
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* While this overlay is up it owns its own keys.
|
|
490
|
+
*
|
|
491
|
+
* Escape closes it, and undo/redo go to the STORE — the waveform's stack,
|
|
492
|
+
* which is the one the user is looking at. Before this, Ctrl+Z inside the
|
|
493
|
+
* overlay reached the surrounding editor and rolled back the MARKDOWN
|
|
494
|
+
* DOCUMENT behind the modal (measured: the tail paragraph's edit
|
|
495
|
+
* disappeared while the overlay stayed up), which also rewrote `lines` and
|
|
496
|
+
* left the caller's line range stale. Capture phase, and
|
|
497
|
+
* `stopPropagation()` on every key this claims, so the decision is made
|
|
498
|
+
* here rather than by whichever listener happens to be registered first.
|
|
499
|
+
*/
|
|
500
|
+
function onKeyDown(ev) {
|
|
501
|
+
// A key that came from the conflict banner is the banner's, not this
|
|
502
|
+
// dialog's. The banner is inside `modalRoots()` on purpose — it has to
|
|
503
|
+
// stay Tab-reachable — but that is a focus decision, not a claim on its
|
|
504
|
+
// keys. MEASURED before this: a user who Tab-walked over to read the
|
|
505
|
+
// banner and pressed Escape to back out lost the entire wave editing
|
|
506
|
+
// session while the banner they were looking at stayed up.
|
|
507
|
+
//
|
|
508
|
+
// Nothing is swallowed here: no `preventDefault`, no `stopPropagation`, so
|
|
509
|
+
// whatever else would handle that key still can. Today nothing does — the
|
|
510
|
+
// banner has no Escape of its own (see `dismissKeynavExitBanner()`'s
|
|
511
|
+
// comment) and client.js's own listener returns at the modal gate — so
|
|
512
|
+
// Escape there is a no-op. A no-op is the right answer; losing the session
|
|
513
|
+
// is not.
|
|
514
|
+
//
|
|
515
|
+
// Only ESCAPE is handed back, not every key: Tab has to stay trapped even
|
|
516
|
+
// while the cursor is on the banner, or the walk leaves the modal layer
|
|
517
|
+
// from its far end and the trap has a hole exactly where the banner is.
|
|
518
|
+
const fromBanner = ev.target !== null && ev.target !== undefined &&
|
|
519
|
+
typeof ev.target.closest === 'function' &&
|
|
520
|
+
ev.target.closest('.ed-conflict') !== null;
|
|
521
|
+
if (ev.key === 'Escape' && fromBanner) return;
|
|
522
|
+
if (ev.key === 'Escape') {
|
|
523
|
+
ev.preventDefault();
|
|
524
|
+
ev.stopPropagation();
|
|
525
|
+
// Escape is LAYERED: the innermost thing on screen first, this dialog
|
|
526
|
+
// second, and (above) the conflict banner's own. The rail's rename
|
|
527
|
+
// field is the only sub-panel this editor has, and before this its
|
|
528
|
+
// Escape reached `close('escape')` — which DISCARDS the session. So
|
|
529
|
+
// backing out of a half-typed group name threw away every gesture the
|
|
530
|
+
// user had made, and the one Ctrl+Z that takes a discard back is not
|
|
531
|
+
// something the keystroke announces.
|
|
532
|
+
if (groupRename !== null) { groupRename.cancel(); return; }
|
|
533
|
+
close('escape');
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
if (ev.key === 'Tab') {
|
|
537
|
+
// The trap. Without it the surrounding editor's own「nothing focused」
|
|
538
|
+
// branch swallowed Tab and none of this dialog's controls could be
|
|
539
|
+
// reached from the keyboard at all.
|
|
540
|
+
const list = focusables();
|
|
541
|
+
ev.preventDefault();
|
|
542
|
+
ev.stopPropagation();
|
|
543
|
+
if (list.length === 0) return;
|
|
544
|
+
const at = list.indexOf(d.activeElement);
|
|
545
|
+
const next = ev.shiftKey
|
|
546
|
+
? list[at <= 0 ? list.length - 1 : at - 1]
|
|
547
|
+
: list[(at === -1 || at === list.length - 1) ? 0 : at + 1];
|
|
548
|
+
next.focus();
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
// The drawing's own keys, claimed here rather than on the canvas element
|
|
552
|
+
// so they are decided in the same place — and in the same phase — as
|
|
553
|
+
// Escape, Tab and undo/redo. Deliberately BELOW those three (a cell
|
|
554
|
+
// cursor may not outrank the way out of the dialog) and ABOVE the
|
|
555
|
+
// modifier gate below, so `Ctrl+Z` with the drawing focused still means
|
|
556
|
+
// undo and nothing here has to re-implement it.
|
|
557
|
+
if (store.ok === true && !ev.ctrlKey && !ev.metaKey && !ev.altKey &&
|
|
558
|
+
handleDrawingKey(ev)) {
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (!(ev.ctrlKey || ev.metaKey) || ev.altKey) return;
|
|
562
|
+
const undoKey = typeof ev.key === 'string' && ev.key.toLowerCase() === 'z';
|
|
563
|
+
const redoKey = typeof ev.key === 'string' &&
|
|
564
|
+
(ev.key.toLowerCase() === 'y' || (ev.key.toLowerCase() === 'z' && ev.shiftKey));
|
|
565
|
+
// The unreadable panel has no store to move and no status line to write
|
|
566
|
+
// to — `say` and the toolbar below are never constructed on that path —
|
|
567
|
+
// but it still has to CLAIM undo and redo rather than let them past.
|
|
568
|
+
// MEASURED before this: Ctrl+Z pressed over the unreadable panel reached
|
|
569
|
+
// the surrounding editor and rolled the markdown document back behind a
|
|
570
|
+
// modal the user cannot see past.
|
|
571
|
+
if (store.ok !== true) {
|
|
572
|
+
if (undoKey || redoKey) { ev.preventDefault(); ev.stopPropagation(); }
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
const key = typeof ev.key === 'string' ? ev.key.toLowerCase() : '';
|
|
576
|
+
if (key === 'z' && !ev.shiftKey) {
|
|
577
|
+
ev.preventDefault();
|
|
578
|
+
ev.stopPropagation();
|
|
579
|
+
if (store.undo()) afterStoreMoved('undo');
|
|
580
|
+
else say('沒有可以復原的動作');
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
if (key === 'y' || (key === 'z' && ev.shiftKey)) {
|
|
584
|
+
ev.preventDefault();
|
|
585
|
+
ev.stopPropagation();
|
|
586
|
+
if (store.redo()) afterStoreMoved('redo');
|
|
587
|
+
else say('沒有可以重做的動作');
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
d.addEventListener('keydown', onKeyDown, true);
|
|
591
|
+
|
|
592
|
+
const panel = d.createElement('div');
|
|
593
|
+
panel.className = 'ed-wave-panel';
|
|
594
|
+
// The dialog itself is the initial focus holder — the ARIA pattern's own
|
|
595
|
+
// default, and the one that does not put a highlight on 關閉 the moment the
|
|
596
|
+
// panel opens. `tabindex="-1"` makes it focusable without putting it in the
|
|
597
|
+
// sequential tab order; the first Tab then lands on the first real control.
|
|
598
|
+
panel.setAttribute('tabindex', '-1');
|
|
599
|
+
overlay.appendChild(panel);
|
|
600
|
+
|
|
601
|
+
const head = d.createElement('div');
|
|
602
|
+
head.className = 'ed-wave-head';
|
|
603
|
+
const heading = d.createElement('span');
|
|
604
|
+
heading.className = 'ed-wave-title';
|
|
605
|
+
heading.textContent = '波形編輯器';
|
|
606
|
+
head.appendChild(heading);
|
|
607
|
+
// What the two ways out DO, said on screen rather than assumed.
|
|
608
|
+
//
|
|
609
|
+
// Escape is a modal's universal close key and here it DISCARDS: ten minutes
|
|
610
|
+
// of drawing goes, and the one keystroke that brings it back (`Ctrl+Z`) is
|
|
611
|
+
// not something the gesture announces. This batch's own rule — a
|
|
612
|
+
// destructive outcome and a safe one may not both be silent — is the rule
|
|
613
|
+
// behind the drawio「這一頁已經不在了」banner and behind the refusal
|
|
614
|
+
// notices, and it applies to the biggest surface in the product just as
|
|
615
|
+
// much. So the header says which key keeps the work, which key throws it
|
|
616
|
+
// away, and that throwing it away is undoable.
|
|
617
|
+
const escapeHint = d.createElement('span');
|
|
618
|
+
escapeHint.className = 'ed-wave-escape-hint';
|
|
619
|
+
escapeHint.setAttribute('data-wave-escape-hint', '');
|
|
620
|
+
escapeHint.textContent = 'Esc=放棄這次編輯(按一次 Ctrl+Z 可以拿回來)';
|
|
621
|
+
head.appendChild(escapeHint);
|
|
622
|
+
const closeBtn = d.createElement('button');
|
|
623
|
+
closeBtn.type = 'button';
|
|
624
|
+
closeBtn.className = 'ed-wave-close';
|
|
625
|
+
closeBtn.textContent = '關閉';
|
|
626
|
+
closeBtn.title = '保留這次編輯並關閉(Esc 則是放棄)';
|
|
627
|
+
// Wrapped rather than passed straight in: a bare `close` receives the
|
|
628
|
+
// MouseEvent as its first argument, and a reason parameter that is silently
|
|
629
|
+
// fed a DOM event is one rename away from meaning something.
|
|
630
|
+
closeBtn.addEventListener('click', function () { close('commit'); });
|
|
631
|
+
head.appendChild(closeBtn);
|
|
632
|
+
panel.appendChild(head);
|
|
633
|
+
|
|
634
|
+
if (store.ok !== true) {
|
|
635
|
+
// ── the block cannot be read back ──────────────────────────────────
|
|
636
|
+
// Ruling P3-6 parses at Edit-click time, so this is the first moment
|
|
637
|
+
// anyone could know. Say the message AND the offset, and turn the offset
|
|
638
|
+
// into a line/column plus the text around it — an offset on its own is
|
|
639
|
+
// not something a person can act on.
|
|
640
|
+
const bad = d.createElement('div');
|
|
641
|
+
bad.className = 'ed-wave-parse-error';
|
|
642
|
+
const at = whereIs(opts.source, store.error.offset);
|
|
643
|
+
const msg = d.createElement('p');
|
|
644
|
+
msg.className = 'ed-wave-parse-message';
|
|
645
|
+
msg.textContent = '這個 wavedrom 區塊讀不回來,所以沒有東西可以編輯:' +
|
|
646
|
+
store.error.message;
|
|
647
|
+
bad.appendChild(msg);
|
|
648
|
+
const where = d.createElement('p');
|
|
649
|
+
where.className = 'ed-wave-parse-where';
|
|
650
|
+
where.textContent = '位置:offset ' + store.error.offset +
|
|
651
|
+
'(第 ' + at.line + ' 行第 ' + at.column + ' 個字)';
|
|
652
|
+
where.setAttribute('data-wave-offset', String(store.error.offset));
|
|
653
|
+
bad.appendChild(where);
|
|
654
|
+
const excerpt = d.createElement('pre');
|
|
655
|
+
excerpt.className = 'ed-wave-parse-excerpt';
|
|
656
|
+
excerpt.textContent = String(opts.source).split(/\r\n|\r|\n/)[at.line - 1] || '';
|
|
657
|
+
bad.appendChild(excerpt);
|
|
658
|
+
panel.appendChild(bad);
|
|
659
|
+
overlay.setAttribute('data-wave-state', 'unreadable');
|
|
660
|
+
lockScroll();
|
|
661
|
+
host.appendChild(overlay);
|
|
662
|
+
panel.focus();
|
|
663
|
+
return { el: overlay, store: store, close: close, refresh: function () {} };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
overlay.setAttribute('data-wave-state', 'ready');
|
|
667
|
+
// The marks start out addressing the document the store opened with.
|
|
668
|
+
marksDoc = store.doc;
|
|
669
|
+
|
|
670
|
+
// ── toolbar ───────────────────────────────────────────────────────────
|
|
671
|
+
const toolbar = d.createElement('div');
|
|
672
|
+
toolbar.className = 'ed-wave-toolbar';
|
|
673
|
+
panel.appendChild(toolbar);
|
|
674
|
+
|
|
675
|
+
const brushBar = d.createElement('div');
|
|
676
|
+
brushBar.className = 'ed-wave-brushes';
|
|
677
|
+
toolbar.appendChild(brushBar);
|
|
678
|
+
const brushButtons = [];
|
|
679
|
+
for (const ch of BRUSHES) {
|
|
680
|
+
const b = d.createElement('button');
|
|
681
|
+
b.type = 'button';
|
|
682
|
+
b.className = 'ed-wave-brush';
|
|
683
|
+
b.setAttribute('data-brush', ch);
|
|
684
|
+
b.setAttribute('data-focus-key', 'brush-' + ch);
|
|
685
|
+
b.textContent = ch;
|
|
686
|
+
b.addEventListener('click', function () {
|
|
687
|
+
brush = ch;
|
|
688
|
+
paintBrushState();
|
|
689
|
+
});
|
|
690
|
+
brushBar.appendChild(b);
|
|
691
|
+
brushButtons.push(b);
|
|
692
|
+
}
|
|
693
|
+
function paintBrushState() {
|
|
694
|
+
for (const b of brushButtons) {
|
|
695
|
+
b.classList.toggle('is-on', b.getAttribute('data-brush') === brush);
|
|
696
|
+
}
|
|
697
|
+
overlay.setAttribute('data-wave-brush', brush);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const cycleBar = d.createElement('div');
|
|
701
|
+
cycleBar.className = 'ed-wave-cycleops';
|
|
702
|
+
toolbar.appendChild(cycleBar);
|
|
703
|
+
|
|
704
|
+
function toolButton(cls, label, hint, fn) {
|
|
705
|
+
const b = d.createElement('button');
|
|
706
|
+
b.type = 'button';
|
|
707
|
+
b.className = cls;
|
|
708
|
+
b.setAttribute('data-focus-key', cls);
|
|
709
|
+
b.textContent = label;
|
|
710
|
+
b.title = hint;
|
|
711
|
+
b.addEventListener('click', fn);
|
|
712
|
+
cycleBar.appendChild(b);
|
|
713
|
+
return b;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
toolButton('ed-wave-cycle-insert', '插入 cycle',
|
|
717
|
+
'在選取的位置插入一個 cycle(每一條 lane 同時加寬,不會有人掉隊)', function () {
|
|
718
|
+
const at = selection === null ? 0 : Math.min(selection.from, selection.to);
|
|
719
|
+
commit('insert-cycle', function (doc) { return codec.insertCycles(doc, at, 1); });
|
|
720
|
+
});
|
|
721
|
+
toolButton('ed-wave-cycle-delete', '刪除 cycle',
|
|
722
|
+
'刪掉選取的 cycle(每一條 lane 同時變窄)', function () {
|
|
723
|
+
if (selection === null) { say('先選一段 cycle 再刪'); return; }
|
|
724
|
+
const from = Math.min(selection.from, selection.to);
|
|
725
|
+
const count = Math.abs(selection.to - selection.from) + 1;
|
|
726
|
+
commit('delete-cycle', function (doc) { return codec.deleteCycles(doc, from, count); });
|
|
727
|
+
});
|
|
728
|
+
toolButton('ed-wave-cycle-copy', '複製',
|
|
729
|
+
'把選取的 cycle 從每一條 lane 上拷一份', function () {
|
|
730
|
+
if (selection === null) { say('先選一段 cycle 再複製'); return; }
|
|
731
|
+
const from = Math.min(selection.from, selection.to);
|
|
732
|
+
const count = Math.abs(selection.to - selection.from) + 1;
|
|
733
|
+
clip = codec.copyCycles(store.doc, from, count);
|
|
734
|
+
say('複製了 ' + count + ' 個 cycle');
|
|
735
|
+
render();
|
|
736
|
+
});
|
|
737
|
+
toolButton('ed-wave-cycle-paste', '貼上(插入)',
|
|
738
|
+
'把剪貼簿插進選取的位置', function () { pasteAt('insert'); });
|
|
739
|
+
toolButton('ed-wave-cycle-paste-over', '貼上(覆蓋)',
|
|
740
|
+
'用剪貼簿蓋掉選取位置起算的那幾個 cycle', function () { pasteAt('overwrite'); });
|
|
741
|
+
|
|
742
|
+
function pasteAt(mode) {
|
|
743
|
+
if (clip === null) { say('剪貼簿是空的'); return; }
|
|
744
|
+
const at = selection === null ? 0 : Math.min(selection.from, selection.to);
|
|
745
|
+
commit('paste-' + mode, function (doc) {
|
|
746
|
+
return codec.pasteCycles(doc, at, clip, mode);
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const undoBtn = toolButton('ed-wave-undo', '復原', '退回上一個動作', function () {
|
|
751
|
+
if (!store.undo()) { say('沒有可以復原的動作'); return; }
|
|
752
|
+
afterStoreMoved('undo');
|
|
753
|
+
});
|
|
754
|
+
const redoBtn = toolButton('ed-wave-redo', '重做', '再做一次剛剛復原掉的動作', function () {
|
|
755
|
+
if (!store.redo()) { say('沒有可以重做的動作'); return; }
|
|
756
|
+
afterStoreMoved('redo');
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
// head / foot — the document-level fields this drawing can honour.
|
|
760
|
+
//
|
|
761
|
+
// `config.hscale` USED to be a third field here and has been removed. The
|
|
762
|
+
// drawing does not model `hscale`, `period` or `phase` at all (zero
|
|
763
|
+
// occurrences in wave-codec, wave-geometry or this file), and measured
|
|
764
|
+
// against the pinned engine each of them changes what the engine paints
|
|
765
|
+
// while leaving the drawing exactly as it was:
|
|
766
|
+
//
|
|
767
|
+
// {wave:'0101'} engine 8 half-bricks drawing 4 cycles
|
|
768
|
+
// {wave:'0101', period:2} engine 16 drawing 4, unchanged
|
|
769
|
+
// {wave:'0101', phase:0.5} engine 7 drawing 4, unshifted
|
|
770
|
+
// {wave:'0101'} config.hscale:2 engine 16 drawing 4, unchanged
|
|
771
|
+
//
|
|
772
|
+
// Shipping a CONTROL for a property the canvas ignores is the worst of the
|
|
773
|
+
// three options: it invites the user to make the two pictures disagree and
|
|
774
|
+
// then shows them a confident wrong one. Modelling the three properly is
|
|
775
|
+
// not a change this file may make on its own either — cycle width lives in
|
|
776
|
+
// `wave-geometry`, which is closed and whose arithmetic Task 3 pinned, and
|
|
777
|
+
// a half-model here would be a SECOND belief about cycle geometry in the
|
|
778
|
+
// same tree, which is the exact failure this batch keeps producing.
|
|
779
|
+
//
|
|
780
|
+
// So: the control is gone, and a document that carries any of the three is
|
|
781
|
+
// told so out loud by `renderUnmodelled()` below rather than drawn wrongly
|
|
782
|
+
// in silence.
|
|
783
|
+
const metaBar = d.createElement('div');
|
|
784
|
+
metaBar.className = 'ed-wave-meta';
|
|
785
|
+
toolbar.appendChild(metaBar);
|
|
786
|
+
|
|
787
|
+
function metaField(cls, label, placeholder) {
|
|
788
|
+
const wrap = d.createElement('label');
|
|
789
|
+
wrap.className = 'ed-wave-metafield';
|
|
790
|
+
const text = d.createElement('span');
|
|
791
|
+
text.textContent = label;
|
|
792
|
+
wrap.appendChild(text);
|
|
793
|
+
const input = d.createElement('input');
|
|
794
|
+
input.type = 'text';
|
|
795
|
+
input.className = cls;
|
|
796
|
+
input.setAttribute('data-focus-key', cls);
|
|
797
|
+
input.placeholder = placeholder;
|
|
798
|
+
wrap.appendChild(input);
|
|
799
|
+
metaBar.appendChild(wrap);
|
|
800
|
+
return input;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const headInput = metaField('ed-wave-head-text', 'head', '上方標題');
|
|
804
|
+
const footInput = metaField('ed-wave-foot-text', 'foot', '下方標題');
|
|
805
|
+
|
|
806
|
+
headInput.addEventListener('change', function () { commitBanner('head', headInput.value); });
|
|
807
|
+
footInput.addEventListener('change', function () { commitBanner('foot', footInput.value); });
|
|
808
|
+
|
|
809
|
+
// The "this drawing is not showing you everything" notice. Empty, and with
|
|
810
|
+
// no box on screen, for the documents that carry none of the three.
|
|
811
|
+
const unmodelled = d.createElement('div');
|
|
812
|
+
unmodelled.className = 'ed-wave-unmodelled';
|
|
813
|
+
unmodelled.hidden = true;
|
|
814
|
+
toolbar.appendChild(unmodelled);
|
|
815
|
+
|
|
816
|
+
// The other notice: this block has nothing this editor can edit. Its own
|
|
817
|
+
// element rather than a second sentence in the one above, because the two
|
|
818
|
+
// are different claims — "the drawing is not showing you everything" and
|
|
819
|
+
// "there is nothing here to draw" — and a document can be in both states.
|
|
820
|
+
const noSignal = d.createElement('div');
|
|
821
|
+
noSignal.className = 'ed-wave-unmodelled ed-wave-nolanes';
|
|
822
|
+
noSignal.hidden = true;
|
|
823
|
+
toolbar.appendChild(noSignal);
|
|
824
|
+
|
|
825
|
+
function commitBanner(which, value) {
|
|
826
|
+
commit(which, function (doc) {
|
|
827
|
+
const had = doc[which];
|
|
828
|
+
if (value === '' && (had === undefined || had === null)) return doc;
|
|
829
|
+
const next = Object.assign({}, doc);
|
|
830
|
+
next[which] = Object.assign({}, (had !== null && typeof had === 'object') ? had : {});
|
|
831
|
+
next[which].text = value;
|
|
832
|
+
return next;
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// ── body: lane list | drawing | preview ───────────────────────────────
|
|
837
|
+
const body = d.createElement('div');
|
|
838
|
+
body.className = 'ed-wave-body';
|
|
839
|
+
panel.appendChild(body);
|
|
840
|
+
|
|
841
|
+
const laneCol = d.createElement('div');
|
|
842
|
+
laneCol.className = 'ed-wave-lanes';
|
|
843
|
+
body.appendChild(laneCol);
|
|
844
|
+
|
|
845
|
+
const canvasWrap = d.createElement('div');
|
|
846
|
+
canvasWrap.className = 'ed-wave-canvas-wrap';
|
|
847
|
+
body.appendChild(canvasWrap);
|
|
848
|
+
|
|
849
|
+
const previewWrap = d.createElement('div');
|
|
850
|
+
previewWrap.className = 'ed-wave-preview';
|
|
851
|
+
const previewLabel = d.createElement('div');
|
|
852
|
+
previewLabel.className = 'ed-wave-preview-label';
|
|
853
|
+
previewLabel.textContent = 'WaveDrom 預覽';
|
|
854
|
+
previewWrap.appendChild(previewLabel);
|
|
855
|
+
const previewHost = d.createElement('div');
|
|
856
|
+
previewHost.className = 'ed-wave-preview-host';
|
|
857
|
+
// The output id prefix is deliberately NOT `WaveDrom_Display_`: md2doc's
|
|
858
|
+
// own init hook reclaims every id with that prefix before each
|
|
859
|
+
// ProcessAll(), and the reader's lightbox picks its click targets by it.
|
|
860
|
+
// This preview belongs to neither.
|
|
861
|
+
previewHost.id = PREVIEW_ID_PREFIX + PREVIEW_INDEX;
|
|
862
|
+
previewWrap.appendChild(previewHost);
|
|
863
|
+
body.appendChild(previewWrap);
|
|
864
|
+
|
|
865
|
+
const status = d.createElement('div');
|
|
866
|
+
status.className = 'ed-wave-status';
|
|
867
|
+
// Everything this dialog says back — where the cursor is, what a gesture
|
|
868
|
+
// did, why one was refused — is written here and nowhere else, so it is
|
|
869
|
+
// the one place that has to be announced rather than merely painted.
|
|
870
|
+
status.setAttribute('role', 'status');
|
|
871
|
+
panel.appendChild(status);
|
|
872
|
+
|
|
873
|
+
function say(text) {
|
|
874
|
+
status.textContent = text;
|
|
875
|
+
overlay.setAttribute('data-wave-status', text);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// ── the one road from a gesture to the document ───────────────────────
|
|
879
|
+
/**
|
|
880
|
+
* Run one gesture: one `apply`, then one `toPatch`, then repaint.
|
|
881
|
+
*
|
|
882
|
+
* Constraint 5. The patch is computed HERE, on the gesture, and not saved
|
|
883
|
+
* up for a session-ending write: Task 5 measured the store's refusal rate
|
|
884
|
+
* rising with the number of structural operations stacked before a
|
|
885
|
+
* write-back, and a refusal the user meets ten edits later is one they have
|
|
886
|
+
* to bisect themselves. Refusing early refuses small.
|
|
887
|
+
*
|
|
888
|
+
* A refusal is shown and nothing is written. It is NOT rolled back — the
|
|
889
|
+
* document on screen is still what the user asked for, and undoing their
|
|
890
|
+
* edit because the file cannot express it yet would be this layer deciding
|
|
891
|
+
* for them. Task 7 owns what happens to the patch; this owns that there is
|
|
892
|
+
* exactly one per gesture.
|
|
893
|
+
*/
|
|
894
|
+
function commit(name, fn) {
|
|
895
|
+
// A gesture that arrives after the overlay came down has nowhere to land:
|
|
896
|
+
// the store is off screen and the caller's own state is already torn
|
|
897
|
+
// down. Cheap and load-bearing — an Escape mid-drag is exactly this.
|
|
898
|
+
if (closed) return false;
|
|
899
|
+
const changed = store.apply(name, fn);
|
|
900
|
+
if (!changed) {
|
|
901
|
+
say('這個動作沒有改變任何東西');
|
|
902
|
+
return false;
|
|
903
|
+
}
|
|
904
|
+
gestures++;
|
|
905
|
+
afterStoreMoved(name);
|
|
906
|
+
return true;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* Move the marks from the document they were made in to the one the store
|
|
911
|
+
* is showing now, by asking who each lane IS rather than where it sat.
|
|
912
|
+
*
|
|
913
|
+
* `laneTrace(before, after)[j]` is the row that lane `j` occupied before,
|
|
914
|
+
* or null for a row that did not exist then — the store's own answer, the
|
|
915
|
+
* one the patch planner reads, injected rather than re-derived here (see
|
|
916
|
+
* `wave-store.laneTrace`). Inverting it answers the question a mark has:
|
|
917
|
+
* "the lane I was on — where is it now?"
|
|
918
|
+
*
|
|
919
|
+
* A lane that is GONE (its ✕, or the undo of an insert) has no answer, and
|
|
920
|
+
* the mark falls back to the old row number clamped into the new layout —
|
|
921
|
+
* which is what a person expects after deleting a row: the cursor lands on
|
|
922
|
+
* whatever took its place, rather than disappearing. The clamp in
|
|
923
|
+
* `renderCanvas` does that, so this simply leaves the number alone.
|
|
924
|
+
*
|
|
925
|
+
* With no `laneTrace` injected — an older page carrying an older
|
|
926
|
+
* wave-store — the marks are DROPPED on any structural change instead of
|
|
927
|
+
* being left pointing at whatever inherited the index. "I no longer know
|
|
928
|
+
* where you were" is a state the user can see; "here, except it is a
|
|
929
|
+
* different lane now" is the defect.
|
|
930
|
+
*/
|
|
931
|
+
function carryMarks(nextDoc) {
|
|
932
|
+
if (marksDoc === nextDoc) return;
|
|
933
|
+
const before = marksDoc;
|
|
934
|
+
marksDoc = nextDoc;
|
|
935
|
+
if (before === null || (cursor === null && selection === null)) return;
|
|
936
|
+
const trace = typeof opts.laneTrace === 'function'
|
|
937
|
+
? opts.laneTrace(before, nextDoc) : null;
|
|
938
|
+
if (trace === null) { cursor = null; selection = null; return; }
|
|
939
|
+
// Structural or not: a content operation traces as the identity, so this
|
|
940
|
+
// is a no-op for a paint without having to know that it is one.
|
|
941
|
+
const rowOf = function (was) {
|
|
942
|
+
for (let j = 0; j < trace.length; j++) {
|
|
943
|
+
if (trace[j] === was) return j;
|
|
944
|
+
}
|
|
945
|
+
return null;
|
|
946
|
+
};
|
|
947
|
+
if (cursor !== null) {
|
|
948
|
+
const at = rowOf(cursor.laneIndex);
|
|
949
|
+
if (at !== null) cursor = { laneIndex: at, cycle: cursor.cycle };
|
|
950
|
+
}
|
|
951
|
+
if (selection !== null) {
|
|
952
|
+
const at = rowOf(selection.laneIndex);
|
|
953
|
+
if (at !== null) {
|
|
954
|
+
selection = { laneIndex: at, from: selection.from, to: selection.to };
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function afterStoreMoved(name) {
|
|
960
|
+
if (closed) return;
|
|
961
|
+
// Before anything reads or draws them. `commit`, the undo/redo buttons
|
|
962
|
+
// and the Ctrl+Z / Ctrl+Y keys all arrive here, and nothing else moves
|
|
963
|
+
// the store — so this is the whole of "the marks follow their lane".
|
|
964
|
+
carryMarks(store.doc);
|
|
965
|
+
lastPatch = store.toPatch();
|
|
966
|
+
render();
|
|
967
|
+
if (lastPatch.ok === true) {
|
|
968
|
+
say('已寫回:' + name);
|
|
969
|
+
} else {
|
|
970
|
+
say('這個改動沒辦法只改幾個位元組寫回去:' + lastPatch.reason);
|
|
971
|
+
}
|
|
972
|
+
if (typeof opts.onGesture === 'function') {
|
|
973
|
+
opts.onGesture({ name: name, patch: lastPatch, store: store });
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// ── painting ──────────────────────────────────────────────────────────
|
|
978
|
+
let canvas = null;
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* Repaint everything, and give the keyboard cursor back where it was.
|
|
982
|
+
*
|
|
983
|
+
* `renderLanes` rebuilds the lane list from scratch, so without this a
|
|
984
|
+
* rename committed with Enter left `document.activeElement` on
|
|
985
|
+
* `document.body` — measured. That is the same defect class as four of the
|
|
986
|
+
* last five commits on this branch, and here it is also the ENABLING step
|
|
987
|
+
* for the block-selection data loss: focus on body plus a standing
|
|
988
|
+
* selection turns the next Backspace into "delete the selected blocks".
|
|
989
|
+
*
|
|
990
|
+
* Identity is carried by `data-focus-key`, not by element identity, because
|
|
991
|
+
* the element the user was typing in no longer exists after the rebuild.
|
|
992
|
+
* The text cursor inside a field is carried too; a field that came back
|
|
993
|
+
* with different text keeps the cursor clamped to what is there now.
|
|
994
|
+
*
|
|
995
|
+
* The rename field is the ONE `data-focus-key` that a repaint does not
|
|
996
|
+
* bring back — the rail only ever rebuilds the tag it replaced — so a
|
|
997
|
+
* repaint that runs while it is open has to retire it explicitly. Without
|
|
998
|
+
* that, `Ctrl+Z` typed into the field wedged the whole page: the undo
|
|
999
|
+
* repainted, `restoreFocus` looked for `group-input-<n>` and found
|
|
1000
|
+
* nothing, focus was left on a DETACHED element, and from then on NO key
|
|
1001
|
+
* reached the page at all — not `a`, not three Escapes, so the modal could
|
|
1002
|
+
* not even be closed without a mouse. That is the defect family this task
|
|
1003
|
+
* exists for, one worse than the six it cites, and it was reachable in two
|
|
1004
|
+
* keystrokes from the sub-panel the task itself redesigned.
|
|
1005
|
+
*/
|
|
1006
|
+
function render() {
|
|
1007
|
+
if (groupRename !== null) groupRename.retire();
|
|
1008
|
+
const before = focusMark();
|
|
1009
|
+
const doc = store.doc;
|
|
1010
|
+
const layout = geometry.layoutOf(doc, SIZES);
|
|
1011
|
+
renderLanes(doc, layout);
|
|
1012
|
+
renderCanvas(doc, layout);
|
|
1013
|
+
renderPreview(doc);
|
|
1014
|
+
renderMeta(doc);
|
|
1015
|
+
renderUnmodelled(doc);
|
|
1016
|
+
undoBtn.disabled = !store.canUndo();
|
|
1017
|
+
redoBtn.disabled = !store.canRedo();
|
|
1018
|
+
overlay.setAttribute('data-wave-gestures', String(gestures));
|
|
1019
|
+
overlay.setAttribute('data-wave-lanes', String(layout.lanes.length));
|
|
1020
|
+
overlay.setAttribute('data-wave-cycles', String(layout.cycles));
|
|
1021
|
+
overlay.setAttribute('data-wave-dirty', store.isDirty() ? '1' : '0');
|
|
1022
|
+
overlay.setAttribute('data-wave-patch',
|
|
1023
|
+
lastPatch === null ? 'none' : (lastPatch.ok === true ? 'ok' : 'refused'));
|
|
1024
|
+
paintBrushState();
|
|
1025
|
+
restoreFocus(before);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/** What has the keyboard now, as something that survives a rebuild. */
|
|
1029
|
+
function focusMark() {
|
|
1030
|
+
const el = d.activeElement;
|
|
1031
|
+
if (el === null || el === undefined || !overlay.contains(el)) return null;
|
|
1032
|
+
const key = el.getAttribute('data-focus-key');
|
|
1033
|
+
if (key === null) return null;
|
|
1034
|
+
const mark = { key: key, start: null, end: null };
|
|
1035
|
+
// `selectionStart` throws on input types that have no text selection; the
|
|
1036
|
+
// fields here are all type=text, but reading it defensively costs nothing
|
|
1037
|
+
// and keeps a future field from turning a repaint into an exception.
|
|
1038
|
+
try {
|
|
1039
|
+
mark.start = el.selectionStart;
|
|
1040
|
+
mark.end = el.selectionEnd;
|
|
1041
|
+
} catch (e) { /* not a text field */ }
|
|
1042
|
+
return mark;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function restoreFocus(mark) {
|
|
1046
|
+
const want = focusOverride !== null ? focusOverride : (mark === null ? null : mark.key);
|
|
1047
|
+
focusOverride = null;
|
|
1048
|
+
if (want === null) return;
|
|
1049
|
+
let el = overlay.querySelector('[data-focus-key="' + want + '"]');
|
|
1050
|
+
// A named destination the repaint did not bring back. It happens: a
|
|
1051
|
+
// group's key is its first lane's row number, so inserting a lane above
|
|
1052
|
+
// it renumbers the key that a rename field had already nominated —
|
|
1053
|
+
// measured, `{overlay:1, input:0, key:null, tag:"BODY"}`. Fall back to
|
|
1054
|
+
// where the keyboard actually was, and failing that to the dialog
|
|
1055
|
+
// itself. Never to nothing: nothing is `document.body`, and this whole
|
|
1056
|
+
// task is about not ending there.
|
|
1057
|
+
if (el === null && mark !== null && mark.key !== want) {
|
|
1058
|
+
el = overlay.querySelector('[data-focus-key="' + mark.key + '"]');
|
|
1059
|
+
}
|
|
1060
|
+
if (el === null) {
|
|
1061
|
+
if (!overlay.contains(d.activeElement)) panel.focus();
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
el.focus();
|
|
1065
|
+
if (mark === null || mark.key !== want) return;
|
|
1066
|
+
if (mark.start === null || typeof el.setSelectionRange !== 'function') return;
|
|
1067
|
+
const n = typeof el.value === 'string' ? el.value.length : 0;
|
|
1068
|
+
try {
|
|
1069
|
+
el.setSelectionRange(Math.min(mark.start, n), Math.min(mark.end, n));
|
|
1070
|
+
} catch (e) { /* not a text field */ }
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function renderMeta(doc) {
|
|
1074
|
+
const h = doc.head;
|
|
1075
|
+
const f = doc.foot;
|
|
1076
|
+
headInput.value = (h !== null && typeof h === 'object' && typeof h.text === 'string')
|
|
1077
|
+
? h.text : '';
|
|
1078
|
+
footInput.value = (f !== null && typeof f === 'object' && typeof f.text === 'string')
|
|
1079
|
+
? f.text : '';
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* Say out loud which properties of this document the drawing is not
|
|
1084
|
+
* showing.
|
|
1085
|
+
*
|
|
1086
|
+
* `config.hscale`, and a lane's `period` / `phase`, all change what the
|
|
1087
|
+
* engine paints and none of them is modelled here (see the toolbar comment
|
|
1088
|
+
* for the measurements). Drawing a confident picture that ignores them is
|
|
1089
|
+
* the failure this whole overlay is arranged to avoid, so a document that
|
|
1090
|
+
* carries any of them says so, names them, and points at the preview —
|
|
1091
|
+
* which IS faithful, because it is the engine.
|
|
1092
|
+
*/
|
|
1093
|
+
function renderUnmodelled(doc) {
|
|
1094
|
+
const found = [];
|
|
1095
|
+
const cfg = doc.config;
|
|
1096
|
+
if (cfg !== null && typeof cfg === 'object' && cfg.hscale !== undefined) {
|
|
1097
|
+
found.push('config.hscale');
|
|
1098
|
+
}
|
|
1099
|
+
for (const path of codec.lanePaths(doc)) {
|
|
1100
|
+
const lane = valueAt(doc, path);
|
|
1101
|
+
if (lane === null || typeof lane !== 'object') continue;
|
|
1102
|
+
if (lane.period !== undefined && found.indexOf('period') === -1) found.push('period');
|
|
1103
|
+
if (lane.phase !== undefined && found.indexOf('phase') === -1) found.push('phase');
|
|
1104
|
+
}
|
|
1105
|
+
overlay.setAttribute('data-wave-unmodelled', found.join(' '));
|
|
1106
|
+
unmodelled.hidden = found.length === 0;
|
|
1107
|
+
unmodelled.textContent = found.length === 0 ? '' :
|
|
1108
|
+
'這份文件用了 ' + found.join('、') +
|
|
1109
|
+
',左邊的手繪波形不表現它們(寬度與相位會不一樣)——以右邊的 WaveDrom 預覽為準。';
|
|
1110
|
+
|
|
1111
|
+
// …and the bigger version of the same honesty. This editor edits
|
|
1112
|
+
// `signal:` and nothing else, so a `reg:` or `assign:` block opens a
|
|
1113
|
+
// canvas with no lanes on it: `lanePaths()` is empty, `addLane()` hands
|
|
1114
|
+
// the document straight back, and pressing + produced one status line
|
|
1115
|
+
//「這個動作沒有改變任何東西」— true, and no explanation at all. An
|
|
1116
|
+
// editor that looks broken is worse than one that says what it cannot
|
|
1117
|
+
// do, and the notice above is the precedent: the drawing already tells
|
|
1118
|
+
// the user which properties it is not showing them.
|
|
1119
|
+
const kinds = [];
|
|
1120
|
+
for (const key of ['reg', 'assign']) {
|
|
1121
|
+
if (doc[key] !== undefined) kinds.push(key + ':');
|
|
1122
|
+
}
|
|
1123
|
+
const noLanes = codec.lanePaths(doc).length === 0;
|
|
1124
|
+
overlay.setAttribute('data-wave-nolanes', noLanes ? '1' : '0');
|
|
1125
|
+
noSignal.hidden = !noLanes;
|
|
1126
|
+
noSignal.textContent = !noLanes ? '' :
|
|
1127
|
+
(kinds.length === 0
|
|
1128
|
+
? '這個區塊裡沒有 signal:,所以沒有可以編輯的 lane。'
|
|
1129
|
+
: '這個區塊用的是 ' + kinds.join(' / ') +
|
|
1130
|
+
',這個編輯器只編輯 signal: 的 lane。') +
|
|
1131
|
+
'右邊的 WaveDrom 預覽仍然是引擎畫的原圖;要改這個區塊請用「⠿ → MD 原始碼」。';
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* The lane list.
|
|
1136
|
+
*
|
|
1137
|
+
* Rows are `layout.laneHeight` tall and in `layout.lanes` order, so row k
|
|
1138
|
+
* here and row k of the drawing are the same lane by construction rather
|
|
1139
|
+
* than by a second traversal that could disagree.
|
|
1140
|
+
*/
|
|
1141
|
+
function renderLanes(doc, layout) {
|
|
1142
|
+
laneCol.textContent = '';
|
|
1143
|
+
const rows = d.createElement('div');
|
|
1144
|
+
rows.className = 'ed-wave-lane-rows';
|
|
1145
|
+
rows.style.height = layout.height + 'px';
|
|
1146
|
+
laneCol.appendChild(rows);
|
|
1147
|
+
|
|
1148
|
+
// Group titles first, so they sit UNDER the rows in paint order and a
|
|
1149
|
+
// lane's own controls stay clickable over them.
|
|
1150
|
+
for (const span of groupSpansOf(doc, layout)) {
|
|
1151
|
+
const tag = d.createElement('button');
|
|
1152
|
+
tag.type = 'button';
|
|
1153
|
+
tag.className = 'ed-wave-group';
|
|
1154
|
+
tag.setAttribute('data-focus-key', 'group-' + span.from);
|
|
1155
|
+
tag.textContent = span.title;
|
|
1156
|
+
tag.title = '群組「' + span.title + '」——點一下改名';
|
|
1157
|
+
tag.setAttribute('data-group-from', String(span.from));
|
|
1158
|
+
tag.setAttribute('data-group-to', String(span.to));
|
|
1159
|
+
tag.style.top = (span.from * layout.laneHeight) + 'px';
|
|
1160
|
+
tag.style.height = ((span.to - span.from + 1) * layout.laneHeight) + 'px';
|
|
1161
|
+
tag.style.left = ((span.depth - 1) * 16) + 'px';
|
|
1162
|
+
tag.addEventListener('click', function () { renameGroup(span); });
|
|
1163
|
+
rows.appendChild(tag);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
for (let i = 0; i < layout.lanes.length; i++) {
|
|
1167
|
+
rows.appendChild(laneRow(doc, layout, i));
|
|
1168
|
+
}
|
|
1169
|
+
// The tail insert. `laneInsertPath(doc, laneCount)` goes on the end of
|
|
1170
|
+
// `signal` itself, which is what makes the group asymmetry visible here
|
|
1171
|
+
// rather than surprising afterwards.
|
|
1172
|
+
rows.appendChild(insertButton(doc, layout.lanes.length, layout, true));
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function laneRow(doc, layout, i) {
|
|
1176
|
+
const row = d.createElement('div');
|
|
1177
|
+
row.className = 'ed-wave-lane-row';
|
|
1178
|
+
row.setAttribute('data-lane', String(i));
|
|
1179
|
+
row.style.top = (i * layout.laneHeight) + 'px';
|
|
1180
|
+
row.style.height = layout.laneHeight + 'px';
|
|
1181
|
+
row.style.paddingLeft = (layout.lanes[i].depth * 16) + 'px';
|
|
1182
|
+
if (selection !== null && selection.laneIndex === i) row.classList.add('is-selected');
|
|
1183
|
+
|
|
1184
|
+
row.appendChild(insertButton(doc, i, layout, false));
|
|
1185
|
+
|
|
1186
|
+
const name = d.createElement('input');
|
|
1187
|
+
name.type = 'text';
|
|
1188
|
+
name.className = 'ed-wave-lane-name';
|
|
1189
|
+
name.setAttribute('data-focus-key', 'lane-name-' + i);
|
|
1190
|
+
const lane = layout.lanes[i].lane;
|
|
1191
|
+
name.value = typeof lane.name === 'string' ? lane.name : '';
|
|
1192
|
+
name.addEventListener('change', function () {
|
|
1193
|
+
commit('rename', function (doc2) { return codec.renameLane(doc2, i, name.value); });
|
|
1194
|
+
});
|
|
1195
|
+
row.appendChild(name);
|
|
1196
|
+
|
|
1197
|
+
const up = d.createElement('button');
|
|
1198
|
+
up.type = 'button';
|
|
1199
|
+
up.className = 'ed-wave-lane-up';
|
|
1200
|
+
up.setAttribute('data-focus-key', 'lane-up-' + i);
|
|
1201
|
+
up.textContent = '▲';
|
|
1202
|
+
up.title = '往上移一格';
|
|
1203
|
+
up.disabled = i === 0;
|
|
1204
|
+
up.addEventListener('click', function () {
|
|
1205
|
+
// The button this lane arrives at, EXCEPT at the top, where `lane-up-0`
|
|
1206
|
+
// is disabled and `.focus()` on it is inert — measured, walking a lane
|
|
1207
|
+
// to the top with three ▲ put the cursor on `lane-up-2`, `lane-up-1`
|
|
1208
|
+
// and then BODY, which is the state F7 exists to prevent and the fourth
|
|
1209
|
+
// press then did nothing at all. At the top the cursor goes to the one
|
|
1210
|
+
// control on that row that is never disabled there: ▼.
|
|
1211
|
+
focusOverride = i - 1 === 0 ? 'lane-down-0' : 'lane-up-' + (i - 1);
|
|
1212
|
+
commit('move', function (doc2) { return codec.moveLane(doc2, i, i - 1); });
|
|
1213
|
+
});
|
|
1214
|
+
row.appendChild(up);
|
|
1215
|
+
|
|
1216
|
+
const down = d.createElement('button');
|
|
1217
|
+
down.type = 'button';
|
|
1218
|
+
down.className = 'ed-wave-lane-down';
|
|
1219
|
+
down.setAttribute('data-focus-key', 'lane-down-' + i);
|
|
1220
|
+
down.textContent = '▼';
|
|
1221
|
+
down.title = '往下移一格';
|
|
1222
|
+
down.disabled = i === layout.lanes.length - 1;
|
|
1223
|
+
down.addEventListener('click', function () {
|
|
1224
|
+
// Mirror of ▲ above: `lane-down-<last>` is disabled, so the bottom row
|
|
1225
|
+
// hands the cursor to ▲ instead.
|
|
1226
|
+
focusOverride = i + 1 === layout.lanes.length - 1
|
|
1227
|
+
? 'lane-up-' + (i + 1) : 'lane-down-' + (i + 1);
|
|
1228
|
+
commit('move', function (doc2) { return codec.moveLane(doc2, i, i + 1); });
|
|
1229
|
+
});
|
|
1230
|
+
row.appendChild(down);
|
|
1231
|
+
|
|
1232
|
+
const del = d.createElement('button');
|
|
1233
|
+
del.type = 'button';
|
|
1234
|
+
del.className = 'ed-wave-lane-remove';
|
|
1235
|
+
del.setAttribute('data-focus-key', 'lane-remove-' + i);
|
|
1236
|
+
del.textContent = '✕';
|
|
1237
|
+
del.title = '刪掉這條 lane(如果群組因此空掉,群組也會一起走)';
|
|
1238
|
+
del.addEventListener('click', function () {
|
|
1239
|
+
selection = null;
|
|
1240
|
+
// The row this button belongs to is about to stop existing, so the
|
|
1241
|
+
// keyboard goes to the + that occupies that position instead — the
|
|
1242
|
+
// one control at that spot that survives every removal, tail included.
|
|
1243
|
+
focusOverride = 'lane-add-' + i;
|
|
1244
|
+
commit('remove-lane', function (doc2) { return codec.removeLane(doc2, i); });
|
|
1245
|
+
});
|
|
1246
|
+
row.appendChild(del);
|
|
1247
|
+
return row;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* Where a lane inserted at display position `index` would actually land,
|
|
1252
|
+
* asked of the bridge rather than guessed.
|
|
1253
|
+
*
|
|
1254
|
+
* This is constraint 4 made visible. `laneInsertPath` puts an insert at a
|
|
1255
|
+
* group's FIRST lane inside that group, and an insert at the position just
|
|
1256
|
+
* past its LAST lane outside it — a group can be joined at its head and
|
|
1257
|
+
* never at its tail. That asymmetry is not something a label can argue
|
|
1258
|
+
* away, so the label states it: the button says which container it will
|
|
1259
|
+
* land in, before it is pressed, and the status line says it again after.
|
|
1260
|
+
*/
|
|
1261
|
+
function landingOf(doc, index) {
|
|
1262
|
+
const path = codec.laneInsertPath(doc, index);
|
|
1263
|
+
if (path === null) return { path: null, title: null };
|
|
1264
|
+
const container = path.slice(0, path.length - 1);
|
|
1265
|
+
const held = valueAt(doc, container);
|
|
1266
|
+
const title = Array.isArray(held) && typeof held[0] === 'string' ? held[0] : null;
|
|
1267
|
+
return { path: path, title: title };
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
function insertButton(doc, index, layout, isTail) {
|
|
1271
|
+
const b = d.createElement('button');
|
|
1272
|
+
b.type = 'button';
|
|
1273
|
+
b.className = isTail ? 'ed-wave-lane-add ed-wave-lane-add-tail' : 'ed-wave-lane-add';
|
|
1274
|
+
b.textContent = '+';
|
|
1275
|
+
b.setAttribute('data-insert-at', String(index));
|
|
1276
|
+
b.setAttribute('data-focus-key', 'lane-add-' + index);
|
|
1277
|
+
const landing = landingOf(doc, index);
|
|
1278
|
+
const where = landing.title === null ? '群組外' : '群組「' + landing.title + '」裡';
|
|
1279
|
+
b.setAttribute('data-lands-in', landing.title === null ? '' : landing.title);
|
|
1280
|
+
b.title = '在這裡加一條 lane —— 會落在' + where;
|
|
1281
|
+
if (isTail) {
|
|
1282
|
+
b.style.top = (layout.lanes.length * layout.laneHeight) + 'px';
|
|
1283
|
+
}
|
|
1284
|
+
b.addEventListener('click', function () {
|
|
1285
|
+
// Straight into the new lane's name field: it is what the user is going
|
|
1286
|
+
// to type next, and it is the only control the new row introduces.
|
|
1287
|
+
focusOverride = 'lane-name-' + index;
|
|
1288
|
+
const added = commit('add-lane', function (doc2) {
|
|
1289
|
+
return codec.addLane(doc2, index, { name: '', wave: 'x' });
|
|
1290
|
+
});
|
|
1291
|
+
if (added) say('新增的 lane 落在' + where);
|
|
1292
|
+
});
|
|
1293
|
+
return b;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
/**
|
|
1297
|
+
* The rail's rename field: this dialog's one sub-panel.
|
|
1298
|
+
*
|
|
1299
|
+
* The field is INSERTED beside its tag and the tag is hidden, rather than
|
|
1300
|
+
* the two being swapped, and taking it down again touches only those two
|
|
1301
|
+
* nodes. That is not tidiness. Taking it down used to mean a full
|
|
1302
|
+
* `render()` — a rebuild of the entire rail — and a rebuild that runs
|
|
1303
|
+
* inside a `blur` handler destroys the element the user is in the middle
|
|
1304
|
+
* of pressing. Measured before this: open the field, then press any rail
|
|
1305
|
+
* button; the mousedown blurred the field, the rail was rebuilt, the
|
|
1306
|
+
* mouseup landed on a fresh element so no `click` ever fired, the press
|
|
1307
|
+
* did nothing at all, and the keyboard ended on `document.body` inside a
|
|
1308
|
+
* modal with no visible cursor.
|
|
1309
|
+
*/
|
|
1310
|
+
function renameGroup(span) {
|
|
1311
|
+
const rows = laneCol.querySelector('.ed-wave-lane-rows');
|
|
1312
|
+
const tag = rows === null ? null
|
|
1313
|
+
: rows.querySelector('.ed-wave-group[data-group-from="' + span.from + '"]');
|
|
1314
|
+
if (tag === null) return;
|
|
1315
|
+
|
|
1316
|
+
const input = d.createElement('input');
|
|
1317
|
+
input.type = 'text';
|
|
1318
|
+
input.className = 'ed-wave-group-input';
|
|
1319
|
+
input.setAttribute('data-focus-key', 'group-input-' + span.from);
|
|
1320
|
+
input.value = span.title;
|
|
1321
|
+
input.style.top = tag.style.top;
|
|
1322
|
+
input.style.left = tag.style.left;
|
|
1323
|
+
|
|
1324
|
+
/** The field off, the tag back — and nothing else in the rail touched.
|
|
1325
|
+
* A no-op for both once a repaint has taken them away. */
|
|
1326
|
+
const takeDown = function () {
|
|
1327
|
+
if (input.parentNode !== null) input.parentNode.removeChild(input);
|
|
1328
|
+
if (d.contains(tag)) tag.hidden = false;
|
|
1329
|
+
};
|
|
1330
|
+
/** The tag this field was covering, or — when a repaint has replaced it
|
|
1331
|
+
* and moved it (a group's `from` is its first lane's row, so an insert
|
|
1332
|
+
* above it renumbers the key) — whatever now carries that key, and
|
|
1333
|
+
* failing that the dialog itself. Never nothing: nothing is
|
|
1334
|
+
* `document.body`. */
|
|
1335
|
+
const focusTag = function () {
|
|
1336
|
+
const live = d.contains(tag) ? tag
|
|
1337
|
+
: overlay.querySelector('[data-focus-key="group-' + span.from + '"]');
|
|
1338
|
+
if (live !== null) live.focus();
|
|
1339
|
+
else panel.focus();
|
|
1340
|
+
};
|
|
1341
|
+
/** Whether the keyboard is IN the field right now. False on the `blur`
|
|
1342
|
+
* path — the browser has already moved it by then — which is how the
|
|
1343
|
+
* two are told apart without asking which device did anything. */
|
|
1344
|
+
const holdsKeyboard = function () { return d.activeElement === input; };
|
|
1345
|
+
|
|
1346
|
+
const finish = function () {
|
|
1347
|
+
// Once. `change` and `blur` both fire for a field committed with Enter,
|
|
1348
|
+
// and every teardown below is a blur too. Whoever gets here first owns
|
|
1349
|
+
// the gesture; the latch is also how the other two say "not you".
|
|
1350
|
+
if (groupRename === null) return;
|
|
1351
|
+
groupRename = null;
|
|
1352
|
+
const hadKeyboard = holdsKeyboard();
|
|
1353
|
+
const value = input.value;
|
|
1354
|
+
takeDown();
|
|
1355
|
+
if (value === span.title) {
|
|
1356
|
+
// Nothing to write, so nothing to repaint — the tag is already back.
|
|
1357
|
+
// The keyboard is only moved when it was in the field: on the mouse
|
|
1358
|
+
// path the browser is already carrying it to the control that was
|
|
1359
|
+
// pressed, and that control is still there to receive it now that
|
|
1360
|
+
// this path no longer rebuilds the rail.
|
|
1361
|
+
if (hadKeyboard) focusTag();
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
// A real commit, so a real repaint — and the keyboard has to be named
|
|
1365
|
+
// if it would otherwise be lost. `focusOverride` is left alone when a
|
|
1366
|
+
// caller has already made a more specific promise.
|
|
1367
|
+
if (focusOverride === null &&
|
|
1368
|
+
(hadKeyboard || !overlay.contains(d.activeElement))) {
|
|
1369
|
+
focusOverride = 'group-' + span.from;
|
|
1370
|
+
}
|
|
1371
|
+
commit('rename-group', function (doc2) {
|
|
1372
|
+
return replaceAt(doc2, span.path.concat([0]), value);
|
|
1373
|
+
});
|
|
1374
|
+
};
|
|
1375
|
+
// The rail is about to be rebuilt underneath this field by somebody
|
|
1376
|
+
// else's repaint — an undo, a redo, any gesture that reaches `render()`
|
|
1377
|
+
// while it is open. Both nodes go with that rebuild, so there is nothing
|
|
1378
|
+
// to take down; what matters is dropping the latch and naming where the
|
|
1379
|
+
// keyboard lands, because `group-input-<n>` will not exist afterwards and
|
|
1380
|
+
// focus would be left on a node that is no longer in the document — which
|
|
1381
|
+
// is the wedge that lost every subsequent keystroke.
|
|
1382
|
+
const retire = function () {
|
|
1383
|
+
if (groupRename === null) return;
|
|
1384
|
+
groupRename = null;
|
|
1385
|
+
if (focusOverride === null && holdsKeyboard()) {
|
|
1386
|
+
focusOverride = 'group-' + span.from;
|
|
1387
|
+
}
|
|
1388
|
+
};
|
|
1389
|
+
// Escape's half of the layering: the field goes, the name does not
|
|
1390
|
+
// change, the session stands, and the keyboard goes back to the tag the
|
|
1391
|
+
// field was covering — never to `document.body`, which is the state the
|
|
1392
|
+
// whole of this task exists to make unreachable. No repaint at all: the
|
|
1393
|
+
// tag was never destroyed.
|
|
1394
|
+
const cancel = function () {
|
|
1395
|
+
if (groupRename === null) return;
|
|
1396
|
+
groupRename = null;
|
|
1397
|
+
takeDown();
|
|
1398
|
+
focusTag();
|
|
1399
|
+
};
|
|
1400
|
+
|
|
1401
|
+
input.addEventListener('change', finish);
|
|
1402
|
+
input.addEventListener('blur', finish);
|
|
1403
|
+
tag.hidden = true;
|
|
1404
|
+
rows.insertBefore(input, tag);
|
|
1405
|
+
// Three facts, and only the three that are read: that a rename is
|
|
1406
|
+
// standing (this being non-null), how to take it down on purpose, and how
|
|
1407
|
+
// to let go of it when a repaint takes it down for us. A copy of the span
|
|
1408
|
+
// or of the field itself would be a second record of something the
|
|
1409
|
+
// closure above already holds.
|
|
1410
|
+
groupRename = { cancel: cancel, retire: retire };
|
|
1411
|
+
input.focus();
|
|
1412
|
+
input.select();
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
/**
|
|
1416
|
+
* A copy of `doc` with one scalar replaced, for the two fields the codec's
|
|
1417
|
+
* lane operations do not cover (a group's own title).
|
|
1418
|
+
*
|
|
1419
|
+
* Structure-sharing everywhere it does not have to copy, so the store's
|
|
1420
|
+
* lane-identity bookkeeping still sees every untouched lane as the same
|
|
1421
|
+
* object — `remapOrigins` answers a content edit by position and a
|
|
1422
|
+
* structural one by identity, and a wholesale clone here would make an
|
|
1423
|
+
* ordinary rename look like a structural change.
|
|
1424
|
+
*/
|
|
1425
|
+
function replaceAt(node, path, value) {
|
|
1426
|
+
if (path.length === 0) return value;
|
|
1427
|
+
const seg = path[0];
|
|
1428
|
+
const child = replaceAt(node[seg], path.slice(1), value);
|
|
1429
|
+
if (Array.isArray(node)) {
|
|
1430
|
+
const out = node.slice();
|
|
1431
|
+
out[seg] = child;
|
|
1432
|
+
return out;
|
|
1433
|
+
}
|
|
1434
|
+
const out = Object.assign({}, node);
|
|
1435
|
+
out[seg] = child;
|
|
1436
|
+
return out;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* The hand-drawn waveform.
|
|
1441
|
+
*
|
|
1442
|
+
* Every cycle's picture comes from `codec.levelsOf` through `brickOf`, and
|
|
1443
|
+
* from nowhere else — constraint 3. The levels and the bricks are both
|
|
1444
|
+
* published on the element (`data-levels-N`, `data-bricks-N`) so the
|
|
1445
|
+
* agreement with the preview beside it is something a test can measure
|
|
1446
|
+
* rather than something this comment can promise: the preview's own SVG
|
|
1447
|
+
* names its bricks in its `<use xlink:href="#…">` list.
|
|
1448
|
+
*/
|
|
1449
|
+
function renderCanvas(doc, layout) {
|
|
1450
|
+
// Every repaint REPLACES the canvas element, so a canvas that had the
|
|
1451
|
+
// keyboard loses it to `document.body` the instant it is detached —
|
|
1452
|
+
// exactly the state F7 and T6h exist to prevent, and here it would fire
|
|
1453
|
+
// on every arrow key. `render()`'s own `data-focus-key` restore covers
|
|
1454
|
+
// the full-repaint path; this covers the three that call renderCanvas
|
|
1455
|
+
// directly (a drag's two, and the keyboard's own), so the property holds
|
|
1456
|
+
// for the canvas no matter which road the repaint came down.
|
|
1457
|
+
const hadFocus = canvas !== null && d.activeElement === canvas;
|
|
1458
|
+
// The cursor is an index pair, and the document under it can get shorter:
|
|
1459
|
+
// deleting cycles or removing the bottom lane leaves it pointing past the
|
|
1460
|
+
// end. Clamped HERE, where the layout that decides what "past the end"
|
|
1461
|
+
// means is already in hand — so nothing downstream ever has to ask.
|
|
1462
|
+
if (cursor !== null) {
|
|
1463
|
+
if (layout.lanes.length === 0 || layout.cycles === 0) {
|
|
1464
|
+
cursor = null;
|
|
1465
|
+
} else {
|
|
1466
|
+
cursor = {
|
|
1467
|
+
laneIndex: Math.max(0, Math.min(cursor.laneIndex, layout.lanes.length - 1)),
|
|
1468
|
+
cycle: Math.max(0, Math.min(cursor.cycle, layout.cycles - 1)),
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
// …and `selection` with it, which is the other half of the same fact and
|
|
1473
|
+
// was left out of the first cut. Measured before this: Shift-select four
|
|
1474
|
+
// cycles, press 刪除 cycle, and a one-cycle document was left with the
|
|
1475
|
+
// cursor on cycle 1, no visible box, 複製 reporting「複製了 4 個 cycle」
|
|
1476
|
+
// and 貼上(插入)growing the document back to five. The toolbar was
|
|
1477
|
+
// acting on a range the drawing no longer had anywhere to put.
|
|
1478
|
+
//
|
|
1479
|
+
// The two axes are not treated alike, and that is the point: a CYCLE
|
|
1480
|
+
// range that overshoots is the same range in a shorter document, so it
|
|
1481
|
+
// clamps; a LANE that is gone is not a lane the user can have meant, so
|
|
1482
|
+
// the selection goes rather than silently moving to whichever lane
|
|
1483
|
+
// inherited the index. (`lane ✕` already nulls it on its own path; this
|
|
1484
|
+
// catches the undo/redo of that.)
|
|
1485
|
+
//
|
|
1486
|
+
// It does NOT catch every route, and the earlier wording here said it
|
|
1487
|
+
// did. MEASURED: `+` at 0, drag-select cycles 1-3 on the new lane 0,
|
|
1488
|
+
// then Ctrl+Z — the selection box and the rail highlight both stay on
|
|
1489
|
+
// row 0, which is now `clk`. The trace-derived marks above fix the
|
|
1490
|
+
// CURSOR for that case; the selection is still carried by row number,
|
|
1491
|
+
// so it lands on whoever inherited the row. The cost is confined to the
|
|
1492
|
+
// highlight and to where the next Shift+arrow anchors, because every
|
|
1493
|
+
// cycle operation is document-wide — but it is a real gap, not a
|
|
1494
|
+
// covered one.
|
|
1495
|
+
if (selection !== null) {
|
|
1496
|
+
if (layout.lanes.length === 0 || layout.cycles === 0 ||
|
|
1497
|
+
selection.laneIndex < 0 || selection.laneIndex >= layout.lanes.length) {
|
|
1498
|
+
selection = null;
|
|
1499
|
+
} else {
|
|
1500
|
+
selection = {
|
|
1501
|
+
laneIndex: selection.laneIndex,
|
|
1502
|
+
from: Math.max(0, Math.min(selection.from, layout.cycles - 1)),
|
|
1503
|
+
to: Math.max(0, Math.min(selection.to, layout.cycles - 1)),
|
|
1504
|
+
};
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
canvasWrap.textContent = '';
|
|
1508
|
+
canvas = d.createElementNS(SVGNS, 'svg');
|
|
1509
|
+
canvas.setAttribute('class', 'ed-wave-canvas');
|
|
1510
|
+
// In the Tab cycle, and therefore in the focus trap, like every other
|
|
1511
|
+
// control in here: the drawing is this dialog's main surface and was the
|
|
1512
|
+
// one thing in it a keyboard could not reach.
|
|
1513
|
+
canvas.setAttribute('tabindex', '0');
|
|
1514
|
+
canvas.setAttribute('data-focus-key', 'canvas');
|
|
1515
|
+
canvas.setAttribute('aria-label',
|
|
1516
|
+
'波形繪圖區:方向鍵移動游標,Shift+左右鍵選一段 cycle,' +
|
|
1517
|
+
'按電位字元(' + BRUSHES.join(' ') + ')塗上去');
|
|
1518
|
+
const width = Math.max(layout.width, SIZES.cycleWidth);
|
|
1519
|
+
canvas.setAttribute('width', String(width));
|
|
1520
|
+
canvas.setAttribute('height', String(Math.max(layout.height, layout.laneHeight)));
|
|
1521
|
+
canvas.setAttribute('viewBox', '0 0 ' + width + ' ' +
|
|
1522
|
+
Math.max(layout.height, layout.laneHeight));
|
|
1523
|
+
canvas.setAttribute('data-lane-count', String(layout.lanes.length));
|
|
1524
|
+
canvas.setAttribute('data-cycle-count', String(layout.cycles));
|
|
1525
|
+
|
|
1526
|
+
// cycle grid
|
|
1527
|
+
for (let c = 0; c <= layout.cycles; c++) {
|
|
1528
|
+
const g = d.createElementNS(SVGNS, 'line');
|
|
1529
|
+
g.setAttribute('class', 'ed-wave-grid');
|
|
1530
|
+
g.setAttribute('x1', String(c * layout.cycleWidth));
|
|
1531
|
+
g.setAttribute('x2', String(c * layout.cycleWidth));
|
|
1532
|
+
g.setAttribute('y1', '0');
|
|
1533
|
+
g.setAttribute('y2', String(layout.height));
|
|
1534
|
+
canvas.appendChild(g);
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
for (let i = 0; i < layout.lanes.length; i++) {
|
|
1538
|
+
drawLane(canvas, layout, i);
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
if (selection !== null) {
|
|
1542
|
+
const from = Math.min(selection.from, selection.to);
|
|
1543
|
+
const to = Math.max(selection.from, selection.to);
|
|
1544
|
+
const box = d.createElementNS(SVGNS, 'rect');
|
|
1545
|
+
box.setAttribute('class', 'ed-wave-selection');
|
|
1546
|
+
box.setAttribute('x', String(from * layout.cycleWidth));
|
|
1547
|
+
box.setAttribute('y', String(selection.laneIndex * layout.laneHeight));
|
|
1548
|
+
box.setAttribute('width', String((to - from + 1) * layout.cycleWidth));
|
|
1549
|
+
box.setAttribute('height', String(layout.laneHeight));
|
|
1550
|
+
canvas.appendChild(box);
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
// The keyboard's cell, drawn last so it is over the selection tint and
|
|
1554
|
+
// over the trace. Its geometry comes from `wave-geometry.cellRect` — the
|
|
1555
|
+
// inverse of the hit test the pointer uses — so the cell the cursor is
|
|
1556
|
+
// DRAWN on and the cell an edit lands in cannot drift apart.
|
|
1557
|
+
let cursorCell = '';
|
|
1558
|
+
if (cursor !== null) {
|
|
1559
|
+
const cell = geometry.cellRect(layout, cursor.laneIndex, cursor.cycle);
|
|
1560
|
+
if (cell !== null) {
|
|
1561
|
+
cursorCell = cursor.laneIndex + ',' + cursor.cycle;
|
|
1562
|
+
const cur = d.createElementNS(SVGNS, 'rect');
|
|
1563
|
+
cur.setAttribute('class', 'ed-wave-cursor');
|
|
1564
|
+
cur.setAttribute('data-ed-wave-cursor', '');
|
|
1565
|
+
cur.setAttribute('data-cell', cursorCell);
|
|
1566
|
+
cur.setAttribute('x', String(cell.x));
|
|
1567
|
+
cur.setAttribute('y', String(cell.y));
|
|
1568
|
+
cur.setAttribute('width', String(cell.width));
|
|
1569
|
+
cur.setAttribute('height', String(cell.height));
|
|
1570
|
+
canvas.appendChild(cur);
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
// The same value on the overlay: what a scenario reads when the rect is
|
|
1574
|
+
// not the thing under test. It is assigned INSIDE the branch that draws
|
|
1575
|
+
// the rect and defaults to empty, so "there is no rect" and "the overlay
|
|
1576
|
+
// says none" are one decision rather than two that happen to agree —
|
|
1577
|
+
// they did not, in the first cut: this line read `cursor` directly, so a
|
|
1578
|
+
// cursor the clamp had not caught published a cell that `cellRect`
|
|
1579
|
+
// refused to give a rectangle to, and a build with the clamp removed
|
|
1580
|
+
// reported `{cell: null, published: "5,4"}`.
|
|
1581
|
+
overlay.setAttribute('data-wave-cursor', cursorCell);
|
|
1582
|
+
|
|
1583
|
+
canvas.addEventListener('mousedown', onCanvasDown);
|
|
1584
|
+
// Tabbing onto the drawing is an entry like any other, so it puts the
|
|
1585
|
+
// cursor on screen rather than leaving a focused surface with no visible
|
|
1586
|
+
// cursor in it. Re-entrant only once: `enterDrawing()` repaints, the
|
|
1587
|
+
// repaint re-focuses the new canvas and fires this again, and by then
|
|
1588
|
+
// `cursor` is set.
|
|
1589
|
+
canvas.addEventListener('focus', function () {
|
|
1590
|
+
if (cursor === null) enterDrawing();
|
|
1591
|
+
});
|
|
1592
|
+
canvasWrap.appendChild(canvas);
|
|
1593
|
+
if (hadFocus) canvas.focus();
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
function drawLane(svg, layout, i) {
|
|
1597
|
+
const row = layout.lanes[i];
|
|
1598
|
+
const lane = row.lane;
|
|
1599
|
+
// An ABSENT `wave` and an EMPTY one are two different pictures, and
|
|
1600
|
+
// collapsing them into one was the regression the previous round shipped
|
|
1601
|
+
// on the very axis it was fixing. MEASURED against the pinned engine,
|
|
1602
|
+
// second half-brick per cycle:
|
|
1603
|
+
//
|
|
1604
|
+
// {name:'a', wave:''} ["xxx","xxx"] ONE x cycle
|
|
1605
|
+
// {name:'a'} [] NOTHING
|
|
1606
|
+
// {} (spacer) [] NOTHING
|
|
1607
|
+
// {name:'a', wave:1234} [] NOTHING
|
|
1608
|
+
// {name:'a', wave:null} [] NOTHING
|
|
1609
|
+
//
|
|
1610
|
+
// and in a mixed diagram the engine simply emits no bricks for that row:
|
|
1611
|
+
// `{signal:[{a,'0101'},{},{b,'1010'}]}` is 16 uses, i.e. 2 lanes × 4 × 2.
|
|
1612
|
+
// So the rule is exactly "a lane draws cycles only when `wave` is a
|
|
1613
|
+
// string"; `''` is a string, which is why it gets its one cycle, and
|
|
1614
|
+
// `{}` — the canonical WaveDrom blank-row spacer — gets none.
|
|
1615
|
+
//
|
|
1616
|
+
// The empty-string case is not a corner either: `deleteCycles` covering
|
|
1617
|
+
// every cycle sets every lane's wave to `''`, two clicks away.
|
|
1618
|
+
const hasWave = typeof lane.wave === 'string';
|
|
1619
|
+
const wave = hasWave ? lane.wave : '';
|
|
1620
|
+
const cells = hasWave ? codec.expandWave(wave) : [];
|
|
1621
|
+
const levels = !hasWave ? [] : (wave === '' ? ['x'] : codec.levelsOf(cells));
|
|
1622
|
+
const bricks = levels.map(brickOf);
|
|
1623
|
+
// Which cycles carry the engine's discontinuity marker. `levelsOf`
|
|
1624
|
+
// deliberately erases `|` — it answers what a cycle SHOWS, and a gap
|
|
1625
|
+
// shows whatever is in front of it — so the marker has to be read off the
|
|
1626
|
+
// cells. Measured: `01|10` renders one `<use xlink:href="#gap">` at
|
|
1627
|
+
// translate(100), which is the centre of cycle 2, the `|` cycle.
|
|
1628
|
+
const gaps = [];
|
|
1629
|
+
for (let c = 0; c < cells.length; c++) {
|
|
1630
|
+
if (cells[c].held !== true && cells[c].ch === '|') gaps.push(c);
|
|
1631
|
+
}
|
|
1632
|
+
// `data-wave-<i>` cannot carry the difference between "no `wave` key" and
|
|
1633
|
+
// `wave: ''` — both are the empty string — and that difference is exactly
|
|
1634
|
+
// what the engine draws differently, so it gets its own attribute.
|
|
1635
|
+
svg.setAttribute('data-haswave-' + i, hasWave ? '1' : '0');
|
|
1636
|
+
svg.setAttribute('data-wave-' + i, wave);
|
|
1637
|
+
svg.setAttribute('data-levels-' + i, levels.join(''));
|
|
1638
|
+
svg.setAttribute('data-bricks-' + i, bricks.join(' '));
|
|
1639
|
+
svg.setAttribute('data-gaps-' + i, gaps.join(' '));
|
|
1640
|
+
|
|
1641
|
+
const hi = row.y + 7;
|
|
1642
|
+
const lo = row.y + row.height - 9;
|
|
1643
|
+
const mid = (hi + lo) / 2;
|
|
1644
|
+
const cw = layout.cycleWidth;
|
|
1645
|
+
const labels = labelsOf(lane);
|
|
1646
|
+
// Which cycles carry a `data` label: the explicit value cycles, in order.
|
|
1647
|
+
// `levelsOf` repeats a bus's level through its continuation cycles, so the
|
|
1648
|
+
// label belongs to the cycle that STARTS the run, which is what the run
|
|
1649
|
+
// grouping below already knows.
|
|
1650
|
+
let labelSlot = 0;
|
|
1651
|
+
|
|
1652
|
+
// Runs of one picture. Clock bricks never merge — each `p` cycle is a
|
|
1653
|
+
// whole clock period, and a merged run would draw one long one.
|
|
1654
|
+
const runs = [];
|
|
1655
|
+
for (let c = 0; c < bricks.length; c++) {
|
|
1656
|
+
const last = runs.length === 0 ? null : runs[runs.length - 1];
|
|
1657
|
+
if (last !== null && last.brick === bricks[c] && !isClock(bricks[c])) last.to = c;
|
|
1658
|
+
else runs.push({ brick: bricks[c], from: c, to: c });
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
function bandOf(brick) {
|
|
1662
|
+
if (brick === '111' || brick === 'uuu') return [hi, hi];
|
|
1663
|
+
if (brick === '000' || brick === 'ddd') return [lo, lo];
|
|
1664
|
+
if (brick === 'zzz') return [mid, mid];
|
|
1665
|
+
return [hi, lo];
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
for (let r = 0; r < runs.length; r++) {
|
|
1669
|
+
const run = runs[r];
|
|
1670
|
+
const x0 = run.from * cw;
|
|
1671
|
+
const x1 = (run.to + 1) * cw;
|
|
1672
|
+
const brick = run.brick;
|
|
1673
|
+
if (isBus(brick)) {
|
|
1674
|
+
const poly = d.createElementNS(SVGNS, 'polygon');
|
|
1675
|
+
const s = Math.min(6, cw / 4);
|
|
1676
|
+
poly.setAttribute('class', 'ed-wave-bus ed-wave-bus-' + brick.slice(4));
|
|
1677
|
+
poly.setAttribute('points',
|
|
1678
|
+
(x0 + s) + ',' + hi + ' ' + (x1 - s) + ',' + hi + ' ' +
|
|
1679
|
+
x1 + ',' + mid + ' ' + (x1 - s) + ',' + lo + ' ' +
|
|
1680
|
+
(x0 + s) + ',' + lo + ' ' + x0 + ',' + mid);
|
|
1681
|
+
svg.appendChild(poly);
|
|
1682
|
+
const text = labels[labelSlot];
|
|
1683
|
+
if (typeof text === 'string' && text !== '') {
|
|
1684
|
+
const t = d.createElementNS(SVGNS, 'text');
|
|
1685
|
+
t.setAttribute('class', 'ed-wave-buslabel');
|
|
1686
|
+
t.setAttribute('x', String((x0 + x1) / 2));
|
|
1687
|
+
t.setAttribute('y', String(mid + 4));
|
|
1688
|
+
t.setAttribute('text-anchor', 'middle');
|
|
1689
|
+
t.textContent = text;
|
|
1690
|
+
svg.appendChild(t);
|
|
1691
|
+
}
|
|
1692
|
+
labelSlot++;
|
|
1693
|
+
} else if (brick === 'xxx') {
|
|
1694
|
+
const rect = d.createElementNS(SVGNS, 'rect');
|
|
1695
|
+
rect.setAttribute('class', 'ed-wave-x');
|
|
1696
|
+
rect.setAttribute('x', String(x0));
|
|
1697
|
+
rect.setAttribute('y', String(hi));
|
|
1698
|
+
rect.setAttribute('width', String(x1 - x0));
|
|
1699
|
+
rect.setAttribute('height', String(lo - hi));
|
|
1700
|
+
svg.appendChild(rect);
|
|
1701
|
+
} else if (isClock(brick)) {
|
|
1702
|
+
const xm = (x0 + x1) / 2;
|
|
1703
|
+
const p = d.createElementNS(SVGNS, 'path');
|
|
1704
|
+
p.setAttribute('class', 'ed-wave-line ed-wave-clock');
|
|
1705
|
+
p.setAttribute('d', brick === 'nclk'
|
|
1706
|
+
? 'M' + x0 + ',' + lo + ' L' + x0 + ',' + hi + ' L' + xm + ',' + hi +
|
|
1707
|
+
' L' + xm + ',' + lo + ' L' + x1 + ',' + lo
|
|
1708
|
+
: 'M' + x0 + ',' + hi + ' L' + x0 + ',' + lo + ' L' + xm + ',' + lo +
|
|
1709
|
+
' L' + xm + ',' + hi + ' L' + x1 + ',' + hi);
|
|
1710
|
+
svg.appendChild(p);
|
|
1711
|
+
} else {
|
|
1712
|
+
const y = bandOf(brick)[0];
|
|
1713
|
+
const line = d.createElementNS(SVGNS, 'line');
|
|
1714
|
+
line.setAttribute('class', brick === 'uuu' || brick === 'ddd'
|
|
1715
|
+
? 'ed-wave-line ed-wave-weak' : 'ed-wave-line');
|
|
1716
|
+
line.setAttribute('x1', String(x0));
|
|
1717
|
+
line.setAttribute('x2', String(x1));
|
|
1718
|
+
line.setAttribute('y1', String(y));
|
|
1719
|
+
line.setAttribute('y2', String(y));
|
|
1720
|
+
svg.appendChild(line);
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
// The edge into this run. Clock bricks draw their own.
|
|
1724
|
+
if (r > 0 && !isClock(brick) && !isClock(runs[r - 1].brick)) {
|
|
1725
|
+
const a = bandOf(runs[r - 1].brick);
|
|
1726
|
+
const b = bandOf(brick);
|
|
1727
|
+
const top = Math.min(a[0], a[1], b[0], b[1]);
|
|
1728
|
+
const bot = Math.max(a[0], a[1], b[0], b[1]);
|
|
1729
|
+
if (bot > top) {
|
|
1730
|
+
const edge = d.createElementNS(SVGNS, 'line');
|
|
1731
|
+
edge.setAttribute('class', 'ed-wave-edge');
|
|
1732
|
+
edge.setAttribute('x1', String(x0));
|
|
1733
|
+
edge.setAttribute('x2', String(x0));
|
|
1734
|
+
edge.setAttribute('y1', String(top));
|
|
1735
|
+
edge.setAttribute('y2', String(bot));
|
|
1736
|
+
svg.appendChild(edge);
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
// The gap marker, last so it sits over the trace it interrupts. The
|
|
1742
|
+
// engine draws one per `|`; drawing none made the two pictures disagree
|
|
1743
|
+
// on a lane whose levels agreed exactly, which is the quietest form of
|
|
1744
|
+
// the disagreement this overlay exists to surface.
|
|
1745
|
+
for (const c of gaps) {
|
|
1746
|
+
const xm = (c + 0.5) * cw;
|
|
1747
|
+
const mark = d.createElementNS(SVGNS, 'path');
|
|
1748
|
+
mark.setAttribute('class', 'ed-wave-gap');
|
|
1749
|
+
mark.setAttribute('d',
|
|
1750
|
+
'M' + (xm - 5) + ',' + (lo + 3) + ' L' + (xm + 1) + ',' + (mid) +
|
|
1751
|
+
' L' + (xm - 5) + ',' + (hi - 3) +
|
|
1752
|
+
' M' + (xm - 1) + ',' + (lo + 3) + ' L' + (xm + 5) + ',' + (mid) +
|
|
1753
|
+
' L' + (xm - 1) + ',' + (hi - 3));
|
|
1754
|
+
svg.appendChild(mark);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
// ── the keyboard on the drawing ───────────────────────────────────────
|
|
1759
|
+
/**
|
|
1760
|
+
* Every gesture the drawing has, without a pointer.
|
|
1761
|
+
*
|
|
1762
|
+
* The drawing was the one surface in this dialog a keyboard could not
|
|
1763
|
+
* operate AT ALL: painting was a mouse drag, and `selection` — which every
|
|
1764
|
+
* cycle operation reads — could only be made by dragging. There are FIVE
|
|
1765
|
+
* cycle buttons, and four of them refused outright for a keyboard user,
|
|
1766
|
+
* each with its own message a few lines above: 刪除 cycle and 複製 answer
|
|
1767
|
+
*「先選一段 cycle 再…」with no selection, and both 貼上 answer「剪貼簿是
|
|
1768
|
+
* 空的」because the only thing that fills the clipboard is 複製. The fifth,
|
|
1769
|
+
* 插入 cycle, is the one that worked — and `selection === null ? 0` means
|
|
1770
|
+
* it silently meant "at cycle 0" wherever the user was looking. This is
|
|
1771
|
+
* that `selection`, made with the arrow keys.
|
|
1772
|
+
*
|
|
1773
|
+
* The keymap is deliberately small and has no chords: arrows move,
|
|
1774
|
+
* Shift+arrow extends along the lane, Home/End jump to the ends of the
|
|
1775
|
+
* lane, a brush character paints, Space and Enter paint with whatever
|
|
1776
|
+
* brush is on. Everything else the dialog can do is a real `<button>` or
|
|
1777
|
+
* `<input>` that Tab already reaches.
|
|
1778
|
+
*
|
|
1779
|
+
* Two targets are accepted, and only two:
|
|
1780
|
+
* the canvas — the surface itself, `tabindex="0"`, so it is in the Tab
|
|
1781
|
+
* cycle and in the focus trap like every other control.
|
|
1782
|
+
* the panel — the dialog element, which holds the keyboard for exactly
|
|
1783
|
+
* one moment: right after it opens, where the ARIA pattern
|
|
1784
|
+
* puts it (T6k pins that). An arrow key there can only
|
|
1785
|
+
* mean the drawing, so it ENTERS the drawing — and does not
|
|
1786
|
+
* also move, because a cursor that appears one cell away
|
|
1787
|
+
* from where it was born never shows the user where it
|
|
1788
|
+
* started counting from.
|
|
1789
|
+
*/
|
|
1790
|
+
function handleDrawingKey(ev) {
|
|
1791
|
+
const onCanvas = canvas !== null && ev.target === canvas;
|
|
1792
|
+
const onPanel = ev.target === panel;
|
|
1793
|
+
if (!onCanvas && !onPanel) return false;
|
|
1794
|
+
const key = ev.key;
|
|
1795
|
+
const nav = key === 'ArrowLeft' || key === 'ArrowRight' ||
|
|
1796
|
+
key === 'ArrowUp' || key === 'ArrowDown' || key === 'Home' || key === 'End';
|
|
1797
|
+
if (onPanel) {
|
|
1798
|
+
if (!nav) return false;
|
|
1799
|
+
ev.preventDefault();
|
|
1800
|
+
ev.stopPropagation();
|
|
1801
|
+
enterDrawing();
|
|
1802
|
+
return true;
|
|
1803
|
+
}
|
|
1804
|
+
if (nav) {
|
|
1805
|
+
ev.preventDefault();
|
|
1806
|
+
ev.stopPropagation();
|
|
1807
|
+
moveCursor(key, ev.shiftKey === true);
|
|
1808
|
+
return true;
|
|
1809
|
+
}
|
|
1810
|
+
// A brush character both PICKS the brush and paints with it. Picking
|
|
1811
|
+
// without painting would need a second keystroke to do the thing the
|
|
1812
|
+
// user already said, and the toolbar's own brush buttons are still there
|
|
1813
|
+
// for「選起來待會再用」. `ev.shiftKey` is excluded so a shifted key can
|
|
1814
|
+
// never paint: with Shift the browser reports `H`, not `h`, and a brush
|
|
1815
|
+
// table that quietly lower-cased it would make Shift+arrow's neighbour
|
|
1816
|
+
// keys destructive.
|
|
1817
|
+
if (!ev.shiftKey && BRUSHES.indexOf(key) !== -1) {
|
|
1818
|
+
ev.preventDefault();
|
|
1819
|
+
ev.stopPropagation();
|
|
1820
|
+
brush = key;
|
|
1821
|
+
paintBrushState();
|
|
1822
|
+
paintAtCursor();
|
|
1823
|
+
return true;
|
|
1824
|
+
}
|
|
1825
|
+
if (key === ' ' || key === 'Enter') {
|
|
1826
|
+
ev.preventDefault();
|
|
1827
|
+
ev.stopPropagation();
|
|
1828
|
+
paintAtCursor();
|
|
1829
|
+
return true;
|
|
1830
|
+
}
|
|
1831
|
+
return false;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
/** Put the keyboard on the drawing, at the cell it last held (cell 0 of
|
|
1835
|
+
* lane 0 the first time). Never moves the cursor: entering and moving in
|
|
1836
|
+
* one keystroke hides where the cursor came from. */
|
|
1837
|
+
function enterDrawing() {
|
|
1838
|
+
if (canvas === null) return;
|
|
1839
|
+
const layout = geometry.layoutOf(store.doc, SIZES);
|
|
1840
|
+
if (layout.lanes.length === 0 || layout.cycles === 0) { canvas.focus(); return; }
|
|
1841
|
+
// A cursor, and NOT a selection. Arriving is not choosing: this fires on
|
|
1842
|
+
// a plain `Tab` onto the drawing, and a focus move that quietly arms
|
|
1843
|
+
// 刪除 cycle and 複製 with a range nobody drew is a destructive button
|
|
1844
|
+
// loaded by walking past it. The first arrow key — an act, not a
|
|
1845
|
+
// traversal — is what makes a selection, exactly as a pointer press
|
|
1846
|
+
// does, and `paintAtCursor` already falls back to the single cursor cell
|
|
1847
|
+
// when there is no selection to use.
|
|
1848
|
+
if (cursor === null) cursor = { laneIndex: 0, cycle: 0 };
|
|
1849
|
+
canvas.focus();
|
|
1850
|
+
repaintDrawing(layout);
|
|
1851
|
+
sayCursor(layout);
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
/**
|
|
1855
|
+
* Move the cell cursor, and carry `selection` with it.
|
|
1856
|
+
*
|
|
1857
|
+
* The two are one thing on purpose. `selection` is what every cycle
|
|
1858
|
+
* operation acts on and what `setCellRange` is given, and its shape —
|
|
1859
|
+
* ONE lane, a run of cycles — is exactly what a cursor plus Shift can
|
|
1860
|
+
* describe. A pointer press already collapses it to the pressed cell; a
|
|
1861
|
+
* plain arrow does the same, and Shift+←/→ keeps the anchor it was made
|
|
1862
|
+
* with. Shift+↑/↓ collapses: a selection that spanned two lanes is not a
|
|
1863
|
+
* thing this document model has, so pretending with a highlight would be
|
|
1864
|
+
* the drawing telling the user something the codec cannot honour.
|
|
1865
|
+
*/
|
|
1866
|
+
function moveCursor(key, extend) {
|
|
1867
|
+
const layout = geometry.layoutOf(store.doc, SIZES);
|
|
1868
|
+
if (layout.lanes.length === 0 || layout.cycles === 0) return;
|
|
1869
|
+
if (cursor === null) { enterDrawing(); return; }
|
|
1870
|
+
let lane = cursor.laneIndex;
|
|
1871
|
+
let cyc = cursor.cycle;
|
|
1872
|
+
if (key === 'ArrowLeft') cyc -= 1;
|
|
1873
|
+
else if (key === 'ArrowRight') cyc += 1;
|
|
1874
|
+
else if (key === 'ArrowUp') lane -= 1;
|
|
1875
|
+
else if (key === 'ArrowDown') lane += 1;
|
|
1876
|
+
else if (key === 'Home') cyc = 0;
|
|
1877
|
+
else if (key === 'End') cyc = layout.cycles - 1;
|
|
1878
|
+
lane = Math.max(0, Math.min(lane, layout.lanes.length - 1));
|
|
1879
|
+
cyc = Math.max(0, Math.min(cyc, layout.cycles - 1));
|
|
1880
|
+
const sameLane = lane === cursor.laneIndex;
|
|
1881
|
+
cursor = { laneIndex: lane, cycle: cyc };
|
|
1882
|
+
if (extend && sameLane && selection !== null && selection.laneIndex === lane) {
|
|
1883
|
+
selection = { laneIndex: lane, from: selection.from, to: cyc };
|
|
1884
|
+
} else {
|
|
1885
|
+
selection = { laneIndex: lane, from: cyc, to: cyc };
|
|
1886
|
+
}
|
|
1887
|
+
repaintDrawing(layout);
|
|
1888
|
+
scrollCursorIntoView(layout);
|
|
1889
|
+
sayCursor(layout);
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
/** Paint the cursor's run with the current brush: one gesture, one
|
|
1893
|
+
* `commit`, exactly like the pointer's `mouseup`. The range comes from
|
|
1894
|
+
* `selection` when it belongs to the cursor's own lane — which every
|
|
1895
|
+
* keyboard move guarantees — and from the single cursor cell otherwise,
|
|
1896
|
+
* so a selection left behind by a drag on ANOTHER lane can never make a
|
|
1897
|
+
* keystroke paint somewhere the cursor is not. */
|
|
1898
|
+
function paintAtCursor() {
|
|
1899
|
+
if (cursor === null) return;
|
|
1900
|
+
const sel = (selection !== null && selection.laneIndex === cursor.laneIndex)
|
|
1901
|
+
? selection : { laneIndex: cursor.laneIndex, from: cursor.cycle, to: cursor.cycle };
|
|
1902
|
+
const ch = brush;
|
|
1903
|
+
commit('paint', function (doc) {
|
|
1904
|
+
return codec.setCellRange(doc, sel.laneIndex, sel.from, sel.to, ch);
|
|
1905
|
+
});
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
/**
|
|
1909
|
+
* Say where the cursor is, in words.
|
|
1910
|
+
*
|
|
1911
|
+
* The rectangle is the answer for someone who can see it; the status line
|
|
1912
|
+
* carries `role="status"`, so this is the same answer for someone who
|
|
1913
|
+
* cannot. It names the LANE rather than its index because the index is the
|
|
1914
|
+
* codec's flattened display order and means nothing to the person reading.
|
|
1915
|
+
*
|
|
1916
|
+
* POSITION ONLY — no instructions. A live region is re-announced in full
|
|
1917
|
+
* every time it changes, and this changes on every arrow key, so a hint
|
|
1918
|
+
* appended here is a hint read out on every press of a key the user is
|
|
1919
|
+
* holding down. The hint is already in the two places it belongs and is
|
|
1920
|
+
* announced once each: the canvas's own `aria-label`, read when the
|
|
1921
|
+
* keyboard arrives on the drawing, and the opening status line.
|
|
1922
|
+
*/
|
|
1923
|
+
function sayCursor(layout) {
|
|
1924
|
+
if (cursor === null) return;
|
|
1925
|
+
const row = layout.lanes[cursor.laneIndex];
|
|
1926
|
+
const lane = row === undefined ? null : row.lane;
|
|
1927
|
+
const name = (lane !== null && typeof lane.name === 'string' && lane.name !== '')
|
|
1928
|
+
? lane.name : '第 ' + (cursor.laneIndex + 1) + ' 條 lane';
|
|
1929
|
+
const from = selection === null ? cursor.cycle : Math.min(selection.from, selection.to);
|
|
1930
|
+
const to = selection === null ? cursor.cycle : Math.max(selection.from, selection.to);
|
|
1931
|
+
const span = (selection !== null && selection.laneIndex === cursor.laneIndex && to > from)
|
|
1932
|
+
? ',選了 cycle ' + (from + 1) + '–' + (to + 1) : '';
|
|
1933
|
+
say('游標:' + name + ' cycle ' + (cursor.cycle + 1) + span);
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
/** The lane list and the drawing, repainted together and without touching
|
|
1937
|
+
* the store or the preview: a cursor move changes no document content, so
|
|
1938
|
+
* re-rendering the engine's preview beside it would be a round trip to
|
|
1939
|
+
* paint the identical picture. The lane list IS repainted, because
|
|
1940
|
+
* `.is-selected` on its rows is where "which lane am I on" is shown. */
|
|
1941
|
+
function repaintDrawing(layout) {
|
|
1942
|
+
const l = layout === undefined || layout === null
|
|
1943
|
+
? geometry.layoutOf(store.doc, SIZES) : layout;
|
|
1944
|
+
renderLanes(store.doc, l);
|
|
1945
|
+
renderCanvas(store.doc, l);
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
/**
|
|
1949
|
+
* Keep the cursor inside the canvas column's clip.
|
|
1950
|
+
*
|
|
1951
|
+
* `.ed-wave-canvas-wrap` is a scroll container — that is how a diagram
|
|
1952
|
+
* wider than the column is reached at all — so a cursor walked off its
|
|
1953
|
+
* right-hand edge is a keyboard cursor the user cannot see, which is the
|
|
1954
|
+
* one thing this task may not produce. Not a corner: MEASURED in this
|
|
1955
|
+
* session on the 800x600 viewport the browser suites use, with the fixture
|
|
1956
|
+
* this file's journey rows use, the column's `clientWidth` is 230px and the
|
|
1957
|
+
* drawing is 240px wide at 5 cycles — so the LAST cycle is already off the
|
|
1958
|
+
* clip before anything is inserted.
|
|
1959
|
+
*
|
|
1960
|
+
* The arithmetic is against the wrap's PADDING BOX (`clientLeft` /
|
|
1961
|
+
* `clientWidth`), which is the box `scrollLeft` moves; measuring against
|
|
1962
|
+
* `getBoundingClientRect().width` instead counts the border and leaves the
|
|
1963
|
+
* cell short of where it was asked to go.
|
|
1964
|
+
*/
|
|
1965
|
+
function scrollCursorIntoView(layout) {
|
|
1966
|
+
if (cursor === null || canvas === null) return;
|
|
1967
|
+
const box = geometry.cellRect(layout, cursor.laneIndex, cursor.cycle);
|
|
1968
|
+
if (box === null || typeof canvas.getBoundingClientRect !== 'function') return;
|
|
1969
|
+
const cbox = canvas.getBoundingClientRect();
|
|
1970
|
+
const wbox = canvasWrap.getBoundingClientRect();
|
|
1971
|
+
const left = (cbox.left + box.x) - (wbox.left + canvasWrap.clientLeft);
|
|
1972
|
+
const top = (cbox.top + box.y) - (wbox.top + canvasWrap.clientTop);
|
|
1973
|
+
const w = canvasWrap.clientWidth;
|
|
1974
|
+
const h = canvasWrap.clientHeight;
|
|
1975
|
+
if (left < 0) canvasWrap.scrollLeft += left;
|
|
1976
|
+
else if (left + box.width > w) canvasWrap.scrollLeft += left + box.width - w;
|
|
1977
|
+
if (top < 0) canvasWrap.scrollTop += top;
|
|
1978
|
+
else if (top + box.height > h) canvasWrap.scrollTop += top + box.height - h;
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
// ── the pointer ───────────────────────────────────────────────────────
|
|
1982
|
+
/**
|
|
1983
|
+
* A paint is a real drag: `mousedown` picks the first cycle, `mousemove`
|
|
1984
|
+
* extends the run, `mouseup` is the one moment the document changes.
|
|
1985
|
+
*
|
|
1986
|
+
* Nothing is applied while the button is down. One gesture is one
|
|
1987
|
+
* `setCellRange`, which is also what the run means — a bus dragged across
|
|
1988
|
+
* four cycles is one value with three continuations, not four values.
|
|
1989
|
+
*/
|
|
1990
|
+
function onCanvasDown(ev) {
|
|
1991
|
+
if (ev.button !== 0) return;
|
|
1992
|
+
const layout = geometry.layoutOf(store.doc, SIZES);
|
|
1993
|
+
const at = cellFromEvent(ev, layout);
|
|
1994
|
+
if (at === null) return;
|
|
1995
|
+
ev.preventDefault();
|
|
1996
|
+
drag = { laneIndex: at.laneIndex, from: at.cycle, to: at.cycle };
|
|
1997
|
+
selection = { laneIndex: at.laneIndex, from: at.cycle, to: at.cycle };
|
|
1998
|
+
// The keyboard follows the pointer, for two reasons that are really one:
|
|
1999
|
+
// the two cursors may not disagree about which cell is current, and
|
|
2000
|
+
// `preventDefault()` above suppresses the focus a press would otherwise
|
|
2001
|
+
// give a `tabindex` element — so without this, clicking the drawing and
|
|
2002
|
+
// then pressing `1` would paint wherever the keyboard had been left.
|
|
2003
|
+
cursor = { laneIndex: at.laneIndex, cycle: at.cycle };
|
|
2004
|
+
canvas.focus();
|
|
2005
|
+
renderCanvas(store.doc, layout);
|
|
2006
|
+
d.addEventListener('mousemove', onCanvasMove, true);
|
|
2007
|
+
d.addEventListener('mouseup', onCanvasUp, true);
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
function onCanvasMove(ev) {
|
|
2011
|
+
if (drag === null) return;
|
|
2012
|
+
// The button came up somewhere this page never heard about — outside the
|
|
2013
|
+
// window, or over the browser's own chrome. Without this the drag stays
|
|
2014
|
+
// armed, the next pointer move across the canvas keeps extending a
|
|
2015
|
+
// selection with nothing held down, and the following press commits a
|
|
2016
|
+
// range the user never drew. Read from the code, not measured: puppeteer
|
|
2017
|
+
// cannot withhold a `mouseup`.
|
|
2018
|
+
if (ev.buttons === 0) { onCanvasUp(); return; }
|
|
2019
|
+
const layout = geometry.layoutOf(store.doc, SIZES);
|
|
2020
|
+
const at = cellFromEvent(ev, layout);
|
|
2021
|
+
if (at === null) return;
|
|
2022
|
+
// The lane is fixed by the press: a drag that strays onto a neighbour is
|
|
2023
|
+
// still painting the lane it started on.
|
|
2024
|
+
drag.to = at.cycle;
|
|
2025
|
+
selection = { laneIndex: drag.laneIndex, from: drag.from, to: drag.to };
|
|
2026
|
+
// The drag's live end is where the cursor is, so a paint continued with
|
|
2027
|
+
// Shift+←/→ after the button comes up carries on from the same cell.
|
|
2028
|
+
cursor = { laneIndex: drag.laneIndex, cycle: drag.to };
|
|
2029
|
+
renderCanvas(store.doc, layout);
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
function onCanvasUp() {
|
|
2033
|
+
d.removeEventListener('mousemove', onCanvasMove, true);
|
|
2034
|
+
d.removeEventListener('mouseup', onCanvasUp, true);
|
|
2035
|
+
if (drag === null) return;
|
|
2036
|
+
const g = drag;
|
|
2037
|
+
drag = null;
|
|
2038
|
+
commit('paint', function (doc) {
|
|
2039
|
+
return codec.setCellRange(doc, g.laneIndex, g.from, g.to, brush);
|
|
2040
|
+
});
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
/** Pointer coordinates in the drawing's own space, then the codec's cell. */
|
|
2044
|
+
function cellFromEvent(ev, layout) {
|
|
2045
|
+
if (canvas === null) return null;
|
|
2046
|
+
const box = canvas.getBoundingClientRect();
|
|
2047
|
+
return geometry.cellAt(layout, ev.clientX - box.left, ev.clientY - box.top);
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
// ── the preview ───────────────────────────────────────────────────────
|
|
2051
|
+
/**
|
|
2052
|
+
* The engine's own rendering of the very same document.
|
|
2053
|
+
*
|
|
2054
|
+
* This is the check on the drawing, which is the only reason it is on
|
|
2055
|
+
* screen. It is rendered from a plain copy because wavedrom writes into the
|
|
2056
|
+
* source object it is handed and the store's documents are frozen.
|
|
2057
|
+
*/
|
|
2058
|
+
function renderPreview(doc) {
|
|
2059
|
+
const engine = opts.wavedrom;
|
|
2060
|
+
if (engine === null || engine === undefined ||
|
|
2061
|
+
typeof engine.RenderWaveForm !== 'function') {
|
|
2062
|
+
previewHost.textContent = '(這一頁沒有載入 WaveDrom,預覽從缺)';
|
|
2063
|
+
previewWrap.setAttribute('data-wave-preview', 'absent');
|
|
2064
|
+
return;
|
|
2065
|
+
}
|
|
2066
|
+
try {
|
|
2067
|
+
// `notFirstSignal` means "the skin is already on this page, do not
|
|
2068
|
+
// emit it again". True whenever a WaveDrom render has already run here,
|
|
2069
|
+
// which is always in the flow that reaches this editor — the entry
|
|
2070
|
+
// affordance only appears over a RENDERED diagram. The fallback to
|
|
2071
|
+
// `false` is for the page where it somehow has not: emitting the skin
|
|
2072
|
+
// is only wasteful, while omitting one that is not there leaves every
|
|
2073
|
+
// `<use>` unresolvable and the preview blank, which is the one state
|
|
2074
|
+
// this panel may never be in.
|
|
2075
|
+
// `socket` is a bare, global, un-namespaced id that the engine's own
|
|
2076
|
+
// skin `<defs>` defines — so ask for it INSIDE a WaveDrom-rendered SVG's
|
|
2077
|
+
// defs rather than anywhere on the page. A document carrying inline
|
|
2078
|
+
// HTML with `id="socket"` would otherwise convince this that the skin is
|
|
2079
|
+
// present on a page where no diagram had rendered, and the preview would
|
|
2080
|
+
// come up blank: the one state this panel may never be in.
|
|
2081
|
+
const skinOnPage = d.querySelector('svg.WaveDrom defs #socket') !== null;
|
|
2082
|
+
engine.RenderWaveForm(PREVIEW_INDEX, plainCopy(doc, 0),
|
|
2083
|
+
PREVIEW_ID_PREFIX, skinOnPage);
|
|
2084
|
+
previewWrap.setAttribute('data-wave-preview', 'ok');
|
|
2085
|
+
previewWrap.setAttribute('data-wave-preview-skin', skinOnPage ? 'shared' : 'own');
|
|
2086
|
+
} catch (e) {
|
|
2087
|
+
previewHost.textContent = 'WaveDrom 畫不出這份文件:' + String(e && e.message);
|
|
2088
|
+
previewWrap.setAttribute('data-wave-preview', 'failed');
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
// Mounted BEFORE the first paint, not after: the preview is rendered by the
|
|
2093
|
+
// engine's own `RenderWaveForm`, which resolves its target with
|
|
2094
|
+
// `getElementById` — an element that is not in the document yet is not
|
|
2095
|
+
// findable, and the engine's failure there is a null dereference several
|
|
2096
|
+
// frames away from the cause. Measured: rendering first left every preview
|
|
2097
|
+
// reading `failed` with "Cannot read properties of null".
|
|
2098
|
+
lockScroll();
|
|
2099
|
+
host.appendChild(overlay);
|
|
2100
|
+
panel.focus();
|
|
2101
|
+
render();
|
|
2102
|
+
// The opening line described a pointer and only a pointer, which is how a
|
|
2103
|
+
// surface ends up with no keyboard: the instructions never mentioned one.
|
|
2104
|
+
say('選一個電位,然後在波形上按住拖過去;或直接按方向鍵進波形,' +
|
|
2105
|
+
'Shift+左右鍵選一段,再按電位字元塗上去。');
|
|
2106
|
+
|
|
2107
|
+
return {
|
|
2108
|
+
el: overlay,
|
|
2109
|
+
store: store,
|
|
2110
|
+
close: close,
|
|
2111
|
+
refresh: render,
|
|
2112
|
+
lastPatch: function () { return lastPatch; },
|
|
2113
|
+
};
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
return {
|
|
2117
|
+
createWaveEditor: createWaveEditor,
|
|
2118
|
+
brickOf: brickOf,
|
|
2119
|
+
groupSpansOf: groupSpansOf,
|
|
2120
|
+
mountedInsideContent: mountedInsideContent,
|
|
2121
|
+
BRUSHES: BRUSHES,
|
|
2122
|
+
SIZES: SIZES,
|
|
2123
|
+
};
|
|
2124
|
+
});
|