@anyknown/axis 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +216 -2
- package/dist/axis.d.ts +98 -0
- package/dist/axis.js +655 -0
- package/dist/coordinator.d.ts +36 -0
- package/dist/coordinator.js +247 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/math.d.ts +90 -0
- package/dist/math.js +139 -0
- package/dist/react.d.ts +22 -0
- package/dist/react.js +49 -0
- package/package.json +83 -5
package/dist/axis.js
ADDED
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
import { isScrollKey, register } from "./coordinator.js";
|
|
2
|
+
import { adjacentSnap, clamp, easeOutCubic, glideDuration, MIN_VELOCITY, momentumDistance, momentumStep, nearestSnap, parseSnapAlign, parseSnapType, snapPositions, toLogical, toPhysical, } from "./math.js";
|
|
3
|
+
const AXES = ["x", "y"];
|
|
4
|
+
const EDGE = 1;
|
|
5
|
+
const WHEEL_SETTLE_MS = 150;
|
|
6
|
+
const LINE_HEIGHT = 16;
|
|
7
|
+
const DRAGGING_ATTR = "data-axis-dragging";
|
|
8
|
+
const ZERO = { x: 0, y: 0 };
|
|
9
|
+
const EMPTY_AXIS = {
|
|
10
|
+
scroll: 0,
|
|
11
|
+
maxScroll: 0,
|
|
12
|
+
progress: 0,
|
|
13
|
+
canScrollPrev: false,
|
|
14
|
+
canScrollNext: false,
|
|
15
|
+
};
|
|
16
|
+
const INITIAL_STATE = { x: EMPTY_AXIS, y: EMPTY_AXIS, isDragging: false, isMomentum: false };
|
|
17
|
+
const DEFAULTS = { axis: "x", momentum: true, wheel: false, cursor: true };
|
|
18
|
+
function merge(base, patch) {
|
|
19
|
+
const next = { ...base };
|
|
20
|
+
for (const key of Object.keys(DEFAULTS)) {
|
|
21
|
+
if (patch[key] !== undefined)
|
|
22
|
+
next[key] = patch[key];
|
|
23
|
+
}
|
|
24
|
+
return next;
|
|
25
|
+
}
|
|
26
|
+
function axisState(scroll, max, previous) {
|
|
27
|
+
const next = {
|
|
28
|
+
scroll,
|
|
29
|
+
maxScroll: max,
|
|
30
|
+
progress: max > 0 ? scroll / max : 0,
|
|
31
|
+
canScrollPrev: scroll > EDGE,
|
|
32
|
+
canScrollNext: scroll < max - EDGE,
|
|
33
|
+
};
|
|
34
|
+
for (const key of Object.keys(next))
|
|
35
|
+
if (next[key] !== previous[key])
|
|
36
|
+
return next;
|
|
37
|
+
return previous;
|
|
38
|
+
}
|
|
39
|
+
function px(value, basis) {
|
|
40
|
+
const number = Number.parseFloat(value);
|
|
41
|
+
if (Number.isNaN(number))
|
|
42
|
+
return 0;
|
|
43
|
+
return value.endsWith("%") ? (basis * number) / 100 : number;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Enhances a scroll container (`overflow: auto`) with mouse drag-scrolling, momentum, snap-aware release,
|
|
47
|
+
* optional wheel translation and an observable scroll state.
|
|
48
|
+
*
|
|
49
|
+
* Native scrolling stays the source of truth: touch, trackpad, keyboard and scrollbar input are untouched,
|
|
50
|
+
* and only a primary-button mouse drag is taken over (by writing `scrollLeft` / `scrollTop`). Nested instances
|
|
51
|
+
* share the drag: the innermost instance that can scroll in the drag's direction takes it.
|
|
52
|
+
*/
|
|
53
|
+
export function createAxis(el, options = {}) {
|
|
54
|
+
const win = el.ownerDocument.defaultView ?? window;
|
|
55
|
+
let opts = merge(DEFAULTS, options);
|
|
56
|
+
let state = INITIAL_STATE;
|
|
57
|
+
let destroyed = false;
|
|
58
|
+
const listeners = new Set();
|
|
59
|
+
const allows = (axis) => opts.axis === "both" || opts.axis === axis;
|
|
60
|
+
// --- inline style overrides -------------------------------------------------------------------------
|
|
61
|
+
const hadStyleAttr = el.hasAttribute("style");
|
|
62
|
+
// Safari only knows the prefixed property; Chrome aliases the two, so exactly one of them is used.
|
|
63
|
+
const USER_SELECT = "userSelect" in el.style ? "user-select" : "-webkit-user-select";
|
|
64
|
+
const saved = new Map();
|
|
65
|
+
// Watches the container (children, and class/style/dir anywhere inside it) and `dir` on every ancestor.
|
|
66
|
+
// The handler is defined with the rest of the wiring below.
|
|
67
|
+
const mutationObserver = new MutationObserver((records) => onMutations(records));
|
|
68
|
+
// axis's own inline styles do not change the layout. Their mutation records are dropped; records that
|
|
69
|
+
// were already pending are handed on as usual.
|
|
70
|
+
const ownStyle = (write) => {
|
|
71
|
+
const pending = mutationObserver.takeRecords();
|
|
72
|
+
write();
|
|
73
|
+
mutationObserver.takeRecords();
|
|
74
|
+
if (pending.length > 0)
|
|
75
|
+
queueMicrotask(() => onMutations(pending));
|
|
76
|
+
};
|
|
77
|
+
const override = (prop, value, important = false) => {
|
|
78
|
+
if (!saved.has(prop)) {
|
|
79
|
+
saved.set(prop, [el.style.getPropertyValue(prop), el.style.getPropertyPriority(prop)]);
|
|
80
|
+
}
|
|
81
|
+
else if (el.style.getPropertyValue(prop) === value)
|
|
82
|
+
return;
|
|
83
|
+
ownStyle(() => el.style.setProperty(prop, value, important ? "important" : ""));
|
|
84
|
+
};
|
|
85
|
+
const restore = (prop) => {
|
|
86
|
+
const original = saved.get(prop);
|
|
87
|
+
if (!original)
|
|
88
|
+
return;
|
|
89
|
+
saved.delete(prop);
|
|
90
|
+
ownStyle(() => {
|
|
91
|
+
if (original[0])
|
|
92
|
+
el.style.setProperty(prop, original[0], original[1]);
|
|
93
|
+
else
|
|
94
|
+
el.style.removeProperty(prop);
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
// --- geometry: everything below works in logical positions --------------------------------------------
|
|
98
|
+
// Cleared whenever the layout may have changed: resize and mutation observers, setOptions and refresh.
|
|
99
|
+
let snapCache = null;
|
|
100
|
+
const invalidate = () => {
|
|
101
|
+
snapCache = null;
|
|
102
|
+
};
|
|
103
|
+
const isRtl = () => win.getComputedStyle(el).direction === "rtl";
|
|
104
|
+
const maxScroll = () => ({
|
|
105
|
+
x: Math.max(0, el.scrollWidth - el.clientWidth),
|
|
106
|
+
y: Math.max(0, el.scrollHeight - el.clientHeight),
|
|
107
|
+
});
|
|
108
|
+
const read = (rtl = isRtl(), max = maxScroll()) => ({
|
|
109
|
+
x: clamp(toLogical(el.scrollLeft, rtl), 0, max.x),
|
|
110
|
+
y: clamp(el.scrollTop, 0, max.y),
|
|
111
|
+
});
|
|
112
|
+
const write = (position, rtl = isRtl()) => {
|
|
113
|
+
if (position.x !== undefined)
|
|
114
|
+
el.scrollLeft = toPhysical(position.x, rtl);
|
|
115
|
+
if (position.y !== undefined)
|
|
116
|
+
el.scrollTop = position.y;
|
|
117
|
+
};
|
|
118
|
+
const reducedMotion = () => win.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
|
|
119
|
+
const measureSnaps = () => {
|
|
120
|
+
const style = win.getComputedStyle(el);
|
|
121
|
+
const rect = el.getBoundingClientRect();
|
|
122
|
+
const max = maxScroll();
|
|
123
|
+
const items = { x: [], y: [] };
|
|
124
|
+
for (const child of el.children) {
|
|
125
|
+
const childStyle = win.getComputedStyle(child);
|
|
126
|
+
const align = parseSnapAlign(childStyle.scrollSnapAlign);
|
|
127
|
+
if (align.x === "none" && align.y === "none")
|
|
128
|
+
continue;
|
|
129
|
+
const box = child.getBoundingClientRect();
|
|
130
|
+
const margin = (side) => px(childStyle.getPropertyValue(`scroll-margin-${side}`), 0);
|
|
131
|
+
const left = margin("left");
|
|
132
|
+
const top = margin("top");
|
|
133
|
+
items.x.push({ start: box.left - left, size: box.width + left + margin("right"), align: align.x });
|
|
134
|
+
items.y.push({ start: box.top - top, size: box.height + top + margin("bottom"), align: align.y });
|
|
135
|
+
}
|
|
136
|
+
const padding = (side, basis) => px(style.getPropertyValue(`scroll-padding-${side}`), basis);
|
|
137
|
+
return {
|
|
138
|
+
x: snapPositions({
|
|
139
|
+
start: rect.left + el.clientLeft,
|
|
140
|
+
size: el.clientWidth,
|
|
141
|
+
paddingStart: padding("left", el.clientWidth),
|
|
142
|
+
paddingEnd: padding("right", el.clientWidth),
|
|
143
|
+
scroll: el.scrollLeft,
|
|
144
|
+
maxScroll: max.x,
|
|
145
|
+
rtl: style.direction === "rtl",
|
|
146
|
+
}, items.x),
|
|
147
|
+
y: snapPositions({
|
|
148
|
+
start: rect.top + el.clientTop,
|
|
149
|
+
size: el.clientHeight,
|
|
150
|
+
paddingStart: padding("top", el.clientHeight),
|
|
151
|
+
paddingEnd: padding("bottom", el.clientHeight),
|
|
152
|
+
scroll: el.scrollTop,
|
|
153
|
+
maxScroll: max.y,
|
|
154
|
+
rtl: false,
|
|
155
|
+
}, items.y),
|
|
156
|
+
};
|
|
157
|
+
};
|
|
158
|
+
const snapPoints = () => (snapCache ??= measureSnaps());
|
|
159
|
+
// --- state --------------------------------------------------------------------------------------------
|
|
160
|
+
let motion = "idle";
|
|
161
|
+
const syncCursor = (max) => {
|
|
162
|
+
if (!opts.cursor)
|
|
163
|
+
restore("cursor");
|
|
164
|
+
else if (motion === "dragging")
|
|
165
|
+
override("cursor", "grabbing");
|
|
166
|
+
else if ((allows("x") && max.x > 0) || (allows("y") && max.y > 0))
|
|
167
|
+
override("cursor", "grab");
|
|
168
|
+
else
|
|
169
|
+
restore("cursor");
|
|
170
|
+
};
|
|
171
|
+
const update = () => {
|
|
172
|
+
if (destroyed)
|
|
173
|
+
return;
|
|
174
|
+
const max = maxScroll();
|
|
175
|
+
const position = read(isRtl(), max);
|
|
176
|
+
const x = axisState(position.x, max.x, state.x);
|
|
177
|
+
const y = axisState(position.y, max.y, state.y);
|
|
178
|
+
const isDragging = motion === "dragging";
|
|
179
|
+
const isMomentum = motion === "momentum";
|
|
180
|
+
syncCursor(max);
|
|
181
|
+
if (x === state.x &&
|
|
182
|
+
y === state.y &&
|
|
183
|
+
isDragging === state.isDragging &&
|
|
184
|
+
isMomentum === state.isMomentum) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
state = { x, y, isDragging, isMomentum };
|
|
188
|
+
for (const listener of listeners)
|
|
189
|
+
listener(state);
|
|
190
|
+
};
|
|
191
|
+
const setMotion = (next) => {
|
|
192
|
+
motion = next;
|
|
193
|
+
update();
|
|
194
|
+
};
|
|
195
|
+
// --- motion: every lib-driven scroll runs with snap and smooth scrolling switched off inline -----------
|
|
196
|
+
let frame = 0;
|
|
197
|
+
let wheelTimer = 0;
|
|
198
|
+
let glideTarget = { x: 0, y: 0 };
|
|
199
|
+
// The computed scroll-snap-type from before the override, or null while nothing is overridden.
|
|
200
|
+
let snapType = null;
|
|
201
|
+
const beginOverride = () => {
|
|
202
|
+
if (snapType !== null)
|
|
203
|
+
return;
|
|
204
|
+
snapType = win.getComputedStyle(el).scrollSnapType;
|
|
205
|
+
override("scroll-snap-type", "none", true);
|
|
206
|
+
override("scroll-behavior", "auto", true);
|
|
207
|
+
};
|
|
208
|
+
const endOverride = () => {
|
|
209
|
+
if (snapType === null)
|
|
210
|
+
return;
|
|
211
|
+
snapType = null;
|
|
212
|
+
restore("scroll-snap-type");
|
|
213
|
+
restore("scroll-behavior");
|
|
214
|
+
};
|
|
215
|
+
const snapAxes = () => parseSnapType(snapType ?? win.getComputedStyle(el).scrollSnapType);
|
|
216
|
+
// While a drag lasts, the physical scroll position each instance under the pointer holds (see onScroll).
|
|
217
|
+
let held = null;
|
|
218
|
+
// True from yield() to pass() or interrupt(): another instance owns the drag and this one holds.
|
|
219
|
+
let yielded = false;
|
|
220
|
+
const stop = () => {
|
|
221
|
+
// Every scroll axis drives itself starts here, and it moves the position on purpose.
|
|
222
|
+
held = null;
|
|
223
|
+
if (frame)
|
|
224
|
+
win.cancelAnimationFrame(frame);
|
|
225
|
+
frame = 0;
|
|
226
|
+
win.clearTimeout(wheelTimer);
|
|
227
|
+
wheelTimer = 0;
|
|
228
|
+
};
|
|
229
|
+
const settle = () => {
|
|
230
|
+
stop();
|
|
231
|
+
// A glide that ends while another instance owns the drag holds where it ended.
|
|
232
|
+
if (yielded)
|
|
233
|
+
held = { x: el.scrollLeft, y: el.scrollTop };
|
|
234
|
+
endOverride();
|
|
235
|
+
setMotion("idle");
|
|
236
|
+
};
|
|
237
|
+
// Stops a glide, and lets go of a held position, when the user takes over with native input.
|
|
238
|
+
const interrupt = () => {
|
|
239
|
+
held = null;
|
|
240
|
+
yielded = false;
|
|
241
|
+
if (motion === "momentum" || motion === "glide")
|
|
242
|
+
settle();
|
|
243
|
+
};
|
|
244
|
+
const jump = (target) => {
|
|
245
|
+
stop();
|
|
246
|
+
beginOverride();
|
|
247
|
+
write(target);
|
|
248
|
+
settle();
|
|
249
|
+
};
|
|
250
|
+
// Glides both axes to `target` together. Axes missing from `target` stay where they are.
|
|
251
|
+
const animateTo = (target, velocity, kind) => {
|
|
252
|
+
stop();
|
|
253
|
+
const rtl = isRtl();
|
|
254
|
+
const from = read(rtl);
|
|
255
|
+
const moving = AXES.filter((axis) => target[axis] !== undefined && Math.abs(target[axis] - from[axis]) >= 0.5);
|
|
256
|
+
if (moving.length === 0 || reducedMotion()) {
|
|
257
|
+
jump(target);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
beginOverride();
|
|
261
|
+
glideTarget = { x: target.x ?? from.x, y: target.y ?? from.y };
|
|
262
|
+
const distance = { x: glideTarget.x - from.x, y: glideTarget.y - from.y };
|
|
263
|
+
const duration = Math.max(...moving.map((axis) => glideDuration(distance[axis], velocity[axis])));
|
|
264
|
+
const start = win.performance.now();
|
|
265
|
+
setMotion(kind);
|
|
266
|
+
// A listener may have destroyed the instance.
|
|
267
|
+
if (destroyed)
|
|
268
|
+
return;
|
|
269
|
+
const tick = (now) => {
|
|
270
|
+
if (destroyed)
|
|
271
|
+
return;
|
|
272
|
+
const eased = easeOutCubic(clamp((now - start) / duration, 0, 1));
|
|
273
|
+
const position = {};
|
|
274
|
+
for (const axis of moving)
|
|
275
|
+
position[axis] = from[axis] + distance[axis] * eased;
|
|
276
|
+
write(position, rtl);
|
|
277
|
+
if (eased < 1)
|
|
278
|
+
frame = win.requestAnimationFrame(tick);
|
|
279
|
+
else
|
|
280
|
+
settle();
|
|
281
|
+
};
|
|
282
|
+
frame = win.requestAnimationFrame(tick);
|
|
283
|
+
};
|
|
284
|
+
const runMomentum = (velocity) => {
|
|
285
|
+
stop();
|
|
286
|
+
beginOverride();
|
|
287
|
+
setMotion("momentum");
|
|
288
|
+
if (destroyed)
|
|
289
|
+
return;
|
|
290
|
+
const rtl = isRtl();
|
|
291
|
+
const max = maxScroll();
|
|
292
|
+
const position = read(rtl, max);
|
|
293
|
+
const speed = { ...velocity };
|
|
294
|
+
let last = win.performance.now();
|
|
295
|
+
const tick = (now) => {
|
|
296
|
+
if (destroyed)
|
|
297
|
+
return;
|
|
298
|
+
const dt = clamp(now - last, 0, 64);
|
|
299
|
+
last = now;
|
|
300
|
+
const next = {};
|
|
301
|
+
for (const axis of AXES) {
|
|
302
|
+
if (speed[axis] === 0)
|
|
303
|
+
continue;
|
|
304
|
+
const advance = momentumStep(speed[axis], dt);
|
|
305
|
+
position[axis] = clamp(position[axis] + advance.distance, 0, max[axis]);
|
|
306
|
+
speed[axis] = position[axis] <= 0 || position[axis] >= max[axis] ? 0 : advance.velocity;
|
|
307
|
+
next[axis] = position[axis];
|
|
308
|
+
}
|
|
309
|
+
write(next, rtl);
|
|
310
|
+
if (Math.hypot(speed.x, speed.y) < MIN_VELOCITY)
|
|
311
|
+
settle();
|
|
312
|
+
else
|
|
313
|
+
frame = win.requestAnimationFrame(tick);
|
|
314
|
+
};
|
|
315
|
+
frame = win.requestAnimationFrame(tick);
|
|
316
|
+
};
|
|
317
|
+
// Called when a drag ends. `velocity` is the logical scroll velocity in px/ms.
|
|
318
|
+
const fling = (velocity) => {
|
|
319
|
+
const allowed = opts.momentum && !reducedMotion();
|
|
320
|
+
const speed = { x: allowed && allows("x") ? velocity.x : 0, y: allowed && allows("y") ? velocity.y : 0 };
|
|
321
|
+
const snaps = snapAxes();
|
|
322
|
+
if (snaps.x || snaps.y) {
|
|
323
|
+
// Predict where momentum would land, then glide each snapping axis to its nearest snap position.
|
|
324
|
+
const max = maxScroll();
|
|
325
|
+
const from = read(isRtl(), max);
|
|
326
|
+
const positions = snapPoints();
|
|
327
|
+
const target = {};
|
|
328
|
+
for (const axis of AXES) {
|
|
329
|
+
const landing = clamp(from[axis] + momentumDistance(speed[axis]), 0, max[axis]);
|
|
330
|
+
target[axis] = snaps[axis] ? nearestSnap(positions[axis], landing) : landing;
|
|
331
|
+
}
|
|
332
|
+
animateTo(target, speed, "momentum");
|
|
333
|
+
}
|
|
334
|
+
else if (Math.hypot(speed.x, speed.y) > MIN_VELOCITY)
|
|
335
|
+
runMomentum(speed);
|
|
336
|
+
else
|
|
337
|
+
settle();
|
|
338
|
+
};
|
|
339
|
+
// --- drag (the coordinator decides which instance owns it) ---------------------------------------------
|
|
340
|
+
let pointerId = -1;
|
|
341
|
+
let pressScroll = { x: 0, y: 0 };
|
|
342
|
+
const holdBack = () => {
|
|
343
|
+
if (!held)
|
|
344
|
+
return;
|
|
345
|
+
if (Math.abs(el.scrollLeft - held.x) >= 1)
|
|
346
|
+
el.scrollLeft = held.x;
|
|
347
|
+
if (Math.abs(el.scrollTop - held.y) >= 1)
|
|
348
|
+
el.scrollTop = held.y;
|
|
349
|
+
};
|
|
350
|
+
const endDrag = () => {
|
|
351
|
+
try {
|
|
352
|
+
if (el.hasPointerCapture(pointerId))
|
|
353
|
+
el.releasePointerCapture(pointerId);
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
// The pointer is already gone.
|
|
357
|
+
}
|
|
358
|
+
pointerId = -1;
|
|
359
|
+
el.removeAttribute(DRAGGING_ATTR);
|
|
360
|
+
restore(USER_SELECT);
|
|
361
|
+
};
|
|
362
|
+
const client = {
|
|
363
|
+
el,
|
|
364
|
+
onScrollbar(event) {
|
|
365
|
+
const rect = el.getBoundingClientRect();
|
|
366
|
+
const x = event.clientX - rect.left - el.clientLeft;
|
|
367
|
+
const y = event.clientY - rect.top - el.clientTop;
|
|
368
|
+
return x < 0 || y < 0 || x >= el.clientWidth || y >= el.clientHeight;
|
|
369
|
+
},
|
|
370
|
+
accepts(axis) {
|
|
371
|
+
if (!allows(axis))
|
|
372
|
+
return false;
|
|
373
|
+
const max = maxScroll();
|
|
374
|
+
return opts.axis === "both" ? max.x > 0 || max.y > 0 : max[axis] > 0;
|
|
375
|
+
},
|
|
376
|
+
canScroll(delta) {
|
|
377
|
+
const rtl = isRtl();
|
|
378
|
+
const max = maxScroll();
|
|
379
|
+
const position = read(rtl, max);
|
|
380
|
+
// The content moves with the pointer, so the scroll position moves against it.
|
|
381
|
+
const push = { x: toLogical(-delta.x, rtl), y: -delta.y };
|
|
382
|
+
return AXES.some((axis) => allows(axis) &&
|
|
383
|
+
((push[axis] > 0 && position[axis] < max[axis] - EDGE) ||
|
|
384
|
+
(push[axis] < 0 && position[axis] > EDGE)));
|
|
385
|
+
},
|
|
386
|
+
press() {
|
|
387
|
+
// A press freezes momentum (the glide after a drag, with its snap settling), like grabbing a flung
|
|
388
|
+
// list. Overrides stay on, so the browser cannot re-snap underneath. A glide the page started
|
|
389
|
+
// (scrollTo, prev/next, wheel snapping) keeps going; it only stops when this instance is dragged.
|
|
390
|
+
// Nothing is held yet: until the drag starts, the press may still be a click, and the page's own
|
|
391
|
+
// scrolling (scrollIntoView, focus, anchoring) must work.
|
|
392
|
+
if (motion === "momentum") {
|
|
393
|
+
stop();
|
|
394
|
+
setMotion("pressed");
|
|
395
|
+
}
|
|
396
|
+
else if (motion === "idle")
|
|
397
|
+
setMotion("pressed");
|
|
398
|
+
},
|
|
399
|
+
yield() {
|
|
400
|
+
yielded = true;
|
|
401
|
+
// A running glide drives the position itself, and holds where it ends (see settle).
|
|
402
|
+
if (motion === "glide" || motion === "wheel")
|
|
403
|
+
return;
|
|
404
|
+
held = { x: el.scrollLeft, y: el.scrollTop };
|
|
405
|
+
},
|
|
406
|
+
start(id) {
|
|
407
|
+
// Start from where the content is now: the page, a scrollTo or prev/next may have moved it during the
|
|
408
|
+
// press. Such a glide stops here, so it can neither fight the drag nor end it.
|
|
409
|
+
stop();
|
|
410
|
+
pressScroll = { x: el.scrollLeft, y: el.scrollTop };
|
|
411
|
+
held = { ...pressScroll };
|
|
412
|
+
pointerId = id;
|
|
413
|
+
beginOverride();
|
|
414
|
+
try {
|
|
415
|
+
el.setPointerCapture(id);
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
// Synthetic or already released pointer: the coordinator's window listeners still see it.
|
|
419
|
+
}
|
|
420
|
+
el.setAttribute(DRAGGING_ATTR, "");
|
|
421
|
+
override(USER_SELECT, "none");
|
|
422
|
+
win.getSelection()?.removeAllRanges();
|
|
423
|
+
setMotion("dragging");
|
|
424
|
+
},
|
|
425
|
+
move(delta) {
|
|
426
|
+
if (destroyed)
|
|
427
|
+
return;
|
|
428
|
+
if (allows("x")) {
|
|
429
|
+
el.scrollLeft = pressScroll.x - delta.x;
|
|
430
|
+
if (held)
|
|
431
|
+
held.x = el.scrollLeft;
|
|
432
|
+
}
|
|
433
|
+
if (allows("y")) {
|
|
434
|
+
el.scrollTop = pressScroll.y - delta.y;
|
|
435
|
+
if (held)
|
|
436
|
+
held.y = el.scrollTop;
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
pass() {
|
|
440
|
+
// Undo an autoscroll that came after the last scroll event, then let go.
|
|
441
|
+
holdBack();
|
|
442
|
+
held = null;
|
|
443
|
+
yielded = false;
|
|
444
|
+
// Only settle what the press froze. A glide that kept going during the press runs to its end.
|
|
445
|
+
if (motion !== "pressed")
|
|
446
|
+
return;
|
|
447
|
+
if (snapType !== null)
|
|
448
|
+
fling(ZERO);
|
|
449
|
+
else
|
|
450
|
+
setMotion("idle");
|
|
451
|
+
},
|
|
452
|
+
end(velocity) {
|
|
453
|
+
endDrag();
|
|
454
|
+
// Undo an autoscroll that came after the last scroll event, so the release starts where the drag is.
|
|
455
|
+
holdBack();
|
|
456
|
+
fling(velocity ? { x: toLogical(-velocity.x, isRtl()), y: -velocity.y } : ZERO);
|
|
457
|
+
},
|
|
458
|
+
interrupt,
|
|
459
|
+
};
|
|
460
|
+
// --- wheel (axis "x" only: vertical scrolling is already native) -------------------------------------
|
|
461
|
+
let wheelDirection = 1;
|
|
462
|
+
const settleWheel = () => {
|
|
463
|
+
wheelTimer = 0;
|
|
464
|
+
if (destroyed)
|
|
465
|
+
return;
|
|
466
|
+
if (!snapAxes().x) {
|
|
467
|
+
settle();
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
// Like native wheel snapping: any wheel travel moves on to the next snap position in that direction.
|
|
471
|
+
const current = read().x;
|
|
472
|
+
const positions = snapPoints().x;
|
|
473
|
+
const target = adjacentSnap(positions, current - 2 * wheelDirection, wheelDirection) ?? nearestSnap(positions, current);
|
|
474
|
+
animateTo({ x: target }, ZERO, "glide");
|
|
475
|
+
};
|
|
476
|
+
const onWheel = (event) => {
|
|
477
|
+
if (opts.axis !== "x" || event.ctrlKey || event.shiftKey)
|
|
478
|
+
return;
|
|
479
|
+
if (Math.abs(event.deltaY) <= Math.abs(event.deltaX))
|
|
480
|
+
return;
|
|
481
|
+
if (motion === "pressed" || motion === "dragging")
|
|
482
|
+
return;
|
|
483
|
+
const scale = event.deltaMode === 1 ? LINE_HEIGHT : event.deltaMode === 2 ? el.clientWidth : 1;
|
|
484
|
+
const delta = event.deltaY * scale;
|
|
485
|
+
const rtl = isRtl();
|
|
486
|
+
const max = maxScroll();
|
|
487
|
+
const current = read(rtl, max).x;
|
|
488
|
+
// At the edge in the wheel's direction: let the page scroll.
|
|
489
|
+
if (delta > 0 ? current >= max.x - EDGE : current <= EDGE)
|
|
490
|
+
return;
|
|
491
|
+
event.preventDefault();
|
|
492
|
+
stop();
|
|
493
|
+
beginOverride();
|
|
494
|
+
wheelDirection = delta > 0 ? 1 : -1;
|
|
495
|
+
write({ x: clamp(current + delta, 0, max.x) }, rtl);
|
|
496
|
+
if (motion !== "wheel")
|
|
497
|
+
setMotion("wheel");
|
|
498
|
+
if (destroyed)
|
|
499
|
+
return;
|
|
500
|
+
wheelTimer = win.setTimeout(settleWheel, WHEEL_SETTLE_MS);
|
|
501
|
+
};
|
|
502
|
+
let wheelAttached = false;
|
|
503
|
+
const syncWheel = () => {
|
|
504
|
+
const wanted = opts.wheel && opts.axis === "x";
|
|
505
|
+
if (wanted === wheelAttached)
|
|
506
|
+
return;
|
|
507
|
+
wheelAttached = wanted;
|
|
508
|
+
if (wanted)
|
|
509
|
+
el.addEventListener("wheel", onWheel, { passive: false });
|
|
510
|
+
else
|
|
511
|
+
el.removeEventListener("wheel", onWheel);
|
|
512
|
+
};
|
|
513
|
+
// --- wiring -------------------------------------------------------------------------------------------
|
|
514
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
515
|
+
invalidate();
|
|
516
|
+
update();
|
|
517
|
+
});
|
|
518
|
+
resizeObserver.observe(el);
|
|
519
|
+
for (const child of el.children)
|
|
520
|
+
resizeObserver.observe(child);
|
|
521
|
+
const onMutations = (records) => {
|
|
522
|
+
if (destroyed)
|
|
523
|
+
return;
|
|
524
|
+
invalidate();
|
|
525
|
+
// Records can arrive late and out of order (ownStyle defers some), so the current parent decides.
|
|
526
|
+
for (const record of records) {
|
|
527
|
+
if (record.target !== el)
|
|
528
|
+
continue;
|
|
529
|
+
for (const node of [...record.addedNodes, ...record.removedNodes]) {
|
|
530
|
+
if (node.nodeType !== 1)
|
|
531
|
+
continue;
|
|
532
|
+
if (node.parentElement === el)
|
|
533
|
+
resizeObserver.observe(node);
|
|
534
|
+
else
|
|
535
|
+
resizeObserver.unobserve(node);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
// Cheap when nothing changed (no notification), and it covers what the resize observer never reports,
|
|
539
|
+
// such as an added 0×0 child that only adds a flex gap.
|
|
540
|
+
update();
|
|
541
|
+
};
|
|
542
|
+
mutationObserver.observe(el, {
|
|
543
|
+
childList: true,
|
|
544
|
+
attributes: true,
|
|
545
|
+
attributeFilter: ["class", "style", "dir"],
|
|
546
|
+
subtree: true,
|
|
547
|
+
});
|
|
548
|
+
// A `dir` change on an ancestor flips the direction without resizing anything. Observing the root again
|
|
549
|
+
// would replace the options above when the container is the root itself.
|
|
550
|
+
const root = el.ownerDocument.documentElement;
|
|
551
|
+
if (el !== root) {
|
|
552
|
+
mutationObserver.observe(root, { attributes: true, attributeFilter: ["dir"], subtree: true });
|
|
553
|
+
}
|
|
554
|
+
const onKeyDown = (event) => {
|
|
555
|
+
if (isScrollKey(event))
|
|
556
|
+
interrupt();
|
|
557
|
+
};
|
|
558
|
+
const onScroll = () => {
|
|
559
|
+
// Safari autoscrolls the pressed containers towards a pointer held outside them, on any axis and also
|
|
560
|
+
// for instances that do not own the drag. Every instance under the press holds its position until the
|
|
561
|
+
// press ends or the user scrolls with the wheel or keyboard.
|
|
562
|
+
holdBack();
|
|
563
|
+
update();
|
|
564
|
+
};
|
|
565
|
+
const passive = { passive: true };
|
|
566
|
+
el.addEventListener("scroll", onScroll, passive);
|
|
567
|
+
el.addEventListener("wheel", interrupt, passive);
|
|
568
|
+
el.addEventListener("touchstart", interrupt, passive);
|
|
569
|
+
el.addEventListener("keydown", onKeyDown);
|
|
570
|
+
const unregister = register(client);
|
|
571
|
+
syncWheel();
|
|
572
|
+
update();
|
|
573
|
+
const resolveAxis = (method, axis) => {
|
|
574
|
+
if (axis)
|
|
575
|
+
return axis;
|
|
576
|
+
if (opts.axis === "both") {
|
|
577
|
+
throw new Error(`axis: ${method}() needs an axis ("x" or "y") when the instance uses axis: "both"`);
|
|
578
|
+
}
|
|
579
|
+
return opts.axis;
|
|
580
|
+
};
|
|
581
|
+
const step = (axis, direction) => {
|
|
582
|
+
if (destroyed || motion === "dragging")
|
|
583
|
+
return;
|
|
584
|
+
const visible = axis === "x" ? el.clientWidth : el.clientHeight;
|
|
585
|
+
// Repeated presses during a glide continue from where the glide is heading.
|
|
586
|
+
const max = maxScroll();
|
|
587
|
+
const from = motion === "glide" ? glideTarget[axis] : read(isRtl(), max)[axis];
|
|
588
|
+
const target = snapAxes()[axis]
|
|
589
|
+
? (adjacentSnap(snapPoints()[axis], from, direction) ?? (direction === 1 ? max[axis] : 0))
|
|
590
|
+
: from + direction * visible * 0.9;
|
|
591
|
+
animateTo({ [axis]: clamp(target, 0, max[axis]) }, ZERO, "glide");
|
|
592
|
+
};
|
|
593
|
+
return {
|
|
594
|
+
getState: () => state,
|
|
595
|
+
subscribe(listener) {
|
|
596
|
+
listeners.add(listener);
|
|
597
|
+
return () => {
|
|
598
|
+
listeners.delete(listener);
|
|
599
|
+
};
|
|
600
|
+
},
|
|
601
|
+
scrollPrev: (axis) => step(resolveAxis("scrollPrev", axis), -1),
|
|
602
|
+
scrollNext: (axis) => step(resolveAxis("scrollNext", axis), 1),
|
|
603
|
+
scrollTo(position, { animate = true } = {}) {
|
|
604
|
+
if (destroyed || motion === "dragging")
|
|
605
|
+
return;
|
|
606
|
+
const max = maxScroll();
|
|
607
|
+
const target = {};
|
|
608
|
+
for (const axis of AXES) {
|
|
609
|
+
const value = position[axis];
|
|
610
|
+
if (value !== undefined)
|
|
611
|
+
target[axis] = clamp(value, 0, max[axis]);
|
|
612
|
+
}
|
|
613
|
+
if (animate)
|
|
614
|
+
animateTo(target, ZERO, "glide");
|
|
615
|
+
else
|
|
616
|
+
jump(target);
|
|
617
|
+
},
|
|
618
|
+
setOptions(patch) {
|
|
619
|
+
if (destroyed)
|
|
620
|
+
return;
|
|
621
|
+
opts = merge(opts, patch);
|
|
622
|
+
invalidate();
|
|
623
|
+
syncWheel();
|
|
624
|
+
update();
|
|
625
|
+
},
|
|
626
|
+
refresh() {
|
|
627
|
+
if (destroyed)
|
|
628
|
+
return;
|
|
629
|
+
invalidate();
|
|
630
|
+
update();
|
|
631
|
+
},
|
|
632
|
+
destroy() {
|
|
633
|
+
if (destroyed)
|
|
634
|
+
return;
|
|
635
|
+
stop();
|
|
636
|
+
unregister();
|
|
637
|
+
if (pointerId !== -1)
|
|
638
|
+
endDrag();
|
|
639
|
+
resizeObserver.disconnect();
|
|
640
|
+
mutationObserver.disconnect();
|
|
641
|
+
el.removeEventListener("scroll", onScroll);
|
|
642
|
+
el.removeEventListener("wheel", interrupt);
|
|
643
|
+
el.removeEventListener("touchstart", interrupt);
|
|
644
|
+
el.removeEventListener("keydown", onKeyDown);
|
|
645
|
+
el.removeEventListener("wheel", onWheel);
|
|
646
|
+
el.removeAttribute(DRAGGING_ATTR);
|
|
647
|
+
for (const prop of saved.keys())
|
|
648
|
+
restore(prop);
|
|
649
|
+
if (!hadStyleAttr && !el.getAttribute("style"))
|
|
650
|
+
el.removeAttribute("style");
|
|
651
|
+
destroyed = true;
|
|
652
|
+
listeners.clear();
|
|
653
|
+
},
|
|
654
|
+
};
|
|
655
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type Vector } from "./math.js";
|
|
2
|
+
/** True for a key press that scrolls: arrows, Page Up/Down, Home, End and Space, outside editable elements. */
|
|
3
|
+
export declare function isScrollKey(event: KeyboardEvent): boolean;
|
|
4
|
+
/** The side of an instance that the coordinator talks to. */
|
|
5
|
+
export interface DragClient {
|
|
6
|
+
readonly el: HTMLElement;
|
|
7
|
+
/** True when the press lands on this element's own scrollbar, which belongs to the browser. */
|
|
8
|
+
onScrollbar(event: PointerEvent): boolean;
|
|
9
|
+
/** True when this instance drags along `axis` and has something to scroll. */
|
|
10
|
+
accepts(axis: "x" | "y"): boolean;
|
|
11
|
+
/** True when this instance can still scroll the way the pointer `delta` pushes it. */
|
|
12
|
+
canScroll(delta: Vector): boolean;
|
|
13
|
+
/** A drag may start here: stop any glide and remember the start position. */
|
|
14
|
+
press(): void;
|
|
15
|
+
/** The drag started and another instance under the pointer owns it: hold the current scroll position. */
|
|
16
|
+
yield(): void;
|
|
17
|
+
/** This instance owns the drag from now on, and holds its scroll position on the axes it does not drag. */
|
|
18
|
+
start(pointerId: number): void;
|
|
19
|
+
/** Pointer moved by `delta` since the press. */
|
|
20
|
+
move(delta: Vector): void;
|
|
21
|
+
/** The press ended and this instance did not own a drag: stop holding, settle what the press froze. */
|
|
22
|
+
pass(): void;
|
|
23
|
+
/** The drag ended. `velocity` is the pointer velocity in px/ms, or null when the drag was cancelled. */
|
|
24
|
+
end(velocity: Vector | null): void;
|
|
25
|
+
/** The user scrolls on purpose (wheel, keyboard), or non-draggable input arrived: stop gliding and holding. */
|
|
26
|
+
interrupt(): void;
|
|
27
|
+
}
|
|
28
|
+
/** Registers `client` with the coordinator of its window. Returns the function that unregisters it. */
|
|
29
|
+
export declare function register(client: DragClient): () => void;
|
|
30
|
+
/**
|
|
31
|
+
* Picks the owner for a drag that moved by `delta`. `chain` runs from the innermost instance outwards.
|
|
32
|
+
* First choice: the innermost instance that drags along the dominant axis and can still scroll that way.
|
|
33
|
+
* Otherwise the innermost one that drags along that axis at all (it is at its edge, but still owns the
|
|
34
|
+
* gesture so the click is not fired). Otherwise nobody, and the browser handles the movement natively.
|
|
35
|
+
*/
|
|
36
|
+
export declare function pickOwner(chain: readonly DragClient[], delta: Vector): DragClient | null;
|