'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); });