Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions .changeset/olive-donkeys-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@visimer/dom': patch
---

Re-assert each canvas's mermaid config before it renders.

`mermaid.initialize()` writes a module-global config, so on a page with more
than one canvas the last one to mount owned it: every other canvas rendered
with the wrong theme and layout options from its second render onwards.
`render()` now re-asserts its own config instead of trusting whatever was
initialized in the constructor.
18 changes: 17 additions & 1 deletion apps/site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,23 @@
</script>
</head>
<body>
<div id="root"></div>
<div id="root">
<!--
Replaced by the app the moment it mounts. It is here because this is a
client-rendered SPA: without it the served document body is literally an
empty div, so anything that does not execute JavaScript sees no headline
at all, and the first paint is blank until the bundle has run.

Since the visible headline is drawn by mermaid at runtime, this is also
what shows if that render ever fails while the rest of the page works.

Keep this copy in sync with the hero in App.tsx.
-->
<div class="boot">
<h1 class="boot-title">WYSIWYG editor renders native mermaid</h1>
<p class="boot-sub">Click a node to edit it. Perfect for polishing AI-generated diagrams.</p>
</div>
</div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
155 changes: 143 additions & 12 deletions apps/site/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,60 @@ const REPO = 'inkeep/visimer'
const REPO_URL = `https://github.com/${REPO}`
const INSTALL_CMD = 'npm i @visimer/react'

/**
* The headline, as Mermaid. The verb rides the connector, which is where mermaid
* puts verbs, so the whole thing reads as one sentence.
*
* Direction is picked once at mount rather than on every resize: a phone fits the
* left-to-right layout by width, which shrinks the headline to caption size, but
* re-picking on resize would overwrite whatever the visitor had typed into it.
*/
const HERO_SOURCE_LR = `flowchart LR
A[WYSIWYG editor] -->|renders| B[native mermaid]`

const HERO_SOURCE_TD = `flowchart TD
A[WYSIWYG editor] -->|renders| B[native mermaid]`

/**
* Inclusive at 760 to match `@media (max-width: 760px)` in site.css, which sizes
* the band for this layout. A strict `<` disagrees with the media query at
* exactly 760px: the band goes tall for a stacked diagram while the source is
* still left-to-right.
*/
const HERO_STACK_MAX_WIDTH = 760

function initialHeroSource(): string {
if (typeof window === 'undefined') return HERO_SOURCE_LR
return window.innerWidth <= HERO_STACK_MAX_WIDTH ? HERO_SOURCE_TD : HERO_SOURCE_LR
}

/**
* Mermaid lays the headline out at display size, so this config carries the type
* scale rather than CSS scaling a small render up. Node padding is deliberately
* tight: the masthead fits to width, so every unit of padding is paid for in
* headline size.
*/
const heroConfig = {
theme: 'base',
flowchart: { padding: 11, nodeSpacing: 44, rankSpacing: 44, useMaxWidth: false },
themeVariables: {
fontFamily: "'Inter', sans-serif",
primaryColor: '#EAF3F0',
primaryBorderColor: '#0E7C6B',
primaryTextColor: '#1C1A17',
lineColor: '#0E7C6B',
secondaryColor: '#F5E9C9',
secondaryBorderColor: '#C9A227',
tertiaryColor: '#FBF9F4',
tertiaryBorderColor: '#E6E0D4',
mainBkg: '#EAF3F0',
nodeBorder: '#0E7C6B',
textColor: '#1C1A17',
// No edgeLabelBackground: mermaid ignores it for flowchart edge labels and
// backs them with the secondary tint instead, so site.css sets it.
},
}


