mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-03 02:59:07 +08:00
Fix/at symbol deletion issue (#9206)
* fix: prevent incorrect @ symbol deletion in QuickPanel - Track trigger source (input vs button) and @ position - Only delete @ when triggered by input with model selection - Button-triggered panels never delete text content - Validate @ still exists at recorded position before deletion * feat: delete search text along with @ symbol - Pass searchText from QuickPanel to onClose callback - Delete both @ and search text (e.g., @cla) when model selected - Validate text matches before deletion for safety - Fallback to deleting only @ if text doesn't match * refactor: clarify ESC vs Backspace behavior in QuickPanel - ESC: Cancel operation, delete @ + searchText when models selected - Backspace: Natural editing, @ already deleted by browser, no extra action - Clear separation of intent improves predictability and UX
This commit is contained in:
parent
4dad2a593b
commit
535dcf4778
@ -5,7 +5,8 @@ import {
|
||||
QuickPanelCloseAction,
|
||||
QuickPanelContextType,
|
||||
QuickPanelListItem,
|
||||
QuickPanelOpenOptions
|
||||
QuickPanelOpenOptions,
|
||||
QuickPanelTriggerInfo
|
||||
} from './types'
|
||||
|
||||
const QuickPanelContext = createContext<QuickPanelContextType | null>(null)
|
||||
@ -19,9 +20,8 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
const [defaultIndex, setDefaultIndex] = useState<number>(0)
|
||||
const [pageSize, setPageSize] = useState<number>(7)
|
||||
const [multiple, setMultiple] = useState<boolean>(false)
|
||||
const [onClose, setOnClose] = useState<
|
||||
((Options: Pick<QuickPanelCallBackOptions, 'symbol' | 'action'>) => void) | undefined
|
||||
>()
|
||||
const [triggerInfo, setTriggerInfo] = useState<QuickPanelTriggerInfo | undefined>()
|
||||
const [onClose, setOnClose] = useState<((Options: Partial<QuickPanelCallBackOptions>) => void) | undefined>()
|
||||
const [beforeAction, setBeforeAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>()
|
||||
const [afterAction, setAfterAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>()
|
||||
|
||||
@ -44,6 +44,7 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
setPageSize(options.pageSize ?? 7)
|
||||
setMultiple(options.multiple ?? false)
|
||||
setSymbol(options.symbol)
|
||||
setTriggerInfo(options.triggerInfo)
|
||||
|
||||
setOnClose(() => options.onClose)
|
||||
setBeforeAction(() => options.beforeAction)
|
||||
@ -53,9 +54,9 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
}, [])
|
||||
|
||||
const close = useCallback(
|
||||
(action?: QuickPanelCloseAction) => {
|
||||
(action?: QuickPanelCloseAction, searchText?: string) => {
|
||||
setIsVisible(false)
|
||||
onClose?.({ symbol, action })
|
||||
onClose?.({ symbol, action, triggerInfo, searchText, item: {} as QuickPanelListItem, multiple: false })
|
||||
|
||||
clearTimer.current = setTimeout(() => {
|
||||
setList([])
|
||||
@ -64,9 +65,10 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
setAfterAction(undefined)
|
||||
setTitle(undefined)
|
||||
setSymbol('')
|
||||
setTriggerInfo(undefined)
|
||||
}, 200)
|
||||
},
|
||||
[onClose, symbol]
|
||||
[onClose, symbol, triggerInfo]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@ -92,6 +94,7 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
defaultIndex,
|
||||
pageSize,
|
||||
multiple,
|
||||
triggerInfo,
|
||||
onClose,
|
||||
beforeAction,
|
||||
afterAction
|
||||
@ -107,6 +110,7 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
defaultIndex,
|
||||
pageSize,
|
||||
multiple,
|
||||
triggerInfo,
|
||||
onClose,
|
||||
beforeAction,
|
||||
afterAction
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
import React from 'react'
|
||||
|
||||
export type QuickPanelCloseAction = 'enter' | 'click' | 'esc' | 'outsideclick' | 'enter_empty' | string | undefined
|
||||
export type QuickPanelTriggerInfo = {
|
||||
type: 'input' | 'button'
|
||||
position?: number
|
||||
originalText?: string
|
||||
}
|
||||
|
||||
export type QuickPanelCallBackOptions = {
|
||||
symbol: string
|
||||
action: QuickPanelCloseAction
|
||||
@ -8,6 +14,7 @@ export type QuickPanelCallBackOptions = {
|
||||
searchText?: string
|
||||
/** 是否处于多选状态 */
|
||||
multiple?: boolean
|
||||
triggerInfo?: QuickPanelTriggerInfo
|
||||
}
|
||||
|
||||
export type QuickPanelOpenOptions = {
|
||||
@ -26,6 +33,8 @@ export type QuickPanelOpenOptions = {
|
||||
* 可以是/@#符号,也可以是其他字符串
|
||||
*/
|
||||
symbol: string
|
||||
/** 触发信息,记录面板是如何被打开的 */
|
||||
triggerInfo?: QuickPanelTriggerInfo
|
||||
beforeAction?: (options: QuickPanelCallBackOptions) => void
|
||||
afterAction?: (options: QuickPanelCallBackOptions) => void
|
||||
onClose?: (options: QuickPanelCallBackOptions) => void
|
||||
@ -51,7 +60,7 @@ export type QuickPanelListItem = {
|
||||
// 定义上下文类型
|
||||
export interface QuickPanelContextType {
|
||||
readonly open: (options: QuickPanelOpenOptions) => void
|
||||
readonly close: (action?: QuickPanelCloseAction) => void
|
||||
readonly close: (action?: QuickPanelCloseAction, searchText?: string) => void
|
||||
readonly updateItemSelection: (targetItem: QuickPanelListItem, isSelected: boolean) => void
|
||||
readonly isVisible: boolean
|
||||
readonly symbol: string
|
||||
@ -60,6 +69,7 @@ export interface QuickPanelContextType {
|
||||
readonly defaultIndex: number
|
||||
readonly pageSize: number
|
||||
readonly multiple: boolean
|
||||
readonly triggerInfo?: QuickPanelTriggerInfo
|
||||
|
||||
readonly onClose?: (Options: QuickPanelCallBackOptions) => void
|
||||
readonly beforeAction?: (Options: QuickPanelCallBackOptions) => void
|
||||
|
||||
@ -204,7 +204,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
|
||||
const handleClose = useCallback(
|
||||
(action?: QuickPanelCloseAction) => {
|
||||
ctx.close(action)
|
||||
// 传递 searchText 给 close 函数,去掉第一个字符(@ 或 /)
|
||||
const cleanSearchText = searchText.length > 1 ? searchText.slice(1) : ''
|
||||
ctx.close(action, cleanSearchText)
|
||||
setHistoryPanel([])
|
||||
scrollTriggerRef.current = 'initial'
|
||||
|
||||
@ -217,7 +219,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
clearSearchText(true)
|
||||
}
|
||||
},
|
||||
[ctx, clearSearchText, setInputText]
|
||||
[ctx, clearSearchText, setInputText, searchText]
|
||||
)
|
||||
|
||||
const handleItemAction = useCallback(
|
||||
|
||||
@ -530,7 +530,11 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
}
|
||||
|
||||
if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '@') {
|
||||
inputbarToolsRef.current?.openMentionModelsPanel()
|
||||
inputbarToolsRef.current?.openMentionModelsPanel({
|
||||
type: 'input',
|
||||
position: cursorPosition - 1,
|
||||
originalText: newText
|
||||
})
|
||||
}
|
||||
},
|
||||
[enableQuickPanelTriggers, quickPanel, t, files, couldAddImageFile, openSelectFileMenu, translate]
|
||||
|
||||
@ -49,7 +49,7 @@ export interface InputbarToolsRef {
|
||||
openSelectFileMenu: () => void
|
||||
translate: () => void
|
||||
}) => QuickPanelListItem[]
|
||||
openMentionModelsPanel: () => void
|
||||
openMentionModelsPanel: (triggerInfo?: { type: 'input' | 'button'; position?: number; originalText?: string }) => void
|
||||
openAttachmentQuickPanel: () => void
|
||||
}
|
||||
|
||||
@ -292,7 +292,7 @@ const InputbarTools = ({
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getQuickPanelMenu: getQuickPanelMenuImpl,
|
||||
openMentionModelsPanel: () => mentionModelsButtonRef.current?.openQuickPanel(),
|
||||
openMentionModelsPanel: (triggerInfo) => mentionModelsButtonRef.current?.openQuickPanel(triggerInfo),
|
||||
openAttachmentQuickPanel: () => attachmentButtonRef.current?.openQuickPanel()
|
||||
}))
|
||||
|
||||
|
||||
@ -17,7 +17,7 @@ import { useNavigate } from 'react-router'
|
||||
import styled from 'styled-components'
|
||||
|
||||
export interface MentionModelsButtonRef {
|
||||
openQuickPanel: () => void
|
||||
openQuickPanel: (triggerInfo?: { type: 'input' | 'button'; position?: number; originalText?: string }) => void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@ -137,42 +137,67 @@ const MentionModelsButton: FC<Props> = ({
|
||||
return items
|
||||
}, [pinnedModels, providers, t, couldMentionNotVisionModel, mentionedModels, onMentionModel, navigate])
|
||||
|
||||
const openQuickPanel = useCallback(() => {
|
||||
// 重置模型动作标记
|
||||
hasModelActionRef.current = false
|
||||
const openQuickPanel = useCallback(
|
||||
(triggerInfo?: { type: 'input' | 'button'; position?: number; originalText?: string }) => {
|
||||
// 重置模型动作标记
|
||||
hasModelActionRef.current = false
|
||||
|
||||
quickPanel.open({
|
||||
title: t('agents.edit.model.select.title'),
|
||||
list: modelItems,
|
||||
symbol: '@',
|
||||
multiple: true,
|
||||
afterAction({ item }) {
|
||||
item.isSelected = !item.isSelected
|
||||
},
|
||||
onClose({ action }) {
|
||||
// ESC或Backspace关闭时的特殊处理
|
||||
if (action === 'esc' || action === 'delete-symbol') {
|
||||
// 如果有模型选择动作发生,删除@字符
|
||||
if (hasModelActionRef.current) {
|
||||
// 使用React的setText来更新状态,而不是直接操作DOM
|
||||
setText((currentText) => {
|
||||
const lastAtIndex = currentText.lastIndexOf('@')
|
||||
if (lastAtIndex !== -1) {
|
||||
return currentText.slice(0, lastAtIndex) + currentText.slice(lastAtIndex + 1)
|
||||
}
|
||||
return currentText
|
||||
})
|
||||
quickPanel.open({
|
||||
title: t('agents.edit.model.select.title'),
|
||||
list: modelItems,
|
||||
symbol: '@',
|
||||
multiple: true,
|
||||
triggerInfo: triggerInfo || { type: 'button' },
|
||||
afterAction({ item }) {
|
||||
item.isSelected = !item.isSelected
|
||||
},
|
||||
onClose({ action, triggerInfo: closeTriggerInfo, searchText }) {
|
||||
// ESC关闭时的处理:删除 @ 和搜索文本
|
||||
if (action === 'esc') {
|
||||
// 只有在输入触发且有模型选择动作时才删除@字符和搜索文本
|
||||
if (
|
||||
hasModelActionRef.current &&
|
||||
closeTriggerInfo?.type === 'input' &&
|
||||
closeTriggerInfo?.position !== undefined
|
||||
) {
|
||||
// 使用React的setText来更新状态
|
||||
setText((currentText) => {
|
||||
const position = closeTriggerInfo.position!
|
||||
// 验证位置的字符是否仍是 @
|
||||
if (currentText[position] !== '@') {
|
||||
return currentText
|
||||
}
|
||||
|
||||
// 计算删除范围:@ + searchText
|
||||
const deleteLength = 1 + (searchText?.length || 0)
|
||||
|
||||
// 验证要删除的内容是否匹配预期
|
||||
const expectedText = '@' + (searchText || '')
|
||||
const actualText = currentText.slice(position, position + deleteLength)
|
||||
|
||||
if (actualText !== expectedText) {
|
||||
// 如果实际文本不匹配,只删除 @ 字符
|
||||
return currentText.slice(0, position) + currentText.slice(position + 1)
|
||||
}
|
||||
|
||||
// 删除 @ 和搜索文本
|
||||
return currentText.slice(0, position) + currentText.slice(position + deleteLength)
|
||||
})
|
||||
}
|
||||
}
|
||||
// Backspace删除@的情况(delete-symbol):
|
||||
// @ 已经被Backspace自然删除,面板关闭,不需要额外操作
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [modelItems, quickPanel, t, setText])
|
||||
})
|
||||
},
|
||||
[modelItems, quickPanel, t, setText]
|
||||
)
|
||||
|
||||
const handleOpenQuickPanel = useCallback(() => {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === '@') {
|
||||
quickPanel.close()
|
||||
} else {
|
||||
openQuickPanel()
|
||||
openQuickPanel({ type: 'button' })
|
||||
}
|
||||
}, [openQuickPanel, quickPanel])
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user