mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-28 15:53:37 +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}`)
|
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 textNode = findTextNodeContaining(element, text)
|
||||||
const startOffset = textNode.textContent?.indexOf(text) ?? -1
|
const startOffset = textNode.textContent?.indexOf(text) ?? -1
|
||||||
const range = document.createRange()
|
const range = document.createRange()
|
||||||
@ -65,14 +78,14 @@ async function selectMessageText(element: Element, text: string) {
|
|||||||
range.setEnd(textNode, startOffset + text.length)
|
range.setEnd(textNode, startOffset + text.length)
|
||||||
Object.assign(range, {
|
Object.assign(range, {
|
||||||
getBoundingClientRect: () => ({
|
getBoundingClientRect: () => ({
|
||||||
left: 160,
|
left: rect.left ?? 160,
|
||||||
top: 80,
|
top: rect.top ?? 80,
|
||||||
right: 280,
|
right: rect.right ?? 280,
|
||||||
bottom: 98,
|
bottom: rect.bottom ?? 98,
|
||||||
width: 120,
|
width: rect.width ?? 120,
|
||||||
height: 18,
|
height: rect.height ?? 18,
|
||||||
x: 160,
|
x: rect.x ?? rect.left ?? 160,
|
||||||
y: 80,
|
y: rect.y ?? rect.top ?? 80,
|
||||||
toJSON: () => ({}),
|
toJSON: () => ({}),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@ -95,10 +108,21 @@ async function selectMessageText(element: Element, text: string) {
|
|||||||
window.getSelection()?.removeAllRanges()
|
window.getSelection()?.removeAllRanges()
|
||||||
window.getSelection()?.addRange(range)
|
window.getSelection()?.addRange(range)
|
||||||
|
|
||||||
|
return selectableRoot ?? element
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectMessageText(
|
||||||
|
element: Element,
|
||||||
|
text: string,
|
||||||
|
rect: Partial<DOMRect> = {},
|
||||||
|
) {
|
||||||
|
prepareMessageTextSelection(element, text, rect)
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.mouseUp(element, { clientX: 260, clientY: 104 })
|
fireEvent.mouseUp(element, { clientX: 260, clientY: 104 })
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
})
|
})
|
||||||
|
await waitForSelectionMenuUpdate()
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('MessageList nested tool calls', () => {
|
describe('MessageList nested tool calls', () => {
|
||||||
@ -1654,6 +1678,85 @@ describe('MessageList nested tool calls', () => {
|
|||||||
expect(window.getSelection()?.toString()).toBe('')
|
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 () => {
|
it('adds selected assistant reply text to the composer context', async () => {
|
||||||
useChatStore.setState({
|
useChatStore.setState({
|
||||||
sessions: {
|
sessions: {
|
||||||
|
|||||||
@ -75,6 +75,11 @@ type ChatSelectionState = {
|
|||||||
y: number
|
y: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SelectionPointer = {
|
||||||
|
clientX: number
|
||||||
|
clientY: number
|
||||||
|
}
|
||||||
|
|
||||||
const CHAT_SELECTION_MENU_OFFSET = 10
|
const CHAT_SELECTION_MENU_OFFSET = 10
|
||||||
const CHAT_SELECTION_MENU_WIDTH = 158
|
const CHAT_SELECTION_MENU_WIDTH = 158
|
||||||
const CHAT_SELECTION_MENU_HEIGHT = 44
|
const CHAT_SELECTION_MENU_HEIGHT = 44
|
||||||
@ -95,7 +100,7 @@ function getChatSelectionPosition(range: Range, root: HTMLElement, pointer: { cl
|
|||||||
|
|
||||||
function getChatSelectionFromContainer(
|
function getChatSelectionFromContainer(
|
||||||
root: HTMLElement | null,
|
root: HTMLElement | null,
|
||||||
pointer: { clientX: number; clientY: number },
|
pointer: SelectionPointer,
|
||||||
): ChatSelectionState | null {
|
): ChatSelectionState | null {
|
||||||
if (!root) return null
|
if (!root) return null
|
||||||
const selection = window.getSelection()
|
const selection = window.getSelection()
|
||||||
@ -117,6 +122,13 @@ function getChatSelectionFromContainer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSelectionPointer(event: SelectionPointer): SelectionPointer {
|
||||||
|
return {
|
||||||
|
clientX: event.clientX,
|
||||||
|
clientY: event.clientY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function ChatSelectionMenu({
|
function ChatSelectionMenu({
|
||||||
selection,
|
selection,
|
||||||
onAdd,
|
onAdd,
|
||||||
@ -388,6 +400,8 @@ function SelectableChatMessage({
|
|||||||
}) {
|
}) {
|
||||||
const rootRef = useRef<HTMLDivElement>(null)
|
const rootRef = useRef<HTMLDivElement>(null)
|
||||||
const selectionMenuRef = useRef<HTMLButtonElement>(null)
|
const selectionMenuRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const selectionStartedInsideRef = useRef(false)
|
||||||
|
const selectionUpdateFrameRef = useRef<number | null>(null)
|
||||||
const addReference = useWorkspaceChatContextStore((state) => state.addReference)
|
const addReference = useWorkspaceChatContextStore((state) => state.addReference)
|
||||||
const [selectionMenu, setSelectionMenu] = useState<ChatSelectionState | null>(null)
|
const [selectionMenu, setSelectionMenu] = useState<ChatSelectionState | null>(null)
|
||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
@ -397,12 +411,51 @@ function SelectableChatMessage({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectionMenu(null)
|
setSelectionMenu(null)
|
||||||
|
selectionStartedInsideRef.current = false
|
||||||
}, [content, messageId])
|
}, [content, messageId])
|
||||||
|
|
||||||
const dismissSelectionMenu = useCallback(() => {
|
const dismissSelectionMenu = useCallback(() => {
|
||||||
setSelectionMenu(null)
|
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({
|
useSelectionPopoverDismiss({
|
||||||
active: Boolean(selectionMenu),
|
active: Boolean(selectionMenu),
|
||||||
popoverRef: selectionMenuRef,
|
popoverRef: selectionMenuRef,
|
||||||
@ -426,8 +479,13 @@ function SelectableChatMessage({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
if (event.pointerType === 'mouse' && event.button !== 0) return
|
||||||
|
selectionStartedInsideRef.current = true
|
||||||
|
}}
|
||||||
onMouseUp={(event) => {
|
onMouseUp={(event) => {
|
||||||
setSelectionMenu(getChatSelectionFromContainer(rootRef.current, event))
|
selectionStartedInsideRef.current = false
|
||||||
|
queueSelectionMenuUpdate(getSelectionPointer(event))
|
||||||
}}
|
}}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === 'Escape') setSelectionMenu(null)
|
if (event.key === 'Escape') setSelectionMenu(null)
|
||||||
|
|||||||
@ -6,10 +6,48 @@ type ElementRef = {
|
|||||||
|
|
||||||
const VIEWPORT_MARGIN = 12
|
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) {
|
function clampValue(value: number, min: number, max: number) {
|
||||||
return Math.max(min, Math.min(value, max))
|
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() {
|
export function clearWindowSelection() {
|
||||||
window.getSelection()?.removeAllRanges()
|
window.getSelection()?.removeAllRanges()
|
||||||
}
|
}
|
||||||
@ -29,39 +67,54 @@ export function getSelectionPopoverPosition(
|
|||||||
fallbackPointer?: { clientX: number; clientY: number }
|
fallbackPointer?: { clientX: number; clientY: number }
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const rect = typeof range.getBoundingClientRect === 'function'
|
const rect = getRangeSelectionRect(range)
|
||||||
? range.getBoundingClientRect()
|
|
||||||
: null
|
|
||||||
const rootRect = root.getBoundingClientRect()
|
const rootRect = root.getBoundingClientRect()
|
||||||
const hasUsableRangeRect = Boolean(rect && (rect.width > 0 || rect.height > 0))
|
|
||||||
const pointerInsideRoot = fallbackPointer
|
const pointerInsideRoot = fallbackPointer
|
||||||
&& fallbackPointer.clientX >= rootRect.left
|
&& fallbackPointer.clientX >= rootRect.left
|
||||||
&& fallbackPointer.clientX <= rootRect.right
|
&& fallbackPointer.clientX <= rootRect.right
|
||||||
&& fallbackPointer.clientY >= rootRect.top
|
&& fallbackPointer.clientY >= rootRect.top
|
||||||
&& fallbackPointer.clientY <= rootRect.bottom
|
&& fallbackPointer.clientY <= rootRect.bottom
|
||||||
const fallbackLeft = pointerInsideRoot ? fallbackPointer.clientX - menuWidth / 2 : rootRect.left + 24
|
const fallbackX = pointerInsideRoot ? fallbackPointer.clientX : rootRect.left + 24
|
||||||
const fallbackTop = pointerInsideRoot ? fallbackPointer.clientY : rootRect.top + 24
|
const fallbackY = pointerInsideRoot ? fallbackPointer.clientY : rootRect.top + 24
|
||||||
const selectionLeft = hasUsableRangeRect ? rect!.left : fallbackLeft
|
const selectionRect = rect ?? {
|
||||||
const selectionRight = hasUsableRangeRect ? rect!.right : selectionLeft + menuWidth
|
left: fallbackX,
|
||||||
const selectionTop = hasUsableRangeRect ? rect!.top : fallbackTop
|
top: fallbackY,
|
||||||
const selectionBottom = hasUsableRangeRect ? rect!.bottom : selectionTop + menuHeight
|
right: fallbackX,
|
||||||
|
bottom: fallbackY,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
}
|
||||||
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || rootRect.right + VIEWPORT_MARGIN
|
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || rootRect.right + VIEWPORT_MARGIN
|
||||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || rootRect.bottom + VIEWPORT_MARGIN
|
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || rootRect.bottom + VIEWPORT_MARGIN
|
||||||
const minX = VIEWPORT_MARGIN
|
const minX = VIEWPORT_MARGIN
|
||||||
const maxX = Math.max(minX, viewportWidth - menuWidth - VIEWPORT_MARGIN)
|
const maxX = Math.max(minX, viewportWidth - menuWidth - VIEWPORT_MARGIN)
|
||||||
const minY = VIEWPORT_MARGIN
|
const minY = VIEWPORT_MARGIN
|
||||||
const maxY = Math.max(minY, viewportHeight - menuHeight - VIEWPORT_MARGIN)
|
const maxY = Math.max(minY, viewportHeight - menuHeight - VIEWPORT_MARGIN)
|
||||||
const aboveY = selectionTop - menuHeight - offset
|
const clampPosition = (position: { x: number; y: number }) => ({
|
||||||
const belowY = selectionBottom + offset
|
x: clampValue(position.x, minX, maxX),
|
||||||
const y = aboveY >= VIEWPORT_MARGIN || belowY + menuHeight > viewportHeight - VIEWPORT_MARGIN
|
y: clampValue(position.y, minY, maxY),
|
||||||
? aboveY
|
})
|
||||||
: belowY
|
const centerX = selectionRect.left + selectionRect.width / 2
|
||||||
const centerX = selectionLeft + (selectionRight - selectionLeft) / 2
|
const centerY = selectionRect.top + selectionRect.height / 2
|
||||||
|
const above = {
|
||||||
return {
|
x: centerX - menuWidth / 2,
|
||||||
x: clampValue(centerX - menuWidth / 2, minX, maxX),
|
y: selectionRect.top - menuHeight - offset,
|
||||||
y: clampValue(y, minY, maxY),
|
|
||||||
}
|
}
|
||||||
|
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({
|
export function useSelectionPopoverDismiss({
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user