@kgdiff/viewer 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (257) hide show
  1. package/README.md +43 -16
  2. package/custom-elements.json +2347 -0
  3. package/dist/bind.d.ts +41 -0
  4. package/dist/bind.d.ts.map +1 -0
  5. package/dist/bind.js +47 -0
  6. package/dist/bind.js.map +1 -0
  7. package/dist/context.d.ts +42 -0
  8. package/dist/context.d.ts.map +1 -0
  9. package/dist/context.js +87 -0
  10. package/dist/context.js.map +1 -0
  11. package/dist/controller.d.ts +26 -237
  12. package/dist/controller.d.ts.map +1 -1
  13. package/dist/controller.js +33 -1484
  14. package/dist/controller.js.map +1 -1
  15. package/dist/dom.d.ts +10 -0
  16. package/dist/dom.d.ts.map +1 -1
  17. package/dist/dom.js +29 -0
  18. package/dist/dom.js.map +1 -1
  19. package/dist/element.d.ts +51 -42
  20. package/dist/element.d.ts.map +1 -1
  21. package/dist/element.js +381 -602
  22. package/dist/element.js.map +1 -1
  23. package/dist/elements/actions.d.ts +27 -0
  24. package/dist/elements/actions.d.ts.map +1 -0
  25. package/dist/elements/actions.js +81 -0
  26. package/dist/elements/actions.js.map +1 -0
  27. package/dist/elements/bound.d.ts +63 -0
  28. package/dist/elements/bound.d.ts.map +1 -0
  29. package/dist/elements/bound.js +213 -0
  30. package/dist/elements/bound.js.map +1 -0
  31. package/dist/elements/change-detail.d.ts +25 -0
  32. package/dist/elements/change-detail.d.ts.map +1 -0
  33. package/dist/elements/change-detail.js +19 -0
  34. package/dist/elements/change-detail.js.map +1 -0
  35. package/dist/elements/change-list.d.ts +27 -0
  36. package/dist/elements/change-list.d.ts.map +1 -0
  37. package/dist/elements/change-list.js +29 -0
  38. package/dist/elements/change-list.js.map +1 -0
  39. package/dist/elements/filters.d.ts +25 -0
  40. package/dist/elements/filters.d.ts.map +1 -0
  41. package/dist/elements/filters.js +19 -0
  42. package/dist/elements/filters.js.map +1 -0
  43. package/dist/elements/legend.d.ts +40 -0
  44. package/dist/elements/legend.d.ts.map +1 -0
  45. package/dist/elements/legend.js +87 -0
  46. package/dist/elements/legend.js.map +1 -0
  47. package/dist/elements/minimap.d.ts +38 -0
  48. package/dist/elements/minimap.d.ts.map +1 -0
  49. package/dist/elements/minimap.js +59 -0
  50. package/dist/elements/minimap.js.map +1 -0
  51. package/dist/elements/root.d.ts +40 -0
  52. package/dist/elements/root.d.ts.map +1 -0
  53. package/dist/elements/root.js +67 -0
  54. package/dist/elements/root.js.map +1 -0
  55. package/dist/elements/search.d.ts +29 -0
  56. package/dist/elements/search.d.ts.map +1 -0
  57. package/dist/elements/search.js +23 -0
  58. package/dist/elements/search.js.map +1 -0
  59. package/dist/elements/stage-context.d.ts +31 -0
  60. package/dist/elements/stage-context.d.ts.map +1 -0
  61. package/dist/elements/stage-context.js +4 -0
  62. package/dist/elements/stage-context.js.map +1 -0
  63. package/dist/elements/stage.d.ts +65 -0
  64. package/dist/elements/stage.d.ts.map +1 -0
  65. package/dist/elements/stage.js +259 -0
  66. package/dist/elements/stage.js.map +1 -0
  67. package/dist/elements/title.d.ts +25 -0
  68. package/dist/elements/title.d.ts.map +1 -0
  69. package/dist/elements/title.js +19 -0
  70. package/dist/elements/title.js.map +1 -0
  71. package/dist/elements/transport.d.ts +29 -0
  72. package/dist/elements/transport.d.ts.map +1 -0
  73. package/dist/elements/transport.js +24 -0
  74. package/dist/elements/transport.js.map +1 -0
  75. package/dist/elements/view-bound.d.ts +39 -0
  76. package/dist/elements/view-bound.d.ts.map +1 -0
  77. package/dist/elements/view-bound.js +88 -0
  78. package/dist/elements/view-bound.js.map +1 -0
  79. package/dist/elements/view-dock.d.ts +28 -0
  80. package/dist/elements/view-dock.d.ts.map +1 -0
  81. package/dist/elements/view-dock.js +28 -0
  82. package/dist/elements/view-dock.js.map +1 -0
  83. package/dist/elements/zoom.d.ts +28 -0
  84. package/dist/elements/zoom.d.ts.map +1 -0
  85. package/dist/elements/zoom.js +22 -0
  86. package/dist/elements/zoom.js.map +1 -0
  87. package/dist/i18n.d.ts +10 -0
  88. package/dist/i18n.d.ts.map +1 -1
  89. package/dist/i18n.js +20 -0
  90. package/dist/i18n.js.map +1 -1
  91. package/dist/icons.d.ts +1 -0
  92. package/dist/icons.d.ts.map +1 -1
  93. package/dist/icons.js +1 -0
  94. package/dist/icons.js.map +1 -1
  95. package/dist/index.d.ts +331 -36
  96. package/dist/index.d.ts.map +1 -1
  97. package/dist/index.js +13 -0
  98. package/dist/index.js.map +1 -1
  99. package/dist/keyboard.d.ts +39 -2
  100. package/dist/keyboard.d.ts.map +1 -1
  101. package/dist/keyboard.js +145 -62
  102. package/dist/keyboard.js.map +1 -1
  103. package/dist/layout-store.d.ts +26 -0
  104. package/dist/layout-store.d.ts.map +1 -0
  105. package/dist/layout-store.js +46 -0
  106. package/dist/layout-store.js.map +1 -0
  107. package/dist/panel-levels.d.ts +18 -0
  108. package/dist/panel-levels.d.ts.map +1 -0
  109. package/dist/panel-levels.js +41 -0
  110. package/dist/panel-levels.js.map +1 -0
  111. package/dist/panels.d.ts +28 -0
  112. package/dist/panels.d.ts.map +1 -0
  113. package/dist/panels.js +73 -0
  114. package/dist/panels.js.map +1 -0
  115. package/dist/pipeline.d.ts.map +1 -1
  116. package/dist/pipeline.js +3 -2
  117. package/dist/pipeline.js.map +1 -1
  118. package/dist/prefs.d.ts +5 -2
  119. package/dist/prefs.d.ts.map +1 -1
  120. package/dist/prefs.js +12 -3
  121. package/dist/prefs.js.map +1 -1
  122. package/dist/react.d.ts +460 -0
  123. package/dist/react.d.ts.map +1 -0
  124. package/dist/react.js +2 -0
  125. package/dist/react.js.map +1 -0
  126. package/dist/root-element.d.ts +122 -0
  127. package/dist/root-element.d.ts.map +1 -0
  128. package/dist/root-element.js +541 -0
  129. package/dist/root-element.js.map +1 -0
  130. package/dist/root.d.ts +54 -0
  131. package/dist/root.d.ts.map +1 -0
  132. package/dist/root.js +288 -0
  133. package/dist/root.js.map +1 -0
  134. package/dist/schedule.d.ts +22 -0
  135. package/dist/schedule.d.ts.map +1 -0
  136. package/dist/schedule.js +41 -0
  137. package/dist/schedule.js.map +1 -0
  138. package/dist/session.d.ts +269 -0
  139. package/dist/session.d.ts.map +1 -0
  140. package/dist/session.js +1237 -0
  141. package/dist/session.js.map +1 -0
  142. package/dist/standalone/kgdiff-viewer-compute.js +1 -1
  143. package/dist/standalone/kgdiff-viewer.js +4 -4
  144. package/dist/styles.d.ts +17 -6
  145. package/dist/styles.d.ts.map +1 -1
  146. package/dist/styles.js +367 -258
  147. package/dist/styles.js.map +1 -1
  148. package/dist/theme.d.ts +32 -0
  149. package/dist/theme.d.ts.map +1 -0
  150. package/dist/theme.js +152 -0
  151. package/dist/theme.js.map +1 -0
  152. package/dist/topics.d.ts +8 -0
  153. package/dist/topics.d.ts.map +1 -0
  154. package/dist/topics.js +18 -0
  155. package/dist/topics.js.map +1 -0
  156. package/dist/ui/change-list.d.ts +18 -0
  157. package/dist/ui/change-list.d.ts.map +1 -0
  158. package/dist/ui/change-list.js +331 -0
  159. package/dist/ui/change-list.js.map +1 -0
  160. package/dist/ui/context.d.ts +34 -7
  161. package/dist/ui/context.d.ts.map +1 -1
  162. package/dist/ui/context.js.map +1 -1
  163. package/dist/ui/filters.d.ts +9 -0
  164. package/dist/ui/filters.d.ts.map +1 -0
  165. package/dist/ui/filters.js +68 -0
  166. package/dist/ui/filters.js.map +1 -0
  167. package/dist/ui/header.d.ts +41 -3
  168. package/dist/ui/header.d.ts.map +1 -1
  169. package/dist/ui/header.js +101 -36
  170. package/dist/ui/header.js.map +1 -1
  171. package/dist/ui/legend.d.ts +15 -0
  172. package/dist/ui/legend.d.ts.map +1 -0
  173. package/dist/ui/legend.js +68 -0
  174. package/dist/ui/legend.js.map +1 -0
  175. package/dist/ui/popovers.d.ts +3 -1
  176. package/dist/ui/popovers.d.ts.map +1 -1
  177. package/dist/ui/popovers.js +62 -23
  178. package/dist/ui/popovers.js.map +1 -1
  179. package/dist/ui/search.d.ts +13 -0
  180. package/dist/ui/search.d.ts.map +1 -0
  181. package/dist/ui/search.js +192 -0
  182. package/dist/ui/search.js.map +1 -0
  183. package/dist/ui/sidebar.d.ts +32 -17
  184. package/dist/ui/sidebar.d.ts.map +1 -1
  185. package/dist/ui/sidebar.js +327 -519
  186. package/dist/ui/sidebar.js.map +1 -1
  187. package/dist/ui/stage.d.ts +56 -1
  188. package/dist/ui/stage.d.ts.map +1 -1
  189. package/dist/ui/stage.js +244 -256
  190. package/dist/ui/stage.js.map +1 -1
  191. package/dist/ui/swatch.js +1 -1
  192. package/dist/ui/swatch.js.map +1 -1
  193. package/dist/ui/transport.d.ts +12 -0
  194. package/dist/ui/transport.d.ts.map +1 -0
  195. package/dist/ui/transport.js +198 -0
  196. package/dist/ui/transport.js.map +1 -0
  197. package/dist/ui/zoom.d.ts +15 -0
  198. package/dist/ui/zoom.d.ts.map +1 -0
  199. package/dist/ui/zoom.js +30 -0
  200. package/dist/ui/zoom.js.map +1 -0
  201. package/dist/view.d.ts +94 -0
  202. package/dist/view.d.ts.map +1 -0
  203. package/dist/view.js +441 -0
  204. package/dist/view.js.map +1 -0
  205. package/package.json +95 -12
  206. package/src/bind.ts +80 -0
  207. package/src/context.ts +106 -0
  208. package/src/controller.ts +52 -1630
  209. package/src/dom.ts +30 -0
  210. package/src/element.ts +386 -619
  211. package/src/elements/actions.ts +94 -0
  212. package/src/elements/bound.ts +247 -0
  213. package/src/elements/change-detail.ts +34 -0
  214. package/src/elements/change-list.ts +51 -0
  215. package/src/elements/filters.ts +34 -0
  216. package/src/elements/legend.ts +103 -0
  217. package/src/elements/minimap.ts +75 -0
  218. package/src/elements/root.ts +86 -0
  219. package/src/elements/search.ts +38 -0
  220. package/src/elements/stage-context.ts +34 -0
  221. package/src/elements/stage.ts +285 -0
  222. package/src/elements/title.ts +34 -0
  223. package/src/elements/transport.ts +39 -0
  224. package/src/elements/view-bound.ts +118 -0
  225. package/src/elements/view-dock.ts +43 -0
  226. package/src/elements/zoom.ts +38 -0
  227. package/src/i18n.ts +20 -0
  228. package/src/icons.ts +1 -0
  229. package/src/index.ts +350 -35
  230. package/src/keyboard.ts +188 -61
  231. package/src/layout-store.ts +71 -0
  232. package/src/panel-levels.ts +53 -0
  233. package/src/panels.ts +81 -0
  234. package/src/pipeline.ts +4 -1
  235. package/src/prefs.ts +12 -4
  236. package/src/react.ts +459 -0
  237. package/src/root-element.ts +624 -0
  238. package/src/root.ts +335 -0
  239. package/src/schedule.ts +43 -0
  240. package/src/session.ts +1375 -0
  241. package/src/styles.ts +396 -263
  242. package/src/theme.ts +170 -0
  243. package/src/topics.ts +37 -0
  244. package/src/ui/change-list.ts +405 -0
  245. package/src/ui/context.ts +36 -7
  246. package/src/ui/filters.ts +79 -0
  247. package/src/ui/header.ts +129 -58
  248. package/src/ui/legend.ts +112 -0
  249. package/src/ui/popovers.ts +68 -25
  250. package/src/ui/search.ts +225 -0
  251. package/src/ui/sidebar.ts +362 -639
  252. package/src/ui/stage.ts +267 -331
  253. package/src/ui/swatch.ts +1 -1
  254. package/src/ui/transport.ts +240 -0
  255. package/src/ui/zoom.ts +45 -0
  256. package/src/view.ts +516 -0
  257. package/vscode.html-custom-data.json +726 -0
