import { action, IReactionDisposer, observable, reaction, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import * as ReactDOM from 'react-dom'; import * as GoldenLayout from '../../../client/goldenLayout'; import { Doc, DocListCast, Opt } from '../../../fields/Doc'; import { Id } from '../../../fields/FieldSymbols'; import { InkTool } from '../../../fields/InkField'; import { List } from '../../../fields/List'; import { Cast, NumCast, StrCast } from '../../../fields/Types'; import { ImageField } from '../../../fields/URLField'; import { inheritParentAcls } from '../../../fields/util'; import { emptyFunction, incrementTitleCopy } from '../../../Utils'; import { DocServer } from '../../DocServer'; import { Docs } from '../../documents/Documents'; import { CollectionViewType, DocumentType } from '../../documents/DocumentTypes'; import { DragManager } from '../../util/DragManager'; import { InteractionUtils } from '../../util/InteractionUtils'; import { ScriptingGlobals } from '../../util/ScriptingGlobals'; import { SelectionManager } from '../../util/SelectionManager'; import { undoBatch, UndoManager } from '../../util/UndoManager'; import { DashboardView } from '../DashboardView'; import { LightboxView } from '../LightboxView'; import './CollectionDockingView.scss'; import { CollectionFreeFormView } from './collectionFreeForm'; import { CollectionSubView, SubCollectionViewProps } from './CollectionSubView'; import { TabDocView } from './TabDocView'; import React = require('react'); const _global = (window /* browser */ || global) /* node */ as any; @observer export class CollectionDockingView extends CollectionSubView() { @observable public static Instance: CollectionDockingView | undefined; public static makeDocumentConfig(document: Doc, panelName?: string, width?: number) { return { type: 'react-component', component: 'DocumentFrameRenderer', title: document.title, width: width, props: { documentId: document[Id], panelName, // name of tab that can be used to close or replace its contents }, }; } private _reactionDisposer?: IReactionDisposer; private _lightboxReactionDisposer?: IReactionDisposer; private _containerRef = React.createRef(); public _flush: UndoManager.Batch | undefined; private _ignoreStateChange = ''; public tabMap: Set = new Set(); public get HasFullScreen() { return this._goldenLayout._maximisedItem !== null; } private _goldenLayout: any = null; constructor(props: SubCollectionViewProps) { super(props); runInAction(() => (CollectionDockingView.Instance = this)); //Why is this here? (window as any).React = React; (window as any).ReactDOM = ReactDOM; DragManager.StartWindowDrag = this.StartOtherDrag; } /** * Switches from dragging a document around a freeform canvas to dragging it as a tab to be docked. * * @param e fake mouse down event position data containing pageX and pageY coordinates * @param dragDocs the documents to be dragged * @param batch optionally an undo batch that has been started to use instead of starting a new batch */ public StartOtherDrag = (e: { pageX: number; pageY: number }, dragDocs: Doc[], finishDrag?: (aborted: boolean) => void) => { this._flush = this._flush ?? UndoManager.StartBatch('golden layout drag'); const config = dragDocs.length === 1 ? CollectionDockingView.makeDocumentConfig(dragDocs[0]) : { type: 'row', content: dragDocs.map(doc => CollectionDockingView.makeDocumentConfig(doc)) }; const dragSource = this._goldenLayout.createDragSource(document.createElement('div'), config); this.tabDragStart(dragSource, finishDrag); dragSource._dragListener.onMouseDown({ pageX: e.pageX, pageY: e.pageY, preventDefault: emptyFunction, button: 0 }); }; tabItemDropped = () => DragManager.CompleteWindowDrag?.(false); tabDragStart = (proxy: any, finishDrag?: (aborted: boolean) => void) => { const dashDoc = proxy?._contentItem?.tab?.DashDoc as Doc; dashDoc && (DragManager.DocDragData = new DragManager.DocumentDragData([proxy._contentItem.tab.DashDoc])); DragManager.CompleteWindowDrag = (aborted: boolean) => { if (aborted) { proxy._dragListener.AbortDrag(); if (this._flush) { this._flush.cancel(); // cancel the undo change being logged this._flush = undefined; this.setupGoldenLayout(); // restore golden layout to where it was before the drag (this is a no-op when using StartOtherDrag because the proxy dragged item was never in the golden layout) } DragManager.CompleteWindowDrag = undefined; } finishDrag?.(aborted); }; }; @undoBatch public CloseFullScreen = () => { this._goldenLayout._maximisedItem?.toggleMaximise(); this.stateChanged(); }; @undoBatch public static CloseSplit(document: Opt, panelName?: string): boolean { if (CollectionDockingView.Instance) { const tab = Array.from(CollectionDockingView.Instance.tabMap.keys()).find(tab => (panelName ? tab.contentItem.config.props.panelName === panelName : tab.DashDoc === document)); if (tab) { const j = tab.header.parent.contentItems.indexOf(tab.contentItem); if (j !== -1) { tab.header.parent.contentItems[j].remove(); return CollectionDockingView.Instance.layoutChanged(); } } } return false; } @undoBatch public static OpenFullScreen(doc: Doc) { SelectionManager.DeselectAll(); const instance = CollectionDockingView.Instance; if (instance) { if (doc._viewType === CollectionViewType.Docking && doc.layoutKey === 'layout') { return DashboardView.openDashboard(doc); } const newItemStackConfig = { type: 'stack', content: [CollectionDockingView.makeDocumentConfig(Doc.MakeAlias(doc))], }; const docconfig = instance._goldenLayout.root.layoutManager.createContentItem(newItemStackConfig, instance._goldenLayout); instance._goldenLayout.root.contentItems[0].addChild(docconfig); docconfig.callDownwards('_$init'); instance._goldenLayout._$maximiseItem(docconfig); instance._goldenLayout.emit('stateChanged'); instance.stateChanged(); } return true; } @undoBatch @action public static ReplaceTab(document: Doc, panelName: string, stack: any, addToSplit?: boolean): boolean { const instance = CollectionDockingView.Instance; if (!instance) return false; const newConfig = CollectionDockingView.makeDocumentConfig(document, panelName); if (!panelName && stack) { const activeContentItemIndex = stack.contentItems.findIndex((item: any) => item.config === stack._activeContentItem.config); const newContentItem = stack.layoutManager.createContentItem(newConfig, instance._goldenLayout); stack.addChild(newContentItem.contentItems[0], undefined); stack.contentItems[activeContentItemIndex].remove(); return instance.layoutChanged(); } const tab = Array.from(instance.tabMap.keys()).find(tab => tab.contentItem.config.props.panelName === panelName); if (tab) { tab.header.parent.addChild(newConfig, undefined); const j = tab.header.parent.contentItems.indexOf(tab.contentItem); !addToSplit && j !== -1 && tab.header.parent.contentItems[j].remove(); return instance.layoutChanged(); } return CollectionDockingView.AddSplit(document, panelName, stack, panelName); } @undoBatch public static ToggleSplit(doc: Doc, location: string, stack?: any, panelName?: string) { return CollectionDockingView.Instance && Array.from(CollectionDockingView.Instance.tabMap.keys()).findIndex(tab => tab.DashDoc === doc) !== -1 ? CollectionDockingView.CloseSplit(doc) : CollectionDockingView.AddSplit(doc, location, stack, panelName); } // // Creates a split on any side of the docking view based on the passed input pullSide and then adds the Document to the requested side // @undoBatch @action public static AddSplit(document: Doc, pullSide: string, stack?: any, panelName?: string) { if (document?._viewType === CollectionViewType.Docking) return DashboardView.openDashboard(document); if (!CollectionDockingView.Instance) return false; const tab = Array.from(CollectionDockingView.Instance.tabMap).find(tab => tab.DashDoc === document); if (tab) { tab.header.parent.setActiveContentItem(tab.contentItem); return true; } const instance = CollectionDockingView.Instance; const glayRoot = instance._goldenLayout.root; if (!instance) return false; const docContentConfig = CollectionDockingView.makeDocumentConfig(document, panelName); if (!pullSide && stack) { stack.addChild(docContentConfig, undefined); stack.setActiveContentItem(stack.contentItems[stack.contentItems.length - 1]); } else { const newContentItem = () => { const newItem = glayRoot.layoutManager.createContentItem({ type: 'stack', content: [docContentConfig] }, instance._goldenLayout); newItem.callDownwards('_$init'); return newItem; }; if (glayRoot.contentItems.length === 0) { // if no rows / columns glayRoot.addChild(newContentItem()); } else if (glayRoot.contentItems[0].isStack) { glayRoot.contentItems[0].addChild(docContentConfig); } else if (glayRoot.contentItems.length === 1 && glayRoot.contentItems[0].contentItems.length === 1 && glayRoot.contentItems[0].contentItems[0].contentItems.length === 0) { glayRoot.contentItems[0].contentItems[0].addChild(docContentConfig); } else if (instance._goldenLayout.root.contentItems[0].isRow) { // if row switch (pullSide) { default: case 'right': glayRoot.contentItems[0].addChild(newContentItem()); break; case 'left': glayRoot.contentItems[0].addChild(newContentItem(), 0); break; case 'top': case 'bottom': // if not going in a row layout, must add already existing content into column const rowlayout = glayRoot.contentItems[0]; const newColumn = rowlayout.layoutManager.createContentItem({ type: 'column' }, instance._goldenLayout); const newItem = newContentItem(); instance._goldenLayout.saveScrollTops(rowlayout.element); rowlayout.parent.replaceChild(rowlayout, newColumn); if (pullSide === 'top') { newColumn.addChild(rowlayout, undefined, true); newColumn.addChild(newItem, 0, true); } else if (pullSide === 'bottom') { newColumn.addChild(newItem, undefined, true); newColumn.addChild(rowlayout, 0, true); } instance._goldenLayout.restoreScrollTops(rowlayout.element); rowlayout.config.height = 50; newItem.config.height = 50; } } else { // if (instance._goldenLayout.root.contentItems[0].isColumn) { // if column switch (pullSide) { case 'top': glayRoot.contentItems[0].addChild(newContentItem(), 0); break; case 'bottom': glayRoot.contentItems[0].addChild(newContentItem()); break; case 'left': case 'right': default: // if not going in a row layout, must add already existing content into column const collayout = glayRoot.contentItems[0]; const newRow = collayout.layoutManager.createContentItem({ type: 'row' }, instance._goldenLayout); const newItem = newContentItem(); instance._goldenLayout.saveScrollTops(collayout.element); collayout.parent.replaceChild(collayout, newRow); if (pullSide === 'left') { newRow.addChild(collayout, undefined, true); newRow.addChild(newItem, 0, true); } else { newRow.addChild(newItem, undefined, true); newRow.addChild(collayout, 0, true); } instance._goldenLayout.restoreScrollTops(collayout.element); collayout.config.width = 50; newItem.config.width = 50; } } instance._ignoreStateChange = JSON.stringify(instance._goldenLayout.toConfig()); } return instance.layoutChanged(); } @undoBatch @action layoutChanged() { this._goldenLayout.root.callDownwards('setSize', [this._goldenLayout.width, this._goldenLayout.height]); this._goldenLayout.emit('stateChanged'); this.stateChanged(); return true; } setupGoldenLayout = async () => { const config = StrCast(this.props.Document.dockingConfig); if (config) { const matches = config.match(/\"documentId\":\"[a-z0-9-]+\"/g); const docids = matches?.map(m => m.replace('"documentId":"', '').replace('"', '')) ?? []; await Promise.all(docids.map(id => DocServer.GetRefField(id))); if (this._goldenLayout) { if (config === JSON.stringify(this._goldenLayout.toConfig())) { return; } else { try { this._goldenLayout.unbind('tabCreated', this.tabCreated); this._goldenLayout.unbind('tabDestroyed', this.tabDestroyed); this._goldenLayout.unbind('stackCreated', this.stackCreated); } catch (e) {} } this.tabMap.clear(); this._goldenLayout.destroy(); } const glay = (this._goldenLayout = new GoldenLayout(JSON.parse(config))); glay.on('tabCreated', this.tabCreated); glay.on('tabDestroyed', this.tabDestroyed); glay.on('stackCreated', this.stackCreated); glay.registerComponent('DocumentFrameRenderer', TabDocView); glay.container = this._containerRef.current; glay.init(); glay.root.layoutManager.on('itemDropped', this.tabItemDropped); glay.root.layoutManager.on('dragStart', this.tabDragStart); glay.root.layoutManager.on('activeContentItemChanged', this.stateChanged); } }; componentDidMount: () => void = () => { if (this._containerRef.current) { this._lightboxReactionDisposer = reaction( () => LightboxView.LightboxDoc, doc => setTimeout(() => !doc && this.onResize(undefined)) ); new _global.ResizeObserver(this.onResize).observe(this._containerRef.current); this._reactionDisposer = reaction( () => StrCast(this.props.Document.dockingConfig), config => { if (!this._goldenLayout || this._ignoreStateChange !== config) { // bcz: TODO! really need to diff config with ignoreStateChange and modify the current goldenLayout instead of building a new one. this.setupGoldenLayout(); } this._ignoreStateChange = ''; } ); setTimeout(this.setupGoldenLayout); //window.addEventListener('resize', this.onResize); // bcz: would rather add this event to the parent node, but resize events only come from Window } }; componentWillUnmount: () => void = () => { try { this._goldenLayout.unbind('stackCreated', this.stackCreated); this._goldenLayout.unbind('tabDestroyed', this.tabDestroyed); } catch (e) {} this._goldenLayout?.destroy(); window.removeEventListener('resize', this.onResize); this._reactionDisposer?.(); this._lightboxReactionDisposer?.(); }; @action onResize = (event: any) => { const cur = this._containerRef.current; // bcz: since GoldenLayout isn't a React component itself, we need to notify it to resize when its document container's size has changed !LightboxView.LightboxDoc && cur && this._goldenLayout?.updateSize(cur.getBoundingClientRect().width, cur.getBoundingClientRect().height); }; @action onPointerUp = (e: MouseEvent): void => { window.removeEventListener('pointerup', this.onPointerUp); const flush = this._flush; this._flush = undefined; if (flush) { DragManager.CompleteWindowDrag = undefined; if (!this.stateChanged()) flush.cancel(); else flush.end(); } }; @action onPointerDown = (e: React.PointerEvent): void => { let hitFlyout = false; for (let par = e.target as any; !hitFlyout && par; par = par.parentElement) { hitFlyout = par.className === 'dockingViewButtonSelector'; } if (!hitFlyout) { const htmlTarget = e.target as HTMLElement; window.addEventListener('mouseup', this.onPointerUp); if (!htmlTarget.closest('*.lm_content') && (htmlTarget.closest('*.lm_tab') || htmlTarget.closest('*.lm_stack'))) { const className = typeof htmlTarget.className === 'string' ? htmlTarget.className : ''; if (!className.includes('lm_close') && !className.includes('lm_maximise')) { this._flush = UndoManager.StartBatch('golden layout edit'); } } } if (!InteractionUtils.IsType(e, InteractionUtils.TOUCHTYPE) && !InteractionUtils.IsType(e, InteractionUtils.PENTYPE) && ![InkTool.Highlighter, InkTool.Pen, InkTool.Write].includes(Doc.ActiveTool)) { e.stopPropagation(); } }; public CaptureThumbnail() { const content = this.props.DocumentView?.()?.ContentDiv; if (content) { const _width = Number(getComputedStyle(content).width.replace('px', '')); const _height = Number(getComputedStyle(content).height.replace('px', '')); return CollectionFreeFormView.UpdateIcon(this.layoutDoc[Id] + '-icon' + new Date().getTime(), content, _width, _height, _width, _height, 0, 1, true, this.layoutDoc[Id] + '-icon', (iconFile, _nativeWidth, _nativeHeight) => { const img = Docs.Create.ImageDocument(new ImageField(iconFile), { title: this.rootDoc.title + '-icon', _width, _height, _nativeWidth, _nativeHeight }); const proto = Cast(img.proto, Doc, null)!; proto['data-nativeWidth'] = _width; proto['data-nativeHeight'] = _height; this.dataDoc.thumb = img; }); } } public static async TakeSnapshot(doc: Doc | undefined, clone = false) { if (!doc) return undefined; let json = StrCast(doc.dockingConfig); if (clone) { const cloned = await Doc.MakeClone(doc); Array.from(cloned.map.entries()).map(entry => (json = json.replace(entry[0], entry[1][Id]))); Doc.GetProto(cloned.clone).dockingConfig = json; return DashboardView.openDashboard(cloned.clone); } const matches = json.match(/\"documentId\":\"[a-z0-9-]+\"/g); const origtabids = matches?.map(m => m.replace('"documentId":"', '').replace('"', '')) || []; const origtabs = origtabids .map(id => DocServer.GetCachedRefField(id)) .filter(f => f) .map(f => f as Doc); const newtabs = origtabs.map(origtab => { const origtabdocs = DocListCast(origtab.data); const newtab = origtabdocs.length ? Doc.MakeCopy(origtab, true, undefined, true) : Doc.MakeAlias(origtab); const newtabdocs = origtabdocs.map(origtabdoc => Doc.MakeAlias(origtabdoc)); if (newtabdocs.length) { Doc.GetProto(newtab).data = new List(newtabdocs); newtabdocs.forEach(ntab => (ntab.context = newtab)); } json = json.replace(origtab[Id], newtab[Id]); return newtab; }); const copy = Docs.Create.DockDocument(newtabs, json, { title: incrementTitleCopy(StrCast(doc.title)) }); return DashboardView.openDashboard(await copy); } @action stateChanged = () => { this._ignoreStateChange = JSON.stringify(this._goldenLayout.toConfig()); const json = JSON.stringify(this._goldenLayout.toConfig()); const matches = json.match(/\"documentId\":\"[a-z0-9-]+\"/g); const docids = matches?.map(m => m.replace('"documentId":"', '').replace('"', '')); const docs = !docids ? [] : docids .map(id => DocServer.GetCachedRefField(id)) .filter(f => f) .map(f => f as Doc); const changesMade = this.props.Document.dockcingConfig !== json; if (changesMade && !this._flush) { UndoManager.RunInBatch(() => { this.props.Document.dockingConfig = json; this.props.Document.data = new List(docs); }, 'state changed'); } return changesMade; }; tabDestroyed = (tab: any) => { if (tab.DashDoc?.type !== DocumentType.KVP) { Doc.AddDocToList(Doc.MyHeaderBar, 'data', tab.DashDoc); Doc.AddDocToList(Doc.MyRecentlyClosed, 'data', tab.DashDoc, undefined, true, true); } if (CollectionDockingView.Instance) { const dview = CollectionDockingView.Instance.props.Document; const fieldKey = CollectionDockingView.Instance.props.fieldKey; Doc.RemoveDocFromList(dview, fieldKey, tab.DashDoc); this.tabMap.delete(tab); tab._disposers && Object.values(tab._disposers).forEach((disposer: any) => disposer?.()); tab.reactComponents?.forEach((ele: any) => ReactDOM.unmountComponentAtNode(ele)); setTimeout(this.stateChanged); } }; tabCreated = (tab: any) => { this.tabMap.add(tab); tab.contentItem.element[0]?.firstChild?.firstChild?.InitTab?.(tab); // have to explicitly initialize tabs that reuse contents from previous tabs (ie, when dragging a tab around a new tab is created for the old content) }; stackCreated = (stack: any) => { stack.header?.element.on('mousedown', (e: any) => { const dashboard = Doc.ActiveDashboard; if (dashboard && e.target === stack.header?.element[0] && e.button === 2) { dashboard['pane-count'] = NumCast(dashboard['pane-count']) + 1; const docToAdd = Docs.Create.FreeformDocument([], { _width: this.props.PanelWidth(), _height: this.props.PanelHeight(), _backgroundGridShow: true, _fitWidth: true, title: `Untitled Tab ${NumCast(dashboard['pane-count'])}`, }); this.props.Document.isShared && inheritParentAcls(this.props.Document, docToAdd); CollectionDockingView.AddSplit(docToAdd, '', stack); } }); stack.header?.controlsContainer .find('.lm_close') //get the close icon .off('click') //unbind the current click handler .click( action(() => { //if (confirm('really close this?')) { if (!stack.parent.parent.isRoot || stack.parent.contentItems.length > 1) { stack.remove(); } else { alert('cant delete the last stack'); } }) ); stack.header?.controlsContainer .find('.lm_maximise') //get the close icon .click(() => setTimeout(this.stateChanged)); stack.header?.controlsContainer .find('.lm_popout') //get the popout icon .off('click') //unbind the current click handler .click( action(() => { // stack.config.fixed = !stack.config.fixed; // force the stack to have a fixed size const dashboard = Doc.ActiveDashboard; if (dashboard) { dashboard['pane-count'] = NumCast(dashboard['pane-count']) + 1; const docToAdd = Docs.Create.FreeformDocument([], { _width: this.props.PanelWidth(), _height: this.props.PanelHeight(), _fitWidth: true, _backgroundGridShow: true, title: `Untitled Tab ${NumCast(dashboard['pane-count'])}`, }); this.props.Document.isShared && inheritParentAcls(this.props.Document, docToAdd); CollectionDockingView.AddSplit(docToAdd, '', stack); } }) ); }; render() { return
; } } ScriptingGlobals.add( function openInLightbox(doc: any) { LightboxView.AddDocTab(doc, 'lightbox'); }, 'opens up document in a lightbox', '(doc: any)' ); ScriptingGlobals.add( function openOnRight(doc: any) { return CollectionDockingView.AddSplit(doc, 'right'); }, 'opens up document in tab on right side of the screen', '(doc: any)' ); ScriptingGlobals.add( function openInOverlay(doc: any) { return Doc.AddDocToList(Doc.MyOverlayDocs, undefined, doc); }, 'opens up document in screen overlay layer', '(doc: any)' ); ScriptingGlobals.add(function useRightSplit(doc: any, shiftKey?: boolean) { CollectionDockingView.ReplaceTab(doc, 'right', undefined, shiftKey); });