import { useState, useRef, useEffect, useCallback } from 'react' import { IconBold, IconItalic, IconStrikethrough, IconLink, IconCode, IconList, IconListNumbers, IconQuote, IconPhoto, IconTable, IconH1, IconH2, IconH3, IconMinus, IconCheckbox, IconSourceCode, IconEye, IconPencil, } from '@tabler/icons-react' import Markdown from './Markdown' import Avatar from './Avatar' import { api } from '../lib/api' interface MentionUser { id: string username: string avatarUrl: string | null } interface Props { value: string onChange: (value: string) => void placeholder?: string rows?: number autoFocus?: boolean preview?: boolean ariaRequired?: boolean ariaLabel?: string mentions?: boolean onPaste?: () => void } type Action = | { prefix: string; suffix: string } | { linePrefix: string } | { block: string } interface ToolbarItem { icon: typeof IconBold title: string action: Action } type ToolbarEntry = ToolbarItem | 'table' type ToolbarGroup = ToolbarEntry[] const toolbar: ToolbarGroup[] = [ [ { icon: IconH1, title: 'Heading 1', action: { linePrefix: '# ' } }, { icon: IconH2, title: 'Heading 2', action: { linePrefix: '## ' } }, { icon: IconH3, title: 'Heading 3', action: { linePrefix: '### ' } }, ], [ { icon: IconBold, title: 'Bold', action: { prefix: '**', suffix: '**' } }, { icon: IconItalic, title: 'Italic', action: { prefix: '*', suffix: '*' } }, { icon: IconStrikethrough, title: 'Strikethrough', action: { prefix: '~~', suffix: '~~' } }, ], [ { icon: IconCode, title: 'Inline code', action: { prefix: '`', suffix: '`' } }, { icon: IconSourceCode, title: 'Code block', action: { block: '```\n\n```' } }, { icon: IconQuote, title: 'Blockquote', action: { linePrefix: '> ' } }, ], [ { icon: IconList, title: 'Bullet list', action: { linePrefix: '- ' } }, { icon: IconListNumbers, title: 'Numbered list', action: { linePrefix: '1. ' } }, { icon: IconCheckbox, title: 'Task list', action: { linePrefix: '- [ ] ' } }, ], [ { icon: IconLink, title: 'Link', action: { prefix: '[', suffix: '](url)' } }, { icon: IconPhoto, title: 'Image', action: { prefix: '![', suffix: '](url)' } }, { icon: IconMinus, title: 'Horizontal rule', action: { block: '---' } }, 'table', ], ] const GRID_COLS = 8 const GRID_ROWS = 6 const CELL = 44 const GAP = 1 function buildTable(cols: number, rows: number): string { const header = '| ' + Array.from({ length: cols }, (_, i) => `Column ${i + 1}`).join(' | ') + ' |' const sep = '| ' + Array.from({ length: cols }, () => '---').join(' | ') + ' |' const dataRows = Array.from({ length: rows }, () => '| ' + Array.from({ length: cols }, () => ' ').join(' | ') + ' |' ) return [header, sep, ...dataRows].join('\n') } function TablePicker({ onSelect, onClose }: { onSelect: (cols: number, rows: number) => void; onClose: () => void }) { const [hover, setHover] = useState<[number, number]>([0, 0]) const popRef = useRef(null) useEffect(() => { const handler = (e: MouseEvent) => { if (popRef.current && !popRef.current.contains(e.target as Node)) onClose() } document.addEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler) }, [onClose]) return (
{Array.from({ length: GRID_ROWS * GRID_COLS }, (_, i) => { const col = i % GRID_COLS const row = Math.floor(i / GRID_COLS) const active = col < hover[0] && row < hover[1] return (
{hover[0] > 0 && hover[1] > 0 ? `${hover[0]} x ${hover[1]}` : 'Select size'}
) } export default function MarkdownEditor({ value, onChange, placeholder, rows = 3, autoFocus, preview: enablePreview, ariaRequired, ariaLabel, mentions: enableMentions, onPaste }: Props) { const ref = useRef(null) const [previewing, setPreviewing] = useState(false) const [tablePicker, setTablePicker] = useState(false) const [mentionUsers, setMentionUsers] = useState([]) const [mentionActive, setMentionActive] = useState(0) const mentionDebounce = useRef>() const mentionDropdownRef = useRef(null) const [mentionQuery, setMentionQuery] = useState('') const getMentionQuery = useCallback((): string | null => { const ta = ref.current if (!ta || !enableMentions) return null const pos = ta.selectionStart const before = value.slice(0, pos) const match = before.match(/@([a-zA-Z0-9_]{2,30})$/) return match ? match[1] : null }, [value, enableMentions]) useEffect(() => { if (!enableMentions) return const q = getMentionQuery() if (!q || q.length < 2) { setMentionUsers([]) setMentionQuery('') return } if (q === mentionQuery) return setMentionQuery(q) if (mentionDebounce.current) clearTimeout(mentionDebounce.current) mentionDebounce.current = setTimeout(() => { api.get<{ users: MentionUser[] }>(`/users/search?q=${encodeURIComponent(q)}`) .then((r) => { setMentionUsers(r.users); setMentionActive(0) }) .catch(() => setMentionUsers([])) }, 200) return () => { if (mentionDebounce.current) clearTimeout(mentionDebounce.current) } }, [value, getMentionQuery, enableMentions]) const insertMention = (username: string) => { const ta = ref.current if (!ta) return const pos = ta.selectionStart const before = value.slice(0, pos) const after = value.slice(pos) const atIdx = before.lastIndexOf('@') if (atIdx === -1) return const newVal = before.slice(0, atIdx) + '@' + username + ' ' + after onChange(newVal) setMentionUsers([]) setMentionQuery('') const newPos = atIdx + username.length + 2 requestAnimationFrame(() => { ta.focus(); ta.setSelectionRange(newPos, newPos) }) } useEffect(() => { if (!enableMentions) return const handler = (e: MouseEvent) => { if (mentionDropdownRef.current && !mentionDropdownRef.current.contains(e.target as Node)) { setMentionUsers([]) } } document.addEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler) }, [enableMentions]) const insertBlock = (block: string) => { const ta = ref.current if (!ta) return const start = ta.selectionStart const end = ta.selectionEnd const before = value.slice(0, start) const after = value.slice(end) const needsBefore = before.length > 0 && !before.endsWith('\n\n') const needsAfter = after.length > 0 && !after.startsWith('\n') const prefix = needsBefore ? (before.endsWith('\n') ? '\n' : '\n\n') : '' const suffix = needsAfter ? '\n' : '' const newValue = before + prefix + block + suffix + after const firstNewline = block.indexOf('\n') const blockStart = before.length + prefix.length const cursorPos = firstNewline > -1 ? blockStart + firstNewline + 1 : blockStart + block.length onChange(newValue) requestAnimationFrame(() => { ta.focus() ta.setSelectionRange(cursorPos, cursorPos) }) } const apply = (action: Action) => { const ta = ref.current if (!ta) return const start = ta.selectionStart const end = ta.selectionEnd const selected = value.slice(start, end) let newValue: string let cursorPos: number if ('block' in action) { insertBlock(action.block) return } else if ('linePrefix' in action) { const lines = selected ? selected.split('\n') : [''] const prefixed = lines.map((l) => action.linePrefix + l).join('\n') newValue = value.slice(0, start) + prefixed + value.slice(end) cursorPos = start + prefixed.length } else { const wrapped = action.prefix + (selected || 'text') + action.suffix newValue = value.slice(0, start) + wrapped + value.slice(end) if (selected) { cursorPos = start + wrapped.length } else { cursorPos = start + action.prefix.length + 4 } } onChange(newValue) requestAnimationFrame(() => { ta.focus() if (!selected && 'prefix' in action && 'suffix' in action) { ta.setSelectionRange(start + action.prefix.length, start + action.prefix.length + 4) } else { ta.setSelectionRange(cursorPos, cursorPos) } }) } const btnStyle = { color: 'var(--text-tertiary)', borderRadius: 'var(--radius-sm)', transition: 'color var(--duration-fast) ease-out, background var(--duration-fast) ease-out', } const hover = (e: React.MouseEvent | React.FocusEvent, enter: boolean) => { e.currentTarget.style.color = enter ? 'var(--text)' : 'var(--text-tertiary)' e.currentTarget.style.background = enter ? 'var(--surface-hover)' : 'transparent' } return (
{toolbar.map((group, gi) => (
{gi > 0 && (
)} {group.map((entry) => { if (entry === 'table') { return (
{tablePicker && ( { setPreviewing(false) insertBlock(buildTable(cols, rows)) }} onClose={() => setTablePicker(false)} /> )}
) } const { icon: Icon, title, action } = entry return ( ) })}
))} {enablePreview && ( <>
)}
{previewing ? (
{value.trim() ? ( {value} ) : ( Nothing to preview )}
) : (