const FEATURES: Array<{ mark: string; title: string; body: string }> = [
{
Expand Down Expand Up @@ -156,6 +210,14 @@ export default function App() {
const [type, setType] = useState('flowchart')
const [skin, setSkin] = useState<'light' | 'dark'>('light')
const { editor } = useMermaidEditor(PRESETS.flowchart)

// The headline runs on its own editor so editing it never disturbs the
// playground below, and vice versa.
const [heroInitial] = useState(initialHeroSource)
const { editor: heroEditor } = useMermaidEditor(heroInitial)
const [heroSource, setHeroSource] = useState(heroInitial)
useEffect(() => heroEditor.on('change', ({ code }) => setHeroSource(code.trim())), [heroEditor])

useCanvasControlTracking()

// Expanding hands off to the dedicated /playground page, carrying the
Expand Down Expand Up @@ -409,8 +471,15 @@ export default function App() {
</div>
</header>

<main id="top">
<section style={{ maxWidth: 1000, margin: '0 auto', padding: '82px 26px 30px', textAlign: 'center' }}>
{/* The hero canvas is 100vw, which counts the scrollbar on platforms that
reserve space for one, so it would sit a few pixels wider than the page
and add a horizontal scrollbar. `clip` trims that without creating a
scroll container, so the sticky header above is unaffected. */}
<main id="top" style={{ overflowX: 'clip' }}>
{/* Wider than the old 1000px hero: the headline is now fit to the width of
this container, so a narrow one renders it well below the 76px the
text h1 used to carry. 1180 matches the demo section below it. */}
<section style={{ maxWidth: 1180, margin: '0 auto', padding: '74px 26px 34px', textAlign: 'center' }}>
<div
ref={badgeRef}
style={{
Expand All @@ -429,23 +498,85 @@ export default function App() {
<span style={{ width: 7, height: 7, borderRadius: 99, background: '#0E7C6B', display: 'inline-block' }} />
Open source · {licenseLabel} · React &amp; vanilla
</div>
<h1

{/* The canvas is not a card in the hero, it IS the hero: full-bleed to the
window, on the page's own background, with no border, fill or radius of
its own. Only the diagram is drawn, so the band reads as open page.

It stays in flow rather than sitting behind the whole section. Mermaid
centres the diagram in its canvas, so a canvas spanning the section
would centre on the section's midpoint, which on a phone lands on top
of the copy. Owning its own band keeps that under control. */}
{/* The heading is the text; the canvas is its rendering. Keeping them as
separate elements matters: with the canvas inside the h1, the heading
exposed the sentence once as its own text and then again, out of
order, from the diagram's own labels, and its textContent picked up
the whole stylesheet mermaid injects. The canvas is hidden from
assistive tech because it duplicates the heading and its editing is
pointer-only, so exposing it would add confusion without adding a
capability.

This text deliberately does not track edits to the diagram. It is the
page's heading, and rewriting it under an assistive-tech user as
somebody types would churn the accessibility tree and the document
outline for a change only the editing visitor made, to their own
local copy. The live source is echoed in the hint line below instead. */}
<h1 className="sr-only">WYSIWYG editor renders native mermaid</h1>
<div
className="hero-masthead"
aria-hidden
style={{
fontFamily: "'Inter', sans-serif",
fontWeight: 700,
fontSize: 'clamp(42px, 6.6vw, 76px)',
lineHeight: 1.04,
letterSpacing: '-0.035em',
position: 'relative',
left: '50%',
width: '100vw',
transform: 'translateX(-50%)',
height: 'clamp(210px, 24vw, 300px)',
margin: '26px 0 0',
textWrap: 'balance',
}}
>
Edit Mermaid diagrams <em style={{ fontStyle: 'normal', color: '#0E7C6B' }}>visually.</em>
</h1>
<MermaidCanvas
editor={heroEditor}
mermaid={mermaid}
mermaidConfig={heroConfig}
accentColor="#0E7C6B"
className="site-demo-canvas"
// The binding defaults the host to min-height 240, which fights the
// absolute inset positioning: between 761 and 999px the band is
// shorter than that, so the canvas outgrows the box and the diagram
// drifts off centre into the hint below.
style={{ backgroundColor: 'transparent', minHeight: 0 }}
// The canvas host sets tabIndex 0, which would leave a tab stop
// inside this aria-hidden subtree that announces nothing and does
// nothing, since editing here is pointer-only.
onReady={(view) => {
view.container.tabIndex = -1
}}
/>
</div>
<div
className="hero-hint"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
flexWrap: 'wrap',
margin: '14px 0 0',
fontSize: 13,
color: '#8A857A',
}}
>
<span style={{ color: '#0E7C6B', fontWeight: 600 }}>That headline is a live Mermaid diagram.</span>
<span>Double-click a word to rewrite it.</span>
{/* the statement line only: "flowchart LR" is noise in a one-line hint */}
<code style={{ fontFamily: mono, fontSize: 12.5, color: '#6B6559' }}>
{heroSource.split('\n').slice(1).join(' ').replace(/\s+/g, ' ').trim()}
</code>
</div>
<p
style={{
maxWidth: 620,
margin: '22px auto 0',
margin: '20px auto 0',
fontSize: 18.5,
lineHeight: 1.55,
color: '#544F47',
Expand Down
156 changes: 156 additions & 0 deletions apps/site/src/site.css
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,162 @@ textarea {
}
}

/* ---- Hero masthead -------------------------------------------------------
The h1 is a live Mermaid flowchart rather than text. It renders through the
published canvas, so it is editable in place: double-click a word and the
headline rewrites itself.

Three things this needs that an ordinary canvas does not:

1. Display scale. Mermaid ignores the fontSize theme variable for HTML
labels (they render at 16px) and the canvas fit caps at 2x, so neither can
drive headline size. The SVG's own viewBox does it: width and height at
100% with the default preserveAspectRatio scales glyphs, rule weights and
hit regions together, with no CSS transform to keep in sync.
2. Label slack. Mermaid measures labels in a detached node at its own weight,
so this heavier face needs slightly more width than the box it was given.
Node padding in the mermaid config supplies it; the overflow rules here
only stop a glyph being sliced if a future string outgrows that padding.
3. Room for the popover. The edit toolbar is a child of the canvas, so a
clipped hero would cut it in half on the first double-click.

Every rule is scoped to `.mw-svg-host > svg`, the diagram itself. A bare
`.mw-canvas svg` also matches the icons inside that popover, which would pin
each one to inset 0 and stack them into an unreadable pile. */
/* The host has to be pinned to the card as well as the svg. Left to size itself
it takes the diagram's natural height, and since the svg is positioned against
the host rather than the card, the diagram then paints straight out of the
hero — most visibly on phones, where the stacked layout is tallest. */
.hero-masthead .mw-svg-host {
position: absolute;
inset: 0;
}
.hero-masthead .mw-svg-host > svg {
position: absolute;
inset: 0;
width: 100% !important;
height: 100% !important;
max-width: none !important;
transform: none !important;
}
.hero-masthead .mw-svg-host > svg .nodeLabel,
.hero-masthead .mw-svg-host > svg .nodeLabel p {
font-family: 'Inter', sans-serif !important;
font-weight: 700 !important;
letter-spacing: -0.035em !important;
color: #1c1a17 !important;
fill: #1c1a17 !important;
}
.hero-masthead .mw-svg-host > svg foreignObject {
overflow: visible !important;
}
.hero-masthead .mw-svg-host > svg .nodeLabel,
.hero-masthead .mw-svg-host > svg .nodeLabel p,
.hero-masthead .mw-svg-host > svg .nodeLabel div {
overflow: visible !important;
white-space: nowrap !important;
max-width: none !important;
}
.hero-masthead .mw-svg-host > svg .edgePath path,
.hero-masthead .mw-svg-host > svg .flowchart-link {
stroke: #0e7c6b !important;
stroke-width: 2px !important;
}
.hero-masthead .mw-svg-host > svg marker path,
.hero-masthead .mw-svg-host > svg .marker {
fill: #0e7c6b !important;
stroke: #0e7c6b !important;
}
/* The edge label needs an opaque backing so the connector does not strike
through the word, but mermaid takes that colour from the theme's secondary
(the gold tint) and ignores edgeLabelBackground here, which reads as a
highlighter chip. It matches the page instead, since the hero canvas sits
directly on the site background with no card of its own. */
.hero-masthead .mw-svg-host > svg .edgeLabel,
.hero-masthead .mw-svg-host > svg .edgeLabel span,
.hero-masthead .mw-svg-host > svg .edgeLabel p {
background: #f7f4ed !important;
background-color: #f7f4ed !important;
color: #544f47 !important;
fill: #544f47 !important;
}
.hero-masthead .mw-svg-host > svg .edgeLabel rect {
fill: #f7f4ed !important;
}
/* Zoom chrome in the corner of an h1 gives away that it is a widget, and a
headline should not pan. */
.hero-masthead .mw-zoom-btn {
display: none !important;
}
.hero-masthead,
.hero-masthead .mw-canvas,
.hero-masthead .mw-svg-host {
overflow: visible !important;
}

/* The canvas runs the full width of the window, so without an inset the diagram
would butt against both screen edges. This keeps a margin at every size while
leaving the canvas itself full-bleed. */
.hero-masthead .mw-svg-host {
left: clamp(16px, 5vw, 90px);
right: clamp(16px, 5vw, 90px);
}

/* Under 760px the headline switches to the stacked top-down layout, which is
nearly square rather than wide, so the open band it is centred into has to be
deep enough that it is fitted by width rather than squashed by height. */
@media (max-width: 760px) {
.hero-masthead {
height: clamp(300px, 78vw, 360px) !important;
}
.hero-hint {
font-size: 12px !important;
}
}

/* First-paint content, defined in index.html inside #root and replaced by the
app on mount. Vite emits the stylesheet as a real <link> and the bundle as a
deferred module, so this paints styled before any JavaScript runs. Positioned
to sit roughly where the hero headline lands, so the swap is not a jump. */
.boot {
max-width: 1180px;
margin: 0 auto;
padding: 150px 26px 30px;
text-align: center;
}
.boot-title {
font-family: 'Inter', sans-serif;
font-weight: 700;
font-size: clamp(34px, 5vw, 58px);
line-height: 1.06;
letter-spacing: -0.035em;
color: #1c1a17;
margin: 0;
text-wrap: balance;
}
.boot-sub {
max-width: 620px;
margin: 20px auto 0;
font-size: 18.5px;
line-height: 1.55;
color: #544f47;
}

/* Keeps the headline text in the document for assistive tech and crawlers,
since the visible version is drawn as SVG by the diagram renderer. */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}

/* The live demo embeds the real editor; keep its canvas transparent so the
dotted preview background shows through, and center the rendered SVG the
way the design's preview pane does. */
Expand Down
Loading