1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
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>
|