@lonli-lokli/react-mosaic-component
Version:
A React Tiling Window Manager
416 lines (415 loc) • 13.7 kB
JavaScript
// libs/react-mosaic-component/src/lib/MosaicTabs.tsx
import React from "react";
import classNames from "classnames";
import { useDrop, useDrag } from "react-dnd";
import { defer, drop as _drop, isEqual } from "lodash-es";
import {
MosaicDragType
} from "./types.mjs";
import { boundingBoxAsStyles } from "./util/BoundingBox.mjs";
import { MosaicContext } from "./contextTypes.mjs";
import { updateTree, createDragToUpdates } from "./util/mosaicUpdates.mjs";
import { normalizeMosaicTree, getNodeAtPath, isTabsNode } from "./util/mosaicUtilities.mjs";
import { OptionalBlueprint } from "./util/OptionalBlueprint.mjs";
import { DraggableTab } from "./DraggableTab.mjs";
import { createDefaultTabsControls } from "./buttons/defaultToolbarControls.mjs";
var DefaultTabButton = ({
tabKey,
index,
isActive,
path,
mosaicId,
onTabClick,
mosaicActions,
renderTabTitle = ({ tabKey: tabKey2 }) => `Tab ${tabKey2}`,
canClose = () => "canClose",
onTabClose,
tabs
}) => {
const handleCloseClick = (event) => {
event.stopPropagation();
onTabClose?.(tabKey, index);
};
const closeState = canClose(tabKey, tabs, index, path);
return /* @__PURE__ */ React.createElement(
DraggableTab,
{
tabKey,
tabIndex: index,
tabContainerPath: path,
mosaicActions,
mosaicId
},
({ isDragging, connectDragSource, connectDragPreview }) => /* @__PURE__ */ React.createElement(
"button",
{
className: classNames("mosaic-tab-button", {
"-active": isActive,
"-dragging": isDragging
}),
onClick: onTabClick,
title: `${tabKey}`,
ref: (node) => {
connectDragSource(node);
connectDragPreview(node);
}
},
/* @__PURE__ */ React.createElement("span", { className: "mosaic-tab-button-content" }, renderTabTitle({ tabKey, path, isActive, index, mosaicId })),
closeState !== "noClose" && /* @__PURE__ */ React.createElement(
"span",
{
className: classNames("mosaic-tab-close-button", {
"-can-close": closeState === "canClose",
"-cannot-close": closeState === "cannotClose",
"-active-tab": isActive,
"-inactive-tab": !isActive
}),
onClick: closeState === "canClose" ? handleCloseClick : void 0,
title: closeState === "canClose" ? "Close tab" : "Cannot close tab"
},
/* @__PURE__ */ React.createElement(OptionalBlueprint.Icon, { size: "empty", icon: "CROSS" })
)
)
);
};
var TabDropTarget = ({
tabContainerPath,
insertIndex
}) => {
const [{ isOver, canDrop, draggedMosaicId }, connectDropTarget] = useDrop({
accept: MosaicDragType.WINDOW,
canDrop: (item) => {
const isTabReorder = item?.isTab && item?.tabContainerPath && isEqual(item.tabContainerPath, tabContainerPath);
const isExternalDrop = !item?.isTab || !isEqual(item.tabContainerPath, tabContainerPath);
const shouldAccept = isTabReorder || isExternalDrop;
return shouldAccept;
},
drop: () => {
return {
path: tabContainerPath,
position: void 0,
// Custom property to indicate this is a tab reorder operation
tabReorderIndex: insertIndex
};
},
hover: () => {
},
collect: (monitor) => {
const result = {
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
draggedMosaicId: monitor.getItem()?.mosaicId
};
return result;
}
});
const isDragging = draggedMosaicId != null;
return connectDropTarget(
/* @__PURE__ */ React.createElement(
"div",
{
className: classNames("tab-drop-target", {
"tab-drop-target-hover": isOver,
dragging: isDragging
})
},
isOver ? (
// Show placeholder when hovering during drag
/* @__PURE__ */ React.createElement("div", { className: "tab-drop-placeholder" }, /* @__PURE__ */ React.createElement("div", { className: "tab-drop-arrow" }), "``")
) : (
// Subtle indicator when not hovering
/* @__PURE__ */ React.createElement(
"div",
{
className: classNames("tab-drop-indicator", {
"can-drop": canDrop,
default: !canDrop
})
}
)
)
)
);
};
var MosaicTabs = ({
node,
path,
renderTile,
renderTabToolbar,
boundingBox,
renderTabTitle,
renderTabButton,
tabToolbarControls = createDefaultTabsControls(path),
canClose
}) => {
const { mosaicActions, mosaicId } = React.useContext(
MosaicContext
);
const { tabs, activeTabIndex } = node;
const [, connectDragSource, connectDragPreview] = useDrag({
type: MosaicDragType.WINDOW,
item: () => {
const hideTimer = defer(() => mosaicActions.hide(path));
return {
mosaicId,
hideTimer
};
},
end: ({ hideTimer }, monitor) => {
window.clearTimeout(hideTimer);
const ownPath = path;
const dropResult = monitor.getDropResult() || {};
const { position, path: destinationPath } = dropResult;
const dropped = destinationPath != null;
const isSelfDrop = dropped && isEqual(destinationPath, ownPath);
const isTabContainerSelfDrop = dropped && (() => {
const root = mosaicActions.getRoot();
const destinationNode = getNodeAtPath(root, destinationPath);
return isTabsNode(destinationNode) && isEqual(ownPath, destinationPath);
})();
const isChildDrop = dropped && destinationPath.length > ownPath.length && isEqual(
ownPath,
_drop(destinationPath, destinationPath.length - ownPath.length)
);
if (dropped && !isSelfDrop && !isChildDrop && !isTabContainerSelfDrop) {
const updates = createDragToUpdates(
mosaicActions.getRoot(),
ownPath,
destinationPath,
dropResult.tabReorderIndex !== void 0 ? {
type: "tab-reorder",
insertIndex: dropResult.tabReorderIndex
} : position === void 0 ? { type: "tab-container" } : {
type: "split",
position
}
);
mosaicActions.updateTree(updates, {
shouldNormalize: true
});
} else {
mosaicActions.show(ownPath, true);
}
}
});
const [, connectDropTarget] = useDrop({
accept: MosaicDragType.WINDOW,
canDrop: () => {
return false;
},
collect: (monitor) => ({
isOver: monitor.isOver({ shallow: true }),
draggedMosaicId: monitor.getItem()?.mosaicId
})
});
const [
{ isOver: isTabBarOver, draggedMosaicId: tabBarDraggedMosaicId },
connectTabBarDropTarget
] = useDrop({
accept: MosaicDragType.WINDOW,
canDrop: (_item, monitor) => {
return monitor.isOver({ shallow: true });
},
drop: () => {
return {
path,
position: void 0
// No position needed - always add as new tab
};
},
collect: (monitor) => ({
isOver: monitor.isOver({ shallow: true }),
draggedMosaicId: monitor.getItem()?.mosaicId
})
});
const onTabClick = (index) => {
if (index === activeTabIndex) {
return;
}
mosaicActions.updateTree([
{
path,
spec: { activeTabIndex: { $set: index } }
}
]);
};
const onTabClose = (tabKey, index) => {
const closeState = canClose ? canClose(tabKey, tabs, index, path) : "noClose";
if (closeState !== "canClose") {
return;
}
if (tabs.length <= 1) {
return;
}
const newTabs = tabs.filter((_, i) => i !== index);
let newActiveTabIndex = activeTabIndex;
if (index === activeTabIndex) {
newActiveTabIndex = Math.max(0, index - 1);
} else if (index < activeTabIndex) {
newActiveTabIndex = activeTabIndex - 1;
}
const updates = [
{
path,
spec: {
tabs: { $set: newTabs },
activeTabIndex: { $set: newActiveTabIndex }
}
}
];
let newTree = mosaicActions.getRoot();
if (!newTree) return;
updates.forEach((update) => {
newTree = updateTree(newTree, [update]);
});
const normalizedTree = normalizeMosaicTree(newTree);
mosaicActions.replaceWith([], normalizedTree);
};
const addTab = () => {
if (mosaicActions.createNode == null) {
throw new Error(
"Operation invalid unless `createNode` is defined on Mosaic"
);
}
Promise.resolve(mosaicActions.createNode()).then((newNode) => {
if (typeof newNode !== "string" && typeof newNode !== "number") {
console.error(
"createNode() for adding a tab should return a MosaicKey (string or number)."
);
return;
}
const updates = [
{
path,
// The path to this tabs node
spec: {
tabs: { $push: [newNode] },
// Set the new tab as active. Its index is the original length of the array.
activeTabIndex: { $set: tabs.length }
}
}
];
let newTree = mosaicActions.getRoot();
if (!newTree) return;
updates.forEach((update) => {
newTree = updateTree(newTree, [update]);
});
const normalizedTree = normalizeMosaicTree(newTree);
mosaicActions.replaceWith([], normalizedTree);
});
};
const renderDefaultToolbar = () => {
const isDragAllowed = path.length > 0;
const dragHandle = isDragAllowed ? connectDragSource(
/* @__PURE__ */ React.createElement("div", { className: "mosaic-tab-drag-handle", title: "Drag to move tab container" })
) : null;
return connectTabBarDropTarget(
/* @__PURE__ */ React.createElement(
"div",
{
className: classNames("mosaic-tab-bar", {
"tab-bar-drop-target-hover": isTabBarOver && tabBarDraggedMosaicId === mosaicId,
draggable: isDragAllowed
})
},
/* @__PURE__ */ React.createElement("div", { className: "mosaic-tab-bar-tabs" }, /* @__PURE__ */ React.createElement(
TabDropTarget,
{
tabContainerPath: path,
insertIndex: 0,
mosaicActions,
mosaicId
}
), tabs.map((tabKey, index) => {
const TabButtonComponent = renderTabButton || DefaultTabButton;
return /* @__PURE__ */ React.createElement(React.Fragment, { key: tabKey }, /* @__PURE__ */ React.createElement(
TabButtonComponent,
{
tabKey,
index,
isActive: index === activeTabIndex,
path,
mosaicId,
onTabClick: () => onTabClick(index),
mosaicActions,
renderTabTitle,
canClose,
onTabClose,
tabs
}
), /* @__PURE__ */ React.createElement(
TabDropTarget,
{
tabContainerPath: path,
insertIndex: index + 1,
mosaicActions,
mosaicId
}
));
})),
/* @__PURE__ */ React.createElement("div", { className: "mosaic-tab-bar-controls" }, /* @__PURE__ */ React.createElement(
"button",
{
className: "mosaic-tab-add-button",
onClick: addTab,
"aria-label": "Add new tab",
title: "Add new tab"
},
"+"
), dragHandle, /* @__PURE__ */ React.createElement("div", { className: "mosaic-tab-toolbar-controls" }, tabToolbarControls))
)
);
};
const activeTabKey = tabs[activeTabIndex];
const tilePath = path.concat(activeTabIndex);
const renderPreview = () => /* @__PURE__ */ React.createElement("div", { className: "mosaic-preview" }, /* @__PURE__ */ React.createElement("div", { className: "mosaic-tab-bar" }, /* @__PURE__ */ React.createElement("div", { className: "mosaic-tab-bar-tabs" }, tabs.map((tabKey, index) => /* @__PURE__ */ React.createElement(
"button",
{
key: tabKey,
className: classNames("mosaic-tab-button", {
"-active": index === activeTabIndex
})
},
/* @__PURE__ */ React.createElement("span", { className: "mosaic-tab-button-content" }, renderTabTitle ? renderTabTitle({ tabKey, path, isActive: index === activeTabIndex, index, mosaicId }) : `Tab ${tabKey}`)
)))), /* @__PURE__ */ React.createElement("div", { className: "mosaic-window-body" }, /* @__PURE__ */ React.createElement("h4", null, "Tab Container"), /* @__PURE__ */ React.createElement(
OptionalBlueprint.Icon,
{
className: "default-preview-icon",
size: "large",
icon: "APPLICATION"
}
)));
return (
// This is the container for the entire tab group.
// Its position and size are determined ONLY by the boundingBox.
/* @__PURE__ */ React.createElement(
"div",
{
className: "mosaic-tabs-container",
style: boundingBoxAsStyles(boundingBox)
},
renderTabToolbar ? renderTabToolbar({
tabs,
activeTabIndex,
path,
DraggableTab: (props) => /* @__PURE__ */ React.createElement(
DraggableTab,
{
key: props.tabKey,
...props,
tabContainerPath: path,
mosaicActions,
mosaicId
}
)
}) : renderDefaultToolbar(),
connectDropTarget(
/* @__PURE__ */ React.createElement("div", { className: "mosaic-tile" }, renderTile(activeTabKey, tilePath))
),
connectDragPreview(renderPreview())
)
);
};
export {
MosaicTabs
};