@kgdiff/viewer 0.5.0 → 0.6.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 (232) hide show
  1. package/README.md +43 -16
  2. package/custom-elements.json +2347 -0
  3. package/dist/bind.d.ts +41 -0
  4. package/dist/bind.d.ts.map +1 -0
  5. package/dist/bind.js +47 -0
  6. package/dist/bind.js.map +1 -0
  7. package/dist/controller.d.ts +22 -74
  8. package/dist/controller.d.ts.map +1 -1
  9. package/dist/controller.js +35 -376
  10. package/dist/controller.js.map +1 -1
  11. package/dist/dom.d.ts +10 -0
  12. package/dist/dom.d.ts.map +1 -1
  13. package/dist/dom.js +29 -0
  14. package/dist/dom.js.map +1 -1
  15. package/dist/element.d.ts +52 -46
  16. package/dist/element.d.ts.map +1 -1
  17. package/dist/element.js +362 -403
  18. package/dist/element.js.map +1 -1
  19. package/dist/elements/actions.d.ts +27 -0
  20. package/dist/elements/actions.d.ts.map +1 -0
  21. package/dist/elements/actions.js +81 -0
  22. package/dist/elements/actions.js.map +1 -0
  23. package/dist/elements/bound.d.ts +63 -0
  24. package/dist/elements/bound.d.ts.map +1 -0
  25. package/dist/elements/bound.js +213 -0
  26. package/dist/elements/bound.js.map +1 -0
  27. package/dist/elements/change-detail.d.ts +25 -0
  28. package/dist/elements/change-detail.d.ts.map +1 -0
  29. package/dist/elements/change-detail.js +19 -0
  30. package/dist/elements/change-detail.js.map +1 -0
  31. package/dist/elements/change-list.d.ts +27 -0
  32. package/dist/elements/change-list.d.ts.map +1 -0
  33. package/dist/elements/change-list.js +29 -0
  34. package/dist/elements/change-list.js.map +1 -0
  35. package/dist/elements/filters.d.ts +25 -0
  36. package/dist/elements/filters.d.ts.map +1 -0
  37. package/dist/elements/filters.js +19 -0
  38. package/dist/elements/filters.js.map +1 -0
  39. package/dist/elements/legend.d.ts +40 -0
  40. package/dist/elements/legend.d.ts.map +1 -0
  41. package/dist/elements/legend.js +87 -0
  42. package/dist/elements/legend.js.map +1 -0
  43. package/dist/elements/minimap.d.ts +38 -0
  44. package/dist/elements/minimap.d.ts.map +1 -0
  45. package/dist/elements/minimap.js +59 -0
  46. package/dist/elements/minimap.js.map +1 -0
  47. package/dist/elements/root.d.ts +40 -0
  48. package/dist/elements/root.d.ts.map +1 -0
  49. package/dist/elements/root.js +67 -0
  50. package/dist/elements/root.js.map +1 -0
  51. package/dist/elements/search.d.ts +29 -0
  52. package/dist/elements/search.d.ts.map +1 -0
  53. package/dist/elements/search.js +23 -0
  54. package/dist/elements/search.js.map +1 -0
  55. package/dist/elements/stage-context.d.ts +31 -0
  56. package/dist/elements/stage-context.d.ts.map +1 -0
  57. package/dist/elements/stage-context.js +4 -0
  58. package/dist/elements/stage-context.js.map +1 -0
  59. package/dist/elements/stage.d.ts +65 -0
  60. package/dist/elements/stage.d.ts.map +1 -0
  61. package/dist/elements/stage.js +259 -0
  62. package/dist/elements/stage.js.map +1 -0
  63. package/dist/elements/title.d.ts +25 -0
  64. package/dist/elements/title.d.ts.map +1 -0
  65. package/dist/elements/title.js +19 -0
  66. package/dist/elements/title.js.map +1 -0
  67. package/dist/elements/transport.d.ts +29 -0
  68. package/dist/elements/transport.d.ts.map +1 -0
  69. package/dist/elements/transport.js +24 -0
  70. package/dist/elements/transport.js.map +1 -0
  71. package/dist/elements/view-bound.d.ts +39 -0
  72. package/dist/elements/view-bound.d.ts.map +1 -0
  73. package/dist/elements/view-bound.js +88 -0
  74. package/dist/elements/view-bound.js.map +1 -0
  75. package/dist/elements/view-dock.d.ts +28 -0
  76. package/dist/elements/view-dock.d.ts.map +1 -0
  77. package/dist/elements/view-dock.js +28 -0
  78. package/dist/elements/view-dock.js.map +1 -0
  79. package/dist/elements/zoom.d.ts +28 -0
  80. package/dist/elements/zoom.d.ts.map +1 -0
  81. package/dist/elements/zoom.js +22 -0
  82. package/dist/elements/zoom.js.map +1 -0
  83. package/dist/i18n.d.ts +10 -0
  84. package/dist/i18n.d.ts.map +1 -1
  85. package/dist/i18n.js +20 -0
  86. package/dist/i18n.js.map +1 -1
  87. package/dist/icons.d.ts +1 -0
  88. package/dist/icons.d.ts.map +1 -1
  89. package/dist/icons.js +1 -0
  90. package/dist/icons.js.map +1 -1
  91. package/dist/index.d.ts +328 -34
  92. package/dist/index.d.ts.map +1 -1
  93. package/dist/index.js +13 -0
  94. package/dist/index.js.map +1 -1
  95. package/dist/keyboard.d.ts +39 -2
  96. package/dist/keyboard.d.ts.map +1 -1
  97. package/dist/keyboard.js +145 -62
  98. package/dist/keyboard.js.map +1 -1
  99. package/dist/layout-store.d.ts +26 -0
  100. package/dist/layout-store.d.ts.map +1 -0
  101. package/dist/layout-store.js +46 -0
  102. package/dist/layout-store.js.map +1 -0
  103. package/dist/panel-levels.d.ts +18 -0
  104. package/dist/panel-levels.d.ts.map +1 -0
  105. package/dist/panel-levels.js +41 -0
  106. package/dist/panel-levels.js.map +1 -0
  107. package/dist/panels.d.ts +28 -0
  108. package/dist/panels.d.ts.map +1 -0
  109. package/dist/panels.js +73 -0
  110. package/dist/panels.js.map +1 -0
  111. package/dist/prefs.d.ts +5 -2
  112. package/dist/prefs.d.ts.map +1 -1
  113. package/dist/prefs.js +12 -3
  114. package/dist/prefs.js.map +1 -1
  115. package/dist/react.d.ts +460 -0
  116. package/dist/react.d.ts.map +1 -0
  117. package/dist/react.js +2 -0
  118. package/dist/react.js.map +1 -0
  119. package/dist/root-element.d.ts +122 -0
  120. package/dist/root-element.d.ts.map +1 -0
  121. package/dist/root-element.js +541 -0
  122. package/dist/root-element.js.map +1 -0
  123. package/dist/root.d.ts +30 -30
  124. package/dist/root.d.ts.map +1 -1
  125. package/dist/root.js +65 -42
  126. package/dist/root.js.map +1 -1
  127. package/dist/session.d.ts +19 -3
  128. package/dist/session.d.ts.map +1 -1
  129. package/dist/session.js +15 -17
  130. package/dist/session.js.map +1 -1
  131. package/dist/standalone/kgdiff-viewer.js +4 -4
  132. package/dist/styles.d.ts +4 -0
  133. package/dist/styles.d.ts.map +1 -1
  134. package/dist/styles.js +224 -84
  135. package/dist/styles.js.map +1 -1
  136. package/dist/theme.d.ts +7 -0
  137. package/dist/theme.d.ts.map +1 -1
  138. package/dist/theme.js +10 -7
  139. package/dist/theme.js.map +1 -1
  140. package/dist/topics.d.ts +8 -0
  141. package/dist/topics.d.ts.map +1 -0
  142. package/dist/topics.js +18 -0
  143. package/dist/topics.js.map +1 -0
  144. package/dist/ui/change-list.d.ts.map +1 -1
  145. package/dist/ui/change-list.js +2 -2
  146. package/dist/ui/change-list.js.map +1 -1
  147. package/dist/ui/context.d.ts +20 -7
  148. package/dist/ui/context.d.ts.map +1 -1
  149. package/dist/ui/context.js.map +1 -1
  150. package/dist/ui/header.d.ts +41 -3
  151. package/dist/ui/header.d.ts.map +1 -1
  152. package/dist/ui/header.js +99 -34
  153. package/dist/ui/header.js.map +1 -1
  154. package/dist/ui/legend.d.ts +15 -0
  155. package/dist/ui/legend.d.ts.map +1 -0
  156. package/dist/ui/legend.js +68 -0
  157. package/dist/ui/legend.js.map +1 -0
  158. package/dist/ui/popovers.d.ts +3 -1
  159. package/dist/ui/popovers.d.ts.map +1 -1
  160. package/dist/ui/popovers.js +62 -23
  161. package/dist/ui/popovers.js.map +1 -1
  162. package/dist/ui/search.d.ts.map +1 -1
  163. package/dist/ui/search.js +3 -2
  164. package/dist/ui/search.js.map +1 -1
  165. package/dist/ui/sidebar.d.ts +26 -3
  166. package/dist/ui/sidebar.d.ts.map +1 -1
  167. package/dist/ui/sidebar.js +344 -12
  168. package/dist/ui/sidebar.js.map +1 -1
  169. package/dist/ui/stage.d.ts +56 -1
  170. package/dist/ui/stage.d.ts.map +1 -1
  171. package/dist/ui/stage.js +244 -256
  172. package/dist/ui/stage.js.map +1 -1
  173. package/dist/ui/transport.d.ts +12 -0
  174. package/dist/ui/transport.d.ts.map +1 -0
  175. package/dist/ui/transport.js +198 -0
  176. package/dist/ui/transport.js.map +1 -0
  177. package/dist/ui/zoom.d.ts +15 -0
  178. package/dist/ui/zoom.d.ts.map +1 -0
  179. package/dist/ui/zoom.js +30 -0
  180. package/dist/ui/zoom.js.map +1 -0
  181. package/dist/view.d.ts +94 -0
  182. package/dist/view.d.ts.map +1 -0
  183. package/dist/view.js +441 -0
  184. package/dist/view.js.map +1 -0
  185. package/package.json +95 -12
  186. package/src/bind.ts +80 -0
  187. package/src/controller.ts +51 -442
  188. package/src/dom.ts +30 -0
  189. package/src/element.ts +364 -406
  190. package/src/elements/actions.ts +94 -0
  191. package/src/elements/bound.ts +247 -0
  192. package/src/elements/change-detail.ts +34 -0
  193. package/src/elements/change-list.ts +51 -0
  194. package/src/elements/filters.ts +34 -0
  195. package/src/elements/legend.ts +103 -0
  196. package/src/elements/minimap.ts +75 -0
  197. package/src/elements/root.ts +86 -0
  198. package/src/elements/search.ts +38 -0
  199. package/src/elements/stage-context.ts +34 -0
  200. package/src/elements/stage.ts +285 -0
  201. package/src/elements/title.ts +34 -0
  202. package/src/elements/transport.ts +39 -0
  203. package/src/elements/view-bound.ts +118 -0
  204. package/src/elements/view-dock.ts +43 -0
  205. package/src/elements/zoom.ts +38 -0
  206. package/src/i18n.ts +20 -0
  207. package/src/icons.ts +1 -0
  208. package/src/index.ts +347 -34
  209. package/src/keyboard.ts +188 -61
  210. package/src/layout-store.ts +71 -0
  211. package/src/panel-levels.ts +53 -0
  212. package/src/panels.ts +81 -0
  213. package/src/prefs.ts +12 -4
  214. package/src/react.ts +459 -0
  215. package/src/root-element.ts +624 -0
  216. package/src/root.ts +92 -65
  217. package/src/session.ts +22 -33
  218. package/src/styles.ts +245 -84
  219. package/src/theme.ts +10 -7
  220. package/src/topics.ts +37 -0
  221. package/src/ui/change-list.ts +6 -2
  222. package/src/ui/context.ts +20 -7
  223. package/src/ui/header.ts +127 -56
  224. package/src/ui/legend.ts +112 -0
  225. package/src/ui/popovers.ts +68 -25
  226. package/src/ui/search.ts +10 -5
  227. package/src/ui/sidebar.ts +386 -23
  228. package/src/ui/stage.ts +267 -331
  229. package/src/ui/transport.ts +240 -0
  230. package/src/ui/zoom.ts +45 -0
  231. package/src/view.ts +516 -0
  232. package/vscode.html-custom-data.json +726 -0
