@kgdiff/viewer 0.2.0 → 0.4.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 +48 -6
- package/dist/controller.d.ts +63 -14
- package/dist/controller.d.ts.map +1 -1
- package/dist/controller.js +308 -54
- package/dist/controller.js.map +1 -1
- package/dist/element.d.ts +17 -4
- package/dist/element.d.ts.map +1 -1
- package/dist/element.js +199 -26
- package/dist/element.js.map +1 -1
- package/dist/framing.d.ts +20 -1
- package/dist/framing.d.ts.map +1 -1
- package/dist/framing.js +49 -0
- package/dist/framing.js.map +1 -1
- package/dist/hash-sync.d.ts +4 -2
- package/dist/hash-sync.d.ts.map +1 -1
- package/dist/hash-sync.js +21 -5
- package/dist/hash-sync.js.map +1 -1
- package/dist/i18n.d.ts +7 -7
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +14 -14
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +66 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/keyboard.d.ts +6 -4
- package/dist/keyboard.d.ts.map +1 -1
- package/dist/keyboard.js +6 -5
- package/dist/keyboard.js.map +1 -1
- package/dist/prefs.d.ts +13 -1
- package/dist/prefs.d.ts.map +1 -1
- package/dist/prefs.js +30 -3
- package/dist/prefs.js.map +1 -1
- package/dist/standalone/kgdiff-viewer.js +4 -4
- package/dist/styles.d.ts.map +1 -1
- package/dist/styles.js +84 -61
- package/dist/styles.js.map +1 -1
- package/dist/ui/context.d.ts +2 -0
- package/dist/ui/context.d.ts.map +1 -1
- package/dist/ui/context.js.map +1 -1
- package/dist/ui/dock.d.ts +17 -0
- package/dist/ui/dock.d.ts.map +1 -0
- package/dist/ui/dock.js +151 -0
- package/dist/ui/dock.js.map +1 -0
- package/dist/ui/header.d.ts +7 -2
- package/dist/ui/header.d.ts.map +1 -1
- package/dist/ui/header.js +24 -133
- package/dist/ui/header.js.map +1 -1
- package/dist/ui/popovers.d.ts.map +1 -1
- package/dist/ui/popovers.js +3 -2
- package/dist/ui/popovers.js.map +1 -1
- package/dist/ui/sidebar.d.ts.map +1 -1
- package/dist/ui/sidebar.js +2 -1
- package/dist/ui/sidebar.js.map +1 -1
- package/dist/ui/stage.d.ts +8 -0
- package/dist/ui/stage.d.ts.map +1 -1
- package/dist/ui/stage.js +66 -93
- package/dist/ui/stage.js.map +1 -1
- package/dist/ui/swatch.d.ts +7 -0
- package/dist/ui/swatch.d.ts.map +1 -0
- package/dist/ui/swatch.js +26 -0
- package/dist/ui/swatch.js.map +1 -0
- package/package.json +6 -6
- package/src/controller.ts +332 -56
- package/src/element.ts +220 -29
- package/src/framing.ts +65 -1
- package/src/hash-sync.ts +20 -6
- package/src/i18n.ts +15 -14
- package/src/index.ts +65 -7
- package/src/keyboard.ts +12 -6
- package/src/prefs.ts +45 -5
- package/src/styles.ts +84 -61
- package/src/ui/context.ts +2 -0
- package/src/ui/dock.ts +198 -0
- package/src/ui/header.ts +38 -168
- package/src/ui/popovers.ts +3 -2
- package/src/ui/sidebar.ts +2 -1
- package/src/ui/stage.ts +88 -112
- package/src/ui/swatch.ts +31 -0
package/src/element.ts
CHANGED
|
@@ -9,16 +9,32 @@ import { h } from './dom.js';
|
|
|
9
9
|
import { formatHash, parseHash } from './hash-sync.js';
|
|
10
10
|
import { detectLocale, translate, translator } from './i18n.js';
|
|
11
11
|
import type {
|
|
12
|
+
FitOptions,
|
|
12
13
|
KgdiffViewerElement,
|
|
13
14
|
LoadOptions,
|
|
14
15
|
ReportPayload,
|
|
15
16
|
ReviewState,
|
|
16
17
|
ThemePreference,
|
|
18
|
+
ViewerChrome,
|
|
19
|
+
ViewerLegend,
|
|
17
20
|
ViewerMode,
|
|
21
|
+
ViewerScope,
|
|
18
22
|
} from './index.js';
|
|
19
23
|
import { isTypingTarget, keyAction } from './keyboard.js';
|
|
20
24
|
import { assertPayload } from './payload.js';
|
|
21
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
DEFAULT_LOOP_DELAY,
|
|
27
|
+
LEGACY_CHANGES_MODE,
|
|
28
|
+
THEME_PREFERENCES,
|
|
29
|
+
toChrome,
|
|
30
|
+
toDelay,
|
|
31
|
+
toLegend,
|
|
32
|
+
toLocale,
|
|
33
|
+
toMode,
|
|
34
|
+
toPalette,
|
|
35
|
+
toScope,
|
|
36
|
+
toTheme,
|
|
37
|
+
} from './prefs.js';
|
|
22
38
|
import { defaultStorage } from './review-store.js';
|
|
23
39
|
import { STYLES, themeVariables } from './styles.js';
|
|
24
40
|
import type { UiContext } from './ui/context.js';
|
|
@@ -27,13 +43,38 @@ import { HelpDialog, Menu } from './ui/popovers.js';
|
|
|
27
43
|
import { Sidebar } from './ui/sidebar.js';
|
|
28
44
|
import { Stage } from './ui/stage.js';
|
|
29
45
|
|
|
30
|
-
/** Below this width the sidebar becomes a bottom sheet
|
|
46
|
+
/** Below this width the sidebar becomes a bottom sheet. */
|
|
31
47
|
export const COMPACT_WIDTH = 760;
|
|
32
|
-
/** Below this width the
|
|
48
|
+
/** Below this width the title drops the version tags. */
|
|
33
49
|
const NARROW_WIDTH = 1100;
|
|
34
50
|
|
|
35
|
-
const OBSERVED = [
|
|
36
|
-
|
|
51
|
+
const OBSERVED = [
|
|
52
|
+
'mode',
|
|
53
|
+
'scope',
|
|
54
|
+
'theme',
|
|
55
|
+
'palette',
|
|
56
|
+
'locale',
|
|
57
|
+
'sidebar',
|
|
58
|
+
'sync-hash',
|
|
59
|
+
'chrome',
|
|
60
|
+
'legend',
|
|
61
|
+
'loop',
|
|
62
|
+
'loop-delay',
|
|
63
|
+
'pause-on-hover',
|
|
64
|
+
] as const;
|
|
65
|
+
const UPGRADED = [
|
|
66
|
+
'mode',
|
|
67
|
+
'scope',
|
|
68
|
+
'theme',
|
|
69
|
+
'palette',
|
|
70
|
+
'locale',
|
|
71
|
+
'chrome',
|
|
72
|
+
'legend',
|
|
73
|
+
'loop',
|
|
74
|
+
'loopDelay',
|
|
75
|
+
'pauseOnHover',
|
|
76
|
+
'payload',
|
|
77
|
+
] as const;
|
|
37
78
|
|
|
38
79
|
/** Keys the page itself scrolls with: never taken at document level. */
|
|
39
80
|
const PAGE_KEYS: ReadonlySet<string> = new Set([
|
|
@@ -110,6 +151,8 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
110
151
|
readonly #cleanup: (() => void)[] = [];
|
|
111
152
|
#lastHash = '';
|
|
112
153
|
#upgraded = false;
|
|
154
|
+
/** A mouse or pen rests on the graph (holds a looping replay with `pause-on-hover`). */
|
|
155
|
+
#pointerOver = false;
|
|
113
156
|
|
|
114
157
|
constructor() {
|
|
115
158
|
super();
|
|
@@ -120,14 +163,15 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
120
163
|
this.#emit('kgdiff-select', { hunk, node });
|
|
121
164
|
this.#writeHash();
|
|
122
165
|
},
|
|
123
|
-
mode: (mode) => {
|
|
124
|
-
this.#emit('kgdiff-mode', { mode });
|
|
166
|
+
mode: (mode, scope) => {
|
|
167
|
+
this.#emit('kgdiff-mode', { mode, scope });
|
|
125
168
|
this.#writeHash();
|
|
126
169
|
},
|
|
127
170
|
review: (hunk, reviewed, progress) =>
|
|
128
171
|
this.#emit('kgdiff-review', { hunk, reviewed, progress }),
|
|
129
172
|
error: (error) => this.#emit('kgdiff-error', { error }),
|
|
130
173
|
announce: (text) => this.#announce(text),
|
|
174
|
+
replayEnd: (loop) => this.#emit('kgdiff-replay-end', { loop }),
|
|
131
175
|
};
|
|
132
176
|
this.controller = new ViewerController(host, {
|
|
133
177
|
now: () => (typeof performance !== 'undefined' ? performance.now() : Date.now()),
|
|
@@ -165,8 +209,10 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
165
209
|
// The graph takes keyboard focus (shortcuts apply where focus is), also on click.
|
|
166
210
|
const canvas = ui.stage.canvasHost.querySelector('canvas');
|
|
167
211
|
if (canvas) {
|
|
168
|
-
canvas.tabIndex = 0;
|
|
169
|
-
const focusCanvas = (): void =>
|
|
212
|
+
canvas.tabIndex = this.chrome === 'none' ? -1 : 0;
|
|
213
|
+
const focusCanvas = (): void => {
|
|
214
|
+
if (this.chrome !== 'none') canvas.focus({ preventScroll: true });
|
|
215
|
+
};
|
|
170
216
|
canvas.addEventListener('pointerdown', focusCanvas);
|
|
171
217
|
this.#cleanup.push(() => canvas.removeEventListener('pointerdown', focusCanvas));
|
|
172
218
|
}
|
|
@@ -182,7 +228,7 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
182
228
|
window.addEventListener('hashchange', onHash);
|
|
183
229
|
this.#cleanup.push(() => window.removeEventListener('hashchange', onHash));
|
|
184
230
|
this.controller.setChrome(() => ui.stage.chromeLayout());
|
|
185
|
-
document.fonts?.ready?.then(() => this.#ui?.
|
|
231
|
+
document.fonts?.ready?.then(() => this.#ui?.stage.layout()).catch(() => undefined);
|
|
186
232
|
this.#readHash();
|
|
187
233
|
this.#render(
|
|
188
234
|
new Set<Topic>([
|
|
@@ -197,6 +243,7 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
197
243
|
'focus',
|
|
198
244
|
'swipe',
|
|
199
245
|
'replay',
|
|
246
|
+
'overview',
|
|
200
247
|
]),
|
|
201
248
|
);
|
|
202
249
|
}
|
|
@@ -206,12 +253,16 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
206
253
|
if (i >= 0) connected.splice(i, 1);
|
|
207
254
|
for (const fn of this.#cleanup.splice(0)) fn();
|
|
208
255
|
this.controller.detach();
|
|
256
|
+
// Moved while hovered: no pointerleave follows.
|
|
257
|
+
this.#pointerOver = false;
|
|
258
|
+
this.#applyHold();
|
|
209
259
|
}
|
|
210
260
|
|
|
211
261
|
attributeChangedCallback(name: string, _old: string | null, value: string | null): void {
|
|
212
262
|
if (this.#reflecting) return;
|
|
213
263
|
switch (name) {
|
|
214
264
|
case 'mode':
|
|
265
|
+
case 'scope':
|
|
215
266
|
case 'theme':
|
|
216
267
|
case 'palette':
|
|
217
268
|
case 'locale':
|
|
@@ -223,6 +274,18 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
223
274
|
case 'sync-hash':
|
|
224
275
|
if (value !== null) this.#readHash();
|
|
225
276
|
break;
|
|
277
|
+
case 'chrome':
|
|
278
|
+
case 'legend':
|
|
279
|
+
this.#applyChrome();
|
|
280
|
+
break;
|
|
281
|
+
case 'loop':
|
|
282
|
+
case 'loop-delay':
|
|
283
|
+
this.controller.setLoop(this.loop ? this.loopDelay : null);
|
|
284
|
+
this.#applyHold();
|
|
285
|
+
break;
|
|
286
|
+
case 'pause-on-hover':
|
|
287
|
+
this.#applyHold();
|
|
288
|
+
break;
|
|
226
289
|
default:
|
|
227
290
|
break;
|
|
228
291
|
}
|
|
@@ -230,23 +293,39 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
230
293
|
|
|
231
294
|
/**
|
|
232
295
|
* 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`, `
|
|
234
|
-
* `default`, the browser's language). Properties reflect the normalized value
|
|
296
|
+
* `view` does not override them); invalid ones fall back to the default (`overlay`, `all`,
|
|
297
|
+
* `auto`, `default`, the browser's language). Properties reflect the normalized value; the
|
|
298
|
+
* legacy mode `changes` reflects as `overlay` + scope `changes`.
|
|
235
299
|
*/
|
|
236
300
|
#applyPreference(
|
|
237
|
-
name: 'mode' | 'theme' | 'palette' | 'locale',
|
|
301
|
+
name: 'mode' | 'scope' | 'theme' | 'palette' | 'locale',
|
|
238
302
|
raw: unknown,
|
|
239
303
|
reflect: boolean,
|
|
240
304
|
): void {
|
|
241
305
|
const c = this.controller;
|
|
306
|
+
// `mode="changes"` is rewritten to the attribute pair it stands for.
|
|
307
|
+
const legacy = name === 'mode' && raw === LEGACY_CHANGES_MODE;
|
|
242
308
|
let value: string | null;
|
|
243
309
|
switch (name) {
|
|
244
310
|
case 'mode': {
|
|
311
|
+
if (legacy) {
|
|
312
|
+
c.explicit.add('scope');
|
|
313
|
+
c.setView('overlay', 'changes');
|
|
314
|
+
value = 'overlay';
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
245
317
|
const v = toMode(raw);
|
|
246
318
|
c.setMode(v ?? 'overlay');
|
|
247
319
|
value = v;
|
|
248
320
|
break;
|
|
249
321
|
}
|
|
322
|
+
case 'scope': {
|
|
323
|
+
const v = toScope(raw);
|
|
324
|
+
c.setScope(v ?? 'all');
|
|
325
|
+
// A locked scope stays `changes`: the attribute says what is shown.
|
|
326
|
+
value = v === null ? null : c.scope;
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
250
329
|
case 'theme': {
|
|
251
330
|
const v = toTheme(raw);
|
|
252
331
|
c.setThemePreference(v ?? 'auto');
|
|
@@ -270,7 +349,8 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
270
349
|
}
|
|
271
350
|
if (value === null) c.explicit.delete(name);
|
|
272
351
|
else c.explicit.add(name);
|
|
273
|
-
if (reflect) this.#reflect(name, value);
|
|
352
|
+
if (reflect || legacy) this.#reflect(name, value);
|
|
353
|
+
if (legacy) this.#reflect('scope', c.scope);
|
|
274
354
|
}
|
|
275
355
|
|
|
276
356
|
// ─── Properties ───────────────────────────────────────────────────────────
|
|
@@ -279,10 +359,18 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
279
359
|
return this.controller.mode;
|
|
280
360
|
}
|
|
281
361
|
|
|
282
|
-
set mode(value: ViewerMode) {
|
|
362
|
+
set mode(value: ViewerMode | typeof LEGACY_CHANGES_MODE) {
|
|
283
363
|
this.#applyPreference('mode', value, true);
|
|
284
364
|
}
|
|
285
365
|
|
|
366
|
+
get scope(): ViewerScope {
|
|
367
|
+
return this.controller.scope;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
set scope(value: ViewerScope) {
|
|
371
|
+
this.#applyPreference('scope', value, true);
|
|
372
|
+
}
|
|
373
|
+
|
|
286
374
|
get theme(): ThemePreference {
|
|
287
375
|
return this.controller.themePreference;
|
|
288
376
|
}
|
|
@@ -307,6 +395,52 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
307
395
|
this.#applyPreference('locale', value, true);
|
|
308
396
|
}
|
|
309
397
|
|
|
398
|
+
get chrome(): ViewerChrome {
|
|
399
|
+
return toChrome(this.getAttribute('chrome')) ?? 'full';
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
set chrome(value: ViewerChrome) {
|
|
403
|
+
this.#reflect('chrome', toChrome(value) === 'none' ? 'none' : null);
|
|
404
|
+
this.#applyChrome();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
get legend(): ViewerLegend {
|
|
408
|
+
return toLegend(this.getAttribute('legend')) ?? 'auto';
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
set legend(value: ViewerLegend) {
|
|
412
|
+
const v = toLegend(value);
|
|
413
|
+
this.#reflect('legend', v === 'auto' ? null : v);
|
|
414
|
+
this.#applyChrome();
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
get loop(): boolean {
|
|
418
|
+
return this.hasAttribute('loop');
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
set loop(value: boolean) {
|
|
422
|
+
this.toggleAttribute('loop', Boolean(value));
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
get loopDelay(): number {
|
|
426
|
+
return toDelay(this.getAttribute('loop-delay')) ?? DEFAULT_LOOP_DELAY;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
set loopDelay(value: number) {
|
|
430
|
+
const v = toDelay(value);
|
|
431
|
+
if (v === null) this.removeAttribute('loop-delay');
|
|
432
|
+
else this.setAttribute('loop-delay', String(v));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
get pauseOnHover(): boolean {
|
|
436
|
+
return this.getAttribute('pause-on-hover') !== 'false';
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
set pauseOnHover(value: boolean) {
|
|
440
|
+
if (value) this.removeAttribute('pause-on-hover');
|
|
441
|
+
else this.setAttribute('pause-on-hover', 'false');
|
|
442
|
+
}
|
|
443
|
+
|
|
310
444
|
get payload(): ReportPayload | null {
|
|
311
445
|
return this.#currentPayload;
|
|
312
446
|
}
|
|
@@ -353,8 +487,8 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
353
487
|
this.controller.pause();
|
|
354
488
|
}
|
|
355
489
|
|
|
356
|
-
fit(): Promise<void> {
|
|
357
|
-
return this.controller.fit();
|
|
490
|
+
fit(options?: FitOptions): Promise<void> {
|
|
491
|
+
return this.controller.fit(options);
|
|
358
492
|
}
|
|
359
493
|
|
|
360
494
|
exportDelta(): Delta {
|
|
@@ -415,6 +549,34 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
415
549
|
ui.header.setSidebarState(this.#sidebarOpen);
|
|
416
550
|
}
|
|
417
551
|
|
|
552
|
+
/**
|
|
553
|
+
* `chrome` and `legend`: what the stage shows, whether clicks select, whether the view stays
|
|
554
|
+
* fitted. The CSS hides the rest (`.root[data-chrome="none"]`).
|
|
555
|
+
*/
|
|
556
|
+
#applyChrome(): void {
|
|
557
|
+
const bare = this.chrome === 'none';
|
|
558
|
+
const c = this.controller;
|
|
559
|
+
c.autoFit = bare;
|
|
560
|
+
c.selectOnClick = !bare;
|
|
561
|
+
c.setLabelsInside(bare);
|
|
562
|
+
const ui = this.#ui;
|
|
563
|
+
if (!ui) return;
|
|
564
|
+
ui.root.dataset.chrome = this.chrome;
|
|
565
|
+
const legend = this.legend;
|
|
566
|
+
ui.stage.setChrome(legend === 'auto' ? (bare ? 'mini' : 'full') : legend, !bare);
|
|
567
|
+
const canvas = ui.stage.canvasHost.querySelector('canvas');
|
|
568
|
+
if (canvas) canvas.tabIndex = bare ? -1 : 0;
|
|
569
|
+
if (bare) {
|
|
570
|
+
ui.help.hide();
|
|
571
|
+
this.#toggleMenu(false);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** Hold a looping replay while the pointer is over the graph (`pause-on-hover`). */
|
|
576
|
+
#applyHold(): void {
|
|
577
|
+
this.controller.hold(this.#pointerOver && this.loop && this.pauseOnHover);
|
|
578
|
+
}
|
|
579
|
+
|
|
418
580
|
#build(): void {
|
|
419
581
|
const style = h('style', { text: STYLES });
|
|
420
582
|
const vars = h('style');
|
|
@@ -425,6 +587,9 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
425
587
|
setMode: (mode) => {
|
|
426
588
|
this.controller.setMode(mode);
|
|
427
589
|
},
|
|
590
|
+
toggleScope: () => {
|
|
591
|
+
this.controller.toggleScope();
|
|
592
|
+
},
|
|
428
593
|
setThemePreference: (pref) => {
|
|
429
594
|
this.theme = pref;
|
|
430
595
|
},
|
|
@@ -446,12 +611,13 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
446
611
|
const menu = new Menu(ctx);
|
|
447
612
|
const help = new HelpDialog(ctx);
|
|
448
613
|
const live = h('div', { class: 'sr-only', 'aria-live': 'polite', 'aria-atomic': 'true' });
|
|
614
|
+
// The title and the actions float over the canvas; the menu drops from the actions.
|
|
615
|
+
header.attachMenu(menu.el);
|
|
616
|
+
stage.el.appendChild(header.el);
|
|
449
617
|
const root = h(
|
|
450
618
|
'div',
|
|
451
619
|
{ class: 'root' },
|
|
452
|
-
header.el,
|
|
453
620
|
h('div', { class: 'body' }, stage.el, sidebar.el),
|
|
454
|
-
menu.el,
|
|
455
621
|
help.el,
|
|
456
622
|
live,
|
|
457
623
|
);
|
|
@@ -463,7 +629,15 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
463
629
|
const path = e.composedPath();
|
|
464
630
|
if (!path.includes(menu.el) && !path.includes(header.menuButton)) this.#toggleMenu(false);
|
|
465
631
|
});
|
|
632
|
+
const hover = (over: boolean) => (e: PointerEvent) => {
|
|
633
|
+
if (e.pointerType === 'touch') return;
|
|
634
|
+
this.#pointerOver = over;
|
|
635
|
+
this.#applyHold();
|
|
636
|
+
};
|
|
637
|
+
stage.el.addEventListener('pointerenter', hover(true));
|
|
638
|
+
stage.el.addEventListener('pointerleave', hover(false));
|
|
466
639
|
this.#applySidebar();
|
|
640
|
+
this.#applyChrome();
|
|
467
641
|
}
|
|
468
642
|
|
|
469
643
|
/** Render, never throwing: a failure shows the error state with safe appearance defaults. */
|
|
@@ -486,7 +660,10 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
486
660
|
#renderTopics(topics: ReadonlySet<Topic>): void {
|
|
487
661
|
const ui = this.#ui;
|
|
488
662
|
const c = this.controller;
|
|
489
|
-
if (topics.has('mode'))
|
|
663
|
+
if (topics.has('mode')) {
|
|
664
|
+
this.#reflect('mode', c.mode);
|
|
665
|
+
this.#reflect('scope', c.scope);
|
|
666
|
+
}
|
|
490
667
|
if (topics.has('theme')) {
|
|
491
668
|
this.#reflect(
|
|
492
669
|
'theme',
|
|
@@ -507,7 +684,10 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
507
684
|
ui.header.update(topics);
|
|
508
685
|
ui.sidebar.update(topics);
|
|
509
686
|
ui.stage.update(topics);
|
|
510
|
-
if (topics.has('mode') || topics.has('status'))
|
|
687
|
+
if (topics.has('mode') || topics.has('status')) {
|
|
688
|
+
ui.root.dataset.mode = c.mode;
|
|
689
|
+
ui.root.dataset.scope = c.scope;
|
|
690
|
+
}
|
|
511
691
|
}
|
|
512
692
|
|
|
513
693
|
#toggleSidebar(open?: boolean): void {
|
|
@@ -557,19 +737,21 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
557
737
|
this.#compact = compact;
|
|
558
738
|
ui.root.dataset.compact = String(compact);
|
|
559
739
|
}
|
|
560
|
-
ui.
|
|
740
|
+
ui.stage.layout();
|
|
561
741
|
ui.sidebar.renderWindow();
|
|
562
742
|
if (compact) ui.sidebar.syncSheet();
|
|
563
743
|
this.controller.layoutChanged();
|
|
564
744
|
});
|
|
565
745
|
ro.observe(this);
|
|
746
|
+
// The canvas also changes size without the element (chrome shown or hidden).
|
|
747
|
+
if (this.#ui) ro.observe(this.#ui.stage.el);
|
|
566
748
|
this.#cleanup.push(() => ro.disconnect());
|
|
567
749
|
}
|
|
568
750
|
|
|
569
751
|
#onKeyDown(e: KeyboardEvent): void {
|
|
570
752
|
if (e.defaultPrevented || e.isComposing) return;
|
|
571
753
|
const ui = this.#ui;
|
|
572
|
-
if (!ui) return;
|
|
754
|
+
if (!ui || this.chrome === 'none') return;
|
|
573
755
|
const path = e.composedPath();
|
|
574
756
|
if (!path.includes(this)) {
|
|
575
757
|
// Outside the component only with `global-shortcuts`, when nothing else has focus, for
|
|
@@ -590,7 +772,7 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
590
772
|
const target = path[0] ?? null;
|
|
591
773
|
if (isTypingTarget(target)) return;
|
|
592
774
|
const c = this.controller;
|
|
593
|
-
const action = keyAction(e, c.mode);
|
|
775
|
+
const action = keyAction(e, c.mode, c.scope);
|
|
594
776
|
if (!action) return;
|
|
595
777
|
// Enter / Space on a focused control activate that control.
|
|
596
778
|
const el = target as Element | null;
|
|
@@ -607,6 +789,9 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
607
789
|
case 'mode':
|
|
608
790
|
c.setMode(action.mode);
|
|
609
791
|
break;
|
|
792
|
+
case 'scope':
|
|
793
|
+
c.toggleScope();
|
|
794
|
+
break;
|
|
610
795
|
case 'next':
|
|
611
796
|
c.next();
|
|
612
797
|
break;
|
|
@@ -673,10 +858,12 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
673
858
|
const state = parseHash(location.hash);
|
|
674
859
|
this.#lastHash = location.hash;
|
|
675
860
|
if (!state) return;
|
|
676
|
-
if (state.mode) {
|
|
677
|
-
// A deep link outranks the large-graph default and a report's preferred
|
|
678
|
-
this.controller
|
|
679
|
-
|
|
861
|
+
if (state.mode || state.scope) {
|
|
862
|
+
// A deep link outranks the large-graph default and a report's preferred view.
|
|
863
|
+
const c = this.controller;
|
|
864
|
+
if (state.mode) c.explicit.add('mode');
|
|
865
|
+
if (state.scope) c.explicit.add('scope');
|
|
866
|
+
c.setView(state.mode ?? c.mode, state.scope ?? c.scope);
|
|
680
867
|
}
|
|
681
868
|
if (state.hunk) this.controller.selectHunkById(state.hunk, { fly: true });
|
|
682
869
|
}
|
|
@@ -685,7 +872,11 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
685
872
|
if (!this.hasAttribute('sync-hash') || typeof location === 'undefined') return;
|
|
686
873
|
const c = this.controller;
|
|
687
874
|
const hunk = c.selectedHunk >= 0 ? c.scene?.hunks[c.selectedHunk]?.id : undefined;
|
|
688
|
-
const hash = formatHash({
|
|
875
|
+
const hash = formatHash({
|
|
876
|
+
...(hunk !== undefined ? { hunk } : {}),
|
|
877
|
+
mode: c.mode,
|
|
878
|
+
scope: c.scope,
|
|
879
|
+
});
|
|
689
880
|
if (hash === location.hash) return;
|
|
690
881
|
this.#lastHash = hash;
|
|
691
882
|
try {
|
package/src/framing.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* float over the canvas; a fit or flight must keep its target clear of them without wasting the
|
|
4
4
|
* rest of the canvas. Each obstacle can be avoided through one side (an inset); all assignments
|
|
5
5
|
* are tried (4ⁿ, n ≤ ~6 visible chips) and the one that shows `bounds` largest wins, ties going
|
|
6
|
-
* to the free region closest to the canvas center.
|
|
6
|
+
* to the free region closest to the canvas center. `zoomFit` fits content whose world extent
|
|
7
|
+
* depends on the zoom (labels). Pure; all sizes in canvas px.
|
|
7
8
|
*/
|
|
8
9
|
import type { Rect } from '@kgdiff/core';
|
|
9
10
|
|
|
@@ -139,6 +140,69 @@ export function frameTarget(
|
|
|
139
140
|
return { x: cx - (sx - width / 2) / k, y: cy - (sy - height / 2) / k, k };
|
|
140
141
|
}
|
|
141
142
|
|
|
143
|
+
/** Lowest zoom a fit considers (the renderer's default `minZoom`). */
|
|
144
|
+
const MIN_FIT_ZOOM = 0.05;
|
|
145
|
+
/** Bisection steps for the zoom of a label-aware fit (a fraction of a percent). */
|
|
146
|
+
const FIT_STEPS = 16;
|
|
147
|
+
|
|
148
|
+
/** A fit whose content depends on the zoom it reaches. */
|
|
149
|
+
export interface ZoomFit {
|
|
150
|
+
/** The zoom the fit lands on. */
|
|
151
|
+
readonly k: number;
|
|
152
|
+
/** What it shows (world units). */
|
|
153
|
+
readonly content: Rect;
|
|
154
|
+
/** Bounds for the camera: `content` centred in a rect that a fit lands on at exactly `k`. */
|
|
155
|
+
readonly bounds: Rect;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Fit content whose world extent depends on the zoom, `extent(k)`: labels keep their pixel size
|
|
160
|
+
* (and change sides as they make room for each other), so the further out the camera, the more
|
|
161
|
+
* world they cover. Finds the largest zoom up to `maxK` at which the content fits the canvas
|
|
162
|
+
* minus `insets` at that very zoom, by bisection (what fits only grows on screen as the camera
|
|
163
|
+
* zooms in), and lands exactly there, since the labels could move at another zoom. When not
|
|
164
|
+
* even the smallest zoom has room, `extent(Infinity)` (the discs alone) is fitted plainly.
|
|
165
|
+
*/
|
|
166
|
+
export function zoomFit(
|
|
167
|
+
extent: (k: number) => Rect | null,
|
|
168
|
+
width: number,
|
|
169
|
+
height: number,
|
|
170
|
+
insets: Insets,
|
|
171
|
+
maxK: number,
|
|
172
|
+
): ZoomFit | null {
|
|
173
|
+
const fits = (k: number): boolean => {
|
|
174
|
+
const b = extent(k);
|
|
175
|
+
return b !== null && fitZoom(b, width, height, insets) >= k;
|
|
176
|
+
};
|
|
177
|
+
let k = maxK;
|
|
178
|
+
if (!fits(k)) {
|
|
179
|
+
let lo = Math.min(MIN_FIT_ZOOM, maxK);
|
|
180
|
+
if (!fits(lo)) {
|
|
181
|
+
const discs = extent(Number.POSITIVE_INFINITY);
|
|
182
|
+
if (!discs) return null;
|
|
183
|
+
return { k: fitZoom(discs, width, height, insets), content: discs, bounds: discs };
|
|
184
|
+
}
|
|
185
|
+
let hi = k;
|
|
186
|
+
for (let i = 0; i < FIT_STEPS; i++) {
|
|
187
|
+
const mid = Math.sqrt(lo * hi);
|
|
188
|
+
if (fits(mid)) lo = mid;
|
|
189
|
+
else hi = mid;
|
|
190
|
+
}
|
|
191
|
+
k = lo;
|
|
192
|
+
}
|
|
193
|
+
const content = extent(k);
|
|
194
|
+
if (!content) return null;
|
|
195
|
+
const w = (width - insets.left - insets.right) / k;
|
|
196
|
+
const h = (height - insets.top - insets.bottom) / k;
|
|
197
|
+
const bounds = {
|
|
198
|
+
x: content.x + content.width / 2 - w / 2,
|
|
199
|
+
y: content.y + content.height / 2 - h / 2,
|
|
200
|
+
width: w,
|
|
201
|
+
height: h,
|
|
202
|
+
};
|
|
203
|
+
return { k, content, bounds };
|
|
204
|
+
}
|
|
205
|
+
|
|
142
206
|
/** The part of `view` (world units) not covered by `insets` at zoom `k`. */
|
|
143
207
|
export function freeView(view: Rect, k: number, insets: Insets): Rect {
|
|
144
208
|
return {
|
package/src/hash-sync.ts
CHANGED
|
@@ -1,22 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Deep links for `sync-hash`: `#kgdiff=hunk:<id>;mode:<mode>`.
|
|
2
|
+
* Deep links for `sync-hash`: `#kgdiff=hunk:<id>;mode:<mode>;scope:<scope>`. The legacy
|
|
3
|
+
* `mode:changes` reads as overlay + the `changes` scope.
|
|
3
4
|
*
|
|
4
5
|
* Values are percent-encoded (hunk ids embed union ids, which may be IRIs containing `;`, `#`
|
|
5
6
|
* or `:`), so parsing splits on the first `:` of each `;`-separated field. Unknown fields are
|
|
6
7
|
* ignored; a malformed value drops just that field.
|
|
7
8
|
*/
|
|
8
|
-
import type { ViewerMode } from './index.js';
|
|
9
|
+
import type { ViewerMode, ViewerScope } from './index.js';
|
|
10
|
+
import { LEGACY_CHANGES_MODE, toMode, toScope } from './prefs.js';
|
|
9
11
|
|
|
10
12
|
export interface HashState {
|
|
11
13
|
readonly hunk?: string;
|
|
12
14
|
readonly mode?: ViewerMode;
|
|
15
|
+
readonly scope?: ViewerScope;
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
const PREFIX = 'kgdiff=';
|
|
16
|
-
const MODES: ReadonlySet<string> = new Set<ViewerMode>(['overlay', 'swipe', 'replay', 'changes']);
|
|
17
19
|
|
|
18
20
|
export function isViewerMode(value: unknown): value is ViewerMode {
|
|
19
|
-
return
|
|
21
|
+
return toMode(value) !== null;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
function decode(value: string): string | undefined {
|
|
@@ -33,6 +35,8 @@ export function parseHash(hash: string): HashState | null {
|
|
|
33
35
|
if (!raw.startsWith(PREFIX)) return null;
|
|
34
36
|
let hunk: string | undefined;
|
|
35
37
|
let mode: ViewerMode | undefined;
|
|
38
|
+
let scope: ViewerScope | undefined;
|
|
39
|
+
let legacy = false;
|
|
36
40
|
for (const field of raw.slice(PREFIX.length).split(';')) {
|
|
37
41
|
const colon = field.indexOf(':');
|
|
38
42
|
if (colon <= 0) continue;
|
|
@@ -40,9 +44,18 @@ export function parseHash(hash: string): HashState | null {
|
|
|
40
44
|
const value = decode(field.slice(colon + 1));
|
|
41
45
|
if (value === undefined || value === '') continue;
|
|
42
46
|
if (key === 'hunk') hunk = value;
|
|
43
|
-
else if (key === 'mode' &&
|
|
47
|
+
else if (key === 'mode' && value === LEGACY_CHANGES_MODE) {
|
|
48
|
+
mode = 'overlay';
|
|
49
|
+
legacy = true;
|
|
50
|
+
} else if (key === 'mode') mode = toMode(value) ?? mode;
|
|
51
|
+
else if (key === 'scope') scope = toScope(value) ?? scope;
|
|
44
52
|
}
|
|
45
|
-
|
|
53
|
+
if (legacy) scope ??= 'changes';
|
|
54
|
+
return {
|
|
55
|
+
...(hunk !== undefined ? { hunk } : {}),
|
|
56
|
+
...(mode !== undefined ? { mode } : {}),
|
|
57
|
+
...(scope !== undefined ? { scope } : {}),
|
|
58
|
+
};
|
|
46
59
|
}
|
|
47
60
|
|
|
48
61
|
/** Encode `state` as a hash (`#kgdiff=…`); empty state gives `#kgdiff=`. */
|
|
@@ -50,5 +63,6 @@ export function formatHash(state: HashState): string {
|
|
|
50
63
|
const fields: string[] = [];
|
|
51
64
|
if (state.hunk !== undefined) fields.push(`hunk:${encodeURIComponent(state.hunk)}`);
|
|
52
65
|
if (state.mode !== undefined) fields.push(`mode:${state.mode}`);
|
|
66
|
+
if (state.scope !== undefined) fields.push(`scope:${state.scope}`);
|
|
53
67
|
return `#${PREFIX}${fields.join(';')}`;
|
|
54
68
|
}
|
package/src/i18n.ts
CHANGED
|
@@ -10,11 +10,18 @@ import type { Locale } from '@kgdiff/core';
|
|
|
10
10
|
const en = {
|
|
11
11
|
'brand.tagline': 'git diff for knowledge graphs',
|
|
12
12
|
'header.compare': 'Compared versions',
|
|
13
|
-
'
|
|
13
|
+
'header.actions': 'Viewer actions',
|
|
14
|
+
'dock.label': 'View',
|
|
15
|
+
'mode.label': 'Compare by',
|
|
14
16
|
'mode.overlay': 'Overlay',
|
|
15
17
|
'mode.swipe': 'Swipe',
|
|
16
18
|
'mode.replay': 'Replay',
|
|
17
19
|
'mode.changes': 'Changes only',
|
|
20
|
+
'scope.hint': 'Show only the changes, with nearby context',
|
|
21
|
+
'scope.on': 'Showing only the changes',
|
|
22
|
+
'scope.off': 'Showing the whole graph',
|
|
23
|
+
'scope.locked':
|
|
24
|
+
'This graph was laid out around its changes only: the whole graph cannot be shown.',
|
|
18
25
|
'family.added': 'Added',
|
|
19
26
|
'family.removed': 'Removed',
|
|
20
27
|
'family.content': 'Content',
|
|
@@ -41,12 +48,6 @@ const en = {
|
|
|
41
48
|
'status.added': 'Added',
|
|
42
49
|
'status.removed': 'Removed',
|
|
43
50
|
'status.modified': 'Modified',
|
|
44
|
-
'stat.added': '{n} added',
|
|
45
|
-
'stat.removed': '{n} removed',
|
|
46
|
-
'stat.modified': '{n} modified',
|
|
47
|
-
'stat.restructured': '{n} restructured',
|
|
48
|
-
'stat.filter': 'Show only these changes',
|
|
49
|
-
'stat.label': 'Change summary',
|
|
50
51
|
'action.fit': 'Fit to view',
|
|
51
52
|
'action.help': 'Keyboard shortcuts',
|
|
52
53
|
'action.sidebar': 'Toggle sidebar',
|
|
@@ -199,11 +200,17 @@ export type Messages = Readonly<Record<MessageKey, string>>;
|
|
|
199
200
|
const zhCN: Messages = {
|
|
200
201
|
'brand.tagline': '知识图谱的 git diff',
|
|
201
202
|
'header.compare': '对比的版本',
|
|
202
|
-
'
|
|
203
|
+
'header.actions': '查看器操作',
|
|
204
|
+
'dock.label': '视图',
|
|
205
|
+
'mode.label': '对比方式',
|
|
203
206
|
'mode.overlay': '叠加',
|
|
204
207
|
'mode.swipe': '滑动对比',
|
|
205
208
|
'mode.replay': '回放',
|
|
206
209
|
'mode.changes': '仅看变更',
|
|
210
|
+
'scope.hint': '只显示变更,以及附近的上下文',
|
|
211
|
+
'scope.on': '只显示变更',
|
|
212
|
+
'scope.off': '显示全图',
|
|
213
|
+
'scope.locked': '这张图只对变更部分做了布局,无法显示全图。',
|
|
207
214
|
'family.added': '新增',
|
|
208
215
|
'family.removed': '删除',
|
|
209
216
|
'family.content': '内容',
|
|
@@ -230,12 +237,6 @@ const zhCN: Messages = {
|
|
|
230
237
|
'status.added': '新增',
|
|
231
238
|
'status.removed': '删除',
|
|
232
239
|
'status.modified': '修改',
|
|
233
|
-
'stat.added': '新增 {n} 处',
|
|
234
|
-
'stat.removed': '删除 {n} 处',
|
|
235
|
-
'stat.modified': '修改 {n} 处',
|
|
236
|
-
'stat.restructured': '结构调整 {n} 处',
|
|
237
|
-
'stat.filter': '只显示这类变更',
|
|
238
|
-
'stat.label': '变更概览',
|
|
239
240
|
'action.fit': '适应视图',
|
|
240
241
|
'action.help': '键盘快捷键',
|
|
241
242
|
'action.sidebar': '显示或隐藏侧栏',
|