Files
typst-desktop/src/lib/components/Editor.svelte
T
2026-07-18 15:00:22 -04:00

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>