fix(reader): Android text selection breaks on the first word of hyphenated paragraphs (#4545)
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user