@kgdiff/viewer 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +52 -2
- package/dist/compute-loader.d.ts +13 -0
- package/dist/compute-loader.d.ts.map +1 -0
- package/dist/compute-loader.js +46 -0
- package/dist/compute-loader.js.map +1 -0
- package/dist/compute.d.ts +8 -0
- package/dist/compute.d.ts.map +1 -0
- package/dist/compute.js +8 -0
- package/dist/compute.js.map +1 -0
- package/dist/controller.d.ts +199 -0
- package/dist/controller.d.ts.map +1 -0
- package/dist/controller.js +1254 -0
- package/dist/controller.js.map +1 -0
- package/dist/dom.d.ts +32 -0
- package/dist/dom.d.ts.map +1 -0
- package/dist/dom.js +105 -0
- package/dist/dom.js.map +1 -0
- package/dist/element.d.ts +43 -0
- package/dist/element.d.ts.map +1 -0
- package/dist/element.js +656 -0
- package/dist/element.js.map +1 -0
- package/dist/framing.d.ts +45 -0
- package/dist/framing.d.ts.map +1 -0
- package/dist/framing.js +107 -0
- package/dist/framing.js.map +1 -0
- package/dist/hash-sync.d.ts +18 -0
- package/dist/hash-sync.d.ts.map +1 -0
- package/dist/hash-sync.js +45 -0
- package/dist/hash-sync.js.map +1 -0
- package/dist/i18n.d.ts +222 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +434 -0
- package/dist/i18n.js.map +1 -0
- package/dist/icons.d.ts +37 -0
- package/dist/icons.d.ts.map +1 -0
- package/dist/icons.js +36 -0
- package/dist/icons.js.map +1 -0
- package/dist/index.d.ts +151 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +41 -0
- package/dist/index.js.map +1 -0
- package/dist/keyboard.d.ts +64 -0
- package/dist/keyboard.d.ts.map +1 -0
- package/dist/keyboard.js +95 -0
- package/dist/keyboard.js.map +1 -0
- package/dist/list-model.d.ts +35 -0
- package/dist/list-model.d.ts.map +1 -0
- package/dist/list-model.js +70 -0
- package/dist/list-model.js.map +1 -0
- package/dist/payload.d.ts +10 -0
- package/dist/payload.d.ts.map +1 -0
- package/dist/payload.js +138 -0
- package/dist/payload.js.map +1 -0
- package/dist/pipeline.d.ts +31 -0
- package/dist/pipeline.d.ts.map +1 -0
- package/dist/pipeline.js +122 -0
- package/dist/pipeline.js.map +1 -0
- package/dist/prefs.d.ts +18 -0
- package/dist/prefs.d.ts.map +1 -0
- package/dist/prefs.js +38 -0
- package/dist/prefs.js.map +1 -0
- package/dist/registry.d.ts +3 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +3 -0
- package/dist/registry.js.map +1 -0
- package/dist/renderer-port.d.ts +14 -0
- package/dist/renderer-port.d.ts.map +1 -0
- package/dist/renderer-port.js +16 -0
- package/dist/renderer-port.js.map +1 -0
- package/dist/replay.d.ts +55 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +147 -0
- package/dist/replay.js.map +1 -0
- package/dist/review-store.d.ts +30 -0
- package/dist/review-store.d.ts.map +1 -0
- package/dist/review-store.js +120 -0
- package/dist/review-store.js.map +1 -0
- package/dist/search.d.ts +25 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +90 -0
- package/dist/search.js.map +1 -0
- package/dist/standalone/kgdiff-viewer-compute.js +1 -0
- package/dist/standalone/kgdiff-viewer.js +5 -0
- package/dist/standalone-compute.d.ts +2 -0
- package/dist/standalone-compute.d.ts.map +1 -0
- package/dist/standalone-compute.js +8 -0
- package/dist/standalone-compute.js.map +1 -0
- package/dist/standalone.d.ts +2 -0
- package/dist/standalone.d.ts.map +1 -0
- package/dist/standalone.js +18 -0
- package/dist/standalone.js.map +1 -0
- package/dist/styles.d.ts +11 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/styles.js +622 -0
- package/dist/styles.js.map +1 -0
- package/dist/text-diff.d.ts +44 -0
- package/dist/text-diff.d.ts.map +1 -0
- package/dist/text-diff.js +261 -0
- package/dist/text-diff.js.map +1 -0
- package/dist/ui/context.d.ts +32 -0
- package/dist/ui/context.d.ts.map +1 -0
- package/dist/ui/context.js +49 -0
- package/dist/ui/context.js.map +1 -0
- package/dist/ui/detail.d.ts +9 -0
- package/dist/ui/detail.d.ts.map +1 -0
- package/dist/ui/detail.js +609 -0
- package/dist/ui/detail.js.map +1 -0
- package/dist/ui/header.d.ts +14 -0
- package/dist/ui/header.d.ts.map +1 -0
- package/dist/ui/header.js +195 -0
- package/dist/ui/header.js.map +1 -0
- package/dist/ui/popovers.d.ts +20 -0
- package/dist/ui/popovers.d.ts.map +1 -0
- package/dist/ui/popovers.js +180 -0
- package/dist/ui/popovers.js.map +1 -0
- package/dist/ui/sidebar.d.ts +25 -0
- package/dist/ui/sidebar.d.ts.map +1 -0
- package/dist/ui/sidebar.js +609 -0
- package/dist/ui/sidebar.js.map +1 -0
- package/dist/ui/stage.d.ts +18 -0
- package/dist/ui/stage.d.ts.map +1 -0
- package/dist/ui/stage.js +664 -0
- package/dist/ui/stage.js.map +1 -0
- package/package.json +58 -3
- package/src/compute-loader.ts +58 -0
- package/src/compute.ts +7 -0
- package/src/controller.ts +1393 -0
- package/src/dom.ts +131 -0
- package/src/element.ts +697 -0
- package/src/framing.ts +150 -0
- package/src/hash-sync.ts +54 -0
- package/src/i18n.ts +467 -0
- package/src/icons.ts +48 -0
- package/src/index.ts +171 -0
- package/src/keyboard.ts +128 -0
- package/src/list-model.ts +86 -0
- package/src/payload.ts +165 -0
- package/src/pipeline.ts +179 -0
- package/src/prefs.ts +55 -0
- package/src/registry.ts +2 -0
- package/src/renderer-port.ts +31 -0
- package/src/replay.ts +163 -0
- package/src/review-store.ts +125 -0
- package/src/search.ts +113 -0
- package/src/standalone-compute.ts +8 -0
- package/src/standalone.ts +18 -0
- package/src/styles.ts +633 -0
- package/src/text-diff.ts +263 -0
- package/src/ui/context.ts +70 -0
- package/src/ui/detail.ts +929 -0
- package/src/ui/header.ts +278 -0
- package/src/ui/popovers.ts +251 -0
- package/src/ui/sidebar.ts +752 -0
- package/src/ui/stage.ts +853 -0
package/src/icons.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline SVG icon bodies for a 16 × 16 viewBox, drawn with a 1.5 px `currentColor` stroke.
|
|
3
|
+
* Static markup only (never user data).
|
|
4
|
+
*/
|
|
5
|
+
export const ICONS = {
|
|
6
|
+
overlay:
|
|
7
|
+
'<path d="M8 2.25 14 5.5 8 8.75 2 5.5Z"/><path d="m2 8.25 6 3.25 6-3.25"/><path d="m2 11 6 3.25L14 11" opacity=".55"/>',
|
|
8
|
+
swipe:
|
|
9
|
+
'<rect x="2" y="3" width="12" height="10" rx="2"/><path d="M8 1.75v12.5"/><path d="M4.75 8h-1M12.25 8h-1" opacity=".55"/>',
|
|
10
|
+
replay:
|
|
11
|
+
'<path d="M2.75 8a5.25 5.25 0 1 0 1.6-3.78"/><path d="M2.5 2.5v2.75h2.75"/><path d="M8 5.25V8l1.9 1.3"/>',
|
|
12
|
+
changes:
|
|
13
|
+
'<path d="M2.5 5.5v-2a1 1 0 0 1 1-1h2M10.5 2.5h2a1 1 0 0 1 1 1v2M13.5 10.5v2a1 1 0 0 1-1 1h-2M5.5 13.5h-2a1 1 0 0 1-1-1v-2"/><circle cx="8" cy="8" r="1.9"/>',
|
|
14
|
+
fit: '<path d="M2.5 6V3.5a1 1 0 0 1 1-1H6M10 2.5h2.5a1 1 0 0 1 1 1V6M13.5 10v2.5a1 1 0 0 1-1 1H10M6 13.5H3.5a1 1 0 0 1-1-1V10"/>',
|
|
15
|
+
help: '<circle cx="8" cy="8" r="6"/><path d="M6.25 6.25a1.8 1.8 0 0 1 3.5.5c0 1.25-1.75 1.5-1.75 2.6"/><path d="M8 11.3v.01"/>',
|
|
16
|
+
sidebar: '<rect x="2" y="2.75" width="12" height="10.5" rx="2"/><path d="M10 2.75v10.5"/>',
|
|
17
|
+
contrast:
|
|
18
|
+
'<circle cx="8" cy="8" r="5.75"/><path d="M8 2.25a5.75 5.75 0 0 1 0 11.5Z" fill="currentColor" stroke="none"/>',
|
|
19
|
+
sun: '<circle cx="8" cy="8" r="2.75"/><path d="M8 1.75v1.5M8 12.75v1.5M1.75 8h1.5M12.75 8h1.5M3.6 3.6l1.05 1.05M11.35 11.35l1.05 1.05M3.6 12.4l1.05-1.05M11.35 4.65l1.05-1.05"/>',
|
|
20
|
+
moon: '<path d="M13.25 9.6A5.5 5.5 0 0 1 6.4 2.75a5.5 5.5 0 1 0 6.85 6.85Z"/>',
|
|
21
|
+
monitor:
|
|
22
|
+
'<rect x="2" y="2.75" width="12" height="8.5" rx="1.5"/><path d="M5.5 13.75h5M8 11.25v2.5"/>',
|
|
23
|
+
search: '<circle cx="7.25" cy="7.25" r="4.5"/><path d="m10.6 10.6 3 3"/>',
|
|
24
|
+
close: '<path d="m4.25 4.25 7.5 7.5M11.75 4.25l-7.5 7.5"/>',
|
|
25
|
+
check: '<path d="m3.5 8.4 3 2.9 6-6.6"/>',
|
|
26
|
+
'chevron-left': '<path d="M10 3.5 5.5 8l4.5 4.5"/>',
|
|
27
|
+
'chevron-right': '<path d="M6 3.5 10.5 8 6 12.5"/>',
|
|
28
|
+
'chevron-up': '<path d="M3.5 10 8 5.5l4.5 4.5"/>',
|
|
29
|
+
'chevron-down': '<path d="M3.5 6 8 10.5 12.5 6"/>',
|
|
30
|
+
play: '<path d="M5.25 3.6v8.8a.5.5 0 0 0 .77.42l6.6-4.4a.5.5 0 0 0 0-.84l-6.6-4.4a.5.5 0 0 0-.77.42Z" fill="currentColor"/>',
|
|
31
|
+
pause: '<path d="M5.5 3.75v8.5M10.5 3.75v8.5" stroke-width="2"/>',
|
|
32
|
+
'skip-back':
|
|
33
|
+
'<path d="M12 4.1v7.8a.5.5 0 0 1-.78.41L5.6 8.41a.5.5 0 0 1 0-.82l5.62-3.9A.5.5 0 0 1 12 4.1Z" fill="currentColor"/><path d="M3.75 4v8"/>',
|
|
34
|
+
'skip-forward':
|
|
35
|
+
'<path d="M4 4.1v7.8a.5.5 0 0 0 .78.41l5.62-3.9a.5.5 0 0 0 0-.82L4.78 3.69A.5.5 0 0 0 4 4.1Z" fill="currentColor"/><path d="M12.25 4v8"/>',
|
|
36
|
+
restart: '<path d="M2.75 8a5.25 5.25 0 1 0 1.6-3.78"/><path d="M2.5 2.5v2.75h2.75"/>',
|
|
37
|
+
plus: '<path d="M8 3.25v9.5M3.25 8h9.5"/>',
|
|
38
|
+
minus: '<path d="M3.25 8h9.5"/>',
|
|
39
|
+
target:
|
|
40
|
+
'<circle cx="8" cy="8" r="4.25"/><path d="M8 1.75v2.5M8 11.75v2.5M1.75 8h2.5M11.75 8h2.5"/>',
|
|
41
|
+
'check-circle': '<circle cx="8" cy="8" r="6"/><path d="m5.4 8.2 1.85 1.85L10.75 6.3"/>',
|
|
42
|
+
alert:
|
|
43
|
+
'<path d="M8 2.25 14.25 13a.5.5 0 0 1-.43.75H2.18a.5.5 0 0 1-.43-.75Z"/><path d="M8 6.5v3"/><path d="M8 11.6v.01"/>',
|
|
44
|
+
keyboard:
|
|
45
|
+
'<rect x="1.75" y="4" width="12.5" height="8" rx="1.5"/><path d="M4.5 6.75h.01M7 6.75h.01M9.5 6.75h.01M12 6.75h.01M5.5 9.5h5"/>',
|
|
46
|
+
} as const;
|
|
47
|
+
|
|
48
|
+
export type IconName = keyof typeof ICONS;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @kgdiff/viewer: the drop-in `<kgdiff-viewer>` web component.
|
|
3
|
+
*
|
|
4
|
+
* Importing this module defines `<kgdiff-viewer>` (if not yet defined).
|
|
5
|
+
* Behaviour: docs/interaction.md. Visuals: docs/visual-language.md.
|
|
6
|
+
*
|
|
7
|
+
* Modules: `element.ts` (the custom element), `controller.ts` (state, load pipeline, replay
|
|
8
|
+
* clock, camera follow), `ui/*` (header, sidebar, detail panel, canvas chrome, popovers),
|
|
9
|
+
* `i18n.ts`, `styles.ts`, `review-store.ts`, `hash-sync.ts`, `payload.ts`, `text-diff.ts`,
|
|
10
|
+
* `keyboard.ts`.
|
|
11
|
+
*/
|
|
12
|
+
import type {
|
|
13
|
+
Delta,
|
|
14
|
+
DiffJSON,
|
|
15
|
+
DiffOptions,
|
|
16
|
+
DiffStats,
|
|
17
|
+
GraphInput,
|
|
18
|
+
KGraph,
|
|
19
|
+
Locale,
|
|
20
|
+
} from '@kgdiff/core';
|
|
21
|
+
import type { ParseOptions } from '@kgdiff/formats';
|
|
22
|
+
import type { LayoutJSON, LayoutOptions } from '@kgdiff/layout';
|
|
23
|
+
import type { PaletteName } from '@kgdiff/scene';
|
|
24
|
+
import { KgdiffViewer } from './element.js';
|
|
25
|
+
import { hydrate } from './payload.js';
|
|
26
|
+
import { viewerTags } from './registry.js';
|
|
27
|
+
|
|
28
|
+
export type ViewerMode = 'overlay' | 'swipe' | 'replay' | 'changes';
|
|
29
|
+
export type ThemePreference = 'auto' | 'light' | 'dark';
|
|
30
|
+
|
|
31
|
+
/** A precomputed diff + layout: what the CLI embeds in HTML reports. Deterministic. */
|
|
32
|
+
export interface ReportPayload {
|
|
33
|
+
readonly format: 'kgdiff.report';
|
|
34
|
+
readonly version: 1;
|
|
35
|
+
readonly diff: DiffJSON;
|
|
36
|
+
readonly layout: LayoutJSON;
|
|
37
|
+
readonly meta: {
|
|
38
|
+
readonly title?: string;
|
|
39
|
+
/** e.g. `kgdiff 0.1.0`. */
|
|
40
|
+
readonly generator: string;
|
|
41
|
+
/** ISO timestamp; omitted for reproducible reports. */
|
|
42
|
+
readonly generatedAt?: string;
|
|
43
|
+
readonly before: { readonly name: string; readonly format?: string };
|
|
44
|
+
readonly after: { readonly name: string; readonly format?: string };
|
|
45
|
+
};
|
|
46
|
+
/** Initial view preferences. */
|
|
47
|
+
readonly view?: {
|
|
48
|
+
readonly mode?: ViewerMode;
|
|
49
|
+
readonly theme?: ThemePreference;
|
|
50
|
+
readonly palette?: PaletteName;
|
|
51
|
+
readonly locale?: Locale;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A graph to load: already structured, or text to parse with @kgdiff/formats. */
|
|
56
|
+
export type GraphSource =
|
|
57
|
+
| GraphInput
|
|
58
|
+
| KGraph
|
|
59
|
+
| { readonly text: string; readonly filename?: string; readonly parse?: ParseOptions };
|
|
60
|
+
|
|
61
|
+
export interface LoadOptions {
|
|
62
|
+
readonly before: GraphSource;
|
|
63
|
+
readonly after: GraphSource;
|
|
64
|
+
readonly diff?: DiffOptions;
|
|
65
|
+
readonly layout?: LayoutOptions;
|
|
66
|
+
readonly names?: { readonly before?: string; readonly after?: string };
|
|
67
|
+
readonly title?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ReviewState {
|
|
71
|
+
/** Reviewed hunk ids, in review order. */
|
|
72
|
+
readonly reviewed: readonly string[];
|
|
73
|
+
readonly total: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface KgdiffViewerEventMap {
|
|
77
|
+
'kgdiff-ready': CustomEvent<{ readonly stats: DiffStats }>;
|
|
78
|
+
'kgdiff-select': CustomEvent<{ readonly hunk: string | null; readonly node: string | null }>;
|
|
79
|
+
'kgdiff-mode': CustomEvent<{ readonly mode: ViewerMode }>;
|
|
80
|
+
'kgdiff-review': CustomEvent<{
|
|
81
|
+
readonly hunk: string;
|
|
82
|
+
readonly reviewed: boolean;
|
|
83
|
+
readonly progress: ReviewState;
|
|
84
|
+
}>;
|
|
85
|
+
'kgdiff-error': CustomEvent<{ readonly error: Error }>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Public surface of the `<kgdiff-viewer>` element.
|
|
90
|
+
*
|
|
91
|
+
* Boolean attributes: `sync-hash` (deep links `#kgdiff=hunk:<id>;mode:<mode>`) and
|
|
92
|
+
* `global-shortcuts`: keyboard shortcuts normally apply only while focus is inside the viewer;
|
|
93
|
+
* with this attribute they also apply when nothing on the page has focus (for full-page
|
|
94
|
+
* viewers such as HTML reports), except the keys pages scroll with (arrows, Home / End, Page
|
|
95
|
+
* Up / Down, Space).
|
|
96
|
+
*
|
|
97
|
+
* `header="compact"` leaves the brand and the compared names out of the header, for hosts that
|
|
98
|
+
* show their own (such as the playground).
|
|
99
|
+
*/
|
|
100
|
+
export interface KgdiffViewerElement extends HTMLElement {
|
|
101
|
+
/** Reflected to the `mode` attribute. Default `overlay` (`changes` for large graphs). */
|
|
102
|
+
mode: ViewerMode;
|
|
103
|
+
/** Reflected to the `theme` attribute. Default `auto`. */
|
|
104
|
+
theme: ThemePreference;
|
|
105
|
+
/** Reflected to the `palette` attribute. Default `default`. */
|
|
106
|
+
palette: PaletteName;
|
|
107
|
+
/** Reflected to the `locale` attribute. Default from `navigator.language`. */
|
|
108
|
+
locale: Locale;
|
|
109
|
+
/** Set a precomputed report (no diff / layout work in the browser). */
|
|
110
|
+
payload: ReportPayload | null;
|
|
111
|
+
/** Parse (if needed), diff, lay out and show two graphs. Rejects with a KgdiffError on bad input. */
|
|
112
|
+
load(options: LoadOptions): Promise<void>;
|
|
113
|
+
/** Select a hunk by id (null clears) and bring it into view. */
|
|
114
|
+
selectHunk(id: string | null): void;
|
|
115
|
+
next(): void;
|
|
116
|
+
prev(): void;
|
|
117
|
+
play(): void;
|
|
118
|
+
pause(): void;
|
|
119
|
+
fit(): Promise<void>;
|
|
120
|
+
/** The full delta of the current diff. */
|
|
121
|
+
exportDelta(): Delta;
|
|
122
|
+
reviewState(): ReviewState;
|
|
123
|
+
setReviewed(hunkId: string, reviewed: boolean): void;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Global through which the optional compute add-on (`@kgdiff/viewer/standalone/compute`, i.e.
|
|
128
|
+
* `kgdiff-viewer-compute.js`) hands the diff engine and force layout to the standalone viewer:
|
|
129
|
+
* the add-on assigns `globalThis[COMPUTE_GLOBAL]`. The viewer reads it lazily, each time it needs
|
|
130
|
+
* them (`load()`, expanding a group outside a `changes`-scoped layout), so the add-on can be
|
|
131
|
+
* inlined right after the viewer bundle — or loaded later — without a reload. Without it,
|
|
132
|
+
* reports work fully except those two features (the expand pill says why). The ES module build
|
|
133
|
+
* does not need it: it imports the same code on demand.
|
|
134
|
+
*/
|
|
135
|
+
export { COMPUTE_GLOBAL } from './compute-loader.js';
|
|
136
|
+
|
|
137
|
+
/** MIME type of the `<script>` element that carries an embedded report payload. */
|
|
138
|
+
export const PAYLOAD_SCRIPT_TYPE = 'application/kgdiff+json';
|
|
139
|
+
|
|
140
|
+
let constructorUsed = false;
|
|
141
|
+
|
|
142
|
+
/** Define the custom element under `tagName` (idempotent). Called automatically on import. */
|
|
143
|
+
export function defineViewer(tagName = 'kgdiff-viewer'): void {
|
|
144
|
+
if (typeof customElements === 'undefined') return;
|
|
145
|
+
const name = tagName.toLowerCase();
|
|
146
|
+
if (customElements.get(name) === undefined) {
|
|
147
|
+
// A constructor can be registered once: further names get a trivial subclass.
|
|
148
|
+
const ctor = constructorUsed ? class extends KgdiffViewer {} : KgdiffViewer;
|
|
149
|
+
customElements.define(name, ctor);
|
|
150
|
+
constructorUsed = true;
|
|
151
|
+
}
|
|
152
|
+
viewerTags.add(name);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Read embedded payloads: every `<script type="application/kgdiff+json">` (optionally with
|
|
157
|
+
* `data-encoding="gzip-base64"`) is decoded and assigned to the `<kgdiff-viewer>` it targets
|
|
158
|
+
* (`data-for="<element id>"`, else the first viewer in the document).
|
|
159
|
+
*/
|
|
160
|
+
export function hydrateEmbeddedPayloads(root?: ParentNode): Promise<void> {
|
|
161
|
+
const scope = root ?? (typeof document !== 'undefined' ? document : undefined);
|
|
162
|
+
return scope ? hydrate(scope) : Promise.resolve();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
declare global {
|
|
166
|
+
interface HTMLElementTagNameMap {
|
|
167
|
+
'kgdiff-viewer': KgdiffViewerElement;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
defineViewer();
|
package/src/keyboard.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The keyboard map (docs/interaction.md §Keyboard map), as a pure function from a key event to
|
|
3
|
+
* an action, so it can be tested without a DOM.
|
|
4
|
+
*/
|
|
5
|
+
import type { ViewerMode } from './index.js';
|
|
6
|
+
|
|
7
|
+
export type KeyAction =
|
|
8
|
+
| { readonly type: 'mode'; readonly mode: ViewerMode }
|
|
9
|
+
| { readonly type: 'next' }
|
|
10
|
+
| { readonly type: 'prev' }
|
|
11
|
+
| { readonly type: 'first' }
|
|
12
|
+
| { readonly type: 'last' }
|
|
13
|
+
| { readonly type: 'fly' }
|
|
14
|
+
| { readonly type: 'escape' }
|
|
15
|
+
| { readonly type: 'play-pause' }
|
|
16
|
+
| { readonly type: 'step'; readonly direction: 1 | -1 }
|
|
17
|
+
| { readonly type: 'divider'; readonly delta: number }
|
|
18
|
+
| { readonly type: 'fit' }
|
|
19
|
+
| { readonly type: 'zoom'; readonly direction: 1 | -1 }
|
|
20
|
+
| { readonly type: 'search' }
|
|
21
|
+
| { readonly type: 'review' }
|
|
22
|
+
| { readonly type: 'sidebar' }
|
|
23
|
+
| { readonly type: 'theme' }
|
|
24
|
+
| { readonly type: 'expand' }
|
|
25
|
+
| { readonly type: 'help' };
|
|
26
|
+
|
|
27
|
+
export interface KeyLike {
|
|
28
|
+
readonly key: string;
|
|
29
|
+
readonly shiftKey?: boolean;
|
|
30
|
+
readonly ctrlKey?: boolean;
|
|
31
|
+
readonly metaKey?: boolean;
|
|
32
|
+
readonly altKey?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const MODE_KEYS: Readonly<Record<string, ViewerMode>> = {
|
|
36
|
+
'1': 'overlay',
|
|
37
|
+
'2': 'swipe',
|
|
38
|
+
'3': 'replay',
|
|
39
|
+
'4': 'changes',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Divider steps for swipe, as a fraction of the width. */
|
|
43
|
+
export const DIVIDER_STEP = 0.02;
|
|
44
|
+
export const DIVIDER_STEP_LARGE = 0.1;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Map a key press to an action in `mode`, or null when the key means nothing here. Chords with
|
|
48
|
+
* Ctrl / Meta / Alt are never handled (they belong to the browser and the host page).
|
|
49
|
+
*/
|
|
50
|
+
export function keyAction(e: KeyLike, mode: ViewerMode): KeyAction | null {
|
|
51
|
+
if (e.ctrlKey || e.metaKey || e.altKey) return null;
|
|
52
|
+
const key = e.key;
|
|
53
|
+
const mapped = MODE_KEYS[key];
|
|
54
|
+
if (mapped !== undefined) return { type: 'mode', mode: mapped };
|
|
55
|
+
switch (key) {
|
|
56
|
+
case 'j':
|
|
57
|
+
case 'ArrowDown':
|
|
58
|
+
return { type: 'next' };
|
|
59
|
+
case 'k':
|
|
60
|
+
case 'ArrowUp':
|
|
61
|
+
return { type: 'prev' };
|
|
62
|
+
case 'Home':
|
|
63
|
+
return { type: 'first' };
|
|
64
|
+
case 'End':
|
|
65
|
+
return { type: 'last' };
|
|
66
|
+
case 'Enter':
|
|
67
|
+
return { type: 'fly' };
|
|
68
|
+
case 'Escape':
|
|
69
|
+
return { type: 'escape' };
|
|
70
|
+
case ' ':
|
|
71
|
+
case 'Spacebar':
|
|
72
|
+
return mode === 'replay' ? { type: 'play-pause' } : null;
|
|
73
|
+
case 'ArrowLeft':
|
|
74
|
+
case 'ArrowRight': {
|
|
75
|
+
const direction = key === 'ArrowRight' ? 1 : -1;
|
|
76
|
+
if (mode === 'replay') return { type: 'step', direction };
|
|
77
|
+
if (mode === 'swipe') {
|
|
78
|
+
return {
|
|
79
|
+
type: 'divider',
|
|
80
|
+
delta: direction * (e.shiftKey ? DIVIDER_STEP_LARGE : DIVIDER_STEP),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
case 'f':
|
|
86
|
+
case 'F':
|
|
87
|
+
return { type: 'fit' };
|
|
88
|
+
case '+':
|
|
89
|
+
case '=':
|
|
90
|
+
return { type: 'zoom', direction: 1 };
|
|
91
|
+
case '-':
|
|
92
|
+
case '_':
|
|
93
|
+
case '−':
|
|
94
|
+
return { type: 'zoom', direction: -1 };
|
|
95
|
+
case '/':
|
|
96
|
+
return { type: 'search' };
|
|
97
|
+
case 'x':
|
|
98
|
+
case 'X':
|
|
99
|
+
return { type: 'review' };
|
|
100
|
+
case '\\':
|
|
101
|
+
return { type: 'sidebar' };
|
|
102
|
+
case 't':
|
|
103
|
+
case 'T':
|
|
104
|
+
return { type: 'theme' };
|
|
105
|
+
case 'e':
|
|
106
|
+
case 'E':
|
|
107
|
+
return mode === 'changes' ? { type: 'expand' } : null;
|
|
108
|
+
case '?':
|
|
109
|
+
return { type: 'help' };
|
|
110
|
+
default:
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Whether a key event comes from a place where the user types (shortcuts must not fire). */
|
|
116
|
+
export function isTypingTarget(target: EventTarget | null): boolean {
|
|
117
|
+
if (target === null || typeof (target as Element).tagName !== 'string') return false;
|
|
118
|
+
const el = target as HTMLElement;
|
|
119
|
+
const tag = el.tagName.toLowerCase();
|
|
120
|
+
if (tag === 'textarea' || tag === 'select') return true;
|
|
121
|
+
if (tag === 'input') {
|
|
122
|
+
const type = ((el as HTMLInputElement).type ?? 'text').toLowerCase();
|
|
123
|
+
return !['button', 'checkbox', 'radio', 'range', 'submit', 'reset', 'color', 'file'].includes(
|
|
124
|
+
type,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return el.isContentEditable === true || el.getAttribute?.('contenteditable') === 'true';
|
|
128
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The change list: scene hunks grouped by replay phase (Removed, Restructured, Modified, Added),
|
|
3
|
+
* scene order within a group, filtered by family. Pure; rebuilt when hunks or filters change.
|
|
4
|
+
*/
|
|
5
|
+
import type { ReplayPhase } from '@kgdiff/core';
|
|
6
|
+
import type { ChangeFamily, Scene } from '@kgdiff/scene';
|
|
7
|
+
|
|
8
|
+
export const PHASE_ORDER: readonly ReplayPhase[] = ['remove', 'restructure', 'modify', 'add'];
|
|
9
|
+
|
|
10
|
+
export type ListItem =
|
|
11
|
+
| { readonly kind: 'header'; readonly phase: ReplayPhase; readonly count: number }
|
|
12
|
+
| { readonly kind: 'row'; readonly hunk: number };
|
|
13
|
+
|
|
14
|
+
export interface ListModel {
|
|
15
|
+
readonly items: readonly ListItem[];
|
|
16
|
+
/** Visible hunk indices in list (navigation) order. */
|
|
17
|
+
readonly order: readonly number[];
|
|
18
|
+
/** Hunk index → position in `order`. */
|
|
19
|
+
readonly rank: ReadonlyMap<number, number>;
|
|
20
|
+
/** Hunk index → position in `items`. */
|
|
21
|
+
readonly itemOf: ReadonlyMap<number, number>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const EMPTY_LIST: ListModel = Object.freeze({
|
|
25
|
+
items: [],
|
|
26
|
+
order: [],
|
|
27
|
+
rank: new Map<number, number>(),
|
|
28
|
+
itemOf: new Map<number, number>(),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export function buildList(scene: Scene, families: ReadonlySet<ChangeFamily>): ListModel {
|
|
32
|
+
const groups = new Map<ReplayPhase, number[]>(PHASE_ORDER.map((p) => [p, []]));
|
|
33
|
+
for (const h of scene.hunks) {
|
|
34
|
+
if (families.size > 0 && !families.has(h.family)) continue;
|
|
35
|
+
groups.get(h.phase)?.push(h.index);
|
|
36
|
+
}
|
|
37
|
+
const items: ListItem[] = [];
|
|
38
|
+
const order: number[] = [];
|
|
39
|
+
const rank = new Map<number, number>();
|
|
40
|
+
const itemOf = new Map<number, number>();
|
|
41
|
+
for (const phase of PHASE_ORDER) {
|
|
42
|
+
const hunks = groups.get(phase) ?? [];
|
|
43
|
+
if (hunks.length === 0) continue;
|
|
44
|
+
items.push({ kind: 'header', phase, count: hunks.length });
|
|
45
|
+
for (const h of hunks) {
|
|
46
|
+
rank.set(h, order.length);
|
|
47
|
+
itemOf.set(h, items.length);
|
|
48
|
+
order.push(h);
|
|
49
|
+
items.push({ kind: 'row', hunk: h });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { items, order, rank, itemOf };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Hunk counts per family (for the filter chips). */
|
|
56
|
+
export function familyCounts(scene: Scene): Record<ChangeFamily, number> {
|
|
57
|
+
const counts: Record<ChangeFamily, number> = {
|
|
58
|
+
added: 0,
|
|
59
|
+
removed: 0,
|
|
60
|
+
content: 0,
|
|
61
|
+
structure: 0,
|
|
62
|
+
identity: 0,
|
|
63
|
+
};
|
|
64
|
+
for (const h of scene.hunks) counts[h.family]++;
|
|
65
|
+
return counts;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The hunk `direction` steps away from `current` in list order. With no current hunk (or one
|
|
70
|
+
* that is filtered out) next starts at the first hunk after it in scene order (else the first),
|
|
71
|
+
* prev at the last before it (else the last). Clamps at the ends. -1 for an empty list.
|
|
72
|
+
*/
|
|
73
|
+
export function stepHunk(model: ListModel, current: number, direction: 1 | -1): number {
|
|
74
|
+
const { order, rank } = model;
|
|
75
|
+
if (order.length === 0) return -1;
|
|
76
|
+
const r = rank.get(current);
|
|
77
|
+
if (r !== undefined) {
|
|
78
|
+
return order[Math.min(order.length - 1, Math.max(0, r + direction))] as number;
|
|
79
|
+
}
|
|
80
|
+
if (current < 0) return (direction > 0 ? order[0] : order[order.length - 1]) as number;
|
|
81
|
+
// Filtered out: continue from its place in scene order.
|
|
82
|
+
if (direction > 0) return order.find((h) => h > current) ?? (order[order.length - 1] as number);
|
|
83
|
+
let prev = order[0] as number;
|
|
84
|
+
for (const h of order) if (h < current) prev = h;
|
|
85
|
+
return prev;
|
|
86
|
+
}
|
package/src/payload.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embedded report payloads: `<script type="application/kgdiff+json">` elements written by the
|
|
3
|
+
* CLI's HTML report, optionally gzip-compressed and base64-encoded
|
|
4
|
+
* (`data-encoding="gzip-base64"`), decoded with the platform `DecompressionStream`.
|
|
5
|
+
*/
|
|
6
|
+
import { KgdiffError } from '@kgdiff/core';
|
|
7
|
+
import type { ReportPayload } from './index.js';
|
|
8
|
+
import { viewerTags } from './registry.js';
|
|
9
|
+
|
|
10
|
+
export const PAYLOAD_TYPE = 'application/kgdiff+json';
|
|
11
|
+
|
|
12
|
+
function base64ToBytes(text: string): Uint8Array {
|
|
13
|
+
const clean = text.replace(/\s+/g, '');
|
|
14
|
+
const binary = atob(clean);
|
|
15
|
+
const bytes = new Uint8Array(binary.length);
|
|
16
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
17
|
+
return bytes;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function gunzip(bytes: Uint8Array): Promise<string> {
|
|
21
|
+
if (typeof DecompressionStream !== 'function') {
|
|
22
|
+
throw new KgdiffError(
|
|
23
|
+
'invalid-input',
|
|
24
|
+
'This browser cannot decompress the embedded report (DecompressionStream is missing).',
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
const input = new ReadableStream<Uint8Array>({
|
|
28
|
+
start(controller) {
|
|
29
|
+
controller.enqueue(bytes);
|
|
30
|
+
controller.close();
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
const reader = input
|
|
34
|
+
.pipeThrough(
|
|
35
|
+
new DecompressionStream('gzip') as unknown as TransformStream<Uint8Array, Uint8Array>,
|
|
36
|
+
)
|
|
37
|
+
.getReader();
|
|
38
|
+
const chunks: Uint8Array[] = [];
|
|
39
|
+
let size = 0;
|
|
40
|
+
for (;;) {
|
|
41
|
+
const { done, value } = await reader.read();
|
|
42
|
+
if (done) break;
|
|
43
|
+
chunks.push(value);
|
|
44
|
+
size += value.length;
|
|
45
|
+
}
|
|
46
|
+
const out = new Uint8Array(size);
|
|
47
|
+
let offset = 0;
|
|
48
|
+
for (const c of chunks) {
|
|
49
|
+
out.set(c, offset);
|
|
50
|
+
offset += c.length;
|
|
51
|
+
}
|
|
52
|
+
return new TextDecoder().decode(out);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Decode the text of a payload script. `encoding` is its `data-encoding` attribute. */
|
|
56
|
+
export async function decodePayloadText(
|
|
57
|
+
text: string,
|
|
58
|
+
encoding: string | null | undefined,
|
|
59
|
+
): Promise<ReportPayload> {
|
|
60
|
+
let json: string;
|
|
61
|
+
const enc = (encoding ?? '').trim().toLowerCase();
|
|
62
|
+
if (enc === '' || enc === 'json' || enc === 'identity') json = text;
|
|
63
|
+
else if (enc === 'gzip-base64') {
|
|
64
|
+
try {
|
|
65
|
+
json = await gunzip(base64ToBytes(text));
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (error instanceof KgdiffError) throw error;
|
|
68
|
+
throw new KgdiffError(
|
|
69
|
+
'invalid-input',
|
|
70
|
+
`The embedded report is not valid gzip-base64: ${(error as Error).message}`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
throw new KgdiffError('invalid-input', `Unknown payload encoding "${encoding}".`);
|
|
75
|
+
}
|
|
76
|
+
let value: unknown;
|
|
77
|
+
try {
|
|
78
|
+
value = JSON.parse(json);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
throw new KgdiffError(
|
|
81
|
+
'invalid-input',
|
|
82
|
+
`The embedded report is not valid JSON: ${(error as Error).message}`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return assertPayload(value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Validate the envelope of a report payload (the diff and layout are validated when read). */
|
|
89
|
+
export function assertPayload(value: unknown): ReportPayload {
|
|
90
|
+
if (typeof value !== 'object' || value === null) {
|
|
91
|
+
throw new KgdiffError('invalid-input', 'A kgdiff report payload must be an object.');
|
|
92
|
+
}
|
|
93
|
+
const v = value as Partial<ReportPayload>;
|
|
94
|
+
if (v.format !== 'kgdiff.report') {
|
|
95
|
+
throw new KgdiffError(
|
|
96
|
+
'invalid-input',
|
|
97
|
+
`Expected format "kgdiff.report", got ${JSON.stringify(v.format)}.`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (v.version !== 1) {
|
|
101
|
+
throw new KgdiffError(
|
|
102
|
+
'unsupported-version',
|
|
103
|
+
`Unsupported kgdiff report version ${JSON.stringify(v.version)} (this viewer reads version 1).`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
if (typeof v.diff !== 'object' || v.diff === null || typeof v.layout !== 'object' || !v.layout) {
|
|
107
|
+
throw new KgdiffError('invalid-input', 'A kgdiff report needs both `diff` and `layout`.');
|
|
108
|
+
}
|
|
109
|
+
return value as ReportPayload;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function findById(root: ParentNode, id: string): Element | null {
|
|
113
|
+
const doc = root as Partial<Document>;
|
|
114
|
+
if (typeof doc.getElementById === 'function') return doc.getElementById(id);
|
|
115
|
+
return root.querySelector(`[id="${id.replace(/["\\]/g, '\\$&')}"]`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function firstViewer(root: ParentNode): Element | null {
|
|
119
|
+
for (const el of root.querySelectorAll('*')) {
|
|
120
|
+
if (viewerTags.has(el.localName)) return el;
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The viewer a payload script targets: `data-for="<id>"`, else the first viewer in `root`. */
|
|
126
|
+
export function payloadTarget(script: Element, root: ParentNode): Element | null {
|
|
127
|
+
const id = script.getAttribute('data-for');
|
|
128
|
+
return id ? findById(root, id) : firstViewer(root);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
interface PayloadReceiver {
|
|
132
|
+
payload: ReportPayload | null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function hydrate(root: ParentNode): Promise<void> {
|
|
136
|
+
const scripts = [...root.querySelectorAll(`script[type="${PAYLOAD_TYPE}"]`)];
|
|
137
|
+
await Promise.all(
|
|
138
|
+
scripts.map(async (script) => {
|
|
139
|
+
const target = payloadTarget(script, root);
|
|
140
|
+
try {
|
|
141
|
+
const payload = await decodePayloadText(
|
|
142
|
+
script.textContent ?? '',
|
|
143
|
+
script.getAttribute('data-encoding'),
|
|
144
|
+
);
|
|
145
|
+
if (target) (target as unknown as PayloadReceiver).payload = payload;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
const err =
|
|
148
|
+
error instanceof Error ? error : new KgdiffError('invalid-input', String(error));
|
|
149
|
+
if (target) {
|
|
150
|
+
const showError = (target as Partial<{ showError(e: Error): void }>).showError;
|
|
151
|
+
if (typeof showError === 'function') showError.call(target, err);
|
|
152
|
+
else {
|
|
153
|
+
target.dispatchEvent(
|
|
154
|
+
new CustomEvent('kgdiff-error', {
|
|
155
|
+
detail: { error: err },
|
|
156
|
+
bubbles: true,
|
|
157
|
+
composed: true,
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
} else console.error('[kgdiff] embedded payload:', err);
|
|
162
|
+
}
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
}
|