diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10f909157b4..d9983fd7266 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1060,8 +1060,8 @@ packages: camelize@1.0.1: resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==} - caniuse-lite@1.0.30001718: - resolution: {integrity: sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==} + caniuse-lite@1.0.30001754: + resolution: {integrity: sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==} chalk@3.0.0: resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} @@ -4009,7 +4009,7 @@ snapshots: camelize@1.0.1: {} - caniuse-lite@1.0.30001718: {} + caniuse-lite@1.0.30001754: {} chalk@3.0.0: dependencies: @@ -4319,7 +4319,7 @@ snapshots: eslint: 8.56.0 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.32.1(eslint@8.56.0)(typescript@5.8.2))(eslint@8.56.0))(eslint@8.56.0) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.32.1(eslint@8.56.0)(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.56.0) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.32.1(eslint@8.56.0)(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.32.1(eslint@8.56.0)(typescript@5.8.2))(eslint@8.56.0))(eslint@8.56.0))(eslint@8.56.0) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.56.0) eslint-plugin-react: 7.37.5(eslint@8.56.0) eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.56.0) @@ -4353,7 +4353,7 @@ snapshots: tinyglobby: 0.2.13 unrs-resolver: 1.7.2 optionalDependencies: - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.32.1(eslint@8.56.0)(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.56.0) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.32.1(eslint@8.56.0)(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.32.1(eslint@8.56.0)(typescript@5.8.2))(eslint@8.56.0))(eslint@8.56.0))(eslint@8.56.0) transitivePeerDependencies: - supports-color @@ -4368,7 +4368,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.32.1(eslint@8.56.0)(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.56.0): + eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.32.1(eslint@8.56.0)(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.32.1(eslint@8.56.0)(typescript@5.8.2))(eslint@8.56.0))(eslint@8.56.0))(eslint@8.56.0): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.8 @@ -5232,7 +5232,7 @@ snapshots: '@next/env': 14.2.28 '@swc/helpers': 0.5.5 busboy: 1.6.0 - caniuse-lite: 1.0.30001718 + caniuse-lite: 1.0.30001754 graceful-fs: 4.2.11 postcss: 8.4.31 react: 18.3.1 diff --git a/src/features/editor/views/GraphView/CustomNode/ObjectNode.tsx b/src/features/editor/views/GraphView/CustomNode/ObjectNode.tsx index bd87e928f01..9beba2e0fb8 100644 --- a/src/features/editor/views/GraphView/CustomNode/ObjectNode.tsx +++ b/src/features/editor/views/GraphView/CustomNode/ObjectNode.tsx @@ -4,15 +4,21 @@ 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 useJson from "../../../../../store/useJson"; +import { useNodeEdit } from "../../../../../store/useNodeEdit"; +import updateJsonStyles from "../../../../../lib/utils/json/updateJsonStyles"; +import styled from "styled-components"; type RowProps = { row: NodeData["text"][number]; x: number; y: number; index: number; + styles?: { displayName?: string } | null; }; -const Row = ({ row, x, y, index }: RowProps) => { +const Row = ({ row, x, y, index, styles }: RowProps) => { const rowPosition = index * NODE_DIMENSIONS.ROW_HEIGHT; const getRowText = () => { @@ -21,6 +27,9 @@ const Row = ({ row, x, y, index }: RowProps) => { return row.value; }; + // if this row is the "name" key and styles.displayName exists, show the displayName instead of original value + const valueToRender = row.key === "name" && styles?.displayName ? styles.displayName : getRowText(); + return ( { data-y={y + rowPosition} > {row.key}: - {getRowText()} + {valueToRender} ); }; -const Node = ({ node, x, y }: CustomNodeProps) => ( - - {node.text.map((row, index) => ( - - ))} - -); +const EditControlsWrapper = styled.div` + position: relative; + width: 100%; + height: 100%; + /* default click-through so node selection works by clicking anywhere */ + pointer-events: none; +`; + +const EditButton = styled.button` + position: absolute; + right: 6px; + top: 6px; + background: transparent; + border: 1px solid rgba(0,0,0,0.08); + padding: 2px 6px; + font-size: 11px; + cursor: pointer; + pointer-events: all; + z-index: 20; +`; + +const EditForm = styled.div` + position: absolute; + right: 6px; + top: 28px; + background: white; + border: 1px solid #ddd; + padding: 8px; + z-index: 30; + display: flex; + flex-direction: column; + gap: 6px; + pointer-events: all; +`; + +const Node = ({ node, x, y }: CustomNodeProps) => { + const selectedNode = useGraph(state => state.selectedNode); + const isSelected = selectedNode?.id === node.id; + + const json = useJson(state => state.json); + const setJson = useJson(state => state.setJson); + + const { open, editingNodeId, draft, start, updateDraft, reset, suppressInline } = useNodeEdit(); + + const handleEditClick = (e: React.MouseEvent) => { + e.stopPropagation(); + // pick first row as default name and coerce to string + const defaultName = node.text?.[0]?.value ?? ""; + start(node.id, { name: String(defaultName), color: "#4C6EF5" }); + }; + + const handleSave = (e: React.MouseEvent) => { + e.stopPropagation(); + const next = updateJsonStyles(json, node.id, { displayName: draft.name, color: draft.color }); + setJson(next); + reset(); + }; + + const handleCancel = (e?: React.MouseEvent) => { + e?.stopPropagation(); + reset(); + }; + + return ( + + + {(() => { + try { + const parsed = JSON.parse(useJson.getState().json || "{}"); + const styles = parsed?._styles?.[node.id] ?? null; + return node.text.map((row, index) => ( + + )); + } catch (e) { + return node.text.map((row, index) => ( + + )); + } + })()} + + {/* Inline edit UI removed — editing should happen only via the modal */} + + + ); +}; function propsAreEqual(prev: CustomNodeProps, next: CustomNodeProps) { return ( diff --git a/src/features/editor/views/GraphView/CustomNode/TextNode.tsx b/src/features/editor/views/GraphView/CustomNode/TextNode.tsx index 718ced9d989..ee8fced73a0 100644 --- a/src/features/editor/views/GraphView/CustomNode/TextNode.tsx +++ b/src/features/editor/views/GraphView/CustomNode/TextNode.tsx @@ -5,6 +5,10 @@ import useConfig from "../../../../../store/useConfig"; import { isContentImage } from "../lib/utils/calculateNodeSize"; import { TextRenderer } from "./TextRenderer"; import * as Styled from "./styles"; +import useGraph from "../stores/useGraph"; +import useJson from "../../../../../store/useJson"; +import { useNodeEdit } from "../../../../../store/useNodeEdit"; +import updateJsonStyles from "../../../../../lib/utils/json/updateJsonStyles"; const StyledTextNodeWrapper = styled.span<{ $isParent: boolean }>` display: flex; @@ -26,11 +30,79 @@ const StyledImage = styled.img` background: ${({ theme }) => theme.BACKGROUND_MODIFIER_ACCENT}; `; +const EditControlsWrapper = styled.div` + position: relative; + width: 100%; + height: 100%; + /* keep node content click-through by default; only interactive children opt-in */ + pointer-events: none; +`; + +const EditButton = styled.button` + position: absolute; + right: 6px; + top: 6px; + background: transparent; + border: 1px solid rgba(0,0,0,0.08); + padding: 2px 6px; + font-size: 11px; + cursor: pointer; + pointer-events: all; /* allow clicking the button */ + z-index: 20; +`; + +const EditForm = styled.div` + position: absolute; + right: 6px; + top: 28px; + background: white; + border: 1px solid #ddd; + padding: 8px; + z-index: 30; + display: flex; + flex-direction: column; + gap: 6px; + pointer-events: all; /* make the form interactive */ +`; + const Node = ({ node, x, y }: CustomNodeProps) => { const { text, width, height } = node; const imagePreviewEnabled = useConfig(state => state.imagePreviewEnabled); const isImage = imagePreviewEnabled && isContentImage(JSON.stringify(text[0].value)); const value = text[0].value; + const json = useJson(state => state.json); + const setJson = useJson(state => state.setJson); + + // read styles for displayName + let displayName: string | undefined; + try { + const parsed = JSON.parse(json ?? "{}"); + const styles = parsed?._styles?.[node.id]; + if (styles && styles.displayName) displayName = String(styles.displayName); + } catch (e) { + displayName = undefined; + } + const selectedNode = useGraph(state => state.selectedNode); + const isSelected = selectedNode?.id === node.id; + + const { open, editingNodeId, draft, start, updateDraft, reset, suppressInline } = useNodeEdit(); + + const handleEditClick = (e: React.MouseEvent) => { + e.stopPropagation(); + start(node.id, { name: String(value ?? ""), color: "#4C6EF5" }); + }; + + const handleSave = (e: React.MouseEvent) => { + e.stopPropagation(); + const next = updateJsonStyles(json, node.id, { displayName: draft.name, color: draft.color }); + setJson(next); + reset(); + }; + + const handleCancel = (e?: React.MouseEvent) => { + e?.stopPropagation(); + reset(); + }; return ( { ) : ( - - - {value} - - + + + + {displayName ?? value} + + + + {/* Inline edit UI intentionally removed — editing happens only via the modal */} + )} ); diff --git a/src/features/editor/views/GraphView/CustomNode/index.tsx b/src/features/editor/views/GraphView/CustomNode/index.tsx index ea3ac6be981..03557eaec86 100644 --- a/src/features/editor/views/GraphView/CustomNode/index.tsx +++ b/src/features/editor/views/GraphView/CustomNode/index.tsx @@ -2,6 +2,7 @@ import React from "react"; import { useComputedColorScheme } from "@mantine/core"; import type { NodeProps } from "reaflow"; import { Node } from "reaflow"; +import useJson from "../../../../../store/useJson"; import { useModal } from "../../../../../store/useModal"; import type { NodeData } from "../../../../../types/graph"; import useGraph from "../stores/useGraph"; @@ -41,13 +42,24 @@ const CustomNodeWrapper = (nodeProps: NodeProps) => { ev.currentTarget.style.stroke = colorScheme === "dark" ? "#424242" : "#BCBEC0"; }} style={{ - fill: colorScheme === "dark" ? "#292929" : "#ffffff", + fill: (() => { + try { + const json = useJson.getState().json; + const parsed = JSON.parse(json || "{}"); + const styles = parsed?._styles?.[nodeProps.properties.id]; + if (styles && styles.color) return styles.color; + } catch (e) { + // ignore + } + return colorScheme === "dark" ? "#292929" : "#ffffff"; + })(), stroke: colorScheme === "dark" ? "#424242" : "#BCBEC0", strokeWidth: 1, }} > {({ node, x, y }) => { - const hasKey = nodeProps.properties.text[0].key; + // Guard against accessing text[0] when it doesn't exist + const hasKey = nodeProps.properties.text?.[0]?.key; if (!hasKey) return ; return ; diff --git a/src/features/editor/views/GraphView/lib/jsonParser.ts b/src/features/editor/views/GraphView/lib/jsonParser.ts index 8c397983fed..64b30bb0092 100644 --- a/src/features/editor/views/GraphView/lib/jsonParser.ts +++ b/src/features/editor/views/GraphView/lib/jsonParser.ts @@ -65,6 +65,8 @@ export const parser = (json: string): Graph => { if (!child.children || !child.children[1]) return traverse(child, id); const key = child.children[0].value ?? null; + // ignore internal sidecar used for presentation (styles) so it doesn't become graph nodes + if (key === "_styles") return; const valueNode = child.children[1]; const type = valueNode.type; diff --git a/src/features/editor/views/GraphView/stores/useGraph.ts b/src/features/editor/views/GraphView/stores/useGraph.ts index 6e067c3c2a7..b462c0129bd 100644 --- a/src/features/editor/views/GraphView/stores/useGraph.ts +++ b/src/features/editor/views/GraphView/stores/useGraph.ts @@ -60,9 +60,14 @@ const useGraph = create((set, get) => ({ }); } + // refresh selected node reference so modal / inline UI sees latest data + const prevSelected = get().selectedNode; + const refreshedSelected = prevSelected ? nodes.find(n => n.id === prevSelected.id) ?? null : null; + set({ nodes, edges, + selectedNode: refreshedSelected, aboveSupportedLimit: false, ...options, }); diff --git a/src/features/modals/NodeModal/index.tsx b/src/features/modals/NodeModal/index.tsx index caba85febac..aaf3c643c12 100644 --- a/src/features/modals/NodeModal/index.tsx +++ b/src/features/modals/NodeModal/index.tsx @@ -1,9 +1,16 @@ 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 } 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 useNodeEdit from "../../../store/useNodeEdit"; +import { useEffect } from "react"; +import updateJsonStyles from "../../../lib/utils/json/updateJsonStyles"; +import updateJsonValue from "../../../lib/utils/json/updateJsonValue"; +import { useState } from "react"; // return object from json removing array and object fields const normalizeNodeData = (nodeRows: NodeData["text"]) => { @@ -28,7 +35,18 @@ const jsonPathToString = (path?: NodeData["path"]) => { export const NodeModal = ({ opened, onClose }: ModalProps) => { const nodeData = useGraph(state => state.selectedNode); + const { open, editingNodeId, draft, start, updateDraft, reset } = useNodeEdit(); + const json = useJson(state => state.json); + const setJson = useJson(state => state.setJson); + const setContents = useFile(state => state.setContents); + const [saving, setSaving] = useState(false); + useEffect(() => { + if (!opened) { + // clear any edit state when the modal closes so inline controls don't linger + reset(); + } + }, [opened, reset]); return ( @@ -37,16 +55,142 @@ export const NodeModal = ({ opened, onClose }: ModalProps) => { Content - + + {!(open && editingNodeId === nodeData?.id) && ( + + )} + + - + {/* if the edit UI is active for this node, render a small inline editor here */} + {open && editingNodeId === nodeData?.id ? ( +
e.stopPropagation()}> + updateDraft({ name: e.currentTarget.value })} + placeholder="Name" + style={{ padding: 8, fontSize: 13 }} + /> +
+ updateDraft({ color: e.currentTarget.value })} + aria-label="Color" + /> + updateDraft({ color: e.currentTarget.value })} + style={{ padding: 8, fontSize: 13, width: 120 }} + /> +
+
+ + +
+
+ ) : ( + + )}
@@ -67,3 +211,7 @@ export const NodeModal = ({ opened, onClose }: ModalProps) => {
); }; + +// ensure edit state is cleared whenever the modal closes to avoid leftover inline controls +// (we purposely don't clear when modal opens so the edit can be started) +export default NodeModal; diff --git a/src/lib/utils/json/updateJsonStyles.ts b/src/lib/utils/json/updateJsonStyles.ts new file mode 100644 index 00000000000..bb82c4eba86 --- /dev/null +++ b/src/lib/utils/json/updateJsonStyles.ts @@ -0,0 +1,54 @@ +import { applyEdits, modify } from "jsonc-parser"; + +const FORMATTING = { formattingOptions: { tabSize: 2, insertSpaces: true } } as any; + +type Patch = { displayName?: string; color?: string }; + +/** + * Upsert _styles[nodeId] with provided fields and timestamps. + * Keeps createdAt if it already exists. + */ +export function updateJsonStyles(jsonStr: string, nodeId: string, patch: Patch): string { + let text = jsonStr ?? "{}"; + + // Best-effort parse to detect whether _styles/nodeId exists to preserve createdAt + let exists = false; + try { + const parsed = JSON.parse(text); + exists = !!(parsed && parsed._styles && Object.prototype.hasOwnProperty.call(parsed._styles, nodeId)); + } catch (err) { + // fall back to modifying directly if parse fails + exists = false; + } + + // 1) Ensure _styles exists as an object + let edits = modify(text, ["_styles"], {}, FORMATTING); + if (edits.length) text = applyEdits(text, edits); + + const now = new Date().toISOString(); + + if (!exists) { + // Insert whole entry including createdAt + const newEntry = { + ...(patch.displayName ? { displayName: patch.displayName } : {}), + ...(patch.color ? { color: patch.color } : {}), + createdAt: now, + updatedAt: now, + } as any; + + const insertEdits = modify(text, ["_styles", nodeId], newEntry, FORMATTING); + if (insertEdits.length) return applyEdits(text, insertEdits); + } + + // If exists, update fields individually and updatedAt + const updates: Record = { ...(patch.displayName !== undefined ? { displayName: patch.displayName } : {}), ...(patch.color !== undefined ? { color: patch.color } : {}), updatedAt: now }; + let after = text; + for (const [k, v] of Object.entries(updates)) { + const fieldEdits = modify(after, ["_styles", nodeId, k], v, FORMATTING); + if (fieldEdits.length) after = applyEdits(after, fieldEdits); + } + + return after; +} + +export default updateJsonStyles; diff --git a/src/lib/utils/json/updateJsonValue.ts b/src/lib/utils/json/updateJsonValue.ts new file mode 100644 index 00000000000..7eac8488cd1 --- /dev/null +++ b/src/lib/utils/json/updateJsonValue.ts @@ -0,0 +1,43 @@ +import { applyEdits, modify } from "jsonc-parser"; +import type { JSONPath } from "jsonc-parser"; + +const FORMATTING = { formattingOptions: { tabSize: 2, insertSpaces: true } } as any; + +/** + * Updates the actual JSON value at the given path. + * For object nodes, updates the "name" field if it exists, otherwise updates the value directly. + * For text nodes (leaf values), updates the value at the path. + */ +export function updateJsonValue( + jsonStr: string, + path: JSONPath | undefined, + newValue: string | number | null, + fieldKey?: string | null +): string { + if (!path || path.length === 0) { + // Root level - can't update root directly + return jsonStr; + } + + let text = jsonStr ?? "{}"; + + // If fieldKey is provided (e.g., "name"), update that specific field in the object + if (fieldKey) { + const fieldPath = [...path, fieldKey]; + const edits = modify(text, fieldPath, newValue, FORMATTING); + if (edits.length) { + return applyEdits(text, edits); + } + } else { + // Update the value directly at the path + const edits = modify(text, path, newValue, FORMATTING); + if (edits.length) { + return applyEdits(text, edits); + } + } + + return text; +} + +export default updateJsonValue; + diff --git a/src/store/useNodeEdit.ts b/src/store/useNodeEdit.ts new file mode 100644 index 00000000000..81a353faf53 --- /dev/null +++ b/src/store/useNodeEdit.ts @@ -0,0 +1,32 @@ +import { create } from "zustand"; + +type NodeDraft = { + name: string; + color: string; +}; + +type EditState = { + open: boolean; + editingNodeId: string | null; + // when true, the inline editor inside nodes should be suppressed (use modal editor instead) + suppressInline: boolean; + draft: NodeDraft; + start: (nodeId: string, initial?: Partial, options?: { suppressInline?: boolean }) => void; + updateDraft: (patch: Partial) => void; + reset: () => void; +}; + +const DEFAULT_COLOR = "#4C6EF5"; + +export const useNodeEdit = create((set) => ({ + open: false, + editingNodeId: null, + suppressInline: false, + draft: { name: "", color: DEFAULT_COLOR }, + start: (nodeId, initial = {}, options = {}) => + set({ open: true, editingNodeId: nodeId, suppressInline: !!options.suppressInline, draft: { name: initial.name ?? "", color: initial.color ?? DEFAULT_COLOR } }), + updateDraft: (patch) => set((s) => ({ draft: { ...s.draft, ...patch } })), + reset: () => set({ open: false, editingNodeId: null, suppressInline: false, draft: { name: "", color: DEFAULT_COLOR } }), +})); + +export default useNodeEdit;