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.
158 lines
4.8 KiB
Svelte
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>
|