|
1 | | -import { memo } from 'react' |
| 1 | +import { memo, useMemo, useCallback, useRef, useEffect, Children, isValidElement } from 'react' |
2 | 2 | import ReactMarkdown from 'react-markdown' |
3 | 3 | import remarkGfm from 'remark-gfm' |
4 | 4 | import rehypeHighlight from 'rehype-highlight' |
5 | 5 | import rehypeRaw from 'rehype-raw' |
6 | 6 | import { markdownComponents } from './MarkdownComponents' |
| 7 | +import type { Components } from 'react-markdown' |
7 | 8 |
|
8 | 9 | interface MarkdownRendererProps { |
9 | 10 | content: string |
10 | 11 | className?: string |
| 12 | + onContentChange?: (newContent: string) => void |
11 | 13 | } |
12 | 14 |
|
13 | | -export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className = '' }: MarkdownRendererProps) { |
| 15 | +interface TaskItem { |
| 16 | + lineIndex: number |
| 17 | + checked: boolean |
| 18 | +} |
| 19 | + |
| 20 | +function parseTaskItems(content: string): TaskItem[] { |
| 21 | + const items: TaskItem[] = [] |
| 22 | + const lines = content.split('\n') |
| 23 | + for (let i = 0; i < lines.length; i++) { |
| 24 | + const match = lines[i].match(/^(\s*[-*+]\s+)\[([ xX])\]/i) |
| 25 | + if (match) { |
| 26 | + items.push({ lineIndex: i, checked: match[2].toLowerCase() === 'x' }) |
| 27 | + } |
| 28 | + } |
| 29 | + return items |
| 30 | +} |
| 31 | + |
| 32 | +export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className = '', onContentChange }: MarkdownRendererProps) { |
| 33 | + const taskItems = useMemo(() => parseTaskItems(content), [content]) |
| 34 | + const renderIndexRef = useRef(0) |
| 35 | + |
| 36 | + useEffect(() => { |
| 37 | + renderIndexRef.current = 0 |
| 38 | + }, [content]) |
| 39 | + |
| 40 | + const handleToggle = useCallback((taskItem: TaskItem) => { |
| 41 | + if (!onContentChange) return |
| 42 | + const lines = content.split('\n') |
| 43 | + const line = lines[taskItem.lineIndex] |
| 44 | + const newLine = line.replace( |
| 45 | + /^(\s*[-*+]\s+)\[([ xX])\]/i, |
| 46 | + (_, prefix: string, checked: string) => `${prefix}[${checked.toLowerCase() === 'x' ? ' ' : 'x'}]`, |
| 47 | + ) |
| 48 | + if (newLine !== line) { |
| 49 | + lines[taskItem.lineIndex] = newLine |
| 50 | + onContentChange(lines.join('\n')) |
| 51 | + } |
| 52 | + }, [content, onContentChange]) |
| 53 | + |
| 54 | + const components: Components = { |
| 55 | + ...markdownComponents, |
| 56 | + li(props) { |
| 57 | + const { children, ...rest } = props |
| 58 | + const maybeChecked = (rest as Record<string, unknown>).checked as boolean | null | undefined |
| 59 | + |
| 60 | + if (maybeChecked !== null && maybeChecked !== undefined) { |
| 61 | + const currentIndex = renderIndexRef.current++ |
| 62 | + const taskItem = taskItems[currentIndex] |
| 63 | + |
| 64 | + if (taskItem) { |
| 65 | + const filteredChildren = Children.toArray(children).filter( |
| 66 | + child => !(isValidElement(child) && child.type === 'input'), |
| 67 | + ) |
| 68 | + |
| 69 | + return ( |
| 70 | + <li className="task-list-item flex items-start gap-1.5 my-0.5 md:my-1 list-none"> |
| 71 | + <input |
| 72 | + type="checkbox" |
| 73 | + checked={taskItem.checked} |
| 74 | + onChange={() => handleToggle(taskItem)} |
| 75 | + className="mt-[3px] cursor-pointer accent-primary shrink-0" |
| 76 | + /> |
| 77 | + <span className="flex-1 min-w-0">{filteredChildren}</span> |
| 78 | + </li> |
| 79 | + ) |
| 80 | + } |
| 81 | + } |
| 82 | + |
| 83 | + return <li className="text-foreground my-0.5 md:my-1" {...rest}>{children}</li> |
| 84 | + }, |
| 85 | + } |
| 86 | + |
14 | 87 | return ( |
15 | 88 | <div className={`pb-[200px] p-4 prose prose-invert prose-enhanced max-w-none text-foreground overflow-hidden break-words leading-snug ${className}`}> |
16 | 89 | <ReactMarkdown |
17 | 90 | remarkPlugins={[remarkGfm]} |
18 | 91 | rehypePlugins={[rehypeHighlight, rehypeRaw]} |
19 | | - components={markdownComponents} |
| 92 | + components={components} |
20 | 93 | > |
21 | 94 | {content} |
22 | 95 | </ReactMarkdown> |
|
0 commit comments