diff options
Diffstat (limited to 'src/client/views/pdf')
-rw-r--r-- | src/client/views/pdf/PDFAnnotationLayer.tsx | 24 | ||||
-rw-r--r-- | src/client/views/pdf/PDFMenu.scss | 25 | ||||
-rw-r--r-- | src/client/views/pdf/PDFMenu.tsx | 206 | ||||
-rw-r--r-- | src/client/views/pdf/PDFViewer.scss | 44 | ||||
-rw-r--r-- | src/client/views/pdf/PDFViewer.tsx | 586 | ||||
-rw-r--r-- | src/client/views/pdf/Page.tsx | 392 |
6 files changed, 1277 insertions, 0 deletions
diff --git a/src/client/views/pdf/PDFAnnotationLayer.tsx b/src/client/views/pdf/PDFAnnotationLayer.tsx new file mode 100644 index 000000000..1f49e0d2f --- /dev/null +++ b/src/client/views/pdf/PDFAnnotationLayer.tsx @@ -0,0 +1,24 @@ +import React = require("react"); +import { observer } from "mobx-react"; + +interface IAnnotationProps { + +} + +@observer +export class PDFAnnotationLayer extends React.Component { + onPointerDown = (e: React.PointerEvent) => { + if (e.ctrlKey) { + console.log("annotating"); + e.stopPropagation(); + } + } + + render() { + return ( + <div className="pdfAnnotationLayer-cont" style={{ width: "100%", height: "100%", position: "relative", top: "-200%" }} onPointerDown={this.onPointerDown}> + + </div> + ); + } +}
\ No newline at end of file diff --git a/src/client/views/pdf/PDFMenu.scss b/src/client/views/pdf/PDFMenu.scss new file mode 100644 index 000000000..22868082a --- /dev/null +++ b/src/client/views/pdf/PDFMenu.scss @@ -0,0 +1,25 @@ +.pdfMenu-cont { + position: absolute; + z-index: 10000; + height: 35px; + background: #323232; + box-shadow: 3px 3px 3px rgba(0, 0, 0, 0.25); + border-radius: 0px 6px 6px 6px; + overflow: hidden; + display: flex; + + .pdfMenu-button { + background-color: transparent; + width: 35px; + height: 35px; + } + + .pdfMenu-button:hover { + background-color: #121212; + } + + .pdfMenu-dragger { + height: 100%; + transition: width .2s; + } +}
\ No newline at end of file diff --git a/src/client/views/pdf/PDFMenu.tsx b/src/client/views/pdf/PDFMenu.tsx new file mode 100644 index 000000000..39b15fb11 --- /dev/null +++ b/src/client/views/pdf/PDFMenu.tsx @@ -0,0 +1,206 @@ +import React = require("react"); +import "./PDFMenu.scss"; +import { observable, action } from "mobx"; +import { observer } from "mobx-react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { emptyFunction } from "../../../Utils"; +import { Doc } from "../../../new_fields/Doc"; + +@observer +export default class PDFMenu extends React.Component { + static Instance: PDFMenu; + + @observable private _top: number = -300; + @observable private _left: number = -300; + @observable private _opacity: number = 1; + @observable private _transition: string = "opacity 0.5s"; + @observable private _transitionDelay: string = ""; + + @observable public Pinned: boolean = false; + + StartDrag: (e: PointerEvent) => void = emptyFunction; + Highlight: (d: Doc | undefined, color: string | undefined) => void = emptyFunction; + Delete: () => void = emptyFunction; + + @observable public Highlighting: boolean = false; + @observable public Status: "pdf" | "annotation" | "" = ""; + + private _offsetY: number = 0; + private _offsetX: number = 0; + private _mainCont: React.RefObject<HTMLDivElement>; + private _dragging: boolean = false; + + constructor(props: Readonly<{}>) { + super(props); + + PDFMenu.Instance = this; + + this._mainCont = React.createRef(); + } + + pointerDown = (e: React.PointerEvent) => { + document.removeEventListener("pointermove", this.pointerMove); + document.addEventListener("pointermove", this.pointerMove); + document.removeEventListener("pointerup", this.pointerUp); + document.addEventListener("pointerup", this.pointerUp); + + e.stopPropagation(); + e.preventDefault(); + } + + pointerMove = (e: PointerEvent) => { + e.stopPropagation(); + e.preventDefault(); + + if (this._dragging) { + return; + } + + this.StartDrag(e); + this._dragging = true; + } + + pointerUp = (e: PointerEvent) => { + this._dragging = false; + document.removeEventListener("pointermove", this.pointerMove); + document.removeEventListener("pointerup", this.pointerUp); + e.stopPropagation(); + e.preventDefault(); + } + + @action + jumpTo = (x: number, y: number, forceJump: boolean = false) => { + if (!this.Pinned || forceJump) { + this._transition = this._transitionDelay = ""; + this._opacity = 1; + this._left = x; + this._top = y; + } + } + + @action + fadeOut = (forceOut: boolean) => { + if (!this.Pinned) { + if (this._opacity === 0.2) { + this._transition = "opacity 0.1s"; + this._transitionDelay = ""; + this._opacity = 0; + this._left = this._top = -300; + } + + if (forceOut) { + this._transition = ""; + this._transitionDelay = ""; + this._opacity = 0; + this._left = this._top = -300; + } + } + } + + @action + pointerLeave = (e: React.PointerEvent) => { + if (!this.Pinned) { + this._transition = "opacity 0.5s"; + this._transitionDelay = "1s"; + this._opacity = 0.2; + setTimeout(() => this.fadeOut(false), 3000); + } + } + + @action + pointerEntered = (e: React.PointerEvent) => { + this._transition = "opacity 0.1s"; + this._transitionDelay = ""; + this._opacity = 1; + } + + @action + togglePin = (e: React.MouseEvent) => { + this.Pinned = !this.Pinned; + if (!this.Pinned) { + this.Highlighting = false; + } + } + + @action + dragging = (e: PointerEvent) => { + this._left = e.pageX - this._offsetX; + this._top = e.pageY - this._offsetY; + + e.stopPropagation(); + e.preventDefault(); + } + + dragEnd = (e: PointerEvent) => { + document.removeEventListener("pointermove", this.dragging); + document.removeEventListener("pointerup", this.dragEnd); + e.stopPropagation(); + e.preventDefault(); + } + + dragStart = (e: React.PointerEvent) => { + document.removeEventListener("pointermove", this.dragging); + document.addEventListener("pointermove", this.dragging); + document.removeEventListener("pointerup", this.dragEnd); + document.addEventListener("pointerup", this.dragEnd); + + this._offsetX = this._mainCont.current!.getBoundingClientRect().width - e.nativeEvent.offsetX; + this._offsetY = e.nativeEvent.offsetY; + + e.stopPropagation(); + e.preventDefault(); + } + + @action + highlightClicked = (e: React.MouseEvent) => { + if (!this.Pinned) { + this.Highlight(undefined, "#f4f442"); + } + else { + this.Highlighting = !this.Highlighting; + this.Highlight(undefined, "#f4f442"); + } + } + + deleteClicked = (e: React.PointerEvent) => { + this.Delete(); + } + + handleContextMenu = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + } + + render() { + let buttons = this.Status === "pdf" ? [ + <button className="pdfMenu-button" title="Click to Highlight" onClick={this.highlightClicked} + style={this.Highlighting ? { backgroundColor: "#121212" } : {}}> + <FontAwesomeIcon icon="highlighter" size="lg" style={{ transition: "transform 0.1s", transform: this.Highlighting ? "" : "rotate(-45deg)" }} /> + </button>, + <button className="pdfMenu-button" title="Drag to Annotate" onPointerDown={this.pointerDown}><FontAwesomeIcon icon="comment-alt" size="lg" /></button>, + <button className="pdfMenu-button" title="Pin Menu" onClick={this.togglePin} + style={this.Pinned ? { backgroundColor: "#121212" } : {}}> + <FontAwesomeIcon icon="thumbtack" size="lg" style={{ transition: "transform 0.1s", transform: this.Pinned ? "rotate(45deg)" : "" }} /> + </button> + ] : [ + <button className="pdfMenu-button" title="Delete Anchor" onPointerDown={this.deleteClicked}><FontAwesomeIcon icon="trash-alt" size="lg" /></button> + ]; + + return ( + <div className="pdfMenu-cont" onPointerLeave={this.pointerLeave} onPointerEnter={this.pointerEntered} ref={this._mainCont} onContextMenu={this.handleContextMenu} + style={{ left: this._left, top: this._top, opacity: this._opacity, transition: this._transition, transitionDelay: this._transitionDelay }}> + {buttons} + {/* <button className="pdfMenu-button" title="Highlight" onClick={this.highlightClicked} + style={this.Highlighting ? { backgroundColor: "#121212" } : {}}> + <FontAwesomeIcon icon="highlighter" size="lg" style={{ transition: "transform 0.1s", transform: this.Highlighting ? "" : "rotate(-45deg)" }} /> + </button> + <button className="pdfMenu-button" title="Annotate" onPointerDown={this.pointerDown}><FontAwesomeIcon icon="comment-alt" size="lg" /></button> + <button className="pdfMenu-button" title="Pin Menu" onClick={this.togglePin} + style={this._pinned ? { backgroundColor: "#121212" } : {}}> + <FontAwesomeIcon icon="thumbtack" size="lg" style={{ transition: "transform 0.1s", transform: this._pinned ? "rotate(45deg)" : "" }} /> + </button> */} + <div className="pdfMenu-dragger" onPointerDown={this.dragStart} style={{ width: this.Pinned ? "20px" : "0px" }} /> + </div > + ); + } +}
\ No newline at end of file diff --git a/src/client/views/pdf/PDFViewer.scss b/src/client/views/pdf/PDFViewer.scss new file mode 100644 index 000000000..53c33ce0b --- /dev/null +++ b/src/client/views/pdf/PDFViewer.scss @@ -0,0 +1,44 @@ +.textLayer { + div { + user-select: text; + } +} + +.viewer-button-cont { + position: absolute; + display: flex; + justify-content: space-evenly; + align-items: center; +} + +.viewer-previousPage, +.viewer-nextPage { + background: grey; + font-weight: bold; + opacity: 0.5; + padding: 0 10px; + border-radius: 5px; +} + +.textLayer { + user-select: auto; +} + +.pdfViewer-annotationBox { + position: absolute; + background-color: red; + opacity: 0.1; +} + +.pdfViewer-annotationLayer { + position: absolute; + top: 0; +} + + + +.pdfViewer-pinAnnotation { + background-color: red; + position: absolute; + border-radius: 100%; +}
\ No newline at end of file diff --git a/src/client/views/pdf/PDFViewer.tsx b/src/client/views/pdf/PDFViewer.tsx new file mode 100644 index 000000000..7000352e7 --- /dev/null +++ b/src/client/views/pdf/PDFViewer.tsx @@ -0,0 +1,586 @@ +import { action, computed, IReactionDisposer, observable, reaction, runInAction } from "mobx"; +import { observer } from "mobx-react"; +import * as Pdfjs from "pdfjs-dist"; +import "pdfjs-dist/web/pdf_viewer.css"; +import * as rp from "request-promise"; +import { Dictionary } from "typescript-collections"; +import { Doc, DocListCast, HeightSym, Opt, WidthSym } from "../../../new_fields/Doc"; +import { Id } from "../../../new_fields/FieldSymbols"; +import { List } from "../../../new_fields/List"; +import { BoolCast, Cast, NumCast, StrCast, FieldValue } from "../../../new_fields/Types"; +import { emptyFunction } from "../../../Utils"; +import { DocServer } from "../../DocServer"; +import { Docs, DocUtils } from "../../documents/Documents"; +import { DocumentManager } from "../../util/DocumentManager"; +import { DragManager } from "../../util/DragManager"; +import { DocumentView } from "../nodes/DocumentView"; +import { PDFBox } from "../nodes/PDFBox"; +import Page from "./Page"; +import "./PDFViewer.scss"; +import React = require("react"); +import PDFMenu from "./PDFMenu"; +import { UndoManager } from "../../util/UndoManager"; + +export const scale = 2; +interface IPDFViewerProps { + url: string; + loaded: (nw: number, nh: number, np: number) => void; + scrollY: number; + parent: PDFBox; +} + +/** + * Wrapper that loads the PDF and cascades the pdf down + */ +@observer +export class PDFViewer extends React.Component<IPDFViewerProps> { + @observable _pdf: Opt<Pdfjs.PDFDocumentProxy>; + private _mainDiv = React.createRef<HTMLDivElement>(); + + @action + componentDidMount() { + Pdfjs.getDocument(this.props.url).promise.then(pdf => runInAction(() => this._pdf = pdf)); + } + + render() { + return ( + <div ref={this._mainDiv}> + {!this._pdf ? (null) : + <Viewer pdf={this._pdf} loaded={this.props.loaded} scrollY={this.props.scrollY} parent={this.props.parent} mainCont={this._mainDiv} url={this.props.url} />} + </div> + ); + } +} + +interface IViewerProps { + pdf: Pdfjs.PDFDocumentProxy; + loaded: (nw: number, nh: number, np: number) => void; + scrollY: number; + parent: PDFBox; + mainCont: React.RefObject<HTMLDivElement>; + url: string; +} + +const PinRadius = 25; + +/** + * Handles rendering and virtualization of the pdf + */ +@observer +class Viewer extends React.Component<IViewerProps> { + // _visibleElements is the array of JSX elements that gets rendered + @observable.shallow private _visibleElements: JSX.Element[] = []; + // _isPage is an array that tells us whether or not an index is rendered as a page or as a placeholder + @observable private _isPage: string[] = []; + @observable private _pageSizes: { width: number, height: number }[] = []; + @observable private _annotations: Doc[] = []; + @observable private _savedAnnotations: Dictionary<number, HTMLDivElement[]> = new Dictionary<number, HTMLDivElement[]>(); + + private _pageBuffer: number = 1; + private _annotationLayer: React.RefObject<HTMLDivElement> = React.createRef(); + private _reactionDisposer?: IReactionDisposer; + private _annotationReactionDisposer?: IReactionDisposer; + private _dropDisposer?: DragManager.DragDropDisposer; + + componentDidUpdate = (prevProps: IViewerProps) => { + if (this.scrollY !== prevProps.scrollY) { + this.renderPages(); + } + } + + @action + componentDidMount = () => { + this._reactionDisposer = reaction( + + () => [this.props.parent.props.active(), this.startIndex, this._pageSizes.length ? this.endIndex : 0], + async () => { + await this.initialLoad(); + this.renderPages(); + }, { fireImmediately: true }); + + this._annotationReactionDisposer = reaction( + () => this.props.parent.Document && DocListCast(this.props.parent.Document.annotations), + (annotations: Doc[]) => + annotations && annotations.length && this.renderAnnotations(annotations, true), + { fireImmediately: true }); + } + + componentWillUnmount = () => { + this._reactionDisposer && this._reactionDisposer(); + this._annotationReactionDisposer && this._annotationReactionDisposer(); + } + + @action + initialLoad = async () => { + if (this._pageSizes.length === 0) { + let pageSizes = Array<{ width: number, height: number }>(this.props.pdf.numPages); + this._isPage = Array<string>(this.props.pdf.numPages); + for (let i = 0; i < this.props.pdf.numPages; i++) { + await this.props.pdf.getPage(i + 1).then(page => runInAction(() => { + // pageSizes[i] = { width: page.view[2] * scale, height: page.view[3] * scale }; + let x = page.getViewport(scale); + pageSizes[i] = { width: x.width, height: x.height }; + })); + } + runInAction(() => + Array.from(Array((this._pageSizes = pageSizes).length).keys()).map(this.getPlaceholderPage)); + this.props.loaded(Math.max(...pageSizes.map(i => i.width)), pageSizes[0].height, this.props.pdf.numPages); + // this.props.loaded(Math.max(...pageSizes.map(i => i.width)), pageSizes[0].height, this.props.pdf.numPages); + } + } + + private mainCont = (div: HTMLDivElement | null) => { + this._dropDisposer && this._dropDisposer(); + if (div) { + this._dropDisposer = div && DragManager.MakeDropTarget(div, { handlers: { drop: this.drop.bind(this) } }); + } + } + + makeAnnotationDocument = (sourceDoc: Doc | undefined, s: number, color: string): Doc => { + let annoDocs: Doc[] = []; + let mainAnnoDoc = new Doc(); + this._savedAnnotations.forEach((key: number, value: HTMLDivElement[]) => { + for (let anno of value) { + let annoDoc = new Doc(); + if (anno.style.left) annoDoc.x = parseInt(anno.style.left) / scale; + if (anno.style.top) annoDoc.y = parseInt(anno.style.top) / scale; + if (anno.style.height) annoDoc.height = parseInt(anno.style.height) / scale; + if (anno.style.width) annoDoc.width = parseInt(anno.style.width) / scale; + annoDoc.page = key; + annoDoc.target = sourceDoc; + annoDoc.group = mainAnnoDoc; + annoDoc.color = color; + annoDoc.type = AnnotationTypes.Region; + annoDocs.push(annoDoc); + anno.remove(); + } + }); + + mainAnnoDoc.annotations = new List<Doc>(annoDocs); + if (sourceDoc) { + DocUtils.MakeLink(sourceDoc, mainAnnoDoc, undefined, `Annotation from ${StrCast(this.props.parent.Document.title)}`, "", StrCast(this.props.parent.Document.title)); + } + this._savedAnnotations.clear(); + return mainAnnoDoc; + } + + drop = async (e: Event, de: DragManager.DropEvent) => { + if (de.data instanceof DragManager.LinkDragData) { + let sourceDoc = de.data.linkSourceDocument; + let destDoc = this.makeAnnotationDocument(sourceDoc, 1, "red"); + let targetAnnotations = DocListCast(this.props.parent.Document.annotations); + if (targetAnnotations) { + targetAnnotations.push(destDoc); + this.props.parent.Document.annotations = new List<Doc>(targetAnnotations); + } + else { + this.props.parent.Document.annotations = new List<Doc>([destDoc]); + } + e.stopPropagation(); + } + } + /** + * Called by the Page class when it gets rendered, initializes the lists and + * puts a placeholder with all of the correct page sizes when all of the pages have been loaded. + */ + @action + pageLoaded = (index: number, page: Pdfjs.PDFPageViewport): void => { + this.props.loaded(page.width, page.height, this.props.pdf.numPages); + } + @action + getPlaceholderPage = (page: number) => { + if (this._isPage[page] !== "none") { + this._isPage[page] = "none"; + this._visibleElements[page] = ( + <div key={`${this.props.url}-placeholder-${page + 1}`} className="pdfviewer-placeholder" + style={{ width: this._pageSizes[page].width, height: this._pageSizes[page].height }} /> + ); + } + } + @action + getRenderedPage = (page: number) => { + if (this._isPage[page] !== "page") { + this._isPage[page] = "page"; + this._visibleElements[page] = ( + <Page + pdf={this.props.pdf} + page={page} + numPages={this.props.pdf.numPages} + key={`${this.props.url}-rendered-${page + 1}`} + name={`${this.props.pdf.fingerprint + `-page${page + 1}`}`} + pageLoaded={this.pageLoaded} + parent={this.props.parent} + makePin={emptyFunction} + renderAnnotations={this.renderAnnotations} + createAnnotation={this.createAnnotation} + sendAnnotations={this.receiveAnnotations} + makeAnnotationDocuments={this.makeAnnotationDocument} + receiveAnnotations={this.sendAnnotations} + {...this.props} /> + ); + } + } + + // change the address to be the file address of the PNG version of each page + // file address of the pdf + @action + getPageImage = async (page: number) => { + let handleError = () => this.getRenderedPage(page); + if (this._isPage[page] !== "image") { + this._isPage[page] = "image"; + const address = this.props.url; + let res = JSON.parse(await rp.get(DocServer.prepend(`/thumbnail${address.substring("files/".length, address.length - ".pdf".length)}-${page + 1}.PNG`))); + runInAction(() => this._visibleElements[page] = + <img key={res.path} src={res.path} onError={handleError} + style={{ width: `${parseInt(res.width) * scale}px`, height: `${parseInt(res.height) * scale}px` }} />); + } + } + + @computed get scrollY(): number { return this.props.scrollY; } + + // startIndex: where to start rendering pages + @computed get startIndex(): number { return Math.max(0, this.getPageFromScroll(this.scrollY) - this._pageBuffer); } + + // endIndex: where to end rendering pages + @computed get endIndex(): number { + return Math.min(this.props.pdf.numPages - 1, this.getPageFromScroll(this.scrollY + this._pageSizes[0].height) + this._pageBuffer); + } + + @action + renderPages = () => { + for (let i = 0; i < this.props.pdf.numPages; i++) { + if (i < this.startIndex || i > this.endIndex) { + this.getPlaceholderPage(i); // pages outside of the pdf use empty stand-in divs + } else { + if (this.props.parent.props.active()) { + this.getRenderedPage(i); + } else { + this.getPageImage(i); + } + } + } + } + + @action + private renderAnnotations = (annotations: Doc[], removeOldAnnotations: boolean): void => { + if (removeOldAnnotations) { + this._annotations = annotations; + } + else { + this._annotations.push(...annotations); + this._annotations = new Array<Doc>(...this._annotations); + } + } + + @action + receiveAnnotations = (annotations: HTMLDivElement[], page: number) => { + if (page === -1) { + this._savedAnnotations.values().forEach(v => v.forEach(a => a.remove())); + this._savedAnnotations.keys().forEach(k => this._savedAnnotations.setValue(k, annotations)); + } + else { + this._savedAnnotations.setValue(page, annotations); + } + } + + sendAnnotations = (page: number): HTMLDivElement[] | undefined => { + return this._savedAnnotations.getValue(page); + } + + // createPinAnnotation = (x: number, y: number, page: number): void => { + // let targetDoc = Docs.TextDocument({ width: 100, height: 50, title: "New Pin Annotation" }); + // let pinAnno = new Doc(); + // pinAnno.x = x; + // pinAnno.y = y + this.getScrollFromPage(page); + // pinAnno.width = pinAnno.height = PinRadius; + // pinAnno.page = page; + // pinAnno.target = targetDoc; + // pinAnno.type = AnnotationTypes.Pin; + // // this._annotations.push(pinAnno); + // let annoDoc = new Doc(); + // annoDoc.annotations = new List<Doc>([pinAnno]); + // let annotations = DocListCast(this.props.parent.Document.annotations); + // if (annotations && annotations.length) { + // annotations.push(annoDoc); + // this.props.parent.Document.annotations = new List<Doc>(annotations); + // } + // else { + // this.props.parent.Document.annotations = new List<Doc>([annoDoc]); + // } + // } + + // get the page index that the vertical offset passed in is on + getPageFromScroll = (vOffset: number) => { + let index = 0; + let currOffset = vOffset; + while (index < this._pageSizes.length && currOffset - this._pageSizes[index].height > 0) { + currOffset -= this._pageSizes[index++].height; + } + return index; + } + + getScrollFromPage = (index: number): number => { + let counter = 0; + for (let i = 0; i < Math.min(this.props.pdf.numPages, index); i++) { + counter += this._pageSizes[i].height; + } + return counter; + } + + createAnnotation = (div: HTMLDivElement, page: number) => { + if (this._annotationLayer.current) { + if (div.style.top) { + div.style.top = (parseInt(div.style.top) + this.getScrollFromPage(page)).toString(); + } + this._annotationLayer.current.append(div); + let savedPage = this._savedAnnotations.getValue(page); + if (savedPage) { + savedPage.push(div); + this._savedAnnotations.setValue(page, savedPage); + } + else { + this._savedAnnotations.setValue(page, [div]); + } + } + } + + renderAnnotation = (anno: Doc): JSX.Element[] => { + let annotationDocs = DocListCast(anno.annotations); + let res = annotationDocs.map(a => { + let type = NumCast(a.type); + switch (type) { + // case AnnotationTypes.Pin: + // return <PinAnnotation parent={this} document={a} x={NumCast(a.x)} y={NumCast(a.y)} width={a[WidthSym]()} height={a[HeightSym]()} key={a[Id]} />; + case AnnotationTypes.Region: + return <RegionAnnotation parent={this} document={a} x={NumCast(a.x)} y={NumCast(a.y)} width={a[WidthSym]()} height={a[HeightSym]()} key={a[Id]} />; + default: + return <div></div>; + } + }); + return res; + } + + render() { + return ( + <div ref={this.mainCont} style={{ pointerEvents: "all" }}> + <div className="viewer"> + {this._visibleElements} + </div> + <div className="pdfViewer-annotationLayer" + style={{ + height: this.props.parent.Document.nativeHeight, width: `100%`, + pointerEvents: this.props.parent.props.active() ? "none" : "all" + }}> + <div className="pdfViewer-annotationLayer-subCont" ref={this._annotationLayer}> + {this._annotations.map(anno => this.renderAnnotation(anno))} + </div> + </div> + </div > + ); + } +} + +export enum AnnotationTypes { + Region +} + +interface IAnnotationProps { + x: number; + y: number; + width: number; + height: number; + parent: Viewer; + document: Doc; +} + +// @observer +// class PinAnnotation extends React.Component<IAnnotationProps> { +// @observable private _backgroundColor: string = "green"; +// @observable private _display: string = "initial"; + +// private _mainCont: React.RefObject<HTMLDivElement>; + +// constructor(props: IAnnotationProps) { +// super(props); +// this._mainCont = React.createRef(); +// } + +// componentDidMount = () => { +// let selected = this.props.document.selected; +// if (!BoolCast(selected)) { +// runInAction(() => { +// this._backgroundColor = "red"; +// this._display = "none"; +// }); +// } +// if (selected) { +// if (BoolCast(selected)) { +// runInAction(() => { +// this._backgroundColor = "green"; +// this._display = "initial"; +// }); +// } +// else { +// runInAction(() => { +// this._backgroundColor = "red"; +// this._display = "none"; +// }); +// } +// } +// else { +// runInAction(() => { +// this._backgroundColor = "red"; +// this._display = "none"; +// }); +// } +// } + +// @action +// pointerDown = (e: React.PointerEvent) => { +// let selected = this.props.document.selected; +// if (selected && BoolCast(selected)) { +// this._backgroundColor = "red"; +// this._display = "none"; +// this.props.document.selected = false; +// } +// else { +// this._backgroundColor = "green"; +// this._display = "initial"; +// this.props.document.selected = true; +// } +// e.preventDefault(); +// e.stopPropagation(); +// } + +// @action +// doubleClick = (e: React.MouseEvent) => { +// if (this._mainCont.current) { +// let annotations = DocListCast(this.props.parent.props.parent.Document.annotations); +// if (annotations && annotations.length) { +// let index = annotations.indexOf(this.props.document); +// annotations.splice(index, 1); +// this.props.parent.props.parent.Document.annotations = new List<Doc>(annotations); +// } +// // this._mainCont.current.childNodes.forEach(e => e.remove()); +// this._mainCont.current.style.display = "none"; +// // if (this._mainCont.current.parentElement) { +// // this._mainCont.current.remove(); +// // } +// } +// e.stopPropagation(); +// } + +// render() { +// let targetDoc = Cast(this.props.document.target, Doc); +// if (targetDoc instanceof Doc) { +// return ( +// <div className="pdfViewer-pinAnnotation" onPointerDown={this.pointerDown} +// onDoubleClick={this.doubleClick} ref={this._mainCont} +// style={{ +// top: this.props.y * scale - PinRadius / 2, left: this.props.x * scale - PinRadius / 2, width: PinRadius, +// height: PinRadius, pointerEvents: "all", backgroundColor: this._backgroundColor +// }}> +// <div style={{ +// position: "absolute", top: "25px", left: "25px", transform: "scale(3)", transformOrigin: "top left", +// display: this._display, width: targetDoc[WidthSym](), height: targetDoc[HeightSym]() +// }}> +// <DocumentView Document={targetDoc} +// ContainingCollectionView={undefined} +// ScreenToLocalTransform={this.props.parent.props.parent.props.ScreenToLocalTransform} +// isTopMost={false} +// ContentScaling={() => 1} +// PanelWidth={() => NumCast(this.props.parent.props.parent.Document.nativeWidth)} +// PanelHeight={() => NumCast(this.props.parent.props.parent.Document.nativeHeight)} +// focus={emptyFunction} +// selectOnLoad={false} +// parentActive={this.props.parent.props.parent.props.active} +// whenActiveChanged={this.props.parent.props.parent.props.whenActiveChanged} +// bringToFront={emptyFunction} +// addDocTab={this.props.parent.props.parent.props.addDocTab} +// /> +// </div> +// </div > +// ); +// } +// return null; +// } +// } + +class RegionAnnotation extends React.Component<IAnnotationProps> { + @observable private _backgroundColor: string = "red"; + + private _reactionDisposer?: IReactionDisposer; + private _mainCont: React.RefObject<HTMLDivElement>; + + constructor(props: IAnnotationProps) { + super(props); + + this._mainCont = React.createRef(); + } + + componentDidMount() { + this._reactionDisposer = reaction( + () => BoolCast(this.props.document.delete), + () => { + if (BoolCast(this.props.document.delete)) { + if (this._mainCont.current) { + this._mainCont.current.style.display = "none"; + } + } + }, + { fireImmediately: true } + ); + } + + componentWillUnmount() { + this._reactionDisposer && this._reactionDisposer(); + } + + deleteAnnotation = () => { + let annotation = DocListCast(this.props.parent.props.parent.Document.annotations); + let group = FieldValue(Cast(this.props.document.group, Doc)); + if (group && annotation.indexOf(group) !== -1) { + let newAnnotations = annotation.filter(a => a !== FieldValue(Cast(this.props.document.group, Doc))); + this.props.parent.props.parent.Document.annotations = new List<Doc>(newAnnotations); + } + + if (group) { + let groupAnnotations = DocListCast(group.annotations); + groupAnnotations.forEach(anno => anno.delete = true); + } + + PDFMenu.Instance.fadeOut(true); + } + + + // annotateThis = (e: PointerEvent) => { + // e.preventDefault(); + // e.stopPropagation(); + // // document that this annotation is linked to + // let targetDoc = Docs.TextDocument({ width: 200, height: 200, title: "New Annotation" }); + // let group = FieldValue(Cast(this.props.document.group, Doc)); + // } + + @action + onPointerDown = (e: React.PointerEvent) => { + if (e.button === 0) { + let targetDoc = Cast(this.props.document.target, Doc, null); + if (targetDoc) { + DocumentManager.Instance.jumpToDocument(targetDoc); + } + } + if (e.button === 2) { + PDFMenu.Instance.Status = "annotation"; + PDFMenu.Instance.Delete = this.deleteAnnotation; + PDFMenu.Instance.Pinned = false; + PDFMenu.Instance.jumpTo(e.clientX, e.clientY, true); + } + } + + render() { + return ( + <div className="pdfViewer-annotationBox" onPointerDown={this.onPointerDown} ref={this._mainCont} + style={{ top: this.props.y * scale, left: this.props.x * scale, width: this.props.width * scale, height: this.props.height * scale, pointerEvents: "all", backgroundColor: StrCast(this.props.document.color) }}></div> + ); + } +}
\ No newline at end of file diff --git a/src/client/views/pdf/Page.tsx b/src/client/views/pdf/Page.tsx new file mode 100644 index 000000000..734dff7fc --- /dev/null +++ b/src/client/views/pdf/Page.tsx @@ -0,0 +1,392 @@ +import { observer } from "mobx-react"; +import React = require("react"); +import { observable, action, runInAction, IReactionDisposer, reaction } from "mobx"; +import * as Pdfjs from "pdfjs-dist"; +import { Opt, Doc, FieldResult, Field, DocListCast, WidthSym, HeightSym } from "../../../new_fields/Doc"; +import "./PDFViewer.scss"; +import "pdfjs-dist/web/pdf_viewer.css"; +import { PDFBox } from "../nodes/PDFBox"; +import { DragManager } from "../../util/DragManager"; +import { Docs, DocUtils } from "../../documents/Documents"; +import { List } from "../../../new_fields/List"; +import { emptyFunction } from "../../../Utils"; +import { Cast, NumCast, StrCast } from "../../../new_fields/Types"; +import { listSpec } from "../../../new_fields/Schema"; +import { menuBar } from "prosemirror-menu"; +import { AnnotationTypes, PDFViewer, scale } from "./PDFViewer"; +import PDFMenu from "./PDFMenu"; +import { UndoManager } from "../../util/UndoManager"; + + +interface IPageProps { + pdf: Opt<Pdfjs.PDFDocumentProxy>; + name: string; + numPages: number; + page: number; + pageLoaded: (index: number, page: Pdfjs.PDFPageViewport) => void; + parent: PDFBox; + renderAnnotations: (annotations: Doc[], removeOld: boolean) => void; + makePin: (x: number, y: number, page: number) => void; + sendAnnotations: (annotations: HTMLDivElement[], page: number) => void; + receiveAnnotations: (page: number) => HTMLDivElement[] | undefined; + createAnnotation: (div: HTMLDivElement, page: number) => void; + makeAnnotationDocuments: (doc: Doc | undefined, scale: number, color: string) => Doc; +} + +@observer +export default class Page extends React.Component<IPageProps> { + @observable private _state: string = "N/A"; + @observable private _width: number = 0; + @observable private _height: number = 0; + @observable private _page: Opt<Pdfjs.PDFPageProxy>; + @observable private _currPage: number = this.props.page + 1; + @observable private _marqueeX: number = 0; + @observable private _marqueeY: number = 0; + @observable private _marqueeWidth: number = 0; + @observable private _marqueeHeight: number = 0; + @observable private _rotate: string = ""; + + private _canvas: React.RefObject<HTMLCanvasElement>; + private _textLayer: React.RefObject<HTMLDivElement>; + private _annotationLayer: React.RefObject<HTMLDivElement>; + private _marquee: React.RefObject<HTMLDivElement>; + private _curly: React.RefObject<HTMLImageElement>; + private _marqueeing: boolean = false; + private _reactionDisposer?: IReactionDisposer; + + constructor(props: IPageProps) { + super(props); + this._canvas = React.createRef(); + this._textLayer = React.createRef(); + this._annotationLayer = React.createRef(); + this._marquee = React.createRef(); + this._curly = React.createRef(); + } + + componentDidMount = (): void => { + if (this.props.pdf) { + this.update(this.props.pdf); + } + } + + componentWillUnmount = (): void => { + if (this._reactionDisposer) { + this._reactionDisposer(); + } + } + + componentDidUpdate = (): void => { + if (this.props.pdf) { + this.update(this.props.pdf); + } + } + + private update = (pdf: Pdfjs.PDFDocumentProxy): void => { + if (pdf) { + this.loadPage(pdf); + } + else { + this._state = "loading"; + } + } + + private loadPage = (pdf: Pdfjs.PDFDocumentProxy): void => { + if (this._state === "rendering" || this._page) return; + + pdf.getPage(this._currPage).then( + (page: Pdfjs.PDFPageProxy): void => { + this._state = "rendering"; + this.renderPage(page); + } + ); + } + + @action + private renderPage = (page: Pdfjs.PDFPageProxy): void => { + // lower scale = easier to read at small sizes, higher scale = easier to read at large sizes + let viewport = page.getViewport(scale); + let canvas = this._canvas.current; + let textLayer = this._textLayer.current; + if (canvas && textLayer) { + let ctx = canvas.getContext("2d"); + canvas.width = viewport.width; + this._width = viewport.width; + canvas.height = viewport.height; + this._height = viewport.height; + this.props.pageLoaded(this._currPage, viewport); + if (ctx) { + // renders the page onto the canvas context + page.render({ canvasContext: ctx, viewport: viewport }); + // renders text onto the text container + page.getTextContent().then((res: Pdfjs.TextContent): void => { + //@ts-ignore + Pdfjs.renderTextLayer({ + textContent: res, + container: textLayer, + viewport: viewport + }); + }); + + this._page = page; + } + } + } + + @action + highlight = (targetDoc?: Doc, color: string = "red") => { + // creates annotation documents for current highlights + let annotationDoc = this.props.makeAnnotationDocuments(targetDoc, scale, color); + let targetAnnotations = Cast(this.props.parent.Document.annotations, listSpec(Doc)); + if (targetAnnotations === undefined) { + Doc.GetProto(this.props.parent.Document).annotations = new List([annotationDoc]); + } else { + targetAnnotations.push(annotationDoc); + } + return annotationDoc; + } + + /** + * This is temporary for creating annotations from highlights. It will + * start a drag event and create or put the necessary info into the drag event. + */ + @action + startDrag = (e: PointerEvent): void => { + e.preventDefault(); + e.stopPropagation(); + let thisDoc = this.props.parent.Document; + // document that this annotation is linked to + let targetDoc = Docs.TextDocument({ width: 200, height: 200, title: "New Annotation" }); + targetDoc.targetPage = this.props.page; + let annotationDoc = this.highlight(targetDoc, "red"); + // create dragData and star tdrag + let dragData = new DragManager.AnnotationDragData(thisDoc, annotationDoc, targetDoc); + if (this._textLayer.current) { + DragManager.StartAnnotationDrag([this._textLayer.current], dragData, e.pageX, e.pageY, { + handlers: { + dragComplete: emptyFunction, + }, + hideSource: false + }); + } + } + + // cleans up events and boolean + endDrag = (e: PointerEvent): void => { + // document.removeEventListener("pointermove", this.startDrag); + // document.removeEventListener("pointerup", this.endDrag); + e.stopPropagation(); + } + + @action + onPointerDown = (e: React.PointerEvent): void => { + // if alt+left click, drag and annotate + if (e.altKey && e.button === 0) { + e.stopPropagation(); + + // document.removeEventListener("pointermove", this.startDrag); + // document.addEventListener("pointermove", this.startDrag); + // document.removeEventListener("pointerup", this.endDrag); + // document.addEventListener("pointerup", this.endDrag); + } + else if (e.button === 0) { + PDFMenu.Instance.StartDrag = this.startDrag; + PDFMenu.Instance.Highlight = this.highlight; + PDFMenu.Instance.Status = "pdf"; + PDFMenu.Instance.fadeOut(true); + let target: any = e.target; + if (target && target.parentElement === this._textLayer.current) { + e.stopPropagation(); + } + else { + // set marquee x and y positions to the spatially transformed position + let current = this._textLayer.current; + if (current) { + let boundingRect = current.getBoundingClientRect(); + this._marqueeX = (e.clientX - boundingRect.left) * (current.offsetWidth / boundingRect.width); + this._marqueeY = (e.clientY - boundingRect.top) * (current.offsetHeight / boundingRect.height); + } + this._marqueeing = true; + if (this._marquee.current) this._marquee.current.style.opacity = "0.2"; + } + document.removeEventListener("pointermove", this.onSelectStart); + document.addEventListener("pointermove", this.onSelectStart); + document.removeEventListener("pointerup", this.onSelectEnd); + document.addEventListener("pointerup", this.onSelectEnd); + if (!e.ctrlKey) { + this.props.sendAnnotations([], -1); + } + } + } + + @action + onSelectStart = (e: PointerEvent): void => { + let target: any = e.target; + if (this._marqueeing) { + let current = this._textLayer.current; + if (current) { + // transform positions and find the width and height to set the marquee to + let boundingRect = current.getBoundingClientRect(); + this._marqueeWidth = (e.clientX - boundingRect.left) * (current.offsetWidth / boundingRect.width) - this._marqueeX; + this._marqueeHeight = (e.clientY - boundingRect.top) * (current.offsetHeight / boundingRect.height) - this._marqueeY; + let { background, opacity, transform: transform } = this.getCurlyTransform(); + if (this._marquee.current && this._curly.current) { + this._marquee.current.style.background = background; + this._curly.current.style.opacity = opacity; + this._rotate = transform; + } + } + e.stopPropagation(); + e.preventDefault(); + } + else if (target && target.parentElement === this._textLayer.current) { + e.stopPropagation(); + } + } + + getCurlyTransform = (): { background: string, opacity: string, transform: string } => { + let background = "", opacity = "", transform = ""; + if (this._marquee.current && this._curly.current) { + if (this._marqueeWidth > 100 && this._marqueeHeight > 100) { + background = "red"; + opacity = "0"; + } + else { + background = "transparent"; + opacity = "1"; + } + + // split up for simplicity, could be done in a nested ternary. please do not. -syip2 + let ratio = this._marqueeWidth / this._marqueeHeight; + if (ratio > 1.5) { + // vertical + transform = "rotate(90deg) scale(1, 5)"; + } + else if (ratio < 0.5) { + // horizontal + transform = "scale(2, 1)"; + } + else { + // diagonal + transform = "rotate(45deg) scale(1.5, 1.5)"; + } + } + return { background: background, opacity: opacity, transform: transform }; + } + + @action + onSelectEnd = (e: PointerEvent): void => { + if (this._marqueeing) { + this._marqueeing = false; + if (this._marquee.current) { + let copy = document.createElement("div"); + // make a copy of the marquee + copy.style.left = this._marquee.current.style.left; + copy.style.top = this._marquee.current.style.top; + copy.style.width = this._marquee.current.style.width; + copy.style.height = this._marquee.current.style.height; + + // apply the appropriate background, opacity, and transform + let { background, opacity, transform } = this.getCurlyTransform(); + copy.style.background = background; + // if curly bracing, add a curly brace + if (opacity === "1" && this._curly.current) { + copy.style.opacity = opacity; + let img = this._curly.current.cloneNode(); + (img as any).style.opacity = opacity; + (img as any).style.transform = transform; + copy.appendChild(img); + } + else { + copy.style.opacity = this._marquee.current.style.opacity; + } + copy.className = this._marquee.current.className; + this.props.createAnnotation(copy, this.props.page); + this._marquee.current.style.opacity = "0"; + } + + if (this._marqueeWidth > 10 || this._marqueeHeight > 10) { + PDFMenu.Instance.jumpTo(e.clientX, e.clientY); + } + + this._marqueeHeight = this._marqueeWidth = 0; + } + else { + let sel = window.getSelection(); + if (sel && sel.type === "Range") { + this.createTextAnnotation(sel); + PDFMenu.Instance.jumpTo(e.clientX, e.clientY); + } + } + + + if (PDFMenu.Instance.Highlighting) { + this.highlight(undefined, "#f4f442"); + } + else { + PDFMenu.Instance.StartDrag = this.startDrag; + PDFMenu.Instance.Highlight = this.highlight; + } + document.removeEventListener("pointermove", this.onSelectStart); + document.removeEventListener("pointerup", this.onSelectEnd); + } + + @action + createTextAnnotation = (sel: Selection) => { + let clientRects = sel.getRangeAt(0).getClientRects(); + if (this._textLayer.current) { + let boundingRect = this._textLayer.current.getBoundingClientRect(); + for (let i = 0; i < clientRects.length; i++) { + let rect = clientRects.item(i); + if (rect && rect.width !== this._textLayer.current.getBoundingClientRect().width && rect.height !== this._textLayer.current.getBoundingClientRect().height) { + let annoBox = document.createElement("div"); + annoBox.className = "pdfViewer-annotationBox"; + // transforms the positions from screen onto the pdf div + annoBox.style.top = ((rect.top - boundingRect.top) * (this._textLayer.current.offsetHeight / boundingRect.height)).toString(); + annoBox.style.left = ((rect.left - boundingRect.left) * (this._textLayer.current.offsetWidth / boundingRect.width)).toString(); + annoBox.style.width = (rect.width * this._textLayer.current.offsetWidth / boundingRect.width).toString(); + annoBox.style.height = (rect.height * this._textLayer.current.offsetHeight / boundingRect.height).toString(); + this.props.createAnnotation(annoBox, this.props.page); + } + } + } + // clear selection + if (sel.empty) { // Chrome + sel.empty(); + } else if (sel.removeAllRanges) { // Firefox + sel.removeAllRanges(); + } + } + + doubleClick = (e: React.MouseEvent) => { + let target: any = e.target; + // if double clicking text + if (target && target.parentElement === this._textLayer.current) { + // do something to select the paragraph ideally + } + + let current = this._textLayer.current; + if (current) { + let boundingRect = current.getBoundingClientRect(); + let x = (e.clientX - boundingRect.left) * (current.offsetWidth / boundingRect.width); + let y = (e.clientY - boundingRect.top) * (current.offsetHeight / boundingRect.height); + this.props.makePin(x, y, this.props.page); + } + } + + render() { + return ( + <div onPointerDown={this.onPointerDown} onDoubleClick={this.doubleClick} className={"page-cont"} style={{ "width": this._width, "height": this._height }}> + <div className="canvasContainer"> + <canvas ref={this._canvas} /> + </div> + <div className="pdfInkingLayer-cont" ref={this._annotationLayer} style={{ width: "100%", height: "100%", position: "relative", top: "-100%" }}> + <div className="pdfViewer-annotationBox" ref={this._marquee} + style={{ left: `${this._marqueeX}px`, top: `${this._marqueeY}px`, width: `${this._marqueeWidth}px`, height: `${this._marqueeHeight}px`, background: "transparent" }}> + <img ref={this._curly} src="https://static.thenounproject.com/png/331760-200.png" style={{ width: "100%", height: "100%", transform: `${this._rotate}` }} /> + </div> + </div> + <div className="textlayer" ref={this._textLayer} style={{ "position": "relative", "top": `-${2 * this._height}px`, "height": `${this._height}px` }} /> + </div> + ); + } +} |