mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-03 02:11:07 +05:30
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4201acad9 | ||
|
|
0b5af04a6a | ||
|
|
2d6f909b0e | ||
|
|
8624257c1f | ||
|
|
b75f436aaa | ||
|
|
d81d8c28d2 | ||
|
|
559d09cad9 | ||
|
|
5ad582ec3e | ||
|
|
d02f896167 | ||
|
|
f34775ce53 | ||
|
|
5d6a55dd09 | ||
|
|
0e80e37d9d | ||
|
|
9a7e994224 | ||
|
|
3effacf816 | ||
|
|
1be10f9ae5 | ||
|
|
4c0b7d6e84 |
@@ -0,0 +1,80 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { EditorState, Compartment } from '@codemirror/state';
|
||||||
|
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
|
||||||
|
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
|
||||||
|
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
|
||||||
|
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
||||||
|
import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
|
||||||
|
import { oneDark } from '@codemirror/theme-one-dark';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { lightTheme, lightHighlight } from './themes/light';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
bufferId: string;
|
||||||
|
initialContent: string;
|
||||||
|
onChange?: (content: string) => void;
|
||||||
|
onCursorChange?: (line: number, column: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorChange }: Props) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const viewRef = useRef<EditorView | null>(null);
|
||||||
|
const themeCompartment = useRef(new Compartment());
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
const updateContent = useEditorStore((s) => s.updateContent);
|
||||||
|
const setCursor = useEditorStore((s) => s.setCursor);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc: initialContent,
|
||||||
|
extensions: [
|
||||||
|
lineNumbers(),
|
||||||
|
highlightActiveLine(),
|
||||||
|
highlightSelectionMatches(),
|
||||||
|
history(),
|
||||||
|
drawSelection(),
|
||||||
|
markdown({ base: markdownLanguage, codeLanguages: [] }),
|
||||||
|
autocompletion(),
|
||||||
|
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap, ...completionKeymap, indentWithTab]),
|
||||||
|
themeCompartment.current.of(resolvedTheme === 'dark' ? [oneDark] : [lightTheme, lightHighlight]),
|
||||||
|
EditorView.lineWrapping,
|
||||||
|
EditorView.updateListener.of((v) => {
|
||||||
|
if (v.docChanged) {
|
||||||
|
const content = v.state.doc.toString();
|
||||||
|
updateContent(bufferId, content);
|
||||||
|
onChange?.(content);
|
||||||
|
}
|
||||||
|
if (v.selectionSet || v.docChanged) {
|
||||||
|
const head = v.state.selection.main.head;
|
||||||
|
const line = v.state.doc.lineAt(head);
|
||||||
|
const lineNo = line.number;
|
||||||
|
const col = head - line.from + 1;
|
||||||
|
setCursor(bufferId, lineNo, col);
|
||||||
|
onCursorChange?.(lineNo, col);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const view = new EditorView({ state, parent: ref.current });
|
||||||
|
viewRef.current = view;
|
||||||
|
return () => {
|
||||||
|
view.destroy();
|
||||||
|
viewRef.current = null;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [bufferId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const view = viewRef.current;
|
||||||
|
if (!view) return;
|
||||||
|
view.dispatch({
|
||||||
|
effects: themeCompartment.current.reconfigure(
|
||||||
|
resolvedTheme === 'dark' ? [oneDark] : [lightTheme, lightHighlight]
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [resolvedTheme]);
|
||||||
|
|
||||||
|
return <div ref={ref} className="h-full overflow-hidden" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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 (
|
||||||
|
<div className="flex h-full items-center justify-center bg-background text-muted-foreground">
|
||||||
|
<p>No file open. Use File → Open to start.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full">
|
||||||
|
<CodeMirrorEditor
|
||||||
|
key={buf.id}
|
||||||
|
bufferId={buf.id}
|
||||||
|
initialContent={buf.content}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { EditorView } from '@codemirror/view';
|
||||||
|
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||||
|
import { tags as t } from '@lezer/highlight';
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
background: '#ffffff',
|
||||||
|
foreground: '#0d0b09',
|
||||||
|
cursor: '#e5461f',
|
||||||
|
selection: 'rgba(229, 70, 31, 0.15)',
|
||||||
|
gutterBackground: '#fafbfc',
|
||||||
|
gutterForeground: '#7a7878',
|
||||||
|
lineHighlight: 'rgba(0, 0, 0, 0.04)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const lightTheme = EditorView.theme(
|
||||||
|
{
|
||||||
|
'&': {
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
color: colors.foreground,
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
'.cm-content': {
|
||||||
|
caretColor: colors.cursor,
|
||||||
|
fontFamily: 'JetBrains Mono, Fira Code, monospace',
|
||||||
|
fontSize: '13.5px',
|
||||||
|
},
|
||||||
|
'.cm-cursor, .cm-dropCursor': { borderLeftColor: colors.cursor },
|
||||||
|
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection': {
|
||||||
|
backgroundColor: colors.selection,
|
||||||
|
},
|
||||||
|
'.cm-gutters': {
|
||||||
|
backgroundColor: colors.gutterBackground,
|
||||||
|
color: colors.gutterForeground,
|
||||||
|
border: 'none',
|
||||||
|
},
|
||||||
|
'.cm-activeLine': { backgroundColor: colors.lineHighlight },
|
||||||
|
'.cm-activeLineGutter': { backgroundColor: 'transparent', color: '#e5461f' },
|
||||||
|
},
|
||||||
|
{ dark: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
const highlightStyle = HighlightStyle.define([
|
||||||
|
{ tag: t.heading1, color: '#0d0b09', fontWeight: '700' },
|
||||||
|
{ tag: t.heading2, color: '#0d0b09', fontWeight: '700' },
|
||||||
|
{ tag: t.heading3, color: '#464646', fontWeight: '600' },
|
||||||
|
{ tag: t.link, color: '#e5461f', textDecoration: 'underline' },
|
||||||
|
{ tag: t.url, color: '#e5461f' },
|
||||||
|
{ tag: t.emphasis, fontStyle: 'italic' },
|
||||||
|
{ tag: t.strong, fontWeight: '700' },
|
||||||
|
{ tag: t.monospace, color: '#c93a18' },
|
||||||
|
{ tag: t.list, color: '#0ea5e9' },
|
||||||
|
{ tag: t.quote, color: '#7a7878', fontStyle: 'italic' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const lightHighlight = syntaxHighlighting(highlightStyle);
|
||||||
@@ -3,6 +3,8 @@ import { TabBar } from './TabBar';
|
|||||||
import { Toolbar } from './Toolbar';
|
import { Toolbar } from './Toolbar';
|
||||||
import { Breadcrumb } from './Breadcrumb';
|
import { Breadcrumb } from './Breadcrumb';
|
||||||
import { StatusBar } from './StatusBar';
|
import { StatusBar } from './StatusBar';
|
||||||
|
import { EditorPane } from '@/components/editor/EditorPane';
|
||||||
|
import { PreviewPane } from '@/components/preview/PreviewPane';
|
||||||
import { useAppStore } from '@/stores/app-store';
|
import { useAppStore } from '@/stores/app-store';
|
||||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
|
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
|
||||||
|
|
||||||
@@ -31,17 +33,13 @@ export function AppShell() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<ResizablePanel defaultSize={previewVisible ? paneSizes.editor : 100} minSize={20}>
|
<ResizablePanel defaultSize={previewVisible ? paneSizes.editor : 100} minSize={20}>
|
||||||
<section className="h-full bg-background p-4 text-sm text-muted-foreground">
|
<EditorPane />
|
||||||
Editor placeholder
|
|
||||||
</section>
|
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
{previewVisible && (
|
{previewVisible && (
|
||||||
<>
|
<>
|
||||||
<ResizableHandle />
|
<ResizableHandle />
|
||||||
<ResizablePanel defaultSize={paneSizes.preview} minSize={20}>
|
<ResizablePanel defaultSize={paneSizes.preview} minSize={20}>
|
||||||
<section className="h-full border-l border-border bg-card/10 p-4 text-sm text-muted-foreground">
|
<PreviewPane />
|
||||||
Preview placeholder
|
|
||||||
</section>
|
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
|
||||||
|
function countWords(text: string): number {
|
||||||
|
return text.trim().length === 0 ? 0 : text.trim().split(/\s+/).length;
|
||||||
|
}
|
||||||
|
|
||||||
export function StatusBar() {
|
export function StatusBar() {
|
||||||
|
const { buffers, activeId } = useEditorStore();
|
||||||
|
const buf = activeId ? buffers.get(activeId) : null;
|
||||||
|
const wordCount = buf ? countWords(buf.content) : 0;
|
||||||
|
const cursor = buf?.cursor ?? { line: 1, column: 1 };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="flex h-7 items-center justify-between border-t border-border bg-card/20 px-3 text-xs text-muted-foreground">
|
<footer className="flex h-7 items-center justify-between border-t border-border bg-card/20 px-3 text-xs text-muted-foreground">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span>0 words</span>
|
<span>{wordCount} words</span>
|
||||||
<span>UTF-8</span>
|
<span>UTF-8</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span>Ln 1, Col 1</span>
|
<span>Ln {cursor.line}, Col {cursor.column}</span>
|
||||||
<span>Markdown</span>
|
<span>Markdown</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -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,63 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { immer } from 'zustand/middleware/immer';
|
||||||
|
import { enableMapSet } from 'immer';
|
||||||
|
|
||||||
|
enableMapSet();
|
||||||
|
|
||||||
|
export interface Buffer {
|
||||||
|
id: string;
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
dirty: boolean;
|
||||||
|
cursor?: { line: number; column: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorState {
|
||||||
|
buffers: Map<string, Buffer>;
|
||||||
|
activeId: string | null;
|
||||||
|
openBuffer: (id: string, path: string, content: string) => void;
|
||||||
|
updateContent: (id: string, content: string) => void;
|
||||||
|
markSaved: (id: string) => void;
|
||||||
|
setCursor: (id: string, line: number, column: number) => void;
|
||||||
|
closeBuffer: (id: string) => void;
|
||||||
|
setActive: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEditorStore = create<EditorState>()(
|
||||||
|
immer((set) => ({
|
||||||
|
buffers: new Map(),
|
||||||
|
activeId: null,
|
||||||
|
openBuffer: (id, path, content) =>
|
||||||
|
set((s) => {
|
||||||
|
s.buffers.set(id, { id, path, content, dirty: false });
|
||||||
|
s.activeId = id;
|
||||||
|
}),
|
||||||
|
updateContent: (id, content) =>
|
||||||
|
set((s) => {
|
||||||
|
const buf = s.buffers.get(id);
|
||||||
|
if (buf) {
|
||||||
|
buf.content = content;
|
||||||
|
buf.dirty = true;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
markSaved: (id) =>
|
||||||
|
set((s) => {
|
||||||
|
const buf = s.buffers.get(id);
|
||||||
|
if (buf) buf.dirty = false;
|
||||||
|
}),
|
||||||
|
setCursor: (id, line, column) =>
|
||||||
|
set((s) => {
|
||||||
|
const buf = s.buffers.get(id);
|
||||||
|
if (buf) buf.cursor = { line, column };
|
||||||
|
}),
|
||||||
|
closeBuffer: (id) =>
|
||||||
|
set((s) => {
|
||||||
|
s.buffers.delete(id);
|
||||||
|
if (s.activeId === id) s.activeId = null;
|
||||||
|
}),
|
||||||
|
setActive: (id) =>
|
||||||
|
set((s) => {
|
||||||
|
s.activeId = id;
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
);
|
||||||
@@ -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,38 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { ThemeProvider } from '@/components/theme-provider';
|
||||||
|
import { EditorPane } from '@/components/editor/EditorPane';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
import { CodeMirrorEditor } from '@/components/editor/CodeMirrorEditor';
|
||||||
|
|
||||||
|
vi.mock('@/components/editor/CodeMirrorEditor', () => ({
|
||||||
|
CodeMirrorEditor: ({ initialContent }: { initialContent: string }) => (
|
||||||
|
<div data-testid="codemirror-mock">{initialContent}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('EditorPane', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useEditorStore.setState({ buffers: new Map(), activeId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the empty state when no buffer is open', () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<EditorPane />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/no file open/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the editor when a buffer is open', () => {
|
||||||
|
useEditorStore.getState().openBuffer('b1', '/x.md', '# hello');
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<EditorPane />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('codemirror-mock')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('codemirror-mock')).toHaveTextContent('# hello');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,36 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
|
||||||
|
describe('useEditorStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useEditorStore.setState({ buffers: new Map(), activeId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a new buffer with the given content', () => {
|
||||||
|
useEditorStore.getState().openBuffer('file-1', '/foo.md', '# hello');
|
||||||
|
const buf = useEditorStore.getState().buffers.get('file-1');
|
||||||
|
expect(buf?.content).toBe('# hello');
|
||||||
|
expect(buf?.path).toBe('/foo.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates content and marks dirty', () => {
|
||||||
|
useEditorStore.getState().openBuffer('file-1', '/foo.md', '');
|
||||||
|
useEditorStore.getState().updateContent('file-1', 'new content');
|
||||||
|
const buf = useEditorStore.getState().buffers.get('file-1');
|
||||||
|
expect(buf?.content).toBe('new content');
|
||||||
|
expect(buf?.dirty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a buffer clean after save', () => {
|
||||||
|
useEditorStore.getState().openBuffer('file-1', '/foo.md', '');
|
||||||
|
useEditorStore.getState().updateContent('file-1', 'x');
|
||||||
|
useEditorStore.getState().markSaved('file-1');
|
||||||
|
expect(useEditorStore.getState().buffers.get('file-1')?.dirty).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes a buffer and removes it', () => {
|
||||||
|
useEditorStore.getState().openBuffer('file-1', '/foo.md', '');
|
||||||
|
useEditorStore.getState().closeBuffer('file-1');
|
||||||
|
expect(useEditorStore.getState().buffers.has('file-1')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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