Files
new-api/web/src/components/ai-elements/code-block.tsx
T
RedwindA 137d1171f2 feat(web): fade in streamed response words and harden playground editor (#6895)
* feat(web): fade in newly streamed response words

Animate only new word-level deltas while markdown is still streaming, and
cache markdown-it instances per parser id so concurrent Response trees do
not rebuild or reparse on every render.

* fix(web): keep CodeMirror editor alive across keystroke re-renders

Deliver onKeyDown through a ref instead of the extensions memo so a new
handler identity no longer tears down the EditorView, which reset the
cursor to the document start and made typing appear right-to-left.

* feat(web): add unsaved changes confirmation dialog in PlaygroundMessageEditor

Implement a confirmation dialog to warn users about unsaved changes when attempting to leave the editor. This includes handling the beforeunload event to prevent accidental navigation away from the editor. Additionally, add tests to verify the dialog's behavior under various scenarios.

* test(web): cover beforeunload guard and fade hydration suppression

Address review feedback: add regression tests for the unsaved-changes
beforeunload guard and the first-render fade suppression of hydrated
content, and annotate getCachedMarkdown's return type.
2026-08-18 18:20:53 +08:00

669 lines
17 KiB
TypeScript

/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
/* eslint-disable react-refresh/only-export-components */
'use client'
import { markdown } from '@codemirror/lang-markdown'
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
import { EditorState, type Extension } from '@codemirror/state'
import { EditorView, lineNumbers } from '@codemirror/view'
import { tags as highlightTags } from '@lezer/highlight'
import {
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
CopyIcon,
DownloadIcon,
} from 'lucide-react'
import {
type ComponentProps,
createContext,
type CSSProperties,
type HTMLAttributes,
type ReactNode,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import type { BundledLanguage } from 'shiki'
import { Button } from '@/components/ui/button'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string
collapsedLines?: number
defaultCollapsed?: boolean
enableCollapse?: boolean
filename?: string
language: BundledLanguage | string
maxExpandedLines?: number
/** @deprecated use collapsedLines for collapsed preview height. */
maxCollapsedLines?: number
showLineNumbers?: boolean
showToolbar?: boolean
title?: ReactNode
}
type CodeBlockEditorProps = Omit<
HTMLAttributes<HTMLDivElement>,
'onChange' | 'onKeyDown' | 'title'
> & {
actions?: ReactNode
ariaLabel: string
language: BundledLanguage | string
onChange: (value: string) => void
onKeyDown?: (event: globalThis.KeyboardEvent) => void
rows?: number
title?: ReactNode
value: string
}
type CodeMirrorCodeViewProps = {
ariaLabel: string
autoFocus?: boolean
language: BundledLanguage | string
onChange?: (value: string) => void
onKeyDown?: (event: globalThis.KeyboardEvent) => void
readOnly?: boolean
rows?: number
showLineNumbers?: boolean
value: string
}
type CodeBlockFrameProps = Omit<HTMLAttributes<HTMLDivElement>, 'title'> & {
bodyClassName?: string
bodyMaxHeight?: string
bodyOverlay?: ReactNode
children: ReactNode
endActions?: ReactNode
showToolbar?: boolean
title?: ReactNode
}
type CodeBlockContextType = {
code: string
language: string
}
const CodeBlockContext = createContext<CodeBlockContextType>({
code: '',
language: 'plaintext',
})
const LANGUAGE_ALIASES: Record<string, BundledLanguage> = {
csharp: 'c#',
golang: 'go',
js: 'javascript',
shell: 'bash',
shellscript: 'bash',
ts: 'typescript',
}
const LANGUAGE_PATTERN = /^[a-z0-9][a-z0-9+#._-]{0,31}$/i
const codeMirrorTheme = EditorView.theme({
'&': {
background: 'transparent',
color: 'var(--foreground)',
fontSize: '13px',
},
'.cm-content': {
caretColor: 'var(--foreground)',
fontFamily: 'var(--font-mono)',
lineHeight: '1.5rem',
minHeight: 'var(--code-editor-min-height)',
minWidth: 'max-content',
padding: '1rem 1rem 1rem 0',
},
'.cm-editor': {
background: 'transparent',
width: '100%',
},
'.cm-focused': {
outline: 'none',
},
'.cm-gutters': {
background: 'transparent',
borderRight: '0',
color: 'var(--muted-foreground)',
fontFamily: 'var(--font-mono)',
fontSize: '13px',
lineHeight: '1.5rem',
padding: '1rem 1rem 1rem 0',
},
'.cm-gutters:empty': {
display: 'none',
},
'.cm-lineNumbers .cm-gutterElement': {
minWidth: '2.5rem',
padding: '0 1rem 0 0',
textAlign: 'right',
},
'.cm-line': {
padding: '0',
},
'.cm-scroller': {
fontFamily: 'var(--font-mono)',
lineHeight: '1.5rem',
minHeight: 'var(--code-editor-min-height)',
overflow: 'auto',
},
'.cm-selectionBackground': {
background:
'color-mix(in oklch, var(--primary) 28%, transparent) !important',
},
})
const codeMirrorHighlightStyle = syntaxHighlighting(
HighlightStyle.define([
{ tag: highlightTags.heading, color: '#e06c75', fontWeight: '600' },
{ tag: [highlightTags.strong, highlightTags.emphasis], color: '#d19a66' },
{ tag: [highlightTags.link, highlightTags.url], color: '#61afef' },
{
tag: [highlightTags.monospace, highlightTags.contentSeparator],
color: '#98c379',
},
{
tag: [highlightTags.keyword, highlightTags.processingInstruction],
color: '#c678dd',
},
{
tag: [highlightTags.atom, highlightTags.bool, highlightTags.number],
color: '#d19a66',
},
{ tag: [highlightTags.string, highlightTags.inserted], color: '#98c379' },
{ tag: [highlightTags.deleted, highlightTags.invalid], color: '#e06c75' },
{
tag: [highlightTags.meta, highlightTags.comment],
color: 'var(--muted-foreground)',
},
])
)
function getRequestedCodeLanguage(language?: string) {
const normalized = language?.trim().toLowerCase() || 'plaintext'
if (!LANGUAGE_PATTERN.test(normalized)) {
return 'plaintext'
}
return LANGUAGE_ALIASES[normalized] ?? normalized
}
function getCodeMirrorLanguageExtension(language: BundledLanguage | string) {
const requestedLanguage = getRequestedCodeLanguage(language)
if (
requestedLanguage === 'markdown' ||
requestedLanguage === 'md' ||
requestedLanguage === 'mdx'
) {
return markdown()
}
return []
}
function getCodeLineCount(code: string) {
if (!code) {
return 1
}
return code.split('\n').length
}
function getDownloadFilename(language: string, filename?: string) {
if (filename) {
return filename
}
const extension = language === 'plaintext' ? 'txt' : language
return `code.${extension}`
}
function getCodeBlockHeight(lines: number) {
return `${Math.max(4, lines) * 1.5 + 2}rem`
}
function getCodeBlockMaxHeight(
isCodeCollapsed: boolean,
previewLines: number,
maxExpandedLines?: number
): string | undefined {
if (isCodeCollapsed) {
return getCodeBlockHeight(previewLines)
}
if (maxExpandedLines) {
return getCodeBlockHeight(maxExpandedLines)
}
return undefined
}
function getCodeMirrorExtensions(options: {
language: BundledLanguage | string
onKeyDown: (event: globalThis.KeyboardEvent) => void
readOnly: boolean
showLineNumbers: boolean
}): Extension[] {
const extensions: Extension[] = [
getCodeMirrorLanguageExtension(options.language),
codeMirrorHighlightStyle,
codeMirrorTheme,
EditorState.tabSize.of(2),
EditorState.readOnly.of(options.readOnly),
EditorView.editable.of(!options.readOnly),
EditorView.domEventHandlers({
keydown(event) {
options.onKeyDown(event)
return event.defaultPrevented
},
}),
]
if (options.showLineNumbers) {
extensions.unshift(lineNumbers())
}
return extensions
}
function CodeMirrorCodeView({
ariaLabel,
autoFocus = false,
language,
onChange,
onKeyDown,
readOnly = false,
rows = 8,
showLineNumbers = true,
value,
}: CodeMirrorCodeViewProps) {
const editorHostRef = useRef<HTMLDivElement>(null)
const editorViewRef = useRef<EditorView | null>(null)
const initialValueRef = useRef(value)
const onChangeRef = useRef(onChange)
const onKeyDownRef = useRef(onKeyDown)
const editorMinHeight = `${Math.max(4, rows) * 1.5 + 2}rem`
// onKeyDown is delivered through a ref so a new handler identity from the
// parent (recreated on every keystroke-driven render) does not invalidate
// the extensions and tear down the EditorView, which would reset the cursor
// to the document start and make typing appear right-to-left.
const editorExtensions = useMemo(
() =>
getCodeMirrorExtensions({
language,
onKeyDown: (event) => onKeyDownRef.current?.(event),
readOnly,
showLineNumbers,
}),
[language, readOnly, showLineNumbers]
)
useEffect(() => {
onChangeRef.current = onChange
onKeyDownRef.current = onKeyDown
}, [onChange, onKeyDown])
useEffect(() => {
const editorHost = editorHostRef.current
if (!editorHost) {
return
}
const editorView = new EditorView({
doc: initialValueRef.current,
extensions: [
...editorExtensions,
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChangeRef.current?.(update.state.doc.toString())
}
}),
],
parent: editorHost,
})
editorViewRef.current = editorView
if (autoFocus) {
editorView.focus()
}
return () => {
editorView.destroy()
editorViewRef.current = null
}
}, [autoFocus, editorExtensions])
useEffect(() => {
// Track the latest value so a future editor rebuild (e.g. language change)
// starts from the current document instead of the mount-time snapshot.
initialValueRef.current = value
const editorView = editorViewRef.current
if (!editorView) {
return
}
const currentValue = editorView.state.doc.toString()
if (currentValue === value) {
return
}
editorView.dispatch({
changes: {
from: 0,
to: editorView.state.doc.length,
insert: value,
},
})
}, [value])
return (
<div
aria-label={ariaLabel}
aria-readonly={readOnly}
className='min-h-(--code-editor-min-height)'
ref={editorHostRef}
role='textbox'
style={
{
'--code-editor-min-height': editorMinHeight,
} as CSSProperties
}
/>
)
}
export const CodeBlockFrame = ({
bodyClassName,
bodyMaxHeight,
bodyOverlay,
children,
className,
endActions,
showToolbar = false,
title,
...props
}: CodeBlockFrameProps) => (
<div
className={cn(
'group/code-block bg-muted/20 text-foreground my-3 w-full max-w-full overflow-hidden rounded-lg border shadow-xs',
className
)}
{...props}
>
{showToolbar && (
<div className='bg-muted/35 border-border/70 flex min-h-10 items-center gap-2 border-b px-2 py-1.5'>
<div className='min-w-0 flex-1'>
<div className='text-muted-foreground truncate font-mono text-[11px] font-medium tracking-wide uppercase'>
{title}
</div>
</div>
{endActions && (
<div className='flex shrink-0 items-center gap-1'>{endActions}</div>
)}
</div>
)}
<div className='relative min-w-0'>
<div
className={cn(
'code-block-scroll max-w-full overflow-auto transition-[max-height] duration-200 ease-out',
bodyClassName
)}
style={{ maxHeight: bodyMaxHeight }}
>
{children}
</div>
{bodyOverlay}
</div>
</div>
)
export const CodeBlock = ({
code,
collapsedLines = 12,
defaultCollapsed,
enableCollapse = true,
filename,
language,
maxExpandedLines,
maxCollapsedLines,
showLineNumbers = false,
showToolbar = false,
title,
className,
children,
...props
}: CodeBlockProps) => {
const { t } = useTranslation()
const [isCollapsed, setIsCollapsed] = useState(Boolean(defaultCollapsed))
const displayLanguage = getRequestedCodeLanguage(language)
const lineCount = useMemo(() => getCodeLineCount(code), [code])
const previewLines = maxCollapsedLines ?? collapsedLines
const canCollapse = enableCollapse && lineCount > previewLines
const isCodeCollapsed = canCollapse && isCollapsed
const displayTitle = title ?? displayLanguage
const bodyMaxHeight = getCodeBlockMaxHeight(
isCodeCollapsed,
previewLines,
maxExpandedLines
)
const downloadCode = () => {
if (typeof window === 'undefined') {
return
}
const blob = new Blob([code], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = getDownloadFilename(displayLanguage, filename)
anchor.click()
URL.revokeObjectURL(url)
}
return (
<CodeBlockContext.Provider value={{ code, language: displayLanguage }}>
<CodeBlockFrame
bodyClassName='p-0'
bodyMaxHeight={bodyMaxHeight}
bodyOverlay={
<>
{isCodeCollapsed && (
<div className='from-muted/20 to-background pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-linear-to-b' />
)}
{!showToolbar && children && (
<div className='absolute top-2 right-2 flex items-center gap-1'>
{children}
</div>
)}
</>
}
className={className}
endActions={
<>
{canCollapse && (
<Tooltip>
<TooltipTrigger
render={
<Button
aria-label={isCodeCollapsed ? t('Expand') : t('Collapse')}
className='size-8'
onClick={() => setIsCollapsed((value) => !value)}
size='icon-sm'
type='button'
variant='ghost'
>
{isCodeCollapsed ? (
<ChevronRightIcon className='size-4' />
) : (
<ChevronDownIcon className='size-4' />
)}
</Button>
}
/>
<TooltipContent>
<p>{isCodeCollapsed ? t('Expand') : t('Collapse')}</p>
</TooltipContent>
</Tooltip>
)}
{showToolbar && children}
<Tooltip>
<TooltipTrigger
render={
<Button
aria-label={t('Download')}
className='size-8'
onClick={downloadCode}
size='icon-sm'
type='button'
variant='ghost'
>
<DownloadIcon className='size-4' />
</Button>
}
/>
<TooltipContent>
<p>{t('Download')}</p>
</TooltipContent>
</Tooltip>
</>
}
showToolbar={showToolbar}
title={displayTitle}
{...props}
>
<CodeMirrorCodeView
ariaLabel={
typeof displayTitle === 'string' ? displayTitle : displayLanguage
}
language={language}
readOnly
rows={Math.min(Math.max(lineCount, 4), maxExpandedLines ?? lineCount)}
showLineNumbers={showLineNumbers}
value={code}
/>
</CodeBlockFrame>
</CodeBlockContext.Provider>
)
}
export const CodeBlockEditor = ({
actions,
ariaLabel,
className,
language,
onChange,
onKeyDown,
rows = 8,
title,
value,
...props
}: CodeBlockEditorProps) => {
return (
<CodeBlockFrame
bodyClassName='p-0'
className={className}
endActions={actions}
showToolbar
title={title}
{...props}
>
<CodeMirrorCodeView
ariaLabel={ariaLabel}
autoFocus
language={language}
onChange={onChange}
onKeyDown={onKeyDown}
rows={rows}
showLineNumbers
value={value}
/>
</CodeBlockFrame>
)
}
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void
onError?: (error: Error) => void
timeout?: number
}
export const CodeBlockCopyButton = ({
onCopy,
onError,
timeout = 2000,
children,
className,
...props
}: CodeBlockCopyButtonProps) => {
const { t } = useTranslation()
const [isCopied, setIsCopied] = useState(false)
const { code } = useContext(CodeBlockContext)
const copyToClipboard = async () => {
if (typeof window === 'undefined' || !navigator?.clipboard?.writeText) {
onError?.(new Error('Clipboard API not available'))
return
}
try {
await navigator.clipboard.writeText(code)
setIsCopied(true)
onCopy?.()
setTimeout(() => setIsCopied(false), timeout)
} catch (error) {
onError?.(error as Error)
}
}
const Icon = isCopied ? CheckIcon : CopyIcon
const button = (
<Button
aria-label={isCopied ? t('Copied!') : t('Copy code')}
className={cn('size-8 shrink-0', className)}
onClick={copyToClipboard}
size='icon-sm'
type='button'
variant='ghost'
{...props}
>
{children ?? <Icon size={14} />}
</Button>
)
return (
<Tooltip>
<TooltipTrigger render={button} />
<TooltipContent>
<p>{isCopied ? t('Copied!') : t('Copy code')}</p>
</TooltipContent>
</Tooltip>
)
}