@alikhalilll/a-skeleton 1.1.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.
Files changed (53) hide show
  1. package/.media/hero.png +0 -0
  2. package/.media/hero.svg +232 -0
  3. package/README.md +458 -172
  4. package/dist/index.cjs +3685 -840
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +527 -40
  7. package/dist/index.d.ts +527 -40
  8. package/dist/index.js +3666 -842
  9. package/dist/index.js.map +1 -1
  10. package/dist/nuxt/index.cjs +16 -1
  11. package/dist/nuxt/index.cjs.map +1 -1
  12. package/dist/nuxt/index.js +16 -1
  13. package/dist/nuxt/index.js.map +1 -1
  14. package/dist/resolver/index.cjs +16 -1
  15. package/dist/resolver/index.cjs.map +1 -1
  16. package/dist/resolver/index.js +16 -1
  17. package/dist/resolver/index.js.map +1 -1
  18. package/dist/styles.css +56 -11
  19. package/package.json +8 -2
  20. package/src/components/ASkeleton.vue +212 -113
  21. package/src/components/ASkeletonClone.vue +106 -0
  22. package/src/components/ASkeletonLayer.vue +20 -32
  23. package/src/components/CloneNode.ts +161 -0
  24. package/src/components/StructuralLayerNode.ts +157 -0
  25. package/src/components/icons.ts +45 -0
  26. package/src/components/variants/ASkeletonArticle.vue +33 -0
  27. package/src/components/variants/ASkeletonAvatar.vue +42 -0
  28. package/src/components/variants/ASkeletonButton.vue +37 -0
  29. package/src/components/variants/ASkeletonCard.vue +47 -0
  30. package/src/components/variants/ASkeletonChart.vue +56 -0
  31. package/src/components/variants/ASkeletonChip.vue +32 -0
  32. package/src/components/variants/ASkeletonDivider.vue +26 -0
  33. package/src/components/variants/ASkeletonForm.vue +32 -0
  34. package/src/components/variants/ASkeletonHeading.vue +47 -0
  35. package/src/components/variants/ASkeletonImage.vue +57 -0
  36. package/src/components/variants/ASkeletonInput.vue +33 -0
  37. package/src/components/variants/ASkeletonListItem.vue +40 -0
  38. package/src/components/variants/ASkeletonTable.vue +49 -0
  39. package/src/components/variants/ASkeletonText.vue +49 -0
  40. package/src/components/variants/ASkeletonVideo.vue +55 -0
  41. package/src/composables/useShapeProbe.ts +33 -9
  42. package/src/composables/useSkeleton.ts +33 -21
  43. package/src/composables/useSkeletonCache.ts +251 -22
  44. package/src/index.ts +48 -2
  45. package/src/nuxt/index.ts +16 -0
  46. package/src/resolver/index.ts +16 -0
  47. package/src/types.ts +118 -2
  48. package/src/utils/buildStructuralSkeleton.ts +400 -103
  49. package/src/utils/captureStyles.ts +378 -0
  50. package/src/utils/domRead.ts +143 -0
  51. package/src/utils/walkDom.ts +261 -16
  52. package/src/utils/walkStructural.ts +418 -0
  53. package/web-types.json +9 -3
@@ -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 style: CSSProperties = Object.freeze({
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
- if (node.type === 'text' && node.lines && node.lines > 1) {
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 = `${node.w}px`;
195
- const widthLast = `${Math.max(40, Math.round(node.w * 0.7))}px`;
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
- arr.push(
202
- Object.freeze<CSSProperties>({
203
- left: `${node.x}px`,
204
- top: `${node.y + (i - 1) * lh}px`,
205
- width: isLast ? widthLast : widthFull,
206
- height: heightStr,
207
- borderRadius: radiusStr,
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
+ }