From 47b498437645399fadbc55791af417400da927e6 Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Tue, 4 Feb 2025 15:23:29 -0800 Subject: [PATCH] Changed code editor subblock to react-simple-code-editor --- .../components/sub-block/components/code.tsx | 830 +++--------------- package-lock.json | 29 + package.json | 3 + 3 files changed, 152 insertions(+), 710 deletions(-) diff --git a/app/w/[id]/components/workflow-block/components/sub-block/components/code.tsx b/app/w/[id]/components/workflow-block/components/sub-block/components/code.tsx index f428b4b063..0eed7691f0 100644 --- a/app/w/[id]/components/workflow-block/components/sub-block/components/code.tsx +++ b/app/w/[id]/components/workflow-block/components/sub-block/components/code.tsx @@ -1,31 +1,11 @@ -import { useState, useRef, useEffect, useCallback, useMemo } from 'react' +import { useState, useRef, useEffect } from 'react' +import Editor from 'react-simple-code-editor' +import { highlight, languages } from 'prismjs' +import 'prismjs/components/prism-javascript' +import 'prismjs/themes/prism.css' import { cn } from '@/lib/utils' import { useSubBlockValue } from '../hooks/use-sub-block-value' -import { formatDisplayText } from '@/components/ui/formatted-text' - -interface CodeLine { - id: string - content: string -} - -const INITIAL_LINES = 1 -const TAB_SIZE = 4 -const TAB_SPACES = ' '.repeat(TAB_SIZE) -const LINE_HEIGHT = 32 - -interface SelectionState { - start: number - end: number -} - -const MATCHING_PAIRS: Record = { - '{': '}', - '[': ']', - '(': ')', - '"': '"', - "'": "'", - '`': '`', -} +import { TagDropdown, checkTagTrigger } from '@/components/ui/tag-dropdown' interface CodeProps { blockId: string @@ -33,717 +13,147 @@ interface CodeProps { isConnecting: boolean } -function useCodeLines(blockId: string, subBlockId: string) { - const [value, setValue] = useSubBlockValue(blockId, subBlockId) - - // Initialize lines from store value or create initial state - const [lines, setLines] = useState(() => { - if (Array.isArray(value)) { - return value - } - return Array(INITIAL_LINES) - .fill(null) - .map(() => ({ - id: crypto.randomUUID(), - content: '', - })) - }) - - // Update store whenever lines change - useEffect(() => { - setValue(lines) - }, [lines, setValue]) - - const [currentLine, setCurrentLine] = useState(0) - const [selection, setSelection] = useState(null) - - const handleChange = useCallback((lineIndex: number, value: string) => { - setLines((prevLines) => { - const newLines = [...prevLines] - newLines[lineIndex] = { ...newLines[lineIndex], content: value } - - if ( - lineIndex === newLines.length - 1 && - value !== '' && - value.length > prevLines[lineIndex].content.length - ) { - newLines.push({ id: crypto.randomUUID(), content: '' }) - } - - return newLines - }) - }, []) - - const handleDrop = useCallback( - (lineIndex: number, e: React.DragEvent) => { - e.preventDefault() - try { - const data = JSON.parse(e.dataTransfer.getData('application/json')) - - const isValidConnectionBlock = - data.type === 'connectionBlock' && - data.connectionData.sourceBlockId === blockId - - if (!isValidConnectionBlock) return - - const textarea = e.currentTarget - const start = textarea.selectionStart - const end = textarea.selectionEnd - const currentContent = lines[lineIndex].content - - const connectionName = data.connectionData.name - .replace(/\s+/g, '') - .toLowerCase() - const outputSuffix = - data.connectionData.outputType === 'any' - ? 'res' - : data.connectionData.outputType - - const tag = `<${connectionName}.${outputSuffix}>` - const newContent = - currentContent.substring(0, start) + - tag + - currentContent.substring(end) - - setLines((prevLines) => { - const newLines = [...prevLines] - newLines[lineIndex] = { - ...newLines[lineIndex], - content: newContent, - } - return newLines - }) - - // Set cursor position after the inserted tag - setTimeout(() => { - textarea.selectionStart = textarea.selectionEnd = start + tag.length - }, 0) - } catch (error) { - console.error('Failed to parse drop data:', error) - } - }, - [blockId, lines, setLines] - ) - - const handleDragOver = useCallback( - (e: React.DragEvent) => { - e.preventDefault() // Required to allow drops - }, - [] - ) - - return { - lines, - setLines, - currentLine, - setCurrentLine, - handleChange, - selection, - setSelection, - handleDrop, - handleDragOver, - } -} - export function Code({ blockId, subBlockId, isConnecting }: CodeProps) { - const { - lines, - setLines, - currentLine, - setCurrentLine, - handleChange, - selection, - setSelection, - handleDrop, - handleDragOver, - } = useCodeLines(blockId, subBlockId) - const textareaRefs = useRef<(HTMLTextAreaElement | null)[]>([]) - const scrollContainerRef = useRef(null) - const contentWrapperRef = useRef(null) - const [contentWidth, setContentWidth] = useState(0) - const [maxLineWidth, setMaxLineWidth] = useState(0) - const [containerWidth, setContainerWidth] = useState(0) - const containerRef = useRef(null) - - useEffect(() => { - if (!contentWrapperRef.current) return - - const resizeObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - setContentWidth(entry.contentRect.width) - } - }) - - resizeObserver.observe(contentWrapperRef.current) - return () => resizeObserver.disconnect() - }, []) - - useEffect(() => { - if (!containerRef.current) return - - const resizeObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - setContainerWidth(entry.contentRect.width) - } - }) - - resizeObserver.observe(containerRef.current) - return () => resizeObserver.disconnect() - }, []) - - // Update scroll handler to sync textareas with container - const handleContainerScroll = useCallback( - (e: React.UIEvent) => { - const scrollLeft = e.currentTarget.scrollLeft - textareaRefs.current.forEach((textarea) => { - if (textarea) { - textarea.scrollLeft = scrollLeft - } - }) - }, - [] + const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) + const [code, setCode] = useState('') + const [lineCount, setLineCount] = useState(1) + const [showTags, setShowTags] = useState(false) + const [cursorPosition, setCursorPosition] = useState(0) + const [activeSourceBlockId, setActiveSourceBlockId] = useState( + null ) + const editorRef = useRef(null) - const handleAutoClose = useCallback( - (e: React.KeyboardEvent) => { - // If we're typing a closing character (not an opening one) - if (Object.values(MATCHING_PAIRS).includes(e.key)) { - const textarea = e.currentTarget - const start = textarea.selectionStart - const currentContent = lines[currentLine].content - - // If cursor is before the same closing character, just move cursor - if (currentContent[start] === e.key) { - e.preventDefault() - setTimeout(() => { - textarea.selectionStart = textarea.selectionEnd = start + 1 - }, 0) - return true - } - } - - // Handle opening characters - const closingChar = MATCHING_PAIRS[e.key] - if (!closingChar) return false - - e.preventDefault() - const textarea = e.currentTarget - const start = textarea.selectionStart - const end = textarea.selectionEnd - const currentContent = lines[currentLine].content - - // Check if cursor is before a matching closing character - if (start === end && currentContent[start] === closingChar) { - // Just move the cursor past the existing closing character - setTimeout(() => { - textarea.selectionStart = textarea.selectionEnd = start + 1 - }, 0) - return true - } - - // Handle selected text - wrap it in brackets/quotes - if (start !== end) { - const selectedText = currentContent.substring(start, end) - const newContent = - currentContent.substring(0, start) + - e.key + - selectedText + - closingChar + - currentContent.substring(end) - - const newLines = [...lines] - newLines[currentLine] = { - ...newLines[currentLine], - content: newContent, - } - setLines(newLines) - - // Place cursor after the selected text but before closing character - setTimeout(() => { - textarea.selectionStart = textarea.selectionEnd = end + 1 - }, 0) - return true - } - - // No selection - add opening and closing characters - const newContent = - currentContent.substring(0, start) + - e.key + - closingChar + - currentContent.substring(end) - - const newLines = [...lines] - newLines[currentLine] = { - ...newLines[currentLine], - content: newContent, - } - setLines(newLines) - - // Place cursor between the characters - setTimeout(() => { - textarea.selectionStart = textarea.selectionEnd = start + 1 - }, 0) - return true - }, - [lines, currentLine, setLines] - ) - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (handleAutoClose(e)) { - return + // Sync code with store value on initial load and when store value changes + useEffect(() => { + if (storeValue !== null) { + setCode(storeValue.toString()) } + }, [storeValue]) - if (e.metaKey && e.key === 'a') { - e.preventDefault() - setSelection({ start: 0, end: lines.length - 1 }) - return - } + // Update line count when code changes + useEffect(() => { + const lines = code.split('\n').length + setLineCount(lines) + }, [code]) - // Handle selection deletion - if (e.key === 'Backspace' && selection) { - e.preventDefault() - const newLines = [ - ...lines.slice(0, selection.start), - { id: crypto.randomUUID(), content: '' }, - ...lines.slice(selection.end + 1), - ] - setLines(newLines) - setSelection(null) - setCurrentLine(selection.start) + // Handle drops from connection blocks + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + try { + const data = JSON.parse(e.dataTransfer.getData('application/json')) + if (data.type !== 'connectionBlock') return - // Focus the remaining line + // Get current cursor position from the textarea + const textarea = editorRef.current?.querySelector('textarea') + const dropPosition = textarea?.selectionStart ?? code.length + + // Insert '<' at drop position to trigger the dropdown + const newValue = + code.slice(0, dropPosition) + '<' + code.slice(dropPosition) + + setCode(newValue) + setStoreValue(newValue) + setCursorPosition(dropPosition + 1) + setShowTags(true) + + if (data.connectionData?.sourceBlockId) { + setActiveSourceBlockId(data.connectionData.sourceBlockId) + } + + // Set cursor position after state updates setTimeout(() => { - const textarea = textareaRefs.current[selection.start] if (textarea) { + textarea.selectionStart = dropPosition + 1 + textarea.selectionEnd = dropPosition + 1 textarea.focus() - textarea.selectionStart = textarea.selectionEnd = 0 } }, 0) - return - } - - // Clear selection when typing other keys - if (selection) { - setSelection(null) - } - - if (e.key === 'Backspace') { - const textarea = e.currentTarget - const start = textarea.selectionStart - const end = textarea.selectionEnd - - // Handle line deletion when cursor is at start of line - if (start === 0 && end === 0 && currentLine > 0) { - e.preventDefault() - - const previousLine = lines[currentLine - 1] - const currentLineContent = lines[currentLine].content - const previousLineLength = previousLine.content.length - - // Merge current line with previous line - const newLines = [...lines] - newLines[currentLine - 1] = { - ...previousLine, - content: previousLine.content + currentLineContent, - } - newLines.splice(currentLine, 1) - - setLines(newLines) - setCurrentLine(currentLine - 1) - - // Set cursor position at the merge point - setTimeout(() => { - const textarea = textareaRefs.current[currentLine - 1] - if (textarea) { - textarea.focus() - textarea.selectionStart = textarea.selectionEnd = previousLineLength - } - }, 0) - return - } - - // Handle matching pairs deletion - if (start === end) { - const currentContent = lines[currentLine].content - const charBeforeCursor = currentContent[start - 1] - const charAfterCursor = currentContent[start] - - if (MATCHING_PAIRS[charBeforeCursor] === charAfterCursor) { - e.preventDefault() - const newContent = - currentContent.substring(0, start - 1) + - currentContent.substring(start + 1) - - const newLines = [...lines] - newLines[currentLine] = { - ...newLines[currentLine], - content: newContent, - } - setLines(newLines) - - setTimeout(() => { - textarea.selectionStart = textarea.selectionEnd = start - 1 - }, 0) - return - } - } - } - - if (e.key === 'ArrowUp' && currentLine > 0) { - e.preventDefault() - const newLine = currentLine - 1 - const currentPosition = e.currentTarget.selectionStart - setCurrentLine(newLine) - - setTimeout(() => { - const textarea = textareaRefs.current[newLine] - if (textarea) { - textarea.focus() - textarea.selectionStart = textarea.selectionEnd = Math.min( - currentPosition, - lines[newLine].content.length - ) - } - }, 0) - } else if (e.key === 'ArrowDown' && currentLine < lines.length - 1) { - e.preventDefault() - const newLine = currentLine + 1 - const currentPosition = e.currentTarget.selectionStart - setCurrentLine(newLine) - - setTimeout(() => { - const textarea = textareaRefs.current[newLine] - if (textarea) { - textarea.focus() - textarea.selectionStart = textarea.selectionEnd = Math.min( - currentPosition, - lines[newLine].content.length - ) - } - }, 0) - } else if (e.key === 'Tab') { - e.preventDefault() - const textarea = e.currentTarget - const start = textarea.selectionStart - const end = textarea.selectionEnd - - // Insert tab at cursor position - const newLines = [...lines] - newLines[currentLine] = { - ...newLines[currentLine], - content: - newLines[currentLine].content.substring(0, start) + - ' ' + - newLines[currentLine].content.substring(end), - } - setLines(newLines) - - // Move cursor after tab - setTimeout(() => { - textarea.selectionStart = textarea.selectionEnd = start + 4 - }, 0) - } else if (e.key === 'Enter') { - e.preventDefault() - const textarea = e.currentTarget - const start = textarea.selectionStart - - // Get the indentation level of the current line - const currentIndentation = - lines[currentLine].content.match(/^\s*/)?.[0] || '' - - // Check if cursor is between braces - const currentContent = lines[currentLine].content - const charBeforeCursor = currentContent[start - 1] - const charAfterCursor = currentContent[start] - const isBetweenBraces = - charBeforeCursor === '{' && charAfterCursor === '}' - - if (isBetweenBraces) { - // Split the content and create three lines - const beforeCursor = currentContent.substring(0, start) - const afterCursor = currentContent.substring(start) - - const newLines = [...lines] - // Update current line to only have content before cursor - newLines[currentLine] = { - ...newLines[currentLine], - content: beforeCursor, - } - - // Insert new indented line - newLines.splice(currentLine + 1, 0, { - id: crypto.randomUUID(), - content: currentIndentation + TAB_SPACES, // Add extra indentation - }) - - // Insert closing brace line - newLines.splice(currentLine + 2, 0, { - id: crypto.randomUUID(), - content: currentIndentation + afterCursor, - }) - - setLines(newLines) - setCurrentLine(currentLine + 1) - - // Focus the indented line - setTimeout(() => { - const nextTextarea = textareaRefs.current[currentLine + 1] - if (nextTextarea) { - nextTextarea.focus() - nextTextarea.selectionStart = nextTextarea.selectionEnd = - currentIndentation.length + TAB_SIZE - } - }, 0) - } else { - // Regular new line behavior with indentation preservation - const newLines = [...lines] - const currentContent = lines[currentLine].content - newLines[currentLine] = { - ...newLines[currentLine], - content: currentContent.substring(0, start), - } - - // Insert new line after current line - newLines.splice(currentLine + 1, 0, { - id: crypto.randomUUID(), - content: currentIndentation + currentContent.substring(start), - }) - - setLines(newLines) - setCurrentLine(currentLine + 1) - - // Set cursor position at the start of the new line after indentation - setTimeout(() => { - const nextTextarea = textareaRefs.current[currentLine + 1] - if (nextTextarea) { - nextTextarea.focus() - nextTextarea.selectionStart = nextTextarea.selectionEnd = - currentIndentation.length - } - }, 0) - } + } catch (error) { + console.error('Failed to parse drop data:', error) } } - const handlePaste = useCallback( - (e: React.ClipboardEvent) => { - e.preventDefault() - const pastedText = e.clipboardData.getData('text') - const pastedLines = pastedText.split('\n') - - // If there's a selection, replace the selected lines - if (selection) { - const newLines = [ - ...lines.slice(0, selection.start), - ...pastedLines.map((content) => ({ - id: crypto.randomUUID(), - content, - })), - ...lines.slice(selection.end + 1), - ] - setLines(newLines) - setSelection(null) - setCurrentLine(selection.start) - - // Focus the first line of pasted content - setTimeout(() => { - const textarea = textareaRefs.current[selection.start] - if (textarea) { - textarea.focus() - textarea.selectionStart = textarea.selectionEnd = 0 - } - }, 0) - return - } - - // Handle paste at current cursor position - const textarea = textareaRefs.current[currentLine] - if (!textarea) return - - const cursorPos = textarea.selectionStart - const currentContent = lines[currentLine].content - - // Split current line content at cursor - const beforeCursor = currentContent.substring(0, cursorPos) - const afterCursor = currentContent.substring(textarea.selectionEnd) - - // Create new lines array - const newLines = [ - ...lines.slice(0, currentLine), - // First line combines with content before cursor - { id: crypto.randomUUID(), content: beforeCursor + pastedLines[0] }, - // Middle lines (if any) - ...pastedLines.slice(1, -1).map((content) => ({ - id: crypto.randomUUID(), - content, - })), - // Last line combines with content after cursor - { - id: crypto.randomUUID(), - content: pastedLines[pastedLines.length - 1] + afterCursor, - }, - ...lines.slice(currentLine + 1), - ] - - setLines(newLines) - const newCurrentLine = currentLine + pastedLines.length - 1 - setCurrentLine(newCurrentLine) - - // Focus the last line of pasted content - setTimeout(() => { - const newTextarea = textareaRefs.current[newCurrentLine] - if (newTextarea) { - newTextarea.focus() - newTextarea.selectionStart = newTextarea.selectionEnd = - pastedLines[pastedLines.length - 1].length - } - }, 0) - }, - [lines, currentLine, selection, setLines, setCurrentLine, setSelection] - ) - - const textareaClassName = useMemo( - () => - cn( - 'w-full resize-none bg-transparent px-3 border-0', - 'focus:outline-none focus:ring-0', - 'text-muted-foreground placeholder:text-muted-foreground/50', - 'leading-none flex items-center', - 'whitespace-pre', - 'allow-scroll', - selection && 'selection:bg-primary/20' - ), - [selection] - ) - - const textareaStyle = useMemo( - () => ({ - height: `${LINE_HEIGHT}px`, - minHeight: `${LINE_HEIGHT}px`, - paddingTop: '8px', - paddingBottom: '8px', - margin: 0, - }), - [] - ) - - // Add handler to prevent scroll propagation - const handleScroll = useCallback((e: React.WheelEvent) => { - e.stopPropagation() - }, []) - - // Add effect to measure the longest line - useEffect(() => { - const measureText = (text: string) => { - const canvas = document.createElement('canvas') - const context = canvas.getContext('2d') - if (!context) return 0 - - context.font = '14px monospace' - const metrics = context.measureText(text) - return metrics.width - } - - const maxWidth = lines.reduce((max, line) => { - const width = measureText(line.content) - return Math.max(max, width) - }, 0) - - setMaxLineWidth(maxWidth + 30) - }, [lines]) + // Handle tag selection + const handleTagSelect = (newValue: string) => { + setCode(newValue) + setStoreValue(newValue) + setShowTags(false) + setActiveSourceBlockId(null) + } return (
e.preventDefault()} + onDrop={handleDrop} > -
- {lines.map((_, i) => ( - + {/* Line numbers */} + -
-
+ { + setCode(newCode) + setStoreValue(newCode) + + // Check for tag trigger + const textarea = editorRef.current?.querySelector('textarea') + if (textarea) { + const pos = textarea.selectionStart + setCursorPosition(pos) + const trigger = checkTagTrigger(newCode, pos) + setShowTags(trigger.show) + if (!trigger.show) { + setActiveSourceBlockId(null) + } + } }} - > -
- {lines.map((line, i) => ( -
-