mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Merge commit '2b5bf56475c5a8afd2ccd40df4e63e58d4d88c87'
This commit is contained in:
commit
0506452a1b
@ -905,8 +905,8 @@ describe('MessageList nested tool calls', () => {
|
||||
await selectMessageText(userText, 'workspace selection behavior')
|
||||
const floatingAddButton = screen.getByRole('button', { name: 'Add to chat' })
|
||||
|
||||
expect(floatingAddButton.style.left).toBe('260px')
|
||||
expect(floatingAddButton.style.top).toBe('112px')
|
||||
expect(floatingAddButton.style.left).toBe('141px')
|
||||
expect(floatingAddButton.style.top).toBe('26px')
|
||||
|
||||
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 () => {
|
||||
const scrollIntoView = vi.fn()
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
@ -22,6 +22,7 @@ import { InlineTaskSummary } from './InlineTaskSummary'
|
||||
import { CurrentTurnChangeCard } from './CurrentTurnChangeCard'
|
||||
import type { AgentTaskNotification, UIMessage } from '../../types/chat'
|
||||
import { ConfirmDialog } from '../shared/ConfirmDialog'
|
||||
import { clearWindowSelection, getSelectionPopoverPosition, useSelectionPopoverDismiss } from '../../hooks/useSelectionPopoverDismiss'
|
||||
|
||||
type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
|
||||
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
|
||||
@ -62,11 +63,9 @@ type ChatSelectionState = {
|
||||
y: number
|
||||
}
|
||||
|
||||
const CHAT_SELECTION_MENU_OFFSET = 8
|
||||
|
||||
function clampValue(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(value, max))
|
||||
}
|
||||
const CHAT_SELECTION_MENU_OFFSET = 10
|
||||
const CHAT_SELECTION_MENU_WIDTH = 158
|
||||
const CHAT_SELECTION_MENU_HEIGHT = 44
|
||||
|
||||
function getElementForNode(node: Node | null): Element | 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 }) {
|
||||
const rect = typeof range.getBoundingClientRect === 'function'
|
||||
? range.getBoundingClientRect()
|
||||
: null
|
||||
const rootRect = root.getBoundingClientRect()
|
||||
const pointerInsideRoot =
|
||||
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),
|
||||
}
|
||||
return getSelectionPopoverPosition(range, root, {
|
||||
menuWidth: CHAT_SELECTION_MENU_WIDTH,
|
||||
menuHeight: CHAT_SELECTION_MENU_HEIGHT,
|
||||
offset: CHAT_SELECTION_MENU_OFFSET,
|
||||
fallbackPointer: pointer,
|
||||
})
|
||||
}
|
||||
|
||||
function getChatSelectionFromContainer(
|
||||
@ -129,22 +108,25 @@ function getChatSelectionFromContainer(
|
||||
function ChatSelectionMenu({
|
||||
selection,
|
||||
onAdd,
|
||||
popoverRef,
|
||||
}: {
|
||||
selection: ChatSelectionState | null
|
||||
onAdd: () => void
|
||||
popoverRef: { current: HTMLButtonElement | null }
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
if (!selection) return null
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={popoverRef}
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
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 }}
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
)
|
||||
@ -299,6 +281,7 @@ function SelectableChatMessage({
|
||||
children: ReactNode
|
||||
}) {
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const selectionMenuRef = useRef<HTMLButtonElement>(null)
|
||||
const addReference = useWorkspaceChatContextStore((state) => state.addReference)
|
||||
const [selectionMenu, setSelectionMenu] = useState<ChatSelectionState | null>(null)
|
||||
const t = useTranslation()
|
||||
@ -310,6 +293,16 @@ function SelectableChatMessage({
|
||||
setSelectionMenu(null)
|
||||
}, [content, messageId])
|
||||
|
||||
const dismissSelectionMenu = useCallback(() => {
|
||||
setSelectionMenu(null)
|
||||
}, [])
|
||||
|
||||
useSelectionPopoverDismiss({
|
||||
active: Boolean(selectionMenu),
|
||||
popoverRef: selectionMenuRef,
|
||||
onDismiss: dismissSelectionMenu,
|
||||
})
|
||||
|
||||
const addCurrentSelectionToChat = useCallback(() => {
|
||||
if (!sessionId || !selectionMenu) return
|
||||
addReference(sessionId, {
|
||||
@ -321,7 +314,7 @@ function SelectableChatMessage({
|
||||
messageId,
|
||||
})
|
||||
setSelectionMenu(null)
|
||||
window.getSelection()?.removeAllRanges()
|
||||
clearWindowSelection()
|
||||
}, [addReference, messageId, role, selectionMenu, sessionId, sourceName])
|
||||
|
||||
return (
|
||||
@ -335,7 +328,7 @@ function SelectableChatMessage({
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<ChatSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} />
|
||||
<ChatSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} popoverRef={selectionMenuRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -144,13 +144,13 @@ async function selectWorkspaceCodeText(
|
||||
Object.assign(range, {
|
||||
getBoundingClientRect: () => ({
|
||||
left: 120,
|
||||
top: 40,
|
||||
top: 100,
|
||||
right: 240,
|
||||
bottom: 58,
|
||||
bottom: 118,
|
||||
width: 120,
|
||||
height: 18,
|
||||
x: 120,
|
||||
y: 40,
|
||||
y: 100,
|
||||
toJSON: () => ({}),
|
||||
}),
|
||||
})
|
||||
@ -160,7 +160,7 @@ async function selectWorkspaceCodeText(
|
||||
selection?.addRange(range)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.mouseUp(code, { clientX: 180, clientY: 72 })
|
||||
fireEvent.mouseUp(code, { clientX: 180, clientY: 122 })
|
||||
await Promise.resolve()
|
||||
})
|
||||
await flushReactWork()
|
||||
@ -1444,8 +1444,8 @@ describe('WorkspacePanel', () => {
|
||||
const addButtons = view.getAllByRole('button', { name: 'Add to chat' })
|
||||
const floatingAddButton = addButtons[addButtons.length - 1]!
|
||||
|
||||
expect(floatingAddButton.style.left).toBe('180px')
|
||||
expect(floatingAddButton.style.top).toBe('80px')
|
||||
expect(floatingAddButton.style.left).toBe('101px')
|
||||
expect(floatingAddButton.style.top).toBe('46px')
|
||||
|
||||
fireEvent.keyDown(view.getByTestId('workspace-code').parentElement?.parentElement ?? view.getByTestId('workspace-code'), {
|
||||
key: 'Escape',
|
||||
@ -1454,6 +1454,61 @@ describe('WorkspacePanel', () => {
|
||||
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 () => {
|
||||
await setWorkspaceState((state) => ({
|
||||
...state,
|
||||
|
||||
@ -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 type {
|
||||
WorkspaceChangedFile,
|
||||
@ -18,6 +19,7 @@ import { useChatStore } from '../../stores/chatStore'
|
||||
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
|
||||
import { useUIStore } from '../../stores/uiStore'
|
||||
import { copyTextToClipboard } from '../chat/clipboard'
|
||||
import { clearWindowSelection, getSelectionPopoverPosition, useSelectionPopoverDismiss } from '../../hooks/useSelectionPopoverDismiss'
|
||||
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
|
||||
import {
|
||||
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_PREVIEW_TABS: WorkspacePreviewTab[] = []
|
||||
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 }> = {
|
||||
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)]' },
|
||||
@ -239,37 +243,13 @@ function getLineNumberFromNode(node: Node | null, root: HTMLElement) {
|
||||
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) {
|
||||
const rect = typeof range.getBoundingClientRect === 'function'
|
||||
? range.getBoundingClientRect()
|
||||
: null
|
||||
const rootRect = root.getBoundingClientRect()
|
||||
const pointerInsideRoot = 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),
|
||||
}
|
||||
return getSelectionPopoverPosition(range, root, {
|
||||
menuWidth: SELECTION_MENU_WIDTH,
|
||||
menuHeight: SELECTION_MENU_HEIGHT,
|
||||
offset: SELECTION_MENU_OFFSET,
|
||||
fallbackPointer: pointer,
|
||||
})
|
||||
}
|
||||
|
||||
function getTextSelectionFromContainer(
|
||||
@ -321,22 +301,25 @@ function getLineRangeForText(value: string, text: string) {
|
||||
function FloatingSelectionMenu({
|
||||
selection,
|
||||
onAdd,
|
||||
popoverRef,
|
||||
}: {
|
||||
selection: FloatingSelectionMenuState | null
|
||||
onAdd: () => void
|
||||
popoverRef: { current: HTMLButtonElement | null }
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
if (!selection) return null
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={popoverRef}
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
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 }}
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
)
|
||||
@ -452,6 +435,7 @@ function CodeSurface({
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
const surfaceRef = useRef<HTMLDivElement>(null)
|
||||
const selectionMenuRef = useRef<HTMLButtonElement>(null)
|
||||
const [commentLine, setCommentLine] = useState<number | null>(null)
|
||||
const [commentDraft, setCommentDraft] = useState('')
|
||||
const [showAllLines, setShowAllLines] = useState(false)
|
||||
@ -469,6 +453,16 @@ function CodeSurface({
|
||||
setSelectionMenu(null)
|
||||
}, [language, value])
|
||||
|
||||
const dismissSelectionMenu = useCallback(() => {
|
||||
setSelectionMenu(null)
|
||||
}, [])
|
||||
|
||||
useSelectionPopoverDismiss({
|
||||
active: Boolean(selectionMenu),
|
||||
popoverRef: selectionMenuRef,
|
||||
onDismiss: dismissSelectionMenu,
|
||||
})
|
||||
|
||||
const submitLineComment = () => {
|
||||
if (!commentLine || !commentDraft.trim()) return
|
||||
onAddLineComment(commentLine, commentDraft.trim(), activeQuote)
|
||||
@ -497,7 +491,7 @@ function CodeSurface({
|
||||
endLine: selectionMenu.endLine,
|
||||
})
|
||||
setSelectionMenu(null)
|
||||
window.getSelection()?.removeAllRanges()
|
||||
clearWindowSelection()
|
||||
}
|
||||
|
||||
const renderLineCommentEditor = (lineNumber: number) => {
|
||||
@ -650,7 +644,7 @@ function CodeSurface({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<FloatingSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} />
|
||||
<FloatingSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} popoverRef={selectionMenuRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -663,12 +657,23 @@ function MarkdownSurface({
|
||||
onAddSelection: (selection: WorkspaceTextSelection) => void
|
||||
}) {
|
||||
const surfaceRef = useRef<HTMLDivElement>(null)
|
||||
const selectionMenuRef = useRef<HTMLButtonElement>(null)
|
||||
const [selectionMenu, setSelectionMenu] = useState<FloatingSelectionMenuState | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setSelectionMenu(null)
|
||||
}, [value])
|
||||
|
||||
const dismissSelectionMenu = useCallback(() => {
|
||||
setSelectionMenu(null)
|
||||
}, [])
|
||||
|
||||
useSelectionPopoverDismiss({
|
||||
active: Boolean(selectionMenu),
|
||||
popoverRef: selectionMenuRef,
|
||||
onDismiss: dismissSelectionMenu,
|
||||
})
|
||||
|
||||
const handleSelectionMouseUp = (event: MouseEvent<HTMLDivElement>) => {
|
||||
setSelectionMenu(getTextSelectionFromContainer(
|
||||
surfaceRef.current,
|
||||
@ -685,7 +690,7 @@ function MarkdownSurface({
|
||||
endLine: selectionMenu.endLine,
|
||||
})
|
||||
setSelectionMenu(null)
|
||||
window.getSelection()?.removeAllRanges()
|
||||
clearWindowSelection()
|
||||
}
|
||||
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<FloatingSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} />
|
||||
<FloatingSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} popoverRef={selectionMenuRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
93
desktop/src/hooks/useSelectionPopoverDismiss.ts
Normal file
93
desktop/src/hooks/useSelectionPopoverDismiss.ts
Normal 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])
|
||||
}
|
||||
@ -122,7 +122,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'workspace.showAllLoadedLines': '显示全部已加载行',
|
||||
'workspace.collapsePreview': '收起预览',
|
||||
'workspace.addToChat': '添加到聊天',
|
||||
'workspace.addSelectionToChat': '添加至对话',
|
||||
'workspace.addSelectionToChat': '添加到对话',
|
||||
'workspace.copyPath': '复制路径',
|
||||
'workspace.pathCopied': '路径已复制。',
|
||||
'workspace.localComment': '本地评论',
|
||||
@ -907,7 +907,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'chat.dropFilesHint': '会以文件路径附加到当前输入。',
|
||||
'chat.workspaceReferencesOnly': '已添加 {count} 个工作区引用',
|
||||
'chat.contextReferencesOnly': '已添加 {count} 个引用',
|
||||
'chat.addSelectionToChat': '添加至对话',
|
||||
'chat.addSelectionToChat': '添加到对话',
|
||||
'chat.userMessageReference': '用户消息',
|
||||
'chat.assistantMessageReference': 'AI 回复',
|
||||
'chat.slashCommands': '斜杠命令',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user