@celestia-island/hikari 0.43.14 → 0.43.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.43.14",
3
+ "version": "0.43.15",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -89,6 +89,30 @@ describe("HkAuthMethodList", () => {
89
89
  expect(wrapper.style.getPropertyValue("--auth-methods-label-width")).toBe("63px");
90
90
  });
91
91
 
92
+ it("publishes a width in the column's own units inside a scaled root", async () => {
93
+ // The labels are measured where they are DRAWN while the custom property
94
+ // is a CSS length the column is laid out with: in a scaled or zoomed root
95
+ // the published column would come out that many times too wide.
96
+ const c = mount(h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { methods })));
97
+ await nextTick();
98
+ const wrapper = c.querySelector<HTMLElement>(".s-auth-methods-list")!;
99
+ const spans = [...wrapper.querySelectorAll<HTMLElement>(".s-auth-methods-label")];
100
+ Object.defineProperty(spans[0]!, "getBoundingClientRect", { configurable: true, value: () => ({ width: 58 }) });
101
+ Object.defineProperty(spans[1]!, "getBoundingClientRect", { configurable: true, value: () => ({ width: 61.4 }) });
102
+ // The list is drawn twice the size it is laid out at.
103
+ Object.defineProperty(wrapper, "getBoundingClientRect", {
104
+ configurable: true,
105
+ value: () => ({ left: 0, top: 0, right: 400, bottom: 200, width: 400, height: 200, x: 0, y: 0 }) as DOMRect,
106
+ });
107
+ Object.defineProperty(wrapper, "offsetWidth", { configurable: true, get: () => 200 });
108
+ Object.defineProperty(wrapper, "offsetHeight", { configurable: true, get: () => 100 });
109
+ const instance = (wrapper as unknown as { __vueParentComponent?: { exposed?: Record<string, unknown> } })
110
+ .__vueParentComponent?.exposed;
111
+ (instance!.measure as () => void)();
112
+ // 61.4 drawn are 30.7 of the column's own: ceil 31, plus the 1px headroom.
113
+ expect(wrapper.style.getPropertyValue("--auth-methods-label-width")).toBe("32px");
114
+ });
115
+
92
116
  it("leaves the column fallback intact when no text metrics are available", async () => {
93
117
  const c = mount(h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { methods })));
94
118
  await nextTick();
@@ -1,5 +1,6 @@
1
1
  import { defineComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
2
2
  import HkButton from "./HkButton";
3
+ import { drawnScale } from "../composables/layoutGeometry";
3
4
  import "./HkAuthMethodList.scss";
4
5
 
5
6
  /**
@@ -81,10 +82,16 @@ export default defineComponent({
81
82
  const root = listRef.value;
82
83
  if (!root) return;
83
84
  let widest = 0;
85
+ // The labels are measured where they are DRAWN, and the width is
86
+ // published as a CSS length the column is laid out with: in a scaled or
87
+ // zoomed root the two differ by the scale of the space, so the column
88
+ // would come out that many times too wide (see `layoutGeometry`).
89
+ const space = drawnScale(root);
90
+ const factor = root.offsetWidth > 0 ? space.x : root.offsetHeight > 0 ? space.y : 1;
84
91
  for (const label of root.querySelectorAll<HTMLElement>(".s-auth-methods-label")) {
85
92
  const previous = label.style.width;
86
93
  label.style.width = "max-content";
87
- widest = Math.max(widest, label.getBoundingClientRect().width);
94
+ widest = Math.max(widest, label.getBoundingClientRect().width / (factor > 0 ? factor : 1));
88
95
  label.style.width = previous;
89
96
  }
90
97
  // Guard: only publish when real text metrics were observed, so a
@@ -0,0 +1,136 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick } from "vue";
3
+
4
+ import HkBoard from "./HkBoard";
5
+
6
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
7
+ const restores: Array<() => void> = [];
8
+
9
+ afterEach(() => {
10
+ for (const restore of restores.splice(0)) restore();
11
+ for (const m of mounts.splice(0)) {
12
+ m.app.unmount();
13
+ m.container.remove();
14
+ }
15
+ });
16
+
17
+ /** The board's camera lives in the board's OWN pixels: the world transform is
18
+ * written in them. */
19
+ function worldTransform(container: HTMLElement): { x: number; y: number; k: number } {
20
+ const world = container.querySelector<HTMLElement>(".hk-board-world")!;
21
+ const m = /translate\((-?[\d.]+)px,\s*(-?[\d.]+)px\)\s*scale\(([\d.]+)\)/.exec(
22
+ world.style.transform,
23
+ );
24
+ if (!m) throw new Error(`unexpected world transform: ${world.style.transform}`);
25
+ return { x: parseFloat(m[1]), y: parseFloat(m[2]), k: parseFloat(m[3]) };
26
+ }
27
+
28
+ function pointer(type: string, id: number, x: number, y: number): PointerEvent {
29
+ return new PointerEvent(type, { bubbles: true, pointerId: id, pointerType: "touch", clientX: x, clientY: y });
30
+ }
31
+
32
+ async function mountBoard() {
33
+ // The board reads its viewport size at mount (clientWidth/Height, i.e. the
34
+ // layout box): pin it on the prototype for the duration so the camera's
35
+ // clamp has a real viewport to work with, the way a laid-out page would.
36
+ const proto = HTMLElement.prototype as unknown as {
37
+ clientWidth: number;
38
+ clientHeight: number;
39
+ };
40
+ const beforeW = Object.getOwnPropertyDescriptor(proto, "clientWidth");
41
+ const beforeH = Object.getOwnPropertyDescriptor(proto, "clientHeight");
42
+ Object.defineProperty(proto, "clientWidth", {
43
+ configurable: true,
44
+ get(this: HTMLElement) {
45
+ return this.classList?.contains("hk-board") ? 800 : 0;
46
+ },
47
+ });
48
+ Object.defineProperty(proto, "clientHeight", {
49
+ configurable: true,
50
+ get(this: HTMLElement) {
51
+ return this.classList?.contains("hk-board") ? 600 : 0;
52
+ },
53
+ });
54
+ restores.push(() => {
55
+ if (beforeW) Object.defineProperty(proto, "clientWidth", beforeW);
56
+ else delete (proto as { clientWidth?: number }).clientWidth;
57
+ if (beforeH) Object.defineProperty(proto, "clientHeight", beforeH);
58
+ else delete (proto as { clientHeight?: number }).clientHeight;
59
+ });
60
+ const container = document.createElement("div");
61
+ document.body.appendChild(container);
62
+ const app = createApp(
63
+ defineComponent({
64
+ setup: () => () =>
65
+ h(HkBoard, {
66
+ // Content far larger than the viewport, so the camera's clamp lets
67
+ // it pan instead of re-centring a board that already fits.
68
+ nodes: [
69
+ { id: "a", x: 0, y: 0, w: 200, h: 120, label: "A" },
70
+ { id: "b", x: 3000, y: 3000, w: 200, h: 120, label: "B" },
71
+ ],
72
+ fitOnMount: false,
73
+ // No tween: the assertions read the camera right after the event.
74
+ animated: false,
75
+ grid: false,
76
+ minimap: false,
77
+ }),
78
+ }),
79
+ );
80
+ app.mount(container);
81
+ mounts.push({ app, container });
82
+ await nextTick();
83
+ const viewport = container.querySelector<HTMLElement>(".hk-board")!;
84
+ // The board is drawn twice the size it is laid out at, and its own box
85
+ // reports the layout size — the shape a scaled or zoomed root produces.
86
+ Object.defineProperty(viewport, "getBoundingClientRect", {
87
+ configurable: true,
88
+ value: () => ({ left: 0, top: 0, right: 1600, bottom: 1200, width: 1600, height: 1200, x: 0, y: 0 }) as DOMRect,
89
+ });
90
+ Object.defineProperty(viewport, "offsetWidth", { configurable: true, get: () => 800 });
91
+ Object.defineProperty(viewport, "offsetHeight", { configurable: true, get: () => 600 });
92
+ Object.defineProperty(viewport, "clientWidth", { configurable: true, get: () => 800 });
93
+ Object.defineProperty(viewport, "clientHeight", { configurable: true, get: () => 600 });
94
+ // happy-dom has no pointer capture: the board captures every finger, and a
95
+ // throw there would end the gesture before it starts.
96
+ Object.defineProperty(viewport, "setPointerCapture", { configurable: true, value: () => {} });
97
+ Object.defineProperty(viewport, "releasePointerCapture", { configurable: true, value: () => {} });
98
+ Object.defineProperty(viewport, "hasPointerCapture", { configurable: true, value: () => true });
99
+ return { container, viewport };
100
+ }
101
+
102
+ describe("HkBoard scaled-space gestures", () => {
103
+ it("pans by the finger's own distance inside a scaled root", async () => {
104
+ const { container, viewport } = await mountBoard();
105
+ const before = worldTransform(container);
106
+
107
+ viewport.dispatchEvent(pointer("pointerdown", 1, 1600, 1600));
108
+ viewport.dispatchEvent(pointer("pointermove", 1, 1520, 1540));
109
+ await nextTick();
110
+
111
+ const after = worldTransform(container);
112
+ expect(after.x, "-80 drawn px are -40 of the board's own").toBeCloseTo(before.x - 40, 4);
113
+ expect(after.y, "-60 drawn px are -30 of the board's own").toBeCloseTo(before.y - 30, 4);
114
+ viewport.dispatchEvent(pointer("pointerup", 1, 1520, 1540));
115
+ });
116
+
117
+ it("keeps a zoom anchored under the pointer inside a scaled root", async () => {
118
+ const { container, viewport } = await mountBoard();
119
+ const wheel = new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -1 });
120
+ Object.defineProperty(wheel, "clientX", { value: 800 });
121
+ Object.defineProperty(wheel, "clientY", { value: 600 });
122
+ // The pointer sits at the board's own (400, 300) — half the layout box.
123
+ const anchor = { x: 400, y: 300 };
124
+ const before = worldTransform(container);
125
+ const world = {
126
+ x: (anchor.x - before.x) / before.k,
127
+ y: (anchor.y - before.y) / before.k,
128
+ };
129
+ viewport.dispatchEvent(wheel);
130
+ await nextTick();
131
+ const after = worldTransform(container);
132
+ expect(after.k).toBeGreaterThan(before.k);
133
+ expect(after.x + world.x * after.k, "the world point stays under the pointer").toBeCloseTo(anchor.x, 3);
134
+ expect(after.y + world.y * after.k).toBeCloseTo(anchor.y, 3);
135
+ });
136
+ });
@@ -59,6 +59,7 @@ import {
59
59
  type BoardEdgeStyle,
60
60
  } from "../utils/boardEdges";
