@celestia-island/hikari 0.43.12 → 0.43.13
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/HkPlaceholderMarquee.test.ts +27 -0
- package/src/components/HkPlaceholderMarquee.tsx +14 -6
- package/src/components/HkTimeline.test.tsx +39 -0
- package/src/components/HkTimeline.tsx +16 -2
- package/src/composables/layoutGeometry.test.ts +177 -0
- package/src/composables/layoutGeometry.ts +231 -0
- package/src/composables/useOverlayScrollbar.test.ts +32 -0
- package/src/composables/useOverlayScrollbar.ts +9 -1
- package/src/composables/usePointerReorder.test.ts +5 -2
- package/src/composables/usePointerReorder.ts +83 -147
package/package.json
CHANGED
|
@@ -84,6 +84,33 @@ afterEach(() => {
|
|
|
84
84
|
}
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
+
describe("HkPlaceholderMarquee scale-aware measurement", () => {
|
|
88
|
+
it("converts the copy's drawn width into the host's own units before looping", async () => {
|
|
89
|
+
// The copy is measured where it is DRAWN while the host it is compared
|
|
90
|
+
// with (`clientWidth`) is laid out. Inside a scaled or zoomed root the two
|
|
91
|
+
// differ by the factor the space is drawn at: the raw rect would make the
|
|
92
|
+
// loop overshoot by (k-1) of a copy per cycle and report overflow for
|
|
93
|
+
// copies that fit k times over.
|
|
94
|
+
const { container } = mountMarquee({ text: "a very long placeholder that overflows".repeat(4) });
|
|
95
|
+
const host = container.querySelector(".hk-placeholder-marquee") as HTMLElement;
|
|
96
|
+
const copy = container.querySelector(".hk-placeholder-marquee__copy") as HTMLElement;
|
|
97
|
+
Object.defineProperty(host, "clientWidth", { configurable: true, get: () => 200 });
|
|
98
|
+
// The host is drawn twice the size it is laid out at: 800 drawn px are
|
|
99
|
+
// 400 of its own, while the copy really advances 500 of them.
|
|
100
|
+
Object.defineProperty(host, "getBoundingClientRect", {
|
|
101
|
+
configurable: true,
|
|
102
|
+
value: () => ({ width: 400, left: 0, top: 0, right: 400, bottom: 40, height: 40, x: 0, y: 0 }) as DOMRect,
|
|
103
|
+
});
|
|
104
|
+
Object.defineProperty(host, "offsetWidth", { configurable: true, get: () => 200 });
|
|
105
|
+
vi.spyOn(copy, "getBoundingClientRect").mockReturnValue({ width: 1000 } as DOMRect);
|
|
106
|
+
marqueeExposed(container)?.measure?.();
|
|
107
|
+
await nextTick();
|
|
108
|
+
|
|
109
|
+
const strip = container.querySelector(".hk-placeholder-marquee__strip") as HTMLElement;
|
|
110
|
+
expect(strip.style.getPropertyValue("--hk-marquee-shift"), "1000 drawn = 500 of the host's own").toBe("-500px");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
87
114
|
describe("HkPlaceholderMarquee", () => {
|
|
88
115
|
it("stays a hidden single-copy probe while the text fits", () => {
|
|
89
116
|
const { container } = mountMarquee({ text: "用户名" });
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { defineComponent, ref, computed, onMounted, onBeforeUnmount, watch, type PropType } from "vue";
|
|
2
|
+
import { drawnScale } from "../composables/layoutGeometry";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Overflow strategy for a placeholder that does not fit its input.
|
|
@@ -64,12 +65,19 @@ export const HkPlaceholderMarquee = defineComponent({
|
|
|
64
65
|
// copy spacing as trailing padding). Reading the first copy element —
|
|
65
66
|
// instead of stripWidth / 3 — stays correct in the fitting case,
|
|
66
67
|
// where the strip holds a single copy, and through overflow flips,
|
|
67
|
-
// where the copy count changes under the same strip. The
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
68
|
+
// where the copy count changes under the same strip. The width is read
|
|
69
|
+
// where the copy is DRAWN and converted into the host's own units,
|
|
70
|
+
// because that is the space the CSS loop shift and `clientWidth` live
|
|
71
|
+
// in: inside a scaled or zoomed root the raw rect is k times the
|
|
72
|
+
// advance, so the loop would overshoot by (k-1) per cycle and the
|
|
73
|
+
// overflow test would fire on copies that fit k times over. The
|
|
74
|
+
// conversion keeps the fractional width that makes the shift
|
|
75
|
+
// bit-exact — an integer-rounded width would show a ≤0.5px seam at
|
|
76
|
+
// every wrap (the copy rides the animated strip, so the width itself
|
|
77
|
+
// is never affected by the strip's transform).
|
|
78
|
+
const space = drawnScale(host);
|
|
79
|
+
const factor = space.x > 0 ? space.x : 1;
|
|
80
|
+
const copyWidth = copy.getBoundingClientRect().width / factor;
|
|
73
81
|
loopWidth.value = copyWidth;
|
|
74
82
|
overflowing.value = copyWidth - COPY_SPACING > host.clientWidth;
|
|
75
83
|
emit("overflowChange", overflowing.value);
|
|
@@ -182,6 +182,45 @@ describe("HkTimeline full mode", () => {
|
|
|
182
182
|
});
|
|
183
183
|
});
|
|
184
184
|
|
|
185
|
+
describe("HkTimeline scale-aware collapse", () => {
|
|
186
|
+
it("measures the natural size in the units the host is sized in", async () => {
|
|
187
|
+
// The probe is measured where it is DRAWN while the host it is compared
|
|
188
|
+
// against (`clientWidth`) is laid out: inside a scaled or zoomed root the
|
|
189
|
+
// probe reports k times the space it really needs, so a timeline that
|
|
190
|
+
// fits is collapsed — and because the remembered full size is drawn too,
|
|
191
|
+
// it never comes back. The measurement converts through the space it was
|
|
192
|
+
// taken in.
|
|
193
|
+
const proto = Element.prototype as unknown as { getBoundingClientRect: () => DOMRect };
|
|
194
|
+
const original = proto.getBoundingClientRect;
|
|
195
|
+
const rect = (w: number, h: number): DOMRect =>
|
|
196
|
+
({ left: 0, top: 0, right: w, bottom: h, width: w, height: h, x: 0, y: 0 }) as DOMRect;
|
|
197
|
+
proto.getBoundingClientRect = function (this: HTMLElement) {
|
|
198
|
+
// The natural-size probe (an absolutely positioned clone) and the root
|
|
199
|
+
// it cloned report a box drawn twice the size it is laid out at.
|
|
200
|
+
if (this.style?.position === "absolute" || this.classList?.contains("hk-timeline")) {
|
|
201
|
+
return rect(600, 40);
|
|
202
|
+
}
|
|
203
|
+
return rect(0, 0);
|
|
204
|
+
};
|
|
205
|
+
try {
|
|
206
|
+
const t = mountTimeline({ currentKey: "c" });
|
|
207
|
+
const root = t.container.querySelector<HTMLElement>(".hk-timeline")!;
|
|
208
|
+
Object.defineProperty(root, "offsetWidth", { configurable: true, get: () => 300 });
|
|
209
|
+
Object.defineProperty(root, "offsetHeight", { configurable: true, get: () => 20 });
|
|
210
|
+
Object.defineProperty(root, "clientWidth", { configurable: true, get: () => 300 });
|
|
211
|
+
await nextTick();
|
|
212
|
+
await nextTick();
|
|
213
|
+
await nextTick();
|
|
214
|
+
expect(
|
|
215
|
+
root.getAttribute("data-mode"),
|
|
216
|
+
"300 of natural width in a 300px host is not an overflow",
|
|
217
|
+
).toBe("full");
|
|
218
|
+
} finally {
|
|
219
|
+
proto.getBoundingClientRect = original;
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
185
224
|
describe("HkTimeline window mode", () => {
|
|
186
225
|
it("shows only the previous, current and next steps around the middle", () => {
|
|
187
226
|
const t = mountTimeline({ currentKey: "c", collapse: "always" });
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "vue";
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
import { drawnScale } from "../composables/layoutGeometry";
|
|
14
15
|
import "./HkTimeline.scss";
|
|
15
16
|
|
|
16
17
|
export type TimelineStepStatus = "completed" | "active" | "pending";
|
|
@@ -76,6 +77,15 @@ export function computeTimelineWindow(
|
|
|
76
77
|
* sized to `max-content`, which lets the flex items keep their natural
|
|
77
78
|
* widths (labels are `white-space: nowrap; flex-shrink: 0`).
|
|
78
79
|
*/
|
|
80
|
+
/** A length measured where it is DRAWN, in the laid-out units the host it
|
|
81
|
+
* will be compared against is sized in: a probe appended inside a scaled or
|
|
82
|
+
* zoomed root reports `k` times the space it really occupies there. */
|
|
83
|
+
function drawnToLaid(drawn: number, space: HTMLElement): number {
|
|
84
|
+
const scale = drawnScale(space);
|
|
85
|
+
const factor = space.offsetWidth > 0 ? scale.x : space.offsetHeight > 0 ? scale.y : 1;
|
|
86
|
+
return factor > 0 ? drawn / factor : drawn;
|
|
87
|
+
}
|
|
88
|
+
|
|
79
89
|
function naturalRowWidth(el: HTMLElement): number {
|
|
80
90
|
const probe = el.cloneNode(true) as HTMLElement;
|
|
81
91
|
probe.style.position = "absolute";
|
|
@@ -86,7 +96,11 @@ function naturalRowWidth(el: HTMLElement): number {
|
|
|
86
96
|
const parent = el.parentElement;
|
|
87
97
|
if (!parent) return el.scrollWidth;
|
|
88
98
|
parent.appendChild(probe);
|
|
89
|
-
|
|
99
|
+
// The probe is measured where it is DRAWN, while the host it is compared
|
|
100
|
+
// against (`clientWidth`) is laid out: convert through the space the probe
|
|
101
|
+
// lives in, or a scaled or zoomed root answers with a size that is k times
|
|
102
|
+
// the one the host can hold (see `layoutGeometry`).
|
|
103
|
+
const width = drawnToLaid(probe.getBoundingClientRect().width, el);
|
|
90
104
|
probe.remove();
|
|
91
105
|
return Math.ceil(width);
|
|
92
106
|
}
|
|
@@ -103,7 +117,7 @@ function naturalColumnHeight(el: HTMLElement): number {
|
|
|
103
117
|
const parent = el.parentElement;
|
|
104
118
|
if (!parent) return el.scrollHeight;
|
|
105
119
|
parent.appendChild(probe);
|
|
106
|
-
const height = probe.getBoundingClientRect().height;
|
|
120
|
+
const height = drawnToLaid(probe.getBoundingClientRect().height, el);
|
|
107
121
|
probe.remove();
|
|
108
122
|
return Math.ceil(height);
|
|
109
123
|
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
drawnScale,
|
|
5
|
+
frameMetrics,
|
|
6
|
+
frameScale,
|
|
7
|
+
laidSize,
|
|
8
|
+
layoutOffset,
|
|
9
|
+
layoutRect,
|
|
10
|
+
nearestLaidAncestor,
|
|
11
|
+
placePoint,
|
|
12
|
+
} from "./layoutGeometry";
|
|
13
|
+
|
|
14
|
+
/** A box the engine reports, on both axes. */
|
|
15
|
+
interface Box {
|
|
16
|
+
left: number;
|
|
17
|
+
right: number;
|
|
18
|
+
top: number;
|
|
19
|
+
bottom: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function asRect(box: Box): DOMRect {
|
|
23
|
+
return {
|
|
24
|
+
...box,
|
|
25
|
+
width: box.right - box.left,
|
|
26
|
+
height: box.bottom - box.top,
|
|
27
|
+
x: box.left,
|
|
28
|
+
y: box.top,
|
|
29
|
+
} as DOMRect;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function drawn(el: HTMLElement, box: Box): void {
|
|
33
|
+
Object.defineProperty(el, "getBoundingClientRect", {
|
|
34
|
+
configurable: true,
|
|
35
|
+
value: () => asRect(box),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function laid(
|
|
40
|
+
el: HTMLElement,
|
|
41
|
+
box: { left: number; top: number; w: number; h: number },
|
|
42
|
+
parent: HTMLElement | null,
|
|
43
|
+
): void {
|
|
44
|
+
Object.defineProperty(el, "offsetLeft", { configurable: true, get: () => box.left });
|
|
45
|
+
Object.defineProperty(el, "offsetTop", { configurable: true, get: () => box.top });
|
|
46
|
+
Object.defineProperty(el, "offsetWidth", { configurable: true, get: () => box.w });
|
|
47
|
+
Object.defineProperty(el, "offsetHeight", { configurable: true, get: () => box.h });
|
|
48
|
+
Object.defineProperty(el, "offsetParent", { configurable: true, get: () => parent });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
document.body.innerHTML = "";
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("layoutGeometry", () => {
|
|
56
|
+
it("reports the laid-out border box, not the rounded one", () => {
|
|
57
|
+
// A fractional border box: the computed width is the CONTENT box unless
|
|
58
|
+
// the element is border-box, so the padding and border come back on top of
|
|
59
|
+
// it — and the integer `offsetWidth` is only the fallback.
|
|
60
|
+
const el = document.createElement("div");
|
|
61
|
+
document.body.appendChild(el);
|
|
62
|
+
el.style.width = "100.4px";
|
|
63
|
+
el.style.height = "40.25px";
|
|
64
|
+
el.style.paddingLeft = "3px";
|
|
65
|
+
el.style.paddingRight = "3px";
|
|
66
|
+
el.style.borderLeftWidth = "1px";
|
|
67
|
+
el.style.borderRightWidth = "1px";
|
|
68
|
+
laid(el, { left: 0, top: 0, w: 108, h: 40 }, document.body);
|
|
69
|
+
expect(laidSize(el)).toEqual({ w: 108.4, h: 40.25 });
|
|
70
|
+
|
|
71
|
+
el.style.boxSizing = "border-box";
|
|
72
|
+
expect(laidSize(el).w, "a border-box element reports its border box already").toBe(100.4);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("falls back to the integer box when nothing finer can be read", () => {
|
|
76
|
+
const el = document.createElement("div");
|
|
77
|
+
document.body.appendChild(el);
|
|
78
|
+
laid(el, { left: 0, top: 0, w: 108, h: 40 }, document.body);
|
|
79
|
+
expect(laidSize(el), "no computed width to read").toEqual({ w: 108, h: 40 });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("adds a frame's border only when the walk went through it", () => {
|
|
83
|
+
// The two families a browser produces: a positioned frame IS the child's
|
|
84
|
+
// offsetParent (the offsets are measured from its PADDING edge, so its
|
|
85
|
+
// border has to come back), a static frame is skipped by the chain (they
|
|
86
|
+
// are measured from its BORDER edge, so adding it would double-count).
|
|
87
|
+
const frame = document.createElement("div");
|
|
88
|
+
const child = document.createElement("div");
|
|
89
|
+
frame.appendChild(child);
|
|
90
|
+
document.body.appendChild(frame);
|
|
91
|
+
frame.style.borderLeftWidth = "10px";
|
|
92
|
+
frame.style.borderTopWidth = "10px";
|
|
93
|
+
drawn(frame, { left: 100, right: 300, top: 200, bottom: 260 });
|
|
94
|
+
laid(frame, { left: 0, top: 0, w: 200, h: 60 }, document.body);
|
|
95
|
+
laid(child, { left: 30, top: 30, w: 100, h: 40 }, frame);
|
|
96
|
+
drawn(child, { left: 140, right: 240, top: 240, bottom: 280 });
|
|
97
|
+
|
|
98
|
+
// Through the frame: 100 + 10 (border) + (30 - 0 - 0) = 140.
|
|
99
|
+
expect(placePoint(frame, frameMetrics(frame), layoutOffset(child, frame), layoutOffset(frame, null)))
|
|
100
|
+
.toEqual({ x: 140, y: 240 });
|
|
101
|
+
expect(layoutRect(child, frame)).toEqual({ left: 140, top: 240, width: 100, height: 40 });
|
|
102
|
+
|
|
103
|
+
// Skipping the frame: the offsets already carry its border.
|
|
104
|
+
laid(child, { left: 40, top: 40, w: 100, h: 40 }, null);
|
|
105
|
+
expect(layoutOffset(child, frame).through).toBe(false);
|
|
106
|
+
expect(placePoint(frame, frameMetrics(frame), layoutOffset(child, frame), layoutOffset(frame, null)))
|
|
107
|
+
.toEqual({ x: 140, y: 240 });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("scales what it places by the scale the frame is drawn at", () => {
|
|
111
|
+
const frame = document.createElement("div");
|
|
112
|
+
const child = document.createElement("div");
|
|
113
|
+
frame.appendChild(child);
|
|
114
|
+
document.body.appendChild(frame);
|
|
115
|
+
drawn(frame, { left: 50, right: 450, top: 20, bottom: 140 }); // drawn 2x
|
|
116
|
+
laid(frame, { left: 0, top: 0, w: 200, h: 60 }, document.body);
|
|
117
|
+
laid(child, { left: 50, top: 30, w: 40, h: 10 }, frame);
|
|
118
|
+
drawn(child, { left: 0, right: 0, top: 0, bottom: 0 });
|
|
119
|
+
|
|
120
|
+
const box = frameMetrics(frame);
|
|
121
|
+
expect(frameScale(box), "the frame is drawn twice its laid size").toEqual({ x: 2, y: 2 });
|
|
122
|
+
expect(placePoint(frame, box, layoutOffset(child, frame), layoutOffset(frame, null))).toEqual({
|
|
123
|
+
x: 150,
|
|
124
|
+
y: 80,
|
|
125
|
+
});
|
|
126
|
+
expect(layoutRect(child, frame)).toEqual({ left: 150, top: 80, width: 80, height: 20 });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("refuses to place a box it cannot read", () => {
|
|
130
|
+
const frame = document.createElement("div");
|
|
131
|
+
const child = document.createElement("div");
|
|
132
|
+
frame.appendChild(child);
|
|
133
|
+
document.body.appendChild(frame);
|
|
134
|
+
laid(frame, { left: 0, top: 0, w: 200, h: 60 }, document.body);
|
|
135
|
+
laid(child, { left: 10, top: 10, w: 40, h: 10 }, frame);
|
|
136
|
+
drawn(frame, { left: 0, right: 200, top: 0, bottom: 0 });
|
|
137
|
+
expect(layoutRect(child, frame), "a frame drawn with no height places nothing").toBeNull();
|
|
138
|
+
|
|
139
|
+
drawn(frame, { left: 0, right: 200, top: 0, bottom: 60 });
|
|
140
|
+
laid(child, { left: 10, top: 10, w: 0, h: 0 }, frame);
|
|
141
|
+
expect(layoutRect(child, frame), "an item with no box places nothing").toBeNull();
|
|
142
|
+
|
|
143
|
+
// A chain through something without offsets of its own cannot be added up.
|
|
144
|
+
laid(child, { left: 10, top: 10, w: 40, h: 10 }, frame);
|
|
145
|
+
const inner = document.createElement("div");
|
|
146
|
+
Object.defineProperty(inner, "offsetLeft", { configurable: true, get: () => undefined });
|
|
147
|
+
Object.defineProperty(inner, "offsetTop", { configurable: true, get: () => undefined });
|
|
148
|
+
Object.defineProperty(inner, "offsetParent", { configurable: true, get: () => frame });
|
|
149
|
+
laid(child, { left: 10, top: 10, w: 40, h: 10 }, inner);
|
|
150
|
+
expect(layoutOffset(child, frame).x).toBeNaN();
|
|
151
|
+
expect(layoutRect(child, frame)).toBeNull();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("measures how much bigger an element is drawn than laid out", () => {
|
|
155
|
+
const el = document.createElement("div");
|
|
156
|
+
document.body.appendChild(el);
|
|
157
|
+
el.style.width = "100px";
|
|
158
|
+
el.style.height = "50px";
|
|
159
|
+
laid(el, { left: 0, top: 0, w: 100, h: 50 }, document.body);
|
|
160
|
+
drawn(el, { left: 0, right: 150, top: 0, bottom: 75 });
|
|
161
|
+
expect(drawnScale(el)).toEqual({ x: 1.5, y: 1.5 });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("walks up to the nearest ancestor that has a box", () => {
|
|
165
|
+
const outer = document.createElement("div");
|
|
166
|
+
const contentless = document.createElement("div");
|
|
167
|
+
const child = document.createElement("div");
|
|
168
|
+
outer.appendChild(contentless);
|
|
169
|
+
contentless.appendChild(child);
|
|
170
|
+
document.body.appendChild(outer);
|
|
171
|
+
drawn(outer, { left: 0, right: 300, top: 0, bottom: 60 });
|
|
172
|
+
laid(outer, { left: 0, top: 0, w: 300, h: 60 }, null);
|
|
173
|
+
drawn(contentless, { left: 0, right: 0, top: 0, bottom: 0 });
|
|
174
|
+
laid(contentless, { left: 0, top: 0, w: 0, h: 0 }, outer);
|
|
175
|
+
expect(nearestLaidAncestor(child), "a boxless wrapper is not a frame").toBe(outer);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* layoutGeometry — where things are LAID OUT, as opposed to where they are
|
|
3
|
+
* DRAWN.
|
|
4
|
+
*
|
|
5
|
+
* A browser keeps two answers to "where is this element", and hikari's
|
|
6
|
+
* interactions keep needing the first one:
|
|
7
|
+
*
|
|
8
|
+
* - the DRAWN box (`getBoundingClientRect`, `getClientRects`, `Range`,
|
|
9
|
+
* `elementFromPoint`): what the user sees, after every `transform`, zoom,
|
|
10
|
+
* drag lift, FLIP animation and transition in flight. Every one of those
|
|
11
|
+
* moves it.
|
|
12
|
+
* - the LAYOUT box (`offsetLeft`, `offsetTop`, `offsetWidth`,
|
|
13
|
+
* `offsetHeight`, walked along the `offsetParent` chain, plus
|
|
14
|
+
* `scrollLeft`/`scrollTop`): where the engine put the element in the flow,
|
|
15
|
+
* in CSS pixels that no transform touches.
|
|
16
|
+
*
|
|
17
|
+
* The difference matters when a gesture has to compare an element that carries
|
|
18
|
+
* a live transform against one that does not — a dragged chip against its
|
|
19
|
+
* neighbours, a ghost against the strip it came from, an overlay against a
|
|
20
|
+
* scrolled container — because the drawn box of the transformed element
|
|
21
|
+
* answers a question about the transform rather than about the layout. These
|
|
22
|
+
* helpers read the layout side and place it back in the viewport coordinates
|
|
23
|
+
* the rest of a component measures in, so the two can be compared.
|
|
24
|
+
*
|
|
25
|
+
* Measured in Chromium (2026-09, the wave that introduced this file):
|
|
26
|
+
*
|
|
27
|
+
* - all four offset values are byte-identical under a transform on the element
|
|
28
|
+
* itself, on a transition mid-flight, and on any ancestor;
|
|
29
|
+
* - a transform (or even `will-change: transform`) on an ancestor REBINDS
|
|
30
|
+
* `offsetParent`, so a chain must be re-read on every use and never cached;
|
|
31
|
+
* - an offset is measured from the `offsetParent`'s PADDING edge (its border
|
|
32
|
+
* excluded, the child's margin included): a positioned `offsetParent`
|
|
33
|
+
* measures from that padding edge, while a `body` `offsetParent` is
|
|
34
|
+
* special-cased by Blink (static body → document space, positioned body →
|
|
35
|
+
* its border box). Walking BOTH the element and its frame to the same root
|
|
36
|
+
* and subtracting cancels every one of those conventions, which is why
|
|
37
|
+
* `layoutOffset` also reports whether the walk passed THROUGH the frame:
|
|
38
|
+
* only then is the difference measured from the frame's padding box, and
|
|
39
|
+
* only then does its border have to be added back;
|
|
40
|
+
* - the four values are integers (rounded half-up, ~±0.5px per hop) while the
|
|
41
|
+
* computed `width`/`height` are fractional — `laidSize` takes the finer pair
|
|
42
|
+
* and rebuilds the border box the integer values measure in;
|
|
43
|
+
* - `scrollLeft`/`scrollTop` are layout values too, so a scroll has to be
|
|
44
|
+
* applied in layout units and scaled with the content the frame holds.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/** What places a coordinate: the frame's drawn box, its laid-out size (which
|
|
48
|
+
* no transform touches, so the two together are the scale it is drawn at)
|
|
49
|
+
* and the scroll that slides its content inside the box. */
|
|
50
|
+
export interface FrameMetrics {
|
|
51
|
+
/** The frame's drawn border box, in viewport coordinates. */
|
|
52
|
+
x: number;
|
|
53
|
+
y: number;
|
|
54
|
+
w: number;
|
|
55
|
+
h: number;
|
|
56
|
+
/** The frame's laid-out border box (`0` when it has no layout). */
|
|
57
|
+
laidW: number;
|
|
58
|
+
laidH: number;
|
|
59
|
+
/** The frame's own scroll offsets. */
|
|
60
|
+
scrollX: number;
|
|
61
|
+
scrollY: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A border box in viewport coordinates. */
|
|
65
|
+
export interface LayoutRect {
|
|
66
|
+
left: number;
|
|
67
|
+
top: number;
|
|
68
|
+
width: number;
|
|
69
|
+
height: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A point in the layout space an element's ancestors share. */
|
|
73
|
+
export interface LayoutOffset {
|
|
74
|
+
x: number;
|
|
75
|
+
y: number;
|
|
76
|
+
/** Whether the walk passed through the frame it was asked for. */
|
|
77
|
+
through: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** An element's border box in the layout space its ancestors share — the
|
|
81
|
+
* position CSS transforms do not move. `through` reports whether the walk
|
|
82
|
+
* passed through `frame`, which is what decides whether a caller has to add
|
|
83
|
+
* that frame's border back (see the file comment).
|
|
84
|
+
*
|
|
85
|
+
* A chain that runs through something without offsets of its own (an element
|
|
86
|
+
* inside `<svg>`) sums to `NaN`: callers check `Number.isFinite` before
|
|
87
|
+
* placing anything with it. */
|
|
88
|
+
export function layoutOffset(el: HTMLElement, frame?: HTMLElement | null): LayoutOffset {
|
|
89
|
+
let x = 0;
|
|
90
|
+
let y = 0;
|
|
91
|
+
let through = false;
|
|
92
|
+
for (let node: HTMLElement | null = el; node; node = node.offsetParent as HTMLElement | null) {
|
|
93
|
+
x += node.offsetLeft;
|
|
94
|
+
y += node.offsetTop;
|
|
95
|
+
if (node === frame) through = true;
|
|
96
|
+
}
|
|
97
|
+
return { x, y, through };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** An element's laid-out BORDER box, as finely as the engine will report it.
|
|
101
|
+
* `offsetWidth`/`offsetHeight` are integers — they round, and half a pixel is
|
|
102
|
+
* a third of the line filter's entire tolerance — while the computed
|
|
103
|
+
* `width`/`height` are fractional. The computed pair describes the CONTENT
|
|
104
|
+
* box unless the element is `box-sizing: border-box`, so its padding and
|
|
105
|
+
* border are added back to reach the border box that `offsetWidth`,
|
|
106
|
+
* `getBoundingClientRect` and every measurement here uses. Anything
|
|
107
|
+
* unreadable (`auto` on an unrendered element, no layout at all) falls back
|
|
108
|
+
* to the integer pair. */
|
|
109
|
+
export function laidSize(el: HTMLElement): { w: number; h: number } {
|
|
110
|
+
const style = getComputedStyle(el);
|
|
111
|
+
const edge = (value: string): number => parseFloat(value) || 0;
|
|
112
|
+
const size = (along: "w" | "h"): number => {
|
|
113
|
+
const integer = along === "w" ? el.offsetWidth : el.offsetHeight;
|
|
114
|
+
const computed = parseFloat(along === "w" ? style.width : style.height);
|
|
115
|
+
if (!Number.isFinite(computed)) return integer;
|
|
116
|
+
if (style.boxSizing === "border-box") return computed;
|
|
117
|
+
const padding =
|
|
118
|
+
along === "w"
|
|
119
|
+
? edge(style.paddingLeft) + edge(style.paddingRight)
|
|
120
|
+
: edge(style.paddingTop) + edge(style.paddingBottom);
|
|
121
|
+
const border =
|
|
122
|
+
along === "w"
|
|
123
|
+
? edge(style.borderLeftWidth) + edge(style.borderRightWidth)
|
|
124
|
+
: edge(style.borderTopWidth) + edge(style.borderBottomWidth);
|
|
125
|
+
return computed + padding + border;
|
|
126
|
+
};
|
|
127
|
+
return { w: size("w"), h: size("h") };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** How much bigger an element is DRAWN than it is laid out, per axis: the
|
|
131
|
+
* cumulative scale of every transform above it (its own included). `1` per
|
|
132
|
+
* axis when either side of the ratio cannot be read. */
|
|
133
|
+
export function drawnScale(el: HTMLElement): { x: number; y: number } {
|
|
134
|
+
const rect = el.getBoundingClientRect();
|
|
135
|
+
const laid = laidSize(el);
|
|
136
|
+
return {
|
|
137
|
+
x: laid.w > 0 && rect.width > 0 ? rect.width / laid.w : 1,
|
|
138
|
+
y: laid.h > 0 && rect.height > 0 ? rect.height / laid.h : 1,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Read a frame as it is right now (see `FrameMetrics`). */
|
|
143
|
+
export function frameMetrics(frame: HTMLElement): FrameMetrics {
|
|
144
|
+
const rect = frame.getBoundingClientRect();
|
|
145
|
+
return {
|
|
146
|
+
x: rect.left,
|
|
147
|
+
y: rect.top,
|
|
148
|
+
w: rect.width,
|
|
149
|
+
h: rect.height,
|
|
150
|
+
laidW: frame.offsetWidth,
|
|
151
|
+
laidH: frame.offsetHeight,
|
|
152
|
+
scrollX: frame.scrollLeft,
|
|
153
|
+
scrollY: frame.scrollTop,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The scale a frame draws its content at, per axis (`1` when its laid-out
|
|
158
|
+
* size cannot be read). */
|
|
159
|
+
export function frameScale(box: FrameMetrics): { x: number; y: number } {
|
|
160
|
+
return {
|
|
161
|
+
x: box.laidW > 0 ? box.w / box.laidW : 1,
|
|
162
|
+
y: box.laidH > 0 ? box.h / box.laidH : 1,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Where a frame DRAWS a point that was laid out at `at` inside it, given the
|
|
167
|
+
* frame's metrics and the offset-chain sums of the element (`at`) and of the
|
|
168
|
+
* frame itself (`of`). This is the placement every helper here reduces to:
|
|
169
|
+
* the frame's drawn origin, plus its border when the element's chain ran
|
|
170
|
+
* through it (a positioned frame measures its children from its padding
|
|
171
|
+
* edge), plus the element's offset from that origin taken against the scroll
|
|
172
|
+
* the frame has since moved, all at the scale the frame is drawn at. */
|
|
173
|
+
export function placePoint(
|
|
174
|
+
frame: HTMLElement,
|
|
175
|
+
box: FrameMetrics,
|
|
176
|
+
at: LayoutOffset,
|
|
177
|
+
of: LayoutOffset,
|
|
178
|
+
): { x: number; y: number } {
|
|
179
|
+
const scale = frameScale(box);
|
|
180
|
+
const style = getComputedStyle(frame);
|
|
181
|
+
const border = (edge: string, factor: number): number =>
|
|
182
|
+
at.through ? (parseFloat(edge) || 0) * factor : 0;
|
|
183
|
+
return {
|
|
184
|
+
x: box.x + border(style.borderLeftWidth, scale.x) + (at.x - of.x - box.scrollX) * scale.x,
|
|
185
|
+
y: box.y + border(style.borderTopWidth, scale.y) + (at.y - of.y - box.scrollY) * scale.y,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The nearest ancestor of `el` that is actually LAID OUT. A layout box can
|
|
190
|
+
* only be measured inside a frame that has a box to measure it in: an element
|
|
191
|
+
* with `display: contents` generates none, and its children are laid out by
|
|
192
|
+
* the next box up — which is the frame their movement follows. A tree with no
|
|
193
|
+
* boxes anywhere (no layout engine, a test environment) walks out of
|
|
194
|
+
* ancestors and keeps the parent it started with. */
|
|
195
|
+
export function nearestLaidAncestor(el: HTMLElement): HTMLElement | null {
|
|
196
|
+
for (let node = el.parentElement; node; node = node.parentElement) {
|
|
197
|
+
const rect = node.getBoundingClientRect();
|
|
198
|
+
if (rect.width > 0 || rect.height > 0 || node.offsetWidth > 0 || node.offsetHeight > 0) {
|
|
199
|
+
return node;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return el.parentElement;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The element's layout border box in VIEWPORT coordinates — where the layout
|
|
206
|
+
* says it is, whatever its own transform (or any transform above it) draws.
|
|
207
|
+
* `frame` defaults to the element's `offsetParent`; pass the box the caller
|
|
208
|
+
* measures against when that is a different element (a strip's frame, a
|
|
209
|
+
* scrolled container). `null` when nothing can be placed: no frame, a frame
|
|
210
|
+
* that is detached, a chain that cannot be added up, or a frame drawn with no
|
|
211
|
+
* size on an axis the box needs. */
|
|
212
|
+
export function layoutRect(el: HTMLElement, frame?: HTMLElement | null): LayoutRect | null {
|
|
213
|
+
const target = (frame === undefined ? el.offsetParent : frame) as HTMLElement | null;
|
|
214
|
+
if (!target || !target.isConnected) return null;
|
|
215
|
+
const box = frameMetrics(target);
|
|
216
|
+
const at = layoutOffset(el, target);
|
|
217
|
+
const of = layoutOffset(target, null);
|
|
218
|
+
if (!Number.isFinite(at.x) || !Number.isFinite(at.y)) return null;
|
|
219
|
+
if (!Number.isFinite(of.x) || !Number.isFinite(of.y)) return null;
|
|
220
|
+
const size = laidSize(el);
|
|
221
|
+
if (!(size.w > 0) || !(size.h > 0)) return null;
|
|
222
|
+
if (!(box.w > 0) || !(box.h > 0)) return null;
|
|
223
|
+
const drawn = placePoint(target, box, at, of);
|
|
224
|
+
const scale = frameScale(box);
|
|
225
|
+
return {
|
|
226
|
+
left: drawn.x,
|
|
227
|
+
top: drawn.y,
|
|
228
|
+
width: size.w * scale.x,
|
|
229
|
+
height: size.h * scale.y,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
@@ -124,6 +124,38 @@ describe("attachOverlayScrollbars", () => {
|
|
|
124
124
|
expect(viewport.scrollTop).toBe(80);
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
+
it("moves the thumb the distance the POINTER went, however the space is scaled", () => {
|
|
128
|
+
// The pointer moves in drawn pixels; the track it is divided by is laid
|
|
129
|
+
// out. Inside a scaled or zoomed root the two differ by the factor the
|
|
130
|
+
// space is drawn at, and the thumb would travel that many times further
|
|
131
|
+
// than the pointer — it would reach the end of the track and stop
|
|
132
|
+
// following after 1/k of the drag.
|
|
133
|
+
const { viewport, wrapper, handle } = mountViewport("vertical");
|
|
134
|
+
stubGeometry(viewport, {
|
|
135
|
+
scrollHeight: 400, clientHeight: 100, scrollTop: 0,
|
|
136
|
+
scrollWidth: 100, clientWidth: 100, scrollLeft: 0,
|
|
137
|
+
});
|
|
138
|
+
handle.update();
|
|
139
|
+
const track = wrapper.querySelector<HTMLElement>(".hk-scrollbar-track")!;
|
|
140
|
+
const thumb = wrapper.querySelector<HTMLElement>(".hk-scrollbar-thumb")!;
|
|
141
|
+
// The track is drawn twice the size it is laid out at (a zoom or a
|
|
142
|
+
// scale on any ancestor), so 100 drawn px are 50 of the track's own.
|
|
143
|
+
Object.defineProperty(track, "getBoundingClientRect", {
|
|
144
|
+
configurable: true,
|
|
145
|
+
value: () => ({ left: 0, top: 0, width: 200, height: 200, right: 200, bottom: 200, x: 0, y: 0 }) as DOMRect,
|
|
146
|
+
});
|
|
147
|
+
Object.defineProperty(track, "offsetWidth", { configurable: true, get: () => 100 });
|
|
148
|
+
Object.defineProperty(track, "offsetHeight", { configurable: true, get: () => 100 });
|
|
149
|
+
Object.defineProperty(track, "clientHeight", { configurable: true, get: () => 100 });
|
|
150
|
+
Object.defineProperty(track, "clientWidth", { configurable: true, get: () => 100 });
|
|
151
|
+
|
|
152
|
+
thumb.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientY: 0 }));
|
|
153
|
+
// 50 of the track's own px over a 75px range maps onto 300 scroll px.
|
|
154
|
+
document.dispatchEvent(new MouseEvent("mousemove", { clientY: 100 }));
|
|
155
|
+
expect(viewport.scrollTop, "half the track, half the range").toBe(200);
|
|
156
|
+
document.dispatchEvent(new MouseEvent("mouseup"));
|
|
157
|
+
});
|
|
158
|
+
|
|
127
159
|
it("pages when the track (not the thumb) is clicked", () => {
|
|
128
160
|
const { viewport, wrapper, handle } = mountViewport("vertical");
|
|
129
161
|
stubGeometry(viewport, {
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
// Works inside teleported popups/modals: attach on mount/open and call
|
|
24
24
|
// `detach()` on close/unmount so no DOM or listener leaks.
|
|
25
25
|
|
|
26
|
+
import { drawnScale } from "./layoutGeometry";
|
|
26
27
|
import { scheduleCronAfter, type CronHandle } from "../runtime/cronBus";
|
|
27
28
|
import { scheduleFrame, type AnimationHandle } from "../runtime/animationBus";
|
|
28
29
|
|
|
@@ -199,7 +200,14 @@ export function attachOverlayScrollbars(
|
|
|
199
200
|
const onMove = (e: MouseEvent) => {
|
|
200
201
|
if (!s.dragging) return;
|
|
201
202
|
const { scrollSize, clientSize } = axisMetrics(viewport, horizontal);
|
|
202
|
-
|
|
203
|
+
// The pointer moves in DRAWN pixels while the track it is compared
|
|
204
|
+
// against is laid out, so the delta is converted into the track's own
|
|
205
|
+
// units first: a scaled or zoomed root would otherwise slide the thumb
|
|
206
|
+
// `k` times further than the pointer went (see `layoutGeometry`).
|
|
207
|
+
const drawnDelta = (horizontal ? e.clientX : e.clientY) - s.dragStartClient;
|
|
208
|
+
const space = drawnScale(s.track);
|
|
209
|
+
const factor = horizontal ? space.x : space.y;
|
|
210
|
+
const delta = factor > 0 ? drawnDelta / factor : drawnDelta;
|
|
203
211
|
// Mirror updateAxis's RENDER math exactly — the thumb is sized
|
|
204
212
|
// against the TRACK box, so drag travel must divide by the same
|
|
205
213
|
// numbers or the thumb slides out of sync under short tracks.
|
|
@@ -1395,9 +1395,12 @@ describe("usePointerReorder", () => {
|
|
|
1395
1395
|
{ left: 100, right: 150, top: 0, bottom: 40 },
|
|
1396
1396
|
]);
|
|
1397
1397
|
move(140, 20);
|
|
1398
|
-
|
|
1398
|
+
// Each half comes from the freshest source that can place it: a chip
|
|
1399
|
+
// collapsed to no height has no line to read, so the line stays the one
|
|
1400
|
+
// the press found, while its position along the strip is read live.
|
|
1401
|
+
expect(handle.dragOver.value, "the position follows the layout").toBe(2);
|
|
1399
1402
|
up(140, 20);
|
|
1400
|
-
expect(drops).toEqual([]);
|
|
1403
|
+
expect(drops).toEqual([[1, 2]]);
|
|
1401
1404
|
});
|
|
1402
1405
|
|
|
1403
1406
|
it("ends the gesture the moment the strip empties", () => {
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { getCurrentScope, onScopeDispose, ref, type Ref } from "vue";
|
|
2
2
|
|
|
3
|
+
import {
|
|
4
|
+
frameMetrics,
|
|
5
|
+
frameScale,
|
|
6
|
+
laidSize,
|
|
7
|
+
layoutOffset,
|
|
8
|
+
nearestLaidAncestor,
|
|
9
|
+
placePoint,
|
|
10
|
+
type FrameMetrics,
|
|
11
|
+
} from "./layoutGeometry";
|
|
12
|
+
|
|
3
13
|
/** The axis a reorderable strip flows along: field chips run left to
|
|
4
14
|
* right, stacked panel rows top to bottom. */
|
|
5
15
|
export type PointerReorderAxis = "x" | "y";
|
|
@@ -252,7 +262,7 @@ export function usePointerReorder(options: PointerReorderOptions): PointerReorde
|
|
|
252
262
|
let pressedBand: { lo: number; hi: number } | null = null;
|
|
253
263
|
let pressedMid = 0;
|
|
254
264
|
let pressedFrame: HTMLElement | null = null;
|
|
255
|
-
let pressedFrameBox:
|
|
265
|
+
let pressedFrameBox: FrameMetrics | null = null;
|
|
256
266
|
/** The very ELEMENT the press landed on. The drag is moved BY element:
|
|
257
267
|
* the index is re-read from it on every resolution, so a strip that
|
|
258
268
|
* reorders under the gesture keeps the drop on what the pointer grabbed
|
|
@@ -312,156 +322,60 @@ export function usePointerReorder(options: PointerReorderOptions): PointerReorde
|
|
|
312
322
|
);
|
|
313
323
|
}
|
|
314
324
|
|
|
315
|
-
/**
|
|
316
|
-
* Everything the frame says about where it puts a coordinate: the box the
|
|
317
|
-
* item is laid out in — where it is drawn (`x`/`y`), how big it is drawn
|
|
318
|
-
* (`w`/`h`) and how big it is LAID OUT (`laidW`/`laidH`, which no
|
|
319
|
-
* transform touches, so the two together are the scale it is drawn at) —
|
|
320
|
-
* and the scroll that slides its children inside that box.
|
|
321
|
-
*/
|
|
322
|
-
interface FrameBox {
|
|
323
|
-
x: number;
|
|
324
|
-
y: number;
|
|
325
|
-
w: number;
|
|
326
|
-
h: number;
|
|
327
|
-
laidW: number;
|
|
328
|
-
laidH: number;
|
|
329
|
-
scrollX: number;
|
|
330
|
-
scrollY: number;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
/** The nearest ancestor of `item` that is actually LAID OUT. A snapshot
|
|
334
|
-
* can only be carried in a frame that has a box to move it: an element
|
|
335
|
-
* with `display: contents` generates none, and its children are laid out
|
|
336
|
-
* by the next box up — which is the frame whose movement they follow. A
|
|
337
|
-
* tree with no boxes anywhere (no layout engine at all, a test
|
|
338
|
-
* environment) walks out of ancestors and keeps the parent it started
|
|
339
|
-
* with. */
|
|
340
|
-
function layoutFrameOf(item: HTMLElement): HTMLElement | null {
|
|
341
|
-
for (let el = item.parentElement; el; el = el.parentElement) {
|
|
342
|
-
const rect = el.getBoundingClientRect();
|
|
343
|
-
if (rect.width > 0 || rect.height > 0 || el.offsetWidth > 0 || el.offsetHeight > 0) return el;
|
|
344
|
-
}
|
|
345
|
-
return item.parentElement;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/** Read the frame as it is right now (see `FrameBox`). */
|
|
349
|
-
function frameBox(frame: HTMLElement): FrameBox {
|
|
350
|
-
const rect = frame.getBoundingClientRect();
|
|
351
|
-
return {
|
|
352
|
-
x: rect.left,
|
|
353
|
-
y: rect.top,
|
|
354
|
-
w: rect.width,
|
|
355
|
-
h: rect.height,
|
|
356
|
-
laidW: frame.offsetWidth,
|
|
357
|
-
laidH: frame.offsetHeight,
|
|
358
|
-
scrollX: frame.scrollLeft,
|
|
359
|
-
scrollY: frame.scrollTop,
|
|
360
|
-
};
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
/** Where an element's border box sits in a space its ancestors share: the
|
|
364
|
-
* one number CSS transforms do not touch. `offsetLeft`/`offsetTop` are
|
|
365
|
-
* LAYOUT positions — the drag's own lift, a host `scale()`, a FLIP
|
|
366
|
-
* animation and a transition mid-flight all leave them exactly where they
|
|
367
|
-
* were — so walking the offsetParent chain of the item and of its frame
|
|
368
|
-
* and subtracting gives the item's current place inside the frame however
|
|
369
|
-
* it is drawn. Both sides are walked to the same root, so a frame that is
|
|
370
|
-
* not itself an offsetParent (a static frame) cancels exactly.
|
|
371
|
-
*
|
|
372
|
-
* `through` reports whether the walk passed THROUGH the frame, which is
|
|
373
|
-
* what decides how the two differ: an offset is measured from the
|
|
374
|
-
* offsetParent's PADDING edge, so a chain that runs through the frame
|
|
375
|
-
* leaves the difference measured from the frame's padding box (its border
|
|
376
|
-
* has to be added back), while a chain that skips it — a frame that is
|
|
377
|
-
* `position: static` is nobody's offsetParent — leaves the difference
|
|
378
|
-
* measured from the frame's BORDER box, where adding the border again
|
|
379
|
-
* would double-count it.
|
|
380
|
-
*
|
|
381
|
-
* The chain must be walked LIVE: a transform (or even
|
|
382
|
-
* `will-change: transform`) on an ancestor rebinds `offsetParent`. */
|
|
383
|
-
function layoutOffset(
|
|
384
|
-
el: HTMLElement,
|
|
385
|
-
frame: HTMLElement | null,
|
|
386
|
-
): { x: number; y: number; through: boolean } {
|
|
387
|
-
let x = 0;
|
|
388
|
-
let y = 0;
|
|
389
|
-
let through = false;
|
|
390
|
-
for (let node: HTMLElement | null = el; node; node = node.offsetParent as HTMLElement | null) {
|
|
391
|
-
x += node.offsetLeft;
|
|
392
|
-
y += node.offsetTop;
|
|
393
|
-
if (node === frame) through = true;
|
|
394
|
-
}
|
|
395
|
-
return { x, y, through };
|
|
396
|
-
}
|
|
397
|
-
|
|
398
325
|
/** The pressed item's layout geometry read off the LIVE strip rather than
|
|
399
326
|
* carried from the press — the answer to "where is the item I am holding
|
|
400
327
|
* NOW?", which is what changes when the list reorders, an item is added or
|
|
401
328
|
* removed around it, a font or a zoom changes, or the strip re-wraps.
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
329
|
+
*
|
|
330
|
+
* The two halves are read SEPARATELY: the line the item is on needs the
|
|
331
|
+
* cross axis to be expressible, the position along it needs the main axis,
|
|
332
|
+
* and a strip whose frame draws no size on one of them can still answer
|
|
333
|
+
* the other (the caller keeps the carried snapshot for whichever half
|
|
334
|
+
* cannot be placed). A chain that cannot be added up at all — it runs
|
|
335
|
+
* through something without offsets of its own, like an element inside
|
|
336
|
+
* `<svg>` — leaves both `null`. */
|
|
337
|
+
function derivedGeometry(): { band: { lo: number; hi: number } | null; mid: number | null } {
|
|
338
|
+
const nothing = { band: null, mid: null } as const;
|
|
339
|
+
if (!pressedItem || !pressedFrame || !pressedFrame.isConnected) return nothing;
|
|
340
|
+
const box = frameMetrics(pressedFrame);
|
|
412
341
|
const at = layoutOffset(pressedItem, pressedFrame);
|
|
413
342
|
const of = layoutOffset(pressedFrame, null);
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
const
|
|
420
|
-
const
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
const
|
|
425
|
-
const
|
|
426
|
-
const
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
343
|
+
if (!Number.isFinite(at.x) || !Number.isFinite(at.y)) return nothing;
|
|
344
|
+
if (!Number.isFinite(of.x) || !Number.isFinite(of.y)) return nothing;
|
|
345
|
+
const size = laidSize(pressedItem);
|
|
346
|
+
// Where the frame draws the item's laid-out origin, and how big it draws
|
|
347
|
+
// what the item laid out there.
|
|
348
|
+
const drawn = placePoint(pressedFrame, box, at, of);
|
|
349
|
+
const scale = frameScale(box);
|
|
350
|
+
const itemW = size.w * scale.x;
|
|
351
|
+
const itemH = size.h * scale.y;
|
|
352
|
+
// Along the strip (`axis`) versus across it.
|
|
353
|
+
const drawnAlong = axis === "x" ? box.w : box.h;
|
|
354
|
+
const drawnAcross = axis === "x" ? box.h : box.w;
|
|
355
|
+
const itemAlong = axis === "x" ? size.w : size.h;
|
|
356
|
+
const itemAcross = axis === "x" ? size.h : size.w;
|
|
357
|
+
return {
|
|
358
|
+
// The line the item is on needs a cross-axis extent on both sides; the
|
|
359
|
+
// position along the strip needs a main-axis one. A strip that draws no
|
|
360
|
+
// size on one of them still answers the other (`layoutOrigin`).
|
|
361
|
+
band:
|
|
362
|
+
drawnAcross > 0 && itemAcross > 0
|
|
363
|
+
? axis === "x"
|
|
364
|
+
? { lo: drawn.y, hi: drawn.y + itemH }
|
|
365
|
+
: { lo: drawn.x, hi: drawn.x + itemW }
|
|
366
|
+
: null,
|
|
367
|
+
mid:
|
|
368
|
+
drawnAlong > 0 && itemAlong > 0
|
|
369
|
+
? axis === "x"
|
|
370
|
+
? drawn.x + itemW / 2
|
|
371
|
+
: drawn.y + itemH / 2
|
|
372
|
+
: null,
|
|
373
|
+
};
|
|
433
374
|
}
|
|
434
375
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
* a host scrolling the surface or any scroller above it, a page scroll, a
|
|
439
|
-
* re-parent, a transform on the frame or on an ancestor — has to be
|
|
440
|
-
* carried with it, or the line the item was laid out on would be compared
|
|
441
|
-
* against siblings that have since moved away from it. `null` before any
|
|
442
|
-
* press, and for a press whose item was not in the strip.
|
|
443
|
-
*
|
|
444
|
-
* The carrier is the frame's own reading of a coordinate: the snapshot
|
|
445
|
-
* travels to wherever the frame puts that same place in its layout now,
|
|
446
|
-
* which is exact for the three things that can move it —
|
|
447
|
-
*
|
|
448
|
-
* - a DISPLACEMENT of the frame (a page scroll, a re-parent, a
|
|
449
|
-
* translation) moves the snapshot by the same amount;
|
|
450
|
-
* - the frame's own SCROLL slides its children inside a box that does not
|
|
451
|
-
* move, so it is taken off inside the frame's own units rather than in
|
|
452
|
-
* drawn pixels (`sample`);
|
|
453
|
-
* - a `scale()` or a zoom — on the frame or on an ancestor — keeps the
|
|
454
|
-
* snapshot at the same place INSIDE the frame, which is what the frame
|
|
455
|
-
* does to everything it holds, so the placement scales with it.
|
|
456
|
-
*
|
|
457
|
-
* A frame with no box to read any of this from falls back to the plain
|
|
458
|
-
* displacement. */
|
|
459
|
-
function layoutOrigin(): PointerReorderOrigin | null {
|
|
460
|
-
const live = derivedOrigin();
|
|
461
|
-
if (live) return live;
|
|
462
|
-
if (!pressedBand || !pressedFrameBox) return null;
|
|
463
|
-
const at = pressedFrameBox;
|
|
464
|
-
const now = pressedFrame && pressedFrame.isConnected ? frameBox(pressedFrame) : at;
|
|
376
|
+
function carriedOrigin(): PointerReorderOrigin {
|
|
377
|
+
const at = pressedFrameBox as FrameMetrics;
|
|
378
|
+
const now = pressedFrame && pressedFrame.isConnected ? frameMetrics(pressedFrame) : at;
|
|
465
379
|
/** Where the frame draws a coordinate it drew at `from` when the press
|
|
466
380
|
* was read: how far it sat from the frame's content origin, taken
|
|
467
381
|
* against the scroll the frame has moved since, drawn again at the
|
|
@@ -489,11 +403,33 @@ export function usePointerReorder(options: PointerReorderOptions): PointerReorde
|
|
|
489
403
|
return to + ratio * (value - from) + (scrollFrom - scrollTo);
|
|
490
404
|
};
|
|
491
405
|
const down = axis === "x" ? "y" : "x";
|
|
492
|
-
const lo = carry(
|
|
493
|
-
const hi = carry(
|
|
406
|
+
const lo = carry(pressedBandLo(), down);
|
|
407
|
+
const hi = carry(pressedBandHi(), down);
|
|
494
408
|
return { band: { lo, hi }, mid: carry(pressedMid, axis) };
|
|
495
409
|
}
|
|
496
410
|
|
|
411
|
+
function layoutOrigin(): PointerReorderOrigin | null {
|
|
412
|
+
const live = derivedGeometry();
|
|
413
|
+
if (!pressedBand || !pressedFrameBox) {
|
|
414
|
+
// Nothing carried to fall back on: only a complete reading places it.
|
|
415
|
+
return live.band && live.mid !== null ? { band: live.band, mid: live.mid } : null;
|
|
416
|
+
}
|
|
417
|
+
const carried = carriedOrigin();
|
|
418
|
+
return {
|
|
419
|
+
band: live.band ?? carried.band,
|
|
420
|
+
mid: live.mid ?? carried.mid,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** The pressed item's own band, for the callers that know a press is live. */
|
|
425
|
+
function pressedBandLo(): number {
|
|
426
|
+
return pressedBand ? pressedBand.lo : 0;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function pressedBandHi(): number {
|
|
430
|
+
return pressedBand ? pressedBand.hi : 0;
|
|
431
|
+
}
|
|
432
|
+
|
|
497
433
|
/** The pointer's coordinates split by axis: the position along the strip
|
|
498
434
|
* and the position across it. */
|
|
499
435
|
function pointerAt(event: PointerEvent): { main: number; cross: number } {
|
|
@@ -741,8 +677,8 @@ export function usePointerReorder(options: PointerReorderOptions): PointerReorde
|
|
|
741
677
|
? rect.left + rect.width / 2
|
|
742
678
|
: rect.top + rect.height / 2
|
|
743
679
|
: 0;
|
|
744
|
-
pressedFrame = item ?
|
|
745
|
-
pressedFrameBox = pressedFrame ?
|
|
680
|
+
pressedFrame = item ? nearestLaidAncestor(item) : null;
|
|
681
|
+
pressedFrameBox = pressedFrame ? frameMetrics(pressedFrame) : null;
|
|
746
682
|
pressedItem = item ?? null;
|
|
747
683
|
originX = event.clientX;
|
|
748
684
|
originY = event.clientY;
|