import { Bezier } from 'bezier-js'; import { Colors } from 'browndash-components'; import { action, computed, IReactionDisposer, observable, reaction, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import { computedFn } from 'mobx-utils'; import { DateField } from '../../../../fields/DateField'; import { DataSym, Doc, DocListCast, HeightSym, Opt, StrListCast, WidthSym } from '../../../../fields/Doc'; import { Id } from '../../../../fields/FieldSymbols'; import { InkData, InkField, InkTool, PointData, Segment } from '../../../../fields/InkField'; import { List } from '../../../../fields/List'; import { ObjectField } from '../../../../fields/ObjectField'; import { RichTextField } from '../../../../fields/RichTextField'; import { listSpec } from '../../../../fields/Schema'; import { ScriptField } from '../../../../fields/ScriptField'; import { BoolCast, Cast, FieldValue, NumCast, ScriptCast, StrCast } from '../../../../fields/Types'; import { ImageField } from '../../../../fields/URLField'; import { TraceMobx } from '../../../../fields/util'; import { GestureUtils } from '../../../../pen-gestures/GestureUtils'; import { aggregateBounds, emptyFunction, intersectRect, returnFalse, 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 { HistoryUtil } from '../../../util/History'; import { InteractionUtils } from '../../../util/InteractionUtils'; import { ReplayMovements } from '../../../util/ReplayMovements'; import { ScriptingGlobals } from '../../../util/ScriptingGlobals'; import { SelectionManager } from '../../../util/SelectionManager'; import { ColorScheme, freeformScrollMode } from '../../../util/SettingsManager'; import { SnappingManager } from '../../../util/SnappingManager'; import { Transform } from '../../../util/Transform'; import { undoBatch, UndoManager } from '../../../util/UndoManager'; import { COLLECTION_BORDER_WIDTH } from '../../../views/global/globalCssVariables.scss'; import { Timeline } from '../../animationtimeline/Timeline'; import { ContextMenu } from '../../ContextMenu'; import { GestureOverlay } from '../../GestureOverlay'; import { ActiveArrowEnd, ActiveArrowStart, ActiveDash, ActiveFillColor, ActiveInkBezierApprox, ActiveInkColor, ActiveInkWidth, ActiveIsInkMask, InkingStroke, SetActiveInkColor, SetActiveInkWidth } from '../../InkingStroke'; import { LightboxView } from '../../LightboxView'; import { CollectionFreeFormDocumentView } from '../../nodes/CollectionFreeFormDocumentView'; import { DocFocusOptions, DocumentView, DocumentViewProps, OpenWhere, ViewAdjustment, ViewSpecPrefix } from '../../nodes/DocumentView'; import { FieldViewProps } from '../../nodes/FieldView'; import { FormattedTextBox } from '../../nodes/formattedText/FormattedTextBox'; import { PresBox } from '../../nodes/trails/PresBox'; import { VideoBox } from '../../nodes/VideoBox'; import { CreateImage } from '../../nodes/WebBoxRenderer'; import { StyleProp } from '../../StyleProvider'; import { CollectionSubView } from '../CollectionSubView'; import { TreeViewType } from '../CollectionTreeView'; import { TabDocView } from '../TabDocView'; import { computePassLayout, computePivotLayout, computeStarburstLayout, computeTimelineLayout, PoolData, ViewDefBounds, ViewDefResult } from './CollectionFreeFormLayoutEngines'; import { CollectionFreeFormRemoteCursors } from './CollectionFreeFormRemoteCursors'; import './CollectionFreeFormView.scss'; import { MarqueeView } from './MarqueeView'; import React = require('react'); export type collectionFreeformViewProps = { annotationLayerHostsContent?: boolean; // whether to force scaling of content (needed by ImageBox) viewDefDivClick?: ScriptField; childPointerEvents?: string; scaleField?: string; noOverlay?: boolean; // used to suppress docs in the overlay (z) layer (ie, for minimap since overlay doesn't scale) engineProps?: any; dontScaleFilter?: (doc: Doc) => boolean; // whether this collection should scale documents to fit their panel vs just scrolling them dontRenderDocuments?: boolean; // used for annotation overlays which need to distribute documents into different freeformviews with different mixBlendModes depending on whether they are transparent or not. // However, this screws up interactions since only the top layer gets events. so we render the freeformview a 3rd time with all documents in order to get interaction events (eg., marquee) but we don't actually want to display the documents. }; @observer export class CollectionFreeFormView extends CollectionSubView>() { public get displayName() { return 'CollectionFreeFormView(' + this.props.Document.title?.toString() + ')'; } // this makes mobx trace() statements more descriptive @observable public static ShowPresPaths = false; private _lastNudge: any; private _lastX: number = 0; private _lastY: number = 0; private _downX: number = 0; private _downY: number = 0; private _inkToTextStartX: number | undefined; private _inkToTextStartY: number | undefined; private _wordPalette: Map = new Map(); private _clusterDistance: number = 75; private _hitCluster: number = -1; private _disposers: { [name: string]: IReactionDisposer } = {}; private _renderCutoffData = observable.map(); private _layoutPoolData = observable.map(); private _layoutSizeData = observable.map(); private _cachedPool: Map = new Map(); private _lastTap = 0; private _batch: UndoManager.Batch | undefined = undefined; private _brushtimer: any; private _brushtimer1: any; // private isWritingMode: boolean = true; // private writingModeDocs: Doc[] = []; private get isAnnotationOverlay() { return this.props.isAnnotationOverlay; } private get scaleFieldKey() { return this.props.scaleField || '_viewScale'; } private get borderWidth() { return this.isAnnotationOverlay ? 0 : COLLECTION_BORDER_WIDTH; } @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 _hLines: number[] | undefined; @observable _vLines: number[] | undefined; @observable _firstRender = true; // 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. @observable _showAnimTimeline = false; @observable _clusterSets: Doc[][] = []; @observable _deleteList: DocumentView[] = []; @observable _timelineRef = React.createRef(); @observable _marqueeRef: HTMLDivElement | null = null; @observable _marqueeViewRef = React.createRef(); @observable ChildDrag: DocumentView | undefined; // child document view being dragged. needed to update drop areas of groups when a group item is dragged. @observable _brushedView = { width: 0, height: 0, panX: 0, panY: 0, opacity: 0 }; // highlighted region of freeform canvas used by presentations to indicate a region constructor(props: any) { super(props); this.props.setContentView?.(this); } @computed get views() { 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() { return { bounds: { ...this.contentBounds, cx: (this.contentBounds.x + this.contentBounds.r) / 2, cy: (this.contentBounds.y + this.contentBounds.b) / 2 }, scale: !this.childDocs.length ? 1 : Math.min(this.props.PanelHeight() / (this.contentBounds.b - this.contentBounds.y), this.props.PanelWidth() / (this.contentBounds.r - this.contentBounds.x)), }; } @computed get fitContentsToBox() { return (this.props.fitContentsToBox?.() || this.Document._fitContentsToBox || this.Document.isGroup) && !this.isAnnotationOverlay; } @computed get contentBounds() { const cb = Cast(this.rootDoc.contentBounds, listSpec('number')); return cb ? { x: cb[0], y: cb[1], r: cb[2], b: cb[3] } : this.props.contentBounds?.() ?? aggregateBounds( this._layoutElements.filter(e => e.bounds && !e.bounds.z).map(e => e.bounds!), NumCast(this.layoutDoc._xPadding, 10), NumCast(this.layoutDoc._yPadding, 10) ); } @computed get nativeWidth() { return this.fitContentsToBox ? 0 : Doc.NativeWidth(this.Document, Cast(this.Document.resolvedDataDoc, Doc, null)); } @computed get nativeHeight() { return this.fitContentsToBox ? 0 : Doc.NativeHeight(this.Document, Cast(this.Document.resolvedDataDoc, Doc, null)); } @computed get cachedCenteringShiftX(): number { const scaling = this.fitContentsToBox || !this.nativeDimScaling ? 1 : this.nativeDimScaling; return this.props.isAnnotationOverlay ? 0 : this.props.PanelWidth() / 2 / scaling; // shift so pan position is at center of window for non-overlay collections } @computed get cachedCenteringShiftY(): number { const dv = this.props.DocumentView?.(); const scaling = this.fitContentsToBox || !this.nativeDimScaling ? 1 : this.nativeDimScaling; // if freeform has a native aspect, then the panel height needs to be adjusted to match it const aspect = dv?.nativeWidth && dv?.nativeHeight && !dv.layoutDoc.fitWidth ? dv.nativeHeight / dv.nativeWidth : this.props.PanelHeight() / this.props.PanelWidth(); return this.props.isAnnotationOverlay ? 0 : (aspect * this.props.PanelWidth()) / 2 / scaling; // shift so pan position is at center of window for non-overlay collections } @computed get cachedGetLocalTransform(): Transform { return Transform.Identity() .scale(1 / this.zoomScaling()) .translate(this.panX(), this.panY()); } @computed get cachedGetContainerTransform(): Transform { return this.props.ScreenToLocalTransform().translate(-this.borderWidth, -this.borderWidth); } @computed get cachedGetTransform(): Transform { return this.getContainerTransform().translate(-this.cachedCenteringShiftX, -this.cachedCenteringShiftY).transform(this.cachedGetLocalTransform); } _keyTimer: NodeJS.Timeout | undefined; 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 = CollectionFreeFormDocumentView.gotoKeyframe(this._keyTimer, this.childDocs.slice()); this.Document._currentFrame = Math.max(0, (currentFrame || 0) - 1); } else { this._keyTimer = CollectionFreeFormDocumentView.updateKeyframe(this._keyTimer, this.childDocs, 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.onBrowseClick?.() || ScriptCast(this.layoutDoc.onBrowseClick); onChildClickHandler = () => this.props.childClickScript || ScriptCast(this.Document.onChildClick); onChildDoubleClickHandler = () => this.props.childDoubleClickScript || ScriptCast(this.Document.onChildDoubleClick); elementFunc = () => this._layoutElements; shrinkWrap = () => { if (this.props.DocumentView?.().nativeWidth) return; const vals = this.fitToContentVals; this.layoutDoc._panX = vals.bounds.cx; this.layoutDoc._panY = vals.bounds.cy; this.layoutDoc._viewScale = vals.scale; }; freeformData = (force?: boolean) => (!this._firstRender && (this.fitContentsToBox || force) ? this.fitToContentVals : undefined); reverseNativeScaling = () => (this.fitContentsToBox ? true : false); // panx, pany, zoomscale 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._panX, NumCast(Cast(this.Document.resolvedDataDoc, Doc, null)?.panX, 1)); panY = () => this.freeformData()?.bounds.cy ?? NumCast(this.Document._panY, NumCast(Cast(this.Document.resolvedDataDoc, Doc, null)?.panY, 1)); zoomScaling = () => this.freeformData()?.scale ?? NumCast(Doc.Layout(this.Document)[this.scaleFieldKey], NumCast(Cast(this.Document.resolvedDataDoc, Doc, null)?.[this.scaleFieldKey], 1)); contentTransform = () => !this.cachedCenteringShiftX && !this.cachedCenteringShiftY && this.zoomScaling() === 1 ? '' : `translate(${this.cachedCenteringShiftX}px, ${this.cachedCenteringShiftY}px) scale(${this.zoomScaling()}) translate(${-this.panX()}px, ${-this.panY()}px)`; getTransform = () => this.cachedGetTransform.copy(); getLocalTransform = () => this.cachedGetLocalTransform.copy(); getContainerTransform = () => this.cachedGetContainerTransform.copy(); getActiveDocuments = () => this.childLayoutPairs.filter(pair => this.isCurrent(pair.layout)).map(pair => pair.layout); isAnyChildContentActive = () => this.props.isAnyChildContentActive(); addLiveTextBox = (newBox: Doc) => { FormattedTextBox.SelectOnLoad = newBox[Id]; // track the new text box so we can give it a prop that tells it to focus itself when it's displayed this.addDocument(newBox); }; selectDocuments = (docs: Doc[]) => { SelectionManager.DeselectAll(); docs.map(doc => DocumentManager.Instance.getDocumentView(doc, this.props.CollectionView)).map(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._currentTimecode, -1); return dispTime === -1 || (curTime - dispTime >= -1e-4 && curTime <= endTime); } @action internalDocDrop(e: Event, de: DragManager.DropEvent, docDragData: DragManager.DocumentDragData, xp: number, yp: number) { if (!de.embedKey && !this.ChildDrag && this.rootDoc._isGroup) return false; if (!super.onInternalDrop(e, de)) return false; const refDoc = docDragData.droppedDocuments[0]; const [xpo, ypo] = this.getContainerTransform().transformPoint(de.x, de.y); const z = NumCast(refDoc.z); const x = (z ? xpo : xp) - docDragData.offset[0]; const y = (z ? ypo : yp) - 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.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); 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 = x + NumCast(pvals.x) - dropPos[0]; vals.y = y + NumCast(pvals.y) - dropPos[1]; CollectionFreeFormDocumentView.setValues(NumCast(this.Document._currentFrame), d, vals); } else { d.x = x + NumCast(d.x) - dropPos[0]; d.y = y + NumCast(d.y) - dropPos[1]; } d._lastModified = 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._raiseWhenDragged === undefined ? DragManager.GetRaiseWhenDragged() : d._raiseWhenDragged) && (d.zIndex = zsorted.length + 1 + i); // bringToFront } (docDragData.droppedDocuments.length === 1 || de.shiftKey) && this.updateClusterDocs(docDragData.droppedDocuments); return true; } @undoBatch internalAnchorAnnoDrop(e: Event, annoDragData: DragManager.AnchorAnnoDragData, xp: number, yp: number) { const dropCreator = annoDragData.dropDocCreator; 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.rootDoc; }; return true; } @undoBatch internalLinkDrop(e: Event, de: DragManager.DropEvent, linkDragData: DragManager.LinkDragData, xp: number, yp: number) { if (linkDragData.linkDragView.props.docViewPath().includes(this.props.docViewPath().lastElement())) { // dragged document is a child of this collection if (!linkDragData.linkDragView.props.CollectionFreeFormDocumentView?.() || linkDragData.dragDocument.context !== this.props.Document) { // if the source doc view's context isn't this same freeformcollectionlinkDragData.dragDocument.context === this.props.Document const source = Docs.Create.TextDocument('', { _width: 200, _height: 75, x: xp, y: yp, title: 'dropped annotation' }); this.props.addDocument?.(source); de.complete.linkDocument = DocUtils.MakeLink({ doc: linkDragData.linkSourceGetAnchor() }, { doc: source }, 'annotated by:annotation of', ''); // TODODO this is where in text links get passed } e.stopPropagation(); // do nothing if link is dropped into any freeform view parent of dragged document return true; } return false; } onInternalDrop = (e: Event, de: DragManager.DropEvent) => { const [xp, yp] = this.getTransform().transformPoint(de.x, de.y); if (de.complete.annoDragData?.dragDocument && super.onInternalDrop(e, de)) return this.internalAnchorAnnoDrop(e, de.complete.annoDragData, xp, yp); else if (de.complete.linkDragData) return this.internalLinkDrop(e, de, de.complete.linkDragData, xp, yp); else if (de.complete.docDragData?.droppedDocuments.length) return this.internalDocDrop(e, de, de.complete.docDragData, xp, yp); return false; }; onExternalDrop = (e: React.DragEvent) => { return (pt => super.onExternalDrop(e, { x: pt[0], y: pt[1] }))(this.getTransform().transformPoint(e.pageX, e.pageY)); }; pickCluster(probe: number[]) { return this.childLayoutPairs .map(pair => pair.layout) .reduce((cluster, cd) => { const grouping = this.props.Document._useClusters ? NumCast(cd.cluster, -1) : NumCast(cd.group, -1); if (grouping !== -1) { const layoutDoc = Doc.Layout(cd); const cx = NumCast(cd.x) - this._clusterDistance; const cy = NumCast(cd.y) - this._clusterDistance; const cw = NumCast(layoutDoc._width) + 2 * this._clusterDistance; const ch = NumCast(layoutDoc._height) + 2 * 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 | TouchEvent, cluster: number) { if (cluster !== -1) { const ptsParent = e instanceof PointerEvent ? e : e.targetTouches.item(0); if (ptsParent) { const eles = this.childLayoutPairs.map(pair => pair.layout).filter(cd => (this.props.Document._useClusters ? NumCast(cd.cluster) : NumCast(cd.group, -1)) === cluster); const clusterDocs = eles.map(ele => DocumentManager.Instance.getDocumentView(ele, this.props.CollectionView)!); const { left, top } = clusterDocs[0].getBounds() || { left: 0, top: 0 }; const de = new DragManager.DocumentDragData(eles, e.ctrlKey || e.altKey ? 'alias' : undefined); de.moveDocument = this.props.moveDocument; de.offset = this.getTransform().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; } @undoBatch @action updateClusters(_useClusters: boolean) { this.props.Document._useClusters = _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.props.Document._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.cluster); docs.map(doc => (doc.cluster = -1)); docs.map(doc => this._clusterSets.map((set, i) => set.map(member => { if (docFirst.cluster === -1 && Doc.IndexOf(member, childLayouts) !== -1 && Doc.overlapping(doc, member, this._clusterDistance)) { docFirst.cluster = i; } }) ) ); if (docFirst.cluster === -1 && preferredInd !== -1 && this._clusterSets.length > preferredInd && (!this._clusterSets[preferredInd] || !this._clusterSets[preferredInd].filter(member => Doc.IndexOf(member, childLayouts) !== -1).length)) { docFirst.cluster = preferredInd; } this._clusterSets.map((set, i) => { if (docFirst.cluster === -1 && !set.filter(member => Doc.IndexOf(member, childLayouts) !== -1).length) { docFirst.cluster = i; } }); if (docFirst.cluster === -1) { docs.map(doc => { doc.cluster = this._clusterSets.length; this._clusterSets.push([doc]); }); } else if (this._clusterSets.length) { for (let i = this._clusterSets.length; i <= NumCast(docFirst.cluster); i++) !this._clusterSets[i] && this._clusterSets.push([]); docs.map(doc => this._clusterSets[(doc.cluster = NumCast(docFirst.cluster))].push(doc)); } childLayouts.map(child => !this._clusterSets.some((set, i) => Doc.IndexOf(child, set) !== -1 && child.cluster === i) && this.updateCluster(child)); } } @undoBatch @action updateCluster(doc: Doc) { const childLayouts = this.childLayoutPairs.map(pair => pair.layout); if (this.props.Document._useClusters) { this._clusterSets.forEach(set => Doc.IndexOf(doc, set) !== -1 && set.splice(Doc.IndexOf(doc, set), 1)); const preferredInd = NumCast(doc.cluster); doc.cluster = -1; this._clusterSets.forEach((set, i) => set.forEach(member => { if (doc.cluster === -1 && Doc.IndexOf(member, childLayouts) !== -1 && Doc.overlapping(doc, member, this._clusterDistance)) { doc.cluster = i; } }) ); if (doc.cluster === -1 && preferredInd !== -1 && this._clusterSets.length > preferredInd && (!this._clusterSets[preferredInd] || !this._clusterSets[preferredInd].filter(member => Doc.IndexOf(member, childLayouts) !== -1).length)) { doc.cluster = preferredInd; } this._clusterSets.forEach((set, i) => { if (doc.cluster === -1 && !set.filter(member => Doc.IndexOf(member, childLayouts) !== -1).length) { doc.cluster = i; } }); if (doc.cluster === -1) { doc.cluster = this._clusterSets.length; this._clusterSets.push([doc]); } else if (this._clusterSets.length) { for (let i = this._clusterSets.length; i <= doc.cluster; i++) !this._clusterSets[i] && this._clusterSets.push([]); this._clusterSets[doc.cluster].push(doc); } } } getClusterColor = (doc: Opt, props: Opt, property: string) => { let styleProp = this.props.styleProvider?.(doc, props, property); // bcz: check 'props' used to be renderDepth + 1 switch (property) { case StyleProp.BackgroundColor: const cluster = NumCast(doc?.cluster); if (this.Document._useClusters) { 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; } return styleProp; }; trySelectCluster = (addToSel: boolean) => { if (this._hitCluster !== -1) { !addToSel && SelectionManager.DeselectAll(); const eles = this.childLayoutPairs.map(pair => pair.layout).filter(cd => (this.props.Document._useClusters ? NumCast(cd.cluster) : NumCast(cd.group, -1)) === this._hitCluster); this.selectDocuments(eles); return true; } return false; }; @action onPenUp = (e: PointerEvent): void => { if (!InteractionUtils.IsType(e, InteractionUtils.TOUCHTYPE)) { document.removeEventListener('pointerup', this.onPenUp); const currentCol = DocListCast(this.rootDoc.currentInkDoc); const rootDocList = DocListCast(this.rootDoc.data); currentCol.push(rootDocList[rootDocList.length - 1]); this._batch?.end(); } }; @action onPointerDown = (e: React.PointerEvent): void => { this._downX = this._lastX = e.pageX; this._downY = this._lastY = e.pageY; if (e.button === 0 && !e.altKey && !e.ctrlKey && this.props.isContentActive(true)) { if ( !this.props.Document._isGroup && // group freeforms don't pan when dragged -- instead let the event go through to allow the group itself to drag !InteractionUtils.IsType(e, InteractionUtils.TOUCHTYPE) && !InteractionUtils.IsType(e, InteractionUtils.PENTYPE) ) { switch (Doc.ActiveTool) { case InkTool.Highlighter: break; // TODO: nda - this where we want to create the new "writingDoc" collection that we add strokes to 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.getTransform().transformPoint(e.clientX, e.clientY)); setupMoveUpEvents(this, e, this.onPointerMove, emptyFunction, emptyFunction, false); } break; } } } }; @action handle1PointerDown = (e: React.TouchEvent, me: InteractionUtils.MultiTouchEvent) => { // const myTouches = InteractionUtils.GetMyTargetTouches(me, this.prevPoints, true); const pt = me.changedTouches[0]; if (pt) { this._hitCluster = this.pickCluster(this.getTransform().transformPoint(pt.clientX, pt.clientY)); if (!e.shiftKey && !e.altKey && !e.ctrlKey && this.props.isContentActive(true)) { this.removeMoveListeners(); this.addMoveListeners(); this.removeEndListeners(); this.addEndListeners(); if (Doc.ActiveTool === InkTool.None) { this._lastX = pt.pageX; this._lastY = pt.pageY; e.preventDefault(); e.stopPropagation(); } else { e.preventDefault(); } } } }; 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.getTransform().transformBounds(ge.bounds.left, ge.bounds.top, ge.bounds.width, ge.bounds.height); const inkDoc = Docs.Create.InkDocument( ActiveInkColor(), Doc.ActiveTool, ActiveInkWidth() * this.props.ScreenToLocalTransform().Scale, ActiveInkBezierApprox(), ActiveFillColor(), ActiveArrowStart(), ActiveArrowEnd(), ActiveDash(), points, ActiveIsInkMask(), { title: 'ink stroke', x: B.x - ActiveInkWidth() / 2, y: B.y - ActiveInkWidth() / 2, _width: B.width + ActiveInkWidth() * this.props.ScreenToLocalTransform().Scale, _height: B.height + ActiveInkWidth() * this.props.ScreenToLocalTransform().Scale, } ); if (Doc.ActiveTool === InkTool.Write) { this.unprocessedDocs.push(inkDoc); CollectionFreeFormView.collectionsWithUnprocessedInk.add(this); } this.addDocument(inkDoc); e.stopPropagation(); break; case GestureUtils.Gestures.Rectangle: if (this._inkToTextStartX && this._inkToTextStartY) { const end = this.getTransform().transformPoint(Math.max(...ge.points.map(p => p.X)), Math.max(...ge.points.map(p => p.Y))); const setDocs = this.getActiveDocuments().filter(s => s.proto?.type === 'rtf' && s.color); const sets = setDocs.map(sd => { return Cast(sd.text, RichTextField)?.Text as string; }); if (sets.length && sets[0]) { this._wordPalette.clear(); const colors = setDocs.map(sd => FieldValue(sd.color) as string); sets.forEach((st: string, i: number) => st.split(',').forEach(word => this._wordPalette.set(word, colors[i]))); } const inks = this.getActiveDocuments().filter(doc => { if (doc.type === 'ink') { const l = NumCast(doc.x); const r = l + doc[WidthSym](); const t = NumCast(doc.y); const b = t + doc[HeightSym](); const pass = !(this._inkToTextStartX! > r || end[0] < l || this._inkToTextStartY! > b || end[1] < t); return pass; } return false; }); // const inkFields = inks.map(i => Cast(i.data, InkField)); const strokes: InkData[] = []; inks.forEach(i => { const d = Cast(i.data, InkField); const x = NumCast(i.x); const y = NumCast(i.y); const left = Math.min(...(d?.inkData.map(pd => pd.X) ?? [0])); const top = Math.min(...(d?.inkData.map(pd => pd.Y) ?? [0])); if (d) { strokes.push(d.inkData.map(pd => ({ X: pd.X + x - left, Y: pd.Y + y - top }))); } }); CognitiveServices.Inking.Appliers.InterpretStrokes(strokes).then(results => { const wordResults = results.filter((r: any) => r.category === 'inkWord'); for (const word of wordResults) { const indices: number[] = word.strokeIds; indices.forEach(i => { const otherInks: Doc[] = []; indices.forEach(i2 => i2 !== i && otherInks.push(inks[i2])); inks[i].relatedInks = new List(otherInks); const uniqueColors: string[] = []; Array.from(this._wordPalette.values()).forEach(c => uniqueColors.indexOf(c) === -1 && uniqueColors.push(c)); inks[i].alternativeColors = new List(uniqueColors); if (this._wordPalette.has(word.recognizedText.toLowerCase())) { inks[i].color = this._wordPalette.get(word.recognizedText.toLowerCase()); } else if (word.alternates) { for (const alt of word.alternates) { if (this._wordPalette.has(alt.recognizedString.toLowerCase())) { inks[i].color = this._wordPalette.get(alt.recognizedString.toLowerCase()); break; } } } }); } }); this._inkToTextStartX = end[0]; } break; case GestureUtils.Gestures.Text: if (ge.text) { const B = this.getTransform().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.rootDoc)); this._deleteList = []; this._batch?.end(); }; onClick = (e: React.MouseEvent) => { if (this.onBrowseClickHandler()) { if (this.props.DocumentView?.()) { this.onBrowseClickHandler().script.run({ documentView: this.props.DocumentView(), clientX: e.clientX, clientY: e.clientY }); } e.stopPropagation(); e.preventDefault(); } else if (Math.abs(e.pageX - this._downX) < 3 && Math.abs(e.pageY - this._downY) < 3) { if (e.shiftKey) { if (Date.now() - this._lastTap < 300) { // reset zoom of freeform view to 1-to-1 on a shift + double click this.zoomSmoothlyAboutPt(this.getTransform().transformPoint(e.clientX, e.clientY), 1); } e.stopPropagation(); e.preventDefault(); } this._lastTap = Date.now(); } }; @action scrollPan = (e: WheelEvent | { deltaX: number; deltaY: number }): void => { const dx = e.deltaX; const dy = e.deltaY; this.setPan(NumCast(this.Document._panX) - dx, NumCast(this.Document._panY) - dy, 0, true); }; @action pan = (e: PointerEvent | React.Touch | { clientX: number; clientY: number }): void => { this.props.DocumentView?.().clearViewTransition(); const [dx, dy] = this.getTransform().transformDirection(e.clientX - this._lastX, e.clientY - this._lastY); this.setPan(NumCast(this.Document._panX) - dx, NumCast(this.Document._panY) - dy, 0, true); this._lastX = e.clientX; this._lastY = e.clientY; }; /** * 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 }; 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.rootDoc.strokeWidth?.toString()) || '1'); SetActiveInkColor(StrCast(intersect.inkView.rootDoc.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.segmentInkStroke(intersect.inkView, intersect.t).forEach(segment => GestureOverlay.Instance.dispatchGesture( 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[]) ) ); } // 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; }; @action onPointerMove = (e: PointerEvent): boolean => { if (InteractionUtils.IsType(e, InteractionUtils.PENTYPE)) return false; if (InteractionUtils.IsType(e, InteractionUtils.TOUCHTYPE)) { Doc.ActiveTool = InkTool.None; if (this.props.isContentActive(true)) e.stopPropagation(); } else if (!e.cancelBubble) { if (this.tryDragCluster(e, this._hitCluster)) { return true; } else this.pan(e); } 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.props.CollectionView)) .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); segment.push(inkSegment.split(localStartTVal < 0 ? 0 : localStartTVal, t)); segment.length && segments.push(segment); } // start a new segment from the intersection t value segment = tVals.length - 1 === index ? [inkSegment.split(t).right] : []; 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.props.CollectionView)?.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.props.Document && neighboringSegment) continue; const otherCurve = new Bezier(otherCtrlPts.slice(j, j + 4).map(p => ({ x: p.X, y: p.Y }))); 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). }); } }); return tVals; }; handle1PointerMove = (e: TouchEvent, me: InteractionUtils.MultiTouchEvent) => { if (!e.cancelBubble) { const myTouches = InteractionUtils.GetMyTargetTouches(me, this.prevPoints, true); if (myTouches[0]) { if (Doc.ActiveTool === InkTool.None) { if (this.tryDragCluster(e, this._hitCluster)) { e.stopPropagation(); // doesn't actually stop propagation since all our listeners are listening to events on 'document' however it does mark the event as cancelBubble=true which we test for in the move event handlers e.preventDefault(); document.removeEventListener('pointermove', this.onPointerMove); return; } // TODO: nda - this allows us to pan collections with finger -> only want to do this when collection is selected' this.pan(myTouches[0]); } } // e.stopPropagation(); e.preventDefault(); } }; handle2PointersMove = (e: TouchEvent, me: InteractionUtils.MultiTouchEvent) => { // pinch zooming if (!e.cancelBubble) { const myTouches = InteractionUtils.GetMyTargetTouches(me, this.prevPoints, true); const pt1 = myTouches[0]; const pt2 = myTouches[1]; if (this.prevPoints.size === 2) { const oldPoint1 = this.prevPoints.get(pt1.identifier); const oldPoint2 = this.prevPoints.get(pt2.identifier); if (oldPoint1 && oldPoint2) { const dir = InteractionUtils.Pinching(pt1, pt2, oldPoint1, oldPoint2); // if zooming, zoom if (dir !== 0) { const d1 = Math.sqrt(Math.pow(pt1.clientX - oldPoint1.clientX, 2) + Math.pow(pt1.clientY - oldPoint1.clientY, 2)); const d2 = Math.sqrt(Math.pow(pt2.clientX - oldPoint2.clientX, 2) + Math.pow(pt2.clientY - oldPoint2.clientY, 2)); const centerX = Math.min(pt1.clientX, pt2.clientX) + Math.abs(pt2.clientX - pt1.clientX) / 2; const centerY = Math.min(pt1.clientY, pt2.clientY) + Math.abs(pt2.clientY - pt1.clientY) / 2; // calculate the raw delta value const rawDelta = dir * (d1 + d2); // this floors and ceils the delta value to prevent jitteriness const delta = Math.sign(rawDelta) * Math.min(Math.abs(rawDelta), 8); this.zoom(centerX, centerY, delta * window.devicePixelRatio); this.prevPoints.set(pt1.identifier, pt1); this.prevPoints.set(pt2.identifier, pt2); } // this is not zooming. derive some form of panning from it. else { // use the centerx and centery as the "new mouse position" const centerX = Math.min(pt1.clientX, pt2.clientX) + Math.abs(pt2.clientX - pt1.clientX) / 2; const centerY = Math.min(pt1.clientY, pt2.clientY) + Math.abs(pt2.clientY - pt1.clientY) / 2; // const transformed = this.getTransform().inverse().transformPoint(centerX, centerY); this._lastX = centerX; this._lastY = centerY; } } } // e.stopPropagation(); e.preventDefault(); } }; @action handle2PointersDown = (e: React.TouchEvent, me: InteractionUtils.MultiTouchEvent) => { if (this.props.isContentActive(true)) { // const pt1: React.Touch | null = e.targetTouches.item(0); // const pt2: React.Touch | null = e.targetTouches.item(1); // // if (!pt1 || !pt2) return; const myTouches = InteractionUtils.GetMyTargetTouches(me, this.prevPoints, true); const pt1 = myTouches[0]; const pt2 = myTouches[1]; if (pt1 && pt2) { const centerX = Math.min(pt1.clientX, pt2.clientX) + Math.abs(pt2.clientX - pt1.clientX) / 2; const centerY = Math.min(pt1.clientY, pt2.clientY) + Math.abs(pt2.clientY - pt1.clientY) / 2; this._lastX = centerX; this._lastY = centerY; this.removeMoveListeners(); this.addMoveListeners(); this.removeEndListeners(); this.addEndListeners(); e.stopPropagation(); } } }; cleanUpInteractions = () => { this.removeMoveListeners(); this.removeEndListeners(); }; @action zoom = (pointX: number, pointY: number, deltaY: number): void => { if (this.Document._isGroup) return; let deltaScale = deltaY > 0 ? 1 / 1.05 : 1.05; if (deltaScale < 0) deltaScale = -deltaScale; const [x, y] = this.getTransform().transformPoint(pointX, pointY); const invTransform = this.getLocalTransform().inverse(); if (deltaScale * invTransform.Scale > 20) { deltaScale = 20 / invTransform.Scale; } if (deltaScale * invTransform.Scale < NumCast(this.rootDoc._viewScaleMin, 1) && this.isAnnotationOverlay) { deltaScale = NumCast(this.rootDoc._viewScaleMin, 1) / invTransform.Scale; } const localTransform = this.getLocalTransform().inverse().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.props.Document[this.scaleFieldKey] = Math.abs(safeScale); this.setPan(-localTransform.TranslateX / safeScale, -localTransform.TranslateY / safeScale); } }; @action onPointerWheel = (e: React.WheelEvent): void => { if (this.layoutDoc._Transform || DocListCast(Doc.MyOverlayDocs?.data).includes(this.props.Document) || this.props.Document.treeViewOutlineMode === TreeViewType.outline) return; e.stopPropagation(); e.preventDefault(); switch (!e.ctrlKey ? Doc.UserDoc().freeformScrollMode : freeformScrollMode.Pan) { case freeformScrollMode.Pan: // if shift is selected then zoom if (e.ctrlKey) { if (!e.ctrlKey && this.props.Document.scrollHeight !== undefined) { // things that can scroll vertically should do that instead of zooming } else if (this.props.isContentActive(true) && !this.Document._isGroup) { !this.props.isAnnotationOverlayScrollable && this.zoom(e.clientX, e.clientY, e.deltaY); // if (!this.props.isAnnotationOverlay) // bcz: do we want to zoom in on images/videos/etc? } // otherwise pan } else if (!e.ctrlKey && this.props.Document.scrollHeight !== undefined) { // things that can scroll vertically should do that instead of zooming } else if (this.props.isContentActive(true) && !this.Document._isGroup) { const dx = -e.deltaX; const dy = -e.deltaY; if (e.shiftKey) { !this.props.isAnnotationOverlayScrollable && this.scrollPan({ deltaX: dy, deltaY: 0 }); } else { !this.props.isAnnotationOverlayScrollable && this.scrollPan({ deltaX: dx, deltaY: dy }); } } break; default: case freeformScrollMode.Zoom: if (!e.ctrlKey && this.props.Document.scrollHeight !== undefined) { // things that can scroll vertically should do that instead of zooming } else if (this.props.isContentActive(true) && !this.Document._isGroup) { !this.props.isAnnotationOverlayScrollable && this.zoom(e.clientX, e.clientY, e.deltaY); // if (!this.props.isAnnotationOverlay) // bcz: do we want to zoom in on images/videos/etc? } 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); const measuredDocs = docs .map(doc => ({ pos: this.childPositionProviderUnmemoized(doc, ''), size: this.childSizeProviderUnmemoized(doc, '') })) .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: Number.MAX_VALUE, max: -Number.MAX_VALUE }, yrange: { min: Number.MAX_VALUE, max: -Number.MAX_VALUE }, } ); const panelDim = [this.props.PanelWidth() / this.zoomScaling(), this.props.PanelHeight() / this.zoomScaling()]; if (ranges.xrange.min >= panX + panelDim[0] / 2) panX = ranges.xrange.max + panelDim[0] / 2; // snaps pan position of range of content goes out of bounds else if (ranges.xrange.max <= panX - panelDim[0] / 2) panX = ranges.xrange.min - panelDim[0] / 2; if (ranges.yrange.min >= panY + panelDim[1] / 2) panY = ranges.yrange.max + panelDim[1] / 2; else if (ranges.yrange.max <= panY - panelDim[1] / 2) panY = ranges.yrange.min - panelDim[1] / 2; } } if (!this.layoutDoc._lockedTransform || LightboxView.LightboxDoc || DocListCast(Doc.MyOverlayDocs?.data).includes(this.Document)) { this._panZoomTransition = panTime; const scale = this.getLocalTransform().inverse().Scale; const minScale = NumCast(this.rootDoc._viewScaleMin, 1); const minPanX = NumCast(this.rootDoc._panXMin, 0); const minPanY = NumCast(this.rootDoc._panYMin, 0); const newPanX = Math.min(minPanX + (1 - minScale / scale) * NumCast(this.rootDoc._panXMax, this.nativeWidth), Math.max(minPanX, panX)); const newPanY = Math.min(this.props.Document.scrollHeight !== undefined ? NumCast(this.Document.scrollHeight) : minPanY + (1 - minScale / scale) * NumCast(this.rootDoc._panYMax, this.nativeHeight), Math.max(minPanY, panY)); !this.Document._verticalScroll && (this.Document._panX = this.isAnnotationOverlay ? newPanX : panX); !this.Document._horizontalScroll && (this.Document._panY = this.isAnnotationOverlay ? newPanY : panY); } } @action nudge = (x: number, y: number, nudgeTime: number = 500) => { if (this.props.ContainingCollectionDoc?._viewType !== CollectionViewType.Freeform || this.props.ContainingCollectionDoc._panX !== undefined) { // bcz: this isn't ideal, but want to try it out... this.setPan(NumCast(this.layoutDoc._panX) + ((this.props.PanelWidth() / 2) * x) / this.zoomScaling(), NumCast(this.layoutDoc._panY) + ((this.props.PanelHeight() / 2) * -y) / this.zoomScaling(), nudgeTime, true); this._lastNudge && clearTimeout(this._lastNudge); this._lastNudge = setTimeout( action(() => (this._panZoomTransition = 0)), nudgeTime ); return true; } return false; }; @action bringToFront = (doc: Doc, sendToBack?: boolean) => { if (sendToBack) { doc.zIndex = 0; } else if (doc.isInkMask) { doc.zIndex = 5000; } else { const docs = this.childLayoutPairs.map(pair => pair.layout).slice(); docs.sort((doc1, doc2) => NumCast(doc1.zIndex) - NumCast(doc2.zIndex)); let zlast = docs.length ? Math.max(docs.length, NumCast(docs[docs.length - 1].zIndex)) : 1; 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 zoomSmoothlyAboutPt(docpt: number[], scale: number, transitionTime = 500) { if (this.Document._isGroup) return; this._panZoomTransition = transitionTime; const screenXY = this.getTransform().inverse().transformPoint(docpt[0], docpt[1]); this.layoutDoc[this.scaleFieldKey] = scale; const newScreenXY = this.getTransform().inverse().transformPoint(docpt[0], docpt[1]); const scrDelta = { x: screenXY[0] - newScreenXY[0], y: screenXY[1] - newScreenXY[1] }; const newpan = this.getTransform().transformDirection(scrDelta.x, scrDelta.y); this.layoutDoc._panX = NumCast(this.layoutDoc._panX) - newpan[0]; this.layoutDoc._panY = NumCast(this.layoutDoc._panY) - newpan[1]; return new Promise(res => setTimeout(() => res(runInAction(() => (this._panZoomTransition = 0))), this._panZoomTransition)); // set transition to be smooth, then reset } _focusCount = 0; focusDocument = (doc: Doc, options: DocFocusOptions) => { const state = HistoryUtil.getState(); // TODO This technically isn't correct if type !== "doc", as // currently nothing is done, but we should probably push a new state if (state.type === 'doc' && this.Document._panX !== undefined && this.Document._panY !== undefined) { const init = state.initializers![this.Document[Id]]; if (!init) { state.initializers![this.Document[Id]] = { panX: NumCast(this.Document._panX), panY: NumCast(this.Document._panY) }; HistoryUtil.pushState(state); } else if (init.panX !== this.Document._panX || init.panY !== this.Document._panY) { init.panX = NumCast(this.Document._panX); init.panY = NumCast(this.Document._panY); HistoryUtil.pushState(state); } } // if (SelectionManager.Views().length !== 1 || SelectionManager.Views()[0].Document !== doc) { // SelectionManager.DeselectAll(); // } if (this.props.Document.scrollHeight || this.props.Document.scrollTop !== undefined || this.props.Document.currentTimecode !== undefined || doc.type === DocumentType.MARKER) { this.props.focus(doc, options); } else { const xfToCollection = options?.docTransform ?? Transform.Identity(); const savedState = { panX: NumCast(this.Document._panX), panY: NumCast(this.Document._panY), scale: options?.willPanZoom ? this.Document[this.scaleFieldKey] : undefined }; const newState = HistoryUtil.getState(); const cantTransform = (this.rootDoc._isGroup || this.layoutDoc._lockedTransform) && !LightboxView.LightboxDoc; const { panX, panY, scale } = cantTransform || (!options.willPan && !options.willPanZoom) ? savedState : this.calculatePanIntoView(doc, xfToCollection, options?.willPanZoom ? options?.zoomScale || 0.75 : undefined); if (!cantTransform) { // only pan and zoom to focus on a document if the document is not an annotation in an annotation overlay collection newState.initializers![this.Document[Id]] = { panX, panY }; HistoryUtil.pushState(newState); } // focus on the document in the collection const didMove = !cantTransform && !doc.z && (panX !== savedState.panX || panY !== savedState.panY || scale !== savedState.scale); const focusSpeed = options?.instant ? 0 : didMove ? options.zoomTime ?? 500 : 0; // glr: freeform transform speed can be set by adjusting presTransition field - needs a way of knowing when presentation is not active... if (didMove) { options.zoomScale && scale && (this.Document[this.scaleFieldKey] = scale); this.setPan(panX, panY, focusSpeed, 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 } const focusCount = ++this._focusCount; const startTime = Date.now(); // focus on this collection within its parent view. the parent view after focusing determines whether to reset the view change within the collection const endFocus = async (moved: boolean) => { doc.hidden && Doc.UnHighlightDoc(doc); const resetView = options?.afterFocus ? await options?.afterFocus(moved) : ViewAdjustment.doNothing; if (resetView) { const restoreState = savedState; if (typeof restoreState !== 'boolean') { this.Document._panX = restoreState.panX; this.Document._panY = restoreState.panY; this.Document[this.scaleFieldKey] = restoreState.scale; } } this._focusCount === focusCount && didMove && runInAction(() => (this._panZoomTransition = 0)); return resetView; }; const xf = !cantTransform ? Transform.Identity() : this.props.isAnnotationOverlay ? new Transform(NumCast(this.rootDoc.x), NumCast(this.rootDoc.y), this.rootDoc[WidthSym]() / Doc.NativeWidth(this.rootDoc)) : new Transform(NumCast(this.rootDoc.x) + this.rootDoc[WidthSym]() / 2 - NumCast(this.rootDoc._panX), NumCast(this.rootDoc.y) + this.rootDoc[HeightSym]() / 2 - NumCast(this.rootDoc._panY), 1); this.props.focus(!cantTransform ? this.rootDoc : doc, { ...options, docTransform: xf, afterFocus: (didFocus: boolean) => new Promise(res => setTimeout(async () => res(await endFocus(didMove || didFocus)), Math.max(0, focusSpeed - (Date.now() - startTime)))), }); } }; 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) + layoutdoc[WidthSym](), NumCast(doc.y) + layoutdoc[HeightSym]()); 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) { 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 = Math.min(maxZoom, (1 / (this.nativeDimScaling || 1)) * 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._viewScale, 1); const ph = panelHeight / NumCast(this.layoutDoc._viewScale, 1); const cx = NumCast(this.layoutDoc._panX) + (this.props.isAnnotationOverlay ? pw / 2 : 0); const cy = NumCast(this.layoutDoc._panY) + (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._panX) : 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._panY) : cy) + Math.min(0, bounds.top - phborder - screen.top) + Math.max(0, bounds.bot + phborder - screen.bot), }; }; isContentActive = () => this.props.isSelected() || this.props.isContentActive(); @undoBatch @action onKeyDown = (e: React.KeyboardEvent, fieldProps: FieldViewProps) => { const docView = fieldProps.DocumentView?.(); if (docView && (e.metaKey || e.ctrlKey || e.altKey || docView.rootDoc._singleLine) && ['Tab', 'Enter'].includes(e.key)) { e.stopPropagation?.(); const below = !e.altKey && e.key !== 'Tab'; const layoutKey = StrCast(docView.LayoutFieldKey); const newDoc = Doc.MakeCopy(docView.rootDoc, true); const dataField = docView.rootDoc[Doc.LayoutFieldKey(newDoc)]; newDoc[DataSym][Doc.LayoutFieldKey(newDoc)] = dataField === undefined || Cast(dataField, listSpec(Doc), null)?.length !== undefined ? new List([]) : undefined; if (below) newDoc.y = NumCast(docView.rootDoc.y) + NumCast(docView.rootDoc._height) + 10; else newDoc.x = NumCast(docView.rootDoc.x) + NumCast(docView.rootDoc._width) + 10; if (layoutKey !== 'layout' && docView.rootDoc[layoutKey] instanceof Doc) { newDoc[layoutKey] = docView.rootDoc[layoutKey]; } Doc.GetProto(newDoc).text = undefined; FormattedTextBox.SelectOnLoad = newDoc[Id]; return this.addDocument?.(newDoc); } }; pointerEvents = () => { const engine = this.props.layoutEngine?.() || StrCast(this.props.Document._layoutEngine); const pointerEvents = this.props.isContentActive() === false ? 'none' : this.props.childPointerEvents ?? (this.props.viewDefDivClick || (engine === computePassLayout.name && !this.props.isSelected(true)) ? 'none' : this.props.pointerEvents?.()); return pointerEvents; }; getChildDocView(entry: PoolData) { const childLayout = entry.pair.layout; const childData = entry.pair.data; return ( ); } addDocTab = action((doc: Doc, where: OpenWhere) => { switch (where) { case OpenWhere.inParent: return this.props.addDocument?.(doc) || false; case OpenWhere.inParentFromScreen: return ( this.props.addDocument?.( (doc instanceof Doc ? [doc] : doc).map(doc => { const pt = this.getTransform().transformPoint(NumCast(doc.x), NumCast(doc.y)); doc.x = pt[0]; doc.y = pt[1]; return doc; }) ) || false ); case OpenWhere.inPlace: if (this.layoutDoc.isInPlaceContainer) { this.dataDoc[this.props.fieldKey] = new List(doc instanceof Doc ? [doc] : doc); return true; } } return this.props.addDocTab(doc, where); }); getCalculatedPositions(params: { pair: { layout: Doc; data?: Doc }; index: number; collection: Doc }): PoolData { const curFrame = Cast(this.Document._currentFrame, 'number'); const childDoc = params.pair.layout; const childDocLayout = Doc.Layout(childDoc); const { z, zIndex } = childDoc; const { backgroundColor, color } = curFrame === undefined ? { backgroundColor: undefined, color: undefined } : CollectionFreeFormDocumentView.getStringValues(childDoc, curFrame); const { x, y, opacity } = curFrame === undefined ? { x: childDoc.x, y: childDoc.y, opacity: this.props.childOpacity?.() } : CollectionFreeFormDocumentView.getValues(childDoc, curFrame); return { x: Number.isNaN(NumCast(x)) ? 0 : NumCast(x), y: Number.isNaN(NumCast(y)) ? 0 : NumCast(y), z: Cast(z, 'number'), color: Cast(color, 'string') ? StrCast(color) : this.props.styleProvider?.(childDoc, this.props, StyleProp.Color), backgroundColor: Cast(backgroundColor, 'string') ? StrCast(backgroundColor) : this.props.styleProvider?.(childDoc, this.props, StyleProp.BackgroundColor), opacity: this._keyframeEditing ? 1 : Cast(opacity, 'number') ?? this.props.styleProvider?.(childDoc, this.props, StyleProp.Opacity), zIndex: Cast(zIndex, 'number'), width: Cast(childDocLayout._width, 'number'), height: Cast(childDocLayout._height, 'number'), transition: StrCast(childDocLayout.dataTransition), pair: params.pair, replica: '', }; } onViewDefDivClick = (e: React.MouseEvent, payload: any) => { (this.props.viewDefDivClick || ScriptCast(this.props.Document.onViewDefDivClick))?.script.run({ this: this.props.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._renderCutoffData.get(doc[Id] + ''); }.bind(this) ); childPositionProviderUnmemoized = (doc: Doc, replica: string) => { return this._layoutPoolData.get(doc[Id] + (replica || '')); }; childDataProvider = computedFn( function childDataProvider(this: any, doc: Doc, replica: string) { return this._layoutPoolData.get(doc[Id] + (replica || '')); }.bind(this) ); childSizeProviderUnmemoized = (doc: Doc, replica: string) => { return this._layoutSizeData.get(doc[Id] + (replica || '')); }; childSizeProvider = computedFn( function childSizeProvider(this: any, doc: Doc, replica: string) { return this._layoutSizeData.get(doc[Id] + (replica || '')); }.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.props.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, index: i, collection: this.Document }))); 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) }; } @observable _numLoaded = 1; get doLayoutComputation() { const { newPool, computedElementData } = this.doInternalLayoutComputation; const array = Array.from(newPool.entries()); runInAction(() => { for (const entry of array) { const lastPos = this._cachedPool.get(entry[0]); // last computed pos const newPos = entry[1]; if ( !lastPos || newPos.color !== lastPos.color || newPos.backgroundColor !== lastPos.backgroundColor || newPos.opacity !== lastPos.opacity || newPos.x !== lastPos.x || newPos.y !== lastPos.y || newPos.z !== lastPos.z || newPos.zIndex !== lastPos.zIndex || newPos.transition !== lastPos.transition ) { this._layoutPoolData.set(entry[0], newPos); } if (!lastPos || newPos.height !== lastPos.height || newPos.width !== lastPos.width) { this._layoutSizeData.set(entry[0], { width: newPos.width, height: newPos.height }); } } }); this._cachedPool.clear(); Array.from(newPool.entries()).forEach(k => this._cachedPool.set(k[0], k[1])); const elements = computedElementData.slice(); Array.from(newPool.entries()) .filter(entry => this.isCurrent(entry[1].pair.layout)) .forEach((entry, i) => elements.push({ ele: this.getChildDocView(entry[1]), bounds: this.childDataProvider(entry[1].pair.layout, entry[1].replica), inkMask: BoolCast(entry[1].pair.layout.isInkMask) ? NumCast(entry[1].pair.layout.opacity, 1) : -1, }) ); if (this.props.isAnnotationOverlay && this.props.Document[this.scaleFieldKey]) { // don't zoom out farther than 1-1 if it's a bounded item (image, video, pdf), otherwise don't allow zooming in closer than 1-1 if it's a text sidebar if (this.props.scaleField) this.props.Document[this.scaleFieldKey] = Math.min(1, this.zoomScaling()); else this.props.Document[this.scaleFieldKey] = Math.max(1, this.zoomScaling()); // NumCast(this.props.Document[this.scaleFieldKey])); } this.Document._useClusters && !this._clusterSets.length && this.childDocs.length && this.updateClusters(true); return elements; } @action setViewSpec = (anchor: Doc, preview: boolean) => { if (preview) { this._focusFilters = StrListCast(Doc.GetProto(anchor).docFilters); this._focusRangeFilters = StrListCast(Doc.GetProto(anchor).docRangeFilters); } else { if (anchor.docFilters) { this.layoutDoc._docFilters = new List(StrListCast(anchor.docFilters)); } if (anchor.docRangeFilters) { this.layoutDoc._docRangeFilters = new List(StrListCast(anchor.docRangeFilters)); } } return 0; }; @action scrollFocus = (anchor: Doc, options: DocFocusOptions) => { const focusSpeed = options.instant ? 0 : options.zoomTime ?? 500; return PresBox.restoreTargetDocView( this.props.DocumentView?.(), // { pinDocLayout: BoolCast(anchor.presPinDocLayout) }, anchor, focusSpeed, { pannable: anchor.presPinData ? true : false, } ) ? focusSpeed : undefined; }; // sets viewing information for a componentview, typically when following a link. 'preview' tells the view to use the values without writing to the document getAnchor = () => { if (this.props.Document.annotationOn) { return this.rootDoc; } const anchor = Docs.Create.TextanchorDocument({ title: 'ViewSpec - ' + StrCast(this.layoutDoc._viewType), presTransition: 500, annotationOn: this.rootDoc }); PresBox.pinDocView(anchor, { pinData: { pannable: true } }, this.rootDoc); const proto = Doc.GetProto(anchor); proto[ViewSpecPrefix + '_viewType'] = this.layoutDoc._viewType; proto.docFilters = ObjectField.MakeCopy(this.layoutDoc.docFilters as ObjectField) || new List([]); 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 componentDidMount() { super.componentDidMount?.(); this.props.setBrushViewer?.(this.brushView); setTimeout( action(() => { this._firstRender = false; this._disposers.groupBounds = reaction( () => { if (this.props.Document._isGroup && this.childDocs.length === this.childDocList?.length) { const clist = this.childDocs.map(cd => ({ x: NumCast(cd.x), y: NumCast(cd.y), width: cd[WidthSym](), height: cd[HeightSym]() })); return aggregateBounds(clist, NumCast(this.layoutDoc._xPadding), NumCast(this.layoutDoc._yPadding)); } return undefined; }, cbounds => { if (cbounds) { const c = [NumCast(this.layoutDoc.x) + this.layoutDoc[WidthSym]() / 2, NumCast(this.layoutDoc.y) + this.layoutDoc[HeightSym]() / 2]; const p = [NumCast(this.layoutDoc._panX), NumCast(this.layoutDoc._panY)]; 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._panX = (cbounds.r + cbounds.x) / 2; this.layoutDoc._panY = (cbounds.b + cbounds.y) / 2; this.layoutDoc.x = pbounds.x; this.layoutDoc.y = pbounds.y; } } }, { fireImmediately: true } ); this._disposers.layoutComputation = reaction( () => this.doLayoutComputation, elements => (this._layoutElements = elements || []), { fireImmediately: true, name: 'doLayout' } ); }) ); } 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 img.src = canvas.toDataURL(); //image source 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.props.docViewPath().lastElement().ContentDiv!, this.layoutDoc[WidthSym](), this.layoutDoc[HeightSym](), 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 VideoBox.convertDataUri(data_url, filename, noSuffix, replaceRootFilename); cb(returnedFilename as string, nativeWidth, nativeHeight); }) .catch(function (error: any) { console.error('oops, something went wrong!', error); }); } componentWillUnmount() { Object.values(this._disposers).forEach(disposer => disposer?.()); this._marqueeRef?.removeEventListener('dashDragAutoScroll', this.onDragAutoScroll as any); } @action onCursorMove = (e: React.PointerEvent) => { // super.setCursorPosition(this.getTransform().transformPoint(e.clientX, e.clientY)); }; @action onDragAutoScroll = (e: CustomEvent) => { if ((e as any).handlePan || this.props.isAnnotationOverlay) return; (e as any).handlePan = true; if (!this.layoutDoc._noAutoscroll && !this.props.renderDepth && this._marqueeRef) { const dragX = e.detail.clientX; const dragY = e.detail.clientY; const bounds = this._marqueeRef?.getBoundingClientRect(); const deltaX = dragX - bounds.left < 25 ? -(25 + (bounds.left - dragX)) : bounds.right - dragX < 25 ? 25 - (bounds.right - dragX) : 0; const deltaY = dragY - bounds.top < 25 ? -(25 + (bounds.top - dragY)) : bounds.bottom - dragY < 25 ? 25 - (bounds.bottom - dragY) : 0; if (deltaX !== 0 || deltaY !== 0) { this.Document._panY = NumCast(this.Document._panY) + deltaY / 2; this.Document._panX = NumCast(this.Document._panX) + deltaX / 2; } } e.stopPropagation(); }; @undoBatch promoteCollection = () => { const childDocs = this.childDocs.slice(); childDocs.forEach(doc => { const scr = this.getTransform().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); this.props.ContainingCollectionView?.removeDocument(this.props.Document); }; @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._panX) + (i % dim) * width - (width * dim) / 2; doc.y = NumCast(this.Document._panY) + Math.floor(i / dim) * height - (height * dim) / 2; }); }; @undoBatch toggleNativeDimensions = () => Doc.toggleNativeDimensions(this.layoutDoc, 1, this.nativeWidth, this.nativeHeight); onContextMenu = (e: React.MouseEvent) => { if (this.props.isAnnotationOverlay || this.props.Document.annotationOn || !ContextMenu.Instance) return; const appearance = ContextMenu.Instance.findByDescription('Appearance...'); const appearanceItems = appearance && 'subitems' in appearance ? appearance.subitems : []; appearanceItems.push({ description: 'Reset View', event: () => { this.props.Document._panX = this.props.Document._panY = 0; this.props.Document[this.scaleFieldKey] = 1; }, icon: 'compress-arrows-alt', }); !Doc.noviceMode && Doc.UserDoc().defaultTextLayout && appearanceItems.push({ description: 'Reset default note style', event: () => (Doc.UserDoc().defaultTextLayout = undefined), icon: 'eye' }); appearanceItems.push({ description: `${this.fitContentsToBox ? 'Make Zoomable' : 'Scale to Window'}`, event: () => (this.Document._fitContentsToBox = !this.fitContentsToBox), icon: !this.fitContentsToBox ? 'expand-arrows-alt' : 'compress-arrows-alt', }); appearanceItems.push({ description: `Pin View`, event: () => TabDocView.PinDoc(this.rootDoc, { pinViewport: MarqueeView.CurViewBounds(this.rootDoc, this.props.PanelWidth(), this.props.PanelHeight()) }), icon: 'map-pin' }); //appearanceItems.push({ description: `update icon`, event: this.updateIcon, icon: "compress-arrows-alt" }); this.props.ContainingCollectionView && appearanceItems.push({ description: 'Ungroup collection', event: this.promoteCollection, icon: 'table' }); this.props.Document._isGroup && this.Document.transcription && appearanceItems.push({ description: 'Ink to text', event: () => this.transcribeStrokes(false), icon: 'font' }); // this.props.Document._isGroup && this.childDocs.filter(s => s.type === DocumentType.INK).length > 0 && appearanceItems.push({ description: "Ink to math", event: () => this.transcribeStrokes(true), icon: "square-root-alt" }); !Doc.noviceMode ? appearanceItems.push({ description: 'Arrange contents in grid', event: this.layoutDocsInGrid, icon: 'table' }) : null; !appearance && ContextMenu.Instance.addItem({ description: 'Appearance...', subitems: appearanceItems, icon: 'eye' }); const viewctrls = ContextMenu.Instance.findByDescription('UI Controls...'); const viewCtrlItems = viewctrls && 'subitems' in viewctrls ? viewctrls.subitems : []; !Doc.noviceMode ? viewCtrlItems.push({ description: (SnappingManager.GetShowSnapLines() ? 'Hide' : 'Show') + ' Snap Lines', event: () => SnappingManager.SetShowSnapLines(!SnappingManager.GetShowSnapLines()), icon: 'compress-arrows-alt' }) : null; !Doc.noviceMode ? viewCtrlItems.push({ description: (this.Document._useClusters ? 'Hide' : 'Show') + ' Clusters', event: () => this.updateClusters(!this.Document._useClusters), icon: 'braille' }) : null; !viewctrls && ContextMenu.Instance.addItem({ description: 'UI Controls...', subitems: viewCtrlItems, 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' }); 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 : []; if (!Doc.noviceMode) { e.persist(); moreItems.push({ description: 'Export collection', icon: 'download', event: async () => Doc.Zip(this.props.Document) }); moreItems.push({ description: 'Import exported collection', icon: 'upload', event: ({ x, y }) => this.importDocument(e.clientX, e.clientY) }); } !mores && ContextMenu.Instance.addItem({ description: 'More...', subitems: moreItems, icon: 'eye' }); }; importDocument = (x: number, y: number) => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.zip'; input.onchange = _e => { input.files && Doc.importDocument(input.files[0]).then(doc => { if (doc instanceof Doc) { const [xx, yy] = this.getTransform().transformPoint(x, y); (doc.x = xx), (doc.y = yy); this.props.addDocument?.(doc); } }); }; input.click(); }; @undoBatch @action transcribeStrokes = (math: boolean) => { if (this.props.Document._isGroup && this.props.Document.transcription) { if (!math) { const text = StrCast(this.props.Document.transcription); const lines = text.split('\n'); const height = 30 + 15 * lines.length; this.props.ContainingCollectionView?.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 setupDragLines = (snapToDraggedDoc: boolean = false) => { const activeDocs = this.getActiveDocuments(); const size = this.getTransform().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 otherBounds = { left: this.panX(), top: this.panY(), width: Math.abs(size[0]), height: Math.abs(size[1]) }; let snappableDocs = activeDocs.filter(doc => doc.z === undefined && isDocInView(doc, selRect)); // first see if there are any foreground docs to snap to !snappableDocs.length && (snappableDocs = activeDocs.filter(doc => doc.z === undefined && isDocInView(doc, selRect))); // if not, see if there are background docs to snap to !snappableDocs.length && (snappableDocs = activeDocs.filter(doc => doc.z !== undefined && isDocInView(doc, otherBounds))); // if not, then why not snap to floating docs const horizLines: number[] = []; const vertLines: number[] = []; const invXf = this.getTransform().inverse(); snappableDocs .filter(doc => snapToDraggedDoc || !DragManager.docsBeingDragged.includes(Cast(doc.rootDocument, Doc, null) || 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 }); DragManager.SetSnapLines(horizLines, vertLines); }; incrementalRendering = () => this.childDocs.filter(doc => !this._renderCutoffData.get(doc[Id])).length !== 0; incrementalRender = action(() => { if (!LightboxView.LightboxDoc || LightboxView.IsLightboxDocView(this.props.docViewPath())) { const unrendered = this.childDocs.filter(doc => !this._renderCutoffData.get(doc[Id])); const loadIncrement = 5; for (var i = 0; i < Math.min(unrendered.length, loadIncrement); i++) { this._renderCutoffData.set(unrendered[i][Id] + '', true); } } this.childDocs.some(doc => !this._renderCutoffData.get(doc[Id])) && setTimeout(this.incrementalRender, 1); }); children = () => { this.incrementalRender(); const children = typeof this.props.children === 'function' ? ((this.props.children as any)() as JSX.Element[]) : []; return [...children, ...this.views, ]; }; @computed get placeholder() { return (
{this.props.Document.annotationOn ? '' : this.props.Document.title?.toString()}
); } showPresPaths = () => (CollectionFreeFormView.ShowPresPaths ? PresBox.Instance.getPaths(this.rootDoc) : null); @computed get marqueeView() { TraceMobx(); 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.getContainerTransform} getTransform={this.getTransform} isAnnotationOverlay={this.isAnnotationOverlay}>
{ this._marqueeRef = r; r?.addEventListener('dashDragAutoScroll', this.onDragAutoScroll as any); }} style={{ opacity: this.props.dontRenderDocuments ? 0.7 : undefined }}> {this.layoutDoc._backgroundGridShow ? (
) : null} {this.children}
{this._showAnimTimeline ? : null}
); } @computed get nativeDimScaling() { if (this._firstRender || (this.props.isAnnotationOverlay && !this.props.annotationLayerHostsContent)) return 0; 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.layoutDoc.fitWidth ? wscale : hscale; } private groupDropDisposer?: DragManager.DragDropDisposer; protected createGroupEventsTarget = (ele: HTMLDivElement) => { //used for stacking and masonry view this.groupDropDisposer?.(); if (ele) { this.groupDropDisposer = DragManager.MakeDropTarget(ele, this.onInternalDrop.bind(this), this.layoutDoc, this.onInternalPreDrop.bind(this)); } }; @action brushView = (viewport: { width: number; height: number; panX: number; panY: number }) => { this._brushedView = { ...viewport, panX: viewport.panX - viewport.width / 2, panY: viewport.panY - viewport.height / 2, opacity: 1 }; this._brushtimer1 && clearTimeout(this._brushtimer1); this._brushtimer && clearTimeout(this._brushtimer); this._brushtimer1 = setTimeout( action(() => { this._brushedView.opacity = 0; this._brushtimer = setTimeout( action(() => (this._brushedView = { width: 0, height: 0, panX: 0, panY: 0, opacity: 0 })), 500 ); }), 1000 ); }; render() { TraceMobx(); return (
e.preventDefault()} onContextMenu={this.onContextMenu} style={{ pointerEvents: this.props.Document.type === DocumentType.MARKER ? 'none' // bcz: ugh.. this is here to prevent markers, which render as freeform views, from grabbing events -- need a better approach. : SnappingManager.GetIsDragging() && this.childDocs.includes(DragManager.docsBeingDragged.lastElement()) ? 'all' : (this.props.pointerEvents?.() as any), transform: `scale(${this.nativeDimScaling || 1})`, width: `${100 / (this.nativeDimScaling || 1)}%`, height: this.isAnnotationOverlay && this.Document.scrollHeight ? NumCast(this.Document.scrollHeight) : `${100 / (this.nativeDimScaling || 1)}%`, // : this.isAnnotationOverlay ? (this.Document.scrollHeight ? this.Document.scrollHeight : "100%") : this.props.PanelHeight() }}> {this._firstRender ? this.placeholder : this.marqueeView} {this.props.noOverlay ? null : } {/* // uncomment to show snap lines */}
{this._hLines?.map(l => ( ))} {this._vLines?.map(l => ( ))}
{this.props.Document._isGroup && SnappingManager.GetIsDragging() && this.ChildDrag ? (
) : null}
); } } interface CollectionFreeFormOverlayViewProps { elements: () => ViewDefResult[]; } @observer class CollectionFreeFormOverlayView extends React.Component { render() { return this.props .elements() .filter(ele => ele.bounds?.z) .map(ele => ele.ele); } } interface CollectionFreeFormViewPannableContentsProps { transform: () => string; zoomScaling: () => number; viewDefDivClick?: ScriptField; children: () => JSX.Element[]; transition?: string; presPaths: () => JSX.Element | null; presPinView?: boolean; isAnnotationOverlay: boolean | undefined; isAnnotationOverlayScrollable: boolean | undefined; brushView: { panX: number; panY: number; width: number; height: number; opacity: number }; } @observer class CollectionFreeFormViewPannableContents extends React.Component { @observable _drag: string = ''; //Adds event listener so knows pointer is down and moving onPointerDown = (e: React.PointerEvent): void => { e.stopPropagation(); e.preventDefault(); this._drag = (e.target as any)?.id ?? ''; document.getElementById(this._drag) && setupMoveUpEvents(e.target, e, this.onPointerMove, emptyFunction, emptyFunction); }; //Adjusts the value in NodeStore @action onPointerMove = (e: PointerEvent) => { const doc = document.getElementById('resizable'); const toNumber = (original: number, delta: number) => original + delta * this.props.zoomScaling(); if (doc) { switch (this._drag) { case 'resizer-br': doc.style.width = toNumber(doc.offsetWidth, e.movementX) + 'px'; doc.style.height = toNumber(doc.offsetHeight, e.movementY) + 'px'; break; case 'resizer-bl': doc.style.width = toNumber(doc.offsetWidth, -e.movementX) + 'px'; doc.style.height = toNumber(doc.offsetHeight, e.movementY) + 'px'; doc.style.left = toNumber(doc.offsetLeft, e.movementX) + 'px'; break; case 'resizer-tr': doc.style.width = toNumber(doc.offsetWidth, -e.movementX) + 'px'; doc.style.height = toNumber(doc.offsetHeight, -e.movementY) + 'px'; doc.style.top = toNumber(doc.offsetTop, e.movementY) + 'px'; case 'resizer-tl': doc.style.width = toNumber(doc.offsetWidth, -e.movementX) + 'px'; doc.style.height = toNumber(doc.offsetHeight, -e.movementY) + 'px'; doc.style.top = toNumber(doc.offsetTop, e.movementY) + 'px'; doc.style.left = toNumber(doc.offsetLeft, e.movementX) + 'px'; case 'resizable': doc.style.top = toNumber(doc.offsetTop, e.movementY) + 'px'; doc.style.left = toNumber(doc.offsetLeft, e.movementX) + 'px'; } return false; } return true; }; @computed get presPaths() { return !this.props.presPaths() ? null : ( <>
{PresBox.Instance?.order}
{this.props.presPaths()} ); } render() { return (
{ const target = e.target as any; if (getComputedStyle(target)?.overflow === 'visible') { // if collection is visible, then scrolling will mess things up since there are no scroll bars target.scrollTop = target.scrollLeft = 0; } }} style={{ transform: this.props.transform(), transition: this.props.transition, width: this.props.isAnnotationOverlay ? undefined : 0, // if not an overlay, then this will be the size of the collection, but panning and zooming will move it outside the visible border of the collection and make it selectable. This problem shows up after zooming/panning on a background collection -- you can drag the collection by clicking on apparently empty space outside the collection //willChange: "transform" }}> {this.props.children()} {!this.props.brushView.width ? null : (
)} {this.presPaths}
); } } interface CollectionFreeFormViewBackgroundGridProps { panX: () => number; panY: () => number; PanelWidth: () => number; PanelHeight: () => number; isAnnotationOverlay?: boolean; zoomScaling: () => number; layoutDoc: Doc; cachedCenteringShiftX: number; cachedCenteringShiftY: number; } @observer class CollectionFreeFormBackgroundGrid extends React.Component { chooseGridSpace = (gridSpace: number): number => { if (!this.props.zoomScaling()) return 50; const divisions = this.props.PanelWidth() / this.props.zoomScaling() / gridSpace + 3; return divisions < 60 ? gridSpace : this.chooseGridSpace(gridSpace * 10); }; render() { const gridSpace = this.chooseGridSpace(NumCast(this.props.layoutDoc['_backgroundGrid-spacing'], 50)); const shiftX = (this.props.isAnnotationOverlay ? 0 : (-this.props.panX() % gridSpace) - gridSpace) * this.props.zoomScaling(); const shiftY = (this.props.isAnnotationOverlay ? 0 : (-this.props.panY() % gridSpace) - gridSpace) * this.props.zoomScaling(); const renderGridSpace = gridSpace * this.props.zoomScaling(); const w = this.props.PanelWidth() + 2 * renderGridSpace; const h = this.props.PanelHeight() + 2 * renderGridSpace; const strokeStyle = Doc.ActiveDashboard?.colorScheme === ColorScheme.Dark ? 'rgba(255,255,255,0.5)' : 'rgba(0, 0,0,0.5)'; return ( { const ctx = el?.getContext('2d'); if (ctx) { const Cx = this.props.cachedCenteringShiftX % renderGridSpace; const Cy = this.props.cachedCenteringShiftY % renderGridSpace; ctx.lineWidth = Math.min(1, Math.max(0.5, this.props.zoomScaling())); ctx.setLineDash(gridSpace > 50 ? [3, 3] : [1, 5]); ctx.clearRect(0, 0, w, h); if (ctx) { ctx.strokeStyle = strokeStyle; ctx.beginPath(); for (let x = Cx - renderGridSpace; x <= w - Cx; x += renderGridSpace) { ctx.moveTo(x, Cy - h); ctx.lineTo(x, Cy + h); } for (let y = Cy - renderGridSpace; y <= h - Cy; y += renderGridSpace) { ctx.moveTo(Cx - w, y); ctx.lineTo(Cx + w, y); } ctx.stroke(); } } }} /> ); } } export function CollectionBrowseClick(dv: DocumentView, clientX: number, clientY: number) { SelectionManager.DeselectAll(); dv.props.focus(dv.props.Document, { willPanZoom: true, afterFocus: async didMove => { if (!didMove) { const selfFfview = dv.ComponentView instanceof CollectionFreeFormView ? dv.ComponentView : undefined; const parFfview = dv.props.CollectionFreeFormDocumentView?.().props.CollectionFreeFormView; const ffview = selfFfview && selfFfview.rootDoc[selfFfview.props.scaleField || '_viewScale'] !== 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 await ffview?.zoomSmoothlyAboutPt(ffview.getTransform().transformPoint(clientX, clientY), 0.5); } return ViewAdjustment.doNothing; }, }); Doc.linkFollowHighlight(dv?.props.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(readOnly: boolean, pinDocContent: boolean) { !readOnly && SelectionManager.Views().forEach(view => TabDocView.PinDoc(view.rootDoc, { currentFrame: Cast(view.rootDoc.currentFrame, 'number', null), pinDocContent, pinViewport: MarqueeView.CurViewBounds(view.rootDoc, view.props.PanelWidth(), view.props.PanelHeight()) }) ); });