mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
Changed code editor subblock to react-simple-code-editor
This commit is contained in:
@@ -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<string, string> = {
|
||||
'{': '}',
|
||||
'[': ']',
|
||||
'(': ')',
|
||||
'"': '"',
|
||||
"'": "'",
|
||||
'`': '`',
|
||||
}
|
||||
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<any[]>(() => {
|
||||
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<SelectionState | null>(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<HTMLTextAreaElement>) => {
|
||||
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<HTMLTextAreaElement>) => {
|
||||
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<HTMLDivElement>(null)
|
||||
const contentWrapperRef = useRef<HTMLDivElement>(null)
|
||||
const [contentWidth, setContentWidth] = useState(0)
|
||||
const [maxLineWidth, setMaxLineWidth] = useState(0)
|
||||
const [containerWidth, setContainerWidth] = useState(0)
|
||||
const containerRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
|
||||
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<string | null>(
|
||||
null
|
||||
)
|
||||
const editorRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleAutoClose = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// 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<HTMLTextAreaElement>) => {
|
||||
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<HTMLTextAreaElement>) => {
|
||||
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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'font-mono text-sm border rounded-md overflow-hidden relative',
|
||||
'font-mono text-sm border rounded-md overflow-visible relative',
|
||||
'bg-background text-muted-foreground',
|
||||
isConnecting && 'ring-2 ring-blue-500 ring-offset-2'
|
||||
)}
|
||||
onWheel={handleScroll}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="absolute top-0 left-0 z-50 h-full bg-background">
|
||||
{lines.map((_, i) => (
|
||||
<LineNumber key={`line-${i}`} number={i + 1} />
|
||||
{/* Line numbers */}
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 w-[30px] bg-muted/30 flex flex-col items-end pr-3 pt-3 select-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{Array.from({ length: lineCount }, (_, i) => (
|
||||
<div
|
||||
key={i + 1}
|
||||
className="text-xs text-muted-foreground leading-[21px]"
|
||||
>
|
||||
{i + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div ref={scrollContainerRef} className="relative h-full z-10">
|
||||
<div
|
||||
className="overflow-auto scrollbar-hide h-full"
|
||||
onScroll={handleContainerScroll}
|
||||
style={{
|
||||
maxWidth: `${Math.max(
|
||||
maxLineWidth + 35,
|
||||
contentWidth,
|
||||
containerWidth
|
||||
)}px`,
|
||||
<div ref={editorRef} className="pl-[30px] pt-0 mt-0 relative">
|
||||
<Editor
|
||||
value={code}
|
||||
onValueChange={(newCode) => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div ref={contentWrapperRef} className="w-fit">
|
||||
{lines.map((line, i) => (
|
||||
<div
|
||||
key={line.id}
|
||||
className="flex pl-8 w-fit"
|
||||
style={{
|
||||
width: `${Math.max(
|
||||
maxLineWidth + 35,
|
||||
contentWidth,
|
||||
containerWidth
|
||||
)}px`,
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
ref={(el) => {
|
||||
textareaRefs.current[i] = el
|
||||
}}
|
||||
rows={1}
|
||||
value={line.content}
|
||||
onChange={(e) => handleChange(i, e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setCurrentLine(i)}
|
||||
onDrop={(e) => handleDrop(i, e)}
|
||||
onDragOver={handleDragOver}
|
||||
className={cn(
|
||||
textareaClassName,
|
||||
'overflow-hidden w-full',
|
||||
selection &&
|
||||
i >= selection.start &&
|
||||
i <= selection.end &&
|
||||
'bg-muted/30'
|
||||
)}
|
||||
style={textareaStyle}
|
||||
wrap="off"
|
||||
onPaste={handlePaste}
|
||||
/>
|
||||
<div className="absolute inset-0 pointer-events-none px-3 py-1 whitespace-pre text-sm">
|
||||
{formatDisplayText(line.content)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
highlight={(code) =>
|
||||
highlight(code, languages.javascript, 'javascript')
|
||||
}
|
||||
padding={12}
|
||||
style={{
|
||||
fontFamily: 'inherit',
|
||||
minHeight: '46px',
|
||||
lineHeight: '21px',
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
textareaClassName="focus:outline-none focus:ring-0 bg-transparent"
|
||||
/>
|
||||
|
||||
{showTags && (
|
||||
<TagDropdown
|
||||
visible={showTags}
|
||||
onSelect={handleTagSelect}
|
||||
blockId={blockId}
|
||||
activeSourceBlockId={activeSourceBlockId}
|
||||
inputValue={code}
|
||||
cursorPosition={cursorPosition}
|
||||
onClose={() => {
|
||||
setShowTags(false)
|
||||
setActiveSourceBlockId(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style jsx global>{`
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LineNumber({ number }: { number: number }) {
|
||||
return (
|
||||
<div
|
||||
className="w-8 flex-none py-2 px-3 text-right text-muted-foreground/50 select-none border-r flex items-center justify-end"
|
||||
style={{ height: `${LINE_HEIGHT}px` }}
|
||||
>
|
||||
{number}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Generated
+29
@@ -27,8 +27,10 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.469.0",
|
||||
"next": "15.1.3",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"reactflow": "^11.11.4",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
@@ -37,6 +39,7 @@
|
||||
"@jest/globals": "^29.7.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^20",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"jest": "^29.7.0",
|
||||
@@ -3460,6 +3463,13 @@
|
||||
"undici-types": "~6.19.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prismjs": {
|
||||
"version": "1.26.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz",
|
||||
"integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz",
|
||||
@@ -7164,6 +7174,15 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/prismjs": {
|
||||
"version": "1.29.0",
|
||||
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz",
|
||||
"integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/prompts": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
||||
@@ -7294,6 +7313,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-simple-code-editor": {
|
||||
"version": "0.14.1",
|
||||
"resolved": "https://registry.npmjs.org/react-simple-code-editor/-/react-simple-code-editor-0.14.1.tgz",
|
||||
"integrity": "sha512-BR5DtNRy+AswWJECyA17qhUDvrrCZ6zXOCfkQY5zSmb96BVUbpVAv03WpcjcwtCwiLbIANx3gebHOcXYn1EHow==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-style-singleton": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||
|
||||
@@ -30,8 +30,10 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.469.0",
|
||||
"next": "15.1.3",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"reactflow": "^11.11.4",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
@@ -40,6 +42,7 @@
|
||||
"@jest/globals": "^29.7.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^20",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"jest": "^29.7.0",
|
||||
|
||||
Reference in New Issue
Block a user