import React, { useRef, useMemo, useEffect, useCallback, useState } from 'react'; import DockLayout from 'rc-dock'; import PropTypes from 'prop-types'; import EventBus from '../EventBus'; import getApiInstance from '../../api_instance'; import url_for from 'sources/url_for'; import { PgIconButton } from '../../components/Buttons'; import CloseIcon from '@mui/icons-material/CloseRounded'; import gettext from 'sources/gettext'; import {ExpandDialogIcon, MinimizeDialogIcon } from '../../components/ExternalIcon'; import { Box } from '@mui/material'; import ErrorBoundary from '../ErrorBoundary'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ContextMenu from '../../components/ContextMenu'; import { showRenameTab } from '../../Dialogs'; import usePreferences from '../../../../preferences/static/js/store'; import _ from 'lodash'; function TabTitle({id, closable, defaultInternal}) { const layoutDocker = React.useContext(LayoutDockerContext); const internal = layoutDocker?.find(id)?.internal ?? defaultInternal; const [attrs, setAttrs] = useState({ icon: internal.icon, title: internal.title, tooltip: internal.tooltip ?? internal.title, }); const onContextMenu = useCallback((e)=>{ const g = layoutDocker.find(id)?.group??''; if((layoutDocker.noContextGroups??[]).includes(g)) return; e.preventDefault(); layoutDocker.eventBus.fireEvent(LAYOUT_EVENTS.CONTEXT, e, id); }, []); useEffect(()=>{ const deregister = layoutDocker.eventBus.registerListener(LAYOUT_EVENTS.REFRESH_TITLE, _.debounce((panelId)=>{ if(panelId == id) { const internal = layoutDocker?.find(id)?.internal??{}; setAttrs({ icon: internal.icon, title: internal.title, tooltip: internal.tooltip ?? internal.title, }); } }, 100)); return ()=>deregister?.(); }, []); return ( {attrs.icon && } {attrs.title} {closable && } size="xs" noBorder onClick={()=>{ layoutDocker.close(id); }} style={{margin: '-1px -10px -1px 0'}} />} ); } TabTitle.propTypes = { id: PropTypes.string, closable: PropTypes.bool, defaultInternal: PropTypes.object }; export class LayoutDocker { constructor(layoutId, defaultLayout, resetToTabPanel, noContextGroups) { this.layoutId = layoutId; this.defaultLayout = defaultLayout; /* When reset layout, we'll move the manually added tabs to this panel */ this.resetToTabPanel = resetToTabPanel; // don't show context for these groups this.noContextGroups = noContextGroups??[]; this.noContextGroups.push('dialogs'); this.layoutObj = null; this.eventBus = new EventBus(); } close(panelId, force=false) { const panelData = this.find(panelId); if(!panelData) { return; } if(!panelData.internal?.closable) { return; } if(panelData.internal?.manualClose && !force) { this.eventBus.fireEvent(LAYOUT_EVENTS.CLOSING, panelId); } else { this.layoutObj.dockMove(panelData, null, 'remove'); // rc-dock is not firing the "active" event after a tab is removed // and another is focussed. here we try get the new active id and // manually fire the active event const newActiveId = this.find(panelData?.parent?.id)?.activeId; if(newActiveId) { this.eventBus.fireEvent(LAYOUT_EVENTS.ACTIVE, newActiveId); } } } closeAll(panelId, exceptCurrent=false) { let parentData = this.find(panelId); if(_.isUndefined(parentData.tabs)) { parentData = parentData.parent; } if(parentData?.tabs) { parentData.tabs.filter((t)=>(t.internal?.closable && (exceptCurrent ? t.id!=panelId : true))).forEach((t)=>{ this.close(t.id); }); } } focus(panelId) { this.layoutObj.updateTab(panelId, null, true); } //it will navigate to nearest panel/tab navigatePanel() { this.layoutObj.navigateToPanel(); } find(...args) { return this.layoutObj?.find(...args); } setTitle(panelId, title, icon, tooltip) { const panelData = this.find(panelId); if(!panelData) return; const internal = { ...panelData.internal, }; if(title) { internal.title = title; } if(icon) { internal.icon = icon; } if(tooltip) { internal.tooltip = tooltip; } panelData.internal = internal; this.eventBus.fireEvent(LAYOUT_EVENTS.REFRESH_TITLE, panelId); } setInternalAttrs(panelId, attrs) { const panelData = this.find(panelId); panelData.internal = { ...panelData.internal, ...attrs, }; } getInternalAttrs(panelId) { const panelData = this.find(panelId); return panelData.internal; } openDialog(panelData, width=500, height=300) { let panel = this.layoutObj.find(panelData.id); if(panel) { this.layoutObj.dockMove(panel, null, 'front'); } else { let {width: lw, height: lh} = this.layoutObj.getLayoutSize(); /* position in more top direction */ lw = (lw - width)/2; lh = (lh - height)/5; this.layoutObj.dockMove({ x: lw, y: lh, w: width, h: height, tabs: [LayoutDocker.getPanel({ ...panelData, content: {panelData.content}, group: 'dialogs', closable: true, })], }, null, 'float'); } } isTabOpen(panelId) { return Boolean(this.layoutObj.find(panelId)); } isTabVisible(panelId) { let panelData = this.layoutObj?.find(panelId); return panelData?.parent?.activeId == panelData?.id; } openTab(panelData, refTabId, direction='middle', forceRerender=false) { let panel = this.layoutObj.find(panelData.id); if(panel) { if(forceRerender) { this.layoutObj.updateTab(panelData.id, LayoutDocker.getPanel(panelData), true); } else { this.focus(panelData.id); } } else { let tgtPanel = this.layoutObj.find(refTabId); this.layoutObj.dockMove(LayoutDocker.getPanel(panelData), tgtPanel, direction); } } loadLayout(savedLayout) { try { this.layoutObj.loadLayout(JSON.parse(savedLayout)); } catch { /* Fallback to default */ this.layoutObj.loadLayout(this.defaultLayout); } } saveLayout(l) { let api = getApiInstance(); if(!this.layoutId || !this.layoutObj) { return; } const formData = new FormData(); formData.append('setting', this.layoutId); formData.append('value', JSON.stringify(l || this.layoutObj.saveLayout())); api.post(url_for('settings.store_bulk'), formData) .catch(()=>{/* No need to throw error */}); } resetLayout() { const flatCurr = []; const flatDefault = []; // flatten the nested tabs into an array const flattenLayout = (box, arr)=>{ box.children.forEach((child)=>{ if(child.children) { flattenLayout(child, arr); } else { arr.push(...child.tabs??[]); } }); }; flattenLayout(this.defaultLayout.dockbox, flatDefault); flattenLayout(this.layoutObj.getLayout().dockbox, flatCurr); // Find the difference between default layout and current layout let saveNonDefaultTabs = _.differenceBy(flatCurr, flatDefault, 'id'); // load the default layout this.layoutObj.loadLayout(this.defaultLayout); const focusOn = this.find(this.resetToTabPanel)?.activeId; // restor the tabs opened saveNonDefaultTabs.forEach((t)=>{ this.openTab({ id: t.id, content: t.content, ...t.internal }, this.resetToTabPanel, 'middle'); }); focusOn && this.focus(focusOn); this.saveLayout(); } static getPanel({icon, title, closable, tooltip, renamable, manualClose, ...attrs}) { const internal = { icon: icon, title: title, tooltip: tooltip, closable: _.isUndefined(closable) ? manualClose : closable, renamable: renamable, manualClose: manualClose, }; return { cached: true, group: 'default', minWidth: 200, ...attrs, closable: false, title: , internal: internal }; } static moveTo(direction) { let dockBar = document.activeElement.closest('.dock')?.querySelector('.dock-bar.drag-initiator'); if(dockBar) { let key = { key: 'ArrowRight', keyCode: 39, which: 39, code: 'ArrowRight', metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, bubbles: true, }; if(direction == 'right') { key = { ...key, key: 'ArrowRight', keyCode: 39, which: 39, code: 'ArrowRight' }; } else if(direction == 'left') { key = { ...key, key: 'ArrowLeft', keyCode: 37, which: 37, code: 'ArrowLeft', }; } dockBar.dispatchEvent(new KeyboardEvent('keydown', key)); } } static switchPanel() { let currDockPanel = document.activeElement.closest('.dock-panel.dock-style-default'); let dockLayoutPanels = currDockPanel?.closest('.dock-layout').querySelectorAll('.dock-panel.dock-style-default'); if(dockLayoutPanels?.length > 1) { for(let i=0; i } size="xs" noBorder onClick={()=>{ layoutDocker.close(panelData.activeId); }} style={{marginRight: '-4px'}}/> ); } DialogClose.propTypes = { panelData: PropTypes.object }; function getDialogsGroup() { return { disableDock: true, tabLocked: true, floatable: 'singleTab', moreIcon: , panelExtra: (panelData) => { return ; } }; } export function getDefaultGroup() { return { closable: false, maximizable: false, floatable: false, moreIcon: , panelExtra: (panelData, context) => { let icon = ; let title = gettext('Maximise'); if(panelData?.parent?.mode == 'maximize') { icon = ; title = gettext('Restore'); } return {Boolean(panelData.maximizable) && { context.dockMove(panelData, null, 'maximize'); }} />} ; } }; } export default function Layout({groups, noContextGroups, getLayoutInstance, layoutId, savedLayout, resetToTabPanel, ...props}) { const [[contextPos, contextPanelId, contextExtraMenus], setContextPos] = React.useState([null, null, null]); const defaultGroups = React.useMemo(()=>({ 'dialogs': getDialogsGroup(), 'default': getDefaultGroup(), ...groups, }), [groups]); const layoutDockerObj = React.useMemo(()=>new LayoutDocker(layoutId, props.defaultLayout, resetToTabPanel, noContextGroups), []); const prefStore = usePreferences(); const dynamicTabsStyleRef = useRef(); useEffect(()=>{ layoutDockerObj.eventBus.registerListener(LAYOUT_EVENTS.REMOVE, (panelId)=>{ layoutDockerObj.close(panelId); }); layoutDockerObj.eventBus.registerListener(LAYOUT_EVENTS.CONTEXT, (e, id, extraMenus)=>{ setContextPos([{x: e.clientX, y: e.clientY}, id, extraMenus]); }); }, []); useEffect(()=>{ const dynamicTabs = prefStore.getPreferencesForModule('browser')?.dynamic_tabs; // Add a class to set max width for non dynamic Tabs if(!dynamicTabs && !dynamicTabsStyleRef.current) { const css = '.dock-tab:not(div.dock-tab-active) { max-width: 180px; }', head = document.head || document.getElementsByTagName('head')[0]; dynamicTabsStyleRef.current = document.createElement('style'); head.appendChild(dynamicTabsStyleRef.current); dynamicTabsStyleRef.current.appendChild(document.createTextNode(css)); } else if(dynamicTabs && dynamicTabsStyleRef.current) { dynamicTabsStyleRef.current.remove(); dynamicTabsStyleRef.current = null; } }, [prefStore]); const getTabMenuItems = (panelId)=>{ const ret = []; if(panelId) { const panelData = layoutDockerObj?.find(panelId); if(_.isUndefined(panelData.tabs)) { if(panelData.internal.closable) { ret.push({ label: gettext('Close'), callback: ()=>{ layoutDockerObj.close(panelId); } }); } if(panelData.parent?.tabs?.length > 1) { ret.push({ label: gettext('Close Others'), callback: ()=>{ layoutDockerObj.closeAll(panelId, true); } }); } } ret.push({ label: gettext('Close All'), callback: ()=>{ layoutDockerObj.closeAll(panelId); } }); if(panelData.internal?.renamable) { ret.push({ type: 'separator', }, { label: gettext('Rename'), callback: ()=>{ showRenameTab(panelId, layoutDockerObj); } }); } } return ret; }; const contextMenuItems = getTabMenuItems(contextPanelId) .concat(contextExtraMenus ? [{type: 'separator'}, ...contextExtraMenus] : []); return ( {useMemo(()=>({ if(obj) { layoutDockerObj.layoutObj = obj; getLayoutInstance?.(layoutDockerObj); layoutDockerObj.loadLayout(savedLayout); } }} groups={defaultGroups} onLayoutChange={(l, currentTabId, direction)=>{ if(Object.values(LAYOUT_EVENTS).indexOf(direction) > -1) { layoutDockerObj.eventBus.fireEvent(LAYOUT_EVENTS[direction.toUpperCase()], currentTabId); layoutDockerObj.saveLayout(l); } else if(direction && direction != 'update') { layoutDockerObj.eventBus.fireEvent(LAYOUT_EVENTS.CHANGE, currentTabId); layoutDockerObj.saveLayout(l); } }} {...props} />), [])}
setContextPos([null, null, null])} label="Layout Context Menu" />
); } Layout.propTypes = { groups: PropTypes.object, defaultLayout: PropTypes.object, noContextGroups: PropTypes.array, getLayoutInstance: PropTypes.func, layoutId: PropTypes.string, savedLayout: PropTypes.string, resetToTabPanel: PropTypes.string, }; export const LAYOUT_EVENTS = { ACTIVE: 'active', REMOVE: 'remove', FLOAT: 'float', FRONT: 'front', MAXIMIZE: 'maximize', MOVE: 'move', CLOSING: 'closing', CONTEXT: 'context', CHANGE: 'change', REFRESH_TITLE: 'refresh-title' };