mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-12 07:12:54 +01:00
new code editor
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
import type { StorybookConfig } from '@storybook/react-webpack5';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import type { StorybookConfig } from '@storybook/react-webpack5';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const editorDir = path.join(__dirname, '../../noodl-editor');
|
||||
const coreLibDir = path.join(__dirname, '../');
|
||||
@@ -40,7 +44,7 @@ const config: StorybookConfig = {
|
||||
test: /\.ts$/,
|
||||
use: [
|
||||
{
|
||||
loader: require.resolve('ts-loader')
|
||||
loader: 'ts-loader'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
@@ -34,7 +34,16 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"classnames": "^2.5.1"
|
||||
"@codemirror/autocomplete": "^6.20.0",
|
||||
"@codemirror/commands": "^6.10.1",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/language": "^6.12.1",
|
||||
"@codemirror/lint": "^6.9.2",
|
||||
"@codemirror/search": "^6.5.11",
|
||||
"@codemirror/state": "^6.5.3",
|
||||
"@codemirror/view": "^6.39.9",
|
||||
"classnames": "^2.5.1",
|
||||
"prismjs": "^1.30.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@noodl/platform": "file:../noodl-platform",
|
||||
@@ -50,6 +59,7 @@
|
||||
"@storybook/react-webpack5": "^8.6.14",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/node": "^16.11.42",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"babel-plugin-named-exports-order": "^0.0.2",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
)
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
14
packages/noodl-core-ui/src/components/code-editor/index.ts
Normal file
14
packages/noodl-core-ui/src/components/code-editor/index.ts
Normal 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';
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 => 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ExpressionInput } from './ExpressionInput';
|
||||
export type { ExpressionInputProps } from './ExpressionInput';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ExpressionToggle } from './ExpressionToggle';
|
||||
export type { ExpressionToggleProps } from './ExpressionToggle';
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user