@celestia-island/hikari 0.43.14 → 0.43.16

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.16",
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,35 @@ 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
+ // The scale has to come from a LABEL: the list is `display: contents` and
101
+ // generates no box at all (Blink reports offsetWidth 0 and a 0x0 rect), so
102
+ // stubbing a box on the list would only ever be green by accident.
103
+ for (const [i, drawn] of [58, 61.4].entries()) {
104
+ Object.defineProperty(spans[i]!, "getBoundingClientRect", {
105
+ configurable: true,
106
+ value: () => ({ left: 0, top: 0, right: drawn * 2, bottom: 40, width: drawn * 2, height: 40, x: 0, y: 0 }) as DOMRect,
107
+ });
108
+ // Laid out at half: the label is drawn twice the size it is laid out at.
109
+ Object.defineProperty(spans[i]!, "offsetWidth", { configurable: true, get: () => drawn });
110
+ Object.defineProperty(spans[i]!, "offsetHeight", { configurable: true, get: () => 20 });
111
+ }
112
+ const instance = (wrapper as unknown as { __vueParentComponent?: { exposed?: Record<string, unknown> } })
113
+ .__vueParentComponent?.exposed;
114
+ (instance!.measure as () => void)();
115
+ // 122.8 drawn are 61.4 of the column's own: ceil 62, plus the 1px headroom.
116
+ // Reading the scale off the LIST instead — the shape this shipped with —
117
+ // yields 1 for a `display: contents` element and publishes the drawn 124px.
118
+ expect(wrapper.style.getPropertyValue("--auth-methods-label-width")).toBe("63px");
119
+ });
120
+
92
121
  it("leaves the column fallback intact when no text metrics are available", async () => {
93
122
  const c = mount(h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { methods })));
94
123
  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,25 @@ 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
+ //
90
+ // The scale is read from the LABEL — the element the box was measured
91
+ // on. Reading it from the list itself cannot work: the list is
92
+ // `display: contents`, so it generates no box at all (measured in Blink:
93
+ // `offsetWidth` 0, a 0x0 rect, computed width `auto`), and every reader
94
+ // of a box reports nothing to divide by.
84
95
  for (const label of root.querySelectorAll<HTMLElement>(".s-auth-methods-label")) {
85
96
  const previous = label.style.width;
86
97
  label.style.width = "max-content";
87
- widest = Math.max(widest, label.getBoundingClientRect().width);
98
+ const drawn = label.getBoundingClientRect().width;
99
+ const scale = drawnScale(label);
100
+ const factor = Number.isFinite(scale.x) && scale.x > 0 ? scale.x : 1;
101
+ // `offsetWidth` is the laid-out fallback — already in the units the
102
+ // published length is used in, so it needs no division.
103
+ widest = Math.max(widest, factor > 0 ? drawn / factor : label.offsetWidth);
88
104
  label.style.width = previous;
89
105
  }
90
106
  // 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;