package/src/root.ts ADDED
@@ -0,0 +1,335 @@
1
+ /**
2
+ * The root of a viewer (RFC 0001 §2–§6): it owns the session and provides it to the regions
3
+ * bound to it (context `kgdiff.viewer/1`), renders them on every notification, and owns what
4
+ * they share: the keyboard map, the live region, the events, the focus targets and the theme
5
+ * tokens. Regions never reference each other; everything goes through the session or the root.
6
+ *
7
+ * Its host is `<kgdiff-viewer>` (the default layout, whose regions render into its shadow
8
+ * root) or `<kgdiff-root>` (whose elements are placed by the page).
9
+ */
10
+ import type { Theme } from '@kgdiff/scene';
11
+ import type { Region, ViewerContext } from './bind.js';
12
+ import { viewerContext } from './bind.js';
13
+ import { provideContext } from './context.js';
14
+ import { h } from './dom.js';
15
+ import type { ThemePreference } from './index.js';
16
+ import { isTypingTarget, type Keymap, keyAction } from './keyboard.js';
17
+ import { THEME_PREFERENCES } from './prefs.js';
18
+ import { defaultStorage } from './review-store.js';
19
+ import { cancelFrame, nextPaint, requestFrame } from './schedule.js';
20
+ import type { Topic } from './session.js';
21
+ import { DiffSession, type SessionDeps, type SessionHost } from './session.js';
22
+ import { stylesheet } from './styles.js';
23
+ import { themeFromStyle, tokenSheet } from './theme.js';
24
+ import type { FocusTarget, UiActions } from './ui/context.js';
25
+
26
+ export type { Region, ViewerContext } from './bind.js';
27
+ export { bindRegion, uiContext, viewerContext, wasForwarded } from './bind.js';
28
+
29
+ /** Hosts of roots (the nearest root acts on a key; an outer one leaves it alone). */
30
+ const rootHosts = new WeakSet<EventTarget>();
31
+
32
+ /** Whether `target` is the host of a root. */
33
+ export function isRoot(target: EventTarget): boolean {
34
+ return rootHosts.has(target);
35
+ }
36
+
37
+ /** What the element that owns a root adds to it. */
38
+ export interface RootOptions<S extends DiffSession = DiffSession> {
39
+ /** The session to make (default: a plain `DiffSession`, without a canvas of its own). */
40
+ readonly session?: (host: SessionHost, deps: SessionDeps) => S;
41
+ /** The sheets the root's shadow root adopts before its token set (default: the viewer's). */
42
+ readonly sheets?: (doc: Document) => readonly CSSStyleSheet[];
43
+ /** A key forwarded by an element bound with `for` (default: the keyboard map). */
44
+ readonly key?: (e: KeyboardEvent) => void;
45
+ /** The pointer entered or left the graph. */
46
+ readonly hover?: (over: boolean) => void;
47
+ }
48
+
49
+ /** The shortcuts a focused list keeps with `shortcuts="none"` (the listbox's own keys). */
50
+ const LIST_KEYS: ReadonlySet<string> = new Set(['next', 'prev', 'first', 'last', 'fly']);
51
+
52
+ interface Target {
53
+ readonly focus: () => void;
54
+ readonly available: () => boolean;
55
+ }
56
+
57
+ function media(query: string): MediaQueryList | null {
58
+ return typeof window !== 'undefined' && typeof window.matchMedia === 'function'
59
+ ? window.matchMedia(query)
60
+ : null;
61
+ }
62
+
63
+ export class ViewerRoot<S extends DiffSession = DiffSession> implements ViewerContext {
64
+ readonly controller: S;
65
+ /** The single polite live region announcing navigation ("Change 3 of 12: …"). */
66
+ readonly live = h('div', { class: 'sr-only', 'aria-live': 'polite', 'aria-atomic': 'true' });
67
+ readonly host: HTMLElement;
68
+ readonly actions: UiActions;
69
+ readonly #shadow: ShadowRoot;
70
+ readonly #regions: Region[] = [];
71
+ readonly #targets = new Map<FocusTarget, Target[]>();
72
+ readonly #options: RootOptions<S>;
73
+ #renderFailed = false;
74
+ /** The built-in theme of the token set applied last. */
75
+ #base: Theme | null = null;
76
+ /** `keymap`: single keys bound to other shortcuts, or unbound. */
77
+ keymap: Keymap | null = null;
78
+ /** `shortcuts="none"`: no shortcuts, but a focused list keeps its own keys. */
79
+ shortcutsOff = false;
80
+
81
+ /**
82
+ * `host` dispatches the events and computes the tokens set on `shadow` (its shadow root);
83
+ * `emitted` runs after each event was dispatched.
84
+ */
85
+ constructor(
86
+ host: HTMLElement,
87
+ shadow: ShadowRoot,
88
+ actions: UiActions,
89
+ emitted?: (type: string) => void,
90
+ options: RootOptions<S> = {},
91
+ ) {
92
+ this.host = host;
93
+ this.#shadow = shadow;
94
+ this.actions = actions;
95
+ this.#options = options;
96
+ rootHosts.add(host);
97
+ const emit = (type: string, detail: unknown): void => {
98
+ host.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }));
99
+ emitted?.(type);
100
+ };
101
+ const events: SessionHost = {
102
+ ready: (stats) => emit('kgdiff-ready', { stats }),
103
+ select: (hunk, node) => emit('kgdiff-select', { hunk, node }),
104
+ mode: (mode, scope) => emit('kgdiff-mode', { mode, scope }),
105
+ review: (hunk, reviewed, progress) => emit('kgdiff-review', { hunk, reviewed, progress }),
106
+ error: (error) => emit('kgdiff-error', { error }),
107
+ announce: (text) => this.announce(text),
108
+ replayEnd: (loop) => emit('kgdiff-replay-end', { loop }),
109
+ };
110
+ const deps: SessionDeps = {
111
+ now: () => (typeof performance !== 'undefined' ? performance.now() : Date.now()),
112
+ requestFrame,
113
+ cancelFrame,
114
+ yieldToPaint: nextPaint,
115
+ storage: defaultStorage(),
116
+ // The one place the token set is applied: the canvas then draws with the tokens as
117
+ // computed on the host (page styles win).
118
+ theme: (base) => {
119
+ this.#tokens(base);
120
+ const view = host.ownerDocument.defaultView;
121
+ return view ? themeFromStyle(base, view.getComputedStyle(host)) : base;
122
+ },
123
+ };
124
+ this.controller = options.session
125
+ ? options.session(events, deps)
126
+ : (new DiffSession(events, deps) as S);
127
+ this.controller.subscribe((topics) => this.render(topics));
128
+ }
129
+
130
+ /**
131
+ * Provide the session to the regions and follow the system's color scheme and motion
132
+ * preference and other tabs' review progress; returns the function that stops it all.
133
+ */
134
+ connect(): () => void {
135
+ const c = this.controller;
136
+ const stop = [provideContext(this.host, viewerContext, () => this)];
137
+ const dark = media('(prefers-color-scheme: light)');
138
+ const motion = media('(prefers-reduced-motion: reduce)');
139
+ const apply = (): void => {
140
+ c.setSystemDark(!(dark?.matches ?? false));
141
+ c.setReducedMotion(motion?.matches ?? false);
142
+ };
143
+ for (const q of [dark, motion]) {
144
+ q?.addEventListener?.('change', apply);
145
+ stop.push(() => q?.removeEventListener?.('change', apply));
146
+ }
147
+ apply();
148
+ // Page styles may override tokens now that the host is in the document.
149
+ c.refreshTheme();
150
+ const onStorage = (e: StorageEvent): void => c.syncReview(e.key);
151
+ window.addEventListener('storage', onStorage);
152
+ stop.push(() => window.removeEventListener('storage', onStorage));
153
+ return () => {
154
+ for (const fn of stop) fn();
155
+ };
156
+ }
157
+
158
+ attach(region: Region): () => void {
159
+ this.#regions.push(region);
160
+ return () => {
161
+ const i = this.#regions.indexOf(region);
162
+ if (i >= 0) this.#regions.splice(i, 1);
163
+ };
164
+ }
165
+
166
+ target(name: FocusTarget, focus: () => void, available?: () => boolean): () => void {
167
+ const list = this.#targets.get(name) ?? [];
168
+ this.#targets.set(name, list);
169
+ const target: Target = { focus, available: available ?? (() => true) };
170
+ list.push(target);
171
+ return () => {
172
+ const i = list.indexOf(target);
173
+ if (i >= 0) list.splice(i, 1);
174
+ };
175
+ }
176
+
177
+ /** Focus the `name` target registered last of those available (in an open region). */
178
+ focus(name: FocusTarget): boolean {
179
+ const list = this.#targets.get(name) ?? [];
180
+ for (let i = list.length - 1; i >= 0; i--) {
181
+ const target = list[i] as Target;
182
+ if (!target.available()) continue;
183
+ target.focus();
184
+ return true;
185
+ }
186
+ return false;
187
+ }
188
+
189
+ announce(text: string): void {
190
+ const live = this.live;
191
+ live.textContent = '';
192
+ // A fresh text node in a later task makes screen readers announce repeats too.
193
+ setTimeout(() => {
194
+ live.textContent = text;
195
+ }, 30);
196
+ }
197
+
198
+ /** Render every region, never throwing: a failure shows the error state with safe defaults. */
199
+ render(topics: ReadonlySet<Topic>): void {
200
+ try {
201
+ for (const region of this.#regions) region.update(topics);
202
+ this.#renderFailed = false;
203
+ } catch (error) {
204
+ console.error('[kgdiff] render failed:', error);
205
+ if (this.#renderFailed) return;
206
+ this.#renderFailed = true;
207
+ const c = this.controller;
208
+ c.explicit.clear();
209
+ c.setThemePreference('auto');
210
+ c.setPalette('default');
211
+ c.fail(error instanceof Error ? error : new Error(String(error)));
212
+ }
213
+ }
214
+
215
+ /** Apply the token set of `base` (a built-in theme) on the host, in the host's document. */
216
+ #tokens(base: Theme): void {
217
+ this.#base = base;
218
+ const doc = this.host.ownerDocument;
219
+ const tokens = tokenSheet(base, doc);
220
+ if (this.#shadow.adoptedStyleSheets.at(-1) !== tokens) {
221
+ const sheets = this.#options.sheets?.(doc) ?? [stylesheet(doc)];
222
+ this.#shadow.adoptedStyleSheets = [...sheets, tokens];
223
+ }
224
+ }
225
+
226
+ tokens(doc: Document): CSSStyleSheet {
227
+ // Resolving the theme applies the token set (and remembers its base).
228
+ if (!this.#base) void this.controller.theme;
229
+ return tokenSheet(this.#base ?? this.controller.theme, doc);
230
+ }
231
+
232
+ forwardKey(e: KeyboardEvent): void {
233
+ if (this.#options.key) this.#options.key(e);
234
+ else this.handleKey(e);
235
+ }
236
+
237
+ hover(over: boolean): void {
238
+ this.#options.hover?.(over);
239
+ }
240
+
241
+ handleKey(e: KeyboardEvent): void {
242
+ const target = e.composedPath()[0] ?? null;
243
+ if (isTypingTarget(target)) return;
244
+ const c = this.controller;
245
+ const action = keyAction(e, c.mode, c.scope, this.keymap);
246
+ if (!action) return;
247
+ if (
248
+ this.shortcutsOff &&
249
+ !(LIST_KEYS.has(action.type) && (target as Element | null)?.closest?.('[role="listbox"]'))
250
+ ) {
251
+ return;
252
+ }
253
+ // Enter / Space on a focused control activate that control.
254
+ const el = target as Element | null;
255
+ if (
256
+ (e.key === 'Enter' || e.key === ' ') &&
257
+ el?.closest?.('button, a, summary, [role="radio"], [role="menuitemradio"]')
258
+ ) {
259
+ return;
260
+ }
261
+ if (c.status !== 'ready' && !['help', 'theme', 'sidebar', 'escape'].includes(action.type))
262
+ return;
263
+ e.preventDefault();
264
+ const a = this.actions;
265
+ switch (action.type) {
266
+ case 'mode':
267
+ c.setMode(action.mode);
268
+ break;
269
+ case 'scope':
270
+ c.toggleScope();
271
+ break;
272
+ case 'next':
273
+ c.next();
274
+ break;
275
+ case 'prev':
276
+ c.prev();
277
+ break;
278
+ case 'first':
279
+ c.first();
280
+ break;
281
+ case 'last':
282
+ c.last();
283
+ break;
284
+ case 'fly':
285
+ c.flyToSelection();
286
+ break;
287
+ case 'escape':
288
+ // An active search is cleared first (focus goes back to it).
289
+ if (c.query) {
290
+ c.setQuery('');
291
+ this.focus('search');
292
+ } else c.clearSelection();
293
+ break;
294
+ case 'play-pause':
295
+ c.togglePlay();
296
+ break;
297
+ case 'step':
298
+ c.stepReplay(action.direction);
299
+ break;
300
+ case 'divider':
301
+ c.setSplit(c.split + action.delta);
302
+ break;
303
+ case 'fit':
304
+ void c.fit();
305
+ break;
306
+ case 'zoom':
307
+ c.zoom(action.direction);
308
+ break;
309
+ case 'search':
310
+ a.focusSearch();
311
+ break;
312
+ case 'review':
313
+ c.toggleReviewed();
314
+ break;
315
+ case 'sidebar':
316
+ a.toggleSidebar();
317
+ break;
318
+ case 'theme': {
319
+ const i = THEME_PREFERENCES.indexOf(c.themePreference);
320
+ a.setThemePreference(
321
+ THEME_PREFERENCES[(i + 1) % THEME_PREFERENCES.length] as ThemePreference,
322
+ );
323
+ break;
324
+ }
325
+ case 'help':
326
+ a.openHelp();
327
+ break;
328
+ case 'expand':
329
+ c.expandNearest();
330
+ break;
331
+ default:
332
+ action satisfies never;
333
+ }
334
+ }
335
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Frames (RFC 0001 §1, Scheduling).
3
+ *
4
+ * The session's frame loop runs on animation frames only (`requestFrame`). It pauses where the
5
+ * browser pauses them, so a looping replay in a background tab costs nothing, and under load it
6
+ * never draws a frame that is not presented.
7
+ *
8
+ * One-off work that must not wait forever races a frame against a timer (`frameOrTimeout`):
9
+ * the loading steps and the change list's window. A hidden embedding container (an inactive
10
+ * tab panel, a collapsed notebook output, a background pane) can pause animation frames without
11
+ * the page reporting itself hidden; with frames alone, loading stalled there.
12
+ */
13
+
14
+ /** One-off work waits at most this long for a frame (ms). */
15
+ export const FRAME_TIMEOUT = 32;
16
+
17
+ /** The next animation frame (0, and no call, where there are none). */
18
+ export function requestFrame(cb: (time: number) => void): number {
19
+ return typeof requestAnimationFrame === 'function' ? requestAnimationFrame(cb) : 0;
20
+ }
21
+
22
+ export function cancelFrame(id: number): void {
23
+ if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(id);
24
+ }
25
+
26
+ /** Call `cb` once: on the next animation frame, or after `FRAME_TIMEOUT` ms if none came. */
27
+ export function frameOrTimeout(cb: () => void): void {
28
+ let done = false;
29
+ const run = (): void => {
30
+ if (done) return;
31
+ done = true;
32
+ cancelFrame(raf);
33
+ clearTimeout(timer);
34
+ cb();
35
+ };
36
+ const timer = setTimeout(run, FRAME_TIMEOUT);
37
+ const raf = requestFrame(run);
38
+ }
39
+
40
+ /** Resolve once the browser had a chance to paint (between loading steps). */
41
+ export function nextPaint(): Promise<void> {
42
+ return new Promise((resolve) => frameOrTimeout(() => setTimeout(resolve, 0)));
43
+ }