Files
readest/apps/readest-app/src/components/Popup.tsx
T
Huang Xin d326e1c73d fix: hide popup triangle when inside popup + EPUB image-only paragraph rendering (#4121)
- Popup: hide the inner triangle when its anchor point lands inside
  the popup body. Extracted as a generic `isPointInRect` helper in
  `sel.ts` (with a default 1px padding so edge cases stay visible).
- style.ts: handle `<p[width][height]><img></p>` (common in some
  MOBI conversions) — clear hardcoded width/height and apply
  multiply blend for dark themes so the image doesn't sit on a
  colored box.
- Annotator: shrink dict popup height from 480 to 360 to fit
  smaller screens.
- foliate-js: submodule bump.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:12:37 +02:00

183 lines
5.3 KiB
TypeScript

import clsx from 'clsx';
import { Position, isPointInRect } from '@/utils/sel';
import { useEffect, useRef, useState } from 'react';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
const getTriangleStyles = (
trianglePosition: Position | undefined,
size: number,
offset: number,
): React.CSSProperties => {
if (!trianglePosition) {
return { left: '-999px', top: '-999px' };
}
const { dir, point } = trianglePosition;
let topOffset = 0;
let leftOffset = 0;
switch (dir) {
case 'up':
topOffset = offset;
break;
case 'down':
topOffset = -offset;
break;
case 'left':
leftOffset = offset;
break;
case 'right':
leftOffset = -offset;
break;
}
return {
left: `${point.x + leftOffset}px`,
top: `${point.y + topOffset}px`,
borderLeft:
dir === 'right' ? 'none' : dir === 'left' ? `${size}px solid` : `${size}px solid transparent`,
borderRight:
dir === 'left' ? 'none' : dir === 'right' ? `${size}px solid` : `${size}px solid transparent`,
borderTop:
dir === 'down' ? 'none' : dir === 'up' ? `${size}px solid` : `${size}px solid transparent`,
borderBottom:
dir === 'up' ? 'none' : dir === 'down' ? `${size}px solid` : `${size}px solid transparent`,
transform: dir === 'left' || dir === 'right' ? 'translateY(-50%)' : 'translateX(-50%)',
};
};
const Popup = ({
width,
height,
minHeight,
maxHeight,
position,
trianglePosition,
children,
className = '',
triangleClassName = '',
additionalStyle = {},
isOpen = true,
onDismiss,
}: {
isOpen?: boolean;
width: number;
height?: number;
minHeight?: number;
maxHeight?: number;
position?: Position;
trianglePosition?: Position;
children: React.ReactNode;
className?: string;
triangleClassName?: string;
additionalStyle?: React.CSSProperties;
onDismiss?: () => void;
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [adjustedPosition, setAdjustedPosition] = useState(position);
const [childrenHeight, setChildrenHeight] = useState(height || minHeight || 0);
useKeyDownActions({ onCancel: onDismiss, elementRef: containerRef, enabled: isOpen });
const popupPadding = useResponsiveSize(10);
let availableHeight = window.innerHeight - 2 * popupPadding;
if (trianglePosition?.dir === 'up') {
availableHeight = trianglePosition.point.y - popupPadding;
} else if (trianglePosition?.dir === 'down') {
availableHeight = window.innerHeight - trianglePosition.point.y - popupPadding;
}
maxHeight = Math.min(maxHeight || availableHeight, availableHeight);
useEffect(() => {
if (!containerRef.current) return;
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const newHeight = entry.contentRect.height;
if (newHeight !== childrenHeight) {
setChildrenHeight(newHeight);
return;
}
}
});
resizeObserver.observe(containerRef.current);
return () => {
resizeObserver.disconnect();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!containerRef.current) return;
if (!position || !trianglePosition || position.dir !== 'up') {
setAdjustedPosition(position);
return;
}
const containerHeight = childrenHeight || containerRef.current.offsetHeight;
const newPosition = {
...position,
point: {
...position.point,
y: Math.max(popupPadding, trianglePosition.point.y - containerHeight),
},
};
setAdjustedPosition(newPosition);
}, [position, trianglePosition, popupPadding, childrenHeight]);
const outerTriangleStyles = getTriangleStyles(trianglePosition, 7, 0);
const innerTriangleStyles = getTriangleStyles(trianglePosition, 7, -1);
const popupHeight = height ?? childrenHeight;
const triangleHidden = !!(
adjustedPosition &&
trianglePosition &&
isPointInRect(trianglePosition.point, {
left: adjustedPosition.point.x,
top: adjustedPosition.point.y,
right: adjustedPosition.point.x + width,
bottom: adjustedPosition.point.y + popupHeight,
})
);
return (
<div>
<div
className={clsx('popup-triangle-outer text-base-300 absolute z-50', triangleClassName)}
style={outerTriangleStyles}
/>
<div
id='popup-container'
ref={containerRef}
className={clsx(
'popup-container bg-base-300 absolute z-50 rounded-lg font-sans',
'eink:border eink:border-base-content',
trianglePosition?.dir !== 'up' && 'not-eink:shadow-xl',
className,
)}
style={{
width: `${width}px`,
height: height ? `${height}px` : 'auto',
minHeight: minHeight ? `${minHeight}px` : 'none',
maxHeight: maxHeight ? `${maxHeight}px` : 'none',
left: `${adjustedPosition ? adjustedPosition.point.x : -999}px`,
top: `${adjustedPosition ? adjustedPosition.point.y : -999}px`,
...additionalStyle,
}}
>
{children}
</div>
<div
className={clsx(
'popup-triangle-inner text-base-300 absolute',
triangleHidden ? 'z-10' : 'z-50',
triangleClassName,
)}
style={innerTriangleStyles}
/>
</div>
);
};
export default Popup;