diff --git a/src/features/editor/views/GraphView/CustomNode/ObjectNode.tsx b/src/features/editor/views/GraphView/CustomNode/ObjectNode.tsx
index bd87e928f01..ebc0f5afe51 100644
--- a/src/features/editor/views/GraphView/CustomNode/ObjectNode.tsx
+++ b/src/features/editor/views/GraphView/CustomNode/ObjectNode.tsx
@@ -4,15 +4,28 @@ import { NODE_DIMENSIONS } from "../../../../../constants/graph";
import type { NodeData } from "../../../../../types/graph";
import { TextRenderer } from "./TextRenderer";
import * as Styled from "./styles";
+import useGraph from "../stores/useGraph";
+import { Button, Group } from "@mantine/core";
type RowProps = {
row: NodeData["text"][number];
x: number;
y: number;
index: number;
+ nodeId: string;
+ isEditing: boolean;
+ editingValue: string | null;
+ onEdit: () => void;
+ onCancel: () => void;
+ onSave: () => void;
+ onChange: (val: string) => void;
};
-const Row = ({ row, x, y, index }: RowProps) => {
+const Row = ({
+ row, x, y, index, nodeId,
+ isEditing, editingValue,
+ onEdit, onCancel, onSave, onChange,
+}: RowProps) => {
const rowPosition = index * NODE_DIMENSIONS.ROW_HEIGHT;
const getRowText = () => {
@@ -29,25 +42,70 @@ const Row = ({ row, x, y, index }: RowProps) => {
data-y={y + rowPosition}
>
{row.key}:
- {getRowText()}
+ {isEditing ? (
+
+ onChange(e.target.value)}
+ style={{ minWidth: 80 }}
+ />
+
+
+
+ ) : (
+ <>
+ {getRowText()}
+ {(row.type !== "object" && row.type !== "array") && (
+
+ )}
+ >
+ )}
);
};
-const Node = ({ node, x, y }: CustomNodeProps) => (
-
- {node.text.map((row, index) => (
-
- ))}
-
-);
+const Node = ({ node, x, y }: CustomNodeProps) => {
+ const {
+ editingNodeId,
+ editingRowIndex,
+ editingValue,
+ startEditingNode,
+ cancelEditingNode,
+ setEditingValue,
+ saveEditingNodeValue,
+ } = useGraph();
+
+ return (
+
+ {node.text.map((row, index) => {
+ const isEditing = editingNodeId === node.id && editingRowIndex === index;
+ return (
+ startEditingNode(node.id, index, String(row.value ?? ""))}
+ onCancel={cancelEditingNode}
+ onSave={saveEditingNodeValue}
+ onChange={setEditingValue}
+ />
+ );
+ })}
+
+ );
+};
function propsAreEqual(prev: CustomNodeProps, next: CustomNodeProps) {
return (
diff --git a/src/features/editor/views/GraphView/stores/useGraph.ts b/src/features/editor/views/GraphView/stores/useGraph.ts
index 6e067c3c2a7..1e85eacacec 100644
--- a/src/features/editor/views/GraphView/stores/useGraph.ts
+++ b/src/features/editor/views/GraphView/stores/useGraph.ts
@@ -16,6 +16,9 @@ export interface Graph {
selectedNode: NodeData | null;
path: string;
aboveSupportedLimit: boolean;
+ editingNodeId: string | null;
+ editingRowIndex: number | null;
+ editingValue: string | null;
}
const initialStates: Graph = {
@@ -28,6 +31,9 @@ const initialStates: Graph = {
selectedNode: null,
path: "",
aboveSupportedLimit: false,
+ editingNodeId: null,
+ editingRowIndex: null,
+ editingValue: null,
};
interface GraphActions {
@@ -43,7 +49,55 @@ interface GraphActions {
centerView: () => void;
clearGraph: () => void;
setZoomFactor: (zoomFactor: number) => void;
+ startEditingNode: (nodeId: string, rowIndex: number, value: string) => void;
+ cancelEditingNode: () => void;
+ setEditingValue: (value: string) => void;
+ saveEditingNodeValue: () => void;
}
+type JsonPath = (string | number)[];
+
+function safeParseJson(text: string): any | null {
+ try {
+ return JSON.parse(text);
+ } catch {
+ return null;
+ }
+}
+
+function setValueAtPath(target: any, path: JsonPath, newValue: any): any {
+ if (path.length === 0) return newValue;
+
+ const [head, ...rest] = path;
+ const key = typeof head === "number" ? head : String(head);
+
+ // Arrays
+ if (Array.isArray(target)) {
+ const index = typeof head === "number" ? head : Number(head);
+ const clone = [...target];
+ const currentChild = clone[index];
+ if (rest.length === 0) {
+ clone[index] = newValue;
+ } else {
+ clone[index] = setValueAtPath(currentChild, rest, newValue);
+ }
+ return clone;
+ }
+
+ // Objects
+ const base: any =
+ target !== null && typeof target === "object"
+ ? { ...target }
+ : {};
+
+ if (rest.length === 0) {
+ base[key] = newValue;
+ } else {
+ base[key] = setValueAtPath(base[key], rest, newValue);
+ }
+
+ return base;
+}
+
const useGraph = create((set, get) => ({
...initialStates,
@@ -101,6 +155,104 @@ const useGraph = create((set, get) => ({
},
toggleFullscreen: fullscreen => set({ fullscreen }),
setViewPort: viewPort => set({ viewPort }),
+ editingNodeId: null,
+ editingRowIndex: null,
+ editingValue: null,
+
+ startEditingNode: (nodeId, rowIndex, value) => set({
+ editingNodeId: nodeId,
+ editingRowIndex: rowIndex,
+ editingValue: value,
+ }),
+ cancelEditingNode: () => set({
+ editingNodeId: null,
+ editingRowIndex: null,
+ editingValue: null,
+ }),
+ setEditingValue: value => set({ editingValue: value }),
+ saveEditingNodeValue: () => {
+ const { editingNodeId, editingRowIndex, editingValue, nodes, selectedNode } = get();
+ if (!editingNodeId || editingValue == null) return;
+
+ const jsonStr = useJson.getState().json;
+ const root = safeParseJson(jsonStr);
+ if (root == null) {
+ set({ editingNodeId: null, editingRowIndex: null, editingValue: null });
+ return;
+ }
+
+ let updatedRoot = root;
+
+ // 1) Modal editing (editing the whole node JSON)
+ if (editingRowIndex == null && selectedNode?.path) {
+ // Here editingValue is the JSON text shown in the modal for that node.
+ const parsedNodeValue = safeParseJson(editingValue);
+ if (parsedNodeValue == null) {
+ // invalid JSON, abort and reset editing state
+ set({ editingNodeId: null, editingRowIndex: null, editingValue: null });
+ return;
+ }
+
+ updatedRoot = setValueAtPath(
+ root,
+ selectedNode.path as JsonPath,
+ parsedNodeValue
+ );
+ }
+
+ // 2) Row editing (single property in node details)
+ if (editingRowIndex != null) {
+ const node = nodes.find(n => n.id === editingNodeId);
+ if (!node || !node.path) {
+ set({ editingNodeId: null, editingRowIndex: null, editingValue: null });
+ return;
+ }
+
+ const row = node.text[editingRowIndex];
+ if (!row) {
+ set({ editingNodeId: null, editingRowIndex: null, editingValue: null });
+ return;
+ }
+
+ // Try to preserve numbers/booleans/null by parsing simple literals
+ let value: any = editingValue;
+ const trimmed = editingValue.trim();
+ const looksLikeLiteral =
+ trimmed === "true" ||
+ trimmed === "false" ||
+ trimmed === "null" ||
+ /^-?\d+(\.\d+)?$/.test(trimmed);
+
+ if (looksLikeLiteral) {
+ try {
+ value = JSON.parse(trimmed);
+ } catch {
+ value = editingValue;
+ }
+ }
+
+ const fullPath: JsonPath = [...(node.path as JsonPath), row.key];
+ updatedRoot = setValueAtPath(root, fullPath, value);
+ }
+
+ const updatedJsonStr = JSON.stringify(updatedRoot, null, 2);
+
+ // This will also update the left editor (via your setJson/useFile wiring)
+ useJson.getState().setJson(updatedJsonStr);
+
+ // Optionally re-parse to refresh nodes/edges + selectedNode
+ const { nodes: updatedNodes, edges: updatedEdges } = parser(updatedJsonStr);
+ set({
+ nodes: updatedNodes,
+ edges: updatedEdges,
+ selectedNode: updatedNodes.find(n => n.id === editingNodeId) ?? null,
+ editingNodeId: null,
+ editingRowIndex: null,
+ editingValue: null,
+ });
+},
}));
+
export default useGraph;
+
diff --git a/src/features/modals/NodeModal/index.tsx b/src/features/modals/NodeModal/index.tsx
index caba85febac..37a8ee90041 100644
--- a/src/features/modals/NodeModal/index.tsx
+++ b/src/features/modals/NodeModal/index.tsx
@@ -1,6 +1,6 @@
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 } from "@mantine/core";
import { CodeHighlight } from "@mantine/code-highlight";
import type { NodeData } from "../../../types/graph";
import useGraph from "../../editor/views/GraphView/stores/useGraph";
@@ -28,6 +28,32 @@ const jsonPathToString = (path?: NodeData["path"]) => {
export const NodeModal = ({ opened, onClose }: ModalProps) => {
const nodeData = useGraph(state => state.selectedNode);
+ const {
+ editingNodeId,
+ editingValue,
+ startEditingNode,
+ cancelEditingNode,
+ setEditingValue,
+ saveEditingNodeValue,
+ } = useGraph();
+
+ // Use node id for modal editing
+ const isEditing = editingNodeId === nodeData?.id;
+
+ // Get normalized JSON string for Content
+ const normalizedContent = normalizeNodeData(nodeData?.text ?? []);
+
+ // Start editing with normalized JSON
+ const handleEdit = () => {
+ startEditingNode(nodeData.id, null, normalizedContent);
+ };
+
+ // Save and immediately exit edit mode, so modal updates with new JSON
+ const handleSave = () => {
+ saveEditingNodeValue();
+ // Wait for zustand update, then exit edit mode (triggers re-render with new selectedNode)
+ setTimeout(() => cancelEditingNode(), 0);
+ };
return (
@@ -40,13 +66,41 @@ export const NodeModal = ({ opened, onClose }: ModalProps) => {
-
+ {isEditing ? (
+ <>
+
diff --git a/src/store/useFile.ts b/src/store/useFile.ts
index 21e2a230bc4..da546cf99b5 100644
--- a/src/store/useFile.ts
+++ b/src/store/useFile.ts
@@ -100,26 +100,32 @@ const useFile = create()((set, get) => ({
}
},
setContents: async ({ contents, hasChanges = true, skipUpdate = false, format }) => {
- try {
- set({
- ...(contents && { contents }),
- error: null,
- hasChanges,
- format: format ?? get().format,
- });
+ try {
+ // 1) Always update the editor contents + local state
+ set({
+ ...(contents && { contents }),
+ error: null,
+ hasChanges,
+ format: format ?? get().format,
+ });
+
+ // 2) If this update is coming FROM useJson (graph edit), don't loop back
+ if (skipUpdate) {
+ return;
+ }
- const isFetchURL = window.location.href.includes("?");
- const json = await contentToJson(get().contents, get().format);
+ const isFetchURL = window.location.href.includes("?");
+ const json = await contentToJson(get().contents, get().format);
- if (!useConfig.getState().liveTransformEnabled && skipUpdate) return;
+ if (!useConfig.getState().liveTransformEnabled && skipUpdate) return;
- if (get().hasChanges && contents && contents.length < 80_000 && !isIframe() && !isFetchURL) {
- sessionStorage.setItem("content", contents);
- sessionStorage.setItem("format", get().format);
- set({ hasChanges: true });
- }
+ if (get().hasChanges && contents && contents.length < 80_000 && !isIframe() && !isFetchURL) {
+ sessionStorage.setItem("content", contents);
+ sessionStorage.setItem("format", get().format);
+ set({ hasChanges: true });
+ }
- debouncedUpdateJson(json);
+ debouncedUpdateJson(json);
} catch (error: any) {
if (error?.mark?.snippet) return set({ error: error.mark.snippet });
if (error?.message) set({ error: error.message });
diff --git a/src/store/useJson.ts b/src/store/useJson.ts
index 62512c79743..fe11feba032 100644
--- a/src/store/useJson.ts
+++ b/src/store/useJson.ts
@@ -1,5 +1,7 @@
import { create } from "zustand";
import useGraph from "../features/editor/views/GraphView/stores/useGraph";
+import useFile from "./useFile";
+
interface JsonActions {
setJson: (json: string) => void;
@@ -17,14 +19,36 @@ export type JsonStates = typeof initialStates;
const useJson = create()((set, get) => ({
...initialStates,
getJson: () => get().json,
+
setJson: json => {
+ // 1) store JSON string here
set({ json, loading: false });
+
+ // 2) update graph from this JSON
useGraph.getState().setGraph(json);
+
+ // 3) update left editor text without re-triggering JSON->graph loop
+ const { setContents } = useFile.getState();
+ setContents({
+ contents: json,
+ hasChanges: false, // this is just “clean” state from graph edit
+ skipUpdate: true, // ⬅️ important: don't call debouncedUpdateJson
+ });
},
+
clear: () => {
set({ json: "", loading: false });
useGraph.getState().clearGraph();
+
+ // also clear editor, but again, don't loop back
+ const { setContents } = useFile.getState();
+ setContents({
+ contents: "",
+ hasChanges: false,
+ skipUpdate: true,
+ });
},
}));
+
export default useJson;