import { Bezier } from 'bezier-js';
import { Colors } from 'browndash-components';
import { Property } from 'csstype';
import { action, computed, IReactionDisposer, makeObservable, observable, reaction, runInAction } from 'mobx';
import { observer } from 'mobx-react';
import { computedFn } from 'mobx-utils';
import * as React from 'react';
import { ClientUtils, DashColor, lightOrDark, OmitKeys, returnFalse, returnZero, setupMoveUpEvents, UpdateIcon } from '../../../../ClientUtils';
import { DateField } from '../../../../fields/DateField';
import { Doc, DocListCast, Field, FieldType, Opt, StrListCast } from '../../../../fields/Doc';
import { DocData, Height, Width } from '../../../../fields/DocSymbols';
import { Id } from '../../../../fields/FieldSymbols';
import { InkData, InkField, InkTool, Segment } from '../../../../fields/InkField';
import { List } from '../../../../fields/List';
import { RichTextField } from '../../../../fields/RichTextField';
import { listSpec } from '../../../../fields/Schema';
import { ScriptField } from '../../../../fields/ScriptField';
import { BoolCast, Cast, DocCast, NumCast, ScriptCast, StrCast, toList } from '../../../../fields/Types';
import { ImageField } from '../../../../fields/URLField';
import { TraceMobx } from '../../../../fields/util';
import { Gestures, PointData } from '../../../../pen-gestures/GestureTypes';
import { GestureUtils } from '../../../../pen-gestures/GestureUtils';
import { aggregateBounds, clamp, emptyFunction, intersectRect, Utils } from '../../../../Utils';
import { Docs } from '../../../documents/Documents';
import { CollectionViewType, DocumentType } from '../../../documents/DocumentTypes';
import { DocUtils } from '../../../documents/DocUtils';
import { DragManager } from '../../../util/DragManager';
import { dropActionType } from '../../../util/DropActionTypes';
import { CompileScript } from '../../../util/Scripting';
import { ScriptingGlobals } from '../../../util/ScriptingGlobals';
import { freeformScrollMode, SnappingManager } from '../../../util/SnappingManager';
import { Transform } from '../../../util/Transform';
import { undoable, undoBatch, UndoManager } from '../../../util/UndoManager';
import { Timeline } from '../../animationtimeline/Timeline';
import { ContextMenu } from '../../ContextMenu';
import { InkingStroke } from '../../InkingStroke';
import { CollectionFreeFormDocumentView } from '../../nodes/CollectionFreeFormDocumentView';
import { SchemaCSVPopUp } from '../../nodes/DataVizBox/SchemaCSVPopUp';
import { ActiveArrowEnd, ActiveArrowStart, ActiveDash, ActiveEraserWidth, ActiveFillColor, ActiveInkBezierApprox, ActiveInkColor, ActiveInkWidth, ActiveIsInkMask, DocumentView, SetActiveInkColor, SetActiveInkWidth } from '../../nodes/DocumentView';
import { FieldViewProps } from '../../nodes/FieldView';
import { FocusViewOptions } from '../../nodes/FocusViewOptions';
import { FormattedTextBox } from '../../nodes/formattedText/FormattedTextBox';
import { OpenWhere } from '../../nodes/OpenWhere';
import { PinDocView, PinProps } from '../../PinFuncs';
import { AnnotationPalette } from '../../smartdraw/AnnotationPalette';
import { DrawingOptions, SmartDrawHandler } from '../../smartdraw/SmartDrawHandler';
import { StyleProp } from '../../StyleProp';
import { CollectionSubView, SubCollectionViewProps } from '../CollectionSubView';
import { TreeViewType } from '../CollectionTreeViewType';
import { CollectionFreeFormBackgroundGrid } from './CollectionFreeFormBackgroundGrid';
import { CollectionFreeFormClusters } from './CollectionFreeFormClusters';
import { computePassLayout, computePivotLayout, computeStarburstLayout, computeTimelineLayout, PoolData, ViewDefBounds, ViewDefResult } from './CollectionFreeFormLayoutEngines';
import { CollectionFreeFormPannableContents } from './CollectionFreeFormPannableContents';
import { CollectionFreeFormRemoteCursors } from './CollectionFreeFormRemoteCursors';
import './CollectionFreeFormView.scss';
import { MarqueeView } from './MarqueeView';

@observer
class CollectionFreeFormOverlayView extends React.Component<{ elements: () => ViewDefResult[] }> {
    render() {
        return this.props.elements().filter(ele => ele.bounds?.z).map(ele => ele.ele); // prettier-ignore
    }
}
export interface collectionFreeformViewProps {
    NativeWidth?: () => number;
    NativeHeight?: () => number;
    originTopLeft?: boolean;
    annotationLayerHostsContent?: boolean; // whether to force scaling of content (needed by ImageBox)
    viewDefDivClick?: ScriptField;
    childPointerEvents?: () => string | undefined;
    viewField?: string;
    noOverlay?: boolean; // used to suppress docs in the overlay (z) layer (ie, for minimap since overlay doesn't scale)
    engineProps?: unknown;
    getScrollHeight?: () => number | undefined;
}

@observer
export class CollectionFreeFormView extends CollectionSubView<Partial<collectionFreeformViewProps>>() {
    public get displayName() {
        return 'CollectionFreeFormView(' + (this.Document.title?.toString() ?? '') + ')';
    } // this makes mobx trace() statements more descriptive
    public unprocessedDocs: Doc[] = [];
    public static collectionsWithUnprocessedInk = new Set<CollectionFreeFormView>();
    public static from(dv?: DocumentView): CollectionFreeFormView | undefined {
        const parent = CollectionFreeFormDocumentView.from(dv)?._props.reactParent;
        return parent instanceof CollectionFreeFormView ? parent : undefined;
    }

    private _clusters = new CollectionFreeFormClusters(this);
    private _oldWheel: HTMLDivElement | null = null;
    private _panZoomTransitionTimer: NodeJS.Timeout | undefined = undefined;
    private _brushtimer: NodeJS.Timeout | undefined = undefined;
    private _brushtimer1: NodeJS.Timeout | undefined = undefined;
    private _lastX: number = 0;
    private _lastY: number = 0;
    private _downX: number = 0;
    private _downY: number = 0;
    private _downTime = 0;
    private _disposers: { [name: string]: IReactionDisposer } = {};
    private _renderCutoffData = observable.map<string, boolean>();
    private _batch: UndoManager.Batch | undefined = undefined;
    private _keyTimer: NodeJS.Timeout | undefined; // timer for turning off transition flag when key frame change has completed.  Need to clear this if you do a second navigation before first finishes, or else first timer can go off during second naviation.

    private _presEaseFunc: string = 'ease';

    @action
    setPresEaseFunc = (easeFunc: string) => {
        this._presEaseFunc = easeFunc;
    };
    private get isAnnotationOverlay() { return this._props.isAnnotationOverlay; } // prettier-ignore
    private get scaleFieldKey()       { return (this._props.viewField ?? '') + '_freeform_scale'; } // prettier-ignore
    private get panXFieldKey()        { return (this._props.viewField ?? '') + '_freeform_panX'; } // prettier-ignore
    private get panYFieldKey()        { return (this._props.viewField ?? '') + '_freeform_panY'; } // prettier-ignore
    private get autoResetFieldKey()   { return (this._props.viewField ?? '') + '_freeform_autoReset'; } // prettier-ignore

    @observable.shallow _layoutElements: ViewDefResult[] = []; // shallow because some layout items (eg pivot labels) are just generated 'divs' and can't be frozen as observables
    @observable _panZoomTransition: number = 0; // sets the pan/zoom transform ease time- used by nudge(), focus() etc to smoothly zoom/pan.  set to 0 to use document's transition time or default of 0
    @observable _firstRender = false; // this turns off rendering of the collection's content so that there's instant feedback when a tab is switched of what content will be shown.  could be used for performance improvement
    @observable _showAnimTimeline = false;
    @observable _showDrawingEditor = false;
    @observable _deleteList: DocumentView[] = [];
    @observable _timelineRef = React.createRef<Timeline>();
    @observable _marqueeViewRef = React.createRef<MarqueeView>();
    @observable _brushedView: { width: number; height: number; panX: number; panY: number } | undefined = undefined; // highlighted region of freeform canvas used by presentations to indicate a region
    @observable GroupChildDrag: boolean = false; // child document view being dragged.  needed to update drop areas of groups when a group item is dragged.
    @observable _childPointerEvents: Property.PointerEvents | undefined = undefined;
    @observable _lightboxDoc: Opt<Doc> = undefined;
    @observable _paintedId = 'id' + Utils.GenerateGuid().replace(/-/g, '');
    @observable _keyframeEditing = false;
    @observable _eraserX: number = 0;
    @observable _eraserY: number = 0;
    @observable _showEraserCircle: boolean = false; // to determine whether the radius eraser should show
    constructor(props: SubCollectionViewProps) {
        super(props);
        makeObservable(this);
    }
    @computed get layoutEngine() {
        return this._props.layoutEngine?.() || StrCast(this.layoutDoc._layoutEngine);
    }
    @computed get childPointerEvents() {
        return SnappingManager.IsResizing
            ? 'none'
            : (this._props.childPointerEvents?.() ??
                  (this._props.viewDefDivClick || //
                  (this.layoutEngine === computePassLayout.name && !this._props.isSelected()) ||
                  this.isContentActive() === false
                      ? 'none'
                      : this._props.pointerEvents?.()));
    }
    @computed get contentViews() {
        const viewsMask = this._layoutElements.filter(ele => ele.bounds && !ele.bounds.z && ele.inkMask !== -1 && ele.inkMask !== undefined).map(ele => ele.ele);
        const renderableEles = this._layoutElements.filter(ele => ele.bounds && !ele.bounds.z && (ele.inkMask === -1 || ele.inkMask === undefined)).map(ele => ele.ele);
        if (viewsMask.length) renderableEles.push(<div className={`collectionfreeformview-mask${this._layoutElements.some(ele => (ele.inkMask ?? 0) > 0) ? '' : '-empty'}`}>{viewsMask}</div>);
        return renderableEles;
    }
    @computed get fitContentsToBox() {
        return (this._props.fitContentsToBox?.() || this.Document._freeform_fitContentsToBox) && !this.isAnnotationOverlay;
    }
    @computed get nativeWidth() {
        return this._props.NativeWidth?.() || Doc.NativeWidth(this.Document, Cast(this.Document.resolvedDataDoc, Doc, null));
    }
    @computed get nativeHeight() {
        return this._props.NativeHeight?.() || Doc.NativeHeight(this.Document, Cast(this.Document.resolvedDataDoc, Doc, null));
    }
    @computed get centeringShiftX(): number {
        return this._props.isAnnotationOverlay || this._props.originTopLeft ? 0 : this._props.PanelWidth() / 2 / this.nativeDimScaling; // shift so pan position is at center of window for non-overlay collections
    }
    @computed get centeringShiftY(): number {
        const panLocAtCenter = !(this._props.isAnnotationOverlay || this._props.originTopLeft);
        if (!panLocAtCenter) return 0;
        const dv = this.DocumentView?.();
        const aspect = !this.fitWidth && dv?.nativeWidth && dv?.nativeHeight;
        const scaling = this.nativeDimScaling;
        // if freeform has a native aspect, then the panel height needs to be adjusted to match it
        const height = aspect ? (dv.nativeHeight / dv.nativeWidth) * this._props.PanelWidth() : this._props.PanelHeight();
        return height / 2 / scaling; // shift so pan position is at center of window for non-overlay collections
    }
    @computed get panZoomXf() {
        return new Transform(this.panX(), this.panY(), 1 / this.zoomScaling());
    }
    @computed get screenToFreeformContentsXf() {
        return this._props
            .ScreenToLocalTransform() //
            .translate(-this.centeringShiftX, -this.centeringShiftY)
            .transform(this.panZoomXf);
    }
    @computed get backgroundColor() {
        return this._props.styleProvider?.(this.Document, this._props, StyleProp.BackgroundColor) as string;
    }
    @computed get fitWidth() {
        return this._props.fitWidth?.(this.Document) ?? this.layoutDoc.layout_fitWidth;
    }
    @computed get nativeDimScaling() {
        if (this._firstRender || (this._props.isAnnotationOverlay && !this._props.annotationLayerHostsContent)) return 1;
        const hscale = this._props.PanelHeight() / (this.nativeHeight || this._props.PanelHeight());
        const wscale = this._props.PanelWidth() / (this.nativeWidth || this._props.PanelWidth());
        return wscale < hscale || this.fitWidth ? wscale : hscale;
    }
    @computed get fitContentBounds() { return !this._firstRender && this.fitContentsToBox ? this.contentBounds() : undefined; } // prettier-ignore
    @computed get paintFunc() {
        const field = this.dataDoc[this.fieldKey];
        const paintFunc = StrCast(Field.toJavascriptString(Cast(field, RichTextField, null)?.Text as FieldType)).trim();
        return !paintFunc
            ? ''
            : paintFunc.includes('dashDiv')
              ? `const dashDiv = document.querySelector('#${this._paintedId}');
                    (async () => {  ${paintFunc} })()`
              : paintFunc;
    }

    public static gotoKeyframe(timer: NodeJS.Timeout | undefined, docs: Doc[], duration: number) {
        return DocumentView.SetViewTransition(docs, 'all', duration, timer, undefined, true);
    }
    changeKeyFrame = (back = false) => {
        const currentFrame = Cast(this.Document._currentFrame, 'number', null);
        if (currentFrame === undefined) {
            this.Document._currentFrame = 0;
            CollectionFreeFormDocumentView.setupKeyframes(this.childDocs, 0);
        }
        if (back) {
            this._keyTimer = CollectionFreeFormView.gotoKeyframe(this._keyTimer, [...this.childDocs, this.layoutDoc], 1000);
            this.Document._currentFrame = Math.max(0, (currentFrame || 0) - 1);
        } else {
            this._keyTimer = CollectionFreeFormDocumentView.updateKeyframe(this._keyTimer, [...this.childDocs, this.layoutDoc], currentFrame || 0);
            this.Document._currentFrame = Math.max(0, (currentFrame || 0) + 1);
            this.Document.lastFrame = Math.max(NumCast(this.Document._currentFrame), NumCast(this.Document.lastFrame));
        }
    };
    @action setKeyFrameEditing = (set: boolean) => {
        this._keyframeEditing = set;
    };
    getKeyFrameEditing = () => this._keyframeEditing;

