@issuegraph/editor 0.24.0 → 0.26.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.
Files changed (49) hide show
  1. package/README.md +6 -2
  2. package/dist/index.d.ts +2 -2
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +1 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/overlay/render.d.ts.map +1 -1
  7. package/dist/overlay/render.js +18 -2
  8. package/dist/overlay/render.js.map +1 -1
  9. package/dist/reevaluate/styles.d.ts +7 -1
  10. package/dist/reevaluate/styles.d.ts.map +1 -1
  11. package/dist/reevaluate/styles.js +32 -11
  12. package/dist/reevaluate/styles.js.map +1 -1
  13. package/dist/scale/render.d.ts +5 -1
  14. package/dist/scale/render.d.ts.map +1 -1
  15. package/dist/scale/render.js +19 -2
  16. package/dist/scale/render.js.map +1 -1
  17. package/dist/workspace/host.d.ts +33 -2
  18. package/dist/workspace/host.d.ts.map +1 -1
  19. package/dist/workspace/host.js +64 -14
  20. package/dist/workspace/host.js.map +1 -1
  21. package/dist/workspace/inspector.d.ts +9 -0
  22. package/dist/workspace/inspector.d.ts.map +1 -1
  23. package/dist/workspace/inspector.js +16 -0
  24. package/dist/workspace/inspector.js.map +1 -1
  25. package/dist/workspace/mount.d.ts +17 -3
  26. package/dist/workspace/mount.d.ts.map +1 -1
  27. package/dist/workspace/mount.js +150 -30
  28. package/dist/workspace/mount.js.map +1 -1
  29. package/dist/workspace/narrow.d.ts +38 -0
  30. package/dist/workspace/narrow.d.ts.map +1 -0
  31. package/dist/workspace/narrow.js +48 -0
  32. package/dist/workspace/narrow.js.map +1 -0
  33. package/dist/workspace/related.d.ts +65 -0
  34. package/dist/workspace/related.d.ts.map +1 -0
  35. package/dist/workspace/related.js +105 -0
  36. package/dist/workspace/related.js.map +1 -0
  37. package/dist/workspace/render.d.ts +102 -19
  38. package/dist/workspace/render.d.ts.map +1 -1
  39. package/dist/workspace/render.js +211 -29
  40. package/dist/workspace/render.js.map +1 -1
  41. package/dist/workspace/selection.d.ts +44 -3
  42. package/dist/workspace/selection.d.ts.map +1 -1
  43. package/dist/workspace/selection.js +58 -0
  44. package/dist/workspace/selection.js.map +1 -1
  45. package/dist/workspace/styles.d.ts +1 -1
  46. package/dist/workspace/styles.d.ts.map +1 -1
  47. package/dist/workspace/styles.js +265 -8
  48. package/dist/workspace/styles.js.map +1 -1
  49. package/package.json +3 -3
@@ -47,7 +47,7 @@
47
47
  */
48
48
  import { edgeIdentity } from '@issuegraph/core';
49
49
  import { nextDocument } from '@issuegraph/store';
50
- import { navigate, renderViewer, resolveTheme, } from '@issuegraph/viewer';
50
+ import { isFooterSlot, navigate, normalizeDocument, renderViewer, resolveTheme, sameSelection, } from '@issuegraph/viewer';
51
51
  import { CONTROL_ATTRIBUTES } from "../a11y/baseline.js";
52
52
  import { isChoosingKind, isDraftLive } from "../create/draft.js";
53
53
  import { KIND_KEYS, keyIntent } from "../create/keys.js";
@@ -64,10 +64,9 @@ import { mountStylesheet } from "./chrome.js";
64
64
  import { renderWorkspaceSkeleton } from "./skeleton.js";
65
65
  import { INITIAL_HOST_STATE, editCarrier, narrowOverlayOpen, railRowAt, railSlackFor, railWindowTarget, reconcileHost, reduceHost, targetMatches, } from "./host.js";
66
66
  import { conflictDiff } from "./recovery.js";
