From 2b81632b5b3b1c916c35c77ec86cce33b98524fc Mon Sep 17 00:00:00 2001 From: Martynas Bagdonas Date: Wed, 4 Sep 2024 20:44:15 +0300 Subject: [PATCH] Keyboard accessibility improvements: - Using the TAB and arrow keys, any currenlty visible object in the PDF view can be selected, including annotations, internal/external links, and citations. - Create annotations using keyboard shortcuts alone. - Create highlight/underline annotations from the find popup (it was the only possible option). - Move and resize annotations using keyboard shortcuts. - Clicking an annotation in the document view no longer switches focus to the annotation sidebar. To navigate between annotations, the annotation list in the sidebar must be focused. - The Escape key now performs the single most important action at the time, rather than closing and deactivating everything at once. - Use Enter/Escape to focus or blur annotation comments in the sidebar. - When focusing the annotation sidebar using the keyboard, the last selected annotation is now selected, instead of always navigating to the first one. New keyboard shortcuts: - Move Note, Text, Image, Ink annotation: ArrowKeys - Resize Text, Image, Ink annotation: Shift-ArrowKeys - Resize Highlight/Underline annotation: Shift-ArrowKeys, Shift-Cmd-ArrowKeys (macOS), Shift-Alt-ArrowKeys (Windows, Linux) - Create Note, Text, Image annotation: Ctrl-Option/Alt-3/4/5 - Create Highlight/Underline annotation from text selection or find popup result: Ctrl-Option/Alt-1/2 zotero/zotero#4224 --- src/common/annotation-manager.js | 14 +- src/common/components/common/preview.js | 15 +- src/common/components/reader-ui.js | 17 +- .../components/sidebar/annotations-view.js | 4 +- src/common/components/sidebar/search-box.js | 2 +- .../components/sidebar/thumbnails-view.js | 2 +- .../components/view-popup/find-popup.js | 49 +- .../view-popup/overlay-popup/preview-popup.js | 2 +- src/common/defines.js | 3 + src/common/focus-manager.js | 19 +- src/common/keyboard-manager.js | 109 +- src/common/lib/utilities.js | 20 +- src/common/reader.js | 49 +- .../stylesheets/components/_view-popup.scss | 1 + src/pdf/lib/utilities.js | 103 +- src/pdf/page.js | 89 +- src/pdf/pdf-find-controller.js | 1271 +++++++++++++++++ src/pdf/pdf-view.js | 702 +++++++-- 18 files changed, 2279 insertions(+), 192 deletions(-) create mode 100644 src/pdf/pdf-find-controller.js diff --git a/src/common/annotation-manager.js b/src/common/annotation-manager.js index 95463184..b8db0fc1 100644 --- a/src/common/annotation-manager.js +++ b/src/common/annotation-manager.js @@ -15,6 +15,7 @@ class AnnotationManager { this._readOnly = options.readOnly; this._authorName = options.authorName; this._annotations = options.annotations; + this._tools = options.tools; this._onChangeFilter = options.onChangeFilter; this._onSave = options.onSave; this._onDelete = options.onDelete; @@ -59,14 +60,15 @@ class AnnotationManager { return null; } // Mandatory properties - let { color, sortIndex } = annotation; - if (!color) { - throw new Error(`Missing 'color' property`); - } - if (!sortIndex) { + if (!annotation.sortIndex) { throw new Error(`Missing 'sortIndex' property`); } + // Use the current default color from the toolbar, if missing + if (!annotation.color) { + annotation.color = this._tools[annotation.type].color; + } + // Optional properties annotation.pageLabel = annotation.pageLabel || ''; annotation.text = annotation.text || ''; @@ -123,7 +125,7 @@ class AnnotationManager { } // All properties in the existing annotation position are preserved except nextPageRects, // which isn't preserved only when a new rects property is given - let deleteNextPageRects = annotation.rects && !annotation.position?.nextPageRects; + let deleteNextPageRects = annotation.position?.rects && !annotation.position?.nextPageRects; annotation = { ...existingAnnotation, ...annotation, diff --git a/src/common/components/common/preview.js b/src/common/components/common/preview.js index a78c3a09..3d13f711 100644 --- a/src/common/components/common/preview.js +++ b/src/common/components/common/preview.js @@ -1,4 +1,4 @@ -import React, { useContext } from 'react'; +import React, { useContext, useRef } from 'react'; import { FormattedMessage, useIntl } from 'react-intl'; import cx from 'classnames'; import Editor from './editor'; @@ -136,6 +136,13 @@ export function PopupPreview(props) { export function SidebarPreview(props) { const intl = useIntl(); const { platform } = useContext(ReaderContext); + const lastImageRef = useRef(); + + // Store and render the last image to avoid flickering when annotation manager removes + // old image, but the new one isn't generated yet + if (props.annotation.image) { + lastImageRef.current = props.annotation.image; + } function handlePageLabelClick(event) { event.stopPropagation(); @@ -276,6 +283,8 @@ export function SidebarPreview(props) { let expandedState = {}; expandedState['expanded' + props.state] = true; + let image = annotation.image || lastImageRef.current; + return (
{props.readOnly ? : }
- {annotation.image && ( + {image && ( handleSectionClick(e, 'image')} draggable={true} onDragStart={handleDragStart} diff --git a/src/common/components/reader-ui.js b/src/common/components/reader-ui.js index 1c67aaac..8a8402c7 100644 --- a/src/common/components/reader-ui.js +++ b/src/common/components/reader-ui.js @@ -43,14 +43,6 @@ function View(props) { data-proxy={`#${name}-view > iframe`} style={{ position: 'absolute' }} /> - {state[name + 'ViewFindState'].popupOpen && - - } {state[name + 'ViewSelectionPopup'] && !state.readOnly && } + {state[name + 'ViewFindState'].popupOpen && + + } ); } diff --git a/src/common/components/sidebar/annotations-view.js b/src/common/components/sidebar/annotations-view.js index 9169abe6..336f7a98 100644 --- a/src/common/components/sidebar/annotations-view.js +++ b/src/common/components/sidebar/annotations-view.js @@ -193,7 +193,7 @@ const AnnotationsView = memo(React.forwardRef((props, ref) => { }, []); // Allow navigating to next/previous annotation if inner annotation element like - // more button, empty comment or tags are focused + // more button, or tags are focused, but not comment/text function handleKeyDown(event) { let node = event.target; // Don't do anything if annotation element is focused, because focus-manager will do the navigation @@ -201,7 +201,7 @@ const AnnotationsView = memo(React.forwardRef((props, ref) => { return; } let annotationNode = node.closest('.annotation'); - if (!node.classList.contains('content') || !node.innerText) { + if (!node.classList.contains('content')) { if (pressedPreviousKey(event)) { annotationNode.previousElementSibling?.focus(); event.preventDefault(); diff --git a/src/common/components/sidebar/search-box.js b/src/common/components/sidebar/search-box.js index ae6fcda0..8c6d0db3 100644 --- a/src/common/components/sidebar/search-box.js +++ b/src/common/components/sidebar/search-box.js @@ -23,7 +23,7 @@ function SearchBox({ query, placeholder, onInput }) { function handleKeyDown(event) { if (event.key === 'Escape') { if (event.target.value) { - handleClear(); + handleClear(event); event.stopPropagation(); } } diff --git a/src/common/components/sidebar/thumbnails-view.js b/src/common/components/sidebar/thumbnails-view.js index 6b5ef3dd..1d456df6 100644 --- a/src/common/components/sidebar/thumbnails-view.js +++ b/src/common/components/sidebar/thumbnails-view.js @@ -224,7 +224,7 @@ function ThumbnailsView(props) { onMouseDown={handleMouseDown} ref={containerRef} tabIndex={-1} - role='listbox' + role="listbox" aria-label={intl.formatMessage({ id: "pdfReader.thumbnails" })} aria-activedescendant={`thumbnail_${selected[selected.length-1]}`} aria-multiselectable="true" diff --git a/src/common/components/view-popup/find-popup.js b/src/common/components/view-popup/find-popup.js index 4135a1c3..90e6bf30 100644 --- a/src/common/components/view-popup/find-popup.js +++ b/src/common/components/view-popup/find-popup.js @@ -7,15 +7,20 @@ import { DEBOUNCE_FIND_POPUP_INPUT } from '../../defines'; import IconChevronUp from '../../../../res/icons/20/chevron-up.svg'; import IconChevronDown from '../../../../res/icons/20/chevron-down.svg'; import IconClose from '../../../../res/icons/20/x.svg'; +import { getCodeCombination, getKeyCombination } from '../../lib/utilities'; -function FindPopup({ params, onChange, onFindNext, onFindPrevious }) { +function FindPopup({ params, onChange, onFindNext, onFindPrevious, onAddAnnotation }) { const intl = useIntl(); const inputRef = useRef(); + const preventInputRef = useRef(false); const [query, setQuery] = useState(params.query); const debounceInputChange = useCallback(debounce(value => { + if (!inputRef.current) { + return; + } let query = inputRef.current.value; - if (!(query.length === 1 && RegExp(/^\p{Script=Latin}/, 'u').test(query))) { + if (query !== params.query && !(query.length === 1 && RegExp(/^\p{Script=Latin}/, 'u').test(query))) { onChange({ ...params, query, active: true, result: null }); } }, DEBOUNCE_FIND_POPUP_INPUT), [onChange]); @@ -31,25 +36,51 @@ function FindPopup({ params, onChange, onFindNext, onFindPrevious }) { }, [params.query]); function handleInputChange(event) { + if (preventInputRef.current) { + preventInputRef.current = false; + return; + } let value = event.target.value; setQuery(value); debounceInputChange(); } function handleInputKeyDown(event) { - if (event.key === 'Enter') { + let key = getKeyCombination(event); + let code = getCodeCombination(event); + if (key === 'Enter') { if (params.active) { - if (event.shiftKey) { - onFindPrevious(); - } - else { - onFindNext(); - } + onFindNext(); } else { onChange({ ...params, active: true }); } } + else if (key === 'Shift-Enter') { + if (params.active) { + onFindPrevious(); + } + else { + onChange({ ...params, active: true }); + } + } + else if (key === 'Escape') { + onChange({ ...params, popupOpen: false, active: false, result: null }); + event.preventDefault(); + event.stopPropagation(); + } + else if (code === 'Ctrl-Alt-Digit1') { + preventInputRef.current = true; + if (params.result?.annotation) { + onAddAnnotation({ ...params.result.annotation, type: 'highlight' }, true); + } + } + else if (code === 'Ctrl-Alt-Digit2') { + preventInputRef.current = true; + if (params.result?.annotation) { + onAddAnnotation({ ...params.result.annotation, type: 'underline' }, true); + } + } } function handleCloseClick() { diff --git a/src/common/components/view-popup/overlay-popup/preview-popup.js b/src/common/components/view-popup/overlay-popup/preview-popup.js index 18d56244..6cb2ac03 100644 --- a/src/common/components/view-popup/overlay-popup/preview-popup.js +++ b/src/common/components/view-popup/overlay-popup/preview-popup.js @@ -30,7 +30,7 @@ function PreviewPopup(props) { padding={10} onRender={handleRender} > -
+
diff --git a/src/common/defines.js b/src/common/defines.js index 77a64db7..aadee024 100644 --- a/src/common/defines.js +++ b/src/common/defines.js @@ -28,3 +28,6 @@ export const MIN_IMAGE_ANNOTATION_SIZE = 10; // pt export const DEBOUNCE_STATE_CHANGE = 300; // ms export const DEBOUNCE_STATS_CHANGE = 100; // ms export const DEBOUNCE_FIND_POPUP_INPUT = 500; // ms + +export const FIND_RESULT_COLOR_ALL = '#EDD3ED'; +export const FIND_RESULT_COLOR_CURRENT = '#D4E0D1'; diff --git a/src/common/focus-manager.js b/src/common/focus-manager.js index b88f8339..31b1dc63 100644 --- a/src/common/focus-manager.js +++ b/src/common/focus-manager.js @@ -81,7 +81,7 @@ export class FocusManager { _handlePointerDown(event) { if ('closest' in event.target) { - if (!event.target.closest('input, textarea, [contenteditable="true"], .annotation, .thumbnails-view, .outline-view, .error-bar, .reference-row')) { + if (!event.target.closest('input, textarea, [contenteditable="true"], .annotation, .thumbnails-view, .outline-view, .error-bar, .reference-row, .preview-popup')) { // Note: Doing event.preventDefault() also prevents :active class on Firefox event.preventDefault(); } @@ -105,11 +105,11 @@ export class FocusManager { if ((e.target.closest('.outline-view') || e.target.closest('input[type="range"]')) && ['ArrowLeft', 'ArrowRight'].includes(e.key)) { return; } - if (pressedNextKey(e) && !e.target.closest('[contenteditable], input[type="text"]')) { + if (pressedNextKey(e) && !e.target.closest('[contenteditable], input[type="text"], .preview-popup')) { e.preventDefault(); this.tabToItem(); } - else if (pressedPreviousKey(e) && !e.target.closest('[contenteditable], input[type="text"]')) { + else if (pressedPreviousKey(e) && !e.target.closest('[contenteditable], input[type="text"], .preview-popup')) { e.preventDefault(); this.tabToItem(true); } @@ -168,6 +168,19 @@ export class FocusManager { group = groups[groupIndex]; + // If jumping into the sidebar annotations view, focus the last selected annotation, + // but don't trigger navigation in the view + if (group.classList.contains('annotations') + && this._reader._lastSelectedAnnotationID + // Make sure there are at least two annotations, otherwise it won't be possible to navigate to annotation + && this._reader._state.annotations.length >= 2 + // Make sure the annotation still exists + && this._reader._state.annotations.find(x => x.id === this._reader._lastSelectedAnnotationID)) { + this._reader._updateState({ selectedAnnotationIDs: [this._reader._lastSelectedAnnotationID] }); + // It also needs to be focused, otherwise pressing TAB will shift the focus to an unexpected location + setTimeout(() => group.querySelector(`[data-sidebar-annotation-id="${this._reader._lastSelectedAnnotationID}"]`)?.focus(), 100); + return; + } let focusableParent = item.parentNode.closest('[tabindex="-1"]'); diff --git a/src/common/keyboard-manager.js b/src/common/keyboard-manager.js index 077fbd61..7ab3e87c 100644 --- a/src/common/keyboard-manager.js +++ b/src/common/keyboard-manager.js @@ -4,7 +4,8 @@ import { isMac, getKeyCombination, isWin, - getCodeCombination + getCodeCombination, + setCaretToEnd } from './lib/utilities'; import { ANNOTATION_COLORS } from './defines'; @@ -68,33 +69,89 @@ export class KeyboardManager { } } - // Escape must be pressed alone. We basically want to prevent - // Option-Escape (speak text on macOS) deselecting text + // Focus on the last view if an arrow key is pressed in an empty annotation comment within the sidebar, + // and the annotation was selected from the view + let content = document.activeElement?.closest('.annotation .comment .content'); + if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(key) + && (!content || !content.innerText) + && this._reader._annotationSelectionTriggeredFromView + ) { + setTimeout(() => this._reader._lastView.focus()); + } + if (key === 'Escape') { - this._reader._lastView.focus(); - this._reader.abortPrint(); - this._reader._updateState({ - selectedAnnotationIDs: [], - labelPopup: null, - contextMenu: null, - tool: this._reader._tools['pointer'], - primaryViewFindState: { - ...this._reader._state.primaryViewFindState, - active: false, - popupOpen: false, - }, - secondaryViewFindState: { - ...this._reader._state.secondaryViewFindState, - active: false, - popupOpen: false + // Blur annotation comment and focus either the last view or the annotation in the sidebar + if (document.activeElement.closest('.annotation .content')) { + // Restore focus to the last view if the comment was focused by clicking on + // an annotation without a comment. + if (this._reader._annotationSelectionTriggeredFromView) { + this._reader._updateState({ selectedAnnotationIDs: [] }); + setTimeout(() => this._reader._lastView.focus()); + } + // Focus sidebar annotation (this is necessary for when using Enter/Escape to quickly + // focus/blur sidebar annotation comment + else { + setTimeout(() => document.activeElement.closest('.annotation').focus()); } - }); - this._reader.setFilter({ - query: '', - colors: [], - tags: [], - authors: [] - }); + } + // Close print popup and cancel print preparation + else if (this._reader._state.printPopup) { + event.preventDefault(); + this._reader.abortPrint(); + setTimeout(() => this._reader._lastView.focus()); + } + // Close context menu + else if (this._reader._state.contextMenu) { + event.preventDefault(); + this._reader._updateState({ contextMenu: null }); + setTimeout(() => this._reader._lastView.focus()); + } + // Close label popup + else if (this._reader._state.labelPopup) { + event.preventDefault(); + this._reader._updateState({ labelPopup: null }); + setTimeout(() => this._reader._lastView.focus()); + } + // Close both overlay popups + else if ( + this._reader._state.primaryViewOverlayPopup + || this._reader._state.secondaryViewOverlayPopup + ) { + event.preventDefault(); + this._reader._updateState({ + primaryViewOverlayPopup: null, + secondaryViewOverlayPopup: null + }); + setTimeout(() => this._reader._lastView.focus()); + } + // Deselect annotations + else if (this._reader._state.selectedAnnotationIDs.length) { + event.preventDefault(); + this._reader._updateState({ selectedAnnotationIDs: [] }); + setTimeout(() => this._reader._lastView.focus()); + } + // Switch off the current annotation tool + else if (this._reader._state.tool !== this._reader._tools['pointer']) { + this._reader._updateState({ tool: this._reader._tools['pointer'] }); + event.preventDefault(); + setTimeout(() => this._reader._lastView.focus()); + } + else { + setTimeout(() => this._reader._lastView.focus()); + } + } + + // Focus sidebar annotation comment if pressed Enter + if (key === 'Enter') { + if (document.activeElement.classList.contains('annotation')) { + setTimeout(() => { + let input = document.activeElement.querySelector('.comment .content'); + if (input) { + input.focus(); + setCaretToEnd(input); + } + }); + } } if (['Cmd-a', 'Ctrl-a'].includes(key)) { diff --git a/src/common/lib/utilities.js b/src/common/lib/utilities.js index e481c909..339f7292 100644 --- a/src/common/lib/utilities.js +++ b/src/common/lib/utilities.js @@ -74,8 +74,15 @@ export function getKeyCombination(event) { if (key === ' ') { key = 'Space'; } + + if (['Shift', 'Control', 'Meta', 'Alt'].includes(key)) { + key = ''; + } + // Combine the modifiers and the normalized key into a single string - modifiers.push(key); + if (key) { + modifiers.push(key); + } return modifiers.join('-'); } @@ -94,8 +101,17 @@ export function getCodeCombination(event) { if (event.shiftKey) { modifiers.push('Shift'); } + + let { key, code } = event; + + if (['Shift', 'Control', 'Meta', 'Alt'].includes(key)) { + code = ''; + } + // Combine the modifiers and the normalized key into a single string - modifiers.push(event.code); + if (code) { + modifiers.push(code); + } return modifiers.join('-'); } diff --git a/src/common/reader.js b/src/common/reader.js index 31ecc2fa..2a5e1b2b 100644 --- a/src/common/reader.js +++ b/src/common/reader.js @@ -25,6 +25,8 @@ import { debounce } from './lib/debounce'; // Compute style values for usage in views (CSS variables aren't sufficient for that) // Font family is necessary for text annotations window.computedFontFamily = window.getComputedStyle(document.body).getPropertyValue('font-family'); +window.computedColorFocusBorder = window.getComputedStyle(document.body).getPropertyValue('--color-focus-border'); +window.computedWidthFocusBorder = window.getComputedStyle(document.body).getPropertyValue('--width-focus-border'); export const ReaderContext = createContext({}); @@ -218,6 +220,7 @@ class Reader { readOnly: this._state.readOnly, authorName: options.authorName, annotations: options.annotations, + tools: this._tools, onSave: this._onSaveAnnotations, onDelete: this._handleDeleteAnnotations, onRender: (annotations) => { @@ -264,9 +267,13 @@ class Reader { this._onChangeSidebarWidth(width); }} onResizeSplitView={this.setSplitViewSize.bind(this)} - onAddAnnotation={(annotation) => { - this._annotationManager.addAnnotation(annotation); - this.setSelectedAnnotations([]); + onAddAnnotation={(annotation, select) => { + annotation = this._annotationManager.addAnnotation(annotation); + if (select) { + this.setSelectedAnnotations([annotation.id]); + } else { + this.setSelectedAnnotations([]); + } }} onUpdateAnnotations={(annotations) => { this._annotationManager.updateAnnotations(annotations); @@ -1105,6 +1112,12 @@ class Reader { return; } + let reselecting = ids.length === 1 && this._state.selectedAnnotationIDs.includes(ids[0]); + + if (ids[0]) { + this._lastSelectedAnnotationID = ids[0]; + } + this._enableAnnotationDeletionFromComment = false; this._annotationSelectionTriggeredFromView = triggeredFromView; if (ids.length === 1) { @@ -1160,27 +1173,19 @@ class Reader { // Don't navigate to annotation or focus comment if opening a context menu if (!triggeringEvent || triggeringEvent.button !== 2) { if (triggeredFromView) { - if (annotation.type !== 'text') { + if (['note', 'highlight', 'underline'].includes(annotation.type) + && !annotation.comment && (!triggeringEvent || !('key' in triggeringEvent))) { this._enableAnnotationDeletionFromComment = true; - if (annotation.comment) { - let sidebarItem = document.querySelector(`[data-sidebar-annotation-id="${id}"]`); - if (sidebarItem) { - // Make sure to call this after all events, because mousedown will re-focus the View - setTimeout(() => sidebarItem.focus()); + setTimeout(() => { + let content; + if (this._state.sidebarOpen) { + content = document.querySelector(`[data-sidebar-annotation-id="${id}"] .comment .content`); } - } - else { - setTimeout(() => { - let content; - if (this._state.sidebarOpen) { - content = document.querySelector(`[data-sidebar-annotation-id="${id}"] .comment .content`); - } - else { - content = document.querySelector(`.annotation-popup .comment .content`); - } - content?.focus(); - }, 50); - } + else { + content = document.querySelector(`.annotation-popup .comment .content`); + } + content?.focus(); + }, 50); } } else { diff --git a/src/common/stylesheets/components/_view-popup.scss b/src/common/stylesheets/components/_view-popup.scss index 823df40d..ef644e52 100644 --- a/src/common/stylesheets/components/_view-popup.scss +++ b/src/common/stylesheets/components/_view-popup.scss @@ -222,6 +222,7 @@ overflow-y: auto; img { + pointer-events: none; @include pdf-page-image-dark-light; } } diff --git a/src/pdf/lib/utilities.js b/src/pdf/lib/utilities.js index 5060e9ac..f1555b7c 100644 --- a/src/pdf/lib/utilities.js +++ b/src/pdf/lib/utilities.js @@ -9,11 +9,17 @@ export function fitRectIntoRect(rect, containingRect) { export function getPositionBoundingRect(position, pageIndex) { // Use nextPageRects - if (position.rects) { + if (position.nextPageRects && position.pageIndex + 1 === pageIndex) { + let rects = position.nextPageRects; + return [ + Math.min(...rects.map(x => x[0])), + Math.min(...rects.map(x => x[1])), + Math.max(...rects.map(x => x[2])), + Math.max(...rects.map(x => x[3])) + ]; + } + if (position.rects && (position.pageIndex === pageIndex || pageIndex === undefined)) { let rects = position.rects; - if (position.nextPageRects && position.pageIndex + 1 === pageIndex) { - rects = position.nextPageRects; - } if (position.rotation) { let rect = rects[0]; let tm = getRotationTransform(rect, position.rotation); @@ -513,3 +519,92 @@ export function getRectsAreaSize(rects) { } return areaSize; } + +export function getClosestObject(currentObjectRect, otherObjects, side) { + let closestObject = null; + let closestObjectDistance = null; + + for (let object of otherObjects) { + let objectRect = object.rect; + if (side === 'left') { + if (currentObjectRect[0] >= objectRect[2]) { + let r1 = [currentObjectRect[0], currentObjectRect[1], currentObjectRect[0], currentObjectRect[3]]; + let r2 = [objectRect[2], objectRect[1], objectRect[2], objectRect[3]]; + let distance = distanceBetweenRects(r1, r2); + if (distance >= 0 && (!closestObject || closestObjectDistance > distance)) { + closestObject = object; + closestObjectDistance = distance; + } + } + } + else if (side === 'right') { + if (objectRect[0] >= currentObjectRect[2]) { + let r1 = [currentObjectRect[2], currentObjectRect[1], currentObjectRect[2], currentObjectRect[3]]; + let r2 = [objectRect[0], objectRect[1], objectRect[0], objectRect[3]]; + let distance = distanceBetweenRects(r1, r2); + if (distance >= 0 && (!closestObject || closestObjectDistance > distance)) { + closestObject = object; + closestObjectDistance = distance; + } + } + } + else if (side === 'top') { + if (objectRect[3] <= currentObjectRect[1]) { + let r1 = [currentObjectRect[0], currentObjectRect[1], currentObjectRect[2], currentObjectRect[1]]; + let r2 = [objectRect[0], objectRect[3], objectRect[2], objectRect[3]]; + let distance = distanceBetweenRects(r1, r2); + if (distance >= 0 && (!closestObject || closestObjectDistance > distance)) { + closestObject = object; + closestObjectDistance = distance; + } + } + } + else if (side === 'bottom') { + if (currentObjectRect[3] <= objectRect[1]) { + let r1 = [currentObjectRect[0], currentObjectRect[3], currentObjectRect[2], currentObjectRect[3]]; + let r2 = [objectRect[0], objectRect[1], objectRect[2], objectRect[1]]; + let distance = distanceBetweenRects(r1, r2); + if (distance >= 0 && (!closestObject || closestObjectDistance > distance)) { + closestObject = object; + closestObjectDistance = distance; + } + } + } + } + + if (!closestObject) { + for (let object of otherObjects) { + let objectRect = object.rect; + if (quickIntersectRect(currentObjectRect, objectRect) || !side) { + let distance = distanceBetweenRects(currentObjectRect, objectRect); + if ((!closestObject || closestObjectDistance > distance)) { + closestObject = object; + closestObjectDistance = distance; + } + } + } + } + + return closestObject; +} + +export function getRangeRects(chars, offsetStart, offsetEnd) { + let rects = []; + let start = offsetStart; + for (let i = start; i <= offsetEnd; i++) { + let char = chars[i]; + if (char.lineBreakAfter || i === offsetEnd) { + let firstChar = chars[start]; + let lastChar = char; + let rect = [ + firstChar.rect[0], + firstChar.inlineRect[1], + lastChar.rect[2], + firstChar.inlineRect[3], + ]; + rects.push(rect); + start = i + 1; + } + } + return rects; +} diff --git a/src/pdf/page.js b/src/pdf/page.js index ae7264d9..be91acdc 100644 --- a/src/pdf/page.js +++ b/src/pdf/page.js @@ -9,7 +9,12 @@ import { normalizeDegrees, inverseTransform } from './lib/utilities'; -import { DARKEN_INK_AND_TEXT_COLOR, MIN_IMAGE_ANNOTATION_SIZE, SELECTION_COLOR } from '../common/defines'; +import { + DARKEN_INK_AND_TEXT_COLOR, + FIND_RESULT_COLOR_ALL, FIND_RESULT_COLOR_CURRENT, + MIN_IMAGE_ANNOTATION_SIZE, + SELECTION_COLOR +} from '../common/defines'; import { getRectRotationOnText } from './selection'; import { darkenHex } from './lib/utilities'; @@ -443,6 +448,43 @@ export default class Page { this.actualContext.restore(); } + _renderFindResults() { + if (!this.layer._findController) { + return; + } + if (!this.layer._findController.highlightMatches) { + return; + } + let { _pageMatchesPosition, selected } = this.layer._findController; + let positions = _pageMatchesPosition[this.pageIndex]; + + if (!positions || !positions.length) { + return; + } + + this.actualContext.save(); + this.actualContext.globalCompositeOperation = 'multiply'; + + for (let i = 0; i < positions.length; i++) { + let position = positions[i]; + if (selected.pageIdx === this.pageIndex && i === selected.matchIdx) { + this.actualContext.fillStyle = FIND_RESULT_COLOR_CURRENT; + } + else { + if (!this.layer._findController.state.highlightAll) { + continue; + } + this.actualContext.fillStyle = FIND_RESULT_COLOR_ALL; + } + + position = this.p2v(position); + for (let rect of position.rects) { + this.actualContext.fillRect(rect[0], rect[1], rect[2] - rect[0], rect[3] - rect[1]); + } + } + + this.actualContext.restore(); + } render() { @@ -515,6 +557,13 @@ export default class Page { node.classList.remove('focusable'); // node.contentEditable = false; }); + node.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + event.stopPropagation(); + event.preventDefault(); + node.blur(); + } + }); customAnnotationLayer.append(node); } @@ -593,36 +642,44 @@ export default class Page { this.drawHover(); + this._renderFindResults(); - - if (focusedObject && ( - focusedObject.position.pageIndex === this.pageIndex - || focusedObject.position.nextPageRects && focusedObject.position.pageIndex + 1 === this.pageIndex - )) { - let position = focusedObject.position; - - this.actualContext.strokeStyle = '#838383'; - this.actualContext.beginPath(); - this.actualContext.setLineDash([5 * devicePixelRatio, 3 * devicePixelRatio]); - this.actualContext.lineWidth = 2 * devicePixelRatio; - + if (!selectedAnnotationIDs.length + && focusedObject && ( + focusedObject.pageIndex === this.pageIndex + || focusedObject.object.position.nextPageRects && focusedObject.pageIndex === this.pageIndex + ) + ) { + let position = focusedObject.object.position; + this.actualContext.strokeStyle = window.computedColorFocusBorder; + this.actualContext.lineWidth = 3 * devicePixelRatio; let padding = 5 * devicePixelRatio; - let rect = getPositionBoundingRect(position, this.pageIndex); rect = this.getViewRect(rect); - rect = [ rect[0] - padding, rect[1] - padding, rect[2] + padding, rect[3] + padding, ]; - this.actualContext.rect(rect[0], rect[1], rect[2] - rect[0], rect[3] - rect[1]); + + let radius = 10 * devicePixelRatio; // Radius for rounded corners + + this.actualContext.beginPath(); + this.actualContext.moveTo(rect[0] + radius, rect[1]); + this.actualContext.lineTo(rect[2] - radius, rect[1]); + this.actualContext.arcTo(rect[2], rect[1], rect[2], rect[1] + radius, radius); + this.actualContext.lineTo(rect[2], rect[3] - radius); + this.actualContext.arcTo(rect[2], rect[3], rect[2] - radius, rect[3], radius); + this.actualContext.lineTo(rect[0] + radius, rect[3]); + this.actualContext.arcTo(rect[0], rect[3], rect[0], rect[3] - radius, radius); + this.actualContext.lineTo(rect[0], rect[1] + radius); + this.actualContext.arcTo(rect[0], rect[1], rect[0] + radius, rect[1], radius); this.actualContext.stroke(); } diff --git a/src/pdf/pdf-find-controller.js b/src/pdf/pdf-find-controller.js new file mode 100644 index 00000000..a2beffcb --- /dev/null +++ b/src/pdf/pdf-find-controller.js @@ -0,0 +1,1271 @@ +/* + * Modified version of PDF.js pdf_find_controller.js + */ + +/* Copyright 2012 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +Promise.withResolvers || (Promise.withResolvers = function withResolvers() { + var a, b, c = new this(function (resolve, reject) { + a = resolve; + b = reject; + }); + return { resolve: a, reject: b, promise: c }; +}); + +import { getRangeRects } from './lib/utilities'; + +// pdf_find_utils.js [ +const CharacterType = { + SPACE: 0, + ALPHA_LETTER: 1, + PUNCT: 2, + HAN_LETTER: 3, + KATAKANA_LETTER: 4, + HIRAGANA_LETTER: 5, + HALFWIDTH_KATAKANA_LETTER: 6, + THAI_LETTER: 7, +}; + +function isAlphabeticalScript(charCode) { + return charCode < 0x2e80; +} + +function isAscii(charCode) { + return (charCode & 0xff80) === 0; +} + +function isAsciiAlpha(charCode) { + return ( + (charCode >= /* a = */ 0x61 && charCode <= /* z = */ 0x7a) || + (charCode >= /* A = */ 0x41 && charCode <= /* Z = */ 0x5a) + ); +} + +function isAsciiDigit(charCode) { + return charCode >= /* 0 = */ 0x30 && charCode <= /* 9 = */ 0x39; +} + +function isAsciiSpace(charCode) { + return ( + charCode === /* SPACE = */ 0x20 || + charCode === /* TAB = */ 0x09 || + charCode === /* CR = */ 0x0d || + charCode === /* LF = */ 0x0a + ); +} + +function isHan(charCode) { + return ( + (charCode >= 0x3400 && charCode <= 0x9fff) || + (charCode >= 0xf900 && charCode <= 0xfaff) + ); +} + +function isKatakana(charCode) { + return charCode >= 0x30a0 && charCode <= 0x30ff; +} + +function isHiragana(charCode) { + return charCode >= 0x3040 && charCode <= 0x309f; +} + +function isHalfwidthKatakana(charCode) { + return charCode >= 0xff60 && charCode <= 0xff9f; +} + +function isThai(charCode) { + return (charCode & 0xff80) === 0x0e00; +} + +/** + * This function is based on the word-break detection implemented in: + * https://hg.mozilla.org/mozilla-central/file/tip/intl/lwbrk/WordBreaker.cpp + */ +function getCharacterType(charCode) { + if (isAlphabeticalScript(charCode)) { + if (isAscii(charCode)) { + if (isAsciiSpace(charCode)) { + return CharacterType.SPACE; + } + else if ( + isAsciiAlpha(charCode) || + isAsciiDigit(charCode) || + charCode === /* UNDERSCORE = */ 0x5f + ) { + return CharacterType.ALPHA_LETTER; + } + return CharacterType.PUNCT; + } + else if (isThai(charCode)) { + return CharacterType.THAI_LETTER; + } + else if (charCode === /* NBSP = */ 0xa0) { + return CharacterType.SPACE; + } + return CharacterType.ALPHA_LETTER; + } + + if (isHan(charCode)) { + return CharacterType.HAN_LETTER; + } + else if (isKatakana(charCode)) { + return CharacterType.KATAKANA_LETTER; + } + else if (isHiragana(charCode)) { + return CharacterType.HIRAGANA_LETTER; + } + else if (isHalfwidthKatakana(charCode)) { + return CharacterType.HALFWIDTH_KATAKANA_LETTER; + } + return CharacterType.ALPHA_LETTER; +} + +let NormalizeWithNFKC; + +function getNormalizeWithNFKC() { + /* eslint-disable no-irregular-whitespace */ + NormalizeWithNFKC ||= ` ¨ª¯²-µ¸-º¼-¾IJ-ijĿ-ŀʼnſDŽ-njDZ-dzʰ-ʸ˘-˝ˠ-ˤʹͺ;΄-΅·ϐ-ϖϰ-ϲϴ-ϵϹևٵ-ٸक़-य़ড়-ঢ়য়ਲ਼ਸ਼ਖ਼-ਜ਼ਫ਼ଡ଼-ଢ଼ำຳໜ-ໝ༌གྷཌྷདྷབྷཛྷཀྵჼᴬ-ᴮᴰ-ᴺᴼ-ᵍᵏ-ᵪᵸᶛ-ᶿẚ-ẛάέήίόύώΆ᾽-῁ΈΉ῍-῏ΐΊ῝-῟ΰΎ῭-`ΌΏ´-῾ - ‑‗․-… ″-‴‶-‷‼‾⁇-⁉⁗ ⁰-ⁱ⁴-₎ₐ-ₜ₨℀-℃℅-ℇ℉-ℓℕ-№ℙ-ℝ℠-™ℤΩℨK-ℭℯ-ℱℳ-ℹ℻-⅀ⅅ-ⅉ⅐-ⅿ↉∬-∭∯-∰〈-〉①-⓪⨌⩴-⩶⫝̸ⱼ-ⱽⵯ⺟⻳⼀-⿕ 〶〸-〺゛-゜ゟヿㄱ-ㆎ㆒-㆟㈀-㈞㈠-㉇㉐-㉾㊀-㏿ꚜ-ꚝꝰꟲ-ꟴꟸ-ꟹꭜ-ꭟꭩ豈-嗀塚晴凞-羽蘒諸逸-都飯-舘並-龎ff-stﬓ-ﬗיִײַ-זּטּ-לּמּנּ-סּףּ-פּצּ-ﮱﯓ-ﴽﵐ-ﶏﶒ-ﷇﷰ-﷼︐-︙︰-﹄﹇-﹒﹔-﹦﹨-﹫ﹰ-ﹲﹴﹶ-ﻼ!-하-ᅦᅧ-ᅬᅭ-ᅲᅳ-ᅵ¢-₩`; + + if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) { + const ranges = []; + const range = []; + const diacriticsRegex = /^\p{M}$/u; + // Some chars must be replaced by their NFKC counterpart during a search. + for (let i = 0; i < 65536; i++) { + const c = String.fromCharCode(i); + if (c.normalize("NFKC") !== c && !diacriticsRegex.test(c)) { + if (range.length !== 2) { + range[0] = range[1] = i; + continue; + } + if (range[1] + 1 !== i) { + if (range[0] === range[1]) { + ranges.push(String.fromCharCode(range[0])); + } + else { + ranges.push( + `${String.fromCharCode(range[0])}-${String.fromCharCode( + range[1] + )}` + ); + } + range[0] = range[1] = i; + } + else { + range[1] = i; + } + } + } + if (ranges.join("") !== NormalizeWithNFKC) { + throw new Error( + "getNormalizeWithNFKC - update the `NormalizeWithNFKC` string." + ); + } + } + return NormalizeWithNFKC; +} +// ] + +/** + * Use binary search to find the index of the first item in a given array which + * passes a given condition. The items are expected to be sorted in the sense + * that if the condition is true for one item in the array, then it is also true + * for all following items. + * + * @returns {number} Index of the first array element to pass the test, + * or |items.length| if no such element exists. + */ +function binarySearchFirstItem(items, condition, start = 0) { + let minIndex = start; + let maxIndex = items.length - 1; + + if (maxIndex < 0 || !condition(items[maxIndex])) { + return items.length; + } + if (condition(items[minIndex])) { + return minIndex; + } + + while (minIndex < maxIndex) { + const currentIndex = (minIndex + maxIndex) >> 1; + const currentItem = items[currentIndex]; + if (condition(currentItem)) { + maxIndex = currentIndex; + } + else { + minIndex = currentIndex + 1; + } + } + return minIndex; /* === maxIndex */ +} + + +const FindState = { + FOUND: 0, + NOT_FOUND: 1, + WRAPPED: 2, + PENDING: 3, +}; + +const FIND_TIMEOUT = 250; // ms + +const CHARACTERS_TO_NORMALIZE = { + "\u2010": "-", // Hyphen + "\u2018": "'", // Left single quotation mark + "\u2019": "'", // Right single quotation mark + "\u201A": "'", // Single low-9 quotation mark + "\u201B": "'", // Single high-reversed-9 quotation mark + "\u201C": '"', // Left double quotation mark + "\u201D": '"', // Right double quotation mark + "\u201E": '"', // Double low-9 quotation mark + "\u201F": '"', // Double high-reversed-9 quotation mark + "\u00BC": "1/4", // Vulgar fraction one quarter + "\u00BD": "1/2", // Vulgar fraction one half + "\u00BE": "3/4", // Vulgar fraction three quarters +}; + +// These diacritics aren't considered as combining diacritics +// when searching in a document: +// https://searchfox.org/mozilla-central/source/intl/unicharutil/util/is_combining_diacritic.py. +// The combining class definitions can be found: +// https://www.unicode.org/reports/tr44/#Canonical_Combining_Class_Values +// Category 0 corresponds to [^\p{Mn}]. +const DIACRITICS_EXCEPTION = new Set([ + // UNICODE_COMBINING_CLASS_KANA_VOICING + // https://www.compart.com/fr/unicode/combining/8 + 0x3099, 0x309a, + // UNICODE_COMBINING_CLASS_VIRAMA (under 0xFFFF) + // https://www.compart.com/fr/unicode/combining/9 + 0x094d, 0x09cd, 0x0a4d, 0x0acd, 0x0b4d, 0x0bcd, 0x0c4d, 0x0ccd, 0x0d3b, + 0x0d3c, 0x0d4d, 0x0dca, 0x0e3a, 0x0eba, 0x0f84, 0x1039, 0x103a, 0x1714, + 0x1734, 0x17d2, 0x1a60, 0x1b44, 0x1baa, 0x1bab, 0x1bf2, 0x1bf3, 0x2d7f, + 0xa806, 0xa82c, 0xa8c4, 0xa953, 0xa9c0, 0xaaf6, 0xabed, + // 91 + // https://www.compart.com/fr/unicode/combining/91 + 0x0c56, + // 129 + // https://www.compart.com/fr/unicode/combining/129 + 0x0f71, + // 130 + // https://www.compart.com/fr/unicode/combining/130 + 0x0f72, 0x0f7a, 0x0f7b, 0x0f7c, 0x0f7d, 0x0f80, + // 132 + // https://www.compart.com/fr/unicode/combining/132 + 0x0f74, +]); +let DIACRITICS_EXCEPTION_STR; // Lazily initialized, see below. + +const DIACRITICS_REG_EXP = /\p{M}+/gu; +const SPECIAL_CHARS_REG_EXP = + /([.*+?^${}()|[\]\\])|(\p{P})|(\s+)|(\p{M})|(\p{L})/gu; +const NOT_DIACRITIC_FROM_END_REG_EXP = /([^\p{M}])\p{M}*$/u; +const NOT_DIACRITIC_FROM_START_REG_EXP = /^\p{M}*([^\p{M}])/u; + +// The range [AC00-D7AF] corresponds to the Hangul syllables. +// The few other chars are some CJK Compatibility Ideographs. +const SYLLABLES_REG_EXP = /[\uAC00-\uD7AF\uFA6C\uFACF-\uFAD1\uFAD5-\uFAD7]+/g; +const SYLLABLES_LENGTHS = new Map(); +// When decomposed (in using NFD) the above syllables will start +// with one of the chars in this regexp. +const FIRST_CHAR_SYLLABLES_REG_EXP = + "[\\u1100-\\u1112\\ud7a4-\\ud7af\\ud84a\\ud84c\\ud850\\ud854\\ud857\\ud85f]"; + +const NFKC_CHARS_TO_NORMALIZE = new Map(); + +let noSyllablesRegExp = null; +let withSyllablesRegExp = null; + +function normalize(text) { + // The diacritics in the text or in the query can be composed or not. + // So we use a decomposed text using NFD (and the same for the query) + // in order to be sure that diacritics are in the same order. + + // Collect syllables length and positions. + const syllablePositions = []; + let m; + while ((m = SYLLABLES_REG_EXP.exec(text)) !== null) { + let { index } = m; + for (const char of m[0]) { + let len = SYLLABLES_LENGTHS.get(char); + if (!len) { + len = char.normalize("NFD").length; + SYLLABLES_LENGTHS.set(char, len); + } + syllablePositions.push([len, index++]); + } + } + + let normalizationRegex; + if (syllablePositions.length === 0 && noSyllablesRegExp) { + normalizationRegex = noSyllablesRegExp; + } + else if (syllablePositions.length > 0 && withSyllablesRegExp) { + normalizationRegex = withSyllablesRegExp; + } + else { + // Compile the regular expression for text normalization once. + const replace = Object.keys(CHARACTERS_TO_NORMALIZE).join(""); + const toNormalizeWithNFKC = getNormalizeWithNFKC(); + + // 3040-309F: Hiragana + // 30A0-30FF: Katakana + const CJK = "(?:\\p{Ideographic}|[\u3040-\u30FF])"; + const HKDiacritics = "(?:\u3099|\u309A)"; + const regexp = `([${replace}])|([${toNormalizeWithNFKC}])|(${HKDiacritics}\\n)|(\\p{M}+(?:-\\n)?)|(\\S-\\n)|(${CJK}\\n)|(\\n)`; + + if (syllablePositions.length === 0) { + // Most of the syllables belong to Hangul so there are no need + // to search for them in a non-Hangul document. + // We use the \0 in order to have the same number of groups. + normalizationRegex = noSyllablesRegExp = new RegExp( + regexp + "|(\\u0000)", + "gum" + ); + } + else { + normalizationRegex = withSyllablesRegExp = new RegExp( + regexp + `|(${FIRST_CHAR_SYLLABLES_REG_EXP})`, + "gum" + ); + } + } + + // The goal of this function is to normalize the string and + // be able to get from an index in the new string the + // corresponding index in the old string. + // For example if we have: abCd12ef456gh where C is replaced by ccc + // and numbers replaced by nothing (it's the case for diacritics), then + // we'll obtain the normalized string: abcccdefgh. + // So here the reverse map is: [0,1,2,2,2,3,6,7,11,12]. + + // The goal is to obtain the array: [[0, 0], [3, -1], [4, -2], + // [6, 0], [8, 3]]. + // which can be used like this: + // - let say that i is the index in new string and j the index + // the old string. + // - if i is in [0; 3[ then j = i + 0 + // - if i is in [3; 4[ then j = i - 1 + // - if i is in [4; 6[ then j = i - 2 + // ... + // Thanks to a binary search it's easy to know where is i and what's the + // shift. + // Let say that the last entry in the array is [x, s] and we have a + // substitution at index y (old string) which will replace o chars by n chars. + // Firstly, if o === n, then no need to add a new entry: the shift is + // the same. + // Secondly, if o < n, then we push the n - o elements: + // [y - (s - 1), s - 1], [y - (s - 2), s - 2], ... + // Thirdly, if o > n, then we push the element: [y - (s - n), o + s - n] + + // Collect diacritics length and positions. + const rawDiacriticsPositions = []; + while ((m = DIACRITICS_REG_EXP.exec(text)) !== null) { + rawDiacriticsPositions.push([m[0].length, m.index]); + } + + let normalized = text.normalize("NFD"); + const positions = [[0, 0]]; + let rawDiacriticsIndex = 0; + let syllableIndex = 0; + let shift = 0; + let shiftOrigin = 0; + let eol = 0; + let hasDiacritics = false; + + normalized = normalized.replace( + normalizationRegex, + (match, p1, p2, p3, p4, p5, p6, p7, p8, i) => { + i -= shiftOrigin; + if (p1) { + // Maybe fractions or quotations mark... + const replacement = CHARACTERS_TO_NORMALIZE[p1]; + const jj = replacement.length; + for (let j = 1; j < jj; j++) { + positions.push([i - shift + j, shift - j]); + } + shift -= jj - 1; + return replacement; + } + + if (p2) { + // Use the NFKC representation to normalize the char. + let replacement = NFKC_CHARS_TO_NORMALIZE.get(p2); + if (!replacement) { + replacement = p2.normalize("NFKC"); + NFKC_CHARS_TO_NORMALIZE.set(p2, replacement); + } + const jj = replacement.length; + for (let j = 1; j < jj; j++) { + positions.push([i - shift + j, shift - j]); + } + shift -= jj - 1; + return replacement; + } + + if (p3) { + // We've a Katakana-Hiragana diacritic followed by a \n so don't replace + // the \n by a whitespace. + hasDiacritics = true; + + // Diacritic. + if (i + eol === rawDiacriticsPositions[rawDiacriticsIndex]?.[1]) { + ++rawDiacriticsIndex; + } + else { + // i is the position of the first diacritic + // so (i - 1) is the position for the letter before. + positions.push([i - 1 - shift + 1, shift - 1]); + shift -= 1; + shiftOrigin += 1; + } + + // End-of-line. + positions.push([i - shift + 1, shift]); + shiftOrigin += 1; + eol += 1; + + return p3.charAt(0); + } + + if (p4) { + const hasTrailingDashEOL = p4.endsWith("\n"); + const len = hasTrailingDashEOL ? p4.length - 2 : p4.length; + + // Diacritics. + hasDiacritics = true; + let jj = len; + if (i + eol === rawDiacriticsPositions[rawDiacriticsIndex]?.[1]) { + jj -= rawDiacriticsPositions[rawDiacriticsIndex][0]; + ++rawDiacriticsIndex; + } + + for (let j = 1; j <= jj; j++) { + // i is the position of the first diacritic + // so (i - 1) is the position for the letter before. + positions.push([i - 1 - shift + j, shift - j]); + } + shift -= jj; + shiftOrigin += jj; + + if (hasTrailingDashEOL) { + // Diacritics are followed by a -\n. + // See comments in `if (p5)` block. + i += len - 1; + positions.push([i - shift + 1, 1 + shift]); + shift += 1; + shiftOrigin += 1; + eol += 1; + return p4.slice(0, len); + } + + return p4; + } + + if (p5) { + // "X-\n" is removed because an hyphen at the end of a line + // with not a space before is likely here to mark a break + // in a word. + // If X is encoded with UTF-32 then it can have a length greater than 1. + // The \n isn't in the original text so here y = i, n = X.len - 2 and + // o = X.len - 1. + const len = p5.length - 2; + positions.push([i - shift + len, 1 + shift]); + shift += 1; + shiftOrigin += 1; + eol += 1; + return p5.slice(0, -2); + } + + if (p6) { + // An ideographic at the end of a line doesn't imply adding an extra + // white space. + // A CJK can be encoded in UTF-32, hence their length isn't always 1. + const len = p6.length - 1; + positions.push([i - shift + len, shift]); + shiftOrigin += 1; + eol += 1; + return p6.slice(0, -1); + } + + if (p7) { + // eol is replaced by space: "foo\nbar" is likely equivalent to + // "foo bar". + positions.push([i - shift + 1, shift - 1]); + shift -= 1; + shiftOrigin += 1; + eol += 1; + return " "; + } + + // p8 + if (i + eol === syllablePositions[syllableIndex]?.[1]) { + // A syllable (1 char) is replaced with several chars (n) so + // newCharsLen = n - 1. + const newCharLen = syllablePositions[syllableIndex][0] - 1; + ++syllableIndex; + for (let j = 1; j <= newCharLen; j++) { + positions.push([i - (shift - j), shift - j]); + } + shift -= newCharLen; + shiftOrigin += newCharLen; + } + return p8; + } + ); + + positions.push([normalized.length, shift]); + + return [normalized, positions, hasDiacritics]; +} + +// Determine the original, non-normalized, match index such that highlighting of +// search results is correct in the `textLayer` for strings containing e.g. "½" +// characters; essentially "inverting" the result of the `normalize` function. +function getOriginalIndex(diffs, pos, len) { + if (!diffs) { + return [pos, len]; + } + + // First char in the new string. + const start = pos; + // Last char in the new string. + const end = pos + len - 1; + let i = binarySearchFirstItem(diffs, x => x[0] >= start); + if (diffs[i][0] > start) { + --i; + } + + let j = binarySearchFirstItem(diffs, x => x[0] >= end, i); + if (diffs[j][0] > end) { + --j; + } + + // First char in the old string. + const oldStart = start + diffs[i][1]; + + // Last char in the old string. + const oldEnd = end + diffs[j][1]; + const oldLen = oldEnd + 1 - oldStart; + + return [oldStart, oldLen]; +} + +/** + * @typedef {Object} PDFFindControllerOptions + * @property {IPDFLinkService} linkService - The navigation/linking service. + */ + +/** + * Provides search functionality to find a given string in a PDF document. + */ +class PDFFindController { + _state = null; + + _visitedPagesCount = 0; + + /** + * @param {PDFFindControllerOptions} options + */ + constructor({ linkService, onNavigate, onUpdateMatches, onUpdateState }) { + this._linkService = linkService; + this._onNavigate = onNavigate; + this._onUpdateMatches = onUpdateMatches; + this._onUpdateState = onUpdateState; + + /** + * Callback used to check if a `pageNumber` is currently visible. + * @type {function} + */ + this.onIsPageVisible = null; + + this._reset(); + // eventBus._on("find", this.#onFind.bind(this)); + // eventBus._on("findbarclose", this.#onFindBarClose.bind(this)); + } + + get highlightMatches() { + return this._highlightMatches; + } + + get pageMatches() { + return this._pageMatches; + } + + get pageMatchesLength() { + return this._pageMatchesLength; + } + + get selected() { + return this._selected; + } + + get state() { + return this._state; + } + + /** + * Set a reference to the PDF document in order to search it. + * Note that searching is not possible if this method is not called. + * + * @param {PDFDocumentProxy} pdfDocument - The PDF document to search. + */ + setDocument(pdfDocument) { + if (this._pdfDocument) { + this._reset(); + } + if (!pdfDocument) { + return; + } + this._pdfDocument = pdfDocument; + this._firstPageCapability.resolve(); + } + + find(state) { + if (!state) { + return; + } + const pdfDocument = this._pdfDocument; + const { type } = state; + + if (this._state === null || this._shouldDirtyMatch(state)) { + this._dirtyMatch = true; + } + this._state = state; + if (type !== "highlightallchange") { + this._updateUIState(FindState.PENDING); + } + + this._firstPageCapability.promise.then(() => { + // If the document was closed before searching began, or if the search + // operation was relevant for a previously opened document, do nothing. + if ( + !this._pdfDocument || + (pdfDocument && this._pdfDocument !== pdfDocument) + ) { + return; + } + this._extractText(); + + const findbarClosed = !this._highlightMatches; + const pendingTimeout = !!this._findTimeout; + + if (this._findTimeout) { + clearTimeout(this._findTimeout); + this._findTimeout = null; + } + if (!type) { + // Trigger the find action with a small delay to avoid starting the + // search when the user is still typing (saving resources). + this._findTimeout = setTimeout(() => { + this._nextMatch(); + this._findTimeout = null; + }, FIND_TIMEOUT); + } + else if (this._dirtyMatch) { + // Immediately trigger searching for non-'find' operations, when the + // current state needs to be reset and matches re-calculated. + this._nextMatch(); + } + else if (type === "again") { + this._nextMatch(); + } + else if (type === "highlightallchange") { + // If there was a pending search operation, synchronously trigger a new + // search *first* to ensure that the correct matches are highlighted. + if (pendingTimeout) { + this._nextMatch(); + } + else { + this._highlightMatches = true; + } + } + else { + this._nextMatch(); + } + }); + } + + _reset() { + this._highlightMatches = false; + this._scrollMatches = false; + this._pdfDocument = null; + this._pageMatches = []; + this._pageMatchesLength = []; + this._pageMatchesPosition = []; + this._pageChars = []; + this._pageText = []; + this._visitedPagesCount = 0; + this._state = null; + // Currently selected match. + this._selected = { + pageIdx: -1, + matchIdx: -1, + }; + // Where the find algorithm currently is in the document. + this._offset = { + pageIdx: null, + matchIdx: null, + wrapped: false, + }; + this._extractTextPromises = []; + this._pageContents = []; // Stores the normalized text for each page. + this._pageDiffs = []; + this._hasDiacritics = []; + this._matchesCountTotal = 0; + this._pagesToSearch = null; + this._pendingFindMatches = new Set(); + this._resumePageIdx = null; + this._dirtyMatch = false; + clearTimeout(this._findTimeout); + this._findTimeout = null; + + this._firstPageCapability = Promise.withResolvers(); + } + + /** + * @type {string|Array} The (current) normalized search query. + */ + get _query() { + const { query } = this._state; + if (typeof query === "string") { + if (query !== this._rawQuery) { + this._rawQuery = query; + [this._normalizedQuery] = normalize(query); + } + return this._normalizedQuery; + } + // We don't bother caching the normalized search query in the Array-case, + // since this code-path is *essentially* unused in the default viewer. + return (query || []).filter(q => !!q).map(q => normalize(q)[0]); + } + + _shouldDirtyMatch(state) { + // When the search query changes, regardless of the actual search command + // used, always re-calculate matches to avoid errors (fixes bug 1030622). + const newQuery = state.query, + prevQuery = this._state.query; + const newType = typeof newQuery, + prevType = typeof prevQuery; + + if (newType !== prevType) { + return true; + } + if (newType === "string") { + if (newQuery !== prevQuery) { + return true; + } + } + else if ( + /* isArray && */ JSON.stringify(newQuery) !== JSON.stringify(prevQuery) + ) { + return true; + } + + switch (state.type) { + case "again": + const pageNumber = this._selected.pageIdx + 1; + const linkService = this._linkService; + // Only treat a 'findagain' event as a new search operation when it's + // *absolutely* certain that the currently selected match is no longer + // visible, e.g. as a result of the user scrolling in the document. + // + // NOTE: If only a simple `this._linkService.page` check was used here, + // there's a risk that consecutive 'findagain' operations could "skip" + // over matches at the top/bottom of pages thus making them completely + // inaccessible when there's multiple pages visible in the viewer. + return ( + pageNumber >= 1 && + pageNumber <= linkService.pagesCount && + pageNumber !== linkService.page && + !(this.onIsPageVisible?.(pageNumber) ?? true) + ); + case "highlightallchange": + return false; + } + return true; + } + + /** + * Determine if the search query constitutes a "whole word", by comparing the + * first/last character type with the preceding/following character type. + */ + _isEntireWord(content, startIdx, length) { + let match = content.slice(0, startIdx).match(NOT_DIACRITIC_FROM_END_REG_EXP); + if (match) { + const first = content.charCodeAt(startIdx); + const limit = match[1].charCodeAt(0); + if (getCharacterType(first) === getCharacterType(limit)) { + return false; + } + } + + match = content.slice(startIdx + length).match(NOT_DIACRITIC_FROM_START_REG_EXP); + if (match) { + const last = content.charCodeAt(startIdx + length - 1); + const limit = match[1].charCodeAt(0); + if (getCharacterType(last) === getCharacterType(limit)) { + return false; + } + } + + return true; + } + + _calculateRegExpMatch(query, entireWord, pageIndex, pageContent) { + const matches = (this._pageMatches[pageIndex] = []); + const matchesLength = (this._pageMatchesLength[pageIndex] = []); + const matchesPosition = (this._pageMatchesPosition[pageIndex] = []); + if (!query) { + // The query can be empty because some chars like diacritics could have + // been stripped out. + return; + } + const diffs = this._pageDiffs[pageIndex]; + let match; + while ((match = query.exec(pageContent)) !== null) { + if ( + entireWord && + !this._isEntireWord(pageContent, match.index, match[0].length) + ) { + continue; + } + + let [matchPos, matchLen] = getOriginalIndex( + diffs, + match.index, + match[0].length + ); + + if (matchLen) { + let chars = this._pageChars[pageIndex]; + let start = null; + let end = null; + let total = 0; + for (let i = 0; i < chars.length; i++) { + let char = chars[i]; + total++; + // For unknown reason char.u can sometimes have decomposed ligatures instead of + // single ligature character + total += char.u.length - 1; + if (char.spaceAfter || char.lineBreakAfter || char.paragraphBreakAfter) { + total++; + } + if (total >= matchPos && start === null) { + start = i + 1; + } + if (total >= matchPos + matchLen) { + end = i; + break; + } + } + let rects = getRangeRects(chars, start, end); + let position = { pageIndex, rects }; + matches.push(start); + matchesLength.push(end - start); + matchesPosition.push(position); + } + } + } + + _convertToRegExpString(query, hasDiacritics) { + const { matchDiacritics } = this._state; + let isUnicode = false; + query = query.replaceAll( + SPECIAL_CHARS_REG_EXP, + ( + match, + p1 /* to escape */, + p2 /* punctuation */, + p3 /* whitespaces */, + p4 /* diacritics */, + p5 /* letters */ + ) => { + // We don't need to use a \s for whitespaces since all the different + // kind of whitespaces are replaced by a single " ". + + if (p1) { + // Escape characters like *+?... to not interfer with regexp syntax. + return `[ ]*\\${p1}[ ]*`; + } + if (p2) { + // Allow whitespaces around punctuation signs. + return `[ ]*${p2}[ ]*`; + } + if (p3) { + // Replace spaces by \s+ to be sure to match any spaces. + return "[ ]+"; + } + if (matchDiacritics) { + return p4 || p5; + } + + if (p4) { + // Diacritics are removed with few exceptions. + return DIACRITICS_EXCEPTION.has(p4.charCodeAt(0)) ? p4 : ""; + } + + // A letter has been matched and it can be followed by any diacritics + // in normalized text. + if (hasDiacritics) { + isUnicode = true; + return `${p5}\\p{M}*`; + } + return p5; + } + ); + + const trailingSpaces = "[ ]*"; + if (query.endsWith(trailingSpaces)) { + // The [ ]* has been added in order to help to match "foo . bar" but + // it doesn't make sense to match some whitespaces after the dot + // when it's the last character. + query = query.slice(0, query.length - trailingSpaces.length); + } + + if (matchDiacritics) { + // aX must not match aXY. + if (hasDiacritics) { + DIACRITICS_EXCEPTION_STR ||= String.fromCharCode( + ...DIACRITICS_EXCEPTION + ); + + isUnicode = true; + query = `${query}(?=[${DIACRITICS_EXCEPTION_STR}]|[^\\p{M}]|$)`; + } + } + + return [isUnicode, query]; + } + + _calculateMatch(pageIndex) { + let query = this._query; + if (query.length === 0) { + return; // Do nothing: the matches should be wiped out already. + } + const { caseSensitive, entireWord } = this._state; + const pageContent = this._pageContents[pageIndex]; + const hasDiacritics = this._hasDiacritics[pageIndex]; + + let isUnicode = false; + if (typeof query === "string") { + [isUnicode, query] = this._convertToRegExpString(query, hasDiacritics); + } + else { + // Words are sorted in reverse order to be sure that "foobar" is matched + // before "foo" in case the query is "foobar foo". + query = query.sort().reverse().map(q => { + const [isUnicodePart, queryPart] = this._convertToRegExpString( + q, + hasDiacritics + ); + isUnicode ||= isUnicodePart; + return `(${queryPart})`; + }).join("|"); + } + + const flags = `g${isUnicode ? "u" : ""}${caseSensitive ? "" : "i"}`; + query = query ? new RegExp(query, flags) : null; + + this._calculateRegExpMatch(query, entireWord, pageIndex, pageContent); + + if (this._resumePageIdx === pageIndex) { + this._resumePageIdx = null; + this._nextPageMatch(); + } + + // Update the match count. + const pageMatchesCount = this._pageMatches[pageIndex].length; + this._matchesCountTotal += pageMatchesCount; + if (pageMatchesCount > 0) { + this._onUpdateMatches({ + matchesCount: this._requestMatchesCount(), + }); + } + } + + _extractText() { + // Perform text extraction once if this method is called multiple times. + if (this._extractTextPromises.length > 0) { + return; + } + + for (let i = 0, ii = this._linkService.pagesCount; i < ii; i++) { + const { promise, resolve } = Promise.withResolvers(); + this._extractTextPromises[i] = promise; + + (async () => { + + let text = ''; + let chars = []; + + try { + let pageData = await this._pdfDocument.getPageData({ pageIndex: i }); + + function getTextFromChars(chars) { + let text = []; + for (let char of chars) { + text.push(char.u) + if (char.spaceAfter || char.lineBreakAfter || char.paragraphBreakAfter) { + text.push(' '); + } + } + return text.join('').trim(); + } + + chars = pageData.chars; + text = getTextFromChars(pageData.chars); + } catch (e) { + console.log(e); + } + + this._pageChars[i] = chars; + this._pageText[i] = text; + + [ + this._pageContents[i], + this._pageDiffs[i], + this._hasDiacritics[i], + ] = normalize(text); + + resolve(); + })(); + } + } + + _nextMatch() { + const previous = this._state.findPrevious; + const currentPageIndex = this._linkService.page - 1; + const numPages = this._linkService.pagesCount; + + this._highlightMatches = true; + + if (this._dirtyMatch) { + // Need to recalculate the matches, reset everything. + this._dirtyMatch = false; + this._selected.pageIdx = this._selected.matchIdx = -1; + this._offset.pageIdx = currentPageIndex; + this._offset.matchIdx = null; + this._offset.wrapped = false; + this._resumePageIdx = null; + this._pageMatches.length = 0; + this._pageMatchesLength.length = 0; + this._visitedPagesCount = 0; + this._matchesCountTotal = 0; + + for (let i = 0; i < numPages; i++) { + // Start finding the matches as soon as the text is extracted. + if (this._pendingFindMatches.has(i)) { + continue; + } + this._pendingFindMatches.add(i); + this._extractTextPromises[i].then(() => { + this._pendingFindMatches.delete(i); + this._calculateMatch(i); + }); + } + } + + // If there's no query there's no point in searching. + const query = this._query; + if (query.length === 0) { + this._updateUIState(FindState.FOUND); + return; + } + // If we're waiting on a page, we return since we can't do anything else. + if (this._resumePageIdx) { + return; + } + + const offset = this._offset; + // Keep track of how many pages we should maximally iterate through. + this._pagesToSearch = numPages; + // If there's already a `matchIdx` that means we are iterating through a + // page's matches. + if (offset.matchIdx !== null) { + const numPageMatches = this._pageMatches[offset.pageIdx].length; + if ( + (!previous && offset.matchIdx + 1 < numPageMatches) || + (previous && offset.matchIdx > 0) + ) { + // The simple case; we just have advance the matchIdx to select + // the next match on the page. + offset.matchIdx = previous ? offset.matchIdx - 1 : offset.matchIdx + 1; + this._updateMatch(/* found = */ true); + return; + } + // We went beyond the current page's matches, so we advance to + // the next page. + this._advanceOffsetPage(previous); + } + // Start searching through the page. + this._nextPageMatch(); + } + + _matchesReady(matches) { + const offset = this._offset; + const numMatches = matches.length; + const previous = this._state.findPrevious; + + if (numMatches) { + // There were matches for the page, so initialize `matchIdx`. + offset.matchIdx = previous ? numMatches - 1 : 0; + this._updateMatch(/* found = */ true); + return true; + } + // No matches, so attempt to search the next page. + this._advanceOffsetPage(previous); + if (offset.wrapped) { + offset.matchIdx = null; + if (this._pagesToSearch < 0) { + // No point in wrapping again, there were no matches. + this._updateMatch(/* found = */ false); + // While matches were not found, searching for a page + // with matches should nevertheless halt. + return true; + } + } + // Matches were not found (and searching is not done). + return false; + } + + _nextPageMatch() { + if (this._resumePageIdx !== null) { + console.error("There can only be one pending page."); + } + + let matches = null; + do { + const pageIdx = this._offset.pageIdx; + matches = this._pageMatches[pageIdx]; + if (!matches) { + // The matches don't exist yet for processing by `_matchesReady`, + // so set a resume point for when they do exist. + this._resumePageIdx = pageIdx; + break; + } + } while (!this._matchesReady(matches)); + } + + _advanceOffsetPage(previous) { + const offset = this._offset; + const numPages = this._linkService.pagesCount; + offset.pageIdx = previous ? offset.pageIdx - 1 : offset.pageIdx + 1; + offset.matchIdx = null; + + this._pagesToSearch--; + + if (offset.pageIdx >= numPages || offset.pageIdx < 0) { + offset.pageIdx = previous ? numPages - 1 : 0; + offset.wrapped = true; + } + } + + _updateMatch(found = false) { + let state = FindState.NOT_FOUND; + const wrapped = this._offset.wrapped; + this._offset.wrapped = false; + + if (found) { + const previousPage = this._selected.pageIdx; + this._selected.pageIdx = this._offset.pageIdx; + this._selected.matchIdx = this._offset.matchIdx; + state = wrapped ? FindState.WRAPPED : FindState.FOUND; + } + + this._updateUIState(state, this._state.findPrevious); + if (this._selected.pageIdx !== -1) { + this._onNavigate(this._pageMatchesPosition[this._selected.pageIdx][this._selected.matchIdx]); + } + } + + onClose() { + const pdfDocument = this._pdfDocument; + // Since searching is asynchronous, ensure that the removal of highlighted + // matches (from the UI) is async too such that the 'updatetextlayermatches' + // events will always be dispatched in the expected order. + this._firstPageCapability.promise.then(() => { + // Only update the UI if the document is open, and is the current one. + if ( + !this._pdfDocument || + (pdfDocument && this._pdfDocument !== pdfDocument) + ) { + return; + } + // Ensure that a pending, not yet started, search operation is aborted. + if (this._findTimeout) { + clearTimeout(this._findTimeout); + this._findTimeout = null; + } + // Abort any long running searches, to avoid a match being scrolled into + // view *after* the findbar has been closed. In this case `this._offset` + // will most likely differ from `this._selected`, hence we also ensure + // that any new search operation will always start with a clean slate. + if (this._resumePageIdx) { + this._resumePageIdx = null; + this._dirtyMatch = true; + } + + this._highlightMatches = false; + + // Avoid the UI being in a pending state when the findbar is re-opened. + this._updateUIState(FindState.FOUND); + }); + } + + _requestMatchesCount() { + const { pageIdx, matchIdx } = this._selected; + let current = 0, + total = this._matchesCountTotal; + if (matchIdx !== -1) { + for (let i = 0; i < pageIdx; i++) { + current += this._pageMatches[i]?.length || 0; + } + current += matchIdx + 1; + } + // When searching starts, this method may be called before the `pageMatches` + // have been counted (in `_calculateMatch`). Ensure that the UI won't show + // temporarily broken state when the active find result doesn't make sense. + if (current < 1 || current > total) { + current = total = 0; + } + + let currentOffsetStart = -1; + let currentOffsetEnd = -1; + let currentPageIndex = -1; + + if (total) { + if (this._pageMatches[pageIdx]) { + currentOffsetStart = this._pageMatches[pageIdx][matchIdx]; + currentOffsetEnd = currentOffsetStart + this._pageMatchesLength[pageIdx][matchIdx]; + currentPageIndex = pageIdx; + } + } + + return { current, total, currentPageIndex, currentOffsetStart, currentOffsetEnd }; + } + + _updateUIState(state, previous = false) { + this._onUpdateState({ + state, + previous, + entireWord: this._state?.entireWord ?? null, + matchesCount: this._requestMatchesCount(), + rawQuery: this._state?.query ?? null, + }); + } +} + +export { FindState, PDFFindController }; diff --git a/src/pdf/pdf-view.js b/src/pdf/pdf-view.js index e15ee5a0..390de8d1 100644 --- a/src/pdf/pdf-view.js +++ b/src/pdf/pdf-view.js @@ -1,5 +1,5 @@ import Page from './page'; -import { v2p } from './lib/coordinates'; +import { p2v, v2p } from './lib/coordinates'; import { getLineSelectionRanges, getModifiedSelectionRanges, @@ -30,21 +30,28 @@ import { getTransformFromRects, getRotationDegrees, normalizeDegrees, - getRectsAreaSize + getRectsAreaSize, + getClosestObject } from './lib/utilities'; -import { debounceUntilScrollFinishes, normalizeKey } from '../common/lib/utilities'; import { + debounceUntilScrollFinishes, + getCodeCombination, + getKeyCombination, getAffectedAnnotations, - isFirefox, isMac, + isLinux, + isWin, + isFirefox, isSafari, - pressedNextKey, - pressedPreviousKey, throttle } from '../common/lib/utilities'; import { AutoScroll } from './lib/auto-scroll'; import { PDFThumbnails } from './pdf-thumbnails'; -import { DEFAULT_TEXT_ANNOTATION_FONT_SIZE, MIN_IMAGE_ANNOTATION_SIZE, PDF_NOTE_DIMENSIONS } from '../common/defines'; +import { + DEFAULT_TEXT_ANNOTATION_FONT_SIZE, + MIN_IMAGE_ANNOTATION_SIZE, + PDF_NOTE_DIMENSIONS +} from '../common/defines'; import PDFRenderer from './pdf-renderer'; import { drawAnnotationsOnCanvas } from './lib/render'; import PopupDelayer from '../common/lib/popup-delayer'; @@ -55,6 +62,7 @@ import { smoothPath } from './lib/path'; import { History } from '../common/lib/history'; +import { FindState, PDFFindController } from './pdf-find-controller'; class PDFView { constructor(options) { @@ -265,8 +273,47 @@ class PDFView { await this._iframeWindow.PDFViewerApplication.initializedPromise; this._iframeWindow.PDFViewerApplication.eventBus.on('documentinit', this._handleDocumentInit.bind(this)); - this._iframeWindow.PDFViewerApplication.eventBus.on('updatefindmatchescount', this._updateFindMatchesCount.bind(this)); - this._iframeWindow.PDFViewerApplication.eventBus.on('updatefindcontrolstate', this._updateFindControlState.bind(this)); + + this._findController = new PDFFindController({ + linkService: this._iframeWindow.PDFViewerApplication.pdfViewer.linkService, + onNavigate: (position) => { + this.navigateToPosition(position); + }, + onUpdateMatches: ({ matchesCount }) => { + let result = { total: matchesCount.total, index: matchesCount.current - 1 }; + if (matchesCount.current) { + let selectionRanges = getSelectionRanges( + this._pdfPages, + { pageIndex: matchesCount.currentPageIndex, offset: matchesCount.currentOffsetStart }, + { pageIndex: matchesCount.currentPageIndex, offset: matchesCount.currentOffsetEnd + 1 } + ); + result.annotation = this._getAnnotationFromSelectionRanges(selectionRanges, 'highlight'); + } + if (this._pdfjsFindState === FindState.PENDING) { + result = null; + } + this._onSetFindState({ ...this._findState, result }); + this._render(); + }, + onUpdateState: async ({ matchesCount, state, rawQuery }) => { + this._pdfjsFindState = state; + let result = { total: matchesCount.total, index: matchesCount.current - 1 }; + if (matchesCount.current) { + await this._ensureBasicPageData(matchesCount.currentPageIndex); + let selectionRanges = getSelectionRanges( + this._pdfPages, + { pageIndex: matchesCount.currentPageIndex, offset: matchesCount.currentOffsetStart }, + { pageIndex: matchesCount.currentPageIndex, offset: matchesCount.currentOffsetEnd + 1 } + ); + result.annotation = this._getAnnotationFromSelectionRanges(selectionRanges, 'highlight'); + } + if (this._pdfjsFindState === FindState.PENDING || !rawQuery.length) { + result = null; + } + this._onSetFindState({ ...this._findState, result }); + this._render(); + } + }); } async _init2() { @@ -297,24 +344,9 @@ class PDFView { if (this._location) { this.navigate(this._location); } - await this._initProcessedData(); - } - - _updateFindMatchesCount({ matchesCount }) { - let result = { total: matchesCount.total, index: matchesCount.current - 1 }; - if (this._pdfjsFindState === 3) { - result = null; - } - this._onSetFindState({ ...this._findState, result }); - } - _updateFindControlState({ matchesCount, state, rawQuery }) { - this._pdfjsFindState = state; - let result = { total: matchesCount.total, index: matchesCount.current - 1 }; - if (this._pdfjsFindState === 3 || !rawQuery.length) { - result = null; - } - this._onSetFindState({ ...this._findState, result }); + await this._initProcessedData(); + this._findController.setDocument(this._iframeWindow.PDFViewerApplication.pdfDocument); } async _setState(state, skipScroll) { @@ -477,36 +509,91 @@ class PDFView { this._render(); } - _focusNext(reverse) { - let objects = [...this._annotations]; - if (this._focusedObject) { - if (reverse) { - objects.reverse(); + _focusNext(side) { + let visiblePages = this._iframeWindow.PDFViewerApplication.pdfViewer._getVisiblePages(); + let visibleObjects = []; + + let scrollY = this._iframeWindow.PDFViewerApplication.pdfViewer.scroll.lastY; + let scrollX = this._iframeWindow.PDFViewerApplication.pdfViewer.scroll.lastX; + for (let view of visiblePages.views) { + let visibleRect = [ + scrollX, + scrollY, + scrollX + this._iframeWindow.innerWidth, + scrollY + this._iframeWindow.innerHeight, + ]; + + let pageIndex = view.id - 1; + + let overlays = []; + let pdfPage = this._pdfPages[pageIndex]; + if (pdfPage) { + overlays = pdfPage.overlays; } - let index = objects.findIndex(x => x === this._focusedObject); - if (index === -1) { + let objects = []; + for (let annotation of this._annotations) { + if (annotation.position.pageIndex === pageIndex + || annotation.position.nextPageRects && annotation.position.pageIndex + 1 === pageIndex) { + objects.push({ type: 'annotation', object: annotation }); + } } - if (index < objects.length - 1) { - this._focusedObject = objects[index + 1]; - this.navigateToPosition(this._focusedObject.position); + + for (let overlay of overlays) { + objects.push({ type: 'overlay', object: overlay }); } - } - else { - let pageIndex = this._iframeWindow.PDFViewerApplication.pdfViewer.currentPageNumber - 1; - let pageObjects = objects.filter(x => x.position.pageIndex === pageIndex); - if (pageObjects.length) { - this._focusedObject = pageObjects[0]; - this.navigateToPosition(this._focusedObject.position); + + for (let object of objects) { + let p = p2v(object.object.position, view.view.viewport, pageIndex); + let br = getPositionBoundingRect(p, pageIndex); + let absoluteRect = [ + view.x + br[0], + view.y + br[1], + view.x + br[2], + view.y + br[3], + ]; + + object.rect = absoluteRect; + object.pageIndex = pageIndex; + + if (quickIntersectRect(absoluteRect, visibleRect)) { + visibleObjects.push(object); + } } } - this._onFocusAnnotation(this._focusedObject); - this._lastFocusedObject = this._focusedObject; + let nextObject; - this._render(); + let focusedObject; + if (this._focusedObject) { + for (let visibleObject of visibleObjects) { + if (visibleObject.object === this._focusedObject.object + && visibleObject.pageIndex === this._focusedObject.pageIndex) { + focusedObject = visibleObject; + } + } + } + + if (focusedObject && side) { + let otherObjects = visibleObjects.filter(x => x !== focusedObject); + nextObject = getClosestObject(focusedObject.rect, otherObjects, side); + } + else { + let cornerPointRect = [scrollX, scrollY, scrollX, scrollY]; + nextObject = getClosestObject(cornerPointRect, visibleObjects); + } + if (nextObject) { + this._focusedObject = nextObject; + this._onFocusAnnotation(nextObject.object); + this._lastFocusedObject = this._focusedObject; + this._render(); + if (this._selectedOverlay) { + this._selectedOverlay = null; + this._onSetOverlayPopup(null); + } + } return !!this._focusedObject; } @@ -590,8 +677,26 @@ class PDFView { setAnnotations(annotations) { let affected = getAffectedAnnotations(this._annotations, annotations, true); - this._annotations = annotations; let { created, updated, deleted } = affected; + this._annotations = annotations; + if (this._focusedObject?.type === 'annotation') { + if (updated.find(x => x.id === this._focusedObject.object.id)) { + this._focusedObject.object = updated.find(x => x.id === this._focusedObject.object.id); + } + else if (deleted.find(x => x.id === this._focusedObject.object.id)) { + this._focusedObject = null; + } + } + + if (this._lastFocusedObject?.type === 'annotation') { + if (updated.find(x => x.id === this._lastFocusedObject.object.id)) { + this._lastFocusedObject.object = updated.find(x => x.id === this._lastFocusedObject.object.id); + } + else if (deleted.find(x => x.id === this._lastFocusedObject.object.id)) { + this._lastFocusedObject = null; + } + } + let all = [...created, ...updated, ...deleted]; let pageIndexes = getPageIndexesFromAnnotations(all); this._render(pageIndexes); @@ -635,7 +740,7 @@ class PDFView { setFindState(state) { if (!state.active && this._findState.active !== state.active) { - this._iframeWindow.PDFViewerApplication.eventBus.dispatch('findbarclose', { source: this._iframeWindow }); + this._findController.onClose(); } if (state.active) { @@ -647,8 +752,8 @@ class PDFView { // Immediately update find state because pdf.js find will trigger _updateFindMatchesCount // and _updateFindControlState that update the current find state this._findState = state; - this._iframeWindow.PDFViewerApplication.eventBus.dispatch('find', { - source: this._iframeWindow, + + this._findController.find({ type: 'find', query: state.query, phraseSearch: true, @@ -665,8 +770,7 @@ class PDFView { } findNext() { - this._iframeWindow.PDFViewerApplication.eventBus.dispatch('find', { - source: this._iframeWindow, + this._findController.find({ type: 'again', query: this._findState.query, phraseSearch: true, @@ -678,7 +782,7 @@ class PDFView { } findPrevious() { - this._iframeWindow.PDFViewerApplication.eventBus.dispatch('find', { + this._findController.find({ source: this._iframeWindow, type: 'again', query: this._findState.query, @@ -693,7 +797,7 @@ class PDFView { setSelectedAnnotationIDs(ids) { this._selectedAnnotationIDs = ids; this._setSelectionRanges(); - this._clearFocus(); + // this._clearFocus(); this._render(); @@ -1548,6 +1652,8 @@ class PDFView { return; } + this._clearFocus(); + let shift = event.shiftKey; let position = this.pointerEventToPosition(event); @@ -2393,14 +2499,10 @@ class PDFView { if (this.textAnnotationFocused()) { return; } - let { key, code } = event; - let ctrl = event.ctrlKey; - let cmd = event.metaKey && isMac(); - let mod = ctrl || cmd; let alt = event.altKey; - let shift = event.shiftKey; - key = normalizeKey(key, code); + let key = getKeyCombination(event); + let code = getCodeCombination(event); if (event.target.classList.contains('textAnnotation')) { return; @@ -2413,76 +2515,447 @@ class PDFView { setTextLayerSelection(this._iframeWindow, this._selectionRanges); } } - // Prevent "open file", "download file" PDF.js keyboard shortcuts // https://github.com/mozilla/pdf.js/wiki/Frequently-Asked-Questions#faq-shortcuts - if (mod && ['o', 's'].includes(key)) { + if (['Cmd-o', 'Ctrl-o', 'Cmd-s', 'Ctrl-s'].includes(key)) { event.stopPropagation(); event.preventDefault(); } // Prevent full screen - else if (mod && alt && key === 'p') { + else if (['Ctrl-Alt-p', 'Ctrl-Alt-p'].includes(key)) { event.stopPropagation(); } // Prevent PDF.js page view rotation - else if (key.toLowerCase() === 'r') { + else if (key === 'r') { event.stopPropagation(); } - else if (['n', 'j', 'p', 'k'].includes(key.toLowerCase())) { + else if (['n', 'j', 'p', 'k'].includes(key)) { event.stopPropagation(); } // This is necessary when a page is zoomed in and left/right arrow keys can't change page - else if (alt && key === 'ArrowUp') { + else if (['Alt-ArrowUp'].includes(key)) { this.navigateToPreviousPage(); event.stopPropagation(); event.preventDefault(); } - else if (alt && key === 'ArrowDown') { + else if (['Alt-ArrowDown'].includes(key)) { this.navigateToNextPage(); event.stopPropagation(); event.preventDefault(); } - else if (shift && this._selectionRanges.length) { + else if (key.startsWith('Shift') && this._selectionRanges.length) { // Prevent browser doing its own text selection event.stopPropagation(); event.preventDefault(); - if (key === 'ArrowLeft') { + if (key === 'Shift-ArrowLeft') { this._setSelectionRanges(getModifiedSelectionRanges(this._pdfPages, this._selectionRanges, 'left')); } - else if (key === 'ArrowRight') { + else if (key === 'Shift-ArrowRight') { this._setSelectionRanges(getModifiedSelectionRanges(this._pdfPages, this._selectionRanges, 'right')); } - else if (key === 'ArrowUp') { + else if (key === 'Shift-ArrowUp') { this._setSelectionRanges(getModifiedSelectionRanges(this._pdfPages, this._selectionRanges, 'up')); } - else if (key === 'ArrowDown') { + else if (key === 'Shift-ArrowDown') { this._setSelectionRanges(getModifiedSelectionRanges(this._pdfPages, this._selectionRanges, 'down')); } this._render(); } + else if ( + !this._readOnly + && this._selectedAnnotationIDs.length === 1 + && !this._annotations.find(x => x.id === this._selectedAnnotationIDs[0])?.readOnly + ) { + let annotation = this._annotations.find(x => x.id === this._selectedAnnotationIDs[0]); + let modified = false; + + let { id, type, position } = annotation; + const STEP = 5; // pt + const PADDING = 5; + let viewBox = this._pdfPages[position.pageIndex].viewBox; + + if ( + ['note', 'text', 'image', 'ink'].includes(type) + && ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(key) + ) { + let rect; + if (annotation.type === 'ink') { + rect = getPositionBoundingRect(position); + } + else { + rect = position.rects[0].slice(); + } + let dx = 0; + let dy = 0; + if (key === 'ArrowLeft' && rect[0] >= STEP + PADDING) { + dx = -STEP; + } + else if (key === 'ArrowRight' && rect[2] <= viewBox[2] - STEP - PADDING) { + dx = STEP; + } + else if (key === 'ArrowDown' && rect[1] >= STEP + PADDING) { + dy = -STEP; + } + else if (key === 'ArrowUp' && rect[3] <= viewBox[3] - STEP - PADDING) { + dy = STEP; + } + if (dx || dy) { + position = JSON.parse(JSON.stringify(position)); + if (annotation.type === 'ink') { + let m = [1, 0, 0, 1, dx, dy]; + position = applyTransformationMatrixToInkPosition(m, position); + } + else { + rect[0] += dx; + rect[1] += dy; + rect[2] += dx; + rect[3] += dy; + position = { ...position, rects: [rect] }; + } + let sortIndex = getSortIndex(this._pdfPages, position); + this._onUpdateAnnotations([{ id, position, sortIndex }]); + this._render(); + } + event.stopPropagation(); + event.preventDefault(); + } + else if (['highlight', 'underline'].includes(type) + && ['Shift-ArrowLeft', 'Shift-ArrowRight', 'Shift-ArrowUp', 'Shift-ArrowDown'].includes(key)) { + let selectionRanges = getSelectionRangesByPosition(this._pdfPages, annotation.position); + if (key === 'Shift-ArrowLeft') { + selectionRanges = getModifiedSelectionRanges(this._pdfPages, selectionRanges, 'left'); + } + else if (key === 'Shift-ArrowRight') { + selectionRanges = getModifiedSelectionRanges(this._pdfPages, selectionRanges, 'right'); + } + else if (key === 'Shift-ArrowUp') { + selectionRanges = getModifiedSelectionRanges(this._pdfPages, selectionRanges, 'up'); + } + else if (key === 'Shift-ArrowDown') { + selectionRanges = getModifiedSelectionRanges(this._pdfPages, selectionRanges, 'down'); + } + + if (!(selectionRanges.length === 1 + && selectionRanges[0].anchorOffset >= selectionRanges[0].headOffset)) { + let annotation2 = this._getAnnotationFromSelectionRanges(selectionRanges, 'highlight'); + let { text, sortIndex, position } = annotation2; + this._onUpdateAnnotations([{ id, text, sortIndex, position }]); + } + event.stopPropagation(); + event.preventDefault(); + } + else if (['highlight', 'underline'].includes(type) + && ( + isMac() && ['Cmd-Shift-ArrowLeft', 'Cmd-Shift-ArrowRight', 'Cmd-Shift-ArrowUp', 'Cmd-Shift-ArrowDown'].includes(key) + || (isWin() || isLinux()) && ['Alt-Shift-ArrowLeft', 'Alt-Shift-ArrowRight', 'Alt-Shift-ArrowUp', 'Alt-Shift-ArrowDown'].includes(key) + )) { + let selectionRanges = getSelectionRangesByPosition(this._pdfPages, annotation.position); + selectionRanges = getReversedSelectionRanges(selectionRanges); + if ( + isMac() && key === 'Cmd-Shift-ArrowLeft' + || (isWin() || isLinux()) && key === 'Alt-Shift-ArrowLeft' + ) { + selectionRanges = getModifiedSelectionRanges(this._pdfPages, selectionRanges, 'left'); + } + else if ( + isMac() && key === 'Cmd-Shift-ArrowRight' + || (isWin() || isLinux()) && key === 'Alt-Shift-ArrowRight' + ) { + selectionRanges = getModifiedSelectionRanges(this._pdfPages, selectionRanges, 'right'); + } + else if ( + isMac() && key === 'Cmd-Shift-ArrowUp' + || (isWin() || isLinux()) && key === 'Alt-Shift-ArrowUp' + ) { + selectionRanges = getModifiedSelectionRanges(this._pdfPages, selectionRanges, 'up'); + } + else if ( + isMac() && key === 'Cmd-Shift-ArrowDown' + || (isWin() || isLinux()) && key === 'Cmd-Shift-ArrowDown' + ) { + selectionRanges = getModifiedSelectionRanges(this._pdfPages, selectionRanges, 'down'); + } + if (!(selectionRanges.length === 1 + && selectionRanges[0].anchorOffset <= selectionRanges[0].headOffset)) { + let annotation2 = this._getAnnotationFromSelectionRanges(selectionRanges, 'highlight'); + let { text, sortIndex, position } = annotation2; + this._onUpdateAnnotations([{ id, text, sortIndex, position }]); + } + event.stopPropagation(); + event.preventDefault(); + } + else if ( + ['text', 'image', 'ink'].includes(type) + && ( + isMac() && ['Shift-ArrowLeft', 'Shift-ArrowRight', 'Shift-ArrowUp', 'Shift-ArrowDown'].includes(key) + || (isWin() || isLinux()) && ['Shift-ArrowLeft', 'Shift-ArrowRight', 'Shift-ArrowUp', 'Shift-ArrowDown'].includes(key) + ) + ) { + if (type === 'ink') { + let rect = getPositionBoundingRect(position); + let r1 = rect.slice(); + let ratio = (rect[2] - rect[0]) / (rect[3] - rect[1]); + let [, y] = rect; + + if (key === 'Shift-ArrowLeft') { + rect[2] -= STEP; + rect[1] += STEP / ratio; + modified = true; + } + else if (key === 'Shift-ArrowRight') { + rect[2] += STEP; + rect[1] -= STEP / ratio; + modified = true; + } + else if (key === 'Shift-ArrowDown') { + modified = true; + y -= STEP; + rect[2] += STEP * ratio; + rect[1] = y; + } + else if (key === 'Shift-ArrowUp') { + y += STEP; + rect[2] -= STEP * ratio; + rect[1] = y; + modified = true; + } + if (modified) { + let r2 = rect; + let mm = getTransformFromRects(r1, r2); + position = applyTransformationMatrixToInkPosition(mm, annotation.position); + } + } + else if (type === 'image') { + let rect = position.rects[0].slice(); + + let [, y, x] = rect; + if (key === 'Shift-ArrowLeft') { + x -= STEP; + rect[2] = x < rect[0] + MIN_IMAGE_ANNOTATION_SIZE && rect[0] + MIN_IMAGE_ANNOTATION_SIZE || x; + modified = true; + } + else if (key === 'Shift-ArrowRight') { + x += STEP; + rect[2] = x < viewBox[2] && x || viewBox[2]; + modified = true; + } + else if (key === 'Shift-ArrowDown') { + y -= STEP; + rect[1] = y > viewBox[1] && y || viewBox[1]; + modified = true; + } + else if (key === 'Shift-ArrowUp') { + y += STEP; + rect[1] = y > rect[3] - MIN_IMAGE_ANNOTATION_SIZE && rect[3] - MIN_IMAGE_ANNOTATION_SIZE || y; + modified = true; + } + + if (modified) { + position = { ...position, rects: [rect] }; + } + } + else if (type === 'text') { + let rect = position.rects[0].slice(); + const MIN_TEXT_ANNOTATION_WIDTH = 10; + let x = rect[2]; + if (key === 'Shift-ArrowLeft') { + x -= STEP; + rect[2] = x < rect[0] + MIN_TEXT_ANNOTATION_WIDTH && rect[0] + MIN_TEXT_ANNOTATION_WIDTH || x; + modified = true; + } + else if (key === 'Shift-ArrowRight') { + x += STEP; + rect[2] = x; + modified = true; + } + + if (modified) { + let r1 = annotation.position.rects[0]; + let r2 = rect; + let m1 = getRotationTransform(r1, annotation.position.rotation); + let m2 = getRotationTransform(r2, annotation.position.rotation); + let mm = getScaleTransform(r1, r2, m1, m2, 'r'); + let mmm = transform(m2, mm); + mmm = inverseTransform(mmm); + r2 = [ + ...applyTransform(r2, m2), + ...applyTransform(r2.slice(2), m2) + ]; + rect = [ + ...applyTransform(r2, mmm), + ...applyTransform(r2.slice(2), mmm) + ]; + + position = { ...position, rects: [rect] }; + + position = measureTextAnnotationDimensions({ + ...annotation, + position + }); + } + } + if (modified) { + let sortIndex = getSortIndex(this._pdfPages, position); + this._onUpdateAnnotations([{ id, position, sortIndex }]); + this._render(); + } + event.stopPropagation(); + event.preventDefault(); + } + } + else if ( + code === 'Ctrl-Alt-Digit1' + && this._selectionRanges.length + && !this._selectionRanges[0].collapsed + && !this._readOnly + ) { + let annotation = this._getAnnotationFromSelectionRanges(this._selectionRanges, 'highlight'); + annotation.sortIndex = getSortIndex(this._pdfPages, annotation.position); + this._onAddAnnotation(annotation, true); + this.navigateToPosition(annotation.position); + this._setSelectionRanges(); + } + else if ( + code === 'Ctrl-Alt-Digit2' + && this._selectionRanges.length + && !this._selectionRanges[0].collapsed + && !this._readOnly + ) { + let annotation = this._getAnnotationFromSelectionRanges(this._selectionRanges, 'underline'); + annotation.sortIndex = getSortIndex(this._pdfPages, annotation.position); + this._onAddAnnotation(annotation, true); + this.navigateToPosition(annotation.position); + this._setSelectionRanges(); + } + else if (code === 'Ctrl-Alt-Digit3' && !this._readOnly) { + + // 1. Add to this annotation to last selected object, to have it after escape + // 2. Errors when writing + + let pageIndex = this._iframeWindow.PDFViewerApplication.pdfViewer.currentPageNumber - 1; + let page = this._iframeWindow.PDFViewerApplication.pdfViewer._pages[pageIndex]; + let viewBox = page.viewport.viewBox; + let cx = (viewBox[0] + viewBox[2]) / 2; + let cy = (viewBox[1] + viewBox[3]) / 2; + let position = { + pageIndex, + rects: [[ + cx - PDF_NOTE_DIMENSIONS / 2, + cy - PDF_NOTE_DIMENSIONS / 2, + cx + PDF_NOTE_DIMENSIONS / 2, + cy + PDF_NOTE_DIMENSIONS / 2 + ]] + }; + let annotation = this._onAddAnnotation({ + type: 'note', + pageLabel: this._getPageLabel(pageIndex, true), + sortIndex: getSortIndex(this._pdfPages, position), + position + }); + if (annotation) { + this.navigateToPosition(position); + this._onSelectAnnotations([annotation.id], event); + this._openAnnotationPopup(); + this._focusedObject = { + type: 'annotation', + object: annotation, + rect: annotation.position.rects[0], + pageIndex: annotation.position.pageIndex + }; + this._render(); + } + } + else if (code === 'Ctrl-Alt-Digit4' && !this._readOnly) { + let pageIndex = this._iframeWindow.PDFViewerApplication.pdfViewer.currentPageNumber - 1; + let page = this._iframeWindow.PDFViewerApplication.pdfViewer._pages[pageIndex]; + let viewBox = page.viewport.viewBox; + let cx = (viewBox[0] + viewBox[2]) / 2; + let cy = (viewBox[1] + viewBox[3]) / 2; + let position = { + pageIndex, + fontSize: DEFAULT_TEXT_ANNOTATION_FONT_SIZE, + rotation: 0, + rects: [[ + cx - DEFAULT_TEXT_ANNOTATION_FONT_SIZE / 2, + cy - DEFAULT_TEXT_ANNOTATION_FONT_SIZE / 2, + cx + DEFAULT_TEXT_ANNOTATION_FONT_SIZE / 2, + cy + DEFAULT_TEXT_ANNOTATION_FONT_SIZE / 2 + ]] + }; + let annotation = this._onAddAnnotation({ + type: 'text', + pageLabel: this._getPageLabel(pageIndex, true), + sortIndex: getSortIndex(this._pdfPages, position), + position + }); + if (annotation) { + this.navigateToPosition(position); + this.setSelectedAnnotationIDs([annotation.id]); + setTimeout(() => { + this._iframeWindow.document.querySelector(`[data-id="${annotation.id}"]`)?.focus(); + }, 100); + } + } + else if (code === 'Ctrl-Alt-Digit5' && !this._readOnly) { + let pageIndex = this._iframeWindow.PDFViewerApplication.pdfViewer.currentPageNumber - 1; + let page = this._iframeWindow.PDFViewerApplication.pdfViewer._pages[pageIndex]; + let viewBox = page.viewport.viewBox; + let cx = (viewBox[0] + viewBox[2]) / 2; + let cy = (viewBox[1] + viewBox[3]) / 2; + let size = MIN_IMAGE_ANNOTATION_SIZE * 4; + let position = { + pageIndex, + rects: [[ + cx - size / 2, + cy - size / 2, + cx + size / 2, + cy + size / 2 + ]] + }; + let annotation = this._onAddAnnotation({ + type: 'image', + pageLabel: this._getPageLabel(pageIndex, true), + sortIndex: getSortIndex(this._pdfPages, position), + position + }, true); + if (annotation) { + this.navigateToPosition(position); + } + } if (key === 'Escape') { - this.action = null; - if (this._selectionRanges.length) { + if (this.action || this.pointerDownPosition || this._selectionRanges.length) { + event.preventDefault(); + this.action = null; + this.pointerDownPosition = null; this._setSelectionRanges(); this._render(); return; } - this.pointerDownPosition = null; - if (this._selectedAnnotationIDs.length) { + else if (this._selectedAnnotationIDs.length) { + event.preventDefault(); this._onSelectAnnotations([], event); if (this._lastFocusedObject) { this._focusedObject = this._lastFocusedObject; this._render(); } + return; + } + else if (this._selectedOverlay) { + this._selectedOverlay = null; + this._onSetOverlayPopup(null); + event.preventDefault(); + return; } else if (this._focusedObject) { + event.preventDefault(); this._clearFocus(); + return; } } - if (shift && key === 'Tab') { + if (key === 'Shift-Tab') { if (this._focusedObject) { this._clearFocus(); } @@ -2498,28 +2971,81 @@ class PDFView { } } else { - this._clearFocus(); + // this._clearFocus(); this._onTabOut(); } event.preventDefault(); } - if (this._focusedObject) { - if (pressedNextKey(event)) { - this._focusNext(); + if (this._focusedObject && !this._selectedAnnotationIDs.length) { + if (key === 'ArrowLeft') { + this._focusNext('left'); event.preventDefault(); + event.stopPropagation(); } - else if (pressedPreviousKey(event)) { - this._focusNext(true); + else if (key === 'ArrowRight') { + this._focusNext('right'); + event.preventDefault(); + event.stopPropagation(); + } + if (key === 'ArrowUp') { + this._focusNext('top'); event.preventDefault(); + event.stopPropagation(); + } + if (key === 'ArrowDown') { + this._focusNext('bottom'); + event.preventDefault(); + event.stopPropagation(); } else if (['Enter', 'Space'].includes(key)) { - if (this._focusedObject.type) { - this._onSelectAnnotations([this._focusedObject.id], event); - this._openAnnotationPopup(); - } - else { + if (this._focusedObject) { + if (this._focusedObject.type === 'annotation') { + this._onSelectAnnotations([this._focusedObject.object.id], event); + this._openAnnotationPopup(); + } + else if (this._focusedObject.type === 'overlay') { + let overlay = this._focusedObject.object; + this._selectedOverlay = overlay; + let rect = this.getClientRect(overlay.position.rects[0], overlay.position.pageIndex); + let overlayPopup = { ...overlay, rect }; + if (overlayPopup.type === 'internal-link') { + (async () => { + let { + image, + width, + height, + x, + y + } = await this._pdfRenderer?.renderPreviewPage(overlay.destinationPosition); + overlayPopup.image = image; + overlayPopup.width = width; + overlayPopup.height = height; + overlayPopup.x = x; + overlayPopup.y = y; + this._onSetOverlayPopup(overlayPopup); + })(); + } + else if (['citation', 'reference'].includes(overlay.type)) { + this._onSetOverlayPopup(overlayPopup); + } + else if (overlay.type === 'external-link') { + this._onOpenLink(overlay.url); + } + } + event.preventDefault(); + event.stopPropagation(); + } + } + } + else if (this._selectedAnnotationIDs.length === 1) { + let annotation = this._annotations.find(x => x.id === this._selectedAnnotationIDs[0]); + if (annotation.type === 'text') { + if (['Enter'].includes(key)) { + setTimeout(() => { + this._iframeWindow.document.querySelector(`[data-id="${annotation.id}"]`)?.focus(); + }, 100); } } }