@lumina-study/graph
Version:
Graph library for Lumina Study
513 lines (512 loc) • 14.9 kB
JavaScript
import { jsx, Fragment, jsxs } from "react/jsx-runtime";
import { useState, useEffect } from "react";
import { Position, Handle, ReactFlow, Background, Controls } from "@xyflow/react";
const NODE_WIDTH_PX = 240;
const NODE_HEIGHT_PX = 80;
const MOBILE_NODE_WIDTH_PX = 180;
const MOBILE_NODE_HEIGHT_PX = 120;
function getNodeDimensions(isMobile) {
return {
width: isMobile ? MOBILE_NODE_WIDTH_PX : NODE_WIDTH_PX,
height: isMobile ? MOBILE_NODE_HEIGHT_PX : NODE_HEIGHT_PX
};
}
function getHandlePositions(isVertical, dir) {
if (isVertical) {
return {
targetPosition: Position.Top,
sourcePosition: Position.Bottom
};
}
if (dir === "rtl") {
return {
targetPosition: Position.Right,
sourcePosition: Position.Left
};
}
return {
targetPosition: Position.Left,
sourcePosition: Position.Right
};
}
function getTreeNodeClassName(data) {
const classes = ["tree-node"];
if (data.isSelected) {
classes.push("tree-node--selected");
}
if (data.isHighlighted) {
classes.push("tree-node--highlighted");
}
if (data.disabled) {
classes.push("tree-node--disabled");
}
return classes.join(" ");
}
function hasSubModules(data) {
return Boolean(data.subModules && data.subModules.length > 0);
}
function handleClick(event, onClick) {
if (!onClick) {
return;
}
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
if (target.tagName === "BUTTON" || target.closest("button") || target.tagName === "A" || target.closest("a")) {
return;
}
onClick();
}
function handleKeyDown(event, onClick) {
if (!onClick) {
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onClick();
}
}
function highlightText(text, searchTerm) {
if (!searchTerm || !searchTerm.trim()) {
return text;
}
const escapedTerm = searchTerm.replace(/[$()*+.?[\\\]^{|}]/g, "\\$&");
const parts = text.split(new RegExp(`(${escapedTerm})`, "gi"));
return /* @__PURE__ */ jsx(Fragment, { children: parts.map((part, index) => {
const isMatch = part.toLowerCase() === searchTerm.toLowerCase();
return isMatch ? /* @__PURE__ */ jsx("mark", { className: "bg-yellow-200", children: part }, index) : /* @__PURE__ */ jsx("span", { className: "", children: part }, index);
}) });
}
function getBlockIcon(style) {
if (!style) {
return void 0;
}
return void 0;
}
function autoLayout(nodes, edges, options = {}) {
const {
direction = "vertical",
horizontalSpacing = 300,
verticalSpacing = 150
} = options;
if (nodes.length === 0) {
return [];
}
const childrenMap = /* @__PURE__ */ new Map();
const parentMap = /* @__PURE__ */ new Map();
edges.forEach((edge) => {
const children = childrenMap.get(edge.source) || [];
children.push(edge.target);
childrenMap.set(edge.source, children);
parentMap.set(edge.target, edge.source);
});
const roots = nodes.filter((node) => !parentMap.has(node.id));
if (roots.length === 0) {
roots.push(nodes[0]);
}
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
function buildTree(nodeId) {
const node = nodeMap.get(nodeId);
const children = (childrenMap.get(nodeId) || []).map(buildTree);
const width = children.length === 0 ? 1 : children.reduce((sum, child) => sum + child.width, 0);
return { node, children, width };
}
const trees = roots.map((root) => buildTree(root.id));
const positioned = /* @__PURE__ */ new Map();
function positionTree(tree, x, y) {
if (direction === "vertical") {
positioned.set(tree.node.id, { x, y });
if (tree.children.length > 0) {
const totalWidth = tree.width;
const childY = y + verticalSpacing;
let childX = x - (totalWidth - 1) * horizontalSpacing / 2;
tree.children.forEach((child) => {
const childCenterX = childX + child.width * horizontalSpacing / 2;
positionTree(child, childCenterX, childY);
childX += child.width * horizontalSpacing;
});
}
} else {
positioned.set(tree.node.id, { x: y, y: x });
if (tree.children.length > 0) {
const totalWidth = tree.width;
const childX = y + horizontalSpacing;
let childY = x - (totalWidth - 1) * verticalSpacing / 2;
tree.children.forEach((child) => {
const childCenterY = childY + child.width * verticalSpacing / 2;
positionTree(child, childCenterY, childX);
childY += child.width * verticalSpacing;
});
}
}
}
let offsetX = 0;
trees.forEach((tree) => {
const treeWidth = tree.width * horizontalSpacing;
positionTree(tree, offsetX + treeWidth / 2, 0);
offsetX += treeWidth + horizontalSpacing;
});
return nodes.map((node) => ({
...node,
position: positioned.get(node.id) || { x: 0, y: 0 }
}));
}
function createTreeFromHierarchy(items) {
const nodes = items.map((item) => ({
id: item.id,
type: "treeNode",
position: { x: 0, y: 0 },
// Will be calculated by autoLayout
data: item.data
}));
const edges = items.filter((item) => item.parentId).map((item) => ({
id: `e-${item.parentId}-${item.id}`,
source: item.parentId,
target: item.id
}));
return { nodes, edges };
}
const TreeNodeHandles = ({
isVertical,
dir
}) => {
const { targetPosition, sourcePosition } = getHandlePositions(
isVertical,
dir
);
return /* @__PURE__ */ jsxs(Fragment, { children: [
/* @__PURE__ */ jsx(
Handle,
{
id: "target",
type: "target",
position: targetPosition,
style: { background: "#3b82f6" }
}
),
/* @__PURE__ */ jsx(
Handle,
{
id: "source",
type: "source",
position: sourcePosition,
style: { background: "#3b82f6" }
}
)
] });
};
const CollapseButton = ({
isCollapsed,
setIsCollapsed,
onToggleCollapse,
nodeId,
isMobile
}) => {
const handleToggle = (e) => {
e.stopPropagation();
const newCollapsed = !isCollapsed;
setIsCollapsed(newCollapsed);
onToggleCollapse(nodeId, newCollapsed);
};
const handleKeyDown2 = (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleToggle(e);
}
};
const buttonSize = isMobile ? "w-8 h-8 text-base" : "w-4 h-4 text-xs";
const buttonPadding = isMobile ? "p-1" : "p-0.5";
return /* @__PURE__ */ jsx(
"button",
{
id: `tree-node-collapse-button-${nodeId}`,
onClick: handleToggle,
onKeyDown: handleKeyDown2,
className: `bg-transparent border-none cursor-pointer ${buttonPadding} rounded-sm text-gray-500 flex items-center justify-center ${buttonSize}`,
title: isCollapsed ? "Expand sub-modules" : "Collapse sub-modules",
"aria-label": isCollapsed ? "Expand sub-modules" : "Collapse sub-modules",
type: "button",
children: isCollapsed ? "▶" : "▼"
}
);
};
const ZoomBadge = () => /* @__PURE__ */ jsxs(
"span",
{
className: "zoom-badge",
style: {
fontSize: "0.75rem",
color: "#2563eb",
backgroundColor: "#eff6ff",
padding: "0.25rem 0.5rem",
borderRadius: "9999px",
display: "flex",
alignItems: "center",
gap: "0.25rem",
fontWeight: 400
},
children: [
/* @__PURE__ */ jsx(
"svg",
{
className: "zoom-icon",
style: { width: "0.75rem", height: "0.75rem" },
fill: "none",
stroke: "currentColor",
viewBox: "0 0 24 24",
children: /* @__PURE__ */ jsx(
"path",
{
className: "zoom-icon-path",
strokeLinecap: "round",
strokeLinejoin: "round",
strokeWidth: 2,
d: "M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7"
}
)
}
),
"Zoom in"
]
}
);
const TreeNodeHeader = ({
hasSubModules: hasSubModules2,
isCollapsed,
setIsCollapsed,
onToggleCollapse,
nodeId,
label,
searchTerm,
iconPath,
canZoom,
isMobile
}) => {
return /* @__PURE__ */ jsxs(
"div",
{
id: `tree-node-header-${nodeId}`,
className: `${isMobile ? "flex flex-col gap-2" : ""}`,
style: { display: isMobile ? "flex" : "block" },
children: [
hasSubModules2 ? /* @__PURE__ */ jsx(
"div",
{
id: `tree-node-collapse-wrapper-${nodeId}`,
className: `${isMobile ? "flex justify-start" : ""}`,
style: { display: isMobile ? "flex" : "inline" },
children: /* @__PURE__ */ jsx(
CollapseButton,
{
isCollapsed,
setIsCollapsed,
onToggleCollapse,
nodeId,
isMobile
}
)
}
) : null,
/* @__PURE__ */ jsxs(
"div",
{
id: `tree-node-content-${nodeId}`,
className: `relative ${isMobile ? "text-start" : ""}`,
style: { lineHeight: "1.25", textAlign: isMobile ? "start" : "initial" },
children: [
iconPath && /* @__PURE__ */ jsx(
"div",
{
className: "icon-wrapper",
style: {
width: "3rem",
marginBottom: isMobile ? "0.5rem" : 0,
float: isMobile ? "none" : "right"
},
children: /* @__PURE__ */ jsx("img", { src: iconPath, alt: "", role: "presentation", className: "icon-img" })
}
),
/* @__PURE__ */ jsxs(
"span",
{
className: "tree-node__label",
style: {
fontWeight: 600,
display: canZoom ? "flex" : "block",
fontSize: isMobile ? "1.125rem" : "1.25rem",
textAlign: "start"
},
children: [
highlightText(label, searchTerm),
canZoom && /* @__PURE__ */ jsx(ZoomBadge, {})
]
}
)
]
}
)
]
}
);
};
const TreeNodeSubmodules = ({
subModules,
searchTerm
}) => {
if (!subModules || subModules.length === 0) {
return null;
}
return /* @__PURE__ */ jsx("div", { className: "tree-node__submodules", children: subModules.map((subModule, index) => /* @__PURE__ */ jsx("span", { className: "tree-node__submodule", children: highlightText(subModule, searchTerm) }, index)) });
};
function useMobileDetection() {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768);
};
checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, []);
return isMobile;
}
function TreeNodeContainer(props) {
const { data } = props;
const isMobile = useMobileDetection();
const isVertical = data.direction === "ttb";
const layoutDirection = data.direction || "ttb";
const [isCollapsed, setIsCollapsed] = useState(
data.isCollapsed !== void 0 ? data.isCollapsed : false
);
useEffect(() => {
if (data.isCollapsed !== void 0) {
setIsCollapsed(data.isCollapsed);
}
}, [data.isCollapsed]);
const hasSubModulesFlag = hasSubModules(data);
const iconPath = getBlockIcon(data.style);
const textDirection = data.language === "he" ? "rtl" : "ltr";
const textAlign = "start";
const { width: nodeWidth, height: nodeHeight } = getNodeDimensions(isMobile);
const handleToggleCollapse = (nodeId, collapsed) => {
if (data.onToggleCollapse) {
data.onToggleCollapse(nodeId, collapsed);
}
};
return /* @__PURE__ */ jsxs(
"div",
{
id: `tree-node-${props.id}`,
onClick: (e) => handleClick(e, data.disabled ? void 0 : data.onClick),
onKeyDown: (e) => handleKeyDown(e, data.disabled ? void 0 : data.onClick),
className: getTreeNodeClassName(data),
style: {
direction: textDirection,
textAlign,
width: nodeWidth,
height: nodeHeight
},
role: "treeitem",
"aria-expanded": hasSubModulesFlag ? !isCollapsed : void 0,
"aria-selected": data.isSelected,
"aria-disabled": data.disabled,
tabIndex: data.isSelected ? 0 : -1,
children: [
/* @__PURE__ */ jsx(TreeNodeHandles, { isVertical, dir: layoutDirection }),
/* @__PURE__ */ jsx(
TreeNodeHeader,
{
hasSubModules: hasSubModulesFlag,
isCollapsed,
setIsCollapsed,
onToggleCollapse: handleToggleCollapse,
nodeId: props.id,
label: data.label,
searchTerm: data.searchTerm,
iconPath,
canZoom: data.canZoom,
isMobile
}
),
!isCollapsed && /* @__PURE__ */ jsx(
TreeNodeSubmodules,
{
subModules: data.subModules,
searchTerm: data.searchTerm
}
)
]
}
);
}
const TreeNode = TreeNodeContainer;
function Tree({
nodes,
edges = [],
width = "100%",
height = "600px",
showBackground = true,
showControls = true,
fitView = true,
nodeTypes: customNodeTypes,
autoLayout: enableAutoLayout = false,
layoutOptions,
direction = "ttb",
...reactFlowProps
}) {
const nodeTypes = {
treeNode: TreeNode,
...customNodeTypes
};
let processedNodes = enableAutoLayout ? autoLayout(nodes, edges, layoutOptions) : nodes;
processedNodes = processedNodes.map((node) => ({
...node,
data: {
...node.data,
direction: node.data.direction || direction
}
}));
const containerStyle = {
width: typeof width === "number" ? `${width}px` : width,
height: typeof height === "number" ? `${height}px` : height
};
return /* @__PURE__ */ jsx("div", { style: containerStyle, children: /* @__PURE__ */ jsxs(
ReactFlow,
{
nodes: processedNodes,
edges,
nodeTypes,
fitView,
...reactFlowProps,
children: [
showBackground && /* @__PURE__ */ jsx(Background, {}),
showControls && /* @__PURE__ */ jsx(Controls, {})
]
}
) });
}
export {
CollapseButton,
MOBILE_NODE_HEIGHT_PX,
MOBILE_NODE_WIDTH_PX,
NODE_HEIGHT_PX,
NODE_WIDTH_PX,
Tree,
TreeNode,
TreeNodeHandles,
TreeNodeHeader,
TreeNodeSubmodules,
ZoomBadge,
autoLayout,
createTreeFromHierarchy,
getBlockIcon,
getHandlePositions,
getNodeDimensions,
getTreeNodeClassName,
handleClick,
handleKeyDown,
hasSubModules,
highlightText
};
//# sourceMappingURL=index.js.map