diff --git a/src/common/lib/utilities.js b/src/common/lib/utilities.js index 09d418de..2850048a 100644 --- a/src/common/lib/utilities.js +++ b/src/common/lib/utilities.js @@ -55,7 +55,11 @@ export function normalizeKey(key, code) { return key; } -// Key combination taking into account layout and modifier keys +/** + * Key combination taking into account layout and modifier keys + * @param {KeyboardEvent} event + * @returns {string} + */ export function getKeyCombination(event) { let modifiers = []; if (event.metaKey && isMac()) { @@ -86,7 +90,11 @@ export function getKeyCombination(event) { return modifiers.join('-'); } -// Physical key combination +/** + * Physical key combination + * @param {KeyboardEvent} event + * @returns {string} + */ export function getCodeCombination(event) { let modifiers = []; if (event.metaKey && isMac()) { diff --git a/src/common/types.ts b/src/common/types.ts index 1172fed7..7aa4c658 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -160,7 +160,8 @@ export type FindState = { total: number, index: number, // Mobile app lists all results in a popup - snippets: string[] + snippets: string[], + annotation?: NewAnnotation } | null; }; diff --git a/src/dom/common/components/overlay/annotation-overlay.tsx b/src/dom/common/components/overlay/annotation-overlay.tsx index 54ccf4ce..75c9c45c 100644 --- a/src/dom/common/components/overlay/annotation-overlay.tsx +++ b/src/dom/common/components/overlay/annotation-overlay.tsx @@ -373,7 +373,13 @@ let HighlightOrUnderline: React.FC = (props) => { // the whole outer containing the underline/highlight (potentially small) and the interactive s // (big) so that we get all the highlighted text to render in the drag image. return <> - + e.preventDefault()} + data-annotation-id={annotation.id} + fill={annotation.color} + ref={outerGroupRef} + > {rectGroup} {foreignObjects} {resizer} @@ -445,6 +451,7 @@ const Note: React.FC = (props) => { opacity={annotation.id ? '100%' : '50%'} selected={selected} large={true} + tabIndex={-1} onPointerDown={onPointerDown && (event => onPointerDown!(annotation, event))} onPointerUp={onPointerUp && (event => onPointerUp!(annotation, event))} onContextMenu={onContextMenu && (event => onContextMenu!(annotation, event))} @@ -767,6 +774,7 @@ let CommentIcon = React.forwardRef((props, ref) width={size} height={size} viewBox="0 0 24 24" + data-annotation-id={props.annotation?.id} ref={ref} > @@ -780,6 +788,8 @@ let CommentIcon = React.forwardRef((props, ref) width={size} height={size} className="needs-pointer-events" + tabIndex={props.tabIndex} + data-annotation-id={props.annotation?.id} >
void; onPointerUp?: (event: React.PointerEvent) => void; onContextMenu?: (event: React.MouseEvent) => void; diff --git a/src/dom/common/dom-view.tsx b/src/dom/common/dom-view.tsx index 3276ce06..f11fd9b2 100644 --- a/src/dom/common/dom-view.tsx +++ b/src/dom/common/dom-view.tsx @@ -17,6 +17,7 @@ import { Platform, SelectionPopupParams, Tool, + ToolType, ViewStats, WADMAnnotation, } from "../../common/types"; @@ -40,10 +41,16 @@ import { import { getSelectionRanges } from "./lib/selection"; import { FindProcessor } from "./lib/find"; import { SELECTION_COLOR } from "../../common/defines"; -import { debounceUntilScrollFinishes, isMac, isSafari } from "../../common/lib/utilities"; +import { + debounceUntilScrollFinishes, + getCodeCombination, + getKeyCombination, + isMac, + isSafari +} from "../../common/lib/utilities"; import { closestElement, - isElement + getContainingBlock, isBlock } from "./lib/nodes"; import { debounce } from "../../common/lib/debounce"; import { @@ -136,6 +143,8 @@ abstract class DOMView { protected _outline!: OutlineItem[]; + protected _lastKeyboardFocusedAnnotationID: string | null = null; + scale = 1; protected constructor(options: DOMViewOptions) { @@ -245,6 +254,12 @@ abstract class DOMView { protected abstract _updateViewStats(): void; + protected _getContainingRoot(node: Node): HTMLElement | null { + return this._iframeDocument.body.contains(node) + ? this._iframeDocument.body + : null; + } + // *** // Utilities - called in appropriate event handlers // *** @@ -303,7 +318,25 @@ abstract class DOMView { if (!selection || selection.isCollapsed) { return null; } - let range = makeRangeSpanning(...getSelectionRanges(selection)); + let range: Range; + if (type === 'highlight' || type === 'underline') { + range = makeRangeSpanning(...getSelectionRanges(selection)); + } + else if (type === 'note') { + let element = closestElement(selection.getRangeAt(0).commonAncestorContainer); + if (!element) { + return null; + } + let blockElement = getContainingBlock(element); + if (!blockElement) { + return null; + } + range = this._iframeDocument.createRange(); + range.selectNode(blockElement); + } + else { + return null; + } return this._getAnnotationFromRange(range, type, color); } @@ -340,6 +373,89 @@ abstract class DOMView { protected _tryUseToolDebounced = debounce(this._tryUseTool.bind(this), 500); + protected _getFocusState() { + let getFocusedElement = () => { + let focusedElement = this._iframeDocument.activeElement as HTMLElement | SVGElement | null; + if (focusedElement === this._annotationShadowRoot.host) { + focusedElement = this._annotationShadowRoot.activeElement as HTMLElement | SVGElement | null; + if (!focusedElement?.matches('[tabindex="-1"]')) { + focusedElement = null; + } + } + else if (!focusedElement?.matches('a, area')) { + focusedElement = null; + } + return focusedElement; + }; + + let getFocusedElementIndex = () => { + return obj.focusedElement ? obj.focusableElements.indexOf(obj.focusedElement) : -1; + }; + + let getFocusableElements = () => { + let focusableElements = [ + ...this._iframeDocument.querySelectorAll('a, area'), + ...this._annotationShadowRoot.querySelectorAll('[tabindex="-1"]') + ] as (HTMLElement | SVGElement)[]; + focusableElements = focusableElements.filter( + el => isPageRectVisible(getBoundingPageRect(el), this._iframeWindow, 0) + ); + focusableElements.sort((a, b) => { + let rangeA; + if (a.getRootNode() === this._annotationShadowRoot && a.hasAttribute('data-annotation-id')) { + rangeA = this.toDisplayedRange(this._annotationsByID.get(a.getAttribute('data-annotation-id')!)!.position); + } + if (!rangeA) { + rangeA = this._iframeDocument.createRange(); + rangeA.selectNode(a); + } + let rangeB; + if (b.getRootNode() === this._annotationShadowRoot && b.hasAttribute('data-annotation-id')) { + rangeB = this.toDisplayedRange(this._annotationsByID.get(b.getAttribute('data-annotation-id')!)!.position); + } + if (!rangeB) { + rangeB = this._iframeDocument.createRange(); + rangeB.selectNode(b); + } + return rangeA.compareBoundaryPoints(Range.START_TO_START, rangeB); + }); + return focusableElements; + }; + + let obj = { + get focusedElement() { + let value = getFocusedElement(); + Object.defineProperty(this, 'focusedElement', { value }); + return value; + }, + + get focusedElementIndex() { + let value = getFocusedElementIndex(); + Object.defineProperty(this, 'focusedElementIndex', { value }); + return value; + }, + + get focusableElements() { + let value = getFocusableElements(); + Object.defineProperty(this, 'focusableElements', { value }); + return value; + }, + }; + + return obj; + } + + protected _setAnnotationRange(annotation: WADMAnnotation, range: Range) { + let newAnnotation = this._getAnnotationFromRange(range, annotation.type); + if (!newAnnotation) { + throw new Error('Invalid updated range'); + } + annotation.position = newAnnotation.position; + annotation.pageLabel = newAnnotation.pageLabel; + annotation.sortIndex = newAnnotation.sortIndex; + annotation.text = newAnnotation.text; + } + protected _handleViewUpdate() { this._updateViewState(); this._updateViewStats(); @@ -674,13 +790,11 @@ abstract class DOMView { else { let pos = supportsCaretPositionFromPoint() && caretPositionFromPoint(this._iframeDocument, event.clientX, event.clientY); - let node = pos ? pos.offsetNode : target; - // Expand to the closest block element - while (node.parentNode - && (!isElement(node) || this._iframeWindow.getComputedStyle(node).display.includes('inline'))) { - node = node.parentNode; - } - range.selectNode(node); + let element = closestElement(pos ? pos.offsetNode : target); + if (!element) return null; + let blockElement = getContainingBlock(element); + if (!blockElement) return null; + range.selectNode(blockElement); } let rect = range.getBoundingClientRect(); if (rect.right <= 0 || rect.left >= this._iframeWindow.innerWidth @@ -707,42 +821,35 @@ abstract class DOMView { protected abstract _handleInternalLinkClick(link: HTMLAnchorElement): void; protected _handleKeyDown(event: KeyboardEvent) { - let { key } = event; - let shift = event.shiftKey; - // To figure out if wheel events are pinch-to-zoom this._isCtrlKeyDown = event.key === 'Control'; - // Focusable elements in PDF view are annotations and overlays (links, citations, figures). - // Once TAB is pressed, arrows can be used to navigate between them - let focusableElements: HTMLElement[] = []; - let focusedElementIndex = -1; - let focusedElement: HTMLElement | null = this._iframeDocument.activeElement as HTMLElement | null; - if (focusedElement?.getAttribute('tabindex') != '-1') { - focusedElement = null; - } - for (let element of this._iframeDocument.querySelectorAll('[tabindex="-1"]')) { - focusableElements.push(element as HTMLElement); - if (element === focusedElement) { - focusedElementIndex = focusableElements.length - 1; - } - } + let key = getKeyCombination(event); + let code = getCodeCombination(event); + + let f = this._getFocusState(); if (key === 'Escape' && !this._resizingAnnotationID) { if (this._selectedAnnotationIDs.length) { this._options.onSelectAnnotations([], event); + if (this._lastKeyboardFocusedAnnotationID) { + (this._annotationRenderRootEl.querySelector( + `[tabindex="-1"][data-annotation-id="${this._lastKeyboardFocusedAnnotationID}"]` + ) as HTMLElement | SVGElement | null) + ?.focus({ preventScroll: true }); + } } - else if (focusedElement) { - focusedElement.blur(); + else if (f.focusedElement) { + f.focusedElement.blur(); } this._iframeWindow.getSelection()?.removeAllRanges(); // The keyboard shortcut was handled here, therefore no need to // pass it to this._onKeyDown(event) below return; } - else if (shift && key === 'Tab') { - if (focusedElement) { - focusedElement.blur(); + else if (key === 'Shift-Tab') { + if (f.focusedElement) { + f.focusedElement.blur(); } else { this._options.onTabOut(true); @@ -751,10 +858,10 @@ abstract class DOMView { return; } else if (key === 'Tab') { - if (!focusedElement) { + if (!f.focusedElement && this._iframeDocument.getSelection()!.isCollapsed && !this._selectedAnnotationIDs.length) { // In PDF view the first visible object (annotation, overlay) is focused - if (focusableElements.length) { - focusableElements[0].focus(); + if (f.focusableElements.length) { + f.focusableElements[0].focus({ preventScroll: true }); } else { this._options.onTabOut(); @@ -767,31 +874,186 @@ abstract class DOMView { return; } - if (focusedElement) { + if (f.focusedElement) { if (!window.rtl && key === 'ArrowRight' || window.rtl && key === 'ArrowLeft' || key === 'ArrowDown') { - focusableElements[focusedElementIndex + 1]?.focus(); + f.focusableElements[(f.focusedElementIndex + 1) % f.focusableElements.length] + ?.focus({ preventScroll: true }); event.preventDefault(); return; } else if (!window.rtl && key === 'ArrowLeft' || window.rtl && key === 'ArrowRight' || key === 'ArrowUp') { - focusableElements[focusedElementIndex - 1]?.focus(); + f.focusableElements[(f.focusedElementIndex - 1 + f.focusableElements.length) % f.focusableElements.length] + ?.focus({ preventScroll: true }); event.preventDefault(); return; } else if (['Enter', 'Space'].includes(key)) { - if (focusedElement.classList.contains('highlight')) { - let annotationID = focusedElement.getAttribute('data-annotation-id')!; + if (f.focusedElement.matches('a, area')) { + (f.focusedElement as HTMLElement).click(); + event.preventDefault(); + return; + } + else if (f.focusedElement.hasAttribute('data-annotation-id')) { + let annotationID = f.focusedElement.getAttribute('data-annotation-id')!; let annotation = this._annotationsByID.get(annotationID); if (annotation) { this._options.onSelectAnnotations([annotationID], event); if (this._selectedAnnotationIDs.length == 1) { this._openAnnotationPopup(annotation); } + this._lastKeyboardFocusedAnnotationID = annotationID; + f.focusedElement.blur(); + event.preventDefault(); return; } } } } + else if (this._selectedAnnotationIDs.length === 1 && key === 'Enter') { + this._openAnnotationPopup(this._annotationsByID.get(this._selectedAnnotationIDs[0])!); + } + + if (this._selectedAnnotationIDs.length === 1 && key.includes('Shift-Arrow')) { + let annotation = this._annotationsByID.get(this._selectedAnnotationIDs[0])!; + let oldRange = this.toDisplayedRange(annotation.position); + if (!oldRange) { + event.preventDefault(); + return; + } + if (annotation.type === 'note') { + let root = this._getContainingRoot(oldRange.startContainer); + if (!root) { + throw new Error('Annotation is outside of root?'); + } + let walker = this._iframeDocument.createTreeWalker( + root, + NodeFilter.SHOW_ELEMENT, + node => (isBlock(node as Element) && !node.contains(oldRange!.startContainer) + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_SKIP), + ); + walker.currentNode = oldRange.startContainer; + + let newRange = this._iframeDocument.createRange(); + if (key.endsWith('Arrow' + (window.rtl ? 'Left' : 'Right')) + || key.endsWith('ArrowDown')) { + walker.nextNode(); + } + else { + walker.previousNode(); + } + newRange.selectNode(walker.currentNode); + try { + this._setAnnotationRange(annotation, newRange); + } + catch (e) { + // Reached the end of the section (EPUB) + // TODO: Allow movement between sections + event.preventDefault(); + return; + } + this._options.onUpdateAnnotations([annotation]); + this._navigateToSelector(annotation.position, { + block: 'center', + behavior: 'smooth', + skipHistory: true, + ifNeeded: true, + }); + } + else { + let resizeStart = key.startsWith('Cmd-') || key.startsWith('Ctrl-'); + let granularity; + // Up/down set via granularity, not direction + if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { + granularity = 'line'; + } + else if (event.altKey) { + granularity = 'word'; + } + else { + granularity = 'character'; + } + let selection = this._iframeDocument.getSelection()!; + + selection.removeAllRanges(); + selection.addRange(oldRange); + if (resizeStart) { + selection.collapseToStart(); + } + else { + selection.collapseToEnd(); + } + selection.modify( + 'move', + event.key === 'ArrowRight' || event.key === 'ArrowDown' ? 'right' : 'left', + granularity + ); + let newRange = selection.getRangeAt(0); + if (resizeStart) { + newRange.setEnd(oldRange.endContainer, oldRange.endOffset); + } + else { + newRange.setStart(oldRange.startContainer, oldRange.startOffset); + } + selection.removeAllRanges(); + + if (!newRange.collapsed) { + this._setAnnotationRange(annotation, newRange); + this._options.onUpdateAnnotations([annotation]); + } + } + + this._options.onSetAnnotationPopup(null); + event.preventDefault(); + return; + } + + if (!this._selectedAnnotationIDs.length + && (code === 'Ctrl-Alt-Digit1' || code === 'Ctrl-Alt-Digit2' || code === 'Ctrl-Alt-Digit3')) { + let type: AnnotationType; + switch (code) { + case 'Ctrl-Alt-Digit1': + type = 'highlight'; + break; + case 'Ctrl-Alt-Digit2': + type = 'underline'; + break; + case 'Ctrl-Alt-Digit3': + type = 'note'; + break; + } + let annotation = this._getAnnotationFromTextSelection(type, this._options.tools[type].color); + if (!annotation && type === 'note') { + let pos = caretPositionFromPoint( + this._iframeDocument, + this._iframeWindow.innerWidth / 2, + this._iframeWindow.innerHeight / 2 + ); + let elem = pos && closestElement(pos.offsetNode); + let block = elem && getContainingBlock(elem); + if (block) { + let range = this._iframeDocument.createRange(); + range.selectNode(block); + annotation = this._getAnnotationFromRange(range, type, this._options.tools[type].color); + } + } + if (annotation) { + this._options.onAddAnnotation(annotation, true); + this._navigateToSelector(annotation.position, { + block: 'center', + behavior: 'smooth', + skipHistory: true, + ifNeeded: true, + }); + this._iframeWindow.getSelection()?.removeAllRanges(); + if (type === 'note') { + this._renderAnnotations(true); + this._openAnnotationPopup(); + } + } + event.preventDefault(); + return; + } // Pass keydown even to the main window where common keyboard // shortcuts are handled i.e. Delete, Cmd-Minus, Cmd-f, etc. @@ -926,6 +1188,8 @@ abstract class DOMView { this._openAnnotationPopup(this._annotationsByID.get(id)!); } } + this._iframeDocument.body.focus(); + this._lastKeyboardFocusedAnnotationID = null; } this._handledPointerIDs.add(event.pointerId); }; @@ -951,6 +1215,7 @@ abstract class DOMView { if (this._selectedAnnotationIDs.length == 1) { this._openAnnotationPopup(this._annotationsByID.get(nextID)!); } + this._lastKeyboardFocusedAnnotationID = null; }; private _getAnnotationsAtPoint(clientX: number, clientY: number): string[] { @@ -1017,14 +1282,7 @@ abstract class DOMView { return; } let annotation = this._annotationsByID.get(id)!; - let updatedAnnotation = this._getAnnotationFromRange(range, annotation.type); - if (!updatedAnnotation) { - throw new Error('Invalid resized range'); - } - annotation.position = updatedAnnotation.position; - annotation.pageLabel = updatedAnnotation.pageLabel; - annotation.sortIndex = updatedAnnotation.sortIndex; - annotation.text = updatedAnnotation.text; + this._setAnnotationRange(annotation, range); this._options.onUpdateAnnotations([annotation]); // If the resize ends over a link, that somehow counts as a click in Fx @@ -1405,6 +1663,7 @@ export type DOMViewOptions = { primary?: boolean; mobile?: boolean; container: Element; + tools: Record; tool: Tool; platform: Platform; selectedAnnotationIDs: string[]; @@ -1423,7 +1682,7 @@ export type DOMViewOptions = { onChangeViewState: (state: State, primary?: boolean) => void; onChangeViewStats: (stats: ViewStats) => void; onSetDataTransferAnnotations: (dataTransfer: DataTransfer, annotation: NewAnnotation | NewAnnotation[], fromText?: boolean) => void; - onAddAnnotation: (annotation: NewAnnotation, select?: boolean) => void; + onAddAnnotation: (annotation: NewAnnotation, select?: boolean) => WADMAnnotation; onUpdateAnnotations: (annotations: Annotation[]) => void; onOpenLink: (url: string) => void; onSelectAnnotations: (ids: string[], triggeringEvent?: KeyboardEvent | MouseEvent) => void; diff --git a/src/dom/common/lib/find/index.ts b/src/dom/common/lib/find/index.ts index 14930e07..035f677a 100644 --- a/src/dom/common/lib/find/index.ts +++ b/src/dom/common/lib/find/index.ts @@ -17,7 +17,7 @@ class DefaultFindProcessor implements FindProcessor { private _initialPos: number | null = null; - private readonly _onSetFindState?: (state?: FindState) => void; + private readonly _onSetFindState?: (result: ResultArg) => void; private readonly _annotationKeyPrefix?: string; @@ -27,7 +27,7 @@ class DefaultFindProcessor implements FindProcessor { constructor(options: { findState: FindState, - onSetFindState?: (state?: FindState) => void, + onSetFindState?: (result: ResultArg) => void, annotationKeyPrefix?: string, }) { this.findState = options.findState; @@ -247,12 +247,10 @@ class DefaultFindProcessor implements FindProcessor { if (this._cancelled) return; if (this._onSetFindState) { this._onSetFindState({ - ...this.findState, - result: { - total: this._buf.length, - index: this._pos === null ? 0 : this._pos, - snippets: this.getSnippets(), - } + total: this._buf.length, + index: this._pos === null ? 0 : this._pos, + snippets: this.getSnippets(), + range: this.current?.range }); } } @@ -282,6 +280,13 @@ function normalize(s: string) { export type FindAnnotation = Omit & { range: PersistentRange }; +export type ResultArg = { + total: number; + index: number; + snippets: string[]; + range?: PersistentRange; +}; + export type SearchContext = { text: string; charDataRanges: CharDataRange[]; diff --git a/src/dom/common/lib/nodes.ts b/src/dom/common/lib/nodes.ts index f0d20583..fd506806 100644 --- a/src/dom/common/lib/nodes.ts +++ b/src/dom/common/lib/nodes.ts @@ -42,16 +42,15 @@ export function closestElement(node: Node): Element | null { const BLOCK_DISPLAYS = new Set(['block', 'list-item', 'table-cell', 'table', 'flex']); const BLOCK_ELEMENTS = new Set(['DIV', 'P', 'LI', 'OL', 'UL', 'TABLE', 'THEAD', 'TBODY', 'TR', 'TD', 'TH', 'DL', 'DT', 'DD', 'FORM', 'FIELDSET', 'SECTION', 'HEADER', 'FOOTER', 'ASIDE', 'NAV', 'ARTICLE', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER', 'FOOTER', 'ASIDE', 'NAV', 'ARTICLE']); +export function isBlock(element: Element) { + let display = getComputedStyle(element).display; + return (display && BLOCK_DISPLAYS.has(display)) || BLOCK_ELEMENTS.has(element.tagName); +} + export function getContainingBlock(element: Element): Element | null { let el: Element | null = element; while (el) { - let display = getComputedStyle(el).display; - if (display) { - if (BLOCK_DISPLAYS.has(display)) { - return el; - } - } - else if (BLOCK_ELEMENTS.has(el.tagName)) { + if (isBlock(el)) { return el; } el = el.parentElement; diff --git a/src/dom/common/lib/range.ts b/src/dom/common/lib/range.ts index 85242958..0df2275c 100644 --- a/src/dom/common/lib/range.ts +++ b/src/dom/common/lib/range.ts @@ -211,9 +211,11 @@ export function getStartElement(range: Range | PersistentRange): Element | null return startContainer as Element | null; } -export function getBoundingPageRect(range: Range) { - let rect = range.getBoundingClientRect(); - let win = range.commonAncestorContainer.ownerDocument?.defaultView; +export function getBoundingPageRect(rangeOrElem: Range | Element) { + let rect = rangeOrElem.getBoundingClientRect(); + let win = ('ownerDocument' in rangeOrElem + ? rangeOrElem.ownerDocument + : rangeOrElem.commonAncestorContainer.ownerDocument)?.defaultView; rect.x += win?.scrollX ?? 0; rect.y += win?.scrollY ?? 0; return rect; diff --git a/src/dom/epub/epub-view.ts b/src/dom/epub/epub-view.ts index 7ae6b0cc..649361a2 100644 --- a/src/dom/epub/epub-view.ts +++ b/src/dom/epub/epub-view.ts @@ -516,6 +516,11 @@ class EPUBView extends DOMView { }; } + protected override _getContainingRoot(node: Node) { + return this._sectionRenderers.find(r => r.container.contains(node))?.container + ?? null; + } + private _upsertAnnotation(annotation: NewAnnotation) { let existingAnnotation = this._annotations.find( existingAnnotation => existingAnnotation.text === annotation!.text @@ -773,6 +778,11 @@ class EPUBView extends DOMView { protected override _handleKeyDown(event: KeyboardEvent) { let { key } = event; + super._handleKeyDown(event); + if (event.defaultPrevented) { + return; + } + if (!event.shiftKey) { if (key == 'ArrowLeft') { this.flow.navigateLeft(); @@ -785,8 +795,6 @@ class EPUBView extends DOMView { return; } } - - super._handleKeyDown(event); } protected override _updateViewState() { @@ -1019,7 +1027,20 @@ class EPUBView extends DOMView { this._find = new EPUBFindProcessor({ view: this, findState: { ...state }, - onSetFindState: this._options.onSetFindState, + onSetFindState: (result) => { + this._options.onSetFindState({ + ...state, + result: { + total: result.total, + index: result.index, + snippets: result.snippets, + annotation: ( + result.range + && this._getAnnotationFromRange(result.range.toRange(), 'highlight') + ) ?? undefined + } + }); + }, }); let startRange = (this.flow.startRange && new PersistentRange(this.flow.startRange)) ?? undefined; let onFirstResult = () => this.findNext(); diff --git a/src/dom/epub/find.ts b/src/dom/epub/find.ts index 72083539..8dcfb3e9 100644 --- a/src/dom/epub/find.ts +++ b/src/dom/epub/find.ts @@ -1,7 +1,7 @@ import DefaultFindProcessor, { FindAnnotation, FindProcessor, - FindResult + FindResult, ResultArg } from "../common/lib/find"; import EPUBView from "./epub-view"; import SectionRenderer from "./section-renderer"; @@ -23,12 +23,12 @@ export class EPUBFindProcessor implements FindProcessor { private _cancelled = false; - private readonly _onSetFindState?: (state?: FindState) => void; + private readonly _onSetFindState?: (result: ResultArg) => void; constructor(options: { view: EPUBView, findState: FindState, - onSetFindState?: (state?: FindState) => void, + onSetFindState?: (result: ResultArg) => void, }) { this.view = options.view; this.findState = options.findState; @@ -165,13 +165,17 @@ export class EPUBFindProcessor implements FindProcessor { let index = 0; let foundSelected = false; let snippets = []; + let range: PersistentRange | undefined; for (let processor of this._processors) { if (!processor) { continue; } if (this._selectedProcessor == processor) { - index += processor.position ?? 0; + let position = processor.position ?? 0; + index += position; foundSelected = true; + // TODO: Expose this in a nicer way + range = processor.getAnnotations()[position]?.range; } else if (!foundSelected) { index += processor.getResults().length; @@ -179,12 +183,10 @@ export class EPUBFindProcessor implements FindProcessor { snippets.push(...processor.getSnippets()); } this._onSetFindState({ - ...this.findState, - result: { - total: this._totalResults, - index, - snippets, - } + total: this._totalResults, + index, + snippets, + range, }); } } diff --git a/src/dom/epub/flow.ts b/src/dom/epub/flow.ts index f011eca0..d9505d58 100644 --- a/src/dom/epub/flow.ts +++ b/src/dom/epub/flow.ts @@ -588,6 +588,9 @@ export class PaginatedFlow extends AbstractFlow { } private _handleKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) { + return; + } let { key, shiftKey } = event; // Left/right arrows are handled in EPUBView if (!shiftKey) { diff --git a/src/dom/snapshot/snapshot-view.ts b/src/dom/snapshot/snapshot-view.ts index 09400406..f6b46cee 100644 --- a/src/dom/snapshot/snapshot-view.ts +++ b/src/dom/snapshot/snapshot-view.ts @@ -34,6 +34,7 @@ import injectCSS from './stylesheets/inject.scss'; // @ts-expect-error import darkReaderJS from '!!raw-loader!darkreader/darkreader'; import { DynamicThemeFix } from "darkreader"; +import { isPageRectVisible } from "../common/lib/rect"; class SnapshotView extends DOMView { protected _find: DefaultFindProcessor | null = null; @@ -321,6 +322,10 @@ class SnapshotView extends DOMView { // Non-element nodes and ranges don't have scrollIntoView(), // so scroll using a temporary element, removed synchronously let rect = getBoundingPageRect(range); + if (options.ifNeeded && isPageRectVisible(rect, this._iframeWindow, 0)) { + return; + } + let tempElem = this._iframeDocument.createElement('div'); tempElem.style.position = 'absolute'; tempElem.style.visibility = 'hidden'; @@ -405,7 +410,20 @@ class SnapshotView extends DOMView { console.log('Initiating new search', state); this._find = new DefaultFindProcessor({ findState: { ...state }, - onSetFindState: this._options.onSetFindState, + onSetFindState: (result) => { + this._options.onSetFindState({ + ...state, + result: { + total: result.total, + index: result.index, + snippets: result.snippets, + annotation: ( + result.range + && this._getAnnotationFromRange(result.range.toRange(), 'highlight') + ) ?? undefined + } + }); + }, }); await this._find.run( this._searchContext,