@kgdiff/viewer 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (232) hide show
  1. package/README.md +43 -16
  2. package/custom-elements.json +2347 -0
  3. package/dist/bind.d.ts +41 -0
  4. package/dist/bind.d.ts.map +1 -0
  5. package/dist/bind.js +47 -0
  6. package/dist/bind.js.map +1 -0
  7. package/dist/controller.d.ts +22 -74
  8. package/dist/controller.d.ts.map +1 -1
  9. package/dist/controller.js +35 -376
  10. package/dist/controller.js.map +1 -1
  11. package/dist/dom.d.ts +10 -0
  12. package/dist/dom.d.ts.map +1 -1
  13. package/dist/dom.js +29 -0
  14. package/dist/dom.js.map +1 -1
  15. package/dist/element.d.ts +52 -46
  16. package/dist/element.d.ts.map +1 -1
  17. package/dist/element.js +362 -403
  18. package/dist/element.js.map +1 -1
  19. package/dist/elements/actions.d.ts +27 -0
  20. package/dist/elements/actions.d.ts.map +1 -0
  21. package/dist/elements/actions.js +81 -0
  22. package/dist/elements/actions.js.map +1 -0
  23. package/dist/elements/bound.d.ts +63 -0
  24. package/dist/elements/bound.d.ts.map +1 -0
  25. package/dist/elements/bound.js +213 -0
  26. package/dist/elements/bound.js.map +1 -0
  27. package/dist/elements/change-detail.d.ts +25 -0
  28. package/dist/elements/change-detail.d.ts.map +1 -0
  29. package/dist/elements/change-detail.js +19 -0
  30. package/dist/elements/change-detail.js.map +1 -0
  31. package/dist/elements/change-list.d.ts +27 -0
  32. package/dist/elements/change-list.d.ts.map +1 -0
  33. package/dist/elements/change-list.js +29 -0
  34. package/dist/elements/change-list.js.map +1 -0
  35. package/dist/elements/filters.d.ts +25 -0
  36. package/dist/elements/filters.d.ts.map +1 -0
  37. package/dist/elements/filters.js +19 -0
  38. package/dist/elements/filters.js.map +1 -0
  39. package/dist/elements/legend.d.ts +40 -0
  40. package/dist/elements/legend.d.ts.map +1 -0
  41. package/dist/elements/legend.js +87 -0
  42. package/dist/elements/legend.js.map +1 -0
  43. package/dist/elements/minimap.d.ts +38 -0
  44. package/dist/elements/minimap.d.ts.map +1 -0
  45. package/dist/elements/minimap.js +59 -0
  46. package/dist/elements/minimap.js.map +1 -0
  47. package/dist/elements/root.d.ts +40 -0
  48. package/dist/elements/root.d.ts.map +1 -0
  49. package/dist/elements/root.js +67 -0
  50. package/dist/elements/root.js.map +1 -0
  51. package/dist/elements/search.d.ts +29 -0
  52. package/dist/elements/search.d.ts.map +1 -0
  53. package/dist/elements/search.js +23 -0
  54. package/dist/elements/search.js.map +1 -0
  55. package/dist/elements/stage-context.d.ts +31 -0
  56. package/dist/elements/stage-context.d.ts.map +1 -0
  57. package/dist/elements/stage-context.js +4 -0
  58. package/dist/elements/stage-context.js.map +1 -0
  59. package/dist/elements/stage.d.ts +65 -0
  60. package/dist/elements/stage.d.ts.map +1 -0
  61. package/dist/elements/stage.js +259 -0
  62. package/dist/elements/stage.js.map +1 -0
  63. package/dist/elements/title.d.ts +25 -0
  64. package/dist/elements/title.d.ts.map +1 -0
  65. package/dist/elements/title.js +19 -0
  66. package/dist/elements/title.js.map +1 -0
  67. package/dist/elements/transport.d.ts +29 -0
  68. package/dist/elements/transport.d.ts.map +1 -0
  69. package/dist/elements/transport.js +24 -0
  70. package/dist/elements/transport.js.map +1 -0
  71. package/dist/elements/view-bound.d.ts +39 -0
  72. package/dist/elements/view-bound.d.ts.map +1 -0
  73. package/dist/elements/view-bound.js +88 -0
  74. package/dist/elements/view-bound.js.map +1 -0
  75. package/dist/elements/view-dock.d.ts +28 -0
  76. package/dist/elements/view-dock.d.ts.map +1 -0
  77. package/dist/elements/view-dock.js +28 -0
  78. package/dist/elements/view-dock.js.map +1 -0
  79. package/dist/elements/zoom.d.ts +28 -0
  80. package/dist/elements/zoom.d.ts.map +1 -0
  81. package/dist/elements/zoom.js +22 -0
  82. package/dist/elements/zoom.js.map +1 -0
  83. package/dist/i18n.d.ts +10 -0
  84. package/dist/i18n.d.ts.map +1 -1
  85. package/dist/i18n.js +20 -0
  86. package/dist/i18n.js.map +1 -1
  87. package/dist/icons.d.ts +1 -0
  88. package/dist/icons.d.ts.map +1 -1
  89. package/dist/icons.js +1 -0
  90. package/dist/icons.js.map +1 -1
  91. package/dist/index.d.ts +328 -34
  92. package/dist/index.d.ts.map +1 -1
  93. package/dist/index.js +13 -0
  94. package/dist/index.js.map +1 -1
  95. package/dist/keyboard.d.ts +39 -2
  96. package/dist/keyboard.d.ts.map +1 -1
  97. package/dist/keyboard.js +145 -62
  98. package/dist/keyboard.js.map +1 -1
  99. package/dist/layout-store.d.ts +26 -0
  100. package/dist/layout-store.d.ts.map +1 -0
  101. package/dist/layout-store.js +46 -0
  102. package/dist/layout-store.js.map +1 -0
  103. package/dist/panel-levels.d.ts +18 -0
  104. package/dist/panel-levels.d.ts.map +1 -0
  105. package/dist/panel-levels.js +41 -0
  106. package/dist/panel-levels.js.map +1 -0
  107. package/dist/panels.d.ts +28 -0
  108. package/dist/panels.d.ts.map +1 -0
  109. package/dist/panels.js +73 -0
  110. package/dist/panels.js.map +1 -0
  111. package/dist/prefs.d.ts +5 -2
  112. package/dist/prefs.d.ts.map +1 -1
  113. package/dist/prefs.js +12 -3
  114. package/dist/prefs.js.map +1 -1
  115. package/dist/react.d.ts +460 -0
  116. package/dist/react.d.ts.map +1 -0
  117. package/dist/react.js +2 -0
  118. package/dist/react.js.map +1 -0
  119. package/dist/root-element.d.ts +122 -0
  120. package/dist/root-element.d.ts.map +1 -0
  121. package/dist/root-element.js +541 -0
  122. package/dist/root-element.js.map +1 -0
  123. package/dist/root.d.ts +30 -30
  124. package/dist/root.d.ts.map +1 -1
  125. package/dist/root.js +65 -42
  126. package/dist/root.js.map +1 -1
  127. package/dist/session.d.ts +19 -3
  128. package/dist/session.d.ts.map +1 -1
  129. package/dist/session.js +15 -17
  130. package/dist/session.js.map +1 -1
  131. package/dist/standalone/kgdiff-viewer.js +4 -4
  132. package/dist/styles.d.ts +4 -0
  133. package/dist/styles.d.ts.map +1 -1
  134. package/dist/styles.js +224 -84
  135. package/dist/styles.js.map +1 -1
  136. package/dist/theme.d.ts +7 -0
  137. package/dist/theme.d.ts.map +1 -1
  138. package/dist/theme.js +10 -7
  139. package/dist/theme.js.map +1 -1
  140. package/dist/topics.d.ts +8 -0
  141. package/dist/topics.d.ts.map +1 -0
  142. package/dist/topics.js +18 -0
  143. package/dist/topics.js.map +1 -0
  144. package/dist/ui/change-list.d.ts.map +1 -1
  145. package/dist/ui/change-list.js +2 -2
  146. package/dist/ui/change-list.js.map +1 -1
  147. package/dist/ui/context.d.ts +20 -7
  148. package/dist/ui/context.d.ts.map +1 -1
  149. package/dist/ui/context.js.map +1 -1
  150. package/dist/ui/header.d.ts +41 -3
  151. package/dist/ui/header.d.ts.map +1 -1
  152. package/dist/ui/header.js +99 -34
  153. package/dist/ui/header.js.map +1 -1
  154. package/dist/ui/legend.d.ts +15 -0
  155. package/dist/ui/legend.d.ts.map +1 -0
  156. package/dist/ui/legend.js +68 -0
  157. package/dist/ui/legend.js.map +1 -0
  158. package/dist/ui/popovers.d.ts +3 -1
  159. package/dist/ui/popovers.d.ts.map +1 -1
  160. package/dist/ui/popovers.js +62 -23
  161. package/dist/ui/popovers.js.map +1 -1
  162. package/dist/ui/search.d.ts.map +1 -1
  163. package/dist/ui/search.js +3 -2
  164. package/dist/ui/search.js.map +1 -1
  165. package/dist/ui/sidebar.d.ts +26 -3
  166. package/dist/ui/sidebar.d.ts.map +1 -1
  167. package/dist/ui/sidebar.js +344 -12
  168. package/dist/ui/sidebar.js.map +1 -1
  169. package/dist/ui/stage.d.ts +56 -1
  170. package/dist/ui/stage.d.ts.map +1 -1
  171. package/dist/ui/stage.js +244 -256
  172. package/dist/ui/stage.js.map +1 -1
  173. package/dist/ui/transport.d.ts +12 -0
  174. package/dist/ui/transport.d.ts.map +1 -0
  175. package/dist/ui/transport.js +198 -0
  176. package/dist/ui/transport.js.map +1 -0
  177. package/dist/ui/zoom.d.ts +15 -0
  178. package/dist/ui/zoom.d.ts.map +1 -0
  179. package/dist/ui/zoom.js +30 -0
  180. package/dist/ui/zoom.js.map +1 -0
  181. package/dist/view.d.ts +94 -0
  182. package/dist/view.d.ts.map +1 -0
  183. package/dist/view.js +441 -0
  184. package/dist/view.js.map +1 -0
  185. package/package.json +95 -12
  186. package/src/bind.ts +80 -0
  187. package/src/controller.ts +51 -442
  188. package/src/dom.ts +30 -0
  189. package/src/element.ts +364 -406
  190. package/src/elements/actions.ts +94 -0
  191. package/src/elements/bound.ts +247 -0
  192. package/src/elements/change-detail.ts +34 -0
  193. package/src/elements/change-list.ts +51 -0
  194. package/src/elements/filters.ts +34 -0
  195. package/src/elements/legend.ts +103 -0
  196. package/src/elements/minimap.ts +75 -0
  197. package/src/elements/root.ts +86 -0
  198. package/src/elements/search.ts +38 -0
  199. package/src/elements/stage-context.ts +34 -0
  200. package/src/elements/stage.ts +285 -0
  201. package/src/elements/title.ts +34 -0
  202. package/src/elements/transport.ts +39 -0
  203. package/src/elements/view-bound.ts +118 -0
  204. package/src/elements/view-dock.ts +43 -0
  205. package/src/elements/zoom.ts +38 -0
  206. package/src/i18n.ts +20 -0
  207. package/src/icons.ts +1 -0
  208. package/src/index.ts +347 -34
  209. package/src/keyboard.ts +188 -61
  210. package/src/layout-store.ts +71 -0
  211. package/src/panel-levels.ts +53 -0
  212. package/src/panels.ts +81 -0
  213. package/src/prefs.ts +12 -4
  214. package/src/react.ts +459 -0
  215. package/src/root-element.ts +624 -0
  216. package/src/root.ts +92 -65
  217. package/src/session.ts +22 -33
  218. package/src/styles.ts +245 -84
  219. package/src/theme.ts +10 -7
  220. package/src/topics.ts +37 -0
  221. package/src/ui/change-list.ts +6 -2
  222. package/src/ui/context.ts +20 -7
  223. package/src/ui/header.ts +127 -56
  224. package/src/ui/legend.ts +112 -0
  225. package/src/ui/popovers.ts +68 -25
  226. package/src/ui/search.ts +10 -5
  227. package/src/ui/sidebar.ts +386 -23
  228. package/src/ui/stage.ts +267 -331
  229. package/src/ui/transport.ts +240 -0
  230. package/src/ui/zoom.ts +45 -0
  231. package/src/view.ts +516 -0
  232. package/vscode.html-custom-data.json +726 -0
