114 lines
3.4 KiB
TypeScript
114 lines
3.4 KiB
TypeScript
/**
|
|
* Shared code editor with Nunjucks support by default.
|
|
* Uses react-simple-code-editor + Prism + prism-react-renderer; highlights base language + {{ }}, {% %}, {# #}.
|
|
* Wherever this editor is used, it provides the same features (syntax highlighting + Nunjucks).
|
|
* The parent (node) provides the expected base language for highlighting.
|
|
*/
|
|
import React, { useCallback, useLayoutEffect, useRef } from 'react'
|
|
import Editor from 'react-simple-code-editor'
|
|
import { highlight, type HighlightLanguage } from '@/lib/syntaxHighlight'
|
|
|
|
export type CodeEditorProps = {
|
|
value: string
|
|
onValueChange: (value: string) => void
|
|
/** Base language for syntax highlighting (Nunjucks is always applied on top). */
|
|
language: HighlightLanguage
|
|
/** Stable id for the underlying textarea (for insert-at-cursor). */
|
|
textareaId?: string
|
|
readOnly?: boolean
|
|
placeholder?: string
|
|
padding?: number
|
|
tabSize?: number
|
|
insertSpaces?: boolean
|
|
ignoreTabKey?: boolean
|
|
style?: React.CSSProperties
|
|
className?: string
|
|
textareaClassName?: string
|
|
preClassName?: string
|
|
/** Minimum height so the container doesn't collapse before resize. */
|
|
minHeight?: number
|
|
}
|
|
|
|
const defaultStyle: React.CSSProperties = {
|
|
fontFamily: 'ui-monospace, monospace',
|
|
fontSize: 12,
|
|
lineHeight: 1.5,
|
|
overflow: 'auto',
|
|
}
|
|
|
|
export function CodeEditor({
|
|
value,
|
|
onValueChange,
|
|
language,
|
|
textareaId,
|
|
readOnly = false,
|
|
padding = 8,
|
|
tabSize = 2,
|
|
insertSpaces = true,
|
|
ignoreTabKey = false,
|
|
style,
|
|
className,
|
|
textareaClassName,
|
|
preClassName,
|
|
minHeight = 120,
|
|
}: CodeEditorProps) {
|
|
const containerRef = useRef<HTMLDivElement | null>(null)
|
|
const selectionRestoreRef = useRef<{ start: number; end: number } | null>(null)
|
|
|
|
const highlightCode = useCallback(
|
|
(code: string) => highlight(code, language),
|
|
[language]
|
|
)
|
|
|
|
const handleValueChange = useCallback(
|
|
(newValue: string) => {
|
|
if (readOnly) {
|
|
onValueChange(newValue)
|
|
return
|
|
}
|
|
const ta =
|
|
(textareaId ? document.getElementById(textareaId) : containerRef.current?.querySelector('textarea')) as HTMLTextAreaElement | null
|
|
const start = ta?.selectionStart ?? newValue.length
|
|
const end = ta?.selectionEnd ?? newValue.length
|
|
selectionRestoreRef.current = { start, end }
|
|
onValueChange(newValue)
|
|
},
|
|
[onValueChange, readOnly, textareaId]
|
|
)
|
|
|
|
useLayoutEffect(() => {
|
|
const pending = selectionRestoreRef.current
|
|
if (pending == null) return
|
|
selectionRestoreRef.current = null
|
|
const el = (textareaId
|
|
? document.getElementById(textareaId)
|
|
: containerRef.current?.querySelector('textarea')) as HTMLTextAreaElement | null
|
|
if (el) {
|
|
const { start, end } = pending
|
|
const safeEnd = Math.min(end, el.value.length)
|
|
const safeStart = Math.min(start, safeEnd)
|
|
el.setSelectionRange(safeStart, safeEnd)
|
|
}
|
|
}, [value, textareaId])
|
|
|
|
return (
|
|
<div ref={containerRef} className="code-editor min-h-0 h-full overflow-auto" style={{ minHeight: 0 }}>
|
|
<Editor
|
|
value={value}
|
|
onValueChange={handleValueChange}
|
|
highlight={highlightCode}
|
|
tabSize={tabSize}
|
|
insertSpaces={insertSpaces}
|
|
ignoreTabKey={ignoreTabKey}
|
|
padding={padding}
|
|
readOnly={readOnly}
|
|
textareaId={textareaId}
|
|
style={{ ...defaultStyle, minHeight, ...style }}
|
|
className={className}
|
|
textareaClassName={textareaClassName}
|
|
preClassName={preClassName}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|