    override contentBounds = () => {
        const { x, y, r, b } = aggregateBounds(
            this._layoutElements.filter(e => e.bounds?.width && !e.bounds.z).map(e => e.bounds!),
            NumCast(this.layoutDoc._xPadding, this._props.xPadding ?? 0),
            NumCast(this.layoutDoc._yPadding, this._props.yPadding ?? 0)
        );
        const [width, height] = [r - x, b - y];
        return {
            width,
            height,
            cx: x + width / 2,
            cy: y + height / 2,
            bounds: { x, y, r, b },
            scale: (!this.childDocs.length || !Number.isFinite(height) || !Number.isFinite(width)
                ? 1 //
                : Math.min(this._props.PanelHeight() / height,this._props.PanelWidth() / width )) / (this._props.NativeDimScaling?.() || 1),
        }; // prettier-ignore
    };
    onChildClickHandler = () => this._props.childClickScript || ScriptCast(this.Document.onChildClick);
    onChildDoubleClickHandler = () => this._props.childDoubleClickScript || ScriptCast(this.Document.onChildDoubleClick);
    elementFunc = () => this._layoutElements;
    viewTransition = () => (this._panZoomTransition ? '' + this._panZoomTransition : undefined);
    panZoomTransition = () => (this._panZoomTransition ? `transform ${this._panZoomTransition}ms` : Cast(this.layoutDoc._viewTransition, 'string', Cast(this.Document._viewTransition, 'string', null)));
    fitContentOnce = () => {
        const { cx, cy, scale } = this.contentBounds(); // prettier-ignore
        this.layoutDoc._freeform_panX = cx;
        this.layoutDoc._freeform_panY = cy;
        this.layoutDoc._freeform_scale = scale;
    };
    // freeform_panx, freeform_pany, freeform_scale all attempt to get values first from the layout controller, then from the layout/dataDoc (or template layout doc), and finally from the resolved template data document.
    // this search order, for example, allows icons of cropped images to find the panx/pany/zoom on the cropped image's data doc instead of the usual layout doc because the zoom/panX/panY define the cropped image
    panX = () => this.fitContentBounds?.cx ?? NumCast(this.Document[this.panXFieldKey], NumCast(Cast(this.Document.resolvedDataDoc, Doc, null)?.freeform_panX, 1));
    panY = () => this.fitContentBounds?.cy ?? NumCast(this.Document[this.panYFieldKey], NumCast(Cast(this.Document.resolvedDataDoc, Doc, null)?.freeform_panY, 1));
    zoomScaling = () => this.fitContentBounds?.scale ?? NumCast(Doc.Layout(this.Document)[this.scaleFieldKey], 1); // , NumCast(DocCast(this.Document.resolvedDataDoc)?.[this.scaleFieldKey], 1));
    PanZoomCenterXf = () => (this._props.isAnnotationOverlay && this.zoomScaling() === 1 ? `` : `translate(${this.centeringShiftX}px, ${this.centeringShiftY}px) scale(${this.zoomScaling()}) translate(${-this.panX()}px, ${-this.panY()}px)`);
    ScreenToContentsXf = () => this.screenToFreeformContentsXf.copy();
    getActiveDocuments = () => this.childLayoutPairs.filter(pair => this.isCurrent(pair.layout)).map(pair => pair.layout);
    isAnyChildContentActive = () => this._props.isAnyChildContentActive();
    addLiveTextBox = (newDoc: Doc) => {
        Doc.SetSelectOnLoad(newDoc); // track the new text box so we can give it a prop that tells it to focus itself when it's displayed
        this.addDocument(newDoc);
    };
    selectDocuments = (docs: Doc[]) => {
        DocumentView.DeselectAll();
        docs.map(doc => DocumentView.getDocumentView(doc, this.DocumentView?.())).forEach(dv => dv && DocumentView.SelectView(dv, true));
    };
    addDocument = (newBox: Doc | Doc[]) => {
        const newBoxes = toList(newBox);
        const retVal = newBoxes.every(doc => {
            const added = this._props.addDocument?.(doc);
            if (added) {
                this.bringToFront(doc);
                this._clusters.addDocument(doc);
            }
            return added;
        });
        if (retVal) {
            newBoxes.forEach(box => {
                if (box.activeFrame !== undefined) {
                    const vals = CollectionFreeFormDocumentView.animFields.map(field => box[field.key]);
                    CollectionFreeFormDocumentView.animFields.forEach(field => delete box[`${field.key}_indexed`]);
                    CollectionFreeFormDocumentView.animFields.forEach(field => delete box[field.key]);
                    delete box.activeFrame;
                    CollectionFreeFormDocumentView.animFields.forEach((field, i) => {
                        field.key !== 'opacity' && (box[field.key] = vals[i]);
                    });
                }
            });
            if (this.Document._currentFrame !== undefined && !this._props.isAnnotationOverlay) {
                CollectionFreeFormDocumentView.setupKeyframes(newBoxes, NumCast(this.Document._currentFrame), true);
            }
        }
        return retVal;
    };

    isCurrent(doc: Doc) {
        const dispTime = NumCast(doc._timecodeToShow, -1);
        const endTime = NumCast(doc._timecodeToHide, dispTime + 1.5);
        const curTime = NumCast(this.Document._layout_currentTimecode, -1);
        return dispTime === -1 || curTime === -1 || (curTime - dispTime >= -1e-4 && curTime <= endTime);
    }

    /**
     * focuses on a specified point in the freeform coordinate space.  (alternative to focusing on a Document)
     * @param options
     * @returns how long a transition it will be to focus on the point, or undefined the doc is a group or something else already moved
     */
    focusOnPoint = (options: FocusViewOptions) => {
        const { pointFocus, zoomTime, didMove } = options;
        if (!this.Document.isGroup && pointFocus && !didMove) {
            const dfltScale = this.isAnnotationOverlay ? 1 : 0.5;
            if (this.layoutDoc[this.scaleFieldKey] !== dfltScale) {
                this.zoomSmoothlyAboutPt(this.screenToFreeformContentsXf.transformPoint(pointFocus.X, pointFocus.Y), dfltScale, zoomTime);
                options.didMove = true;
                return zoomTime;
            }
        }
        return undefined;
    };

    /**
     * Focusing on a member of a group -
     *   Since groups can't pan and zoom like regular collections, this method focuses on a Doc in a group by
     * focusing on the group with an additional transformation to force the final focus to be on the center of the group item.
     * @param anchor
     * @param options
     * @returns
     */
    groupFocus = (anchor: Doc, options: FocusViewOptions) => {
        if (options.pointFocus) return undefined;
        options.docTransform = new Transform(NumCast(anchor.x) + NumCast(anchor._width)/2 - NumCast(this.layoutDoc[this.panXFieldKey]),
                                             NumCast(anchor.y) + NumCast(anchor._height)/2- NumCast(this.layoutDoc[this.panYFieldKey]), 1); // prettier-ignore
        const res = this._props.focus(this.Document, options);
        options.docTransform = undefined;
        return res;
    };

    /**
     * focuses the freeform view on the anchor subject to options.
     *    If a pointFocus is specified, then groupFocus is triggered instad
     *    Otherwise, this shifts the pan and zoom to the anchor target (as specified by options).
     *    NOTE: focusing on a group only has an effet if the options contextPath is empty.
     * @param anchor
     * @param options
     * @returns
     */
    focus = (anchor: Doc, options: FocusViewOptions) => {
        if (anchor.isGroup && !options.docTransform && options.contextPath?.length) {
            // don't focus on group if there's a context path because we're about to focus on a group item
            // which will override any group focus. (If we allowed the group to focus, it would mark didMove even if there were no net movement)
            return undefined;
        }
        if (options.easeFunc) this.setPresEaseFunc(options.easeFunc);
        if (this._lightboxDoc) return undefined;
        if (options.pointFocus) return this.focusOnPoint(options);
        const anchorInCollection = DocListCast(this.Document[this.fieldKey ?? Doc.LayoutFieldKey(this.Document)]).includes(anchor);
        const anchorInChildViews = this.childLayoutPairs.map(pair => pair.layout).includes(anchor);
        if (!anchorInCollection && !anchorInChildViews) {
            return undefined;
        }
        const xfToCollection = options?.docTransform ?? Transform.Identity();
        const savedState = { panX: NumCast(this.Document[this.panXFieldKey]), panY: NumCast(this.Document[this.panYFieldKey]), scale: options?.willZoomCentered ? this.Document[this.scaleFieldKey] : undefined };
        const cantTransform = this.fitContentsToBox || ((this.Document.isGroup || this.layoutDoc._lockedTransform) && !DocumentView.LightboxDoc());
        const { panX, panY, scale } = cantTransform || (!options.willPan && !options.willZoomCentered) ? savedState : this.calculatePanIntoView(anchor, xfToCollection, options?.willZoomCentered ? (options?.zoomScale ?? 0.75) : undefined);

        // focus on the document in the collection
        const didMove = !cantTransform && !anchor.z && (panX !== savedState.panX || panY !== savedState.panY || scale !== savedState.scale);
        if (didMove) options.didMove = true;
        // glr: freeform transform speed can be set by adjusting presentation_transition field - needs a way of knowing when presentation is not active...
        if (didMove) {
            const focusTime = options?.instant ? 0 : (options.zoomTime ?? 500);
            (options.zoomScale ?? options.willZoomCentered) && scale && (this.Document[this.scaleFieldKey] = scale);
            this.setPan(panX, panY, focusTime); // docs that are floating in their collection can't be panned to from their collection -- need to propagate the pan to a parent freeform somehow
            return focusTime;
        }
        return undefined;
    };

    getView = (doc: Doc, options: FocusViewOptions): Promise<Opt<DocumentView>> =>
        new Promise<Opt<DocumentView>>(res => {
            if (doc.hidden && this._lightboxDoc !== doc) options.didMove = !(doc.hidden = false);
            if (doc === this.Document) {
                res(this.DocumentView?.());
                return;
            }
            const findDoc = (finish: (dv: DocumentView) => void) => DocumentView.addViewRenderedCb(doc, dv => finish(dv));
            findDoc(dv => res(dv));
        });

    internalDocDrop(e: Event, de: DragManager.DropEvent, docDragData: DragManager.DocumentDragData) {
        if (!super.onInternalDrop(e, de)) return false;
        const refDoc = docDragData.droppedDocuments[0];
        const fromScreenXf = NumCast(refDoc.z) ? this.ScreenToLocalBoxXf() : this.screenToFreeformContentsXf;
        const [xpo, ypo] = fromScreenXf.transformPoint(de.x, de.y);
        const [x, y] = [xpo - docDragData.offset[0], ypo - docDragData.offset[1]];
        runInAction(() => {
            // needs to be in action to avoid having each edit trigger a freeform layout engine recompute - this triggers just one for each document at the end
            const zsorted = this.childLayoutPairs
                .map(pair => pair.layout) //
                .sort((doc1, doc2) => NumCast(doc1.zIndex) - NumCast(doc2.zIndex));
            zsorted.forEach((doc, index) => {
                doc.zIndex = doc.stroke_isInkMask ? 5000 : index + 1;
            });
            const dvals = CollectionFreeFormDocumentView.getValues(refDoc, NumCast(refDoc.activeFrame, 1000));
            const dropPos = this.Document._currentFrame !== undefined ? [NumCast(dvals.x), NumCast(dvals.y)] : [NumCast(refDoc.x), NumCast(refDoc.y)];

            docDragData.droppedDocuments.forEach((d, i) => {
                const layoutDoc = Doc.Layout(d);
                const delta = Utils.rotPt(x - dropPos[0], y - dropPos[1], fromScreenXf.Rotate);
                if (this.Document._currentFrame !== undefined) {
                    CollectionFreeFormDocumentView.setupKeyframes([d], NumCast(this.Document._currentFrame), false);
                    const pvals = CollectionFreeFormDocumentView.getValues(d, NumCast(d.activeFrame, 1000)); // get filled in values (uses defaults when not value is specified) for position
                    const vals = CollectionFreeFormDocumentView.getValues(d, NumCast(d.activeFrame, 1000), false); // get non-default values for everything else
                    vals.x = NumCast(pvals.x) + delta.x;
                    vals.y = NumCast(pvals.y) + delta.y;
                    CollectionFreeFormDocumentView.setValues(NumCast(this.Document._currentFrame), d, vals);
                } else {
                    d.x = NumCast(d.x) + delta.x;
                    d.y = NumCast(d.y) + delta.y;
                }
                d._layout_modificationDate = new DateField();
                const nd = [Doc.NativeWidth(layoutDoc), Doc.NativeHeight(layoutDoc)];
                layoutDoc._width = NumCast(layoutDoc._width, 300);
                layoutDoc._height = NumCast(layoutDoc._height, nd[0] && nd[1] ? (nd[1] / nd[0]) * NumCast(layoutDoc._width) : 300);
                !d._keepZWhenDragged && (d.zIndex = zsorted.length + 1 + i); // bringToFront
            });
            (docDragData.droppedDocuments.length === 1 || de.shiftKey) && this._clusters.addDocuments(docDragData.droppedDocuments);
        });

        return true;
    }

    internalAnchorAnnoDrop = undoable((e: Event, de: DragManager.DropEvent, annoDragData: DragManager.AnchorAnnoDragData) => {
        const dropCreator = annoDragData.dropDocCreator;
        const [xp, yp] = this.screenToFreeformContentsXf.transformPoint(de.x, de.y);
        annoDragData.dropDocCreator = (annotationOn: Doc | undefined) => {
            const dropDoc = dropCreator(annotationOn);
            if (dropDoc) {
                dropDoc.x = xp - annoDragData.offset[0];
                dropDoc.y = yp - annoDragData.offset[1];
                this.bringToFront(dropDoc);
            }
            return dropDoc || this.Document;
        };
        return true;
    }, 'anchor drop');

    internalLinkDrop = undoable((e: Event, de: DragManager.DropEvent, linkDragData: DragManager.LinkDragData) => {
        if (this.DocumentView?.() && linkDragData.linkDragView.containerViewPath?.().includes(this.DocumentView())) {
            const [x, y] = this.screenToFreeformContentsXf.transformPoint(de.x, de.y);
            //  do nothing if link is dropped into any freeform view parent of dragged document
            const source = Docs.Create.TextDocument('', { _width: 200, _height: 75, x, y, title: 'dropped annotation' });
            const added = !!this._props.addDocument?.(source);
            de.complete.linkDocument = DocUtils.MakeLink(linkDragData.linkSourceGetAnchor(), source, { link_relationship: 'annotated by:annotation of' });
            if (de.complete.linkDocument) {
                de.complete.linkDocument.layout_isSvg = true;
                this.addDocument(de.complete.linkDocument);
            }
            e.stopPropagation();
            !added && e.preventDefault();
            return added;
        }
        return false;
    }, 'link drop');

    onInternalDrop = (e: Event, de: DragManager.DropEvent): boolean => {
        if (de.complete.annoDragData?.dragDocument && super.onInternalDrop(e, de)) return this.internalAnchorAnnoDrop(e, de, de.complete.annoDragData);
        if (de.complete.linkDragData) return this.internalLinkDrop(e, de, de.complete.linkDragData);
        if (de.complete.docDragData?.droppedDocuments.length) return this.internalDocDrop(e, de, de.complete.docDragData);
        return false;
    };

    onExternalDrop = (e: React.DragEvent) => (([x, y]) => super.onExternalDrop(e, { x, y }))(this.screenToFreeformContentsXf.transformPoint(e.pageX, e.pageY));

