Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -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<HTMLElement | null>(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<HTMLElement>) => {
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 (
<Component
ref={elementRef as React.RefObject<HTMLElement>}
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}
/>
);
},
);
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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<HTMLDivElement>) => {
const newValue = e.currentTarget.textContent || '';
setCurrentValue(newValue);
}, []);

const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
setCurrentValue(value);
elementRef.current?.blur();
}
},
[value],
);

return (
<div
ref={elementRef}
contentEditable
suppressContentEditableWarning
onFocus={handleFocus}
onBlur={handleBlur}
onInput={handleInput}
onKeyDown={handleKeyDown}
title={isTruncated ? fullText : undefined}
className={`${className} ${showPlaceholder ? 'placeholder' : ''}`}
style={{
outline: 'none',
cursor: 'text',
minHeight: isEditing ? '3em' : '1.5em',
maxHeight: isEditing ? 'none' : '1.5em',
overflow: isEditing ? 'auto' : 'hidden',
padding: 'var(--component-spacing-sm)',
border: '1px solid var(--color-neutral-border-default)',
borderRadius: 'var(--border-radius-md)',
backgroundColor: 'var(--color-neutral-canvas-default)',
whiteSpace: isEditing ? 'pre-wrap' : 'nowrap',
wordWrap: 'break-word',
textOverflow: isEditing ? 'clip' : 'ellipsis',
transition: 'all 0.2s ease-in-out',
...style,
...(showPlaceholder && {
color: 'var(--color-neutral-canvas-default-fg-muted)',
fontStyle: 'italic',
}),
...(isTruncated && {
borderColor: 'var(--color-neutral-border-subtle)',
backgroundColor: 'var(--color-neutral-canvas-subtle)',
}),
}}
children={undefined}
/>
);
},
);
62 changes: 62 additions & 0 deletions packages/graph-editor/src/components/panels/node/README.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading