mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 18:10:18 +05:30
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,25 @@
|
|||||||
|
import { CodeMirrorEditor } from './CodeMirrorEditor';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
|
||||||
|
export function EditorPane() {
|
||||||
|
const { buffers, activeId } = useEditorStore();
|
||||||
|
const buf = activeId ? buffers.get(activeId) : null;
|
||||||
|
|
||||||
|
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,7 @@ 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 { 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,9 +32,7 @@ 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 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,14 +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() {
|
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,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,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,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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user