package/dist/ui/stage.js CHANGED
@@ -1,37 +1,77 @@
1
1
  /**
2
- * The canvas area and its floating chrome: the view dock (with replay's caption and transport
3
- * stacked above it), swipe divider, legend (a card, or one line of dots for chrome-less embeds),
4
- * zoom controls + minimap (shown only while part of the content is out of view), hover tooltip
5
- * and the loading / identical / error / empty states. Overlays never cover the current hunk:
6
- * fits and flights frame it in the free part of the canvas.
2
+ * The canvas area and its floating chrome: the view dock (`dock.ts`, with replay's caption and
3
+ * transport stacked above it), swipe divider, legend (`legend.ts`: a card, or one line of dots
4
+ * for chrome-less embeds), zoom controls (`zoom.ts`) + minimap (shown only while part of the
5
+ * content is out of view), hover tooltip and the loading / identical / error / empty states.
6
+ * Overlays never cover the current hunk: fits and flights frame it in the free part of the
7
+ * canvas. The view (`view` once attached) draws the canvas; the chrome reads the session.
8
+ *
9
+ * With slots (`<kgdiff-stage>`, and `<kgdiff-viewer>` for the page's content), a chrome element
10
+ * slotted in (`<kgdiff-transport slot="bottom-center">`) replaces the built-in one.
7
11
  */
