diff --git a/src/viser/client/package.json b/src/viser/client/package.json index 67c687d6..40538ff6 100644 --- a/src/viser/client/package.json +++ b/src/viser/client/package.json @@ -5,6 +5,7 @@ "dependencies": { "@mantine/core": "^7.6.2", "@mantine/dates": "^7.6.2", + "@mantine/form": "^7.13.5", "@mantine/hooks": "^7.6.2", "@mantine/notifications": "^7.6.2", "@mantine/vanilla-extract": "^7.6.2", diff --git a/src/viser/client/src/ControlPanel/SceneTreeTable.css.ts b/src/viser/client/src/ControlPanel/SceneTreeTable.css.ts index ad4cdc8b..1350bad1 100644 --- a/src/viser/client/src/ControlPanel/SceneTreeTable.css.ts +++ b/src/viser/client/src/ControlPanel/SceneTreeTable.css.ts @@ -1,9 +1,7 @@ -import { style } from "@vanilla-extract/css"; +import { globalStyle, style } from "@vanilla-extract/css"; import { vars } from "../AppTheme"; export const tableWrapper = style({ - border: "1px solid", - borderColor: vars.colors.defaultBorder, borderRadius: vars.radius.xs, padding: "0.1em 0", overflowX: "auto", @@ -12,6 +10,22 @@ export const tableWrapper = style({ gap: "0", }); +export const propsWrapper = style({ + position: "relative", + borderRadius: vars.radius.xs, + border: "1px solid", + borderColor: vars.colors.defaultBorder, + padding: vars.spacing.xs, + paddingTop: "1.5em", + boxSizing: "border-box", + margin: vars.spacing.xs, + marginTop: "0.1em", + overflowX: "auto", + display: "flex", + flexDirection: "column", + gap: vars.spacing.xs, +}); + export const caretIcon = style({ opacity: 0.5, height: "1em", @@ -19,6 +33,10 @@ export const caretIcon = style({ transform: "translateY(0.1em)", }); +export const editIconWrapper = style({ + opacity: "0", +}); + export const tableRow = style({ display: "flex", alignItems: "center", @@ -27,3 +45,7 @@ export const tableRow = style({ lineHeight: "2em", fontSize: "0.875em", }); + +globalStyle(`${tableRow}:hover ${editIconWrapper}`, { + opacity: "1.0", +}); diff --git a/src/viser/client/src/ControlPanel/SceneTreeTable.tsx b/src/viser/client/src/ControlPanel/SceneTreeTable.tsx index 875f1e95..c8963baf 100644 --- a/src/viser/client/src/ControlPanel/SceneTreeTable.tsx +++ b/src/viser/client/src/ControlPanel/SceneTreeTable.tsx @@ -1,14 +1,258 @@ -import { ViewerContext } from "../App"; -import { Box, ScrollArea, Tooltip } from "@mantine/core"; import { IconCaretDown, IconCaretRight, IconEye, IconEyeOff, + IconPencil, + IconDeviceFloppy, + IconX, } from "@tabler/icons-react"; import React from "react"; -import { caretIcon, tableRow, tableWrapper } from "./SceneTreeTable.css"; +import { + caretIcon, + editIconWrapper, + propsWrapper, + tableRow, + tableWrapper, +} from "./SceneTreeTable.css"; import { useDisclosure } from "@mantine/hooks"; +import { useForm } from "@mantine/form"; +import { ViewerContext } from "../App"; +import { + Box, + Flex, + ScrollArea, + TextInput, + Tooltip, + ColorInput, +} from "@mantine/core"; + +function EditNodeProps({ + nodeName, + close, +}: { + nodeName: string; + close: () => void; +}) { + const viewer = React.useContext(ViewerContext)!; + const node = viewer.useSceneTree((state) => state.nodeFromName[nodeName]); + const updateSceneNode = viewer.useSceneTree((state) => state.updateSceneNode); + + if (node === undefined) { + return null; + } + + // We'll use JSON, but add support for Infinity. + // We use infinity for point cloud rendering norms. + function stringify(value: any) { + if (value == Number.POSITIVE_INFINITY) { + return "Infinity"; + } else { + return JSON.stringify(value); + } + } + function parse(value: string) { + if (value === "Infinity") { + return Number.POSITIVE_INFINITY; + } else { + return JSON.parse(value); + } + } + + const props = node.message.props; + console.log(props); + const initialValues = Object.fromEntries( + Object.entries(props) + .filter(([, value]) => !(value instanceof Uint8Array)) + .map(([key, value]) => [key, stringify(value)]), + ); + + const form = useForm({ + initialValues: { + ...initialValues, + }, + validate: { + ...Object.fromEntries( + Object.keys(initialValues).map((key) => [ + key, + (value: string) => { + try { + parse(value); + return null; + } catch (e) { + return "Invalid JSON"; + } + }, + ]), + ), + }, + }); + + const handleSubmit = (values: Record) => { + Object.entries(values).forEach(([key, value]) => { + if (value !== initialValues[key]) { + try { + const parsedValue = parse(value); + updateSceneNode(nodeName, { [key]: parsedValue }); + // Update the form value to match the parsed value + form.setFieldValue(key, stringify(parsedValue)); + } catch (e) { + console.error("Failed to parse JSON:", e); + } + } + }); + }; + + return ( + + + + { + evt.stopPropagation(); + close(); + }} + /> + + + {Object.entries(props).map(([key, value]) => { + if (value instanceof Uint8Array) { + return null; + } + + const isDirty = form.values[key] !== initialValues[key]; + + return ( + + + {key.charAt(0).toUpperCase() + key.slice(1).split("_").join(" ")} + + + {(() => { + // Check if this is a color property + try { + const parsedValue = parse(form.values[key]); + const isColorProp = + key.toLowerCase().includes("color") && + Array.isArray(parsedValue) && + parsedValue.length === 3 && + parsedValue.every((v) => typeof v === "number"); + + if (isColorProp) { + // Convert RGB array [0-1] to hex color + const rgbToHex = (r: number, g: number, b: number) => { + const toHex = (n: number) => { + const hex = Math.round(n).toString(16); + return hex.length === 1 ? "0" + hex : hex; + }; + return "#" + toHex(r) + toHex(g) + toHex(b); + }; + + // Convert hex color to RGB array [0-1] + const hexToRgb = (hex: string) => { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return [r, g, b]; + }; + + return ( + { + const rgb = hexToRgb(hex); + form.setFieldValue(key, stringify(rgb)); + form.onSubmit(handleSubmit)(); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + form.onSubmit(handleSubmit)(); + } + }} + /> + ); + } + } catch (e) { + // If parsing fails, fall back to TextInput + } + + // Default TextInput for non-color properties + return ( + { + if (e.key === "Enter") { + e.preventDefault(); + form.onSubmit(handleSubmit)(); + } + }} + rightSection={ + { + if (isDirty) { + form.onSubmit(handleSubmit)(); + } + }} + /> + } + /> + ); + })()} + + + ); + })} + + Changes can be overwritten by updates from the server. + + + ); +} /* Table for seeing an overview of the scene tree, toggling visibility, etc. * */ export default function SceneTreeTable() { @@ -74,6 +318,9 @@ const SceneTreeTableRow = React.memo(function SceneTreeTableRow(props: { const isVisibleEffective = isVisible && props.isParentVisible; const VisibleIcon = isVisible ? IconEye : IconEyeOff; + const [modalOpened, { open: openEditModal, close: closeEditModal }] = + useDisclosure(false); + return ( <> { evt.stopPropagation(); @@ -113,7 +361,7 @@ const SceneTreeTableRow = React.memo(function SceneTreeTableRow(props: { /> - + {props.nodeName .split("/") .filter((part) => part.length > 0) @@ -128,7 +376,36 @@ const SceneTreeTableRow = React.memo(function SceneTreeTableRow(props: { ))} + {!modalOpened ? ( + + + { + evt.stopPropagation(); + openEditModal(); + }} + /> + + + ) : null} + {modalOpened ? ( + + ) : null} {expanded ? childrenName.map((name) => ( - + Scene tree diff --git a/src/viser/client/yarn.lock b/src/viser/client/yarn.lock index 0dddc85c..df49a0ef 100644 --- a/src/viser/client/yarn.lock +++ b/src/viser/client/yarn.lock @@ -573,6 +573,14 @@ dependencies: clsx "^2.1.1" +"@mantine/form@^7.13.5": + version "7.13.5" + resolved "https://registry.yarnpkg.com/@mantine/form/-/form-7.13.5.tgz#dfd57a594f71a91c4edf93a8c32037a8dcdc6efe" + integrity sha512-BnHbGFPRlIfzpetg+igjn81MUuI38qEcLhiC3s7grolaJncAnvcxSEVUTiwUJP2KS6mqxtNHKOcaqEs7rH8Umg== + dependencies: + fast-deep-equal "^3.1.3" + klona "^2.0.6" + "@mantine/hooks@^7.6.2": version "7.12.2" resolved "https://registry.yarnpkg.com/@mantine/hooks/-/hooks-7.12.2.tgz#f8e6a8345bb0892d8d1f5d1dc544a568572b79f4" @@ -3056,6 +3064,11 @@ keyv@^4.5.3: dependencies: json-buffer "3.0.1" +klona@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/klona/-/klona-2.0.6.tgz#85bffbf819c03b2f53270412420a4555ef882e22" + integrity sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA== + levn@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade"