@alikhalilll/a-smart-skeleton 0.0.0-stage → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +356 -2
- package/dist/_chunks/core.js +2314 -0
- package/dist/_chunks/core.js.map +1 -0
- package/dist/_chunks/index.d.ts +431 -0
- package/dist/core/index.cjs +67 -0
- package/dist/core/index.d.cts +2 -0
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +2 -0
- package/dist/element/index.cjs +47 -0
- package/dist/element/index.cjs.map +1 -0
- package/dist/element/index.d.cts +24 -0
- package/dist/element/index.d.ts +24 -0
- package/dist/element/index.js +46 -0
- package/dist/element/index.js.map +1 -0
- package/dist/index.cjs +512 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +204 -0
- package/dist/index.d.ts +204 -0
- package/dist/index.js +441 -0
- package/dist/index.js.map +1 -0
- package/dist/nuxt/index.cjs +36 -0
- package/dist/nuxt/index.cjs.map +1 -0
- package/dist/nuxt/index.d.cts +30 -0
- package/dist/nuxt/index.d.ts +30 -0
- package/dist/nuxt/index.js +36 -0
- package/dist/nuxt/index.js.map +1 -0
- package/dist/resolver/index.cjs +26 -0
- package/dist/resolver/index.cjs.map +1 -0
- package/dist/resolver/index.d.cts +19 -0
- package/dist/resolver/index.d.ts +19 -0
- package/dist/resolver/index.js +26 -0
- package/dist/resolver/index.js.map +1 -0
- package/dist/styles.css +136 -0
- package/package.json +144 -3
- package/src/components/ASmartSkeleton.vue +228 -0
- package/src/components/ASmartSkeletonBox.vue +23 -0
- package/src/components/ASmartSkeletonProvider.vue +34 -0
- package/src/components/ASmartSkeletonSuspense.vue +142 -0
- package/src/composables/useSmartSkeleton.ts +20 -0
- package/src/composables/useStyles.ts +8 -0
- package/src/core/boot.ts +63 -0
- package/src/core/canvas.ts +7 -0
- package/src/core/constants.ts +76 -0
- package/src/core/debug.ts +154 -0
- package/src/core/dev.ts +53 -0
- package/src/core/fade.ts +490 -0
- package/src/core/ghost.ts +236 -0
- package/src/core/images.ts +28 -0
- package/src/core/index.ts +71 -0
- package/src/core/inline.generated.ts +9 -0
- package/src/core/measure.ts +571 -0
- package/src/core/memory.ts +146 -0
- package/src/core/motion.ts +12 -0
- package/src/core/plan.ts +368 -0
- package/src/core/reveal.ts +89 -0
- package/src/core/reveals.ts +39 -0
- package/src/core/shapes.ts +84 -0
- package/src/core/shimmer.ts +229 -0
- package/src/core/skeleton.ts +43 -0
- package/src/core/snapshot.ts +159 -0
- package/src/core/styles.ts +187 -0
- package/src/core/sweep.ts +203 -0
- package/src/core/theme.ts +162 -0
- package/src/element/index.ts +59 -0
- package/src/index.ts +27 -0
- package/src/nuxt/index.ts +59 -0
- package/src/resolver/index.ts +35 -0
- package/src/types.ts +100 -0
- package/src/vue/Skeleton.ts +275 -0
- package/src/vue/Suspense.ts +157 -0
- package/src/vue/config.ts +78 -0
- package/src/vue/index.ts +7 -0
- package/web-types.json +256 -0
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
import { textContext } from './canvas';
|
|
2
|
+
import {
|
|
3
|
+
ATTR_BLOCK,
|
|
4
|
+
ATTR_COVERED,
|
|
5
|
+
ATTR_IGNORE,
|
|
6
|
+
ATTR_OWN,
|
|
7
|
+
MEDIA_TAGS,
|
|
8
|
+
TEXT_TAGS,
|
|
9
|
+
} from './constants';
|
|
10
|
+
|
|
11
|
+
/** Captured paint from a container — background / borders / box-shadow — reproduced on
|
|
12
|
+
* the chrome bone so the outline survives shape recall from storage, SSR pre-paint, and
|
|
13
|
+
* any path where the real DOM isn't visible under the layer. */
|
|
14
|
+
export type SkeletonChrome = {
|
|
15
|
+
background?: string;
|
|
16
|
+
borderTop?: string;
|
|
17
|
+
borderRight?: string;
|
|
18
|
+
borderBottom?: string;
|
|
19
|
+
borderLeft?: string;
|
|
20
|
+
boxShadow?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** A box to draw, relative to the layer it was measured against. */
|
|
24
|
+
export type SkeletonRect = {
|
|
25
|
+
/** Which kind of leaf this bone stands in for — exposed as `[ask-rect="<type>"]` so
|
|
26
|
+
* consumers can style text / media / box / chrome bones differently in their own CSS. */
|
|
27
|
+
type: 'text' | 'media' | 'box' | 'chrome';
|
|
28
|
+
x: number;
|
|
29
|
+
y: number;
|
|
30
|
+
width: number;
|
|
31
|
+
height: number;
|
|
32
|
+
radius: string;
|
|
33
|
+
/** Present on `type === 'chrome'` bones — the container's own paint, captured verbatim from `getComputedStyle`. */
|
|
34
|
+
chrome?: SkeletonChrome;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type Rect = { left: number; right: number; top: number; bottom: number };
|
|
38
|
+
|
|
39
|
+
// Corner radii in px: top left, top right, bottom right, bottom left
|
|
40
|
+
type Corners = [number, number, number, number];
|
|
41
|
+
|
|
42
|
+
// Overflow a box hides, which a bone must not paint past, with the corners it rounds; null while nothing clips
|
|
43
|
+
type Clip = (Rect & { corners: Corners }) | null;
|
|
44
|
+
|
|
45
|
+
const intersect = (clip: Clip, rect: Rect): Rect =>
|
|
46
|
+
clip
|
|
47
|
+
? {
|
|
48
|
+
left: Math.max(clip.left, rect.left),
|
|
49
|
+
right: Math.min(clip.right, rect.right),
|
|
50
|
+
top: Math.max(clip.top, rect.top),
|
|
51
|
+
bottom: Math.min(clip.bottom, rect.bottom),
|
|
52
|
+
}
|
|
53
|
+
: rect;
|
|
54
|
+
|
|
55
|
+
const cornersOf = (rect: Rect): [number, number][] => [
|
|
56
|
+
[rect.left, rect.top],
|
|
57
|
+
[rect.right, rect.top],
|
|
58
|
+
[rect.right, rect.bottom],
|
|
59
|
+
[rect.left, rect.bottom],
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const near = (a: [number, number], b: [number, number]) =>
|
|
63
|
+
Math.abs(a[0] - b[0]) < 1 && Math.abs(a[1] - b[1]) < 1;
|
|
64
|
+
|
|
65
|
+
function radiiOf(style: CSSStyleDeclaration, rect: Rect): Corners {
|
|
66
|
+
const size = Math.min(rect.right - rect.left, rect.bottom - rect.top);
|
|
67
|
+
const px = (value: string) =>
|
|
68
|
+
value.endsWith('%') ? (parseFloat(value) / 100) * size : parseFloat(value) || 0;
|
|
69
|
+
return [
|
|
70
|
+
px(style.borderTopLeftRadius),
|
|
71
|
+
px(style.borderTopRightRadius),
|
|
72
|
+
px(style.borderBottomRightRadius),
|
|
73
|
+
px(style.borderBottomLeftRadius),
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A clip within a clip: each corner rounds as whichever box's corner it is
|
|
78
|
+
function clipWithin(clip: Clip, rect: Rect, style: CSSStyleDeclaration): Clip {
|
|
79
|
+
const box = intersect(clip, rect);
|
|
80
|
+
const own = radiiOf(style, rect);
|
|
81
|
+
const outer = clip ? cornersOf(clip) : [];
|
|
82
|
+
const ownCorners = cornersOf(rect);
|
|
83
|
+
const corners = cornersOf(box).map((corner, i) =>
|
|
84
|
+
near(corner, ownCorners[i]!) ? own[i]! : clip && near(corner, outer[i]!) ? clip.corners[i]! : 0
|
|
85
|
+
) as Corners;
|
|
86
|
+
// Sides by name: a DOMRect's are getters, which spreading leaves behind
|
|
87
|
+
const { left, right, top, bottom } = box;
|
|
88
|
+
return { left, right, top, bottom, corners };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// A block in a rounded corner of what clips it is cut by that corner, so takes it
|
|
92
|
+
function roundedBy(clip: Clip, box: Rect, own: string, style: CSSStyleDeclaration) {
|
|
93
|
+
if (!clip) return own;
|
|
94
|
+
const clipCorners = cornersOf(clip);
|
|
95
|
+
const touching = cornersOf(box).map(
|
|
96
|
+
(corner, i) => clip.corners[i]! > 0 && near(corner, clipCorners[i]!)
|
|
97
|
+
);
|
|
98
|
+
if (!touching.some(Boolean)) return own;
|
|
99
|
+
const mine = radiiOf(style, box);
|
|
100
|
+
return touching
|
|
101
|
+
.map((touches, i) => (touches ? Math.max(mine[i]!, clip.corners[i]!) : mine[i]!))
|
|
102
|
+
.map((r) => `${r}px`)
|
|
103
|
+
.join(' ');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Clipped to a sliver, it isn't seen: a visually hidden label, say, cut to a 1px box
|
|
107
|
+
const clipped = (rect: Rect, clip: Clip): Rect | null => {
|
|
108
|
+
const box = intersect(clip, rect);
|
|
109
|
+
return box.right - box.left >= 2 && box.bottom - box.top >= 2 ? box : null;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
type Line = {
|
|
113
|
+
left: number;
|
|
114
|
+
right: number;
|
|
115
|
+
top: number;
|
|
116
|
+
bottom: number;
|
|
117
|
+
fontSize: number;
|
|
118
|
+
/** Where the bar goes, by the typography rules — within the line's box. */
|
|
119
|
+
barTop: number;
|
|
120
|
+
barBottom: number;
|
|
121
|
+
/** The theme's `--ask-radius`, if it sets one. */
|
|
122
|
+
radius: string;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** The font line each line of text is drawn from. */
|
|
126
|
+
export type TextTop = 'ascender' | 'cap-height' | 'x-height';
|
|
127
|
+
/** The font line each line of text is drawn down to. */
|
|
128
|
+
export type TextBottom = 'baseline' | 'descender';
|
|
129
|
+
|
|
130
|
+
export const DEFAULT_TEXT = {
|
|
131
|
+
top: 'ascender',
|
|
132
|
+
bottom: 'descender',
|
|
133
|
+
} as const satisfies { top: TextTop; bottom: TextBottom };
|
|
134
|
+
|
|
135
|
+
type Metrics = {
|
|
136
|
+
ascent: number; // the font's own box, above and below the baseline
|
|
137
|
+
descent: number;
|
|
138
|
+
ascender: number; // ink of b, d, h, l
|
|
139
|
+
capHeight: number;
|
|
140
|
+
xHeight: number;
|
|
141
|
+
descender: number; // ink of g, p, y
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const metricsByFont = new Map<string, Metrics | null>();
|
|
145
|
+
|
|
146
|
+
// A range has no layout in jsdom
|
|
147
|
+
const lineRects = (range: Range) => [...(range.getClientRects?.() ?? [])];
|
|
148
|
+
|
|
149
|
+
function metricsOf(style: CSSStyleDeclaration): Metrics | null {
|
|
150
|
+
const font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
|
|
151
|
+
if (metricsByFont.has(font)) return metricsByFont.get(font)!;
|
|
152
|
+
|
|
153
|
+
const context = textContext();
|
|
154
|
+
let metrics: Metrics | null = null;
|
|
155
|
+
if (context) {
|
|
156
|
+
context.font = font;
|
|
157
|
+
const box = context.measureText('H');
|
|
158
|
+
const up = (text: string) => context!.measureText(text).actualBoundingBoxAscent;
|
|
159
|
+
metrics = {
|
|
160
|
+
ascent: box.fontBoundingBoxAscent,
|
|
161
|
+
descent: box.fontBoundingBoxDescent,
|
|
162
|
+
ascender: up('bdfhkl'),
|
|
163
|
+
capHeight: box.actualBoundingBoxAscent,
|
|
164
|
+
xHeight: up('xz'),
|
|
165
|
+
descender: context.measureText('gjpqy').actualBoundingBoxDescent,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
metricsByFont.set(font, metrics);
|
|
169
|
+
return metrics;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const TOPS: Record<TextTop, keyof Metrics> = {
|
|
173
|
+
ascender: 'ascender',
|
|
174
|
+
'cap-height': 'capHeight',
|
|
175
|
+
'x-height': 'xHeight',
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const rule = <T extends string>(
|
|
179
|
+
style: CSSStyleDeclaration,
|
|
180
|
+
name: string,
|
|
181
|
+
allowed: readonly T[],
|
|
182
|
+
fallback: T
|
|
183
|
+
): T => {
|
|
184
|
+
const value = style.getPropertyValue(name).trim() as T;
|
|
185
|
+
return allowed.includes(value) ? value : fallback;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// Between the font lines `--ask-text-top` and `--ask-text-bottom` name, the baseline half-leading in
|
|
189
|
+
function barOf(rect: Rect, style: CSSStyleDeclaration, fontSize: number) {
|
|
190
|
+
const metrics = metricsOf(style);
|
|
191
|
+
if (!metrics) {
|
|
192
|
+
// No canvas to read the type with: a bar centred in the line
|
|
193
|
+
const height = Math.min(fontSize * 0.72, rect.bottom - rect.top);
|
|
194
|
+
const middle = (rect.top + rect.bottom) / 2;
|
|
195
|
+
return { barTop: middle - height / 2, barBottom: middle + height / 2 };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const top = rule(style, '--ask-text-top', Object.keys(TOPS) as TextTop[], DEFAULT_TEXT.top);
|
|
199
|
+
const bottom = rule(
|
|
200
|
+
style,
|
|
201
|
+
'--ask-text-bottom',
|
|
202
|
+
['baseline', 'descender'] as const,
|
|
203
|
+
DEFAULT_TEXT.bottom
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const box = metrics.ascent + metrics.descent;
|
|
207
|
+
const baseline = rect.top + (rect.bottom - rect.top - box) / 2 + metrics.ascent;
|
|
208
|
+
return {
|
|
209
|
+
barTop: baseline - metrics[TOPS[top]],
|
|
210
|
+
barBottom: baseline + (bottom === 'descender' ? metrics.descender : 0),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const MEDIA = new Set(MEDIA_TAGS);
|
|
215
|
+
const TEXT = new Set(TEXT_TAGS);
|
|
216
|
+
|
|
217
|
+
const LINE_GAP = 0.4; // of the font size — wider than a space, so one line of text stays one bar
|
|
218
|
+
|
|
219
|
+
// matches a zero alpha in both `rgba(0, 0, 0, 0)` and `color(srgb 0 0 0 / 0)`
|
|
220
|
+
const TRANSPARENT = /[,/]\s*0\)$/;
|
|
221
|
+
|
|
222
|
+
// Unset reads as "" outside a browser engine, which is no paint either
|
|
223
|
+
const isSet = (value: string, none: string) => value !== '' && value !== none;
|
|
224
|
+
|
|
225
|
+
// All the way round: a rule along one side divides, it doesn't make a box a chip
|
|
226
|
+
const SIDES = ['Top', 'Right', 'Bottom', 'Left'] as const;
|
|
227
|
+
const bordered = (style: CSSStyleDeclaration) =>
|
|
228
|
+
SIDES.every(
|
|
229
|
+
(side) =>
|
|
230
|
+
isSet(style[`border${side}Style`], 'none') && isSet(style[`border${side}Width`], '0px')
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
// Dividers (`border-t` on a footer, `border-b` on a heading) still need to show
|
|
234
|
+
// up in the skeleton, even though they don't make the container a solid block.
|
|
235
|
+
// This looser check feeds chrome capture; `bordered` above remains what
|
|
236
|
+
// `isPainted` reads so single-side borders keep their "divider" semantics for
|
|
237
|
+
// the isBlock classifier.
|
|
238
|
+
const anyBordered = (style: CSSStyleDeclaration) =>
|
|
239
|
+
SIDES.some(
|
|
240
|
+
(side) =>
|
|
241
|
+
isSet(style[`border${side}Style`], 'none') && isSet(style[`border${side}Width`], '0px')
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
// An inset hairline along one side, drawn as a shadow, is a divider like a one-sided border
|
|
245
|
+
const isRule = (shadow: string) => {
|
|
246
|
+
const [x = 0, y = 0, blur = 0, spread = 0] = (
|
|
247
|
+
shadow.replace(/rgba?\([^)]*\)|#\w+/g, '').match(/-?[\d.]+px/g) ?? []
|
|
248
|
+
).map(parseFloat);
|
|
249
|
+
return (
|
|
250
|
+
shadow.includes('inset') && !blur && !spread && (x === 0) !== (y === 0) && Math.abs(x + y) <= 2
|
|
251
|
+
);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const shadowPaints = (boxShadow: string) =>
|
|
255
|
+
isSet(boxShadow, 'none') && !boxShadow.split(/,(?![^(]*\))/).every(isRule);
|
|
256
|
+
|
|
257
|
+
/** Splits `background-image` on its top-level commas (respecting nested parens, which
|
|
258
|
+
* gradient stops abuse heavily) and drops any layer that starts with `url(`. Returns
|
|
259
|
+
* an empty string when nothing survives — the caller treats that as "no background". */
|
|
260
|
+
function stripUrlLayers(bgImage: string): string {
|
|
261
|
+
const layers: string[] = [];
|
|
262
|
+
let depth = 0;
|
|
263
|
+
let start = 0;
|
|
264
|
+
for (let i = 0; i < bgImage.length; i++) {
|
|
265
|
+
const ch = bgImage[i];
|
|
266
|
+
if (ch === '(') depth++;
|
|
267
|
+
else if (ch === ')') depth--;
|
|
268
|
+
else if (ch === ',' && depth === 0) {
|
|
269
|
+
layers.push(bgImage.slice(start, i));
|
|
270
|
+
start = i + 1;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
layers.push(bgImage.slice(start));
|
|
274
|
+
return layers
|
|
275
|
+
.map((l) => l.trim())
|
|
276
|
+
.filter((l) => l && !l.startsWith('url('))
|
|
277
|
+
.join(', ');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const isPainted = (style: CSSStyleDeclaration) =>
|
|
281
|
+
(isSet(style.backgroundColor, 'transparent') && !TRANSPARENT.test(style.backgroundColor)) ||
|
|
282
|
+
isSet(style.backgroundImage, 'none') ||
|
|
283
|
+
shadowPaints(style.boxShadow) ||
|
|
284
|
+
bordered(style);
|
|
285
|
+
|
|
286
|
+
/** Reads the painted chrome off a container so the skeleton can reproduce it exactly —
|
|
287
|
+
* background, four border sides, and box-shadow, but only the properties that are set.
|
|
288
|
+
* Returns `undefined` when nothing paints, so callers can skip emitting a chrome bone. */
|
|
289
|
+
function captureChrome(style: CSSStyleDeclaration): SkeletonChrome | undefined {
|
|
290
|
+
const chrome: SkeletonChrome = {};
|
|
291
|
+
|
|
292
|
+
const bg = style.backgroundColor;
|
|
293
|
+
if (isSet(bg, 'transparent') && !TRANSPARENT.test(bg)) chrome.background = bg;
|
|
294
|
+
// Keep gradient layers (Tailwind ring utilities, shadcn chrome, etc.) but drop
|
|
295
|
+
// `url(...)` layers — a hero bitmap re-downloaded into the skeleton is wasteful
|
|
296
|
+
// and almost always wrong.
|
|
297
|
+
const bgImage = style.backgroundImage;
|
|
298
|
+
if (isSet(bgImage, 'none')) {
|
|
299
|
+
const gradientsOnly = stripUrlLayers(bgImage);
|
|
300
|
+
if (gradientsOnly) {
|
|
301
|
+
chrome.background = chrome.background
|
|
302
|
+
? `${gradientsOnly}, ${chrome.background}`
|
|
303
|
+
: gradientsOnly;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
for (const side of SIDES) {
|
|
308
|
+
const width = style[`border${side}Width`];
|
|
309
|
+
const styleProp = style[`border${side}Style`];
|
|
310
|
+
const color = style[`border${side}Color`];
|
|
311
|
+
if (isSet(styleProp, 'none') && isSet(width, '0px') && parseFloat(width) > 0) {
|
|
312
|
+
const key = `border${side}` as 'borderTop' | 'borderRight' | 'borderBottom' | 'borderLeft';
|
|
313
|
+
chrome[key] = `${width} ${styleProp} ${color}`;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (shadowPaints(style.boxShadow)) chrome.boxShadow = style.boxShadow;
|
|
318
|
+
|
|
319
|
+
return Object.keys(chrome).length ? chrome : undefined;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const isMedia = (element: Element) => MEDIA.has(element.localName);
|
|
323
|
+
|
|
324
|
+
const isEmptyText = (element: Element) =>
|
|
325
|
+
TEXT.has(element.localName) && !element.firstElementChild && !element.textContent?.trim();
|
|
326
|
+
|
|
327
|
+
/** Labels inside: one per piece of text, however many lines it wraps to or nodes it's split into. */
|
|
328
|
+
const countLabels = (element: Element) => {
|
|
329
|
+
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
|
|
330
|
+
const range = document.createRange();
|
|
331
|
+
const pieces: DOMRect[][] = [];
|
|
332
|
+
let nodes = 0;
|
|
333
|
+
while (walker.nextNode()) {
|
|
334
|
+
const node = walker.currentNode;
|
|
335
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
336
|
+
const parent = node.parentElement;
|
|
337
|
+
if (!node.textContent?.trim() || !parent) continue;
|
|
338
|
+
nodes++;
|
|
339
|
+
// Visually hidden text, read to screen readers only
|
|
340
|
+
const box = parent.getBoundingClientRect();
|
|
341
|
+
if (box.width <= 1 || box.height <= 1) continue;
|
|
342
|
+
range.selectNodeContents(node);
|
|
343
|
+
pieces.push(lineRects(range).filter((r) => r.width || r.height));
|
|
344
|
+
} else if (isEmptyText(node as Element)) {
|
|
345
|
+
nodes++;
|
|
346
|
+
pieces.push([...(node as Element).getClientRects()]);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// No layout to go by
|
|
350
|
+
if (!pieces.some((rects) => rects.length)) return nodes;
|
|
351
|
+
|
|
352
|
+
const reach = (parseFloat(getComputedStyle(element).fontSize) || 16) * LINE_GAP;
|
|
353
|
+
const beside = (a: DOMRect, b: DOMRect) =>
|
|
354
|
+
Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) >= Math.min(a.height, b.height) / 2 &&
|
|
355
|
+
Math.max(a.left, b.left) - Math.min(a.right, b.right) <= reach;
|
|
356
|
+
|
|
357
|
+
// Pieces joined into labels: each starts as its own, and merges with any beside it
|
|
358
|
+
const label = pieces.map((_, i) => i);
|
|
359
|
+
const find = (i: number): number => (label[i] === i ? i : (label[i] = find(label[i]!)));
|
|
360
|
+
for (let i = 0; i < pieces.length; i++) {
|
|
361
|
+
for (let j = i + 1; j < pieces.length; j++) {
|
|
362
|
+
if (pieces[i]!.some((a) => pieces[j]!.some((b) => beside(a, b)))) {
|
|
363
|
+
label[find(j)] = find(i);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return new Set(pieces.map((_, i) => find(i))).size;
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
// A painted box around one label (a button, a chip) fills as a block, a card keeps its surface
|
|
371
|
+
const isBlock = (element: Element, style: CSSStyleDeclaration) =>
|
|
372
|
+
element.hasAttribute(ATTR_BLOCK) ||
|
|
373
|
+
isMedia(element) ||
|
|
374
|
+
(isPainted(style) && countLabels(element) <= 1);
|
|
375
|
+
|
|
376
|
+
// Media clipped by a rounded parent of the same size, like an avatar, takes the parent's corners
|
|
377
|
+
function radiusOf(element: Element, style: CSSStyleDeclaration) {
|
|
378
|
+
if (parseFloat(style.borderRadius) || !element.parentElement) {
|
|
379
|
+
return style.borderRadius || '0px';
|
|
380
|
+
}
|
|
381
|
+
const parent = element.parentElement;
|
|
382
|
+
const parentStyle = getComputedStyle(parent);
|
|
383
|
+
const a = element.getBoundingClientRect();
|
|
384
|
+
const b = parent.getBoundingClientRect();
|
|
385
|
+
const same = Math.abs(a.width - b.width) < 1 && Math.abs(a.height - b.height) < 1;
|
|
386
|
+
return same && parentStyle.overflow !== 'visible'
|
|
387
|
+
? parentStyle.borderRadius
|
|
388
|
+
: style.borderRadius || '0px';
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function toBars(lines: Line[]): SkeletonRect[] {
|
|
392
|
+
// Left to right, so each fragment only ever extends a bar to its left
|
|
393
|
+
lines.sort((a, b) => a.left - b.left);
|
|
394
|
+
|
|
395
|
+
const merged: Line[] = [];
|
|
396
|
+
for (const line of lines) {
|
|
397
|
+
const last = merged.find((m) => {
|
|
398
|
+
const overlap = Math.min(m.bottom, line.bottom) - Math.max(m.top, line.top);
|
|
399
|
+
const shorter = Math.min(m.bottom - m.top, line.bottom - line.top);
|
|
400
|
+
const gap = line.left - m.right;
|
|
401
|
+
return overlap >= shorter / 2 && gap <= Math.max(m.fontSize, line.fontSize) * LINE_GAP;
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
if (last) {
|
|
405
|
+
last.left = Math.min(last.left, line.left);
|
|
406
|
+
last.right = Math.max(last.right, line.right);
|
|
407
|
+
last.top = Math.min(last.top, line.top);
|
|
408
|
+
last.bottom = Math.max(last.bottom, line.bottom);
|
|
409
|
+
last.fontSize = Math.max(last.fontSize, line.fontSize);
|
|
410
|
+
last.barTop = Math.min(last.barTop, line.barTop);
|
|
411
|
+
last.barBottom = Math.max(last.barBottom, line.barBottom);
|
|
412
|
+
} else {
|
|
413
|
+
merged.push({ ...line });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return merged.map((line) => {
|
|
418
|
+
const height = line.barBottom - line.barTop;
|
|
419
|
+
return {
|
|
420
|
+
type: 'text' as const,
|
|
421
|
+
x: line.left,
|
|
422
|
+
y: line.barTop,
|
|
423
|
+
width: line.right - line.left,
|
|
424
|
+
height,
|
|
425
|
+
radius: line.radius || `${Math.round(height / 3)}px`,
|
|
426
|
+
};
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Measures what `root` holds into rects relative to `layer`; without `mark`, `root` is left untouched. */
|
|
431
|
+
export function measureSkeleton(
|
|
432
|
+
root: HTMLElement,
|
|
433
|
+
layer: HTMLElement,
|
|
434
|
+
{
|
|
435
|
+
mark = true,
|
|
436
|
+
onCovered,
|
|
437
|
+
}: {
|
|
438
|
+
mark?: boolean;
|
|
439
|
+
/** What's drawn as one block, its own paint to hide — for a caller that can't mark it. */
|
|
440
|
+
onCovered?: (covered: Set<Element>) => void;
|
|
441
|
+
} = {}
|
|
442
|
+
): SkeletonRect[] {
|
|
443
|
+
const origin = layer.getBoundingClientRect();
|
|
444
|
+
// Rects are post-transform; bones are drawn inside the same transform
|
|
445
|
+
const scaleX = layer.offsetWidth ? origin.width / layer.offsetWidth : 1;
|
|
446
|
+
const scaleY = layer.offsetHeight ? origin.height / layer.offsetHeight : 1;
|
|
447
|
+
|
|
448
|
+
const local = (rect: Rect) => ({
|
|
449
|
+
left: (rect.left - origin.left) / scaleX,
|
|
450
|
+
right: (rect.right - origin.left) / scaleX,
|
|
451
|
+
top: (rect.top - origin.top) / scaleY,
|
|
452
|
+
bottom: (rect.bottom - origin.top) / scaleY,
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
const blocks: SkeletonRect[] = [];
|
|
456
|
+
const lines: Line[] = [];
|
|
457
|
+
const covered = new Set<Element>();
|
|
458
|
+
const range = document.createRange();
|
|
459
|
+
|
|
460
|
+
const addLine = (rect: DOMRect, style: CSSStyleDeclaration, clip: Clip) => {
|
|
461
|
+
const box = clipped(rect, clip);
|
|
462
|
+
if (!box) return;
|
|
463
|
+
const fontSize = parseFloat(style.fontSize) || 16;
|
|
464
|
+
const line = local(box);
|
|
465
|
+
lines.push({
|
|
466
|
+
...line,
|
|
467
|
+
fontSize,
|
|
468
|
+
...barOf(line, style, fontSize),
|
|
469
|
+
radius: style.getPropertyValue('--ask-radius').trim(),
|
|
470
|
+
});
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
const addText = (node: Text, clip: Clip) => {
|
|
474
|
+
const start = node.data.search(/\S/);
|
|
475
|
+
if (start < 0 || !node.parentElement) return;
|
|
476
|
+
range.setStart(node, start);
|
|
477
|
+
range.setEnd(node, node.data.trimEnd().length);
|
|
478
|
+
const style = getComputedStyle(node.parentElement);
|
|
479
|
+
for (const rect of lineRects(range)) addLine(rect, style, clip);
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
const visit = (parent: Element, clip: Clip) => {
|
|
483
|
+
for (const node of parent.childNodes) {
|
|
484
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
485
|
+
addText(node as Text, clip);
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
if (node.nodeType !== Node.ELEMENT_NODE) continue;
|
|
489
|
+
|
|
490
|
+
const element = node as Element;
|
|
491
|
+
if (element.hasAttribute(ATTR_IGNORE) || element.hasAttribute(ATTR_OWN)) {
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const style = getComputedStyle(element);
|
|
496
|
+
if (style.display === 'none') continue;
|
|
497
|
+
|
|
498
|
+
if (isBlock(element, style)) {
|
|
499
|
+
const visible = clipped(element.getBoundingClientRect(), clip);
|
|
500
|
+
if (visible) {
|
|
501
|
+
const box = local(visible);
|
|
502
|
+
blocks.push({
|
|
503
|
+
type: isMedia(element) ? 'media' : 'box',
|
|
504
|
+
x: box.left,
|
|
505
|
+
y: box.top,
|
|
506
|
+
width: box.right - box.left,
|
|
507
|
+
height: box.bottom - box.top,
|
|
508
|
+
radius: roundedBy(clip, visible, radiusOf(element, style), style),
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
if (!isMedia(element)) covered.add(element);
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (isEmptyText(element)) {
|
|
516
|
+
for (const rect of element.getClientRects()) {
|
|
517
|
+
addLine(rect, style, clip);
|
|
518
|
+
}
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// A painted container (a card, a chip) doesn't become a solid block — it still
|
|
523
|
+
// has interior bones to measure — but its own paint (bg / border / radius / shadow)
|
|
524
|
+
// is captured as a `chrome` bone so the outline survives shape recall and SSR
|
|
525
|
+
// pre-paint, when the real DOM isn't painted under the layer yet. Emitted BEFORE
|
|
526
|
+
// the recursion so it draws underneath the interior bones in document order.
|
|
527
|
+
//
|
|
528
|
+
// Single-side borders (`border-t` on a footer divider) deliberately fail
|
|
529
|
+
// `isPainted` — they're dividers, not chip paint — but they still need to
|
|
530
|
+
// appear in the skeleton, so `anyBordered` widens the capture trigger.
|
|
531
|
+
if (isPainted(style) || anyBordered(style)) {
|
|
532
|
+
const chrome = captureChrome(style);
|
|
533
|
+
if (chrome) {
|
|
534
|
+
const visible = clipped(element.getBoundingClientRect(), clip);
|
|
535
|
+
if (visible) {
|
|
536
|
+
const box = local(visible);
|
|
537
|
+
blocks.push({
|
|
538
|
+
type: 'chrome',
|
|
539
|
+
x: box.left,
|
|
540
|
+
y: box.top,
|
|
541
|
+
width: box.right - box.left,
|
|
542
|
+
height: box.bottom - box.top,
|
|
543
|
+
radius: roundedBy(clip, visible, radiusOf(element, style), style),
|
|
544
|
+
chrome,
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const clips = style.overflowX !== 'visible' || style.overflowY !== 'visible';
|
|
551
|
+
visit(element, clips ? clipWithin(clip, element.getBoundingClientRect(), style) : clip);
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
visit(root, null);
|
|
556
|
+
onCovered?.(covered);
|
|
557
|
+
|
|
558
|
+
if (!mark) return [...blocks, ...toBars(lines)];
|
|
559
|
+
|
|
560
|
+
// Only touched when they change, so a MutationObserver watching `root` settles
|
|
561
|
+
for (const element of root.querySelectorAll(`[${ATTR_COVERED}]`)) {
|
|
562
|
+
if (!covered.has(element)) element.removeAttribute(ATTR_COVERED);
|
|
563
|
+
}
|
|
564
|
+
for (const element of covered) {
|
|
565
|
+
if (!element.hasAttribute(ATTR_COVERED)) {
|
|
566
|
+
element.setAttribute(ATTR_COVERED, '');
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return [...blocks, ...toBars(lines)];
|
|
571
|
+
}
|