8
12
  import { KgdiffError } from '@kgdiff/core';
9
- import { attr, h, icon, iconButton, relabel, replace, replaceKeepingFocus, setText, } from '../dom.js';
10
- import { FAMILIES } from '../styles.js';
13
+ import { attr, h, icon, moveFocusOut, replace, setText } from '../dom.js';
11
14
  import { sceneHunkGlyph } from './context.js';
12
15
  import { Dock } from './dock.js';
13
- import { swatch } from './swatch.js';
14
- const TICK_W = 1000;
16
+ import { Legend } from './legend.js';
17
+ import { Transport } from './transport.js';
18
+ import { ZoomBar } from './zoom.js';
19
+ /** The named slots of `<kgdiff-stage>`, where the page puts its own content over the canvas. */
20
+ export const STAGE_SLOTS = [
21
+ 'top-start',
22
+ 'top-end',
23
+ 'bottom-start',
24
+ 'bottom-center',
25
+ 'bottom-end',
26
+ ];
27
+ /** The chrome elements, by the region a slotted one replaces. */
28
+ const CHROME_TAGS = {
29
+ 'kgdiff-view-dock': 'dock',
30
+ 'kgdiff-transport': 'transport',
31
+ 'kgdiff-legend': 'legend',
32
+ 'kgdiff-zoom': 'zoom',
33
+ 'kgdiff-minimap': 'minimap',
34
+ 'kgdiff-title': 'title',
35
+ 'kgdiff-actions': 'actions',
36
+ };
15
37
  export class Stage {
16
38
  el;
17
39
  canvasHost;
18
40
  minimapHost;
41
+ #view = null;
42
+ /** Removes the stage's own minimap from the view; null while it has none. */
43
+ #ownMinimap = null;
44
+ /** The view drawing this stage's canvas (its camera, minimap, framing), once attached. */
45
+ get view() {
46
+ return this.#view;
47
+ }
48
+ set view(view) {
49
+ if (view === this.#view)
50
+ return;
51
+ this.#ownMinimap?.();
52
+ this.#ownMinimap = null;
53
+ this.#view = view;
54
+ this.#syncMinimap();
55
+ }
56
+ /**
57
+ * The view draws the stage's own minimap unless a slotted `<kgdiff-minimap>` replaces it:
58
+ * none is created, or drawn, while one is, and it comes back when that one leaves.
59
+ */
60
+ #syncMinimap() {
61
+ const view = this.#view;
62
+ const own = view !== null && !this.#replaced.has('minimap');
63
+ if (own && !this.#ownMinimap)
64
+ this.#ownMinimap = view.addMinimap(this.minimapHost);
65
+ else if (!own && this.#ownMinimap) {
66
+ this.#ownMinimap();
67
+ this.#ownMinimap = null;
68
+ }
69
+ }
19
70
  #chrome;
20
71
  #liveCanvasLabel;
21
72
  // Replay
22
73
  #caption;
23
- #transport;
24
- #playButton;
25
- #prevButton;
26
- #nextButton;
27
- #scrubber;
28
- #fill;
29
- #knob;
30
- #ticks;
31
- #scrubTip;
32
- #pos;
33
- #speed;
34
- #finished;
74
+ transport;
35
75
  // Swipe
36
76
  #swipe;
37
77
  #divider;
@@ -41,24 +81,37 @@ export class Stage {
41
81
  dock;
42
82
  #dockWrap;
43
83
  // Chrome
44
- #legend;
45
- #legendHead;
46
- #legendBody;
47
- #miniLegend;
48
- #zoomIn;
49
- #zoomOut;
50
- #fitButton;
84
+ legend;
85
+ zoom;
51
86
  #mapCluster;
52
- #zoomBar;
53
87
  tooltip;
54
88
  #state;
55
89
  #pointer = null;
56
- #scrubbing = null;
57
90
  #dragging = null;
58
- #legendOpen = false;
59
91
  #stateKey = '';
92
+ #slots = [];
93
+ /** Elements over the canvas that registered themselves (`registerObstacle`). */
94
+ #obstacles = new Set();
95
+ #version = null;
96
+ #versionChip = h('div', { class: 'version-chip chip card', hidden: true });
97
+ /** The chrome's panel states, and the regions a slotted chrome element replaces. */
98
+ #states = null;
99
+ #replaced = new Set();
100
+ /**
101
+ * Called when the regions slotted elements replace change: the layout that draws the title
102
+ * and the actions (`<kgdiff-viewer>`) applies its panels again.
103
+ */
104
+ onReplaced = null;
105
+ /** Whether the stage is a named region (`asRegion`) rather than the main landmark. */
106
+ #region = false;
107
+ /** The legend's state applied last: the user's toggle stands until it changes. */
108
+ #legendState = null;
60
109
  #ctx;
61
- constructor(ctx) {
110
+ /**
111
+ * `slots`: the stage has named slots for the page's content (stacked with the built-in chrome
112
+ * in its corner).
113
+ */
114
+ constructor(ctx, slots = false) {
62
115
  this.#ctx = ctx;
63
116
  const c = ctx.controller;
64
117
  this.canvasHost = h('div', { class: 'stage' });
@@ -69,32 +122,7 @@ export class Stage {
69
122
  'aria-hidden': 'true',
70
123
  'data-empty': 'true',
71
124
  });
72
- this.#prevButton = iconButton('skip-back', '', () => c.stepReplay(-1), { class: 'icon-btn' });
73
- this.#playButton = iconButton('play', '', () => c.togglePlay(), { class: 'icon-btn play' });
74
- this.#nextButton = iconButton('skip-forward', '', () => c.stepReplay(1), { class: 'icon-btn' });
75
- this.#fill = h('div', { class: 'fill' });
76
- this.#knob = h('div', { class: 'knob' });
77
- this.#ticks = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
78
- this.#ticks.setAttribute('class', 'ticks');
79
- this.#ticks.setAttribute('viewBox', `0 0 ${TICK_W} 14`);
80
- this.#ticks.setAttribute('preserveAspectRatio', 'none');
81
- this.#ticks.setAttribute('aria-hidden', 'true');
82
- this.#scrubTip = h('div', { class: 'scrub-tip card', hidden: true });
83
- this.#scrubber = h('div', {
84
- class: 'scrubber',
85
- role: 'slider',
86
- tabindex: '0',
87
- 'aria-valuemin': '0',
88
- }, h('div', { class: 'track' }, this.#fill), this.#ticks, this.#knob, this.#scrubTip);
89
- this.#bindScrubber();
90
- this.#pos = h('span', { class: 'pos', 'aria-hidden': 'true' });
91
- this.#speed = h('button', {
92
- type: 'button',
93
- class: 'speed',
94
- onclick: () => c.cycleSpeed(),
95
- });
96
- this.#transport = h('div', { class: 'transport card', role: 'toolbar' }, this.#prevButton, this.#playButton, this.#nextButton, this.#scrubber, this.#pos, this.#speed);
97
- this.#finished = h('div', { class: 'finished card', role: 'status', hidden: true });
125
+ this.transport = new Transport(ctx);
98
126
  // Swipe
99
127
  this.#chipBefore = h('div', { class: 'side-chip before chip card' });
100
128
  this.#chipAfter = h('div', { class: 'side-chip after chip card' });
@@ -110,28 +138,34 @@ export class Stage {
110
138
  this.#swipe = h('div', { class: 'swipe', hidden: true }, this.#chipBefore, this.#chipAfter, this.#divider);
111
139
  // View dock, with replay's finished pill, caption and transport stacked above it.
112
140
  this.dock = new Dock(ctx);
113
- this.#dockWrap = h('div', { class: 'dock' }, this.#finished, this.#caption, this.#transport, this.dock.el);
141
+ this.#dockWrap = h('div', { class: 'dock' }, this.transport.finished, this.#caption, this.transport.el, this.dock.el);
114
142
  // Legend
115
- this.#legendHead = h('button', {
116
- type: 'button',
117
- class: 'legend-head',
118
- 'aria-expanded': 'false',
119
- onclick: () => this.#setLegend(!this.#legendOpen),
120
- }, h('span', { class: 'lbl' }), icon('chevron-down'));
121
- this.#legendBody = h('div', { class: 'legend-body', id: 'kg-legend' });
122
- attr(this.#legendHead, 'aria-controls', 'kg-legend');
123
- this.#legend = h('div', { class: 'legend card float bottom-left', 'data-open': 'false' }, this.#legendHead, this.#legendBody);
124
- this.#miniLegend = h('div', { class: 'mini-legend float bottom-left', role: 'list' });
125
- // Zoom + minimap
126
- this.#zoomIn = iconButton('plus', '', () => c.zoom(1));
127
- this.#zoomOut = iconButton('minus', '', () => c.zoom(-1));
128
- this.#fitButton = iconButton('fit', '', () => void c.fit());
143
+ this.legend = new Legend(ctx, (open) => ctx.setPanel?.('legend', open ? 'open' : 'collapsed'));
144
+ // Zoom + minimap: the buttons act on this stage's view.
145
+ this.zoom = new ZoomBar(ctx, () => this.view);
129
146
  this.minimapHost = h('div', { class: 'minimap-host' });
130
- this.#zoomBar = h('div', { class: 'zoom-bar card', role: 'toolbar' }, this.#zoomIn, this.#zoomOut, this.#fitButton);
131
- this.#mapCluster = h('div', { class: 'map-cluster float bottom-right' }, this.#zoomBar, this.minimapHost);
147
+ this.#mapCluster = h('div', { class: 'map-cluster float bottom-right' }, this.zoom.el, this.minimapHost);
132
148
  this.tooltip = h('div', { class: 'tooltip card', role: 'tooltip', 'data-open': 'false' });
133
149
  this.#state = h('div', { class: 'state', hidden: true });
134
- this.#chrome = h('div', { class: 'chrome' }, this.#swipe, this.#legend, this.#miniLegend, this.#mapCluster, this.#dockWrap, this.tooltip);
150
+ let bottomStart = this.legend.card;
151
+ let bottomEnd = this.#mapCluster;
152
+ const corners = [];
153
+ if (slots) {
154
+ const slot = (name) => {
155
+ const el = document.createElement('slot');
156
+ el.name = name;
157
+ el.addEventListener('slotchange', () => this.slotted());
158
+ this.#slots.push(el);
159
+ return el;
160
+ };
161
+ // Slotted content sits further from the corner than the built-in chrome.
162
+ corners.push(h('div', { class: 'corner top-start' }, this.#versionChip, slot('top-start')), h('div', { class: 'corner top-end' }, slot('top-end')));
163
+ bottomStart = h('div', { class: 'corner bottom-start' }, slot('bottom-start'), this.legend.card);
164
+ bottomEnd = h('div', { class: 'corner bottom-end' }, slot('bottom-end'), this.#mapCluster);
165
+ this.#dockWrap.prepend(slot('bottom-center'));
166
+ }
167
+ // The top corners first: the chrome reads and tabs from the top down.
168
+ this.#chrome = h('div', { class: 'chrome' }, ...corners, this.#swipe, bottomStart, this.legend.mini, bottomEnd, this.#dockWrap, this.tooltip);
135
169
  this.el = h('main', { class: 'stage-wrap' }, this.canvasHost, this.#liveCanvasLabel, this.#chrome, this.#state);
136
170
  this.el.addEventListener('pointermove', (e) => {
137
171
  const r = this.el.getBoundingClientRect();
@@ -152,22 +186,27 @@ export class Stage {
152
186
  const r = this.el.getBoundingClientRect();
153
187
  const obstacles = [];
154
188
  const parts = [
155
- this.#legend,
156
- this.#miniLegend,
157
- this.#zoomBar,
189
+ this.legend.card,
190
+ this.legend.mini,
191
+ this.zoom.el,
158
192
  this.dock.el,
159
- this.#transport,
160
- this.#finished,
193
+ this.transport.el,
194
+ this.transport.finished,
161
195
  this.#chipBefore,
162
196
  this.#chipAfter,
163
197
  ];
164
198
  if (this.#caption.dataset.empty !== 'true')
165
199
  parts.push(this.#caption);
200
+ parts.push(this.#versionChip);
166
201
  if (this.minimapHost.dataset.hidden !== 'true')
167
202
  parts.push(this.minimapHost);
168
203
  // The HUD (title, actions) floats over the canvas too.
169
204
  for (const el of this.el.querySelectorAll('.hud .card'))
170
205
  parts.push(el);
206
+ // The page's content in the slots, and what registered itself: one rule for all.
207
+ for (const slot of this.#slots)
208
+ parts.push(...slot.assignedElements({ flatten: true }));
209
+ parts.push(...this.#obstacles);
171
210
  for (const el of parts) {
172
211
  if (el.hidden || el.closest('[hidden]'))
173
212
  continue;
@@ -178,14 +217,118 @@ export class Stage {
178
217
  }
179
218
  return { width: r.width, height: r.height, obstacles };
180
219
  }
220
+ /**
221
+ * Keep `el` clear of fits and flights, as the chrome is (content over the canvas the stage
222
+ * does not place itself); returns the function that stops it.
223
+ */
224
+ registerObstacle(el) {
225
+ this.#obstacles.add(el);
226
+ return () => {
227
+ this.#obstacles.delete(el);
228
+ };
229
+ }
181
230
  /** Re-measure what depends on rendered sizes (fonts, resizes). */
182
231
  layout() {
183
232
  this.dock.layout();
184
233
  }
185
- #setLegend(open) {
186
- this.#legendOpen = open;
187
- attr(this.#legend, 'data-open', String(open));
188
- attr(this.#legendHead, 'aria-expanded', String(open));
234
+ /**
235
+ * The page's slotted content changed (and once the stage is in its shadow root): a chrome
236
+ * element replaces the built-in one.
237
+ */
238
+ slotted() {
239
+ const replaced = new Set();
240
+ for (const slot of this.#slots) {
241
+ for (const el of slot.assignedElements({ flatten: true })) {
242
+ const region = CHROME_TAGS[el.localName];
243
+ if (region)
244
+ replaced.add(region);
245
+ }
246
+ }
247
+ const same = replaced.size === this.#replaced.size && [...replaced].every((r) => this.#replaced.has(r));
248
+ if (!same) {
249
+ this.#replaced.clear();
250
+ for (const r of replaced)
251
+ this.#replaced.add(r);
252
+ this.#applyPanels();
253
+ this.#syncMinimap();
254
+ this.onReplaced?.();
255
+ }
256
+ this.view?.layoutChanged();
257
+ }
258
+ /** Whether a chrome element slotted into the stage replaces `region`. */
259
+ replaces(region) {
260
+ return this.#replaced.has(region);
261
+ }
262
+ /**
263
+ * Follow the panel states of the chrome over the canvas: the dock, the replay transport, the
264
+ * legend (collapsed: folded to its head), the zoom buttons and the minimap. A region that is
265
+ * not open is not shown (and inert); focus inside it moves to the canvas first. A region a
266
+ * slotted chrome element replaces is not shown either, whatever its state.
267
+ */
268
+ setPanels(states) {
269
+ this.#states = states;
270
+ this.#applyPanels();
271
+ }
272
+ #applyPanels() {
273
+ const given = this.#states;
274
+ const state = (r) => this.#replaced.has(r) ? 'hidden' : (given?.[r] ?? (r === 'legend' ? 'collapsed' : 'open'));
275
+ // Nothing declared or replaced, ever: the chrome stays as built.
276
+ if (!given && this.#replaced.size === 0 && this.#legendState === null)
277
+ return;
278
+ const canvas = () => this.canvasHost.querySelector('canvas');
279
+ const set = (s, ...els) => {
280
+ for (const el of els) {
281
+ if (s !== 'open')
282
+ moveFocusOut(this.#ctx.root, el, canvas);
283
+ attr(el, 'data-panel', s);
284
+ el.inert = s !== 'open';
285
+ }
286
+ };
287
+ set(state('dock'), this.dock.el);
288
+ set(state('transport'), this.transport.el, this.transport.finished);
289
+ set(state('zoom'), this.zoom.el);
290
+ set(state('minimap'), this.minimapHost);
291
+ const legend = state('legend');
292
+ if (legend === 'hidden')
293
+ moveFocusOut(this.#ctx.root, this.legend.card, canvas);
294
+ attr(this.legend.card, 'data-panel', legend);
295
+ attr(this.legend.mini, 'data-panel', legend);
296
+ this.legend.card.inert = legend === 'hidden';
297
+ if (legend !== 'hidden' && legend !== this.#legendState)
298
+ this.legend.setOpen(legend === 'open');
299
+ this.#legendState = legend;
300
+ }
301
+ /**
302
+ * The version a stage pinned to it draws (`<kgdiff-stage version>`): no swipe divider or
303
+ * replay caption then, and a chip naming the version; null follows the mode.
304
+ */
305
+ setVersion(version) {
306
+ this.#version = version;
307
+ if (version)
308
+ this.el.dataset.version = version;
309
+ else
310
+ delete this.el.dataset.version;
311
+ this.#renderMode();
312
+ this.#renderVersion(this.#ctx.tr());
313
+ }
314
+ #renderVersion(tr) {
315
+ const v = this.#version;
316
+ const version = v ? tr.t(v === 'before' ? 'detail.before' : 'detail.after') : '';
317
+ this.#versionChip.hidden = v === null;
318
+ if (v)
319
+ setText(this.#versionChip, version);
320
+ if (this.#region) {
321
+ attr(this.el, 'aria-label', v ? `${tr.t('stage.label')} · ${version}` : tr.t('stage.label'));
322
+ }
323
+ }
324
+ /**
325
+ * A region named after the graph (and its version), not the page's main landmark: for a
326
+ * `<kgdiff-stage>`, one of several on a page that has its own.
327
+ */
328
+ asRegion() {
329
+ this.#region = true;
330
+ this.el.setAttribute('role', 'region');
331
+ this.#renderVersion(this.#ctx.tr());
189
332
  }
190
333
  /** Legend style, and whether clicks select (the tooltip only hints at clicking then). */
191
334
  setChrome(legend, selectable) {
@@ -197,63 +340,36 @@ export class Stage {
197
340
  const tr = this.#ctx.tr();
198
341
  const all = topics.has('status') || topics.has('data') || topics.has('locale');
199
342
  if (all) {
343
+ this.#renderVersion(tr);
200
344
  this.#renderLabels(tr);
201
- this.#renderMiniLegend(tr);
202
345
  this.#renderState(tr);
203
- this.#renderTicks();
204
346
  this.#renderCanvasLabel(tr);
205
347
  }
348
+ this.legend.update(topics);
349
+ this.zoom.update(topics);
350
+ this.transport.update(topics);
206
351
  this.dock.update(topics);
207
352
  if (all || topics.has('mode'))
208
353
  this.#renderMode();
209
354
  if (all || topics.has('overview')) {
210
- attr(this.minimapHost, 'data-hidden', String(c.overview));
355
+ attr(this.minimapHost, 'data-hidden', String(this.view?.overview ?? true));
211
356
  }
212
357
  if (all || topics.has('mode') || topics.has('replay'))
213
- this.#renderReplay(tr);
358
+ this.#renderCaption(tr);
214
359
  if (all || topics.has('mode') || topics.has('swipe'))
215
360
  this.#renderSwipe();
216
361
  if (all || topics.has('hover') || topics.has('selection') || topics.has('focus')) {
217
362
  this.#renderTooltip(tr);
218
363
  }
219
- if (topics.has('theme'))
220
- this.#renderTicks();
221
- const ready = c.status === 'ready';
222
- this.#chrome.hidden = !ready;
364
+ // Until a diff is ready the built-in chrome waits; the page's slotted content stays.
365
+ attr(this.#chrome, 'data-ready', String(c.status === 'ready'));
223
366
  }
224
367
  #renderLabels(tr) {
225
- relabel(this.#prevButton, `${tr.t('detail.prev')} (←)`);
226
- relabel(this.#nextButton, `${tr.t('detail.next')} (→)`);
227
- attr(this.#transport, 'aria-label', tr.t('replay.label'));
228
- attr(this.#scrubber, 'aria-label', tr.t('replay.scrubber'));
229
368
  setText(this.#chipBefore, tr.t('detail.before'));
230
369
  setText(this.#chipAfter, tr.t('detail.after'));
231
370
  attr(this.#divider, 'aria-label', tr.t('swipe.divider'));
232
- setText(this.#legendHead.querySelector('.lbl'), tr.t('legend.title'));
233
- attr(this.#legendHead, 'aria-label', tr.t('legend.toggle'));
234
- relabel(this.#zoomIn, `${tr.t('action.zoomIn')} (+)`);
235
- relabel(this.#zoomOut, `${tr.t('action.zoomOut')} (−)`);
236
- relabel(this.#fitButton, `${tr.t('action.fit')} (F)`);
237
371
  attr(this.minimapHost, 'aria-label', tr.t('minimap.label'));
238
372
  attr(this.minimapHost, 'title', tr.t('minimap.label'));
239
- const fams = [...FAMILIES, 'neutral'];
240
- replace(this.#legendBody, fams.map((f) => h('div', { class: 'legend-row', 'data-family': f, title: tr.t(`familyHint.${f}`) }, swatch(f), h('span', { class: 'name', text: tr.t(`family.${f}`) }))));
241
- }
242
- /** One line: the families this diff has, then the unchanged context. */
243
- #renderMiniLegend(tr) {
244
- const scene = this.#ctx.controller.scene;
245
- const present = new Set(scene?.hunks.map((x) => x.family));
246
- if (scene?.nodes.some((n) => n.status === 'unchanged'))
247
- present.add('neutral');
248
- attr(this.#miniLegend, 'aria-label', tr.t('legend.title'));
249
- replace(this.#miniLegend, [...FAMILIES, 'neutral']
250
- .filter((f) => present.has(f))
251
- .map((f) => h('span', {
252
- class: 'mini-item',
253
- role: 'listitem',
254
- 'data-family': f,
255
- title: tr.t(`familyHint.${f}`),
256
- }, h('span', { class: 'dot', 'aria-hidden': 'true' }), tr.t(`family.${f}`))));
257
373
  }
258
374
  #renderCanvasLabel(tr) {
259
375
  const stats = this.#ctx.controller.diff?.stats;
@@ -273,39 +389,19 @@ export class Stage {
273
389
  }
274
390
  #renderMode() {
275
391
  const mode = this.#ctx.controller.mode;
276
- this.#transport.hidden = mode !== 'replay';
277
- this.#caption.hidden = mode !== 'replay';
278
- this.#swipe.hidden = mode !== 'swipe';
392
+ const pinned = this.#version !== null;
393
+ this.#caption.hidden = mode !== 'replay' || pinned;
394
+ this.#swipe.hidden = mode !== 'swipe' || pinned;
279
395
  }
280
- #renderReplay(tr) {
396
+ /** The replay caption: the playing hunk (or chapter), else the last one played. */
397
+ #renderCaption(tr) {
281
398
  const c = this.#ctx.controller;
282
399
  const clock = c.clock;
283
400
  const scene = c.scene;
284
- if (c.mode !== 'replay' || !clock || !scene) {
285
- this.#finished.hidden = true;
401
+ if (c.mode !== 'replay' || !clock || !scene)
286
402
  return;
287
- }
288
- const p = clock.progress;
289
- this.#fill.style.transform = `scaleX(${p})`;
290
- this.#knob.style.left = `${p * 100}%`;
291
403
  const position = clock.position();
292
404
  const total = clock.total;
293
- setText(this.#pos, tr.t('replay.position', { i: position, n: total }));
294
- attr(this.#scrubber, 'aria-valuemax', String(Math.round(clock.duration)));
295
- attr(this.#scrubber, 'aria-valuenow', String(Math.round(clock.t)));
296
- attr(this.#scrubber, 'aria-valuetext', tr.t('replay.position', { i: position, n: total }));
297
- const playing = clock.playing;
298
- const playLabel = `${tr.t(playing ? 'replay.pause' : 'replay.play')} (Space)`;
299
- if (this.#playButton.dataset.state !== String(playing)) {
300
- this.#playButton.dataset.state = String(playing);
301
- replace(this.#playButton, icon(playing ? 'pause' : 'play'));
302
- }
303
- relabel(this.#playButton, playLabel);
304
- setText(this.#speed, `${clock.speed}×`);
305
- relabel(this.#speed, tr.t('replay.speed', { s: `${clock.speed}×` }));
306
- this.#prevButton.disabled = clock.t <= 0;
307
- this.#nextButton.disabled = clock.t >= clock.duration;
308
- // Caption: the playing hunk (or chapter), else the last one played.
309
405
  let key = '';
310
406
  let content = [];
311
407
  if (clock.t <= 0) {
@@ -347,117 +443,9 @@ export class Stage {
347
443
  attr(this.#caption, 'data-empty', String(key === ''));
348
444
  replace(this.#caption, ...content);
349
445
  }
350
- const finished = clock.finished;
351
- this.#finished.hidden = !finished;
352
446
  // The finished pill takes the caption's place above the transport.
353
- this.#caption.hidden = finished;
354
- if (finished && this.#finished.childElementCount === 0) {
355
- replace(this.#finished, icon('check-circle'), h('span', { text: tr.t('replay.finished') }), h('button', { type: 'button', class: 'btn', onclick: () => c.restart() }, icon('restart'), tr.t('replay.restart')));
356
- }
357
- else if (!finished && this.#finished.childElementCount > 0) {
358
- // Restart was pressed from the pill: keep focus in the transport.
359
- replaceKeepingFocus(this.#ctx.root, this.#finished, this.#playButton);
360
- }
361
- }
362
- #renderTicks() {
363
- const c = this.#ctx.controller;
364
- const clock = c.clock;
365
- const scene = c.scene;
366
- while (this.#ticks.firstChild)
367
- this.#ticks.removeChild(this.#ticks.firstChild);
368
- if (!clock || !scene || clock.duration <= 0)
369
- return;
370
- const paths = new Map();
371
- for (const w of clock.timeline.windows) {
372
- const fam = scene.hunks[w.hunk]?.family;
373
- if (!fam)
374
- continue;
375
- const x = ((w.start / clock.duration) * TICK_W).toFixed(1);
376
- let list = paths.get(fam);
377
- if (!list) {
378
- list = [];
379
- paths.set(fam, list);
380
- }
381
- list.push(`M${x} 2V12`);
382
- }
383
- for (const [fam, d] of paths) {
384
- const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
385
- p.setAttribute('d', d.join(''));
386
- p.setAttribute('stroke', `var(--kgdiff-${fam})`);
387
- p.setAttribute('stroke-width', '2');
388
- p.setAttribute('stroke-linecap', 'round');
389
- p.setAttribute('vector-effect', 'non-scaling-stroke');
390
- this.#ticks.appendChild(p);
391
- }
392
- }
393
- #bindScrubber() {
394
- const timeAt = (clientX) => {
395
- const clock = this.#ctx.controller.clock;
396
- const r = this.#scrubber.getBoundingClientRect();
397
- if (!clock || r.width <= 0)
398
- return 0;
399
- return Math.max(0, Math.min(1, (clientX - r.left) / r.width)) * clock.duration;
400
- };
401
- this.#scrubber.addEventListener('pointerdown', (e) => {
402
- this.#scrubbing = e.pointerId;
403
- this.#scrubber.setPointerCapture?.(e.pointerId);
404
- this.#ctx.controller.pause();
405
- this.#ctx.controller.seek(timeAt(e.clientX));
406
- });
407
- this.#scrubber.addEventListener('pointermove', (e) => {
408
- if (this.#scrubbing === e.pointerId)
409
- this.#ctx.controller.seek(timeAt(e.clientX));
410
- this.#showScrubTip(e.clientX, timeAt(e.clientX));
411
- });
412
- const end = (e) => {
413
- if (this.#scrubbing === e.pointerId)
414
- this.#scrubbing = null;
415
- };
416
- this.#scrubber.addEventListener('pointerup', end);
417
- this.#scrubber.addEventListener('pointercancel', end);
418
- this.#scrubber.addEventListener('pointerleave', () => {
419
- this.#scrubTip.hidden = true;
420
- });
421
- this.#scrubber.addEventListener('keydown', (e) => {
422
- const c = this.#ctx.controller;
423
- if (e.key === 'Home')
424
- c.seek(0);
425
- else if (e.key === 'End')
426
- c.seek(c.clock?.duration ?? 0);
427
- else
428
- return;
429
- e.preventDefault();
430
- e.stopPropagation();
431
- });
432
- }
433
- #showScrubTip(clientX, t) {
434
- const c = this.#ctx.controller;
435
- const clock = c.clock;
436
- const scene = c.scene;
437
- if (!clock || !scene)
438
- return;
439
- // Nearest window start: the tick under the pointer.
440
- let best = -1;
441
- let bestD = Number.POSITIVE_INFINITY;
442
- for (const w of clock.timeline.windows) {
443
- const d = Math.abs(w.start - t);
444
- if (d < bestD) {
445
- bestD = d;
446
- best = w.hunk;
447
- }
448
- }
449
- const hunk = scene.hunks[best];
450
- if (!hunk) {
451
- this.#scrubTip.hidden = true;
452
- return;
453
- }
454
- const r = this.#scrubber.getBoundingClientRect();
455
- this.#scrubTip.hidden = false;
456
- this.#scrubTip.style.left = `${clientX - r.left}px`;
457
- if (this.#scrubTip.dataset.hunk !== hunk.id) {
458
- this.#scrubTip.dataset.hunk = hunk.id;
459
- replace(this.#scrubTip, h('span', { class: 'glyph-box', 'data-family': hunk.family, text: sceneHunkGlyph(hunk) }), h('span', { text: hunk.title }));
460
- }
447
+ if (this.#version === null)
448
+ this.#caption.hidden = clock.finished;
461
449
  }
462
450
  #renderSwipe() {
463
451
  const c = this.#ctx.controller;