    @action
    onPointerDown = (e: React.PointerEvent): void => {
        this._downX = this._lastX = e.pageX;
        this._downY = this._lastY = e.pageY;
        this._downTime = Date.now();
        const scrollMode = e.altKey ? (Doc.UserDoc().freeformScrollMode === freeformScrollMode.Pan ? freeformScrollMode.Zoom : freeformScrollMode.Pan) : Doc.UserDoc().freeformScrollMode;
        if (e.button === 0 && (!(e.ctrlKey && !e.metaKey) || scrollMode !== freeformScrollMode.Pan) && this._props.isContentActive()) {
            if (!this.Document.isGroup) {
                // group freeforms don't pan when dragged -- instead let the event go through to allow the group itself to drag
                // prettier-ignore
                const hit = this._clusters.handlePointerDown(this.screenToFreeformContentsXf.transformPoint(e.clientX, e.clientY));
                switch (Doc.ActiveTool) {
                    case InkTool.Highlighter:
                    case InkTool.Write:
                    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.StrokeEraser:
                    case InkTool.SegmentEraser:
                    case InkTool.RadiusEraser:
                        this._batch = UndoManager.StartBatch('collectionErase');
                        this._eraserPts.length = 0;
                        setupMoveUpEvents(this, e, this.onEraserMove, this.onEraserUp, this.onEraserClick, hit !== -1);
                        e.stopPropagation();
                        break;
                    case InkTool.SmartDraw:
                        setupMoveUpEvents(this, e, this.onPointerMove, emptyFunction, () => this.showSmartDraw(e.pageX, e.pageY), hit !== -1);
                        e.stopPropagation();
                        break;
                    case InkTool.None:
                        if (!(this._props.layoutEngine?.() || StrCast(this.layoutDoc._layoutEngine))) {
                            const ahit = this._clusters.handlePointerDown(this.screenToFreeformContentsXf.transformPoint(e.clientX, e.clientY));
                            setupMoveUpEvents(this, e, this.onPointerMove, emptyFunction, emptyFunction, ahit !== -1, false);
                        }
                        break;
                    default:
                }
            }
        }
    };

    onGesture = undoable((e: Event, ge: GestureUtils.GestureEvent) => {
        switch (ge.gesture) {
            case Gestures.Text:
                if (ge.text) {
                    const B = this.screenToFreeformContentsXf.transformPoint(ge.points[0].X, ge.points[0].Y);
                    this.addDocument(Docs.Create.TextDocument(ge.text, { title: ge.text, x: B[0], y: B[1] }));
                    e.stopPropagation();
                }
                break;
            case Gestures.Line:
            case Gestures.Circle:
            case Gestures.Rectangle:
            case Gestures.Triangle:
            case Gestures.Stroke:
            default: {
                const { points } = ge;
                const B = this.screenToFreeformContentsXf.transformBounds(ge.bounds.left, ge.bounds.top, ge.bounds.width, ge.bounds.height);
                const inkDoc = this.createInkDoc(points, B);
                if (Doc.ActiveTool === InkTool.Write) {
                    this.unprocessedDocs.push(inkDoc);
                    CollectionFreeFormView.collectionsWithUnprocessedInk.add(this);
                }
                this.addDocument(inkDoc);
                e.stopPropagation();
            }
        }
    }, 'gesture');
    @action
    onEraserUp = (): void => {
        this._deleteList.lastElement()?._props.removeDocument?.(this._deleteList.map(ink => ink.Document));
        this._deleteList = [];
        this._batch?.end();
    };

    @action
    onClick = (e: React.MouseEvent) => {
        if (this._lightboxDoc) this._lightboxDoc = undefined;
        if (ClientUtils.isClick(e.pageX, e.pageY, this._downX, this._downY, this._downTime)) {
            if (this.isContentActive() && e.shiftKey) {
                // reset zoom of freeform view to 1-to-1 on a shift + double click
                this.zoomSmoothlyAboutPt(this.screenToFreeformContentsXf.transformPoint(e.clientX, e.clientY), 1);
                e.stopPropagation();
                e.preventDefault();
            }
        }
    };

    scrollPan = (e: WheelEvent | { deltaX: number; deltaY: number }): void => {
        SnappingManager.TriggerUserPanned();
        this.setPan(NumCast(this.Document[this.panXFieldKey]) - e.deltaX, NumCast(this.Document[this.panYFieldKey]) - e.deltaY, 0);
    };

    @action
    pan = (e: PointerEvent): void => {
        const [ctrlKey, shiftKey] = [e.ctrlKey && !e.shiftKey, e.shiftKey && !e.ctrlKey];
        SnappingManager.TriggerUserPanned();
        this.DocumentView?.().clearViewTransition();
        const [dxi, dyi] = this.screenToFreeformContentsXf.transformDirection(e.clientX - this._lastX, e.clientY - this._lastY);
        const { x: dx, y: dy } = Utils.rotPt(dxi, dyi, this.ScreenToLocalBoxXf().Rotate);
        this.setPan(NumCast(this.Document[this.panXFieldKey]) - (ctrlKey ? 0 : dx), NumCast(this.Document[this.panYFieldKey]) - (shiftKey ? 0 : dy), 0);
        this._lastX = e.clientX;
        this._lastY = e.clientY;
    };

    _eraserLock = 0;
    _eraserPts: number[][] = []; // keep track of the last few eraserPts to make the eraser circle 'stretch'

