mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
The chat reference action was still tied too closely to message-local mouseup timing, and the floating fixed-position control stayed inside virtualized message DOM. In Electron Chromium that made multi-line ranges race selection settlement and let transformed ancestors offset the button away from the viewport position we calculated. This reads settled document selections after pointerup/selectionchange, portals the action to document.body, and prefers right-side placement for multi-line selections. Constraint: Electron Chromium selectionchange can settle after message-local mouse events Rejected: Keep the popover inside the message node | transformed/virtualized ancestors offset fixed positioning in real browser layout Confidence: high Scope-risk: narrow Directive: Keep chat selection actions portaled; do not reparent under virtualized message rows without browser-coordinate verification Tested: cd desktop && bun run test -- src/components/chat/MessageList.test.tsx Tested: bun run check:desktop Tested: Playwright Chromium smoke for multi-line selection, single-line selection, and outside-click dismissal on local desktop harness
180 lines
5.0 KiB
TypeScript
180 lines
5.0 KiB
TypeScript
import { useEffect } from 'react'
|
|
|
|
type ElementRef = {
|
|
current: HTMLElement | null
|
|
}
|
|
|
|
const VIEWPORT_MARGIN = 12
|
|
|
|
type SelectionRect = {
|
|
left: number
|
|
top: number
|
|
right: number
|
|
bottom: number
|
|
width: number
|
|
height: number
|
|
}
|
|
|
|
type SelectionGeometry = {
|
|
rect: SelectionRect | DOMRect
|
|
isMultiLine: boolean
|
|
}
|
|
|
|
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 getRangeSelectionGeometry(range: Range): SelectionGeometry | null {
|
|
const clientRects = typeof range.getClientRects === 'function'
|
|
? Array.from(range.getClientRects()).filter(isUsableRect)
|
|
: []
|
|
const boundingRect = typeof range.getBoundingClientRect === 'function'
|
|
? range.getBoundingClientRect()
|
|
: null
|
|
|
|
if (clientRects.length > 0) {
|
|
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))
|
|
const unionRect = {
|
|
left,
|
|
top,
|
|
right,
|
|
bottom,
|
|
width: right - left,
|
|
height: bottom - top,
|
|
}
|
|
const maxLineHeight = Math.max(...clientRects.map((rect) => rect.height))
|
|
return {
|
|
rect: boundingRect && isUsableRect(boundingRect) ? boundingRect : unionRect,
|
|
isMultiLine: clientRects.length > 1 || unionRect.height > maxLineHeight * 1.6,
|
|
}
|
|
}
|
|
|
|
if (boundingRect && isUsableRect(boundingRect)) {
|
|
return {
|
|
rect: boundingRect,
|
|
isMultiLine: boundingRect.height > 32,
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
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 geometry = getRangeSelectionGeometry(range)
|
|
const rootRect = root.getBoundingClientRect()
|
|
const pointerInsideRoot = fallbackPointer
|
|
&& fallbackPointer.clientX >= rootRect.left
|
|
&& fallbackPointer.clientX <= rootRect.right
|
|
&& fallbackPointer.clientY >= rootRect.top
|
|
&& fallbackPointer.clientY <= rootRect.bottom
|
|
const fallbackX = pointerInsideRoot ? fallbackPointer.clientX : rootRect.left + 24
|
|
const fallbackY = pointerInsideRoot ? fallbackPointer.clientY : rootRect.top + 24
|
|
const selectionRect = geometry?.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 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,
|
|
}
|
|
|
|
const right = {
|
|
x: selectionRect.right + offset,
|
|
y: centerY - menuHeight / 2,
|
|
}
|
|
if (geometry?.isMultiLine && right.x + menuWidth <= viewportWidth - VIEWPORT_MARGIN) return clampPosition(right)
|
|
|
|
if (above.y >= VIEWPORT_MARGIN) return clampPosition(above)
|
|
|
|
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({
|
|
active,
|
|
popoverRef,
|
|
onDismiss,
|
|
}: {
|
|
active: boolean
|
|
popoverRef: ElementRef
|
|
onDismiss: () => void
|
|
}) {
|
|
useEffect(() => {
|
|
if (!active) return
|
|
|
|
const dismiss = () => {
|
|
onDismiss()
|
|
clearWindowSelection()
|
|
}
|
|
|
|
const handlePointerDown = (event: PointerEvent) => {
|
|
const popover = popoverRef.current
|
|
const target = event.target
|
|
if (popover && target instanceof Node && popover.contains(target)) {
|
|
return
|
|
}
|
|
|
|
dismiss()
|
|
}
|
|
|
|
const handleScroll = () => {
|
|
dismiss()
|
|
}
|
|
|
|
document.addEventListener('pointerdown', handlePointerDown, true)
|
|
document.addEventListener('scroll', handleScroll, true)
|
|
return () => {
|
|
document.removeEventListener('pointerdown', handlePointerDown, true)
|
|
document.removeEventListener('scroll', handleScroll, true)
|
|
}
|
|
}, [active, onDismiss, popoverRef])
|
|
}
|