import { action, computed, observable, ObservableMap } from 'mobx'; import { observer } from 'mobx-react'; import { Doc, Opt } from '../../fields/Doc'; import { AclAdmin, AclAugment, AclEdit, AclSelfEdit, DocData } from '../../fields/DocSymbols'; import { Id } from '../../fields/FieldSymbols'; import { List } from '../../fields/List'; import { NumCast } from '../../fields/Types'; import { GetEffectiveAcl } from '../../fields/util'; import { unimplementedFunction, Utils } from '../../Utils'; import { Docs, DocUtils } from '../documents/Documents'; import { DragManager } from '../util/DragManager'; import { FollowLinkScript } from '../util/LinkFollower'; import { undoable, undoBatch, UndoManager } 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 { Document: Doc; down?: number[]; scrollTop: number; scaling?: () => number; annotationLayerScaling?: () => number; annotationLayerScrollTop: number; containerOffset?: () => number[]; mainCont: HTMLDivElement; docView: () => DocumentView; savedAnnotations: () => ObservableMap; selectionText: () => string; annotationLayer: HTMLDivElement; addDocument: (doc: Doc) => boolean; getPageFromScroll?: (top: number) => number; finishMarquee: (x?: number, y?: number, PointerEvent?: PointerEvent) => void; anchorMenuClick?: () => undefined | ((anchor: Doc) => void); anchorMenuCrop?: (anchor: Doc | undefined, addCrop: boolean) => Doc | undefined; highlightDragSrcColor?: string; } @observer export class MarqueeAnnotator extends React.Component { private _start: { x: number; y: number } = { x: 0, y: 0 }; @observable private _width: number = 0; @observable private _height: number = 0; @computed get top() { return Math.min(this._start.y, this._start.y + this._height); } // prettier-ignore @computed get left() { return Math.min(this._start.x, this._start.x + this._width);} // prettier-ignore @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(); } @undoBatch @action makeAnnotationDocument = (color: string, isLinkButton?: boolean, savedAnnotations?: ObservableMap): Opt => { const savedAnnoMap = savedAnnotations?.values() && Array.from(savedAnnotations?.values()).length ? savedAnnotations : this.props.savedAnnotations(); if (savedAnnoMap.size === 0) return undefined; const savedAnnos = Array.from(savedAnnoMap.values())[0]; const doc = this.props.docView().Document; const scale = (this.props.annotationLayerScaling?.() || 1) * NumCast(doc._freeform_scale, 1); if (savedAnnos.length && (savedAnnos[0] as any).marqueeing) { const anno = savedAnnos[0]; const containerOffset = this.props.containerOffset?.() || [0, 0]; const marqueeAnno = Docs.Create.FreeformDocument([], { onClick: isLinkButton ? FollowLinkScript() : undefined, backgroundColor: color, annotationOn: this.props.Document, title: 'Annotation on ' + this.props.Document.title, }); marqueeAnno.x = NumCast(doc.freeform_panX_min) + (parseInt(anno.style.left || '0') - containerOffset[0]) / scale; marqueeAnno.y = NumCast(doc.freeform_panY_min) + (parseInt(anno.style.top || '0') - containerOffset[1]) / scale; 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.HTMLMarkerDocument([], { annotationOn: this.props.Document, text: this.props.selectionText(), backgroundColor: 'transparent', presentation_duration: 2100, presentation_transition: 500, presentation_zoomText: true, title: 'Selection on ' + this.props.Document.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.text_inlineAnnotations = new List(annoDocs); savedAnnoMap.clear(); return textRegionAnno; }; @action highlight = (color: string, isLinkButton: boolean, savedAnnotations?: ObservableMap, addAsAnnotation?: boolean, summarize?: boolean) => { // creates annotation documents for current highlights const effectiveAcl = GetEffectiveAcl(this.props.Document[DocData]); const annotationDoc = [AclAugment, AclSelfEdit, AclEdit, AclAdmin].includes(effectiveAcl) && this.makeAnnotationDocument(color, isLinkButton, savedAnnotations); addAsAnnotation && annotationDoc && this.props.addDocument(annotationDoc); return annotationDoc as Doc; }; public static previewNewAnnotation = action((savedAnnotations: ObservableMap, annotationLayer: HTMLDivElement, div: HTMLDivElement, page: number) => { div.style.backgroundColor = '#ACCEF7'; div.style.opacity = '0.5'; annotationLayer.append(div); const savedPage = savedAnnotations.get(page); if (savedPage) savedPage.push(div); savedAnnotations.set(page, savedPage ?? [div]); }); getTransformedScreenPt = (down: number[]) => { const boundingRect = this.props.mainCont.getBoundingClientRect(); const center = { x: boundingRect.x + boundingRect.width / 2, y: boundingRect.y + boundingRect.height / 2 }; const downPt = Utils.rotPt(down[0] - center.x, down[1] - center.y, NumCast(this.props.docView().screenToLocalTransform().Rotate)); const scale = this.props.docView().props.ScreenToLocalTransform().Scale; const scalex = this.props.mainCont.offsetWidth / NumCast(this.props.Document.width); const scaley = this.props.mainCont.offsetHeight / NumCast(this.props.Document.height); // set marquee x and y positions to the spatially transformed position return { x: scalex * (downPt.x + NumCast(this.props.Document.width) / scale / 2) * scale, y: scaley * (downPt.y + NumCast(this.props.Document.height) / scale / 2) * scale + this.props.annotationLayerScrollTop }; // prettier-ignore }; @action public onInitiateSelection(down: number[]) { this._width = this._height = 0; this._start = this.getTransformedScreenPt(down); document.removeEventListener('pointermove', this.onSelectMove); document.removeEventListener('pointerup', this.onSelectEnd); document.addEventListener('pointermove', this.onSelectMove); document.addEventListener('pointerup', this.onSelectEnd); AnchorMenu.Instance.OnCrop = (e: PointerEvent) => { if (this.props.anchorMenuCrop) { UndoManager.RunInBatch(() => this.props.anchorMenuCrop?.(this.highlight('', true, undefined, false), true), 'cropping'); } }; AnchorMenu.Instance.OnClick = undoable((e: PointerEvent) => this.props.anchorMenuClick?.()?.(this.highlight(this.props.highlightDragSrcColor ?? 'rgba(173, 216, 230, 0.75)', true, undefined, true)), 'make sidebar annotation'); AnchorMenu.Instance.OnAudio = unimplementedFunction; AnchorMenu.Instance.Highlight = this.highlight; AnchorMenu.Instance.GetAnchor = (savedAnnotations?: ObservableMap, addAsAnnotation?: boolean) => this.highlight('rgba(173, 216, 230, 0.75)', true, savedAnnotations, true); AnchorMenu.Instance.onMakeAnchor = () => AnchorMenu.Instance.GetAnchor(undefined, true); /** * 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 = () => this.highlight(this.props.highlightDragSrcColor ?? 'rgba(173, 216, 230, 0.75)', true, undefined, true); // hyperlink color const targetCreator = (annotationOn: Doc | undefined) => { const target = DocUtils.GetNewTextDoc('Note linked to ' + this.props.Document.title, 0, 0, 100, 100, undefined, annotationOn, undefined, 'yellow'); FormattedTextBox.SetSelectOnLoad(target); 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.followLinkToggle = e.annoDragData.dropDocument.annotationOn === this.props.Document; e.annoDragData.linkSourceDoc.followLinkZoom = false; } }, }); }); /** * 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.StartCropDrag = !this.props.anchorMenuCrop ? unimplementedFunction : action((e: PointerEvent, ele: HTMLElement) => { e.preventDefault(); e.stopPropagation(); var cropRegion: Doc | undefined; const sourceAnchorCreator = () => (cropRegion = this.highlight('', true, undefined, true)); // hyperlink color const targetCreator = (annotationOn: Doc | undefined) => this.props.anchorMenuCrop!(cropRegion, false)!; DragManager.StartAnchorAnnoDrag([ele], new DragManager.AnchorAnnoDragData(this.props.docView(), sourceAnchorCreator, targetCreator), e.pageX, e.pageY, { dragComplete: e => { if (!e.aborted && e.linkDocument) { Doc.GetProto(e.linkDocument).link_relationship = 'cropped image'; Doc.GetProto(e.linkDocument).title = 'crop: ' + this.props.docView().Document.title; Doc.GetProto(e.linkDocument).link_displayLine = false; } }, }); }); } public onTerminateSelection() { document.removeEventListener('pointermove', this.onSelectMove); document.removeEventListener('pointerup', this.onSelectEnd); } @action onSelectMove = (e: PointerEvent) => { const movLoc = this.getTransformedScreenPt([e.clientX, e.clientY]); this._width = movLoc.x - this._start.x; this._height = movLoc.y - this._start.y; //e.stopPropagation(); // overlay documents are all 'active', yet they can be dragged. if we stop propagation, then they can be marqueed but not dragged. if we don't stop, then they will be marqueed and dragged, but the marquee will be zero width since the doc will move along with the cursor. }; @action onSelectEnd = (e: PointerEvent) => { e.stopPropagation(); const marquees = this.props.mainCont.getElementsByClassName('marqueeAnnotator-dragBox'); const marqueeStyle = (Array.from(marquees).lastElement() as HTMLDivElement)?.style; if (!this.isEmpty && marqueeStyle) { // configure and show the annotation/link menu if a the drag region is big enough // copy the temporary marquee to allow for multiple selections (not currently available though). const copy = document.createElement('div'); const scale = (this.props.scaling?.() || 1) * NumCast(this.props.docView().Document._freeform_scale, 1); ['border', 'opacity', 'top', 'left', 'width', 'height'].forEach(prop => (copy.style[prop as any] = marqueeStyle[prop as any])); copy.className = 'marqueeAnnotator-annotationBox'; copy.style.top = parseInt(marqueeStyle.top.toString().replace('px', '')) / scale + this.props.scrollTop + 'px'; copy.style.left = parseInt(marqueeStyle.left.toString().replace('px', '')) / scale + 'px'; copy.style.width = parseInt(marqueeStyle.width.toString().replace('px', '')) / scale + 'px'; copy.style.height = parseInt(marqueeStyle.height.toString().replace('px', '')) / scale + 'px'; (copy as any).marqueeing = true; MarqueeAnnotator.previewNewAnnotation(this.props.savedAnnotations(), this.props.annotationLayer, copy, this.props.getPageFromScroll?.(this.top) || 0); AnchorMenu.Instance.jumpTo(e.clientX, e.clientY); } this.props.finishMarquee(this.isEmpty ? e.clientX : undefined, this.isEmpty ? e.clientY : undefined, e); this._width = this._height = 0; }; get isEmpty() { return Math.abs(this._width) <= 10 && Math.abs(this._height) <= 10; } render() { return (
); } }