502 lines
18 KiB
TypeScript
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: '' } },
|
|
{ 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>
|
|
)
|
|
}
|