From 2f64bf6ba09311331d79611eddbac37a0682e125 Mon Sep 17 00:00:00 2001 From: gaozhenqian Date: Wed, 3 Jul 2024 14:04:08 +0800 Subject: [PATCH] fix: when hovering over an empty space the selection will expand to cover all the text --- packages/react-pdf/src/Document.tsx | 55 ++++++++++++++- packages/react-pdf/src/Page/TextLayer.tsx | 7 ++ packages/react-pdf/src/shared/types.ts | 1 + packages/react-pdf/src/shared/utils.ts | 86 +++++++++++++++++++++++ 4 files changed, 148 insertions(+), 1 deletion(-) diff --git a/packages/react-pdf/src/Document.tsx b/packages/react-pdf/src/Document.tsx index 9de1392f3..dc5be64f8 100644 --- a/packages/react-pdf/src/Document.tsx +++ b/packages/react-pdf/src/Document.tsx @@ -25,6 +25,8 @@ import { isBrowser, isDataURI, loadFromFile, + moveEndElementToSelectionEnd, + resetTextLayer, } from './shared/utils.js'; import useResolver from './shared/hooks/useResolver.js'; @@ -274,6 +276,8 @@ const Document: React.ForwardRefExoticComponent< const pages = useRef([]); + // the selection range + const prevRangeRef = useRef(); const prevFile = useRef(undefined); const prevOptions = useRef(undefined); @@ -571,6 +575,8 @@ const Document: React.ForwardRefExoticComponent< delete pages.current[pageIndex]; }, []); + const textLayers = useMemo(() => new Map(), [pdf]); + const childContext = useMemo( () => ({ imageResourcesPath, @@ -581,10 +587,57 @@ const Document: React.ForwardRefExoticComponent< renderMode, rotate, unregisterPage, + textLayers, }), - [imageResourcesPath, onItemClick, pdf, registerPage, renderMode, rotate, unregisterPage], + [ + imageResourcesPath, + textLayers, + onItemClick, + pdf, + registerPage, + renderMode, + rotate, + unregisterPage, + ], ); + /** + * In non-Firefox browsers, when hovering over an empty space, + * the selection will expand to cover all the text between the + * current selection and .endOfContent.By moving .endOfContent to right after + * limit the selection jump to at most cover the enteirety of the where + * the selection is being modified. + */ + useEffect(() => { + const handlePointerup = () => { + textLayers.forEach(resetTextLayer); + }; + + const handleSelectionChange = () => { + const selection = document.getSelection()!; + if (selection.rangeCount === 0) { + textLayers.forEach((end: HTMLElement, textlayer: HTMLElement) => { + if (textlayer.isConnected) { + resetTextLayer(end, textlayer); + } else { + textLayers.delete(textlayer); + } + }); + return; + } + const clonedRange = moveEndElementToSelectionEnd(textLayers, prevRangeRef.current); + + prevRangeRef.current = clonedRange; + }; + document.addEventListener('pointerup', handlePointerup); + document.addEventListener('selectionchange', handleSelectionChange); + + return () => { + document.removeEventListener('selectionchange', handleSelectionChange); + document.removeEventListener('pointerup', handlePointerup); + }; + }, [textLayers]); + const eventProps = useMemo( () => makeEventProps(otherProps, () => pdf), // biome-ignore lint/correctness/useExhaustiveDependencies: FIXME diff --git a/packages/react-pdf/src/Page/TextLayer.tsx b/packages/react-pdf/src/Page/TextLayer.tsx index ca9006c06..f6f2fc084 100644 --- a/packages/react-pdf/src/Page/TextLayer.tsx +++ b/packages/react-pdf/src/Page/TextLayer.tsx @@ -8,6 +8,7 @@ import warning from 'warning'; import * as pdfjs from 'pdfjs-dist'; import usePageContext from '../shared/hooks/usePageContext.js'; +import useDocumentContext from '../shared/hooks/useDocumentContext.js'; import useResolver from '../shared/hooks/useResolver.js'; import { cancelRunningTask } from '../shared/utils.js'; @@ -19,8 +20,10 @@ function isTextItem(item: TextItem | TextMarkedContent): item is TextItem { export default function TextLayer(): React.ReactElement { const pageContext = usePageContext(); + const documentContext = useDocumentContext(); invariant(pageContext, 'Unable to find Page context.'); + invariant(documentContext, 'Unable to find Document context.'); const { customTextRenderer, @@ -35,6 +38,8 @@ export default function TextLayer(): React.ReactElement { scale, } = pageContext; + const { textLayers } = documentContext; + invariant(page, 'Attempted to load page text content, but no page was specified.'); const [textContentState, textContentDispatch] = useResolver(); @@ -205,6 +210,8 @@ export default function TextLayer(): React.ReactElement { layer.append(end); endElement.current = end; + textLayers.set(layer, end); + const layerChildren = layer.querySelectorAll('[role="presentation"]'); if (customTextRenderer) { diff --git a/packages/react-pdf/src/shared/types.ts b/packages/react-pdf/src/shared/types.ts index ad9faa946..6577b3f25 100644 --- a/packages/react-pdf/src/shared/types.ts +++ b/packages/react-pdf/src/shared/types.ts @@ -136,6 +136,7 @@ export type DocumentContextType = { renderMode?: RenderMode; rotate?: number | null; unregisterPage: UnregisterPage; + textLayers: Map; } | null; export type PageContextType = { diff --git a/packages/react-pdf/src/shared/utils.ts b/packages/react-pdf/src/shared/utils.ts index 5abf8dfcf..dfdca55e4 100644 --- a/packages/react-pdf/src/shared/utils.ts +++ b/packages/react-pdf/src/shared/utils.ts @@ -178,3 +178,89 @@ export function loadFromFile(file: Blob): Promise { reader.readAsArrayBuffer(file); }); } + +// Get current browser +export function getBrowser() { + var userAgent = navigator.userAgent; + var browserName; + + if (userAgent.indexOf('Firefox') > -1) { + browserName = 'Firefox'; + } else if (userAgent.indexOf('SamsungBrowser') > -1) { + browserName = 'Samsung'; + } else if (userAgent.indexOf('Opera') > -1 || userAgent.indexOf('OPR') > -1) { + browserName = 'Opera'; + } else if (userAgent.indexOf('Trident') > -1) { + browserName = 'IE'; + } else if (userAgent.indexOf('Edge') > -1) { + browserName = 'Edge'; + } else if (userAgent.indexOf('Chrome') > -1) { + browserName = 'Chrome'; + } else if (userAgent.indexOf('Safari') > -1) { + browserName = 'Safari'; + } else { + browserName = 'Unknown'; + } + + return browserName; +} + +// reset text layer +export const resetTextLayer = (end: HTMLElement, textLayer: HTMLElement) => { + if (getBrowser() === 'Firefox') { + textLayer.append(end); + end.style.width = ''; + end.style.height = ''; + } + end.classList.remove('active'); +}; + +// move .endOfContent to right after selection range +export const moveEndElementToSelectionEnd = ( + textLayers: Map, + prevRange?: Range, +) => { + const selection = document.getSelection()!; + + const activeTextLayers = new Set(); + for (let i = 0; i < selection.rangeCount; i++) { + const range = selection.getRangeAt(i); + for (const textLayerDiv of textLayers.keys()) { + if (!activeTextLayers.has(textLayerDiv) && range.intersectsNode(textLayerDiv)) { + activeTextLayers.add(textLayerDiv); + } + } + } + + for (const [textLayerDiv, endDiv] of textLayers) { + if (activeTextLayers.has(textLayerDiv)) { + endDiv.classList.add('active'); + } else { + resetTextLayer(endDiv, textLayerDiv); + } + } + + if (getBrowser() === 'Firefox') { + return; + } + + const range = selection.getRangeAt(0); + const modifyStart = + prevRange && + (range.compareBoundaryPoints(Range.END_TO_END, prevRange) === 0 || + range.compareBoundaryPoints(Range.START_TO_END, prevRange) === 0); + let anchor = modifyStart ? range.startContainer : range.endContainer; + if (anchor.nodeType === Node.TEXT_NODE) { + anchor = anchor.parentNode!; + } + + const parentTextLayer = anchor.parentElement!.closest('.textLayer') as HTMLDivElement; + const endDiv = textLayers.get(parentTextLayer); + if (endDiv && parentTextLayer) { + endDiv.style.width = parentTextLayer.style.width; + endDiv.style.height = parentTextLayer.style.height; + anchor.parentElement!.insertBefore(endDiv, modifyStart ? anchor : anchor.nextSibling); + } + + return range.cloneRange(); +};