67
- import { renderWorkspace, } from "./render.js";
68
- import { selectedEdgeId, selectedKey } from "./selection.js";
69
- /** What the canvas zone draws: the editor's scale ladder, or the viewer's tree projection. */
70
- export const CANVAS_MODES = Object.freeze(['neighbourhood', 'tree']);
67
+ import { RELATED_STATUS_CLASS, renderWorkspace, } from "./render.js";
68
+ import { selectedEdgeId, selectedKey, toViewerSelection } from "./selection.js";
69
+ export { CANVAS_MODES } from "./narrow.js";
71
70
  /** The package default: wide enough that a scroll rarely lands past the drawn rows. */
72
71
  export const MOUNT_RAIL_COUNT = 80;
73
72
  /** The kind chooser's size for placement — the stylesheet decides the real one; this only picks a corner. */
@@ -83,6 +82,8 @@ const KEY_ATTRIBUTE = 'data-ig-key';
83
82
  * what has to be preserved is the live region specifically.
84
83
  */
85
84
  const LIVE_REGION = '.ig-change-line[role="status"]';
85
+ /** The Related groups search's status line, carried across redraws the same way. */
86
+ const RELATED_STATUS = `.${RELATED_STATUS_CLASS}[role="status"]`;
86
87
  const GROUP_ATTRIBUTE = 'data-ig-group';
87
88
  const COMMAND_ATTRIBUTE = 'data-ig-command';
88
89
  /**
@@ -165,6 +166,13 @@ function isNavigating(node) {
165
166
  function isInput(node) {
166
167
  return node !== null && node !== undefined && node.tagName === 'INPUT';
167
168
  }
169
+ /** Whether a key press was typed into a text field. */
170
+ function typedInField(event) {
171
+ const target = event.target;
172
+ if (!isElement(target))
173
+ return false;
174
+ return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.closest('[contenteditable="true"]') !== null;
175
+ }
168
176
  function isComposing(event) {
169
177
  return 'isComposing' in event && event.isComposing === true;
170
178
  }
@@ -220,7 +228,18 @@ export function mountWorkspace(element, options) {
220
228
  const surface = doc.createElement('div');
221
229
  surface.className = 'ig-mount';
222
230
  element.append(styles, surface);
223
- let state = INITIAL_HOST_STATE;
231
+ let state = { ...INITIAL_HOST_STATE, canvas: options.canvas ?? INITIAL_HOST_STATE.canvas };
232
+ // THE LAST SELECTION THE HOST WAS TOLD, compared by value in `render()`,
233
+ // which every path reaches — a gesture, a store notification, the
234
+ // reconciliation that drops a member — so the notice has one home.
235
+ let announced = state.selection;
236
+ /** The viewer document last drawn, which a Shift range reads its rank order from. */
237
+ let lastViewer = null;
238
+ // THE RAIL OFFSET THE SCROLL HANDLER LAST SAW, which is what every redraw
239
+ // restores. Read back off the old rail instead, a redraw while another tab
240
+ // shows would restore the 0 a hidden rail reports, and the reader would lose
241
+ // their place in the order on the way back.
242
+ let railOffset = 0;
224
243
  let pending = false;
225
244
  let destroyed = false;
226
245
  // What the last redraw drew, kept so a key press can ask the viewer's own
@@ -949,7 +968,7 @@ export function mountWorkspace(element, options) {
949
968
  }).scene;
950
969
  if (zoneName !== 'canvas')
951
970
  return null;
952
- if (current.canvas === 'tree')
971
+ if (state.canvas === 'tree')
953
972
  return renderViewer(withoutChrome(drawn.viewer), { projection: 'tree', theme: theme(), chrome: false, frame: false, dockLegend: true }).scene;
954
973
  const ladder = scaleLadder(drawn.viewer, state.scale);
955
974
  return ladder.tier === 'direct'
