new code editor

This commit is contained in:
Richard Osborne
2026-01-11 09:48:20 +01:00
parent 7fc49ae3a8
commit 6f08163590
63 changed files with 12074 additions and 74 deletions

View File

@@ -0,0 +1,67 @@
/**
* CodeHistoryButton Styles
*/
.Root {
position: relative;
}
.Button {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
color: var(--theme-color-fg-default);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: var(--theme-color-bg-3);
border-color: var(--theme-color-border-highlight);
color: var(--theme-color-fg-highlight);
}
&:active {
transform: translateY(1px);
}
}
.Icon {
width: 16px;
height: 16px;
opacity: 0.8;
}
.Label {
line-height: 1;
}
.Dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 1000;
min-width: 350px;
max-width: 450px;
background: var(--theme-color-bg-1);
border: 1px solid var(--theme-color-border-default);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
animation: slideDown 0.15s ease;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -0,0 +1,94 @@
/**
* CodeHistoryButton Component
*
* Displays a history button in the code editor toolbar.
* Opens a dropdown showing code snapshots with diffs.
*
* @module code-editor/CodeHistory
*/
import React, { useState, useRef, useEffect } from 'react';
import css from './CodeHistoryButton.module.scss';
import { CodeHistoryDropdown } from './CodeHistoryDropdown';
import type { CodeSnapshot } from './types';
export interface CodeHistoryButtonProps {
/** Node ID to fetch history for */
nodeId: string;
/** Parameter name (e.g., 'code', 'expression') */
parameterName: string;
/** Current code value */
currentCode: string;
/** Callback when user wants to restore a snapshot */
onRestore: (snapshot: CodeSnapshot) => void;
}
/**
* History button with dropdown
*/
export function CodeHistoryButton({ nodeId, parameterName, currentCode, onRestore }: CodeHistoryButtonProps) {
const [isOpen, setIsOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
if (!isOpen) return;
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
buttonRef.current &&
!buttonRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
return (
<div className={css.Root}>
<button
ref={buttonRef}
onClick={() => setIsOpen(!isOpen)}
className={css.Button}
title="View code history"
type="button"
>
<svg className={css.Icon} width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M8 14A6 6 0 1 0 8 2a6 6 0 0 0 0 12z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M8 4v4l2 2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span className={css.Label}>History</span>
</button>
{isOpen && (
<div ref={dropdownRef} className={css.Dropdown}>
<CodeHistoryDropdown
nodeId={nodeId}
parameterName={parameterName}
currentCode={currentCode}
onRestore={(snapshot) => {
onRestore(snapshot);
setIsOpen(false);
}}
onClose={() => setIsOpen(false)}
/>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,326 @@
/**
* CodeHistoryDiffModal Styles
* The KILLER feature - beautiful side-by-side diff comparison
*/
.Overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
animation: fadeIn 0.2s ease;
}
.Modal {
background: var(--theme-color-bg-1);
border: 1px solid var(--theme-color-border-default);
border-radius: 12px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6);
width: 90vw;
max-width: 1200px;
max-height: 90vh;
display: flex;
flex-direction: column;
animation: scaleIn 0.2s ease;
}
.Header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid var(--theme-color-border-default);
}
.Title {
margin: 0;
font-size: 17px;
font-weight: 600;
color: var(--theme-color-fg-default);
}
.CloseButton {
background: none;
border: none;
color: var(--theme-color-fg-default-shy);
font-size: 28px;
line-height: 1;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
transition: all 0.15s ease;
&:hover {
background: var(--theme-color-bg-3);
color: var(--theme-color-fg-default);
}
}
/* Diff Container */
.DiffContainer {
display: flex;
gap: 16px;
padding: 24px;
overflow: hidden;
flex: 1;
min-height: 0;
}
.DiffSide {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
background: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-border-default);
border-radius: 8px;
overflow: hidden;
}
.DiffHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--theme-color-bg-3);
border-bottom: 1px solid var(--theme-color-border-default);
}
.DiffLabel {
font-size: 13px;
font-weight: 600;
color: var(--theme-color-fg-default);
}
.DiffInfo {
display: flex;
gap: 8px;
font-size: 12px;
font-weight: 600;
}
.Additions {
color: #4ade80;
}
.Deletions {
color: #f87171;
}
.Modifications {
color: #fbbf24;
}
.DiffCode {
flex: 1;
overflow-y: auto;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
}
.DiffLine {
display: flex;
padding: 2px 0;
min-height: 21px;
transition: background 0.1s ease;
&:hover {
background: rgba(255, 255, 255, 0.03);
}
}
.LineNumber {
flex-shrink: 0;
width: 50px;
padding: 0 12px;
text-align: right;
color: var(--theme-color-fg-default-shy);
user-select: none;
font-size: 12px;
}
.LineContent {
flex: 1;
padding-right: 12px;
white-space: pre;
overflow-x: auto;
color: var(--theme-color-fg-default);
}
/* Diff line states */
.DiffLineAdded {
background: rgba(74, 222, 128, 0.15);
.LineNumber {
background: rgba(74, 222, 128, 0.2);
color: #4ade80;
}
.LineContent {
color: #d9f99d;
}
}
.DiffLineRemoved {
background: rgba(248, 113, 113, 0.15);
.LineNumber {
background: rgba(248, 113, 113, 0.2);
color: #f87171;
}
.LineContent {
color: #fecaca;
text-decoration: line-through;
opacity: 0.8;
}
}
.DiffLineModified {
background: rgba(251, 191, 36, 0.12);
.LineNumber {
background: rgba(251, 191, 36, 0.2);
color: #fbbf24;
}
.LineContent {
color: #fef3c7;
}
}
.DiffLineEmpty {
background: rgba(255, 255, 255, 0.02);
opacity: 0.3;
.LineNumber {
opacity: 0;
}
.LineContent {
opacity: 0;
}
}
/* Separator */
.DiffSeparator {
display: flex;
align-items: center;
justify-content: center;
color: var(--theme-color-fg-default-shy);
opacity: 0.6;
}
/* Summary */
.Summary {
padding: 16px 24px;
border-top: 1px solid var(--theme-color-border-default);
border-bottom: 1px solid var(--theme-color-border-default);
background: var(--theme-color-bg-2);
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
color: var(--theme-color-fg-default-shy);
}
/* Footer */
.Footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
padding: 20px 24px;
}
.CancelButton {
padding: 10px 20px;
background: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-border-default);
border-radius: 6px;
color: var(--theme-color-fg-default);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: var(--theme-color-bg-3);
border-color: var(--theme-color-border-highlight);
}
}
.RestoreButton {
padding: 10px 20px;
background: var(--theme-color-primary);
border: 1px solid var(--theme-color-primary);
border-radius: 6px;
color: white;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: var(--theme-color-primary-highlight);
border-color: var(--theme-color-primary-highlight);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
&:active {
transform: translateY(0);
}
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Scrollbar styling */
.DiffCode::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.DiffCode::-webkit-scrollbar-track {
background: var(--theme-color-bg-1);
}
.DiffCode::-webkit-scrollbar-thumb {
background: var(--theme-color-border-default);
border-radius: 4px;
&:hover {
background: var(--theme-color-border-highlight);
}
}

View File

@@ -0,0 +1,177 @@
/**
* CodeHistoryDiffModal Component
*
* Shows a side-by-side diff comparison between code versions.
* This is the KILLER feature - beautiful visual diff with restore confirmation.
*
* @module code-editor/CodeHistory
*/
import React, { useMemo } from 'react';
import { computeDiff, getContextualDiff } from '../utils/codeDiff';
import css from './CodeHistoryDiffModal.module.scss';
export interface CodeHistoryDiffModalProps {
oldCode: string;
newCode: string;
timestamp: string;
onRestore: () => void;
onClose: () => void;
}
// Format timestamp
function formatTimestamp(timestamp: string): string {
const now = new Date();
const then = new Date(timestamp);
const diffMs = now.getTime() - then.getTime();
const diffMin = Math.floor(diffMs / 1000 / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffMin < 60) {
return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`;
} else if (diffHour < 24) {
return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago`;
} else if (diffDay === 1) {
return 'yesterday';
} else {
return `${diffDay} days ago`;
}
}
export function CodeHistoryDiffModal({ oldCode, newCode, timestamp, onRestore, onClose }: CodeHistoryDiffModalProps) {
// Compute diff
const diff = useMemo(() => {
const fullDiff = computeDiff(oldCode, newCode);
const contextualLines = getContextualDiff(fullDiff, 3);
return {
full: fullDiff,
lines: contextualLines
};
}, [oldCode, newCode]);
// Split into old and new for side-by-side view
const sideBySide = useMemo(() => {
const oldLines: Array<{ content: string; type: string; lineNumber: number }> = [];
const newLines: Array<{ content: string; type: string; lineNumber: number }> = [];
diff.lines.forEach((line) => {
if (line.type === 'unchanged') {
oldLines.push({ content: line.content, type: 'unchanged', lineNumber: line.lineNumber });
newLines.push({ content: line.content, type: 'unchanged', lineNumber: line.lineNumber });
} else if (line.type === 'removed') {
oldLines.push({ content: line.content, type: 'removed', lineNumber: line.lineNumber });
newLines.push({ content: '', type: 'empty', lineNumber: line.lineNumber });
} else if (line.type === 'added') {
oldLines.push({ content: '', type: 'empty', lineNumber: line.lineNumber });
newLines.push({ content: line.content, type: 'added', lineNumber: line.lineNumber });
} else if (line.type === 'modified') {
oldLines.push({ content: line.oldContent || '', type: 'modified-old', lineNumber: line.lineNumber });
newLines.push({ content: line.newContent || '', type: 'modified-new', lineNumber: line.lineNumber });
}
});
return { oldLines, newLines };
}, [diff.lines]);
return (
<div className={css.Overlay} onClick={onClose}>
<div className={css.Modal} onClick={(e) => e.stopPropagation()}>
<div className={css.Header}>
<h2 className={css.Title}>Restore code from {formatTimestamp(timestamp)}?</h2>
<button onClick={onClose} className={css.CloseButton} type="button" title="Close">
×
</button>
</div>
<div className={css.DiffContainer}>
<div className={css.DiffSide}>
<div className={css.DiffHeader}>
<span className={css.DiffLabel}>{formatTimestamp(timestamp)}</span>
<span className={css.DiffInfo}>
{diff.full.deletions > 0 && <span className={css.Deletions}>-{diff.full.deletions}</span>}
{diff.full.modifications > 0 && <span className={css.Modifications}>~{diff.full.modifications}</span>}
</span>
</div>
<div className={css.DiffCode}>
{sideBySide.oldLines.map((line, index) => (
<div
key={index}
className={`${css.DiffLine} ${
line.type === 'removed'
? css.DiffLineRemoved
: line.type === 'modified-old'
? css.DiffLineModified
: line.type === 'empty'
? css.DiffLineEmpty
: ''
}`}
>
<span className={css.LineNumber}>{line.type !== 'empty' ? line.lineNumber : ''}</span>
<span className={css.LineContent}>{line.content || ' '}</span>
</div>
))}
</div>
</div>
<div className={css.DiffSeparator}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path
d="M5 12h14M13 5l7 7-7 7"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<div className={css.DiffSide}>
<div className={css.DiffHeader}>
<span className={css.DiffLabel}>Current</span>
<span className={css.DiffInfo}>
{diff.full.additions > 0 && <span className={css.Additions}>+{diff.full.additions}</span>}
{diff.full.modifications > 0 && <span className={css.Modifications}>~{diff.full.modifications}</span>}
</span>
</div>
<div className={css.DiffCode}>
{sideBySide.newLines.map((line, index) => (
<div
key={index}
className={`${css.DiffLine} ${
line.type === 'added'
? css.DiffLineAdded
: line.type === 'modified-new'
? css.DiffLineModified
: line.type === 'empty'
? css.DiffLineEmpty
: ''
}`}
>
<span className={css.LineNumber}>{line.type !== 'empty' ? line.lineNumber : ''}</span>
<span className={css.LineContent}>{line.content || ' '}</span>
</div>
))}
</div>
</div>
</div>
<div className={css.Summary}>
{diff.full.additions > 0 && <span> {diff.full.additions} line(s) will be removed</span>}
{diff.full.deletions > 0 && <span> {diff.full.deletions} line(s) will be added</span>}
{diff.full.modifications > 0 && <span> {diff.full.modifications} line(s) will change</span>}
</div>
<div className={css.Footer}>
<button onClick={onClose} className={css.CancelButton} type="button">
Cancel
</button>
<button onClick={onRestore} className={css.RestoreButton} type="button">
Restore Code
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,166 @@
/**
* CodeHistoryDropdown Styles
*/
.Root {
display: flex;
flex-direction: column;
max-height: 500px;
}
.Header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid var(--theme-color-border-default);
}
.Title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--theme-color-fg-default);
}
.CloseButton {
background: none;
border: none;
color: var(--theme-color-fg-default-shy);
font-size: 24px;
line-height: 1;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.15s ease;
&:hover {
background: var(--theme-color-bg-3);
color: var(--theme-color-fg-default);
}
}
.List {
overflow-y: auto;
max-height: 400px;
padding: 8px;
}
.Item {
padding: 12px;
border-radius: 6px;
margin-bottom: 4px;
border: 1px solid transparent;
transition: all 0.15s ease;
&:hover {
background: var(--theme-color-bg-2);
border-color: var(--theme-color-border-default);
}
}
.ItemCurrent {
background: var(--theme-color-primary);
color: white;
opacity: 0.9;
&:hover {
background: var(--theme-color-primary);
opacity: 1;
}
.ItemIcon {
color: white;
}
.ItemTime {
color: white;
font-weight: 600;
}
}
.ItemHeader {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.ItemIcon {
font-size: 16px;
width: 20px;
text-align: center;
color: var(--theme-color-fg-default-shy);
}
.ItemTime {
font-size: 13px;
font-weight: 500;
color: var(--theme-color-fg-default);
}
.ItemSummary {
font-size: 12px;
color: var(--theme-color-fg-default-shy);
margin-left: 28px;
margin-bottom: 8px;
}
.ItemActions {
display: flex;
gap: 8px;
margin-left: 28px;
}
.PreviewButton {
padding: 4px 12px;
background: var(--theme-color-bg-3);
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
color: var(--theme-color-fg-default);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: var(--theme-color-primary);
border-color: var(--theme-color-primary);
color: white;
}
}
/* Empty state */
.Empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
text-align: center;
}
.EmptyIcon {
color: var(--theme-color-fg-default-shy);
opacity: 0.5;
margin-bottom: 16px;
}
.EmptyText {
margin: 0 0 8px 0;
font-size: 15px;
font-weight: 600;
color: var(--theme-color-fg-default);
}
.EmptyHint {
margin: 0;
font-size: 13px;
color: var(--theme-color-fg-default-shy);
max-width: 280px;
}