package/dist/element.js CHANGED
@@ -1,133 +1,116 @@
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 { KgdiffError } from '@kgdiff/core';
9
- import { h } from './dom.js';
10
- import { formatHash, parseHash } from './hash-sync.js';
11
- import { detectLocale, translate, translator } from './i18n.js';
12
- import { assertPayload } from './payload.js';
13
- import { DEFAULT_LOOP_DELAY, LEGACY_CHANGES_MODE, toChrome, toDelay, toLegend, toLocale, toMode, toPalette, toScope, toTheme, } from './prefs.js';
14
- import { bindRegion, uiContext, ViewerRoot } from './root.js';
15
- import { ALL_TOPICS } from './session.js';
14
+ import { provideContext } from './context.js';
15
+ import { ViewerController } from './controller.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';
22
+ import { defaultStorage } from './review-store.js';
23
+ import { bindRegion, uiContext } from './root.js';
24
+ import { ROOT_ATTRIBUTES, RootElement } from './root-element.js';
25
+ import { stylesheet } from './styles.js';
16
26
  import { ChangeList } from './ui/change-list.js';
17
27
  import { Detail } from './ui/detail.js';
18
28
  import { Filters } from './ui/filters.js';
19
29
  import { Header } from './ui/header.js';
20
30
  import { HelpDialog, Menu } from './ui/popovers.js';
