@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
package/src/utils/walkDom.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CSSProperties } from 'vue';
|
|
2
|
-
import type { CachedShape, ShapeNode, ShapeNodeType } from '../types';
|
|
2
|
+
import type { CachedShape, ShapeNode, ShapeNodeType, TextLineRect } from '../types';
|
|
3
3
|
|
|
4
4
|
export interface WalkOptions {
|
|
5
5
|
maxDepth: number;
|
|
@@ -78,11 +78,19 @@ export function walkDom(root: HTMLElement, options: WalkOptions): CachedShape {
|
|
|
78
78
|
const isLeaf = isLeafTag || hasStop || reachedDepth || childElements.length === 0;
|
|
79
79
|
|
|
80
80
|
if (isLeaf) {
|
|
81
|
-
const node = elementToShape(tag, cs, rect, rootRect, hasOwnText);
|
|
81
|
+
const node = elementToShape(el, tag, cs, rect, rootRect, hasOwnText);
|
|
82
82
|
if (node) nodes.push(node);
|
|
83
83
|
return;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/* Container with a visible surface (background / border / shadow / opacity)
|
|
87
|
+
* — emit a backing block at the container's exact rect BEFORE recursing, so
|
|
88
|
+
* children render on top of a card that keeps its identity. Without this,
|
|
89
|
+
* a `<div class="bg-white shadow-lg rounded-2xl">` wrapping content would
|
|
90
|
+
* vanish from the replay because the walker only ever recurses into it. */
|
|
91
|
+
const containerNode = containerSurfaceToShape(cs, rect, rootRect);
|
|
92
|
+
if (containerNode) nodes.push(containerNode);
|
|
93
|
+
|
|
86
94
|
for (let i = 0; i < childElements.length; i++) {
|
|
87
95
|
if (nodes.length >= maxNodes) {
|
|
88
96
|
truncated = true;
|
|
@@ -118,7 +126,40 @@ function hasDirectTextContent(el: Element): boolean {
|
|
|
118
126
|
return false;
|
|
119
127
|
}
|
|
120
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Emit a backing block for a container with its own visible surface — real
|
|
131
|
+
* background, border, box-shadow, or non-full opacity. Geometry is the
|
|
132
|
+
* container's exact bounding rect so the width / height / radius match the
|
|
133
|
+
* real DOM 1:1. Returns `null` when the container has no visible surface
|
|
134
|
+
* (a plain unstyled `<div>` is layout-only and shouldn't add a block).
|
|
135
|
+
*/
|
|
136
|
+
function containerSurfaceToShape(
|
|
137
|
+
cs: CSSStyleDeclaration,
|
|
138
|
+
rect: DOMRect,
|
|
139
|
+
origin: DOMRect
|
|
140
|
+
): ShapeNode | null {
|
|
141
|
+
const bg = readBackgroundColor(cs);
|
|
142
|
+
const border = readBorder(cs);
|
|
143
|
+
const boxShadow = readBoxShadow(cs);
|
|
144
|
+
const opacity = readOpacity(cs);
|
|
145
|
+
if (!bg && !border && !boxShadow && opacity === undefined) return null;
|
|
146
|
+
|
|
147
|
+
return freezeShape({
|
|
148
|
+
type: 'block',
|
|
149
|
+
x: Math.round(rect.left - origin.left),
|
|
150
|
+
y: Math.round(rect.top - origin.top),
|
|
151
|
+
w: Math.round(rect.width),
|
|
152
|
+
h: Math.round(rect.height),
|
|
153
|
+
radius: parseFloat(cs.borderRadius) || 0,
|
|
154
|
+
bg,
|
|
155
|
+
border,
|
|
156
|
+
boxShadow,
|
|
157
|
+
opacity,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
121
161
|
function elementToShape(
|
|
162
|
+
el: Element,
|
|
122
163
|
tag: string,
|
|
123
164
|
cs: CSSStyleDeclaration,
|
|
124
165
|
rect: DOMRect,
|
|
@@ -138,6 +179,8 @@ function elementToShape(
|
|
|
138
179
|
let resolvedRadius = radius;
|
|
139
180
|
let lines: number | undefined;
|
|
140
181
|
let lineHeight: number | undefined;
|
|
182
|
+
let textRects: TextLineRect[] | undefined;
|
|
183
|
+
let textAlign: ShapeNode['textAlign'];
|
|
141
184
|
|
|
142
185
|
if (tag === 'IMG' || tag === 'SVG' || tag === 'VIDEO' || tag === 'CANVAS') {
|
|
143
186
|
type = 'image';
|
|
@@ -149,10 +192,21 @@ function elementToShape(
|
|
|
149
192
|
lineHeight = Math.round(parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.4 || 16);
|
|
150
193
|
lines = Math.max(1, Math.round(h / lineHeight));
|
|
151
194
|
resolvedRadius = Math.min(radius, 4);
|
|
195
|
+
textAlign = readTextAlign(cs);
|
|
196
|
+
textRects = measureTextRects(el, origin);
|
|
152
197
|
} else {
|
|
153
198
|
type = 'block';
|
|
154
199
|
}
|
|
155
200
|
|
|
201
|
+
/* Style detectors — captured for every leaf so the replay carries the actual
|
|
202
|
+
* fill / border / shadow / opacity from the real DOM. Each captured value is
|
|
203
|
+
* "non-default": skip transparent backgrounds, zero-width borders, "none"
|
|
204
|
+
* shadows, opacity≥1 — so the persisted payload stays small. */
|
|
205
|
+
const bg = readBackgroundColor(cs);
|
|
206
|
+
const border = readBorder(cs);
|
|
207
|
+
const boxShadow = readBoxShadow(cs);
|
|
208
|
+
const opacity = readOpacity(cs);
|
|
209
|
+
|
|
156
210
|
return freezeShape({
|
|
157
211
|
type,
|
|
158
212
|
x,
|
|
@@ -162,12 +216,129 @@ function elementToShape(
|
|
|
162
216
|
radius: resolvedRadius,
|
|
163
217
|
lines,
|
|
164
218
|
lineHeight,
|
|
219
|
+
textRects,
|
|
220
|
+
bg,
|
|
221
|
+
border,
|
|
222
|
+
boxShadow,
|
|
223
|
+
opacity,
|
|
224
|
+
textAlign,
|
|
165
225
|
});
|
|
166
226
|
}
|
|
167
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Per-line text rects via `Range.getClientRects()`. Returns one rect per visual
|
|
230
|
+
* line of rendered text — exact left/width for each line so wrapped paragraphs,
|
|
231
|
+
* RTL last-line position, centered headings all replay 1:1 without heuristics.
|
|
232
|
+
* Returns `undefined` if the element has no direct text content or the Range
|
|
233
|
+
* API isn't usable in this environment.
|
|
234
|
+
*/
|
|
235
|
+
function measureTextRects(el: Element, origin: DOMRect): TextLineRect[] | undefined {
|
|
236
|
+
if (typeof document === 'undefined' || typeof document.createRange !== 'function')
|
|
237
|
+
return undefined;
|
|
238
|
+
let range: Range;
|
|
239
|
+
try {
|
|
240
|
+
range = document.createRange();
|
|
241
|
+
range.selectNodeContents(el);
|
|
242
|
+
} catch {
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
const rects = range.getClientRects();
|
|
246
|
+
if (!rects || rects.length === 0) return undefined;
|
|
247
|
+
/* De-duplicate rects that share the same baseline AND actually touch
|
|
248
|
+
* horizontally. Two inline spans on the same line emit two rects with
|
|
249
|
+
* identical y/h whose x ranges abut; merging them gives one bar that
|
|
250
|
+
* spans the whole rendered line. Two rects with the same y/h on
|
|
251
|
+
* different visual lines (rare, but possible with float-into-paragraph
|
|
252
|
+
* layouts) won't touch horizontally, so they stay separate.
|
|
253
|
+
*
|
|
254
|
+
* We keep zero-width rects out (they're real but invisible — collapsed
|
|
255
|
+
* whitespace at line breaks), but we don't filter sub-pixel `width`/
|
|
256
|
+
* `height` — those are legitimate (1-glyph symbol, etc). */
|
|
257
|
+
const merged: TextLineRect[] = [];
|
|
258
|
+
for (let i = 0; i < rects.length; i++) {
|
|
259
|
+
const r = rects[i];
|
|
260
|
+
if (r.width <= 0 || r.height <= 0) continue;
|
|
261
|
+
const lr: TextLineRect = {
|
|
262
|
+
x: Math.round(r.left - origin.left),
|
|
263
|
+
y: Math.round(r.top - origin.top),
|
|
264
|
+
w: Math.round(r.width),
|
|
265
|
+
h: Math.round(r.height),
|
|
266
|
+
};
|
|
267
|
+
const last = merged[merged.length - 1];
|
|
268
|
+
const sameLine = last && Math.abs(last.y - lr.y) <= 1 && Math.abs(last.h - lr.h) <= 1;
|
|
269
|
+
/* Touching = `gap` between the trailing edge of `last` and the leading
|
|
270
|
+
* edge of `lr` is at most 2 px (one rounding slack on each end). */
|
|
271
|
+
const touching =
|
|
272
|
+
sameLine && Math.max(last!.x, lr.x) - Math.min(last!.x + last!.w, lr.x + lr.w) <= 2;
|
|
273
|
+
if (touching) {
|
|
274
|
+
const leftEdge = Math.min(last!.x, lr.x);
|
|
275
|
+
const rightEdge = Math.max(last!.x + last!.w, lr.x + lr.w);
|
|
276
|
+
last!.x = leftEdge;
|
|
277
|
+
last!.w = rightEdge - leftEdge;
|
|
278
|
+
} else {
|
|
279
|
+
merged.push(lr);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return merged.length > 0 ? merged : undefined;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const TRANSPARENT_RE = /^(transparent|rgba?\([^)]*,\s*0(\.0+)?\s*\)|hsla?\([^)]*,\s*0%?\s*\))$/i;
|
|
286
|
+
|
|
287
|
+
function readBackgroundColor(cs: CSSStyleDeclaration): string | undefined {
|
|
288
|
+
const bg = cs.backgroundColor;
|
|
289
|
+
if (!bg || TRANSPARENT_RE.test(bg)) return undefined;
|
|
290
|
+
return bg;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function readBorder(cs: CSSStyleDeclaration): string | undefined {
|
|
294
|
+
/* Treat the top border as representative — uniform borders are the common case.
|
|
295
|
+
* Skip 0-width and `none` style. */
|
|
296
|
+
const width = parseFloat(cs.borderTopWidth) || 0;
|
|
297
|
+
if (width < 0.5) return undefined;
|
|
298
|
+
const style = cs.borderTopStyle;
|
|
299
|
+
if (!style || style === 'none' || style === 'hidden') return undefined;
|
|
300
|
+
const color = cs.borderTopColor;
|
|
301
|
+
if (!color || TRANSPARENT_RE.test(color)) return undefined;
|
|
302
|
+
return `${Math.round(width)}px ${style} ${color}`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function readBoxShadow(cs: CSSStyleDeclaration): string | undefined {
|
|
306
|
+
const sh = cs.boxShadow;
|
|
307
|
+
if (!sh || sh === 'none') return undefined;
|
|
308
|
+
return sh;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function readOpacity(cs: CSSStyleDeclaration): number | undefined {
|
|
312
|
+
const o = parseFloat(cs.opacity);
|
|
313
|
+
if (!Number.isFinite(o) || o >= 1) return undefined;
|
|
314
|
+
if (o <= 0) return undefined; /* fully transparent — caller already skipped these */
|
|
315
|
+
return Math.round(o * 100) / 100;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function readTextAlign(cs: CSSStyleDeclaration): ShapeNode['textAlign'] {
|
|
319
|
+
const ta = cs.textAlign as ShapeNode['textAlign'] | undefined;
|
|
320
|
+
if (!ta) return undefined;
|
|
321
|
+
if (
|
|
322
|
+
ta === 'left' ||
|
|
323
|
+
ta === 'right' ||
|
|
324
|
+
ta === 'center' ||
|
|
325
|
+
ta === 'justify' ||
|
|
326
|
+
ta === 'start' ||
|
|
327
|
+
ta === 'end'
|
|
328
|
+
) {
|
|
329
|
+
return ta;
|
|
330
|
+
}
|
|
331
|
+
return undefined;
|
|
332
|
+
}
|
|
333
|
+
|
|
168
334
|
/**
|
|
169
335
|
* Pre-compute (and freeze) the inline styles used at render time. Doing it once
|
|
170
336
|
* here means rendering 500 blocks doesn't allocate 500 style objects per frame.
|
|
337
|
+
*
|
|
338
|
+
* Captured visual signals (bg, border, shadow, opacity) are merged into the
|
|
339
|
+
* frozen style so the replay carries the real DOM's surface — a white button
|
|
340
|
+
* stays white, a ring-bordered button keeps its ring, a shadowed card keeps
|
|
341
|
+
* its elevation.
|
|
171
342
|
*/
|
|
172
343
|
function freezeShape(node: {
|
|
173
344
|
type: ShapeNodeType;
|
|
@@ -178,35 +349,73 @@ function freezeShape(node: {
|
|
|
178
349
|
radius: number;
|
|
179
350
|
lines?: number;
|
|
180
351
|
lineHeight?: number;
|
|
352
|
+
textRects?: TextLineRect[];
|
|
353
|
+
bg?: string;
|
|
354
|
+
border?: string;
|
|
355
|
+
boxShadow?: string;
|
|
356
|
+
opacity?: number;
|
|
357
|
+
textAlign?: ShapeNode['textAlign'];
|
|
181
358
|
}): ShapeNode {
|
|
182
|
-
const
|
|
359
|
+
const baseStyle: CSSProperties = {
|
|
183
360
|
left: `${node.x}px`,
|
|
184
361
|
top: `${node.y}px`,
|
|
185
362
|
width: `${node.w}px`,
|
|
186
363
|
height: `${node.h}px`,
|
|
187
364
|
borderRadius: `${node.radius}px`,
|
|
188
|
-
}
|
|
365
|
+
};
|
|
366
|
+
applyVisualSignals(baseStyle, node);
|
|
367
|
+
const style = Object.freeze(baseStyle);
|
|
189
368
|
|
|
190
369
|
let lineStyles: ReadonlyArray<Readonly<CSSProperties>> | undefined;
|
|
191
|
-
|
|
370
|
+
|
|
371
|
+
/* Range-based per-line capture — exact rendered geometry, no heuristics.
|
|
372
|
+
* Bars sit at the exact captured rect; no shrink/centre math, so dense
|
|
373
|
+
* paragraphs don't accumulate baseline drift. */
|
|
374
|
+
if (node.type === 'text' && node.textRects && node.textRects.length > 0) {
|
|
375
|
+
const radiusStr = `${node.radius}px`;
|
|
376
|
+
const arr: Readonly<CSSProperties>[] = [];
|
|
377
|
+
for (const r of node.textRects) {
|
|
378
|
+
const lineStyle: CSSProperties = {
|
|
379
|
+
left: `${r.x}px`,
|
|
380
|
+
top: `${r.y}px`,
|
|
381
|
+
width: `${r.w}px`,
|
|
382
|
+
height: `${r.h}px`,
|
|
383
|
+
borderRadius: radiusStr,
|
|
384
|
+
};
|
|
385
|
+
applyVisualSignals(lineStyle, node);
|
|
386
|
+
arr.push(Object.freeze(lineStyle));
|
|
387
|
+
}
|
|
388
|
+
lineStyles = Object.freeze(arr);
|
|
389
|
+
} else if (node.type === 'text' && node.lines && node.lines > 1) {
|
|
390
|
+
/* Fallback path (kept for shapes rehydrated from pre-2 captures): synthesize
|
|
391
|
+
* lines from `lines` + `lineHeight`. */
|
|
192
392
|
const lh = node.lineHeight ?? Math.round(node.h / node.lines);
|
|
193
393
|
const barHeight = Math.max(8, Math.round(lh * 0.7));
|
|
194
|
-
const widthFull =
|
|
195
|
-
const widthLast =
|
|
394
|
+
const widthFull = node.w;
|
|
395
|
+
const widthLast = Math.max(40, Math.round(node.w * 0.7));
|
|
196
396
|
const heightStr = `${barHeight}px`;
|
|
197
397
|
const radiusStr = `${node.radius}px`;
|
|
198
398
|
const arr: Readonly<CSSProperties>[] = [];
|
|
199
399
|
for (let i = 1; i <= node.lines; i++) {
|
|
200
400
|
const isLast = i === node.lines;
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
401
|
+
const lineWidth = isLast ? widthLast : widthFull;
|
|
402
|
+
/* Honour captured text-align so the short last line lands where the eye
|
|
403
|
+
* expects: right for RTL, centered for centered headings. */
|
|
404
|
+
let leftX = node.x;
|
|
405
|
+
if (isLast && node.textAlign) {
|
|
406
|
+
const slack = widthFull - lineWidth;
|
|
407
|
+
if (node.textAlign === 'center') leftX = node.x + Math.round(slack / 2);
|
|
408
|
+
else if (node.textAlign === 'right' || node.textAlign === 'end') leftX = node.x + slack;
|
|
409
|
+
}
|
|
410
|
+
const lineStyle: CSSProperties = {
|
|
411
|
+
left: `${leftX}px`,
|
|
412
|
+
top: `${node.y + (i - 1) * lh}px`,
|
|
413
|
+
width: `${lineWidth}px`,
|
|
414
|
+
height: heightStr,
|
|
415
|
+
borderRadius: radiusStr,
|
|
416
|
+
};
|
|
417
|
+
applyVisualSignals(lineStyle, node);
|
|
418
|
+
arr.push(Object.freeze(lineStyle));
|
|
210
419
|
}
|
|
211
420
|
lineStyles = Object.freeze(arr);
|
|
212
421
|
}
|
|
@@ -220,7 +429,43 @@ function freezeShape(node: {
|
|
|
220
429
|
radius: node.radius,
|
|
221
430
|
lines: node.lines,
|
|
222
431
|
lineHeight: node.lineHeight,
|
|
432
|
+
textRects: node.textRects ? Object.freeze(node.textRects) : undefined,
|
|
433
|
+
bg: node.bg,
|
|
434
|
+
border: node.border,
|
|
435
|
+
boxShadow: node.boxShadow,
|
|
436
|
+
opacity: node.opacity,
|
|
437
|
+
textAlign: node.textAlign,
|
|
223
438
|
style,
|
|
224
439
|
lineStyles,
|
|
225
440
|
});
|
|
226
441
|
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Merge captured surface signals into a style object in place. Each signal is
|
|
445
|
+
* additive; `bg` uses `background` shorthand so it wipes the default linear
|
|
446
|
+
* gradient on `.a-skel-block` cleanly.
|
|
447
|
+
*/
|
|
448
|
+
function applyVisualSignals(
|
|
449
|
+
out: CSSProperties,
|
|
450
|
+
node: {
|
|
451
|
+
bg?: string;
|
|
452
|
+
border?: string;
|
|
453
|
+
boxShadow?: string;
|
|
454
|
+
opacity?: number;
|
|
455
|
+
}
|
|
456
|
+
): void {
|
|
457
|
+
if (node.bg) {
|
|
458
|
+
/* Use `background` (shorthand) so the default `background-image` gradient
|
|
459
|
+
* from `.a-skel-block` is overridden, not stacked. */
|
|
460
|
+
out.background = node.bg;
|
|
461
|
+
}
|
|
462
|
+
if (node.border) {
|
|
463
|
+
out.border = node.border;
|
|
464
|
+
}
|
|
465
|
+
if (node.boxShadow) {
|
|
466
|
+
out.boxShadow = node.boxShadow;
|
|
467
|
+
}
|
|
468
|
+
if (node.opacity !== undefined) {
|
|
469
|
+
out.opacity = node.opacity;
|
|
470
|
+
}
|
|
471
|
+
}
|