@rc-component/trigger
Version:
base abstract trigger component for react
521 lines (484 loc) • 19.3 kB
JavaScript
import { isDOM } from "@rc-component/util/es/Dom/findDOMNode";
import isVisible from "@rc-component/util/es/Dom/isVisible";
import useEvent from "@rc-component/util/es/hooks/useEvent";
import useLayoutEffect from "@rc-component/util/es/hooks/useLayoutEffect";
import * as React from 'react';
import { collectScroller, getVisibleArea, getWin, toNum } from "../util";
function getUnitOffset(size, offset = 0) {
const offsetStr = `${offset}`;
const cells = offsetStr.match(/^(.*)\%$/);
if (cells) {
return size * (parseFloat(cells[1]) / 100);
}
return parseFloat(offsetStr);
}
function getNumberOffset(rect, offset) {
const [offsetX, offsetY] = offset || [];
return [getUnitOffset(rect.width, offsetX), getUnitOffset(rect.height, offsetY)];
}
function splitPoints(points = '') {
return [points[0], points[1]];
}
function getAlignPoint(rect, points) {
const topBottom = points[0];
const leftRight = points[1];
let x;
let y;
// Top & Bottom
if (topBottom === 't') {
y = rect.y;
} else if (topBottom === 'b') {
y = rect.y + rect.height;
} else {
y = rect.y + rect.height / 2;
}
// Left & Right
if (leftRight === 'l') {
x = rect.x;
} else if (leftRight === 'r') {
x = rect.x + rect.width;
} else {
x = rect.x + rect.width / 2;
}
return {
x,
y
};
}
function reversePoints(points, index) {
const reverseMap = {
t: 'b',
b: 't',
l: 'r',
r: 'l'
};
return points.map((point, i) => {
if (i === index) {
return reverseMap[point] || 'c';
}
return point;
}).join('');
}
export default function useAlign(open, popupEle, target, placement, builtinPlacements, popupAlign, onPopupAlign) {
const [offsetInfo, setOffsetInfo] = React.useState({
ready: false,
offsetX: 0,
offsetY: 0,
offsetR: 0,
offsetB: 0,
arrowX: 0,
arrowY: 0,
scaleX: 1,
scaleY: 1,
align: builtinPlacements[placement] || {}
});
const alignCountRef = React.useRef(0);
const scrollerList = React.useMemo(() => {
if (!popupEle) {
return [];
}
return collectScroller(popupEle);
}, [popupEle]);
// ========================= Flip ==========================
// We will memo flip info.
// If size change to make flip, it will memo the flip info and use it in next align.
const prevFlipRef = React.useRef({});
const resetFlipCache = () => {
prevFlipRef.current = {};
};
if (!open) {
resetFlipCache();
}
// ========================= Align =========================
const onAlign = useEvent(() => {
if (popupEle && target && open) {
const popupElement = popupEle;
const doc = popupElement.ownerDocument;
const win = getWin(popupElement);
const {
width,
height,
position: popupPosition
} = win.getComputedStyle(popupElement);
const originLeft = popupElement.style.left;
const originTop = popupElement.style.top;
const originRight = popupElement.style.right;
const originBottom = popupElement.style.bottom;
const originOverflow = popupElement.style.overflow;
// Placement
const placementInfo = {
...builtinPlacements[placement],
...popupAlign
};
// placeholder element
const placeholderElement = doc.createElement('div');
popupElement.parentElement?.appendChild(placeholderElement);
placeholderElement.style.left = `${popupElement.offsetLeft}px`;
placeholderElement.style.top = `${popupElement.offsetTop}px`;
placeholderElement.style.position = popupPosition;
placeholderElement.style.height = `${popupElement.offsetHeight}px`;
placeholderElement.style.width = `${popupElement.offsetWidth}px`;
// Reset first
popupElement.style.left = '0';
popupElement.style.top = '0';
popupElement.style.right = 'auto';
popupElement.style.bottom = 'auto';
popupElement.style.overflow = 'hidden';
// Calculate align style, we should consider `transform` case
let targetRect;
if (Array.isArray(target)) {
targetRect = {
x: target[0],
y: target[1],
width: 0,
height: 0
};
} else {
const rect = target.getBoundingClientRect();
rect.x = rect.x ?? rect.left;
rect.y = rect.y ?? rect.top;
targetRect = {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height
};
}
const popupRect = popupElement.getBoundingClientRect();
popupRect.x = popupRect.x ?? popupRect.left;
popupRect.y = popupRect.y ?? popupRect.top;
const {
clientWidth,
clientHeight,
scrollWidth,
scrollHeight,
scrollTop,
scrollLeft
} = doc.documentElement;
const popupHeight = popupRect.height;
const popupWidth = popupRect.width;
const targetHeight = targetRect.height;
const targetWidth = targetRect.width;
// Get bounding of visible area
const visibleRegion = {
left: 0,
top: 0,
right: clientWidth,
bottom: clientHeight
};
const scrollRegion = {
left: -scrollLeft,
top: -scrollTop,
right: scrollWidth - scrollLeft,
bottom: scrollHeight - scrollTop
};
let {
htmlRegion
} = placementInfo;
const VISIBLE = 'visible';
const VISIBLE_FIRST = 'visibleFirst';
if (htmlRegion !== 'scroll' && htmlRegion !== VISIBLE_FIRST) {
htmlRegion = VISIBLE;
}
const isVisibleFirst = htmlRegion === VISIBLE_FIRST;
const scrollRegionArea = getVisibleArea(scrollRegion, scrollerList);
const visibleRegionArea = getVisibleArea(visibleRegion, scrollerList);
const visibleArea = htmlRegion === VISIBLE ? visibleRegionArea : scrollRegionArea;
// When set to `visibleFirst`,
// the check `adjust` logic will use `visibleRegion` for check first.
const adjustCheckVisibleArea = isVisibleFirst ? visibleRegionArea : visibleArea;
// Record right & bottom align data
popupElement.style.left = 'auto';
popupElement.style.top = 'auto';
popupElement.style.right = '0';
popupElement.style.bottom = '0';
const popupMirrorRect = popupElement.getBoundingClientRect();
// Reset back
popupElement.style.left = originLeft;
popupElement.style.top = originTop;
popupElement.style.right = originRight;
popupElement.style.bottom = originBottom;
popupElement.style.overflow = originOverflow;
popupElement.parentElement?.removeChild(placeholderElement);
// Calculate scale
const scaleX = toNum(Math.round(popupWidth / parseFloat(width) * 1000) / 1000);
const scaleY = toNum(Math.round(popupHeight / parseFloat(height) * 1000) / 1000);
// No need to align since it's not visible in view
if (scaleX === 0 || scaleY === 0 || isDOM(target) && !isVisible(target)) {
return;
}
// Offset
const {
offset,
targetOffset
} = placementInfo;
let [popupOffsetX, popupOffsetY] = getNumberOffset(popupRect, offset);
const [targetOffsetX, targetOffsetY] = getNumberOffset(targetRect, targetOffset);
targetRect.x -= targetOffsetX;
targetRect.y -= targetOffsetY;
// Points
const [popupPoint, targetPoint] = placementInfo.points || [];
const targetPoints = splitPoints(targetPoint);
const popupPoints = splitPoints(popupPoint);
const targetAlignPoint = getAlignPoint(targetRect, targetPoints);
const popupAlignPoint = getAlignPoint(popupRect, popupPoints);
// Real align info may not same as origin one
const nextAlignInfo = {
...placementInfo
};
// Next Offset
let nextOffsetX = targetAlignPoint.x - popupAlignPoint.x + popupOffsetX;
let nextOffsetY = targetAlignPoint.y - popupAlignPoint.y + popupOffsetY;
// ============== Intersection ===============
// Get area by position. Used for check if flip area is better
function getIntersectionVisibleArea(offsetX, offsetY, area = visibleArea) {
const l = popupRect.x + offsetX;
const t = popupRect.y + offsetY;
const r = l + popupWidth;
const b = t + popupHeight;
const visibleL = Math.max(l, area.left);
const visibleT = Math.max(t, area.top);
const visibleR = Math.min(r, area.right);
const visibleB = Math.min(b, area.bottom);
return Math.max(0, (visibleR - visibleL) * (visibleB - visibleT));
}
const originIntersectionVisibleArea = getIntersectionVisibleArea(nextOffsetX, nextOffsetY);
// As `visibleFirst`, we prepare this for check
const originIntersectionRecommendArea = getIntersectionVisibleArea(nextOffsetX, nextOffsetY, visibleRegionArea);
// ========================== Overflow ===========================
const targetAlignPointTL = getAlignPoint(targetRect, ['t', 'l']);
const popupAlignPointTL = getAlignPoint(popupRect, ['t', 'l']);
const targetAlignPointBR = getAlignPoint(targetRect, ['b', 'r']);
const popupAlignPointBR = getAlignPoint(popupRect, ['b', 'r']);
const overflow = placementInfo.overflow || {};
const {
adjustX,
adjustY,
shiftX,
shiftY
} = overflow;
const supportAdjust = val => {
if (typeof val === 'boolean') {
return val;
}
return val >= 0;
};
// Prepare position
let nextPopupY;
let nextPopupBottom;
let nextPopupX;
let nextPopupRight;
function syncNextPopupPosition() {
nextPopupY = popupRect.y + nextOffsetY;
nextPopupBottom = nextPopupY + popupHeight;
nextPopupX = popupRect.x + nextOffsetX;
nextPopupRight = nextPopupX + popupWidth;
}
syncNextPopupPosition();
// >>>>>>>>>> Top & Bottom
const needAdjustY = supportAdjust(adjustY);
const sameTB = popupPoints[0] === targetPoints[0];
// Bottom to Top
if (needAdjustY && popupPoints[0] === 't' && (nextPopupBottom > adjustCheckVisibleArea.bottom || prevFlipRef.current.bt)) {
let tmpNextOffsetY = nextOffsetY;
if (sameTB) {
tmpNextOffsetY -= popupHeight - targetHeight;
} else {
tmpNextOffsetY = targetAlignPointTL.y - popupAlignPointBR.y - popupOffsetY;
}
const newVisibleArea = getIntersectionVisibleArea(nextOffsetX, tmpNextOffsetY);
const newVisibleRecommendArea = getIntersectionVisibleArea(nextOffsetX, tmpNextOffsetY, visibleRegionArea);
if (
// Of course use larger one
newVisibleArea > originIntersectionVisibleArea || newVisibleArea === originIntersectionVisibleArea && (!isVisibleFirst ||
// Choose recommend one
newVisibleRecommendArea >= originIntersectionRecommendArea)) {
prevFlipRef.current.bt = true;
nextOffsetY = tmpNextOffsetY;
popupOffsetY = -popupOffsetY;
nextAlignInfo.points = [reversePoints(popupPoints, 0), reversePoints(targetPoints, 0)];
} else {
prevFlipRef.current.bt = false;
}
}
// Top to Bottom
if (needAdjustY && popupPoints[0] === 'b' && (nextPopupY < adjustCheckVisibleArea.top || prevFlipRef.current.tb)) {
let tmpNextOffsetY = nextOffsetY;
if (sameTB) {
tmpNextOffsetY += popupHeight - targetHeight;
} else {
tmpNextOffsetY = targetAlignPointBR.y - popupAlignPointTL.y - popupOffsetY;
}
const newVisibleArea = getIntersectionVisibleArea(nextOffsetX, tmpNextOffsetY);
const newVisibleRecommendArea = getIntersectionVisibleArea(nextOffsetX, tmpNextOffsetY, visibleRegionArea);
if (
// Of course use larger one
newVisibleArea > originIntersectionVisibleArea || newVisibleArea === originIntersectionVisibleArea && (!isVisibleFirst ||
// Choose recommend one
newVisibleRecommendArea >= originIntersectionRecommendArea)) {
prevFlipRef.current.tb = true;
nextOffsetY = tmpNextOffsetY;
popupOffsetY = -popupOffsetY;
nextAlignInfo.points = [reversePoints(popupPoints, 0), reversePoints(targetPoints, 0)];
} else {
prevFlipRef.current.tb = false;
}
}
// >>>>>>>>>> Left & Right
const needAdjustX = supportAdjust(adjustX);
// >>>>> Flip
const sameLR = popupPoints[1] === targetPoints[1];
// Right to Left
if (needAdjustX && popupPoints[1] === 'l' && (nextPopupRight > adjustCheckVisibleArea.right || prevFlipRef.current.rl)) {
let tmpNextOffsetX = nextOffsetX;
if (sameLR) {
tmpNextOffsetX -= popupWidth - targetWidth;
} else {
tmpNextOffsetX = targetAlignPointTL.x - popupAlignPointBR.x - popupOffsetX;
}
const newVisibleArea = getIntersectionVisibleArea(tmpNextOffsetX, nextOffsetY);
const newVisibleRecommendArea = getIntersectionVisibleArea(tmpNextOffsetX, nextOffsetY, visibleRegionArea);
if (
// Of course use larger one
newVisibleArea > originIntersectionVisibleArea || newVisibleArea === originIntersectionVisibleArea && (!isVisibleFirst ||
// Choose recommend one
newVisibleRecommendArea >= originIntersectionRecommendArea)) {
prevFlipRef.current.rl = true;
nextOffsetX = tmpNextOffsetX;
popupOffsetX = -popupOffsetX;
nextAlignInfo.points = [reversePoints(popupPoints, 1), reversePoints(targetPoints, 1)];
} else {
prevFlipRef.current.rl = false;
}
}
// Left to Right
if (needAdjustX && popupPoints[1] === 'r' && (nextPopupX < adjustCheckVisibleArea.left || prevFlipRef.current.lr)) {
let tmpNextOffsetX = nextOffsetX;
if (sameLR) {
tmpNextOffsetX += popupWidth - targetWidth;
} else {
tmpNextOffsetX = targetAlignPointBR.x - popupAlignPointTL.x - popupOffsetX;
}
const newVisibleArea = getIntersectionVisibleArea(tmpNextOffsetX, nextOffsetY);
const newVisibleRecommendArea = getIntersectionVisibleArea(tmpNextOffsetX, nextOffsetY, visibleRegionArea);
if (
// Of course use larger one
newVisibleArea > originIntersectionVisibleArea || newVisibleArea === originIntersectionVisibleArea && (!isVisibleFirst ||
// Choose recommend one
newVisibleRecommendArea >= originIntersectionRecommendArea)) {
prevFlipRef.current.lr = true;
nextOffsetX = tmpNextOffsetX;
popupOffsetX = -popupOffsetX;
nextAlignInfo.points = [reversePoints(popupPoints, 1), reversePoints(targetPoints, 1)];
} else {
prevFlipRef.current.lr = false;
}
}
// ============================ Shift ============================
syncNextPopupPosition();
const numShiftX = shiftX === true ? 0 : shiftX;
if (typeof numShiftX === 'number') {
// Left
if (nextPopupX < visibleRegionArea.left) {
nextOffsetX -= nextPopupX - visibleRegionArea.left - popupOffsetX;
if (targetRect.x + targetWidth < visibleRegionArea.left + numShiftX) {
nextOffsetX += targetRect.x - visibleRegionArea.left + targetWidth - numShiftX;
}
}
// Right
if (nextPopupRight > visibleRegionArea.right) {
nextOffsetX -= nextPopupRight - visibleRegionArea.right - popupOffsetX;
if (targetRect.x > visibleRegionArea.right - numShiftX) {
nextOffsetX += targetRect.x - visibleRegionArea.right + numShiftX;
}
}
}
const numShiftY = shiftY === true ? 0 : shiftY;
if (typeof numShiftY === 'number') {
// Top
if (nextPopupY < visibleRegionArea.top) {
nextOffsetY -= nextPopupY - visibleRegionArea.top - popupOffsetY;
// When target if far away from visible area
// Stop shift
if (targetRect.y + targetHeight < visibleRegionArea.top + numShiftY) {
nextOffsetY += targetRect.y - visibleRegionArea.top + targetHeight - numShiftY;
}
}
// Bottom
if (nextPopupBottom > visibleRegionArea.bottom) {
nextOffsetY -= nextPopupBottom - visibleRegionArea.bottom - popupOffsetY;
if (targetRect.y > visibleRegionArea.bottom - numShiftY) {
nextOffsetY += targetRect.y - visibleRegionArea.bottom + numShiftY;
}
}
}
// ============================ Arrow ============================
// Arrow center align
const popupLeft = popupRect.x + nextOffsetX;
const popupRight = popupLeft + popupWidth;
const popupTop = popupRect.y + nextOffsetY;
const popupBottom = popupTop + popupHeight;
const targetLeft = targetRect.x;
const targetRight = targetLeft + targetWidth;
const targetTop = targetRect.y;
const targetBottom = targetTop + targetHeight;
const maxLeft = Math.max(popupLeft, targetLeft);
const minRight = Math.min(popupRight, targetRight);
const xCenter = (maxLeft + minRight) / 2;
const nextArrowX = xCenter - popupLeft;
const maxTop = Math.max(popupTop, targetTop);
const minBottom = Math.min(popupBottom, targetBottom);
const yCenter = (maxTop + minBottom) / 2;
const nextArrowY = yCenter - popupTop;
onPopupAlign?.(popupEle, nextAlignInfo);
// Additional calculate right & bottom position
let offsetX4Right = popupMirrorRect.right - popupRect.x - (nextOffsetX + popupRect.width);
let offsetY4Bottom = popupMirrorRect.bottom - popupRect.y - (nextOffsetY + popupRect.height);
if (scaleX === 1) {
nextOffsetX = Math.round(nextOffsetX);
offsetX4Right = Math.round(offsetX4Right);
}
if (scaleY === 1) {
nextOffsetY = Math.round(nextOffsetY);
offsetY4Bottom = Math.round(offsetY4Bottom);
}
const nextOffsetInfo = {
ready: true,
offsetX: nextOffsetX / scaleX,
offsetY: nextOffsetY / scaleY,
offsetR: offsetX4Right / scaleX,
offsetB: offsetY4Bottom / scaleY,
arrowX: nextArrowX / scaleX,
arrowY: nextArrowY / scaleY,
scaleX,
scaleY,
align: nextAlignInfo
};
setOffsetInfo(nextOffsetInfo);
}
});
const triggerAlign = () => {
alignCountRef.current += 1;
const id = alignCountRef.current;
// Merge all align requirement into one frame
Promise.resolve().then(() => {
if (alignCountRef.current === id) {
onAlign();
}
});
};
// Reset ready status when placement & open changed
const resetReady = () => {
setOffsetInfo(ori => ({
...ori,
ready: false
}));
};
useLayoutEffect(resetReady, [placement]);
useLayoutEffect(() => {
if (!open) {
resetReady();
}
}, [open]);
return [offsetInfo.ready, offsetInfo.offsetX, offsetInfo.offsetY, offsetInfo.offsetR, offsetInfo.offsetB, offsetInfo.arrowX, offsetInfo.arrowY, offsetInfo.scaleX, offsetInfo.scaleY, offsetInfo.align, triggerAlign];
}