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:
239
src/lib/components/Preview.svelte
Normal file
239
src/lib/components/Preview.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user