diff options
Diffstat (limited to 'frontend/src/components/CodeEditor.svelte')
| -rw-r--r-- | frontend/src/components/CodeEditor.svelte | 104 |
1 files changed, 104 insertions, 0 deletions
diff --git a/frontend/src/components/CodeEditor.svelte b/frontend/src/components/CodeEditor.svelte new file mode 100644 index 0000000..544a6a4 --- /dev/null +++ b/frontend/src/components/CodeEditor.svelte @@ -0,0 +1,104 @@ +<script lang="ts"> + import { onMount, onDestroy } from 'svelte'; + import { EditorView, keymap, drawSelection } from '@codemirror/view'; + import { EditorState, StateEffect } from '@codemirror/state'; + import { python } from '@codemirror/lang-python'; + import { indentWithTab } from '@codemirror/commands'; + import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; + + interface Props { + value: string; + onInput: (v: string) => void; + height?: number; + readOnly?: boolean; + label?: string; + } + let { value, onInput, height = 420, readOnly = false, label }: Props = $props(); + + let host: HTMLDivElement; + let view: EditorView | null = null; + let applyingExternal = false; + // svelte-ignore state_referenced_locally + let handler = onInput; + $effect(() => { + handler = onInput; + }); + + const setDoc = StateEffect.define<string>(); + + onMount(() => { + view = new EditorView({ + parent: host, + state: EditorState.create({ + doc: value ?? '', + extensions: [ + readOnly ? EditorView.editable.of(false) : [], + history(), + drawSelection(), + keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]), + python(), + EditorView.lineWrapping, + EditorView.theme({ + '&': { + fontSize: '13px', + background: 'var(--surface)', + color: 'var(--text)', + fontFamily: + "ui-monospace, 'SF Mono', Menlo, Consolas, 'Noto Sans Mono CJK SC', monospace" + }, + '.cm-content': { padding: '10px 0', minHeight: `${height}px` }, + '.cm-scroller': { lineHeight: '1.55' }, + '&.cm-focused': { outline: 'none' }, + '.cm-gutters': { + background: 'var(--surface)', + border: 'none', + color: 'var(--text-3)' + }, + '.cm-activeLine': { background: 'var(--surface-2)' }, + '.cm-activeLineGutter': { background: 'var(--surface-2)' } + }), + EditorView.updateListener.of((u) => { + if (applyingExternal) return; + if (u.docChanged) handler(u.state.doc.toString()); + }) + ] + }) + }); + return () => view?.destroy(); + }); + + $effect(() => { + if (!view) return; + const current = view.state.doc.toString(); + if (value !== current) { + applyingExternal = true; + view.dispatch({ changes: { from: 0, to: current.length, insert: value ?? '' } }); + applyingExternal = false; + } + }); + + $effect(() => { + if (view) { + if (readOnly) view.contentDOM.setAttribute('aria-readonly', 'true'); + else view.contentDOM.removeAttribute('aria-readonly'); + } + }); + + onDestroy(() => view?.destroy()); +</script> + +<div> + {#if label}<span class="hint editor-label">{label}</span>{/if} + <div bind:this={host} class="cm-host"></div> +</div> + +<style> + .cm-host { + border: 1px solid var(--border-strong); + border-radius: var(--radius); + overflow: hidden; + background: var(--surface); + } + .cm-host :global(.cm-editor) { min-height: 60px; } + .editor-label { display: block; margin-bottom: 0.25rem; } +</style> |
