@udixio/ui-react 1.6.3 → 2.0.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/CHANGELOG.md +41 -0
- package/dist/index.cjs +3 -3
- package/dist/index.js +1710 -1449
- package/dist/lib/config/config.interface.d.ts +4 -0
- package/dist/lib/config/config.interface.d.ts.map +1 -0
- package/dist/lib/config/define-config.d.ts +4 -0
- package/dist/lib/config/define-config.d.ts.map +1 -0
- package/dist/lib/config/index.d.ts +3 -0
- package/dist/lib/config/index.d.ts.map +1 -0
- package/dist/lib/effects/AnimateOnScroll.d.ts +15 -0
- package/dist/lib/effects/AnimateOnScroll.d.ts.map +1 -0
- package/dist/lib/effects/ThemeProvider.d.ts +3 -2
- package/dist/lib/effects/ThemeProvider.d.ts.map +1 -1
- package/dist/lib/effects/block-scroll.effect.d.ts +22 -0
- package/dist/lib/effects/block-scroll.effect.d.ts.map +1 -0
- package/dist/lib/effects/custom-scroll/custom-scroll.effect.d.ts.map +1 -1
- package/dist/lib/effects/index.d.ts +1 -0
- package/dist/lib/effects/index.d.ts.map +1 -1
- package/dist/lib/effects/scrollDriven.d.ts +5 -0
- package/dist/lib/effects/scrollDriven.d.ts.map +1 -0
- package/dist/lib/effects/smooth-scroll.effect.d.ts +5 -5
- package/dist/lib/effects/smooth-scroll.effect.d.ts.map +1 -1
- package/dist/lib/index.d.ts +1 -0
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/styles/fab.style.d.ts.map +1 -1
- package/dist/scrollDriven-AP2yWhzi.js +121 -0
- package/dist/scrollDriven-DWAu7CR0.cjs +1 -0
- package/package.json +5 -3
- package/src/lib/config/config.interface.ts +9 -0
- package/src/lib/config/define-config.ts +16 -0
- package/src/lib/config/index.ts +2 -0
- package/src/lib/effects/AnimateOnScroll.tsx +353 -0
- package/src/lib/effects/ThemeProvider.tsx +78 -52
- package/src/lib/effects/block-scroll.effect.tsx +174 -0
- package/src/lib/effects/custom-scroll/custom-scroll.effect.tsx +16 -5
- package/src/lib/effects/index.ts +1 -0
- package/src/lib/effects/scrollDriven.ts +239 -0
- package/src/lib/effects/smooth-scroll.effect.tsx +105 -72
- package/src/lib/index.ts +1 -0
- package/src/lib/styles/card.style.ts +1 -1
- package/src/lib/styles/fab.style.ts +9 -17
- package/src/lib/styles/slider.style.ts +2 -2
- package/src/lib/styles/tab.style.ts +1 -1
- package/src/lib/styles/tabs.style.ts +3 -3
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { ReactNode, useEffect, useMemo, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AnimateOnScroll
|
|
5
|
+
*
|
|
6
|
+
* Manages triggers for animations:
|
|
7
|
+
* - ScrollDriven animations: use native CSS if supported; otherwise import JS fallback per element set.
|
|
8
|
+
* - Other entry/exit animations: handled via IntersectionObserver in JS.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type AnimateOnScrollProps = {
|
|
12
|
+
prefix?: string;
|
|
13
|
+
children?: ReactNode;
|
|
14
|
+
once?: boolean; // if true in JS modes, animate only first time per element
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function supportsScrollTimeline(): boolean {
|
|
18
|
+
if (typeof window === `undefined`) return false;
|
|
19
|
+
try {
|
|
20
|
+
// @ts-ignore - CSS may not exist in TS lib
|
|
21
|
+
if (window.CSS && typeof window.CSS.supports === `function`) {
|
|
22
|
+
// @ts-ignore
|
|
23
|
+
return (
|
|
24
|
+
CSS.supports(`animation-timeline: view()`) ||
|
|
25
|
+
CSS.supports(`animation-timeline: scroll()`) ||
|
|
26
|
+
// some older implementations used view-timeline-name
|
|
27
|
+
CSS.supports(`view-timeline-name: --a`)
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
} catch {}
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function prefersReducedMotion(): boolean {
|
|
35
|
+
if (typeof window === `undefined` || !(`matchMedia` in window)) return false;
|
|
36
|
+
return window.matchMedia(`(prefers-reduced-motion: reduce)`).matches;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isScrollDrivenCandidate(el: Element): boolean {
|
|
40
|
+
if (!(el instanceof HTMLElement)) return false;
|
|
41
|
+
const cls = el.classList;
|
|
42
|
+
|
|
43
|
+
return Array.from(cls).some(
|
|
44
|
+
(className) =>
|
|
45
|
+
className.startsWith('anim-') && className.includes('scroll'),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
function isJsObserverCandidate(el: Element): boolean {
|
|
49
|
+
if (!(el instanceof HTMLElement)) return false;
|
|
50
|
+
const cls = el.classList;
|
|
51
|
+
const hasAnimation = Array.from(cls).some((className) =>
|
|
52
|
+
className.startsWith('anim-'),
|
|
53
|
+
);
|
|
54
|
+
if (!hasAnimation) return false;
|
|
55
|
+
// Not scroll-driven
|
|
56
|
+
return !isScrollDrivenCandidate(el);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Collect `--anim-names-*` CSS custom properties on the element and update:
|
|
60
|
+
// - animations-names: comma-separated list of suffixes (after --anim-names-)
|
|
61
|
+
// - will-change: union of comma-separated values from each variable
|
|
62
|
+
function updateAnimNamesAndWillChange(el: HTMLElement, prefix: string): void {
|
|
63
|
+
if (typeof window === 'undefined') return;
|
|
64
|
+
try {
|
|
65
|
+
const cs = getComputedStyle(el);
|
|
66
|
+
|
|
67
|
+
const animationNames = new Set<string>();
|
|
68
|
+
|
|
69
|
+
// Replace the classList loop with this:
|
|
70
|
+
Array.from(el.classList).forEach((className) => {
|
|
71
|
+
if (className.startsWith('anim')) {
|
|
72
|
+
const cssVarName = `--anim-name-${className.replaceAll(/-in|-out|-scroll/g, '').replace(prefix + '-', '')}`;
|
|
73
|
+
const animationName = cs.getPropertyValue(cssVarName).trim();
|
|
74
|
+
if (animationName) {
|
|
75
|
+
animationNames.add(animationName.replace(prefix + '-', ''));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (animationNames.size > 0) {
|
|
81
|
+
const value = Array.from(animationNames)
|
|
82
|
+
.map((name) => {
|
|
83
|
+
return `var(--anim-name-${name})`;
|
|
84
|
+
})
|
|
85
|
+
.join(', ');
|
|
86
|
+
const changes = Array.from(animationNames)
|
|
87
|
+
.map((name) => {
|
|
88
|
+
return `var(--anim-dependencies-${name})`;
|
|
89
|
+
})
|
|
90
|
+
.join(', ');
|
|
91
|
+
|
|
92
|
+
el.style.animationName = value;
|
|
93
|
+
el.style.willChange = changes;
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
// no-op
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function hydrateElement(el: HTMLElement, prefix: string): void {
|
|
101
|
+
if (!isScrollDrivenCandidate(el)) return;
|
|
102
|
+
|
|
103
|
+
// Map data-anim-scroll to correct axis class if provided
|
|
104
|
+
if (el.hasAttribute(`data-${prefix}-scroll`)) {
|
|
105
|
+
const raw = (el.getAttribute(`data-${prefix}-scroll`) || ``)
|
|
106
|
+
.trim()
|
|
107
|
+
.toLowerCase();
|
|
108
|
+
const axis =
|
|
109
|
+
raw === `x` || raw === `inline`
|
|
110
|
+
? `inline`
|
|
111
|
+
: raw === `y` || raw === `block`
|
|
112
|
+
? `block`
|
|
113
|
+
: `auto`;
|
|
114
|
+
const hasAny =
|
|
115
|
+
el.classList.contains(`${prefix}-timeline`) ||
|
|
116
|
+
el.classList.contains(`${prefix}-timeline-inline`) ||
|
|
117
|
+
el.classList.contains(`${prefix}-timeline-block`) ||
|
|
118
|
+
el.classList.contains(`${prefix}-timeline-x`) ||
|
|
119
|
+
el.classList.contains(`${prefix}-timeline-y`);
|
|
120
|
+
if (!hasAny) {
|
|
121
|
+
if (axis === `inline`) el.classList.add(`${prefix}-timeline-inline`);
|
|
122
|
+
else if (axis === `block`) el.classList.add(`${prefix}-timeline-block`);
|
|
123
|
+
else el.classList.add(`${prefix}-scroll`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Offsets via data-anim-start / data-anim-end (accepts tokens like "entry 20%", "cover 50%", etc.)
|
|
128
|
+
const start = el.getAttribute(`data-${prefix}-start`);
|
|
129
|
+
if (start) el.style.setProperty(`--${prefix}-range-start`, start);
|
|
130
|
+
const end = el.getAttribute(`data-${prefix}-end`);
|
|
131
|
+
if (end) el.style.setProperty(`--${prefix}-range-end`, end);
|
|
132
|
+
|
|
133
|
+
// Ensure play state is running unless explicitly paused
|
|
134
|
+
const explicitlyPaused =
|
|
135
|
+
el.hasAttribute(`data-${prefix}-paused`) ||
|
|
136
|
+
el.classList.contains(`${prefix}-paused`);
|
|
137
|
+
const alreadyRunning =
|
|
138
|
+
el.hasAttribute(`data-${prefix}-run`) ||
|
|
139
|
+
el.classList.contains(`${prefix}-run`);
|
|
140
|
+
if (!explicitlyPaused && !alreadyRunning) {
|
|
141
|
+
el.setAttribute(`data-${prefix}-run`, ``);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Update animations-names and will-change derived from --anim-names-*
|
|
145
|
+
updateAnimNamesAndWillChange(el, prefix);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const scrollDrivenSelectorParts = (prefix: string) => [
|
|
149
|
+
`.${prefix}-timeline`,
|
|
150
|
+
`.${prefix}-timeline-x`,
|
|
151
|
+
`.${prefix}-timeline-y`,
|
|
152
|
+
`.${prefix}-timeline-inline`,
|
|
153
|
+
`.${prefix}-timeline-block`,
|
|
154
|
+
`[data-${prefix}-scroll]`,
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
function queryScrollDrivenCandidates(
|
|
158
|
+
root: ParentNode = document,
|
|
159
|
+
prefix: string,
|
|
160
|
+
): HTMLElement[] {
|
|
161
|
+
// Select any elements that have an animation class and are marked as scroll-driven
|
|
162
|
+
const animated = Array.from(
|
|
163
|
+
root.querySelectorAll<HTMLElement>(`[class*="${prefix}-"]`),
|
|
164
|
+
);
|
|
165
|
+
return animated.filter((el) => isScrollDrivenCandidate(el));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function queryJsObserverCandidates(
|
|
169
|
+
root: ParentNode = document,
|
|
170
|
+
prefix: string,
|
|
171
|
+
): HTMLElement[] {
|
|
172
|
+
// All anim-in/out that are NOT scroll-driven
|
|
173
|
+
const animated = Array.from(
|
|
174
|
+
root.querySelectorAll<HTMLElement>(`[class*="anim-"]`),
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
return animated.filter((el) => !isScrollDrivenCandidate(el));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export const AnimateOnScroll = ({
|
|
181
|
+
prefix = 'anim',
|
|
182
|
+
children,
|
|
183
|
+
once = true,
|
|
184
|
+
}: AnimateOnScrollProps) => {
|
|
185
|
+
const reduced = useMemo(prefersReducedMotion, []);
|
|
186
|
+
const cssSupported = useMemo(() => supportsScrollTimeline(), []);
|
|
187
|
+
const moRef = useRef<MutationObserver | null>(null);
|
|
188
|
+
const ioRef = useRef<IntersectionObserver | null>(null);
|
|
189
|
+
const observedSetRef = useRef<WeakSet<Element> | null>(null);
|
|
190
|
+
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
if (reduced) return; // respect reduced motion
|
|
193
|
+
|
|
194
|
+
// Setup JS observers for non-scroll-driven animations
|
|
195
|
+
const observed = new WeakSet<Element>();
|
|
196
|
+
observedSetRef.current = observed;
|
|
197
|
+
|
|
198
|
+
const io = new IntersectionObserver(
|
|
199
|
+
(entries) => {
|
|
200
|
+
for (const entry of entries) {
|
|
201
|
+
const el = entry.target as HTMLElement;
|
|
202
|
+
|
|
203
|
+
if (!isJsObserverCandidate(el)) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const cls = el.classList;
|
|
208
|
+
const isOut = cls.contains(`${prefix}-out`);
|
|
209
|
+
const isIn = !isOut;
|
|
210
|
+
|
|
211
|
+
if (isIn && entry.isIntersecting) {
|
|
212
|
+
el.setAttribute(`data-${prefix}-in-run`, ``);
|
|
213
|
+
if (once) io.unobserve(el);
|
|
214
|
+
} else if (isOut && !entry.isIntersecting) {
|
|
215
|
+
// Play exit when leaving viewport
|
|
216
|
+
el.setAttribute(`data-${prefix}-out-run`, ``);
|
|
217
|
+
if (once) io.unobserve(el);
|
|
218
|
+
} else {
|
|
219
|
+
// Pause when not in the triggering state
|
|
220
|
+
// Do not aggressively remove attribute if once=true and already ran
|
|
221
|
+
if (!once) {
|
|
222
|
+
// Store current animation name
|
|
223
|
+
const currentAnimationName = el.style.animationName;
|
|
224
|
+
// Remove animation name
|
|
225
|
+
el.style.animationName = 'none';
|
|
226
|
+
el.removeAttribute(`data-${prefix}-in-run`);
|
|
227
|
+
el.removeAttribute(`data-${prefix}-out-run`);
|
|
228
|
+
void el.offsetWidth; // reflow
|
|
229
|
+
// Re-apply animation name
|
|
230
|
+
el.style.animationName = currentAnimationName;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
{ threshold: [0, 0.2] },
|
|
236
|
+
);
|
|
237
|
+
ioRef.current = io;
|
|
238
|
+
|
|
239
|
+
const observeJsCandidates = (root?: ParentNode) => {
|
|
240
|
+
const candidates = queryJsObserverCandidates(root || document, prefix);
|
|
241
|
+
for (const el of candidates) {
|
|
242
|
+
if (observed.has(el)) continue;
|
|
243
|
+
observed.add(el);
|
|
244
|
+
// Update animations meta for non-scroll-driven animated elements
|
|
245
|
+
updateAnimNamesAndWillChange(el, prefix);
|
|
246
|
+
io.observe(el);
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
// Initial observe
|
|
251
|
+
observeJsCandidates();
|
|
252
|
+
|
|
253
|
+
// Now handle scroll-driven branch per support state
|
|
254
|
+
let cleanupScrollDriven: void | (() => void);
|
|
255
|
+
|
|
256
|
+
if (cssSupported) {
|
|
257
|
+
let rafId: number | null = null;
|
|
258
|
+
const schedule = () => {
|
|
259
|
+
if (rafId != null) return;
|
|
260
|
+
rafId = requestAnimationFrame(() => {
|
|
261
|
+
rafId = null;
|
|
262
|
+
const els = queryScrollDrivenCandidates(undefined, prefix);
|
|
263
|
+
for (const el of els) hydrateElement(el, prefix);
|
|
264
|
+
});
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// Initial hydration
|
|
268
|
+
schedule();
|
|
269
|
+
|
|
270
|
+
// Observe DOM changes to re-hydrate and attach IO to new js candidates
|
|
271
|
+
const mo = new MutationObserver((muts) => {
|
|
272
|
+
for (const m of muts) {
|
|
273
|
+
if (m.type === `attributes`) {
|
|
274
|
+
const t = m.target;
|
|
275
|
+
if (t instanceof HTMLElement) {
|
|
276
|
+
hydrateElement(t as HTMLElement, prefix);
|
|
277
|
+
// If an element lost/gained scroll-driven marker, ensure it`s observed appropriately
|
|
278
|
+
if (isJsObserverCandidate(t)) {
|
|
279
|
+
if (!observed.has(t)) {
|
|
280
|
+
observed.add(t);
|
|
281
|
+
io.observe(t);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// Always update animations meta on attribute changes that may affect styles
|
|
285
|
+
updateAnimNamesAndWillChange(t as HTMLElement, prefix);
|
|
286
|
+
}
|
|
287
|
+
} else if (m.type === `childList`) {
|
|
288
|
+
// new nodes
|
|
289
|
+
if (m.addedNodes && m.addedNodes.length) {
|
|
290
|
+
for (const node of Array.from(m.addedNodes)) {
|
|
291
|
+
if (node instanceof HTMLElement) {
|
|
292
|
+
// hydrate scroll-driven in subtree
|
|
293
|
+
const sds = queryScrollDrivenCandidates(node, prefix);
|
|
294
|
+
for (const el of sds) hydrateElement(el, prefix);
|
|
295
|
+
// observe js candidates in subtree
|
|
296
|
+
observeJsCandidates(node);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
mo.observe(document.documentElement, {
|
|
304
|
+
subtree: true,
|
|
305
|
+
childList: true,
|
|
306
|
+
attributes: true,
|
|
307
|
+
attributeFilter: [
|
|
308
|
+
`class`,
|
|
309
|
+
`data-${prefix}-scroll`,
|
|
310
|
+
`data-${prefix}-start`,
|
|
311
|
+
`data-${prefix}-end`,
|
|
312
|
+
`data-${prefix}-paused`,
|
|
313
|
+
`data-${prefix}-run`,
|
|
314
|
+
],
|
|
315
|
+
});
|
|
316
|
+
moRef.current = mo;
|
|
317
|
+
|
|
318
|
+
cleanupScrollDriven = () => {
|
|
319
|
+
if (rafId != null) cancelAnimationFrame(rafId);
|
|
320
|
+
mo.disconnect();
|
|
321
|
+
moRef.current = null;
|
|
322
|
+
};
|
|
323
|
+
} else {
|
|
324
|
+
// No CSS support: dynamically import the fallback ONLY if there are scroll-driven candidates
|
|
325
|
+
let stop: void | (() => void);
|
|
326
|
+
const existing = queryScrollDrivenCandidates(undefined, prefix);
|
|
327
|
+
// Update animations meta for scroll-driven candidates even without native support
|
|
328
|
+
for (const el of existing) updateAnimNamesAndWillChange(el, prefix);
|
|
329
|
+
if (existing.length > 0) {
|
|
330
|
+
import(`./scrollDriven`).then((m) => {
|
|
331
|
+
stop = m.initScrollViewFallback({ once });
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
cleanupScrollDriven = () => {
|
|
335
|
+
if (typeof stop === `function`) (stop as () => void)();
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return () => {
|
|
340
|
+
// cleanup both branches
|
|
341
|
+
if (cleanupScrollDriven) cleanupScrollDriven();
|
|
342
|
+
// IO cleanup
|
|
343
|
+
if (ioRef.current) {
|
|
344
|
+
ioRef.current.disconnect();
|
|
345
|
+
ioRef.current = null;
|
|
346
|
+
}
|
|
347
|
+
observedSetRef.current = null;
|
|
348
|
+
};
|
|
349
|
+
}, [cssSupported, reduced, once]);
|
|
350
|
+
|
|
351
|
+
// Always return only children; no extra DOM
|
|
352
|
+
return <>{children}</>;
|
|
353
|
+
};
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type API,
|
|
3
|
+
type ConfigInterface,
|
|
4
|
+
ContextOptions,
|
|
5
|
+
loader,
|
|
6
|
+
} from '@udixio/theme';
|
|
2
7
|
import { useEffect, useRef, useState } from 'react';
|
|
3
8
|
import { TailwindPlugin } from '@udixio/tailwind';
|
|
4
9
|
|
|
@@ -11,78 +16,98 @@ export const ThemeProvider = ({
|
|
|
11
16
|
config,
|
|
12
17
|
throttleDelay = 100, // Délai par défaut de 300ms
|
|
13
18
|
onLoad,
|
|
19
|
+
loadTheme = false,
|
|
14
20
|
}: {
|
|
15
|
-
config: ConfigInterface
|
|
21
|
+
config: Readonly<ConfigInterface>;
|
|
16
22
|
onLoad?: (api: API) => void;
|
|
17
23
|
throttleDelay?: number;
|
|
24
|
+
loadTheme?: boolean;
|
|
18
25
|
}) => {
|
|
19
|
-
const [
|
|
26
|
+
const [themeApi, setThemeApi] = useState<API | null>(null);
|
|
20
27
|
|
|
21
|
-
//
|
|
28
|
+
// Charger l'API du thème une fois au montage
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
(async () => {
|
|
31
|
+
const api = await loader(config, loadTheme);
|
|
32
|
+
setThemeApi(api);
|
|
33
|
+
})();
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
const [outputCss, setOutputCss] = useState<string | null>(null);
|
|
37
|
+
|
|
38
|
+
// Throttle avec exécution en tête (leading) et en fin (trailing)
|
|
22
39
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
23
|
-
const
|
|
24
|
-
const
|
|
40
|
+
const lastExecTimeRef = useRef<number>(0);
|
|
41
|
+
const lastArgsRef = useRef<Partial<ContextOptions> | null>(null);
|
|
25
42
|
|
|
26
43
|
useEffect(() => {
|
|
27
|
-
|
|
28
|
-
if (isInitialLoadRef.current) {
|
|
29
|
-
isInitialLoadRef.current = false;
|
|
30
|
-
lastSourceColorRef.current = config.sourceColor;
|
|
31
|
-
applyThemeChange(config.sourceColor);
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
44
|
+
if (!themeApi) return; // Attendre que l'API soit prête
|
|
34
45
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
46
|
+
const ctx: Partial<ContextOptions> = {
|
|
47
|
+
...config,
|
|
48
|
+
// Assurer la compatibilité avec l'API qui attend sourceColorHex
|
|
49
|
+
sourceColor: config.sourceColor,
|
|
50
|
+
};
|
|
39
51
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
clearTimeout(timeoutRef.current);
|
|
43
|
-
}
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
const timeSinceLast = now - lastExecTimeRef.current;
|
|
44
54
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
timeoutRef.current = null;
|
|
50
|
-
}, throttleDelay);
|
|
55
|
+
const invoke = async (args: Partial<ContextOptions>) => {
|
|
56
|
+
// applique et notifie
|
|
57
|
+
await applyThemeChange(args);
|
|
58
|
+
};
|
|
51
59
|
|
|
52
|
-
//
|
|
53
|
-
|
|
60
|
+
// Leading: si délai écoulé ou jamais exécuté, exécuter tout de suite
|
|
61
|
+
if (lastExecTimeRef.current === 0 || timeSinceLast >= throttleDelay) {
|
|
54
62
|
if (timeoutRef.current) {
|
|
55
63
|
clearTimeout(timeoutRef.current);
|
|
56
64
|
timeoutRef.current = null;
|
|
57
65
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
66
|
+
lastArgsRef.current = null;
|
|
67
|
+
lastExecTimeRef.current = now;
|
|
68
|
+
void invoke(ctx);
|
|
69
|
+
} else {
|
|
70
|
+
// Sinon, mémoriser la dernière requête et programmer une exécution en trailing
|
|
71
|
+
lastArgsRef.current = ctx;
|
|
72
|
+
if (!timeoutRef.current) {
|
|
73
|
+
const remaining = Math.max(0, throttleDelay - timeSinceLast);
|
|
74
|
+
timeoutRef.current = setTimeout(async () => {
|
|
75
|
+
timeoutRef.current = null;
|
|
76
|
+
const args = lastArgsRef.current;
|
|
77
|
+
lastArgsRef.current = null;
|
|
78
|
+
if (args) {
|
|
79
|
+
lastExecTimeRef.current = Date.now();
|
|
80
|
+
await invoke(args);
|
|
81
|
+
}
|
|
82
|
+
}, remaining);
|
|
83
|
+
}
|
|
64
84
|
}
|
|
65
85
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
...config,
|
|
70
|
-
sourceColor,
|
|
71
|
-
});
|
|
72
|
-
onLoad?.(api);
|
|
86
|
+
// Cleanup: au changement de dépendances, ne rien faire ici (on gère trailing)
|
|
87
|
+
return () => {};
|
|
88
|
+
}, [config, throttleDelay, themeApi]);
|
|
73
89
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (generatedCss) {
|
|
79
|
-
setOutputCss(generatedCss);
|
|
90
|
+
const applyThemeChange = async (ctx: Partial<ContextOptions>) => {
|
|
91
|
+
if (typeof ctx.sourceColor == 'string') {
|
|
92
|
+
if (!isValidHexColor(ctx.sourceColor)) {
|
|
93
|
+
throw new Error('Invalid hex color');
|
|
80
94
|
}
|
|
81
|
-
} catch (err) {
|
|
82
|
-
throw new Error(
|
|
83
|
-
err instanceof Error ? err.message : 'Theme loading failed',
|
|
84
|
-
);
|
|
85
95
|
}
|
|
96
|
+
|
|
97
|
+
if (!themeApi) {
|
|
98
|
+
// L'API n'est pas prête; ignorer silencieusement car l'effet principal attend themeApi
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
themeApi.context.update(ctx);
|
|
102
|
+
|
|
103
|
+
await themeApi.load();
|
|
104
|
+
|
|
105
|
+
const outputCss = themeApi?.plugins
|
|
106
|
+
.getPlugin(TailwindPlugin)
|
|
107
|
+
.getInstance().outputCss;
|
|
108
|
+
setOutputCss(outputCss);
|
|
109
|
+
|
|
110
|
+
onLoad?.(themeApi);
|
|
86
111
|
};
|
|
87
112
|
|
|
88
113
|
// Cleanup lors du démontage du composant
|
|
@@ -90,6 +115,7 @@ export const ThemeProvider = ({
|
|
|
90
115
|
return () => {
|
|
91
116
|
if (timeoutRef.current) {
|
|
92
117
|
clearTimeout(timeoutRef.current);
|
|
118
|
+
timeoutRef.current = null;
|
|
93
119
|
}
|
|
94
120
|
};
|
|
95
121
|
}, []);
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
type ScrollIntent =
|
|
4
|
+
| {
|
|
5
|
+
type: 'intent';
|
|
6
|
+
source: 'wheel' | 'touch' | 'keyboard';
|
|
7
|
+
deltaX: number;
|
|
8
|
+
deltaY: number;
|
|
9
|
+
originalEvent: Event;
|
|
10
|
+
}
|
|
11
|
+
| {
|
|
12
|
+
type: 'scrollbar';
|
|
13
|
+
scrollTop: number;
|
|
14
|
+
scrollLeft: number;
|
|
15
|
+
maxScrollTop: number;
|
|
16
|
+
maxScrollLeft: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type BlockScrollProps = {
|
|
20
|
+
onScroll?: (evt: ScrollIntent) => void; // log des intentions + du scroll via scrollbar
|
|
21
|
+
touch?: boolean;
|
|
22
|
+
el: HTMLElement;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const BlockScroll: React.FC<BlockScrollProps> = ({
|
|
26
|
+
onScroll,
|
|
27
|
+
el,
|
|
28
|
+
touch = true,
|
|
29
|
+
}) => {
|
|
30
|
+
const lastTouch = useRef<{ x: number; y: number } | null>(null);
|
|
31
|
+
const lastScrollTop = useRef<number>(0);
|
|
32
|
+
const lastScrollLeft = useRef<number>(0);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!el) return;
|
|
36
|
+
|
|
37
|
+
// Initialize last known scroll positions to block scrollbar-based scrolling
|
|
38
|
+
lastScrollTop.current = el.scrollTop;
|
|
39
|
+
lastScrollLeft.current = el.scrollLeft;
|
|
40
|
+
|
|
41
|
+
const emitIntent = (payload: Extract<ScrollIntent, { type: 'intent' }>) => {
|
|
42
|
+
// Log the desired deltaY for every scroll attempt (wheel/touch/keyboard)
|
|
43
|
+
onScroll?.(payload);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const onWheel = (e: WheelEvent) => {
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
emitIntent({
|
|
49
|
+
type: 'intent',
|
|
50
|
+
source: 'wheel',
|
|
51
|
+
deltaX: e.deltaX,
|
|
52
|
+
deltaY: e.deltaY,
|
|
53
|
+
originalEvent: e,
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const onTouchStart = (e: TouchEvent) => {
|
|
58
|
+
if (!touch) return;
|
|
59
|
+
const t = e.touches[0];
|
|
60
|
+
if (t) lastTouch.current = { x: t.clientX, y: t.clientY };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const onTouchMove = (e: TouchEvent) => {
|
|
64
|
+
if (!touch) return;
|
|
65
|
+
const t = e.touches[0];
|
|
66
|
+
if (!t || !lastTouch.current) return;
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
const dx = lastTouch.current.x - t.clientX;
|
|
69
|
+
const dy = lastTouch.current.y - t.clientY;
|
|
70
|
+
lastTouch.current = { x: t.clientX, y: t.clientY };
|
|
71
|
+
emitIntent({
|
|
72
|
+
type: 'intent',
|
|
73
|
+
source: 'touch',
|
|
74
|
+
deltaX: dx,
|
|
75
|
+
deltaY: dy,
|
|
76
|
+
originalEvent: e,
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const onTouchEnd = () => {
|
|
81
|
+
if (!touch) return;
|
|
82
|
+
lastTouch.current = null;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
86
|
+
const line = 40;
|
|
87
|
+
const page = el.clientHeight * 0.9;
|
|
88
|
+
let dx = 0,
|
|
89
|
+
dy = 0;
|
|
90
|
+
|
|
91
|
+
switch (e.key) {
|
|
92
|
+
case 'ArrowDown':
|
|
93
|
+
dy = line;
|
|
94
|
+
break;
|
|
95
|
+
case 'ArrowUp':
|
|
96
|
+
dy = -line;
|
|
97
|
+
break;
|
|
98
|
+
case 'ArrowRight':
|
|
99
|
+
dx = line;
|
|
100
|
+
break;
|
|
101
|
+
case 'ArrowLeft':
|
|
102
|
+
dx = -line;
|
|
103
|
+
break;
|
|
104
|
+
case 'PageDown':
|
|
105
|
+
dy = page;
|
|
106
|
+
break;
|
|
107
|
+
case 'PageUp':
|
|
108
|
+
dy = -page;
|
|
109
|
+
break;
|
|
110
|
+
case 'Home':
|
|
111
|
+
dy = Number.NEGATIVE_INFINITY;
|
|
112
|
+
break;
|
|
113
|
+
case 'End':
|
|
114
|
+
dy = Number.POSITIVE_INFINITY;
|
|
115
|
+
break;
|
|
116
|
+
case ' ':
|
|
117
|
+
dy = e.shiftKey ? -page : page;
|
|
118
|
+
break;
|
|
119
|
+
default:
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
e.preventDefault();
|
|
123
|
+
emitIntent({
|
|
124
|
+
type: 'intent',
|
|
125
|
+
source: 'keyboard',
|
|
126
|
+
deltaX: dx,
|
|
127
|
+
deltaY: dy,
|
|
128
|
+
originalEvent: e,
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// const onScrollEvent = (e) => {
|
|
133
|
+
// const currentScrollTop = e.target.scrollTop;
|
|
134
|
+
// const currentScrollLeft = e.target.scrollLeft;
|
|
135
|
+
//
|
|
136
|
+
// // Check if scroll position changed from last known position
|
|
137
|
+
// if (
|
|
138
|
+
// currentScrollTop !== lastScrollTop.current ||
|
|
139
|
+
// currentScrollLeft !== lastScrollLeft.current
|
|
140
|
+
// ) {
|
|
141
|
+
// console.log('onScrollllllllllll', e, document);
|
|
142
|
+
// onScroll?.({
|
|
143
|
+
// type: 'scrollbar',
|
|
144
|
+
// scrollTop: currentScrollTop,
|
|
145
|
+
// scrollLeft: currentScrollLeft,
|
|
146
|
+
// maxScrollTop: e.target.scrollHeight - e.target.clientHeight,
|
|
147
|
+
// maxScrollLeft: e.target.scrollWidth - e.target.clientWidth,
|
|
148
|
+
// });
|
|
149
|
+
// }
|
|
150
|
+
//
|
|
151
|
+
// // Update last known scroll positions
|
|
152
|
+
// lastScrollTop.current = currentScrollTop;
|
|
153
|
+
// lastScrollLeft.current = currentScrollLeft;
|
|
154
|
+
//
|
|
155
|
+
// document.querySelector('html')?.scrollTo({ top: 0 });
|
|
156
|
+
// };
|
|
157
|
+
|
|
158
|
+
el.addEventListener('wheel', onWheel, { passive: false });
|
|
159
|
+
el.addEventListener('touchstart', onTouchStart, { passive: true });
|
|
160
|
+
el.addEventListener('touchmove', onTouchMove, { passive: false });
|
|
161
|
+
el.addEventListener('touchend', onTouchEnd, { passive: true });
|
|
162
|
+
el.addEventListener('keydown', onKeyDown);
|
|
163
|
+
// el.addEventListener('scroll', onScrollEvent, { passive: true });
|
|
164
|
+
|
|
165
|
+
return () => {
|
|
166
|
+
el.removeEventListener('wheel', onWheel as EventListener);
|
|
167
|
+
el.removeEventListener('touchstart', onTouchStart as EventListener);
|
|
168
|
+
el.removeEventListener('touchmove', onTouchMove as EventListener);
|
|
169
|
+
el.removeEventListener('touchend', onTouchEnd as EventListener);
|
|
170
|
+
el.removeEventListener('keydown', onKeyDown as EventListener);
|
|
171
|
+
// el.removeEventListener('scroll', onScrollEvent as EventListener);
|
|
172
|
+
};
|
|
173
|
+
}, [onScroll]);
|
|
174
|
+
};
|