/* eslint-disable jsx-a11y/no-static-element-interactions */ /* eslint-disable jsx-a11y/click-events-have-key-events */ import { action, computed, IReactionDisposer, makeObservable, observable, reaction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import { DivHeight, returnAll, returnEmptyDoclist, returnEmptyFilter, returnFalse, returnNone, returnOne, returnTrue, returnZero } from '../../../ClientUtils'; import { Doc, DocListCast, Opt, StrListCast } from '../../../fields/Doc'; import { DocData } from '../../../fields/DocSymbols'; import { Id } from '../../../fields/FieldSymbols'; import { listSpec } from '../../../fields/Schema'; import { ScriptField } from '../../../fields/ScriptField'; import { BoolCast, Cast, NumCast, ScriptCast, StrCast } from '../../../fields/Types'; import { TraceMobx } from '../../../fields/util'; import { emptyFunction, Utils } from '../../../Utils'; import { Docs, DocUtils } from '../../documents/Documents'; import { DocumentManager } from '../../util/DocumentManager'; import { DragManager } from '../../util/DragManager'; import { dropActionType } from '../../util/DropActionTypes'; import { ScriptingGlobals } from '../../util/ScriptingGlobals'; import { SelectionManager } from '../../util/SelectionManager'; import { SnappingManager } from '../../util/SnappingManager'; import { Transform } from '../../util/Transform'; import { undoBatch, UndoManager } from '../../util/UndoManager'; import { ContextMenu } from '../ContextMenu'; import { ContextMenuProps } from '../ContextMenuItem'; import { EditableView } from '../EditableView'; import { DocumentView } from '../nodes/DocumentView'; import { FormattedTextBox } from '../nodes/formattedText/FormattedTextBox'; import { StyleProp } from '../StyleProvider'; import { CollectionFreeFormView } from './collectionFreeForm'; import { CollectionSubView } from './CollectionSubView'; import './CollectionTreeView.scss'; import { TreeView } from './TreeView'; const _global = (window /* browser */ || global) /* node */ as any; export type collectionTreeViewProps = { treeViewExpandedView?: 'fields' | 'layout' | 'links' | 'data'; treeViewOpen?: boolean; treeViewHideTitle?: boolean; treeViewHideHeaderFields?: boolean; treeViewSkipFields?: string[]; // prevents specific fields from being displayed (see LinkBox) onCheckedClick?: () => ScriptField; onChildClick?: () => ScriptField; // TODO: [AL] add these fields AddToMap?: (treeViewDoc: Doc, index: number[]) => void; RemFromMap?: (treeViewDoc: Doc, index: number[]) => void; hierarchyIndex?: number[]; }; export enum TreeViewType { outline = 'outline', fileSystem = 'fileSystem', default = 'default', } @observer export class CollectionTreeView extends CollectionSubView>() { public static AddTreeFunc = 'addTreeFolder(this.embedContainer)'; private _treedropDisposer?: DragManager.DragDropDisposer; private _mainEle?: HTMLDivElement; private _titleRef?: HTMLDivElement | HTMLInputElement | null; private _disposers: { [name: string]: IReactionDisposer } = {}; private _isDisposing = false; // notes that instance is in process of being disposed private refList: Set = new Set(); // list of tree view items to monitor for height changes private observer: any; // observer for monitoring tree view items. constructor(props: any) { super(props); makeObservable(this); } get dataDoc() { return this._props.TemplateDataDocument || this.Document; } @computed get treeViewtruncateTitleWidth() { return NumCast(this.Document.treeView_TruncateTitleWidth, this.panelWidth()); } @computed get treeChildren() { TraceMobx(); return this._props.childDocuments || this.childDocs; } @computed get outlineMode() { return this.Document.treeView_Type === TreeViewType.outline; } @computed get fileSysMode() { return this.Document.treeView_Type === TreeViewType.fileSystem; } @computed get dashboardMode() { return this.Document === Doc.MyDashboards; } @observable _titleHeight = 0; // height of the title bar MainEle = () => this._mainEle; // these should stay in synch with counterparts in DocComponent.ts ViewBoxAnnotatableComponent @observable _isAnyChildContentActive = false; whenChildContentsActiveChanged = action((isActive: boolean) => { this._props.whenChildContentsActiveChanged((this._isAnyChildContentActive = isActive)); }); isContentActive = () => (this._isAnyChildContentActive ? true : !!this._props.isContentActive()); componentWillUnmount() { this._isDisposing = true; super.componentWillUnmount(); this._treedropDisposer?.(); Object.values(this._disposers).forEach(disposer => disposer?.()); } componentDidMount() { // this._props.setContentView?.(this); this._disposers.autoheight = reaction( () => this.layoutDoc.layout_autoHeight, auto => auto && this.computeHeight(), { fireImmediately: true } ); } computeHeight = () => { if (!this._isDisposing) { const titleHeight = !this._titleRef ? this.marginTop() : DivHeight(this._titleRef); const bodyHeight = Array.from(this.refList).reduce((p, r) => p + DivHeight(r), this.marginBot()) + 6; this.layoutDoc._layout_autoHeightMargins = bodyHeight; !this._props.dontRegisterView && this._props.setHeight?.(bodyHeight + titleHeight); } }; unobserveHeight = (ref: any) => { this.refList.delete(ref); this.layoutDoc.layout_autoHeight && this.computeHeight(); }; observeHeight = (ref: any) => { if (ref) { this.refList.add(ref); this.observer = new _global.ResizeObserver(() => { if (this.layoutDoc.layout_autoHeight && ref && this.refList.size && !SnappingManager.IsDragging) { this.computeHeight(); } }); this.layoutDoc.layout_autoHeight && this.computeHeight(); this.observer.observe(ref); } }; protected createTreeDropTarget = (ele: HTMLDivElement) => { this._treedropDisposer?.(); this._mainEle = ele; if (ele) this._treedropDisposer = DragManager.MakeDropTarget(ele, this.onInternalDrop.bind(this), this.Document, this.onInternalPreDrop.bind(this)); }; protected onInternalDrop(e: Event, de: DragManager.DropEvent) { const res = super.onInternalDrop(e, de); if (res && de.complete.docDragData) { if (this.Document !== Doc.MyRecentlyClosed) de.complete.docDragData.droppedDocuments.forEach(doc => { if (this.Document !== Doc.MyRecentlyClosed) Doc.RemoveDocFromList(Doc.MyRecentlyClosed, undefined, doc); }); } return res; } protected onInternalPreDrop = (e: Event, de: DragManager.DropEvent, targetDropAction: dropActionType) => { const dragData = de.complete.docDragData; if (dragData) { const sourceDragAction = dragData.dropAction; const sameTree = dragData.treeViewDoc?.[DocData] === this.dataDoc; dragData.dropAction = !sameTree // if doc from another tree ? sourceDragAction || targetDropAction // then use the source's dragAction otherwise the target's : sourceDragAction === dropActionType.inPlace // if source drag is inPlace ? sourceDragAction // keep the doc in place : dropActionType.same; // otherwise use same tree semantics to move within tree e.stopPropagation(); } }; dragConfig = (dragData: DragManager.DocumentDragData) => { dragData.treeViewDoc = this.Document; }; // prettier-ignore screenToLocalTransform = () => this.ScreenToLocalBoxXf().translate(0, -this._headerHeight); @action remove = (docIn: Doc | Doc[]): boolean => { const docs = docIn instanceof Doc ? [docIn] : docIn; const targetDataDoc = this.Document[DocData]; const value = DocListCast(targetDataDoc[this._props.fieldKey]); const result = value.filter(v => !docs.includes(v)); if (docs.some(doc => SelectionManager.Views.some(dv => Doc.AreProtosEqual(dv.Document, doc)))) SelectionManager.DeselectAll(); if (result.length !== value.length) { if (docIn instanceof Doc) { const ind = DocListCast(targetDataDoc[this._props.fieldKey]).indexOf(docIn); const prev = ind && DocListCast(targetDataDoc[this._props.fieldKey])[ind - 1]; this._props.removeDocument?.(docIn); if (ind > 0 && prev) { FormattedTextBox.SetSelectOnLoad(prev); DocumentManager.Instance.getDocumentView(prev, this.DocumentView?.())?.select(false); } return true; } return this._props.removeDocument?.(docIn) ?? false; } return false; }; @action addDoc = (docs: Doc | Doc[], relativeTo: Opt, before?: boolean): boolean => { const addDocRelativeTo = (adocs: Doc | Doc[]) => (adocs as Doc[]).reduce((flg, doc) => flg && Doc.AddDocToList(this.Document[DocData], this._props.fieldKey, doc, relativeTo, before), true); if (this.Document.resolvedDataDoc instanceof Promise) return false; const doclist = docs instanceof Doc ? [docs] : docs; const res = relativeTo === undefined ? this._props.addDocument?.(doclist) || false : addDocRelativeTo(doclist); res && doclist.forEach(doc => { Doc.SetContainer(doc, this.Document); if (this.Document !== Doc.MyRecentlyClosed) Doc.RemoveDocFromList(Doc.MyRecentlyClosed, undefined, doc); }); return res; }; onContextMenu = (): void => { // need to test if propagation has stopped because GoldenLayout forces a parallel react hierarchy to be created for its top-level layout const layoutItems: ContextMenuProps[] = []; const menuDoc = ScriptCast(Cast(this.layoutDoc.layout_headerButton, Doc, null)?.onClick).script.originalScript === CollectionTreeView.AddTreeFunc; menuDoc && layoutItems.push({ description: 'Create new folder', event: () => CollectionTreeView.addTreeFolder(this.Document), icon: 'paint-brush' }); if (!Doc.noviceMode) { layoutItems.push({ description: 'Make tree state ' + (this.Document.treeView_OpenIsTransient ? 'persistent' : 'transient'), event: () => { this.Document.treeView_OpenIsTransient = !this.Document.treeView_OpenIsTransient; }, // prettier-ignore icon: 'paint-brush', }); layoutItems.push({ description: (this.Document.treeView_HideHeaderFields ? 'Show' : 'Hide') + ' Header Fields', event: () => { this.Document.treeView_HideHeaderFields = !this.Document.treeView_HideHeaderFields; }, icon: 'paint-brush' }); // prettier-ignore layoutItems.push({ description: (this.Document.treeView_HideTitle ? 'Show' : 'Hide') + ' Title', event: () => { this.Document.treeView_HideTitle = !this.Document.treeView_HideTitle; }, icon: 'paint-brush' }); // prettier-ignore } ContextMenu.Instance.addItem({ description: 'Options...', subitems: layoutItems, icon: 'eye' }); if (!Doc.noviceMode) { const existingOnClick = ContextMenu.Instance.findByDescription('OnClick...'); const onClicks: ContextMenuProps[] = existingOnClick && 'subitems' in existingOnClick ? existingOnClick.subitems : []; onClicks.push({ description: 'Edit onChecked Script', event: () => UndoManager.RunInBatch(() => DocUtils.makeCustomViewClicked(this.Document, undefined, 'onCheckedClick'), 'edit onCheckedClick'), icon: 'edit' }); !existingOnClick && ContextMenu.Instance.addItem({ description: 'OnClick...', noexpand: true, subitems: onClicks, icon: 'mouse-pointer' }); } }; onTreeDrop = (e: React.DragEvent, addDocs?: (docs: Doc[]) => void) => this.onExternalDrop(e, {}, addDocs); @undoBatch makeTextCollection = (childDocs: Doc[]) => this.addDoc(TreeView.makeTextBullet(), childDocs.length ? childDocs[0] : undefined, true); get editableTitle() { return ( StrCast(this.dataDoc.title)} SetValue={undoBatch((value: string, shift: boolean, enter: boolean) => { if (enter && this.Document.treeView_Type === TreeViewType.outline) this.makeTextCollection(this.treeChildren); this.dataDoc.title = value; return true; })} /> ); } onKey = (e: React.KeyboardEvent /* , fieldProps: FieldViewProps */) => { if (this.outlineMode && e.key === 'Enter') { e.stopPropagation(); this.makeTextCollection(this.treeChildren); return true; } return undefined; }; get documentTitle() { return ( ); } childContextMenuItems = () => { const customScripts = Cast(this.Document.childContextMenuScripts, listSpec(ScriptField), []); const customFilters = Cast(this.Document.childContextMenuFilters, listSpec(ScriptField), []); const icons = StrListCast(this.Document.childContextMenuIcons); return StrListCast(this.Document.childContextMenuLabels).map((label, i) => ({ script: customScripts[i], filter: customFilters[i], icon: icons[i], label })); }; headerFields = () => this._props.treeViewHideHeaderFields || BoolCast(this.Document.treeView_HideHeaderFields); @observable _renderCount = 1; @computed get treeViewElements() { TraceMobx(); const dragAction = StrCast(this.Document.childDragAction) as any as dropActionType; const treeAddDoc = (doc: Doc | Doc[], relativeTo?: Doc, before?: boolean) => this.addDoc(doc, relativeTo, before); const moveDoc = (d: Doc | Doc[], target: Doc | undefined, addDoc: (doc: Doc | Doc[]) => boolean) => this._props.moveDocument?.(d, target, addDoc) || false; if (this._renderCount < this.treeChildren.length) setTimeout( action(() => { this._renderCount = Math.min(this.treeChildren.length, this._renderCount + 20); }) ); return TreeView.GetChildElements( this.treeChildren, this, this, this.Document, this._props.TemplateDataDocument, undefined, undefined, treeAddDoc, this.remove, moveDoc, dragAction, this._props.addDocTab, this._props.styleProvider, this.screenToLocalTransform, this.isContentActive, this.panelWidth, this._props.renderDepth, this.headerFields, [], this._props.onCheckedClick, this.onChildClick, this._props.treeViewSkipFields, true, this.whenChildContentsActiveChanged, this._props.dontRegisterView || Cast(this.Document.childDontRegisterViews, 'boolean', null), this.observeHeight, this.unobserveHeight, this.childContextMenuItems(), this._props.AddToMap, this._props.RemFromMap, this._props.hierarchyIndex, this._renderCount ); } @computed get titleBar() { return this.dataDoc === null ? null : (
{ (this._titleRef = r) && (this._titleHeight = r.getBoundingClientRect().height * this.ScreenToLocalBoxXf().Scale); })} key={this.Document[Id]} style={!this.outlineMode ? { marginLeft: this.marginX(), paddingTop: this.marginTop() } : {}}> {this.outlineMode ? this.documentTitle : this.editableTitle}
); } @computed get noviceExplainer() { return !Doc.noviceMode || !this.layoutDoc.layout_explainer ? null :
{StrCast(this.layoutDoc.layout_explainer)}
; } return35 = () => 35; @computed get buttonMenu() { const menuDoc = Cast(this.layoutDoc.layout_headerButton, Doc, null); // To create a multibutton menu add a CollectionLinearView return !menuDoc ? null : (
); } @computed get nativeWidth() { return Doc.NativeWidth(this.Document, undefined, true); } @computed get nativeHeight() { return Doc.NativeHeight(this.Document, undefined, true); } /// scale factor for tree view so that it will fit within it's panel bounds @computed get nativeDimScaling() { const nw = this.nativeWidth; const nh = this.nativeHeight; const hscale = nh ? this._props.PanelHeight() / nh : 1; const wscale = nw ? this._props.PanelWidth() / nw : 1; return wscale < hscale ? wscale : hscale; } marginX = () => NumCast(this.Document._xMargin); marginTop = () => NumCast(this.Document._yMargin); marginBot = () => NumCast(this.Document._yMargin); documentTitleWidth = () => Math.min(NumCast(this.layoutDoc?._width), this.panelWidth()); documentTitleHeight = () => NumCast(this.layoutDoc?._height) - NumCast(this.layoutDoc.layout_autoHeightMargins); truncateTitleWidth = () => this.treeViewtruncateTitleWidth; onChildClick = () => this._props.onChildClick?.() || ScriptCast(this.Document.onChildClick); panelWidth = () => Math.max(0, this._props.PanelWidth() - 2 * this.marginX() * (this._props.NativeDimScaling?.() || 1)); addAnnotationDocument = (doc: Doc | Doc[]) => this.addDocument(doc, `${this._props.fieldKey}_annotations`) || false; remAnnotationDocument = (doc: Doc | Doc[]) => this.removeDocument(doc, `${this._props.fieldKey}_annotations`) || false; moveAnnotationDocument = (doc: Doc | Doc[], targetCollection: Doc | undefined, addDocument: (document: Doc | Doc[], annotationKey?: string) => boolean) => this.moveDocument(doc, targetCollection, addDocument) || false; @observable _headerHeight = 0; @computed get content() { const background = () => this._props.styleProvider?.(this.Document, this._props, StyleProp.BackgroundColor); const color = () => this._props.styleProvider?.(this.Document, this._props, StyleProp.Color); const pointerEvents = () => (this._props.isContentActive() === false ? 'none' : undefined); const titleBar = this._props.treeViewHideTitle || this.Document.treeView_HideTitle ? null : this.titleBar; return (
{!this.buttonMenu && !this.noviceExplainer ? null : (
{ r && (this._headerHeight = DivHeight(r)); })}> {this.buttonMenu} {this.noviceExplainer}
)}
!this.Document.treeView_HasOverlay && r && this.createTreeDropTarget(r)} style={{ ...(!titleBar ? { marginLeft: this.marginX(), paddingTop: this.marginTop() } : {}), color: color(), overflow: 'auto', width: '100%', height: '100%', }}> {titleBar}
e.stopPropagation()} onClick={() => (!this.layoutDoc.forceActive ? this._props.select(false) : SelectionManager.DeselectAll())} onDrop={this.onTreeDrop}>
    {this.treeViewElements}
); } render() { TraceMobx(); const scale = this._props.NativeDimScaling?.() || 1; return (
{!(this.Document instanceof Doc) || !this.treeChildren ? null : this.Document.treeView_HasOverlay ? ( {this.content} ) : ( this.content )}
); } static addTreeFolder(container: Doc) { TreeView._editTitleOnLoad = { id: Utils.GenerateGuid(), parent: undefined }; const opts = { title: 'Untitled folder', _dragOnlyWithinContainer: true, isFolder: true }; return Doc.AddDocToList(container, 'data', Docs.Create.TreeDocument([], opts, TreeView._editTitleOnLoad.id)); } } // eslint-disable-next-line prefer-arrow-callback ScriptingGlobals.add(function addTreeFolder(doc: Doc) { CollectionTreeView.addTreeFolder(doc); });