From d2af82f8c719d0ebb463454a0ed31de15362ab03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=B3=E5=BB=B7=E5=AE=89?= <73953029+nrps9909@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:27:07 +0800 Subject: [PATCH] fix(Mask): clip click blockers at viewport edges --- src/Mask.tsx | 10 ++-- tests/Mask.test.tsx | 74 +++++++++++++++++++++++ tests/__snapshots__/index.test.tsx.snap | 78 ++++++++++++------------- 3 files changed, 118 insertions(+), 44 deletions(-) create mode 100644 tests/Mask.test.tsx diff --git a/src/Mask.tsx b/src/Mask.tsx index 0cda275..85a5075 100644 --- a/src/Mask.tsx +++ b/src/Mask.tsx @@ -86,7 +86,7 @@ const Mask: React.FC = props => { }} > {showMask ? ( - + @@ -138,16 +138,16 @@ const Mask: React.FC = props => { {/* Right */} diff --git a/tests/Mask.test.tsx b/tests/Mask.test.tsx new file mode 100644 index 0000000..9aa354f --- /dev/null +++ b/tests/Mask.test.tsx @@ -0,0 +1,74 @@ +import { render } from '@testing-library/react'; +import React from 'react'; +import Mask from '../src/Mask'; +import type { PosInfo } from '../src/hooks/useTarget'; + +const renderMask = (pos: PosInfo, style?: React.CSSProperties) => + render(); + +const getMaskElements = () => { + const svg = document.querySelector('.rc-tour-mask svg'); + const [, top, left, bottom, right] = Array.from( + svg.querySelectorAll(':scope > rect'), + ); + + return { svg, top, left, bottom, right }; +}; + +describe('Mask', () => { + it('keeps the normal click-blocking geometry inside the mask', () => { + renderMask({ left: 100, top: 50, width: 200, height: 100, radius: 2 }); + + const { svg, top, left, bottom, right } = getMaskElements(); + + expect(svg).toHaveStyle({ overflow: 'hidden' }); + expect(top).toHaveAttribute('width', '100%'); + expect(top).toHaveAttribute('height', '50'); + expect(left).toHaveAttribute('width', '100'); + expect(left).toHaveAttribute('height', '100%'); + expect(bottom).toHaveAttribute('y', '150'); + expect(bottom).toHaveAttribute('height', '100%'); + expect(right).toHaveAttribute('x', '300'); + expect(right).toHaveAttribute('width', '100%'); + + [top, left, bottom, right].forEach(rect => { + expect(rect).toHaveAttribute('fill', 'transparent'); + expect(rect).toHaveAttribute('pointer-events', 'auto'); + }); + }); + + it('does not create negative dimensions when a gap crosses the viewport edges', () => { + const maskSize = { width: 800, height: 600 }; + const pos = { left: 780, top: 580, width: 40, height: 40, radius: 2 }; + + renderMask(pos, { + ...maskSize, + right: 'auto', + bottom: 'auto', + }); + + const { bottom, right } = getMaskElements(); + const mask = document.querySelector('.rc-tour-mask'); + + expect(mask).toHaveStyle({ width: '800px', height: '600px' }); + expect(pos.top + pos.height).toBeGreaterThan(maskSize.height); + expect(pos.left + pos.width).toBeGreaterThan(maskSize.width); + expect(bottom).toHaveAttribute('y', '620'); + expect(bottom).toHaveAttribute('height', '100%'); + expect(right).toHaveAttribute('x', '820'); + expect(right).toHaveAttribute('width', '100%'); + }); + + it('clamps click blockers when the target is above or left of the mask', () => { + renderMask({ left: -20, top: -20, width: 10, height: 10, radius: 2 }); + + const { top, left, bottom, right } = getMaskElements(); + + expect(top).toHaveAttribute('height', '0'); + expect(left).toHaveAttribute('width', '0'); + expect(bottom).toHaveAttribute('y', '0'); + expect(bottom).toHaveAttribute('height', '100%'); + expect(right).toHaveAttribute('x', '0'); + expect(right).toHaveAttribute('width', '100%'); + }); +}); diff --git a/tests/__snapshots__/index.test.tsx.snap b/tests/__snapshots__/index.test.tsx.snap index a51c698..8200081 100644 --- a/tests/__snapshots__/index.test.tsx.snap +++ b/tests/__snapshots__/index.test.tsx.snap @@ -17,7 +17,7 @@ exports[`Tour animated placeholder true 1`] = ` style="position: fixed; left: 0px; right: 0px; top: 0px; bottom: 0px; z-index: 1001; pointer-events: none;" > @@ -170,7 +170,7 @@ exports[`Tour animated true 1`] = ` style="position: fixed; left: 0px; right: 0px; top: 0px; bottom: 0px; z-index: 1001; pointer-events: none;" > @@ -351,7 +351,7 @@ exports[`Tour renderPanel basic 1`] = ` style="position: fixed; left: 0px; right: 0px; top: 0px; bottom: 0px; z-index: 1001; pointer-events: none;" > @@ -462,7 +462,7 @@ exports[`Tour rootClassName 1`] = ` style="position: fixed; left: 0px; right: 0px; top: 0px; bottom: 0px; z-index: 1001; pointer-events: none;" > @@ -623,7 +623,7 @@ exports[`Tour run in strict mode 1`] = ` style="position: fixed; left: 0px; right: 0px; top: 0px; bottom: 0px; z-index: 1001; pointer-events: auto;" > @@ -909,7 +909,7 @@ exports[`Tour should keep current when controlled 1`] = ` style="position: fixed; left: 0px; right: 0px; top: 0px; bottom: 0px; z-index: 1001; pointer-events: auto;" > @@ -1299,7 +1299,7 @@ exports[`Tour should update position when window scroll 1`] = ` style="position: fixed; left: 0px; right: 0px; top: 0px; bottom: 0px; z-index: 1001; pointer-events: none;" > @@ -1452,7 +1452,7 @@ exports[`Tour showArrow should show tooltip arrow default 1`] = ` style="position: fixed; left: 0px; right: 0px; top: 0px; bottom: 0px; z-index: 1001; pointer-events: none;" > @@ -1601,7 +1601,7 @@ exports[`Tour single 1`] = ` style="position: fixed; left: 0px; right: 0px; top: 0px; bottom: 0px; z-index: 1001; pointer-events: none;" > @@ -1756,7 +1756,7 @@ exports[`Tour use custom builtinPlacements 1`] = ` style="position: fixed; left: 0px; right: 0px; top: 0px; bottom: 0px; z-index: 1001; pointer-events: none;" > @@ -1911,7 +1911,7 @@ exports[`Tour use custom builtinPlacements 2`] = ` style="position: fixed; left: 0px; right: 0px; top: 0px; bottom: 0px; z-index: 1001; pointer-events: none;" > @@ -2077,7 +2077,7 @@ exports[`Tour use custom builtinPlacements 3`] = ` style="position: fixed; left: 0px; right: 0px; top: 0px; bottom: 0px; z-index: 1001; pointer-events: none;" >