@kgdiff/viewer 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +9 -8
  2. package/dist/controller.d.ts +23 -7
  3. package/dist/controller.d.ts.map +1 -1
  4. package/dist/controller.js +119 -14
  5. package/dist/controller.js.map +1 -1
  6. package/dist/element.d.ts +6 -3
  7. package/dist/element.d.ts.map +1 -1
  8. package/dist/element.js +68 -19
  9. package/dist/element.js.map +1 -1
  10. package/dist/hash-sync.d.ts +4 -2
  11. package/dist/hash-sync.d.ts.map +1 -1
  12. package/dist/hash-sync.js +21 -5
  13. package/dist/hash-sync.js.map +1 -1
  14. package/dist/i18n.d.ts +7 -7
  15. package/dist/i18n.d.ts.map +1 -1
  16. package/dist/i18n.js +14 -14
  17. package/dist/i18n.js.map +1 -1
  18. package/dist/index.d.ts +21 -8
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/keyboard.d.ts +6 -4
  22. package/dist/keyboard.d.ts.map +1 -1
  23. package/dist/keyboard.js +6 -5
  24. package/dist/keyboard.js.map +1 -1
  25. package/dist/prefs.d.ts +7 -1
  26. package/dist/prefs.d.ts.map +1 -1
  27. package/dist/prefs.js +15 -3
  28. package/dist/prefs.js.map +1 -1
  29. package/dist/standalone/kgdiff-viewer.js +4 -4
  30. package/dist/styles.d.ts.map +1 -1
  31. package/dist/styles.js +69 -63
  32. package/dist/styles.js.map +1 -1
  33. package/dist/ui/context.d.ts +2 -0
  34. package/dist/ui/context.d.ts.map +1 -1
  35. package/dist/ui/context.js.map +1 -1
  36. package/dist/ui/dock.d.ts +17 -0
  37. package/dist/ui/dock.d.ts.map +1 -0
  38. package/dist/ui/dock.js +151 -0
  39. package/dist/ui/dock.js.map +1 -0
  40. package/dist/ui/header.d.ts +7 -2
  41. package/dist/ui/header.d.ts.map +1 -1
  42. package/dist/ui/header.js +24 -133
  43. package/dist/ui/header.js.map +1 -1
  44. package/dist/ui/popovers.d.ts.map +1 -1
  45. package/dist/ui/popovers.js +3 -2
  46. package/dist/ui/popovers.js.map +1 -1
  47. package/dist/ui/sidebar.d.ts.map +1 -1
  48. package/dist/ui/sidebar.js +2 -1
  49. package/dist/ui/sidebar.js.map +1 -1
  50. package/dist/ui/stage.d.ts +4 -0
  51. package/dist/ui/stage.d.ts.map +1 -1
  52. package/dist/ui/stage.js +41 -94
  53. package/dist/ui/stage.js.map +1 -1
  54. package/dist/ui/swatch.d.ts +7 -0
  55. package/dist/ui/swatch.d.ts.map +1 -0
  56. package/dist/ui/swatch.js +26 -0
  57. package/dist/ui/swatch.js.map +1 -0
  58. package/package.json +6 -6
  59. package/src/controller.ts +131 -20
  60. package/src/element.ts +72 -21
  61. package/src/hash-sync.ts +20 -6
  62. package/src/i18n.ts +15 -14
  63. package/src/index.ts +21 -9
  64. package/src/keyboard.ts +12 -6
  65. package/src/prefs.ts +19 -4
  66. package/src/styles.ts +69 -63
  67. package/src/ui/context.ts +2 -0
  68. package/src/ui/dock.ts +198 -0
  69. package/src/ui/header.ts +38 -168
  70. package/src/ui/popovers.ts +3 -2
  71. package/src/ui/sidebar.ts +2 -1
  72. package/src/ui/stage.ts +49 -114
  73. package/src/ui/swatch.ts +31 -0
