Files
Mermix/src/lib/components/Editor.svelte
Aleksey Shakhmatov bbc12ee4be feat(optimize): Diagram Doctor panel to declutter tangled flowcharts
Add an Optimize side panel that analyses the active flowchart and applies
one-click, reversible source rewrites to make dense graphs readable.

- optimize.ts: pure, label-aware flowchart parser + metrics, a 0-100
  readability score, and transforms — ELK layered layout, node/rank
  spacing + curved edges, de-emphasising cross-cutting hub edges
  (event bus / audit / cost-style fan-ins), duplicate-edge removal,
  direction toggle, and a non-destructive focus variant.
- mermaid.ts: register @mermaid-js/layout-elk so `layout: elk` renders.
- OptimizePanel.svelte + Toolbar toggle; panels are mutually exclusive.
- Preview: render the focus variant when a node is spotlighted, with a
  clear-focus pill.
- store/Editor: applySource() bumps a revision so programmatic rewrites
  re-sync CodeMirror and stay undoable with the editor's history.
2026-05-22 20:11:49 +03:00

158 lines
4.8 KiB
Svelte

<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 { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
import { lintKeymap } from '@codemirror/lint';
import { store } from '../store.svelte';
import { mermaidSupport } from '../cm-mermaid';
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' },
// Autocomplete popup
'.cm-tooltip': {
backgroundColor: 'var(--bg-elev-2)',
border: '1px solid var(--border-strong)',
borderRadius: '6px',
color: 'var(--text)',
boxShadow: '0 8px 28px rgba(0,0,0,0.45)',
},
'.cm-tooltip.cm-tooltip-autocomplete > ul': { fontFamily: 'var(--mono)', maxHeight: '16em' },
'.cm-tooltip-autocomplete ul li[aria-selected]': {
backgroundColor: 'var(--accent)',
color: '#fff',
},
'.cm-completionIcon': { color: 'var(--text-dim)', paddingRight: '0.6em' },
'.cm-completionDetail': { color: 'var(--text-faint)', fontStyle: 'normal' },
'.cm-completionInfo': {
backgroundColor: 'var(--bg-elev-2)',
border: '1px solid var(--border-strong)',
borderRadius: '6px',
color: 'var(--text-dim)',
},
// Lint diagnostics
'.cm-diagnostic': {
fontFamily: 'var(--mono)',
fontSize: '12px',
borderLeft: '3px solid var(--red)',
padding: '6px 8px',
},
'.cm-diagnostic-error': { borderLeftColor: 'var(--red)' },
'.cm-lintRange-error': {
backgroundImage: 'none',
textDecoration: 'underline wavy var(--red)',
textDecorationSkipInk: 'none',
},
'.cm-lint-marker-error': { content: '""' },
},
{ dark: true }
);
onMount(() => {
view = new EditorView({
parent: host,
state: EditorState.create({
doc: store.content,
extensions: [
lineNumbers(),
highlightActiveLineGutter(),
highlightSpecialChars(),
history(),
drawSelection(),
indentOnInput(),
bracketMatching(),
closeBrackets(),
highlightActiveLine(),
...mermaidSupport(),
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...historyKeymap,
...lintKeymap,
indentWithTab,
]),
theme,
EditorView.lineWrapping,
EditorView.updateListener.of((u) => {
if (u.docChanged) {
store.content = u.state.doc.toString();
}
}),
],
}),
});
return () => view?.destroy();
});
// Replace the whole document when a different diagram is loaded (activeId) or
// when content is swapped programmatically (revision, e.g. an applied
// optimization). Read content untracked so plain typing never resets it.
$effect(() => {
void store.activeId; // re-run when the selected diagram changes…
void store.revision; // …or when an optimization rewrites the source.
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>