@issuegraph/editor 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/overlay/projected.d.ts +21 -0
- package/dist/overlay/projected.d.ts.map +1 -0
- package/dist/overlay/projected.js +49 -0
- package/dist/overlay/projected.js.map +1 -0
- package/dist/scale/ladder.d.ts.map +1 -1
- package/dist/scale/ladder.js +3 -0
- package/dist/scale/ladder.js.map +1 -1
- package/dist/scale/render.d.ts +19 -0
- package/dist/scale/render.d.ts.map +1 -1
- package/dist/scale/render.js +10 -29
- package/dist/scale/render.js.map +1 -1
- package/dist/workspace/chrome.d.ts +21 -0
- package/dist/workspace/chrome.d.ts.map +1 -0
- package/dist/workspace/chrome.js +178 -0
- package/dist/workspace/chrome.js.map +1 -0
- package/dist/workspace/host.d.ts +160 -0
- package/dist/workspace/host.d.ts.map +1 -0
- package/dist/workspace/host.js +330 -0
- package/dist/workspace/host.js.map +1 -0
- package/dist/workspace/mount.d.ts +139 -0
- package/dist/workspace/mount.d.ts.map +1 -0
- package/dist/workspace/mount.js +909 -0
- package/dist/workspace/mount.js.map +1 -0
- 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/render.d.ts +7 -0
- package/dist/workspace/render.d.ts.map +1 -1
- package/dist/workspace/render.js +7 -1
- package/dist/workspace/render.js.map +1 -1
- package/package.json +5 -3
|
@@ -0,0 +1,909 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The workspace's DOM shell — the only module in this package that touches
|
|
3
|
+
* nodes.
|
|
4
|
+
*
|
|
5
|
+
* `renderWorkspace` renders the three-zone workspace as markup and publishes
|
|
6
|
+
* every control as `data-ig-command`; `@issuegraph/viewer` publishes its two
|
|
7
|
+
* identities, `data-ig-key` and `data-ig-group`. A mount reads those, reduces,
|
|
8
|
+
* and renders again — and that loop is this file. What it DECIDES lives in
|
|
9
|
+
* `host.ts`, as a reducer with no DOM, so this file is left with listeners, an
|
|
10
|
+
* `innerHTML` assignment, and the chrome the panels leave to a mount: the add
|
|
11
|
+
* button, the kind chooser, the target search, the delete button.
|
|
12
|
+
*
|
|
13
|
+
* ## Lifted from the demo, in the viewer's shape
|
|
14
|
+
*
|
|
15
|
+
* This was the demo's `workspace.ts`, written once for that page. The viewer
|
|
16
|
+
* made the opposite call for the same problem — it exports `mountViewer` with
|
|
17
|
+
* `update` and `destroy` — and that is the entry point a typed host actually
|
|
18
|
+
* consumes. It took nine review rounds to get this shell right, and every
|
|
19
|
+
* finding was in the shell, none in the reducer; a second host would have
|
|
20
|
+
* re-run them. So the shell ships, in the viewer's shape: one function, one
|
|
21
|
+
* handle, and a container it touches and nothing else.
|
|
22
|
+
*
|
|
23
|
+
* ## `innerHTML`, and why it is not the thing `render.ts` refused
|
|
24
|
+
*
|
|
25
|
+
* Nothing in this file interpolates host text into markup. What IS assigned as
|
|
26
|
+
* markup is the package's own rendered output, which `renderMarkup` escaped —
|
|
27
|
+
* the exact bytes a server-rendered host would send. The chrome beside it is
|
|
28
|
+
* built with `createElement` and `textContent`, so the two disciplines meet at
|
|
29
|
+
* the seam rather than mixing.
|
|
30
|
+
*
|
|
31
|
+
* ## One listener per event, at the root
|
|
32
|
+
*
|
|
33
|
+
* The workspace is re-rendered on every change, so a listener attached to a
|
|
34
|
+
* rendered node dies with it. Delegation at the root is what makes the loop
|
|
35
|
+
* cheap to reason about: one `click`, one `input`, one `keydown`, one scroll
|
|
36
|
+
* (captured, because scroll does not bubble), and the three pointer events the
|
|
37
|
+
* canvas drag needs.
|
|
38
|
+
*
|
|
39
|
+
* ## It reaches no global
|
|
40
|
+
*
|
|
41
|
+
* The document comes from the element the caller passed, which is what keeps
|
|
42
|
+
* this module importable on a runtime that has no DOM — the property
|
|
43
|
+
* `purity.test.ts` measures. An element is recognised by what it can do rather
|
|
44
|
+
* than by `instanceof` against a constructor this module would have to reach
|
|
45
|
+
* for, and a pointer release outside the element is heard on the element's own
|
|
46
|
+
* document rather than on a window.
|
|
47
|
+
*/
|
|
48
|
+
import { edgeIdentity } from '@issuegraph/core';
|
|
49
|
+
import { navigate, renderViewer, resolveTheme, } from '@issuegraph/viewer';
|
|
50
|
+
import { keyIntent } from "../create/keys.js";
|
|
51
|
+
import { pickerPlacement } from "../create/placement.js";
|
|
52
|
+
import { STATE_ATTRIBUTE, overlayFor } from "../overlay/grammar.js";
|
|
53
|
+
import { overlaysFor } from "../overlay/projected.js";
|
|
54
|
+
import { renderPicker } from "../picker/render.js";
|
|
55
|
+
import { pickerStylesheet } from "../picker/styles.js";
|
|
56
|
+
import { scaleLadder } from "../scale/ladder.js";
|
|
57
|
+
import { mountStylesheet } from "./chrome.js";
|
|
58
|
+
import { INITIAL_HOST_STATE, KINDS, railRowAt, railSlackFor, railWindowTarget, reconcileHost, reduceHost, targetMatches, } from "./host.js";
|
|
59
|
+
import { renderWorkspace } from "./render.js";
|
|
60
|
+
import { selectedEdgeId, selectedKey } from "./selection.js";
|
|
61
|
+
/** What the canvas zone draws: the editor's scale ladder, or the viewer's tree projection. */
|
|
62
|
+
export const CANVAS_MODES = Object.freeze(['neighbourhood', 'tree']);
|
|
63
|
+
/** The package default: wide enough that a scroll rarely lands past the drawn rows. */
|
|
64
|
+
export const MOUNT_RAIL_COUNT = 80;
|
|
65
|
+
/** The kind chooser's size for placement — the stylesheet decides the real one; this only picks a corner. */
|
|
66
|
+
const CHOOSER_SIZE = { width: 280, height: 220 };
|
|
67
|
+
/** The drag threshold, in CSS pixels: a press that moves less is a click. */
|
|
68
|
+
const DRAG_THRESHOLD = 6;
|
|
69
|
+
const KEY_ATTRIBUTE = 'data-ig-key';
|
|
70
|
+
const GROUP_ATTRIBUTE = 'data-ig-group';
|
|
71
|
+
const COMMAND_ATTRIBUTE = 'data-ig-command';
|
|
72
|
+
/**
|
|
73
|
+
* An element, recognised by what it can do.
|
|
74
|
+
*
|
|
75
|
+
* Not `instanceof Element`: that reaches for a constructor this module has no
|
|
76
|
+
* global for, and on a runtime with two documents — a test's jsdom beside
|
|
77
|
+
* Node's own globals — it answers wrong even where one exists.
|
|
78
|
+
*/
|
|
79
|
+
function isElement(target) {
|
|
80
|
+
return target !== null && target !== undefined && 'closest' in target && 'getAttribute' in target;
|
|
81
|
+
}
|
|
82
|
+
function isFocusable(node) {
|
|
83
|
+
return node !== null && node !== undefined && 'focus' in node;
|
|
84
|
+
}
|
|
85
|
+
function isInput(node) {
|
|
86
|
+
return node !== null && node !== undefined && node.tagName === 'INPUT';
|
|
87
|
+
}
|
|
88
|
+
function isComposing(event) {
|
|
89
|
+
return 'isComposing' in event && event.isComposing === true;
|
|
90
|
+
}
|
|
91
|
+
/** Every element under `scope` carrying exactly this key, in document order. */
|
|
92
|
+
function withKey(scope, key) {
|
|
93
|
+
return [...scope.querySelectorAll(`[${KEY_ATTRIBUTE}]`)].filter((node) => node.getAttribute(KEY_ATTRIBUTE) === key);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The document the CANVAS draws: the host's document without its host facts.
|
|
97
|
+
*
|
|
98
|
+
* The rail draws the header, the NOW list and the freshness stamp — every
|
|
99
|
+
* host fact is whole-order, and the rail is the workspace's order surface. The
|
|
100
|
+
* scale ladder's focus already drops `host` for the graph canvas; the tree
|
|
101
|
+
* canvas renders the whole document and so drew a second header and a second
|
|
102
|
+
* refresh control in the same workspace. One place strips it for both.
|
|
103
|
+
*/
|
|
104
|
+
function withoutHost(document) {
|
|
105
|
+
if (document.host === undefined)
|
|
106
|
+
return document;
|
|
107
|
+
const { host: _host, ...rest } = document;
|
|
108
|
+
return rest;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Mount the workspace into an element.
|
|
112
|
+
*
|
|
113
|
+
* Returns a handle rather than nothing, for the viewer's reason: a host that
|
|
114
|
+
* cannot tear this down leaks a listener on every re-render.
|
|
115
|
+
*/
|
|
116
|
+
export function mountWorkspace(element, options) {
|
|
117
|
+
const doc = element.ownerDocument;
|
|
118
|
+
const { store } = options;
|
|
119
|
+
let current = options;
|
|
120
|
+
const styles = doc.createElement('style');
|
|
121
|
+
const surface = doc.createElement('div');
|
|
122
|
+
surface.className = 'ig-mount';
|
|
123
|
+
element.append(styles, surface);
|
|
124
|
+
let state = INITIAL_HOST_STATE;
|
|
125
|
+
let pending = false;
|
|
126
|
+
let destroyed = false;
|
|
127
|
+
// What the last redraw drew, kept so a key press can ask the viewer's own
|
|
128
|
+
// navigation reducer about the scene the reader is looking at.
|
|
129
|
+
let drawn = null;
|
|
130
|
+
// A rail row to focus once the window has been re-cut around it.
|
|
131
|
+
let pendingFocus = null;
|
|
132
|
+
let pressed = null;
|
|
133
|
+
// Whether the last render drew the target search, so focus moves into it on
|
|
134
|
+
// the render that OPENS it and not on every render while it stays open.
|
|
135
|
+
let searchWasOpen = false;
|
|
136
|
+
const railCount = () => current.railCount ?? MOUNT_RAIL_COUNT;
|
|
137
|
+
const theme = () => resolveTheme(current.theme);
|
|
138
|
+
const el = (tag, attributes = {}, children = []) => {
|
|
139
|
+
const node = doc.createElement(tag);
|
|
140
|
+
for (const [name, value] of Object.entries(attributes))
|
|
141
|
+
node.setAttribute(name, value);
|
|
142
|
+
node.append(...children);
|
|
143
|
+
return node;
|
|
144
|
+
};
|
|
145
|
+
const button = (label, command, attributes = {}) => el('button', { type: 'button', class: 'ig-chrome-button', [COMMAND_ATTRIBUTE]: command, ...attributes }, [label]);
|
|
146
|
+
const landed = () => {
|
|
147
|
+
const snapshot = store.getSnapshot();
|
|
148
|
+
return { issues: snapshot.issues, edges: snapshot.landed };
|
|
149
|
+
};
|
|
150
|
+
// COALESCED ON A MICROTASK, NOT A FRAME. The store notifies once per state
|
|
151
|
+
// change and a hydrate or a settling write can produce several in one task;
|
|
152
|
+
// one render per task is what a reader sees anyway. A frame would coalesce
|
|
153
|
+
// the same way but never fires while the tab is hidden, which stalls every
|
|
154
|
+
// store notification until the reader returns — and makes the surface
|
|
155
|
+
// impossible to drive headlessly, which is how it is verified.
|
|
156
|
+
const schedule = () => {
|
|
157
|
+
if (pending || destroyed)
|
|
158
|
+
return;
|
|
159
|
+
pending = true;
|
|
160
|
+
queueMicrotask(() => {
|
|
161
|
+
pending = false;
|
|
162
|
+
render();
|
|
163
|
+
});
|
|
164
|
+
};
|
|
165
|
+
const perform = (effect) => {
|
|
166
|
+
switch (effect.kind) {
|
|
167
|
+
case 'propose':
|
|
168
|
+
void store.propose(effect.proposal);
|
|
169
|
+
return;
|
|
170
|
+
case 'retry': {
|
|
171
|
+
// A conflict retries against the LATEST document. The store owns that
|
|
172
|
+
// resolution: it reserves the edit, re-reads, then re-dispatches as one
|
|
173
|
+
// operation, so there is nothing for the mount to sequence.
|
|
174
|
+
const record = store.getSnapshot().writes.find((each) => each.mutationId === effect.mutationId);
|
|
175
|
+
if (record?.state === 'conflict') {
|
|
176
|
+
void store.retryOnLatest(effect.mutationId);
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
void store.retry(effect.mutationId);
|
|
180
|
+
}
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
case 'discard':
|
|
184
|
+
store.discardMine(effect.mutationId);
|
|
185
|
+
return;
|
|
186
|
+
case 'dismiss-change':
|
|
187
|
+
store.dismissChange();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
const dispatch = (command) => {
|
|
192
|
+
if (destroyed)
|
|
193
|
+
return;
|
|
194
|
+
const result = reduceHost(state, command, landed());
|
|
195
|
+
state = result.state;
|
|
196
|
+
for (const effect of result.effects)
|
|
197
|
+
perform(effect);
|
|
198
|
+
schedule();
|
|
199
|
+
};
|
|
200
|
+
const zone = (name) => surface.querySelector(`.ig-zone[data-zone="${name}"]`);
|
|
201
|
+
const pitch = () => theme().metrics['--ig-row-height'] + theme().metrics['--ig-space-tight'];
|
|
202
|
+
const kindChooser = (source, target) => {
|
|
203
|
+
const { words } = current;
|
|
204
|
+
const chooser = el('div', { class: 'ig-chrome-chooser', role: 'group', 'aria-label': words.picker.heading });
|
|
205
|
+
chooser.append(el('p', { class: 'ig-chrome-sentence' }, [`#${source} … ${target === null ? words.chooseKind : `#${target}`}`]));
|
|
206
|
+
const list = el('div', { class: 'ig-chrome-kinds' });
|
|
207
|
+
KINDS.forEach((kind, index) => {
|
|
208
|
+
list.append(button(`${String(index + 1)} ${words.picker.kinds[kind]}`, 'kind', {
|
|
209
|
+
'data-ig-value': kind,
|
|
210
|
+
'data-edge': kind,
|
|
211
|
+
}));
|
|
212
|
+
});
|
|
213
|
+
chooser.append(list, button(words.cancel, 'cancel', { class: 'ig-chrome-button ig-chrome-quiet' }));
|
|
214
|
+
return chooser;
|
|
215
|
+
};
|
|
216
|
+
const targetSearch = (document, source, kind) => {
|
|
217
|
+
const { words } = current;
|
|
218
|
+
const search = el('div', { class: 'ig-chrome-search' });
|
|
219
|
+
search.append(el('p', { class: 'ig-chrome-sentence' }, [`#${source} ${words.picker.kinds[kind]} …`]));
|
|
220
|
+
const input = el('input', {
|
|
221
|
+
type: 'search',
|
|
222
|
+
class: 'ig-chrome-input',
|
|
223
|
+
placeholder: words.targetPlaceholder,
|
|
224
|
+
'aria-label': words.targetLabel,
|
|
225
|
+
[COMMAND_ATTRIBUTE]: 'target-query',
|
|
226
|
+
});
|
|
227
|
+
input.value = state.targetQuery;
|
|
228
|
+
search.append(input);
|
|
229
|
+
const matches = targetMatches(document.issues, state.targetQuery, source);
|
|
230
|
+
if (matches.length > 0) {
|
|
231
|
+
const list = el('ul', { class: 'ig-chrome-matches' });
|
|
232
|
+
for (const match of matches) {
|
|
233
|
+
list.append(el('li', {}, [
|
|
234
|
+
button(`#${match.ref} ${match.title}`, 'target', {
|
|
235
|
+
'data-ig-target': match.ref,
|
|
236
|
+
class: 'ig-chrome-button ig-chrome-match',
|
|
237
|
+
}),
|
|
238
|
+
]));
|
|
239
|
+
}
|
|
240
|
+
search.append(list);
|
|
241
|
+
}
|
|
242
|
+
search.append(button(words.cancel, 'cancel', { class: 'ig-chrome-button ig-chrome-quiet' }));
|
|
243
|
+
return search;
|
|
244
|
+
};
|
|
245
|
+
/** The chrome the inspector zone gets beside the package's own panel. */
|
|
246
|
+
const inspectorChrome = (document) => {
|
|
247
|
+
const { words } = current;
|
|
248
|
+
const panel = el('div', { class: 'ig-chrome', 'data-chrome': 'inspector' });
|
|
249
|
+
const edgeId = selectedEdgeId(state.selection);
|
|
250
|
+
const issue = selectedKey(state.selection);
|
|
251
|
+
const { draft } = state;
|
|
252
|
+
if (edgeId !== null) {
|
|
253
|
+
const picker = el('div', { class: 'ig-chrome-picker' });
|
|
254
|
+
// Package-rendered markup, escaped by the package.
|
|
255
|
+
picker.innerHTML = renderPicker(document, edgeId, { words: words.picker, theme: theme() }).markup;
|
|
256
|
+
panel.append(picker, button(words.deleteRelationship, 'delete', { class: 'ig-chrome-button ig-chrome-danger' }));
|
|
257
|
+
}
|
|
258
|
+
else if (draft.source === null) {
|
|
259
|
+
if (issue !== null)
|
|
260
|
+
panel.append(button(words.addRelationship, 'add'));
|
|
261
|
+
}
|
|
262
|
+
else if (draft.kind === null) {
|
|
263
|
+
// Placed at the drop point when a canvas drag got here; inline otherwise.
|
|
264
|
+
if (state.drop === null)
|
|
265
|
+
panel.append(kindChooser(draft.source, draft.target));
|
|
266
|
+
}
|
|
267
|
+
else if (draft.target === null) {
|
|
268
|
+
panel.append(targetSearch(document, draft.source, draft.kind));
|
|
269
|
+
}
|
|
270
|
+
if (words.keys !== undefined)
|
|
271
|
+
panel.append(el('p', { class: 'ig-chrome-keys' }, [words.keys]));
|
|
272
|
+
return panel;
|
|
273
|
+
};
|
|
274
|
+
const floatingChooser = () => {
|
|
275
|
+
const { draft, drop } = state;
|
|
276
|
+
if (drop === null || draft.source === null || draft.kind !== null)
|
|
277
|
+
return null;
|
|
278
|
+
const bounds = surface.getBoundingClientRect();
|
|
279
|
+
const placed = pickerPlacement(drop, CHOOSER_SIZE, {
|
|
280
|
+
x: 0,
|
|
281
|
+
y: 0,
|
|
282
|
+
width: bounds.width,
|
|
283
|
+
height: bounds.height,
|
|
284
|
+
});
|
|
285
|
+
const chooser = kindChooser(draft.source, draft.target);
|
|
286
|
+
chooser.classList.add('ig-chrome-floating');
|
|
287
|
+
chooser.style.left = `${String(placed.x)}px`;
|
|
288
|
+
chooser.style.top = `${String(placed.y)}px`;
|
|
289
|
+
return chooser;
|
|
290
|
+
};
|
|
291
|
+
/** Focus the element carrying `key`, inside one zone when named, without scrolling the page. */
|
|
292
|
+
const focusIn = (zoneName, key) => {
|
|
293
|
+
const scope = zoneName === null ? surface : (zone(zoneName) ?? surface);
|
|
294
|
+
const candidates = withKey(scope, key);
|
|
295
|
+
// THE FOCUSABLE ONE. The graph draws an issue twice — its SVG node and the
|
|
296
|
+
// rail row positioned over it — and only the element carrying `tabindex`
|
|
297
|
+
// takes focus; the first match by document order is the node, on which
|
|
298
|
+
// `focus()` is a no-op and the reader's focus falls off the page.
|
|
299
|
+
const target = candidates.find((node) => node.hasAttribute('tabindex')) ?? candidates[0];
|
|
300
|
+
if (target === undefined)
|
|
301
|
+
return;
|
|
302
|
+
// THE ROVING TAB STOP MOVES WITH FOCUS, as the viewer's own mount moves it.
|
|
303
|
+
// The zone renders one element at tabindex 0 and the rest at -1; moving
|
|
304
|
+
// focus without moving the stop leaves Tab returning to the old row when
|
|
305
|
+
// the reader leaves the zone and comes back.
|
|
306
|
+
if (target.hasAttribute('tabindex')) {
|
|
307
|
+
const owner = target.closest('.ig-zone') ?? scope;
|
|
308
|
+
for (const stop of owner.querySelectorAll(`[${KEY_ATTRIBUTE}][tabindex="0"]`)) {
|
|
309
|
+
if (stop !== target)
|
|
310
|
+
stop.setAttribute('tabindex', '-1');
|
|
311
|
+
}
|
|
312
|
+
target.setAttribute('tabindex', '0');
|
|
313
|
+
}
|
|
314
|
+
target.focus({ preventScroll: true });
|
|
315
|
+
};
|
|
316
|
+
/**
|
|
317
|
+
* The scene a zone is showing, for the viewer's navigation reducer.
|
|
318
|
+
*
|
|
319
|
+
* `renderWorkspace` composes its scenes and publishes none of them, so the
|
|
320
|
+
* scene is rendered again here from the SAME documents the workspace drew —
|
|
321
|
+
* the rail's window and the ladder's canvas — through the same pure
|
|
322
|
+
* `renderViewer`. That is a second render, not a second implementation: the
|
|
323
|
+
* traversal order, the lateral neighbours and the Enter semantics are the
|
|
324
|
+
* viewer's own, read off its scene rather than guessed from the markup.
|
|
325
|
+
*/
|
|
326
|
+
const sceneFor = (zoneName) => {
|
|
327
|
+
if (drawn === null)
|
|
328
|
+
return null;
|
|
329
|
+
if (zoneName === 'rail')
|
|
330
|
+
return renderViewer(drawn.rail.document, { projection: 'linear', theme: theme() }).scene;
|
|
331
|
+
if (zoneName !== 'canvas')
|
|
332
|
+
return null;
|
|
333
|
+
if (current.canvas === 'tree')
|
|
334
|
+
return renderViewer(withoutHost(drawn.viewer), { projection: 'tree', theme: theme() }).scene;
|
|
335
|
+
const ladder = scaleLadder(drawn.viewer, state.scale);
|
|
336
|
+
return ladder.tier === 'direct' ? renderViewer(ladder.canvas, { projection: 'graph', theme: theme() }).scene : null;
|
|
337
|
+
};
|
|
338
|
+
/** The row or node that owns keyboard focus, if focus is on one at all. */
|
|
339
|
+
const focusedKey = () => {
|
|
340
|
+
const active = doc.activeElement;
|
|
341
|
+
const keyed = isElement(active) ? active.closest(`[${KEY_ATTRIBUTE}]`) : null;
|
|
342
|
+
return keyed !== null && surface.contains(keyed) ? keyed.getAttribute(KEY_ATTRIBUTE) : null;
|
|
343
|
+
};
|
|
344
|
+
const render = () => {
|
|
345
|
+
if (destroyed)
|
|
346
|
+
return;
|
|
347
|
+
const snapshot = store.getSnapshot();
|
|
348
|
+
const document_ = landed();
|
|
349
|
+
// A landed write can retire what the state names; agree with the document first.
|
|
350
|
+
state = reconcileHost(state, document_);
|
|
351
|
+
const projected = current.project(snapshot);
|
|
352
|
+
// WHAT TO DRAW IS THE STORE'S PROJECTION, by the store's own contract:
|
|
353
|
+
// `projected` is "landed plus every unsettled edit, each carrying its
|
|
354
|
+
// states". A host projects the ORDER from the landed document — the order
|
|
355
|
+
// must not move for an edit that did not land — so its viewer document
|
|
356
|
+
// carries landed edges, and an edit in flight would be invisible on the
|
|
357
|
+
// very surface that just proposed it. The unsettled edges are added here,
|
|
358
|
+
// and only those: an edge the host's projection deliberately left out stays
|
|
359
|
+
// out, because a landed edge carries no state and is never added.
|
|
360
|
+
// AND A LANDED EDGE THE STORE HIDES IS DROPPED, for the same reason. A
|
|
361
|
+
// pending retype or flip gives the edge a new identity and the store's
|
|
362
|
+
// projection hides the old one until the write settles; the host's
|
|
363
|
+
// document still carries it, so without this the old line and the new
|
|
364
|
+
// dashed one were drawn together for the life of the write.
|
|
365
|
+
const shown = new Set(snapshot.projected.map((edge) => edge.id));
|
|
366
|
+
const hidden = new Set(snapshot.landed.map((edge) => edge.id).filter((id) => !shown.has(id)));
|
|
367
|
+
const kept = projected.viewer.edges.filter((edge) => !hidden.has(edgeIdentity(edge.field, edge.from, edge.to)));
|
|
368
|
+
const drawn_ = new Set(kept.map((edge) => edgeIdentity(edge.field, edge.from, edge.to)));
|
|
369
|
+
const unsettled = snapshot.projected
|
|
370
|
+
.filter((edge) => edge.states.length > 0 && !drawn_.has(edge.id))
|
|
371
|
+
.map((edge) => ({ field: edge.kind, from: edge.from, to: edge.to }));
|
|
372
|
+
const viewer = unsettled.length === 0 && kept.length === projected.viewer.edges.length
|
|
373
|
+
? projected.viewer
|
|
374
|
+
: { ...projected.viewer, edges: [...kept, ...unsettled] };
|
|
375
|
+
const { audit } = projected;
|
|
376
|
+
const resolved = theme();
|
|
377
|
+
// THE WRITE STATES ONLY. The workspace holds the one selection, and every
|
|
378
|
+
// canvas draws its halo from that; a `selected` the host put on the store
|
|
379
|
+
// through `store.select()` would draw a second halo the inspector does not
|
|
380
|
+
// reflect, so it is stripped before the projection reaches a canvas.
|
|
381
|
+
const writeStates = snapshot.projected.map((edge) => edge.states.includes('selected') ? { ...edge, states: edge.states.filter((state) => state !== 'selected') } : edge);
|
|
382
|
+
const result = renderWorkspace(viewer, {
|
|
383
|
+
words: current.words,
|
|
384
|
+
selection: state.selection,
|
|
385
|
+
scale: state.scale,
|
|
386
|
+
rail: { start: state.railStart, count: railCount() },
|
|
387
|
+
audit,
|
|
388
|
+
auditFiltered: state.auditFiltered,
|
|
389
|
+
theme: resolved,
|
|
390
|
+
themeSelector: current.themeSelector,
|
|
391
|
+
// THE WRITE STATES ONLY. The workspace holds the one selection, and the
|
|
392
|
+
// ladder draws its halo from that; a `selected` the host put on the store
|
|
393
|
+
// through `store.select()` would draw a second halo the inspector does
|
|
394
|
+
// not reflect, so it is stripped before the projection reaches the canvas.
|
|
395
|
+
projected: writeStates,
|
|
396
|
+
});
|
|
397
|
+
const sheet = [result.styles, pickerStylesheet, mountStylesheet].join('\n');
|
|
398
|
+
if (styles.textContent !== sheet)
|
|
399
|
+
styles.textContent = sheet;
|
|
400
|
+
// What the reader was doing survives the redraw: the rail's scroll offset,
|
|
401
|
+
// and the caret in whichever search box they were typing into.
|
|
402
|
+
const railBefore = zone('rail');
|
|
403
|
+
const scrollTop = railBefore?.scrollTop ?? 0;
|
|
404
|
+
const active = doc.activeElement;
|
|
405
|
+
const activeInput = isElement(active) && isInput(active) && surface.contains(active) ? active : null;
|
|
406
|
+
const activeCommand = activeInput?.getAttribute(COMMAND_ATTRIBUTE) ?? null;
|
|
407
|
+
// THE CARET AS IT WAS, not the end of the value: a reader editing in the
|
|
408
|
+
// middle of a query keeps typing there, and every keystroke redraws.
|
|
409
|
+
const caret = activeInput !== null
|
|
410
|
+
? { start: activeInput.selectionStart, end: activeInput.selectionEnd, direction: activeInput.selectionDirection }
|
|
411
|
+
: null;
|
|
412
|
+
const focused = focusedKey();
|
|
413
|
+
// The ZONE too: an issue is commonly drawn in the rail and on the canvas,
|
|
414
|
+
// and restoring "the first element with this key" would move focus from
|
|
415
|
+
// a canvas node into the rail on every redraw.
|
|
416
|
+
const focusedZone = isElement(active) ? (active.closest('.ig-zone')?.getAttribute('data-zone') ?? null) : null;
|
|
417
|
+
drawn = { viewer, rail: result.view.rail };
|
|
418
|
+
// Package-rendered markup, escaped by the package.
|
|
419
|
+
surface.innerHTML = result.markup;
|
|
420
|
+
// THE ORDER'S STATUS, PUBLISHED AS DATA. A write in flight holds the order
|
|
421
|
+
// — it does not move until the edit lands — and a host that wants to say
|
|
422
|
+
// so has nowhere to read it once the snapshot is consumed here. The
|
|
423
|
+
// attribute is the store's own vocabulary, verbatim, so a host styles or
|
|
424
|
+
// reads it without this package inventing a sentence.
|
|
425
|
+
surface.setAttribute('data-order', snapshot.order.status);
|
|
426
|
+
if (current.canvas === 'tree') {
|
|
427
|
+
const canvas = zone('canvas');
|
|
428
|
+
if (canvas !== null) {
|
|
429
|
+
// THE SAME STATES THE LADDER DRAWS, on the tree's badges. The tree
|
|
430
|
+
// draws a relationship as a badge rather than a line, and the overlays
|
|
431
|
+
// module decorates lines only — a halo, a ghost, a dash — so there is
|
|
432
|
+
// nothing for it to attach here. What the badge CAN carry is the state
|
|
433
|
+
// as data, the same `data-ig-state` the ladder's line carries, so a
|
|
434
|
+
// pending edge is not drawn as a settled one and a host styles or reads
|
|
435
|
+
// it the same way in both modes. The merge is the ladder's own.
|
|
436
|
+
canvas.innerHTML = renderViewer(withoutHost(viewer), { projection: 'tree', theme: resolved, selected: selectedKey(state.selection) }).markup;
|
|
437
|
+
const states = new Map(overlaysFor(viewer.edges, writeStates, selectedEdgeId(state.selection)).map((edge) => [edge.id, overlayFor(edge).attribute]));
|
|
438
|
+
for (const badge of canvas.querySelectorAll(`[${GROUP_ATTRIBUTE}]`)) {
|
|
439
|
+
const attribute = states.get(badge.getAttribute(GROUP_ATTRIBUTE) ?? '');
|
|
440
|
+
if (attribute !== undefined && attribute !== null)
|
|
441
|
+
badge.setAttribute(STATE_ATTRIBUTE, attribute);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
zone('inspector')?.append(inspectorChrome(document_));
|
|
446
|
+
// INSIDE THE WORKSPACE ROOT, not beside it: that root is the box the
|
|
447
|
+
// chrome sheet positions the chooser against, and a host that scopes its
|
|
448
|
+
// theme to the root still resolves the chooser's tokens there.
|
|
449
|
+
const floating = floatingChooser();
|
|
450
|
+
if (floating !== null)
|
|
451
|
+
(surface.firstElementChild ?? surface).append(floating);
|
|
452
|
+
const rail = zone('rail');
|
|
453
|
+
if (rail !== null)
|
|
454
|
+
rail.scrollTop = scrollTop;
|
|
455
|
+
// FOCUS SURVIVES THE REDRAW, and it moves to the target search when that
|
|
456
|
+
// step opens. The keyboard path is R -> kind -> search -> Enter, and every
|
|
457
|
+
// step redraws: without this, R destroyed the focused row, the next press
|
|
458
|
+
// landed outside the workspace and read as 'elsewhere', and the advertised
|
|
459
|
+
// pointer-free loop could not get past its first key.
|
|
460
|
+
const search = surface.querySelector(`input[${COMMAND_ATTRIBUTE}="target-query"]`);
|
|
461
|
+
const focusRow = (key, within = focusedZone) => {
|
|
462
|
+
if (key === null)
|
|
463
|
+
return;
|
|
464
|
+
focusIn(within, key);
|
|
465
|
+
};
|
|
466
|
+
if (activeCommand !== null) {
|
|
467
|
+
const again = surface.querySelector(`input[${COMMAND_ATTRIBUTE}="${activeCommand}"]`);
|
|
468
|
+
if (again !== null) {
|
|
469
|
+
again.focus();
|
|
470
|
+
const end = again.value.length;
|
|
471
|
+
again.setSelectionRange(Math.min(caret?.start ?? end, end), Math.min(caret?.end ?? end, end), caret?.direction ?? 'none');
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
// The search closed under the caret — the target was committed or the
|
|
475
|
+
// draft cancelled — so focus returns to the row the flow started on.
|
|
476
|
+
focusRow(selectedKey(state.selection));
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
else if (search !== null && !searchWasOpen) {
|
|
480
|
+
// ON THE RENDER THAT OPENS IT ONLY. A store notification while the search
|
|
481
|
+
// stands open and focus rests on the host's own chrome must not yank
|
|
482
|
+
// focus back into the search; the caret arm above already keeps it when
|
|
483
|
+
// the reader is typing there.
|
|
484
|
+
search.focus();
|
|
485
|
+
}
|
|
486
|
+
else if (pendingFocus !== null) {
|
|
487
|
+
const rows = result.view.rail.rows;
|
|
488
|
+
const wanted = pendingFocus.key;
|
|
489
|
+
const at = rows.findIndex((slot) => wanted !== null && slot.members.includes(wanted));
|
|
490
|
+
// THE ENDS ARE THE VIEWER'S ENDS. The linear projection appends the
|
|
491
|
+
// excluded rows after the slots, so the last focusable row of the last
|
|
492
|
+
// window is an exclusion when the document has any; read the ends off
|
|
493
|
+
// what the rail actually drew rather than off the slots alone.
|
|
494
|
+
const drawnKeys = [...(rail?.querySelectorAll(`[${KEY_ATTRIBUTE}][tabindex]`) ?? [])].map((row) => row.getAttribute(KEY_ATTRIBUTE));
|
|
495
|
+
const target = pendingFocus.kind === 'first' ? (drawnKeys[0] ?? rows[0]?.lead)
|
|
496
|
+
: pendingFocus.kind === 'last' ? (drawnKeys[drawnKeys.length - 1] ?? rows[rows.length - 1]?.lead)
|
|
497
|
+
: pendingFocus.kind === 'after' ? rows[at + 1]?.lead
|
|
498
|
+
: at > 0 ? rows[at - 1]?.lead : undefined;
|
|
499
|
+
const jump = pendingFocus.kind;
|
|
500
|
+
pendingFocus = null;
|
|
501
|
+
focusRow(target ?? focused, 'rail');
|
|
502
|
+
// THE VIEWPORT FOLLOWS THE JUMP. The window was re-cut around the target
|
|
503
|
+
// but the scroll offset restored above is the one from before the key
|
|
504
|
+
// press, so without this the focused row sits below (or above) the
|
|
505
|
+
// visible rows and the reader sees nothing move.
|
|
506
|
+
if (rail !== null) {
|
|
507
|
+
if (jump === 'last')
|
|
508
|
+
rail.scrollTop = rail.scrollHeight;
|
|
509
|
+
else if (jump === 'first')
|
|
510
|
+
rail.scrollTop = 0;
|
|
511
|
+
else {
|
|
512
|
+
const now = doc.activeElement;
|
|
513
|
+
if (isFocusable(now) && typeof now.scrollIntoView === 'function')
|
|
514
|
+
now.scrollIntoView({ block: 'nearest' });
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
else {
|
|
519
|
+
focusRow(focused);
|
|
520
|
+
}
|
|
521
|
+
searchWasOpen = search !== null;
|
|
522
|
+
};
|
|
523
|
+
// --- listeners ---
|
|
524
|
+
const onClick = (event) => {
|
|
525
|
+
const target = isElement(event.target) ? event.target : null;
|
|
526
|
+
if (target === null)
|
|
527
|
+
return;
|
|
528
|
+
const control = target.closest(`[${COMMAND_ATTRIBUTE}]`);
|
|
529
|
+
if (control !== null && surface.contains(control)) {
|
|
530
|
+
const name = control.getAttribute(COMMAND_ATTRIBUTE) ?? '';
|
|
531
|
+
if (isInput(control))
|
|
532
|
+
return; // the `input` listener owns these
|
|
533
|
+
dispatch({
|
|
534
|
+
kind: 'control',
|
|
535
|
+
name,
|
|
536
|
+
target: control.getAttribute('data-ig-target') ?? undefined,
|
|
537
|
+
// The picker publishes its kind as `data-ig-kind`; the mount's chrome
|
|
538
|
+
// publishes `data-ig-value`. One command channel, two spellings.
|
|
539
|
+
value: control.getAttribute('data-ig-value') ?? control.getAttribute('data-ig-kind') ?? undefined,
|
|
540
|
+
});
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
if (target.closest('[data-ig-audit-filter]') !== null) {
|
|
544
|
+
dispatch({ kind: 'control', name: 'audit-filter' });
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
// THE NEARER IDENTITY WINS. A relationship badge inside a row carries
|
|
548
|
+
// `data-ig-group` and sits under the row's `data-ig-key`, so resolving the
|
|
549
|
+
// key first would answer every badge click with the row and no edge could
|
|
550
|
+
// ever be selected from a row. One `closest` over both attributes answers
|
|
551
|
+
// with whichever the pointer actually landed on.
|
|
552
|
+
const named = target.closest(`[${GROUP_ATTRIBUTE}],[${KEY_ATTRIBUTE}]`);
|
|
553
|
+
if (named === null || !surface.contains(named))
|
|
554
|
+
return;
|
|
555
|
+
const id = named.getAttribute(GROUP_ATTRIBUTE);
|
|
556
|
+
if (id !== null) {
|
|
557
|
+
dispatch({ kind: 'group', id });
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
const key = named.getAttribute(KEY_ATTRIBUTE);
|
|
561
|
+
if (key !== null)
|
|
562
|
+
dispatch({ kind: 'point', key });
|
|
563
|
+
};
|
|
564
|
+
const readInput = (event) => {
|
|
565
|
+
const target = isElement(event.target) && isInput(event.target) ? event.target : null;
|
|
566
|
+
if (target === null || !surface.contains(target))
|
|
567
|
+
return;
|
|
568
|
+
const name = target.getAttribute(COMMAND_ATTRIBUTE);
|
|
569
|
+
if (name === 'search' || name === 'target-query')
|
|
570
|
+
dispatch({ kind: 'control', name, value: target.value });
|
|
571
|
+
};
|
|
572
|
+
// NOT WHILE AN INPUT METHOD IS COMPOSING. Every keystroke of a composition
|
|
573
|
+
// fires `input`, and a redraw replaces the element that owns the
|
|
574
|
+
// composition, which truncates or cancels the text before `compositionend`.
|
|
575
|
+
// The value is read once the composition settles, from the same listener.
|
|
576
|
+
const onInput = (event) => {
|
|
577
|
+
if (isComposing(event))
|
|
578
|
+
return;
|
|
579
|
+
readInput(event);
|
|
580
|
+
};
|
|
581
|
+
const onCompositionEnd = (event) => {
|
|
582
|
+
readInput(event);
|
|
583
|
+
};
|
|
584
|
+
const onScroll = (event) => {
|
|
585
|
+
const rail = zone('rail');
|
|
586
|
+
if (rail === null || event.target !== rail)
|
|
587
|
+
return;
|
|
588
|
+
if (drawn === null)
|
|
589
|
+
return;
|
|
590
|
+
// THE DECISION IS THE REDUCER'S, and `null` is load-bearing: a scroll that
|
|
591
|
+
// clamps back to the current start must not dispatch, because every
|
|
592
|
+
// dispatch redraws and every redraw restores the scroll offset — which
|
|
593
|
+
// fires this listener again. See `railWindowTarget`.
|
|
594
|
+
// THE PITCH IS A ROW'S, and the rail zone holds more than rows: the legend,
|
|
595
|
+
// and the host header and the NOW list when the host supplies them — one
|
|
596
|
+
// row per running job, unbounded. Their height is measured off the drawn
|
|
597
|
+
// tree and subtracted before the offset becomes a row (`railRowAt`), so a
|
|
598
|
+
// host running dozens of jobs does not scroll the window past ranks the
|
|
599
|
+
// reader has not reached. Measured, not derived from the theme: the header
|
|
600
|
+
// wraps, and a wrapped header is taller than any constant would say.
|
|
601
|
+
const rows = rail.querySelector('.ig-viewer .ig-list');
|
|
602
|
+
const viewer = rail.querySelector('.ig-viewer');
|
|
603
|
+
const chrome = rows === null || viewer === null
|
|
604
|
+
? 0
|
|
605
|
+
: rows.getBoundingClientRect().top - viewer.getBoundingClientRect().top;
|
|
606
|
+
const row = railRowAt(rail.scrollTop, chrome, pitch());
|
|
607
|
+
const start = railWindowTarget(row, state.railStart, railCount(), drawn.rail.total);
|
|
608
|
+
if (start !== null)
|
|
609
|
+
dispatch({ kind: 'scroll', start });
|
|
610
|
+
};
|
|
611
|
+
/**
|
|
612
|
+
* Which of the create flow's interactions the keyboard is in.
|
|
613
|
+
*
|
|
614
|
+
* `canvas` ONLY on the navigation surface — a focused row or node — because
|
|
615
|
+
* that is the one place every binding belongs. A picker choice, the audit
|
|
616
|
+
* filter or the delete button is a control with its own Enter and Space, and
|
|
617
|
+
* a Backspace there must not delete the selected edge; the key map names
|
|
618
|
+
* `elsewhere` as "everything that is not our own search box", and a button is
|
|
619
|
+
* that. The target search keeps its two bindings, and everything else is
|
|
620
|
+
* someone else's key.
|
|
621
|
+
*/
|
|
622
|
+
const interaction = () => {
|
|
623
|
+
const active = doc.activeElement;
|
|
624
|
+
if (isElement(active) && isInput(active) && active.getAttribute(COMMAND_ATTRIBUTE) === 'target-query') {
|
|
625
|
+
return 'target-search';
|
|
626
|
+
}
|
|
627
|
+
return focusedKey() !== null ? 'canvas' : 'elsewhere';
|
|
628
|
+
};
|
|
629
|
+
/**
|
|
630
|
+
* The rail's window edge, decided BEFORE the viewer is asked.
|
|
631
|
+
*
|
|
632
|
+
* The viewer draws the excluded rows after every window, so its scene's
|
|
633
|
+
* focus order runs from the last drawn slot into the exclusions rather than
|
|
634
|
+
* ending there — from the viewer's side the window is the whole document.
|
|
635
|
+
* The window is the mount's, so the mount decides first: on the last drawn
|
|
636
|
+
* slot, ArrowDown and End re-cut the window rather than stepping into the
|
|
637
|
+
* exclusions; on the first, ArrowUp and Home re-cut it upward. Anything
|
|
638
|
+
* inside the window, and every other key, is the viewer's to answer.
|
|
639
|
+
*/
|
|
640
|
+
const advanceRail = (rail, key, pressedKey) => {
|
|
641
|
+
const first = rail.rows[0];
|
|
642
|
+
const last = rail.rows[rail.rows.length - 1];
|
|
643
|
+
const onFirst = first !== undefined && first.members.includes(key);
|
|
644
|
+
const onLast = last !== undefined && last.members.includes(key);
|
|
645
|
+
const offset = rail.offsetOf(key);
|
|
646
|
+
const count = railCount();
|
|
647
|
+
const slack = railSlackFor(count);
|
|
648
|
+
const lastStart = Math.max(0, rail.total - count);
|
|
649
|
+
switch (pressedKey) {
|
|
650
|
+
// THE WINDOW ALWAYS MOVES BY AT LEAST ONE ROW. The re-cut lands the
|
|
651
|
+
// window `slack` rows above the reader; with a small window that could
|
|
652
|
+
// land on the current start, and a start that does not change draws
|
|
653
|
+
// no next row to focus.
|
|
654
|
+
case 'ArrowDown':
|
|
655
|
+
if (!onLast || rail.after === 0 || offset === undefined)
|
|
656
|
+
return false;
|
|
657
|
+
pendingFocus = { kind: 'after', key };
|
|
658
|
+
dispatch({ kind: 'scroll', start: Math.min(lastStart, Math.max(state.railStart + 1, offset - slack)) });
|
|
659
|
+
return true;
|
|
660
|
+
case 'ArrowUp':
|
|
661
|
+
if (!onFirst || rail.before === 0 || offset === undefined)
|
|
662
|
+
return false;
|
|
663
|
+
pendingFocus = { kind: 'before', key };
|
|
664
|
+
dispatch({ kind: 'scroll', start: Math.max(0, Math.min(state.railStart - 1, offset - (count - slack))) });
|
|
665
|
+
return true;
|
|
666
|
+
case 'End':
|
|
667
|
+
if (rail.after === 0)
|
|
668
|
+
return false;
|
|
669
|
+
pendingFocus = { kind: 'last', key: null };
|
|
670
|
+
dispatch({ kind: 'scroll', start: lastStart });
|
|
671
|
+
return true;
|
|
672
|
+
case 'Home':
|
|
673
|
+
if (rail.before === 0)
|
|
674
|
+
return false;
|
|
675
|
+
pendingFocus = { kind: 'first', key: null };
|
|
676
|
+
dispatch({ kind: 'scroll', start: 0 });
|
|
677
|
+
return true;
|
|
678
|
+
default:
|
|
679
|
+
return false;
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
/**
|
|
683
|
+
* The viewer's movement keys, through the viewer's own reducer.
|
|
684
|
+
*
|
|
685
|
+
* `mountViewer` wires these itself; the workspace composes the viewer's
|
|
686
|
+
* scenes rather than its mount, so this asks `navigate` about the zone's
|
|
687
|
+
* scene (see `sceneFor`) and moves focus to the key it answers with.
|
|
688
|
+
* Movement never wraps and never crosses zones — the ends of the order are
|
|
689
|
+
* the ends of the work — and Enter or Space selects, exactly as the viewer's
|
|
690
|
+
* own shell does.
|
|
691
|
+
*
|
|
692
|
+
* THE RAIL WINDOW IS THE ONE THING THE VIEWER CANNOT SEE. Its scene is the
|
|
693
|
+
* drawn window, so at the window's edge `navigate` correctly stays put while
|
|
694
|
+
* the order goes on behind the spacer. That edge is the mount's, because the
|
|
695
|
+
* window is: the window is re-cut around the row and the neighbour is
|
|
696
|
+
* focused once the redraw has drawn it (`pendingFocus`).
|
|
697
|
+
*/
|
|
698
|
+
const navigateFocus = (event) => {
|
|
699
|
+
if (event.isComposing)
|
|
700
|
+
return false;
|
|
701
|
+
const active = doc.activeElement;
|
|
702
|
+
if (!isElement(active))
|
|
703
|
+
return false;
|
|
704
|
+
const owner = active.closest('.ig-zone');
|
|
705
|
+
const zoneName = owner?.getAttribute('data-zone') ?? null;
|
|
706
|
+
const key = active.closest(`[${KEY_ATTRIBUTE}]`)?.getAttribute(KEY_ATTRIBUTE) ?? null;
|
|
707
|
+
if (owner === null || zoneName === null || key === null || !surface.contains(owner))
|
|
708
|
+
return false;
|
|
709
|
+
if (zoneName === 'rail' && drawn !== null && advanceRail(drawn.rail, key, event.key))
|
|
710
|
+
return true;
|
|
711
|
+
const scene = sceneFor(zoneName);
|
|
712
|
+
if (scene === null)
|
|
713
|
+
return false;
|
|
714
|
+
const result = navigate(scene, { focused: key, selected: selectedKey(state.selection) }, event.key);
|
|
715
|
+
if (result.command.kind === 'select') {
|
|
716
|
+
dispatch({ kind: 'point', key: result.command.key });
|
|
717
|
+
return true;
|
|
718
|
+
}
|
|
719
|
+
if (result.command.kind === 'focus') {
|
|
720
|
+
focusIn(zoneName, result.command.key);
|
|
721
|
+
return true;
|
|
722
|
+
}
|
|
723
|
+
return false;
|
|
724
|
+
};
|
|
725
|
+
const onKeydown = (event) => {
|
|
726
|
+
const document_ = landed();
|
|
727
|
+
const match = targetMatches(document_.issues, state.targetQuery, state.draft.source)[0]?.ref ?? null;
|
|
728
|
+
const context = {
|
|
729
|
+
// The FOCUSED row, not the selection: on a fresh page a row can own
|
|
730
|
+
// focus while nothing is selected, and after focus moves on, a stale
|
|
731
|
+
// selection must not become the source of a keyboard-started draft.
|
|
732
|
+
focused: focusedKey(),
|
|
733
|
+
match,
|
|
734
|
+
selectedEdge: selectedEdgeId(state.selection),
|
|
735
|
+
interaction: interaction(),
|
|
736
|
+
};
|
|
737
|
+
const intent = keyIntent(event, context);
|
|
738
|
+
if (intent.kind !== 'none') {
|
|
739
|
+
event.preventDefault();
|
|
740
|
+
dispatch({ kind: 'intent', intent });
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
if (context.focused !== null && navigateFocus(event))
|
|
744
|
+
event.preventDefault();
|
|
745
|
+
};
|
|
746
|
+
const onPointerDown = (event) => {
|
|
747
|
+
// ONE PRIMARY MAIN-BUTTON PRESS AT A TIME. A second pointer during a drag
|
|
748
|
+
// would replace the press and strand the first drag's release; a
|
|
749
|
+
// right-button press would start a drag under the context menu.
|
|
750
|
+
if (pressed !== null || !event.isPrimary || event.button !== 0)
|
|
751
|
+
return;
|
|
752
|
+
const target = isElement(event.target) ? event.target : null;
|
|
753
|
+
const canvas = zone('canvas');
|
|
754
|
+
const keyed = target?.closest(`[${KEY_ATTRIBUTE}]`) ?? null;
|
|
755
|
+
if (canvas === null || keyed === null || !canvas.contains(keyed))
|
|
756
|
+
return;
|
|
757
|
+
const key = keyed.getAttribute(KEY_ATTRIBUTE);
|
|
758
|
+
if (key === null)
|
|
759
|
+
return;
|
|
760
|
+
pressed = { pointerId: event.pointerId, key, x: event.clientX, y: event.clientY, dragging: false };
|
|
761
|
+
};
|
|
762
|
+
// A PRESS RELEASED OUTSIDE THE ELEMENT BEFORE THE DRAG THRESHOLD. Nothing is
|
|
763
|
+
// captured yet at that point, so the release never reaches the delegated
|
|
764
|
+
// listeners and the press would stay recorded — and a later move inside the
|
|
765
|
+
// element, even from another press, could exceed the distance from those
|
|
766
|
+
// stale coordinates and start a phantom drag for the old node. The document
|
|
767
|
+
// sees every release inside the page, so the pre-drag press is cleared
|
|
768
|
+
// there; a drag underway holds capture and is delivered to the element.
|
|
769
|
+
const onDocumentPointerUp = (event) => {
|
|
770
|
+
if (pressed !== null && !pressed.dragging && pressed.pointerId === event.pointerId)
|
|
771
|
+
pressed = null;
|
|
772
|
+
};
|
|
773
|
+
const onPointerMove = (event) => {
|
|
774
|
+
if (pressed === null || pressed.dragging || pressed.pointerId !== event.pointerId)
|
|
775
|
+
return;
|
|
776
|
+
if (Math.hypot(event.clientX - pressed.x, event.clientY - pressed.y) < DRAG_THRESHOLD)
|
|
777
|
+
return;
|
|
778
|
+
pressed.dragging = true;
|
|
779
|
+
surface.setAttribute('data-dragging', 'true');
|
|
780
|
+
// CAPTURED ON THE ELEMENT, ONLY ONCE A DRAG IS UNDERWAY. Without capture a
|
|
781
|
+
// pointer released outside the element never reports back, and the drag
|
|
782
|
+
// state stays set until some later interaction happens to clear it. Not
|
|
783
|
+
// captured on the press itself, because capture also redirects the click
|
|
784
|
+
// and a plain click on a node must keep reaching the node.
|
|
785
|
+
try {
|
|
786
|
+
element.setPointerCapture(event.pointerId);
|
|
787
|
+
}
|
|
788
|
+
catch {
|
|
789
|
+
// A synthetic or already-released pointer cannot be captured; the
|
|
790
|
+
// release then reaches the element only if it lands inside it.
|
|
791
|
+
}
|
|
792
|
+
dispatch({ kind: 'drag-start', key: pressed.key });
|
|
793
|
+
};
|
|
794
|
+
const releasePointer = (event) => {
|
|
795
|
+
try {
|
|
796
|
+
if (element.hasPointerCapture(event.pointerId))
|
|
797
|
+
element.releasePointerCapture(event.pointerId);
|
|
798
|
+
}
|
|
799
|
+
catch {
|
|
800
|
+
// Nothing was captured.
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
const onPointerUp = (event) => {
|
|
804
|
+
// THE PRESS IS CONSUMED BEFORE THE CAPTURE IS RELEASED. Releasing fires
|
|
805
|
+
// `lostpointercapture` synchronously, which is wired to the cancel path;
|
|
806
|
+
// with `pressed` already null that path stands down instead of cancelling
|
|
807
|
+
// the drop this very handler is about to deliver.
|
|
808
|
+
const was = pressed;
|
|
809
|
+
pressed = null;
|
|
810
|
+
releasePointer(event);
|
|
811
|
+
surface.removeAttribute('data-dragging');
|
|
812
|
+
if (was === null || !was.dragging)
|
|
813
|
+
return;
|
|
814
|
+
// WHAT IS UNDER THE POINTER, from the document, because a drag captured on
|
|
815
|
+
// the element reports every event with the element as its target.
|
|
816
|
+
const under = typeof doc.elementFromPoint === 'function' ? doc.elementFromPoint(event.clientX, event.clientY) : null;
|
|
817
|
+
const canvas = zone('canvas');
|
|
818
|
+
const keyed = under?.closest(`[${KEY_ATTRIBUTE}]`) ?? null;
|
|
819
|
+
const key = keyed !== null && canvas?.contains(keyed) === true ? keyed.getAttribute(KEY_ATTRIBUTE) : null;
|
|
820
|
+
const bounds = surface.getBoundingClientRect();
|
|
821
|
+
dispatch({ kind: 'drop', key, at: { x: event.clientX - bounds.left, y: event.clientY - bounds.top } });
|
|
822
|
+
};
|
|
823
|
+
const onPointerCancel = (event) => {
|
|
824
|
+
if (pressed === null)
|
|
825
|
+
return;
|
|
826
|
+
pressed = null;
|
|
827
|
+
releasePointer(event);
|
|
828
|
+
surface.removeAttribute('data-dragging');
|
|
829
|
+
if (state.drag !== null)
|
|
830
|
+
dispatch({ kind: 'drop', key: null, at: { x: 0, y: 0 } });
|
|
831
|
+
};
|
|
832
|
+
element.addEventListener('click', onClick);
|
|
833
|
+
element.addEventListener('input', onInput);
|
|
834
|
+
element.addEventListener('compositionend', onCompositionEnd);
|
|
835
|
+
element.addEventListener('scroll', onScroll, true);
|
|
836
|
+
element.addEventListener('keydown', onKeydown);
|
|
837
|
+
element.addEventListener('pointerdown', onPointerDown);
|
|
838
|
+
element.addEventListener('pointermove', onPointerMove);
|
|
839
|
+
element.addEventListener('pointerup', onPointerUp);
|
|
840
|
+
element.addEventListener('pointercancel', onPointerCancel);
|
|
841
|
+
element.addEventListener('lostpointercapture', onPointerCancel);
|
|
842
|
+
doc.addEventListener('pointerup', onDocumentPointerUp);
|
|
843
|
+
doc.addEventListener('pointercancel', onDocumentPointerUp);
|
|
844
|
+
const unsubscribe = store.subscribe(schedule);
|
|
845
|
+
/** Every listener off and every node this mount built removed. Shared by `destroy` and a failed first render. */
|
|
846
|
+
const teardown = () => {
|
|
847
|
+
unsubscribe();
|
|
848
|
+
element.removeEventListener('click', onClick);
|
|
849
|
+
element.removeEventListener('input', onInput);
|
|
850
|
+
element.removeEventListener('compositionend', onCompositionEnd);
|
|
851
|
+
element.removeEventListener('scroll', onScroll, true);
|
|
852
|
+
element.removeEventListener('keydown', onKeydown);
|
|
853
|
+
element.removeEventListener('pointerdown', onPointerDown);
|
|
854
|
+
element.removeEventListener('pointermove', onPointerMove);
|
|
855
|
+
element.removeEventListener('pointerup', onPointerUp);
|
|
856
|
+
element.removeEventListener('pointercancel', onPointerCancel);
|
|
857
|
+
element.removeEventListener('lostpointercapture', onPointerCancel);
|
|
858
|
+
doc.removeEventListener('pointerup', onDocumentPointerUp);
|
|
859
|
+
doc.removeEventListener('pointercancel', onDocumentPointerUp);
|
|
860
|
+
// A DRAG STILL HELD IS LET GO. The element keeps a pointer capture across
|
|
861
|
+
// a destroy otherwise, and the demo's reset mounts again over this very
|
|
862
|
+
// element while the reader may still be dragging.
|
|
863
|
+
if (pressed !== null) {
|
|
864
|
+
try {
|
|
865
|
+
if (element.hasPointerCapture(pressed.pointerId))
|
|
866
|
+
element.releasePointerCapture(pressed.pointerId);
|
|
867
|
+
}
|
|
868
|
+
catch {
|
|
869
|
+
// Nothing was captured.
|
|
870
|
+
}
|
|
871
|
+
pressed = null;
|
|
872
|
+
}
|
|
873
|
+
surface.removeAttribute('data-dragging');
|
|
874
|
+
styles.remove();
|
|
875
|
+
surface.remove();
|
|
876
|
+
drawn = null;
|
|
877
|
+
};
|
|
878
|
+
// THE FIRST RENDER CAN THROW — a host's `project` is host code — and by then
|
|
879
|
+
// the listeners and the subscription are live with no handle to remove
|
|
880
|
+
// them. Tear down before rethrowing, so a mount that failed leaves nothing.
|
|
881
|
+
try {
|
|
882
|
+
render();
|
|
883
|
+
}
|
|
884
|
+
catch (error) {
|
|
885
|
+
destroyed = true;
|
|
886
|
+
teardown();
|
|
887
|
+
throw error;
|
|
888
|
+
}
|
|
889
|
+
return {
|
|
890
|
+
update(next) {
|
|
891
|
+
if (destroyed)
|
|
892
|
+
return;
|
|
893
|
+
if (next !== undefined)
|
|
894
|
+
current = { ...current, ...next, store };
|
|
895
|
+
schedule();
|
|
896
|
+
},
|
|
897
|
+
dispatch,
|
|
898
|
+
get state() {
|
|
899
|
+
return state;
|
|
900
|
+
},
|
|
901
|
+
destroy() {
|
|
902
|
+
if (destroyed)
|
|
903
|
+
return;
|
|
904
|
+
destroyed = true;
|
|
905
|
+
teardown();
|
|
906
|
+
},
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
//# sourceMappingURL=mount.js.map
|