summaryrefslogtreecommitdiff
path: root/frontend/src/components/CodeEditor.svelte
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/components/CodeEditor.svelte')
-rw-r--r--frontend/src/components/CodeEditor.svelte104
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>