gap-inspector 0.2.0 → 0.2.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/dist/index.cjs +2946 -0
- package/dist/{measurement.d.ts → index.d.cts} +21 -19
- package/dist/index.d.ts +78 -5
- package/dist/index.js +2826 -819
- package/package.json +7 -4
- package/dist/measurement.js +0 -1214
- package/dist/styles.d.ts +0 -1
- package/dist/styles.js +0 -635
package/dist/measurement.js
DELETED
|
@@ -1,1214 +0,0 @@
|
|
|
1
|
-
const AXIS_PROPS = {
|
|
2
|
-
horizontal: {
|
|
3
|
-
start: "left",
|
|
4
|
-
end: "right",
|
|
5
|
-
size: "width",
|
|
6
|
-
beforeMargin: "marginLeft",
|
|
7
|
-
afterMargin: "marginRight",
|
|
8
|
-
beforePadding: "paddingLeft",
|
|
9
|
-
afterPadding: "paddingRight",
|
|
10
|
-
beforeBorder: "borderLeftWidth",
|
|
11
|
-
afterBorder: "borderRightWidth",
|
|
12
|
-
gap: "columnGap",
|
|
13
|
-
alternateGap: "gap",
|
|
14
|
-
perpStart: "top",
|
|
15
|
-
perpEnd: "bottom"
|
|
16
|
-
},
|
|
17
|
-
vertical: {
|
|
18
|
-
start: "top",
|
|
19
|
-
end: "bottom",
|
|
20
|
-
size: "height",
|
|
21
|
-
beforeMargin: "marginTop",
|
|
22
|
-
afterMargin: "marginBottom",
|
|
23
|
-
beforePadding: "paddingTop",
|
|
24
|
-
afterPadding: "paddingBottom",
|
|
25
|
-
beforeBorder: "borderTopWidth",
|
|
26
|
-
afterBorder: "borderBottomWidth",
|
|
27
|
-
gap: "rowGap",
|
|
28
|
-
alternateGap: "gap",
|
|
29
|
-
perpStart: "left",
|
|
30
|
-
perpEnd: "right"
|
|
31
|
-
}
|
|
32
|
-
};
|
|
33
|
-
export function inferAxis(start, end) {
|
|
34
|
-
return Math.abs(end.x - start.x) >= Math.abs(end.y - start.y)
|
|
35
|
-
? "horizontal"
|
|
36
|
-
: "vertical";
|
|
37
|
-
}
|
|
38
|
-
export function measureGap(options) {
|
|
39
|
-
const doc = options.document ?? document;
|
|
40
|
-
const axis = options.axis ?? inferAxis(options.start, options.end);
|
|
41
|
-
const props = AXIS_PROPS[axis];
|
|
42
|
-
const ignoreElements = new Set((options.ignoreElements ?? []).filter((element) => Boolean(element)));
|
|
43
|
-
const line = normalizeLine(axis, options.start, options.end);
|
|
44
|
-
const endpoints = findEndpointElementsFromPointer(doc, axis, options.start, options.end, ignoreElements)
|
|
45
|
-
?? (options.boundaryScan === false ? null : findBoundaryElements(doc, axis, line, ignoreElements));
|
|
46
|
-
if (!endpoints) {
|
|
47
|
-
return null;
|
|
48
|
-
}
|
|
49
|
-
if (endpoints.kind === "internal") {
|
|
50
|
-
return measureInternalGap(axis, endpoints);
|
|
51
|
-
}
|
|
52
|
-
const fromElement = endpoints.from;
|
|
53
|
-
const toElement = endpoints.to;
|
|
54
|
-
const fromRect = toGapRect(endpoints.from.getBoundingClientRect());
|
|
55
|
-
const toRect = toGapRect(endpoints.to.getBoundingClientRect());
|
|
56
|
-
const totalPx = Math.max(0, toRect[props.start] - fromRect[props.end]);
|
|
57
|
-
const commonAncestor = findCommonAncestor(fromElement, toElement);
|
|
58
|
-
const fromBranch = commonAncestor
|
|
59
|
-
? childBranchUnderAncestor(fromElement, commonAncestor)
|
|
60
|
-
: null;
|
|
61
|
-
const toBranch = commonAncestor
|
|
62
|
-
? childBranchUnderAncestor(toElement, commonAncestor)
|
|
63
|
-
: null;
|
|
64
|
-
const contributions = [];
|
|
65
|
-
const warnings = [];
|
|
66
|
-
for (const element of [fromElement, toElement]) {
|
|
67
|
-
if (element.tagName.toLowerCase() === "iframe") {
|
|
68
|
-
warnings.push(`\`${selectorForElement(element)}\` is an iframe; its contents render in a separate document and cannot be inspected or attributed.`);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
appendGeometryWarnings(warnings, [fromElement, toElement, fromBranch, toBranch, commonAncestor]);
|
|
72
|
-
// Contributions are assembled in geometric order along the axis: the space
|
|
73
|
-
// inside the from-side wrappers, then the space between the sibling branches,
|
|
74
|
-
// then the space inside the to-side wrappers.
|
|
75
|
-
if (commonAncestor && fromBranch && fromBranch !== endpoints.from) {
|
|
76
|
-
contributions.push(...describeInternalSpace(axis, fromElement, fromBranch, "after", warnings));
|
|
77
|
-
}
|
|
78
|
-
if (commonAncestor && fromBranch && toBranch && fromBranch !== toBranch) {
|
|
79
|
-
contributions.push(...describeBetweenBranches(axis, commonAncestor, fromBranch, toBranch, warnings));
|
|
80
|
-
}
|
|
81
|
-
else {
|
|
82
|
-
contributions.push(makeUnknownContribution(axis, fromElement, totalPx, "Unable to isolate sibling branches for this gap."));
|
|
83
|
-
}
|
|
84
|
-
if (commonAncestor && toBranch && toBranch !== endpoints.to) {
|
|
85
|
-
contributions.push(...describeInternalSpace(axis, toElement, toBranch, "before", warnings));
|
|
86
|
-
}
|
|
87
|
-
const visibleContributions = limitContributionsToMeasuredGap(collapseContributions(contributions)
|
|
88
|
-
.filter((contribution) => contribution.valuePx > 0.49), totalPx, warnings);
|
|
89
|
-
const attributedPx = roundPx(visibleContributions.reduce((sum, contribution) => sum + contribution.valuePx, 0));
|
|
90
|
-
const unattributedPx = roundPx(Math.max(0, totalPx - attributedPx));
|
|
91
|
-
if (unattributedPx > 0.49) {
|
|
92
|
-
warnings.push(`${formatPx(unattributedPx)} is rendered space that could not be tied to a direct margin, padding, border, or gap declaration. Check flex/grid distribution, widths, min-width, transforms, or empty wrappers.`);
|
|
93
|
-
}
|
|
94
|
-
const measurement = {
|
|
95
|
-
axis,
|
|
96
|
-
totalPx: roundPx(totalPx),
|
|
97
|
-
attributedPx,
|
|
98
|
-
unattributedPx,
|
|
99
|
-
from: elementInfo(fromElement),
|
|
100
|
-
to: elementInfo(toElement),
|
|
101
|
-
commonAncestor: commonAncestor ? elementInfo(commonAncestor) : undefined,
|
|
102
|
-
contributions: visibleContributions,
|
|
103
|
-
warnings
|
|
104
|
-
};
|
|
105
|
-
const equation = buildEquation(measurement);
|
|
106
|
-
return {
|
|
107
|
-
...measurement,
|
|
108
|
-
equation,
|
|
109
|
-
markdown: buildMarkdown({ ...measurement, equation, markdown: "" })
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
export function inspectPoint(options) {
|
|
113
|
-
const doc = options.document ?? document;
|
|
114
|
-
const axis = options.axis ?? "horizontal";
|
|
115
|
-
const ignoreElements = new Set((options.ignoreElements ?? []).filter((element) => Boolean(element)));
|
|
116
|
-
const marginInspection = inspectMarginAtPoint(doc, axis, options.point, ignoreElements);
|
|
117
|
-
if (marginInspection) {
|
|
118
|
-
return marginInspection;
|
|
119
|
-
}
|
|
120
|
-
const target = deepestElementAtPoint(doc, options.point.x, options.point.y, ignoreElements);
|
|
121
|
-
if (!target || isStructuralPageElement(target)) {
|
|
122
|
-
return inspectGapAtPoint(doc, axis, options.point, ignoreElements);
|
|
123
|
-
}
|
|
124
|
-
if (isContainerLikeHit(target, options.point)) {
|
|
125
|
-
const gapInspection = inspectGapAtPoint(doc, axis, options.point, ignoreElements);
|
|
126
|
-
if (gapInspection) {
|
|
127
|
-
return gapInspection;
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
return inspectElementAtPoint(axis, options.point, target);
|
|
131
|
-
}
|
|
132
|
-
function measureInternalGap(axis, endpoints) {
|
|
133
|
-
const props = AXIS_PROPS[axis];
|
|
134
|
-
const containerRect = toGapRect(endpoints.container.getBoundingClientRect());
|
|
135
|
-
const childRect = toGapRect(endpoints.child.getBoundingClientRect());
|
|
136
|
-
const totalPx = endpoints.side === "before"
|
|
137
|
-
? Math.max(0, childRect[props.start] - containerRect[props.start])
|
|
138
|
-
: Math.max(0, containerRect[props.end] - childRect[props.end]);
|
|
139
|
-
const warnings = [];
|
|
140
|
-
appendGeometryWarnings(warnings, [endpoints.container, endpoints.child]);
|
|
141
|
-
const visibleContributions = limitContributionsToMeasuredGap(collapseContributions(describeContainedGap(axis, endpoints.container, endpoints.child, endpoints.side, warnings))
|
|
142
|
-
.filter((contribution) => contribution.valuePx > 0.49), totalPx, warnings);
|
|
143
|
-
const attributedPx = roundPx(visibleContributions.reduce((sum, contribution) => sum + contribution.valuePx, 0));
|
|
144
|
-
const unattributedPx = roundPx(Math.max(0, totalPx - attributedPx));
|
|
145
|
-
if (unattributedPx > 0.49) {
|
|
146
|
-
warnings.push(`${formatPx(unattributedPx)} is internal rendered space that could not be tied to a direct margin, padding, border, or gap declaration. Check nested wrappers, explicit widths, or positioned children.`);
|
|
147
|
-
}
|
|
148
|
-
const measurement = {
|
|
149
|
-
axis,
|
|
150
|
-
totalPx: roundPx(totalPx),
|
|
151
|
-
attributedPx,
|
|
152
|
-
unattributedPx,
|
|
153
|
-
from: elementInfo(endpoints.container),
|
|
154
|
-
to: elementInfo(endpoints.child),
|
|
155
|
-
commonAncestor: elementInfo(endpoints.container),
|
|
156
|
-
internalSide: endpoints.side,
|
|
157
|
-
contributions: visibleContributions,
|
|
158
|
-
warnings
|
|
159
|
-
};
|
|
160
|
-
const equation = buildEquation(measurement);
|
|
161
|
-
return {
|
|
162
|
-
...measurement,
|
|
163
|
-
equation,
|
|
164
|
-
markdown: buildMarkdown({ ...measurement, equation, markdown: "" })
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
|
-
function normalizeLine(axis, start, end) {
|
|
168
|
-
const props = AXIS_PROPS[axis];
|
|
169
|
-
const startAlong = axis === "horizontal" ? start.x : start.y;
|
|
170
|
-
const endAlong = axis === "horizontal" ? end.x : end.y;
|
|
171
|
-
const perp = axis === "horizontal"
|
|
172
|
-
? (start.y + end.y) / 2
|
|
173
|
-
: (start.x + end.x) / 2;
|
|
174
|
-
return {
|
|
175
|
-
min: Math.min(startAlong, endAlong),
|
|
176
|
-
max: Math.max(startAlong, endAlong),
|
|
177
|
-
mid: (startAlong + endAlong) / 2,
|
|
178
|
-
perp,
|
|
179
|
-
props
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
function findBoundaryElements(doc, axis, line, ignoreElements) {
|
|
183
|
-
const elements = uniqueElementCandidates(queryAllDeep(doc.body)
|
|
184
|
-
.filter((element) => shouldInspectElement(element, ignoreElements))
|
|
185
|
-
.map((element) => normalizeScannedTarget(element))
|
|
186
|
-
.filter((element) => !isStructuralPageElement(element)))
|
|
187
|
-
.map((element) => ({ element, rect: toGapRect(element.getBoundingClientRect()) }))
|
|
188
|
-
.filter(({ rect }) => rect.width > 0 && rect.height > 0)
|
|
189
|
-
.filter(({ rect }) => line.perp >= rect[line.props.perpStart] - 2 && line.perp <= rect[line.props.perpEnd] + 2);
|
|
190
|
-
const fromCandidates = elements
|
|
191
|
-
.filter(({ rect }) => rect[line.props.end] <= line.max + 8)
|
|
192
|
-
.sort((a, b) => edgeCandidateScore(a.element, a.rect, line, "from") - edgeCandidateScore(b.element, b.rect, line, "from"));
|
|
193
|
-
const toCandidates = elements
|
|
194
|
-
.filter(({ rect }) => rect[line.props.start] >= line.min - 8)
|
|
195
|
-
.sort((a, b) => edgeCandidateScore(a.element, a.rect, line, "to") - edgeCandidateScore(b.element, b.rect, line, "to"));
|
|
196
|
-
const bestPair = chooseBestEndpointPair(fromCandidates, toCandidates, line);
|
|
197
|
-
if (bestPair) {
|
|
198
|
-
return bestPair;
|
|
199
|
-
}
|
|
200
|
-
const startElement = elementFromPointIgnoring(doc, axis, line.min, line.perp, ignoreElements);
|
|
201
|
-
const endElement = elementFromPointIgnoring(doc, axis, line.max, line.perp, ignoreElements);
|
|
202
|
-
if (!startElement || !endElement || startElement === endElement) {
|
|
203
|
-
return null;
|
|
204
|
-
}
|
|
205
|
-
const startRect = toGapRect(startElement.getBoundingClientRect());
|
|
206
|
-
const endRect = toGapRect(endElement.getBoundingClientRect());
|
|
207
|
-
return startRect[line.props.end] <= endRect[line.props.start]
|
|
208
|
-
? { kind: "normal", from: startElement, to: endElement }
|
|
209
|
-
: { kind: "normal", from: endElement, to: startElement };
|
|
210
|
-
}
|
|
211
|
-
function findEndpointElementsFromPointer(doc, axis, start, end, ignoreElements) {
|
|
212
|
-
const startElement = deepestElementAtPoint(doc, start.x, start.y, ignoreElements);
|
|
213
|
-
const endElement = deepestElementAtPoint(doc, end.x, end.y, ignoreElements);
|
|
214
|
-
if (!startElement || !endElement || startElement === endElement) {
|
|
215
|
-
return null;
|
|
216
|
-
}
|
|
217
|
-
if (startElement.contains(endElement) || endElement.contains(startElement)) {
|
|
218
|
-
return containedEndpointPair(axis, startElement, endElement, start, end);
|
|
219
|
-
}
|
|
220
|
-
const props = AXIS_PROPS[axis];
|
|
221
|
-
const startRect = toGapRect(startElement.getBoundingClientRect());
|
|
222
|
-
const endRect = toGapRect(endElement.getBoundingClientRect());
|
|
223
|
-
if (startRect[props.end] <= endRect[props.start]) {
|
|
224
|
-
if (endRect[props.start] - startRect[props.end] < 0.5) {
|
|
225
|
-
return null;
|
|
226
|
-
}
|
|
227
|
-
return { kind: "normal", from: startElement, to: endElement };
|
|
228
|
-
}
|
|
229
|
-
if (endRect[props.end] <= startRect[props.start]) {
|
|
230
|
-
if (startRect[props.start] - endRect[props.end] < 0.5) {
|
|
231
|
-
return null;
|
|
232
|
-
}
|
|
233
|
-
return { kind: "normal", from: endElement, to: startElement };
|
|
234
|
-
}
|
|
235
|
-
return null;
|
|
236
|
-
}
|
|
237
|
-
function containedEndpointPair(axis, startElement, endElement, start, end) {
|
|
238
|
-
const startContainsEnd = startElement.contains(endElement);
|
|
239
|
-
const container = startContainsEnd ? startElement : endElement;
|
|
240
|
-
const child = startContainsEnd ? endElement : startElement;
|
|
241
|
-
const containerPoint = startContainsEnd ? start : end;
|
|
242
|
-
const childPoint = startContainsEnd ? end : start;
|
|
243
|
-
const childRect = toGapRect(child.getBoundingClientRect());
|
|
244
|
-
const props = AXIS_PROPS[axis];
|
|
245
|
-
const pointAlong = axis === "horizontal" ? containerPoint.x : containerPoint.y;
|
|
246
|
-
const childPointAlong = axis === "horizontal" ? childPoint.x : childPoint.y;
|
|
247
|
-
const side = pointAlong <= childRect[props.start]
|
|
248
|
-
? "before"
|
|
249
|
-
: pointAlong >= childRect[props.end]
|
|
250
|
-
? "after"
|
|
251
|
-
: pointAlong < childPointAlong
|
|
252
|
-
? "before"
|
|
253
|
-
: "after";
|
|
254
|
-
const containerRect = toGapRect(container.getBoundingClientRect());
|
|
255
|
-
const totalPx = side === "before"
|
|
256
|
-
? childRect[props.start] - containerRect[props.start]
|
|
257
|
-
: containerRect[props.end] - childRect[props.end];
|
|
258
|
-
if (totalPx < 0.5) {
|
|
259
|
-
return null;
|
|
260
|
-
}
|
|
261
|
-
return {
|
|
262
|
-
kind: "internal",
|
|
263
|
-
container,
|
|
264
|
-
child,
|
|
265
|
-
side
|
|
266
|
-
};
|
|
267
|
-
}
|
|
268
|
-
function deepestElementAtPoint(doc, x, y, ignoreElements) {
|
|
269
|
-
const elements = elementsFromPointDeep(doc, x, y, ignoreElements);
|
|
270
|
-
for (const element of elements) {
|
|
271
|
-
if (!(element instanceof HTMLElement)) {
|
|
272
|
-
continue;
|
|
273
|
-
}
|
|
274
|
-
if (!shouldInspectElement(element, ignoreElements)) {
|
|
275
|
-
continue;
|
|
276
|
-
}
|
|
277
|
-
if (isStructuralPageElement(element)) {
|
|
278
|
-
continue;
|
|
279
|
-
}
|
|
280
|
-
return element;
|
|
281
|
-
}
|
|
282
|
-
return null;
|
|
283
|
-
}
|
|
284
|
-
// Hit-testing that descends into open shadow roots: drill into the topmost
|
|
285
|
-
// non-ignored hit while it hosts a shadow tree, then report deepest-tree-first.
|
|
286
|
-
function elementsFromPointDeep(doc, x, y, ignoreElements) {
|
|
287
|
-
const layers = [];
|
|
288
|
-
let scope = doc;
|
|
289
|
-
for (let depth = 0; depth < 12; depth += 1) {
|
|
290
|
-
const elements = scope.elementsFromPoint(x, y)
|
|
291
|
-
.filter((element) => element.getRootNode() === scope);
|
|
292
|
-
if (!elements.length) {
|
|
293
|
-
break;
|
|
294
|
-
}
|
|
295
|
-
layers.push(elements);
|
|
296
|
-
const top = elements.find((element) => element instanceof HTMLElement
|
|
297
|
-
&& !ignoreElements.has(element)
|
|
298
|
-
&& !containsAnyIgnored(element, ignoreElements));
|
|
299
|
-
const shadowRoot = top instanceof HTMLElement ? top.shadowRoot : null;
|
|
300
|
-
if (!shadowRoot) {
|
|
301
|
-
break;
|
|
302
|
-
}
|
|
303
|
-
scope = shadowRoot;
|
|
304
|
-
}
|
|
305
|
-
return layers.reverse().flat();
|
|
306
|
-
}
|
|
307
|
-
function queryAllDeep(scope) {
|
|
308
|
-
const results = [];
|
|
309
|
-
const visit = (node) => {
|
|
310
|
-
for (const element of Array.from(node.querySelectorAll("*"))) {
|
|
311
|
-
results.push(element);
|
|
312
|
-
if (element.shadowRoot) {
|
|
313
|
-
visit(element.shadowRoot);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
};
|
|
317
|
-
visit(scope);
|
|
318
|
-
return results;
|
|
319
|
-
}
|
|
320
|
-
function parentThroughShadow(element) {
|
|
321
|
-
if (element.parentElement) {
|
|
322
|
-
return element.parentElement;
|
|
323
|
-
}
|
|
324
|
-
const root = element.getRootNode();
|
|
325
|
-
return root instanceof ShadowRoot ? root.host : null;
|
|
326
|
-
}
|
|
327
|
-
function normalizeScannedTarget(element) {
|
|
328
|
-
let current = element;
|
|
329
|
-
while (current.parentElement
|
|
330
|
-
&& !isStructuralPageElement(current.parentElement)
|
|
331
|
-
&& shouldClimbPointTarget(current, current.parentElement)) {
|
|
332
|
-
current = current.parentElement;
|
|
333
|
-
}
|
|
334
|
-
return current;
|
|
335
|
-
}
|
|
336
|
-
function uniqueElementCandidates(elements) {
|
|
337
|
-
return Array.from(new Set(elements));
|
|
338
|
-
}
|
|
339
|
-
function shouldClimbPointTarget(element, parent) {
|
|
340
|
-
const tagName = element.tagName.toLowerCase();
|
|
341
|
-
const rect = element.getBoundingClientRect();
|
|
342
|
-
const parentRect = parent.getBoundingClientRect();
|
|
343
|
-
const style = getComputedStyle(element);
|
|
344
|
-
if (["span", "strong", "em", "small", "label", "b", "i"].includes(tagName)) {
|
|
345
|
-
return true;
|
|
346
|
-
}
|
|
347
|
-
if (style.display === "inline") {
|
|
348
|
-
return true;
|
|
349
|
-
}
|
|
350
|
-
if (rect.height < 22 && parentRect.height > rect.height * 1.5) {
|
|
351
|
-
return true;
|
|
352
|
-
}
|
|
353
|
-
if (rect.width < 32 && parentRect.width > rect.width * 1.5) {
|
|
354
|
-
return true;
|
|
355
|
-
}
|
|
356
|
-
return false;
|
|
357
|
-
}
|
|
358
|
-
const STRUCTURAL_ROOT_IDS = new Set(["root", "app", "__next", "__nuxt"]);
|
|
359
|
-
function isStructuralPageElement(element) {
|
|
360
|
-
const tagName = element.tagName.toLowerCase();
|
|
361
|
-
return tagName === "html" || tagName === "body" || STRUCTURAL_ROOT_IDS.has(element.id);
|
|
362
|
-
}
|
|
363
|
-
function edgeCandidateScore(element, rect, line, side) {
|
|
364
|
-
const edge = side === "from" ? rect[line.props.end] : rect[line.props.start];
|
|
365
|
-
const target = side === "from" ? line.min : line.max;
|
|
366
|
-
const endpointDistance = Math.abs(edge - target);
|
|
367
|
-
const elementSizePenalty = Math.sqrt(rectArea(rect)) * 0.015;
|
|
368
|
-
const depthReward = elementDepth(element) * 0.35;
|
|
369
|
-
return endpointDistance + elementSizePenalty - depthReward;
|
|
370
|
-
}
|
|
371
|
-
function chooseBestEndpointPair(fromCandidates, toCandidates, line) {
|
|
372
|
-
const drawnGap = line.max - line.min;
|
|
373
|
-
let best = null;
|
|
374
|
-
for (const fromCandidate of fromCandidates.slice(0, 40)) {
|
|
375
|
-
for (const toCandidate of toCandidates.slice(0, 40)) {
|
|
376
|
-
if (fromCandidate.element === toCandidate.element) {
|
|
377
|
-
continue;
|
|
378
|
-
}
|
|
379
|
-
if (fromCandidate.element.contains(toCandidate.element)
|
|
380
|
-
|| toCandidate.element.contains(fromCandidate.element)) {
|
|
381
|
-
continue;
|
|
382
|
-
}
|
|
383
|
-
const gap = toCandidate.rect[line.props.start] - fromCandidate.rect[line.props.end];
|
|
384
|
-
if (gap < 0.5) {
|
|
385
|
-
continue;
|
|
386
|
-
}
|
|
387
|
-
const score = edgeCandidateScore(fromCandidate.element, fromCandidate.rect, line, "from")
|
|
388
|
-
+ edgeCandidateScore(toCandidate.element, toCandidate.rect, line, "to")
|
|
389
|
-
+ Math.abs(gap - drawnGap) * 0.4
|
|
390
|
-
+ sharedAncestorDistance(fromCandidate.element, toCandidate.element) * 0.2;
|
|
391
|
-
if (!best || score < best.score) {
|
|
392
|
-
best = {
|
|
393
|
-
from: fromCandidate.element,
|
|
394
|
-
to: toCandidate.element,
|
|
395
|
-
score
|
|
396
|
-
};
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
return best ? { kind: "normal", from: best.from, to: best.to } : null;
|
|
401
|
-
}
|
|
402
|
-
function describeInternalSpace(axis, element, branch, direction, warnings) {
|
|
403
|
-
const levels = [];
|
|
404
|
-
const props = AXIS_PROPS[axis];
|
|
405
|
-
let current = element;
|
|
406
|
-
while (current && current !== branch) {
|
|
407
|
-
const parent = parentThroughShadow(current);
|
|
408
|
-
if (!parent) {
|
|
409
|
-
break;
|
|
410
|
-
}
|
|
411
|
-
const currentRect = toGapRect(current.getBoundingClientRect());
|
|
412
|
-
const parentRect = toGapRect(parent.getBoundingClientRect());
|
|
413
|
-
const style = getComputedStyle(parent);
|
|
414
|
-
const childStyle = getComputedStyle(current);
|
|
415
|
-
const measured = direction === "after"
|
|
416
|
-
? parentRect[props.end] - currentRect[props.end]
|
|
417
|
-
: currentRect[props.start] - parentRect[props.start];
|
|
418
|
-
warnNegativeMargin(warnings, childStyle, direction === "after" ? props.afterMargin : props.beforeMargin, current);
|
|
419
|
-
if (measured > 0.49) {
|
|
420
|
-
const marginProperty = direction === "after" ? props.afterMargin : props.beforeMargin;
|
|
421
|
-
const paddingProperty = direction === "after" ? props.afterPadding : props.beforePadding;
|
|
422
|
-
const borderProperty = direction === "after" ? props.afterBorder : props.beforeBorder;
|
|
423
|
-
const margin = positivePx(childStyle[marginProperty]);
|
|
424
|
-
const padding = positivePx(style[paddingProperty]);
|
|
425
|
-
const border = positivePx(style[borderProperty]);
|
|
426
|
-
const scrollbar = scrollbarGutterPx(axis, direction, parent, style);
|
|
427
|
-
const residual = measured - margin - padding - border - scrollbar;
|
|
428
|
-
// One wrapper level, in geometric order along the axis. Walking "after"
|
|
429
|
-
// (from-side) the span reads margin -> residual -> padding -> scrollbar
|
|
430
|
-
// -> border; walking "before" (to-side) it is the mirror image.
|
|
431
|
-
const level = [];
|
|
432
|
-
pushContribution(level, "margin", marginProperty, margin, childStyle[marginProperty], current);
|
|
433
|
-
pushContribution(level, "layout", direction === "after" ? "inner inline-end space" : "inner inline-start space", residual, undefined, parent, "Rendered space inside this wrapper between the measured element and the wrapper edge.");
|
|
434
|
-
pushContribution(level, "padding", paddingProperty, padding, style[paddingProperty], parent);
|
|
435
|
-
pushContribution(level, "scrollbar", "scrollbar gutter", Math.min(scrollbar, measured), style.scrollbarGutter, parent, "Native scrollbar gutter inside this scroll container.");
|
|
436
|
-
pushContribution(level, "border", borderProperty, border, style[borderProperty], parent);
|
|
437
|
-
if (direction === "before") {
|
|
438
|
-
level.reverse();
|
|
439
|
-
}
|
|
440
|
-
levels.push(level);
|
|
441
|
-
}
|
|
442
|
-
current = parent;
|
|
443
|
-
}
|
|
444
|
-
// "after" walks outward in axis direction; "before" walks outward against it,
|
|
445
|
-
// so its levels flatten in reverse to keep the whole list axis-ordered.
|
|
446
|
-
if (direction === "before") {
|
|
447
|
-
levels.reverse();
|
|
448
|
-
}
|
|
449
|
-
return levels.flat();
|
|
450
|
-
}
|
|
451
|
-
function describeBetweenBranches(axis, ancestor, fromBranch, toBranch, warnings) {
|
|
452
|
-
const props = AXIS_PROPS[axis];
|
|
453
|
-
const ancestorStyle = getComputedStyle(ancestor);
|
|
454
|
-
const fromStyle = getComputedStyle(fromBranch);
|
|
455
|
-
const toStyle = getComputedStyle(toBranch);
|
|
456
|
-
const fromRect = toGapRect(fromBranch.getBoundingClientRect());
|
|
457
|
-
const toRect = toGapRect(toBranch.getBoundingClientRect());
|
|
458
|
-
const measured = Math.max(0, toRect[props.start] - fromRect[props.end]);
|
|
459
|
-
const contributions = [];
|
|
460
|
-
const afterMargin = positivePx(fromStyle[props.afterMargin]);
|
|
461
|
-
const beforeMargin = positivePx(toStyle[props.beforeMargin]);
|
|
462
|
-
const gap = layoutGapPx(ancestorStyle, props.gap, props.alternateGap);
|
|
463
|
-
warnNegativeMargin(warnings, fromStyle, props.afterMargin, fromBranch);
|
|
464
|
-
warnNegativeMargin(warnings, toStyle, props.beforeMargin, toBranch);
|
|
465
|
-
const marginsCollapse = axis === "vertical"
|
|
466
|
-
&& !isGapLayout(ancestorStyle.display)
|
|
467
|
-
&& !ancestorStyle.display.includes("table")
|
|
468
|
-
&& isBlockLevel(fromStyle)
|
|
469
|
-
&& isBlockLevel(toStyle);
|
|
470
|
-
if (marginsCollapse && (afterMargin > 0.49 || beforeMargin > 0.49)) {
|
|
471
|
-
const fromWins = afterMargin >= beforeMargin;
|
|
472
|
-
const winner = fromWins
|
|
473
|
-
? { property: props.afterMargin, value: afterMargin, cssValue: fromStyle[props.afterMargin], element: fromBranch }
|
|
474
|
-
: { property: props.beforeMargin, value: beforeMargin, cssValue: toStyle[props.beforeMargin], element: toBranch };
|
|
475
|
-
const loser = fromWins
|
|
476
|
-
? { property: props.beforeMargin, value: beforeMargin, cssValue: toStyle[props.beforeMargin] }
|
|
477
|
-
: { property: props.afterMargin, value: afterMargin, cssValue: fromStyle[props.afterMargin] };
|
|
478
|
-
pushContribution(contributions, "margin", winner.property, winner.value, winner.cssValue, winner.element, loser.value > 0.49
|
|
479
|
-
? `Adjacent vertical margins collapse: ${cssPropertyName(loser.property)} (${loser.cssValue}) on the sibling collapsed into this larger margin.`
|
|
480
|
-
: undefined);
|
|
481
|
-
}
|
|
482
|
-
else {
|
|
483
|
-
pushContribution(contributions, "margin", props.afterMargin, afterMargin, fromStyle[props.afterMargin], fromBranch);
|
|
484
|
-
}
|
|
485
|
-
if (isGapLayout(ancestorStyle.display)) {
|
|
486
|
-
pushContribution(contributions, "gap", props.gap, Math.min(gap, measured), ancestorStyle[props.gap] || ancestorStyle[props.alternateGap], ancestor, `${ancestorStyle.display} parent`);
|
|
487
|
-
}
|
|
488
|
-
const attributedMargins = marginsCollapse ? Math.max(afterMargin, beforeMargin) : afterMargin + beforeMargin;
|
|
489
|
-
const residual = measured - attributedMargins - (isGapLayout(ancestorStyle.display) ? gap : 0);
|
|
490
|
-
pushContribution(contributions, "layout", layoutDistributionProperty(ancestorStyle), residual, undefined, ancestor, "Rendered space between the sibling layout branches.");
|
|
491
|
-
if (!marginsCollapse) {
|
|
492
|
-
pushContribution(contributions, "margin", props.beforeMargin, beforeMargin, toStyle[props.beforeMargin], toBranch);
|
|
493
|
-
}
|
|
494
|
-
return contributions;
|
|
495
|
-
}
|
|
496
|
-
function describeContainedGap(axis, container, child, side, warnings) {
|
|
497
|
-
const props = AXIS_PROPS[axis];
|
|
498
|
-
const containerRect = toGapRect(container.getBoundingClientRect());
|
|
499
|
-
const childRect = toGapRect(child.getBoundingClientRect());
|
|
500
|
-
const containerStyle = getComputedStyle(container);
|
|
501
|
-
const childStyle = getComputedStyle(child);
|
|
502
|
-
const measured = side === "before"
|
|
503
|
-
? childRect[props.start] - containerRect[props.start]
|
|
504
|
-
: containerRect[props.end] - childRect[props.end];
|
|
505
|
-
const contributions = [];
|
|
506
|
-
const paddingProperty = side === "before" ? props.beforePadding : props.afterPadding;
|
|
507
|
-
const borderProperty = side === "before" ? props.beforeBorder : props.afterBorder;
|
|
508
|
-
const marginProperty = side === "before" ? props.beforeMargin : props.afterMargin;
|
|
509
|
-
const padding = positivePx(containerStyle[paddingProperty]);
|
|
510
|
-
const border = positivePx(containerStyle[borderProperty]);
|
|
511
|
-
const margin = positivePx(childStyle[marginProperty]);
|
|
512
|
-
warnNegativeMargin(warnings, childStyle, marginProperty, child);
|
|
513
|
-
const residual = measured - padding - border - margin;
|
|
514
|
-
const residualContribution = [
|
|
515
|
-
"layout",
|
|
516
|
-
side === "before" ? "internal start space" : "internal end space",
|
|
517
|
-
residual,
|
|
518
|
-
undefined,
|
|
519
|
-
container,
|
|
520
|
-
"Rendered internal space between the container edge and the selected child."
|
|
521
|
-
];
|
|
522
|
-
// Geometric order along the axis: before-side reads container border ->
|
|
523
|
-
// padding -> residual -> child margin; after-side is the mirror image.
|
|
524
|
-
if (side === "before") {
|
|
525
|
-
pushContribution(contributions, "border", borderProperty, border, containerStyle[borderProperty], container);
|
|
526
|
-
pushContribution(contributions, "padding", paddingProperty, padding, containerStyle[paddingProperty], container);
|
|
527
|
-
pushContribution(contributions, ...residualContribution);
|
|
528
|
-
pushContribution(contributions, "margin", marginProperty, margin, childStyle[marginProperty], child);
|
|
529
|
-
}
|
|
530
|
-
else {
|
|
531
|
-
pushContribution(contributions, "margin", marginProperty, margin, childStyle[marginProperty], child);
|
|
532
|
-
pushContribution(contributions, ...residualContribution);
|
|
533
|
-
pushContribution(contributions, "padding", paddingProperty, padding, containerStyle[paddingProperty], container);
|
|
534
|
-
pushContribution(contributions, "border", borderProperty, border, containerStyle[borderProperty], container);
|
|
535
|
-
}
|
|
536
|
-
return contributions;
|
|
537
|
-
}
|
|
538
|
-
function makeUnknownContribution(axis, element, valuePx, note) {
|
|
539
|
-
return {
|
|
540
|
-
kind: "unknown",
|
|
541
|
-
property: `${axis} gap`,
|
|
542
|
-
valuePx: roundPx(valuePx),
|
|
543
|
-
selector: selectorForElement(element),
|
|
544
|
-
element: elementInfo(element),
|
|
545
|
-
note
|
|
546
|
-
};
|
|
547
|
-
}
|
|
548
|
-
function pushContribution(contributions, kind, property, valuePx, cssValue, element, note) {
|
|
549
|
-
if (valuePx <= 0.49) {
|
|
550
|
-
return;
|
|
551
|
-
}
|
|
552
|
-
contributions.push({
|
|
553
|
-
kind,
|
|
554
|
-
property: cssPropertyName(property),
|
|
555
|
-
valuePx: roundPx(valuePx),
|
|
556
|
-
cssValue,
|
|
557
|
-
selector: selectorForElement(element),
|
|
558
|
-
element: elementInfo(element),
|
|
559
|
-
note
|
|
560
|
-
});
|
|
561
|
-
}
|
|
562
|
-
function collapseContributions(contributions) {
|
|
563
|
-
const byKey = new Map();
|
|
564
|
-
for (const contribution of contributions) {
|
|
565
|
-
const key = [
|
|
566
|
-
contribution.kind,
|
|
567
|
-
contribution.property,
|
|
568
|
-
contribution.selector,
|
|
569
|
-
contribution.note ?? ""
|
|
570
|
-
].join("::");
|
|
571
|
-
const existing = byKey.get(key);
|
|
572
|
-
if (existing) {
|
|
573
|
-
existing.valuePx = roundPx(existing.valuePx + contribution.valuePx);
|
|
574
|
-
}
|
|
575
|
-
else {
|
|
576
|
-
byKey.set(key, { ...contribution });
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
return Array.from(byKey.values());
|
|
580
|
-
}
|
|
581
|
-
function limitContributionsToMeasuredGap(contributions, totalPx, warnings) {
|
|
582
|
-
let remaining = roundPx(totalPx);
|
|
583
|
-
const limited = [];
|
|
584
|
-
let capped = false;
|
|
585
|
-
for (const contribution of contributions) {
|
|
586
|
-
if (remaining <= 0.49) {
|
|
587
|
-
capped = true;
|
|
588
|
-
continue;
|
|
589
|
-
}
|
|
590
|
-
if (contribution.valuePx > remaining) {
|
|
591
|
-
capped = true;
|
|
592
|
-
limited.push({
|
|
593
|
-
...contribution,
|
|
594
|
-
valuePx: remaining,
|
|
595
|
-
note: [
|
|
596
|
-
contribution.note,
|
|
597
|
-
`Raw computed value was ${formatPx(contribution.valuePx)}; capped to the remaining measured gap.`
|
|
598
|
-
].filter(Boolean).join(" ")
|
|
599
|
-
});
|
|
600
|
-
remaining = 0;
|
|
601
|
-
continue;
|
|
602
|
-
}
|
|
603
|
-
limited.push(contribution);
|
|
604
|
-
remaining = roundPx(remaining - contribution.valuePx);
|
|
605
|
-
}
|
|
606
|
-
if (capped) {
|
|
607
|
-
warnings.push("Candidate contributors exceeded the rendered gap, usually because nested wrappers overlap in the measurement path. Values were capped so the equation stays tied to measured geometry.");
|
|
608
|
-
}
|
|
609
|
-
return limited;
|
|
610
|
-
}
|
|
611
|
-
function buildEquation(measurement) {
|
|
612
|
-
const parts = measurement.contributions.map((contribution) => formatPx(contribution.valuePx));
|
|
613
|
-
if (measurement.unattributedPx > 0.49) {
|
|
614
|
-
parts.push(`${formatPx(measurement.unattributedPx)} unattributed`);
|
|
615
|
-
}
|
|
616
|
-
return `${parts.length ? parts.join(" + ") : "0px"} = ${formatPx(measurement.totalPx)}`;
|
|
617
|
-
}
|
|
618
|
-
function buildMarkdown(measurement) {
|
|
619
|
-
const lines = [
|
|
620
|
-
`Measured ${measurement.axis} gap: ${formatPx(measurement.totalPx)}`,
|
|
621
|
-
"",
|
|
622
|
-
`From: \`${measurement.from.selector}\``,
|
|
623
|
-
`To: \`${measurement.to.selector}\``
|
|
624
|
-
];
|
|
625
|
-
if (measurement.commonAncestor) {
|
|
626
|
-
lines.push(`Common ancestor: \`${measurement.commonAncestor.selector}\``);
|
|
627
|
-
}
|
|
628
|
-
lines.push("", "Contributions:");
|
|
629
|
-
if (measurement.contributions.length === 0) {
|
|
630
|
-
lines.push("- No direct box-model contributors found.");
|
|
631
|
-
}
|
|
632
|
-
else {
|
|
633
|
-
for (const contribution of measurement.contributions) {
|
|
634
|
-
const cssValue = contribution.cssValue ? ` (${contribution.cssValue})` : "";
|
|
635
|
-
const note = contribution.note ? ` - ${contribution.note}` : "";
|
|
636
|
-
lines.push(`- ${formatPx(contribution.valuePx)} from \`${contribution.selector}\` ${contribution.property}${cssValue}${note}`);
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
lines.push("", `Equation: ${measurement.equation}`);
|
|
640
|
-
if (measurement.warnings.length) {
|
|
641
|
-
lines.push("", "Warnings:");
|
|
642
|
-
for (const warning of measurement.warnings) {
|
|
643
|
-
lines.push(`- ${warning}`);
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
return lines.join("\n");
|
|
647
|
-
}
|
|
648
|
-
function shouldInspectElement(element, ignoreElements) {
|
|
649
|
-
if (ignoreElements.has(element) || containsAnyIgnored(element, ignoreElements)) {
|
|
650
|
-
return false;
|
|
651
|
-
}
|
|
652
|
-
const tagName = element.tagName.toLowerCase();
|
|
653
|
-
if (["script", "style", "template", "noscript", "meta", "link"].includes(tagName)) {
|
|
654
|
-
return false;
|
|
655
|
-
}
|
|
656
|
-
const style = getComputedStyle(element);
|
|
657
|
-
return style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0";
|
|
658
|
-
}
|
|
659
|
-
function containsAnyIgnored(element, ignoreElements) {
|
|
660
|
-
for (const ignored of ignoreElements) {
|
|
661
|
-
if (ignored.contains(element) || element.contains(ignored)) {
|
|
662
|
-
return true;
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
return false;
|
|
666
|
-
}
|
|
667
|
-
function elementFromPointIgnoring(doc, axis, along, perp, ignoreElements) {
|
|
668
|
-
const x = axis === "horizontal" ? along : perp;
|
|
669
|
-
const y = axis === "horizontal" ? perp : along;
|
|
670
|
-
return elementsFromPointDeep(doc, x, y, ignoreElements).find((element) => element instanceof HTMLElement
|
|
671
|
-
&& !ignoreElements.has(element)
|
|
672
|
-
&& !containsAnyIgnored(element, ignoreElements)) ?? null;
|
|
673
|
-
}
|
|
674
|
-
function findCommonAncestor(a, b) {
|
|
675
|
-
const ancestors = new Set();
|
|
676
|
-
let current = a;
|
|
677
|
-
while (current) {
|
|
678
|
-
ancestors.add(current);
|
|
679
|
-
current = parentThroughShadow(current);
|
|
680
|
-
}
|
|
681
|
-
current = b;
|
|
682
|
-
while (current) {
|
|
683
|
-
if (ancestors.has(current)) {
|
|
684
|
-
return current;
|
|
685
|
-
}
|
|
686
|
-
current = parentThroughShadow(current);
|
|
687
|
-
}
|
|
688
|
-
return null;
|
|
689
|
-
}
|
|
690
|
-
function childBranchUnderAncestor(element, ancestor) {
|
|
691
|
-
let current = element;
|
|
692
|
-
let parent = parentThroughShadow(current);
|
|
693
|
-
while (parent && parent !== ancestor) {
|
|
694
|
-
current = parent;
|
|
695
|
-
parent = parentThroughShadow(current);
|
|
696
|
-
}
|
|
697
|
-
return parent === ancestor ? current : null;
|
|
698
|
-
}
|
|
699
|
-
function layoutGapPx(style, primary, fallback) {
|
|
700
|
-
return positivePx(style[primary])
|
|
701
|
-
|| positivePx(style[fallback]);
|
|
702
|
-
}
|
|
703
|
-
function isGapLayout(display) {
|
|
704
|
-
return display.includes("flex") || display.includes("grid");
|
|
705
|
-
}
|
|
706
|
-
function isBlockLevel(style) {
|
|
707
|
-
return !style.display.startsWith("inline")
|
|
708
|
-
&& style.cssFloat === "none"
|
|
709
|
-
&& style.position !== "absolute"
|
|
710
|
-
&& style.position !== "fixed";
|
|
711
|
-
}
|
|
712
|
-
function warnNegativeMargin(warnings, style, property, element) {
|
|
713
|
-
const cssValue = style[property];
|
|
714
|
-
const parsed = Number.parseFloat(cssValue);
|
|
715
|
-
if (Number.isFinite(parsed) && parsed < -0.49) {
|
|
716
|
-
const warning = `${cssPropertyName(property)}: ${cssValue} on \`${selectorForElement(element)}\` pulls the boxes closer together; negative margins are not listed as contributors.`;
|
|
717
|
-
if (!warnings.includes(warning)) {
|
|
718
|
-
warnings.push(warning);
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
function appendGeometryWarnings(warnings, elements) {
|
|
723
|
-
const seen = new Set();
|
|
724
|
-
for (const element of elements) {
|
|
725
|
-
if (!element || seen.has(element)) {
|
|
726
|
-
continue;
|
|
727
|
-
}
|
|
728
|
-
seen.add(element);
|
|
729
|
-
const style = getComputedStyle(element);
|
|
730
|
-
const zoom = style.getPropertyValue("zoom");
|
|
731
|
-
const transformed = style.transform !== "none"
|
|
732
|
-
|| isActiveTransformValue(style.getPropertyValue("translate"))
|
|
733
|
-
|| isActiveTransformValue(style.getPropertyValue("rotate"))
|
|
734
|
-
|| isActiveTransformValue(style.getPropertyValue("scale"))
|
|
735
|
-
|| (zoom !== "" && zoom !== "1" && zoom !== "normal");
|
|
736
|
-
if (transformed) {
|
|
737
|
-
warnings.push(`\`${selectorForElement(element)}\` is transformed (transform/translate/rotate/scale/zoom); rendered pixels may not match its computed CSS values.`);
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
function isActiveTransformValue(value) {
|
|
742
|
-
return value !== "" && value !== "none";
|
|
743
|
-
}
|
|
744
|
-
function scrollbarGutterPx(axis, direction, element, style) {
|
|
745
|
-
if (!(element instanceof HTMLElement)) {
|
|
746
|
-
return 0;
|
|
747
|
-
}
|
|
748
|
-
const directionStyle = style.direction || "ltr";
|
|
749
|
-
if (axis === "horizontal") {
|
|
750
|
-
const overflowY = style.overflowY;
|
|
751
|
-
const canScrollY = overflowY === "auto" || overflowY === "scroll";
|
|
752
|
-
const hasScrollableContent = element.scrollHeight > element.clientHeight;
|
|
753
|
-
if (!canScrollY || (!hasScrollableContent && overflowY !== "scroll")) {
|
|
754
|
-
return 0;
|
|
755
|
-
}
|
|
756
|
-
const borderLeft = positivePx(style.borderLeftWidth);
|
|
757
|
-
const borderRight = positivePx(style.borderRightWidth);
|
|
758
|
-
const gutter = Math.max(0, element.offsetWidth - element.clientWidth - borderLeft - borderRight);
|
|
759
|
-
const gutterIsOnMeasuredSide = (direction === "after" && directionStyle !== "rtl")
|
|
760
|
-
|| (direction === "before" && directionStyle === "rtl");
|
|
761
|
-
return gutterIsOnMeasuredSide ? gutter : 0;
|
|
762
|
-
}
|
|
763
|
-
const overflowX = style.overflowX;
|
|
764
|
-
const canScrollX = overflowX === "auto" || overflowX === "scroll";
|
|
765
|
-
const hasScrollableContent = element.scrollWidth > element.clientWidth;
|
|
766
|
-
if (!canScrollX || (!hasScrollableContent && overflowX !== "scroll")) {
|
|
767
|
-
return 0;
|
|
768
|
-
}
|
|
769
|
-
const borderTop = positivePx(style.borderTopWidth);
|
|
770
|
-
const borderBottom = positivePx(style.borderBottomWidth);
|
|
771
|
-
const gutter = Math.max(0, element.offsetHeight - element.clientHeight - borderTop - borderBottom);
|
|
772
|
-
return direction === "after" ? gutter : 0;
|
|
773
|
-
}
|
|
774
|
-
function layoutDistributionProperty(style) {
|
|
775
|
-
if (style.display.includes("flex")) {
|
|
776
|
-
return `flex ${style.justifyContent || "layout space"}`;
|
|
777
|
-
}
|
|
778
|
-
if (style.display.includes("grid")) {
|
|
779
|
-
return "grid track/layout space";
|
|
780
|
-
}
|
|
781
|
-
if (style.position !== "static") {
|
|
782
|
-
return `${style.position} positioning`;
|
|
783
|
-
}
|
|
784
|
-
return "layout space";
|
|
785
|
-
}
|
|
786
|
-
function selectorForElement(element) {
|
|
787
|
-
const root = element.getRootNode();
|
|
788
|
-
if (root instanceof ShadowRoot) {
|
|
789
|
-
return `${selectorForElement(root.host)} >>> ${selectorWithinRoot(element, root)}`;
|
|
790
|
-
}
|
|
791
|
-
return selectorWithinRoot(element, element.ownerDocument ?? document);
|
|
792
|
-
}
|
|
793
|
-
function selectorWithinRoot(element, root) {
|
|
794
|
-
if (element.id) {
|
|
795
|
-
const idSelector = `#${cssEscape(element.id)}`;
|
|
796
|
-
if (isUniqueInRoot(idSelector, element, root)) {
|
|
797
|
-
return idSelector;
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
const testId = element.getAttribute("data-testid") ?? element.getAttribute("data-test");
|
|
801
|
-
if (testId) {
|
|
802
|
-
const attribute = element.hasAttribute("data-testid") ? "data-testid" : "data-test";
|
|
803
|
-
const testSelector = `[${attribute}="${testId.replace(/"/g, '\\"')}"]`;
|
|
804
|
-
if (isUniqueInRoot(testSelector, element, root)) {
|
|
805
|
-
return testSelector;
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
const readable = readablePathSelector(element);
|
|
809
|
-
if (readable && isUniqueInRoot(readable, element, root)) {
|
|
810
|
-
return readable;
|
|
811
|
-
}
|
|
812
|
-
return uniquePathSelector(element);
|
|
813
|
-
}
|
|
814
|
-
function readablePathSelector(element) {
|
|
815
|
-
const parts = [];
|
|
816
|
-
let current = element;
|
|
817
|
-
while (current && parts.length < 4 && current.tagName.toLowerCase() !== "html") {
|
|
818
|
-
const tag = current.tagName.toLowerCase();
|
|
819
|
-
const classes = Array.from(current.classList)
|
|
820
|
-
.filter((className) => !className.includes(":"))
|
|
821
|
-
.slice(0, 2)
|
|
822
|
-
.map((className) => `.${cssEscape(className)}`)
|
|
823
|
-
.join("");
|
|
824
|
-
const nth = nthOfType(current);
|
|
825
|
-
parts.unshift(`${tag}${classes}${nth ? `:nth-of-type(${nth})` : ""}`);
|
|
826
|
-
current = current.parentElement;
|
|
827
|
-
}
|
|
828
|
-
return parts.join(" > ");
|
|
829
|
-
}
|
|
830
|
-
function isUniqueInRoot(selector, element, root) {
|
|
831
|
-
try {
|
|
832
|
-
const matches = root.querySelectorAll(selector);
|
|
833
|
-
return matches.length === 1 && matches[0] === element;
|
|
834
|
-
}
|
|
835
|
-
catch {
|
|
836
|
-
return false;
|
|
837
|
-
}
|
|
838
|
-
}
|
|
839
|
-
// Guaranteed unique by construction: a full nth-child path from the tree root.
|
|
840
|
-
function uniquePathSelector(element) {
|
|
841
|
-
const parts = [];
|
|
842
|
-
let current = element;
|
|
843
|
-
while (current) {
|
|
844
|
-
const tag = current.tagName.toLowerCase();
|
|
845
|
-
const parent = current.parentNode;
|
|
846
|
-
if (parent && (parent instanceof Element || parent instanceof ShadowRoot)) {
|
|
847
|
-
const index = Array.prototype.indexOf.call(parent.children, current) + 1;
|
|
848
|
-
parts.unshift(`${tag}:nth-child(${index})`);
|
|
849
|
-
current = parent instanceof Element ? parent : null;
|
|
850
|
-
}
|
|
851
|
-
else {
|
|
852
|
-
parts.unshift(tag);
|
|
853
|
-
current = null;
|
|
854
|
-
}
|
|
855
|
-
}
|
|
856
|
-
return parts.join(" > ");
|
|
857
|
-
}
|
|
858
|
-
// Resolves selectors produced by this tool, including the " >>> " shadow-root
|
|
859
|
-
// separator, which document.querySelector cannot parse.
|
|
860
|
-
export function resolveSelector(selector, doc = document) {
|
|
861
|
-
const segments = selector.split(" >>> ");
|
|
862
|
-
let scope = doc;
|
|
863
|
-
let element = null;
|
|
864
|
-
for (let index = 0; index < segments.length; index += 1) {
|
|
865
|
-
try {
|
|
866
|
-
element = scope.querySelector(segments[index]);
|
|
867
|
-
}
|
|
868
|
-
catch {
|
|
869
|
-
return null;
|
|
870
|
-
}
|
|
871
|
-
if (!element) {
|
|
872
|
-
return null;
|
|
873
|
-
}
|
|
874
|
-
if (index < segments.length - 1) {
|
|
875
|
-
if (!element.shadowRoot) {
|
|
876
|
-
return null;
|
|
877
|
-
}
|
|
878
|
-
scope = element.shadowRoot;
|
|
879
|
-
}
|
|
880
|
-
}
|
|
881
|
-
return element;
|
|
882
|
-
}
|
|
883
|
-
function nthOfType(element) {
|
|
884
|
-
const parent = element.parentElement;
|
|
885
|
-
if (!parent) {
|
|
886
|
-
return null;
|
|
887
|
-
}
|
|
888
|
-
const sameTagSiblings = Array.from(parent.children)
|
|
889
|
-
.filter((child) => child.tagName === element.tagName);
|
|
890
|
-
if (sameTagSiblings.length <= 1) {
|
|
891
|
-
return null;
|
|
892
|
-
}
|
|
893
|
-
return sameTagSiblings.indexOf(element) + 1;
|
|
894
|
-
}
|
|
895
|
-
function elementInfo(element) {
|
|
896
|
-
return {
|
|
897
|
-
selector: selectorForElement(element),
|
|
898
|
-
tagName: element.tagName.toLowerCase(),
|
|
899
|
-
className: element.getAttribute("class") ?? "",
|
|
900
|
-
rect: toGapRect(element.getBoundingClientRect()),
|
|
901
|
-
element
|
|
902
|
-
};
|
|
903
|
-
}
|
|
904
|
-
function inspectElementAtPoint(axis, point, element) {
|
|
905
|
-
const rect = toGapRect(element.getBoundingClientRect());
|
|
906
|
-
const style = getComputedStyle(element);
|
|
907
|
-
const borderRect = rect;
|
|
908
|
-
const paddingRect = shrinkRect(borderRect, {
|
|
909
|
-
top: positivePx(style.borderTopWidth),
|
|
910
|
-
right: positivePx(style.borderRightWidth),
|
|
911
|
-
bottom: positivePx(style.borderBottomWidth),
|
|
912
|
-
left: positivePx(style.borderLeftWidth)
|
|
913
|
-
});
|
|
914
|
-
const contentRect = shrinkRect(paddingRect, {
|
|
915
|
-
top: positivePx(style.paddingTop),
|
|
916
|
-
right: positivePx(style.paddingRight),
|
|
917
|
-
bottom: positivePx(style.paddingBottom),
|
|
918
|
-
left: positivePx(style.paddingLeft)
|
|
919
|
-
});
|
|
920
|
-
let region = "content";
|
|
921
|
-
let property = "content box";
|
|
922
|
-
let cssValue;
|
|
923
|
-
let totalPx;
|
|
924
|
-
if (!pointInRect(point, paddingRect)) {
|
|
925
|
-
region = "border";
|
|
926
|
-
const side = nearestSide(point, borderRect);
|
|
927
|
-
property = cssPropertyName(`border${capitalize(side)}Width`);
|
|
928
|
-
cssValue = style[`border${capitalize(side)}Width`];
|
|
929
|
-
totalPx = positivePx(cssValue);
|
|
930
|
-
}
|
|
931
|
-
else if (!pointInRect(point, contentRect)) {
|
|
932
|
-
region = "padding";
|
|
933
|
-
const side = nearestSide(point, paddingRect);
|
|
934
|
-
property = cssPropertyName(`padding${capitalize(side)}`);
|
|
935
|
-
cssValue = style[`padding${capitalize(side)}`];
|
|
936
|
-
totalPx = positivePx(cssValue);
|
|
937
|
-
}
|
|
938
|
-
const inspection = {
|
|
939
|
-
kind: "point",
|
|
940
|
-
axis,
|
|
941
|
-
point,
|
|
942
|
-
region,
|
|
943
|
-
totalPx,
|
|
944
|
-
property,
|
|
945
|
-
cssValue,
|
|
946
|
-
element: elementInfo(element),
|
|
947
|
-
rect,
|
|
948
|
-
note: `Clicked inside ${region} region.`,
|
|
949
|
-
markdown: ""
|
|
950
|
-
};
|
|
951
|
-
return {
|
|
952
|
-
...inspection,
|
|
953
|
-
markdown: buildPointMarkdown(inspection)
|
|
954
|
-
};
|
|
955
|
-
}
|
|
956
|
-
function inspectMarginAtPoint(doc, axis, point, ignoreElements) {
|
|
957
|
-
const candidates = queryAllDeep(doc.body)
|
|
958
|
-
.filter((element) => shouldInspectElement(element, ignoreElements))
|
|
959
|
-
.map((element) => {
|
|
960
|
-
const style = getComputedStyle(element);
|
|
961
|
-
const rect = toGapRect(element.getBoundingClientRect());
|
|
962
|
-
const marginRect = expandRect(rect, {
|
|
963
|
-
top: positivePx(style.marginTop),
|
|
964
|
-
right: positivePx(style.marginRight),
|
|
965
|
-
bottom: positivePx(style.marginBottom),
|
|
966
|
-
left: positivePx(style.marginLeft)
|
|
967
|
-
});
|
|
968
|
-
return { element, style, rect, marginRect };
|
|
969
|
-
})
|
|
970
|
-
.filter(({ rect, marginRect }) => pointInRect(point, marginRect) && !pointInRect(point, rect))
|
|
971
|
-
.sort((a, b) => rectArea(a.marginRect) - rectArea(b.marginRect));
|
|
972
|
-
const candidate = candidates[0];
|
|
973
|
-
if (!candidate) {
|
|
974
|
-
return null;
|
|
975
|
-
}
|
|
976
|
-
const side = nearestMarginSide(point, candidate.rect);
|
|
977
|
-
const property = cssPropertyName(`margin${capitalize(side)}`);
|
|
978
|
-
const cssValue = candidate.style[`margin${capitalize(side)}`];
|
|
979
|
-
const inspection = {
|
|
980
|
-
kind: "point",
|
|
981
|
-
axis,
|
|
982
|
-
point,
|
|
983
|
-
region: "margin",
|
|
984
|
-
totalPx: positivePx(cssValue),
|
|
985
|
-
property,
|
|
986
|
-
cssValue,
|
|
987
|
-
element: elementInfo(candidate.element),
|
|
988
|
-
rect: candidate.marginRect,
|
|
989
|
-
note: "Clicked inside computed margin area.",
|
|
990
|
-
markdown: ""
|
|
991
|
-
};
|
|
992
|
-
return {
|
|
993
|
-
...inspection,
|
|
994
|
-
markdown: buildPointMarkdown(inspection)
|
|
995
|
-
};
|
|
996
|
-
}
|
|
997
|
-
function inspectGapAtPoint(doc, axis, point, ignoreElements) {
|
|
998
|
-
const props = AXIS_PROPS[axis];
|
|
999
|
-
const along = axis === "horizontal" ? point.x : point.y;
|
|
1000
|
-
const perp = axis === "horizontal" ? point.y : point.x;
|
|
1001
|
-
const candidates = uniqueElementCandidates(queryAllDeep(doc.body)
|
|
1002
|
-
.filter((element) => shouldInspectElement(element, ignoreElements))
|
|
1003
|
-
.map((element) => normalizeScannedTarget(element))
|
|
1004
|
-
.filter((element) => !isStructuralPageElement(element)))
|
|
1005
|
-
.map((element) => ({ element, rect: toGapRect(element.getBoundingClientRect()) }))
|
|
1006
|
-
.filter(({ rect }) => perp >= rect[props.perpStart] - 1 && perp <= rect[props.perpEnd] + 1)
|
|
1007
|
-
.filter(({ rect }) => !pointInRect(point, rect));
|
|
1008
|
-
const before = candidates
|
|
1009
|
-
.filter(({ rect }) => rect[props.end] <= along)
|
|
1010
|
-
.sort((a, b) => b.rect[props.end] - a.rect[props.end] || rectArea(a.rect) - rectArea(b.rect))[0];
|
|
1011
|
-
const after = candidates
|
|
1012
|
-
.filter(({ rect }) => rect[props.start] >= along)
|
|
1013
|
-
.sort((a, b) => a.rect[props.start] - b.rect[props.start] || rectArea(a.rect) - rectArea(b.rect))[0];
|
|
1014
|
-
if (!before || !after || before.element === after.element) {
|
|
1015
|
-
return null;
|
|
1016
|
-
}
|
|
1017
|
-
const totalPx = roundPx(after.rect[props.start] - before.rect[props.end]);
|
|
1018
|
-
if (totalPx < 0.5) {
|
|
1019
|
-
return null;
|
|
1020
|
-
}
|
|
1021
|
-
const rect = axis === "horizontal"
|
|
1022
|
-
? {
|
|
1023
|
-
left: before.rect.right,
|
|
1024
|
-
right: after.rect.left,
|
|
1025
|
-
top: perp - 6,
|
|
1026
|
-
bottom: perp + 6,
|
|
1027
|
-
width: totalPx,
|
|
1028
|
-
height: 12
|
|
1029
|
-
}
|
|
1030
|
-
: {
|
|
1031
|
-
left: perp - 6,
|
|
1032
|
-
right: perp + 6,
|
|
1033
|
-
top: before.rect.bottom,
|
|
1034
|
-
bottom: after.rect.top,
|
|
1035
|
-
width: 12,
|
|
1036
|
-
height: totalPx
|
|
1037
|
-
};
|
|
1038
|
-
const inspection = {
|
|
1039
|
-
kind: "point",
|
|
1040
|
-
axis,
|
|
1041
|
-
point,
|
|
1042
|
-
region: "gap",
|
|
1043
|
-
totalPx,
|
|
1044
|
-
property: `${axis} rendered gap`,
|
|
1045
|
-
from: elementInfo(before.element),
|
|
1046
|
-
to: elementInfo(after.element),
|
|
1047
|
-
rect,
|
|
1048
|
-
note: "Clicked in empty rendered space between two visible edges.",
|
|
1049
|
-
markdown: ""
|
|
1050
|
-
};
|
|
1051
|
-
return {
|
|
1052
|
-
...inspection,
|
|
1053
|
-
markdown: buildPointMarkdown(inspection)
|
|
1054
|
-
};
|
|
1055
|
-
}
|
|
1056
|
-
function buildPointMarkdown(inspection) {
|
|
1057
|
-
const lines = [
|
|
1058
|
-
`Point inspection: ${inspection.region}`,
|
|
1059
|
-
`Point: ${Math.round(inspection.point.x)}, ${Math.round(inspection.point.y)}`
|
|
1060
|
-
];
|
|
1061
|
-
if (inspection.totalPx !== undefined) {
|
|
1062
|
-
lines.push(`Value: ${formatPx(inspection.totalPx)}`);
|
|
1063
|
-
}
|
|
1064
|
-
if (inspection.property) {
|
|
1065
|
-
lines.push(`Property: ${inspection.property}${inspection.cssValue ? ` (${inspection.cssValue})` : ""}`);
|
|
1066
|
-
}
|
|
1067
|
-
if (inspection.element) {
|
|
1068
|
-
lines.push(`Element: \`${inspection.element.selector}\``);
|
|
1069
|
-
}
|
|
1070
|
-
if (inspection.from && inspection.to) {
|
|
1071
|
-
lines.push(`From: \`${inspection.from.selector}\``);
|
|
1072
|
-
lines.push(`To: \`${inspection.to.selector}\``);
|
|
1073
|
-
}
|
|
1074
|
-
if (inspection.note) {
|
|
1075
|
-
lines.push(`Note: ${inspection.note}`);
|
|
1076
|
-
}
|
|
1077
|
-
return lines.join("\n");
|
|
1078
|
-
}
|
|
1079
|
-
function isContainerLikeHit(element, point) {
|
|
1080
|
-
const rect = toGapRect(element.getBoundingClientRect());
|
|
1081
|
-
const tagName = element.tagName.toLowerCase();
|
|
1082
|
-
const area = rect.width * rect.height;
|
|
1083
|
-
if (["button", "a", "input", "textarea", "select", "h1", "h2", "h3", "p", "span"].includes(tagName)) {
|
|
1084
|
-
return false;
|
|
1085
|
-
}
|
|
1086
|
-
return area > 8000 || !pointInRect(point, rect);
|
|
1087
|
-
}
|
|
1088
|
-
function pointInRect(point, rect) {
|
|
1089
|
-
return point.x >= rect.left
|
|
1090
|
-
&& point.x <= rect.right
|
|
1091
|
-
&& point.y >= rect.top
|
|
1092
|
-
&& point.y <= rect.bottom;
|
|
1093
|
-
}
|
|
1094
|
-
function shrinkRect(rect, inset) {
|
|
1095
|
-
const left = rect.left + inset.left;
|
|
1096
|
-
const right = rect.right - inset.right;
|
|
1097
|
-
const top = rect.top + inset.top;
|
|
1098
|
-
const bottom = rect.bottom - inset.bottom;
|
|
1099
|
-
return {
|
|
1100
|
-
left,
|
|
1101
|
-
right,
|
|
1102
|
-
top,
|
|
1103
|
-
bottom,
|
|
1104
|
-
width: Math.max(0, right - left),
|
|
1105
|
-
height: Math.max(0, bottom - top)
|
|
1106
|
-
};
|
|
1107
|
-
}
|
|
1108
|
-
function expandRect(rect, outset) {
|
|
1109
|
-
const left = rect.left - outset.left;
|
|
1110
|
-
const right = rect.right + outset.right;
|
|
1111
|
-
const top = rect.top - outset.top;
|
|
1112
|
-
const bottom = rect.bottom + outset.bottom;
|
|
1113
|
-
return {
|
|
1114
|
-
left,
|
|
1115
|
-
right,
|
|
1116
|
-
top,
|
|
1117
|
-
bottom,
|
|
1118
|
-
width: Math.max(0, right - left),
|
|
1119
|
-
height: Math.max(0, bottom - top)
|
|
1120
|
-
};
|
|
1121
|
-
}
|
|
1122
|
-
function nearestSide(point, rect) {
|
|
1123
|
-
const distances = [
|
|
1124
|
-
{ side: "Top", distance: Math.abs(point.y - rect.top) },
|
|
1125
|
-
{ side: "Right", distance: Math.abs(point.x - rect.right) },
|
|
1126
|
-
{ side: "Bottom", distance: Math.abs(point.y - rect.bottom) },
|
|
1127
|
-
{ side: "Left", distance: Math.abs(point.x - rect.left) }
|
|
1128
|
-
];
|
|
1129
|
-
return distances.sort((a, b) => a.distance - b.distance)[0].side;
|
|
1130
|
-
}
|
|
1131
|
-
function nearestMarginSide(point, rect) {
|
|
1132
|
-
if (point.x < rect.left) {
|
|
1133
|
-
return "Left";
|
|
1134
|
-
}
|
|
1135
|
-
if (point.x > rect.right) {
|
|
1136
|
-
return "Right";
|
|
1137
|
-
}
|
|
1138
|
-
if (point.y < rect.top) {
|
|
1139
|
-
return "Top";
|
|
1140
|
-
}
|
|
1141
|
-
return "Bottom";
|
|
1142
|
-
}
|
|
1143
|
-
function capitalize(value) {
|
|
1144
|
-
return `${value.slice(0, 1).toUpperCase()}${value.slice(1)}`;
|
|
1145
|
-
}
|
|
1146
|
-
function toGapRect(rect) {
|
|
1147
|
-
return {
|
|
1148
|
-
top: roundPx(rect.top),
|
|
1149
|
-
right: roundPx(rect.right),
|
|
1150
|
-
bottom: roundPx(rect.bottom),
|
|
1151
|
-
left: roundPx(rect.left),
|
|
1152
|
-
width: roundPx(rect.width),
|
|
1153
|
-
height: roundPx(rect.height)
|
|
1154
|
-
};
|
|
1155
|
-
}
|
|
1156
|
-
function rectArea(rect) {
|
|
1157
|
-
return rect.width * rect.height;
|
|
1158
|
-
}
|
|
1159
|
-
function elementDepth(element) {
|
|
1160
|
-
let depth = 0;
|
|
1161
|
-
let current = element;
|
|
1162
|
-
while (current) {
|
|
1163
|
-
depth += 1;
|
|
1164
|
-
current = parentThroughShadow(current);
|
|
1165
|
-
}
|
|
1166
|
-
return depth;
|
|
1167
|
-
}
|
|
1168
|
-
function sharedAncestorDistance(a, b) {
|
|
1169
|
-
const commonAncestor = findCommonAncestor(a, b);
|
|
1170
|
-
if (!commonAncestor) {
|
|
1171
|
-
return 100;
|
|
1172
|
-
}
|
|
1173
|
-
return distanceToAncestor(a, commonAncestor) + distanceToAncestor(b, commonAncestor);
|
|
1174
|
-
}
|
|
1175
|
-
function distanceToAncestor(element, ancestor) {
|
|
1176
|
-
let distance = 0;
|
|
1177
|
-
let current = element;
|
|
1178
|
-
while (current && current !== ancestor) {
|
|
1179
|
-
distance += 1;
|
|
1180
|
-
current = parentThroughShadow(current);
|
|
1181
|
-
}
|
|
1182
|
-
return current === ancestor ? distance : 100;
|
|
1183
|
-
}
|
|
1184
|
-
function positivePx(value) {
|
|
1185
|
-
if (typeof value === "number") {
|
|
1186
|
-
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
1187
|
-
}
|
|
1188
|
-
if (!value || value === "auto" || value === "normal") {
|
|
1189
|
-
return 0;
|
|
1190
|
-
}
|
|
1191
|
-
const parsed = Number.parseFloat(value);
|
|
1192
|
-
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
1193
|
-
}
|
|
1194
|
-
function roundPx(value) {
|
|
1195
|
-
const nearest = Math.round(value);
|
|
1196
|
-
// Snap subpixel rendering noise (7.992px at fractional DPRs/zoom) to the integer.
|
|
1197
|
-
if (Math.abs(value - nearest) < 0.15) {
|
|
1198
|
-
return nearest;
|
|
1199
|
-
}
|
|
1200
|
-
return Math.round(value * 10) / 10;
|
|
1201
|
-
}
|
|
1202
|
-
function formatPx(value) {
|
|
1203
|
-
const rounded = roundPx(value);
|
|
1204
|
-
return `${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)}px`;
|
|
1205
|
-
}
|
|
1206
|
-
function cssPropertyName(property) {
|
|
1207
|
-
return property.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
|
|
1208
|
-
}
|
|
1209
|
-
function cssEscape(value) {
|
|
1210
|
-
if (typeof CSS !== "undefined" && CSS.escape) {
|
|
1211
|
-
return CSS.escape(value);
|
|
1212
|
-
}
|
|
1213
|
-
return value.replace(/[^a-zA-Z0-9_-]/g, (character) => `\\${character}`);
|
|
1214
|
-
}
|