@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/dist/element.js CHANGED
@@ -1,169 +1,116 @@
1
1
  /**
2
- * `<kgdiff-viewer>`: attribute / property reflection, lifecycle, public methods and events
3
- * around a `ViewerController` and the Shadow-DOM UI.
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.
4
13
  */
5
- import { KgdiffError } from '@kgdiff/core';
14
+ import { provideContext } from './context.js';
6
15
  import { ViewerController } from './controller.js';
7
- import { h } from './dom.js';
8
- import { formatHash, parseHash } from './hash-sync.js';
9
- import { detectLocale, translate, translator } from './i18n.js';
10
- import { isTypingTarget, keyAction } from './keyboard.js';
11
- import { assertPayload } from './payload.js';
12
- import { DEFAULT_LOOP_DELAY, LEGACY_CHANGES_MODE, THEME_PREFERENCES, toChrome, toDelay, toLegend, toLocale, toMode, toPalette, toScope, toTheme, } from './prefs.js';
16
+ import { h, isOpen } from './dom.js';
17
+ import { stageContext } from './elements/stage-context.js';
18
+ import { LAYOUT_KEY, readRemembered, writeRemembered } from './layout-store.js';
19
+ import { presetPanels, resolvePanels, SIDEBAR_REGIONS, samePanels } from './panel-levels.js';
20
+ import { DEFAULT_PANELS, formatPanels, PANEL_REGIONS, parsePanels, toPanels } from './panels.js';
21
+ import { sanitizeView, toChrome, toLegend } from './prefs.js';
13
22
  import { defaultStorage } from './review-store.js';
14
- import { STYLES, themeVariables } from './styles.js';
23
+ import { bindRegion, uiContext } from './root.js';
24
+ import { ROOT_ATTRIBUTES, RootElement } from './root-element.js';
25
+ import { stylesheet } from './styles.js';
26
+ import { ChangeList } from './ui/change-list.js';
27
+ import { Detail } from './ui/detail.js';
28
+ import { Filters } from './ui/filters.js';
15
29
  import { Header } from './ui/header.js';
16
30
  import { HelpDialog, Menu } from './ui/popovers.js';
17
- import { Sidebar } from './ui/sidebar.js';
31
+ import { Search } from './ui/search.js';
32
+ import { SIDEBAR_WIDTH, Sidebar } from './ui/sidebar.js';
18
33
  import { Stage } from './ui/stage.js';
19
34
  /** Below this width the sidebar becomes a bottom sheet. */
20
35
  export const COMPACT_WIDTH = 760;
21
36
  /** Below this width the title drops the version tags. */
22
37
  const NARROW_WIDTH = 1100;
23
38
  const OBSERVED = [
24
- 'mode',
25
- 'scope',
26
- 'theme',
27
- 'palette',
28
- 'locale',
39
+ ...ROOT_ATTRIBUTES,
29
40
  'sidebar',
30
- 'sync-hash',
41
+ 'header',
31
42
  'chrome',
32
43
  'legend',
33
- 'loop',
34
- 'loop-delay',
35
- 'pause-on-hover',
44
+ 'panels',
45
+ 'persist',
46
+ 'persist-key',
36
47
  ];
