Compact editor UI, move LSP info to settings, redesign status badges
Claude-Session: https://claude.ai/code/session_01PoLmSR1pFVyHf8i5b1rNWk
This commit is contained in:
@@ -5,9 +5,7 @@
|
||||
import {
|
||||
insertText,
|
||||
prefixLines,
|
||||
redoEdit,
|
||||
setTypstConfig,
|
||||
undoEdit,
|
||||
wrapSelection,
|
||||
} from "$lib/ts/editor-actions";
|
||||
import { app } from "$lib/ts/state.svelte";
|
||||
@@ -37,10 +35,10 @@
|
||||
icon: string,
|
||||
label: string,
|
||||
run: () => void,
|
||||
size = "text-base",
|
||||
size = "text-sm",
|
||||
)}
|
||||
<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"
|
||||
class="rounded p-1 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}
|
||||
@@ -51,17 +49,12 @@
|
||||
{/snippet}
|
||||
|
||||
{#snippet divider()}
|
||||
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--color-line)]"></div>
|
||||
<div class="mx-0.5 h-3.5 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"
|
||||
class="scroll-thin flex shrink-0 items-center gap-px overflow-x-auto border-b border-[var(--color-line)] bg-[var(--color-surface)] px-1.5 py-0.5"
|
||||
>
|
||||
{@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", () =>
|
||||
@@ -103,7 +96,7 @@
|
||||
{@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"
|
||||
class="max-w-24 rounded border border-[var(--color-line)] bg-[var(--color-surface)] px-1 py-0.5 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}
|
||||
@@ -118,14 +111,7 @@
|
||||
{/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>
|
||||
{@render action("ph:file-text", "Page settings", onpagesettings)}
|
||||
|
||||
<div class="flex-1"></div>
|
||||
</div>
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
| "appearance"
|
||||
| "accessibility"
|
||||
| "account"
|
||||
| "lsp"
|
||||
| "about";
|
||||
|
||||
const sections: { id: Section; label: string; icon: string }[] = [
|
||||
@@ -41,9 +42,17 @@
|
||||
{ id: "appearance", label: "Appearance", icon: "ph:palette" },
|
||||
{ id: "accessibility", label: "Accessibility", icon: "ph:wheelchair" },
|
||||
{ id: "account", label: "Account", icon: "ph:user-circle" },
|
||||
{ id: "lsp", label: "Language Server", icon: "ph:plugs-connected" },
|
||||
{ id: "about", label: "About", icon: "ph:info" },
|
||||
];
|
||||
|
||||
const lspLabel: Record<string, string> = {
|
||||
off: "Off",
|
||||
starting: "Starting…",
|
||||
on: "Running",
|
||||
unavailable: "Unavailable",
|
||||
};
|
||||
|
||||
const accentPresets = [
|
||||
"#3b6cf6",
|
||||
"#7c5cfc",
|
||||
@@ -227,7 +236,9 @@
|
||||
{/each}
|
||||
</select>
|
||||
<span class="text-[var(--color-ink-muted)]">
|
||||
Saves the file being edited after you stop typing.
|
||||
Saves the file being edited after you stop typing. Cloud-linked
|
||||
files always save to disk every few seconds regardless of this
|
||||
setting, since they sync live and have no manual Save button.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -460,6 +471,42 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else if section === "lsp"}
|
||||
<div class="flex flex-col gap-5">
|
||||
<div class="flex items-center gap-3 rounded-md border border-[var(--color-line)] bg-[var(--color-surface-muted)] p-3">
|
||||
<span
|
||||
class="h-2.5 w-2.5 rounded-full
|
||||
{app.lspStatus === 'on'
|
||||
? 'bg-[var(--color-success)]'
|
||||
: app.lspStatus === 'starting'
|
||||
? 'bg-[var(--color-accent)]'
|
||||
: 'bg-[var(--color-ink-muted)]'}"
|
||||
></span>
|
||||
<div class="text-xs">
|
||||
<p class="font-medium">{lspLabel[app.lspStatus]}</p>
|
||||
<p class="text-[var(--color-ink-muted)]">
|
||||
Reflects the file currently open in the editor.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-[var(--color-ink-muted)]">
|
||||
Typst Desktop uses <span class="font-medium">tinymist</span>, the
|
||||
official Typst language server, to provide autocomplete, diagnostics,
|
||||
and hover info while editing. It must be installed and available on
|
||||
your system PATH — Typst Desktop does not bundle or install it for
|
||||
you.
|
||||
</p>
|
||||
|
||||
{#if app.lspStatus === "unavailable"}
|
||||
<div
|
||||
class="rounded-md border border-[var(--color-danger)]/30 bg-[var(--color-danger)]/10 p-3 text-xs text-[var(--color-danger)]"
|
||||
>
|
||||
tinymist could not be started. Confirm it's installed and on your
|
||||
PATH, then reopen the file.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -521,7 +568,8 @@
|
||||
{@const draftless =
|
||||
section === "about" ||
|
||||
section === "appearance" ||
|
||||
section === "accessibility"}
|
||||
section === "accessibility" ||
|
||||
section === "lsp"}
|
||||
<button
|
||||
class="rounded-md px-3 py-1.5 text-xs text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={onclose}
|
||||
|
||||
+154
-51
@@ -3,6 +3,7 @@
|
||||
import { onMount } from "svelte";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { writeImage, writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { Image } from "@tauri-apps/api/image";
|
||||
import { revealItemInDir } from "@tauri-apps/plugin-opener";
|
||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
@@ -23,7 +24,7 @@
|
||||
import PageSettingsModal from "$lib/components/PageSettingsModal.svelte";
|
||||
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { insertText } from "$lib/ts/editor-actions";
|
||||
import { insertText, redoEdit, undoEdit } from "$lib/ts/editor-actions";
|
||||
|
||||
import * as api from "$lib/ts/api";
|
||||
import type { BrowseEntry, CloudFile, CloudFolder } from "$lib/ts/api";
|
||||
@@ -43,6 +44,8 @@
|
||||
refreshEntries,
|
||||
refreshTarget,
|
||||
removeDownloadedDocument,
|
||||
resolveCollabConflict,
|
||||
cancelCollabConflict,
|
||||
runSync,
|
||||
saveAndCompile,
|
||||
scheduleAutosave,
|
||||
@@ -90,6 +93,16 @@
|
||||
let selectedIsDir = $state(false);
|
||||
let treeDropTarget = $state<string | null>(null);
|
||||
|
||||
const SIDEBAR_COLLAPSED_KEY = "editor-sidebar-collapsed";
|
||||
let sidebarCollapsed = $state(
|
||||
localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "true",
|
||||
);
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed = !sidebarCollapsed;
|
||||
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(sidebarCollapsed));
|
||||
}
|
||||
|
||||
const selectedFolder = $derived(
|
||||
!selectedEntry
|
||||
? ""
|
||||
@@ -371,13 +384,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function pngBytesToRgba(bytes: Uint8Array) {
|
||||
const blob = new Blob([bytes], { type: "image/png" });
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Could not create canvas context");
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
const { data } = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
return { rgba: new Uint8Array(data.buffer), width: canvas.width, height: canvas.height };
|
||||
}
|
||||
|
||||
async function copyAs(format: string) {
|
||||
if (!app.target) return;
|
||||
|
||||
try {
|
||||
if (format === "png") {
|
||||
const bytes = await api.renderTargetPng(app.target.path);
|
||||
await writeImage(new Uint8Array(bytes));
|
||||
const { rgba, width, height } = await pngBytesToRgba(new Uint8Array(bytes));
|
||||
const image = await Image.new(rgba, width, height);
|
||||
await writeImage(image);
|
||||
} else if (format === "svg") {
|
||||
const svg = app.compiled?.pages[0];
|
||||
if (!svg) {
|
||||
@@ -506,22 +534,41 @@
|
||||
};
|
||||
});
|
||||
|
||||
const lspLabel: Record<string, string> = {
|
||||
off: "LSP off",
|
||||
starting: "LSP starting",
|
||||
on: "LSP ready",
|
||||
unavailable: "LSP unavailable",
|
||||
};
|
||||
|
||||
const wsLabel: Record<string, string> = {
|
||||
connected: "Live sync",
|
||||
connecting: "Connecting…",
|
||||
offline: "Offline (polling)",
|
||||
};
|
||||
|
||||
const collabLabel: Record<string, string> = {
|
||||
connected: "Live",
|
||||
connecting: "Connecting…",
|
||||
offline: "Offline — editing locally",
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeydown} />
|
||||
|
||||
{#snippet statusBadge(
|
||||
icon: string,
|
||||
label: string,
|
||||
tone: "success" | "accent" | "muted",
|
||||
spin = false,
|
||||
)}
|
||||
<span
|
||||
class="flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium
|
||||
{tone === 'success'
|
||||
? 'bg-[var(--color-success)]/10 text-[var(--color-success)]'
|
||||
: tone === 'accent'
|
||||
? 'bg-[var(--color-accent)]/10 text-[var(--color-accent)]'
|
||||
: 'bg-[var(--color-surface-sunken)] text-[var(--color-ink-muted)]'}"
|
||||
>
|
||||
<Icon {icon} class="text-xs {spin ? 'animate-spin' : ''}" />
|
||||
{label}
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
<div class="flex h-screen flex-col">
|
||||
<header
|
||||
data-tauri-drag-region
|
||||
@@ -535,15 +582,31 @@
|
||||
<Icon icon="ph:arrow-left" />
|
||||
Files
|
||||
</button>
|
||||
|
||||
{#if editorView}
|
||||
<button
|
||||
class="rounded-md p-1.5 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
|
||||
title="Undo"
|
||||
aria-label="Undo"
|
||||
onclick={() => undoEdit(editorView)}
|
||||
>
|
||||
<Icon icon="ph:arrow-counter-clockwise" class="text-sm" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded-md p-1.5 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
|
||||
title="Redo"
|
||||
aria-label="Redo"
|
||||
onclick={() => redoEdit(editorView)}
|
||||
>
|
||||
<Icon icon="ph:arrow-clockwise" class="text-sm" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<span data-tauri-drag-region class="text-sm font-medium">
|
||||
{app.target?.path.split("/").pop()}
|
||||
</span>
|
||||
{#if app.target?.standalone}
|
||||
<span
|
||||
class="rounded bg-[var(--color-surface-sunken)] px-1.5 py-0.5 text-[10px] text-[var(--color-ink-muted)]"
|
||||
>
|
||||
single file
|
||||
</span>
|
||||
{@render statusBadge("ph:file", "Single file", "muted")}
|
||||
{/if}
|
||||
{#if app.dirty}
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-[var(--color-accent)]"></span>
|
||||
@@ -579,45 +642,53 @@
|
||||
{/if}
|
||||
|
||||
{#if app.view === "editor"}
|
||||
<span
|
||||
class="flex items-center gap-1 text-[10px] text-[var(--color-ink-muted)]"
|
||||
title="Typst language server (tinymist)"
|
||||
>
|
||||
<span
|
||||
class="h-1.5 w-1.5 rounded-full
|
||||
{app.lspStatus === 'on'
|
||||
? 'bg-[var(--color-success)]'
|
||||
: app.lspStatus === 'starting'
|
||||
? 'bg-[var(--color-accent)]'
|
||||
: 'bg-[var(--color-ink-muted)]'}"
|
||||
></span>
|
||||
{lspLabel[app.lspStatus]}
|
||||
</span>
|
||||
|
||||
{#if app.account}
|
||||
<span
|
||||
class="flex items-center gap-1 text-[10px] text-[var(--color-ink-muted)]"
|
||||
title="Cloud sync connection"
|
||||
>
|
||||
<span
|
||||
class="h-1.5 w-1.5 rounded-full
|
||||
{app.wsStatus === 'connected'
|
||||
? 'bg-[var(--color-success)]'
|
||||
: app.wsStatus === 'connecting'
|
||||
? 'bg-[var(--color-accent)]'
|
||||
: 'bg-[var(--color-ink-muted)]'}"
|
||||
></span>
|
||||
{wsLabel[app.wsStatus] ?? "Offline (polling)"}
|
||||
{#if app.account && (app.target?.cloud_project_id || app.documentLink) && !app.collabIntent}
|
||||
<span title="Cloud sync connection">
|
||||
{@render statusBadge(
|
||||
app.wsStatus === "connected"
|
||||
? "ph:cloud-check"
|
||||
: app.wsStatus === "connecting"
|
||||
? "ph:circle-notch"
|
||||
: "ph:cloud-slash",
|
||||
wsLabel[app.wsStatus] ?? "Offline (polling)",
|
||||
app.wsStatus === "connected"
|
||||
? "success"
|
||||
: app.wsStatus === "connecting"
|
||||
? "accent"
|
||||
: "muted",
|
||||
app.wsStatus === "connecting",
|
||||
)}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
|
||||
onclick={saveAndCompile}
|
||||
>
|
||||
<Icon icon="ph:floppy-disk" />
|
||||
Save
|
||||
</button>
|
||||
{#if app.collabIntent}
|
||||
<span title="Realtime cloud sync">
|
||||
{@render statusBadge(
|
||||
app.collabStatus === "connected"
|
||||
? "ph:broadcast"
|
||||
: app.collabStatus === "connecting"
|
||||
? "ph:circle-notch"
|
||||
: "ph:wifi-slash",
|
||||
collabLabel[app.collabStatus ?? "offline"],
|
||||
app.collabStatus === "connected"
|
||||
? "success"
|
||||
: app.collabStatus === "connecting"
|
||||
? "accent"
|
||||
: "muted",
|
||||
app.collabStatus === "connecting",
|
||||
)}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if !app.collabIntent}
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
|
||||
onclick={saveAndCompile}
|
||||
>
|
||||
<Icon icon="ph:floppy-disk" />
|
||||
Save
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="group relative">
|
||||
<button
|
||||
@@ -661,7 +732,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if app.target?.cloud_project_id || app.documentLink}
|
||||
{#if (app.target?.cloud_project_id || app.documentLink) && !app.collabIntent}
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-2.5 py-1.5 text-xs font-medium text-white transition hover:opacity-90 disabled:opacity-50"
|
||||
disabled={app.syncing}
|
||||
@@ -789,7 +860,20 @@
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
{#if !app.target?.standalone}
|
||||
{#if !app.target?.standalone && sidebarCollapsed}
|
||||
<div
|
||||
class="flex w-8 shrink-0 flex-col items-center border-r border-[var(--color-line)] bg-[var(--color-surface)] py-1.5"
|
||||
>
|
||||
<button
|
||||
class="rounded p-1 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
|
||||
title="Show files"
|
||||
aria-label="Show files"
|
||||
onclick={toggleSidebar}
|
||||
>
|
||||
<Icon icon="ph:sidebar-simple" class="text-base" />
|
||||
</button>
|
||||
</div>
|
||||
{:else if !app.target?.standalone}
|
||||
<div
|
||||
class="flex w-56 shrink-0 flex-col border-r border-[var(--color-line)] bg-[var(--color-surface)]"
|
||||
>
|
||||
@@ -801,6 +885,14 @@
|
||||
>
|
||||
{selectedFolder ? selectedFolder : "Files"}
|
||||
</span>
|
||||
<button
|
||||
class="shrink-0 rounded p-1 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
|
||||
title="Hide files"
|
||||
aria-label="Hide files"
|
||||
onclick={toggleSidebar}
|
||||
>
|
||||
<Icon icon="ph:sidebar-simple" class="text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<FileTree
|
||||
@@ -855,6 +947,9 @@
|
||||
filePath={app.activePath}
|
||||
targetPath={app.target?.path ?? ""}
|
||||
diagnostics={app.diagnostics}
|
||||
collab={app.collab
|
||||
? { text: app.collab.text, awareness: app.collab.provider.awareness }
|
||||
: null}
|
||||
onchange={(value) => {
|
||||
app.editorContent = value;
|
||||
app.dirty = true;
|
||||
@@ -1148,3 +1243,11 @@
|
||||
onclose={close}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if app.collabConflict}
|
||||
<ConflictModal
|
||||
conflicts={[app.collabConflict]}
|
||||
onresolve={(resolutions) => resolveCollabConflict(resolutions[0]?.content ?? "")}
|
||||
onclose={cancelCollabConflict}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user