View File

@@ -0,0 +1,172 @@
/**
* CodeHistoryDropdown Component
*
* Shows a list of code snapshots with preview and restore functionality.
*
* @module code-editor/CodeHistory
*/
import React, { useState, useMemo } from 'react';
import { computeDiff, getDiffSummary } from '../utils/codeDiff';
import { CodeHistoryDiffModal } from './CodeHistoryDiffModal';
import css from './CodeHistoryDropdown.module.scss';
import type { CodeSnapshot } from './types';
export interface CodeHistoryDropdownProps {
nodeId: string;
parameterName: string;
currentCode: string;
onRestore: (snapshot: CodeSnapshot) => void;
onClose: () => void;
}
// Format timestamp to human-readable format
function formatTimestamp(timestamp: string): string {
const now = new Date();
const then = new Date(timestamp);
const diffMs = now.getTime() - then.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) {
return 'just now';
} else if (diffMin < 60) {
return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`;
} else if (diffHour < 24) {
return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago`;
} else if (diffDay === 1) {
return 'yesterday at ' + then.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (diffDay < 7) {
return `${diffDay} days ago`;
} else {
return then.toLocaleDateString() + ' at ' + then.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
}
export function CodeHistoryDropdown({
nodeId,
parameterName,
currentCode,
onRestore,
onClose
}: CodeHistoryDropdownProps) {
const [selectedSnapshot, setSelectedSnapshot] = useState<CodeSnapshot | null>(null);
const [history, setHistory] = useState<CodeSnapshot[]>([]);
// Load history on mount
React.useEffect(() => {
// Dynamically import CodeHistoryManager to avoid circular dependencies
// This allows noodl-core-ui to access noodl-editor functionality
import('@noodl-models/CodeHistoryManager')
.then(({ CodeHistoryManager }) => {
const historyData = CodeHistoryManager.instance.getHistory(nodeId, parameterName);
setHistory(historyData);
})
.catch((error) => {
console.warn('Could not load CodeHistoryManager:', error);
setHistory([]);
});
}, [nodeId, parameterName]);
// Compute diffs for all snapshots (newest first)
const snapshotsWithDiffs = useMemo(() => {
return history
.slice() // Don't mutate original
.reverse() // Newest first
.map((snapshot) => {
const diff = computeDiff(snapshot.code, currentCode);
const summary = getDiffSummary(diff);
return {
snapshot,
diff,
summary
};
});
}, [history, currentCode]);
if (history.length === 0) {
return (
<div className={css.Root}>
<div className={css.Header}>
<h3 className={css.Title}>Code History</h3>
<button onClick={onClose} className={css.CloseButton} type="button" title="Close">
×
</button>
</div>
<div className={css.Empty}>
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" className={css.EmptyIcon}>
<path
d="M24 42c9.941 0 18-8.059 18-18S33.941 6 24 6 6 14.059 6 24s8.059 18 18 18z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M24 14v12l6 6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<p className={css.EmptyText}>No history yet</p>
<p className={css.EmptyHint}>Code snapshots are saved automatically when you save changes.</p>
</div>
</div>
);
}
return (
<>
<div className={css.Root}>
<div className={css.Header}>
<h3 className={css.Title}>Code History</h3>
<button onClick={onClose} className={css.CloseButton} type="button" title="Close">
×
</button>
</div>
<div className={css.List}>
{/* Historical snapshots (newest first) */}
{snapshotsWithDiffs.map(({ snapshot, diff, summary }, index) => (
<div key={snapshot.timestamp} className={css.Item}>
<div className={css.ItemHeader}>
<span className={css.ItemIcon}></span>
<span className={css.ItemTime}>{formatTimestamp(snapshot.timestamp)}</span>
</div>
<div className={css.ItemSummary}>{summary.description}</div>
<div className={css.ItemActions}>
<button
onClick={() => setSelectedSnapshot(snapshot)}
className={css.PreviewButton}
type="button"
title="Preview changes"
>
Preview
</button>
</div>
</div>
))}
</div>
</div>
{/* Diff Modal */}
{selectedSnapshot && (
<CodeHistoryDiffModal
oldCode={selectedSnapshot.code}
newCode={currentCode}
timestamp={selectedSnapshot.timestamp}
onRestore={() => {
onRestore(selectedSnapshot);
setSelectedSnapshot(null);
}}
onClose={() => setSelectedSnapshot(null)}
/>
)}
</>
);
}

View File

@@ -0,0 +1,12 @@
/**
* Code History Components
*
* Exports code history components for use in code editors.
*
* @module code-editor/CodeHistory
*/
export { CodeHistoryButton } from './CodeHistoryButton';
export { CodeHistoryDropdown } from './CodeHistoryDropdown';
export { CodeHistoryDiffModal } from './CodeHistoryDiffModal';
export type { CodeSnapshot } from './types';

View File

@@ -0,0 +1,14 @@
/**
* Shared types for Code History components
*
* @module code-editor/CodeHistory
*/
/**
* A single code snapshot
*/
export interface CodeSnapshot {
code: string;
timestamp: string; // ISO 8601 format
hash: string; // For deduplication
}

View File

@@ -0,0 +1,187 @@
/**
* JavaScriptEditor Component Styles
* Uses design tokens for consistency with OpenNoodl design system
*/
.Root {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background-color: var(--theme-color-bg-1);
border: 1px solid var(--theme-color-border-default);
border-radius: 6px;
overflow: hidden;
}
/* Toolbar */
.Toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background-color: var(--theme-color-bg-2);
border-bottom: 1px solid var(--theme-color-border-default);
min-height: 36px;
}
.ToolbarLeft,
.ToolbarRight {
display: flex;
align-items: center;
gap: 12px;
}
.ModeLabel {
font-size: 12px;
font-weight: 600;
color: var(--theme-color-fg-default);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.StatusValid {
font-size: 12px;
font-weight: 500;
color: var(--theme-color-success);
}
.StatusInvalid {
font-size: 12px;
font-weight: 500;
color: var(--theme-color-error);
}
.FormatButton,
.SaveButton {
padding: 6px 12px;
font-size: 12px;
font-weight: 500;
border: 1px solid var(--theme-color-border-default);
background-color: var(--theme-color-bg-3);
color: var(--theme-color-fg-default);
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
&:hover:not(:disabled) {
background-color: var(--theme-color-bg-4);
border-color: var(--theme-color-primary);
}
&:active:not(:disabled) {
transform: translateY(1px);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
.SaveButton {
background-color: var(--theme-color-primary);
color: white;
border-color: var(--theme-color-primary);
&:hover:not(:disabled) {
background-color: var(--theme-color-primary-hover, var(--theme-color-primary));
border-color: var(--theme-color-primary-hover, var(--theme-color-primary));
}
}
/* Editor Container with CodeMirror */
.EditorContainer {
flex: 1;
overflow: hidden;
position: relative;
background-color: var(--theme-color-bg-2);
/* CodeMirror will fill this container */
:global(.cm-editor) {
height: 100%;
font-family: var(--theme-font-mono, 'Monaco', 'Menlo', 'Courier New', monospace);
}
:global(.cm-scroller) {
overflow: auto;
}
}
/* Error Panel */
.ErrorPanel {
padding: 12px 16px;
background-color: #fef2f2;
border-top: 1px solid #fecaca;
}
.ErrorHeader {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.ErrorIcon {
font-size: 18px;
line-height: 1;
}
.ErrorTitle {
font-size: 13px;
font-weight: 600;
color: #dc2626;
}
.ErrorMessage {
font-size: 13px;
color: #7c2d12;
line-height: 1.5;
font-family: var(--theme-font-mono, 'Monaco', 'Menlo', 'Courier New', monospace);
padding: 8px 12px;
background-color: #fff;
border: 1px solid #fecaca;
border-radius: 4px;
margin-bottom: 8px;
}
.ErrorSuggestion {
font-size: 12px;
color: #7c2d12;
line-height: 1.4;
padding: 8px 12px;
background-color: #fef3c7;
border: 1px solid #fde68a;
border-radius: 4px;
margin-bottom: 8px;
strong {
font-weight: 600;
}
}
.ErrorLocation {
font-size: 11px;
color: #92400e;
font-family: var(--theme-font-mono, 'Monaco', 'Menlo', 'Courier New', monospace);
font-weight: 500;
}
/* Footer with resize grip */
.Footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
background-color: var(--theme-color-bg-2);
border-top: 1px solid var(--theme-color-border-default);
min-height: 24px;
flex-shrink: 0;
}
.FooterLeft,
.FooterRight {
display: flex;
align-items: center;
gap: 8px;
}

View File

@@ -0,0 +1,176 @@
/**
* Storybook Stories for JavaScriptEditor
*
* Demonstrates all validation modes and features
*/
import type { Meta, StoryObj } from '@storybook/react';
import React, { useState } from 'react';
import { JavaScriptEditor } from './JavaScriptEditor';
const meta: Meta<typeof JavaScriptEditor> = {
title: 'Code Editor/JavaScriptEditor',
component: JavaScriptEditor,
parameters: {
layout: 'padded'
},
tags: ['autodocs']
};
export default meta;
type Story = StoryObj<typeof JavaScriptEditor>;
/**
* Interactive wrapper for stories
*/
function InteractiveEditor(props: React.ComponentProps<typeof JavaScriptEditor>) {
const [value, setValue] = useState(props.value || '');
return (
<div style={{ width: '800px', height: '500px' }}>
<JavaScriptEditor {...props} value={value} onChange={setValue} />
</div>
);
}
/**
* Expression validation mode
* Used for Expression nodes - validates as a JavaScript expression
*/
export const ExpressionMode: Story = {
render: () => (
<InteractiveEditor value="a + b" validationType="expression" placeholder="Enter a JavaScript expression..." />
)
};
/**
* Function validation mode
* Used for Function nodes - validates as a function body
*/
export const FunctionMode: Story = {
render: () => (
<InteractiveEditor
value={`// Calculate sum
const sum = inputs.a + inputs.b;
outputs.result = sum;`}
validationType="function"
placeholder="Enter JavaScript function code..."
/>
)
};
/**
* Script validation mode
* Used for Script nodes - validates as JavaScript statements
*/
export const ScriptMode: Story = {
render: () => (
<InteractiveEditor
value={`console.log('Script running');
const value = 42;
return value;`}
validationType="script"
placeholder="Enter JavaScript script code..."
/>
)
};
/**
* Invalid expression
* Shows error display and validation
*/
export const InvalidExpression: Story = {
render: () => <InteractiveEditor value="a + + b" validationType="expression" />
};
/**
* Invalid function
* Missing closing brace
*/
export const InvalidFunction: Story = {
render: () => (
<InteractiveEditor
value={`function test() {
console.log('missing closing brace');
// Missing }`}
validationType="function"
/>
)
};
/**
* With onSave callback
* Shows Save button and handles Ctrl+S
*/
export const WithSaveCallback: Story = {
render: () => {
const [savedValue, setSavedValue] = useState('');
return (
<div>
<div style={{ marginBottom: '16px', padding: '12px', backgroundColor: '#f0f0f0', borderRadius: '4px' }}>
<strong>Last saved:</strong> {savedValue || '(not saved yet)'}
</div>
<InteractiveEditor
value="a + b"
validationType="expression"
onSave={(code) => {
setSavedValue(code);
alert(`Saved: ${code}`);
}}
/>
</div>
);
}
};
/**
* Disabled state
*/
export const Disabled: Story = {
render: () => <InteractiveEditor value="a + b" validationType="expression" disabled={true} />
};
/**
* Custom height
*/
export const CustomHeight: Story = {
render: () => (
<div style={{ width: '800px' }}>
<JavaScriptEditor
value={`// Small editor
const x = 1;`}
onChange={() => {}}
validationType="function"
height={200}
/>
</div>
)
};
/**
* Complex function example
* Real-world usage scenario
*/
export const ComplexFunction: Story = {
render: () => (
<InteractiveEditor
value={`// Process user data
const name = inputs.firstName + ' ' + inputs.lastName;
const age = inputs.age;
if (age >= 18) {
outputs.category = 'adult';
outputs.message = 'Welcome, ' + name;
} else {
outputs.category = 'minor';
outputs.message = 'Hello, ' + name;
}
outputs.displayName = name;
outputs.isValid = true;`}
validationType="function"
/>
)
};

View File

@@ -0,0 +1,334 @@
/**
* JavaScriptEditor Component
*
* A feature-rich JavaScript code editor powered by CodeMirror 6.
* Includes syntax highlighting, autocompletion, linting, and all IDE features.
*
* @module code-editor
*/
import { EditorView } from '@codemirror/view';
import { useDragHandler } from '@noodl-hooks/useDragHandler';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { ToolbarGrip } from '@noodl-core-ui/components/toolbar/ToolbarGrip';
import { CodeHistoryButton, type CodeSnapshot } from './CodeHistory';
import { createEditorState, createExtensions } from './codemirror-extensions';
import css from './JavaScriptEditor.module.scss';
import { formatJavaScript } from './utils/jsFormatter';
import { validateJavaScript } from './utils/jsValidator';
import { JavaScriptEditorProps } from './utils/types';
/**
* Main JavaScriptEditor Component
*/
export function JavaScriptEditor({
value,
onChange,
onSave,
validationType = 'expression',
disabled = false,
height,
width,
placeholder = '// Enter your JavaScript code here',
nodeId,
parameterName
}: JavaScriptEditorProps) {
const rootRef = useRef<HTMLDivElement>(null);
const editorContainerRef = useRef<HTMLDivElement>(null);
const editorViewRef = useRef<EditorView | null>(null);
// Generation counter approach to prevent race conditions
// Replaces the unreliable isInternalChangeRef + setTimeout pattern
const changeGenerationRef = useRef(0);
const lastSyncedGenerationRef = useRef(0);
// Only store validation state (needed for display outside editor)
// Don't store localValue - CodeMirror is the single source of truth
const [validation, setValidation] = useState(validateJavaScript(value || '', validationType));
// Resize support - convert width/height to numbers
const initialWidth = typeof width === 'number' ? width : typeof width === 'string' ? parseInt(width, 10) : 800;
const initialHeight = typeof height === 'number' ? height : typeof height === 'string' ? parseInt(height, 10) : 500;
const [size, setSize] = useState<{ width: number; height: number }>({
width: initialWidth,
height: initialHeight
});
const { startDrag } = useDragHandler({
root: rootRef,
minHeight: 200,
minWidth: 400,
onDrag(contentWidth, contentHeight) {
setSize({
width: contentWidth,
height: contentHeight
});
},
onEndDrag() {
editorViewRef.current?.focus();
}
});
// Handle text changes from CodeMirror
const handleChange = useCallback(
(newValue: string) => {
// Increment generation counter for every internal change
// This prevents race conditions with external value syncing
changeGenerationRef.current++;
// Validate the new code
const result = validateJavaScript(newValue, validationType);
setValidation(result);
// Propagate changes to parent
if (onChange) {
onChange(newValue);
}
// No setTimeout needed - generation counter handles sync safely
},
[onChange, validationType]
);
// Handle format button
const handleFormat = useCallback(() => {
if (!editorViewRef.current) return;
try {
const currentCode = editorViewRef.current.state.doc.toString();
const formatted = formatJavaScript(currentCode);
// Increment generation counter for programmatic changes
changeGenerationRef.current++;
// Update CodeMirror with formatted code
editorViewRef.current.dispatch({
changes: {
from: 0,
to: editorViewRef.current.state.doc.length,
insert: formatted
}
});
if (onChange) {
onChange(formatted);
}
// No setTimeout needed
} catch (error) {
console.error('Format error:', error);
}
}, [onChange]);
// Initialize CodeMirror editor
useEffect(() => {
if (!editorContainerRef.current) return;
// Create extensions
const extensions = createExtensions({
validationType,
placeholder,
readOnly: disabled,
onChange: handleChange,
onSave,
tabSize: 2
});
// Create editor state
const state = createEditorState(value || '', extensions);
// Create editor view
const view = new EditorView({
state,
parent: editorContainerRef.current
});
editorViewRef.current = view;
// Cleanup on unmount
return () => {
view.destroy();
editorViewRef.current = null;
};
// Only run on mount - we handle updates separately
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Update editor when external value changes (but NOT from internal typing)
useEffect(() => {
if (!editorViewRef.current) return;
// Skip if internal changes have happened since last sync
// This prevents race conditions from auto-complete, fold, etc.
if (changeGenerationRef.current > lastSyncedGenerationRef.current) {
return;
}
const currentValue = editorViewRef.current.state.doc.toString();
// Only update if value actually changed from external source
if (currentValue !== value) {
// Update synced generation to current
lastSyncedGenerationRef.current = changeGenerationRef.current;
// Preserve cursor position during external update
const currentSelection = editorViewRef.current.state.selection;
editorViewRef.current.dispatch({
changes: {
from: 0,
to: editorViewRef.current.state.doc.length,
insert: value || ''
},
// Try to preserve selection if it's still valid
selection: currentSelection.ranges[0].to <= (value || '').length ? currentSelection : undefined
});
setValidation(validateJavaScript(value || '', validationType));
}
}, [value, validationType]);
// Update read-only state
useEffect(() => {
if (!editorViewRef.current) return;
editorViewRef.current.dispatch({
effects: [
// Note: This requires reconfiguring the editor
// For now, we handle it on initial mount
]
});
}, [disabled]);
// Get validation mode label
const getModeLabel = () => {
switch (validationType) {
case 'expression':
return 'Expression';
case 'function':
return 'Function';
case 'script':
return 'Script';
default:
return 'JavaScript';
}
};
return (
<div
ref={rootRef}
className={css['Root']}
style={{
width: size.width,
height: size.height,
minWidth: 400,
minHeight: 200
}}
>
{/* Toolbar */}
<div className={css['Toolbar']}>
<div className={css['ToolbarLeft']}>
<span className={css['ModeLabel']}>{getModeLabel()}</span>
{validation.valid ? (
<span className={css['StatusValid']}> Valid</span>
) : (
<span className={css['StatusInvalid']}> Error</span>
)}
</div>
<div className={css['ToolbarRight']}>
{/* History button - only show if nodeId and parameterName provided */}
{nodeId && parameterName && (
<CodeHistoryButton
nodeId={nodeId}
parameterName={parameterName}
currentCode={editorViewRef.current?.state.doc.toString() || value || ''}
onRestore={(snapshot: CodeSnapshot) => {
if (!editorViewRef.current) return;
// Increment generation counter for restore operation
changeGenerationRef.current++;
// Restore code from snapshot
editorViewRef.current.dispatch({
changes: {
from: 0,
to: editorViewRef.current.state.doc.length,
insert: snapshot.code
}
});
if (onChange) {
onChange(snapshot.code);
}
// No setTimeout needed
// Don't auto-save - let user manually save if they want to keep the restored version
// This prevents creating duplicate snapshots
}}
/>
)}
<button
onClick={handleFormat}
disabled={disabled}
className={css['FormatButton']}
title="Format code"
type="button"
>
Format
</button>
{onSave && (
<button
onClick={() => {
const currentCode = editorViewRef.current?.state.doc.toString() || '';
onSave(currentCode);
}}
disabled={disabled}
className={css['SaveButton']}
title="Save (Ctrl+S)"
type="button"
>
Save
</button>
)}
</div>
</div>
{/* CodeMirror Editor Container */}
<div ref={editorContainerRef} className={css['EditorContainer']} />
{/* Validation Errors */}
{!validation.valid && (
<div className={css['ErrorPanel']}>
<div className={css['ErrorHeader']}>
<span className={css['ErrorIcon']}></span>
<span className={css['ErrorTitle']}>Syntax Error</span>
</div>
<div className={css['ErrorMessage']}>{validation.error}</div>
{validation.suggestion && (
<div className={css['ErrorSuggestion']}>
<strong>💡 Suggestion:</strong> {validation.suggestion}
</div>
)}
{validation.line !== undefined && (
<div className={css['ErrorLocation']}>
Line {validation.line}
{validation.column !== undefined && `, Column ${validation.column}`}
</div>
)}
</div>
)}
{/* Footer with resize grip */}
<div className={css['Footer']}>
<div className={css['FooterLeft']}></div>
<div className={css['FooterRight']}>
<ToolbarGrip onMouseDown={startDrag} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,437 @@
/**
* CodeMirror Extensions Configuration
*
* Configures all CodeMirror extensions and features including:
* - Language support (JavaScript)
* - Autocompletion
* - Search/replace
* - Code folding
* - Linting
* - Custom keybindings
* - Bracket colorization
* - Indent guides
* - And more...
*
* @module code-editor
*/
import { autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete';
import { defaultKeymap, history, historyKeymap, indentWithTab, redo, undo, toggleComment } from '@codemirror/commands';
import { javascript } from '@codemirror/lang-javascript';
import {
bracketMatching,
foldGutter,
foldKeymap,
indentOnInput,
syntaxHighlighting,
defaultHighlightStyle
} from '@codemirror/language';
import { lintGutter, linter, type Diagnostic } from '@codemirror/lint';
import { highlightSelectionMatches, searchKeymap } from '@codemirror/search';
import { EditorSelection, EditorState, Extension, StateEffect, StateField, type Range } from '@codemirror/state';
import {
drawSelection,
dropCursor,
EditorView,
highlightActiveLine,
highlightActiveLineGutter,
keymap,
lineNumbers,
placeholder as placeholderExtension,
rectangularSelection,
ViewPlugin,
ViewUpdate,
Decoration,
DecorationSet
} from '@codemirror/view';
import { createOpenNoodlTheme } from './codemirror-theme';
import { noodlCompletionSource } from './noodl-completions';
import { validateJavaScript } from './utils/jsValidator';
/**
* Options for creating CodeMirror extensions
*/
export interface ExtensionOptions {
/** Validation type (expression, function, script) */
validationType?: 'expression' | 'function' | 'script';
/** Placeholder text */
placeholder?: string;
/** Is editor read-only? */
readOnly?: boolean;
/** onChange callback */
onChange?: (value: string) => void;
/** onSave callback (Cmd+S) */
onSave?: (value: string) => void;
/** Tab size (default: 2) */
tabSize?: number;
}
/**
* Indent guides extension
* Draws vertical lines to show indentation levels
*/
function indentGuides(): Extension {
const indentGuideDeco = Decoration.line({
attributes: { class: 'cm-indent-guide' }
});
return ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = this.buildDecorations(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = this.buildDecorations(update.view);
}
}
buildDecorations(view: EditorView): DecorationSet {
const decorations: Range<Decoration>[] = [];
const tabSize = view.state.tabSize;
for (const { from, to } of view.visibleRanges) {
for (let pos = from; pos <= to; ) {
const line = view.state.doc.lineAt(pos);
const text = line.text;
// Count leading spaces/tabs
let indent = 0;
for (let i = 0; i < text.length; i++) {
if (text[i] === ' ') indent++;
else if (text[i] === '\t') indent += tabSize;
else break;
}
// Add decoration if line has indentation
if (indent > 0) {
decorations.push(indentGuideDeco.range(line.from));
}
pos = line.to + 1;
}
}
return Decoration.set(decorations);
}
},
{
decorations: (v) => v.decorations
}
);
}
/**
* Custom Enter key handler for better brace/bracket handling
*/
function handleEnterKey(view: EditorView): boolean {
const { state } = view;
const { selection } = state;
// Only handle if single cursor
if (selection.ranges.length !== 1) {
return false;
}
const range = selection.main;
if (!range.empty) {
return false; // Has selection, use default behavior
}
const pos = range.from;
const line = state.doc.lineAt(pos);
const before = state.sliceDoc(line.from, pos);
// Check if cursor is between matching brackets/braces
const beforeChar = state.sliceDoc(Math.max(0, pos - 1), pos);
const afterChar = state.sliceDoc(pos, Math.min(state.doc.length, pos + 1));
const matchingPairs: Record<string, string> = {
'{': '}',
'[': ']',
'(': ')'
};
// If between matching pair (e.g., {|})
if (matchingPairs[beforeChar] === afterChar) {
// Calculate indentation
const indent = before.match(/^\s*/)?.[0] || '';
const indentSize = state.tabSize;
const newIndent = indent + ' '.repeat(indentSize);
// Insert newline with indentation, then another newline with original indentation
view.dispatch({
changes: {
from: pos,
to: pos,
insert: '\n' + newIndent + '\n' + indent
},
selection: { anchor: pos + 1 + newIndent.length }
});
return true; // Handled
}
// Default behavior
return false;
}
/**
* Move line up command (Alt+↑)
*/
function moveLineUp(view: EditorView): boolean {
const { state } = view;
const changes = state.changeByRange((range) => {
const line = state.doc.lineAt(range.from);
if (line.number === 1) return { range }; // Can't move first line up
const prevLine = state.doc.line(line.number - 1);
const lineText = state.doc.sliceString(line.from, line.to);
const prevLineText = state.doc.sliceString(prevLine.from, prevLine.to);
return {
changes: [
{ from: prevLine.from, to: prevLine.to, insert: lineText },
{ from: line.from, to: line.to, insert: prevLineText }
],
range: EditorSelection.range(prevLine.from, prevLine.from + lineText.length)
};
});
view.dispatch(changes);
return true;
}
/**
* Move line down command (Alt+↓)
*/
function moveLineDown(view: EditorView): boolean {
const { state } = view;
const changes = state.changeByRange((range) => {
const line = state.doc.lineAt(range.from);
if (line.number === state.doc.lines) return { range }; // Can't move last line down
const nextLine = state.doc.line(line.number + 1);
const lineText = state.doc.sliceString(line.from, line.to);
const nextLineText = state.doc.sliceString(nextLine.from, nextLine.to);
return {
changes: [
{ from: line.from, to: line.to, insert: nextLineText },
{ from: nextLine.from, to: nextLine.to, insert: lineText }
],
range: EditorSelection.range(nextLine.from, nextLine.from + lineText.length)
};
});
view.dispatch(changes);
return true;
}
/**
* Create custom keybindings
*/
function customKeybindings(options: ExtensionOptions) {
return keymap.of([
// Custom Enter key handler (before default keymap)
{
key: 'Enter',
run: handleEnterKey
},
// Standard keymaps
// REMOVED: closeBracketsKeymap (was intercepting closing brackets)
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...foldKeymap,
...completionKeymap,
// Tab key for indentation (not focus change)
indentWithTab,
// Comment toggle (Cmd+/)
{
key: 'Mod-/',
run: toggleComment
},
// Move lines up/down (Alt+↑/↓)
{
key: 'Alt-ArrowUp',
run: moveLineUp
},
{
key: 'Alt-ArrowDown',
run: moveLineDown
},
// Save (Cmd+S)
...(options.onSave
? [
{
key: 'Mod-s',
preventDefault: true,
run: (view: EditorView) => {
options.onSave?.(view.state.doc.toString());
return true;
}
}
]
: []),
// Undo/Redo (ensure they work)
{
key: 'Mod-z',
run: undo
},
{
key: 'Mod-Shift-z',
run: redo
},
{
key: 'Mod-y',
mac: 'Mod-Shift-z',
run: redo
}
]);
}
/**
* Create a linter from our validation function
*/
function createLinter(validationType: 'expression' | 'function' | 'script') {
return linter((view) => {
const code = view.state.doc.toString();
const validation = validateJavaScript(code, validationType);
if (validation.valid) {
return [];
}
const diagnostics: Diagnostic[] = [];
// Calculate position from line/column
let from = 0;
let to = code.length;
if (validation.line !== undefined) {
const lines = code.split('\n');
const lineIndex = validation.line - 1;
if (lineIndex >= 0 && lineIndex < lines.length) {
// Calculate character position of the line
from = lines.slice(0, lineIndex).reduce((sum, line) => sum + line.length + 1, 0);
if (validation.column !== undefined) {
from += validation.column;
to = from + 1; // Highlight just one character
} else {
to = from + lines[lineIndex].length;
}
}
}
diagnostics.push({
from: Math.max(0, from),
to: Math.min(code.length, to),
severity: 'error',
message: validation.error || 'Syntax error'
});
return diagnostics;
});
}
/**
* Create all CodeMirror extensions
*/
export function createExtensions(options: ExtensionOptions = {}): Extension[] {
const {
validationType = 'expression',
placeholder = '// Enter your JavaScript code here',
readOnly = false,
onChange,
tabSize = 2
} = options;
// Adding extensions back one by one to find the culprit
const extensions: Extension[] = [
// 1. Language support
javascript(),
// 2. Theme
createOpenNoodlTheme(),
// 3. Custom keybindings with Enter handler
customKeybindings(options),
// 4. Essential UI
lineNumbers(),
history(),
// 5. Visual enhancements (Group 1 - SAFE ✅)
highlightActiveLineGutter(),
highlightActiveLine(),
drawSelection(),
dropCursor(),
rectangularSelection(),
// 6. Bracket & selection features (Group 2 - SAFE ✅)
bracketMatching(),
highlightSelectionMatches(),
placeholderExtension(placeholder),
EditorView.lineWrapping,
// 7. Complex features (tested safe)
foldGutter({
openText: '▼',
closedText: '▶'
}),
autocompletion({
activateOnTyping: true,
maxRenderedOptions: 10,
defaultKeymap: true,
override: [noodlCompletionSource]
}),
// 8. Tab size
EditorState.tabSize.of(tabSize),
// 9. Read-only mode
EditorView.editable.of(!readOnly),
EditorState.readOnly.of(readOnly),
// 10. onChange handler
...(onChange
? [
EditorView.updateListener.of((update: ViewUpdate) => {
if (update.docChanged) {
onChange(update.state.doc.toString());
}
})
]
: [])
// ALL EXTENSIONS NOW ENABLED (except closeBrackets/indentOnInput)
// closeBrackets() - PERMANENTLY DISABLED (conflicted with custom Enter handler)
// closeBracketsKeymap - PERMANENTLY REMOVED (intercepted closing brackets)
// indentOnInput() - PERMANENTLY DISABLED (not needed with our custom handler)
];
return extensions;
}
/**
* Helper to create a basic CodeMirror state
*/
export function createEditorState(initialValue: string, extensions: Extension[]): EditorState {
return EditorState.create({
doc: initialValue,
extensions
});
}

View File

@@ -0,0 +1,338 @@
/**
* CodeMirror Theme for OpenNoodl
*
* Custom theme matching OpenNoodl design tokens and VSCode Dark+ colors.
* Provides syntax highlighting, UI colors, and visual feedback.
*
* @module code-editor
*/
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { Extension } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { tags as t } from '@lezer/highlight';
/**
* Create the OpenNoodl editor theme
*/
export function createOpenNoodlTheme(): Extension {
// Editor theme (UI elements)
const editorTheme = EditorView.theme(
{
// Main editor
'&': {
backgroundColor: 'var(--theme-color-bg-2)',
color: 'var(--theme-color-fg-default)',
fontSize: '13px',
fontFamily: "var(--theme-font-mono, 'Monaco', 'Menlo', 'Courier New', monospace)",
lineHeight: '1.6'
},
// Content area
'.cm-content': {
caretColor: 'var(--theme-color-fg-default)',
padding: '16px 0'
},
// Cursor
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--theme-color-fg-default)',
borderLeftWidth: '2px'
},
// Selection
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
backgroundColor: 'rgba(86, 156, 214, 0.3)'
},
// Active line
'.cm-activeLine': {
backgroundColor: 'rgba(255, 255, 255, 0.05)'
},
// Line numbers gutter
'.cm-gutters': {
backgroundColor: 'var(--theme-color-bg-3)',
color: 'var(--theme-color-fg-muted)',
border: 'none',
borderRight: '1px solid var(--theme-color-border-default)',
minWidth: '35px'
},
'.cm-gutter': {
minWidth: '35px'
},
'.cm-lineNumbers': {
minWidth: '35px'
},
'.cm-lineNumbers .cm-gutterElement': {
padding: '0 8px 0 6px',
textAlign: 'right',
minWidth: '35px'
},
// Active line number
'.cm-activeLineGutter': {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
color: 'var(--theme-color-fg-default)'
},
// Fold gutter
'.cm-foldGutter': {
width: '20px',
padding: '0 4px'
},
'.cm-foldGutter .cm-gutterElement': {
textAlign: 'center',
cursor: 'pointer'
},
'.cm-foldPlaceholder': {
backgroundColor: 'rgba(255, 255, 255, 0.1)',
border: '1px solid rgba(255, 255, 255, 0.2)',
color: 'var(--theme-color-fg-muted)',
borderRadius: '3px',
padding: '0 6px',
margin: '0 4px'
},
// Search panel
'.cm-panel': {
backgroundColor: 'var(--theme-color-bg-3)',
border: '1px solid var(--theme-color-border-default)',
borderRadius: '4px',
padding: '8px'
},
'.cm-panel.cm-search': {
padding: '8px 12px'
},
'.cm-searchMatch': {
backgroundColor: 'rgba(255, 215, 0, 0.3)',
outline: '1px solid rgba(255, 215, 0, 0.5)'
},
'.cm-searchMatch-selected': {
backgroundColor: 'rgba(255, 165, 0, 0.4)',
outline: '1px solid rgba(255, 165, 0, 0.7)'
},
// Highlight selection matches
'.cm-selectionMatch': {
backgroundColor: 'rgba(255, 255, 255, 0.1)',
outline: '1px solid rgba(255, 255, 255, 0.2)'
},
// Matching brackets
'.cm-matchingBracket': {
backgroundColor: 'rgba(255, 255, 255, 0.15)',
outline: '1px solid rgba(255, 255, 255, 0.3)',
borderRadius: '2px'
},
'.cm-nonmatchingBracket': {
backgroundColor: 'rgba(255, 0, 0, 0.2)',
outline: '1px solid rgba(255, 0, 0, 0.4)'
},
// Autocomplete panel
'.cm-tooltip-autocomplete': {
backgroundColor: 'var(--theme-color-bg-3)',
border: '1px solid var(--theme-color-border-default)',
borderRadius: '6px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
overflow: 'hidden',
fontFamily: "var(--theme-font-mono, 'Monaco', 'Menlo', 'Courier New', monospace)",
fontSize: '13px'
},
'.cm-tooltip-autocomplete ul': {
maxHeight: '300px',
overflowY: 'auto'
},
'.cm-tooltip-autocomplete ul li': {
padding: '6px 12px',
color: 'var(--theme-color-fg-default)',
cursor: 'pointer'
},
'.cm-tooltip-autocomplete ul li[aria-selected]': {
backgroundColor: 'var(--theme-color-primary)',
color: 'white'
},
'.cm-completionIcon': {
width: '1em',
marginRight: '8px',
fontSize: '14px',
lineHeight: '1'
},
// Lint markers (errors/warnings)
'.cm-lintRange-error': {
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='6' height='3'%3E%3Cpath d='m0 3 l3 -3 l3 3' stroke='%23ef4444' fill='none' stroke-width='.7'/%3E%3C/svg%3E\")",
backgroundRepeat: 'repeat-x',
backgroundPosition: 'left bottom',
paddingBottom: '3px'
},
'.cm-lintRange-warning': {
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='6' height='3'%3E%3Cpath d='m0 3 l3 -3 l3 3' stroke='%23f59e0b' fill='none' stroke-width='.7'/%3E%3C/svg%3E\")",
backgroundRepeat: 'repeat-x',
backgroundPosition: 'left bottom',
paddingBottom: '3px'
},
'.cm-lint-marker-error': {
content: '●',
color: '#ef4444'
},
'.cm-lint-marker-warning': {
content: '●',
color: '#f59e0b'
},
// Hover tooltips
'.cm-tooltip': {
backgroundColor: 'var(--theme-color-bg-4)',
border: '1px solid var(--theme-color-border-default)',
borderRadius: '4px',
padding: '6px 10px',
color: 'var(--theme-color-fg-default)',
fontSize: '12px',
maxWidth: '400px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.2)'
},
'.cm-tooltip-lint': {
fontFamily: "var(--theme-font-mono, 'Monaco', 'Menlo', 'Courier New', monospace)"
},
// Placeholder
'.cm-placeholder': {
color: 'var(--theme-color-fg-muted)',
opacity: 0.6
},
// Indent guides (will be added via custom extension)
'.cm-indent-guide': {
position: 'absolute',
top: '0',
bottom: '0',
width: '1px',
backgroundColor: 'rgba(255, 255, 255, 0.1)'
},
// Scroller
'.cm-scroller': {
overflow: 'auto',
fontFamily: "var(--theme-font-mono, 'Monaco', 'Menlo', 'Courier New', monospace)"
},
// Focused state
'&.cm-focused': {
outline: 'none'
}
},
{ dark: true }
);
// Syntax highlighting theme (token colors)
const syntaxTheme = HighlightStyle.define([
// Keywords (if, for, function, return, etc.)
{ tag: t.keyword, color: '#569cd6', fontWeight: 'bold' },
// Control keywords (if, else, switch, case)
{ tag: t.controlKeyword, color: '#c586c0', fontWeight: 'bold' },
// Definition keywords (function, class, const, let, var)
{ tag: t.definitionKeyword, color: '#569cd6', fontWeight: 'bold' },
// Module keywords (import, export)
{ tag: t.moduleKeyword, color: '#c586c0', fontWeight: 'bold' },
// Operator keywords (typeof, instanceof, new, delete)
{ tag: t.operatorKeyword, color: '#569cd6', fontWeight: 'bold' },
// Comments
{ tag: t.comment, color: '#6a9955', fontStyle: 'italic' },
{ tag: t.lineComment, color: '#6a9955', fontStyle: 'italic' },
{ tag: t.blockComment, color: '#6a9955', fontStyle: 'italic' },
// Strings
{ tag: t.string, color: '#ce9178' },
{ tag: t.special(t.string), color: '#d16969' },
// Numbers
{ tag: t.number, color: '#b5cea8' },
{ tag: t.integer, color: '#b5cea8' },
{ tag: t.float, color: '#b5cea8' },
// Booleans
{ tag: t.bool, color: '#569cd6', fontWeight: 'bold' },
// Null/Undefined
{ tag: t.null, color: '#569cd6', fontWeight: 'bold' },
// Variables
{ tag: t.variableName, color: '#9cdcfe' },
{ tag: t.local(t.variableName), color: '#9cdcfe' },
{ tag: t.definition(t.variableName), color: '#9cdcfe' },
// Functions
{ tag: t.function(t.variableName), color: '#dcdcaa' },
{ tag: t.function(t.propertyName), color: '#dcdcaa' },
// Properties
{ tag: t.propertyName, color: '#9cdcfe' },
{ tag: t.special(t.propertyName), color: '#4fc1ff' },
// Operators
{ tag: t.operator, color: '#d4d4d4' },
{ tag: t.arithmeticOperator, color: '#d4d4d4' },
{ tag: t.logicOperator, color: '#d4d4d4' },
{ tag: t.compareOperator, color: '#d4d4d4' },
// Punctuation
{ tag: t.punctuation, color: '#d4d4d4' },
{ tag: t.separator, color: '#d4d4d4' },
{ tag: t.paren, color: '#ffd700' }, // Gold for ()
{ tag: t.bracket, color: '#87ceeb' }, // Sky blue for []
{ tag: t.brace, color: '#98fb98' }, // Pale green for {}
{ tag: t.squareBracket, color: '#87ceeb' },
{ tag: t.angleBracket, color: '#dda0dd' },
// Types (for TypeScript/JSDoc)
{ tag: t.typeName, color: '#4ec9b0' },
{ tag: t.className, color: '#4ec9b0' },
{ tag: t.namespace, color: '#4ec9b0' },
// Special identifiers (self keyword)
{ tag: t.self, color: '#569cd6', fontWeight: 'bold' },
// Regular expressions
{ tag: t.regexp, color: '#d16969' },
// Invalid/Error
{ tag: t.invalid, color: '#f44747', textDecoration: 'underline' },
// Meta
{ tag: t.meta, color: '#808080' },
// Escape sequences
{ tag: t.escape, color: '#d7ba7d' },
// Labels
{ tag: t.labelName, color: '#c8c8c8' }
]);
return [editorTheme, syntaxHighlighting(syntaxTheme)];
}

View File

@@ -0,0 +1,14 @@
/**
* JavaScriptEditor Component
*
* A feature-rich JavaScript code editor powered by CodeMirror 6.
* Includes syntax highlighting, autocompletion, linting, code folding,
* and all modern IDE features for Expression, Function, and Script nodes.
*
* @module code-editor
*/
export { JavaScriptEditor } from './JavaScriptEditor';
export type { JavaScriptEditorProps, ValidationType, ValidationResult } from './utils/types';
export { validateJavaScript } from './utils/jsValidator';
export { formatJavaScript } from './utils/jsFormatter';

View File

@@ -0,0 +1,109 @@
/**
* Noodl-Specific Autocomplete
*
* Provides intelligent code completion for Noodl's global API:
* - Noodl.Variables, Noodl.Objects, Noodl.Arrays
* - Inputs, Outputs, State, Props (node context)
* - Math helpers (min, max, cos, sin, etc.)
*
* @module code-editor
*/
import { CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import { syntaxTree } from '@codemirror/language';
/**
* Noodl API structure completions
*/
const noodlCompletions = [
// Noodl global API
{ label: 'Noodl.Variables', type: 'property', info: 'Access global variables' },
{ label: 'Noodl.Objects', type: 'property', info: 'Access objects from model scope' },
{ label: 'Noodl.Arrays', type: 'property', info: 'Access arrays from model scope' },
// Shorthand versions
{ label: 'Variables', type: 'property', info: 'Shorthand for Noodl.Variables' },
{ label: 'Objects', type: 'property', info: 'Shorthand for Noodl.Objects' },
{ label: 'Arrays', type: 'property', info: 'Shorthand for Noodl.Arrays' },
// Node context (for Expression/Function nodes)
{ label: 'Inputs', type: 'property', info: 'Access node input values' },
{ label: 'Outputs', type: 'property', info: 'Set node output values' },
{ label: 'State', type: 'property', info: 'Access component state' },
{ label: 'Props', type: 'property', info: 'Access component props' },
// Math helpers
{ label: 'min', type: 'function', info: 'Math.min - Return smallest value' },
{ label: 'max', type: 'function', info: 'Math.max - Return largest value' },
{ label: 'cos', type: 'function', info: 'Math.cos - Cosine function' },
{ label: 'sin', type: 'function', info: 'Math.sin - Sine function' },
{ label: 'tan', type: 'function', info: 'Math.tan - Tangent function' },
{ label: 'sqrt', type: 'function', info: 'Math.sqrt - Square root' },
{ label: 'pi', type: 'constant', info: 'Math.PI - The pi constant (3.14159...)' },
{ label: 'round', type: 'function', info: 'Math.round - Round to nearest integer' },
{ label: 'floor', type: 'function', info: 'Math.floor - Round down' },
{ label: 'ceil', type: 'function', info: 'Math.ceil - Round up' },
{ label: 'abs', type: 'function', info: 'Math.abs - Absolute value' },
{ label: 'random', type: 'function', info: 'Math.random - Random number 0-1' },
{ label: 'pow', type: 'function', info: 'Math.pow - Power function' },
{ label: 'log', type: 'function', info: 'Math.log - Natural logarithm' },
{ label: 'exp', type: 'function', info: 'Math.exp - e to the power of x' }
];
/**
* Get the word before the cursor
*/
function wordBefore(context: CompletionContext): { from: number; to: number; text: string } | null {
const word = context.matchBefore(/\w*/);
if (!word) return null;
if (word.from === word.to && !context.explicit) return null;
return word;
}
/**
* Get completions for after "Noodl."
*/
function getNoodlPropertyCompletions(): CompletionResult {
return {
from: 0, // Will be set by caller
options: [
{ label: 'Variables', type: 'property', info: 'Access global variables' },
{ label: 'Objects', type: 'property', info: 'Access objects from model scope' },
{ label: 'Arrays', type: 'property', info: 'Access arrays from model scope' }
]
};
}
/**
* Main Noodl completion source
*/
export function noodlCompletionSource(context: CompletionContext): CompletionResult | null {
const word = wordBefore(context);
if (!word) return null;
// Check if we're after "Noodl."
const textBefore = context.state.doc.sliceString(Math.max(0, word.from - 6), word.from);
if (textBefore.endsWith('Noodl.')) {
const result = getNoodlPropertyCompletions();
result.from = word.from;
return result;
}
// Check if we're typing "Noodl" itself
if (word.text.toLowerCase().startsWith('nood')) {
return {
from: word.from,
options: [{ label: 'Noodl', type: 'namespace', info: 'Noodl global namespace' }]
};
}
// General completions (always available)
const filtered = noodlCompletions.filter((c) => c.label.toLowerCase().startsWith(word.text.toLowerCase()));
if (filtered.length === 0) return null;
return {
from: word.from,
options: filtered
};
}

View File

@@ -0,0 +1,279 @@
/**
* Code Diff Utilities
*
* Computes line-based diffs between code snippets for history visualization.
* Uses a simplified Myers diff algorithm.
*
* @module code-editor/utils
*/
export type DiffLineType = 'unchanged' | 'added' | 'removed' | 'modified';
export interface DiffLine {
type: DiffLineType;
lineNumber: number;
content: string;
oldContent?: string; // For modified lines
newContent?: string; // For modified lines
}
export interface DiffResult {
lines: DiffLine[];
additions: number;
deletions: number;
modifications: number;
}
export interface DiffSummary {
additions: number;
deletions: number;
modifications: number;
description: string;
}
/**
* Compute a diff between two code snippets
*/
export function computeDiff(oldCode: string, newCode: string): DiffResult {
const oldLines = oldCode.split('\n');
const newLines = newCode.split('\n');
const diff = simpleDiff(oldLines, newLines);
return {
lines: diff,
additions: diff.filter((l) => l.type === 'added').length,
deletions: diff.filter((l) => l.type === 'removed').length,
modifications: diff.filter((l) => l.type === 'modified').length
};
}
/**
* Get a human-readable summary of changes
*/
export function getDiffSummary(diff: DiffResult): DiffSummary {
const { additions, deletions, modifications } = diff;
let description = '';
const parts: string[] = [];
if (additions > 0) {
parts.push(`+${additions} line${additions === 1 ? '' : 's'}`);
}
if (deletions > 0) {
parts.push(`-${deletions} line${deletions === 1 ? '' : 's'}`);
}
if (modifications > 0) {
parts.push(`~${modifications} modified`);
}
if (parts.length === 0) {
description = 'No changes';
} else if (additions + deletions + modifications > 10) {
description = 'Major refactor';
} else if (modifications > additions && modifications > deletions) {
description = 'Modified: ' + parts.join(', ');
} else {
description = 'Changed: ' + parts.join(', ');
}
return {
additions,
deletions,
modifications,
description
};
}
/**
* Simplified diff algorithm
* Uses Longest Common Subsequence (LCS) approach
*/
function simpleDiff(oldLines: string[], newLines: string[]): DiffLine[] {
const result: DiffLine[] = [];
// Compute LCS matrix
const lcs = computeLCS(oldLines, newLines);
// Backtrack through LCS to build diff (builds in reverse)
let i = oldLines.length;
let j = newLines.length;
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
// Lines are identical
result.unshift({
type: 'unchanged',
lineNumber: 0, // Will assign later
content: oldLines[i - 1]
});
i--;
j--;
} else if (j > 0 && (i === 0 || lcs[i][j - 1] >= lcs[i - 1][j])) {
// Line added in new version
result.unshift({
type: 'added',
lineNumber: 0, // Will assign later
content: newLines[j - 1]
});
j--;
} else if (i > 0 && (j === 0 || lcs[i][j - 1] < lcs[i - 1][j])) {
// Line removed from old version
result.unshift({
type: 'removed',
lineNumber: 0, // Will assign later
content: oldLines[i - 1]
});
i--;
}
}
// Post-process to detect modifications (adjacent add/remove pairs)
const processed = detectModifications(result);
// Assign sequential line numbers (ascending order)
let lineNumber = 1;
processed.forEach((line) => {
line.lineNumber = lineNumber++;
});
return processed;
}
/**
* Detect modified lines (pairs of removed + added lines)
*/
function detectModifications(lines: DiffLine[]): DiffLine[] {
const result: DiffLine[] = [];
for (let i = 0; i < lines.length; i++) {
const current = lines[i];
const next = lines[i + 1];
// Check if we have a removed line followed by an added line
if (current.type === 'removed' && next && next.type === 'added') {
// This is likely a modification
const similarity = calculateSimilarity(current.content, next.content);
// If lines are somewhat similar (>30% similar), treat as modification
if (similarity > 0.3) {
result.push({
type: 'modified',
lineNumber: current.lineNumber,
content: next.content,
oldContent: current.content,
newContent: next.content
});
i++; // Skip next line (we processed it)
continue;
}
}
result.push(current);
}
return result;
}
/**
* Compute Longest Common Subsequence (LCS) matrix
*/
function computeLCS(a: string[], b: string[]): number[][] {
const m = a.length;
const n = b.length;
const lcs: number[][] = Array(m + 1)
.fill(null)
.map(() => Array(n + 1).fill(0));
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (a[i - 1] === b[j - 1]) {
lcs[i][j] = lcs[i - 1][j - 1] + 1;
} else {
lcs[i][j] = Math.max(lcs[i - 1][j], lcs[i][j - 1]);
}
}
}
return lcs;
}
/**
* Calculate similarity between two strings (0 to 1)
* Uses simple character overlap metric
*/
function calculateSimilarity(a: string, b: string): number {
if (a === b) return 1;
if (a.length === 0 || b.length === 0) return 0;
// Count matching characters (case insensitive, ignoring whitespace)
const aNorm = a.toLowerCase().replace(/\s+/g, '');
const bNorm = b.toLowerCase().replace(/\s+/g, '');
const shorter = aNorm.length < bNorm.length ? aNorm : bNorm;
const longer = aNorm.length >= bNorm.length ? aNorm : bNorm;
let matches = 0;
for (let i = 0; i < shorter.length; i++) {
if (longer.includes(shorter[i])) {
matches++;
}
}
return matches / longer.length;
}
/**
* Format diff for display - returns context-aware subset of lines
* Shows changes with 3 lines of context before/after
*/
export function getContextualDiff(diff: DiffResult, contextLines = 3): DiffLine[] {
const { lines } = diff;
// Find all changed lines
const changedIndices = lines
.map((line, index) => (line.type !== 'unchanged' ? index : -1))
.filter((index) => index !== -1);
if (changedIndices.length === 0) {
// No changes, return first few lines
return lines.slice(0, Math.min(10, lines.length));
}
// Determine ranges to include (changes + context)
const ranges: Array<[number, number]> = [];
for (const index of changedIndices) {
const start = Math.max(0, index - contextLines);
const end = Math.min(lines.length - 1, index + contextLines);
// Merge overlapping ranges
if (ranges.length > 0) {
const lastRange = ranges[ranges.length - 1];
if (start <= lastRange[1] + 1) {
lastRange[1] = Math.max(lastRange[1], end);
continue;
}
}
ranges.push([start, end]);
}
// Extract lines from ranges
const result: DiffLine[] = [];
for (let i = 0; i < ranges.length; i++) {
const [start, end] = ranges[i];
// Add separator if not first range
if (i > 0 && start - ranges[i - 1][1] > 1) {
result.push({
type: 'unchanged',
lineNumber: -1,
content: '...'
});
}
result.push(...lines.slice(start, end + 1));
}
return result;
}

