Add Typst Desktop app
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user