@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.
Files changed (232) hide show
  1. package/README.md +43 -16
  2. package/custom-elements.json +2347 -0
  3. package/dist/bind.d.ts +41 -0
  4. package/dist/bind.d.ts.map +1 -0
  5. package/dist/bind.js +47 -0
  6. package/dist/bind.js.map +1 -0
  7. package/dist/controller.d.ts +22 -74
  8. package/dist/controller.d.ts.map +1 -1
  9. package/dist/controller.js +35 -376
  10. package/dist/controller.js.map +1 -1
  11. package/dist/dom.d.ts +10 -0
  12. package/dist/dom.d.ts.map +1 -1
  13. package/dist/dom.js +29 -0
  14. package/dist/dom.js.map +1 -1
  15. package/dist/element.d.ts +52 -46
  16. package/dist/element.d.ts.map +1 -1
  17. package/dist/element.js +362 -403
  18. package/dist/element.js.map +1 -1
  19. package/dist/elements/actions.d.ts +27 -0
  20. package/dist/elements/actions.d.ts.map +1 -0
  21. package/dist/elements/actions.js +81 -0
  22. package/dist/elements/actions.js.map +1 -0
  23. package/dist/elements/bound.d.ts +63 -0
  24. package/dist/elements/bound.d.ts.map +1 -0
  25. package/dist/elements/bound.js +213 -0
  26. package/dist/elements/bound.js.map +1 -0
  27. package/dist/elements/change-detail.d.ts +25 -0
  28. package/dist/elements/change-detail.d.ts.map +1 -0
  29. package/dist/elements/change-detail.js +19 -0
  30. package/dist/elements/change-detail.js.map +1 -0
  31. package/dist/elements/change-list.d.ts +27 -0
  32. package/dist/elements/change-list.d.ts.map +1 -0
  33. package/dist/elements/change-list.js +29 -0
  34. package/dist/elements/change-list.js.map +1 -0
  35. package/dist/elements/filters.d.ts +25 -0
  36. package/dist/elements/filters.d.ts.map +1 -0
  37. package/dist/elements/filters.js +19 -0
  38. package/dist/elements/filters.js.map +1 -0
  39. package/dist/elements/legend.d.ts +40 -0
  40. package/dist/elements/legend.d.ts.map +1 -0
  41. package/dist/elements/legend.js +87 -0
  42. package/dist/elements/legend.js.map +1 -0
  43. package/dist/elements/minimap.d.ts +38 -0
  44. package/dist/elements/minimap.d.ts.map +1 -0
  45. package/dist/elements/minimap.js +59 -0
  46. package/dist/elements/minimap.js.map +1 -0
  47. package/dist/elements/root.d.ts +40 -0
  48. package/dist/elements/root.d.ts.map +1 -0
  49. package/dist/elements/root.js +67 -0
  50. package/dist/elements/root.js.map +1 -0
  51. package/dist/elements/search.d.ts +29 -0
  52. package/dist/elements/search.d.ts.map +1 -0
  53. package/dist/elements/search.js +23 -0
  54. package/dist/elements/search.js.map +1 -0
  55. package/dist/elements/stage-context.d.ts +31 -0
  56. package/dist/elements/stage-context.d.ts.map +1 -0
  57. package/dist/elements/stage-context.js +4 -0
  58. package/dist/elements/stage-context.js.map +1 -0
  59. package/dist/elements/stage.d.ts +65 -0
  60. package/dist/elements/stage.d.ts.map +1 -0
  61. package/dist/elements/stage.js +259 -0
  62. package/dist/elements/stage.js.map +1 -0
  63. package/dist/elements/title.d.ts +25 -0
  64. package/dist/elements/title.d.ts.map +1 -0
  65. package/dist/elements/title.js +19 -0
  66. package/dist/elements/title.js.map +1 -0
  67. package/dist/elements/transport.d.ts +29 -0
  68. package/dist/elements/transport.d.ts.map +1 -0
  69. package/dist/elements/transport.js +24 -0
  70. package/dist/elements/transport.js.map +1 -0
  71. package/dist/elements/view-bound.d.ts +39 -0
  72. package/dist/elements/view-bound.d.ts.map +1 -0
  73. package/dist/elements/view-bound.js +88 -0
  74. package/dist/elements/view-bound.js.map +1 -0
  75. package/dist/elements/view-dock.d.ts +28 -0
  76. package/dist/elements/view-dock.d.ts.map +1 -0
  77. package/dist/elements/view-dock.js +28 -0
  78. package/dist/elements/view-dock.js.map +1 -0
  79. package/dist/elements/zoom.d.ts +28 -0
  80. package/dist/elements/zoom.d.ts.map +1 -0
  81. package/dist/elements/zoom.js +22 -0
  82. package/dist/elements/zoom.js.map +1 -0
  83. package/dist/i18n.d.ts +10 -0
  84. package/dist/i18n.d.ts.map +1 -1
  85. package/dist/i18n.js +20 -0
  86. package/dist/i18n.js.map +1 -1
  87. package/dist/icons.d.ts +1 -0
  88. package/dist/icons.d.ts.map +1 -1
  89. package/dist/icons.js +1 -0
  90. package/dist/icons.js.map +1 -1
  91. package/dist/index.d.ts +328 -34
  92. package/dist/index.d.ts.map +1 -1
  93. package/dist/index.js +13 -0
  94. package/dist/index.js.map +1 -1
  95. package/dist/keyboard.d.ts +39 -2
  96. package/dist/keyboard.d.ts.map +1 -1
  97. package/dist/keyboard.js +145 -62
  98. package/dist/keyboard.js.map +1 -1
  99. package/dist/layout-store.d.ts +26 -0
  100. package/dist/layout-store.d.ts.map +1 -0
  101. package/dist/layout-store.js +46 -0
  102. package/dist/layout-store.js.map +1 -0
  103. package/dist/panel-levels.d.ts +18 -0
  104. package/dist/panel-levels.d.ts.map +1 -0
  105. package/dist/panel-levels.js +41 -0
  106. package/dist/panel-levels.js.map +1 -0
  107. package/dist/panels.d.ts +28 -0
  108. package/dist/panels.d.ts.map +1 -0
  109. package/dist/panels.js +73 -0
  110. package/dist/panels.js.map +1 -0
  111. package/dist/prefs.d.ts +5 -2
  112. package/dist/prefs.d.ts.map +1 -1
  113. package/dist/prefs.js +12 -3
  114. package/dist/prefs.js.map +1 -1
  115. package/dist/react.d.ts +460 -0
  116. package/dist/react.d.ts.map +1 -0
  117. package/dist/react.js +2 -0
  118. package/dist/react.js.map +1 -0
  119. package/dist/root-element.d.ts +122 -0
  120. package/dist/root-element.d.ts.map +1 -0
  121. package/dist/root-element.js +541 -0
  122. package/dist/root-element.js.map +1 -0
  123. package/dist/root.d.ts +30 -30
  124. package/dist/root.d.ts.map +1 -1
  125. package/dist/root.js +65 -42
  126. package/dist/root.js.map +1 -1
  127. package/dist/session.d.ts +19 -3
  128. package/dist/session.d.ts.map +1 -1
  129. package/dist/session.js +15 -17
  130. package/dist/session.js.map +1 -1
  131. package/dist/standalone/kgdiff-viewer.js +4 -4
  132. package/dist/styles.d.ts +4 -0
  133. package/dist/styles.d.ts.map +1 -1
  134. package/dist/styles.js +224 -84
  135. package/dist/styles.js.map +1 -1
  136. package/dist/theme.d.ts +7 -0
  137. package/dist/theme.d.ts.map +1 -1
  138. package/dist/theme.js +10 -7
  139. package/dist/theme.js.map +1 -1
  140. package/dist/topics.d.ts +8 -0
  141. package/dist/topics.d.ts.map +1 -0
  142. package/dist/topics.js +18 -0
  143. package/dist/topics.js.map +1 -0
  144. package/dist/ui/change-list.d.ts.map +1 -1
  145. package/dist/ui/change-list.js +2 -2
  146. package/dist/ui/change-list.js.map +1 -1
  147. package/dist/ui/context.d.ts +20 -7
  148. package/dist/ui/context.d.ts.map +1 -1
  149. package/dist/ui/context.js.map +1 -1
  150. package/dist/ui/header.d.ts +41 -3
  151. package/dist/ui/header.d.ts.map +1 -1
  152. package/dist/ui/header.js +99 -34
  153. package/dist/ui/header.js.map +1 -1
  154. package/dist/ui/legend.d.ts +15 -0
  155. package/dist/ui/legend.d.ts.map +1 -0
  156. package/dist/ui/legend.js +68 -0
  157. package/dist/ui/legend.js.map +1 -0
  158. package/dist/ui/popovers.d.ts +3 -1
  159. package/dist/ui/popovers.d.ts.map +1 -1
  160. package/dist/ui/popovers.js +62 -23
  161. package/dist/ui/popovers.js.map +1 -1
  162. package/dist/ui/search.d.ts.map +1 -1
  163. package/dist/ui/search.js +3 -2
  164. package/dist/ui/search.js.map +1 -1
  165. package/dist/ui/sidebar.d.ts +26 -3
  166. package/dist/ui/sidebar.d.ts.map +1 -1
  167. package/dist/ui/sidebar.js +344 -12
  168. package/dist/ui/sidebar.js.map +1 -1
  169. package/dist/ui/stage.d.ts +56 -1
  170. package/dist/ui/stage.d.ts.map +1 -1
  171. package/dist/ui/stage.js +244 -256
  172. package/dist/ui/stage.js.map +1 -1
  173. package/dist/ui/transport.d.ts +12 -0
  174. package/dist/ui/transport.d.ts.map +1 -0
  175. package/dist/ui/transport.js +198 -0
  176. package/dist/ui/transport.js.map +1 -0
  177. package/dist/ui/zoom.d.ts +15 -0
  178. package/dist/ui/zoom.d.ts.map +1 -0
  179. package/dist/ui/zoom.js +30 -0
  180. package/dist/ui/zoom.js.map +1 -0
  181. package/dist/view.d.ts +94 -0
  182. package/dist/view.d.ts.map +1 -0
  183. package/dist/view.js +441 -0
  184. package/dist/view.js.map +1 -0
  185. package/package.json +95 -12
  186. package/src/bind.ts +80 -0
  187. package/src/controller.ts +51 -442
  188. package/src/dom.ts +30 -0
  189. package/src/element.ts +364 -406
  190. package/src/elements/actions.ts +94 -0
  191. package/src/elements/bound.ts +247 -0
  192. package/src/elements/change-detail.ts +34 -0
  193. package/src/elements/change-list.ts +51 -0
  194. package/src/elements/filters.ts +34 -0
  195. package/src/elements/legend.ts +103 -0
  196. package/src/elements/minimap.ts +75 -0
  197. package/src/elements/root.ts +86 -0
  198. package/src/elements/search.ts +38 -0
  199. package/src/elements/stage-context.ts +34 -0
  200. package/src/elements/stage.ts +285 -0
  201. package/src/elements/title.ts +34 -0
  202. package/src/elements/transport.ts +39 -0
  203. package/src/elements/view-bound.ts +118 -0
  204. package/src/elements/view-dock.ts +43 -0
  205. package/src/elements/zoom.ts +38 -0
  206. package/src/i18n.ts +20 -0
  207. package/src/icons.ts +1 -0
  208. package/src/index.ts +347 -34
  209. package/src/keyboard.ts +188 -61
  210. package/src/layout-store.ts +71 -0
  211. package/src/panel-levels.ts +53 -0
  212. package/src/panels.ts +81 -0
  213. package/src/prefs.ts +12 -4
  214. package/src/react.ts +459 -0
  215. package/src/root-element.ts +624 -0
  216. package/src/root.ts +92 -65
  217. package/src/session.ts +22 -33
  218. package/src/styles.ts +245 -84
  219. package/src/theme.ts +10 -7
  220. package/src/topics.ts +37 -0
  221. package/src/ui/change-list.ts +6 -2
  222. package/src/ui/context.ts +20 -7
  223. package/src/ui/header.ts +127 -56
  224. package/src/ui/legend.ts +112 -0
  225. package/src/ui/popovers.ts +68 -25
  226. package/src/ui/search.ts +10 -5
  227. package/src/ui/sidebar.ts +386 -23
  228. package/src/ui/stage.ts +267 -331
  229. package/src/ui/transport.ts +240 -0
  230. package/src/ui/zoom.ts +45 -0
  231. package/src/view.ts +516 -0
  232. 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 canvas side of the viewer: a `DiffSession` (state, commands, events; `session.ts`) plus
