Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 3.8k
feat(files): interactive markdown checkbox toggling in preview#3829
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
11aea6ba7e9399853a2a75721f705f7c61f5d7b4d9342696d51163c6File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -290,6 +290,16 @@ function TextEditor({ | ||
| } | ||
| }, [isResizing]) | ||
| const handleCheckboxToggle = useCallback( | ||
| (checkboxIndex: number, checked: boolean) => { | ||
| const toggled = toggleMarkdownCheckbox(contentRef.current, checkboxIndex, checked) | ||
| if (toggled !== contentRef.current) { | ||
| handleContentChange(toggled) | ||
| } | ||
| }, | ||
| [handleContentChange] | ||
| ) | ||
| const isStreaming = streamingContent !== undefined | ||
| const revealedContent = useStreamingText(content, isStreaming) | ||
| @@ -392,10 +402,11 @@ function TextEditor({ | ||
| className={cn('min-w-0 flex-1 overflow-hidden', isResizing && 'pointer-events-none')} | ||
| > | ||
| <PreviewPanel | ||
| content={revealedContent} | ||
| content={isStreaming ? revealedContent : content} | ||
| mimeType={file.type} | ||
| filename={file.name} | ||
| isStreaming={isStreaming} | ||
| onCheckboxToggle={canEdit && !isStreaming ? handleCheckboxToggle : undefined} | ||
| /> | ||
| </div> | ||
| </> | ||
| @@ -703,6 +714,14 @@ function PptxPreview({ | ||
| ) | ||
| } | ||
| function toggleMarkdownCheckbox(markdown: string, targetIndex: number, checked: boolean): string { | ||
| let currentIndex = 0 | ||
| return markdown.replace(/^(\s*(?:[-*+]|\d+[.)]) +)\[([ xX])\]/gm, (match, prefix: string) => { | ||
| if (currentIndex++ !== targetIndex) return match | ||
| return `${prefix}[${checked ? 'x' : ' '}]` | ||
| }) | ||
| } | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| const UnsupportedPreview = memo(function UnsupportedPreview({ | ||
| file, | ||
| }: { | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,10 @@ | ||
| 'use client' | ||
| import { memo, useMemo } from 'react' | ||
| import { memo, useMemo, useRef } from 'react' | ||
| import ReactMarkdown from 'react-markdown' | ||
| import remarkBreaks from 'remark-breaks' | ||
| import remarkGfm from 'remark-gfm' | ||
| import { Checkbox } from '@/components/emcn' | ||
| import { cn } from '@/lib/core/utils/cn' | ||
| import { getFileExtension } from '@/lib/uploads/utils/file-utils' | ||
| import { useAutoScroll } from '@/hooks/use-auto-scroll' | ||
| @@ -40,18 +41,26 @@ interface PreviewPanelProps { | ||
| mimeType: string | null | ||
| filename: string | ||
| isStreaming?: boolean | ||
| onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void | ||
| } | ||
| export const PreviewPanel = memo(function PreviewPanel({ | ||
| content, | ||
| mimeType, | ||
| filename, | ||
| isStreaming, | ||
| onCheckboxToggle, | ||
| }: PreviewPanelProps) { | ||
| const previewType = resolvePreviewType(mimeType, filename) | ||
| if (previewType === 'markdown') | ||
| return <MarkdownPreview content={content} isStreaming={isStreaming} /> | ||
| return ( | ||
| <MarkdownPreview | ||
| content={content} | ||
| isStreaming={isStreaming} | ||
| onCheckboxToggle={onCheckboxToggle} | ||
| /> | ||
| ) | ||
| if (previewType === 'html') return <HtmlPreview content={content} /> | ||
| if (previewType === 'csv') return <CsvPreview content={content} /> | ||
| if (previewType === 'svg') return <SvgPreview content={content} /> | ||
| @@ -61,7 +70,7 @@ export const PreviewPanel = memo(function PreviewPanel({ | ||
| const REMARK_PLUGINS = [remarkGfm, remarkBreaks] | ||
| const PREVIEW_MARKDOWN_COMPONENTS = { | ||
| const STATIC_MARKDOWN_COMPONENTS = { | ||
| p: ({ children }: any) => ( | ||
| <p className='mb-3 break-words text-[14px] text-[var(--text-primary)] leading-[1.6] last:mb-0'> | ||
| {children} | ||
| @@ -87,17 +96,6 @@ const PREVIEW_MARKDOWN_COMPONENTS = { | ||
| {children} | ||
| </h4> | ||
| ), | ||
| ul: ({ children }: any) => ( | ||
| <ul className='mt-1 mb-3 list-disc space-y-1 break-words pl-6 text-[14px] text-[var(--text-primary)]'> | ||
| {children} | ||
| </ul> | ||
| ), | ||
| ol: ({ children }: any) => ( | ||
| <ol className='mt-1 mb-3 list-decimal space-y-1 break-words pl-6 text-[14px] text-[var(--text-primary)]'> | ||
| {children} | ||
| </ol> | ||
| ), | ||
| li: ({ children }: any) => <li className='break-words leading-[1.6]'>{children}</li>, | ||
| code: ({ inline, className, children, ...props }: any) => { | ||
| const isInline = inline || !className?.includes('language-') | ||
| @@ -165,26 +163,110 @@ const PREVIEW_MARKDOWN_COMPONENTS = { | ||
| td: ({ children }: any) => <td className='px-3 py-2 text-[var(--text-secondary)]'>{children}</td>, | ||
| } | ||
| function buildMarkdownComponents( | ||
| checkboxCounterRef: React.MutableRefObject<number>, | ||
| onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void | ||
| ) { | ||
| const isInteractive = Boolean(onCheckboxToggle) | ||
| return { | ||
| ...STATIC_MARKDOWN_COMPONENTS, | ||
| ul: ({ className, children }: any) => { | ||
| const isTaskList = typeof className === 'string' && className.includes('contains-task-list') | ||
| return ( | ||
| <ul | ||
| className={cn( | ||
| 'mt-1 mb-3 space-y-1 break-words text-[14px] text-[var(--text-primary)]', | ||
| isTaskList ? 'list-none pl-0' : 'list-disc pl-6' | ||
| )} | ||
| > | ||
| {children} | ||
| </ul> | ||
| ) | ||
| }, | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| ol: ({ className, children }: any) => { | ||
| const isTaskList = typeof className === 'string' && className.includes('contains-task-list') | ||
| return ( | ||
| <ol | ||
| className={cn( | ||
| 'mt-1 mb-3 space-y-1 break-words text-[14px] text-[var(--text-primary)]', | ||
| isTaskList ? 'list-none pl-0' : 'list-decimal pl-6' | ||
| )} | ||
| > | ||
| {children} | ||
| </ol> | ||
| ) | ||
| }, | ||
| li: ({ className, children }: any) => { | ||
| const isTaskItem = typeof className === 'string' && className.includes('task-list-item') | ||
| if (isTaskItem) { | ||
| return <li className='flex items-start gap-2 break-words leading-[1.6]'>{children}</li> | ||
| } | ||
| return <li className='break-words leading-[1.6]'>{children}</li> | ||
| }, | ||
| input: ({ type, checked, ...props }: any) => { | ||
| if (type !== 'checkbox') return <input type={type} checked={checked} {...props} /> | ||
| const index = checkboxCounterRef.current++ | ||
| return ( | ||
| <Checkbox | ||
| checked={checked ?? false} | ||
| onCheckedChange={ | ||
| isInteractive | ||
| ? (newChecked) => onCheckboxToggle!(index, Boolean(newChecked)) | ||
| : undefined | ||
| } | ||
| disabled={!isInteractive} | ||
| size='sm' | ||
| className='mt-1 shrink-0' | ||
| /> | ||
| ) | ||
| }, | ||
| } | ||
| } | ||
| const MarkdownPreview = memo(function MarkdownPreview({ | ||
| content, | ||
| isStreaming = false, | ||
| onCheckboxToggle, | ||
| }: { | ||
| content: string | ||
| isStreaming?: boolean | ||
| onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void | ||
| }) { | ||
| const { ref: scrollRef } = useAutoScroll(isStreaming) | ||
| const { committed, incoming, generation } = useStreamingReveal(content, isStreaming) | ||
| const checkboxCounterRef = useRef(0) | ||
| const components = useMemo( | ||
| () => buildMarkdownComponents(checkboxCounterRef, onCheckboxToggle), | ||
| [onCheckboxToggle] | ||
| ) | ||
| checkboxCounterRef.current = 0 | ||
| const committedMarkdown = useMemo( | ||
| () => | ||
| committed ? ( | ||
| <ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={PREVIEW_MARKDOWN_COMPONENTS}> | ||
| <ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={components}> | ||
| {committed} | ||
| </ReactMarkdown> | ||
| ) : null, | ||
| [committed] | ||
| [committed, components] | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| ) | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| if (onCheckboxToggle) { | ||
| return ( | ||
| <div ref={scrollRef} className='h-full overflow-auto p-6'> | ||
| <ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={components}> | ||
| {content} | ||
| </ReactMarkdown> | ||
| </div> | ||
| ) | ||
| } | ||
| return ( | ||
| <div ref={scrollRef} className='h-full overflow-auto p-6'> | ||
| {committedMarkdown} | ||
| @@ -193,7 +275,7 @@ const MarkdownPreview = memo(function MarkdownPreview({ | ||
| key={generation} | ||
| className={cn(isStreaming && 'animate-stream-fade-in', '[&>:first-child]:mt-0')} | ||
| > | ||
| <ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={PREVIEW_MARKDOWN_COMPONENTS}> | ||
| <ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={components}> | ||
| {incoming} | ||
| </ReactMarkdown> | ||
| </div> | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.