21
31
  import { Search } from './ui/search.js';
22
- import { Sidebar } from './ui/sidebar.js';
32
+ import { SIDEBAR_WIDTH, Sidebar } from './ui/sidebar.js';
23
33
  import { Stage } from './ui/stage.js';
24
34
  /** Below this width the sidebar becomes a bottom sheet. */
25
35
  export const COMPACT_WIDTH = 760;
26
36
  /** Below this width the title drops the version tags. */
27
37
  const NARROW_WIDTH = 1100;
28
38
  const OBSERVED = [
29
- 'mode',
30
- 'scope',
31
- 'theme',
32
- 'palette',
33
- 'locale',
39
+ ...ROOT_ATTRIBUTES,
34
40
  'sidebar',
35
- 'sync-hash',
41
+ 'header',
36
42
  'chrome',
37
43
  'legend',
38
- 'loop',
39
- 'loop-delay',
40
- 'pause-on-hover',
44
+ 'panels',
45
+ 'persist',
46
+ 'persist-key',
41
47
  ];
42
- const UPGRADED = [
43
- 'mode',
44
- 'scope',
45
- 'theme',
46
- 'palette',
47
- 'locale',
48
- 'chrome',
49
- 'legend',
50
- 'loop',
51
- 'loopDelay',
52
- 'pauseOnHover',
53
- 'payload',
54
- ];
55
- /** Keys the page itself scrolls with: never taken at document level. */
56
- const PAGE_KEYS = new Set([
57
- 'ArrowUp',
58
- 'ArrowDown',
59
- 'ArrowLeft',
60
- 'ArrowRight',
61
- 'Home',
62
- 'End',
63
- 'PageUp',
64
- 'PageDown',
65
- ' ',
66
- 'Spacebar',
67
- ]);
68
- /** Connected viewers, in connection order (`global-shortcuts` goes to the first opted-in one). */
69
- const connected = [];
70
- function deepFreeze(value) {
71
- if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) {
72
- for (const v of Object.values(value))
73
- deepFreeze(v);
74
- Object.freeze(value);
75
- }
76
- return value;
48
+ function warnPanels(invalid) {
49
+ if (invalid.length > 0)
50
+ console.warn(`[kgdiff] ignoring invalid panels: ${invalid.join(' ')}`);
77
51
  }
