@darksnow-ui/node-tree-react
Version:
Ready-to-use React component for node-tree-headless
535 lines (530 loc) • 17.7 kB
JavaScript
// src/components/NodeTree.tsx
import {
useNodeTree as useNodeTree3,
useNodeTreeState as useNodeTreeState3,
useContextMenu
} from "@darksnow-ui/node-tree-headless";
// src/components/TreeNode.tsx
import {
useNodeTreeState as useNodeTreeState2
} from "@darksnow-ui/node-tree-headless";
import { ChevronRight, ChevronDown } from "lucide-react";
import { clsx as clsx2 } from "clsx";
// src/components/NodeWrapper.tsx
import { useNodeTree, useNodeTreeState } from "@darksnow-ui/node-tree-headless";
import { memo, useMemo } from "react";
import { clsx } from "clsx";
import { jsx } from "react/jsx-runtime";
var NodeWrapper = memo(
({ id, children, className }) => {
const { handlers } = useNodeTree();
const wrapperProps = useMemo(
() => handlers.wrapperProps(id),
[handlers, id]
);
const isSelected = useNodeTreeState((s) => s.selectedNodes.has(id));
const isFocused = useNodeTreeState((s) => s.focusId === id);
const isCursor = useNodeTreeState((s) => s.cursorId === id);
const isDragOver = useNodeTreeState((s) => s.dragOverId === id);
const isDragging = useNodeTreeState((s) => s.dragStartId === id);
return /* @__PURE__ */ jsx(
"div",
{
...wrapperProps,
"data-node-id": id,
className: clsx(
"node-wrapper",
className,
isSelected && "selected",
isFocused && "focused",
isCursor && "cursor",
isDragOver && "drag-over",
isDragging && "dragging"
),
children
}
);
}
);
NodeWrapper.displayName = "NodeWrapper";
// src/components/TreeNode.tsx
import { jsx as jsx2, jsxs } from "react/jsx-runtime";
function TreeNode({
row,
renderLabel,
resolveIcon,
indentSize = 20
}) {
const { node } = row;
const isExpanded = useNodeTreeState2((s) => s.expandedNodes.has(node.id));
const isSelected = useNodeTreeState2((s) => s.selectedNodes.has(node.id));
const isCursor = useNodeTreeState2((s) => s.cursorId === node.id);
const isFocus = useNodeTreeState2((s) => s.focusId === node.id);
const isDragOver = useNodeTreeState2((s) => s.dragOverId === node.id);
const defaultIconResolver = (node2, isExpanded2) => {
if (!node2.expandable) {
const ext = node2.label.split(".").pop()?.toLowerCase();
switch (ext) {
case "ts":
return "typescript";
case "tsx":
return "react_ts";
case "js":
return "javascript";
case "jsx":
return "react";
case "json":
return "json";
case "md":
return "markdown";
default:
return "file";
}
}
return isExpanded2 ? "folder-open" : "folder";
};
const iconName = resolveIcon ? resolveIcon(node, isExpanded) : defaultIconResolver(node, isExpanded);
return /* @__PURE__ */ jsxs(
NodeWrapper,
{
id: node.id,
className: clsx2(
"group flex items-center px-2 cursor-pointer relative",
"hover:bg-vscode-list-hoverBackground",
isSelected && "bg-vscode-list-activeSelectionBackground",
isCursor && "outline outline-1 outline-vscode-focusBorder",
isDragOver && "bg-vscode-list-dropBackground",
node.expandable && "expandable"
),
children: [
/* @__PURE__ */ jsx2("div", { style: { width: `${row.level * indentSize}px` } }),
/* @__PURE__ */ jsx2("div", { className: "flex items-center justify-center w-5 h-5", children: node.expandable ? /* @__PURE__ */ jsx2(
"span",
{
className: clsx2(
"flex items-center justify-center w-full h-full",
"hover:bg-vscode-border/50 rounded transition-colors"
),
"aria-expanded": isExpanded,
children: isExpanded ? /* @__PURE__ */ jsx2(ChevronDown, { className: "w-3 h-3" }) : /* @__PURE__ */ jsx2(ChevronRight, { className: "w-3 h-3" })
}
) : null }),
/* @__PURE__ */ jsx2("div", { className: "flex-shrink-0 mr-2", children: /* @__PURE__ */ jsx2("i", { className: `icon icon-${iconName}` }) }),
/* @__PURE__ */ jsx2(
"span",
{
className: clsx2(
"flex-1 text-sm truncate",
isSelected ? "text-vscode-list-activeSelectionForeground" : "text-vscode-foreground"
// isFocus && "font-semibold",
),
"aria-selected": isSelected,
children: renderLabel ? renderLabel(node) : node.label
}
)
]
}
);
}
// src/components/ContextMenu.tsx
import { useNodeTree as useNodeTree2 } from "@darksnow-ui/node-tree-headless";
import { useEffect, useRef } from "react";
import {
FolderPlus,
FilePlus,
Trash2,
Edit2,
Copy,
Scissors,
Clipboard,
FolderOpen,
FolderClosed
} from "lucide-react";
import { Fragment, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
function ContextMenu({
x,
y,
nodeId,
containerElementRef,
onClose,
className,
onAction
}) {
const { services, controller } = useNodeTree2();
const menuRef = useRef(null);
const nodes = services.store.getState().nodes;
const isRoot = nodeId === "root" || !nodeId;
const node = isRoot ? null : nodes.get(nodeId);
const isExpanded = !isRoot && services.store.getState().expandedNodes.has(nodeId);
useEffect(() => {
menuRef.current?.focus();
return () => {
containerElementRef?.current?.focus();
};
}, [containerElementRef]);
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === "Escape") {
onClose();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
const handleAction = async (action, actionName) => {
await action();
if (actionName && onAction) {
onAction(actionName, nodeId);
}
onClose();
};
if (isRoot) {
return /* @__PURE__ */ jsxs2(
"div",
{
ref: menuRef,
className: className || "context-menu",
style: { left: x, top: y },
onClick: (e) => e.stopPropagation(),
tabIndex: -1,
children: [
/* @__PURE__ */ jsxs2(
"button",
{
className: "context-menu-item",
onClick: () => handleAction(async () => {
const name = prompt("New folder name:");
if (name) {
await controller.createNode(void 0, name);
}
}, "create-folder"),
children: [
/* @__PURE__ */ jsx3(FolderPlus, { className: "w-4 h-4" }),
/* @__PURE__ */ jsx3("span", { children: "New Folder" })
]
}
),
/* @__PURE__ */ jsxs2(
"button",
{
className: "context-menu-item",
onClick: () => handleAction(async () => {
const name = prompt("New file name:");
if (name) {
await controller.createNode(void 0, name);
}
}, "create-file"),
children: [
/* @__PURE__ */ jsx3(FilePlus, { className: "w-4 h-4" }),
/* @__PURE__ */ jsx3("span", { children: "New File" })
]
}
),
services.store.getState().clipboardNodes?.length > 0 && /* @__PURE__ */ jsxs2(Fragment, { children: [
/* @__PURE__ */ jsx3("div", { className: "context-menu-divider" }),
/* @__PURE__ */ jsxs2(
"button",
{
className: "context-menu-item",
onClick: () => handleAction(async () => {
await controller.pasteNodes(void 0);
}, "paste"),
children: [
/* @__PURE__ */ jsx3(Clipboard, { className: "w-4 h-4" }),
/* @__PURE__ */ jsx3("span", { children: "Paste" })
]
}
)
] })
]
}
);
}
if (!node) return null;
return /* @__PURE__ */ jsxs2(
"div",
{
ref: menuRef,
className: className || "context-menu",
style: { left: x, top: y },
onClick: (e) => e.stopPropagation(),
tabIndex: -1,
children: [
node.expandable && /* @__PURE__ */ jsxs2(Fragment, { children: [
/* @__PURE__ */ jsx3(
"button",
{
className: "context-menu-item",
onClick: () => handleAction(
() => {
if (isExpanded) {
services.navigationService.collapseNode(nodeId);
} else {
services.navigationService.expandNode(nodeId);
}
},
isExpanded ? "collapse" : "expand"
),
children: isExpanded ? /* @__PURE__ */ jsxs2(Fragment, { children: [
/* @__PURE__ */ jsx3(FolderClosed, { className: "w-4 h-4" }),
/* @__PURE__ */ jsx3("span", { children: "Collapse" })
] }) : /* @__PURE__ */ jsxs2(Fragment, { children: [
/* @__PURE__ */ jsx3(FolderOpen, { className: "w-4 h-4" }),
/* @__PURE__ */ jsx3("span", { children: "Expand" })
] })
}
),
/* @__PURE__ */ jsx3("div", { className: "context-menu-divider" })
] }),
node.expandable && /* @__PURE__ */ jsxs2(Fragment, { children: [
/* @__PURE__ */ jsxs2(
"button",
{
className: "context-menu-item",
onClick: () => handleAction(async () => {
const name = prompt("New folder name:");
if (name) {
await controller.createNode(nodeId, name);
}
}, "create-folder"),
children: [
/* @__PURE__ */ jsx3(FolderPlus, { className: "w-4 h-4" }),
/* @__PURE__ */ jsx3("span", { children: "New Folder" })
]
}
),
/* @__PURE__ */ jsxs2(
"button",
{
className: "context-menu-item",
onClick: () => handleAction(async () => {
const name = prompt("New file name:");
if (name) {
await controller.createNode(nodeId, name);
}
}, "create-file"),
children: [
/* @__PURE__ */ jsx3(FilePlus, { className: "w-4 h-4" }),
/* @__PURE__ */ jsx3("span", { children: "New File" })
]
}
),
/* @__PURE__ */ jsx3("div", { className: "context-menu-divider" })
] }),
/* @__PURE__ */ jsxs2(
"button",
{
className: "context-menu-item",
onClick: () => handleAction(() => {
controller.copyNodes([nodeId]);
}, "copy"),
children: [
/* @__PURE__ */ jsx3(Copy, { className: "w-4 h-4" }),
/* @__PURE__ */ jsx3("span", { children: "Copy" })
]
}
),
/* @__PURE__ */ jsxs2(
"button",
{
className: "context-menu-item",
onClick: () => handleAction(() => {
controller.cutNodes([nodeId]);
}, "cut"),
children: [
/* @__PURE__ */ jsx3(Scissors, { className: "w-4 h-4" }),
/* @__PURE__ */ jsx3("span", { children: "Cut" })
]
}
),
node.expandable && /* @__PURE__ */ jsxs2(
"button",
{
className: "context-menu-item",
onClick: () => handleAction(async () => {
await controller.pasteNodes(nodeId);
}, "paste"),
children: [
/* @__PURE__ */ jsx3(Clipboard, { className: "w-4 h-4" }),
/* @__PURE__ */ jsx3("span", { children: "Paste" })
]
}
),
/* @__PURE__ */ jsx3("div", { className: "context-menu-divider" }),
/* @__PURE__ */ jsxs2(
"button",
{
className: "context-menu-item",
onClick: () => handleAction(async () => {
await controller.renameNode(nodeId);
}, "rename"),
children: [
/* @__PURE__ */ jsx3(Edit2, { className: "w-4 h-4" }),
/* @__PURE__ */ jsx3("span", { children: "Rename" })
]
}
),
/* @__PURE__ */ jsxs2(
"button",
{
className: "context-menu-item context-menu-item-danger",
onClick: () => handleAction(async () => {
await controller.deleteNodes([nodeId]);
}, "delete"),
children: [
/* @__PURE__ */ jsx3(Trash2, { className: "w-4 h-4" }),
/* @__PURE__ */ jsx3("span", { children: "Delete" })
]
}
)
]
}
);
}
// src/components/NodeTree.tsx
import { useState, useCallback } from "react";
import { clsx as clsx3 } from "clsx";
import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
function NodeTree({
className,
style,
renderNode,
renderEmptyState,
onContextMenuAction,
indentSize = 20,
showContextMenu = true,
contextMenuClassName,
emptyStateClassName,
showRootDropArea = true,
rootDropAreaClassName,
renderRootDropArea,
resolveIcon
}) {
const context = useNodeTree3();
const indexedNodes = useNodeTreeState3((state) => state.indexedNodes);
const nodes = useNodeTreeState3((state) => state.nodes);
const dragOverId = useNodeTreeState3((state) => state.dragOverId);
const dragStartId = useNodeTreeState3((state) => state.dragStartId);
const { contextMenu, closeContextMenu } = useContextMenu();
const [hasFocus, setHasFocus] = useState(false);
const defaultRenderNode = useCallback(
(row) => {
const node = nodes.get(row.id);
if (!node) return null;
const state = context.services.store.getState();
const enhancedRow = {
...row,
node,
selected: state.selectedNodes.has(row.id),
expanded: state.expandedNodes.has(row.id),
visible: true,
isCursor: state.cursorId === row.id,
isFocus: state.focusId === row.id
};
return /* @__PURE__ */ jsx4(
TreeNode,
{
row: enhancedRow,
indentSize,
resolveIcon
},
row.id
);
},
[nodes, context.services.store, indentSize]
);
const defaultRenderEmptyState = () => /* @__PURE__ */ jsx4(
"div",
{
className: emptyStateClassName || "text-center py-8 text-gray-500 dark:text-gray-400",
children: "No nodes to display"
}
);
const defaultRenderRootDropArea = (isDragOver) => /* @__PURE__ */ jsx4(
"div",
{
className: clsx3(
rootDropAreaClassName || "node-tree-root-drop-area",
isDragOver && "drag-over"
),
style: {
flex: 1,
minHeight: "20px",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "all 0.2s ease",
cursor: "pointer"
}
}
);
return /* @__PURE__ */ jsxs3(Fragment2, { children: [
/* @__PURE__ */ jsxs3(
"div",
{
ref: context.elementRef,
tabIndex: 0,
className: clsx3(
"node-tree-container",
hasFocus && "has-focus",
className
),
style: {
...style,
display: "flex",
flexDirection: "column",
height: "100%"
},
onFocus: () => setHasFocus(true),
onBlur: () => setHasFocus(false),
children: [
/* @__PURE__ */ jsx4("div", { className: "node-tree-content", style: { flexShrink: 0 }, children: indexedNodes && indexedNodes.length > 0 ? indexedNodes.map((row) => {
if (renderNode) {
const node = nodes.get(row.id);
if (!node) return null;
const state = context.services.store.getState();
const enhancedRow = {
...row,
node,
selected: state.selectedNodes.has(row.id),
expanded: state.expandedNodes.has(row.id),
visible: true,
isCursor: state.cursorId === row.id,
isFocus: state.focusId === row.id
};
return renderNode({ row: enhancedRow });
}
return defaultRenderNode(row);
}) : renderEmptyState ? renderEmptyState() : defaultRenderEmptyState() }),
showRootDropArea && indexedNodes && indexedNodes.length > 0 && /* @__PURE__ */ jsx4(
"div",
{
...context.handlers.rootDropProps,
style: { flex: 1, display: "flex" },
children: renderRootDropArea ? renderRootDropArea(dragOverId === "root") : defaultRenderRootDropArea(dragOverId === "root")
}
)
]
}
),
showContextMenu && contextMenu && /* @__PURE__ */ jsx4(
ContextMenu,
{
x: contextMenu.x,
y: contextMenu.y,
nodeId: contextMenu.nodeId,
containerElementRef: contextMenu.containerElementRef,
onClose: closeContextMenu,
className: contextMenuClassName,
onAction: onContextMenuAction
}
)
] });
}
export {
ContextMenu,
NodeTree,
NodeWrapper,
TreeNode
};
//# sourceMappingURL=index.mjs.map