@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.43.12",
3
+ "version": "0.43.13",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -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 fractional
68
- // bounding-rect width keeps the CSS loop shift bit-exact with the
69
- // laid-out advance — an integer-rounded width would show a ≤0.5px
70
- // seam at every wrap (the copy rides the animated strip, so the
71
- // width itself is never affected by the strip's transform).
72
- const copyWidth = copy.getBoundingClientRect().width;
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
- const width = probe.getBoundingClientRect().width;
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
- const delta = (horizontal ? e.clientX : e.clientY) - s.dragStartClient;
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
- expect(handle.dragOver.value, "a chip with no height has no line to stand on").toBe(1);
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: FrameBox | null = null;
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
- * `null` when there is no box to read (no layout engine at all, a detached
403
- * element, a collapsed or partially-readable one), which keeps the caller
404
- * on the carried snapshot. */
405
- function derivedOrigin(): PointerReorderOrigin | null {
406
- if (!pressedItem || !pressedFrame || !pressedFrame.isConnected) return null;
407
- const box = frameBox(pressedFrame);
408
- const itemW = pressedItem.offsetWidth;
409
- const itemH = pressedItem.offsetHeight;
410
- if (!(itemW > 0) || !(itemH > 0)) return null;
411
- if (!(box.w > 0) || !(box.h > 0)) return null;
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
- // A chain that runs through something without layout offsets of its own
415
- // (an SVG ancestor, a foreignObject boundary) sums to NaN rather than
416
- // failing: nothing can be placed from it, so the snapshot keeps the call.
417
- if (!Number.isFinite(at.x) || !Number.isFinite(at.y)) return null;
418
- if (!Number.isFinite(of.x) || !Number.isFinite(of.y)) return null;
419
- const scaleX = box.laidW > 0 ? box.w / box.laidW : 1;
420
- const scaleY = box.laidH > 0 ? box.h / box.laidH : 1;
421
- /** The frame's own border, in the units it is DRAWN at — and only when
422
- * the walk into the frame came through it (`through`). */
423
- const style = getComputedStyle(pressedFrame);
424
- const borderLeft = at.through ? (parseFloat(style.borderLeftWidth) || 0) * scaleX : 0;
425
- const borderTop = at.through ? (parseFloat(style.borderTopWidth) || 0) * scaleY : 0;
426
- const left = box.x + borderLeft + (at.x - of.x - box.scrollX) * scaleX;
427
- const top = box.y + borderTop + (at.y - of.y - box.scrollY) * scaleY;
428
- const width = itemW * scaleX;
429
- const height = itemH * scaleY;
430
- return axis === "x"
431
- ? { band: { lo: top, hi: top + height }, mid: left + width / 2 }
432
- : { band: { lo: left, hi: left + width }, mid: top + height / 2 };
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
- /** The pressed item's layout geometry for `indexAt`, placed where the
436
- * frame puts it NOW. The snapshot is in VIEWPORT coordinates, so anything
437
- * that moves the frame under it — the auto-scroll this composable drives,
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(pressedBand.lo, down);
493
- const hi = carry(pressedBand.hi, down);
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 ? layoutFrameOf(item) : null;
745
- pressedFrameBox = pressedFrame ? frameBox(pressedFrame) : null;
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;