@kgdiff/viewer 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +48 -6
  2. package/dist/controller.d.ts +63 -14
  3. package/dist/controller.d.ts.map +1 -1
  4. package/dist/controller.js +308 -54
  5. package/dist/controller.js.map +1 -1
  6. package/dist/element.d.ts +17 -4
  7. package/dist/element.d.ts.map +1 -1
  8. package/dist/element.js +199 -26
  9. package/dist/element.js.map +1 -1
  10. package/dist/framing.d.ts +20 -1
  11. package/dist/framing.d.ts.map +1 -1
  12. package/dist/framing.js +49 -0
  13. package/dist/framing.js.map +1 -1
  14. package/dist/hash-sync.d.ts +4 -2
  15. package/dist/hash-sync.d.ts.map +1 -1
  16. package/dist/hash-sync.js +21 -5
  17. package/dist/hash-sync.js.map +1 -1
  18. package/dist/i18n.d.ts +7 -7
  19. package/dist/i18n.d.ts.map +1 -1
  20. package/dist/i18n.js +14 -14
  21. package/dist/i18n.js.map +1 -1
  22. package/dist/index.d.ts +66 -6
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/keyboard.d.ts +6 -4
  26. package/dist/keyboard.d.ts.map +1 -1
  27. package/dist/keyboard.js +6 -5
  28. package/dist/keyboard.js.map +1 -1
  29. package/dist/prefs.d.ts +13 -1
  30. package/dist/prefs.d.ts.map +1 -1
  31. package/dist/prefs.js +30 -3
  32. package/dist/prefs.js.map +1 -1
  33. package/dist/standalone/kgdiff-viewer.js +4 -4
  34. package/dist/styles.d.ts.map +1 -1
  35. package/dist/styles.js +84 -61
  36. package/dist/styles.js.map +1 -1
  37. package/dist/ui/context.d.ts +2 -0
  38. package/dist/ui/context.d.ts.map +1 -1
  39. package/dist/ui/context.js.map +1 -1
  40. package/dist/ui/dock.d.ts +17 -0
  41. package/dist/ui/dock.d.ts.map +1 -0
  42. package/dist/ui/dock.js +151 -0
  43. package/dist/ui/dock.js.map +1 -0
  44. package/dist/ui/header.d.ts +7 -2
  45. package/dist/ui/header.d.ts.map +1 -1
  46. package/dist/ui/header.js +24 -133
  47. package/dist/ui/header.js.map +1 -1
  48. package/dist/ui/popovers.d.ts.map +1 -1
  49. package/dist/ui/popovers.js +3 -2
  50. package/dist/ui/popovers.js.map +1 -1
  51. package/dist/ui/sidebar.d.ts.map +1 -1
  52. package/dist/ui/sidebar.js +2 -1
  53. package/dist/ui/sidebar.js.map +1 -1
  54. package/dist/ui/stage.d.ts +8 -0
  55. package/dist/ui/stage.d.ts.map +1 -1
  56. package/dist/ui/stage.js +66 -93
  57. package/dist/ui/stage.js.map +1 -1
  58. package/dist/ui/swatch.d.ts +7 -0
  59. package/dist/ui/swatch.d.ts.map +1 -0
  60. package/dist/ui/swatch.js +26 -0
  61. package/dist/ui/swatch.js.map +1 -0
  62. package/package.json +6 -6
  63. package/src/controller.ts +332 -56
  64. package/src/element.ts +220 -29
  65. package/src/framing.ts +65 -1
  66. package/src/hash-sync.ts +20 -6
  67. package/src/i18n.ts +15 -14
  68. package/src/index.ts +65 -7
  69. package/src/keyboard.ts +12 -6
  70. package/src/prefs.ts +45 -5
  71. package/src/styles.ts +84 -61
  72. package/src/ui/context.ts +2 -0
  73. package/src/ui/dock.ts +198 -0
  74. package/src/ui/header.ts +38 -168
  75. package/src/ui/popovers.ts +3 -2
  76. package/src/ui/sidebar.ts +2 -1
  77. package/src/ui/stage.ts +88 -112
  78. package/src/ui/swatch.ts +31 -0
package/src/ui/stage.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
- * The canvas area and its floating chrome: replay caption and transport, swipe divider,
3
- * changes-only context stepper, legend, zoom controls + minimap, hover tooltip and the
4
- * loading / identical / error / empty states. Overlays never cover the current hunk: the
5
- * element reserves their space through the renderer's safe area.
2
+ * The canvas area and its floating chrome: the view dock (with replay's caption and transport
3
+ * stacked above it), swipe divider, legend (a card, or one line of dots for chrome-less embeds),
4
+ * zoom controls + minimap (shown only while part of the content is out of view), hover tooltip
5
+ * and the loading / identical / error / empty states. Overlays never cover the current hunk:
6
+ * fits and flights frame it in the free part of the canvas.
6
7
  */
