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,107 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { EditorState } from '@codemirror/state';
import {
EditorView,
keymap,
lineNumbers,
highlightActiveLine,
highlightActiveLineGutter,
drawSelection,
highlightSpecialChars,
} from '@codemirror/view';
import {
defaultKeymap,
history,
historyKeymap,
indentWithTab,
} from '@codemirror/commands';
import { bracketMatching, indentOnInput } from '@codemirror/language';
import { store } from '../store.svelte';
let host: HTMLDivElement;
let view: EditorView | undefined;
const theme = EditorView.theme(
{
'&': {
height: '100%',
fontSize: '13.5px',
backgroundColor: 'var(--bg-input)',
color: 'var(--text)',
},
'.cm-content': {
fontFamily: 'var(--mono)',
caretColor: 'var(--accent)',
padding: '12px 0',
},
'.cm-scroller': { fontFamily: 'var(--mono)', lineHeight: '1.55' },
'.cm-gutters': {
backgroundColor: 'var(--bg-input)',
color: 'var(--text-faint)',
border: 'none',
},
'.cm-activeLineGutter': { backgroundColor: 'transparent', color: 'var(--text-dim)' },
'.cm-activeLine': { backgroundColor: 'rgba(255,255,255,0.03)' },
'&.cm-focused .cm-cursor': { borderLeftColor: 'var(--accent)' },
'.cm-selectionBackground, &.cm-focused .cm-selectionBackground, ::selection': {
backgroundColor: 'var(--accent-soft) !important',
},
'.cm-matchingBracket': { backgroundColor: 'var(--accent-soft)', outline: 'none' },
},
{ dark: true }
);
onMount(() => {
view = new EditorView({
parent: host,
state: EditorState.create({
doc: store.content,
extensions: [
lineNumbers(),
highlightActiveLineGutter(),
highlightSpecialChars(),
history(),
drawSelection(),
indentOnInput(),
bracketMatching(),
highlightActiveLine(),
keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
theme,
EditorView.lineWrapping,
EditorView.updateListener.of((u) => {
if (u.docChanged) {
store.content = u.state.doc.toString();
}
}),
],
}),
});
return () => view?.destroy();
});
// When a different diagram is loaded, replace the whole document. Depend only
// on activeId; read content untracked so typing doesn't reset the editor.
$effect(() => {
void store.activeId; // re-run only when the selected diagram changes
if (!view) return;
untrack(() => {
const incoming = store.content;
if (incoming !== view!.state.doc.toString()) {
view!.dispatch({
changes: { from: 0, to: view!.state.doc.length, insert: incoming },
});
}
});
});
</script>
<div class="editor" bind:this={host}></div>
<style>
.editor {
height: 100%;
overflow: hidden;
background: var(--bg-input);
}
</style>