mirror of https://github.com/langgenius/dify.git
fix(web): improve chat edit input behavior and shortcuts (#32757)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
parent
4b8a02cf25
commit
36fad7256d
|
|
@ -1,7 +1,7 @@
|
|||
import type { Theme } from '../embedded-chatbot/theme/theme-context'
|
||||
import type { ChatConfig, ChatItem, OnRegenerate } from '../types'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import * as React from 'react'
|
||||
|
|
@ -180,7 +180,7 @@ describe('Question component', () => {
|
|||
await user.clear(textbox)
|
||||
await user.type(textbox, 'Edited question')
|
||||
|
||||
const resendBtn = screen.getByRole('button', { name: /chat.resend/i })
|
||||
const resendBtn = screen.getByRole('button', { name: /operation.save/i })
|
||||
await user.click(resendBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -209,6 +209,91 @@ describe('Question component', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('should confirm editing when Enter is pressed', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onRegenerate = vi.fn() as unknown as OnRegenerate
|
||||
|
||||
renderWithProvider(makeItem(), onRegenerate)
|
||||
|
||||
await user.click(screen.getByTestId('edit-btn'))
|
||||
const textbox = await screen.findByRole('textbox')
|
||||
|
||||
await user.clear(textbox)
|
||||
await user.type(textbox, 'Edited with Enter')
|
||||
|
||||
fireEvent.keyDown(textbox, { key: 'Enter', code: 'Enter' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onRegenerate).toHaveBeenCalledWith(makeItem(), { message: 'Edited with Enter', files: [] })
|
||||
})
|
||||
})
|
||||
|
||||
it('should insert a new line when Shift+Enter is pressed', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onRegenerate = vi.fn() as unknown as OnRegenerate
|
||||
|
||||
renderWithProvider(makeItem(), onRegenerate)
|
||||
|
||||
await user.click(screen.getByTestId('edit-btn'))
|
||||
const textbox = await screen.findByRole('textbox')
|
||||
|
||||
await user.clear(textbox)
|
||||
await user.type(textbox, 'Line 1')
|
||||
await user.type(textbox, '{Shift>}{Enter}{/Shift}')
|
||||
|
||||
expect(textbox).toHaveValue('Line 1\n')
|
||||
expect(onRegenerate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not confirm editing when Enter is pressed during IME composition', () => {
|
||||
const onRegenerate = vi.fn() as unknown as OnRegenerate
|
||||
|
||||
renderWithProvider(makeItem(), onRegenerate)
|
||||
|
||||
fireEvent.click(screen.getByTestId('edit-btn'))
|
||||
const textbox = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.compositionStart(textbox)
|
||||
fireEvent.keyDown(textbox, { key: 'Enter', code: 'Enter' })
|
||||
|
||||
expect(onRegenerate).not.toHaveBeenCalled()
|
||||
expect(textbox).toHaveValue('This is the question content')
|
||||
})
|
||||
|
||||
it('should keep text unchanged and suppress Enter if a new composition starts before previous composition-end timer finishes', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
const onRegenerate = vi.fn() as unknown as OnRegenerate
|
||||
renderWithProvider(makeItem(), onRegenerate)
|
||||
|
||||
fireEvent.click(screen.getByTestId('edit-btn'))
|
||||
const textbox = screen.getByRole('textbox')
|
||||
fireEvent.change(textbox, { target: { value: 'IME guard text' } })
|
||||
|
||||
fireEvent.compositionStart(textbox)
|
||||
fireEvent.compositionEnd(textbox)
|
||||
fireEvent.compositionStart(textbox)
|
||||
|
||||
vi.advanceTimersByTime(50)
|
||||
|
||||
const blockedEnterEvent = new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true })
|
||||
textbox.dispatchEvent(blockedEnterEvent)
|
||||
expect(onRegenerate).not.toHaveBeenCalled()
|
||||
expect(blockedEnterEvent.defaultPrevented).toBe(true)
|
||||
expect(textbox).toHaveValue('IME guard text')
|
||||
|
||||
fireEvent.compositionEnd(textbox)
|
||||
vi.advanceTimersByTime(50)
|
||||
|
||||
fireEvent.keyDown(textbox, { key: 'Enter', code: 'Enter' })
|
||||
expect(onRegenerate).toHaveBeenCalledWith(makeItem(), { message: 'IME guard text', files: [] })
|
||||
}
|
||||
finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('should switch siblings when prev/next buttons are clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const switchSibling = vi.fn()
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ const Question: FC<QuestionProps> = ({
|
|||
const [editedContent, setEditedContent] = useState(content)
|
||||
const [contentWidth, setContentWidth] = useState(0)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const isComposingRef = useRef(false)
|
||||
const compositionEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
setIsEditing(true)
|
||||
|
|
@ -63,15 +65,62 @@ const Question: FC<QuestionProps> = ({
|
|||
}, [content])
|
||||
|
||||
const handleResend = useCallback(() => {
|
||||
if (compositionEndTimerRef.current) {
|
||||
clearTimeout(compositionEndTimerRef.current)
|
||||
compositionEndTimerRef.current = null
|
||||
}
|
||||
isComposingRef.current = false
|
||||
setIsEditing(false)
|
||||
onRegenerate?.(item, { message: editedContent, files: message_files })
|
||||
}, [editedContent, message_files, item, onRegenerate])
|
||||
|
||||
const handleCancelEditing = useCallback(() => {
|
||||
if (compositionEndTimerRef.current) {
|
||||
clearTimeout(compositionEndTimerRef.current)
|
||||
compositionEndTimerRef.current = null
|
||||
}
|
||||
isComposingRef.current = false
|
||||
setIsEditing(false)
|
||||
setEditedContent(content)
|
||||
}, [content])
|
||||
|
||||
const handleEditInputKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key !== 'Enter' || e.shiftKey)
|
||||
return
|
||||
|
||||
if (e.nativeEvent.isComposing)
|
||||
return
|
||||
|
||||
if (isComposingRef.current) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
handleResend()
|
||||
}, [handleResend])
|
||||
|
||||
const clearCompositionEndTimer = useCallback(() => {
|
||||
if (!compositionEndTimerRef.current)
|
||||
return
|
||||
|
||||
clearTimeout(compositionEndTimerRef.current)
|
||||
compositionEndTimerRef.current = null
|
||||
}, [])
|
||||
|
||||
const handleCompositionStart = useCallback(() => {
|
||||
clearCompositionEndTimer()
|
||||
isComposingRef.current = true
|
||||
}, [clearCompositionEndTimer])
|
||||
|
||||
const handleCompositionEnd = useCallback(() => {
|
||||
clearCompositionEndTimer()
|
||||
compositionEndTimerRef.current = setTimeout(() => {
|
||||
isComposingRef.current = false
|
||||
compositionEndTimerRef.current = null
|
||||
}, 50)
|
||||
}, [clearCompositionEndTimer])
|
||||
|
||||
const handleSwitchSibling = useCallback((direction: 'prev' | 'next') => {
|
||||
if (direction === 'prev') {
|
||||
if (item.prevSibling)
|
||||
|
|
@ -100,6 +149,12 @@ const Question: FC<QuestionProps> = ({
|
|||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearCompositionEndTimer()
|
||||
}
|
||||
}, [clearCompositionEndTimer])
|
||||
|
||||
return (
|
||||
<div className="mb-2 flex justify-end last:mb-0">
|
||||
<div className={cn('group relative mr-4 flex max-w-full items-start overflow-x-hidden pl-14', isEditing && 'flex-1')}>
|
||||
|
|
@ -128,13 +183,17 @@ const Question: FC<QuestionProps> = ({
|
|||
<div
|
||||
ref={contentRef}
|
||||
data-testid="question-content"
|
||||
className="w-full rounded-2xl bg-background-gradient-bg-fill-chat-bubble-bg-3 px-4 py-3 text-sm text-text-primary"
|
||||
style={theme?.chatBubbleColorStyle ? CssTransform(theme.chatBubbleColorStyle) : {}}
|
||||
className={cn(
|
||||
'w-full px-4 py-3 text-sm',
|
||||
!isEditing && 'rounded-2xl bg-background-gradient-bg-fill-chat-bubble-bg-3 text-text-primary',
|
||||
isEditing && 'rounded-[24px] border-[3px] border-components-option-card-option-selected-border bg-components-panel-bg-blur shadow-lg',
|
||||
)}
|
||||
style={(!isEditing && theme?.chatBubbleColorStyle) ? CssTransform(theme.chatBubbleColorStyle) : {}}
|
||||
>
|
||||
{
|
||||
!!message_files?.length && (
|
||||
<FileList
|
||||
className="mb-2"
|
||||
className={cn(isEditing ? 'mb-3' : 'mb-2')}
|
||||
files={message_files}
|
||||
showDeleteAction={false}
|
||||
showDownloadAction={true}
|
||||
|
|
@ -144,25 +203,24 @@ const Question: FC<QuestionProps> = ({
|
|||
{!isEditing
|
||||
? <Markdown content={content} />
|
||||
: (
|
||||
<div className="
|
||||
flex flex-col gap-2 rounded-xl
|
||||
border border-components-chat-input-border bg-components-panel-bg-blur p-[9px] shadow-md
|
||||
"
|
||||
>
|
||||
<div className="max-h-[158px] overflow-y-auto overflow-x-hidden">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="max-h-[158px] overflow-y-auto overflow-x-hidden pr-1">
|
||||
<Textarea
|
||||
className={cn(
|
||||
'w-full p-1 leading-6 text-text-tertiary outline-none body-lg-regular',
|
||||
'w-full resize-none bg-transparent p-0 leading-7 text-text-primary outline-none body-lg-regular',
|
||||
)}
|
||||
autoFocus
|
||||
minRows={1}
|
||||
value={editedContent}
|
||||
onChange={e => setEditedContent(e.target.value)}
|
||||
onKeyDown={handleEditInputKeyDown}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={handleCancelEditing}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
<Button variant="primary" onClick={handleResend}>{t('chat.resend', { ns: 'common' })}</Button>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button className="min-w-24" onClick={handleCancelEditing}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
<Button className="min-w-24" variant="primary" onClick={handleResend}>{t('operation.save', { ns: 'common' })}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in New Issue