@kgdiff/viewer 0.5.0 → 0.6.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/README.md +43 -16
- package/custom-elements.json +2347 -0
- package/dist/bind.d.ts +41 -0
- package/dist/bind.d.ts.map +1 -0
- package/dist/bind.js +47 -0
- package/dist/bind.js.map +1 -0
- package/dist/controller.d.ts +22 -74
- package/dist/controller.d.ts.map +1 -1
- package/dist/controller.js +35 -376
- package/dist/controller.js.map +1 -1
- package/dist/dom.d.ts +10 -0
- package/dist/dom.d.ts.map +1 -1
- package/dist/dom.js +29 -0
- package/dist/dom.js.map +1 -1
- package/dist/element.d.ts +52 -46
- package/dist/element.d.ts.map +1 -1
- package/dist/element.js +362 -403
- package/dist/element.js.map +1 -1
- package/dist/elements/actions.d.ts +27 -0
- package/dist/elements/actions.d.ts.map +1 -0
- package/dist/elements/actions.js +81 -0
- package/dist/elements/actions.js.map +1 -0
- package/dist/elements/bound.d.ts +63 -0
- package/dist/elements/bound.d.ts.map +1 -0
- package/dist/elements/bound.js +213 -0
- package/dist/elements/bound.js.map +1 -0
- package/dist/elements/change-detail.d.ts +25 -0
- package/dist/elements/change-detail.d.ts.map +1 -0
- package/dist/elements/change-detail.js +19 -0
- package/dist/elements/change-detail.js.map +1 -0
- package/dist/elements/change-list.d.ts +27 -0
- package/dist/elements/change-list.d.ts.map +1 -0
- package/dist/elements/change-list.js +29 -0
- package/dist/elements/change-list.js.map +1 -0
- package/dist/elements/filters.d.ts +25 -0
- package/dist/elements/filters.d.ts.map +1 -0
- package/dist/elements/filters.js +19 -0
- package/dist/elements/filters.js.map +1 -0
- package/dist/elements/legend.d.ts +40 -0
- package/dist/elements/legend.d.ts.map +1 -0
- package/dist/elements/legend.js +87 -0
- package/dist/elements/legend.js.map +1 -0
- package/dist/elements/minimap.d.ts +38 -0
- package/dist/elements/minimap.d.ts.map +1 -0
- package/dist/elements/minimap.js +59 -0
- package/dist/elements/minimap.js.map +1 -0
- package/dist/elements/root.d.ts +40 -0
- package/dist/elements/root.d.ts.map +1 -0
- package/dist/elements/root.js +67 -0
- package/dist/elements/root.js.map +1 -0
- package/dist/elements/search.d.ts +29 -0
- package/dist/elements/search.d.ts.map +1 -0
- package/dist/elements/search.js +23 -0
- package/dist/elements/search.js.map +1 -0
- package/dist/elements/stage-context.d.ts +31 -0
- package/dist/elements/stage-context.d.ts.map +1 -0
- package/dist/elements/stage-context.js +4 -0
- package/dist/elements/stage-context.js.map +1 -0
- package/dist/elements/stage.d.ts +65 -0
- package/dist/elements/stage.d.ts.map +1 -0
- package/dist/elements/stage.js +259 -0
- package/dist/elements/stage.js.map +1 -0
- package/dist/elements/title.d.ts +25 -0
- package/dist/elements/title.d.ts.map +1 -0
- package/dist/elements/title.js +19 -0
- package/dist/elements/title.js.map +1 -0
- package/dist/elements/transport.d.ts +29 -0
- package/dist/elements/transport.d.ts.map +1 -0
- package/dist/elements/transport.js +24 -0
- package/dist/elements/transport.js.map +1 -0
- package/dist/elements/view-bound.d.ts +39 -0
- package/dist/elements/view-bound.d.ts.map +1 -0
- package/dist/elements/view-bound.js +88 -0
- package/dist/elements/view-bound.js.map +1 -0
- package/dist/elements/view-dock.d.ts +28 -0
- package/dist/elements/view-dock.d.ts.map +1 -0
- package/dist/elements/view-dock.js +28 -0
- package/dist/elements/view-dock.js.map +1 -0
- package/dist/elements/zoom.d.ts +28 -0
- package/dist/elements/zoom.d.ts.map +1 -0
- package/dist/elements/zoom.js +22 -0
- package/dist/elements/zoom.js.map +1 -0
- package/dist/i18n.d.ts +10 -0
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +20 -0
- package/dist/i18n.js.map +1 -1
- package/dist/icons.d.ts +1 -0
- package/dist/icons.d.ts.map +1 -1
- package/dist/icons.js +1 -0
- package/dist/icons.js.map +1 -1
- package/dist/index.d.ts +328 -34
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -1
- package/dist/keyboard.d.ts +39 -2
- package/dist/keyboard.d.ts.map +1 -1
- package/dist/keyboard.js +145 -62
- package/dist/keyboard.js.map +1 -1
- package/dist/layout-store.d.ts +26 -0
- package/dist/layout-store.d.ts.map +1 -0
- package/dist/layout-store.js +46 -0
- package/dist/layout-store.js.map +1 -0
- package/dist/panel-levels.d.ts +18 -0
- package/dist/panel-levels.d.ts.map +1 -0
- package/dist/panel-levels.js +41 -0
- package/dist/panel-levels.js.map +1 -0
- package/dist/panels.d.ts +28 -0
- package/dist/panels.d.ts.map +1 -0
- package/dist/panels.js +73 -0
- package/dist/panels.js.map +1 -0
- package/dist/prefs.d.ts +5 -2
- package/dist/prefs.d.ts.map +1 -1
- package/dist/prefs.js +12 -3
- package/dist/prefs.js.map +1 -1
- package/dist/react.d.ts +460 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +2 -0
- package/dist/react.js.map +1 -0
- package/dist/root-element.d.ts +122 -0
- package/dist/root-element.d.ts.map +1 -0
- package/dist/root-element.js +541 -0
- package/dist/root-element.js.map +1 -0
- package/dist/root.d.ts +30 -30
- package/dist/root.d.ts.map +1 -1
- package/dist/root.js +65 -42
- package/dist/root.js.map +1 -1
- package/dist/session.d.ts +19 -3
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +15 -17
- package/dist/session.js.map +1 -1
- package/dist/standalone/kgdiff-viewer.js +4 -4
- package/dist/styles.d.ts +4 -0
- package/dist/styles.d.ts.map +1 -1
- package/dist/styles.js +224 -84
- package/dist/styles.js.map +1 -1
- package/dist/theme.d.ts +7 -0
- package/dist/theme.d.ts.map +1 -1
- package/dist/theme.js +10 -7
- package/dist/theme.js.map +1 -1
- package/dist/topics.d.ts +8 -0
- package/dist/topics.d.ts.map +1 -0
- package/dist/topics.js +18 -0
- package/dist/topics.js.map +1 -0
- package/dist/ui/change-list.d.ts.map +1 -1
- package/dist/ui/change-list.js +2 -2
- package/dist/ui/change-list.js.map +1 -1
- package/dist/ui/context.d.ts +20 -7
- package/dist/ui/context.d.ts.map +1 -1
- package/dist/ui/context.js.map +1 -1
- package/dist/ui/header.d.ts +41 -3
- package/dist/ui/header.d.ts.map +1 -1
- package/dist/ui/header.js +99 -34
- package/dist/ui/header.js.map +1 -1
- package/dist/ui/legend.d.ts +15 -0
- package/dist/ui/legend.d.ts.map +1 -0
- package/dist/ui/legend.js +68 -0
- package/dist/ui/legend.js.map +1 -0
- package/dist/ui/popovers.d.ts +3 -1
- package/dist/ui/popovers.d.ts.map +1 -1
- package/dist/ui/popovers.js +62 -23
- package/dist/ui/popovers.js.map +1 -1
- package/dist/ui/search.d.ts.map +1 -1
- package/dist/ui/search.js +3 -2
- package/dist/ui/search.js.map +1 -1
- package/dist/ui/sidebar.d.ts +26 -3
- package/dist/ui/sidebar.d.ts.map +1 -1
- package/dist/ui/sidebar.js +344 -12
- package/dist/ui/sidebar.js.map +1 -1
- package/dist/ui/stage.d.ts +56 -1
- package/dist/ui/stage.d.ts.map +1 -1
- package/dist/ui/stage.js +244 -256
- package/dist/ui/stage.js.map +1 -1
- package/dist/ui/transport.d.ts +12 -0
- package/dist/ui/transport.d.ts.map +1 -0
- package/dist/ui/transport.js +198 -0
- package/dist/ui/transport.js.map +1 -0
- package/dist/ui/zoom.d.ts +15 -0
- package/dist/ui/zoom.d.ts.map +1 -0
- package/dist/ui/zoom.js +30 -0
- package/dist/ui/zoom.js.map +1 -0
- package/dist/view.d.ts +94 -0
- package/dist/view.d.ts.map +1 -0
- package/dist/view.js +441 -0
- package/dist/view.js.map +1 -0
- package/package.json +95 -12
- package/src/bind.ts +80 -0
- package/src/controller.ts +51 -442
- package/src/dom.ts +30 -0
- package/src/element.ts +364 -406
- package/src/elements/actions.ts +94 -0
- package/src/elements/bound.ts +247 -0
- package/src/elements/change-detail.ts +34 -0
- package/src/elements/change-list.ts +51 -0
- package/src/elements/filters.ts +34 -0
- package/src/elements/legend.ts +103 -0
- package/src/elements/minimap.ts +75 -0
- package/src/elements/root.ts +86 -0
- package/src/elements/search.ts +38 -0
- package/src/elements/stage-context.ts +34 -0
- package/src/elements/stage.ts +285 -0
- package/src/elements/title.ts +34 -0
- package/src/elements/transport.ts +39 -0
- package/src/elements/view-bound.ts +118 -0
- package/src/elements/view-dock.ts +43 -0
- package/src/elements/zoom.ts +38 -0
- package/src/i18n.ts +20 -0
- package/src/icons.ts +1 -0
- package/src/index.ts +347 -34
- package/src/keyboard.ts +188 -61
- package/src/layout-store.ts +71 -0
- package/src/panel-levels.ts +53 -0
- package/src/panels.ts +81 -0
- package/src/prefs.ts +12 -4
- package/src/react.ts +459 -0
- package/src/root-element.ts +624 -0
- package/src/root.ts +92 -65
- package/src/session.ts +22 -33
- package/src/styles.ts +245 -84
- package/src/theme.ts +10 -7
- package/src/topics.ts +37 -0
- package/src/ui/change-list.ts +6 -2
- package/src/ui/context.ts +20 -7
- package/src/ui/header.ts +127 -56
- package/src/ui/legend.ts +112 -0
- package/src/ui/popovers.ts +68 -25
- package/src/ui/search.ts +10 -5
- package/src/ui/sidebar.ts +386 -23
- package/src/ui/stage.ts +267 -331
- package/src/ui/transport.ts +240 -0
- package/src/ui/zoom.ts +45 -0
- package/src/view.ts +516 -0
- package/vscode.html-custom-data.json +726 -0
package/src/ui/stage.ts
CHANGED
|
@@ -1,55 +1,95 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The canvas area and its floating chrome: the view dock (with replay's caption and
|
|
3
|
-
* stacked above it), swipe divider, legend (a card, or one line of dots
|
|
4
|
-
* zoom controls + minimap (shown only while part of the
|
|
5
|
-
* and the loading / identical / error / empty states.
|
|
6
|
-
* fits and flights frame it in the free part of the
|
|
2
|
+
* The canvas area and its floating chrome: the view dock (`dock.ts`, with replay's caption and
|
|
3
|
+
* transport stacked above it), swipe divider, legend (`legend.ts`: a card, or one line of dots
|
|
4
|
+
* for chrome-less embeds), zoom controls (`zoom.ts`) + minimap (shown only while part of the
|
|
5
|
+
* content is out of view), hover tooltip and the loading / identical / error / empty states.
|
|
6
|
+
* Overlays never cover the current hunk: fits and flights frame it in the free part of the
|
|
7
|
+
* canvas. The view (`view` once attached) draws the canvas; the chrome reads the session.
|
|
8
|
+
*
|
|
9
|
+
* With slots (`<kgdiff-stage>`, and `<kgdiff-viewer>` for the page's content), a chrome element
|
|
10
|
+
* slotted in (`<kgdiff-transport slot="bottom-center">`) replaces the built-in one.
|
|
7
11
|
*/
|
|
8
12
|
import { KgdiffError, type Rect } from '@kgdiff/core';
|
|
9
|
-
import type {
|
|
13
|
+
import type { Family } from '@kgdiff/scene';
|
|
10
14
|
import type { Topic } from '../controller.js';
|
|
11
|
-
import {
|
|
12
|
-
attr,
|
|
13
|
-
h,
|
|
14
|
-
icon,
|
|
15
|
-
iconButton,
|
|
16
|
-
relabel,
|
|
17
|
-
replace,
|
|
18
|
-
replaceKeepingFocus,
|
|
19
|
-
setText,
|
|
20
|
-
} from '../dom.js';
|
|
15
|
+
import { attr, h, icon, moveFocusOut, replace, setText } from '../dom.js';
|
|
21
16
|
import type { ChromeLayout } from '../framing.js';
|
|
22
17
|
import type { Translator } from '../i18n.js';
|
|
23
|
-
import {
|
|
18
|
+
import type { PanelState, PanelStates, StageRegion, StageVersion } from '../index.js';
|
|
19
|
+
import type { StageView } from '../view.js';
|
|
24
20
|
import { sceneHunkGlyph, type UiContext } from './context.js';
|
|
25
21
|
import { Dock } from './dock.js';
|
|
26
|
-
import {
|
|
27
|
-
|
|
28
|
-
|
|
22
|
+
import { Legend } from './legend.js';
|
|
23
|
+
import { Transport } from './transport.js';
|
|
24
|
+
import { ZoomBar } from './zoom.js';
|
|
29
25
|
|
|
30
26
|
/** How the legend shows: the card, one line of dots, or not at all. */
|
|
31
27
|
export type LegendStyle = 'full' | 'mini' | 'none';
|
|
32
28
|
|
|
29
|
+
/** The named slots of `<kgdiff-stage>`, where the page puts its own content over the canvas. */
|
|
30
|
+
export const STAGE_SLOTS = [
|
|
31
|
+
'top-start',
|
|
32
|
+
'top-end',
|
|
33
|
+
'bottom-start',
|
|
34
|
+
'bottom-center',
|
|
35
|
+
'bottom-end',
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
/** The chrome over a canvas: the stage's regions, and the viewer's title and actions. */
|
|
39
|
+
export type ChromeRegion = StageRegion | 'title' | 'actions';
|
|
40
|
+
|
|
41
|
+
/** The chrome elements, by the region a slotted one replaces. */
|
|
42
|
+
const CHROME_TAGS: Readonly<Record<string, ChromeRegion>> = {
|
|
43
|
+
'kgdiff-view-dock': 'dock',
|
|
44
|
+
'kgdiff-transport': 'transport',
|
|
45
|
+
'kgdiff-legend': 'legend',
|
|
46
|
+
'kgdiff-zoom': 'zoom',
|
|
47
|
+
'kgdiff-minimap': 'minimap',
|
|
48
|
+
'kgdiff-title': 'title',
|
|
49
|
+
'kgdiff-actions': 'actions',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type ChromeStates = Pick<PanelStates, StageRegion>;
|
|
53
|
+
|
|
33
54
|
export class Stage {
|
|
34
55
|
readonly el: HTMLElement;
|
|
35
56
|
readonly canvasHost: HTMLElement;
|
|
36
57
|
readonly minimapHost: HTMLElement;
|
|
58
|
+
#view: StageView | null = null;
|
|
59
|
+
/** Removes the stage's own minimap from the view; null while it has none. */
|
|
60
|
+
#ownMinimap: (() => void) | null = null;
|
|
61
|
+
|
|
62
|
+
/** The view drawing this stage's canvas (its camera, minimap, framing), once attached. */
|
|
63
|
+
get view(): StageView | null {
|
|
64
|
+
return this.#view;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
set view(view: StageView | null) {
|
|
68
|
+
if (view === this.#view) return;
|
|
69
|
+
this.#ownMinimap?.();
|
|
70
|
+
this.#ownMinimap = null;
|
|
71
|
+
this.#view = view;
|
|
72
|
+
this.#syncMinimap();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The view draws the stage's own minimap unless a slotted `<kgdiff-minimap>` replaces it:
|
|
77
|
+
* none is created, or drawn, while one is, and it comes back when that one leaves.
|
|
78
|
+
*/
|
|
79
|
+
#syncMinimap(): void {
|
|
80
|
+
const view = this.#view;
|
|
81
|
+
const own = view !== null && !this.#replaced.has('minimap');
|
|
82
|
+
if (own && !this.#ownMinimap) this.#ownMinimap = view.addMinimap(this.minimapHost);
|
|
83
|
+
else if (!own && this.#ownMinimap) {
|
|
84
|
+
this.#ownMinimap();
|
|
85
|
+
this.#ownMinimap = null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
37
88
|
readonly #chrome: HTMLElement;
|
|
38
89
|
readonly #liveCanvasLabel: HTMLElement;
|
|
39
90
|
// Replay
|
|
40
91
|
readonly #caption: HTMLElement;
|
|
41
|
-
readonly
|
|
42
|
-
readonly #playButton: HTMLButtonElement;
|
|
43
|
-
readonly #prevButton: HTMLButtonElement;
|
|
44
|
-
readonly #nextButton: HTMLButtonElement;
|
|
45
|
-
readonly #scrubber: HTMLElement;
|
|
46
|
-
readonly #fill: HTMLElement;
|
|
47
|
-
readonly #knob: HTMLElement;
|
|
48
|
-
readonly #ticks: SVGSVGElement;
|
|
49
|
-
readonly #scrubTip: HTMLElement;
|
|
50
|
-
readonly #pos: HTMLElement;
|
|
51
|
-
readonly #speed: HTMLButtonElement;
|
|
52
|
-
readonly #finished: HTMLElement;
|
|
92
|
+
readonly transport: Transport;
|
|
53
93
|
// Swipe
|
|
54
94
|
readonly #swipe: HTMLElement;
|
|
55
95
|
readonly #divider: HTMLElement;
|
|
@@ -59,26 +99,39 @@ export class Stage {
|
|
|
59
99
|
readonly dock: Dock;
|
|
60
100
|
readonly #dockWrap: HTMLElement;
|
|
61
101
|
// Chrome
|
|
62
|
-
readonly
|
|
63
|
-
readonly
|
|
64
|
-
readonly #legendBody: HTMLElement;
|
|
65
|
-
readonly #miniLegend: HTMLElement;
|
|
66
|
-
readonly #zoomIn: HTMLButtonElement;
|
|
67
|
-
readonly #zoomOut: HTMLButtonElement;
|
|
68
|
-
readonly #fitButton: HTMLButtonElement;
|
|
102
|
+
readonly legend: Legend;
|
|
103
|
+
readonly zoom: ZoomBar;
|
|
69
104
|
readonly #mapCluster: HTMLElement;
|
|
70
|
-
readonly #zoomBar: HTMLElement;
|
|
71
105
|
readonly tooltip: HTMLElement;
|
|
72
106
|
readonly #state: HTMLElement;
|
|
73
107
|
#pointer: { x: number; y: number } | null = null;
|
|
74
|
-
#scrubbing: number | null = null;
|
|
75
108
|
#dragging: number | null = null;
|
|
76
|
-
#legendOpen = false;
|
|
77
109
|
#stateKey = '';
|
|
110
|
+
readonly #slots: HTMLSlotElement[] = [];
|
|
111
|
+
/** Elements over the canvas that registered themselves (`registerObstacle`). */
|
|
112
|
+
readonly #obstacles = new Set<Element>();
|
|
113
|
+
#version: StageVersion | null = null;
|
|
114
|
+
readonly #versionChip = h('div', { class: 'version-chip chip card', hidden: true });
|
|
115
|
+
/** The chrome's panel states, and the regions a slotted chrome element replaces. */
|
|
116
|
+
#states: ChromeStates | null = null;
|
|
117
|
+
readonly #replaced = new Set<ChromeRegion>();
|
|
118
|
+
/**
|
|
119
|
+
* Called when the regions slotted elements replace change: the layout that draws the title
|
|
120
|
+
* and the actions (`<kgdiff-viewer>`) applies its panels again.
|
|
121
|
+
*/
|
|
122
|
+
onReplaced: (() => void) | null = null;
|
|
123
|
+
/** Whether the stage is a named region (`asRegion`) rather than the main landmark. */
|
|
124
|
+
#region = false;
|
|
125
|
+
/** The legend's state applied last: the user's toggle stands until it changes. */
|
|
126
|
+
#legendState: PanelState | null = null;
|
|
78
127
|
|
|
79
128
|
readonly #ctx: UiContext;
|
|
80
129
|
|
|
81
|
-
|
|
130
|
+
/**
|
|
131
|
+
* `slots`: the stage has named slots for the page's content (stacked with the built-in chrome
|
|
132
|
+
* in its corner).
|
|
133
|
+
*/
|
|
134
|
+
constructor(ctx: UiContext, slots = false) {
|
|
82
135
|
this.#ctx = ctx;
|
|
83
136
|
const c = ctx.controller;
|
|
84
137
|
this.canvasHost = h('div', { class: 'stage' });
|
|
@@ -90,48 +143,7 @@ export class Stage {
|
|
|
90
143
|
'aria-hidden': 'true',
|
|
91
144
|
'data-empty': 'true',
|
|
92
145
|
});
|
|
93
|
-
this
|
|
94
|
-
this.#playButton = iconButton('play', '', () => c.togglePlay(), { class: 'icon-btn play' });
|
|
95
|
-
this.#nextButton = iconButton('skip-forward', '', () => c.stepReplay(1), { class: 'icon-btn' });
|
|
96
|
-
this.#fill = h('div', { class: 'fill' });
|
|
97
|
-
this.#knob = h('div', { class: 'knob' });
|
|
98
|
-
this.#ticks = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
99
|
-
this.#ticks.setAttribute('class', 'ticks');
|
|
100
|
-
this.#ticks.setAttribute('viewBox', `0 0 ${TICK_W} 14`);
|
|
101
|
-
this.#ticks.setAttribute('preserveAspectRatio', 'none');
|
|
102
|
-
this.#ticks.setAttribute('aria-hidden', 'true');
|
|
103
|
-
this.#scrubTip = h('div', { class: 'scrub-tip card', hidden: true });
|
|
104
|
-
this.#scrubber = h(
|
|
105
|
-
'div',
|
|
106
|
-
{
|
|
107
|
-
class: 'scrubber',
|
|
108
|
-
role: 'slider',
|
|
109
|
-
tabindex: '0',
|
|
110
|
-
'aria-valuemin': '0',
|
|
111
|
-
},
|
|
112
|
-
h('div', { class: 'track' }, this.#fill),
|
|
113
|
-
this.#ticks,
|
|
114
|
-
this.#knob,
|
|
115
|
-
this.#scrubTip,
|
|
116
|
-
);
|
|
117
|
-
this.#bindScrubber();
|
|
118
|
-
this.#pos = h('span', { class: 'pos', 'aria-hidden': 'true' });
|
|
119
|
-
this.#speed = h('button', {
|
|
120
|
-
type: 'button',
|
|
121
|
-
class: 'speed',
|
|
122
|
-
onclick: () => c.cycleSpeed(),
|
|
123
|
-
});
|
|
124
|
-
this.#transport = h(
|
|
125
|
-
'div',
|
|
126
|
-
{ class: 'transport card', role: 'toolbar' },
|
|
127
|
-
this.#prevButton,
|
|
128
|
-
this.#playButton,
|
|
129
|
-
this.#nextButton,
|
|
130
|
-
this.#scrubber,
|
|
131
|
-
this.#pos,
|
|
132
|
-
this.#speed,
|
|
133
|
-
);
|
|
134
|
-
this.#finished = h('div', { class: 'finished card', role: 'status', hidden: true });
|
|
146
|
+
this.transport = new Transport(ctx);
|
|
135
147
|
|
|
136
148
|
// Swipe
|
|
137
149
|
this.#chipBefore = h('div', { class: 'side-chip before chip card' });
|
|
@@ -167,62 +179,61 @@ export class Stage {
|
|
|
167
179
|
this.#dockWrap = h(
|
|
168
180
|
'div',
|
|
169
181
|
{ class: 'dock' },
|
|
170
|
-
this
|
|
182
|
+
this.transport.finished,
|
|
171
183
|
this.#caption,
|
|
172
|
-
this
|
|
184
|
+
this.transport.el,
|
|
173
185
|
this.dock.el,
|
|
174
186
|
);
|
|
175
187
|
|
|
176
188
|
// Legend
|
|
177
|
-
this
|
|
178
|
-
'button',
|
|
179
|
-
{
|
|
180
|
-
type: 'button',
|
|
181
|
-
class: 'legend-head',
|
|
182
|
-
'aria-expanded': 'false',
|
|
183
|
-
onclick: () => this.#setLegend(!this.#legendOpen),
|
|
184
|
-
},
|
|
185
|
-
h('span', { class: 'lbl' }),
|
|
186
|
-
icon('chevron-down'),
|
|
187
|
-
);
|
|
188
|
-
this.#legendBody = h('div', { class: 'legend-body', id: 'kg-legend' });
|
|
189
|
-
attr(this.#legendHead, 'aria-controls', 'kg-legend');
|
|
190
|
-
this.#legend = h(
|
|
191
|
-
'div',
|
|
192
|
-
{ class: 'legend card float bottom-left', 'data-open': 'false' },
|
|
193
|
-
this.#legendHead,
|
|
194
|
-
this.#legendBody,
|
|
195
|
-
);
|
|
196
|
-
this.#miniLegend = h('div', { class: 'mini-legend float bottom-left', role: 'list' });
|
|
189
|
+
this.legend = new Legend(ctx, (open) => ctx.setPanel?.('legend', open ? 'open' : 'collapsed'));
|
|
197
190
|
|
|
198
|
-
// Zoom + minimap
|
|
199
|
-
this
|
|
200
|
-
this.#zoomOut = iconButton('minus', '', () => c.zoom(-1));
|
|
201
|
-
this.#fitButton = iconButton('fit', '', () => void c.fit());
|
|
191
|
+
// Zoom + minimap: the buttons act on this stage's view.
|
|
192
|
+
this.zoom = new ZoomBar(ctx, () => this.view);
|
|
202
193
|
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
|
-
);
|
|
210
194
|
this.#mapCluster = h(
|
|
211
195
|
'div',
|
|
212
196
|
{ class: 'map-cluster float bottom-right' },
|
|
213
|
-
this
|
|
197
|
+
this.zoom.el,
|
|
214
198
|
this.minimapHost,
|
|
215
199
|
);
|
|
216
200
|
|
|
217
201
|
this.tooltip = h('div', { class: 'tooltip card', role: 'tooltip', 'data-open': 'false' });
|
|
218
202
|
this.#state = h('div', { class: 'state', hidden: true });
|
|
203
|
+
let bottomStart: HTMLElement = this.legend.card;
|
|
204
|
+
let bottomEnd: HTMLElement = this.#mapCluster;
|
|
205
|
+
const corners: HTMLElement[] = [];
|
|
206
|
+
if (slots) {
|
|
207
|
+
const slot = (name: string): HTMLSlotElement => {
|
|
208
|
+
const el = document.createElement('slot');
|
|
209
|
+
el.name = name;
|
|
210
|
+
el.addEventListener('slotchange', () => this.slotted());
|
|
211
|
+
this.#slots.push(el);
|
|
212
|
+
return el;
|
|
213
|
+
};
|
|
214
|
+
// Slotted content sits further from the corner than the built-in chrome.
|
|
215
|
+
corners.push(
|
|
216
|
+
h('div', { class: 'corner top-start' }, this.#versionChip, slot('top-start')),
|
|
217
|
+
h('div', { class: 'corner top-end' }, slot('top-end')),
|
|
218
|
+
);
|
|
219
|
+
bottomStart = h(
|
|
220
|
+
'div',
|
|
221
|
+
{ class: 'corner bottom-start' },
|
|
222
|
+
slot('bottom-start'),
|
|
223
|
+
this.legend.card,
|
|
224
|
+
);
|
|
225
|
+
bottomEnd = h('div', { class: 'corner bottom-end' }, slot('bottom-end'), this.#mapCluster);
|
|
226
|
+
this.#dockWrap.prepend(slot('bottom-center'));
|
|
227
|
+
}
|
|
228
|
+
// The top corners first: the chrome reads and tabs from the top down.
|
|
219
229
|
this.#chrome = h(
|
|
220
230
|
'div',
|
|
221
231
|
{ class: 'chrome' },
|
|
232
|
+
...corners,
|
|
222
233
|
this.#swipe,
|
|
223
|
-
|
|
224
|
-
this
|
|
225
|
-
|
|
234
|
+
bottomStart,
|
|
235
|
+
this.legend.mini,
|
|
236
|
+
bottomEnd,
|
|
226
237
|
this.#dockWrap,
|
|
227
238
|
this.tooltip,
|
|
228
239
|
);
|
|
@@ -252,22 +263,26 @@ export class Stage {
|
|
|
252
263
|
chromeLayout(): ChromeLayout {
|
|
253
264
|
const r = this.el.getBoundingClientRect();
|
|
254
265
|
const obstacles: Rect[] = [];
|
|
255
|
-
const parts = [
|
|
256
|
-
this
|
|
257
|
-
this
|
|
258
|
-
this
|
|
266
|
+
const parts: Element[] = [
|
|
267
|
+
this.legend.card,
|
|
268
|
+
this.legend.mini,
|
|
269
|
+
this.zoom.el,
|
|
259
270
|
this.dock.el,
|
|
260
|
-
this
|
|
261
|
-
this
|
|
271
|
+
this.transport.el,
|
|
272
|
+
this.transport.finished,
|
|
262
273
|
this.#chipBefore,
|
|
263
274
|
this.#chipAfter,
|
|
264
275
|
];
|
|
265
276
|
if (this.#caption.dataset.empty !== 'true') parts.push(this.#caption);
|
|
277
|
+
parts.push(this.#versionChip);
|
|
266
278
|
if (this.minimapHost.dataset.hidden !== 'true') parts.push(this.minimapHost);
|
|
267
279
|
// The HUD (title, actions) floats over the canvas too.
|
|
268
280
|
for (const el of this.el.querySelectorAll<HTMLElement>('.hud .card')) parts.push(el);
|
|
281
|
+
// The page's content in the slots, and what registered itself: one rule for all.
|
|
282
|
+
for (const slot of this.#slots) parts.push(...slot.assignedElements({ flatten: true }));
|
|
283
|
+
parts.push(...this.#obstacles);
|
|
269
284
|
for (const el of parts) {
|
|
270
|
-
if (el.hidden || el.closest('[hidden]')) continue;
|
|
285
|
+
if ((el as HTMLElement).hidden || el.closest('[hidden]')) continue;
|
|
271
286
|
const b = el.getBoundingClientRect();
|
|
272
287
|
if (b.width <= 0 || b.height <= 0) continue;
|
|
273
288
|
obstacles.push({ x: b.left - r.left, y: b.top - r.top, width: b.width, height: b.height });
|
|
@@ -275,15 +290,119 @@ export class Stage {
|
|
|
275
290
|
return { width: r.width, height: r.height, obstacles };
|
|
276
291
|
}
|
|
277
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Keep `el` clear of fits and flights, as the chrome is (content over the canvas the stage
|
|
295
|
+
* does not place itself); returns the function that stops it.
|
|
296
|
+
*/
|
|
297
|
+
registerObstacle(el: Element): () => void {
|
|
298
|
+
this.#obstacles.add(el);
|
|
299
|
+
return () => {
|
|
300
|
+
this.#obstacles.delete(el);
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
278
304
|
/** Re-measure what depends on rendered sizes (fonts, resizes). */
|
|
279
305
|
layout(): void {
|
|
280
306
|
this.dock.layout();
|
|
281
307
|
}
|
|
282
308
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
309
|
+
/**
|
|
310
|
+
* The page's slotted content changed (and once the stage is in its shadow root): a chrome
|
|
311
|
+
* element replaces the built-in one.
|
|
312
|
+
*/
|
|
313
|
+
slotted(): void {
|
|
314
|
+
const replaced = new Set<ChromeRegion>();
|
|
315
|
+
for (const slot of this.#slots) {
|
|
316
|
+
for (const el of slot.assignedElements({ flatten: true })) {
|
|
317
|
+
const region = CHROME_TAGS[el.localName];
|
|
318
|
+
if (region) replaced.add(region);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
const same =
|
|
322
|
+
replaced.size === this.#replaced.size && [...replaced].every((r) => this.#replaced.has(r));
|
|
323
|
+
if (!same) {
|
|
324
|
+
this.#replaced.clear();
|
|
325
|
+
for (const r of replaced) this.#replaced.add(r);
|
|
326
|
+
this.#applyPanels();
|
|
327
|
+
this.#syncMinimap();
|
|
328
|
+
this.onReplaced?.();
|
|
329
|
+
}
|
|
330
|
+
this.view?.layoutChanged();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Whether a chrome element slotted into the stage replaces `region`. */
|
|
334
|
+
replaces(region: ChromeRegion): boolean {
|
|
335
|
+
return this.#replaced.has(region);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Follow the panel states of the chrome over the canvas: the dock, the replay transport, the
|
|
340
|
+
* legend (collapsed: folded to its head), the zoom buttons and the minimap. A region that is
|
|
341
|
+
* not open is not shown (and inert); focus inside it moves to the canvas first. A region a
|
|
342
|
+
* slotted chrome element replaces is not shown either, whatever its state.
|
|
343
|
+
*/
|
|
344
|
+
setPanels(states: ChromeStates): void {
|
|
345
|
+
this.#states = states;
|
|
346
|
+
this.#applyPanels();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
#applyPanels(): void {
|
|
350
|
+
const given = this.#states;
|
|
351
|
+
const state = (r: StageRegion): PanelState =>
|
|
352
|
+
this.#replaced.has(r) ? 'hidden' : (given?.[r] ?? (r === 'legend' ? 'collapsed' : 'open'));
|
|
353
|
+
// Nothing declared or replaced, ever: the chrome stays as built.
|
|
354
|
+
if (!given && this.#replaced.size === 0 && this.#legendState === null) return;
|
|
355
|
+
const canvas = (): HTMLElement | null => this.canvasHost.querySelector('canvas');
|
|
356
|
+
const set = (s: PanelState, ...els: HTMLElement[]): void => {
|
|
357
|
+
for (const el of els) {
|
|
358
|
+
if (s !== 'open') moveFocusOut(this.#ctx.root, el, canvas);
|
|
359
|
+
attr(el, 'data-panel', s);
|
|
360
|
+
el.inert = s !== 'open';
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
set(state('dock'), this.dock.el);
|
|
364
|
+
set(state('transport'), this.transport.el, this.transport.finished);
|
|
365
|
+
set(state('zoom'), this.zoom.el);
|
|
366
|
+
set(state('minimap'), this.minimapHost);
|
|
367
|
+
const legend = state('legend');
|
|
368
|
+
if (legend === 'hidden') moveFocusOut(this.#ctx.root, this.legend.card, canvas);
|
|
369
|
+
attr(this.legend.card, 'data-panel', legend);
|
|
370
|
+
attr(this.legend.mini, 'data-panel', legend);
|
|
371
|
+
this.legend.card.inert = legend === 'hidden';
|
|
372
|
+
if (legend !== 'hidden' && legend !== this.#legendState) this.legend.setOpen(legend === 'open');
|
|
373
|
+
this.#legendState = legend;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* The version a stage pinned to it draws (`<kgdiff-stage version>`): no swipe divider or
|
|
378
|
+
* replay caption then, and a chip naming the version; null follows the mode.
|
|
379
|
+
*/
|
|
380
|
+
setVersion(version: StageVersion | null): void {
|
|
381
|
+
this.#version = version;
|
|
382
|
+
if (version) this.el.dataset.version = version;
|
|
383
|
+
else delete this.el.dataset.version;
|
|
384
|
+
this.#renderMode();
|
|
385
|
+
this.#renderVersion(this.#ctx.tr());
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
#renderVersion(tr: Translator): void {
|
|
389
|
+
const v = this.#version;
|
|
390
|
+
const version = v ? tr.t(v === 'before' ? 'detail.before' : 'detail.after') : '';
|
|
391
|
+
this.#versionChip.hidden = v === null;
|
|
392
|
+
if (v) setText(this.#versionChip, version);
|
|
393
|
+
if (this.#region) {
|
|
394
|
+
attr(this.el, 'aria-label', v ? `${tr.t('stage.label')} · ${version}` : tr.t('stage.label'));
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* A region named after the graph (and its version), not the page's main landmark: for a
|
|
400
|
+
* `<kgdiff-stage>`, one of several on a page that has its own.
|
|
401
|
+
*/
|
|
402
|
+
asRegion(): void {
|
|
403
|
+
this.#region = true;
|
|
404
|
+
this.el.setAttribute('role', 'region');
|
|
405
|
+
this.#renderVersion(this.#ctx.tr());
|
|
287
406
|
}
|
|
288
407
|
|
|
289
408
|
/** Legend style, and whether clicks select (the tooltip only hints at clicking then). */
|
|
@@ -297,80 +416,34 @@ export class Stage {
|
|
|
297
416
|
const tr = this.#ctx.tr();
|
|
298
417
|
const all = topics.has('status') || topics.has('data') || topics.has('locale');
|
|
299
418
|
if (all) {
|
|
419
|
+
this.#renderVersion(tr);
|
|
300
420
|
this.#renderLabels(tr);
|
|
301
|
-
this.#renderMiniLegend(tr);
|
|
302
421
|
this.#renderState(tr);
|
|
303
|
-
this.#renderTicks();
|
|
304
422
|
this.#renderCanvasLabel(tr);
|
|
305
423
|
}
|
|
424
|
+
this.legend.update(topics);
|
|
425
|
+
this.zoom.update(topics);
|
|
426
|
+
this.transport.update(topics);
|
|
306
427
|
this.dock.update(topics);
|
|
307
428
|
if (all || topics.has('mode')) this.#renderMode();
|
|
308
429
|
if (all || topics.has('overview')) {
|
|
309
|
-
attr(this.minimapHost, 'data-hidden', String(
|
|
430
|
+
attr(this.minimapHost, 'data-hidden', String(this.view?.overview ?? true));
|
|
310
431
|
}
|
|
311
|
-
if (all || topics.has('mode') || topics.has('replay')) this.#
|
|
432
|
+
if (all || topics.has('mode') || topics.has('replay')) this.#renderCaption(tr);
|
|
312
433
|
if (all || topics.has('mode') || topics.has('swipe')) this.#renderSwipe();
|
|
313
434
|
if (all || topics.has('hover') || topics.has('selection') || topics.has('focus')) {
|
|
314
435
|
this.#renderTooltip(tr);
|
|
315
436
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
this.#chrome.hidden = !ready;
|
|
437
|
+
// Until a diff is ready the built-in chrome waits; the page's slotted content stays.
|
|
438
|
+
attr(this.#chrome, 'data-ready', String(c.status === 'ready'));
|
|
319
439
|
}
|
|
320
440
|
|
|
321
441
|
#renderLabels(tr: Translator): void {
|
|
322
|
-
relabel(this.#prevButton, `${tr.t('detail.prev')} (←)`);
|
|
323
|
-
relabel(this.#nextButton, `${tr.t('detail.next')} (→)`);
|
|
324
|
-
attr(this.#transport, 'aria-label', tr.t('replay.label'));
|
|
325
|
-
attr(this.#scrubber, 'aria-label', tr.t('replay.scrubber'));
|
|
326
442
|
setText(this.#chipBefore, tr.t('detail.before'));
|
|
327
443
|
setText(this.#chipAfter, tr.t('detail.after'));
|
|
328
444
|
attr(this.#divider, 'aria-label', tr.t('swipe.divider'));
|
|
329
|
-
setText(this.#legendHead.querySelector('.lbl') as Element, tr.t('legend.title'));
|
|
330
|
-
attr(this.#legendHead, 'aria-label', tr.t('legend.toggle'));
|
|
331
|
-
relabel(this.#zoomIn, `${tr.t('action.zoomIn')} (+)`);
|
|
332
|
-
relabel(this.#zoomOut, `${tr.t('action.zoomOut')} (−)`);
|
|
333
|
-
relabel(this.#fitButton, `${tr.t('action.fit')} (F)`);
|
|
334
445
|
attr(this.minimapHost, 'aria-label', tr.t('minimap.label'));
|
|
335
446
|
attr(this.minimapHost, 'title', tr.t('minimap.label'));
|
|
336
|
-
const fams: Family[] = [...FAMILIES, 'neutral'];
|
|
337
|
-
replace(
|
|
338
|
-
this.#legendBody,
|
|
339
|
-
fams.map((f) =>
|
|
340
|
-
h(
|
|
341
|
-
'div',
|
|
342
|
-
{ class: 'legend-row', 'data-family': f, title: tr.t(`familyHint.${f}`) },
|
|
343
|
-
swatch(f),
|
|
344
|
-
h('span', { class: 'name', text: tr.t(`family.${f}`) }),
|
|
345
|
-
),
|
|
346
|
-
),
|
|
347
|
-
);
|
|
348
|
-
}
|
|
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
447
|
}
|
|
375
448
|
|
|
376
449
|
#renderCanvasLabel(tr: Translator): void {
|
|
@@ -390,41 +463,19 @@ export class Stage {
|
|
|
390
463
|
|
|
391
464
|
#renderMode(): void {
|
|
392
465
|
const mode = this.#ctx.controller.mode;
|
|
393
|
-
|
|
394
|
-
this.#caption.hidden = mode !== 'replay';
|
|
395
|
-
this.#swipe.hidden = mode !== 'swipe';
|
|
466
|
+
const pinned = this.#version !== null;
|
|
467
|
+
this.#caption.hidden = mode !== 'replay' || pinned;
|
|
468
|
+
this.#swipe.hidden = mode !== 'swipe' || pinned;
|
|
396
469
|
}
|
|
397
470
|
|
|
398
|
-
|
|
471
|
+
/** The replay caption: the playing hunk (or chapter), else the last one played. */
|
|
472
|
+
#renderCaption(tr: Translator): void {
|
|
399
473
|
const c = this.#ctx.controller;
|
|
400
474
|
const clock = c.clock;
|
|
401
475
|
const scene = c.scene;
|
|
402
|
-
if (c.mode !== 'replay' || !clock || !scene)
|
|
403
|
-
this.#finished.hidden = true;
|
|
404
|
-
return;
|
|
405
|
-
}
|
|
406
|
-
const p = clock.progress;
|
|
407
|
-
this.#fill.style.transform = `scaleX(${p})`;
|
|
408
|
-
this.#knob.style.left = `${p * 100}%`;
|
|
476
|
+
if (c.mode !== 'replay' || !clock || !scene) return;
|
|
409
477
|
const position = clock.position();
|
|
410
478
|
const total = clock.total;
|
|
411
|
-
setText(this.#pos, tr.t('replay.position', { i: position, n: total }));
|
|
412
|
-
attr(this.#scrubber, 'aria-valuemax', String(Math.round(clock.duration)));
|
|
413
|
-
attr(this.#scrubber, 'aria-valuenow', String(Math.round(clock.t)));
|
|
414
|
-
attr(this.#scrubber, 'aria-valuetext', tr.t('replay.position', { i: position, n: total }));
|
|
415
|
-
const playing = clock.playing;
|
|
416
|
-
const playLabel = `${tr.t(playing ? 'replay.pause' : 'replay.play')} (Space)`;
|
|
417
|
-
if (this.#playButton.dataset.state !== String(playing)) {
|
|
418
|
-
this.#playButton.dataset.state = String(playing);
|
|
419
|
-
replace(this.#playButton, icon(playing ? 'pause' : 'play'));
|
|
420
|
-
}
|
|
421
|
-
relabel(this.#playButton, playLabel);
|
|
422
|
-
setText(this.#speed, `${clock.speed}×`);
|
|
423
|
-
relabel(this.#speed, tr.t('replay.speed', { s: `${clock.speed}×` }));
|
|
424
|
-
this.#prevButton.disabled = clock.t <= 0;
|
|
425
|
-
this.#nextButton.disabled = clock.t >= clock.duration;
|
|
426
|
-
|
|
427
|
-
// Caption: the playing hunk (or chapter), else the last one played.
|
|
428
479
|
let key = '';
|
|
429
480
|
let content: (Node | string)[] = [];
|
|
430
481
|
if (clock.t <= 0) {
|
|
@@ -463,123 +514,8 @@ export class Stage {
|
|
|
463
514
|
attr(this.#caption, 'data-empty', String(key === ''));
|
|
464
515
|
replace(this.#caption, ...content);
|
|
465
516
|
}
|
|
466
|
-
const finished = clock.finished;
|
|
467
|
-
this.#finished.hidden = !finished;
|
|
468
517
|
// The finished pill takes the caption's place above the transport.
|
|
469
|
-
this.#caption.hidden = finished;
|
|
470
|
-
if (finished && this.#finished.childElementCount === 0) {
|
|
471
|
-
replace(
|
|
472
|
-
this.#finished,
|
|
473
|
-
icon('check-circle'),
|
|
474
|
-
h('span', { text: tr.t('replay.finished') }),
|
|
475
|
-
h(
|
|
476
|
-
'button',
|
|
477
|
-
{ type: 'button', class: 'btn', onclick: () => c.restart() },
|
|
478
|
-
icon('restart'),
|
|
479
|
-
tr.t('replay.restart'),
|
|
480
|
-
),
|
|
481
|
-
);
|
|
482
|
-
} else if (!finished && this.#finished.childElementCount > 0) {
|
|
483
|
-
// Restart was pressed from the pill: keep focus in the transport.
|
|
484
|
-
replaceKeepingFocus(this.#ctx.root, this.#finished, this.#playButton);
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
#renderTicks(): void {
|
|
489
|
-
const c = this.#ctx.controller;
|
|
490
|
-
const clock = c.clock;
|
|
491
|
-
const scene = c.scene;
|
|
492
|
-
while (this.#ticks.firstChild) this.#ticks.removeChild(this.#ticks.firstChild);
|
|
493
|
-
if (!clock || !scene || clock.duration <= 0) return;
|
|
494
|
-
const paths = new Map<ChangeFamily, string[]>();
|
|
495
|
-
for (const w of clock.timeline.windows) {
|
|
496
|
-
const fam = scene.hunks[w.hunk]?.family;
|
|
497
|
-
if (!fam) continue;
|
|
498
|
-
const x = ((w.start / clock.duration) * TICK_W).toFixed(1);
|
|
499
|
-
let list = paths.get(fam);
|
|
500
|
-
if (!list) {
|
|
501
|
-
list = [];
|
|
502
|
-
paths.set(fam, list);
|
|
503
|
-
}
|
|
504
|
-
list.push(`M${x} 2V12`);
|
|
505
|
-
}
|
|
506
|
-
for (const [fam, d] of paths) {
|
|
507
|
-
const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
508
|
-
p.setAttribute('d', d.join(''));
|
|
509
|
-
p.setAttribute('stroke', `var(--kgdiff-${fam})`);
|
|
510
|
-
p.setAttribute('stroke-width', '2');
|
|
511
|
-
p.setAttribute('stroke-linecap', 'round');
|
|
512
|
-
p.setAttribute('vector-effect', 'non-scaling-stroke');
|
|
513
|
-
this.#ticks.appendChild(p);
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
#bindScrubber(): void {
|
|
518
|
-
const timeAt = (clientX: number): number => {
|
|
519
|
-
const clock = this.#ctx.controller.clock;
|
|
520
|
-
const r = this.#scrubber.getBoundingClientRect();
|
|
521
|
-
if (!clock || r.width <= 0) return 0;
|
|
522
|
-
return Math.max(0, Math.min(1, (clientX - r.left) / r.width)) * clock.duration;
|
|
523
|
-
};
|
|
524
|
-
this.#scrubber.addEventListener('pointerdown', (e) => {
|
|
525
|
-
this.#scrubbing = e.pointerId;
|
|
526
|
-
this.#scrubber.setPointerCapture?.(e.pointerId);
|
|
527
|
-
this.#ctx.controller.pause();
|
|
528
|
-
this.#ctx.controller.seek(timeAt(e.clientX));
|
|
529
|
-
});
|
|
530
|
-
this.#scrubber.addEventListener('pointermove', (e) => {
|
|
531
|
-
if (this.#scrubbing === e.pointerId) this.#ctx.controller.seek(timeAt(e.clientX));
|
|
532
|
-
this.#showScrubTip(e.clientX, timeAt(e.clientX));
|
|
533
|
-
});
|
|
534
|
-
const end = (e: PointerEvent): void => {
|
|
535
|
-
if (this.#scrubbing === e.pointerId) this.#scrubbing = null;
|
|
536
|
-
};
|
|
537
|
-
this.#scrubber.addEventListener('pointerup', end);
|
|
538
|
-
this.#scrubber.addEventListener('pointercancel', end);
|
|
539
|
-
this.#scrubber.addEventListener('pointerleave', () => {
|
|
540
|
-
this.#scrubTip.hidden = true;
|
|
541
|
-
});
|
|
542
|
-
this.#scrubber.addEventListener('keydown', (e) => {
|
|
543
|
-
const c = this.#ctx.controller;
|
|
544
|
-
if (e.key === 'Home') c.seek(0);
|
|
545
|
-
else if (e.key === 'End') c.seek(c.clock?.duration ?? 0);
|
|
546
|
-
else return;
|
|
547
|
-
e.preventDefault();
|
|
548
|
-
e.stopPropagation();
|
|
549
|
-
});
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
#showScrubTip(clientX: number, t: number): void {
|
|
553
|
-
const c = this.#ctx.controller;
|
|
554
|
-
const clock = c.clock;
|
|
555
|
-
const scene = c.scene;
|
|
556
|
-
if (!clock || !scene) return;
|
|
557
|
-
// Nearest window start: the tick under the pointer.
|
|
558
|
-
let best = -1;
|
|
559
|
-
let bestD = Number.POSITIVE_INFINITY;
|
|
560
|
-
for (const w of clock.timeline.windows) {
|
|
561
|
-
const d = Math.abs(w.start - t);
|
|
562
|
-
if (d < bestD) {
|
|
563
|
-
bestD = d;
|
|
564
|
-
best = w.hunk;
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
const hunk = scene.hunks[best];
|
|
568
|
-
if (!hunk) {
|
|
569
|
-
this.#scrubTip.hidden = true;
|
|
570
|
-
return;
|
|
571
|
-
}
|
|
572
|
-
const r = this.#scrubber.getBoundingClientRect();
|
|
573
|
-
this.#scrubTip.hidden = false;
|
|
574
|
-
this.#scrubTip.style.left = `${clientX - r.left}px`;
|
|
575
|
-
if (this.#scrubTip.dataset.hunk !== hunk.id) {
|
|
576
|
-
this.#scrubTip.dataset.hunk = hunk.id;
|
|
577
|
-
replace(
|
|
578
|
-
this.#scrubTip,
|
|
579
|
-
h('span', { class: 'glyph-box', 'data-family': hunk.family, text: sceneHunkGlyph(hunk) }),
|
|
580
|
-
h('span', { text: hunk.title }),
|
|
581
|
-
);
|
|
582
|
-
}
|
|
518
|
+
if (this.#version === null) this.#caption.hidden = clock.finished;
|
|
583
519
|
}
|
|
584
520
|
|
|
585
521
|
#renderSwipe(): void {
|