import { action, computed, makeObservable, observable, ObservableMap } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import { Doc, Opt } from '../../fields/Doc'; import { AclAdmin, AclAugment, AclEdit, AclSelfEdit, DocData } from '../../fields/DocSymbols'; import { List } from '../../fields/List'; import { NumCast } from '../../fields/Types'; import { GetEffectiveAcl } from '../../fields/util'; import { unimplementedFunction, Utils } from '../../Utils'; import { Docs } from '../documents/Documents'; import { DocUtils, FollowLinkScript } from '../documents/DocUtils'; import { DragManager } from '../util/DragManager'; import { undoable, undoBatch, UndoManager } from '../util/UndoManager'; import './MarqueeAnnotator.scss'; import { DocumentView } from './nodes/DocumentView'; import { ObservableReactComponent } from './ObservableReactComponent'; import { AnchorMenu } from './pdf/AnchorMenu'; import { Transform } from '../util/Transform'; export interface MarqueeAnnotatorProps { Document: Doc; down?: number[]; scrollTop: number; scaling?: () => number; annotationLayerScaling?: () => number; annotationLayerScrollTop: number; containerOffset?: () => number[]; marqueeContainer: HTMLDivElement; docView: () => DocumentView; screenTransform: () => Transform; savedAnnotations: () => ObservableMap; selectionText: () => string; annotationLayer: HTMLDivElement; addDocument: (doc: Doc) => boolean; getPageFromScroll?: (top: number) => number; finishMarquee: (x?: number, y?: number) => void; anchorMenuClick?: () => undefined | ((anchor: Doc) => void); anchorMenuFlashcard?: () => Promise; anchorMenuCrop?: (anchor: Doc | undefined, addCrop: boolean) => Doc | undefined; highlightDragSrcColor?: string; } @observer export class MarqueeAnnotator extends ObservableReactComponent { private _start: { x: number; y: number } = { x: 0, y: 0 }; constructor(props: MarqueeAnnotatorProps) { super(props); makeObservable(this); } @observable Width: number = 0; @observable 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 static clearAnnotations = action((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 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.Document; const scale = (this.props.annotationLayerScaling?.() || 1) * NumCast(doc._freeform_scale, 1); if (savedAnnos.length && savedAnnos[0].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.ConfigDocument({ annotationOn: this.props.Document, // eslint-disable-next-line @typescript-eslint/no-explicit-any text: this.props.selectionText() as any, // text wants an RTFfield, but strings are acceptable, too. text_html: this.props.selectionText(), backgroundColor: 'transparent', presentation_duration: 2100, presentation_transition: 500, presentation_zoomText: true, title: '>' + 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 annoRects: string[] = []; savedAnnoMap.forEach((value: HTMLDivElement[]) => value.forEach(anno => { const x = parseInt(anno.style.left || '0'); const y = parseInt(anno.style.top || '0'); const height = parseInt(anno.style.height || '0'); const width = parseInt(anno.style.width || '0'); annoRects.push(`${x}:${y}:${width}:${height}`); anno.remove(); minY = Math.min(y, minY); minX = Math.min(x, minX); maxY = Math.max(y + height, maxY); maxX = Math.max(x + width, maxX); }) ); textRegionAnno.$y = Math.max(minY, 0); textRegionAnno.$x = Math.max(minX, 0); textRegionAnno.$height = Math.max(maxY, 0) - Math.max(minY, 0); textRegionAnno.$width = Math.max(maxX, 0) - Math.max(minX, 0); textRegionAnno.$backgroundColor = color; // mainAnnoDocProto.text = this._selectionText; textRegionAnno.$text_inlineAnnotations = new List(annoRects); textRegionAnno.$opacity = 0; textRegionAnno.$layout_unrendered = true; savedAnnoMap.clear(); return textRegionAnno; }; @action highlight = (color: string, isLinkButton: boolean, savedAnnotations?: ObservableMap, addAsAnnotation?: 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 & { marqueeing?: boolean }, 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]); }); // this transforms a screen point into a local coordinate subject. It's complicated by documents that are rotated // since the DOM's bounding rectangle is not rotated and Dash's ScreenToLocalTransform carries along a rotation value, but doesn't // use it when transforming points. // So the idea here is to reconstruct a local point by unrotating the screen point about the center of the bounding box. The approach is: // 1) Get vector from the screen point to the center of the rotated bounding box in screens space // 2) unrotate that vector in screen space // 3) localize the unrotated vector by scaling into the marquee container's coordinates // 4) reattach the vector to the center of the bounding box getTransformedScreenPt = (down: number[]) => { const { marqueeContainer } = this.props; const containerXf = this.props.screenTransform(); const boundingRect = marqueeContainer.getBoundingClientRect(); const center = { x: boundingRect.x + boundingRect.width / 2, y: boundingRect.y + boundingRect.height / 2 }; const downVec = Utils.rotPt(down[0] - center.x, down[1] - center.y, NumCast(containerXf.Rotate)); // prettier-ignore return { x: downVec.x * containerXf.Scale + marqueeContainer.offsetWidth /2, y: downVec.y * containerXf.Scale + marqueeContainer.offsetHeight/2 + 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 = () => { if (this.props.anchorMenuCrop) { UndoManager.RunInBatch(() => this.props.anchorMenuCrop?.(this.highlight('', true, undefined, false), true), 'cropping'); } }; AnchorMenu.Instance.OnClick = undoable(() => 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 = (color: string) => this.highlight(color, false, undefined, true); 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 targetCreator = (annotationOn: Doc | undefined) => { const target = DocUtils.GetNewTextDoc('Note linked to ' + this.props.Document.title, 0, 0, 100, 100, annotationOn, 'yellow'); target.layout_fitWidth = true; DocumentView.SetSelectOnLoad(target); return target; }; const sourceAnchorCreator = () => this.highlight(this.props.highlightDragSrcColor ?? 'rgba(173, 216, 230, 0.75)', true, undefined, true); // hyperlink color DragManager.StartAnchorAnnoDrag([ele], new DragManager.AnchorAnnoDragData(this.props.docView(), sourceAnchorCreator, targetCreator), e.pageX, e.pageY, { dragComplete: dragEv => { if (!dragEv.aborted && dragEv.annoDragData && dragEv.annoDragData.linkSourceDoc && dragEv.annoDragData.dropDocument && dragEv.linkDocument) { dragEv.annoDragData.linkSourceDoc.followLinkToggle = dragEv.annoDragData.dropDocument.annotationOn === this.props.Document; dragEv.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(); let 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: dragEx => { if (!dragEx.aborted && dragEx.linkDocument) { dragEx.linkDocument.$link_relationship = 'cropped image'; dragEx.linkDocument.$title = 'crop: ' + this.props.Document.title; } }, }); }); } public onTerminateSelection() { document.removeEventListener('pointermove', this.onSelectMove); document.removeEventListener('pointerup', this.onSelectEnd); } @action onMove = (pt: number[]) => { const movLoc = this.getTransformedScreenPt(pt); this.Width = movLoc.x - this._start.x; this.Height = movLoc.y - this._start.y; }; @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. }; onSelectEnd = (e: PointerEvent) => { e.stopPropagation(); this.onEnd(e.clientX, e.clientY); }; @action onEnd = (x: number, y: number) => { AnchorMenu.Instance.setSelectedText(''); const marquees = this.props.marqueeContainer.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: HTMLDivElement & { marqueeing?: boolean } = document.createElement('div'); const scale = (this.props.scaling?.() || 1) * NumCast(this.props.Document._freeform_scale, 1); ['border', 'opacity', 'top', 'left', 'width', 'height'].forEach(prop => { copy.style[prop as unknown as number] = marqueeStyle[prop as unknown as number]; // bcz: hack to get around TS type checking for array index with strings }); 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.marqueeing = true; MarqueeAnnotator.previewNewAnnotation(this.props.savedAnnotations(), this.props.annotationLayer, copy, this.props.getPageFromScroll?.(this.top) || 0); AnchorMenu.Instance.jumpTo(x, y); } this.props.finishMarquee(this.isEmpty ? x : undefined, this.isEmpty ? y : undefined); this.Width = this.Height = 0; }; get isEmpty() { return Math.abs(this.Width) <= 10 && Math.abs(this.Height) <= 10; } render() { return (
); } }