158 lines
5.8 KiB
JavaScript
158 lines
5.8 KiB
JavaScript
'use client';
|
|
import { mergeClasses } from '@griffel/react';
|
|
import { useId } from '@fluentui/react-utilities';
|
|
import * as React from 'react';
|
|
import { useSyncExternalStore } from 'use-sync-external-store/shim';
|
|
import { getRectCorners } from './getRectCorners';
|
|
import { getMouseAnchor } from './getMouseAnchor';
|
|
import { pointsToSvgPath } from './pointsToSvgPath';
|
|
import { useStyles } from './SafeZoneArea.styles';
|
|
import { computeOutsideClipPath } from './computeOutsideClipPath';
|
|
// ---
|
|
const EMPTY_RECT = {
|
|
top: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
left: 0,
|
|
width: 0,
|
|
height: 0,
|
|
x: 0,
|
|
y: 0,
|
|
toJSON () {
|
|
return '';
|
|
}
|
|
};
|
|
export function isSameRect(a, b) {
|
|
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, b) {
|
|
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
|
|
*
|
|
* @internal
|
|
*/ export const SafeZoneArea = /*#__PURE__*/ React.memo((props)=>{
|
|
const { debug, onMouseEnter, onMouseMove, onMouseLeave, stateStore } = props;
|
|
const clipPathId = useId();
|
|
const styles = useStyles();
|
|
const active = useSyncExternalStore(stateStore.subscribe, stateStore.isActive);
|
|
const svgRef = React.useRef(null);
|
|
const [state, setState] = React.useState(()=>({
|
|
containerRect: EMPTY_RECT,
|
|
targetRect: EMPTY_RECT,
|
|
mouseCoordinates: [
|
|
0,
|
|
0
|
|
]
|
|
}));
|
|
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;
|
|
});
|
|
}
|
|
}), []);
|
|
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 = [
|
|
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 /*#__PURE__*/ React.createElement("div", {
|
|
className: mergeClasses(styles.wrapper, active && styles.wrapperActive),
|
|
"data-safe-zone": ""
|
|
}, active ? /*#__PURE__*/ React.createElement("svg", {
|
|
"aria-hidden": true,
|
|
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)`
|
|
}
|
|
}, /*#__PURE__*/ React.createElement("g", {
|
|
className: mergeClasses(styles.triangle, debug && styles.triangleDebug),
|
|
clipPath: `url(#${clipPathId})`,
|
|
onMouseEnter: onMouseEnter,
|
|
onMouseMove: onMouseMove,
|
|
onMouseLeave: onMouseLeave
|
|
}, /*#__PURE__*/ React.createElement("path", {
|
|
d: pointsToSvgPath(triangleA)
|
|
}), /*#__PURE__*/ React.createElement("path", {
|
|
d: pointsToSvgPath(triangleB)
|
|
}), /*#__PURE__*/ React.createElement("path", {
|
|
d: pointsToSvgPath(triangleC)
|
|
}), /*#__PURE__*/ React.createElement("path", {
|
|
d: pointsToSvgPath(triangleD)
|
|
})), /*#__PURE__*/ React.createElement("clipPath", {
|
|
id: clipPathId
|
|
}, /*#__PURE__*/ React.createElement("path", {
|
|
d: clipPath
|
|
})), debug && /*#__PURE__*/ React.createElement("path", {
|
|
className: styles.rectDebug,
|
|
d: clipPath
|
|
})) : null);
|
|
});
|