From b07c2357e3021b35d9f41c4befdd499fffa178f7 Mon Sep 17 00:00:00 2001 From: Marco Christian Krenn Date: Fri, 23 May 2025 23:34:52 +0200 Subject: [PATCH] add single node panel --- .../panels/node/ContentEditableText.tsx | 106 +++++++++ .../panels/node/ContentEditableTextarea.tsx | 124 +++++++++++ .../src/components/panels/node/README.md | 62 ++++++ .../src/components/panels/node/index.tsx | 202 ++++++++++++++++++ .../src/components/panels/node/test.tsx | 8 + .../components/toolbar/dropdowns/layout.tsx | 3 + .../src/components/toolbar/layoutButtons.tsx | 6 + .../src/editor/layoutController.tsx | 26 ++- packages/ui/src/components/editor/layout.ts | 11 +- 9 files changed, 528 insertions(+), 20 deletions(-) create mode 100644 packages/graph-editor/src/components/panels/node/ContentEditableText.tsx create mode 100644 packages/graph-editor/src/components/panels/node/ContentEditableTextarea.tsx create mode 100644 packages/graph-editor/src/components/panels/node/README.md create mode 100644 packages/graph-editor/src/components/panels/node/index.tsx create mode 100644 packages/graph-editor/src/components/panels/node/test.tsx diff --git a/packages/graph-editor/src/components/panels/node/ContentEditableText.tsx b/packages/graph-editor/src/components/panels/node/ContentEditableText.tsx new file mode 100644 index 000000000..93a4562f8 --- /dev/null +++ b/packages/graph-editor/src/components/panels/node/ContentEditableText.tsx @@ -0,0 +1,106 @@ +import { observer } from 'mobx-react-lite'; +import React, { useCallback, useEffect, useState } from 'react'; + +interface ContentEditableTextProps { + value: string; + placeholder: string; + onSave: (value: string) => void; + className?: string; + style?: React.CSSProperties; + as?: 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; +} + +export const ContentEditableText = observer( + ({ + value, + placeholder, + onSave, + className = '', + style = {}, + as: Component = 'div', + }: ContentEditableTextProps) => { + const [isEditing, setIsEditing] = useState(false); + const [currentValue, setCurrentValue] = useState(value); + const [elementRef, setElementRef] = useState(null); + + useEffect(() => { + setCurrentValue(value); + }, [value]); + + // Set initial content and update when not editing to preserve cursor position + useEffect(() => { + if (elementRef) { + if (!isEditing) { + const displayValue = currentValue || placeholder; + if (elementRef.textContent !== displayValue) { + elementRef.textContent = displayValue; + } + } + } + }, [elementRef, currentValue, placeholder, isEditing]); + + // Set initial content on mount + useEffect(() => { + if (elementRef && !isEditing) { + const displayValue = currentValue || placeholder; + elementRef.textContent = displayValue; + } + }, [elementRef]); + + const handleFocus = useCallback(() => { + setIsEditing(true); + }, []); + + const handleBlur = useCallback(() => { + setIsEditing(false); + if (currentValue !== value) { + onSave(currentValue); + } + }, [currentValue, value, onSave]); + + const handleInput = useCallback((e: React.FormEvent) => { + const newValue = e.currentTarget.textContent || ''; + setCurrentValue(newValue); + }, []); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + elementRef.current?.blur(); + } + if (e.key === 'Escape') { + setCurrentValue(value); + elementRef.current?.blur(); + } + }, + [value], + ); + + const showPlaceholder = !currentValue && !isEditing; + + return ( + } + contentEditable + suppressContentEditableWarning + onFocus={handleFocus} + onBlur={handleBlur} + onInput={handleInput} + onKeyDown={handleKeyDown} + className={`${className} ${showPlaceholder ? 'placeholder' : ''}`} + style={{ + outline: 'none', + cursor: 'text', + minHeight: '1.2em', + ...style, + ...(showPlaceholder && { + color: 'var(--color-neutral-canvas-default-fg-muted)', + fontStyle: 'italic', + }), + }} + children={undefined} + /> + ); + }, +); diff --git a/packages/graph-editor/src/components/panels/node/ContentEditableTextarea.tsx b/packages/graph-editor/src/components/panels/node/ContentEditableTextarea.tsx new file mode 100644 index 000000000..5bc943854 --- /dev/null +++ b/packages/graph-editor/src/components/panels/node/ContentEditableTextarea.tsx @@ -0,0 +1,124 @@ +import { observer } from 'mobx-react-lite'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +interface ContentEditableTextareaProps { + value: string; + placeholder: string; + onSave: (value: string) => void; + className?: string; + style?: React.CSSProperties; +} + +export const ContentEditableTextarea = observer( + ({ + value, + placeholder, + onSave, + className = '', + style = {}, + }: ContentEditableTextareaProps) => { + const [isEditing, setIsEditing] = useState(false); + const [currentValue, setCurrentValue] = useState(value); + const elementRef = useRef(null); + + useEffect(() => { + setCurrentValue(value); + }, [value]); + + const showPlaceholder = !currentValue && !isEditing; + + // Check if content is truncated for styling + const fullText = currentValue || placeholder; + const firstLine = fullText.split('\n')[0]; + const isTruncated = + !isEditing && + !showPlaceholder && + (fullText.includes('\n') || firstLine.length > 100); + + // Set initial content and update when not editing to preserve cursor position + useEffect(() => { + if (elementRef.current && !isEditing) { + const fullText = currentValue || placeholder; + const firstLine = fullText.split('\n')[0]; + const hasMoreLines = fullText.includes('\n') || firstLine.length > 100; + const displayValue = + hasMoreLines && !showPlaceholder + ? `${firstLine.slice(0, 100)}...` + : firstLine; + + if (elementRef.current.textContent !== displayValue) { + elementRef.current.textContent = displayValue; + } + } + }, [currentValue, placeholder, isEditing, showPlaceholder]); + + const handleFocus = useCallback(() => { + setIsEditing(true); + // When entering edit mode, show the full content + if (elementRef.current) { + elementRef.current.textContent = currentValue || ''; + } + }, [currentValue]); + + const handleBlur = useCallback(() => { + setIsEditing(false); + if (currentValue !== value) { + onSave(currentValue); + } + }, [currentValue, value, onSave]); + + const handleInput = useCallback((e: React.FormEvent) => { + const newValue = e.currentTarget.textContent || ''; + setCurrentValue(newValue); + }, []); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + setCurrentValue(value); + elementRef.current?.blur(); + } + }, + [value], + ); + + return ( +
+ ); + }, +); diff --git a/packages/graph-editor/src/components/panels/node/README.md b/packages/graph-editor/src/components/panels/node/README.md new file mode 100644 index 000000000..8015af24d --- /dev/null +++ b/packages/graph-editor/src/components/panels/node/README.md @@ -0,0 +1,62 @@ +# Unified Node Panel + +This directory contains the implementation of the unified Node Panel that consolidates the previously separate Node Settings, Input, and Output panels into a single comprehensive view. + +## Components + +### NodePanel (`index.tsx`) + +The main unified panel component that displays: + +- **Top Section**: Editable title and description using contentEditable +- **Middle Section**: Output ports and their values +- **Bottom Section**: Node information (ID, type, annotations) and input controls + +### ContentEditableText (`ContentEditableText.tsx`) + +A reusable contentEditable component for inline text editing: + +- Supports different HTML elements (div, h1, h2, etc.) +- Handles focus/blur events for immediate saving +- Shows placeholder text when empty +- Supports keyboard shortcuts (Enter to save, Escape to cancel) + +### ContentEditableTextarea (`ContentEditableTextarea.tsx`) + +A reusable contentEditable component for multi-line text editing: + +- Similar to ContentEditableText but optimized for longer text +- Preserves line breaks and formatting +- Styled to look like a textarea + +## Features + +### Inline Editing + +- Title and description are always editable without requiring a separate edit mode +- Changes are saved immediately on blur +- Placeholder text is shown from the node factory when fields are empty + +### Comprehensive View + +- All node information is displayed in a logical top-to-bottom flow +- Preserves all functionality from the original separate panels +- Updates correctly when different nodes are selected + +### Layout Integration + +- Replaces the separate `input` and `outputs` tabs with a single `nodePanel` tab +- Configured in both `layoutController.tsx` and `layout.ts` +- Added to `layoutButtons.tsx` for toolbar integration + +## Usage + +The NodePanel automatically displays when a node is selected and shows: + +1. **Node Title** (editable) - from `ui.title` annotation or factory title +2. **Node Description** (editable) - from `ui.description` annotation or factory description +3. **Output Ports** - read-only display of all output ports and their values +4. **Node Information** - ID, type, and other annotations +5. **Input Controls** - dynamic inputs, specific inputs, and input ports + +When no node is selected, it shows a helpful message to select a node. diff --git a/packages/graph-editor/src/components/panels/node/index.tsx b/packages/graph-editor/src/components/panels/node/index.tsx new file mode 100644 index 000000000..b9c324e68 --- /dev/null +++ b/packages/graph-editor/src/components/panels/node/index.tsx @@ -0,0 +1,202 @@ +import { Heading, Label, Separator, Stack, Text } from '@tokens-studio/ui'; +import React, { useCallback, useMemo } from 'react'; + +import { ContentEditableText } from './ContentEditableText.js'; +import { ContentEditableTextarea } from './ContentEditableTextarea.js'; +import { DynamicInputs } from '../inputs/dynamicInputs.js'; +import { Node } from '@tokens-studio/graph-engine'; +import { PortPanel } from '@/components/portPanel/index.js'; +import { annotatedDynamicInputs } from '@tokens-studio/graph-engine'; +import { currentNode } from '@/redux/selectors/graph.js'; +import { description, editable, title } from '@/annotations/index.js'; +import { inputControls } from '@/redux/selectors/registry.js'; +import { observer } from 'mobx-react-lite'; +import { useGraph } from '@/hooks/useGraph.js'; +import { useSelector } from 'react-redux'; + +export const NodePanel = () => { + const graph = useGraph(); + const nodeID = useSelector(currentNode); + const selectedNode = useMemo(() => graph?.getNode(nodeID), [graph, nodeID]); + + if (!selectedNode) { + return ( +
+ Select a node to view its properties +
+ ); + } + + return ; +}; + +const NodePanelContent = observer( + ({ selectedNode }: { selectedNode: Node }) => { + const inputControlRegistry = useSelector(inputControls); + + const SpecificInput = useMemo(() => { + return inputControlRegistry[selectedNode?.factory?.type]; + }, [inputControlRegistry, selectedNode]); + + const dynamicInputs = Boolean( + selectedNode.annotations[annotatedDynamicInputs] && + selectedNode.annotations[editable] !== false, + ); + + const onTitleSave = useCallback( + (newTitle: string) => { + selectedNode.setAnnotation(title, newTitle); + }, + [selectedNode], + ); + + const onDescriptionSave = useCallback( + (newDescription: string) => { + selectedNode.setAnnotation(description, newDescription); + }, + [selectedNode], + ); + + return ( +
+ + {/* Top Section: Editable Title and Description */} + + + + + + + + + {/* First Section: Input Controls */} + + Inputs + + {dynamicInputs && } + + {SpecificInput && } + + {Object.keys(selectedNode.inputs).length > 0 ? ( + + ) : ( + + No inputs + + )} + + + + + {/* Second Section: Output Ports and Values */} + + Outputs + {Object.keys(selectedNode.outputs).length > 0 ? ( + + ) : ( + + No outputs + + )} + + + + + {/* Third Section: Node Information */} + + Node Information + + + + + {selectedNode.id} + + + + + + {selectedNode.factory.type} + + + {Object.keys(selectedNode.annotations).length > 2 && ( + + +
+ {Object.entries(selectedNode.annotations) + .filter(([key]) => key !== title && key !== description) + .map(([key, value]) => ( + + + {key}: + + + {typeof value === 'string' + ? value + : JSON.stringify(value)} + + + ))} +
+
+ )} +
+
+
+
+ ); + }, +); diff --git a/packages/graph-editor/src/components/panels/node/test.tsx b/packages/graph-editor/src/components/panels/node/test.tsx new file mode 100644 index 000000000..aff91fbad --- /dev/null +++ b/packages/graph-editor/src/components/panels/node/test.tsx @@ -0,0 +1,8 @@ +// Simple test file to verify the NodePanel components work +import { NodePanel } from './index.js'; +import React from 'react'; + +// This is just a basic test to ensure the component can be imported and rendered +export const TestNodePanel = () => { + return ; +}; diff --git a/packages/graph-editor/src/components/toolbar/dropdowns/layout.tsx b/packages/graph-editor/src/components/toolbar/dropdowns/layout.tsx index 4a51af251..8cd4afe97 100644 --- a/packages/graph-editor/src/components/toolbar/dropdowns/layout.tsx +++ b/packages/graph-editor/src/components/toolbar/dropdowns/layout.tsx @@ -59,6 +59,9 @@ export const LayoutDropdown = () => { onClick('outputs')}> Output + onClick('nodePanel')}> + Node Panel + onClick('nodeSettings')}> Node Settings diff --git a/packages/graph-editor/src/components/toolbar/layoutButtons.tsx b/packages/graph-editor/src/components/toolbar/layoutButtons.tsx index 8a7ff794d..ea75b89ff 100644 --- a/packages/graph-editor/src/components/toolbar/layoutButtons.tsx +++ b/packages/graph-editor/src/components/toolbar/layoutButtons.tsx @@ -4,6 +4,7 @@ import { GraphPanel } from '../panels/graph/index.js'; import { Inputsheet } from '../panels/inputs/index.js'; import { Legend } from '../panels/legend/index.js'; import { LogsPanel } from '../panels/logs/index.js'; +import { NodePanel } from '../panels/node/index.js'; import { NodeSettingsPanel } from '../panels/nodeSettings/index.js'; import { OutputSheet } from '../panels/output/index.js'; import { Settings } from '../panels/settings/index.js'; @@ -25,6 +26,11 @@ export const layoutButtons = { title: 'Outputs', content: , }, + nodePanel: { + id: 'nodePanel', + title: 'Node Panel', + content: , + }, nodeSettings: { id: 'nodeSettings', title: 'Node Settings', diff --git a/packages/graph-editor/src/editor/layoutController.tsx b/packages/graph-editor/src/editor/layoutController.tsx index de810e195..9ddcb2680 100644 --- a/packages/graph-editor/src/editor/layoutController.tsx +++ b/packages/graph-editor/src/editor/layoutController.tsx @@ -19,6 +19,7 @@ import { IconButton, Stack, Tooltip } from '@tokens-studio/ui'; import { Inputsheet } from '@/components/panels/inputs/index.js'; import { MAIN_GRAPH_ID } from '@/constants.js'; import { MenuBar, defaultMenuDataFactory } from '@/components/menubar/index.js'; +import { NodePanel } from '@/components/panels/node/index.js'; import { OutputSheet } from '@/components/panels/output/index.js'; import { dockerSelector } from '@/redux/selectors/refs.js'; import { useDispatch } from '@/hooks/useDispatch.js'; @@ -197,18 +198,9 @@ const defaultLayout: LayoutBase = { mode: 'vertical', children: [ { - size: 12, tabs: [ { - id: 'input', - }, - ], - }, - { - size: 12, - tabs: [ - { - id: 'outputs', + id: 'nodePanel', }, ], }, @@ -277,6 +269,20 @@ const layoutLoader = (tab: TabBase, props, ref): TabData => { ), }; + case 'nodePanel': + return { + closable: true, + cached: true, + group: 'popout', + title: 'Node Panel', + ...tab, + content: ( + }> + + + ), + }; + case 'dropPanel': return { group: 'popout', diff --git a/packages/ui/src/components/editor/layout.ts b/packages/ui/src/components/editor/layout.ts index cbeca686f..a746cb393 100644 --- a/packages/ui/src/components/editor/layout.ts +++ b/packages/ui/src/components/editor/layout.ts @@ -52,18 +52,9 @@ export const defaultLayout: LayoutBase = { mode: 'vertical', children: [ { - size: 12, tabs: [ { - id: 'input' - } - ] - }, - { - size: 12, - tabs: [ - { - id: 'outputs' + id: 'nodePanel' } ] }