Skip to content

Commit 2252b7c

Browse files
author
Mazin Zakaria
committed
Adds initial support for calc
1 parent 0522664 commit 2252b7c

8 files changed

Lines changed: 826 additions & 51 deletions

File tree

Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict
8+
*/
9+
10+
import { CSSLengthUnitValue } from './CSSLengthUnitValue';
11+
12+
import valueParser from 'postcss-value-parser';
13+
14+
type CalcLeafToken =
15+
| { type: 'literal', value: number }
16+
| { type: 'length', value: CSSLengthUnitValue }
17+
| { type: 'percentage', value: number };
18+
19+
type CalcOpToken = { type: 'op', value: '+' | '-' | '*' | '/' };
20+
21+
type CalcGroupToken = { type: 'group', tokens: Array<CalcToken> };
22+
23+
type CalcToken = CalcLeafToken | CalcOpToken | CalcGroupToken;
24+
25+
type CalcASTNode =
26+
| CalcLeafToken
27+
| {
28+
type: 'binary',
29+
op: '+' | '-' | '*' | '/',
30+
left: CalcASTNode,
31+
right: CalcASTNode
32+
};
33+
34+
type DualValue = { percent: number, offset: number };
35+
36+
type ResolvePixelValueOptions = $ReadOnly<{
37+
fontScale?: number | void,
38+
inheritedFontSize?: ?number,
39+
viewportHeight?: number,
40+
viewportScale?: number,
41+
viewportWidth?: number
42+
}>;
43+
44+
export type CalcResult =
45+
| number
46+
| { __rsdCalc: true, percent: number, offset: number };
47+
48+
const memoizedCalcValues = new Map<string, CSSCalcValue | null>();
49+
50+
export class CSSCalcValue {
51+
ast: CalcASTNode;
52+
53+
static parse(input: string): CSSCalcValue | null {
54+
const memoizedValue = memoizedCalcValues.get(input);
55+
if (memoizedValue !== undefined) {
56+
return memoizedValue;
57+
}
58+
try {
59+
const parsed = valueParser(input);
60+
const calcNode = parsed.nodes.find(
61+
(n) => n.type === 'function' && n.value === 'calc'
62+
);
63+
if (calcNode == null || !Array.isArray(calcNode.nodes)) {
64+
memoizedCalcValues.set(input, null);
65+
return null;
66+
}
67+
const tokens = CSSCalcValue._tokenize(calcNode.nodes);
68+
if (tokens.length === 0) {
69+
memoizedCalcValues.set(input, null);
70+
return null;
71+
}
72+
const ast = CSSCalcValue._parseExpression(tokens, { pos: 0 });
73+
if (ast == null) {
74+
memoizedCalcValues.set(input, null);
75+
return null;
76+
}
77+
const instance = new CSSCalcValue(ast);
78+
memoizedCalcValues.set(input, instance);
79+
return instance;
80+
} catch {
81+
memoizedCalcValues.set(input, null);
82+
return null;
83+
}
84+
}
85+
86+
static _tokenize(nodes: $ReadOnlyArray<{ ... }>): Array<CalcToken> {
87+
const tokens: Array<CalcToken> = [];
88+
for (let i = 0; i < nodes.length; i++) {
89+
const node = nodes[i];
90+
if (node.type === 'space') {
91+
continue;
92+
}
93+
if (node.type === 'function') {
94+
// Nested calc() or grouping parens (empty value)
95+
if (
96+
(node.value === 'calc' || node.value === '') &&
97+
Array.isArray(node.nodes)
98+
) {
99+
const innerTokens = CSSCalcValue._tokenize(node.nodes);
100+
tokens.push({ type: 'group', tokens: innerTokens });
101+
} else {
102+
return []; // unsupported function
103+
}
104+
continue;
105+
}
106+
if (node.type === 'word') {
107+
const val: string = node.value;
108+
// Pure operator
109+
if (val === '*' || val === '/') {
110+
tokens.push({ type: 'op', value: val });
111+
continue;
112+
}
113+
if (val === '+' || val === '-') {
114+
tokens.push({ type: 'op', value: val });
115+
continue;
116+
}
117+
// Check if value starts with +/- and previous token was an operand
118+
// This handles cases like "100vh" followed by "-64px" (space-separated)
119+
if (
120+
(val.startsWith('+') || val.startsWith('-')) &&
121+
val.length > 1 &&
122+
tokens.length > 0
123+
) {
124+
const lastToken = tokens[tokens.length - 1];
125+
if (lastToken.type !== 'op') {
126+
// Split into operator + value
127+
tokens.push({ type: 'op', value: (val[0]: $FlowFixMe) });
128+
const rest = val.slice(1);
129+
const leaf = CSSCalcValue._parseLeaf(rest);
130+
if (leaf == null) {
131+
return [];
132+
}
133+
tokens.push(leaf);
134+
continue;
135+
}
136+
}
137+
// Parse as leaf value
138+
const leaf = CSSCalcValue._parseLeaf(val);
139+
if (leaf == null) {
140+
return [];
141+
}
142+
tokens.push(leaf);
143+
continue;
144+
}
145+
}
146+
return tokens;
147+
}
148+
149+
static _parseLeaf(val: string): CalcLeafToken | null {
150+
// Percentage
151+
if (val.endsWith('%')) {
152+
const num = parseFloat(val.slice(0, -1));
153+
if (isNaN(num)) {
154+
return null;
155+
}
156+
return { type: 'percentage', value: num };
157+
}
158+
// Try CSSLengthUnitValue
159+
const lengthVal = CSSLengthUnitValue.parse(val);
160+
if (lengthVal != null) {
161+
return { type: 'length', value: lengthVal };
162+
}
163+
// Unitless number
164+
const num = parseFloat(val);
165+
if (!isNaN(num) && String(num) === val) {
166+
return { type: 'literal', value: num };
167+
}
168+
// Could be integer like "2" parsed differently
169+
const numAlt = Number(val);
170+
if (!isNaN(numAlt) && isFinite(numAlt)) {
171+
return { type: 'literal', value: numAlt };
172+
}
173+
return null;
174+
}
175+
176+
// Recursive descent parser: expression = term (('+' | '-') term)*
177+
static _parseExpression(
178+
tokens: $ReadOnlyArray<CalcToken>,
179+
state: { pos: number }
180+
): CalcASTNode | null {
181+
let left = CSSCalcValue._parseTerm(tokens, state);
182+
if (left == null) {
183+
return null;
184+
}
185+
while (state.pos < tokens.length) {
186+
const token = tokens[state.pos];
187+
if (
188+
token.type === 'op' &&
189+
(token.value === '+' || token.value === '-')
190+
) {
191+
state.pos++;
192+
const right = CSSCalcValue._parseTerm(tokens, state);
193+
if (right == null) {
194+
return null;
195+
}
196+
left = { type: 'binary', op: token.value, left, right };
197+
} else {
198+
break;
199+
}
200+
}
201+
return left;
202+
}
203+
204+
// term = primary (('*' | '/') primary)*
205+
static _parseTerm(
206+
tokens: $ReadOnlyArray<CalcToken>,
207+
state: { pos: number }
208+
): CalcASTNode | null {
209+
let left = CSSCalcValue._parsePrimary(tokens, state);
210+
if (left == null) {
211+
return null;
212+
}
213+
while (state.pos < tokens.length) {
214+
const token = tokens[state.pos];
215+
if (
216+
token.type === 'op' &&
217+
(token.value === '*' || token.value === '/')
218+
) {
219+
state.pos++;
220+
const right = CSSCalcValue._parsePrimary(tokens, state);
221+
if (right == null) {
222+
return null;
223+
}
224+
left = { type: 'binary', op: token.value, left, right };
225+
} else {
226+
break;
227+
}
228+
}
229+
return left;
230+
}
231+
232+
// primary = group | leaf
233+
static _parsePrimary(
234+
tokens: $ReadOnlyArray<CalcToken>,
235+
state: { pos: number }
236+
): CalcASTNode | null {
237+
if (state.pos >= tokens.length) {
238+
return null;
239+
}
240+
const token = tokens[state.pos];
241+
if (token.type === 'group') {
242+
state.pos++;
243+
const innerState = { pos: 0 };
244+
const result = CSSCalcValue._parseExpression(token.tokens, innerState);
245+
return result;
246+
}
247+
if (
248+
token.type === 'literal' ||
249+
token.type === 'length' ||
250+
token.type === 'percentage'
251+
) {
252+
state.pos++;
253+
return token;
254+
}
255+
return null;
256+
}
257+
258+
constructor(ast: CalcASTNode) {
259+
this.ast = ast;
260+
}
261+
262+
resolvePixelValue(
263+
options: ResolvePixelValueOptions,
264+
propertyName: string
265+
): CalcResult {
266+
const result = CSSCalcValue._evaluateDual(this.ast, options, propertyName);
267+
if (result.percent === 0) {
268+
return result.offset;
269+
}
270+
// Has percentage component: return structured object for native
271+
// post-layout resolution (percent resolved by Yoga, offset applied after)
272+
return { __rsdCalc: true, percent: result.percent, offset: result.offset };
273+
}
274+
275+
// Evaluates to {percent, offset} pair. Percentage stays symbolic;
276+
// non-percentage parts (px, vh, rem, etc.) resolve to offset.
277+
static _evaluateDual(
278+
node: CalcASTNode,
279+
options: ResolvePixelValueOptions,
280+
propertyName: string
281+
): DualValue {
282+
if (node.type === 'literal') {
283+
return { percent: 0, offset: node.value };
284+
}
285+
if (node.type === 'length') {
286+
return {
287+
percent: 0,
288+
offset: node.value.resolvePixelValue((options: $FlowFixMe))
289+
};
290+
}
291+
if (node.type === 'percentage') {
292+
return { percent: node.value, offset: 0 };
293+
}
294+
if (node.type === 'binary') {
295+
const left = CSSCalcValue._evaluateDual(
296+
node.left,
297+
options,
298+
propertyName
299+
);
300+
const right = CSSCalcValue._evaluateDual(
301+
node.right,
302+
options,
303+
propertyName
304+
);
305+
switch (node.op) {
306+
case '+':
307+
return {
308+
percent: left.percent + right.percent,
309+
offset: left.offset + right.offset
310+
};
311+
case '-':
312+
return {
313+
percent: left.percent - right.percent,
314+
offset: left.offset - right.offset
315+
};
316+
case '*':
317+
// Multiplication: one side must be unitless (no percentage)
318+
if (left.percent === 0 && right.percent === 0) {
319+
return { percent: 0, offset: left.offset * right.offset };
320+
}
321+
if (left.percent === 0) {
322+
return {
323+
percent: right.percent * left.offset,
324+
offset: right.offset * left.offset
325+
};
326+
}
327+
if (right.percent === 0) {
328+
return {
329+
percent: left.percent * right.offset,
330+
offset: left.offset * right.offset
331+
};
332+
}
333+
// percent * percent is invalid CSS
334+
return { percent: 0, offset: 0 };
335+
case '/':
336+
// Divisor must be unitless
337+
if (right.percent !== 0 || right.offset === 0) {
338+
return { percent: 0, offset: 0 };
339+
}
340+
return {
341+
percent: left.percent / right.offset,
342+
offset: left.offset / right.offset
343+
};
344+
default:
345+
return { percent: 0, offset: 0 };
346+
}
347+
}
348+
return { percent: 0, offset: 0 };
349+
}
350+
}

0 commit comments

Comments
 (0)