import { action, observable, ObservableMap, runInAction } from "mobx"; import { observer } from "mobx-react"; import { AclAugment, AclAdmin, AclEdit, DataSym, Doc, Opt, AclSelfEdit } from "../../fields/Doc"; import { Id } from "../../fields/FieldSymbols"; import { List } from "../../fields/List"; import { NumCast } from "../../fields/Types"; import { GetEffectiveAcl } from "../../fields/util"; import { Utils } from "../../Utils"; import { Docs } from "../documents/Documents"; import { CurrentUserUtils } from "../util/CurrentUserUtils"; import { DragManager } from "../util/DragManager"; import { undoBatch } from "../util/UndoManager"; import "./MarqueeAnnotator.scss"; import { DocumentView } from "./nodes/DocumentView"; import { FormattedTextBox } from "./nodes/formattedText/FormattedTextBox"; import { AnchorMenu } from "./pdf/AnchorMenu"; import React = require("react"); const _global = (window /* browser */ || global /* node */) as any; export interface MarqueeAnnotatorProps { rootDoc: Doc; down: number[]; iframe?: () => undefined | HTMLIFrameElement; scrollTop: number; scaling?: () => number; iframeScaling?: () => number; containerOffset?: () => number[]; mainCont: HTMLDivElement; docView: DocumentView; savedAnnotations: ObservableMap; annotationLayer: HTMLDivElement; addDocument: (doc: Doc) => boolean; getPageFromScroll?: (top: number) => number; finishMarquee: (x?: number, y?: number, PointerEvent?: PointerEvent) => void; anchorMenuClick?: () => undefined | ((anchor: Doc) => void); } @observer export class MarqueeAnnotator extends React.Component { private _startX: number = 0; private _startY: number = 0; @observable private _left: number = 0; @observable private _top: number = 0; @observable private _width: number = 0; @observable private _height: number = 0; @action static clearAnnotations(savedAnnotations: ObservableMap) { AnchorMenu.Instance.Status = "marquee"; AnchorMenu.Instance.fadeOut(true); // clear out old marquees and initialize menu for new selection Array.from(savedAnnotations.values()).forEach(v => v.forEach(a => a.remove())); savedAnnotations.clear(); } @action componentDidMount() { // set marquee x and y positions to the spatially transformed position const boundingRect = this.props.mainCont.getBoundingClientRect(); this._startX = this._left = (this.props.down[0] - boundingRect.left) * (this.props.mainCont.offsetWidth / boundingRect.width); this._startY = this._top = (this.props.down[1] - boundingRect.top) * (this.props.mainCont.offsetHeight / boundingRect.height) + this.props.mainCont.scrollTop; this._height = this._width = 0; const doc = (this.props.iframe?.()?.contentDocument ?? document); doc.addEventListener("pointermove", this.onSelectMove); doc.addEventListener("pointerup", this.onSelectEnd); AnchorMenu.Instance.OnClick = (e: PointerEvent) => this.props.anchorMenuClick?.()?.(this.highlight("rgba(173, 216, 230, 0.75)", true)); AnchorMenu.Instance.Highlight = this.highlight; AnchorMenu.Instance.GetAnchor = (savedAnnotations?: ObservableMap) => this.highlight("rgba(173, 216, 230, 0.75)", true, savedAnnotations); AnchorMenu.Instance.onMakeAnchor = AnchorMenu.Instance.GetAnchor; /** * This function is used by the AnchorMenu to create an anchor highlight and a new linked text annotation. * It also initiates a Drag/Drop interaction to place the text annotation. */ AnchorMenu.Instance.StartDrag = action((e: PointerEvent, ele: HTMLElement) => { e.preventDefault(); e.stopPropagation(); const sourceAnchorCreator = () => { const annoDoc = this.highlight("rgba(173, 216, 230, 0.75)", true); // hyperlink color annoDoc && this.props.addDocument(annoDoc); return annoDoc; }; const targetCreator = (annotationOn: Doc | undefined) => { const target = CurrentUserUtils.GetNewTextDoc("Note linked to " + this.props.rootDoc.title, 0, 0, 100, 100, undefined, annotationOn, undefined, "yellow"); FormattedTextBox.SelectOnLoad = target[Id]; return target; }; DragManager.StartAnchorAnnoDrag([ele], new DragManager.AnchorAnnoDragData(this.props.docView, sourceAnchorCreator, targetCreator), e.pageX, e.pageY, { dragComplete: e => { if (!e.aborted && e.annoDragData && e.annoDragData.linkSourceDoc && e.annoDragData.dropDocument && e.linkDocument) { e.annoDragData.linkSourceDoc.isPushpin = e.annoDragData.dropDocument.annotationOn === this.props.rootDoc; } } }); }); } componentWillUnmount() { const doc = (this.props.iframe?.()?.contentDocument ?? document); doc.removeEventListener("pointermove", this.onSelectMove); doc.removeEventListener("pointerup", this.onSelectEnd); } @undoBatch @action makeAnnotationDocument = (color: string, isLinkButton?: boolean, savedAnnotations?: ObservableMap): Opt => { const savedAnnoMap = savedAnnotations ?? this.props.savedAnnotations; if (savedAnnoMap.size === 0) return undefined; const savedAnnos = Array.from(savedAnnoMap.values())[0]; if (savedAnnos.length && (savedAnnos[0] as any).marqueeing) { const scale = this.props.scaling?.() || 1; const anno = savedAnnos[0]; const containerOffset = this.props.containerOffset?.() || [0, 0]; const marqueeAnno = Docs.Create.FreeformDocument([], { _isLinkButton: isLinkButton, backgroundColor: color, annotationOn: this.props.rootDoc, title: "Annotation on " + this.props.rootDoc.title }); marqueeAnno.x = (parseInt(anno.style.left || "0") - containerOffset[0]) / scale; marqueeAnno.y = (parseInt(anno.style.top || "0") - containerOffset[1]) / scale + NumCast(this.props.scrollTop); marqueeAnno._height = parseInt(anno.style.height || "0") / scale; marqueeAnno._width = parseInt(anno.style.width || "0") / scale; anno.remove(); savedAnnoMap.clear(); return marqueeAnno; } const textRegionAnno = Docs.Create.HTMLAnchorDocument([], { annotationOn: this.props.rootDoc, backgroundColor: "transparent", title: "Selection on " + this.props.rootDoc.title }); let minX = Number.MAX_VALUE; let maxX = -Number.MAX_VALUE; let minY = Number.MAX_VALUE; let maxY = -Number.MIN_VALUE; const annoDocs: Doc[] = []; savedAnnoMap.forEach((value: HTMLDivElement[], key: number) => value.map(anno => { const textRegion = new Doc(); textRegion.x = parseInt(anno.style.left ?? "0"); textRegion.y = parseInt(anno.style.top ?? "0"); textRegion._height = parseInt(anno.style.height ?? "0"); textRegion._width = parseInt(anno.style.width ?? "0"); textRegion.annoTextRegion = textRegionAnno; textRegion.backgroundColor = color; annoDocs.push(textRegion); anno.remove(); minY = Math.min(NumCast(textRegion.y), minY); minX = Math.min(NumCast(textRegion.x), minX); maxY = Math.max(NumCast(textRegion.y) + NumCast(textRegion._height), maxY); maxX = Math.max(NumCast(textRegion.x) + NumCast(textRegion._width), maxX); })); const textRegionAnnoProto = Doc.GetProto(textRegionAnno); textRegionAnnoProto.y = Math.max(minY, 0); textRegionAnnoProto.x = Math.max(minX, 0); textRegionAnnoProto.height = Math.max(maxY, 0) - Math.max(minY, 0); textRegionAnnoProto.width = Math.max(maxX, 0) - Math.max(minX, 0); // mainAnnoDocProto.text = this._selectionText; textRegionAnnoProto.textInlineAnnotations = new List(annoDocs); savedAnnoMap.clear(); return textRegionAnno; } @action highlight = (color: string, isLinkButton: boolean, savedAnnotations?: ObservableMap) => { // creates annotation documents for current highlights const effectiveAcl = GetEffectiveAcl(this.props.rootDoc[DataSym]); const annotationDoc = [AclAugment, AclSelfEdit, AclEdit, AclAdmin].includes(effectiveAcl) && this.makeAnnotationDocument(color, isLinkButton, savedAnnotations); !savedAnnotations && annotationDoc && this.props.addDocument(annotationDoc); return annotationDoc as Doc ?? undefined; } public static previewNewAnnotation = action((savedAnnotations: ObservableMap, annotationLayer: HTMLDivElement, div: HTMLDivElement, page: number) => { if (div.style.top) { div.style.top = (parseInt(div.style.top)/*+ this.getScrollFromPage(page)*/).toString(); } annotationLayer.append(div); div.style.backgroundColor = "#ACCEF7"; div.style.opacity = "0.5"; const savedPage = savedAnnotations.get(page); if (savedPage) { savedPage.push(div); savedAnnotations.set(page, savedPage); } else { savedAnnotations.set(page, [div]); } }); @action onSelectMove = (e: PointerEvent) => { // transform positions and find the width and height to set the marquee to const boundingRect = (this.props.iframe?.()?.contentDocument?.body || this.props.mainCont).getBoundingClientRect(); const mainRect = this.props.mainCont.getBoundingClientRect(); const cliX = e.clientX * (this.props.iframeScaling?.() || 1) - boundingRect.left; const cliY = e.clientY * (this.props.iframeScaling?.() || 1) - boundingRect.top; this._width = (cliX * (this.props.mainCont.offsetWidth / mainRect.width)) - this._startX; this._height = (cliY * (this.props.mainCont.offsetHeight / mainRect.height)) - this._startY + this.props.mainCont.scrollTop; this._left = Math.min(this._startX, this._startX + this._width); this._top = Math.min(this._startY, this._startY + this._height); this._width = Math.abs(this._width); this._height = Math.abs(this._height); e.stopPropagation(); } onSelectEnd = (e: PointerEvent) => { const mainRect = this.props.mainCont.getBoundingClientRect(); const cliX = e.clientX * (this.props.iframeScaling?.() || 1) + (this.props.iframe ? mainRect.left : 0); const cliY = e.clientY * (this.props.iframeScaling?.() || 1) + (this.props.iframe ? mainRect.top : 0); if (this._width > 10 || this._height > 10) { // configure and show the annotation/link menu if a the drag region is big enough const marquees = this.props.mainCont.getElementsByClassName("marqueeAnnotator-dragBox"); if (marquees?.length) { // copy the temporary marquee to allow for multiple selections (not currently available though). const copy = document.createElement("div"); ["border", "opacity"].forEach(prop => copy.style[prop as any] = (marquees[0] as HTMLDivElement).style[prop as any]); const bounds = (marquees[0] as HTMLDivElement).getBoundingClientRect(); const uitls = Utils.GetScreenTransform(marquees[0] as HTMLDivElement); const rbounds = { top: uitls.translateY, left: uitls.translateX, width: (bounds.right - bounds.left), height: (bounds.bottom - bounds.top) }; const otls = Utils.GetScreenTransform(this.props.annotationLayer); const fbounds = { top: (rbounds.top - otls.translateY) / otls.scale, left: (rbounds.left - otls.translateX) / otls.scale, width: rbounds.width / otls.scale, height: rbounds.height / otls.scale }; copy.style.top = fbounds.top.toString() + "px"; copy.style.left = fbounds.left.toString() + "px"; copy.style.width = fbounds.width.toString() + "px"; copy.style.height = fbounds.height.toString() + "px"; copy.className = "marqueeAnnotator-annotationBox"; (copy as any).marqueeing = true; MarqueeAnnotator.previewNewAnnotation(this.props.savedAnnotations, this.props.annotationLayer, copy, this.props.getPageFromScroll?.(this._top) || 0); } AnchorMenu.Instance.jumpTo(cliX, cliY); if (AnchorMenu.Instance.Highlighting) {// when highlighter has been toggled when menu is pinned, we auto-highlight immediately on mouse up this.highlight("rgba(245, 230, 95, 0.75)", false); // yellowish highlight color for highlighted text (should match AnchorMenu's highlight color) } this.props.finishMarquee(undefined, undefined, e); } else { runInAction(() => this._width = this._height = 0); this.props.finishMarquee(cliX, cliY, e); } } render() { return
; } }