Add Typst Desktop app
This commit is contained in:
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user