/** @jsxImportSource preact */ import {useEffect, useRef, useState} from 'preact/hooks'; import {loadTextEditorMonacoRuntime} from './text-editor-panel-monaco-runtime'; import {useEffectiveWidgetPanelThemeMode} from './widget-containers'; import type {WidgetPanel, WidgetPanelTheme} from './widget-containers'; import type {editor as EditorNamespace, IDisposable} from 'monaco-editor'; import type {JSX} from 'preact'; /** Text-editor widget panel configuration. */ export type TextEditorPanelProps = { /** Stable panel id used by parent containers. */ id: string; /** Visible heading text for the panel. */ title: string; /** Controlled editor text. If supplied, callers own the current value. */ value?: string; /** Uncontrolled initial editor text used when `value` is not supplied. */ defaultValue?: string; /** Called when user edits the current document text. */ onValueChange?: (nextValue: string) => void; /** Monaco language mode used by the document model. */ language?: 'json' | 'plaintext'; /** JSON schema used to drive diagnostics and completion in JSON mode. */ jsonSchema?: Record; /** If true, prevent editing while still showing Monaco viewer chrome. */ readOnly?: boolean; /** Optional placeholder shown while the document is empty. */ placeholder?: string; /** Optional class name applied to the outer panel content wrapper. */ className?: string; /** Optional theme override applied to this panel subtree. */ theme?: WidgetPanelTheme; /** Monaco theme id used when the effective panel theme resolves to light mode. */ lightMonacoTheme?: string; /** Monaco theme id used when the effective panel theme resolves to dark mode. */ darkMonacoTheme?: string; }; /** * A widget panel that lazily loads Monaco and edits one text document. */ export class TextEditorPanel implements WidgetPanel { id: string; title: string; theme?: WidgetPanelTheme; content: JSX.Element; constructor(props: TextEditorPanelProps) { this.id = props.id; this.title = props.title; this.theme = props.theme ?? 'inherit'; this.content = ; } } /** * Internal runtime state for the asynchronous Monaco loader. */ type TextEditorLoadState = | {status: 'loading'} | {status: 'ready'; runtime: Awaited>} | {status: 'error'; error: Error}; /** * Renders the Monaco-backed editor body used by the widget panel. */ function TextEditorPanelContent({ id, value, defaultValue = '', onValueChange, language = 'json', jsonSchema, readOnly = false, placeholder, className, lightMonacoTheme = 'vs', darkMonacoTheme = 'vs-dark' }: TextEditorPanelProps) { const hostElementRef = useRef(null); const editorRef = useRef(null); const modelRef = useRef(null); const modelChangeSubscriptionRef = useRef(null); const isApplyingExternalValueRef = useRef(false); const isControlledRef = useRef(value !== undefined); const resolvedValueRef = useRef(resolveInitialEditorValue(value, defaultValue)); const languageRef = useRef(language); const onValueChangeRef = useRef(onValueChange); const [loadState, setLoadState] = useState({status: 'loading'}); const [displayValue, setDisplayValue] = useState(() => resolveInitialEditorValue(value, defaultValue) ); const isControlled = value !== undefined; const resolvedValue = isControlled ? value : displayValue; const runtime = loadState.status === 'ready' ? loadState.runtime : undefined; const themeMode = useEffectiveWidgetPanelThemeMode(); useEffect(() => { isControlledRef.current = isControlled; resolvedValueRef.current = resolvedValue; languageRef.current = language; onValueChangeRef.current = onValueChange; }, [isControlled, language, onValueChange, resolvedValue]); useEffect(() => { let isDisposed = false; loadTextEditorMonacoRuntime() .then((loadedRuntime) => { if (isDisposed) { return; } setLoadState({status: 'ready', runtime: loadedRuntime}); }) .catch((error) => { if (isDisposed) { return; } setLoadState({ status: 'error', error: error instanceof Error ? error : new Error(String(error)) }); }); return () => { isDisposed = true; }; }, []); useEffect(() => { if (!runtime || !hostElementRef.current || editorRef.current) { return undefined; } const {monaco} = runtime; const modelUri = monaco.Uri.parse(getTextEditorModelUri(id)); const existingModel = monaco.editor.getModel(modelUri); const model = existingModel ?? monaco.editor.createModel( resolvedValueRef.current, getTextEditorLanguageId(languageRef.current), modelUri ); if (existingModel && existingModel.getValue() !== resolvedValueRef.current) { model.setValue(resolvedValueRef.current); } modelRef.current = model; monaco.editor.setTheme(getMonacoThemeId(themeMode, lightMonacoTheme, darkMonacoTheme)); editorRef.current = monaco.editor.create(hostElementRef.current, { ...TEXT_EDITOR_MONACO_OPTIONS, model, readOnly }); setDisplayValue(model.getValue()); modelChangeSubscriptionRef.current = model.onDidChangeContent(() => { if (isApplyingExternalValueRef.current) { return; } const nextValue = model.getValue(); setDisplayValue(nextValue); onValueChangeRef.current?.(nextValue); if (isControlledRef.current) { return; } }); return () => { modelChangeSubscriptionRef.current?.dispose(); modelChangeSubscriptionRef.current = null; editorRef.current?.dispose(); editorRef.current = null; runtime.clearJsonSchema(model.uri.toString()); model.dispose(); modelRef.current = null; }; }, [darkMonacoTheme, id, lightMonacoTheme, runtime, themeMode]); useEffect(() => { if (!runtime) { return; } runtime.monaco.editor.setTheme(getMonacoThemeId(themeMode, lightMonacoTheme, darkMonacoTheme)); }, [darkMonacoTheme, lightMonacoTheme, runtime, themeMode]); useEffect(() => { const editor = editorRef.current; if (!editor) { return; } editor.updateOptions({ ...TEXT_EDITOR_MONACO_OPTIONS, readOnly }); }, [readOnly]); useEffect(() => { const model = modelRef.current; if (!model || !runtime) { return undefined; } runtime.monaco.editor.setModelLanguage(model, getTextEditorLanguageId(language)); return undefined; }, [language, runtime]); useEffect(() => { const model = modelRef.current; if (!model || !runtime) { return () => {}; } const modelUri = model.uri.toString(); if (language === 'json') { runtime.configureJsonSchema(modelUri, jsonSchema); return () => { runtime.clearJsonSchema(modelUri); }; } runtime.clearJsonSchema(modelUri); return () => {}; }, [jsonSchema, language, runtime]); useEffect(() => { const model = modelRef.current; if (!model) { return; } if (model.getValue() === resolvedValue) { setDisplayValue(resolvedValue); return; } isApplyingExternalValueRef.current = true; model.setValue(resolvedValue); isApplyingExternalValueRef.current = false; setDisplayValue(resolvedValue); }, [resolvedValue]); return (
{loadState.status === 'loading' ? (
Loading editor…
) : null} {loadState.status === 'error' ? (
Failed to load editor: {loadState.error.message}
) : null} {loadState.status === 'ready' ? (
{placeholder && displayValue.length === 0 ? (
{placeholder}
) : null}
) : null}
); } /** * Returns the initial editor value based on controlled or uncontrolled props. */ function resolveInitialEditorValue(value: string | undefined, defaultValue: string): string { return value ?? defaultValue; } /** * Returns the stable in-memory Monaco model URI for one panel id. */ function getTextEditorModelUri(id: string): string { return `inmemory://deck-gl-community/widgets/${encodeURIComponent(id)}`; } /** * Maps public panel language names onto Monaco language ids. */ function getTextEditorLanguageId(language: TextEditorPanelProps['language']): string { if (language === 'plaintext') { return 'plaintext'; } return 'json'; } /** * Maps the effective widget panel theme mode onto a Monaco editor theme id. */ function getMonacoThemeId( themeMode: 'light' | 'dark', lightMonacoTheme: string, darkMonacoTheme: string ): string { return themeMode === 'dark' ? darkMonacoTheme : lightMonacoTheme; } const TEXT_EDITOR_MONACO_OPTIONS: EditorNamespace.IStandaloneEditorConstructionOptions = { automaticLayout: true, minimap: {enabled: false}, scrollbar: { verticalScrollbarSize: 10, horizontalScrollbarSize: 10 }, fontSize: 12, lineHeight: 18, wordWrap: 'on', lineNumbers: 'on', glyphMargin: false, folding: false, scrollBeyondLastLine: false, tabSize: 2 }; const TEXT_EDITOR_PANEL_STYLE: JSX.CSSProperties = { display: 'flex', flexDirection: 'column', minHeight: '280px' }; const TEXT_EDITOR_SURFACE_STYLE: JSX.CSSProperties = { position: 'relative', minHeight: '280px', border: '1px solid var(--menu-border, rgba(148, 163, 184, 0.35))', borderRadius: 'var(--button-corner-radius, 8px)', overflow: 'hidden', background: 'var(--menu-background, #fff)' }; const TEXT_EDITOR_HOST_STYLE: JSX.CSSProperties = { width: '100%', minHeight: '280px' }; const TEXT_EDITOR_STATUS_STYLE: JSX.CSSProperties = { ...TEXT_EDITOR_SURFACE_STYLE, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--menu-text, rgb(24, 24, 26))', fontSize: '12px', padding: '16px' }; const TEXT_EDITOR_ERROR_STYLE: JSX.CSSProperties = { ...TEXT_EDITOR_STATUS_STYLE, color: 'rgb(185, 28, 28)' }; const TEXT_EDITOR_PLACEHOLDER_STYLE: JSX.CSSProperties = { position: 'absolute', top: '12px', left: '14px', right: '14px', color: 'var(--button-icon-idle, rgb(100, 116, 139))', fontSize: '12px', pointerEvents: 'none', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' };