View File

@@ -0,0 +1,101 @@
/**
* JavaScript Formatting Utilities
*
* Simple indentation and formatting for JavaScript code.
* Not a full formatter, just basic readability improvements.
*
* @module code-editor/utils
*/
/**
* Format JavaScript code with basic indentation
*
* This is a simple formatter that:
* - Adds indentation after opening braces
* - Removes indentation after closing braces
* - Adds newlines for readability
*
* Not perfect, but good enough for small code snippets.
*/
export function formatJavaScript(code: string): string {
if (!code || code.trim() === '') {
return code;
}
let formatted = '';
let indentLevel = 0;
const indentSize = 2; // 2 spaces per indent
let inString = false;
let stringChar = '';
// Remove existing whitespace for consistent formatting
const trimmed = code.trim();
for (let i = 0; i < trimmed.length; i++) {
const char = trimmed[i];
const prevChar = i > 0 ? trimmed[i - 1] : '';
const nextChar = i < trimmed.length - 1 ? trimmed[i + 1] : '';
// Track string state to avoid formatting inside strings
if ((char === '"' || char === "'" || char === '`') && prevChar !== '\\') {
if (!inString) {
inString = true;
stringChar = char;
} else if (char === stringChar) {
inString = false;
stringChar = '';
}
}
// Don't format inside strings
if (inString) {
formatted += char;
continue;
}
// Handle opening brace
if (char === '{') {
formatted += char;
indentLevel++;
if (nextChar !== '}') {
formatted += '\n' + ' '.repeat(indentLevel * indentSize);
}
continue;
}
// Handle closing brace
if (char === '}') {
indentLevel = Math.max(0, indentLevel - 1);
// Add newline before closing brace if there's content before it
if (prevChar !== '{' && prevChar !== '\n') {
formatted += '\n' + ' '.repeat(indentLevel * indentSize);
}
formatted += char;
continue;
}
// Handle semicolon (add newline after)
if (char === ';') {
formatted += char;
if (nextChar && nextChar !== '\n' && nextChar !== '}') {
formatted += '\n' + ' '.repeat(indentLevel * indentSize);
}
continue;
}
// Skip multiple consecutive spaces/newlines
if ((char === ' ' || char === '\n') && (prevChar === ' ' || prevChar === '\n')) {
continue;
}
// Replace newlines with properly indented newlines
if (char === '\n') {
formatted += '\n' + ' '.repeat(indentLevel * indentSize);
continue;
}
formatted += char;
}
return formatted.trim();
}