7
8
  import { KgdiffError, type Rect } from '@kgdiff/core';
8
9
  import type { ChangeFamily, Family } from '@kgdiff/scene';
@@ -21,35 +22,13 @@ import type { ChromeLayout } from '../framing.js';
21
22
  import type { Translator } from '../i18n.js';
22
23
  import { FAMILIES } from '../styles.js';
23
24
  import { sceneHunkGlyph, type UiContext } from './context.js';
25
+ import { Dock } from './dock.js';
26
+ import { swatch } from './swatch.js';
24
27
 
25
28
  const TICK_W = 1000;
26
29
 
27
- /** Legend swatch: the node cue of each family (docs/visual-language.md §Meaning families). */
28
- function swatch(family: Family): SVGSVGElement {
29
- const v = (name: string) => `var(--kg-${name})`;
30
- const c = family === 'neutral' ? v('neutral') : v(family);
31
- let body: string;
32
- switch (family) {
33
- case 'added':
34
- body = `<circle cx="11" cy="11" r="8.5" fill="none" stroke="${c}" stroke-opacity=".35" stroke-width="2"/><circle cx="11" cy="11" r="5" fill="${c}"/>`;
35
- break;
36
- case 'removed':
37
- body = `<circle cx="11" cy="11" r="5.25" fill="none" stroke="${c}" stroke-width="1.5" stroke-dasharray="3 2.5"/>`;
38
- break;
39
- case 'identity':
40
- body = `<circle cx="11" cy="11" r="9.5" fill="none" stroke="${c}" stroke-opacity=".3" stroke-width="1.5"/><circle cx="11" cy="11" r="7.25" fill="none" stroke="${c}" stroke-width="1.5"/><circle cx="11" cy="11" r="4.25" fill="${c}"/>`;
41
- break;
42
- case 'content':
43
- case 'structure':
44
- body = `<circle cx="11" cy="11" r="7.75" fill="none" stroke="${c}" stroke-width="1.5"/><circle cx="11" cy="11" r="4.5" fill="${c}"/>`;
45
- break;
46
- default:
47
- body = `<circle cx="11" cy="11" r="3.5" fill="${c}"/>`;
48
- }
49
- const tpl = document.createElement('template');
50
- tpl.innerHTML = `<svg class="swatch" viewBox="0 0 22 22" aria-hidden="true" focusable="false">${body}</svg>`;
51
- return tpl.content.firstElementChild as SVGSVGElement;
52
- }
30
+ /** How the legend shows: the card, one line of dots, or not at all. */
31
+ export type LegendStyle = 'full' | 'mini' | 'none';
53
32
 
54
33
  export class Stage {
55
34
  readonly el: HTMLElement;
@@ -76,24 +55,25 @@ export class Stage {
76
55
  readonly #divider: HTMLElement;
77
56
  readonly #chipBefore: HTMLElement;
78
57
  readonly #chipAfter: HTMLElement;
79
- // Changes
80
- readonly #stepper: HTMLElement;
81
- readonly #hopButtons: HTMLButtonElement[] = [];
82
- readonly #expandButton: HTMLButtonElement;
58
+ // Dock
59
+ readonly dock: Dock;
60
+ readonly #dockWrap: HTMLElement;
83
61
  // Chrome
84
62
  readonly #legend: HTMLElement;
85
63
  readonly #legendHead: HTMLButtonElement;
86
64
  readonly #legendBody: HTMLElement;
65
+ readonly #miniLegend: HTMLElement;
87
66
  readonly #zoomIn: HTMLButtonElement;
88
67
  readonly #zoomOut: HTMLButtonElement;
89
68
  readonly #fitButton: HTMLButtonElement;
90
69
  readonly #mapCluster: HTMLElement;
70
+ readonly #zoomBar: HTMLElement;
91
71
  readonly tooltip: HTMLElement;
92
72
  readonly #state: HTMLElement;
93
73
  #pointer: { x: number; y: number } | null = null;
94
74
  #scrubbing: number | null = null;
95
75
  #dragging: number | null = null;
96
- #legendOpen: boolean | null = null;
76
+ #legendOpen = false;
97
77
  #stateKey = '';
98
78
 
99
79
  readonly #ctx: UiContext;
@@ -182,39 +162,15 @@ export class Stage {
182
162
  this.#divider,
183
163
  );
184
164
 
185
- // Changes-only context stepper
186
- const opts = h('span', { class: 'opts', role: 'radiogroup' });
187
- for (let n = 0; n <= 2; n++) {
188
- const b = h('button', {
189
- type: 'button',
190
- class: 'opt',
191
- role: 'radio',
192
- text: String(n),
193
- onclick: () => c.setHops(n),
194
- });
195
- this.#hopButtons.push(b);
196
- opts.appendChild(b);
197
- }
198
- opts.addEventListener('keydown', (e) => {
199
- if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
200
- e.preventDefault();
201
- e.stopPropagation();
202
- const next = Math.max(0, Math.min(2, c.hops + (e.key === 'ArrowRight' ? 1 : -1)));
203
- c.setHops(next);
204
- this.#hopButtons[next]?.focus();
205
- });
206
- this.#expandButton = h(
207
- 'button',
208
- { type: 'button', class: 'expand', onclick: () => c.expandNearest() },
209
- icon('plus'),
210
- h('span', { class: 'n' }),
211
- );
212
- this.#stepper = h(
165
+ // View dock, with replay's finished pill, caption and transport stacked above it.
166
+ this.dock = new Dock(ctx);
167
+ this.#dockWrap = h(
213
168
  'div',
214
- { class: 'stepper card float top-left', hidden: true },
215
- h('span', { class: 'label' }),
216
- opts,
217
- this.#expandButton,
169
+ { class: 'dock' },
170
+ this.#finished,
171
+ this.#caption,
172
+ this.#transport,
173
+ this.dock.el,
218
174
  );
