@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.
@@ -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