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 (
+
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);
}
}
}