219
175
 
220
176
  // Legend
@@ -223,7 +179,8 @@ export class Stage {
223
179
  {
224
180
  type: 'button',
225
181
  class: 'legend-head',
226
- onclick: () => this.#setLegend(!(this.#legendOpen ?? true)),
182
+ 'aria-expanded': 'false',
183
+ onclick: () => this.#setLegend(!this.#legendOpen),
227
184
  },
228
185
  h('span', { class: 'lbl' }),
229
186
  icon('chevron-down'),
@@ -232,26 +189,28 @@ export class Stage {
232
189
  attr(this.#legendHead, 'aria-controls', 'kg-legend');
233
190
  this.#legend = h(
234
191
  'div',
235
- { class: 'legend card float bottom-left', 'data-open': 'true' },
192
+ { class: 'legend card float bottom-left', 'data-open': 'false' },
236
193
  this.#legendHead,
237
194
  this.#legendBody,
238
195
  );
196
+ this.#miniLegend = h('div', { class: 'mini-legend float bottom-left', role: 'list' });
239
197
 
240
198
  // Zoom + minimap
241
199
  this.#zoomIn = iconButton('plus', '', () => c.zoom(1));
242
200
  this.#zoomOut = iconButton('minus', '', () => c.zoom(-1));
243
201
  this.#fitButton = iconButton('fit', '', () => void c.fit());
244
202
  this.minimapHost = h('div', { class: 'minimap-host' });
203
+ this.#zoomBar = h(
204
+ 'div',
205
+ { class: 'zoom-bar card', role: 'toolbar' },
206
+ this.#zoomIn,
207
+ this.#zoomOut,
208
+ this.#fitButton,
209
+ );
245
210
  this.#mapCluster = h(
246
211
  'div',
247
212
  { class: 'map-cluster float bottom-right' },
248
- h(
249
- 'div',
250
- { class: 'zoom-bar card', role: 'toolbar' },
251
- this.#zoomIn,
252
- this.#zoomOut,
253
- this.#fitButton,
254
- ),
213
+ this.#zoomBar,
255
214
  this.minimapHost,
256
215
  );
257
216
 
@@ -261,12 +220,10 @@ export class Stage {
261
220
  'div',
262
221
  { class: 'chrome' },
263
222
  this.#swipe,
264
- this.#caption,
265
- this.#stepper,
266
223
  this.#legend,
224
+ this.#miniLegend,
267
225
  this.#mapCluster,
268
- this.#finished,
269
- this.#transport,
226
+ this.#dockWrap,
270
227
  this.tooltip,
271
228
  );
