Files
Mermix/src/lib/components/Preview.svelte
Aleksey Shakhmatov 29bf6438b3 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)
2026-05-22 16:27:28 +03:00

240 lines
5.8 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>