Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: the selection will expand to cover all text #1840

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 54 additions & 1 deletion packages/react-pdf/src/Document.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
isBrowser,
isDataURI,
loadFromFile,
moveEndElementToSelectionEnd,
resetTextLayer,
} from './shared/utils.js';

import useResolver from './shared/hooks/useResolver.js';
Expand Down Expand Up @@ -274,6 +276,8 @@ const Document: React.ForwardRefExoticComponent<

const pages = useRef<HTMLDivElement[]>([]);

// the selection range
const prevRangeRef = useRef<Range>();
const prevFile = useRef<File | undefined>(undefined);
const prevOptions = useRef<Options | undefined>(undefined);

Expand Down Expand Up @@ -571,6 +575,8 @@ const Document: React.ForwardRefExoticComponent<
delete pages.current[pageIndex];
}, []);

const textLayers = useMemo(() => new Map(), [pdf]);

const childContext = useMemo(
() => ({
imageResourcesPath,
Expand All @@ -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 <span> 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
Expand Down
7 changes: 7 additions & 0 deletions packages/react-pdf/src/Page/TextLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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,
Expand All @@ -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<TextContent>();
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions packages/react-pdf/src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ export type DocumentContextType = {
renderMode?: RenderMode;
rotate?: number | null;
unregisterPage: UnregisterPage;
textLayers: Map<HTMLElement, HTMLElement>;
} | null;

export type PageContextType = {
Expand Down
86 changes: 86 additions & 0 deletions packages/react-pdf/src/shared/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,89 @@ export function loadFromFile(file: Blob): Promise<ArrayBuffer> {
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<HTMLElement, HTMLElement>,
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();
};