import { PixelRatio, Platform, PlatformColor, StyleSheet } from "react-native"; import type { EasingFunction, Time } from "lightningcss"; import type { AnimatableValue } from "react-native-reanimated"; import { isDescriptorArray, isDescriptorFunction, transformKeys, } from "../../shared"; import type { InteropComponentConfig, RuntimeValueDescriptor, RuntimeValueFrame, } from "../../types"; import { Effect, observable } from "../observable"; import { systemColorScheme } from "./appearance-observables"; import { textShadow } from "./resolvers/text-shadow"; import { getUniversalVariable, getVariable } from "./styles"; import { ReducerState, ReducerTracking, Refs, ShorthandResult } from "./types"; import { rem, vh, vw } from "./unit-observables"; /** * Get the final value of a value descriptor * A descriptor is a value like 'red', 12 or { name: 'var', arguments: ['--primary'] } * They are generated by the compiler. * @param state * @param descriptor * @param style * @returns */ export function resolveValue( state: ReducerState, refs: Refs, tracking: ReducerTracking, descriptor: RuntimeValueDescriptor, style: Record | undefined, castToArray = false, ): RuntimeValueDescriptor | ShorthandResult { try { switch (typeof descriptor) { case "undefined": return; case "boolean": case "number": case "function": return descriptor; case "string": return descriptor.endsWith("px") // Inline vars() might set a value with a px suffix ? parseInt(descriptor.slice(0, -2), 10) : descriptor; } if (isDescriptorArray(descriptor)) { descriptor = descriptor.flatMap((d) => { const value = resolveValue(state, refs, tracking, d, style); return value === undefined ? [] : value; }) as RuntimeValueDescriptor[]; if (castToArray && !Array.isArray(descriptor)) { return [descriptor]; } else { return descriptor; } } const [, name, descriptorArgs = []] = descriptor; const cast = (value: RuntimeValueDescriptor) => { if (value === undefined) return; return castToArray && !Array.isArray(value) ? [value] : value; }; switch (name) { case "@textShadow": { return textShadow( resolve, state, refs, tracking, descriptorArgs, style, ); } case "var": { let value = resolve(state, refs, tracking, descriptorArgs[0], style); if (typeof value === "string") value = getVar(state, refs, tracking, value, style); if (value === undefined && descriptorArgs[1]) { value = resolveValue(state, refs, tracking, descriptorArgs[1], style); } return cast(value); } case "calc": { return cast(calc(state, refs, tracking, descriptorArgs, style)?.value); } case "max": { let mode; let values: number[] = []; for (const arg of descriptorArgs) { const result = calc(state, refs, tracking, [arg], style); if (result) { if (!mode) mode = result?.mode; if (result.mode === mode) { values.push(result.raw); } } } const max = Math.max(...values); return cast(mode === "percentage" ? `${max}%` : max); } case "min": { let mode; let values: number[] = []; for (const arg of descriptorArgs) { const result = calc(state, refs, tracking, [arg], style); if (result) { if (!mode) mode = result?.mode; if (result.mode === mode) { values.push(result.raw); } } } const min = Math.min(...values); return cast(mode === "percentage" ? `${min}%` : min); } case "clamp": { const min = calc(state, refs, tracking, descriptorArgs[0], style); const val = calc(state, refs, tracking, descriptorArgs[1], style); const max = calc(state, refs, tracking, descriptorArgs[2], style); if (!min || !val || !max) return; if (min.mode !== val.mode && max.mode !== val.mode) return; const value = Math.max(min.raw, Math.min(val.raw, max.raw)); return cast(val.mode === "percentage" ? `${value}%` : value); } case "vh": { // 50vh = 50% of the viewport height const value = resolve(state, refs, tracking, descriptorArgs[0], style); const vhValue = vh.get(tracking.effect) / 100; if (typeof value === "number") { return cast(round(vhValue * value)); } } case "vw": { const value = resolve(state, refs, tracking, descriptorArgs[0], style); const vwValue = vw.get(tracking.effect) / 100; if (typeof value === "number") { return cast(round(vwValue * value)); } } case "em": { const value = resolve(state, refs, tracking, descriptorArgs[0], style); const fontSize = style?.fontSize ?? rem.get(tracking.effect); if (typeof value === "number") { return cast(round(fontSize * value)); } } case "rem": { const value = resolve(state, refs, tracking, descriptorArgs[0], style); const remValue = rem.get(tracking.effect); if (typeof value === "number") { return cast(round(remValue * value)); } } case "rnh": { const value = resolve(state, refs, tracking, descriptorArgs[0], style); const height = style?.height ?? getHeight(state, refs, tracking); if (typeof value === "number") { return cast(round(height * value)); } } case "rnw": { const value = resolve(state, refs, tracking, descriptorArgs[0], style); const width = style?.width ?? getWidth(state, refs, tracking); if (typeof value === "number") { return cast(round(width * value)); } } case "hwb": const args = resolve(state, refs, tracking, descriptorArgs, style).flat( 10, ); return cast(getColorArgs(args, { 3: "hwb" })); case "rgb": case "rgba": { const args = resolve(state, refs, tracking, descriptorArgs, style).flat( 10, ); return cast(getColorArgs(args, { 3: "rgb", 4: "rgba" })); } case "hsl": case "hsla": { const args = resolve(state, refs, tracking, descriptorArgs, style).flat( 10, ); return cast(getColorArgs(args, { 3: "hsl", 4: "hsla" })); } case "hairlineWidth": { return cast(StyleSheet.hairlineWidth); } case "platformColor": { return cast( PlatformColor(...(descriptorArgs as any[])) as unknown as string, ); } case "platformSelect": { if (!isDescriptorArray(descriptorArgs)) return; const value = resolve( state, refs, tracking, Platform.select(Object.fromEntries(descriptorArgs as any)), style, ); return cast(value); } case "getPixelSizeForLayoutSize": { const v = resolve(state, refs, tracking, descriptorArgs[0], style); if (typeof v === "number") { return cast(PixelRatio.getPixelSizeForLayoutSize(v)); } } case "fontScale": { const value = resolve(state, refs, tracking, descriptorArgs[0], style); if (typeof value === "number") { return cast(PixelRatio.getFontScale() * value); } } case "pixelScale": { const value = resolve(state, refs, tracking, descriptorArgs[0], style); if (typeof value === "number") { return cast(PixelRatio.get() * value); } } case "pixelScaleSelect": { const specifics = Object.fromEntries( descriptorArgs as [string, RuntimeValueDescriptor][], ); const value = resolve( state, refs, tracking, specifics[PixelRatio.get()] ?? specifics["default"], style, ); return cast(value); } case "fontScaleSelect": { const specifics = Object.fromEntries( descriptorArgs as [string, RuntimeValueDescriptor][], ); const value = resolve( state, refs, tracking, specifics[PixelRatio.getFontScale()] ?? specifics["default"], style, ); return cast(value); } case "roundToNearestPixel": { const v = resolve(state, refs, tracking, descriptorArgs[0], style); if (typeof v === "number") { return PixelRatio.roundToNearestPixel(v); } } case "translateX": case "translateY": case "scale": case "scaleX": case "scaleY": case "rotate": case "rotateX": case "rotateY": case "rotateZ": case "skewX": case "skewY": case "perspective": case "matrix": case "transformOrigin": { const value = { [name]: resolve(state, refs, tracking, descriptorArgs[0], style), } as RuntimeValueDescriptor; return cast(value); } case "translate": case "scale": { return [ { [`${name}X`]: resolve( state, refs, tracking, descriptorArgs[0], style, ), }, { [`${name}Y`]: resolve( state, refs, tracking, descriptorArgs[1], style, ), }, ] as unknown as RuntimeValueDescriptor; } default: { if ("name" in descriptor && "arguments" in descriptor) { const args = resolve( state, refs, tracking, descriptorArgs, style, ).join(","); return cast(`${descriptor.name}(${args})`); } else { return cast(descriptor); } } } } catch (error) { if (process.env.NODE_ENV !== "development") { console.error(error); } return undefined; } } function resolve( state: ReducerState, refs: Refs, tracking: ReducerTracking, descriptor: RuntimeValueDescriptor | RuntimeValueDescriptor[], style?: Record, ): any { if (typeof descriptor !== "object" || !Array.isArray(descriptor)) { return descriptor; } if (isDescriptorArray(descriptor)) { // Resolve the items, but skip anything that returns undefined let resolved = []; for (let value of descriptor) { value = resolve(state, refs, tracking, value, style); if (value !== undefined) { resolved.push(value); } } return resolved; } return resolveValue(state, refs, tracking, descriptor, style); } /** * Get a CSS variable, it can be * - inline (via via a className or via the style prop) * - universal (e.g this CSS sets a universal variable `* { --primary: red; }` ) * - inherited via the parent (either the parent set a variable, or its a :root variable) * @param state * @param name * @param style * @returns */ function getVar( state: ReducerState, refs: Refs, tracking: ReducerTracking, name: string, style?: Record, ) { if (!name) return; let value: any = undefined; // Get the value from the inline style value ??= getVariable(name, state.variables); // Get the value from the universal variables value ??= getUniversalVariable(name, tracking.effect); if (value === undefined) { // Get the value from the parent value = getVariable(name, refs.variables, tracking.effect); // If the parent is :root, these are Observables instead of the raw values // So you need to access them with the styleEffect if (typeof value === "object" && "get" in value) { value = value.get(tracking.effect); } else if (value !== undefined) { // This is a normal value that came from the context, so we need to track it tracking.guards.push( (refs) => getVariable(name, refs.variables, tracking.effect) !== value, ); } } // The value may be another descriptor, so we need to resolve it return resolveValue(state, refs, tracking, value, style); } export function resolveAnimation( state: ReducerState, refs: Refs, [initialFrame, ...frames]: RuntimeValueFrame[], property: string, delay: number, totalDuration: number, easingFuncs: EasingFunction | EasingFunction[], ): [AnimatableValue, AnimatableValue, ...AnimatableValue[]] { const { withDelay, withTiming, Easing } = require("react-native-reanimated") as typeof import("react-native-reanimated"); let progress = 0; const initialValue = resolveAnimationValue( state, refs, property, initialFrame.value, ); return [ initialValue, ...frames.map((frame, index) => { const easingFunction = Array.isArray(easingFuncs) ? easingFuncs[index] : easingFuncs; const framesProgress = frame.progress - progress; let value = withTiming( resolveAnimationValue(state, refs, property, frame.value), { duration: totalDuration * framesProgress, easing: getEasing(easingFunction, Easing), }, ); // You can only have a delay between the initial and first frame if (index === 1) { value = withDelay(delay, value); } progress += framesProgress; return value; }), ] as [AnimatableValue, AnimatableValue, ...AnimatableValue[]]; } function resolveAnimationValue( state: ReducerState, refs: Refs, property: string, value: RuntimeValueDescriptor, ) { if (value === "!INHERIT!") { const { value: baseValue, defaultValue } = getBaseValue(state, [property]); value = baseValue ?? defaultValue; if (value === undefined) { const defaultValueFn = defaultValues[property as keyof typeof defaultValues]; return typeof defaultValueFn === "function" ? defaultValueFn(state.styleTracking.effect) : defaultValueFn; } return value; } else { return resolve(state, refs, state.styleTracking, value, state.props); } } export const timeToMS = (time: Time) => { return time.type === "milliseconds" ? time.value : time.value * 1000; }; function round(number: number) { return Math.round((number + Number.EPSILON) * 100) / 100; } export function getEasing( timingFunction: EasingFunction, Easing: (typeof import("react-native-reanimated"))["Easing"], ) { switch (timingFunction.type) { case "ease": return Easing.ease; case "ease-in": return Easing.in(Easing.quad); case "ease-out": return Easing.out(Easing.quad); case "ease-in-out": return Easing.inOut(Easing.quad); case "linear": return Easing.linear; case "cubic-bezier": return Easing.bezier( timingFunction.x1, timingFunction.y1, timingFunction.x2, timingFunction.y2, ); default: return Easing.linear; } } export function setDeep( target: Record, paths: string[], value: any, ) { const prop = paths[paths.length - 1]; for (let i = 0; i < paths.length - 1; i++) { const token = paths[i]; target[token] ??= {}; target = target[token]; } if (transformKeys.has(prop)) { if (target.transform) { const existing = target.transform.find( (t: any) => Object.keys(t)[0] === prop, ); if (existing) { existing[prop] = value; } else { target.transform.push({ [prop]: value }); } } else { target.transform ??= []; target.transform.push({ [prop]: value }); } } else { target[prop] = value; } } function getColorArgs(args: any[], config: Record) { // Do we perfectly match a function? if (config[args.length]) return `${config[args.length]}(${args.join(", ")})`; // Otherwise, we need to split the args and remove any empty strings // e.g ["255 0 0", 1] => ["255", "0", "0", 1] args = args.flatMap((arg) => { return typeof arg === "string" ? arg.split(/[,\s\/]/g).filter(Boolean) : arg; }); // Now do we match a function? if (config[args.length]) return `${config[args.length]}(${args.join(", ")})`; } function getLayout(state: ReducerState, refs: Refs, tracking: ReducerTracking) { refs.sharedState.layout ??= observable([0, 0]); return refs.sharedState.layout.get(tracking.effect); } export function getWidth( state: ReducerState, refs: Refs, tracking: ReducerTracking, ) { return getLayout(state, refs, tracking)[0]; } export function getHeight( state: ReducerState, refs: Refs, tracking: ReducerTracking, ) { return getLayout(state, refs, tracking)[1]; } export const defaultValues = { backgroundColor: "transparent", borderBottomColor: "transparent", borderBottomLeftRadius: 0, borderBottomRightRadius: 0, borderBottomWidth: 0, borderColor: "transparent", borderLeftColor: "transparent", borderLeftWidth: 0, borderRadius: 0, borderRightColor: "transparent", borderRightWidth: 0, borderTopColor: "transparent", borderTopWidth: 0, borderWidth: 0, bottom: 0, color: (effect: Effect) => { return systemColorScheme.get(effect) === "dark" ? "white" : "black"; }, flex: 1, flexBasis: 1, flexGrow: 1, flexShrink: 0, fontSize: 14, fontWeight: "400", gap: 0, left: 0, lineHeight: 14, margin: 0, marginBottom: 0, marginLeft: 0, marginRight: 0, marginTop: 0, maxHeight: 99999, maxWidth: 99999, minHeight: 0, minWidth: 0, opacity: 1, padding: 0, paddingBottom: 0, paddingLeft: 0, paddingRight: 0, paddingTop: 0, perspective: 1, right: 0, rotate: "0deg", rotateX: "0deg", rotateY: "0deg", rotateZ: "0deg", scale: 1, scaleX: 1, scaleY: 1, skewX: "0deg", skewY: "0deg", textShadowRadius: 0, top: 0, translateX: 0, translateY: 0, zIndex: 0, }; const calcPrecedence: Record = { "+": 1, "-": 1, "*": 2, "/": 2, }; function applyCalcOperator( operator: string, b: number, // These are reversed because we pop them off the stack a: number, values: number[], ) { switch (operator) { case "+": return values.push(a + b); case "-": return values.push(a - b); case "*": return values.push(a * b); case "/": return values.push(a / b); } } export function calc( state: ReducerState, refs: Refs, tracking: ReducerTracking, descriptor: RuntimeValueDescriptor | RuntimeValueDescriptor[], style?: Record, ) { let mode; const values: number[] = []; const ops: string[] = []; descriptor = Array.isArray(descriptor) ? isDescriptorFunction(descriptor) ? [descriptor] : descriptor : [descriptor]; for (let token of descriptor) { switch (typeof token) { case "undefined": // Fail on an undefined value return; case "number": if (!mode) mode = "number"; if (mode !== "number") return; values.push(token); continue; case "object": { // All values should resolve to a numerical value const value = resolveValue(state, refs, tracking, token, style); switch (typeof value) { case "number": { if (!mode) mode = "number"; if (mode !== "number") return; values.push(value); continue; } case "string": { if (!value.endsWith("%")) { return; } if (!mode) mode = "percentage"; if (mode !== "percentage") return; values.push(Number.parseFloat(value.slice(0, -1))); continue; } default: return; } } case "string": { if (token === "(") { ops.push(token); } else if (token === ")") { // Resolve all values within the brackets while (ops.length && ops[ops.length - 1] !== "(") { applyCalcOperator(ops.pop()!, values.pop()!, values.pop()!, values); } ops.pop(); } else if (token.endsWith("%")) { if (!mode) mode = "percentage"; if (mode !== "percentage") return; values.push(Number.parseFloat(token.slice(0, -1))); } else { // This means we have an operator while ( ops.length && calcPrecedence[ops[ops.length - 1]] >= calcPrecedence[token] ) { applyCalcOperator(ops.pop()!, values.pop()!, values.pop()!, values); } ops.push(token); } } } } while (ops.length) { applyCalcOperator(ops.pop()!, values.pop()!, values.pop()!, values); } if (!mode) return; const value = round(values[0]); return { mode, raw: value, value: mode === "percentage" ? `${value}%` : value, }; } export function getBaseValue(state: ReducerState, paths: string[]) { paths = [...state.config.target, ...paths]; let prop: string = ""; let target: unknown = state.props; for (let index = 0; index < paths.length && target; index++) { if (!isRecord(target)) { target = undefined; continue; } prop = paths[index]; if (target[prop] === undefined) { if ( transformKeys.has(prop) && isRecord(target) && "transform" in target ) { target = target.transform.find((t: any) => t[prop] !== undefined); if (isRecord(target) && prop in target) { target = target[prop]; } else { target = undefined; } } else { target = undefined; } } else { target = target[prop]; } } const defaultValueFn = defaultValues[paths[paths.length - 1] as keyof typeof defaultValues]; const defaultValue = typeof defaultValueFn === "function" ? defaultValueFn(state.styleTracking.effect) : defaultValueFn; return { value: target as any | undefined, defaultValue, }; } function isRecord(value: unknown): value is Record { return Boolean(typeof value === "object" && value); } export function getTarget( target: Record | undefined | null, config: InteropComponentConfig, ) { for (let index = 0; index < config.target.length && target; index++) { const prop = config.target[index]; target = target[prop]; } return target || undefined; }