@enigmax/primitives 0.1.2 → 0.3.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.
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Search-as-you-type: debouncing, ordering, cancellation and the field wiring, with the
3
+ * matching left pluggable.
4
+ *
5
+ * Fuse.js is NOT a dependency of this package. Hand it the constructor and you get fuzzy
6
+ * matching configured exactly as if you had called it yourself - `fuseOptions` is passed
7
+ * through untouched. Hand it nothing and a built-in accent-insensitive substring matcher
8
+ * runs instead, so the primitive works with zero dependencies. Hand it a `matcher` and
9
+ * every part of this is replaced by your own.
10
+ */
11
+
12
+ export interface SearchMatch<T> {
13
+ item: T;
14
+ /** Lower is better, matching Fuse's convention. 0 is an exact hit. */
15
+ score: number;
16
+ /** Which key matched, when the matcher reports it. */
17
+ key?: string;
18
+ }
19
+
20
+ /** The shape of Fuse's constructor, declared here so the package need not depend on it. */
21
+ export interface FuseLike<T> {
22
+ search(query: string): { item: T; score?: number; matches?: { key?: string; }[]; }[];
23
+ }
24
+ export type FuseConstructor = new <T>(items: readonly T[], options?: Record<string, unknown>) => FuseLike<T>;
25
+
26
+ export interface SearchOptions<T> {
27
+ items?: readonly T[];
28
+ /** Fields to search. Dotted paths work with the built-in matcher and with Fuse. */
29
+ keys?: string[];
30
+ /**
31
+ * Pass Fuse.js's constructor to get fuzzy matching. Omit it for the built-in
32
+ * substring matcher. Ignored when `matcher` is set.
33
+ */
34
+ fuse?: FuseConstructor;
35
+ /** Handed to Fuse verbatim, so any Fuse option behaves exactly as documented there. */
36
+ fuseOptions?: Record<string, unknown>;
37
+ /** Replaces the engine entirely. Return the results in the order you want them. */
38
+ matcher?: (query: string, items: readonly T[]) => SearchMatch<T>[];
39
+ /** ms to wait after the last keystroke. 0 searches on every one. */
40
+ debounce?: number;
41
+ /** Queries shorter than this return nothing rather than everything. */
42
+ minLength?: number;
43
+ /** Cap the result list. */
44
+ limit?: number;
45
+ /** What an empty query returns. "none" (default) or "all". */
46
+ empty?: "none" | "all";
47
+ onResults?: (results: SearchMatch<T>[], query: string) => void;
48
+ }
49
+
50
+ export interface SearchInstance<T> {
51
+ readonly query: string;
52
+ readonly results: SearchMatch<T>[];
53
+ /** Debounced. */
54
+ search(query: string): void;
55
+ /** Skips the debounce, e.g. on Enter. */
56
+ searchNow(query: string): SearchMatch<T>[];
57
+ setItems(items: readonly T[]): void;
58
+ update(options: Partial<SearchOptions<T>>): void;
59
+ /** Bind to a field: input events search, Escape clears. Returns an unbind. */
60
+ attach(input: HTMLInputElement): () => void;
61
+ subscribe(listener: (results: SearchMatch<T>[], query: string) => void): () => void;
62
+ destroy(): void;
63
+ }
64
+
65
+ const DEFAULT_DEBOUNCE = 120;
66
+
67
+ /** "Café" must match "cafe" - a search that fails on an accent reads as broken. */
68
+ function fold(value: string): string {
69
+ return value.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase();
70
+ }
71
+
72
+ function readPath(item: unknown, path: string): string {
73
+ let cursor: unknown = item;
74
+ for (const step of path.split(".")) {
75
+ if (cursor == null || typeof cursor !== "object") return "";
76
+ cursor = (cursor as Record<string, unknown>)[step];
77
+ }
78
+ return cursor == null ? "" : String(cursor);
79
+ }
80
+
81
+ /**
82
+ * The zero-dependency fallback: accent-insensitive substring, ranked by where the match
83
+ * lands and which key it landed in, so a title hit outranks a body hit.
84
+ */
85
+ function substringMatcher<T>(query: string, items: readonly T[], keys: string[]): SearchMatch<T>[] {
86
+ const needle = fold(query);
87
+ const results: SearchMatch<T>[] = [];
88
+
89
+ for (const item of items) {
90
+ const fields = keys.length ? keys : [""];
91
+ let best: SearchMatch<T> | null = null;
92
+
93
+ for (let index = 0; index < fields.length; index++) {
94
+ const key = fields[index];
95
+ const haystack = fold(key ? readPath(item, key) : String(item));
96
+ const at = haystack.indexOf(needle);
97
+ if (at < 0) continue;
98
+ // Earlier in the string and earlier in the key list is a better hit; a whole
99
+ // field that IS the query scores 0.
100
+ const score = haystack === needle ? 0 : (at + 1) / (haystack.length + 1) + index * 0.01;
101
+ if (!best || score < best.score) best = { item, score, key: key || undefined };
102
+ }
103
+ if (best) results.push(best);
104
+ }
105
+
106
+ return results.sort((left, right) => left.score - right.score);
107
+ }
108
+
109
+ export function createSearch<T>(options: SearchOptions<T> = {}): SearchInstance<T> {
110
+ let opts: SearchOptions<T> = { ...options };
111
+ let items: readonly T[] = opts.items ?? [];
112
+ let query = "";
113
+ let results: SearchMatch<T>[] = [];
114
+ let timer: ReturnType<typeof setTimeout> | null = null;
115
+ let engine: FuseLike<T> | null = null;
116
+ let destroyed = false;
117
+ const listeners = new Set<(results: SearchMatch<T>[], query: string) => void>();
118
+
119
+ function buildEngine(): void {
120
+ engine = null;
121
+ if (opts.matcher || !opts.fuse) return;
122
+ // Fuse indexes on construction, so it is rebuilt when the items or keys change
123
+ // and never per keystroke.
124
+ engine = new opts.fuse(items, { keys: opts.keys ?? [], includeScore: true, includeMatches: true, threshold: 0.4, ...opts.fuseOptions });
125
+ }
126
+
127
+ function run(next: string): SearchMatch<T>[] {
128
+ const trimmed = next.trim();
129
+ const minLength = opts.minLength ?? 1;
130
+
131
+ let found: SearchMatch<T>[];
132
+ // The limit applies to this branch too: a caller asking for 10 rows means 10 rows,
133
+ // including the "show everything" state, which is the longest list of them all.
134
+ if (trimmed.length < minLength) {
135
+ found = (opts.empty ?? "none") === "all" ? items.map((item) => ({ item, score: 0 })) : [];
136
+ } else if (opts.matcher) {
137
+ found = opts.matcher(trimmed, items);
138
+ } else if (engine) {
139
+ found = engine.search(trimmed).map((hit) => ({
140
+ item: hit.item,
141
+ score: hit.score ?? 0,
142
+ key: hit.matches?.[0]?.key
143
+ }));
144
+ } else {
145
+ found = substringMatcher(trimmed, items, opts.keys ?? []);
146
+ }
147
+
148
+ return opts.limit != null ? found.slice(0, opts.limit) : found;
149
+ }
150
+
151
+ function publish(next: string): SearchMatch<T>[] {
152
+ query = next;
153
+ results = run(next);
154
+ opts.onResults?.(results, query);
155
+ for (const listener of listeners) listener(results, query);
156
+ return results;
157
+ }
158
+
159
+ function clearTimer(): void {
160
+ if (timer === null) return;
161
+ clearTimeout(timer);
162
+ timer = null;
163
+ }
164
+
165
+ buildEngine();
166
+
167
+ return {
168
+ get query() { return query; },
169
+ get results() { return results; },
170
+ search(next: string) {
171
+ if (destroyed) return;
172
+ clearTimer();
173
+ const wait = opts.debounce ?? DEFAULT_DEBOUNCE;
174
+ // A pending search is dropped rather than queued, so a fast typist gets one
175
+ // pass over the data instead of one per keystroke.
176
+ if (wait <= 0) { publish(next); return; }
177
+ timer = setTimeout(() => { timer = null; publish(next); }, wait);
178
+ },
179
+ searchNow(next: string) {
180
+ clearTimer();
181
+ return publish(next);
182
+ },
183
+ setItems(next: readonly T[]) {
184
+ items = next;
185
+ buildEngine();
186
+ // The visible results are stale the moment the data changes under them.
187
+ if (query) publish(query);
188
+ },
189
+ update(next: Partial<SearchOptions<T>>) {
190
+ opts = { ...opts, ...next };
191
+ if (next.items) items = next.items;
192
+ buildEngine();
193
+ if (query) publish(query);
194
+ },
195
+ attach(input: HTMLInputElement) {
196
+ const onInput = () => this.search(input.value);
197
+ const onKeyDown = (event: KeyboardEvent) => {
198
+ if (event.key !== "Escape" || !input.value) return;
199
+ // Escape clears the field first and only then reaches the dialog around
200
+ // it, which is what a visitor expects from a search box.
201
+ event.stopPropagation();
202
+ input.value = "";
203
+ this.searchNow("");
204
+ };
205
+ input.addEventListener("input", onInput);
206
+ input.addEventListener("keydown", onKeyDown);
207
+ input.dataset.enigmaSearch = "";
208
+ return () => {
209
+ input.removeEventListener("input", onInput);
210
+ input.removeEventListener("keydown", onKeyDown);
211
+ delete input.dataset.enigmaSearch;
212
+ };
213
+ },
214
+ subscribe(listener) {
215
+ listeners.add(listener);
216
+ return () => { listeners.delete(listener); };
217
+ },
218
+ destroy() {
219
+ destroyed = true;
220
+ clearTimer();
221
+ listeners.clear();
222
+ engine = null;
223
+ }
224
+ };
225
+ }
package/src/index.ts CHANGED
@@ -1 +1,3 @@
1
1
  export { createMarquee, type MarqueeOptions, type MarqueeInstance } from "@/core/marquee";
