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,
+ })
+ }
+
+ );
+}