package/src/ui/stage.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
- * The canvas area and its floating chrome: replay caption and transport, swipe divider,
3
- * changes-only context stepper, legend (a card, or one line of dots for chrome-less embeds),
4
- * zoom controls + minimap, hover tooltip and the loading / identical / error / empty states.
5
- * Overlays never cover the current hunk: the element reserves their space through the
6
- * 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.
7
7
  */
8
8
  import { KgdiffError, type Rect } from '@kgdiff/core';
9
9
  import type { ChangeFamily, Family } from '@kgdiff/scene';
@@ -22,39 +22,14 @@ import type { ChromeLayout } from '../framing.js';
22
22
  import type { Translator } from '../i18n.js';
23
23
  import { FAMILIES } from '../styles.js';
24
24
  import { sceneHunkGlyph, type UiContext } from './context.js';
25
+ import { Dock } from './dock.js';
26
+ import { swatch } from './swatch.js';
25
27
 
26
28
  const TICK_W = 1000;
27
29
 
28
30
  /** How the legend shows: the card, one line of dots, or not at all. */
29
31
  export type LegendStyle = 'full' | 'mini' | 'none';
30
32
 
31
- /** Legend swatch: the node cue of each family (docs/visual-language.md §Meaning families). */
32
- function swatch(family: Family): SVGSVGElement {
33
- const v = (name: string) => `var(--kg-${name})`;
34
- const c = family === 'neutral' ? v('neutral') : v(family);
35
- let body: string;
36
- switch (family) {
37
- case 'added':
38
- 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}"/>`;
39
- break;
40
- case 'removed':
41
- body = `<circle cx="11" cy="11" r="5.25" fill="none" stroke="${c}" stroke-width="1.5" stroke-dasharray="3 2.5"/>`;
42
- break;
43
- case 'identity':
44
- 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}"/>`;
45
- break;
46
- case 'content':
47
- case 'structure':
48
- 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}"/>`;
49
- break;
50
- default:
51
- body = `<circle cx="11" cy="11" r="3.5" fill="${c}"/>`;
52
- }
53
- const tpl = document.createElement('template');
54
- tpl.innerHTML = `<svg class="swatch" viewBox="0 0 22 22" aria-hidden="true" focusable="false">${body}</svg>`;
55
- return tpl.content.firstElementChild as SVGSVGElement;
56
- }
57
-
58
33
  export class Stage {
59
34
  readonly el: HTMLElement;
60
35
  readonly canvasHost: HTMLElement;
@@ -80,10 +55,9 @@ export class Stage {
80
55
  readonly #divider: HTMLElement;
81
56
  readonly #chipBefore: HTMLElement;
82
57
  readonly #chipAfter: HTMLElement;
83
- // Changes
84
- readonly #stepper: HTMLElement;
85
- readonly #hopButtons: HTMLButtonElement[] = [];
86
- readonly #expandButton: HTMLButtonElement;
58
+ // Dock
59
+ readonly dock: Dock;
60
+ readonly #dockWrap: HTMLElement;
87
61
  // Chrome
88
62
  readonly #legend: HTMLElement;
89
63
  readonly #legendHead: HTMLButtonElement;
@@ -93,12 +67,13 @@ export class Stage {
93
67
  readonly #zoomOut: HTMLButtonElement;
94
68
  readonly #fitButton: HTMLButtonElement;
95
69
  readonly #mapCluster: HTMLElement;
70
+ readonly #zoomBar: HTMLElement;
96
71
  readonly tooltip: HTMLElement;
97
72
  readonly #state: HTMLElement;
98
73
  #pointer: { x: number; y: number } | null = null;
99
74
  #scrubbing: number | null = null;
100
75
  #dragging: number | null = null;
101
- #legendOpen: boolean | null = null;
76
+ #legendOpen = false;
102
77
  #stateKey = '';
103
78
 
104
79
  readonly #ctx: UiContext;
@@ -187,39 +162,15 @@ export class Stage {
187
162
  this.#divider,
188
163
  );
189
164
 
190
- // Changes-only context stepper
191
- const opts = h('span', { class: 'opts', role: 'radiogroup' });
192
- for (let n = 0; n <= 2; n++) {
193
- const b = h('button', {
194
- type: 'button',
195
- class: 'opt',
196
- role: 'radio',
197
- text: String(n),
198
- onclick: () => c.setHops(n),
199
- });
200
- this.#hopButtons.push(b);
201
- opts.appendChild(b);
202
- }
203
- opts.addEventListener('keydown', (e) => {
204
- if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
205
- e.preventDefault();
206
- e.stopPropagation();
207
- const next = Math.max(0, Math.min(2, c.hops + (e.key === 'ArrowRight' ? 1 : -1)));
208
- c.setHops(next);
209
- this.#hopButtons[next]?.focus();
210
- });
211
- this.#expandButton = h(
212
- 'button',
213
- { type: 'button', class: 'expand', onclick: () => c.expandNearest() },
214
- icon('plus'),
215
- h('span', { class: 'n' }),
216
- );
217
- 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(
218
168
  'div',
219
- { class: 'stepper card float top-left', hidden: true },
220
- h('span', { class: 'label' }),
221
- opts,
222
- this.#expandButton,
169
+ { class: 'dock' },
170
+ this.#finished,
171
+ this.#caption,
172
+ this.#transport,
173
+ this.dock.el,
223
174
  );
224
175
 
225
176
  // Legend
@@ -228,7 +179,8 @@ export class Stage {
228
179
  {
229
180
  type: 'button',
230
181
  class: 'legend-head',
231
- onclick: () => this.#setLegend(!(this.#legendOpen ?? true)),
182
+ 'aria-expanded': 'false',
183
+ onclick: () => this.#setLegend(!this.#legendOpen),
232
184
  },
233
185
  h('span', { class: 'lbl' }),
234
186
  icon('chevron-down'),
@@ -237,7 +189,7 @@ export class Stage {
237
189
  attr(this.#legendHead, 'aria-controls', 'kg-legend');
238
190
  this.#legend = h(
239
191
  'div',
240
- { class: 'legend card float bottom-left', 'data-open': 'true' },
192
+ { class: 'legend card float bottom-left', 'data-open': 'false' },
241
193
  this.#legendHead,
242
194
  this.#legendBody,
243
195
  );
@@ -248,16 +200,17 @@ export class Stage {
248
200
  this.#zoomOut = iconButton('minus', '', () => c.zoom(-1));
249
201
  this.#fitButton = iconButton('fit', '', () => void c.fit());
250
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
+ );
251
210
  this.#mapCluster = h(
252
211
  'div',
253
212
  { class: 'map-cluster float bottom-right' },
254
- h(
255
- 'div',
256
- { class: 'zoom-bar card', role: 'toolbar' },
257
- this.#zoomIn,
258
- this.#zoomOut,
259
- this.#fitButton,
260
- ),
213
+ this.#zoomBar,
261
214
  this.minimapHost,
262
215
  );
263
216
 
@@ -267,13 +220,10 @@ export class Stage {
267
220
  'div',
268
221
  { class: 'chrome' },
269
222
  this.#swipe,
270
- this.#caption,
271
- this.#stepper,
272
223
  this.#legend,
273
224
  this.#miniLegend,
274
225
  this.#mapCluster,
275
- this.#finished,
276
- this.#transport,
226
+ this.#dockWrap,
277
227
  this.tooltip,
278
228
  );
279
229
  this.el = h(
@@ -305,16 +255,19 @@ export class Stage {
305
255
  const parts = [
306
256
  this.#legend,
307
257
  this.#miniLegend,
308
- this.#mapCluster,
258
+ this.#zoomBar,
259
+ this.dock.el,
309
260
  this.#transport,
310
261
  this.#finished,
311
- this.#stepper,
312
262
  this.#chipBefore,
313
263
  this.#chipAfter,
314
264
  ];
315
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);
316
269
  for (const el of parts) {
317
- if (el.hidden) continue;
270
+ if (el.hidden || el.closest('[hidden]')) continue;
318
271
  const b = el.getBoundingClientRect();
319
272
  if (b.width <= 0 || b.height <= 0) continue;
320
273
  obstacles.push({ x: b.left - r.left, y: b.top - r.top, width: b.width, height: b.height });
@@ -322,10 +275,9 @@ export class Stage {
322
275
  return { width: r.width, height: r.height, obstacles };
323
276
  }
324
277
 
325
- /** Whether the stage is tall enough to show the legend expanded by default. */
326
- #defaultLegendOpen(): boolean {
327
- const hgt = this.el.clientHeight;
328
- return hgt === 0 || hgt >= 560;
278
+ /** Re-measure what depends on rendered sizes (fonts, resizes). */
279
+ layout(): void {
280
+ this.dock.layout();
329
281
  }
330
282
 
331
283
  #setLegend(open: boolean): void {
@@ -350,10 +302,11 @@ export class Stage {
350
302
  this.#renderState(tr);
351
303
  this.#renderTicks();
352
304
  this.#renderCanvasLabel(tr);
353
- if (this.#legendOpen === null) this.#setLegend(this.#defaultLegendOpen());
354
305
  }
355
- if (all || topics.has('mode') || topics.has('focus') || topics.has('selection')) {
356
- 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));
357
310
  }
358
311
  if (all || topics.has('mode') || topics.has('replay')) this.#renderReplay(tr);
359
312
  if (all || topics.has('mode') || topics.has('swipe')) this.#renderSwipe();
@@ -373,8 +326,6 @@ export class Stage {
373
326
  setText(this.#chipBefore, tr.t('detail.before'));
374
327
  setText(this.#chipAfter, tr.t('detail.after'));
375
328
  attr(this.#divider, 'aria-label', tr.t('swipe.divider'));
376
- setText(this.#stepper.querySelector('.label') as Element, tr.t('focus.context'));
377
- attr(this.#stepper.querySelector('.opts') as Element, 'aria-label', tr.t('focus.hops'));
378
329
  setText(this.#legendHead.querySelector('.lbl') as Element, tr.t('legend.title'));
379
330
  attr(this.#legendHead, 'aria-label', tr.t('legend.toggle'));
380
331
  relabel(this.#zoomIn, `${tr.t('action.zoomIn')} (+)`);
@@ -437,26 +388,8 @@ export class Stage {
437
388
  setText(this.#liveCanvasLabel, '');
438
389
  }
439
390
 
440
- #renderMode(tr: Translator): void {
441
- const c = this.#ctx.controller;
442
- const mode = c.mode;
443
- this.#stepper.hidden = mode !== 'changes';
444
- this.#hopButtons.forEach((b, n) => {
445
- attr(b, 'aria-checked', String(n === c.hops));
446
- b.tabIndex = n === c.hops ? 0 : -1;
447
- attr(b, 'aria-label', `${tr.t('focus.hops')}: ${n}`);
448
- });
449
- const group = c.focus?.collapsed[c.nearestGroup()];
450
- this.#expandButton.hidden = !group;
451
- if (group) {
452
- const usable = c.canExpand(c.nearestGroup()) || group.nodes.length > 0;
453
- this.#expandButton.disabled = !usable;
454
- setText(this.#expandButton.querySelector('.n') as Element, tr.num(group.count));
455
- relabel(
456
- this.#expandButton,
457
- `${usable ? tr.t('focus.expandNearest') : tr.t('focus.expandUnavailable')}: ${group.label} (E)`,
458
- );
459
- }
391
+ #renderMode(): void {
392
+ const mode = this.#ctx.controller.mode;
460
393
  this.#transport.hidden = mode !== 'replay';
461
394
  this.#caption.hidden = mode !== 'replay';
462
395
  this.#swipe.hidden = mode !== 'swipe';
@@ -532,6 +465,8 @@ export class Stage {
532
465
  }
533
466
  const finished = clock.finished;
534
467
  this.#finished.hidden = !finished;
468
+ // The finished pill takes the caption's place above the transport.
469
+ this.#caption.hidden = finished;
535
470
  if (finished && this.#finished.childElementCount === 0) {
536
471
  replace(
537
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
+ }