@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/src/element.ts CHANGED
@@ -1,43 +1,41 @@
1
1
  /**
2
- * `<kgdiff-viewer>`, the default layout (RFC 0001 §4): a root (the session, keyboard map, live
3
- * region, events and theme tokens; `root.ts`) and the regions bound to it, the stage with its
4
- * chrome, the change list, the search, the family filters and the detail panel, placed as they
5
- * always were (a 360 px sidebar; a bottom sheet below 760 px). Around them: attribute /
6
- * property reflection, lifecycle, the public methods and deep links.
2
+ * `<kgdiff-viewer>`, the default layout (RFC 0001 §4): a root (`root-element.ts`: the session,
3
+ * keyboard map, live region, events, theme tokens, preferences and methods) and the regions
4
+ * bound to it, the stage with its chrome, the change list, the search, the family filters and
5
+ * the detail panel, placed as they always were (a 360 px sidebar; a bottom sheet below 760 px).
6
+ *
7
+ * Every region has a panel state (`panels.ts`): the host's `panels` attribute or property and
8
+ * the presets `sidebar`, `header` and `chrome` set them, the user changes them (folding the
9
+ * sidebar, the list or the detail panel, opening the legend, dragging the sidebar's width or
10
+ * the list / detail split), and the layout remembers the user's choices, theme, palette and
11
+ * language per origin (`layout-store.ts`; `persist="none"` turns that off). `kgdiff-layout`
12
+ * reports every change.
7
13
  */
8
- import { type Delta, KgdiffError, type Locale } from '@kgdiff/core';
9
- import type { PaletteName } from '@kgdiff/scene';
10
- import type { ViewerController } from './controller.js';
11
- import { h } from './dom.js';
12
- import { formatHash, parseHash } from './hash-sync.js';
13
- import { detectLocale, translate, translator } from './i18n.js';
14
+
15
+ import { provideContext } from './context.js';
16
+ import { ViewerController } from './controller.js';
17
+ import { h, isOpen } from './dom.js';
18
+ import { type StageViewContext, stageContext } from './elements/stage-context.js';
14
19
  import type {
15
- FitOptions,
16
20
  KgdiffViewerElement,
17
21
  LoadOptions,
22
+ PanelRegion,
23
+ PanelStates,
18
24
  ReportPayload,
19
- ReviewState,
20
- ThemePreference,
21
25
  ViewerChrome,
26
+ ViewerLayout,
22
27
  ViewerLegend,
23
- ViewerMode,
24
- ViewerScope,
28
+ ViewerPanels,
25
29
  } from './index.js';
26
- import { assertPayload } from './payload.js';
27
- import {
28
- DEFAULT_LOOP_DELAY,
29
- LEGACY_CHANGES_MODE,
30
- toChrome,
31
- toDelay,
32
- toLegend,
33
- toLocale,
34
- toMode,
35
- toPalette,
36
- toScope,
37
- toTheme,
38
- } from './prefs.js';
39
- import { bindRegion, uiContext, ViewerRoot } from './root.js';
40
- import { ALL_TOPICS, type Topic } from './session.js';
30
+ import { LAYOUT_KEY, type Remembered, readRemembered, writeRemembered } from './layout-store.js';
31
+ import { presetPanels, resolvePanels, SIDEBAR_REGIONS, samePanels } from './panel-levels.js';
32
+ import { DEFAULT_PANELS, formatPanels, PANEL_REGIONS, parsePanels, toPanels } from './panels.js';
33
+ import { sanitizeView, toChrome, toLegend } from './prefs.js';
34
+ import { defaultStorage } from './review-store.js';
35
+ import { bindRegion, uiContext } from './root.js';
36
+ import { ROOT_ATTRIBUTES, RootElement } from './root-element.js';
37
+ import type { SessionDeps, SessionHost, Topic } from './session.js';
38
+ import { stylesheet } from './styles.js';
41
39
  import { ChangeList } from './ui/change-list.js';
42
40
  import type { UiActions } from './ui/context.js';
43
41
  import { Detail } from './ui/detail.js';
@@ -45,7 +43,7 @@ import { Filters } from './ui/filters.js';
45
43
  import { Header } from './ui/header.js';
46
44
  import { HelpDialog, Menu } from './ui/popovers.js';
47
45
  import { Search } from './ui/search.js';
48
- import { Sidebar } from './ui/sidebar.js';
46
+ import { SIDEBAR_WIDTH, Sidebar } from './ui/sidebar.js';
49
47
  import { Stage } from './ui/stage.js';
50
48
 
51
49
  /** Below this width the sidebar becomes a bottom sheet. */
@@ -54,67 +52,41 @@ export const COMPACT_WIDTH = 760;
54
52
  const NARROW_WIDTH = 1100;
55
53
 
56
54
  const OBSERVED = [
57
- 'mode',
58
- 'scope',
59
- 'theme',
60
- 'palette',
61
- 'locale',
55
+ ...ROOT_ATTRIBUTES,
62
56
  'sidebar',
63
- 'sync-hash',
64
- 'chrome',
65
- 'legend',
66
- 'loop',
67
- 'loop-delay',
68
- 'pause-on-hover',
69
- ] as const;
70
- const UPGRADED = [
71
- 'mode',
72
- 'scope',
73
- 'theme',
74
- 'palette',
75
- 'locale',
57
+ 'header',
76
58
  'chrome',
77
59
  'legend',
78
- 'loop',
79
- 'loopDelay',
80
- 'pauseOnHover',
81
- 'payload',
60
+ 'panels',
61
+ 'persist',
62
+ 'persist-key',
82
63
  ] as const;
83
64
 
