diff --git a/src/features/editor/views/GraphView/CustomNode/ObjectNode.tsx b/src/features/editor/views/GraphView/CustomNode/ObjectNode.tsx index bd87e928f01..c027bf820a6 100644 --- a/src/features/editor/views/GraphView/CustomNode/ObjectNode.tsx +++ b/src/features/editor/views/GraphView/CustomNode/ObjectNode.tsx @@ -43,16 +43,38 @@ const Node = ({ node, x, y }: CustomNodeProps) => ( y={0} $isObject > - {node.text.map((row, index) => ( - - ))} +
+ {(() => { + const firstRow = node.text?.[0]; + const firstPrimitiveValue = typeof firstRow?.value === "string" || typeof firstRow?.value === "number" ? String(firstRow?.value) : undefined; + const shouldShowLabel = Boolean(node.name && node.name !== firstPrimitiveValue); + return shouldShowLabel ? ( +
+ {node.name} +
+ ) : null; + })()} + +
{ + const firstRow = node.text?.[0]; + const firstPrimitiveValue = typeof firstRow?.value === "string" || typeof firstRow?.value === "number" ? String(firstRow?.value) : undefined; + const shouldShowLabel = Boolean(node.name && node.name !== firstPrimitiveValue); + return shouldShowLabel ? 16 : 0; + })() }}> + {node.text.map((row, index) => ( + + ))} +
+
); function propsAreEqual(prev: CustomNodeProps, next: CustomNodeProps) { return ( JSON.stringify(prev.node.text) === JSON.stringify(next.node.text) && - prev.node.width === next.node.width + prev.node.width === next.node.width && + prev.node.name === next.node.name && + prev.node.color === next.node.color ); } diff --git a/src/features/editor/views/GraphView/CustomNode/TextNode.tsx b/src/features/editor/views/GraphView/CustomNode/TextNode.tsx index 718ced9d989..a6c1320e51e 100644 --- a/src/features/editor/views/GraphView/CustomNode/TextNode.tsx +++ b/src/features/editor/views/GraphView/CustomNode/TextNode.tsx @@ -32,6 +32,9 @@ const Node = ({ node, x, y }: CustomNodeProps) => { const isImage = imagePreviewEnabled && isContentImage(JSON.stringify(text[0].value)); const value = text[0].value; + const firstPrimitiveValue = typeof text[0]?.value === "string" || typeof text[0]?.value === "number" ? String(text[0]?.value) : undefined; + const shouldShowLabel = Boolean(node.name && node.name !== firstPrimitiveValue); + return ( { x={0} y={0} > - {isImage ? ( - - - - ) : ( - - - {value} - - - )} +
+ {shouldShowLabel ? ( +
+ {node.name} +
+ ) : null} + + {isImage ? ( + + + + ) : ( + + + {value} + + + )} +
); }; function propsAreEqual(prev: CustomNodeProps, next: CustomNodeProps) { - return prev.node.text === next.node.text && prev.node.width === next.node.width; + return ( + prev.node.text === next.node.text && + prev.node.width === next.node.width && + prev.node.name === next.node.name && + prev.node.color === next.node.color + ); } export const TextNode = React.memo(Node, propsAreEqual); diff --git a/src/features/editor/views/GraphView/CustomNode/index.tsx b/src/features/editor/views/GraphView/CustomNode/index.tsx index ea3ac6be981..2f5f78d79dc 100644 --- a/src/features/editor/views/GraphView/CustomNode/index.tsx +++ b/src/features/editor/views/GraphView/CustomNode/index.tsx @@ -22,8 +22,9 @@ const CustomNodeWrapper = (nodeProps: NodeProps) => { const handleNodeClick = React.useCallback( (_: React.MouseEvent, data: NodeData) => { - if (setSelectedNode) setSelectedNode(data); + // show modal first for perceived responsiveness, then set selected node data setVisible("NodeModal", true); + if (setSelectedNode) setSelectedNode(data); }, [setSelectedNode, setVisible] ); @@ -41,9 +42,10 @@ const CustomNodeWrapper = (nodeProps: NodeProps) => { ev.currentTarget.style.stroke = colorScheme === "dark" ? "#424242" : "#BCBEC0"; }} style={{ - fill: colorScheme === "dark" ? "#292929" : "#ffffff", - stroke: colorScheme === "dark" ? "#424242" : "#BCBEC0", + fill: (nodeProps.properties as NodeData).color ?? (colorScheme === "dark" ? "#292929" : "#ffffff"), + stroke: (nodeProps.properties as NodeData).color ?? (colorScheme === "dark" ? "#424242" : "#BCBEC0"), strokeWidth: 1, + pointerEvents: "auto", }} > {({ node, x, y }) => { diff --git a/src/features/editor/views/GraphView/stores/useGraph.ts b/src/features/editor/views/GraphView/stores/useGraph.ts index 6e067c3c2a7..eaf8647b4a3 100644 --- a/src/features/editor/views/GraphView/stores/useGraph.ts +++ b/src/features/editor/views/GraphView/stores/useGraph.ts @@ -36,6 +36,7 @@ interface GraphActions { setDirection: (direction: CanvasDirection) => void; setViewPort: (ref: ViewPort) => void; setSelectedNode: (nodeData: NodeData) => void; + updateNode: (id: string, patch: Partial) => void; focusFirstNode: () => void; toggleFullscreen: (value: boolean) => void; zoomIn: () => void; @@ -49,6 +50,12 @@ const useGraph = create((set, get) => ({ ...initialStates, clearGraph: () => set({ nodes: [], edges: [], loading: false }), setSelectedNode: nodeData => set({ selectedNode: nodeData }), + updateNode: (id, patch) => + set(state => { + const nodes = state.nodes.map(n => (n.id === id ? { ...n, ...patch } : n)); + const selectedNode = state.selectedNode?.id === id ? { ...state.selectedNode, ...patch } : state.selectedNode; + return { nodes, selectedNode } as any; + }), setGraph: (data, options) => { const { nodes, edges } = parser(data ?? useJson.getState().json); diff --git a/src/features/modals/NodeModal/index.tsx b/src/features/modals/NodeModal/index.tsx index caba85febac..3bc7bffd08e 100644 --- a/src/features/modals/NodeModal/index.tsx +++ b/src/features/modals/NodeModal/index.tsx @@ -1,9 +1,14 @@ import React from "react"; import type { ModalProps } from "@mantine/core"; -import { Modal, Stack, Text, ScrollArea, Flex, CloseButton } from "@mantine/core"; +import { Modal, Stack, Text, ScrollArea, Flex, CloseButton, Button, Group, TextInput, Box } from "@mantine/core"; +import { ColorInput } from "@mantine/core"; import { CodeHighlight } from "@mantine/code-highlight"; import type { NodeData } from "../../../types/graph"; import useGraph from "../../editor/views/GraphView/stores/useGraph"; +import useJson from "../../../store/useJson"; +import useFile from "../../../store/useFile"; +import { useState, useEffect } from "react"; +import { useRef } from "react"; // return object from json removing array and object fields const normalizeNodeData = (nodeRows: NodeData["text"]) => { @@ -28,25 +33,232 @@ const jsonPathToString = (path?: NodeData["path"]) => { export const NodeModal = ({ opened, onClose }: ModalProps) => { const nodeData = useGraph(state => state.selectedNode); + const updateNode = useGraph(state => state.updateNode); + const setSelectedNode = useGraph(state => state.setSelectedNode); + + const [editMode, setEditMode] = useState(false); + const [localName, setLocalName] = useState(""); + const [localColor, setLocalColor] = useState("#3B82F6"); + const origNameRef = useRef(null); + const origColorRef = useRef(null); + + useEffect(() => { + if (!nodeData) return; + // prefer an explicit name, then if the node has a key+primitive value prefer the value (e.g. name: "Apple" -> "Apple"), + // otherwise fall back to the key, then parentKey, then first primitive value + const firstRow = nodeData.text?.[0]; + const firstPrimitiveValue = typeof firstRow?.value === "string" || typeof firstRow?.value === "number" ? String(firstRow?.value) : undefined; + + const inferredName = + nodeData.name ?? + (firstRow && firstRow.key && firstPrimitiveValue ? firstPrimitiveValue : undefined) ?? + firstRow?.key ?? + nodeData.parentKey ?? + firstPrimitiveValue ?? + ""; + + // infer actual color from the node's text row if present, or use nodeData.color, then fallback + const colorHexRegex = /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/; + const colorRow = nodeData.text?.find(r => r.key === "color" || (typeof r.value === "string" && colorHexRegex.test(r.value as string))); + const inferredColor = (typeof colorRow?.value === "string" && colorHexRegex.test(colorRow.value)) ? colorRow.value : (nodeData.color ?? "#3B82F6"); + + setLocalName(inferredName ?? ""); + setLocalColor(inferredColor); + origNameRef.current = inferredName ?? ""; + origColorRef.current = inferredColor; + }, [nodeData]); return ( - - Content - - +
+ + Content + +
+ + Name + +
+
+ {localName || "-"} +
+
+
+
+ {editMode ? ( + <> + + + + ) : ( + + )} + { + // reset edit state when closing the modal + if (nodeData) { + const firstRow = nodeData.text?.[0]; + const firstPrimitiveValue = typeof firstRow?.value === "string" || typeof firstRow?.value === "number" ? String(firstRow?.value) : undefined; + const inferredName = + nodeData.name ?? + (firstRow && firstRow.key && firstPrimitiveValue ? firstPrimitiveValue : undefined) ?? + firstRow?.key ?? + nodeData.parentKey ?? + firstPrimitiveValue ?? + ""; + setLocalName(inferredName ?? ""); + setLocalColor(nodeData.color ?? "#3B82F6"); + } + setEditMode(false); + onClose?.(); + }} + /> +
- + {editMode ? ( + + setLocalName(e.currentTarget.value)} label="Name" size="xs" /> + + + ) : ( + + )} diff --git a/src/types/graph.ts b/src/types/graph.ts index 428dd64617b..84b651cb082 100644 --- a/src/types/graph.ts +++ b/src/types/graph.ts @@ -14,6 +14,11 @@ export interface NodeData { width: number; height: number; path?: JSONPath; + // optional metadata populated by parser or UI + parentKey?: string; + parentType?: string; + name?: string; + color?: string; } export interface EdgeData {