@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 +1 -1
- package/src/components/HkAuthMethodList.test.tsx +24 -0
- package/src/components/HkAuthMethodList.tsx +8 -1
- package/src/components/HkBoard.scale.test.ts +136 -0
- package/src/components/HkBoard.tsx +27 -14
- package/src/components/HkImageViewer.tsx +37 -16
- package/src/components/HkImageViewerTouch.test.tsx +57 -0
- package/src/components/HkMinimap.scale.test.ts +78 -0
- package/src/components/HkMinimap.tsx +16 -1
- package/src/components/HkPasswordInput.test.ts +48 -0
- package/src/components/HkPasswordInput.tsx +9 -1
- package/src/components/HkScrollContainer.test.ts +48 -1
- package/src/components/HkScrollContainer.tsx +12 -1
- package/src/components/HkWindowedItem.scale.test.ts +107 -0
- package/src/components/HkWindowedItem.tsx +9 -1
- package/src/composables/layoutGeometry.test.ts +41 -0
- package/src/composables/layoutGeometry.ts +27 -0
- package/src/composables/useOverlayScrollbar.test.ts +33 -0
- package/src/composables/useOverlayScrollbar.ts +15 -1
- package/src/composables/useZoomPan.test.ts +90 -0
- package/src/composables/useZoomPan.ts +44 -19
- package/src/index.ts +16 -0
package/package.json
CHANGED
|
@@ -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
|
|
216
|
-
|
|
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
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
(pair[0].
|
|
180
|
-
|
|
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
|
|
187
|
-
const
|
|
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
|
|
239
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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 {
|