refactor: replace dual-layer textarea with Monaco Editor for script body

The transparent-textarea-over-highlighted-overlay approach had
persistent cursor alignment issues. Replace with Monaco Editor
(already used in tree editor Code Mode) using built-in PowerShell
language support and our existing dark theme. Gives proper syntax
highlighting, cursor positioning, line numbers, and editing for free.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-03-14 19:41:49 -04:00
parent e5899d81c4
commit 50fd7d06da

View File

@@ -1,5 +1,7 @@
import { useRef, useCallback } from 'react'
import { PowerShellHighlighter } from '@/components/scripts/PowerShellHighlighter'
import { useCallback } from 'react'
import Editor, { type BeforeMount } from '@monaco-editor/react'
import { resolutionFlowTheme, THEME_ID } from '@/components/tree-editor/code-mode/resolutionFlowTheme'
import { Spinner } from '@/components/common/Spinner'
interface Props {
value: string
@@ -8,50 +10,43 @@ interface Props {
}
export function ScriptBodyEditor({ value, onChange, disabled }: Props) {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const overlayRef = useRef<HTMLDivElement>(null)
const handleScroll = useCallback(() => {
if (textareaRef.current && overlayRef.current) {
overlayRef.current.scrollTop = textareaRef.current.scrollTop
overlayRef.current.scrollLeft = textareaRef.current.scrollLeft
}
const handleBeforeMount: BeforeMount = useCallback((monaco) => {
// Register our dark theme if not already defined
monaco.editor.defineTheme(THEME_ID, resolutionFlowTheme)
}, [])
const handleTab = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Tab') {
e.preventDefault()
const ta = e.currentTarget
const start = ta.selectionStart
const end = ta.selectionEnd
const newValue = value.substring(0, start) + ' ' + value.substring(end)
onChange(newValue)
// Restore cursor position after React re-render
requestAnimationFrame(() => {
ta.selectionStart = ta.selectionEnd = start + 4
})
}
}, [value, onChange])
return (
<div className="relative rounded-xl border border-border overflow-hidden">
{/* Highlighted overlay (read-only visual layer) — scroll synced to textarea */}
<div ref={overlayRef} className="absolute inset-0 pointer-events-none overflow-hidden p-4">
<PowerShellHighlighter script={value || ' '} className="font-label text-sm leading-[21px] whitespace-pre m-0 p-0 border-0" />
</div>
{/* Editable textarea (transparent text, visible caret) */}
<textarea
ref={textareaRef}
<div className="rounded-xl border border-border overflow-hidden">
<Editor
height="300px"
language="powershell"
theme={THEME_ID}
value={value}
onChange={e => onChange(e.target.value)}
onScroll={handleScroll}
onKeyDown={handleTab}
disabled={disabled}
wrap="off"
spellCheck={false}
className="relative z-10 w-full min-h-[300px] resize-y font-label text-sm leading-[21px] bg-transparent text-transparent caret-foreground p-4 whitespace-pre overflow-auto focus:outline-none focus:ring-1 focus:ring-[rgba(6,182,212,0.2)] disabled:cursor-not-allowed disabled:opacity-50"
placeholder="# Enter your PowerShell script here…&#10;# Use {{ param_name }} for parameter placeholders"
onChange={v => onChange(v ?? '')}
beforeMount={handleBeforeMount}
loading={
<div className="flex h-[300px] items-center justify-center bg-card">
<Spinner size="sm" className="h-6 w-6 border-t-foreground" />
</div>
}
options={{
minimap: { enabled: false },
fontSize: 13,
fontFamily: "'JetBrains Mono', monospace",
lineNumbers: 'on',
wordWrap: 'on',
scrollBeyondLastLine: false,
renderLineHighlight: 'line',
tabSize: 4,
insertSpaces: true,
automaticLayout: true,
readOnly: disabled,
padding: { top: 12, bottom: 12 },
scrollbar: {
verticalScrollbarSize: 8,
horizontalScrollbarSize: 8,
},
}}
/>
</div>
)