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>
This commit is contained in:
Huang Xin
2026-05-11 10:12:37 +08:00
committed by GitHub
parent 1705006b6b
commit d326e1c73d
6 changed files with 68 additions and 5 deletions
@@ -6,9 +6,39 @@ import type { Rect, Position } from '@/utils/sel';
// constrainPointWithinRect is also non-exported but exercised through getPosition.
// For getPopupPosition we can test directly.
import { getPopupPosition } from '@/utils/sel';
import { getPopupPosition, isPointInRect } from '@/utils/sel';
describe('sel utilities', () => {
describe('isPointInRect', () => {
const rect: Rect = { left: 100, top: 50, right: 300, bottom: 200 };
it('returns true for a point strictly inside the rect', () => {
expect(isPointInRect({ x: 200, y: 100 }, rect)).toBe(true);
});
it('treats edges as outside with the default 1px padding', () => {
expect(isPointInRect({ x: 100, y: 100 }, rect)).toBe(false);
expect(isPointInRect({ x: 300, y: 100 }, rect)).toBe(false);
expect(isPointInRect({ x: 200, y: 50 }, rect)).toBe(false);
expect(isPointInRect({ x: 200, y: 200 }, rect)).toBe(false);
});
it('returns true on edges when padding is 0', () => {
expect(isPointInRect({ x: 100, y: 50 }, rect, 0)).toBe(true);
expect(isPointInRect({ x: 300, y: 200 }, rect, 0)).toBe(true);
});
it('returns false when x is outside', () => {
expect(isPointInRect({ x: 99, y: 100 }, rect)).toBe(false);
expect(isPointInRect({ x: 301, y: 100 }, rect)).toBe(false);
});
it('returns false when y is outside', () => {
expect(isPointInRect({ x: 200, y: 49 }, rect)).toBe(false);
expect(isPointInRect({ x: 200, y: 201 }, rect)).toBe(false);
});
});
describe('getPopupPosition', () => {
const boundingRect: Rect = { top: 0, right: 800, bottom: 600, left: 0 };
@@ -127,7 +127,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// Tall enough to fit a header + 2-3 expanded cards comfortably. The popup
// shows all enabled providers stacked (no tabs) so it needs more vertical
// room than the legacy single-tab layout.
const dictPopupHeight = Math.min(480, maxHeight);
const dictPopupHeight = Math.min(360, maxHeight);
const transPopupWidth = Math.min(480, maxWidth);
const transPopupHeight = Math.min(265, maxHeight);
const proofreadPopupWidth = Math.min(440, maxWidth);
+18 -2
View File
@@ -1,5 +1,5 @@
import clsx from 'clsx';
import { Position } from '@/utils/sel';
import { Position, isPointInRect } from '@/utils/sel';
import { useEffect, useRef, useState } from 'react';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
@@ -128,6 +128,18 @@ const Popup = ({
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
@@ -156,7 +168,11 @@ const Popup = ({
{children}
</div>
<div
className={clsx('popup-triangle-inner text-base-300 absolute z-50', triangleClassName)}
className={clsx(
'popup-triangle-inner text-base-300 absolute',
triangleHidden ? 'z-10' : 'z-50',
triangleClassName,
)}
style={innerTriangleStyles}
/>
</div>
+9
View File
@@ -75,6 +75,15 @@ const constrainPointWithinRect = (point: Point, rect: Rect, padding: number) =>
};
};
export const isPointInRect = (point: Point, rect: Rect, padding: number = 1): boolean => {
return (
point.x >= rect.left + padding &&
point.x <= rect.right - padding &&
point.y >= rect.top + padding &&
point.y <= rect.bottom - padding
);
};
export const isPointerInsideSelection = (selection: Selection, ev: PointerEvent) => {
if (selection.rangeCount === 0) return false;
const range = selection.getRangeAt(0);
+8
View File
@@ -182,6 +182,9 @@ const getColorStyles = (
hr.background-img {
mix-blend-mode: multiply;
}
p[width][height] > img:only-child {
mix-blend-mode: multiply;
}
/* inline images */
*:has(> img.has-text-siblings):not(body) {
${overrideColor ? `background-color: ${bg};` : ''}
@@ -349,6 +352,11 @@ const getPageLayoutStyles = (
width: auto;
height: auto;
}
/* some mobi */
p[width][height] > img:only-child {
width: unset !important;
height: unset !important;
}
/* page break */
body.paginated-mode div[style*="page-break-after: always"],