@@ -4,7 +4,10 @@ import { memo, useMemo } from 'react'
44import ReactMarkdown from 'react-markdown'
55import remarkBreaks from 'remark-breaks'
66import remarkGfm from 'remark-gfm'
7+ import { cn } from '@/lib/core/utils/cn'
78import { getFileExtension } from '@/lib/uploads/utils/file-utils'
9+ import { useAutoScroll } from '@/app/workspace/[workspaceId]/home/hooks/use-auto-scroll'
10+ import { useStreamingReveal } from '@/app/workspace/[workspaceId]/home/hooks/use-streaming-reveal'
811
912type PreviewType = 'markdown' | 'html' | 'csv' | 'svg' | null
1013
@@ -36,134 +39,163 @@ interface PreviewPanelProps {
3639 content : string
3740 mimeType : string | null
3841 filename : string
42+ isStreaming ?: boolean
3943}
4044
41- export function PreviewPanel ( { content, mimeType, filename } : PreviewPanelProps ) {
45+ export function PreviewPanel ( { content, mimeType, filename, isStreaming } : PreviewPanelProps ) {
4246 const previewType = resolvePreviewType ( mimeType , filename )
4347
44- if ( previewType === 'markdown' ) return < MarkdownPreview content = { content } />
48+ if ( previewType === 'markdown' ) return < MarkdownPreview content = { content } isStreaming = { isStreaming } />
4549 if ( previewType === 'html' ) return < HtmlPreview content = { content } />
4650 if ( previewType === 'csv' ) return < CsvPreview content = { content } />
4751 if ( previewType === 'svg' ) return < SvgPreview content = { content } />
4852
4953 return null
5054}
5155
52- const MarkdownPreview = memo ( function MarkdownPreview ( { content } : { content : string } ) {
53- return (
54- < div className = 'h-full overflow-auto p-[24px]' >
55- < ReactMarkdown
56- remarkPlugins = { [ remarkGfm , remarkBreaks ] }
57- components = { {
58- p : ( { children } : any ) => (
59- < p className = 'mb-3 break-words text-[14px] text-[var(--text-primary)] leading-[1.6] last:mb-0' >
60- { children }
61- </ p >
62- ) ,
63- h1 : ( { children } : any ) => (
64- < h1 className = 'mt-6 mb-4 break-words border-[var(--border)] border-b pb-2 font-semibold text-[24px] text-[var(--text-primary)] first:mt-0' >
65- { children }
66- </ h1 >
67- ) ,
68- h2 : ( { children } : any ) => (
69- < h2 className = 'mt-5 mb-3 break-words border-[var(--border)] border-b pb-1.5 font-semibold text-[20px] text-[var(--text-primary)] first:mt-0' >
70- { children }
71- </ h2 >
72- ) ,
73- h3 : ( { children } : any ) => (
74- < h3 className = 'mt-4 mb-2 break-words font-semibold text-[16px] text-[var(--text-primary)] first:mt-0' >
75- { children }
76- </ h3 >
77- ) ,
78- h4 : ( { children } : any ) => (
79- < h4 className = 'mt-3 mb-2 break-words font-semibold text-[14px] text-[var(--text-primary)] first:mt-0' >
80- { children }
81- </ h4 >
82- ) ,
83- ul : ( { children } : any ) => (
84- < ul className = 'mt-1 mb-3 list-disc space-y-1 break-words pl-6 text-[14px] text-[var(--text-primary)]' >
85- { children }
86- </ ul >
87- ) ,
88- ol : ( { children } : any ) => (
89- < ol className = 'mt-1 mb-3 list-decimal space-y-1 break-words pl-6 text-[14px] text-[var(--text-primary)]' >
90- { children }
91- </ ol >
92- ) ,
93- li : ( { children } : any ) => < li className = 'break-words leading-[1.6]' > { children } </ li > ,
94- code : ( { inline, className, children, ...props } : any ) => {
95- const isInline = inline || ! className ?. includes ( 'language-' )
56+ const REMARK_PLUGINS = [ remarkGfm , remarkBreaks ]
9657
97- if ( isInline ) {
98- return (
99- < code
100- { ...props }
101- className = 'whitespace-normal rounded bg-[var(--surface-5)] px-1.5 py-0.5 font-mono text-[#F59E0B] text-[13px]'
102- >
103- { children }
104- </ code >
105- )
106- }
58+ const PREVIEW_MARKDOWN_COMPONENTS = {
59+ p : ( { children } : any ) => (
60+ < p className = 'mb-3 break-words text-[14px] text-[var(--text-primary)] leading-[1.6] last:mb-0' >
61+ { children }
62+ </ p >
63+ ) ,
64+ h1 : ( { children } : any ) => (
65+ < h1 className = 'mt-6 mb-4 break-words border-[var(--border)] border-b pb-2 font-semibold text-[24px] text-[var(--text-primary)] first:mt-0' >
66+ { children }
67+ </ h1 >
68+ ) ,
69+ h2 : ( { children } : any ) => (
70+ < h2 className = 'mt-5 mb-3 break-words border-[var(--border)] border-b pb-1.5 font-semibold text-[20px] text-[var(--text-primary)] first:mt-0' >
71+ { children }
72+ </ h2 >
73+ ) ,
74+ h3 : ( { children } : any ) => (
75+ < h3 className = 'mt-4 mb-2 break-words font-semibold text-[16px] text-[var(--text-primary)] first:mt-0' >
76+ { children }
77+ </ h3 >
78+ ) ,
79+ h4 : ( { children } : any ) => (
80+ < h4 className = 'mt-3 mb-2 break-words font-semibold text-[14px] text-[var(--text-primary)] first:mt-0' >
81+ { children }
82+ </ h4 >
83+ ) ,
84+ ul : ( { children } : any ) => (
85+ < ul className = 'mt-1 mb-3 list-disc space-y-1 break-words pl-6 text-[14px] text-[var(--text-primary)]' >
86+ { children }
87+ </ ul >
88+ ) ,
89+ ol : ( { children } : any ) => (
90+ < ol className = 'mt-1 mb-3 list-decimal space-y-1 break-words pl-6 text-[14px] text-[var(--text-primary)]' >
91+ { children }
92+ </ ol >
93+ ) ,
94+ li : ( { children } : any ) => < li className = 'break-words leading-[1.6]' > { children } </ li > ,
95+ code : ( { inline, className, children, ...props } : any ) => {
96+ const isInline = inline || ! className ?. includes ( 'language-' )
97+
98+ if ( isInline ) {
99+ return (
100+ < code
101+ { ...props }
102+ className = 'whitespace-normal rounded bg-[var(--surface-5)] px-1.5 py-0.5 font-mono text-[#F59E0B] text-[13px]'
103+ >
104+ { children }
105+ </ code >
106+ )
107+ }
107108
108- return (
109- < code
110- { ...props }
111- className = 'my-3 block whitespace-pre-wrap break-words rounded-md bg-[var(--surface-5)] p-4 font-mono text-[13px] text-[var(--text-primary)]'
112- >
113- { children }
114- </ code >
115- )
116- } ,
117- pre : ( { children } : any ) => < > { children } </ > ,
118- a : ( { href, children } : any ) => (
119- < a
120- href = { href }
121- target = '_blank'
122- rel = 'noopener noreferrer'
123- className = 'break-all text-[var(--brand-secondary)] underline-offset-2 hover:underline'
124- >
125- { children }
126- </ a >
127- ) ,
128- strong : ( { children } : any ) => (
129- < strong className = 'break-words font-semibold text-[var(--text-primary)]' >
130- { children }
131- </ strong >
132- ) ,
133- em : ( { children } : any ) => (
134- < em className = 'break-words text-[var(--text-tertiary)]' > { children } </ em >
135- ) ,
136- blockquote : ( { children } : any ) => (
137- < blockquote className = 'my-4 break-words border-[var(--border-1)] border-l-4 py-1 pl-4 text-[var(--text-tertiary)] italic' >
138- { children }
139- </ blockquote >
140- ) ,
141- hr : ( ) => < hr className = 'my-6 border-[var(--border)]' /> ,
142- img : ( { src, alt } : any ) => (
143- < img src = { src } alt = { alt ?? '' } className = 'my-3 max-w-full rounded-md' loading = 'lazy' />
144- ) ,
145- table : ( { children } : any ) => (
146- < div className = 'my-4 max-w-full overflow-x-auto rounded-md border border-[var(--border)]' >
147- < table className = 'w-full border-collapse text-[13px]' > { children } </ table >
148- </ div >
149- ) ,
150- thead : ( { children } : any ) => < thead className = 'bg-[var(--surface-2)]' > { children } </ thead > ,
151- tbody : ( { children } : any ) => < tbody > { children } </ tbody > ,
152- tr : ( { children } : any ) => (
153- < tr className = 'border-[var(--border)] border-b last:border-b-0' > { children } </ tr >
154- ) ,
155- th : ( { children } : any ) => (
156- < th className = 'px-3 py-2 text-left font-semibold text-[12px] text-[var(--text-primary)]' >
157- { children }
158- </ th >
159- ) ,
160- td : ( { children } : any ) => (
161- < td className = 'px-3 py-2 text-[var(--text-secondary)]' > { children } </ td >
162- ) ,
163- } }
109+ return (
110+ < code
111+ { ...props }
112+ className = 'my-3 block whitespace-pre-wrap break-words rounded-md bg-[var(--surface-5)] p-4 font-mono text-[13px] text-[var(--text-primary)]'
164113 >
165- { content }
166- </ ReactMarkdown >
114+ { children }
115+ </ code >
116+ )
117+ } ,
118+ pre : ( { children } : any ) => < > { children } </ > ,
119+ a : ( { href, children } : any ) => (
120+ < a
121+ href = { href }
122+ target = '_blank'
123+ rel = 'noopener noreferrer'
124+ className = 'break-all text-[var(--brand-secondary)] underline-offset-2 hover:underline'
125+ >
126+ { children }
127+ </ a >
128+ ) ,
129+ strong : ( { children } : any ) => (
130+ < strong className = 'break-words font-semibold text-[var(--text-primary)]' >
131+ { children }
132+ </ strong >
133+ ) ,
134+ em : ( { children } : any ) => (
135+ < em className = 'break-words text-[var(--text-tertiary)]' > { children } </ em >
136+ ) ,
137+ blockquote : ( { children } : any ) => (
138+ < blockquote className = 'my-4 break-words border-[var(--border-1)] border-l-4 py-1 pl-4 text-[var(--text-tertiary)] italic' >
139+ { children }
140+ </ blockquote >
141+ ) ,
142+ hr : ( ) => < hr className = 'my-6 border-[var(--border)]' /> ,
143+ img : ( { src, alt } : any ) => (
144+ < img src = { src } alt = { alt ?? '' } className = 'my-3 max-w-full rounded-md' loading = 'lazy' />
145+ ) ,
146+ table : ( { children } : any ) => (
147+ < div className = 'my-4 max-w-full overflow-x-auto rounded-md border border-[var(--border)]' >
148+ < table className = 'w-full border-collapse text-[13px]' > { children } </ table >
149+ </ div >
150+ ) ,
151+ thead : ( { children } : any ) => < thead className = 'bg-[var(--surface-2)]' > { children } </ thead > ,
152+ tbody : ( { children } : any ) => < tbody > { children } </ tbody > ,
153+ tr : ( { children } : any ) => (
154+ < tr className = 'border-[var(--border)] border-b last:border-b-0' > { children } </ tr >
155+ ) ,
156+ th : ( { children } : any ) => (
157+ < th className = 'px-3 py-2 text-left font-semibold text-[12px] text-[var(--text-primary)]' >
158+ { children }
159+ </ th >
160+ ) ,
161+ td : ( { children } : any ) => (
162+ < td className = 'px-3 py-2 text-[var(--text-secondary)]' > { children } </ td >
163+ ) ,
164+ }
165+
166+ const MarkdownPreview = memo ( function MarkdownPreview ( {
167+ content,
168+ isStreaming = false ,
169+ } : {
170+ content : string
171+ isStreaming ?: boolean
172+ } ) {
173+ const { ref : scrollRef } = useAutoScroll ( isStreaming )
174+ const { committed, incoming, generation } = useStreamingReveal ( content , isStreaming )
175+
176+ const committedMarkdown = useMemo (
177+ ( ) =>
178+ committed ? (
179+ < ReactMarkdown remarkPlugins = { REMARK_PLUGINS } components = { PREVIEW_MARKDOWN_COMPONENTS } >
180+ { committed }
181+ </ ReactMarkdown >
182+ ) : null ,
183+ [ committed ]
184+ )
185+
186+ return (
187+ < div ref = { scrollRef } className = 'h-full overflow-auto p-[24px]' >
188+ { committedMarkdown }
189+ { incoming && (
190+ < div
191+ key = { generation }
192+ className = { cn ( isStreaming && 'animate-stream-fade-in' , '[&>:first-child]:mt-0' ) }
193+ >
194+ < ReactMarkdown remarkPlugins = { REMARK_PLUGINS } components = { PREVIEW_MARKDOWN_COMPONENTS } >
195+ { incoming }
196+ </ ReactMarkdown >
197+ </ div >
198+ ) }
167199 </ div >
168200 )
169201} )
0 commit comments