2026-03-06 15:03:23 -06:00
|
|
|
<script lang="ts">
|
|
|
|
|
import '$lib/utils/codemirror';
|
|
|
|
|
import { basicSetup, EditorView } from 'codemirror';
|
|
|
|
|
import { keymap } from '@codemirror/view';
|
|
|
|
|
import { Compartment, EditorState } from '@codemirror/state';
|
|
|
|
|
import { indentWithTab } from '@codemirror/commands';
|
|
|
|
|
import { indentUnit, LanguageDescription } from '@codemirror/language';
|
|
|
|
|
import { languages } from '@codemirror/language-data';
|
|
|
|
|
import { oneDark } from '@codemirror/theme-one-dark';
|
|
|
|
|
import { onMount, onDestroy } from 'svelte';
|
|
|
|
|
|
|
|
|
|
export let value = '';
|
|
|
|
|
export let filePath: string | null = null;
|
|
|
|
|
export let onSave: ((content: string) => Promise<void>) | null = null;
|
2026-08-22 09:13:13 -04:00
|
|
|
export let searchTarget: {
|
|
|
|
|
line: number;
|
|
|
|
|
column: number;
|
|
|
|
|
length: number;
|
|
|
|
|
requestId: number;
|
|
|
|
|
} | null = null;
|
2026-03-06 15:03:23 -06:00
|
|
|
|
|
|
|
|
let container: HTMLDivElement;
|
|
|
|
|
let editor: EditorView | null = null;
|
|
|
|
|
let editorTheme = new Compartment();
|
|
|
|
|
let editorLanguage = new Compartment();
|
|
|
|
|
let internalValue = '';
|
2026-08-22 09:13:13 -04:00
|
|
|
let lastSearchTargetRequestId = 0;
|
2026-03-06 15:03:23 -06:00
|
|
|
|
|
|
|
|
/** Return the current editor content */
|
|
|
|
|
export const getValue = (): string => {
|
|
|
|
|
return editor?.state.doc.toString() ?? value;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** Replace editor content */
|
|
|
|
|
export const setValue = (newValue: string) => {
|
|
|
|
|
if (!editor) return;
|
|
|
|
|
internalValue = newValue;
|
|
|
|
|
editor.dispatch({
|
|
|
|
|
changes: { from: 0, to: editor.state.doc.length, insert: newValue }
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const focus = () => {
|
|
|
|
|
editor?.focus();
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-22 09:13:13 -04:00
|
|
|
const revealSearchTarget = () => {
|
|
|
|
|
if (!editor || !searchTarget || searchTarget.requestId === lastSearchTargetRequestId) return;
|
|
|
|
|
lastSearchTargetRequestId = searchTarget.requestId;
|
|
|
|
|
const lineNumber = Math.min(Math.max(searchTarget.line, 1), editor.state.doc.lines);
|
|
|
|
|
const line = editor.state.doc.line(lineNumber);
|
|
|
|
|
const from = line.from + Math.min(Math.max(searchTarget.column - 1, 0), line.length);
|
|
|
|
|
const to = Math.min(from + Math.max(searchTarget.length, 1), line.to);
|
|
|
|
|
editor.dispatch({
|
|
|
|
|
selection: { anchor: from, head: to },
|
|
|
|
|
effects: EditorView.scrollIntoView(from, { y: 'center' })
|
|
|
|
|
});
|
|
|
|
|
editor.focus();
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-06 15:03:23 -06:00
|
|
|
const detectLanguage = async (path: string | null) => {
|
|
|
|
|
if (!path) return;
|
|
|
|
|
const match = LanguageDescription.matchFilename(languages, path);
|
|
|
|
|
if (match) {
|
|
|
|
|
const lang = await match.load();
|
|
|
|
|
if (lang && editor) {
|
|
|
|
|
editor.dispatch({ effects: editorLanguage.reconfigure(lang) });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// React to external value changes (e.g. switching files)
|
|
|
|
|
$: if (editor && value !== internalValue) {
|
|
|
|
|
internalValue = value;
|
|
|
|
|
editor.dispatch({
|
|
|
|
|
changes: { from: 0, to: editor.state.doc.length, insert: value }
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// React to filePath changes for language detection
|
|
|
|
|
$: if (editor && filePath) {
|
|
|
|
|
detectLanguage(filePath);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-22 09:13:13 -04:00
|
|
|
$: if (editor && searchTarget) {
|
|
|
|
|
revealSearchTarget();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 15:03:23 -06:00
|
|
|
onMount(() => {
|
|
|
|
|
const isDark = document.documentElement.classList.contains('dark');
|
|
|
|
|
internalValue = value;
|
|
|
|
|
|
|
|
|
|
const extensions = [
|
|
|
|
|
basicSetup,
|
|
|
|
|
keymap.of([
|
|
|
|
|
indentWithTab,
|
|
|
|
|
{
|
|
|
|
|
key: 'Mod-s',
|
|
|
|
|
run: () => {
|
|
|
|
|
if (onSave) {
|
|
|
|
|
onSave(editor?.state.doc.toString() ?? '');
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
]),
|
|
|
|
|
indentUnit.of(' '),
|
|
|
|
|
EditorView.updateListener.of((e) => {
|
|
|
|
|
if (e.docChanged) {
|
|
|
|
|
internalValue = e.state.doc.toString();
|
|
|
|
|
value = internalValue;
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
editorTheme.of(isDark ? oneDark : []),
|
|
|
|
|
editorLanguage.of([]),
|
|
|
|
|
EditorView.theme({
|
|
|
|
|
'&': { fontSize: '0.75rem', height: '100%' },
|
|
|
|
|
'.cm-content': {
|
|
|
|
|
padding: '0.5rem 0',
|
|
|
|
|
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace'
|
|
|
|
|
},
|
|
|
|
|
'.cm-scroller': { overflow: 'auto' },
|
|
|
|
|
'.cm-focused': { outline: 'none' }
|
|
|
|
|
})
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
editor = new EditorView({
|
|
|
|
|
state: EditorState.create({ doc: value, extensions }),
|
|
|
|
|
parent: container
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
detectLanguage(filePath);
|
2026-08-22 09:13:13 -04:00
|
|
|
revealSearchTarget();
|
2026-03-06 15:03:23 -06:00
|
|
|
|
|
|
|
|
// Watch dark mode
|
|
|
|
|
const observer = new MutationObserver(() => {
|
|
|
|
|
const dark = document.documentElement.classList.contains('dark');
|
|
|
|
|
editor?.dispatch({ effects: editorTheme.reconfigure(dark ? oneDark : []) });
|
|
|
|
|
});
|
|
|
|
|
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
observer.disconnect();
|
|
|
|
|
editor?.destroy();
|
|
|
|
|
editor = null;
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
onDestroy(() => {
|
|
|
|
|
editor?.destroy();
|
|
|
|
|
editor = null;
|
|
|
|
|
});
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<div bind:this={container} class="file-code-editor" />
|
|
|
|
|
|
|
|
|
|
<style>
|
|
|
|
|
.file-code-editor {
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
}
|
|
|
|
|
.file-code-editor :global(.cm-editor) {
|
|
|
|
|
height: 100%;
|
|
|
|
|
}
|
|
|
|
|
</style>
|