@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.
- package/.media/hero.png +0 -0
- package/.media/hero.svg +232 -0
- package/README.md +458 -172
- package/dist/index.cjs +3685 -840
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +527 -40
- package/dist/index.d.ts +527 -40
- package/dist/index.js +3666 -842
- 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 +212 -113
- 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 +251 -22
- package/src/index.ts +48 -2
- package/src/nuxt/index.ts +16 -0
- package/src/resolver/index.ts +16 -0
- package/src/types.ts +118 -2
- 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 +9 -3
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comprehensive style-capture engine.
|
|
3
|
+
*
|
|
4
|
+
* Walks the slot's rendered DOM after mount and captures **every visible CSS
|
|
5
|
+
* property** of every element it encounters, plus geometry, into a frozen
|
|
6
|
+
* snapshot tree. The snapshot is replayed by `<ASkeletonClone>` as a tree
|
|
7
|
+
* of positioned divs each carrying its captured inline style.
|
|
8
|
+
*
|
|
9
|
+
* Why this exists (vs the DOM-mirror walker in `buildStructuralSkeleton.ts`):
|
|
10
|
+
*
|
|
11
|
+
* - DOM-mirror preserves the slot's vnode tree and inherits styling from the
|
|
12
|
+
* consumer's `class` / inline `style` attributes. That works for *static*
|
|
13
|
+
* styles, but it can't see styles applied via:
|
|
14
|
+
* - JavaScript-set inline style (refs, watchers, animation libs)
|
|
15
|
+
* - CSS-in-JS runtimes that hash class names per instance
|
|
16
|
+
* - DaisyUI / shadcn / custom CSS where the computed result differs
|
|
17
|
+
* from what the class string implies
|
|
18
|
+
* - Scoped styles compiled with content-hash data attributes
|
|
19
|
+
*
|
|
20
|
+
* - `captureSnapshot` reads `getComputedStyle()` for each element — the
|
|
21
|
+
* **final** computed style after the cascade has resolved. Replaying that
|
|
22
|
+
* produces a pixel-identical surface no matter what styling system the
|
|
23
|
+
* consumer uses.
|
|
24
|
+
*
|
|
25
|
+
* Cost is bounded by the same `maxNodes` / `maxDepth` / `minSize` filters
|
|
26
|
+
* as the legacy `walkDom` capture. The whole pass is a single top-down read
|
|
27
|
+
* with no DOM writes between, so the browser does one layout up front.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
captureTextLines,
|
|
32
|
+
collectVisibleChildren,
|
|
33
|
+
hasDirectText,
|
|
34
|
+
readComputedStyles,
|
|
35
|
+
type TextLineRect,
|
|
36
|
+
} from './domRead';
|
|
37
|
+
|
|
38
|
+
export type { TextLineRect };
|
|
39
|
+
|
|
40
|
+
/** A captured element — geometry + comprehensive style snapshot + children. */
|
|
41
|
+
export interface CapturedNode {
|
|
42
|
+
/** Tag name lowercased — used by the replay to decide content-type treatment. */
|
|
43
|
+
tag: string;
|
|
44
|
+
/** Root-relative position + size in CSS pixels. */
|
|
45
|
+
x: number;
|
|
46
|
+
y: number;
|
|
47
|
+
w: number;
|
|
48
|
+
h: number;
|
|
49
|
+
/**
|
|
50
|
+
* Frozen, ready-to-apply `style` object. Only non-default visual
|
|
51
|
+
* properties are included — the snapshot for a default `<div>` is tiny.
|
|
52
|
+
*/
|
|
53
|
+
style: Readonly<Record<string, string>>;
|
|
54
|
+
/**
|
|
55
|
+
* Content classification — drives how `<ASkeletonClone>` renders the leaf:
|
|
56
|
+
* - `text` → shimmer text bar (optionally with per-line rects)
|
|
57
|
+
* - `image` → solid surface with image-placeholder icon
|
|
58
|
+
* - `video` → solid surface with play-icon
|
|
59
|
+
* - `media` → atomic block (svg/canvas/iframe — no icon)
|
|
60
|
+
* - `block` → opaque shimmer block (default for unrecognised leaves)
|
|
61
|
+
* - `container` → has children; rendered as a positioned wrapper
|
|
62
|
+
*/
|
|
63
|
+
kind: 'text' | 'image' | 'video' | 'media' | 'block' | 'container';
|
|
64
|
+
/**
|
|
65
|
+
* Per-line text rects (only set when `kind === 'text'`). Replaces the
|
|
66
|
+
* single-rect bar with one bar per rendered text line at the exact width
|
|
67
|
+
* of that line — handles wrapping, RTL, centered headings 1:1.
|
|
68
|
+
*/
|
|
69
|
+
textLines?: ReadonlyArray<TextLineRect>;
|
|
70
|
+
/** Children (only set when `kind === 'container'`). */
|
|
71
|
+
children?: ReadonlyArray<CapturedNode>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface CaptureSnapshot {
|
|
75
|
+
/** Overall bounding box. */
|
|
76
|
+
width: number;
|
|
77
|
+
height: number;
|
|
78
|
+
/** Top-level captured nodes (siblings of the root's direct children). */
|
|
79
|
+
nodes: ReadonlyArray<CapturedNode>;
|
|
80
|
+
/** True if `maxNodes` was hit and the walk bailed out early. */
|
|
81
|
+
truncated: boolean;
|
|
82
|
+
/** When the snapshot was taken (ms since epoch). For cache invalidation policies. */
|
|
83
|
+
capturedAt: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface CaptureOptions {
|
|
87
|
+
/** Max recursion depth. Default 12. */
|
|
88
|
+
maxDepth?: number;
|
|
89
|
+
/** Hard cap on captured nodes. Default 800. */
|
|
90
|
+
maxNodes?: number;
|
|
91
|
+
/** Skip elements smaller than this many CSS pixels (either axis). Default 4. */
|
|
92
|
+
minSize?: number;
|
|
93
|
+
/**
|
|
94
|
+
* When true, capture child elements even inside leaves that we'd normally
|
|
95
|
+
* treat atomically. Default false (atomic = single block).
|
|
96
|
+
*/
|
|
97
|
+
walkAtomic?: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const DEFAULT_MAX_DEPTH = 12;
|
|
101
|
+
const DEFAULT_MAX_NODES = 800;
|
|
102
|
+
const DEFAULT_MIN_SIZE = 4;
|
|
103
|
+
|
|
104
|
+
/** Tags treated as atomic leaves — no recursion into their contents. */
|
|
105
|
+
const ATOMIC_TAGS = new Set([
|
|
106
|
+
'img',
|
|
107
|
+
'svg',
|
|
108
|
+
'canvas',
|
|
109
|
+
'video',
|
|
110
|
+
'audio',
|
|
111
|
+
'input',
|
|
112
|
+
'textarea',
|
|
113
|
+
'select',
|
|
114
|
+
'progress',
|
|
115
|
+
'meter',
|
|
116
|
+
'hr',
|
|
117
|
+
'iframe',
|
|
118
|
+
'object',
|
|
119
|
+
'embed',
|
|
120
|
+
'picture',
|
|
121
|
+
'br',
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
/** Tags whose content is text — captured as text bars (per-line via Range). */
|
|
125
|
+
const TEXT_OWNERS = new Set([
|
|
126
|
+
'p',
|
|
127
|
+
'h1',
|
|
128
|
+
'h2',
|
|
129
|
+
'h3',
|
|
130
|
+
'h4',
|
|
131
|
+
'h5',
|
|
132
|
+
'h6',
|
|
133
|
+
'span',
|
|
134
|
+
'a',
|
|
135
|
+
'em',
|
|
136
|
+
'strong',
|
|
137
|
+
'small',
|
|
138
|
+
'code',
|
|
139
|
+
'b',
|
|
140
|
+
'i',
|
|
141
|
+
'mark',
|
|
142
|
+
'label',
|
|
143
|
+
'caption',
|
|
144
|
+
'time',
|
|
145
|
+
'dt',
|
|
146
|
+
'dd',
|
|
147
|
+
'li',
|
|
148
|
+
'th',
|
|
149
|
+
'td',
|
|
150
|
+
'figcaption',
|
|
151
|
+
'blockquote',
|
|
152
|
+
'cite',
|
|
153
|
+
'q',
|
|
154
|
+
]);
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Visual CSS properties read from `getComputedStyle()`. Property listed here
|
|
158
|
+
* is included in the captured snapshot **only when its value differs from the
|
|
159
|
+
* skip set** — so a plain unstyled `<div>` produces an empty style object.
|
|
160
|
+
*
|
|
161
|
+
* Listed in the order they're written into the snapshot's `style` object so
|
|
162
|
+
* the replay is deterministic across captures.
|
|
163
|
+
*/
|
|
164
|
+
const VISUAL_PROPS = [
|
|
165
|
+
/* Box model — padding only (margin doesn't apply to absolute children). */
|
|
166
|
+
'padding-top',
|
|
167
|
+
'padding-right',
|
|
168
|
+
'padding-bottom',
|
|
169
|
+
'padding-left',
|
|
170
|
+
|
|
171
|
+
/* Border — per edge so non-uniform borders survive. */
|
|
172
|
+
'border-top-width',
|
|
173
|
+
'border-right-width',
|
|
174
|
+
'border-bottom-width',
|
|
175
|
+
'border-left-width',
|
|
176
|
+
'border-top-style',
|
|
177
|
+
'border-right-style',
|
|
178
|
+
'border-bottom-style',
|
|
179
|
+
'border-left-style',
|
|
180
|
+
'border-top-color',
|
|
181
|
+
'border-right-color',
|
|
182
|
+
'border-bottom-color',
|
|
183
|
+
'border-left-color',
|
|
184
|
+
'border-top-left-radius',
|
|
185
|
+
'border-top-right-radius',
|
|
186
|
+
'border-bottom-right-radius',
|
|
187
|
+
'border-bottom-left-radius',
|
|
188
|
+
|
|
189
|
+
/* Background. */
|
|
190
|
+
'background-color',
|
|
191
|
+
'background-image',
|
|
192
|
+
'background-position',
|
|
193
|
+
'background-size',
|
|
194
|
+
'background-repeat',
|
|
195
|
+
'background-origin',
|
|
196
|
+
'background-clip',
|
|
197
|
+
|
|
198
|
+
/* Effects. */
|
|
199
|
+
'box-shadow',
|
|
200
|
+
'opacity',
|
|
201
|
+
'filter',
|
|
202
|
+
'backdrop-filter',
|
|
203
|
+
'transform',
|
|
204
|
+
'transform-origin',
|
|
205
|
+
'mix-blend-mode',
|
|
206
|
+
|
|
207
|
+
/* Typography (only meaningful for text leaves but harmless to read for all). */
|
|
208
|
+
'font-family',
|
|
209
|
+
'font-size',
|
|
210
|
+
'font-weight',
|
|
211
|
+
'font-style',
|
|
212
|
+
'line-height',
|
|
213
|
+
'letter-spacing',
|
|
214
|
+
'text-align',
|
|
215
|
+
'text-transform',
|
|
216
|
+
'text-decoration-line',
|
|
217
|
+
'text-decoration-color',
|
|
218
|
+
];
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Snapshot the rendered DOM under `root`, returning a frozen tree of every
|
|
222
|
+
* visible element + its computed visual styles. Replaying the snapshot
|
|
223
|
+
* produces a surface visually identical to `root`.
|
|
224
|
+
*/
|
|
225
|
+
export function captureSnapshot(root: HTMLElement, options: CaptureOptions = {}): CaptureSnapshot {
|
|
226
|
+
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
227
|
+
const maxNodes = options.maxNodes ?? DEFAULT_MAX_NODES;
|
|
228
|
+
const minSize = options.minSize ?? DEFAULT_MIN_SIZE;
|
|
229
|
+
const walkAtomic = options.walkAtomic ?? false;
|
|
230
|
+
|
|
231
|
+
const rootRect = root.getBoundingClientRect();
|
|
232
|
+
const state = { count: 0, truncated: false };
|
|
233
|
+
const nodes: CapturedNode[] = [];
|
|
234
|
+
|
|
235
|
+
for (let i = 0; i < root.children.length; i++) {
|
|
236
|
+
if (state.count >= maxNodes) {
|
|
237
|
+
state.truncated = true;
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
const node = capture(root.children[i] as HTMLElement, rootRect, 1, {
|
|
241
|
+
maxDepth,
|
|
242
|
+
maxNodes,
|
|
243
|
+
minSize,
|
|
244
|
+
walkAtomic,
|
|
245
|
+
state,
|
|
246
|
+
});
|
|
247
|
+
if (node) nodes.push(node);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return Object.freeze({
|
|
251
|
+
width: Math.round(rootRect.width),
|
|
252
|
+
height: Math.round(rootRect.height),
|
|
253
|
+
nodes: Object.freeze(nodes),
|
|
254
|
+
truncated: state.truncated,
|
|
255
|
+
capturedAt: Date.now(),
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
interface InternalCtx {
|
|
260
|
+
maxDepth: number;
|
|
261
|
+
maxNodes: number;
|
|
262
|
+
minSize: number;
|
|
263
|
+
walkAtomic: boolean;
|
|
264
|
+
state: { count: number; truncated: boolean };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function capture(
|
|
268
|
+
el: HTMLElement,
|
|
269
|
+
origin: DOMRect,
|
|
270
|
+
depth: number,
|
|
271
|
+
ctx: InternalCtx
|
|
272
|
+
): CapturedNode | null {
|
|
273
|
+
if (ctx.state.count >= ctx.maxNodes) {
|
|
274
|
+
ctx.state.truncated = true;
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
if (el.dataset?.skeletonIgnore !== undefined) return null;
|
|
278
|
+
|
|
279
|
+
const cs = window.getComputedStyle(el);
|
|
280
|
+
if (cs.display === 'none' || cs.visibility === 'hidden') return null;
|
|
281
|
+
/* Opacity zero — skip (the element is invisible). */
|
|
282
|
+
const o = parseFloat(cs.opacity);
|
|
283
|
+
if (Number.isFinite(o) && o === 0) return null;
|
|
284
|
+
|
|
285
|
+
const rect = el.getBoundingClientRect();
|
|
286
|
+
const tag = el.tagName.toLowerCase();
|
|
287
|
+
const isTextOwnerTag = TEXT_OWNERS.has(tag);
|
|
288
|
+
|
|
289
|
+
/* Text-owner tags can have zero height when their interpolation is empty
|
|
290
|
+
* (e.g. `<h3>{{ data?.name }}</h3>` with `data === null`). The base
|
|
291
|
+
* `minSize` filter would drop them, leaving the heading invisible in the
|
|
292
|
+
* clone replay. For text-owners only, synthesize a height from the
|
|
293
|
+
* element's `line-height` (or `font-size * 1.4` as a fallback) so the
|
|
294
|
+
* fallback bar still renders at the heading's natural rendered height. */
|
|
295
|
+
let effectiveHeight = rect.height;
|
|
296
|
+
if (isTextOwnerTag && effectiveHeight < ctx.minSize) {
|
|
297
|
+
const lh = parseFloat(cs.lineHeight);
|
|
298
|
+
const fs = parseFloat(cs.fontSize);
|
|
299
|
+
if (Number.isFinite(lh) && lh > 0) effectiveHeight = lh;
|
|
300
|
+
else if (Number.isFinite(fs) && fs > 0) effectiveHeight = fs * 1.4;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (rect.width < ctx.minSize || effectiveHeight < ctx.minSize) return null;
|
|
304
|
+
|
|
305
|
+
const x = Math.round(rect.left - origin.left);
|
|
306
|
+
const y = Math.round(rect.top - origin.top);
|
|
307
|
+
const w = Math.round(rect.width);
|
|
308
|
+
const h = Math.round(effectiveHeight);
|
|
309
|
+
|
|
310
|
+
const style = readComputedStyles(cs, VISUAL_PROPS);
|
|
311
|
+
|
|
312
|
+
/* Classify the leaf — drives how `<ASkeletonClone>` renders this node. */
|
|
313
|
+
const isAtomic = ATOMIC_TAGS.has(tag);
|
|
314
|
+
const hasOwnText = hasDirectText(el);
|
|
315
|
+
const childrenEls = collectVisibleChildren(el);
|
|
316
|
+
|
|
317
|
+
let kind: CapturedNode['kind'];
|
|
318
|
+
|
|
319
|
+
if (tag === 'img' || tag === 'picture') {
|
|
320
|
+
kind = 'image';
|
|
321
|
+
} else if (tag === 'video') {
|
|
322
|
+
kind = 'video';
|
|
323
|
+
} else if (isAtomic) {
|
|
324
|
+
kind = 'media';
|
|
325
|
+
} else if (childrenEls.length === 0 && (hasOwnText || isTextOwnerTag)) {
|
|
326
|
+
/* Text-owner tags with no children (e.g. `<h3>{{ data?.name }}</h3>` with
|
|
327
|
+
* null data) classify as text even though their interpolation is empty —
|
|
328
|
+
* the renderer falls back to a single full-box bar at the element's
|
|
329
|
+
* natural rendered dimensions, so the heading shimmers at the right
|
|
330
|
+
* height instead of rendering as a generic block. */
|
|
331
|
+
kind = 'text';
|
|
332
|
+
} else if (childrenEls.length === 0) {
|
|
333
|
+
kind = 'block';
|
|
334
|
+
} else {
|
|
335
|
+
kind = 'container';
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/* For text leaves, capture per-line rects via Range API. */
|
|
339
|
+
let textLines: CapturedNode['textLines'];
|
|
340
|
+
if (kind === 'text') {
|
|
341
|
+
textLines = captureTextLines(el, origin);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/* Recurse into children for containers (and atomic if walkAtomic). */
|
|
345
|
+
let children: CapturedNode[] | undefined;
|
|
346
|
+
if (kind === 'container' && depth < ctx.maxDepth) {
|
|
347
|
+
children = [];
|
|
348
|
+
for (const c of childrenEls) {
|
|
349
|
+
if (ctx.state.count >= ctx.maxNodes) {
|
|
350
|
+
ctx.state.truncated = true;
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
const childNode = capture(c, origin, depth + 1, ctx);
|
|
354
|
+
if (childNode) children.push(childNode);
|
|
355
|
+
}
|
|
356
|
+
if (children.length === 0) {
|
|
357
|
+
/* All children filtered — degrade to block so the surface still renders. */
|
|
358
|
+
children = undefined;
|
|
359
|
+
kind = style && Object.keys(style).length > 0 ? 'block' : 'container';
|
|
360
|
+
}
|
|
361
|
+
} else if ((kind === 'container' && depth >= ctx.maxDepth) || (isAtomic && ctx.walkAtomic)) {
|
|
362
|
+
kind = 'block';
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
ctx.state.count++;
|
|
366
|
+
|
|
367
|
+
return Object.freeze({
|
|
368
|
+
tag,
|
|
369
|
+
x,
|
|
370
|
+
y,
|
|
371
|
+
w,
|
|
372
|
+
h,
|
|
373
|
+
style,
|
|
374
|
+
kind,
|
|
375
|
+
textLines: textLines ? Object.freeze(textLines) : undefined,
|
|
376
|
+
children: children ? Object.freeze(children) : undefined,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared DOM-read helpers used by both capture strategies:
|
|
3
|
+
* - `captureStyles.ts` (clone-mode comprehensive style snapshot)
|
|
4
|
+
* - `walkStructural.ts` (Recipe 3 tree-shaped capture)
|
|
5
|
+
*
|
|
6
|
+
* One source of truth for "what counts as a default CSS value", how to read a
|
|
7
|
+
* computed-style subset into a frozen camelCased object, and how to measure
|
|
8
|
+
* per-line text rectangles. Keeping both walkers behind these helpers prevents
|
|
9
|
+
* silent drift between the two pipelines.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A single rendered text-line rectangle, expressed in root-relative pixels. */
|
|
13
|
+
export interface TextLineRect {
|
|
14
|
+
x: number;
|
|
15
|
+
y: number;
|
|
16
|
+
w: number;
|
|
17
|
+
h: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Computed values that count as "default" and shouldn't be persisted. Stripped
|
|
22
|
+
* before a captured `style` object lands on a node so a plain unstyled element
|
|
23
|
+
* produces an empty style — keeps snapshots small and the rendered DOM clean.
|
|
24
|
+
*/
|
|
25
|
+
export const SKIP_VALUES: ReadonlySet<string> = new Set([
|
|
26
|
+
'none',
|
|
27
|
+
'normal',
|
|
28
|
+
'auto',
|
|
29
|
+
'0px',
|
|
30
|
+
'0',
|
|
31
|
+
'0 0',
|
|
32
|
+
'0% 0%',
|
|
33
|
+
'0px 0px',
|
|
34
|
+
'0deg',
|
|
35
|
+
'0s',
|
|
36
|
+
'visible',
|
|
37
|
+
'static',
|
|
38
|
+
'transparent',
|
|
39
|
+
'rgba(0, 0, 0, 0)',
|
|
40
|
+
'rgb(0, 0, 0, 0)',
|
|
41
|
+
'initial',
|
|
42
|
+
'inherit',
|
|
43
|
+
'unset',
|
|
44
|
+
'currentcolor',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Read the subset of `props` from `cs` and return a frozen camelCased style
|
|
49
|
+
* map with the SKIP_VALUES entries omitted. `opacity: 1` is treated as default
|
|
50
|
+
* (matches CSS' initial value).
|
|
51
|
+
*/
|
|
52
|
+
export function readComputedStyles(
|
|
53
|
+
cs: CSSStyleDeclaration,
|
|
54
|
+
props: ReadonlyArray<string>
|
|
55
|
+
): Readonly<Record<string, string>> {
|
|
56
|
+
const out: Record<string, string> = {};
|
|
57
|
+
for (const prop of props) {
|
|
58
|
+
const val = cs.getPropertyValue(prop).trim();
|
|
59
|
+
if (!val) continue;
|
|
60
|
+
if (SKIP_VALUES.has(val.toLowerCase())) continue;
|
|
61
|
+
if (prop === 'opacity' && (val === '1' || parseFloat(val) === 1)) continue;
|
|
62
|
+
out[camelCase(prop)] = val;
|
|
63
|
+
}
|
|
64
|
+
return Object.freeze(out);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function camelCase(prop: string): string {
|
|
68
|
+
return prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** True when the element has at least one non-whitespace direct text-node child. */
|
|
72
|
+
export function hasDirectText(el: Element): boolean {
|
|
73
|
+
for (let i = 0; i < el.childNodes.length; i++) {
|
|
74
|
+
const node = el.childNodes[i];
|
|
75
|
+
if (node.nodeType === Node.TEXT_NODE && (node.textContent ?? '').trim().length > 0) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Element children, with `data-skeleton-ignore` descendants filtered out. */
|
|
83
|
+
export function collectVisibleChildren(el: Element): HTMLElement[] {
|
|
84
|
+
const out: HTMLElement[] = [];
|
|
85
|
+
for (let i = 0; i < el.children.length; i++) {
|
|
86
|
+
const c = el.children[i] as HTMLElement;
|
|
87
|
+
if (c.dataset?.skeletonIgnore !== undefined) continue;
|
|
88
|
+
out.push(c);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Per-line text rects via `Range.getClientRects()`. Returns one rect per visual
|
|
95
|
+
* line — exact left/width for each line so wrapped paragraphs, RTL last-line
|
|
96
|
+
* positions, centered headings replay 1:1 without heuristics. Returns
|
|
97
|
+
* `undefined` if the element has no direct text or the Range API isn't usable.
|
|
98
|
+
*
|
|
99
|
+
* Two rects on the same baseline AND horizontally touching (gap ≤ 2 px) are
|
|
100
|
+
* merged so inline spans on the same line collapse to one bar. Same-baseline
|
|
101
|
+
* rects on different visual lines (rare; float-into-paragraph layouts) won't
|
|
102
|
+
* touch horizontally and stay separate.
|
|
103
|
+
*/
|
|
104
|
+
export function captureTextLines(el: Element, origin: DOMRect): TextLineRect[] | undefined {
|
|
105
|
+
if (typeof document === 'undefined' || typeof document.createRange !== 'function')
|
|
106
|
+
return undefined;
|
|
107
|
+
let range: Range;
|
|
108
|
+
try {
|
|
109
|
+
range = document.createRange();
|
|
110
|
+
range.selectNodeContents(el);
|
|
111
|
+
} catch {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
const rects = range.getClientRects();
|
|
115
|
+
if (!rects || rects.length === 0) return undefined;
|
|
116
|
+
|
|
117
|
+
const merged: TextLineRect[] = [];
|
|
118
|
+
for (let i = 0; i < rects.length; i++) {
|
|
119
|
+
const r = rects[i];
|
|
120
|
+
if (r.width <= 0 || r.height <= 0) continue;
|
|
121
|
+
const lr: TextLineRect = {
|
|
122
|
+
x: Math.round(r.left - origin.left),
|
|
123
|
+
y: Math.round(r.top - origin.top),
|
|
124
|
+
w: Math.round(r.width),
|
|
125
|
+
h: Math.round(r.height),
|
|
126
|
+
};
|
|
127
|
+
const last = merged[merged.length - 1];
|
|
128
|
+
const sameLine = last && Math.abs(last.y - lr.y) <= 1 && Math.abs(last.h - lr.h) <= 1;
|
|
129
|
+
/* Touching = gap between the trailing edge of `last` and the leading
|
|
130
|
+
* edge of `lr` is at most 2 px (one rounding slack per end). */
|
|
131
|
+
const touching =
|
|
132
|
+
sameLine && Math.max(last!.x, lr.x) - Math.min(last!.x + last!.w, lr.x + lr.w) <= 2;
|
|
133
|
+
if (touching) {
|
|
134
|
+
const leftEdge = Math.min(last!.x, lr.x);
|
|
135
|
+
const rightEdge = Math.max(last!.x + last!.w, lr.x + lr.w);
|
|
136
|
+
last!.x = leftEdge;
|
|
137
|
+
last!.w = rightEdge - leftEdge;
|
|
138
|
+
} else {
|
|
139
|
+
merged.push(lr);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return merged.length > 0 ? merged : undefined;
|
|
143
|
+
}
|