Files
echoboard/packages/web/src/components/MarkdownEditor.tsx

502 lines
18 KiB
TypeScript

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<HTMLDivElement>(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 (
<div
ref={popRef}
className="absolute z-50"
style={{
top: '100%',
left: 0,
marginTop: 4,
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
boxShadow: 'var(--shadow-lg)',
padding: 8,
}}
>
<div
style={{
display: 'grid',
gridTemplateColumns: `repeat(${GRID_COLS}, ${CELL}px)`,
gap: GAP,
}}
>
{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 (
<button
key={i}
type="button"
aria-label={`${col + 1} by ${row + 1} table`}
onMouseEnter={() => setHover([col + 1, row + 1])}
onFocus={() => setHover([col + 1, row + 1])}
onClick={() => { onSelect(col + 1, row + 1); onClose() }}
style={{
width: CELL,
height: CELL,
borderRadius: 3,
border: active ? '1px solid var(--accent)' : '1px solid var(--border)',
background: active ? 'var(--accent-subtle)' : 'transparent',
cursor: 'pointer',
padding: 0,
transition: 'background 60ms ease-out, border-color 60ms ease-out',
}}
/>
)
})}
</div>
<div
className="text-center mt-1.5"
style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}
>
{hover[0] > 0 && hover[1] > 0 ? `${hover[0]} x ${hover[1]}` : 'Select size'}
</div>
</div>
)
}
export default function MarkdownEditor({ value, onChange, placeholder, rows = 3, autoFocus, preview: enablePreview, ariaRequired, ariaLabel, mentions: enableMentions, onPaste }: Props) {
const ref = useRef<HTMLTextAreaElement>(null)
const [previewing, setPreviewing] = useState(false)
const [tablePicker, setTablePicker] = useState(false)
const [mentionUsers, setMentionUsers] = useState<MentionUser[]>([])
const [mentionActive, setMentionActive] = useState(0)
const mentionDebounce = useRef<ReturnType<typeof setTimeout>>()
const mentionDropdownRef = useRef<HTMLDivElement>(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<HTMLButtonElement> | React.FocusEvent<HTMLButtonElement>, enter: boolean) => {
e.currentTarget.style.color = enter ? 'var(--text)' : 'var(--text-tertiary)'
e.currentTarget.style.background = enter ? 'var(--surface-hover)' : 'transparent'
}
return (
<div>
<div
className="flex items-center gap-0.5 mb-1.5 px-1 flex-wrap"
style={{ minHeight: 28 }}
>
{toolbar.map((group, gi) => (
<div key={gi} className="flex items-center gap-0.5">
{gi > 0 && (
<div
style={{
width: 1,
height: 16,
background: 'var(--border)',
margin: '0 4px',
flexShrink: 0,
}}
/>
)}
{group.map((entry) => {
if (entry === 'table') {
return (
<div key="table" className="relative">
<button
type="button"
title="Table"
aria-label="Table"
onClick={() => { setPreviewing(false); setTablePicker(!tablePicker) }}
className="w-11 h-11 flex items-center justify-center"
style={{
...btnStyle,
color: tablePicker ? 'var(--accent)' : 'var(--text-tertiary)',
}}
onMouseEnter={(e) => hover(e, true)}
onMouseLeave={(e) => {
e.currentTarget.style.color = tablePicker ? 'var(--accent)' : 'var(--text-tertiary)'
e.currentTarget.style.background = 'transparent'
}}
onFocus={(e) => hover(e, true)}
onBlur={(e) => {
e.currentTarget.style.color = tablePicker ? 'var(--accent)' : 'var(--text-tertiary)'
e.currentTarget.style.background = 'transparent'
}}
>
<IconTable size={14} stroke={2} />
</button>
{tablePicker && (
<TablePicker
onSelect={(cols, rows) => {
setPreviewing(false)
insertBlock(buildTable(cols, rows))
}}
onClose={() => setTablePicker(false)}
/>
)}
</div>
)
}
const { icon: Icon, title, action } = entry
return (
<button
key={title}
type="button"
title={title}
aria-label={title}
onClick={() => { setPreviewing(false); apply(action) }}
className="w-11 h-11 flex items-center justify-center"
style={btnStyle}
onMouseEnter={(e) => hover(e, true)}
onMouseLeave={(e) => hover(e, false)}
onFocus={(e) => hover(e, true)}
onBlur={(e) => hover(e, false)}
>
<Icon size={14} stroke={2} />
</button>
)
})}
</div>
))}
{enablePreview && (
<>
<div
style={{
width: 1,
height: 16,
background: 'var(--border)',
margin: '0 4px',
flexShrink: 0,
}}
/>
<button
type="button"
title={previewing ? 'Edit' : 'Preview'}
aria-label={previewing ? 'Edit' : 'Preview'}
onClick={() => setPreviewing(!previewing)}
className="w-11 h-11 flex items-center justify-center"
style={{
...btnStyle,
color: previewing ? 'var(--accent)' : 'var(--text-tertiary)',
}}
onMouseEnter={(e) => hover(e, true)}
onMouseLeave={(e) => {
e.currentTarget.style.color = previewing ? 'var(--accent)' : 'var(--text-tertiary)'
e.currentTarget.style.background = 'transparent'
}}
onFocus={(e) => hover(e, true)}
onBlur={(e) => {
e.currentTarget.style.color = previewing ? 'var(--accent)' : 'var(--text-tertiary)'
e.currentTarget.style.background = 'transparent'
}}
>
{previewing ? <IconPencil size={14} stroke={2} /> : <IconEye size={14} stroke={2} />}
</button>
</>
)}
</div>
{previewing ? (
<div
className="input w-full"
style={{
minHeight: rows * 24 + 24,
padding: '12px 14px',
overflow: 'auto',
resize: 'vertical',
}}
>
{value.trim() ? (
<Markdown>{value}</Markdown>
) : (
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>
Nothing to preview
</span>
)}
</div>
) : (
<div style={{ position: 'relative' }}>
<textarea
ref={ref}
className="input w-full"
placeholder={placeholder}
rows={rows}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={enableMentions && mentionUsers.length > 0 ? (e) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setMentionActive((a) => (a + 1) % mentionUsers.length) }
else if (e.key === 'ArrowUp') { e.preventDefault(); setMentionActive((a) => (a - 1 + mentionUsers.length) % mentionUsers.length) }
else if ((e.key === 'Enter' || e.key === 'Tab') && mentionUsers[mentionActive]) { e.preventDefault(); insertMention(mentionUsers[mentionActive].username) }
else if (e.key === 'Escape') { setMentionUsers([]); setMentionQuery('') }
} : undefined}
onPaste={onPaste}
style={{ resize: 'vertical' }}
autoFocus={autoFocus}
aria-label={ariaLabel || 'Markdown content'}
aria-required={ariaRequired || undefined}
/>
{enableMentions && mentionUsers.length > 0 && (
<div
ref={mentionDropdownRef}
role="listbox"
aria-label="Mention suggestions"
style={{
position: 'absolute',
left: 0,
bottom: -4,
transform: 'translateY(100%)',
zIndex: 50,
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
minWidth: 200,
maxHeight: 240,
overflow: 'auto',
}}
>
{mentionUsers.map((u, i) => (
<button
key={u.id}
role="option"
aria-selected={i === mentionActive}
onClick={() => insertMention(u.username)}
className="flex items-center gap-2 w-full px-3"
style={{
minHeight: 44,
background: i === mentionActive ? 'var(--surface-hover)' : 'transparent',
border: 'none',
cursor: 'pointer',
fontSize: 'var(--text-sm)',
color: 'var(--text)',
textAlign: 'left',
}}
onMouseEnter={() => setMentionActive(i)}
>
<Avatar userId={u.id} name={u.username} avatarUrl={u.avatarUrl} size={22} />
<span>@{u.username}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
)
}