@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/pipeline.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load pipeline: sources → (parse) → diff → layout, or a precomputed report → diff + layout.
|
|
3
|
+
* Each step is announced through `onStep`, which the controller awaits so the loading state
|
|
4
|
+
* can paint between the (synchronous, CPU-bound) steps.
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
type DiffResult,
|
|
8
|
+
diffFromJSON,
|
|
9
|
+
type GraphInput,
|
|
10
|
+
type GraphMeta,
|
|
11
|
+
isKGraph,
|
|
12
|
+
type KGraph,
|
|
13
|
+
KgdiffError,
|
|
14
|
+
} from '@kgdiff/core';
|
|
15
|
+
import { type LayoutOptions, type LayoutResult, layoutFromJSON } from '@kgdiff/layout';
|
|
16
|
+
import { loadCompute } from './compute-loader.js';
|
|
17
|
+
import { translate } from './i18n.js';
|
|
18
|
+
import type { GraphSource, LoadOptions, ReportPayload } from './index.js';
|
|
19
|
+
import { sanitizeView } from './prefs.js';
|
|
20
|
+
|
|
21
|
+
export type LoadStep = 'parse' | 'match' | 'layout' | 'scene';
|
|
22
|
+
|
|
23
|
+
export interface LoadedDiff {
|
|
24
|
+
readonly diff: DiffResult;
|
|
25
|
+
readonly layout: LayoutResult;
|
|
26
|
+
/** Options to reuse when the layout is extended. */
|
|
27
|
+
readonly layoutOptions: LayoutOptions | undefined;
|
|
28
|
+
readonly names: { readonly before: string; readonly after: string };
|
|
29
|
+
readonly versions: { readonly before?: string; readonly after?: string };
|
|
30
|
+
readonly title?: string;
|
|
31
|
+
/** Initial view preferences from a report. */
|
|
32
|
+
readonly view?: ReportPayload['view'];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type TextSource = Extract<GraphSource, { readonly text: string }>;
|
|
36
|
+
|
|
37
|
+
function isTextSource(source: GraphSource): source is TextSource {
|
|
38
|
+
return typeof (source as Partial<TextSource>).text === 'string';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isGraphInput(value: unknown): value is GraphInput {
|
|
42
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
43
|
+
const v = value as Partial<GraphInput>;
|
|
44
|
+
return Array.isArray(v.nodes) && (v.edges === undefined || Array.isArray(v.edges));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type Formats = typeof import('@kgdiff/formats');
|
|
48
|
+
|
|
49
|
+
let formatsPromise: Promise<Formats> | null = null;
|
|
50
|
+
|
|
51
|
+
/** Loaded lazily so the standalone bundle (reports) never pays for the parsers. */
|
|
52
|
+
function loadFormats(): Promise<Formats> {
|
|
53
|
+
formatsPromise ??= import('@kgdiff/formats').catch((error: unknown) => {
|
|
54
|
+
formatsPromise = null;
|
|
55
|
+
throw new KgdiffError('invalid-input', translate('en', 'error.formats'), {
|
|
56
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
return formatsPromise;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface Resolved {
|
|
63
|
+
readonly graph: GraphInput | KGraph;
|
|
64
|
+
readonly meta: GraphMeta;
|
|
65
|
+
readonly filename?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function resolveSource(source: GraphSource, side: 'before' | 'after'): Promise<Resolved> {
|
|
69
|
+
if (typeof source !== 'object' || source === null) {
|
|
70
|
+
throw new KgdiffError(
|
|
71
|
+
'invalid-input',
|
|
72
|
+
`load(): \`${side}\` must be a GraphInput, a KGraph or { text, filename }.`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
if (isTextSource(source)) {
|
|
76
|
+
const { parseGraph } = await loadFormats();
|
|
77
|
+
const parsed = parseGraph(source.text, {
|
|
78
|
+
...source.parse,
|
|
79
|
+
...(source.filename !== undefined ? { filename: source.filename } : {}),
|
|
80
|
+
});
|
|
81
|
+
return {
|
|
82
|
+
graph: parsed.graph,
|
|
83
|
+
meta: parsed.graph.meta ?? {},
|
|
84
|
+
...(source.filename !== undefined ? { filename: source.filename } : {}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if (isKGraph(source)) return { graph: source, meta: source.meta };
|
|
88
|
+
if (isGraphInput(source)) return { graph: source, meta: source.meta ?? {} };
|
|
89
|
+
throw new KgdiffError(
|
|
90
|
+
'invalid-input',
|
|
91
|
+
`load(): \`${side}\` must be a GraphInput ({ nodes, edges }), a KGraph or { text, filename }.`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function baseName(path: string): string {
|
|
96
|
+
const clean = path.split(/[?#]/)[0] ?? path;
|
|
97
|
+
return clean.slice(Math.max(clean.lastIndexOf('/'), clean.lastIndexOf('\\')) + 1) || path;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function str(value: unknown): string | undefined {
|
|
101
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Display name of a version: explicit name, graph name, file name, else the side. */
|
|
105
|
+
export function versionName(
|
|
106
|
+
meta: GraphMeta,
|
|
107
|
+
side: 'before' | 'after',
|
|
108
|
+
explicit?: string,
|
|
109
|
+
filename?: string,
|
|
110
|
+
): string {
|
|
111
|
+
return (
|
|
112
|
+
str(explicit) ??
|
|
113
|
+
str(meta.name) ??
|
|
114
|
+
(filename ? baseName(filename) : undefined) ??
|
|
115
|
+
(str(meta.source) ? baseName(meta.source as string) : undefined) ??
|
|
116
|
+
side
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function runLoad(
|
|
121
|
+
options: LoadOptions,
|
|
122
|
+
onStep: (step: LoadStep) => Promise<void>,
|
|
123
|
+
): Promise<LoadedDiff> {
|
|
124
|
+
if (typeof options !== 'object' || options === null) {
|
|
125
|
+
throw new KgdiffError('invalid-input', 'load() expects { before, after, … }.');
|
|
126
|
+
}
|
|
127
|
+
const { before, after } = options;
|
|
128
|
+
if (before === undefined || after === undefined) {
|
|
129
|
+
throw new KgdiffError('invalid-input', 'load() needs both `before` and `after`.');
|
|
130
|
+
}
|
|
131
|
+
const computing = loadCompute();
|
|
132
|
+
if (isTextSource(before) || isTextSource(after)) await onStep('parse');
|
|
133
|
+
const b = await resolveSource(before, 'before');
|
|
134
|
+
const a = await resolveSource(after, 'after');
|
|
135
|
+
const compute = await computing;
|
|
136
|
+
await onStep('match');
|
|
137
|
+
const diff = compute.diff(b.graph, a.graph, options.diff);
|
|
138
|
+
await onStep('layout');
|
|
139
|
+
const layout = compute.layoutDiff(diff, options.layout);
|
|
140
|
+
const vb = str(diff.before.meta.version);
|
|
141
|
+
const va = str(diff.after.meta.version);
|
|
142
|
+
return {
|
|
143
|
+
diff,
|
|
144
|
+
layout,
|
|
145
|
+
layoutOptions: options.layout,
|
|
146
|
+
names: {
|
|
147
|
+
before: versionName(b.meta, 'before', options.names?.before, b.filename),
|
|
148
|
+
after: versionName(a.meta, 'after', options.names?.after, a.filename),
|
|
149
|
+
},
|
|
150
|
+
versions: { ...(vb ? { before: vb } : {}), ...(va ? { after: va } : {}) },
|
|
151
|
+
...(str(options.title) ? { title: str(options.title) as string } : {}),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function readPayload(
|
|
156
|
+
payload: ReportPayload,
|
|
157
|
+
onStep: (step: LoadStep) => Promise<void>,
|
|
158
|
+
): Promise<LoadedDiff> {
|
|
159
|
+
await onStep('match');
|
|
160
|
+
const diff = diffFromJSON(payload.diff);
|
|
161
|
+
await onStep('layout');
|
|
162
|
+
const layout = layoutFromJSON(payload.layout);
|
|
163
|
+
const vb = str(diff.before.meta.version);
|
|
164
|
+
const va = str(diff.after.meta.version);
|
|
165
|
+
const meta = payload.meta;
|
|
166
|
+
const view = sanitizeView(payload.view);
|
|
167
|
+
return {
|
|
168
|
+
diff,
|
|
169
|
+
layout,
|
|
170
|
+
layoutOptions: undefined,
|
|
171
|
+
names: {
|
|
172
|
+
before: versionName(diff.before.meta, 'before', meta?.before?.name),
|
|
173
|
+
after: versionName(diff.after.meta, 'after', meta?.after?.name),
|
|
174
|
+
},
|
|
175
|
+
versions: { ...(vb ? { before: vb } : {}), ...(va ? { after: va } : {}) },
|
|
176
|
+
...(str(meta?.title) ? { title: str(meta?.title) as string } : {}),
|
|
177
|
+
...(view ? { view } : {}),
|
|
178
|
+
};
|
|
179
|
+
}
|
package/src/prefs.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place viewer preferences are validated: attributes, properties and a report's `view`
|
|
3
|
+
* all go through these. Invalid values never reach the controller (they fall back, with a
|
|
4
|
+
* console warning for payloads, which come from files rather than code).
|
|
5
|
+
*/
|
|
6
|
+
import type { Locale } from '@kgdiff/core';
|
|
7
|
+
import type { PaletteName } from '@kgdiff/scene';
|
|
8
|
+
import { normalizeLocale } from './i18n.js';
|
|
9
|
+
import type { ReportPayload, ThemePreference, ViewerMode } from './index.js';
|
|
10
|
+
|
|
11
|
+
const MODES: readonly ViewerMode[] = ['overlay', 'swipe', 'replay', 'changes'];
|
|
12
|
+
const THEMES: readonly ThemePreference[] = ['auto', 'light', 'dark'];
|
|
13
|
+
const PALETTES: readonly PaletteName[] = ['default', 'colorblind'];
|
|
14
|
+
|
|
15
|
+
export const THEME_PREFERENCES = THEMES;
|
|
16
|
+
|
|
17
|
+
export function toMode(value: unknown): ViewerMode | null {
|
|
18
|
+
return MODES.includes(value as ViewerMode) ? (value as ViewerMode) : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function toTheme(value: unknown): ThemePreference | null {
|
|
22
|
+
return THEMES.includes(value as ThemePreference) ? (value as ThemePreference) : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function toPalette(value: unknown): PaletteName | null {
|
|
26
|
+
return PALETTES.includes(value as PaletteName) ? (value as PaletteName) : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function toLocale(value: unknown): Locale | null {
|
|
30
|
+
return typeof value === 'string' ? normalizeLocale(value) : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type View = NonNullable<ReportPayload['view']>;
|
|
34
|
+
|
|
35
|
+
/** Keep only the valid fields of a report's `view` (warning about the rest). */
|
|
36
|
+
export function sanitizeView(view: unknown): View | undefined {
|
|
37
|
+
if (typeof view !== 'object' || view === null) return undefined;
|
|
38
|
+
const v = view as Record<string, unknown>;
|
|
39
|
+
const mode = toMode(v.mode);
|
|
40
|
+
const theme = toTheme(v.theme);
|
|
41
|
+
const palette = toPalette(v.palette);
|
|
42
|
+
const locale = toLocale(v.locale);
|
|
43
|
+
const dropped = (['mode', 'theme', 'palette', 'locale'] as const).filter(
|
|
44
|
+
(k) => v[k] !== undefined && { mode, theme, palette, locale }[k] === null,
|
|
45
|
+
);
|
|
46
|
+
if (dropped.length > 0 && typeof console !== 'undefined') {
|
|
47
|
+
console.warn(`[kgdiff] ignoring invalid report view preferences: ${dropped.join(', ')}`);
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
...(mode ? { mode } : {}),
|
|
51
|
+
...(theme ? { theme } : {}),
|
|
52
|
+
...(palette ? { palette } : {}),
|
|
53
|
+
...(locale ? { locale } : {}),
|
|
54
|
+
};
|
|
55
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The seam between the viewer and `@kgdiff/renderer`. The controller only talks to the
|
|
3
|
+
* `Renderer` / `Minimap` interfaces, and tests swap in a recording mock (there is no canvas in
|
|
4
|
+
* happy-dom) with `setRenderBackend`.
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
createMinimap,
|
|
8
|
+
createRenderer,
|
|
9
|
+
type Minimap,
|
|
10
|
+
type MinimapOptions,
|
|
11
|
+
type Renderer,
|
|
12
|
+
type RendererOptions,
|
|
13
|
+
} from '@kgdiff/renderer';
|
|
14
|
+
|
|
15
|
+
export interface RenderBackend {
|
|
16
|
+
createRenderer(container: HTMLElement, options: RendererOptions): Renderer;
|
|
17
|
+
createMinimap(container: HTMLElement, renderer: Renderer, options?: MinimapOptions): Minimap;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const defaultBackend: RenderBackend = { createRenderer, createMinimap };
|
|
21
|
+
|
|
22
|
+
let backend: RenderBackend = defaultBackend;
|
|
23
|
+
|
|
24
|
+
export function renderBackend(): RenderBackend {
|
|
25
|
+
return backend;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Replace the rendering backend (tests). `null` restores the canvas renderer. */
|
|
29
|
+
export function setRenderBackend(next: RenderBackend | null): void {
|
|
30
|
+
backend = next ?? defaultBackend;
|
|
31
|
+
}
|
package/src/replay.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replay clock: pure time arithmetic over a scene `Timeline` (no timers). The controller
|
|
3
|
+
* advances it from its animation-frame loop.
|
|
4
|
+
*/
|
|
5
|
+
import type { Timeline } from '@kgdiff/scene';
|
|
6
|
+
|
|
7
|
+
export const SPEEDS = [0.5, 1, 2, 4] as const;
|
|
8
|
+
export type ReplaySpeed = (typeof SPEEDS)[number];
|
|
9
|
+
|
|
10
|
+
/** Times closer than this (ms) count as equal when stepping. */
|
|
11
|
+
const EPS = 0.5;
|
|
12
|
+
/** A frame gap longer than this (tab in background, debugger) advances at most this much. */
|
|
13
|
+
export const MAX_FRAME_MS = 100;
|
|
14
|
+
|
|
15
|
+
export class ReplayClock {
|
|
16
|
+
#time = 0;
|
|
17
|
+
#rate: ReplaySpeed = 1;
|
|
18
|
+
#running = false;
|
|
19
|
+
/** Sorted, distinct stop times for stepping (window or chapter ends). */
|
|
20
|
+
readonly #stops: readonly number[];
|
|
21
|
+
readonly #starts: Float64Array;
|
|
22
|
+
|
|
23
|
+
constructor(readonly timeline: Timeline) {
|
|
24
|
+
const ends =
|
|
25
|
+
timeline.mode === 'chapters'
|
|
26
|
+
? timeline.chapters.map((c) => c.end)
|
|
27
|
+
: timeline.windows.map((w) => w.end);
|
|
28
|
+
this.#stops = [...new Set(ends)].sort((a, b) => a - b);
|
|
29
|
+
this.#starts = Float64Array.from(timeline.windows.map((w) => w.start)).sort();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get t(): number {
|
|
33
|
+
return this.#time;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get duration(): number {
|
|
37
|
+
return this.timeline.duration;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get speed(): ReplaySpeed {
|
|
41
|
+
return this.#rate;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get playing(): boolean {
|
|
45
|
+
return this.#running;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** At the end and not playing. */
|
|
49
|
+
get finished(): boolean {
|
|
50
|
+
return !this.#running && this.duration > 0 && this.#time >= this.duration;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Normalized position in [0, 1]. */
|
|
54
|
+
get progress(): number {
|
|
55
|
+
return this.duration > 0 ? this.#time / this.duration : 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
setSpeed(speed: number): void {
|
|
59
|
+
const s = SPEEDS.find((x) => x === speed);
|
|
60
|
+
if (s !== undefined) this.#rate = s;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Next speed in `SPEEDS`, wrapping around. */
|
|
64
|
+
cycleSpeed(): ReplaySpeed {
|
|
65
|
+
const i = SPEEDS.indexOf(this.#rate);
|
|
66
|
+
this.#rate = SPEEDS[(i + 1) % SPEEDS.length] as ReplaySpeed;
|
|
67
|
+
return this.#rate;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Start playing; a finished replay restarts from 0. No-op for an empty timeline. */
|
|
71
|
+
play(): void {
|
|
72
|
+
if (this.duration <= 0) return;
|
|
73
|
+
if (this.#time >= this.duration) this.#time = 0;
|
|
74
|
+
this.#running = true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
pause(): void {
|
|
78
|
+
this.#running = false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
seek(t: number): void {
|
|
82
|
+
this.#time = Number.isFinite(t) ? Math.min(this.duration, Math.max(0, t)) : 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Advance by a real-time delta (ms, capped at `MAX_FRAME_MS`) scaled by the speed. Stops at
|
|
87
|
+
* the end. Returns whether time moved.
|
|
88
|
+
*/
|
|
89
|
+
advance(dt: number): boolean {
|
|
90
|
+
if (!this.#running || !(dt > 0)) return false;
|
|
91
|
+
const before = this.#time;
|
|
92
|
+
this.#time = Math.min(this.duration, this.#time + Math.min(dt, MAX_FRAME_MS) * this.#rate);
|
|
93
|
+
if (this.#time >= this.duration) this.#running = false;
|
|
94
|
+
return this.#time !== before;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Seek to the next (+1) / previous (−1) stop: the end of a hunk window (`hunks` timelines) or
|
|
99
|
+
* of a chapter (`chapters`). Backwards from the first stop goes to 0. Pauses playback.
|
|
100
|
+
*/
|
|
101
|
+
step(direction: 1 | -1): void {
|
|
102
|
+
this.#running = false;
|
|
103
|
+
const t = this.#time;
|
|
104
|
+
if (direction > 0) {
|
|
105
|
+
const next = this.#stops.find((s) => s > t + EPS);
|
|
106
|
+
this.#time = next ?? this.duration;
|
|
107
|
+
} else {
|
|
108
|
+
let prev = 0;
|
|
109
|
+
for (const s of this.#stops) {
|
|
110
|
+
if (s < t - EPS) prev = s;
|
|
111
|
+
else break;
|
|
112
|
+
}
|
|
113
|
+
this.#time = prev;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** End of hunk `h`'s window (where selecting it in the list seeks to), or null. */
|
|
118
|
+
windowEnd(h: number): number | null {
|
|
119
|
+
const w = this.timeline.windows.find((x) => x.hunk === h);
|
|
120
|
+
return w === undefined ? null : w.end;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Number of windows that have started (strictly before `t`): the `3` in `3 / 23`. */
|
|
124
|
+
position(t = this.#time): number {
|
|
125
|
+
let lo = 0;
|
|
126
|
+
let hi = this.#starts.length;
|
|
127
|
+
while (lo < hi) {
|
|
128
|
+
const mid = (lo + hi) >> 1;
|
|
129
|
+
if ((this.#starts[mid] as number) < t) lo = mid + 1;
|
|
130
|
+
else hi = mid;
|
|
131
|
+
}
|
|
132
|
+
return lo;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
get total(): number {
|
|
136
|
+
return this.timeline.windows.length;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* What the camera follows at `t`: in `hunks` mode the hunk whose window contains `t` (the latest
|
|
141
|
+
* started), in `chapters` mode the chapter containing `t`. `-1` between windows.
|
|
142
|
+
*/
|
|
143
|
+
activeTarget(t = this.#time): { readonly kind: 'hunk' | 'chapter'; readonly index: number } {
|
|
144
|
+
if (this.timeline.mode === 'chapters') {
|
|
145
|
+
const c = this.timeline.chapters.findIndex((x) => t >= x.start && t < x.end);
|
|
146
|
+
return { kind: 'chapter', index: c };
|
|
147
|
+
}
|
|
148
|
+
return { kind: 'hunk', index: this.activeHunk(t) };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The hunk whose window is playing at `t` (latest started), or -1. */
|
|
152
|
+
activeHunk(t = this.#time): number {
|
|
153
|
+
let best = -1;
|
|
154
|
+
let bestStart = Number.NEGATIVE_INFINITY;
|
|
155
|
+
for (const w of this.timeline.windows) {
|
|
156
|
+
if (t >= w.start && t < w.end && w.start >= bestStart) {
|
|
157
|
+
best = w.hunk;
|
|
158
|
+
bestStart = w.start;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return best;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Review progress: which hunks were marked reviewed, persisted in `localStorage` per diff
|
|
3
|
+
* (`kgdiff:review:<base hash>→<target hash>`). Storage failures (private mode, quota, sandboxed
|
|
4
|
+
* iframes) never break the viewer: the state simply stays in memory.
|
|
5
|
+
*/
|
|
6
|
+
import type { ReviewState } from './index.js';
|
|
7
|
+
|
|
8
|
+
export const STORAGE_PREFIX = 'kgdiff:review:';
|
|
9
|
+
|
|
10
|
+
export type StorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
|
|
11
|
+
|
|
12
|
+
export function reviewKey(baseHash: string, targetHash: string): string {
|
|
13
|
+
return `${STORAGE_PREFIX}${baseHash}→${targetHash}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** `window.localStorage`, or null when unavailable / blocked. */
|
|
17
|
+
export function defaultStorage(): StorageLike | null {
|
|
18
|
+
try {
|
|
19
|
+
return typeof localStorage !== 'undefined' ? localStorage : null;
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class ReviewStore {
|
|
26
|
+
#reviewed: string[] = [];
|
|
27
|
+
#set = new Set<string>();
|
|
28
|
+
/** Ids from storage this diff does not have (another hunking version): kept, never shown. */
|
|
29
|
+
#foreign: string[] = [];
|
|
30
|
+
readonly #known: ReadonlySet<string>;
|
|
31
|
+
readonly #storage: StorageLike | null;
|
|
32
|
+
readonly #key: string | null;
|
|
33
|
+
readonly #valid: readonly string[];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param key storage key (`reviewKey`), or null to keep the state in memory only.
|
|
37
|
+
* @param valid hunk ids of the current diff; stored ids it does not have are preserved.
|
|
38
|
+
*/
|
|
39
|
+
constructor(storage: StorageLike | null, key: string | null, valid: readonly string[]) {
|
|
40
|
+
this.#storage = storage;
|
|
41
|
+
this.#key = key;
|
|
42
|
+
this.#valid = valid;
|
|
43
|
+
this.#known = new Set(valid);
|
|
44
|
+
this.#load();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#load(): void {
|
|
48
|
+
this.#reviewed = [];
|
|
49
|
+
this.#set = new Set();
|
|
50
|
+
this.#foreign = [];
|
|
51
|
+
for (const id of this.#read()) {
|
|
52
|
+
if (this.#set.has(id) || this.#foreign.includes(id)) continue;
|
|
53
|
+
if (this.#known.has(id)) {
|
|
54
|
+
this.#set.add(id);
|
|
55
|
+
this.#reviewed.push(id);
|
|
56
|
+
} else this.#foreign.push(id);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Re-read storage after a `storage` event for `key` (null: everything changed). Returns
|
|
62
|
+
* whether the visible state changed.
|
|
63
|
+
*/
|
|
64
|
+
reload(key: string | null): boolean {
|
|
65
|
+
if (key !== null && key !== this.#key) return false;
|
|
66
|
+
const before = this.#reviewed.join('\n');
|
|
67
|
+
this.#load();
|
|
68
|
+
return this.#reviewed.join('\n') !== before;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
has(id: string): boolean {
|
|
72
|
+
return this.#set.has(id);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
get size(): number {
|
|
76
|
+
return this.#reviewed.length;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Returns true when the state changed. Unknown ids are ignored. */
|
|
80
|
+
setReviewed(id: string, reviewed: boolean): boolean {
|
|
81
|
+
if (reviewed === this.#set.has(id)) return false;
|
|
82
|
+
if (reviewed) {
|
|
83
|
+
if (!this.#known.has(id)) return false;
|
|
84
|
+
this.#set.add(id);
|
|
85
|
+
this.#reviewed.push(id);
|
|
86
|
+
} else {
|
|
87
|
+
this.#set.delete(id);
|
|
88
|
+
this.#reviewed.splice(this.#reviewed.indexOf(id), 1);
|
|
89
|
+
}
|
|
90
|
+
this.#write();
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
state(): ReviewState {
|
|
95
|
+
return { reviewed: [...this.#reviewed], total: this.#valid.length };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#read(): string[] {
|
|
99
|
+
if (!this.#storage || !this.#key) return [];
|
|
100
|
+
try {
|
|
101
|
+
const raw = this.#storage.getItem(this.#key);
|
|
102
|
+
if (!raw) return [];
|
|
103
|
+
const parsed: unknown = JSON.parse(raw);
|
|
104
|
+
const list = Array.isArray(parsed)
|
|
105
|
+
? parsed
|
|
106
|
+
: typeof parsed === 'object' && parsed !== null && 'reviewed' in parsed
|
|
107
|
+
? (parsed as { reviewed: unknown }).reviewed
|
|
108
|
+
: [];
|
|
109
|
+
return Array.isArray(list) ? list.filter((x): x is string => typeof x === 'string') : [];
|
|
110
|
+
} catch {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#write(): void {
|
|
116
|
+
if (!this.#storage || !this.#key) return;
|
|
117
|
+
try {
|
|
118
|
+
const all = [...this.#reviewed, ...this.#foreign];
|
|
119
|
+
if (all.length === 0) this.#storage.removeItem(this.#key);
|
|
120
|
+
else this.#storage.setItem(this.#key, JSON.stringify({ v: 1, reviewed: all }));
|
|
121
|
+
} catch {
|
|
122
|
+
// Quota exceeded or storage blocked: keep the in-memory state.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
package/src/search.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node search for the sidebar: fuzzy match over labels, aliases and ids of every union node
|
|
3
|
+
* (including nodes outside a `changes`-scoped layout).
|
|
4
|
+
*
|
|
5
|
+
* Scoring per candidate string (higher is better): exact 1000, prefix 800, word-start 600,
|
|
6
|
+
* substring 400 (earlier is better), then subsequence (gaps cost). Labels outrank aliases
|
|
7
|
+
* outrank ids; changed nodes win ties.
|
|
8
|
+
*/
|
|
9
|
+
import type { DiffResult, UnionNode } from '@kgdiff/core';
|
|
10
|
+
|
|
11
|
+
export interface SearchHit {
|
|
12
|
+
readonly node: UnionNode;
|
|
13
|
+
readonly score: number;
|
|
14
|
+
/** The text that matched (label, alias or id). */
|
|
15
|
+
readonly matched: string;
|
|
16
|
+
readonly field: 'label' | 'alias' | 'id';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface Entry {
|
|
20
|
+
readonly node: UnionNode;
|
|
21
|
+
readonly texts: readonly string[];
|
|
22
|
+
readonly folded: readonly string[];
|
|
23
|
+
readonly fields: readonly SearchHit['field'][];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function fold(text: string): string {
|
|
27
|
+
return text.normalize('NFKC').toLowerCase();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const WORD_BREAK = /[\s_\-./:#|()[\]{}「」“”"'’,;]/;
|
|
31
|
+
|
|
32
|
+
export function scoreText(query: string, text: string): number {
|
|
33
|
+
if (query === '') return 0;
|
|
34
|
+
if (text === query) return 1000;
|
|
35
|
+
if (text.startsWith(query)) return 800 - Math.min(100, text.length - query.length);
|
|
36
|
+
const at = text.indexOf(query);
|
|
37
|
+
if (at > 0) {
|
|
38
|
+
const wordStart = WORD_BREAK.test(text[at - 1] ?? '');
|
|
39
|
+
return (wordStart ? 600 : 400) - Math.min(150, at);
|
|
40
|
+
}
|
|
41
|
+
// Subsequence: every query character in order; contiguous runs and word starts score.
|
|
42
|
+
let score = 200;
|
|
43
|
+
let ti = 0;
|
|
44
|
+
let prev = -2;
|
|
45
|
+
for (const ch of query) {
|
|
46
|
+
const found = text.indexOf(ch, ti);
|
|
47
|
+
if (found < 0) return 0;
|
|
48
|
+
if (found !== prev + 1) score -= Math.min(20, found - ti + 2);
|
|
49
|
+
if (found === 0 || WORD_BREAK.test(text[found - 1] ?? '')) score += 6;
|
|
50
|
+
prev = found;
|
|
51
|
+
ti = found + 1;
|
|
52
|
+
}
|
|
53
|
+
return Math.max(1, score);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class SearchIndex {
|
|
57
|
+
readonly #entries: Entry[];
|
|
58
|
+
|
|
59
|
+
constructor(diff: DiffResult) {
|
|
60
|
+
this.#entries = diff.nodes.map((node) => {
|
|
61
|
+
const texts: string[] = [node.label];
|
|
62
|
+
const fields: SearchHit['field'][] = ['label'];
|
|
63
|
+
const add = (text: string | undefined, field: SearchHit['field']): void => {
|
|
64
|
+
if (text && !texts.includes(text)) {
|
|
65
|
+
texts.push(text);
|
|
66
|
+
fields.push(field);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
add(node.before?.label, 'label');
|
|
70
|
+
for (const a of node.after?.aliases ?? []) add(a, 'alias');
|
|
71
|
+
for (const a of node.before?.aliases ?? []) add(a, 'alias');
|
|
72
|
+
add(node.afterId, 'id');
|
|
73
|
+
add(node.beforeId, 'id');
|
|
74
|
+
add(node.id, 'id');
|
|
75
|
+
return { node, texts, folded: texts.map(fold), fields };
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Best `limit` hits for `query` (trimmed, case- and width-insensitive). O(N · |text|). */
|
|
80
|
+
search(query: string, limit = 50): SearchHit[] {
|
|
81
|
+
const q = fold(query.trim());
|
|
82
|
+
if (q === '') return [];
|
|
83
|
+
const hits: SearchHit[] = [];
|
|
84
|
+
for (const e of this.#entries) {
|
|
85
|
+
let best = 0;
|
|
86
|
+
let bi = 0;
|
|
87
|
+
for (let i = 0; i < e.folded.length; i++) {
|
|
88
|
+
const field = e.fields[i];
|
|
89
|
+
const weight = field === 'label' ? 0 : field === 'alias' ? 40 : 60;
|
|
90
|
+
const s = scoreText(q, e.folded[i] as string);
|
|
91
|
+
if (s > 0 && s - weight > best) {
|
|
92
|
+
best = s - weight;
|
|
93
|
+
bi = i;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (best > 0) {
|
|
97
|
+
hits.push({
|
|
98
|
+
node: e.node,
|
|
99
|
+
score: best + (e.node.status === 'unchanged' ? 0 : 1),
|
|
100
|
+
matched: e.texts[bi] as string,
|
|
101
|
+
field: e.fields[bi] as SearchHit['field'],
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
hits.sort(
|
|
106
|
+
(a, b) =>
|
|
107
|
+
b.score - a.score ||
|
|
108
|
+
a.node.label.length - b.node.label.length ||
|
|
109
|
+
(a.node.id < b.node.id ? -1 : a.node.id > b.node.id ? 1 : 0),
|
|
110
|
+
);
|
|
111
|
+
return hits.slice(0, limit);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entry of the optional standalone add-on (`dist/standalone/kgdiff-viewer-compute.js`): makes
|
|
3
|
+
* the compute module (diff engine + force layout) available to the standalone viewer.
|
|
4
|
+
*/
|
|
5
|
+
import * as compute from './compute.js';
|
|
6
|
+
import { COMPUTE_GLOBAL } from './compute-loader.js';
|
|
7
|
+
|
|
8
|
+
(globalThis as Record<string, unknown>)[COMPUTE_GLOBAL] = compute;
|