@@ -1054,6 +1073,7 @@ export function mountWorkspace(element, options) {
1054
1073
  const viewer = unsettled.length === 0 && kept.length === projected.viewer.edges.length
1055
1074
  ? projected.viewer
1056
1075
  : { ...projected.viewer, edges: [...kept, ...unsettled] };
1076
+ lastViewer = viewer;
1057
1077
  const { audit } = projected;
1058
1078
  const resolved = theme();
1059
1079
  // THE WRITE STATES ONLY. The workspace holds the one selection, and every
@@ -1249,6 +1269,12 @@ export function mountWorkspace(element, options) {
1249
1269
  // that reopened itself on every landed write would be unusable.
1250
1270
  canvasOpen: state.canvasOpen,
1251
1271
  inspectorDismissed: state.inspectorDismissed,
1272
+ // The layout under 640's tab and the Related groups list's place, for the
1273
+ // same reason: the renderer keeps nothing across a redraw.
1274
+ narrowView: state.narrowView,
1275
+ canvas: state.canvas,
1276
+ relatedGroup: state.relatedGroup,
1277
+ relatedQuery: state.relatedQuery,
1252
1278
  // §17d's overlay. Host state, for the same reason the filter beside it
1253
1279
  // is: the renderer keeps nothing across a redraw.
1254
1280
  auditOpen: state.auditOpen,
@@ -1280,11 +1306,9 @@ export function mountWorkspace(element, options) {
1280
1306
  const sheet = [result.styles, pickerStylesheet, firstPassStylesheet, mountStylesheet].join('\n');
1281
1307
  if (styles.textContent !== sheet)
1282
1308
  styles.textContent = sheet;
1283
- // What the reader was doing survives the redraw: the rail's scroll offset,
1284
- // the audit panel's, and the caret in whichever search box they were typing
1285
- // into.
1286
- const railBefore = zone('rail');
1287
- const scrollTop = railBefore?.scrollTop ?? 0;
1309
+ // What the reader was doing survives the redraw: the rail's scroll offset
1310
+ // (kept as `railOffset`), the audit panel's, and the caret in whichever
1311
+ // search box they were typing into.
1288
1312
  // THE AUDIT SCROLLS TOO, SINCE IT TOOK A SHARE OF ITS COLUMN. Before it had
1289
1313
  // a bound it grew to its content and only the ZONE scrolled, so there was no
1290
1314
  // second offset to keep. Now a long audit is read inside its own scroller,
@@ -1360,19 +1384,24 @@ export function mountWorkspace(element, options) {
1360
1384
  // summary, and only then given the new one. The insertion says nothing and
1361
1385
  // the mutation is what announces, which is the sequence the region was
1362
1386
  // designed around.
1363
- const liveBefore = surface.querySelector(LIVE_REGION);
1387
+ // THE RELATED SEARCH'S STATUS LINE IS THE SAME KIND OF REGION and is
1388
+ // carried the same way, so the left-out count and the no-match sentence
1389
+ // are announced as they change while the reader types.
1390
+ const carried = [LIVE_REGION, RELATED_STATUS].map((selector) => [selector, surface.querySelector(selector)]);
1364
1391
  // Package-rendered markup, escaped by the package.
1365
1392
  surface.innerHTML = result.markup;
1366
- const liveAfter = surface.querySelector(LIVE_REGION);
1367
- if (liveBefore !== null && liveAfter !== null) {
1368
- const next = liveAfter.innerHTML;
1369
- liveAfter.replaceWith(liveBefore);
1370
- // GUARDED, so a redraw that did not touch the summary is not a change to
1371
- // announce. Every keystroke in the ladder's search box redraws this
1372
- // surface, and re-assigning identical content would re-announce the last
1373
- // edit's result on each one.
1374
- if (liveBefore.innerHTML !== next)
1375
- liveBefore.innerHTML = next;
1393
+ for (const [selector, before] of carried) {
1394
+ const after = surface.querySelector(selector);
1395
+ if (before === null || after === null)
1396
+ continue;
1397
+ const next = after.innerHTML;
1398
+ after.replaceWith(before);
1399
+ // GUARDED, so a redraw that did not touch the region is not a change to
1400
+ // announce. Every keystroke in a search box redraws this surface, and
1401
+ // re-assigning identical content would re-announce the last result on
1402
+ // each one.
1403
+ if (before.innerHTML !== next)
1404
+ before.innerHTML = next;
1376
1405
  }
1377
1406
  // THE ORDER'S STATUS, PUBLISHED AS DATA. A write in flight holds the order
1378
1407
  // — it does not move until the edit lands — and a host that wants to say
@@ -1380,7 +1409,7 @@ export function mountWorkspace(element, options) {
1380
1409
  // attribute is the store's own vocabulary, verbatim, so a host styles or
1381
1410
  // reads it without this package inventing a sentence.
1382
1411
  surface.setAttribute('data-order', snapshot.order.status);
1383
- if (current.canvas === 'tree') {
1412
+ if (state.canvas === 'tree') {
1384
1413
  const canvas = zone('canvas');
1385
1414
  if (canvas !== null) {
1386
1415
  // THE SAME STATES THE LADDER DRAWS, on the tree's badges. The tree
@@ -1425,6 +1454,12 @@ export function mountWorkspace(element, options) {
1425
1454
  // element is not assembled from optional parts: `isolatedSpec` returns
1426
1455
  // null rather than an empty husk, so anything found here has content.
1427
1456
  const isolated = canvas.querySelector('.ig-ladder-isolated');
1457
+ // §17d'S FINDINGS PANEL SURVIVES TOO. It is an overlay drawn in THIS
1458
+ // zone (RULINGS.md §3: over the canvas, anchored to the header count),
1459
+ // so the assignment below deleted it and the header's problem count
1460
+ // opened nothing in tree mode, at every width. It is kept whole, as the
1461
+ // isolated list is, and put back last so it still floats over the tree.
1462
+ const findings = canvas.querySelector('.ig-audit-overlay');
1428
1463
  canvas.innerHTML = renderViewer(withoutChrome(viewer), {
1429
1464
  projection: 'tree',
1430
1465
  theme: resolved,
@@ -1461,6 +1496,8 @@ export function mountWorkspace(element, options) {
1461
1496
  surface.firstElementChild?.setAttribute('data-canvas-legend', 'drawn');
1462
1497
  if (isolated !== null)
1463
1498
  canvas.append(isolated);
1499
+ if (findings !== null)
1500
+ canvas.append(findings);
1464
1501
  if (toolbar !== null && toolbar.childElementCount > 0)
1465
1502
  canvas.prepend(toolbar);
1466
1503
  const states = new Map(overlaysFor(viewer.edges, writeStates, selectedEdgeId(state.selection)).map((edge) => [edge.id, overlayFor(edge).attribute]));
@@ -1489,7 +1526,7 @@ export function mountWorkspace(element, options) {
1489
1526
  }
1490
1527
  const rail = zone('rail');
1491
1528
  if (rail !== null)
1492
- rail.scrollTop = scrollTop;
1529
+ rail.scrollTop = railOffset;
1493
1530
  // AFTER the subtree is in place, because it measures what was just drawn.
1494
1531
  dockRailLegend();
1495
1532
  // RE-QUERIED, NEVER REUSED: `surface.innerHTML` above replaced the subtree,
@@ -1605,6 +1642,9 @@ export function mountWorkspace(element, options) {
1605
1642
  if (isFocusable(now) && typeof now.scrollIntoView === 'function')
1606
1643
  now.scrollIntoView({ block: 'nearest' });
1607
1644
  }
1645
+ // Recorded here as well as by the scroll handler, whose event arrives
1646
+ // later: a redraw before it would put the rail back where the jump began.
1647
+ railOffset = rail.scrollTop;
1608
1648
  }
1609
1649
  }
1610
1650
  else {
@@ -1726,6 +1766,53 @@ export function mountWorkspace(element, options) {
1726
1766
  }
1727
1767
  firstPassWas = state.firstPass.phase.kind;
1728
1768
  searchWasOpen = search !== null;
1769
+ announceSelection();
1770
+ };
1771
+ /**
1772
+ * Tell the host the selection changed, once, with a fresh frozen copy. The
1773
+ * announced value is written BEFORE the callback, so a host that changes the
1774
+ * selection from inside it is told about that change on its own render.
1775
+ */
1776
+ const announceSelection = () => {
1777
+ const now = state.selection;
1778
+ if (sameSelection(toViewerSelection(announced), toViewerSelection(now)))
1779
+ return;
1780
+ announced = now;
1781
+ const handed = now.kind === 'issues' ? Object.freeze({ kind: 'issues', keys: Object.freeze([...now.keys]) }) : Object.freeze({ ...now });
1782
+ current.onSelectionChange?.(handed);
1783
+ };
1784
+ /**
1785
+ * The WHOLE rank order as last drawn — every member of every ranked slot —
1786
+ * which a Shift range walks. Never the rail window: a range whose anchor is
1787
+ * outside the window covers the full span.
1788
+ */
1789
+ const rankOrder = () => lastViewer === null
1790
+ ? []
1791
+ : normalizeDocument(lastViewer).document.order.slots
1792
+ .filter((slot) => slot.rank !== null)
1793
+ .flatMap((slot) => slot.members);
1794
+ /** The pointer gesture a click's modifiers ask for: Cmd or Ctrl toggles, Shift ranges. */
1795
+ const gestureOf = (event) => {
1796
+ if (event.metaKey || event.ctrlKey)
1797
+ return { gesture: 'toggle' };
1798
+ return event.shiftKey ? { gesture: 'range', order: rankOrder() } : {};
1799
+ };
1800
+ /**
1801
+ * The row across the rail window's edge that ArrowDown on its last drawn row
1802
+ * (or ArrowUp on its first) will focus once the window is re-cut: the next
1803
+ * (or previous) slot in the WHOLE sequence. `null` when the press does not
1804
+ * re-cut the window — exactly the conditions `advanceRail` re-cuts on.
1805
+ */
1806
+ const acrossEdge = (rail, key, pressedKey) => {
1807
+ const offset = rail.offsetOf(key);
1808
+ if (offset === undefined || lastViewer === null)
1809
+ return null;
1810
+ const down = pressedKey === 'ArrowDown' && rail.after > 0 && (rail.rows.at(-1)?.members.includes(key) ?? false);
1811
+ const up = pressedKey === 'ArrowUp' && rail.before > 0 && (rail.rows[0]?.members.includes(key) ?? false);
1812
+ if (!down && !up)
1813
+ return null;
1814
+ const sequence = normalizeDocument(lastViewer).document.order.slots.filter((slot) => !isFooterSlot(slot));
1815
+ return sequence[offset + (down ? 1 : -1)]?.lead ?? null;
1729
1816
  };
1730
1817
  /**
1731
1818
  * Take the option's scanner, and end the lifecycle the old one was scanning for.
@@ -1907,17 +1994,20 @@ export function mountWorkspace(element, options) {
1907
1994
  dispatch({ kind: 'group', id });
1908
1995
  return;
1909
1996
  }
1997
+ // MODIFIERS ARE READ ON AN ISSUE ONLY. A relationship above stays a plain
1998
+ // relationship click under any modifier, replacing any set.
1910
1999
  const key = named.getAttribute(KEY_ATTRIBUTE);
1911
2000
  if (key !== null)
1912
- dispatch({ kind: 'point', key });
2001
+ dispatch({ kind: 'point', key, ...gestureOf(event) });
1913
2002
  };
1914
2003
  const readInput = (event) => {
1915
2004
  const target = isElement(event.target) && isInput(event.target) ? event.target : null;
1916
2005
  if (target === null || !surface.contains(target))
1917
2006
  return;
1918
2007
  const name = target.getAttribute(COMMAND_ATTRIBUTE);
1919
- if (name === 'search' || name === 'target-query')
2008
+ if (name === 'search' || name === 'target-query' || name === 'related-query') {
1920
2009
  dispatch({ kind: 'control', name, value: target.value });
2010
+ }
1921
2011
  };
1922
2012
  // NOT WHILE AN INPUT METHOD IS COMPOSING. Every keystroke of a composition
1923
2013
  // fires `input`, and a redraw replaces the element that owns the
@@ -1978,6 +2068,7 @@ export function mountWorkspace(element, options) {
1978
2068
  const rail = zone('rail');
1979
2069
  if (rail === null || event.target !== rail)
1980
2070
  return;
2071
+ railOffset = rail.scrollTop;
1981
2072
  if (drawn === null)
1982
2073
  return;
1983
2074
  // THE DECISION IS THE REDUCER'S, and `null` is load-bearing: a scroll that
@@ -2237,16 +2328,33 @@ export function mountWorkspace(element, options) {
2237
2328
  const key = active.closest(`[${KEY_ATTRIBUTE}]`)?.getAttribute(KEY_ATTRIBUTE) ?? null;
2238
2329
  if (owner === null || zoneName === null || key === null || !surface.contains(owner))
2239
2330
  return false;
2240
- if (zoneName === 'rail' && drawn !== null && advanceRail(drawn.rail, key, event.key))
2241
- return true;
2331
+ // SHIFT IS READ BEFORE THE RE-CUT, because `advanceRail` returns before
2332
+ // `navigate` runs: the range goes to the neighbour in the whole order that
2333
+ // the re-cut then focuses, so the set extends across the edge exactly as
2334
+ // it does inside the window.
2335
+ if (zoneName === 'rail' && drawn !== null) {
2336
+ const across = event.shiftKey ? acrossEdge(drawn.rail, key, event.key) : null;
2337
+ if (across !== null)
2338
+ dispatch({ kind: 'point', key: across, gesture: 'range', order: rankOrder() });
2339
+ if (drawn !== null && advanceRail(drawn.rail, key, event.key))
2340
+ return true;
2341
+ }
2242
2342
  const scene = sceneFor(zoneName);
2243
2343
  if (scene === null)
2244
2344
  return false;
2245
- const result = navigate(scene, { focused: key, selected: selectedKey(state.selection) }, event.key);
2345
+ const result = navigate(scene, { focused: key, selected: selectedKey(state.selection) }, event.key, {
2346
+ shift: event.shiftKey,
2347
+ });
2246
2348
  if (result.command.kind === 'select') {
2247
2349
  dispatch({ kind: 'point', key: result.command.key });
2248
2350
  return true;
2249
2351
  }
2352
+ // SHIFT WITH AN ARROW: a Shift-click on the issue focus moves to.
2353
+ if (result.command.kind === 'extend') {
2354
+ dispatch({ kind: 'point', key: result.command.key, gesture: 'range', order: rankOrder() });
2355
+ focusIn(zoneName, result.command.key);
2356
+ return true;
2357
+ }
2250
2358
  if (result.command.kind === 'focus') {
2251
2359
  focusIn(zoneName, result.command.key);
2252
2360
  return true;
@@ -2466,6 +2574,14 @@ export function mountWorkspace(element, options) {
2466
2574
  dispatch({ kind: 'control', name: 'narrow-dismiss' });
2467
2575
  return;
2468
2576
  }
2577
+ // THE SELECTION CLEARS LAST, only when nothing above claimed the press
2578
+ // and before the create map's cancel, and never from a text field: an
2579
+ // Escape typed there belongs to the field.
2580
+ if (state.selection.kind !== 'none' && !typedInField(event)) {
2581
+ event.preventDefault();
2582
+ dispatch({ kind: 'control', name: 'clear' });
2583
+ return;
2584
+ }
2469
2585
  }
2470
2586
  const intent = keyIntent(event, context);
2471
2587
  if (intent.kind !== 'none') {
@@ -2796,6 +2912,10 @@ export function mountWorkspace(element, options) {
2796
2912
  return;
2797
2913
  if (next !== undefined) {
2798
2914
  current = { ...current, ...next, store };
2915
+ // ONLY AN UPDATE THAT NAMES THE MODE SETS IT. `current` keeps the mount's
2916
+ // seed, so re-reading it here would undo the reader's last tab press.
2917
+ if (next.canvas !== undefined)
2918
+ state = { ...state, canvas: next.canvas };
2799
2919
  adoptSource();
2800
2920
  }
2801
2921
  schedule();