@issuegraph/editor 0.3.0 → 0.10.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 +31 -6
- package/dist/a11y/baseline.d.ts +182 -0
- package/dist/a11y/baseline.d.ts.map +1 -0
- package/dist/a11y/baseline.js +430 -0
- package/dist/a11y/baseline.js.map +1 -0
- package/dist/audit/styles.d.ts +1 -1
- package/dist/audit/styles.d.ts.map +1 -1
- package/dist/audit/styles.js +1 -1
- package/dist/create/draft.d.ts +17 -0
- package/dist/create/draft.d.ts.map +1 -1
- package/dist/create/draft.js +19 -0
- package/dist/create/draft.js.map +1 -1
- package/dist/create/keys.d.ts +70 -5
- package/dist/create/keys.d.ts.map +1 -1
- package/dist/create/keys.js +78 -17
- package/dist/create/keys.js.map +1 -1
- package/dist/firstpass/candidates.d.ts +17 -1
- package/dist/firstpass/candidates.d.ts.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -2
- package/dist/index.js.map +1 -1
- package/dist/overlay/render.d.ts.map +1 -1
- package/dist/overlay/render.js +54 -14
- package/dist/overlay/render.js.map +1 -1
- package/dist/overlay/styles.d.ts +1 -1
- package/dist/overlay/styles.d.ts.map +1 -1
- package/dist/overlay/styles.js +40 -0
- package/dist/overlay/styles.js.map +1 -1
- package/dist/picker/render.d.ts +26 -16
- package/dist/picker/render.d.ts.map +1 -1
- package/dist/picker/render.js +25 -36
- package/dist/picker/render.js.map +1 -1
- package/dist/picker/styles.d.ts +7 -7
- package/dist/picker/styles.d.ts.map +1 -1
- package/dist/picker/styles.js +7 -36
- package/dist/picker/styles.js.map +1 -1
- package/dist/picker/view.d.ts +8 -4
- package/dist/picker/view.d.ts.map +1 -1
- package/dist/picker/view.js +4 -3
- package/dist/picker/view.js.map +1 -1
- package/dist/picker/words.d.ts +12 -10
- package/dist/picker/words.d.ts.map +1 -1
- package/dist/picker/words.js +12 -8
- package/dist/picker/words.js.map +1 -1
- package/dist/scale/ladder.d.ts.map +1 -1
- package/dist/scale/ladder.js +10 -0
- package/dist/scale/ladder.js.map +1 -1
- package/dist/scale/render.d.ts.map +1 -1
- package/dist/scale/render.js +9 -1
- package/dist/scale/render.js.map +1 -1
- package/dist/workspace/chrome.d.ts +1 -1
- package/dist/workspace/chrome.d.ts.map +1 -1
- package/dist/workspace/chrome.js +48 -0
- package/dist/workspace/chrome.js.map +1 -1
- package/dist/workspace/firstpass.d.ts +166 -0
- package/dist/workspace/firstpass.d.ts.map +1 -0
- package/dist/workspace/firstpass.js +151 -0
- package/dist/workspace/firstpass.js.map +1 -0
- package/dist/workspace/host.d.ts +212 -10
- package/dist/workspace/host.d.ts.map +1 -1
- package/dist/workspace/host.js +434 -42
- package/dist/workspace/host.js.map +1 -1
- package/dist/workspace/inspector.d.ts +45 -1
- package/dist/workspace/inspector.d.ts.map +1 -1
- package/dist/workspace/inspector.js +11 -0
- package/dist/workspace/inspector.js.map +1 -1
- package/dist/workspace/mount.d.ts +52 -5
- package/dist/workspace/mount.d.ts.map +1 -1
- package/dist/workspace/mount.js +1391 -56
- package/dist/workspace/mount.js.map +1 -1
- package/dist/workspace/rail.d.ts.map +1 -1
- package/dist/workspace/rail.js +12 -0
- package/dist/workspace/rail.js.map +1 -1
- package/dist/workspace/recovery.d.ts +155 -0
- package/dist/workspace/recovery.d.ts.map +1 -0
- package/dist/workspace/recovery.js +156 -0
- package/dist/workspace/recovery.js.map +1 -0
- package/dist/workspace/render.d.ts +402 -1
- package/dist/workspace/render.d.ts.map +1 -1
- package/dist/workspace/render.js +947 -36
- package/dist/workspace/render.js.map +1 -1
- package/dist/workspace/styles.d.ts +1 -1
- package/dist/workspace/styles.d.ts.map +1 -1
- package/dist/workspace/styles.js +554 -41
- package/dist/workspace/styles.js.map +1 -1
- package/package.json +3 -3
package/dist/workspace/mount.js
CHANGED
|
@@ -46,17 +46,24 @@
|
|
|
46
46
|
* document rather than on a window.
|
|
47
47
|
*/
|
|
48
48
|
import { edgeIdentity } from '@issuegraph/core';
|
|
49
|
+
import { nextDocument } from '@issuegraph/store';
|
|
49
50
|
import { navigate, renderViewer, resolveTheme, } from '@issuegraph/viewer';
|
|
50
|
-
import {
|
|
51
|
+
import { CONTROL_ATTRIBUTES } from "../a11y/baseline.js";
|
|
52
|
+
import { isChoosingKind } from "../create/draft.js";
|
|
53
|
+
import { KIND_KEYS, keyIntent } from "../create/keys.js";
|
|
51
54
|
import { pickerPlacement } from "../create/placement.js";
|
|
55
|
+
import { firstPassIntent } from "../firstpass/keys.js";
|
|
56
|
+
import { ANSWER_ATTRIBUTE, renderFirstPass } from "../firstpass/render.js";
|
|
57
|
+
import { firstPassStylesheet } from "../firstpass/styles.js";
|
|
52
58
|
import { STATE_ATTRIBUTE, overlayFor } from "../overlay/grammar.js";
|
|
53
59
|
import { overlaysFor } from "../overlay/projected.js";
|
|
54
60
|
import { renderPicker } from "../picker/render.js";
|
|
55
61
|
import { pickerStylesheet } from "../picker/styles.js";
|
|
56
62
|
import { scaleLadder } from "../scale/ladder.js";
|
|
57
63
|
import { mountStylesheet } from "./chrome.js";
|
|
58
|
-
import { INITIAL_HOST_STATE,
|
|
59
|
-
import {
|
|
64
|
+
import { INITIAL_HOST_STATE, editCarrier, railRowAt, railSlackFor, railWindowTarget, reconcileHost, reduceHost, targetMatches, } from "./host.js";
|
|
65
|
+
import { conflictDiff } from "./recovery.js";
|
|
66
|
+
import { renderWorkspace, } from "./render.js";
|
|
60
67
|
import { selectedEdgeId, selectedKey } from "./selection.js";
|
|
61
68
|
/** What the canvas zone draws: the editor's scale ladder, or the viewer's tree projection. */
|
|
62
69
|
export const CANVAS_MODES = Object.freeze(['neighbourhood', 'tree']);
|
|
@@ -69,6 +76,40 @@ const DRAG_THRESHOLD = 6;
|
|
|
69
76
|
const KEY_ATTRIBUTE = 'data-ig-key';
|
|
70
77
|
const GROUP_ATTRIBUTE = 'data-ig-group';
|
|
71
78
|
const COMMAND_ATTRIBUTE = 'data-ig-command';
|
|
79
|
+
/**
|
|
80
|
+
* Which subject a command acts on, when one command names several.
|
|
81
|
+
*
|
|
82
|
+
* Named here because this file now READS it in two places rather than one —
|
|
83
|
+
* `controlAnswer` turns it into the dispatch's `target`, and the focus token
|
|
84
|
+
* below uses it to tell one `select-edge` from the next. A third bare literal for an
|
|
85
|
+
* attribute the renderer writes on every relationship row and every recovery
|
|
86
|
+
* button is how a typo becomes a control that silently stops matching.
|
|
87
|
+
*/
|
|
88
|
+
const TARGET_ATTRIBUTE = 'data-ig-target';
|
|
89
|
+
/**
|
|
90
|
+
* Commands that name ONE control in two states.
|
|
91
|
+
*
|
|
92
|
+
* The isolated-issues chip redraws the SAME button with its command flipped
|
|
93
|
+
* (`scale/render.ts`), so a focus token comparing the raw command rejects the
|
|
94
|
+
* replacement and sends the reader to the rail — and that control, like the
|
|
95
|
+
* conflict disclosure, is one you press a second time to undo. It is this
|
|
96
|
+
* pull request's own defect wearing a different attribute value, which is
|
|
97
|
+
* why it is worth a table rather than a special case: the next toggle gets
|
|
98
|
+
* one line here instead of a new bug.
|
|
99
|
+
*
|
|
100
|
+
* A TABLE, NOT A PREFIX RULE. `open-` / `close-` looks like a pattern and is
|
|
101
|
+
* not one — `first-pass` and `first-pass-close` are two different controls in
|
|
102
|
+
* two different places, and a rule that folded them would restore focus onto
|
|
103
|
+
* the wrong one.
|
|
104
|
+
*/
|
|
105
|
+
const TOGGLE_IDENTITY = Object.freeze({
|
|
106
|
+
'open-isolated': 'isolated',
|
|
107
|
+
'close-isolated': 'isolated',
|
|
108
|
+
});
|
|
109
|
+
/** What a control is, rather than which of its states is showing. */
|
|
110
|
+
function controlIdentity(control) {
|
|
111
|
+
return TOGGLE_IDENTITY[control] ?? control;
|
|
112
|
+
}
|
|
72
113
|
/**
|
|
73
114
|
* An element, recognised by what it can do.
|
|
74
115
|
*
|
|
@@ -82,6 +123,9 @@ function isElement(target) {
|
|
|
82
123
|
function isFocusable(node) {
|
|
83
124
|
return node !== null && node !== undefined && 'focus' in node;
|
|
84
125
|
}
|
|
126
|
+
/** The two refusals, allocated once: they carry no per-press data. */
|
|
127
|
+
const REFUSED_INPUT = Object.freeze({ kind: 'refused', reason: 'input' });
|
|
128
|
+
const REFUSED_INERT = Object.freeze({ kind: 'refused', reason: 'inert' });
|
|
85
129
|
function isInput(node) {
|
|
86
130
|
return node !== null && node !== undefined && node.tagName === 'INPUT';
|
|
87
131
|
}
|
|
@@ -92,6 +136,36 @@ function isComposing(event) {
|
|
|
92
136
|
function withKey(scope, key) {
|
|
93
137
|
return [...scope.querySelectorAll(`[${KEY_ATTRIBUTE}]`)].filter((node) => node.getAttribute(KEY_ATTRIBUTE) === key);
|
|
94
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* The document the CANVAS draws: the host's document without its host facts.
|
|
141
|
+
*
|
|
142
|
+
* The rail draws the header, the NOW list and the freshness stamp — every
|
|
143
|
+
* host fact is whole-order, and the rail is the workspace's order surface. The
|
|
144
|
+
* scale ladder's focus already drops `host` for the graph canvas; the tree
|
|
145
|
+
* canvas renders the whole document and so drew a second header and a second
|
|
146
|
+
* refresh control in the same workspace. One place strips it for both.
|
|
147
|
+
*/
|
|
148
|
+
/**
|
|
149
|
+
* The host facts a SECONDARY view keeps: what is true, never what is drawn.
|
|
150
|
+
*
|
|
151
|
+
* The rail owns this workspace's one header, NOW row and freshness stamp, and
|
|
152
|
+
* carrying them onto a second view would draw them twice — that is why the
|
|
153
|
+
* facts are stripped here at all, and it stays true.
|
|
154
|
+
*
|
|
155
|
+
* THE CONDITION IS NOT ONE OF THEM. It is the host's account of WHY the panel
|
|
156
|
+
* is short, and the viewer uses it to refuse to invent a different account:
|
|
157
|
+
* without it, a canvas beside an explained rail falls back to "no issue in this
|
|
158
|
+
* document declares a relationship", which under a stated `error` is a claim
|
|
159
|
+
* about something else and contradicts the rail. It draws nothing on its own
|
|
160
|
+
* under `chrome: false`, so keeping it costs no chrome — it only stops a second
|
|
161
|
+
* view explaining the same emptiness a different, false way.
|
|
162
|
+
*/
|
|
163
|
+
function withoutChrome(document) {
|
|
164
|
+
if (document.host === undefined)
|
|
165
|
+
return document;
|
|
166
|
+
const { host, ...rest } = document;
|
|
167
|
+
return host.condition === undefined ? rest : { ...rest, host: { condition: host.condition } };
|
|
168
|
+
}
|
|
95
169
|
/**
|
|
96
170
|
* Mount the workspace into an element.
|
|
97
171
|
*
|
|
@@ -114,10 +188,95 @@ export function mountWorkspace(element, options) {
|
|
|
114
188
|
let drawn = null;
|
|
115
189
|
// A rail row to focus once the window has been re-cut around it.
|
|
116
190
|
let pendingFocus = null;
|
|
191
|
+
// The keys currently holding a control they activated. See `onKeydown`'s first
|
|
192
|
+
// arm for why this is a fact about the PRESS and not a question asked of
|
|
193
|
+
// whatever holds focus by the time the repeats arrive.
|
|
194
|
+
//
|
|
195
|
+
// A SET RATHER THAN THE LATEST KEY. Two can be down at once — hold `Space` on
|
|
196
|
+
// one control and press `Enter` on another — and a scalar answers that by
|
|
197
|
+
// forgetting the first, so its repeats stop being recognised as its own and go
|
|
198
|
+
// wherever focus has since moved. That is not a case to guard; it is a case
|
|
199
|
+
// the representation should not be able to express, which is what this is.
|
|
200
|
+
const activating = new Set();
|
|
117
201
|
let pressed = null;
|
|
118
202
|
// Whether the last render drew the target search, so focus moves into it on
|
|
119
203
|
// the render that OPENS it and not on every render while it stays open.
|
|
120
204
|
let searchWasOpen = false;
|
|
205
|
+
// The phase the last render drew, so the overlay is entered once rather than
|
|
206
|
+
// on every redraw, and left exactly once.
|
|
207
|
+
let firstPassWas = 'closed';
|
|
208
|
+
// Where focus was when the overlay went up, so it can be given back when the
|
|
209
|
+
// overlay comes down. `mount.ts` already learned this lesson on the create
|
|
210
|
+
// flow: a surface removed with focus inside it leaves `activeElement` on the
|
|
211
|
+
// body, and the keydown listener is on the element — so the whole workspace
|
|
212
|
+
// goes keyboard-dead until the reader clicks something.
|
|
213
|
+
let focusBeforeFirstPass = null;
|
|
214
|
+
/** The scanner the live lifecycle belongs to, so a swap can end it. */
|
|
215
|
+
let firstPassSource = options.firstPass?.source ?? null;
|
|
216
|
+
/**
|
|
217
|
+
* Which write each applied candidate created.
|
|
218
|
+
*
|
|
219
|
+
* THE ONLY HANDLE THAT IDENTIFIES A WRITE, and it exists nowhere else: the
|
|
220
|
+
* `MutationId` is minted by `store.propose` and seen only here, while the
|
|
221
|
+
* create's own `kind`/`from`/`to` does NOT identify it — `candidates.ts` mints
|
|
222
|
+
* a distinct id per finding precisely so two detectors proposing the same pair
|
|
223
|
+
* stay two questions, and an older failed write can carry the same triple. An
|
|
224
|
+
* earlier revision matched structurally and could have discarded the wrong
|
|
225
|
+
* record. Cleared when the queue closes.
|
|
226
|
+
*/
|
|
227
|
+
const appliedWrites = new Map();
|
|
228
|
+
/**
|
|
229
|
+
* The issue each unsettled write is about, answered once and then remembered.
|
|
230
|
+
*
|
|
231
|
+
* THE ANSWER GOES STALE, WHICH IS WHY IT IS KEPT RATHER THAN RE-ASKED. A
|
|
232
|
+
* refusal reaches the panel through the ledger, long after the act that
|
|
233
|
+
* caused it, and `editCarrier` reads the LANDED document — so between the two
|
|
234
|
+
* moments a sibling write can land and take the relationship away, leaving
|
|
235
|
+
* that call with nothing but the edge's identity. An identity records which
|
|
236
|
+
* two issues a relationship was between, and for a SYMMETRIC field it does not
|
|
237
|
+
* record which of them declared it: `edgeIdentity` sorts those endpoints, so
|
|
238
|
+
* the declaring end is not in the string at all. Re-asking therefore answered a
|
|
239
|
+
* different issue from the one the edit went out under, for exactly the pairs
|
|
240
|
+
* whose stored `from` sorted after their `to` — and `renderWorkspace` draws a
|
|
241
|
+
* refusal only on the panel it names, so it was drawn where the reader was
|
|
242
|
+
* not. Asked once, there is nothing for a second answer to disagree with.
|
|
243
|
+
*
|
|
244
|
+
* TWO WAYS IN, AND THE EARLIER ONE WINS. `reduceHost` decides the carrier as
|
|
245
|
+
* it emits the edit — before `store.propose` is even called, from the
|
|
246
|
+
* document as it stood when the reader acted — and `perform` records it under
|
|
247
|
+
* the identity the store mints. A write this mount did not emit (a host
|
|
248
|
+
* proposing on the same store) has no such moment, so it is answered the first
|
|
249
|
+
* time this render sees it in the ledger, which is the earliest moment there
|
|
250
|
+
* is. Neither is ever recomputed.
|
|
251
|
+
*
|
|
252
|
+
* ITS LIFETIME IS THE LEDGER'S, exactly. A record that lands is REMOVED from
|
|
253
|
+
* `snapshot.writes` — that is the store's own contract — and a refused or
|
|
254
|
+
* failed one stays until the reader discards it, which is precisely as long as
|
|
255
|
+
* it can still be drawn. So the map is pruned to the ledger's own keys on
|
|
256
|
+
* every render and needs no rule of its own. Clearing it beside
|
|
257
|
+
* `appliedWrites` was the alternative and is wrong in both directions: those
|
|
258
|
+
* clears are the first pass's lifecycle, not the write ledger's, so a queue
|
|
259
|
+
* that closes over a still-refused write would forget where to state it, and
|
|
260
|
+
* a write settling with no queue in sight would never be forgotten at all.
|
|
261
|
+
*/
|
|
262
|
+
const writeCarriers = new Map();
|
|
263
|
+
/**
|
|
264
|
+
* The conflict whose `retry on latest` is the last one the reader pressed.
|
|
265
|
+
*
|
|
266
|
+
* WITHOUT IT, `hydrationError` IS THE WRONG FACT TO DRAW. The store sets that
|
|
267
|
+
* field from ANY failed read — a host's own `refresh()`, a background
|
|
268
|
+
* rehydrate, a failed first load — and attributes it to no mutation. Painted
|
|
269
|
+
* on every conflict card it says "could not read the newest version" on cards
|
|
270
|
+
* whose button nobody pressed, and on all of them at once when two conflicts
|
|
271
|
+
* stand. The card's claim is about a control the reader operated, so the
|
|
272
|
+
* shell records which control that was; the store cannot answer it.
|
|
273
|
+
*
|
|
274
|
+
* CLEARED WHEN THE READ SUCCEEDS, which the ledger cannot express: a resolve
|
|
275
|
+
* that reads fine and is then refused leaves an `invalid` record and no
|
|
276
|
+
* hydration error, and a stale flag here would keep explaining a failure that
|
|
277
|
+
* did not happen.
|
|
278
|
+
*/
|
|
279
|
+
let refreshFailedFor = null;
|
|
121
280
|
const railCount = () => current.railCount ?? MOUNT_RAIL_COUNT;
|
|
122
281
|
const theme = () => resolveTheme(current.theme);
|
|
123
282
|
const el = (tag, attributes = {}, children = []) => {
|
|
@@ -150,7 +309,10 @@ export function mountWorkspace(element, options) {
|
|
|
150
309
|
const perform = (effect) => {
|
|
151
310
|
switch (effect.kind) {
|
|
152
311
|
case 'propose':
|
|
153
|
-
|
|
312
|
+
// THE CARRIER IS RECORDED, NOT RE-DERIVED. See `writeCarriers`: the
|
|
313
|
+
// reducer decided it from the document the reader acted on, and the
|
|
314
|
+
// `MutationId` that binds the two exists only here.
|
|
315
|
+
writeCarriers.set(store.propose(effect.proposal).mutationId, effect.carrier);
|
|
154
316
|
return;
|
|
155
317
|
case 'retry': {
|
|
156
318
|
// A conflict retries against the LATEST document. The store owns that
|
|
@@ -158,6 +320,10 @@ export function mountWorkspace(element, options) {
|
|
|
158
320
|
// operation, so there is nothing for the mount to sequence.
|
|
159
321
|
const record = store.getSnapshot().writes.find((each) => each.mutationId === effect.mutationId);
|
|
160
322
|
if (record?.state === 'conflict') {
|
|
323
|
+
// WHOSE READ THIS IS, recorded as the press happens. See
|
|
324
|
+
// `refreshFailedFor`: after the fact there is nothing that ties the
|
|
325
|
+
// store's hydration error to the edit it was read for.
|
|
326
|
+
refreshFailedFor = effect.mutationId;
|
|
161
327
|
void store.retryOnLatest(effect.mutationId);
|
|
162
328
|
}
|
|
163
329
|
else {
|
|
@@ -171,30 +337,199 @@ export function mountWorkspace(element, options) {
|
|
|
171
337
|
case 'dismiss-change':
|
|
172
338
|
store.dismissChange();
|
|
173
339
|
return;
|
|
340
|
+
case 'find-candidates': {
|
|
341
|
+
const option = current.firstPass;
|
|
342
|
+
// UNREACHABLE WITHOUT A BUNDLE, because the command is withheld at the
|
|
343
|
+
// listener — but written as a guard rather than an assertion, because
|
|
344
|
+
// `update()` can take the bundle away between the click and this call.
|
|
345
|
+
if (option === undefined) {
|
|
346
|
+
// CLOSED, NOT FAILED. `failed` is a state this surface DRAWS, in the
|
|
347
|
+
// host's own words — and with no bundle there are no words and no
|
|
348
|
+
// overlay, so a failed phase here is an invisible one that blocks the
|
|
349
|
+
// workspace's keyboard with no control to close it. The DOM guard
|
|
350
|
+
// withholds this command, but `handle.dispatch` is public and reaches
|
|
351
|
+
// the reducer directly, so the shell answers for that route too.
|
|
352
|
+
dispatch({ kind: 'first-pass', command: { kind: 'close' } });
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
// THE OPEN IS REAL, SO THE DRAFT GOES NOW. Only the shell knows a scan
|
|
356
|
+
// will actually run — the reducer cannot tell this open from one about
|
|
357
|
+
// to be refused for want of a source, and clearing on both destroyed a
|
|
358
|
+
// draft for a queue that never appeared. Both routes into the queue, the
|
|
359
|
+
// attribute and `handle.dispatch`, arrive here.
|
|
360
|
+
dispatch({ kind: 'control', name: 'cancel' });
|
|
361
|
+
const { scan } = effect;
|
|
362
|
+
// STARTED FROM A CALLBACK, so a host whose `findCandidates` throws
|
|
363
|
+
// BEFORE returning its promise — reading its own state, building a
|
|
364
|
+
// request — reaches the same failed scan as one whose promise rejects.
|
|
365
|
+
// Called directly, that throw escapes the `.catch` entirely and unwinds
|
|
366
|
+
// through the click handler, leaving the phase `scanning` for good.
|
|
367
|
+
void Promise.resolve()
|
|
368
|
+
.then(() => option.source.findCandidates())
|
|
369
|
+
.then((candidates) => {
|
|
370
|
+
dispatch({ kind: 'first-pass', command: { kind: 'candidates', scan, candidates } });
|
|
371
|
+
})
|
|
372
|
+
.catch(() => {
|
|
373
|
+
// THE REASON IS THE HOST'S AND IS NOT READ. A failed scan is a phase
|
|
374
|
+
// this surface draws in the host's own words; adopting the rejection
|
|
375
|
+
// value would be this package writing a sentence about someone
|
|
376
|
+
// else's tracker.
|
|
377
|
+
dispatch({ kind: 'first-pass', command: { kind: 'scan-failed', scan } });
|
|
378
|
+
});
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
case 'first-pass-apply': {
|
|
382
|
+
// THIS CANDIDATE'S WRITE MAY ALREADY BE OUT THERE. `⌫` on an answer whose
|
|
383
|
+
// create is still `pending` steps the queue back but takes nothing away,
|
|
384
|
+
// because `discardMine` declines a pending record — so answering `Y`
|
|
385
|
+
// again would propose the SAME create a second time, and when the first
|
|
386
|
+
// lands the second turns `invalid` and shows the reader an error about a
|
|
387
|
+
// relationship that now exists. One consent, one write: the handle is
|
|
388
|
+
// kept and nothing new is proposed.
|
|
389
|
+
if (pendingWriteFor(effect.candidateId) !== undefined)
|
|
390
|
+
return;
|
|
391
|
+
// AND THE RELATIONSHIP ITSELF MAY ALREADY BE THERE. The check above is
|
|
392
|
+
// per candidate, and a candidate is not a relationship: two findings
|
|
393
|
+
// with different ids may propose the same pair (`candidates.ts` keeps
|
|
394
|
+
// them apart on purpose), and a close-and-reopen starts a fresh queue
|
|
395
|
+
// over a document the earlier answer has since written to. Either way
|
|
396
|
+
// the store refuses the second create as a duplicate — correctly — and
|
|
397
|
+
// the reader is left an `invalid` record about a relationship that
|
|
398
|
+
// exists.
|
|
399
|
+
// LANDED OR ON ITS WAY, AND NOTHING ELSE. Asked of the two records that
|
|
400
|
+
// answer it directly: `landed` says the relationship is there, and a
|
|
401
|
+
// `pending-write` state says one is being created right now. An edge
|
|
402
|
+
// whose only write FAILED is precisely NOT there, so a second consent is
|
|
403
|
+
// free to try again.
|
|
404
|
+
// ASKED OF `landed`, NOT INFERRED FROM "no unsettled write". An earlier
|
|
405
|
+
// revision read a landed edge as one with an empty `writes` — which a
|
|
406
|
+
// landed edge carrying any settled overlay, a failed DELETE among them,
|
|
407
|
+
// is not. It then proposed a create for a relationship that was still
|
|
408
|
+
// there, and the store recorded the invalid duplicate.
|
|
409
|
+
const { proposal } = effect;
|
|
410
|
+
if (proposal.op === 'create') {
|
|
411
|
+
const id = edgeIdentity(proposal.kind, proposal.from, proposal.to);
|
|
412
|
+
const snapshot = store.getSnapshot();
|
|
413
|
+
// ASKED OF THE STORE'S OWN RULE, not enumerated here. `nextDocument`
|
|
414
|
+
// is the store's answer to "what would this edit produce if it landed
|
|
415
|
+
// exactly as proposed", and it answers for every operation the store
|
|
416
|
+
// has: a create adds the edge, a delete removes it, and a retype or a
|
|
417
|
+
// flip replaces it with a different identity — which removes the one
|
|
418
|
+
// the reader is being asked about just as surely as a delete does.
|
|
419
|
+
// FOUR EARLIER VERSIONS RECONSTRUCTED THAT ANSWER and each was wrong
|
|
420
|
+
// in a way only the store's internals reveal — a failed write still
|
|
421
|
+
// projects, a landed edge can carry a settled overlay, a pending
|
|
422
|
+
// delete is marked `pending-write` too, and a retype removes an edge
|
|
423
|
+
// without being a delete. Every one of them suppressed a consent that
|
|
424
|
+
// then wrote nothing, showed nothing, and was already decided. So the
|
|
425
|
+
// enumeration is the store's, and a fifth operation is covered the day
|
|
426
|
+
// the store learns about one. See autnmy/issuegraph#142.
|
|
427
|
+
const landedNow = { issues: snapshot.issues, edges: snapshot.landed };
|
|
428
|
+
const isThere = (document___) => document___.edges.some((edge) => edge.id === id);
|
|
429
|
+
const already = isThere(landedNow);
|
|
430
|
+
let coming = false;
|
|
431
|
+
let going = false;
|
|
432
|
+
for (const write of snapshot.writes) {
|
|
433
|
+
if (write.state !== 'pending')
|
|
434
|
+
continue;
|
|
435
|
+
const after = isThere(nextDocument(landedNow, write.mutation));
|
|
436
|
+
if (after && !already)
|
|
437
|
+
coming = true;
|
|
438
|
+
if (!after && already)
|
|
439
|
+
going = true;
|
|
440
|
+
}
|
|
441
|
+
// THERE, AND NOT ON ITS WAY OUT. A landed edge under a pending edit
|
|
442
|
+
// that would remove it is a relationship the reader may be about to
|
|
443
|
+
// lose, so their consent goes to the store and is adjudicated there —
|
|
444
|
+
// visible either way, which is the difference this guard exists to
|
|
445
|
+
// preserve.
|
|
446
|
+
if ((already && !going) || coming)
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const handle = store.propose(effect.proposal);
|
|
450
|
+
appliedWrites.set(effect.candidateId, handle.mutationId);
|
|
451
|
+
// AND THE SAME RECORDING AS THE PLAIN ROUTE, because this is the same
|
|
452
|
+
// kind of event: a write going out with a panel it belongs to. The
|
|
453
|
+
// effect carries one now — it used to be built without going through
|
|
454
|
+
// the reducer's emit funnel at all, so a refused first-pass answer had
|
|
455
|
+
// no panel to be stated on.
|
|
456
|
+
writeCarriers.set(handle.mutationId, effect.carrier);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
case 'first-pass-withdraw': {
|
|
460
|
+
// THE STORE'S OWN UNDO, AND NOTHING ELSE. `discardMine` is the whole of
|
|
461
|
+
// what a host may take back: it declines a `pending` record itself, and
|
|
462
|
+
// a create that LANDED has no record left at all — so in practice the
|
|
463
|
+
// withdrawable set is `invalid`, `failed` and `conflict`, and the
|
|
464
|
+
// reader's `⌫` on anything else steps the queue back and leaves the
|
|
465
|
+
// write alone.
|
|
466
|
+
// THAT REFUSAL IS NOT RESTATED HERE. An earlier revision guarded on
|
|
467
|
+
// `state !== 'pending'` before calling, which is a second copy of a rule
|
|
468
|
+
// the store already enforces — and a mutation test proved it: removing
|
|
469
|
+
// the guard changed no observable behaviour, because `discardMine` was
|
|
470
|
+
// refusing anyway. What is NOT done here is the thing that would be
|
|
471
|
+
// wrong: proposing a compensating `delete`, which is the second
|
|
472
|
+
// retraction path `firstpass/queue.ts` refuses in terms — "free to
|
|
473
|
+
// disagree with [the store] about what undoing a create means."
|
|
474
|
+
// BY THE WRITE'S OWN IDENTITY. See `appliedWrites`: a structural match on
|
|
475
|
+
// the create's fields cannot tell this answer's write from another
|
|
476
|
+
// candidate proposing the same pair, or from an older failed one.
|
|
477
|
+
const mutationId = appliedWrites.get(effect.candidateId);
|
|
478
|
+
if (mutationId === undefined)
|
|
479
|
+
return;
|
|
480
|
+
store.discardMine(mutationId);
|
|
481
|
+
// THE HANDLE OUTLIVES A REFUSED DISCARD. `discardMine` leaves a `pending`
|
|
482
|
+
// record exactly where it was, so forgetting the id here would lose the
|
|
483
|
+
// only thing that can find that write again — see the apply arm.
|
|
484
|
+
if (!recordedWriteFor(effect.candidateId)) {
|
|
485
|
+
appliedWrites.delete(effect.candidateId);
|
|
486
|
+
}
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
174
489
|
}
|
|
175
490
|
};
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
491
|
+
/**
|
|
492
|
+
* Take a reduction: keep its state, perform its effects, draw.
|
|
493
|
+
*
|
|
494
|
+
* SPLIT FROM `dispatch` SO ONE CALLER CAN LOOK BEFORE IT LEAPS. The keyboard
|
|
495
|
+
* arm has to know whether this reducer owns a command BEFORE it does anything
|
|
496
|
+
* at all, and `schedule()` is not nothing: it redraws, which replaces
|
|
497
|
+
* `surface.innerHTML` and with it the very button the reader is holding. A
|
|
498
|
+
* `<button>`'s `Space` activation lands on KEYUP, so destroying it first takes
|
|
499
|
+
* the host's own control off the keyboard just as surely as cancelling the
|
|
500
|
+
* press did. `reduceHost` is pure, so asking costs nothing and answers safely.
|
|
501
|
+
*/
|
|
502
|
+
const applyResult = (result) => {
|
|
180
503
|
state = result.state;
|
|
181
504
|
for (const effect of result.effects)
|
|
182
505
|
perform(effect);
|
|
183
506
|
schedule();
|
|
184
507
|
};
|
|
508
|
+
const dispatch = (command) => {
|
|
509
|
+
if (destroyed)
|
|
510
|
+
return;
|
|
511
|
+
applyResult(reduceHost(state, command, landed()));
|
|
512
|
+
};
|
|
185
513
|
const zone = (name) => surface.querySelector(`.ig-zone[data-zone="${name}"]`);
|
|
186
514
|
const pitch = () => theme().metrics['--ig-row-height'] + theme().metrics['--ig-space-tight'];
|
|
187
515
|
const kindChooser = (source, target) => {
|
|
188
516
|
const { words } = current;
|
|
189
517
|
const chooser = el('div', { class: 'ig-chrome-chooser', role: 'group', 'aria-label': words.picker.heading });
|
|
190
518
|
chooser.append(el('p', { class: 'ig-chrome-sentence' }, [`#${source} … ${target === null ? words.chooseKind : `#${target}`}`]));
|
|
519
|
+
// THE DIGITS ARE THE KEYBOARD'S OWN, READ OFF `KIND_KEYS`. They used to be
|
|
520
|
+
// computed here as `index + 1` over a `KINDS` alias in `host.ts` — a second
|
|
521
|
+
// construction of `create/keys.ts`'s table: the two agreed only because
|
|
522
|
+
// both walked `EDGE_FIELDS`, and nothing pinned them, so a chooser could
|
|
523
|
+
// have told the reader to press a key the key map resolved to a different
|
|
524
|
+
// kind. That alias is gone with its last caller, so this is now the only
|
|
525
|
+
// numbering in the package.
|
|
191
526
|
const list = el('div', { class: 'ig-chrome-kinds' });
|
|
192
|
-
|
|
193
|
-
list.append(button(`${
|
|
194
|
-
'data-ig-value':
|
|
195
|
-
'data-edge':
|
|
527
|
+
for (const entry of KIND_KEYS) {
|
|
528
|
+
list.append(button(`${entry.key} ${words.picker.kinds[entry.edgeKind]}`, 'kind', {
|
|
529
|
+
'data-ig-value': entry.edgeKind,
|
|
530
|
+
'data-edge': entry.edgeKind,
|
|
196
531
|
}));
|
|
197
|
-
}
|
|
532
|
+
}
|
|
198
533
|
chooser.append(list, button(words.cancel, 'cancel', { class: 'ig-chrome-button ig-chrome-quiet' }));
|
|
199
534
|
return chooser;
|
|
200
535
|
};
|
|
@@ -217,7 +552,7 @@ export function mountWorkspace(element, options) {
|
|
|
217
552
|
for (const match of matches) {
|
|
218
553
|
list.append(el('li', {}, [
|
|
219
554
|
button(`#${match.ref} ${match.title}`, 'target', {
|
|
220
|
-
|
|
555
|
+
[TARGET_ATTRIBUTE]: match.ref,
|
|
221
556
|
class: 'ig-chrome-button ig-chrome-match',
|
|
222
557
|
}),
|
|
223
558
|
]));
|
|
@@ -232,24 +567,31 @@ export function mountWorkspace(element, options) {
|
|
|
232
567
|
const { words } = current;
|
|
233
568
|
const panel = el('div', { class: 'ig-chrome', 'data-chrome': 'inspector' });
|
|
234
569
|
const edgeId = selectedEdgeId(state.selection);
|
|
235
|
-
const issue = selectedKey(state.selection);
|
|
236
570
|
const { draft } = state;
|
|
571
|
+
// WHAT IS LEFT HERE IS WHAT NEEDS A DOM. `+ add`, the numbered kind list
|
|
572
|
+
// and §17b's flip are `renderWorkspace`'s now — the flip because the
|
|
573
|
+
// statement it reverses is the selected relationship's ROW, and a picker
|
|
574
|
+
// composed into this same panel drew a second statement and a second flip
|
|
575
|
+
// one element from the first. What is left below draws one control, not two.
|
|
576
|
+
// The older half of this note stands: `+ add` and the numbered kind list
|
|
577
|
+
// are `renderWorkspace`'s now — they are markup, and a host rendering the
|
|
578
|
+
// package without mounting had a panel it could only read. The retype
|
|
579
|
+
// picker and the target search stay: the first is another package's
|
|
580
|
+
// renderer composed as markup, and the second is a live input over
|
|
581
|
+
// `state.targetQuery` with a caret to preserve, which a markup-only
|
|
582
|
+
// renderer cannot be.
|
|
583
|
+
//
|
|
584
|
+
// THE CHAIN'S EXCLUSIVITY IS NOW SPLIT ACROSS TWO LAYERS, and that is why
|
|
585
|
+
// `renderWorkspace` is told about the draft and the drop: it re-states the
|
|
586
|
+
// same guards for the steps it draws, rather than the two surfaces each
|
|
587
|
+
// drawing whatever they can see.
|
|
237
588
|
if (edgeId !== null) {
|
|
238
589
|
const picker = el('div', { class: 'ig-chrome-picker' });
|
|
239
590
|
// Package-rendered markup, escaped by the package.
|
|
240
591
|
picker.innerHTML = renderPicker(document, edgeId, { words: words.picker, theme: theme() }).markup;
|
|
241
592
|
panel.append(picker, button(words.deleteRelationship, 'delete', { class: 'ig-chrome-button ig-chrome-danger' }));
|
|
242
593
|
}
|
|
243
|
-
else if (draft.source === null) {
|
|
244
|
-
if (issue !== null)
|
|
245
|
-
panel.append(button(words.addRelationship, 'add'));
|
|
246
|
-
}
|
|
247
|
-
else if (draft.kind === null) {
|
|
248
|
-
// Placed at the drop point when a canvas drag got here; inline otherwise.
|
|
249
|
-
if (state.drop === null)
|
|
250
|
-
panel.append(kindChooser(draft.source, draft.target));
|
|
251
|
-
}
|
|
252
|
-
else if (draft.target === null) {
|
|
594
|
+
else if (draft.source !== null && draft.kind !== null && draft.target === null) {
|
|
253
595
|
panel.append(targetSearch(document, draft.source, draft.kind));
|
|
254
596
|
}
|
|
255
597
|
if (words.keys !== undefined)
|
|
@@ -273,6 +615,225 @@ export function mountWorkspace(element, options) {
|
|
|
273
615
|
chooser.style.top = `${String(placed.y)}px`;
|
|
274
616
|
return chooser;
|
|
275
617
|
};
|
|
618
|
+
/**
|
|
619
|
+
* The first-pass surface, when one is up.
|
|
620
|
+
*
|
|
621
|
+
* A TAKEOVER RATHER THAN A FOURTH ZONE: `ZONES` is a closed union the
|
|
622
|
+
* workspace's grid is laid out from, and §17e's queue occupies the surface
|
|
623
|
+
* rather than a column. It is built and appended the way the floating chooser
|
|
624
|
+
* is — after `surface.innerHTML`, inside the workspace root, which is the box
|
|
625
|
+
* `chrome.ts` positions against and the element a host's theme is scoped to.
|
|
626
|
+
*
|
|
627
|
+
* ## It is a modal, and says so
|
|
628
|
+
*
|
|
629
|
+
* The rail and the canvas are still in the DOM underneath, still focusable and
|
|
630
|
+
* still in the accessibility tree. Without `aria-modal` and `inert` on the
|
|
631
|
+
* zones, one Tab lands the reader on a row they cannot see, where every key is
|
|
632
|
+
* inert because the queue owns the keyboard — and a screen reader reads the
|
|
633
|
+
* whole covered workspace as though nothing were over it.
|
|
634
|
+
*
|
|
635
|
+
* ## `aria-live`, because focus deliberately does not move
|
|
636
|
+
*
|
|
637
|
+
* Answering swaps the question in place while focus stays on this wrapper, so
|
|
638
|
+
* without a live region a reader answering sixty questions at speed hears
|
|
639
|
+
* nothing after the first. Focus stays here rather than moving onto a control
|
|
640
|
+
* because the first control is `apply` — the one irreversible answer — and a
|
|
641
|
+
* `Space` on a focus move nobody made is precisely the un-consented apply
|
|
642
|
+
* §17e forbids.
|
|
643
|
+
*/
|
|
644
|
+
const firstPassOverlay = () => {
|
|
645
|
+
const option = current.firstPass;
|
|
646
|
+
const { phase } = state.firstPass;
|
|
647
|
+
if (option === undefined || phase.kind === 'closed')
|
|
648
|
+
return null;
|
|
649
|
+
const wrapper = el('div', {
|
|
650
|
+
class: 'ig-firstpass-overlay',
|
|
651
|
+
role: 'dialog',
|
|
652
|
+
'aria-modal': 'true',
|
|
653
|
+
// THE DIALOG IS NAMED, not just its child. `renderFirstPass` puts the
|
|
654
|
+
// host's label on the `<section>` it draws, which names that landmark and
|
|
655
|
+
// says nothing about its dialog ancestor — so assistive technology met an
|
|
656
|
+
// unnamed modal. The same word, on the element that is the modal.
|
|
657
|
+
'aria-label': option.words.label,
|
|
658
|
+
'aria-live': 'polite',
|
|
659
|
+
'data-ig-firstpass': phase.kind,
|
|
660
|
+
tabindex: '-1',
|
|
661
|
+
});
|
|
662
|
+
if (phase.kind === 'open') {
|
|
663
|
+
const panel = el('div', { class: 'ig-firstpass-panel' });
|
|
664
|
+
// Package-rendered markup, escaped by the package — the file's standing
|
|
665
|
+
// idiom, the same one the inspector's picker uses.
|
|
666
|
+
panel.innerHTML = renderFirstPass(phase.queue, { words: option.words, theme: theme() }).markup;
|
|
667
|
+
wrapper.append(panel);
|
|
668
|
+
}
|
|
669
|
+
else {
|
|
670
|
+
// THE HOST'S SENTENCE, PLACED. A scan that is out and a scan that failed
|
|
671
|
+
// are different facts and get different words; neither is worded here.
|
|
672
|
+
wrapper.append(el('p', { class: 'ig-firstpass-note' }, [
|
|
673
|
+
phase.kind === 'scanning' ? option.scanning : option.scanFailed,
|
|
674
|
+
]));
|
|
675
|
+
}
|
|
676
|
+
wrapper.append(button(option.exit, 'first-pass-close', { class: 'ig-chrome-button ig-chrome-quiet' }));
|
|
677
|
+
return wrapper;
|
|
678
|
+
};
|
|
679
|
+
/**
|
|
680
|
+
* This candidate's write, if one is still IN FLIGHT.
|
|
681
|
+
*
|
|
682
|
+
* `pending` and nothing else, because the question it answers is "is this
|
|
683
|
+
* create already on its way?" — and a record that has FAILED is not on its way
|
|
684
|
+
* and never landed, so the relationship it stood for does not exist. Reading
|
|
685
|
+
* "a record exists" as "a write is out there" suppressed the reader's second
|
|
686
|
+
* consent after a failure: nothing was proposed, and the queue nevertheless
|
|
687
|
+
* marked the candidate decided, so a relationship they believed they had
|
|
688
|
+
* recorded was silently absent and never asked about again.
|
|
689
|
+
*/
|
|
690
|
+
const pendingWriteFor = (candidateId) => {
|
|
691
|
+
const mutationId = appliedWrites.get(candidateId);
|
|
692
|
+
if (mutationId === undefined)
|
|
693
|
+
return undefined;
|
|
694
|
+
return store
|
|
695
|
+
.getSnapshot()
|
|
696
|
+
.writes.some((write) => write.mutationId === mutationId && write.state === 'pending')
|
|
697
|
+
? mutationId
|
|
698
|
+
: undefined;
|
|
699
|
+
};
|
|
700
|
+
/** Whether the store still holds a record for this candidate's write, in any state. */
|
|
701
|
+
const recordedWriteFor = (candidateId) => {
|
|
702
|
+
const mutationId = appliedWrites.get(candidateId);
|
|
703
|
+
return (mutationId !== undefined &&
|
|
704
|
+
store.getSnapshot().writes.some((write) => write.mutationId === mutationId));
|
|
705
|
+
};
|
|
706
|
+
/**
|
|
707
|
+
* Which control inside the overlay owns focus, as a SELECTOR a redraw can find
|
|
708
|
+
* again — or `''` for the wrapper itself, or `null` when focus is elsewhere.
|
|
709
|
+
*
|
|
710
|
+
* A selector rather than the node, because the node does not survive the
|
|
711
|
+
* redraw: `surface.innerHTML` is reassigned and the overlay is rebuilt, so
|
|
712
|
+
* what has to be carried across is a way to name the control, not a reference
|
|
713
|
+
* to the one that was destroyed.
|
|
714
|
+
*/
|
|
715
|
+
const overlayFocusToken = () => {
|
|
716
|
+
const active = doc.activeElement;
|
|
717
|
+
if (!isElement(active))
|
|
718
|
+
return null;
|
|
719
|
+
const overlay = active.closest('.ig-firstpass-overlay');
|
|
720
|
+
if (overlay === null || !surface.contains(overlay))
|
|
721
|
+
return null;
|
|
722
|
+
const answer = active.getAttribute(ANSWER_ATTRIBUTE);
|
|
723
|
+
if (answer !== null)
|
|
724
|
+
return `[${ANSWER_ATTRIBUTE}="${answer}"]`;
|
|
725
|
+
const command = active.getAttribute(COMMAND_ATTRIBUTE);
|
|
726
|
+
if (command !== null)
|
|
727
|
+
return `[${COMMAND_ATTRIBUTE}="${command}"]`;
|
|
728
|
+
return '';
|
|
729
|
+
};
|
|
730
|
+
/**
|
|
731
|
+
* The command control that owns focus, as facts a redraw can match again.
|
|
732
|
+
*
|
|
733
|
+
* `overlayFocusToken` above answers the same question for the first-pass
|
|
734
|
+
* overlay and only for it, so every command control OUTSIDE that overlay had
|
|
735
|
+
* no restore path at all: the arms below cover the overlay, an `input`'s
|
|
736
|
+
* caret, the target search on the render that opens it, and a pending rail
|
|
737
|
+
* jump — and then fall through to restoring by RAIL ROW, which needs a
|
|
738
|
+
* `[data-ig-key]` ancestor. An inspector button has none, so focus fell to
|
|
739
|
+
* the body. The keydown listener is on the mount's element, so that killed
|
|
740
|
+
* the whole keyboard loop until the reader clicked something else.
|
|
741
|
+
*
|
|
742
|
+
* FACTS, NOT A SELECTOR STRING. `overlayFocusToken` interpolates its value
|
|
743
|
+
* into `[attr="value"]`, which is safe only because `data-ig-answer` and the
|
|
744
|
+
* overlay's own commands come from closed sets. A general token carries
|
|
745
|
+
* {@link TARGET_ATTRIBUTE} — edge identities and issue keys straight out of
|
|
746
|
+
* a host's tracker — and a `"` in one would break the selector or match the
|
|
747
|
+
* wrong control. Comparing attribute values during a walk removes the class
|
|
748
|
+
* instead of escaping it.
|
|
749
|
+
*
|
|
750
|
+
* THE ZONE IS PART OF THE IDENTITY. One command name is published by many
|
|
751
|
+
* controls — every relationship row carries `select-edge` — and an issue is
|
|
752
|
+
* commonly drawn in the rail and again on the canvas, so a token without its
|
|
753
|
+
* zone would restore focus to a namesake in a zone the reader was not in.
|
|
754
|
+
*/
|
|
755
|
+
const commandFocusToken = () => {
|
|
756
|
+
const active = doc.activeElement;
|
|
757
|
+
if (!isElement(active) || !surface.contains(active))
|
|
758
|
+
return null;
|
|
759
|
+
// ALL THREE CHANNELS, because the defect is not the command channel's. The
|
|
760
|
+
// audit header's toggle publishes on `data-ig-audit-filter` alone and the
|
|
761
|
+
// first pass's answers on `data-ig-answer` — `controlAnswer` resolves each
|
|
762
|
+
// in its own branch — so a token reading only `data-ig-command` left the audit
|
|
763
|
+
// toggle dropping focus to the body exactly as before. #149 says "any
|
|
764
|
+
// command control", and this is the list that makes that true.
|
|
765
|
+
for (const channel of CONTROL_ATTRIBUTES) {
|
|
766
|
+
const control = active.getAttribute(channel);
|
|
767
|
+
if (control === null)
|
|
768
|
+
continue;
|
|
769
|
+
const partial = {
|
|
770
|
+
channel,
|
|
771
|
+
zone: active.closest('.ig-zone')?.getAttribute('data-zone') ?? null,
|
|
772
|
+
control,
|
|
773
|
+
target: active.getAttribute(TARGET_ATTRIBUTE),
|
|
774
|
+
// THE SAME TWO SPELLINGS `controlAnswer` READS, and in its order: the picker
|
|
775
|
+
// publishes its kind as `data-ig-kind`, the mount's own chrome as
|
|
776
|
+
// `data-ig-value`.
|
|
777
|
+
value: active.getAttribute('data-ig-value') ?? active.getAttribute('data-ig-kind'),
|
|
778
|
+
};
|
|
779
|
+
// `findIndex` RATHER THAN `indexOf`, because `active` is narrowed to
|
|
780
|
+
// `Element` and the siblings are `HTMLElement`. Identity comparison is
|
|
781
|
+
// the same question either way, and the alternative — widening the list
|
|
782
|
+
// or asserting the narrower type — would be a cast this repository bans.
|
|
783
|
+
const ordinal = siblingsOf(partial).findIndex((node) => node === active);
|
|
784
|
+
// A CONTROL THAT DOES NOT FIND ITSELF is one this scope cannot name, so
|
|
785
|
+
// there is no token to carry. `indexOf` answering -1 would otherwise
|
|
786
|
+
// become an ordinal that matches nothing on the way back.
|
|
787
|
+
return ordinal < 0 ? null : { ...partial, ordinal };
|
|
788
|
+
}
|
|
789
|
+
return null;
|
|
790
|
+
};
|
|
791
|
+
/**
|
|
792
|
+
* Every control this token could name, in document order.
|
|
793
|
+
*
|
|
794
|
+
* THE ATTRIBUTE NAME IS A CONSTANT AND THE VALUES ARE COMPARED, so nothing a
|
|
795
|
+
* host's tracker can spell reaches a selector. See the token above.
|
|
796
|
+
*
|
|
797
|
+
* Shared by the token and the restore so the ordinal means the same thing on
|
|
798
|
+
* both sides: a list computed two ways is a list that can disagree with
|
|
799
|
+
* itself, and the ordinal would then point at a different control than the
|
|
800
|
+
* one it was counted against.
|
|
801
|
+
*/
|
|
802
|
+
const siblingsOf = (token) => {
|
|
803
|
+
const scope = token.zone === null ? surface : (zone(token.zone) ?? surface);
|
|
804
|
+
return [...scope.querySelectorAll(`[${token.channel}]`)].filter((node) => {
|
|
805
|
+
const control = node.getAttribute(token.channel);
|
|
806
|
+
// BY IDENTITY, so a toggle that redraws itself with the other command is
|
|
807
|
+
// still the control the reader was on. See {@link TOGGLE_IDENTITY}.
|
|
808
|
+
if (control === null || controlIdentity(control) !== controlIdentity(token.control))
|
|
809
|
+
return false;
|
|
810
|
+
if (node.getAttribute(TARGET_ATTRIBUTE) !== token.target)
|
|
811
|
+
return false;
|
|
812
|
+
const value = node.getAttribute('data-ig-value') ?? node.getAttribute('data-ig-kind');
|
|
813
|
+
if (value !== token.value)
|
|
814
|
+
return false;
|
|
815
|
+
// A ZONELESS TOKEN MUST NOT MATCH A ZONED CONTROL. The floating chooser
|
|
816
|
+
// and the first-pass overlay are appended OUTSIDE the four zones, so
|
|
817
|
+
// their tokens carry `zone: null` and scope to the whole surface — and
|
|
818
|
+
// without this the chooser's `cancel` restored onto the inspector's.
|
|
819
|
+
// The two directions are asymmetric on purpose: a zoned token is already
|
|
820
|
+
// confined by `scope`.
|
|
821
|
+
return !(token.zone === null && node.closest('.ig-zone') !== null);
|
|
822
|
+
});
|
|
823
|
+
};
|
|
824
|
+
/** Give focus back to the control a {@link CommandFocus} names. `false` when it is gone. */
|
|
825
|
+
const refocusCommand = (token) => {
|
|
826
|
+
const siblings = siblingsOf(token);
|
|
827
|
+
// THE SAME ONE, THEN THE FIRST. A list that shrank under the reader — a
|
|
828
|
+
// match that stopped matching, a capsule that collapsed — has no nth
|
|
829
|
+
// member, and landing on the first sibling keeps the keyboard alive where
|
|
830
|
+
// returning `false` would drop to the rail.
|
|
831
|
+
const again = siblings[token.ordinal] ?? siblings[0];
|
|
832
|
+
if (again === undefined)
|
|
833
|
+
return false;
|
|
834
|
+
again.focus({ preventScroll: true });
|
|
835
|
+
return true;
|
|
836
|
+
};
|
|
276
837
|
/** Focus the element carrying `key`, inside one zone when named, without scrolling the page. */
|
|
277
838
|
const focusIn = (zoneName, key) => {
|
|
278
839
|
const scope = zoneName === null ? surface : (zone(zoneName) ?? surface);
|
|
@@ -316,9 +877,11 @@ export function mountWorkspace(element, options) {
|
|
|
316
877
|
if (zoneName !== 'canvas')
|
|
317
878
|
return null;
|
|
318
879
|
if (current.canvas === 'tree')
|
|
319
|
-
return renderViewer(drawn.viewer, { projection: 'tree', theme: theme() }).scene;
|
|
880
|
+
return renderViewer(withoutChrome(drawn.viewer), { projection: 'tree', theme: theme(), chrome: false }).scene;
|
|
320
881
|
const ladder = scaleLadder(drawn.viewer, state.scale);
|
|
321
|
-
return ladder.tier === 'direct'
|
|
882
|
+
return ladder.tier === 'direct'
|
|
883
|
+
? renderViewer(ladder.canvas, { projection: 'graph', theme: theme(), chrome: false }).scene
|
|
884
|
+
: null;
|
|
322
885
|
};
|
|
323
886
|
/** The row or node that owns keyboard focus, if focus is on one at all. */
|
|
324
887
|
const focusedKey = () => {
|
|
@@ -331,8 +894,16 @@ export function mountWorkspace(element, options) {
|
|
|
331
894
|
return;
|
|
332
895
|
const snapshot = store.getSnapshot();
|
|
333
896
|
const document_ = landed();
|
|
334
|
-
//
|
|
335
|
-
|
|
897
|
+
// WHAT THE STORE IS NOT SHOWING, computed before the reconcile rather than
|
|
898
|
+
// beside the edges below, because the reconcile is the first reader of it:
|
|
899
|
+
// an unsettled retype or flip hides the edge it replaces, and the landed
|
|
900
|
+
// document the reconcile is otherwise handed still carries it. Both halves
|
|
901
|
+
// of "the reader can no longer see this" reach `reconcileHost` together.
|
|
902
|
+
const shown = new Set(snapshot.projected.map((edge) => edge.id));
|
|
903
|
+
const hidden = new Set(snapshot.landed.map((edge) => edge.id).filter((id) => !shown.has(id)));
|
|
904
|
+
// A landed write can retire what the state names, and an unsettled one can
|
|
905
|
+
// hide it; agree with what is on show first.
|
|
906
|
+
state = reconcileHost(state, document_, hidden);
|
|
336
907
|
const projected = current.project(snapshot);
|
|
337
908
|
// WHAT TO DRAW IS THE STORE'S PROJECTION, by the store's own contract:
|
|
338
909
|
// `projected` is "landed plus every unsettled edit, each carrying its
|
|
@@ -347,8 +918,6 @@ export function mountWorkspace(element, options) {
|
|
|
347
918
|
// projection hides the old one until the write settles; the host's
|
|
348
919
|
// document still carries it, so without this the old line and the new
|
|
349
920
|
// dashed one were drawn together for the life of the write.
|
|
350
|
-
const shown = new Set(snapshot.projected.map((edge) => edge.id));
|
|
351
|
-
const hidden = new Set(snapshot.landed.map((edge) => edge.id).filter((id) => !shown.has(id)));
|
|
352
921
|
const kept = projected.viewer.edges.filter((edge) => !hidden.has(edgeIdentity(edge.field, edge.from, edge.to)));
|
|
353
922
|
const drawn_ = new Set(kept.map((edge) => edgeIdentity(edge.field, edge.from, edge.to)));
|
|
354
923
|
const unsettled = snapshot.projected
|
|
@@ -364,6 +933,160 @@ export function mountWorkspace(element, options) {
|
|
|
364
933
|
// through `store.select()` would draw a second halo the inspector does not
|
|
365
934
|
// reflect, so it is stripped before the projection reaches a canvas.
|
|
366
935
|
const writeStates = snapshot.projected.map((edge) => edge.states.includes('selected') ? { ...edge, states: edge.states.filter((state) => state !== 'selected') } : edge);
|
|
936
|
+
// THE WRITE LEDGER, TURNED INTO WHAT THE PANEL DRAWS — in ONE pass, in the
|
|
937
|
+
// ledger's own order.
|
|
938
|
+
//
|
|
939
|
+
// IT WAS TWO PASSES AND A CONCATENATION: one walk over the projection for
|
|
940
|
+
// the refusals it had an edge for, then one over the ledger for the ones it
|
|
941
|
+
// did not, appended after. Both halves were right and the JOIN was not.
|
|
942
|
+
// The renderer collapses repeated edges last-wins, so the array's order is
|
|
943
|
+
// the chronology — and appending every stranded record after every
|
|
944
|
+
// projected one is not the order the reader made them in. An older
|
|
945
|
+
// `unknown-edge` naming an edge a later refused create then projects
|
|
946
|
+
// reported the older reason. There is nothing to get wrong now: the ledger
|
|
947
|
+
// is walked once, and its order is the order.
|
|
948
|
+
//
|
|
949
|
+
// WHICH EDGE A REFUSAL MARKS IS THE PROJECTION'S OWN ANSWER.
|
|
950
|
+
// `ProjectedEdge.writes` is its record of which mutations it is speaking
|
|
951
|
+
// for, and `validity.ts` has already decided which edge each one marks — a
|
|
952
|
+
// create marks the edge it would have made, a retype the one it would have
|
|
953
|
+
// become. Rebuilding that here would be a second answer, free to disagree
|
|
954
|
+
// with the line the canvas draws the ghost on; asked of the projection, the
|
|
955
|
+
// record that produced one edge cannot also be counted against another,
|
|
956
|
+
// which is the double-draw this join used to be able to produce.
|
|
957
|
+
const markedBy = new Map(snapshot.projected.flatMap((edge) => edge.writes.map((write) => [write, edge.id])));
|
|
958
|
+
// WHETHER THE MARKED EDGE IS REAL. Three codes refuse an edit ABOUT a
|
|
959
|
+
// landed relationship — `duplicate-edge`, `unchanged-kind`,
|
|
960
|
+
// `symmetric-edge` — and the panel keeps the row for those rather than
|
|
961
|
+
// replacing it with a capsule; see `WorkspaceRefusal.phantom`.
|
|
962
|
+
//
|
|
963
|
+
// ASKED OF `landed`, NEVER OF THE CODE. A list of codes here would be a
|
|
964
|
+
// second copy of `validity.ts`'s decision about which edge each refusal
|
|
965
|
+
// marks, and it would already be wrong: `cardinality` reads like a fourth
|
|
966
|
+
// member of that set and marks a PHANTOM through both of its routes. This
|
|
967
|
+
// is the store's own answer to "what does the document actually carry".
|
|
968
|
+
const landedIds = new Set(snapshot.landed.map((edge) => edge.id));
|
|
969
|
+
const landedNow = { issues: snapshot.issues, edges: snapshot.landed };
|
|
970
|
+
// THE CARRIER MAP, BROUGHT LEVEL WITH THE LEDGER. See `writeCarriers` for
|
|
971
|
+
// why the answer is kept rather than re-asked, and why the ledger's own
|
|
972
|
+
// membership is the whole of its lifetime.
|
|
973
|
+
const ledger = new Set(snapshot.writes.map((record) => record.mutationId));
|
|
974
|
+
// CLEARED WHEN THE RECORD GOES, AND NOT BEFORE.
|
|
975
|
+
//
|
|
976
|
+
// Clearing it on `hydrationError === undefined` as well was the obvious
|
|
977
|
+
// rule and it was WRONG BY ONE RENDER: `retryOnLatest` reserves its record
|
|
978
|
+
// synchronously and publishes, so the very next render happens BEFORE the
|
|
979
|
+
// read has had a chance to fail — the error is still undefined, the flag
|
|
980
|
+
// was dropped, and when the failure did arrive there was nothing left to
|
|
981
|
+
// attribute it to. The card then stayed silent on exactly the failure this
|
|
982
|
+
// field exists to explain. Measured: the pin below went red.
|
|
983
|
+
//
|
|
984
|
+
// So the flag's lifetime is the RECORD's, and whether there is anything to
|
|
985
|
+
// draw is asked of the snapshot at draw time instead.
|
|
986
|
+
if (refreshFailedFor !== null && !ledger.has(refreshFailedFor)) {
|
|
987
|
+
refreshFailedFor = null;
|
|
988
|
+
}
|
|
989
|
+
for (const mutationId of writeCarriers.keys()) {
|
|
990
|
+
if (!ledger.has(mutationId))
|
|
991
|
+
writeCarriers.delete(mutationId);
|
|
992
|
+
}
|
|
993
|
+
for (const record of snapshot.writes) {
|
|
994
|
+
// FIRST SIGHT IS THE EARLIEST MOMENT THERE IS for a write this mount did
|
|
995
|
+
// not emit. Its own edits are already recorded by `perform`, from the
|
|
996
|
+
// document the reader acted on, so this fills in only what a host
|
|
997
|
+
// proposed on the same store — and never a second time.
|
|
998
|
+
if (!writeCarriers.has(record.mutationId)) {
|
|
999
|
+
writeCarriers.set(record.mutationId, editCarrier(landedNow, record.mutation));
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
const refusals = snapshot.writes.flatMap((record) => {
|
|
1003
|
+
if (record.state !== 'invalid')
|
|
1004
|
+
return [];
|
|
1005
|
+
// WHOSE PANEL STATES IT, READ BACK RATHER THAN WORKED OUT. The panel used
|
|
1006
|
+
// to derive this from the refused edge's endpoints, against a subject it
|
|
1007
|
+
// had derived by an unrelated rule; then `editCarrier` was called here
|
|
1008
|
+
// instead, which is one rule but still asked at the wrong TIME — a
|
|
1009
|
+
// sibling write can remove the relationship between the act and the
|
|
1010
|
+
// refusal, and what is left says which end declared it only for the
|
|
1011
|
+
// directed fields — `edgeIdentity` sorts the symmetric ones. The answer
|
|
1012
|
+
// was taken when the edit was made; this only looks it up. The two loops
|
|
1013
|
+
// above run over this same `snapshot.writes`, so the key is always
|
|
1014
|
+
// present, and `null` means the document held neither end — there is no
|
|
1015
|
+
// panel a reader could be standing on to read it.
|
|
1016
|
+
const carrier = writeCarriers.get(record.mutationId) ?? null;
|
|
1017
|
+
if (carrier === null)
|
|
1018
|
+
return [];
|
|
1019
|
+
// THE PROJECTION'S EDGE, AND THE EDIT'S OWN AS THE FALLBACK. A refusal
|
|
1020
|
+
// the projection has nothing to hang on is the whole `unknown-edge`
|
|
1021
|
+
// class — a retype, flip or delete of an edge the document no longer
|
|
1022
|
+
// carries produces `{ hidden: [], drawn: [], marked: [] }` — and the
|
|
1023
|
+
// mutation names the edge it was about, so that id is what the capsule
|
|
1024
|
+
// is keyed on.
|
|
1025
|
+
const edgeId = markedBy.get(record.mutationId) ??
|
|
1026
|
+
(record.mutation.op === 'create'
|
|
1027
|
+
? edgeIdentity(record.mutation.kind, record.mutation.from, record.mutation.to)
|
|
1028
|
+
: record.mutation.edgeId);
|
|
1029
|
+
return [{ edgeId, code: record.reason.code, carrier, phantom: !landedIds.has(edgeId) }];
|
|
1030
|
+
});
|
|
1031
|
+
// §17b's RECOVERY CARDS, BUILT FROM THE SAME LEDGER PASS AS THE REFUSALS.
|
|
1032
|
+
// One walk of `snapshot.writes` keeps the chronology the panel relies on,
|
|
1033
|
+
// and reuses the `edgeId` and `carrier` answers already worked out above
|
|
1034
|
+
// rather than asking either question a second time.
|
|
1035
|
+
//
|
|
1036
|
+
// THE CARRIER IS CARRIED EVEN WHEN IT IS `null`, and that is the one place
|
|
1037
|
+
// this deliberately parts company with the refusal derivation above, which
|
|
1038
|
+
// drops those. A refusal dropped costs the reader a sentence. A RECOVERY
|
|
1039
|
+
// dropped costs them the only retry and discard they have, on a write that
|
|
1040
|
+
// is still theirs to resolve — so it travels, and the panel draws it in the
|
|
1041
|
+
// unplaced region instead of nowhere.
|
|
1042
|
+
// ANNOTATED RATHER THAN INFERRED. `flatMap` takes its element type from the
|
|
1043
|
+
// first arm it sees, which here is the `failed` one — so an unannotated
|
|
1044
|
+
// callback types the whole list as failures and rejects the conflict arm
|
|
1045
|
+
// for want of a `reason`. The repair this repository bans is a cast; the
|
|
1046
|
+
// repair it wants is saying what the function returns.
|
|
1047
|
+
const recoveries = snapshot.writes.flatMap((record) => {
|
|
1048
|
+
if (record.state !== 'failed' && record.state !== 'conflict')
|
|
1049
|
+
return [];
|
|
1050
|
+
const carrier = writeCarriers.get(record.mutationId) ?? null;
|
|
1051
|
+
const edgeId = markedBy.get(record.mutationId) ??
|
|
1052
|
+
(record.mutation.op === 'create'
|
|
1053
|
+
? edgeIdentity(record.mutation.kind, record.mutation.from, record.mutation.to)
|
|
1054
|
+
: record.mutation.edgeId);
|
|
1055
|
+
if (record.state === 'failed') {
|
|
1056
|
+
return [{ kind: 'failed', mutationId: record.mutationId, edgeId, carrier, reason: record.reason }];
|
|
1057
|
+
}
|
|
1058
|
+
return [
|
|
1059
|
+
{
|
|
1060
|
+
kind: 'conflict',
|
|
1061
|
+
mutationId: record.mutationId,
|
|
1062
|
+
edgeId,
|
|
1063
|
+
carrier,
|
|
1064
|
+
// AGAINST `landedNow`, WHICH CANNOT HOLD THE EDIT — that is the
|
|
1065
|
+
// point. `conflictDiff` reconstructs the reader's own side from the
|
|
1066
|
+
// mutation, so the card is guaranteed to name the relationship it is
|
|
1067
|
+
// about rather than depending on a document that by contract omits it.
|
|
1068
|
+
diff: conflictDiff(landedNow, record.upstream, record.mutation),
|
|
1069
|
+
// THE READ'S OWN FAILURE, WHICH IS NOT THE RECORD'S. A `retry on
|
|
1070
|
+
// latest` whose refresh fails restores this record verbatim and
|
|
1071
|
+
// dispatches nothing; the reason lands on the snapshot instead. Read
|
|
1072
|
+
// from there, the card can say why the button appeared to do nothing.
|
|
1073
|
+
// ONLY ON THE CARD WHOSE READ FAILED. Both halves are required: a
|
|
1074
|
+
// hydration error with no press behind it belongs to no card, and a
|
|
1075
|
+
// press whose read succeeded leaves no error to draw.
|
|
1076
|
+
refreshError: refreshFailedFor === record.mutationId ? (snapshot.hydrationError ?? null) : null,
|
|
1077
|
+
},
|
|
1078
|
+
];
|
|
1079
|
+
});
|
|
1080
|
+
// ITS LIFETIME IS THE CONFLICT'S, NOT THE LEDGER'S, and the distinction is
|
|
1081
|
+
// load-bearing: `retryOnLatest` reserves its record as `pending`
|
|
1082
|
+
// synchronously, and a resolve whose re-check refuses leaves it `invalid`.
|
|
1083
|
+
// In both the record is still in the ledger and its held document is gone,
|
|
1084
|
+
// so "still in the ledger" would leave a difference region open over
|
|
1085
|
+
// nothing. Asked of the state, it closes on every route out of `conflict`.
|
|
1086
|
+
if (state.diffOpen !== null &&
|
|
1087
|
+
!snapshot.writes.some((record) => record.mutationId === state.diffOpen && record.state === 'conflict')) {
|
|
1088
|
+
state = { ...state, diffOpen: null };
|
|
1089
|
+
}
|
|
367
1090
|
const result = renderWorkspace(viewer, {
|
|
368
1091
|
words: current.words,
|
|
369
1092
|
selection: state.selection,
|
|
@@ -378,8 +1101,23 @@ export function mountWorkspace(element, options) {
|
|
|
378
1101
|
// through `store.select()` would draw a second halo the inspector does
|
|
379
1102
|
// not reflect, so it is stripped before the projection reaches the canvas.
|
|
380
1103
|
projected: writeStates,
|
|
1104
|
+
// THE SAME TWO VALUES THE CHROME BELOW READS. The create path's steps are
|
|
1105
|
+
// split across the two surfaces now, so both have to be told which step is
|
|
1106
|
+
// live — and a drop in flight suppresses the panel's list for the reason
|
|
1107
|
+
// `WorkspaceOptions.drop` records: the floating chooser is already open
|
|
1108
|
+
// at the pointer, and two choosers writing to one draft is what the
|
|
1109
|
+
// shell's own `if (state.drop === null)` was preventing while it drew
|
|
1110
|
+
// both of them.
|
|
1111
|
+
draft: state.draft,
|
|
1112
|
+
drop: state.drop,
|
|
1113
|
+
refusals,
|
|
1114
|
+
recoveries,
|
|
1115
|
+
diffOpen: state.diffOpen,
|
|
381
1116
|
});
|
|
382
|
-
|
|
1117
|
+
// THE FIRST PASS'S OWN SHEET, IMPORTED — not `renderFirstPass(...).styles`,
|
|
1118
|
+
// which carries a second copy of the theme block written just above it. The
|
|
1119
|
+
// picker is installed the same way and for the same reason.
|
|
1120
|
+
const sheet = [result.styles, pickerStylesheet, firstPassStylesheet, mountStylesheet].join('\n');
|
|
383
1121
|
if (styles.textContent !== sheet)
|
|
384
1122
|
styles.textContent = sheet;
|
|
385
1123
|
// What the reader was doing survives the redraw: the rail's scroll offset,
|
|
@@ -395,6 +1133,19 @@ export function mountWorkspace(element, options) {
|
|
|
395
1133
|
? { start: activeInput.selectionStart, end: activeInput.selectionEnd, direction: activeInput.selectionDirection }
|
|
396
1134
|
: null;
|
|
397
1135
|
const focused = focusedKey();
|
|
1136
|
+
const overlayToken = overlayFocusToken();
|
|
1137
|
+
// CAPTURED BEFORE THE REDRAW, like every other fact here: the node itself
|
|
1138
|
+
// does not survive `surface.innerHTML`, so what crosses is a way to name
|
|
1139
|
+
// the control rather than a reference to the one about to be destroyed.
|
|
1140
|
+
const commandToken = commandFocusToken();
|
|
1141
|
+
// WAS THE FOCUS OURS TO RESTORE? A reader whose focus is on the host's own
|
|
1142
|
+
// chrome must not have it dragged into the workspace by a redraw the
|
|
1143
|
+
// workspace happened to do — the search arm below already records paying
|
|
1144
|
+
// for that once. The last-resort arm needs this because it fires on
|
|
1145
|
+
// "nothing inside the surface holds focus", which is equally true of a
|
|
1146
|
+
// redraw that destroyed the focused control and of a reader who simply is
|
|
1147
|
+
// not here.
|
|
1148
|
+
const heldFocus = isElement(active) && surface.contains(active);
|
|
398
1149
|
// The ZONE too: an issue is commonly drawn in the rail and on the canvas,
|
|
399
1150
|
// and restoring "the first element with this key" would move focus from
|
|
400
1151
|
// a canvas node into the rail on every redraw.
|
|
@@ -418,7 +1169,13 @@ export function mountWorkspace(element, options) {
|
|
|
418
1169
|
// as data, the same `data-ig-state` the ladder's line carries, so a
|
|
419
1170
|
// pending edge is not drawn as a settled one and a host styles or reads
|
|
420
1171
|
// it the same way in both modes. The merge is the ladder's own.
|
|
421
|
-
canvas.innerHTML = renderViewer(viewer, {
|
|
1172
|
+
canvas.innerHTML = renderViewer(withoutChrome(viewer), {
|
|
1173
|
+
projection: 'tree',
|
|
1174
|
+
theme: resolved,
|
|
1175
|
+
selected: selectedKey(state.selection),
|
|
1176
|
+
// The rail beside this canvas draws the panel's one header.
|
|
1177
|
+
chrome: false,
|
|
1178
|
+
}).markup;
|
|
422
1179
|
const states = new Map(overlaysFor(viewer.edges, writeStates, selectedEdgeId(state.selection)).map((edge) => [edge.id, overlayFor(edge).attribute]));
|
|
423
1180
|
for (const badge of canvas.querySelectorAll(`[${GROUP_ATTRIBUTE}]`)) {
|
|
424
1181
|
const attribute = states.get(badge.getAttribute(GROUP_ATTRIBUTE) ?? '');
|
|
@@ -434,6 +1191,15 @@ export function mountWorkspace(element, options) {
|
|
|
434
1191
|
const floating = floatingChooser();
|
|
435
1192
|
if (floating !== null)
|
|
436
1193
|
(surface.firstElementChild ?? surface).append(floating);
|
|
1194
|
+
const overlay = firstPassOverlay();
|
|
1195
|
+
if (overlay !== null) {
|
|
1196
|
+
(surface.firstElementChild ?? surface).append(overlay);
|
|
1197
|
+
// THE ZONES GO INERT UNDER IT. Set here rather than in the stylesheet
|
|
1198
|
+
// because it is a behaviour — focus and hit-testing — not a look, and the
|
|
1199
|
+
// markup is rebuilt every render so it cannot be left behind on close.
|
|
1200
|
+
for (const covered of surface.querySelectorAll('.ig-zone'))
|
|
1201
|
+
covered.setAttribute('inert', '');
|
|
1202
|
+
}
|
|
437
1203
|
const rail = zone('rail');
|
|
438
1204
|
if (rail !== null)
|
|
439
1205
|
rail.scrollTop = scrollTop;
|
|
@@ -448,7 +1214,24 @@ export function mountWorkspace(element, options) {
|
|
|
448
1214
|
return;
|
|
449
1215
|
focusIn(within, key);
|
|
450
1216
|
};
|
|
451
|
-
if (
|
|
1217
|
+
if (overlay !== null) {
|
|
1218
|
+
// FOCUS IS TAKEN BACK ON EVERY RENDER WHILE THE OVERLAY IS UP, not only on
|
|
1219
|
+
// the one that opens it. Every answer dispatches, every dispatch redraws,
|
|
1220
|
+
// and the redraw destroys the element focus was on — so a once-only rule
|
|
1221
|
+
// gave the reader exactly one keystroke before `activeElement` fell to the
|
|
1222
|
+
// body, `interaction` read `elsewhere` and every later key returned
|
|
1223
|
+
// `none`. This is the same arm the target search already earns below.
|
|
1224
|
+
if (firstPassWas === 'closed')
|
|
1225
|
+
focusBeforeFirstPass = focused;
|
|
1226
|
+
const again = overlayToken === null || overlayToken === ''
|
|
1227
|
+
? null
|
|
1228
|
+
: overlay.querySelector(overlayToken);
|
|
1229
|
+
// THE WRAPPER IS THE FALLBACK, INCLUDING ON THE RENDER THAT OPENS IT. The
|
|
1230
|
+
// first control is `apply`, and landing focus there would put the one
|
|
1231
|
+
// irreversible answer under the reader's next Space.
|
|
1232
|
+
(again ?? overlay).focus({ preventScroll: true });
|
|
1233
|
+
}
|
|
1234
|
+
else if (activeCommand !== null) {
|
|
452
1235
|
const again = surface.querySelector(`input[${COMMAND_ATTRIBUTE}="${activeCommand}"]`);
|
|
453
1236
|
if (again !== null) {
|
|
454
1237
|
again.focus();
|
|
@@ -501,32 +1284,267 @@ export function mountWorkspace(element, options) {
|
|
|
501
1284
|
}
|
|
502
1285
|
}
|
|
503
1286
|
else {
|
|
504
|
-
|
|
1287
|
+
// THE COMMAND CONTROL, WHERE THE RAIL CANNOT ANSWER. Strictly additive:
|
|
1288
|
+
// it is reached only when `focusedKey()` found nothing, which is exactly
|
|
1289
|
+
// the case `focusRow(null)` returned from without moving focus at all —
|
|
1290
|
+
// so no control that has a keyed ancestor changes behaviour, and the
|
|
1291
|
+
// rail's roving tab stop is untouched.
|
|
1292
|
+
//
|
|
1293
|
+
// The control can legitimately be gone: the edit it named landed, the
|
|
1294
|
+
// relationship it belonged to was deleted, the recovery card it sat in
|
|
1295
|
+
// resolved. `refocusCommand` says so rather than guessing, and the rail
|
|
1296
|
+
// fallback below is then the same one that ran before.
|
|
1297
|
+
const restored = focused === null && commandToken !== null && refocusCommand(commandToken);
|
|
1298
|
+
if (!restored)
|
|
1299
|
+
focusRow(focused);
|
|
1300
|
+
// A LAST RESORT, because half of these controls RESOLVE THEMSELVES. A
|
|
1301
|
+
// picker option closes the picker; `retry`, `discard`, `cancel` and
|
|
1302
|
+
// `dismiss-change` each remove the card they sit in — so `refocusCommand`
|
|
1303
|
+
// correctly answers "gone", `focusRow(null)` moves nothing, and the
|
|
1304
|
+
// keyboard loop died anyway. That is the whole-loop half of the defect,
|
|
1305
|
+
// and leaving it would have fixed the disclosure while every one-shot
|
|
1306
|
+
// control kept failing the same way.
|
|
1307
|
+
//
|
|
1308
|
+
// The first drawn row, on the same reasoning the first-pass close arm
|
|
1309
|
+
// gives for returning to §17a's entry: somewhere inside the surface that
|
|
1310
|
+
// reaches the listener beats the body, and the rail is the zone the
|
|
1311
|
+
// reader can navigate out of.
|
|
1312
|
+
// THE TEST IS "INSIDE THE SURFACE", NOT "FOCUSABLE". `isFocusable` asks
|
|
1313
|
+
// whether a node has `focus`, and `<body>` does — so it answers true for
|
|
1314
|
+
// exactly the state this arm exists to repair. What the keydown listener
|
|
1315
|
+
// needs is a focus owner it can receive an event from.
|
|
1316
|
+
const adrift = !isElement(doc.activeElement) || !surface.contains(doc.activeElement);
|
|
1317
|
+
if (!restored && focused === null && heldFocus && adrift) {
|
|
1318
|
+
// THROUGH `focusIn`, NOT `focus()`. The rail renders one row at
|
|
1319
|
+
// `tabindex="0"` and the rest at `-1`, and focusing a row directly
|
|
1320
|
+
// leaves the STOP on whichever row had it — so Tab out and back
|
|
1321
|
+
// returns to a different row than the one the reader is on.
|
|
1322
|
+
// `focusIn` moves the stop with the focus, which is why every other
|
|
1323
|
+
// row-focus path in this file goes through it.
|
|
1324
|
+
const first = zone('rail')?.querySelector(`[${KEY_ATTRIBUTE}][tabindex]`);
|
|
1325
|
+
const key = first?.getAttribute(KEY_ATTRIBUTE);
|
|
1326
|
+
if (key !== null && key !== undefined)
|
|
1327
|
+
focusIn('rail', key);
|
|
1328
|
+
}
|
|
1329
|
+
// AND A RESORT THAT CANNOT ITSELF FAIL. The rail is not always there to
|
|
1330
|
+
// fall back to: with the audit filter on and nothing flagged it draws no
|
|
1331
|
+
// rows at all, while the inspector stays perfectly usable — so pressing a
|
|
1332
|
+
// self-removing control there left focus on the body and killed the
|
|
1333
|
+
// keyboard loop exactly as before. A fallback with a precondition is not
|
|
1334
|
+
// a last resort.
|
|
1335
|
+
//
|
|
1336
|
+
// The surface itself always exists, and the keydown listener is on it, so
|
|
1337
|
+
// focus landing here is by definition focus the loop can hear. `tabindex`
|
|
1338
|
+
// is -1: this is somewhere to PUT focus, never a stop Tab should find.
|
|
1339
|
+
if (heldFocus &&
|
|
1340
|
+
(!isElement(doc.activeElement) || !surface.contains(doc.activeElement))) {
|
|
1341
|
+
// A CONTROL, BEFORE THE SURFACE ITSELF. Most of these do not merely
|
|
1342
|
+
// vanish — they REPLACE themselves with the step they opened, and `add`
|
|
1343
|
+
// becoming the kind list is the ordinary case. Landing on the bare
|
|
1344
|
+
// surface there put the press back in reach of the listener and no
|
|
1345
|
+
// further: `interaction()` answers `canvas` only for a focused ROW, so
|
|
1346
|
+
// on the surface every create binding — the kind digits, Escape —
|
|
1347
|
+
// returns `none`. Reachable and inoperable is not the loop this is about.
|
|
1348
|
+
//
|
|
1349
|
+
// THE ZONE THE READER WAS IN FIRST, then anywhere. Which control
|
|
1350
|
+
// replaced which is not knowable from here — the chooser is sometimes
|
|
1351
|
+
// floating and sometimes the inspector's own list — so this asks the
|
|
1352
|
+
// weaker, answerable question: what can the reader act on, nearest to
|
|
1353
|
+
// where they were.
|
|
1354
|
+
const zoneName = commandToken?.zone ?? null;
|
|
1355
|
+
const near = zoneName === null ? null : zone(zoneName);
|
|
1356
|
+
const opened = near?.querySelector(`[${COMMAND_ATTRIBUTE}]`) ??
|
|
1357
|
+
surface.querySelector(`[${COMMAND_ATTRIBUTE}]`);
|
|
1358
|
+
if (opened !== null) {
|
|
1359
|
+
opened.focus({ preventScroll: true });
|
|
1360
|
+
}
|
|
1361
|
+
else {
|
|
1362
|
+
// AND THE SURFACE LAST, which is the resort that cannot itself fail.
|
|
1363
|
+
// It leaves the reader without a binding to press, but it keeps the
|
|
1364
|
+
// listener reachable so Tab moves them somewhere useful — strictly
|
|
1365
|
+
// better than the body, which reaches nothing at all.
|
|
1366
|
+
surface.setAttribute('tabindex', '-1');
|
|
1367
|
+
surface.focus({ preventScroll: true });
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
// THE KEYBOARD IS GIVEN BACK. The overlay is removed with focus inside it,
|
|
1372
|
+
// so without this `activeElement` is the body — and the keydown listener is
|
|
1373
|
+
// on the mount's element, so no press reaches it and the whole workspace is
|
|
1374
|
+
// dead until the reader clicks. Restores what held focus when it opened.
|
|
1375
|
+
if (overlay === null && firstPassWas !== 'closed') {
|
|
1376
|
+
const back = focusBeforeFirstPass;
|
|
1377
|
+
focusBeforeFirstPass = null;
|
|
1378
|
+
appliedWrites.clear();
|
|
1379
|
+
if (back !== null) {
|
|
1380
|
+
focusIn(null, back);
|
|
1381
|
+
}
|
|
1382
|
+
else {
|
|
1383
|
+
// NOTHING KEYED HELD FOCUS, WHICH IS THE ORDINARY CASE. §17a's entry is
|
|
1384
|
+
// a button and carries no `data-ig-key`, so a reader who tabbed to it
|
|
1385
|
+
// and pressed it leaves nothing for `focusIn` to find — and focus on
|
|
1386
|
+
// the document body reaches no listener at all, because the keydown
|
|
1387
|
+
// listener is on the mount's element. The entry is the control they
|
|
1388
|
+
// came in through, so it is where they come back to.
|
|
1389
|
+
// ONE RECORDED ANSWER, NOT TWO: an earlier revision also recorded WHICH
|
|
1390
|
+
// control held focus, which a mutation test showed could never differ —
|
|
1391
|
+
// the queue opens from this one control and no other.
|
|
1392
|
+
// THE ENTRY CAN BE GONE BY NOW, THOUGH. Whether a backlog has a first
|
|
1393
|
+
// pass to run is the host's answer and the host may change it: the
|
|
1394
|
+
// sandbox draws the entry only while its detector finds candidates, so
|
|
1395
|
+
// a completed queue whose writes land removes the very control this
|
|
1396
|
+
// would return to. So the order of resort ends inside the ORDER, which
|
|
1397
|
+
// is the one part of the workspace that is always there.
|
|
1398
|
+
const back_ = surface.querySelector(`[${COMMAND_ATTRIBUTE}="first-pass"]`) ??
|
|
1399
|
+
surface.querySelector(`[${KEY_ATTRIBUTE}][tabindex]`);
|
|
1400
|
+
back_?.focus({ preventScroll: true });
|
|
1401
|
+
}
|
|
505
1402
|
}
|
|
1403
|
+
firstPassWas = state.firstPass.phase.kind;
|
|
506
1404
|
searchWasOpen = search !== null;
|
|
507
1405
|
};
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
1406
|
+
/**
|
|
1407
|
+
* Take the option's scanner, and end the lifecycle the old one was scanning for.
|
|
1408
|
+
*
|
|
1409
|
+
* Two failures, one rule. Removing the bundle stops the overlay being drawn
|
|
1410
|
+
* while the phase stays open — and the phase is what hands every key to a
|
|
1411
|
+
* queue that is no longer on screen, with no control left to close it.
|
|
1412
|
+
* REPLACING the source is worse than it looks: the old scan's promise still
|
|
1413
|
+
* resolves under the current generation, so the queue would be drawn in the
|
|
1414
|
+
* new bundle's words and populated by the superseded scanner, and a `Y` there
|
|
1415
|
+
* writes a relationship the configured source never proposed.
|
|
1416
|
+
*
|
|
1417
|
+
* RESET RATHER THAN CLOSE, and the difference is the decided set: a
|
|
1418
|
+
* `CandidateId` is the HOST's and opaque, so one scanner's ids say nothing
|
|
1419
|
+
* about another's — carried across, a collision silently drops the new
|
|
1420
|
+
* detector's question, and a question nobody was asked is indistinguishable
|
|
1421
|
+
* from one already answered.
|
|
1422
|
+
*
|
|
1423
|
+
* CALLED FROM `update()`, AT THE MOMENT THE OPTION CHANGES, not from the
|
|
1424
|
+
* render that observes it later. Renders are coalesced on a microtask, so a
|
|
1425
|
+
* host that calls `update()` and then dispatches an `open` in the same task
|
|
1426
|
+
* had its brand-new lifecycle reset by a render still holding the previous
|
|
1427
|
+
* scanner — and the new scan's answer then arrived on a closed phase and was
|
|
1428
|
+
* dropped, so that supported sequence never opened a queue at all.
|
|
1429
|
+
*
|
|
1430
|
+
* Keyed on the SOURCE rather than the bundle, because a host that rebuilds an
|
|
1431
|
+
* equivalent options object every render has changed nothing.
|
|
1432
|
+
*/
|
|
1433
|
+
const adoptSource = () => {
|
|
1434
|
+
const source = current.firstPass?.source ?? null;
|
|
1435
|
+
if (source === firstPassSource)
|
|
512
1436
|
return;
|
|
1437
|
+
firstPassSource = source;
|
|
1438
|
+
if (state.firstPass.phase.kind !== 'closed' || state.firstPass.decided.length > 0) {
|
|
1439
|
+
dispatch({ kind: 'first-pass', command: { kind: 'reset' } });
|
|
1440
|
+
}
|
|
1441
|
+
appliedWrites.clear();
|
|
1442
|
+
};
|
|
1443
|
+
// --- listeners ---
|
|
1444
|
+
/**
|
|
1445
|
+
* What a control press means, resolved once for both listeners.
|
|
1446
|
+
*
|
|
1447
|
+
* ONE CONSTRUCTION, TWO CALLERS. A pointer press and a key press perform the
|
|
1448
|
+
* same act, and until `#158` only the pointer could perform it — so the
|
|
1449
|
+
* keyboard's half was written as a second reading of the same attributes,
|
|
1450
|
+
* free to disagree with this one about which act a press is. Extracting the
|
|
1451
|
+
* answer is what makes "the click path and the key path do the same thing" a
|
|
1452
|
+
* fact rather than a claim two code paths happen to agree on today.
|
|
1453
|
+
*
|
|
1454
|
+
* WHY A REFUSAL IS A STATE AND NOT A `null`. The two callers want opposite
|
|
1455
|
+
* things from a control that declines, so collapsing the two would be wrong
|
|
1456
|
+
* for one of them whichever way it collapsed:
|
|
1457
|
+
*
|
|
1458
|
+
* - `onClick` must STOP on a refusal. Fall through and a press on an inert
|
|
1459
|
+
* `First pass →` reaches the identity branch below and selects whatever row
|
|
1460
|
+
* or group happens to sit under it — an act the reader never asked for.
|
|
1461
|
+
* - `onKeydown` must hand an `input` refusal BACK to the platform and hold an
|
|
1462
|
+
* `inert` one. Hence the reason travels with the refusal: see the arm's own
|
|
1463
|
+
* comment for the space character that is lost when it does not.
|
|
1464
|
+
*
|
|
1465
|
+
* The alternative considered and rejected: `HostCommand | null` plus an
|
|
1466
|
+
* `isControl(target)` predicate for the callers to gate on. The predicate has
|
|
1467
|
+
* to re-walk these same three `closest` calls, which is a second construction
|
|
1468
|
+
* of the very thing this function exists to make singular.
|
|
1469
|
+
*
|
|
1470
|
+
* THREE `closest` WALKS RATHER THAN ONE COMBINED SELECTOR, on purpose. A
|
|
1471
|
+
* single `closest('[a],[b],[c]')` answers with the NEAREST of the three,
|
|
1472
|
+
* which is not the same question: these channels are asked in a fixed
|
|
1473
|
+
* priority — a command wins over an answer, an answer over the audit filter —
|
|
1474
|
+
* and where two nest, the combined selector would answer with the inner one
|
|
1475
|
+
* whatever the priority says. The saving is two ancestor walks on a press,
|
|
1476
|
+
* which is a human keystroke rather than a loop, and the risk is changing
|
|
1477
|
+
* which act a press performs.
|
|
1478
|
+
*/
|
|
1479
|
+
const controlAnswer = (target) => {
|
|
513
1480
|
const control = target.closest(`[${COMMAND_ATTRIBUTE}]`);
|
|
514
1481
|
if (control !== null && surface.contains(control)) {
|
|
515
1482
|
const name = control.getAttribute(COMMAND_ATTRIBUTE) ?? '';
|
|
516
1483
|
if (isInput(control))
|
|
517
|
-
return; // the `input` listener owns these
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
1484
|
+
return REFUSED_INPUT; // the `input` listener owns these
|
|
1485
|
+
// THE ENTRY IS INERT WITHOUT A BUNDLE, and it is withheld HERE rather
|
|
1486
|
+
// than in the reducer. `renderWorkspace` draws §17a's `First pass →`
|
|
1487
|
+
// inside this surface, so a host cannot intercept it from outside; and
|
|
1488
|
+
// the reducer's `first-pass` arm would move to `scanning` with no source
|
|
1489
|
+
// to call and no way back. See `MountWorkspaceOptions.firstPass`.
|
|
1490
|
+
if (name === 'first-pass' && current.firstPass === undefined)
|
|
1491
|
+
return REFUSED_INERT;
|
|
1492
|
+
return {
|
|
1493
|
+
kind: 'dispatch',
|
|
1494
|
+
command: {
|
|
1495
|
+
kind: 'control',
|
|
1496
|
+
name,
|
|
1497
|
+
target: control.getAttribute(TARGET_ATTRIBUTE) ?? undefined,
|
|
1498
|
+
// The picker publishes its kind as `data-ig-kind`; the mount's chrome
|
|
1499
|
+
// publishes `data-ig-value`. One command channel, two spellings.
|
|
1500
|
+
value: control.getAttribute('data-ig-value') ?? control.getAttribute('data-ig-kind') ?? undefined,
|
|
1501
|
+
},
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
// AN ANSWER IS ITS OWN ATTRIBUTE, so it falls through the command branch
|
|
1505
|
+
// above and is read here — before the identity branch in `onClick`, which
|
|
1506
|
+
// would otherwise answer a click inside the overlay with whatever key or
|
|
1507
|
+
// group is underneath it.
|
|
1508
|
+
//
|
|
1509
|
+
// REACHED BY THE POINTER ONLY, and deliberately. `onKeydown` returns while
|
|
1510
|
+
// the first-pass phase is open, and this attribute is published only by the
|
|
1511
|
+
// overlay that is drawn while it is open — so the key arm can never see one.
|
|
1512
|
+
// The overlay's own `y`/`n`/`s` map owns those presses, which makes this
|
|
1513
|
+
// half a designed vocabulary rather than a gap.
|
|
1514
|
+
//
|
|
1515
|
+
// WHAT IS A GAP, said plainly because the first draft of this note got it
|
|
1516
|
+
// wrong: the same early return also hides `first-pass-close`, and THAT is a
|
|
1517
|
+
// `data-ig-command` control — the very channel `#158` is about, not one
|
|
1518
|
+
// over. Whether it is activated from `firstPassKeydown` or from a binding in
|
|
1519
|
+
// `firstpass/keys.ts` is a decision about who owns the queue's keyboard, so
|
|
1520
|
+
// it is filed as `#161` rather than settled from inside this function.
|
|
1521
|
+
const answered = target.closest(`[${ANSWER_ATTRIBUTE}]`);
|
|
1522
|
+
if (answered !== null && surface.contains(answered)) {
|
|
1523
|
+
return {
|
|
1524
|
+
kind: 'dispatch',
|
|
1525
|
+
command: {
|
|
1526
|
+
kind: 'control',
|
|
1527
|
+
name: 'first-pass-answer',
|
|
1528
|
+
value: answered.getAttribute(ANSWER_ATTRIBUTE) ?? undefined,
|
|
1529
|
+
},
|
|
1530
|
+
};
|
|
527
1531
|
}
|
|
528
1532
|
if (target.closest('[data-ig-audit-filter]') !== null) {
|
|
529
|
-
dispatch
|
|
1533
|
+
return { kind: 'dispatch', command: { kind: 'control', name: 'audit-filter' } };
|
|
1534
|
+
}
|
|
1535
|
+
return null;
|
|
1536
|
+
};
|
|
1537
|
+
const onClick = (event) => {
|
|
1538
|
+
const target = isElement(event.target) ? event.target : null;
|
|
1539
|
+
if (target === null)
|
|
1540
|
+
return;
|
|
1541
|
+
const answer = controlAnswer(target);
|
|
1542
|
+
if (answer !== null) {
|
|
1543
|
+
// A REFUSAL STILL STOPS THE CLICK, which is what the three `return`s this
|
|
1544
|
+
// branch replaced did. See {@link controlAnswer} for why falling through
|
|
1545
|
+
// would select something underneath the control instead.
|
|
1546
|
+
if (answer.kind === 'dispatch')
|
|
1547
|
+
dispatch(answer.command);
|
|
530
1548
|
return;
|
|
531
1549
|
}
|
|
532
1550
|
// THE NEARER IDENTITY WINS. A relationship badge inside a row carries
|
|
@@ -576,7 +1594,19 @@ export function mountWorkspace(element, options) {
|
|
|
576
1594
|
// clamps back to the current start must not dispatch, because every
|
|
577
1595
|
// dispatch redraws and every redraw restores the scroll offset — which
|
|
578
1596
|
// fires this listener again. See `railWindowTarget`.
|
|
579
|
-
|
|
1597
|
+
// THE PITCH IS A ROW'S, and the rail zone holds more than rows: the legend,
|
|
1598
|
+
// and the host header and the NOW list when the host supplies them — one
|
|
1599
|
+
// row per running job, unbounded. Their height is measured off the drawn
|
|
1600
|
+
// tree and subtracted before the offset becomes a row (`railRowAt`), so a
|
|
1601
|
+
// host running dozens of jobs does not scroll the window past ranks the
|
|
1602
|
+
// reader has not reached. Measured, not derived from the theme: the header
|
|
1603
|
+
// wraps, and a wrapped header is taller than any constant would say.
|
|
1604
|
+
const rows = rail.querySelector('.ig-viewer .ig-list');
|
|
1605
|
+
const viewer = rail.querySelector('.ig-viewer');
|
|
1606
|
+
const chrome = rows === null || viewer === null
|
|
1607
|
+
? 0
|
|
1608
|
+
: rows.getBoundingClientRect().top - viewer.getBoundingClientRect().top;
|
|
1609
|
+
const row = railRowAt(rail.scrollTop, chrome, pitch());
|
|
580
1610
|
const start = railWindowTarget(row, state.railStart, railCount(), drawn.rail.total);
|
|
581
1611
|
if (start !== null)
|
|
582
1612
|
dispatch({ kind: 'scroll', start });
|
|
@@ -589,15 +1619,68 @@ export function mountWorkspace(element, options) {
|
|
|
589
1619
|
* filter or the delete button is a control with its own Enter and Space, and
|
|
590
1620
|
* a Backspace there must not delete the selected edge; the key map names
|
|
591
1621
|
* `elsewhere` as "everything that is not our own search box", and a button is
|
|
592
|
-
* that. The target search keeps its two bindings
|
|
593
|
-
*
|
|
1622
|
+
* that. The target search keeps its two bindings.
|
|
1623
|
+
*
|
|
1624
|
+
* ## The kind chooser is the ONE button group that is not `elsewhere`
|
|
1625
|
+
*
|
|
1626
|
+
* The rule above is right for every control it names and wrong for this one,
|
|
1627
|
+
* and the difference is namable rather than a taste: the audit filter and the
|
|
1628
|
+
* delete button share no vocabulary with the key map, while the chooser IS the
|
|
1629
|
+
* key map rendered — its digits are `KIND_KEYS`, the same table `keyIntent`
|
|
1630
|
+
* resolves the press against. A group whose labels tell the reader to press
|
|
1631
|
+
* `2` cannot be a group the reader's `2` is refused by.
|
|
1632
|
+
*
|
|
1633
|
+
* ## Asked of the DRAFT, not of where focus happens to be
|
|
1634
|
+
*
|
|
1635
|
+
* `#152`, and the reason three consecutive rounds of `#150` each fixed a real
|
|
1636
|
+
* bug and surfaced the next one: every one of them moved focus somewhere else,
|
|
1637
|
+
* and this predicate never read the draft, so the next place focus landed
|
|
1638
|
+
* classified `elsewhere` again. Focus is not the fact. A draft with a source
|
|
1639
|
+
* and no kind IS the reader being at the kind step, wherever the mount's
|
|
1640
|
+
* restore happened to put them — and it puts them on the inspector's `clear`,
|
|
1641
|
+
* because the last-resort arm takes the zone's FIRST command control and
|
|
1642
|
+
* `clear` is drawn above the kind list.
|
|
1643
|
+
*
|
|
1644
|
+
* A CONJUNCT ON THE CHOOSER'S OWN MARKUP WAS TRIED ON PAPER AND WOULD HAVE
|
|
1645
|
+
* SHIPPED THE BUG: `closest('<the chooser>')` is false from `clear`, so the
|
|
1646
|
+
* digits would still have died while the change typechecked and its own table
|
|
1647
|
+
* test passed.
|
|
1648
|
+
*
|
|
1649
|
+
* ## `canvas` is asked FIRST, and that ordering is the safety property
|
|
1650
|
+
*
|
|
1651
|
+
* The only answers that move are ones that read `elsewhere` today — where
|
|
1652
|
+
* nothing reaches, so nothing can regress. A reader who leaves the chooser
|
|
1653
|
+
* open and tabs back to a row keeps the whole canvas vocabulary exactly as
|
|
1654
|
+
* before. Note that the order is NOT needed to keep a chooser out of `canvas`:
|
|
1655
|
+
* `focusedKey` walks DOM ancestry, the floating chooser is appended to the
|
|
1656
|
+
* workspace root beside the zones rather than inside the canvas, and the panel
|
|
1657
|
+
* list sits in an inspector that carries no key — so it answers null from
|
|
1658
|
+
* inside either one however this is ordered.
|
|
594
1659
|
*/
|
|
595
1660
|
const interaction = () => {
|
|
596
1661
|
const active = doc.activeElement;
|
|
597
1662
|
if (isElement(active) && isInput(active) && active.getAttribute(COMMAND_ATTRIBUTE) === 'target-query') {
|
|
598
1663
|
return 'target-search';
|
|
599
1664
|
}
|
|
600
|
-
|
|
1665
|
+
if (focusedKey() !== null)
|
|
1666
|
+
return 'canvas';
|
|
1667
|
+
// A FOCUSED TEXT BOX IS NEVER THE CHOOSER, whatever the draft is doing.
|
|
1668
|
+
// `scale/render.ts` draws the search-to-focus input, and it stays usable
|
|
1669
|
+
// while a draft is open — so without this a reader who clicked it and typed
|
|
1670
|
+
// `1` would have the digit taken from their query and spent on a
|
|
1671
|
+
// relationship kind, `preventDefault()` included. That is the failure
|
|
1672
|
+
// `create/keys.ts` withholds the digits from `target-search` to avoid, and
|
|
1673
|
+
// this arm would have reintroduced it one control over.
|
|
1674
|
+
//
|
|
1675
|
+
// NARROWING THE NEW ARM ONLY, deliberately: an input that is not the target
|
|
1676
|
+
// search already read `elsewhere` before this predicate existed, so this
|
|
1677
|
+
// hands those presses back exactly as they were rather than reclassifying
|
|
1678
|
+
// anything that works today. A focused input INSIDE a keyed node still
|
|
1679
|
+
// answers `canvas` above, which is a pre-existing question and not this
|
|
1680
|
+
// change's to settle.
|
|
1681
|
+
if (isInput(active))
|
|
1682
|
+
return 'elsewhere';
|
|
1683
|
+
return isChoosingKind(state.draft) ? 'kind-chooser' : 'elsewhere';
|
|
601
1684
|
};
|
|
602
1685
|
/**
|
|
603
1686
|
* The rail's window edge, decided BEFORE the viewer is asked.
|
|
@@ -695,7 +1778,111 @@ export function mountWorkspace(element, options) {
|
|
|
695
1778
|
}
|
|
696
1779
|
return false;
|
|
697
1780
|
};
|
|
1781
|
+
/**
|
|
1782
|
+
* The queue's keys, while the queue is up.
|
|
1783
|
+
*
|
|
1784
|
+
* Returns whether the press was consumed. The queue OWNS the keyboard while it
|
|
1785
|
+
* is drawn — the create map and the viewer's navigation are not consulted,
|
|
1786
|
+
* because every one of their targets is behind an inert overlay — but a press
|
|
1787
|
+
* it does not recognise is handed back rather than swallowed, which is the
|
|
1788
|
+
* contract `firstpass/keys.ts` states in terms: `none` exists "so the host does
|
|
1789
|
+
* not `preventDefault()` a key it did not consume".
|
|
1790
|
+
*/
|
|
1791
|
+
const firstPassKeydown = (event) => {
|
|
1792
|
+
const { phase } = state.firstPass;
|
|
1793
|
+
if (phase.kind === 'closed')
|
|
1794
|
+
return false;
|
|
1795
|
+
// ESCAPE IS THE MOUNT'S OWN, and it has to be: `firstpass/keys.ts` binds
|
|
1796
|
+
// `y`/`n`/`s` and both spellings of the delete key and nothing else, and its
|
|
1797
|
+
// own tests pin `Escape` to `none`. The exit CONTROL is the mount's, so the
|
|
1798
|
+
// exit KEY is too — without it §17e's "exit anytime" is reachable only with
|
|
1799
|
+
// a pointer, on the one surface that advertises a pointer-free loop.
|
|
1800
|
+
if (event.key === 'Escape') {
|
|
1801
|
+
dispatch({ kind: 'first-pass', command: { kind: 'close' } });
|
|
1802
|
+
return true;
|
|
1803
|
+
}
|
|
1804
|
+
// TAB STAYS INSIDE. `inert` covers the workspace's own zones and nothing
|
|
1805
|
+
// else, so a mount sitting beside other page chrome let Tab — and Shift-Tab
|
|
1806
|
+
// from the wrapper focus lands on — walk straight out of a dialog that
|
|
1807
|
+
// asserts `aria-modal`. The keydown listener is on the mount's element, so
|
|
1808
|
+
// once focus is outside, `Y`/`N`/`S` and Escape all stop working while the
|
|
1809
|
+
// overlay is still up: a keyboard reader stranded with no way back.
|
|
1810
|
+
if (event.key === 'Tab') {
|
|
1811
|
+
const overlay = surface.querySelector('.ig-firstpass-overlay');
|
|
1812
|
+
if (overlay === null)
|
|
1813
|
+
return false;
|
|
1814
|
+
const stops = [...overlay.querySelectorAll('button, [tabindex]:not([tabindex="-1"])')];
|
|
1815
|
+
const first = stops[0];
|
|
1816
|
+
const last = stops[stops.length - 1];
|
|
1817
|
+
if (first === undefined || last === undefined)
|
|
1818
|
+
return false;
|
|
1819
|
+
const active = doc.activeElement;
|
|
1820
|
+
const at = isElement(active) ? stops.indexOf(active) : -1;
|
|
1821
|
+
// WRAPPING AT BOTH ENDS, and treating "focus is on the wrapper" as before
|
|
1822
|
+
// the first stop — which is where it sits on open and after every answer.
|
|
1823
|
+
const next = event.shiftKey ? (at <= 0 ? last : stops[at - 1]) : at === stops.length - 1 ? first : stops[at + 1];
|
|
1824
|
+
(next ?? first).focus({ preventScroll: true });
|
|
1825
|
+
return true;
|
|
1826
|
+
}
|
|
1827
|
+
if (phase.kind !== 'open')
|
|
1828
|
+
return false;
|
|
1829
|
+
const progress = phase.queue;
|
|
1830
|
+
const context = {
|
|
1831
|
+
// `queue` WHENEVER THE OVERLAY HOLDS FOCUS. The surface has no text input
|
|
1832
|
+
// of its own, so the only question is whether the reader is inside it.
|
|
1833
|
+
interaction: isElement(doc.activeElement) &&
|
|
1834
|
+
doc.activeElement.closest('.ig-firstpass-overlay') !== null &&
|
|
1835
|
+
!isInput(doc.activeElement)
|
|
1836
|
+
? 'queue'
|
|
1837
|
+
: 'elsewhere',
|
|
1838
|
+
hasCandidate: progress.cursor < progress.candidates.length,
|
|
1839
|
+
canUndo: progress.answers.length > 0,
|
|
1840
|
+
};
|
|
1841
|
+
const intent = firstPassIntent(event, context);
|
|
1842
|
+
if (intent.kind === 'none')
|
|
1843
|
+
return false;
|
|
1844
|
+
dispatch({ kind: 'first-pass', command: { kind: 'queue', command: intent.command } });
|
|
1845
|
+
return true;
|
|
1846
|
+
};
|
|
698
1847
|
const onKeydown = (event) => {
|
|
1848
|
+
// A HELD KEY BELONGS TO THE CONTROL IT ACTIVATED, wherever focus went next.
|
|
1849
|
+
//
|
|
1850
|
+
// The arm below already refuses to dispatch twice for one held key, and that
|
|
1851
|
+
// is not enough, because ACTIVATING A CONTROL OFTEN MOVES FOCUS: pressing a
|
|
1852
|
+
// kind option redraws and focuses `target-query`, and a self-removing
|
|
1853
|
+
// control falls back to a rail row. So the repeats of that same press arrive
|
|
1854
|
+
// at a DIFFERENT element, where the arm's question — "is what holds focus
|
|
1855
|
+
// now a control?" — answers about the wrong thing entirely. Held `Space` on
|
|
1856
|
+
// a kind option would type spaces into the query the reader has not started;
|
|
1857
|
+
// held `Space` on `add` would walk the rail. Each is the platform doing
|
|
1858
|
+
// exactly the right thing with a press this package took halfway.
|
|
1859
|
+
//
|
|
1860
|
+
// Recorded as a FACT ABOUT THE PRESS rather than re-derived from focus,
|
|
1861
|
+
// which is the same shape as the rest of this file's fixes for this class:
|
|
1862
|
+
// where focus is is not what the question was about. It ends at `keyup`, and
|
|
1863
|
+
// a fresh non-repeat press of the same key clears it too — so a `keyup` lost
|
|
1864
|
+
// to a window blur costs one held key rather than every later one.
|
|
1865
|
+
if (event.repeat && activating.has(event.key)) {
|
|
1866
|
+
event.preventDefault();
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1869
|
+
// A FRESH PRESS ENDS ONLY ITS OWN KEY'S RECORD, and with a set that falls
|
|
1870
|
+
// out rather than being arranged: every key's record is its own, so no other
|
|
1871
|
+
// key's press can reach it. A reader holding `Space` on a control and
|
|
1872
|
+
// touching `Shift` used to have the whole record dropped, and the next
|
|
1873
|
+
// `Space` repeat landed wherever the activation had sent focus.
|
|
1874
|
+
if (!event.repeat)
|
|
1875
|
+
activating.delete(event.key);
|
|
1876
|
+
if (firstPassKeydown(event)) {
|
|
1877
|
+
event.preventDefault();
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
// THE QUEUE STILL OWNS THE SURFACE even for a key it did not consume: every
|
|
1881
|
+
// target the create map and the viewer's navigation could reach is behind
|
|
1882
|
+
// the overlay and inert, so consulting them would act on something the
|
|
1883
|
+
// reader cannot see.
|
|
1884
|
+
if (state.firstPass.phase.kind !== 'closed')
|
|
1885
|
+
return;
|
|
699
1886
|
const document_ = landed();
|
|
700
1887
|
const match = targetMatches(document_.issues, state.targetQuery, state.draft.source)[0]?.ref ?? null;
|
|
701
1888
|
const context = {
|
|
@@ -713,8 +1900,152 @@ export function mountWorkspace(element, options) {
|
|
|
713
1900
|
dispatch({ kind: 'intent', intent });
|
|
714
1901
|
return;
|
|
715
1902
|
}
|
|
716
|
-
if (context.focused !== null && navigateFocus(event))
|
|
1903
|
+
if (context.focused !== null && navigateFocus(event)) {
|
|
1904
|
+
event.preventDefault();
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
// --- activating a control from the keyboard ---
|
|
1908
|
+
//
|
|
1909
|
+
// ## Why this is last, and why that is the safety property
|
|
1910
|
+
//
|
|
1911
|
+
// The same argument `interaction()` makes for its own ordering: the only
|
|
1912
|
+
// answers that move are ones nothing reaches today, so nothing can regress.
|
|
1913
|
+
// Two facts make it true here rather than merely plausible.
|
|
1914
|
+
//
|
|
1915
|
+
// `interaction()` answers `elsewhere` for a focused command control that is
|
|
1916
|
+
// neither an input nor the kind step, so `keyIntent` returns `none` for
|
|
1917
|
+
// every key there. At the KIND STEP it answers `kind-chooser` — the
|
|
1918
|
+
// predicate is asked of the DRAFT, not of where focus is — and there
|
|
1919
|
+
// `enter` does not survive and `' '` is unbound, so `keyIntent` is silent
|
|
1920
|
+
// again. So this arm does claim `Enter` and `Space` on the inspector's
|
|
1921
|
+
// `clear`, which is where the mount's focus restore lands after `begin`.
|
|
1922
|
+
// That is a control doing what a control does, and it is new behaviour on
|
|
1923
|
+
// a step the create loop passes through every time — said out loud because
|
|
1924
|
+
// it is the one place this arm changes an interaction that already worked.
|
|
1925
|
+
//
|
|
1926
|
+
// And `navigateFocus` is gated on a focused `[data-ig-key]` ancestor, which
|
|
1927
|
+
// no control has: every command control this package draws sits in chrome —
|
|
1928
|
+
// the inspector, the header, the ladder's refusal list and search — beside
|
|
1929
|
+
// the keyed rows rather than inside one.
|
|
1930
|
+
//
|
|
1931
|
+
// ## Why cancelling is mandatory here, not tidy
|
|
1932
|
+
//
|
|
1933
|
+
// On a real `<button>` the browser's ACTIVATION BEHAVIOUR is this keydown's
|
|
1934
|
+
// default action, and `onClick` sees the click it synthesises. Cancelling
|
|
1935
|
+
// the press is therefore what stops this arm and the browser from both
|
|
1936
|
+
// dispatching — the double fire that was, correctly, the reason this was
|
|
1937
|
+
// never built. Every path below that resolves a control reaches
|
|
1938
|
+
// `preventDefault()` for that reason first and the page's scroll second.
|
|
1939
|
+
//
|
|
1940
|
+
// ## …and why an `input` refusal is handed back anyway
|
|
1941
|
+
//
|
|
1942
|
+
// Both search boxes are `<input>` elements carrying `data-ig-command` — the
|
|
1943
|
+
// chrome's `target-query` and the ladder's `search`. `' '` is unbound in
|
|
1944
|
+
// `create/keys.ts` and neither input has a keyed ancestor, so a space
|
|
1945
|
+
// pressed in either arrives HERE. Cancel it and the reader cannot type a
|
|
1946
|
+
// space into the target query, which is step three of the very
|
|
1947
|
+
// `R → digit → search → ⏎` loop this arm exists to complete. The `input`
|
|
1948
|
+
// listener and the platform's text editing own those characters.
|
|
1949
|
+
//
|
|
1950
|
+
// An `inert` refusal keeps the press for the OTHER two reasons this block
|
|
1951
|
+
// gives: `Space` would scroll the page, and this package has answered. It is
|
|
1952
|
+
// NOT that handing it back would let a native button activate the entry —
|
|
1953
|
+
// that was the first draft of this comment and it is false, because the
|
|
1954
|
+
// click a handed-back press synthesizes reaches `onClick`, which asks the
|
|
1955
|
+
// same `controlAnswer` and gets the same refusal. Recorded wrong-then-right
|
|
1956
|
+
// because the argument, not the behaviour, is what the next reader inherits.
|
|
1957
|
+
//
|
|
1958
|
+
// `' '` is the modern `KeyboardEvent.key` value for the space bar; legacy
|
|
1959
|
+
// `'Spacebar'` is not bound. `viewer/navigation.ts` is the package's one
|
|
1960
|
+
// other key reader that binds it and spells it the same way. (`create/keys.ts`
|
|
1961
|
+
// is NOT a precedent either way: it binds no space key, and lowercases the
|
|
1962
|
+
// names it does bind.)
|
|
1963
|
+
if (event.key !== 'Enter' && event.key !== ' ')
|
|
1964
|
+
return;
|
|
1965
|
+
// TWO OF THE REFUSALS `create/keys.ts` ARGUES FOR ITS OWN BINDINGS, and for
|
|
1966
|
+
// the same reasons — a chord is the platform's shortcut, and `Enter` while
|
|
1967
|
+
// an input method is composing confirms its candidate. Read off the event
|
|
1968
|
+
// rather than imported: those predicates are that module's private
|
|
1969
|
+
// business, and this is the shell's half. Its third, `repeat`, is answered
|
|
1970
|
+
// below rather than here, and the difference is not cosmetic — see there.
|
|
1971
|
+
if (event.ctrlKey || event.metaKey || event.altKey)
|
|
1972
|
+
return;
|
|
1973
|
+
if (event.isComposing)
|
|
1974
|
+
return;
|
|
1975
|
+
// THE FOCUSED ELEMENT. `onClick` reads `event.target` because a click's
|
|
1976
|
+
// target IS where the pointer landed; a keydown's target is the focused
|
|
1977
|
+
// element only under the browser's own dispatch, so a press delegated from a
|
|
1978
|
+
// container would name the container instead. `interaction()` and
|
|
1979
|
+
// `navigateFocus` both read `activeElement` for that reason, and an arm in
|
|
1980
|
+
// the same handler answering a different question would be the outlier.
|
|
1981
|
+
const active = doc.activeElement;
|
|
1982
|
+
if (!isElement(active))
|
|
1983
|
+
return;
|
|
1984
|
+
const answer = controlAnswer(active);
|
|
1985
|
+
if (answer === null)
|
|
1986
|
+
return;
|
|
1987
|
+
if (answer.kind === 'refused' && answer.reason === 'input')
|
|
1988
|
+
return;
|
|
1989
|
+
// A REPEAT IS SWALLOWED, NOT HANDED BACK, and it has to be resolved first to
|
|
1990
|
+
// know that it is ours to swallow. Handing it back looked like the modest
|
|
1991
|
+
// choice and is the one that breaks: `Space` activates a `<button>` on
|
|
1992
|
+
// KEYUP, so a held `Space` whose first keydown this arm cancels and whose
|
|
1993
|
+
// second it releases lets the platform mark the button active — and the
|
|
1994
|
+
// keyup then synthesizes a click that dispatches the act a SECOND time. One
|
|
1995
|
+
// held key, two edits; on a toggle the two cancel and the control reads
|
|
1996
|
+
// dead. So the reader holding a key is one decision, which is what
|
|
1997
|
+
// `create/keys.ts` says a repeat is, and cancelling is how it stays one.
|
|
1998
|
+
// (`Enter` repeats natively either way; only `Space` carries the asymmetry,
|
|
1999
|
+
// and a rule that split them would be a rule about keyboards rather than
|
|
2000
|
+
// about acts.)
|
|
2001
|
+
// A REFUSAL IS THIS PACKAGE'S OWN ANSWER, so it keeps the press: `Space`
|
|
2002
|
+
// would otherwise scroll the page under a reader who has just pressed
|
|
2003
|
+
// something, and there is nothing else waiting for this key.
|
|
2004
|
+
if (answer.kind === 'refused') {
|
|
717
2005
|
event.preventDefault();
|
|
2006
|
+
return;
|
|
2007
|
+
}
|
|
2008
|
+
// DISPATCH FIRST, THEN DECIDE WHETHER THE PRESS WAS OURS TO KEEP — and that
|
|
2009
|
+
// order is the whole of this arm's remaining subtlety.
|
|
2010
|
+
//
|
|
2011
|
+
// `data-ig-command` IS A SHARED NAMESPACE. `reduceHost`'s `default` arm says
|
|
2012
|
+
// so in terms: a host's own chrome publishes on the same attribute, and
|
|
2013
|
+
// layer 1 already does — the freshness `refresh`, `retry:index`,
|
|
2014
|
+
// `review-pick-order`, `dismiss:adoption` are all drawn inside this surface
|
|
2015
|
+
// and all answered by the host's own `click` listener, never by this
|
|
2016
|
+
// reducer. Cancelling their keydown would suppress the native click that
|
|
2017
|
+
// listener is waiting for, and every one of those controls would stop
|
|
2018
|
+
// answering the keyboard — a regression this arm introduced and could not
|
|
2019
|
+
// see, because from out here a command the reducer ignored and one it
|
|
2020
|
+
// handled without changing anything look identical. So the reducer is asked.
|
|
2021
|
+
//
|
|
2022
|
+
// Unclaimed means HANDS OFF, ALL THE WAY OFF — and "all the way" is the
|
|
2023
|
+
// part that needed a second try. Not cancelling is not enough: dispatching
|
|
2024
|
+
// an unclaimed command still ends in `schedule()`, and the redraw replaces
|
|
2025
|
+
// `surface.innerHTML` — including the button the reader is holding. Since
|
|
2026
|
+
// `Space` activates a `<button>` on KEYUP, destroying it before then loses
|
|
2027
|
+
// the activation just as completely as cancelling the press would. So the
|
|
2028
|
+
// reduction is computed, read, and only THEN applied. No `preventDefault`,
|
|
2029
|
+
// no redraw, no `activating`: the platform's own activation is that
|
|
2030
|
+
// control's route, repeats and all, exactly as before this arm existed.
|
|
2031
|
+
if (destroyed)
|
|
2032
|
+
return;
|
|
2033
|
+
const result = reduceHost(state, answer.command, landed());
|
|
2034
|
+
if (!result.claimed)
|
|
2035
|
+
return;
|
|
2036
|
+
applyResult(result);
|
|
2037
|
+
event.preventDefault();
|
|
2038
|
+
// THE PRESS IS NOW RECORDED AS OURS, so its repeats are swallowed at the top
|
|
2039
|
+
// of this handler wherever the dispatch has since sent focus. Added beside
|
|
2040
|
+
// any other key already held rather than replacing it.
|
|
2041
|
+
activating.add(event.key);
|
|
2042
|
+
};
|
|
2043
|
+
// THE HELD KEY IS LET GO. Registered beside the keydown listener rather than on
|
|
2044
|
+
// the document, so a mount that is torn down takes it with it — and a `keyup`
|
|
2045
|
+
// that never arrives because focus left the window is survivable, which is why
|
|
2046
|
+
// `onKeydown` clears the flag on a fresh press too.
|
|
2047
|
+
const onKeyup = (event) => {
|
|
2048
|
+
activating.delete(event.key);
|
|
718
2049
|
};
|
|
719
2050
|
const onPointerDown = (event) => {
|
|
720
2051
|
// ONE PRIMARY MAIN-BUTTON PRESS AT A TIME. A second pointer during a drag
|
|
@@ -807,6 +2138,7 @@ export function mountWorkspace(element, options) {
|
|
|
807
2138
|
element.addEventListener('compositionend', onCompositionEnd);
|
|
808
2139
|
element.addEventListener('scroll', onScroll, true);
|
|
809
2140
|
element.addEventListener('keydown', onKeydown);
|
|
2141
|
+
element.addEventListener('keyup', onKeyup);
|
|
810
2142
|
element.addEventListener('pointerdown', onPointerDown);
|
|
811
2143
|
element.addEventListener('pointermove', onPointerMove);
|
|
812
2144
|
element.addEventListener('pointerup', onPointerUp);
|
|
@@ -823,6 +2155,7 @@ export function mountWorkspace(element, options) {
|
|
|
823
2155
|
element.removeEventListener('compositionend', onCompositionEnd);
|
|
824
2156
|
element.removeEventListener('scroll', onScroll, true);
|
|
825
2157
|
element.removeEventListener('keydown', onKeydown);
|
|
2158
|
+
element.removeEventListener('keyup', onKeyup);
|
|
826
2159
|
element.removeEventListener('pointerdown', onPointerDown);
|
|
827
2160
|
element.removeEventListener('pointermove', onPointerMove);
|
|
828
2161
|
element.removeEventListener('pointerup', onPointerUp);
|
|
@@ -863,8 +2196,10 @@ export function mountWorkspace(element, options) {
|
|
|
863
2196
|
update(next) {
|
|
864
2197
|
if (destroyed)
|
|
865
2198
|
return;
|
|
866
|
-
if (next !== undefined)
|
|
2199
|
+
if (next !== undefined) {
|
|
867
2200
|
current = { ...current, ...next, store };
|
|
2201
|
+
adoptSource();
|
|
2202
|
+
}
|
|
868
2203
|
schedule();
|
|
869
2204
|
},
|
|
870
2205
|
dispatch,
|