@5even7/dlc-ui 0.2.18 → 0.2.19
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 +147 -144
- package/LICENSE +21 -21
- package/README.md +338 -338
- package/THIRD_PARTY_NOTICES.md +17 -0
- package/dist/avatar-creator.cjs +7221 -0
- package/dist/avatar-creator.mjs +9278 -0
- package/dist/capsule.cjs +185 -66
- package/dist/capsule.mjs +1208 -1103
- package/dist/color.cjs +186 -67
- package/dist/color.mjs +1157 -1052
- package/dist/emo.cjs +166 -47
- package/dist/emo.mjs +324 -219
- package/dist/index.cjs +6806 -98
- package/dist/index.mjs +13240 -4296
- package/dist/index.umd.js +6806 -98
- package/dist/index.umd.min.js +1 -1
- package/dist/progress.cjs +195 -76
- package/dist/progress.mjs +2293 -2188
- package/dist/vue2.cjs +7001 -247
- package/dist/vue2.mjs +13664 -4691
- package/dist/vue3.cjs +7001 -247
- package/dist/vue3.mjs +13664 -4691
- package/package.json +146 -136
- package/styles/avatar-creator.css +13 -0
- package/styles/base.css +32 -32
- package/styles/color.css +18 -18
- package/styles/emo.css +89 -89
- package/styles/progress.css +96 -96
- package/styles/theme.css +1 -0
- package/types/avatar-creator.d.ts +124 -0
- package/types/capsule.d.ts +15 -15
- package/types/color.d.ts +15 -15
- package/types/emo.d.ts +37 -37
- package/types/index.d.ts +581 -554
- package/types/progress.d.ts +16 -16
- package/types/vue2.d.ts +11 -10
- package/types/vue3.d.ts +169 -146
package/dist/emo.mjs
CHANGED
|
@@ -1,82 +1,82 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tiny event emitter. Accepts any event name so the API stays open for
|
|
3
|
-
* future events (hover, click, custom) without breaking changes.
|
|
4
|
-
*/
|
|
5
|
-
function createEmitter() {
|
|
6
|
-
// Plain object storage (no Map/Set) so the legacy build has no API
|
|
7
|
-
// dependencies beyond what IE11 provides.
|
|
8
|
-
const listeners = Object.create(null);
|
|
9
|
-
|
|
10
|
-
return {
|
|
11
|
-
on(event, fn) {
|
|
12
|
-
if (!listeners[event]) listeners[event] = [];
|
|
13
|
-
listeners[event].push(fn);
|
|
14
|
-
return () => {
|
|
15
|
-
const current = listeners[event];
|
|
16
|
-
if (current) {
|
|
17
|
-
const index = current.indexOf(fn);
|
|
18
|
-
if (index !== -1) current.splice(index, 1);
|
|
19
|
-
}
|
|
20
|
-
};
|
|
21
|
-
},
|
|
22
|
-
off(event, fn) {
|
|
23
|
-
const current = listeners[event];
|
|
24
|
-
if (!current) return false;
|
|
25
|
-
const index = current.indexOf(fn);
|
|
26
|
-
if (index === -1) return false;
|
|
27
|
-
current.splice(index, 1);
|
|
28
|
-
return true;
|
|
29
|
-
},
|
|
30
|
-
emit(event, ...args) {
|
|
31
|
-
const current = listeners[event];
|
|
32
|
-
if (!current) return;
|
|
33
|
-
for (const fn of current.slice()) fn(...args);
|
|
34
|
-
}
|
|
35
|
-
};
|
|
1
|
+
/**
|
|
2
|
+
* Tiny event emitter. Accepts any event name so the API stays open for
|
|
3
|
+
* future events (hover, click, custom) without breaking changes.
|
|
4
|
+
*/
|
|
5
|
+
function createEmitter() {
|
|
6
|
+
// Plain object storage (no Map/Set) so the legacy build has no API
|
|
7
|
+
// dependencies beyond what IE11 provides.
|
|
8
|
+
const listeners = Object.create(null);
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
on(event, fn) {
|
|
12
|
+
if (!listeners[event]) listeners[event] = [];
|
|
13
|
+
listeners[event].push(fn);
|
|
14
|
+
return () => {
|
|
15
|
+
const current = listeners[event];
|
|
16
|
+
if (current) {
|
|
17
|
+
const index = current.indexOf(fn);
|
|
18
|
+
if (index !== -1) current.splice(index, 1);
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
},
|
|
22
|
+
off(event, fn) {
|
|
23
|
+
const current = listeners[event];
|
|
24
|
+
if (!current) return false;
|
|
25
|
+
const index = current.indexOf(fn);
|
|
26
|
+
if (index === -1) return false;
|
|
27
|
+
current.splice(index, 1);
|
|
28
|
+
return true;
|
|
29
|
+
},
|
|
30
|
+
emit(event, ...args) {
|
|
31
|
+
const current = listeners[event];
|
|
32
|
+
if (!current) return;
|
|
33
|
+
for (const fn of current.slice()) fn(...args);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
const UNIT_PATTERN = /^[\d.]+(?:px|%|vw|vh|vmin|vmax|em|rem)$/;
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Normalize a size option to a CSS length string.
|
|
42
|
-
* Accepts numbers (treated as px) or strings with px/%, vw/vh/vmin/vmax, em/rem.
|
|
43
|
-
*/
|
|
44
|
-
function parseSize(value) {
|
|
45
|
-
if (typeof value === 'number') {
|
|
46
|
-
if (!Number.isFinite(value) || value < 0) throw new Error(`Invalid size: ${value}`);
|
|
47
|
-
return `${value}px`;
|
|
48
|
-
}
|
|
49
|
-
if (typeof value !== 'string') throw new Error(`Invalid size: ${String(value)}`);
|
|
50
|
-
const trimmed = value.trim();
|
|
51
|
-
if (!trimmed) throw new Error('Invalid size: empty string');
|
|
52
|
-
if (/^\d+(?:\.\d+)?$/.test(trimmed)) return `${trimmed}px`;
|
|
53
|
-
if (!UNIT_PATTERN.test(trimmed)) {
|
|
54
|
-
throw new Error(`Invalid size or unsupported unit: "${value}" (use px, %, vw, vh, vmin, vmax, em, rem)`);
|
|
55
|
-
}
|
|
56
|
-
return trimmed;
|
|
38
|
+
const UNIT_PATTERN = /^[\d.]+(?:px|%|vw|vh|vmin|vmax|em|rem)$/;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Normalize a size option to a CSS length string.
|
|
42
|
+
* Accepts numbers (treated as px) or strings with px/%, vw/vh/vmin/vmax, em/rem.
|
|
43
|
+
*/
|
|
44
|
+
function parseSize(value) {
|
|
45
|
+
if (typeof value === 'number') {
|
|
46
|
+
if (!Number.isFinite(value) || value < 0) throw new Error(`Invalid size: ${value}`);
|
|
47
|
+
return `${value}px`;
|
|
48
|
+
}
|
|
49
|
+
if (typeof value !== 'string') throw new Error(`Invalid size: ${String(value)}`);
|
|
50
|
+
const trimmed = value.trim();
|
|
51
|
+
if (!trimmed) throw new Error('Invalid size: empty string');
|
|
52
|
+
if (/^\d+(?:\.\d+)?$/.test(trimmed)) return `${trimmed}px`;
|
|
53
|
+
if (!UNIT_PATTERN.test(trimmed)) {
|
|
54
|
+
throw new Error(`Invalid size or unsupported unit: "${value}" (use px, %, vw, vh, vmin, vmax, em, rem)`);
|
|
55
|
+
}
|
|
56
|
+
return trimmed;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
function createReducedMotionPreference(enabled, onChange) {
|
|
60
|
-
const query = enabled && typeof matchMedia !== 'undefined'
|
|
61
|
-
? matchMedia('(prefers-reduced-motion: reduce)')
|
|
62
|
-
: null;
|
|
63
|
-
const notify = () => {
|
|
64
|
-
if (typeof onChange === 'function') onChange(Boolean(query && query.matches));
|
|
65
|
-
};
|
|
66
|
-
if (query) {
|
|
67
|
-
if (typeof query.addEventListener === 'function') query.addEventListener('change', notify);
|
|
68
|
-
else if (typeof query.addListener === 'function') query.addListener(notify);
|
|
69
|
-
}
|
|
70
|
-
return {
|
|
71
|
-
matches() {
|
|
72
|
-
return Boolean(query && query.matches);
|
|
73
|
-
},
|
|
74
|
-
dispose() {
|
|
75
|
-
if (!query) return;
|
|
76
|
-
if (typeof query.removeEventListener === 'function') query.removeEventListener('change', notify);
|
|
77
|
-
else if (typeof query.removeListener === 'function') query.removeListener(notify);
|
|
78
|
-
}
|
|
79
|
-
};
|
|
59
|
+
function createReducedMotionPreference(enabled, onChange) {
|
|
60
|
+
const query = enabled && typeof matchMedia !== 'undefined'
|
|
61
|
+
? matchMedia('(prefers-reduced-motion: reduce)')
|
|
62
|
+
: null;
|
|
63
|
+
const notify = () => {
|
|
64
|
+
if (typeof onChange === 'function') onChange(Boolean(query && query.matches));
|
|
65
|
+
};
|
|
66
|
+
if (query) {
|
|
67
|
+
if (typeof query.addEventListener === 'function') query.addEventListener('change', notify);
|
|
68
|
+
else if (typeof query.addListener === 'function') query.addListener(notify);
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
matches() {
|
|
72
|
+
return Boolean(query && query.matches);
|
|
73
|
+
},
|
|
74
|
+
dispose() {
|
|
75
|
+
if (!query) return;
|
|
76
|
+
if (typeof query.removeEventListener === 'function') query.removeEventListener('change', notify);
|
|
77
|
+
else if (typeof query.removeListener === 'function') query.removeListener(notify);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
// Generated by scripts/generate-emo-assets.mjs. Do not edit by hand.
|
|
@@ -343,10 +343,16 @@ function getEmoAnimation(type = 'miao', name = 'smile') {
|
|
|
343
343
|
|
|
344
344
|
/**
|
|
345
345
|
* Color helpers. `normalizeColor` converts any supported CSS color
|
|
346
|
-
* (hex / named / rgb() / rgba()) into a 6-digit hex string,
|
|
347
|
-
* keep working with #rrggbb only. rgba() alpha is
|
|
348
|
-
* the WebGL shader has no per-color alpha channel, and
|
|
349
|
-
* opaque by design — use component-level `opacity` for
|
|
346
|
+
* (hex / named / rgb() / rgba() / hsl() / hsla()) into a 6-digit hex string,
|
|
347
|
+
* so renderers can keep working with #rrggbb only. rgba()/hsla() alpha is
|
|
348
|
+
* intentionally dropped: the WebGL shader has no per-color alpha channel, and
|
|
349
|
+
* the component is opaque by design — use component-level `opacity` for
|
|
350
|
+
* transparency.
|
|
351
|
+
*
|
|
352
|
+
* `normalizeColorWithAlpha` keeps the alpha channel: it returns a 6-digit hex
|
|
353
|
+
* when alpha is 1, an 8-digit #rrggbbaa hex when alpha < 1, and the literal
|
|
354
|
+
* string 'transparent' for `transparent`. Use it for renderers (e.g. SVG)
|
|
355
|
+
* that can represent per-color transparency.
|
|
350
356
|
*/
|
|
351
357
|
|
|
352
358
|
const NAMED_COLORS = {
|
|
@@ -504,46 +510,145 @@ function clampChannel(value) {
|
|
|
504
510
|
return Math.min(255, Math.max(0, Math.round(value)));
|
|
505
511
|
}
|
|
506
512
|
|
|
507
|
-
function
|
|
508
|
-
|
|
513
|
+
function clampUnit(value) {
|
|
514
|
+
return Math.min(1, Math.max(0, value));
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function hexPair(value) {
|
|
518
|
+
const text = clampChannel(value).toString(16);
|
|
519
|
+
return text.length === 1 ? `0${text}` : text;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Parse the argument list of rgb()/rgba()/hsl()/hsla() (comma syntax or
|
|
524
|
+
* modern space + slash syntax). Returns { parts, alpha } where alpha is 1 when
|
|
525
|
+
* omitted. Returns null when the argument list cannot be parsed.
|
|
526
|
+
*/
|
|
527
|
+
function parseFunctionArgs(args) {
|
|
528
|
+
const parts = args
|
|
529
|
+
.split(/[,\s]+/)
|
|
530
|
+
.map((part) => part.trim())
|
|
531
|
+
.filter((part) => part !== '' && part !== '/');
|
|
509
532
|
if (parts.length < 3) return null;
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
};
|
|
524
|
-
|
|
533
|
+
let alpha = 1;
|
|
534
|
+
if (parts.length >= 4) {
|
|
535
|
+
const alphaText = parts.pop().trim();
|
|
536
|
+
if (alphaText.endsWith('%')) {
|
|
537
|
+
const percent = parseFloat(alphaText);
|
|
538
|
+
if (!Number.isFinite(percent)) return null;
|
|
539
|
+
alpha = clampUnit(percent / 100);
|
|
540
|
+
} else {
|
|
541
|
+
const number = Number(alphaText);
|
|
542
|
+
if (!Number.isFinite(number)) return null;
|
|
543
|
+
alpha = clampUnit(number);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return { parts, alpha };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function parseChannel(value) {
|
|
550
|
+
if (typeof value !== 'string' || value === '') return null;
|
|
551
|
+
const text = value.trim();
|
|
552
|
+
if (text.endsWith('%')) return clampChannel((parseFloat(text) / 100) * 255);
|
|
553
|
+
const number = Number(text);
|
|
554
|
+
return Number.isFinite(number) ? clampChannel(number) : null;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function parsePercent(value) {
|
|
558
|
+
if (typeof value !== 'string' || value === '') return null;
|
|
559
|
+
const text = value.trim();
|
|
560
|
+
if (!text.endsWith('%')) return null;
|
|
561
|
+
const number = parseFloat(text);
|
|
562
|
+
return Number.isFinite(number) ? Math.min(100, Math.max(0, number)) : null;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function parseHue(value) {
|
|
566
|
+
if (typeof value !== 'string' || value === '') return null;
|
|
567
|
+
const number = parseFloat(value);
|
|
568
|
+
return Number.isFinite(number) ? number : null;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Standard HSL -> RGB. h in degrees, s/l in 0..100, returns r/g/b in 0..255. */
|
|
572
|
+
function hslToRgb(h, s, l) {
|
|
573
|
+
const hue = ((h % 360) + 360) % 360 / 360;
|
|
574
|
+
const saturation = s / 100;
|
|
575
|
+
const lightness = l / 100;
|
|
576
|
+
const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation;
|
|
577
|
+
const section = hue * 6;
|
|
578
|
+
const x = chroma * (1 - Math.abs((section % 2) - 1));
|
|
579
|
+
let red = 0;
|
|
580
|
+
let green = 0;
|
|
581
|
+
let blue = 0;
|
|
582
|
+
if (section < 1) { red = chroma; green = x; }
|
|
583
|
+
else if (section < 2) { red = x; green = chroma; }
|
|
584
|
+
else if (section < 3) { green = chroma; blue = x; }
|
|
585
|
+
else if (section < 4) { green = x; blue = chroma; }
|
|
586
|
+
else if (section < 5) { red = x; blue = chroma; }
|
|
587
|
+
else { red = chroma; blue = x; }
|
|
588
|
+
const match = lightness - chroma / 2;
|
|
589
|
+
return [
|
|
590
|
+
Math.round((red + match) * 255),
|
|
591
|
+
Math.round((green + match) * 255),
|
|
592
|
+
Math.round((blue + match) * 255)
|
|
593
|
+
];
|
|
525
594
|
}
|
|
526
595
|
|
|
527
596
|
/**
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
* including percentages. Alpha in rgba() is ignored.
|
|
597
|
+
* Parse any supported CSS color. When keepAlpha is true the alpha channel is
|
|
598
|
+
* preserved (8-digit hex for alpha < 1); when false it is dropped. 'transparent'
|
|
599
|
+
* is only preserved when keepAlpha is true.
|
|
532
600
|
*/
|
|
533
|
-
function
|
|
601
|
+
function parseColor(value, keepAlpha) {
|
|
534
602
|
if (typeof value !== 'string') return null;
|
|
535
603
|
const input = value.trim();
|
|
536
604
|
if (!input) return null;
|
|
537
605
|
const lower = input.toLowerCase();
|
|
538
606
|
if (NAMED_COLORS[lower]) return NAMED_COLORS[lower];
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
607
|
+
// #rgb / #rgba / #rrggbb / #rrggbbaa,以及无 # 前缀的 6/8 位 hex(dicebear 习惯)
|
|
608
|
+
const hexMatch = input.match(/^#?([0-9a-f]{3,8})$/i);
|
|
609
|
+
if (hexMatch) {
|
|
610
|
+
let hex = hexMatch[1].toLowerCase();
|
|
611
|
+
if (hex.length === 3 || hex.length === 4) {
|
|
612
|
+
hex = hex.split('').map((c) => c + c).join('');
|
|
613
|
+
}
|
|
614
|
+
return `#${hex.slice(0, 6)}`;
|
|
543
615
|
}
|
|
544
|
-
const
|
|
545
|
-
if (
|
|
546
|
-
|
|
616
|
+
const funcMatch = input.match(/^(rgba?|hsla?)\(([^)]*)\)$/i);
|
|
617
|
+
if (!funcMatch) return null;
|
|
618
|
+
const kind = funcMatch[1].toLowerCase();
|
|
619
|
+
const parsed = parseFunctionArgs(funcMatch[2]);
|
|
620
|
+
if (!parsed) return null;
|
|
621
|
+
const { parts, alpha } = parsed;
|
|
622
|
+
let red;
|
|
623
|
+
let green;
|
|
624
|
+
let blue;
|
|
625
|
+
if (kind === 'rgb' || kind === 'rgba') {
|
|
626
|
+
if (parts.length !== 3) return null;
|
|
627
|
+
red = parseChannel(parts[0]);
|
|
628
|
+
green = parseChannel(parts[1]);
|
|
629
|
+
blue = parseChannel(parts[2]);
|
|
630
|
+
if (red === null || green === null || blue === null) return null;
|
|
631
|
+
} else {
|
|
632
|
+
if (parts.length !== 3) return null;
|
|
633
|
+
const hue = parseHue(parts[0]);
|
|
634
|
+
const saturation = parsePercent(parts[1]);
|
|
635
|
+
const lightness = parsePercent(parts[2]);
|
|
636
|
+
if (hue === null || saturation === null || lightness === null) return null;
|
|
637
|
+
[red, green, blue] = hslToRgb(hue, saturation, lightness);
|
|
638
|
+
}
|
|
639
|
+
const hex = `#${hexPair(red)}${hexPair(green)}${hexPair(blue)}`;
|
|
640
|
+
return hex;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Convert any supported CSS color into `#rrggbb`. Returns null when the
|
|
645
|
+
* value cannot be parsed. Supports: #rgb / #rgba / #rrggbb / #rrggbbaa
|
|
646
|
+
* (case-insensitive), bare 6/8-digit hex, CSS named colors, rgb() / rgba() /
|
|
647
|
+
* hsl() / hsla() with comma or modern space syntax, including percentages.
|
|
648
|
+
* Alpha is ignored.
|
|
649
|
+
*/
|
|
650
|
+
function normalizeColor(value) {
|
|
651
|
+
return parseColor(value);
|
|
547
652
|
}
|
|
548
653
|
|
|
549
654
|
function hexToRgb01(color) {
|
|
@@ -754,119 +859,119 @@ function createMiaoRenderer({ stage, s, emitter }) {
|
|
|
754
859
|
};
|
|
755
860
|
}
|
|
756
861
|
|
|
757
|
-
/**
|
|
758
|
-
* Document-level shared rAF scheduler. Every component instance subscribes
|
|
759
|
-
* its own frame callback; the whole page runs ONE animation loop (like the
|
|
760
|
-
* original demo), which avoids jank from many competing rAF loops.
|
|
761
|
-
*/
|
|
762
|
-
const subscribers = [];
|
|
763
|
-
let running = false;
|
|
764
|
-
let rafId = 0;
|
|
765
|
-
let last = 0;
|
|
766
|
-
|
|
767
|
-
function tick(now) {
|
|
768
|
-
if (!running) return;
|
|
769
|
-
const activeItems = [];
|
|
770
|
-
{
|
|
771
|
-
for (const item of subscribers.slice()) {
|
|
772
|
-
try {
|
|
773
|
-
if (!item.isPaused()) activeItems.push(item);
|
|
774
|
-
} catch {}
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
|
-
if (activeItems.length === 0) {
|
|
778
|
-
running = false;
|
|
779
|
-
rafId = 0;
|
|
780
|
-
last = 0;
|
|
781
|
-
return;
|
|
782
|
-
}
|
|
783
|
-
const delta = last ? Math.min((now - last) / 1000, 0.05) : 0;
|
|
784
|
-
last = now;
|
|
785
|
-
// Schedule the next frame BEFORE running callbacks so one throwing
|
|
786
|
-
// subscriber can never kill the whole animation loop.
|
|
787
|
-
rafId = requestAnimationFrame(tick);
|
|
788
|
-
for (const item of activeItems) {
|
|
789
|
-
try {
|
|
790
|
-
item.onFrame(delta, now);
|
|
791
|
-
} catch (error) {
|
|
792
|
-
console.warn('[dlc-ui] frame error:', error);
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
if (subscribers.length === 0) {
|
|
796
|
-
cancelAnimationFrame(rafId);
|
|
797
|
-
running = false;
|
|
798
|
-
rafId = 0;
|
|
799
|
-
}
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
function start() {
|
|
803
|
-
if (running) return;
|
|
804
|
-
running = true;
|
|
805
|
-
last = 0;
|
|
806
|
-
rafId = requestAnimationFrame(tick);
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
function wakeScheduler() {
|
|
810
|
-
start();
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
function subscribeScheduler(onFrame, isPaused) {
|
|
814
|
-
const item = { onFrame, isPaused };
|
|
815
|
-
subscribers.push(item);
|
|
816
|
-
start();
|
|
817
|
-
return () => {
|
|
818
|
-
const index = subscribers.indexOf(item);
|
|
819
|
-
if (index !== -1) subscribers.splice(index, 1);
|
|
820
|
-
if (subscribers.length === 0 && rafId) {
|
|
821
|
-
cancelAnimationFrame(rafId);
|
|
822
|
-
running = false;
|
|
823
|
-
rafId = 0;
|
|
824
|
-
}
|
|
825
|
-
};
|
|
862
|
+
/**
|
|
863
|
+
* Document-level shared rAF scheduler. Every component instance subscribes
|
|
864
|
+
* its own frame callback; the whole page runs ONE animation loop (like the
|
|
865
|
+
* original demo), which avoids jank from many competing rAF loops.
|
|
866
|
+
*/
|
|
867
|
+
const subscribers = [];
|
|
868
|
+
let running = false;
|
|
869
|
+
let rafId = 0;
|
|
870
|
+
let last = 0;
|
|
871
|
+
|
|
872
|
+
function tick(now) {
|
|
873
|
+
if (!running) return;
|
|
874
|
+
const activeItems = [];
|
|
875
|
+
{
|
|
876
|
+
for (const item of subscribers.slice()) {
|
|
877
|
+
try {
|
|
878
|
+
if (!item.isPaused()) activeItems.push(item);
|
|
879
|
+
} catch {}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
if (activeItems.length === 0) {
|
|
883
|
+
running = false;
|
|
884
|
+
rafId = 0;
|
|
885
|
+
last = 0;
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
const delta = last ? Math.min((now - last) / 1000, 0.05) : 0;
|
|
889
|
+
last = now;
|
|
890
|
+
// Schedule the next frame BEFORE running callbacks so one throwing
|
|
891
|
+
// subscriber can never kill the whole animation loop.
|
|
892
|
+
rafId = requestAnimationFrame(tick);
|
|
893
|
+
for (const item of activeItems) {
|
|
894
|
+
try {
|
|
895
|
+
item.onFrame(delta, now);
|
|
896
|
+
} catch (error) {
|
|
897
|
+
console.warn('[dlc-ui] frame error:', error);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
if (subscribers.length === 0) {
|
|
901
|
+
cancelAnimationFrame(rafId);
|
|
902
|
+
running = false;
|
|
903
|
+
rafId = 0;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function start() {
|
|
908
|
+
if (running) return;
|
|
909
|
+
running = true;
|
|
910
|
+
last = 0;
|
|
911
|
+
rafId = requestAnimationFrame(tick);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function wakeScheduler() {
|
|
915
|
+
start();
|
|
826
916
|
}
|
|
827
917
|
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
if (typeof
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
918
|
+
function subscribeScheduler(onFrame, isPaused) {
|
|
919
|
+
const item = { onFrame, isPaused };
|
|
920
|
+
subscribers.push(item);
|
|
921
|
+
start();
|
|
922
|
+
return () => {
|
|
923
|
+
const index = subscribers.indexOf(item);
|
|
924
|
+
if (index !== -1) subscribers.splice(index, 1);
|
|
925
|
+
if (subscribers.length === 0 && rafId) {
|
|
926
|
+
cancelAnimationFrame(rafId);
|
|
927
|
+
running = false;
|
|
928
|
+
rafId = 0;
|
|
929
|
+
}
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Gates drawing on "element intersects viewport AND the page tab is visible".
|
|
935
|
+
* Falls back to always-visible when IntersectionObserver is unavailable.
|
|
936
|
+
*/
|
|
937
|
+
function createVisibilityGuard(element, onChange = null) {
|
|
938
|
+
let intersecting = true;
|
|
939
|
+
let pageVisible = typeof document === 'undefined' || !document.hidden;
|
|
940
|
+
let disposed = false;
|
|
941
|
+
let observer = null;
|
|
942
|
+
|
|
943
|
+
if (typeof IntersectionObserver !== 'undefined') {
|
|
944
|
+
observer = new IntersectionObserver(
|
|
945
|
+
(entries) => {
|
|
946
|
+
intersecting = entries.some((entry) => entry.isIntersecting);
|
|
947
|
+
if (typeof onChange === 'function') onChange();
|
|
948
|
+
},
|
|
949
|
+
{ rootMargin: '180px' }
|
|
950
|
+
);
|
|
951
|
+
observer.observe(element);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const onVisibilityChange = () => {
|
|
955
|
+
pageVisible = typeof document !== 'undefined' && !document.hidden;
|
|
956
|
+
if (typeof onChange === 'function') onChange();
|
|
957
|
+
};
|
|
958
|
+
if (typeof document !== 'undefined') {
|
|
959
|
+
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
return {
|
|
963
|
+
isVisible() {
|
|
964
|
+
return intersecting && pageVisible;
|
|
965
|
+
},
|
|
966
|
+
dispose() {
|
|
967
|
+
if (disposed) return;
|
|
968
|
+
disposed = true;
|
|
969
|
+
if (observer) observer.disconnect();
|
|
970
|
+
if (typeof document !== 'undefined') {
|
|
971
|
+
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
};
|
|
870
975
|
}
|
|
871
976
|
|
|
872
977
|
/**
|