61
61
  import "./HkBoard.scss";
62
+ import { drawnScale } from "../composables/layoutGeometry";
62
63
 
63
64
  /**
64
65
  * A node on the board. `hidden` nodes are junction-only (invisible).
@@ -211,9 +212,23 @@ export default defineComponent({
211
212
  // — SCADA scenes, mind maps — still get `nodeClick` on a short tap.
212
213
  let pressed: { id: string; pointerId: number; x: number; y: number } | null = null;
213
214
 
215
+ /** How many of the board's own pixels one DRAWN pixel is worth: the
216
+ * camera and every anchor live in the board's local CSS pixels while the
217
+ * pointer arrives in drawn ones, and a scaled or zoomed root separates
218
+ * the two (see `layoutGeometry`). */
219
+ const pxScale = (): number => {
220
+ const el = viewportRef.value;
221
+ if (!el) return 1;
222
+ const scale = drawnScale(el);
223
+ const factor = el.offsetWidth > 0 ? scale.x : el.offsetHeight > 0 ? scale.y : 1;
224
+ return factor > 0 ? factor : 1;
225
+ };
226
+
214
227
  const localPoint = (e: PointerEvent | WheelEvent): BoardPoint => {
215
- const rect = viewportRef.value?.getBoundingClientRect();
216
- return { x: e.clientX - (rect?.left ?? 0), y: e.clientY - (rect?.top ?? 0) };
228
+ const el = viewportRef.value;
229
+ const rect = el?.getBoundingClientRect();
230
+ const k = pxScale();
231
+ return { x: (e.clientX - (rect?.left ?? 0)) / k, y: (e.clientY - (rect?.top ?? 0)) / k };
217
232
  };
218
233
 
