From 67829e5a585bc5c229ff948b8c19e1397cef1704 Mon Sep 17 00:00:00 2001 From: lijianan <574980606@qq.com> Date: Sun, 7 Jun 2026 22:47:23 +0800 Subject: [PATCH 1/4] chore: update deps --- docs/demos/safe-hover.md | 8 ++ docs/examples/safe-hover.tsx | 61 +++++++++++ src/index.tsx | 151 ++++++++++++++++++++++++--- src/util/safeHover.ts | 193 +++++++++++++++++++++++++++++++++++ tests/basic.test.jsx | 138 +++++++++++++++++++++++++ tests/safeHover.test.ts | 141 +++++++++++++++++++++++++ 6 files changed, 678 insertions(+), 14 deletions(-) create mode 100644 docs/demos/safe-hover.md create mode 100644 docs/examples/safe-hover.tsx create mode 100644 src/util/safeHover.ts create mode 100644 tests/safeHover.test.ts diff --git a/docs/demos/safe-hover.md b/docs/demos/safe-hover.md new file mode 100644 index 00000000..5df13f22 --- /dev/null +++ b/docs/demos/safe-hover.md @@ -0,0 +1,8 @@ +--- +title: Safe Hover +nav: + title: Demo + path: /demo +--- + + diff --git a/docs/examples/safe-hover.tsx b/docs/examples/safe-hover.tsx new file mode 100644 index 00000000..2af3d387 --- /dev/null +++ b/docs/examples/safe-hover.tsx @@ -0,0 +1,61 @@ +import Trigger from '@rc-component/trigger'; +import React from 'react'; +import '../../assets/index.less'; + +const builtinPlacements = { + top: { + points: ['bc', 'tc'], + offset: [0, -56], + }, +}; + +const popupStyle: React.CSSProperties = { + width: 240, + padding: 12, + background: '#fff', + border: '1px solid #d9d9d9', + boxShadow: '0 6px 16px rgba(0, 0, 0, 0.12)', +}; + +const SafeHoverDemo = () => { + return ( +
+ + Safe hover popup +
+ Move through the gap to reach me. +
+ +
+ } + > + + + +
+ The popup is offset upward, leaving a blank hover gap. +
+ + ); +}; + +export default SafeHoverDemo; diff --git a/src/index.tsx b/src/index.tsx index bb5fb838..a95b58db 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -22,6 +22,8 @@ import useDelay from './hooks/useDelay'; import useWatch from './hooks/useWatch'; import useWinClick from './hooks/useWinClick'; import type { PortalProps } from '@rc-component/portal'; +import { isPointInSafeHoverArea } from './util/safeHover'; +import type { SafeHoverPoint } from './util/safeHover'; import type { ActionType, @@ -421,6 +423,120 @@ export function generateTrigger( }, delay); }; + const safeHoverRef = React.useRef<{ + doc: Document; + handler: (event: MouseEvent) => void; + refreshTimer: ReturnType | null; + } | null>(null); + + const clearSafeHover = useEvent(() => { + const safeHover = safeHoverRef.current; + + if (safeHover) { + safeHover.doc.removeEventListener('mousemove', safeHover.handler); + safeHover.doc.removeEventListener('pointermove', safeHover.handler); + + if (safeHover.refreshTimer) { + clearTimeout(safeHover.refreshTimer); + } + + safeHoverRef.current = null; + } + }); + + const startSafeHover = useEvent( + ( + event: React.MouseEvent | React.PointerEvent, + ) => { + if (!targetEle || !popupEle || !openRef.current) { + return false; + } + + const leavePoint: SafeHoverPoint = [event.clientX, event.clientY]; + const targetRect = targetEle.getBoundingClientRect(); + const popupRect = popupEle.getBoundingClientRect(); + + if ( + !isPointInSafeHoverArea(leavePoint, leavePoint, targetRect, popupRect) + ) { + return false; + } + + const doc = targetEle.ownerDocument; + + clearSafeHover(); + + let latestPoint = leavePoint; + + const isPointSafe = (point: SafeHoverPoint) => + isPointInSafeHoverArea( + point, + leavePoint, + targetEle.getBoundingClientRect(), + popupEle.getBoundingClientRect(), + ); + + const refreshDelay = + mouseLeaveDelay > 0 + ? Math.max(16, Math.min(mouseLeaveDelay * 500, 100)) + : 0; + const scheduleRefresh = () => { + const safeHover = safeHoverRef.current; + + if (!safeHover || !refreshDelay) { + return; + } + + safeHover.refreshTimer = setTimeout(() => { + if (isPointSafe(latestPoint)) { + triggerOpen(false, mouseLeaveDelay); + scheduleRefresh(); + } else { + clearSafeHover(); + triggerOpen(false, mouseLeaveDelay); + } + }, refreshDelay); + }; + + // Keep the existing close delay alive while the cursor crosses the gap. + const handler = (nativeEvent: MouseEvent) => { + latestPoint = [nativeEvent.clientX, nativeEvent.clientY]; + + if (isPointSafe(latestPoint)) { + triggerOpen(false, mouseLeaveDelay); + } else { + clearSafeHover(); + triggerOpen(false, mouseLeaveDelay); + } + }; + + doc.addEventListener('mousemove', handler); + doc.addEventListener('pointermove', handler); + safeHoverRef.current = { + doc, + handler, + refreshTimer: null, + }; + + triggerOpen(false, mouseLeaveDelay); + scheduleRefresh(); + + return true; + }, + ); + + React.useEffect(() => { + return () => { + clearSafeHover(); + }; + }, [clearSafeHover]); + + useLayoutEffect(() => { + if (!mergedOpen) { + clearSafeHover(); + } + }, [mergedOpen, clearSafeHover]); + function onEsc({ top }: Parameters[0]) { if (top) { triggerOpen(false); @@ -668,6 +784,7 @@ export function generateTrigger( if (hoverToShow) { const onMouseEnterCallback = (event: React.MouseEvent) => { + clearSafeHover(); setMousePosByEvent(event); }; @@ -688,6 +805,8 @@ export function generateTrigger( ); onPopupMouseEnter = (event) => { + clearSafeHover(); + // Only trigger re-open when popup is visible if ( (mergedOpen || inMotion) && @@ -706,22 +825,26 @@ export function generateTrigger( } if (hoverToHide) { - wrapperAction( - 'onMouseLeave', - false, - mouseLeaveDelay, - undefined, - ignoreMouseTrigger, - ); - wrapperAction( - 'onPointerLeave', - false, - mouseLeaveDelay, - undefined, - ignoreMouseTrigger, - ); + cloneProps.onMouseLeave = (event, ...args) => { + if (!ignoreMouseTrigger() && !startSafeHover(event)) { + triggerOpen(false, mouseLeaveDelay); + } + + // Pass to origin + originChildProps.onMouseLeave?.(event, ...args); + }; + + cloneProps.onPointerLeave = (event, ...args) => { + if (!ignoreMouseTrigger() && !startSafeHover(event)) { + triggerOpen(false, mouseLeaveDelay); + } + + // Pass to origin + originChildProps.onPointerLeave?.(event, ...args); + }; onPopupMouseLeave = () => { + clearSafeHover(); triggerOpen(false, mouseLeaveDelay); }; } diff --git a/src/util/safeHover.ts b/src/util/safeHover.ts new file mode 100644 index 00000000..a609d1c7 --- /dev/null +++ b/src/util/safeHover.ts @@ -0,0 +1,193 @@ +export type SafeHoverPoint = [x: number, y: number]; + +export type SafeHoverRect = Pick< + DOMRect, + 'left' | 'right' | 'top' | 'bottom' | 'width' | 'height' +>; + +export type SafeHoverSide = 'top' | 'bottom' | 'left' | 'right'; + +function isPointInPolygon(point: SafeHoverPoint, polygon: SafeHoverPoint[]) { + const [x, y] = point; + let isInside = false; + + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const [xi, yi] = polygon[i]; + const [xj, yj] = polygon[j]; + const intersect = + yi >= y !== yj >= y && x <= ((xj - xi) * (y - yi)) / (yj - yi) + xi; + + if (intersect) { + isInside = !isInside; + } + } + + return isInside; +} + +function isPointInRect(point: SafeHoverPoint, rect: SafeHoverRect) { + return ( + point[0] >= rect.left && + point[0] <= rect.right && + point[1] >= rect.top && + point[1] <= rect.bottom + ); +} + +export function getSafeHoverSide( + targetRect: SafeHoverRect, + popupRect: SafeHoverRect, +): SafeHoverSide | null { + const gaps: { side: SafeHoverSide; value: number }[] = [ + { side: 'top', value: targetRect.top - popupRect.bottom }, + { side: 'bottom', value: popupRect.top - targetRect.bottom }, + { side: 'left', value: targetRect.left - popupRect.right }, + { side: 'right', value: popupRect.left - targetRect.right }, + ]; + + const largestGap = gaps.reduce((prev, next) => + next.value > prev.value ? next : prev, + ); + + return largestGap.value > 0 ? largestGap.side : null; +} + +function isLeavePointTowardsPopup( + side: SafeHoverSide, + leavePoint: SafeHoverPoint, + targetRect: SafeHoverRect, +) { + const [x, y] = leavePoint; + + switch (side) { + case 'top': + return y <= targetRect.top + 1; + + case 'bottom': + return y >= targetRect.bottom - 1; + + case 'left': + return x <= targetRect.left + 1; + + case 'right': + return x >= targetRect.right - 1; + } +} + +function getSafeHoverGapPolygon( + side: SafeHoverSide, + targetRect: SafeHoverRect, + popupRect: SafeHoverRect, + buffer: number, +): SafeHoverPoint[] { + const verticalRect = + popupRect.width > targetRect.width ? targetRect : popupRect; + const horizontalRect = + popupRect.height > targetRect.height ? targetRect : popupRect; + const left = verticalRect.left - buffer; + const right = verticalRect.right + buffer; + const top = horizontalRect.top - buffer; + const bottom = horizontalRect.bottom + buffer; + + switch (side) { + case 'top': + return [ + [left, popupRect.bottom - 1], + [left, targetRect.top + 1], + [right, targetRect.top + 1], + [right, popupRect.bottom - 1], + ]; + + case 'bottom': + return [ + [left, targetRect.bottom - 1], + [left, popupRect.top + 1], + [right, popupRect.top + 1], + [right, targetRect.bottom - 1], + ]; + + case 'left': + return [ + [popupRect.right - 1, top], + [popupRect.right - 1, bottom], + [targetRect.left + 1, bottom], + [targetRect.left + 1, top], + ]; + + case 'right': + return [ + [targetRect.right - 1, top], + [targetRect.right - 1, bottom], + [popupRect.left + 1, bottom], + [popupRect.left + 1, top], + ]; + } +} + +function getSafeHoverIntentPolygon( + side: SafeHoverSide, + leavePoint: SafeHoverPoint, + popupRect: SafeHoverRect, + buffer: number, +): SafeHoverPoint[] { + switch (side) { + case 'top': + return [ + leavePoint, + [popupRect.left - buffer, popupRect.bottom + buffer], + [popupRect.right + buffer, popupRect.bottom + buffer], + ]; + + case 'bottom': + return [ + leavePoint, + [popupRect.right + buffer, popupRect.top - buffer], + [popupRect.left - buffer, popupRect.top - buffer], + ]; + + case 'left': + return [ + leavePoint, + [popupRect.right + buffer, popupRect.bottom + buffer], + [popupRect.right + buffer, popupRect.top - buffer], + ]; + + case 'right': + return [ + leavePoint, + [popupRect.left - buffer, popupRect.top - buffer], + [popupRect.left - buffer, popupRect.bottom + buffer], + ]; + } +} + +export function isPointInSafeHoverArea( + point: SafeHoverPoint, + leavePoint: SafeHoverPoint, + targetRect: SafeHoverRect, + popupRect: SafeHoverRect, + buffer = 0.5, +) { + const side = getSafeHoverSide(targetRect, popupRect); + + if (!side || !isLeavePointTowardsPopup(side, leavePoint, targetRect)) { + return false; + } + + if (isPointInRect(point, targetRect) || isPointInRect(point, popupRect)) { + return true; + } + + // The gap polygon keeps the straight corridor open; the intent polygon + // catches diagonal movement toward the popup edge. + return ( + isPointInPolygon( + point, + getSafeHoverGapPolygon(side, targetRect, popupRect, buffer), + ) || + isPointInPolygon( + point, + getSafeHoverIntentPolygon(side, leavePoint, popupRect, buffer), + ) + ); +} diff --git a/tests/basic.test.jsx b/tests/basic.test.jsx index 1da873db..6efc28c7 100644 --- a/tests/basic.test.jsx +++ b/tests/basic.test.jsx @@ -136,6 +136,17 @@ describe('Trigger.Basic', () => { }); describe('hover works', () => { + function mockRect(element, rect) { + element.getBoundingClientRect = jest.fn(() => ({ + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + })); + } + it('mouse event', () => { const { container } = render( { trigger(document, '.rc-trigger-popup', 'pointerEnter'); expect(isPopupHidden()).toBeFalsy(); }); + + it('keeps popup open while mouse moves through safe hover area', () => { + const { container } = render( + trigger} + > +
hover
+
, + ); + + const target = container.querySelector('.target'); + + fireEvent.mouseEnter(target, { clientX: 50, clientY: 10 }); + act(() => jest.runAllTimers()); + + const popup = document.querySelector('.rc-trigger-popup'); + + mockRect(target, { left: 0, top: 0, width: 100, height: 20 }); + mockRect(popup, { left: 20, top: 60, width: 60, height: 30 }); + + fireEvent.mouseLeave(target, { clientX: 50, clientY: 20 }); + act(() => jest.advanceTimersByTime(50)); + + fireEvent.mouseMove(document, { clientX: 50, clientY: 40 }); + act(() => jest.advanceTimersByTime(250)); + + expect(isPopupHidden()).toBeFalsy(); + + fireEvent.mouseEnter(popup, { clientX: 50, clientY: 60 }); + act(() => jest.runAllTimers()); + + expect(isPopupHidden()).toBeFalsy(); + }); + + it('closes popup after mouse leaves safe hover area', () => { + const { container } = render( + trigger} + > +
hover
+
, + ); + + const target = container.querySelector('.target'); + + fireEvent.mouseEnter(target, { clientX: 50, clientY: 10 }); + act(() => jest.runAllTimers()); + + const popup = document.querySelector('.rc-trigger-popup'); + + mockRect(target, { left: 0, top: 0, width: 100, height: 20 }); + mockRect(popup, { left: 20, top: 60, width: 60, height: 30 }); + + fireEvent.mouseLeave(target, { clientX: 50, clientY: 20 }); + act(() => jest.advanceTimersByTime(50)); + + fireEvent.mouseMove(document, { clientX: 50, clientY: 40 }); + act(() => jest.advanceTimersByTime(80)); + + expect(isPopupHidden()).toBeFalsy(); + + fireEvent.mouseMove(document, { clientX: 150, clientY: 40 }); + act(() => jest.advanceTimersByTime(100)); + + expect(isPopupHidden()).toBeTruthy(); + }); + + it('closes popup when safe hover area disappears while mouse is paused', () => { + const { container } = render( + trigger} + > +
hover
+
, + ); + + const target = container.querySelector('.target'); + + fireEvent.mouseEnter(target, { clientX: 50, clientY: 10 }); + act(() => jest.runAllTimers()); + + const popup = document.querySelector('.rc-trigger-popup'); + + mockRect(target, { left: 0, top: 0, width: 100, height: 20 }); + mockRect(popup, { left: 20, top: 60, width: 60, height: 30 }); + + fireEvent.mouseLeave(target, { clientX: 50, clientY: 20 }); + + mockRect(popup, { left: 10, top: 10, width: 60, height: 30 }); + act(() => jest.advanceTimersByTime(150)); + + expect(isPopupHidden()).toBeTruthy(); + }); + + it('keeps zero mouseLeaveDelay closing immediately', () => { + const { container } = render( + trigger} + > +
hover
+
, + ); + + const target = container.querySelector('.target'); + + fireEvent.mouseEnter(target, { clientX: 50, clientY: 10 }); + act(() => jest.runAllTimers()); + + const popup = document.querySelector('.rc-trigger-popup'); + + mockRect(target, { left: 0, top: 0, width: 100, height: 20 }); + mockRect(popup, { left: 20, top: 60, width: 60, height: 30 }); + + fireEvent.mouseLeave(target, { clientX: 50, clientY: 20 }); + act(() => jest.runAllTimers()); + + expect(isPopupHidden()).toBeTruthy(); + }); + }); it('contextMenu works', () => { diff --git a/tests/safeHover.test.ts b/tests/safeHover.test.ts new file mode 100644 index 00000000..664c626c --- /dev/null +++ b/tests/safeHover.test.ts @@ -0,0 +1,141 @@ +import { + getSafeHoverSide, + isPointInSafeHoverArea, + type SafeHoverRect, +} from '../src/util/safeHover'; + +function rect( + left: number, + top: number, + width: number, + height: number, +): SafeHoverRect { + return { + left, + top, + width, + height, + right: left + width, + bottom: top + height, + }; +} + +describe('safeHover util', () => { + it('detects popup side from separated rectangles', () => { + const target = rect(40, 40, 20, 20); + + expect(getSafeHoverSide(target, rect(40, 0, 20, 20))).toBe('top'); + expect(getSafeHoverSide(target, rect(40, 80, 20, 20))).toBe('bottom'); + expect(getSafeHoverSide(target, rect(0, 40, 20, 20))).toBe('left'); + expect(getSafeHoverSide(target, rect(80, 40, 20, 20))).toBe('right'); + expect(getSafeHoverSide(target, rect(45, 45, 20, 20))).toBeNull(); + }); + + it('keeps the vertical gap and diagonal intent safe', () => { + const target = rect(0, 0, 100, 20); + const popup = rect(20, 60, 60, 30); + const leavePoint: [number, number] = [50, 20]; + + expect(isPointInSafeHoverArea([50, 10], leavePoint, target, popup)).toBe( + true, + ); + expect(isPointInSafeHoverArea([50, 70], leavePoint, target, popup)).toBe( + true, + ); + expect(isPointInSafeHoverArea([50, 40], leavePoint, target, popup)).toBe( + true, + ); + expect(isPointInSafeHoverArea([30, 50], leavePoint, target, popup)).toBe( + true, + ); + expect(isPointInSafeHoverArea([150, 40], leavePoint, target, popup)).toBe( + false, + ); + }); + + it('rejects leave points moving away from the popup', () => { + expect( + isPointInSafeHoverArea( + [50, 40], + [50, 0], + rect(0, 0, 100, 20), + rect(20, 60, 60, 30), + ), + ).toBe(false); + expect( + isPointInSafeHoverArea( + [50, 60], + [50, 100], + rect(0, 80, 100, 20), + rect(20, 10, 60, 30), + ), + ).toBe(false); + expect( + isPointInSafeHoverArea( + [60, 50], + [100, 50], + rect(80, 0, 20, 100), + rect(10, 20, 30, 60), + ), + ).toBe(false); + expect( + isPointInSafeHoverArea( + [40, 50], + [0, 50], + rect(0, 0, 20, 100), + rect(60, 20, 30, 60), + ), + ).toBe(false); + }); + + it('keeps horizontal gaps safe', () => { + expect( + isPointInSafeHoverArea( + [60, 50], + [80, 50], + rect(80, 0, 20, 100), + rect(10, 20, 30, 60), + ), + ).toBe(true); + expect( + isPointInSafeHoverArea( + [40, 50], + [20, 50], + rect(0, 0, 20, 100), + rect(60, 20, 30, 60), + ), + ).toBe(true); + }); + + it('keeps top gap and diagonal intent safe', () => { + const target = rect(40, 80, 20, 20); + const popup = rect(0, 10, 100, 30); + const leavePoint: [number, number] = [50, 80]; + + expect(isPointInSafeHoverArea([50, 60], leavePoint, target, popup)).toBe( + true, + ); + expect(isPointInSafeHoverArea([25, 55], leavePoint, target, popup)).toBe( + true, + ); + }); + + it('keeps horizontal diagonal intent safe', () => { + expect( + isPointInSafeHoverArea( + [55, 25], + [80, 50], + rect(80, 40, 20, 20), + rect(10, 0, 30, 100), + ), + ).toBe(true); + expect( + isPointInSafeHoverArea( + [45, 25], + [20, 50], + rect(0, 40, 20, 20), + rect(60, 0, 30, 100), + ), + ).toBe(true); + }); +}); From 4a69bd2de01ec7419b5fa9a8d8b6c1c29e3c652f Mon Sep 17 00:00:00 2001 From: lijianan <574980606@qq.com> Date: Sun, 7 Jun 2026 23:04:15 +0800 Subject: [PATCH 2/4] chore: update deps --- docs/examples/safe-hover.tsx | 93 ++++++++++++++++++++++++++++++++++-- src/util/safeHover.ts | 38 ++++++++++----- 2 files changed, 116 insertions(+), 15 deletions(-) diff --git a/docs/examples/safe-hover.tsx b/docs/examples/safe-hover.tsx index 2af3d387..53e6dd62 100644 --- a/docs/examples/safe-hover.tsx +++ b/docs/examples/safe-hover.tsx @@ -1,7 +1,28 @@ -import Trigger from '@rc-component/trigger'; +import Trigger, { type TriggerRef } from '@rc-component/trigger'; import React from 'react'; +import { + getSafeHoverAreaPolygons, + type SafeHoverPoint, +} from '../../src/util/safeHover'; import '../../assets/index.less'; +type SafeHoverPolygon = { + points: SafeHoverPoint[]; + fill: string; + stroke: string; +}; + +const safeHoverPolygonStyles = [ + { + fill: 'rgba(255, 176, 32, 0.22)', + stroke: 'rgba(222, 121, 0, 0.6)', + }, + { + fill: 'rgba(22, 119, 255, 0.16)', + stroke: 'rgba(22, 119, 255, 0.55)', + }, +]; + const builtinPlacements = { top: { points: ['bc', 'tc'], @@ -18,16 +39,77 @@ const popupStyle: React.CSSProperties = { }; const SafeHoverDemo = () => { + const triggerRef = React.useRef(null); + + const [safeHoverPolygons, setSafeHoverPolygons] = React.useState< + SafeHoverPolygon[] + >([]); + + const updateSafeHoverPolygons = ( + event: React.MouseEvent | React.PointerEvent, + ) => { + const target = triggerRef.current?.nativeElement; + const popup = triggerRef.current?.popupElement; + + if (!target || !popup) { + setSafeHoverPolygons([]); + return; + } + + const leavePoint: SafeHoverPoint = [event.clientX, event.clientY]; + setSafeHoverPolygons( + getSafeHoverAreaPolygons( + leavePoint, + target.getBoundingClientRect(), + popup.getBoundingClientRect(), + ).map((points, index) => ({ + points, + ...safeHoverPolygonStyles[index], + })), + ); + }; + return (
+ {safeHoverPolygons.length > 0 && ( + + {safeHoverPolygons.map(({ points, fill, stroke }, index) => ( + point.join(',')).join(' ')} + fill={fill} + stroke={stroke} + strokeDasharray="4 3" + strokeWidth={1} + /> + ))} + + )} { + if (!nextOpen) { + setSafeHoverPolygons([]); + } + }} popup={ -
+
setSafeHoverPolygons([])}> Safe hover popup
Move through the gap to reach me. @@ -38,7 +120,12 @@ const SafeHoverDemo = () => {
} > - diff --git a/src/util/safeHover.ts b/src/util/safeHover.ts index a609d1c7..e67b3136 100644 --- a/src/util/safeHover.ts +++ b/src/util/safeHover.ts @@ -161,8 +161,7 @@ function getSafeHoverIntentPolygon( } } -export function isPointInSafeHoverArea( - point: SafeHoverPoint, +export function getSafeHoverAreaPolygons( leavePoint: SafeHoverPoint, targetRect: SafeHoverRect, popupRect: SafeHoverRect, @@ -171,6 +170,30 @@ export function isPointInSafeHoverArea( const side = getSafeHoverSide(targetRect, popupRect); if (!side || !isLeavePointTowardsPopup(side, leavePoint, targetRect)) { + return []; + } + + return [ + getSafeHoverGapPolygon(side, targetRect, popupRect, buffer), + getSafeHoverIntentPolygon(side, leavePoint, popupRect, buffer), + ]; +} + +export function isPointInSafeHoverArea( + point: SafeHoverPoint, + leavePoint: SafeHoverPoint, + targetRect: SafeHoverRect, + popupRect: SafeHoverRect, + buffer = 0.5, +) { + const safeHoverPolygons = getSafeHoverAreaPolygons( + leavePoint, + targetRect, + popupRect, + buffer, + ); + + if (!safeHoverPolygons.length) { return false; } @@ -180,14 +203,5 @@ export function isPointInSafeHoverArea( // The gap polygon keeps the straight corridor open; the intent polygon // catches diagonal movement toward the popup edge. - return ( - isPointInPolygon( - point, - getSafeHoverGapPolygon(side, targetRect, popupRect, buffer), - ) || - isPointInPolygon( - point, - getSafeHoverIntentPolygon(side, leavePoint, popupRect, buffer), - ) - ); + return safeHoverPolygons.some((polygon) => isPointInPolygon(point, polygon)); } From ba5359bd4535787c914f9f92a4846812532ed60c Mon Sep 17 00:00:00 2001 From: lijianan <574980606@qq.com> Date: Mon, 8 Jun 2026 01:56:37 +0800 Subject: [PATCH 3/4] update --- docs/examples/safe-hover.tsx | 65 ++++++++++++++++-------------------- src/util/safeHover.ts | 61 ++++++++++++++------------------- typings.d.ts | 1 + 3 files changed, 55 insertions(+), 72 deletions(-) create mode 100644 typings.d.ts diff --git a/docs/examples/safe-hover.tsx b/docs/examples/safe-hover.tsx index 53e6dd62..96b6c30a 100644 --- a/docs/examples/safe-hover.tsx +++ b/docs/examples/safe-hover.tsx @@ -1,16 +1,15 @@ -import Trigger, { type TriggerRef } from '@rc-component/trigger'; -import React from 'react'; -import { - getSafeHoverAreaPolygons, - type SafeHoverPoint, -} from '../../src/util/safeHover'; +import Trigger from '@rc-component/trigger'; +import type { TriggerRef } from '@rc-component/trigger'; +import React, { useState } from 'react'; +import { getSafeHoverAreaPolygons } from '../../src/util/safeHover'; +import type { SafeHoverPoint } from '../../src/util/safeHover'; import '../../assets/index.less'; -type SafeHoverPolygon = { +interface SafeHoverPolygon { points: SafeHoverPoint[]; fill: string; stroke: string; -}; +} const safeHoverPolygonStyles = [ { @@ -38,12 +37,10 @@ const popupStyle: React.CSSProperties = { boxShadow: '0 6px 16px rgba(0, 0, 0, 0.12)', }; -const SafeHoverDemo = () => { +const SafeHoverDemo: React.FC = () => { const triggerRef = React.useRef(null); - const [safeHoverPolygons, setSafeHoverPolygons] = React.useState< - SafeHoverPolygon[] - >([]); + const [safeHoverPolygons, setPolygons] = useState([]); const updateSafeHoverPolygons = ( event: React.MouseEvent | React.PointerEvent, @@ -52,20 +49,18 @@ const SafeHoverDemo = () => { const popup = triggerRef.current?.popupElement; if (!target || !popup) { - setSafeHoverPolygons([]); + setPolygons([]); return; } const leavePoint: SafeHoverPoint = [event.clientX, event.clientY]; - setSafeHoverPolygons( + + setPolygons( getSafeHoverAreaPolygons( leavePoint, target.getBoundingClientRect(), popup.getBoundingClientRect(), - ).map((points, index) => ({ - points, - ...safeHoverPolygonStyles[index], - })), + ).map((points, i) => ({ points, ...safeHoverPolygonStyles[i] })), ); }; @@ -83,17 +78,19 @@ const SafeHoverDemo = () => { zIndex: 999, }} > - {safeHoverPolygons.map(({ points, fill, stroke }, index) => ( - point.join(',')).join(' ')} - fill={fill} - stroke={stroke} - strokeDasharray="4 3" - strokeWidth={1} - /> - ))} + {safeHoverPolygons.map(({ points, fill, stroke }, index) => { + return ( + point.join(',')).join(' ')} + fill={fill} + stroke={stroke} + strokeDasharray="4 3" + strokeWidth={1} + /> + ); + })} )} { popupStyle={popupStyle} onOpenChange={(nextOpen) => { if (!nextOpen) { - setSafeHoverPolygons([]); + setPolygons([]); } }} popup={ -
setSafeHoverPolygons([])}> +
setPolygons([])}> Safe hover popup
Move through the gap to reach me.
-
} > @@ -126,10 +120,9 @@ const SafeHoverDemo = () => { onMouseLeave={updateSafeHoverPolygons} onPointerLeave={updateSafeHoverPolygons} > - Hover target + trigger -
{ const [x, y] = point; let isInside = false; @@ -23,63 +26,57 @@ function isPointInPolygon(point: SafeHoverPoint, polygon: SafeHoverPoint[]) { } return isInside; -} +}; -function isPointInRect(point: SafeHoverPoint, rect: SafeHoverRect) { +export const isPointInRect = (point: SafeHoverPoint, rect: SafeHoverRect) => { return ( point[0] >= rect.left && point[0] <= rect.right && point[1] >= rect.top && point[1] <= rect.bottom ); -} +}; -export function getSafeHoverSide( +export const getSafeHoverSide = ( targetRect: SafeHoverRect, popupRect: SafeHoverRect, -): SafeHoverSide | null { +): SafeHoverSide | null => { const gaps: { side: SafeHoverSide; value: number }[] = [ { side: 'top', value: targetRect.top - popupRect.bottom }, { side: 'bottom', value: popupRect.top - targetRect.bottom }, { side: 'left', value: targetRect.left - popupRect.right }, { side: 'right', value: popupRect.left - targetRect.right }, ]; - const largestGap = gaps.reduce((prev, next) => next.value > prev.value ? next : prev, ); - return largestGap.value > 0 ? largestGap.side : null; -} +}; -function isLeavePointTowardsPopup( +const isLeavePointTowardsPopup = ( side: SafeHoverSide, leavePoint: SafeHoverPoint, targetRect: SafeHoverRect, -) { +) => { const [x, y] = leavePoint; - switch (side) { case 'top': return y <= targetRect.top + 1; - case 'bottom': return y >= targetRect.bottom - 1; - case 'left': return x <= targetRect.left + 1; - case 'right': return x >= targetRect.right - 1; } -} +}; -function getSafeHoverGapPolygon( +const getSafeHoverGapPolygon = ( side: SafeHoverSide, targetRect: SafeHoverRect, popupRect: SafeHoverRect, buffer: number, -): SafeHoverPoint[] { +): SafeHoverPoint[] => { const verticalRect = popupRect.width > targetRect.width ? targetRect : popupRect; const horizontalRect = @@ -88,7 +85,6 @@ function getSafeHoverGapPolygon( const right = verticalRect.right + buffer; const top = horizontalRect.top - buffer; const bottom = horizontalRect.bottom + buffer; - switch (side) { case 'top': return [ @@ -97,7 +93,6 @@ function getSafeHoverGapPolygon( [right, targetRect.top + 1], [right, popupRect.bottom - 1], ]; - case 'bottom': return [ [left, targetRect.bottom - 1], @@ -105,7 +100,6 @@ function getSafeHoverGapPolygon( [right, popupRect.top + 1], [right, targetRect.bottom - 1], ]; - case 'left': return [ [popupRect.right - 1, top], @@ -113,7 +107,6 @@ function getSafeHoverGapPolygon( [targetRect.left + 1, bottom], [targetRect.left + 1, top], ]; - case 'right': return [ [targetRect.right - 1, top], @@ -122,14 +115,14 @@ function getSafeHoverGapPolygon( [popupRect.left + 1, top], ]; } -} +}; -function getSafeHoverIntentPolygon( +const getSafeHoverIntentPolygon = ( side: SafeHoverSide, leavePoint: SafeHoverPoint, popupRect: SafeHoverRect, buffer: number, -): SafeHoverPoint[] { +): SafeHoverPoint[] => { switch (side) { case 'top': return [ @@ -137,21 +130,18 @@ function getSafeHoverIntentPolygon( [popupRect.left - buffer, popupRect.bottom + buffer], [popupRect.right + buffer, popupRect.bottom + buffer], ]; - case 'bottom': return [ leavePoint, [popupRect.right + buffer, popupRect.top - buffer], [popupRect.left - buffer, popupRect.top - buffer], ]; - case 'left': return [ leavePoint, [popupRect.right + buffer, popupRect.bottom + buffer], [popupRect.right + buffer, popupRect.top - buffer], ]; - case 'right': return [ leavePoint, @@ -159,33 +149,32 @@ function getSafeHoverIntentPolygon( [popupRect.left - buffer, popupRect.bottom + buffer], ]; } -} +}; -export function getSafeHoverAreaPolygons( +export const getSafeHoverAreaPolygons = ( leavePoint: SafeHoverPoint, targetRect: SafeHoverRect, popupRect: SafeHoverRect, buffer = 0.5, -) { +) => { const side = getSafeHoverSide(targetRect, popupRect); if (!side || !isLeavePointTowardsPopup(side, leavePoint, targetRect)) { return []; } - return [ getSafeHoverGapPolygon(side, targetRect, popupRect, buffer), getSafeHoverIntentPolygon(side, leavePoint, popupRect, buffer), ]; -} +}; -export function isPointInSafeHoverArea( +export const isPointInSafeHoverArea = ( point: SafeHoverPoint, leavePoint: SafeHoverPoint, targetRect: SafeHoverRect, popupRect: SafeHoverRect, buffer = 0.5, -) { +) => { const safeHoverPolygons = getSafeHoverAreaPolygons( leavePoint, targetRect, @@ -204,4 +193,4 @@ export function isPointInSafeHoverArea( // The gap polygon keeps the straight corridor open; the intent polygon // catches diagonal movement toward the popup edge. return safeHoverPolygons.some((polygon) => isPointInPolygon(point, polygon)); -} +}; diff --git a/typings.d.ts b/typings.d.ts new file mode 100644 index 00000000..1ea39606 --- /dev/null +++ b/typings.d.ts @@ -0,0 +1 @@ +declare module '*.less'; From 6ba40acd20c99a50d6c9d4fbd7b94e584862b65b Mon Sep 17 00:00:00 2001 From: lijianan <574980606@qq.com> Date: Mon, 8 Jun 2026 17:59:15 +0800 Subject: [PATCH 4/4] update --- src/index.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index a95b58db..673d5bfa 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -434,7 +434,6 @@ export function generateTrigger( if (safeHover) { safeHover.doc.removeEventListener('mousemove', safeHover.handler); - safeHover.doc.removeEventListener('pointermove', safeHover.handler); if (safeHover.refreshTimer) { clearTimeout(safeHover.refreshTimer); @@ -448,7 +447,12 @@ export function generateTrigger( ( event: React.MouseEvent | React.PointerEvent, ) => { - if (!targetEle || !popupEle || !openRef.current) { + if ( + !targetEle || + !popupEle || + !openRef.current || + mouseLeaveDelay <= 0 + ) { return false; } @@ -511,12 +515,8 @@ export function generateTrigger( }; doc.addEventListener('mousemove', handler); - doc.addEventListener('pointermove', handler); - safeHoverRef.current = { - doc, - handler, - refreshTimer: null, - }; + + safeHoverRef.current = { doc, handler, refreshTimer: null }; triggerOpen(false, mouseLeaveDelay); scheduleRefresh();