84
- /** Keys the page itself scrolls with: never taken at document level. */
85
- const PAGE_KEYS: ReadonlySet<string> = new Set([
86
- 'ArrowUp',
87
- 'ArrowDown',
88
- 'ArrowLeft',
89
- 'ArrowRight',
90
- 'Home',
91
- 'End',
92
- 'PageUp',
93
- 'PageDown',
94
- ' ',
95
- 'Spacebar',
96
- ]);
97
-
98
- /** Connected viewers, in connection order (`global-shortcuts` goes to the first opted-in one). */
99
- const connected: KgdiffViewer[] = [];
100
-
101
- function deepFreeze<T>(value: T): T {
102
- if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) {
103
- for (const v of Object.values(value)) deepFreeze(v);
104
- Object.freeze(value);
105
- }
106
- return value;
65
+ type Preset = 'sidebar' | 'header' | 'chrome';
66
+ type MutablePanels = { -readonly [R in PanelRegion]?: PanelStates[R] };
67
+
68
+ function warnPanels(invalid: readonly string[]): void {
69
+ if (invalid.length > 0) console.warn(`[kgdiff] ignoring invalid panels: ${invalid.join(' ')}`);
107
70
  }
108
71
 
109
- export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
72
+ /**
73
+ * @tagname kgdiff-viewer
74
+ * @attr {'open' | 'closed'} sidebar - A preset: `closed` starts with the sidebar folded (`\` and the
75
+ * sidebar button open it), `open` with its four regions open. Reflects the sidebar's state
76
+ * once the user toggles it.
77
+ * @attr {'compact'} header - A preset: `compact` leaves the brand and the compared names out of
78
+ * the canvas, for hosts that show their own (`panels="title:hidden"`).
79
+ * @slot top-start - The page's content over the canvas, top left (under the title while it shows).
80
+ * @slot top-end - The page's content over the canvas, top right (under the actions while they show).
81
+ * @slot bottom-start - The page's content over the canvas, bottom left, above the legend.
82
+ * @slot bottom-center - The page's content above the view dock and the transport.
83
+ * @slot bottom-end - The page's content over the canvas, bottom right, above the zoom buttons and the minimap.
84
+ */
85
+ export class KgdiffViewer extends RootElement<ViewerController> implements KgdiffViewerElement {
110
86
  static get observedAttributes(): readonly string[] {
111
87
  return OBSERVED;
112
88
  }
113
89
 
114
- /** @internal */
115
- readonly controller: ViewerController;
116
- readonly #shadow: ShadowRoot;
117
- readonly #root: ViewerRoot;
118
90
  #ui: {
119
91
  root: HTMLElement;
120
92
  header: Header;
@@ -124,50 +96,52 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
124
96
  menu: Menu;
125
97
  help: HelpDialog;
126
98
  } | null = null;
127
- #currentPayload: ReportPayload | null = null;
128
- #reflecting = false;
129
99
  #compact = false;
130
100
  readonly #cleanup: (() => void)[] = [];
131
- #lastHash = '';
132
- #upgraded = false;
133
- /** A mouse or pen rests on the graph (holds a looping replay with `pause-on-hover`). */
134
- #pointerOver = false;
101
+
102
+ // Panel states, from the first level that names a region (see `panels.ts`).
103
+ /** What the user changed in this page. */
104
+ readonly #runtime: MutablePanels = {};
105
+ /** The host's `panels`. */
106
+ #hostPanels: ViewerPanels = {};
107
+ /** The presets, from the attributes as the host set them (not as the layout reflects them). */
108
+ readonly #presetAttrs: Record<Preset, string | null> = {
109
+ sidebar: null,
110
+ header: null,
111
+ chrome: null,
112
+ };
113
+ #presets: ViewerPanels = {};
114
+ /** What the user chose before (`persist`), read at the first connection. */
115
+ #remembered: Remembered = { panels: {} };
116
+ /** The report's `view.panels`. */
117
+ #report: ViewerPanels = {};
118
+ #states: PanelStates = DEFAULT_PANELS;
119
+ /** The sidebar's regions that were open when the user folded it. */
120
+ #folded: readonly PanelRegion[] | null = null;
121
+ /** `kgdiff-layout` reports changes from the first connection on. */
122
+ #layoutReady = false;
123
+ readonly #storage = defaultStorage();
135
124
 
136
125
  constructor() {
137
126
  super();
138
- this.#shadow = this.attachShadow({ mode: 'open' });
139
- this.#root = new ViewerRoot(this, this.#shadow, this.#actions(), (type) => {
140
- if (type === 'kgdiff-select' || type === 'kgdiff-mode') this.#writeHash();
141
- });
142
- this.controller = this.#root.controller;
143
- this.controller.locale = toLocale(this.getAttribute('locale')) ?? detectLocale();
144
127
  // The layout renders first, then the regions in the order they bound to the root.
