Skip to content

Commit 7b0ce80

Browse files
authored
feat(files): interactive markdown checkbox toggling in preview (#3829)
* feat(files): interactive markdown checkbox toggling in preview * fix(files): handle ordered-list checkboxes and fix index drift * lint * fix(files): remove counter offset that prevented checkbox toggling * fix(files): apply task-list styling to ordered lists too * fix(files): render single pass when interactive to avoid index drift * fix(files): move useMemo above conditional return to fix Rules of Hooks * fix(files): pass content directly to preview when not streaming to avoid stale frame
1 parent 0ea7326 commit 7b0ce80

File tree

2 files changed

+119
-18
lines changed

2 files changed

+119
-18
lines changed

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,16 @@ function TextEditor({
290290
}
291291
}, [isResizing])
292292

293+
const handleCheckboxToggle = useCallback(
294+
(checkboxIndex: number, checked: boolean) => {
295+
const toggled = toggleMarkdownCheckbox(contentRef.current, checkboxIndex, checked)
296+
if (toggled !== contentRef.current) {
297+
handleContentChange(toggled)
298+
}
299+
},
300+
[handleContentChange]
301+
)
302+
293303
const isStreaming = streamingContent !== undefined
294304
const revealedContent = useStreamingText(content, isStreaming)
295305