219
234
  function onWheel(e: WheelEvent): void {
@@ -237,14 +252,10 @@ export default defineComponent({
237
252
  function rearmPinch(): void {
238
253
  const [a, b] = [...pointers.values()];
239
254
  if (!a || !b) return;
240
- const rect = viewportRef.value?.getBoundingClientRect();
241
255
  pinchBase = {
242
256
  cam: { ...camera.value },
243
257
  dist: Math.hypot(a.x - b.x, a.y - b.y) || 1,
244
- mid: {
245
- x: (a.x + b.x) / 2 - (rect?.left ?? 0),
246
- y: (a.y + b.y) / 2 - (rect?.top ?? 0),
247
- },
258
+ mid: localPoint({ clientX: (a.x + b.x) / 2, clientY: (a.y + b.y) / 2 } as PointerEvent),
248
259
  };
249
260
  }
250
261
 
@@ -273,17 +284,18 @@ export default defineComponent({
273
284
  if (pinchBase && pointers.size >= 2) {
274
285
  const [a, b] = [...pointers.values()];
275
286
  const dist = Math.hypot(a.x - b.x, a.y - b.y) || 1;
276
- const rect = viewportRef.value?.getBoundingClientRect();
277
- const mid = {
278
- x: (a.x + b.x) / 2 - (rect?.left ?? 0),
279
- y: (a.y + b.y) / 2 - (rect?.top ?? 0),
280
- };
287
+ const mid = localPoint({
288
+ clientX: (a.x + b.x) / 2,
289
+ clientY: (a.y + b.y) / 2,
290
+ } as PointerEvent);
281
291
  pinchBase.mid = mid;
282
292
  cam.pinchZoom(pinchBase.cam, dist / pinchBase.dist, mid);
283
293
  return;
284
294
  }
285
295
  if (dragState) {
286
- const k = camera.value.k || 1;
296
+ // The node lives in WORLD units: the drawn delta comes down through
297
+ // the ancestor scale and then through the camera's own zoom.
298
+ const k = (camera.value.k || 1) * pxScale();
287
299
  const dx = (e.clientX - dragState.sx) / k;
288
300
  const dy = (e.clientY - dragState.sy) / k;
289
301
  if (Math.abs(e.clientX - dragState.sx) + Math.abs(e.clientY - dragState.sy) > 3) dragState.moved = true;
@@ -293,7 +305,8 @@ export default defineComponent({
293
305
  return;
294
306
  }
295
307
  if (panState) {
296
- cam.panBy(e.clientX - panState.px, e.clientY - panState.py);
308
+ const k = pxScale();
309
+ cam.panBy((e.clientX - panState.px) / k, (e.clientY - panState.py) / k);
297
310
  panState = { px: e.clientX, py: e.clientY };
298
311
  }
299
312
  }
@@ -2,6 +2,7 @@ import { computed, defineComponent, onBeforeUnmount, onMounted, ref, watch } fro
2
2
 
3
3
  import HSpinner from "./HkSpinner";
4
4
  import HMinimap from "./HkMinimap";
5
+ import { drawnScale } from "../composables/layoutGeometry";
5
6
  import "./HkImageViewer.scss";
6
7
 
7
8
  /**
@@ -57,6 +58,29 @@ export default defineComponent({
57
58
  return c ? { cw: c.clientWidth, ch: c.clientHeight } : { cw: 0, ch: 0 };
58
59
  }
59
60
 
61
+ /** How many of the container's own pixels one DRAWN pixel is worth.
62
+ * The camera (`panX`/`panY`/`zoom`) lives in the container's local CSS
63
+ * pixels while the pointer arrives in drawn ones, and a scaled or zoomed
64
+ * root makes the two differ — feeding a drawn delta straight into the
65
+ * camera moves the image `k` times as far as the finger went. */
66
+ function pxScale() {
67
+ const el = containerRef.value;
68
+ if (!el) return 1;
69
+ const scale = drawnScale(el);
70
+ const factor = el.offsetWidth > 0 ? scale.x : el.offsetHeight > 0 ? scale.y : 1;
71
+ return factor > 0 ? factor : 1;
72
+ }
73
+
74
+ /** A pointer position inside the container, in the container's own
75
+ * pixels (the space every anchor and the camera are written in). */
76
+ function localPoint(e: { clientX: number; clientY: number }) {
77
+ const el = containerRef.value;
78
+ if (!el) return { x: 0, y: 0 };
79
+ const rect = el.getBoundingClientRect();
80
+ const factor = pxScale();
81
+ return { x: (e.clientX - rect.left) / factor, y: (e.clientY - rect.top) / factor };
82
+ }
83
+
60
84
  function recomputeFit() {
61
85
  const { cw, ch } = dims();
62
86
  if (!imgW.value || !imgH.value || !cw || !ch) return;
@@ -115,11 +139,9 @@ export default defineComponent({
115
139
  e.preventDefault();
116
140
  const el = containerRef.value;
117
141
  if (!el) return;
118
- const rect = el.getBoundingClientRect();
119
- const cx = e.clientX - rect.left;
120
- const cy = e.clientY - rect.top;
142
+ const at = localPoint(e);
121
143
  const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
122
- setZoom(zoom.value * factor, cx, cy);
144
+ setZoom(zoom.value * factor, at.x, at.y);
123
145
  }
124
146
 
125
147
  /** Euclidean distance between two pointer positions. */
@@ -172,19 +194,19 @@ export default defineComponent({
172
194
  if (!pair || !el) return;
173
195
  // Anchor the zoom at the fingers' midpoint (container-local
174
196
  // coordinates, same space as the wheel/dblclick anchors).
175
- const rect = el.getBoundingClientRect();
176
197
  const dist = Math.max(pointerDist(pair[0], pair[1]), 1);
177
- setZoom(
178
- pinchStartZoom * (dist / pinchStartDist),
179
- (pair[0].x + pair[1].x) / 2 - rect.left,
180
- (pair[0].y + pair[1].y) / 2 - rect.top,
181
- );
198
+ const mid = localPoint({
199
+ clientX: (pair[0].x + pair[1].x) / 2,
200
+ clientY: (pair[0].y + pair[1].y) / 2,
201
+ });
202
+ setZoom(pinchStartZoom * (dist / pinchStartDist), mid.x, mid.y);
182
203
  return;
183
204
  }
184
205
 
185
206
  if (!dragging.value) return;
186
- const dx = e.clientX - lastPt.value.x;
187
- const dy = e.clientY - lastPt.value.y;
207
+ const factor = pxScale();
208
+ const dx = (e.clientX - lastPt.value.x) / factor;
209
+ const dy = (e.clientY - lastPt.value.y) / factor;
188
210
  lastPt.value = { x: e.clientX, y: e.clientY };
189
211
  panX.value += dx;
190
212
  panY.value += dy;
@@ -235,12 +257,11 @@ export default defineComponent({
235
257
  if (!loaded.value) return;
236
258
  const el = containerRef.value;
237
259
  if (!el) return;
238
- const rect = el.getBoundingClientRect();
239
- const cx = e.clientX - rect.left;
240
- const cy = e.clientY - rect.top;
241
- setZoom(isZoomed.value ? fit.value : Math.min(MAX_ZOOM, fit.value * 3), cx, cy);
260
+ const at = localPoint(e);
261
+ setZoom(isZoomed.value ? fit.value : Math.min(MAX_ZOOM, fit.value * 3), at.x, at.y);
242
262
  }
243
263
 
264
+ /** Panned by another surface (the minimap), in this camera's own units. */
244
265
  function onPanDelta(dx: number, dy: number) {
245
266
  panX.value += dx;
246
267
  panY.value += dy;
@@ -100,6 +100,63 @@ describe("HkImageViewer touch gestures", () => {
100
100
  expect(after.scale).toBe(before.scale);
101
101
  });
102
102
 
103
+ it("pans by the finger's own distance inside a scaled root", async () => {
104
+ // The camera lives in the container's local pixels while the pointer
105
+ // arrives in drawn ones. Inside a scaled or zoomed root (chest's display
106
+ // scale reaches 300%) the image would otherwise travel k times as far as
107
+ // the finger — it slips out from under the pointer.
108
+ const s = await mountViewer();
109
+ const c = s.container();
110
+ // The container is drawn twice the size it is laid out at.
111
+ Object.defineProperty(c, "getBoundingClientRect", {
112
+ configurable: true,
113
+ value: () => ({ left: 0, top: 0, right: 800, bottom: 600, width: 800, height: 600, x: 0, y: 0 }) as DOMRect,
114
+ });
115
+ Object.defineProperty(c, "offsetWidth", { configurable: true, get: () => 400 });
116
+ Object.defineProperty(c, "offsetHeight", { configurable: true, get: () => 300 });
117
+
118
+ const before = s.transform();
119
+ c.dispatchEvent(pointer("pointerdown", 1, 100, 100));
120
+ c.dispatchEvent(pointer("pointermove", 1, 140, 160));
121
+ await nextTick();
122
+
123
+ const after = s.transform();
124
+ expect(after.tx, "40 drawn px are 20 of the container's own").toBeCloseTo(before.tx + 20, 5);
125
+ expect(after.ty, "60 drawn px are 30 of the container's own").toBeCloseTo(before.ty + 30, 5);
126
+ });
127
+
128
+ it("anchors a wheel zoom where the pointer is, inside a scaled root", async () => {
129
+ const s = await mountViewer();
130
+ const c = s.container();
131
+ Object.defineProperty(c, "getBoundingClientRect", {
132
+ configurable: true,
133
+ value: () => ({ left: 0, top: 0, right: 800, bottom: 600, width: 800, height: 600, x: 0, y: 0 }) as DOMRect,
134
+ });
135
+ Object.defineProperty(c, "offsetWidth", { configurable: true, get: () => 400 });
136
+ Object.defineProperty(c, "offsetHeight", { configurable: true, get: () => 300 });
137
+
138
+ // The point under the pointer must stay under it: the container-local
139
+ // coordinate of the pointer is (200, 150) — half of the layout box — and
140
+ // the anchor has to be that, not the drawn (400, 300).
141
+ const anchor = { x: 200, y: 150 };
142
+ const before = s.transform();
143
+ const worldBefore = {
144
+ x: (anchor.x - before.tx) / before.scale,
145
+ y: (anchor.y - before.ty) / before.scale,
146
+ };
147
+ // happy-dom's WheelEvent carries no pointer coordinates: pin them by hand
148
+ // so the anchor has a position to work from.
149
+ const wheel = new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -1 });
150
+ Object.defineProperty(wheel, "clientX", { value: 400 });
151
+ Object.defineProperty(wheel, "clientY", { value: 300 });
152
+ c.dispatchEvent(wheel);
153
+ await nextTick();
154
+ const after = s.transform();
155
+ expect(after.scale).toBeGreaterThan(before.scale);
156
+ expect(after.tx + worldBefore.x * after.scale, "the world point stays under the pointer").toBeCloseTo(anchor.x, 4);
157
+ expect(after.ty + worldBefore.y * after.scale).toBeCloseTo(anchor.y, 4);
158
+ });
159
+
103
160
  it("a stray move without an active pointer never pans", async () => {
104
161
  const s = await mountViewer();
105
162
  const c = s.container();
@@ -0,0 +1,78 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, defineComponent, h } from "vue";
3
+
4
+ import HkMinimap from "./HkMinimap";
5
+
6
+ const mounts: ReturnType<typeof createApp>[] = [];
7
+ const containers: HTMLElement[] = [];
8
+
9
+ afterEach(() => {
10
+ for (const app of mounts.splice(0)) app.unmount();
11
+ for (const el of containers.splice(0)) el.remove();
12
+ });
13
+
14
+ /** Mount the overview map and drag it: the emitted pan says how far the scene
15
+ * should move for a pointer that travelled `drawn` pixels. */
16
+ function drag(drawnWidth: number): { dx: number; dy: number } {
17
+ const container = document.createElement("div");
18
+ document.body.appendChild(container);
19
+ containers.push(container);
20
+ const deltas: Array<[number, number]> = [];
21
+ const Host = defineComponent({
22
+ setup() {
23
+ return () =>
24
+ h(HkMinimap, {
25
+ zoom: 1,
26
+ zoomPercent: 100,
27
+ panX: 0,
28
+ panY: 0,
29
+ viewportWidth: 400,
30
+ viewportHeight: 300,
31
+ boxes: [{ id: "a", bounds: { x: 0, y: 0, w: 100, h: 100 }, color: "red" }],
32
+ onPanDelta: (dx: number, dy: number) => deltas.push([dx, dy]),
33
+ });
34
+ },
35
+ });
36
+ const app = createApp(Host);
37
+ mounts.push(app);
38
+ app.mount(container);
39
+
40
+ const root = container.querySelector<HTMLElement>(".hk-minimap")!;
41
+ const svg = root.querySelector<SVGSVGElement>("svg.hk-minimap-svg")!;
42
+ // The map's viewBox is 160 units wide; the card draws it at `drawnWidth`.
43
+ Object.defineProperty(svg, "getBoundingClientRect", {
44
+ configurable: true,
45
+ value: () =>
46
+ ({ left: 0, top: 0, right: drawnWidth, bottom: drawnWidth * (110 / 160), width: drawnWidth, height: drawnWidth * (110 / 160), x: 0, y: 0 }) as DOMRect,
47
+ });
48
+ Object.defineProperty(root, "setPointerCapture", { configurable: true, value: () => {} });
49
+ Object.defineProperty(root, "releasePointerCapture", { configurable: true, value: () => {} });
50
+
51
+ const at = (type: string, x: number, y: number) =>
52
+ root.dispatchEvent(
53
+ new PointerEvent(type, { bubbles: true, pointerId: 1, pointerType: "mouse", clientX: x, clientY: y }),
54
+ );
55
+ at("pointerdown", 100, 100);
56
+ at("pointermove", 140, 120);
57
+ at("pointerup", 140, 120);
58
+ expect(deltas, "the drag emitted a pan").toHaveLength(1);
59
+ return { dx: deltas[0][0], dy: deltas[0][1] };
60
+ }
61
+
62
+ describe("HkMinimap scaled-space pan", () => {
63
+ it("scales the pan by the map's own drawn size", () => {
64
+ // The map is drawn into a card that is not necessarily as wide as the
65
+ // SVG's viewBox — and, in a scaled or zoomed root, is drawn wider still.
66
+ // The pointer's drawn pixels have to come down through that factor, or a
67
+ // drag moves the scene further than the pointer went (and at k = 3 the
68
+ // scene runs away from the cursor entirely).
69
+ const normal = drag(160); // 1 drawn px = 1 user unit
70
+ const wide = drag(320); // 1 drawn px = half a unit
71
+ expect(Math.abs(wide.dx), "half the travel for the same pointer").toBeCloseTo(
72
+ Math.abs(normal.dx) / 2,
73
+ 6,
74
+ );
75
+ expect(Math.abs(wide.dy)).toBeCloseTo(Math.abs(normal.dy) / 2, 6);
76
+ expect(normal.dx, "the scene follows the pointer the other way").toBeLessThan(0);
77
+ });
78
+ });
@@ -5,6 +5,7 @@ import { computed, defineComponent, onBeforeUnmount, onMounted, ref, type PropTy
5
5
 
6
6
 
7
7
  import { useI18n } from "../i18n/context";
8
+ import { layoutOffset } from "../composables/layoutGeometry";
8
9
  import "./HkMinimap.scss";
9
10
 
10
11
  export interface MinimapBox {
@@ -158,7 +159,21 @@ export default defineComponent({
158
159
  const dy = e.clientY - dragStart.value.y;
159
160
  dragStart.value = { x: e.clientX, y: e.clientY };
160
161
  if (scale.value > 0) {
161
- emit("panDelta", (-dx / scale.value) * props.zoom, (-dy / scale.value) * props.zoom);
162
+ // The pointer moves in DRAWN pixels while the map is drawn into a
163
+ // card that is not necessarily as wide as the SVG's viewBox (and, in
164
+ // a scaled or zoomed root, is drawn wider still). One drawn pixel is
165
+ // `viewBox / rect.width` user units, so the delta is converted before
166
+ // it is turned into a pan.
167
+ const host = rootRef.value;
168
+ const svg =
169
+ host instanceof SVGElement ? host : host?.querySelector("svg") ?? host ?? null;
170
+ const rect = svg?.getBoundingClientRect();
171
+ const units = rect && rect.width > 0 ? svgW / rect.width : 1;
172
+ emit(
173
+ "panDelta",
174
+ (-dx * units / scale.value) * props.zoom,
175
+ (-dy * units / scale.value) * props.zoom,
176
+ );
162
177
  }
163
178
  }
164
179
  function onUp(e: PointerEvent) {
@@ -278,4 +278,52 @@ describe("HkPasswordInput placeholder layers on Tab focus", () => {
278
278
  expect(staticText).toBeTruthy();
279
279
  expect(marqueeText).toBe(staticText);
280
280
  });
281
+
282
+ /** happy-dom's own ResizeObserver never fires (no layout engine): this one
283
+ * keeps the callbacks so a test can run the measurement deterministically. */
284
+ class FakeResizeObserver {
285
+ static instances: FakeResizeObserver[] = [];
286
+ callback: () => void;
287
+ constructor(callback: () => void) {
288
+ this.callback = callback;
289
+ FakeResizeObserver.instances.push(this);
290
+ }
291
+ observe(): void {}
292
+ disconnect(): void {}
293
+ }
294
+
295
+ describe("HkPasswordInput strength dots in a scaled root", () => {
296
+ it("sizes the dot canvas in the box's own units", () => {
297
+ const original = globalThis.ResizeObserver;
298
+ FakeResizeObserver.instances = [];
299
+ globalThis.ResizeObserver = FakeResizeObserver as unknown as typeof ResizeObserver;
300
+ try {
301
+ // The canvas is CSS-sized to its box (`inset: 0`) while its backing store
302
+ // is drawn from the box's DRAWN size: in a scaled or zoomed root the grid
303
+ // came out k times denser and every dot k times smaller.
304
+ const m = mountPasswordInput("hunter2", { showStrengthDots: true });
305
+ const box = m.container.querySelector<HTMLElement>(".hk-pwd-box")!;
306
+ const canvas = m.container.querySelector<HTMLCanvasElement>(".hk-pwd-dots")!;
307
+ expect(box && canvas).toBeTruthy();
308
+ // The box is drawn twice the size it is laid out at.
309
+ Object.defineProperty(box, "getBoundingClientRect", {
310
+ configurable: true,
311
+ value: () => ({ left: 0, top: 0, right: 800, bottom: 200, width: 800, height: 200, x: 0, y: 0 }) as DOMRect,
312
+ });
313
+ Object.defineProperty(box, "offsetWidth", { configurable: true, get: () => 400 });
314
+ Object.defineProperty(box, "offsetHeight", { configurable: true, get: () => 100 });
315
+ const dpr = window.devicePixelRatio || 1;
316
+ // Re-run the measurement the box's observer would run.
317
+ const observers = FakeResizeObserver.instances.filter((o) => o.callback);
318
+ expect(observers.length, "the box is observed").toBeGreaterThan(0);
319
+ observers.forEach((o) => o.callback());
320
+ expect(canvas.width, "400 of the box's own pixels, not the drawn 800").toBe(
321
+ Math.round(400 * dpr),
322
+ );
323
+ } finally {
324
+ globalThis.ResizeObserver = original;
325
+ }
326
+ });
327
+ });
328
+
281
329
  });
@@ -17,6 +17,7 @@ import { scheduleInterval, type IntervalHandle } from "../runtime/intervalBus";
17
17
  import HListTransition from "./HkListTransition";
18
18
  import { HkPlaceholderMarquee, type PlaceholderVariant } from "./HkPlaceholderMarquee";
19
19
  import "./HkPasswordInput.scss";
20
+ import { drawnScale } from "../composables/layoutGeometry";
20
21
 
21
22
  interface Ripple {
22
23
  radius: number;
@@ -232,7 +233,14 @@ export default defineComponent({
232
233
  const cv = dotCanvasRef.value;
233
234
  const bx = boxRef.value;
234
235
  if (!cv || !bx) return;
235
- const { width, height } = bx.getBoundingClientRect();
236
+ // The canvas box is sized in the element's own units (`inset: 0`), so
237
+ // its backing store has to be too: taking the DRAWN box would make the
238
+ // dot grid k times denser inside a scaled or zoomed root (see
239
+ // `layoutGeometry`).
240
+ const drawn = bx.getBoundingClientRect();
241
+ const scale = drawnScale(bx);
242
+ const width = bx.offsetWidth > 0 ? drawn.width / (scale.x > 0 ? scale.x : 1) : drawn.width;
243
+ const height = bx.offsetHeight > 0 ? drawn.height / (scale.y > 0 ? scale.y : 1) : drawn.height;
236
244
  cv.width = width * dpr;
237
245
  cv.height = height * dpr;
238
246
  const usable = width * 0.8;
@@ -1,4 +1,4 @@
1
- import { afterEach, describe, expect, it } from "vitest";
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { createApp, defineComponent, h, nextTick, reactive, ref } from "vue";
3
3
 
4
4
  import HkScrollContainer from "./HkScrollContainer";
@@ -16,6 +16,7 @@ interface Mounted {
16
16
  } | null;
17
17
  }
18
18
 
19
+ const scrolled: number[] = [];
19
20
  const mounts: Mounted["app"][] = [];
20
21
  const containers: HTMLElement[] = [];
21
22
 
@@ -63,6 +64,52 @@ afterEach(() => {
63
64
  });
64
65
 
65
66
  describe("HkScrollContainer alignment", () => {
67
+ it("scrolls to an element by its laid-out distance, inside a scaled root", () => {
68
+ // The target is compared against `scrollTop`, a layout number: measured
69
+ // from drawn boxes it overshoots by (scale - 1) of the element's distance
70
+ // from the visible top, and in a zoomed root that walks the element right
71
+ // out of view.
72
+ const m = mountScroller();
73
+ stubGeometry(m.viewport, {
74
+ scrollHeight: 2000, clientHeight: 400, scrollTop: 100,
75
+ scrollWidth: 300, clientWidth: 300, scrollLeft: 0,
76
+ });
77
+ Object.defineProperty(m.viewport, "scrollTo", {
78
+ configurable: true,
79
+ value: (opts: { top: number }) => {
80
+ scrolled.push(opts.top);
81
+ },
82
+ });
83
+ // The viewport is drawn twice the size it is laid out at.
84
+ const drawn = vi.spyOn(m.viewport, "getBoundingClientRect").mockReturnValue({
85
+ left: 0, top: 0, right: 800, bottom: 800, width: 800, height: 800, x: 0, y: 0,
86
+ } as DOMRect);
87
+ Object.defineProperty(m.viewport, "offsetWidth", { configurable: true, get: () => 400 });
88
+ Object.defineProperty(m.viewport, "offsetHeight", { configurable: true, get: () => 400 });
89
+ void drawn;
90
+
91
+ // The element sits 150 of the viewport's own pixels below its top edge...
92
+ const target = document.createElement("div");
93
+ m.viewport.appendChild(target);
94
+ Object.defineProperty(target, "offsetParent", { configurable: true, get: () => m.viewport });
95
+ Object.defineProperty(target, "offsetLeft", { configurable: true, get: () => 0 });
96
+ Object.defineProperty(target, "offsetTop", { configurable: true, get: () => 150 });
97
+ vi.spyOn(target, "getBoundingClientRect").mockReturnValue({
98
+ left: 0, top: 600, right: 100, bottom: 700, width: 100, height: 100, x: 0, y: 600,
99
+ } as DOMRect);
100
+ // ...while the VIEWPORT itself sits 310 of its own pixels down the page,
101
+ // which the scroll target must not carry: an offset sum walked all the way
102
+ // to the root would ask to scroll 460 and land the element past the top.
103
+ const above = document.createElement("div");
104
+ above.appendChild(m.viewport);
105
+ Object.defineProperty(m.viewport, "offsetParent", { configurable: true, get: () => above });
106
+ Object.defineProperty(m.viewport, "offsetTop", { configurable: true, get: () => 310 });
107
+ Object.defineProperty(above, "offsetParent", { configurable: true, get: () => null });
108
+ Object.defineProperty(above, "offsetTop", { configurable: true, get: () => 0 });
109
+
110
+ (m.instance as unknown as { scrollToElement: (el: HTMLElement) => void }).scrollToElement(target);
111
+ expect(scrolled.at(-1), "150 of the viewport's own pixels, not the drawn 300 nor the page offset 460").toBe(150);
112
+ });
66
113
  it("renders the slot bare without an align prop", () => {
67
114
  const m = mountScroller();
68
115
  expect(m.root.hasAttribute("data-align")).toBe(false);
@@ -10,6 +10,7 @@ import {
10
10
  } from "vue";
11
11
 
12
12
  import { useI18n } from "../i18n/context";
13
+ import { laidOffsetWithin } from "../composables/layoutGeometry";
13
14
  import "./HkScrollContainer.scss";
14
15
  import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
15
16
  import { useApproachEnd, type ApproachEndHandle } from "../composables/useApproachEnd";
@@ -357,7 +358,17 @@ export default defineComponent({
357
358
  el.scrollIntoView({ behavior, block: "start" });
358
359
  return;
359
360
  }
360
- const target = el.getBoundingClientRect().top - vp.getBoundingClientRect().top + vp.scrollTop;
361
+ // Both ends in the LAYOUT space the scroll offset lives in: the drawn
362
+ // boxes would carry the scale of whatever root this sits in, and the
363
+ // target would overshoot by (scale - 1) of the element's distance from
364
+ // the visible top. The offset is measured INSIDE the viewport — a raw
365
+ // `layoutOffset` sum runs on to the document root, which lands the scroll
366
+ // past the element by however far the viewport itself sits down the page
367
+ // (see `laidOffsetWithin`).
368
+ const inside = laidOffsetWithin(el, vp);
369
+ const target = Number.isFinite(inside.y)
370
+ ? inside.y
371
+ : el.getBoundingClientRect().top - vp.getBoundingClientRect().top + vp.scrollTop;
361
372
  vp.scrollTo({ top: target, behavior });
362
373
  }
363
374
 
@@ -0,0 +1,107 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
+
4
+ import HkWindowedItem from "./HkWindowedItem";
5
+ import { provideScrollWindow } from "../composables/useScrollWindow";
6
+
7
+ const SCREEN = 400; // laid-out size of the scroll root, in its own units
8
+ const ITEM = 40;
9
+
10
+ const apps: ReturnType<typeof createApp>[] = [];
11
+ let originalRect: typeof HTMLElement.prototype.getBoundingClientRect;
12
+ /** Drawn (client) geometry of the windowed item, in drawn pixels. */
13
+ let itemDrawn = { top: 0, height: ITEM };
14
+
15
+ function rectOf(left: number, top: number, width: number, height: number): DOMRect {
16
+ return {
17
+ x: left,
18
+ y: top,
19
+ left,
20
+ top,
21
+ right: left + width,
22
+ bottom: top + height,
23
+ width,
24
+ height,
25
+ toJSON: () => ({}),
26
+ } as DOMRect;
27
+ }
28
+
29
+ beforeEach(() => {
30
+ originalRect = HTMLElement.prototype.getBoundingClientRect;
31
+ // The component's own root is created inside the mount, so its drawn box is
32
+ // scripted per element class rather than per instance.
33
+ HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement) {
34
+ if (this.classList?.contains("hk-windowed-item")) {
35
+ return rectOf(0, itemDrawn.top, 200, itemDrawn.height);
36
+ }
37
+ return originalRect.call(this);
38
+ };
39
+ (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver = class {
40
+ observe(): void {}
41
+ disconnect(): void {}
42
+ } as unknown as typeof IntersectionObserver;
43
+ });
44
+
45
+ afterEach(() => {
46
+ HTMLElement.prototype.getBoundingClientRect = originalRect;
47
+ for (const app of apps.splice(0)) app.unmount();
48
+ document.body.innerHTML = "";
49
+ delete (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver;
50
+ });
51
+
52
+ /**
53
+ * Mount one windowed item against a scroll root whose drawn box is `scale`
54
+ * times its laid-out box (zoom on `documentElement`, a transformed ancestor, or
55
+ * a scoped `zoom` all produce this), with the item laid out `laidTop` units
56
+ * below the root's top. Returns whether the item mounted real content or the
57
+ * placeholder.
58
+ */
59
+ async function dataState(laidTop: number, scale: number): Promise<string | null> {
60
+ const rootEl = document.createElement("div");
61
+ document.body.appendChild(rootEl);
62
+ Object.defineProperty(rootEl, "offsetWidth", { configurable: true, value: SCREEN });
63
+ Object.defineProperty(rootEl, "offsetHeight", { configurable: true, value: SCREEN });
64
+ Object.defineProperty(rootEl, "clientHeight", { configurable: true, value: SCREEN });
65
+ Object.defineProperty(rootEl, "getBoundingClientRect", {
66
+ configurable: true,
67
+ value: () => rectOf(0, 0, SCREEN * scale, SCREEN * scale),
68
+ });
69
+ itemDrawn = { top: laidTop * scale, height: ITEM * scale };
70
+
71
+ const scrollRoot = ref<HTMLElement | null>(rootEl);
72
+ const Host = defineComponent({
73
+ setup() {
74
+ provideScrollWindow(scrollRoot, 1);
75
+ return () => h(HkWindowedItem, { estimatedHeight: ITEM }, { default: () => h("span", { class: "real" }) });
76
+ },
77
+ });
78
+ const app = createApp(Host);
79
+ apps.push(app);
80
+ const container = document.createElement("div");
81
+ document.body.appendChild(container);
82
+ app.mount(container);
83
+ await nextTick();
84
+ const item = container.querySelector<HTMLElement>(".hk-windowed-item");
85
+ return item?.getAttribute("data-state") ?? null;
86
+ }
87
+
88
+ describe("HkWindowedItem scaled-space buffer", () => {
89
+ it("keeps an item inside the overscan band that a scaled root draws out of it", async () => {
90
+ // One laid-out screen of overscan = 400 of the root's own units. An item
91
+ // 350 units below the root's bottom is inside it - but it is *drawn* 700px
92
+ // below, so a buffer compared in drawn pixels without the scale reads as an
93
+ // empty slot and the row unmounts while still on (scaled) screen.
94
+ expect(await dataState(750, 2)).toBe("real");
95
+ });
96
+
97
+ it("still windows out an item beyond the overscan band when scaled", async () => {
98
+ // 500 laid-out units below the bottom > one screen of overscan: out either
99
+ // way, so the fix is not "always render".
100
+ expect(await dataState(900, 2)).toBe("placeholder");
101
+ });
102
+
103
+ it("is unchanged for an unscaled root", async () => {
104
+ expect(await dataState(750, 1)).toBe("real");
105
+ expect(await dataState(900, 1)).toBe("placeholder");
106
+ });
107
+ });
@@ -3,6 +3,7 @@ import { defineComponent, onBeforeUnmount, onMounted, ref, shallowRef, watch, ty
3
3
  import "./HkWindowedItem.scss";
4
4
  import { useScrollWindow } from "../composables/useScrollWindow";
5
5
  import { onceFrame } from "../runtime/animationBus";
6
+ import { drawnScale } from "../composables/layoutGeometry";
6
7
 
7
8
  export default defineComponent({
8
9
  name: "HkWindowedItem",
@@ -24,9 +25,16 @@ export default defineComponent({
24
25
  }
25
26
 
26
27
  function withinBuffer(el: HTMLElement, scrollEl: HTMLElement, overscan: number): boolean {
28
+ // Both boxes and the buffer in the scroll root's own units: the rects
29
+ // are drawn (a scaled or zoomed root makes them k times bigger) while
30
+ // `clientHeight` is laid out, which shrank the effective overscan to
31
+ // overscan/k (see `layoutGeometry`).
32
+ const scale = drawnScale(scrollEl);
33
+ const factor = scrollEl.offsetHeight > 0 ? scale.y : scrollEl.offsetWidth > 0 ? scale.x : 1;
34
+ const k = factor > 0 ? factor : 1;
27
35
  const elRect = el.getBoundingClientRect();
28
36
  const rootRect = scrollEl.getBoundingClientRect();
29
- const buf = scrollEl.clientHeight * overscan;
37
+ const buf = scrollEl.clientHeight * overscan * k;
30
38
  return elRect.bottom >= rootRect.top - buf && elRect.top <= rootRect.bottom + buf;
31
39
  }
32
40
 
@@ -4,6 +4,7 @@ import {
4
4
  drawnScale,
5
5
  frameMetrics,
6
6
  frameScale,
7
+ laidOffsetWithin,
7
8
  laidSize,
8
9
  layoutOffset,
9
10
  layoutRect,
@@ -107,6 +108,46 @@ describe("layoutGeometry", () => {
107
108
  .toEqual({ x: 140, y: 240 });
108
109
  });
109
110
 
111
+ it("measures an offset INSIDE its frame, not from the document root", () => {
112
+ // The trap this helper exists for: a raw offset sum runs on to the root,
113
+ // so the frame's own distance down the page is added to the answer. Used
114
+ // as a scroll target that overshoots by exactly that distance — measured
115
+ // in Chromium, a frame 310px down the page asked for 810 where 500 was
116
+ // right, and the drawn-box equivalent is a further zoom factor out.
117
+ const outer = document.createElement("div");
118
+ const frame = document.createElement("div");
119
+ const child = document.createElement("div");
120
+ frame.appendChild(child);
121
+ outer.appendChild(frame);
122
+ document.body.appendChild(outer);
123
+
124
+ laid(outer, { left: 0, top: 0, w: 400, h: 600 }, document.body);
125
+ laid(frame, { left: 10, top: 310, w: 300, h: 400 }, outer);
126
+ laid(child, { left: 0, top: 500, w: 200, h: 60 }, frame);
127
+ // The frame is drawn at 2x: the drawn-box answer for the same scroll.
128
+ drawn(frame, { left: 20, right: 620, top: 620, bottom: 1420 });
129
+ drawn(child, { left: 20, right: 420, top: 1620, bottom: 1740 });
130
+
131
+ expect(layoutOffset(child, frame).y, "the raw sum carries the frame's own 310").toBe(810);
132
+ expect(laidOffsetWithin(child, frame).y, "inside the frame, in its own units").toBe(500);
133
+ expect(laidOffsetWithin(child, frame).through).toBe(true);
134
+ // Same number however the frame is drawn: layout units are scale-free.
135
+ drawn(frame, { left: 30, right: 930, top: 930, bottom: 2130 });
136
+ expect(laidOffsetWithin(child, frame).y).toBe(500);
137
+ });
138
+
139
+ it("refuses an offset inside something that is not an ancestor", () => {
140
+ const frame = document.createElement("div");
141
+ const stranger = document.createElement("div");
142
+ document.body.appendChild(frame);
143
+ document.body.appendChild(stranger);
144
+ laid(frame, { left: 0, top: 0, w: 200, h: 200 }, document.body);
145
+ laid(stranger, { left: 0, top: 40, w: 100, h: 40 }, document.body);
146
+ laid(frame, { left: 0, top: 0, w: 200, h: 200 }, document.body);
147
+
148
+ expect(Number.isFinite(laidOffsetWithin(stranger, frame).y)).toBe(false);
149
+ });
150
+
110
151
  it("scales what it places by the scale the frame is drawn at", () => {
111
152
  const frame = document.createElement("div");
112
153
  const child = document.createElement("div");
@@ -97,6 +97,33 @@ export function layoutOffset(el: HTMLElement, frame?: HTMLElement | null): Layou
97
97
  return { x, y, through };
98
98
  }
99
99
 
100
+ /** Where `el` sits inside `frame`'s content, in the frame's own layout units —
101
+ * the number a scroll offset has to be set to in order to bring the element's
102
+ * top/left edge to the frame's visible top/left (its padding edge).
103
+ *
104
+ * `layoutOffset` sums a chain all the way to the document root, so using its
105
+ * result directly as a scroll target is off by the frame's own distance from
106
+ * that root: measured in Chromium, a frame sitting 310px down the page asked
107
+ * for 810 where the correct scroll offset was 500. Subtracting the frame's own
108
+ * sum cancels every offset convention along the way (see the file comment) and
109
+ * the result is scale-free, so it is the same number at zoom 1 and zoom 3
110
+ * while the drawn-box equivalent (`rect.top - frameRect.top + frame.scrollTop`)
111
+ * is `k` times too large at zoom `k`.
112
+ *
113
+ * `y` (and `x`) are `NaN` when the chain cannot be added up or when `frame` is
114
+ * not an ancestor of `el` — the difference is meaningless then, and callers
115
+ * check `Number.isFinite` before scrolling with it. */
116
+ export function laidOffsetWithin(el: HTMLElement, frame: HTMLElement): LayoutOffset {
117
+ const at = layoutOffset(el, frame);
118
+ const of = layoutOffset(frame, null);
119
+ const through = at.through;
120
+ if (!Number.isFinite(at.x) || !Number.isFinite(at.y) || !Number.isFinite(of.x) || !Number.isFinite(of.y)) {
121
+ return { x: NaN, y: NaN, through };
122
+ }
123
+ if (el !== frame && !frame.contains(el)) return { x: NaN, y: NaN, through };
124
+ return { x: at.x - of.x, y: at.y - of.y, through };
125
+ }
126
+
100
127
  /** An element's laid-out BORDER box, as finely as the engine will report it.
101
128
  * `offsetWidth`/`offsetHeight` are integers — they round, and half a pixel is
102
129
  * a third of the line filter's entire tolerance — while the computed
@@ -156,6 +156,39 @@ describe("attachOverlayScrollbars", () => {
156
156
  document.dispatchEvent(new MouseEvent("mouseup"));
157
157
  });
158
158
 
159
+ it("rests the horizontal thumb at the track's right end in a right-to-left scroller", () => {
160
+ // An RTL scroller starts with `scrollLeft` at 0 — the content's right end
161
+ // — and counts down from there, so the thumb belongs at the right of the
162
+ // track and travels left. Rendering it at translateX(0) parked it at the
163
+ // wrong end of its own track.
164
+ const { viewport, wrapper, handle } = mountViewport("horizontal");
165
+ const track = wrapper.querySelector<HTMLElement>('.hk-scrollbar-track[data-axis="horizontal"]')!;
166
+ const thumb = track.querySelector<HTMLElement>(".hk-scrollbar-thumb")!;
167
+ // A 100px track with a 100-of-400 client box: thumb 25px, 75px of travel.
168
+ Object.defineProperty(track, "clientWidth", { configurable: true, get: () => 100 });
169
+ const at = (scrollLeft: number) => {
170
+ stubGeometry(viewport, {
171
+ scrollWidth: 400, clientWidth: 100, scrollLeft,
172
+ scrollHeight: 100, clientHeight: 100, scrollTop: 0,
173
+ });
174
+ handle.update();
175
+ return thumb.style.transform;
176
+ };
177
+ viewport.style.direction = "rtl";
178
+ // An RTL scroller starts with `scrollLeft` at 0 — the content's right end
179
+ // — and counts down from there, so the thumb belongs at the right of the
180
+ // track and travels left. Rendering it at translateX(0) parked it at the
181
+ // wrong end of its own track.
182
+ expect(at(0), "at the start, against the right end").toBe("translateX(75px)");
183
+ expect(at(-150), "half way").toBe("translateX(37.5px)");
184
+ expect(at(-300), "scrolled to the far end, at the left").toBe("translateX(0px)");
185
+
186
+ // …and the left-to-right bar keeps resting at the left.
187
+ viewport.style.direction = "ltr";
188
+ expect(at(0)).toBe("translateX(0px)");
189
+ expect(at(150)).toBe("translateX(37.5px)");
190
+ });
191
+
159
192
  it("pages when the track (not the thumb) is clicked", () => {
160
193
  const { viewport, wrapper, handle } = mountViewport("vertical");
161
194
  stubGeometry(viewport, {
@@ -81,6 +81,14 @@ function axisMetrics(viewport: HTMLElement, horizontal: boolean) {
81
81
  };
82
82
  }
83
83
 
84
+ /** Whether the axis runs right-to-left. Only the horizontal one can: a
85
+ * vertical bar is RTL-agnostic, and `writing-mode` on the scroller is not
86
+ * something this component supports. */
87
+ function isRtlAxis(viewport: HTMLElement, horizontal: boolean): boolean {
88
+ if (!horizontal) return false;
89
+ return getComputedStyle(viewport).direction === "rtl";
90
+ }
91
+
84
92
  function makeAxisState(horizontal: boolean): AxisState {
85
93
  const track = document.createElement("div");
86
94
  track.className = "hk-scrollbar-track";
@@ -161,7 +169,13 @@ export function attachOverlayScrollbars(
161
169
  const thumbSize = Math.max(ratio * trackSize, 20);
162
170
  const maxScroll = scrollSize - clientSize;
163
171
  const maxTrack = trackSize - thumbSize;
164
- const offset = maxScroll > 0 ? (scrollPos / maxScroll) * maxTrack : 0;
172
+ // A right-to-left scroller starts with `scrollLeft` at 0 (showing the
173
+ // content's right end) and counts DOWN to -maxScroll, so its progress is
174
+ // the magnitude — and the thumb rests at the track's right end and
175
+ // travels left, mirrored against the left-to-right bar.
176
+ const rtl = isRtlAxis(viewport, horizontal);
177
+ const progress = maxScroll > 0 ? Math.abs(scrollPos) / maxScroll : 0;
178
+ const offset = (rtl ? 1 - progress : progress) * maxTrack;
165
179
  if (horizontal) {
166
180
  s.thumb.style.width = `${thumbSize}px`;
167
181
  s.thumb.style.transform = `translateX(${offset}px)`;
@@ -0,0 +1,90 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
+
4
+ import { useZoomPan } from "./useZoomPan";
5
+
6
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
7
+
8
+ afterEach(() => {
9
+ for (const m of mounts.splice(0)) {
10
+ m.app.unmount();
11
+ m.container.remove();
12
+ }
13
+ });
14
+
15
+ /** Mount a host that binds the composable's container ref to a real element. */
16
+ async function mountHost() {
17
+ const container = document.createElement("div");
18
+ document.body.appendChild(container);
19
+ const el = ref<HTMLElement | null>(null);
20
+ const content = ref<HTMLElement | null>(null);
21
+ let api!: ReturnType<typeof useZoomPan>;
22
+ const Host = defineComponent({
23
+ setup() {
24
+ api = useZoomPan({ containerRef: el, contentRef: content });
25
+ return () =>
26
+ h("div", { ref: (r: unknown) => (el.value = r as HTMLElement) }, [
27
+ h("div", { ref: (r: unknown) => (content.value = r as HTMLElement) }),
28
+ ]);
29
+ },
30
+ });
31
+ const app = createApp(Host);
32
+ app.mount(container);
33
+ mounts.push({ app, container });
34
+ await nextTick();
35
+ return { container, viewport: () => el.value as HTMLElement, api: () => api };
36
+ }
37
+
38
+ function pointer(type: string, id: number, x: number, y: number): PointerEvent {
39
+ return new PointerEvent(type, { bubbles: true, pointerId: id, pointerType: "touch", clientX: x, clientY: y });
40
+ }
41
+
42
+ describe("useZoomPan", () => {
43
+ /** The composable only pans once the view is zoomed in (`zoom < 1`), and it
44
+ * clamps the pan to the overflow it can measure — which a layout-less
45
+ * environment has none of. Both are stated here: this file is about the pan
46
+ * maths, not about the zoom state machine. */
47
+ async function zoomIn(api: () => ReturnType<typeof useZoomPan>, el: HTMLElement) {
48
+ Object.defineProperty(el, "clientWidth", { configurable: true, get: () => 400 });
49
+ Object.defineProperty(el, "clientHeight", { configurable: true, get: () => 300 });
50
+ Object.defineProperty(el.firstElementChild!, "scrollWidth", { configurable: true, get: () => 2000 });
51
+ Object.defineProperty(el.firstElementChild!, "scrollHeight", { configurable: true, get: () => 1500 });
52
+ api().zoom.value = 0.5;
53
+ await nextTick();
54
+ expect(api().isZoomed.value, "the view has something to pan").toBe(true);
55
+ }
56
+
57
+ it("wires its gestures once the host has mounted", async () => {
58
+ const { viewport, api } = await mountHost();
59
+ const el = viewport();
60
+ await zoomIn(api, el);
61
+ // The drag has to reach the composable at all: wiring it inline in
62
+ // `setup` left the ref empty and every gesture unattached.
63
+ el.dispatchEvent(pointer("pointerdown", 1, 100, 100));
64
+ el.dispatchEvent(pointer("pointermove", 1, 140, 160));
65
+ await nextTick();
66
+ expect(api().panX.value, "40 drawn px in an unscaled space").toBeCloseTo(40, 5);
67
+ expect(api().panY.value).toBeCloseTo(60, 5);
68
+ el.dispatchEvent(pointer("pointerup", 1, 140, 160));
69
+ });
70
+
71
+ it("pans by the pointer's own distance inside a scaled root", async () => {
72
+ const { viewport, api } = await mountHost();
73
+ const el = viewport();
74
+ await zoomIn(api, el);
75
+ // The container is drawn twice the size it is laid out at.
76
+ Object.defineProperty(el, "getBoundingClientRect", {
77
+ configurable: true,
78
+ value: () => ({ left: 0, top: 0, right: 800, bottom: 600, width: 800, height: 600, x: 0, y: 0 }) as DOMRect,
79
+ });
80
+ Object.defineProperty(el, "offsetWidth", { configurable: true, get: () => 400 });
81
+ Object.defineProperty(el, "offsetHeight", { configurable: true, get: () => 300 });
82
+
83
+ el.dispatchEvent(pointer("pointerdown", 1, 100, 100));
84
+ el.dispatchEvent(pointer("pointermove", 1, 140, 160));
85
+ await nextTick();
86
+ expect(api().panX.value, "40 drawn px are 20 of the container's own").toBeCloseTo(20, 5);
87
+ expect(api().panY.value, "60 drawn px are 30 of the container's own").toBeCloseTo(30, 5);
88
+ el.dispatchEvent(pointer("pointerup", 1, 140, 160));
89
+ });
90
+ });
@@ -1,6 +1,7 @@
1
- import { computed, onUnmounted, ref, type Ref } from "vue";
1
+ import { computed, onMounted, onUnmounted, ref, watch, type Ref } from "vue";
2
2
 
3
3
  import { useReportedTransition } from "@celestia-island/hikari";
4
+ import { drawnScale } from "./layoutGeometry";
4
5
 
5
6
  /** Duration of the transform settle transition (see `style` below) AND the
6
7
  * window reported to the unified animation bus. JS == CSS so the bus
@@ -128,8 +129,22 @@ export function useZoomPan(options: ZoomPanOptions): ZoomPanState {
128
129
 
129
130
  function onPointerMove(e: PointerEvent) {
130
131
  if (!isPanning.value) return;
131
- const dx = e.clientX - lastPointerX.value;
132
- const dy = e.clientY - lastPointerY.value;
132
+ // The container's own pixels, not the drawn ones: a scaled or zoomed root
133
+ // would otherwise pan k times as far as the pointer moved.
134
+ const container = containerRef.value;
135
+ const scale = container ? drawnScale(container) : { x: 1, y: 1 };
136
+ const factor =
137
+ container && container.offsetWidth > 0
138
+ ? scale.x > 0
139
+ ? scale.x
140
+ : 1
141
+ : container && container.offsetHeight > 0
142
+ ? scale.y > 0
143
+ ? scale.y
144
+ : 1
145
+ : 1;
146
+ const dx = (e.clientX - lastPointerX.value) / factor;
147
+ const dy = (e.clientY - lastPointerY.value) / factor;
133
148
  lastPointerX.value = e.clientX;
134
149
  lastPointerY.value = e.clientY;
135
150
  panX.value += dx;
@@ -146,23 +161,33 @@ export function useZoomPan(options: ZoomPanOptions): ZoomPanState {
146
161
  }
147
162
 
148
163
  let detach: (() => void) | null = null;
149
- if (typeof window !== "undefined") {
164
+ /** Wire the gestures to whatever element the ref resolves to. A ref is
165
+ * still empty while `setup` runs, so this has to happen once the host has
166
+ * mounted — wiring it inline left every gesture unattached. */
167
+ function attach(): void {
168
+ detach?.();
169
+ detach = null;
150
170
  const el = containerRef.value;
151
- if (el) {
152
- const wheelOpts = { passive: false } as AddEventListenerOptions;
153
- el.addEventListener("wheel", onWheel, wheelOpts);
154
- el.addEventListener("pointerdown", onPointerDown);
155
- el.addEventListener("pointermove", onPointerMove);
156
- el.addEventListener("pointerup", onPointerUp);
157
- el.addEventListener("pointercancel", onPointerUp);
158
- detach = () => {
159
- el.removeEventListener("wheel", onWheel, wheelOpts);
160
- el.removeEventListener("pointerdown", onPointerDown);
161
- el.removeEventListener("pointermove", onPointerMove);
162
- el.removeEventListener("pointerup", onPointerUp);
163
- el.removeEventListener("pointercancel", onPointerUp);
164
- };
165
- }
171
+ if (!el) return;
172
+ const wheelOpts = { passive: false } as AddEventListenerOptions;
173
+ el.addEventListener("wheel", onWheel, wheelOpts);
174
+ el.addEventListener("pointerdown", onPointerDown);
175
+ el.addEventListener("pointermove", onPointerMove);
176
+ el.addEventListener("pointerup", onPointerUp);
177
+ el.addEventListener("pointercancel", onPointerUp);
178
+ detach = () => {
179
+ el.removeEventListener("wheel", onWheel, wheelOpts);
180
+ el.removeEventListener("pointerdown", onPointerDown);
181
+ el.removeEventListener("pointermove", onPointerMove);
182
+ el.removeEventListener("pointerup", onPointerUp);
183
+ el.removeEventListener("pointercancel", onPointerUp);
184
+ };
185
+ }
186
+
187
+ if (typeof window !== "undefined") {
188
+ onMounted(attach);
189
+ // A host that swaps the element out from under the ref re-wires too.
190
+ watch(containerRef, () => attach());
166
191
  }
167
192
 
168
193
  const style = computed(() => ({
package/src/index.ts CHANGED
@@ -270,6 +270,22 @@ export type { ZoomPanOptions, ZoomPanState } from "./composables/useZoomPan";
270
270
  export { extOf, isImageFile, isAudioFile, codeLanguage, isCodeFile, isArchiveFile, isTextFile, fileIcon, mediaKindOf } from "./utils/fileType";
271
271
 
272
272
  export { useReducedMotion } from "./composables/useReducedMotion";
273
+ export {
274
+ drawnScale,
275
+ frameMetrics,
276
+ frameScale,
277
+ laidOffsetWithin,
278
+ laidSize,
279
+ layoutOffset,
280
+ layoutRect,
281
+ nearestLaidAncestor,
282
+ placePoint,
283
+ } from "./composables/layoutGeometry";
284
+ export type {
285
+ FrameMetrics,
286
+ LayoutOffset,
287
+ LayoutRect,
288
+ } from "./composables/layoutGeometry";
273
289
  export { useMeasuredHighlight } from "./composables/useMeasuredHighlight";
274
290
  export type { UseMeasuredHighlightOptions, MeasuredHighlight } from "./composables/useMeasuredHighlight";
275
291
  export {