Make selection actions track selected text instead of the cursor

The add-to-chat popover was keyed off the mouse-up location, so broad selections could cover the selected text and stale popovers could remain visible. Shared placement and dismissal logic now anchors the action to the selected range, prefers the space above the selection like Codex App, and clears stale selections on outside clicks.

Constraint: Desktop chat and workspace previews share the same selection affordance and must keep their existing add-reference behavior.

Rejected: Keep cursor-based placement | it can hide selected text and makes multi-line selections unpredictable.

Confidence: high

Scope-risk: moderate

Directive: Keep future selection actions range-anchored and outside-click dismissible across chat and workspace surfaces.

Tested: bun run check:desktop

Tested: Browser verification on http://127.0.0.1:5174 showed the popover above selection, no overlap, and outside click clearing it.

Not-tested: Native packaged Tauri window.

Related: https://github.com/NanmiCoder/cc-haha/issues/484
This commit is contained in:
程序员阿江(Relakkes) 2026-05-18 14:40:56 +08:00
parent 8ff5353455
commit 2b5bf56475
6 changed files with 304 additions and 84 deletions

View File

@ -905,8 +905,8 @@ describe('MessageList nested tool calls', () => {
await selectMessageText(userText, 'workspace selection behavior') await selectMessageText(userText, 'workspace selection behavior')
const floatingAddButton = screen.getByRole('button', { name: 'Add to chat' }) const floatingAddButton = screen.getByRole('button', { name: 'Add to chat' })
expect(floatingAddButton.style.left).toBe('260px') expect(floatingAddButton.style.left).toBe('141px')
expect(floatingAddButton.style.top).toBe('112px') expect(floatingAddButton.style.top).toBe('26px')
fireEvent.click(floatingAddButton) fireEvent.click(floatingAddButton)
@ -955,6 +955,80 @@ describe('MessageList nested tool calls', () => {
]) ])
}) })
it('dismisses the selected-message action when clicking outside the popover', async () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [{
id: 'assistant-1',
type: 'assistant_text',
content: 'Clicking outside should clear this selected reply.',
timestamp: 1,
}],
}),
},
})
render(<MessageList />)
const assistantText = screen.getByText(/Clicking outside should clear/)
await selectMessageText(assistantText, 'selected reply')
expect(screen.getByRole('button', { name: 'Add to chat' })).toBeTruthy()
await act(async () => {
fireEvent.pointerDown(document.body)
await Promise.resolve()
})
expect(screen.queryByRole('button', { name: 'Add to chat' })).toBeNull()
expect(window.getSelection()?.toString()).toBe('')
})
it('keeps only the latest selected-message action when selecting across messages', async () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [
{
id: 'assistant-1',
type: 'assistant_text',
content: 'First assistant reply can be selected.',
timestamp: 1,
},
{
id: 'assistant-2',
type: 'assistant_text',
content: 'Second assistant reply should replace it.',
timestamp: 2,
},
],
}),
},
})
render(<MessageList />)
const firstText = screen.getByText(/First assistant reply/)
const secondText = screen.getByText(/Second assistant reply/)
await selectMessageText(firstText, 'First assistant reply')
expect(screen.getAllByRole('button', { name: 'Add to chat' })).toHaveLength(1)
await act(async () => {
fireEvent.pointerDown(secondText)
await Promise.resolve()
})
await selectMessageText(secondText, 'Second assistant reply')
expect(screen.getAllByRole('button', { name: 'Add to chat' })).toHaveLength(1)
fireEvent.click(screen.getByRole('button', { name: 'Add to chat' }))
expect(useWorkspaceChatContextStore.getState().referencesBySession[ACTIVE_TAB]).toMatchObject([
{
messageId: 'assistant-2',
quote: 'Second assistant reply',
},
])
})
it('does not force-scroll to the bottom while the user is reading history', async () => { it('does not force-scroll to the bottom while the user is reading history', async () => {
const scrollIntoView = vi.fn() const scrollIntoView = vi.fn()
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {

View File

@ -1,5 +1,5 @@
import { useRef, useEffect, useMemo, memo, useState, useCallback, useLayoutEffect, type ReactNode } from 'react' import { useRef, useEffect, useMemo, memo, useState, useCallback, useLayoutEffect, type ReactNode } from 'react'
import { ArrowDown, BookMarked, Bot, CheckCircle2, ChevronDown, ChevronRight, CircleStop, LoaderCircle, Settings, Target, XCircle } from 'lucide-react' import { ArrowDown, BookMarked, Bot, CheckCircle2, ChevronDown, ChevronRight, CircleStop, LoaderCircle, MessageCircle, Settings, Target, XCircle } from 'lucide-react'
import { ApiError } from '../../api/client' import { ApiError } from '../../api/client'
import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions' import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions'
import { useChatStore } from '../../stores/chatStore' import { useChatStore } from '../../stores/chatStore'
@ -22,6 +22,7 @@ import { InlineTaskSummary } from './InlineTaskSummary'
import { CurrentTurnChangeCard } from './CurrentTurnChangeCard' import { CurrentTurnChangeCard } from './CurrentTurnChangeCard'
import type { AgentTaskNotification, UIMessage } from '../../types/chat' import type { AgentTaskNotification, UIMessage } from '../../types/chat'
import { ConfirmDialog } from '../shared/ConfirmDialog' import { ConfirmDialog } from '../shared/ConfirmDialog'
import { clearWindowSelection, getSelectionPopoverPosition, useSelectionPopoverDismiss } from '../../hooks/useSelectionPopoverDismiss'
type ToolCall = Extract<UIMessage, { type: 'tool_use' }> type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
type ToolResult = Extract<UIMessage, { type: 'tool_result' }> type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
@ -62,11 +63,9 @@ type ChatSelectionState = {
y: number y: number
} }
const CHAT_SELECTION_MENU_OFFSET = 8 const CHAT_SELECTION_MENU_OFFSET = 10
const CHAT_SELECTION_MENU_WIDTH = 158
function clampValue(value: number, min: number, max: number) { const CHAT_SELECTION_MENU_HEIGHT = 44
return Math.max(min, Math.min(value, max))
}
function getElementForNode(node: Node | null): Element | null { function getElementForNode(node: Node | null): Element | null {
if (!node) return null if (!node) return null
@ -74,32 +73,12 @@ function getElementForNode(node: Node | null): Element | null {
} }
function getChatSelectionPosition(range: Range, root: HTMLElement, pointer: { clientX: number; clientY: number }) { function getChatSelectionPosition(range: Range, root: HTMLElement, pointer: { clientX: number; clientY: number }) {
const rect = typeof range.getBoundingClientRect === 'function' return getSelectionPopoverPosition(range, root, {
? range.getBoundingClientRect() menuWidth: CHAT_SELECTION_MENU_WIDTH,
: null menuHeight: CHAT_SELECTION_MENU_HEIGHT,
const rootRect = root.getBoundingClientRect() offset: CHAT_SELECTION_MENU_OFFSET,
const pointerInsideRoot = fallbackPointer: pointer,
pointer.clientX >= rootRect.left && })
pointer.clientX <= rootRect.right &&
pointer.clientY >= rootRect.top &&
pointer.clientY <= rootRect.bottom
const fallbackX = rect && rect.width > 0
? rect.left + rect.width / 2
: rect?.left ?? rootRect.left + 24
const fallbackY = rect
? rect.bottom + CHAT_SELECTION_MENU_OFFSET
: rootRect.top + 24
const unclampedX = pointerInsideRoot ? pointer.clientX : fallbackX
const unclampedY = pointerInsideRoot ? pointer.clientY + CHAT_SELECTION_MENU_OFFSET : fallbackY
const minX = Math.max(12, rootRect.left + 8)
const maxX = Math.max(minX, Math.min(window.innerWidth - 160, rootRect.right - 136))
const minY = Math.max(12, rootRect.top + 8)
const maxY = Math.max(minY, Math.min(window.innerHeight - 48, rootRect.bottom - 40))
return {
x: clampValue(unclampedX, minX, maxX),
y: clampValue(unclampedY, minY, maxY),
}
} }
function getChatSelectionFromContainer( function getChatSelectionFromContainer(
@ -129,22 +108,25 @@ function getChatSelectionFromContainer(
function ChatSelectionMenu({ function ChatSelectionMenu({
selection, selection,
onAdd, onAdd,
popoverRef,
}: { }: {
selection: ChatSelectionState | null selection: ChatSelectionState | null
onAdd: () => void onAdd: () => void
popoverRef: { current: HTMLButtonElement | null }
}) { }) {
const t = useTranslation() const t = useTranslation()
if (!selection) return null if (!selection) return null
return ( return (
<button <button
ref={popoverRef}
type="button" type="button"
onMouseDown={(event) => event.preventDefault()} onMouseDown={(event) => event.preventDefault()}
onClick={onAdd} onClick={onAdd}
className="fixed z-50 inline-flex h-8 items-center gap-1.5 rounded-[8px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2.5 text-[12px] font-medium text-[var(--color-text-primary)] shadow-[var(--shadow-dropdown)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35" className="fixed z-50 inline-flex h-11 items-center gap-2 rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface-container-lowest)] px-5 text-[15px] font-semibold text-[var(--color-text-primary)] shadow-[0_10px_28px_rgba(15,23,42,0.14),0_2px_8px_rgba(15,23,42,0.08)] transition-colors hover:bg-[var(--color-surface)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
style={{ left: selection.x, top: selection.y }} style={{ left: selection.x, top: selection.y }}
> >
<span aria-hidden="true" className="material-symbols-outlined text-[15px] text-[var(--color-text-tertiary)]">person_add</span> <MessageCircle size={21} strokeWidth={2.15} className="shrink-0 text-[var(--color-text-primary)]" aria-hidden="true" />
<span>{t('chat.addSelectionToChat')}</span> <span>{t('chat.addSelectionToChat')}</span>
</button> </button>
) )
@ -299,6 +281,7 @@ function SelectableChatMessage({
children: ReactNode children: ReactNode
}) { }) {
const rootRef = useRef<HTMLDivElement>(null) const rootRef = useRef<HTMLDivElement>(null)
const selectionMenuRef = useRef<HTMLButtonElement>(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()
@ -310,6 +293,16 @@ function SelectableChatMessage({
setSelectionMenu(null) setSelectionMenu(null)
}, [content, messageId]) }, [content, messageId])
const dismissSelectionMenu = useCallback(() => {
setSelectionMenu(null)
}, [])
useSelectionPopoverDismiss({
active: Boolean(selectionMenu),
popoverRef: selectionMenuRef,
onDismiss: dismissSelectionMenu,
})
const addCurrentSelectionToChat = useCallback(() => { const addCurrentSelectionToChat = useCallback(() => {
if (!sessionId || !selectionMenu) return if (!sessionId || !selectionMenu) return
addReference(sessionId, { addReference(sessionId, {
@ -321,7 +314,7 @@ function SelectableChatMessage({
messageId, messageId,
}) })
setSelectionMenu(null) setSelectionMenu(null)
window.getSelection()?.removeAllRanges() clearWindowSelection()
}, [addReference, messageId, role, selectionMenu, sessionId, sourceName]) }, [addReference, messageId, role, selectionMenu, sessionId, sourceName])
return ( return (
@ -335,7 +328,7 @@ function SelectableChatMessage({
}} }}
> >
{children} {children}
<ChatSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} /> <ChatSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} popoverRef={selectionMenuRef} />
</div> </div>
) )
} }

View File

@ -144,13 +144,13 @@ async function selectWorkspaceCodeText(
Object.assign(range, { Object.assign(range, {
getBoundingClientRect: () => ({ getBoundingClientRect: () => ({
left: 120, left: 120,
top: 40, top: 100,
right: 240, right: 240,
bottom: 58, bottom: 118,
width: 120, width: 120,
height: 18, height: 18,
x: 120, x: 120,
y: 40, y: 100,
toJSON: () => ({}), toJSON: () => ({}),
}), }),
}) })
@ -160,7 +160,7 @@ async function selectWorkspaceCodeText(
selection?.addRange(range) selection?.addRange(range)
await act(async () => { await act(async () => {
fireEvent.mouseUp(code, { clientX: 180, clientY: 72 }) fireEvent.mouseUp(code, { clientX: 180, clientY: 122 })
await Promise.resolve() await Promise.resolve()
}) })
await flushReactWork() await flushReactWork()
@ -1444,8 +1444,8 @@ describe('WorkspacePanel', () => {
const addButtons = view.getAllByRole('button', { name: 'Add to chat' }) const addButtons = view.getAllByRole('button', { name: 'Add to chat' })
const floatingAddButton = addButtons[addButtons.length - 1]! const floatingAddButton = addButtons[addButtons.length - 1]!
expect(floatingAddButton.style.left).toBe('180px') expect(floatingAddButton.style.left).toBe('101px')
expect(floatingAddButton.style.top).toBe('80px') expect(floatingAddButton.style.top).toBe('46px')
fireEvent.keyDown(view.getByTestId('workspace-code').parentElement?.parentElement ?? view.getByTestId('workspace-code'), { fireEvent.keyDown(view.getByTestId('workspace-code').parentElement?.parentElement ?? view.getByTestId('workspace-code'), {
key: 'Escape', key: 'Escape',
@ -1454,6 +1454,61 @@ describe('WorkspacePanel', () => {
expect(view.queryAllByRole('button', { name: 'Add to chat' })).toHaveLength(1) expect(view.queryAllByRole('button', { name: 'Add to chat' })).toHaveLength(1)
}) })
it('dismisses the selected-code action when clicking outside the popover', async () => {
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-selection-dismiss': {
isOpen: true,
activeView: 'all',
},
},
statusBySession: {
...state.statusBySession,
'session-selection-dismiss': {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
},
},
previewTabsBySession: {
...state.previewTabsBySession,
'session-selection-dismiss': [{
id: 'file:src/App.ts',
path: 'src/App.ts',
kind: 'file',
title: 'App.ts',
language: 'text',
content: 'const title = "Todo"\nexport default title',
state: 'ok',
size: 42,
}],
},
activePreviewTabIdBySession: {
...state.activePreviewTabIdBySession,
'session-selection-dismiss': 'file:src/App.ts',
},
}))
const view = await renderPanel('session-selection-dismiss')
await selectWorkspaceCodeText(view, 1, 'const title = "Todo"', 1, 'const title = "Todo"')
expect(view.getAllByRole('button', { name: 'Add to chat' })).toHaveLength(2)
await act(async () => {
fireEvent.pointerDown(document.body)
await Promise.resolve()
})
await flushReactWork()
expect(view.queryAllByRole('button', { name: 'Add to chat' })).toHaveLength(1)
expect(window.getSelection()?.toString()).toBe('')
})
it('adds selected markdown text from a preview to the chat context', async () => { it('adds selected markdown text from a preview to the chat context', async () => {
await setWorkspaceState((state) => ({ await setWorkspaceState((state) => ({
...state, ...state,

View File

@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState, type MouseEvent } from 'react' import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from 'react'
import { MessageCircle } from 'lucide-react'
import { Highlight } from 'prism-react-renderer' import { Highlight } from 'prism-react-renderer'
import type { import type {
WorkspaceChangedFile, WorkspaceChangedFile,
@ -18,6 +19,7 @@ import { useChatStore } from '../../stores/chatStore'
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore' import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
import { useUIStore } from '../../stores/uiStore' import { useUIStore } from '../../stores/uiStore'
import { copyTextToClipboard } from '../chat/clipboard' import { copyTextToClipboard } from '../chat/clipboard'
import { clearWindowSelection, getSelectionPopoverPosition, useSelectionPopoverDismiss } from '../../hooks/useSelectionPopoverDismiss'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer' import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
import { import {
getFileExtension, getFileExtension,
@ -84,7 +86,9 @@ const FILE_STATUS_META: Record<WorkspaceFileStatus, { label: string; className:
const EMPTY_TREE_BY_PATH: Record<string, WorkspaceTreeResult | undefined> = {} const EMPTY_TREE_BY_PATH: Record<string, WorkspaceTreeResult | undefined> = {}
const EMPTY_PREVIEW_TABS: WorkspacePreviewTab[] = [] const EMPTY_PREVIEW_TABS: WorkspacePreviewTab[] = []
const EMPTY_EXPANDED_PATHS: string[] = [] const EMPTY_EXPANDED_PATHS: string[] = []
const SELECTION_MENU_OFFSET = 8 const SELECTION_MENU_OFFSET = 10
const SELECTION_MENU_WIDTH = 158
const SELECTION_MENU_HEIGHT = 44
const FILE_BADGE_META: Record<string, { label: string; className: string }> = { const FILE_BADGE_META: Record<string, { label: string; className: string }> = {
ts: { label: 'TS', className: 'bg-[var(--color-secondary)]/14 text-[var(--color-secondary)]' }, ts: { label: 'TS', className: 'bg-[var(--color-secondary)]/14 text-[var(--color-secondary)]' },
tsx: { label: 'TSX', className: 'bg-[var(--color-secondary)]/14 text-[var(--color-secondary)]' }, tsx: { label: 'TSX', className: 'bg-[var(--color-secondary)]/14 text-[var(--color-secondary)]' },
@ -239,37 +243,13 @@ function getLineNumberFromNode(node: Node | null, root: HTMLElement) {
return Number.isFinite(line) ? line : undefined return Number.isFinite(line) ? line : undefined
} }
function clampValue(value: number, min: number, max: number) {
return Math.max(min, Math.min(value, max))
}
function getSelectionPosition(range: Range, root: HTMLElement, pointer?: SelectionPointer) { function getSelectionPosition(range: Range, root: HTMLElement, pointer?: SelectionPointer) {
const rect = typeof range.getBoundingClientRect === 'function' return getSelectionPopoverPosition(range, root, {
? range.getBoundingClientRect() menuWidth: SELECTION_MENU_WIDTH,
: null menuHeight: SELECTION_MENU_HEIGHT,
const rootRect = root.getBoundingClientRect() offset: SELECTION_MENU_OFFSET,
const pointerInsideRoot = pointer fallbackPointer: pointer,
&& pointer.clientX >= rootRect.left })
&& pointer.clientX <= rootRect.right
&& pointer.clientY >= rootRect.top
&& pointer.clientY <= rootRect.bottom
const fallbackX = rect && rect.width > 0
? rect.left + rect.width / 2
: rect?.left ?? rootRect.left + 24
const fallbackY = rect
? rect.bottom + SELECTION_MENU_OFFSET
: rootRect.top + 24
const unclampedX = pointerInsideRoot ? pointer.clientX : fallbackX
const unclampedY = pointerInsideRoot ? pointer.clientY + SELECTION_MENU_OFFSET : fallbackY
const minX = Math.max(12, rootRect.left + 8)
const maxX = Math.max(minX, Math.min(window.innerWidth - 160, rootRect.right - 136))
const minY = Math.max(12, rootRect.top + 8)
const maxY = Math.max(minY, Math.min(window.innerHeight - 48, rootRect.bottom - 40))
return {
x: clampValue(unclampedX, minX, maxX),
y: clampValue(unclampedY, minY, maxY),
}
} }
function getTextSelectionFromContainer( function getTextSelectionFromContainer(
@ -321,22 +301,25 @@ function getLineRangeForText(value: string, text: string) {
function FloatingSelectionMenu({ function FloatingSelectionMenu({
selection, selection,
onAdd, onAdd,
popoverRef,
}: { }: {
selection: FloatingSelectionMenuState | null selection: FloatingSelectionMenuState | null
onAdd: () => void onAdd: () => void
popoverRef: { current: HTMLButtonElement | null }
}) { }) {
const t = useTranslation() const t = useTranslation()
if (!selection) return null if (!selection) return null
return ( return (
<button <button
ref={popoverRef}
type="button" type="button"
onMouseDown={(event) => event.preventDefault()} onMouseDown={(event) => event.preventDefault()}
onClick={onAdd} onClick={onAdd}
className="fixed z-50 inline-flex h-8 items-center gap-1.5 rounded-[8px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2.5 text-[12px] font-medium text-[var(--color-text-primary)] shadow-[var(--shadow-dropdown)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35" className="fixed z-50 inline-flex h-11 items-center gap-2 rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface-container-lowest)] px-5 text-[15px] font-semibold text-[var(--color-text-primary)] shadow-[0_10px_28px_rgba(15,23,42,0.14),0_2px_8px_rgba(15,23,42,0.08)] transition-colors hover:bg-[var(--color-surface)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
style={{ left: selection.x, top: selection.y }} style={{ left: selection.x, top: selection.y }}
> >
<span aria-hidden="true" className="material-symbols-outlined text-[15px] text-[var(--color-text-tertiary)]">person_add</span> <MessageCircle size={21} strokeWidth={2.15} className="shrink-0 text-[var(--color-text-primary)]" aria-hidden="true" />
<span>{t('workspace.addSelectionToChat')}</span> <span>{t('workspace.addSelectionToChat')}</span>
</button> </button>
) )
@ -452,6 +435,7 @@ function CodeSurface({
}) { }) {
const t = useTranslation() const t = useTranslation()
const surfaceRef = useRef<HTMLDivElement>(null) const surfaceRef = useRef<HTMLDivElement>(null)
const selectionMenuRef = useRef<HTMLButtonElement>(null)
const [commentLine, setCommentLine] = useState<number | null>(null) const [commentLine, setCommentLine] = useState<number | null>(null)
const [commentDraft, setCommentDraft] = useState('') const [commentDraft, setCommentDraft] = useState('')
const [showAllLines, setShowAllLines] = useState(false) const [showAllLines, setShowAllLines] = useState(false)
@ -469,6 +453,16 @@ function CodeSurface({
setSelectionMenu(null) setSelectionMenu(null)
}, [language, value]) }, [language, value])
const dismissSelectionMenu = useCallback(() => {
setSelectionMenu(null)
}, [])
useSelectionPopoverDismiss({
active: Boolean(selectionMenu),
popoverRef: selectionMenuRef,
onDismiss: dismissSelectionMenu,
})
const submitLineComment = () => { const submitLineComment = () => {
if (!commentLine || !commentDraft.trim()) return if (!commentLine || !commentDraft.trim()) return
onAddLineComment(commentLine, commentDraft.trim(), activeQuote) onAddLineComment(commentLine, commentDraft.trim(), activeQuote)
@ -497,7 +491,7 @@ function CodeSurface({
endLine: selectionMenu.endLine, endLine: selectionMenu.endLine,
}) })
setSelectionMenu(null) setSelectionMenu(null)
window.getSelection()?.removeAllRanges() clearWindowSelection()
} }
const renderLineCommentEditor = (lineNumber: number) => { const renderLineCommentEditor = (lineNumber: number) => {
@ -650,7 +644,7 @@ function CodeSurface({
</div> </div>
)} )}
</div> </div>
<FloatingSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} /> <FloatingSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} popoverRef={selectionMenuRef} />
</div> </div>
) )
} }
@ -663,12 +657,23 @@ function MarkdownSurface({
onAddSelection: (selection: WorkspaceTextSelection) => void onAddSelection: (selection: WorkspaceTextSelection) => void
}) { }) {
const surfaceRef = useRef<HTMLDivElement>(null) const surfaceRef = useRef<HTMLDivElement>(null)
const selectionMenuRef = useRef<HTMLButtonElement>(null)
const [selectionMenu, setSelectionMenu] = useState<FloatingSelectionMenuState | null>(null) const [selectionMenu, setSelectionMenu] = useState<FloatingSelectionMenuState | null>(null)
useEffect(() => { useEffect(() => {
setSelectionMenu(null) setSelectionMenu(null)
}, [value]) }, [value])
const dismissSelectionMenu = useCallback(() => {
setSelectionMenu(null)
}, [])
useSelectionPopoverDismiss({
active: Boolean(selectionMenu),
popoverRef: selectionMenuRef,
onDismiss: dismissSelectionMenu,
})
const handleSelectionMouseUp = (event: MouseEvent<HTMLDivElement>) => { const handleSelectionMouseUp = (event: MouseEvent<HTMLDivElement>) => {
setSelectionMenu(getTextSelectionFromContainer( setSelectionMenu(getTextSelectionFromContainer(
surfaceRef.current, surfaceRef.current,
@ -685,7 +690,7 @@ function MarkdownSurface({
endLine: selectionMenu.endLine, endLine: selectionMenu.endLine,
}) })
setSelectionMenu(null) setSelectionMenu(null)
window.getSelection()?.removeAllRanges() clearWindowSelection()
} }
return ( return (
@ -704,7 +709,7 @@ function MarkdownSurface({
className="workspace-markdown-preview prose-p:text-[14px] prose-p:leading-7 prose-h1:text-[24px] prose-h2:text-[18px] prose-h3:text-[15px] prose-code:text-[12px] prose-pre:my-4" className="workspace-markdown-preview prose-p:text-[14px] prose-p:leading-7 prose-h1:text-[24px] prose-h2:text-[18px] prose-h3:text-[15px] prose-code:text-[12px] prose-pre:my-4"
/> />
</div> </div>
<FloatingSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} /> <FloatingSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} popoverRef={selectionMenuRef} />
</div> </div>
) )
} }

View File

@ -0,0 +1,93 @@
import { useEffect } from 'react'
type ElementRef = {
current: HTMLElement | null
}
const VIEWPORT_MARGIN = 12
function clampValue(value: number, min: number, max: number) {
return Math.max(min, Math.min(value, max))
}
export function clearWindowSelection() {
window.getSelection()?.removeAllRanges()
}
export function getSelectionPopoverPosition(
range: Range,
root: HTMLElement,
{
menuWidth,
menuHeight,
offset,
fallbackPointer,
}: {
menuWidth: number
menuHeight: number
offset: number
fallbackPointer?: { clientX: number; clientY: number }
},
) {
const rect = typeof range.getBoundingClientRect === 'function'
? range.getBoundingClientRect()
: null
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 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),
}
}
export function useSelectionPopoverDismiss({
active,
popoverRef,
onDismiss,
}: {
active: boolean
popoverRef: ElementRef
onDismiss: () => void
}) {
useEffect(() => {
if (!active) return
const handlePointerDown = (event: PointerEvent) => {
const popover = popoverRef.current
const target = event.target
if (popover && target instanceof Node && popover.contains(target)) {
return
}
onDismiss()
clearWindowSelection()
}
document.addEventListener('pointerdown', handlePointerDown, true)
return () => document.removeEventListener('pointerdown', handlePointerDown, true)
}, [active, onDismiss, popoverRef])
}

View File

@ -122,7 +122,7 @@ export const zh: Record<TranslationKey, string> = {
'workspace.showAllLoadedLines': '显示全部已加载行', 'workspace.showAllLoadedLines': '显示全部已加载行',
'workspace.collapsePreview': '收起预览', 'workspace.collapsePreview': '收起预览',
'workspace.addToChat': '添加到聊天', 'workspace.addToChat': '添加到聊天',
'workspace.addSelectionToChat': '添加对话', 'workspace.addSelectionToChat': '添加对话',
'workspace.copyPath': '复制路径', 'workspace.copyPath': '复制路径',
'workspace.pathCopied': '路径已复制。', 'workspace.pathCopied': '路径已复制。',
'workspace.localComment': '本地评论', 'workspace.localComment': '本地评论',
@ -907,7 +907,7 @@ export const zh: Record<TranslationKey, string> = {
'chat.dropFilesHint': '会以文件路径附加到当前输入。', 'chat.dropFilesHint': '会以文件路径附加到当前输入。',
'chat.workspaceReferencesOnly': '已添加 {count} 个工作区引用', 'chat.workspaceReferencesOnly': '已添加 {count} 个工作区引用',
'chat.contextReferencesOnly': '已添加 {count} 个引用', 'chat.contextReferencesOnly': '已添加 {count} 个引用',
'chat.addSelectionToChat': '添加对话', 'chat.addSelectionToChat': '添加对话',
'chat.userMessageReference': '用户消息', 'chat.userMessageReference': '用户消息',
'chat.assistantMessageReference': 'AI 回复', 'chat.assistantMessageReference': 'AI 回复',
'chat.slashCommands': '斜杠命令', 'chat.slashCommands': '斜杠命令',