Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 1 addition & 8 deletions apps/vscode/src/extension/editor/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
43 changes: 29 additions & 14 deletions packages/@triplex/editor-next/src/features/panels/inputs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/
import {
CheckIcon,
CodeIcon,
ExclamationTriangleIcon,
SwitchIcon,
} from "@radix-ui/react-icons";
Expand All @@ -16,7 +17,7 @@ import {
BooleanInput,
ColorInput,
LiteralUnionInput,
NumberInput,
NumberOrExpressionInput,
resolveDefaultValue,
StringInput,
TupleInputNext,
Expand Down Expand Up @@ -126,7 +127,7 @@ export const renderPropInputs: RenderInputs = ({
const persistedValue = "value" in prop.prop ? prop.prop.value : undefined;

return (
<NumberInput
<NumberOrExpressionInput
{...prop.prop.tags}
actionId="scene_controls"
defaultValue={resolveDefaultValue(prop.prop, "number")}
Expand All @@ -138,7 +139,7 @@ export const renderPropInputs: RenderInputs = ({
pointerMode="capture"
required={prop.prop.required}
>
{({ ref, ...props }, { isActive }) => (
{({ ref, ...props }, { isActive, mode, shouldFocus, toggle }) => (
<>
<Label
description={prop.prop.description}
Expand All @@ -147,19 +148,33 @@ export const renderPropInputs: RenderInputs = ({
>
{prop.prop.name}
</Label>
<input
{...props}
aria-label={prop.prop.label}
className={cn([
!isActive && "invalid:border-danger",
"text-input border-input focus:border-selected bg-input placeholder:text-input-placeholder mb-1 h-[26px] w-full cursor-col-resize rounded-sm border px-[9px] [color-scheme:light_dark] [font-variant-numeric:tabular-nums] focus:cursor-text focus:outline-none",
])}
ref={ref}
type="number"
/>
<div className="mb-1 flex gap-1">
<input
{...props}
aria-label={prop.prop.label}
autoFocus={shouldFocus}
className={cn([
!isActive && "invalid:border-danger",
"text-input border-input focus:border-selected bg-input placeholder:text-input-placeholder mb-1 h-[26px] w-full cursor-col-resize rounded-sm border px-[9px] [color-scheme:light_dark] [font-variant-numeric:tabular-nums] focus:cursor-text focus:outline-none",
])}
ref={ref}
type={mode === "number" ? "number" : "text"}
/>
<IconButton
actionId="contextpanel_input_number_expression_toggle"
icon={CodeIcon}
label={
mode === "number"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any thoughts on how we could have this auto infer? e.g. when typing into the input it would change to expression mode if an expression is found (or alternatively/more simply, maybe it is always in expression mode for number inputs?) and then we can enable / disable features if the value is an expression vs. number literal (the latter enables dragging to change the value)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ohh that's smart, I like the idea of having the input always be in expression mode since a number is an expression albeit a simple one. I think I'll have a second go at it cause this all could be simpler than having a toggle when I think about it. thx!

? "Switch to expression"
: "Switch to number"
}
onClick={toggle}
spacing="spacious"
/>
</div>
</>
)}
</NumberInput>
</NumberOrExpressionInput>
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<mesh
position={[Math.sqrt(2), Math.sqrt(2), Math.sqrt(2)]}
rotateX={Math.PI / 2}
rotateY={pi / 2}
>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="pink" />
</mesh>
);
};

export default memo(Box);
33 changes: 33 additions & 0 deletions packages/@triplex/server/src/ast/__tests__/type-infer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@

const { props } = getJsxElementPropTypes(sceneObject);

expect(props).toMatchInlineSnapshot(`

Check failure on line 49 in packages/@triplex/server/src/ast/__tests__/type-infer.test.ts

View workflow job for this annotation

GitHub Actions / unit (ubuntu-latest)

packages/@triplex/server/src/ast/__tests__/type-infer.test.ts > type infer > should return types of a imported component

Error: Snapshot `type infer > should return types of a imported component 1` mismatched - Expected + Received @@ -58,12 +58,12 @@ }, ], "tags": {}, "value": [ 1.660031347769923, - -0.07873115868670048, + "-0.078_731_158_686_700_48", - -0.7211124466452248, + "-0.721_112_446_645_224_8", ], "valueKind": "array", }, { "description": undefined, ❯ packages/@triplex/server/src/ast/__tests__/type-infer.test.ts:49:19
[
{
"column": 9,
Expand Down Expand Up @@ -1201,4 +1201,37 @@
]
`);
});
it("should evaluate expressions", () => {
const project = _createProject({
tsConfigFilePath: join(__dirname, "__mocks__/tsconfig.json"),
});
const sourceFile = project.addSourceFileAtPath(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: you can use createSourceFile and have it embedded in the test instead of having it in the mocks folder

tbh I would have done that but didn't realize it existed until much later

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)"],
}),
);
});
});
5 changes: 5 additions & 0 deletions packages/@triplex/server/src/ast/type-infer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -464,6 +465,10 @@ export function resolveExpressionValue(
return { kind: "number", value: Number(expression.getLiteralText()) };
}

if (expression && evaluateNumericalExpression(expression.getText())) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah cool so if we can evaluate it then we allow it, nice

return { kind: "number", value: expression.getText() };
}

if (Node.isPrefixUnaryExpression(expression)) {
const operand = expression.getOperand();
if (Node.isNumericLiteral(operand)) {
Expand Down
5 changes: 5 additions & 0 deletions packages/lib/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion packages/lib/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
23 changes: 23 additions & 0 deletions packages/lib/src/math.ts
Original file line number Diff line number Diff line change
@@ -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}`);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any thoughts on sanitizing this input

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yep that's definitely a good idea

const result = func();

if (
typeof result === "number" &&
!Number.isNaN(result) &&
Number.isFinite(result)
) {
return result;
}
return null;
} catch {
return null;
}
}
32 changes: 28 additions & 4 deletions packages/lib/src/string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,24 @@
*/

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() {
const [, setState] = useState(0);
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<string, unknown>,
Expand All @@ -40,16 +50,18 @@ export function useTemporaryProps(
}
}),
on("request-set-element-prop", (data) => {
const propValue = unwrapPropValue(data.propValue);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah because we receive the prop as the expression literal not the string 🤔

and this is only for setting the temporary value before it's persisted?

what if we updated this code path to always take the evaluated value and then for the actual prop setting it takes the serializable prop values / expressions?

before if I remember correctly it just assumes everything is one in the same, now it can be different

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, I'll look into it :)


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 &&
data.column === meta.column &&
data.line === meta.line &&
data.path === meta.path
) {
intermediateProps.current[data.propName] = data.propValue;
intermediateProps.current[data.propName] = propValue;
forceRender();
}
}),
Expand Down
1 change: 1 addition & 0 deletions packages/ux/src/inputs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading
Loading