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