@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/controller.ts
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* Viewer state and orchestration, independent of the UI:
|
|
3
3
|
*
|
|
4
4
|
* - load pipeline with progress steps (sources or a precomputed report);
|
|
5
|
-
* - mode, selection (with an animated spotlight `emphasis`), family filters,
|
|
6
|
-
*
|
|
5
|
+
* - view (mode × scope), selection (with an animated spotlight `emphasis`), family filters,
|
|
6
|
+
* review progress;
|
|
7
|
+
* - the `changes` scope's focus with expandable collapsed groups (`extendLayout` + scene rebuild);
|
|
7
8
|
* - replay clock driven by one requestAnimationFrame loop that sleeps when idle, with a camera
|
|
8
9
|
* that follows the playing hunk / chapter unless the user moved it in the last 2 s;
|
|
9
10
|
* - swipe position, hover, theme / palette / locale (rebuilding the scene when they change).
|
|
@@ -13,7 +14,14 @@
|
|
|
13
14
|
import type { DiffResult, DiffStats, Locale, Rect, UnionId } from '@kgdiff/core';
|
|
14
15
|
import { KgdiffError } from '@kgdiff/core';
|
|
15
16
|
import type { LayoutResult } from '@kgdiff/layout';
|
|
16
|
-
import
|
|
17
|
+
import {
|
|
18
|
+
type Camera,
|
|
19
|
+
type CameraState,
|
|
20
|
+
labelFootprint,
|
|
21
|
+
type Minimap,
|
|
22
|
+
type PickResult,
|
|
23
|
+
type Renderer,
|
|
24
|
+
} from '@kgdiff/renderer';
|
|
17
25
|
import {
|
|
18
26
|
buildScene,
|
|
19
27
|
type ChangeFamily,
|
|
@@ -40,6 +48,7 @@ import {
|
|
|
40
48
|
freeView,
|
|
41
49
|
type Insets,
|
|
42
50
|
NO_INSETS,
|
|
51
|
+
zoomFit,
|
|
43
52
|
} from './framing.js';
|
|
44
53
|
import { translate } from './i18n.js';
|
|
45
54
|
import type {
|
|
@@ -48,10 +57,11 @@ import type {
|
|
|
48
57
|
ReviewState,
|
|
49
58
|
ThemePreference,
|
|
50
59
|
ViewerMode,
|
|
60
|
+
ViewerScope,
|
|
51
61
|
} from './index.js';
|
|
52
62
|
import { buildList, EMPTY_LIST, type ListModel, stepHunk } from './list-model.js';
|
|
53
63
|
import { type LoadedDiff, type LoadStep, readPayload, runLoad } from './pipeline.js';
|
|
54
|
-
import { toLocale, toMode, toPalette, toTheme } from './prefs.js';
|
|
64
|
+
import { LEGACY_CHANGES_MODE, toLocale, toMode, toPalette, toScope, toTheme } from './prefs.js';
|
|
55
65
|
import { renderBackend } from './renderer-port.js';
|
|
56
66
|
import { ReplayClock } from './replay.js';
|
|
57
67
|
import { ReviewStore, reviewKey, type StorageLike } from './review-store.js';
|
|
@@ -71,15 +81,24 @@ export type Topic =
|
|
|
71
81
|
| 'theme'
|
|
72
82
|
| 'locale'
|
|
73
83
|
| 'focus'
|
|
74
|
-
| 'swipe'
|
|
84
|
+
| 'swipe'
|
|
85
|
+
| 'overview';
|
|
75
86
|
|
|
76
87
|
export interface ControllerHost {
|
|
77
88
|
ready(stats: DiffStats): void;
|
|
78
89
|
select(hunk: string | null, node: string | null): void;
|
|
79
|
-
mode(mode: ViewerMode): void;
|
|
90
|
+
mode(mode: ViewerMode, scope: ViewerScope): void;
|
|
80
91
|
review(hunk: string, reviewed: boolean, progress: ReviewState): void;
|
|
81
92
|
error(error: Error): void;
|
|
82
93
|
announce(text: string): void;
|
|
94
|
+
/** Playback reached the end of the replay; `loop`: it starts again after the loop delay. */
|
|
95
|
+
replayEnd(loop: boolean): void;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface FitOptions {
|
|
99
|
+
readonly animate?: boolean;
|
|
100
|
+
/** Room around the content, px (default `FRAME_PADDING`); kept for later automatic fits. */
|
|
101
|
+
readonly padding?: number;
|
|
83
102
|
}
|
|
84
103
|
|
|
85
104
|
export interface ControllerDeps {
|
|
@@ -91,7 +110,7 @@ export interface ControllerDeps {
|
|
|
91
110
|
readonly storage: StorageLike | null;
|
|
92
111
|
}
|
|
93
112
|
|
|
94
|
-
export type Preference = 'mode' | 'theme' | 'palette' | 'locale';
|
|
113
|
+
export type Preference = 'mode' | 'scope' | 'theme' | 'palette' | 'locale';
|
|
95
114
|
|
|
96
115
|
/** Camera follow is suppressed this long after the user moved the camera (ms). */
|
|
97
116
|
export const USER_CAMERA_GRACE = 2000;
|
|
@@ -105,6 +124,8 @@ export const FLY_MAX_ZOOM = 1.8;
|
|
|
105
124
|
export const FIT_MAX_ZOOM = 2.5;
|
|
106
125
|
/** Minimap marks are re-rasterized at most this often while replaying (ms). */
|
|
107
126
|
const MINIMAP_INTERVAL = 200;
|
|
127
|
+
/** Everything counts as in view (the minimap hides) from this share of the content bounds on. */
|
|
128
|
+
export const OVERVIEW_COVERAGE = 0.98;
|
|
108
129
|
|
|
109
130
|
class Superseded extends Error {}
|
|
110
131
|
|
|
@@ -153,6 +174,7 @@ export class ViewerController {
|
|
|
153
174
|
list: ListModel = EMPTY_LIST;
|
|
154
175
|
|
|
155
176
|
mode: ViewerMode = 'overlay';
|
|
177
|
+
scope: ViewerScope = 'all';
|
|
156
178
|
themePreference: ThemePreference = 'auto';
|
|
157
179
|
palette: PaletteName = 'default';
|
|
158
180
|
locale: Locale = 'en';
|
|
@@ -175,6 +197,16 @@ export class ViewerController {
|
|
|
175
197
|
clock: ReplayClock | null = null;
|
|
176
198
|
split = 0.5;
|
|
177
199
|
emphasis = 1;
|
|
200
|
+
/**
|
|
201
|
+
* Keep the view fitted (embeds without controls): fit again when the canvas is resized unless
|
|
202
|
+
* the user moved the camera since, and at the start of every loop.
|
|
203
|
+
*/
|
|
204
|
+
autoFit = false;
|
|
205
|
+
/** Canvas clicks select (and double clicks fly); off for embeds without controls. */
|
|
206
|
+
selectOnClick = true;
|
|
207
|
+
#labelsInside = false;
|
|
208
|
+
/** The whole content is in view (the minimap has nothing to add). */
|
|
209
|
+
overview = true;
|
|
178
210
|
|
|
179
211
|
#review: ReviewStore | null = null;
|
|
180
212
|
#searchIndex: SearchIndex | null = null;
|
|
@@ -193,11 +225,26 @@ export class ViewerController {
|
|
|
193
225
|
#followed = '';
|
|
194
226
|
#loadToken = 0;
|
|
195
227
|
#pendingHunk: { readonly id: string; readonly fly: boolean } | null = null;
|
|
228
|
+
#boundsCache: {
|
|
229
|
+
readonly scene: Scene;
|
|
230
|
+
readonly focus: FocusState | null;
|
|
231
|
+
readonly rect: Rect | null;
|
|
232
|
+
} | null = null;
|
|
196
233
|
#chrome: (() => ChromeLayout) | null = null;
|
|
197
234
|
/** Camera kept across detach / attach (element moved in the DOM). */
|
|
198
235
|
#savedCamera: CameraState | null = null;
|
|
199
236
|
/** A new diff is installed but has not been fitted to a laid-out canvas yet. */
|
|
200
237
|
#needsFit = false;
|
|
238
|
+
/** The user panned or zoomed since the last fit. */
|
|
239
|
+
#userMoved = false;
|
|
240
|
+
#fitPadding = FRAME_PADDING;
|
|
241
|
+
/** A finished replay starts again this long (ms) after its end; null: it plays once. */
|
|
242
|
+
#loopDelay: number | null = null;
|
|
243
|
+
/** When a finished looping replay starts again (`deps.now()` time), else NaN. */
|
|
244
|
+
#restartAt = Number.NaN;
|
|
245
|
+
/** Playback is held (pointer over the graph); `#resume`: it was playing and continues after. */
|
|
246
|
+
#held = false;
|
|
247
|
+
#resume = false;
|
|
201
248
|
readonly #listeners = new Set<(topics: ReadonlySet<Topic>) => void>();
|
|
202
249
|
#pendingTopics = new Set<Topic>();
|
|
203
250
|
#flushQueued = false;
|
|
@@ -325,6 +372,8 @@ export class ViewerController {
|
|
|
325
372
|
fail(error: Error): void {
|
|
326
373
|
this.#loadToken++;
|
|
327
374
|
this.clock?.pause();
|
|
375
|
+
this.#restartAt = Number.NaN;
|
|
376
|
+
this.#resume = false;
|
|
328
377
|
this.status = 'error';
|
|
329
378
|
this.error = error;
|
|
330
379
|
this.step = null;
|
|
@@ -366,10 +415,14 @@ export class ViewerController {
|
|
|
366
415
|
if (view.locale && !this.explicit.has('locale')) this.locale = view.locale;
|
|
367
416
|
}
|
|
368
417
|
const previousMode = this.mode;
|
|
369
|
-
|
|
418
|
+
const previousScope = this.scope;
|
|
419
|
+
if (!this.explicit.has('mode')) this.mode = view?.mode ?? 'overlay';
|
|
420
|
+
if (layout.scope === 'changes') {
|
|
421
|
+
// Only the changes are laid out: there is no whole graph to show.
|
|
422
|
+
this.scope = 'changes';
|
|
423
|
+
} else if (!this.explicit.has('scope')) {
|
|
370
424
|
const threshold = loaded.layoutOptions?.largeGraphThreshold ?? 3000;
|
|
371
|
-
|
|
372
|
-
this.mode = view?.mode ?? (large ? 'changes' : 'overlay');
|
|
425
|
+
this.scope = view?.scope ?? (diff.nodes.length > threshold ? 'changes' : 'all');
|
|
373
426
|
}
|
|
374
427
|
this.#review = new ReviewStore(
|
|
375
428
|
this.#deps.storage,
|
|
@@ -398,6 +451,7 @@ export class ViewerController {
|
|
|
398
451
|
'locale',
|
|
399
452
|
'focus',
|
|
400
453
|
'swipe',
|
|
454
|
+
'overview',
|
|
401
455
|
);
|
|
402
456
|
// A new diff starts fitted, after the UI has shown its chrome (the notification above is
|
|
403
457
|
// delivered first) and once the canvas has a size (see `layoutChanged`).
|
|
@@ -405,10 +459,13 @@ export class ViewerController {
|
|
|
405
459
|
this.#savedCamera = null;
|
|
406
460
|
queueMicrotask(() => this.layoutChanged());
|
|
407
461
|
this.#host.ready(diff.stats);
|
|
408
|
-
if (this.mode !== previousMode
|
|
462
|
+
if (this.mode !== previousMode || this.scope !== previousScope) {
|
|
463
|
+
this.#host.mode(this.mode, this.scope);
|
|
464
|
+
}
|
|
409
465
|
const pending = this.#pendingHunk;
|
|
410
466
|
this.#pendingHunk = null;
|
|
411
467
|
if (pending !== null) this.selectHunkById(pending.id, { fly: pending.fly });
|
|
468
|
+
else this.#autoplay();
|
|
412
469
|
}
|
|
413
470
|
|
|
414
471
|
/** (Re)build scene, resolver, list, clock and focus; selection is carried over by id. */
|
|
@@ -445,7 +502,7 @@ export class ViewerController {
|
|
|
445
502
|
|
|
446
503
|
#updateFocus(): void {
|
|
447
504
|
const scene = this.scene;
|
|
448
|
-
if (!scene || this.
|
|
505
|
+
if (!scene || this.scope !== 'changes') {
|
|
449
506
|
this.focus = null;
|
|
450
507
|
return;
|
|
451
508
|
}
|
|
@@ -462,7 +519,10 @@ export class ViewerController {
|
|
|
462
519
|
attach(stage: HTMLElement, minimapHost: HTMLElement | null): void {
|
|
463
520
|
if (this.#renderer) return;
|
|
464
521
|
const backend = renderBackend();
|
|
465
|
-
const renderer = backend.createRenderer(stage, {
|
|
522
|
+
const renderer = backend.createRenderer(stage, {
|
|
523
|
+
theme: this.theme,
|
|
524
|
+
labelsInside: this.#labelsInside,
|
|
525
|
+
});
|
|
466
526
|
this.#renderer = renderer;
|
|
467
527
|
// Re-attached (element moved): keep the view; this also stops the renderer's auto-fit.
|
|
468
528
|
if (this.#savedCamera) renderer.camera.set(this.#savedCamera);
|
|
@@ -473,6 +533,7 @@ export class ViewerController {
|
|
|
473
533
|
renderer.on('dblclick', ({ pick }) => this.#onCanvasDblClick(pick)),
|
|
474
534
|
renderer.on('camera', ({ source }) => {
|
|
475
535
|
if (source === 'user') this.noteUserCamera();
|
|
536
|
+
this.#updateOverview();
|
|
476
537
|
}),
|
|
477
538
|
);
|
|
478
539
|
this.#minimapDirty = true;
|
|
@@ -509,9 +570,26 @@ export class ViewerController {
|
|
|
509
570
|
this.#chrome = provider;
|
|
510
571
|
}
|
|
511
572
|
|
|
512
|
-
/**
|
|
573
|
+
/**
|
|
574
|
+
* Keep labels whole inside the canvas (embeds without camera controls): a label pushed from
|
|
575
|
+
* its spot by a neighbour moves inside or is left out, never cut at the edge.
|
|
576
|
+
*/
|
|
577
|
+
setLabelsInside(inside: boolean): void {
|
|
578
|
+
this.#labelsInside = inside;
|
|
579
|
+
this.#renderer?.setLabelsInside(inside);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* The canvas was laid out or resized: run the initial fit a new diff is waiting for, or (with
|
|
584
|
+
* `autoFit`) fit again unless the user moved the camera since the last fit.
|
|
585
|
+
*/
|
|
513
586
|
layoutChanged(): void {
|
|
514
|
-
|
|
587
|
+
this.#updateOverview();
|
|
588
|
+
if (this.status !== 'ready') return;
|
|
589
|
+
if (!this.#needsFit) {
|
|
590
|
+
if (this.autoFit && !this.#userMoved) void this.fit({ animate: false });
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
515
593
|
// A selection made before the first layout (deep link, `kgdiff-ready` handler) wins.
|
|
516
594
|
const target = this.#selectionBounds();
|
|
517
595
|
if (target) {
|
|
@@ -520,17 +598,16 @@ export class ViewerController {
|
|
|
520
598
|
this.#flyTo(target, { animate: false });
|
|
521
599
|
return;
|
|
522
600
|
}
|
|
523
|
-
const
|
|
524
|
-
const padding = bounds ? this.#frameInsets(bounds, FIT_MAX_ZOOM) : null;
|
|
601
|
+
const fit = this.#fitTarget();
|
|
525
602
|
const camera = this.#renderer?.camera;
|
|
526
|
-
if (!
|
|
527
|
-
this.#
|
|
528
|
-
void camera.fit(bounds, { animate: false, padding, maxZoom: FIT_MAX_ZOOM });
|
|
603
|
+
if (!fit || !camera) return;
|
|
604
|
+
void this.#fitTo(camera, fit, { animate: false });
|
|
529
605
|
}
|
|
530
606
|
|
|
531
607
|
/** The user panned / zoomed: suspend replay camera follow for `USER_CAMERA_GRACE`. */
|
|
532
608
|
noteUserCamera(): void {
|
|
533
609
|
this.#lastUserCamera = this.#deps.now();
|
|
610
|
+
this.#userMoved = true;
|
|
534
611
|
}
|
|
535
612
|
|
|
536
613
|
// ─── Frame loop ───────────────────────────────────────────────────────────
|
|
@@ -559,7 +636,11 @@ export class ViewerController {
|
|
|
559
636
|
this.#notify('replay');
|
|
560
637
|
this.#follow();
|
|
561
638
|
if (clock.playing) animating = true;
|
|
562
|
-
else if (clock.finished) this.#
|
|
639
|
+
else if (clock.finished) this.#ended();
|
|
640
|
+
}
|
|
641
|
+
if (!Number.isNaN(this.#restartAt) && !this.#held) {
|
|
642
|
+
if (now >= this.#restartAt) this.#loopAgain();
|
|
643
|
+
else animating = true;
|
|
563
644
|
}
|
|
564
645
|
if (this.#emphasisAnim) {
|
|
565
646
|
const a = this.#emphasisAnim;
|
|
@@ -659,24 +740,74 @@ export class ViewerController {
|
|
|
659
740
|
};
|
|
660
741
|
}
|
|
661
742
|
|
|
662
|
-
// ───
|
|
743
|
+
// ─── View: mode × scope ───────────────────────────────────────────────────
|
|
744
|
+
|
|
745
|
+
/** Whether the scope is fixed to `changes` (the layout covers only the changes). */
|
|
746
|
+
get scopeLocked(): boolean {
|
|
747
|
+
return this.layout?.scope === 'changes';
|
|
748
|
+
}
|
|
663
749
|
|
|
664
|
-
|
|
750
|
+
/** Switch how the versions are compared. The legacy `changes` means overlay + `changes` scope. */
|
|
751
|
+
setMode(value: ViewerMode | typeof LEGACY_CHANGES_MODE): void {
|
|
752
|
+
if (value === LEGACY_CHANGES_MODE) {
|
|
753
|
+
this.setView('overlay', 'changes');
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
665
756
|
const mode = toMode(value);
|
|
666
|
-
if (mode
|
|
757
|
+
if (mode !== null) this.setView(mode, this.scope);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** Show the whole graph or only the changes, in any mode. */
|
|
761
|
+
setScope(value: ViewerScope): void {
|
|
762
|
+
const scope = toScope(value);
|
|
763
|
+
if (scope !== null) this.setView(this.mode, scope);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/** Toggle the `changes` scope (key `4`); says why when the scope is locked. */
|
|
767
|
+
toggleScope(): void {
|
|
768
|
+
if (this.scopeLocked) {
|
|
769
|
+
this.#host.announce(translate(this.locale, 'scope.locked'));
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
this.setScope(this.scope === 'changes' ? 'all' : 'changes');
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** Set mode and scope together (one notification, one `kgdiff-mode` event). */
|
|
776
|
+
setView(mode: ViewerMode, scope: ViewerScope): void {
|
|
777
|
+
const nextScope: ViewerScope = this.scopeLocked ? 'changes' : scope;
|
|
778
|
+
const modeChanged = mode !== this.mode;
|
|
779
|
+
const scopeChanged = nextScope !== this.scope;
|
|
780
|
+
if (!modeChanged && !scopeChanged) return;
|
|
667
781
|
this.mode = mode;
|
|
668
|
-
|
|
782
|
+
this.scope = nextScope;
|
|
783
|
+
if (mode !== 'replay') {
|
|
784
|
+
this.clock?.pause();
|
|
785
|
+
this.#restartAt = Number.NaN;
|
|
786
|
+
this.#resume = false;
|
|
787
|
+
}
|
|
669
788
|
this.#followed = '';
|
|
670
|
-
|
|
789
|
+
if (scopeChanged) {
|
|
790
|
+
this.#updateFocus();
|
|
791
|
+
this.#updateOverview();
|
|
792
|
+
}
|
|
671
793
|
this.#minimapDirty = true;
|
|
672
794
|
this.#notify('mode', 'focus', 'replay');
|
|
673
795
|
this.invalidate();
|
|
674
796
|
if (this.status === 'ready') {
|
|
675
|
-
this.#host.mode(mode);
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
797
|
+
this.#host.mode(mode, nextScope);
|
|
798
|
+
const parts: string[] = [];
|
|
799
|
+
if (modeChanged) {
|
|
800
|
+
parts.push(
|
|
801
|
+
translate(this.locale, 'announce.mode', { mode: translate(this.locale, `mode.${mode}`) }),
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
if (scopeChanged) {
|
|
805
|
+
parts.push(translate(this.locale, nextScope === 'changes' ? 'scope.on' : 'scope.off'));
|
|
806
|
+
}
|
|
807
|
+
this.#host.announce(parts.join(' · '));
|
|
679
808
|
}
|
|
809
|
+
// Switching to replay starts a looping replay; toggling the scope leaves playback alone.
|
|
810
|
+
if (modeChanged && mode === 'replay') this.#autoplay();
|
|
680
811
|
}
|
|
681
812
|
|
|
682
813
|
// ─── Selection ────────────────────────────────────────────────────────────
|
|
@@ -719,6 +850,8 @@ export class ViewerController {
|
|
|
719
850
|
if (this.mode === 'replay' && this.clock) {
|
|
720
851
|
const end = this.clock.windowEnd(h);
|
|
721
852
|
this.clock.pause();
|
|
853
|
+
this.#restartAt = Number.NaN;
|
|
854
|
+
this.#resume = false;
|
|
722
855
|
if (end !== null) this.clock.seek(end);
|
|
723
856
|
this.#followed = '';
|
|
724
857
|
this.#notify('replay');
|
|
@@ -933,41 +1066,86 @@ export class ViewerController {
|
|
|
933
1066
|
}
|
|
934
1067
|
|
|
935
1068
|
/**
|
|
936
|
-
* Fit everything visible (the focus in changes-only mode) into the free part
|
|
937
|
-
* Before the canvas is laid out it falls back to the renderer's own fit.
|
|
1069
|
+
* Fit everything visible (the focus in changes-only mode), labels included, into the free part
|
|
1070
|
+
* of the canvas. Before the canvas is laid out it falls back to the renderer's own fit.
|
|
938
1071
|
*/
|
|
939
|
-
fit(options:
|
|
1072
|
+
fit(options: FitOptions = {}): Promise<void> {
|
|
940
1073
|
const camera = this.#renderer?.camera;
|
|
941
1074
|
if (!camera) return Promise.resolve();
|
|
942
|
-
const
|
|
943
|
-
|
|
944
|
-
|
|
1075
|
+
const { padding, ...transition } = options;
|
|
1076
|
+
if (padding !== undefined) {
|
|
1077
|
+
this.#fitPadding = Number.isFinite(padding) && padding >= 0 ? padding : FRAME_PADDING;
|
|
1078
|
+
}
|
|
1079
|
+
const fit = this.#fitTarget();
|
|
1080
|
+
if (!fit) return camera.fit(undefined, transition);
|
|
1081
|
+
return this.#fitTo(camera, fit, transition);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
#fitTo(
|
|
1085
|
+
camera: Camera,
|
|
1086
|
+
fit: { readonly bounds: Rect; readonly insets: Insets },
|
|
1087
|
+
transition: { readonly animate?: boolean },
|
|
1088
|
+
): Promise<void> {
|
|
945
1089
|
this.#needsFit = false;
|
|
946
|
-
|
|
1090
|
+
this.#userMoved = false;
|
|
1091
|
+
return camera.fit(fit.bounds, { ...transition, padding: fit.insets, maxZoom: FIT_MAX_ZOOM });
|
|
947
1092
|
}
|
|
948
1093
|
|
|
949
|
-
/**
|
|
1094
|
+
/**
|
|
1095
|
+
* Bounds and insets that show everything visible (the changes-only focus) with every label
|
|
1096
|
+
* in its spot below its node, clear of the chrome, or null before layout. Measured in the
|
|
1097
|
+
* union layer, which holds every state a replay or swipe passes through. Insets depend on
|
|
1098
|
+
* what is framed, which depends on the zoom they allow: they are settled once more.
|
|
1099
|
+
*/
|
|
1100
|
+
#fitTarget(): { bounds: Rect; insets: Insets } | null {
|
|
1101
|
+
const layout = this.#chrome?.();
|
|
1102
|
+
const resolver = this.resolver;
|
|
1103
|
+
if (!layout || !resolver || !(layout.width > 0) || !(layout.height > 0)) return null;
|
|
1104
|
+
const frame = resolver.resolve(
|
|
1105
|
+
createViewState({
|
|
1106
|
+
layer: 'union',
|
|
1107
|
+
focus: this.focus,
|
|
1108
|
+
families: this.families,
|
|
1109
|
+
reducedMotion: this.reducedMotion,
|
|
1110
|
+
}),
|
|
1111
|
+
);
|
|
1112
|
+
const footprint = labelFootprint(frame, this.theme);
|
|
1113
|
+
const extent = (k: number): Rect | null => footprint.at(k);
|
|
1114
|
+
const discs = extent(Number.POSITIVE_INFINITY);
|
|
1115
|
+
if (!discs) return null;
|
|
1116
|
+
const { width, height } = layout;
|
|
1117
|
+
const padding = this.#fitPadding;
|
|
1118
|
+
let insets = chromeInsets(discs, layout, padding, FIT_MAX_ZOOM);
|
|
1119
|
+
let fit = zoomFit(extent, width, height, insets, FIT_MAX_ZOOM);
|
|
1120
|
+
if (!fit) return null;
|
|
1121
|
+
const settled = chromeInsets(fit.content, layout, padding, FIT_MAX_ZOOM);
|
|
1122
|
+
if (!sameInsets(settled, insets)) {
|
|
1123
|
+
insets = settled;
|
|
1124
|
+
fit = zoomFit(extent, width, height, insets, FIT_MAX_ZOOM) ?? fit;
|
|
1125
|
+
}
|
|
1126
|
+
return { bounds: fit.bounds, insets };
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/** Bounds of what is drawn: the scene, or the visible part of the `changes` scope's focus. */
|
|
950
1130
|
#contentBounds(): Rect | null {
|
|
951
1131
|
const scene = this.scene;
|
|
952
1132
|
if (!scene) return null;
|
|
953
1133
|
const focus = this.focus;
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
if (!(x0 <= x1)) return frameBounds(scene);
|
|
970
|
-
return padRect({ x: x0, y: y0, width: x1 - x0, height: y1 - y0 }, 160, 1.12);
|
|
1134
|
+
const cached = this.#boundsCache;
|
|
1135
|
+
if (cached && cached.scene === scene && cached.focus === focus) return cached.rect;
|
|
1136
|
+
const rect = focus ? focusBounds(scene, focus) : frameBounds(scene);
|
|
1137
|
+
this.#boundsCache = { scene, focus, rect };
|
|
1138
|
+
return rect;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/** Whether everything drawn is in view; the minimap hides while it is. */
|
|
1142
|
+
#updateOverview(): void {
|
|
1143
|
+
const bounds = this.#contentBounds();
|
|
1144
|
+
const view = this.#renderer?.camera.viewport();
|
|
1145
|
+
const next = !bounds || !view || coverage(bounds, view) >= OVERVIEW_COVERAGE;
|
|
1146
|
+
if (next === this.overview) return;
|
|
1147
|
+
this.overview = next;
|
|
1148
|
+
this.#notify('overview');
|
|
971
1149
|
}
|
|
972
1150
|
|
|
973
1151
|
zoom(direction: 1 | -1): void {
|
|
@@ -1014,6 +1192,7 @@ export class ViewerController {
|
|
|
1014
1192
|
}
|
|
1015
1193
|
|
|
1016
1194
|
#onCanvasClick(pick: PickResult | null): void {
|
|
1195
|
+
if (!this.selectOnClick) return;
|
|
1017
1196
|
if (!pick) {
|
|
1018
1197
|
this.clearSelection();
|
|
1019
1198
|
return;
|
|
@@ -1038,7 +1217,7 @@ export class ViewerController {
|
|
|
1038
1217
|
}
|
|
1039
1218
|
|
|
1040
1219
|
#onCanvasDblClick(pick: PickResult | null): void {
|
|
1041
|
-
if (pick?.type !== 'node') return;
|
|
1220
|
+
if (!this.selectOnClick || pick?.type !== 'node') return;
|
|
1042
1221
|
const n = this.scene?.nodes[pick.index];
|
|
1043
1222
|
if (!n) return;
|
|
1044
1223
|
if (n.hunk >= 0) this.flyToHunk(n.hunk);
|
|
@@ -1074,6 +1253,7 @@ export class ViewerController {
|
|
|
1074
1253
|
if (h === this.hops) return;
|
|
1075
1254
|
this.hops = h;
|
|
1076
1255
|
this.#updateFocus();
|
|
1256
|
+
this.#updateOverview();
|
|
1077
1257
|
this.#minimapDirty = true;
|
|
1078
1258
|
this.#notify('focus');
|
|
1079
1259
|
this.invalidate();
|
|
@@ -1200,6 +1380,7 @@ export class ViewerController {
|
|
|
1200
1380
|
|
|
1201
1381
|
#focusChanged(): void {
|
|
1202
1382
|
this.#updateFocus();
|
|
1383
|
+
this.#updateOverview();
|
|
1203
1384
|
this.#minimapDirty = true;
|
|
1204
1385
|
this.#notify('focus');
|
|
1205
1386
|
this.invalidate();
|
|
@@ -1230,6 +1411,8 @@ export class ViewerController {
|
|
|
1230
1411
|
const clock = this.clock;
|
|
1231
1412
|
if (!clock || this.status !== 'ready') return;
|
|
1232
1413
|
if (this.mode !== 'replay') this.setMode('replay');
|
|
1414
|
+
this.#restartAt = Number.NaN;
|
|
1415
|
+
this.#resume = false;
|
|
1233
1416
|
clock.play();
|
|
1234
1417
|
this.#followed = '';
|
|
1235
1418
|
this.#lastTick = 0;
|
|
@@ -1237,7 +1420,10 @@ export class ViewerController {
|
|
|
1237
1420
|
this.invalidate();
|
|
1238
1421
|
}
|
|
1239
1422
|
|
|
1423
|
+
/** Pause; a looping replay stays paused (until `play`, a new diff or loop turned on again). */
|
|
1240
1424
|
pause(): void {
|
|
1425
|
+
this.#restartAt = Number.NaN;
|
|
1426
|
+
this.#resume = false;
|
|
1241
1427
|
if (!this.clock?.playing) return;
|
|
1242
1428
|
this.clock.pause();
|
|
1243
1429
|
this.#notify('replay');
|
|
@@ -1249,9 +1435,73 @@ export class ViewerController {
|
|
|
1249
1435
|
else this.play();
|
|
1250
1436
|
}
|
|
1251
1437
|
|
|
1438
|
+
/**
|
|
1439
|
+
* Play the replay over and over: `delay` ms after it ends it starts again; null plays it
|
|
1440
|
+
* once. Turning the loop on starts a replay that is shown but not playing.
|
|
1441
|
+
*/
|
|
1442
|
+
setLoop(delay: number | null): void {
|
|
1443
|
+
const was = this.#loopDelay;
|
|
1444
|
+
this.#loopDelay = delay === null ? null : Number.isFinite(delay) ? Math.max(0, delay) : 0;
|
|
1445
|
+
if (this.#loopDelay === null) this.#restartAt = Number.NaN;
|
|
1446
|
+
else if (was === null) this.#autoplay();
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
get loopDelay(): number | null {
|
|
1450
|
+
return this.#loopDelay;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
/**
|
|
1454
|
+
* Hold playback while the pointer rests on the graph (`pause-on-hover`): a playing replay
|
|
1455
|
+
* pauses and continues on release; a pending loop restart waits for the release, then the
|
|
1456
|
+
* full loop delay.
|
|
1457
|
+
*/
|
|
1458
|
+
hold(held: boolean): void {
|
|
1459
|
+
if (held === this.#held) return;
|
|
1460
|
+
this.#held = held;
|
|
1461
|
+
const clock = this.clock;
|
|
1462
|
+
if (held) {
|
|
1463
|
+
if (!clock?.playing) return;
|
|
1464
|
+
clock.pause();
|
|
1465
|
+
this.#resume = true;
|
|
1466
|
+
this.#notify('replay');
|
|
1467
|
+
this.invalidate();
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
if (!Number.isNaN(this.#restartAt) && this.#loopDelay !== null) {
|
|
1471
|
+
this.#restartAt = this.#deps.now() + this.#loopDelay;
|
|
1472
|
+
this.#schedule();
|
|
1473
|
+
}
|
|
1474
|
+
if (this.#resume) this.play();
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
/** Looping and showing a replay that is not playing: start it (or after the hold). */
|
|
1478
|
+
#autoplay(): void {
|
|
1479
|
+
if (this.#loopDelay === null || this.status !== 'ready' || this.mode !== 'replay') return;
|
|
1480
|
+
if (!this.clock || this.clock.playing || !Number.isNaN(this.#restartAt)) return;
|
|
1481
|
+
if (this.#held) this.#resume = true;
|
|
1482
|
+
else this.play();
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
/** Playback reached the end: announce it, tell the host and arm the loop restart. */
|
|
1486
|
+
#ended(): void {
|
|
1487
|
+
this.#host.announce(translate(this.locale, 'announce.finished'));
|
|
1488
|
+
const loop = this.#loopDelay !== null;
|
|
1489
|
+
// Armed first, so a `kgdiff-replay-end` listener can still cancel it (pause, loop off).
|
|
1490
|
+
if (loop) this.#restartAt = this.#deps.now() + (this.#loopDelay ?? 0);
|
|
1491
|
+
this.#host.replayEnd(loop);
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
#loopAgain(): void {
|
|
1495
|
+
this.#restartAt = Number.NaN;
|
|
1496
|
+
if (this.autoFit) void this.fit();
|
|
1497
|
+
this.clock?.seek(0);
|
|
1498
|
+
this.play();
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1252
1501
|
seek(t: number): void {
|
|
1253
1502
|
const clock = this.clock;
|
|
1254
1503
|
if (!clock) return;
|
|
1504
|
+
this.#restartAt = Number.NaN;
|
|
1255
1505
|
clock.seek(t);
|
|
1256
1506
|
this.#notify('replay');
|
|
1257
1507
|
this.#follow();
|
|
@@ -1262,6 +1512,8 @@ export class ViewerController {
|
|
|
1262
1512
|
const clock = this.clock;
|
|
1263
1513
|
if (!clock) return;
|
|
1264
1514
|
clock.step(direction);
|
|
1515
|
+
this.#restartAt = Number.NaN;
|
|
1516
|
+
this.#resume = false;
|
|
1265
1517
|
this.#followed = '';
|
|
1266
1518
|
// Follow (and announce) the hunk that just completed: stops sit at window ends.
|
|
1267
1519
|
const w = clock.timeline.windows[clock.position() - 1];
|
|
@@ -1385,6 +1637,30 @@ function isString(x: string | undefined): x is string {
|
|
|
1385
1637
|
return x !== undefined;
|
|
1386
1638
|
}
|
|
1387
1639
|
|
|
1640
|
+
function sameInsets(a: Insets, b: Insets): boolean {
|
|
1641
|
+
return a.top === b.top && a.right === b.right && a.bottom === b.bottom && a.left === b.left;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
/** Bounds of the visible part of `focus`, with room for labels and the collapsed pills. */
|
|
1645
|
+
function focusBounds(scene: Scene, focus: FocusState): Rect {
|
|
1646
|
+
let x0 = Number.POSITIVE_INFINITY;
|
|
1647
|
+
let y0 = Number.POSITIVE_INFINITY;
|
|
1648
|
+
let x1 = Number.NEGATIVE_INFINITY;
|
|
1649
|
+
let y1 = Number.NEGATIVE_INFINITY;
|
|
1650
|
+
const add = (x: number, y: number, rx: number, top: number, bottom: number): void => {
|
|
1651
|
+
x0 = Math.min(x0, x - rx);
|
|
1652
|
+
x1 = Math.max(x1, x + rx);
|
|
1653
|
+
y0 = Math.min(y0, y - top);
|
|
1654
|
+
y1 = Math.max(y1, y + bottom);
|
|
1655
|
+
};
|
|
1656
|
+
// Labels sit centered below their node: leave room for a typical one.
|
|
1657
|
+
for (const n of scene.nodes)
|
|
1658
|
+
if (focus.nodes[n.index] === 1) add(n.x, n.y, n.r + 48, n.r, n.r + 24);
|
|
1659
|
+
for (const g of focus.collapsed) add(g.x, g.y, 48, 12, 12);
|
|
1660
|
+
if (!(x0 <= x1)) return frameBounds(scene);
|
|
1661
|
+
return padRect({ x: x0, y: y0, width: x1 - x0, height: y1 - y0 }, 160, 1.12);
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1388
1664
|
/** Pad a small rect so a node is shown with its neighbourhood. */
|
|
1389
1665
|
function padRect(r: Rect, min = 200, ratio = 1.4): Rect {
|
|
1390
1666
|
const w = Math.max(min, r.width * ratio);
|