mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
fix(desktop): restore chat selection references (#351)
Chat selection references could miss drag gestures that ended outside the message bubble after the Electron migration. The chat transcript now tracks selection gestures at the document pointer-up boundary and repositions the shared selection action near the selected text with above-then-right placement. Constraint: Electron/WebView selection ranges are not always stable during the message-local mouseup event Rejected: Add a new selection popover system | the existing shared selection hook already covers chat and workspace dismissal behavior Confidence: high Scope-risk: narrow Directive: Keep chat and workspace selection popovers on the shared positioning/dismissal hook unless their behavior intentionally diverges Tested: bun run test -- src/components/chat/MessageList.test.tsx -t "selected-message action" Tested: bun run test -- src/components/chat/MessageList.test.tsx Tested: bun run check:desktop Tested: in-app browser smoke at http://127.0.0.1:5181/ with zero console errors Not-tested: Packaged Electron manual drag selection smoke Related: #351
This commit is contained in:
parent
959e177cdf
commit
358b2c50be
@ -57,7 +57,20 @@ function findTextNodeContaining(container: Element, text: string) {
|
||||
throw new Error(`Unable to find text node containing ${text}`)
|
||||
}
|
||||
|
||||
async function selectMessageText(element: Element, text: string) {
|
||||
async function waitForSelectionMenuUpdate() {
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => resolve())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function prepareMessageTextSelection(
|
||||
element: Element,
|
||||
text: string,
|
||||
rect: Partial<DOMRect> = {},
|
||||
) {
|
||||
const textNode = findTextNodeContaining(element, text)
|
||||
const startOffset = textNode.textContent?.indexOf(text) ?? -1
|
||||
const range = document.createRange()
|
||||
@ -65,14 +78,14 @@ async function selectMessageText(element: Element, text: string) {
|
||||
range.setEnd(textNode, startOffset + text.length)
|
||||
Object.assign(range, {
|
||||
getBoundingClientRect: () => ({
|
||||
left: 160,
|
||||
top: 80,
|
||||
right: 280,
|
||||
bottom: 98,
|
||||
width: 120,
|
||||
height: 18,
|
||||
x: 160,
|
||||
y: 80,
|
||||
left: rect.left ?? 160,
|
||||
top: rect.top ?? 80,
|
||||
right: rect.right ?? 280,
|
||||
bottom: rect.bottom ?? 98,
|
||||
width: rect.width ?? 120,
|
||||
height: rect.height ?? 18,
|
||||
x: rect.x ?? rect.left ?? 160,
|
||||
y: rect.y ?? rect.top ?? 80,
|
||||
toJSON: () => ({}),
|
||||
}),
|
||||
})
|
||||
@ -95,10 +108,21 @@ async function selectMessageText(element: Element, text: string) {
|
||||
window.getSelection()?.removeAllRanges()
|
||||
window.getSelection()?.addRange(range)
|
||||
|
||||
return selectableRoot ?? element
|
||||
}
|
||||
|
||||
async function selectMessageText(
|
||||
element: Element,
|
||||
text: string,
|
||||
rect: Partial<DOMRect> = {},
|
||||
) {
|
||||
prepareMessageTextSelection(element, text, rect)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.mouseUp(element, { clientX: 260, clientY: 104 })
|
||||
await Promise.resolve()
|
||||
})
|
||||
await waitForSelectionMenuUpdate()
|
||||
}
|
||||
|
||||
describe('MessageList nested tool calls', () => {
|
||||
@ -1654,6 +1678,85 @@ describe('MessageList nested tool calls', () => {
|
||||
expect(window.getSelection()?.toString()).toBe('')
|
||||
})
|
||||
|
||||
it('shows the selected-message action when text selection ends outside the message', async () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [{
|
||||
id: 'assistant-1',
|
||||
type: 'assistant_text',
|
||||
content: 'Drag selection gestures can finish outside the message bubble.',
|
||||
timestamp: 1,
|
||||
}],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
render(<MessageList />)
|
||||
|
||||
const assistantText = screen.getByText(/Drag selection gestures/)
|
||||
prepareMessageTextSelection(assistantText, 'selection gestures')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.pointerDown(assistantText, {
|
||||
button: 0,
|
||||
clientX: 172,
|
||||
clientY: 88,
|
||||
pointerId: 1,
|
||||
pointerType: 'mouse',
|
||||
})
|
||||
fireEvent.pointerMove(document.body, {
|
||||
clientX: 640,
|
||||
clientY: 120,
|
||||
pointerId: 1,
|
||||
pointerType: 'mouse',
|
||||
})
|
||||
fireEvent.pointerUp(document.body, {
|
||||
clientX: 640,
|
||||
clientY: 120,
|
||||
pointerId: 1,
|
||||
pointerType: 'mouse',
|
||||
})
|
||||
await Promise.resolve()
|
||||
})
|
||||
await waitForSelectionMenuUpdate()
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Add to chat' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('places the selected-message action to the right when there is no room above', async () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [{
|
||||
id: 'assistant-1',
|
||||
type: 'assistant_text',
|
||||
content: 'Top edge selections need a nearby right-side action.',
|
||||
timestamp: 1,
|
||||
}],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
render(<MessageList />)
|
||||
|
||||
const assistantText = screen.getByText(/Top edge selections/)
|
||||
await selectMessageText(assistantText, 'right-side action', {
|
||||
left: 160,
|
||||
top: 18,
|
||||
right: 280,
|
||||
bottom: 36,
|
||||
width: 120,
|
||||
height: 18,
|
||||
x: 160,
|
||||
y: 18,
|
||||
})
|
||||
const floatingAddButton = screen.getByRole('button', { name: 'Add to chat' })
|
||||
|
||||
expect(floatingAddButton.style.left).toBe('290px')
|
||||
expect(floatingAddButton.style.top).toBe('12px')
|
||||
})
|
||||
|
||||
it('adds selected assistant reply text to the composer context', async () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
|
||||
@ -75,6 +75,11 @@ type ChatSelectionState = {
|
||||
y: number
|
||||
}
|
||||
|
||||
type SelectionPointer = {
|
||||
clientX: number
|
||||
clientY: number
|
||||
}
|
||||
|
||||
const CHAT_SELECTION_MENU_OFFSET = 10
|
||||
const CHAT_SELECTION_MENU_WIDTH = 158
|
||||
const CHAT_SELECTION_MENU_HEIGHT = 44
|
||||
@ -95,7 +100,7 @@ function getChatSelectionPosition(range: Range, root: HTMLElement, pointer: { cl
|
||||
|
||||
function getChatSelectionFromContainer(
|
||||
root: HTMLElement | null,
|
||||
pointer: { clientX: number; clientY: number },
|
||||
pointer: SelectionPointer,
|
||||
): ChatSelectionState | null {
|
||||
if (!root) return null
|
||||
const selection = window.getSelection()
|
||||
@ -117,6 +122,13 @@ function getChatSelectionFromContainer(
|
||||
}
|
||||
}
|
||||
|
||||
function getSelectionPointer(event: SelectionPointer): SelectionPointer {
|
||||
return {
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
}
|
||||
}
|
||||
|
||||
function ChatSelectionMenu({
|
||||
selection,
|
||||
onAdd,
|
||||
@ -388,6 +400,8 @@ function SelectableChatMessage({
|
||||
}) {
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const selectionMenuRef = useRef<HTMLButtonElement>(null)
|
||||
const selectionStartedInsideRef = useRef(false)
|
||||
const selectionUpdateFrameRef = useRef<number | null>(null)
|
||||
const addReference = useWorkspaceChatContextStore((state) => state.addReference)
|
||||
const [selectionMenu, setSelectionMenu] = useState<ChatSelectionState | null>(null)
|
||||
const t = useTranslation()
|
||||
@ -397,12 +411,51 @@ function SelectableChatMessage({
|
||||
|
||||
useEffect(() => {
|
||||
setSelectionMenu(null)
|
||||
selectionStartedInsideRef.current = false
|
||||
}, [content, messageId])
|
||||
|
||||
const dismissSelectionMenu = useCallback(() => {
|
||||
setSelectionMenu(null)
|
||||
}, [])
|
||||
|
||||
const queueSelectionMenuUpdate = useCallback((pointer: SelectionPointer) => {
|
||||
if (selectionUpdateFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(selectionUpdateFrameRef.current)
|
||||
}
|
||||
|
||||
selectionUpdateFrameRef.current = window.requestAnimationFrame(() => {
|
||||
selectionUpdateFrameRef.current = null
|
||||
setSelectionMenu(getChatSelectionFromContainer(rootRef.current, pointer))
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (selectionUpdateFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(selectionUpdateFrameRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerUp = (event: PointerEvent) => {
|
||||
if (!selectionStartedInsideRef.current) return
|
||||
selectionStartedInsideRef.current = false
|
||||
queueSelectionMenuUpdate(getSelectionPointer(event))
|
||||
}
|
||||
|
||||
const handlePointerCancel = () => {
|
||||
selectionStartedInsideRef.current = false
|
||||
}
|
||||
|
||||
document.addEventListener('pointerup', handlePointerUp, true)
|
||||
document.addEventListener('pointercancel', handlePointerCancel, true)
|
||||
return () => {
|
||||
document.removeEventListener('pointerup', handlePointerUp, true)
|
||||
document.removeEventListener('pointercancel', handlePointerCancel, true)
|
||||
}
|
||||
}, [queueSelectionMenuUpdate])
|
||||
|
||||
useSelectionPopoverDismiss({
|
||||
active: Boolean(selectionMenu),
|
||||
popoverRef: selectionMenuRef,
|
||||
@ -426,8 +479,13 @@ function SelectableChatMessage({
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
onPointerDown={(event) => {
|
||||
if (event.pointerType === 'mouse' && event.button !== 0) return
|
||||
selectionStartedInsideRef.current = true
|
||||
}}
|
||||
onMouseUp={(event) => {
|
||||
setSelectionMenu(getChatSelectionFromContainer(rootRef.current, event))
|
||||
selectionStartedInsideRef.current = false
|
||||
queueSelectionMenuUpdate(getSelectionPointer(event))
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') setSelectionMenu(null)
|
||||
|
||||
@ -6,10 +6,48 @@ type ElementRef = {
|
||||
|
||||
const VIEWPORT_MARGIN = 12
|
||||
|
||||
type SelectionRect = {
|
||||
left: number
|
||||
top: number
|
||||
right: number
|
||||
bottom: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
function clampValue(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(value, max))
|
||||
}
|
||||
|
||||
function isUsableRect(rect: SelectionRect | DOMRect) {
|
||||
return rect.width > 0 || rect.height > 0
|
||||
}
|
||||
|
||||
function getRangeSelectionRect(range: Range): SelectionRect | null {
|
||||
const boundingRect = typeof range.getBoundingClientRect === 'function'
|
||||
? range.getBoundingClientRect()
|
||||
: null
|
||||
if (boundingRect && isUsableRect(boundingRect)) return boundingRect
|
||||
|
||||
const clientRects = typeof range.getClientRects === 'function'
|
||||
? Array.from(range.getClientRects()).filter(isUsableRect)
|
||||
: []
|
||||
if (clientRects.length === 0) return null
|
||||
|
||||
const left = Math.min(...clientRects.map((rect) => rect.left))
|
||||
const top = Math.min(...clientRects.map((rect) => rect.top))
|
||||
const right = Math.max(...clientRects.map((rect) => rect.right))
|
||||
const bottom = Math.max(...clientRects.map((rect) => rect.bottom))
|
||||
return {
|
||||
left,
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
width: right - left,
|
||||
height: bottom - top,
|
||||
}
|
||||
}
|
||||
|
||||
export function clearWindowSelection() {
|
||||
window.getSelection()?.removeAllRanges()
|
||||
}
|
||||
@ -29,39 +67,54 @@ export function getSelectionPopoverPosition(
|
||||
fallbackPointer?: { clientX: number; clientY: number }
|
||||
},
|
||||
) {
|
||||
const rect = typeof range.getBoundingClientRect === 'function'
|
||||
? range.getBoundingClientRect()
|
||||
: null
|
||||
const rect = getRangeSelectionRect(range)
|
||||
const rootRect = root.getBoundingClientRect()
|
||||
const hasUsableRangeRect = Boolean(rect && (rect.width > 0 || rect.height > 0))
|
||||
const pointerInsideRoot = fallbackPointer
|
||||
&& fallbackPointer.clientX >= rootRect.left
|
||||
&& fallbackPointer.clientX <= rootRect.right
|
||||
&& fallbackPointer.clientY >= rootRect.top
|
||||
&& fallbackPointer.clientY <= rootRect.bottom
|
||||
const fallbackLeft = pointerInsideRoot ? fallbackPointer.clientX - menuWidth / 2 : rootRect.left + 24
|
||||
const fallbackTop = pointerInsideRoot ? fallbackPointer.clientY : rootRect.top + 24
|
||||
const selectionLeft = hasUsableRangeRect ? rect!.left : fallbackLeft
|
||||
const selectionRight = hasUsableRangeRect ? rect!.right : selectionLeft + menuWidth
|
||||
const selectionTop = hasUsableRangeRect ? rect!.top : fallbackTop
|
||||
const selectionBottom = hasUsableRangeRect ? rect!.bottom : selectionTop + menuHeight
|
||||
const fallbackX = pointerInsideRoot ? fallbackPointer.clientX : rootRect.left + 24
|
||||
const fallbackY = pointerInsideRoot ? fallbackPointer.clientY : rootRect.top + 24
|
||||
const selectionRect = rect ?? {
|
||||
left: fallbackX,
|
||||
top: fallbackY,
|
||||
right: fallbackX,
|
||||
bottom: fallbackY,
|
||||
width: 0,
|
||||
height: 0,
|
||||
}
|
||||
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || rootRect.right + VIEWPORT_MARGIN
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || rootRect.bottom + VIEWPORT_MARGIN
|
||||
const minX = VIEWPORT_MARGIN
|
||||
const maxX = Math.max(minX, viewportWidth - menuWidth - VIEWPORT_MARGIN)
|
||||
const minY = VIEWPORT_MARGIN
|
||||
const maxY = Math.max(minY, viewportHeight - menuHeight - VIEWPORT_MARGIN)
|
||||
const aboveY = selectionTop - menuHeight - offset
|
||||
const belowY = selectionBottom + offset
|
||||
const y = aboveY >= VIEWPORT_MARGIN || belowY + menuHeight > viewportHeight - VIEWPORT_MARGIN
|
||||
? aboveY
|
||||
: belowY
|
||||
const centerX = selectionLeft + (selectionRight - selectionLeft) / 2
|
||||
|
||||
return {
|
||||
x: clampValue(centerX - menuWidth / 2, minX, maxX),
|
||||
y: clampValue(y, minY, maxY),
|
||||
const clampPosition = (position: { x: number; y: number }) => ({
|
||||
x: clampValue(position.x, minX, maxX),
|
||||
y: clampValue(position.y, minY, maxY),
|
||||
})
|
||||
const centerX = selectionRect.left + selectionRect.width / 2
|
||||
const centerY = selectionRect.top + selectionRect.height / 2
|
||||
const above = {
|
||||
x: centerX - menuWidth / 2,
|
||||
y: selectionRect.top - menuHeight - offset,
|
||||
}
|
||||
if (above.y >= VIEWPORT_MARGIN) return clampPosition(above)
|
||||
|
||||
const right = {
|
||||
x: selectionRect.right + offset,
|
||||
y: centerY - menuHeight / 2,
|
||||
}
|
||||
if (right.x + menuWidth <= viewportWidth - VIEWPORT_MARGIN) return clampPosition(right)
|
||||
|
||||
const below = {
|
||||
x: centerX - menuWidth / 2,
|
||||
y: selectionRect.bottom + offset,
|
||||
}
|
||||
if (below.y + menuHeight <= viewportHeight - VIEWPORT_MARGIN) return clampPosition(below)
|
||||
|
||||
return clampPosition(above)
|
||||
}
|
||||
|
||||
export function useSelectionPopoverDismiss({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user