    /**
     * Erases strokes by intersecting them with an invisible "eraser stroke".
     *  By default this iterates through all intersected ink strokes, determines which parts of a stroke need to be erased based on the type
     * of eraser, draws back the ink segments to keep, and deletes the original stroke.
     *
     * Radius eraser: erases strokes by intersecting them with a circle of variable radius. Essentially creates an InkField for the
     * eraser circle, then determines its intersections with other ink strokes. Each stroke's DocumentView and its
     * intersection t-values are put into a map, which gets looped through to take out the erased parts.
     */
    erase = (e: PointerEvent, delta: number[]) => {
        e.stopImmediatePropagation();
        const currPoint = { X: e.clientX, Y: e.clientY };
        this._eraserPts.push([currPoint.X, currPoint.Y]);
        this._eraserPts = this._eraserPts.slice(Math.max(0, this._eraserPts.length - 5));
        if (Doc.ActiveTool === InkTool.RadiusEraser) {
            const strokeMap: Map<DocumentView, number[]> = this.getRadiusEraserIntersections({ X: currPoint.X - delta[0], Y: currPoint.Y - delta[1] }, currPoint);
            strokeMap.forEach((intersects, stroke) => {
                if (!this._deleteList.includes(stroke)) {
                    this._deleteList.push(stroke);
                    SetActiveInkWidth(StrCast(stroke.Document.stroke_width?.toString()) || '1');
                    SetActiveInkColor(StrCast(stroke.Document.color?.toString()) || 'black');
                    const segments = this.radiusErase(stroke, intersects.sort());
                    segments?.forEach(segment =>
                        this.forceStrokeGesture(
                            e,
                            Gestures.Stroke,
                            segment.reduce((data, curve) => [...data, ...curve.points.map(p => stroke.ComponentView?.ptToScreen?.({ X: p.x, Y: p.y }) ?? { X: 0, Y: 0 })], [] as PointData[])
                        )
                    );
                }
                stroke.layoutDoc.opacity = 0;
                stroke.layoutDoc.dontIntersect = true;
            });
        } else {
            this.getEraserIntersections({ X: currPoint.X - delta[0], Y: currPoint.Y - delta[1] }, currPoint).forEach(intersect => {
                if (!this._deleteList.includes(intersect.inkView)) {
                    this._deleteList.push(intersect.inkView);
                    SetActiveInkWidth(StrCast(intersect.inkView.Document.stroke_width?.toString()) || '1');
                    SetActiveInkColor(StrCast(intersect.inkView.Document.color?.toString()) || 'black');
                    // create a new curve by appending all curves of the current segment together in order to render a single new stroke.
                    if (Doc.ActiveTool !== InkTool.StrokeEraser) {
                        // this._eraserLock++;
                        const segments = this.segmentErase(intersect.inkView, intersect.t); // intersect.t is where the eraser intersected the ink stroke - want to remove the segment that starts at the intersection just before this t value and goes to the one just after it
                        const newStrokes = segments?.map(segment => {
                            const points = 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[]);
                            return this.createInkDoc(points);
                        });
                        newStrokes && this.addDocument?.(newStrokes);
                        // setTimeout(() => this._eraserLock--);
                    }
                }
            });
        }
        return false;
    };

    @action
    onEraserMove = (e: PointerEvent, down: number[], delta: number[]) => {
        this.erase(e, delta);
        // if (this._eraserLock) return false; // leaving this commented out in case the idea is revisited in the future
        return false;
    };

    @action
    onEraserClick = (e: PointerEvent) => {
        e.preventDefault();
        e.stopImmediatePropagation();
        this.erase(e, [0, 0]);
    };

    forceStrokeGesture = (e: PointerEvent, gesture: Gestures, points: InkData, text?: string) => {
        this.onGesture(e, new GestureUtils.GestureEvent(gesture, points, InkField.getBounds(points), text));
    };

    onPointerMove = (e: PointerEvent) => {
        if (this._clusters.tryToDrag(e)) {
            e.stopPropagation(); // we're moving a cluster, so stop propagation and return true to end panning and let the document drag take over
            return true;
        }
        // pan the view if this is a regular collection, or it's an overlay and the overlay is zoomed (otherwise, there's nothing to pan)
        if (!this._props.isAnnotationOverlay || 1 - NumCast(this.layoutDoc._freeform_scale_min, 1) / this.zoomScaling()) {
            this.pan(e);
            e.stopPropagation(); // if we are actually panning, stop propagation -- this will preven things like the overlayView from dragging the document while we're panning
        }
        return false;
    };

    /**
     * Creates the eraser outline for a radius eraser. The outline is used to intersect with ink strokes and determine
     * what falls inside the eraser outline.
     * @param startInkCoordsIn
     * @param endInkCoordsIn
     * @param inkStrokeWidth
     * @returns
     */
    createEraserOutline = (startInkCoordsIn: { X: number; Y: number }, endInkCoordsIn: { X: number; Y: number }, inkStrokeWidth: number) => {
        // increase radius slightly based on the erased stroke's width, added to make eraser look more realistic
        const radius = ActiveEraserWidth() + 5 + inkStrokeWidth * 0.1; // add 5 to prevent eraser from being too small
        const c = 0.551915024494; // circle tangent length to side ratio
        const movement = { x: Math.max(endInkCoordsIn.X - startInkCoordsIn.X, 1), y: Math.max(endInkCoordsIn.Y - startInkCoordsIn.Y, 1) };
        const moveLen = Math.sqrt(movement.x ** 2 + movement.y ** 2);
        const direction = { x: (movement.x / moveLen) * radius, y: (movement.y / moveLen) * radius };
        const normal    = { x: -direction.y, y: direction.x }; // prettier-ignore

        const startCoords = { X: startInkCoordsIn.X - direction.x, Y: startInkCoordsIn.Y - direction.y };
        const endCoords = { X: endInkCoordsIn.X + direction.x, Y: endInkCoordsIn.Y + direction.y };
        return new InkField([
            // left bot arc
            { X: startCoords.X,                                            Y: startCoords.Y }, // prettier-ignore
            { X: startCoords.X + normal.x * c,                             Y: startCoords.Y + normal.y * c }, // prettier-ignore
            { X: startCoords.X + direction.x + normal.x - direction.x * c, Y: startCoords.Y + direction.y + normal.y - direction.y * c },
            { X: startCoords.X + direction.x + normal.x,                   Y: startCoords.Y + direction.y + normal.y }, // prettier-ignore

            // bot
            { X: startCoords.X + direction.x + normal.x,                   Y: startCoords.Y + direction.y + normal.y }, // prettier-ignore
            { X: startCoords.X + direction.x + normal.x + direction.x * c, Y: startCoords.Y + direction.y + normal.y + direction.y * c },
            { X: endCoords.X - direction.x + normal.x - direction.x * c,   Y: endCoords.Y - direction.y + normal.y - direction.y * c }, // prettier-ignore
            { X: endCoords.X - direction.x + normal.x,                     Y: endCoords.Y - direction.y + normal.y }, // prettier-ignore

            // right bot arc
            { X: endCoords.X - direction.x + normal.x,                    Y: endCoords.Y - direction.y + normal.y }, // prettier-ignore
            { X: endCoords.X - direction.x + normal.x + direction.x * c,  Y: endCoords.Y - direction.y + normal.y + direction.y * c}, // prettier-ignore
            { X: endCoords.X + normal.x * c,                              Y: endCoords.Y + normal.y * c }, // prettier-ignore
            { X: endCoords.X,                                             Y: endCoords.Y  }, // prettier-ignore

            // right top arc
            { X: endCoords.X,                                             Y: endCoords.Y  }, // prettier-ignore
            { X: endCoords.X - normal.x * c,                              Y: endCoords.Y - normal.y * c }, // prettier-ignore
            { X: endCoords.X - direction.x - normal.x + direction.x * c,  Y: endCoords.Y - direction.y - normal.y + direction.y * c}, // prettier-ignore
            { X: endCoords.X - direction.x - normal.x,                    Y: endCoords.Y - direction.y - normal.y }, // prettier-ignore

            // top
            { X: endCoords.X - direction.x - normal.x,                     Y: endCoords.Y - direction.y - normal.y }, // prettier-ignore
            { X: endCoords.X - direction.x - normal.x - direction.x * c,   Y: endCoords.Y - direction.y - normal.y - direction.y * c}, // prettier-ignore
            { X: startCoords.X + direction.x - normal.x + direction.x * c, Y: startCoords.Y + direction.y - normal.y + direction.y * c },
            { X: startCoords.X + direction.x - normal.x,                   Y: startCoords.Y + direction.y - normal.y }, // prettier-ignore

            // left top arc
            { X: startCoords.X + direction.x - normal.x,                   Y: startCoords.Y + direction.y - normal.y }, // prettier-ignore
            { X: startCoords.X + direction.x - normal.x - direction.x * c, Y: startCoords.Y + direction.y - normal.y - direction.y * c }, // prettier-ignore
            { X: startCoords.X - normal.x * c,                             Y: startCoords.Y - normal.y * c }, // prettier-ignore
            { X: startCoords.X,                                            Y: startCoords.Y }, // prettier-ignore
        ]);
    };

    /**
     * Ray-tracing algorithm to determine whether a point is inside the eraser outline.
     * @param eraserOutline
     * @param point
     * @returns
     */
    insideEraserOutline = (eraserOutline: InkData, point: { X: number; Y: number }) => {
        let isInside = false;
        if (!isNaN(eraserOutline[0].X) && !isNaN(eraserOutline[0].Y)) {
            let [minX, minY] = [eraserOutline[0].X, eraserOutline[0].Y];
            let [maxX, maxY] = [eraserOutline[0].X, eraserOutline[0].Y];
            for (let i = 1; i < eraserOutline.length; i++) {
                const currPoint: { X: number; Y: number } = eraserOutline[i];
                minX = Math.min(currPoint.X, minX);
                maxX = Math.max(currPoint.X, maxX);
                minY = Math.min(currPoint.Y, minY);
                maxY = Math.max(currPoint.Y, maxY);
            }

            if (point.X < minX || point.X > maxX || point.Y < minY || point.Y > maxY) {
                return false;
            }

            for (let i = 0, j = eraserOutline.length - 1; i < eraserOutline.length; j = i, i++) {
                if (eraserOutline[i].Y > point.Y !== eraserOutline[j].Y > point.Y && point.X < ((eraserOutline[j].X - eraserOutline[i].X) * (point.Y - eraserOutline[i].Y)) / (eraserOutline[j].Y - eraserOutline[i].Y) + eraserOutline[i].X) {
                    isInside = !isInside;
                }
            }
        }
        return isInside;
    };

    /**
     * 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 => DocumentView.getDocumentView(doc, this.DocumentView?.()))
            .filter(inkView => inkView?.ComponentView instanceof InkingStroke)
            .map(inkView => ({ inkViewBounds: inkView!.getBounds, inkStroke: inkView!.ComponentView as InkingStroke, inkView: inkView! }))
            .filter(
                ({ inkViewBounds }) =>
                    inkViewBounds && // bounding box of eraser segment and ink stroke overlap
                    eraserMin.X <= inkViewBounds.right &&
                    eraserMin.Y <= inkViewBounds.bottom &&
                    eraserMax.X >= inkViewBounds.left &&
                    eraserMax.Y >= inkViewBounds.top
            )
            .reduce(
                (intersections, { inkStroke, inkView }) => {
                    const { inkData } = inkStroke.inkScaledData(); // get bezier curve as set of control points
                    // Convert from screen space to ink space for the intersection.
                    const prevPointInkSpace = inkStroke.ptFromScreen(lastPoint);
                    const currPointInkSpace = inkStroke.ptFromScreen(currPoint);
                    for (let i = 0; i < inkData.length - 3; i += 4) {
                        // iterate over each segment of bezier curve
                        const rawIntersects = InkField.Segment(inkData, i).intersects({
                            // segment's are indexed by 0, 4, 8,
                            // 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 }[]
            );
    };

    /**
     * Same as getEraserIntersections but specific to the radius eraser. The key difference is that the radius eraser
     * will often intersect multiple strokes, depending on what strokes are inside the eraser. Populates a Map of each
     * intersected DocumentView to the t-values where the eraser intersected it, then returns this map.
     * @returns
     */
    getRadiusEraserIntersections = (lastPoint: { X: number; Y: number }, currPoint: { X: number; Y: number }) => {
        // set distance of the eraser's bounding box based on the zoom
        let boundingBoxDist = ActiveEraserWidth() + 5;
        this.zoomScaling() < 1 ? (boundingBoxDist /= this.zoomScaling() * 1.5) : (boundingBoxDist *= this.zoomScaling());

        const eraserMin = { X: Math.min(lastPoint.X, currPoint.X) - boundingBoxDist, Y: Math.min(lastPoint.Y, currPoint.Y) - boundingBoxDist };
        const eraserMax = { X: Math.max(lastPoint.X, currPoint.X) + boundingBoxDist, Y: Math.max(lastPoint.Y, currPoint.Y) + boundingBoxDist };
        const strokeToTVals = new Map<DocumentView, number[]>();
        const intersectingStrokes = this.childDocs
            .map(doc => DocumentView.getDocumentView(doc, this.DocumentView?.()))
            .filter(inkView => inkView?.ComponentView instanceof InkingStroke) // filter to all inking strokes
            .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
            );

        intersectingStrokes.forEach(({ inkStroke, inkView }) => {
            const { inkData, inkStrokeWidth } = inkStroke.inkScaledData();
            const prevPointInkSpace = inkStroke.ptFromScreen(lastPoint);
            const currPointInkSpace = inkStroke.ptFromScreen(currPoint);
            const eraserInkData = this.createEraserOutline(prevPointInkSpace, currPointInkSpace, inkStrokeWidth).inkData;

            // add the ends of the stroke in as "intersections"
            if (this.insideEraserOutline(eraserInkData, inkData[0])) {
                strokeToTVals.set(inkView, [0]);
            }
            if (this.insideEraserOutline(eraserInkData, inkData[inkData.length - 1])) {
                const inkList = strokeToTVals.get(inkView);
                if (inkList !== undefined) {
                    inkList.push(Math.floor(inkData.length / 4) + 1);
                } else {
                    strokeToTVals.set(inkView, [Math.floor(inkData.length / 4) + 1]);
                }
            }
            for (let i = 0; i < inkData.length - 3; i += 4) {
                // iterate over each segment of bezier curve
                for (let j = 0; j < eraserInkData.length - 3; j += 4) {
                    const intersectCurve: Bezier = InkField.Segment(inkData, i); // other curve
                    const eraserCurve: Bezier = InkField.Segment(eraserInkData, j); // eraser curve
                    InkField.bintersects(intersectCurve, eraserCurve).forEach((val: string | number, k: number) => {
                        // Converting the Bezier.js Split type to a t-value number.
                        const t = +val.toString().split('/')[0];
                        if (k % 2 === 0) {
                            // here, add to the map
                            const inkList = strokeToTVals.get(inkView);
                            if (inkList !== undefined) {
                                const tValOffset = ActiveEraserWidth() / 1050; // to prevent tVals from being added when too close, but scaled by eraser width
                                const inList = inkList.some(ival => Math.abs(ival - (t + Math.floor(i / 4))) <= tValOffset);
                                if (!inList) {
                                    inkList.push(t + Math.floor(i / 4));
                                }
                            } else {
                                strokeToTVals.set(inkView, [t + Math.floor(i / 4)]);
                            }
                        }
                    });
                }
            }
        });
        return strokeToTVals;
    };

    /**
     * Splits the passed in ink stroke at the intersection t values, taking out the erased parts.
     * Operates in pairs of t values, where the first t value is the start of the erased portion and the following t value is the end.
     * @param ink the ink stroke DocumentView to split
     * @param tVals all the t values to split the ink stroke at
     * @returns a list of the new segments with the erased part removed
     */
    @action
    radiusErase = (ink: DocumentView, tVals: number[]): Segment[] => {
        const segments: Segment[] = [];
        const inkStroke = ink?.ComponentView as InkingStroke;
        const { inkData } = inkStroke.inkScaledData();
        let currSegment: Segment = [];

        // any radius erase stroke will always result in even tVals, since the ends are included
        if (tVals.length % 2 !== 0) {
            for (let i = 0; i < inkData.length - 3; i += 4) {
                currSegment.push(InkField.Segment(inkData, i));
            }
            segments.push(currSegment);
            return segments; // return the full original stroke
        }

        let continueErasing = false; // used to erase segments if they are completely enclosed in the eraser
        let firstSegment: Segment = []; // used to keep track of the first segment for closed curves

        // early return if nothing to split on
        if (tVals.length === 0 || (tVals.length === 1 && tVals[0] === 0)) {
            for (let i = 0; i < inkData.length - 3; i += 4) {
                currSegment.push(InkField.Segment(inkData, i));
            }
            segments.push(currSegment);
            return segments;
        }

        // loop through all segments of an ink stroke, string together the pieces, excluding the erased parts,
        // and push each piece we want to keep to the return list
        for (let i = 0; i < inkData.length - 3; i += 4) {
            const currCurveT = Math.floor(i / 4);
            const inkBezier: Bezier = InkField.Segment(inkData, i);
            // filter to this segment's t-values
            const segmentTs = tVals.filter(t => t >= currCurveT && t < currCurveT + 1);

            if (segmentTs.length > 0) {
                for (let j = 0; j < segmentTs.length; j++) {
                    if (segmentTs[j] === 0) {
                        // if the first end of the segment is within the eraser
                        continueErasing = true;
                    } else if (segmentTs[j] === Math.floor(inkData.length / 4) + 1) {
                        // the last end
                        break;
                    } else if (!continueErasing) {
                        currSegment.push(inkBezier.split(0, segmentTs[j] - currCurveT));
                        continueErasing = true;
                    } else {
                        // we've reached the end of the part to take out...
                        continueErasing = false;
                        if (currSegment.length > 0) {
                            segments.push(currSegment); // ...so we add it to the list and reset currSegment
                            if (firstSegment.length === 0) {
                                firstSegment = currSegment;
                            }
                            currSegment = [];
                        }
                        currSegment.push(inkBezier.split(segmentTs[j] - currCurveT, 1));
                    }
                }
            } else if (!continueErasing) {
                // push the bezier piece if not in the eraser circle
                currSegment.push(inkBezier);
            }
        }

        if (currSegment.length > 0) {
            // add the first segment onto the last to avoid fragmentation for closed curves
            if (InkingStroke.IsClosed(inkData)) {
                currSegment = currSegment.concat(firstSegment);
            }
            segments.push(currSegment);
        }
        return segments;
    };

    /**
     * Erases ink strokes by segments. Locates intersections of the current ink stroke with all other ink strokes (including itself),
     * then erases the segment that was intersected by the eraser. This is done by creating either 1 or two resulting segments
     * (this depends on whether the eraser his the middle or end of a stroke), and returning the segments to "redraw."
     * @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
    segmentErase = (ink: DocumentView, excludeT: number): Segment[] => {
        const segments: Segment[] = [];
        let segment1: Segment = [];
        let segment2: Segment = [];
        const { inkData } = (ink?.ComponentView as InkingStroke).inkScaledData();
        let intersections: number[] = []; // list of the ink stroke's intersections
        const segmentIndexes: number[] = []; // list of indexes of the curve's segment where each intersection occured

        // loops through each segment and adds intersections to the list
        for (let i = 0; i < inkData.length - 3; i += 4) {
            const inkSegment: Bezier = InkField.Segment(inkData, i);
            let currIntersects = this.getInkIntersections(i, ink, inkSegment).sort();
            // get current segment's intersections (if any) and add the curve index
            currIntersects = currIntersects.filter(tVal => tVal > 0 && tVal < 1).map(tVal => tVal + Math.floor(i / 4));
            if (currIntersects.length) {
                intersections = [...intersections, ...currIntersects];
                for (let j = 0; j < currIntersects.length; j++) {
                    segmentIndexes.push(Math.floor(i / 4));
                }
            }
        }

        let isClosedCurve = false;
        if (InkingStroke.IsClosed(inkData)) {
            isClosedCurve = true;
            if (intersections.length === 1) {
                // delete whole stroke if a closed curve has 1 intersection
                return segments;
            }
        }

        if (intersections.length) {
            // this is the indexes of the closest intersection(s)
            const closestTs = this.getClosestTs(intersections, excludeT, 0, intersections.length - 1);

            // find the segments that need to be split
            let splitSegment1 = -1; // stays -1 if left end is deleted
            let splitSegment2 = -1; // stays -1 if right end is deleted
            if (closestTs[0] !== -1 && closestTs[1] !== -1) {
                // if not on the ends
                splitSegment1 = segmentIndexes[closestTs[0]];
                splitSegment2 = segmentIndexes[closestTs[1]];
            } else if (closestTs[0] === -1) {
                // for a curve before an intersection
                splitSegment2 = segmentIndexes[closestTs[1]];
            } else {
                // for a curve after an intersection
                splitSegment1 = segmentIndexes[closestTs[0]];
            }
            // so by this point splitSegment1 and splitSegment2 will be the index(es) of the segment(s) to split

            let hasSplit = false;
            let continueErasing = false;
            // loop through segments again and split them if they match the split segments
            for (let i = 0; i < inkData.length - 3; i += 4) {
                const currCurveT = Math.floor(i / 4);
                const inkSegment: Bezier = InkField.Segment(inkData, i);

                // case where the current curve is the first to split
                if (splitSegment1 !== -1 && splitSegment2 !== -1) {
                    if (splitSegment1 === splitSegment2 && splitSegment1 === currCurveT) {
                        // if it's the same segment
                        segment1.push(inkSegment.split(0, intersections[closestTs[0]] - currCurveT));
                        segment2.push(inkSegment.split(intersections[closestTs[1]] - currCurveT, 1));
                        hasSplit = true;
                    } else if (splitSegment1 === currCurveT) {
                        segment1.push(inkSegment.split(0, intersections[closestTs[0]] - currCurveT));
                        continueErasing = true;
                    } else if (splitSegment2 === currCurveT) {
                        segment2.push(inkSegment.split(intersections[closestTs[1]] - currCurveT, 1));
                        continueErasing = false;
                        hasSplit = true;
                    } else if (!continueErasing && !hasSplit) {
                        // segment doesn't get pushed if continueErasing is true
                        segment1.push(inkSegment);
                    } else if (!continueErasing && hasSplit) {
                        segment2.push(inkSegment);
                    }
                } else if (splitSegment1 === -1) {
                    // case where first end is erased
                    if (currCurveT === splitSegment2) {
                        if (isClosedCurve && currCurveT === segmentIndexes.lastElement()) {
                            segment2.push(inkSegment.split(intersections[closestTs[1]] - currCurveT, intersections.lastElement() - currCurveT));
                            continueErasing = true;
                        } else {
                            segment2.push(inkSegment.split(intersections[closestTs[1]] - currCurveT, 1));
                        }
                        hasSplit = true;
                    } else if (isClosedCurve && currCurveT === segmentIndexes.lastElement()) {
                        segment2.push(inkSegment.split(0, intersections.lastElement() - currCurveT));
                        continueErasing = true;
                    } else if (hasSplit && !continueErasing) {
                        segment2.push(inkSegment);
                    }
                }
                // case where last end is erased
                else if (currCurveT === segmentIndexes[0] && isClosedCurve) {
                    if (isClosedCurve && currCurveT === segmentIndexes.lastElement()) {
                        segment1.push(inkSegment.split(intersections[0] - currCurveT, intersections.lastElement() - currCurveT));
                        continueErasing = true;
                    } else {
                        segment1.push(inkSegment.split(intersections[0] - currCurveT, 1));
                    }
                    hasSplit = true;
                } else if (currCurveT === splitSegment1) {
                    segment1.push(inkSegment.split(0, intersections[closestTs[0]] - currCurveT));
                    hasSplit = true;
                    continueErasing = true;
                } else if ((isClosedCurve && hasSplit && !continueErasing) || (!isClosedCurve && !hasSplit)) {
                    segment1.push(inkSegment);
                }
            }
        }

        // add the first segment onto the second one for closed curves, so they don't get fragmented into two pieces
        if (isClosedCurve && segment1.length > 0 && segment2.length > 0) {
            segment2 = segment2.concat(segment1);
            segment1 = [];
        }

        // push 1 or both segments if they are not empty
        if (segment1.length && (Math.abs(segment1[0].points[0].x - segment1[0].points.lastElement().x) > 0.5 || Math.abs(segment1[0].points[0].y - segment1[0].points.lastElement().y) > 0.5)) {
            segments.push(segment1);
        }
        if (segment2.length && (Math.abs(segment2[0].points[0].x - segment2[0].points.lastElement().x) > 0.5 || Math.abs(segment2[0].points[0].y - segment2[0].points.lastElement().y) > 0.5)) {
            segments.push(segment2);
        }

        return segments;
    };

    /**
     * Standard logarithmic search function to search a sorted list of tVals for the ones closest to excludeT.
     * @param tVals list of tvalues (usage is for intersection t values) to search within
     * @param excludeT the t value of where the eraser intersected the curve
     * @param startIndex the start index to search from
     * @param endIndex the end index to search to
     * @returns 2-item array of the closest tVals indexes
     */
    getClosestTs = (tVals: number[], excludeT: number, startIndex: number, endIndex: number): number[] => {
        if (tVals[startIndex] >= excludeT) {
            return [-1, startIndex];
        }
        if (tVals[endIndex] < excludeT) {
            return [endIndex, -1];
        }
        const mid = Math.floor((startIndex + endIndex) / 2);
        if (excludeT >= tVals[mid]) {
            if (mid + 1 <= endIndex && tVals[mid + 1] > excludeT) {
                return [mid, mid + 1];
            }
            return this.getClosestTs(tVals, excludeT, mid + 1, endIndex);
        }
        if (mid - 1 >= startIndex && tVals[mid - 1] < excludeT) {
            return [mid - 1, mid];
        }
        return this.getClosestTs(tVals, excludeT, startIndex, mid - 1);
    };

    /**
     * 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 = DocumentView.getDocumentView(doc, this.DocumentView?.())?.ComponentView as InkingStroke;
                const { inkData: otherInkData } = otherInk?.inkScaledData() ?? { inkData: [] };
                const otherScreenPts = otherInkData.map(point => otherInk.ptToScreen(point));
                const otherCtrlPts = otherScreenPts.map(spt => (ink.ComponentView as InkingStroke).ptFromScreen(spt));
                for (let j = 0; j < otherCtrlPts.length - 3; j += 4) {
                    const neighboringSegment = i === j || i === j - 4 || i === j + 4;
                    // Ensuring that the curve intersected by the eraser is not checked for further ink intersections.
                    if (ink?.Document === otherInk.Document && neighboringSegment) continue;

                    const otherCurve = new Bezier(otherCtrlPts.slice(j, j + 4).map(p => ({ x: p.X, y: p.Y })));
                    const c0 = otherCurve.get(0);
                    const c1 = otherCurve.get(1);
                    const apt = curve.project(c0);
                    const bpt = curve.project(c1);
                    if (apt.d !== undefined && apt.d < 1 && apt.t !== undefined && !tVals.includes(apt.t)) {
                        tVals.push(apt.t);
                    }
                    InkField.bintersects(curve, otherCurve).forEach((val: string | number, ival: number) => {
                        // Converting the Bezier.js Split type to a t-value number.
                        const t = +val.toString().split('/')[0];
                        if (ival % 2 === 0 && !tVals.includes(t)) tVals.push(t); // bcz: Hack!  don't know why but intersection points are doubled from bezier.js (but not identical).
                    });
                    if (bpt.d !== undefined && bpt.d < 1 && bpt.t !== undefined && !tVals.includes(bpt.t)) {
                        tVals.push(bpt.t);
                    }
                }
            });
        return tVals;
    };

    /**
     * Creates an ink document to add to the freeform canvas.
     */
    createInkDoc = (points: InkData, transformedBounds?: { x: number; y: number; width: number; height: number }) => {
        const bounds = InkField.getBounds(points);
        const B = transformedBounds || this.screenToFreeformContentsXf.transformBounds(bounds.left, bounds.top, bounds.width, bounds.height);
        const inkWidth = ActiveInkWidth() * this.ScreenToLocalBoxXf().Scale;
        return Docs.Create.InkDocument(
            points,
            { title: 'stroke',
                x: B.x - inkWidth / 2,
                y: B.y - inkWidth / 2,
                _width: B.width + inkWidth,
                _height: B.height + inkWidth,
                stroke_showLabel: BoolCast(Doc.UserDoc().activeInkHideTextLabels)}, // prettier-ignore
            inkWidth,
            ActiveInkColor(),
            ActiveInkBezierApprox(),
            ActiveFillColor(),
            ActiveArrowStart(),
            ActiveArrowEnd(),
            ActiveDash(),
            ActiveIsInkMask()
        );
    };

    @action
    showSmartDraw = (x: number, y: number) => {
        SmartDrawHandler.Instance.CreateDrawingDoc = this.createDrawingDoc;
        SmartDrawHandler.Instance.RemoveDrawing = this.removeDrawing;
        SmartDrawHandler.Instance.AddDrawing = this.addDrawing;
        SmartDrawHandler.Instance.displaySmartDrawHandler(x, y);
    };

    _drawing: Doc[] = [];
    _drawingContainer: Doc | undefined = undefined;
    /**
     * Function that creates a drawing--a group of ink strokes--to go with the smart draw function.
     */
    @undoBatch
    createDrawingDoc = (strokeData: [InkData, string, string][], opts: DrawingOptions) => {
        this._drawing = [];
        const xf = this.screenToFreeformContentsXf;
        strokeData.forEach((stroke: [InkData, string, string]) => {
            const bounds = InkField.getBounds(stroke[0]);
            const B = xf.transformBounds(bounds.left, bounds.top, bounds.width, bounds.height);
            const inkWidth = ActiveInkWidth() * this.ScreenToLocalBoxXf().Scale;
            const inkDoc = Docs.Create.InkDocument(
                stroke[0],
                { title: 'stroke',
                    x: B.x - inkWidth / 2,
                    y: B.y - inkWidth / 2,
                    _width: B.width + inkWidth,
                    _height: B.height + inkWidth,
                    stroke_showLabel: BoolCast(Doc.UserDoc().activeInkHideTextLabels)}, // prettier-ignore
                inkWidth,
                opts.autoColor ? stroke[1] : ActiveInkColor(),
                ActiveInkBezierApprox(),
                stroke[2] === 'none' ? ActiveFillColor() : stroke[2],
                ActiveArrowStart(),
                ActiveArrowEnd(),
                ActiveDash(),
                ActiveIsInkMask()
            );
            this._drawing.push(inkDoc);
        });
        return MarqueeView.getCollection(this._drawing, undefined, true, { left: opts.x, top: opts.y, width: 1, height: 1 });
    };

    /**
     * Part of regenerating a drawing--deletes the old drawing.
     */
    removeDrawing = (useLastContainer: boolean, doc?: Doc) => {
        this._batch = UndoManager.StartBatch('regenerateDrawing');
        if (useLastContainer && this._drawingContainer) {
            this._props.removeDocument?.(this._drawingContainer);
        } else if (doc) {
            const docData = doc[DocData];
            const children = DocListCast(docData.data);
            this._props.removeDocument?.(doc);
            this._props.removeDocument?.(children);
        }
        this._drawing = [];
    };

    /**
     * Adds the created drawing to the freeform canvas and sets the metadata.
     */
    addDrawing = (doc: Doc, opts: DrawingOptions, gptRes: string) => {
        const docData = doc[DocData];
        docData.title = opts.text.match(/^(.*?)~~~.*$/)?.[1] || opts.text;
        docData.width = opts.size;
        docData.drawingInput = opts.text;
        docData.drawingComplexity = opts.complexity;
        docData.drawingColored = opts.autoColor;
        docData.drawingSize = opts.size;
        docData.drawingData = gptRes;
        this._drawingContainer = doc;
        this.addDocument(doc);
        this._batch?.end();
    };

    @action
    zoom = (pointX: number, pointY: number, deltaY: number): void => {
        if (this.Document.isGroup || this.Document[(this._props.viewField ?? '_') + 'freeform_noZoom']) return;
        let deltaScale = deltaY > 0 ? 1 / 1.05 : 1.05;
        if (deltaScale < 0) deltaScale = -deltaScale;
        const [x, y] = this.screenToFreeformContentsXf.transformPoint(pointX, pointY);
        const invTransform = this.panZoomXf.inverse();
        if (deltaScale * invTransform.Scale > 20) {
            deltaScale = 20 / invTransform.Scale;
        }
        if (deltaScale < 1 && invTransform.Scale <= NumCast(this.Document[this.scaleFieldKey + '_min'])) {
            this.setPan(0, 0);
            return;
        }
        const minScale = NumCast(this.Document[this.scaleFieldKey + '_min'], this.isAnnotationOverlay ? 1 : 0);
        const maxScale = NumCast(this.Document[this.scaleFieldKey + '_max'], Number.MAX_VALUE);
        deltaScale = clamp(deltaScale, minScale / invTransform.Scale, maxScale / invTransform.Scale);
        const localTransform = invTransform.scaleAbout(deltaScale, x, y);
        if (localTransform.Scale >= 0.05 || localTransform.Scale > this.zoomScaling()) {
            const safeScale = Math.min(Math.max(0.05, localTransform.Scale), 20);
            const allowScroll = this.Document[this.scaleFieldKey] !== minScale && Math.abs(safeScale) === minScale;
            this.Document[this.scaleFieldKey] = Math.abs(safeScale);
            this.setPan(-localTransform.TranslateX / safeScale, (this._props.originTopLeft ? undefined : NumCast(this.Document.layout_scrollTop) * safeScale) || -localTransform.TranslateY / safeScale, undefined, allowScroll);
        }
    };

    @action
    onPointerWheel = (e: React.WheelEvent): void => {
        if (this.Document.isGroup || !this.isContentActive()) return; // group style collections neither pan nor zoom
        SnappingManager.TriggerUserPanned();
        if (this.layoutDoc._Transform || this.Document.treeView_OutlineMode === TreeViewType.outline) return;
        e.stopPropagation();
        const docHeight = NumCast(this.Document[Doc.LayoutFieldKey(this.Document) + '_nativeHeight'], this.nativeHeight);
        const scrollable = this.isAnnotationOverlay && NumCast(this.layoutDoc[this.scaleFieldKey], 1) === 1 && docHeight > this._props.PanelHeight() / this.nativeDimScaling + 1e-4;
        switch (
            !e.ctrlKey && !e.shiftKey && !e.metaKey && !e.altKey ?//
                Doc.UserDoc().freeformScrollMode : // no modifiers, do assigned mode
                    e.ctrlKey && !SnappingManager.CtrlKey? // otherwise, if ctrl key (pinch gesture) try to zoom else pan
                        freeformScrollMode.Zoom : freeformScrollMode.Pan // prettier-ignore
        ) {
            case freeformScrollMode.Pan:
                if (((!e.metaKey && !e.altKey) || Doc.UserDoc().freeformScrollMode === freeformScrollMode.Zoom) && this._props.isContentActive()) {
                    const deltaX = e.shiftKey ? e.deltaX : e.ctrlKey ? 0 : e.deltaX;
                    const deltaY = e.shiftKey ? 0 : e.ctrlKey ? e.deltaY : e.deltaY;
                    this.scrollPan({ deltaX: -deltaX * this.screenToFreeformContentsXf.Scale, deltaY: e.shiftKey ? 0 : -deltaY * this.screenToFreeformContentsXf.Scale });
                    break;
                }
            // eslint-disable-next-line no-fallthrough
            case freeformScrollMode.Zoom:
            default:
                if ((e.ctrlKey || !scrollable) && this._props.isContentActive()) {
                    this.zoom(e.clientX, e.clientY, Math.max(-1, Math.min(1, e.deltaY))); // if (!this._props.isAnnotationOverlay) // bcz: do we want to zoom in on images/videos/etc?
                    // e.preventDefault();
                }
                break;
        }
    };

    @action
    setPan(panXIn: number, panYIn: number, panTime: number = 0, allowScroll = false) {
        let [panX, panY] = [panXIn, panYIn];

        if (!this.isAnnotationOverlay && this.childDocs.length) {
            // this section wraps the pan position, horizontally and/or vertically whenever the content is panned out of the viewing bounds
            const { bounds: { x: xrangeMin, y: yrangeMin, r: xrangeMax, b: yrangeMax } } = this.contentBounds(); // prettier-ignore
            const scaling = this.zoomScaling() * (this._props.NativeDimScaling?.() || 1);
            const [widScaling, hgtScaling] = [this._props.PanelWidth() / scaling, this._props.PanelHeight() / scaling];
            panX = clamp(panX, xrangeMin - widScaling / 2, xrangeMax + widScaling / 2);
            panY = clamp(panY, yrangeMin - hgtScaling / 2, yrangeMax + hgtScaling / 2);
        }
        if (!this.layoutDoc._lockedTransform || DocumentView.LightboxDoc()) {
            this.setPanZoomTransition(panTime);
            const minScale = NumCast(this.dataDoc._freeform_scale_min, 1);
            const scale = 1 - minScale / this.zoomScaling();
            const minPanX = NumCast(this.dataDoc._freeform_panX_min, 0);
            const minPanY = NumCast(this.dataDoc._freeform_panY_min, 0);
            const maxPanX = NumCast(this.dataDoc._freeform_panX_max, this.nativeWidth);
            const newPanX = clamp(panX, minPanX, minPanX + scale * maxPanX);
            const fitYscroll = (((this.nativeHeight / this.nativeWidth) * this._props.PanelWidth() - this._props.PanelHeight()) * this.ScreenToLocalBoxXf().Scale) / minScale;
            const nativeHeight = (this._props.PanelHeight() / this._props.PanelWidth() / (this.nativeHeight / this.nativeWidth)) * this.nativeHeight;
            const maxScrollTop = this.nativeHeight / this.ScreenToLocalBoxXf().Scale - this._props.PanelHeight();
            const maxPanY =
                minPanY + // minPanY + scrolling introduced by view scaling + scrolling introduced by layout_fitWidth
                scale * NumCast(this.dataDoc._freeform_panY_max, nativeHeight) +
                (!this._props.getScrollHeight?.() ? fitYscroll : 0); // when not zoomed, scrolling is handled via a scrollbar, not panning
            const newPanY = clamp(panY, minPanY, maxPanY);
            // this mess fixes a problem when zooming to the default on an image that is fit width and can scroll.
            // Without this, the scroll always goes to the top, instead of matching the pan position.
            if (fitYscroll > 2 && allowScroll && NumCast(this.layoutDoc._freeform_scale, minScale) === minScale) {
                setTimeout(() => {
                    const relTop = (clamp(panY, minPanY, fitYscroll) - minPanY) / fitYscroll;
                    this.layoutDoc.layout_scrollTop = relTop * maxScrollTop;
                }, 10);
            }
            !this.Document._verticalScroll && (this.Document[this.panXFieldKey] = this.isAnnotationOverlay ? newPanX : panX);
            !this.Document._horizontalScroll && (this.Document[this.panYFieldKey] = this.isAnnotationOverlay ? newPanY : panY);
        }
    }

    @action
    nudge = (x: number, y: number, nudgeTime: number = 500) => {
        const collectionDoc = this.Document;
        if (collectionDoc?._type_collection !== CollectionViewType.Freeform) {
            SnappingManager.TriggerUserPanned();
            this.setPan(
                NumCast(this.layoutDoc[this.panXFieldKey]) + ((this._props.PanelWidth() / 2) * x) / this.zoomScaling(), // nudge x,y as a function of panel dimension and scale
                NumCast(this.layoutDoc[this.panYFieldKey]) + ((this._props.PanelHeight() / 2) * -y) / this.zoomScaling(),
                nudgeTime
            );
            return true;
        }
        return false;
    };

    @action
    bringToFront = (doc: Doc, sendToBack?: boolean) => {
        if (doc.stroke_isInkMask) {
            doc.zIndex = 5000;
        } else {
            // prettier-ignore
            const docs = this.childLayoutPairs.map(pair => pair.layout)
                  .sort((doc1, doc2) => NumCast(doc1.zIndex) - NumCast(doc2.zIndex));
            if (sendToBack) {
                const zfirst = docs.length ? NumCast(docs[0].zIndex) : 0;
                doc.zIndex = zfirst - 1;
            } else {
                let zlast = docs.length ? Math.max(docs.length, NumCast(docs.lastElement().zIndex)) : 1;
                if (docs.lastElement() !== doc) {
                    if (zlast - docs.length > 100) {
                        for (let i = 0; i < docs.length; i++) doc.zIndex = i + 1;
                        zlast = docs.length + 1;
                    }
                    doc.zIndex = zlast + 1;
                }
            }
        }
    };

    @action
    setPanZoomTransition = (transitionTime: number) => {
        this._panZoomTransition = transitionTime;
        this._panZoomTransitionTimer && clearTimeout(this._panZoomTransitionTimer);
        this._panZoomTransitionTimer = setTimeout(
            action(() => {
                this._panZoomTransition = 0;
            }),
            transitionTime
        );
    };

    @action
    zoomSmoothlyAboutPt(docpt: number[], scale: number, transitionTime = 500) {
        if (this.Document.isGroup) return;
        this.setPanZoomTransition(transitionTime);
        const screenXY = this.screenToFreeformContentsXf.inverse().transformPoint(docpt[0], docpt[1]);
        this.layoutDoc[this.scaleFieldKey] = scale;
        const newScreenXY = this.screenToFreeformContentsXf.inverse().transformPoint(docpt[0], docpt[1]);
        const scrDelta = { x: screenXY[0] - newScreenXY[0], y: screenXY[1] - newScreenXY[1] };
        const newpan = this.screenToFreeformContentsXf.transformDirection(scrDelta.x, scrDelta.y);
        this.layoutDoc[this.panXFieldKey] = NumCast(this.layoutDoc[this.panXFieldKey]) - newpan[0];
        this.layoutDoc[this.panYFieldKey] = NumCast(this.layoutDoc[this.panYFieldKey]) - newpan[1];
    }

    calculatePanIntoView = (doc: Doc, xf: Transform, scale?: number) => {
        const layoutdoc = Doc.Layout(doc);
        const pt = xf.transformPoint(NumCast(doc.x), NumCast(doc.y));
        const pt2 = xf.transformPoint(NumCast(doc.x) + NumCast(layoutdoc._width), NumCast(doc.y) + NumCast(layoutdoc._height));
        const bounds = { left: pt[0], right: pt2[0], top: pt[1], bot: pt2[1], width: pt2[0] - pt[0], height: pt2[1] - pt[1] };

        if (scale !== undefined) {
            const maxZoom = 5; // sets the limit for how far we will zoom. this is useful for preventing small text boxes from filling the screen. So probably needs to be more sophisticated to consider more about the target and context
            const newScale =
                scale === 0 ? NumCast(this.layoutDoc[this.scaleFieldKey]) : Math.min(maxZoom, (1 / this.nativeDimScaling) * scale * Math.min(this._props.PanelWidth() / Math.abs(bounds.width), this._props.PanelHeight() / Math.abs(bounds.height)));
            return {
                panX: this._props.isAnnotationOverlay ? bounds.left - (Doc.NativeWidth(this.layoutDoc) / newScale - bounds.width) / 2 : (bounds.left + bounds.right) / 2,
                panY: this._props.isAnnotationOverlay ? bounds.top - (Doc.NativeHeight(this.layoutDoc) / newScale - bounds.height) / 2 : (bounds.top + bounds.bot) / 2,
                scale: newScale,
            };
        }

        const panelWidth = this._props.isAnnotationOverlay ? this.nativeWidth : this._props.PanelWidth();
        const panelHeight = this._props.isAnnotationOverlay ? this.nativeHeight : this._props.PanelHeight();
        const pw = panelWidth / NumCast(this.layoutDoc._freeform_scale, 1);
        const ph = panelHeight / NumCast(this.layoutDoc._freeform_scale, 1);
        const cx = NumCast(this.layoutDoc[this.panXFieldKey]) + (this._props.isAnnotationOverlay ? pw / 2 : 0);
        const cy = NumCast(this.layoutDoc[this.panYFieldKey]) + (this._props.isAnnotationOverlay ? ph / 2 : 0);
        const screen = { left: cx - pw / 2, right: cx + pw / 2, top: cy - ph / 2, bot: cy + ph / 2 };
        const maxYShift = Math.max(0, screen.bot - screen.top - (bounds.bot - bounds.top));
        const phborder = bounds.top < screen.top || bounds.bot > screen.bot ? Math.min(ph / 10, maxYShift / 2) : 0;
        if (screen.right - screen.left < bounds.right - bounds.left || screen.bot - screen.top < bounds.bot - bounds.top) {
            return {
                panX: (bounds.left + bounds.right) / 2,
                panY: (bounds.top + bounds.bot) / 2,
                scale: Math.min(this._props.PanelHeight() / (bounds.bot - bounds.top), this._props.PanelWidth() / (bounds.right - bounds.left)) / 1.1,
            };
        }
        return {
            panX: (this._props.isAnnotationOverlay ? NumCast(this.layoutDoc[this.panXFieldKey]) : cx) + Math.min(0, bounds.left - pw / 10 - screen.left) + Math.max(0, bounds.right + pw / 10 - screen.right),
            panY: (this._props.isAnnotationOverlay ? NumCast(this.layoutDoc[this.panYFieldKey]) : cy) + Math.min(0, bounds.top - phborder - screen.top) + Math.max(0, bounds.bot + phborder - screen.bot),
        };
    };

    isContentActive = () => this._props.isContentActive();

    /**
     * Create a new text note of the same style as the one being typed into.
     * If the text doc is be part of a larger templated doc, the new Doc will be a copy of the templated Doc
     *
     * @param fieldProps render props for the text doc being typed into
     * @param below whether to place the new text Doc below or to the right of the one being typed into.
     * @returns whether the new text doc was created and added successfully
     */
    createTextDocCopy = undoable((fieldProps: FieldViewProps, below: boolean) => {
        const textDoc = DocCast(fieldProps.Document.rootDocument, fieldProps.Document);
        const newDoc = Doc.MakeCopy(textDoc, true);
        newDoc[DocData][Doc.LayoutFieldKey(newDoc, fieldProps.LayoutTemplateString)] = undefined; // the copy should not copy the text contents of it source, just the render style
        newDoc.x = NumCast(textDoc.x) + (below ? 0 : NumCast(textDoc._width) + 10);
        newDoc.y = NumCast(textDoc.y) + (below ? NumCast(textDoc._height) + 10 : 0);
        Doc.SetSelectOnLoad(newDoc);
        FormattedTextBox.DontSelectInitialText = true;
        return this.addDocument?.(newDoc);
    }, 'copied text note');

    onKeyDown = (e: React.KeyboardEvent, fieldProps: FieldViewProps) => {
        if ((e.metaKey || e.ctrlKey || e.altKey || fieldProps.Document._createDocOnCR) && ['Tab', 'Enter'].includes(e.key)) {
            e.stopPropagation?.();
            return this.createTextDocCopy(fieldProps, !e.altKey && e.key !== 'Tab');
        }
        return undefined;
    };

    removeDocument = (docs: Doc | Doc[], annotationKey?: string | undefined) => {
        const ret = !!this._props.removeDocument?.(docs, annotationKey);
        // if this is a group and we have fewer than 2 Docs, then just promote what's left to our parent and get rid of the group.
        if (ret && DocListCast(this.dataDoc[annotationKey ?? this.fieldKey]).length < 2 && this.Document.isGroup) {
            this.promoteCollection();
        }
        return ret;
    };
    childPointerEventsFunc = () => this._childPointerEvents;
    childContentsActive = () => ((this._props.childContentsActive ?? this.isContentActive() === false) ? returnFalse : emptyFunction)();
    getChildDocView(entry: PoolData) {
        const childLayout = entry.pair.layout;
        const childData = entry.pair.data;
        return (
            <CollectionFreeFormDocumentView
                {...(OmitKeys(entry, ['replica', 'pair']).omit as { x: number; y: number; z: number; width: number; height: number })}
                key={childLayout[Id] + (entry.replica || '')}
                Document={childLayout}
                reactParent={this}
                containerViewPath={this.DocumentView?.().docViewPath}
                styleProvider={this._clusters.styleProvider}
                TemplateDataDocument={childData}
                dragStarting={this.dragStarting}
                dragEnding={this.dragEnding}
                isAnyChildContentActive={this.isAnyChildContentActive}
                isGroupActive={this._props.isGroupActive}
                renderDepth={this._props.renderDepth + 1}
                hideDecorations={BoolCast(childLayout._layout_isSvg && childLayout.type === DocumentType.LINK)}
                suppressSetHeight={!!this.layoutEngine}
                RenderCutoffProvider={this.renderCutoffProvider}
                LayoutTemplate={childLayout.z ? undefined : this._props.childLayoutTemplate}
                LayoutTemplateString={childLayout.z ? undefined : this._props.childLayoutString}
                rootSelected={childData ? this.rootSelected : returnFalse}
                waitForDoubleClickToClick={this._props.waitForDoubleClickToClick}
                onClickScript={this.onChildClickHandler}
                onKey={this.onKeyDown}
                onDoubleClickScript={this.onChildDoubleClickHandler}
                bringToFront={this.bringToFront}
                ScreenToLocalTransform={childLayout.z ? this.ScreenToLocalBoxXf : this.ScreenToContentsXf}
                PanelWidth={childLayout[Width]}
                PanelHeight={childLayout[Height]}
                childFilters={this.childDocFilters}
                childFiltersByRanges={this.childDocRangeFilters}
                searchFilterDocs={this.searchFilterDocs}
                isDocumentActive={childLayout.pointerEvents === 'none' ? returnFalse : this._props.childDocumentsActive?.() ? this._props.isDocumentActive : this.isContentActive}
                isContentActive={this.childContentsActive}
                focus={this.Document.isGroup ? this.groupFocus : this.isAnnotationOverlay ? this._props.focus : this.focus}
                addDocTab={this.addDocTab}
                addDocument={this._props.addDocument}
                removeDocument={this.removeDocument}
                moveDocument={this._props.moveDocument}
                pinToPres={this._props.pinToPres}
                whenChildContentsActiveChanged={this._props.whenChildContentsActiveChanged}
                dragAction={(this.Document.childDragAction ?? this._props.childDragAction) as dropActionType}
                showTitle={this._props.childlayout_showTitle}
                dontRegisterView={this._props.dontRegisterView}
                pointerEvents={this.childPointerEventsFunc}
            />
        );
    }
    addDocTab = action((docsIn: Doc | Doc[], location: OpenWhere) => {
        const docs = toList(docsIn);
        if (this._props.isAnnotationOverlay) return this._props.addDocTab(docs, location);
        const where = location.split(':')[0];
        switch (where) {
            case OpenWhere.inParent:
                return this._props.addDocument?.(docs) || false;
            case OpenWhere.inParentFromScreen: {
                const docContext = DocCast(docs[0]?.embedContainer);
                return (
                    (this.addDocument?.(
                        toList(docs).map(doc => {
                            [doc.x, doc.y] = this.screenToFreeformContentsXf.transformPoint(NumCast(doc.x), NumCast(doc.y));
                            return doc;
                        })
                    ) &&
                        (!docContext || this._props.removeDocument?.(docContext))) ||
                    false
                );
            }
            case undefined:
            case OpenWhere.lightbox:
                if (this.layoutDoc._isLightbox) {
                    this._lightboxDoc = docs[0];
                    return true;
                }
                return this.addLinkedDocTab(docsIn, location);
            default:
        }
        return this._props.addDocTab(docsIn, location);
    });
    getCalculatedPositions(pair: { layout: Doc; data?: Doc }): PoolData {
        const random = (min: number, max: number, x: number, y: number) => /* min should not be equal to max */ min + (((Math.abs(x * y) * 9301 + 49297) % 233280) / 233280) * (max - min);
        const childDoc = pair.layout;
        const childDocLayout = Doc.Layout(childDoc);
        const layoutFrameNumber = Cast(this.Document._currentFrame, 'number'); // frame number that container is at which determines layout frame values
        const contentFrameNumber = Cast(childDocLayout._currentFrame, 'number', layoutFrameNumber ?? null); // frame number that content is at which determines what content is displayed
        const { z, zIndex } = childDoc;
        const { backgroundColor, color } = contentFrameNumber === undefined ? { backgroundColor: undefined, color: undefined } : CollectionFreeFormDocumentView.getStringValues(childDoc, contentFrameNumber);
        const { x, y, autoDim, _width, _height, opacity, _rotation } =
            layoutFrameNumber === undefined // -1 for width/height means width/height should be PanelWidth/PanelHeight (prevents collectionfreeformdocumentview width/height from getting out of synch with panelWIdth/Height which causes detailView to re-render and lose focus because HTMLtag scaling gets set to a bad intermediate value)
                ? { autoDim: 1, _width: Cast(childDoc._width, 'number'), _height: Cast(childDoc._height, 'number'), _rotation: Cast(childDocLayout._rotation, 'number'), x: childDoc.x, y: childDoc.y, opacity: this._props.childOpacity?.() }
                : CollectionFreeFormDocumentView.getValues(childDoc, layoutFrameNumber);
        // prettier-ignore
        const rotation = Cast(_rotation,'number',
            !this.layoutDoc._rotation_jitter ? null
                : NumCast(this.layoutDoc._rotation_jitter) * random(-1, 1, NumCast(x), NumCast(y)) );
        return {
            x: isNaN(NumCast(x)) ? 0 : NumCast(x),
            y: isNaN(NumCast(y)) ? 0 : NumCast(y),
            z: Cast(z, 'number'),
            autoDim,
            rotation,
            color: Cast(color, 'string', null),
            backgroundColor: Cast(backgroundColor, 'string', null),
            opacity: !_width ? 0 : this._keyframeEditing ? 1 : Cast(opacity, 'number', null),
            zIndex: Cast(zIndex, 'number'),
            width: _width,
            height: _height,
            transition: StrCast(childDocLayout.dataTransition),
            pointerEvents: Cast(childDoc.pointerEvents, 'string', null),
            pair,
            replica: '',
        };
    }

    onViewDefDivClick = (e: React.MouseEvent, payload: unknown) => {
        (this._props.viewDefDivClick || ScriptCast(this.Document.onViewDefDivClick))?.script.run({ this: this.Document, payload });
        e.stopPropagation();
    };

    viewDefsToJSX = (views: ViewDefBounds[]) => (!Array.isArray(views) ? [] : views.filter(ele => this.viewDefToJSX(ele)).map(ele => this.viewDefToJSX(ele)!));

    viewDefToJSX(viewDef: ViewDefBounds): Opt<ViewDefResult> {
        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: (
                          <div className="collectionFreeform-customText" key={(text || '') + x + y + z + color} style={{ width, height, color, fontSize, transform }}>
                              {text}
                          </div>
                      ),
                      bounds: viewDef,
                  };
        }
        if (viewDef.type === 'div') {
            return [x, y].some(val => val === undefined)
                ? undefined
                : {
                      ele: (
                          <div
                              className="collectionFreeform-customDiv"
                              title={StrListCast(viewDef.payload as string).join(' ')}
                              key={'div' + x + y + z + viewDef.payload}
                              onClick={e => this.onViewDefDivClick(e, viewDef)}
                              style={{ width, height, backgroundColor: color, transform }}
                          />
                      ),
                      bounds: viewDef,
                  };
        }
        return undefined;
    }

    /**
     * Determines whether the passed doc should be rendered
     * since rendering a large collection of documents can be slow, at startup, docs are rendered in batches.
     * each doc's render() method will call the cutoff provider which will let the doc know if it should render itself yet, or wait
     */
    renderCutoffProvider = computedFn((doc: Doc) => (this.Document.isTemplateDoc || this.Document.isTemplateForField ? false : !this._renderCutoffData.get(doc[Id] + '')));

    doEngineLayout(
        poolData: Map<string, PoolData>,
        engine: (poolData: Map<string, PoolData>, pivotDoc: Doc, childPairs: { layout: Doc; data?: Doc }[], panelDim: number[], viewDefsToJSX: (views: ViewDefBounds[]) => ViewDefResult[], engineProps: unknown) => ViewDefResult[]
    ) {
        return engine(poolData, this.Document, this.childLayoutPairs, [this._props.PanelWidth(), this._props.PanelHeight()], this.viewDefsToJSX, this._props.engineProps);
    }

    doFreeformLayout(poolData: Map<string, PoolData>) {
        this._clusters.initLayout();
        this.childLayoutPairs.filter(pair => this.isCurrent(pair.layout)).map(pair => poolData.set(pair.layout[Id], this.getCalculatedPositions(pair)));
        return [] as ViewDefResult[];
    }

    @computed get doInternalLayoutComputation() {
        TraceMobx();
        const newPool = new Map<string, PoolData>();
        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) };
            default:                          return { newPool, computedElementData: this.doFreeformLayout(newPool) };
        } // prettier-ignore
    }

    doLayoutComputation = (newPool: Map<string, PoolData>, computedElementData: ViewDefResult[]) => {
        const elements = computedElementData.slice();
        Array.from(newPool.entries())
            .filter(entry => this.isCurrent(entry[1].pair.layout))
            .forEach(entry =>
                elements.push({
                    ele: this.getChildDocView(entry[1]),
                    bounds: entry[1].opacity === 0 ? { payload: undefined, type: '', ...entry[1], width: 0, height: 0 } : { payload: undefined, type: '', ...entry[1] },
                    inkMask: BoolCast(entry[1].pair.layout.stroke_isInkMask) ? NumCast(entry[1].pair.layout.opacity, 1) : -1,
                })
            );

        return elements;
    };

    getAnchor = (addAsAnnotation: boolean, pinProps?: PinProps) => {
        // create an anchor that saves information about the current state of the freeform view (pan, zoom, view type)
        const anchor = Docs.Create.ConfigDocument({
            title: 'ViewSpec - ' + StrCast(this.layoutDoc._type_collection),
            layout_unrendered: true,
            presentation_transition: 500,
            annotationOn: this.Document,
        });
        PinDocView(
            anchor,
            { pinDocLayout: pinProps?.pinDocLayout, pinData: { ...(pinProps?.pinData ? { ...pinProps.pinData, poslayoutview: pinProps.pinData.dataview } : {}), pannable: !this.Document.isGroup, collectionType: true, filters: true } },
            this.Document
        );

        if (addAsAnnotation) {
            if (Cast(this.dataDoc[this._props.fieldKey + '_annotations'], listSpec(Doc), null) !== undefined) {
                Cast(this.dataDoc[this._props.fieldKey + '_annotations'], listSpec(Doc), []).push(anchor);
            } else {
                this.dataDoc[this._props.fieldKey + '_annotations'] = new List<Doc>([anchor]);
            }
        }
        return anchor;
    };

    childDocsFunc = () => this.childDocs;
    closeInfo = action(() => { Doc.IsInfoUIDisabled = true }); // prettier-ignore
    static _infoUI: ((doc: Doc, layout: Doc, childDocs: () => Doc[], close: () => void) => JSX.Element) | null = null;
    static SetInfoUICreator(func: (doc: Doc, layout: Doc, childDocs: () => Doc[], close: () => void) => JSX.Element) {
        CollectionFreeFormView._infoUI = func;
    }
    infoUI = () =>
        Doc.IsInfoUIDisabled || this.Document.annotationOn || this._props.renderDepth
            ? null //
            : CollectionFreeFormView._infoUI?.(this.Document, this.layoutDoc, this.childDocsFunc, this.closeInfo) || null;

    componentDidMount() {
        this._props.setContentViewBox?.(this);
        super.componentDidMount?.();
        setTimeout(
            action(() => {
                this._firstRender = false;
                this._disposers.groupBounds = reaction(
                    () => {
                        if (this.Document.isGroup && this.childDocs.length === this.childDocList?.length) {
                            const clist = this.childDocs.map(cd => ({ x: NumCast(cd.x), y: NumCast(cd.y), width: NumCast(cd._width), height: NumCast(cd._height) }));
                            return aggregateBounds(clist, NumCast(this.layoutDoc._xPadding), NumCast(this.layoutDoc._yPadding));
                        }
                        return undefined;
                    },
                    cbounds => {
                        if (cbounds) {
                            const c = [NumCast(this.layoutDoc.x) + NumCast(this.layoutDoc._width) / 2, NumCast(this.layoutDoc.y) + NumCast(this.layoutDoc._height) / 2];
                            const p = [NumCast(this.layoutDoc[this.panXFieldKey]), NumCast(this.layoutDoc[this.panYFieldKey])];
                            const pbounds = {
                                x: cbounds.x - p[0] + c[0],
                                y: cbounds.y - p[1] + c[1],
                                r: cbounds.r - p[0] + c[0],
                                b: cbounds.b - p[1] + c[1],
                            };
                            if (Number.isFinite(pbounds.r - pbounds.x) && Number.isFinite(pbounds.b - pbounds.y)) {
                                this.layoutDoc._width = pbounds.r - pbounds.x;
                                this.layoutDoc._height = pbounds.b - pbounds.y;
                                this.layoutDoc[this.panXFieldKey] = (cbounds.r + cbounds.x) / 2;
                                this.layoutDoc[this.panYFieldKey] = (cbounds.b + cbounds.y) / 2;
                                this.layoutDoc.x = pbounds.x;
                                this.layoutDoc.y = pbounds.y;
                            }
                        }
                    },
                    { fireImmediately: true }
                );

                this._disposers.pointerevents = reaction(
                    () => this.childPointerEvents,
                    pointerevents => {
                        this._childPointerEvents = pointerevents as Property.PointerEvents | undefined;
                    },
                    { fireImmediately: true }
                );

                this._disposers.active = reaction(
                    () => this.isContentActive(), // if autoreset is on, then whenever the view is selected, it will be restored to it default pan/zoom positions
                    active => !SnappingManager.IsDragging && this.dataDoc[this.autoResetFieldKey] && active && this.resetView()
                );
            })
        );

        this._disposers.paintFunc = reaction(
            () => ({ code: this.paintFunc, first: this._firstRender, width: this.Document._width, height: this.Document._height }),
            ({ code, first }) => {
                if (!code.includes('dashDiv')) {
                    const script = CompileScript(code, { params: { docView: 'any' }, typecheck: false, editable: true });
                    if (script.compiled) script.run({ this: this.DocumentView?.() });
                } else code && !first && eval?.(code);
            },
            { fireImmediately: true }
        );

        this._disposers.layoutElements = reaction(
            // layoutElements can't be a computed value because doLayoutComputation() is an action that has side effect of updating clusters
            () => this.doInternalLayoutComputation,
            computation => {
                this._layoutElements = this.doLayoutComputation(computation.newPool, computation.computedElementData);
            },
            { fireImmediately: true }
        );
    }

    componentWillUnmount() {
        this.dataDoc[this.autoResetFieldKey] && this.resetView();
        Object.values(this._disposers).forEach(disposer => disposer?.());
    }

    updateIcon = (usePanelDimensions?: boolean) => {
        const contentDiv = this.DocumentView?.().ContentDiv;
        return !contentDiv
            ? new Promise<void>(res => res())
            : UpdateIcon(
                  this.layoutDoc[Id] + '_icon_' + new Date().getTime(),
                  contentDiv,
                  usePanelDimensions ? this._props.PanelWidth() : NumCast(this.layoutDoc._width),
                  usePanelDimensions ? this._props.PanelHeight() : NumCast(this.layoutDoc._height),
                  this._props.PanelWidth(),
                  this._props.PanelHeight(),
                  0,
                  1,
                  false,
                  '',
                  (iconFile, nativeWidth, nativeHeight) => {
                      this.dataDoc.icon = new ImageField(iconFile);
                      this.dataDoc.icon_nativeWidth = nativeWidth;
                      this.dataDoc.icon_nativeHeight = nativeHeight;
                  }
              );
    };

    @action
    onCursorMove = (e: React.PointerEvent) => {
        const locPt = this.ScreenToLocalBoxXf().transformPoint(e.clientX, e.clientY);
        this._eraserX = locPt[0];
        this._eraserY = locPt[1];
    };

    @action
    onMouseLeave = () => {
        this._showEraserCircle = false;
    };

    @action
    onMouseEnter = () => {
        this._showEraserCircle = true;
    };

    promoteCollection = undoable(() => {
        const childDocs = this.childDocs.slice();
        childDocs.forEach(docIn => {
            const doc = docIn;
            const scr = this.screenToFreeformContentsXf.inverse().transformPoint(NumCast(doc.x), NumCast(doc.y));
            doc.x = scr?.[0];
            doc.y = scr?.[1];
        });
        this._props.addDocTab(childDocs, OpenWhere.inParentFromScreen);
    }, 'promote collection');

    layoutDocsInGrid = undoable(() => {
        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((docIn, i) => {
            const doc = docIn;
            doc.x = NumCast(this.Document[this.panXFieldKey]) + (i % dim) * width - (width * dim) / 2;
            doc.y = NumCast(this.Document[this.panYFieldKey]) + Math.floor(i / dim) * height - (height * dim) / 2;
        });
    }, 'layout docs in grid');

    toggleNativeDimensions = undoable(() => Doc.toggleNativeDimensions(this.layoutDoc, 1, this.nativeWidth, this.nativeHeight), 'toggle native dimensions');

    ///
    /// resetView restores a freeform collection to unit scale and centered at (0,0)  UNLESS
    ///  the view is a group, in which case this does nothing (since Groups calculate their own scale and center)
    ///
    resetView = undoable(() => {
        this.layoutDoc[this.panXFieldKey] = NumCast(this.dataDoc[this.panXFieldKey + '_reset']);
        this.layoutDoc[this.panYFieldKey] = NumCast(this.dataDoc[this.panYFieldKey + '_reset']);
        this.layoutDoc[this.scaleFieldKey] = NumCast(this.dataDoc[this.scaleFieldKey + '_reset'], 1);
    }, 'reset view');
    ///
    /// resetView restores a freeform collection to unit scale and centered at (0,0)  UNLESS
    ///  the view is a group, in which case this does nothing (since Groups calculate their own scale and center)
    ///
    toggleResetView = undoable(() => {
        this.dataDoc[this.autoResetFieldKey] = !this.dataDoc[this.autoResetFieldKey];
        if (this.dataDoc[this.autoResetFieldKey]) {
            this.dataDoc[this.panXFieldKey + '_reset'] = this.layoutDoc[this.panXFieldKey];
            this.dataDoc[this.panYFieldKey + '_reset'] = this.layoutDoc[this.panYFieldKey];
            this.dataDoc[this.scaleFieldKey + '_reset'] = this.layoutDoc[this.scaleFieldKey];
        }
    }, 'toggle reset view');

    onContextMenu = () => {
        if (this._props.isAnnotationOverlay || !ContextMenu.Instance) return;

        const appearance = ContextMenu.Instance.findByDescription('Appearance...');
        const appearanceItems = appearance?.subitems ?? [];
        !this.Document.isGroup && appearanceItems.push({ description: 'Reset View', event: this.resetView, icon: 'compress-arrows-alt' });
        !this.Document.isGroup && appearanceItems.push({ description: 'Toggle Auto Reset View', event: this.toggleResetView, icon: 'compress-arrows-alt' });
        if (this._props.setContentViewBox === emptyFunction) {
            !appearance && ContextMenu.Instance.addItem({ description: 'Appearance...', subitems: appearanceItems, icon: 'eye' });
            return;
        }
        !Doc.noviceMode &&
            Doc.UserDoc().defaultTextLayout &&
            appearanceItems.push({
                description: 'Reset default note style',
                event: () => {
                    Doc.UserDoc().defaultTextLayout = undefined;
                },
                icon: 'eye',
            });
        appearanceItems.push({ description: `Pin View`, event: () => this._props.pinToPres(this.Document, { pinViewport: MarqueeView.CurViewBounds(this.dataDoc, this._props.PanelWidth(), this._props.PanelHeight()) }), icon: 'map-pin' });
        !Doc.noviceMode && appearanceItems.push({ description: `update icon`, event: () => this.updateIcon(), icon: 'compress-arrows-alt' });
        this._props.renderDepth && appearanceItems.push({ description: 'Ungroup collection', event: this.promoteCollection, icon: 'table' });

        this.Document.isGroup && this.Document.transcription && appearanceItems.push({ description: 'Ink to text', event: this.transcribeStrokes, icon: 'font' });

        !Doc.noviceMode ? appearanceItems.push({ description: 'Arrange contents in grid', event: this.layoutDocsInGrid, icon: 'table' }) : null;
        !Doc.noviceMode ? appearanceItems.push({ description: (this.Document._freeform_useClusters ? 'Hide' : 'Show') + ' Clusters', event: () => this._clusters.updateClusters(!this.Document._freeform_useClusters), icon: 'braille' }) : null;
        !appearance && ContextMenu.Instance.addItem({ description: 'Appearance...', subitems: appearanceItems, icon: 'eye' });

        const options = ContextMenu.Instance.findByDescription('Options...');
        const optionItems = options?.subitems ?? [];
        !this._props.isAnnotationOverlay &&
            !Doc.noviceMode &&
            optionItems.push({
                description: (this._showAnimTimeline ? 'Close' : 'Open') + ' Animation Timeline',
                event: action(() => {
                    this._showAnimTimeline = !this._showAnimTimeline;
                }),
                icon: 'eye',
            });
        optionItems.push({
            description: 'Show Drawing Editor',
            event: action(() => {
                SmartDrawHandler.Instance.CreateDrawingDoc = this.createDrawingDoc;
                SmartDrawHandler.Instance.AddDrawing = this.addDrawing;
                SmartDrawHandler.Instance.RemoveDrawing = this.removeDrawing;
                !SmartDrawHandler.Instance.ShowRegenerate ? SmartDrawHandler.Instance.displayRegenerate(this._downX, this._downY - 10) : SmartDrawHandler.Instance.hideRegenerate();
            }),
            icon: 'pen-to-square',
        });
        optionItems.push({
            description: this.Document.savedAsAnno ? 'Saved as Annotation!' : 'Save to Annotation Palette',
            event: action(undoable(async () => await AnnotationPalette.addToPalette(this.Document), 'save to palette')),
            icon: this.Document.savedAsAnno ? 'clipboard-check' : 'file-arrow-down',
        });
        this._props.renderDepth &&
            optionItems.push({
                description: 'Use Background Color as Default',
                event: () => {
                    Cast(Doc.UserDoc().emptyCollection, Doc, null).backgroundColor = StrCast(this.layoutDoc.backgroundColor);
                },
                icon: 'palette',
            });
        this._props.renderDepth && optionItems.push({ description: 'Fit Content Once', event: this.fitContentOnce, icon: 'object-group' });
        if (!Doc.noviceMode) {
            optionItems.push({ description: (!Doc.NativeWidth(this.layoutDoc) || !Doc.NativeHeight(this.layoutDoc) ? 'Freeze' : 'Unfreeze') + ' Aspect', event: this.toggleNativeDimensions, icon: 'snowflake' });
        }
        !options && ContextMenu.Instance.addItem({ description: 'Options...', subitems: optionItems, icon: 'eye' });
        const mores = ContextMenu.Instance.findByDescription('More...');
        const moreItems = mores?.subitems ?? [];
        moreItems.push({
            description: 'recognize all ink',
            event: () => {
                this.unprocessedDocs.push(...this.childDocs.filter(doc => doc.type === DocumentType.INK));
                CollectionFreeFormView.collectionsWithUnprocessedInk.add(this);
            },
            icon: 'pen',
        });
        !mores && ContextMenu.Instance.addItem({ description: 'More...', subitems: moreItems, icon: 'eye' });
    };

    transcribeStrokes = undoable(() => {
        if (this.Document.isGroup && this.Document.transcription) {
            const text = StrCast(this.Document.transcription);
            const lines = text.split('\n');
            const height = 30 + 15 * lines.length;

            this.addDocument(Docs.Create.TextDocument(text, { title: lines[0], x: NumCast(this.layoutDoc.x) + NumCast(this.layoutDoc._width) + 20, y: NumCast(this.layoutDoc.y), _width: 200, _height: height }));
        }
    }, 'transcribe strokes');

    @action
    dragEnding = () => {
        this.GroupChildDrag = false;
        SnappingManager.clearSnapLines();
    };
    @action
    dragStarting = (snapToDraggedDoc: boolean = false, showGroupDragTarget: boolean = true, visited = new Set<Doc>()) => {
        if (visited.has(this.Document)) return;
        visited.add(this.Document);
        showGroupDragTarget && (this.GroupChildDrag = BoolCast(this.Document.isGroup));
        const activeDocs = this.getActiveDocuments();
        const size = this.screenToFreeformContentsXf.transformDirection(this._props.PanelWidth(), this._props.PanelHeight());
        const selRect = { left: this.panX() - size[0] / 2, top: this.panY() - size[1] / 2, width: size[0], height: size[1] };
        const docDims = (doc: Doc) => ({ left: NumCast(doc.x), top: NumCast(doc.y), width: NumCast(doc._width), height: NumCast(doc._height) });
        const isDocInView = (doc: Doc, rect: { left: number; top: number; width: number; height: number }) => intersectRect(docDims(doc), rect);

        const snappableDocs = activeDocs.filter(doc => doc.z === undefined && isDocInView(doc, selRect)); // first see if there are any foreground docs to snap to
        activeDocs.filter(doc => doc.isGroup && SnappingManager.IsResizing !== doc[Id] && !DragManager.docsBeingDragged.includes(doc)).forEach(doc => DocumentView.getDocumentView(doc)?.ComponentView?.dragStarting?.(snapToDraggedDoc, false, visited));

        const horizLines: number[] = [];
        const vertLines: number[] = [];
        const invXf = this.screenToFreeformContentsXf.inverse();
        snappableDocs
            .filter(doc => !doc.isGroup && (snapToDraggedDoc || (SnappingManager.IsResizing !== doc[Id] && !DragManager.docsBeingDragged.includes(doc))))
            .forEach(doc => {
                const { left, top, width, height } = docDims(doc);
                const topLeftInScreen = invXf.transformPoint(left, top);
                const docSize = invXf.transformDirection(width, height);

                horizLines.push(topLeftInScreen[1], topLeftInScreen[1] + docSize[1] / 2, topLeftInScreen[1] + docSize[1]); // horiz center line
                vertLines.push(topLeftInScreen[0], topLeftInScreen[0] + docSize[0] / 2, topLeftInScreen[0] + docSize[0]); // right line
            });
        this.layoutDoc._freeform_snapLines && SnappingManager.addSnapLines(horizLines, vertLines);
    };

    incrementalRendering = () => this.childDocs.filter(doc => !this._renderCutoffData.get(doc[Id])).length !== 0;

    incrementalRender = action(() => {
        if (!DocumentView.LightboxDoc() || DocumentView.LightboxContains(this.DocumentView?.())) {
            const layoutUnrendered = this.childDocs.filter(doc => !this._renderCutoffData.get(doc[Id]));
            const loadIncrement = this.Document.isTemplateDoc || this.Document.isTemplateForField ? Number.MAX_VALUE : 5;
            for (let i = 0; i < Math.min(layoutUnrendered.length, loadIncrement); i++) {
                this._renderCutoffData.set(layoutUnrendered[i][Id] + '', true);
            }
        }
        this.childDocs.some(doc => !this._renderCutoffData.get(doc[Id])) && setTimeout(this.incrementalRender, 1);
    });
    showPresPaths = () => SnappingManager.ShowPresPaths;
    brushedView = () => this._brushedView;
    gridColor = () => DashColor(lightOrDark(this.backgroundColor)).fade(0.5).toString(); // prettier-ignore
    nativeDim = () => this.nativeDimScaling;

    brushView = action((viewport: { width: number; height: number; panX: number; panY: number }, transTime: number, holdTime: number = 2500) => {
        this._brushtimer1 && clearTimeout(this._brushtimer1);
        this._brushtimer && clearTimeout(this._brushtimer);
        this._brushedView = undefined;
        this._brushtimer1 = setTimeout(
            action(() => {
                this._brushedView = { ...viewport, panX: viewport.panX - viewport.width / 2, panY: viewport.panY - viewport.height / 2 };
                this._brushtimer = setTimeout(action(() => { this._brushedView = undefined; }), holdTime); // prettier-ignore
            }),
            transTime + 1
        );
    });
    lightboxPanelWidth = () => Math.max(0, this._props.PanelWidth() - 30);
    lightboxPanelHeight = () => Math.max(0, this._props.PanelHeight() - 30);
    lightboxScreenToLocal = () => this.ScreenToLocalBoxXf().translate(-15, -15);
    onPassiveWheel = (e: WheelEvent) => {
        const docHeight = NumCast(this.Document[Doc.LayoutFieldKey(this.Document) + '_nativeHeight'], this.nativeHeight);
        const scrollable = NumCast(this.layoutDoc[this.scaleFieldKey], 1) === 1 && docHeight > this._props.PanelHeight() / this.nativeDimScaling;
        this._props.isSelected() && !scrollable && e.preventDefault();
    };
    get backgroundGrid() {
        return (
            <div>
                <CollectionFreeFormBackgroundGrid // bcz : UGHH  don't know why, but if we don't wrap in a div, then PDF's don't render when taking snapshot of a dashboard and the background grid is on!!?
                    PanelWidth={this._props.PanelWidth}
                    PanelHeight={this._props.PanelHeight}
                    panX={this.panX}
                    panY={this.panY}
                    color={this.gridColor}
                    nativeDimScaling={this.nativeDim}
                    zoomScaling={this.zoomScaling}
                    layoutDoc={this.layoutDoc}
                    isAnnotationOverlay={this.isAnnotationOverlay}
                    centeringShiftX={this.centeringShiftX}
                    centeringShiftY={this.centeringShiftY}
                />
            </div>
        );
    }
    transitionFunc = () => (this._panZoomTransition ? `transform ${this._panZoomTransition}ms ${this._presEaseFunc}` : Cast(this.layoutDoc._viewTransition, 'string', Cast(this.Document._viewTransition, 'string', null)));
    get pannableContents() {
        this.incrementalRender(); // needs to happen synchronously or freshly typed text documents will flash and miss their first characters
        return (
            <CollectionFreeFormPannableContents
                Document={this.Document}
                brushedView={this.brushedView}
                isAnnotationOverlay={this.isAnnotationOverlay}
                transform={this.PanZoomCenterXf}
                showPresPaths={this.showPresPaths}
                transition={this.transitionFunc}
                viewDefDivClick={this._props.viewDefDivClick}>
                {this.props.children ?? null} {/* most likely case of children is document content that's being annoated: eg., an image */}
                {this.contentViews}
                <CollectionFreeFormRemoteCursors {...this._props} key="remoteCursors" />
            </CollectionFreeFormPannableContents>
        );
    }
    get marqueeView() {
        return (
            <MarqueeView
                {...this._props}
                ref={this._marqueeViewRef}
                ungroup={this.Document.isGroup ? this.promoteCollection : undefined}
                nudge={this.isAnnotationOverlay || this._props.renderDepth > 0 ? undefined : this.nudge}
                addDocTab={this.addDocTab}
                slowLoadDocuments={this.slowLoadDocuments}
                trySelectCluster={this._clusters.tryToSelect}
                activeDocuments={this.getActiveDocuments}
                selectDocuments={this.selectDocuments}
                addDocument={this.addDocument}
                addLiveTextDocument={this.addLiveTextBox}
                getContainerTransform={this.ScreenToLocalBoxXf}
                getTransform={this.ScreenToContentsXf}
                panXFieldKey={this.panXFieldKey}
                panYFieldKey={this.panYFieldKey}
                isAnnotationOverlay={this.isAnnotationOverlay}>
                {this.layoutDoc._freeform_backgroundGrid ? this.backgroundGrid : null}
                {this.pannableContents}
                {this._showAnimTimeline ? <Timeline ref={this._timelineRef} {...this._props} /> : null}
            </MarqueeView>
        );
    }
    get placeholder() {
        return (
            <div className="collectionfreeformview-placeholder" style={{ background: this.backgroundColor }}>
                <span className="collectionfreeformview-placeholderSpan">{this.Document.annotationOn ? '' : this.Document.title?.toString()}</span>
            </div>
        );
    }
    render() {
        TraceMobx();
        return (
            <div
                className="collectionfreeformview-container"
                id={this._paintedId}
                ref={r => {
                    this.createDashEventsTarget(r);
                    this._oldWheel?.removeEventListener('wheel', this.onPassiveWheel);
                    this._oldWheel = r;
                    // prevent wheel events from passivly propagating up through containers
                    r?.addEventListener('wheel', this.onPassiveWheel, { passive: false });
                    r?.addEventListener('mouseleave', this.onMouseLeave);
                    r?.addEventListener('mouseenter', this.onMouseEnter);
                }}
                onWheel={this.onPointerWheel}
                onClick={this.onClick}
                onPointerDown={this.onPointerDown}
                onPointerMove={this.onCursorMove}
                onDrop={this.onExternalDrop}
                onDragOver={e => e.preventDefault()}
                onContextMenu={this.onContextMenu}
                style={{
                    pointerEvents: this._props.isContentActive() && SnappingManager.IsDragging ? 'all' : this._props.pointerEvents?.(),
                    textAlign: this.isAnnotationOverlay ? 'initial' : undefined,
                    transform: `scale(${this.nativeDimScaling})`,
                    width: `${100 / this.nativeDimScaling}%`,
                    height: this._props.getScrollHeight?.() ?? `${100 / this.nativeDimScaling}%`,
                }}>
                {Doc.ActiveTool === InkTool.RadiusEraser && this._showEraserCircle && (
                    <div
                        onPointerMove={this.onCursorMove}
                        style={{
                            position: 'fixed',
                            left: this._eraserX,
                            top: this._eraserY,
                            width: (ActiveEraserWidth() + 5) * 2,
                            height: (ActiveEraserWidth() + 5) * 2,
                            borderRadius: '50%',
                            border: '1px solid gray',
                            transform: 'translate(-50%, -50%)',
                        }}
                    />
                )}
                {this.paintFunc ? (
                    <FormattedTextBox {...this.props} /> // need this so that any live dashfieldviews will update the underlying text that the code eval reads
                ) : this._lightboxDoc ? (
                    <div style={{ padding: 15, width: '100%', height: '100%' }}>
                        <DocumentView
                            {...this._props}
                            Document={this._lightboxDoc}
                            containerViewPath={this.DocumentView?.().docViewPath}
                            TemplateDataDocument={undefined}
                            PanelWidth={this.lightboxPanelWidth}
                            PanelHeight={this.lightboxPanelHeight}
                            NativeWidth={returnZero}
                            NativeHeight={returnZero}
                            onClickScript={this.onChildClickHandler}
                            onKey={this.onKeyDown}
                            onDoubleClickScript={this.onChildDoubleClickHandler}
                            childFilters={this.childDocFilters}
                            childFiltersByRanges={this.childDocRangeFilters}
                            searchFilterDocs={this.searchFilterDocs}
                            isDocumentActive={this._props.childDocumentsActive?.() ? this._props.isDocumentActive : this.isContentActive}
                            isContentActive={this._props.childContentsActive ?? emptyFunction}
                            addDocTab={this.addDocTab}
                            ScreenToLocalTransform={this.lightboxScreenToLocal}
                            fitContentsToBox={undefined}
                            focus={this.focus}
                        />
                    </div>
                ) : (
                    <>
                        {this._firstRender ? this.placeholder : this.marqueeView}
                        {this._props.noOverlay ? null : <CollectionFreeFormOverlayView elements={this.elementFunc} />}
                        {!this.GroupChildDrag ? null : <div className="collectionFreeForm-groupDropper" />}
                    </>
                )}
            </div>
        );
    }
}

