- 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)
240 lines
5.8 KiB
Svelte
240 lines
5.8 KiB
Svelte
<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>
|