feat(annotator): responsive layout for the loupe (#3568)

This commit is contained in:
Huang Xin
2026-03-20 00:54:24 +08:00
committed by GitHub
parent 764acf59de
commit 1f4f5f8a6d
5 changed files with 29 additions and 54 deletions
@@ -1,61 +1,42 @@
import { describe, it, expect, vi } from 'vitest';
import {
getEffectiveLoupePoint,
getExternalDragHandle,
toParentViewportPoint,
} from '@/app/reader/utils/annotatorUtil';
import { getExternalDragHandle, toParentViewportPoint } from '@/app/reader/utils/annotatorUtil';
import { Point } from '@/utils/sel';
describe('getEffectiveLoupePoint', () => {
it('returns loupePoint when set (internal handle drag takes priority)', () => {
const loupePoint: Point = { x: 150, y: 180 };
const externalDragPoint: Point = { x: 280, y: 200 };
const result = getEffectiveLoupePoint(loupePoint, externalDragPoint);
expect(result).toBe(loupePoint);
});
it('returns externalDragPoint when loupePoint is null (smooth pointer tracking)', () => {
const externalDragPoint: Point = { x: 280, y: 200 };
const result = getEffectiveLoupePoint(null, externalDragPoint);
expect(result).toBe(externalDragPoint);
});
it('returns null when both loupePoint and externalDragPoint are null', () => {
const result = getEffectiveLoupePoint(null, null);
expect(result).toBeNull();
});
it('returns null when externalDragPoint is undefined', () => {
const result = getEffectiveLoupePoint(null, undefined);
expect(result).toBeNull();
});
});
describe('getExternalDragHandle', () => {
const currentStart: Point = { x: 100, y: 200 };
const currentEnd: Point = { x: 300, y: 200 };
it('forward drag — externalDragPoint closer to end → returns end', () => {
const result = getExternalDragHandle({ x: 280, y: 200 }, currentStart, currentEnd);
const result = getExternalDragHandle(currentStart, currentEnd, { x: 280, y: 200 });
expect(result).toBe('end');
});
it('backward drag — externalDragPoint closer to start → returns start', () => {
const result = getExternalDragHandle({ x: 120, y: 200 }, currentStart, currentEnd);
const result = getExternalDragHandle(currentStart, currentEnd, { x: 120, y: 200 });
expect(result).toBe('start');
});
it('returns null when externalDragPoint is null', () => {
const result = getExternalDragHandle(currentStart, currentEnd, null);
expect(result).toBeNull();
});
it('returns null when externalDragPoint is undefined', () => {
const result = getExternalDragHandle(currentStart, currentEnd);
expect(result).toBeNull();
});
it('vertical text — works with vertical coordinates', () => {
const vStart: Point = { x: 200, y: 100 };
const vEnd: Point = { x: 200, y: 400 };
const result = getExternalDragHandle({ x: 200, y: 350 }, vStart, vEnd);
const result = getExternalDragHandle(vStart, vEnd, { x: 200, y: 350 });
expect(result).toBe('end');
});
it('equal distance — returns end (deterministic tie-breaking)', () => {
// Midpoint between start(100,200) and end(300,200) is (200,200)
// distToStart === distToEnd, so !(distToStart < distToEnd) → returns 'end'
const result = getExternalDragHandle({ x: 200, y: 200 }, currentStart, currentEnd);
const result = getExternalDragHandle(currentStart, currentEnd, { x: 200, y: 200 });
expect(result).toBe('end');
});
});
@@ -9,11 +9,7 @@ import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useAnnotationEditor } from '../../hooks/useAnnotationEditor';
import {
getEffectiveLoupePoint,
getExternalDragHandle,
getHighlightColorHex,
} from '../../utils/annotatorUtil';
import { getExternalDragHandle, getHighlightColorHex } from '../../utils/annotatorUtil';
import MagnifierLoupe from './MagnifierLoupe';
interface HandleProps {
@@ -253,17 +249,17 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
return null;
}
const effectiveLoupePoint = getEffectiveLoupePoint(loupePoint, externalDragPoint);
const loupeDragPoint = externalDragPoint;
const effectiveLoupePoint = loupePoint ?? loupeDragPoint;
const activeHandle =
draggingHandle ??
(externalDragPoint ? getExternalDragHandle(externalDragPoint, currentStart, currentEnd) : null);
draggingHandle ?? getExternalDragHandle(currentStart, currentEnd, loupeDragPoint);
const showLoupe = effectiveLoupePoint !== null && appService?.isMobile && !viewSettings?.vertical;
return (
<div className='pointer-events-none fixed inset-0 z-50'>
<Handle
hidden={activeHandle === 'end'}
hidden={activeHandle === 'end' || loupeDragPoint !== null}
position={currentStart}
isVertical={isVertical}
type='start'
@@ -273,7 +269,7 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
onDragEnd={handleDragEnd}
/>
<Handle
hidden={activeHandle === 'start'}
hidden={activeHandle === 'start' || loupeDragPoint !== null}
position={currentEnd}
isVertical={isVertical}
type='end'
@@ -134,6 +134,10 @@ export const useTextSelector = (
stopInstantAnnotating(ev);
const handled = await handleInstantAnnotationPointerUp(doc, index, ev);
if (handled) {
isTextSelected.current = true;
setTimeout(() => {
isTextSelected.current = false;
}, 200);
return;
} else {
// If instant annotation was not created, we let the event propagate
@@ -13,18 +13,12 @@ export const getHighlightColorHex = (
return customColors?.[color] ?? HIGHLIGHT_COLOR_HEX[color];
};
export function getEffectiveLoupePoint(
loupePoint: Point | null,
externalDragPoint: Point | null | undefined,
): Point | null {
return loupePoint ?? externalDragPoint ?? null;
}
export function getExternalDragHandle(
externalDragPoint: Point,
currentStart: Point,
currentEnd: Point,
): 'start' | 'end' {
externalDragPoint?: Point | null,
): 'start' | 'end' | null {
if (!externalDragPoint) return null;
const distToStart = Math.hypot(
externalDragPoint.x - currentStart.x,
externalDragPoint.y - currentStart.y,