import { Bezier } from 'bezier-js'; import { Colors } from 'browndash-components'; import { action, computed, IReactionDisposer, makeObservable, observable, reaction, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import { computedFn } from 'mobx-utils'; import * as React from 'react'; import { DateField } from '../../../../fields/DateField'; import { Doc, DocListCast, Field, Opt } from '../../../../fields/Doc'; import { DocData, Height, Width } from '../../../../fields/DocSymbols'; import { Id } from '../../../../fields/FieldSymbols'; import { InkData, InkField, InkTool, PointData, Segment } from '../../../../fields/InkField'; import { List } from '../../../../fields/List'; import { RichTextField } from '../../../../fields/RichTextField'; import { listSpec } from '../../../../fields/Schema'; import { ScriptField } from '../../../../fields/ScriptField'; import { BoolCast, Cast, DocCast, NumCast, ScriptCast, StrCast } from '../../../../fields/Types'; import { ImageField } from '../../../../fields/URLField'; import { TraceMobx } from '../../../../fields/util'; import { GestureUtils } from '../../../../pen-gestures/GestureUtils'; import { aggregateBounds, DashColor, emptyFunction, intersectRect, lightOrDark, OmitKeys, returnFalse, returnZero, setupMoveUpEvents, Utils } from '../../../../Utils'; import { CognitiveServices } from '../../../cognitive_services/CognitiveServices'; import { Docs, DocUtils } from '../../../documents/Documents'; import { CollectionViewType, DocumentType } from '../../../documents/DocumentTypes'; import { DocumentManager } from '../../../util/DocumentManager'; import { DragManager, dropActionType } from '../../../util/DragManager'; import { ReplayMovements } from '../../../util/ReplayMovements'; import { CompileScript } from '../../../util/Scripting'; import { ScriptingGlobals } from '../../../util/ScriptingGlobals'; import { SelectionManager } from '../../../util/SelectionManager'; import { freeformScrollMode } from '../../../util/SettingsManager'; import { SnappingManager } from '../../../util/SnappingManager'; import { Transform } from '../../../util/Transform'; import { undoable, undoBatch, UndoManager } from '../../../util/UndoManager'; import { Timeline } from '../../animationtimeline/Timeline'; import { ContextMenu } from '../../ContextMenu'; import { GestureOverlay } from '../../GestureOverlay'; import { CtrlKey } from '../../GlobalKeyHandler'; import { ActiveInkWidth, InkingStroke, SetActiveInkColor, SetActiveInkWidth } from '../../InkingStroke'; import { LightboxView } from '../../LightboxView'; import { CollectionFreeFormDocumentView } from '../../nodes/CollectionFreeFormDocumentView'; import { SchemaCSVPopUp } from '../../nodes/DataVizBox/SchemaCSVPopUp'; import { DocumentView, OpenWhere } from '../../nodes/DocumentView'; import { FieldViewProps, FocusViewOptions } from '../../nodes/FieldView'; import { FormattedTextBox } from '../../nodes/formattedText/FormattedTextBox'; import { PinProps, PresBox } from '../../nodes/trails/PresBox'; import { CreateImage } from '../../nodes/WebBoxRenderer'; import { StyleProp } from '../../StyleProvider'; import { CollectionSubView } from '../CollectionSubView'; import { TreeViewType } from '../CollectionTreeView'; import { CollectionFreeFormBackgroundGrid } from './CollectionFreeFormBackgroundGrid'; import { CollectionFreeFormInfoUI } from './CollectionFreeFormInfoUI'; import { computePassLayout, computePivotLayout, computeStarburstLayout, computeTimelineLayout, PoolData, ViewDefBounds, ViewDefResult } from './CollectionFreeFormLayoutEngines'; import { CollectionFreeFormPannableContents } from './CollectionFreeFormPannableContents'; import { CollectionFreeFormRemoteCursors } from './CollectionFreeFormRemoteCursors'; import './CollectionFreeFormView.scss'; import { MarqueeView } from './MarqueeView'; export interface collectionFreeformViewProps { NativeWidth?: () => number; NativeHeight?: () => number; originTopLeft?: boolean; annotationLayerHostsContent?: boolean; // whether to force scaling of content (needed by ImageBox) viewDefDivClick?: ScriptField; childPointerEvents?: () => string | undefined; viewField?: string; noOverlay?: boolean; // used to suppress docs in the overlay (z) layer (ie, for minimap since overlay doesn't scale) engineProps?: any; getScrollHeight?: () => number | undefined; } @observer export class CollectionFreeFormView extends CollectionSubView>() { public get displayName() { return 'CollectionFreeFormView(' + this.Document.title?.toString() + ')'; } // this makes mobx trace() statements more descriptive @observable _paintedId = 'id' + Utils.GenerateGuid().replace(/-/g, ''); @computed get paintFunc() { const field = this.dataDoc[this.fieldKey]; const paintFunc = StrCast(Field.toJavascriptString(Cast(field, RichTextField, null)?.Text as Field)).trim(); return !paintFunc ? '' : paintFunc.includes('dashDiv') ? `const dashDiv = document.querySelector('#${this._paintedId}'); (async () => { ${paintFunc} })()` : paintFunc; } constructor(props: any) { super(props); makeObservable(this); } @observable public static ShowPresPaths = false; private _panZoomTransitionTimer: any; private _lastX: number = 0; private _lastY: number = 0; private _downX: number = 0; private _downY: number = 0; private _downTime = 0; private _clusterDistance: number = 75; private _hitCluster: number = -1; private _disposers: { [name: string]: IReactionDisposer } = {}; private _renderCutoffData = observable.map(); private _batch: UndoManager.Batch | undefined = undefined; private _brushtimer: any; private _brushtimer1: any; public get isAnnotationOverlay() { return this._props.isAnnotationOverlay; } public get scaleFieldKey() { return (this._props.viewField ?? '') + '_freeform_scale'; } private get panXFieldKey() { return (this._props.viewField ?? '') + '_freeform_panX'; } private get panYFieldKey() { return (this._props.viewField ?? '') + '_freeform_panY'; } private get autoResetFieldKey() { return (this._props.viewField ?? '') + '_freeform_autoReset'; } @observable.shallow _layoutElements: ViewDefResult[] = []; // shallow because some layout items (eg pivot labels) are just generated 'divs' and can't be frozen as observables @observable _panZoomTransition: number = 0; // sets the pan/zoom transform ease time- used by nudge(), focus() etc to smoothly zoom/pan. set to 0 to use document's transition time or default of 0 @observable _firstRender = false; // this turns off rendering of the collection's content so that there's instant feedback when a tab is switched of what content will be shown. could be used for performance improvement @observable _showAnimTimeline = false; @observable _clusterSets: Doc[][] = []; @observable _deleteList: DocumentView[] = []; @observable _timelineRef = React.createRef(); @observable _marqueeViewRef = React.createRef(); @observable _brushedView: { width: number; height: number; panX: number; panY: number } | undefined = undefined; // highlighted region of freeform canvas used by presentations to indicate a region @observable GroupChildDrag: boolean = false; // child document view being dragged. needed to update drop areas of groups when a group item is dragged. @computed get contentViews() { const viewsMask = this._layoutElements.filter(ele => ele.bounds && !ele.bounds.z && ele.inkMask !== -1 && ele.inkMask !== undefined).map(ele => ele.ele); const renderableEles = this._layoutElements.filter(ele => ele.bounds && !ele.bounds.z && (ele.inkMask === -1 || ele.inkMask === undefined)).map(ele => ele.ele); if (viewsMask.length) renderableEles.push(
(ele.inkMask ?? 0) > 0) ? '' : '-empty'}`}>{viewsMask}
); return renderableEles; } @computed get fitToContentVals() { const hgt = this.contentBounds.b - this.contentBounds.y; const wid = this.contentBounds.r - this.contentBounds.x; return { bounds: { ...this.contentBounds, cx: this.contentBounds.x + wid / 2, cy: this.contentBounds.y + hgt / 2 }, scale: (!this.childDocs.length || !Number.isFinite(hgt) || !Number.isFinite(wid) ? 1 // : Math.min(this._props.PanelHeight() / hgt, this._props.PanelWidth() / wid)) / (this._props.NativeDimScaling?.() || 1), }; } @computed get fitContentsToBox() { return (this._props.fitContentsToBox?.() || this.Document._freeform_fitContentsToBox) && !this.isAnnotationOverlay; } @computed get contentBounds() { const cb = Cast(this.dataDoc.contentBounds, listSpec('number')); return cb ? { x: cb[0], y: cb[1], r: cb[2], b: cb[3] } : aggregateBounds( this._layoutElements.filter(e => e.bounds?.width && !e.bounds.z).map(e => e.bounds!), NumCast(this.layoutDoc._xPadding, this._props.xPadding ?? 10), NumCast(this.layoutDoc._yPadding, this._props.yPadding ?? 10) ); } @computed get nativeWidth() { return this._props.NativeWidth?.() || Doc.NativeWidth(this.Document, Cast(this.Document.resolvedDataDoc, Doc, null)); } @computed get nativeHeight() { return this._props.NativeHeight?.() || Doc.NativeHeight(this.Document, Cast(this.Document.resolvedDataDoc, Doc, null)); } @computed get centeringShiftX(): number { const scaling = this.nativeDimScaling; return this._props.isAnnotationOverlay || this._props.originTopLeft ? 0 : this._props.PanelWidth() / 2 / scaling; // shift so pan position is at center of window for non-overlay collections } @computed get centeringShiftY(): number { const panLocAtCenter = !(this._props.isAnnotationOverlay || this._props.originTopLeft); if (!panLocAtCenter) return 0; const dv = this.DocumentView?.(); const aspect = !(this._props.layout_fitWidth?.(this.Document) ?? dv?.layoutDoc.layout_fitWidth) && dv?.nativeWidth && dv?.nativeHeight; const scaling = this.nativeDimScaling; // if freeform has a native aspect, then the panel height needs to be adjusted to match it const height = aspect ? (dv.nativeHeight / dv.nativeWidth) * this._props.PanelWidth() : this._props.PanelHeight(); return height / 2 / scaling; // shift so pan position is at center of window for non-overlay collections } @computed get panZoomXf() { return new Transform(this.panX(), this.panY(), 1 / this.zoomScaling()); } @computed get screenToFreeformContentsXf() { return this._props .ScreenToLocalTransform() // .translate(-this.centeringShiftX, -this.centeringShiftY) .transform(this.panZoomXf); } public static gotoKeyframe(timer: NodeJS.Timeout | undefined, docs: Doc[], duration: number) { return DocumentView.SetViewTransition(docs, 'all', duration, timer, undefined, true); } public static updateKeyframe(timer: NodeJS.Timeout | undefined, docs: Doc[], time: number) { const newTimer = DocumentView.SetViewTransition(docs, 'all', 1000, timer, undefined, true); const timecode = Math.round(time); docs.forEach(doc => { CollectionFreeFormDocumentView.animFields.forEach(val => { const findexed = Cast(doc[`${val.key}_indexed`], listSpec('number'), null); findexed?.length <= timecode + 1 && findexed.push(undefined as any as number); }); CollectionFreeFormDocumentView.animStringFields.forEach(val => { const findexed = Cast(doc[`${val}_indexed`], listSpec('string'), null); findexed?.length <= timecode + 1 && findexed.push(undefined as any as string); }); CollectionFreeFormDocumentView.animDataFields(doc).forEach(val => { const findexed = Cast(doc[`${val}_indexed`], listSpec(InkField), null); findexed?.length <= timecode + 1 && findexed.push(undefined as any); }); }); return newTimer; } _keyTimer: NodeJS.Timeout | undefined; // timer for turning off transition flag when key frame change has completed. Need to clear this if you do a second navigation before first finishes, or else first timer can go off during second naviation. changeKeyFrame = (back = false) => { const currentFrame = Cast(this.Document._currentFrame, 'number', null); if (currentFrame === undefined) { this.Document._currentFrame = 0; CollectionFreeFormDocumentView.setupKeyframes(this.childDocs, 0); } if (back) { this._keyTimer = CollectionFreeFormView.gotoKeyframe(this._keyTimer, [...this.childDocs, this.layoutDoc], 1000); this.Document._currentFrame = Math.max(0, (currentFrame || 0) - 1); } else { this._keyTimer = CollectionFreeFormView.updateKeyframe(this._keyTimer, [...this.childDocs, this.layoutDoc], currentFrame || 0); this.Document._currentFrame = Math.max(0, (currentFrame || 0) + 1); this.Document.lastFrame = Math.max(NumCast(this.Document._currentFrame), NumCast(this.Document.lastFrame)); } }; @observable _keyframeEditing = false; @action setKeyFrameEditing = (set: boolean) => (this._keyframeEditing = set); getKeyFrameEditing = () => this._keyframeEditing; onBrowseClickHandler = () => this._props.onBrowseClickScript?.() || ScriptCast(this.layoutDoc.onBrowseClick); onChildClickHandler = () => this._props.childClickScript || ScriptCast(this.Document.onChildClick); onChildDoubleClickHandler = () => this._props.childDoubleClickScript || ScriptCast(this.Document.onChildDoubleClick); elementFunc = () => this._layoutElements; viewTransition = () => (this._panZoomTransition ? '' + this._panZoomTransition : undefined); fitContentOnce = () => { const vals = this.fitToContentVals; this.layoutDoc._freeform_panX = vals.bounds.cx; this.layoutDoc._freeform_panY = vals.bounds.cy; this.layoutDoc._freeform_scale = vals.scale; }; freeformData = (force?: boolean) => (!this._firstRender && (this.fitContentsToBox || force) ? this.fitToContentVals : undefined); // freeform_panx, freeform_pany, freeform_scale all attempt to get values first from the layout controller, then from the layout/dataDoc (or template layout doc), and finally from the resolved template data document. // this search order, for example, allows icons of cropped images to find the panx/pany/zoom on the cropped image's data doc instead of the usual layout doc because the zoom/panX/panY define the cropped image panX = () => this.freeformData()?.bounds.cx ?? NumCast(this.Document[this.panXFieldKey], NumCast(Cast(this.Document.resolvedDataDoc, Doc, null)?.freeform_panX, 1)); panY = () => this.freeformData()?.bounds.cy ?? NumCast(this.Document[this.panYFieldKey], NumCast(Cast(this.Document.resolvedDataDoc, Doc, null)?.freeform_panY, 1)); zoomScaling = () => this.freeformData()?.scale ?? NumCast(Doc.Layout(this.Document)[this.scaleFieldKey], 1); //, NumCast(DocCast(this.Document.resolvedDataDoc)?.[this.scaleFieldKey], 1)); PanZoomCenterXf = () => (this._props.isAnnotationOverlay && this.zoomScaling() === 1 ? `` : `translate(${this.centeringShiftX}px, ${this.centeringShiftY}px) scale(${this.zoomScaling()}) translate(${-this.panX()}px, ${-this.panY()}px)`); ScreenToContentsXf = () => this.screenToFreeformContentsXf.copy(); getActiveDocuments = () => this.childLayoutPairs.filter(pair => this.isCurrent(pair.layout)).map(pair => pair.layout); isAnyChildContentActive = () => this._props.isAnyChildContentActive(); addLiveTextBox = (newDoc: Doc) => { FormattedTextBox.SetSelectOnLoad(newDoc); // track the new text box so we can give it a prop that tells it to focus itself when it's displayed this.addDocument(newDoc); }; selectDocuments = (docs: Doc[]) => { SelectionManager.DeselectAll(); docs.map(doc => DocumentManager.Instance.getDocumentView(doc, this.DocumentView?.())).forEach(dv => dv && SelectionManager.SelectView(dv, true)); }; addDocument = (newBox: Doc | Doc[]) => { let retVal = false; if (newBox instanceof Doc) { if ((retVal = this._props.addDocument?.(newBox) || false)) { this.bringToFront(newBox); this.updateCluster(newBox); } } else { retVal = this._props.addDocument?.(newBox) || false; // bcz: deal with clusters } if (retVal) { const newBoxes = newBox instanceof Doc ? [newBox] : newBox; for (const newBox of newBoxes) { if (newBox.activeFrame !== undefined) { const vals = CollectionFreeFormDocumentView.animFields.map(field => newBox[field.key]); CollectionFreeFormDocumentView.animFields.forEach(field => delete newBox[`${field.key}_indexed`]); CollectionFreeFormDocumentView.animFields.forEach(field => delete newBox[field.key]); delete newBox.activeFrame; CollectionFreeFormDocumentView.animFields.forEach((field, i) => field.key !== 'opacity' && (newBox[field.key] = vals[i])); } } if (this.Document._currentFrame !== undefined && !this._props.isAnnotationOverlay) { CollectionFreeFormDocumentView.setupKeyframes(newBoxes, NumCast(this.Document._currentFrame), true); } } return retVal; }; isCurrent(doc: Doc) { const dispTime = NumCast(doc._timecodeToShow, -1); const endTime = NumCast(doc._timecodeToHide, dispTime + 1.5); const curTime = NumCast(this.Document._layout_currentTimecode, -1); return dispTime === -1 || curTime === -1 || (curTime - dispTime >= -1e-4 && curTime <= endTime); } groupFocus = (anchor: Doc, options: FocusViewOptions) => { options.docTransform = new Transform(-NumCast(this.layoutDoc[this.panXFieldKey]) + NumCast(anchor.x), -NumCast(this.layoutDoc[this.panYFieldKey]) + NumCast(anchor.y), 1); const res = this._props.focus(this.Document, options); options.docTransform = undefined; return res; }; focus = (anchor: Doc, options: FocusViewOptions) => { if (this._lightboxDoc) return; if (anchor === this.Document) { // if (options.willZoomCentered && options.zoomScale) { // this.fitContentOnce(); // options.didMove = true; // } } if (anchor.type !== DocumentType.CONFIG && !DocListCast(this.Document[this.fieldKey ?? Doc.LayoutFieldKey(this.Document)]).includes(anchor) && !this.childLayoutPairs.map(pair => pair.layout).includes(anchor)) return; const xfToCollection = options?.docTransform ?? Transform.Identity(); const savedState = { panX: NumCast(this.Document[this.panXFieldKey]), panY: NumCast(this.Document[this.panYFieldKey]), scale: options?.willZoomCentered ? this.Document[this.scaleFieldKey] : undefined }; const cantTransform = this.fitContentsToBox || ((this.Document.isGroup || this.layoutDoc._lockedTransform) && !LightboxView.LightboxDoc); const { panX, panY, scale } = cantTransform || (!options.willPan && !options.willZoomCentered) ? savedState : this.calculatePanIntoView(anchor, xfToCollection, options?.willZoomCentered ? options?.zoomScale ?? 0.75 : undefined); // focus on the document in the collection const didMove = !cantTransform && !anchor.z && (panX !== savedState.panX || panY !== savedState.panY || scale !== savedState.scale); if (didMove) options.didMove = true; // glr: freeform transform speed can be set by adjusting presentation_transition field - needs a way of knowing when presentation is not active... if (didMove) { const focusTime = options?.instant ? 0 : options.zoomTime ?? 500; (options.zoomScale ?? options.willZoomCentered) && scale && (this.Document[this.scaleFieldKey] = scale); this.setPan(panX, panY, focusTime, true); // docs that are floating in their collection can't be panned to from their collection -- need to propagate the pan to a parent freeform somehow return focusTime; } }; getView = async (doc: Doc, options: FocusViewOptions): Promise> => new Promise>(res => { if (doc.hidden && this._lightboxDoc !== doc) options.didMove = !(doc.hidden = false); if (doc === this.Document) return res(this.DocumentView?.()); const findDoc = (finish: (dv: DocumentView) => void) => DocumentManager.Instance.AddViewRenderedCb(doc, dv => finish(dv)); findDoc(dv => res(dv)); }); internalDocDrop(e: Event, de: DragManager.DropEvent, docDragData: DragManager.DocumentDragData) { if (!super.onInternalDrop(e, de)) return false; const refDoc = docDragData.droppedDocuments[0]; const fromScreenXf = NumCast(refDoc.z) ? this.ScreenToLocalBoxXf() : this.screenToFreeformContentsXf; const [xpo, ypo] = fromScreenXf.transformPoint(de.x, de.y); const x = xpo - docDragData.offset[0]; const y = ypo - docDragData.offset[1]; const zsorted = this.childLayoutPairs .map(pair => pair.layout) .slice() .sort((doc1, doc2) => NumCast(doc1.zIndex) - NumCast(doc2.zIndex)); zsorted.forEach((doc, index) => (doc.zIndex = doc.stroke_isInkMask ? 5000 : index + 1)); const dvals = CollectionFreeFormDocumentView.getValues(refDoc, NumCast(refDoc.activeFrame, 1000)); const dropPos = this.Document._currentFrame !== undefined ? [NumCast(dvals.x), NumCast(dvals.y)] : [NumCast(refDoc.x), NumCast(refDoc.y)]; for (let i = 0; i < docDragData.droppedDocuments.length; i++) { const d = docDragData.droppedDocuments[i]; const layoutDoc = Doc.Layout(d); const delta = Utils.rotPt(x - dropPos[0], y - dropPos[1], fromScreenXf.Rotate); if (this.Document._currentFrame !== undefined) { CollectionFreeFormDocumentView.setupKeyframes([d], NumCast(this.Document._currentFrame), false); const pvals = CollectionFreeFormDocumentView.getValues(d, NumCast(d.activeFrame, 1000)); // get filled in values (uses defaults when not value is specified) for position const vals = CollectionFreeFormDocumentView.getValues(d, NumCast(d.activeFrame, 1000), false); // get non-default values for everything else vals.x = NumCast(pvals.x) + delta.x; vals.y = NumCast(pvals.y) + delta.y; CollectionFreeFormDocumentView.setValues(NumCast(this.Document._currentFrame), d, vals); } else { d.x = NumCast(d.x) + delta.x; d.y = NumCast(d.y) + delta.y; } d._layout_modificationDate = new DateField(); const nd = [Doc.NativeWidth(layoutDoc), Doc.NativeHeight(layoutDoc)]; layoutDoc._width = NumCast(layoutDoc._width, 300); layoutDoc._height = NumCast(layoutDoc._height, nd[0] && nd[1] ? (nd[1] / nd[0]) * NumCast(layoutDoc._width) : 300); !d._keepZWhenDragged && (d.zIndex = zsorted.length + 1 + i); // bringToFront } (docDragData.droppedDocuments.length === 1 || de.shiftKey) && this.updateClusterDocs(docDragData.droppedDocuments); return true; } @undoBatch internalAnchorAnnoDrop(e: Event, de: DragManager.DropEvent, annoDragData: DragManager.AnchorAnnoDragData) { const dropCreator = annoDragData.dropDocCreator; const [xp, yp] = this.screenToFreeformContentsXf.transformPoint(de.x, de.y); annoDragData.dropDocCreator = (annotationOn: Doc | undefined) => { const dropDoc = dropCreator(annotationOn); if (dropDoc) { dropDoc.x = xp - annoDragData.offset[0]; dropDoc.y = yp - annoDragData.offset[1]; this.bringToFront(dropDoc); } return dropDoc || this.Document; }; return true; } @undoBatch internalLinkDrop(e: Event, de: DragManager.DropEvent, linkDragData: DragManager.LinkDragData) { if (this.DocumentView?.() && linkDragData.linkDragView.containerViewPath?.().includes(this.DocumentView())) { const [x, y] = this.screenToFreeformContentsXf.transformPoint(de.x, de.y); let added = false; // do nothing if link is dropped into any freeform view parent of dragged document const source = Docs.Create.TextDocument('', { _width: 200, _height: 75, x, y, title: 'dropped annotation' }); added = this._props.addDocument?.(source) ? true : false; de.complete.linkDocument = DocUtils.MakeLink(linkDragData.linkSourceGetAnchor(), source, { link_relationship: 'annotated by:annotation of' }); // TODODO this is where in text links get passed if (de.complete.linkDocument) { de.complete.linkDocument.layout_isSvg = true; this.addDocument(de.complete.linkDocument); } e.stopPropagation(); !added && e.preventDefault(); return added; } return false; } onInternalDrop = (e: Event, de: DragManager.DropEvent) => { if (de.complete.annoDragData?.dragDocument && super.onInternalDrop(e, de)) return this.internalAnchorAnnoDrop(e, de, de.complete.annoDragData); else if (de.complete.linkDragData) return this.internalLinkDrop(e, de, de.complete.linkDragData); else if (de.complete.docDragData?.droppedDocuments.length) return this.internalDocDrop(e, de, de.complete.docDragData); return false; }; onExternalDrop = (e: React.DragEvent) => (([x, y]) => super.onExternalDrop(e, { x, y }))(this.screenToFreeformContentsXf.transformPoint(e.pageX, e.pageY)); static overlapping(doc1: Doc, doc2: Doc, clusterDistance: number) { const doc2Layout = Doc.Layout(doc2); const doc1Layout = Doc.Layout(doc1); const x2 = NumCast(doc2.x) - clusterDistance; const y2 = NumCast(doc2.y) - clusterDistance; const w2 = NumCast(doc2Layout._width) + clusterDistance; const h2 = NumCast(doc2Layout._height) + clusterDistance; const x = NumCast(doc1.x) - clusterDistance; const y = NumCast(doc1.y) - clusterDistance; const w = NumCast(doc1Layout._width) + clusterDistance; const h = NumCast(doc1Layout._height) + clusterDistance; return doc1.z === doc2.z && intersectRect({ left: x, top: y, width: w, height: h }, { left: x2, top: y2, width: w2, height: h2 }); } pickCluster(probe: number[]) { return this.childLayoutPairs .map(pair => pair.layout) .reduce((cluster, cd) => { const grouping = this.Document._freeform_useClusters ? NumCast(cd.layout_cluster, -1) : NumCast(cd.group, -1); if (grouping !== -1) { const layoutDoc = Doc.Layout(cd); const cx = NumCast(cd.x) - this._clusterDistance / 2; const cy = NumCast(cd.y) - this._clusterDistance / 2; const cw = NumCast(layoutDoc._width) + this._clusterDistance; const ch = NumCast(layoutDoc._height) + this._clusterDistance; return !layoutDoc.z && intersectRect({ left: cx, top: cy, width: cw, height: ch }, { left: probe[0], top: probe[1], width: 1, height: 1 }) ? grouping : cluster; } return cluster; }, -1); } tryDragCluster(e: PointerEvent, cluster: number) { if (cluster !== -1) { const ptsParent = e; if (ptsParent) { const eles = this.childLayoutPairs.map(pair => pair.layout).filter(cd => (this.Document._freeform_useClusters ? NumCast(cd.layout_cluster) : NumCast(cd.group, -1)) === cluster); const clusterDocs = eles.map(ele => DocumentManager.Instance.getDocumentView(ele, this.DocumentView?.())!); const { left, top } = clusterDocs[0].getBounds || { left: 0, top: 0 }; const de = new DragManager.DocumentDragData(eles, e.ctrlKey || e.altKey ? dropActionType.embed : undefined); de.moveDocument = this._props.moveDocument; de.offset = this.screenToFreeformContentsXf.transformDirection(ptsParent.clientX - left, ptsParent.clientY - top); DragManager.StartDocumentDrag( clusterDocs.map(v => v.ContentDiv!), de, ptsParent.clientX, ptsParent.clientY, { hideSource: !de.dropAction } ); return true; } } return false; } @action updateClusters(_freeform_useClusters: boolean) { this.Document._freeform_useClusters = _freeform_useClusters; this._clusterSets.length = 0; this.childLayoutPairs.map(pair => pair.layout).map(c => this.updateCluster(c)); } @action updateClusterDocs(docs: Doc[]) { const childLayouts = this.childLayoutPairs.map(pair => pair.layout); if (this.Document._freeform_useClusters) { const docFirst = docs[0]; docs.map(doc => this._clusterSets.map(set => Doc.IndexOf(doc, set) !== -1 && set.splice(Doc.IndexOf(doc, set), 1))); const preferredInd = NumCast(docFirst.layout_cluster); docs.map(doc => (doc.layout_cluster = -1)); docs.map(doc => this._clusterSets.map((set, i) => set.map(member => { if (docFirst.layout_cluster === -1 && Doc.IndexOf(member, childLayouts) !== -1 && CollectionFreeFormView.overlapping(doc, member, this._clusterDistance)) { docFirst.layout_cluster = i; } }) ) ); if ( docFirst.layout_cluster === -1 && preferredInd !== -1 && this._clusterSets.length > preferredInd && (!this._clusterSets[preferredInd] || !this._clusterSets[preferredInd].filter(member => Doc.IndexOf(member, childLayouts) !== -1).length) ) { docFirst.layout_cluster = preferredInd; } this._clusterSets.map((set, i) => { if (docFirst.layout_cluster === -1 && !set.filter(member => Doc.IndexOf(member, childLayouts) !== -1).length) { docFirst.layout_cluster = i; } }); if (docFirst.layout_cluster === -1) { docs.map(doc => { doc.layout_cluster = this._clusterSets.length; this._clusterSets.push([doc]); }); } else if (this._clusterSets.length) { for (let i = this._clusterSets.length; i <= NumCast(docFirst.layout_cluster); i++) !this._clusterSets[i] && this._clusterSets.push([]); docs.map(doc => this._clusterSets[(doc.layout_cluster = NumCast(docFirst.layout_cluster))].push(doc)); } childLayouts.map(child => !this._clusterSets.some((set, i) => Doc.IndexOf(child, set) !== -1 && child.layout_cluster === i) && this.updateCluster(child)); } } @action updateCluster = (doc: Doc) => { const childLayouts = this.childLayoutPairs.map(pair => pair.layout); if (this.Document._freeform_useClusters) { this._clusterSets.forEach(set => Doc.IndexOf(doc, set) !== -1 && set.splice(Doc.IndexOf(doc, set), 1)); const preferredInd = NumCast(doc.layout_cluster); doc.layout_cluster = -1; this._clusterSets.forEach((set, i) => set.forEach(member => { if (doc.layout_cluster === -1 && Doc.IndexOf(member, childLayouts) !== -1 && CollectionFreeFormView.overlapping(doc, member, this._clusterDistance)) { doc.layout_cluster = i; } }) ); if (doc.layout_cluster === -1 && preferredInd !== -1 && this._clusterSets.length > preferredInd && (!this._clusterSets[preferredInd] || !this._clusterSets[preferredInd].filter(member => Doc.IndexOf(member, childLayouts) !== -1).length)) { doc.layout_cluster = preferredInd; } this._clusterSets.forEach((set, i) => { if (doc.layout_cluster === -1 && !set.filter(member => Doc.IndexOf(member, childLayouts) !== -1).length) { doc.layout_cluster = i; } }); if (doc.layout_cluster === -1) { doc.layout_cluster = this._clusterSets.length; this._clusterSets.push([doc]); } else if (this._clusterSets.length) { for (let i = this._clusterSets.length; i <= doc.layout_cluster; i++) !this._clusterSets[i] && this._clusterSets.push([]); this._clusterSets[doc.layout_cluster ?? 0].push(doc); } } }; clusterStyleProvider = (doc: Opt, props: Opt, property: string) => { let styleProp = this._props.styleProvider?.(doc, props, property); // bcz: check 'props' used to be renderDepth + 1 if (doc && this.childDocList?.includes(doc)) switch (property.split(':')[0]) { case StyleProp.BackgroundColor: const cluster = NumCast(doc?.layout_cluster); if (this.Document._freeform_useClusters && doc?.type !== DocumentType.IMG) { if (this._clusterSets.length <= cluster) { setTimeout(() => doc && this.updateCluster(doc)); } else { // choose a cluster color from a palette const colors = ['#da42429e', '#31ea318c', 'rgba(197, 87, 20, 0.55)', '#4a7ae2c4', 'rgba(216, 9, 255, 0.5)', '#ff7601', '#1dffff', 'yellow', 'rgba(27, 130, 49, 0.55)', 'rgba(0, 0, 0, 0.268)']; styleProp = colors[cluster % colors.length]; const set = this._clusterSets[cluster]?.filter(s => s.backgroundColor); // override the cluster color with an explicitly set color on a non-background document. then override that with an explicitly set color on a background document set?.map(s => (styleProp = StrCast(s.backgroundColor))); } } break; case StyleProp.FillColor: if (doc && this.Document._currentFrame !== undefined) { return CollectionFreeFormDocumentView.getStringValues(doc, NumCast(this.Document._currentFrame))?.fillColor; } } return styleProp; }; trySelectCluster = (addToSel: boolean) => { if (addToSel && this._hitCluster !== -1) { !addToSel && SelectionManager.DeselectAll(); const eles = this.childLayoutPairs.map(pair => pair.layout).filter(cd => (this.Document._freeform_useClusters ? NumCast(cd.layout_cluster) : NumCast(cd.group, -1)) === this._hitCluster); this.selectDocuments(eles); return true; } return false; }; @action onPointerDown = (e: React.PointerEvent): void => { this._downX = this._lastX = e.pageX; this._downY = this._lastY = e.pageY; this._downTime = Date.now(); const scrollMode = e.altKey ? (Doc.UserDoc().freeformScrollMode === freeformScrollMode.Pan ? freeformScrollMode.Zoom : freeformScrollMode.Pan) : Doc.UserDoc().freeformScrollMode; if (e.button === 0 && (!(e.ctrlKey && !e.metaKey) || scrollMode !== freeformScrollMode.Pan) && this._props.isContentActive()) { if (!this.Document.isGroup) { // group freeforms don't pan when dragged -- instead let the event go through to allow the group itself to drag // prettier-ignore switch (Doc.ActiveTool) { case InkTool.Highlighter: break; case InkTool.Write: break; case InkTool.Pen: break; // the GestureOverlay handles ink stroke input -- either as gestures, or drying as ink strokes that are added to document views case InkTool.Eraser: this._batch = UndoManager.StartBatch('collectionErase'); setupMoveUpEvents(this, e, this.onEraserMove, this.onEraserUp, emptyFunction); break; case InkTool.None: if (!(this._props.layoutEngine?.() || StrCast(this.layoutDoc._layoutEngine))) { this._hitCluster = this.pickCluster(this.screenToFreeformContentsXf.transformPoint(e.clientX, e.clientY)); setupMoveUpEvents(this, e, this.onPointerMove, emptyFunction, emptyFunction, this._hitCluster !== -1 ? true : false, false); } break; } } } }; public unprocessedDocs: Doc[] = []; public static collectionsWithUnprocessedInk = new Set(); @undoBatch onGesture = (e: Event, ge: GestureUtils.GestureEvent) => { switch (ge.gesture) { default: case GestureUtils.Gestures.Line: case GestureUtils.Gestures.Circle: case GestureUtils.Gestures.Rectangle: case GestureUtils.Gestures.Triangle: case GestureUtils.Gestures.Stroke: const points = ge.points; const B = this.screenToFreeformContentsXf.transformBounds(ge.bounds.left, ge.bounds.top, ge.bounds.width, ge.bounds.height); const inkWidth = ActiveInkWidth() * this.ScreenToLocalBoxXf().Scale; const inkDoc = Docs.Create.InkDocument( points, { title: ge.gesture.toString(), x: B.x - inkWidth / 2, y: B.y - inkWidth / 2, _width: B.width + inkWidth, _height: B.height + inkWidth, stroke_showLabel: BoolCast(Doc.UserDoc().activeInkHideTextLabels)}, // prettier-ignore inkWidth ); if (Doc.ActiveTool === InkTool.Write) { this.unprocessedDocs.push(inkDoc); CollectionFreeFormView.collectionsWithUnprocessedInk.add(this); } this.addDocument(inkDoc); e.stopPropagation(); break; case GestureUtils.Gestures.Rectangle: const strokes = this.getActiveDocuments() .filter(doc => doc.type === DocumentType.INK) .map(i => { const d = Cast(i.stroke, InkField); const x = NumCast(i.x) - Math.min(...(d?.inkData.map(pd => pd.X) ?? [0])); const y = NumCast(i.y) - Math.min(...(d?.inkData.map(pd => pd.Y) ?? [0])); return !d ? [] : d.inkData.map(pd => ({ X: x + pd.X, Y: y + pd.Y })); }); CognitiveServices.Inking.Appliers.InterpretStrokes(strokes).then(results => {}); break; case GestureUtils.Gestures.Text: if (ge.text) { const B = this.screenToFreeformContentsXf.transformPoint(ge.points[0].X, ge.points[0].Y); this.addDocument(Docs.Create.TextDocument(ge.text, { title: ge.text, x: B[0], y: B[1] })); e.stopPropagation(); } } }; @action onEraserUp = (e: PointerEvent): void => { this._deleteList.forEach(ink => ink._props.removeDocument?.(ink.Document)); this._deleteList = []; this._batch?.end(); }; @action onClick = (e: React.MouseEvent) => { if (this._lightboxDoc) this._lightboxDoc = undefined; if (Utils.isClick(e.pageX, e.pageY, this._downX, this._downY, this._downTime)) { if (this.onBrowseClickHandler()) { this.onBrowseClickHandler().script.run({ documentView: this.DocumentView?.(), clientX: e.clientX, clientY: e.clientY }); e.stopPropagation(); e.preventDefault(); } else if (this.isContentActive() && e.shiftKey) { // reset zoom of freeform view to 1-to-1 on a shift + double click this.zoomSmoothlyAboutPt(this.screenToFreeformContentsXf.transformPoint(e.clientX, e.clientY), 1); e.stopPropagation(); e.preventDefault(); } } }; @action scrollPan = (e: WheelEvent | { deltaX: number; deltaY: number }): void => { PresBox.Instance?.pauseAutoPres(); this.setPan(NumCast(this.Document[this.panXFieldKey]) - e.deltaX, NumCast(this.Document[this.panYFieldKey]) - e.deltaY, 0, true); }; @action pan = (e: PointerEvent): void => { const ctrlKey = e.ctrlKey && !e.shiftKey; const shiftKey = e.shiftKey && !e.ctrlKey; PresBox.Instance?.pauseAutoPres(); this.DocumentView?.().clearViewTransition(); const [dxi, dyi] = this.screenToFreeformContentsXf.transformDirection(e.clientX - this._lastX, e.clientY - this._lastY); const { x: dx, y: dy } = Utils.rotPt(dxi, dyi, this.ScreenToLocalBoxXf().Rotate); this.setPan(NumCast(this.Document[this.panXFieldKey]) - (ctrlKey ? 0 : dx), NumCast(this.Document[this.panYFieldKey]) - (shiftKey ? 0 : dy), 0, true); this._lastX = e.clientX; this._lastY = e.clientY; }; _eraserLock = 0; /** * Erases strokes by intersecting them with an invisible "eraser stroke". * By default this iterates through all intersected ink strokes, determines their segmentation, draws back the non-intersected segments, * and deletes the original stroke. * However, if Shift is held, then no segmentation is done -- instead any intersected stroke is deleted in its entirety. */ @action onEraserMove = (e: PointerEvent, down: number[], delta: number[]) => { const currPoint = { X: e.clientX, Y: e.clientY }; if (this._eraserLock) return false; // bcz: should be fixed by putting it on a queue to be processed after the last eraser movement is processed. this.getEraserIntersections({ X: currPoint.X - delta[0], Y: currPoint.Y - delta[1] }, currPoint).forEach(intersect => { if (!this._deleteList.includes(intersect.inkView)) { this._deleteList.push(intersect.inkView); SetActiveInkWidth(StrCast(intersect.inkView.Document.stroke_width?.toString()) || '1'); SetActiveInkColor(StrCast(intersect.inkView.Document.color?.toString()) || 'black'); // create a new curve by appending all curves of the current segment together in order to render a single new stroke. if (!e.shiftKey) { this._eraserLock++; const segments = this.segmentInkStroke(intersect.inkView, intersect.t); segments.forEach(segment => this.forceStrokeGesture( e, GestureUtils.Gestures.Stroke, segment.reduce((data, curve) => [...data, ...curve.points.map(p => intersect.inkView.ComponentView?.ptToScreen?.({ X: p.x, Y: p.y }) ?? { X: 0, Y: 0 })], [] as PointData[]) ) ); setTimeout(() => this._eraserLock--); } // Lower ink opacity to give the user a visual indicator of deletion. intersect.inkView.layoutDoc.opacity = 0.5; intersect.inkView.layoutDoc.dontIntersect = true; } }); return false; }; forceStrokeGesture = (e: PointerEvent, gesture: GestureUtils.Gestures, points: InkData, text?: any) => { this.onGesture(e, new GestureUtils.GestureEvent(gesture, points, GestureOverlay.getBounds(points), text)); }; onPointerMove = (e: PointerEvent) => { if (this.tryDragCluster(e, this._hitCluster)) { e.stopPropagation(); // we're moving a cluster, so stop propagation and return true to end panning and let the document drag take over return true; } // pan the view if this is a regular collection, or it's an overlay and the overlay is zoomed (otherwise, there's nothing to pan) if (!this._props.isAnnotationOverlay || 1 - NumCast(this.layoutDoc._freeform_scale_min, 1) / this.zoomScaling()) { this.pan(e); e.stopPropagation(); // if we are actually panning, stop propagation -- this will preven things like the overlayView from dragging the document while we're panning } return false; }; /** * Determines if the Eraser tool has intersected with an ink stroke in the current freeform collection. * @returns an array of tuples containing the intersected ink DocumentView and the t-value where it was intersected */ getEraserIntersections = (lastPoint: { X: number; Y: number }, currPoint: { X: number; Y: number }) => { const eraserMin = { X: Math.min(lastPoint.X, currPoint.X), Y: Math.min(lastPoint.Y, currPoint.Y) }; const eraserMax = { X: Math.max(lastPoint.X, currPoint.X), Y: Math.max(lastPoint.Y, currPoint.Y) }; return this.childDocs .map(doc => DocumentManager.Instance.getDocumentView(doc, this.DocumentView?.())) .filter(inkView => inkView?.ComponentView instanceof InkingStroke) .map(inkView => ({ inkViewBounds: inkView!.getBounds, inkStroke: inkView!.ComponentView as InkingStroke, inkView: inkView! })) .filter( ({ inkViewBounds }) => inkViewBounds && // bounding box of eraser segment and ink stroke overlap eraserMin.X <= inkViewBounds.right && eraserMin.Y <= inkViewBounds.bottom && eraserMax.X >= inkViewBounds.left && eraserMax.Y >= inkViewBounds.top ) .reduce( (intersections, { inkStroke, inkView }) => { const { inkData } = inkStroke.inkScaledData(); // Convert from screen space to ink space for the intersection. const prevPointInkSpace = inkStroke.ptFromScreen(lastPoint); const currPointInkSpace = inkStroke.ptFromScreen(currPoint); for (var i = 0; i < inkData.length - 3; i += 4) { const rawIntersects = InkField.Segment(inkData, i).intersects({ // compute all unique intersections p1: { x: prevPointInkSpace.X, y: prevPointInkSpace.Y }, p2: { x: currPointInkSpace.X, y: currPointInkSpace.Y }, }); const intersects = Array.from(new Set(rawIntersects as (number | string)[])); // convert to more manageable union array type // return tuples of the inkingStroke intersected, and the t value of the intersection intersections.push(...intersects.map(t => ({ inkView, t: +t + Math.floor(i / 4) }))); // convert string t's to numbers and add start of curve segment to convert from local t value to t value along complete curve } return intersections; }, [] as { t: number; inkView: DocumentView }[] ); }; /** * Performs segmentation of the ink stroke - creates "segments" or subsections of the current ink stroke at points in which the * ink stroke intersects any other ink stroke (including itself). * @param ink The ink DocumentView intersected by the eraser. * @param excludeT The index of the curve in the ink document that the eraser intersection occurred. * @returns The ink stroke represented as a list of segments, excluding the segment in which the eraser intersection occurred. */ @action segmentInkStroke = (ink: DocumentView, excludeT: number): Segment[] => { const segments: Segment[] = []; var segment: Segment = []; var startSegmentT = 0; const { inkData } = (ink?.ComponentView as InkingStroke).inkScaledData(); // This iterates through all segments of the curve and splits them where they intersect another curve. // if 'excludeT' is specified, then any segment containing excludeT will be skipped (ie, deleted) for (var i = 0; i < inkData.length - 3; i += 4) { const inkSegment = InkField.Segment(inkData, i); // Getting all t-value intersections of the current curve with all other curves. const tVals = this.getInkIntersections(i, ink, inkSegment).sort(); if (tVals.length) { tVals.forEach((t, index) => { const docCurveTVal = t + Math.floor(i / 4); if (excludeT < startSegmentT || excludeT > docCurveTVal) { const localStartTVal = startSegmentT - Math.floor(i / 4); t !== (localStartTVal < 0 ? 0 : localStartTVal) && segment.push(inkSegment.split(localStartTVal < 0 ? 0 : localStartTVal, t)); if (segment.length && (Math.abs(segment[0].points[0].x - segment[0].points.lastElement().x) > 0.5 || Math.abs(segment[0].points[0].y - segment[0].points.lastElement().y) > 0.5)) segments.push(segment); } // start a new segment from the intersection t value if (tVals.length - 1 === index) { const split = inkSegment.split(t).right; if (split && (Math.abs(split.points[0].x - split.points.lastElement().x) > 0.5 || Math.abs(split.points[0].y - split.points.lastElement().y) > 0.5)) segment = [split]; else segment = []; } else segment = []; startSegmentT = docCurveTVal; }); } else { segment.push(inkSegment); } } if (excludeT < startSegmentT || excludeT > inkData.length / 4) { segment.length && segments.push(segment); } return segments; }; // for some reason bezier.js doesn't handle the case of intersecting a linear curve, so we wrap the intersection // call in a test for linearity bintersects = (curve: Bezier, otherCurve: Bezier) => { if ((otherCurve as any)._linear) { return curve.lineIntersects({ p1: otherCurve.points[0], p2: otherCurve.points[3] }); } return curve.intersects(otherCurve); }; /** * Determines all possible intersections of the current curve of the intersected ink stroke with all other curves of all * ink strokes in the current collection. * @param i The index of the current curve within the inkData of the intersected ink stroke. * @param ink The intersected DocumentView of the ink stroke. * @param curve The current curve of the intersected ink stroke. * @returns A list of all t-values at which intersections occur at the current curve of the intersected ink stroke. */ getInkIntersections = (i: number, ink: DocumentView, curve: Bezier): number[] => { const tVals: number[] = []; // Iterating through all ink strokes in the current freeform collection. this.childDocs .filter(doc => doc.type === DocumentType.INK && !doc.dontIntersect) .forEach(doc => { const otherInk = DocumentManager.Instance.getDocumentView(doc, this.DocumentView?.())?.ComponentView as InkingStroke; const { inkData: otherInkData } = otherInk?.inkScaledData() ?? { inkData: [] }; const otherScreenPts = otherInkData.map(point => otherInk.ptToScreen(point)); const otherCtrlPts = otherScreenPts.map(spt => (ink.ComponentView as InkingStroke).ptFromScreen(spt)); for (var j = 0; j < otherCtrlPts.length - 3; j += 4) { const neighboringSegment = i === j || i === j - 4 || i === j + 4; // Ensuring that the curve intersected by the eraser is not checked for further ink intersections. if (ink?.Document === otherInk.Document && neighboringSegment) continue; const otherCurve = new Bezier(otherCtrlPts.slice(j, j + 4).map(p => ({ x: p.X, y: p.Y }))); const c0 = otherCurve.get(0); const c1 = otherCurve.get(1); const apt = curve.project(c0); const bpt = curve.project(c1); if (apt.d !== undefined && apt.d < 1 && apt.t !== undefined && !tVals.includes(apt.t)) { tVals.push(apt.t); } this.bintersects(curve, otherCurve).forEach((val: string | number, i: number) => { // Converting the Bezier.js Split type to a t-value number. const t = +val.toString().split('/')[0]; if (i % 2 === 0 && !tVals.includes(t)) tVals.push(t); // bcz: Hack! don't know why but intersection points are doubled from bezier.js (but not identical). }); if (bpt.d !== undefined && bpt.d < 1 && bpt.t !== undefined && !tVals.includes(bpt.t)) { tVals.push(bpt.t); } } }); return tVals; }; @action zoom = (pointX: number, pointY: number, deltaY: number): void => { if (this.Document.isGroup || this.Document[(this._props.viewField ?? '_') + 'freeform_noZoom']) return; let deltaScale = deltaY > 0 ? 1 / 1.05 : 1.05; if (deltaScale < 0) deltaScale = -deltaScale; const [x, y] = this.screenToFreeformContentsXf.transformPoint(pointX, pointY); const invTransform = this.panZoomXf.inverse(); if (deltaScale * invTransform.Scale > 20) { deltaScale = 20 / invTransform.Scale; } if (deltaScale < 1 && invTransform.Scale <= NumCast(this.Document[this.scaleFieldKey + '_min'])) { this.setPan(0, 0); return; } if (deltaScale * invTransform.Scale > NumCast(this.Document[this.scaleFieldKey + '_max'], Number.MAX_VALUE)) { deltaScale = NumCast(this.Document[this.scaleFieldKey + '_max'], 1) / invTransform.Scale; } if (deltaScale * invTransform.Scale < NumCast(this.Document[this.scaleFieldKey + '_min'], this.isAnnotationOverlay ? 1 : 0)) { deltaScale = NumCast(this.Document[this.scaleFieldKey + '_min'], 1) / invTransform.Scale; } const localTransform = invTransform.scaleAbout(deltaScale, x, y); if (localTransform.Scale >= 0.05 || localTransform.Scale > this.zoomScaling()) { const safeScale = Math.min(Math.max(0.05, localTransform.Scale), 20); this.Document[this.scaleFieldKey] = Math.abs(safeScale); this.setPan(-localTransform.TranslateX / safeScale, (this._props.originTopLeft ? undefined : NumCast(this.Document.layout_scrollTop) * safeScale) || -localTransform.TranslateY / safeScale); } }; @action onPointerWheel = (e: React.WheelEvent): void => { if (this.Document.isGroup || !this.isContentActive()) return; // group style collections neither pan nor zoom PresBox.Instance?.pauseAutoPres(); if (this.layoutDoc._Transform || this.Document.treeView_OutlineMode === TreeViewType.outline) return; e.stopPropagation(); const docHeight = NumCast(this.Document[Doc.LayoutFieldKey(this.Document) + '_nativeHeight'], this.nativeHeight); const scrollable = this.isAnnotationOverlay && NumCast(this.layoutDoc[this.scaleFieldKey], 1) === 1 && docHeight > this._props.PanelHeight() / this.nativeDimScaling + 1e-4; switch ( !e.ctrlKey && !e.shiftKey && !e.metaKey && !e.altKey ?// Doc.UserDoc().freeformScrollMode : // no modifiers, do assigned mode e.ctrlKey && !CtrlKey? // otherwise, if ctrl key (pinch gesture) try to zoom else pan freeformScrollMode.Zoom : freeformScrollMode.Pan // prettier-ignore ) { case freeformScrollMode.Pan: if (((!e.metaKey && !e.altKey) || Doc.UserDoc().freeformScrollMode === freeformScrollMode.Zoom) && this._props.isContentActive()) { const deltaX = e.shiftKey ? e.deltaX : e.ctrlKey ? 0 : e.deltaX; const deltaY = e.shiftKey ? 0 : e.ctrlKey ? e.deltaY : e.deltaY; this.scrollPan({ deltaX: -deltaX * this.screenToFreeformContentsXf.Scale, deltaY: e.shiftKey ? 0 : -deltaY * this.screenToFreeformContentsXf.Scale }); break; } default: case freeformScrollMode.Zoom: if ((e.ctrlKey || !scrollable) && this._props.isContentActive()) { this.zoom(e.clientX, e.clientY, Math.max(-1, Math.min(1, e.deltaY))); // if (!this._props.isAnnotationOverlay) // bcz: do we want to zoom in on images/videos/etc? // e.preventDefault(); } break; } }; @action setPan(panX: number, panY: number, panTime: number = 0, clamp: boolean = false) { // this is the easiest way to do this -> will talk with Bob about using mobx to do this to remove this line of code. if (Doc.UserDoc()?.presentationMode === 'watching') ReplayMovements.Instance.pauseFromInteraction(); if (!this.isAnnotationOverlay && clamp) { // this section wraps the pan position, horizontally and/or vertically whenever the content is panned out of the viewing bounds const docs = this.childLayoutPairs.map(pair => pair.layout).filter(doc => doc instanceof Doc && doc.type !== DocumentType.LINK); const measuredDocs = docs .map(doc => ({ pos: { x: NumCast(doc.x), y: NumCast(doc.y) }, size: { width: NumCast(doc._width), height: NumCast(doc._height) } })) .filter(({ pos, size }) => pos && size) .map(({ pos, size }) => ({ pos: pos!, size: size! })); if (measuredDocs.length) { const ranges = measuredDocs.reduce( ( { xrange, yrange }, { pos, size } // computes range of content ) => ({ xrange: { min: Math.min(xrange.min, pos.x), max: Math.max(xrange.max, pos.x + (size.width || 0)) }, yrange: { min: Math.min(yrange.min, pos.y), max: Math.max(yrange.max, pos.y + (size.height || 0)) }, }), { xrange: { min: this._props.originTopLeft ? 0 : Number.MAX_VALUE, max: -Number.MAX_VALUE }, yrange: { min: this._props.originTopLeft ? 0 : Number.MAX_VALUE, max: -Number.MAX_VALUE }, } ); const scaling = this.zoomScaling() * (this._props.NativeDimScaling?.() || 1); const panelWidMax = (this._props.PanelWidth() / scaling) * (this._props.originTopLeft ? 2 / this.nativeDimScaling : 1); const panelWidMin = (this._props.PanelWidth() / scaling) * (this._props.originTopLeft ? 0 : 1); const panelHgtMax = (this._props.PanelHeight() / scaling) * (this._props.originTopLeft ? 2 / this.nativeDimScaling : 1); const panelHgtMin = (this._props.PanelHeight() / scaling) * (this._props.originTopLeft ? 0 : 1); if (ranges.xrange.min >= panX + panelWidMax / 2) panX = ranges.xrange.max + (this._props.originTopLeft ? 0 : panelWidMax / 2); else if (ranges.xrange.max <= panX - panelWidMin / 2) panX = ranges.xrange.min - (this._props.originTopLeft ? panelWidMax / 2 : panelWidMin / 2); if (ranges.yrange.min >= panY + panelHgtMax / 2) panY = ranges.yrange.max + (this._props.originTopLeft ? 0 : panelHgtMax / 2); else if (ranges.yrange.max <= panY - panelHgtMin / 2) panY = ranges.yrange.min - (this._props.originTopLeft ? panelHgtMax / 2 : panelHgtMin / 2); } } if (!this.layoutDoc._lockedTransform || LightboxView.LightboxDoc) { this.setPanZoomTransition(panTime); const minScale = NumCast(this.dataDoc._freeform_scale_min, 1); const scale = 1 - minScale / this.zoomScaling(); const minPanX = NumCast(this.dataDoc._freeform_panX_min, 0); const minPanY = NumCast(this.dataDoc._freeform_panY_min, 0); const maxPanX = NumCast(this.dataDoc._freeform_panX_max, this.nativeWidth); const newPanX = Math.min(minPanX + scale * maxPanX, Math.max(minPanX, panX)); const fitYscroll = (((this.nativeHeight / this.nativeWidth) * this._props.PanelWidth() - this._props.PanelHeight()) * this.ScreenToLocalBoxXf().Scale) / minScale; const nativeHeight = (this._props.PanelHeight() / this._props.PanelWidth() / (this.nativeHeight / this.nativeWidth)) * this.nativeHeight; const maxScrollTop = this.nativeHeight / this.ScreenToLocalBoxXf().Scale - this._props.PanelHeight(); const maxPanY = minPanY + // minPanY + scrolling introduced by view scaling + scrolling introduced by layout_fitWidth scale * NumCast(this.dataDoc._panY_max, nativeHeight) + (!this._props.getScrollHeight?.() ? fitYscroll : 0); // when not zoomed, scrolling is handled via a scrollbar, not panning let newPanY = Math.max(minPanY, Math.min(maxPanY, panY)); if (false && NumCast(this.layoutDoc.layout_scrollTop) && NumCast(this.layoutDoc._freeform_scale, minScale) !== minScale) { const relTop = NumCast(this.layoutDoc.layout_scrollTop) / maxScrollTop; this.layoutDoc.layout_scrollTop = undefined; newPanY = minPanY + relTop * (maxPanY - minPanY); } else if (fitYscroll > 2 && this.layoutDoc.layout_scrollTop === undefined && NumCast(this.layoutDoc._freeform_scale, minScale) === minScale) { const maxPanY = minPanY + fitYscroll; const relTop = (panY - minPanY) / (maxPanY - minPanY); setTimeout(() => (this.layoutDoc.layout_scrollTop = relTop * maxScrollTop), 10); newPanY = minPanY; } !this.Document._verticalScroll && (this.Document[this.panXFieldKey] = this.isAnnotationOverlay ? newPanX : panX); !this.Document._horizontalScroll && (this.Document[this.panYFieldKey] = this.isAnnotationOverlay ? newPanY : panY); } } @action nudge = (x: number, y: number, nudgeTime: number = 500) => { const collectionDoc = this.Document; if (collectionDoc?._type_collection !== CollectionViewType.Freeform) { this.setPan( NumCast(this.layoutDoc[this.panXFieldKey]) + ((this._props.PanelWidth() / 2) * x) / this.zoomScaling(), // nudge x,y as a function of panel dimension and scale NumCast(this.layoutDoc[this.panYFieldKey]) + ((this._props.PanelHeight() / 2) * -y) / this.zoomScaling(), nudgeTime, true ); return true; } return false; }; @action bringToFront = (doc: Doc, sendToBack?: boolean) => { if (doc.stroke_isInkMask) { doc.zIndex = 5000; } else { // prettier-ignore const docs = this.childLayoutPairs.map(pair => pair.layout) .sort((doc1, doc2) => NumCast(doc1.zIndex) - NumCast(doc2.zIndex)); if (sendToBack) { const zfirst = docs.length ? NumCast(docs[0].zIndex) : 0; doc.zIndex = zfirst - 1; } else { let zlast = docs.length ? Math.max(docs.length, NumCast(docs.lastElement().zIndex)) : 1; if (docs.lastElement() !== doc) { if (zlast - docs.length > 100) { for (let i = 0; i < docs.length; i++) doc.zIndex = i + 1; zlast = docs.length + 1; } doc.zIndex = zlast + 1; } } } }; @action setPanZoomTransition = (transitionTime: number) => { this._panZoomTransition = transitionTime; this._panZoomTransitionTimer && clearTimeout(this._panZoomTransitionTimer); this._panZoomTransitionTimer = setTimeout( action(() => (this._panZoomTransition = 0)), transitionTime ); }; @action zoomSmoothlyAboutPt(docpt: number[], scale: number, transitionTime = 500) { if (this.Document.isGroup) return; this.setPanZoomTransition(transitionTime); const screenXY = this.screenToFreeformContentsXf.inverse().transformPoint(docpt[0], docpt[1]); this.layoutDoc[this.scaleFieldKey] = scale; const newScreenXY = this.screenToFreeformContentsXf.inverse().transformPoint(docpt[0], docpt[1]); const scrDelta = { x: screenXY[0] - newScreenXY[0], y: screenXY[1] - newScreenXY[1] }; const newpan = this.screenToFreeformContentsXf.transformDirection(scrDelta.x, scrDelta.y); this.layoutDoc[this.panXFieldKey] = NumCast(this.layoutDoc[this.panXFieldKey]) - newpan[0]; this.layoutDoc[this.panYFieldKey] = NumCast(this.layoutDoc[this.panYFieldKey]) - newpan[1]; } calculatePanIntoView = (doc: Doc, xf: Transform, scale?: number) => { const layoutdoc = Doc.Layout(doc); const pt = xf.transformPoint(NumCast(doc.x), NumCast(doc.y)); const pt2 = xf.transformPoint(NumCast(doc.x) + NumCast(layoutdoc._width), NumCast(doc.y) + NumCast(layoutdoc._height)); const bounds = { left: pt[0], right: pt2[0], top: pt[1], bot: pt2[1], width: pt2[0] - pt[0], height: pt2[1] - pt[1] }; if (scale !== undefined) { const maxZoom = 5; // sets the limit for how far we will zoom. this is useful for preventing small text boxes from filling the screen. So probably needs to be more sophisticated to consider more about the target and context const newScale = scale === 0 ? NumCast(this.layoutDoc[this.scaleFieldKey]) : Math.min(maxZoom, (1 / this.nativeDimScaling) * scale * Math.min(this._props.PanelWidth() / Math.abs(bounds.width), this._props.PanelHeight() / Math.abs(bounds.height))); return { panX: this._props.isAnnotationOverlay ? bounds.left - (Doc.NativeWidth(this.layoutDoc) / newScale - bounds.width) / 2 : (bounds.left + bounds.right) / 2, panY: this._props.isAnnotationOverlay ? bounds.top - (Doc.NativeHeight(this.layoutDoc) / newScale - bounds.height) / 2 : (bounds.top + bounds.bot) / 2, scale: newScale, }; } const panelWidth = this._props.isAnnotationOverlay ? this.nativeWidth : this._props.PanelWidth(); const panelHeight = this._props.isAnnotationOverlay ? this.nativeHeight : this._props.PanelHeight(); const pw = panelWidth / NumCast(this.layoutDoc._freeform_scale, 1); const ph = panelHeight / NumCast(this.layoutDoc._freeform_scale, 1); const cx = NumCast(this.layoutDoc[this.panXFieldKey]) + (this._props.isAnnotationOverlay ? pw / 2 : 0); const cy = NumCast(this.layoutDoc[this.panYFieldKey]) + (this._props.isAnnotationOverlay ? ph / 2 : 0); const screen = { left: cx - pw / 2, right: cx + pw / 2, top: cy - ph / 2, bot: cy + ph / 2 }; const maxYShift = Math.max(0, screen.bot - screen.top - (bounds.bot - bounds.top)); const phborder = bounds.top < screen.top || bounds.bot > screen.bot ? Math.min(ph / 10, maxYShift / 2) : 0; if (screen.right - screen.left < bounds.right - bounds.left || screen.bot - screen.top < bounds.bot - bounds.top) { return { panX: (bounds.left + bounds.right) / 2, panY: (bounds.top + bounds.bot) / 2, scale: Math.min(this._props.PanelHeight() / (bounds.bot - bounds.top), this._props.PanelWidth() / (bounds.right - bounds.left)) / 1.1, }; } return { panX: (this._props.isAnnotationOverlay ? NumCast(this.layoutDoc[this.panXFieldKey]) : cx) + Math.min(0, bounds.left - pw / 10 - screen.left) + Math.max(0, bounds.right + pw / 10 - screen.right), panY: (this._props.isAnnotationOverlay ? NumCast(this.layoutDoc[this.panYFieldKey]) : cy) + Math.min(0, bounds.top - phborder - screen.top) + Math.max(0, bounds.bot + phborder - screen.bot), }; }; isContentActive = () => this._props.isContentActive(); /** * Create a new text note of the same style as the one being typed into. * If the text doc is be part of a larger templated doc, the new Doc will be a copy of the templated Doc * * @param fieldProps render props for the text doc being typed into * @param below whether to place the new text Doc below or to the right of the one being typed into. * @returns whether the new text doc was created and added successfully */ createTextDocCopy = undoable((fieldProps: FieldViewProps, below: boolean) => { const textDoc = DocCast(fieldProps.Document.rootDocument, fieldProps.Document); const newDoc = Doc.MakeCopy(textDoc, true); newDoc[DocData][Doc.LayoutFieldKey(newDoc, fieldProps.LayoutTemplateString)] = undefined; // the copy should not copy the text contents of it source, just the render style newDoc.x = NumCast(textDoc.x) + (below ? 0 : NumCast(textDoc._width) + 10); newDoc.y = NumCast(textDoc.y) + (below ? NumCast(textDoc._height) + 10 : 0); FormattedTextBox.SetSelectOnLoad(newDoc); FormattedTextBox.DontSelectInitialText = true; return this.addDocument?.(newDoc); }, 'copied text note'); onKeyDown = (e: React.KeyboardEvent, fieldProps: FieldViewProps) => { if ((e.metaKey || e.ctrlKey || e.altKey || fieldProps.Document._createDocOnCR) && ['Tab', 'Enter'].includes(e.key)) { e.stopPropagation?.(); return this.createTextDocCopy(fieldProps, !e.altKey && e.key !== 'Tab'); } }; @computed get childPointerEvents() { const engine = this._props.layoutEngine?.() || StrCast(this.Document._layoutEngine); return SnappingManager.IsResizing ? 'none' : this._props.childPointerEvents?.() ?? (this._props.viewDefDivClick || // (engine === computePassLayout.name && !this._props.isSelected()) || this.isContentActive() === false ? 'none' : this._props.pointerEvents?.()); } @observable _childPointerEvents: 'none' | 'all' | 'visiblepainted' | undefined = undefined; childPointerEventsFunc = () => this._childPointerEvents; childContentsActive = () => (this._props.childContentsActive ?? this.isContentActive() === false ? returnFalse : emptyFunction)(); getChildDocView(entry: PoolData) { const childLayout = entry.pair.layout; const childData = entry.pair.data; return ( ); } addDocTab = action((doc: Doc, where: OpenWhere) => { if (this._props.isAnnotationOverlay) return this._props.addDocTab(doc, where); switch (where) { case OpenWhere.inParent: return this._props.addDocument?.(doc) || false; case OpenWhere.inParentFromScreen: const docContext = DocCast((doc instanceof Doc ? doc : doc?.[0])?.embedContainer); return ( (this.addDocument?.( (doc instanceof Doc ? [doc] : doc).map(doc => { const pt = this.screenToFreeformContentsXf.transformPoint(NumCast(doc.x), NumCast(doc.y)); doc.x = pt[0]; doc.y = pt[1]; return doc; }) ) && (!docContext || this._props.removeDocument?.(docContext))) || false ); case undefined: case OpenWhere.lightbox: if (this.layoutDoc._isLightbox) { this._lightboxDoc = doc; return true; } if (doc === this.Document || this.childDocList?.includes(doc) || this.childLayoutPairs.map(pair => pair.layout)?.includes(doc)) { if (doc.hidden) doc.hidden = false; return true; } } return this._props.addDocTab(doc, where); }); @observable _lightboxDoc: Opt = undefined; getCalculatedPositions(pair: { layout: Doc; data?: Doc }): PoolData { const random = (min: number, max: number, x: number, y: number) => /* min should not be equal to max */ min + (((Math.abs(x * y) * 9301 + 49297) % 233280) / 233280) * (max - min); const childDoc = pair.layout; const childDocLayout = Doc.Layout(childDoc); const layoutFrameNumber = Cast(this.Document._currentFrame, 'number'); // frame number that container is at which determines layout frame values const contentFrameNumber = Cast(childDocLayout._currentFrame, 'number', layoutFrameNumber ?? null); // frame number that content is at which determines what content is displayed const { z, zIndex } = childDoc; const { backgroundColor, color } = contentFrameNumber === undefined ? { backgroundColor: undefined, color: undefined } : CollectionFreeFormDocumentView.getStringValues(childDoc, contentFrameNumber); const { x, y, autoDim, _width, _height, opacity, _rotation } = layoutFrameNumber === undefined // -1 for width/height means width/height should be PanelWidth/PanelHeight (prevents collectionfreeformdocumentview width/height from getting out of synch with panelWIdth/Height which causes detailView to re-render and lose focus because HTMLtag scaling gets set to a bad intermediate value) ? { autoDim: 1, _width: Cast(childDoc._width, 'number'), _height: Cast(childDoc._height, 'number'), _rotation: Cast(childDocLayout._rotation, 'number'), x: childDoc.x, y: childDoc.y, opacity: this._props.childOpacity?.() } : CollectionFreeFormDocumentView.getValues(childDoc, layoutFrameNumber); // prettier-ignore const rotation = Cast(_rotation,'number', !this.layoutDoc._rotation_jitter ? null : NumCast(this.layoutDoc._rotation_jitter) * random(-1, 1, NumCast(x), NumCast(y)) ); const childProps = { ...this._props, fieldKey: '', styleProvider: this.clusterStyleProvider }; return { x: Number.isNaN(NumCast(x)) ? 0 : NumCast(x), y: Number.isNaN(NumCast(y)) ? 0 : NumCast(y), z: Cast(z, 'number'), autoDim, rotation, color: Cast(color, 'string') ? StrCast(color) : this.clusterStyleProvider(childDoc, childProps, StyleProp.Color), backgroundColor: Cast(backgroundColor, 'string') ? StrCast(backgroundColor) : this.clusterStyleProvider(childDoc, childProps, StyleProp.BackgroundColor), opacity: !_width ? 0 : this._keyframeEditing ? 1 : Cast(opacity, 'number') ?? this.clusterStyleProvider?.(childDoc, childProps, StyleProp.Opacity), zIndex: Cast(zIndex, 'number'), width: _width, height: _height, transition: StrCast(childDocLayout.dataTransition), pointerEvents: Cast(childDoc.pointerEvents, 'string', null), pair, replica: '', }; } onViewDefDivClick = (e: React.MouseEvent, payload: any) => { (this._props.viewDefDivClick || ScriptCast(this.Document.onViewDefDivClick))?.script.run({ this: this.Document, payload }); e.stopPropagation(); }; viewDefsToJSX = (views: ViewDefBounds[]) => { return !Array.isArray(views) ? [] : views.filter(ele => this.viewDefToJSX(ele)).map(ele => this.viewDefToJSX(ele)!); }; viewDefToJSX(viewDef: ViewDefBounds): Opt { const { x, y, z } = viewDef; const color = StrCast(viewDef.color); const width = Cast(viewDef.width, 'number'); const height = Cast(viewDef.height, 'number'); const transform = `translate(${x}px, ${y}px)`; if (viewDef.type === 'text') { const text = Cast(viewDef.text, 'string'); // don't use NumCast, StrCast, etc since we want to test for undefined below const fontSize = Cast(viewDef.fontSize, 'string'); return [text, x, y].some(val => val === undefined) ? undefined : { ele: (
{text}
), bounds: viewDef, }; } else if (viewDef.type === 'div') { return [x, y].some(val => val === undefined) ? undefined : { ele: (
this.onViewDefDivClick(e, viewDef)} style={{ width, height, backgroundColor: color, transform }} /> ), bounds: viewDef, }; } } renderCutoffProvider = computedFn( function renderCutoffProvider(this: any, doc: Doc) { return this.Document.isTemplateDoc ? false : !this._renderCutoffData.get(doc[Id] + ''); }.bind(this) ); doEngineLayout( poolData: Map, engine: (poolData: Map, pivotDoc: Doc, childPairs: { layout: Doc; data?: Doc }[], panelDim: number[], viewDefsToJSX: (views: ViewDefBounds[]) => ViewDefResult[], engineProps: any) => ViewDefResult[] ) { return engine(poolData, this.Document, this.childLayoutPairs, [this._props.PanelWidth(), this._props.PanelHeight()], this.viewDefsToJSX, this._props.engineProps); } doFreeformLayout(poolData: Map) { this.childLayoutPairs.filter(pair => this.isCurrent(pair.layout)).map((pair, i) => poolData.set(pair.layout[Id], this.getCalculatedPositions(pair))); return [] as ViewDefResult[]; } @computed get layoutEngine() { return this._props.layoutEngine?.() || StrCast(this.layoutDoc._layoutEngine); } @computed get doInternalLayoutComputation() { TraceMobx(); const newPool = new Map(); // prettier-ignore switch (this.layoutEngine) { case computePassLayout.name : return { newPool, computedElementData: this.doEngineLayout(newPool, computePassLayout) }; case computeTimelineLayout.name: return { newPool, computedElementData: this.doEngineLayout(newPool, computeTimelineLayout) }; case computePivotLayout.name: return { newPool, computedElementData: this.doEngineLayout(newPool, computePivotLayout) }; case computeStarburstLayout.name: return { newPool, computedElementData: this.doEngineLayout(newPool, computeStarburstLayout) }; } return { newPool, computedElementData: this.doFreeformLayout(newPool) }; } @action doLayoutComputation = (newPool: Map, computedElementData: ViewDefResult[]) => { const elements = computedElementData.slice(); Array.from(newPool.entries()) .filter(entry => this.isCurrent(entry[1].pair.layout)) .forEach(entry => elements.push({ ele: this.getChildDocView(entry[1]), bounds: (entry[1].opacity === 0 ? { ...entry[1], width: 0, height: 0 } : { ...entry[1] }) as any, inkMask: BoolCast(entry[1].pair.layout.stroke_isInkMask) ? NumCast(entry[1].pair.layout.opacity, 1) : -1, }) ); this.Document._freeform_useClusters && !this._clusterSets.length && this.childDocs.length && this.updateClusters(true); return elements; }; getAnchor = (addAsAnnotation: boolean, pinProps?: PinProps) => { // create an anchor that saves information about the current state of the freeform view (pan, zoom, view type) const anchor = Docs.Create.ConfigDocument({ title: 'ViewSpec - ' + StrCast(this.layoutDoc._type_collection), layout_unrendered: true, presentation_transition: 500, annotationOn: this.Document, }); PresBox.pinDocView( anchor, { pinDocLayout: pinProps?.pinDocLayout, pinData: { ...(pinProps?.pinData ? { ...pinProps.pinData, poslayoutview: pinProps.pinData.dataview } : {}), pannable: !this.Document.isGroup, type_collection: true, filters: true } }, this.Document ); if (addAsAnnotation) { if (Cast(this.dataDoc[this._props.fieldKey + '_annotations'], listSpec(Doc), null) !== undefined) { Cast(this.dataDoc[this._props.fieldKey + '_annotations'], listSpec(Doc), []).push(anchor); } else { this.dataDoc[this._props.fieldKey + '_annotations'] = new List([anchor]); } } return anchor; }; @action closeInfo = () => (Doc.IsInfoUIDisabled = true); infoUI = () => (Doc.IsInfoUIDisabled || this.Document.annotationOn || this._props.renderDepth ? null : ); componentDidMount() { this._props.setContentViewBox?.(this); super.componentDidMount?.(); setTimeout( action(() => { this._firstRender = false; this._disposers.groupBounds = reaction( () => { if (this.Document.isGroup && this.childDocs.length === this.childDocList?.length) { const clist = this.childDocs.map(cd => ({ x: NumCast(cd.x), y: NumCast(cd.y), width: NumCast(cd._width), height: NumCast(cd._height) })); return aggregateBounds(clist, NumCast(this.layoutDoc._xPadding), NumCast(this.layoutDoc._yPadding)); } return undefined; }, cbounds => { if (cbounds) { const c = [NumCast(this.layoutDoc.x) + NumCast(this.layoutDoc._width) / 2, NumCast(this.layoutDoc.y) + NumCast(this.layoutDoc._height) / 2]; const p = [NumCast(this.layoutDoc[this.panXFieldKey]), NumCast(this.layoutDoc[this.panYFieldKey])]; const pbounds = { x: cbounds.x - p[0] + c[0], y: cbounds.y - p[1] + c[1], r: cbounds.r - p[0] + c[0], b: cbounds.b - p[1] + c[1], }; if (Number.isFinite(pbounds.r - pbounds.x) && Number.isFinite(pbounds.b - pbounds.y)) { this.layoutDoc._width = pbounds.r - pbounds.x; this.layoutDoc._height = pbounds.b - pbounds.y; this.layoutDoc[this.panXFieldKey] = (cbounds.r + cbounds.x) / 2; this.layoutDoc[this.panYFieldKey] = (cbounds.b + cbounds.y) / 2; this.layoutDoc.x = pbounds.x; this.layoutDoc.y = pbounds.y; } } }, { fireImmediately: true } ); this._disposers.pointerevents = reaction( () => this.childPointerEvents, pointerevents => (this._childPointerEvents = pointerevents as any), { fireImmediately: true } ); this._disposers.active = reaction( () => this.isContentActive(), // if autoreset is on, then whenever the view is selected, it will be restored to it default pan/zoom positions active => !SnappingManager.IsDragging && this.dataDoc[this.autoResetFieldKey] && active && this.resetView() ); }) ); this._disposers.paintFunc = reaction( () => ({ code: this.paintFunc, first: this._firstRender, width: this.Document._width, height: this.Document._height }), ({ code, first }) => { if (!code.includes('dashDiv')) { const script = CompileScript(code, { params: { docView: 'any' }, typecheck: false, editable: true }); if (script.compiled) script.run({ this: this.DocumentView?.() }); } else code && !first && eval?.(code); }, { fireImmediately: true } ); this._disposers.layoutElements = reaction( // layoutElements can't be a computed value because doLayoutComputation() is an action that has side effect of updating clusters () => this.doInternalLayoutComputation, computation => (this._layoutElements = this.doLayoutComputation(computation.newPool, computation.computedElementData)), { fireImmediately: true } ); } static replaceCanvases(oldDiv: HTMLElement, newDiv: HTMLElement) { if (oldDiv.childNodes && newDiv) { for (let i = 0; i < oldDiv.childNodes.length; i++) { this.replaceCanvases(oldDiv.childNodes[i] as HTMLElement, newDiv.childNodes[i] as HTMLElement); } } if (oldDiv instanceof HTMLCanvasElement) { if (oldDiv.className === 'collectionFreeFormView-grid') { const newCan = newDiv as HTMLCanvasElement; const parEle = newCan.parentElement as HTMLElement; parEle.removeChild(newCan); parEle.appendChild(document.createElement('div')); } else { const canvas = oldDiv; const img = document.createElement('img'); // create a Image Element try { img.src = canvas.toDataURL(); //image source } catch (e) { console.log(e); } img.style.width = canvas.style.width; img.style.height = canvas.style.height; const newCan = newDiv as HTMLCanvasElement; if (newCan) { const parEle = newCan.parentElement as HTMLElement; parEle.removeChild(newCan); parEle.appendChild(img); } } } } updateIcon = () => CollectionFreeFormView.UpdateIcon( this.layoutDoc[Id] + '-icon' + new Date().getTime(), this.DocumentView?.().ContentDiv!, NumCast(this.layoutDoc._width), NumCast(this.layoutDoc._height), this._props.PanelWidth(), this._props.PanelHeight(), 0, 1, false, '', (iconFile, nativeWidth, nativeHeight) => { this.dataDoc.icon = new ImageField(iconFile); this.dataDoc.icon_nativeWidth = nativeWidth; this.dataDoc.icon_nativeHeight = nativeHeight; } ); public static UpdateIcon( filename: string, docViewContent: HTMLElement, width: number, height: number, panelWidth: number, panelHeight: number, scrollTop: number, realNativeHeight: number, noSuffix: boolean, replaceRootFilename: string | undefined, cb: (iconFile: string, nativeWidth: number, nativeHeight: number) => any ) { const newDiv = docViewContent.cloneNode(true) as HTMLDivElement; newDiv.style.width = width.toString(); newDiv.style.height = height.toString(); this.replaceCanvases(docViewContent, newDiv); const htmlString = new XMLSerializer().serializeToString(newDiv); const nativeWidth = width; const nativeHeight = height; return CreateImage(Utils.prepend(''), document.styleSheets, htmlString, nativeWidth, (nativeWidth * panelHeight) / panelWidth, (scrollTop * panelHeight) / realNativeHeight) .then(async (data_url: any) => { const returnedFilename = await Utils.convertDataUri(data_url, filename, noSuffix, replaceRootFilename); cb(returnedFilename as string, nativeWidth, nativeHeight); }) .catch(function (error: any) { console.error('oops, something went wrong!', error); }); } componentWillUnmount() { this.dataDoc[this.autoResetFieldKey] && this.resetView(); Object.values(this._disposers).forEach(disposer => disposer?.()); } @action onCursorMove = (e: React.PointerEvent) => { // super.setCursorPosition(this.getTransform().transformPoint(e.clientX, e.clientY)); }; @undoBatch promoteCollection = () => { const childDocs = this.childDocs.slice(); childDocs.forEach(doc => { const scr = this.screenToFreeformContentsXf.inverse().transformPoint(NumCast(doc.x), NumCast(doc.y)); doc.x = scr?.[0]; doc.y = scr?.[1]; }); this._props.addDocTab(childDocs as any as Doc, OpenWhere.inParentFromScreen); }; @undoBatch layoutDocsInGrid = () => { const docs = this.childLayoutPairs.map(pair => pair.layout); const width = Math.max(...docs.map(doc => NumCast(doc._width))) + 20; const height = Math.max(...docs.map(doc => NumCast(doc._height))) + 20; const dim = Math.ceil(Math.sqrt(docs.length)); docs.forEach((doc, i) => { doc.x = NumCast(this.Document[this.panXFieldKey]) + (i % dim) * width - (width * dim) / 2; doc.y = NumCast(this.Document[this.panYFieldKey]) + Math.floor(i / dim) * height - (height * dim) / 2; }); }; @undoBatch toggleNativeDimensions = () => Doc.toggleNativeDimensions(this.layoutDoc, 1, this.nativeWidth, this.nativeHeight); /// /// resetView restores a freeform collection to unit scale and centered at (0,0) UNLESS /// the view is a group, in which case this does nothing (since Groups calculate their own scale and center) /// @undoBatch resetView = () => { this.layoutDoc[this.panXFieldKey] = NumCast(this.dataDoc[this.panXFieldKey + '_reset']); this.layoutDoc[this.panYFieldKey] = NumCast(this.dataDoc[this.panYFieldKey + '_reset']); this.layoutDoc[this.scaleFieldKey] = NumCast(this.dataDoc[this.scaleFieldKey + '_reset'], 1); }; /// /// resetView restores a freeform collection to unit scale and centered at (0,0) UNLESS /// the view is a group, in which case this does nothing (since Groups calculate their own scale and center) /// @undoBatch toggleResetView = () => { this.dataDoc[this.autoResetFieldKey] = !this.dataDoc[this.autoResetFieldKey]; if (this.dataDoc[this.autoResetFieldKey]) { this.dataDoc[this.panXFieldKey + '_reset'] = this.layoutDoc[this.panXFieldKey]; this.dataDoc[this.panYFieldKey + '_reset'] = this.layoutDoc[this.panYFieldKey]; this.dataDoc[this.scaleFieldKey + '_reset'] = this.layoutDoc[this.scaleFieldKey]; } }; onContextMenu = (e: React.MouseEvent) => { if (this._props.isAnnotationOverlay || !ContextMenu.Instance) return; const appearance = ContextMenu.Instance.findByDescription('Appearance...'); const appearanceItems = appearance && 'subitems' in appearance ? appearance.subitems : []; !this.Document.isGroup && appearanceItems.push({ description: 'Reset View', event: this.resetView, icon: 'compress-arrows-alt' }); !this.Document.isGroup && appearanceItems.push({ description: 'Toggle Auto Reset View', event: this.toggleResetView, icon: 'compress-arrows-alt' }); if (this._props.setContentViewBox === emptyFunction) { !appearance && ContextMenu.Instance.addItem({ description: 'Appearance...', subitems: appearanceItems, icon: 'eye' }); return; } !Doc.noviceMode && Doc.UserDoc().defaultTextLayout && appearanceItems.push({ description: 'Reset default note style', event: () => (Doc.UserDoc().defaultTextLayout = undefined), icon: 'eye' }); appearanceItems.push({ description: `Pin View`, event: () => this._props.pinToPres(this.Document, { pinViewport: MarqueeView.CurViewBounds(this.dataDoc, this._props.PanelWidth(), this._props.PanelHeight()) }), icon: 'map-pin' }); !Doc.noviceMode && appearanceItems.push({ description: `update icon`, event: this.updateIcon, icon: 'compress-arrows-alt' }); this._props.renderDepth && appearanceItems.push({ description: 'Ungroup collection', event: this.promoteCollection, icon: 'table' }); this.Document.isGroup && this.Document.transcription && appearanceItems.push({ description: 'Ink to text', event: this.transcribeStrokes, icon: 'font' }); !Doc.noviceMode ? appearanceItems.push({ description: 'Arrange contents in grid', event: this.layoutDocsInGrid, icon: 'table' }) : null; !Doc.noviceMode ? appearanceItems.push({ description: (this.Document._freeform_useClusters ? 'Hide' : 'Show') + ' Clusters', event: () => this.updateClusters(!this.Document._freeform_useClusters), icon: 'braille' }) : null; !appearance && ContextMenu.Instance.addItem({ description: 'Appearance...', subitems: appearanceItems, icon: 'eye' }); const options = ContextMenu.Instance.findByDescription('Options...'); const optionItems = options && 'subitems' in options ? options.subitems : []; !this._props.isAnnotationOverlay && !Doc.noviceMode && optionItems.push({ description: (this._showAnimTimeline ? 'Close' : 'Open') + ' Animation Timeline', event: action(() => (this._showAnimTimeline = !this._showAnimTimeline)), icon: 'eye' }); this._props.renderDepth && optionItems.push({ description: 'Use Background Color as Default', event: () => (Cast(Doc.UserDoc().emptyCollection, Doc, null).backgroundColor = StrCast(this.layoutDoc.backgroundColor)), icon: 'palette' }); this._props.renderDepth && optionItems.push({ description: 'Fit Content Once', event: this.fitContentOnce, icon: 'object-group' }); if (!Doc.noviceMode) { optionItems.push({ description: (!Doc.NativeWidth(this.layoutDoc) || !Doc.NativeHeight(this.layoutDoc) ? 'Freeze' : 'Unfreeze') + ' Aspect', event: this.toggleNativeDimensions, icon: 'snowflake' }); } !options && ContextMenu.Instance.addItem({ description: 'Options...', subitems: optionItems, icon: 'eye' }); const mores = ContextMenu.Instance.findByDescription('More...'); const moreItems = mores && 'subitems' in mores ? mores.subitems : []; !mores && ContextMenu.Instance.addItem({ description: 'More...', subitems: moreItems, icon: 'eye' }); }; @undoBatch transcribeStrokes = () => { if (this.Document.isGroup && this.Document.transcription) { const text = StrCast(this.Document.transcription); const lines = text.split('\n'); const height = 30 + 15 * lines.length; this.addDocument(Docs.Create.TextDocument(text, { title: lines[0], x: NumCast(this.layoutDoc.x) + NumCast(this.layoutDoc._width) + 20, y: NumCast(this.layoutDoc.y), _width: 200, _height: height })); } }; @action dragEnding = () => { this.GroupChildDrag = false; SnappingManager.clearSnapLines(); }; @action dragStarting = (snapToDraggedDoc: boolean = false, showGroupDragTarget: boolean = true, visited = new Set()) => { if (visited.has(this.Document)) return; visited.add(this.Document); showGroupDragTarget && (this.GroupChildDrag = BoolCast(this.Document.isGroup)); const activeDocs = this.getActiveDocuments(); const size = this.screenToFreeformContentsXf.transformDirection(this._props.PanelWidth(), this._props.PanelHeight()); const selRect = { left: this.panX() - size[0] / 2, top: this.panY() - size[1] / 2, width: size[0], height: size[1] }; const docDims = (doc: Doc) => ({ left: NumCast(doc.x), top: NumCast(doc.y), width: NumCast(doc._width), height: NumCast(doc._height) }); const isDocInView = (doc: Doc, rect: { left: number; top: number; width: number; height: number }) => intersectRect(docDims(doc), rect); const snappableDocs = activeDocs.filter(doc => doc.z === undefined && isDocInView(doc, selRect)); // first see if there are any foreground docs to snap to activeDocs .filter(doc => doc.isGroup && SnappingManager.IsResizing !== doc && !DragManager.docsBeingDragged.includes(doc)) .forEach(doc => DocumentManager.Instance.getDocumentView(doc)?.ComponentView?.dragStarting?.(snapToDraggedDoc, false, visited)); const horizLines: number[] = []; const vertLines: number[] = []; const invXf = this.screenToFreeformContentsXf.inverse(); snappableDocs .filter(doc => !doc.isGroup && (snapToDraggedDoc || (SnappingManager.IsResizing !== doc && !DragManager.docsBeingDragged.includes(doc)))) .forEach(doc => { const { left, top, width, height } = docDims(doc); const topLeftInScreen = invXf.transformPoint(left, top); const docSize = invXf.transformDirection(width, height); horizLines.push(topLeftInScreen[1], topLeftInScreen[1] + docSize[1] / 2, topLeftInScreen[1] + docSize[1]); // horiz center line vertLines.push(topLeftInScreen[0], topLeftInScreen[0] + docSize[0] / 2, topLeftInScreen[0] + docSize[0]); // right line }); this.layoutDoc._freeform_snapLines && SnappingManager.addSnapLines(horizLines, vertLines); }; incrementalRendering = () => this.childDocs.filter(doc => !this._renderCutoffData.get(doc[Id])).length !== 0; incrementalRender = action(() => { if (!LightboxView.LightboxDoc || LightboxView.Contains(this.DocumentView?.())) { const layout_unrendered = this.childDocs.filter(doc => !this._renderCutoffData.get(doc[Id])); const loadIncrement = this.Document.isTemplateDoc ? Number.MAX_VALUE : 5; for (var i = 0; i < Math.min(layout_unrendered.length, loadIncrement); i++) { this._renderCutoffData.set(layout_unrendered[i][Id] + '', true); } } this.childDocs.some(doc => !this._renderCutoffData.get(doc[Id])) && setTimeout(this.incrementalRender, 1); }); @computed get placeholder() { return (
{this.Document.annotationOn ? '' : this.Document.title?.toString()}
); } brushedView = () => this._brushedView; gridColor = () => DashColor(lightOrDark(this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.BackgroundColor))) .fade(0.5) .toString(); @computed get backgroundGrid() { return (
); } get pannableContents() { this.incrementalRender(); // needs to happen synchronously or freshly typed text documents will flash and miss their first characters return ( {this.props.children ?? null} {/* most likely case of children is document content that's being annoated: eg., an image */} {this.contentViews} ); } get marqueeView() { return ( 0 ? undefined : this.nudge} addDocTab={this.addDocTab} slowLoadDocuments={this.slowLoadDocuments} trySelectCluster={this.trySelectCluster} activeDocuments={this.getActiveDocuments} selectDocuments={this.selectDocuments} addDocument={this.addDocument} addLiveTextDocument={this.addLiveTextBox} getContainerTransform={this.ScreenToLocalBoxXf} getTransform={this.ScreenToContentsXf} panXFieldKey={this.panXFieldKey} panYFieldKey={this.panYFieldKey} isAnnotationOverlay={this.isAnnotationOverlay}> {this.layoutDoc._freeform_backgroundGrid ? this.backgroundGrid : null} {this.pannableContents} {this._showAnimTimeline ? : null} ); } @computed get nativeDimScaling() { if (this._firstRender || (this._props.isAnnotationOverlay && !this._props.annotationLayerHostsContent)) return 1; const nw = this.nativeWidth; const nh = this.nativeHeight; const hscale = nh ? this._props.PanelHeight() / nh : 1; const wscale = nw ? this._props.PanelWidth() / nw : 1; return wscale < hscale || (this._props.layout_fitWidth?.(this.Document) ?? this.layoutDoc.layout_fitWidth) ? wscale : hscale; } nativeDim = () => this.nativeDimScaling; @action brushView = (viewport: { width: number; height: number; panX: number; panY: number }, transTime: number, holdTime: number = 2500) => { this._brushtimer1 && clearTimeout(this._brushtimer1); this._brushtimer && clearTimeout(this._brushtimer); this._brushedView = undefined; this._brushtimer1 = setTimeout( action(() => { this._brushedView = { ...viewport, panX: viewport.panX - viewport.width / 2, panY: viewport.panY - viewport.height / 2 }; this._brushtimer = setTimeout(action(() => (this._brushedView = undefined)), holdTime); // prettier-ignore }), transTime + 1 ); }; lightboxPanelWidth = () => Math.max(0, this._props.PanelWidth() - 30); lightboxPanelHeight = () => Math.max(0, this._props.PanelHeight() - 30); lightboxScreenToLocal = () => this.ScreenToLocalBoxXf().translate(-15, -15); onPassiveWheel = (e: WheelEvent) => { const docHeight = NumCast(this.Document[Doc.LayoutFieldKey(this.Document) + '_nativeHeight'], this.nativeHeight); const scrollable = NumCast(this.layoutDoc[this.scaleFieldKey], 1) === 1 && docHeight > this._props.PanelHeight() / this.nativeDimScaling; this._props.isSelected() && !scrollable && e.preventDefault(); }; _oldWheel: any; render() { TraceMobx(); return (
{ this.createDashEventsTarget(r); this._oldWheel?.removeEventListener('wheel', this.onPassiveWheel); this._oldWheel = r; // prevent wheel events from passivly propagating up through containers r?.addEventListener('wheel', this.onPassiveWheel, { passive: false }); }} onWheel={this.onPointerWheel} onClick={this.onClick} onPointerDown={this.onPointerDown} onPointerMove={this.onCursorMove} onDrop={this.onExternalDrop} onDragOver={e => e.preventDefault()} onContextMenu={this.onContextMenu} style={{ pointerEvents: this._props.isContentActive() && SnappingManager.IsDragging ? 'all' : (this._props.pointerEvents?.() as any), textAlign: this.isAnnotationOverlay ? 'initial' : undefined, transform: `scale(${this.nativeDimScaling})`, width: `${100 / this.nativeDimScaling}%`, height: this._props.getScrollHeight?.() ?? `${100 / this.nativeDimScaling}%`, }}> {this.paintFunc ? ( // need this so that any live dashfieldviews will update the underlying text that the code eval reads ) : this._lightboxDoc ? (
) : ( <> {this._firstRender ? this.placeholder : this.marqueeView} {this._props.noOverlay ? null : } {!this.GroupChildDrag ? null :
} )}
); } } @observer class CollectionFreeFormOverlayView extends React.Component<{ elements: () => ViewDefResult[] }> { render() { return this.props.elements().filter(ele => ele.bounds?.z).map(ele => ele.ele); // prettier-ignore } } export function CollectionBrowseClick(dv: DocumentView, clientX: number, clientY: number) { const browseTransitionTime = 500; SelectionManager.DeselectAll(); dv && DocumentManager.Instance.showDocument(dv.Document, { zoomScale: 0.8, willZoomCentered: true }, (focused: boolean) => { if (!focused) { const selfFfview = !dv.Document.isGroup && dv.ComponentView instanceof CollectionFreeFormView ? dv.ComponentView : undefined; let containers = dv.containerViewPath?.() ?? []; let parFfview = dv.CollectionFreeFormView; for (var cont of containers) { parFfview = parFfview ?? cont.CollectionFreeFormView; } while (parFfview?.Document.isGroup) parFfview = parFfview.DocumentView?.().CollectionFreeFormView; const ffview = selfFfview && selfFfview.layoutDoc[selfFfview.scaleFieldKey] !== 0.5 ? selfFfview : parFfview; // if focus doc is a freeform that is not at it's default 0.5 scale, then zoom out on it. Otherwise, zoom out on the parent ffview ffview?.zoomSmoothlyAboutPt(ffview.screenToFreeformContentsXf.transformPoint(clientX, clientY), ffview?.isAnnotationOverlay ? 1 : 0.5, browseTransitionTime); Doc.linkFollowHighlight(dv?.Document, false); } }); } ScriptingGlobals.add(CollectionBrowseClick); ScriptingGlobals.add(function nextKeyFrame(readOnly: boolean) { !readOnly && (SelectionManager.Views[0].ComponentView as CollectionFreeFormView)?.changeKeyFrame(); }); ScriptingGlobals.add(function prevKeyFrame(readOnly: boolean) { !readOnly && (SelectionManager.Views[0].ComponentView as CollectionFreeFormView)?.changeKeyFrame(true); }); ScriptingGlobals.add(function curKeyFrame(readOnly: boolean) { const selView = SelectionManager.Views; if (readOnly) return selView[0].ComponentView?.getKeyFrameEditing?.() ? Colors.MEDIUM_BLUE : 'transparent'; runInAction(() => selView[0].ComponentView?.setKeyFrameEditing?.(!selView[0].ComponentView?.getKeyFrameEditing?.())); }); ScriptingGlobals.add(function pinWithView(pinContent: boolean) { SelectionManager.Views.forEach(view => view._props.pinToPres(view.Document, { currentFrame: Cast(view.Document.currentFrame, 'number', null), pinData: { poslayoutview: pinContent, dataview: pinContent, }, pinViewport: MarqueeView.CurViewBounds(view.Document, view._props.PanelWidth(), view._props.PanelHeight()), }) ); }); ScriptingGlobals.add(function bringToFront() { SelectionManager.Views.forEach(view => view.CollectionFreeFormView?.bringToFront(view.Document)); }); ScriptingGlobals.add(function sendToBack(doc: Doc) { SelectionManager.Views.forEach(view => view.CollectionFreeFormView?.bringToFront(view.Document, true)); }); ScriptingGlobals.add(function datavizFromSchema(doc: Doc) { // creating a dataviz doc to represent the schema table SelectionManager.Views.forEach(view => { if (!view.layoutDoc.schema_columnKeys) { view.layoutDoc.schema_columnKeys = new List(['title', 'type', 'author', 'author_date']); } const keys = Cast(view.layoutDoc.schema_columnKeys, listSpec('string'))?.filter(key => key != 'text'); if (!keys) return; const children = DocListCast(view.Document[Doc.LayoutFieldKey(view.Document)]); let csvRows = []; csvRows.push(keys.join(',')); for (let i = 0; i < children.length; i++) { let eachRow = []; for (let j = 0; j < keys.length; j++) { var cell = children[i][keys[j]]?.toString(); if (cell) cell = cell.toString().replace(/\,/g, ''); eachRow.push(cell); } csvRows.push(eachRow); } const blob = new Blob([csvRows.join('\n')], { type: 'text/csv' }); const options = { x: 0, y: 0, title: 'schemaTable', _width: 300, _height: 100, type: 'text/csv' }; const file = new File([blob], 'schemaTable', options); const loading = Docs.Create.LoadingDocument(file, options); loading.presentation_openInLightbox = true; DocUtils.uploadFileToDoc(file, {}, loading); // holds the doc in a popup until it is dragged onto a canvas if (view.ComponentView?.addDocument) { loading._dataViz_asSchema = view.layoutDoc; SchemaCSVPopUp.Instance.setView(view); SchemaCSVPopUp.Instance.setTarget(view.layoutDoc); SchemaCSVPopUp.Instance.setDataVizDoc(loading); SchemaCSVPopUp.Instance.setVisible(true); } }); });