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