View File

@@ -0,0 +1,125 @@
/**
* JavaScript Validation Utilities
*
* Validates JavaScript code using the Function constructor.
* This catches syntax errors without needing a full parser.
*
* @module code-editor/utils
*/
import { ValidationResult, ValidationType } from './types';
/**
* Extract line and column from error message
*/
function parseErrorLocation(error: Error): { line?: number; column?: number } {
const message = error.message;
// Try to extract line number from various error formats
const lineMatch = message.match(/line (\d+)/i);
const posMatch = message.match(/position (\d+)/i);
return {
line: lineMatch ? parseInt(lineMatch[1], 10) : undefined,
column: posMatch ? parseInt(posMatch[1], 10) : undefined
};
}
/**
* Get helpful suggestion based on error message
*/
function getSuggestion(error: Error): string | undefined {
const message = error.message.toLowerCase();
if (message.includes('unexpected token') || message.includes('unexpected identifier')) {
return 'Check for missing or extra brackets, parentheses, or quotes';
}
if (message.includes('unexpected end of input')) {
return 'You may be missing a closing bracket or parenthesis';
}
if (message.includes('unexpected string') || message.includes("unexpected ','")) {
return 'Check for missing operators or commas between values';
}
if (message.includes('missing') && message.includes('after')) {
return 'Check the syntax around the indicated position';
}
return undefined;
}
/**
* Validate JavaScript expression
* Wraps code in `return ()` to validate as expression
*/
function validateExpression(code: string): ValidationResult {
if (!code || code.trim() === '') {
return { valid: true };
}
try {
// Try to create a function that returns the expression
// This validates that it's a valid JavaScript expression
new Function(`return (${code});`);
return { valid: true };
} catch (error) {
const location = parseErrorLocation(error as Error);
return {
valid: false,
error: (error as Error).message,
suggestion: getSuggestion(error as Error),
...location
};
}
}
/**
* Validate JavaScript function body
* Creates a function with the code as body
*/
function validateFunction(code: string): ValidationResult {
if (!code || code.trim() === '') {
return { valid: true };
}
try {
// Create a function with the code as body
new Function(code);
return { valid: true };
} catch (error) {
const location = parseErrorLocation(error as Error);
return {
valid: false,
error: (error as Error).message,
suggestion: getSuggestion(error as Error),
...location
};
}
}
/**
* Validate JavaScript script
* Same as function validation for our purposes
*/
function validateScript(code: string): ValidationResult {
return validateFunction(code);
}
/**
* Main validation function
* Validates JavaScript code based on validation type
*/
export function validateJavaScript(code: string, validationType: ValidationType = 'expression'): ValidationResult {
switch (validationType) {
case 'expression':
return validateExpression(code);
case 'function':
return validateFunction(code);
case 'script':
return validateScript(code);
default:
return { valid: false, error: 'Unknown validation type' };
}
}

