@kgdiff/viewer 0.4.0 → 0.5.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/dist/context.d.ts +42 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +87 -0
- package/dist/context.js.map +1 -0
- package/dist/controller.d.ts +26 -185
- package/dist/controller.d.ts.map +1 -1
- package/dist/controller.js +45 -1155
- 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 +49 -229
- package/dist/element.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/pipeline.d.ts.map +1 -1
- package/dist/pipeline.js +3 -2
- package/dist/pipeline.js.map +1 -1
- package/dist/root.d.ts +54 -0
- package/dist/root.d.ts.map +1 -0
- package/dist/root.js +265 -0
- package/dist/root.js.map +1 -0
- package/dist/schedule.d.ts +22 -0
- package/dist/schedule.d.ts.map +1 -0
- package/dist/schedule.js +41 -0
- package/dist/schedule.js.map +1 -0
- package/dist/session.d.ts +253 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +1239 -0
- package/dist/session.js.map +1 -0
- package/dist/standalone/kgdiff-viewer-compute.js +1 -1
- package/dist/standalone/kgdiff-viewer.js +4 -4
- package/dist/styles.d.ts +13 -6
- package/dist/styles.d.ts.map +1 -1
- package/dist/styles.js +217 -248
- package/dist/styles.js.map +1 -1
- package/dist/theme.d.ts +25 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +149 -0
- package/dist/theme.js.map +1 -0
- package/dist/ui/change-list.d.ts +18 -0
- package/dist/ui/change-list.d.ts.map +1 -0
- package/dist/ui/change-list.js +331 -0
- package/dist/ui/change-list.js.map +1 -0
- package/dist/ui/context.d.ts +19 -5
- package/dist/ui/context.d.ts.map +1 -1
- package/dist/ui/context.js.map +1 -1
- package/dist/ui/filters.d.ts +9 -0
- package/dist/ui/filters.d.ts.map +1 -0
- package/dist/ui/filters.js +68 -0
- package/dist/ui/filters.js.map +1 -0
- package/dist/ui/header.js +2 -2
- package/dist/ui/header.js.map +1 -1
- package/dist/ui/search.d.ts +13 -0
- package/dist/ui/search.d.ts.map +1 -0
- package/dist/ui/search.js +191 -0
- package/dist/ui/search.js.map +1 -0
- package/dist/ui/sidebar.d.ts +8 -16
- package/dist/ui/sidebar.d.ts.map +1 -1
- package/dist/ui/sidebar.js +15 -539
- package/dist/ui/sidebar.js.map +1 -1
- package/dist/ui/stage.js +1 -1
- package/dist/ui/stage.js.map +1 -1
- package/dist/ui/swatch.js +1 -1
- package/dist/ui/swatch.js.map +1 -1
- package/package.json +6 -6
- package/src/context.ts +106 -0
- package/src/controller.ts +70 -1257
- package/src/element.ts +52 -243
- package/src/index.ts +3 -1
- package/src/pipeline.ts +4 -1
- package/src/root.ts +308 -0
- package/src/schedule.ts +43 -0
- package/src/session.ts +1386 -0
- package/src/styles.ts +225 -253
- package/src/theme.ts +167 -0
- package/src/ui/change-list.ts +401 -0
- package/src/ui/context.ts +21 -5
- package/src/ui/filters.ts +79 -0
- package/src/ui/header.ts +2 -2
- package/src/ui/search.ts +220 -0
- package/src/ui/sidebar.ts +28 -668
- package/src/ui/stage.ts +1 -1
- package/src/ui/swatch.ts +1 -1
package/src/theme.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The theme as CSS (RFC 0001 §6; decision 1: CSS is the theme's single source of truth).
|
|
3
|
+
*
|
|
4
|
+
* Colors and fonts are custom properties named `--kgdiff-*`. The built-in themes (dark / light
|
|
5
|
+
* × default / color-blind palette, `getTheme` in @kgdiff/scene, whose contrast is tested there)
|
|
6
|
+
* are token sets a root applies on `:host` (`tokenSheet`). The canvas is drawn with the tokens
|
|
7
|
+
* as computed on the root (`themeFromStyle`), so a token the host overrides with ordinary CSS
|
|
8
|
+
* changes the panels and the canvas alike. Motion and the interaction tints are written as
|
|
9
|
+
* tokens too, for the panels; the canvas keeps the theme's own (they stay in JavaScript).
|
|
10
|
+
*/
|
|
11
|
+
import { contrastRatio, type Theme } from '@kgdiff/scene';
|
|
12
|
+
import { FAMILIES, sheetFor } from './styles.js';
|
|
13
|
+
|
|
14
|
+
/** Token (after `--kgdiff-`) → where its value lives in a `Theme`. */
|
|
15
|
+
const TOKENS: readonly (readonly [token: string, path: string])[] = [
|
|
16
|
+
['bg', 'canvas.background'],
|
|
17
|
+
['grid', 'canvas.grid'],
|
|
18
|
+
['panel', 'surface.panel'],
|
|
19
|
+
['raised', 'surface.raised'],
|
|
20
|
+
['overlay', 'surface.overlay'],
|
|
21
|
+
['border', 'surface.border'],
|
|
22
|
+
['border-strong', 'surface.borderStrong'],
|
|
23
|
+
['control-border', 'surface.controlBorder'],
|
|
24
|
+
['shadow', 'surface.shadow'],
|
|
25
|
+
['text', 'text.primary'],
|
|
26
|
+
['text-2', 'text.secondary'],
|
|
27
|
+
['text-3', 'text.muted'],
|
|
28
|
+
['accent', 'accent.base'],
|
|
29
|
+
['accent-strong', 'accent.strong'],
|
|
30
|
+
['accent-soft', 'accent.soft'],
|
|
31
|
+
['accent-text', 'accent.text'],
|
|
32
|
+
['neutral', 'neutral.node'],
|
|
33
|
+
['neutral-stroke', 'neutral.nodeStroke'],
|
|
34
|
+
['neutral-edge', 'neutral.edge'],
|
|
35
|
+
['neutral-label', 'neutral.label'],
|
|
36
|
+
['font', 'font.family'],
|
|
37
|
+
['mono', 'font.mono'],
|
|
38
|
+
...FAMILIES.flatMap((f) => [
|
|
39
|
+
[f, `family.${f}.base`] as const,
|
|
40
|
+
[`${f}-strong`, `family.${f}.strong`] as const,
|
|
41
|
+
[`${f}-soft`, `family.${f}.soft`] as const,
|
|
42
|
+
[`${f}-text`, `family.${f}.text`] as const,
|
|
43
|
+
]),
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/** Tokens that are not colors (taken as they are). */
|
|
47
|
+
const VERBATIM: ReadonlySet<string> = new Set(['shadow', 'font', 'mono']);
|
|
48
|
+
|
|
49
|
+
type Tree = { readonly [key: string]: unknown };
|
|
50
|
+
|
|
51
|
+
function get(theme: Theme, path: string): string {
|
|
52
|
+
let at: unknown = theme;
|
|
53
|
+
for (const key of path.split('.')) at = (at as Tree)[key];
|
|
54
|
+
return at as string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A copy of `tree` with `value` at `keys` (copy on write: untouched branches are shared). */
|
|
58
|
+
function put(tree: Tree, keys: readonly string[], value: string): Tree {
|
|
59
|
+
const [key = '', ...rest] = keys;
|
|
60
|
+
return { ...tree, [key]: rest.length > 0 ? put(tree[key] as Tree, rest, value) : value };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** CSS custom properties for `theme` (a `:host` rule body). */
|
|
64
|
+
export function themeVariables(theme: Theme): string {
|
|
65
|
+
const dark = theme.name === 'dark';
|
|
66
|
+
const vars: [string, string][] = [['color-scheme', dark ? 'dark' : 'light']];
|
|
67
|
+
for (const [token, path] of TOKENS) vars.push([token, get(theme, path)]);
|
|
68
|
+
vars.push(
|
|
69
|
+
['fast', `${theme.motion.fast}ms`],
|
|
70
|
+
['base', `${theme.motion.base}ms`],
|
|
71
|
+
['slow', `${theme.motion.slow}ms`],
|
|
72
|
+
['ease', `cubic-bezier(${theme.motion.easing.join(', ')})`],
|
|
73
|
+
// Derived interaction tints (not color-coded: hover / press / inputs / scrims).
|
|
74
|
+
['hover', dark ? 'rgba(255,255,255,0.045)' : 'rgba(15,23,42,0.04)'],
|
|
75
|
+
['press', dark ? 'rgba(255,255,255,0.08)' : 'rgba(15,23,42,0.07)'],
|
|
76
|
+
['input', dark ? theme.surface.raised : '#F3F5F8'],
|
|
77
|
+
['selected', dark ? theme.surface.raised : '#F5F7FB'],
|
|
78
|
+
['scrim', dark ? 'rgba(5,7,11,0.56)' : 'rgba(15,23,42,0.18)'],
|
|
79
|
+
['skeleton', dark ? 'rgba(255,255,255,0.05)' : 'rgba(15,23,42,0.05)'],
|
|
80
|
+
['skeleton-hi', dark ? 'rgba(255,255,255,0.09)' : 'rgba(15,23,42,0.09)'],
|
|
81
|
+
['track', dark ? 'rgba(255,255,255,0.08)' : 'rgba(15,23,42,0.08)'],
|
|
82
|
+
['kbd', dark ? 'rgba(255,255,255,0.06)' : 'rgba(15,23,42,0.05)'],
|
|
83
|
+
);
|
|
84
|
+
return vars.map(([k, v]) => `${k === 'color-scheme' ? k : `--kgdiff-${k}`}: ${v};`).join(' ');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const sheets = new WeakMap<Document, Map<Theme, CSSStyleSheet>>();
|
|
88
|
+
|
|
89
|
+
/** The built-in `theme` as a token set on `:host`, parsed once per document. */
|
|
90
|
+
export function tokenSheet(theme: Theme, doc: Document): CSSStyleSheet {
|
|
91
|
+
let byTheme = sheets.get(doc);
|
|
92
|
+
if (!byTheme) {
|
|
93
|
+
byTheme = new Map();
|
|
94
|
+
sheets.set(doc, byTheme);
|
|
95
|
+
}
|
|
96
|
+
let sheet = byTheme.get(theme);
|
|
97
|
+
if (!sheet) {
|
|
98
|
+
sheet = sheetFor(doc, `:host { ${themeVariables(theme)} }`);
|
|
99
|
+
byTheme.set(theme, sheet);
|
|
100
|
+
}
|
|
101
|
+
return sheet;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let probe: CanvasRenderingContext2D | null | undefined;
|
|
105
|
+
const warned = new Set<string>();
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `value` as a color the scene can parse (`#rrggbb`, `rgba()`), or null. A canvas normalizes
|
|
109
|
+
* whatever sRGB color syntax the browser knows (names, `hsl()`, `color-mix()`, …).
|
|
110
|
+
*/
|
|
111
|
+
function canvasColor(value: string): string | null {
|
|
112
|
+
probe ??=
|
|
113
|
+
typeof document === 'undefined' ? null : document.createElement('canvas').getContext('2d');
|
|
114
|
+
let color = value;
|
|
115
|
+
if (probe) {
|
|
116
|
+
probe.fillStyle = '#000';
|
|
117
|
+
probe.fillStyle = value;
|
|
118
|
+
color = String(probe.fillStyle);
|
|
119
|
+
probe.fillStyle = '#fff';
|
|
120
|
+
probe.fillStyle = value;
|
|
121
|
+
if (String(probe.fillStyle) !== color) return null;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
contrastRatio(color, color);
|
|
125
|
+
return color;
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Themes built from computed tokens, by base theme and overrides (stable identities). */
|
|
132
|
+
const built = new Map<string, Theme>();
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The theme the canvas draws with: `base` (the built-in theme of the root's token set) with
|
|
136
|
+
* every token the root's computed `style` sets to another value. Unchanged tokens keep the
|
|
137
|
+
* base's identity, so caches keyed by the theme survive; a value the canvas cannot draw is
|
|
138
|
+
* ignored with a warning.
|
|
139
|
+
*/
|
|
140
|
+
export function themeFromStyle(
|
|
141
|
+
base: Theme,
|
|
142
|
+
style: { getPropertyValue(name: string): string },
|
|
143
|
+
): Theme {
|
|
144
|
+
const overrides: [string, string][] = [];
|
|
145
|
+
for (const [token, path] of TOKENS) {
|
|
146
|
+
const raw = style.getPropertyValue(`--kgdiff-${token}`).trim();
|
|
147
|
+
if (!raw || raw === get(base, path)) continue;
|
|
148
|
+
const value = VERBATIM.has(token) ? raw : canvasColor(raw);
|
|
149
|
+
if (value === null) {
|
|
150
|
+
if (!warned.has(raw)) {
|
|
151
|
+
warned.add(raw);
|
|
152
|
+
console.warn(`[kgdiff] --kgdiff-${token}: ${raw} is not a color the canvas draws.`);
|
|
153
|
+
}
|
|
154
|
+
} else if (value !== get(base, path)) overrides.push([path, value]);
|
|
155
|
+
}
|
|
156
|
+
if (overrides.length === 0) return base;
|
|
157
|
+
const key = `${base.name}/${base.palette}|${overrides.join('|')}`;
|
|
158
|
+
let theme = built.get(key);
|
|
159
|
+
if (!theme) {
|
|
160
|
+
let tree: Tree = base as unknown as Tree;
|
|
161
|
+
for (const [path, value] of overrides) tree = put(tree, path.split('.'), value);
|
|
162
|
+
theme = tree as unknown as Theme;
|
|
163
|
+
if (built.size >= 16) built.clear();
|
|
164
|
+
built.set(key, theme);
|
|
165
|
+
}
|
|
166
|
+
return theme;
|
|
167
|
+
}
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The change list (RFC 0001 §3, `<kgdiff-change-list>` from phase 2): the "Changes" title with
|
|
3
|
+
* the count and review progress, and the phase-grouped list of changes (virtualized, sticky
|
|
4
|
+
* group headers, a `listbox` with `aria-activedescendant`). While a search is active (the
|
|
5
|
+
* session's query is not blank) it steps aside. Registers itself as the root's `list` focus
|
|
6
|
+
* target.
|
|
7
|
+
*/
|
|
8
|
+
import type { SceneHunk } from '@kgdiff/scene';
|
|
9
|
+
import { attr, h, icon, replace, setText } from '../dom.js';
|
|
10
|
+
import type { ListItem } from '../list-model.js';
|
|
11
|
+
import { frameOrTimeout } from '../schedule.js';
|
|
12
|
+
import type { Topic } from '../session.js';
|
|
13
|
+
import { sceneHunkGlyph, type UiContext } from './context.js';
|
|
14
|
+
|
|
15
|
+
/** Row slot and group-header heights (px); rows are 52 px tall inside a 56 px slot. */
|
|
16
|
+
export const ROW_SLOT = 56;
|
|
17
|
+
export const HEADER_SLOT = 32;
|
|
18
|
+
const OVERSCAN = 320;
|
|
19
|
+
|
|
20
|
+
function itemHeight(item: ListItem): number {
|
|
21
|
+
return item.kind === 'header' ? HEADER_SLOT : ROW_SLOT;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class ChangeList {
|
|
25
|
+
/** Title, count and review progress. */
|
|
26
|
+
readonly head: HTMLElement;
|
|
27
|
+
readonly list: HTMLElement;
|
|
28
|
+
readonly sticky: HTMLElement;
|
|
29
|
+
/** Visually hidden group names the options are described by. */
|
|
30
|
+
readonly phases: HTMLElement = h('div', { class: 'sr-only' });
|
|
31
|
+
readonly #title: HTMLElement;
|
|
32
|
+
readonly #count: HTMLElement;
|
|
33
|
+
readonly #progress: HTMLElement;
|
|
34
|
+
readonly #progressText: HTMLElement;
|
|
35
|
+
readonly #progressBar: HTMLElement;
|
|
36
|
+
readonly #listInner: HTMLElement;
|
|
37
|
+
#offsets: number[] = [];
|
|
38
|
+
#rendered = new Map<number, HTMLElement>();
|
|
39
|
+
#scrollQueued = false;
|
|
40
|
+
|
|
41
|
+
readonly #ctx: UiContext;
|
|
42
|
+
|
|
43
|
+
constructor(ctx: UiContext) {
|
|
44
|
+
this.#ctx = ctx;
|
|
45
|
+
const c = ctx.controller;
|
|
46
|
+
this.#title = h('h2', { id: 'kg-changes-title' });
|
|
47
|
+
this.#count = h('span', { class: 'count-badge' });
|
|
48
|
+
this.#progressText = h('span');
|
|
49
|
+
this.#progressBar = h('div');
|
|
50
|
+
this.#progress = h(
|
|
51
|
+
'div',
|
|
52
|
+
{ class: 'progress' },
|
|
53
|
+
this.#progressText,
|
|
54
|
+
h('span', { class: 'bar', 'aria-hidden': 'true' }, this.#progressBar),
|
|
55
|
+
);
|
|
56
|
+
this.head = h('div', { class: 'sb-title' }, this.#title, this.#count, this.#progress);
|
|
57
|
+
this.#listInner = h('div', { class: 'list-inner', role: 'presentation' });
|
|
58
|
+
this.list = h(
|
|
59
|
+
'div',
|
|
60
|
+
{
|
|
61
|
+
class: 'list scroll',
|
|
62
|
+
role: 'listbox',
|
|
63
|
+
tabindex: '0',
|
|
64
|
+
'aria-labelledby': 'kg-changes-title',
|
|
65
|
+
'aria-keyshortcuts': 'J K X Enter Home End',
|
|
66
|
+
},
|
|
67
|
+
this.#listInner,
|
|
68
|
+
);
|
|
69
|
+
this.list.addEventListener('scroll', () => this.#queueWindow(), { passive: true });
|
|
70
|
+
this.list.addEventListener('click', (e) => this.#onListClick(e));
|
|
71
|
+
this.list.addEventListener('dblclick', (e) => {
|
|
72
|
+
const row = (e.target as Element).closest('.row');
|
|
73
|
+
if (row) c.flyToSelection();
|
|
74
|
+
});
|
|
75
|
+
this.sticky = h('div', { class: 'sticky', 'aria-hidden': 'true', hidden: true });
|
|
76
|
+
ctx.target('list', () => this.list.focus({ preventScroll: true }));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
update(topics: ReadonlySet<Topic>): void {
|
|
80
|
+
const tr = this.#ctx.tr();
|
|
81
|
+
const all = topics.has('status') || topics.has('data') || topics.has('locale');
|
|
82
|
+
if (all) {
|
|
83
|
+
setText(this.#title, tr.t('sidebar.changes'));
|
|
84
|
+
const scene = this.#ctx.controller.scene;
|
|
85
|
+
setText(this.#count, tr.num(scene?.hunks.length ?? 0));
|
|
86
|
+
this.#count.hidden = !scene;
|
|
87
|
+
}
|
|
88
|
+
if (all || topics.has('filters')) this.#layoutList();
|
|
89
|
+
if (all || topics.has('review')) {
|
|
90
|
+
this.#renderProgress();
|
|
91
|
+
this.#refreshRows();
|
|
92
|
+
}
|
|
93
|
+
if (topics.has('selection')) {
|
|
94
|
+
this.#refreshRows();
|
|
95
|
+
this.#revealSelected();
|
|
96
|
+
}
|
|
97
|
+
if (all || topics.has('search')) this.#stepAside();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The search's results take the list's place while the query is not blank. Coming back, the
|
|
102
|
+
* list measures itself on the next frame, once every region has updated (the results it
|
|
103
|
+
* shared its space with are hidden by then, whatever order the regions update in).
|
|
104
|
+
*/
|
|
105
|
+
#stepAside(): void {
|
|
106
|
+
const searching = this.#ctx.controller.query.trim() !== '';
|
|
107
|
+
if (this.list.hidden === searching) return;
|
|
108
|
+
this.list.hidden = searching;
|
|
109
|
+
if (searching) this.sticky.hidden = true;
|
|
110
|
+
else this.#queueWindow();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#renderProgress(): void {
|
|
114
|
+
const tr = this.#ctx.tr();
|
|
115
|
+
const state = this.#ctx.controller.reviewState();
|
|
116
|
+
const done = state.reviewed.length;
|
|
117
|
+
const total = state.total;
|
|
118
|
+
this.#progress.hidden = total === 0;
|
|
119
|
+
const complete = total > 0 && done === total;
|
|
120
|
+
attr(this.#progress, 'data-complete', String(complete));
|
|
121
|
+
setText(
|
|
122
|
+
this.#progressText,
|
|
123
|
+
complete ? tr.t('review.complete') : tr.t('review.progress', { done, total }),
|
|
124
|
+
);
|
|
125
|
+
this.#progressBar.style.transform = `scaleX(${total > 0 ? done / total : 0})`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─── Virtual list ────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
#layoutList(): void {
|
|
131
|
+
const c = this.#ctx.controller;
|
|
132
|
+
const tr = this.#ctx.tr();
|
|
133
|
+
replace(
|
|
134
|
+
this.phases,
|
|
135
|
+
c.list.items.map((it) =>
|
|
136
|
+
it.kind === 'header'
|
|
137
|
+
? h('span', {
|
|
138
|
+
id: `kg-phase-${it.phase}`,
|
|
139
|
+
text: `${tr.t(`phase.${it.phase}`)} (${tr.num(it.count)})`,
|
|
140
|
+
})
|
|
141
|
+
: null,
|
|
142
|
+
),
|
|
143
|
+
);
|
|
144
|
+
for (const el of this.#rendered.values()) el.remove();
|
|
145
|
+
this.#rendered.clear();
|
|
146
|
+
const items = c.list.items;
|
|
147
|
+
this.#offsets = new Array(items.length);
|
|
148
|
+
let y = 0;
|
|
149
|
+
for (let i = 0; i < items.length; i++) {
|
|
150
|
+
this.#offsets[i] = y;
|
|
151
|
+
y += itemHeight(items[i] as ListItem);
|
|
152
|
+
}
|
|
153
|
+
this.#listInner.style.height = `${y + 8}px`;
|
|
154
|
+
this.#renderEmpty();
|
|
155
|
+
this.renderWindow();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
#renderEmpty(): void {
|
|
159
|
+
const c = this.#ctx.controller;
|
|
160
|
+
const tr = this.#ctx.tr();
|
|
161
|
+
const existing = this.#listInner.querySelector('.list-empty, .skeleton');
|
|
162
|
+
existing?.remove();
|
|
163
|
+
if (c.status === 'loading' || c.status === 'idle') {
|
|
164
|
+
const sk = h('div', { class: 'skeleton', 'aria-hidden': 'true' });
|
|
165
|
+
if (c.status === 'loading') {
|
|
166
|
+
for (let i = 0; i < 6; i++) {
|
|
167
|
+
sk.appendChild(
|
|
168
|
+
h(
|
|
169
|
+
'div',
|
|
170
|
+
{ class: 'skeleton-row shimmer' },
|
|
171
|
+
h('span', { class: 'g' }),
|
|
172
|
+
h('span', { class: 'b' }),
|
|
173
|
+
),
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
this.#listInner.appendChild(sk);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (c.status !== 'ready' || c.list.items.length > 0) return;
|
|
181
|
+
const filtered = c.families.size > 0 && c.hunkCount > 0;
|
|
182
|
+
this.#listInner.appendChild(
|
|
183
|
+
h(
|
|
184
|
+
'div',
|
|
185
|
+
{ class: 'list-empty' },
|
|
186
|
+
icon(filtered ? 'search' : 'check-circle'),
|
|
187
|
+
h('span', { text: tr.t(filtered ? 'sidebar.filteredEmpty' : 'sidebar.noChanges') }),
|
|
188
|
+
filtered
|
|
189
|
+
? h('button', {
|
|
190
|
+
type: 'button',
|
|
191
|
+
class: 'btn',
|
|
192
|
+
text: tr.t('sidebar.clearFilters'),
|
|
193
|
+
onclick: () => c.setFamilies([]),
|
|
194
|
+
})
|
|
195
|
+
: null,
|
|
196
|
+
),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
#queueWindow(): void {
|
|
201
|
+
if (this.#scrollQueued) return;
|
|
202
|
+
this.#scrollQueued = true;
|
|
203
|
+
frameOrTimeout(() => {
|
|
204
|
+
this.#scrollQueued = false;
|
|
205
|
+
this.renderWindow();
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** First item index whose bottom is below `y` (binary search over offsets). */
|
|
210
|
+
#indexAt(y: number): number {
|
|
211
|
+
let lo = 0;
|
|
212
|
+
let hi = this.#offsets.length;
|
|
213
|
+
while (lo < hi) {
|
|
214
|
+
const mid = (lo + hi) >> 1;
|
|
215
|
+
if ((this.#offsets[mid] as number) + ROW_SLOT <= y) lo = mid + 1;
|
|
216
|
+
else hi = mid;
|
|
217
|
+
}
|
|
218
|
+
return lo;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
renderWindow(): void {
|
|
222
|
+
const items = this.#ctx.controller.list.items;
|
|
223
|
+
const top = this.list.scrollTop;
|
|
224
|
+
const height = this.list.clientHeight || 640;
|
|
225
|
+
const from = this.#indexAt(Math.max(0, top - OVERSCAN));
|
|
226
|
+
const limit = top + height + OVERSCAN;
|
|
227
|
+
const needed = new Set<number>();
|
|
228
|
+
for (let i = from; i < items.length && (this.#offsets[i] as number) < limit; i++) needed.add(i);
|
|
229
|
+
for (const [i, el] of this.#rendered) {
|
|
230
|
+
if (!needed.has(i)) {
|
|
231
|
+
el.remove();
|
|
232
|
+
this.#rendered.delete(i);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
for (const i of needed) {
|
|
236
|
+
if (this.#rendered.has(i)) continue;
|
|
237
|
+
const el = this.#renderItem(items[i] as ListItem, this.#offsets[i] as number);
|
|
238
|
+
this.#rendered.set(i, el);
|
|
239
|
+
this.#listInner.appendChild(el);
|
|
240
|
+
}
|
|
241
|
+
this.#renderSticky(top);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
#renderItem(item: ListItem, y: number): HTMLElement {
|
|
245
|
+
const tr = this.#ctx.tr();
|
|
246
|
+
const c = this.#ctx.controller;
|
|
247
|
+
if (item.kind === 'header') {
|
|
248
|
+
return h(
|
|
249
|
+
'div',
|
|
250
|
+
{ class: 'group', role: 'presentation', style: `top: ${y}px` },
|
|
251
|
+
h('span', { text: tr.t(`phase.${item.phase}`) }),
|
|
252
|
+
h('span', { class: 'n', text: tr.num(item.count) }),
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
const hunk = c.scene?.hunks[item.hunk] as SceneHunk;
|
|
256
|
+
const reviewed = c.isReviewed(hunk.id);
|
|
257
|
+
// Pointer shortcut only: options hold no interactive children. Keyboard users press `x` or
|
|
258
|
+
// use the detail panel's "Reviewed" button.
|
|
259
|
+
const rv = h(
|
|
260
|
+
'span',
|
|
261
|
+
{
|
|
262
|
+
class: 'rv',
|
|
263
|
+
'aria-hidden': 'true',
|
|
264
|
+
'data-pressed': String(reviewed),
|
|
265
|
+
title: tr.t(reviewed ? 'review.unmark' : 'review.mark'),
|
|
266
|
+
'data-hunk': hunk.id,
|
|
267
|
+
},
|
|
268
|
+
h('span', { class: 'ring' }, icon('check')),
|
|
269
|
+
);
|
|
270
|
+
const rank = c.list.rank.get(item.hunk) ?? 0;
|
|
271
|
+
const row = h(
|
|
272
|
+
'div',
|
|
273
|
+
{
|
|
274
|
+
class: 'row',
|
|
275
|
+
role: 'option',
|
|
276
|
+
id: `kg-opt-${item.hunk}`,
|
|
277
|
+
'data-index': String(item.hunk),
|
|
278
|
+
'data-family': hunk.family,
|
|
279
|
+
'data-reviewed': String(reviewed),
|
|
280
|
+
'aria-selected': String(c.selectedHunk === item.hunk),
|
|
281
|
+
'aria-setsize': String(c.list.order.length),
|
|
282
|
+
'aria-posinset': String(rank + 1),
|
|
283
|
+
'aria-describedby': `kg-phase-${hunk.phase}`,
|
|
284
|
+
style: `top: ${y + 2}px`,
|
|
285
|
+
},
|
|
286
|
+
h('span', { class: 'glyph-box', 'aria-hidden': 'true', text: sceneHunkGlyph(hunk) }),
|
|
287
|
+
h(
|
|
288
|
+
'span',
|
|
289
|
+
{ class: 'txt' },
|
|
290
|
+
h('span', { class: 'title', title: hunk.title, text: hunk.title }),
|
|
291
|
+
h('span', { class: 'sub', text: hunk.subtitle }),
|
|
292
|
+
),
|
|
293
|
+
rv,
|
|
294
|
+
);
|
|
295
|
+
this.#labelRow(row, hunk, reviewed);
|
|
296
|
+
return row;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
#labelRow(row: HTMLElement, hunk: SceneHunk, reviewed: boolean): void {
|
|
300
|
+
const tr = this.#ctx.tr();
|
|
301
|
+
const parts = [hunk.title];
|
|
302
|
+
if (hunk.subtitle) parts.push(hunk.subtitle);
|
|
303
|
+
if (reviewed) parts.push(tr.t('review.toggle'));
|
|
304
|
+
attr(row, 'aria-label', parts.join('. '));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Update selection / reviewed state of the rendered rows in place. */
|
|
308
|
+
#refreshRows(): void {
|
|
309
|
+
const c = this.#ctx.controller;
|
|
310
|
+
const tr = this.#ctx.tr();
|
|
311
|
+
for (const el of this.#rendered.values()) {
|
|
312
|
+
if (!el.classList.contains('row')) continue;
|
|
313
|
+
const index = Number(el.dataset.index);
|
|
314
|
+
const hunk = c.scene?.hunks[index];
|
|
315
|
+
if (!hunk) continue;
|
|
316
|
+
const reviewed = c.isReviewed(hunk.id);
|
|
317
|
+
attr(el, 'aria-selected', String(index === c.selectedHunk));
|
|
318
|
+
attr(el, 'data-reviewed', String(reviewed));
|
|
319
|
+
const rv = el.querySelector('.rv') as HTMLElement;
|
|
320
|
+
attr(rv, 'data-pressed', String(reviewed));
|
|
321
|
+
attr(rv, 'title', tr.t(reviewed ? 'review.unmark' : 'review.mark'));
|
|
322
|
+
this.#labelRow(el, hunk, reviewed);
|
|
323
|
+
}
|
|
324
|
+
const sel = c.selectedHunk;
|
|
325
|
+
attr(
|
|
326
|
+
this.list,
|
|
327
|
+
'aria-activedescendant',
|
|
328
|
+
sel >= 0 && c.list.rank.has(sel) ? `kg-opt-${sel}` : null,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
#revealSelected(): void {
|
|
333
|
+
const c = this.#ctx.controller;
|
|
334
|
+
const item = c.list.itemOf.get(c.selectedHunk);
|
|
335
|
+
if (item === undefined) return;
|
|
336
|
+
const y = this.#offsets[item] as number;
|
|
337
|
+
const top = this.list.scrollTop;
|
|
338
|
+
// Not laid out yet (hidden sheet): position it for when it shows.
|
|
339
|
+
const height = this.list.clientHeight || 640;
|
|
340
|
+
// The first row of a group scrolls its group header into view too.
|
|
341
|
+
const above =
|
|
342
|
+
c.list.items[item - 1]?.kind === 'header'
|
|
343
|
+
? (this.#offsets[item - 1] as number)
|
|
344
|
+
: y - HEADER_SLOT;
|
|
345
|
+
if (y < top + HEADER_SLOT) this.list.scrollTop = Math.max(0, above);
|
|
346
|
+
else if (y + ROW_SLOT > top + height) this.list.scrollTop = y + ROW_SLOT - height + 8;
|
|
347
|
+
this.renderWindow();
|
|
348
|
+
this.#refreshRows();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
#renderSticky(top: number): void {
|
|
352
|
+
const items = this.#ctx.controller.list.items;
|
|
353
|
+
let current = -1;
|
|
354
|
+
let next = -1;
|
|
355
|
+
for (let i = 0; i < items.length; i++) {
|
|
356
|
+
if (items[i]?.kind !== 'header') continue;
|
|
357
|
+
if ((this.#offsets[i] as number) <= top) current = i;
|
|
358
|
+
else {
|
|
359
|
+
next = i;
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (current < 0 || top <= 0) {
|
|
364
|
+
this.sticky.hidden = true;
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const item = items[current] as Extract<ListItem, { kind: 'header' }>;
|
|
368
|
+
const tr = this.#ctx.tr();
|
|
369
|
+
this.sticky.hidden = false;
|
|
370
|
+
const label = `${tr.t(`phase.${item.phase}`)}|${item.count}`;
|
|
371
|
+
if (this.sticky.dataset.key !== label) {
|
|
372
|
+
this.sticky.dataset.key = label;
|
|
373
|
+
replace(
|
|
374
|
+
this.sticky,
|
|
375
|
+
h(
|
|
376
|
+
'div',
|
|
377
|
+
{ class: 'group' },
|
|
378
|
+
h('span', { text: tr.t(`phase.${item.phase}`) }),
|
|
379
|
+
h('span', { class: 'n', text: tr.num(item.count) }),
|
|
380
|
+
),
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
const push = next >= 0 ? Math.min(0, (this.#offsets[next] as number) - top - HEADER_SLOT) : 0;
|
|
384
|
+
this.sticky.style.transform = `translateY(${push}px)`;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
#onListClick(e: MouseEvent): void {
|
|
388
|
+
const c = this.#ctx.controller;
|
|
389
|
+
const target = e.target as Element;
|
|
390
|
+
const rv = target.closest('.rv') as HTMLElement | null;
|
|
391
|
+
if (rv?.dataset.hunk) {
|
|
392
|
+
e.stopPropagation();
|
|
393
|
+
c.toggleReviewed(rv.dataset.hunk);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const row = target.closest('.row') as HTMLElement | null;
|
|
397
|
+
if (!row) return;
|
|
398
|
+
const index = Number(row.dataset.index);
|
|
399
|
+
if (Number.isInteger(index)) c.selectHunk(index);
|
|
400
|
+
}
|
|
401
|
+
}
|
package/src/ui/context.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* What UI components share: the controller, the
|
|
3
|
-
*
|
|
2
|
+
* What UI components share: the session (`controller`), the layout's actions, the translator
|
|
3
|
+
* and the root's focus targets.
|
|
4
4
|
*/
|
|
5
5
|
import type { AttrValue, HunkKind, Locale } from '@kgdiff/core';
|
|
6
6
|
import type { ChangeFamily, PaletteName, SceneHunk } from '@kgdiff/scene';
|
|
@@ -8,8 +8,11 @@ import type { ViewerController } from '../controller.js';
|
|
|
8
8
|
import type { Translator } from '../i18n.js';
|
|
9
9
|
import type { ThemePreference, ViewerMode } from '../index.js';
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
/**
|
|
12
|
+
* What the layout does for the regions (preferences it reflects as attributes, the panels and
|
|
13
|
+
* popovers it owns).
|
|
14
|
+
*/
|
|
15
|
+
export interface UiActions {
|
|
13
16
|
readonly tr: () => Translator;
|
|
14
17
|
setMode(mode: ViewerMode): void;
|
|
15
18
|
/** Toggle the `changes` scope (explains itself when it is locked). */
|
|
@@ -21,9 +24,22 @@ export interface UiContext {
|
|
|
21
24
|
sidebarOpen(): boolean;
|
|
22
25
|
toggleMenu(open?: boolean): void;
|
|
23
26
|
openHelp(): void;
|
|
27
|
+
/** Show and focus the search (`/`). */
|
|
24
28
|
focusSearch(): void;
|
|
25
|
-
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Elements of a region that others move focus to, through the root. */
|
|
32
|
+
export type FocusTarget = 'search' | 'list';
|
|
33
|
+
|
|
34
|
+
/** What a region gets from the root it is bound to: the session, the actions, focus moves. */
|
|
35
|
+
export interface UiContext extends UiActions {
|
|
36
|
+
readonly controller: ViewerController;
|
|
37
|
+
/** Root of the region's shadow tree (for focus management). */
|
|
26
38
|
readonly root: ShadowRoot;
|
|
39
|
+
/** Register `focus` as the root's `name` target; returns the function that unregisters it. */
|
|
40
|
+
target(name: FocusTarget, focus: () => void): () => void;
|
|
41
|
+
/** Focus the root's `name` target registered last; false when there is none. */
|
|
42
|
+
focus(name: FocusTarget): boolean;
|
|
27
43
|
}
|
|
28
44
|
|
|
29
45
|
/** One-character glyph of a hunk (docs/interaction.md §Change list): `+ − ~ ⇄ ⊕ ⑂`. */
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The family filters (RFC 0001 §3, `<kgdiff-filters>` from phase 2): one chip per family the
|
|
3
|
+
* diff has, with its count and the legend's swatch, toggling the session's family filter, and
|
|
4
|
+
* "show all" while a filter is on.
|
|
5
|
+
*/
|
|
6
|
+
import { attr, h, setText } from '../dom.js';
|
|
7
|
+
import { familyCounts } from '../list-model.js';
|
|
8
|
+
import type { Topic } from '../session.js';
|
|
9
|
+
import { FAMILIES } from '../styles.js';
|
|
10
|
+
import type { UiContext } from './context.js';
|
|
11
|
+
import { swatch } from './swatch.js';
|
|
12
|
+
|
|
13
|
+
export class Filters {
|
|
14
|
+
readonly el: HTMLElement;
|
|
15
|
+
readonly #chips = new Map<string, HTMLButtonElement>();
|
|
16
|
+
readonly #showAll: HTMLButtonElement;
|
|
17
|
+
|
|
18
|
+
readonly #ctx: UiContext;
|
|
19
|
+
|
|
20
|
+
constructor(ctx: UiContext) {
|
|
21
|
+
this.#ctx = ctx;
|
|
22
|
+
const c = ctx.controller;
|
|
23
|
+
this.el = h('div', { class: 'filters', role: 'group' });
|
|
24
|
+
for (const f of FAMILIES) {
|
|
25
|
+
const chip = h(
|
|
26
|
+
'button',
|
|
27
|
+
{
|
|
28
|
+
type: 'button',
|
|
29
|
+
class: 'fchip',
|
|
30
|
+
'data-family': f,
|
|
31
|
+
'aria-pressed': 'false',
|
|
32
|
+
onclick: () => c.toggleFamily(f),
|
|
33
|
+
},
|
|
34
|
+
swatch(f),
|
|
35
|
+
h('span', { class: 'lbl' }),
|
|
36
|
+
h('span', { class: 'n' }),
|
|
37
|
+
);
|
|
38
|
+
this.#chips.set(f, chip);
|
|
39
|
+
this.el.appendChild(chip);
|
|
40
|
+
}
|
|
41
|
+
this.#showAll = h('button', {
|
|
42
|
+
type: 'button',
|
|
43
|
+
class: 'link-btn',
|
|
44
|
+
hidden: true,
|
|
45
|
+
onclick: () => c.setFamilies([]),
|
|
46
|
+
});
|
|
47
|
+
this.el.appendChild(this.#showAll);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
update(topics: ReadonlySet<Topic>): void {
|
|
51
|
+
const all = topics.has('status') || topics.has('data') || topics.has('locale');
|
|
52
|
+
if (all) this.#renderChips();
|
|
53
|
+
if (all || topics.has('filters')) this.#renderState();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#renderChips(): void {
|
|
57
|
+
const tr = this.#ctx.tr();
|
|
58
|
+
const scene = this.#ctx.controller.scene;
|
|
59
|
+
const counts = scene ? familyCounts(scene) : null;
|
|
60
|
+
attr(this.el, 'aria-label', tr.t('sidebar.filters'));
|
|
61
|
+
setText(this.#showAll, tr.t('sidebar.clearFilters'));
|
|
62
|
+
this.el.hidden = !scene || scene.hunks.length === 0;
|
|
63
|
+
for (const [f, chip] of this.#chips) {
|
|
64
|
+
const n = counts ? counts[f as keyof typeof counts] : 0;
|
|
65
|
+
setText(chip.querySelector('.lbl') as Element, tr.t(`family.${f as keyof typeof counts}`));
|
|
66
|
+
setText(chip.querySelector('.n') as Element, tr.num(n));
|
|
67
|
+
chip.hidden = n === 0;
|
|
68
|
+
attr(chip, 'title', tr.t(`familyHint.${f as keyof typeof counts}`));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
#renderState(): void {
|
|
73
|
+
const fams = this.#ctx.controller.families;
|
|
74
|
+
for (const [f, chip] of this.#chips) {
|
|
75
|
+
attr(chip, 'aria-pressed', String(fams.has(f as never)));
|
|
76
|
+
}
|
|
77
|
+
this.#showAll.hidden = fams.size === 0;
|
|
78
|
+
}
|
|
79
|
+
}
|