Add Typst Desktop app
This commit is contained in:
+73
@@ -0,0 +1,73 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-muted: #f6f7f9;
|
||||
--color-surface-sunken: #eceef1;
|
||||
--color-line: #dfe2e7;
|
||||
--color-ink: #14161a;
|
||||
--color-ink-muted: #5f6672;
|
||||
--color-accent: #3b6cf6;
|
||||
--color-accent-soft: #e8eefe;
|
||||
--color-danger: #d5382f;
|
||||
--color-success: #1f8a4c;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--color-surface: #16181d;
|
||||
--color-surface-muted: #1d2026;
|
||||
--color-surface-sunken: #24282f;
|
||||
--color-line: #2f343d;
|
||||
--color-ink: #eef0f4;
|
||||
--color-ink-muted: #969ead;
|
||||
--color-accent: #6b93ff;
|
||||
--color-accent-soft: #1e2a45;
|
||||
--color-danger: #f4736a;
|
||||
--color-success: #4cc47f;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-surface-muted);
|
||||
color: var(--color-ink);
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.scroll-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-line) transparent;
|
||||
}
|
||||
|
||||
.cm-editor {
|
||||
height: 100%;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cm-editor .cm-scroller {
|
||||
font-family: ui-monospace, "SF Mono", "JetBrains Mono", monospace;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.cm-editor.cm-focused {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.preview-page svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Typst Desktop</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,177 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import Modal from "./Modal.svelte";
|
||||
import * as api from "$lib/ts/api";
|
||||
import type { Asset } from "$lib/ts/api";
|
||||
import { pickFiles } from "$lib/ts/import";
|
||||
|
||||
interface Props {
|
||||
oninsert?: (snippet: string) => void;
|
||||
onchanged?: () => void;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { oninsert, onchanged, onclose }: Props = $props();
|
||||
|
||||
let assets = $state<Asset[]>([]);
|
||||
let busy = $state(false);
|
||||
let error = $state("");
|
||||
let copied = $state<string | null>(null);
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
assets = await api.listAssets();
|
||||
} catch (caught) {
|
||||
error = api.errorMessage(caught);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
refresh();
|
||||
});
|
||||
|
||||
async function importFiles() {
|
||||
const sources = await pickFiles("assets");
|
||||
if (sources.length === 0) return;
|
||||
|
||||
busy = true;
|
||||
error = "";
|
||||
try {
|
||||
await api.importAssets(sources);
|
||||
await refresh();
|
||||
onchanged?.();
|
||||
} catch (caught) {
|
||||
error = api.errorMessage(caught);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(asset: Asset) {
|
||||
try {
|
||||
await api.deleteAsset(asset.name);
|
||||
await refresh();
|
||||
onchanged?.();
|
||||
} catch (caught) {
|
||||
error = api.errorMessage(caught);
|
||||
}
|
||||
}
|
||||
|
||||
function insert(asset: Asset) {
|
||||
if (asset.kind === "image") {
|
||||
oninsert?.(`#image("${asset.name}")`);
|
||||
} else if (asset.font_families.length > 0) {
|
||||
oninsert?.(`#set text(font: "${asset.font_families[0]}")`);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyFamily(family: string) {
|
||||
await navigator.clipboard.writeText(family);
|
||||
copied = family;
|
||||
setTimeout(() => (copied = null), 1200);
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const iconFor: Record<Asset["kind"], string> = {
|
||||
image: "ph:image",
|
||||
font: "ph:text-aa",
|
||||
file: "ph:file",
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal title="Images and fonts" icon="ph:images" width="max-w-2xl" {onclose}>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-xs text-[var(--color-ink-muted)]">
|
||||
Files imported here are available to every project. Reference an image by
|
||||
its file name, and a font by its family name.
|
||||
</p>
|
||||
|
||||
{#if error}
|
||||
<p class="text-xs text-[var(--color-danger)]">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if assets.length === 0}
|
||||
<div
|
||||
class="flex flex-col items-center gap-3 rounded-lg border border-dashed border-[var(--color-line)] px-4 py-10 text-center"
|
||||
>
|
||||
<Icon icon="ph:image-square" class="text-4xl text-[var(--color-ink-muted)]" />
|
||||
<p class="text-xs text-[var(--color-ink-muted)]">
|
||||
No images or fonts imported yet.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each assets as asset (asset.name)}
|
||||
<div
|
||||
class="group flex items-center gap-3 rounded-md border border-[var(--color-line)] px-3 py-2"
|
||||
>
|
||||
<Icon
|
||||
icon={iconFor[asset.kind]}
|
||||
class="text-lg text-[var(--color-accent)]"
|
||||
/>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-xs font-medium">{asset.name}</p>
|
||||
{#if asset.font_families.length > 0}
|
||||
<div class="mt-0.5 flex flex-wrap gap-1">
|
||||
{#each asset.font_families as family}
|
||||
<button
|
||||
class="rounded bg-[var(--color-surface-sunken)] px-1.5 py-px text-[10px] text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]"
|
||||
onclick={() => copyFamily(family)}
|
||||
title="Copy family name"
|
||||
>
|
||||
{copied === family ? "Copied" : family}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-[10px] text-[var(--color-ink-muted)]">
|
||||
{formatSize(asset.size)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if oninsert && (asset.kind === "image" || asset.font_families.length > 0)}
|
||||
<button
|
||||
class="rounded border border-[var(--color-line)] px-2 py-1 text-[10px] opacity-0 transition group-hover:opacity-100 hover:bg-[var(--color-surface-muted)]"
|
||||
onclick={() => insert(asset)}
|
||||
>
|
||||
Insert
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="rounded p-1 text-[var(--color-ink-muted)] opacity-0 transition group-hover:opacity-100 hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-danger)]"
|
||||
onclick={() => remove(asset)}
|
||||
aria-label="Delete"
|
||||
>
|
||||
<Icon icon="ph:trash" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet footer()}
|
||||
<button
|
||||
class="rounded-md px-3 py-1.5 text-xs text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={onclose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90 disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={importFiles}
|
||||
>
|
||||
<Icon icon="ph:upload-simple" />
|
||||
{busy ? "Importing..." : "Import files"}
|
||||
</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import Modal from "./Modal.svelte";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
onconfirm: () => void;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
message,
|
||||
confirmLabel = "Delete",
|
||||
onconfirm,
|
||||
onclose,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<Modal {title} icon="ph:warning-circle" {onclose}>
|
||||
<p class="text-sm text-[var(--color-ink-muted)]">{message}</p>
|
||||
|
||||
{#snippet footer()}
|
||||
<button
|
||||
class="rounded-md px-3 py-1.5 text-xs text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={onclose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="rounded-md bg-[var(--color-danger)] px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90"
|
||||
onclick={onconfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
@@ -0,0 +1,147 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import Modal from "./Modal.svelte";
|
||||
import type { Conflict, Resolution } from "$lib/ts/api";
|
||||
|
||||
interface Props {
|
||||
conflicts: Conflict[];
|
||||
onresolve: (resolutions: Resolution[]) => void;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { conflicts, onresolve, onclose }: Props = $props();
|
||||
|
||||
let index = $state(0);
|
||||
let choices = $state<string[]>([]);
|
||||
let mode = $state<("merged" | "local" | "remote")[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
choices = conflicts.map((conflict) =>
|
||||
conflict.binary ? conflict.remote_text : conflict.merged_text,
|
||||
);
|
||||
mode = conflicts.map(() => "merged");
|
||||
index = 0;
|
||||
});
|
||||
|
||||
const current = $derived(conflicts[index]);
|
||||
|
||||
function choose(option: "merged" | "local" | "remote") {
|
||||
mode[index] = option;
|
||||
choices[index] =
|
||||
option === "local"
|
||||
? current.local_text
|
||||
: option === "remote"
|
||||
? current.remote_text
|
||||
: current.merged_text;
|
||||
}
|
||||
|
||||
const unresolvedMarkers = $derived(
|
||||
choices[index]?.includes("<<<<<<<") ?? false,
|
||||
);
|
||||
|
||||
const anyUnresolved = $derived(
|
||||
choices.some((text) => text.includes("<<<<<<<")),
|
||||
);
|
||||
|
||||
function submit() {
|
||||
onresolve(
|
||||
conflicts.map((conflict, position) => ({
|
||||
path: conflict.path,
|
||||
content: choices[position],
|
||||
server_hash: conflict.server_hash,
|
||||
})),
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
title="Resolve sync conflicts"
|
||||
icon="ph:git-merge"
|
||||
width="max-w-4xl"
|
||||
{onclose}
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-xs text-[var(--color-ink-muted)]">
|
||||
These files changed both on this device and in the cloud. Pick a version or
|
||||
edit the merged result, then save to upload your resolution.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each conflicts as conflict, position}
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs transition
|
||||
{position === index
|
||||
? 'border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-accent)]'
|
||||
: 'border-[var(--color-line)] text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-muted)]'}"
|
||||
onclick={() => (index = position)}
|
||||
>
|
||||
{#if choices[position].includes("<<<<<<<")}
|
||||
<Icon icon="ph:warning" class="text-[var(--color-danger)]" />
|
||||
{:else}
|
||||
<Icon icon="ph:check-circle" class="text-[var(--color-success)]" />
|
||||
{/if}
|
||||
{conflict.path}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if current}
|
||||
{#if current.binary}
|
||||
<div
|
||||
class="rounded-md border border-[var(--color-line)] bg-[var(--color-surface-muted)] p-4 text-xs"
|
||||
>
|
||||
<p class="font-medium">{current.path}</p>
|
||||
<p class="mt-1 text-[var(--color-ink-muted)]">
|
||||
This is a binary file and cannot be merged automatically. The cloud
|
||||
version will be kept.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-1.5">
|
||||
{#each [["merged", "Merged"], ["local", "This device"], ["remote", "Cloud"]] as [option, label]}
|
||||
<button
|
||||
class="rounded-md border px-3 py-1.5 text-xs transition
|
||||
{mode[index] === option
|
||||
? 'border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-accent)]'
|
||||
: 'border-[var(--color-line)] text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-muted)]'}"
|
||||
onclick={() => choose(option as "merged" | "local" | "remote")}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if unresolvedMarkers}
|
||||
<span
|
||||
class="ml-2 flex items-center gap-1 text-xs text-[var(--color-danger)]"
|
||||
>
|
||||
<Icon icon="ph:warning" />
|
||||
Conflict markers still present
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
class="scroll-thin h-80 w-full resize-none rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] p-3 font-mono text-xs leading-relaxed focus:border-[var(--color-accent)] focus:outline-none"
|
||||
bind:value={choices[index]}
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet footer()}
|
||||
<button
|
||||
class="rounded-md px-3 py-1.5 text-xs text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={onclose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90 disabled:opacity-50"
|
||||
disabled={anyUnresolved}
|
||||
onclick={submit}
|
||||
>
|
||||
Save and upload
|
||||
</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import * as api from "$lib/ts/api";
|
||||
import {
|
||||
insertText,
|
||||
prefixLines,
|
||||
redoEdit,
|
||||
setTypstConfig,
|
||||
undoEdit,
|
||||
wrapSelection,
|
||||
} from "$lib/ts/editor-actions";
|
||||
import { app } from "$lib/ts/state.svelte";
|
||||
|
||||
interface Props {
|
||||
view: EditorView | null;
|
||||
disabled?: boolean;
|
||||
onassets: () => void;
|
||||
onpagesettings: () => void;
|
||||
}
|
||||
|
||||
let { view, disabled = false, onassets, onpagesettings }: Props = $props();
|
||||
|
||||
let fonts = $state<string[]>([]);
|
||||
let selectedFont = $state("");
|
||||
|
||||
$effect(() => {
|
||||
const path = app.target?.path;
|
||||
api
|
||||
.listFontFamilies(path)
|
||||
.then((families) => (fonts = families))
|
||||
.catch(() => (fonts = []));
|
||||
});
|
||||
|
||||
const stats = $derived(app.compiled?.stats ?? null);
|
||||
</script>
|
||||
|
||||
{#snippet action(
|
||||
icon: string,
|
||||
label: string,
|
||||
run: () => void,
|
||||
size = "text-base",
|
||||
)}
|
||||
<button
|
||||
class="rounded p-1.5 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)] disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
{disabled}
|
||||
onclick={run}
|
||||
>
|
||||
<Icon {icon} class={size} />
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
{#snippet divider()}
|
||||
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--color-line)]"></div>
|
||||
{/snippet}
|
||||
|
||||
<div
|
||||
class="scroll-thin flex shrink-0 items-center gap-0.5 overflow-x-auto border-b border-[var(--color-line)] bg-[var(--color-surface)] px-2 py-1"
|
||||
>
|
||||
{@render action("ph:arrow-counter-clockwise", "Undo", () => undoEdit(view))}
|
||||
{@render action("ph:arrow-clockwise", "Redo", () => redoEdit(view))}
|
||||
|
||||
{@render divider()}
|
||||
|
||||
{@render action("ph:text-h", "Heading", () => prefixLines(view, "= ", "Heading"))}
|
||||
{@render action("ph:text-b", "Bold", () => wrapSelection(view, "*", "*", "bold"))}
|
||||
{@render action("ph:text-italic", "Italic", () =>
|
||||
wrapSelection(view, "_", "_", "italic"),
|
||||
)}
|
||||
{@render action("ph:code", "Raw", () => wrapSelection(view, "`", "`", "code"))}
|
||||
|
||||
{@render divider()}
|
||||
|
||||
{@render action("ph:sigma", "Inline math", () =>
|
||||
wrapSelection(view, "$", "$", "x = y"),
|
||||
)}
|
||||
{@render action("ph:function", "Block math", () =>
|
||||
wrapSelection(view, "$ \n ", "\n$", "x = y"),
|
||||
)}
|
||||
|
||||
{@render divider()}
|
||||
|
||||
{@render action("ph:list-bullets", "Bullet list", () =>
|
||||
prefixLines(view, "- ", "List item"),
|
||||
)}
|
||||
{@render action("ph:list-numbers", "Numbered list", () =>
|
||||
prefixLines(view, "+ ", "Numbered item"),
|
||||
)}
|
||||
|
||||
{@render divider()}
|
||||
|
||||
{@render action("ph:link", "Link", () =>
|
||||
insertText(view, '#link("https://")[text]'),
|
||||
)}
|
||||
{@render action("ph:table", "Table", () =>
|
||||
insertText(view, "#table(\n columns: 2,\n [a], [b],\n)"),
|
||||
)}
|
||||
{@render action("ph:image-square", "Figure", () =>
|
||||
insertText(view, '#figure(\n image("file.png"),\n caption: [Caption],\n)'),
|
||||
)}
|
||||
{@render action("ph:images", "Images and fonts", onassets)}
|
||||
|
||||
{@render divider()}
|
||||
|
||||
<select
|
||||
class="max-w-36 rounded border border-[var(--color-line)] bg-[var(--color-surface)] px-1.5 py-1 text-xs text-[var(--color-ink)] focus:border-[var(--color-accent)] focus:outline-none disabled:opacity-40"
|
||||
aria-label="Document font"
|
||||
{disabled}
|
||||
bind:value={selectedFont}
|
||||
onchange={(event) => {
|
||||
const family = event.currentTarget.value;
|
||||
if (family) setTypstConfig(view, "text", "font", `"${family}"`);
|
||||
}}
|
||||
>
|
||||
<option value="">Font</option>
|
||||
{#each fonts as family}
|
||||
<option value={family}>{family}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded px-2 py-1 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)] disabled:opacity-40"
|
||||
{disabled}
|
||||
onclick={onpagesettings}
|
||||
>
|
||||
<Icon icon="ph:file-text" />
|
||||
Page
|
||||
</button>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
{#if stats}
|
||||
<span
|
||||
class="shrink-0 whitespace-nowrap pr-1 text-[10px] text-[var(--color-ink-muted)]"
|
||||
>
|
||||
{stats.pages} pages · {stats.words} words · {stats.characters} chars
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,182 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import type { FileEntry } from "$lib/ts/api";
|
||||
|
||||
interface Props {
|
||||
files: FileEntry[];
|
||||
activePath: string | null;
|
||||
entrypoint: string;
|
||||
onopen: (path: string) => void;
|
||||
onrename: (path: string) => void;
|
||||
ondelete: (path: string) => void;
|
||||
onsetentry: (path: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
files,
|
||||
activePath,
|
||||
entrypoint,
|
||||
onopen,
|
||||
onrename,
|
||||
ondelete,
|
||||
onsetentry,
|
||||
}: Props = $props();
|
||||
|
||||
interface TreeNode {
|
||||
name: string;
|
||||
path: string;
|
||||
file: FileEntry | null;
|
||||
children: TreeNode[];
|
||||
}
|
||||
|
||||
const tree = $derived(buildTree(files));
|
||||
|
||||
function buildTree(entries: FileEntry[]): TreeNode[] {
|
||||
const root: TreeNode = { name: "", path: "", file: null, children: [] };
|
||||
|
||||
for (const entry of entries) {
|
||||
const segments = entry.path.split("/");
|
||||
let node = root;
|
||||
|
||||
segments.forEach((segment, index) => {
|
||||
const path = segments.slice(0, index + 1).join("/");
|
||||
const isLeaf = index === segments.length - 1;
|
||||
let child = node.children.find((c) => c.name === segment);
|
||||
|
||||
if (!child) {
|
||||
child = {
|
||||
name: segment,
|
||||
path,
|
||||
file: isLeaf ? entry : null,
|
||||
children: [],
|
||||
};
|
||||
node.children.push(child);
|
||||
}
|
||||
node = child;
|
||||
});
|
||||
}
|
||||
|
||||
return sortNodes(root.children);
|
||||
}
|
||||
|
||||
function sortNodes(nodes: TreeNode[]): TreeNode[] {
|
||||
nodes.sort((a, b) => {
|
||||
const aIsFolder = a.file === null;
|
||||
const bIsFolder = b.file === null;
|
||||
if (aIsFolder !== bIsFolder) return aIsFolder ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
for (const node of nodes) sortNodes(node.children);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function iconFor(node: TreeNode): string {
|
||||
if (!node.file) return "ph:folder";
|
||||
if (node.name.endsWith(".typ")) return "ph:file-text";
|
||||
if (node.name.endsWith(".toml")) return "ph:gear-six";
|
||||
if (node.file.is_text) return "ph:file";
|
||||
return "ph:image";
|
||||
}
|
||||
|
||||
let collapsed = $state<Record<string, boolean>>({});
|
||||
let menuPath = $state<string | null>(null);
|
||||
</script>
|
||||
|
||||
{#snippet branch(nodes: TreeNode[], depth: number)}
|
||||
{#each nodes as node (node.path)}
|
||||
<div>
|
||||
<div
|
||||
class="group flex items-center gap-1.5 rounded px-2 py-1 text-xs transition
|
||||
{node.path === activePath
|
||||
? 'bg-[var(--color-accent-soft)] text-[var(--color-accent)]'
|
||||
: 'text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)]'}"
|
||||
style="padding-left: {depth * 12 + 8}px"
|
||||
>
|
||||
<button
|
||||
class="flex min-w-0 flex-1 items-center gap-1.5 text-left"
|
||||
onclick={() => {
|
||||
if (node.file) {
|
||||
onopen(node.path);
|
||||
} else {
|
||||
collapsed[node.path] = !collapsed[node.path];
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if !node.file}
|
||||
<Icon
|
||||
icon={collapsed[node.path] ? "ph:caret-right" : "ph:caret-down"}
|
||||
class="shrink-0 text-[10px] text-[var(--color-ink-muted)]"
|
||||
/>
|
||||
{/if}
|
||||
<Icon icon={iconFor(node)} class="shrink-0 text-sm" />
|
||||
<span class="truncate">{node.name}</span>
|
||||
{#if node.path === entrypoint}
|
||||
<span
|
||||
class="shrink-0 rounded bg-[var(--color-accent)] px-1 py-px text-[9px] font-medium text-white"
|
||||
>
|
||||
main
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if node.file}
|
||||
<button
|
||||
class="shrink-0 rounded p-0.5 text-[var(--color-ink-muted)] opacity-0 transition group-hover:opacity-100 hover:bg-[var(--color-surface)]"
|
||||
onclick={() => (menuPath = menuPath === node.path ? null : node.path)}
|
||||
aria-label="File actions"
|
||||
>
|
||||
<Icon icon="ph:dots-three-vertical" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if node.file && menuPath === node.path}
|
||||
<div
|
||||
class="ml-6 mb-1 flex flex-col rounded border border-[var(--color-line)] bg-[var(--color-surface)] py-1 text-xs shadow-sm"
|
||||
>
|
||||
{#if node.name.endsWith(".typ")}
|
||||
<button
|
||||
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={() => {
|
||||
onsetentry(node.path);
|
||||
menuPath = null;
|
||||
}}
|
||||
>
|
||||
Set as entrypoint
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={() => {
|
||||
onrename(node.path);
|
||||
menuPath = null;
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 text-left text-[var(--color-danger)] hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={() => {
|
||||
ondelete(node.path);
|
||||
menuPath = null;
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if node.children.length > 0 && !collapsed[node.path]}
|
||||
{@render branch(node.children, depth + 1)}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/snippet}
|
||||
|
||||
<div class="scroll-thin flex-1 overflow-y-auto py-1">
|
||||
{#if files.length === 0}
|
||||
<p class="px-3 py-4 text-xs text-[var(--color-ink-muted)]">No files yet</p>
|
||||
{:else}
|
||||
{@render branch(tree, 0)}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,513 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import * as api from "$lib/ts/api";
|
||||
import type { BrowseEntry, EntryKind } from "$lib/ts/api";
|
||||
import {
|
||||
app,
|
||||
breadcrumbs,
|
||||
browseTo,
|
||||
openTarget,
|
||||
} from "$lib/ts/state.svelte";
|
||||
|
||||
interface Props {
|
||||
onnewfolder: () => void;
|
||||
onnewproject: () => void;
|
||||
onnewdocument: () => void;
|
||||
onupload: () => void;
|
||||
onassets: () => void;
|
||||
onrename: (entry: BrowseEntry) => void;
|
||||
ondelete: (entry: BrowseEntry) => void;
|
||||
onlink: (entry: BrowseEntry) => void;
|
||||
onviewimage: (paths: string[], index: number) => void;
|
||||
onclonespace: (spaceId: string, name: string) => void;
|
||||
ondeletespace: (spaceId: string) => void;
|
||||
onnewspace: () => void;
|
||||
onsignin: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
onnewfolder,
|
||||
onnewproject,
|
||||
onnewdocument,
|
||||
onupload,
|
||||
onassets,
|
||||
onrename,
|
||||
ondelete,
|
||||
onlink,
|
||||
onviewimage,
|
||||
onclonespace,
|
||||
ondeletespace,
|
||||
onnewspace,
|
||||
onsignin,
|
||||
}: Props = $props();
|
||||
|
||||
let menuFor = $state<string | null>(null);
|
||||
|
||||
const trail = $derived(breadcrumbs());
|
||||
|
||||
const containers = $derived(
|
||||
app.entries.filter(
|
||||
(entry) => entry.kind === "folder" || entry.kind === "project",
|
||||
),
|
||||
);
|
||||
|
||||
const documents = $derived(
|
||||
app.entries.filter(
|
||||
(entry) => entry.kind === "document" || entry.kind === "file",
|
||||
),
|
||||
);
|
||||
|
||||
let thumbs = $state<Record<string, { kind: string; data: string }>>({});
|
||||
|
||||
$effect(() => {
|
||||
const pending = documents.map((entry) => entry.path);
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
for (const path of pending) {
|
||||
if (cancelled) return;
|
||||
if (thumbs[path]) continue;
|
||||
try {
|
||||
const result = await api.thumbnail(path);
|
||||
if (!cancelled) thumbs[path] = result;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
const localSpaceIds = $derived(
|
||||
new Set(
|
||||
app.entries
|
||||
.filter((entry) => entry.space_id)
|
||||
.map((entry) => entry.space_id as string),
|
||||
),
|
||||
);
|
||||
|
||||
const iconFor: Record<EntryKind, string> = {
|
||||
project: "ph:folder-star",
|
||||
folder: "ph:folder",
|
||||
document: "ph:file-text",
|
||||
file: "ph:file",
|
||||
};
|
||||
|
||||
const colorFor: Record<EntryKind, string> = {
|
||||
project: "text-[var(--color-accent)]",
|
||||
folder: "text-[var(--color-ink-muted)]",
|
||||
document: "text-[var(--color-accent)]",
|
||||
file: "text-[var(--color-ink-muted)]",
|
||||
};
|
||||
|
||||
const imagePaths = $derived(
|
||||
app.entries
|
||||
.filter((entry) => api.isImagePath(entry.path))
|
||||
.map((entry) => entry.path),
|
||||
);
|
||||
|
||||
function activate(entry: BrowseEntry) {
|
||||
if (entry.kind === "folder") {
|
||||
browseTo(entry.path);
|
||||
} else if (entry.kind === "project" || entry.kind === "document") {
|
||||
openTarget(entry.path);
|
||||
} else if (api.isImagePath(entry.path)) {
|
||||
onviewimage(imagePaths, imagePaths.indexOf(entry.path));
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? "" : date.toLocaleDateString();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet actions(entry: BrowseEntry, offset: string)}
|
||||
<button
|
||||
class="absolute right-2 {offset} rounded p-1 text-[var(--color-ink-muted)] opacity-0 transition group-hover:opacity-100 hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={() => (menuFor = menuFor === entry.path ? null : entry.path)}
|
||||
aria-label="Actions"
|
||||
>
|
||||
<Icon icon="ph:dots-three-vertical" />
|
||||
</button>
|
||||
|
||||
{#if menuFor === entry.path}
|
||||
<div
|
||||
class="absolute right-2 top-9 z-10 flex w-40 flex-col rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] py-1 text-xs shadow-lg"
|
||||
>
|
||||
{#if entry.kind === "project" || entry.kind === "document"}
|
||||
<button
|
||||
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={() => {
|
||||
openTarget(entry.path);
|
||||
menuFor = null;
|
||||
}}
|
||||
>
|
||||
Open in editor
|
||||
</button>
|
||||
{:else if api.isImagePath(entry.path)}
|
||||
<button
|
||||
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={() => {
|
||||
onviewimage(imagePaths, imagePaths.indexOf(entry.path));
|
||||
menuFor = null;
|
||||
}}
|
||||
>
|
||||
Open image
|
||||
</button>
|
||||
{/if}
|
||||
{#if entry.kind === "project" && !entry.space_id && app.account}
|
||||
<button
|
||||
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={() => {
|
||||
onlink(entry);
|
||||
menuFor = null;
|
||||
}}
|
||||
>
|
||||
Upload to cloud
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={() => {
|
||||
onrename(entry);
|
||||
menuFor = null;
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 text-left text-[var(--color-danger)] hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={() => {
|
||||
ondelete(entry);
|
||||
menuFor = null;
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class="flex h-full flex-col bg-[var(--color-surface-muted)]">
|
||||
<div
|
||||
class="flex items-center gap-2 border-b border-[var(--color-line)] bg-[var(--color-surface)] px-4 py-2.5"
|
||||
>
|
||||
<div class="flex rounded-lg bg-[var(--color-surface-sunken)] p-0.5 text-xs font-medium">
|
||||
{#each [["local", "Local", "ph:hard-drives"], ["cloud", "Cloud", "ph:cloud"]] as [value, label, icon]}
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 transition
|
||||
{app.scope === value
|
||||
? 'bg-[var(--color-surface)] text-[var(--color-ink)] shadow-sm'
|
||||
: 'text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]'}"
|
||||
onclick={() => (app.scope = value as "local" | "cloud")}
|
||||
>
|
||||
<Icon {icon} />
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
{#if app.scope === "local"}
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
|
||||
onclick={onassets}
|
||||
>
|
||||
<Icon icon="ph:images" />
|
||||
Assets
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
|
||||
onclick={onupload}
|
||||
>
|
||||
<Icon icon="ph:upload-simple" />
|
||||
Import
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
|
||||
onclick={onnewfolder}
|
||||
>
|
||||
<Icon icon="ph:folder-plus" />
|
||||
Folder
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
|
||||
onclick={onnewdocument}
|
||||
>
|
||||
<Icon icon="ph:file-plus" />
|
||||
Document
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-2.5 py-1.5 text-xs font-medium text-white transition hover:opacity-90"
|
||||
onclick={onnewproject}
|
||||
>
|
||||
<Icon icon="ph:plus" />
|
||||
New project
|
||||
</button>
|
||||
{:else if app.account}
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-2.5 py-1.5 text-xs font-medium text-white transition hover:opacity-90"
|
||||
onclick={onnewspace}
|
||||
>
|
||||
<Icon icon="ph:plus" />
|
||||
New space
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if app.scope === "local"}
|
||||
<div
|
||||
class="flex items-center gap-1 border-b border-[var(--color-line)] bg-[var(--color-surface)] px-4 py-2 text-xs"
|
||||
>
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded px-2 py-1 transition hover:bg-[var(--color-surface-muted)]
|
||||
{app.currentDir === '' ? 'font-medium' : 'text-[var(--color-ink-muted)]'}"
|
||||
onclick={() => browseTo("")}
|
||||
>
|
||||
<Icon icon="ph:house" />
|
||||
Workspace
|
||||
</button>
|
||||
|
||||
{#each trail as crumb, index}
|
||||
<Icon icon="ph:caret-right" class="text-[10px] text-[var(--color-ink-muted)]" />
|
||||
<button
|
||||
class="rounded px-2 py-1 transition hover:bg-[var(--color-surface-muted)]
|
||||
{index === trail.length - 1 ? 'font-medium' : 'text-[var(--color-ink-muted)]'}"
|
||||
onclick={() => browseTo(crumb.path)}
|
||||
>
|
||||
{crumb.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="scroll-thin flex-1 overflow-y-auto p-4">
|
||||
{#if app.entries.length === 0}
|
||||
<div
|
||||
class="flex h-full flex-col items-center justify-center gap-3 text-[var(--color-ink-muted)]"
|
||||
>
|
||||
<Icon icon="ph:folder-open" class="text-5xl" />
|
||||
<p class="text-sm">This folder is empty.</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white hover:opacity-90"
|
||||
onclick={onnewproject}
|
||||
>
|
||||
New project
|
||||
</button>
|
||||
<button
|
||||
class="rounded-md border border-[var(--color-line)] px-3 py-1.5 text-xs hover:bg-[var(--color-surface)]"
|
||||
onclick={onnewdocument}
|
||||
>
|
||||
New document
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-5">
|
||||
{#if containers.length > 0}
|
||||
<section class="flex flex-col gap-2">
|
||||
<h2
|
||||
class="text-[10px] font-semibold uppercase tracking-wider text-[var(--color-ink-muted)]"
|
||||
>
|
||||
Folders
|
||||
</h2>
|
||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(210px,1fr))] gap-2">
|
||||
{#each containers as entry (entry.path)}
|
||||
<div
|
||||
class="group relative flex items-center gap-2.5 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface-sunken)] px-3 py-2.5 transition hover:border-[var(--color-accent)] hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
<button
|
||||
class="flex min-w-0 flex-1 items-center gap-2.5 text-left"
|
||||
ondblclick={() => activate(entry)}
|
||||
onclick={() => activate(entry)}
|
||||
>
|
||||
<Icon
|
||||
icon={entry.kind === "project"
|
||||
? "ph:folder-star-fill"
|
||||
: "ph:folder-fill"}
|
||||
class="shrink-0 text-2xl {entry.kind === 'project'
|
||||
? 'text-[var(--color-accent)]'
|
||||
: 'text-[var(--color-ink-muted)]'}"
|
||||
/>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span
|
||||
class="truncate text-xs font-medium"
|
||||
title={entry.name}
|
||||
>
|
||||
{entry.name}
|
||||
</span>
|
||||
{#if entry.space_id}
|
||||
<Icon
|
||||
icon="ph:cloud-check"
|
||||
class="shrink-0 text-xs text-[var(--color-success)]"
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
<span
|
||||
class="block truncate text-[10px] text-[var(--color-ink-muted)]"
|
||||
>
|
||||
{entry.kind === "project" ? "Project · " : ""}{entry.child_count}
|
||||
items
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{@render actions(entry, "top-2")}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if documents.length > 0}
|
||||
<section class="flex flex-col gap-2">
|
||||
<h2
|
||||
class="text-[10px] font-semibold uppercase tracking-wider text-[var(--color-ink-muted)]"
|
||||
>
|
||||
Documents
|
||||
</h2>
|
||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
||||
{#each documents as entry (entry.path)}
|
||||
<div
|
||||
class="group relative flex flex-col overflow-hidden rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] transition hover:border-[var(--color-accent)] hover:shadow-md"
|
||||
>
|
||||
<button
|
||||
class="flex flex-col text-left"
|
||||
ondblclick={() => activate(entry)}
|
||||
onclick={() => activate(entry)}
|
||||
>
|
||||
<span
|
||||
class="flex h-24 items-center justify-center overflow-hidden border-b border-[var(--color-line)] bg-[var(--color-surface-muted)]"
|
||||
>
|
||||
{#if thumbs[entry.path]?.kind === "svg"}
|
||||
<span
|
||||
class="flex w-full origin-top scale-100 items-start justify-center bg-white p-1 [&_svg]:h-auto [&_svg]:w-full"
|
||||
>
|
||||
{@html thumbs[entry.path].data}
|
||||
</span>
|
||||
{:else if thumbs[entry.path]?.kind === "image"}
|
||||
<img
|
||||
src={thumbs[entry.path].data}
|
||||
alt={entry.name}
|
||||
class="h-full w-full object-contain"
|
||||
/>
|
||||
{:else}
|
||||
<Icon
|
||||
icon={iconFor[entry.kind]}
|
||||
class="text-3xl {colorFor[entry.kind]}"
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<span class="flex flex-col gap-0.5 px-2.5 py-2">
|
||||
<span
|
||||
class="truncate text-xs font-medium"
|
||||
title={entry.name}
|
||||
>
|
||||
{entry.name}
|
||||
</span>
|
||||
<span class="text-[10px] text-[var(--color-ink-muted)]">
|
||||
{formatSize(entry.size)}
|
||||
{#if entry.modified}
|
||||
· {formatDate(entry.modified)}
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{@render actions(entry, "top-2")}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="scroll-thin flex-1 overflow-y-auto p-4">
|
||||
{#if !app.account}
|
||||
<div
|
||||
class="flex h-full flex-col items-center justify-center gap-3 text-[var(--color-ink-muted)]"
|
||||
>
|
||||
<Icon icon="ph:cloud-slash" class="text-5xl" />
|
||||
<p class="max-w-xs text-center text-sm">
|
||||
Connect a TypstDrive account to sync your projects across devices.
|
||||
</p>
|
||||
<button
|
||||
class="rounded-md bg-[var(--color-accent)] px-3 py-2 text-xs font-medium text-white hover:opacity-90"
|
||||
onclick={onsignin}
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
{:else if app.spaces.length === 0}
|
||||
<div
|
||||
class="flex h-full flex-col items-center justify-center gap-3 text-[var(--color-ink-muted)]"
|
||||
>
|
||||
<Icon icon="ph:cloud" class="text-5xl" />
|
||||
<p class="text-sm">No cloud spaces yet.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-3">
|
||||
{#each app.spaces as space (space.id)}
|
||||
<div
|
||||
class="group flex flex-col gap-2 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] p-3 transition hover:border-[var(--color-accent)]"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="ph:cloud" class="text-xl text-[var(--color-accent)]" />
|
||||
{#if localSpaceIds.has(space.id)}
|
||||
<span title="Downloaded to this device" class="flex">
|
||||
<Icon
|
||||
icon="ph:hard-drives"
|
||||
class="text-sm text-[var(--color-success)]"
|
||||
/>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<span class="truncate text-xs font-medium">{space.name}</span>
|
||||
<span class="text-[10px] text-[var(--color-ink-muted)]">
|
||||
{space.role} · {formatDate(space.updated_at)}
|
||||
</span>
|
||||
|
||||
<div class="mt-1 flex gap-1">
|
||||
{#if !localSpaceIds.has(space.id)}
|
||||
<button
|
||||
class="flex flex-1 items-center justify-center gap-1 rounded border border-[var(--color-line)] px-2 py-1 text-[10px] hover:bg-[var(--color-surface-muted)]"
|
||||
onclick={() => onclonespace(space.id, space.name)}
|
||||
>
|
||||
<Icon icon="ph:download-simple" />
|
||||
Download
|
||||
</button>
|
||||
{/if}
|
||||
{#if space.role === "owner"}
|
||||
<button
|
||||
class="rounded border border-[var(--color-line)] px-2 py-1 text-[10px] text-[var(--color-danger)] hover:bg-[var(--color-surface-muted)]"
|
||||
onclick={() => ondeletespace(space.id)}
|
||||
aria-label="Delete space"
|
||||
>
|
||||
<Icon icon="ph:trash" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,172 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import { untrack } from "svelte";
|
||||
import * as api from "$lib/ts/api";
|
||||
import type { ImageData } from "$lib/ts/api";
|
||||
|
||||
interface Props {
|
||||
paths: string[];
|
||||
index: number;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { paths, index, onclose }: Props = $props();
|
||||
|
||||
let current = $state(untrack(() => index));
|
||||
let image = $state<ImageData | null>(null);
|
||||
let error = $state("");
|
||||
let loading = $state(true);
|
||||
let zoom = $state(1);
|
||||
let fit = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
const path = paths[current];
|
||||
if (!path) return;
|
||||
|
||||
loading = true;
|
||||
error = "";
|
||||
api
|
||||
.readImage(path)
|
||||
.then((result) => {
|
||||
image = result;
|
||||
zoom = 1;
|
||||
fit = true;
|
||||
})
|
||||
.catch((caught) => {
|
||||
image = null;
|
||||
error = api.errorMessage(caught);
|
||||
})
|
||||
.finally(() => (loading = false));
|
||||
});
|
||||
|
||||
function step(delta: number) {
|
||||
const next = current + delta;
|
||||
if (next < 0 || next >= paths.length) return;
|
||||
current = next;
|
||||
}
|
||||
|
||||
function handleKey(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") onclose();
|
||||
if (event.key === "ArrowRight") step(1);
|
||||
if (event.key === "ArrowLeft") step(-1);
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKey} />
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex flex-col bg-black/80"
|
||||
role="presentation"
|
||||
onclick={(event) => {
|
||||
if (event.target === event.currentTarget) onclose();
|
||||
}}
|
||||
>
|
||||
<header
|
||||
class="flex h-11 shrink-0 items-center gap-3 bg-[var(--color-surface)] px-3 text-xs"
|
||||
>
|
||||
<Icon icon="ph:image" class="text-base text-[var(--color-accent)]" />
|
||||
<span class="font-medium">{image?.name ?? paths[current]?.split("/").pop()}</span>
|
||||
|
||||
{#if image}
|
||||
<span class="text-[var(--color-ink-muted)]">
|
||||
{#if image.width && image.height}
|
||||
{image.width} × {image.height} ·
|
||||
{/if}
|
||||
{formatSize(image.size)}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
{#if paths.length > 1}
|
||||
<span class="text-[var(--color-ink-muted)]">
|
||||
{current + 1} of {paths.length}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="rounded p-1.5 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
|
||||
onclick={() => {
|
||||
fit = false;
|
||||
zoom = Math.max(0.1, zoom - 0.25);
|
||||
}}
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
<Icon icon="ph:minus" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
|
||||
onclick={() => {
|
||||
fit = !fit;
|
||||
zoom = 1;
|
||||
}}
|
||||
>
|
||||
{fit ? "Fit" : `${Math.round(zoom * 100)}%`}
|
||||
</button>
|
||||
<button
|
||||
class="rounded p-1.5 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
|
||||
onclick={() => {
|
||||
fit = false;
|
||||
zoom = Math.min(8, zoom + 0.25);
|
||||
}}
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
<Icon icon="ph:plus" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="rounded p-1.5 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-danger)] hover:text-white"
|
||||
onclick={onclose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<Icon icon="ph:x" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="relative flex min-h-0 flex-1 items-center justify-center">
|
||||
{#if paths.length > 1}
|
||||
<button
|
||||
class="absolute left-3 z-10 rounded-full bg-black/50 p-2 text-white transition hover:bg-black/70 disabled:opacity-30"
|
||||
disabled={current === 0}
|
||||
onclick={() => step(-1)}
|
||||
aria-label="Previous image"
|
||||
>
|
||||
<Icon icon="ph:caret-left" class="text-lg" />
|
||||
</button>
|
||||
<button
|
||||
class="absolute right-3 z-10 rounded-full bg-black/50 p-2 text-white transition hover:bg-black/70 disabled:opacity-30"
|
||||
disabled={current === paths.length - 1}
|
||||
onclick={() => step(1)}
|
||||
aria-label="Next image"
|
||||
>
|
||||
<Icon icon="ph:caret-right" class="text-lg" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<Icon icon="ph:circle-notch" class="animate-spin text-3xl text-white/70" />
|
||||
{:else if error}
|
||||
<div class="flex flex-col items-center gap-2 text-white/70">
|
||||
<Icon icon="ph:warning-circle" class="text-3xl" />
|
||||
<p class="text-sm">{error}</p>
|
||||
</div>
|
||||
{:else if image}
|
||||
<div class="scroll-thin h-full w-full overflow-auto p-6">
|
||||
<img
|
||||
src={image.data}
|
||||
alt={image.name}
|
||||
class={fit
|
||||
? "mx-auto max-h-full max-w-full object-contain"
|
||||
: "mx-auto max-w-none"}
|
||||
style={fit ? "" : `width: ${zoom * 100}%`}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import Modal from "./Modal.svelte";
|
||||
import * as api from "$lib/ts/api";
|
||||
|
||||
interface Props {
|
||||
serverUrl: string;
|
||||
onsuccess: () => void;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { serverUrl, onsuccess, onclose }: Props = $props();
|
||||
|
||||
let url = $state(serverUrl);
|
||||
let email = $state("");
|
||||
let password = $state("");
|
||||
let busy = $state(false);
|
||||
let error = $state("");
|
||||
|
||||
async function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!email.trim() || !password) {
|
||||
error = "Enter your email and password";
|
||||
return;
|
||||
}
|
||||
|
||||
busy = true;
|
||||
error = "";
|
||||
try {
|
||||
await api.cloudLogin(url.trim(), email.trim(), password);
|
||||
onsuccess();
|
||||
} catch (caught) {
|
||||
error = api.errorMessage(caught);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal title="Connect to TypstDrive" icon="ph:cloud" {onclose}>
|
||||
<form id="login-form" class="flex flex-col gap-3" onsubmit={submit}>
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="font-medium text-[var(--color-ink-muted)]">Server</span>
|
||||
<input
|
||||
class="rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 text-sm focus:border-[var(--color-accent)] focus:outline-none"
|
||||
bind:value={url}
|
||||
placeholder="https://drive.example.com"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="font-medium text-[var(--color-ink-muted)]">Email</span>
|
||||
<input
|
||||
type="email"
|
||||
class="rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 text-sm focus:border-[var(--color-accent)] focus:outline-none"
|
||||
bind:value={email}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="font-medium text-[var(--color-ink-muted)]">Password</span>
|
||||
<input
|
||||
type="password"
|
||||
class="rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 text-sm focus:border-[var(--color-accent)] focus:outline-none"
|
||||
bind:value={password}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{#if error}
|
||||
<p class="text-xs text-[var(--color-danger)]">{error}</p>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
{#snippet footer()}
|
||||
<button
|
||||
class="rounded-md px-3 py-1.5 text-xs text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={onclose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form="login-form"
|
||||
class="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90 disabled:opacity-50"
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? "Connecting..." : "Connect"}
|
||||
</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
icon?: string;
|
||||
width?: string;
|
||||
onclose: () => void;
|
||||
children: Snippet;
|
||||
footer?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
icon = "ph:squares-four",
|
||||
width = "max-w-md",
|
||||
onclose,
|
||||
children,
|
||||
footer,
|
||||
}: Props = $props();
|
||||
|
||||
function handleKey(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") onclose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKey} />
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-6"
|
||||
role="presentation"
|
||||
onclick={(event) => {
|
||||
if (event.target === event.currentTarget) onclose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="w-full {width} overflow-hidden rounded-xl border border-[var(--color-line)] bg-[var(--color-surface)] shadow-2xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
>
|
||||
<header
|
||||
class="flex items-center gap-2 border-b border-[var(--color-line)] px-5 py-3.5"
|
||||
>
|
||||
<Icon {icon} class="text-lg text-[var(--color-accent)]" />
|
||||
<h2 class="flex-1 text-sm font-semibold">{title}</h2>
|
||||
<button
|
||||
class="rounded p-1 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
|
||||
onclick={onclose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<Icon icon="ph:x" class="text-base" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="scroll-thin max-h-[70vh] overflow-y-auto px-5 py-4">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
{#if footer}
|
||||
<footer
|
||||
class="flex items-center justify-end gap-2 border-t border-[var(--color-line)] bg-[var(--color-surface-muted)] px-5 py-3"
|
||||
>
|
||||
{@render footer()}
|
||||
</footer>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,113 @@
|
||||
<script lang="ts">
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import Modal from "./Modal.svelte";
|
||||
import { setTypstConfig } from "$lib/ts/editor-actions";
|
||||
|
||||
interface Props {
|
||||
view: EditorView | null;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { view, onclose }: Props = $props();
|
||||
|
||||
const papers = [
|
||||
"a4",
|
||||
"a3",
|
||||
"a5",
|
||||
"us-letter",
|
||||
"us-legal",
|
||||
"presentation-16-9",
|
||||
];
|
||||
|
||||
let paper = $state("a4");
|
||||
let margin = $state("2.5cm");
|
||||
let columns = $state(1);
|
||||
let flipped = $state(false);
|
||||
let numbering = $state("none");
|
||||
|
||||
function apply() {
|
||||
setTypstConfig(view, "page", "paper", `"${paper}"`);
|
||||
if (margin.trim()) {
|
||||
setTypstConfig(view, "page", "margin", margin.trim());
|
||||
}
|
||||
if (columns > 1) {
|
||||
setTypstConfig(view, "page", "columns", String(columns));
|
||||
}
|
||||
if (flipped) {
|
||||
setTypstConfig(view, "page", "flipped", "true");
|
||||
}
|
||||
if (numbering !== "none") {
|
||||
setTypstConfig(view, "page", "numbering", `"${numbering}"`);
|
||||
}
|
||||
onclose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal title="Page settings" icon="ph:file-text" {onclose}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="font-medium text-[var(--color-ink-muted)]">Paper</span>
|
||||
<select
|
||||
class="rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 text-sm focus:border-[var(--color-accent)] focus:outline-none"
|
||||
bind:value={paper}
|
||||
>
|
||||
{#each papers as option}
|
||||
<option value={option}>{option}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="font-medium text-[var(--color-ink-muted)]">Margin</span>
|
||||
<input
|
||||
class="rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 text-sm focus:border-[var(--color-accent)] focus:outline-none"
|
||||
bind:value={margin}
|
||||
placeholder="2.5cm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="font-medium text-[var(--color-ink-muted)]">Columns</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="6"
|
||||
class="rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 text-sm focus:border-[var(--color-accent)] focus:outline-none"
|
||||
bind:value={columns}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="font-medium text-[var(--color-ink-muted)]">Page numbers</span>
|
||||
<select
|
||||
class="rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 text-sm focus:border-[var(--color-accent)] focus:outline-none"
|
||||
bind:value={numbering}
|
||||
>
|
||||
<option value="none">None</option>
|
||||
<option value="1">1, 2, 3</option>
|
||||
<option value="1 / 1">1 / 10</option>
|
||||
<option value="i">i, ii, iii</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-xs">
|
||||
<input type="checkbox" bind:checked={flipped} />
|
||||
<span>Landscape</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#snippet footer()}
|
||||
<button
|
||||
class="rounded-md px-3 py-1.5 text-xs text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={onclose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90"
|
||||
onclick={apply}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import type { CompileResult, Diagnostic } from "$lib/ts/api";
|
||||
|
||||
interface Props {
|
||||
compiled: CompileResult | null;
|
||||
diagnostics: Diagnostic[];
|
||||
compiling: boolean;
|
||||
}
|
||||
|
||||
let { compiled, diagnostics, compiling }: Props = $props();
|
||||
|
||||
let zoom = $state(1);
|
||||
|
||||
const errors = $derived(diagnostics.filter((d) => d.severity === "error"));
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col bg-[var(--color-surface-sunken)]">
|
||||
<div
|
||||
class="flex items-center gap-2 border-b border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 text-xs"
|
||||
>
|
||||
<Icon icon="ph:file-text" class="text-[var(--color-ink-muted)]" />
|
||||
<span class="text-[var(--color-ink-muted)]">
|
||||
{#if compiled}
|
||||
{compiled.stats.pages} pages, {compiled.stats.words} words
|
||||
{:else}
|
||||
Preview
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
{#if compiling}
|
||||
<Icon icon="ph:circle-notch" class="animate-spin text-[var(--color-accent)]" />
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="rounded p-1 text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={() => (zoom = Math.max(0.4, zoom - 0.15))}
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
<Icon icon="ph:minus" />
|
||||
</button>
|
||||
<span class="w-10 text-center tabular-nums text-[var(--color-ink-muted)]">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
class="rounded p-1 text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={() => (zoom = Math.min(2.5, zoom + 0.15))}
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
<Icon icon="ph:plus" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if errors.length > 0}
|
||||
<div
|
||||
class="scroll-thin max-h-40 shrink-0 overflow-y-auto border-b border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2"
|
||||
>
|
||||
{#each errors as diagnostic}
|
||||
<div class="flex items-start gap-2 py-1 text-xs text-[var(--color-danger)]">
|
||||
<Icon icon="ph:warning-circle" class="mt-0.5 shrink-0" />
|
||||
<span>
|
||||
{#if diagnostic.line}
|
||||
<span class="font-medium">Line {diagnostic.line}:</span>
|
||||
{/if}
|
||||
{diagnostic.message}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="scroll-thin flex-1 overflow-auto p-6">
|
||||
{#if compiled && compiled.pages.length > 0}
|
||||
<div
|
||||
class="mx-auto flex flex-col items-center gap-6"
|
||||
style="width: {Math.round(zoom * 100)}%; max-width: {zoom > 1 ? 'none' : '820px'};"
|
||||
>
|
||||
{#each compiled.pages as page}
|
||||
<div
|
||||
class="preview-page w-full overflow-hidden rounded bg-white shadow-lg ring-1 ring-black/5"
|
||||
>
|
||||
{@html page}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-full flex-col items-center justify-center gap-2 text-[var(--color-ink-muted)]"
|
||||
>
|
||||
<Icon icon="ph:file-dashed" class="text-4xl" />
|
||||
<p class="text-sm">
|
||||
{errors.length > 0 ? "Fix the errors above to see a preview" : "Nothing to preview yet"}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import Modal from "./Modal.svelte";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
confirmLabel?: string;
|
||||
danger?: boolean;
|
||||
suffix?: string;
|
||||
onsubmit: (value: string) => void;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
label,
|
||||
icon = "ph:pencil-simple",
|
||||
value = "",
|
||||
placeholder = "",
|
||||
confirmLabel = "Create",
|
||||
danger = false,
|
||||
suffix = "",
|
||||
onsubmit,
|
||||
onclose,
|
||||
}: Props = $props();
|
||||
|
||||
let text = $state(
|
||||
untrack(() =>
|
||||
suffix && value.endsWith(suffix) ? value.slice(0, -suffix.length) : value,
|
||||
),
|
||||
);
|
||||
|
||||
function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!text.trim()) return;
|
||||
onsubmit(text.trim());
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal {title} {icon} {onclose}>
|
||||
<form id="prompt-form" onsubmit={submit}>
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="font-medium text-[var(--color-ink-muted)]">{label}</span>
|
||||
<div
|
||||
class="flex items-center rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] focus-within:border-[var(--color-accent)]"
|
||||
>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
autofocus
|
||||
class="min-w-0 flex-1 bg-transparent px-3 py-2 text-sm focus:outline-none"
|
||||
bind:value={text}
|
||||
{placeholder}
|
||||
/>
|
||||
{#if suffix}
|
||||
<span class="pr-3 text-sm text-[var(--color-ink-muted)]">{suffix}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
{#snippet footer()}
|
||||
<button
|
||||
class="rounded-md px-3 py-1.5 text-xs text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={onclose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form="prompt-form"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90
|
||||
{danger ? 'bg-[var(--color-danger)]' : 'bg-[var(--color-accent)]'}"
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
@@ -0,0 +1,153 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import Modal from "./Modal.svelte";
|
||||
import * as api from "$lib/ts/api";
|
||||
import { app, applyTheme, refreshEntries, setError } from "$lib/ts/state.svelte";
|
||||
|
||||
interface Props {
|
||||
onclose: () => void;
|
||||
onsignin: () => void;
|
||||
}
|
||||
|
||||
let { onclose, onsignin }: Props = $props();
|
||||
|
||||
let workspaceRoot = $state(app.settings?.workspace_root ?? "");
|
||||
let serverUrl = $state(app.settings?.server_url ?? "");
|
||||
let saving = $state(false);
|
||||
|
||||
async function browse() {
|
||||
const selected = await open({ directory: true, multiple: false });
|
||||
if (typeof selected === "string") {
|
||||
workspaceRoot = selected;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving = true;
|
||||
try {
|
||||
app.settings = await api.updateSettings({
|
||||
workspaceRoot,
|
||||
serverUrl,
|
||||
});
|
||||
await refreshEntries();
|
||||
onclose();
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
try {
|
||||
await api.cloudLogout();
|
||||
app.account = null;
|
||||
app.spaces = [];
|
||||
app.settings = await api.getSettings();
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal title="Settings" icon="ph:gear-six" {onclose}>
|
||||
<div class="flex flex-col gap-5">
|
||||
<div class="flex flex-col gap-1 text-xs">
|
||||
<span class="font-medium text-[var(--color-ink-muted)]">Workspace folder</span>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
class="flex-1 rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 text-sm focus:border-[var(--color-accent)] focus:outline-none"
|
||||
bind:value={workspaceRoot}
|
||||
/>
|
||||
<button
|
||||
class="flex items-center gap-1 rounded-md border border-[var(--color-line)] px-3 text-xs hover:bg-[var(--color-surface-muted)]"
|
||||
onclick={browse}
|
||||
>
|
||||
<Icon icon="ph:folder-open" />
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
<span class="text-[var(--color-ink-muted)]">
|
||||
Projects are stored as plain folders here.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 text-xs">
|
||||
<span class="font-medium text-[var(--color-ink-muted)]">TypstDrive server</span>
|
||||
<input
|
||||
class="rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 text-sm focus:border-[var(--color-accent)] focus:outline-none"
|
||||
bind:value={serverUrl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-md border border-[var(--color-line)] bg-[var(--color-surface-muted)] p-3"
|
||||
>
|
||||
<Icon
|
||||
icon={app.account ? "ph:user-circle-check" : "ph:user-circle"}
|
||||
class="text-2xl {app.account ? 'text-[var(--color-success)]' : 'text-[var(--color-ink-muted)]'}"
|
||||
/>
|
||||
<div class="flex-1 text-xs">
|
||||
{#if app.account}
|
||||
<p class="font-medium">{app.account.username}</p>
|
||||
<p class="text-[var(--color-ink-muted)]">{app.account.email}</p>
|
||||
{:else}
|
||||
<p class="font-medium">Not connected</p>
|
||||
<p class="text-[var(--color-ink-muted)]">
|
||||
Sign in to sync projects to the cloud.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if app.account}
|
||||
<button
|
||||
class="rounded-md border border-[var(--color-line)] px-3 py-1.5 text-xs hover:bg-[var(--color-surface)]"
|
||||
onclick={signOut}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white hover:opacity-90"
|
||||
onclick={onsignin}
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="font-medium text-[var(--color-ink-muted)]">Appearance</span>
|
||||
<div class="flex gap-1">
|
||||
{#each [["light", "ph:sun"], ["dark", "ph:moon"]] as [value, icon]}
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md border px-3 py-1.5 transition
|
||||
{app.theme === value
|
||||
? 'border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-accent)]'
|
||||
: 'border-[var(--color-line)] text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-muted)]'}"
|
||||
onclick={() => applyTheme(value as "light" | "dark")}
|
||||
>
|
||||
<Icon {icon} />
|
||||
{value === "light" ? "Light" : "Dark"}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#snippet footer()}
|
||||
<button
|
||||
class="rounded-md px-3 py-1.5 text-xs text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={onclose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90 disabled:opacity-50"
|
||||
disabled={saving}
|
||||
onclick={save}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
|
||||
const appWindow = getCurrentWindow();
|
||||
|
||||
let maximized = $state(false);
|
||||
|
||||
async function sync() {
|
||||
try {
|
||||
maximized = await appWindow.isMaximized();
|
||||
} catch {
|
||||
maximized = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
sync();
|
||||
const pending = appWindow.onResized(sync);
|
||||
return () => {
|
||||
pending.then((unlisten) => unlisten());
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex items-center">
|
||||
<button
|
||||
class="flex h-11 w-11 items-center justify-center text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
|
||||
onclick={() => appWindow.minimize()}
|
||||
aria-label="Minimize"
|
||||
>
|
||||
<Icon icon="ph:minus" class="text-sm" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex h-11 w-11 items-center justify-center text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
|
||||
onclick={async () => {
|
||||
await appWindow.toggleMaximize();
|
||||
sync();
|
||||
}}
|
||||
aria-label={maximized ? "Restore" : "Maximize"}
|
||||
>
|
||||
<Icon icon={maximized ? "ph:corners-in" : "ph:square"} class="text-sm" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex h-11 w-11 items-center justify-center text-[var(--color-ink-muted)] transition hover:bg-[var(--color-danger)] hover:text-white"
|
||||
onclick={() => appWindow.close()}
|
||||
aria-label="Close"
|
||||
>
|
||||
<Icon icon="ph:x" class="text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,268 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export interface Settings {
|
||||
workspace_root: string;
|
||||
server_url: string;
|
||||
device_token: string | null;
|
||||
account_email: string | null;
|
||||
account_username: string | null;
|
||||
}
|
||||
|
||||
export interface FileEntry {
|
||||
path: string;
|
||||
name: string;
|
||||
is_text: boolean;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface FilePayload {
|
||||
path: string;
|
||||
is_text: boolean;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface Diagnostic {
|
||||
message: string;
|
||||
severity: string;
|
||||
line: number | null;
|
||||
column: number | null;
|
||||
}
|
||||
|
||||
export interface DocumentStats {
|
||||
pages: number;
|
||||
words: number;
|
||||
characters: number;
|
||||
}
|
||||
|
||||
export interface CompileResult {
|
||||
pages: string[];
|
||||
stats: DocumentStats;
|
||||
diagnostics: Diagnostic[];
|
||||
}
|
||||
|
||||
export interface CompileFailure {
|
||||
diagnostics: Diagnostic[];
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
user_id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface SpaceSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
entrypoint: string;
|
||||
role: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Conflict {
|
||||
path: string;
|
||||
local_text: string;
|
||||
remote_text: string;
|
||||
merged_text: string;
|
||||
server_hash: string;
|
||||
auto_merged: boolean;
|
||||
binary: boolean;
|
||||
}
|
||||
|
||||
export interface SyncReport {
|
||||
pushed: string[];
|
||||
pulled: string[];
|
||||
deleted_local: string[];
|
||||
deleted_remote: string[];
|
||||
merged: string[];
|
||||
conflicts: Conflict[];
|
||||
}
|
||||
|
||||
export interface Resolution {
|
||||
path: string;
|
||||
content: string;
|
||||
server_hash: string;
|
||||
}
|
||||
|
||||
export type EntryKind = "folder" | "project" | "document" | "file";
|
||||
|
||||
export interface BrowseEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
kind: EntryKind;
|
||||
size: number;
|
||||
modified: string | null;
|
||||
space_id: string | null;
|
||||
last_synced_at: string | null;
|
||||
child_count: number;
|
||||
}
|
||||
|
||||
export interface TargetInfo {
|
||||
path: string;
|
||||
entrypoint: string;
|
||||
standalone: boolean;
|
||||
is_project: boolean;
|
||||
space_id: string | null;
|
||||
files: FileEntry[];
|
||||
}
|
||||
|
||||
export const browseWorkspace = (path: string) =>
|
||||
invoke<BrowseEntry[]>("browse_workspace", { path });
|
||||
|
||||
export const createFolderEntry = (parent: string, name: string) =>
|
||||
invoke<string>("create_folder_entry", { parent, name });
|
||||
|
||||
export const createDocumentEntry = (parent: string, name: string) =>
|
||||
invoke<string>("create_document_entry", { parent, name });
|
||||
|
||||
export const createProjectEntry = (parent: string, name: string) =>
|
||||
invoke<string>("create_project_entry", { parent, name });
|
||||
|
||||
export const renameEntry = (path: string, newName: string) =>
|
||||
invoke<string>("rename_entry", { path, newName });
|
||||
|
||||
export const deleteEntry = (path: string) =>
|
||||
invoke<void>("delete_entry", { path });
|
||||
|
||||
export const uploadEntry = (
|
||||
parent: string,
|
||||
name: string,
|
||||
base64Content: string,
|
||||
) => invoke<string>("upload_entry", { parent, name, base64Content });
|
||||
|
||||
export const targetInfo = (path: string) =>
|
||||
invoke<TargetInfo>("target_info", { path });
|
||||
|
||||
export const readTargetFile = (path: string, file: string) =>
|
||||
invoke<FilePayload>("read_target_file", { path, file });
|
||||
|
||||
export const writeTargetFile = (path: string, file: string, content: string) =>
|
||||
invoke<void>("write_target_file", { path, file, content });
|
||||
|
||||
export const setTargetEntrypoint = (path: string, entrypoint: string) =>
|
||||
invoke<void>("set_target_entrypoint", { path, entrypoint });
|
||||
|
||||
export const compileTarget = (
|
||||
path: string,
|
||||
overrides?: Record<string, string>,
|
||||
) => invoke<CompileResult>("compile_target", { path, overrides });
|
||||
|
||||
export const exportTarget = (
|
||||
path: string,
|
||||
format: string,
|
||||
destination: string,
|
||||
) => invoke<string>("export_target", { path, format, destination });
|
||||
|
||||
export interface Asset {
|
||||
name: string;
|
||||
kind: "font" | "image" | "file";
|
||||
size: number;
|
||||
font_families: string[];
|
||||
}
|
||||
|
||||
export const listAssets = () => invoke<Asset[]>("list_assets");
|
||||
|
||||
export interface Thumbnail {
|
||||
kind: "svg" | "image";
|
||||
data: string;
|
||||
}
|
||||
|
||||
export const thumbnail = (path: string) =>
|
||||
invoke<Thumbnail>("thumbnail", { path });
|
||||
|
||||
export const clearThumbnails = () => invoke<void>("clear_thumbnails");
|
||||
|
||||
export interface ImageData {
|
||||
name: string;
|
||||
data: string;
|
||||
size: number;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
}
|
||||
|
||||
export const readImage = (path: string) =>
|
||||
invoke<ImageData>("read_image", { path });
|
||||
|
||||
export const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "svg", "webp"];
|
||||
|
||||
export function isImagePath(path: string): boolean {
|
||||
const extension = path.split(".").pop()?.toLowerCase() ?? "";
|
||||
return IMAGE_EXTENSIONS.includes(extension);
|
||||
}
|
||||
|
||||
export const listFontFamilies = (path?: string) =>
|
||||
invoke<string[]>("list_font_families", { path: path ?? null });
|
||||
|
||||
export const importAssets = (sources: string[]) =>
|
||||
invoke<string[]>("import_assets", { sources });
|
||||
|
||||
export const deleteAsset = (name: string) =>
|
||||
invoke<void>("delete_asset", { name });
|
||||
|
||||
export const importIntoTarget = (path: string, sources: string[]) =>
|
||||
invoke<string[]>("import_into_target", { path, sources });
|
||||
|
||||
export const importIntoFolder = (parent: string, sources: string[]) =>
|
||||
invoke<string[]>("import_into_folder", { parent, sources });
|
||||
|
||||
export const getSettings = () => invoke<Settings>("get_settings");
|
||||
|
||||
export const updateSettings = (changes: {
|
||||
workspaceRoot?: string;
|
||||
serverUrl?: string;
|
||||
}) => invoke<Settings>("update_settings", changes);
|
||||
|
||||
export const cloudLogin = (
|
||||
serverUrl: string,
|
||||
email: string,
|
||||
password: string,
|
||||
) => invoke<Account>("cloud_login", { serverUrl, email, password });
|
||||
|
||||
export const cloudLogout = () => invoke<void>("cloud_logout");
|
||||
|
||||
export const cloudAccount = () => invoke<Account | null>("cloud_account");
|
||||
|
||||
export const cloudListSpaces = () =>
|
||||
invoke<SpaceSummary[]>("cloud_list_spaces");
|
||||
|
||||
export const cloudCreateSpace = (name: string) =>
|
||||
invoke<SpaceSummary>("cloud_create_space", { name });
|
||||
|
||||
export const cloudDeleteSpace = (spaceId: string) =>
|
||||
invoke<void>("cloud_delete_space", { spaceId });
|
||||
|
||||
export const cloudCloneSpace = (spaceId: string, projectName: string) =>
|
||||
invoke<SyncReport>("cloud_clone_space", { spaceId, projectName });
|
||||
|
||||
export const cloudLinkProject = (project: string, spaceId?: string) =>
|
||||
invoke<SyncReport>("cloud_link_project", { project, spaceId: spaceId ?? null });
|
||||
|
||||
export const cloudUnlinkProject = (project: string) =>
|
||||
invoke<void>("cloud_unlink_project", { project });
|
||||
|
||||
export const cloudPush = (project: string) =>
|
||||
invoke<SyncReport>("cloud_push", { project });
|
||||
|
||||
export const cloudPull = (project: string) =>
|
||||
invoke<SyncReport>("cloud_pull", { project });
|
||||
|
||||
export const cloudSync = (project: string) =>
|
||||
invoke<SyncReport>("cloud_sync", { project });
|
||||
|
||||
export const cloudResolveConflicts = (
|
||||
project: string,
|
||||
resolutions: Resolution[],
|
||||
) => invoke<SyncReport>("cloud_resolve_conflicts", { project, resolutions });
|
||||
|
||||
export function errorMessage(error: unknown): string {
|
||||
if (typeof error === "string") return error;
|
||||
if (error && typeof error === "object" && "diagnostics" in error) {
|
||||
const failure = error as CompileFailure;
|
||||
return failure.diagnostics.map((d) => d.message).join("; ");
|
||||
}
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export function isCompileFailure(error: unknown): error is CompileFailure {
|
||||
return Boolean(error && typeof error === "object" && "diagnostics" in error);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
snippetCompletion,
|
||||
type CompletionContext,
|
||||
} from "@codemirror/autocomplete";
|
||||
|
||||
const typstOptions = [
|
||||
|
||||
snippetCompletion("let ${name} = ${value}", { label: "let", type: "keyword", info: "Variable declaration" }),
|
||||
snippetCompletion("set ${rule}(${value})", { label: "set", type: "keyword", info: "Set rule" }),
|
||||
snippetCompletion("show ${selector}: ${rule}", { label: "show", type: "keyword", info: "Show rule" }),
|
||||
snippetCompletion("import \"${module}\": ${items}", { label: "import", type: "keyword", info: "Import module" }),
|
||||
snippetCompletion("include \"${file}\"", { label: "include", type: "keyword", info: "Include file" }),
|
||||
snippetCompletion("if ${condition} {\n\t${}\n}", { label: "if", type: "keyword", info: "If statement" }),
|
||||
snippetCompletion("else {\n\t${}\n}", { label: "else", type: "keyword", info: "Else statement" }),
|
||||
snippetCompletion("for ${item} in ${collection} {\n\t${}\n}", { label: "for", type: "keyword", info: "For loop" }),
|
||||
snippetCompletion("while ${condition} {\n\t${}\n}", { label: "while", type: "keyword", info: "While loop" }),
|
||||
snippetCompletion("break", { label: "break", type: "keyword", info: "Break loop" }),
|
||||
snippetCompletion("continue", { label: "continue", type: "keyword", info: "Continue loop" }),
|
||||
snippetCompletion("return ${value}", { label: "return", type: "keyword", info: "Return value" }),
|
||||
snippetCompletion("context", { label: "context", type: "keyword", info: "Context expression" }),
|
||||
snippetCompletion("align(${alignment})[${content}]", { label: "align", type: "function", info: "Align content" }),
|
||||
snippetCompletion("page(${content})", { label: "page", type: "function", info: "Page configuration" }),
|
||||
snippetCompletion("pagebreak()", { label: "pagebreak", type: "function", info: "Break page" }),
|
||||
snippetCompletion("colbreak()", { label: "colbreak", type: "function", info: "Break column" }),
|
||||
snippetCompletion("place(${alignment})[${content}]", { label: "place", type: "function", info: "Place content" }),
|
||||
snippetCompletion("columns(${2})[${content}]", { label: "columns", type: "function", info: "Multiple columns" }),
|
||||
snippetCompletion("pad(${10pt})[${content}]", { label: "pad", type: "function", info: "Pad content" }),
|
||||
snippetCompletion("stack(dir: ${ttb}, spacing: ${10pt}, ${items})", { label: "stack", type: "function", info: "Stack items" }),
|
||||
snippetCompletion("grid(columns: ${2}, gutter: ${10pt}, ${items})", { label: "grid", type: "function", info: "Grid layout" }),
|
||||
snippetCompletion("table(columns: ${2}, ${items})", { label: "table", type: "function", info: "Table layout" }),
|
||||
snippetCompletion("rect(width: ${100%}, height: ${100%})[${content}]", { label: "rect", type: "function", info: "Draw rectangle" }),
|
||||
snippetCompletion("square(size: ${10pt})[${content}]", { label: "square", type: "function", info: "Draw square" }),
|
||||
snippetCompletion("circle(radius: ${10pt})[${content}]", { label: "circle", type: "function", info: "Draw circle" }),
|
||||
snippetCompletion("ellipse(width: ${20pt}, height: ${10pt})[${content}]", { label: "ellipse", type: "function", info: "Draw ellipse" }),
|
||||
snippetCompletion("line(length: ${100%})", { label: "line", type: "function", info: "Draw line" }),
|
||||
snippetCompletion("polygon(${vertices})", { label: "polygon", type: "function", info: "Draw polygon" }),
|
||||
snippetCompletion("path(${vertices})", { label: "path", type: "function", info: "Draw path" }),
|
||||
snippetCompletion("image(\"${path}\", width: ${100%})", { label: "image", type: "function", info: "Insert image" }),
|
||||
snippetCompletion("box[${content}]", { label: "box", type: "function", info: "Box inline content" }),
|
||||
snippetCompletion("block[${content}]", { label: "block", type: "function", info: "Block content" }),
|
||||
snippetCompletion("figure(${content}, caption: [${caption}])", { label: "figure", type: "function", info: "Figure with caption" }),
|
||||
snippetCompletion("text(size: ${11pt}, font: \"${Arial}\")[${content}]", { label: "text", type: "function", info: "Text styling" }),
|
||||
snippetCompletion("heading(level: ${1})[${title}]", { label: "heading", type: "function", info: "Heading" }),
|
||||
snippetCompletion("par[${content}]", { label: "par", type: "function", info: "Paragraph" }),
|
||||
snippetCompletion("list([${item}])", { label: "list", type: "function", info: "Bullet list" }),
|
||||
snippetCompletion("enum([${item}])", { label: "enum", type: "function", info: "Numbered list" }),
|
||||
snippetCompletion("terms([${term}], [${description}])", { label: "terms", type: "function", info: "Terms list" }),
|
||||
snippetCompletion("strong[${content}]", { label: "strong", type: "function", info: "Bold text" }),
|
||||
snippetCompletion("emph[${content}]", { label: "emph", type: "function", info: "Italic text" }),
|
||||
snippetCompletion("underline[${content}]", { label: "underline", type: "function", info: "Underline text" }),
|
||||
snippetCompletion("strike[${content}]", { label: "strike", type: "function", info: "Strikethrough text" }),
|
||||
snippetCompletion("overline[${content}]", { label: "overline", type: "function", info: "Overline text" }),
|
||||
snippetCompletion("sub[${content}]", { label: "sub", type: "function", info: "Subscript text" }),
|
||||
snippetCompletion("super[${content}]", { label: "super", type: "function", info: "Superscript text" }),
|
||||
snippetCompletion("raw(\"${code}\", block: ${true})", { label: "raw", type: "function", info: "Raw code block" }),
|
||||
snippetCompletion("link(\"${url}\")[${text}]", { label: "link", type: "function", info: "Hyperlink" }),
|
||||
snippetCompletion("ref(<${label}>)", { label: "ref", type: "function", info: "Reference" }),
|
||||
snippetCompletion("cite(<${label}>)", { label: "cite", type: "function", info: "Citation" }),
|
||||
snippetCompletion("bibliography(\"${file.bib}\")", { label: "bibliography", type: "function", info: "Bibliography" }),
|
||||
snippetCompletion("outline(title: [${Contents}])", { label: "outline", type: "function", info: "Table of contents" }),
|
||||
snippetCompletion("rgb(\"${#000000}\")", { label: "rgb", type: "function", info: "RGB Color" }),
|
||||
snippetCompletion("cmyk(${0%}, ${0%}, ${0%}, ${100%})", { label: "cmyk", type: "function", info: "CMYK Color" }),
|
||||
snippetCompletion("luma(${0%})", { label: "luma", type: "function", info: "Luma (Grayscale) Color" }),
|
||||
snippetCompletion("color", { label: "color", type: "variable" }),
|
||||
snippetCompletion("gradient", { label: "gradient", type: "variable" }),
|
||||
snippetCompletion("pattern(size: (${10pt}, ${10pt}))[${content}]", { label: "pattern", type: "function", info: "Fill pattern" }),
|
||||
snippetCompletion("type(${value})", { label: "type", type: "function", info: "Get type of value" }),
|
||||
snippetCompletion("repr(${value})", { label: "repr", type: "function", info: "String representation" }),
|
||||
snippetCompletion("str(${value})", { label: "str", type: "function", info: "Convert to string" }),
|
||||
snippetCompletion("int(${value})", { label: "int", type: "function", info: "Convert to integer" }),
|
||||
snippetCompletion("float(${value})", { label: "float", type: "function", info: "Convert to float" }),
|
||||
snippetCompletion("datetime(year: ${2024}, month: ${1}, day: ${1})", { label: "datetime", type: "function", info: "Date and time" }),
|
||||
snippetCompletion("math", { label: "math", type: "variable", info: "Math module" }),
|
||||
snippetCompletion("calc", { label: "calc", type: "variable", info: "Calc module" }),
|
||||
snippetCompletion("sys", { label: "sys", type: "variable", info: "System module" }),
|
||||
snippetCompletion("frac(${num}, ${denom})", { label: "frac", type: "function", info: "Fraction (Math)" }),
|
||||
snippetCompletion("binom(${n}, ${k})", { label: "binom", type: "function", info: "Binomial (Math)" }),
|
||||
snippetCompletion("mat(${1}, ${2}; ${3}, ${4})", { label: "mat", type: "function", info: "Matrix (Math)" }),
|
||||
snippetCompletion("vec(${1}, ${2})", { label: "vec", type: "function", info: "Vector (Math)" }),
|
||||
snippetCompletion("cases(${a}, ${b})", { label: "cases", type: "function", info: "Cases (Math)" }),
|
||||
snippetCompletion("sqrt(${x})", { label: "sqrt", type: "function", info: "Square root (Math)" }),
|
||||
snippetCompletion("root(${3}, ${x})", { label: "root", type: "function", info: "N-th root (Math)" }),
|
||||
snippetCompletion("abs(${x})", { label: "abs", type: "function", info: "Absolute value (Math)" }),
|
||||
snippetCompletion("norm(${x})", { label: "norm", type: "function", info: "Norm (Math)" }),
|
||||
snippetCompletion("floor(${x})", { label: "floor", type: "function", info: "Floor (Math)" }),
|
||||
snippetCompletion("ceil(${x})", { label: "ceil", type: "function", info: "Ceiling (Math)" }),
|
||||
snippetCompletion("round(${x})", { label: "round", type: "function", info: "Round (Math)" }),
|
||||
snippetCompletion("cancel(${x})", { label: "cancel", type: "function", info: "Cancel/strike (Math)" }),
|
||||
snippetCompletion("attach(${base}, t: ${top}, b: ${bottom})", { label: "attach", type: "function", info: "Attach scripts (Math)" }),
|
||||
snippetCompletion("scripts(${expr})", { label: "scripts", type: "function", info: "Scripts (Math)" }),
|
||||
snippetCompletion("limits(${expr})", { label: "limits", type: "function", info: "Limits (Math)" }),
|
||||
snippetCompletion("op(\"${name}\")", { label: "op", type: "function", info: "Operator (Math)" }),
|
||||
snippetCompletion("lr(${expr})", { label: "lr", type: "function", info: "Left/Right scales (Math)" }),
|
||||
snippetCompletion("mid(${|})", { label: "mid", type: "function", info: "Mid delimiter (Math)" })
|
||||
];
|
||||
|
||||
export function typstCompletions(context: CompletionContext) {
|
||||
const word = context.matchBefore(/[\w#]*/);
|
||||
if (!word || (word.from === word.to && !context.explicit)) return null;
|
||||
|
||||
return {
|
||||
from: word.text.startsWith("#") ? word.from + 1 : word.from,
|
||||
options: typstOptions,
|
||||
validFor: /^[\w]*$/,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { undo, redo } from "@codemirror/commands";
|
||||
|
||||
export function insertText(view: EditorView | null, text: string) {
|
||||
if (!view) return;
|
||||
const { from, to } = view.state.selection.main;
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: text },
|
||||
selection: { anchor: from + text.length },
|
||||
});
|
||||
view.focus();
|
||||
}
|
||||
|
||||
export function wrapSelection(
|
||||
view: EditorView | null,
|
||||
prefix: string,
|
||||
suffix: string,
|
||||
placeholder = "",
|
||||
) {
|
||||
if (!view) return;
|
||||
const selection = view.state.selection.main;
|
||||
const selected = view.state.doc.sliceString(selection.from, selection.to);
|
||||
const body = selected || placeholder;
|
||||
|
||||
view.dispatch({
|
||||
changes: { from: selection.from, to: selection.to, insert: prefix + body + suffix },
|
||||
selection: {
|
||||
anchor: selection.from + prefix.length,
|
||||
head: selection.from + prefix.length + body.length,
|
||||
},
|
||||
});
|
||||
view.focus();
|
||||
}
|
||||
|
||||
export function prefixLines(
|
||||
view: EditorView | null,
|
||||
prefix: string,
|
||||
placeholder = "",
|
||||
) {
|
||||
if (!view) return;
|
||||
const selection = view.state.selection.main;
|
||||
const startLine = view.state.doc.lineAt(selection.from);
|
||||
const endLine = view.state.doc.lineAt(selection.to);
|
||||
|
||||
if (selection.empty && startLine.text.trim() === "") {
|
||||
insertText(view, prefix + placeholder);
|
||||
return;
|
||||
}
|
||||
|
||||
const changes = [];
|
||||
for (let number = startLine.number; number <= endLine.number; number += 1) {
|
||||
const line = view.state.doc.line(number);
|
||||
if (line.text.startsWith(prefix)) continue;
|
||||
changes.push({ from: line.from, insert: prefix });
|
||||
}
|
||||
|
||||
view.dispatch({ changes });
|
||||
view.focus();
|
||||
}
|
||||
|
||||
export function undoEdit(view: EditorView | null) {
|
||||
if (!view) return;
|
||||
undo(view);
|
||||
view.focus();
|
||||
}
|
||||
|
||||
export function redoEdit(view: EditorView | null) {
|
||||
if (!view) return;
|
||||
redo(view);
|
||||
view.focus();
|
||||
}
|
||||
|
||||
export function setTypstConfig(
|
||||
view: EditorView | null,
|
||||
setting: string,
|
||||
property: string,
|
||||
value: string,
|
||||
) {
|
||||
if (!view) return;
|
||||
|
||||
const content = view.state.doc.toString();
|
||||
const rule = new RegExp(`^#set\\s+${setting}\\s*\\(([^)]*)\\)`, "m");
|
||||
const match = content.match(rule);
|
||||
|
||||
if (!match || match.index === undefined) {
|
||||
view.dispatch({
|
||||
changes: { from: 0, insert: `#set ${setting}(${property}: ${value})\n` },
|
||||
});
|
||||
view.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = match[1];
|
||||
const property_rule = new RegExp(
|
||||
`${property}\\s*:\\s*(?:\\([^)]*\\)|"[^"]*"|[^,)]+)`,
|
||||
);
|
||||
|
||||
const next = property_rule.test(existing)
|
||||
? existing.replace(property_rule, `${property}: ${value}`)
|
||||
: existing.trim()
|
||||
? `${existing}, ${property}: ${value}`
|
||||
: `${property}: ${value}`;
|
||||
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: match.index,
|
||||
to: match.index + match[0].length,
|
||||
insert: `#set ${setting}(${next})`,
|
||||
},
|
||||
});
|
||||
view.focus();
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||||
import { tags as t } from "@lezer/highlight";
|
||||
|
||||
interface ThemeColors {
|
||||
background: string;
|
||||
surface: string;
|
||||
text: string;
|
||||
selection: string;
|
||||
activeLine: string;
|
||||
cursor: string;
|
||||
border: string;
|
||||
keyword: string;
|
||||
string: string;
|
||||
number: string;
|
||||
comment: string;
|
||||
variable: string;
|
||||
function: string;
|
||||
heading: string;
|
||||
}
|
||||
|
||||
const palette: Record<"light" | "dark", ThemeColors> = {
|
||||
light: {
|
||||
background: "#ffffff",
|
||||
surface: "#f6f7f9",
|
||||
text: "#14161a",
|
||||
selection: "#dbe6fe",
|
||||
activeLine: "#f6f7f9",
|
||||
cursor: "#3b6cf6",
|
||||
border: "#dfe2e7",
|
||||
keyword: "#7c3aed",
|
||||
string: "#0f766e",
|
||||
number: "#b45309",
|
||||
comment: "#6b7280",
|
||||
variable: "#14161a",
|
||||
function: "#2563eb",
|
||||
heading: "#1d4ed8",
|
||||
},
|
||||
dark: {
|
||||
background: "#16181d",
|
||||
surface: "#1d2026",
|
||||
text: "#eef0f4",
|
||||
selection: "#2f3a52",
|
||||
activeLine: "#1d2026",
|
||||
cursor: "#6b93ff",
|
||||
border: "#2f343d",
|
||||
keyword: "#c4a7f7",
|
||||
string: "#8ddba4",
|
||||
number: "#f0b37e",
|
||||
comment: "#7b8496",
|
||||
variable: "#eef0f4",
|
||||
function: "#7aa2ff",
|
||||
heading: "#8fb3ff",
|
||||
},
|
||||
};
|
||||
|
||||
export function editorTheme(isDark: boolean) {
|
||||
const colors = palette[isDark ? "dark" : "light"];
|
||||
|
||||
const theme = EditorView.theme(
|
||||
{
|
||||
"&": {
|
||||
color: colors.text,
|
||||
backgroundColor: colors.background,
|
||||
height: "100%",
|
||||
fontSize: "13px",
|
||||
},
|
||||
".cm-content": {
|
||||
caretColor: colors.cursor,
|
||||
padding: "12px 0",
|
||||
},
|
||||
".cm-cursor, .cm-dropCursor": { borderLeftColor: colors.cursor },
|
||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":
|
||||
{ backgroundColor: colors.selection },
|
||||
".cm-activeLine": { backgroundColor: colors.activeLine },
|
||||
".cm-gutters": {
|
||||
backgroundColor: colors.background,
|
||||
color: colors.comment,
|
||||
border: "none",
|
||||
},
|
||||
".cm-activeLineGutter": { backgroundColor: colors.activeLine },
|
||||
"&.cm-focused .cm-matchingBracket": {
|
||||
backgroundColor: colors.selection,
|
||||
outline: `1px solid ${colors.border}`,
|
||||
},
|
||||
|
||||
".cm-tooltip": {
|
||||
backgroundColor: colors.surface,
|
||||
color: colors.text,
|
||||
border: `1px solid ${colors.border}`,
|
||||
borderRadius: "6px",
|
||||
maxWidth: "500px",
|
||||
},
|
||||
".cm-tooltip-hover": { maxHeight: "300px", overflow: "auto" },
|
||||
".cm-tooltip .cm-tooltip-arrow:before": {
|
||||
borderTopColor: colors.border,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
".cm-tooltip .cm-tooltip-arrow:after": {
|
||||
borderTopColor: colors.surface,
|
||||
borderBottomColor: colors.surface,
|
||||
},
|
||||
|
||||
".cm-tooltip.cm-tooltip-autocomplete": {
|
||||
backgroundColor: colors.surface,
|
||||
border: `1px solid ${colors.border}`,
|
||||
padding: "4px",
|
||||
},
|
||||
".cm-tooltip.cm-tooltip-autocomplete > ul": {
|
||||
fontFamily: "inherit",
|
||||
maxHeight: "16em",
|
||||
},
|
||||
".cm-tooltip.cm-tooltip-autocomplete > ul > li": {
|
||||
color: colors.text,
|
||||
padding: "3px 8px",
|
||||
borderRadius: "4px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
},
|
||||
".cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected]": {
|
||||
backgroundColor: colors.cursor,
|
||||
color: "#ffffff",
|
||||
},
|
||||
".cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected] .cm-completionDetail":
|
||||
{ color: "#ffffff" },
|
||||
".cm-completionLabel": { color: "inherit" },
|
||||
".cm-completionMatchedText": {
|
||||
textDecoration: "none",
|
||||
fontWeight: "600",
|
||||
color: "inherit",
|
||||
},
|
||||
".cm-completionDetail": {
|
||||
color: colors.comment,
|
||||
fontStyle: "normal",
|
||||
marginLeft: "auto",
|
||||
fontSize: "0.85em",
|
||||
},
|
||||
".cm-completionIcon": {
|
||||
color: colors.comment,
|
||||
opacity: "1",
|
||||
width: "1.1em",
|
||||
},
|
||||
".cm-completionInfo": {
|
||||
backgroundColor: colors.surface,
|
||||
color: colors.text,
|
||||
border: `1px solid ${colors.border}`,
|
||||
borderRadius: "6px",
|
||||
padding: "6px 8px",
|
||||
},
|
||||
|
||||
".cm-panels": { backgroundColor: colors.surface, color: colors.text },
|
||||
".cm-searchMatch": { backgroundColor: "#72a1ff59" },
|
||||
".cm-selectionMatch": { backgroundColor: "#aafe661a" },
|
||||
},
|
||||
{ dark: isDark },
|
||||
);
|
||||
|
||||
const highlightStyle = HighlightStyle.define([
|
||||
{ tag: t.keyword, color: colors.keyword },
|
||||
{
|
||||
tag: [t.name, t.deleted, t.character, t.propertyName, t.macroName],
|
||||
color: colors.variable,
|
||||
},
|
||||
{ tag: [t.function(t.variableName), t.labelName], color: colors.function },
|
||||
{
|
||||
tag: [t.color, t.constant(t.name), t.standard(t.name)],
|
||||
color: colors.function,
|
||||
},
|
||||
{ tag: [t.definition(t.name), t.separator], color: colors.variable },
|
||||
{
|
||||
tag: [
|
||||
t.typeName,
|
||||
t.className,
|
||||
t.number,
|
||||
t.changed,
|
||||
t.annotation,
|
||||
t.modifier,
|
||||
t.self,
|
||||
t.namespace,
|
||||
],
|
||||
color: colors.number,
|
||||
},
|
||||
{
|
||||
tag: [
|
||||
t.operator,
|
||||
t.operatorKeyword,
|
||||
t.url,
|
||||
t.escape,
|
||||
t.regexp,
|
||||
t.special(t.string),
|
||||
],
|
||||
color: colors.keyword,
|
||||
},
|
||||
{ tag: [t.meta, t.comment], color: colors.comment, fontStyle: "italic" },
|
||||
{ tag: t.strong, fontWeight: "bold" },
|
||||
{ tag: t.emphasis, fontStyle: "italic" },
|
||||
{ tag: t.strikethrough, textDecoration: "line-through" },
|
||||
{ tag: t.link, color: colors.function, textDecoration: "underline" },
|
||||
{ tag: t.heading, fontWeight: "bold", color: colors.heading },
|
||||
{ tag: [t.atom, t.bool, t.special(t.variableName)], color: colors.number },
|
||||
{
|
||||
tag: [t.processingInstruction, t.string, t.inserted],
|
||||
color: colors.string,
|
||||
},
|
||||
{ tag: t.invalid, color: "#ff5c57" },
|
||||
]);
|
||||
|
||||
return [theme, syntaxHighlighting(highlightStyle, { fallback: true })];
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
export const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "svg", "webp"];
|
||||
export const FONT_EXTENSIONS = ["ttf", "otf", "ttc", "otc"];
|
||||
export const DATA_EXTENSIONS = ["bib", "csl", "json", "yaml", "yml", "csv", "toml"];
|
||||
|
||||
export type PickKind = "all" | "assets" | "images" | "fonts";
|
||||
|
||||
export async function pickFiles(kind: PickKind = "all"): Promise<string[]> {
|
||||
const filters =
|
||||
kind === "images"
|
||||
? [{ name: "Images", extensions: IMAGE_EXTENSIONS }]
|
||||
: kind === "fonts"
|
||||
? [{ name: "Fonts", extensions: FONT_EXTENSIONS }]
|
||||
: kind === "assets"
|
||||
? [
|
||||
{
|
||||
name: "Images and fonts",
|
||||
extensions: [...IMAGE_EXTENSIONS, ...FONT_EXTENSIONS],
|
||||
},
|
||||
{ name: "Images", extensions: IMAGE_EXTENSIONS },
|
||||
{ name: "Fonts", extensions: FONT_EXTENSIONS },
|
||||
]
|
||||
: [
|
||||
{
|
||||
name: "Typst files",
|
||||
extensions: ["typ", ...DATA_EXTENSIONS, ...IMAGE_EXTENSIONS, ...FONT_EXTENSIONS],
|
||||
},
|
||||
];
|
||||
|
||||
const selected = await open({ multiple: true, filters });
|
||||
|
||||
if (!selected) return [];
|
||||
return Array.isArray(selected) ? selected : [selected];
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
|
||||
export interface LspHandle {
|
||||
root_uri: string;
|
||||
document_uri: string;
|
||||
}
|
||||
|
||||
type Handler = (value: string) => void;
|
||||
|
||||
export class LspBridge {
|
||||
private handlers: Handler[] = [];
|
||||
private unlistenMessage: UnlistenFn | null = null;
|
||||
private unlistenClosed: UnlistenFn | null = null;
|
||||
|
||||
handle: LspHandle | null = null;
|
||||
|
||||
readonly transport = {
|
||||
send: (message: string) => {
|
||||
invoke("lsp_send", { message }).catch(() => {});
|
||||
},
|
||||
subscribe: (handler: Handler) => {
|
||||
this.handlers.push(handler);
|
||||
},
|
||||
unsubscribe: (handler: Handler) => {
|
||||
this.handlers = this.handlers.filter((existing) => existing !== handler);
|
||||
},
|
||||
};
|
||||
|
||||
async start(path: string, onClosed?: () => void): Promise<LspHandle> {
|
||||
await this.stop();
|
||||
|
||||
this.unlistenMessage = await listen<string>("lsp://message", (event) => {
|
||||
const message = this.filterDiagnostics(event.payload);
|
||||
for (const handler of this.handlers) handler(message);
|
||||
});
|
||||
|
||||
this.unlistenClosed = await listen("lsp://closed", () => {
|
||||
onClosed?.();
|
||||
});
|
||||
|
||||
this.handle = await invoke<LspHandle>("lsp_start", { path });
|
||||
return this.handle;
|
||||
}
|
||||
|
||||
private filterDiagnostics(message: string): string {
|
||||
if (!message.includes("publishDiagnostics")) return message;
|
||||
try {
|
||||
const parsed = JSON.parse(message);
|
||||
if (parsed.method === "textDocument/publishDiagnostics") {
|
||||
parsed.params.diagnostics = parsed.params.diagnostics.filter(
|
||||
(diagnostic: { message: string }) =>
|
||||
!diagnostic.message.toLowerCase().includes("unknown font family"),
|
||||
);
|
||||
return JSON.stringify(parsed);
|
||||
}
|
||||
} catch {
|
||||
return message;
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
async stop() {
|
||||
this.unlistenMessage?.();
|
||||
this.unlistenClosed?.();
|
||||
this.unlistenMessage = null;
|
||||
this.unlistenClosed = null;
|
||||
this.handlers = [];
|
||||
this.handle = null;
|
||||
await invoke("lsp_stop").catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export const lspAvailable = () => invoke<boolean>("lsp_running");
|
||||
@@ -0,0 +1,348 @@
|
||||
import * as api from "./api";
|
||||
import type {
|
||||
Account,
|
||||
BrowseEntry,
|
||||
CompileResult,
|
||||
Conflict,
|
||||
Diagnostic,
|
||||
Settings,
|
||||
SpaceSummary,
|
||||
TargetInfo,
|
||||
} from "./api";
|
||||
|
||||
export type Scope = "local" | "cloud";
|
||||
export type View = "files" | "editor";
|
||||
export type LspStatus = "off" | "starting" | "on" | "unavailable";
|
||||
|
||||
interface AppState {
|
||||
view: View;
|
||||
scope: Scope;
|
||||
settings: Settings | null;
|
||||
account: Account | null;
|
||||
|
||||
currentDir: string;
|
||||
entries: BrowseEntry[];
|
||||
spaces: SpaceSummary[];
|
||||
|
||||
target: TargetInfo | null;
|
||||
activePath: string | null;
|
||||
editorContent: string;
|
||||
dirty: boolean;
|
||||
compiled: CompileResult | null;
|
||||
diagnostics: Diagnostic[];
|
||||
compiling: boolean;
|
||||
lspStatus: LspStatus;
|
||||
|
||||
syncing: boolean;
|
||||
conflicts: Conflict[];
|
||||
status: string;
|
||||
error: string;
|
||||
theme: "light" | "dark";
|
||||
}
|
||||
|
||||
export const app = $state<AppState>({
|
||||
view: "files",
|
||||
scope: "local",
|
||||
settings: null,
|
||||
account: null,
|
||||
|
||||
currentDir: "",
|
||||
entries: [],
|
||||
spaces: [],
|
||||
|
||||
target: null,
|
||||
activePath: null,
|
||||
editorContent: "",
|
||||
dirty: false,
|
||||
compiled: null,
|
||||
diagnostics: [],
|
||||
compiling: false,
|
||||
lspStatus: "off",
|
||||
|
||||
syncing: false,
|
||||
conflicts: [],
|
||||
status: "",
|
||||
error: "",
|
||||
theme: "light",
|
||||
});
|
||||
|
||||
export function setError(error: unknown) {
|
||||
app.error = api.errorMessage(error);
|
||||
app.status = "";
|
||||
}
|
||||
|
||||
export function setStatus(message: string) {
|
||||
app.status = message;
|
||||
app.error = "";
|
||||
}
|
||||
|
||||
export function clearMessages() {
|
||||
app.status = "";
|
||||
app.error = "";
|
||||
}
|
||||
|
||||
export function applyTheme(theme: "light" | "dark") {
|
||||
app.theme = theme;
|
||||
document.documentElement.dataset.theme = theme;
|
||||
localStorage.setItem("typst-desktop-theme", theme);
|
||||
}
|
||||
|
||||
export function breadcrumbs(): { name: string; path: string }[] {
|
||||
if (!app.currentDir) return [];
|
||||
const segments = app.currentDir.split("/");
|
||||
return segments.map((name, index) => ({
|
||||
name,
|
||||
path: segments.slice(0, index + 1).join("/"),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function bootstrap() {
|
||||
const stored = localStorage.getItem("typst-desktop-theme");
|
||||
applyTheme(stored === "dark" ? "dark" : "light");
|
||||
|
||||
try {
|
||||
app.settings = await api.getSettings();
|
||||
await browseTo("");
|
||||
await refreshAccount();
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function browseTo(path: string) {
|
||||
try {
|
||||
app.entries = await api.browseWorkspace(path);
|
||||
app.currentDir = path;
|
||||
clearMessages();
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshEntries() {
|
||||
await browseTo(app.currentDir);
|
||||
}
|
||||
|
||||
export async function refreshAccount() {
|
||||
try {
|
||||
app.account = await api.cloudAccount();
|
||||
if (app.account) {
|
||||
await refreshSpaces();
|
||||
} else {
|
||||
app.spaces = [];
|
||||
}
|
||||
} catch {
|
||||
app.account = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshSpaces() {
|
||||
try {
|
||||
app.spaces = await api.cloudListSpaces();
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function openTarget(path: string) {
|
||||
try {
|
||||
const target = await api.targetInfo(path);
|
||||
app.target = target;
|
||||
app.view = "editor";
|
||||
app.activePath = null;
|
||||
app.editorContent = "";
|
||||
app.dirty = false;
|
||||
app.compiled = null;
|
||||
app.diagnostics = [];
|
||||
app.lspStatus = "off";
|
||||
clearMessages();
|
||||
|
||||
const preferred =
|
||||
target.files.find((file) => file.path === target.entrypoint) ??
|
||||
target.files.find((file) => file.path.endsWith(".typ")) ??
|
||||
target.files[0];
|
||||
|
||||
if (preferred) await openFile(preferred.path);
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeTarget() {
|
||||
cancelScheduledCompile();
|
||||
if (app.dirty) await saveActiveFile();
|
||||
app.view = "files";
|
||||
app.target = null;
|
||||
app.activePath = null;
|
||||
app.editorContent = "";
|
||||
app.compiled = null;
|
||||
app.diagnostics = [];
|
||||
app.lspStatus = "off";
|
||||
await refreshEntries();
|
||||
}
|
||||
|
||||
export async function refreshTarget() {
|
||||
if (!app.target) return;
|
||||
try {
|
||||
app.target = await api.targetInfo(app.target.path);
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function openFile(file: string) {
|
||||
if (!app.target) return;
|
||||
|
||||
cancelScheduledCompile();
|
||||
|
||||
if (app.dirty && app.activePath) await saveActiveFile();
|
||||
|
||||
try {
|
||||
const payload = await api.readTargetFile(app.target.path, file);
|
||||
app.activePath = file;
|
||||
app.editorContent = payload.is_text ? payload.content : "";
|
||||
app.dirty = false;
|
||||
if (payload.is_text) await compile();
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveActiveFile() {
|
||||
if (!app.target || !app.activePath) return;
|
||||
try {
|
||||
await api.writeTargetFile(
|
||||
app.target.path,
|
||||
app.activePath,
|
||||
app.editorContent,
|
||||
);
|
||||
app.dirty = false;
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function liveOverrides(): Record<string, string> | undefined {
|
||||
if (!app.dirty || !app.activePath) return undefined;
|
||||
return { [app.activePath]: app.editorContent };
|
||||
}
|
||||
|
||||
let compileRunning = false;
|
||||
let compileQueued = false;
|
||||
|
||||
export async function compile() {
|
||||
if (!app.target) return;
|
||||
|
||||
if (compileRunning) {
|
||||
compileQueued = true;
|
||||
return;
|
||||
}
|
||||
|
||||
compileRunning = true;
|
||||
app.compiling = true;
|
||||
|
||||
try {
|
||||
const result = await api.compileTarget(app.target.path, liveOverrides());
|
||||
app.compiled = result;
|
||||
app.diagnostics = result.diagnostics;
|
||||
} catch (error) {
|
||||
if (api.isCompileFailure(error)) {
|
||||
app.diagnostics = error.diagnostics;
|
||||
} else {
|
||||
setError(error);
|
||||
}
|
||||
} finally {
|
||||
compileRunning = false;
|
||||
app.compiling = false;
|
||||
|
||||
if (compileQueued) {
|
||||
compileQueued = false;
|
||||
await compile();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const COMPILE_DEBOUNCE_MS = 400;
|
||||
let compileTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
export function scheduleCompile() {
|
||||
if (compileTimer) clearTimeout(compileTimer);
|
||||
compileTimer = setTimeout(() => {
|
||||
compileTimer = null;
|
||||
compile();
|
||||
}, COMPILE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
export function cancelScheduledCompile() {
|
||||
if (compileTimer) {
|
||||
clearTimeout(compileTimer);
|
||||
compileTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveAndCompile() {
|
||||
cancelScheduledCompile();
|
||||
await saveActiveFile();
|
||||
await compile();
|
||||
}
|
||||
|
||||
export async function runSync(
|
||||
action: "sync" | "push" | "pull",
|
||||
project = app.target?.path,
|
||||
) {
|
||||
if (!project) return;
|
||||
|
||||
app.syncing = true;
|
||||
clearMessages();
|
||||
|
||||
try {
|
||||
const report =
|
||||
action === "push"
|
||||
? await api.cloudPush(project)
|
||||
: action === "pull"
|
||||
? await api.cloudPull(project)
|
||||
: await api.cloudSync(project);
|
||||
|
||||
app.conflicts = report.conflicts;
|
||||
|
||||
if (report.conflicts.length > 0) {
|
||||
setError(`${report.conflicts.length} file(s) need conflict resolution`);
|
||||
} else {
|
||||
setStatus(summarize(report));
|
||||
}
|
||||
|
||||
await refreshTarget();
|
||||
if (app.activePath) {
|
||||
const payload = await api.readTargetFile(project, app.activePath);
|
||||
if (payload.is_text) {
|
||||
app.editorContent = payload.content;
|
||||
app.dirty = false;
|
||||
}
|
||||
}
|
||||
await compile();
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
} finally {
|
||||
app.syncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function summarize(report: {
|
||||
pushed: string[];
|
||||
pulled: string[];
|
||||
merged: string[];
|
||||
deleted_local: string[];
|
||||
deleted_remote: string[];
|
||||
}): string {
|
||||
const parts: string[] = [];
|
||||
if (report.pushed.length) parts.push(`${report.pushed.length} uploaded`);
|
||||
if (report.pulled.length) parts.push(`${report.pulled.length} downloaded`);
|
||||
if (report.merged.length) parts.push(`${report.merged.length} merged`);
|
||||
if (report.deleted_local.length)
|
||||
parts.push(`${report.deleted_local.length} removed locally`);
|
||||
if (report.deleted_remote.length)
|
||||
parts.push(`${report.deleted_remote.length} removed in cloud`);
|
||||
return parts.length
|
||||
? `Sync complete: ${parts.join(", ")}`
|
||||
: "Already up to date";
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
import "../app.css";
|
||||
import { onMount } from "svelte";
|
||||
import { bootstrap } from "$lib/ts/state.svelte";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
onMount(bootstrap);
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Tauri doesn't have a Node.js server to do proper SSR
|
||||
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
|
||||
// See: https://svelte.dev/docs/kit/single-page-apps
|
||||
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
|
||||
export const ssr = false;
|
||||
@@ -0,0 +1,768 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||
|
||||
import FileViewer from "$lib/components/FileViewer.svelte";
|
||||
import FileTree from "$lib/components/FileTree.svelte";
|
||||
import Editor from "$lib/components/Editor.svelte";
|
||||
import Preview from "$lib/components/Preview.svelte";
|
||||
import PromptModal from "$lib/components/PromptModal.svelte";
|
||||
import ConfirmModal from "$lib/components/ConfirmModal.svelte";
|
||||
import ConflictModal from "$lib/components/ConflictModal.svelte";
|
||||
import LoginModal from "$lib/components/LoginModal.svelte";
|
||||
import SettingsModal from "$lib/components/SettingsModal.svelte";
|
||||
import AssetsModal from "$lib/components/AssetsModal.svelte";
|
||||
import WindowControls from "$lib/components/WindowControls.svelte";
|
||||
import ImageViewer from "$lib/components/ImageViewer.svelte";
|
||||
import EditorToolbar from "$lib/components/EditorToolbar.svelte";
|
||||
import PageSettingsModal from "$lib/components/PageSettingsModal.svelte";
|
||||
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { insertText } from "$lib/ts/editor-actions";
|
||||
|
||||
import * as api from "$lib/ts/api";
|
||||
import type { BrowseEntry } from "$lib/ts/api";
|
||||
import { pickFiles } from "$lib/ts/import";
|
||||
import {
|
||||
app,
|
||||
browseTo,
|
||||
clearMessages,
|
||||
closeTarget,
|
||||
compile,
|
||||
openFile,
|
||||
openTarget,
|
||||
refreshAccount,
|
||||
refreshEntries,
|
||||
refreshSpaces,
|
||||
refreshTarget,
|
||||
runSync,
|
||||
saveAndCompile,
|
||||
scheduleCompile,
|
||||
setError,
|
||||
setStatus,
|
||||
} from "$lib/ts/state.svelte";
|
||||
|
||||
type Dialog =
|
||||
| { kind: "none" }
|
||||
| { kind: "new-project" }
|
||||
| { kind: "new-folder" }
|
||||
| { kind: "new-document" }
|
||||
| { kind: "rename-entry"; entry: BrowseEntry }
|
||||
| { kind: "delete-entry"; entry: BrowseEntry }
|
||||
| { kind: "link-entry"; entry: BrowseEntry }
|
||||
| { kind: "new-space" }
|
||||
| { kind: "delete-space"; id: string }
|
||||
| { kind: "clone-space"; id: string; name: string }
|
||||
| { kind: "new-file" }
|
||||
| { kind: "new-subfolder" }
|
||||
| { kind: "rename-file"; path: string }
|
||||
| { kind: "delete-file"; path: string }
|
||||
| { kind: "login" }
|
||||
| { kind: "settings" }
|
||||
| { kind: "assets" }
|
||||
| { kind: "page-settings" }
|
||||
| { kind: "conflicts" };
|
||||
|
||||
let dialog = $state<Dialog>({ kind: "none" });
|
||||
let editorView = $state<EditorView | null>(null);
|
||||
let imageViewer = $state<{ paths: string[]; index: number } | null>(null);
|
||||
|
||||
const activeFile = $derived(
|
||||
app.target?.files.find((file) => file.path === app.activePath) ?? null,
|
||||
);
|
||||
|
||||
function close() {
|
||||
dialog = { kind: "none" };
|
||||
}
|
||||
|
||||
async function guard(action: () => Promise<void>) {
|
||||
try {
|
||||
await action();
|
||||
close();
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
}
|
||||
|
||||
const createProject = (name: string) =>
|
||||
guard(async () => {
|
||||
const path = await api.createProjectEntry(app.currentDir, name);
|
||||
await refreshEntries();
|
||||
await openTarget(path);
|
||||
});
|
||||
|
||||
const createFolder = (name: string) =>
|
||||
guard(async () => {
|
||||
await api.createFolderEntry(app.currentDir, name);
|
||||
await refreshEntries();
|
||||
});
|
||||
|
||||
const createDocument = (name: string) =>
|
||||
guard(async () => {
|
||||
const path = await api.createDocumentEntry(app.currentDir, name);
|
||||
await refreshEntries();
|
||||
await openTarget(path);
|
||||
});
|
||||
|
||||
const renameEntry = (entry: BrowseEntry, name: string) =>
|
||||
guard(async () => {
|
||||
await api.renameEntry(entry.path, name);
|
||||
await refreshEntries();
|
||||
});
|
||||
|
||||
const deleteEntry = (entry: BrowseEntry) =>
|
||||
guard(async () => {
|
||||
await api.deleteEntry(entry.path);
|
||||
await refreshEntries();
|
||||
setStatus(`Deleted '${entry.name}'`);
|
||||
});
|
||||
|
||||
const linkEntry = (entry: BrowseEntry) =>
|
||||
guard(async () => {
|
||||
const report = await api.cloudLinkProject(entry.path);
|
||||
await refreshEntries();
|
||||
await refreshSpaces();
|
||||
setStatus(`Uploaded ${report.pushed.length} files to a new cloud space`);
|
||||
});
|
||||
|
||||
const createSpace = (name: string) =>
|
||||
guard(async () => {
|
||||
await api.cloudCreateSpace(name);
|
||||
await refreshSpaces();
|
||||
});
|
||||
|
||||
const deleteSpace = (id: string) =>
|
||||
guard(async () => {
|
||||
await api.cloudDeleteSpace(id);
|
||||
await refreshSpaces();
|
||||
});
|
||||
|
||||
const cloneSpace = (id: string, name: string) =>
|
||||
guard(async () => {
|
||||
await api.cloudCloneSpace(id, name);
|
||||
app.scope = "local";
|
||||
await browseTo("");
|
||||
setStatus(`Downloaded '${name}' to this device`);
|
||||
});
|
||||
|
||||
async function importFiles() {
|
||||
const sources = await pickFiles("all");
|
||||
if (sources.length === 0) return;
|
||||
|
||||
try {
|
||||
if (app.view === "editor" && app.target) {
|
||||
const imported = await api.importIntoTarget(app.target.path, sources);
|
||||
await refreshTarget();
|
||||
await compile();
|
||||
setStatus(`Imported ${imported.length} file(s)`);
|
||||
} else {
|
||||
const imported = await api.importIntoFolder(app.currentDir, sources);
|
||||
await refreshEntries();
|
||||
setStatus(`Imported ${imported.length} file(s)`);
|
||||
}
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
}
|
||||
|
||||
const createFileInTarget = (name: string) =>
|
||||
guard(async () => {
|
||||
const path = name.includes(".") ? name : `${name}.typ`;
|
||||
await api.writeTargetFile(app.target!.path, path, "");
|
||||
await refreshTarget();
|
||||
await openFile(path);
|
||||
});
|
||||
|
||||
const createFolderInTarget = (path: string) =>
|
||||
guard(async () => {
|
||||
await api.createFolderEntry(app.target!.path, path);
|
||||
await refreshTarget();
|
||||
});
|
||||
|
||||
const renameFile = (path: string, next: string) =>
|
||||
guard(async () => {
|
||||
const payload = await api.readTargetFile(app.target!.path, path);
|
||||
await api.writeTargetFile(app.target!.path, next, payload.content);
|
||||
await api.deleteEntry(`${app.target!.path}/${path}`);
|
||||
await refreshTarget();
|
||||
if (app.activePath === path) await openFile(next);
|
||||
});
|
||||
|
||||
const deleteFile = (path: string) =>
|
||||
guard(async () => {
|
||||
await api.deleteEntry(`${app.target!.path}/${path}`);
|
||||
if (app.activePath === path) {
|
||||
app.activePath = null;
|
||||
app.editorContent = "";
|
||||
}
|
||||
await refreshTarget();
|
||||
await compile();
|
||||
});
|
||||
|
||||
async function setEntrypoint(path: string) {
|
||||
if (!app.target) return;
|
||||
try {
|
||||
await api.setTargetEntrypoint(app.target.path, path);
|
||||
await refreshTarget();
|
||||
await compile();
|
||||
setStatus(`'${path}' is now the entrypoint`);
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportAs(format: string) {
|
||||
if (!app.target) return;
|
||||
|
||||
const name = app.target.path.split("/").pop() ?? "document";
|
||||
const destination = await save({
|
||||
defaultPath: `${name.replace(/\.typ$/i, "")}.${format}`,
|
||||
filters: [{ name: format.toUpperCase(), extensions: [format] }],
|
||||
});
|
||||
if (!destination) return;
|
||||
|
||||
try {
|
||||
await api.exportTarget(app.target.path, format, destination);
|
||||
setStatus(`Exported to ${destination}`);
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
}
|
||||
|
||||
const resolveConflicts = (resolutions: api.Resolution[]) =>
|
||||
guard(async () => {
|
||||
const report = await api.cloudResolveConflicts(
|
||||
app.target!.path,
|
||||
resolutions,
|
||||
);
|
||||
app.conflicts = report.conflicts;
|
||||
await refreshTarget();
|
||||
if (app.activePath) await openFile(app.activePath);
|
||||
setStatus("Conflicts resolved and uploaded");
|
||||
});
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "s") {
|
||||
event.preventDefault();
|
||||
if (app.view === "editor") saveAndCompile();
|
||||
}
|
||||
}
|
||||
|
||||
let dropActive = $state(false);
|
||||
|
||||
function dropDestination():
|
||||
| { kind: "folder"; path: string }
|
||||
| { kind: "target"; path: string }
|
||||
| null {
|
||||
if (app.view === "editor" && app.target) {
|
||||
return { kind: "target", path: app.target.path };
|
||||
}
|
||||
if (app.view === "files" && app.scope === "local") {
|
||||
return { kind: "folder", path: app.currentDir };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function dropPaths(paths: string[]) {
|
||||
const destination = dropDestination();
|
||||
if (!destination || paths.length === 0) return;
|
||||
|
||||
try {
|
||||
const imported =
|
||||
destination.kind === "target"
|
||||
? await api.importIntoTarget(destination.path, paths)
|
||||
: await api.importIntoFolder(destination.path, paths);
|
||||
|
||||
if (destination.kind === "target") {
|
||||
await refreshTarget();
|
||||
await compile();
|
||||
} else {
|
||||
await refreshEntries();
|
||||
}
|
||||
setStatus(`Imported ${imported.length} item(s)`);
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const pending = getCurrentWebview().onDragDropEvent((event) => {
|
||||
if (event.payload.type === "over") {
|
||||
dropActive = dropDestination() !== null;
|
||||
} else if (event.payload.type === "drop") {
|
||||
dropActive = false;
|
||||
dropPaths(event.payload.paths);
|
||||
} else {
|
||||
dropActive = false;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
pending.then((unlisten) => unlisten());
|
||||
};
|
||||
});
|
||||
|
||||
const lspLabel: Record<string, string> = {
|
||||
off: "LSP off",
|
||||
starting: "LSP starting",
|
||||
on: "LSP ready",
|
||||
unavailable: "LSP unavailable",
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeydown} />
|
||||
|
||||
<div class="flex h-screen flex-col">
|
||||
<header
|
||||
data-tauri-drag-region
|
||||
class="flex h-11 shrink-0 select-none items-center gap-2 border-b border-[var(--color-line)] bg-[var(--color-surface)] pl-3"
|
||||
>
|
||||
{#if app.view === "editor"}
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
|
||||
onclick={closeTarget}
|
||||
>
|
||||
<Icon icon="ph:arrow-left" />
|
||||
Files
|
||||
</button>
|
||||
<span data-tauri-drag-region class="text-sm font-medium">
|
||||
{app.target?.path.split("/").pop()}
|
||||
</span>
|
||||
{#if app.target?.standalone}
|
||||
<span
|
||||
class="rounded bg-[var(--color-surface-sunken)] px-1.5 py-0.5 text-[10px] text-[var(--color-ink-muted)]"
|
||||
>
|
||||
single file
|
||||
</span>
|
||||
{/if}
|
||||
{#if app.dirty}
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-[var(--color-accent)]"></span>
|
||||
{/if}
|
||||
{:else}
|
||||
<Icon icon="ph:file-code" class="text-lg text-[var(--color-accent)]" />
|
||||
<span data-tauri-drag-region class="text-sm font-semibold">
|
||||
Typst Desktop
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<div data-tauri-drag-region class="h-full flex-1"></div>
|
||||
|
||||
{#if app.view === "editor"}
|
||||
<span
|
||||
class="flex items-center gap-1 text-[10px] text-[var(--color-ink-muted)]"
|
||||
title="Typst language server (tinymist)"
|
||||
>
|
||||
<span
|
||||
class="h-1.5 w-1.5 rounded-full
|
||||
{app.lspStatus === 'on'
|
||||
? 'bg-[var(--color-success)]'
|
||||
: app.lspStatus === 'starting'
|
||||
? 'bg-[var(--color-accent)]'
|
||||
: 'bg-[var(--color-ink-muted)]'}"
|
||||
></span>
|
||||
{lspLabel[app.lspStatus]}
|
||||
</span>
|
||||
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
|
||||
onclick={() => (dialog = { kind: "assets" })}
|
||||
>
|
||||
<Icon icon="ph:images" />
|
||||
Assets
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
|
||||
onclick={saveAndCompile}
|
||||
>
|
||||
<Icon icon="ph:floppy-disk" />
|
||||
Save
|
||||
</button>
|
||||
|
||||
<div class="group relative">
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
|
||||
>
|
||||
<Icon icon="ph:export" />
|
||||
Export
|
||||
</button>
|
||||
<div
|
||||
class="invisible absolute right-0 top-full z-20 flex w-32 flex-col rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] py-1 text-xs shadow-lg group-hover:visible"
|
||||
>
|
||||
{#each ["pdf", "png", "html"] as format}
|
||||
<button
|
||||
class="px-3 py-1.5 text-left uppercase hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={() => exportAs(format)}
|
||||
>
|
||||
{format}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if app.target?.space_id}
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-2.5 py-1.5 text-xs font-medium text-white transition hover:opacity-90 disabled:opacity-50"
|
||||
disabled={app.syncing}
|
||||
onclick={() => runSync("sync")}
|
||||
>
|
||||
<Icon
|
||||
icon={app.syncing ? "ph:circle-notch" : "ph:arrows-clockwise"}
|
||||
class={app.syncing ? "animate-spin" : ""}
|
||||
/>
|
||||
Sync
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="rounded-md p-1.5 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
|
||||
onclick={() => (dialog = { kind: "settings" })}
|
||||
aria-label="Settings"
|
||||
>
|
||||
<Icon icon="ph:gear-six" />
|
||||
</button>
|
||||
|
||||
<div class="ml-1 h-5 w-px bg-[var(--color-line)]"></div>
|
||||
|
||||
<WindowControls />
|
||||
</header>
|
||||
|
||||
{#if app.status || app.error}
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-2 border-b px-3 py-1.5 text-xs
|
||||
{app.error
|
||||
? 'border-[var(--color-danger)]/30 bg-[var(--color-danger)]/10 text-[var(--color-danger)]'
|
||||
: 'border-[var(--color-success)]/30 bg-[var(--color-success)]/10 text-[var(--color-success)]'}"
|
||||
>
|
||||
<Icon icon={app.error ? "ph:warning-circle" : "ph:check-circle"} />
|
||||
<span class="flex-1">{app.error || app.status}</span>
|
||||
{#if app.conflicts.length > 0}
|
||||
<button
|
||||
class="rounded border border-current px-2 py-0.5 font-medium"
|
||||
onclick={() => (dialog = { kind: "conflicts" })}
|
||||
>
|
||||
Resolve
|
||||
</button>
|
||||
{/if}
|
||||
<button onclick={clearMessages} aria-label="Dismiss">
|
||||
<Icon icon="ph:x" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex min-h-0 flex-1">
|
||||
{#if app.view === "files"}
|
||||
<div class="flex-1">
|
||||
<FileViewer
|
||||
onnewproject={() => (dialog = { kind: "new-project" })}
|
||||
onnewfolder={() => (dialog = { kind: "new-folder" })}
|
||||
onnewdocument={() => (dialog = { kind: "new-document" })}
|
||||
onupload={importFiles}
|
||||
onassets={() => (dialog = { kind: "assets" })}
|
||||
onrename={(entry) => (dialog = { kind: "rename-entry", entry })}
|
||||
ondelete={(entry) => (dialog = { kind: "delete-entry", entry })}
|
||||
onlink={(entry) => (dialog = { kind: "link-entry", entry })}
|
||||
onviewimage={(paths, index) => (imageViewer = { paths, index })}
|
||||
onnewspace={() => (dialog = { kind: "new-space" })}
|
||||
onclonespace={(id, name) => (dialog = { kind: "clone-space", id, name })}
|
||||
ondeletespace={(id) => (dialog = { kind: "delete-space", id })}
|
||||
onsignin={() => (dialog = { kind: "login" })}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
{#if !app.target?.standalone}
|
||||
<div
|
||||
class="flex w-56 shrink-0 flex-col border-r border-[var(--color-line)] bg-[var(--color-surface)]"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-[var(--color-line)] px-3 py-1.5"
|
||||
>
|
||||
<span
|
||||
class="text-[10px] font-semibold uppercase tracking-wider text-[var(--color-ink-muted)]"
|
||||
>
|
||||
Files
|
||||
</span>
|
||||
<div class="flex gap-0.5">
|
||||
<button
|
||||
class="rounded p-1 text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
|
||||
onclick={() => (dialog = { kind: "new-file" })}
|
||||
aria-label="New file"
|
||||
>
|
||||
<Icon icon="ph:file-plus" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded p-1 text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
|
||||
onclick={() => (dialog = { kind: "new-subfolder" })}
|
||||
aria-label="New folder"
|
||||
>
|
||||
<Icon icon="ph:folder-plus" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded p-1 text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
|
||||
onclick={importFiles}
|
||||
aria-label="Import files from this computer"
|
||||
>
|
||||
<Icon icon="ph:upload-simple" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FileTree
|
||||
files={app.target?.files ?? []}
|
||||
activePath={app.activePath}
|
||||
entrypoint={app.target?.entrypoint ?? "main.typ"}
|
||||
onopen={openFile}
|
||||
onrename={(path) => (dialog = { kind: "rename-file", path })}
|
||||
ondelete={(path) => (dialog = { kind: "delete-file", path })}
|
||||
onsetentry={setEntrypoint}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex min-w-0 flex-1">
|
||||
<div class="flex min-w-0 flex-1 flex-col border-r border-[var(--color-line)]">
|
||||
{#if app.activePath && activeFile?.is_text}
|
||||
<EditorToolbar
|
||||
view={editorView}
|
||||
disabled={app.activePath.toLowerCase().endsWith(".toml")}
|
||||
onassets={() => (dialog = { kind: "assets" })}
|
||||
onpagesettings={() => (dialog = { kind: "page-settings" })}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-2 border-b border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 text-xs"
|
||||
>
|
||||
<Icon icon="ph:file-text" class="text-[var(--color-ink-muted)]" />
|
||||
<span>{app.activePath}</span>
|
||||
{#if app.dirty}
|
||||
<span class="text-[var(--color-ink-muted)]">edited</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#key app.target?.path + ":" + app.activePath}
|
||||
<div class="min-h-0 flex-1">
|
||||
<Editor
|
||||
content={app.editorContent}
|
||||
filePath={app.activePath}
|
||||
targetPath={app.target?.path ?? ""}
|
||||
diagnostics={app.diagnostics}
|
||||
onchange={(value) => {
|
||||
app.editorContent = value;
|
||||
app.dirty = true;
|
||||
scheduleCompile();
|
||||
}}
|
||||
onsave={saveAndCompile}
|
||||
onlspstatus={(status) => (app.lspStatus = status)}
|
||||
onready={(view) => (editorView = view)}
|
||||
/>
|
||||
</div>
|
||||
{/key}
|
||||
{:else}
|
||||
<div
|
||||
class="flex flex-1 flex-col items-center justify-center gap-2 bg-[var(--color-surface)] text-[var(--color-ink-muted)]"
|
||||
>
|
||||
<Icon icon="ph:file-dashed" class="text-4xl" />
|
||||
<p class="text-sm">
|
||||
{app.activePath
|
||||
? "This file cannot be edited as text"
|
||||
: "Select a file to edit"}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<Preview
|
||||
compiled={app.compiled}
|
||||
diagnostics={app.diagnostics}
|
||||
compiling={app.compiling}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if imageViewer}
|
||||
<ImageViewer
|
||||
paths={imageViewer.paths}
|
||||
index={imageViewer.index}
|
||||
onclose={() => (imageViewer = null)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if dropActive}
|
||||
<div
|
||||
class="pointer-events-none fixed inset-0 z-40 flex items-center justify-center bg-[var(--color-accent)]/10 p-8"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center gap-3 rounded-xl border-2 border-dashed border-[var(--color-accent)] bg-[var(--color-surface)]/95 px-10 py-8 shadow-xl"
|
||||
>
|
||||
<Icon icon="ph:tray-arrow-down" class="text-4xl text-[var(--color-accent)]" />
|
||||
<p class="text-sm font-medium">
|
||||
{app.view === "editor"
|
||||
? `Drop to add files to ${app.target?.path.split("/").pop()}`
|
||||
: app.currentDir
|
||||
? `Drop to add files to ${app.currentDir.split("/").pop()}`
|
||||
: "Drop to add files to your workspace"}
|
||||
</p>
|
||||
<p class="text-xs text-[var(--color-ink-muted)]">
|
||||
Images, fonts, and Typst files are copied in.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if dialog.kind === "new-project"}
|
||||
<PromptModal
|
||||
title="New project"
|
||||
label="Project name"
|
||||
icon="ph:folder-plus"
|
||||
placeholder="My thesis"
|
||||
onsubmit={createProject}
|
||||
onclose={close}
|
||||
/>
|
||||
{:else if dialog.kind === "new-folder"}
|
||||
<PromptModal
|
||||
title="New folder"
|
||||
label="Folder name"
|
||||
icon="ph:folder-plus"
|
||||
onsubmit={createFolder}
|
||||
onclose={close}
|
||||
/>
|
||||
{:else if dialog.kind === "new-document"}
|
||||
<PromptModal
|
||||
title="New document"
|
||||
label="Document name"
|
||||
icon="ph:file-plus"
|
||||
placeholder="notes"
|
||||
suffix=".typ"
|
||||
onsubmit={createDocument}
|
||||
onclose={close}
|
||||
/>
|
||||
{:else if dialog.kind === "rename-entry"}
|
||||
{@const target = dialog}
|
||||
<PromptModal
|
||||
title="Rename"
|
||||
label="New name"
|
||||
value={target.entry.name}
|
||||
confirmLabel="Rename"
|
||||
onsubmit={(name) => renameEntry(target.entry, name)}
|
||||
onclose={close}
|
||||
/>
|
||||
{:else if dialog.kind === "delete-entry"}
|
||||
{@const target = dialog}
|
||||
<ConfirmModal
|
||||
title="Delete"
|
||||
message="'{target.entry.name}' will be permanently removed from this device."
|
||||
onconfirm={() => deleteEntry(target.entry)}
|
||||
onclose={close}
|
||||
/>
|
||||
{:else if dialog.kind === "link-entry"}
|
||||
{@const target = dialog}
|
||||
<ConfirmModal
|
||||
title="Upload to cloud"
|
||||
message="A new cloud space will be created for '{target.entry.name}' and its files uploaded."
|
||||
confirmLabel="Upload"
|
||||
onconfirm={() => linkEntry(target.entry)}
|
||||
onclose={close}
|
||||
/>
|
||||
{:else if dialog.kind === "new-space"}
|
||||
<PromptModal
|
||||
title="New cloud space"
|
||||
label="Space name"
|
||||
icon="ph:cloud-plus"
|
||||
onsubmit={createSpace}
|
||||
onclose={close}
|
||||
/>
|
||||
{:else if dialog.kind === "delete-space"}
|
||||
{@const target = dialog}
|
||||
<ConfirmModal
|
||||
title="Delete cloud space"
|
||||
message="This permanently deletes the space and its files from TypstDrive. Local copies are kept."
|
||||
onconfirm={() => deleteSpace(target.id)}
|
||||
onclose={close}
|
||||
/>
|
||||
{:else if dialog.kind === "clone-space"}
|
||||
{@const target = dialog}
|
||||
<PromptModal
|
||||
title="Download space"
|
||||
label="Save as project"
|
||||
icon="ph:download-simple"
|
||||
value={target.name}
|
||||
confirmLabel="Download"
|
||||
onsubmit={(name) => cloneSpace(target.id, name)}
|
||||
onclose={close}
|
||||
/>
|
||||
{:else if dialog.kind === "new-file"}
|
||||
<PromptModal
|
||||
title="New file"
|
||||
label="File name"
|
||||
icon="ph:file-plus"
|
||||
placeholder="chapter-1"
|
||||
onsubmit={createFileInTarget}
|
||||
onclose={close}
|
||||
/>
|
||||
{:else if dialog.kind === "new-subfolder"}
|
||||
<PromptModal
|
||||
title="New folder"
|
||||
label="Folder name"
|
||||
icon="ph:folder-plus"
|
||||
placeholder="figures"
|
||||
onsubmit={createFolderInTarget}
|
||||
onclose={close}
|
||||
/>
|
||||
{:else if dialog.kind === "rename-file"}
|
||||
{@const target = dialog}
|
||||
<PromptModal
|
||||
title="Rename file"
|
||||
label="New path"
|
||||
value={target.path}
|
||||
confirmLabel="Rename"
|
||||
onsubmit={(next) => renameFile(target.path, next)}
|
||||
onclose={close}
|
||||
/>
|
||||
{:else if dialog.kind === "delete-file"}
|
||||
{@const target = dialog}
|
||||
<ConfirmModal
|
||||
title="Delete file"
|
||||
message="'{target.path}' will be permanently deleted."
|
||||
onconfirm={() => deleteFile(target.path)}
|
||||
onclose={close}
|
||||
/>
|
||||
{:else if dialog.kind === "login"}
|
||||
<LoginModal
|
||||
serverUrl={app.settings?.server_url ?? ""}
|
||||
onsuccess={async () => {
|
||||
close();
|
||||
await refreshAccount();
|
||||
app.scope = "cloud";
|
||||
setStatus("Connected to TypstDrive");
|
||||
}}
|
||||
onclose={close}
|
||||
/>
|
||||
{:else if dialog.kind === "settings"}
|
||||
<SettingsModal onclose={close} onsignin={() => (dialog = { kind: "login" })} />
|
||||
{:else if dialog.kind === "assets"}
|
||||
<AssetsModal
|
||||
oninsert={app.view === "editor" && editorView
|
||||
? (snippet) => {
|
||||
insertText(editorView, snippet);
|
||||
close();
|
||||
}
|
||||
: undefined}
|
||||
onchanged={compile}
|
||||
onclose={close}
|
||||
/>
|
||||
{:else if dialog.kind === "page-settings"}
|
||||
<PageSettingsModal view={editorView} onclose={close} />
|
||||
{:else if dialog.kind === "conflicts"}
|
||||
<ConflictModal
|
||||
conflicts={app.conflicts}
|
||||
onresolve={resolveConflicts}
|
||||
onclose={close}
|
||||
/>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user