Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ pnpm install && pnpm dev # playground at http://localhost:5173
| [block](https://mermaid.js.org/syntax/block.html) | ✅ | ✅ | ❌ |
| [zenuml](https://mermaid.js.org/syntax/zenuml.html) | ✅ | ❌ | ❌ |

**Item editing** is select, edit in place, add, and delete; **structural editing** adds connect, reorder, and restyle. Every type round-trips losslessly and syncs selection between code and canvas, including view-only zenuml (an external plugin).
**Item editing** is select, edit in place, add, and delete; **structural editing** adds connect, reorder, and restyle. Every type round-trips losslessly and syncs selection between code and canvas, including view-only zenuml (an external plugin). All editable types also work when rendering through Pele (`peleAdapter` in `@visimer/dom`) — most edit fully there; a few gitgraph/architecture/c4 items remain view-only pending follow-up, and zenuml is Mermaid-only since it is an external plugin.

## Packages

Expand All @@ -122,6 +122,7 @@ Some other editor? Implement the adapter, it's about forty lines.
## Architecture
- Leverages the native Mermaid.js renderer for visualizing the Mermaid diagram true to how it’s intended. We just overlay/add point-edit functionality on top.
- We map the rendered SVG elements back to a Concrete Syntax Tree (CST) representation, which we can then use to edit only the parts of the Mermaid source that need updating (without affecting the rest of the file).
- The canvas renders through a swappable renderer seam: bring your own `mermaid` instance, or render via Pele by passing a `peleAdapter` (from `@visimer/dom`). Every editable diagram type works under both renderers — under Pele a few gitgraph/architecture/c4 items remain view-only pending follow-up, and zenuml is a Mermaid-only external plugin.

## Design

Expand Down
14 changes: 9 additions & 5 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ test exercises the seam.
| CodeMirror binding | `@visimer/codemirror` against a real CodeMirror 6 `EditorView` | `packages/codemirror/test/binding.test.ts` (jsdom): engine ops → view, view edits → engine, decorations in the DOM, caret → entity selection, engine-authoritative undo, teardown | A |
| Monaco binding | `@visimer/monaco` against the structural editor interface it binds | `packages/monaco/test/binding.test.ts`: fake implementing exactly the bound surface (both sync directions, decorations, caret reasons, undo keys, dispose), plus a compile-time conformance check that real `monaco-editor` types satisfy the interface | B |
| Canvas render loop (what makes `@visimer/dom` re-render) | The triggers and the guards on them: source changes, config changes, and the webfont-settled re-measure that keeps labels from being clipped on a cold load | `packages/dom/test/fonts.test.ts` drives a real `MermaidCanvasView` over a fake mermaid and a stubbed `FontFaceSet`: re-renders once when fonts land late, never when they were already there, never after `destroy()`, never where the document exposes no font set | B |
| SVG correlation (dom package ↔ Mermaid's rendered DOM) | Third-party dependency seam: correlators key off Mermaid's internal SVG structure, which can shift between Mermaid releases | None automated. Verified manually in the playground across all 23 diagram types | uncovered |
| SVG correlation — mermaid side (dom package ↔ Mermaid's rendered DOM) | Third-party dependency seam: correlators key off Mermaid's internal SVG structure, which can shift between Mermaid releases | None automated. Verified manually in the playground across all 23 diagram types | uncovered |
| SVG correlation — pele side (dom package ↔ pele's rendered SVG) | Third-party dependency seam: pele correlators key off pele's internal SVG structure | `packages/dom/test/correlate-pele*.test.ts` (~100 fixture tests, one suite per correlation family group; jsdom; pele's metrics fallback) render real pele SVG — flowchart, sequence, state, class, ER, pie, gantt, and the 15 line-item types — and assert entities were tagged, ambiguous matches degraded (never mis-paired), scenery left untagged, and edits round-tripped visibly through the view | A |
| Canvas interaction layer (popovers, drag, in-place editing) | `@visimer/dom` gestures compiled to engine ops | None automated. Verified manually in the playground | uncovered |
| React bindings | `@visimer/react` hooks/components over core events | None automated. Thin subscription layer; exercised manually via the playground | uncovered |

Expand All @@ -23,8 +24,11 @@ test exercises the seam.
What the current suite cannot catch:

- **A Mermaid upgrade that shifts the rendered SVG structure.** This is the
highest-risk seam. Correlators fail soft (elements degrade to view-only),
so the failure is silent feature loss, not a crash. Closing it needs a
highest-risk seam on the Mermaid side. Correlators fail soft (elements
degrade to view-only), so the failure is silent feature loss, not a crash.
The pele side of this seam is now covered: every diagram family has a
real-render fixture suite (`correlate-pele*.test.ts`) asserting entities
were tagged and degraded sanely. Closing the Mermaid side still needs a
real-browser rung (vitest browser mode or Playwright) that renders real
Mermaid per diagram type and asserts entities were tagged; jsdom cannot
provide it because Mermaid layout requires real text measurement.
Expand All @@ -38,8 +42,8 @@ What the current suite cannot catch:
- **React render-loop regressions** (stale subscriptions, effect ordering).
Would need @testing-library/react coverage.

The playground doubles as the manual harness for all three: every editable
type has a sample, and the toolbar/popovers exercise the gesture surface.
The playground doubles as the manual harness for the uncovered seams: every
editable type has a sample, and the toolbar/popovers exercise the gesture surface.

## Running

Expand Down
1 change: 1 addition & 0 deletions apps/playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@visimer/react": "workspace:*",
"mermaid": "^11.16.1",
"monaco-editor": "^0.55.1",
"pele": "^1.13.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
Expand Down
39 changes: 34 additions & 5 deletions apps/playground/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,22 @@ import {
type ParticipantType,
type ShapeId,
} from '@visimer/core'
import type { MermaidCanvasView, Tool } from '@visimer/dom'
import * as pele from 'pele'
import { peleAdapter, type MermaidCanvasView, type Tool } from '@visimer/dom'
import { MermaidCanvas, useMermaidEditor } from '@visimer/react'
import { MermaidCodeMirror } from '@visimer/codemirror'
import { MonacoPane } from './MonacoPane'
import { SAMPLES } from '../../js-playground/src/samples'

/**
* Pele is stateless, so one adapter serves every canvas — built once at module
* scope instead of per-render, keeping the `renderer` prop identity stable (the
* canvas re-creates its view when props it was constructed with change).
*/
const peleRenderer = peleAdapter(pele, {
fontFamily: '"Inter Variable", "Inter", ui-sans-serif, system-ui, sans-serif',
})

let zenumlReady: Promise<boolean> | null = null
function ensureZenuml(): Promise<boolean> {
zenumlReady ??= import('@mermaid-js/mermaid-zenuml')
Expand Down Expand Up @@ -142,6 +152,13 @@ export default function App() {
const [theme, setTheme] = useState('ink')
const [readOnly, setReadOnly] = useState(false)
const [zenumlOk, setZenumlOk] = useState(false)
const [renderer, setRenderer] = useState<'mermaid' | 'pele'>(() =>
localStorage.getItem('visimer.renderer') === 'pele' ? 'pele' : 'mermaid',
)

useEffect(() => {
localStorage.setItem('visimer.renderer', renderer)
}, [renderer])

const sample = SAMPLES[sampleIndex]
useEffect(() => {
Expand All @@ -160,6 +177,10 @@ export default function App() {
<span className="sub">playground</span>
</div>
<div className="controls">
<div className="seg" role="group" aria-label="Renderer">
<button className={renderer === 'mermaid' ? 'on' : ''} onClick={() => setRenderer('mermaid')}>Mermaid</button>
<button className={renderer === 'pele' ? 'on' : ''} onClick={() => setRenderer('pele')}>Pele</button>
</div>
<select value={theme} onChange={(e) => setTheme(e.target.value)} title="Mermaid theme">
{['ink', 'dark', 'default', 'forest', 'neutral'].map((t) => (
<option key={t} value={t}>{t}</option>
Expand Down Expand Up @@ -189,13 +210,13 @@ export default function App() {
<span><i className="dot render" /> render</span>
</div>
</aside>
{!waitForZenuml && <Workspace key={sampleIndex} code={sample.code} theme={theme} readOnly={readOnly} />}
{!waitForZenuml && <Workspace key={sampleIndex} code={sample.code} theme={theme} readOnly={readOnly} renderer={renderer} />}
</div>
</div>
)
}

function Workspace({ code, theme, readOnly }: { code: string; theme: string; readOnly: boolean }) {
function Workspace({ code, theme, readOnly, renderer }: { code: string; theme: string; readOnly: boolean; renderer: 'mermaid' | 'pele' }) {
const { editor } = useMermaidEditor(code)
const [view, setView] = useState<MermaidCanvasView | null>(null)
const [tool, setToolState] = useState<Tool>('select')
Expand Down Expand Up @@ -234,10 +255,18 @@ function Workspace({ code, theme, readOnly }: { code: string; theme: string; rea
<button disabled={!editor.canUndo} onClick={() => editor.undo()}>Undo</button>
<button disabled={!editor.canRedo} onClick={() => editor.redo()}>Redo</button>
</div>
{renderer === 'pele' && (
<div className="notice" role="note">
<strong>Pele</strong> renderer: every editable diagram type renders here and is
editable, except zenuml (external plugin — Pele restriction). A few legacy
correlator gaps stay view-only (some gitgraph/architecture/c4 items).
</div>
)}
<MermaidCanvas
editor={editor}
mermaid={mermaid}
mermaidConfig={{ securityLevel: 'loose', ...themeConfig(theme) }}
mermaid={renderer === 'mermaid' ? mermaid : undefined}
renderer={renderer === 'pele' ? peleRenderer : undefined}
mermaidConfig={renderer === 'mermaid' ? { securityLevel: 'loose', ...themeConfig(theme) } : undefined}
accentColor="#69a3ff"
readOnly={readOnly}
onReady={setView}
Expand Down
18 changes: 18 additions & 0 deletions apps/playground/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,14 @@ body {
.toolbar.disabled { opacity: 0.4; pointer-events: none; }
.spacer { flex: 1; }

/* renderer notice (shown under the toolbar when the pele renderer is active) */
.notice {
flex: 0 0 auto; padding: 6px 12px; font-size: 12px; line-height: 1.45;
color: var(--dim); background: var(--panel);
border-bottom: 1px solid var(--border);
}
.notice strong { color: var(--fg); font-weight: 600; }

button, select {
height: 30px; padding: 0 12px; border-radius: 6px; font-size: 13px; font-family: var(--sans);
background: var(--bg); color: var(--fg); border: 1px solid var(--border-strong);
Expand Down Expand Up @@ -110,6 +118,16 @@ select { appearance: none; padding-right: 26px; background-image: url("data:imag
--mw-chrome-fg: var(--fg);
--mw-chrome-hover: oklch(0.269 0 0);
--mw-chrome-dim: var(--dim);
/* pele theme (INK-matched; read by pele's SVG stylesheet) */
--pele-bg: #000000;
--pele-fg: #ededed;
--pele-muted: #a1a1a1;
--pele-line: #8f8f8f;
--pele-surface: #111111;
--pele-surface-alt: #1a1a1a;
--pele-border: #3f3f3f;
--pele-accent: #3784ff;
--pele-font: "Inter Variable", "Inter", ui-sans-serif, system-ui, sans-serif;
}
.canvas:focus { outline: none; }

Expand Down
Loading
Loading