onPointerDown!(annotation, event)) : undefined}
+ onPointerUp={onPointerUp ? (event => onPointerUp!(annotation, event)) : undefined}
+ onDragStart={handleDragStart}
+ data-annotation-id={props.annotation?.id}
+ />
+
+ )}
+ {selected
+ ?
+ : }
+ {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 7964f92d..9fd6203e 100644
--- a/src/dom/common/dom-view.tsx
+++ b/src/dom/common/dom-view.tsx
@@ -165,7 +165,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;
@@ -177,6 +177,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;
@@ -480,10 +482,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) {
@@ -523,8 +540,6 @@ 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);
@@ -865,10 +880,11 @@ 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;
+ 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.
@@ -968,7 +984,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' ? 'pinch-zoom' : '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/lib/range.ts b/src/dom/common/lib/range.ts
index 0d8bfe46..4962aaef 100644
--- a/src/dom/common/lib/range.ts
+++ b/src/dom/common/lib/range.ts
@@ -1,9 +1,15 @@
+import {
+ closestElement,
+ getContainingBlock
+} from "./nodes";
+import { getImageDataURL } from "../../../common/lib/utilities";
+
/**
* 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;
@@ -12,13 +18,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);
@@ -119,6 +129,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
@@ -184,3 +218,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 4195c3e9..311626d1 100644
--- a/src/dom/epub/epub-view.ts
+++ b/src/dom/epub/epub-view.ts
@@ -15,9 +15,10 @@ import Epub, {
NavItem,
} from "epubjs";
import {
+ getAnnotationImage,
+ getAnnotationText,
moveRangeEndsIntoTextNodes,
- PersistentRange,
- splitRangeToTextNodes
+ PersistentRange
} from "../common/lib/range";
import {
FragmentSelector,
@@ -57,6 +58,7 @@ import contentCSS from '!!raw-loader!./stylesheets/content.css';
// The module resolver is incapable of understanding this
// @ts-ignore
import Path from "epubjs/src/utils/path";
+import { getImageDataURL } from "../../common/lib/utilities";
class EPUBView extends DOMView {
protected _find: EPUBFindProcessor | null = null;
@@ -289,13 +291,17 @@ 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;
}
- 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;
}
@@ -352,29 +358,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;
@@ -383,6 +375,7 @@ class EPUBView extends DOMView {
else {
text = undefined;
}
+ let image = type == 'image' && getAnnotationImage(range) || undefined;
let selector = this.toSelector(range);
if (!selector) {
@@ -408,7 +401,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 932c7acf..25d52c88 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 a5b82bca..01822b1e 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 {
@@ -33,6 +34,7 @@ import {
// @ts-ignore
import contentCSS from '!!raw-loader!./stylesheets/content.css';
+import { getImageDataURL } from "../../common/lib/utilities";
class SnapshotView extends DOMView {
private readonly _navStack = new NavStack<[number, number]>();
@@ -175,6 +177,7 @@ class SnapshotView extends DOMView {
if (text === '') {
return null;
}
+ let image = type == 'image' && getAnnotationImage(range) || undefined;
let selector = this.toSelector(range);
if (!selector) {
@@ -187,39 +190,82 @@ 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');
}
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) {
@@ -231,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/tsconfig.json b/tsconfig.json
index 3141ab8d..10681f15 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,7 +1,7 @@
{
"compilerOptions": {
"outDir": "./build",
- "jsx": "react",
+ "jsx": "react-jsx",
"allowJs": true,
"strict": true,
"target": "ES6",