auto-loading-skeleton 1.0.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/package.json +1 -1
- package/src/AutoSkeleton.js +117 -0
- package/src/analyzer.js +336 -113
- package/src/cache.js +47 -0
- package/src/context.js +38 -0
- package/src/hooks.js +161 -0
- package/src/index.d.ts +167 -51
- package/src/index.js +52 -102
- package/src/primitives.js +293 -0
- package/src/renderer.js +259 -84
- package/src/styles.js +190 -76
- package/src/AutoSkeleton.jsx +0 -72
- package/src/SkeletonItem.jsx +0 -99
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "auto-loading-skeleton",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Automatically generate loading skeleton UIs from your existing React components — no manual skeleton screens needed.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"types": "src/index.d.ts",
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AutoSkeleton.js — V2
|
|
5
|
+
*
|
|
6
|
+
* Improvements over V1:
|
|
7
|
+
* - Reads defaults from SkeletonContext (SkeletonProvider)
|
|
8
|
+
* - Memoized descriptor via cache.js
|
|
9
|
+
* - fadeIn prop smoothly transitions real content in after loading
|
|
10
|
+
* - theme preset + themeOverrides merged into CSS vars
|
|
11
|
+
* - aria-live="polite" so screen readers announce content arrival
|
|
12
|
+
* - count renders siblings separated by gap (configurable)
|
|
13
|
+
* - renderSkeleton render prop for full manual override
|
|
14
|
+
* - onLoaded callback fired when loading transitions false
|
|
15
|
+
* - SSR safe (injectStyles is no-op on server)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
var React = require('react');
|
|
19
|
+
var analyzeElement = require('./analyzer').analyzeElement;
|
|
20
|
+
var renderNode = require('./renderer').renderNode;
|
|
21
|
+
var resetKeys = require('./renderer').resetKeys;
|
|
22
|
+
var injectStyles = require('./styles').injectStyles;
|
|
23
|
+
var buildThemeStyle = require('./styles').buildThemeStyle;
|
|
24
|
+
var cache = require('./cache');
|
|
25
|
+
var ctx = require('./context');
|
|
26
|
+
|
|
27
|
+
function AutoSkeleton(props) {
|
|
28
|
+
var loading = props.loading;
|
|
29
|
+
var children = props.children;
|
|
30
|
+
var className = props.className || '';
|
|
31
|
+
var style = props.style || {};
|
|
32
|
+
var count = props.count || 1;
|
|
33
|
+
var gap = props.gap || '16px';
|
|
34
|
+
var ariaLabel = props.ariaLabel || 'Loading…';
|
|
35
|
+
var onLoaded = props.onLoaded;
|
|
36
|
+
var renderSkeleton = props.renderSkeleton; // render prop override
|
|
37
|
+
|
|
38
|
+
// Read context
|
|
39
|
+
var context = React.useContext(ctx.SkeletonContext);
|
|
40
|
+
var animation = props.animation || context.animation;
|
|
41
|
+
var theme = props.theme || context.theme;
|
|
42
|
+
var themeOverrides = props.themeOverrides || context.themeOverrides;
|
|
43
|
+
var fadeIn = props.fadeIn != null ? props.fadeIn : context.fadeIn;
|
|
44
|
+
var maxDepth = props.maxDepth != null ? props.maxDepth : context.maxDepth;
|
|
45
|
+
var detectRepeats = props.detectRepeats != null ? props.detectRepeats : context.detectRepeats;
|
|
46
|
+
|
|
47
|
+
// Inject base CSS once
|
|
48
|
+
React.useEffect(function() { injectStyles(); }, []);
|
|
49
|
+
if (typeof document !== 'undefined') injectStyles();
|
|
50
|
+
|
|
51
|
+
// Fire onLoaded when loading transitions to false
|
|
52
|
+
var prevLoading = React.useRef(loading);
|
|
53
|
+
React.useEffect(function() {
|
|
54
|
+
if (prevLoading.current === true && loading === false && typeof onLoaded === 'function') {
|
|
55
|
+
onLoaded();
|
|
56
|
+
}
|
|
57
|
+
prevLoading.current = loading;
|
|
58
|
+
}, [loading]);
|
|
59
|
+
|
|
60
|
+
// Build theme CSS vars
|
|
61
|
+
var themeStyle = buildThemeStyle(theme, themeOverrides);
|
|
62
|
+
var wrapStyle = Object.assign({}, style, themeStyle);
|
|
63
|
+
|
|
64
|
+
if (!loading) {
|
|
65
|
+
// Render real children with optional fade-in animation
|
|
66
|
+
return React.createElement('div', {
|
|
67
|
+
'data-ask': '',
|
|
68
|
+
className: 'ask-wrap' + (className ? ' ' + className : '') + (fadeIn ? ' ask-fade-in' : ''),
|
|
69
|
+
style: wrapStyle,
|
|
70
|
+
'aria-live': 'polite',
|
|
71
|
+
'aria-busy': 'false',
|
|
72
|
+
}, children);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Use render prop if provided
|
|
76
|
+
if (typeof renderSkeleton === 'function') {
|
|
77
|
+
return React.createElement('div', {
|
|
78
|
+
'data-ask': '',
|
|
79
|
+
className: 'ask-wrap' + (className ? ' ' + className : ''),
|
|
80
|
+
style: wrapStyle,
|
|
81
|
+
'aria-busy': 'true',
|
|
82
|
+
'aria-label': ariaLabel,
|
|
83
|
+
}, renderSkeleton({ animation: animation }));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Analyze + render skeleton
|
|
87
|
+
var descriptor = cache.get(children);
|
|
88
|
+
if (!descriptor) {
|
|
89
|
+
descriptor = analyzeElement(children, 0, { maxDepth: maxDepth, detectRepeats: detectRepeats });
|
|
90
|
+
cache.set(children, descriptor);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
resetKeys();
|
|
94
|
+
var skeletonEl = renderNode(descriptor, { animation: animation });
|
|
95
|
+
|
|
96
|
+
// Render count copies
|
|
97
|
+
var items = [];
|
|
98
|
+
for (var i = 0; i < Math.max(1, count); i++) {
|
|
99
|
+
items.push(React.createElement('div', {
|
|
100
|
+
key: 'ask-item-' + i,
|
|
101
|
+
style: i < count - 1 ? { marginBottom: gap } : {},
|
|
102
|
+
}, skeletonEl));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return React.createElement('div', {
|
|
106
|
+
'data-ask': '',
|
|
107
|
+
className: 'ask-wrap' + (className ? ' ' + className : ''),
|
|
108
|
+
style: wrapStyle,
|
|
109
|
+
'aria-busy': 'true',
|
|
110
|
+
'aria-label': ariaLabel,
|
|
111
|
+
role: 'status',
|
|
112
|
+
}, items);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
AutoSkeleton.displayName = 'AutoSkeleton';
|
|
116
|
+
|
|
117
|
+
module.exports = AutoSkeleton;
|
package/src/analyzer.js
CHANGED
|
@@ -1,113 +1,336 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* analyzer.js — V2
|
|
5
|
+
*
|
|
6
|
+
* Improvements over V1:
|
|
7
|
+
* - Depth-limited recursion with configurable maxDepth
|
|
8
|
+
* - ARIA role awareness (role="img", role="button", etc.)
|
|
9
|
+
* - Smart width inference from text content length & element type
|
|
10
|
+
* - Detects flex/grid containers and replicates their direction
|
|
11
|
+
* - Detects repeat patterns (lists of similar children) for count-based skeletons
|
|
12
|
+
* - Extracts computed className hints (card, title, subtitle, badge, tag, chip…)
|
|
13
|
+
* - Inline style extraction: width, height, borderRadius, display, flexDirection, gap, padding, margin, aspectRatio
|
|
14
|
+
* - Handles React.Fragment and arrays
|
|
15
|
+
* - Skips purely decorative elements (aria-hidden, role="presentation")
|
|
16
|
+
* - Detects video/audio/iframe as media placeholders
|
|
17
|
+
* - Result memoization keyed on element type + props shape
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const NODE = {
|
|
21
|
+
TEXT: 'text',
|
|
22
|
+
HEADING: 'heading',
|
|
23
|
+
IMAGE: 'image',
|
|
24
|
+
AVATAR: 'avatar',
|
|
25
|
+
MEDIA: 'media', // video / audio / iframe
|
|
26
|
+
BUTTON: 'button',
|
|
27
|
+
INPUT: 'input',
|
|
28
|
+
TEXTAREA: 'textarea',
|
|
29
|
+
SELECT: 'select',
|
|
30
|
+
BADGE: 'badge',
|
|
31
|
+
ICON: 'icon',
|
|
32
|
+
CARD: 'card',
|
|
33
|
+
CONTAINER: 'container',
|
|
34
|
+
REPEAT: 'repeat', // detected list pattern
|
|
35
|
+
SKIP: 'skip', // decorative / aria-hidden
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/* ── helpers ── */
|
|
39
|
+
|
|
40
|
+
function tag(el) {
|
|
41
|
+
return typeof el.type === 'string' ? el.type.toLowerCase() : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function cls(el) {
|
|
45
|
+
return ((el.props && el.props.className) || '').toLowerCase();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function role(el) {
|
|
49
|
+
return ((el.props && el.props.role) || '').toLowerCase();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function aria(el, key) {
|
|
53
|
+
return el.props && el.props[key];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function styleOf(el) {
|
|
57
|
+
return (el.props && el.props.style) || {};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isDecorativeEl(el) {
|
|
61
|
+
if (!el || typeof el !== 'object') return false;
|
|
62
|
+
return aria(el, 'aria-hidden') === true ||
|
|
63
|
+
aria(el, 'aria-hidden') === 'true' ||
|
|
64
|
+
role(el) === 'presentation' ||
|
|
65
|
+
role(el) === 'none';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function extractStyle(el) {
|
|
69
|
+
const s = styleOf(el);
|
|
70
|
+
return {
|
|
71
|
+
width: s.width || null,
|
|
72
|
+
height: s.height || null,
|
|
73
|
+
borderRadius: s.borderRadius || null,
|
|
74
|
+
display: s.display || null,
|
|
75
|
+
flexDir: s.flexDirection|| null,
|
|
76
|
+
gap: s.gap || null,
|
|
77
|
+
padding: s.padding || s.p || null,
|
|
78
|
+
margin: s.margin || s.m || null,
|
|
79
|
+
aspectRatio: s.aspectRatio || null,
|
|
80
|
+
maxWidth: s.maxWidth || null,
|
|
81
|
+
minHeight: s.minHeight || null,
|
|
82
|
+
alignItems: s.alignItems || null,
|
|
83
|
+
fontSize: s.fontSize || null,
|
|
84
|
+
fontWeight: s.fontWeight || null,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const CLASS_PATTERNS = {
|
|
89
|
+
avatar: /\b(avatar|profile[-_]?pic|user[-_]?img|user[-_]?photo|pfp)\b/,
|
|
90
|
+
badge: /\b(badge|chip|tag|label|pill|status[-_]?dot)\b/,
|
|
91
|
+
icon: /\b(icon|ico|svg[-_]?icon|material[-_]?icon|fa[-_]|bi[-_]|ri[-_]|heroicon)\b/,
|
|
92
|
+
heading: /\b(heading|title|headline|h[1-6]|display[-_]?(text|title))\b/,
|
|
93
|
+
card: /\b(card|panel|tile|widget|box[-_]?container)\b/,
|
|
94
|
+
button: /\b(btn|button)\b/,
|
|
95
|
+
input: /\b(input|field|text[-_]?box|form[-_]?control)\b/,
|
|
96
|
+
media: /\b(media|video|player|thumbnail|cover[-_]?img|hero[-_]?img|banner)\b/,
|
|
97
|
+
list: /\b(list|feed|grid|gallery|results)\b/,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function matchesClass(el, key) {
|
|
101
|
+
return CLASS_PATTERNS[key] && CLASS_PATTERNS[key].test(cls(el));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* ── text content extraction ── */
|
|
105
|
+
function getTextContent(children) {
|
|
106
|
+
if (typeof children === 'string' || typeof children === 'number') {
|
|
107
|
+
return String(children);
|
|
108
|
+
}
|
|
109
|
+
if (Array.isArray(children)) {
|
|
110
|
+
return children.map(c => getTextContent(c && c.props ? c.props.children : c)).join('');
|
|
111
|
+
}
|
|
112
|
+
return '';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isTextLeaf(el) {
|
|
116
|
+
const c = el.props && el.props.children;
|
|
117
|
+
if (typeof c === 'string' || typeof c === 'number') return true;
|
|
118
|
+
if (Array.isArray(c)) return c.every(x => typeof x === 'string' || typeof x === 'number' || x == null);
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* ── list/repeat pattern detection ── */
|
|
123
|
+
function detectRepeat(children) {
|
|
124
|
+
if (!Array.isArray(children) || children.length < 2) return null;
|
|
125
|
+
const real = children.filter(c => c && typeof c === 'object');
|
|
126
|
+
if (real.length < 2) return null;
|
|
127
|
+
|
|
128
|
+
// Check if all children share the same element type
|
|
129
|
+
const firstType = real[0].type;
|
|
130
|
+
const allSame = real.every(c => c.type === firstType);
|
|
131
|
+
if (allSame && real.length >= 2) {
|
|
132
|
+
return { count: real.length, template: real[0] };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Check if all children share the same className prefix
|
|
136
|
+
const firstCls = cls(real[0]).split(' ')[0];
|
|
137
|
+
if (firstCls && real.every(c => cls(c).startsWith(firstCls))) {
|
|
138
|
+
return { count: real.length, template: real[0] };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* ── main analyzer ── */
|
|
145
|
+
function analyzeElement(element, depth, opts) {
|
|
146
|
+
depth = depth || 0;
|
|
147
|
+
opts = opts || {};
|
|
148
|
+
var maxDepth = opts.maxDepth != null ? opts.maxDepth : 8;
|
|
149
|
+
|
|
150
|
+
if (element === null || element === undefined) return null;
|
|
151
|
+
if (typeof element === 'boolean') return null;
|
|
152
|
+
|
|
153
|
+
// String / number node
|
|
154
|
+
if (typeof element === 'string' || typeof element === 'number') {
|
|
155
|
+
var str = String(element).trim();
|
|
156
|
+
if (!str) return null;
|
|
157
|
+
return {
|
|
158
|
+
nodeType: NODE.TEXT,
|
|
159
|
+
content: str,
|
|
160
|
+
width: estimateTextWidth(str),
|
|
161
|
+
depth: depth,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Array / Fragment
|
|
166
|
+
if (Array.isArray(element)) {
|
|
167
|
+
var results = [];
|
|
168
|
+
element.forEach(function(el) {
|
|
169
|
+
var r = analyzeElement(el, depth, opts);
|
|
170
|
+
if (Array.isArray(r)) results = results.concat(r);
|
|
171
|
+
else if (r) results.push(r);
|
|
172
|
+
});
|
|
173
|
+
return results.length === 1 ? results[0] : results;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// React.Fragment
|
|
177
|
+
if (element.type && (element.type === Symbol.for('react.fragment') || element.type.toString() === 'Symbol(react.fragment)')) {
|
|
178
|
+
return analyzeElement(element.props && element.props.children, depth, opts);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Non-DOM component (function/class) — analyse its children if present
|
|
182
|
+
if (typeof element.type === 'function') {
|
|
183
|
+
var fc = element.props && element.props.children;
|
|
184
|
+
if (fc) return analyzeElement(fc, depth, opts);
|
|
185
|
+
// treat as opaque container
|
|
186
|
+
return { nodeType: NODE.CONTAINER, children: [], styleHints: {}, tag: 'div', depth: depth };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Skip decorative
|
|
190
|
+
if (isDecorativeEl(element)) {
|
|
191
|
+
return { nodeType: NODE.SKIP, depth: depth };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
var t = tag(element);
|
|
195
|
+
var c = cls(element);
|
|
196
|
+
var r = role(element);
|
|
197
|
+
var sHints = extractStyle(element);
|
|
198
|
+
var props = element.props || {};
|
|
199
|
+
|
|
200
|
+
// ── specific tag classification ──
|
|
201
|
+
|
|
202
|
+
if (t === 'img') {
|
|
203
|
+
var isAvatar = matchesClass(element, 'avatar') ||
|
|
204
|
+
/avatar|profile|pfp/i.test(props.alt || '') ||
|
|
205
|
+
(sHints.width && sHints.height && sHints.width === sHints.height && sHints.borderRadius);
|
|
206
|
+
return {
|
|
207
|
+
nodeType: isAvatar ? NODE.AVATAR : NODE.IMAGE,
|
|
208
|
+
styleHints: sHints,
|
|
209
|
+
alt: props.alt || '',
|
|
210
|
+
depth: depth,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (t === 'video' || t === 'audio' || t === 'iframe') {
|
|
215
|
+
return { nodeType: NODE.MEDIA, styleHints: sHints, depth: depth };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (t === 'svg' || r === 'img' && !sHints.width) {
|
|
219
|
+
return { nodeType: NODE.ICON, styleHints: sHints, depth: depth };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (t === 'button' || r === 'button' || matchesClass(element, 'button')) {
|
|
223
|
+
return {
|
|
224
|
+
nodeType: NODE.BUTTON,
|
|
225
|
+
label: typeof props.children === 'string' ? props.children : '',
|
|
226
|
+
styleHints: sHints,
|
|
227
|
+
depth: depth,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (t === 'input') {
|
|
232
|
+
var inputType = (props.type || 'text').toLowerCase();
|
|
233
|
+
if (inputType === 'checkbox' || inputType === 'radio') {
|
|
234
|
+
return { nodeType: NODE.ICON, styleHints: { width: '18px', height: '18px' }, depth: depth };
|
|
235
|
+
}
|
|
236
|
+
return { nodeType: NODE.INPUT, inputType: inputType, styleHints: sHints, depth: depth };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (t === 'textarea') return { nodeType: NODE.TEXTAREA, styleHints: sHints, depth: depth };
|
|
240
|
+
if (t === 'select') return { nodeType: NODE.SELECT, styleHints: sHints, depth: depth };
|
|
241
|
+
|
|
242
|
+
// Heading tags
|
|
243
|
+
if (/^h[1-6]$/.test(t || '') || matchesClass(element, 'heading')) {
|
|
244
|
+
if (isTextLeaf(element)) {
|
|
245
|
+
var hContent = getTextContent(props.children);
|
|
246
|
+
return {
|
|
247
|
+
nodeType: NODE.HEADING,
|
|
248
|
+
level: t ? parseInt(t[1]) : 2,
|
|
249
|
+
content: hContent,
|
|
250
|
+
width: estimateTextWidth(hContent) || '60%',
|
|
251
|
+
styleHints: sHints,
|
|
252
|
+
depth: depth,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Class-based inference
|
|
258
|
+
if (matchesClass(element, 'avatar')) {
|
|
259
|
+
return { nodeType: NODE.AVATAR, styleHints: sHints, depth: depth };
|
|
260
|
+
}
|
|
261
|
+
if (matchesClass(element, 'badge')) {
|
|
262
|
+
return { nodeType: NODE.BADGE, styleHints: sHints, depth: depth };
|
|
263
|
+
}
|
|
264
|
+
if (matchesClass(element, 'icon')) {
|
|
265
|
+
return { nodeType: NODE.ICON, styleHints: sHints, depth: depth };
|
|
266
|
+
}
|
|
267
|
+
if (matchesClass(element, 'media')) {
|
|
268
|
+
return { nodeType: NODE.IMAGE, styleHints: sHints, depth: depth };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Text leaf inside any tag
|
|
272
|
+
if (isTextLeaf(element)) {
|
|
273
|
+
var content = getTextContent(props.children);
|
|
274
|
+
var isHead = matchesClass(element, 'heading') || /^h[1-6]$/.test(t || '');
|
|
275
|
+
var isBadge = matchesClass(element, 'badge');
|
|
276
|
+
if (isBadge) return { nodeType: NODE.BADGE, content: content, styleHints: sHints, depth: depth };
|
|
277
|
+
return {
|
|
278
|
+
nodeType: isHead ? NODE.HEADING : NODE.TEXT,
|
|
279
|
+
content: content,
|
|
280
|
+
width: sHints.width || estimateTextWidth(content),
|
|
281
|
+
styleHints: sHints,
|
|
282
|
+
depth: depth,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Depth limit — treat as opaque block
|
|
287
|
+
if (depth >= maxDepth) {
|
|
288
|
+
return { nodeType: NODE.CONTAINER, children: [], styleHints: sHints, tag: t || 'div', depth: depth };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── Container: recurse ──
|
|
292
|
+
var rawChildren = props.children;
|
|
293
|
+
var childArr = rawChildren == null ? [] : (Array.isArray(rawChildren) ? rawChildren : [rawChildren]);
|
|
294
|
+
|
|
295
|
+
// Detect repeat pattern
|
|
296
|
+
var repeat = opts.detectRepeats !== false ? detectRepeat(childArr) : null;
|
|
297
|
+
if (repeat && depth > 0) {
|
|
298
|
+
return {
|
|
299
|
+
nodeType: NODE.REPEAT,
|
|
300
|
+
count: repeat.count,
|
|
301
|
+
template: analyzeElement(repeat.template, depth + 1, opts),
|
|
302
|
+
styleHints: sHints,
|
|
303
|
+
depth: depth,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
var children = [];
|
|
308
|
+
childArr.forEach(function(child) {
|
|
309
|
+
var res = analyzeElement(child, depth + 1, opts);
|
|
310
|
+
if (Array.isArray(res)) { res.forEach(function(r2) { if (r2 && r2.nodeType !== NODE.SKIP) children.push(r2); }); }
|
|
311
|
+
else if (res && res.nodeType !== NODE.SKIP) children.push(res);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// Card heuristic: contains image + text children
|
|
315
|
+
var isCard = matchesClass(element, 'card') || t === 'article' || t === 'section';
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
nodeType: isCard ? NODE.CARD : NODE.CONTAINER,
|
|
319
|
+
tag: t || 'div',
|
|
320
|
+
styleHints: sHints,
|
|
321
|
+
children: children,
|
|
322
|
+
depth: depth,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/* ── text width estimation ── */
|
|
327
|
+
function estimateTextWidth(text) {
|
|
328
|
+
if (!text) return '80%';
|
|
329
|
+
var len = text.length;
|
|
330
|
+
if (len <= 10) return Math.min(100, Math.max(20, len * 7)) + 'px';
|
|
331
|
+
if (len <= 30) return Math.min(90, 30 + len) + '%';
|
|
332
|
+
if (len <= 80) return '90%';
|
|
333
|
+
return '100%';
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
module.exports = { analyzeElement, NODE, estimateTextWidth };
|
package/src/cache.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* cache.js — V2
|
|
5
|
+
*
|
|
6
|
+
* LRU-style descriptor cache so repeated renders of the same component
|
|
7
|
+
* tree skip the recursive analyzeElement walk.
|
|
8
|
+
*
|
|
9
|
+
* Key: shallow fingerprint of the React element (type + prop keys + child count)
|
|
10
|
+
* Value: the analyzed descriptor
|
|
11
|
+
* Capacity: 32 entries (configurable)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
var MAX = 32;
|
|
15
|
+
var _cache = new Map();
|
|
16
|
+
|
|
17
|
+
function fingerprint(element) {
|
|
18
|
+
if (!element || typeof element !== 'object') return String(element);
|
|
19
|
+
var t = typeof element.type === 'string' ? element.type : (element.type && element.type.displayName) || '?';
|
|
20
|
+
var p = element.props ? Object.keys(element.props).sort().join(',') : '';
|
|
21
|
+
var c = element.props && element.props.children;
|
|
22
|
+
var cl = Array.isArray(c) ? c.length : (c ? 1 : 0);
|
|
23
|
+
return t + '|' + p + '|' + cl;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function get(element) {
|
|
27
|
+
var key = fingerprint(element);
|
|
28
|
+
if (!_cache.has(key)) return null;
|
|
29
|
+
// LRU: move to end
|
|
30
|
+
var val = _cache.get(key);
|
|
31
|
+
_cache.delete(key);
|
|
32
|
+
_cache.set(key, val);
|
|
33
|
+
return val;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function set(element, descriptor) {
|
|
37
|
+
var key = fingerprint(element);
|
|
38
|
+
if (_cache.size >= MAX) {
|
|
39
|
+
// evict oldest entry
|
|
40
|
+
_cache.delete(_cache.keys().next().value);
|
|
41
|
+
}
|
|
42
|
+
_cache.set(key, descriptor);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function clear() { _cache.clear(); }
|
|
46
|
+
|
|
47
|
+
module.exports = { get, set, clear, fingerprint };
|
package/src/context.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* context.js — V2
|
|
5
|
+
*
|
|
6
|
+
* SkeletonProvider lets you set default animation, theme, and options
|
|
7
|
+
* globally so every <AutoSkeleton> in the tree inherits them without
|
|
8
|
+
* passing props repeatedly.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
var React = require('react');
|
|
12
|
+
|
|
13
|
+
var DEFAULT_CTX = {
|
|
14
|
+
animation: 'shimmer',
|
|
15
|
+
theme: 'default',
|
|
16
|
+
themeOverrides: {},
|
|
17
|
+
fadeIn: true,
|
|
18
|
+
maxDepth: 8,
|
|
19
|
+
detectRepeats: true,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
var SkeletonContext = React.createContext(DEFAULT_CTX);
|
|
23
|
+
|
|
24
|
+
function SkeletonProvider(props) {
|
|
25
|
+
var value = Object.assign({}, DEFAULT_CTX, {
|
|
26
|
+
animation: props.animation || DEFAULT_CTX.animation,
|
|
27
|
+
theme: props.theme || DEFAULT_CTX.theme,
|
|
28
|
+
themeOverrides: props.themeOverrides || DEFAULT_CTX.themeOverrides,
|
|
29
|
+
fadeIn: props.fadeIn != null ? props.fadeIn : DEFAULT_CTX.fadeIn,
|
|
30
|
+
maxDepth: props.maxDepth != null ? props.maxDepth : DEFAULT_CTX.maxDepth,
|
|
31
|
+
detectRepeats: props.detectRepeats != null ? props.detectRepeats : DEFAULT_CTX.detectRepeats,
|
|
32
|
+
});
|
|
33
|
+
return React.createElement(SkeletonContext.Provider, { value: value }, props.children);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
SkeletonProvider.displayName = 'SkeletonProvider';
|
|
37
|
+
|
|
38
|
+
module.exports = { SkeletonContext, SkeletonProvider, DEFAULT_CTX };
|