Skip to content

Commit 1b5020f

Browse files
authored
Merge pull request #81 from bocan/feature/mermaid2
Add Mermaid diagramming and PDF export feature
2 parents d891caf + 630debc commit 1b5020f

6 files changed

Lines changed: 2284 additions & 145 deletions

File tree

client/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"diff": "^8.0.3",
1717
"github-slugger": "^2.0.0",
1818
"html-docx-js-typescript": "^0.1.5",
19+
"mermaid": "^11.4.1",
1920
"react": "^19.2.4",
2021
"react-dom": "^19.2.4",
2122
"react-markdown": "^10.1.0",

client/src/components/Preview.css

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,61 @@
245245
line-height: 1.6;
246246
}
247247

248+
.mermaid-diagram-wrapper {
249+
position: relative;
250+
margin: 1.25em 0;
251+
background: var(--bg-secondary);
252+
border: 1px solid var(--border-color);
253+
border-radius: var(--radius-md);
254+
padding: var(--space-4);
255+
overflow-x: auto;
256+
}
257+
258+
.mermaid-diagram {
259+
/* Mermaid outputs inline SVG; keep it responsive where possible */
260+
width: 100%;
261+
}
262+
263+
.mermaid-diagram svg {
264+
max-width: 100%;
265+
height: auto;
266+
}
267+
268+
.mermaid-download-btn {
269+
position: absolute;
270+
top: var(--space-3);
271+
right: var(--space-3);
272+
background: transparent;
273+
border: 1px solid var(--border-color);
274+
color: var(--text-secondary);
275+
padding: 6px 8px;
276+
border-radius: 4px;
277+
cursor: pointer;
278+
font-size: 12px;
279+
transition: all 0.2s;
280+
}
281+
282+
.mermaid-download-btn:hover:not(:disabled) {
283+
background: var(--bg-tertiary);
284+
color: var(--text-primary);
285+
border-color: var(--text-secondary);
286+
}
287+
288+
.mermaid-download-btn:disabled {
289+
opacity: 0.6;
290+
cursor: not-allowed;
291+
}
292+
293+
.mermaid-error {
294+
color: var(--text-primary);
295+
background: var(--bg-tertiary);
296+
border: 1px solid var(--border-color);
297+
border-radius: var(--radius-sm);
298+
padding: var(--space-3);
299+
font-family: var(--font-mono);
300+
font-size: var(--text-sm);
301+
}
302+
248303
/* Remove default pre/code styling inside code block wrapper - SyntaxHighlighter handles this */
249304
.code-block-wrapper pre,
250305
.code-block-wrapper code {

client/src/components/Preview.test.tsx

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
import { render, screen, waitFor } from "@testing-library/react";
2+
import userEvent from "@testing-library/user-event";
23
import { Preview } from "../components/Preview";
34
import { api } from "../services/api";
5+
import { useEditorStore } from "../store/editorStore";
6+
import mermaid from "mermaid";
7+
8+
vi.mock("mermaid", () => ({
9+
default: {
10+
initialize: vi.fn(),
11+
render: vi.fn(),
12+
},
13+
}));
414

515
// Mock the api module
616
vi.mock("../services/api", () => ({
@@ -13,6 +23,22 @@ describe("Preview", () => {
1323
beforeEach(() => {
1424
vi.clearAllMocks();
1525
vi.mocked(api.getPage).mockResolvedValue({ path: "test.md", content: "" });
26+
27+
// Ensure the preview uses deterministic content in tests.
28+
useEditorStore.setState({
29+
content: "",
30+
scrollSource: null,
31+
editorScrollPercent: 0,
32+
previewScrollPercent: 0,
33+
});
34+
35+
// Provide CSS variables used by Mermaid theme initialization in jsdom.
36+
document.documentElement.style.setProperty("--bg-primary", "#18181b");
37+
document.documentElement.style.setProperty("--bg-secondary", "#27272a");
38+
document.documentElement.style.setProperty("--bg-tertiary", "#1f1f23");
39+
document.documentElement.style.setProperty("--text-primary", "#fafaf9");
40+
document.documentElement.style.setProperty("--border-color", "#3f3f46");
41+
document.documentElement.style.setProperty("--accent-color", "#818cf8");
1642
});
1743

1844
it("shows empty state when no page is selected", () => {
@@ -32,4 +58,52 @@ describe("Preview", () => {
3258
expect(screen.queryByLabelText(/loading/i)).not.toBeInTheDocument(),
3359
);
3460
});
61+
62+
it("shows Export to PDF in export menu", async () => {
63+
vi.mocked(api.getPage).mockResolvedValueOnce({ path: "test.md", content: "Hello" });
64+
render(<Preview pagePath="test.md" />);
65+
66+
await waitFor(() => expect(api.getPage).toHaveBeenCalledWith("test.md"));
67+
68+
const user = userEvent.setup();
69+
await user.click(screen.getByRole("button", { name: /export options/i }));
70+
expect(screen.getByRole("menuitem", { name: /export to pdf/i })).toBeInTheDocument();
71+
});
72+
73+
it("renders Mermaid code fences and avoids rerender when code is unchanged", async () => {
74+
vi.mocked(mermaid.render).mockResolvedValue({
75+
svg: '<svg data-testid="mmd"><g><text>OK</text></g></svg>',
76+
bindFunctions: undefined,
77+
diagramType: "flowchart",
78+
} as unknown as {
79+
svg: string;
80+
bindFunctions?: (element: Element) => void;
81+
diagramType: string;
82+
});
83+
84+
document.documentElement.setAttribute("data-theme", "dark");
85+
86+
const markdown = [
87+
"```mermaid",
88+
"flowchart LR",
89+
" A --> B",
90+
"```",
91+
].join("\n");
92+
93+
// Prevent the async API load from overwriting the live editor content during the test.
94+
vi.mocked(api.getPage).mockResolvedValueOnce({ path: "test.md", content: markdown });
95+
96+
useEditorStore.setState({ content: markdown });
97+
98+
const { rerender } = render(<Preview pagePath="test.md" />);
99+
100+
await waitFor(() => expect(vi.mocked(mermaid.render)).toHaveBeenCalledTimes(1));
101+
expect(
102+
await screen.findByRole("button", { name: /download diagram as svg/i }),
103+
).toBeInTheDocument();
104+
105+
// Rerender parent with same content: Mermaid should not redraw.
106+
rerender(<Preview pagePath="test.md" />);
107+
await waitFor(() => expect(vi.mocked(mermaid.render)).toHaveBeenCalledTimes(1));
108+
});
35109
});

0 commit comments

Comments
 (0)