@kgdiff/viewer 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +52 -2
- package/dist/compute-loader.d.ts +13 -0
- package/dist/compute-loader.d.ts.map +1 -0
- package/dist/compute-loader.js +46 -0
- package/dist/compute-loader.js.map +1 -0
- package/dist/compute.d.ts +8 -0
- package/dist/compute.d.ts.map +1 -0
- package/dist/compute.js +8 -0
- package/dist/compute.js.map +1 -0
- package/dist/controller.d.ts +199 -0
- package/dist/controller.d.ts.map +1 -0
- package/dist/controller.js +1254 -0
- package/dist/controller.js.map +1 -0
- package/dist/dom.d.ts +32 -0
- package/dist/dom.d.ts.map +1 -0
- package/dist/dom.js +105 -0
- package/dist/dom.js.map +1 -0
- package/dist/element.d.ts +43 -0
- package/dist/element.d.ts.map +1 -0
- package/dist/element.js +656 -0
- package/dist/element.js.map +1 -0
- package/dist/framing.d.ts +45 -0
- package/dist/framing.d.ts.map +1 -0
- package/dist/framing.js +107 -0
- package/dist/framing.js.map +1 -0
- package/dist/hash-sync.d.ts +18 -0
- package/dist/hash-sync.d.ts.map +1 -0
- package/dist/hash-sync.js +45 -0
- package/dist/hash-sync.js.map +1 -0
- package/dist/i18n.d.ts +223 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +436 -0
- package/dist/i18n.js.map +1 -0
- package/dist/icons.d.ts +37 -0
- package/dist/icons.d.ts.map +1 -0
- package/dist/icons.js +36 -0
- package/dist/icons.js.map +1 -0
- package/dist/index.d.ts +151 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +41 -0
- package/dist/index.js.map +1 -0
- package/dist/keyboard.d.ts +64 -0
- package/dist/keyboard.d.ts.map +1 -0
- package/dist/keyboard.js +95 -0
- package/dist/keyboard.js.map +1 -0
- package/dist/list-model.d.ts +35 -0
- package/dist/list-model.d.ts.map +1 -0
- package/dist/list-model.js +70 -0
- package/dist/list-model.js.map +1 -0
- package/dist/payload.d.ts +10 -0
- package/dist/payload.d.ts.map +1 -0
- package/dist/payload.js +138 -0
- package/dist/payload.js.map +1 -0
- package/dist/pipeline.d.ts +31 -0
- package/dist/pipeline.d.ts.map +1 -0
- package/dist/pipeline.js +122 -0
- package/dist/pipeline.js.map +1 -0
- package/dist/prefs.d.ts +18 -0
- package/dist/prefs.d.ts.map +1 -0
- package/dist/prefs.js +38 -0
- package/dist/prefs.js.map +1 -0
- package/dist/registry.d.ts +3 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +3 -0
- package/dist/registry.js.map +1 -0
- package/dist/renderer-port.d.ts +14 -0
- package/dist/renderer-port.d.ts.map +1 -0
- package/dist/renderer-port.js +16 -0
- package/dist/renderer-port.js.map +1 -0
- package/dist/replay.d.ts +55 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +147 -0
- package/dist/replay.js.map +1 -0
- package/dist/review-store.d.ts +30 -0
- package/dist/review-store.d.ts.map +1 -0
- package/dist/review-store.js +120 -0
- package/dist/review-store.js.map +1 -0
- package/dist/search.d.ts +25 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +90 -0
- package/dist/search.js.map +1 -0
- package/dist/standalone/kgdiff-viewer-compute.js +1 -0
- package/dist/standalone/kgdiff-viewer.js +5 -0
- package/dist/standalone-compute.d.ts +2 -0
- package/dist/standalone-compute.d.ts.map +1 -0
- package/dist/standalone-compute.js +8 -0
- package/dist/standalone-compute.js.map +1 -0
- package/dist/standalone.d.ts +2 -0
- package/dist/standalone.d.ts.map +1 -0
- package/dist/standalone.js +18 -0
- package/dist/standalone.js.map +1 -0
- package/dist/styles.d.ts +11 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/styles.js +627 -0
- package/dist/styles.js.map +1 -0
- package/dist/text-diff.d.ts +32 -0
- package/dist/text-diff.d.ts.map +1 -0
- package/dist/text-diff.js +211 -0
- package/dist/text-diff.js.map +1 -0
- package/dist/ui/context.d.ts +32 -0
- package/dist/ui/context.d.ts.map +1 -0
- package/dist/ui/context.js +49 -0
- package/dist/ui/context.js.map +1 -0
- package/dist/ui/detail.d.ts +9 -0
- package/dist/ui/detail.d.ts.map +1 -0
- package/dist/ui/detail.js +566 -0
- package/dist/ui/detail.js.map +1 -0
- package/dist/ui/header.d.ts +14 -0
- package/dist/ui/header.d.ts.map +1 -0
- package/dist/ui/header.js +195 -0
- package/dist/ui/header.js.map +1 -0
- package/dist/ui/popovers.d.ts +20 -0
- package/dist/ui/popovers.d.ts.map +1 -0
- package/dist/ui/popovers.js +180 -0
- package/dist/ui/popovers.js.map +1 -0
- package/dist/ui/sidebar.d.ts +25 -0
- package/dist/ui/sidebar.d.ts.map +1 -0
- package/dist/ui/sidebar.js +609 -0
- package/dist/ui/sidebar.js.map +1 -0
- package/dist/ui/stage.d.ts +18 -0
- package/dist/ui/stage.d.ts.map +1 -0
- package/dist/ui/stage.js +664 -0
- package/dist/ui/stage.js.map +1 -0
- package/package.json +58 -3
- package/src/compute-loader.ts +58 -0
- package/src/compute.ts +7 -0
- package/src/controller.ts +1393 -0
- package/src/dom.ts +131 -0
- package/src/element.ts +697 -0
- package/src/framing.ts +150 -0
- package/src/hash-sync.ts +54 -0
- package/src/i18n.ts +469 -0
- package/src/icons.ts +48 -0
- package/src/index.ts +171 -0
- package/src/keyboard.ts +128 -0
- package/src/list-model.ts +86 -0
- package/src/payload.ts +165 -0
- package/src/pipeline.ts +179 -0
- package/src/prefs.ts +55 -0
- package/src/registry.ts +2 -0
- package/src/renderer-port.ts +31 -0
- package/src/replay.ts +163 -0
- package/src/review-store.ts +125 -0
- package/src/search.ts +113 -0
- package/src/standalone-compute.ts +8 -0
- package/src/standalone.ts +18 -0
- package/src/styles.ts +638 -0
- package/src/text-diff.ts +208 -0
- package/src/ui/context.ts +70 -0
- package/src/ui/detail.ts +921 -0
- package/src/ui/header.ts +278 -0
- package/src/ui/popovers.ts +251 -0
- package/src/ui/sidebar.ts +752 -0
- package/src/ui/stage.ts +853 -0
package/src/element.ts
ADDED
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<kgdiff-viewer>`: attribute / property reflection, lifecycle, public methods and events
|
|
3
|
+
* around a `ViewerController` and the Shadow-DOM UI.
|
|
4
|
+
*/
|
|
5
|
+
import { type Delta, KgdiffError, type Locale } from '@kgdiff/core';
|
|
6
|
+
import type { PaletteName } from '@kgdiff/scene';
|
|
7
|
+
import { type ControllerHost, type Topic, ViewerController } from './controller.js';
|
|
8
|
+
import { h } from './dom.js';
|
|
9
|
+
import { formatHash, parseHash } from './hash-sync.js';
|
|
10
|
+
import { detectLocale, translate, translator } from './i18n.js';
|
|
11
|
+
import type {
|
|
12
|
+
KgdiffViewerElement,
|
|
13
|
+
LoadOptions,
|
|
14
|
+
ReportPayload,
|
|
15
|
+
ReviewState,
|
|
16
|
+
ThemePreference,
|
|
17
|
+
ViewerMode,
|
|
18
|
+
} from './index.js';
|
|
19
|
+
import { isTypingTarget, keyAction } from './keyboard.js';
|
|
20
|
+
import { assertPayload } from './payload.js';
|
|
21
|
+
import { THEME_PREFERENCES, toLocale, toMode, toPalette, toTheme } from './prefs.js';
|
|
22
|
+
import { defaultStorage } from './review-store.js';
|
|
23
|
+
import { STYLES, themeVariables } from './styles.js';
|
|
24
|
+
import type { UiContext } from './ui/context.js';
|
|
25
|
+
import { Header } from './ui/header.js';
|
|
26
|
+
import { HelpDialog, Menu } from './ui/popovers.js';
|
|
27
|
+
import { Sidebar } from './ui/sidebar.js';
|
|
28
|
+
import { Stage } from './ui/stage.js';
|
|
29
|
+
|
|
30
|
+
/** Below this width the sidebar becomes a bottom sheet and the mode control shows icons. */
|
|
31
|
+
export const COMPACT_WIDTH = 760;
|
|
32
|
+
/** Below this width the header drops the diffstat pills and version tags. */
|
|
33
|
+
const NARROW_WIDTH = 1100;
|
|
34
|
+
|
|
35
|
+
const OBSERVED = ['mode', 'theme', 'palette', 'locale', 'sidebar', 'sync-hash'] as const;
|
|
36
|
+
const UPGRADED = ['mode', 'theme', 'palette', 'locale', 'payload'] as const;
|
|
37
|
+
|
|
38
|
+
/** Keys the page itself scrolls with: never taken at document level. */
|
|
39
|
+
const PAGE_KEYS: ReadonlySet<string> = new Set([
|
|
40
|
+
'ArrowUp',
|
|
41
|
+
'ArrowDown',
|
|
42
|
+
'ArrowLeft',
|
|
43
|
+
'ArrowRight',
|
|
44
|
+
'Home',
|
|
45
|
+
'End',
|
|
46
|
+
'PageUp',
|
|
47
|
+
'PageDown',
|
|
48
|
+
' ',
|
|
49
|
+
'Spacebar',
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
/** Connected viewers, in connection order (`global-shortcuts` goes to the first opted-in one). */
|
|
53
|
+
const connected: KgdiffViewer[] = [];
|
|
54
|
+
|
|
55
|
+
function deepFreeze<T>(value: T): T {
|
|
56
|
+
if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) {
|
|
57
|
+
for (const v of Object.values(value)) deepFreeze(v);
|
|
58
|
+
Object.freeze(value);
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function media(query: string): MediaQueryList | null {
|
|
64
|
+
return typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
|
65
|
+
? window.matchMedia(query)
|
|
66
|
+
: null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Resolve once the browser had a chance to paint the loading step. Hidden tabs never run
|
|
71
|
+
* animation frames, so a short timer resolves it there (loading must never stall).
|
|
72
|
+
*/
|
|
73
|
+
function nextPaint(): Promise<void> {
|
|
74
|
+
return new Promise((resolve) => {
|
|
75
|
+
let done = false;
|
|
76
|
+
const finish = (): void => {
|
|
77
|
+
if (done) return;
|
|
78
|
+
done = true;
|
|
79
|
+
resolve();
|
|
80
|
+
};
|
|
81
|
+
if (typeof requestAnimationFrame === 'function') {
|
|
82
|
+
requestAnimationFrame(() => setTimeout(finish, 0));
|
|
83
|
+
}
|
|
84
|
+
setTimeout(finish, 50);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
89
|
+
static get observedAttributes(): readonly string[] {
|
|
90
|
+
return OBSERVED;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** @internal */
|
|
94
|
+
readonly controller: ViewerController;
|
|
95
|
+
readonly #shadow: ShadowRoot;
|
|
96
|
+
#ui: {
|
|
97
|
+
root: HTMLElement;
|
|
98
|
+
vars: HTMLStyleElement;
|
|
99
|
+
header: Header;
|
|
100
|
+
stage: Stage;
|
|
101
|
+
sidebar: Sidebar;
|
|
102
|
+
menu: Menu;
|
|
103
|
+
help: HelpDialog;
|
|
104
|
+
live: HTMLElement;
|
|
105
|
+
} | null = null;
|
|
106
|
+
#currentPayload: ReportPayload | null = null;
|
|
107
|
+
#renderFailed = false;
|
|
108
|
+
#reflecting = false;
|
|
109
|
+
#compact = false;
|
|
110
|
+
readonly #cleanup: (() => void)[] = [];
|
|
111
|
+
#lastHash = '';
|
|
112
|
+
#upgraded = false;
|
|
113
|
+
|
|
114
|
+
constructor() {
|
|
115
|
+
super();
|
|
116
|
+
this.#shadow = this.attachShadow({ mode: 'open' });
|
|
117
|
+
const host: ControllerHost = {
|
|
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) => {
|
|
124
|
+
this.#emit('kgdiff-mode', { mode });
|
|
125
|
+
this.#writeHash();
|
|
126
|
+
},
|
|
127
|
+
review: (hunk, reviewed, progress) =>
|
|
128
|
+
this.#emit('kgdiff-review', { hunk, reviewed, progress }),
|
|
129
|
+
error: (error) => this.#emit('kgdiff-error', { error }),
|
|
130
|
+
announce: (text) => this.#announce(text),
|
|
131
|
+
};
|
|
132
|
+
this.controller = new ViewerController(host, {
|
|
133
|
+
now: () => (typeof performance !== 'undefined' ? performance.now() : Date.now()),
|
|
134
|
+
requestFrame: (cb) =>
|
|
135
|
+
typeof requestAnimationFrame === 'function' ? requestAnimationFrame(cb) : 0,
|
|
136
|
+
cancelFrame: (id) => {
|
|
137
|
+
if (typeof cancelAnimationFrame === 'function') 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));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─── Lifecycle ────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
connectedCallback(): void {
|
|
149
|
+
if (!this.#upgraded) {
|
|
150
|
+
this.#upgraded = true;
|
|
151
|
+
// Properties set before the element was defined shadow the accessors: re-apply them.
|
|
152
|
+
for (const prop of UPGRADED) {
|
|
153
|
+
if (Object.hasOwn(this, prop)) {
|
|
154
|
+
const value = (this as unknown as Record<string, unknown>)[prop];
|
|
155
|
+
delete (this as unknown as Record<string, unknown>)[prop];
|
|
156
|
+
(this as unknown as Record<string, unknown>)[prop] = value;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (!this.#ui) this.#build();
|
|
161
|
+
connected.push(this);
|
|
162
|
+
const ui = this.#ui;
|
|
163
|
+
if (!ui) return;
|
|
164
|
+
this.controller.attach(ui.stage.canvasHost, ui.stage.minimapHost);
|
|
165
|
+
// The graph takes keyboard focus (shortcuts apply where focus is), also on click.
|
|
166
|
+
const canvas = ui.stage.canvasHost.querySelector('canvas');
|
|
167
|
+
if (canvas) {
|
|
168
|
+
canvas.tabIndex = 0;
|
|
169
|
+
const focusCanvas = (): void => canvas.focus({ preventScroll: true });
|
|
170
|
+
canvas.addEventListener('pointerdown', focusCanvas);
|
|
171
|
+
this.#cleanup.push(() => canvas.removeEventListener('pointerdown', focusCanvas));
|
|
172
|
+
}
|
|
173
|
+
this.#watchMedia();
|
|
174
|
+
this.#watchSize();
|
|
175
|
+
const onKey = (e: KeyboardEvent): void => this.#onKeyDown(e);
|
|
176
|
+
document.addEventListener('keydown', onKey);
|
|
177
|
+
this.#cleanup.push(() => document.removeEventListener('keydown', onKey));
|
|
178
|
+
const onStorage = (e: StorageEvent): void => this.controller.syncReview(e.key);
|
|
179
|
+
window.addEventListener('storage', onStorage);
|
|
180
|
+
this.#cleanup.push(() => window.removeEventListener('storage', onStorage));
|
|
181
|
+
const onHash = (): void => this.#readHash();
|
|
182
|
+
window.addEventListener('hashchange', onHash);
|
|
183
|
+
this.#cleanup.push(() => window.removeEventListener('hashchange', onHash));
|
|
184
|
+
this.controller.setChrome(() => ui.stage.chromeLayout());
|
|
185
|
+
document.fonts?.ready?.then(() => this.#ui?.header.layout()).catch(() => undefined);
|
|
186
|
+
this.#readHash();
|
|
187
|
+
this.#render(
|
|
188
|
+
new Set<Topic>([
|
|
189
|
+
'status',
|
|
190
|
+
'data',
|
|
191
|
+
'mode',
|
|
192
|
+
'theme',
|
|
193
|
+
'locale',
|
|
194
|
+
'filters',
|
|
195
|
+
'review',
|
|
196
|
+
'selection',
|
|
197
|
+
'focus',
|
|
198
|
+
'swipe',
|
|
199
|
+
'replay',
|
|
200
|
+
]),
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
disconnectedCallback(): void {
|
|
205
|
+
const i = connected.indexOf(this);
|
|
206
|
+
if (i >= 0) connected.splice(i, 1);
|
|
207
|
+
for (const fn of this.#cleanup.splice(0)) fn();
|
|
208
|
+
this.controller.detach();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
attributeChangedCallback(name: string, _old: string | null, value: string | null): void {
|
|
212
|
+
if (this.#reflecting) return;
|
|
213
|
+
switch (name) {
|
|
214
|
+
case 'mode':
|
|
215
|
+
case 'theme':
|
|
216
|
+
case 'palette':
|
|
217
|
+
case 'locale':
|
|
218
|
+
this.#applyPreference(name, value, false);
|
|
219
|
+
break;
|
|
220
|
+
case 'sidebar':
|
|
221
|
+
this.#applySidebar();
|
|
222
|
+
break;
|
|
223
|
+
case 'sync-hash':
|
|
224
|
+
if (value !== null) this.#readHash();
|
|
225
|
+
break;
|
|
226
|
+
default:
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Apply a preference from an attribute or a property. Valid values are explicit (a report's
|
|
233
|
+
* `view` does not override them); invalid ones fall back to the default (`overlay`, `auto`,
|
|
234
|
+
* `default`, the browser's language). Properties reflect the normalized value.
|
|
235
|
+
*/
|
|
236
|
+
#applyPreference(
|
|
237
|
+
name: 'mode' | 'theme' | 'palette' | 'locale',
|
|
238
|
+
raw: unknown,
|
|
239
|
+
reflect: boolean,
|
|
240
|
+
): void {
|
|
241
|
+
const c = this.controller;
|
|
242
|
+
let value: string | null;
|
|
243
|
+
switch (name) {
|
|
244
|
+
case 'mode': {
|
|
245
|
+
const v = toMode(raw);
|
|
246
|
+
c.setMode(v ?? 'overlay');
|
|
247
|
+
value = v;
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
case 'theme': {
|
|
251
|
+
const v = toTheme(raw);
|
|
252
|
+
c.setThemePreference(v ?? 'auto');
|
|
253
|
+
value = v;
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
case 'palette': {
|
|
257
|
+
const v = toPalette(raw);
|
|
258
|
+
c.setPalette(v ?? 'default');
|
|
259
|
+
value = v;
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
case 'locale': {
|
|
263
|
+
const v = toLocale(raw);
|
|
264
|
+
c.setLocale(v ?? detectLocale());
|
|
265
|
+
value = v;
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
default:
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (value === null) c.explicit.delete(name);
|
|
272
|
+
else c.explicit.add(name);
|
|
273
|
+
if (reflect) this.#reflect(name, value);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ─── Properties ───────────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
get mode(): ViewerMode {
|
|
279
|
+
return this.controller.mode;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
set mode(value: ViewerMode) {
|
|
283
|
+
this.#applyPreference('mode', value, true);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
get theme(): ThemePreference {
|
|
287
|
+
return this.controller.themePreference;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
set theme(value: ThemePreference) {
|
|
291
|
+
this.#applyPreference('theme', value, true);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
get palette(): PaletteName {
|
|
295
|
+
return this.controller.palette;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
set palette(value: PaletteName) {
|
|
299
|
+
this.#applyPreference('palette', value, true);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
get locale(): Locale {
|
|
303
|
+
return this.controller.locale;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
set locale(value: Locale) {
|
|
307
|
+
this.#applyPreference('locale', value, true);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
get payload(): ReportPayload | null {
|
|
311
|
+
return this.#currentPayload;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
set payload(value: ReportPayload | null) {
|
|
315
|
+
this.#currentPayload = value;
|
|
316
|
+
if (value === null || value === undefined) return;
|
|
317
|
+
let payload: ReportPayload;
|
|
318
|
+
try {
|
|
319
|
+
payload = assertPayload(value);
|
|
320
|
+
} catch (error) {
|
|
321
|
+
this.controller.fail(error as Error);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
this.controller.loadPayload(payload).catch(() => {
|
|
325
|
+
// Already surfaced: error state + `kgdiff-error`.
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ─── Methods ──────────────────────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
load(options: LoadOptions): Promise<void> {
|
|
332
|
+
this.#currentPayload = null;
|
|
333
|
+
return this.controller.load(options);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
selectHunk(id: string | null): void {
|
|
337
|
+
this.controller.selectHunkById(id, { fly: true });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
next(): void {
|
|
341
|
+
this.controller.next();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
prev(): void {
|
|
345
|
+
this.controller.prev();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
play(): void {
|
|
349
|
+
this.controller.play();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
pause(): void {
|
|
353
|
+
this.controller.pause();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
fit(): Promise<void> {
|
|
357
|
+
return this.controller.fit();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
exportDelta(): Delta {
|
|
361
|
+
const diff = this.controller.diff;
|
|
362
|
+
if (!diff) throw new KgdiffError('invalid-input', translate('en', 'error.notLoaded'));
|
|
363
|
+
// A copy: callers can never mutate the diff the viewer shows.
|
|
364
|
+
return deepFreeze(structuredClone(diff.delta));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
reviewState(): ReviewState {
|
|
368
|
+
return this.controller.reviewState();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
setReviewed(hunkId: string, reviewed: boolean): void {
|
|
372
|
+
this.controller.setReviewed(hunkId, reviewed);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Show an error state (used for undecodable embedded payloads). */
|
|
376
|
+
showError(error: Error): void {
|
|
377
|
+
this.controller.fail(error);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ─── Internals ────────────────────────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
#emit(type: string, detail: unknown): void {
|
|
383
|
+
this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
#reflect(name: string, value: string | null): void {
|
|
387
|
+
if (this.getAttribute(name) === value) return;
|
|
388
|
+
this.#reflecting = true;
|
|
389
|
+
try {
|
|
390
|
+
if (value === null) this.removeAttribute(name);
|
|
391
|
+
else this.setAttribute(name, value);
|
|
392
|
+
} finally {
|
|
393
|
+
this.#reflecting = false;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
#announce(text: string): void {
|
|
398
|
+
const live = this.#ui?.live;
|
|
399
|
+
if (!live) return;
|
|
400
|
+
live.textContent = '';
|
|
401
|
+
// A fresh text node in a later task makes screen readers announce repeats too.
|
|
402
|
+
setTimeout(() => {
|
|
403
|
+
live.textContent = text;
|
|
404
|
+
}, 30);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
get #sidebarOpen(): boolean {
|
|
408
|
+
return this.getAttribute('sidebar') !== 'closed';
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
#applySidebar(): void {
|
|
412
|
+
const ui = this.#ui;
|
|
413
|
+
if (!ui) return;
|
|
414
|
+
ui.sidebar.setOpen(this.#sidebarOpen);
|
|
415
|
+
ui.header.setSidebarState(this.#sidebarOpen);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
#build(): void {
|
|
419
|
+
const style = h('style', { text: STYLES });
|
|
420
|
+
const vars = h('style');
|
|
421
|
+
const ctx: UiContext = {
|
|
422
|
+
controller: this.controller,
|
|
423
|
+
tr: () => translator(this.controller.locale),
|
|
424
|
+
root: this.#shadow,
|
|
425
|
+
setMode: (mode) => {
|
|
426
|
+
this.controller.setMode(mode);
|
|
427
|
+
},
|
|
428
|
+
setThemePreference: (pref) => {
|
|
429
|
+
this.theme = pref;
|
|
430
|
+
},
|
|
431
|
+
setPalette: (p) => {
|
|
432
|
+
this.palette = p;
|
|
433
|
+
},
|
|
434
|
+
setLocale: (l) => {
|
|
435
|
+
this.locale = l;
|
|
436
|
+
},
|
|
437
|
+
toggleSidebar: (open) => this.#toggleSidebar(open),
|
|
438
|
+
sidebarOpen: () => this.#sidebarOpen,
|
|
439
|
+
toggleMenu: (open) => this.#toggleMenu(open),
|
|
440
|
+
openHelp: () => this.#ui?.help.show(),
|
|
441
|
+
focusSearch: () => this.#focusSearch(),
|
|
442
|
+
};
|
|
443
|
+
const header = new Header(ctx);
|
|
444
|
+
const stage = new Stage(ctx);
|
|
445
|
+
const sidebar = new Sidebar(ctx);
|
|
446
|
+
const menu = new Menu(ctx);
|
|
447
|
+
const help = new HelpDialog(ctx);
|
|
448
|
+
const live = h('div', { class: 'sr-only', 'aria-live': 'polite', 'aria-atomic': 'true' });
|
|
449
|
+
const root = h(
|
|
450
|
+
'div',
|
|
451
|
+
{ class: 'root' },
|
|
452
|
+
header.el,
|
|
453
|
+
h('div', { class: 'body' }, stage.el, sidebar.el),
|
|
454
|
+
menu.el,
|
|
455
|
+
help.el,
|
|
456
|
+
live,
|
|
457
|
+
);
|
|
458
|
+
this.#shadow.append(style, vars, root);
|
|
459
|
+
this.#ui = { root, vars, header, stage, sidebar, menu, help, live };
|
|
460
|
+
// Close the menu on outside clicks.
|
|
461
|
+
root.addEventListener('pointerdown', (e) => {
|
|
462
|
+
if (!menu.open) return;
|
|
463
|
+
const path = e.composedPath();
|
|
464
|
+
if (!path.includes(menu.el) && !path.includes(header.menuButton)) this.#toggleMenu(false);
|
|
465
|
+
});
|
|
466
|
+
this.#applySidebar();
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Render, never throwing: a failure shows the error state with safe appearance defaults. */
|
|
470
|
+
#render(topics: ReadonlySet<Topic>): void {
|
|
471
|
+
try {
|
|
472
|
+
this.#renderTopics(topics);
|
|
473
|
+
this.#renderFailed = false;
|
|
474
|
+
} catch (error) {
|
|
475
|
+
console.error('[kgdiff] render failed:', error);
|
|
476
|
+
if (this.#renderFailed) return;
|
|
477
|
+
this.#renderFailed = true;
|
|
478
|
+
const c = this.controller;
|
|
479
|
+
c.explicit.clear();
|
|
480
|
+
c.setThemePreference('auto');
|
|
481
|
+
c.setPalette('default');
|
|
482
|
+
c.fail(error instanceof Error ? error : new Error(String(error)));
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
#renderTopics(topics: ReadonlySet<Topic>): void {
|
|
487
|
+
const ui = this.#ui;
|
|
488
|
+
const c = this.controller;
|
|
489
|
+
if (topics.has('mode')) this.#reflect('mode', c.mode);
|
|
490
|
+
if (topics.has('theme')) {
|
|
491
|
+
this.#reflect(
|
|
492
|
+
'theme',
|
|
493
|
+
c.themePreference === 'auto' && !this.hasAttribute('theme') ? null : c.themePreference,
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
if (!ui) return;
|
|
497
|
+
if (topics.has('theme') || topics.has('status')) {
|
|
498
|
+
ui.vars.textContent = `:host { ${themeVariables(c.theme)} }`;
|
|
499
|
+
ui.root.dataset.theme = c.themeName;
|
|
500
|
+
}
|
|
501
|
+
if (topics.has('locale') || topics.has('status')) {
|
|
502
|
+
ui.root.setAttribute('lang', c.locale);
|
|
503
|
+
if (ui.menu.open) ui.menu.render();
|
|
504
|
+
if (ui.help.open) ui.help.render();
|
|
505
|
+
}
|
|
506
|
+
if (topics.has('theme') && ui.menu.open) ui.menu.render();
|
|
507
|
+
ui.header.update(topics);
|
|
508
|
+
ui.sidebar.update(topics);
|
|
509
|
+
ui.stage.update(topics);
|
|
510
|
+
if (topics.has('mode') || topics.has('status')) ui.root.dataset.mode = c.mode;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
#toggleSidebar(open?: boolean): void {
|
|
514
|
+
const next = open ?? !this.#sidebarOpen;
|
|
515
|
+
this.#reflect('sidebar', next ? 'open' : 'closed');
|
|
516
|
+
this.#applySidebar();
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
#toggleMenu(open?: boolean): void {
|
|
520
|
+
const ui = this.#ui;
|
|
521
|
+
if (!ui) return;
|
|
522
|
+
const next = open ?? !ui.menu.open;
|
|
523
|
+
if (next) ui.menu.show(ui.header.menuButton);
|
|
524
|
+
else ui.menu.hide(false);
|
|
525
|
+
ui.header.setMenuState(next);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
#focusSearch(): void {
|
|
529
|
+
if (!this.#sidebarOpen) this.#toggleSidebar(true);
|
|
530
|
+
this.#ui?.sidebar.focusSearch();
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
#watchMedia(): void {
|
|
534
|
+
const dark = media('(prefers-color-scheme: light)');
|
|
535
|
+
const motion = media('(prefers-reduced-motion: reduce)');
|
|
536
|
+
const apply = (): void => {
|
|
537
|
+
this.controller.setSystemDark(!(dark?.matches ?? false));
|
|
538
|
+
this.controller.setReducedMotion(motion?.matches ?? false);
|
|
539
|
+
};
|
|
540
|
+
apply();
|
|
541
|
+
for (const q of [dark, motion]) {
|
|
542
|
+
if (!q) continue;
|
|
543
|
+
q.addEventListener?.('change', apply);
|
|
544
|
+
this.#cleanup.push(() => q.removeEventListener?.('change', apply));
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
#watchSize(): void {
|
|
549
|
+
if (typeof ResizeObserver !== 'function') return;
|
|
550
|
+
const ro = new ResizeObserver(() => {
|
|
551
|
+
const ui = this.#ui;
|
|
552
|
+
if (!ui) return;
|
|
553
|
+
const width = this.clientWidth;
|
|
554
|
+
const compact = width > 0 && width < COMPACT_WIDTH;
|
|
555
|
+
ui.root.dataset.narrow = String(width > 0 && width < NARROW_WIDTH);
|
|
556
|
+
if (compact !== this.#compact) {
|
|
557
|
+
this.#compact = compact;
|
|
558
|
+
ui.root.dataset.compact = String(compact);
|
|
559
|
+
}
|
|
560
|
+
ui.header.layout();
|
|
561
|
+
ui.sidebar.renderWindow();
|
|
562
|
+
if (compact) ui.sidebar.syncSheet();
|
|
563
|
+
this.controller.layoutChanged();
|
|
564
|
+
});
|
|
565
|
+
ro.observe(this);
|
|
566
|
+
this.#cleanup.push(() => ro.disconnect());
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
#onKeyDown(e: KeyboardEvent): void {
|
|
570
|
+
if (e.defaultPrevented || e.isComposing) return;
|
|
571
|
+
const ui = this.#ui;
|
|
572
|
+
if (!ui) return;
|
|
573
|
+
const path = e.composedPath();
|
|
574
|
+
if (!path.includes(this)) {
|
|
575
|
+
// Outside the component only with `global-shortcuts`, when nothing else has focus, for
|
|
576
|
+
// the first such viewer, and never the keys pages scroll with.
|
|
577
|
+
if (!this.hasAttribute('global-shortcuts') || PAGE_KEYS.has(e.key)) return;
|
|
578
|
+
const active = document.activeElement;
|
|
579
|
+
if (active && active !== document.body && active !== document.documentElement) return;
|
|
580
|
+
if (connected.find((v) => v.hasAttribute('global-shortcuts')) !== this) return;
|
|
581
|
+
}
|
|
582
|
+
if (ui.help.open || ui.menu.open) {
|
|
583
|
+
if (e.key === 'Escape') {
|
|
584
|
+
e.preventDefault();
|
|
585
|
+
ui.help.hide();
|
|
586
|
+
this.#toggleMenu(false);
|
|
587
|
+
}
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
const target = path[0] ?? null;
|
|
591
|
+
if (isTypingTarget(target)) return;
|
|
592
|
+
const c = this.controller;
|
|
593
|
+
const action = keyAction(e, c.mode);
|
|
594
|
+
if (!action) return;
|
|
595
|
+
// Enter / Space on a focused control activate that control.
|
|
596
|
+
const el = target as Element | null;
|
|
597
|
+
if (
|
|
598
|
+
(e.key === 'Enter' || e.key === ' ') &&
|
|
599
|
+
el?.closest?.('button, a, summary, [role="radio"], [role="menuitemradio"]')
|
|
600
|
+
) {
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (c.status !== 'ready' && !['help', 'theme', 'sidebar', 'escape'].includes(action.type))
|
|
604
|
+
return;
|
|
605
|
+
e.preventDefault();
|
|
606
|
+
switch (action.type) {
|
|
607
|
+
case 'mode':
|
|
608
|
+
c.setMode(action.mode);
|
|
609
|
+
break;
|
|
610
|
+
case 'next':
|
|
611
|
+
c.next();
|
|
612
|
+
break;
|
|
613
|
+
case 'prev':
|
|
614
|
+
c.prev();
|
|
615
|
+
break;
|
|
616
|
+
case 'first':
|
|
617
|
+
c.first();
|
|
618
|
+
break;
|
|
619
|
+
case 'last':
|
|
620
|
+
c.last();
|
|
621
|
+
break;
|
|
622
|
+
case 'fly':
|
|
623
|
+
c.flyToSelection();
|
|
624
|
+
break;
|
|
625
|
+
case 'escape':
|
|
626
|
+
if (!ui.sidebar.escape()) c.clearSelection();
|
|
627
|
+
break;
|
|
628
|
+
case 'play-pause':
|
|
629
|
+
c.togglePlay();
|
|
630
|
+
break;
|
|
631
|
+
case 'step':
|
|
632
|
+
c.stepReplay(action.direction);
|
|
633
|
+
break;
|
|
634
|
+
case 'divider':
|
|
635
|
+
c.setSplit(c.split + action.delta);
|
|
636
|
+
break;
|
|
637
|
+
case 'fit':
|
|
638
|
+
void c.fit();
|
|
639
|
+
break;
|
|
640
|
+
case 'zoom':
|
|
641
|
+
c.zoom(action.direction);
|
|
642
|
+
break;
|
|
643
|
+
case 'search':
|
|
644
|
+
this.#focusSearch();
|
|
645
|
+
break;
|
|
646
|
+
case 'review':
|
|
647
|
+
c.toggleReviewed();
|
|
648
|
+
break;
|
|
649
|
+
case 'sidebar':
|
|
650
|
+
this.#toggleSidebar();
|
|
651
|
+
break;
|
|
652
|
+
case 'theme': {
|
|
653
|
+
const i = THEME_PREFERENCES.indexOf(c.themePreference);
|
|
654
|
+
this.theme = THEME_PREFERENCES[(i + 1) % THEME_PREFERENCES.length] as ThemePreference;
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
case 'help':
|
|
658
|
+
ui.help.show();
|
|
659
|
+
break;
|
|
660
|
+
case 'expand':
|
|
661
|
+
c.expandNearest();
|
|
662
|
+
break;
|
|
663
|
+
default:
|
|
664
|
+
action satisfies never;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// ─── Deep links ───────────────────────────────────────────────────────────
|
|
669
|
+
|
|
670
|
+
#readHash(): void {
|
|
671
|
+
if (!this.hasAttribute('sync-hash') || typeof location === 'undefined') return;
|
|
672
|
+
if (location.hash === this.#lastHash) return;
|
|
673
|
+
const state = parseHash(location.hash);
|
|
674
|
+
this.#lastHash = location.hash;
|
|
675
|
+
if (!state) return;
|
|
676
|
+
if (state.mode) {
|
|
677
|
+
// A deep link outranks the large-graph default and a report's preferred mode.
|
|
678
|
+
this.controller.explicit.add('mode');
|
|
679
|
+
this.controller.setMode(state.mode);
|
|
680
|
+
}
|
|
681
|
+
if (state.hunk) this.controller.selectHunkById(state.hunk, { fly: true });
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
#writeHash(): void {
|
|
685
|
+
if (!this.hasAttribute('sync-hash') || typeof location === 'undefined') return;
|
|
686
|
+
const c = this.controller;
|
|
687
|
+
const hunk = c.selectedHunk >= 0 ? c.scene?.hunks[c.selectedHunk]?.id : undefined;
|
|
688
|
+
const hash = formatHash({ ...(hunk !== undefined ? { hunk } : {}), mode: c.mode });
|
|
689
|
+
if (hash === location.hash) return;
|
|
690
|
+
this.#lastHash = hash;
|
|
691
|
+
try {
|
|
692
|
+
history.replaceState(history.state, '', hash);
|
|
693
|
+
} catch {
|
|
694
|
+
location.hash = hash;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|