"use client"; import { indentSelection, redo, redoDepth, undo, undoDepth } from "@codemirror/commands"; import type { EditorView as EditorViewType, ViewUpdate } from "@codemirror/view"; import { EditorView } from "@codemirror/view"; import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Badge, Collapse, Group, Input, Loader, Tabs, Text, useComputedColorScheme } from "@mantine/core"; import { IconCheck } from "@tabler/icons-react"; import { EditorDiagnostics } from "./code-editor-diagnostics"; import { CodeEditorToolbar } from "./code-editor-toolbar"; import type { EditorDiagnostic } from "./analyzer"; import type { CustomWidgetCodeEditorProps, CustomWidgetEditorMessages, CustomWidgetSchemaReferenceData, } from "./code-editor-types"; import classes from "./code-editor.module.css"; export type { CustomWidgetCodeEditorProps, CustomWidgetEditorMessages, CustomWidgetSchemaReferenceData, } from "./code-editor-types"; const EMPTY_DIAGNOSTICS: NonNullable = []; const EMPTY_COMPLETIONS: NonNullable = []; const LazyCodeMirror = lazy(() => import("./direct-code-mirror")); export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) { const editorViewRef = useRef(null); const diagnostics = props.diagnostics ?? EMPTY_DIAGNOSTICS; const [editorReady, setEditorReady] = useState(false); const [copied, setCopied] = useState(false); const [referenceOpened, setReferenceOpened] = useState(false); const [cursor, setCursor] = useState({ line: 1, column: 1 }); const [editorCreated, setEditorCreated] = useState(false); const [historyDepth, setHistoryDepth] = useState({ undo: 0, redo: 0 }); const colorScheme = useComputedColorScheme("light"); useEffect(() => { setEditorReady(true); }, []); useEffect(() => { const editorView = editorViewRef.current; if (!editorView || !props.revealText) return; const from = editorView.state.doc.toString().indexOf(props.revealText); if (from < 0) return; editorView.dispatch({ selection: { anchor: from, head: from + props.revealText.length }, effects: EditorView.scrollIntoView(from, { y: "center" }), }); editorView.focus(); }, [editorCreated, props.revealKey, props.revealText]); useEffect(() => { const editorView = editorViewRef.current; if (!editorView || !props.insertText) return; const selection = editorView.state.selection.main; editorView.dispatch({ changes: { from: selection.from, to: selection.to, insert: props.insertText }, selection: { anchor: selection.from + props.insertText.length }, scrollIntoView: true, }); editorView.focus(); }, [editorCreated, props.insertKey, props.insertText]); const handleCreateEditor = useCallback((view: EditorViewType) => { editorViewRef.current = view; window.setTimeout(() => { if (editorViewRef.current === view) setEditorCreated(true); }, 0); }, []); const handleDestroyEditor = useCallback((view: EditorViewType) => { if (editorViewRef.current !== view) return; editorViewRef.current = null; setEditorCreated(false); setCursor({ line: 1, column: 1 }); setHistoryDepth({ undo: 0, redo: 0 }); }, []); const handleUndo = useCallback(() => { const editorView = editorViewRef.current; if (!editorView) return; undo(editorView); editorView.focus(); }, []); const handleRedo = useCallback(() => { const editorView = editorViewRef.current; if (!editorView) return; redo(editorView); editorView.focus(); }, []); const handleUpdate = useCallback((update: ViewUpdate) => { if (!update.selectionSet && !update.docChanged) return; const head = update.state.selection.main.head; const line = update.state.doc.lineAt(head); setCursor({ line: line.number, column: head - line.from + 1 }); setHistoryDepth({ undo: undoDepth(update.state), redo: redoDepth(update.state) }); }, []); const selectDiagnostic = useCallback((diagnostic: EditorDiagnostic) => { const editorView = editorViewRef.current; if (!editorView || diagnostic.index === undefined) return; const anchor = Math.min(diagnostic.index, editorView.state.doc.length); editorView.dispatch({ selection: { anchor }, scrollIntoView: true }); editorView.focus(); }, []); const formattedValue = useMemo(() => formatCode(props.value, props.language), [props.language, props.value]); const errorCount = diagnostics.filter(({ severity }) => severity === "error").length; const warningCount = diagnostics.length - errorCount; const isCharacterLimitExceeded = props.maxLength !== undefined && props.value.length > props.maxLength; const isEditorInvalid = Boolean(props.error) || errorCount > 0 || isCharacterLimitExceeded; const copy = async () => { try { await navigator.clipboard.writeText(props.value); setCopied(true); window.setTimeout(() => setCopied(false), 2_000); } catch { setCopied(false); } }; return (
setReferenceOpened((value) => !value)} onCopy={() => void copy()} onUndo={handleUndo} onRedo={handleRedo} onFormat={() => { const editorView = editorViewRef.current; if (props.language === "jsx" && editorView) { const selection = editorView.state.selection.main; editorView.dispatch({ selection: { anchor: 0, head: editorView.state.doc.length } }); indentSelection(editorView); const documentLength = editorView.state.doc.length; editorView.dispatch({ selection: { anchor: Math.min(selection.anchor, documentLength), head: Math.min(selection.head, documentLength), }, }); } else { props.onChange(formattedValue); } }} /> {props.reference && ( )}
{editorReady ? ( }> undefined : props.onChange} onCreateEditor={handleCreateEditor} onDestroyEditor={handleDestroyEditor} onUpdate={handleUpdate} /> ) : ( )}
{errorCount > 0 ? ( {props.messages.errors(errorCount)} ) : warningCount > 0 ? ( {props.messages.warnings(warningCount)} ) : ( {props.messages.ready} )} {props.messages.position(cursor)} {props.messages.characters(props.value.length, props.maxLength)}
); } export function ReadOnlyCustomWidgetCode(props: Omit) { return undefined} />; } export function CustomWidgetSchemaReference({ id, reference, messages, }: { id: string; reference: CustomWidgetSchemaReferenceData; messages: CustomWidgetEditorMessages; }) { const values = { schema: reference.schema, minimal: reference.minimal, full: reference.full }; const labels = { schema: messages.schemaTab, minimal: messages.minimalTab, full: messages.fullTab }; return ( {messages.schemaTab} {messages.minimalTab} {messages.fullTab} {Object.entries(values).map(([key, value]) => ( ))} ); } function EditorLoader() { return ( ); } function formatCode(value: string, language: "jsx" | "json" | "css") { if (language === "json") { try { return `${JSON.stringify(JSON.parse(value) as unknown, null, 2)}\n`; } catch { return value; } } return value .replace(/\r\n?/gu, "\n") .split("\n") .map((line) => line.trimEnd()) .join("\n") .trim(); }