@@ -392,10 +402,11 @@ function TextEditor({
392402
className={cn('min-w-0 flex-1 overflow-hidden', isResizing && 'pointer-events-none')}
393403
>
394404
<PreviewPanel
395-
content={revealedContent}
405+
content={isStreaming ? revealedContent : content}
396406
mimeType={file.type}
397407
filename={file.name}
398408
isStreaming={isStreaming}
409+
onCheckboxToggle={canEdit && !isStreaming ? handleCheckboxToggle : undefined}
399410
/>
400411
</div>
401412
</>
@@ -703,6 +714,14 @@ function PptxPreview({
703714
)
704715
}
705716

717+
function toggleMarkdownCheckbox(markdown: string, targetIndex: number, checked: boolean): string {
718+
let currentIndex = 0
719+
return markdown.replace(/^(\s*(?:[-*+]|\d+[.)]) +)\[([ xX])\]/gm, (match, prefix: string) => {
720+
if (currentIndex++ !== targetIndex) return match
721+
return `${prefix}[${checked ? 'x' : ' '}]`
722+
})
723+
}
724+
706725
const UnsupportedPreview = memo(function UnsupportedPreview({
707726
file,
708727
}: {

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx

Lines changed: 99 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
'use client'
22

3-
import { memo, useMemo } from 'react'
3+
import { memo, useMemo, useRef } from 'react'
44
import ReactMarkdown from 'react-markdown'
55
import remarkBreaks from 'remark-breaks'
66
import remarkGfm from 'remark-gfm'
7+
import { Checkbox } from '@/components/emcn'
78
import { cn } from '@/lib/core/utils/cn'
89
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
910
import { useAutoScroll } from '@/hooks/use-auto-scroll'
@@ -40,18 +41,26 @@ interface PreviewPanelProps {
4041
mimeType: string | null
4142
filename: string
4243
isStreaming?: boolean
44+
onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void
4345
}
4446

4547
export const PreviewPanel = memo(function PreviewPanel({
4648
content,
4749
mimeType,
4850
filename,
4951
isStreaming,
52+
onCheckboxToggle,
5053
}: PreviewPanelProps) {
5154
const previewType = resolvePreviewType(mimeType, filename)
5255

5356
if (previewType === 'markdown')
54-
return <MarkdownPreview content={content} isStreaming={isStreaming} />
57+
return (
58+
<MarkdownPreview
59+
content={content}
60+
isStreaming={isStreaming}
61+
onCheckboxToggle={onCheckboxToggle}
62+
/>
63+
)
5564
if (previewType === 'html') return <HtmlPreview content={content} />
5665
if (previewType === 'csv') return <CsvPreview content={content} />
5766
if (previewType === 'svg') return <SvgPreview content={content} />
@@ -61,7 +70,7 @@ export const PreviewPanel = memo(function PreviewPanel({
6170

6271
const REMARK_PLUGINS = [remarkGfm, remarkBreaks]
6372

64-
const PREVIEW_MARKDOWN_COMPONENTS = {
73+
const STATIC_MARKDOWN_COMPONENTS = {
6574
p: ({ children }: any) => (
6675
<p className='mb-3 break-words text-[14px] text-[var(--text-primary)] leading-[1.6] last:mb-0'>
6776
{children}
@@ -87,17 +96,6 @@ const PREVIEW_MARKDOWN_COMPONENTS = {
8796
{children}
8897
</h4>
8998
),
90-
ul: ({ children }: any) => (
91-
<ul className='mt-1 mb-3 list-disc space-y-1 break-words pl-6 text-[14px] text-[var(--text-primary)]'>
92-
{children}
93-
</ul>
94-
),
95-
ol: ({ children }: any) => (
96-
<ol className='mt-1 mb-3 list-decimal space-y-1 break-words pl-6 text-[14px] text-[var(--text-primary)]'>
97-
{children}
98-
</ol>
99-
),
100-
li: ({ children }: any) => <li className='break-words leading-[1.6]'>{children}</li>,
10199
code: ({ inline, className, children, ...props }: any) => {
102100
const isInline = inline || !className?.includes('language-')
103101

@@ -165,26 +163,110 @@ const PREVIEW_MARKDOWN_COMPONENTS = {
165163
td: ({ children }: any) => <td className='px-3 py-2 text-[var(--text-secondary)]'>{children}</td>,
166164
}
167165

166+
function buildMarkdownComponents(
167+
checkboxCounterRef: React.MutableRefObject<number>,
168+
onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void
169+
) {
170+
const isInteractive = Boolean(onCheckboxToggle)
171+
172+
return {
173+
...STATIC_MARKDOWN_COMPONENTS,
174+
ul: ({ className, children }: any) => {
175+
const isTaskList = typeof className === 'string' && className.includes('contains-task-list')
176+
return (
177+
<ul
178+
className={cn(
179+
'mt-1 mb-3 space-y-1 break-words text-[14px] text-[var(--text-primary)]',
180+
isTaskList ? 'list-none pl-0' : 'list-disc pl-6'
181+
)}
182+
>
183+
{children}
184+
</ul>
185+
)
186+
},
187+
ol: ({ className, children }: any) => {
188+
const isTaskList = typeof className === 'string' && className.includes('contains-task-list')
189+
return (
190+
<ol
191+
className={cn(
192+
'mt-1 mb-3 space-y-1 break-words text-[14px] text-[var(--text-primary)]',
193+
isTaskList ? 'list-none pl-0' : 'list-decimal pl-6'
194+
)}
195+
>
196+
{children}
197+
</ol>
198+
)
199+
},
200+
li: ({ className, children }: any) => {
201+
const isTaskItem = typeof className === 'string' && className.includes('task-list-item')
202+
if (isTaskItem) {
203+
return <li className='flex items-start gap-2 break-words leading-[1.6]'>{children}</li>
204+
}
205+
return <li className='break-words leading-[1.6]'>{children}</li>
206+
},
207+
input: ({ type, checked, ...props }: any) => {
208+
if (type !== 'checkbox') return <input type={type} checked={checked} {...props} />
209+
210+
const index = checkboxCounterRef.current++
211+
212+
return (
213+
<Checkbox
214+
checked={checked ?? false}
215+
onCheckedChange={
216+
isInteractive
217+
? (newChecked) => onCheckboxToggle!(index, Boolean(newChecked))
218+
: undefined
219+
}
220+
disabled={!isInteractive}
221+
size='sm'
222+
className='mt-1 shrink-0'
223+
/>
224+
)
225+
},
226+
}
227+
}
228+
168229
const MarkdownPreview = memo(function MarkdownPreview({
169230
content,
170231
isStreaming = false,
232+
onCheckboxToggle,
171233
}: {
172234
content: string
173235
isStreaming?: boolean
236+
onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void
174237
}) {
175238
const { ref: scrollRef } = useAutoScroll(isStreaming)
176239
const { committed, incoming, generation } = useStreamingReveal(content, isStreaming)
177240

241+
const checkboxCounterRef = useRef(0)
242+
243+
const components = useMemo(
244+
() => buildMarkdownComponents(checkboxCounterRef, onCheckboxToggle),
245+
[onCheckboxToggle]
246+
)
247+
248+
checkboxCounterRef.current = 0
249+
178250
const committedMarkdown = useMemo(
179251
() =>
180252
committed ? (
181-
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={PREVIEW_MARKDOWN_COMPONENTS}>
253+
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={components}>
182254
{committed}
183255
</ReactMarkdown>
184256
) : null,
185-
[committed]
257+
[committed, components]
186258
)
187259

260+
if (onCheckboxToggle) {
261+
return (
262+
<div ref={scrollRef} className='h-full overflow-auto p-6'>
263+
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={components}>
264+
{content}
265+
</ReactMarkdown>
266+
</div>
267+
)
268+
}
269+
188270
return (
189271
<div ref={scrollRef} className='h-full overflow-auto p-6'>
190272
{committedMarkdown}
@@ -193,7 +275,7 @@ const MarkdownPreview = memo(function MarkdownPreview({
193275
key={generation}
194276
className={cn(isStreaming && 'animate-stream-fade-in', '[&>:first-child]:mt-0')}
195277
>
196-
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={PREVIEW_MARKDOWN_COMPONENTS}>
278+
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={components}>
197279
{incoming}
198280
</ReactMarkdown>
199281
</div>

0 commit comments

Comments
 (0)