fix(reader): Android text selection breaks on the first word of hyphenated paragraphs (#4545)

This commit is contained in:
Huang Xin
2026-06-12 12:30:56 +08:00
committed by GitHub
parent 390c711070
commit cfe2bb9116
18 changed files with 2124 additions and 102 deletions
@@ -23,7 +23,7 @@ interface HandleProps {
onDragEnd: () => void;
}
const Handle: React.FC<HandleProps> = ({
export const Handle: React.FC<HandleProps> = ({
hidden,
position,
isVertical,
@@ -63,6 +63,7 @@ import {
} from '../../utils/globalAnnotations';
import { annotationToolButtons } from './AnnotationTools';
import AnnotationRangeEditor from './AnnotationRangeEditor';
import SelectionRangeEditor from './SelectionRangeEditor';
import AnnotationPopup from './AnnotationPopup';
import DictionaryPopup from './DictionaryPopup';
import DictionarySheet from './DictionarySheet';
@@ -284,6 +285,7 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({
handleShowPopup,
handleUpToPopup,
handleContextmenu,
applyProgrammaticSelection,
} = useTextSelector(
bookKey,
contentInsets,
@@ -1442,6 +1444,16 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({
}}
/>
)}
{!editingAnnotation && selection?.handlesSuppressed && selection.range && (
<SelectionRangeEditor
bookKey={bookKey}
isVertical={viewSettings.vertical}
selection={selection}
handleColor={selectedColor}
onRangeChange={applyProgrammaticSelection}
onStartDrag={handleStartEditAnnotation}
/>
)}
{editingAnnotation && editingAnnotation.color && selection && (
<AnnotationRangeEditor
bookKey={bookKey}
@@ -0,0 +1,181 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { HighlightColor } from '@/types/book';
import { Point, rangeFromAnchorToPoint, TextSelection } from '@/utils/sel';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
import { getHandlePositionsFromRange, getHighlightColorHex } from '../../utils/annotatorUtil';
import MagnifierLoupe from './MagnifierLoupe';
import { Handle } from './AnnotationRangeEditor';
interface SelectionRangeEditorProps {
bookKey: string;
isVertical: boolean;
selection: TextSelection;
handleColor: HighlightColor;
onRangeChange: (range: Range, index: number, commit: boolean) => void;
onStartDrag: () => void;
}
// Drag handles for a plain (not yet annotated) text selection. Used on
// Android when the native selection handles had to be suppressed because of
// the Blink hyphen selection-bounds bug (issue #1553): the DOM selection
// stays as the visible highlight while these handles replace the native
// ones for adjusting the range.
const SelectionRangeEditor: React.FC<SelectionRangeEditorProps> = ({
bookKey,
isVertical,
selection,
handleColor,
onRangeChange,
onStartDrag,
}) => {
const { appService } = useEnv();
const { settings } = useSettingsStore();
const { isDarkMode } = useThemeStore();
const { getViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey);
const isEink = settings.globalViewSettings.isEink;
const einkFgColor = isDarkMode ? '#ffffff' : '#000000';
const handleColorHex = getHighlightColorHex(settings, handleColor) ?? '#FFFF00';
const draggingRef = useRef<'start' | 'end' | null>(null);
const startRef = useRef<Point>({ x: 0, y: 0 });
const endRef = useRef<Point>({ x: 0, y: 0 });
const lastBuiltRef = useRef<{ range: Range; index: number } | null>(null);
const [draggingHandle, setDraggingHandle] = useState<'start' | 'end' | null>(null);
const [currentStart, setCurrentStart] = useState<Point>({ x: 0, y: 0 });
const [currentEnd, setCurrentEnd] = useState<Point>({ x: 0, y: 0 });
const [loupePoint, setLoupePoint] = useState<Point | null>(null);
useEffect(() => {
if (draggingRef.current) return;
const positions = getHandlePositionsFromRange(bookKey, selection.range, isVertical);
if (positions) {
setCurrentStart(positions.start);
setCurrentEnd(positions.end);
startRef.current = positions.start;
endRef.current = positions.end;
lastBuiltRef.current = { range: selection.range, index: selection.index };
}
}, [bookKey, selection, isVertical]);
// The non-dragged end is anchored as a DOM position captured at drag start.
// Anchoring it to its window coordinate instead would silently re-target it
// whenever the content shifts underneath — e.g. the corner-dwell auto page
// turn (#1354) mid-drag — losing the previous page's part of the selection.
const fixedAnchorRef = useRef<{ node: Node; offset: number } | null>(null);
const updateFromDraggedPoint = useCallback(
(point: Point) => {
const anchor = fixedAnchorRef.current;
if (!anchor) return;
const doc = anchor.node.ownerDocument;
const win = doc?.defaultView;
if (!doc || !win) return;
const feRect = win.frameElement?.getBoundingClientRect();
const built = rangeFromAnchorToPoint(
doc,
anchor.node,
anchor.offset,
point.x - (feRect?.left ?? 0),
point.y - (feRect?.top ?? 0),
);
if (!built) return;
lastBuiltRef.current = { range: built, index: selection.index };
onRangeChange(built, selection.index, false);
},
[selection.index, onRangeChange],
);
const handleStartDragStart = useCallback(() => {
const base = lastBuiltRef.current?.range ?? selection.range;
fixedAnchorRef.current = { node: base.endContainer, offset: base.endOffset };
draggingRef.current = 'start';
setDraggingHandle('start');
setLoupePoint({ ...startRef.current });
onStartDrag();
}, [selection, onStartDrag]);
const handleEndDragStart = useCallback(() => {
const base = lastBuiltRef.current?.range ?? selection.range;
fixedAnchorRef.current = { node: base.startContainer, offset: base.startOffset };
draggingRef.current = 'end';
setDraggingHandle('end');
setLoupePoint({ ...endRef.current });
onStartDrag();
}, [selection, onStartDrag]);
const handleStartDrag = useCallback(
(point: Point) => {
setCurrentStart(point);
setLoupePoint(point);
startRef.current = point;
updateFromDraggedPoint(point);
},
[updateFromDraggedPoint],
);
const handleEndDrag = useCallback(
(point: Point) => {
setCurrentEnd(point);
setLoupePoint(point);
endRef.current = point;
updateFromDraggedPoint(point);
},
[updateFromDraggedPoint],
);
const handleDragEnd = useCallback(() => {
draggingRef.current = null;
setDraggingHandle(null);
setLoupePoint(null);
const last = lastBuiltRef.current;
if (last) {
onRangeChange(last.range, last.index, true);
}
}, [onRangeChange]);
if (currentStart.x === 0 && currentStart.y === 0) {
return null;
}
const showLoupe = appService?.isMobile && !viewSettings?.isEink && !viewSettings?.vertical;
return (
<div className='pointer-events-none fixed inset-0 z-50'>
<Handle
hidden={draggingHandle === 'end'}
position={currentStart}
isVertical={isVertical}
type='start'
color={isEink ? einkFgColor : handleColorHex}
onDragStart={handleStartDragStart}
onDrag={handleStartDrag}
onDragEnd={handleDragEnd}
/>
<Handle
hidden={draggingHandle === 'start'}
position={currentEnd}
isVertical={isVertical}
type='end'
color={isEink ? einkFgColor : handleColorHex}
onDragStart={handleEndDragStart}
onDrag={handleEndDrag}
onDragEnd={handleDragEnd}
/>
{showLoupe && loupePoint && (
<MagnifierLoupe
bookKey={bookKey}
dragPoint={loupePoint}
isVertical={isVertical}
color={handleColorHex}
/>
)}
</div>
);
};
export default SelectionRangeEditor;