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:
235
src/App.svelte
Normal file
235
src/App.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user