231 lines
5.8 KiB
Svelte
231 lines
5.8 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from "svelte";
|
|
import {
|
|
EditorView,
|
|
keymap,
|
|
lineNumbers,
|
|
highlightActiveLine,
|
|
} from "@codemirror/view";
|
|
import { EditorState, Compartment } from "@codemirror/state";
|
|
import {
|
|
defaultKeymap,
|
|
history,
|
|
historyKeymap,
|
|
indentWithTab,
|
|
} from "@codemirror/commands";
|
|
import {
|
|
bracketMatching,
|
|
indentOnInput,
|
|
Language,
|
|
StreamLanguage,
|
|
} from "@codemirror/language";
|
|
import { toml } from "@codemirror/legacy-modes/mode/toml";
|
|
import {
|
|
autocompletion,
|
|
closeBrackets,
|
|
closeBracketsKeymap,
|
|
} from "@codemirror/autocomplete";
|
|
import { lintGutter, setDiagnostics } from "@codemirror/lint";
|
|
import { LSPClient, languageServerExtensions } from "@codemirror/lsp-client";
|
|
|
|
import { typstCompletions } from "$lib/ts/completions";
|
|
import { editorTheme } from "$lib/ts/editor-theme";
|
|
import { LspBridge } from "$lib/ts/lsp";
|
|
import { app } from "$lib/ts/state.svelte";
|
|
import type { Diagnostic } from "$lib/ts/api";
|
|
|
|
interface Props {
|
|
content: string;
|
|
filePath: string;
|
|
targetPath: string;
|
|
enableLsp?: boolean;
|
|
diagnostics?: Diagnostic[];
|
|
onchange: (value: string) => void;
|
|
onsave: () => void;
|
|
onlspstatus?: (status: "off" | "starting" | "on" | "unavailable") => void;
|
|
onready?: (view: EditorView | null) => void;
|
|
}
|
|
|
|
let {
|
|
content,
|
|
filePath,
|
|
targetPath,
|
|
enableLsp = true,
|
|
diagnostics = [],
|
|
onchange,
|
|
onsave,
|
|
onlspstatus,
|
|
onready,
|
|
}: Props = $props();
|
|
|
|
let host: HTMLDivElement;
|
|
let view: EditorView | null = null;
|
|
|
|
const languageSlot = new Compartment();
|
|
const lspSlot = new Compartment();
|
|
const themeSlot = new Compartment();
|
|
|
|
const bridge = new LspBridge();
|
|
let client: LSPClient | null = null;
|
|
|
|
const isToml = $derived(filePath.toLowerCase().endsWith(".toml"));
|
|
|
|
async function loadTypstLanguage() {
|
|
const { typst, TypstParser, typstHighlight } = await import(
|
|
"codemirror-lang-typst"
|
|
);
|
|
const support = typst();
|
|
const parser = new (TypstParser as any)(typstHighlight);
|
|
return new Language(
|
|
support.language.data,
|
|
parser,
|
|
[parser.updateListener()],
|
|
"typst",
|
|
);
|
|
}
|
|
|
|
async function connectLsp() {
|
|
if (!enableLsp || isToml || !view) return;
|
|
|
|
onlspstatus?.("starting");
|
|
try {
|
|
const handle = await bridge.start(targetPath, () => {
|
|
onlspstatus?.("off");
|
|
});
|
|
|
|
client = new LSPClient({
|
|
rootUri: handle.root_uri,
|
|
timeout: 10000,
|
|
extensions: languageServerExtensions(),
|
|
}).connect(bridge.transport);
|
|
|
|
const documentUri = `${handle.root_uri}/${filePath}`;
|
|
view.dispatch({
|
|
effects: lspSlot.reconfigure(client.plugin(documentUri, "typst")),
|
|
});
|
|
onlspstatus?.("on");
|
|
} catch {
|
|
onlspstatus?.("unavailable");
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
view = new EditorView({
|
|
parent: host,
|
|
state: EditorState.create({
|
|
doc: content,
|
|
extensions: [
|
|
lineNumbers(),
|
|
lintGutter(),
|
|
history(),
|
|
bracketMatching(),
|
|
closeBrackets(),
|
|
indentOnInput(),
|
|
highlightActiveLine(),
|
|
languageSlot.of(isToml ? StreamLanguage.define(toml) : []),
|
|
lspSlot.of([]),
|
|
themeSlot.of(editorTheme(app.theme === "dark")),
|
|
...(isToml
|
|
? []
|
|
: [autocompletion({ override: [typstCompletions] })]),
|
|
EditorView.lineWrapping,
|
|
keymap.of([
|
|
{
|
|
key: "Mod-s",
|
|
preventDefault: true,
|
|
run: () => {
|
|
onsave();
|
|
return true;
|
|
},
|
|
},
|
|
...closeBracketsKeymap,
|
|
...defaultKeymap,
|
|
...historyKeymap,
|
|
indentWithTab,
|
|
]),
|
|
EditorView.updateListener.of((update) => {
|
|
if (update.docChanged) onchange(update.state.doc.toString());
|
|
}),
|
|
],
|
|
}),
|
|
});
|
|
|
|
if (!isToml) {
|
|
loadTypstLanguage()
|
|
.then((language) => {
|
|
view?.dispatch({ effects: languageSlot.reconfigure(language) });
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
onready?.(view);
|
|
|
|
connectLsp();
|
|
});
|
|
|
|
onDestroy(() => {
|
|
onready?.(null);
|
|
bridge.stop();
|
|
view?.destroy();
|
|
});
|
|
|
|
function laterDispatch(build: (current: EditorView) => void) {
|
|
queueMicrotask(() => {
|
|
if (!view) return;
|
|
build(view);
|
|
});
|
|
}
|
|
|
|
$effect(() => {
|
|
const next = content;
|
|
if (!view) return;
|
|
if (view.state.doc.toString() === next) return;
|
|
|
|
laterDispatch((current) => {
|
|
if (current.state.doc.toString() === next) return;
|
|
current.dispatch({
|
|
changes: { from: 0, to: current.state.doc.length, insert: next },
|
|
});
|
|
});
|
|
});
|
|
|
|
$effect(() => {
|
|
const isDark = app.theme === "dark";
|
|
laterDispatch((current) => {
|
|
current.dispatch({ effects: themeSlot.reconfigure(editorTheme(isDark)) });
|
|
});
|
|
});
|
|
|
|
$effect(() => {
|
|
const entries = diagnostics;
|
|
if (!view) return;
|
|
|
|
laterDispatch((current) => {
|
|
const doc = current.state.doc;
|
|
const marks = entries
|
|
.filter((entry) => entry.line !== null)
|
|
.map((entry) => {
|
|
const line = doc.line(
|
|
Math.min(Math.max(entry.line ?? 1, 1), doc.lines),
|
|
);
|
|
const from = Math.min(
|
|
line.from + Math.max((entry.column ?? 1) - 1, 0),
|
|
line.to,
|
|
);
|
|
return {
|
|
from,
|
|
to: line.to,
|
|
severity: entry.severity.includes("warn")
|
|
? ("warning" as const)
|
|
: ("error" as const),
|
|
message: entry.message,
|
|
};
|
|
});
|
|
|
|
current.dispatch(setDiagnostics(current.state, marks));
|
|
});
|
|
});
|
|
</script>
|
|
|
|
<div class="h-full overflow-hidden bg-[var(--color-surface)]" bind:this={host}></div>
|