@kgdiff/viewer 0.1.0 → 0.3.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 +44 -3
- package/dist/controller.d.ts +40 -7
- package/dist/controller.d.ts.map +1 -1
- package/dist/controller.js +194 -45
- package/dist/controller.js.map +1 -1
- package/dist/element.d.ts +12 -2
- package/dist/element.d.ts.map +1 -1
- package/dist/element.js +132 -8
- 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/i18n.d.ts +2 -3
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +4 -6
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +48 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/prefs.d.ts +7 -1
- package/dist/prefs.d.ts.map +1 -1
- package/dist/prefs.js +15 -0
- package/dist/prefs.js.map +1 -1
- package/dist/standalone/kgdiff-viewer-compute.js +1 -1
- package/dist/standalone/kgdiff-viewer.js +4 -4
- package/dist/styles.d.ts.map +1 -1
- package/dist/styles.js +29 -17
- package/dist/styles.js.map +1 -1
- package/dist/text-diff.d.ts +14 -2
- package/dist/text-diff.d.ts.map +1 -1
- package/dist/text-diff.js +52 -2
- package/dist/text-diff.js.map +1 -1
- package/dist/ui/detail.d.ts.map +1 -1
- package/dist/ui/detail.js +194 -151
- package/dist/ui/detail.js.map +1 -1
- package/dist/ui/stage.d.ts +4 -0
- package/dist/ui/stage.d.ts.map +1 -1
- package/dist/ui/stage.js +30 -4
- package/dist/ui/stage.js.map +1 -1
- package/package.json +6 -6
- package/src/controller.ts +206 -41
- package/src/element.ts +148 -8
- package/src/framing.ts +65 -1
- package/src/i18n.ts +4 -6
- package/src/index.ts +47 -1
- package/src/prefs.ts +26 -1
- package/src/styles.ts +29 -17
- package/src/text-diff.ts +57 -2
- package/src/ui/detail.ts +253 -245
- package/src/ui/stage.ts +44 -3
package/src/controller.ts
CHANGED
|
@@ -13,7 +13,14 @@
|
|
|
13
13
|
import type { DiffResult, DiffStats, Locale, Rect, UnionId } from '@kgdiff/core';
|
|
14
14
|
import { KgdiffError } from '@kgdiff/core';
|
|
15
15
|
import type { LayoutResult } from '@kgdiff/layout';
|
|
16
|
-
import
|
|
16
|
+
import {
|
|
17
|
+
type Camera,
|
|
18
|
+
type CameraState,
|
|
19
|
+
labelFootprint,
|
|
20
|
+
type Minimap,
|
|
21
|
+
type PickResult,
|
|
22
|
+
type Renderer,
|
|
23
|
+
} from '@kgdiff/renderer';
|
|
17
24
|
import {
|
|
18
25
|
buildScene,
|
|
19
26
|
type ChangeFamily,
|
|
@@ -40,6 +47,7 @@ import {
|
|
|
40
47
|
freeView,
|
|
41
48
|
type Insets,
|
|
42
49
|
NO_INSETS,
|
|
50
|
+
zoomFit,
|
|
43
51
|
} from './framing.js';
|
|
44
52
|
import { translate } from './i18n.js';
|
|
45
53
|
import type {
|
|
@@ -80,6 +88,14 @@ export interface ControllerHost {
|
|
|
80
88
|
review(hunk: string, reviewed: boolean, progress: ReviewState): void;
|
|
81
89
|
error(error: Error): void;
|
|
82
90
|
announce(text: string): void;
|
|
91
|
+
/** Playback reached the end of the replay; `loop`: it starts again after the loop delay. */
|
|
92
|
+
replayEnd(loop: boolean): void;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface FitOptions {
|
|
96
|
+
readonly animate?: boolean;
|
|
97
|
+
/** Room around the content, px (default `FRAME_PADDING`); kept for later automatic fits. */
|
|
98
|
+
readonly padding?: number;
|
|
83
99
|
}
|
|
84
100
|
|
|
85
101
|
export interface ControllerDeps {
|
|
@@ -175,6 +191,14 @@ export class ViewerController {
|
|
|
175
191
|
clock: ReplayClock | null = null;
|
|
176
192
|
split = 0.5;
|
|
177
193
|
emphasis = 1;
|
|
194
|
+
/**
|
|
195
|
+
* Keep the view fitted (embeds without controls): fit again when the canvas is resized unless
|
|
196
|
+
* the user moved the camera since, and at the start of every loop.
|
|
197
|
+
*/
|
|
198
|
+
autoFit = false;
|
|
199
|
+
/** Canvas clicks select (and double clicks fly); off for embeds without controls. */
|
|
200
|
+
selectOnClick = true;
|
|
201
|
+
#labelsInside = false;
|
|
178
202
|
|
|
179
203
|
#review: ReviewStore | null = null;
|
|
180
204
|
#searchIndex: SearchIndex | null = null;
|
|
@@ -198,6 +222,16 @@ export class ViewerController {
|
|
|
198
222
|
#savedCamera: CameraState | null = null;
|
|
199
223
|
/** A new diff is installed but has not been fitted to a laid-out canvas yet. */
|
|
200
224
|
#needsFit = false;
|
|
225
|
+
/** The user panned or zoomed since the last fit. */
|
|
226
|
+
#userMoved = false;
|
|
227
|
+
#fitPadding = FRAME_PADDING;
|
|
228
|
+
/** A finished replay starts again this long (ms) after its end; null: it plays once. */
|
|
229
|
+
#loopDelay: number | null = null;
|
|
230
|
+
/** When a finished looping replay starts again (`deps.now()` time), else NaN. */
|
|
231
|
+
#restartAt = Number.NaN;
|
|
232
|
+
/** Playback is held (pointer over the graph); `#resume`: it was playing and continues after. */
|
|
233
|
+
#held = false;
|
|
234
|
+
#resume = false;
|
|
201
235
|
readonly #listeners = new Set<(topics: ReadonlySet<Topic>) => void>();
|
|
202
236
|
#pendingTopics = new Set<Topic>();
|
|
203
237
|
#flushQueued = false;
|
|
@@ -325,6 +359,8 @@ export class ViewerController {
|
|
|
325
359
|
fail(error: Error): void {
|
|
326
360
|
this.#loadToken++;
|
|
327
361
|
this.clock?.pause();
|
|
362
|
+
this.#restartAt = Number.NaN;
|
|
363
|
+
this.#resume = false;
|
|
328
364
|
this.status = 'error';
|
|
329
365
|
this.error = error;
|
|
330
366
|
this.step = null;
|
|
@@ -409,6 +445,7 @@ export class ViewerController {
|
|
|
409
445
|
const pending = this.#pendingHunk;
|
|
410
446
|
this.#pendingHunk = null;
|
|
411
447
|
if (pending !== null) this.selectHunkById(pending.id, { fly: pending.fly });
|
|
448
|
+
else this.#autoplay();
|
|
412
449
|
}
|
|
413
450
|
|
|
414
451
|
/** (Re)build scene, resolver, list, clock and focus; selection is carried over by id. */
|
|
@@ -462,7 +499,10 @@ export class ViewerController {
|
|
|
462
499
|
attach(stage: HTMLElement, minimapHost: HTMLElement | null): void {
|
|
463
500
|
if (this.#renderer) return;
|
|
464
501
|
const backend = renderBackend();
|
|
465
|
-
const renderer = backend.createRenderer(stage, {
|
|
502
|
+
const renderer = backend.createRenderer(stage, {
|
|
503
|
+
theme: this.theme,
|
|
504
|
+
labelsInside: this.#labelsInside,
|
|
505
|
+
});
|
|
466
506
|
this.#renderer = renderer;
|
|
467
507
|
// Re-attached (element moved): keep the view; this also stops the renderer's auto-fit.
|
|
468
508
|
if (this.#savedCamera) renderer.camera.set(this.#savedCamera);
|
|
@@ -509,9 +549,25 @@ export class ViewerController {
|
|
|
509
549
|
this.#chrome = provider;
|
|
510
550
|
}
|
|
511
551
|
|
|
512
|
-
/**
|
|
552
|
+
/**
|
|
553
|
+
* Keep labels whole inside the canvas (embeds without camera controls): a label pushed from
|
|
554
|
+
* its spot by a neighbour moves inside or is left out, never cut at the edge.
|
|
555
|
+
*/
|
|
556
|
+
setLabelsInside(inside: boolean): void {
|
|
557
|
+
this.#labelsInside = inside;
|
|
558
|
+
this.#renderer?.setLabelsInside(inside);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* The canvas was laid out or resized: run the initial fit a new diff is waiting for, or (with
|
|
563
|
+
* `autoFit`) fit again unless the user moved the camera since the last fit.
|
|
564
|
+
*/
|
|
513
565
|
layoutChanged(): void {
|
|
514
|
-
if (
|
|
566
|
+
if (this.status !== 'ready') return;
|
|
567
|
+
if (!this.#needsFit) {
|
|
568
|
+
if (this.autoFit && !this.#userMoved) void this.fit({ animate: false });
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
515
571
|
// A selection made before the first layout (deep link, `kgdiff-ready` handler) wins.
|
|
516
572
|
const target = this.#selectionBounds();
|
|
517
573
|
if (target) {
|
|
@@ -520,17 +576,16 @@ export class ViewerController {
|
|
|
520
576
|
this.#flyTo(target, { animate: false });
|
|
521
577
|
return;
|
|
522
578
|
}
|
|
523
|
-
const
|
|
524
|
-
const padding = bounds ? this.#frameInsets(bounds, FIT_MAX_ZOOM) : null;
|
|
579
|
+
const fit = this.#fitTarget();
|
|
525
580
|
const camera = this.#renderer?.camera;
|
|
526
|
-
if (!
|
|
527
|
-
this.#
|
|
528
|
-
void camera.fit(bounds, { animate: false, padding, maxZoom: FIT_MAX_ZOOM });
|
|
581
|
+
if (!fit || !camera) return;
|
|
582
|
+
void this.#fitTo(camera, fit, { animate: false });
|
|
529
583
|
}
|
|
530
584
|
|
|
531
585
|
/** The user panned / zoomed: suspend replay camera follow for `USER_CAMERA_GRACE`. */
|
|
532
586
|
noteUserCamera(): void {
|
|
533
587
|
this.#lastUserCamera = this.#deps.now();
|
|
588
|
+
this.#userMoved = true;
|
|
534
589
|
}
|
|
535
590
|
|
|
536
591
|
// ─── Frame loop ───────────────────────────────────────────────────────────
|
|
@@ -559,7 +614,11 @@ export class ViewerController {
|
|
|
559
614
|
this.#notify('replay');
|
|
560
615
|
this.#follow();
|
|
561
616
|
if (clock.playing) animating = true;
|
|
562
|
-
else if (clock.finished) this.#
|
|
617
|
+
else if (clock.finished) this.#ended();
|
|
618
|
+
}
|
|
619
|
+
if (!Number.isNaN(this.#restartAt) && !this.#held) {
|
|
620
|
+
if (now >= this.#restartAt) this.#loopAgain();
|
|
621
|
+
else animating = true;
|
|
563
622
|
}
|
|
564
623
|
if (this.#emphasisAnim) {
|
|
565
624
|
const a = this.#emphasisAnim;
|
|
@@ -665,7 +724,11 @@ export class ViewerController {
|
|
|
665
724
|
const mode = toMode(value);
|
|
666
725
|
if (mode === null || mode === this.mode) return;
|
|
667
726
|
this.mode = mode;
|
|
668
|
-
if (mode !== 'replay')
|
|
727
|
+
if (mode !== 'replay') {
|
|
728
|
+
this.clock?.pause();
|
|
729
|
+
this.#restartAt = Number.NaN;
|
|
730
|
+
this.#resume = false;
|
|
731
|
+
}
|
|
669
732
|
this.#followed = '';
|
|
670
733
|
this.#updateFocus();
|
|
671
734
|
this.#minimapDirty = true;
|
|
@@ -677,6 +740,7 @@ export class ViewerController {
|
|
|
677
740
|
translate(this.locale, 'announce.mode', { mode: translate(this.locale, `mode.${mode}`) }),
|
|
678
741
|
);
|
|
679
742
|
}
|
|
743
|
+
if (mode === 'replay') this.#autoplay();
|
|
680
744
|
}
|
|
681
745
|
|
|
682
746
|
// ─── Selection ────────────────────────────────────────────────────────────
|
|
@@ -719,6 +783,8 @@ export class ViewerController {
|
|
|
719
783
|
if (this.mode === 'replay' && this.clock) {
|
|
720
784
|
const end = this.clock.windowEnd(h);
|
|
721
785
|
this.clock.pause();
|
|
786
|
+
this.#restartAt = Number.NaN;
|
|
787
|
+
this.#resume = false;
|
|
722
788
|
if (end !== null) this.clock.seek(end);
|
|
723
789
|
this.#followed = '';
|
|
724
790
|
this.#notify('replay');
|
|
@@ -933,41 +999,64 @@ export class ViewerController {
|
|
|
933
999
|
}
|
|
934
1000
|
|
|
935
1001
|
/**
|
|
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.
|
|
1002
|
+
* Fit everything visible (the focus in changes-only mode), labels included, into the free part
|
|
1003
|
+
* of the canvas. Before the canvas is laid out it falls back to the renderer's own fit.
|
|
938
1004
|
*/
|
|
939
|
-
fit(options:
|
|
1005
|
+
fit(options: FitOptions = {}): Promise<void> {
|
|
940
1006
|
const camera = this.#renderer?.camera;
|
|
941
1007
|
if (!camera) return Promise.resolve();
|
|
942
|
-
const
|
|
943
|
-
|
|
944
|
-
|
|
1008
|
+
const { padding, ...transition } = options;
|
|
1009
|
+
if (padding !== undefined) {
|
|
1010
|
+
this.#fitPadding = Number.isFinite(padding) && padding >= 0 ? padding : FRAME_PADDING;
|
|
1011
|
+
}
|
|
1012
|
+
const fit = this.#fitTarget();
|
|
1013
|
+
if (!fit) return camera.fit(undefined, transition);
|
|
1014
|
+
return this.#fitTo(camera, fit, transition);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
#fitTo(
|
|
1018
|
+
camera: Camera,
|
|
1019
|
+
fit: { readonly bounds: Rect; readonly insets: Insets },
|
|
1020
|
+
transition: { readonly animate?: boolean },
|
|
1021
|
+
): Promise<void> {
|
|
945
1022
|
this.#needsFit = false;
|
|
946
|
-
|
|
1023
|
+
this.#userMoved = false;
|
|
1024
|
+
return camera.fit(fit.bounds, { ...transition, padding: fit.insets, maxZoom: FIT_MAX_ZOOM });
|
|
947
1025
|
}
|
|
948
1026
|
|
|
949
|
-
/**
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
const
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
1027
|
+
/**
|
|
1028
|
+
* Bounds and insets that show everything visible (the changes-only focus) with every label
|
|
1029
|
+
* in its spot below its node, clear of the chrome, or null before layout. Measured in the
|
|
1030
|
+
* union layer, which holds every state a replay or swipe passes through. Insets depend on
|
|
1031
|
+
* what is framed, which depends on the zoom they allow: they are settled once more.
|
|
1032
|
+
*/
|
|
1033
|
+
#fitTarget(): { bounds: Rect; insets: Insets } | null {
|
|
1034
|
+
const layout = this.#chrome?.();
|
|
1035
|
+
const resolver = this.resolver;
|
|
1036
|
+
if (!layout || !resolver || !(layout.width > 0) || !(layout.height > 0)) return null;
|
|
1037
|
+
const frame = resolver.resolve(
|
|
1038
|
+
createViewState({
|
|
1039
|
+
layer: 'union',
|
|
1040
|
+
focus: this.focus,
|
|
1041
|
+
families: this.families,
|
|
1042
|
+
reducedMotion: this.reducedMotion,
|
|
1043
|
+
}),
|
|
1044
|
+
);
|
|
1045
|
+
const footprint = labelFootprint(frame, this.theme);
|
|
1046
|
+
const extent = (k: number): Rect | null => footprint.at(k);
|
|
1047
|
+
const discs = extent(Number.POSITIVE_INFINITY);
|
|
1048
|
+
if (!discs) return null;
|
|
1049
|
+
const { width, height } = layout;
|
|
1050
|
+
const padding = this.#fitPadding;
|
|
1051
|
+
let insets = chromeInsets(discs, layout, padding, FIT_MAX_ZOOM);
|
|
1052
|
+
let fit = zoomFit(extent, width, height, insets, FIT_MAX_ZOOM);
|
|
1053
|
+
if (!fit) return null;
|
|
1054
|
+
const settled = chromeInsets(fit.content, layout, padding, FIT_MAX_ZOOM);
|
|
1055
|
+
if (!sameInsets(settled, insets)) {
|
|
1056
|
+
insets = settled;
|
|
1057
|
+
fit = zoomFit(extent, width, height, insets, FIT_MAX_ZOOM) ?? fit;
|
|
1058
|
+
}
|
|
1059
|
+
return { bounds: fit.bounds, insets };
|
|
971
1060
|
}
|
|
972
1061
|
|
|
973
1062
|
zoom(direction: 1 | -1): void {
|
|
@@ -1014,6 +1103,7 @@ export class ViewerController {
|
|
|
1014
1103
|
}
|
|
1015
1104
|
|
|
1016
1105
|
#onCanvasClick(pick: PickResult | null): void {
|
|
1106
|
+
if (!this.selectOnClick) return;
|
|
1017
1107
|
if (!pick) {
|
|
1018
1108
|
this.clearSelection();
|
|
1019
1109
|
return;
|
|
@@ -1038,7 +1128,7 @@ export class ViewerController {
|
|
|
1038
1128
|
}
|
|
1039
1129
|
|
|
1040
1130
|
#onCanvasDblClick(pick: PickResult | null): void {
|
|
1041
|
-
if (pick?.type !== 'node') return;
|
|
1131
|
+
if (!this.selectOnClick || pick?.type !== 'node') return;
|
|
1042
1132
|
const n = this.scene?.nodes[pick.index];
|
|
1043
1133
|
if (!n) return;
|
|
1044
1134
|
if (n.hunk >= 0) this.flyToHunk(n.hunk);
|
|
@@ -1230,6 +1320,8 @@ export class ViewerController {
|
|
|
1230
1320
|
const clock = this.clock;
|
|
1231
1321
|
if (!clock || this.status !== 'ready') return;
|
|
1232
1322
|
if (this.mode !== 'replay') this.setMode('replay');
|
|
1323
|
+
this.#restartAt = Number.NaN;
|
|
1324
|
+
this.#resume = false;
|
|
1233
1325
|
clock.play();
|
|
1234
1326
|
this.#followed = '';
|
|
1235
1327
|
this.#lastTick = 0;
|
|
@@ -1237,7 +1329,10 @@ export class ViewerController {
|
|
|
1237
1329
|
this.invalidate();
|
|
1238
1330
|
}
|
|
1239
1331
|
|
|
1332
|
+
/** Pause; a looping replay stays paused (until `play`, a new diff or loop turned on again). */
|
|
1240
1333
|
pause(): void {
|
|
1334
|
+
this.#restartAt = Number.NaN;
|
|
1335
|
+
this.#resume = false;
|
|
1241
1336
|
if (!this.clock?.playing) return;
|
|
1242
1337
|
this.clock.pause();
|
|
1243
1338
|
this.#notify('replay');
|
|
@@ -1249,9 +1344,73 @@ export class ViewerController {
|
|
|
1249
1344
|
else this.play();
|
|
1250
1345
|
}
|
|
1251
1346
|
|
|
1347
|
+
/**
|
|
1348
|
+
* Play the replay over and over: `delay` ms after it ends it starts again; null plays it
|
|
1349
|
+
* once. Turning the loop on starts a replay that is shown but not playing.
|
|
1350
|
+
*/
|
|
1351
|
+
setLoop(delay: number | null): void {
|
|
1352
|
+
const was = this.#loopDelay;
|
|
1353
|
+
this.#loopDelay = delay === null ? null : Number.isFinite(delay) ? Math.max(0, delay) : 0;
|
|
1354
|
+
if (this.#loopDelay === null) this.#restartAt = Number.NaN;
|
|
1355
|
+
else if (was === null) this.#autoplay();
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
get loopDelay(): number | null {
|
|
1359
|
+
return this.#loopDelay;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/**
|
|
1363
|
+
* Hold playback while the pointer rests on the graph (`pause-on-hover`): a playing replay
|
|
1364
|
+
* pauses and continues on release; a pending loop restart waits for the release, then the
|
|
1365
|
+
* full loop delay.
|
|
1366
|
+
*/
|
|
1367
|
+
hold(held: boolean): void {
|
|
1368
|
+
if (held === this.#held) return;
|
|
1369
|
+
this.#held = held;
|
|
1370
|
+
const clock = this.clock;
|
|
1371
|
+
if (held) {
|
|
1372
|
+
if (!clock?.playing) return;
|
|
1373
|
+
clock.pause();
|
|
1374
|
+
this.#resume = true;
|
|
1375
|
+
this.#notify('replay');
|
|
1376
|
+
this.invalidate();
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
if (!Number.isNaN(this.#restartAt) && this.#loopDelay !== null) {
|
|
1380
|
+
this.#restartAt = this.#deps.now() + this.#loopDelay;
|
|
1381
|
+
this.#schedule();
|
|
1382
|
+
}
|
|
1383
|
+
if (this.#resume) this.play();
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/** Looping and showing a replay that is not playing: start it (or after the hold). */
|
|
1387
|
+
#autoplay(): void {
|
|
1388
|
+
if (this.#loopDelay === null || this.status !== 'ready' || this.mode !== 'replay') return;
|
|
1389
|
+
if (!this.clock || this.clock.playing || !Number.isNaN(this.#restartAt)) return;
|
|
1390
|
+
if (this.#held) this.#resume = true;
|
|
1391
|
+
else this.play();
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
/** Playback reached the end: announce it, tell the host and arm the loop restart. */
|
|
1395
|
+
#ended(): void {
|
|
1396
|
+
this.#host.announce(translate(this.locale, 'announce.finished'));
|
|
1397
|
+
const loop = this.#loopDelay !== null;
|
|
1398
|
+
// Armed first, so a `kgdiff-replay-end` listener can still cancel it (pause, loop off).
|
|
1399
|
+
if (loop) this.#restartAt = this.#deps.now() + (this.#loopDelay ?? 0);
|
|
1400
|
+
this.#host.replayEnd(loop);
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
#loopAgain(): void {
|
|
1404
|
+
this.#restartAt = Number.NaN;
|
|
1405
|
+
if (this.autoFit) void this.fit();
|
|
1406
|
+
this.clock?.seek(0);
|
|
1407
|
+
this.play();
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1252
1410
|
seek(t: number): void {
|
|
1253
1411
|
const clock = this.clock;
|
|
1254
1412
|
if (!clock) return;
|
|
1413
|
+
this.#restartAt = Number.NaN;
|
|
1255
1414
|
clock.seek(t);
|
|
1256
1415
|
this.#notify('replay');
|
|
1257
1416
|
this.#follow();
|
|
@@ -1262,6 +1421,8 @@ export class ViewerController {
|
|
|
1262
1421
|
const clock = this.clock;
|
|
1263
1422
|
if (!clock) return;
|
|
1264
1423
|
clock.step(direction);
|
|
1424
|
+
this.#restartAt = Number.NaN;
|
|
1425
|
+
this.#resume = false;
|
|
1265
1426
|
this.#followed = '';
|
|
1266
1427
|
// Follow (and announce) the hunk that just completed: stops sit at window ends.
|
|
1267
1428
|
const w = clock.timeline.windows[clock.position() - 1];
|
|
@@ -1385,6 +1546,10 @@ function isString(x: string | undefined): x is string {
|
|
|
1385
1546
|
return x !== undefined;
|
|
1386
1547
|
}
|
|
1387
1548
|
|
|
1549
|
+
function sameInsets(a: Insets, b: Insets): boolean {
|
|
1550
|
+
return a.top === b.top && a.right === b.right && a.bottom === b.bottom && a.left === b.left;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1388
1553
|
/** Pad a small rect so a node is shown with its neighbourhood. */
|
|
1389
1554
|
function padRect(r: Rect, min = 200, ratio = 1.4): Rect {
|
|
1390
1555
|
const w = Math.max(min, r.width * ratio);
|
package/src/element.ts
CHANGED
|
@@ -9,16 +9,29 @@ 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,
|
|
18
21
|
} from './index.js';
|
|
19
22
|
import { isTypingTarget, keyAction } from './keyboard.js';
|
|
20
23
|
import { assertPayload } from './payload.js';
|
|
21
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
DEFAULT_LOOP_DELAY,
|
|
26
|
+
THEME_PREFERENCES,
|
|
27
|
+
toChrome,
|
|
28
|
+
toDelay,
|
|
29
|
+
toLegend,
|
|
30
|
+
toLocale,
|
|
31
|
+
toMode,
|
|
32
|
+
toPalette,
|
|
33
|
+
toTheme,
|
|
34
|
+
} from './prefs.js';
|
|
22
35
|
import { defaultStorage } from './review-store.js';
|
|
23
36
|
import { STYLES, themeVariables } from './styles.js';
|
|
24
37
|
import type { UiContext } from './ui/context.js';
|
|
@@ -32,8 +45,31 @@ export const COMPACT_WIDTH = 760;
|
|
|
32
45
|
/** Below this width the header drops the diffstat pills and version tags. */
|
|
33
46
|
const NARROW_WIDTH = 1100;
|
|
34
47
|
|
|
35
|
-
const OBSERVED = [
|
|
36
|
-
|
|
48
|
+
const OBSERVED = [
|
|
49
|
+
'mode',
|
|
50
|
+
'theme',
|
|
51
|
+
'palette',
|
|
52
|
+
'locale',
|
|
53
|
+
'sidebar',
|
|
54
|
+
'sync-hash',
|
|
55
|
+
'chrome',
|
|
56
|
+
'legend',
|
|
57
|
+
'loop',
|
|
58
|
+
'loop-delay',
|
|
59
|
+
'pause-on-hover',
|
|
60
|
+
] as const;
|
|
61
|
+
const UPGRADED = [
|
|
62
|
+
'mode',
|
|
63
|
+
'theme',
|
|
64
|
+
'palette',
|
|
65
|
+
'locale',
|
|
66
|
+
'chrome',
|
|
67
|
+
'legend',
|
|
68
|
+
'loop',
|
|
69
|
+
'loopDelay',
|
|
70
|
+
'pauseOnHover',
|
|
71
|
+
'payload',
|
|
72
|
+
] as const;
|
|
37
73
|
|
|
38
74
|
/** Keys the page itself scrolls with: never taken at document level. */
|
|
39
75
|
const PAGE_KEYS: ReadonlySet<string> = new Set([
|
|
@@ -110,6 +146,8 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
110
146
|
readonly #cleanup: (() => void)[] = [];
|
|
111
147
|
#lastHash = '';
|
|
112
148
|
#upgraded = false;
|
|
149
|
+
/** A mouse or pen rests on the graph (holds a looping replay with `pause-on-hover`). */
|
|
150
|
+
#pointerOver = false;
|
|
113
151
|
|
|
114
152
|
constructor() {
|
|
115
153
|
super();
|
|
@@ -128,6 +166,7 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
128
166
|
this.#emit('kgdiff-review', { hunk, reviewed, progress }),
|
|
129
167
|
error: (error) => this.#emit('kgdiff-error', { error }),
|
|
130
168
|
announce: (text) => this.#announce(text),
|
|
169
|
+
replayEnd: (loop) => this.#emit('kgdiff-replay-end', { loop }),
|
|
131
170
|
};
|
|
132
171
|
this.controller = new ViewerController(host, {
|
|
133
172
|
now: () => (typeof performance !== 'undefined' ? performance.now() : Date.now()),
|
|
@@ -165,8 +204,10 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
165
204
|
// The graph takes keyboard focus (shortcuts apply where focus is), also on click.
|
|
166
205
|
const canvas = ui.stage.canvasHost.querySelector('canvas');
|
|
167
206
|
if (canvas) {
|
|
168
|
-
canvas.tabIndex = 0;
|
|
169
|
-
const focusCanvas = (): void =>
|
|
207
|
+
canvas.tabIndex = this.chrome === 'none' ? -1 : 0;
|
|
208
|
+
const focusCanvas = (): void => {
|
|
209
|
+
if (this.chrome !== 'none') canvas.focus({ preventScroll: true });
|
|
210
|
+
};
|
|
170
211
|
canvas.addEventListener('pointerdown', focusCanvas);
|
|
171
212
|
this.#cleanup.push(() => canvas.removeEventListener('pointerdown', focusCanvas));
|
|
172
213
|
}
|
|
@@ -206,6 +247,9 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
206
247
|
if (i >= 0) connected.splice(i, 1);
|
|
207
248
|
for (const fn of this.#cleanup.splice(0)) fn();
|
|
208
249
|
this.controller.detach();
|
|
250
|
+
// Moved while hovered: no pointerleave follows.
|
|
251
|
+
this.#pointerOver = false;
|
|
252
|
+
this.#applyHold();
|
|
209
253
|
}
|
|
210
254
|
|
|
211
255
|
attributeChangedCallback(name: string, _old: string | null, value: string | null): void {
|
|
@@ -223,6 +267,18 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
223
267
|
case 'sync-hash':
|
|
224
268
|
if (value !== null) this.#readHash();
|
|
225
269
|
break;
|
|
270
|
+
case 'chrome':
|
|
271
|
+
case 'legend':
|
|
272
|
+
this.#applyChrome();
|
|
273
|
+
break;
|
|
274
|
+
case 'loop':
|
|
275
|
+
case 'loop-delay':
|
|
276
|
+
this.controller.setLoop(this.loop ? this.loopDelay : null);
|
|
277
|
+
this.#applyHold();
|
|
278
|
+
break;
|
|
279
|
+
case 'pause-on-hover':
|
|
280
|
+
this.#applyHold();
|
|
281
|
+
break;
|
|
226
282
|
default:
|
|
227
283
|
break;
|
|
228
284
|
}
|
|
@@ -307,6 +363,52 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
307
363
|
this.#applyPreference('locale', value, true);
|
|
308
364
|
}
|
|
309
365
|
|
|
366
|
+
get chrome(): ViewerChrome {
|
|
367
|
+
return toChrome(this.getAttribute('chrome')) ?? 'full';
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
set chrome(value: ViewerChrome) {
|
|
371
|
+
this.#reflect('chrome', toChrome(value) === 'none' ? 'none' : null);
|
|
372
|
+
this.#applyChrome();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
get legend(): ViewerLegend {
|
|
376
|
+
return toLegend(this.getAttribute('legend')) ?? 'auto';
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
set legend(value: ViewerLegend) {
|
|
380
|
+
const v = toLegend(value);
|
|
381
|
+
this.#reflect('legend', v === 'auto' ? null : v);
|
|
382
|
+
this.#applyChrome();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
get loop(): boolean {
|
|
386
|
+
return this.hasAttribute('loop');
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
set loop(value: boolean) {
|
|
390
|
+
this.toggleAttribute('loop', Boolean(value));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
get loopDelay(): number {
|
|
394
|
+
return toDelay(this.getAttribute('loop-delay')) ?? DEFAULT_LOOP_DELAY;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
set loopDelay(value: number) {
|
|
398
|
+
const v = toDelay(value);
|
|
399
|
+
if (v === null) this.removeAttribute('loop-delay');
|
|
400
|
+
else this.setAttribute('loop-delay', String(v));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
get pauseOnHover(): boolean {
|
|
404
|
+
return this.getAttribute('pause-on-hover') !== 'false';
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
set pauseOnHover(value: boolean) {
|
|
408
|
+
if (value) this.removeAttribute('pause-on-hover');
|
|
409
|
+
else this.setAttribute('pause-on-hover', 'false');
|
|
410
|
+
}
|
|
411
|
+
|
|
310
412
|
get payload(): ReportPayload | null {
|
|
311
413
|
return this.#currentPayload;
|
|
312
414
|
}
|
|
@@ -353,8 +455,8 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
353
455
|
this.controller.pause();
|
|
354
456
|
}
|
|
355
457
|
|
|
356
|
-
fit(): Promise<void> {
|
|
357
|
-
return this.controller.fit();
|
|
458
|
+
fit(options?: FitOptions): Promise<void> {
|
|
459
|
+
return this.controller.fit(options);
|
|
358
460
|
}
|
|
359
461
|
|
|
360
462
|
exportDelta(): Delta {
|
|
@@ -415,6 +517,34 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
415
517
|
ui.header.setSidebarState(this.#sidebarOpen);
|
|
416
518
|
}
|
|
417
519
|
|
|
520
|
+
/**
|
|
521
|
+
* `chrome` and `legend`: what the stage shows, whether clicks select, whether the view stays
|
|
522
|
+
* fitted. The CSS hides the rest (`.root[data-chrome="none"]`).
|
|
523
|
+
*/
|
|
524
|
+
#applyChrome(): void {
|
|
525
|
+
const bare = this.chrome === 'none';
|
|
526
|
+
const c = this.controller;
|
|
527
|
+
c.autoFit = bare;
|
|
528
|
+
c.selectOnClick = !bare;
|
|
529
|
+
c.setLabelsInside(bare);
|
|
530
|
+
const ui = this.#ui;
|
|
531
|
+
if (!ui) return;
|
|
532
|
+
ui.root.dataset.chrome = this.chrome;
|
|
533
|
+
const legend = this.legend;
|
|
534
|
+
ui.stage.setChrome(legend === 'auto' ? (bare ? 'mini' : 'full') : legend, !bare);
|
|
535
|
+
const canvas = ui.stage.canvasHost.querySelector('canvas');
|
|
536
|
+
if (canvas) canvas.tabIndex = bare ? -1 : 0;
|
|
537
|
+
if (bare) {
|
|
538
|
+
ui.help.hide();
|
|
539
|
+
this.#toggleMenu(false);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Hold a looping replay while the pointer is over the graph (`pause-on-hover`). */
|
|
544
|
+
#applyHold(): void {
|
|
545
|
+
this.controller.hold(this.#pointerOver && this.loop && this.pauseOnHover);
|
|
546
|
+
}
|
|
547
|
+
|
|
418
548
|
#build(): void {
|
|
419
549
|
const style = h('style', { text: STYLES });
|
|
420
550
|
const vars = h('style');
|
|
@@ -463,7 +593,15 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
463
593
|
const path = e.composedPath();
|
|
464
594
|
if (!path.includes(menu.el) && !path.includes(header.menuButton)) this.#toggleMenu(false);
|
|
465
595
|
});
|
|
596
|
+
const hover = (over: boolean) => (e: PointerEvent) => {
|
|
597
|
+
if (e.pointerType === 'touch') return;
|
|
598
|
+
this.#pointerOver = over;
|
|
599
|
+
this.#applyHold();
|
|
600
|
+
};
|
|
601
|
+
stage.el.addEventListener('pointerenter', hover(true));
|
|
602
|
+
stage.el.addEventListener('pointerleave', hover(false));
|
|
466
603
|
this.#applySidebar();
|
|
604
|
+
this.#applyChrome();
|
|
467
605
|
}
|
|
468
606
|
|
|
469
607
|
/** Render, never throwing: a failure shows the error state with safe appearance defaults. */
|
|
@@ -563,13 +701,15 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
563
701
|
this.controller.layoutChanged();
|
|
564
702
|
});
|
|
565
703
|
ro.observe(this);
|
|
704
|
+
// The canvas also changes size without the element (chrome shown or hidden).
|
|
705
|
+
if (this.#ui) ro.observe(this.#ui.stage.el);
|
|
566
706
|
this.#cleanup.push(() => ro.disconnect());
|
|
567
707
|
}
|
|
568
708
|
|
|
569
709
|
#onKeyDown(e: KeyboardEvent): void {
|
|
570
710
|
if (e.defaultPrevented || e.isComposing) return;
|
|
571
711
|
const ui = this.#ui;
|
|
572
|
-
if (!ui) return;
|
|
712
|
+
if (!ui || this.chrome === 'none') return;
|
|
573
713
|
const path = e.composedPath();
|
|
574
714
|
if (!path.includes(this)) {
|
|
575
715
|
// Outside the component only with `global-shortcuts`, when nothing else has focus, for
|