Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "fix: improve safe zone hit testing and avoid rerenders during pointer movement",
"packageName": "@fluentui/react-positioning",
"email": "jakubmiskech@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ export const useStyles = makeStyles({
top: 0,
left: 0,
},
triangle: {
safeZone: {
pointerEvents: 'auto',
},
triangleDebug: {
safeZoneDebug: {
cursor: 'crosshair',
fill: `color-mix(in srgb, ${tokens.colorPaletteGreenBackground3} 20%, transparent)`,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { pointsToSvgPath } from './pointsToSvgPath';
import { useStyles } from './SafeZoneArea.styles';
import type { Point } from './types';
import { computeOutsideClipPath } from './computeOutsideClipPath';
import { getSafeZonePoints } from './getSafeZonePoints';

export type SafeZoneAreaImperativeHandle = {
updateSVG: (options: { containerRect: DOMRect; targetRect: DOMRect; mouseCoordinates: Point }) => void;
Expand All @@ -35,55 +36,13 @@ export type SafeZoneAreaProps = {
stateStore: ReturnType<typeof createSafeZoneAreaStateStore>;
};

/**
* @internal
*/
type SafeZoneAreaState = {
containerRect: DOMRect;
targetRect: DOMRect;
mouseCoordinates: Point;
};

// ---

const EMPTY_RECT: DOMRect = {
top: 0,
right: 0,
bottom: 0,
left: 0,
width: 0,
height: 0,
x: 0,
y: 0,
toJSON() {
return '';
},
};

export function isSameRect(a: DOMRect, b: DOMRect): boolean {
return (
a.top === b.top &&
a.right === b.right &&
a.bottom === b.bottom &&
a.left === b.left &&
a.width === b.width &&
a.height === b.height
);
}

export function isSameCoordinates(a: Point, b: Point): boolean {
return a[0] === b[0] && a[1] === b[1];
}

// ---

/**
* A component that renders a safe zone area with SVG shapes. Uses `useSyncExternalStore` to manage its active state
* to avoid causing re-renders in `useSafeZoneArea()` as the hook might be used in host components like `Menu`.
*
* Draws two shapes:
* - a triangle that points to the target element which is an actual safe zone
* - a rectangle for a clip path that clips out the target element
* Draws a polygon from the mouse to the facing edges of the container and clips out the target element.
*
* @internal
*/
Expand All @@ -95,112 +54,79 @@ export const SafeZoneArea = React.memo((props: SafeZoneAreaProps): JSXElement =>

const active = useSyncExternalStore(stateStore.subscribe, stateStore.isActive);
const svgRef = React.useRef<SVGSVGElement>(null);

const [state, setState] = React.useState<SafeZoneAreaState>(() => ({
containerRect: EMPTY_RECT,
targetRect: EMPTY_RECT,
mouseCoordinates: [0, 0],
}));
const safeZoneRef = React.useRef<SVGPathElement>(null);
const clipPathRef = React.useRef<SVGPathElement>(null);
const rectDebugRef = React.useRef<SVGPathElement>(null);

React.useImperativeHandle(
props.imperativeRef,
() => ({
updateSVG(newState) {
setState(prevState => {
// Heads up!
// A small optimization to avoid unnecessary re-renders
if (
isSameRect(prevState.containerRect, newState.containerRect) &&
isSameRect(prevState.targetRect, newState.targetRect) &&
isSameCoordinates(prevState.mouseCoordinates, newState.mouseCoordinates)
) {
return prevState;
}

return newState;
updateSVG({ containerRect, targetRect, mouseCoordinates }) {
Comment thread
Yakubko marked this conversation as resolved.
const topOffset = Math.min(targetRect.top, containerRect.top);
const leftOffset = Math.min(targetRect.left, containerRect.left);
const bottomOffset = Math.max(targetRect.bottom, containerRect.bottom);
const rightOffset = Math.max(targetRect.right, containerRect.right);

const containerCorners = getRectCorners(containerRect, [leftOffset, topOffset]);
const targetCorners = getRectCorners(targetRect, [leftOffset, topOffset]);

// SVG coordinates are relative to its top-left corner.
const relativeMouseCoordinates: Point = [mouseCoordinates[0] - leftOffset, mouseCoordinates[1] - topOffset];
const mouseAnchor = getMouseAnchor(
containerCorners.topLeft,
containerCorners.bottomRight,
relativeMouseCoordinates,
);

const svgWidth = rightOffset - leftOffset;
const svgHeight = bottomOffset - topOffset;
const clipPath = computeOutsideClipPath(svgWidth, svgHeight, {
x: targetCorners.topLeft[0],
y: targetCorners.topLeft[1],
width: targetRect.width,
height: targetRect.height,
});

if (svgRef.current) {
svgRef.current.style.width = `${svgWidth}px`;
svgRef.current.style.height = `${svgHeight}px`;
svgRef.current.style.transform = `translate(${leftOffset}px, ${topOffset}px)`;
}

const safeZonePoints = getSafeZonePoints(mouseAnchor, containerCorners);
safeZoneRef.current?.setAttribute('d', safeZonePoints.length > 0 ? pointsToSvgPath(safeZonePoints) : '');
clipPathRef.current?.setAttribute('d', clipPath);
rectDebugRef.current?.setAttribute('d', clipPath);
},
}),
[],
);

const { containerRect, targetRect, mouseCoordinates } = state;

const topOffset = Math.min(targetRect.top, containerRect.top);
const leftOffset = Math.min(targetRect.left, containerRect.left);
const bottomOffset = Math.max(targetRect.bottom, containerRect.bottom);
const rightOffset = Math.max(targetRect.right, containerRect.right);

// ---

const containerCorners = getRectCorners(containerRect, [leftOffset, topOffset]);
const targetCorners = getRectCorners(targetRect, [leftOffset, topOffset]);

// Heads up!
// The SVG coordinate system starts at the top-left corner of the SVG element,
// so we need to adjust the mouse coordinates relative to the SVG's top-left corner.
const relativeMouseCoordinates: Point = [mouseCoordinates[0] - leftOffset, mouseCoordinates[1] - topOffset];
const mouseAnchor = getMouseAnchor(containerCorners.topLeft, containerCorners.bottomRight, relativeMouseCoordinates);

const triangleA = [mouseAnchor, containerCorners.topLeft, containerCorners.topRight];
const triangleB = [mouseAnchor, containerCorners.topRight, containerCorners.bottomRight];
const triangleC = [mouseAnchor, containerCorners.bottomRight, containerCorners.bottomLeft];
const triangleD = [mouseAnchor, containerCorners.bottomLeft, containerCorners.topLeft];

const svgWidth = rightOffset - leftOffset;
const svgHeight = bottomOffset - topOffset;

const clipPath = computeOutsideClipPath(
svgWidth,
svgHeight,
{
x: targetCorners.topLeft[0],
y: targetCorners.topLeft[1],
width: targetRect.width,
height: targetRect.height,
},
{
x: containerCorners.topLeft[0],
y: containerCorners.topLeft[1],
width: containerRect.width,
height: containerRect.height,
},
);

return (
<div className={mergeClasses(styles.wrapper, active && styles.wrapperActive)} data-safe-zone="">
{active ? (
<svg
aria-hidden
className={styles.svg}
xmlns="http://www.w3.org/2000/svg"
ref={svgRef}
style={{
width: `${svgWidth}px`,
height: `${svgHeight}px`,
transform: `translate(${leftOffset}px, ${topOffset}px)`,
}}
<svg
aria-hidden
className={styles.svg}
xmlns="http://www.w3.org/2000/svg"
ref={svgRef}
style={{ width: 0, height: 0, transform: 'translate(0px, 0px)' }}
>
<g
className={mergeClasses(styles.safeZone, debug && styles.safeZoneDebug)}
clipPath={`url(#${clipPathId})`}
onMouseEnter={onMouseEnter}
onMouseMove={onMouseMove}
onMouseLeave={onMouseLeave}
>
<g
className={mergeClasses(styles.triangle, debug && styles.triangleDebug)}
clipPath={`url(#${clipPathId})`}
onMouseEnter={onMouseEnter}
onMouseMove={onMouseMove}
onMouseLeave={onMouseLeave}
>
<path d={pointsToSvgPath(triangleA)} />
<path d={pointsToSvgPath(triangleB)} />
<path d={pointsToSvgPath(triangleC)} />
<path d={pointsToSvgPath(triangleD)} />
</g>

<clipPath id={clipPathId}>
<path d={clipPath} />
</clipPath>

{debug && <path className={styles.rectDebug} d={clipPath} />}
</svg>
) : null}
<path ref={safeZoneRef} />
</g>

<clipPath id={clipPathId}>
<path ref={clipPathRef} />
</clipPath>

{debug && <path ref={rectDebugRef} className={styles.rectDebug} />}
</svg>
</div>
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -12,28 +12,19 @@ exports[`SafeZoneArea updateSVGs updates SVGs 1`] = `
clip-path="url(#fui-_r_0_)"
>
<path
d="M -2.978932121654964,-205.2166790391817,200,0,400,0 z"
/>
<path
d="M -2.978932121654964,-205.2166790391817,400,0,400,300 z"
/>
<path
d="M -2.978932121654964,-205.2166790391817,400,300,200,300 z"
/>
<path
d="M -2.978932121654964,-205.2166790391817,200,300,200,0 z"
d="M -2.978932121654964,-205.2166790391817,200,300,200,0,400,0 z"
/>
</g>
<clippath
id="fui-_r_0_"
>
<path
d="M 0,0 H 400 V 300 H 0 Z M 0,100 V 150 H 100 V 100 H 0 Z M 200,0 V 300 H 400 V 0 H 200 Z "
d="M 0,0 H 400 V 300 H 0 Z M 0,100 V 150 H 100 V 100 H 0 Z "
/>
</clippath>
<path
class=""
d="M 0,0 H 400 V 300 H 0 Z M 0,100 V 150 H 100 V 100 H 0 Z M 200,0 V 300 H 400 V 0 H 200 Z "
d="M 0,0 H 400 V 300 H 0 Z M 0,100 V 150 H 100 V 100 H 0 Z "
/>
</svg>
`;
Expand All @@ -49,29 +40,20 @@ exports[`SafeZoneArea updateSVGs updates SVGs 2`] = `
class=""
clip-path="url(#fui-_r_1_)"
>
<path
d="M 111.2475657231036,329.9610515696578,0,0,200,0 z"
/>
<path
d="M 111.2475657231036,329.9610515696578,200,0,200,300 z"
/>
<path
d="M 111.2475657231036,329.9610515696578,200,300,0,300 z"
/>
<path
d="M 111.2475657231036,329.9610515696578,0,300,0,0 z"
/>
</g>
<clippath
id="fui-_r_1_"
>
<path
d="M 0,0 H 400 V 300 H 0 Z M 300,100 V 150 H 400 V 100 H 300 Z M 0,0 V 300 H 200 V 0 H 0 Z "
d="M 0,0 H 400 V 300 H 0 Z M 300,100 V 150 H 400 V 100 H 300 Z "
/>
</clippath>
<path
class=""
d="M 0,0 H 400 V 300 H 0 Z M 300,100 V 150 H 400 V 100 H 300 Z M 0,0 V 300 H 200 V 0 H 0 Z "
d="M 0,0 H 400 V 300 H 0 Z M 300,100 V 150 H 400 V 100 H 300 Z "
/>
</svg>
`;
Expand All @@ -87,15 +69,6 @@ exports[`SafeZoneArea updateSVGs updates SVGs 3`] = `
class=""
clip-path="url(#fui-_r_2_)"
>
<path
d="M -210,350,0,200,200,200 z"
/>
<path
d="M -210,350,200,200,200,500 z"
/>
<path
d="M -210,350,200,500,0,500 z"
/>
<path
d="M -210,350,0,500,0,200 z"
/>
Expand All @@ -104,12 +77,12 @@ exports[`SafeZoneArea updateSVGs updates SVGs 3`] = `
id="fui-_r_2_"
>
<path
d="M 0,0 H 200 V 500 H 0 Z M 100,0 V 50 H 200 V 0 H 100 Z M 0,200 V 500 H 200 V 200 H 0 Z "
d="M 0,0 H 200 V 500 H 0 Z M 100,0 V 50 H 200 V 0 H 100 Z "
/>
</clippath>
<path
class=""
d="M 0,0 H 200 V 500 H 0 Z M 100,0 V 50 H 200 V 0 H 100 Z M 0,200 V 500 H 200 V 200 H 0 Z "
d="M 0,0 H 200 V 500 H 0 Z M 100,0 V 50 H 200 V 0 H 100 Z "
/>
</svg>
`;
Expand All @@ -126,28 +99,19 @@ exports[`SafeZoneArea updateSVGs updates SVGs 4`] = `
clip-path="url(#fui-_r_3_)"
>
<path
d="M 218.33309420986427,408.18129645788565,0,0,200,0 z"
/>
<path
d="M 218.33309420986427,408.18129645788565,200,0,200,300 z"
/>
<path
d="M 218.33309420986427,408.18129645788565,200,300,0,300 z"
/>
<path
d="M 218.33309420986427,408.18129645788565,0,300,0,0 z"
d="M 218.33309420986427,408.18129645788565,200,0,200,300,0,300 z"
/>
</g>
<clippath
id="fui-_r_3_"
>
<path
d="M 0,0 H 200 V 450 H 0 Z M 100,400 V 450 H 200 V 400 H 100 Z M 0,0 V 300 H 200 V 0 H 0 Z "
d="M 0,0 H 200 V 450 H 0 Z M 100,400 V 450 H 200 V 400 H 100 Z "
/>
</clippath>
<path
class=""
d="M 0,0 H 200 V 450 H 0 Z M 100,400 V 450 H 200 V 400 H 100 Z M 0,0 V 300 H 200 V 0 H 0 Z "
d="M 0,0 H 200 V 450 H 0 Z M 100,400 V 450 H 200 V 400 H 100 Z "
/>
</svg>
`;
Loading