512 lines
16 KiB
JavaScript
512 lines
16 KiB
JavaScript
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 (
|
|
<Box display="flex" alignItems="center" title={attrs.tooltip} onContextMenu={onContextMenu} width="100%">
|
|
{attrs.icon && <span className={`dock-tab-icon ${attrs.icon}`}></span>}
|
|
<span style={{textOverflow: 'ellipsis', overflow: 'hidden', whiteSpace: 'nowrap'}} data-visible={layoutDocker.isTabVisible(id)}>{attrs.title}</span>
|
|
{closable && <PgIconButton title={gettext('Close')} icon={<CloseIcon style={{height: '0.7em'}} />} size="xs" noBorder onClick={()=>{
|
|
layoutDocker.close(id);
|
|
}} style={{margin: '-1px -10px -1px 0'}} />}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
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: <ErrorBoundary>{panelData.content}</ErrorBoundary>,
|
|
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: <TabTitle id={attrs.id} closable={attrs.group!='dialogs' && closable} defaultInternal={internal}/>,
|
|
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<dockLayoutPanels.length; i++) {
|
|
if(dockLayoutPanels[i] == currDockPanel) {
|
|
let newPanelIdx = (i+1)%dockLayoutPanels.length;
|
|
dockLayoutPanels[newPanelIdx]?.querySelector('.dock-tab.dock-tab-active .dock-tab-btn')?.focus();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export const LayoutDockerContext = React.createContext(new LayoutDocker(null, null));
|
|
|
|
function DialogClose({panelData}) {
|
|
const layoutDocker = React.useContext(LayoutDockerContext);
|
|
// In a dialog, panelData is the data of the container panel and not the
|
|
// data of actual dialog tab. panelData.activeId gives the id of dialog tab.
|
|
return (
|
|
<Box display="flex" alignItems="center">
|
|
<PgIconButton title={gettext('Close')} icon={<CloseIcon />} size="xs" noBorder onClick={()=>{
|
|
layoutDocker.close(panelData.activeId);
|
|
}} style={{marginRight: '-4px'}}/>
|
|
</Box>
|
|
);
|
|
}
|
|
DialogClose.propTypes = {
|
|
panelData: PropTypes.object
|
|
};
|
|
|
|
function getDialogsGroup() {
|
|
return {
|
|
disableDock: true,
|
|
tabLocked: true,
|
|
floatable: 'singleTab',
|
|
moreIcon: <ExpandMoreIcon style={{height: '0.9em'}} />,
|
|
panelExtra: (panelData) => {
|
|
return <DialogClose panelData={panelData} />;
|
|
}
|
|
};
|
|
}
|
|
|
|
export function getDefaultGroup() {
|
|
return {
|
|
closable: false,
|
|
maximizable: false,
|
|
floatable: false,
|
|
moreIcon: <ExpandMoreIcon style={{height: '0.9em', marginTop: '4px'}} />,
|
|
panelExtra: (panelData, context) => {
|
|
let icon = <ExpandDialogIcon style={{width: '0.7em'}}/>;
|
|
let title = gettext('Maximise');
|
|
if(panelData?.parent?.mode == 'maximize') {
|
|
icon = <MinimizeDialogIcon />;
|
|
title = gettext('Restore');
|
|
}
|
|
return <Box display="flex" alignItems="center">
|
|
{Boolean(panelData.maximizable) && <PgIconButton title={title} icon={icon} size="xs" noBorder onClick={()=>{
|
|
context.dockMove(panelData, null, 'maximize');
|
|
}} />}
|
|
</Box>;
|
|
}
|
|
};
|
|
}
|
|
|
|
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 (
|
|
<LayoutDockerContext.Provider value={layoutDockerObj}>
|
|
{useMemo(()=>(<DockLayout
|
|
style={{
|
|
height: '100%',
|
|
}}
|
|
ref={(obj)=>{
|
|
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}
|
|
/>), [])}
|
|
<div id="layout-portal"></div>
|
|
<ContextMenu menuItems={contextMenuItems} position={contextPos} onClose={()=>setContextPos([null, null, null])}
|
|
label="Layout Context Menu" />
|
|
</LayoutDockerContext.Provider>
|
|
);
|
|
}
|
|
|
|
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'
|
|
};
|