@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/bind.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How regions and elements find the root whose session they show (RFC 0001 §2): the context
|
|
3
|
+
* the root provides (`kgdiff.viewer/1`), what it provides, and the binding helpers. Type-only
|
|
4
|
+
* imports of the session: an element's subpath does not pull the session in through here.
|
|
5
|
+
*/
|
|
6
|
+
import { createContext, requestContext } from './context.js';
|
|
7
|
+
import type { Keymap } from './keyboard.js';
|
|
8
|
+
import type { DiffSession, Topic } from './session.js';
|
|
9
|
+
import type { FocusTarget, UiActions, UiContext } from './ui/context.js';
|
|
10
|
+
|
|
11
|
+
/** Something the root renders: a region, or the layout itself. */
|
|
12
|
+
export interface Region {
|
|
13
|
+
update(topics: ReadonlySet<Topic>): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** What a root provides to the regions that bind to it. */
|
|
17
|
+
export interface ViewerContext {
|
|
18
|
+
readonly controller: DiffSession;
|
|
19
|
+
readonly actions: UiActions;
|
|
20
|
+
/** The element the root's events are dispatched on and its tokens are set on. */
|
|
21
|
+
readonly host: HTMLElement;
|
|
22
|
+
/** Render `region` on every notification (in binding order); returns the detach. */
|
|
23
|
+
attach(region: Region): () => void;
|
|
24
|
+
target(name: FocusTarget, focus: () => void, available?: () => boolean): () => void;
|
|
25
|
+
focus(name: FocusTarget): boolean;
|
|
26
|
+
/** The keys bound over the default map (the root's `keymap`), or null for the default. */
|
|
27
|
+
readonly keymap: Keymap | null;
|
|
28
|
+
/** The keyboard map, for a key event that reached the root. */
|
|
29
|
+
handleKey(e: KeyboardEvent): void;
|
|
30
|
+
/** A key pressed in an element bound with `for` (outside the root): the root's rules apply. */
|
|
31
|
+
forwardKey(e: KeyboardEvent): void;
|
|
32
|
+
/** The root's token set as a sheet of `doc` (for elements outside the root's subtree). */
|
|
33
|
+
tokens(doc: Document): CSSStyleSheet;
|
|
34
|
+
/** The pointer entered or left the graph (holds a looping replay with `pause-on-hover`). */
|
|
35
|
+
hover(over: boolean): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const viewerContext = createContext<ViewerContext>('kgdiff.viewer/1');
|
|
39
|
+
|
|
40
|
+
/** Key events an element forwarded to its root: they belong to that root alone. */
|
|
41
|
+
const forwarded = new WeakSet<Event>();
|
|
42
|
+
|
|
43
|
+
/** Forward `e` to `ctx`'s root; no other root acts on it, whether it was handled or not. */
|
|
44
|
+
export function forwardKey(ctx: ViewerContext, e: KeyboardEvent): void {
|
|
45
|
+
forwarded.add(e);
|
|
46
|
+
ctx.forwardKey(e);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Whether `e` was forwarded to a root by an element bound with `for`. */
|
|
50
|
+
export function wasForwarded(e: Event): boolean {
|
|
51
|
+
return forwarded.has(e);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The UI context of a region placed in `root` (its shadow tree), bound to `ctx`. */
|
|
55
|
+
export function uiContext(ctx: ViewerContext, root: ShadowRoot): UiContext {
|
|
56
|
+
return {
|
|
57
|
+
...ctx.actions,
|
|
58
|
+
controller: ctx.controller,
|
|
59
|
+
root,
|
|
60
|
+
target: (name, focus, available) => ctx.target(name, focus, available),
|
|
61
|
+
focus: (name) => ctx.focus(name),
|
|
62
|
+
keymap: () => ctx.keymap,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Bind a region to the root nearest `anchor` (an element where the region is placed): `make`
|
|
68
|
+
* builds it once the root answers, and the root renders it from then on.
|
|
69
|
+
*/
|
|
70
|
+
export function bindRegion<R extends Region>(anchor: Element, make: (ui: UiContext) => R): R {
|
|
71
|
+
const bound: { region?: R } = {};
|
|
72
|
+
requestContext(anchor, viewerContext, (ctx) => {
|
|
73
|
+
if (bound.region) return;
|
|
74
|
+
bound.region = make(uiContext(ctx, anchor.getRootNode() as ShadowRoot));
|
|
75
|
+
ctx.attach(bound.region);
|
|
76
|
+
});
|
|
77
|
+
// The default layout binds its regions to the root it owns, which answers at once.
|
|
78
|
+
if (!bound.region) throw new Error('[kgdiff] a region found no root to bind to.');
|
|
79
|
+
return bound.region;
|
|
80
|
+
}
|
package/src/controller.ts
CHANGED
|
@@ -1,482 +1,91 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
* the one view
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* - draws the session's view state into the renderer (and the minimap, throttled while
|
|
8
|
-
* replaying);
|
|
9
|
-
* - frames what the session asks for in the free part of the canvas, clear of the chrome over
|
|
10
|
-
* it (`framing.ts`); the replay camera follows the playing hunk / chapter unless the user
|
|
11
|
-
* moved it in the last 2 s;
|
|
12
|
-
* - fits a new diff once the canvas is laid out, and again on resize with `autoFit`;
|
|
13
|
-
* - turns canvas hovers and clicks into session commands.
|
|
2
|
+
* The session of `<kgdiff-viewer>`: a `DiffSession` (state, commands, events; `session.ts`) with
|
|
3
|
+
* the one view of the default layout's canvas (`StageView`, `view.ts`: renderer, camera,
|
|
4
|
+
* framing, minimap) built in, so the layout and its tests address both as one object. A
|
|
5
|
+
* `<kgdiff-stage>` bound to the viewer adds a view of its own; `fit()` and `zoom()` reach all.
|
|
14
6
|
*/
|
|
15
|
-
import type {
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
labelFootprint,
|
|
20
|
-
type Minimap,
|
|
21
|
-
type PickResult,
|
|
22
|
-
type Renderer,
|
|
23
|
-
} from '@kgdiff/renderer';
|
|
24
|
-
import {
|
|
25
|
-
createViewState,
|
|
26
|
-
type FocusState,
|
|
27
|
-
type FrameVisuals,
|
|
28
|
-
frameBounds,
|
|
29
|
-
type Layer,
|
|
30
|
-
type Scene,
|
|
31
|
-
} from '@kgdiff/scene';
|
|
32
|
-
import {
|
|
33
|
-
type ChromeLayout,
|
|
34
|
-
chromeInsets,
|
|
35
|
-
FRAME_PADDING,
|
|
36
|
-
freeView,
|
|
37
|
-
type Insets,
|
|
38
|
-
NO_INSETS,
|
|
39
|
-
zoomFit,
|
|
40
|
-
} from './framing.js';
|
|
41
|
-
import { renderBackend } from './renderer-port.js';
|
|
42
|
-
import {
|
|
43
|
-
DiffSession,
|
|
44
|
-
padRect,
|
|
45
|
-
type SessionDeps,
|
|
46
|
-
type SessionHost,
|
|
47
|
-
type SessionView,
|
|
48
|
-
} from './session.js';
|
|
7
|
+
import type { Camera } from '@kgdiff/renderer';
|
|
8
|
+
import type { ChromeLayout } from './framing.js';
|
|
9
|
+
import { DiffSession, type SessionDeps, type SessionHost } from './session.js';
|
|
10
|
+
import { StageView } from './view.js';
|
|
49
11
|
|
|
50
12
|
export type { Preference, Status, Topic } from './session.js';
|
|
13
|
+
export type { FitOptions } from './view.js';
|
|
14
|
+
export {
|
|
15
|
+
coverage,
|
|
16
|
+
FIT_MAX_ZOOM,
|
|
17
|
+
FLY_MAX_ZOOM,
|
|
18
|
+
FOLLOW_COVERAGE,
|
|
19
|
+
OVERVIEW_COVERAGE,
|
|
20
|
+
USER_CAMERA_GRACE,
|
|
21
|
+
} from './view.js';
|
|
51
22
|
export type ControllerHost = SessionHost;
|
|
52
23
|
export type ControllerDeps = SessionDeps;
|
|
53
24
|
|
|
54
|
-
export
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
readonly padding?: number;
|
|
58
|
-
}
|
|
25
|
+
export class ViewerController extends DiffSession {
|
|
26
|
+
/** The default layout's canvas. */
|
|
27
|
+
readonly view: StageView;
|
|
59
28
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
export const FLY_MAX_ZOOM = 1.8;
|
|
66
|
-
/** Fitting everything never zooms in further (the renderer's default). */
|
|
67
|
-
export const FIT_MAX_ZOOM = 2.5;
|
|
68
|
-
/** Minimap marks are re-rasterized at most this often while replaying (ms). */
|
|
69
|
-
const MINIMAP_INTERVAL = 200;
|
|
70
|
-
/** Everything counts as in view (the minimap hides) from this share of the content bounds on. */
|
|
71
|
-
export const OVERVIEW_COVERAGE = 0.98;
|
|
29
|
+
constructor(host: SessionHost, deps: SessionDeps) {
|
|
30
|
+
super(host, deps);
|
|
31
|
+
this.view = new StageView(this);
|
|
32
|
+
this.addView(this.view);
|
|
33
|
+
}
|
|
72
34
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
35
|
+
/** Keep the canvas fitted (`StageView.autoFit`). */
|
|
36
|
+
get autoFit(): boolean {
|
|
37
|
+
return this.view.autoFit;
|
|
38
|
+
}
|
|
78
39
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const w = Math.min(bounds.x + bounds.width, view.x + view.width) - Math.max(bounds.x, view.x);
|
|
82
|
-
const h = Math.min(bounds.y + bounds.height, view.y + view.height) - Math.max(bounds.y, view.y);
|
|
83
|
-
const area = bounds.width * bounds.height;
|
|
84
|
-
if (!(area > 0)) {
|
|
85
|
-
const inside =
|
|
86
|
-
bounds.x >= view.x &&
|
|
87
|
-
bounds.x <= view.x + view.width &&
|
|
88
|
-
bounds.y >= view.y &&
|
|
89
|
-
bounds.y <= view.y + view.height;
|
|
90
|
-
return inside ? 1 : 0;
|
|
40
|
+
set autoFit(value: boolean) {
|
|
41
|
+
this.view.autoFit = value;
|
|
91
42
|
}
|
|
92
|
-
return w > 0 && h > 0 ? (w * h) / area : 0;
|
|
93
|
-
}
|
|
94
43
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
*/
|
|
100
|
-
autoFit = false;
|
|
101
|
-
/** Canvas clicks select (and double clicks fly); off for embeds without controls. */
|
|
102
|
-
selectOnClick = true;
|
|
103
|
-
/** The whole content is in view (the minimap has nothing to add). */
|
|
104
|
-
overview = true;
|
|
44
|
+
/** Canvas clicks select (`StageView.selectOnClick`). */
|
|
45
|
+
get selectOnClick(): boolean {
|
|
46
|
+
return this.view.selectOnClick;
|
|
47
|
+
}
|
|
105
48
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
readonly #unbind: (() => void)[] = [];
|
|
110
|
-
readonly #frames: Frames = { main: null, before: null, after: null };
|
|
111
|
-
#minimapDirty = true;
|
|
112
|
-
#lastMinimap = Number.NEGATIVE_INFINITY;
|
|
113
|
-
#lastUserCamera = Number.NEGATIVE_INFINITY;
|
|
114
|
-
#boundsCache: {
|
|
115
|
-
readonly scene: Scene;
|
|
116
|
-
readonly focus: FocusState | null;
|
|
117
|
-
readonly rect: Rect | null;
|
|
118
|
-
} | null = null;
|
|
119
|
-
#chrome: (() => ChromeLayout) | null = null;
|
|
120
|
-
/** Camera kept across detach / attach (element moved in the DOM). */
|
|
121
|
-
#savedCamera: CameraState | null = null;
|
|
122
|
-
/** A new diff is installed but has not been fitted to a laid-out canvas yet. */
|
|
123
|
-
#needsFit = false;
|
|
124
|
-
/** The user panned or zoomed since the last fit. */
|
|
125
|
-
#userMoved = false;
|
|
126
|
-
#fitPadding = FRAME_PADDING;
|
|
127
|
-
readonly #now: () => number;
|
|
49
|
+
set selectOnClick(value: boolean) {
|
|
50
|
+
this.view.selectOnClick = value;
|
|
51
|
+
}
|
|
128
52
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
this
|
|
132
|
-
this.addView(this);
|
|
53
|
+
/** The whole content is in the canvas's view (the minimap has nothing to add). */
|
|
54
|
+
get overview(): boolean {
|
|
55
|
+
return this.view.overview;
|
|
133
56
|
}
|
|
134
57
|
|
|
135
|
-
|
|
58
|
+
get camera(): Camera | null {
|
|
59
|
+
return this.view.camera;
|
|
60
|
+
}
|
|
136
61
|
|
|
137
62
|
attach(stage: HTMLElement, minimapHost: HTMLElement | null): void {
|
|
138
|
-
|
|
139
|
-
const backend = renderBackend();
|
|
140
|
-
const renderer = backend.createRenderer(stage, {
|
|
141
|
-
theme: this.theme,
|
|
142
|
-
labelsInside: this.#labelsInside,
|
|
143
|
-
});
|
|
144
|
-
this.#renderer = renderer;
|
|
145
|
-
// Re-attached (element moved): keep the view; this also stops the renderer's auto-fit.
|
|
146
|
-
if (this.#savedCamera) renderer.camera.set(this.#savedCamera);
|
|
147
|
-
if (minimapHost) this.#minimap = backend.createMinimap(minimapHost, renderer);
|
|
148
|
-
this.#unbind.push(
|
|
149
|
-
renderer.on('hover', (pick) => this.#onHover(pick)),
|
|
150
|
-
renderer.on('click', ({ pick }) => this.#onCanvasClick(pick)),
|
|
151
|
-
renderer.on('dblclick', ({ pick }) => this.#onCanvasDblClick(pick)),
|
|
152
|
-
renderer.on('camera', ({ source }) => {
|
|
153
|
-
if (source === 'user') this.noteUserCamera();
|
|
154
|
-
this.#updateOverview();
|
|
155
|
-
}),
|
|
156
|
-
);
|
|
157
|
-
this.#minimapDirty = true;
|
|
158
|
-
this.invalidate();
|
|
63
|
+
this.view.attach(stage, minimapHost);
|
|
159
64
|
}
|
|
160
65
|
|
|
161
66
|
detach(): void {
|
|
162
|
-
|
|
163
|
-
if (this.#renderer && this.status === 'ready') this.#savedCamera = this.#renderer.camera.get();
|
|
67
|
+
this.view.detach();
|
|
164
68
|
this.sleep();
|
|
165
|
-
for (const fn of this.#unbind.splice(0)) fn();
|
|
166
|
-
this.#minimap?.destroy();
|
|
167
|
-
this.#minimap = null;
|
|
168
|
-
this.#renderer?.destroy();
|
|
169
|
-
this.#renderer = null;
|
|
170
69
|
}
|
|
171
70
|
|
|
172
71
|
override destroy(): void {
|
|
173
72
|
super.destroy();
|
|
174
|
-
this.detach();
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
get camera(): Camera | null {
|
|
178
|
-
return this.#renderer?.camera ?? null;
|
|
73
|
+
this.view.detach();
|
|
179
74
|
}
|
|
180
75
|
|
|
181
|
-
/**
|
|
182
|
-
* Where floating chrome covers the canvas (measured by the element at fit / flight time).
|
|
183
|
-
* Every fit and flight frames its target in the free part of the canvas (`framing.ts`).
|
|
184
|
-
*/
|
|
185
76
|
setChrome(provider: (() => ChromeLayout) | null): void {
|
|
186
|
-
this
|
|
77
|
+
this.view.setChrome(provider);
|
|
187
78
|
}
|
|
188
79
|
|
|
189
|
-
/**
|
|
190
|
-
* Keep labels whole inside the canvas (embeds without camera controls): a label pushed from
|
|
191
|
-
* its spot by a neighbour moves inside or is left out, never cut at the edge.
|
|
192
|
-
*/
|
|
193
80
|
setLabelsInside(inside: boolean): void {
|
|
194
|
-
this
|
|
195
|
-
this.#renderer?.setLabelsInside(inside);
|
|
81
|
+
this.view.setLabelsInside(inside);
|
|
196
82
|
}
|
|
197
83
|
|
|
198
|
-
/**
|
|
199
|
-
* The canvas was laid out or resized: run the initial fit a new diff is waiting for, or (with
|
|
200
|
-
* `autoFit`) fit again unless the user moved the camera since the last fit.
|
|
201
|
-
*/
|
|
202
84
|
layoutChanged(): void {
|
|
203
|
-
this
|
|
204
|
-
if (this.status !== 'ready') return;
|
|
205
|
-
if (!this.#needsFit) {
|
|
206
|
-
if (this.autoFit && !this.#userMoved) void this.fit({ animate: false });
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
|
-
// A selection made before the first layout (deep link, `kgdiff-ready` handler) wins.
|
|
210
|
-
const target = this.selectionBounds();
|
|
211
|
-
if (target) {
|
|
212
|
-
if (!this.#frameInsets(target, FLY_MAX_ZOOM)) return;
|
|
213
|
-
this.#needsFit = false;
|
|
214
|
-
this.#flyTo(target, { animate: false });
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
const fit = this.#fitTarget();
|
|
218
|
-
const camera = this.#renderer?.camera;
|
|
219
|
-
if (!fit || !camera) return;
|
|
220
|
-
void this.#fitTo(camera, fit, { animate: false });
|
|
85
|
+
this.view.layoutChanged();
|
|
221
86
|
}
|
|
222
87
|
|
|
223
|
-
/** The user panned / zoomed: suspend replay camera follow for `USER_CAMERA_GRACE`. */
|
|
224
88
|
noteUserCamera(): void {
|
|
225
|
-
this
|
|
226
|
-
this.#userMoved = true;
|
|
89
|
+
this.view.noteUserCamera();
|
|
227
90
|
}
|
|
228
|
-
|
|
229
|
-
// ─── SessionView ──────────────────────────────────────────────────────────
|
|
230
|
-
|
|
231
|
-
/** Resolve the current view and hand it to the renderer (and the minimap). */
|
|
232
|
-
draw(now = this.#now()): void {
|
|
233
|
-
const resolver = this.resolver;
|
|
234
|
-
const renderer = this.#renderer;
|
|
235
|
-
if (!resolver || !renderer || this.status !== 'ready') return;
|
|
236
|
-
const f = this.#frames;
|
|
237
|
-
let mapFrame: FrameVisuals;
|
|
238
|
-
if (this.mode === 'swipe') {
|
|
239
|
-
f.before = resolver.resolve(this.viewState('before'), f.before ?? undefined);
|
|
240
|
-
f.after = resolver.resolve(this.viewState('after'), f.after ?? undefined);
|
|
241
|
-
renderer.setFrame({ split: this.split, before: f.before, after: f.after });
|
|
242
|
-
mapFrame = f.after;
|
|
243
|
-
} else {
|
|
244
|
-
const layer: Layer = this.mode === 'replay' ? 'replay' : 'union';
|
|
245
|
-
f.main = resolver.resolve(this.viewState(layer), f.main ?? undefined);
|
|
246
|
-
renderer.setFrame(f.main);
|
|
247
|
-
mapFrame = f.main;
|
|
248
|
-
}
|
|
249
|
-
const replaying = this.mode === 'replay' && (this.clock?.playing ?? false);
|
|
250
|
-
if (
|
|
251
|
-
this.#minimap &&
|
|
252
|
-
(this.#minimapDirty || (replaying && now - this.#lastMinimap >= MINIMAP_INTERVAL))
|
|
253
|
-
) {
|
|
254
|
-
this.#minimap.update(mapFrame);
|
|
255
|
-
this.#minimapDirty = false;
|
|
256
|
-
this.#lastMinimap = now;
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
sceneChanged(): void {
|
|
261
|
-
const f = this.#frames;
|
|
262
|
-
f.main = f.before = f.after = null;
|
|
263
|
-
this.#renderer?.setTheme(this.scene?.theme ?? this.theme);
|
|
264
|
-
this.#minimapDirty = true;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
installed(): void {
|
|
268
|
-
// Fitted after the UI has shown its chrome (the session's notification is delivered
|
|
269
|
-
// first) and once the canvas has a size (see `layoutChanged`).
|
|
270
|
-
this.#needsFit = true;
|
|
271
|
-
this.#savedCamera = null;
|
|
272
|
-
queueMicrotask(() => this.layoutChanged());
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
drawnChanged(): void {
|
|
276
|
-
this.#updateOverview();
|
|
277
|
-
this.#minimapDirty = true;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
show(bounds: Rect): void {
|
|
281
|
-
this.#flyTo(bounds);
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/** Follow `bounds` (replay): fly unless the user steered recently or it is mostly visible. */
|
|
285
|
-
follow(bounds: Rect): void {
|
|
286
|
-
if (this.#now() - this.#lastUserCamera < USER_CAMERA_GRACE) return;
|
|
287
|
-
const free = this.#freeWorld(bounds);
|
|
288
|
-
if (!free || coverage(bounds, free) >= FOLLOW_COVERAGE) return;
|
|
289
|
-
this.#flyTo(bounds);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
looped(): void {
|
|
293
|
-
if (this.autoFit) void this.fit();
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
viewport(): Rect | null {
|
|
297
|
-
return this.#renderer?.camera.viewport() ?? null;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// ─── Camera ───────────────────────────────────────────────────────────────
|
|
301
|
-
|
|
302
|
-
/** Insets keeping `bounds` clear of the chrome over the canvas, or null before layout. */
|
|
303
|
-
#frameInsets(bounds: Rect, maxZoom: number): Insets | null {
|
|
304
|
-
const layout = this.#chrome?.();
|
|
305
|
-
if (!layout || !(layout.width > 0) || !(layout.height > 0)) return null;
|
|
306
|
-
return chromeInsets(bounds, layout, FRAME_PADDING, maxZoom);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
#flyTo(bounds: Rect, options: { readonly animate?: boolean } = {}): void {
|
|
310
|
-
const camera = this.#renderer?.camera;
|
|
311
|
-
if (!camera) return;
|
|
312
|
-
const padding = this.#frameInsets(bounds, FLY_MAX_ZOOM);
|
|
313
|
-
void camera.flyTo(bounds, {
|
|
314
|
-
...options,
|
|
315
|
-
maxZoom: FLY_MAX_ZOOM,
|
|
316
|
-
...(padding ? { padding } : {}),
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/** The world region visible outside the chrome, as framed for `bounds`. */
|
|
321
|
-
#freeWorld(bounds: Rect): Rect | null {
|
|
322
|
-
const camera = this.#renderer?.camera;
|
|
323
|
-
if (!camera) return null;
|
|
324
|
-
const insets = this.#frameInsets(bounds, FLY_MAX_ZOOM) ?? NO_INSETS;
|
|
325
|
-
return freeView(camera.viewport(), camera.get().k, insets);
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
/**
|
|
329
|
-
* Fit everything visible (the focus in changes-only mode), labels included, into the free part
|
|
330
|
-
* of the canvas. Before the canvas is laid out it falls back to the renderer's own fit.
|
|
331
|
-
*/
|
|
332
|
-
fit(options: FitOptions = {}): Promise<void> {
|
|
333
|
-
const camera = this.#renderer?.camera;
|
|
334
|
-
if (!camera) return Promise.resolve();
|
|
335
|
-
const { padding, ...transition } = options;
|
|
336
|
-
if (padding !== undefined) {
|
|
337
|
-
this.#fitPadding = Number.isFinite(padding) && padding >= 0 ? padding : FRAME_PADDING;
|
|
338
|
-
}
|
|
339
|
-
const fit = this.#fitTarget();
|
|
340
|
-
if (!fit) return camera.fit(undefined, transition);
|
|
341
|
-
return this.#fitTo(camera, fit, transition);
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
#fitTo(
|
|
345
|
-
camera: Camera,
|
|
346
|
-
fit: { readonly bounds: Rect; readonly insets: Insets },
|
|
347
|
-
transition: { readonly animate?: boolean },
|
|
348
|
-
): Promise<void> {
|
|
349
|
-
this.#needsFit = false;
|
|
350
|
-
this.#userMoved = false;
|
|
351
|
-
return camera.fit(fit.bounds, { ...transition, padding: fit.insets, maxZoom: FIT_MAX_ZOOM });
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/**
|
|
355
|
-
* Bounds and insets that show everything visible (the changes-only focus) with every label
|
|
356
|
-
* in its spot below its node, clear of the chrome, or null before layout. Measured in the
|
|
357
|
-
* union layer, which holds every state a replay or swipe passes through. Insets depend on
|
|
358
|
-
* what is framed, which depends on the zoom they allow: they are settled once more.
|
|
359
|
-
*/
|
|
360
|
-
#fitTarget(): { bounds: Rect; insets: Insets } | null {
|
|
361
|
-
const layout = this.#chrome?.();
|
|
362
|
-
const resolver = this.resolver;
|
|
363
|
-
if (!layout || !resolver || !(layout.width > 0) || !(layout.height > 0)) return null;
|
|
364
|
-
const frame = resolver.resolve(
|
|
365
|
-
createViewState({
|
|
366
|
-
layer: 'union',
|
|
367
|
-
focus: this.focus,
|
|
368
|
-
families: this.families,
|
|
369
|
-
reducedMotion: this.reducedMotion,
|
|
370
|
-
}),
|
|
371
|
-
);
|
|
372
|
-
const footprint = labelFootprint(frame, this.theme);
|
|
373
|
-
const extent = (k: number): Rect | null => footprint.at(k);
|
|
374
|
-
const discs = extent(Number.POSITIVE_INFINITY);
|
|
375
|
-
if (!discs) return null;
|
|
376
|
-
const { width, height } = layout;
|
|
377
|
-
const padding = this.#fitPadding;
|
|
378
|
-
let insets = chromeInsets(discs, layout, padding, FIT_MAX_ZOOM);
|
|
379
|
-
let fit = zoomFit(extent, width, height, insets, FIT_MAX_ZOOM);
|
|
380
|
-
if (!fit) return null;
|
|
381
|
-
const settled = chromeInsets(fit.content, layout, padding, FIT_MAX_ZOOM);
|
|
382
|
-
if (!sameInsets(settled, insets)) {
|
|
383
|
-
insets = settled;
|
|
384
|
-
fit = zoomFit(extent, width, height, insets, FIT_MAX_ZOOM) ?? fit;
|
|
385
|
-
}
|
|
386
|
-
return { bounds: fit.bounds, insets };
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/** Bounds of what is drawn: the scene, or the visible part of the `changes` scope's focus. */
|
|
390
|
-
#contentBounds(): Rect | null {
|
|
391
|
-
const scene = this.scene;
|
|
392
|
-
if (!scene) return null;
|
|
393
|
-
const focus = this.focus;
|
|
394
|
-
const cached = this.#boundsCache;
|
|
395
|
-
if (cached && cached.scene === scene && cached.focus === focus) return cached.rect;
|
|
396
|
-
const rect = focus ? focusBounds(scene, focus) : frameBounds(scene);
|
|
397
|
-
this.#boundsCache = { scene, focus, rect };
|
|
398
|
-
return rect;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/** Whether everything drawn is in view; the minimap hides while it is. */
|
|
402
|
-
#updateOverview(): void {
|
|
403
|
-
const bounds = this.#contentBounds();
|
|
404
|
-
const view = this.#renderer?.camera.viewport();
|
|
405
|
-
const next = !bounds || !view || coverage(bounds, view) >= OVERVIEW_COVERAGE;
|
|
406
|
-
if (next === this.overview) return;
|
|
407
|
-
this.overview = next;
|
|
408
|
-
this.notify('overview');
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
zoom(direction: 1 | -1): void {
|
|
412
|
-
this.noteUserCamera();
|
|
413
|
-
this.#renderer?.camera.zoomBy(direction > 0 ? 1.4 : 1 / 1.4);
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// ─── Canvas events ────────────────────────────────────────────────────────
|
|
417
|
-
|
|
418
|
-
#onHover(pick: PickResult | null): void {
|
|
419
|
-
this.setHover(
|
|
420
|
-
pick?.type === 'node' ? pick.index : -1,
|
|
421
|
-
pick?.type === 'edge' ? pick.index : -1,
|
|
422
|
-
pick?.type === 'collapsed' ? pick.index : -1,
|
|
423
|
-
);
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
#onCanvasClick(pick: PickResult | null): void {
|
|
427
|
-
if (!this.selectOnClick) return;
|
|
428
|
-
if (!pick) {
|
|
429
|
-
this.clearSelection();
|
|
430
|
-
return;
|
|
431
|
-
}
|
|
432
|
-
switch (pick.type) {
|
|
433
|
-
case 'node':
|
|
434
|
-
this.selectNode(pick.index, { fly: false });
|
|
435
|
-
break;
|
|
436
|
-
case 'edge': {
|
|
437
|
-
const e = this.scene?.edges[pick.index];
|
|
438
|
-
if (e && e.hunk >= 0) this.selectHunk(e.hunk, { fly: false });
|
|
439
|
-
this.pinnedEdge = pick.index;
|
|
440
|
-
this.invalidate();
|
|
441
|
-
break;
|
|
442
|
-
}
|
|
443
|
-
case 'collapsed':
|
|
444
|
-
this.expandGroup(pick.index);
|
|
445
|
-
break;
|
|
446
|
-
default:
|
|
447
|
-
break;
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
#onCanvasDblClick(pick: PickResult | null): void {
|
|
452
|
-
if (!this.selectOnClick || pick?.type !== 'node') return;
|
|
453
|
-
const n = this.scene?.nodes[pick.index];
|
|
454
|
-
if (!n) return;
|
|
455
|
-
if (n.hunk >= 0) this.flyToHunk(n.hunk);
|
|
456
|
-
else this.flyToNode(pick.index);
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
function sameInsets(a: Insets, b: Insets): boolean {
|
|
461
|
-
return a.top === b.top && a.right === b.right && a.bottom === b.bottom && a.left === b.left;
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
/** Bounds of the visible part of `focus`, with room for labels and the collapsed pills. */
|
|
465
|
-
function focusBounds(scene: Scene, focus: FocusState): Rect {
|
|
466
|
-
let x0 = Number.POSITIVE_INFINITY;
|
|
467
|
-
let y0 = Number.POSITIVE_INFINITY;
|
|
468
|
-
let x1 = Number.NEGATIVE_INFINITY;
|
|
469
|
-
let y1 = Number.NEGATIVE_INFINITY;
|
|
470
|
-
const add = (x: number, y: number, rx: number, top: number, bottom: number): void => {
|
|
471
|
-
x0 = Math.min(x0, x - rx);
|
|
472
|
-
x1 = Math.max(x1, x + rx);
|
|
473
|
-
y0 = Math.min(y0, y - top);
|
|
474
|
-
y1 = Math.max(y1, y + bottom);
|
|
475
|
-
};
|
|
476
|
-
// Labels sit centered below their node: leave room for a typical one.
|
|
477
|
-
for (const n of scene.nodes)
|
|
478
|
-
if (focus.nodes[n.index] === 1) add(n.x, n.y, n.r + 48, n.r, n.r + 24);
|
|
479
|
-
for (const g of focus.collapsed) add(g.x, g.y, 48, 12, 12);
|
|
480
|
-
if (!(x0 <= x1)) return frameBounds(scene);
|
|
481
|
-
return padRect({ x: x0, y: y0, width: x1 - x0, height: y1 - y0 }, 160, 1.12);
|
|
482
91
|
}
|
package/src/dom.ts
CHANGED
|
@@ -129,3 +129,33 @@ export function relabel(el: Element, label: string): void {
|
|
|
129
129
|
attr(el, 'aria-label', label);
|
|
130
130
|
attr(el, 'title', label);
|
|
131
131
|
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Whether `el` is shown and can take focus: connected, and neither it nor an ancestor (across
|
|
135
|
+
* shadow roots) is `hidden` or `inert`.
|
|
136
|
+
*/
|
|
137
|
+
export function isOpen(el: Element): boolean {
|
|
138
|
+
if (!el.isConnected) return false;
|
|
139
|
+
for (let n: Element | null = el; n; ) {
|
|
140
|
+
if ((n as HTMLElement).hidden || (n as HTMLElement).inert) return false;
|
|
141
|
+
n = n.parentElement ?? (n.getRootNode() as Partial<ShadowRoot>).host ?? null;
|
|
142
|
+
}
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Before `from` (a region) folds or hides: if focus is inside it, move focus to `to()`, or let
|
|
148
|
+
* go of it when there is nowhere to go (focus never stays inside an inert region).
|
|
149
|
+
*/
|
|
150
|
+
export function moveFocusOut(
|
|
151
|
+
root: DocumentOrShadowRoot,
|
|
152
|
+
from: Element,
|
|
153
|
+
to: () => HTMLElement | null | undefined,
|
|
154
|
+
): void {
|
|
155
|
+
// A focused element inside a nested shadow root shows as its host here.
|
|
156
|
+
const here = root.activeElement as HTMLElement | null;
|
|
157
|
+
if (!here || !from.contains(here)) return;
|
|
158
|
+
const next = to();
|
|
159
|
+
if (next && !from.contains(next)) next.focus({ preventScroll: true });
|
|
160
|
+
else here.blur();
|
|
161
|
+
}
|