mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 18:10:18 +05:30
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4201acad9 | ||
|
|
0b5af04a6a | ||
|
|
2d6f909b0e | ||
|
|
8624257c1f | ||
|
|
b75f436aaa | ||
|
|
d81d8c28d2 | ||
|
|
559d09cad9 | ||
|
|
5ad582ec3e | ||
|
|
d02f896167 | ||
|
|
f34775ce53 |
@@ -1,9 +1,16 @@
|
||||
import { useEffect } from 'react';
|
||||
import { CodeMirrorEditor } from './CodeMirrorEditor';
|
||||
import { useEditorStore } from '@/stores/editor-store';
|
||||
import { usePreviewStore } from '@/stores/preview-store';
|
||||
|
||||
export function EditorPane() {
|
||||
const { buffers, activeId } = useEditorStore();
|
||||
const buf = activeId ? buffers.get(activeId) : null;
|
||||
const setPreviewSource = usePreviewStore((s) => s.setSource);
|
||||
|
||||
useEffect(() => {
|
||||
if (buf) setPreviewSource(buf.content);
|
||||
}, [buf?.id, buf?.content, buf, setPreviewSource]);
|
||||
|
||||
if (!buf) {
|
||||
return (
|
||||
@@ -22,4 +29,4 @@ export function EditorPane() {
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Toolbar } from './Toolbar';
|
||||
import { Breadcrumb } from './Breadcrumb';
|
||||
import { StatusBar } from './StatusBar';
|
||||
import { EditorPane } from '@/components/editor/EditorPane';
|
||||
import { PreviewPane } from '@/components/preview/PreviewPane';
|
||||
import { useAppStore } from '@/stores/app-store';
|
||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
|
||||
|
||||
@@ -38,9 +39,7 @@ export function AppShell() {
|
||||
<>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel defaultSize={paneSizes.preview} minSize={20}>
|
||||
<section className="h-full border-l border-border bg-card/10 p-4 text-sm text-muted-foreground">
|
||||
Preview placeholder
|
||||
</section>
|
||||
<PreviewPane />
|
||||
</ResizablePanel>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { renderMarkdown } from '@/lib/markdown';
|
||||
import { MermaidLazy } from './MermaidLazy';
|
||||
|
||||
interface Props {
|
||||
source: string;
|
||||
}
|
||||
|
||||
const MERMAID_RE = /```mermaid\n([\s\S]*?)```/g;
|
||||
|
||||
function extractMermaidCodes(source: string): string[] {
|
||||
const codes: string[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = MERMAID_RE.exec(source)) !== null) {
|
||||
codes.push(match[1].trim());
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
|
||||
export function MarkdownRenderer({ source }: Props) {
|
||||
const html = renderMarkdown(source);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [mermaidCodes, setMermaidCodes] = useState<string[]>([]);
|
||||
|
||||
// Re-derive mermaid codes from the original source. The renderMarkdown function
|
||||
// replaces mermaid blocks with placeholders (data-mermaid-source="${idx}"), but
|
||||
// it does not return the actual codes. Rather than thread them through the API,
|
||||
// we re-extract from the source. Source is small (single buffer content) so this
|
||||
// cost is negligible.
|
||||
useEffect(() => {
|
||||
setMermaidCodes(extractMermaidCodes(source));
|
||||
}, [source]);
|
||||
|
||||
return (
|
||||
<div className="prose prose-neutral dark:prose-invert max-w-none p-6" ref={containerRef}>
|
||||
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||
{mermaidCodes.map((code, i) => (
|
||||
<div key={i} className="my-4">
|
||||
<MermaidLazy code={code} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
code: string;
|
||||
}
|
||||
|
||||
let mermaidModule: typeof import('mermaid').default | null = null;
|
||||
let initialized = false;
|
||||
|
||||
async function getMermaid() {
|
||||
if (!mermaidModule) {
|
||||
const mod = await import('mermaid');
|
||||
mermaidModule = mod.default;
|
||||
}
|
||||
if (!initialized) {
|
||||
mermaidModule.initialize({ startOnLoad: false, theme: 'default' });
|
||||
initialized = true;
|
||||
}
|
||||
return mermaidModule;
|
||||
}
|
||||
|
||||
export function MermaidLazy({ code }: Props) {
|
||||
const [svg, setSvg] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const idRef = useRef(`mermaid-${Math.random().toString(36).slice(2)}`);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getMermaid()
|
||||
.then((m) => m.render(idRef.current, code))
|
||||
.then((rendered) => {
|
||||
if (!cancelled) setSvg(rendered);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (!cancelled) setError(err.message);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [code]);
|
||||
|
||||
if (error) return <div className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive">{error}</div>;
|
||||
if (!svg) return <div className="text-xs text-muted-foreground">Loading diagram…</div>;
|
||||
return <div data-testid="mermaid-output" dangerouslySetInnerHTML={{ __html: svg }} />;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { MarkdownRenderer } from './MarkdownRenderer';
|
||||
import { usePreviewStore } from '@/stores/preview-store';
|
||||
import { useScrollSync } from '@/hooks/use-scroll-sync';
|
||||
|
||||
export function PreviewPane() {
|
||||
const { source, setScrollRatio } = usePreviewStore();
|
||||
const { handlePreviewScroll } = useScrollSync({ onPreviewScroll: setScrollRatio });
|
||||
|
||||
if (!source) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<p>Nothing to preview. Start typing in the editor.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto bg-card/10" onScroll={handlePreviewScroll}>
|
||||
<MarkdownRenderer source={source} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
interface Options {
|
||||
onEditorScroll?: (ratio: number) => void;
|
||||
onPreviewScroll?: (ratio: number) => void;
|
||||
}
|
||||
|
||||
export function useScrollSync(opts: Options) {
|
||||
const FRAME_MS = 1000 / 60;
|
||||
const lastTick = useRef(-FRAME_MS);
|
||||
|
||||
const handleEditorScroll = useCallback((evt: React.UIEvent<HTMLElement>) => {
|
||||
const target = evt.currentTarget;
|
||||
const ratio = target.scrollTop / Math.max(target.scrollHeight - target.clientHeight, 1);
|
||||
const now = performance.now();
|
||||
if (now - lastTick.current < FRAME_MS) return;
|
||||
lastTick.current = now;
|
||||
opts.onEditorScroll?.(ratio);
|
||||
}, [opts]);
|
||||
|
||||
const handlePreviewScroll = useCallback((evt: React.UIEvent<HTMLElement>) => {
|
||||
const target = evt.currentTarget;
|
||||
const ratio = target.scrollTop / Math.max(target.scrollHeight - target.clientHeight, 1);
|
||||
opts.onPreviewScroll?.(ratio);
|
||||
}, [opts]);
|
||||
|
||||
return { handleEditorScroll, handlePreviewScroll };
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
marked.setOptions({
|
||||
gfm: true,
|
||||
breaks: false,
|
||||
});
|
||||
|
||||
const MERMAID_RE = /```mermaid\n([\s\S]*?)```/g;
|
||||
|
||||
export function renderMarkdown(source: string): string {
|
||||
// Mark mermaid blocks with a placeholder we can replace client-side.
|
||||
const placeholders: string[] = [];
|
||||
const withPlaceholders = source.replace(MERMAID_RE, (_m, code) => {
|
||||
const idx = placeholders.length;
|
||||
placeholders.push(code.trim());
|
||||
return `<div class="mermaid-block" data-mermaid-source="${idx}"></div>`;
|
||||
});
|
||||
|
||||
const rawHtml = marked.parse(withPlaceholders, { async: false }) as string;
|
||||
const clean = DOMPurify.sanitize(rawHtml, {
|
||||
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'data-mermaid-source', 'data-language', 'id'],
|
||||
});
|
||||
|
||||
// The sanitized HTML still has placeholders; we leave the actual mermaid
|
||||
// rendering to the React layer (MermaidLazy component) so the heavy
|
||||
// mermaid library only loads when needed.
|
||||
return clean;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface PreviewState {
|
||||
source: string;
|
||||
scrollRatio: number;
|
||||
setSource: (s: string) => void;
|
||||
setScrollRatio: (r: number) => void;
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 300;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pending: string = '';
|
||||
|
||||
export const usePreviewStore = create<PreviewState>((set) => ({
|
||||
source: '',
|
||||
scrollRatio: 0,
|
||||
setSource: (s) => {
|
||||
pending = s;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
set({ source: pending });
|
||||
}, DEBOUNCE_MS);
|
||||
},
|
||||
setScrollRatio: (r) => set({ scrollRatio: r }),
|
||||
}));
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { MermaidLazy } from '@/components/preview/MermaidLazy';
|
||||
|
||||
vi.mock('mermaid', () => ({
|
||||
default: {
|
||||
initialize: vi.fn(),
|
||||
render: vi.fn().mockResolvedValue('<svg></svg>'),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('MermaidLazy', () => {
|
||||
it('renders an svg after mermaid resolves', async () => {
|
||||
render(<MermaidLazy code="graph TD; A-->B" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mermaid-output').innerHTML).toContain('<svg');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows an error message if mermaid throws', async () => {
|
||||
const mermaid = (await import('mermaid')).default as any;
|
||||
mermaid.render.mockRejectedValueOnce(new Error('mermaid failed'));
|
||||
render(<MermaidLazy code="bad code" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/mermaid failed/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '@/components/theme-provider';
|
||||
import { PreviewPane } from '@/components/preview/PreviewPane';
|
||||
import { usePreviewStore } from '@/stores/preview-store';
|
||||
|
||||
describe('PreviewPane', () => {
|
||||
beforeEach(() => {
|
||||
usePreviewStore.setState({ scrollRatio: 0, source: '' });
|
||||
});
|
||||
|
||||
it('renders empty state when no source', () => {
|
||||
render(
|
||||
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||
<PreviewPane />
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(screen.getByText(/nothing to preview/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders markdown when source is set', () => {
|
||||
usePreviewStore.setState({ source: '# Hello' });
|
||||
render(
|
||||
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||
<PreviewPane />
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(screen.getByRole('heading', { level: 1, name: /hello/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useScrollSync } from '@/hooks/use-scroll-sync';
|
||||
|
||||
describe('useScrollSync', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
// Mock performance.now() to return 0 and not auto-advance
|
||||
let time = 0;
|
||||
vi.spyOn(performance, 'now').mockImplementation(() => time);
|
||||
});
|
||||
|
||||
it('throttles editor scroll events to 60fps', () => {
|
||||
const onScroll = vi.fn();
|
||||
const { result } = renderHook(() => useScrollSync({ onEditorScroll: onScroll }));
|
||||
const mockEvt = { currentTarget: { scrollTop: 100, scrollHeight: 1000, clientHeight: 200 } } as any;
|
||||
// All calls within the same act() - performance.now() stays at 0
|
||||
// First call passes, subsequent calls within FRAME_MS are throttled
|
||||
act(() => {
|
||||
result.current.handleEditorScroll(mockEvt);
|
||||
result.current.handleEditorScroll(mockEvt);
|
||||
result.current.handleEditorScroll(mockEvt);
|
||||
});
|
||||
expect(onScroll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderMarkdown } from '@/lib/markdown';
|
||||
|
||||
describe('renderMarkdown', () => {
|
||||
it('renders headings', () => {
|
||||
const html = renderMarkdown('# Hello');
|
||||
expect(html).toContain('<h1>Hello</h1>');
|
||||
});
|
||||
|
||||
it('renders bold and italic', () => {
|
||||
const html = renderMarkdown('**bold** and *italic*');
|
||||
expect(html).toContain('<strong>bold</strong>');
|
||||
expect(html).toContain('<em>italic</em>');
|
||||
});
|
||||
|
||||
it('renders code blocks with language class', () => {
|
||||
const html = renderMarkdown('```js\nconst x = 1;\n```');
|
||||
expect(html).toContain('language-js');
|
||||
});
|
||||
|
||||
it('renders mermaid blocks with a placeholder', () => {
|
||||
const html = renderMarkdown('```mermaid\ngraph TD\nA-->B\n```');
|
||||
expect(html).toContain('data-mermaid-source=');
|
||||
});
|
||||
|
||||
it('sanitizes dangerous HTML', () => {
|
||||
const html = renderMarkdown('<script>alert(1)</script>');
|
||||
expect(html).not.toContain('<script>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { usePreviewStore } from '@/stores/preview-store';
|
||||
|
||||
describe('usePreviewStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
usePreviewStore.setState({ scrollRatio: 0, source: '' });
|
||||
});
|
||||
|
||||
it('debounces source updates (300 ms)', () => {
|
||||
usePreviewStore.getState().setSource('# a');
|
||||
usePreviewStore.getState().setSource('# b');
|
||||
usePreviewStore.getState().setSource('# c');
|
||||
expect(usePreviewStore.getState().source).toBe('');
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(usePreviewStore.getState().source).toBe('# c');
|
||||
});
|
||||
|
||||
it('updates scroll ratio', () => {
|
||||
usePreviewStore.getState().setScrollRatio(0.5);
|
||||
expect(usePreviewStore.getState().scrollRatio).toBe(0.5);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user