View File

@@ -0,0 +1,47 @@
/**
* Type definitions for JavaScriptEditor
*
* @module code-editor/utils
*/
export type ValidationType = 'expression' | 'function' | 'script';
export interface ValidationResult {
valid: boolean;
error?: string;
suggestion?: string;
line?: number;
column?: number;
}
export interface JavaScriptEditorProps {
/** Current code value */
value: string;
/** Callback when code changes */
onChange?: (value: string) => void;
/** Callback when user saves (Ctrl+S or Save button) */
onSave?: (value: string) => void;
/** Validation type */
validationType?: ValidationType;
/** Disable the editor */
disabled?: boolean;
/** Width of the editor */
width?: number | string;
/** Height of the editor */
height?: number | string;
/** Placeholder text */
placeholder?: string;
/** Node ID for history tracking (optional) */
nodeId?: string;
/** Parameter name for history tracking (optional) */
parameterName?: string;
}

View File

@@ -0,0 +1,64 @@
.Root {
display: flex;
align-items: center;
gap: 6px;
background-color: var(--theme-color-bg-3, rgba(99, 102, 241, 0.05));
border: 1px solid var(--theme-color-border-default, rgba(99, 102, 241, 0.2));
border-radius: 4px;
padding: 4px 8px;
flex: 1;
transition: all 0.15s ease;
&:focus-within {
border-color: var(--theme-color-primary, #6366f1);
background-color: var(--theme-color-bg-2, rgba(99, 102, 241, 0.08));
}
&.HasError {
border-color: var(--theme-color-error, #ef4444);
background-color: var(--theme-color-bg-2, rgba(239, 68, 68, 0.05));
}
}
.Badge {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
font-size: 10px;
font-weight: 600;
color: var(--theme-color-primary, #6366f1);
padding: 2px 4px;
background-color: var(--theme-color-bg-2, rgba(99, 102, 241, 0.15));
border-radius: 2px;
flex-shrink: 0;
user-select: none;
}
.Input {
flex: 1;
background: transparent;
border: none;
outline: none;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
font-size: 12px;
color: var(--theme-color-fg-default, #ffffff);
padding: 0;
min-width: 0;
&::placeholder {
color: var(--theme-color-fg-default-shy, rgba(255, 255, 255, 0.4));
opacity: 1;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.ErrorIndicator {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--theme-color-error, #ef4444);
cursor: help;
}

View File

@@ -0,0 +1,170 @@
import { Meta, StoryFn } from '@storybook/react';
import React, { useState } from 'react';
import { ExpressionInput, ExpressionInputProps } from './ExpressionInput';
export default {
title: 'Property Panel/Expression Input',
component: ExpressionInput,
argTypes: {
hasError: {
control: 'boolean'
},
placeholder: {
control: 'text'
},
debounceMs: {
control: 'number'
}
}
} as Meta<typeof ExpressionInput>;
const Template: StoryFn<ExpressionInputProps> = (args) => {
const [expression, setExpression] = useState(args.expression);
return (
<div style={{ padding: '20px', maxWidth: '400px' }}>
<ExpressionInput {...args} expression={expression} onChange={setExpression} />
<div style={{ marginTop: '12px', fontSize: '12px', opacity: 0.6 }}>
Current value: <code>{expression}</code>
</div>
</div>
);
};
export const Default = Template.bind({});
Default.args = {
expression: 'Variables.x * 2',
hasError: false,
placeholder: 'Enter expression...'
};
export const Empty = Template.bind({});
Empty.args = {
expression: '',
hasError: false,
placeholder: 'Enter expression...'
};
export const WithError = Template.bind({});
WithError.args = {
expression: 'invalid syntax +',
hasError: true,
errorMessage: 'Syntax error: Unexpected token +',
placeholder: 'Enter expression...'
};
export const LongExpression = Template.bind({});
LongExpression.args = {
expression: 'Variables.isAdmin ? "Administrator Panel" : Variables.isModerator ? "Moderator Panel" : "User Panel"',
hasError: false,
placeholder: 'Enter expression...'
};
export const InteractiveDemo: StoryFn<ExpressionInputProps> = () => {
const [expression, setExpression] = useState('Variables.count');
const [hasError, setHasError] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const handleChange = (newExpression: string) => {
setExpression(newExpression);
// Simple validation: check for unmatched parentheses
const openParens = (newExpression.match(/\(/g) || []).length;
const closeParens = (newExpression.match(/\)/g) || []).length;
if (openParens !== closeParens) {
setHasError(true);
setErrorMessage('Unmatched parentheses');
} else if (newExpression.includes('++') || newExpression.includes('--')) {
setHasError(true);
setErrorMessage('Increment/decrement operators not supported');
} else {
setHasError(false);
setErrorMessage('');
}
};
return (
<div style={{ padding: '20px', maxWidth: '600px' }}>
<h3 style={{ marginTop: 0 }}>Expression Input with Validation</h3>
<p style={{ fontSize: '14px', opacity: 0.8 }}>Try typing expressions. The input validates in real-time.</p>
<div style={{ marginTop: '20px' }}>
<ExpressionInput
expression={expression}
onChange={handleChange}
hasError={hasError}
errorMessage={errorMessage}
/>
</div>
<div
style={{
marginTop: '20px',
padding: '16px',
backgroundColor: hasError ? '#fee' : '#efe',
borderRadius: '4px',
fontSize: '13px'
}}
>
{hasError ? (
<>
<strong style={{ color: '#c00' }}>Error:</strong> {errorMessage}
</>
) : (
<>
<strong style={{ color: '#080' }}>Valid expression</strong>
</>
)}
</div>
<div style={{ marginTop: '20px', fontSize: '12px' }}>
<h4>Try these examples:</h4>
<ul style={{ lineHeight: '1.8' }}>
<li>
<code
style={{ cursor: 'pointer', textDecoration: 'underline' }}
onClick={() => handleChange('Variables.x + Variables.y')}
>
Variables.x + Variables.y
</code>
</li>
<li>
<code
style={{ cursor: 'pointer', textDecoration: 'underline' }}
onClick={() => handleChange('Variables.count * 2')}
>
Variables.count * 2
</code>
</li>
<li>
<code
style={{ cursor: 'pointer', textDecoration: 'underline' }}
onClick={() => handleChange('Math.max(Variables.a, Variables.b)')}
>
Math.max(Variables.a, Variables.b)
</code>
</li>
<li>
<code
style={{ cursor: 'pointer', textDecoration: 'underline' }}
onClick={() => handleChange('Variables.items.filter(x => x.active).length')}
>
Variables.items.filter(x =&gt; x.active).length
</code>
</li>
<li>
<code
style={{ cursor: 'pointer', textDecoration: 'underline', color: '#c00' }}
onClick={() => handleChange('invalid syntax (')}
>
invalid syntax (
</code>{' '}
<em>(causes error)</em>
</li>
</ul>
</div>
</div>
);
};

View File

@@ -0,0 +1,148 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
import { Tooltip } from '@noodl-core-ui/components/popups/Tooltip';
import { UnsafeStyleProps } from '@noodl-core-ui/types/global';
import css from './ExpressionInput.module.scss';
export interface ExpressionInputProps extends UnsafeStyleProps {
/** The expression string */
expression: string;
/** Callback when expression changes (debounced) */
onChange: (expression: string) => void;
/** Callback when input loses focus */
onBlur?: () => void;
/** Whether the expression has an error */
hasError?: boolean;
/** Error message to show in tooltip */
errorMessage?: string;
/** Placeholder text */
placeholder?: string;
/** Test ID for automation */
testId?: string;
/** Debounce delay in milliseconds */
debounceMs?: number;
}
/**
* ExpressionInput
*
* A specialized input field for entering JavaScript expressions.
* Features monospace font, "fx" badge, and error indication.
*
* @example
* ```tsx
* <ExpressionInput
* expression="Variables.x * 2"
* onChange={(expr) => updateExpression(expr)}
* hasError={false}
* />
* ```
*/
export function ExpressionInput({
expression,
onChange,
onBlur,
hasError = false,
errorMessage,
placeholder = 'Enter expression...',
testId,
debounceMs = 300,
UNSAFE_className,
UNSAFE_style
}: ExpressionInputProps) {
const [localValue, setLocalValue] = useState(expression);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
// Update local value when prop changes
useEffect(() => {
setLocalValue(expression);
}, [expression]);
// Debounced onChange handler
const debouncedOnChange = useCallback(
(value: string) => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
onChange(value);
}, debounceMs);
},
[onChange, debounceMs]
);
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setLocalValue(newValue);
debouncedOnChange(newValue);
};
const handleBlur = () => {
// Cancel debounce and apply immediately on blur
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
if (localValue !== expression) {
onChange(localValue);
}
onBlur?.();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
// Apply immediately on Enter
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
onChange(localValue);
e.currentTarget.blur();
}
};
return (
<div
className={`${css['Root']} ${hasError ? css['HasError'] : ''} ${UNSAFE_className || ''}`}
style={UNSAFE_style}
data-test={testId}
>
<span className={css['Badge']}>fx</span>
<input
type="text"
className={css['Input']}
value={localValue}
onChange={handleChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
placeholder={placeholder}
spellCheck={false}
autoComplete="off"
/>
{hasError && errorMessage && (
<Tooltip content={errorMessage}>
<div className={css['ErrorIndicator']}>
<Icon icon={IconName.WarningCircle} size={IconSize.Tiny} />
</div>
</Tooltip>
)}
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { ExpressionInput } from './ExpressionInput';
export type { ExpressionInputProps } from './ExpressionInput';

View File

@@ -0,0 +1,28 @@
.Root {
display: flex;
align-items: center;
justify-content: center;
}
.ExpressionActive {
background-color: var(--theme-color-primary, #6366f1);
color: white;
&:hover {
background-color: var(--theme-color-primary-hover, #4f46e5);
}
&:active {
background-color: var(--theme-color-primary-active, #4338ca);
}
}
.ConnectionIndicator {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
opacity: 0.5;
cursor: default;
}

View File

@@ -0,0 +1,100 @@
import { Meta, StoryFn } from '@storybook/react';
import React, { useState } from 'react';
import { ExpressionToggle, ExpressionToggleProps } from './ExpressionToggle';
export default {
title: 'Property Panel/Expression Toggle',
component: ExpressionToggle,
argTypes: {
mode: {
control: { type: 'radio' },
options: ['fixed', 'expression']
},
isConnected: {
control: 'boolean'
},
isDisabled: {
control: 'boolean'
}
}
} as Meta<typeof ExpressionToggle>;
const Template: StoryFn<ExpressionToggleProps> = (args) => {
const [mode, setMode] = useState<'fixed' | 'expression'>(args.mode);
const handleToggle = () => {
setMode((prevMode) => (prevMode === 'fixed' ? 'expression' : 'fixed'));
};
return <ExpressionToggle {...args} mode={mode} onToggle={handleToggle} />;
};
export const FixedMode = Template.bind({});
FixedMode.args = {
mode: 'fixed',
isConnected: false,
isDisabled: false
};
export const ExpressionMode = Template.bind({});
ExpressionMode.args = {
mode: 'expression',
isConnected: false,
isDisabled: false
};
export const Connected = Template.bind({});
Connected.args = {
mode: 'fixed',
isConnected: true,
isDisabled: false
};
export const Disabled = Template.bind({});
Disabled.args = {
mode: 'fixed',
isConnected: false,
isDisabled: true
};
export const InteractiveDemo: StoryFn<ExpressionToggleProps> = () => {
const [mode, setMode] = useState<'fixed' | 'expression'>('fixed');
const [isConnected, setIsConnected] = useState(false);
const handleToggle = () => {
setMode((prevMode) => (prevMode === 'fixed' ? 'expression' : 'fixed'));
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', padding: '20px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ width: '120px' }}>Normal Toggle:</span>
<ExpressionToggle mode={mode} isConnected={false} onToggle={handleToggle} />
<span style={{ opacity: 0.6, fontSize: '12px' }}>
Current mode: <strong>{mode}</strong>
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ width: '120px' }}>Connected:</span>
<ExpressionToggle mode={mode} isConnected={true} onToggle={handleToggle} />
<span style={{ opacity: 0.6, fontSize: '12px' }}>Shows connection indicator</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ width: '120px' }}>Disabled:</span>
<ExpressionToggle mode={mode} isConnected={false} isDisabled={true} onToggle={handleToggle} />
<span style={{ opacity: 0.6, fontSize: '12px' }}>Cannot be clicked</span>
</div>
<div style={{ marginTop: '20px', padding: '16px', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
<h4 style={{ margin: '0 0 8px 0' }}>Simulate Connection:</h4>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input type="checkbox" checked={isConnected} onChange={(e) => setIsConnected(e.target.checked)} />
<span>Port is connected via cable</span>
</label>
</div>
</div>
);
};

View File

@@ -0,0 +1,84 @@
import React from 'react';
import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
import { IconButton, IconButtonVariant } from '@noodl-core-ui/components/inputs/IconButton';
import { Tooltip } from '@noodl-core-ui/components/popups/Tooltip';
import { UnsafeStyleProps } from '@noodl-core-ui/types/global';
import css from './ExpressionToggle.module.scss';
export interface ExpressionToggleProps extends UnsafeStyleProps {
/** Current mode: 'fixed' for static values, 'expression' for dynamic expressions */
mode: 'fixed' | 'expression';
/** Whether the port is connected via a cable (disables expression toggle) */
isConnected?: boolean;
/** Callback when toggle is clicked */
onToggle: () => void;
/** Whether the toggle is disabled */
isDisabled?: boolean;
/** Test ID for automation */
testId?: string;
}
/**
* ExpressionToggle
*
* Toggle button that switches a property between fixed value mode and expression mode.
* Shows a connection indicator when the port is connected via cable.
*
* @example
* ```tsx
* <ExpressionToggle
* mode="fixed"
* onToggle={() => setMode(mode === 'fixed' ? 'expression' : 'fixed')}
* />
* ```
*/
export function ExpressionToggle({
mode,
isConnected = false,
onToggle,
isDisabled = false,
testId,
UNSAFE_className,
UNSAFE_style
}: ExpressionToggleProps) {
// If connected via cable, show connection indicator instead of toggle
if (isConnected) {
return (
<Tooltip content="Connected via cable">
<div className={css['ConnectionIndicator']} data-test={testId} style={UNSAFE_style}>
<Icon icon={IconName.Link} size={IconSize.Tiny} />
</div>
</Tooltip>
);
}
const isExpressionMode = mode === 'expression';
const tooltipContent = isExpressionMode ? 'Switch to fixed value' : 'Switch to expression';
const icon = isExpressionMode ? IconName.Code : IconName.MagicWand;
const variant = isExpressionMode ? IconButtonVariant.Default : IconButtonVariant.OpaqueOnHover;
return (
<Tooltip content={tooltipContent}>
<div className={css['Root']} style={UNSAFE_style}>
<IconButton
icon={icon}
size={IconSize.Tiny}
variant={variant}
onClick={onToggle}
isDisabled={isDisabled}
testId={testId}
UNSAFE_className={isExpressionMode ? css['ExpressionActive'] : UNSAFE_className}
/>
</div>
</Tooltip>
);
}

View File

@@ -0,0 +1,2 @@
export { ExpressionToggle } from './ExpressionToggle';
export type { ExpressionToggleProps } from './ExpressionToggle';

View File

@@ -1,16 +1,33 @@
import classNames from 'classnames';
import React, { useMemo } from 'react';
import { ExpressionInput } from '@noodl-core-ui/components/property-panel/ExpressionInput';
import { ExpressionToggle } from '@noodl-core-ui/components/property-panel/ExpressionToggle';
import { PropertyPanelBaseInputProps } from '@noodl-core-ui/components/property-panel/PropertyPanelBaseInput';
import { PropertyPanelButton, PropertyPanelButtonProps } from '@noodl-core-ui/components/property-panel/PropertyPanelButton';
import {
PropertyPanelButton,
PropertyPanelButtonProps
} from '@noodl-core-ui/components/property-panel/PropertyPanelButton';
import { PropertyPanelCheckbox } from '@noodl-core-ui/components/property-panel/PropertyPanelCheckbox';
import { PropertyPanelIconRadioInput, PropertyPanelIconRadioProperties } from '@noodl-core-ui/components/property-panel/PropertyPanelIconRadioInput';
import {
PropertyPanelIconRadioInput,
PropertyPanelIconRadioProperties
} from '@noodl-core-ui/components/property-panel/PropertyPanelIconRadioInput';
import { PropertyPanelLengthUnitInput } from '@noodl-core-ui/components/property-panel/PropertyPanelLengthUnitInput';
import { PropertyPanelNumberInput } from '@noodl-core-ui/components/property-panel/PropertyPanelNumberInput';
import { PropertyPanelSelectInput, PropertyPanelSelectProperties } from '@noodl-core-ui/components/property-panel/PropertyPanelSelectInput';
import { PropertyPanelSliderInput, PropertyPanelSliderInputProps } from '@noodl-core-ui/components/property-panel/PropertyPanelSliderInput';
import {
PropertyPanelSelectInput,
PropertyPanelSelectProperties
} from '@noodl-core-ui/components/property-panel/PropertyPanelSelectInput';
import {
PropertyPanelSliderInput,
PropertyPanelSliderInputProps
} from '@noodl-core-ui/components/property-panel/PropertyPanelSliderInput';
import { PropertyPanelTextInput } from '@noodl-core-ui/components/property-panel/PropertyPanelTextInput';
import { PropertyPanelTextRadioInput, PropertyPanelTextRadioProperties } from '@noodl-core-ui/components/property-panel/PropertyPanelTextRadioInput';
import {
PropertyPanelTextRadioInput,
PropertyPanelTextRadioProperties
} from '@noodl-core-ui/components/property-panel/PropertyPanelTextRadioInput';
import { Slot } from '@noodl-core-ui/types/global';
import css from './PropertyPanelInput.module.scss';
@@ -31,13 +48,32 @@ export enum PropertyPanelInputType {
// SizeMode = 'size-mode',
}
export type PropertyPanelProps = undefined |PropertyPanelIconRadioProperties | PropertyPanelButtonProps["properties"]
| PropertyPanelSliderInputProps ["properties"] | PropertyPanelSelectProperties | PropertyPanelTextRadioProperties
export type PropertyPanelProps =
| undefined
| PropertyPanelIconRadioProperties
| PropertyPanelButtonProps['properties']
| PropertyPanelSliderInputProps['properties']
| PropertyPanelSelectProperties
| PropertyPanelTextRadioProperties;
export interface PropertyPanelInputProps extends Omit<PropertyPanelBaseInputProps, 'type'> {
label: string;
inputType: PropertyPanelInputType;
properties: PropertyPanelProps;
// Expression support
/** Whether this input type supports expression mode (default: true for most types) */
supportsExpression?: boolean;
/** Current mode: 'fixed' for static values, 'expression' for dynamic expressions */
expressionMode?: 'fixed' | 'expression';
/** The expression string (when in expression mode) */
expression?: string;
/** Callback when expression mode changes */
onExpressionModeChange?: (mode: 'fixed' | 'expression') => void;
/** Callback when expression text changes */
onExpressionChange?: (expression: string) => void;
/** Whether the expression has an error */
expressionError?: string;
}
export function PropertyPanelInput({
@@ -47,7 +83,14 @@ export function PropertyPanelInput({
properties,
isChanged,
isConnected,
onChange
onChange,
// Expression props
supportsExpression = true,
expressionMode = 'fixed',
expression = '',
onExpressionModeChange,
onExpressionChange,
expressionError
}: PropertyPanelInputProps) {
const Input = useMemo(() => {
switch (inputType) {
@@ -72,28 +115,62 @@ export function PropertyPanelInput({
}
}, [inputType]);
// Determine if we should show expression UI
const showExpressionToggle = supportsExpression && !isConnected;
const isExpressionMode = expressionMode === 'expression';
// Handle toggle between fixed and expression modes
const handleToggleMode = () => {
if (onExpressionModeChange) {
const newMode = isExpressionMode ? 'fixed' : 'expression';
onExpressionModeChange(newMode);
}
};
// Render the appropriate input based on mode
const renderInput = () => {
if (isExpressionMode && onExpressionChange) {
return (
<ExpressionInput
expression={expression}
onChange={onExpressionChange}
hasError={!!expressionError}
errorMessage={expressionError}
UNSAFE_style={{ flex: 1 }}
/>
);
}
// Standard input rendering
return (
// FIXME: fix below ts-ignore with better typing
// this is caused by PropertyPanelBaseInputProps having a generic for "value"
// i want to pass a boolan to the checkbox value that will be used in checked for a better API
<Input
// @ts-expect-error
value={value}
// @ts-expect-error
onChange={onChange}
// @ts-expect-error
isChanged={isChanged}
// @ts-expect-error
isConnected={isConnected}
// @ts-expect-error
properties={properties}
/>
);
};
return (
<div className={css['Root']}>
<div className={classNames(css['Label'], isChanged && css['is-changed'])}>{label}</div>
<div className={css['InputContainer']}>
{
// FIXME: fix below ts-ignore with better typing
// this is caused by PropertyPanelBaseInputProps having a generic for "value"
// i want to pass a boolan to the checkbox value that will be used in checked for a better API
<Input
// @ts-expect-error
value={value}
// @ts-expect-error
onChange={onChange}
// @ts-expect-error
isChanged={isChanged}
// @ts-expect-error
isConnected={isConnected}
// @ts-expect-error
properties={properties}
/>
}
<div style={{ display: 'flex', gap: '4px', alignItems: 'center', width: '100%' }}>
{renderInput()}
{showExpressionToggle && (
<ExpressionToggle mode={expressionMode} isConnected={isConnected} onToggle={handleToggleMode} />
)}
</div>
</div>
</div>
);