3
- * the one view that draws it (RFC 0001 §1: renderer, camera, framing, minimap), behind the
4
- * `SessionView` boundary. Phase 3 of the RFC moves this view out into a `StageView` of its own,
5
- * so one session can drive several canvases.
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 { Rect } from '@kgdiff/core';
16
- import {
17
- type Camera,
18
- type CameraState,
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 interface FitOptions {
55
- readonly animate?: boolean;
56
- /** Room around the content, px (default `FRAME_PADDING`); kept for later automatic fits. */
57
- readonly padding?: number;
58
- }
25
+ export class ViewerController extends DiffSession {
26
+ /** The default layout's canvas. */
27
+ readonly view: StageView;
59
28
 
60
- /** Camera follow is suppressed this long after the user moved the camera (ms). */
61
- export const USER_CAMERA_GRACE = 2000;
62
- /** The camera follows when less than this share of the target bounds is in view. */
63
- export const FOLLOW_COVERAGE = 0.7;
64
- /** Flights to a change never zoom in further, so its neighbourhood stays readable. */
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
- interface Frames {
74
- main: FrameVisuals | null;
75
- before: FrameVisuals | null;
76
- after: FrameVisuals | null;
77
- }
35
+ /** Keep the canvas fitted (`StageView.autoFit`). */
36
+ get autoFit(): boolean {
37
+ return this.view.autoFit;
38
+ }
78
39
 
79
- /** Share of `bounds` inside `view`, in [0, 1]. */
80
- export function coverage(bounds: Rect, view: Rect): number {
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
- export class ViewerController extends DiffSession implements SessionView {
96
- /**
97
- * Keep the view fitted (embeds without controls): fit again when the canvas is resized unless
98
- * the user moved the camera since, and at the start of every loop.
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
- #labelsInside = false;
107
- #renderer: Renderer | null = null;
108
- #minimap: Minimap | null = null;
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
- constructor(host: SessionHost, deps: SessionDeps) {
130
- super(host, deps);
131
- this.#now = deps.now;
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
- // ─── Renderer ─────────────────────────────────────────────────────────────
58
+ get camera(): Camera | null {
59
+ return this.view.camera;
60
+ }
136
61
 
137
62
  attach(stage: HTMLElement, minimapHost: HTMLElement | null): void {
138
- if (this.#renderer) return;
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
- // A pending initial fit still runs after re-attach (`layoutChanged`), overriding this.
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.#chrome = provider;
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.#labelsInside = inside;
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.#updateOverview();
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.#lastUserCamera = this.#now();
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
+ }