@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.
- package/README.md +43 -16
- package/custom-elements.json +2347 -0
- package/dist/bind.d.ts +41 -0
- package/dist/bind.d.ts.map +1 -0
- package/dist/bind.js +47 -0
- package/dist/bind.js.map +1 -0
- package/dist/controller.d.ts +22 -74
- package/dist/controller.d.ts.map +1 -1
- package/dist/controller.js +35 -376
- package/dist/controller.js.map +1 -1
- package/dist/dom.d.ts +10 -0
- package/dist/dom.d.ts.map +1 -1
- package/dist/dom.js +29 -0
- package/dist/dom.js.map +1 -1
- package/dist/element.d.ts +52 -46
- package/dist/element.d.ts.map +1 -1
- package/dist/element.js +362 -403
- package/dist/element.js.map +1 -1
- package/dist/elements/actions.d.ts +27 -0
- package/dist/elements/actions.d.ts.map +1 -0
- package/dist/elements/actions.js +81 -0
- package/dist/elements/actions.js.map +1 -0
- package/dist/elements/bound.d.ts +63 -0
- package/dist/elements/bound.d.ts.map +1 -0
- package/dist/elements/bound.js +213 -0
- package/dist/elements/bound.js.map +1 -0
- package/dist/elements/change-detail.d.ts +25 -0
- package/dist/elements/change-detail.d.ts.map +1 -0
- package/dist/elements/change-detail.js +19 -0
- package/dist/elements/change-detail.js.map +1 -0
- package/dist/elements/change-list.d.ts +27 -0
- package/dist/elements/change-list.d.ts.map +1 -0
- package/dist/elements/change-list.js +29 -0
- package/dist/elements/change-list.js.map +1 -0
- package/dist/elements/filters.d.ts +25 -0
- package/dist/elements/filters.d.ts.map +1 -0
- package/dist/elements/filters.js +19 -0
- package/dist/elements/filters.js.map +1 -0
- package/dist/elements/legend.d.ts +40 -0
- package/dist/elements/legend.d.ts.map +1 -0
- package/dist/elements/legend.js +87 -0
- package/dist/elements/legend.js.map +1 -0
- package/dist/elements/minimap.d.ts +38 -0
- package/dist/elements/minimap.d.ts.map +1 -0
- package/dist/elements/minimap.js +59 -0
- package/dist/elements/minimap.js.map +1 -0
- package/dist/elements/root.d.ts +40 -0
- package/dist/elements/root.d.ts.map +1 -0
- package/dist/elements/root.js +67 -0
- package/dist/elements/root.js.map +1 -0
- package/dist/elements/search.d.ts +29 -0
- package/dist/elements/search.d.ts.map +1 -0
- package/dist/elements/search.js +23 -0
- package/dist/elements/search.js.map +1 -0
- package/dist/elements/stage-context.d.ts +31 -0
- package/dist/elements/stage-context.d.ts.map +1 -0
- package/dist/elements/stage-context.js +4 -0
- package/dist/elements/stage-context.js.map +1 -0
- package/dist/elements/stage.d.ts +65 -0
- package/dist/elements/stage.d.ts.map +1 -0
- package/dist/elements/stage.js +259 -0
- package/dist/elements/stage.js.map +1 -0
- package/dist/elements/title.d.ts +25 -0
- package/dist/elements/title.d.ts.map +1 -0
- package/dist/elements/title.js +19 -0
- package/dist/elements/title.js.map +1 -0
- package/dist/elements/transport.d.ts +29 -0
- package/dist/elements/transport.d.ts.map +1 -0
- package/dist/elements/transport.js +24 -0
- package/dist/elements/transport.js.map +1 -0
- package/dist/elements/view-bound.d.ts +39 -0
- package/dist/elements/view-bound.d.ts.map +1 -0
- package/dist/elements/view-bound.js +88 -0
- package/dist/elements/view-bound.js.map +1 -0
- package/dist/elements/view-dock.d.ts +28 -0
- package/dist/elements/view-dock.d.ts.map +1 -0
- package/dist/elements/view-dock.js +28 -0
- package/dist/elements/view-dock.js.map +1 -0
- package/dist/elements/zoom.d.ts +28 -0
- package/dist/elements/zoom.d.ts.map +1 -0
- package/dist/elements/zoom.js +22 -0
- package/dist/elements/zoom.js.map +1 -0
- package/dist/i18n.d.ts +10 -0
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +20 -0
- package/dist/i18n.js.map +1 -1
- package/dist/icons.d.ts +1 -0
- package/dist/icons.d.ts.map +1 -1
- package/dist/icons.js +1 -0
- package/dist/icons.js.map +1 -1
- package/dist/index.d.ts +328 -34
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -1
- package/dist/keyboard.d.ts +39 -2
- package/dist/keyboard.d.ts.map +1 -1
- package/dist/keyboard.js +145 -62
- package/dist/keyboard.js.map +1 -1
- package/dist/layout-store.d.ts +26 -0
- package/dist/layout-store.d.ts.map +1 -0
- package/dist/layout-store.js +46 -0
- package/dist/layout-store.js.map +1 -0
- package/dist/panel-levels.d.ts +18 -0
- package/dist/panel-levels.d.ts.map +1 -0
- package/dist/panel-levels.js +41 -0
- package/dist/panel-levels.js.map +1 -0
- package/dist/panels.d.ts +28 -0
- package/dist/panels.d.ts.map +1 -0
- package/dist/panels.js +73 -0
- package/dist/panels.js.map +1 -0
- package/dist/prefs.d.ts +5 -2
- package/dist/prefs.d.ts.map +1 -1
- package/dist/prefs.js +12 -3
- package/dist/prefs.js.map +1 -1
- package/dist/react.d.ts +460 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +2 -0
- package/dist/react.js.map +1 -0
- package/dist/root-element.d.ts +122 -0
- package/dist/root-element.d.ts.map +1 -0
- package/dist/root-element.js +541 -0
- package/dist/root-element.js.map +1 -0
- package/dist/root.d.ts +30 -30
- package/dist/root.d.ts.map +1 -1
- package/dist/root.js +65 -42
- package/dist/root.js.map +1 -1
- package/dist/session.d.ts +19 -3
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +15 -17
- package/dist/session.js.map +1 -1
- package/dist/standalone/kgdiff-viewer.js +4 -4
- package/dist/styles.d.ts +4 -0
- package/dist/styles.d.ts.map +1 -1
- package/dist/styles.js +224 -84
- package/dist/styles.js.map +1 -1
- package/dist/theme.d.ts +7 -0
- package/dist/theme.d.ts.map +1 -1
- package/dist/theme.js +10 -7
- package/dist/theme.js.map +1 -1
- package/dist/topics.d.ts +8 -0
- package/dist/topics.d.ts.map +1 -0
- package/dist/topics.js +18 -0
- package/dist/topics.js.map +1 -0
- package/dist/ui/change-list.d.ts.map +1 -1
- package/dist/ui/change-list.js +2 -2
- package/dist/ui/change-list.js.map +1 -1
- package/dist/ui/context.d.ts +20 -7
- package/dist/ui/context.d.ts.map +1 -1
- package/dist/ui/context.js.map +1 -1
- package/dist/ui/header.d.ts +41 -3
- package/dist/ui/header.d.ts.map +1 -1
- package/dist/ui/header.js +99 -34
- package/dist/ui/header.js.map +1 -1
- package/dist/ui/legend.d.ts +15 -0
- package/dist/ui/legend.d.ts.map +1 -0
- package/dist/ui/legend.js +68 -0
- package/dist/ui/legend.js.map +1 -0
- package/dist/ui/popovers.d.ts +3 -1
- package/dist/ui/popovers.d.ts.map +1 -1
- package/dist/ui/popovers.js +62 -23
- package/dist/ui/popovers.js.map +1 -1
- package/dist/ui/search.d.ts.map +1 -1
- package/dist/ui/search.js +3 -2
- package/dist/ui/search.js.map +1 -1
- package/dist/ui/sidebar.d.ts +26 -3
- package/dist/ui/sidebar.d.ts.map +1 -1
- package/dist/ui/sidebar.js +344 -12
- package/dist/ui/sidebar.js.map +1 -1
- package/dist/ui/stage.d.ts +56 -1
- package/dist/ui/stage.d.ts.map +1 -1
- package/dist/ui/stage.js +244 -256
- package/dist/ui/stage.js.map +1 -1
- package/dist/ui/transport.d.ts +12 -0
- package/dist/ui/transport.d.ts.map +1 -0
- package/dist/ui/transport.js +198 -0
- package/dist/ui/transport.js.map +1 -0
- package/dist/ui/zoom.d.ts +15 -0
- package/dist/ui/zoom.d.ts.map +1 -0
- package/dist/ui/zoom.js +30 -0
- package/dist/ui/zoom.js.map +1 -0
- package/dist/view.d.ts +94 -0
- package/dist/view.d.ts.map +1 -0
- package/dist/view.js +441 -0
- package/dist/view.js.map +1 -0
- package/package.json +95 -12
- package/src/bind.ts +80 -0
- package/src/controller.ts +51 -442
- package/src/dom.ts +30 -0
- package/src/element.ts +364 -406
- package/src/elements/actions.ts +94 -0
- package/src/elements/bound.ts +247 -0
- package/src/elements/change-detail.ts +34 -0
- package/src/elements/change-list.ts +51 -0
- package/src/elements/filters.ts +34 -0
- package/src/elements/legend.ts +103 -0
- package/src/elements/minimap.ts +75 -0
- package/src/elements/root.ts +86 -0
- package/src/elements/search.ts +38 -0
- package/src/elements/stage-context.ts +34 -0
- package/src/elements/stage.ts +285 -0
- package/src/elements/title.ts +34 -0
- package/src/elements/transport.ts +39 -0
- package/src/elements/view-bound.ts +118 -0
- package/src/elements/view-dock.ts +43 -0
- package/src/elements/zoom.ts +38 -0
- package/src/i18n.ts +20 -0
- package/src/icons.ts +1 -0
- package/src/index.ts +347 -34
- package/src/keyboard.ts +188 -61
- package/src/layout-store.ts +71 -0
- package/src/panel-levels.ts +53 -0
- package/src/panels.ts +81 -0
- package/src/prefs.ts +12 -4
- package/src/react.ts +459 -0
- package/src/root-element.ts +624 -0
- package/src/root.ts +92 -65
- package/src/session.ts +22 -33
- package/src/styles.ts +245 -84
- package/src/theme.ts +10 -7
- package/src/topics.ts +37 -0
- package/src/ui/change-list.ts +6 -2
- package/src/ui/context.ts +20 -7
- package/src/ui/header.ts +127 -56
- package/src/ui/legend.ts +112 -0
- package/src/ui/popovers.ts +68 -25
- package/src/ui/search.ts +10 -5
- package/src/ui/sidebar.ts +386 -23
- package/src/ui/stage.ts +267 -331
- package/src/ui/transport.ts +240 -0
- package/src/ui/zoom.ts +45 -0
- package/src/view.ts +516 -0
- package/vscode.html-custom-data.json +726 -0
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `<kgdiff-viewer>` (the default layout) and `<kgdiff-root>` share (RFC 0001 §2–§5): an
|
|
3
|
+
* element that is a root. It owns the session (`ViewerRoot`) and provides it to the regions and
|
|
4
|
+
* elements bound to it, and carries everything about the session the host sets or reads: the
|
|
5
|
+
* preferences as attributes and properties, the payload, the public methods, the events, the
|
|
6
|
+
* keyboard map and its scope, deep links and the hover hold of a looping replay.
|
|
7
|
+
*/
|
|
8
|
+
import { type Delta, KgdiffError, type Locale } from '@kgdiff/core';
|
|
9
|
+
import type { PaletteName } from '@kgdiff/scene';
|
|
10
|
+
import { formatHash, parseHash } from './hash-sync.js';
|
|
11
|
+
import { detectLocale, translate, translator } from './i18n.js';
|
|
12
|
+
import type {
|
|
13
|
+
FitOptions,
|
|
14
|
+
LoadOptions,
|
|
15
|
+
ReportPayload,
|
|
16
|
+
ReviewState,
|
|
17
|
+
ThemePreference,
|
|
18
|
+
ViewerMode,
|
|
19
|
+
ViewerScope,
|
|
20
|
+
} from './index.js';
|
|
21
|
+
import { type Keymap, toKeymap } from './keyboard.js';
|
|
22
|
+
import { assertPayload } from './payload.js';
|
|
23
|
+
import {
|
|
24
|
+
DEFAULT_LOOP_DELAY,
|
|
25
|
+
LEGACY_CHANGES_MODE,
|
|
26
|
+
sanitizeView,
|
|
27
|
+
toDelay,
|
|
28
|
+
toLocale,
|
|
29
|
+
toMode,
|
|
30
|
+
toPalette,
|
|
31
|
+
toScope,
|
|
32
|
+
toTheme,
|
|
33
|
+
} from './prefs.js';
|
|
34
|
+
import { isRoot, ViewerRoot, wasForwarded } from './root.js';
|
|
35
|
+
import {
|
|
36
|
+
ALL_TOPICS,
|
|
37
|
+
type DiffSession,
|
|
38
|
+
type SessionDeps,
|
|
39
|
+
type SessionHost,
|
|
40
|
+
type Topic,
|
|
41
|
+
} from './session.js';
|
|
42
|
+
import type { UiActions } from './ui/context.js';
|
|
43
|
+
|
|
44
|
+
/** Attributes every root observes. */
|
|
45
|
+
export const ROOT_ATTRIBUTES = [
|
|
46
|
+
'mode',
|
|
47
|
+
'scope',
|
|
48
|
+
'theme',
|
|
49
|
+
'palette',
|
|
50
|
+
'locale',
|
|
51
|
+
'sync-hash',
|
|
52
|
+
'loop',
|
|
53
|
+
'loop-delay',
|
|
54
|
+
'pause-on-hover',
|
|
55
|
+
'shortcuts',
|
|
56
|
+
'keymap',
|
|
57
|
+
] as const;
|
|
58
|
+
|
|
59
|
+
/** Where keyboard shortcuts apply (`shortcuts`). */
|
|
60
|
+
export type ShortcutScope = 'scoped' | 'global' | 'none';
|
|
61
|
+
const SCOPES: readonly ShortcutScope[] = ['scoped', 'global', 'none'];
|
|
62
|
+
|
|
63
|
+
/** Properties a page may set before the element is defined (they shadow the accessors). */
|
|
64
|
+
const UPGRADED = [
|
|
65
|
+
'mode',
|
|
66
|
+
'scope',
|
|
67
|
+
'theme',
|
|
68
|
+
'palette',
|
|
69
|
+
'locale',
|
|
70
|
+
'loop',
|
|
71
|
+
'loopDelay',
|
|
72
|
+
'pauseOnHover',
|
|
73
|
+
'payload',
|
|
74
|
+
'shortcuts',
|
|
75
|
+
'keymap',
|
|
76
|
+
] as const;
|
|
77
|
+
|
|
78
|
+
/** Keys the page itself scrolls with: never taken at document level. */
|
|
79
|
+
const PAGE_KEYS: ReadonlySet<string> = new Set([
|
|
80
|
+
'ArrowUp',
|
|
81
|
+
'ArrowDown',
|
|
82
|
+
'ArrowLeft',
|
|
83
|
+
'ArrowRight',
|
|
84
|
+
'Home',
|
|
85
|
+
'End',
|
|
86
|
+
'PageUp',
|
|
87
|
+
'PageDown',
|
|
88
|
+
' ',
|
|
89
|
+
'Spacebar',
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
/** Connected roots, in connection order (`global-shortcuts` goes to the first opted-in one). */
|
|
93
|
+
const connected: RootElement[] = [];
|
|
94
|
+
|
|
95
|
+
function deepFreeze<T>(value: T): T {
|
|
96
|
+
if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) {
|
|
97
|
+
for (const v of Object.values(value)) deepFreeze(v);
|
|
98
|
+
Object.freeze(value);
|
|
99
|
+
}
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @attr {boolean} sync-hash - Keep the selection, mode and scope in the page's URL hash
|
|
105
|
+
* (`#kgdiff=hunk:<id>;mode:<mode>;scope:<scope>`) and follow it: deep links.
|
|
106
|
+
* @attr {boolean} global-shortcuts - The same as `shortcuts="global"`.
|
|
107
|
+
*/
|
|
108
|
+
export abstract class RootElement<S extends DiffSession = DiffSession> extends HTMLElement {
|
|
109
|
+
/** @internal */
|
|
110
|
+
readonly controller: S;
|
|
111
|
+
/** @internal The root: session, keyboard map, live region, events, tokens. */
|
|
112
|
+
protected readonly root: ViewerRoot<S>;
|
|
113
|
+
protected readonly shadow: ShadowRoot;
|
|
114
|
+
#currentPayload: ReportPayload | null = null;
|
|
115
|
+
#reflecting = false;
|
|
116
|
+
readonly #cleanup: (() => void)[] = [];
|
|
117
|
+
#lastHash = '';
|
|
118
|
+
#upgraded = false;
|
|
119
|
+
/** A mouse or pen rests on the graph (holds a looping replay with `pause-on-hover`). */
|
|
120
|
+
#pointerOver = false;
|
|
121
|
+
|
|
122
|
+
constructor() {
|
|
123
|
+
super();
|
|
124
|
+
this.shadow = this.attachShadow({ mode: 'open' });
|
|
125
|
+
this.root = new ViewerRoot(
|
|
126
|
+
this,
|
|
127
|
+
this.shadow,
|
|
128
|
+
this.actions(),
|
|
129
|
+
(type) => {
|
|
130
|
+
if (type === 'kgdiff-select' || type === 'kgdiff-mode') this.#writeHash();
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
session: (host, deps) => this.createSession(host, deps),
|
|
134
|
+
sheets: (doc) => this.sheets(doc),
|
|
135
|
+
key: (e) => this.#key(e),
|
|
136
|
+
hover: (over) => this.pointerOver(over),
|
|
137
|
+
},
|
|
138
|
+
);
|
|
139
|
+
this.controller = this.root.controller;
|
|
140
|
+
this.controller.locale = toLocale(this.getAttribute('locale')) ?? detectLocale();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ─── Hooks ────────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
/** What the layout does for the regions (`UiActions`); its closures run later. */
|
|
146
|
+
protected abstract actions(): UiActions;
|
|
147
|
+
|
|
148
|
+
/** Connected: build and attach what the element shows (before the first render). */
|
|
149
|
+
protected abstract connected(): void;
|
|
150
|
+
|
|
151
|
+
/** Disconnected (after the root stopped providing). */
|
|
152
|
+
protected abstract disconnected(): void;
|
|
153
|
+
|
|
154
|
+
/** An observed attribute that is not a root attribute changed (from `_old`). */
|
|
155
|
+
protected attributeChanged(_name: string, _value: string | null, _old: string | null): void {}
|
|
156
|
+
|
|
157
|
+
/** The session this root holds (the default layout's has its canvas built in). */
|
|
158
|
+
protected abstract createSession(host: SessionHost, deps: SessionDeps): S;
|
|
159
|
+
|
|
160
|
+
/** The sheets the shadow root adopts before the token set. */
|
|
161
|
+
protected abstract sheets(doc: Document): readonly CSSStyleSheet[];
|
|
162
|
+
|
|
163
|
+
/** Properties of the subclass a page may set before the element is defined. */
|
|
164
|
+
protected upgradedProperties(): readonly string[] {
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Whether keys do anything at all (`chrome="none"` turns them off in the default layout). */
|
|
169
|
+
protected keysEnabled(): boolean {
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** A key reached the root: return true when the layout took it (an open popover). */
|
|
174
|
+
protected interceptKey(_e: KeyboardEvent): boolean {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ─── Lifecycle ────────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
connectedCallback(): void {
|
|
181
|
+
if (!this.#upgraded) {
|
|
182
|
+
this.#upgraded = true;
|
|
183
|
+
// Properties set before the element was defined shadow the accessors: re-apply them.
|
|
184
|
+
for (const prop of [...UPGRADED, ...this.upgradedProperties()]) {
|
|
185
|
+
if (Object.hasOwn(this, prop)) {
|
|
186
|
+
const value = (this as unknown as Record<string, unknown>)[prop];
|
|
187
|
+
delete (this as unknown as Record<string, unknown>)[prop];
|
|
188
|
+
(this as unknown as Record<string, unknown>)[prop] = value;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// Provide the session (the regions bind to it) and follow the system's preferences.
|
|
193
|
+
this.#cleanup.push(this.root.connect());
|
|
194
|
+
this.connected();
|
|
195
|
+
connected.push(this);
|
|
196
|
+
const onKey = (e: KeyboardEvent): void => this.#onKeyDown(e);
|
|
197
|
+
document.addEventListener('keydown', onKey);
|
|
198
|
+
this.#cleanup.push(() => document.removeEventListener('keydown', onKey));
|
|
199
|
+
const onHash = (): void => this.#readHash();
|
|
200
|
+
window.addEventListener('hashchange', onHash);
|
|
201
|
+
this.#cleanup.push(() => window.removeEventListener('hashchange', onHash));
|
|
202
|
+
this.#readHash();
|
|
203
|
+
this.root.render(ALL_TOPICS);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
disconnectedCallback(): void {
|
|
207
|
+
const i = connected.indexOf(this);
|
|
208
|
+
if (i >= 0) connected.splice(i, 1);
|
|
209
|
+
for (const fn of this.#cleanup.splice(0)) fn();
|
|
210
|
+
this.disconnected();
|
|
211
|
+
// Moved while hovered: no pointerleave follows.
|
|
212
|
+
this.#pointerOver = false;
|
|
213
|
+
this.#applyHold();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
attributeChangedCallback(name: string, old: string | null, value: string | null): void {
|
|
217
|
+
if (this.#reflecting) return;
|
|
218
|
+
switch (name) {
|
|
219
|
+
case 'mode':
|
|
220
|
+
case 'scope':
|
|
221
|
+
case 'theme':
|
|
222
|
+
case 'palette':
|
|
223
|
+
case 'locale':
|
|
224
|
+
this.applyPreference(name, value, false);
|
|
225
|
+
break;
|
|
226
|
+
case 'sync-hash':
|
|
227
|
+
if (value !== null) this.#readHash();
|
|
228
|
+
break;
|
|
229
|
+
case 'loop':
|
|
230
|
+
case 'loop-delay':
|
|
231
|
+
this.controller.setLoop(this.loop ? this.loopDelay : null);
|
|
232
|
+
this.#applyHold();
|
|
233
|
+
break;
|
|
234
|
+
case 'pause-on-hover':
|
|
235
|
+
this.#applyHold();
|
|
236
|
+
break;
|
|
237
|
+
case 'shortcuts':
|
|
238
|
+
this.root.shortcutsOff = this.shortcuts === 'none';
|
|
239
|
+
break;
|
|
240
|
+
case 'keymap':
|
|
241
|
+
this.#setKeymap(value);
|
|
242
|
+
break;
|
|
243
|
+
default:
|
|
244
|
+
this.attributeChanged(name, value, old);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Apply a preference from an attribute or a property. Valid values are explicit (a report's
|
|
250
|
+
* `view` does not override them); invalid ones fall back to the default (`overlay`, `all`,
|
|
251
|
+
* `auto`, `default`, the browser's language). Properties reflect the normalized value; the
|
|
252
|
+
* legacy mode `changes` reflects as `overlay` + scope `changes`.
|
|
253
|
+
*/
|
|
254
|
+
protected applyPreference(
|
|
255
|
+
name: 'mode' | 'scope' | 'theme' | 'palette' | 'locale',
|
|
256
|
+
raw: unknown,
|
|
257
|
+
reflect: boolean,
|
|
258
|
+
): void {
|
|
259
|
+
const c = this.controller;
|
|
260
|
+
// `mode="changes"` is rewritten to the attribute pair it stands for.
|
|
261
|
+
const legacy = name === 'mode' && raw === LEGACY_CHANGES_MODE;
|
|
262
|
+
let value: string | null;
|
|
263
|
+
switch (name) {
|
|
264
|
+
case 'mode': {
|
|
265
|
+
if (legacy) {
|
|
266
|
+
c.explicit.add('scope');
|
|
267
|
+
c.setView('overlay', 'changes');
|
|
268
|
+
value = 'overlay';
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
const v = toMode(raw);
|
|
272
|
+
c.setMode(v ?? 'overlay');
|
|
273
|
+
value = v;
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
case 'scope': {
|
|
277
|
+
const v = toScope(raw);
|
|
278
|
+
c.setScope(v ?? 'all');
|
|
279
|
+
// A locked scope stays `changes`: the attribute says what is shown.
|
|
280
|
+
value = v === null ? null : c.scope;
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
case 'theme': {
|
|
284
|
+
const v = toTheme(raw);
|
|
285
|
+
c.setThemePreference(v ?? 'auto');
|
|
286
|
+
value = v;
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
case 'palette': {
|
|
290
|
+
const v = toPalette(raw);
|
|
291
|
+
c.setPalette(v ?? 'default');
|
|
292
|
+
value = v;
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
case 'locale': {
|
|
296
|
+
const v = toLocale(raw);
|
|
297
|
+
c.setLocale(v ?? detectLocale());
|
|
298
|
+
value = v;
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
default:
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (value === null) c.explicit.delete(name);
|
|
305
|
+
else c.explicit.add(name);
|
|
306
|
+
if (reflect || legacy) this.reflect(name, value);
|
|
307
|
+
if (legacy) this.reflect('scope', c.scope);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ─── Properties ───────────────────────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
get mode(): ViewerMode {
|
|
313
|
+
return this.controller.mode;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
set mode(value: ViewerMode | typeof LEGACY_CHANGES_MODE) {
|
|
317
|
+
this.applyPreference('mode', value, true);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
get scope(): ViewerScope {
|
|
321
|
+
return this.controller.scope;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
set scope(value: ViewerScope) {
|
|
325
|
+
this.applyPreference('scope', value, true);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
get theme(): ThemePreference {
|
|
329
|
+
return this.controller.themePreference;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
set theme(value: ThemePreference) {
|
|
333
|
+
this.applyPreference('theme', value, true);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
get palette(): PaletteName {
|
|
337
|
+
return this.controller.palette;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
set palette(value: PaletteName) {
|
|
341
|
+
this.applyPreference('palette', value, true);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
get locale(): Locale {
|
|
345
|
+
return this.controller.locale;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
set locale(value: Locale) {
|
|
349
|
+
this.applyPreference('locale', value, true);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
get loop(): boolean {
|
|
353
|
+
return this.hasAttribute('loop');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
set loop(value: boolean) {
|
|
357
|
+
this.toggleAttribute('loop', Boolean(value));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
get loopDelay(): number {
|
|
361
|
+
return toDelay(this.getAttribute('loop-delay')) ?? DEFAULT_LOOP_DELAY;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
set loopDelay(value: number) {
|
|
365
|
+
const v = toDelay(value);
|
|
366
|
+
if (v === null) this.removeAttribute('loop-delay');
|
|
367
|
+
else this.setAttribute('loop-delay', String(v));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
get pauseOnHover(): boolean {
|
|
371
|
+
return this.getAttribute('pause-on-hover') !== 'false';
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
set pauseOnHover(value: boolean) {
|
|
375
|
+
if (value) this.removeAttribute('pause-on-hover');
|
|
376
|
+
else this.setAttribute('pause-on-hover', 'false');
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Where the keyboard shortcuts apply. `scoped` (the default): while focus is inside the root
|
|
381
|
+
* (keys of elements bound with `for` are forwarded to it). `global`: also when nothing on the
|
|
382
|
+
* page has focus, for the first such root, except the keys pages scroll with; the boolean
|
|
383
|
+
* `global-shortcuts` attribute is an alias. `none`: no shortcuts (a focused change list keeps
|
|
384
|
+
* its arrow keys).
|
|
385
|
+
*/
|
|
386
|
+
get shortcuts(): ShortcutScope {
|
|
387
|
+
const v = this.getAttribute('shortcuts') as ShortcutScope | null;
|
|
388
|
+
if (v !== null && SCOPES.includes(v)) return v;
|
|
389
|
+
return this.hasAttribute('global-shortcuts') ? 'global' : 'scoped';
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
set shortcuts(value: ShortcutScope) {
|
|
393
|
+
if (SCOPES.includes(value)) this.setAttribute('shortcuts', value);
|
|
394
|
+
else this.removeAttribute('shortcuts');
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Keys bound to other shortcuts, or unbound with null: `{ x: null, r: 'review' }`, or as the
|
|
399
|
+
* `keymap` attribute `"x:none r:review"`. Keys not named keep the default map.
|
|
400
|
+
*/
|
|
401
|
+
get keymap(): Keymap | null {
|
|
402
|
+
return this.root.keymap;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
set keymap(value: Keymap | string | null) {
|
|
406
|
+
this.#setKeymap(value);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
#setKeymap(value: unknown): void {
|
|
410
|
+
const { keymap, invalid } = toKeymap(value);
|
|
411
|
+
if (invalid.length > 0) console.warn(`[kgdiff] ignoring invalid keymap: ${invalid.join(' ')}`);
|
|
412
|
+
this.root.keymap = keymap;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
get payload(): ReportPayload | null {
|
|
416
|
+
return this.#currentPayload;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
set payload(value: ReportPayload | null) {
|
|
420
|
+
this.#currentPayload = value;
|
|
421
|
+
if (value === null || value === undefined) return;
|
|
422
|
+
let payload: ReportPayload;
|
|
423
|
+
try {
|
|
424
|
+
payload = assertPayload(value);
|
|
425
|
+
} catch (error) {
|
|
426
|
+
this.controller.fail(error as Error);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
// The report's theme, palette and language apply at once (the loading steps show in
|
|
430
|
+
// them), unless the host or the user chose (validated, and warned about, when it loads).
|
|
431
|
+
const view = sanitizeView(payload.view, true);
|
|
432
|
+
const c = this.controller;
|
|
433
|
+
if (view?.theme && !c.explicit.has('theme')) c.setThemePreference(view.theme);
|
|
434
|
+
if (view?.palette && !c.explicit.has('palette')) c.setPalette(view.palette);
|
|
435
|
+
if (view?.locale && !c.explicit.has('locale')) c.setLocale(view.locale);
|
|
436
|
+
this.payloadSet(payload);
|
|
437
|
+
this.controller.loadPayload(payload).catch(() => {
|
|
438
|
+
// Already surfaced: error state + `kgdiff-error`.
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** A valid payload was set, before it loads. */
|
|
443
|
+
protected payloadSet(_payload: ReportPayload): void {}
|
|
444
|
+
|
|
445
|
+
// ─── Methods ──────────────────────────────────────────────────────────────
|
|
446
|
+
|
|
447
|
+
load(options: LoadOptions): Promise<void> {
|
|
448
|
+
this.#currentPayload = null;
|
|
449
|
+
return this.controller.load(options);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
selectHunk(id: string | null): void {
|
|
453
|
+
this.controller.selectHunkById(id, { fly: true });
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
next(): void {
|
|
457
|
+
this.controller.next();
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
prev(): void {
|
|
461
|
+
this.controller.prev();
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
play(): void {
|
|
465
|
+
this.controller.play();
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
pause(): void {
|
|
469
|
+
this.controller.pause();
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
fit(options?: FitOptions): Promise<void> {
|
|
473
|
+
return this.controller.fit(options);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
exportDelta(): Delta {
|
|
477
|
+
const diff = this.controller.diff;
|
|
478
|
+
if (!diff) throw new KgdiffError('invalid-input', translate('en', 'error.notLoaded'));
|
|
479
|
+
// A copy: callers can never mutate the diff the viewer shows.
|
|
480
|
+
return deepFreeze(structuredClone(diff.delta));
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
reviewState(): ReviewState {
|
|
484
|
+
return this.controller.reviewState();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
setReviewed(hunkId: string, reviewed: boolean): void {
|
|
488
|
+
this.controller.setReviewed(hunkId, reviewed);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Show an error state (used for undecodable embedded payloads). */
|
|
492
|
+
showError(error: Error): void {
|
|
493
|
+
this.controller.fail(error);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Draw the canvas with the `--kgdiff-*` tokens as computed now. Call it after changing tokens
|
|
498
|
+
* in a way the root cannot observe (a class on an ancestor, a stylesheet); theme, palette and
|
|
499
|
+
* system color scheme changes are followed by themselves.
|
|
500
|
+
*/
|
|
501
|
+
refreshTheme(): void {
|
|
502
|
+
this.controller.refreshTheme();
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// ─── Internals ────────────────────────────────────────────────────────────
|
|
506
|
+
|
|
507
|
+
protected reflect(name: string, value: string | null): void {
|
|
508
|
+
if (this.getAttribute(name) === value) return;
|
|
509
|
+
this.#reflecting = true;
|
|
510
|
+
try {
|
|
511
|
+
if (value === null) this.removeAttribute(name);
|
|
512
|
+
else this.setAttribute(name, value);
|
|
513
|
+
} finally {
|
|
514
|
+
this.#reflecting = false;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** The actions every root has: preferences reflect as attributes. */
|
|
519
|
+
protected baseActions(): Pick<
|
|
520
|
+
UiActions,
|
|
521
|
+
'tr' | 'setMode' | 'toggleScope' | 'setThemePreference' | 'setPalette' | 'setLocale'
|
|
522
|
+
> {
|
|
523
|
+
return {
|
|
524
|
+
tr: () => translator(this.controller.locale),
|
|
525
|
+
setMode: (mode) => this.controller.setMode(mode),
|
|
526
|
+
toggleScope: () => this.controller.toggleScope(),
|
|
527
|
+
setThemePreference: (pref) => {
|
|
528
|
+
this.theme = pref;
|
|
529
|
+
},
|
|
530
|
+
setPalette: (p) => {
|
|
531
|
+
this.palette = p;
|
|
532
|
+
},
|
|
533
|
+
setLocale: (l) => {
|
|
534
|
+
this.locale = l;
|
|
535
|
+
},
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** The pointer entered or left the graph (holds a looping replay with `pause-on-hover`). */
|
|
540
|
+
protected pointerOver(over: boolean): void {
|
|
541
|
+
this.#pointerOver = over;
|
|
542
|
+
this.#applyHold();
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Hold a looping replay while the pointer is over the graph (`pause-on-hover`). */
|
|
546
|
+
#applyHold(): void {
|
|
547
|
+
this.controller.hold(this.#pointerOver && this.loop && this.pauseOnHover);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Reflect what the session changed (mode, scope, theme) back to the attributes. */
|
|
551
|
+
protected reflectSession(topics: ReadonlySet<Topic>): void {
|
|
552
|
+
const c = this.controller;
|
|
553
|
+
if (topics.has('mode')) {
|
|
554
|
+
this.reflect('mode', c.mode);
|
|
555
|
+
this.reflect('scope', c.scope);
|
|
556
|
+
}
|
|
557
|
+
if (topics.has('theme')) {
|
|
558
|
+
this.reflect(
|
|
559
|
+
'theme',
|
|
560
|
+
c.themePreference === 'auto' && !this.hasAttribute('theme') ? null : c.themePreference,
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
#onKeyDown(e: KeyboardEvent): void {
|
|
566
|
+
// A key an element forwarded to its own root is that root's, even when it did nothing.
|
|
567
|
+
if (wasForwarded(e)) return;
|
|
568
|
+
// The nearest root takes a key pressed inside it; an outer one leaves it alone.
|
|
569
|
+
const nearest = e.composedPath().find(isRoot);
|
|
570
|
+
if (nearest !== undefined && nearest !== this) return;
|
|
571
|
+
if (nearest === undefined) {
|
|
572
|
+
// Outside every root only with `shortcuts="global"`, when nothing else has focus, for the
|
|
573
|
+
// first such root, and never the keys pages scroll with.
|
|
574
|
+
if (this.shortcuts !== 'global' || PAGE_KEYS.has(e.key)) return;
|
|
575
|
+
const active = document.activeElement;
|
|
576
|
+
if (active && active !== document.body && active !== document.documentElement) return;
|
|
577
|
+
if (connected.find((v) => v.shortcuts === 'global') !== this) return;
|
|
578
|
+
}
|
|
579
|
+
this.#key(e);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** A key for this root: pressed inside it, or forwarded by an element bound with `for`. */
|
|
583
|
+
#key(e: KeyboardEvent): void {
|
|
584
|
+
if (e.defaultPrevented || e.isComposing || !this.keysEnabled()) return;
|
|
585
|
+
if (this.interceptKey(e)) return;
|
|
586
|
+
this.root.handleKey(e);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// ─── Deep links ───────────────────────────────────────────────────────────
|
|
590
|
+
|
|
591
|
+
#readHash(): void {
|
|
592
|
+
if (!this.hasAttribute('sync-hash') || typeof location === 'undefined') return;
|
|
593
|
+
if (location.hash === this.#lastHash) return;
|
|
594
|
+
const state = parseHash(location.hash);
|
|
595
|
+
this.#lastHash = location.hash;
|
|
596
|
+
if (!state) return;
|
|
597
|
+
if (state.mode || state.scope) {
|
|
598
|
+
// A deep link outranks the large-graph default and a report's preferred view.
|
|
599
|
+
const c = this.controller;
|
|
600
|
+
if (state.mode) c.explicit.add('mode');
|
|
601
|
+
if (state.scope) c.explicit.add('scope');
|
|
602
|
+
c.setView(state.mode ?? c.mode, state.scope ?? c.scope);
|
|
603
|
+
}
|
|
604
|
+
if (state.hunk) this.controller.selectHunkById(state.hunk, { fly: true });
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
#writeHash(): void {
|
|
608
|
+
if (!this.hasAttribute('sync-hash') || typeof location === 'undefined') return;
|
|
609
|
+
const c = this.controller;
|
|
610
|
+
const hunk = c.selectedHunk >= 0 ? c.scene?.hunks[c.selectedHunk]?.id : undefined;
|
|
611
|
+
const hash = formatHash({
|
|
612
|
+
...(hunk !== undefined ? { hunk } : {}),
|
|
613
|
+
mode: c.mode,
|
|
614
|
+
scope: c.scope,
|
|
615
|
+
});
|
|
616
|
+
if (hash === location.hash) return;
|
|
617
|
+
this.#lastHash = hash;
|
|
618
|
+
try {
|
|
619
|
+
history.replaceState(history.state, '', hash);
|
|
620
|
+
} catch {
|
|
621
|
+
location.hash = hash;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|