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
90 changes: 74 additions & 16 deletions src/features/editor/views/GraphView/CustomNode/ObjectNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand All @@ -29,25 +42,70 @@ const Row = ({ row, x, y, index }: RowProps) => {
data-y={y + rowPosition}
>
<Styled.StyledKey $type="object">{row.key}: </Styled.StyledKey>
<TextRenderer>{getRowText()}</TextRenderer>
{isEditing ? (
<Group gap="xs">
<input
value={editingValue ?? ""}
onChange={e => onChange(e.target.value)}
style={{ minWidth: 80 }}
/>
<Button size="xs" onClick={onSave}>Save</Button>
<Button size="xs" variant="light" onClick={onCancel}>Cancel</Button>
</Group>
) : (
<>
<TextRenderer>{getRowText()}</TextRenderer>
{(row.type !== "object" && row.type !== "array") && (
<Button size="xs" ml={8} onClick={onEdit}>Edit</Button>
)}
</>
)}
</Styled.StyledRow>
);
};

const Node = ({ node, x, y }: CustomNodeProps) => (
<Styled.StyledForeignObject
data-id={`node-${node.id}`}
width={node.width}
height={node.height}
x={0}
y={0}
$isObject
>
{node.text.map((row, index) => (
<Row key={`${node.id}-${index}`} row={row} x={x} y={y} index={index} />
))}
</Styled.StyledForeignObject>
);
const Node = ({ node, x, y }: CustomNodeProps) => {
const {
editingNodeId,
editingRowIndex,
editingValue,
startEditingNode,
cancelEditingNode,
setEditingValue,
saveEditingNodeValue,
} = useGraph();

return (
<Styled.StyledForeignObject
data-id={`node-${node.id}`}
width={node.width}
height={node.height}
x={0}
y={0}
$isObject
>
{node.text.map((row, index) => {
const isEditing = editingNodeId === node.id && editingRowIndex === index;
return (
<Row
key={`${node.id}-${index}`}
row={row}
x={x}
y={y}
index={index}
nodeId={node.id}
isEditing={isEditing}
editingValue={isEditing ? editingValue : null}
onEdit={() => startEditingNode(node.id, index, String(row.value ?? ""))}
onCancel={cancelEditingNode}
onSave={saveEditingNodeValue}
onChange={setEditingValue}
/>
);
})}
</Styled.StyledForeignObject>
);
};

function propsAreEqual(prev: CustomNodeProps, next: CustomNodeProps) {
return (
Expand Down
152 changes: 152 additions & 0 deletions src/features/editor/views/GraphView/stores/useGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -28,6 +31,9 @@ const initialStates: Graph = {
selectedNode: null,
path: "",
aboveSupportedLimit: false,
editingNodeId: null,
editingRowIndex: null,
editingValue: null,
};

interface GraphActions {
Expand All @@ -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<Graph & GraphActions>((set, get) => ({
...initialStates,
Expand Down Expand Up @@ -101,6 +155,104 @@ const useGraph = create<Graph & GraphActions>((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;

70 changes: 62 additions & 8 deletions src/features/modals/NodeModal/index.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 (
<Modal size="auto" opened={opened} onClose={onClose} centered withCloseButton={false}>
Expand All @@ -40,13 +66,41 @@ export const NodeModal = ({ opened, onClose }: ModalProps) => {
<CloseButton onClick={onClose} />
</Flex>
<ScrollArea.Autosize mah={250} maw={600}>
<CodeHighlight
code={normalizeNodeData(nodeData?.text ?? [])}
miw={350}
maw={600}
language="json"
withCopyButton
/>
{isEditing ? (
<>
<textarea
value={editingValue ?? ""}
onChange={e => setEditingValue(e.target.value)}
style={{
width: "100%",
minHeight: 150,
fontFamily: "monospace",
fontSize: 14,
}}
/>
<Group gap="xs" mt={8}>
<Button size="xs" onClick={handleSave}>Save</Button>
<Button size="xs" variant="light" onClick={cancelEditingNode}>Cancel</Button>
</Group>
</>
) : (
<>
<CodeHighlight
code={normalizedContent}
miw={350}
maw={600}
language="json"
withCopyButton
/>
<Button
size="xs"
mt={8}
onClick={handleEdit}
>
Edit
</Button>
</>
)}
</ScrollArea.Autosize>
</Stack>
<Text fz="xs" fw={500}>
Expand Down
Loading