// eslint-disable-next-line prefer-arrow-callback
ScriptingGlobals.add(function nextKeyFrame(readOnly: boolean) {
    !readOnly && (DocumentView.Selected()[0].ComponentView as CollectionFreeFormView)?.changeKeyFrame();
});
// eslint-disable-next-line prefer-arrow-callback
ScriptingGlobals.add(function prevKeyFrame(readOnly: boolean) {
    !readOnly && (DocumentView.Selected()[0].ComponentView as CollectionFreeFormView)?.changeKeyFrame(true);
});
// eslint-disable-next-line prefer-arrow-callback
ScriptingGlobals.add(function curKeyFrame(readOnly: boolean) {
    const selView = DocumentView.Selected();
    if (readOnly) return selView[0].ComponentView?.getKeyFrameEditing?.() ? Colors.MEDIUM_BLUE : 'transparent';
    runInAction(() => selView[0].ComponentView?.setKeyFrameEditing?.(!selView[0].ComponentView?.getKeyFrameEditing?.()));
    return undefined;
});
// eslint-disable-next-line prefer-arrow-callback
ScriptingGlobals.add(function pinWithView(pinContent: boolean) {
    DocumentView.Selected().forEach(view =>
        view._props.pinToPres(view.Document, {
            currentFrame: Cast(view.Document.currentFrame, 'number', null),
            pinData: {
                poslayoutview: pinContent,
                dataview: pinContent,
            },
            pinViewport: MarqueeView.CurViewBounds(view.Document, view._props.PanelWidth(), view._props.PanelHeight()),
        })
    );
});
// eslint-disable-next-line prefer-arrow-callback
ScriptingGlobals.add(function bringToFront() {
    DocumentView.Selected().forEach(view => CollectionFreeFormView.from(view)?.bringToFront(view.Document));
});
// eslint-disable-next-line prefer-arrow-callback
ScriptingGlobals.add(function sendToBack() {
    DocumentView.Selected().forEach(view => CollectionFreeFormView.from(view)?.bringToFront(view.Document, true));
});
// eslint-disable-next-line prefer-arrow-callback
ScriptingGlobals.add(function datavizFromSchema() {
    // creating a dataviz doc to represent the schema table
    DocumentView.Selected().forEach(viewIn => {
        const view = viewIn;
        if (!view.layoutDoc.schema_columnKeys) {
            view.layoutDoc.schema_columnKeys = new List<string>(['title', 'type', 'author', 'author_date']);
        }
        const keys = Cast(view.layoutDoc.schema_columnKeys, listSpec('string'))?.filter(key => key !== 'text');
        if (!keys) return;

        const children = DocListCast(view.Document[Doc.LayoutFieldKey(view.Document)]);
        const csvRows = [];
        csvRows.push(keys.join(','));
        for (let i = 0; i < children.length; i++) {
            const eachRow = [];
            for (let j = 0; j < keys.length; j++) {
                let cell = children[i][keys[j]]?.toString();
                if (cell) cell = cell.toString().replace(/,/g, '');
                eachRow.push(cell);
            }
            csvRows.push(eachRow);
        }
        const blob = new Blob([csvRows.join('\n')], { type: 'text/csv' });
        const options = { x: 0, y: 0, title: 'schemaTable', _width: 300, _height: 100, type: 'text/csv' };
        const file = new File([blob], 'schemaTable', options);
        const loading = Docs.Create.LoadingDocument(file, options);
        loading.presentation_openInLightbox = true;
        DocUtils.uploadFileToDoc(file, {}, loading);

        // holds the doc in a popup until it is dragged onto a canvas
        if (view.ComponentView?.addDocument) {
            loading._dataViz_asSchema = view.layoutDoc;
            SchemaCSVPopUp.Instance.setView(view);
            SchemaCSVPopUp.Instance.setTarget(view.layoutDoc);
            SchemaCSVPopUp.Instance.setDataVizDoc(loading);
            SchemaCSVPopUp.Instance.setVisible(true);
        }
    });
});
