diff --git a/apps/vscode/src/extension/editor/document.ts b/apps/vscode/src/extension/editor/document.ts index ecfa4d5cd..009f76947 100644 --- a/apps/vscode/src/extension/editor/document.ts +++ b/apps/vscode/src/extension/editor/document.ts @@ -5,16 +5,9 @@ * see this files license find the nearest LICENSE file up the source tree. */ import { type Mutation } from "@triplex/server"; +import { toJSONString } from "@triplex/lib"; import * as vscode from "vscode"; -function toJSONString(value: unknown): string { - const str = JSON.stringify(value, (_k, v) => - v === undefined ? "__UNDEFINED__" : v, - ); - - return str.replaceAll('"__UNDEFINED__"', "undefined"); -} - export class TriplexDocument implements vscode.CustomDocument { private _onDidChange = new vscode.EventEmitter<{ label: string; diff --git a/packages/@triplex/editor-next/src/features/panels/inputs.tsx b/packages/@triplex/editor-next/src/features/panels/inputs.tsx index 4a6106113..ef8aa94ba 100644 --- a/packages/@triplex/editor-next/src/features/panels/inputs.tsx +++ b/packages/@triplex/editor-next/src/features/panels/inputs.tsx @@ -6,6 +6,7 @@ */ import { CheckIcon, + CodeIcon, ExclamationTriangleIcon, SwitchIcon, } from "@radix-ui/react-icons"; @@ -16,7 +17,7 @@ import { BooleanInput, ColorInput, LiteralUnionInput, - NumberInput, + NumberOrExpressionInput, resolveDefaultValue, StringInput, TupleInputNext, @@ -126,7 +127,7 @@ export const renderPropInputs: RenderInputs = ({ const persistedValue = "value" in prop.prop ? prop.prop.value : undefined; return ( - - {({ ref, ...props }, { isActive }) => ( + {({ ref, ...props }, { isActive, mode, shouldFocus, toggle }) => ( <> - +
+ + +
)} -
+ ); } diff --git a/packages/@triplex/server/src/ast/__tests__/__mocks__/prop-expression.tsx b/packages/@triplex/server/src/ast/__tests__/__mocks__/prop-expression.tsx new file mode 100644 index 000000000..a97c8ddd7 --- /dev/null +++ b/packages/@triplex/server/src/ast/__tests__/__mocks__/prop-expression.tsx @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2022—present Michael Dougall. All rights reserved. + * + * This repository utilizes multiple licenses across different directories. To + * see this files license find the nearest LICENSE file up the source tree. + */ +import { memo } from "react"; + +const Box = () => { + const pi = Math.PI; + return ( + + + + + ); +}; + +export default memo(Box); diff --git a/packages/@triplex/server/src/ast/__tests__/type-infer.test.ts b/packages/@triplex/server/src/ast/__tests__/type-infer.test.ts index 50ed266cf..e202b6ad5 100644 --- a/packages/@triplex/server/src/ast/__tests__/type-infer.test.ts +++ b/packages/@triplex/server/src/ast/__tests__/type-infer.test.ts @@ -1201,4 +1201,37 @@ describe("type infer", () => { ] `); }); + it("should evaluate expressions", () => { + const project = _createProject({ + tsConfigFilePath: join(__dirname, "__mocks__/tsconfig.json"), + }); + const sourceFile = project.addSourceFileAtPath( + join(__dirname, "__mocks__/prop-expression.tsx"), + ); + const sceneObject = getJsxElementAt(sourceFile, 12, 5); + if (!sceneObject) { + throw new Error("not found"); + } + + const { props } = getJsxElementPropTypes(sceneObject); + const rotateXProp = props.find((prop) => prop.name === "rotateX"); + expect(rotateXProp).toEqual( + expect.objectContaining({ + value: "Math.PI / 2", + valueKind: "number", + }), + ); + const rotateYProp = props.find((prop) => prop.name === "rotateY"); + expect(rotateYProp).toEqual( + expect.objectContaining({ + valueKind: "unhandled", + }), + ); + const positionProp = props.find((prop) => prop.name === "position"); + expect(positionProp).toEqual( + expect.objectContaining({ + value: ["Math.sqrt(2)", "Math.sqrt(2)", "Math.sqrt(2)"], + }), + ); + }); }); diff --git a/packages/@triplex/server/src/ast/type-infer.ts b/packages/@triplex/server/src/ast/type-infer.ts index 32f8dfd75..bee66e434 100644 --- a/packages/@triplex/server/src/ast/type-infer.ts +++ b/packages/@triplex/server/src/ast/type-infer.ts @@ -4,6 +4,7 @@ * This repository utilizes multiple licenses across different directories. To * see this files license find the nearest LICENSE file up the source tree. */ +import { evaluateNumericalExpression } from "@triplex/lib/math"; import { type AttributeValue, type DeclaredProp, @@ -464,6 +465,10 @@ export function resolveExpressionValue( return { kind: "number", value: Number(expression.getLiteralText()) }; } + if (expression && evaluateNumericalExpression(expression.getText())) { + return { kind: "number", value: expression.getText() }; + } + if (Node.isPrefixUnaryExpression(expression)) { const operand = expression.getOperand(); if (Node.isNumericLiteral(operand)) { diff --git a/packages/lib/package.json b/packages/lib/package.json index 21750d58f..7a3dc3718 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -29,6 +29,11 @@ "module": "./src/log.ts", "default": "./src/log.ts" }, + "./math": { + "types": "./src/math.ts", + "module": "./src/math.ts", + "default": "./src/math.ts" + }, "./node": { "types": "./src/node.ts", "module": "./src/node.ts", diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts index 79d48994f..c49b8f0be 100644 --- a/packages/lib/src/index.ts +++ b/packages/lib/src/index.ts @@ -5,7 +5,11 @@ * see this files license find the nearest LICENSE file up the source tree. */ export { cn } from "./tw-merge"; -export { toJSONString } from "./string"; +export { + toJSONString, + type RawCodeExpression, + isRawCodeExpression, +} from "./string"; export { useEvent } from "./use-event"; export { type Accelerator, onKeyDown, blockInputPropagation } from "./keyboard"; export { diff --git a/packages/lib/src/math.ts b/packages/lib/src/math.ts new file mode 100644 index 000000000..940f7a46a --- /dev/null +++ b/packages/lib/src/math.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2022—present Michael Dougall. All rights reserved. + * + * This repository utilizes multiple licenses across different directories. To + * see this files license find the nearest LICENSE file up the source tree. + */ +export function evaluateNumericalExpression(expression: string): number | null { + try { + const func = new Function(`return ${expression}`); + const result = func(); + + if ( + typeof result === "number" && + !Number.isNaN(result) && + Number.isFinite(result) + ) { + return result; + } + return null; + } catch { + return null; + } +} diff --git a/packages/lib/src/string.ts b/packages/lib/src/string.ts index 7ecc25850..2c59b7d93 100644 --- a/packages/lib/src/string.ts +++ b/packages/lib/src/string.ts @@ -6,12 +6,36 @@ */ import { type CSSProperties } from "react"; +export interface RawCodeExpression { + __expr: string; +} + +export function isRawCodeExpression( + value: unknown, +): value is RawCodeExpression { + return Boolean(value && typeof value === "object" && "__expr" in value); +} + export function toJSONString(value: unknown): string { - const str = JSON.stringify(value, (_k, v) => - v === undefined ? "__UNDEFINED__" : v, - ); + // Handle RawCodeExpression at the root level first + if (isRawCodeExpression(value)) { + return value.__expr; + } + + const str = JSON.stringify(value, (_k, v) => { + if (v === undefined) { + return "__UNDEFINED__"; + } + // Handle raw code expressions in nested values + if (isRawCodeExpression(v)) { + return `__EXPR__${v.__expr}__EXPR__`; + } + return v; + }); - return str.replaceAll('"__UNDEFINED__"', "undefined"); + return str + .replaceAll('"__UNDEFINED__"', "undefined") + .replaceAll(/"__EXPR__(.*?)__EXPR__"/g, "$1"); } export function kebabCase(str: string): string { diff --git a/packages/renderer/src/features/scene-element/use-temporary-props.tsx b/packages/renderer/src/features/scene-element/use-temporary-props.tsx index e19b1cfd1..5aec3ef51 100644 --- a/packages/renderer/src/features/scene-element/use-temporary-props.tsx +++ b/packages/renderer/src/features/scene-element/use-temporary-props.tsx @@ -6,7 +6,9 @@ */ import { compose, on, type RendererElementProps } from "@triplex/bridge/client"; +import { isRawCodeExpression } from "@triplex/lib"; import { fg } from "@triplex/lib/fg"; +import { evaluateNumericalExpression } from "@triplex/lib/math"; import { useCallback, useEffect, useRef, useState } from "react"; function useForceRender() { @@ -14,6 +16,14 @@ function useForceRender() { return useCallback(() => setState((prev) => prev + 1), []); } +function unwrapPropValue(value: unknown): unknown { + if (isRawCodeExpression(value)) { + const evaluated = evaluateNumericalExpression(value.__expr); + return evaluated !== null ? evaluated : value; + } + return value; +} + export function useTemporaryProps( meta: RendererElementProps["__meta"], props: Record, @@ -40,8 +50,10 @@ export function useTemporaryProps( } }), on("request-set-element-prop", (data) => { + const propValue = unwrapPropValue(data.propValue); + if (data.astPath === meta.astPath && fg("selection_ast_path")) { - intermediateProps.current[data.propName] = data.propValue; + intermediateProps.current[data.propName] = propValue; forceRender(); } else if ( "column" in data && @@ -49,7 +61,7 @@ export function useTemporaryProps( data.line === meta.line && data.path === meta.path ) { - intermediateProps.current[data.propName] = data.propValue; + intermediateProps.current[data.propName] = propValue; forceRender(); } }), diff --git a/packages/ux/src/inputs/index.tsx b/packages/ux/src/inputs/index.tsx index ab9fbc9a3..f8774ef47 100644 --- a/packages/ux/src/inputs/index.tsx +++ b/packages/ux/src/inputs/index.tsx @@ -8,6 +8,7 @@ export { BooleanInput } from "./boolean-input"; export { ColorInput } from "./color-input"; export { LiteralUnionInput } from "./literal-union-input"; export { NumberInput } from "./number-input"; +export { NumberOrExpressionInput } from "./number-or-expression-input"; export { PropInput } from "./prop-input"; export { StringInput } from "./string-input"; export { TupleInput } from "./tuple-input"; diff --git a/packages/ux/src/inputs/number-or-expression-input.tsx b/packages/ux/src/inputs/number-or-expression-input.tsx new file mode 100644 index 000000000..0a014188a --- /dev/null +++ b/packages/ux/src/inputs/number-or-expression-input.tsx @@ -0,0 +1,191 @@ +/** + * Copyright (c) 2022—present Michael Dougall. All rights reserved. + * + * This repository utilizes multiple licenses across different directories. To + * see this files license find the nearest LICENSE file up the source tree. + */ +import { type RawCodeExpression } from "@triplex/lib"; +import { evaluateNumericalExpression } from "@triplex/lib/math"; +import { useCallback, useState } from "react"; +import { type ActionIdSafe } from "../telemetry"; +import { NumberInput } from "./number-input"; +import { StringInput } from "./string-input"; +import { type RenderInput } from "./types"; + +type Mode = "number" | "expression"; + +interface NumberOrExpressionInputProps { + actionId: ActionIdSafe; + children: RenderInput< + { + defaultValue: number | string | undefined; + max?: number; + min?: number; + placeholder?: string; + }, + HTMLInputElement, + { + clear: () => void; + isActive?: boolean; + mode: Mode; + shouldFocus: boolean; + toggle: () => void; + } + >; + defaultValue?: number; + label?: string; + max?: number; + min?: number; + name: string; + onChange: (value: number | string | undefined) => void; + onConfirm: (value: number | string | RawCodeExpression | undefined) => void; + persistedValue?: number | string; + pointerMode?: "capture" | "lock"; + required?: boolean; + step?: number; + testId?: string; + transformValue?: { + in: (value: number | undefined) => number | undefined; + out: (value: number | undefined) => number | undefined; + }; +} + +/** + * A number input that can toggle between numeric input mode and code input + * mode. In expression mode, users can enter mathematical expressions like + * "Math.PI / 2" or "Math.sqrt(2)" + */ +export function NumberOrExpressionInput({ + actionId, + children, + defaultValue, + label, + max, + min, + name, + onChange, + onConfirm, + persistedValue, + pointerMode = "lock", + required, + step, + testId, + transformValue, +}: NumberOrExpressionInputProps) { + const getInitialMode = (): Mode => { + if (typeof persistedValue === "string") { + return "expression"; + } + return "number"; + }; + + const [mode, setMode] = useState(getInitialMode); + const [shouldFocus, setShouldFocus] = useState(false); + + const handleExpressionChange = useCallback( + (value: string | undefined) => { + if (value === undefined) { + onChange(undefined); + return; + } + + // For live preview, pass the evaluated number + const evaluated = evaluateNumericalExpression(value); + if (evaluated !== null) { + onChange(evaluated); + } + }, + [onChange], + ); + + const handleExpressionConfirm = useCallback( + (value: string | undefined) => { + if (value === undefined) { + onConfirm(undefined); + return; + } + + // For saving, wrap the expression in a RawCodeExpression because we don't want it to be interpreted as a regular string + const evaluated = evaluateNumericalExpression(value); + if (evaluated !== null) { + onConfirm({ __expr: value }); + } + }, + [onConfirm], + ); + + const toggleMode = useCallback(() => { + setMode((prevMode) => (prevMode === "number" ? "expression" : "number")); + setShouldFocus(true); + }, []); + + const handleClear = useCallback(() => { + onChange(undefined); + onConfirm(undefined); + }, [onChange, onConfirm]); + + if (mode === "number") { + return ( + + {(inputProps, inputActions) => + children(inputProps, { + clear: handleClear, + isActive: inputActions.isActive, + mode, + shouldFocus, + toggle: toggleMode, + }) + } + + ); + } + + const expressionPersistedValue = + typeof persistedValue === "string" + ? persistedValue + : typeof persistedValue === "number" + ? String(persistedValue) + : undefined; + + return ( + + {(inputProps) => + children(inputProps, { + clear: handleClear, + isActive: false, + mode, + shouldFocus, + toggle: toggleMode, + }) + } + + ); +}