@alikhalilll/a-skeleton 1.0.0 → 1.2.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/.media/hero.png +0 -0
- package/.media/hero.svg +232 -0
- package/README.md +459 -170
- package/dist/index.cjs +3696 -828
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +534 -43
- package/dist/index.d.ts +534 -43
- package/dist/index.js +3677 -830
- package/dist/index.js.map +1 -1
- package/dist/nuxt/index.cjs +16 -1
- package/dist/nuxt/index.cjs.map +1 -1
- package/dist/nuxt/index.js +16 -1
- package/dist/nuxt/index.js.map +1 -1
- package/dist/resolver/index.cjs +16 -1
- package/dist/resolver/index.cjs.map +1 -1
- package/dist/resolver/index.js +16 -1
- package/dist/resolver/index.js.map +1 -1
- package/dist/styles.css +56 -11
- package/package.json +8 -2
- package/src/components/ASkeleton.vue +216 -98
- package/src/components/ASkeletonClone.vue +106 -0
- package/src/components/ASkeletonLayer.vue +20 -32
- package/src/components/CloneNode.ts +161 -0
- package/src/components/StructuralLayerNode.ts +157 -0
- package/src/components/icons.ts +45 -0
- package/src/components/variants/ASkeletonArticle.vue +33 -0
- package/src/components/variants/ASkeletonAvatar.vue +42 -0
- package/src/components/variants/ASkeletonButton.vue +37 -0
- package/src/components/variants/ASkeletonCard.vue +47 -0
- package/src/components/variants/ASkeletonChart.vue +56 -0
- package/src/components/variants/ASkeletonChip.vue +32 -0
- package/src/components/variants/ASkeletonDivider.vue +26 -0
- package/src/components/variants/ASkeletonForm.vue +32 -0
- package/src/components/variants/ASkeletonHeading.vue +47 -0
- package/src/components/variants/ASkeletonImage.vue +57 -0
- package/src/components/variants/ASkeletonInput.vue +33 -0
- package/src/components/variants/ASkeletonListItem.vue +40 -0
- package/src/components/variants/ASkeletonTable.vue +49 -0
- package/src/components/variants/ASkeletonText.vue +49 -0
- package/src/components/variants/ASkeletonVideo.vue +55 -0
- package/src/composables/useShapeProbe.ts +33 -9
- package/src/composables/useSkeleton.ts +33 -21
- package/src/composables/useSkeletonCache.ts +282 -24
- package/src/index.ts +48 -2
- package/src/nuxt/index.ts +16 -0
- package/src/resolver/index.ts +16 -0
- package/src/types.ts +124 -5
- package/src/utils/buildStructuralSkeleton.ts +400 -103
- package/src/utils/captureStyles.ts +378 -0
- package/src/utils/domRead.ts +143 -0
- package/src/utils/walkDom.ts +261 -16
- package/src/utils/walkStructural.ts +418 -0
- package/web-types.json +10 -4
|
@@ -1,24 +1,69 @@
|
|
|
1
1
|
import type { CSSProperties } from 'vue';
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
CachedShape,
|
|
4
|
+
ContainerNode,
|
|
5
|
+
LeafNode,
|
|
6
|
+
ShapeNode,
|
|
7
|
+
StructuralNode,
|
|
8
|
+
StructuralShape,
|
|
9
|
+
} from '../types';
|
|
3
10
|
|
|
4
11
|
const memory = new Map<string, CachedShape>();
|
|
5
12
|
const STORAGE_PREFIX = 'a-skeleton:';
|
|
6
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Schema version for persisted flat `CachedShape` entries. Bump whenever the
|
|
16
|
+
* `ShapeNode` / `CachedShape` field set changes so stale localStorage payloads
|
|
17
|
+
* from older releases get dropped on read instead of rehydrating into a wrong
|
|
18
|
+
* layout.
|
|
19
|
+
*
|
|
20
|
+
* v2 — added advanced surface signals (textRects, bg, border, boxShadow,
|
|
21
|
+
* opacity, textAlign).
|
|
22
|
+
*/
|
|
23
|
+
const SCHEMA_VERSION = 2;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Separate in-memory + localStorage namespace for the Recipe 3 structural
|
|
27
|
+
* shape (`StructuralShape`, `v: 3`). Kept apart from the flat-shape cache so
|
|
28
|
+
* the two pipelines can't collide on the same `cacheKey` — `<ASkeleton>`'s
|
|
29
|
+
* legacy cache-replay path and Recipe 3 may both run on the same page with
|
|
30
|
+
* the same key.
|
|
31
|
+
*/
|
|
32
|
+
const structuralMemory = new Map<string, StructuralShape>();
|
|
33
|
+
const STRUCTURAL_PREFIX = 'a-skeleton:s:';
|
|
34
|
+
const STRUCTURAL_SCHEMA_VERSION = 3 as const;
|
|
35
|
+
|
|
36
|
+
interface PersistedShape {
|
|
37
|
+
v: number;
|
|
38
|
+
width: number;
|
|
39
|
+
height: number;
|
|
40
|
+
nodes: Partial<ShapeNode>[];
|
|
41
|
+
truncated?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
7
44
|
/**
|
|
8
45
|
* Lookup a captured shape by key. Reads in-memory first, then `localStorage` if
|
|
9
46
|
* `persist` is enabled. SSR-safe — bypasses `window` access on the server.
|
|
10
47
|
* Rehydrates pre-computed styles for shapes deserialized from older sessions
|
|
11
48
|
* (Object.freeze + style/lineStyles don't survive `JSON.stringify` round-trip).
|
|
49
|
+
* Drops the entry if it was written by a different schema version.
|
|
12
50
|
*/
|
|
13
51
|
export function getCached(key: string, persist: boolean): CachedShape | undefined {
|
|
14
52
|
const hit = memory.get(key);
|
|
15
53
|
if (hit) return hit;
|
|
16
54
|
if (!persist || typeof window === 'undefined') return undefined;
|
|
17
55
|
try {
|
|
18
|
-
const
|
|
56
|
+
const storageKey = STORAGE_PREFIX + key;
|
|
57
|
+
const raw = window.localStorage.getItem(storageKey);
|
|
19
58
|
if (!raw) return undefined;
|
|
20
|
-
const parsed = JSON.parse(raw) as
|
|
21
|
-
|
|
59
|
+
const parsed = JSON.parse(raw) as Partial<PersistedShape>;
|
|
60
|
+
if (parsed.v !== SCHEMA_VERSION) {
|
|
61
|
+
/* Stale payload from a previous release — purge so the next capture
|
|
62
|
+
* writes a fresh entry instead of rehydrating into a wrong layout. */
|
|
63
|
+
window.localStorage.removeItem(storageKey);
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
const rehydrated = rehydrateShape(parsed as PersistedShape);
|
|
22
67
|
memory.set(key, rehydrated);
|
|
23
68
|
return rehydrated;
|
|
24
69
|
} catch {
|
|
@@ -32,43 +77,207 @@ export function setCached(key: string, value: CachedShape, persist: boolean): vo
|
|
|
32
77
|
if (!persist || typeof window === 'undefined') return;
|
|
33
78
|
try {
|
|
34
79
|
/* Only the geometry survives the round-trip; styles get rebuilt on read. */
|
|
35
|
-
const lean = {
|
|
80
|
+
const lean: PersistedShape = {
|
|
81
|
+
v: SCHEMA_VERSION,
|
|
82
|
+
width: value.width,
|
|
83
|
+
height: value.height,
|
|
84
|
+
nodes: leanNodes(value.nodes),
|
|
85
|
+
truncated: value.truncated,
|
|
86
|
+
};
|
|
36
87
|
window.localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(lean));
|
|
37
88
|
} catch {
|
|
38
89
|
/* quota exceeded / disabled storage — silently degrade to in-memory only. */
|
|
39
90
|
}
|
|
40
91
|
}
|
|
41
92
|
|
|
42
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* Drop a single entry (or all entries when no key) — wipes both the flat-shape
|
|
95
|
+
* and the structural-shape namespaces. Exposed for tests + manual invalidation.
|
|
96
|
+
*/
|
|
43
97
|
export function clearCached(key?: string): void {
|
|
44
98
|
if (!key) {
|
|
45
99
|
memory.clear();
|
|
100
|
+
structuralMemory.clear();
|
|
46
101
|
if (typeof window === 'undefined') return;
|
|
47
102
|
try {
|
|
48
|
-
|
|
49
|
-
|
|
103
|
+
/* Use Storage's own length + key(i) iteration — `Object.keys()` on a
|
|
104
|
+
* Storage object isn't reliable across implementations (works in real
|
|
105
|
+
* browsers via the Storage[Symbol.iterator] hack, but strict polyfills
|
|
106
|
+
* return only own enumerable properties). */
|
|
107
|
+
const ls = window.localStorage;
|
|
108
|
+
const stale: string[] = [];
|
|
109
|
+
for (let i = 0; i < ls.length; i++) {
|
|
110
|
+
const k = ls.key(i);
|
|
111
|
+
if (!k) continue;
|
|
112
|
+
if (k.startsWith(STORAGE_PREFIX) || k.startsWith(STRUCTURAL_PREFIX)) stale.push(k);
|
|
50
113
|
}
|
|
114
|
+
/* Remove in a second pass so we don't shift indices mid-iteration. */
|
|
115
|
+
for (const k of stale) ls.removeItem(k);
|
|
51
116
|
} catch {
|
|
52
117
|
/* ignore */
|
|
53
118
|
}
|
|
54
119
|
return;
|
|
55
120
|
}
|
|
56
121
|
memory.delete(key);
|
|
122
|
+
structuralMemory.delete(key);
|
|
57
123
|
if (typeof window === 'undefined') return;
|
|
58
124
|
try {
|
|
59
125
|
window.localStorage.removeItem(STORAGE_PREFIX + key);
|
|
126
|
+
window.localStorage.removeItem(STRUCTURAL_PREFIX + key);
|
|
60
127
|
} catch {
|
|
61
128
|
/* ignore */
|
|
62
129
|
}
|
|
63
130
|
}
|
|
64
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Drop a single structural-shape entry (or all when no key). Kept separate
|
|
134
|
+
* from `clearCached` so callers that only manage structural shapes don't have
|
|
135
|
+
* to reach across namespaces.
|
|
136
|
+
*/
|
|
137
|
+
export function clearCachedStructural(key?: string): void {
|
|
138
|
+
if (!key) {
|
|
139
|
+
structuralMemory.clear();
|
|
140
|
+
if (typeof window === 'undefined') return;
|
|
141
|
+
try {
|
|
142
|
+
const ls = window.localStorage;
|
|
143
|
+
const stale: string[] = [];
|
|
144
|
+
for (let i = 0; i < ls.length; i++) {
|
|
145
|
+
const k = ls.key(i);
|
|
146
|
+
if (k && k.startsWith(STRUCTURAL_PREFIX)) stale.push(k);
|
|
147
|
+
}
|
|
148
|
+
for (const k of stale) ls.removeItem(k);
|
|
149
|
+
} catch {
|
|
150
|
+
/* ignore */
|
|
151
|
+
}
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
structuralMemory.delete(key);
|
|
155
|
+
if (typeof window === 'undefined') return;
|
|
156
|
+
try {
|
|
157
|
+
window.localStorage.removeItem(STRUCTURAL_PREFIX + key);
|
|
158
|
+
} catch {
|
|
159
|
+
/* ignore */
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* ─────────────────────────────────────────────────────────────────────────────
|
|
164
|
+
* Structural-shape cache — backs Recipe 3 (`useSkeleton()` +
|
|
165
|
+
* `<ASkeletonLayer>`). The schema-version check auto-purges stale `v: 2`
|
|
166
|
+
* entries (or any future mismatched version) on read.
|
|
167
|
+
* ───────────────────────────────────────────────────────────────────────────── */
|
|
168
|
+
|
|
169
|
+
interface PersistedStructuralShape {
|
|
170
|
+
v: typeof STRUCTURAL_SCHEMA_VERSION;
|
|
171
|
+
width: number;
|
|
172
|
+
height: number;
|
|
173
|
+
nodes: StructuralNode[];
|
|
174
|
+
truncated: boolean;
|
|
175
|
+
capturedAt: number;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Lookup a structural shape by key. Reads in-memory first, then `localStorage`
|
|
180
|
+
* when `persist` is enabled. Stale schema versions get purged on read.
|
|
181
|
+
*/
|
|
182
|
+
export function getCachedStructural(key: string, persist: boolean): StructuralShape | undefined {
|
|
183
|
+
const hit = structuralMemory.get(key);
|
|
184
|
+
if (hit) return hit;
|
|
185
|
+
if (!persist || typeof window === 'undefined') return undefined;
|
|
186
|
+
try {
|
|
187
|
+
const storageKey = STRUCTURAL_PREFIX + key;
|
|
188
|
+
const raw = window.localStorage.getItem(storageKey);
|
|
189
|
+
if (!raw) return undefined;
|
|
190
|
+
const parsed = JSON.parse(raw) as Partial<PersistedStructuralShape>;
|
|
191
|
+
if (parsed.v !== STRUCTURAL_SCHEMA_VERSION) {
|
|
192
|
+
window.localStorage.removeItem(storageKey);
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
const rehydrated = rehydrateStructuralShape(parsed as PersistedStructuralShape);
|
|
196
|
+
structuralMemory.set(key, rehydrated);
|
|
197
|
+
return rehydrated;
|
|
198
|
+
} catch {
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Store a structural shape. `persist=true` mirrors to `localStorage`. */
|
|
204
|
+
export function setCachedStructural(key: string, value: StructuralShape, persist: boolean): void {
|
|
205
|
+
structuralMemory.set(key, value);
|
|
206
|
+
if (!persist || typeof window === 'undefined') return;
|
|
207
|
+
try {
|
|
208
|
+
const payload: PersistedStructuralShape = {
|
|
209
|
+
v: STRUCTURAL_SCHEMA_VERSION,
|
|
210
|
+
width: value.width,
|
|
211
|
+
height: value.height,
|
|
212
|
+
nodes: value.nodes.map(leanStructuralNode),
|
|
213
|
+
truncated: value.truncated,
|
|
214
|
+
capturedAt: value.capturedAt,
|
|
215
|
+
};
|
|
216
|
+
window.localStorage.setItem(STRUCTURAL_PREFIX + key, JSON.stringify(payload));
|
|
217
|
+
} catch {
|
|
218
|
+
/* quota / disabled storage — silently degrade to in-memory only. */
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/* JSON-stringify drops `undefined`, but `Object.freeze` survives across the
|
|
223
|
+
* round-trip only if we re-freeze after parse. The lean transforms keep
|
|
224
|
+
* payload size predictable (no unexpected enumerable Vue proxies). */
|
|
225
|
+
function leanStructuralNode(node: StructuralNode): StructuralNode {
|
|
226
|
+
if (node.kind === 'container') {
|
|
227
|
+
return {
|
|
228
|
+
kind: 'container',
|
|
229
|
+
tag: node.tag,
|
|
230
|
+
className: node.className,
|
|
231
|
+
style: node.style,
|
|
232
|
+
children: node.children.map(leanStructuralNode),
|
|
233
|
+
} as ContainerNode;
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
kind: 'leaf',
|
|
237
|
+
leafKind: node.leafKind,
|
|
238
|
+
className: node.className,
|
|
239
|
+
style: node.style,
|
|
240
|
+
textLines: node.textLines,
|
|
241
|
+
} as LeafNode;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function rehydrateStructuralShape(persisted: PersistedStructuralShape): StructuralShape {
|
|
245
|
+
return Object.freeze({
|
|
246
|
+
v: STRUCTURAL_SCHEMA_VERSION,
|
|
247
|
+
width: persisted.width,
|
|
248
|
+
height: persisted.height,
|
|
249
|
+
nodes: Object.freeze(persisted.nodes.map(rehydrateStructuralNode)),
|
|
250
|
+
truncated: persisted.truncated,
|
|
251
|
+
capturedAt: persisted.capturedAt,
|
|
252
|
+
}) as StructuralShape;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function rehydrateStructuralNode(node: StructuralNode): StructuralNode {
|
|
256
|
+
if (node.kind === 'container') {
|
|
257
|
+
return Object.freeze({
|
|
258
|
+
kind: 'container',
|
|
259
|
+
tag: node.tag,
|
|
260
|
+
className: node.className,
|
|
261
|
+
style: Object.freeze({ ...node.style }),
|
|
262
|
+
children: Object.freeze(node.children.map(rehydrateStructuralNode)),
|
|
263
|
+
}) as ContainerNode;
|
|
264
|
+
}
|
|
265
|
+
return Object.freeze({
|
|
266
|
+
kind: 'leaf',
|
|
267
|
+
leafKind: node.leafKind,
|
|
268
|
+
className: node.className ?? '',
|
|
269
|
+
style: Object.freeze({ ...node.style }),
|
|
270
|
+
textLines: node.textLines ? Object.freeze([...node.textLines]) : undefined,
|
|
271
|
+
}) as LeafNode;
|
|
272
|
+
}
|
|
273
|
+
|
|
65
274
|
/**
|
|
66
275
|
* Rebuild `style` + `lineStyles` for nodes that lost them via serialization.
|
|
67
276
|
* Walks the array in-place where possible and freezes the result so the render
|
|
68
277
|
* path stays allocation-free.
|
|
69
278
|
*/
|
|
70
|
-
function rehydrateShape(shape:
|
|
71
|
-
const nodes = shape.nodes.map((n) => (n
|
|
279
|
+
function rehydrateShape(shape: PersistedShape): CachedShape {
|
|
280
|
+
const nodes = shape.nodes.map((n) => freezeNodeStyles(n as ShapeNode));
|
|
72
281
|
return Object.freeze({
|
|
73
282
|
nodes: Object.freeze(nodes),
|
|
74
283
|
width: shape.width,
|
|
@@ -90,38 +299,71 @@ function leanNodes(nodes: ReadonlyArray<ShapeNode>): Partial<ShapeNode>[] {
|
|
|
90
299
|
radius: n.radius,
|
|
91
300
|
lines: n.lines,
|
|
92
301
|
lineHeight: n.lineHeight,
|
|
302
|
+
textRects: n.textRects
|
|
303
|
+
? n.textRects.map((r) => ({ x: r.x, y: r.y, w: r.w, h: r.h }))
|
|
304
|
+
: undefined,
|
|
305
|
+
bg: n.bg,
|
|
306
|
+
border: n.border,
|
|
307
|
+
boxShadow: n.boxShadow,
|
|
308
|
+
opacity: n.opacity,
|
|
309
|
+
textAlign: n.textAlign,
|
|
93
310
|
}));
|
|
94
311
|
}
|
|
95
312
|
|
|
96
313
|
function freezeNodeStyles(node: ShapeNode): ShapeNode {
|
|
97
|
-
const
|
|
314
|
+
const baseStyle: CSSProperties = {
|
|
98
315
|
left: `${node.x}px`,
|
|
99
316
|
top: `${node.y}px`,
|
|
100
317
|
width: `${node.w}px`,
|
|
101
318
|
height: `${node.h}px`,
|
|
102
319
|
borderRadius: `${node.radius}px`,
|
|
103
|
-
}
|
|
320
|
+
};
|
|
321
|
+
applyVisualSignals(baseStyle, node);
|
|
322
|
+
const style = Object.freeze(baseStyle);
|
|
104
323
|
|
|
105
324
|
let lineStyles: ReadonlyArray<Readonly<CSSProperties>> | undefined;
|
|
106
|
-
|
|
325
|
+
|
|
326
|
+
if (node.type === 'text' && node.textRects && node.textRects.length > 0) {
|
|
327
|
+
const radiusStr = `${node.radius}px`;
|
|
328
|
+
const arr: Readonly<CSSProperties>[] = [];
|
|
329
|
+
for (const r of node.textRects) {
|
|
330
|
+
const lineStyle: CSSProperties = {
|
|
331
|
+
left: `${r.x}px`,
|
|
332
|
+
top: `${r.y}px`,
|
|
333
|
+
width: `${r.w}px`,
|
|
334
|
+
height: `${r.h}px`,
|
|
335
|
+
borderRadius: radiusStr,
|
|
336
|
+
};
|
|
337
|
+
applyVisualSignals(lineStyle, node);
|
|
338
|
+
arr.push(Object.freeze(lineStyle));
|
|
339
|
+
}
|
|
340
|
+
lineStyles = Object.freeze(arr);
|
|
341
|
+
} else if (node.type === 'text' && node.lines && node.lines > 1) {
|
|
107
342
|
const lh = node.lineHeight ?? Math.round(node.h / node.lines);
|
|
108
343
|
const barHeight = Math.max(8, Math.round(lh * 0.7));
|
|
109
|
-
const widthFull =
|
|
110
|
-
const widthLast =
|
|
344
|
+
const widthFull = node.w;
|
|
345
|
+
const widthLast = Math.max(40, Math.round(node.w * 0.7));
|
|
111
346
|
const heightStr = `${barHeight}px`;
|
|
112
347
|
const radiusStr = `${node.radius}px`;
|
|
113
348
|
const arr: Readonly<CSSProperties>[] = [];
|
|
114
349
|
for (let i = 1; i <= node.lines; i++) {
|
|
115
350
|
const isLast = i === node.lines;
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
351
|
+
const lineWidth = isLast ? widthLast : widthFull;
|
|
352
|
+
let leftX = node.x;
|
|
353
|
+
if (isLast && node.textAlign) {
|
|
354
|
+
const slack = widthFull - lineWidth;
|
|
355
|
+
if (node.textAlign === 'center') leftX = node.x + Math.round(slack / 2);
|
|
356
|
+
else if (node.textAlign === 'right' || node.textAlign === 'end') leftX = node.x + slack;
|
|
357
|
+
}
|
|
358
|
+
const lineStyle: CSSProperties = {
|
|
359
|
+
left: `${leftX}px`,
|
|
360
|
+
top: `${node.y + (i - 1) * lh}px`,
|
|
361
|
+
width: `${lineWidth}px`,
|
|
362
|
+
height: heightStr,
|
|
363
|
+
borderRadius: radiusStr,
|
|
364
|
+
};
|
|
365
|
+
applyVisualSignals(lineStyle, node);
|
|
366
|
+
arr.push(Object.freeze(lineStyle));
|
|
125
367
|
}
|
|
126
368
|
lineStyles = Object.freeze(arr);
|
|
127
369
|
}
|
|
@@ -135,7 +377,23 @@ function freezeNodeStyles(node: ShapeNode): ShapeNode {
|
|
|
135
377
|
radius: node.radius,
|
|
136
378
|
lines: node.lines,
|
|
137
379
|
lineHeight: node.lineHeight,
|
|
380
|
+
textRects: node.textRects ? Object.freeze(node.textRects) : undefined,
|
|
381
|
+
bg: node.bg,
|
|
382
|
+
border: node.border,
|
|
383
|
+
boxShadow: node.boxShadow,
|
|
384
|
+
opacity: node.opacity,
|
|
385
|
+
textAlign: node.textAlign,
|
|
138
386
|
style,
|
|
139
387
|
lineStyles,
|
|
140
388
|
});
|
|
141
389
|
}
|
|
390
|
+
|
|
391
|
+
function applyVisualSignals(
|
|
392
|
+
out: CSSProperties,
|
|
393
|
+
node: { bg?: string; border?: string; boxShadow?: string; opacity?: number }
|
|
394
|
+
): void {
|
|
395
|
+
if (node.bg) out.background = node.bg;
|
|
396
|
+
if (node.border) out.border = node.border;
|
|
397
|
+
if (node.boxShadow) out.boxShadow = node.boxShadow;
|
|
398
|
+
if (node.opacity !== undefined) out.opacity = node.opacity;
|
|
399
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,15 +2,54 @@
|
|
|
2
2
|
export { default as ASkeleton } from './components/ASkeleton.vue';
|
|
3
3
|
export { default as ASkeletonLayer } from './components/ASkeletonLayer.vue';
|
|
4
4
|
export { default as ASkeletonBlock } from './components/ASkeletonBlock.vue';
|
|
5
|
+
export { default as ASkeletonClone } from './components/ASkeletonClone.vue';
|
|
5
6
|
export { StructuralSkeleton } from './components/StructuralSkeleton';
|
|
6
7
|
|
|
8
|
+
/* Style-capture engine — public surface for advanced consumers who want to
|
|
9
|
+
* roll their own capture/replay flow. */
|
|
10
|
+
export { captureSnapshot } from './utils/captureStyles';
|
|
11
|
+
export type {
|
|
12
|
+
CaptureSnapshot,
|
|
13
|
+
CapturedNode,
|
|
14
|
+
TextLineRect,
|
|
15
|
+
CaptureOptions,
|
|
16
|
+
} from './utils/captureStyles';
|
|
17
|
+
|
|
18
|
+
/* Variant primitives — Vuetify/shadcn/Flowbite-style preset skeletons.
|
|
19
|
+
* Use these when auto-detect from a slot isn't right (or when you want a
|
|
20
|
+
* standalone skeleton without wrapping real content). Each is a thin SFC
|
|
21
|
+
* on top of `.a-skel` + variant CSS classes. */
|
|
22
|
+
export { default as ASkeletonText } from './components/variants/ASkeletonText.vue';
|
|
23
|
+
export { default as ASkeletonHeading } from './components/variants/ASkeletonHeading.vue';
|
|
24
|
+
export { default as ASkeletonAvatar } from './components/variants/ASkeletonAvatar.vue';
|
|
25
|
+
export { default as ASkeletonImage } from './components/variants/ASkeletonImage.vue';
|
|
26
|
+
export { default as ASkeletonVideo } from './components/variants/ASkeletonVideo.vue';
|
|
27
|
+
export { default as ASkeletonButton } from './components/variants/ASkeletonButton.vue';
|
|
28
|
+
export { default as ASkeletonInput } from './components/variants/ASkeletonInput.vue';
|
|
29
|
+
export { default as ASkeletonListItem } from './components/variants/ASkeletonListItem.vue';
|
|
30
|
+
export { default as ASkeletonCard } from './components/variants/ASkeletonCard.vue';
|
|
31
|
+
export { default as ASkeletonTable } from './components/variants/ASkeletonTable.vue';
|
|
32
|
+
export { default as ASkeletonChart } from './components/variants/ASkeletonChart.vue';
|
|
33
|
+
export { default as ASkeletonForm } from './components/variants/ASkeletonForm.vue';
|
|
34
|
+
export { default as ASkeletonArticle } from './components/variants/ASkeletonArticle.vue';
|
|
35
|
+
export { default as ASkeletonDivider } from './components/variants/ASkeletonDivider.vue';
|
|
36
|
+
export { default as ASkeletonChip } from './components/variants/ASkeletonChip.vue';
|
|
37
|
+
|
|
7
38
|
/* Composables */
|
|
8
39
|
export { useSkeleton } from './composables/useSkeleton';
|
|
9
40
|
export { useShapeProbe } from './composables/useShapeProbe';
|
|
10
|
-
export {
|
|
41
|
+
export {
|
|
42
|
+
getCached,
|
|
43
|
+
setCached,
|
|
44
|
+
clearCached,
|
|
45
|
+
getCachedStructural,
|
|
46
|
+
setCachedStructural,
|
|
47
|
+
clearCachedStructural,
|
|
48
|
+
} from './composables/useSkeletonCache';
|
|
11
49
|
|
|
12
50
|
/* Pure utilities — for direct measurement, vnode-walking, default key derivation. */
|
|
13
51
|
export { walkDom } from './utils/walkDom';
|
|
52
|
+
export { walkStructural } from './utils/walkStructural';
|
|
14
53
|
export { buildStructuralSkeleton } from './utils/buildStructuralSkeleton';
|
|
15
54
|
export { fingerprintSlot } from './utils/fingerprint';
|
|
16
55
|
|
|
@@ -25,8 +64,15 @@ export type {
|
|
|
25
64
|
ShapeNodeType,
|
|
26
65
|
SkeletonAnimation,
|
|
27
66
|
SkeletonFallback,
|
|
67
|
+
StructuralShape,
|
|
68
|
+
StructuralNode,
|
|
69
|
+
ContainerNode,
|
|
70
|
+
LeafNode,
|
|
71
|
+
LeafKind,
|
|
72
|
+
StructuralTextLineRect,
|
|
28
73
|
} from './types';
|
|
29
74
|
export type { UseSkeletonOptions, UseSkeletonReturn } from './composables/useSkeleton';
|
|
30
|
-
export type { ShapeProbeOptions } from './composables/useShapeProbe';
|
|
75
|
+
export type { ShapeProbeOptions, CaptureStrategy, ProbeShape } from './composables/useShapeProbe';
|
|
31
76
|
export type { WalkOptions } from './utils/walkDom';
|
|
77
|
+
export type { WalkStructuralOptions } from './utils/walkStructural';
|
|
32
78
|
export type { BuildOptions } from './utils/buildStructuralSkeleton';
|
package/src/nuxt/index.ts
CHANGED
|
@@ -25,6 +25,22 @@ const COMPONENTS: Record<string, string> = {
|
|
|
25
25
|
ASkeleton: '@alikhalilll/a-skeleton',
|
|
26
26
|
ASkeletonLayer: '@alikhalilll/a-skeleton',
|
|
27
27
|
ASkeletonBlock: '@alikhalilll/a-skeleton',
|
|
28
|
+
/* Variant primitives */
|
|
29
|
+
ASkeletonText: '@alikhalilll/a-skeleton',
|
|
30
|
+
ASkeletonHeading: '@alikhalilll/a-skeleton',
|
|
31
|
+
ASkeletonAvatar: '@alikhalilll/a-skeleton',
|
|
32
|
+
ASkeletonImage: '@alikhalilll/a-skeleton',
|
|
33
|
+
ASkeletonVideo: '@alikhalilll/a-skeleton',
|
|
34
|
+
ASkeletonButton: '@alikhalilll/a-skeleton',
|
|
35
|
+
ASkeletonInput: '@alikhalilll/a-skeleton',
|
|
36
|
+
ASkeletonListItem: '@alikhalilll/a-skeleton',
|
|
37
|
+
ASkeletonCard: '@alikhalilll/a-skeleton',
|
|
38
|
+
ASkeletonTable: '@alikhalilll/a-skeleton',
|
|
39
|
+
ASkeletonChart: '@alikhalilll/a-skeleton',
|
|
40
|
+
ASkeletonForm: '@alikhalilll/a-skeleton',
|
|
41
|
+
ASkeletonArticle: '@alikhalilll/a-skeleton',
|
|
42
|
+
ASkeletonDivider: '@alikhalilll/a-skeleton',
|
|
43
|
+
ASkeletonChip: '@alikhalilll/a-skeleton',
|
|
28
44
|
};
|
|
29
45
|
|
|
30
46
|
const module: NuxtModule<ModuleOptions> = defineNuxtModule<ModuleOptions>({
|
package/src/resolver/index.ts
CHANGED
|
@@ -20,6 +20,22 @@ const COMPONENT_TO_ENTRY: Record<string, string> = {
|
|
|
20
20
|
ASkeleton: '@alikhalilll/a-skeleton',
|
|
21
21
|
ASkeletonLayer: '@alikhalilll/a-skeleton',
|
|
22
22
|
ASkeletonBlock: '@alikhalilll/a-skeleton',
|
|
23
|
+
/* Variant primitives */
|
|
24
|
+
ASkeletonText: '@alikhalilll/a-skeleton',
|
|
25
|
+
ASkeletonHeading: '@alikhalilll/a-skeleton',
|
|
26
|
+
ASkeletonAvatar: '@alikhalilll/a-skeleton',
|
|
27
|
+
ASkeletonImage: '@alikhalilll/a-skeleton',
|
|
28
|
+
ASkeletonVideo: '@alikhalilll/a-skeleton',
|
|
29
|
+
ASkeletonButton: '@alikhalilll/a-skeleton',
|
|
30
|
+
ASkeletonInput: '@alikhalilll/a-skeleton',
|
|
31
|
+
ASkeletonListItem: '@alikhalilll/a-skeleton',
|
|
32
|
+
ASkeletonCard: '@alikhalilll/a-skeleton',
|
|
33
|
+
ASkeletonTable: '@alikhalilll/a-skeleton',
|
|
34
|
+
ASkeletonChart: '@alikhalilll/a-skeleton',
|
|
35
|
+
ASkeletonForm: '@alikhalilll/a-skeleton',
|
|
36
|
+
ASkeletonArticle: '@alikhalilll/a-skeleton',
|
|
37
|
+
ASkeletonDivider: '@alikhalilll/a-skeleton',
|
|
38
|
+
ASkeletonChip: '@alikhalilll/a-skeleton',
|
|
23
39
|
};
|
|
24
40
|
|
|
25
41
|
export default function ASkeletonResolver(opts: ResolverOptions = {}): ComponentResolver {
|
package/src/types.ts
CHANGED
|
@@ -2,6 +2,14 @@ import type { CSSProperties, HTMLAttributes } from 'vue';
|
|
|
2
2
|
|
|
3
3
|
export type ShapeNodeType = 'block' | 'text' | 'image' | 'circle';
|
|
4
4
|
|
|
5
|
+
/** A single per-line text rect captured via the `Range` API — root-relative. */
|
|
6
|
+
export interface TextLineRect {
|
|
7
|
+
x: number;
|
|
8
|
+
y: number;
|
|
9
|
+
w: number;
|
|
10
|
+
h: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
5
13
|
/** A single shimmer block in a captured skeleton — positioned absolutely inside the layer. */
|
|
6
14
|
export interface ShapeNode {
|
|
7
15
|
type: ShapeNodeType;
|
|
@@ -14,6 +22,23 @@ export interface ShapeNode {
|
|
|
14
22
|
lines?: number;
|
|
15
23
|
/** Line-height used when expanding `lines` into stacked bars. */
|
|
16
24
|
lineHeight?: number;
|
|
25
|
+
/**
|
|
26
|
+
* Per-line text rects captured via `Range.getClientRects()` — when present, each
|
|
27
|
+
* rendered text line gets its own bar at the exact position and width of the
|
|
28
|
+
* actual rendered text, so wrapped/centered/RTL/short-last-line all replay correctly
|
|
29
|
+
* without heuristics. Supersedes `lines` + `lineHeight` for text nodes.
|
|
30
|
+
*/
|
|
31
|
+
textRects?: ReadonlyArray<TextLineRect>;
|
|
32
|
+
/** Background colour captured via `getComputedStyle()` — applied inline so e.g. a white button stays white. */
|
|
33
|
+
bg?: string;
|
|
34
|
+
/** Shorthand border (`"<width>px solid <color>"`) — captured when the element has a visible border. */
|
|
35
|
+
border?: string;
|
|
36
|
+
/** Box-shadow captured verbatim when non-trivial — preserves elevation on cards. */
|
|
37
|
+
boxShadow?: string;
|
|
38
|
+
/** Element opacity when < 1 — captured so semi-transparent surfaces replay correctly. */
|
|
39
|
+
opacity?: number;
|
|
40
|
+
/** Resolved `text-align` (only meaningful for `type='text'`). */
|
|
41
|
+
textAlign?: 'left' | 'right' | 'center' | 'justify' | 'start' | 'end';
|
|
17
42
|
/**
|
|
18
43
|
* Pre-computed (and frozen) inline style for the block. Built once during
|
|
19
44
|
* capture so the render path never allocates a style object per node per
|
|
@@ -35,16 +60,104 @@ export interface CachedShape {
|
|
|
35
60
|
truncated?: boolean;
|
|
36
61
|
}
|
|
37
62
|
|
|
63
|
+
/* ─────────────────────────────────────────────────────────────────────────────
|
|
64
|
+
* Structural skeleton — tree-shaped capture used by Recipe 3
|
|
65
|
+
* (`useSkeleton()` + `<ASkeletonLayer>`).
|
|
66
|
+
*
|
|
67
|
+
* Differs from `CachedShape` in two ways: the output is a *tree* rather than a
|
|
68
|
+
* flat list, and each container preserves its original `class` + a captured
|
|
69
|
+
* subset of layout-affecting CSS so the skeleton flows through the real DOM's
|
|
70
|
+
* flex/grid/spacing rules instead of being absolutely positioned to fixed
|
|
71
|
+
* coordinates.
|
|
72
|
+
*
|
|
73
|
+
* Leaves carry their own width/height/border-radius + visual signals inline
|
|
74
|
+
* and render as `<div class="a-skel">` placeholders that sit in whatever space
|
|
75
|
+
* their parent's layout reserves for them.
|
|
76
|
+
* ───────────────────────────────────────────────────────────────────────────── */
|
|
77
|
+
|
|
78
|
+
/** Per-line text rect on a text leaf — coordinates are leaf-relative. */
|
|
79
|
+
export interface StructuralTextLineRect {
|
|
80
|
+
x: number;
|
|
81
|
+
y: number;
|
|
82
|
+
w: number;
|
|
83
|
+
h: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type StructuralNode = ContainerNode | LeafNode;
|
|
87
|
+
|
|
88
|
+
export interface ContainerNode {
|
|
89
|
+
kind: 'container';
|
|
90
|
+
/** Lowercased tag — preserved so semantic CSS / a11y still applies. */
|
|
91
|
+
tag: string;
|
|
92
|
+
/** Original `class` attribute, verbatim. Empty string when the element has no class. */
|
|
93
|
+
className: string;
|
|
94
|
+
/** Captured layout + visual CSS (frozen). Layout props belong on containers; visual signals optional. */
|
|
95
|
+
style: Readonly<CSSProperties>;
|
|
96
|
+
children: ReadonlyArray<StructuralNode>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type LeafKind = 'block' | 'text' | 'image' | 'media';
|
|
100
|
+
|
|
101
|
+
export interface LeafNode {
|
|
102
|
+
kind: 'leaf';
|
|
103
|
+
leafKind: LeafKind;
|
|
104
|
+
/** Original `class` attribute of the real element, verbatim. Empty string when absent. */
|
|
105
|
+
className: string;
|
|
106
|
+
/**
|
|
107
|
+
* Captured inline style: width, height, plus the same comprehensive
|
|
108
|
+
* layout + visual capture used on containers (display, margin, padding,
|
|
109
|
+
* border, background, transform, …). Frozen.
|
|
110
|
+
*/
|
|
111
|
+
style: Readonly<CSSProperties>;
|
|
112
|
+
/**
|
|
113
|
+
* Per-line rects for text leaves (`leafKind === 'text'`), captured via
|
|
114
|
+
* `Range.getClientRects()`. Coordinates are relative to the leaf's own box,
|
|
115
|
+
* so the renderer can lay them out with `position: absolute` inside a
|
|
116
|
+
* normal-flow text host.
|
|
117
|
+
*/
|
|
118
|
+
textLines?: ReadonlyArray<StructuralTextLineRect>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface StructuralShape {
|
|
122
|
+
/** Captured root rect — used by consumers for sanity checks; not for replay sizing. */
|
|
123
|
+
width: number;
|
|
124
|
+
height: number;
|
|
125
|
+
nodes: ReadonlyArray<StructuralNode>;
|
|
126
|
+
/** Was the walk cut short by `maxNodes`? */
|
|
127
|
+
truncated: boolean;
|
|
128
|
+
/** ms since epoch — useful for cache invalidation policies. */
|
|
129
|
+
capturedAt: number;
|
|
130
|
+
/** Persisted-shape schema version. Currently 3. */
|
|
131
|
+
v: 3;
|
|
132
|
+
}
|
|
133
|
+
|
|
38
134
|
export type SkeletonAnimation = 'shimmer' | 'pulse' | 'none';
|
|
39
135
|
export type SkeletonFallback = 'shimmer' | 'block';
|
|
40
136
|
|
|
137
|
+
export type ASkeletonMode = 'mirror' | 'clone';
|
|
138
|
+
|
|
41
139
|
export interface ASkeletonProps {
|
|
42
140
|
/** When true, render the captured skeleton (or `fallback` slot) instead of the default slot. */
|
|
43
141
|
loading: boolean;
|
|
44
142
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
143
|
+
* Rendering strategy. Default `'mirror'`.
|
|
144
|
+
* - `'mirror'`: walks the slot's vnode tree and preserves every element
|
|
145
|
+
* with its real class / inline style. Pure-Vue, SSR-safe, no DOM read.
|
|
146
|
+
* - `'clone'`: mounts the slot off-screen once, takes a comprehensive
|
|
147
|
+
* `getComputedStyle()` snapshot of every leaf, then replays the
|
|
148
|
+
* snapshot as positioned divs carrying every captured CSS property.
|
|
149
|
+
* Pixel-identical to the real render, regardless of styling system
|
|
150
|
+
* (Tailwind, CSS-in-JS, DaisyUI, computed inline styles). Requires
|
|
151
|
+
* a DOM (client-side only).
|
|
152
|
+
*/
|
|
153
|
+
mode?: ASkeletonMode;
|
|
154
|
+
/**
|
|
155
|
+
* Identifier used to look up + persist the captured shape. Defaults to
|
|
156
|
+
* `"<slot-fingerprint>:<useId()>"` so every `<ASkeleton>` instance gets its
|
|
157
|
+
* own cache slot automatically — two `<ASkeleton><UserCard/></ASkeleton>` on
|
|
158
|
+
* the same page never collide. Pass explicitly when you *want* multiple
|
|
159
|
+
* instances to share a captured shape (e.g. a list of identical cards), or
|
|
160
|
+
* when one instance renders different shapes depending on a prop.
|
|
48
161
|
*/
|
|
49
162
|
cacheKey?: string;
|
|
50
163
|
/** Max recursion depth during shape capture. Default 6. */
|
|
@@ -79,8 +192,14 @@ export interface ASkeletonSlots {
|
|
|
79
192
|
}
|
|
80
193
|
|
|
81
194
|
export interface ASkeletonLayerProps {
|
|
82
|
-
/**
|
|
83
|
-
|
|
195
|
+
/**
|
|
196
|
+
* Structural shape captured by `useSkeleton()` (Recipe 3) or `walkStructural()`
|
|
197
|
+
* directly. Renders nothing when `undefined`. The layer drops into the
|
|
198
|
+
* consumer's container as a transparent shell — captured containers preserve
|
|
199
|
+
* their tag/class/layout so the skeleton flows naturally inside the
|
|
200
|
+
* surrounding layout rather than being absolutely positioned.
|
|
201
|
+
*/
|
|
202
|
+
shape?: StructuralShape;
|
|
84
203
|
/** Animation variant. Default `'shimmer'`. */
|
|
85
204
|
animation?: SkeletonAnimation;
|
|
86
205
|
/** Class on the layer wrapper. */
|