import { action, computed, IReactionDisposer, observable, ObservableMap, reaction, runInAction, trace } from "mobx"; import { observer } from "mobx-react"; import * as Pdfjs from "pdfjs-dist"; import "pdfjs-dist/web/pdf_viewer.css"; import { Doc, DocListCast, Field, HeightSym, Opt } from "../../../fields/Doc"; import { Id } from "../../../fields/FieldSymbols"; import { InkTool } from "../../../fields/InkField"; import { Cast, NumCast, StrCast } from "../../../fields/Types"; import { TraceMobx } from "../../../fields/util"; import { addStyleSheet, addStyleSheetRule, clearStyleSheetRules, emptyFunction, OmitKeys, returnFalse, smoothScroll, Utils } from "../../../Utils"; import { DocUtils } from "../../documents/Documents"; import { CurrentUserUtils } from "../../util/CurrentUserUtils"; import { SelectionManager } from "../../util/SelectionManager"; import { SharingManager } from "../../util/SharingManager"; import { SnappingManager } from "../../util/SnappingManager"; import { MarqueeOptionsMenu } from "../collections/collectionFreeForm"; import { CollectionFreeFormView } from "../collections/collectionFreeForm/CollectionFreeFormView"; import { MarqueeAnnotator } from "../MarqueeAnnotator"; import { DocumentViewProps } from "../nodes/DocumentView"; import { FieldViewProps } from "../nodes/FieldView"; import { LinkDocPreview } from "../nodes/LinkDocPreview"; import { StyleProp } from "../StyleProvider"; import { AnchorMenu } from "./AnchorMenu"; import { Annotation } from "./Annotation"; import "./PDFViewer.scss"; import React = require("react"); const PDFJSViewer = require("pdfjs-dist/web/pdf_viewer"); const pdfjsLib = require("pdfjs-dist"); const _global = (window /* browser */ || global /* node */) as any; //pdfjsLib.GlobalWorkerOptions.workerSrc = `/assets/pdf.worker.js`; // The workerSrc property shall be specified. pdfjsLib.GlobalWorkerOptions.workerSrc = "https://unpkg.com/pdfjs-dist@2.13.216/build/pdf.worker.js"; interface IViewerProps extends FieldViewProps { Document: Doc; rootDoc: Doc; dataDoc: Doc; layoutDoc: Doc; fieldKey: string; pdf: Pdfjs.PDFDocumentProxy; url: string; loaded?: (nw: number, nh: number, np: number) => void; setPdfViewer: (view: PDFViewer) => void; ContentScaling?: () => number; anchorMenuClick?: () => undefined | ((anchor: Doc) => void); crop: (region: Doc | undefined, addCrop?: boolean) => Doc | undefined; } /** * Handles rendering and virtualization of the pdf */ @observer export class PDFViewer extends React.Component { static _annotationStyle: any = addStyleSheet(); @observable private _pageSizes: { width: number, height: number }[] = []; @observable private _savedAnnotations = new ObservableMap(); @observable private _marqueeing: number[] | undefined; @observable private _textSelecting = true; @observable private _showWaiting = true; @observable private _overlayAnnoInfo: Opt; @observable private Index: number = -1; private _pdfViewer: any; private _styleRule: any; // stylesheet rule for making hyperlinks clickable private _retries = 0; // number of times tried to create the PDF viewer private _setPreviewCursor: undefined | ((x: number, y: number, drag: boolean, hide: boolean) => void); private _annotationLayer: React.RefObject = React.createRef(); private _disposers: { [name: string]: IReactionDisposer } = {}; private _viewer: React.RefObject = React.createRef(); public _getAnchor: (savedAnnotations?: ObservableMap) => Opt = () => undefined; _mainCont: React.RefObject = React.createRef(); private _selectionText: string = ""; private _downX: number = 0; private _downY: number = 0; private _lastSearch = false; private _viewerIsSetup = false; private _ignoreScroll = false; private _initialScroll: Opt; private _forcedScroll = true; @observable isAnnotating = false; // key where data is stored @computed get allAnnotations() { return DocUtils.FilterDocs(DocListCast(this.props.dataDoc[this.props.fieldKey + "-annotations"]), this.props.docFilters(), this.props.docRangeFilters(), undefined); } @computed get inlineTextAnnotations() { return this.allAnnotations.filter(a => a.textInlineAnnotations); } componentDidMount = async () => { runInAction(() => this._showWaiting = true); this.setupPdfJsViewer(); this._mainCont.current?.addEventListener("scroll", e => (e.target as any).scrollLeft = 0); this._disposers.autoHeight = reaction(() => this.props.layoutDoc._autoHeight, autoHeight => { if (autoHeight) { this.props.layoutDoc._nativeHeight = NumCast(this.props.Document[this.props.fieldKey + "-nativeHeight"]); this.props.setHeight?.(NumCast(this.props.Document[this.props.fieldKey + "-nativeHeight"]) * (this.props.scaling?.() || 1)); } }); this._disposers.selected = reaction(() => this.props.isSelected(), selected => { // if (!selected) { // Array.from(this._savedAnnotations.values()).forEach(v => v.forEach(a => a.remove())); // Array.from(this._savedAnnotations.keys()).forEach(k => this._savedAnnotations.set(k, [])); // } (SelectionManager.Views().length === 1) && this.setupPdfJsViewer(); }, { fireImmediately: true }); this._disposers.curPage = reaction(() => Cast(this.props.Document._curPage, "number", null), (page) => page !== undefined && page !== this._pdfViewer?.currentPageNumber && this.gotoPage(page), { fireImmediately: true } ); } componentWillUnmount = () => { Object.values(this._disposers).forEach(disposer => disposer?.()); document.removeEventListener("copy", this.copy); } copy = (e: ClipboardEvent) => { if (this.props.isContentActive() && e.clipboardData) { e.clipboardData.setData("text/plain", this._selectionText); e.preventDefault(); } } @action initialLoad = async () => { if (this._pageSizes.length === 0) { this._pageSizes = Array<{ width: number, height: number }>(this.props.pdf.numPages); await Promise.all(this._pageSizes.map((val, i) => this.props.pdf.getPage(i + 1).then(action((page: Pdfjs.PDFPageProxy) => { const page0or180 = page.rotate === 0 || page.rotate === 180; this._pageSizes.splice(i, 1, { width: (page.view[page0or180 ? 2 : 3] - page.view[page0or180 ? 0 : 1]), height: (page.view[page0or180 ? 3 : 2] - page.view[page0or180 ? 1 : 0]) }); if (i === this.props.pdf.numPages - 1) { this.props.loaded?.(page.view[page0or180 ? 2 : 3] - page.view[page0or180 ? 0 : 1], page.view[page0or180 ? 3 : 2] - page.view[page0or180 ? 1 : 0], this.props.pdf.numPages); } })))); this.props.Document.scrollHeight = this._pageSizes.reduce((size, page) => size + page.height, 0) * 96 / 72; } } // scrolls to focus on a nested annotation document. if this is part a link preview then it will jump to the scroll location, // otherwise it will scroll smoothly. scrollFocus = (doc: Doc, smooth: boolean) => { const mainCont = this._mainCont.current; let focusSpeed: Opt; if (doc !== this.props.rootDoc && mainCont) { const windowHeight = this.props.PanelHeight() / (this.props.scaling?.() || 1); const scrollTo = doc.unrendered ? NumCast(doc.y) : Utils.scrollIntoView(NumCast(doc.y), doc[HeightSym](), NumCast(this.props.layoutDoc._scrollTop), windowHeight, .1 * windowHeight, NumCast(this.props.Document.scrollHeight)); if (scrollTo !== undefined && scrollTo !== this.props.layoutDoc._scrollTop) { focusSpeed = 500; if (!this._pdfViewer) this._initialScroll = scrollTo; else if (smooth) smoothScroll(focusSpeed, mainCont, scrollTo); else this._mainCont.current?.scrollTo({ top: Math.abs(scrollTo || 0) }); } } else { this._initialScroll = NumCast(this.props.layoutDoc._scrollTop); } return focusSpeed; } crop = (region: Doc | undefined, addCrop?: boolean) => { return this.props.crop(region, addCrop); } @action setupPdfJsViewer = async () => { if (this._viewerIsSetup) return; this._viewerIsSetup = true; this._showWaiting = true; this.props.setPdfViewer(this); await this.initialLoad(); this.createPdfViewer(); } pagesinit = () => { if (this._pdfViewer._setDocumentViewerElement?.offsetParent) { runInAction(() => this._pdfViewer.currentScaleValue = this.props.layoutDoc._viewScale = 1); this.gotoPage(NumCast(this.props.Document._curPage, 1)); } document.removeEventListener("pagesinit", this.pagesinit); var quickScroll: string | undefined = this._initialScroll ? this._initialScroll.toString() : ""; this._disposers.scroll = reaction( () => Math.abs(NumCast(this.props.Document._scrollTop)), (pos) => { if (!this._ignoreScroll) { this._showWaiting && this.setupPdfJsViewer(); const viewTrans = quickScroll ?? StrCast(this.props.Document._viewTransition); const durationMiliStr = viewTrans.match(/([0-9]*)ms/); const durationSecStr = viewTrans.match(/([0-9.]*)s/); const duration = durationMiliStr ? Number(durationMiliStr[1]) : durationSecStr ? Number(durationSecStr[1]) * 1000 : 0; this._forcedScroll = true; if (duration) { setTimeout(() => { this._mainCont.current && smoothScroll(duration, this._mainCont.current, pos); setTimeout(() => this._forcedScroll = false, duration); }, this._mainCont.current ? 0 : 250); // wait for mainCont and try again to scroll } else { this._mainCont.current?.scrollTo({ top: pos }); this._forcedScroll = false; } } }, { fireImmediately: true } ); quickScroll = undefined; if (this._initialScroll !== undefined && this._mainCont.current) { this._mainCont.current?.scrollTo({ top: Math.abs(this._initialScroll || 0) }); this._initialScroll = undefined; } } createPdfViewer() { if (!this._mainCont.current) { // bcz: I don't think this is ever triggered or needed console.log("PDFViewer- I guess we got here"); if (this._retries < 5) { this._retries++; console.log("PDFViewer- retry num:" + this._retries); setTimeout(() => this.createPdfViewer(), 1000); } return; } document.removeEventListener("copy", this.copy); document.addEventListener("copy", this.copy); const eventBus = new PDFJSViewer.EventBus(true); eventBus._on("pagesinit", this.pagesinit); eventBus._on("pagerendered", action(() => this._showWaiting = false)); const pdfLinkService = new PDFJSViewer.PDFLinkService({ eventBus }); const pdfFindController = new PDFJSViewer.PDFFindController({ linkService: pdfLinkService, eventBus }); this._pdfViewer = new PDFJSViewer.PDFViewer({ container: this._mainCont.current, viewer: this._viewer.current, linkService: pdfLinkService, findController: pdfFindController, renderer: "canvas", eventBus }); pdfLinkService.setViewer(this._pdfViewer); pdfLinkService.setDocument(this.props.pdf, null); this._pdfViewer.setDocument(this.props.pdf); } @action prevAnnotation = () => { this.Index = Math.max(this.Index - 1, 0); this.scrollToAnnotation(this.allAnnotations.sort((a, b) => NumCast(a.y) - NumCast(b.y))[this.Index]); } @action nextAnnotation = () => { this.Index = Math.min(this.Index + 1, this.allAnnotations.length - 1); this.scrollToAnnotation(this.allAnnotations.sort((a, b) => NumCast(a.y) - NumCast(b.y))[this.Index]); } @action gotoPage = (p: number) => { this._pdfViewer?.scrollPageIntoView({ pageNumber: Math.min(Math.max(1, p), this._pageSizes.length) }); } @action scrollToAnnotation = (scrollToAnnotation: Doc) => { if (scrollToAnnotation) { this.scrollFocus(scrollToAnnotation, true); Doc.linkFollowHighlight(scrollToAnnotation); } } @observable private _scrollTimer: any; onScroll = (e: React.UIEvent) => { if (this._mainCont.current && !this._forcedScroll) { this._ignoreScroll = true; // the pdf scrolled, so we need to tell the Doc to scroll but we don't want the doc to then try to set the PDF scroll pos (which would interfere with the smooth scroll animation) if (!LinkDocPreview.LinkInfo) { this.props.layoutDoc._scrollTop = this._mainCont.current.scrollTop; } this._ignoreScroll = false; if (this._scrollTimer) clearTimeout(this._scrollTimer); // wait until a scrolling pause, then create an anchor to audio this._scrollTimer = setTimeout(() => { DocUtils.MakeLinkToActiveAudio(() => this.props.DocumentView?.().ComponentView?.getAnchor!()!, false); this._scrollTimer = undefined; }, 200); } } // 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 && this._pageSizes[index] && currOffset - this._pageSizes[index].height > 0) { currOffset -= this._pageSizes[index++].height; } return index; } @action search = (searchString: string, bwd?: boolean, clear: boolean = false) => { const findOpts = { caseSensitive: false, findPrevious: bwd, highlightAll: true, phraseSearch: true, query: searchString }; if (clear) { this._pdfViewer?.findController.executeCommand('reset', { query: "" }); } else if (!searchString) { bwd ? this.prevAnnotation() : this.nextAnnotation(); } else if (this._pdfViewer?.pageViewsReady) { this._pdfViewer.findController.executeCommand('findagain', findOpts); } else if (this._mainCont.current) { const executeFind = () => this._pdfViewer.findController.executeCommand('find', findOpts); this._mainCont.current.addEventListener("pagesloaded", executeFind); this._mainCont.current.addEventListener("pagerendered", executeFind); } return true; } @action onPointerDown = (e: React.PointerEvent): void => { // const hit = document.elementFromPoint(e.clientX, e.clientY); // bcz: Change. drag selecting requires that preventDefault is NOT called. This used to happen in DocumentView, // but that's changed, so this shouldn't be needed. // if (hit && hit.localName === "span" && this.annotationsActive(true)) { // drag selecting text stops propagation // e.button === 0 && e.stopPropagation(); // } // if alt+left click, drag and annotate this._downX = e.clientX; this._downY = e.clientY; if ((this.props.Document._viewScale || 1) !== 1) return; if ((e.button !== 0 || e.altKey) && this.props.isContentActive(true)) { this._setPreviewCursor?.(e.clientX, e.clientY, true, false); } if (!e.altKey && e.button === 0 && this.props.isContentActive(true) && ![InkTool.Highlighter, InkTool.Pen, InkTool.Write].includes(CurrentUserUtils.ActiveTool)) { this.props.select(false); MarqueeAnnotator.clearAnnotations(this._savedAnnotations); this._marqueeing = [e.clientX, e.clientY]; this.isAnnotating = true; if (e.target && ((e.target as any).className.includes("endOfContent") || ((e.target as any).parentElement.className !== "textLayer"))) { this._textSelecting = false; document.addEventListener("pointermove", this.onSelectMove); // need this to prevent document from being dragged if stopPropagation doesn't get called } else { // if textLayer is hit, then we select text instead of using a marquee so clear out the marquee. setTimeout(action(() => this._marqueeing = undefined), 100); // bcz: hack .. anchor menu is setup within MarqueeAnnotator so we need to at least create the marqueeAnnotator even though we aren't using it. this._styleRule = addStyleSheetRule(PDFViewer._annotationStyle, "htmlAnnotation", { "pointer-events": "none" }); document.addEventListener("pointerup", this.onSelectEnd); document.addEventListener("pointermove", this.onSelectMove); } } } @action finishMarquee = (x?: number, y?: number) => { this._getAnchor = AnchorMenu.Instance?.GetAnchor; this.isAnnotating = false; this._marqueeing = undefined; this._textSelecting = true; document.removeEventListener("pointermove", this.onSelectMove); } onSelectMove = (e: PointerEvent) => e.stopPropagation(); @action onSelectEnd = (e: PointerEvent): void => { this.isAnnotating = false; clearStyleSheetRules(PDFViewer._annotationStyle); this.props.select(false); document.removeEventListener("pointermove", this.onSelectMove); document.removeEventListener("pointerup", this.onSelectEnd); const sel = window.getSelection(); if (sel?.type === "Range") { this.createTextAnnotation(sel, sel.getRangeAt(0)); AnchorMenu.Instance.jumpTo(e.clientX, e.clientY); } } @action createTextAnnotation = (sel: Selection, selRange: Range) => { if (this._mainCont.current) { const boundingRect = this._mainCont.current.getBoundingClientRect(); const clientRects = selRange.getClientRects(); for (let i = 0; i < clientRects.length; i++) { const rect = clientRects.item(i); if (rect && rect.width !== this._mainCont.current.clientWidth && rect.width) { const scaleX = this._mainCont.current.offsetWidth / boundingRect.width; const pdfScale = NumCast(this.props.layoutDoc._viewScale, 1); const annoBox = document.createElement("div"); annoBox.className = "marqueeAnnotator-annotationBox"; // transforms the positions from screen onto the pdf div annoBox.style.top = ((rect.top - boundingRect.top) * scaleX / pdfScale + this._mainCont.current.scrollTop).toString(); annoBox.style.left = ((rect.left - boundingRect.left) * scaleX / pdfScale).toString(); annoBox.style.width = (rect.width * this._mainCont.current.offsetWidth / boundingRect.width / pdfScale).toString(); annoBox.style.height = (rect.height * this._mainCont.current.offsetHeight / boundingRect.height / pdfScale).toString(); this._annotationLayer.current && MarqueeAnnotator.previewNewAnnotation(this._savedAnnotations, this._annotationLayer.current, annoBox, this.getPageFromScroll(rect.top)); } } } this._selectionText = selRange.cloneContents().textContent || ""; // clear selection if (sel.empty) { // Chrome sel.empty(); } else if (sel.removeAllRanges) { // Firefox sel.removeAllRanges(); } } scrollXf = () => { return this._mainCont.current ? this.props.ScreenToLocalTransform().translate(0, NumCast(this.props.layoutDoc._scrollTop)) : this.props.ScreenToLocalTransform(); } onClick = (e: React.MouseEvent) => { if (this._setPreviewCursor && e.button === 0 && Math.abs(e.clientX - this._downX) < Utils.DRAG_THRESHOLD && Math.abs(e.clientY - this._downY) < Utils.DRAG_THRESHOLD) { this._setPreviewCursor(e.clientX, e.clientY, false, false); } // e.stopPropagation(); // bcz: not sure why this was here. We need to allow the DocumentView to get clicks to process doubleClicks } setPreviewCursor = (func?: (x: number, y: number, drag: boolean, hide: boolean) => void) => this._setPreviewCursor = func; @action onZoomWheel = (e: React.WheelEvent) => { if (this.props.isContentActive(true)) { e.stopPropagation(); if (e.ctrlKey) { const curScale = Number(this._pdfViewer.currentScaleValue); this._pdfViewer.currentScaleValue = Math.max(1, Math.min(10, curScale - curScale * e.deltaY / 1000)); this.props.layoutDoc._viewScale = Number(this._pdfViewer.currentScaleValue); } } } pointerEvents = () => this.props.isContentActive() && this.props.pointerEvents?.() !== "none" && !MarqueeOptionsMenu.Instance.isShown() ? "all" : SnappingManager.GetIsDragging() ? undefined : "none"; @computed get annotationLayer() { return
{this.inlineTextAnnotations.sort((a, b) => NumCast(a.y) - NumCast(b.y)).map(anno => )}
; } @computed get overlayInfo() { return !this._overlayAnnoInfo ? (null) :
users.user.email === this._overlayAnnoInfo!.author)?.userColor }}> {this._overlayAnnoInfo.author + " " + Field.toString(this._overlayAnnoInfo.creationDate as Field)}
; } showInfo = action((anno: Opt) => this._overlayAnnoInfo = anno); overlayTransform = () => this.scrollXf().scale(1 / NumCast(this.props.layoutDoc._viewScale, 1)); panelWidth = () => this.props.PanelWidth() / (this.props.scaling?.() || 1); // (this.Document.scrollHeight || Doc.NativeHeight(this.Document) || 0); panelHeight = () => this.props.PanelHeight() / (this.props.scaling?.() || 1); // () => this._pageSizes.length && this._pageSizes[0] ? this._pageSizes[0].width : Doc.NativeWidth(this.Document); basicFilter = () => [...this.props.docFilters(), Utils.PropUnsetFilter("textInlineAnnotations")]; transparentFilter = () => [...this.props.docFilters(), Utils.IsTransparentFilter()]; opaqueFilter = () => [...this.props.docFilters(), Utils.IsOpaqueFilter()]; childStyleProvider = (doc: (Doc | undefined), props: Opt, property: string): any => { if (doc instanceof Doc && property === StyleProp.PointerEvents) { if (doc.textInlineAnnotations) return "none"; return "all"; } return this.props.styleProvider?.(doc, props, property); } renderAnnotations = (docFilters?: () => string[], dontRender?: boolean) => @computed get overlayTransparentAnnotations() { return this.renderAnnotations(this.transparentFilter, false); } @computed get overlayOpaqueAnnotations() { return this.renderAnnotations(this.opaqueFilter, false); } @computed get overlayClickableAnnotations() { return
{this.renderAnnotations(undefined, true)}
; } @computed get overlayLayer() { return
{this.overlayTransparentAnnotations}
anno.mixBlendMode) ? "hard-light" : undefined, transform: `scale(${NumCast(this.props.layoutDoc._viewScale, 1)})` }}> {this.overlayOpaqueAnnotations} {this.overlayClickableAnnotations}
; } @computed get pdfViewerDiv() { return
; } @computed get contentScaling() { return this.props.ContentScaling?.() || 1; } contentZoom = () => NumCast(this.props.layoutDoc._viewScale, 1); savedAnnotations = () => this._savedAnnotations; render() { TraceMobx(); return
600) ? Doc.NativeHeight(this.props.Document) : `${100 / this.contentScaling}%`, transform: `scale(${this.contentScaling})` }} > {this.pdfViewerDiv} {this.annotationLayer} {this.overlayLayer} {this.overlayInfo} {this._showWaiting ? : (null)} {!this._marqueeing || !this._mainCont.current || !this._annotationLayer.current ? (null) : this.props.addDocument!(doc)} docView={this.props.docViewPath().lastElement()} finishMarquee={this.finishMarquee} savedAnnotations={this.savedAnnotations} annotationLayer={this._annotationLayer.current} mainCont={this._mainCont.current} anchorMenuCrop={this._textSelecting ? undefined : this.crop} />}
; } }