import * as React from "react"; import { createPortal } from "react-dom"; import { DockLocation } from "../DockLocation"; import { DragDrop } from "../DragDrop"; import { DropInfo } from "../DropInfo"; import { I18nLabel } from "../I18nLabel"; import { Action } from "../model/Action"; import { Actions } from "../model/Actions"; import { BorderNode } from "../model/BorderNode"; import { BorderSet } from "../model/BorderSet"; import { IDraggable } from "../model/IDraggable"; import { Model, ILayoutMetrics } from "../model/Model"; import { Node } from "../model/Node"; import { RowNode } from "../model/RowNode"; import { SplitterNode } from "../model/SplitterNode"; import { TabNode } from "../model/TabNode"; import { TabSetNode } from "../model/TabSetNode"; import { Rect } from "../Rect"; import { CLASSES } from "../Types"; import { BorderTabSet } from "./BorderTabSet"; import { Splitter } from "./Splitter"; import { Tab } from "./Tab"; import { TabSet } from "./TabSet"; import { FloatingWindow } from "./FloatingWindow"; import { FloatingWindowTab } from "./FloatingWindowTab"; import { TabFloating } from "./TabFloating"; import { IJsonTabNode } from "../model/IJsonModel"; import { Orientation } from "../Orientation"; import { CloseIcon, EdgeIcon, MaximizeIcon, OverflowIcon, PopoutIcon, RestoreIcon } from "./Icons"; import { TabButtonStamp } from "./TabButtonStamp"; export type CustomDragCallback = (dragging: TabNode | IJsonTabNode, over: TabNode, x: number, y: number, location: DockLocation) => void; export type DragRectRenderCallback = (content: React.ReactElement | undefined, node?: Node, json?: IJsonTabNode) => React.ReactElement | undefined; export type FloatingTabPlaceholderRenderCallback = (dockPopout: () => void, showPopout: () => void) => React.ReactElement | undefined; export type NodeMouseEvent = (node: TabNode | TabSetNode | BorderNode, event: React.MouseEvent) => void; export type ShowOverflowMenuCallback = ( node: TabSetNode | BorderNode, mouseEvent: React.MouseEvent, items: { index: number; node: TabNode }[], onSelect: (item: { index: number; node: TabNode }) => void, ) => void; export type TabSetPlaceHolderCallback = (node: TabSetNode) => React.ReactNode; export type IconFactory = (node: TabNode) => React.ReactNode; export type TitleFactory = (node: TabNode) => ITitleObject | React.ReactNode; export interface ILayoutProps { model: Model; factory: (node: TabNode) => React.ReactNode; font?: IFontValues; fontFamily?: string; iconFactory?: IconFactory; titleFactory?: TitleFactory; icons?: IIcons; onAction?: (action: Action) => Action | undefined; onRenderTab?: ( node: TabNode, renderValues: ITabRenderValues, // change the values in this object as required ) => void; onRenderTabSet?: ( tabSetNode: TabSetNode | BorderNode, renderValues: ITabSetRenderValues, // change the values in this object as required ) => void; onModelChange?: (model: Model, action: Action) => void; onExternalDrag?: (event: React.DragEvent) => undefined | { dragText: string, json: any, onDrop?: (node?: Node, event?: Event) => void }; classNameMapper?: (defaultClassName: string) => string; i18nMapper?: (id: I18nLabel, param?: string) => string | undefined; supportsPopout?: boolean | undefined; popoutURL?: string | undefined; realtimeResize?: boolean | undefined; onTabDrag?: (dragging: TabNode | IJsonTabNode, over: TabNode, x: number, y: number, location: DockLocation, refresh: () => void) => undefined | { x: number, y: number, width: number, height: number, callback: CustomDragCallback, // Called once when `callback` is not going to be called anymore (user canceled the drag, moved mouse and you returned a different callback, etc) invalidated?: () => void, cursor?: string | undefined }; onRenderDragRect?: DragRectRenderCallback; onRenderFloatingTabPlaceholder?: FloatingTabPlaceholderRenderCallback; onContextMenu?: NodeMouseEvent; onAuxMouseClick?: NodeMouseEvent; onShowOverflowMenu?: ShowOverflowMenuCallback; onTabSetPlaceHolder?: TabSetPlaceHolderCallback; } export interface IFontValues { size?: string; family?: string; style?: string; weight?: string; } export interface ITabSetRenderValues { headerContent?: React.ReactNode; centerContent?: React.ReactNode; stickyButtons: React.ReactNode[]; buttons: React.ReactNode[]; headerButtons: React.ReactNode[]; // position to insert overflow button within [...stickyButtons, ...buttons] // if left undefined position will be after the sticky buttons (if any) overflowPosition: number | undefined; } export interface ITabRenderValues { leading: React.ReactNode; content: React.ReactNode; name: string; buttons: React.ReactNode[]; } export interface ITitleObject { titleContent: React.ReactNode; name: string; } export interface ILayoutState { rect: Rect; calculatedHeaderBarSize: number; calculatedTabBarSize: number; calculatedBorderBarSize: number; editingTab?: TabNode; showHiddenBorder: DockLocation; portal?: React.ReactPortal; showEdges?: boolean; } export interface IIcons { close?: (React.ReactNode | ((tabNode: TabNode) => React.ReactNode)); closeTabset?: (React.ReactNode | ((tabSetNode: TabSetNode) => React.ReactNode)); popout?: (React.ReactNode | ((tabNode: TabNode) => React.ReactNode)); maximize?: (React.ReactNode | ((tabSetNode: TabSetNode) => React.ReactNode)); restore?: (React.ReactNode | ((tabSetNode: TabSetNode) => React.ReactNode)); more?: (React.ReactNode | ((tabSetNode: (TabSetNode | BorderNode), hiddenTabs: { node: TabNode; index: number }[]) => React.ReactNode)); edgeArrow?: React.ReactNode ; } const defaultIcons = { close: , closeTabset: , popout: , maximize: , restore: , more: , edgeArrow: }; export interface ICustomDropDestination { rect: Rect; callback: CustomDragCallback; invalidated: (() => void) | undefined; dragging: TabNode | IJsonTabNode; over: TabNode; x: number; y: number; location: DockLocation; cursor: string | undefined; } /** @internal */ export interface ILayoutCallbacks { i18nName(id: I18nLabel, param?: string): string; maximize(tabsetNode: TabSetNode): void; getPopoutURL(): string; isSupportsPopout(): boolean; isRealtimeResize(): boolean; getCurrentDocument(): Document | undefined; getClassName(defaultClassName: string): string; doAction(action: Action): Node | undefined; getDomRect(): DOMRect | undefined; getRootDiv(): HTMLDivElement | null; dragStart( event: Event | React.MouseEvent | React.TouchEvent | React.DragEvent | undefined, dragDivText: string | undefined, node: Node & IDraggable, allowDrag: boolean, onClick?: (event: Event) => void, onDoubleClick?: (event: Event) => void ): void; customizeTab( tabNode: TabNode, renderValues: ITabRenderValues, ): void; customizeTabSet( tabSetNode: TabSetNode | BorderNode, renderValues: ITabSetRenderValues, ): void; styleFont: (style: Record) => Record; setEditingTab(tabNode?: TabNode): void; getEditingTab(): TabNode | undefined; getOnRenderFloatingTabPlaceholder(): FloatingTabPlaceholderRenderCallback | undefined; showContextMenu(node: TabNode | TabSetNode | BorderNode, event: React.MouseEvent): void; auxMouseClick(node: TabNode | TabSetNode | BorderNode, event: React.MouseEvent): void; showPortal: (portal: React.ReactNode, portalDiv: HTMLDivElement) => void; hidePortal: () => void; getShowOverflowMenu(): ShowOverflowMenuCallback | undefined; getTabSetPlaceHolderCallback(): TabSetPlaceHolderCallback | undefined; } // Popout windows work in latest browsers based on webkit (Chrome, Opera, Safari, latest Edge) and Firefox. They do // not work on any version if IE or the original Edge browser // Assume any recent desktop browser not IE or original Edge will work /** @internal */ // @ts-ignore const isIEorEdge = typeof window !== "undefined" && (window.document.documentMode || /Edge\//.test(window.navigator.userAgent)); /** @internal */ const isDesktop = typeof window !== "undefined" && window.matchMedia && window.matchMedia("(hover: hover) and (pointer: fine)").matches; /** @internal */ const defaultSupportsPopout: boolean = isDesktop && !isIEorEdge; /** * A React component that hosts a multi-tabbed layout */ export class Layout extends React.Component { /** @internal */ private selfRef: React.RefObject; /** @internal */ private findHeaderBarSizeRef: React.RefObject; /** @internal */ private findTabBarSizeRef: React.RefObject; /** @internal */ private findBorderBarSizeRef: React.RefObject; /** @internal */ private previousModel?: Model; /** @internal */ private centerRect?: Rect; /** @internal */ // private start: number = 0; /** @internal */ // private layoutTime: number = 0; /** @internal */ private tabIds: string[]; /** @internal */ private newTabJson: IJsonTabNode | undefined; /** @internal */ private firstMove: boolean = false; /** @internal */ private dragNode?: Node & IDraggable; /** @internal */ private dragDiv?: HTMLDivElement; /** @internal */ private dragRectRendered: boolean = true; /** @internal */ private dragDivText: string | undefined = undefined; /** @internal */ private dropInfo: DropInfo | undefined; /** @internal */ private customDrop: ICustomDropDestination | undefined; /** @internal */ private outlineDiv?: HTMLDivElement; /** @internal */ private edgeRectLength = 100; /** @internal */ private edgeRectWidth = 10; /** @internal */ private fnNewNodeDropped?: (node?: Node, event?: Event) => void; /** @internal */ private currentDocument?: Document; /** @internal */ private currentWindow?: Window; /** @internal */ private supportsPopout: boolean; /** @internal */ private popoutURL: string; /** @internal */ private icons: IIcons; /** @internal */ private resizeObserver?: ResizeObserver; constructor(props: ILayoutProps) { super(props); this.props.model._setChangeListener(this.onModelChange); this.tabIds = []; this.selfRef = React.createRef(); this.findHeaderBarSizeRef = React.createRef(); this.findTabBarSizeRef = React.createRef(); this.findBorderBarSizeRef = React.createRef(); this.supportsPopout = props.supportsPopout !== undefined ? props.supportsPopout : defaultSupportsPopout; this.popoutURL = props.popoutURL ? props.popoutURL : "popout.html"; this.icons = { ...defaultIcons, ...props.icons }; this.state = { rect: new Rect(0, 0, 0, 0), calculatedHeaderBarSize: 25, calculatedTabBarSize: 26, calculatedBorderBarSize: 30, editingTab: undefined, showHiddenBorder: DockLocation.CENTER, showEdges: false, }; this.onDragEnter = this.onDragEnter.bind(this); } /** @internal */ styleFont(style: Record): Record { if (this.props.font) { if (this.selfRef.current) { if (this.props.font.size) { this.selfRef.current.style.setProperty("--font-size", this.props.font.size); } if (this.props.font.family) { this.selfRef.current.style.setProperty("--font-family", this.props.font.family); } } if (this.props.font.style) { style.fontStyle = this.props.font.style; } if (this.props.font.weight) { style.fontWeight = this.props.font.weight; } } return style; } /** @internal */ onModelChange = (action: Action) => { this.forceUpdate(); if (this.props.onModelChange) { this.props.onModelChange(this.props.model, action); } }; /** @internal */ doAction(action: Action): Node | undefined { if (this.props.onAction !== undefined) { const outcome = this.props.onAction(action); if (outcome !== undefined) { return this.props.model.doAction(outcome); } return undefined; } else { return this.props.model.doAction(action); } } /** @internal */ componentDidMount() { this.updateRect(); this.updateLayoutMetrics(); // need to re-render if size changes this.currentDocument = (this.selfRef.current as HTMLDivElement).ownerDocument; this.currentWindow = this.currentDocument.defaultView!; this.resizeObserver = new ResizeObserver(entries => { this.updateRect(entries[0].contentRect); }); const selfRefCurr = this.selfRef.current; if (selfRefCurr) { this.resizeObserver.observe(selfRefCurr); } } /** @internal */ componentDidUpdate() { this.updateLayoutMetrics(); if (this.props.model !== this.previousModel) { if (this.previousModel !== undefined) { this.previousModel._setChangeListener(undefined); // stop listening to old model } this.props.model._setChangeListener(this.onModelChange); this.previousModel = this.props.model; } // console.log("Layout time: " + this.layoutTime + "ms Render time: " + (Date.now() - this.start) + "ms"); } /** @internal */ updateRect = (domRect?: DOMRectReadOnly) => { if (!domRect) { domRect = this.getDomRect(); } if (!domRect) { // no dom rect available, return. return; } const rect = new Rect(0, 0, domRect.width, domRect.height); if (!rect.equals(this.state.rect) && rect.width !== 0 && rect.height !== 0) { this.setState({ rect }); } }; /** @internal */ updateLayoutMetrics = () => { if (this.findHeaderBarSizeRef.current) { const headerBarSize = this.findHeaderBarSizeRef.current.getBoundingClientRect().height; if (headerBarSize !== this.state.calculatedHeaderBarSize) { this.setState({ calculatedHeaderBarSize: headerBarSize }); } } if (this.findTabBarSizeRef.current) { const tabBarSize = this.findTabBarSizeRef.current.getBoundingClientRect().height; if (tabBarSize !== this.state.calculatedTabBarSize) { this.setState({ calculatedTabBarSize: tabBarSize }); } } if (this.findBorderBarSizeRef.current) { const borderBarSize = this.findBorderBarSizeRef.current.getBoundingClientRect().height; if (borderBarSize !== this.state.calculatedBorderBarSize) { this.setState({ calculatedBorderBarSize: borderBarSize }); } } }; /** @internal */ getClassName = (defaultClassName: string) => { if (this.props.classNameMapper === undefined) { return defaultClassName; } else { return this.props.classNameMapper(defaultClassName); } }; /** @internal */ getCurrentDocument() { return this.currentDocument; } /** @internal */ getDomRect() { return this.selfRef.current?.getBoundingClientRect(); } /** @internal */ getRootDiv() { return this.selfRef.current; } /** @internal */ isSupportsPopout() { return this.supportsPopout; } /** @internal */ isRealtimeResize() { return this.props.realtimeResize ?? false; } /** @internal */ onTabDrag(...args: Parameters['onTabDrag']>) { return this.props.onTabDrag?.(...args); } /** @internal */ getPopoutURL() { return this.popoutURL; } /** @internal */ componentWillUnmount() { const selfRefCurr = this.selfRef.current; if (selfRefCurr) { this.resizeObserver?.unobserve(selfRefCurr); } } /** @internal */ setEditingTab(tabNode?: TabNode) { this.setState({ editingTab: tabNode }); } /** @internal */ getEditingTab() { return this.state.editingTab; } /** @internal */ render() { // first render will be used to find the size (via selfRef) if (!this.selfRef.current) { return (
{this.metricsElements()}
); } this.props.model._setPointerFine(window && window.matchMedia && window.matchMedia("(pointer: fine)").matches); // this.start = Date.now(); const borderComponents: React.ReactNode[] = []; const tabSetComponents: React.ReactNode[] = []; const floatingWindows: React.ReactNode[] = []; const tabComponents: Record = {}; const splitterComponents: React.ReactNode[] = []; const metrics: ILayoutMetrics = { headerBarSize: this.state.calculatedHeaderBarSize, tabBarSize: this.state.calculatedTabBarSize, borderBarSize: this.state.calculatedBorderBarSize }; this.props.model._setShowHiddenBorder(this.state.showHiddenBorder); this.centerRect = this.props.model._layout(this.state.rect, metrics); this.renderBorder(this.props.model.getBorderSet(), borderComponents, tabComponents, floatingWindows, splitterComponents); this.renderChildren("", this.props.model.getRoot(), tabSetComponents, tabComponents, floatingWindows, splitterComponents); const nextTopIds: string[] = []; const nextTopIdsMap: Record = {}; // Keep any previous tabs in the same DOM order as before, removing any that have been deleted for (const t of this.tabIds) { if (tabComponents[t]) { nextTopIds.push(t); nextTopIdsMap[t] = t; } } this.tabIds = nextTopIds; // Add tabs that have been added to the DOM for (const t of Object.keys(tabComponents)) { if (!nextTopIdsMap[t]) { this.tabIds.push(t); } } const edges: React.ReactNode[] = []; const arrowIcon = this.icons.edgeArrow; if (this.state.showEdges) { const r = this.centerRect; const length = this.edgeRectLength; const width = this.edgeRectWidth; const offset = this.edgeRectLength / 2; const className = this.getClassName(CLASSES.FLEXLAYOUT__EDGE_RECT); const radius = 50; edges.push(
{arrowIcon}
); edges.push(
{arrowIcon}
); edges.push(
{arrowIcon}
); edges.push(
{arrowIcon}
); } // this.layoutTime = (Date.now() - this.start); return (
{tabSetComponents} {this.tabIds.map((t) => { return tabComponents[t]; })} {borderComponents} {splitterComponents} {edges} {floatingWindows} {this.metricsElements()} {this.state.portal}
); } /** @internal */ metricsElements() { // used to measure the tab and border tab sizes const fontStyle = this.styleFont({ visibility: "hidden" }); return (
FindHeaderBarSize
FindTabBarSize
FindBorderBarSize
); } /** @internal */ onCloseWindow = (id: string) => { this.doAction(Actions.unFloatTab(id)); try { (this.props.model.getNodeById(id) as TabNode)._setWindow(undefined); } catch (e) { // catch incase it was a model change } }; /** @internal */ onSetWindow = (id: string, window: Window) => { (this.props.model.getNodeById(id) as TabNode)._setWindow(window); }; /** @internal */ renderBorder(borderSet: BorderSet, borderComponents: React.ReactNode[], tabComponents: Record, floatingWindows: React.ReactNode[], splitterComponents: React.ReactNode[]) { for (const border of borderSet.getBorders()) { const borderPath = `/border/${border.getLocation().getName()}`; if (border.isShowing()) { borderComponents.push( ); const drawChildren = border._getDrawChildren(); let i = 0; let tabCount = 0; for (const child of drawChildren) { if (child instanceof SplitterNode) { let path = borderPath + "/s"; splitterComponents.push(); } else if (child instanceof TabNode) { let path = borderPath + "/t" + tabCount++; if (this.supportsPopout && child.isFloating()) { const rect = this._getScreenRect(child); const tabBorderWidth = child._getAttr("borderWidth"); const tabBorderHeight = child._getAttr("borderHeight"); if (rect) { if (tabBorderWidth !== -1 && border.getLocation().getOrientation() === Orientation.HORZ) { rect.width = tabBorderWidth; } else if (tabBorderHeight !== -1 && border.getLocation().getOrientation() === Orientation.VERT) { rect.height = tabBorderHeight; } } floatingWindows.push( ); tabComponents[child.getId()] = ; } else { tabComponents[child.getId()] = ; } } i++; } } } } /** @internal */ renderChildren(path: string, node: RowNode | TabSetNode, tabSetComponents: React.ReactNode[], tabComponents: Record, floatingWindows: React.ReactNode[], splitterComponents: React.ReactNode[]) { const drawChildren = node._getDrawChildren(); let splitterCount = 0; let tabCount = 0; let rowCount = 0; for (const child of drawChildren!) { if (child instanceof SplitterNode) { const newPath = path + "/s" + (splitterCount++); splitterComponents.push(); } else if (child instanceof TabSetNode) { const newPath = path + "/ts" + (rowCount++); tabSetComponents.push(); this.renderChildren(newPath, child, tabSetComponents, tabComponents, floatingWindows, splitterComponents); } else if (child instanceof TabNode) { const newPath = path + "/t" + (tabCount++); const selectedTab = child.getParent()!.getChildren()[(child.getParent() as TabSetNode).getSelected()]; if (selectedTab === undefined) { // this should not happen! console.warn("undefined selectedTab should not happen"); } if (this.supportsPopout && child.isFloating()) { const rect = this._getScreenRect(child); floatingWindows.push( ); tabComponents[child.getId()] = ; } else { tabComponents[child.getId()] = ; } } else { // is row const newPath = path + ((child.getOrientation() === Orientation.HORZ) ? "/r" : "/c") + (rowCount++); this.renderChildren(newPath, child as RowNode, tabSetComponents, tabComponents, floatingWindows, splitterComponents); } } } /** @internal */ _getScreenRect(node: TabNode) { const rect = node!.getRect()!.clone(); const bodyRect: DOMRect | undefined = this.selfRef.current?.getBoundingClientRect(); if (!bodyRect) { return null; } const navHeight = Math.min(80, this.currentWindow!.outerHeight - this.currentWindow!.innerHeight); const navWidth = Math.min(80, this.currentWindow!.outerWidth - this.currentWindow!.innerWidth); rect.x = rect.x + bodyRect.x + this.currentWindow!.screenX + navWidth; rect.y = rect.y + bodyRect.y + this.currentWindow!.screenY + navHeight; return rect; } /** * Adds a new tab to the given tabset * @param tabsetId the id of the tabset where the new tab will be added * @param json the json for the new tab node * @returns the added tab node or undefined */ addTabToTabSet(tabsetId: string, json: IJsonTabNode) : TabNode | undefined { const tabsetNode = this.props.model.getNodeById(tabsetId); if (tabsetNode !== undefined) { const node = this.doAction(Actions.addNode(json, tabsetId, DockLocation.CENTER, -1)); return node as TabNode; } return undefined; } /** * Adds a new tab to the active tabset (if there is one) * @param json the json for the new tab node * @returns the added tab node or undefined */ addTabToActiveTabSet(json: IJsonTabNode) : TabNode | undefined { const tabsetNode = this.props.model.getActiveTabset(); if (tabsetNode !== undefined) { const node = this.doAction(Actions.addNode(json, tabsetNode.getId(), DockLocation.CENTER, -1)); return node as TabNode; } return undefined; } /** * Adds a new tab by dragging a labeled panel to the drop location, dragging starts immediatelly * @param dragText the text to show on the drag panel * @param json the json for the new tab node * @param onDrop a callback to call when the drag is complete (node and event will be undefined if the drag was cancelled) */ addTabWithDragAndDrop(dragText: string | undefined, json: IJsonTabNode, onDrop?: (node?: Node, event?: Event) => void) { this.fnNewNodeDropped = onDrop; this.newTabJson = json; this.dragStart(undefined, dragText, TabNode._fromJson(json, this.props.model, false), true, undefined, undefined); } /** * Move a tab/tabset using drag and drop * @param node the tab or tabset to drag * @param dragText the text to show on the drag panel */ moveTabWithDragAndDrop(node: (TabNode | TabSetNode), dragText?: string) { this.dragStart(undefined, dragText, node, true, undefined, undefined); } /** * Adds a new tab by dragging a labeled panel to the drop location, dragging starts when you * mouse down on the panel * * @param dragText the text to show on the drag panel * @param json the json for the new tab node * @param onDrop a callback to call when the drag is complete (node and event will be undefined if the drag was cancelled) */ addTabWithDragAndDropIndirect(dragText: string | undefined, json: IJsonTabNode, onDrop?: (node?: Node, event?: Event) => void) { this.fnNewNodeDropped = onDrop; this.newTabJson = json; DragDrop.instance.addGlass(this.onCancelAdd); this.dragDivText = dragText; this.dragDiv = this.currentDocument!.createElement("div"); this.dragDiv.className = this.getClassName(CLASSES.FLEXLAYOUT__DRAG_RECT); this.dragDiv.addEventListener("mousedown", this.onDragDivMouseDown); this.dragDiv.addEventListener("touchstart", this.onDragDivMouseDown, { passive: false }); this.dragRectRender(this.dragDivText, undefined, this.newTabJson, () => { if (this.dragDiv) { // now it's been rendered into the dom it can be centered this.dragDiv.style.visibility = "visible"; const domRect = this.dragDiv.getBoundingClientRect(); const r = new Rect(0, 0, domRect?.width, domRect?.height); r.centerInRect(this.state.rect); this.dragDiv.setAttribute("data-layout-path", "/drag-rectangle"); this.dragDiv.style.left = r.x + "px"; this.dragDiv.style.top = r.y + "px"; } }); const rootdiv = this.selfRef.current; rootdiv!.appendChild(this.dragDiv); } /** @internal */ onCancelAdd = () => { const rootdiv = this.selfRef.current; if (rootdiv && this.dragDiv) { rootdiv.removeChild(this.dragDiv); } this.dragDiv = undefined; this.hidePortal(); if (this.fnNewNodeDropped != null) { this.fnNewNodeDropped(); this.fnNewNodeDropped = undefined; } try { this.customDrop?.invalidated?.() } catch (e) { console.error(e) } DragDrop.instance.hideGlass(); this.newTabJson = undefined; this.customDrop = undefined; }; /** @internal */ onCancelDrag = (wasDragging: boolean) => { if (wasDragging) { const rootdiv = this.selfRef.current; const outlineDiv = this.outlineDiv; if (rootdiv && outlineDiv) { try { rootdiv.removeChild(outlineDiv); } catch (e) {} } const dragDiv = this.dragDiv; if (rootdiv && dragDiv) { try { rootdiv.removeChild(dragDiv); } catch (e) {} } this.dragDiv = undefined; this.hidePortal(); this.setState({ showEdges: false }); if (this.fnNewNodeDropped != null) { this.fnNewNodeDropped(); this.fnNewNodeDropped = undefined; } try { this.customDrop?.invalidated?.() } catch (e) { console.error(e) } DragDrop.instance.hideGlass(); this.newTabJson = undefined; this.customDrop = undefined; } this.setState({ showHiddenBorder: DockLocation.CENTER }); }; /** @internal */ onDragDivMouseDown = (event: Event) => { event.preventDefault(); this.dragStart(event, this.dragDivText, TabNode._fromJson(this.newTabJson, this.props.model, false), true, undefined, undefined); }; /** @internal */ dragStart = ( event: Event | React.MouseEvent | React.TouchEvent | React.DragEvent | undefined, dragDivText: string | undefined, node: Node & IDraggable, allowDrag: boolean, onClick?: (event: Event) => void, onDoubleClick?: (event: Event) => void ) => { if (!allowDrag) { DragDrop.instance.startDrag( event, undefined, undefined, undefined, undefined, onClick, onDoubleClick, this.currentDocument, this.selfRef.current ?? undefined ); } else { this.dragNode = node; this.dragDivText = dragDivText; DragDrop.instance.startDrag( event, this.onDragStart, this.onDragMove, this.onDragEnd, this.onCancelDrag, onClick, onDoubleClick, this.currentDocument, this.selfRef.current ?? undefined ); } }; /** @internal */ dragRectRender = (text: String | undefined, node?: Node, json?: IJsonTabNode, onRendered?: () => void) => { let content: React.ReactElement | undefined; if (text !== undefined) { content =
{text.replace("
", "\n")}
; } else { if (node && node instanceof TabNode) { content = (); } } if (this.props.onRenderDragRect !== undefined) { const customContent = this.props.onRenderDragRect(content, node, json); if (customContent !== undefined) { content = customContent; } } // hide div until the render is complete this.dragRectRendered = false; const dragDiv = this.dragDiv; if (dragDiv) { dragDiv.style.visibility = "hidden"; this.showPortal( { this.dragRectRendered = true; onRendered?.(); }}> {content} , dragDiv, ); } }; /** @internal */ showPortal = (control: React.ReactNode, element: HTMLElement) => { const portal = createPortal(control, element) as React.ReactPortal; this.setState({ portal }); }; /** @internal */ hidePortal = () => { this.setState({ portal: undefined }); }; /** @internal */ onDragStart = () => { this.dropInfo = undefined; this.customDrop = undefined; const rootdiv = this.selfRef.current; this.outlineDiv = this.currentDocument!.createElement("div"); this.outlineDiv.className = this.getClassName(CLASSES.FLEXLAYOUT__OUTLINE_RECT); this.outlineDiv.style.visibility = "hidden"; if (rootdiv) { rootdiv.appendChild(this.outlineDiv); } if (this.dragDiv == null) { this.dragDiv = this.currentDocument!.createElement("div"); this.dragDiv.className = this.getClassName(CLASSES.FLEXLAYOUT__DRAG_RECT); this.dragDiv.setAttribute("data-layout-path", "/drag-rectangle"); this.dragRectRender(this.dragDivText, this.dragNode, this.newTabJson); if (rootdiv) { rootdiv.appendChild(this.dragDiv); } } // add edge indicators if (this.props.model.getMaximizedTabset() === undefined) { this.setState({ showEdges: this.props.model.isEnableEdgeDock() }); } if (this.dragNode && this.outlineDiv && this.dragNode instanceof TabNode && this.dragNode.getTabRect() !== undefined) { this.dragNode.getTabRect()?.positionElement(this.outlineDiv); } this.firstMove = true; return true; }; /** @internal */ onDragMove = (event: React.MouseEvent) => { if (this.firstMove === false) { const speed = this.props.model._getAttribute("tabDragSpeed") as number; if (this.outlineDiv) { this.outlineDiv.style.transition = `top ${speed}s, left ${speed}s, width ${speed}s, height ${speed}s`; } } this.firstMove = false; const clientRect = this.selfRef.current?.getBoundingClientRect(); const pos = { x: event.clientX - (clientRect?.left ?? 0), y: event.clientY - (clientRect?.top ?? 0), }; this.checkForBorderToShow(pos.x, pos.y); // keep it between left & right const dragRect = this.dragDiv?.getBoundingClientRect() ?? new DOMRect(0, 0, 100, 100); let newLeft = pos.x - dragRect.width / 2; if (newLeft + dragRect.width > (clientRect?.width ?? 0)) { newLeft = (clientRect?.width ?? 0) - dragRect.width; } newLeft = Math.max(0, newLeft); if (this.dragDiv) { this.dragDiv.style.left = newLeft + "px"; this.dragDiv.style.top = pos.y + 5 + "px"; if (this.dragRectRendered && this.dragDiv.style.visibility === "hidden") { // make visible once the drag rect has been rendered this.dragDiv.style.visibility = "visible"; } } let dropInfo = this.props.model._findDropTargetNode(this.dragNode!, pos.x, pos.y); if (dropInfo) { if (this.props.onTabDrag) { this.handleCustomTabDrag(dropInfo, pos, event); } else { this.dropInfo = dropInfo; if (this.outlineDiv) { this.outlineDiv.className = this.getClassName(dropInfo.className); dropInfo.rect.positionElement(this.outlineDiv); this.outlineDiv.style.visibility = "visible"; } } } }; /** @internal */ onDragEnd = (event: Event) => { const rootdiv = this.selfRef.current; if (rootdiv) { if (this.outlineDiv) { rootdiv.removeChild(this.outlineDiv); } if (this.dragDiv) { rootdiv.removeChild(this.dragDiv); } } this.dragDiv = undefined; this.hidePortal(); this.setState({ showEdges: false }); DragDrop.instance.hideGlass(); if (this.dropInfo) { if (this.customDrop) { this.newTabJson = undefined; try { const { callback, dragging, over, x, y, location } = this.customDrop; callback(dragging, over, x, y, location); if (this.fnNewNodeDropped != null) { this.fnNewNodeDropped(); this.fnNewNodeDropped = undefined; } } catch (e) { console.error(e) } } else if (this.newTabJson !== undefined) { const newNode = this.doAction(Actions.addNode(this.newTabJson, this.dropInfo.node.getId(), this.dropInfo.location, this.dropInfo.index)); if (this.fnNewNodeDropped != null) { this.fnNewNodeDropped(newNode, event); this.fnNewNodeDropped = undefined; } this.newTabJson = undefined; } else if (this.dragNode !== undefined) { this.doAction(Actions.moveNode(this.dragNode.getId(), this.dropInfo.node.getId(), this.dropInfo.location, this.dropInfo.index)); } } this.setState({ showHiddenBorder: DockLocation.CENTER }); }; /** @internal */ private handleCustomTabDrag(dropInfo: DropInfo, pos: { x: number; y: number; }, event: React.MouseEvent) { let invalidated = this.customDrop?.invalidated; const currentCallback = this.customDrop?.callback; this.customDrop = undefined; const dragging = this.newTabJson || (this.dragNode instanceof TabNode ? this.dragNode : undefined); if (dragging && (dropInfo.node instanceof TabSetNode || dropInfo.node instanceof BorderNode) && dropInfo.index === -1) { const selected = dropInfo.node.getSelectedNode() as TabNode | undefined; const tabRect = selected?.getRect(); if (selected && tabRect?.contains(pos.x, pos.y)) { let customDrop: ICustomDropDestination | undefined = undefined; try { const dest = this.onTabDrag(dragging, selected, pos.x - tabRect.x, pos.y - tabRect.y, dropInfo.location, () => this.onDragMove(event)); if (dest) { customDrop = { rect: new Rect(dest.x + tabRect.x, dest.y + tabRect.y, dest.width, dest.height), callback: dest.callback, invalidated: dest.invalidated, dragging: dragging, over: selected, x: pos.x - tabRect.x, y: pos.y - tabRect.y, location: dropInfo.location, cursor: dest.cursor }; } } catch (e) { console.error(e); } if (customDrop?.callback === currentCallback) { invalidated = undefined; } this.customDrop = customDrop; } } this.dropInfo = dropInfo; if (this.outlineDiv) { this.outlineDiv.className = this.getClassName(this.customDrop ? CLASSES.FLEXLAYOUT__OUTLINE_RECT : dropInfo.className); if (this.customDrop) { this.customDrop.rect.positionElement(this.outlineDiv); } else { dropInfo.rect.positionElement(this.outlineDiv); } } DragDrop.instance.setGlassCursorOverride(this.customDrop?.cursor); if (this.outlineDiv) { this.outlineDiv.style.visibility = "visible"; } try { invalidated?.(); } catch (e) { console.error(e); } } /** @internal */ onDragEnter(event: React.DragEvent) { // DragDrop keeps track of number of dragenters minus the number of // dragleaves. Only start a new drag if there isn't one already. if (DragDrop.instance.isDragging()) return; const drag = this.props.onExternalDrag!(event); if (drag) { // Mimic addTabWithDragAndDrop, but pass in DragEvent this.fnNewNodeDropped = drag.onDrop; this.newTabJson = drag.json; this.dragStart(event, drag.dragText, TabNode._fromJson(drag.json, this.props.model, false), true, undefined, undefined); } } /** @internal */ checkForBorderToShow(x: number, y: number) { const r = this.props.model._getOuterInnerRects().outer; const c = r.getCenter(); const margin = this.edgeRectWidth; const offset = this.edgeRectLength / 2; let overEdge = false; if (this.props.model.isEnableEdgeDock() && this.state.showHiddenBorder === DockLocation.CENTER) { if ((y > c.y - offset && y < c.y + offset) || (x > c.x - offset && x < c.x + offset)) { overEdge = true; } } let location = DockLocation.CENTER; if (!overEdge) { if (x <= r.x + margin) { location = DockLocation.LEFT; } else if (x >= r.getRight() - margin) { location = DockLocation.RIGHT; } else if (y <= r.y + margin) { location = DockLocation.TOP; } else if (y >= r.getBottom() - margin) { location = DockLocation.BOTTOM; } } if (location !== this.state.showHiddenBorder) { this.setState({ showHiddenBorder: location }); } } /** @internal */ maximize(tabsetNode: TabSetNode) { this.doAction(Actions.maximizeToggle(tabsetNode.getId())); } /** @internal */ customizeTab( tabNode: TabNode, renderValues: ITabRenderValues, ) { if (this.props.onRenderTab) { this.props.onRenderTab(tabNode, renderValues); } } /** @internal */ customizeTabSet( tabSetNode: TabSetNode | BorderNode, renderValues: ITabSetRenderValues, ) { if (this.props.onRenderTabSet) { this.props.onRenderTabSet(tabSetNode, renderValues); } } /** @internal */ i18nName(id: I18nLabel, param?: string) { let message; if (this.props.i18nMapper) { message = this.props.i18nMapper(id, param); } if (message === undefined) { message = id + (param === undefined ? "" : param); } return message; } /** @internal */ getOnRenderFloatingTabPlaceholder() { return this.props.onRenderFloatingTabPlaceholder; } /** @internal */ getShowOverflowMenu() { return this.props.onShowOverflowMenu; } /** @internal */ getTabSetPlaceHolderCallback() { return this.props.onTabSetPlaceHolder; } /** @internal */ showContextMenu(node: TabNode | TabSetNode | BorderNode, event: React.MouseEvent) { if (this.props.onContextMenu) { this.props.onContextMenu(node, event); } } /** @internal */ auxMouseClick(node: TabNode | TabSetNode | BorderNode, event: React.MouseEvent) { if (this.props.onAuxMouseClick) { this.props.onAuxMouseClick(node, event); } } } // wrapper round the drag rect renderer that can call // a method once the rendering is written to the dom /** @internal */ interface IDragRectRenderWrapper { onRendered?: () => void; children: React.ReactNode; } /** @internal */ const DragRectRenderWrapper = (props: IDragRectRenderWrapper) => { React.useEffect(() => { props.onRendered?.(); }, [props]); return ( {props.children} ) }