@kgdiff/viewer 0.3.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 +9 -8
- package/dist/controller.d.ts +23 -7
- package/dist/controller.d.ts.map +1 -1
- package/dist/controller.js +119 -14
- package/dist/controller.js.map +1 -1
- package/dist/element.d.ts +6 -3
- package/dist/element.d.ts.map +1 -1
- package/dist/element.js +68 -19
- package/dist/element.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 +21 -8
- 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 +7 -1
- package/dist/prefs.d.ts.map +1 -1
- package/dist/prefs.js +15 -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 +69 -63
- 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 +4 -0
- package/dist/ui/stage.d.ts.map +1 -1
- package/dist/ui/stage.js +41 -94
- 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 +131 -20
- package/src/element.ts +72 -21
- package/src/hash-sync.ts +20 -6
- package/src/i18n.ts +15 -14
- package/src/index.ts +21 -9
- package/src/keyboard.ts +12 -6
- package/src/prefs.ts +19 -4
- package/src/styles.ts +69 -63
- 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 +49 -114
- 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).
|
|
@@ -56,10 +57,11 @@ import type {
|
|
|
56
57
|
ReviewState,
|
|
57
58
|
ThemePreference,
|
|
58
59
|
ViewerMode,
|
|
60
|
+
ViewerScope,
|
|
59
61
|
} from './index.js';
|
|
60
62
|
import { buildList, EMPTY_LIST, type ListModel, stepHunk } from './list-model.js';
|
|
61
63
|
import { type LoadedDiff, type LoadStep, readPayload, runLoad } from './pipeline.js';
|
|
62
|
-
import { toLocale, toMode, toPalette, toTheme } from './prefs.js';
|
|
64
|
+
import { LEGACY_CHANGES_MODE, toLocale, toMode, toPalette, toScope, toTheme } from './prefs.js';
|
|
63
65
|
import { renderBackend } from './renderer-port.js';
|
|
64
66
|
import { ReplayClock } from './replay.js';
|
|
65
67
|
import { ReviewStore, reviewKey, type StorageLike } from './review-store.js';
|
|
@@ -79,12 +81,13 @@ export type Topic =
|
|
|
79
81
|
| 'theme'
|
|
80
82
|
| 'locale'
|
|
81
83
|
| 'focus'
|
|
82
|
-
| 'swipe'
|
|
84
|
+
| 'swipe'
|
|
85
|
+
| 'overview';
|
|
83
86
|
|
|
84
87
|
export interface ControllerHost {
|
|
85
88
|
ready(stats: DiffStats): void;
|
|
86
89
|
select(hunk: string | null, node: string | null): void;
|
|
87
|
-
mode(mode: ViewerMode): void;
|
|
90
|
+
mode(mode: ViewerMode, scope: ViewerScope): void;
|
|
88
91
|
review(hunk: string, reviewed: boolean, progress: ReviewState): void;
|
|
89
92
|
error(error: Error): void;
|
|
90
93
|
announce(text: string): void;
|
|
@@ -107,7 +110,7 @@ export interface ControllerDeps {
|
|
|
107
110
|
readonly storage: StorageLike | null;
|
|
108
111
|
}
|
|
109
112
|
|
|
110
|
-
export type Preference = 'mode' | 'theme' | 'palette' | 'locale';
|
|
113
|
+
export type Preference = 'mode' | 'scope' | 'theme' | 'palette' | 'locale';
|
|
111
114
|
|
|
112
115
|
/** Camera follow is suppressed this long after the user moved the camera (ms). */
|
|
113
116
|
export const USER_CAMERA_GRACE = 2000;
|
|
@@ -121,6 +124,8 @@ export const FLY_MAX_ZOOM = 1.8;
|
|
|
121
124
|
export const FIT_MAX_ZOOM = 2.5;
|
|
122
125
|
/** Minimap marks are re-rasterized at most this often while replaying (ms). */
|
|
123
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;
|
|
124
129
|
|
|
125
130
|
class Superseded extends Error {}
|
|
126
131
|
|
|
@@ -169,6 +174,7 @@ export class ViewerController {
|
|
|
169
174
|
list: ListModel = EMPTY_LIST;
|
|
170
175
|
|
|
171
176
|
mode: ViewerMode = 'overlay';
|
|
177
|
+
scope: ViewerScope = 'all';
|
|
172
178
|
themePreference: ThemePreference = 'auto';
|
|
173
179
|
palette: PaletteName = 'default';
|
|
174
180
|
locale: Locale = 'en';
|
|
@@ -199,6 +205,8 @@ export class ViewerController {
|
|
|
199
205
|
/** Canvas clicks select (and double clicks fly); off for embeds without controls. */
|
|
200
206
|
selectOnClick = true;
|
|
201
207
|
#labelsInside = false;
|
|
208
|
+
/** The whole content is in view (the minimap has nothing to add). */
|
|
209
|
+
overview = true;
|
|
202
210
|
|
|
203
211
|
#review: ReviewStore | null = null;
|
|
204
212
|
#searchIndex: SearchIndex | null = null;
|
|
@@ -217,6 +225,11 @@ export class ViewerController {
|
|
|
217
225
|
#followed = '';
|
|
218
226
|
#loadToken = 0;
|
|
219
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;
|
|
220
233
|
#chrome: (() => ChromeLayout) | null = null;
|
|
221
234
|
/** Camera kept across detach / attach (element moved in the DOM). */
|
|
222
235
|
#savedCamera: CameraState | null = null;
|
|
@@ -402,10 +415,14 @@ export class ViewerController {
|
|
|
402
415
|
if (view.locale && !this.explicit.has('locale')) this.locale = view.locale;
|
|
403
416
|
}
|
|
404
417
|
const previousMode = this.mode;
|
|
405
|
-
|
|
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')) {
|
|
406
424
|
const threshold = loaded.layoutOptions?.largeGraphThreshold ?? 3000;
|
|
407
|
-
|
|
408
|
-
this.mode = view?.mode ?? (large ? 'changes' : 'overlay');
|
|
425
|
+
this.scope = view?.scope ?? (diff.nodes.length > threshold ? 'changes' : 'all');
|
|
409
426
|
}
|
|
410
427
|
this.#review = new ReviewStore(
|
|
411
428
|
this.#deps.storage,
|
|
@@ -434,6 +451,7 @@ export class ViewerController {
|
|
|
434
451
|
'locale',
|
|
435
452
|
'focus',
|
|
436
453
|
'swipe',
|
|
454
|
+
'overview',
|
|
437
455
|
);
|
|
438
456
|
// A new diff starts fitted, after the UI has shown its chrome (the notification above is
|
|
439
457
|
// delivered first) and once the canvas has a size (see `layoutChanged`).
|
|
@@ -441,7 +459,9 @@ export class ViewerController {
|
|
|
441
459
|
this.#savedCamera = null;
|
|
442
460
|
queueMicrotask(() => this.layoutChanged());
|
|
443
461
|
this.#host.ready(diff.stats);
|
|
444
|
-
if (this.mode !== previousMode
|
|
462
|
+
if (this.mode !== previousMode || this.scope !== previousScope) {
|
|
463
|
+
this.#host.mode(this.mode, this.scope);
|
|
464
|
+
}
|
|
445
465
|
const pending = this.#pendingHunk;
|
|
446
466
|
this.#pendingHunk = null;
|
|
447
467
|
if (pending !== null) this.selectHunkById(pending.id, { fly: pending.fly });
|
|
@@ -482,7 +502,7 @@ export class ViewerController {
|
|
|
482
502
|
|
|
483
503
|
#updateFocus(): void {
|
|
484
504
|
const scene = this.scene;
|
|
485
|
-
if (!scene || this.
|
|
505
|
+
if (!scene || this.scope !== 'changes') {
|
|
486
506
|
this.focus = null;
|
|
487
507
|
return;
|
|
488
508
|
}
|
|
@@ -513,6 +533,7 @@ export class ViewerController {
|
|
|
513
533
|
renderer.on('dblclick', ({ pick }) => this.#onCanvasDblClick(pick)),
|
|
514
534
|
renderer.on('camera', ({ source }) => {
|
|
515
535
|
if (source === 'user') this.noteUserCamera();
|
|
536
|
+
this.#updateOverview();
|
|
516
537
|
}),
|
|
517
538
|
);
|
|
518
539
|
this.#minimapDirty = true;
|
|
@@ -563,6 +584,7 @@ export class ViewerController {
|
|
|
563
584
|
* `autoFit`) fit again unless the user moved the camera since the last fit.
|
|
564
585
|
*/
|
|
565
586
|
layoutChanged(): void {
|
|
587
|
+
this.#updateOverview();
|
|
566
588
|
if (this.status !== 'ready') return;
|
|
567
589
|
if (!this.#needsFit) {
|
|
568
590
|
if (this.autoFit && !this.#userMoved) void this.fit({ animate: false });
|
|
@@ -718,29 +740,74 @@ export class ViewerController {
|
|
|
718
740
|
};
|
|
719
741
|
}
|
|
720
742
|
|
|
721
|
-
// ───
|
|
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
|
+
}
|
|
722
749
|
|
|
723
|
-
|
|
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
|
+
}
|
|
724
756
|
const mode = toMode(value);
|
|
725
|
-
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;
|
|
726
781
|
this.mode = mode;
|
|
782
|
+
this.scope = nextScope;
|
|
727
783
|
if (mode !== 'replay') {
|
|
728
784
|
this.clock?.pause();
|
|
729
785
|
this.#restartAt = Number.NaN;
|
|
730
786
|
this.#resume = false;
|
|
731
787
|
}
|
|
732
788
|
this.#followed = '';
|
|
733
|
-
|
|
789
|
+
if (scopeChanged) {
|
|
790
|
+
this.#updateFocus();
|
|
791
|
+
this.#updateOverview();
|
|
792
|
+
}
|
|
734
793
|
this.#minimapDirty = true;
|
|
735
794
|
this.#notify('mode', 'focus', 'replay');
|
|
736
795
|
this.invalidate();
|
|
737
796
|
if (this.status === 'ready') {
|
|
738
|
-
this.#host.mode(mode);
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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(' · '));
|
|
742
808
|
}
|
|
743
|
-
|
|
809
|
+
// Switching to replay starts a looping replay; toggling the scope leaves playback alone.
|
|
810
|
+
if (modeChanged && mode === 'replay') this.#autoplay();
|
|
744
811
|
}
|
|
745
812
|
|
|
746
813
|
// ─── Selection ────────────────────────────────────────────────────────────
|
|
@@ -1059,6 +1126,28 @@ export class ViewerController {
|
|
|
1059
1126
|
return { bounds: fit.bounds, insets };
|
|
1060
1127
|
}
|
|
1061
1128
|
|
|
1129
|
+
/** Bounds of what is drawn: the scene, or the visible part of the `changes` scope's focus. */
|
|
1130
|
+
#contentBounds(): Rect | null {
|
|
1131
|
+
const scene = this.scene;
|
|
1132
|
+
if (!scene) return null;
|
|
1133
|
+
const focus = this.focus;
|
|
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');
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1062
1151
|
zoom(direction: 1 | -1): void {
|
|
1063
1152
|
this.noteUserCamera();
|
|
1064
1153
|
this.#renderer?.camera.zoomBy(direction > 0 ? 1.4 : 1 / 1.4);
|
|
@@ -1164,6 +1253,7 @@ export class ViewerController {
|
|
|
1164
1253
|
if (h === this.hops) return;
|
|
1165
1254
|
this.hops = h;
|
|
1166
1255
|
this.#updateFocus();
|
|
1256
|
+
this.#updateOverview();
|
|
1167
1257
|
this.#minimapDirty = true;
|
|
1168
1258
|
this.#notify('focus');
|
|
1169
1259
|
this.invalidate();
|
|
@@ -1290,6 +1380,7 @@ export class ViewerController {
|
|
|
1290
1380
|
|
|
1291
1381
|
#focusChanged(): void {
|
|
1292
1382
|
this.#updateFocus();
|
|
1383
|
+
this.#updateOverview();
|
|
1293
1384
|
this.#minimapDirty = true;
|
|
1294
1385
|
this.#notify('focus');
|
|
1295
1386
|
this.invalidate();
|
|
@@ -1550,6 +1641,26 @@ function sameInsets(a: Insets, b: Insets): boolean {
|
|
|
1550
1641
|
return a.top === b.top && a.right === b.right && a.bottom === b.bottom && a.left === b.left;
|
|
1551
1642
|
}
|
|
1552
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
|
+
|
|
1553
1664
|
/** Pad a small rect so a node is shown with its neighbourhood. */
|
|
1554
1665
|
function padRect(r: Rect, min = 200, ratio = 1.4): Rect {
|
|
1555
1666
|
const w = Math.max(min, r.width * ratio);
|
package/src/element.ts
CHANGED
|
@@ -18,11 +18,13 @@ import type {
|
|
|
18
18
|
ViewerChrome,
|
|
19
19
|
ViewerLegend,
|
|
20
20
|
ViewerMode,
|
|
21
|
+
ViewerScope,
|
|
21
22
|
} from './index.js';
|
|
22
23
|
import { isTypingTarget, keyAction } from './keyboard.js';
|
|
23
24
|
import { assertPayload } from './payload.js';
|
|
24
25
|
import {
|
|
25
26
|
DEFAULT_LOOP_DELAY,
|
|
27
|
+
LEGACY_CHANGES_MODE,
|
|
26
28
|
THEME_PREFERENCES,
|
|
27
29
|
toChrome,
|
|
28
30
|
toDelay,
|
|
@@ -30,6 +32,7 @@ import {
|
|
|
30
32
|
toLocale,
|
|
31
33
|
toMode,
|
|
32
34
|
toPalette,
|
|
35
|
+
toScope,
|
|
33
36
|
toTheme,
|
|
34
37
|
} from './prefs.js';
|
|
35
38
|
import { defaultStorage } from './review-store.js';
|
|
@@ -40,13 +43,14 @@ import { HelpDialog, Menu } from './ui/popovers.js';
|
|
|
40
43
|
import { Sidebar } from './ui/sidebar.js';
|
|
41
44
|
import { Stage } from './ui/stage.js';
|
|
42
45
|
|
|
43
|
-
/** Below this width the sidebar becomes a bottom sheet
|
|
46
|
+
/** Below this width the sidebar becomes a bottom sheet. */
|
|
44
47
|
export const COMPACT_WIDTH = 760;
|
|
45
|
-
/** Below this width the
|
|
48
|
+
/** Below this width the title drops the version tags. */
|
|
46
49
|
const NARROW_WIDTH = 1100;
|
|
47
50
|
|
|
48
51
|
const OBSERVED = [
|
|
49
52
|
'mode',
|
|
53
|
+
'scope',
|
|
50
54
|
'theme',
|
|
51
55
|
'palette',
|
|
52
56
|
'locale',
|
|
@@ -60,6 +64,7 @@ const OBSERVED = [
|
|
|
60
64
|
] as const;
|
|
61
65
|
const UPGRADED = [
|
|
62
66
|
'mode',
|
|
67
|
+
'scope',
|
|
63
68
|
'theme',
|
|
64
69
|
'palette',
|
|
65
70
|
'locale',
|
|
@@ -158,8 +163,8 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
158
163
|
this.#emit('kgdiff-select', { hunk, node });
|
|
159
164
|
this.#writeHash();
|
|
160
165
|
},
|
|
161
|
-
mode: (mode) => {
|
|
162
|
-
this.#emit('kgdiff-mode', { mode });
|
|
166
|
+
mode: (mode, scope) => {
|
|
167
|
+
this.#emit('kgdiff-mode', { mode, scope });
|
|
163
168
|
this.#writeHash();
|
|
164
169
|
},
|
|
165
170
|
review: (hunk, reviewed, progress) =>
|
|
@@ -223,7 +228,7 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
223
228
|
window.addEventListener('hashchange', onHash);
|
|
224
229
|
this.#cleanup.push(() => window.removeEventListener('hashchange', onHash));
|
|
225
230
|
this.controller.setChrome(() => ui.stage.chromeLayout());
|
|
226
|
-
document.fonts?.ready?.then(() => this.#ui?.
|
|
231
|
+
document.fonts?.ready?.then(() => this.#ui?.stage.layout()).catch(() => undefined);
|
|
227
232
|
this.#readHash();
|
|
228
233
|
this.#render(
|
|
229
234
|
new Set<Topic>([
|
|
@@ -238,6 +243,7 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
238
243
|
'focus',
|
|
239
244
|
'swipe',
|
|
240
245
|
'replay',
|
|
246
|
+
'overview',
|
|
241
247
|
]),
|
|
242
248
|
);
|
|
243
249
|
}
|
|
@@ -256,6 +262,7 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
256
262
|
if (this.#reflecting) return;
|
|
257
263
|
switch (name) {
|
|
258
264
|
case 'mode':
|
|
265
|
+
case 'scope':
|
|
259
266
|
case 'theme':
|
|
260
267
|
case 'palette':
|
|
261
268
|
case 'locale':
|
|
@@ -286,23 +293,39 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
286
293
|
|
|
287
294
|
/**
|
|
288
295
|
* Apply a preference from an attribute or a property. Valid values are explicit (a report's
|
|
289
|
-
* `view` does not override them); invalid ones fall back to the default (`overlay`, `
|
|
290
|
-
* `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`.
|
|
291
299
|
*/
|
|
292
300
|
#applyPreference(
|
|
293
|
-
name: 'mode' | 'theme' | 'palette' | 'locale',
|
|
301
|
+
name: 'mode' | 'scope' | 'theme' | 'palette' | 'locale',
|
|
294
302
|
raw: unknown,
|
|
295
303
|
reflect: boolean,
|
|
296
304
|
): void {
|
|
297
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;
|
|
298
308
|
let value: string | null;
|
|
299
309
|
switch (name) {
|
|
300
310
|
case 'mode': {
|
|
311
|
+
if (legacy) {
|
|
312
|
+
c.explicit.add('scope');
|
|
313
|
+
c.setView('overlay', 'changes');
|
|
314
|
+
value = 'overlay';
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
301
317
|
const v = toMode(raw);
|
|
302
318
|
c.setMode(v ?? 'overlay');
|
|
303
319
|
value = v;
|
|
304
320
|
break;
|
|
305
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
|
+
}
|
|
306
329
|
case 'theme': {
|
|
307
330
|
const v = toTheme(raw);
|
|
308
331
|
c.setThemePreference(v ?? 'auto');
|
|
@@ -326,7 +349,8 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
326
349
|
}
|
|
327
350
|
if (value === null) c.explicit.delete(name);
|
|
328
351
|
else c.explicit.add(name);
|
|
329
|
-
if (reflect) this.#reflect(name, value);
|
|
352
|
+
if (reflect || legacy) this.#reflect(name, value);
|
|
353
|
+
if (legacy) this.#reflect('scope', c.scope);
|
|
330
354
|
}
|
|
331
355
|
|
|
332
356
|
// ─── Properties ───────────────────────────────────────────────────────────
|
|
@@ -335,10 +359,18 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
335
359
|
return this.controller.mode;
|
|
336
360
|
}
|
|
337
361
|
|
|
338
|
-
set mode(value: ViewerMode) {
|
|
362
|
+
set mode(value: ViewerMode | typeof LEGACY_CHANGES_MODE) {
|
|
339
363
|
this.#applyPreference('mode', value, true);
|
|
340
364
|
}
|
|
341
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
|
+
|
|
342
374
|
get theme(): ThemePreference {
|
|
343
375
|
return this.controller.themePreference;
|
|
344
376
|
}
|
|
@@ -555,6 +587,9 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
555
587
|
setMode: (mode) => {
|
|
556
588
|
this.controller.setMode(mode);
|
|
557
589
|
},
|
|
590
|
+
toggleScope: () => {
|
|
591
|
+
this.controller.toggleScope();
|
|
592
|
+
},
|
|
558
593
|
setThemePreference: (pref) => {
|
|
559
594
|
this.theme = pref;
|
|
560
595
|
},
|
|
@@ -576,12 +611,13 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
576
611
|
const menu = new Menu(ctx);
|
|
577
612
|
const help = new HelpDialog(ctx);
|
|
578
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);
|
|
579
617
|
const root = h(
|
|
580
618
|
'div',
|
|
581
619
|
{ class: 'root' },
|
|
582
|
-
header.el,
|
|
583
620
|
h('div', { class: 'body' }, stage.el, sidebar.el),
|
|
584
|
-
menu.el,
|
|
585
621
|
help.el,
|
|
586
622
|
live,
|
|
587
623
|
);
|
|
@@ -624,7 +660,10 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
624
660
|
#renderTopics(topics: ReadonlySet<Topic>): void {
|
|
625
661
|
const ui = this.#ui;
|
|
626
662
|
const c = this.controller;
|
|
627
|
-
if (topics.has('mode'))
|
|
663
|
+
if (topics.has('mode')) {
|
|
664
|
+
this.#reflect('mode', c.mode);
|
|
665
|
+
this.#reflect('scope', c.scope);
|
|
666
|
+
}
|
|
628
667
|
if (topics.has('theme')) {
|
|
629
668
|
this.#reflect(
|
|
630
669
|
'theme',
|
|
@@ -645,7 +684,10 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
645
684
|
ui.header.update(topics);
|
|
646
685
|
ui.sidebar.update(topics);
|
|
647
686
|
ui.stage.update(topics);
|
|
648
|
-
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
|
+
}
|
|
649
691
|
}
|
|
650
692
|
|
|
651
693
|
#toggleSidebar(open?: boolean): void {
|
|
@@ -695,7 +737,7 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
695
737
|
this.#compact = compact;
|
|
696
738
|
ui.root.dataset.compact = String(compact);
|
|
697
739
|
}
|
|
698
|
-
ui.
|
|
740
|
+
ui.stage.layout();
|
|
699
741
|
ui.sidebar.renderWindow();
|
|
700
742
|
if (compact) ui.sidebar.syncSheet();
|
|
701
743
|
this.controller.layoutChanged();
|
|
@@ -730,7 +772,7 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
730
772
|
const target = path[0] ?? null;
|
|
731
773
|
if (isTypingTarget(target)) return;
|
|
732
774
|
const c = this.controller;
|
|
733
|
-
const action = keyAction(e, c.mode);
|
|
775
|
+
const action = keyAction(e, c.mode, c.scope);
|
|
734
776
|
if (!action) return;
|
|
735
777
|
// Enter / Space on a focused control activate that control.
|
|
736
778
|
const el = target as Element | null;
|
|
@@ -747,6 +789,9 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
747
789
|
case 'mode':
|
|
748
790
|
c.setMode(action.mode);
|
|
749
791
|
break;
|
|
792
|
+
case 'scope':
|
|
793
|
+
c.toggleScope();
|
|
794
|
+
break;
|
|
750
795
|
case 'next':
|
|
751
796
|
c.next();
|
|
752
797
|
break;
|
|
@@ -813,10 +858,12 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
813
858
|
const state = parseHash(location.hash);
|
|
814
859
|
this.#lastHash = location.hash;
|
|
815
860
|
if (!state) return;
|
|
816
|
-
if (state.mode) {
|
|
817
|
-
// A deep link outranks the large-graph default and a report's preferred
|
|
818
|
-
this.controller
|
|
819
|
-
|
|
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);
|
|
820
867
|
}
|
|
821
868
|
if (state.hunk) this.controller.selectHunkById(state.hunk, { fly: true });
|
|
822
869
|
}
|
|
@@ -825,7 +872,11 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
|
|
|
825
872
|
if (!this.hasAttribute('sync-hash') || typeof location === 'undefined') return;
|
|
826
873
|
const c = this.controller;
|
|
827
874
|
const hunk = c.selectedHunk >= 0 ? c.scene?.hunks[c.selectedHunk]?.id : undefined;
|
|
828
|
-
const hash = formatHash({
|
|
875
|
+
const hash = formatHash({
|
|
876
|
+
...(hunk !== undefined ? { hunk } : {}),
|
|
877
|
+
mode: c.mode,
|
|
878
|
+
scope: c.scope,
|
|
879
|
+
});
|
|
829
880
|
if (hash === location.hash) return;
|
|
830
881
|
this.#lastHash = hash;
|
|
831
882
|
try {
|
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
|
}
|