Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

EPUB/snapshot image annotations #111

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.

16 changes: 7 additions & 9 deletions src/common/components/toolbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -194,15 +194,13 @@ function Toolbar(props) {
onClick={() => handleToolClick('text')}
><IconText/></button>
)}
{props.type === 'pdf' && (
<button
tabIndex={-1}
className={cx('toolbar-button area', { active: props.tool.type === 'image' })}
title={intl.formatMessage({ id: 'pdfReader.selectArea' })}
disabled={props.readOnly}
onClick={() => handleToolClick('image')}
><IconImage/></button>
)}
<button
tabIndex={-1}
className={cx('toolbar-button area', { active: props.tool.type === 'image' })}
title={intl.formatMessage({ id: props.type == 'pdf' ? 'pdfReader.selectArea' : 'pdfReader.selectImage' })}
disabled={props.readOnly}
onClick={() => handleToolClick('image')}
><IconImage/></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 @@ -164,6 +170,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 @@ -423,8 +454,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 @@ -466,7 +498,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 @@ -476,13 +508,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 @@ -734,3 +767,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 @@ -192,7 +192,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 @@ -206,6 +206,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 @@ -357,6 +359,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 @@ -369,6 +372,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 @@ -524,10 +528,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 @@ -567,20 +586,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 @@ -960,10 +969,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 @@ -1081,7 +1092,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' ? 'none' : '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
Loading