diff --git a/src/components/MarkdownEditor.tsx b/src/components/MarkdownEditor.tsx new file mode 100644 index 0000000..3d7c77c --- /dev/null +++ b/src/components/MarkdownEditor.tsx @@ -0,0 +1,216 @@ +import { useRef, useState, type KeyboardEvent } from 'react'; +import { + BoldIcon, + CodeIcon, + EyeIcon, + HeadingIcon, + ItalicIcon, + LinkIcon, + ListIcon, + ListOrderedIcon, + PencilIcon, + QuoteIcon, + StrikethroughIcon, +} from 'lucide-react'; +import MarkdownContent from './MarkdownContent'; + +interface MarkdownEditorProps { + readonly value: string; + readonly onChange: (value: string) => void; + readonly placeholder?: string; + readonly rows?: number; + readonly disabled?: boolean; +} + +type WrapAction = 'bold' | 'italic' | 'strike' | 'code' | 'link'; +type LineAction = 'h1' | 'h2' | 'h3' | 'ul' | 'ol' | 'quote'; + +const WRAP_SYNTAX: Record = { + bold: { prefix: '**', suffix: '**', placeholder: 'bold text' }, + italic: { prefix: '*', suffix: '*', placeholder: 'italic text' }, + strike: { prefix: '~~', suffix: '~~', placeholder: 'strikethrough' }, + code: { prefix: '`', suffix: '`', placeholder: 'code' }, + link: { prefix: '[', suffix: '](https://)', placeholder: 'link text' }, +}; + +const LINE_PREFIX: Record = { + h1: '# ', + h2: '## ', + h3: '### ', + ul: '- ', + ol: '1. ', + quote: '> ', +}; + +export default function MarkdownEditor({ + value, + onChange, + placeholder = '# Hello world', + rows = 12, + disabled = false, +}: MarkdownEditorProps) { + const textareaRef = useRef(null); + const [mode, setMode] = useState<'write' | 'preview'>('write'); + + function applyWrap(action: WrapAction) { + const textarea = textareaRef.current; + if (!textarea || disabled) return; + + const { prefix, suffix, placeholder: ph } = WRAP_SYNTAX[action]; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const selected = value.slice(start, end); + const inner = selected || ph; + const next = value.slice(0, start) + prefix + inner + suffix + value.slice(end); + + onChange(next); + requestAnimationFrame(() => { + textarea.focus(); + const selStart = start + prefix.length; + const selEnd = selStart + inner.length; + textarea.setSelectionRange(selStart, selEnd); + }); + } + + function applyLine(action: LineAction) { + const textarea = textareaRef.current; + if (!textarea || disabled) return; + + const prefix = LINE_PREFIX[action]; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const lineStart = value.lastIndexOf('\n', start - 1) + 1; + const lineEnd = value.indexOf('\n', end); + const actualLineEnd = lineEnd === -1 ? value.length : lineEnd; + const lineBlock = value.slice(lineStart, actualLineEnd); + + const lines = lineBlock.split('\n'); + const isFirst = (i: number) => action === 'ol' && i > 0; + const transformed = lines + .map((line, i) => (isFirst(i) ? `${i + 1}. ` : prefix) + line) + .join('\n'); + + const next = value.slice(0, lineStart) + transformed + value.slice(actualLineEnd); + onChange(next); + requestAnimationFrame(() => { + textarea.focus(); + textarea.setSelectionRange(lineStart, lineStart + transformed.length); + }); + } + + function handleKeyDown(event: KeyboardEvent) { + if (disabled) return; + const mod = event.metaKey || event.ctrlKey; + if (!mod) return; + + const key = event.key.toLowerCase(); + const map: Record void> = { + b: () => applyWrap('bold'), + i: () => applyWrap('italic'), + k: () => applyWrap('code'), + }; + if (map[key]) { + event.preventDefault(); + map[key](); + } + } + + return ( +
+
+
+ applyLine('h1')}> + + applyWrap('bold')}> + + applyWrap('italic')}> + + applyWrap('strike')}> + +
+
+
+ applyWrap('link')}> + + applyWrap('code')}> + + applyLine('ul')}> + + applyLine('ol')}> + + applyLine('quote')}> + +
+
+
+ setMode('write')}> + + setMode('preview')}> + +
+
+ + {mode === 'write' ? ( +