diff --git a/src/renderer/src/components/source-select/SourceSelectPopover.tsx b/src/renderer/src/components/source-select/SourceSelectPopover.tsx index 9a4a26f..6e8a0a1 100644 --- a/src/renderer/src/components/source-select/SourceSelectPopover.tsx +++ b/src/renderer/src/components/source-select/SourceSelectPopover.tsx @@ -1,75 +1,7 @@ -import { cloneElement, useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type ReactElement, type ReactNode } from 'react' -import { createPortal } from 'react-dom' -import { autoUpdate, flip, FloatingFocusManager, offset, shift, size, useFloating, type Padding, type Placement } from '@floating-ui/react' -import { sourceFloatingPlatform } from './SourceFloatingPlatform' +/** + * 兼容层:通用 Popover 壳已提升到 `ui/Popover.tsx`(实现逐行迁移,DOM/行为不变)。 + * 这里只保留旧路径的两个导出名,不再保留任何实现,避免出现重复实现漂移。 + * 新代码请直接从 `../../ui/Popover` 引入 `Popover` / `PopoverTooltip`。 + */ +export { Popover as SourceSelectPopover } from '../../ui/Popover' export { RecoveredSelectTooltip as SourceSelectTooltip } from './RecoveredSelectTooltip' - -interface PopoverProps { - trigger: ReactElement; children: ReactNode; open: boolean; onOpenChange(open: boolean): void - disabled?: boolean; placement?: Placement; portalRoot?: 'inline' | 'body' | HTMLElement | (() => HTMLElement | null) - className?: string; role?: string; ariaLabel?: string; manageFocus?: boolean; initialFocus?: number - matchReferenceWidth?: boolean; viewportPadding?: Padding; offset?: number; transitionPreset?: 'expand' | 'fade' -} -// Host adaptation of the original PopoverLayer: identical middleware order, inline strategy, -// DOM classes, source transition states and clipping. Floating UI's original version is unknown. -export function SourceSelectPopover(props: PopoverProps): React.JSX.Element { - const { trigger, open, disabled, onOpenChange, children, placement = 'bottom', portalRoot = 'inline', className = '', role = 'dialog', manageFocus = false, initialFocus, matchReferenceWidth, viewportPadding = 8, transitionPreset = 'fade' } = props - const anchor = useRef(null), previous = useRef(open), id = 'wb-popover' + useId() - const [exiting, setExiting] = useState(false) - useEffect(() => { - const wasOpen = previous.current; previous.current = open - if (open) { setExiting(false); return } - if (!wasOpen || transitionPreset !== 'expand') return - setExiting(true) - const timer = window.setTimeout(() => setExiting(false), window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 1 : 150) - return () => clearTimeout(timer) - }, [open, transitionPreset]) - const close = useCallback(() => onOpenChange(false), [onOpenChange]) - const mergedTrigger = cloneElement(trigger, { - ref: (element: HTMLElement | null) => { - anchor.current = element - const ref = trigger.props.ref - if (typeof ref === 'function') ref(element); else if (ref) ref.current = element - }, role: role === 'listbox' ? 'combobox' : trigger.props.role, - 'aria-label': props.ariaLabel ?? trigger.props['aria-label'], - 'aria-haspopup': role, 'aria-expanded': open ? 'true' : 'false', 'aria-controls': open ? id : undefined, - onClick: (event: React.MouseEvent) => { trigger.props.onClick?.(event); if (!event.defaultPrevented && !disabled) onOpenChange(!open) } - }) - return <>{mergedTrigger}{(open || exiting) && {children}} -} -function PopoverLayer({ anchor, close, id, exiting, placement, portalRoot, className, role, manageFocus, initialFocus, matchReferenceWidth, viewportPadding, offset: offsetDistance = 8, transitionPreset, children, ariaLabel }: PopoverProps & { anchor: React.RefObject; close(): void; id: string; exiting: boolean }): React.JSX.Element { - const inline = portalRoot === 'inline' - const padding = typeof viewportPadding === 'number' ? viewportPadding : { top: 8, right: 8, bottom: 8, left: 8, ...viewportPadding } - const { refs, floatingStyles, context, placement: actual, isPositioned } = useFloating({ - open: true, onOpenChange: next => { if (!next && !exiting) close() }, placement, - middleware: [offset(offsetDistance), flip(), shift({ padding }), ...(matchReferenceWidth ? [size({ padding, apply({ elements, rects, availableHeight }) { Object.assign(elements.floating.style, { width: `${rects.reference.width}px`, maxHeight: `${Math.max(0, availableHeight)}px` }) } })] : [])], - whileElementsMounted: autoUpdate, strategy: inline ? 'absolute' : 'fixed', transform: false, platform: sourceFloatingPlatform - }) - useLayoutEffect(() => { refs.setReference(anchor.current) }, [refs, anchor]) - useEffect(() => { - if (exiting) return - const outside = (event: PointerEvent): void => { - if (event.button !== 0 || !(event.target instanceof Node)) return - if (!refs.floating.current?.contains(event.target) && !anchor.current?.contains(event.target)) close() - } - const escape = (event: KeyboardEvent): void => { - if (event.key !== 'Escape') return - event.preventDefault(); event.stopPropagation(); close(); anchor.current?.focus({ preventScroll: true }) - } - document.addEventListener('pointerdown', outside) - // Capture prevents the model editor's document Escape listener from also closing the editor. - document.addEventListener('keydown', escape, true) - return () => { document.removeEventListener('pointerdown', outside); document.removeEventListener('keydown', escape, true) } - }, [anchor, close, exiting, refs.floating]) - useEffect(() => { if (refs.floating.current) refs.floating.current.inert = exiting }, [exiting, refs.floating]) - const [side, alignment] = actual.split('-'), expand = transitionPreset === 'expand' - const origin = side === 'bottom' || side === 'top' ? `${alignment === 'start' ? 'left' : alignment === 'end' ? 'right' : 'center'} ${side === 'bottom' ? 'top' : 'bottom'}` : `${side === 'left' ? 'right' : 'left'} ${alignment === 'start' ? 'top' : alignment === 'end' ? 'bottom' : 'center'}` - const element =
{children}
- const managed = manageFocus ? {element} : element - if (inline) return managed - const target = typeof portalRoot === 'function' ? portalRoot() : portalRoot instanceof HTMLElement ? portalRoot : document.body - return createPortal({managed}, target ?? document.body) -} - diff --git a/src/renderer/src/ui/Popover.tsx b/src/renderer/src/ui/Popover.tsx new file mode 100644 index 0000000..fc3a989 --- /dev/null +++ b/src/renderer/src/ui/Popover.tsx @@ -0,0 +1,84 @@ +/** + * YCode UI Popover / PopoverTooltip(P4)。 + * + * 实现逐行迁移自 `components/source-select/SourceSelectPopover.tsx` 的 REA 适配壳, + * 只改文件位置与导出名:DOM class、floating middleware 顺序、inline/portal 策略、 + * focus/Escape/outside pointer、transitionPreset 与 aria 合并逻辑一律未改动。 + * 迁移保留的既有字面量(数值已存在,token 无法立即表达,本次不发明 token、不改行为): + * viewportPadding/offset 默认 8、退出动画 150(reduced-motion 1)、zIndex 1100, + * 以及按浮层测量结果写入的行内尺寸。 + */ +import { cloneElement, useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type ReactElement, type ReactNode } from 'react' +import { createPortal } from 'react-dom' +import { autoUpdate, flip, FloatingFocusManager, offset, shift, size, useFloating, type Padding, type Placement } from '@floating-ui/react' +import { sourceFloatingPlatform } from '../components/source-select/SourceFloatingPlatform' +export { RecoveredSelectTooltip as PopoverTooltip } from '../components/source-select/RecoveredSelectTooltip' + +export interface PopoverProps { + trigger: ReactElement; children: ReactNode; open: boolean; onOpenChange(open: boolean): void + disabled?: boolean; placement?: Placement; portalRoot?: 'inline' | 'body' | HTMLElement | (() => HTMLElement | null) + className?: string; role?: string; ariaLabel?: string; manageFocus?: boolean; initialFocus?: number + matchReferenceWidth?: boolean; viewportPadding?: Padding; offset?: number; transitionPreset?: 'expand' | 'fade' +} +// Host adaptation of the original PopoverLayer: identical middleware order, inline strategy, +// DOM classes, source transition states and clipping. Floating UI's original version is unknown. +export function Popover(props: PopoverProps): React.JSX.Element { + const { trigger, open, disabled, onOpenChange, children, placement = 'bottom', portalRoot = 'inline', className = '', role = 'dialog', manageFocus = false, initialFocus, matchReferenceWidth, viewportPadding = 8, transitionPreset = 'fade' } = props + const anchor = useRef(null), previous = useRef(open), id = 'wb-popover' + useId() + const [exiting, setExiting] = useState(false) + useEffect(() => { + const wasOpen = previous.current; previous.current = open + if (open) { setExiting(false); return } + if (!wasOpen || transitionPreset !== 'expand') return + setExiting(true) + const timer = window.setTimeout(() => setExiting(false), window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 1 : 150) + return () => clearTimeout(timer) + }, [open, transitionPreset]) + const close = useCallback(() => onOpenChange(false), [onOpenChange]) + const mergedTrigger = cloneElement(trigger, { + ref: (element: HTMLElement | null) => { + anchor.current = element + const ref = trigger.props.ref + if (typeof ref === 'function') ref(element); else if (ref) ref.current = element + }, role: role === 'listbox' ? 'combobox' : trigger.props.role, + 'aria-label': props.ariaLabel ?? trigger.props['aria-label'], + 'aria-haspopup': role, 'aria-expanded': open ? 'true' : 'false', 'aria-controls': open ? id : undefined, + onClick: (event: React.MouseEvent) => { trigger.props.onClick?.(event); if (!event.defaultPrevented && !disabled) onOpenChange(!open) } + }) + return <>{mergedTrigger}{(open || exiting) && {children}} +} +function PopoverLayer({ anchor, close, id, exiting, placement, portalRoot, className, role, manageFocus, initialFocus, matchReferenceWidth, viewportPadding, offset: offsetDistance = 8, transitionPreset, children, ariaLabel }: PopoverProps & { anchor: React.RefObject; close(): void; id: string; exiting: boolean }): React.JSX.Element { + const inline = portalRoot === 'inline' + const padding = typeof viewportPadding === 'number' ? viewportPadding : { top: 8, right: 8, bottom: 8, left: 8, ...viewportPadding } + const { refs, floatingStyles, context, placement: actual, isPositioned } = useFloating({ + open: true, onOpenChange: next => { if (!next && !exiting) close() }, placement, + middleware: [offset(offsetDistance), flip(), shift({ padding }), ...(matchReferenceWidth ? [size({ padding, apply({ elements, rects, availableHeight }) { Object.assign(elements.floating.style, { width: `${rects.reference.width}px`, maxHeight: `${Math.max(0, availableHeight)}px` }) } })] : [])], + whileElementsMounted: autoUpdate, strategy: inline ? 'absolute' : 'fixed', transform: false, platform: sourceFloatingPlatform + }) + useLayoutEffect(() => { refs.setReference(anchor.current) }, [refs, anchor]) + useEffect(() => { + if (exiting) return + const outside = (event: PointerEvent): void => { + if (event.button !== 0 || !(event.target instanceof Node)) return + if (!refs.floating.current?.contains(event.target) && !anchor.current?.contains(event.target)) close() + } + const escape = (event: KeyboardEvent): void => { + if (event.key !== 'Escape') return + event.preventDefault(); event.stopPropagation(); close(); anchor.current?.focus({ preventScroll: true }) + } + document.addEventListener('pointerdown', outside) + // Capture prevents the model editor's document Escape listener from also closing the editor. + document.addEventListener('keydown', escape, true) + return () => { document.removeEventListener('pointerdown', outside); document.removeEventListener('keydown', escape, true) } + }, [anchor, close, exiting, refs.floating]) + useEffect(() => { if (refs.floating.current) refs.floating.current.inert = exiting }, [exiting, refs.floating]) + const [side, alignment] = actual.split('-'), expand = transitionPreset === 'expand' + const origin = side === 'bottom' || side === 'top' ? `${alignment === 'start' ? 'left' : alignment === 'end' ? 'right' : 'center'} ${side === 'bottom' ? 'top' : 'bottom'}` : `${side === 'left' ? 'right' : 'left'} ${alignment === 'start' ? 'top' : alignment === 'end' ? 'bottom' : 'center'}` + const element =
{children}
+ const managed = manageFocus ? {element} : element + if (inline) return managed + const target = typeof portalRoot === 'function' ? portalRoot() : portalRoot instanceof HTMLElement ? portalRoot : document.body + return createPortal({managed}, target ?? document.body) +} diff --git a/src/renderer/src/workbuddy/composer/permission/SourcePermissionControl.tsx b/src/renderer/src/workbuddy/composer/permission/SourcePermissionControl.tsx index c0f4d43..79a6d7d 100644 --- a/src/renderer/src/workbuddy/composer/permission/SourcePermissionControl.tsx +++ b/src/renderer/src/workbuddy/composer/permission/SourcePermissionControl.tsx @@ -2,7 +2,7 @@ import { useEffect, useId, useRef, useState, type ComponentType, type SVGProps } import { createPortal } from 'react-dom' import type { PermissionMode } from '@shared/conversation' import { IconChevronSmall, IconClose } from '../../../components/icons' -import { SourceSelectPopover, SourceSelectTooltip } from '../../../components/source-select/SourceSelectPopover' +import { Popover as SourceSelectPopover, PopoverTooltip as SourceSelectTooltip } from '../../../ui/Popover' import { SelectCheckGlyph } from '../../../components/source-select/SourceSelectIcons' import { sourceConversationLabel as t } from '../../conversation/source-labels' import { ShieldDangerIcon, ShieldSecuredIcon, ShieldErrorIcon } from './SourcePermissionIcons'