mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 18:10:18 +05:30
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4201acad9 | ||
|
|
0b5af04a6a | ||
|
|
2d6f909b0e | ||
|
|
8624257c1f | ||
|
|
b75f436aaa | ||
|
|
d81d8c28d2 | ||
|
|
559d09cad9 | ||
|
|
5ad582ec3e | ||
|
|
d02f896167 | ||
|
|
f34775ce53 | ||
|
|
5d6a55dd09 | ||
|
|
0e80e37d9d | ||
|
|
9a7e994224 | ||
|
|
3effacf816 | ||
|
|
1be10f9ae5 | ||
|
|
4c0b7d6e84 | ||
|
|
db04ea5854 | ||
|
|
567726022c | ||
|
|
1999a7716e | ||
|
|
16e071ae5e | ||
|
|
7ecf54ceb0 | ||
|
|
95e2f45f1f | ||
|
|
7fbbd60525 | ||
|
|
c715bb354c | ||
|
|
6525de7667 |
+2
-21
@@ -1,26 +1,7 @@
|
||||
import { ThemeToggle } from './components/theme-toggle';
|
||||
import { Button } from './components/ui/button';
|
||||
import { AppShell } from './components/layout/AppShell';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-background text-foreground">
|
||||
<header className="flex h-14 items-center justify-between border-b border-border px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-7 w-7 rounded-md bg-brand" aria-label="MarkdownConverter logo" />
|
||||
<h1 className="text-sm font-semibold">MarkdownConverter</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm">File</Button>
|
||||
<Button variant="ghost" size="sm">Edit</Button>
|
||||
<Button variant="ghost" size="sm">View</Button>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex flex-1 items-center justify-center text-muted-foreground">
|
||||
<p>Shell skeleton — implementation phases in progress.</p>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
return <AppShell />;
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -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);
|
||||
@@ -0,0 +1,41 @@
|
||||
import { PanelLeft, PanelRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ThemeToggle } from '@/components/theme-toggle';
|
||||
import { useAppStore } from '@/stores/app-store';
|
||||
|
||||
export function AppHeader() {
|
||||
const { sidebarVisible, previewVisible, toggleSidebar, togglePreview } = useAppStore();
|
||||
|
||||
return (
|
||||
<header className="flex h-14 items-center justify-between border-b border-border bg-card/40 px-4 backdrop-blur">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="h-7 w-7 rounded-md bg-gradient-to-br from-brand to-brand-dark shadow-[var(--shadow-glow-brand)]"
|
||||
aria-label="MarkdownConverter logo"
|
||||
/>
|
||||
<h1 className="font-display text-lg font-bold tracking-tight">MarkdownConverter</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Toggle sidebar"
|
||||
aria-pressed={sidebarVisible}
|
||||
onClick={toggleSidebar}
|
||||
>
|
||||
<PanelLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Toggle preview"
|
||||
aria-pressed={previewVisible}
|
||||
onClick={togglePreview}
|
||||
>
|
||||
<PanelRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { AppHeader } from './AppHeader';
|
||||
import { TabBar } from './TabBar';
|
||||
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';
|
||||
|
||||
export function AppShell() {
|
||||
const { sidebarVisible, previewVisible, paneSizes, setPaneSizes } = useAppStore();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-background text-foreground">
|
||||
<AppHeader />
|
||||
<TabBar />
|
||||
<Toolbar />
|
||||
<Breadcrumb />
|
||||
<main className="flex-1 overflow-hidden">
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
onLayout={(sizes) => setPaneSizes({ sidebar: sizes[0], editor: sizes[1], preview: sizes[2] })}
|
||||
>
|
||||
{sidebarVisible && (
|
||||
<>
|
||||
<ResizablePanel defaultSize={paneSizes.sidebar} minSize={15} maxSize={40}>
|
||||
<aside className="h-full border-r border-border bg-card/10 p-3 text-sm text-muted-foreground">
|
||||
File tree placeholder
|
||||
</aside>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
</>
|
||||
)}
|
||||
<ResizablePanel defaultSize={previewVisible ? paneSizes.editor : 100} minSize={20}>
|
||||
<EditorPane />
|
||||
</ResizablePanel>
|
||||
{previewVisible && (
|
||||
<>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel defaultSize={paneSizes.preview} minSize={20}>
|
||||
<PreviewPane />
|
||||
</ResizablePanel>
|
||||
</>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
</main>
|
||||
<StatusBar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function Breadcrumb() {
|
||||
return (
|
||||
<nav aria-label="File path" className="flex h-7 items-center border-b border-border bg-card/10 px-3 text-xs text-muted-foreground">
|
||||
<span>No file selected</span>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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() {
|
||||
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 (
|
||||
<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">
|
||||
<span>{wordCount} words</span>
|
||||
<span>UTF-8</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span>Ln {cursor.line}, Col {cursor.column}</span>
|
||||
<span>Markdown</span>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function TabBar() {
|
||||
return (
|
||||
<div className="flex h-9 items-center border-b border-border bg-card/20 px-3 text-xs text-muted-foreground">
|
||||
<span>No files open</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Bold, Italic, List, ListOrdered, Code, Link as LinkIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function Toolbar() {
|
||||
return (
|
||||
<div className="flex h-10 items-center gap-1 border-b border-border bg-card/10 px-3">
|
||||
<Button variant="ghost" size="icon" aria-label="Bold"><Bold className="h-4 w-4" /></Button>
|
||||
<Button variant="ghost" size="icon" aria-label="Italic"><Italic className="h-4 w-4" /></Button>
|
||||
<Button variant="ghost" size="icon" aria-label="Unordered list"><List className="h-4 w-4" /></Button>
|
||||
<Button variant="ghost" size="icon" aria-label="Ordered list"><ListOrdered className="h-4 w-4" /></Button>
|
||||
<Button variant="ghost" size="icon" aria-label="Code"><Code className="h-4 w-4" /></Button>
|
||||
<Button variant="ghost" size="icon" aria-label="Link"><LinkIcon className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,62 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { GripVertical } from "lucide-react"
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ResizablePanelGroup = React.forwardRef<
|
||||
React.ElementRef<typeof ResizablePrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof ResizablePrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ResizablePrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ResizablePanelGroup.displayName = "ResizablePanelGroup"
|
||||
|
||||
const ResizablePanel = React.forwardRef<
|
||||
React.ElementRef<typeof ResizablePrimitive.Panel>,
|
||||
React.ComponentPropsWithoutRef<typeof ResizablePrimitive.Panel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ResizablePrimitive.Panel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-full w-full overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ResizablePanel.displayName = "ResizablePanel"
|
||||
|
||||
const ResizableHandle = ({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof ResizablePrimitive.Separator> & {
|
||||
withHandle?: boolean
|
||||
}) => (
|
||||
<ResizablePrimitive.Separator
|
||||
className={cn(
|
||||
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||
<GripVertical className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.Separator>
|
||||
)
|
||||
ResizableHandle.displayName = "ResizableHandle"
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||
@@ -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,35 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export interface PaneSizes {
|
||||
sidebar: number;
|
||||
editor: number;
|
||||
preview: number;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
sidebarVisible: boolean;
|
||||
previewVisible: boolean;
|
||||
zenMode: boolean;
|
||||
paneSizes: PaneSizes;
|
||||
toggleSidebar: () => void;
|
||||
togglePreview: () => void;
|
||||
setZenMode: (value: boolean) => void;
|
||||
setPaneSizes: (sizes: PaneSizes) => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
sidebarVisible: true,
|
||||
previewVisible: true,
|
||||
zenMode: false,
|
||||
paneSizes: { sidebar: 20, editor: 50, preview: 30 },
|
||||
toggleSidebar: () => set((s) => ({ sidebarVisible: !s.sidebarVisible })),
|
||||
togglePreview: () => set((s) => ({ previewVisible: !s.previewVisible })),
|
||||
setZenMode: (value) => set({ zenMode: value }),
|
||||
setPaneSizes: (sizes) => set({ paneSizes: sizes }),
|
||||
}),
|
||||
{ name: 'mc-app-store' }
|
||||
)
|
||||
);
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -10,3 +10,26 @@ declare global {
|
||||
if (typeof window !== 'undefined' && !window.electronAPI) {
|
||||
window.electronAPI = {};
|
||||
}
|
||||
|
||||
// Mock matchMedia for next-themes (jsdom doesn't have it)
|
||||
if (typeof window !== 'undefined') {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// Mock react-resizable-panels for jsdom environment
|
||||
if (typeof window !== 'undefined') {
|
||||
Object.defineProperty(window, 'innerWidth', { writable: true, value: 1920 });
|
||||
Object.defineProperty(window, 'innerHeight', { writable: true, value: 1080 });
|
||||
}
|
||||
|
||||
@@ -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,33 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ThemeProvider } from '@/components/theme-provider';
|
||||
import { AppHeader } from '@/components/layout/AppHeader';
|
||||
import { useAppStore } from '@/stores/app-store';
|
||||
|
||||
describe('AppHeader', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useAppStore.setState({ sidebarVisible: true, previewVisible: true, zenMode: false });
|
||||
});
|
||||
|
||||
it('renders the app title', () => {
|
||||
render(
|
||||
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||
<AppHeader />
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(screen.getByText(/markdownconverter/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles sidebar when sidebar button clicked', async () => {
|
||||
render(
|
||||
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||
<AppHeader />
|
||||
</ThemeProvider>
|
||||
);
|
||||
const btn = screen.getByRole('button', { name: /toggle sidebar/i });
|
||||
await userEvent.click(btn);
|
||||
expect(useAppStore.getState().sidebarVisible).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '@/components/theme-provider';
|
||||
import { AppShell } from '@/components/layout/AppShell';
|
||||
import { useAppStore } from '@/stores/app-store';
|
||||
|
||||
// Mock react-resizable-panels for jsdom environment
|
||||
vi.mock('@/components/ui/resizable', () => ({
|
||||
ResizablePanelGroup: ({ children, direction, onLayout }: any) => (
|
||||
<div data-testid="resizable-panel-group" data-direction={direction}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
ResizablePanel: ({ children, defaultSize, minSize, maxSize }: any) => (
|
||||
<div data-testid="resizable-panel" data-size={defaultSize} data-min={minSize} data-max={maxSize}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
ResizableHandle: () => <div data-testid="resizable-handle" />,
|
||||
}));
|
||||
|
||||
describe('AppShell', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useAppStore.setState({ sidebarVisible: true, previewVisible: true, zenMode: false });
|
||||
});
|
||||
|
||||
it('renders all shell surfaces when sidebar and preview are visible', () => {
|
||||
render(
|
||||
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||
<AppShell />
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(screen.getByText(/markdownconverter/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/no files open/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/no file selected/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/0 words/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides sidebar when sidebarVisible is false', () => {
|
||||
useAppStore.setState({ sidebarVisible: false });
|
||||
render(
|
||||
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||
<AppShell />
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(screen.queryByText(/file tree placeholder/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { StatusBar } from '@/components/layout/StatusBar';
|
||||
|
||||
describe('StatusBar', () => {
|
||||
it('renders zero word count when no file is open', () => {
|
||||
render(<StatusBar />);
|
||||
expect(screen.getByText(/0 words/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { TabBar } from '@/components/layout/TabBar';
|
||||
|
||||
describe('TabBar', () => {
|
||||
it('renders an empty state when no tabs are open', () => {
|
||||
render(<TabBar />);
|
||||
expect(screen.getByText(/no files open/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Toolbar } from '@/components/layout/Toolbar';
|
||||
|
||||
describe('Toolbar', () => {
|
||||
it('renders formatting buttons', () => {
|
||||
render(<Toolbar />);
|
||||
expect(screen.getByRole('button', { name: /bold/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /italic/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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,35 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useAppStore } from '@/stores/app-store';
|
||||
|
||||
describe('useAppStore', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useAppStore.setState({
|
||||
sidebarVisible: true,
|
||||
previewVisible: true,
|
||||
zenMode: false,
|
||||
paneSizes: { sidebar: 20, editor: 50, preview: 30 },
|
||||
});
|
||||
});
|
||||
|
||||
it('toggles sidebar visibility', () => {
|
||||
expect(useAppStore.getState().sidebarVisible).toBe(true);
|
||||
useAppStore.getState().toggleSidebar();
|
||||
expect(useAppStore.getState().sidebarVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('toggles preview visibility', () => {
|
||||
useAppStore.getState().togglePreview();
|
||||
expect(useAppStore.getState().previewVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('updates pane sizes', () => {
|
||||
useAppStore.getState().setPaneSizes({ sidebar: 25, editor: 50, preview: 25 });
|
||||
expect(useAppStore.getState().paneSizes).toEqual({ sidebar: 25, editor: 50, preview: 25 });
|
||||
});
|
||||
|
||||
it('enables and disables zen mode', () => {
|
||||
useAppStore.getState().setZenMode(true);
|
||||
expect(useAppStore.getState().zenMode).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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