@viewlint/rules 0.0.1
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/README.md +5 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +69 -0
- package/dist/rules/clipped-content.d.ts +10 -0
- package/dist/rules/clipped-content.d.ts.map +1 -0
- package/dist/rules/clipped-content.js +222 -0
- package/dist/rules/container-overflow.d.ts +9 -0
- package/dist/rules/container-overflow.d.ts.map +1 -0
- package/dist/rules/container-overflow.js +185 -0
- package/dist/rules/corner-radius-coherence.d.ts +13 -0
- package/dist/rules/corner-radius-coherence.d.ts.map +1 -0
- package/dist/rules/corner-radius-coherence.js +171 -0
- package/dist/rules/hit-target-obscured.d.ts +10 -0
- package/dist/rules/hit-target-obscured.d.ts.map +1 -0
- package/dist/rules/hit-target-obscured.js +237 -0
- package/dist/rules/misalignment.d.ts +10 -0
- package/dist/rules/misalignment.d.ts.map +1 -0
- package/dist/rules/misalignment.js +154 -0
- package/dist/rules/overlapped-elements.d.ts +10 -0
- package/dist/rules/overlapped-elements.d.ts.map +1 -0
- package/dist/rules/overlapped-elements.js +252 -0
- package/dist/rules/space-misuse.d.ts +7 -0
- package/dist/rules/space-misuse.d.ts.map +1 -0
- package/dist/rules/space-misuse.js +204 -0
- package/dist/rules/text-contrast.d.ts +14 -0
- package/dist/rules/text-contrast.d.ts.map +1 -0
- package/dist/rules/text-contrast.js +210 -0
- package/dist/rules/text-overflow.d.ts +9 -0
- package/dist/rules/text-overflow.d.ts.map +1 -0
- package/dist/rules/text-overflow.js +86 -0
- package/dist/rules/text-proximity.d.ts +12 -0
- package/dist/rules/text-proximity.d.ts.map +1 -0
- package/dist/rules/text-proximity.js +115 -0
- package/dist/rules/text-ragged-lines.d.ts +10 -0
- package/dist/rules/text-ragged-lines.d.ts.map +1 -0
- package/dist/rules/text-ragged-lines.js +123 -0
- package/dist/rules/unexpected-scrollbar.d.ts +9 -0
- package/dist/rules/unexpected-scrollbar.d.ts.map +1 -0
- package/dist/rules/unexpected-scrollbar.js +77 -0
- package/dist/utils/domHelpers.d.ts +66 -0
- package/dist/utils/domHelpers.d.ts.map +1 -0
- package/dist/utils/domHelpers.js +548 -0
- package/dist/utils/getDomHelpersHandle.d.ts +4 -0
- package/dist/utils/getDomHelpersHandle.d.ts.map +1 -0
- package/dist/utils/getDomHelpersHandle.js +28 -0
- package/package.json +36 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export type VisibilityOptions = {
|
|
2
|
+
checkOpacity?: boolean;
|
|
3
|
+
checkPointerEvents?: boolean;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Represents overflow amounts in each direction.
|
|
7
|
+
*/
|
|
8
|
+
export type OverflowBox = {
|
|
9
|
+
top: number;
|
|
10
|
+
right: number;
|
|
11
|
+
bottom: number;
|
|
12
|
+
left: number;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Represents a padding box (content + padding area).
|
|
16
|
+
*/
|
|
17
|
+
export type PaddingBoxSize = {
|
|
18
|
+
width: number;
|
|
19
|
+
height: number;
|
|
20
|
+
};
|
|
21
|
+
export type PaddingBoxRect = {
|
|
22
|
+
top: number;
|
|
23
|
+
right: number;
|
|
24
|
+
bottom: number;
|
|
25
|
+
left: number;
|
|
26
|
+
};
|
|
27
|
+
export type DomHelpers = {
|
|
28
|
+
isHtmlElement: (el: Element | null) => el is HTMLElement;
|
|
29
|
+
isSVGElement: (el: Element | null) => el is SVGElement;
|
|
30
|
+
isRenderableElement: (el: Element | null) => el is HTMLElement | SVGElement;
|
|
31
|
+
isTextNode: (node: ChildNode) => node is Text;
|
|
32
|
+
isVisible: (el: Element, options?: VisibilityOptions) => boolean;
|
|
33
|
+
isVisibleInViewport: (el: Element, options?: VisibilityOptions) => boolean;
|
|
34
|
+
parsePx: (value: string) => number;
|
|
35
|
+
getFontSize: (el: HTMLElement) => number;
|
|
36
|
+
isClippingOverflowValue: (value: string) => boolean;
|
|
37
|
+
canScroll: (overflowValue: string) => boolean;
|
|
38
|
+
hasTextOverflowEllipsis: (el: HTMLElement) => boolean;
|
|
39
|
+
isLineClamped: (el: HTMLElement) => boolean;
|
|
40
|
+
isIntentionallyClipped: (el: HTMLElement) => boolean;
|
|
41
|
+
findIntentionallyClippedAncestor: (el: Element) => HTMLElement | null;
|
|
42
|
+
isElementClippedBy: (el: HTMLElement, clippingAncestor: HTMLElement, threshold?: number) => boolean;
|
|
43
|
+
hasRectSize: (rect: DOMRect, minWidth?: number, minHeight?: number) => boolean;
|
|
44
|
+
hasElementRectSize: (el: Element, minWidth?: number, minHeight?: number) => boolean;
|
|
45
|
+
hasClientSize: (el: HTMLElement, minWidth?: number, minHeight?: number) => boolean;
|
|
46
|
+
getPaddingBoxSize: (el: HTMLElement) => PaddingBoxSize;
|
|
47
|
+
getPaddingBoxRect: (el: HTMLElement) => PaddingBoxRect;
|
|
48
|
+
getDirectTextNodes: (el: HTMLElement, minTextLength?: number) => Text[];
|
|
49
|
+
getTextNodeRects: (textNode: Text, minTextLength?: number) => DOMRect[];
|
|
50
|
+
getTextNodeBounds: (textNode: Text, minTextLength?: number) => DOMRect | null;
|
|
51
|
+
getTextRects: (el: HTMLElement, minTextLength?: number) => DOMRect[];
|
|
52
|
+
getTextBounds: (el: HTMLElement, minTextLength?: number) => DOMRect | null;
|
|
53
|
+
getOverflow: (containerRect: DOMRect, contentRect: DOMRect, threshold?: number) => OverflowBox | null;
|
|
54
|
+
formatOverflow: (overflow: OverflowBox, threshold?: number) => string;
|
|
55
|
+
hasNegativeMargin: (el: HTMLElement) => boolean;
|
|
56
|
+
isLayoutContainer: (el: HTMLElement) => boolean;
|
|
57
|
+
isOffscreenPositioned: (el: HTMLElement) => boolean;
|
|
58
|
+
getIntersectionRect: (rectA: DOMRect, rectB: DOMRect) => DOMRect | null;
|
|
59
|
+
getIntersectionArea: (rectA: DOMRect, rectB: DOMRect) => number;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Runs in browser context via Playwright `page.evaluateHandle`.
|
|
63
|
+
* Must not capture non-serializable values.
|
|
64
|
+
*/
|
|
65
|
+
export declare const createDomHelpers: () => DomHelpers;
|
|
66
|
+
//# sourceMappingURL=domHelpers.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"domHelpers.d.ts","sourceRoot":"","sources":["../../src/utils/domHelpers.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs in browser context via Playwright `page.evaluateHandle`.
|
|
3
|
+
* Must not capture non-serializable values.
|
|
4
|
+
*/
|
|
5
|
+
export const createDomHelpers = () => {
|
|
6
|
+
const isHtmlElement = (el) => {
|
|
7
|
+
// Guard used for TypeScript narrowing inside page context.
|
|
8
|
+
// We intentionally use HTMLElement here so callers can safely call
|
|
9
|
+
// HTMLElement-specific APIs after the check.
|
|
10
|
+
return el instanceof HTMLElement;
|
|
11
|
+
};
|
|
12
|
+
const isSVGElement = (el) => {
|
|
13
|
+
return el instanceof SVGElement;
|
|
14
|
+
};
|
|
15
|
+
const isRenderableElement = (el) => {
|
|
16
|
+
return isHtmlElement(el) || isSVGElement(el);
|
|
17
|
+
};
|
|
18
|
+
const isVisible = (el, options = {}) => {
|
|
19
|
+
const resolvedOptions = {
|
|
20
|
+
checkOpacity: options.checkOpacity ?? true,
|
|
21
|
+
checkPointerEvents: options.checkPointerEvents ?? false,
|
|
22
|
+
};
|
|
23
|
+
// Epsilon for opacity comparison to handle floating-point precision
|
|
24
|
+
const OPACITY_EPSILON = 0.0001;
|
|
25
|
+
const parsePx = (value) => {
|
|
26
|
+
const parsed = Number.parseFloat(value);
|
|
27
|
+
return Number.isFinite(parsed) ? parsed : Number.NaN;
|
|
28
|
+
};
|
|
29
|
+
const parseLegacyClipRect = (value) => {
|
|
30
|
+
const trimmed = value.trim();
|
|
31
|
+
if (trimmed.length === 0)
|
|
32
|
+
return null;
|
|
33
|
+
if (trimmed === "auto")
|
|
34
|
+
return null;
|
|
35
|
+
const match = trimmed.match(/^rect\((.*)\)$/i);
|
|
36
|
+
if (!match)
|
|
37
|
+
return null;
|
|
38
|
+
const raw = match[1] ?? "";
|
|
39
|
+
const parts = raw
|
|
40
|
+
.split(/[,\s]+/)
|
|
41
|
+
.map((x) => x.trim())
|
|
42
|
+
.filter((x) => x.length > 0);
|
|
43
|
+
if (parts.length < 4)
|
|
44
|
+
return null;
|
|
45
|
+
const top = parsePx(parts[0] ?? "");
|
|
46
|
+
const right = parsePx(parts[1] ?? "");
|
|
47
|
+
const bottom = parsePx(parts[2] ?? "");
|
|
48
|
+
const left = parsePx(parts[3] ?? "");
|
|
49
|
+
return { top, right, bottom, left };
|
|
50
|
+
};
|
|
51
|
+
const isLegacyClipRectHidden = (style) => {
|
|
52
|
+
const rect = parseLegacyClipRect(style.clip);
|
|
53
|
+
if (!rect)
|
|
54
|
+
return false;
|
|
55
|
+
return rect.right <= rect.left || rect.bottom <= rect.top;
|
|
56
|
+
};
|
|
57
|
+
const isVisuallyHiddenByClipping = (target, style) => {
|
|
58
|
+
// Screen-reader-only / visually-hidden patterns.
|
|
59
|
+
// These are intentionally invisible in the rendered UI.
|
|
60
|
+
if (style.contentVisibility === "hidden")
|
|
61
|
+
return true;
|
|
62
|
+
if (isLegacyClipRectHidden(style))
|
|
63
|
+
return true;
|
|
64
|
+
const clipPath = style.clipPath || style.getPropertyValue("clip-path");
|
|
65
|
+
const normalizedClipPath = clipPath
|
|
66
|
+
.trim()
|
|
67
|
+
.toLowerCase()
|
|
68
|
+
.replace(/\s+/g, "");
|
|
69
|
+
const clipPathLooksHidden = normalizedClipPath.startsWith("inset(50%") ||
|
|
70
|
+
normalizedClipPath.startsWith("inset(100%") ||
|
|
71
|
+
normalizedClipPath.startsWith("circle(0");
|
|
72
|
+
const rect = target.getBoundingClientRect();
|
|
73
|
+
const isTiny = rect.width <= 1 && rect.height <= 1;
|
|
74
|
+
if (!isTiny && !clipPathLooksHidden)
|
|
75
|
+
return false;
|
|
76
|
+
const clipsX = isClippingOverflowValue(style.overflowX);
|
|
77
|
+
const clipsY = isClippingOverflowValue(style.overflowY);
|
|
78
|
+
const clipsBoth = clipsX && clipsY;
|
|
79
|
+
if (!clipsBoth && !clipPathLooksHidden && style.clip === "auto")
|
|
80
|
+
return false;
|
|
81
|
+
return true;
|
|
82
|
+
};
|
|
83
|
+
const ownStyle = window.getComputedStyle(el);
|
|
84
|
+
if (resolvedOptions.checkPointerEvents &&
|
|
85
|
+
ownStyle.pointerEvents === "none") {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
const resolveOpacity = (target) => {
|
|
89
|
+
let current = target;
|
|
90
|
+
let effectiveOpacity = 1;
|
|
91
|
+
while (current) {
|
|
92
|
+
const opacity = Number.parseFloat(window.getComputedStyle(current).opacity);
|
|
93
|
+
const resolvedOpacity = Number.isFinite(opacity) ? opacity : 1;
|
|
94
|
+
effectiveOpacity *= resolvedOpacity;
|
|
95
|
+
// Early exit if effectively transparent (with small epsilon for precision)
|
|
96
|
+
if (effectiveOpacity < OPACITY_EPSILON)
|
|
97
|
+
return 0;
|
|
98
|
+
current = current.parentElement;
|
|
99
|
+
}
|
|
100
|
+
return effectiveOpacity;
|
|
101
|
+
};
|
|
102
|
+
if (resolvedOptions.checkOpacity && resolveOpacity(el) < OPACITY_EPSILON)
|
|
103
|
+
return false;
|
|
104
|
+
let current = el;
|
|
105
|
+
while (current) {
|
|
106
|
+
const style = window.getComputedStyle(current);
|
|
107
|
+
if (isVisuallyHiddenByClipping(current, style))
|
|
108
|
+
return false;
|
|
109
|
+
if (style.display === "none")
|
|
110
|
+
return false;
|
|
111
|
+
if (style.visibility === "hidden" || style.visibility === "collapse") {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
current = current.parentElement;
|
|
115
|
+
}
|
|
116
|
+
return true;
|
|
117
|
+
};
|
|
118
|
+
const isVisibleInViewport = (el, options) => {
|
|
119
|
+
if (!isVisible(el, options))
|
|
120
|
+
return false;
|
|
121
|
+
const rect = el.getBoundingClientRect();
|
|
122
|
+
if (!hasRectSize(rect, 1, 1))
|
|
123
|
+
return false;
|
|
124
|
+
return (rect.bottom > 0 &&
|
|
125
|
+
rect.right > 0 &&
|
|
126
|
+
rect.top < window.innerHeight &&
|
|
127
|
+
rect.left < window.innerWidth);
|
|
128
|
+
};
|
|
129
|
+
const isClippingOverflowValue = (value) => {
|
|
130
|
+
return value === "hidden" || value === "clip";
|
|
131
|
+
};
|
|
132
|
+
const hasTextOverflowEllipsis = (el) => {
|
|
133
|
+
const style = window.getComputedStyle(el);
|
|
134
|
+
return style.textOverflow === "ellipsis";
|
|
135
|
+
};
|
|
136
|
+
const hasRoundedCorners = (style) => {
|
|
137
|
+
const radii = [
|
|
138
|
+
style.borderTopLeftRadius,
|
|
139
|
+
style.borderTopRightRadius,
|
|
140
|
+
style.borderBottomRightRadius,
|
|
141
|
+
style.borderBottomLeftRadius,
|
|
142
|
+
];
|
|
143
|
+
for (const value of radii) {
|
|
144
|
+
const parsed = Number.parseFloat(value);
|
|
145
|
+
if (Number.isFinite(parsed) && parsed > 0)
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
return false;
|
|
149
|
+
};
|
|
150
|
+
const hasVisibleDecoration = (style) => {
|
|
151
|
+
const borderWidths = [
|
|
152
|
+
Number.parseFloat(style.borderTopWidth) || 0,
|
|
153
|
+
Number.parseFloat(style.borderRightWidth) || 0,
|
|
154
|
+
Number.parseFloat(style.borderBottomWidth) || 0,
|
|
155
|
+
Number.parseFloat(style.borderLeftWidth) || 0,
|
|
156
|
+
];
|
|
157
|
+
if (borderWidths.some((w) => w > 0))
|
|
158
|
+
return true;
|
|
159
|
+
const bgImage = style.backgroundImage;
|
|
160
|
+
if (bgImage && bgImage !== "none")
|
|
161
|
+
return true;
|
|
162
|
+
const bgColor = style.backgroundColor;
|
|
163
|
+
if (bgColor &&
|
|
164
|
+
bgColor !== "transparent" &&
|
|
165
|
+
bgColor !== "rgba(0, 0, 0, 0)") {
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
const boxShadow = style.boxShadow;
|
|
169
|
+
if (boxShadow && boxShadow !== "none")
|
|
170
|
+
return true;
|
|
171
|
+
return false;
|
|
172
|
+
};
|
|
173
|
+
const isIntentionallyClipped = (el) => {
|
|
174
|
+
if (el.hasAttribute("data-viewlint-clipped"))
|
|
175
|
+
return true;
|
|
176
|
+
const style = window.getComputedStyle(el);
|
|
177
|
+
const isLineClamped = (style) => {
|
|
178
|
+
const raw = style.getPropertyValue("-webkit-line-clamp") ||
|
|
179
|
+
style.getPropertyValue("line-clamp");
|
|
180
|
+
const value = raw.trim();
|
|
181
|
+
if (value.length === 0)
|
|
182
|
+
return false;
|
|
183
|
+
if (value === "none")
|
|
184
|
+
return false;
|
|
185
|
+
const parsed = Number.parseFloat(value);
|
|
186
|
+
if (Number.isFinite(parsed))
|
|
187
|
+
return parsed > 0;
|
|
188
|
+
return true;
|
|
189
|
+
};
|
|
190
|
+
const clipPath = style.clipPath || style.getPropertyValue("clip-path");
|
|
191
|
+
if (clipPath && clipPath !== "none")
|
|
192
|
+
return true;
|
|
193
|
+
const maskImage = style.maskImage;
|
|
194
|
+
const webkitMaskImage = style.getPropertyValue("-webkit-mask-image");
|
|
195
|
+
if ((maskImage && maskImage !== "none") ||
|
|
196
|
+
(webkitMaskImage && webkitMaskImage !== "none")) {
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
const clipsX = isClippingOverflowValue(style.overflowX);
|
|
200
|
+
const clipsY = isClippingOverflowValue(style.overflowY);
|
|
201
|
+
if (!clipsX && !clipsY)
|
|
202
|
+
return false;
|
|
203
|
+
if (style.textOverflow === "ellipsis")
|
|
204
|
+
return true;
|
|
205
|
+
if (isLineClamped(style))
|
|
206
|
+
return true;
|
|
207
|
+
if (hasRoundedCorners(style))
|
|
208
|
+
return true;
|
|
209
|
+
if (hasVisibleDecoration(style))
|
|
210
|
+
return true;
|
|
211
|
+
if (style.position !== "static")
|
|
212
|
+
return true;
|
|
213
|
+
return false;
|
|
214
|
+
};
|
|
215
|
+
const findIntentionallyClippedAncestor = (el) => {
|
|
216
|
+
let current = el.parentElement;
|
|
217
|
+
while (current) {
|
|
218
|
+
if (isHtmlElement(current) && isIntentionallyClipped(current)) {
|
|
219
|
+
return current;
|
|
220
|
+
}
|
|
221
|
+
current = current.parentElement;
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
};
|
|
225
|
+
const isElementClippedBy = (el, clippingAncestor, threshold = 1) => {
|
|
226
|
+
const rect = el.getBoundingClientRect();
|
|
227
|
+
const clipRect = clippingAncestor.getBoundingClientRect();
|
|
228
|
+
const computedStyle = window.getComputedStyle(clippingAncestor);
|
|
229
|
+
const overflowX = computedStyle.overflowX;
|
|
230
|
+
const overflowY = computedStyle.overflowY;
|
|
231
|
+
const clipsHorizontally = overflowX !== "visible";
|
|
232
|
+
const clipsVertically = overflowY !== "visible";
|
|
233
|
+
const isClippedHorizontally = clipsHorizontally &&
|
|
234
|
+
(rect.left < clipRect.left - threshold ||
|
|
235
|
+
rect.right > clipRect.right + threshold);
|
|
236
|
+
const isClippedVertically = clipsVertically &&
|
|
237
|
+
(rect.top < clipRect.top - threshold ||
|
|
238
|
+
rect.bottom > clipRect.bottom + threshold);
|
|
239
|
+
return isClippedHorizontally || isClippedVertically;
|
|
240
|
+
};
|
|
241
|
+
const hasRectSize = (rect, minWidth = 1, minHeight = 1) => {
|
|
242
|
+
return rect.width >= minWidth && rect.height >= minHeight;
|
|
243
|
+
};
|
|
244
|
+
const hasElementRectSize = (el, minWidth = 1, minHeight = 1) => {
|
|
245
|
+
const rect = el.getBoundingClientRect();
|
|
246
|
+
return hasRectSize(rect, minWidth, minHeight);
|
|
247
|
+
};
|
|
248
|
+
const hasClientSize = (el, minWidth = 1, minHeight = 1) => {
|
|
249
|
+
return el.clientWidth > minWidth && el.clientHeight > minHeight;
|
|
250
|
+
};
|
|
251
|
+
const isTextNode = (node) => {
|
|
252
|
+
return node.nodeType === Node.TEXT_NODE;
|
|
253
|
+
};
|
|
254
|
+
const getDirectTextNodes = (el, minTextLength = 1) => {
|
|
255
|
+
const textNodes = [];
|
|
256
|
+
for (const node of el.childNodes) {
|
|
257
|
+
if (!isTextNode(node))
|
|
258
|
+
continue;
|
|
259
|
+
const text = node.textContent?.trim();
|
|
260
|
+
if (!text || text.length < minTextLength)
|
|
261
|
+
continue;
|
|
262
|
+
textNodes.push(node);
|
|
263
|
+
}
|
|
264
|
+
return textNodes;
|
|
265
|
+
};
|
|
266
|
+
const getTextNodeRects = (textNode, minTextLength = 1) => {
|
|
267
|
+
const text = textNode.textContent?.trim();
|
|
268
|
+
if (!text || text.length < minTextLength)
|
|
269
|
+
return [];
|
|
270
|
+
const range = document.createRange();
|
|
271
|
+
range.selectNodeContents(textNode);
|
|
272
|
+
const rects = range.getClientRects();
|
|
273
|
+
const results = [];
|
|
274
|
+
for (const rect of rects) {
|
|
275
|
+
if (rect.width === 0 || rect.height === 0)
|
|
276
|
+
continue;
|
|
277
|
+
results.push(rect);
|
|
278
|
+
}
|
|
279
|
+
return results;
|
|
280
|
+
};
|
|
281
|
+
const getTextNodeBounds = (textNode, minTextLength = 1) => {
|
|
282
|
+
const rects = getTextNodeRects(textNode, minTextLength);
|
|
283
|
+
if (rects.length === 0)
|
|
284
|
+
return null;
|
|
285
|
+
let left = Infinity;
|
|
286
|
+
let top = Infinity;
|
|
287
|
+
let right = -Infinity;
|
|
288
|
+
let bottom = -Infinity;
|
|
289
|
+
for (const rect of rects) {
|
|
290
|
+
left = Math.min(left, rect.left);
|
|
291
|
+
top = Math.min(top, rect.top);
|
|
292
|
+
right = Math.max(right, rect.right);
|
|
293
|
+
bottom = Math.max(bottom, rect.bottom);
|
|
294
|
+
}
|
|
295
|
+
if (left === Infinity)
|
|
296
|
+
return null;
|
|
297
|
+
return new DOMRect(left, top, right - left, bottom - top);
|
|
298
|
+
};
|
|
299
|
+
const getTextRects = (el, minTextLength = 1) => {
|
|
300
|
+
const results = [];
|
|
301
|
+
const textNodes = getDirectTextNodes(el, minTextLength);
|
|
302
|
+
for (const node of textNodes) {
|
|
303
|
+
results.push(...getTextNodeRects(node, minTextLength));
|
|
304
|
+
}
|
|
305
|
+
return results;
|
|
306
|
+
};
|
|
307
|
+
const getTextBounds = (el, minTextLength = 1) => {
|
|
308
|
+
const rects = getTextRects(el, minTextLength);
|
|
309
|
+
if (rects.length === 0)
|
|
310
|
+
return null;
|
|
311
|
+
let left = Infinity;
|
|
312
|
+
let top = Infinity;
|
|
313
|
+
let right = -Infinity;
|
|
314
|
+
let bottom = -Infinity;
|
|
315
|
+
for (const rect of rects) {
|
|
316
|
+
left = Math.min(left, rect.left);
|
|
317
|
+
top = Math.min(top, rect.top);
|
|
318
|
+
right = Math.max(right, rect.right);
|
|
319
|
+
bottom = Math.max(bottom, rect.bottom);
|
|
320
|
+
}
|
|
321
|
+
if (left === Infinity)
|
|
322
|
+
return null;
|
|
323
|
+
return new DOMRect(left, top, right - left, bottom - top);
|
|
324
|
+
};
|
|
325
|
+
// =========================================================================
|
|
326
|
+
// NEW SHARED HELPERS
|
|
327
|
+
// =========================================================================
|
|
328
|
+
/**
|
|
329
|
+
* Parse a CSS pixel value to a number. Returns 0 for invalid values.
|
|
330
|
+
*/
|
|
331
|
+
const parsePx = (value) => {
|
|
332
|
+
const parsed = Number.parseFloat(value);
|
|
333
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
334
|
+
};
|
|
335
|
+
/**
|
|
336
|
+
* Get the computed font size of an element in pixels.
|
|
337
|
+
*/
|
|
338
|
+
const getFontSize = (el) => {
|
|
339
|
+
const style = window.getComputedStyle(el);
|
|
340
|
+
const parsed = Number.parseFloat(style.fontSize);
|
|
341
|
+
return Number.isFinite(parsed) ? parsed : 16;
|
|
342
|
+
};
|
|
343
|
+
/**
|
|
344
|
+
* Check if an overflow value allows scrolling.
|
|
345
|
+
*/
|
|
346
|
+
const canScroll = (overflowValue) => {
|
|
347
|
+
return (overflowValue === "auto" ||
|
|
348
|
+
overflowValue === "scroll" ||
|
|
349
|
+
overflowValue === "overlay");
|
|
350
|
+
};
|
|
351
|
+
/**
|
|
352
|
+
* Check if an element has CSS line-clamp applied.
|
|
353
|
+
*/
|
|
354
|
+
const isLineClamped = (el) => {
|
|
355
|
+
const style = window.getComputedStyle(el);
|
|
356
|
+
const raw = style.getPropertyValue("-webkit-line-clamp") ||
|
|
357
|
+
style.getPropertyValue("line-clamp");
|
|
358
|
+
const value = raw.trim();
|
|
359
|
+
if (value.length === 0 || value === "none")
|
|
360
|
+
return false;
|
|
361
|
+
const parsed = Number.parseFloat(value);
|
|
362
|
+
return Number.isFinite(parsed) ? parsed > 0 : true;
|
|
363
|
+
};
|
|
364
|
+
/**
|
|
365
|
+
* Get the padding box size (border-box minus borders).
|
|
366
|
+
*/
|
|
367
|
+
const getPaddingBoxSize = (el) => {
|
|
368
|
+
const rect = el.getBoundingClientRect();
|
|
369
|
+
const style = window.getComputedStyle(el);
|
|
370
|
+
const borderTop = parsePx(style.borderTopWidth);
|
|
371
|
+
const borderRight = parsePx(style.borderRightWidth);
|
|
372
|
+
const borderBottom = parsePx(style.borderBottomWidth);
|
|
373
|
+
const borderLeft = parsePx(style.borderLeftWidth);
|
|
374
|
+
return {
|
|
375
|
+
width: Math.max(0, rect.width - borderLeft - borderRight),
|
|
376
|
+
height: Math.max(0, rect.height - borderTop - borderBottom),
|
|
377
|
+
};
|
|
378
|
+
};
|
|
379
|
+
/**
|
|
380
|
+
* Get the padding box rect (border-box minus borders) in viewport coordinates.
|
|
381
|
+
*/
|
|
382
|
+
const getPaddingBoxRect = (el) => {
|
|
383
|
+
const rect = el.getBoundingClientRect();
|
|
384
|
+
const style = window.getComputedStyle(el);
|
|
385
|
+
const borderTop = parsePx(style.borderTopWidth);
|
|
386
|
+
const borderRight = parsePx(style.borderRightWidth);
|
|
387
|
+
const borderBottom = parsePx(style.borderBottomWidth);
|
|
388
|
+
const borderLeft = parsePx(style.borderLeftWidth);
|
|
389
|
+
return {
|
|
390
|
+
top: rect.top + borderTop,
|
|
391
|
+
right: rect.right - borderRight,
|
|
392
|
+
bottom: rect.bottom - borderBottom,
|
|
393
|
+
left: rect.left + borderLeft,
|
|
394
|
+
};
|
|
395
|
+
};
|
|
396
|
+
/**
|
|
397
|
+
* Calculate overflow amounts of content rect relative to container rect.
|
|
398
|
+
* Returns null if no overflow exceeds the threshold.
|
|
399
|
+
*/
|
|
400
|
+
const getOverflow = (containerRect, contentRect, threshold = 0) => {
|
|
401
|
+
const top = Math.max(0, containerRect.top - contentRect.top);
|
|
402
|
+
const right = Math.max(0, contentRect.right - containerRect.right);
|
|
403
|
+
const bottom = Math.max(0, contentRect.bottom - containerRect.bottom);
|
|
404
|
+
const left = Math.max(0, containerRect.left - contentRect.left);
|
|
405
|
+
const hasOverflow = top > threshold ||
|
|
406
|
+
right > threshold ||
|
|
407
|
+
bottom > threshold ||
|
|
408
|
+
left > threshold;
|
|
409
|
+
return hasOverflow ? { top, right, bottom, left } : null;
|
|
410
|
+
};
|
|
411
|
+
/**
|
|
412
|
+
* Format overflow amounts to a human-readable string.
|
|
413
|
+
*/
|
|
414
|
+
const formatOverflow = (overflow, threshold = 0) => {
|
|
415
|
+
const parts = [];
|
|
416
|
+
if (overflow.top > threshold) {
|
|
417
|
+
parts.push(`${Math.round(overflow.top)}px top`);
|
|
418
|
+
}
|
|
419
|
+
if (overflow.right > threshold) {
|
|
420
|
+
parts.push(`${Math.round(overflow.right)}px right`);
|
|
421
|
+
}
|
|
422
|
+
if (overflow.bottom > threshold) {
|
|
423
|
+
parts.push(`${Math.round(overflow.bottom)}px bottom`);
|
|
424
|
+
}
|
|
425
|
+
if (overflow.left > threshold) {
|
|
426
|
+
parts.push(`${Math.round(overflow.left)}px left`);
|
|
427
|
+
}
|
|
428
|
+
return parts.join(", ");
|
|
429
|
+
};
|
|
430
|
+
/**
|
|
431
|
+
* Check if an element has any negative margins.
|
|
432
|
+
*/
|
|
433
|
+
const hasNegativeMargin = (el) => {
|
|
434
|
+
const style = window.getComputedStyle(el);
|
|
435
|
+
const margins = [
|
|
436
|
+
parsePx(style.marginTop),
|
|
437
|
+
parsePx(style.marginRight),
|
|
438
|
+
parsePx(style.marginBottom),
|
|
439
|
+
parsePx(style.marginLeft),
|
|
440
|
+
];
|
|
441
|
+
return margins.some((m) => m < 0);
|
|
442
|
+
};
|
|
443
|
+
/**
|
|
444
|
+
* Check if an element is a layout container (flex, grid, or has explicit sizing).
|
|
445
|
+
*/
|
|
446
|
+
const isLayoutContainer = (el) => {
|
|
447
|
+
const style = window.getComputedStyle(el);
|
|
448
|
+
// Flex or grid containers are intentional layout containers
|
|
449
|
+
if (style.display === "flex" || style.display === "inline-flex") {
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
if (style.display === "grid" || style.display === "inline-grid") {
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
// Check for explicit max-width constraints
|
|
456
|
+
if (style.maxWidth !== "none") {
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
459
|
+
// Check for explicit height constraints (more reliable than width)
|
|
460
|
+
const height = style.height;
|
|
461
|
+
if (height &&
|
|
462
|
+
height !== "auto" &&
|
|
463
|
+
!height.includes("%") &&
|
|
464
|
+
parsePx(height) > 0) {
|
|
465
|
+
return true;
|
|
466
|
+
}
|
|
467
|
+
return false;
|
|
468
|
+
};
|
|
469
|
+
/**
|
|
470
|
+
* Check if an element is positioned offscreen (e.g., skip links pattern).
|
|
471
|
+
*/
|
|
472
|
+
const isOffscreenPositioned = (el) => {
|
|
473
|
+
const style = window.getComputedStyle(el);
|
|
474
|
+
if (style.position !== "absolute" && style.position !== "fixed") {
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
const top = parseFloat(style.top);
|
|
478
|
+
const left = parseFloat(style.left);
|
|
479
|
+
if (!Number.isNaN(top) && top <= -500)
|
|
480
|
+
return true;
|
|
481
|
+
if (!Number.isNaN(left) && left <= -500)
|
|
482
|
+
return true;
|
|
483
|
+
return false;
|
|
484
|
+
};
|
|
485
|
+
/**
|
|
486
|
+
* Get the intersection rectangle of two rects, or null if they don't intersect.
|
|
487
|
+
*/
|
|
488
|
+
const getIntersectionRect = (rectA, rectB) => {
|
|
489
|
+
const left = Math.max(rectA.left, rectB.left);
|
|
490
|
+
const top = Math.max(rectA.top, rectB.top);
|
|
491
|
+
const right = Math.min(rectA.right, rectB.right);
|
|
492
|
+
const bottom = Math.min(rectA.bottom, rectB.bottom);
|
|
493
|
+
if (left >= right || top >= bottom) {
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
return new DOMRect(left, top, right - left, bottom - top);
|
|
497
|
+
};
|
|
498
|
+
/**
|
|
499
|
+
* Get the area of intersection between two rects.
|
|
500
|
+
*/
|
|
501
|
+
const getIntersectionArea = (rectA, rectB) => {
|
|
502
|
+
const intersection = getIntersectionRect(rectA, rectB);
|
|
503
|
+
return intersection ? intersection.width * intersection.height : 0;
|
|
504
|
+
};
|
|
505
|
+
return {
|
|
506
|
+
// Type guards
|
|
507
|
+
isHtmlElement,
|
|
508
|
+
isSVGElement,
|
|
509
|
+
isRenderableElement,
|
|
510
|
+
isTextNode,
|
|
511
|
+
// Visibility
|
|
512
|
+
isVisible,
|
|
513
|
+
isVisibleInViewport,
|
|
514
|
+
// CSS parsing
|
|
515
|
+
parsePx,
|
|
516
|
+
getFontSize,
|
|
517
|
+
// Overflow and clipping
|
|
518
|
+
isClippingOverflowValue,
|
|
519
|
+
canScroll,
|
|
520
|
+
hasTextOverflowEllipsis,
|
|
521
|
+
isLineClamped,
|
|
522
|
+
isIntentionallyClipped,
|
|
523
|
+
findIntentionallyClippedAncestor,
|
|
524
|
+
isElementClippedBy,
|
|
525
|
+
// Size and rect
|
|
526
|
+
hasRectSize,
|
|
527
|
+
hasElementRectSize,
|
|
528
|
+
hasClientSize,
|
|
529
|
+
getPaddingBoxSize,
|
|
530
|
+
getPaddingBoxRect,
|
|
531
|
+
// Text nodes
|
|
532
|
+
getDirectTextNodes,
|
|
533
|
+
getTextNodeRects,
|
|
534
|
+
getTextNodeBounds,
|
|
535
|
+
getTextRects,
|
|
536
|
+
getTextBounds,
|
|
537
|
+
// Overflow calculation
|
|
538
|
+
getOverflow,
|
|
539
|
+
formatOverflow,
|
|
540
|
+
hasNegativeMargin,
|
|
541
|
+
// Layout detection
|
|
542
|
+
isLayoutContainer,
|
|
543
|
+
isOffscreenPositioned,
|
|
544
|
+
// Rect intersection
|
|
545
|
+
getIntersectionRect,
|
|
546
|
+
getIntersectionArea,
|
|
547
|
+
};
|
|
548
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"getDomHelpersHandle.d.ts","sourceRoot":"","sources":["../../src/utils/getDomHelpersHandle.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,YAAY,CAAA;AAEhD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA;AAMjD,eAAO,MAAM,mBAAmB,GAC/B,MAAM,IAAI,KACR,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAC,CA2B9B,CAAA"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createDomHelpers } from "./domHelpers.js";
|
|
2
|
+
const cache = new WeakMap();
|
|
3
|
+
const listenersInstalled = new WeakSet();
|
|
4
|
+
export const getDomHelpersHandle = async (page) => {
|
|
5
|
+
const cached = cache.get(page);
|
|
6
|
+
if (cached)
|
|
7
|
+
return cached;
|
|
8
|
+
const domHelpers = await page.evaluateHandle(createDomHelpers);
|
|
9
|
+
cache.set(page, domHelpers);
|
|
10
|
+
if (!listenersInstalled.has(page)) {
|
|
11
|
+
listenersInstalled.add(page);
|
|
12
|
+
page.on("framenavigated", (frame) => {
|
|
13
|
+
if (frame !== page.mainFrame())
|
|
14
|
+
return;
|
|
15
|
+
const current = cache.get(page);
|
|
16
|
+
if (!current)
|
|
17
|
+
return;
|
|
18
|
+
cache.delete(page);
|
|
19
|
+
void current.dispose().catch(() => { });
|
|
20
|
+
});
|
|
21
|
+
page.once("close", () => {
|
|
22
|
+
const current = cache.get(page);
|
|
23
|
+
cache.delete(page);
|
|
24
|
+
void current?.dispose().catch(() => { });
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
return domHelpers;
|
|
28
|
+
};
|