Compute content size, padding and border for each node (#573)
1
2function getScrollBarWidth() {
3 let el = document.createElement("div");
4 el.style.cssText = "overflow:scroll; visibility:hidden; position:absolute;";
5 document.body.appendChild(el);
6 let width = el.offsetWidth - el.clientWidth;
7 el.remove();
8 return width;
9}
10
11class TrackSizingParser {
12 static INITIAL_CHAR_REGEX = /[a-z-A-Z0-9]/;
13 static TOKEN_CHAR_REGEX = /[-\.a-z-A-Z0-9%]/;
14
15 constructor(input, options = { allowFrUnits: true }) {
16 this.input = input;
17 this.index = 0;
18 this.options = options;
19 }
20
21 parseList() {
22 return this._parseItemList(' ', null);
23 }
24
25 parseSingleItem() {
26 return this.parseItem();
27 }
28
29 _parseItemList(separator, terminator = null) {
30 if (!separator) throw new Error('No terminator passed');
31 let tokenList = [];
32 // console.debug('Parse List', this.index, this.input.slice(this.index));
33
34 while (this.index < this.input.length) {
35 const char = this.input[this.index];
36 // console.debug(this.index, char);
37
38 // Skip whitespace
39 if (char === ' ') { this.index++; continue; }
40
41 if (TrackSizingParser.INITIAL_CHAR_REGEX.test(char)) {
42 const token = this._parseItem();
43 tokenList.push(token);
44
45 const nextChar = this.input[this.index];
46 if ((terminator && nextChar === terminator) || !terminator && !nextChar) {
47 return tokenList;
48 } else {
49 this.index++;
50 continue;
51 }
52 }
53
54 throw new Error(`Invalid start of token ${char}`);
55 }
56 }
57
58 _parseItem() {
59 let token = '';
60 // console.debug('Parse Item', this.index, this.input.slice(this.index));
61
62 while (this.index < this.input.length) {
63 const char = this.input[this.index];
64 // console.debug(this.index, char);
65
66 if (TrackSizingParser.TOKEN_CHAR_REGEX.test(char)) {
67 token += char;
68 this.index++;
69 continue;
70 }
71
72 if (char === '(') {
73 this.index++;
74 const args = this._parseItemList(',', ')');
75 this.index++;
76 return { kind: 'function', name: token, arguments: args };
77 }
78
79 return { kind: 'scalar', ...this._parseScalarItem(token) };
80 }
81 return { kind: 'scalar', ...this._parseScalarItem(token) };
82
83 }
84
85 _parseScalarItem(item) {
86 const res = parseRepetition(item) || parseDimension(item, { allowFrUnits: this.options.allowFrUnits });
87 if (!res) throw new Error(`Invalid scalar grid track sizing function ${item}`);
88 return res;
89 }
90
91}
92
93function parseViewportConstraint(e) {
94 if (e.parentNode.classList.contains('viewport')) {
95 return {
96 width: parseDimension(e.parentNode.style.width || 'max-content'),
97 height: parseDimension(e.parentNode.style.height || 'max-content'),
98 }
99 } else {
100 return {
101 width: { unit: 'max-content' },
102 height: { unit: 'max-content' },
103 }
104 }
105}
106
107function parseRepetition(input) {
108 if (input === "auto-fill") return { unit: 'auto-fill' };
109 if (input === "auto-fit") return { unit: 'auto-fit' };
110 if (/^[0-9]*$/.test(input)) return { 'unit': 'integer', value: parseInt(input, 10) };
111 return undefined;
112}
113
114function parseDimension(input, options = { allowFrUnits: false }) {
115 if (options.allowFrUnits && input.endsWith('fr')) return { unit: 'fraction', value: parseFloat(input.replace('fr', '')) };
116 if (input.endsWith('px')) return { unit: 'px', value: parseFloat(input.replace('px', '')) };
117 if (input.endsWith('%')) return { unit: 'percent', value: parseFloat(input.replace('%', '')) / 100 };
118 if (input === 'auto') return { unit: 'auto' };
119 if (input === 'min-content') return { unit: 'min-content' };
120 if (input === 'max-content') return { unit: 'max-content' };
121 return undefined;
122}
123
124function parseNumber(input) {
125 if (input === '' || isNaN(input)) return undefined;
126 return Number(input);
127}
128
129function parseRatio(input) {
130 if (!input) return undefined;
131
132 if (input.includes('/')) {
133 let [width, height] = input.split("/").map(part => parseFloat(part.trim()));
134 if (!width || width < 0 || !height || height <= 0) return undefined;
135 return width / height;
136 }
137
138 let ratio = parseFloat(input);
139 if (!ratio || ratio < 0) return undefined;
140 return ratio;
141}
142
143function parseEnum(input) {
144 if (input) return input;
145 return undefined;
146}
147
148function parseEdges(edges) {
149 const left = parseDimension(edges.left);
150 const right = parseDimension(edges.right);
151 const top = parseDimension(edges.top);
152 const bottom = parseDimension(edges.bottom);
153
154 if (!left && !right && !top && !bottom) return undefined;
155 return { left, right, top, bottom };
156}
157
158function parseSize(size) {
159 const width = parseDimension(size.width);
160 const height = parseDimension(size.height);
161
162 if (!width && !height) return undefined;
163 return { width, height };
164}
165
166function parseGaps(style) {
167 if (style.gap) {
168 const gaps = style.gap.trim().split(/\s+/).map(part => parseDimension(part));
169 return { row: gaps[0], column: gaps[1] ?? gaps[0] };
170 }
171 if (style.rowGap || style.columnGap) {
172 return { row: parseDimension(style.rowGap), column: parseDimension(style.columnGap) };
173 }
174 return undefined;
175}
176
177
178function parseGridTrackDefinitions(input) {
179 if (input === '') return undefined;
180 return new TrackSizingParser(input).parseList();
181}
182
183function parseGridAutoFlow(input) {
184 if (!/column/.test(input) && !/row/.test(input) && !/dense/.test(input)) return undefined;
185 const direction = /column/.test(input) ? 'column' : 'row';
186 const algorithm = /dense/.test(input) ? 'dense' : 'sparse';
187 return { direction, algorithm };
188}
189
190function parseGridPosition(input) {
191 if (input === 'auto') return { kind: 'auto' };
192 if (/^span +\d+$/.test(input)) return { kind: 'span', value: parseInt(input.replace(/[^\d]/g, ''), 10) };
193 if (/^-?\d+$/.test(input)) return { kind: 'line', value: parseInt(input, 10) };
194 return undefined;
195}
196
197function describeElement(e) {
198
199 // Get precise, unrounded dimensions for the current element and it's parent
200 let boundingRect = e.getBoundingClientRect();
201 let parentBoundingRect = e.parentNode.getBoundingClientRect();
202
203 return {
204 style: {
205 display: parseEnum(e.style.display),
206
207 position: parseEnum(e.style.position),
208 direction: parseEnum(e.style.direction),
209 flexDirection: parseEnum(e.style.flexDirection),
210
211 writingMode: parseEnum(e.style.writingMode),
212
213 flexWrap: parseEnum(e.style.flexWrap),
214 overflowX: parseEnum(e.style.overflowX),
215 overflowY: parseEnum(e.style.overflowY),
216 scrollbarWidth: getScrollBarWidth(),
217
218 alignItems: parseEnum(e.style.alignItems),
219 alignSelf: parseEnum(e.style.alignSelf),
220 justifyItems: parseEnum(e.style.justifyItems),
221 justifySelf: parseEnum(e.style.justifySelf),
222
223 alignContent: parseEnum(e.style.alignContent),
224 justifyContent: parseEnum(e.style.justifyContent),
225
226 flexGrow: parseNumber(e.style.flexGrow),
227 flexShrink: parseNumber(e.style.flexShrink),
228 flexBasis: parseDimension(e.style.flexBasis),
229
230 gridTemplateRows: parseGridTrackDefinitions(e.style.gridTemplateRows),
231 gridTemplateColumns: parseGridTrackDefinitions(e.style.gridTemplateColumns),
232 gridAutoRows: parseGridTrackDefinitions(e.style.gridAutoRows),
233 gridAutoColumns: parseGridTrackDefinitions(e.style.gridAutoColumns),
234 gridAutoFlow: parseGridAutoFlow(e.style.gridAutoFlow),
235
236 gridRowStart: parseGridPosition(e.style.gridRowStart),
237 gridRowEnd: parseGridPosition(e.style.gridRowEnd),
238 gridColumnStart: parseGridPosition(e.style.gridColumnStart),
239 gridColumnEnd: parseGridPosition(e.style.gridColumnEnd),
240
241 gap: parseGaps(e.style),
242
243 size: parseSize({ width: e.style.width, height: e.style.height }),
244 minSize: parseSize({ width: e.style.minWidth, height: e.style.minHeight }),
245 maxSize: parseSize({ width: e.style.maxWidth, height: e.style.maxHeight }),
246 aspectRatio: parseRatio(e.style.aspectRatio),
247
248 margin: parseEdges({
249 left: e.style.marginLeft,
250 right: e.style.marginRight,
251 top: e.style.marginTop,
252 bottom: e.style.marginBottom,
253 }),
254
255 padding: parseEdges({
256 left: e.style.paddingLeft,
257 right: e.style.paddingRight,
258 top: e.style.paddingTop,
259 bottom: e.style.paddingBottom,
260 }),
261
262 border: parseEdges({
263 left: e.style.borderLeftWidth,
264 right: e.style.borderRightWidth,
265 top: e.style.borderTopWidth,
266 bottom: e.style.borderBottomWidth,
267 }),
268
269 inset: parseEdges({
270 left: e.style.left,
271 right: e.style.right,
272 top: e.style.top,
273 bottom: e.style.bottom,
274 }),
275 },
276
277 // The textContent is used for generating intrinsic sizing measure funcs
278 // So we're only interested in the text content of leaf nodes
279 textContent: e.childElementCount === 0 && e.textContent.length && e.textContent !== "\n" ? e.textContent : undefined,
280
281 // The layout of the node in full precision (floating-point)
282 unroundedLayout: {
283 width: boundingRect.width,
284 height: boundingRect.height,
285 x: boundingRect.x - parentBoundingRect.x,
286 y: boundingRect.y - parentBoundingRect.y,
287 scrollWidth: e.scrollWidth,
288 scrollHeight: e.scrollHeight,
289 clientWidth: e.clientWidth,
290 clientHeight: e.clientHeight,
291 },
292
293 // The naively rounded layout of the node. This is equivalent to calling Math.round() on
294 // each value in the unrounded layout individually
295 naivelyRoundedLayout: {
296 width: e.offsetWidth,
297 height: e.offsetHeight,
298 x: e.offsetLeft + e.parentNode.clientLeft,
299 y: e.offsetTop + e.parentNode.clientTop,
300 scrollWidth: e.scrollWidth,
301 scrollHeight: e.scrollHeight,
302 clientWidth: e.clientWidth,
303 clientHeight: e.clientHeight,
304 },
305
306 // The naive rounding can result in 1px gaps in the layout, so Taffy uses a smarter algorithm to avoid this.
307 // Chrome also uses a smarter algorithm, but it doesn't expose the output of that rounding.
308 // So we just emulate Taffy's computation here.
309 smartRoundedLayout: {
310 width: Math.round(boundingRect.right) - Math.round(boundingRect.left),
311 height: Math.round(boundingRect.bottom) - Math.round(boundingRect.top),
312 x: Math.round(boundingRect.x - parentBoundingRect.x),
313 y: Math.round(boundingRect.y - parentBoundingRect.y),
314 scrollWidth: e.scrollWidth,
315 scrollHeight: e.scrollHeight,
316 clientWidth: e.clientWidth,
317 clientHeight: e.clientHeight,
318 },
319
320 // Whether the test should enable rounding
321 useRounding: e.getAttribute("data-test-rounding") !== "false",
322
323 viewport: parseViewportConstraint(e),
324
325 children: Array.from(e.children).map(c => describeElement(c)),
326 };
327}
328
329// Useful when developing this script. Logs the parsed style to the console when any test fixture is opened in a browser.
330window.onload = function () {
331 try {
332 console.log(describeElement(document.getElementById('test-root')));
333 } catch (e) {
334 console.error(e);
335 }
336};