2
+ export { createInput, type InputOptions, type InputInstance, type InputAction, type InputIcon, type InputActionState } from "@/core/input";
3
+ export { createSearch, type SearchOptions, type SearchInstance, type SearchMatch, type FuseConstructor, type FuseLike } from "@/core/search";
@@ -1,2 +1,6 @@
1
1
  export { useMarquee, type UseMarqueeResult } from "@/react/use-marquee";
2
2
  export { type MarqueeOptions, type MarqueeInstance } from "@/core/marquee";
3
+ export { useInput, type UseInputResult } from "@/react/use-input";
4
+ export { useSearch, type UseSearchResult } from "@/react/use-search";
5
+ export { type InputOptions, type InputAction, type InputIcon } from "@/core/input";
6
+ export { type SearchOptions, type SearchMatch, type FuseConstructor } from "@/core/search";
@@ -0,0 +1,70 @@
1
+ import { createInput, type InputOptions, type InputInstance } from "@/core/input";
2
+ import { useRef, useState, useEffect, useLayoutEffect, type RefObject } from "react";
3
+
4
+ const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
5
+
6
+ export interface UseInputResult {
7
+ /** Attach to the `<input>`. */
8
+ inputRef: RefObject<HTMLInputElement | null>;
9
+ /** Attach to your own actions container, or leave it and one is created for you. */
10
+ actionsRef: RefObject<HTMLElement | null>;
11
+ /** True while a password is readable. */
12
+ revealed: boolean;
13
+ reveal: (next?: boolean) => void;
14
+ /** Re-render the actions after you change the field yourself. */
15
+ refresh: () => void;
16
+ }
17
+
18
+ /**
19
+ * In-field actions for an input, with the password reveal wired for free.
20
+ *
21
+ * ```tsx
22
+ * const { inputRef, revealed } = useInput();
23
+ * return <input ref={inputRef} type="password" autoComplete="current-password" />;
24
+ * ```
25
+ *
26
+ * The toggle is a real `<button type="button">`, so it never submits the form, and the
27
+ * caret survives the type switch.
28
+ */
29
+ export function useInput(options: InputOptions = {}): UseInputResult {
30
+ const inputRef = useRef<HTMLInputElement | null>(null);
31
+ const actionsRef = useRef<HTMLElement | null>(null);
32
+ const instanceRef = useRef<InputInstance | null>(null);
33
+ const optionsRef = useRef(options);
34
+ optionsRef.current = options;
35
+
36
+ const [revealed, setRevealed] = useState(false);
37
+
38
+ useIsomorphicLayoutEffect(() => {
39
+ const input = inputRef.current;
40
+ if (!input) return;
41
+
42
+ const instance = createInput(input, {
43
+ ...optionsRef.current,
44
+ container: actionsRef.current ?? optionsRef.current.container,
45
+ onRevealChange: (next) => {
46
+ setRevealed(next);
47
+ optionsRef.current.onRevealChange?.(next);
48
+ }
49
+ });
50
+ instanceRef.current = instance;
51
+ return () => {
52
+ instance.destroy();
53
+ instanceRef.current = null;
54
+ };
55
+ // Created once; option changes go through update() so the field is not rebuilt
56
+ // on every render, which would drop the caret mid-typing.
57
+ }, []);
58
+
59
+ useIsomorphicLayoutEffect(() => {
60
+ instanceRef.current?.update(options);
61
+ }, [options.reveal, options.actions, options.position]);
62
+
63
+ return {
64
+ inputRef,
65
+ actionsRef,
66
+ revealed,
67
+ reveal: (next?: boolean) => instanceRef.current?.reveal(next),
68
+ refresh: () => instanceRef.current?.refresh()
69
+ };
70
+ }
@@ -0,0 +1,77 @@
1
+ import { useRef, useState, useEffect, useMemo, type RefObject } from "react";
2
+ import { createSearch, type SearchOptions, type SearchMatch, type SearchInstance } from "@/core/search";
3
+
4
+ export interface UseSearchResult<T> {
5
+ /** Attach to the search field, or drive it yourself with `search`. */
6
+ inputRef: RefObject<HTMLInputElement | null>;
7
+ query: string;
8
+ results: SearchMatch<T>[];
9
+ /** Debounced. */
10
+ search: (query: string) => void;
11
+ /** Skips the debounce. */
12
+ searchNow: (query: string) => void;
13
+ clear: () => void;
14
+ }
15
+
16
+ /**
17
+ * Search-as-you-type over a list.
18
+ *
19
+ * Pass `fuse` (Fuse.js's constructor) for fuzzy matching, `matcher` to replace the
20
+ * engine, or neither for the built-in accent-insensitive substring search.
21
+ *
22
+ * ```tsx
23
+ * import Fuse from "fuse.js";
24
+ *
25
+ * const { inputRef, results } = useSearch({ items: docs, keys: ["title", "body"], fuse: Fuse });
26
+ * return <input ref={inputRef} type="search" placeholder="Search" />;
27
+ * ```
28
+ */
29
+ export function useSearch<T>(options: SearchOptions<T> = {}): UseSearchResult<T> {
30
+ const inputRef = useRef<HTMLInputElement | null>(null);
31
+ const instanceRef = useRef<SearchInstance<T> | null>(null);
32
+ const optionsRef = useRef(options);
33
+ optionsRef.current = options;
34
+
35
+ const [query, setQuery] = useState("");
36
+ const [results, setResults] = useState<SearchMatch<T>[]>([]);
37
+
38
+ // Built once. The engine indexes its items on construction, so rebuilding it per
39
+ // render would re-index the whole list on every keystroke.
40
+ const instance = useMemo(() => {
41
+ const created = createSearch<T>({
42
+ ...optionsRef.current,
43
+ onResults: (next, nextQuery) => {
44
+ setResults(next);
45
+ setQuery(nextQuery);
46
+ optionsRef.current.onResults?.(next, nextQuery);
47
+ }
48
+ });
49
+ instanceRef.current = created;
50
+ return created;
51
+ // eslint-disable-next-line react-hooks/exhaustive-deps
52
+ }, []);
53
+
54
+ useEffect(() => () => instance.destroy(), [instance]);
55
+
56
+ // New data must re-index and re-run the visible query, or the list shows matches
57
+ // against items that are gone.
58
+ useEffect(() => { instance.setItems(options.items ?? []); }, [instance, options.items]);
59
+
60
+ useEffect(() => {
61
+ const input = inputRef.current;
62
+ if (!input) return;
63
+ return instance.attach(input);
64
+ }, [instance]);
65
+
66
+ return {
67
+ inputRef,
68
+ query,
69
+ results,
70
+ search: (next: string) => instance.search(next),
71
+ searchNow: (next: string) => { instance.searchNow(next); },
72
+ clear: () => {
73
+ if (inputRef.current) inputRef.current.value = "";
74
+ instance.searchNow("");
75
+ }
76
+ };
77
+ }
@@ -1,288 +0,0 @@
1
- // src/core/marquee.ts
2
- var DEFAULT_DECAY = 0.12;
3
- var DRAG_THRESHOLD = 6;
4
- var STILL_MS = 90;
5
- var MAX_DT = 0.05;
6
- var VELOCITY_SMOOTHING = 0.6;
7
- var TOUCH_HOVER_SUPPRESS_MS = 1e3;
8
- var IDLE_EPSILON = 0.01;
9
- var DEFAULTS = {
10
- speed: 60,
11
- reverse: false,
12
- vertical: false,
13
- draggable: true,
14
- hoverScale: 1,
15
- decay: DEFAULT_DECAY,
16
- manageStyles: true,
17
- copies: "clone"
18
- };
19
- function createMarquee(lane, track, options = {}) {
20
- let opts = { ...DEFAULTS, ...options };
21
- let period = 0;
22
- let copyCount = Math.max(2, track.children.length);
23
- let offset = 0;
24
- let velocity = 0;
25
- let running = false;
26
- let paused = false;
27
- let destroyed = false;
28
- let frame = 0;
29
- let lastTime = 0;
30
- let dragging = false;
31
- let pointerId = null;
32
- let lastPointer = 0;
33
- let startPointer = 0;
34
- let lastMoveTime = 0;
35
- let maxTravel = 0;
36
- let hovering = false;
37
- let lastTouchAt = 0;
38
- const motionQuery = typeof window.matchMedia === "function" ? window.matchMedia("(prefers-reduced-motion: reduce)") : null;
39
- let reducedMotion = motionQuery?.matches ?? false;
40
- function target() {
41
- if (reducedMotion || paused) return 0;
42
- const cruise = opts.reverse ? -opts.speed : opts.speed;
43
- return hovering ? cruise * opts.hoverScale : cruise;
44
- }
45
- function axisSize(element) {
46
- return opts.vertical ? element.offsetHeight : element.offsetWidth;
47
- }
48
- function axisStart(element) {
49
- return opts.vertical ? element.offsetTop : element.offsetLeft;
50
- }
51
- function applyStyles() {
52
- if (!opts.manageStyles) return;
53
- lane.style.overflow = "hidden";
54
- lane.style.touchAction = opts.vertical ? "pan-x" : "pan-y";
55
- lane.style.userSelect = "none";
56
- lane.style.webkitUserSelect = "none";
57
- track.style.willChange = "transform";
58
- }
59
- function render() {
60
- const x = opts.vertical ? 0 : -offset;
61
- const y = opts.vertical ? -offset : 0;
62
- track.style.transform = `translate3d(${x}px, ${y}px, 0)`;
63
- }
64
- function readPeriod() {
65
- const first = track.children[0];
66
- const second = track.children[1];
67
- if (!first) return 0;
68
- if (second) {
69
- const step = axisStart(second) - axisStart(first);
70
- if (step > 0) return step;
71
- }
72
- return axisSize(first);
73
- }
74
- function requiredCopies(measured) {
75
- if (measured <= 0) return 2;
76
- return Math.max(2, Math.ceil(axisSize(lane) / measured) + 1);
77
- }
78
- function syncClones(count) {
79
- const source = track.children[0];
80
- if (!source) return;
81
- while (track.children.length > count) track.removeChild(track.lastElementChild);
82
- while (track.children.length < count) {
83
- const clone = source.cloneNode(true);
84
- clone.setAttribute("aria-hidden", "true");
85
- clone.dataset.enigmaMarqueeCopy = String(track.children.length);
86
- track.appendChild(clone);
87
- }
88
- }
89
- function measure() {
90
- if (destroyed) return;
91
- const cloning = opts.copies === "clone";
92
- if (cloning && track.children.length < 2) syncClones(2);
93
- let measured = readPeriod();
94
- let needed = requiredCopies(measured);
95
- if (cloning && track.children.length !== needed) {
96
- syncClones(needed);
97
- measured = readPeriod();
98
- needed = requiredCopies(measured);
99
- }
100
- const changed = measured !== period || needed !== copyCount;
101
- period = measured;
102
- if (needed !== copyCount) {
103
- copyCount = needed;
104
- opts.onCopyCountChange?.(needed);
105
- }
106
- if (period > 0) offset = wrap(offset);
107
- render();
108
- if (changed) opts.onMeasure?.(period, copyCount);
109
- start();
110
- }
111
- function wrap(value) {
112
- if (period <= 0) return value;
113
- return (value % period + period) % period;
114
- }
115
- function tick(now) {
116
- frame = 0;
117
- if (destroyed) return;
118
- const dt = Math.min((now - lastTime) / 1e3, MAX_DT);
119
- lastTime = now;
120
- if (!dragging) {
121
- velocity += (target() - velocity) * (1 - Math.pow(opts.decay, dt));
122
- offset = wrap(offset + velocity * dt);
123
- }
124
- render();
125
- const idle = !dragging && Math.abs(velocity) < IDLE_EPSILON && Math.abs(target()) < IDLE_EPSILON;
126
- if (idle) {
127
- velocity = 0;
128
- running = false;
129
- return;
130
- }
131
- frame = requestAnimationFrame(tick);
132
- }
133
- function start() {
134
- if (destroyed || running) return;
135
- running = true;
136
- lastTime = performance.now();
137
- frame = requestAnimationFrame(tick);
138
- }
139
- function pointerDelta(event) {
140
- return opts.vertical ? event.clientY : event.clientX;
141
- }
142
- function onPointerDown(event) {
143
- if (event.pointerType !== "mouse") noteTouch();
144
- if (!opts.draggable || event.button > 0 || pointerId !== null) return;
145
- pointerId = event.pointerId;
146
- dragging = true;
147
- lastPointer = pointerDelta(event);
148
- startPointer = lastPointer;
149
- lastMoveTime = event.timeStamp;
150
- maxTravel = 0;
151
- lane.dataset.dragging = "true";
152
- window.addEventListener("pointermove", onPointerMove);
153
- window.addEventListener("pointerup", onPointerUp);
154
- window.addEventListener("pointercancel", onPointerUp);
155
- start();
156
- }
157
- function onPointerMove(event) {
158
- if (event.pointerId !== pointerId) return;
159
- const position = pointerDelta(event);
160
- const delta = position - lastPointer;
161
- lastPointer = position;
162
- const dt = Math.max((event.timeStamp - lastMoveTime) / 1e3, 1e-3);
163
- lastMoveTime = event.timeStamp;
164
- maxTravel = Math.max(maxTravel, Math.abs(position - startPointer));
165
- offset = wrap(offset - delta);
166
- const instant = -delta / dt;
167
- velocity = velocity * VELOCITY_SMOOTHING + instant * (1 - VELOCITY_SMOOTHING);
168
- }
169
- function onPointerUp(event) {
170
- if (event.pointerId !== pointerId) return;
171
- pointerId = null;
172
- dragging = false;
173
- delete lane.dataset.dragging;
174
- window.removeEventListener("pointermove", onPointerMove);
175
- window.removeEventListener("pointerup", onPointerUp);
176
- window.removeEventListener("pointercancel", onPointerUp);
177
- if (event.timeStamp - lastMoveTime > STILL_MS) velocity = 0;
178
- start();
179
- }
180
- function onClickCapture(event) {
181
- if (maxTravel <= DRAG_THRESHOLD) return;
182
- event.preventDefault();
183
- event.stopPropagation();
184
- }
185
- function onClick(event) {
186
- if (maxTravel > DRAG_THRESHOLD) return;
187
- opts.onItemClick?.(event);
188
- }
189
- function onPointerEnter(event) {
190
- if (event.pointerType !== "mouse") {
191
- noteTouch();
192
- return;
193
- }
194
- if (Date.now() - lastTouchAt < TOUCH_HOVER_SUPPRESS_MS) return;
195
- hovering = true;
196
- lane.dataset.hovering = "true";
197
- start();
198
- }
199
- function onPointerLeave() {
200
- hovering = false;
201
- delete lane.dataset.hovering;
202
- start();
203
- }
204
- function noteTouch() {
205
- lastTouchAt = Date.now();
206
- if (!hovering) return;
207
- hovering = false;
208
- delete lane.dataset.hovering;
209
- start();
210
- }
211
- function onMotionChange(event) {
212
- reducedMotion = event.matches;
213
- lane.dataset.reducedMotion = String(reducedMotion);
214
- start();
215
- }
216
- const resizeObserver = typeof ResizeObserver === "function" ? new ResizeObserver(() => measure()) : null;
217
- function init() {
218
- applyStyles();
219
- lane.dataset.enigmaMarquee = "";
220
- lane.dataset.reducedMotion = String(reducedMotion);
221
- track.dataset.enigmaMarqueeTrack = "";
222
- for (const child of Array.from(track.children)) {
223
- child.dataset.enigmaMarqueeCopy ??= "0";
224
- }
225
- lane.addEventListener("pointerdown", onPointerDown);
226
- lane.addEventListener("pointerenter", onPointerEnter);
227
- lane.addEventListener("pointerleave", onPointerLeave);
228
- lane.addEventListener("touchstart", noteTouch, { passive: true });
229
- lane.addEventListener("click", onClickCapture, true);
230
- lane.addEventListener("click", onClick);
231
- motionQuery?.addEventListener("change", onMotionChange);
232
- resizeObserver?.observe(lane);
233
- const first = track.children[0];
234
- if (first) resizeObserver?.observe(first);
235
- measure();
236
- void document.fonts?.ready.then(() => measure());
237
- }
238
- init();
239
- return {
240
- get offset() {
241
- return offset;
242
- },
243
- get period() {
244
- return period;
245
- },
246
- get copyCount() {
247
- return copyCount;
248
- },
249
- get dragging() {
250
- return dragging;
251
- },
252
- get reducedMotion() {
253
- return reducedMotion;
254
- },
255
- update(next) {
256
- const styleKeys = next.vertical !== void 0 || next.manageStyles !== void 0;
257
- opts = { ...opts, ...next };
258
- if (styleKeys) applyStyles();
259
- measure();
260
- },
261
- measure,
262
- pause() {
263
- paused = true;
264
- start();
265
- },
266
- resume() {
267
- paused = false;
268
- start();
269
- },
270
- destroy() {
271
- destroyed = true;
272
- if (frame) cancelAnimationFrame(frame);
273
- resizeObserver?.disconnect();
274
- motionQuery?.removeEventListener("change", onMotionChange);
275
- lane.removeEventListener("pointerdown", onPointerDown);
276
- lane.removeEventListener("pointerenter", onPointerEnter);
277
- lane.removeEventListener("pointerleave", onPointerLeave);
278
- lane.removeEventListener("touchstart", noteTouch);
279
- lane.removeEventListener("click", onClickCapture, true);
280
- lane.removeEventListener("click", onClick);
281
- window.removeEventListener("pointermove", onPointerMove);
282
- window.removeEventListener("pointerup", onPointerUp);
283
- window.removeEventListener("pointercancel", onPointerUp);
284
- }
285
- };
286
- }
287
-
288
- export { createMarquee };