145
- this.#root.attach({ update: (topics) => this.#update(topics) });
128
+ this.root.attach({ update: (topics) => this.#update(topics) });
146
129
  }
147
130
 
148
131
  // ─── Lifecycle ────────────────────────────────────────────────────────────
149
132
 
150
- connectedCallback(): void {
151
- if (!this.#upgraded) {
152
- this.#upgraded = true;
153
- // Properties set before the element was defined shadow the accessors: re-apply them.
154
- for (const prop of UPGRADED) {
155
- if (Object.hasOwn(this, prop)) {
156
- const value = (this as unknown as Record<string, unknown>)[prop];
157
- delete (this as unknown as Record<string, unknown>)[prop];
158
- (this as unknown as Record<string, unknown>)[prop] = value;
159
- }
160
- }
161
- }
162
- // Provide the session (the regions bind to it) and follow the system's preferences.
163
- this.#cleanup.push(this.#root.connect());
133
+ protected connected(): void {
164
134
  if (!this.#ui) this.#build();
165
- connected.push(this);
166
135
  const ui = this.#ui;
167
136
  if (!ui) return;
168
- this.controller.attach(ui.stage.canvasHost, ui.stage.minimapHost);
137
+ if (!this.#layoutReady) {
138
+ this.#loadRemembered();
139
+ this.#layoutReady = true;
140
+ }
141
+ // The stage adds its own minimap to the view (unless a slotted one replaces it).
142
+ this.controller.attach(ui.stage.canvasHost, null);
169
143
  // The graph takes keyboard focus (shortcuts apply where focus is), also on click.
170
- const canvas = ui.stage.canvasHost.querySelector('canvas');
144
+ const canvas = this.#canvas();
171
145
  if (canvas) {
172
146
  canvas.tabIndex = this.chrome === 'none' ? -1 : 0;
173
147
  const focusCanvas = (): void => {
@@ -177,171 +151,104 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
177
151
  this.#cleanup.push(() => canvas.removeEventListener('pointerdown', focusCanvas));
178
152
  }
179
153
  this.#watchSize();
180
- const onKey = (e: KeyboardEvent): void => this.#onKeyDown(e);
181
- document.addEventListener('keydown', onKey);
182
- this.#cleanup.push(() => document.removeEventListener('keydown', onKey));
183
- const onHash = (): void => this.#readHash();
184
- window.addEventListener('hashchange', onHash);
185
- this.#cleanup.push(() => window.removeEventListener('hashchange', onHash));
186
154
  this.controller.setChrome(() => ui.stage.chromeLayout());
187
155
  document.fonts?.ready?.then(() => this.#ui?.stage.layout()).catch(() => undefined);
188
- this.#readHash();
189
- this.#root.render(ALL_TOPICS);
190
156
  }
191
157
 
192
- disconnectedCallback(): void {
193
- const i = connected.indexOf(this);
194
- if (i >= 0) connected.splice(i, 1);
158
+ protected disconnected(): void {
195
159
  for (const fn of this.#cleanup.splice(0)) fn();
196
160
  this.controller.detach();
197
- // Moved while hovered: no pointerleave follows.
198
- this.#pointerOver = false;
199
- this.#applyHold();
200
161
  }
201
162
 
202
- attributeChangedCallback(name: string, _old: string | null, value: string | null): void {
203
- if (this.#reflecting) return;
163
+ protected override attributeChanged(
164
+ name: string,
165
+ value: string | null,
166
+ old: string | null,
167
+ ): void {
204
168
  switch (name) {
205
- case 'mode':
206
- case 'scope':
207
- case 'theme':
208
- case 'palette':
209
- case 'locale':
210
- this.#applyPreference(name, value, false);
211
- break;
212
169
  case 'sidebar':
213
- this.#applySidebar();
170
+ // The layout reflects the sidebar's state here (after `\`): the page changing the
171
+ // attribute from what it says now is a command, even back to what the page said
172
+ // before; setting what it already says is not.
173
+ this.#preset(name, value, value !== old);
214
174
  break;
215
- case 'sync-hash':
216
- if (value !== null) this.#readHash();
175
+ case 'header':
176
+ this.#preset(name, value);
217
177
  break;
218
178
  case 'chrome':
219
- case 'legend':
179
+ this.#preset(name, value);
220
180
  this.#applyChrome();
221
181
  break;
222
- case 'loop':
223
- case 'loop-delay':
224
- this.controller.setLoop(this.loop ? this.loopDelay : null);
225
- this.#applyHold();
226
- break;
227
- case 'pause-on-hover':
228
- this.#applyHold();
229
- break;
230
- default:
231
- break;
232
- }
233
- }
234
-
235
- /**
236
- * Apply a preference from an attribute or a property. Valid values are explicit (a report's
237
- * `view` does not override them); invalid ones fall back to the default (`overlay`, `all`,
238
- * `auto`, `default`, the browser's language). Properties reflect the normalized value; the
239
- * legacy mode `changes` reflects as `overlay` + scope `changes`.
240
- */
241
- #applyPreference(
242
- name: 'mode' | 'scope' | 'theme' | 'palette' | 'locale',
243
- raw: unknown,
244
- reflect: boolean,
245
- ): void {
246
- const c = this.controller;
247
- // `mode="changes"` is rewritten to the attribute pair it stands for.
248
- const legacy = name === 'mode' && raw === LEGACY_CHANGES_MODE;
249
- let value: string | null;
250
- switch (name) {
251
- case 'mode': {
252
- if (legacy) {
253
- c.explicit.add('scope');
254
- c.setView('overlay', 'changes');
255
- value = 'overlay';
256
- break;
257
- }
258
- const v = toMode(raw);
259
- c.setMode(v ?? 'overlay');
260
- value = v;
261
- break;
262
- }
263
- case 'scope': {
264
- const v = toScope(raw);
265
- c.setScope(v ?? 'all');
266
- // A locked scope stays `changes`: the attribute says what is shown.
267
- value = v === null ? null : c.scope;
182
+ case 'legend':
183
+ this.#applyChrome();
268
184
  break;
269
- }
270
- case 'theme': {
271
- const v = toTheme(raw);
272
- c.setThemePreference(v ?? 'auto');
273
- value = v;
185
+ case 'panels': {
186
+ const { panels, invalid } = parsePanels(value ?? '');
187
+ warnPanels(invalid);
188
+ this.#setHostPanels(panels);
274
189
  break;
275
190
  }
276
- case 'palette': {
277
- const v = toPalette(raw);
278
- c.setPalette(v ?? 'default');
279
- value = v;
191
+ case 'persist':
192
+ case 'persist-key':
193
+ // Turned on, or another store, in the page: what the user chose before there applies
194
+ // now, as a host change.
195
+ if (this.#layoutReady && this.persist === 'local') this.#remembered = this.#recall();
196
+ this.#resolve('host');
280
197
  break;
281
- }
282
- case 'locale': {
283
- const v = toLocale(raw);
284
- c.setLocale(v ?? detectLocale());
285
- value = v;
286
- break;
287
- }
288
198
  default:
289
- return;
199
+ break;
290
200
  }
291
- if (value === null) c.explicit.delete(name);
292
- else c.explicit.add(name);
293
- if (reflect || legacy) this.#reflect(name, value);
294
- if (legacy) this.#reflect('scope', c.scope);
295
201
  }
296
202
 
297
- // ─── Properties ───────────────────────────────────────────────────────────
298
-
299
- get mode(): ViewerMode {
300
- return this.controller.mode;
203
+ protected override upgradedProperties(): readonly string[] {
204
+ return ['chrome', 'legend', 'panels', 'persist', 'persistKey'];
301
205
  }
302
206
 
303
- set mode(value: ViewerMode | typeof LEGACY_CHANGES_MODE) {
304
- this.#applyPreference('mode', value, true);
207
+ protected sheets(doc: Document): readonly CSSStyleSheet[] {
208
+ return [stylesheet(doc)];
305
209
  }
306
210
 
307
- get scope(): ViewerScope {
308
- return this.controller.scope;
211
+ protected createSession(host: SessionHost, deps: SessionDeps): ViewerController {
212
+ return new ViewerController(host, deps);
309
213
  }
310
214
 
311
- set scope(value: ViewerScope) {
312
- this.#applyPreference('scope', value, true);
215
+ protected override keysEnabled(): boolean {
216
+ return this.#ui !== null && this.chrome !== 'none';
313
217
  }
314
218
 
315
- get theme(): ThemePreference {
316
- return this.controller.themePreference;
317
- }
318
-
319
- set theme(value: ThemePreference) {
320
- this.#applyPreference('theme', value, true);
321
- }
322
-
323
- get palette(): PaletteName {
324
- return this.controller.palette;
219
+ protected override interceptKey(e: KeyboardEvent): boolean {
220
+ const ui = this.#ui;
221
+ if (!ui || !(ui.help.open || ui.menu.open)) return false;
222
+ if (e.key === 'Escape') {
223
+ e.preventDefault();
224
+ ui.help.hide();
225
+ this.#toggleMenu(false);
226
+ }
227
+ return true;
325
228
  }
326
229
 
327
- set palette(value: PaletteName) {
328
- this.#applyPreference('palette', value, true);
230
+ protected override payloadSet(payload: ReportPayload): void {
231
+ // Validated (and warned about) once more when it loads.
232
+ this.#report = sanitizeView(payload.view, true)?.panels ?? {};
233
+ this.#resolve('host');
329
234
  }
330
235
 
331
- get locale(): Locale {
332
- return this.controller.locale;
236
+ override load(options: LoadOptions): Promise<void> {
237
+ this.#report = {};
238
+ this.#resolve('host');
239
+ return super.load(options);
333
240
  }
334
241
 
335
- set locale(value: Locale) {
336
- this.#applyPreference('locale', value, true);
337
- }
242
+ // ─── Properties ───────────────────────────────────────────────────────────
338
243
 
339
244
  get chrome(): ViewerChrome {
340
245
  return toChrome(this.getAttribute('chrome')) ?? 'full';
341
246
  }
342
247
 
343
248
  set chrome(value: ViewerChrome) {
344
- this.#reflect('chrome', toChrome(value) === 'none' ? 'none' : null);
249
+ const v = toChrome(value) === 'none' ? 'none' : null;
250
+ this.reflect('chrome', v);
251
+ this.#preset('chrome', v);
345
252
  this.#applyChrome();
346
253
  }
347
254
 
@@ -351,134 +258,199 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
351
258
 
352
259
  set legend(value: ViewerLegend) {
353
260
  const v = toLegend(value);
354
- this.#reflect('legend', v === 'auto' ? null : v);
261
+ this.reflect('legend', v === 'auto' ? null : v);
355
262
  this.#applyChrome();
356
263
  }
357
264
 
358
- get loop(): boolean {
359
- return this.hasAttribute('loop');
265
+ /** The host's declaration (the `panels` attribute as an object); `layout` has the states. */
266
+ get panels(): ViewerPanels {
267
+ return { ...this.#hostPanels };
360
268
  }
361
269
 
362
- set loop(value: boolean) {
363
- this.toggleAttribute('loop', Boolean(value));
270
+ set panels(value: ViewerPanels | string | null) {
271
+ const { panels, invalid } = toPanels(value);
272
+ warnPanels(invalid);
273
+ this.reflect('panels', formatPanels(panels) || null);
274
+ this.#setHostPanels(panels);
364
275
  }
365
276
 
366
- get loopDelay(): number {
367
- return toDelay(this.getAttribute('loop-delay')) ?? DEFAULT_LOOP_DELAY;
277
+ get persist(): 'local' | 'none' {
278
+ return this.getAttribute('persist') === 'none' ? 'none' : 'local';
368
279
  }
369
280
 
370
- set loopDelay(value: number) {
371
- const v = toDelay(value);
372
- if (v === null) this.removeAttribute('loop-delay');
373
- else this.setAttribute('loop-delay', String(v));
281
+ set persist(value: 'local' | 'none') {
282
+ if (value === 'none') this.setAttribute('persist', 'none');
283
+ else this.removeAttribute('persist');
374
284
  }
375
285
 
376
- get pauseOnHover(): boolean {
377
- return this.getAttribute('pause-on-hover') !== 'false';
286
+ /** Where the layout remembers (`persist-key`): a `localStorage` key, `kgdiff:viewer` by default. */
287
+ get persistKey(): string {
288
+ return this.getAttribute('persist-key') || LAYOUT_KEY;
378
289
  }
379
290
 
380
- set pauseOnHover(value: boolean) {
381
- if (value) this.removeAttribute('pause-on-hover');
382
- else this.setAttribute('pause-on-hover', 'false');
291
+ set persistKey(value: string) {
292
+ if (!value || value === LAYOUT_KEY) this.removeAttribute('persist-key');
293
+ else this.setAttribute('persist-key', String(value));
383
294
  }
384
295
 
385
- get payload(): ReportPayload | null {
386
- return this.#currentPayload;
296
+ /** Every region's state and the sizes now, as `kgdiff-layout` reports them. */
297
+ get layout(): ViewerLayout {
298
+ const sidebar = this.#ui?.sidebar;
299
+ return {
300
+ panels: { ...this.#states },
301
+ sidebarWidth: sidebar?.width ?? SIDEBAR_WIDTH,
302
+ detailShare: sidebar?.detailShare ?? null,
303
+ };
387
304
  }
388
305
 
389
- set payload(value: ReportPayload | null) {
390
- this.#currentPayload = value;
391
- if (value === null || value === undefined) return;
392
- let payload: ReportPayload;
393
- try {
394
- payload = assertPayload(value);
395
- } catch (error) {
396
- this.controller.fail(error as Error);
397
- return;
398
- }
399
- this.controller.loadPayload(payload).catch(() => {
400
- // Already surfaced: error state + `kgdiff-error`.
401
- });
402
- }
306
+ // ─── Panels ───────────────────────────────────────────────────────────────
403
307
 
404
- // ─── Methods ──────────────────────────────────────────────────────────────
308
+ /** Some region's state may have changed: apply the new states, and report them. */
309
+ #resolve(source: 'user' | 'host'): void {
310
+ const remembered = this.persist === 'local' ? this.#remembered.panels : {};
311
+ const next = resolvePanels([
312
+ this.#runtime,
313
+ this.#hostPanels,
314
+ this.#presets,
315
+ remembered,
316
+ this.#report,
317
+ ]);
318
+ if (samePanels(next, this.#states)) return;
319
+ this.#states = next;
320
+ this.#applyPanels();
321
+ if (this.#layoutReady) this.#emitLayout(source);
322
+ }
405
323
 
406
- load(options: LoadOptions): Promise<void> {
407
- this.#currentPayload = null;
408
- return this.controller.load(options);
324
+ #emitLayout(source: 'user' | 'host'): void {
325
+ this.dispatchEvent(
326
+ new CustomEvent('kgdiff-layout', {
327
+ detail: { ...this.layout, source },
328
+ bubbles: true,
329
+ composed: true,
330
+ }),
331
+ );
409
332
  }
410
333
 
411
- selectHunk(id: string | null): void {
412
- this.controller.selectHunkById(id, { fly: true });
334
+ /**
335
+ * The host declared `panels`. Where its value changed, what the user changed there in this
336
+ * page gives way; declaring the same again (a framework re-rendering, an attribute set to
337
+ * the value it has) leaves the user's change alone.
338
+ */
339
+ #setHostPanels(panels: ViewerPanels): void {
340
+ this.#yield(this.#hostPanels, panels);
341
+ this.#hostPanels = panels;
342
+ this.#resolve('host');
413
343
  }
414
344
 
415
- next(): void {
416
- this.controller.next();
345
+ /**
346
+ * A preset attribute changed. `commanded` (`sidebar`, which the layout reflects): the
347
+ * sidebar's regions yield even when the preset the page set last is the same.
348
+ */
349
+ #preset(name: Preset, value: string | null, commanded = false): void {
350
+ this.#presetAttrs[name] = value;
351
+ const { sidebar, header, chrome } = this.#presetAttrs;
352
+ const next = presetPanels(sidebar, header, chrome);
353
+ const yielded = this.#yield(this.#presets, next);
354
+ if (commanded) {
355
+ for (const r of SIDEBAR_REGIONS) delete this.#runtime[r];
356
+ }
357
+ if (commanded || yielded.some((r) => SIDEBAR_REGIONS.includes(r))) this.#folded = null;
358
+ this.#presets = next;
359
+ this.#resolve('host');
417
360
  }
418
361
 
419
- prev(): void {
420
- this.controller.prev();
362
+ /** Drop the user's in-page change of every region `before` and `after` disagree on. */
363
+ #yield(before: ViewerPanels, after: ViewerPanels): PanelRegion[] {
364
+ const changed = PANEL_REGIONS.filter((r) => before[r] !== after[r]);
365
+ for (const r of changed) delete this.#runtime[r];
366
+ return changed;
421
367
  }
422
368
 
423
- play(): void {
424
- this.controller.play();
369
+ /** The user changed some regions: they hold in this page, and are remembered. */
370
+ #userPanels(changes: ViewerPanels): void {
371
+ Object.assign(this.#runtime, changes);
372
+ this.#remember({ panels: { ...this.#remembered.panels, ...changes } });
373
+ this.#resolve('user');
425
374
  }
426
375
 
427
- pause(): void {
428
- this.controller.pause();
376
+ /** What the user chose before; theme, palette and language apply unless the host set them. */
377
+ #recall(): Remembered {
378
+ const r = readRemembered(this.#storage, this.persistKey);
379
+ // A preference the host set wins; one the user chose before wins over a report's view.
380
+ const c = this.controller;
381
+ if (r.theme && !this.hasAttribute('theme') && !c.explicit.has('theme')) {
382
+ c.setThemePreference(r.theme);
383
+ c.explicit.add('theme');
384
+ }
385
+ if (r.palette && !this.hasAttribute('palette') && !c.explicit.has('palette')) {
386
+ c.setPalette(r.palette);
387
+ c.explicit.add('palette');
388
+ }
389
+ if (r.locale && !this.hasAttribute('locale') && !c.explicit.has('locale')) {
390
+ c.setLocale(r.locale);
391
+ c.explicit.add('locale');
392
+ }
393
+ this.#ui?.sidebar.setSizes(r.sidebarWidth ?? SIDEBAR_WIDTH, r.detailShare ?? null, true);
394
+ return r;
429
395
  }
430
396
 
431
- fit(options?: FitOptions): Promise<void> {
432
- return this.controller.fit(options);
397
+ /** The first connection: the remembered states are the first ones (not a change, no event). */
398
+ #loadRemembered(): void {
399
+ if (this.persist !== 'local') return;
400
+ this.#remembered = this.#recall();
401
+ this.#resolve('host');
433
402
  }
434
403
 
435
- exportDelta(): Delta {
436
- const diff = this.controller.diff;
437
- if (!diff) throw new KgdiffError('invalid-input', translate('en', 'error.notLoaded'));
438
- // A copy: callers can never mutate the diff the viewer shows.
439
- return deepFreeze(structuredClone(diff.delta));
404
+ /** Remember the user's choice (merged into what other viewers of this origin wrote). */
405
+ #remember(patch: Partial<Remembered>): void {
406
+ if (this.persist !== 'local') return;
407
+ const stored = readRemembered(this.#storage, this.persistKey);
408
+ const next: Remembered = {
409
+ ...stored,
410
+ ...patch,
411
+ panels: { ...stored.panels, ...(patch.panels ?? {}) },
412
+ };
413
+ this.#remembered = { ...this.#remembered, ...patch, panels: next.panels };
414
+ writeRemembered(this.#storage, next, this.persistKey);
440
415
  }
441
416
 
442
- reviewState(): ReviewState {
443
- return this.controller.reviewState();
417
+ /** The user resized the sidebar or moved the split (once the drag or key press ended). */
418
+ #resized(width: number, detailShare: number | null): void {
419
+ this.#remember({ sidebarWidth: width, detailShare });
420
+ this.#emitLayout('user');
444
421
  }
445
422
 
446
- setReviewed(hunkId: string, reviewed: boolean): void {
447
- this.controller.setReviewed(hunkId, reviewed);
423
+ #applyPanels(): void {
424
+ const ui = this.#ui;
425
+ if (!ui) return;
426
+ const s = this.#states;
427
+ ui.sidebar.setPanels(s);
428
+ ui.stage.setPanels(s);
429
+ const sidebar = SIDEBAR_REGIONS.some((r) => s[r] !== 'hidden');
430
+ // A `<kgdiff-title>` or `<kgdiff-actions>` slotted into the viewer replaces the HUD's.
431
+ const title = ui.stage.replaces('title') ? 'hidden' : s.title;
432
+ const actions = ui.stage.replaces('actions') ? 'hidden' : s.actions;
433
+ ui.header.setPanels(title, actions, sidebar, () => this.#canvas());
434
+ ui.header.setSidebarState(this.#sidebarOpen);
435
+ // The top corners sit under what the HUD shows there.
436
+ const hud = [title === 'open' ? 'title' : '', actions === 'open' ? 'actions' : ''];
437
+ ui.stage.el.dataset.hud = hud.filter(Boolean).join(' ');
448
438
  }
449
439
 
450
- /** Show an error state (used for undecodable embedded payloads). */
451
- showError(error: Error): void {
452
- this.controller.fail(error);
440
+ #canvas(): HTMLElement | null {
441
+ return this.#ui?.stage.canvasHost.querySelector('canvas') ?? null;
453
442
  }
454
443
 
455
444
  // ─── Internals ────────────────────────────────────────────────────────────
456
445
 
457
- #reflect(name: string, value: string | null): void {
458
- if (this.getAttribute(name) === value) return;
459
- this.#reflecting = true;
460
- try {
461
- if (value === null) this.removeAttribute(name);
462
- else this.setAttribute(name, value);
463
- } finally {
464
- this.#reflecting = false;
465
- }
466
- }
467
-
446
+ /** The sidebar is open while one of its regions is. */
468
447
  get #sidebarOpen(): boolean {
469
- return this.getAttribute('sidebar') !== 'closed';
470
- }
471
-
472
- #applySidebar(): void {
473
- const ui = this.#ui;
474
- if (!ui) return;
475
- ui.sidebar.setOpen(this.#sidebarOpen);
476
- ui.header.setSidebarState(this.#sidebarOpen);
448
+ return SIDEBAR_REGIONS.some((r) => this.#states[r] === 'open');
477
449
  }
478
450
 
479
451
  /**
480
452
  * `chrome` and `legend`: what the stage shows, whether clicks select, whether the view stays
481
- * fitted. The CSS hides the rest (`.root[data-chrome="none"]`).
453
+ * fitted. Which regions show is the `chrome="none"` preset's (see `panels.ts`).
482
454
  */
483
455
  #applyChrome(): void {
484
456
  const bare = this.chrome === 'none';
@@ -491,7 +463,7 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
491
463
  ui.root.dataset.chrome = this.chrome;
492
464
  const legend = this.legend;
493
465
  ui.stage.setChrome(legend === 'auto' ? (bare ? 'mini' : 'full') : legend, !bare);
494
- const canvas = ui.stage.canvasHost.querySelector('canvas');
466
+ const canvas = this.#canvas();
495
467
  if (canvas) canvas.tabIndex = bare ? -1 : 0;
496
468
  if (bare) {
497
469
  ui.help.hide();
@@ -499,52 +471,67 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
499
471
  }
500
472
  }
501
473
 
502
- /** Hold a looping replay while the pointer is over the graph (`pause-on-hover`). */
503
- #applyHold(): void {
504
- this.controller.hold(this.#pointerOver && this.loop && this.pauseOnHover);
505
- }
506
-
507
474
  /** What the layout does for the regions: preferences reflect as attributes, panels open. */
508
- #actions(): UiActions {
475
+ protected actions(): UiActions {
509
476
  return {
510
- tr: () => translator(this.controller.locale),
511
- setMode: (mode) => this.controller.setMode(mode),
512
- toggleScope: () => this.controller.toggleScope(),
477
+ ...this.baseActions(),
513
478
  setThemePreference: (pref) => {
514
479
  this.theme = pref;
480
+ this.#remember({ theme: this.theme });
515
481
  },
516
482
  setPalette: (p) => {
517
483
  this.palette = p;
484
+ this.#remember({ palette: this.palette });
518
485
  },
519
486
  setLocale: (l) => {
520
487
  this.locale = l;
488
+ this.#remember({ locale: this.locale });
521
489
  },
522
490
  toggleSidebar: (open) => this.#toggleSidebar(open),
523
491
  sidebarOpen: () => this.#sidebarOpen,
492
+ hasSidebar: () => SIDEBAR_REGIONS.some((r) => this.#states[r] !== 'hidden'),
524
493
  toggleMenu: (open) => this.#toggleMenu(open),
525
494
  openHelp: () => this.#ui?.help.show(),
526
495
  focusSearch: () => this.#focusSearch(),
496
+ setPanel: (region, state) => this.#userPanels({ [region]: state }),
527
497
  };
528
498
  }
529
499
 
530
500
  #build(): void {
531
501
  const root = h('div', { class: 'root' });
532
- this.#shadow.append(root);
502
+ this.shadow.append(root);
533
503
  // Each region binds to the root on its own (context protocol); the layout only places them.
534
504
  const header = bindRegion(root, (ui) => new Header(ui));
535
505
  const list = bindRegion(root, (ui) => new ChangeList(ui));
536
506
  const search = bindRegion(root, (ui) => new Search(ui));
537
507
  const filters = bindRegion(root, (ui) => new Filters(ui));
538
508
  const detail = bindRegion(root, (ui) => new Detail(ui));
539
- const stage = bindRegion(root, (ui) => new Stage(ui));
540
- const ui = uiContext(this.#root, this.#shadow);
509
+ // The stage's slots are the viewer's: the page's content goes over the canvas.
510
+ const stage = bindRegion(root, (ui) => new Stage(ui, true));
511
+ stage.view = this.controller.view;
512
+ stage.onReplaced = () => this.#applyPanels();
513
+ this.#provideStage(stage);
514
+ const ui = uiContext(this.root, this.shadow);
541
515
  const menu = new Menu(ui);
542
516
  const help = new HelpDialog(ui);
543
- const sidebar = new Sidebar(ui.tr, list, search, filters, detail);
517
+ const sidebar = new Sidebar(
518
+ ui,
519
+ {
520
+ setPanel: (region, state) => this.#userPanels({ [region]: state }),
521
+ resized: (width, share) => this.#resized(width, share),
522
+ focusOutside: () => (isOpen(header.sidebarButton) ? header.sidebarButton : this.#canvas()),
523
+ },
524
+ list,
525
+ search,
526
+ filters,
527
+ detail,
528
+ );
544
529
  // The title and the actions float over the canvas; the menu drops from the actions.
545
530
  header.attachMenu(menu.el);
546
531
  stage.el.appendChild(header.el);
547
- root.append(h('div', { class: 'body' }, stage.el, sidebar.el), help.el, this.#root.live);
532
+ root.append(h('div', { class: 'body' }, stage.el, sidebar.el), help.el, this.root.live);
533
+ // Content slotted before the layout was built.
534
+ stage.slotted();
548
535
  this.#ui = { root, header, stage, list, sidebar, menu, help };
549
536
  // Close the menu on outside clicks.
550
537
  root.addEventListener('pointerdown', (e) => {
@@ -554,29 +541,39 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
554
541
  });
555
542
  const hover = (over: boolean) => (e: PointerEvent) => {
556
543
  if (e.pointerType === 'touch') return;
557
- this.#pointerOver = over;
558
- this.#applyHold();
544
+ this.pointerOver(over);
559
545
  };
560
546
  stage.el.addEventListener('pointerenter', hover(true));
561
547
  stage.el.addEventListener('pointerleave', hover(false));
562
- this.#applySidebar();
548
+ sidebar.setSizes(SIDEBAR_WIDTH, null, true);
549
+ this.#applyPanels();
563
550
  this.#applyChrome();
564
551
  }
565
552
 
553
+ /**
554
+ * The viewer is the stage of the chrome that acts on a view (`<kgdiff-zoom>`,
555
+ * `<kgdiff-minimap>`) inside it or naming it with `stage`: its built-in stage's view.
556
+ */
557
+ #provideStage(stage: Stage): void {
558
+ const view = this.controller.view;
559
+ const ctx: StageViewContext = {
560
+ stage: this,
561
+ root: this.root,
562
+ view,
563
+ registerObstacle: (el) => stage.registerObstacle(el),
564
+ watchView: (listener) => {
565
+ listener(view);
566
+ return () => undefined;
567
+ },
568
+ };
569
+ provideContext(this, stageContext, () => ctx);
570
+ }
571
+
566
572
  /** The layout's part of a render (the root renders the regions after it). */
567
573
  #update(topics: ReadonlySet<Topic>): void {
568
574
  const ui = this.#ui;
569
575
  const c = this.controller;
570
- if (topics.has('mode')) {
571
- this.#reflect('mode', c.mode);
572
- this.#reflect('scope', c.scope);
573
- }
574
- if (topics.has('theme')) {
575
- this.#reflect(
576
- 'theme',
577
- c.themePreference === 'auto' && !this.hasAttribute('theme') ? null : c.themePreference,
578
- );
579
- }
576
+ this.reflectSession(topics);
580
577
  if (!ui) return;
581
578
  if (topics.has('theme') || topics.has('status')) ui.root.dataset.theme = c.themeName;
582
579
  if (topics.has('locale') || topics.has('status')) {
@@ -592,10 +589,27 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
592
589
  }
593
590
  }
594
591
 
592
+ /**
593
+ * Fold the sidebar (its open regions collapse) or open it again (the regions that were open,
594
+ * or all four). Reflected to the `sidebar` attribute.
595
+ */
595
596
  #toggleSidebar(open?: boolean): void {
597
+ const s = this.#states;
598
+ const shown = SIDEBAR_REGIONS.filter((r) => s[r] !== 'hidden');
599
+ if (shown.length === 0) return;
596
600
  const next = open ?? !this.#sidebarOpen;
597
- this.#reflect('sidebar', next ? 'open' : 'closed');
598
- this.#applySidebar();
601
+ this.reflect('sidebar', next ? 'open' : 'closed');
602
+ if (next === this.#sidebarOpen) return;
603
+ const changes: MutablePanels = {};
604
+ if (next) {
605
+ const restore = this.#folded?.filter((r) => shown.includes(r)) ?? [];
606
+ for (const r of restore.length > 0 ? restore : shown) changes[r] = 'open';
607
+ this.#folded = null;
608
+ } else {
609
+ this.#folded = shown.filter((r) => s[r] === 'open');
610
+ for (const r of shown) changes[r] = 'collapsed';
611
+ }
612
+ this.#userPanels(changes);
599
613
  }
600
614
 
601
615
  #toggleMenu(open?: boolean): void {
@@ -607,9 +621,13 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
607
621
  ui.header.setMenuState(next);
608
622
  }
609
623
 
624
+ /** `/`: an open search takes focus; else this layout's search opens, unless it is hidden. */
610
625
  #focusSearch(): void {
626
+ if (this.root.focus('search')) return;
627
+ if (this.#states.search === 'hidden') return;
611
628
  if (!this.#sidebarOpen) this.#toggleSidebar(true);
612
- this.#root.focus('search');
629
+ if (this.#states.search === 'collapsed') this.#userPanels({ search: 'open' });
630
+ this.root.focus('search');
613
631
  }
614
632
 
615
633
  #watchSize(): void {
@@ -626,7 +644,7 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
626
644
  }
627
645
  ui.stage.layout();
628
646
  ui.list.renderWindow();
629
- if (compact) ui.sidebar.syncSheet();
647
+ ui.sidebar.layout(compact);
630
648
  this.controller.layoutChanged();
631
649
  });
632
650
  ro.observe(this);
@@ -634,64 +652,4 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
634
652
  if (this.#ui) ro.observe(this.#ui.stage.el);
635
653
  this.#cleanup.push(() => ro.disconnect());
636
654
  }
637
-
638
- #onKeyDown(e: KeyboardEvent): void {
639
- if (e.defaultPrevented || e.isComposing) return;
640
- const ui = this.#ui;
641
- if (!ui || this.chrome === 'none') return;
642
- const path = e.composedPath();
643
- if (!path.includes(this)) {
644
- // Outside the component only with `global-shortcuts`, when nothing else has focus, for
645
- // the first such viewer, and never the keys pages scroll with.
646
- if (!this.hasAttribute('global-shortcuts') || PAGE_KEYS.has(e.key)) return;
647
- const active = document.activeElement;
648
- if (active && active !== document.body && active !== document.documentElement) return;
649
- if (connected.find((v) => v.hasAttribute('global-shortcuts')) !== this) return;
650
- }
651
- if (ui.help.open || ui.menu.open) {
652
- if (e.key === 'Escape') {
653
- e.preventDefault();
654
- ui.help.hide();
655
- this.#toggleMenu(false);
656
- }
657
- return;
658
- }
659
- this.#root.handleKey(e);
660
- }
661
-
662
- // ─── Deep links ───────────────────────────────────────────────────────────
663
-
664
- #readHash(): void {
665
- if (!this.hasAttribute('sync-hash') || typeof location === 'undefined') return;
666
- if (location.hash === this.#lastHash) return;
667
- const state = parseHash(location.hash);
668
- this.#lastHash = location.hash;
669
- if (!state) return;
670
- if (state.mode || state.scope) {
671
- // A deep link outranks the large-graph default and a report's preferred view.
672
- const c = this.controller;
673
- if (state.mode) c.explicit.add('mode');
674
- if (state.scope) c.explicit.add('scope');
675
- c.setView(state.mode ?? c.mode, state.scope ?? c.scope);
676
- }
677
- if (state.hunk) this.controller.selectHunkById(state.hunk, { fly: true });
678
- }
679
-
680
- #writeHash(): void {
681
- if (!this.hasAttribute('sync-hash') || typeof location === 'undefined') return;
682
- const c = this.controller;
683
- const hunk = c.selectedHunk >= 0 ? c.scene?.hunks[c.selectedHunk]?.id : undefined;
684
- const hash = formatHash({
685
- ...(hunk !== undefined ? { hunk } : {}),
686
- mode: c.mode,
687
- scope: c.scope,
688
- });
689
- if (hash === location.hash) return;
690
- this.#lastHash = hash;
691
- try {
692
- history.replaceState(history.state, '', hash);
693
- } catch {
694
- location.hash = hash;
695
- }
696
- }
697
655
  }