78
- export class KgdiffViewer extends HTMLElement {
52
+ /**
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.
64
+ */
65
+ export class KgdiffViewer extends RootElement {
79
66
  static get observedAttributes() {
80
67
  return OBSERVED;
81
68
  }
82
- /** @internal */
83
- controller;
84
- #shadow;
85
- #root;
86
69
  #ui = null;
87
- #currentPayload = null;
88
- #reflecting = false;
89
70
  #compact = false;
90
71
  #cleanup = [];
91
- #lastHash = '';
92
- #upgraded = false;
93
- /** A mouse or pen rests on the graph (holds a looping replay with `pause-on-hover`). */
94
- #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();
95
94
  constructor() {
96
95
  super();
97
- this.#shadow = this.attachShadow({ mode: 'open' });
98
- this.#root = new ViewerRoot(this, this.#shadow, this.#actions(), (type) => {
99
- if (type === 'kgdiff-select' || type === 'kgdiff-mode')
100
- this.#writeHash();
101
- });
102
- this.controller = this.#root.controller;
103
- this.controller.locale = toLocale(this.getAttribute('locale')) ?? detectLocale();
104
96
  // The layout renders first, then the regions in the order they bound to the root.
105
- this.#root.attach({ update: (topics) => this.#update(topics) });
97
+ this.root.attach({ update: (topics) => this.#update(topics) });
106
98
  }
107
99
  // ─── Lifecycle ────────────────────────────────────────────────────────────
108
- connectedCallback() {
109
- if (!this.#upgraded) {
110
- this.#upgraded = true;
111
- // Properties set before the element was defined shadow the accessors: re-apply them.
112
- for (const prop of UPGRADED) {
113
- if (Object.hasOwn(this, prop)) {
114
- const value = this[prop];
115
- delete this[prop];
116
- this[prop] = value;
117
- }
118
- }
119
- }
120
- // Provide the session (the regions bind to it) and follow the system's preferences.
121
- this.#cleanup.push(this.#root.connect());
100
+ connected() {
122
101
  if (!this.#ui)
123
102
  this.#build();
124
- connected.push(this);
125
103
  const ui = this.#ui;
126
104
  if (!ui)
127
105
  return;
128
- 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);
129
112
  // The graph takes keyboard focus (shortcuts apply where focus is), also on click.
130
- const canvas = ui.stage.canvasHost.querySelector('canvas');
113
+ const canvas = this.#canvas();
131
114
  if (canvas) {
132
115
  canvas.tabIndex = this.chrome === 'none' ? -1 : 0;
133
116
  const focusCanvas = () => {
@@ -138,159 +121,91 @@ export class KgdiffViewer extends HTMLElement {
138
121
  this.#cleanup.push(() => canvas.removeEventListener('pointerdown', focusCanvas));
139
122
  }
140
123
  this.#watchSize();
141
- const onKey = (e) => this.#onKeyDown(e);
142
- document.addEventListener('keydown', onKey);
143
- this.#cleanup.push(() => document.removeEventListener('keydown', onKey));
144
- const onHash = () => this.#readHash();
145
- window.addEventListener('hashchange', onHash);
146
- this.#cleanup.push(() => window.removeEventListener('hashchange', onHash));
147
124
  this.controller.setChrome(() => ui.stage.chromeLayout());
148
125
  document.fonts?.ready?.then(() => this.#ui?.stage.layout()).catch(() => undefined);
149
- this.#readHash();
150
- this.#root.render(ALL_TOPICS);
151
126
  }
152
- disconnectedCallback() {
153
- const i = connected.indexOf(this);
154
- if (i >= 0)
155
- connected.splice(i, 1);
127
+ disconnected() {
156
128
  for (const fn of this.#cleanup.splice(0))
157
129
  fn();
158
130
  this.controller.detach();
159
- // Moved while hovered: no pointerleave follows.
160
- this.#pointerOver = false;
161
- this.#applyHold();
162
131
  }
163
- attributeChangedCallback(name, _old, value) {
164
- if (this.#reflecting)
165
- return;
132
+ attributeChanged(name, value, old) {
166
133
  switch (name) {
167
- case 'mode':
168
- case 'scope':
169
- case 'theme':
170
- case 'palette':
171
- case 'locale':
172
- this.#applyPreference(name, value, false);
173
- break;
174
134
  case 'sidebar':
175
- 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);
176
139
  break;
177
- case 'sync-hash':
178
- if (value !== null)
179
- this.#readHash();
140
+ case 'header':
141
+ this.#preset(name, value);
180
142
  break;
181
143
  case 'chrome':
182
- case 'legend':
144
+ this.#preset(name, value);
183
145
  this.#applyChrome();
184
146
  break;
185
- case 'loop':
186
- case 'loop-delay':
187
- this.controller.setLoop(this.loop ? this.loopDelay : null);
188
- this.#applyHold();
189
- break;
190
- case 'pause-on-hover':
191
- this.#applyHold();
192
- break;
193
- default:
194
- break;
195
- }
196
- }
197
- /**
198
- * Apply a preference from an attribute or a property. Valid values are explicit (a report's
199
- * `view` does not override them); invalid ones fall back to the default (`overlay`, `all`,
200
- * `auto`, `default`, the browser's language). Properties reflect the normalized value; the
201
- * legacy mode `changes` reflects as `overlay` + scope `changes`.
202
- */
203
- #applyPreference(name, raw, reflect) {
204
- const c = this.controller;
205
- // `mode="changes"` is rewritten to the attribute pair it stands for.
206
- const legacy = name === 'mode' && raw === LEGACY_CHANGES_MODE;
207
- let value;
208
- switch (name) {
209
- case 'mode': {
210
- if (legacy) {
211
- c.explicit.add('scope');
212
- c.setView('overlay', 'changes');
213
- value = 'overlay';
214
- break;
215
- }
216
- const v = toMode(raw);
217
- c.setMode(v ?? 'overlay');
218
- value = v;
219
- break;
220
- }
221
- case 'scope': {
222
- const v = toScope(raw);
223
- c.setScope(v ?? 'all');
224
- // A locked scope stays `changes`: the attribute says what is shown.
225
- value = v === null ? null : c.scope;
226
- break;
227
- }
228
- case 'theme': {
229
- const v = toTheme(raw);
230
- c.setThemePreference(v ?? 'auto');
231
- value = v;
147
+ case 'legend':
148
+ this.#applyChrome();
232
149
  break;
233
- }
234
- case 'palette': {
235
- const v = toPalette(raw);
236
- c.setPalette(v ?? 'default');
237
- value = v;
150
+ case 'panels': {
151
+ const { panels, invalid } = parsePanels(value ?? '');
152
+ warnPanels(invalid);
153
+ this.#setHostPanels(panels);
238
154
  break;
239
155
  }
240
- case 'locale': {
241
- const v = toLocale(raw);
242
- c.setLocale(v ?? detectLocale());
243
- 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');
244
163
  break;
245
- }
246
164
  default:
247
- return;
165
+ break;
248
166
  }
249
- if (value === null)
250
- c.explicit.delete(name);
251
- else
252
- c.explicit.add(name);
253
- if (reflect || legacy)
254
- this.#reflect(name, value);
255
- if (legacy)
256
- this.#reflect('scope', c.scope);
257
- }
258
- // ─── Properties ───────────────────────────────────────────────────────────
259
- get mode() {
260
- return this.controller.mode;
261
- }
262
- set mode(value) {
263
- this.#applyPreference('mode', value, true);
264
167
  }
265
- get scope() {
266
- return this.controller.scope;
168
+ upgradedProperties() {
169
+ return ['chrome', 'legend', 'panels', 'persist', 'persistKey'];
267
170
  }
268
- set scope(value) {
269
- this.#applyPreference('scope', value, true);
171
+ sheets(doc) {
172
+ return [stylesheet(doc)];
270
173
  }
271
- get theme() {
272
- return this.controller.themePreference;
174
+ createSession(host, deps) {
175
+ return new ViewerController(host, deps);
273
176
  }
274
- set theme(value) {
275
- this.#applyPreference('theme', value, true);
177
+ keysEnabled() {
178
+ return this.#ui !== null && this.chrome !== 'none';
276
179
  }
277
- get palette() {
278
- return this.controller.palette;
279
- }
280
- set palette(value) {
281
- 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;
282
190
  }
283
- get locale() {
284
- 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');
285
195
  }
286
- set locale(value) {
287
- this.#applyPreference('locale', value, true);
196
+ load(options) {
197
+ this.#report = {};
198
+ this.#resolve('host');
199
+ return super.load(options);
288
200
  }
201
+ // ─── Properties ───────────────────────────────────────────────────────────
289
202
  get chrome() {
290
203
  return toChrome(this.getAttribute('chrome')) ?? 'full';
291
204
  }
292
205
  set chrome(value) {
293
- 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);
294
209
  this.#applyChrome();
295
210
  }
296
211
  get legend() {
@@ -298,121 +213,186 @@ export class KgdiffViewer extends HTMLElement {
298
213
  }
299
214
  set legend(value) {
300
215
  const v = toLegend(value);
301
- this.#reflect('legend', v === 'auto' ? null : v);
216
+ this.reflect('legend', v === 'auto' ? null : v);
302
217
  this.#applyChrome();
303
218
  }
304
- get loop() {
305
- 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 };
306
222
  }
307
- set loop(value) {
308
- 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);
309
228
  }
310
- get loopDelay() {
311
- return toDelay(this.getAttribute('loop-delay')) ?? DEFAULT_LOOP_DELAY;
229
+ get persist() {
230
+ return this.getAttribute('persist') === 'none' ? 'none' : 'local';
312
231
  }
313
- set loopDelay(value) {
314
- const v = toDelay(value);
315
- if (v === null)
316
- this.removeAttribute('loop-delay');
232
+ set persist(value) {
233
+ if (value === 'none')
234
+ this.setAttribute('persist', 'none');
317
235
  else
318
- this.setAttribute('loop-delay', String(v));
236
+ this.removeAttribute('persist');
319
237
  }
320
- get pauseOnHover() {
321
- 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;
322
241
  }
323
- set pauseOnHover(value) {
324
- if (value)
325
- this.removeAttribute('pause-on-hover');
242
+ set persistKey(value) {
243
+ if (!value || value === LAYOUT_KEY)
244
+ this.removeAttribute('persist-key');
326
245
  else
327
- this.setAttribute('pause-on-hover', 'false');
246
+ this.setAttribute('persist-key', String(value));
328
247
  }
329
- get payload() {
330
- return this.#currentPayload;
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
+ };
331
256
  }
332
- set payload(value) {
333
- this.#currentPayload = value;
334
- 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))
335
269
  return;
336
- let payload;
337
- try {
338
- payload = assertPayload(value);
339
- }
340
- catch (error) {
341
- this.controller.fail(error);
342
- return;
343
- }
344
- this.controller.loadPayload(payload).catch(() => {
345
- // Already surfaced: error state + `kgdiff-error`.
346
- });
347
- }
348
- // ─── Methods ──────────────────────────────────────────────────────────────
349
- load(options) {
350
- this.#currentPayload = null;
351
- return this.controller.load(options);
352
- }
353
- selectHunk(id) {
354
- this.controller.selectHunkById(id, { fly: true });
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
+ }));
355
281
  }
356
- next() {
357
- this.controller.next();
358
- }
359
- prev() {
360
- this.controller.prev();
361
- }
362
- play() {
363
- this.controller.play();
364
- }
365
- pause() {
366
- this.controller.pause();
367
- }
368
- fit(options) {
369
- return this.controller.fit(options);
370
- }
371
- exportDelta() {
372
- const diff = this.controller.diff;
373
- if (!diff)
374
- throw new KgdiffError('invalid-input', translate('en', 'error.notLoaded'));
375
- // A copy: callers can never mutate the diff the viewer shows.
376
- return deepFreeze(structuredClone(diff.delta));
377
- }
378
- reviewState() {
379
- return this.controller.reviewState();
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');
380
291
  }
381
- setReviewed(hunkId, reviewed) {
382
- this.controller.setReviewed(hunkId, reviewed);
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');
331
+ }
332
+ if (r.palette && !this.hasAttribute('palette') && !c.explicit.has('palette')) {
333
+ c.setPalette(r.palette);
334
+ c.explicit.add('palette');
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;
383
342
  }
384
- /** Show an error state (used for undecodable embedded payloads). */
385
- showError(error) {
386
- this.controller.fail(error);
343
+ /** The first connection: the remembered states are the first ones (not a change, no event). */
344
+ #loadRemembered() {
345
+ if (this.persist !== 'local')
346
+ return;
347
+ this.#remembered = this.#recall();
348
+ this.#resolve('host');
387
349
  }
388
- // ─── Internals ────────────────────────────────────────────────────────────
389
- #reflect(name, value) {
390
- if (this.getAttribute(name) === value)
350
+ /** Remember the user's choice (merged into what other viewers of this origin wrote). */
351
+ #remember(patch) {
352
+ if (this.persist !== 'local')
391
353
  return;
392
- this.#reflecting = true;
393
- try {
394
- if (value === null)
395
- this.removeAttribute(name);
396
- else
397
- this.setAttribute(name, value);
398
- }
399
- finally {
400
- this.#reflecting = false;
401
- }
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);
402
362
  }
403
- get #sidebarOpen() {
404
- return this.getAttribute('sidebar') !== 'closed';
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');
405
367
  }
406
- #applySidebar() {
368
+ #applyPanels() {
407
369
  const ui = this.#ui;
408
370
  if (!ui)
409
371
  return;
410
- 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());
411
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');
412
392
  }
413
393
  /**
414
394
  * `chrome` and `legend`: what the stage shows, whether clicks select, whether the view stays
415
- * fitted. The CSS hides the rest (`.root[data-chrome="none"]`).
395
+ * fitted. Which regions show is the `chrome="none"` preset's (see `panels.ts`).
416
396
  */
417
397
  #applyChrome() {
418
398
  const bare = this.chrome === 'none';
@@ -426,7 +406,7 @@ export class KgdiffViewer extends HTMLElement {
426
406
  ui.root.dataset.chrome = this.chrome;
427
407
  const legend = this.legend;
428
408
  ui.stage.setChrome(legend === 'auto' ? (bare ? 'mini' : 'full') : legend, !bare);
429
- const canvas = ui.stage.canvasHost.querySelector('canvas');
409
+ const canvas = this.#canvas();
430
410
  if (canvas)
431
411
  canvas.tabIndex = bare ? -1 : 0;
432
412
  if (bare) {
@@ -434,50 +414,59 @@ export class KgdiffViewer extends HTMLElement {
434
414
  this.#toggleMenu(false);
435
415
  }
436
416
  }
437
- /** Hold a looping replay while the pointer is over the graph (`pause-on-hover`). */
438
- #applyHold() {
439
- this.controller.hold(this.#pointerOver && this.loop && this.pauseOnHover);
440
- }
441
417
  /** What the layout does for the regions: preferences reflect as attributes, panels open. */
442
- #actions() {
418
+ actions() {
443
419
  return {
444
- tr: () => translator(this.controller.locale),
445
- setMode: (mode) => this.controller.setMode(mode),
446
- toggleScope: () => this.controller.toggleScope(),
420
+ ...this.baseActions(),
447
421
  setThemePreference: (pref) => {
448
422
  this.theme = pref;
423
+ this.#remember({ theme: this.theme });
449
424
  },
450
425
  setPalette: (p) => {
451
426
  this.palette = p;
427
+ this.#remember({ palette: this.palette });
452
428
  },
453
429
  setLocale: (l) => {
454
430
  this.locale = l;
431
+ this.#remember({ locale: this.locale });
455
432
  },
456
433
  toggleSidebar: (open) => this.#toggleSidebar(open),
457
434
  sidebarOpen: () => this.#sidebarOpen,
435
+ hasSidebar: () => SIDEBAR_REGIONS.some((r) => this.#states[r] !== 'hidden'),
458
436
  toggleMenu: (open) => this.#toggleMenu(open),
459
437
  openHelp: () => this.#ui?.help.show(),
460
438
  focusSearch: () => this.#focusSearch(),
439
+ setPanel: (region, state) => this.#userPanels({ [region]: state }),
461
440
  };
462
441
  }
463
442
  #build() {
464
443
  const root = h('div', { class: 'root' });
465
- this.#shadow.append(root);
444
+ this.shadow.append(root);
466
445
  // Each region binds to the root on its own (context protocol); the layout only places them.
467
446
  const header = bindRegion(root, (ui) => new Header(ui));
468
447
  const list = bindRegion(root, (ui) => new ChangeList(ui));
469
448
  const search = bindRegion(root, (ui) => new Search(ui));
470
449
  const filters = bindRegion(root, (ui) => new Filters(ui));
471
450
  const detail = bindRegion(root, (ui) => new Detail(ui));
472
- const stage = bindRegion(root, (ui) => new Stage(ui));
473
- const ui = uiContext(this.#root, this.#shadow);
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);
474
457
  const menu = new Menu(ui);
475
458
  const help = new HelpDialog(ui);
476
- const sidebar = new Sidebar(ui.tr, list, search, filters, detail);
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);
477
464
  // The title and the actions float over the canvas; the menu drops from the actions.
478
465
  header.attachMenu(menu.el);
479
466
  stage.el.appendChild(header.el);
480
- root.append(h('div', { class: 'body' }, stage.el, sidebar.el), help.el, this.#root.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();
481
470
  this.#ui = { root, header, stage, list, sidebar, menu, help };
482
471
  // Close the menu on outside clicks.
483
472
  root.addEventListener('pointerdown', (e) => {
@@ -490,25 +479,37 @@ export class KgdiffViewer extends HTMLElement {
490
479
  const hover = (over) => (e) => {
491
480
  if (e.pointerType === 'touch')
492
481
  return;
493
- this.#pointerOver = over;
494
- this.#applyHold();
482
+ this.pointerOver(over);
495
483
  };
496
484
  stage.el.addEventListener('pointerenter', hover(true));
497
485
  stage.el.addEventListener('pointerleave', hover(false));
498
- this.#applySidebar();
486
+ sidebar.setSizes(SIDEBAR_WIDTH, null, true);
487
+ this.#applyPanels();
499
488
  this.#applyChrome();
500
489
  }
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);
507
+ }
501
508
  /** The layout's part of a render (the root renders the regions after it). */
502
509
  #update(topics) {
503
510
  const ui = this.#ui;
504
511
  const c = this.controller;
505
- if (topics.has('mode')) {
506
- this.#reflect('mode', c.mode);
507
- this.#reflect('scope', c.scope);
508
- }
509
- if (topics.has('theme')) {
510
- this.#reflect('theme', c.themePreference === 'auto' && !this.hasAttribute('theme') ? null : c.themePreference);
511
- }
512
+ this.reflectSession(topics);
512
513
  if (!ui)
513
514
  return;
514
515
  if (topics.has('theme') || topics.has('status'))
@@ -528,10 +529,32 @@ export class KgdiffViewer extends HTMLElement {
528
529
  ui.root.dataset.scope = c.scope;
529
530
  }
530
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
+ */
531
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;
532
541
  const next = open ?? !this.#sidebarOpen;
533
- this.#reflect('sidebar', next ? 'open' : 'closed');
534
- 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);
535
558
  }
536
559
  #toggleMenu(open) {
537
560
  const ui = this.#ui;
@@ -544,10 +567,17 @@ export class KgdiffViewer extends HTMLElement {
544
567
  ui.menu.hide(false);
545
568
  ui.header.setMenuState(next);
546
569
  }
570
+ /** `/`: an open search takes focus; else this layout's search opens, unless it is hidden. */
547
571
  #focusSearch() {
572
+ if (this.root.focus('search'))
573
+ return;
574
+ if (this.#states.search === 'hidden')
575
+ return;
548
576
  if (!this.#sidebarOpen)
549
577
  this.#toggleSidebar(true);
550
- this.#root.focus('search');
578
+ if (this.#states.search === 'collapsed')
579
+ this.#userPanels({ search: 'open' });
580
+ this.root.focus('search');
551
581
  }
552
582
  #watchSize() {
553
583
  if (typeof ResizeObserver !== 'function')
@@ -565,8 +595,7 @@ export class KgdiffViewer extends HTMLElement {
565
595
  }
566
596
  ui.stage.layout();
567
597
  ui.list.renderWindow();
568
- if (compact)
569
- ui.sidebar.syncSheet();
598
+ ui.sidebar.layout(compact);
570
599
  this.controller.layoutChanged();
571
600
  });
572
601
  ro.observe(this);
@@ -575,75 +604,5 @@ export class KgdiffViewer extends HTMLElement {
575
604
  ro.observe(this.#ui.stage.el);
576
605
  this.#cleanup.push(() => ro.disconnect());
577
606
  }
578
- #onKeyDown(e) {
579
- if (e.defaultPrevented || e.isComposing)
580
- return;
581
- const ui = this.#ui;
582
- if (!ui || this.chrome === 'none')
583
- return;
584
- const path = e.composedPath();
585
- if (!path.includes(this)) {
586
- // Outside the component only with `global-shortcuts`, when nothing else has focus, for
587
- // the first such viewer, and never the keys pages scroll with.
588
- if (!this.hasAttribute('global-shortcuts') || PAGE_KEYS.has(e.key))
589
- return;
590
- const active = document.activeElement;
591
- if (active && active !== document.body && active !== document.documentElement)
592
- return;
593
- if (connected.find((v) => v.hasAttribute('global-shortcuts')) !== this)
594
- return;
595
- }
596
- if (ui.help.open || ui.menu.open) {
597
- if (e.key === 'Escape') {
598
- e.preventDefault();
599
- ui.help.hide();
600
- this.#toggleMenu(false);
601
- }
602
- return;
603
- }
604
- this.#root.handleKey(e);
605
- }
606
- // ─── Deep links ───────────────────────────────────────────────────────────
607
- #readHash() {
608
- if (!this.hasAttribute('sync-hash') || typeof location === 'undefined')
609
- return;
610
- if (location.hash === this.#lastHash)
611
- return;
612
- const state = parseHash(location.hash);
613
- this.#lastHash = location.hash;
614
- if (!state)
615
- return;
616
- if (state.mode || state.scope) {
617
- // A deep link outranks the large-graph default and a report's preferred view.
618
- const c = this.controller;
619
- if (state.mode)
620
- c.explicit.add('mode');
621
- if (state.scope)
622
- c.explicit.add('scope');
623
- c.setView(state.mode ?? c.mode, state.scope ?? c.scope);
624
- }
625
- if (state.hunk)
626
- this.controller.selectHunkById(state.hunk, { fly: true });
627
- }
628
- #writeHash() {
629
- if (!this.hasAttribute('sync-hash') || typeof location === 'undefined')
630
- return;
631
- const c = this.controller;
632
- const hunk = c.selectedHunk >= 0 ? c.scene?.hunks[c.selectedHunk]?.id : undefined;
633
- const hash = formatHash({
634
- ...(hunk !== undefined ? { hunk } : {}),
635
- mode: c.mode,
636
- scope: c.scope,
637
- });
638
- if (hash === location.hash)
639
- return;
640
- this.#lastHash = hash;
641
- try {
642
- history.replaceState(history.state, '', hash);
643
- }
644
- catch {
645
- location.hash = hash;
646
- }
647
- }
648
607
  }
649
608
  //# sourceMappingURL=element.js.map