Skip to content

Commit 09a98d2

Browse files
feat: interactive task list checkboxes in markdown preview
1 parent ac3cfc3 commit 09a98d2

2 files changed

Lines changed: 83 additions & 6 deletions

File tree

frontend/src/components/file-browser/FilePreview.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false,
3232
const [isLoadingAllContent, setIsLoadingAllContent] = useState(false)
3333
const [fullContentLoaded, setFullContentLoaded] = useState(false)
3434
const [fullContent, setFullContent] = useState<string | null>(null)
35+
const [localMdContent, setLocalMdContent] = useState<string | null>(null)
3536
const virtualizedRef = useRef<VirtualizedTextViewHandle>(null)
3637
const contentRef = useRef<HTMLDivElement>(null)
3738

@@ -42,6 +43,7 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false,
4243
setFullContentLoaded(false)
4344
setMarkdownPreview(isMarkdownFile)
4445
setFullContent(null)
46+
setLocalMdContent(null)
4547
}, [file.path, isMarkdownFile])
4648

4749
useEffect(() => {
@@ -122,7 +124,8 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false,
122124
}
123125

124126
try {
125-
const content = file.content ? decodeBase64(file.content) : ''
127+
const textContent = file.content ? decodeBase64(file.content) : ''
128+
const content = localMdContent ?? textContent
126129
setEditContent(content)
127130
setViewMode('edit')
128131
const event = new CustomEvent('editModeChange', { detail: { isEditing: true } })
@@ -228,7 +231,7 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false,
228231
<div className="w-6 h-6 border-2 border-muted-foreground border-t-transparent rounded-full animate-spin" />
229232
</div>
230233
) : fullContent ? (
231-
<MarkdownRenderer content={fullContent} />
234+
<MarkdownRenderer content={fullContent} onContentChange={setFullContent} />
232235
) : null}
233236
</>
234237
)}
@@ -266,7 +269,8 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false,
266269
}
267270

268271
if (isMarkdownFile && markdownPreview) {
269-
return <MarkdownRenderer content={textContent} />
272+
const mdContent = localMdContent ?? textContent
273+
return <MarkdownRenderer content={mdContent} onContentChange={setLocalMdContent} />
270274
}
271275

272276
const lines = textContent.split('\n')

frontend/src/components/file-browser/MarkdownRenderer.tsx

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,95 @@
1-
import { memo } from 'react'
1+
import { memo, useMemo, useCallback, useRef, useEffect, Children, isValidElement } from 'react'
22
import ReactMarkdown from 'react-markdown'
33
import remarkGfm from 'remark-gfm'
44
import rehypeHighlight from 'rehype-highlight'
55
import rehypeRaw from 'rehype-raw'
66
import { markdownComponents } from './MarkdownComponents'
7+
import type { Components } from 'react-markdown'
78

89
interface MarkdownRendererProps {
910
content: string
1011
className?: string
12+
onContentChange?: (newContent: string) => void
1113
}
1214

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+
1487
return (
1588
<div className={`pb-[200px] p-4 prose prose-invert prose-enhanced max-w-none text-foreground overflow-hidden break-words leading-snug ${className}`}>
1689
<ReactMarkdown
1790
remarkPlugins={[remarkGfm]}
1891
rehypePlugins={[rehypeHighlight, rehypeRaw]}
19-
components={markdownComponents}
92+
components={components}
2093
>
2194
{content}
2295
</ReactMarkdown>

0 commit comments

Comments
 (0)