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.
240 lines
6.2 KiB
Svelte
240 lines
6.2 KiB
Svelte
<script lang="ts">
|
||
import { fly } from 'svelte/transition';
|
||
import { store } from './lib/store.svelte';
|
||
import StartScreen from './lib/components/StartScreen.svelte';
|
||
import Sidebar from './lib/components/Sidebar.svelte';
|
||
import Toolbar from './lib/components/Toolbar.svelte';
|
||
import Editor from './lib/components/Editor.svelte';
|
||
import Preview from './lib/components/Preview.svelte';
|
||
import GitPanel from './lib/components/GitPanel.svelte';
|
||
import OptimizePanel from './lib/components/OptimizePanel.svelte';
|
||
import Toasts from './lib/components/Toasts.svelte';
|
||
|
||
// Editor/preview split ratio (fraction taken by the editor).
|
||
let ratio = $state(0.42);
|
||
let splitting = $state(false);
|
||
let splitHost = $state<HTMLDivElement>();
|
||
|
||
function startSplit(e: MouseEvent) {
|
||
splitting = true;
|
||
e.preventDefault();
|
||
}
|
||
function moveSplit(e: MouseEvent) {
|
||
if (!splitting || !splitHost) return;
|
||
const rect = splitHost.getBoundingClientRect();
|
||
ratio = Math.min(0.8, Math.max(0.2, (e.clientX - rect.left) / rect.width));
|
||
}
|
||
function endSplit() {
|
||
splitting = false;
|
||
}
|
||
|
||
function onKey(e: KeyboardEvent) {
|
||
if (store.view !== 'editor') return;
|
||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
|
||
e.preventDefault();
|
||
void store.save();
|
||
}
|
||
// ⌘/Ctrl+E: jump into viewer mode and toggle the quick-edit drawer.
|
||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'e') {
|
||
e.preventDefault();
|
||
if (store.layout !== 'preview') store.layout = 'preview';
|
||
store.editDrawer = !store.editDrawer;
|
||
}
|
||
if (e.key === 'Escape' && store.editDrawer) {
|
||
store.editDrawer = false;
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<svelte:window onkeydown={onKey} onmousemove={moveSplit} onmouseup={endSplit} />
|
||
|
||
{#if store.view === 'start'}
|
||
<StartScreen />
|
||
{:else}
|
||
<div class="app">
|
||
<Toolbar />
|
||
<div class="body">
|
||
<Sidebar />
|
||
{#if store.activeId}
|
||
<div class="split" bind:this={splitHost} class:splitting>
|
||
{#if store.layout !== 'preview'}
|
||
<div
|
||
class="pane"
|
||
style={store.layout === 'split' ? `width: ${ratio * 100}%` : 'flex: 1'}
|
||
>
|
||
<Editor />
|
||
</div>
|
||
{/if}
|
||
{#if store.layout === 'split'}
|
||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||
<div
|
||
class="gutter"
|
||
role="separator"
|
||
aria-orientation="vertical"
|
||
tabindex="-1"
|
||
onmousedown={startSplit}
|
||
></div>
|
||
{/if}
|
||
{#if store.layout !== 'code'}
|
||
<div class="pane preview-pane">
|
||
<Preview />
|
||
</div>
|
||
{/if}
|
||
|
||
{#if store.layout === 'preview'}
|
||
{#if store.editDrawer}
|
||
<div class="edit-drawer" transition:fly={{ x: -360, duration: 180 }}>
|
||
<div class="drawer-head">
|
||
<span class="drawer-title">✎ {store.activeName}</span>
|
||
<div class="drawer-actions">
|
||
<button onclick={() => store.save()} disabled={!store.dirty}>Save</button>
|
||
<button class="ghost icon" title="Close (Esc)" onclick={() => (store.editDrawer = false)}>✕</button>
|
||
</div>
|
||
</div>
|
||
<div class="drawer-body">
|
||
<Editor />
|
||
</div>
|
||
</div>
|
||
{:else}
|
||
<button
|
||
class="fab"
|
||
title="Quick edit (⌘E)"
|
||
onclick={() => (store.editDrawer = true)}
|
||
>
|
||
✎ Edit
|
||
{#if store.dirty}<span class="fab-dot"></span>{/if}
|
||
</button>
|
||
{/if}
|
||
{/if}
|
||
</div>
|
||
{:else}
|
||
<div class="no-diagram">
|
||
<div class="muted">No diagram open.</div>
|
||
<div class="faint">Pick one from the sidebar, or create a new diagram with +.</div>
|
||
</div>
|
||
{/if}
|
||
{#if store.showOptimize}
|
||
<OptimizePanel />
|
||
{/if}
|
||
{#if store.showGit}
|
||
<GitPanel />
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<Toasts />
|
||
|
||
<style>
|
||
.app {
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.body {
|
||
flex: 1;
|
||
display: flex;
|
||
min-height: 0;
|
||
}
|
||
.split {
|
||
flex: 1;
|
||
display: flex;
|
||
min-width: 0;
|
||
position: relative;
|
||
}
|
||
.edit-drawer {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
bottom: 0;
|
||
width: clamp(360px, 42%, 640px);
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: var(--bg-elev);
|
||
border-right: 1px solid var(--border-strong);
|
||
box-shadow: 8px 0 32px rgba(0, 0, 0, 0.45);
|
||
z-index: 20;
|
||
}
|
||
.drawer-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 8px 8px 8px 14px;
|
||
border-bottom: 1px solid var(--border);
|
||
flex-shrink: 0;
|
||
}
|
||
.drawer-title {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.drawer-actions {
|
||
display: flex;
|
||
gap: 6px;
|
||
align-items: center;
|
||
}
|
||
.drawer-body {
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
.fab {
|
||
position: absolute;
|
||
left: 16px;
|
||
bottom: 16px;
|
||
z-index: 25;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
padding: 9px 16px;
|
||
border-radius: 999px;
|
||
background: var(--accent);
|
||
border-color: var(--accent);
|
||
color: #fff;
|
||
font-weight: 600;
|
||
box-shadow: 0 6px 22px rgba(124, 92, 255, 0.45);
|
||
}
|
||
.fab:hover {
|
||
background: var(--accent-hover);
|
||
border-color: var(--accent-hover);
|
||
}
|
||
.fab-dot {
|
||
width: 7px;
|
||
height: 7px;
|
||
border-radius: 50%;
|
||
background: var(--amber);
|
||
}
|
||
.split.splitting {
|
||
cursor: col-resize;
|
||
user-select: none;
|
||
}
|
||
.pane {
|
||
min-width: 0;
|
||
height: 100%;
|
||
overflow: hidden;
|
||
}
|
||
.preview-pane {
|
||
flex: 1;
|
||
}
|
||
.gutter {
|
||
width: 5px;
|
||
flex-shrink: 0;
|
||
background: var(--border);
|
||
cursor: col-resize;
|
||
transition: background 0.12s ease;
|
||
}
|
||
.gutter:hover {
|
||
background: var(--accent);
|
||
}
|
||
.no-diagram {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
text-align: center;
|
||
}
|
||
</style>
|