mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 18:10:18 +05:30
- WelcomeDialog: add quick start, feature showcase, keyboard shortcuts, recent files, version display - ExportDocxDialog/ExportHtmlDialog/ExportPdfDialog: add header/footer, paper size, margin controls - GitStatusPanel: full staging/unstaging, discard, commit workflow - CommandPalette: fuzzy command search with keyboard navigation - FindReplaceBar: find/replace with regex, case, whole-word options - New dialogs: BatchMediaConverterDialog, HeaderFooterDialog, PdfEditorDialog, UniversalConverterDialog - Tests: comprehensive coverage for all new/updated components
219 lines
6.8 KiB
TypeScript
219 lines
6.8 KiB
TypeScript
import { useEffect, useRef, useState, useCallback } 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';
|
|
import { useSettingsStore } from '@/stores/settings-store';
|
|
import { setActiveView, insertSnippet } from '@/lib/editor-commands';
|
|
import { Minimap } from './Minimap';
|
|
import { toast } from '@/lib/toast';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface Props {
|
|
bufferId: string;
|
|
initialContent: string;
|
|
onChange?: (content: string) => void;
|
|
onCursorChange?: (line: number, column: number) => void;
|
|
}
|
|
|
|
const IMAGE_TYPES = new Set([
|
|
'image/png',
|
|
'image/jpeg',
|
|
'image/gif',
|
|
'image/webp',
|
|
'image/svg+xml',
|
|
'image/bmp',
|
|
'image/avif',
|
|
]);
|
|
|
|
function guessExt(mimeType: string): string {
|
|
const map: Record<string, string> = {
|
|
'image/png': 'png',
|
|
'image/jpeg': 'jpg',
|
|
'image/gif': 'gif',
|
|
'image/webp': 'webp',
|
|
'image/svg+xml': 'svg',
|
|
'image/bmp': 'bmp',
|
|
'image/avif': 'avif',
|
|
};
|
|
return map[mimeType] ?? 'png';
|
|
}
|
|
|
|
async function handleImageFile(file: File): Promise<void> {
|
|
const reader = new FileReader();
|
|
reader.onload = async () => {
|
|
const base64 = (reader.result as string).split(',')[1];
|
|
if (!base64) return;
|
|
const ext = guessExt(file.type);
|
|
const result = await window.electronAPI.savePastedImage({ base64, ext });
|
|
if (result) {
|
|
insertSnippet(``);
|
|
toast.success('Image pasted');
|
|
}
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
|
|
function createPasteHandler() {
|
|
return EditorView.domEventHandlers({
|
|
paste(event: ClipboardEvent, _view: EditorView) {
|
|
const items = event.clipboardData?.items;
|
|
if (!items) return false;
|
|
for (let i = 0; i < items.length; i++) {
|
|
const item = items[i];
|
|
if (IMAGE_TYPES.has(item.type)) {
|
|
event.preventDefault();
|
|
const file = item.getAsFile();
|
|
if (file) handleImageFile(file);
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
},
|
|
});
|
|
}
|
|
|
|
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);
|
|
const minimap = useSettingsStore((s) => s.minimap);
|
|
const editorFontSize = useSettingsStore((s) => s.editorFontSize);
|
|
const buffer = useEditorStore((s) => s.buffers.get(bufferId));
|
|
const content = buffer?.content ?? initialContent;
|
|
const [scrollRatio, setScrollRatio] = useState(0);
|
|
const [visibleRatio, setVisibleRatio] = useState(1);
|
|
const [isDragOver, setIsDragOver] = useState(false);
|
|
|
|
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);
|
|
}
|
|
}),
|
|
EditorView.theme({
|
|
'&': { fontSize: `${editorFontSize}px` },
|
|
}),
|
|
EditorView.domEventHandlers({
|
|
scroll(_event, view) {
|
|
const el = view.scrollDOM;
|
|
const denom = el.scrollHeight - el.clientHeight;
|
|
setScrollRatio(denom > 0 ? el.scrollTop / denom : 0);
|
|
setVisibleRatio(
|
|
el.clientHeight > 0 ? Math.min(1, el.clientHeight / el.scrollHeight) : 1
|
|
);
|
|
return false;
|
|
},
|
|
}),
|
|
createPasteHandler(),
|
|
],
|
|
});
|
|
const view = new EditorView({ state, parent: ref.current });
|
|
viewRef.current = view;
|
|
setActiveView(view);
|
|
return () => {
|
|
setActiveView(null);
|
|
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]);
|
|
|
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
if (e.dataTransfer.types.includes('Files')) {
|
|
e.preventDefault();
|
|
setIsDragOver(true);
|
|
}
|
|
}, []);
|
|
|
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setIsDragOver(false);
|
|
}, []);
|
|
|
|
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setIsDragOver(false);
|
|
const files = Array.from(e.dataTransfer.files);
|
|
const imageFiles = files.filter((f) => IMAGE_TYPES.has(f.type));
|
|
for (const file of imageFiles) {
|
|
await handleImageFile(file);
|
|
}
|
|
}, []);
|
|
|
|
return (
|
|
<div className="relative h-full overflow-hidden">
|
|
<div
|
|
ref={ref}
|
|
className={cn(
|
|
'h-full overflow-hidden transition-colors duration-150',
|
|
isDragOver && 'ring-2 ring-primary bg-primary/5'
|
|
)}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
/>
|
|
{minimap && (
|
|
<Minimap content={content} scrollRatio={scrollRatio} visibleRatio={visibleRatio} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|