Skip to content

Commit

Permalink
EPUB/snapshot image annotations
Browse files Browse the repository at this point in the history
  • Loading branch information
AbeJellinek committed Sep 19, 2023
1 parent 85c5595 commit 64d7be0
Show file tree
Hide file tree
Showing 13 changed files with 405 additions and 81 deletions.
20 changes: 20 additions & 0 deletions demo/epub/annotations.js

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions demo/snapshot/annotations.js

Large diffs are not rendered by default.

20 changes: 9 additions & 11 deletions src/common/components/toolbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -175,17 +175,15 @@ function Toolbar(props) {
<span className="button-background"/>
</button>
)}
{props.type === 'pdf' && (
<button
tabIndex={-1}
className={cx('toolbarButton area', { toggled: props.tool.type === 'image' })}
title={intl.formatMessage({ id: 'pdfReader.selectArea' })}
disabled={props.readOnly}
onClick={() => handleToolClick('image')}
>
<span className="button-background"/>
</button>
)}
<button
tabIndex={-1}
className={cx('toolbarButton area', { toggled: props.tool.type === 'image' })}
title={intl.formatMessage({ id: props.type == 'pdf' ? 'pdfReader.selectArea' : 'pdfReader.selectImage' })}
disabled={props.readOnly}
onClick={() => handleToolClick('image')}
>
<span className="button-background"/>
</button>
{props.type === 'pdf' && (
<button
tabIndex={-1}
Expand Down
1 change: 1 addition & 0 deletions src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export interface Annotation {
pageLabel?: string;
position: Position;
text?: string;
image?: string;
comment?: string;
tags: string[];
dateCreated: string;
Expand Down
123 changes: 118 additions & 5 deletions src/dom/common/components/overlay/annotation-overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,19 @@ import React, {
import {
caretPositionFromPoint,
collapseToOneCharacterAtStart,
findImageInRange,
splitRangeToTextNodes,
supportsCaretPositionFromPoint
} from "../../lib/range";
import { AnnotationType } from "../../../../common/types";
import ReactDOM from "react-dom";
import { IconNoteLarge } from "../../../../common/components/common/icons";
import { closestElement } from "../../lib/nodes";
import { isSafari } from "../../../../common/lib/utilities";
import {
isFirefox,
isSafari
} from "../../../../common/lib/utilities";
import { Selector } from "../../lib/selector";

export type DisplayedAnnotation = {
id?: string;
Expand All @@ -26,6 +31,7 @@ export type DisplayedAnnotation = {
comment?: string;
readOnly?: boolean;
key: string;
position: Selector | null;
range: Range;
};

Expand Down Expand Up @@ -158,6 +164,31 @@ export const AnnotationOverlay: React.FC<AnnotationOverlayProps> = (props) => {
onDragStart={handleDragStart}
pointerEventsSuppressed={pointerEventsSuppressed}
/>
{annotations.filter(annotation => annotation.type == 'image').map((annotation) => {
if (annotation.id) {
return (
<Image
annotation={annotation}
key={annotation.key}
selected={selectedAnnotationIDs.includes(annotation.id)}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onDragStart={handleDragStart}
pointerEventsSuppressed={pointerEventsSuppressed}
/>
);
}
else {
return (
<Image
annotation={annotation}
key={annotation.key}
selected={false}
pointerEventsSuppressed={true}
/>
);
}
})}
</svg>
</>;
};
Expand Down Expand Up @@ -412,8 +443,9 @@ const StaggeredNotes: React.FC<StaggeredNotesProps> = (props) => {
let staggerMap = new Map<string | undefined, number>();
return <>
{annotations.map((annotation) => {
let stagger = staggerMap.has(annotation.sortIndex) ? staggerMap.get(annotation.sortIndex)! : 0;
staggerMap.set(annotation.sortIndex, stagger + 1);
let key = JSON.stringify(annotation.position);
let stagger = staggerMap.has(key) ? staggerMap.get(key)! : 0;
staggerMap.set(key, stagger + 1);
if (annotation.id) {
return (
<Note
Expand Down Expand Up @@ -453,7 +485,7 @@ type StaggeredNotesProps = {
};

const SelectionBorder: React.FC<SelectionBorderProps> = React.memo((props) => {
let { rect, preview } = props;
let { rect, preview, strokeWidth = 2 } = props;
return (
<rect
x={rect.left - 5}
Expand All @@ -463,13 +495,14 @@ const SelectionBorder: React.FC<SelectionBorderProps> = React.memo((props) => {
fill="none"
stroke={preview ? '#aaaaaa' : '#6d95e0'}
strokeDasharray="10 6"
strokeWidth={2}/>
strokeWidth={strokeWidth}/>
);
}, (prev, next) => JSON.stringify(prev.rect) === JSON.stringify(next.rect));
SelectionBorder.displayName = 'SelectionBorder';
type SelectionBorderProps = {
rect: DOMRect;
preview?: boolean;
strokeWidth?: number;
};

const RangeSelectionBorder: React.FC<RangeSelectionBorderProps> = (props) => {
Expand Down Expand Up @@ -719,3 +752,83 @@ type CommentIconProps = {
onDragStart?: (event: React.DragEvent) => void;
onDragEnd?: (event: React.DragEvent) => void;
};

const Image: React.FC<ImageProps> = (props) => {
let { annotation, selected, pointerEventsSuppressed, onPointerDown, onPointerUp, onDragStart } = props;
let doc = annotation.range.commonAncestorContainer.ownerDocument;
if (!doc || !doc.defaultView) {
return null;
}

let handleDragStart = (event: React.DragEvent) => {
if (!event.dataTransfer) return;
let image = findImageInRange(annotation.range);
if (image) {
let br = image.getBoundingClientRect();
if (isFirefox) {
// The spec says that if an HTMLImageElement is passed to setDragImage(), the drag image should be the
// element's underlying image data at full width/height. Most browsers choose to ignore the spec and
// draw the image at its displayed width/height, which is actually what we want here. Firefox follows
// the spec, so we have to scale using a canvas.
let canvas = doc!.createElement('canvas');
canvas.width = image.width;
canvas.height = image.height;
let ctx = canvas.getContext('2d')!;
ctx.drawImage(image, 0, 0, image.width, image.height);
event.dataTransfer.setDragImage(canvas, event.clientX - br.left, event.clientY - br.top);
}
else {
event.dataTransfer.setDragImage(image, event.clientX - br.left, event.clientY - br.top);
}
}
onDragStart?.(annotation, event.dataTransfer);
};

let rect = annotation.range.getBoundingClientRect();
rect.x += doc.defaultView.scrollX;
rect.y += doc.defaultView.scrollY;
return <>
{!pointerEventsSuppressed && (
<foreignObject x={rect.x} y={rect.y} width={rect.width} height={rect.height}>
<div
// @ts-ignore
xmlns="http://www.w3.org/1999/xhtml"
style={{
pointerEvents: 'auto',
cursor: 'default',
width: '100%',
height: '100%',
}}
draggable={true}
onPointerDown={onPointerDown ? (event => onPointerDown!(annotation, event)) : undefined}
onPointerUp={onPointerUp ? (event => onPointerUp!(annotation, event)) : undefined}
onDragStart={handleDragStart}
data-annotation-id={props.annotation?.id}
/>
</foreignObject>
)}
{selected || !annotation.id
? <SelectionBorder rect={rect} strokeWidth={3} preview={!annotation.id}/>
: <rect
x={rect.x - 5}
y={rect.y - 5}
width={rect.width + 10}
height={rect.height + 10}
stroke={annotation.color}
strokeWidth={3}
fill="none"
/>}
{annotation.comment && (
<CommentIcon x={rect.x - 5} y={rect.y - 5} color={annotation.color!}/>
)}
</>;
};

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;
}
43 changes: 27 additions & 16 deletions src/dom/common/dom-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ abstract class DOMView<State extends DOMViewState, Data> {
// 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;

Expand All @@ -177,6 +177,8 @@ abstract class DOMView<State extends DOMViewState, Data> {

protected abstract _getAnnotationFromRange(range: Range, type: AnnotationType, color?: string): NewAnnotation<WADMAnnotation> | null;

protected abstract _getAnnotationFromElement(elem: Element, type: AnnotationType, color?: string): NewAnnotation<WADMAnnotation> | null;

protected abstract _updateViewState(): void;

protected abstract _updateViewStats(): void;
Expand Down Expand Up @@ -316,6 +318,7 @@ abstract class DOMView<State extends DOMViewState, Data> {
type: 'highlight',
color: SELECTION_COLOR,
key: '_highlightedPosition',
position: this._highlightedPosition,
range: this.toDisplayedRange(this._highlightedPosition)!,
});
}
Expand All @@ -328,6 +331,7 @@ abstract class DOMView<State extends DOMViewState, Data> {
text: this._previewAnnotation.text,
comment: this._previewAnnotation.comment,
key: '_previewAnnotation',
position: this._previewAnnotation.position,
range: this.toDisplayedRange(this._previewAnnotation.position)!,
});
}
Expand Down Expand Up @@ -480,10 +484,25 @@ abstract class DOMView<State extends DOMViewState, Data> {
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) {
Expand Down Expand Up @@ -523,20 +542,10 @@ abstract class DOMView<State extends DOMViewState, Data> {

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);
Expand Down Expand Up @@ -865,10 +874,12 @@ abstract class DOMView<State extends DOMViewState, Data> {
// 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.
Expand Down Expand Up @@ -968,7 +979,7 @@ abstract class DOMView<State extends DOMViewState, Data> {
// 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();
Expand Down
1 change: 1 addition & 0 deletions src/dom/common/find.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
};
Expand Down
Loading

0 comments on commit 64d7be0

Please sign in to comment.