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

View File

@@ -0,0 +1,239 @@
<script lang="ts">
import { tick } from 'svelte';
import { store } from '../store.svelte';
import { renderMermaid } from '../mermaid';
let svg = $state('');
let error = $state('');
let rendering = $state(false);
let zoom = $state(1);
let pan = $state({ x: 0, y: 0 });
let canvasEl = $state<HTMLDivElement>();
let timer: ReturnType<typeof setTimeout> | undefined;
let fittedFor = '';
// Debounced re-render whenever the source or theme changes.
$effect(() => {
const code = store.content;
const theme = store.theme;
clearTimeout(timer);
timer = setTimeout(() => void run(code, theme), 220);
return () => clearTimeout(timer);
});
// Re-fit when the available space changes (layout / panel toggles).
$effect(() => {
void store.layout;
void store.showGit;
tick().then(fit);
});
async function run(code: string, theme: string) {
rendering = true;
const result = await renderMermaid(code, theme);
rendering = false;
if ('svg' in result) {
svg = result.svg;
store.lastSvg = result.svg;
error = '';
// Auto-fit the first time each diagram renders.
if (store.activeId !== fittedFor) {
fittedFor = store.activeId ?? '';
await tick();
fit();
}
} else {
error = result.error;
}
}
function svgDims(s: string): { w: number; h: number } {
const vb = s.match(/viewBox="([\d.\s-]+)"/);
if (vb) {
const p = vb[1].trim().split(/\s+/).map(Number);
if (p.length === 4 && p.every((n) => !Number.isNaN(n))) return { w: p[2], h: p[3] };
}
return { w: 0, h: 0 };
}
function zoomBy(factor: number) {
zoom = Math.min(6, Math.max(0.1, zoom * factor));
}
/** Scale the diagram so it fits within the visible canvas. */
function fit() {
if (!canvasEl || !svg) return;
const { w, h } = svgDims(svg);
if (!w || !h) return;
const margin = store.theme !== 'dark' ? 72 : 32;
const cw = canvasEl.clientWidth - margin;
const ch = canvasEl.clientHeight - margin;
if (cw <= 0 || ch <= 0) return;
zoom = Math.max(0.1, Math.min(cw / w, ch / h, 3));
pan = { x: 0, y: 0 };
}
function reset() {
zoom = 1;
pan = { x: 0, y: 0 };
}
// Drag-to-pan.
let dragging = $state(false);
let last = { x: 0, y: 0 };
function onDown(e: MouseEvent) {
dragging = true;
last = { x: e.clientX, y: e.clientY };
}
function onMove(e: MouseEvent) {
if (!dragging) return;
pan = { x: pan.x + (e.clientX - last.x), y: pan.y + (e.clientY - last.y) };
last = { x: e.clientX, y: e.clientY };
}
function onUp() {
dragging = false;
}
function onWheel(e: WheelEvent) {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
zoomBy(e.deltaY < 0 ? 1.1 : 0.9);
}
}
</script>
<div class="preview">
<div class="ctrls">
{#if rendering}<span class="dot" title="Rendering…"></span>{/if}
<button class="ghost icon" title="Zoom out" onclick={() => zoomBy(0.83)}></button>
<button class="ghost zoom" title="Reset to 100%" onclick={reset}
>{Math.round(zoom * 100)}%</button
>
<button class="ghost icon" title="Zoom in" onclick={() => zoomBy(1.2)}>+</button>
<button class="ghost icon" title="Fit to view" onclick={fit}>⤢</button>
</div>
<div
bind:this={canvasEl}
class="canvas"
class:grab={!dragging}
class:grabbing={dragging}
onmousedown={onDown}
onmousemove={onMove}
onmouseup={onUp}
onmouseleave={onUp}
onwheel={onWheel}
role="presentation"
>
{#if error}
<div class="error">
<div class="error-title">Diagram error</div>
<pre>{error}</pre>
</div>
{:else if svg}
<div
class="stage"
class:paper={store.theme !== 'dark'}
style="transform: translate({pan.x}px, {pan.y}px) scale({zoom});"
>
{@html svg}
</div>
{:else}
<div class="empty faint">Start typing to see your diagram.</div>
{/if}
</div>
</div>
<style>
.preview {
position: relative;
height: 100%;
background:
radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.05) 1px, transparent 0) 0 0 /
22px 22px,
var(--bg);
overflow: hidden;
}
.ctrls {
position: absolute;
top: 10px;
right: 12px;
display: flex;
align-items: center;
gap: 4px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 3px 5px;
z-index: 5;
}
.zoom {
font-size: 12px;
color: var(--text-dim);
min-width: 38px;
text-align: center;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
margin-right: 4px;
animation: pulse 1s infinite;
}
@keyframes pulse {
50% {
opacity: 0.3;
}
}
.canvas {
height: 100%;
display: grid;
place-items: center;
overflow: hidden;
}
.canvas.grab {
cursor: grab;
}
.canvas.grabbing {
cursor: grabbing;
}
.stage {
transform-origin: center center;
transition: transform 0.05s linear;
}
/* Light Mermaid themes are designed for a white page, so give them a
"paper" sheet; the dark theme renders directly on the dark canvas. */
.stage.paper {
background: #ffffff;
padding: 28px;
border-radius: 10px;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.45);
}
.stage :global(svg) {
display: block;
max-width: none;
height: auto;
}
.empty {
font-size: 14px;
}
.error {
max-width: 80%;
background: rgba(248, 81, 73, 0.08);
border: 1px solid rgba(248, 81, 73, 0.4);
border-radius: var(--radius);
padding: 16px 18px;
color: var(--red);
}
.error-title {
font-weight: 600;
margin-bottom: 8px;
}
.error pre {
margin: 0;
white-space: pre-wrap;
font-family: var(--mono);
font-size: 12px;
color: #ffb4ae;
}
</style>