feat(frontend): Svelte editor, live preview and Git panel

- CodeMirror 6 editor with a debounced Mermaid 11 preview (zoom, pan,
  auto fit-to-view and inline error reporting)
- project start screen with recent projects, sidebar diagram explorer,
  toolbar and a Git panel (status, commit, history, branches)
- SVG/PNG export, per-project theme switching, toasts
- rune-based central store orchestrating all backend calls
- view modes (Code / Split / Preview) plus a viewer mode with a
  slide-in quick-edit drawer (Cmd/Ctrl+E)
This commit is contained in:
2026-05-22 16:27:28 +03:00
parent 890390bc65
commit 29bf6438b3
17 changed files with 2277 additions and 0 deletions

235
src/App.svelte Normal file
View File

@@ -0,0 +1,235 @@
<script lang="ts">
import { fly } from 'svelte/transition';
import { store } from './lib/store.svelte';
import StartScreen from './lib/components/StartScreen.svelte';
import Sidebar from './lib/components/Sidebar.svelte';
import Toolbar from './lib/components/Toolbar.svelte';
import Editor from './lib/components/Editor.svelte';
import Preview from './lib/components/Preview.svelte';
import GitPanel from './lib/components/GitPanel.svelte';
import Toasts from './lib/components/Toasts.svelte';
// Editor/preview split ratio (fraction taken by the editor).
let ratio = $state(0.42);
let splitting = $state(false);
let splitHost = $state<HTMLDivElement>();
function startSplit(e: MouseEvent) {
splitting = true;
e.preventDefault();
}
function moveSplit(e: MouseEvent) {
if (!splitting || !splitHost) return;
const rect = splitHost.getBoundingClientRect();
ratio = Math.min(0.8, Math.max(0.2, (e.clientX - rect.left) / rect.width));
}
function endSplit() {
splitting = false;
}
function onKey(e: KeyboardEvent) {
if (store.view !== 'editor') return;
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
e.preventDefault();
void store.save();
}
// ⌘/Ctrl+E: jump into viewer mode and toggle the quick-edit drawer.
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'e') {
e.preventDefault();
if (store.layout !== 'preview') store.layout = 'preview';
store.editDrawer = !store.editDrawer;
}
if (e.key === 'Escape' && store.editDrawer) {
store.editDrawer = false;
}
}
</script>
<svelte:window onkeydown={onKey} onmousemove={moveSplit} onmouseup={endSplit} />
{#if store.view === 'start'}
<StartScreen />
{:else}
<div class="app">
<Toolbar />
<div class="body">
<Sidebar />
{#if store.activeId}
<div class="split" bind:this={splitHost} class:splitting>
{#if store.layout !== 'preview'}
<div
class="pane"
style={store.layout === 'split' ? `width: ${ratio * 100}%` : 'flex: 1'}
>
<Editor />
</div>
{/if}
{#if store.layout === 'split'}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="gutter"
role="separator"
aria-orientation="vertical"
tabindex="-1"
onmousedown={startSplit}
></div>
{/if}
{#if store.layout !== 'code'}
<div class="pane preview-pane">
<Preview />
</div>
{/if}
{#if store.layout === 'preview'}
{#if store.editDrawer}
<div class="edit-drawer" transition:fly={{ x: -360, duration: 180 }}>
<div class="drawer-head">
<span class="drawer-title">{store.activeName}</span>
<div class="drawer-actions">
<button onclick={() => store.save()} disabled={!store.dirty}>Save</button>
<button class="ghost icon" title="Close (Esc)" onclick={() => (store.editDrawer = false)}>✕</button>
</div>
</div>
<div class="drawer-body">
<Editor />
</div>
</div>
{:else}
<button
class="fab"
title="Quick edit (⌘E)"
onclick={() => (store.editDrawer = true)}
>
✎ Edit
{#if store.dirty}<span class="fab-dot"></span>{/if}
</button>
{/if}
{/if}
</div>
{:else}
<div class="no-diagram">
<div class="muted">No diagram open.</div>
<div class="faint">Pick one from the sidebar, or create a new diagram with .</div>
</div>
{/if}
{#if store.showGit}
<GitPanel />
{/if}
</div>
</div>
{/if}
<Toasts />
<style>
.app {
height: 100%;
display: flex;
flex-direction: column;
}
.body {
flex: 1;
display: flex;
min-height: 0;
}
.split {
flex: 1;
display: flex;
min-width: 0;
position: relative;
}
.edit-drawer {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: clamp(360px, 42%, 640px);
display: flex;
flex-direction: column;
background: var(--bg-elev);
border-right: 1px solid var(--border-strong);
box-shadow: 8px 0 32px rgba(0, 0, 0, 0.45);
z-index: 20;
}
.drawer-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 8px 8px 14px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.drawer-title {
font-size: 13px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.drawer-actions {
display: flex;
gap: 6px;
align-items: center;
}
.drawer-body {
flex: 1;
min-height: 0;
}
.fab {
position: absolute;
left: 16px;
bottom: 16px;
z-index: 25;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 9px 16px;
border-radius: 999px;
background: var(--accent);
border-color: var(--accent);
color: #fff;
font-weight: 600;
box-shadow: 0 6px 22px rgba(124, 92, 255, 0.45);
}
.fab:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.fab-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--amber);
}
.split.splitting {
cursor: col-resize;
user-select: none;
}
.pane {
min-width: 0;
height: 100%;
overflow: hidden;
}
.preview-pane {
flex: 1;
}
.gutter {
width: 5px;
flex-shrink: 0;
background: var(--border);
cursor: col-resize;
transition: background 0.12s ease;
}
.gutter:hover {
background: var(--accent);
}
.no-diagram {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
text-align: center;
}
</style>