onPointerDown!(annotation, event)) : undefined}
+ onPointerUp={onPointerUp ? (event => onPointerUp!(annotation, event)) : undefined}
+ onDragStart={handleDragStart}
+ data-annotation-id={props.annotation?.id}
+ />
+
+ )}
+ {selected || !annotation.id
+ ?
+ : }
+ {annotation.comment && (
+
+ )}
+ >;
+};
+
+type ImageProps = {
+ annotation: DisplayedAnnotation;
+ selected: boolean;
+ onPointerDown?: (annotation: DisplayedAnnotation, event: React.PointerEvent) => void;
+ onPointerUp?: (annotation: DisplayedAnnotation, event: React.PointerEvent) => void;
+ onDragStart?: (annotation: DisplayedAnnotation, dataTransfer: DataTransfer) => void;
+ pointerEventsSuppressed: boolean;
+}
diff --git a/src/dom/common/dom-view.tsx b/src/dom/common/dom-view.tsx
index 07321cefa..36a268c4b 100644
--- a/src/dom/common/dom-view.tsx
+++ b/src/dom/common/dom-view.tsx
@@ -192,7 +192,7 @@ abstract class DOMView {
// Utilities for annotations - abstractions over the specific types of selectors used by the two views
// ***
- abstract toSelector(range: Range): Selector | null;
+ abstract toSelector(rangeOrNode: Range | Node): Selector | null;
abstract toDisplayedRange(selector: Selector): Range | null;
@@ -206,6 +206,8 @@ abstract class DOMView {
protected abstract _getAnnotationFromRange(range: Range, type: AnnotationType, color?: string): NewAnnotation | null;
+ protected abstract _getAnnotationFromElement(elem: Element, type: AnnotationType, color?: string): NewAnnotation | null;
+
protected abstract _updateViewState(): void;
protected abstract _updateViewStats(): void;
@@ -357,6 +359,7 @@ abstract class DOMView {
type: 'highlight',
color: SELECTION_COLOR,
key: '_highlightedPosition',
+ position: this._highlightedPosition,
range: this.toDisplayedRange(this._highlightedPosition)!,
});
}
@@ -369,6 +372,7 @@ abstract class DOMView {
text: this._previewAnnotation.text,
comment: this._previewAnnotation.comment,
key: '_previewAnnotation',
+ position: this._previewAnnotation.position,
range: this.toDisplayedRange(this._previewAnnotation.position)!,
});
}
@@ -524,10 +528,25 @@ abstract class DOMView {
if (this._tool.type == 'note') {
let range = this._getNoteTargetRange(event);
if (range) {
- this._previewAnnotation = this._getAnnotationFromRange(range, 'note', this._tool.color);
+ this._previewAnnotation = this._getAnnotationFromRange(range, this._tool.type, this._tool.color);
this._renderAnnotations();
}
}
+ else if (this._tool.type == 'image') {
+ let target = event.target as Element;
+ if (target.tagName == 'IMG') {
+ this._previewAnnotation = this._getAnnotationFromElement(target, this._tool.type, this._tool.color);
+ // Don't allow duplicate image annotations
+ if (this._annotations.find(a => a.type == 'image' && a.position == this._previewAnnotation!.position)) {
+ this._previewAnnotation = null;
+ }
+ }
+ else {
+ // Note tool keeps previous preview if there isn't a new valid target, image tool doesn't
+ this._previewAnnotation = null;
+ }
+ this._renderAnnotations();
+ }
}
protected _handlePointerOverInternalLink(link: HTMLAnchorElement) {
@@ -567,20 +586,10 @@ abstract class DOMView {
protected _getNoteTargetRange(event: PointerEvent | DragEvent): Range | null {
let target = event.target as Element;
- // Disable pointer events and rerender so we can get the cursor position in the text layer,
- // not the annotation layer, even if the mouse is over the annotation layer
let range = this._iframeDocument.createRange();
if (target.tagName === 'IMG') { // Allow targeting images directly
range.selectNode(target);
}
- else if (target.closest('[data-annotation-id]')) {
- let annotation = this._annotationsByID.get(
- target.closest('[data-annotation-id]')!.getAttribute('data-annotation-id')!
- )!;
- let annotationRange = this.toDisplayedRange(annotation.position)!;
- range.setStart(annotationRange.startContainer, annotationRange.startOffset);
- range.setEnd(annotationRange.endContainer, annotationRange.endOffset);
- }
else {
let pos = supportsCaretPositionFromPoint()
&& caretPositionFromPoint(this._iframeDocument, event.clientX, event.clientY);
@@ -960,10 +969,12 @@ abstract class DOMView {
// Create note annotation on pointer down event, if note tool is active.
// The note tool will be automatically deactivated in reader.js,
// because this is what we do in PDF reader
- if (event.button == 0 && this._tool.type == 'note' && this._previewAnnotation) {
- this._options.onAddAnnotation(this._previewAnnotation, true);
- event.preventDefault();
+ if (event.button == 0 && (this._tool.type == 'note' || this._tool.type == 'image') && this._previewAnnotation) {
+ this._options.onAddAnnotation(this._previewAnnotation, this._tool.type == 'note');
+ this._previewAnnotation = null;
+ this._renderAnnotations();
+ event.preventDefault();
// preventDefault() doesn't stop pointerup/click from firing, so our link handler will still fire
// if the note is added to a link. "Fix" this by eating all click events in the next half second.
// Very silly.
@@ -1081,7 +1092,7 @@ abstract class DOMView {
// When using any tool besides pointer, touches should annotate but pinch-zoom should still be allowed
this._iframeDocument.documentElement.style.touchAction = tool.type != 'pointer' ? 'none' : 'auto';
- if (this._previewAnnotation && tool.type !== 'note') {
+ if (this._previewAnnotation && tool.type !== this._previewAnnotation.type) {
this._previewAnnotation = null;
}
this._renderAnnotations();
diff --git a/src/dom/common/find.ts b/src/dom/common/find.ts
index 066279c89..5eaae5e1f 100644
--- a/src/dom/common/find.ts
+++ b/src/dom/common/find.ts
@@ -58,6 +58,7 @@ class DefaultFindProcessor implements FindProcessor {
color: 'rgba(180, 0, 170, 1)',
text: '',
key: 'findResult_' + (this._annotationKeyPrefix || '') + '_' + this._buf.length,
+ position: null,
range,
}
};
diff --git a/src/dom/common/lib/range.ts b/src/dom/common/lib/range.ts
index ce3e9add9..2d3517c75 100644
--- a/src/dom/common/lib/range.ts
+++ b/src/dom/common/lib/range.ts
@@ -1,11 +1,19 @@
-import { isFirefox, isWin } from "../../../common/lib/utilities";
+import {
+ isFirefox,
+ isWin,
+ getImageDataURL
+} from "../../../common/lib/utilities";
+import {
+ closestElement,
+ getContainingBlock
+} from "./nodes";
/**
* Wraps the properties of a Range object in a static structure so that they don't change when the DOM changes.
* (Range objects automatically normalize their start/end points when the DOM changes, which is not what we want -
* even if the start or end is removed from the DOM temporarily, we want to keep our ranges unchanged.)
*/
-export class PersistentRange {
+export class PersistentRange implements StaticRange {
startContainer: Node;
startOffset: number;
@@ -14,13 +22,17 @@ export class PersistentRange {
endOffset: number;
- constructor(range: Range) {
+ constructor(range: StaticRangeInit) {
this.startContainer = range.startContainer;
this.startOffset = range.startOffset;
this.endContainer = range.endContainer;
this.endOffset = range.endOffset;
}
+ get collapsed(): boolean {
+ return this.startContainer === this.endContainer && this.startOffset === this.endOffset;
+ }
+
toRange(): Range {
let range = new Range();
range.setStart(this.startContainer, this.startOffset);
@@ -132,6 +144,30 @@ export function splitRangeToTextNodes(range: Range): Range[] {
return ranges;
}
+export function findImageInRange(range: Range): HTMLImageElement | null {
+ if (range.startContainer == range.endContainer && range.startOffset == range.endOffset - 1) {
+ let node = range.startContainer.childNodes[range.startOffset];
+ if (node && node.nodeName == 'IMG') {
+ return node as HTMLImageElement;
+ }
+ }
+
+ let doc = range.commonAncestorContainer.ownerDocument;
+ if (!doc) {
+ return null;
+ }
+ let treeWalker = doc.createTreeWalker(range.commonAncestorContainer, NodeFilter.SHOW_ELEMENT,
+ node => (range.intersectsNode(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP));
+ let node: Node | null = treeWalker.currentNode;
+ while (node) {
+ if (node.nodeName == 'IMG') {
+ return node as HTMLImageElement;
+ }
+ node = treeWalker.nextNode();
+ }
+ return null;
+}
+
/**
* Create a single range spanning all the positions included in the set of input ranges. For
* example, if rangeA goes from nodeA at offset 5 to nodeB at offset 2 and rangeB goes from nodeC
@@ -197,3 +233,30 @@ export function getStartElement(range: Range | PersistentRange): Element | null
}
return startContainer as Element | null;
}
+
+export function getAnnotationText(range: Range): string {
+ let text = '';
+ let lastSplitRange;
+ for (let splitRange of splitRangeToTextNodes(range)) {
+ if (lastSplitRange) {
+ let lastSplitRangeContainer = closestElement(lastSplitRange.commonAncestorContainer);
+ let lastSplitRangeBlock = lastSplitRangeContainer && getContainingBlock(lastSplitRangeContainer);
+ let splitRangeContainer = closestElement(splitRange.commonAncestorContainer);
+ let splitRangeBlock = splitRangeContainer && getContainingBlock(splitRangeContainer);
+ if (lastSplitRangeBlock !== splitRangeBlock) {
+ text += '\n\n';
+ }
+ }
+ text += splitRange.toString().replace(/\s+/g, ' ');
+ lastSplitRange = splitRange;
+ }
+ return text.trim();
+}
+
+export function getAnnotationImage(range: Range): string | null {
+ let imageElem = findImageInRange(range);
+ if (!imageElem) {
+ return null;
+ }
+ return getImageDataURL(imageElem);
+}
diff --git a/src/dom/epub/epub-view.ts b/src/dom/epub/epub-view.ts
index 8702f8c5b..98acd5d0a 100644
--- a/src/dom/epub/epub-view.ts
+++ b/src/dom/epub/epub-view.ts
@@ -22,9 +22,10 @@ import Epub, {
NavItem,
} from "epubjs";
import {
+ getAnnotationImage,
+ getAnnotationText,
moveRangeEndsIntoTextNodes,
- PersistentRange,
- splitRangeToTextNodes
+ PersistentRange
} from "../common/lib/range";
import {
FragmentSelector,
@@ -56,6 +57,7 @@ import {
ScrolledFlow
} from "./flow";
import { RTL_SCRIPTS } from "./defines";
+import { getImageDataURL } from "../../common/lib/utilities";
class EPUBView extends DOMView {
protected _find: EPUBFindProcessor | null = null;
@@ -287,6 +289,11 @@ class EPUBView extends DOMView {
console.error('Unable to get range for CFI', cfiString);
return null;
}
+ if (range.startContainer === range.endContainer && range.startContainer.nodeType === Node.ELEMENT_NODE
+ && (range.startContainer as Element).tagName === 'IMG') {
+ // Fix toRange() returning a collapsed range *inside* the
tag
+ range.selectNode(range.startContainer);
+ }
this._rangeCache.set(cfiString, new PersistentRange(range));
return range;
}
@@ -296,9 +303,8 @@ class EPUBView extends DOMView {
}
}
- override toSelector(range: Range): FragmentSelector | null {
- range = moveRangeEndsIntoTextNodes(range);
- let cfi = this.getCFI(range);
+ override toSelector(rangeOrNode: Range | Node): FragmentSelector | null {
+ let cfi = this.getCFI(rangeOrNode);
if (!cfi) {
return null;
}
@@ -353,29 +359,15 @@ class EPUBView extends DOMView {
}
protected _getAnnotationFromRange(range: Range, type: AnnotationType, color?: string): NewAnnotation | null {
- range = moveRangeEndsIntoTextNodes(range);
+ if (type != 'image') {
+ range = moveRangeEndsIntoTextNodes(range);
+ }
if (range.collapsed) {
return null;
}
let text;
if (type == 'highlight' || type == 'underline') {
- text = '';
- let lastSplitRange;
- for (let splitRange of splitRangeToTextNodes(range)) {
- if (lastSplitRange) {
- let lastSplitRangeContainer = closestElement(lastSplitRange.commonAncestorContainer);
- let lastSplitRangeBlock = lastSplitRangeContainer && getContainingBlock(lastSplitRangeContainer);
- let splitRangeContainer = closestElement(splitRange.commonAncestorContainer);
- let splitRangeBlock = splitRangeContainer && getContainingBlock(splitRangeContainer);
- if (lastSplitRangeBlock !== splitRangeBlock) {
- text += '\n\n';
- }
- }
- text += splitRange.toString().replace(/\s+/g, ' ');
- lastSplitRange = splitRange;
- }
- text = text.trim();
-
+ text = getAnnotationText(range);
// If this annotation type wants text, but we didn't get any, abort
if (!text) {
return null;
@@ -384,6 +376,7 @@ class EPUBView extends DOMView {
else {
text = undefined;
}
+ let image = type == 'image' && getAnnotationImage(range) || undefined;
let selector = this.toSelector(range);
if (!selector) {
@@ -409,7 +402,51 @@ class EPUBView extends DOMView {
sortIndex,
pageLabel,
position: selector,
- text
+ text,
+ image
+ };
+ }
+
+ protected _getAnnotationFromElement(elem: Element, type: AnnotationType, color: string | undefined): NewAnnotation | null {
+ let text;
+ if (type == 'highlight' || type == 'underline') {
+ text = elem instanceof this._iframeWindow.HTMLElement ? elem.innerText : elem.textContent;
+ // If this annotation type wants text, but we didn't get any, abort
+ if (!text) {
+ return null;
+ }
+ }
+ else {
+ text = undefined;
+ }
+ let image = type == 'image' && elem.tagName === 'IMG' && getImageDataURL(elem) || undefined;
+
+ let selector = this.toSelector(elem);
+ if (!selector) {
+ return null;
+ }
+
+ let pageLabel = this.pageMapping.isPhysical && this.pageMapping.getPageLabel(elem) || '';
+
+ // Use the number of characters between the start of the section and the start of the selection range
+ // to disambiguate the sortIndex
+ let sectionContainer = elem.closest('[data-section-index]');
+ if (!sectionContainer) {
+ return null;
+ }
+ let sectionIndex = parseInt(sectionContainer.getAttribute('data-section-index')!);
+ let offsetRange = this._iframeDocument.createRange();
+ offsetRange.setStart(sectionContainer, 0);
+ offsetRange.setEndBefore(elem);
+ let sortIndex = String(sectionIndex).padStart(5, '0') + '|' + String(offsetRange.toString().length).padStart(8, '0');
+ return {
+ type,
+ color,
+ sortIndex,
+ pageLabel,
+ position: selector,
+ text,
+ image
};
}
diff --git a/src/dom/epub/lib/page-mapping.ts b/src/dom/epub/lib/page-mapping.ts
index 187566e9d..9d04c7175 100644
--- a/src/dom/epub/lib/page-mapping.ts
+++ b/src/dom/epub/lib/page-mapping.ts
@@ -113,8 +113,18 @@ class PageMapping {
return this.tree.keysArray().indexOf(pageStartRange);
}
- getPageLabel(range: Range): string | null {
- return this.tree.getPairOrNextLower(new PersistentRange(range))?.[1] ?? null;
+ getPageLabel(rangeOrNode: Range | Node): string | null {
+ if ('nodeType' in rangeOrNode) {
+ return this.tree.getPairOrNextLower(new PersistentRange({
+ startContainer: rangeOrNode,
+ startOffset: 0,
+ endContainer: rangeOrNode,
+ endOffset: 0,
+ }))?.[1] ?? null;
+ }
+ else {
+ return this.tree.getPairOrNextLower(new PersistentRange(rangeOrNode))?.[1] ?? null;
+ }
}
get firstRange(): Range | null {
diff --git a/src/dom/snapshot/snapshot-view.ts b/src/dom/snapshot/snapshot-view.ts
index 21417dd37..41798e715 100644
--- a/src/dom/snapshot/snapshot-view.ts
+++ b/src/dom/snapshot/snapshot-view.ts
@@ -8,6 +8,7 @@ import {
OutlineItem
} from "../../common/types";
import {
+ getAnnotationImage,
getStartElement
} from "../common/lib/range";
import {
@@ -29,6 +30,7 @@ import {
createSearchContext,
SearchContext
} from "../common/lib/dom-text-search";
+import { getImageDataURL } from "../../common/lib/utilities";
// @ts-expect-error
import injectCSS from './stylesheets/inject.scss';
@@ -172,6 +174,7 @@ class SnapshotView extends DOMView {
if (text === '') {
return null;
}
+ let image = type == 'image' && getAnnotationImage(range) || undefined;
let selector = this.toSelector(range);
if (!selector) {
@@ -184,39 +187,85 @@ class SnapshotView extends DOMView {
color,
sortIndex,
position: selector,
- text
+ text,
+ image
};
}
- private _getSortIndex(range: Range) {
+ protected _getAnnotationFromElement(elem: Element, type: AnnotationType, color: string | undefined): NewAnnotation | null {
+ let text;
+ if (type == 'highlight' || type == 'underline') {
+ text = elem instanceof this._iframeWindow.HTMLElement ? elem.innerText : elem.textContent;
+ // If this annotation type wants text, but we didn't get any, abort
+ if (!text) {
+ return null;
+ }
+ }
+ else {
+ text = undefined;
+ }
+ let image = type == 'image' && elem.tagName === 'IMG' && getImageDataURL(elem) || undefined;
+
+ let selector = this.toSelector(elem);
+ if (!selector) {
+ return null;
+ }
+
+ let sortIndex = this._getSortIndex(elem);
+ return {
+ type,
+ color,
+ sortIndex,
+ position: selector,
+ text,
+ image
+ };
+ }
+
+ private _getSortIndex(rangeOrNode: Range | Node) {
+ let stop = 'nodeType' in rangeOrNode ? rangeOrNode : rangeOrNode.startContainer;
+ let stopOffset = 'nodeType' in rangeOrNode ? 0 : rangeOrNode.startOffset;
let iter = this._iframeDocument.createNodeIterator(this._iframeDocument.documentElement, NodeFilter.SHOW_TEXT);
let count = 0;
let node: Node | null;
while ((node = iter.nextNode())) {
- if (range.startContainer.contains(node)) {
- return String(count + range.startOffset).padStart(8, '0');
+ if (stop.contains(node)) {
+ return String(count + stopOffset).padStart(8, '0');
+ }
+ else if (stop.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING) {
+ return String(count).padStart(8, '0');
}
count += node.nodeValue!.trim().length;
}
return '00000000';
}
- toSelector(range: Range): Selector | null {
- let doc = range.commonAncestorContainer.ownerDocument;
- if (!doc) return null;
- let targetElement;
- // In most cases, the range will wrap a single child of the
- // commonAncestorContainer. Build a selector targeting that element,
- // not the container.
- if (range.startContainer === range.endContainer
- && range.startOffset == range.endOffset - 1
- && range.startContainer.nodeType == Node.ELEMENT_NODE) {
- targetElement = range.startContainer.childNodes[range.startOffset];
+ toSelector(rangeOrNode: Range | Node): Selector | null {
+ let doc;
+ let targetNode;
+ if ('nodeType' in rangeOrNode) {
+ let node = rangeOrNode;
+ if (!node.ownerDocument) return null;
+ doc = node.ownerDocument;
+ targetNode = node;
}
else {
- targetElement = range.commonAncestorContainer;
+ let range = rangeOrNode;
+ if (!range.commonAncestorContainer.ownerDocument) return null;
+ doc = range.commonAncestorContainer.ownerDocument;
+ // In most cases, the range will wrap a single child of the
+ // commonAncestorContainer. Build a selector targeting that element,
+ // not the container.
+ if (range.startContainer === range.endContainer
+ && range.startOffset == range.endOffset - 1
+ && range.startContainer.nodeType == Node.ELEMENT_NODE) {
+ targetNode = range.startContainer.childNodes[range.startOffset];
+ }
+ else {
+ targetNode = range.commonAncestorContainer;
+ }
}
- let targetElementQuery = getUniqueSelectorContaining(targetElement, doc.body);
+ let targetElementQuery = getUniqueSelectorContaining(targetNode, doc.body);
if (targetElementQuery) {
let newCommonAncestor = doc.body.querySelector(targetElementQuery);
if (!newCommonAncestor) {
@@ -228,13 +277,16 @@ class SnapshotView extends DOMView {
};
// If the user has highlighted the full text content of the element, no need to add a
// TextPositionSelector.
- if (range.toString().trim() !== (newCommonAncestor.textContent || '').trim()) {
- selector.refinedBy = textPositionFromRange(range, newCommonAncestor) || undefined;
+ if (!('nodeType' in rangeOrNode) && rangeOrNode.toString().trim() !== (newCommonAncestor.textContent || '').trim()) {
+ selector.refinedBy = textPositionFromRange(rangeOrNode, newCommonAncestor) || undefined;
}
return selector;
}
+ else if (!('nodeType' in rangeOrNode)) {
+ return textPositionFromRange(rangeOrNode, doc.body);
+ }
else {
- return textPositionFromRange(range, doc.body);
+ return null;
}
}
diff --git a/src/en-us.strings.js b/src/en-us.strings.js
index 8a85a0cdf..7d8cb79cf 100644
--- a/src/en-us.strings.js
+++ b/src/en-us.strings.js
@@ -105,6 +105,7 @@ export default {
'pdfReader.underlineText': 'Underline Text',
'pdfReader.addNote': 'Add Note',
'pdfReader.selectArea': 'Select Area',
+ 'pdfReader.selectImage': 'Select Image',
'pdfReader.addText': 'Add Text',
'pdfReader.draw': 'Draw',
'pdfReader.eraser': 'Eraser',