@enigmax/primitives 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 +202 -0
- package/README.md +111 -0
- package/dist/chunk-5Q5TAW2N.js +267 -0
- package/dist/index.d.ts +73 -0
- package/dist/index.js +1 -0
- package/dist/react/index.d.ts +45 -0
- package/dist/react/index.js +55 -0
- package/package.json +71 -0
- package/registry.json +46 -0
- package/src/core/marquee.ts +400 -0
- package/src/index.ts +1 -0
- package/src/react/index.ts +2 -0
- package/src/react/use-marquee.ts +105 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { RefObject } from 'react';
|
|
2
|
+
import { MarqueeOptions } from '../index.js';
|
|
3
|
+
export { MarqueeInstance } from '../index.js';
|
|
4
|
+
|
|
5
|
+
interface UseMarqueeResult {
|
|
6
|
+
/** Attach to the clipping viewport. */
|
|
7
|
+
laneRef: RefObject<HTMLElement | null>;
|
|
8
|
+
/** Attach to the moved element. Render `copies` children into it. */
|
|
9
|
+
trackRef: RefObject<HTMLElement | null>;
|
|
10
|
+
/**
|
|
11
|
+
* How many times to render the content. Starts at 2 so the server-rendered
|
|
12
|
+
* HTML stays small, then grows to cover the lane plus one whole period.
|
|
13
|
+
*/
|
|
14
|
+
copies: number;
|
|
15
|
+
/** True while the row is being grabbed. Useful for a `cursor: grabbing`. */
|
|
16
|
+
dragging: boolean;
|
|
17
|
+
/** Measured distance between two copies, in px. 0 before the first measure. */
|
|
18
|
+
period: number;
|
|
19
|
+
/** Re-read the lap from the DOM. Call after changing the content yourself. */
|
|
20
|
+
measure: () => void;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Drive a draggable infinite marquee from React.
|
|
24
|
+
*
|
|
25
|
+
* `speed` is pixels per second and never a duration: a duration ties the speed
|
|
26
|
+
* to the item count, so the row accelerates every time content is added.
|
|
27
|
+
*
|
|
28
|
+
* ```tsx
|
|
29
|
+
* const { laneRef, trackRef, copies } = useMarquee({ speed: 80, hoverScale: 0.15 });
|
|
30
|
+
* return (
|
|
31
|
+
* <div ref={laneRef}>
|
|
32
|
+
* <div ref={trackRef} style={{ display: "flex" }}>
|
|
33
|
+
* {Array.from({ length: copies }, (_, copy) => (
|
|
34
|
+
* <div key={copy} aria-hidden={copy > 0} style={{ display: "flex", gap: 32 }}>
|
|
35
|
+
* {items.map(item => <Logo key={item.id} {...item} />)}
|
|
36
|
+
* </div>
|
|
37
|
+
* ))}
|
|
38
|
+
* </div>
|
|
39
|
+
* </div>
|
|
40
|
+
* );
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
declare function useMarquee(options?: MarqueeOptions): UseMarqueeResult;
|
|
44
|
+
|
|
45
|
+
export { MarqueeOptions, type UseMarqueeResult, useMarquee };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createMarquee } from '../chunk-5Q5TAW2N.js';
|
|
2
|
+
import { useRef, useState, useEffect, useLayoutEffect } from 'react';
|
|
3
|
+
|
|
4
|
+
var useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
5
|
+
function useMarquee(options = {}) {
|
|
6
|
+
const laneRef = useRef(null);
|
|
7
|
+
const trackRef = useRef(null);
|
|
8
|
+
const instanceRef = useRef(null);
|
|
9
|
+
const optionsRef = useRef(options);
|
|
10
|
+
optionsRef.current = options;
|
|
11
|
+
const [copies, setCopies] = useState(2);
|
|
12
|
+
const [dragging, setDragging] = useState(false);
|
|
13
|
+
const [period, setPeriod] = useState(0);
|
|
14
|
+
useIsomorphicLayoutEffect(() => {
|
|
15
|
+
const lane = laneRef.current;
|
|
16
|
+
const track = trackRef.current;
|
|
17
|
+
if (!lane || !track) return;
|
|
18
|
+
const instance = createMarquee(lane, track, {
|
|
19
|
+
...optionsRef.current,
|
|
20
|
+
// React owns the DOM; cloning behind its back would be undone on render.
|
|
21
|
+
copies: "external",
|
|
22
|
+
onCopyCountChange: setCopies,
|
|
23
|
+
onMeasure: (measured) => setPeriod(measured),
|
|
24
|
+
onItemClick: (event) => optionsRef.current.onItemClick?.(event)
|
|
25
|
+
});
|
|
26
|
+
instanceRef.current = instance;
|
|
27
|
+
const syncDragging = () => setDragging(instance.dragging);
|
|
28
|
+
lane.addEventListener("pointerdown", syncDragging);
|
|
29
|
+
window.addEventListener("pointerup", syncDragging);
|
|
30
|
+
window.addEventListener("pointercancel", syncDragging);
|
|
31
|
+
return () => {
|
|
32
|
+
lane.removeEventListener("pointerdown", syncDragging);
|
|
33
|
+
window.removeEventListener("pointerup", syncDragging);
|
|
34
|
+
window.removeEventListener("pointercancel", syncDragging);
|
|
35
|
+
instance.destroy();
|
|
36
|
+
instanceRef.current = null;
|
|
37
|
+
};
|
|
38
|
+
}, []);
|
|
39
|
+
useIsomorphicLayoutEffect(() => {
|
|
40
|
+
instanceRef.current?.update(options);
|
|
41
|
+
}, [options.speed, options.reverse, options.vertical, options.draggable, options.hoverScale, options.decay]);
|
|
42
|
+
useIsomorphicLayoutEffect(() => {
|
|
43
|
+
instanceRef.current?.measure();
|
|
44
|
+
}, [copies]);
|
|
45
|
+
return {
|
|
46
|
+
laneRef,
|
|
47
|
+
trackRef,
|
|
48
|
+
copies,
|
|
49
|
+
dragging,
|
|
50
|
+
period,
|
|
51
|
+
measure: () => instanceRef.current?.measure()
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export { useMarquee };
|
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@enigmax/primitives",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Headless interaction primitives: the behaviour, the timing and the accessibility of components like a draggable infinite marquee, with no styles of their own. Framework-agnostic core plus thin adapters.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./react": {
|
|
14
|
+
"types": "./dist/react/index.d.ts",
|
|
15
|
+
"import": "./dist/react/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./registry.json": "./registry.json"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsup",
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"test": "playwright test",
|
|
23
|
+
"test:install": "playwright install chromium",
|
|
24
|
+
"prepublishOnly": "npm run typecheck && npm run build"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"src",
|
|
29
|
+
"registry.json",
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE"
|
|
32
|
+
],
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"react": ">=18"
|
|
35
|
+
},
|
|
36
|
+
"peerDependenciesMeta": {
|
|
37
|
+
"react": {
|
|
38
|
+
"optional": true
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@playwright/test": "^1.48.0",
|
|
43
|
+
"@types/react": "^19.2.0",
|
|
44
|
+
"react": "^19.2.0",
|
|
45
|
+
"tsup": "^8.0.0",
|
|
46
|
+
"typescript": "^5.0.0"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=18"
|
|
50
|
+
},
|
|
51
|
+
"keywords": [
|
|
52
|
+
"headless",
|
|
53
|
+
"primitives",
|
|
54
|
+
"marquee",
|
|
55
|
+
"carousel",
|
|
56
|
+
"unstyled",
|
|
57
|
+
"react",
|
|
58
|
+
"vanilla",
|
|
59
|
+
"enigma"
|
|
60
|
+
],
|
|
61
|
+
"license": "Apache-2.0",
|
|
62
|
+
"author": "FJRG2007 <fjrg2007@tpeoficial.com>",
|
|
63
|
+
"repository": {
|
|
64
|
+
"type": "git",
|
|
65
|
+
"url": "git+https://github.com/FJRG2007/enigma.git",
|
|
66
|
+
"directory": "packages/primitives"
|
|
67
|
+
},
|
|
68
|
+
"publishConfig": {
|
|
69
|
+
"access": "public"
|
|
70
|
+
}
|
|
71
|
+
}
|
package/registry.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"registry": "@enigmax/primitives",
|
|
3
|
+
"version": 1,
|
|
4
|
+
"kind": "primitive",
|
|
5
|
+
"items": [
|
|
6
|
+
{
|
|
7
|
+
"name": "marquee",
|
|
8
|
+
"title": "Draggable infinite marquee",
|
|
9
|
+
"description": "Looping row that takes a speed in px/s, measures its own lap, and can be grabbed and thrown without swallowing the clicks on its items.",
|
|
10
|
+
"targets": ["vanilla", "astro", "react"],
|
|
11
|
+
"entry": {
|
|
12
|
+
"vanilla": "@enigmax/primitives",
|
|
13
|
+
"astro": "@enigmax/primitives",
|
|
14
|
+
"react": "@enigmax/primitives/react"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
"vanilla": ["createMarquee"],
|
|
18
|
+
"astro": ["createMarquee"],
|
|
19
|
+
"react": ["useMarquee"]
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
{
|
|
23
|
+
"path": "src/core/marquee.ts",
|
|
24
|
+
"dest": "marquee.ts",
|
|
25
|
+
"targets": ["vanilla", "astro", "react"]
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"path": "src/react/use-marquee.ts",
|
|
29
|
+
"dest": "use-marquee.ts",
|
|
30
|
+
"targets": ["react"],
|
|
31
|
+
"rewrite": { "@/core/marquee": "./marquee" }
|
|
32
|
+
}
|
|
33
|
+
],
|
|
34
|
+
"styles": false,
|
|
35
|
+
"themeHooks": [
|
|
36
|
+
"[data-enigma-marquee]",
|
|
37
|
+
"[data-enigma-marquee][data-dragging]",
|
|
38
|
+
"[data-enigma-marquee][data-hovering]",
|
|
39
|
+
"[data-enigma-marquee][data-reduced-motion=true]",
|
|
40
|
+
"[data-enigma-marquee-track]",
|
|
41
|
+
"[data-enigma-marquee-copy]"
|
|
42
|
+
],
|
|
43
|
+
"docs": "docs/notes/primitives.md#marquee"
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
}
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Draggable infinite marquee: the behaviour engine, with no styling of its own.
|
|
3
|
+
*
|
|
4
|
+
* The contract is a SPEED in pixels per second, never a duration. A lap of a
|
|
5
|
+
* looping row is as long as its content, so a duration makes the row run faster
|
|
6
|
+
* every time an item is added to it. See the package README.
|
|
7
|
+
*
|
|
8
|
+
* Every rule encoded here was measured in a browser, not reasoned about. The
|
|
9
|
+
* comments marked "non-negotiable" name the bug that the obvious implementation
|
|
10
|
+
* shipped with.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Fraction of the remaining gap left after one second. 0.12 flicks, 0.35 drags. */
|
|
14
|
+
const DEFAULT_DECAY = 0.12;
|
|
15
|
+
/** Pointer travel past which a press stops being a click and becomes a drag. */
|
|
16
|
+
const DRAG_THRESHOLD = 6;
|
|
17
|
+
/** A pointer that has not moved for this long throws nothing on release. */
|
|
18
|
+
const STILL_MS = 90;
|
|
19
|
+
/** A backgrounded tab hands back one enormous delta; clamp it or the row teleports. */
|
|
20
|
+
const MAX_DT = 0.05;
|
|
21
|
+
/** One jittery frame must not decide the throw. */
|
|
22
|
+
const VELOCITY_SMOOTHING = 0.6;
|
|
23
|
+
/** Below this the row is idle and the frame loop can stop. */
|
|
24
|
+
const IDLE_EPSILON = 0.01;
|
|
25
|
+
|
|
26
|
+
export interface MarqueeOptions {
|
|
27
|
+
/**
|
|
28
|
+
* Pixels per second. NOT a duration: a duration ties the speed to the item
|
|
29
|
+
* count and the row silently accelerates as content grows.
|
|
30
|
+
*/
|
|
31
|
+
speed?: number;
|
|
32
|
+
/** Scroll towards the start instead of the end. */
|
|
33
|
+
reverse?: boolean;
|
|
34
|
+
/** Scroll on the Y axis instead of the X axis. */
|
|
35
|
+
vertical?: boolean;
|
|
36
|
+
/** Allow grabbing and throwing the row. */
|
|
37
|
+
draggable?: boolean;
|
|
38
|
+
/** Speed multiplier while a MOUSE rests on the row. 0 pauses it. */
|
|
39
|
+
hoverScale?: number;
|
|
40
|
+
/** Fraction of the remaining velocity gap left after one second. */
|
|
41
|
+
decay?: number;
|
|
42
|
+
/**
|
|
43
|
+
* Apply the styles the behaviour needs (overflow, touch-action, user-select,
|
|
44
|
+
* will-change, transform). Never theme styles. Turn off to own them yourself.
|
|
45
|
+
*/
|
|
46
|
+
manageStyles?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* How the repeated copies get into the DOM.
|
|
49
|
+
* - "clone": the engine clones the first child of the track (vanilla, Astro).
|
|
50
|
+
* - "external": the consumer renders them and is told the count (React, Vue).
|
|
51
|
+
*/
|
|
52
|
+
copies?: "clone" | "external";
|
|
53
|
+
/** Called when the required copy count changes. Required for "external". */
|
|
54
|
+
onCopyCountChange?: (count: number) => void;
|
|
55
|
+
/** Called on a click that was not the end of a drag. */
|
|
56
|
+
onItemClick?: (event: MouseEvent) => void;
|
|
57
|
+
/** Called whenever the measured lap period changes. */
|
|
58
|
+
onMeasure?: (period: number, copyCount: number) => void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface MarqueeInstance {
|
|
62
|
+
/** Current offset in px, normalized to [0, period). */
|
|
63
|
+
readonly offset: number;
|
|
64
|
+
/** Measured distance between two copies of the content, in px. */
|
|
65
|
+
readonly period: number;
|
|
66
|
+
/** Copies currently required to cover the lane plus one whole period. */
|
|
67
|
+
readonly copyCount: number;
|
|
68
|
+
/** True between pointerdown and pointerup while the row is being grabbed. */
|
|
69
|
+
readonly dragging: boolean;
|
|
70
|
+
/** True while the OS asks for reduced motion. Autoplay stops, drag does not. */
|
|
71
|
+
readonly reducedMotion: boolean;
|
|
72
|
+
update(options: Partial<MarqueeOptions>): void;
|
|
73
|
+
/** Re-read the lap from the DOM. Called for you on resize and on font load. */
|
|
74
|
+
measure(): void;
|
|
75
|
+
pause(): void;
|
|
76
|
+
resume(): void;
|
|
77
|
+
destroy(): void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface ResolvedOptions extends Required<Omit<MarqueeOptions, "onCopyCountChange" | "onItemClick" | "onMeasure">> {
|
|
81
|
+
onCopyCountChange?: (count: number) => void;
|
|
82
|
+
onItemClick?: (event: MouseEvent) => void;
|
|
83
|
+
onMeasure?: (period: number, copyCount: number) => void;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const DEFAULTS: ResolvedOptions = {
|
|
87
|
+
speed: 60,
|
|
88
|
+
reverse: false,
|
|
89
|
+
vertical: false,
|
|
90
|
+
draggable: true,
|
|
91
|
+
hoverScale: 1,
|
|
92
|
+
decay: DEFAULT_DECAY,
|
|
93
|
+
manageStyles: true,
|
|
94
|
+
copies: "clone"
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Wire a lane and its track into a draggable infinite marquee.
|
|
99
|
+
*
|
|
100
|
+
* @param lane The clipping viewport. Receives the pointer and the click guard.
|
|
101
|
+
* @param track The moved element. Its children are the repeated copies; child 0
|
|
102
|
+
* is the source content and must already be rendered.
|
|
103
|
+
*/
|
|
104
|
+
export function createMarquee(lane: HTMLElement, track: HTMLElement, options: MarqueeOptions = {}): MarqueeInstance {
|
|
105
|
+
let opts: ResolvedOptions = { ...DEFAULTS, ...options };
|
|
106
|
+
|
|
107
|
+
let period = 0;
|
|
108
|
+
let copyCount = Math.max(2, track.children.length);
|
|
109
|
+
let offset = 0;
|
|
110
|
+
let velocity = 0;
|
|
111
|
+
let running = false;
|
|
112
|
+
let paused = false;
|
|
113
|
+
let destroyed = false;
|
|
114
|
+
let frame = 0;
|
|
115
|
+
let lastTime = 0;
|
|
116
|
+
|
|
117
|
+
let dragging = false;
|
|
118
|
+
let pointerId: number | null = null;
|
|
119
|
+
let lastPointer = 0;
|
|
120
|
+
let startPointer = 0;
|
|
121
|
+
let lastMoveTime = 0;
|
|
122
|
+
let maxTravel = 0;
|
|
123
|
+
let hovering = false;
|
|
124
|
+
|
|
125
|
+
const motionQuery = typeof window.matchMedia === "function"
|
|
126
|
+
? window.matchMedia("(prefers-reduced-motion: reduce)")
|
|
127
|
+
: null;
|
|
128
|
+
let reducedMotion = motionQuery?.matches ?? false;
|
|
129
|
+
|
|
130
|
+
/** Read live every frame, never captured: cruise, hover and reduced motion all ease. */
|
|
131
|
+
function target(): number {
|
|
132
|
+
if (reducedMotion || paused) return 0;
|
|
133
|
+
const cruise = opts.reverse ? -opts.speed : opts.speed;
|
|
134
|
+
return hovering ? cruise * opts.hoverScale : cruise;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function axisSize(element: HTMLElement): number {
|
|
138
|
+
return opts.vertical ? element.offsetHeight : element.offsetWidth;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function axisStart(element: HTMLElement): number {
|
|
142
|
+
return opts.vertical ? element.offsetTop : element.offsetLeft;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function applyStyles(): void {
|
|
146
|
+
if (!opts.manageStyles) return;
|
|
147
|
+
lane.style.overflow = "hidden";
|
|
148
|
+
// Non-negotiable 6: "none" would steal the page scroll on a phone, where
|
|
149
|
+
// the row is most of the width of the screen.
|
|
150
|
+
lane.style.touchAction = opts.vertical ? "pan-x" : "pan-y";
|
|
151
|
+
// Non-negotiable 7: without this the browser starts its own text drag.
|
|
152
|
+
lane.style.userSelect = "none";
|
|
153
|
+
(lane.style as CSSStyleDeclaration & { webkitUserSelect: string; }).webkitUserSelect = "none";
|
|
154
|
+
track.style.willChange = "transform";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function render(): void {
|
|
158
|
+
const x = opts.vertical ? 0 : -offset;
|
|
159
|
+
const y = opts.vertical ? -offset : 0;
|
|
160
|
+
track.style.transform = `translate3d(${x}px, ${y}px, 0)`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Non-negotiable 1: measure the lap, never compute it. The step between two
|
|
165
|
+
* copies already contains the gap, the padding, the font metrics and whatever
|
|
166
|
+
* a justify-* did. `itemCount * (itemWidth + gap)` is right by coincidence.
|
|
167
|
+
*/
|
|
168
|
+
function readPeriod(): number {
|
|
169
|
+
const first = track.children[0] as HTMLElement | undefined;
|
|
170
|
+
const second = track.children[1] as HTMLElement | undefined;
|
|
171
|
+
if (!first) return 0;
|
|
172
|
+
if (second) {
|
|
173
|
+
const step = axisStart(second) - axisStart(first);
|
|
174
|
+
if (step > 0) return step;
|
|
175
|
+
}
|
|
176
|
+
// Only reachable with a single copy in the DOM, which the engine avoids.
|
|
177
|
+
return axisSize(first);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Non-negotiable 2: the lane plus one whole period, so there is always one to wrap into. */
|
|
181
|
+
function requiredCopies(measured: number): number {
|
|
182
|
+
if (measured <= 0) return 2;
|
|
183
|
+
return Math.max(2, Math.ceil(axisSize(lane) / measured) + 1);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function syncClones(count: number): void {
|
|
187
|
+
const source = track.children[0] as HTMLElement | undefined;
|
|
188
|
+
if (!source) return;
|
|
189
|
+
while (track.children.length > count) track.removeChild(track.lastElementChild!);
|
|
190
|
+
while (track.children.length < count) {
|
|
191
|
+
const clone = source.cloneNode(true) as HTMLElement;
|
|
192
|
+
// The repeat is decoration; a screen reader must hear the content once.
|
|
193
|
+
clone.setAttribute("aria-hidden", "true");
|
|
194
|
+
clone.dataset.enigmaMarqueeCopy = String(track.children.length);
|
|
195
|
+
track.appendChild(clone);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function measure(): void {
|
|
200
|
+
if (destroyed) return;
|
|
201
|
+
const measured = readPeriod();
|
|
202
|
+
const needed = requiredCopies(measured);
|
|
203
|
+
const changed = measured !== period || needed !== copyCount;
|
|
204
|
+
period = measured;
|
|
205
|
+
if (needed !== copyCount) {
|
|
206
|
+
copyCount = needed;
|
|
207
|
+
if (opts.copies === "clone") syncClones(needed);
|
|
208
|
+
opts.onCopyCountChange?.(needed);
|
|
209
|
+
}
|
|
210
|
+
if (period > 0) offset = wrap(offset);
|
|
211
|
+
render();
|
|
212
|
+
if (changed) opts.onMeasure?.(period, copyCount);
|
|
213
|
+
start();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Non-negotiable 12: wrap every frame or the number grows without bound. */
|
|
217
|
+
function wrap(value: number): number {
|
|
218
|
+
if (period <= 0) return value;
|
|
219
|
+
return ((value % period) + period) % period;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function tick(now: number): void {
|
|
223
|
+
frame = 0;
|
|
224
|
+
if (destroyed) return;
|
|
225
|
+
const dt = Math.min((now - lastTime) / 1000, MAX_DT);
|
|
226
|
+
lastTime = now;
|
|
227
|
+
|
|
228
|
+
if (!dragging) {
|
|
229
|
+
// One integrator for cruise, hover and momentum. Math.pow(decay, dt)
|
|
230
|
+
// is what keeps it identical on a 60Hz and a 120Hz display.
|
|
231
|
+
velocity += (target() - velocity) * (1 - Math.pow(opts.decay, dt));
|
|
232
|
+
offset = wrap(offset + velocity * dt);
|
|
233
|
+
}
|
|
234
|
+
render();
|
|
235
|
+
|
|
236
|
+
const idle = !dragging && Math.abs(velocity) < IDLE_EPSILON && Math.abs(target()) < IDLE_EPSILON;
|
|
237
|
+
if (idle) {
|
|
238
|
+
velocity = 0;
|
|
239
|
+
running = false;
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
frame = requestAnimationFrame(tick);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function start(): void {
|
|
246
|
+
if (destroyed || running) return;
|
|
247
|
+
running = true;
|
|
248
|
+
lastTime = performance.now();
|
|
249
|
+
frame = requestAnimationFrame(tick);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function pointerDelta(event: PointerEvent): number {
|
|
253
|
+
return opts.vertical ? event.clientY : event.clientX;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function onPointerDown(event: PointerEvent): void {
|
|
257
|
+
// Non-negotiable 9: a right click must leave the context menu alone.
|
|
258
|
+
if (!opts.draggable || event.button > 0 || pointerId !== null) return;
|
|
259
|
+
pointerId = event.pointerId;
|
|
260
|
+
dragging = true;
|
|
261
|
+
lastPointer = pointerDelta(event);
|
|
262
|
+
startPointer = lastPointer;
|
|
263
|
+
lastMoveTime = event.timeStamp;
|
|
264
|
+
// Reset here so the next plain click is not cancelled by the last drag.
|
|
265
|
+
maxTravel = 0;
|
|
266
|
+
lane.dataset.dragging = "true";
|
|
267
|
+
// Non-negotiable 5: NO setPointerCapture. It retargets the compatibility
|
|
268
|
+
// mouse events too, so the click of a plain press lands on the lane and
|
|
269
|
+
// the item's link never opens. Window listeners are all the capture was for.
|
|
270
|
+
window.addEventListener("pointermove", onPointerMove);
|
|
271
|
+
window.addEventListener("pointerup", onPointerUp);
|
|
272
|
+
window.addEventListener("pointercancel", onPointerUp);
|
|
273
|
+
start();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function onPointerMove(event: PointerEvent): void {
|
|
277
|
+
if (event.pointerId !== pointerId) return;
|
|
278
|
+
const position = pointerDelta(event);
|
|
279
|
+
const delta = position - lastPointer;
|
|
280
|
+
lastPointer = position;
|
|
281
|
+
|
|
282
|
+
const dt = Math.max((event.timeStamp - lastMoveTime) / 1000, 0.001);
|
|
283
|
+
lastMoveTime = event.timeStamp;
|
|
284
|
+
// The largest displacement since the press, not the distance walked: a
|
|
285
|
+
// slow wobble back to where it started is still a click.
|
|
286
|
+
maxTravel = Math.max(maxTravel, Math.abs(position - startPointer));
|
|
287
|
+
|
|
288
|
+
// The pointer drives the offset directly; the integrator takes over on release.
|
|
289
|
+
offset = wrap(offset - delta);
|
|
290
|
+
const instant = -delta / dt;
|
|
291
|
+
velocity = velocity * VELOCITY_SMOOTHING + instant * (1 - VELOCITY_SMOOTHING);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function onPointerUp(event: PointerEvent): void {
|
|
295
|
+
if (event.pointerId !== pointerId) return;
|
|
296
|
+
pointerId = null;
|
|
297
|
+
dragging = false;
|
|
298
|
+
delete lane.dataset.dragging;
|
|
299
|
+
window.removeEventListener("pointermove", onPointerMove);
|
|
300
|
+
window.removeEventListener("pointerup", onPointerUp);
|
|
301
|
+
window.removeEventListener("pointercancel", onPointerUp);
|
|
302
|
+
// Non-negotiable 10: a slow reposition must not end in a fling.
|
|
303
|
+
if (event.timeStamp - lastMoveTime > STILL_MS) velocity = 0;
|
|
304
|
+
start();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Non-negotiable 7: a drag must not open what it ends on. */
|
|
308
|
+
function onClickCapture(event: MouseEvent): void {
|
|
309
|
+
if (maxTravel <= DRAG_THRESHOLD) return;
|
|
310
|
+
event.preventDefault();
|
|
311
|
+
event.stopPropagation();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function onClick(event: MouseEvent): void {
|
|
315
|
+
if (maxTravel > DRAG_THRESHOLD) return;
|
|
316
|
+
opts.onItemClick?.(event);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Non-negotiable 8: a tap fires pointerenter and never the pointerleave that undoes it. */
|
|
320
|
+
function onPointerEnter(event: PointerEvent): void {
|
|
321
|
+
if (event.pointerType !== "mouse") return;
|
|
322
|
+
hovering = true;
|
|
323
|
+
lane.dataset.hovering = "true";
|
|
324
|
+
start();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function onPointerLeave(event: PointerEvent): void {
|
|
328
|
+
if (event.pointerType !== "mouse") return;
|
|
329
|
+
hovering = false;
|
|
330
|
+
delete lane.dataset.hovering;
|
|
331
|
+
start();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function onMotionChange(event: MediaQueryListEvent): void {
|
|
335
|
+
reducedMotion = event.matches;
|
|
336
|
+
lane.dataset.reducedMotion = String(reducedMotion);
|
|
337
|
+
start();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const resizeObserver = typeof ResizeObserver === "function" ? new ResizeObserver(() => measure()) : null;
|
|
341
|
+
|
|
342
|
+
function init(): void {
|
|
343
|
+
applyStyles();
|
|
344
|
+
lane.dataset.enigmaMarquee = "";
|
|
345
|
+
lane.dataset.reducedMotion = String(reducedMotion);
|
|
346
|
+
track.dataset.enigmaMarqueeTrack = "";
|
|
347
|
+
for (const child of Array.from(track.children)) {
|
|
348
|
+
(child as HTMLElement).dataset.enigmaMarqueeCopy ??= "0";
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
lane.addEventListener("pointerdown", onPointerDown);
|
|
352
|
+
lane.addEventListener("pointerenter", onPointerEnter);
|
|
353
|
+
lane.addEventListener("pointerleave", onPointerLeave);
|
|
354
|
+
lane.addEventListener("click", onClickCapture, true);
|
|
355
|
+
lane.addEventListener("click", onClick);
|
|
356
|
+
motionQuery?.addEventListener("change", onMotionChange);
|
|
357
|
+
|
|
358
|
+
// A lane can change width without the window doing so, so observe the lane.
|
|
359
|
+
resizeObserver?.observe(lane);
|
|
360
|
+
const first = track.children[0];
|
|
361
|
+
if (first) resizeObserver?.observe(first);
|
|
362
|
+
|
|
363
|
+
measure();
|
|
364
|
+
// A late font changes every width the lap was measured from.
|
|
365
|
+
void document.fonts?.ready.then(() => measure());
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
init();
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
get offset() { return offset; },
|
|
372
|
+
get period() { return period; },
|
|
373
|
+
get copyCount() { return copyCount; },
|
|
374
|
+
get dragging() { return dragging; },
|
|
375
|
+
get reducedMotion() { return reducedMotion; },
|
|
376
|
+
update(next: Partial<MarqueeOptions>) {
|
|
377
|
+
const styleKeys = next.vertical !== undefined || next.manageStyles !== undefined;
|
|
378
|
+
opts = { ...opts, ...next };
|
|
379
|
+
if (styleKeys) applyStyles();
|
|
380
|
+
measure();
|
|
381
|
+
},
|
|
382
|
+
measure,
|
|
383
|
+
pause() { paused = true; start(); },
|
|
384
|
+
resume() { paused = false; start(); },
|
|
385
|
+
destroy() {
|
|
386
|
+
destroyed = true;
|
|
387
|
+
if (frame) cancelAnimationFrame(frame);
|
|
388
|
+
resizeObserver?.disconnect();
|
|
389
|
+
motionQuery?.removeEventListener("change", onMotionChange);
|
|
390
|
+
lane.removeEventListener("pointerdown", onPointerDown);
|
|
391
|
+
lane.removeEventListener("pointerenter", onPointerEnter);
|
|
392
|
+
lane.removeEventListener("pointerleave", onPointerLeave);
|
|
393
|
+
lane.removeEventListener("click", onClickCapture, true);
|
|
394
|
+
lane.removeEventListener("click", onClick);
|
|
395
|
+
window.removeEventListener("pointermove", onPointerMove);
|
|
396
|
+
window.removeEventListener("pointerup", onPointerUp);
|
|
397
|
+
window.removeEventListener("pointercancel", onPointerUp);
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createMarquee, type MarqueeOptions, type MarqueeInstance } from "@/core/marquee";
|