37
- const UPGRADED = [
38
- 'mode',
39
- 'scope',
40
- 'theme',
41
- 'palette',
42
- 'locale',
43
- 'chrome',
44
- 'legend',
45
- 'loop',
46
- 'loopDelay',
47
- 'pauseOnHover',
48
- 'payload',
49
- ];
50
- /** Keys the page itself scrolls with: never taken at document level. */
51
- const PAGE_KEYS = new Set([
52
- 'ArrowUp',
53
- 'ArrowDown',
54
- 'ArrowLeft',
55
- 'ArrowRight',
56
- 'Home',
57
- 'End',
58
- 'PageUp',
59
- 'PageDown',
60
- ' ',
61
- 'Spacebar',
62
- ]);
63
- /** Connected viewers, in connection order (`global-shortcuts` goes to the first opted-in one). */
64
- const connected = [];
65
- function deepFreeze(value) {
66
- if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) {
67
- for (const v of Object.values(value))
68
- deepFreeze(v);
69
- Object.freeze(value);
70
- }
71
- return value;
72
- }
73
- function media(query) {
74
- return typeof window !== 'undefined' && typeof window.matchMedia === 'function'
75
- ? window.matchMedia(query)
76
- : null;
48
+ function warnPanels(invalid) {
49
+ if (invalid.length > 0)
50
+ console.warn(`[kgdiff] ignoring invalid panels: ${invalid.join(' ')}`);
77
51
  }
78
52
  /**
79
- * Resolve once the browser had a chance to paint the loading step. Hidden tabs never run
80
- * animation frames, so a short timer resolves it there (loading must never stall).
53
+ * @tagname kgdiff-viewer
54
+ * @attr {'open' | 'closed'} sidebar - A preset: `closed` starts with the sidebar folded (`\` and the
55
+ * sidebar button open it), `open` with its four regions open. Reflects the sidebar's state
56
+ * once the user toggles it.
57
+ * @attr {'compact'} header - A preset: `compact` leaves the brand and the compared names out of
58
+ * the canvas, for hosts that show their own (`panels="title:hidden"`).
59
+ * @slot top-start - The page's content over the canvas, top left (under the title while it shows).
60
+ * @slot top-end - The page's content over the canvas, top right (under the actions while they show).
61
+ * @slot bottom-start - The page's content over the canvas, bottom left, above the legend.
62
+ * @slot bottom-center - The page's content above the view dock and the transport.
63
+ * @slot bottom-end - The page's content over the canvas, bottom right, above the zoom buttons and the minimap.
81
64
  */
82
- function nextPaint() {
83
- return new Promise((resolve) => {
84
- let done = false;
85
- const finish = () => {
86
- if (done)
87
- return;
88
- done = true;
89
- resolve();
90
- };
91
- if (typeof requestAnimationFrame === 'function') {
92
- requestAnimationFrame(() => setTimeout(finish, 0));
93
- }
94
- setTimeout(finish, 50);
95
- });
96
- }
97
- export class KgdiffViewer extends HTMLElement {
65
+ export class KgdiffViewer extends RootElement {
98
66
  static get observedAttributes() {
99
67
  return OBSERVED;
100
68
  }
101
- /** @internal */
102
- controller;
103
- #shadow;
104
69
  #ui = null;
105
- #currentPayload = null;
106
- #renderFailed = false;
107
- #reflecting = false;
108
70
  #compact = false;
109
71
  #cleanup = [];
110
- #lastHash = '';
111
- #upgraded = false;
112
- /** A mouse or pen rests on the graph (holds a looping replay with `pause-on-hover`). */
113
- #pointerOver = false;
72
+ // Panel states, from the first level that names a region (see `panels.ts`).
73
+ /** What the user changed in this page. */
74
+ #runtime = {};
75
+ /** The host's `panels`. */
76
+ #hostPanels = {};
77
+ /** The presets, from the attributes as the host set them (not as the layout reflects them). */
78
+ #presetAttrs = {
79
+ sidebar: null,
80
+ header: null,
81
+ chrome: null,
82
+ };
83
+ #presets = {};
84
+ /** What the user chose before (`persist`), read at the first connection. */
85
+ #remembered = { panels: {} };
86
+ /** The report's `view.panels`. */
87
+ #report = {};
88
+ #states = DEFAULT_PANELS;
89
+ /** The sidebar's regions that were open when the user folded it. */
90
+ #folded = null;
91
+ /** `kgdiff-layout` reports changes from the first connection on. */
92
+ #layoutReady = false;
93
+ #storage = defaultStorage();
114
94
  constructor() {
115
95
  super();
116
- this.#shadow = this.attachShadow({ mode: 'open' });
117
- const host = {
118
- ready: (stats) => this.#emit('kgdiff-ready', { stats }),
119
- select: (hunk, node) => {
120
- this.#emit('kgdiff-select', { hunk, node });
121
- this.#writeHash();
122
- },
123
- mode: (mode, scope) => {
124
- this.#emit('kgdiff-mode', { mode, scope });
125
- this.#writeHash();
126
- },
127
- review: (hunk, reviewed, progress) => this.#emit('kgdiff-review', { hunk, reviewed, progress }),
128
- error: (error) => this.#emit('kgdiff-error', { error }),
129
- announce: (text) => this.#announce(text),
130
- replayEnd: (loop) => this.#emit('kgdiff-replay-end', { loop }),
131
- };
132
- this.controller = new ViewerController(host, {
133
- now: () => (typeof performance !== 'undefined' ? performance.now() : Date.now()),
134
- requestFrame: (cb) => typeof requestAnimationFrame === 'function' ? requestAnimationFrame(cb) : 0,
135
- cancelFrame: (id) => {
136
- if (typeof cancelAnimationFrame === 'function')
137
- cancelAnimationFrame(id);
138
- },
139
- yieldToPaint: nextPaint,
140
- storage: defaultStorage(),
141
- });
142
- this.controller.locale = toLocale(this.getAttribute('locale')) ?? detectLocale();
143
- this.controller.subscribe((topics) => this.#render(topics));
96
+ // The layout renders first, then the regions in the order they bound to the root.
97
+ this.root.attach({ update: (topics) => this.#update(topics) });
144
98
  }
145
99
  // ─── Lifecycle ────────────────────────────────────────────────────────────
146
- connectedCallback() {
147
- if (!this.#upgraded) {
148
- this.#upgraded = true;
149
- // Properties set before the element was defined shadow the accessors: re-apply them.
150
- for (const prop of UPGRADED) {
151
- if (Object.hasOwn(this, prop)) {
152
- const value = this[prop];
153
- delete this[prop];
154
- this[prop] = value;
155
- }
156
- }
157
- }
100
+ connected() {
158
101
  if (!this.#ui)
159
102
  this.#build();
160
- connected.push(this);
161
103
  const ui = this.#ui;
162
104
  if (!ui)
163
105
  return;
164
- this.controller.attach(ui.stage.canvasHost, ui.stage.minimapHost);
106
+ if (!this.#layoutReady) {
107
+ this.#loadRemembered();
108
+ this.#layoutReady = true;
109
+ }
110
+ // The stage adds its own minimap to the view (unless a slotted one replaces it).
111
+ this.controller.attach(ui.stage.canvasHost, null);
165
112
  // The graph takes keyboard focus (shortcuts apply where focus is), also on click.
166
- const canvas = ui.stage.canvasHost.querySelector('canvas');
113
+ const canvas = this.#canvas();
167
114
  if (canvas) {
168
115
  canvas.tabIndex = this.chrome === 'none' ? -1 : 0;
169
116
  const focusCanvas = () => {
@@ -173,177 +120,92 @@ export class KgdiffViewer extends HTMLElement {
173
120
  canvas.addEventListener('pointerdown', focusCanvas);
174
121
  this.#cleanup.push(() => canvas.removeEventListener('pointerdown', focusCanvas));
175
122
  }
176
- this.#watchMedia();
177
123
  this.#watchSize();
178
- const onKey = (e) => this.#onKeyDown(e);
179
- document.addEventListener('keydown', onKey);
180
- this.#cleanup.push(() => document.removeEventListener('keydown', onKey));
181
- const onStorage = (e) => this.controller.syncReview(e.key);
182
- window.addEventListener('storage', onStorage);
183
- this.#cleanup.push(() => window.removeEventListener('storage', onStorage));
184
- const onHash = () => this.#readHash();
185
- window.addEventListener('hashchange', onHash);
186
- this.#cleanup.push(() => window.removeEventListener('hashchange', onHash));
187
124
  this.controller.setChrome(() => ui.stage.chromeLayout());
188
125
  document.fonts?.ready?.then(() => this.#ui?.stage.layout()).catch(() => undefined);
189
- this.#readHash();
190
- this.#render(new Set([
191
- 'status',
192
- 'data',
193
- 'mode',
194
- 'theme',
195
- 'locale',
196
- 'filters',
197
- 'review',
198
- 'selection',
199
- 'focus',
200
- 'swipe',
201
- 'replay',
202
- 'overview',
203
- ]));
204
- }
205
- disconnectedCallback() {
206
- const i = connected.indexOf(this);
207
- if (i >= 0)
208
- connected.splice(i, 1);
126
+ }
127
+ disconnected() {
209
128
  for (const fn of this.#cleanup.splice(0))
210
129
  fn();
211
130
  this.controller.detach();
212
- // Moved while hovered: no pointerleave follows.
213
- this.#pointerOver = false;
214
- this.#applyHold();
215
131
  }
216
- attributeChangedCallback(name, _old, value) {
217
- if (this.#reflecting)
218
- return;
132
+ attributeChanged(name, value, old) {
219
133
  switch (name) {
220
- case 'mode':
221
- case 'scope':
222
- case 'theme':
223
- case 'palette':
224
- case 'locale':
225
- this.#applyPreference(name, value, false);
226
- break;
227
134
  case 'sidebar':
228
- this.#applySidebar();
135
+ // The layout reflects the sidebar's state here (after `\`): the page changing the
136
+ // attribute from what it says now is a command, even back to what the page said
137
+ // before; setting what it already says is not.
138
+ this.#preset(name, value, value !== old);
229
139
  break;
230
- case 'sync-hash':
231
- if (value !== null)
232
- this.#readHash();
140
+ case 'header':
141
+ this.#preset(name, value);
233
142
  break;
234
143
  case 'chrome':
235
- case 'legend':
144
+ this.#preset(name, value);
236
145
  this.#applyChrome();
237
146
  break;
238
- case 'loop':
239
- case 'loop-delay':
240
- this.controller.setLoop(this.loop ? this.loopDelay : null);
241
- this.#applyHold();
242
- break;
243
- case 'pause-on-hover':
244
- this.#applyHold();
245
- break;
246
- default:
247
- break;
248
- }
249
- }
250
- /**
251
- * Apply a preference from an attribute or a property. Valid values are explicit (a report's
252
- * `view` does not override them); invalid ones fall back to the default (`overlay`, `all`,
253
- * `auto`, `default`, the browser's language). Properties reflect the normalized value; the
254
- * legacy mode `changes` reflects as `overlay` + scope `changes`.
255
- */
256
- #applyPreference(name, raw, reflect) {
257
- const c = this.controller;
258
- // `mode="changes"` is rewritten to the attribute pair it stands for.
259
- const legacy = name === 'mode' && raw === LEGACY_CHANGES_MODE;
260
- let value;
261
- switch (name) {
262
- case 'mode': {
263
- if (legacy) {
264
- c.explicit.add('scope');
265
- c.setView('overlay', 'changes');
266
- value = 'overlay';
267
- break;
268
- }
269
- const v = toMode(raw);
270
- c.setMode(v ?? 'overlay');
271
- value = v;
272
- break;
273
- }
274
- case 'scope': {
275
- const v = toScope(raw);
276
- c.setScope(v ?? 'all');
277
- // A locked scope stays `changes`: the attribute says what is shown.
278
- value = v === null ? null : c.scope;
279
- break;
280
- }
281
- case 'theme': {
282
- const v = toTheme(raw);
283
- c.setThemePreference(v ?? 'auto');
284
- value = v;
147
+ case 'legend':
148
+ this.#applyChrome();
285
149
  break;
286
- }
287
- case 'palette': {
288
- const v = toPalette(raw);
289
- c.setPalette(v ?? 'default');
290
- value = v;
150
+ case 'panels': {
151
+ const { panels, invalid } = parsePanels(value ?? '');
152
+ warnPanels(invalid);
153
+ this.#setHostPanels(panels);
291
154
  break;
292
155
  }
293
- case 'locale': {
294
- const v = toLocale(raw);
295
- c.setLocale(v ?? detectLocale());
296
- value = v;
156
+ case 'persist':
157
+ case 'persist-key':
158
+ // Turned on, or another store, in the page: what the user chose before there applies
159
+ // now, as a host change.
160
+ if (this.#layoutReady && this.persist === 'local')
161
+ this.#remembered = this.#recall();
162
+ this.#resolve('host');
297
163
  break;
298
- }
299
164
  default:
300
- return;
165
+ break;
301
166
  }
302
- if (value === null)
303
- c.explicit.delete(name);
304
- else
305
- c.explicit.add(name);
306
- if (reflect || legacy)
307
- this.#reflect(name, value);
308
- if (legacy)
309
- this.#reflect('scope', c.scope);
310
- }
311
- // ─── Properties ───────────────────────────────────────────────────────────
312
- get mode() {
313
- return this.controller.mode;
314
- }
315
- set mode(value) {
316
- this.#applyPreference('mode', value, true);
317
- }
318
- get scope() {
319
- return this.controller.scope;
320
167
  }
321
- set scope(value) {
322
- this.#applyPreference('scope', value, true);
168
+ upgradedProperties() {
169
+ return ['chrome', 'legend', 'panels', 'persist', 'persistKey'];
323
170
  }
324
- get theme() {
325
- return this.controller.themePreference;
171
+ sheets(doc) {
172
+ return [stylesheet(doc)];
326
173
  }
327
- set theme(value) {
328
- this.#applyPreference('theme', value, true);
174
+ createSession(host, deps) {
175
+ return new ViewerController(host, deps);
329
176
  }
330
- get palette() {
331
- return this.controller.palette;
177
+ keysEnabled() {
178
+ return this.#ui !== null && this.chrome !== 'none';
332
179
  }
333
- set palette(value) {
334
- this.#applyPreference('palette', value, true);
180
+ interceptKey(e) {
181
+ const ui = this.#ui;
182
+ if (!ui || !(ui.help.open || ui.menu.open))
183
+ return false;
184
+ if (e.key === 'Escape') {
185
+ e.preventDefault();
186
+ ui.help.hide();
187
+ this.#toggleMenu(false);
188
+ }
189
+ return true;
335
190
  }
336
- get locale() {
337
- return this.controller.locale;
191
+ payloadSet(payload) {
192
+ // Validated (and warned about) once more when it loads.
193
+ this.#report = sanitizeView(payload.view, true)?.panels ?? {};
194
+ this.#resolve('host');
338
195
  }
339
- set locale(value) {
340
- this.#applyPreference('locale', value, true);
196
+ load(options) {
197
+ this.#report = {};
198
+ this.#resolve('host');
199
+ return super.load(options);
341
200
  }
201
+ // ─── Properties ───────────────────────────────────────────────────────────
342
202
  get chrome() {
343
203
  return toChrome(this.getAttribute('chrome')) ?? 'full';
344
204
  }
345
205
  set chrome(value) {
346
- this.#reflect('chrome', toChrome(value) === 'none' ? 'none' : null);
206
+ const v = toChrome(value) === 'none' ? 'none' : null;
207
+ this.reflect('chrome', v);
208
+ this.#preset('chrome', v);
347
209
  this.#applyChrome();
348
210
  }
349
211
  get legend() {
@@ -351,134 +213,186 @@ export class KgdiffViewer extends HTMLElement {
351
213
  }
352
214
  set legend(value) {
353
215
  const v = toLegend(value);
354
- this.#reflect('legend', v === 'auto' ? null : v);
216
+ this.reflect('legend', v === 'auto' ? null : v);
355
217
  this.#applyChrome();
356
218
  }
357
- get loop() {
358
- return this.hasAttribute('loop');
219
+ /** The host's declaration (the `panels` attribute as an object); `layout` has the states. */
220
+ get panels() {
221
+ return { ...this.#hostPanels };
359
222
  }
360
- set loop(value) {
361
- this.toggleAttribute('loop', Boolean(value));
223
+ set panels(value) {
224
+ const { panels, invalid } = toPanels(value);
225
+ warnPanels(invalid);
226
+ this.reflect('panels', formatPanels(panels) || null);
227
+ this.#setHostPanels(panels);
362
228
  }
363
- get loopDelay() {
364
- return toDelay(this.getAttribute('loop-delay')) ?? DEFAULT_LOOP_DELAY;
229
+ get persist() {
230
+ return this.getAttribute('persist') === 'none' ? 'none' : 'local';
365
231
  }
366
- set loopDelay(value) {
367
- const v = toDelay(value);
368
- if (v === null)
369
- this.removeAttribute('loop-delay');
232
+ set persist(value) {
233
+ if (value === 'none')
234
+ this.setAttribute('persist', 'none');
370
235
  else
371
- this.setAttribute('loop-delay', String(v));
236
+ this.removeAttribute('persist');
372
237
  }
373
- get pauseOnHover() {
374
- return this.getAttribute('pause-on-hover') !== 'false';
238
+ /** Where the layout remembers (`persist-key`): a `localStorage` key, `kgdiff:viewer` by default. */
239
+ get persistKey() {
240
+ return this.getAttribute('persist-key') || LAYOUT_KEY;
375
241
  }
376
- set pauseOnHover(value) {
377
- if (value)
378
- this.removeAttribute('pause-on-hover');
242
+ set persistKey(value) {
243
+ if (!value || value === LAYOUT_KEY)
244
+ this.removeAttribute('persist-key');
379
245
  else
380
- this.setAttribute('pause-on-hover', 'false');
381
- }
382
- get payload() {
383
- return this.#currentPayload;
246
+ this.setAttribute('persist-key', String(value));
247
+ }
248
+ /** Every region's state and the sizes now, as `kgdiff-layout` reports them. */
249
+ get layout() {
250
+ const sidebar = this.#ui?.sidebar;
251
+ return {
252
+ panels: { ...this.#states },
253
+ sidebarWidth: sidebar?.width ?? SIDEBAR_WIDTH,
254
+ detailShare: sidebar?.detailShare ?? null,
255
+ };
384
256
  }
385
- set payload(value) {
386
- this.#currentPayload = value;
387
- if (value === null || value === undefined)
257
+ // ─── Panels ───────────────────────────────────────────────────────────────
258
+ /** Some region's state may have changed: apply the new states, and report them. */
259
+ #resolve(source) {
260
+ const remembered = this.persist === 'local' ? this.#remembered.panels : {};
261
+ const next = resolvePanels([
262
+ this.#runtime,
263
+ this.#hostPanels,
264
+ this.#presets,
265
+ remembered,
266
+ this.#report,
267
+ ]);
268
+ if (samePanels(next, this.#states))
388
269
  return;
389
- let payload;
390
- try {
391
- payload = assertPayload(value);
392
- }
393
- catch (error) {
394
- this.controller.fail(error);
395
- return;
396
- }
397
- this.controller.loadPayload(payload).catch(() => {
398
- // Already surfaced: error state + `kgdiff-error`.
399
- });
400
- }
401
- // ─── Methods ──────────────────────────────────────────────────────────────
402
- load(options) {
403
- this.#currentPayload = null;
404
- return this.controller.load(options);
405
- }
406
- selectHunk(id) {
407
- this.controller.selectHunkById(id, { fly: true });
408
- }
409
- next() {
410
- this.controller.next();
411
- }
412
- prev() {
413
- this.controller.prev();
414
- }
415
- play() {
416
- this.controller.play();
270
+ this.#states = next;
271
+ this.#applyPanels();
272
+ if (this.#layoutReady)
273
+ this.#emitLayout(source);
274
+ }
275
+ #emitLayout(source) {
276
+ this.dispatchEvent(new CustomEvent('kgdiff-layout', {
277
+ detail: { ...this.layout, source },
278
+ bubbles: true,
279
+ composed: true,
280
+ }));
417
281
  }
418
- pause() {
419
- this.controller.pause();
420
- }
421
- fit(options) {
422
- return this.controller.fit(options);
423
- }
424
- exportDelta() {
425
- const diff = this.controller.diff;
426
- if (!diff)
427
- throw new KgdiffError('invalid-input', translate('en', 'error.notLoaded'));
428
- // A copy: callers can never mutate the diff the viewer shows.
429
- return deepFreeze(structuredClone(diff.delta));
430
- }
431
- reviewState() {
432
- return this.controller.reviewState();
433
- }
434
- setReviewed(hunkId, reviewed) {
435
- this.controller.setReviewed(hunkId, reviewed);
436
- }
437
- /** Show an error state (used for undecodable embedded payloads). */
438
- showError(error) {
439
- this.controller.fail(error);
440
- }
441
- // ─── Internals ────────────────────────────────────────────────────────────
442
- #emit(type, detail) {
443
- this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }));
282
+ /**
283
+ * The host declared `panels`. Where its value changed, what the user changed there in this
284
+ * page gives way; declaring the same again (a framework re-rendering, an attribute set to
285
+ * the value it has) leaves the user's change alone.
286
+ */
287
+ #setHostPanels(panels) {
288
+ this.#yield(this.#hostPanels, panels);
289
+ this.#hostPanels = panels;
290
+ this.#resolve('host');
444
291
  }
445
- #reflect(name, value) {
446
- if (this.getAttribute(name) === value)
447
- return;
448
- this.#reflecting = true;
449
- try {
450
- if (value === null)
451
- this.removeAttribute(name);
452
- else
453
- this.setAttribute(name, value);
292
+ /**
293
+ * A preset attribute changed. `commanded` (`sidebar`, which the layout reflects): the
294
+ * sidebar's regions yield even when the preset the page set last is the same.
295
+ */
296
+ #preset(name, value, commanded = false) {
297
+ this.#presetAttrs[name] = value;
298
+ const { sidebar, header, chrome } = this.#presetAttrs;
299
+ const next = presetPanels(sidebar, header, chrome);
300
+ const yielded = this.#yield(this.#presets, next);
301
+ if (commanded) {
302
+ for (const r of SIDEBAR_REGIONS)
303
+ delete this.#runtime[r];
304
+ }
305
+ if (commanded || yielded.some((r) => SIDEBAR_REGIONS.includes(r)))
306
+ this.#folded = null;
307
+ this.#presets = next;
308
+ this.#resolve('host');
309
+ }
310
+ /** Drop the user's in-page change of every region `before` and `after` disagree on. */
311
+ #yield(before, after) {
312
+ const changed = PANEL_REGIONS.filter((r) => before[r] !== after[r]);
313
+ for (const r of changed)
314
+ delete this.#runtime[r];
315
+ return changed;
316
+ }
317
+ /** The user changed some regions: they hold in this page, and are remembered. */
318
+ #userPanels(changes) {
319
+ Object.assign(this.#runtime, changes);
320
+ this.#remember({ panels: { ...this.#remembered.panels, ...changes } });
321
+ this.#resolve('user');
322
+ }
323
+ /** What the user chose before; theme, palette and language apply unless the host set them. */
324
+ #recall() {
325
+ const r = readRemembered(this.#storage, this.persistKey);
326
+ // A preference the host set wins; one the user chose before wins over a report's view.
327
+ const c = this.controller;
328
+ if (r.theme && !this.hasAttribute('theme') && !c.explicit.has('theme')) {
329
+ c.setThemePreference(r.theme);
330
+ c.explicit.add('theme');
454
331
  }
455
- finally {
456
- this.#reflecting = false;
332
+ if (r.palette && !this.hasAttribute('palette') && !c.explicit.has('palette')) {
333
+ c.setPalette(r.palette);
334
+ c.explicit.add('palette');
457
335
  }
336
+ if (r.locale && !this.hasAttribute('locale') && !c.explicit.has('locale')) {
337
+ c.setLocale(r.locale);
338
+ c.explicit.add('locale');
339
+ }
340
+ this.#ui?.sidebar.setSizes(r.sidebarWidth ?? SIDEBAR_WIDTH, r.detailShare ?? null, true);
341
+ return r;
458
342
  }
459
- #announce(text) {
460
- const live = this.#ui?.live;
461
- if (!live)
343
+ /** The first connection: the remembered states are the first ones (not a change, no event). */
344
+ #loadRemembered() {
345
+ if (this.persist !== 'local')
462
346
  return;
463
- live.textContent = '';
464
- // A fresh text node in a later task makes screen readers announce repeats too.
465
- setTimeout(() => {
466
- live.textContent = text;
467
- }, 30);
347
+ this.#remembered = this.#recall();
348
+ this.#resolve('host');
468
349
  }
469
- get #sidebarOpen() {
470
- return this.getAttribute('sidebar') !== 'closed';
350
+ /** Remember the user's choice (merged into what other viewers of this origin wrote). */
351
+ #remember(patch) {
352
+ if (this.persist !== 'local')
353
+ return;
354
+ const stored = readRemembered(this.#storage, this.persistKey);
355
+ const next = {
356
+ ...stored,
357
+ ...patch,
358
+ panels: { ...stored.panels, ...(patch.panels ?? {}) },
359
+ };
360
+ this.#remembered = { ...this.#remembered, ...patch, panels: next.panels };
361
+ writeRemembered(this.#storage, next, this.persistKey);
362
+ }
363
+ /** The user resized the sidebar or moved the split (once the drag or key press ended). */
364
+ #resized(width, detailShare) {
365
+ this.#remember({ sidebarWidth: width, detailShare });
366
+ this.#emitLayout('user');
471
367
  }
472
- #applySidebar() {
368
+ #applyPanels() {
473
369
  const ui = this.#ui;
474
370
  if (!ui)
475
371
  return;
476
- ui.sidebar.setOpen(this.#sidebarOpen);
372
+ const s = this.#states;
373
+ ui.sidebar.setPanels(s);
374
+ ui.stage.setPanels(s);
375
+ const sidebar = SIDEBAR_REGIONS.some((r) => s[r] !== 'hidden');
376
+ // A `<kgdiff-title>` or `<kgdiff-actions>` slotted into the viewer replaces the HUD's.
377
+ const title = ui.stage.replaces('title') ? 'hidden' : s.title;
378
+ const actions = ui.stage.replaces('actions') ? 'hidden' : s.actions;
379
+ ui.header.setPanels(title, actions, sidebar, () => this.#canvas());
477
380
  ui.header.setSidebarState(this.#sidebarOpen);
381
+ // The top corners sit under what the HUD shows there.
382
+ const hud = [title === 'open' ? 'title' : '', actions === 'open' ? 'actions' : ''];
383
+ ui.stage.el.dataset.hud = hud.filter(Boolean).join(' ');
384
+ }
385
+ #canvas() {
386
+ return this.#ui?.stage.canvasHost.querySelector('canvas') ?? null;
387
+ }
388
+ // ─── Internals ────────────────────────────────────────────────────────────
389
+ /** The sidebar is open while one of its regions is. */
390
+ get #sidebarOpen() {
391
+ return SIDEBAR_REGIONS.some((r) => this.#states[r] === 'open');
478
392
  }
479
393
  /**
480
394
  * `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"]`).
395
+ * fitted. Which regions show is the `chrome="none"` preset's (see `panels.ts`).
482
396
  */
483
397
  #applyChrome() {
484
398
  const bare = this.chrome === 'none';
@@ -492,7 +406,7 @@ export class KgdiffViewer extends HTMLElement {
492
406
  ui.root.dataset.chrome = this.chrome;
493
407
  const legend = this.legend;
494
408
  ui.stage.setChrome(legend === 'auto' ? (bare ? 'mini' : 'full') : legend, !bare);
495
- const canvas = ui.stage.canvasHost.querySelector('canvas');
409
+ const canvas = this.#canvas();
496
410
  if (canvas)
497
411
  canvas.tabIndex = bare ? -1 : 0;
498
412
  if (bare) {
@@ -500,50 +414,60 @@ export class KgdiffViewer extends HTMLElement {
500
414
  this.#toggleMenu(false);
501
415
  }
502
416
  }
503
- /** Hold a looping replay while the pointer is over the graph (`pause-on-hover`). */
504
- #applyHold() {
505
- this.controller.hold(this.#pointerOver && this.loop && this.pauseOnHover);
506
- }
507
- #build() {
508
- const style = h('style', { text: STYLES });
509
- const vars = h('style');
510
- const ctx = {
511
- controller: this.controller,
512
- tr: () => translator(this.controller.locale),
513
- root: this.#shadow,
514
- setMode: (mode) => {
515
- this.controller.setMode(mode);
516
- },
517
- toggleScope: () => {
518
- this.controller.toggleScope();
519
- },
417
+ /** What the layout does for the regions: preferences reflect as attributes, panels open. */
418
+ actions() {
419
+ return {
420
+ ...this.baseActions(),
520
421
  setThemePreference: (pref) => {
521
422
  this.theme = pref;
423
+ this.#remember({ theme: this.theme });
522
424
  },
523
425
  setPalette: (p) => {
524
426
  this.palette = p;
427
+ this.#remember({ palette: this.palette });
525
428
  },
526
429
  setLocale: (l) => {
527
430
  this.locale = l;
431
+ this.#remember({ locale: this.locale });
528
432
  },
529
433
  toggleSidebar: (open) => this.#toggleSidebar(open),
530
434
  sidebarOpen: () => this.#sidebarOpen,
435
+ hasSidebar: () => SIDEBAR_REGIONS.some((r) => this.#states[r] !== 'hidden'),
531
436
  toggleMenu: (open) => this.#toggleMenu(open),
532
437
  openHelp: () => this.#ui?.help.show(),
533
438
  focusSearch: () => this.#focusSearch(),
439
+ setPanel: (region, state) => this.#userPanels({ [region]: state }),
534
440
  };
535
- const header = new Header(ctx);
536
- const stage = new Stage(ctx);
537
- const sidebar = new Sidebar(ctx);
538
- const menu = new Menu(ctx);
539
- const help = new HelpDialog(ctx);
540
- const live = h('div', { class: 'sr-only', 'aria-live': 'polite', 'aria-atomic': 'true' });
441
+ }
442
+ #build() {
443
+ const root = h('div', { class: 'root' });
444
+ this.shadow.append(root);
445
+ // Each region binds to the root on its own (context protocol); the layout only places them.
446
+ const header = bindRegion(root, (ui) => new Header(ui));
447
+ const list = bindRegion(root, (ui) => new ChangeList(ui));
448
+ const search = bindRegion(root, (ui) => new Search(ui));
449
+ const filters = bindRegion(root, (ui) => new Filters(ui));
450
+ const detail = bindRegion(root, (ui) => new Detail(ui));
451
+ // The stage's slots are the viewer's: the page's content goes over the canvas.
452
+ const stage = bindRegion(root, (ui) => new Stage(ui, true));
453
+ stage.view = this.controller.view;
454
+ stage.onReplaced = () => this.#applyPanels();
455
+ this.#provideStage(stage);
456
+ const ui = uiContext(this.root, this.shadow);
457
+ const menu = new Menu(ui);
458
+ const help = new HelpDialog(ui);
459
+ const sidebar = new Sidebar(ui, {
460
+ setPanel: (region, state) => this.#userPanels({ [region]: state }),
461
+ resized: (width, share) => this.#resized(width, share),
462
+ focusOutside: () => (isOpen(header.sidebarButton) ? header.sidebarButton : this.#canvas()),
463
+ }, list, search, filters, detail);
541
464
  // The title and the actions float over the canvas; the menu drops from the actions.
542
465
  header.attachMenu(menu.el);
543
466
  stage.el.appendChild(header.el);
544
- const root = h('div', { class: 'root' }, h('div', { class: 'body' }, stage.el, sidebar.el), help.el, live);
545
- this.#shadow.append(style, vars, root);
546
- this.#ui = { root, vars, header, stage, sidebar, menu, help, live };
467
+ root.append(h('div', { class: 'body' }, stage.el, sidebar.el), help.el, this.root.live);
468
+ // Content slotted before the layout was built.
469
+ stage.slotted();
470
+ this.#ui = { root, header, stage, list, sidebar, menu, help };
547
471
  // Close the menu on outside clicks.
548
472
  root.addEventListener('pointerdown', (e) => {
549
473
  if (!menu.open)
@@ -555,48 +479,41 @@ export class KgdiffViewer extends HTMLElement {
555
479
  const hover = (over) => (e) => {
556
480
  if (e.pointerType === 'touch')
557
481
  return;
558
- this.#pointerOver = over;
559
- this.#applyHold();
482
+ this.pointerOver(over);
560
483
  };
561
484
  stage.el.addEventListener('pointerenter', hover(true));
562
485
  stage.el.addEventListener('pointerleave', hover(false));
563
- this.#applySidebar();
486
+ sidebar.setSizes(SIDEBAR_WIDTH, null, true);
487
+ this.#applyPanels();
564
488
  this.#applyChrome();
565
489
  }
566
- /** Render, never throwing: a failure shows the error state with safe appearance defaults. */
567
- #render(topics) {
568
- try {
569
- this.#renderTopics(topics);
570
- this.#renderFailed = false;
571
- }
572
- catch (error) {
573
- console.error('[kgdiff] render failed:', error);
574
- if (this.#renderFailed)
575
- return;
576
- this.#renderFailed = true;
577
- const c = this.controller;
578
- c.explicit.clear();
579
- c.setThemePreference('auto');
580
- c.setPalette('default');
581
- c.fail(error instanceof Error ? error : new Error(String(error)));
582
- }
490
+ /**
491
+ * The viewer is the stage of the chrome that acts on a view (`<kgdiff-zoom>`,
492
+ * `<kgdiff-minimap>`) inside it or naming it with `stage`: its built-in stage's view.
493
+ */
494
+ #provideStage(stage) {
495
+ const view = this.controller.view;
496
+ const ctx = {
497
+ stage: this,
498
+ root: this.root,
499
+ view,
500
+ registerObstacle: (el) => stage.registerObstacle(el),
501
+ watchView: (listener) => {
502
+ listener(view);
503
+ return () => undefined;
504
+ },
505
+ };
506
+ provideContext(this, stageContext, () => ctx);
583
507
  }
584
- #renderTopics(topics) {
508
+ /** The layout's part of a render (the root renders the regions after it). */
509
+ #update(topics) {
585
510
  const ui = this.#ui;
586
511
  const c = this.controller;
587
- if (topics.has('mode')) {
588
- this.#reflect('mode', c.mode);
589
- this.#reflect('scope', c.scope);
590
- }
591
- if (topics.has('theme')) {
592
- this.#reflect('theme', c.themePreference === 'auto' && !this.hasAttribute('theme') ? null : c.themePreference);
593
- }
512
+ this.reflectSession(topics);
594
513
  if (!ui)
595
514
  return;
596
- if (topics.has('theme') || topics.has('status')) {
597
- ui.vars.textContent = `:host { ${themeVariables(c.theme)} }`;
515
+ if (topics.has('theme') || topics.has('status'))
598
516
  ui.root.dataset.theme = c.themeName;
599
- }
600
517
  if (topics.has('locale') || topics.has('status')) {
601
518
  ui.root.setAttribute('lang', c.locale);
602
519
  if (ui.menu.open)
@@ -606,18 +523,38 @@ export class KgdiffViewer extends HTMLElement {
606
523
  }
607
524
  if (topics.has('theme') && ui.menu.open)
608
525
  ui.menu.render();
609
- ui.header.update(topics);
610
526
  ui.sidebar.update(topics);
611
- ui.stage.update(topics);
612
527
  if (topics.has('mode') || topics.has('status')) {
613
528
  ui.root.dataset.mode = c.mode;
614
529
  ui.root.dataset.scope = c.scope;
615
530
  }
616
531
  }
532
+ /**
533
+ * Fold the sidebar (its open regions collapse) or open it again (the regions that were open,
534
+ * or all four). Reflected to the `sidebar` attribute.
535
+ */
617
536
  #toggleSidebar(open) {
537
+ const s = this.#states;
538
+ const shown = SIDEBAR_REGIONS.filter((r) => s[r] !== 'hidden');
539
+ if (shown.length === 0)
540
+ return;
618
541
  const next = open ?? !this.#sidebarOpen;
619
- this.#reflect('sidebar', next ? 'open' : 'closed');
620
- this.#applySidebar();
542
+ this.reflect('sidebar', next ? 'open' : 'closed');
543
+ if (next === this.#sidebarOpen)
544
+ return;
545
+ const changes = {};
546
+ if (next) {
547
+ const restore = this.#folded?.filter((r) => shown.includes(r)) ?? [];
548
+ for (const r of restore.length > 0 ? restore : shown)
549
+ changes[r] = 'open';
550
+ this.#folded = null;
551
+ }
552
+ else {
553
+ this.#folded = shown.filter((r) => s[r] === 'open');
554
+ for (const r of shown)
555
+ changes[r] = 'collapsed';
556
+ }
557
+ this.#userPanels(changes);
621
558
  }
622
559
  #toggleMenu(open) {
623
560
  const ui = this.#ui;
@@ -630,25 +567,17 @@ export class KgdiffViewer extends HTMLElement {
630
567
  ui.menu.hide(false);
631
568
  ui.header.setMenuState(next);
632
569
  }
570
+ /** `/`: an open search takes focus; else this layout's search opens, unless it is hidden. */
633
571
  #focusSearch() {
572
+ if (this.root.focus('search'))
573
+ return;
574
+ if (this.#states.search === 'hidden')
575
+ return;
634
576
  if (!this.#sidebarOpen)
635
577
  this.#toggleSidebar(true);
636
- this.#ui?.sidebar.focusSearch();
637
- }
638
- #watchMedia() {
639
- const dark = media('(prefers-color-scheme: light)');
640
- const motion = media('(prefers-reduced-motion: reduce)');
641
- const apply = () => {
642
- this.controller.setSystemDark(!(dark?.matches ?? false));
643
- this.controller.setReducedMotion(motion?.matches ?? false);
644
- };
645
- apply();
646
- for (const q of [dark, motion]) {
647
- if (!q)
648
- continue;
649
- q.addEventListener?.('change', apply);
650
- this.#cleanup.push(() => q.removeEventListener?.('change', apply));
651
- }
578
+ if (this.#states.search === 'collapsed')
579
+ this.#userPanels({ search: 'open' });
580
+ this.root.focus('search');
652
581
  }
653
582
  #watchSize() {
654
583
  if (typeof ResizeObserver !== 'function')
@@ -665,9 +594,8 @@ export class KgdiffViewer extends HTMLElement {
665
594
  ui.root.dataset.compact = String(compact);
666
595
  }
667
596
  ui.stage.layout();
668
- ui.sidebar.renderWindow();
669
- if (compact)
670
- ui.sidebar.syncSheet();
597
+ ui.list.renderWindow();
598
+ ui.sidebar.layout(compact);
671
599
  this.controller.layoutChanged();
672
600
  });
673
601
  ro.observe(this);
@@ -676,154 +604,5 @@ export class KgdiffViewer extends HTMLElement {
676
604
  ro.observe(this.#ui.stage.el);
677
605
  this.#cleanup.push(() => ro.disconnect());
678
606
  }
679
- #onKeyDown(e) {
680
- if (e.defaultPrevented || e.isComposing)
681
- return;
682
- const ui = this.#ui;
683
- if (!ui || this.chrome === 'none')
684
- return;
685
- const path = e.composedPath();
686
- if (!path.includes(this)) {
687
- // Outside the component only with `global-shortcuts`, when nothing else has focus, for
688
- // the first such viewer, and never the keys pages scroll with.
689
- if (!this.hasAttribute('global-shortcuts') || PAGE_KEYS.has(e.key))
690
- return;
691
- const active = document.activeElement;
692
- if (active && active !== document.body && active !== document.documentElement)
693
- return;
694
- if (connected.find((v) => v.hasAttribute('global-shortcuts')) !== this)
695
- return;
696
- }
697
- if (ui.help.open || ui.menu.open) {
698
- if (e.key === 'Escape') {
699
- e.preventDefault();
700
- ui.help.hide();
701
- this.#toggleMenu(false);
702
- }
703
- return;
704
- }
705
- const target = path[0] ?? null;
706
- if (isTypingTarget(target))
707
- return;
708
- const c = this.controller;
709
- const action = keyAction(e, c.mode, c.scope);
710
- if (!action)
711
- return;
712
- // Enter / Space on a focused control activate that control.
713
- const el = target;
714
- if ((e.key === 'Enter' || e.key === ' ') &&
715
- el?.closest?.('button, a, summary, [role="radio"], [role="menuitemradio"]')) {
716
- return;
717
- }
718
- if (c.status !== 'ready' && !['help', 'theme', 'sidebar', 'escape'].includes(action.type))
719
- return;
720
- e.preventDefault();
721
- switch (action.type) {
722
- case 'mode':
723
- c.setMode(action.mode);
724
- break;
725
- case 'scope':
726
- c.toggleScope();
727
- break;
728
- case 'next':
729
- c.next();
730
- break;
731
- case 'prev':
732
- c.prev();
733
- break;
734
- case 'first':
735
- c.first();
736
- break;
737
- case 'last':
738
- c.last();
739
- break;
740
- case 'fly':
741
- c.flyToSelection();
742
- break;
743
- case 'escape':
744
- if (!ui.sidebar.escape())
745
- c.clearSelection();
746
- break;
747
- case 'play-pause':
748
- c.togglePlay();
749
- break;
750
- case 'step':
751
- c.stepReplay(action.direction);
752
- break;
753
- case 'divider':
754
- c.setSplit(c.split + action.delta);
755
- break;
756
- case 'fit':
757
- void c.fit();
758
- break;
759
- case 'zoom':
760
- c.zoom(action.direction);
761
- break;
762
- case 'search':
763
- this.#focusSearch();
764
- break;
765
- case 'review':
766
- c.toggleReviewed();
767
- break;
768
- case 'sidebar':
769
- this.#toggleSidebar();
770
- break;
771
- case 'theme': {
772
- const i = THEME_PREFERENCES.indexOf(c.themePreference);
773
- this.theme = THEME_PREFERENCES[(i + 1) % THEME_PREFERENCES.length];
774
- break;
775
- }
776
- case 'help':
777
- ui.help.show();
778
- break;
779
- case 'expand':
780
- c.expandNearest();
781
- break;
782
- default:
783
- action;
784
- }
785
- }
786
- // ─── Deep links ───────────────────────────────────────────────────────────
787
- #readHash() {
788
- if (!this.hasAttribute('sync-hash') || typeof location === 'undefined')
789
- return;
790
- if (location.hash === this.#lastHash)
791
- return;
792
- const state = parseHash(location.hash);
793
- this.#lastHash = location.hash;
794
- if (!state)
795
- return;
796
- if (state.mode || state.scope) {
797
- // A deep link outranks the large-graph default and a report's preferred view.
798
- const c = this.controller;
799
- if (state.mode)
800
- c.explicit.add('mode');
801
- if (state.scope)
802
- c.explicit.add('scope');
803
- c.setView(state.mode ?? c.mode, state.scope ?? c.scope);
804
- }
805
- if (state.hunk)
806
- this.controller.selectHunkById(state.hunk, { fly: true });
807
- }
808
- #writeHash() {
809
- if (!this.hasAttribute('sync-hash') || typeof location === 'undefined')
810
- return;
811
- const c = this.controller;
812
- const hunk = c.selectedHunk >= 0 ? c.scene?.hunks[c.selectedHunk]?.id : undefined;
813
- const hash = formatHash({
814
- ...(hunk !== undefined ? { hunk } : {}),
815
- mode: c.mode,
816
- scope: c.scope,
817
- });
818
- if (hash === location.hash)
819
- return;
820
- this.#lastHash = hash;
821
- try {
822
- history.replaceState(history.state, '', hash);
823
- }
824
- catch {
825
- location.hash = hash;
826
- }
827
- }
828
607
  }
829
608
  //# sourceMappingURL=element.js.map