From e40874e3c5534b29a88b045247ab216839f044ef Mon Sep 17 00:00:00 2001 From: Martynas Bagdonas Date: Tue, 30 Jun 2026 13:36:49 +0300 Subject: [PATCH] Add mobile text selection handles --- pdfjs/viewer.css | 65 ++++ src/pdf/lib/auto-scroll.js | 17 +- src/pdf/lib/text-range-handles.js | 37 +++ src/pdf/mobile-text-selection.js | 476 ++++++++++++++++++++++++++++++ src/pdf/pdf-view.js | 217 +++++++++----- src/pdf/selection.js | 31 +- 6 files changed, 757 insertions(+), 86 deletions(-) create mode 100644 src/pdf/lib/text-range-handles.js create mode 100644 src/pdf/mobile-text-selection.js diff --git a/pdfjs/viewer.css b/pdfjs/viewer.css index 7fbee0e16..5083f5198 100644 --- a/pdfjs/viewer.css +++ b/pdfjs/viewer.css @@ -302,6 +302,12 @@ body.portal #viewerContainer::-webkit-scrollbar { /* WebKit */ z-index: 2; } +:root[data-mobile-reader] .textLayer { + user-select: none; + -webkit-user-select: none; + -webkit-touch-callout: none; +} + .textLayer :is(span, br) { color: transparent; position: absolute; @@ -400,6 +406,65 @@ body.portal #viewerContainer::-webkit-scrollbar { /* WebKit */ outline: none; } +.mobileTextSelectionHandles { + position: absolute; + left: 0; + top: 0; + z-index: 5; + pointer-events: none; +} + +.mobileTextSelectionHandle { + position: absolute; + width: 44px; + height: 44px; + margin-left: -22px; + margin-top: -22px; + touch-action: none; + pointer-events: auto; +} + +.mobileTextSelectionHandleGlyph { + position: absolute; + left: 6px; + top: 0; + width: 32px; + height: 44px; + transform: rotate(var(--selection-handle-rotation, 0deg)); + transform-origin: 50% 50%; +} + +.mobileTextSelectionHandleGlyph::before { + content: ''; + position: absolute; + left: 15px; + top: 8px; + width: 2px; + height: 28px; + border-radius: 1px; + background: #4078f2; +} + +.mobileTextSelectionHandleGlyph::after { + content: ''; + position: absolute; + width: 12px; + height: 12px; + border-radius: 50%; + background: #4078f2; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35); +} + +.mobileTextSelectionHandle[data-side="start"] .mobileTextSelectionHandleGlyph::after { + left: 4px; + top: 3px; +} + +.mobileTextSelectionHandle[data-side="end"] .mobileTextSelectionHandleGlyph::after { + right: 4px; + bottom: 3px; +} + body, #viewerContainer { background-color: #f2f2f2; /* --color-toolbar */ } diff --git a/src/pdf/lib/auto-scroll.js b/src/pdf/lib/auto-scroll.js index ba3182558..53eb06c7a 100644 --- a/src/pdf/lib/auto-scroll.js +++ b/src/pdf/lib/auto-scroll.js @@ -34,10 +34,8 @@ export class AutoScroll { }; container.ownerDocument.defaultView.requestAnimationFrame(scroll); - container.ownerDocument.defaultView.addEventListener('mousemove', this._handleMouseMove.bind(this)); - container.ownerDocument.defaultView.addEventListener('mouseup', () => this.disable.bind(this)); - + container.ownerDocument.defaultView.addEventListener('mouseup', () => this.disable()); } _handleMouseMove(event) { @@ -45,6 +43,13 @@ export class AutoScroll { this.disable(); return; } + if (!this._enabled) { + return; + } + this.update(event.clientX, event.clientY); + } + + update(clientX, clientY) { if (!this._enabled) { return; } @@ -57,7 +62,7 @@ export class AutoScroll { rect[3] - MARGIN ]; - let p = [event.clientX, event.clientY]; + let p = [clientX, clientY]; // Get absolute distance to rect var dx = Math.max(rect[0] - p[0], 0, p[0] - rect[2]); @@ -93,4 +98,8 @@ export class AutoScroll { this._enabled = false; this._scrollVector = [0, 0]; } + + stop() { + this.disable(); + } } diff --git a/src/pdf/lib/text-range-handles.js b/src/pdf/lib/text-range-handles.js new file mode 100644 index 000000000..698ad2f4c --- /dev/null +++ b/src/pdf/lib/text-range-handles.js @@ -0,0 +1,37 @@ +import { normalizeDegrees } from './utilities'; +import { getRectRotationOnText } from '../selection'; + +function getHandleRect(rect, rotation, side, padding) { + let [x1, y1, x2, y2] = rect; + if (side === 'start') { + return ( + rotation === 0 && [x1 - padding, y1, x1 + padding, y2] + || rotation === 90 && [x1, y2 - padding, x2, y2 + padding] + || rotation === 180 && [x2 - padding, y1, x2 + padding, y2] + || rotation === 270 && [x1, y1 - padding, x2, y1 + padding] + ); + } + return ( + rotation === 0 && [x2 - padding, y1, x2 + padding, y2] + || rotation === 90 && [x1, y1 - padding, x2, y1 + padding] + || rotation === 180 && [x1 - padding, y1, x1 + padding, y2] + || rotation === 270 && [x1, y2 - padding, x2, y2 + padding] + ); +} + +export function getTextRangeHandle({ chars, pageIndex, rect, side, getRect, getViewportRotation, padding = 3 }) { + let rotation = getRectRotationOnText(chars, rect); + rotation += getViewportRotation(pageIndex); + rotation = normalizeDegrees(rotation); + let handleRect = getHandleRect(getRect(rect, pageIndex), rotation, side, padding); + if (!handleRect) { + return null; + } + return { + pageIndex, + rect: handleRect, + rotation, + side, + vertical: [90, 270].includes(rotation) + }; +} diff --git a/src/pdf/mobile-text-selection.js b/src/pdf/mobile-text-selection.js new file mode 100644 index 000000000..4c1ee0c18 --- /dev/null +++ b/src/pdf/mobile-text-selection.js @@ -0,0 +1,476 @@ +import { + getModifiedSelectionRanges, + getReversedSelectionRanges, + getWordSelectionRanges +} from './selection'; +import { getTextRangeHandle } from './lib/text-range-handles'; + +const LONG_PRESS_DELAY = 500; +const MOVE_TOLERANCE = 10; +const WORD_TOLERANCE = 30; +const CONTEXT_MENU_SUPPRESSION_TIMEOUT = 1000; +const CONTEXT_MENU_SUPPRESSION_TOLERANCE = 40; +const HANDLE_TOUCH_SIZE = 44; +const HANDLE_GLYPH_LEFT = 6; +const HANDLE_GLYPH_TOP = 0; + +function clientPointNearRect(x, y, rect, tolerance) { + return x >= rect[0] - tolerance + && x <= rect[2] + tolerance + && y >= rect[1] - tolerance + && y <= rect[3] + tolerance; +} + +function getMovement(event, point) { + return Math.abs(event.clientX - point.clientX) + Math.abs(event.clientY - point.clientY); +} + +function getRangeEndpointSide(range, endpoint) { + let endpointOffset = endpoint === 'anchor' ? range.anchorOffset : range.headOffset; + let otherOffset = endpoint === 'anchor' ? range.headOffset : range.anchorOffset; + return endpointOffset <= otherOffset ? 'start' : 'end'; +} + +function getHandleOutwardVector(rotation, side) { + let vector = ( + rotation === 0 && [-1, 0] + || rotation === 90 && [0, 1] + || rotation === 180 && [1, 0] + || rotation === 270 && [0, -1] + || [0, 0] + ); + return side === 'start' ? vector : [-vector[0], -vector[1]]; +} + +function getHandlePlacements(handles) { + if (!handles.anchor || !handles.head) { + return null; + } + let placements = {}; + for (let endpoint of ['anchor', 'head']) { + let { rect, rotation, side } = handles[endpoint]; + let x = (rect[0] + rect[2]) / 2; + let y = (rect[1] + rect[3]) / 2; + let [outwardX, outwardY] = getHandleOutwardVector(rotation, side); + placements[endpoint] = { + x, + y, + outwardX, + outwardY, + rotation, + side + }; + } + return placements; +} + +function getSelectionRangesWithDraggedEndpoint(pdfPages, selectionRanges, endpoint, position) { + if (endpoint === 'head') { + return getModifiedSelectionRanges(pdfPages, selectionRanges, position); + } + let reversed = getReversedSelectionRanges(selectionRanges); + let modified = getModifiedSelectionRanges(pdfPages, reversed, position); + if (!modified.length) { + return []; + } + return getReversedSelectionRanges(modified); +} + +class MobileSelectionHandleLayer { + constructor(view) { + let doc = view._iframeWindow.document; + let container = doc.getElementById('viewerContainer'); + this._el = doc.createElement('div'); + this._el.className = 'mobileTextSelectionHandles'; + this._handles = {}; + this._glyphs = {}; + for (let endpoint of ['anchor', 'head']) { + let handle = doc.createElement('div'); + handle.className = 'mobileTextSelectionHandle'; + handle.dataset.selectionEndpoint = endpoint; + let glyph = doc.createElement('span'); + glyph.className = 'mobileTextSelectionHandleGlyph'; + handle.append(glyph); + this._el.append(handle); + this._handles[endpoint] = handle; + this._glyphs[endpoint] = glyph; + } + container.append(this._el); + this.hide(); + } + + destroy() { + this._el.remove(); + } + + getEndpointFromTarget(target) { + return target?.closest?.('.mobileTextSelectionHandle')?.dataset.selectionEndpoint || null; + } + + getHandleGlyphClientPoint(endpoint) { + let rect = this._glyphs[endpoint]?.getBoundingClientRect(); + if (!rect) { + return null; + } + return { + clientX: (rect.left + rect.right) / 2, + clientY: (rect.top + rect.bottom) / 2 + }; + } + + moveHandleGlyphToClientPoint(endpoint, point) { + let handle = this._handles[endpoint]; + let glyphPoint = this.getHandleGlyphClientPoint(endpoint); + if (!handle || !glyphPoint) { + return; + } + let left = parseFloat(handle.style.left); + let top = parseFloat(handle.style.top); + if (!Number.isFinite(left) || !Number.isFinite(top)) { + return; + } + handle.style.left = `${left + point.clientX - glyphPoint.clientX}px`; + handle.style.top = `${top + point.clientY - glyphPoint.clientY}px`; + } + + hide() { + this._el.hidden = true; + } + + show(handles) { + if (!handles.anchor || !handles.head) { + this.hide(); + return; + } + this._el.hidden = false; + let placements = getHandlePlacements(handles); + let touchOffset = HANDLE_TOUCH_SIZE / 2; + for (let endpoint of ['anchor', 'head']) { + let handle = this._handles[endpoint]; + let glyph = this._glyphs[endpoint]; + let { x, y, outwardX, outwardY, rotation, side } = placements[endpoint]; + handle.dataset.side = side; + handle.style.setProperty('--selection-handle-rotation', `${rotation}deg`); + handle.style.left = `${x + outwardX * touchOffset}px`; + handle.style.top = `${y + outwardY * touchOffset}px`; + glyph.style.left = `${HANDLE_GLYPH_LEFT - outwardX * touchOffset}px`; + glyph.style.top = `${HANDLE_GLYPH_TOP - outwardY * touchOffset}px`; + } + } +} + +export class PDFMobileTextSelection { + constructor(view) { + this._view = view; + this._pending = null; + this._drag = null; + this._suppressedContextMenu = null; + this._handleLayer = new MobileSelectionHandleLayer(view); + } + + destroy() { + this.cancel(); + this._handleLayer.destroy(); + } + + cancel() { + this._clearPending(); + this._finishDrag(); + this._handleLayer.hide(); + } + + onSelectionChange(selectionRanges) { + if (!this._view._mobile || this._view._tool?.type !== 'pointer') { + this._handleLayer.hide(); + return; + } + if (!selectionRanges?.length || selectionRanges[0].collapsed) { + this._handleLayer.hide(); + return; + } + this._handleLayer.show(this._getHandles(selectionRanges)); + this._positionDraggedHandle(); + } + + handlePointerDown(event, context = {}) { + let endpoint = this._handleLayer.getEndpointFromTarget(event.target); + if (endpoint) { + return this._startDrag(event, endpoint); + } + + this._clearPending(); + if (!context.canStart) { + return false; + } + + let pending = { + pointerId: event.pointerId, + clientX: event.clientX, + clientY: event.clientY, + event, + position: context.position, + handled: false, + timeoutID: null + }; + pending.timeoutID = setTimeout(() => { + this._handleLongPress(pending); + }, LONG_PRESS_DELAY); + this._pending = pending; + return false; + } + + handleTouchMove(touch) { + if (this._drag) { + this._updateDrag(touch); + return true; + } + this._cancelPendingIfMoved(touch); + return false; + } + + handleTouchEnd() { + if (!this._pending?.handled) { + this._clearPending(); + } + } + + handlePointerMove(event) { + if (this._drag) { + if (event.pointerId === this._drag.pointerId) { + this._updateDrag(event); + } + return true; + } + this._cancelPendingIfMoved(event); + return false; + } + + handlePointerUp(event) { + if (this._drag && this._drag.pointerId === event.pointerId) { + this._finishDrag({ updatePopup: true }); + event.preventDefault(); + event.stopPropagation(); + return true; + } + if (this._pending?.handled && this._pending.pointerId === event.pointerId) { + this._clearPending(); + event.preventDefault(); + event.stopPropagation(); + return true; + } + if (this._pending?.pointerId === event.pointerId) { + this._clearPending(); + } + return false; + } + + handlePointerCancel() { + this.cancel(); + } + + handleScroll() { + if (!this._pending?.handled) { + this._clearPending(); + } + } + + shouldSuppressContextMenu(event) { + let suppressed = this._suppressedContextMenu; + if (!suppressed || Date.now() > suppressed.until) { + this._suppressedContextMenu = null; + return false; + } + let movement = getMovement(event, suppressed); + if (movement > CONTEXT_MENU_SUPPRESSION_TOLERANCE) { + return false; + } + this._suppressedContextMenu = null; + return true; + } + + _startDrag(event, endpoint) { + if (!this._view._selectionRanges.length) { + return false; + } + let handlePoint = this._handleLayer.getHandleGlyphClientPoint(endpoint); + this._clearPending(); + this._drag = { + pointerId: event.pointerId, + endpoint, + pageIndex: this._view._selectionRanges.find(x => x[endpoint])?.position.pageIndex, + point: null, + touchOffsetX: handlePoint ? event.clientX - handlePoint.clientX : 0, + touchOffsetY: handlePoint ? event.clientY - handlePoint.clientY : 0 + }; + try { + event.target.setPointerCapture?.(event.pointerId); + } + catch (e) { + // Synthetic pointer events do not always have an active pointer. + } + this._view._autoScroll.enable(); + event.preventDefault(); + event.stopPropagation(); + return true; + } + + _updateDrag(event) { + let point = this._getDragSelectionPoint(event); + this._drag.point = point; + let selectionRanges = this._getSelectionRangesForDragPoint(point); + if (selectionRanges.length && !selectionRanges[0].collapsed) { + this._view._setSelectionRanges(selectionRanges, { updatePopup: false }); + this._view._render(); + } + this._positionDraggedHandle(); + this._view._autoScroll.update(event.clientX, event.clientY); + } + + _getSelectionRangesForDragPoint(point) { + let position = this._view.pointerEventToPosition(point); + if (!position && this._drag.pageIndex !== undefined) { + position = this._view.pointerEventToAltPosition(point, this._drag.pageIndex); + } + if (!position) { + return []; + } + return getSelectionRangesWithDraggedEndpoint( + this._view._pdfPages, + this._view._selectionRanges, + this._drag.endpoint, + position + ); + } + + _getDragSelectionPoint(event) { + return { + clientX: event.clientX - this._drag.touchOffsetX, + clientY: event.clientY - this._drag.touchOffsetY + }; + } + + _positionDraggedHandle() { + if (this._drag?.point) { + this._handleLayer.moveHandleGlyphToClientPoint(this._drag.endpoint, this._drag.point); + } + } + + _finishDrag({ updatePopup = false } = {}) { + if (!this._drag) { + return; + } + this._drag = null; + this._view._autoScroll.stop(); + this.onSelectionChange(this._view._selectionRanges); + if (updatePopup) { + this._view._updateSelectionPopup(); + this._view._updateViewStats(); + this._view._render(); + } + } + + _cancelPendingIfMoved(event) { + if (!this._pending || this._pending.handled) { + return; + } + if (event.pointerId !== undefined && event.pointerId !== this._pending.pointerId) { + return; + } + if (getMovement(event, this._pending) > MOVE_TOLERANCE) { + this._clearPending(); + } + } + + _clearPending() { + if (this._pending?.timeoutID) { + clearTimeout(this._pending.timeoutID); + } + this._pending = null; + } + + async _handleLongPress(pending) { + if (this._pending !== pending || pending.handled) { + return; + } + if (this._view._scrolling) { + this._clearPending(); + return; + } + + pending.timeoutID = null; + let position = this._view.pointerEventToPosition(pending); + if (!position || position.pageIndex !== pending.position.pageIndex) { + this._clearPending(); + return; + } + + await this._view._ensureBasicPageData(position.pageIndex); + if (this._pending !== pending || pending.handled) { + return; + } + if (this._view._getSelectableOverlay(position) || this._view.getSelectableAnnotations(position)?.length) { + this._clearPending(); + return; + } + + let selectionRanges = getWordSelectionRanges(this._view._pdfPages, position, position); + if (!selectionRanges.length || selectionRanges[0].collapsed) { + this._clearPending(); + return; + } + + let rect = this._view.getClientRectForPopup(selectionRanges[0].position); + if (!clientPointNearRect(pending.clientX, pending.clientY, rect, WORD_TOLERANCE)) { + this._clearPending(); + return; + } + + this._view._iframeWindow.getSelection().removeAllRanges(); + this._view._onSelectAnnotations([], pending.event); + this._view._clearPointerAction(); + this._view._setSelectionRanges(selectionRanges); + pending.handled = true; + this._suppressContextMenu(pending); + this._view._render(); + this._view._updateViewStats(); + } + + _suppressContextMenu(event) { + this._suppressedContextMenu = { + clientX: event.clientX, + clientY: event.clientY, + until: Date.now() + CONTEXT_MENU_SUPPRESSION_TIMEOUT + }; + } + + _getHandles(selectionRanges) { + return { + anchor: this._getHandle(selectionRanges, 'anchor'), + head: this._getHandle(selectionRanges, 'head') + }; + } + + _getHandle(selectionRanges, endpoint) { + let range = selectionRanges.find(x => x[endpoint] && !x.collapsed); + if (!range?.position?.rects?.length) { + return null; + } + let pageIndex = range.position.pageIndex; + let page = this._view._pdfPages[pageIndex]; + let pageView = this._view._iframeWindow.PDFViewerApplication.pdfViewer._pages[pageIndex]; + if (!page || !pageView?.div) { + return null; + } + let side = getRangeEndpointSide(range, endpoint); + let rect = side === 'start' ? range.position.rects[0] : range.position.rects.at(-1); + return getTextRangeHandle({ + chars: page.chars, + pageIndex, + rect, + side, + getRect: this._view.getScrollRect.bind(this._view), + getViewportRotation: this._view.getViewportRotation.bind(this._view), + padding: 0 + }); + } +} diff --git a/src/pdf/pdf-view.js b/src/pdf/pdf-view.js index 4faf68328..aa2e35589 100644 --- a/src/pdf/pdf-view.js +++ b/src/pdf/pdf-view.js @@ -4,7 +4,6 @@ import { getLineSelectionRanges, getModifiedSelectionRanges, getNodeOffset, - getRectRotationOnText, getReversedSelectionRanges, getSelectionRanges, getSelectionRangesByPosition, @@ -53,6 +52,7 @@ import { } from '../common/lib/utilities'; import { debounce } from '../common/lib/debounce'; import { AutoScroll } from './lib/auto-scroll'; +import { getTextRangeHandle } from './lib/text-range-handles'; import { PDFThumbnails } from './pdf-thumbnails'; import { A11Y_VIRT_CURSOR_DEBOUNCE_LENGTH, @@ -75,6 +75,7 @@ import { splitReadAloudSegmentsBySelection } from './read-aloud-segments'; import { detectLang } from '../common/lib/detect-lang'; +import { PDFMobileTextSelection } from './mobile-text-selection'; class PDFView { constructor(options) { @@ -169,6 +170,7 @@ class PDFView { this._readAloudHighlightedPosition = null; this._readAloudSentenceHighlightedPosition = null; this._pointerDownTap = null; + this._mobileTextSelection = null; this._iframe = document.createElement('iframe'); this._iframe.style.width = '100%'; @@ -210,6 +212,7 @@ class PDFView { this._iframe.addEventListener('load', () => { this._updateColorScheme(); + this._iframeWindow.document.documentElement.toggleAttribute('data-mobile-reader', !!this._mobile); // This is necessary to make sure this is called after webviewerloaded setTimeout(() => { let handlePasswordRequest = (updateCallback) => { @@ -253,6 +256,7 @@ class PDFView { this._iframeWindow.document.getElementById('viewerContainer').addEventListener('scroll', (event) => { this._scrolling = true; + this._mobileTextSelection?.handleScroll(); clearTimeout(this._scrollTimeout); this._scrollTimeout = setTimeout(() => { this._scrolling = false; @@ -354,6 +358,9 @@ class PDFView { this._autoScroll = new AutoScroll({ container: this._iframeWindow.document.getElementById('viewerContainer') }); + if (this._mobile) { + this._mobileTextSelection = new PDFMobileTextSelection(this); + } await this._iframeWindow.PDFViewerApplication.initializedPromise; this._iframeWindow.PDFViewerApplication.eventBus.on('documentinit', this._handleDocumentInit.bind(this)); @@ -635,6 +642,7 @@ class PDFView { if (textLayer) { textLayer.draggable = true; } + this._refreshMobileTextSelection(); } async _handlePageRendered(event) { @@ -644,6 +652,7 @@ class PDFView { let { isDetailView } = event; if (page) { page.refresh(isDetailView); + this._refreshMobileTextSelection(); } else { this._init2 && this._init2(); @@ -835,10 +844,16 @@ class PDFView { page.render(); } } + this._refreshMobileTextSelection(); + } + + _refreshMobileTextSelection() { + this._mobileTextSelection?.onSelectionChange(this._selectionRanges); } destroy() { this._overlayPopupDelayer.destroy(); + this._mobileTextSelection?.destroy(); } focus() { @@ -984,6 +999,7 @@ class PDFView { this._iframeWindow.document.getElementById('viewerContainer').style.touchAction = tool.type !== 'pointer' ? 'none' : 'auto'; } this._tool = tool; + this._mobileTextSelection?.onSelectionChange(this._selectionRanges); this.updateCursor(); } @@ -1498,8 +1514,15 @@ class PDFView { } } - _setSelectionRanges(selectionRanges) { + _setSelectionRanges(selectionRanges, { updatePopup = true } = {}) { this._selectionRanges = selectionRanges || []; + this._mobileTextSelection?.onSelectionChange(this._selectionRanges); + if (updatePopup) { + this._updateSelectionPopup(); + } + } + + _updateSelectionPopup() { let selectionRange = this._selectionRanges[0]; if (selectionRange && !selectionRange.collapsed) { let rect = this.getClientRectForPopup(selectionRange.position); @@ -1511,6 +1534,13 @@ class PDFView { } } + _clearPointerAction() { + this.action = null; + this.pointerDownPosition = null; + this._pointerDownTap = null; + this._autoScroll?.stop(); + } + _scrollSelectionHeadIntoView(selectionRanges) { let selectionRange = selectionRanges.find(x => !x.collapsed && x.head); if (selectionRange) { @@ -1861,6 +1891,32 @@ class PDFView { ]; } + getScrollRect(rect, pageIndex) { + let page = this._iframeWindow.PDFViewerApplication.pdfViewer._pages[pageIndex]; + let [x1, y2] = page.viewport.convertToViewportPoint(...rect); + let [x2, y1] = page.viewport.convertToViewportPoint(...rect.slice(2, 4)); + + let r = [ + Math.min(x1, x2), + Math.min(y1, y2), + Math.max(x1, x2), + Math.max(y1, y2) + ]; + + let container = this._iframeWindow.document.getElementById('viewerContainer'); + let containerRect = container.getBoundingClientRect(); + let pageRect = page.div.getBoundingClientRect(); + let x = pageRect.x - containerRect.x + container.scrollLeft; + let y = pageRect.y - containerRect.y + container.scrollTop; + + return [ + x + r[0], + y + r[1], + x + r[2], + y + r[3] + ]; + } + getSelectedAnnotationAction(annotation, position) { // Prevent selected single-point ink annotation breaking all other actions in the page if (annotation.type === 'ink') { @@ -1901,70 +1957,28 @@ class PDFView { // Calculate text resizing handle rectangles taking into account text rotation if (this._pdfPages[annotation.position.pageIndex] && (!annotation.position.nextPageRects || this._pdfPages[annotation.position.pageIndex + 1])) { - let { chars } = this._pdfPages[annotation.position.pageIndex]; + let getHandle = (pageIndex, rect, side) => getTextRangeHandle({ + chars: this._pdfPages[pageIndex].chars, + pageIndex, + rect, + side, + getRect: this.getViewRect.bind(this), + getViewportRotation: this.getViewportRotation.bind(this), + padding: 3 + }); let startHandle; let endHandle; - let padding = 3; if (annotation.position.nextPageRects) { if (annotation.position.pageIndex + 1 === position.pageIndex) { - let { chars } = this._pdfPages[annotation.position.pageIndex + 1]; - let rotation = getRectRotationOnText(chars, annotation.position.nextPageRects.at(-1)); - // Add page rotation to text rotation - rotation += this.getViewportRotation(annotation.position.pageIndex + 1); - rotation = normalizeDegrees(rotation); - let rect = this.getViewRect(annotation.position.nextPageRects.at(-1), annotation.position.pageIndex + 1); - let [x1, y1, x2, y2] = rect; - rect = ( - rotation === 0 && [x2 - padding, y1, x2 + padding, y2] - || rotation === 90 && [x1, y1 - padding, x2, y1 + padding] - || rotation === 180 && [x1 - padding, y1, x1 + padding, y2] - || rotation === 270 && [x1, y2 - padding, x2, y2 + padding] - ); - endHandle = { rect, vertical: [90, 270].includes(rotation) }; + endHandle = getHandle(annotation.position.pageIndex + 1, annotation.position.nextPageRects.at(-1), 'end'); } else { - let rotation = getRectRotationOnText(chars, annotation.position.rects[0]); - // Add page rotation to text rotation - rotation += this.getViewportRotation(annotation.position.pageIndex); - rotation = normalizeDegrees(rotation); - let rect = this.getViewRect(annotation.position.rects[0], annotation.position.pageIndex); - let [x1, y1, x2, y2] = rect; - rect = ( - rotation === 0 && [x1 - padding, y1, x1 + padding, y2] - || rotation === 90 && [x1, y2 - padding, x2, y2 + padding] - || rotation === 180 && [x2 - padding, y1, x2 + padding, y2] - || rotation === 270 && [x1, y1 - padding, x2, y1 + padding] - ); - startHandle = { rect, vertical: [90, 270].includes(rotation) }; + startHandle = getHandle(annotation.position.pageIndex, annotation.position.rects[0], 'start'); } } else { - let rotation = getRectRotationOnText(chars, annotation.position.rects[0]); - // Add page rotation to text rotation - rotation += this.getViewportRotation(annotation.position.pageIndex); - rotation = normalizeDegrees(rotation); - let rect = this.getViewRect(annotation.position.rects[0], annotation.position.pageIndex); - let [x1, y1, x2, y2] = rect; - rect = ( - rotation === 0 && [x1 - padding, y1, x1 + padding, y2] - || rotation === 90 && [x1, y2 - padding, x2, y2 + padding] - || rotation === 180 && [x2 - padding, y1, x2 + padding, y2] - || rotation === 270 && [x1, y1 - padding, x2, y1 + padding] - ); - startHandle = { rect, vertical: [90, 270].includes(rotation) }; - rotation = getRectRotationOnText(chars, annotation.position.rects.at(-1)); - // Add page rotation to text rotation - rotation += this.getViewportRotation(annotation.position.pageIndex); - rotation = normalizeDegrees(rotation); - rect = this.getViewRect(annotation.position.rects.at(-1), annotation.position.pageIndex); - [x1, y1, x2, y2] = rect; - rect = ( - rotation === 0 && [x2 - padding, y1, x2 + padding, y2] - || rotation === 90 && [x1, y1 - padding, x2, y1 + padding] - || rotation === 180 && [x1 - padding, y1, x1 + padding, y2] - || rotation === 270 && [x1, y2 - padding, x2, y2 + padding] - ); - endHandle = { rect, vertical: [90, 270].includes(rotation) }; + startHandle = getHandle(annotation.position.pageIndex, annotation.position.rects[0], 'start'); + endHandle = getHandle(annotation.position.pageIndex, annotation.position.rects.at(-1), 'end'); } if (startHandle) { let { rect, vertical } = startHandle; @@ -2412,6 +2426,21 @@ class PDFView { } + _canStartMobileTextSelection(event, position, action, selectAnnotations) { + return this._mobile + && event.pointerType === 'touch' + && event.isPrimary !== false + && event.button === 0 + && this._tool.type === 'pointer' + && !event.altKey + && !event.shiftKey + && position + && action.type === 'none' + && !selectAnnotations?.length + && !event.target.closest('.textAnnotation') + && !this._getSelectableOverlay(position); + } + _handlePointerDown(event) { // Prevent double-click word highlight on triple-click if (this._creationTimeout) { @@ -2419,6 +2448,10 @@ class PDFView { this._creationTimeout = null; } + if (this._mobileTextSelection?.handlePointerDown(event)) { + return; + } + if (event.pointerType === 'mouse') { return; } @@ -2467,6 +2500,10 @@ class PDFView { } let page = this.getPageByIndex(position.pageIndex); let { action, selectAnnotations } = this.getActionAtPosition(position, event); + this._mobileTextSelection?.handlePointerDown(event, { + position, + canStart: this._canStartMobileTextSelection(event, position, action, selectAnnotations) + }); // if (action.type === 'overlay') { // // TODO: Only link overlay should block text selection, while citation and reference shouldn't @@ -2574,23 +2611,27 @@ class PDFView { } if (action.type === 'selectText') { + let selectionRanges; if (event.detail === 1 || !event.detail) { if (shift && this._selectionRanges.length) { - this._selectionRanges = getModifiedSelectionRanges(this._pdfPages, this._selectionRanges, position); + selectionRanges = getModifiedSelectionRanges(this._pdfPages, this._selectionRanges, position); } else { - this._selectionRanges = getSelectionRanges(this._pdfPages, position, position); + selectionRanges = getSelectionRanges(this._pdfPages, position, position); } this.action.mode = 'chars'; } else if (event.detail === 2) { - this._selectionRanges = getWordSelectionRanges(this._pdfPages, position, position); + selectionRanges = getWordSelectionRanges(this._pdfPages, position, position); this.action.mode = 'words'; } else if (event.detail === 3) { - this._selectionRanges = getLineSelectionRanges(this._pdfPages, position, position); + selectionRanges = getLineSelectionRanges(this._pdfPages, position, position); this.action.mode = 'lines'; } + if (selectionRanges) { + this._setSelectionRanges(selectionRanges, { updatePopup: false }); + } if (this._selectionRanges.length && !this._selectionRanges[0].collapsed) { action.triggered = true; } @@ -2616,12 +2657,20 @@ class PDFView { // } - this._autoScroll.enable(); + if (action.type !== 'none') { + this._autoScroll.enable(); + } this._render(); } _handleTouchMove(event) { + if (event.touches?.length === 1 && this._mobileTextSelection?.handleTouchMove(event.touches[0])) { + if (event.cancelable) { + event.preventDefault(); + } + return; + } if ( // Prevent default touch action (which is scroll) if any tool is enabled this._tool.type !== 'pointer' && event.target.id !== 'viewer' @@ -2634,10 +2683,10 @@ class PDFView { _handleTouchEnd(event) { // Prevent emulated mouse event firing (i.e. mousedown, which messes up things). - // Although on chrome we get an error when trying to scroll: - // "[Intervention] Ignored attempt to cancel a touchend event with cancelable=false, - // for example because scrolling is in progress and cannot be interrupted" - event.preventDefault(); + if (event.cancelable) { + event.preventDefault(); + } + this._mobileTextSelection?.handleTouchEnd(); this._pointerDownTriggered = false; } @@ -2646,7 +2695,11 @@ class PDFView { // when the highlight/underline tool is enabled this._creationTimeout = null; + if (this._mobileTextSelection?.handlePointerMove(event)) { + return; + } if (this._scrolling) { + this._mobileTextSelection?.handleScroll(); return; } @@ -2910,14 +2963,18 @@ class PDFView { action.triggered = true; } else if (action.type === 'selectText') { + let selectionRanges; if (action.mode === 'chars') { - this._selectionRanges = getModifiedSelectionRanges(this._pdfPages, this._selectionRanges, position); + selectionRanges = getModifiedSelectionRanges(this._pdfPages, this._selectionRanges, position); } else if (action.mode === 'words') { - this._selectionRanges = getWordSelectionRanges(this._pdfPages, this.pointerDownPosition, position); + selectionRanges = getWordSelectionRanges(this._pdfPages, this.pointerDownPosition, position); } else if (action.mode === 'lines') { - this._selectionRanges = getLineSelectionRanges(this._pdfPages, this.pointerDownPosition, position); + selectionRanges = getLineSelectionRanges(this._pdfPages, this.pointerDownPosition, position); + } + if (selectionRanges) { + this._setSelectionRanges(selectionRanges, { updatePopup: false }); } if (this._selectionRanges.length && !this._selectionRanges[0].collapsed) { action.triggered = true; @@ -3089,6 +3146,21 @@ class PDFView { this._onSetOverlayPopup(null); }); + if (this._mobileTextSelection?.handlePointerUp(event)) { + this._pointerDownTap = null; + if (!this.pointerDownPosition) { + let position = this.pointerEventToPosition(event); + if (position) { + let { action } = this.getActionAtPosition(position, event); + this.updateCursor(action); + } + else { + this.updateCursor(); + } + } + return; + } + let position = this.pointerEventToPosition(event); let handleBackdropTap = this._shouldHandleBackdropTap(event, position); @@ -3282,6 +3354,7 @@ class PDFView { this.pointerDownPosition = null; this._pointerDownTriggered = false; this._pointerDownTap = null; + this._mobileTextSelection?.handlePointerCancel(); this._render(); } @@ -3359,6 +3432,12 @@ class PDFView { return; } + if (this._mobileTextSelection?.shouldSuppressContextMenu(event)) { + event.preventDefault(); + event.stopPropagation(); + return; + } + let position = this.pointerEventToPosition(event); if (this._options.platform !== 'web' && event.button === 2) { // Clear pointer down because the pointer up event won't be received in this iframe diff --git a/src/pdf/selection.js b/src/pdf/selection.js index e734fdf24..8eeebed9d 100644 --- a/src/pdf/selection.js +++ b/src/pdf/selection.js @@ -811,20 +811,25 @@ export function getSelectionRangesByPosition(pdfPages, position) { export function getReversedSelectionRanges(selectionRanges) { selectionRanges = JSON.parse(JSON.stringify(selectionRanges)); - if (selectionRanges.length === 2) { - delete selectionRanges[0].anchor; - delete selectionRanges[1].head; - selectionRanges[0].head = true; - selectionRanges[1].anchor = true; - } + for (let selectionRange of selectionRanges) { + let anchor = selectionRange.anchor; + let head = selectionRange.head; + if (head) { + selectionRange.anchor = true; + } + else { + delete selectionRange.anchor; + } + if (anchor) { + selectionRange.head = true; + } + else { + delete selectionRange.head; + } - let tmp = selectionRanges[0].anchorOffset; - selectionRanges[0].anchorOffset = selectionRanges[0].headOffset; - selectionRanges[0].headOffset = tmp; - if (selectionRanges.length === 2) { - let tmp = selectionRanges[1].anchorOffset; - selectionRanges[1].anchorOffset = selectionRanges[1].headOffset; - selectionRanges[1].headOffset = tmp; + let tmp = selectionRange.anchorOffset; + selectionRange.anchorOffset = selectionRange.headOffset; + selectionRange.headOffset = tmp; } return selectionRanges; }