272
229
  this.el = h(
@@ -297,16 +254,20 @@ export class Stage {
297
254
  const obstacles: Rect[] = [];
298
255
  const parts = [
299
256
  this.#legend,
300
- this.#mapCluster,
257
+ this.#miniLegend,
258
+ this.#zoomBar,
259
+ this.dock.el,
301
260
  this.#transport,
302
261
  this.#finished,
303
- this.#stepper,
304
262
  this.#chipBefore,
305
263
  this.#chipAfter,
306
264
  ];
307
265
  if (this.#caption.dataset.empty !== 'true') parts.push(this.#caption);
266
+ if (this.minimapHost.dataset.hidden !== 'true') parts.push(this.minimapHost);
267
+ // The HUD (title, actions) floats over the canvas too.
268
+ for (const el of this.el.querySelectorAll<HTMLElement>('.hud .card')) parts.push(el);
308
269
  for (const el of parts) {
309
- if (el.hidden) continue;
270
+ if (el.hidden || el.closest('[hidden]')) continue;
310
271
  const b = el.getBoundingClientRect();
311
272
  if (b.width <= 0 || b.height <= 0) continue;
312
273
  obstacles.push({ x: b.left - r.left, y: b.top - r.top, width: b.width, height: b.height });
@@ -314,10 +275,9 @@ export class Stage {
314
275
  return { width: r.width, height: r.height, obstacles };
315
276
  }
316
277
 
317
- /** Whether the stage is tall enough to show the legend expanded by default. */
318
- #defaultLegendOpen(): boolean {
319
- const hgt = this.el.clientHeight;
320
- return hgt === 0 || hgt >= 560;
278
+ /** Re-measure what depends on rendered sizes (fonts, resizes). */
279
+ layout(): void {
280
+ this.dock.layout();
321
281
  }
322
282
 
323
283
  #setLegend(open: boolean): void {
@@ -326,19 +286,27 @@ export class Stage {
326
286
  attr(this.#legendHead, 'aria-expanded', String(open));
327
287
  }
328
288
 
289
+ /** Legend style, and whether clicks select (the tooltip only hints at clicking then). */
290
+ setChrome(legend: LegendStyle, selectable: boolean): void {
291
+ this.el.dataset.legend = legend;
292
+ this.el.dataset.selectable = String(selectable);
293
+ }
294
+
329
295
  update(topics: ReadonlySet<Topic>): void {
330
296
  const c = this.#ctx.controller;
331
297
  const tr = this.#ctx.tr();
332
298
  const all = topics.has('status') || topics.has('data') || topics.has('locale');
333
299
  if (all) {
334
300
  this.#renderLabels(tr);
301
+ this.#renderMiniLegend(tr);
335
302
  this.#renderState(tr);
336
303
  this.#renderTicks();
337
304
  this.#renderCanvasLabel(tr);
338
- if (this.#legendOpen === null) this.#setLegend(this.#defaultLegendOpen());
339
305
  }
340
- if (all || topics.has('mode') || topics.has('focus') || topics.has('selection')) {
341
- this.#renderMode(tr);
306
+ this.dock.update(topics);
307
+ if (all || topics.has('mode')) this.#renderMode();
308
+ if (all || topics.has('overview')) {
309
+ attr(this.minimapHost, 'data-hidden', String(c.overview));
342
310
  }
343
311
  if (all || topics.has('mode') || topics.has('replay')) this.#renderReplay(tr);
344
312
  if (all || topics.has('mode') || topics.has('swipe')) this.#renderSwipe();
@@ -358,8 +326,6 @@ export class Stage {
358
326
  setText(this.#chipBefore, tr.t('detail.before'));
359
327
  setText(this.#chipAfter, tr.t('detail.after'));
360
328
  attr(this.#divider, 'aria-label', tr.t('swipe.divider'));
361
- setText(this.#stepper.querySelector('.label') as Element, tr.t('focus.context'));
362
- attr(this.#stepper.querySelector('.opts') as Element, 'aria-label', tr.t('focus.hops'));
363
329
  setText(this.#legendHead.querySelector('.lbl') as Element, tr.t('legend.title'));
364
330
  attr(this.#legendHead, 'aria-label', tr.t('legend.toggle'));
365
331
  relabel(this.#zoomIn, `${tr.t('action.zoomIn')} (+)`);
@@ -381,6 +347,32 @@ export class Stage {
381
347
  );
382
348
  }
383
349
 
350
+ /** One line: the families this diff has, then the unchanged context. */
351
+ #renderMiniLegend(tr: Translator): void {
352
+ const scene = this.#ctx.controller.scene;
353
+ const present = new Set<Family>(scene?.hunks.map((x) => x.family));
354
+ if (scene?.nodes.some((n) => n.status === 'unchanged')) present.add('neutral');
355
+ attr(this.#miniLegend, 'aria-label', tr.t('legend.title'));
356
+ replace(
357
+ this.#miniLegend,
358
+ [...FAMILIES, 'neutral' as const]
359
+ .filter((f) => present.has(f))
360
+ .map((f) =>
361
+ h(
362
+ 'span',
363
+ {
364
+ class: 'mini-item',
365
+ role: 'listitem',
366
+ 'data-family': f,
367
+ title: tr.t(`familyHint.${f}`),
368
+ },
369
+ h('span', { class: 'dot', 'aria-hidden': 'true' }),
370
+ tr.t(`family.${f}`),
371
+ ),
372
+ ),
373
+ );
374
+ }
375
+
384
376
  #renderCanvasLabel(tr: Translator): void {
385
377
  const stats = this.#ctx.controller.diff?.stats;
386
378
  const canvas = this.canvasHost.querySelector('canvas');
@@ -396,26 +388,8 @@ export class Stage {
396
388
  setText(this.#liveCanvasLabel, '');
397
389
  }
398
390
 
399
- #renderMode(tr: Translator): void {
400
- const c = this.#ctx.controller;
401
- const mode = c.mode;
402
- this.#stepper.hidden = mode !== 'changes';
403
- this.#hopButtons.forEach((b, n) => {
404
- attr(b, 'aria-checked', String(n === c.hops));
405
- b.tabIndex = n === c.hops ? 0 : -1;
406
- attr(b, 'aria-label', `${tr.t('focus.hops')}: ${n}`);
407
- });
408
- const group = c.focus?.collapsed[c.nearestGroup()];
409
- this.#expandButton.hidden = !group;
410
- if (group) {
411
- const usable = c.canExpand(c.nearestGroup()) || group.nodes.length > 0;
412
- this.#expandButton.disabled = !usable;
413
- setText(this.#expandButton.querySelector('.n') as Element, tr.num(group.count));
414
- relabel(
415
- this.#expandButton,
416
- `${usable ? tr.t('focus.expandNearest') : tr.t('focus.expandUnavailable')}: ${group.label} (E)`,
417
- );
418
- }
391
+ #renderMode(): void {
392
+ const mode = this.#ctx.controller.mode;
419
393
  this.#transport.hidden = mode !== 'replay';
420
394
  this.#caption.hidden = mode !== 'replay';
421
395
  this.#swipe.hidden = mode !== 'swipe';
@@ -491,6 +465,8 @@ export class Stage {
491
465
  }
492
466
  const finished = clock.finished;
493
467
  this.#finished.hidden = !finished;
468
+ // The finished pill takes the caption's place above the transport.
469
+ this.#caption.hidden = finished;
494
470
  if (finished && this.#finished.childElementCount === 0) {
495
471
  replace(
496
472
  this.#finished,
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Family swatches: the node cue of each family (docs/visual-language.md §Meaning families), drawn
3
+ * the same in the legend and on the sidebar's filter chips so the chips double as a legend.
4
+ */
5
+ import type { Family } from '@kgdiff/scene';
6
+
7
+ export function swatch(family: Family): SVGSVGElement {
8
+ const v = (name: string) => `var(--kg-${name})`;
9
+ const c = family === 'neutral' ? v('neutral') : v(family);
10
+ let body: string;
11
+ switch (family) {
12
+ case 'added':
13
+ body = `<circle cx="11" cy="11" r="8.5" fill="none" stroke="${c}" stroke-opacity=".35" stroke-width="2"/><circle cx="11" cy="11" r="5" fill="${c}"/>`;
14
+ break;
15
+ case 'removed':
16
+ body = `<circle cx="11" cy="11" r="5.25" fill="none" stroke="${c}" stroke-width="1.5" stroke-dasharray="3 2.5"/>`;
17
+ break;
18
+ case 'identity':
19
+ body = `<circle cx="11" cy="11" r="9.5" fill="none" stroke="${c}" stroke-opacity=".3" stroke-width="1.5"/><circle cx="11" cy="11" r="7.25" fill="none" stroke="${c}" stroke-width="1.5"/><circle cx="11" cy="11" r="4.25" fill="${c}"/>`;
20
+ break;
21
+ case 'content':
22
+ case 'structure':
23
+ body = `<circle cx="11" cy="11" r="7.75" fill="none" stroke="${c}" stroke-width="1.5"/><circle cx="11" cy="11" r="4.5" fill="${c}"/>`;
24
+ break;
25
+ default:
26
+ body = `<circle cx="11" cy="11" r="3.5" fill="${c}"/>`;
27
+ }
28
+ const tpl = document.createElement('template');
29
+ tpl.innerHTML = `<svg class="swatch" viewBox="0 0 22 22" aria-hidden="true" focusable="false">${body}</svg>`;
30
+ return tpl.content.firstElementChild as SVGSVGElement;
31
+ }