@darksnow-ui/node-tree-react
Version:
Ready-to-use React component for node-tree-headless
533 lines (526 loc) • 16.7 kB
JavaScript
// src/components/NodeTree.tsx
import {
useNodeTree as useNodeTree3,
useNodeTreeState as useNodeTreeState4,
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 isCut = useNodeTreeState2(
(s) => s.clipboardOperation === "cut" && s.clipboardNodeIds?.has(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: clsx2("flex-shrink-0 mr-2", isCut && "opacity-50"), 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",
isCut && "opacity-50"
// isFocus && "font-semibold",
),
"aria-selected": isSelected,
children: renderLabel ? renderLabel(node) : node.label
}
)
]
}
);
}
// src/components/ContextMenu.tsx
import { useRef, useEffect } from "react";
import { FloatingMenu } from "@darksnow-ui/menus";
// src/hooks/useNodeTreeMenuItems.tsx
import { useMemo as useMemo2 } from "react";
import {
useNodeTree as useNodeTree2,
useNodeTreeState as useNodeTreeState3
} from "@darksnow-ui/node-tree-headless";
import {
FolderOpen,
FolderClosed,
FolderPlus,
FilePlus,
Copy,
Scissors,
Clipboard,
Edit2,
Trash2
} from "lucide-react";
import { jsx as jsx3 } from "react/jsx-runtime";
function useNodeTreeMenuItems(nodeId, options = {}) {
const { services, controller } = useNodeTree2();
const nodes = useNodeTreeState3((state) => state.nodes);
const expandedNodes = useNodeTreeState3((state) => state.expandedNodes);
const clipboardNodeIds = useNodeTreeState3((state) => state.clipboardNodeIds);
const isRoot = nodeId === "root" || !nodeId;
const node = isRoot ? null : nodes.get(nodeId) || null;
const isExpanded = !isRoot && expandedNodes.has(nodeId);
const hasClipboard = clipboardNodeIds && clipboardNodeIds.size > 0;
const context = {
nodeId,
node,
isRoot,
isExpanded,
controller,
services
};
const defaultItems = useMemo2(() => {
if (isRoot) {
const items2 = [
{
type: "item",
label: "New Folder",
icon: /* @__PURE__ */ jsx3(FolderPlus, { className: "h-4 w-4" }),
handle: async (ctx) => {
const name = prompt("New folder name:");
if (name) {
await ctx.controller.createNode(void 0, name);
options.onAction?.("create-folder", ctx.nodeId);
}
}
},
{
type: "item",
label: "New File",
icon: /* @__PURE__ */ jsx3(FilePlus, { className: "h-4 w-4" }),
handle: async (ctx) => {
const name = prompt("New file name:");
if (name) {
await ctx.controller.createNode(void 0, name);
options.onAction?.("create-file", ctx.nodeId);
}
}
}
];
if (hasClipboard) {
items2.push(
{ type: "separator" },
{
type: "item",
label: "Paste",
icon: /* @__PURE__ */ jsx3(Clipboard, { className: "h-4 w-4" }),
shortcut: "Ctrl+V",
handle: async (ctx) => {
await ctx.controller.pasteNodes(void 0);
options.onAction?.("paste", ctx.nodeId);
}
}
);
}
return items2;
}
const items = [];
if (node?.expandable) {
items.push({
type: "item",
label: isExpanded ? "Collapse" : "Expand",
icon: isExpanded ? /* @__PURE__ */ jsx3(FolderClosed, { className: "h-4 w-4" }) : /* @__PURE__ */ jsx3(FolderOpen, { className: "h-4 w-4" }),
handle: (ctx) => {
if (ctx.isExpanded) {
ctx.services.navigationService.collapseNode(ctx.nodeId);
} else {
ctx.services.navigationService.expandNode(ctx.nodeId);
}
options.onAction?.(
ctx.isExpanded ? "collapse" : "expand",
ctx.nodeId
);
}
});
items.push({ type: "separator" });
}
if (node?.expandable) {
items.push(
{
type: "item",
label: "New Folder",
icon: /* @__PURE__ */ jsx3(FolderPlus, { className: "h-4 w-4" }),
handle: async (ctx) => {
const name = prompt("New folder name:");
if (name) {
await ctx.controller.createNode(ctx.nodeId, name);
options.onAction?.("create-folder", ctx.nodeId);
}
}
},
{
type: "item",
label: "New File",
icon: /* @__PURE__ */ jsx3(FilePlus, { className: "h-4 w-4" }),
handle: async (ctx) => {
const name = prompt("New file name:");
if (name) {
await ctx.controller.createNode(ctx.nodeId, name);
options.onAction?.("create-file", ctx.nodeId);
}
}
},
{ type: "separator" }
);
}
items.push(
{
type: "item",
label: "Copy",
icon: /* @__PURE__ */ jsx3(Copy, { className: "h-4 w-4" }),
shortcut: "Ctrl+C",
handle: (ctx) => {
ctx.controller.copyNodes([ctx.nodeId]);
options.onAction?.("copy", ctx.nodeId);
}
},
{
type: "item",
label: "Cut",
icon: /* @__PURE__ */ jsx3(Scissors, { className: "h-4 w-4" }),
shortcut: "Ctrl+X",
handle: (ctx) => {
ctx.controller.cutNodes([ctx.nodeId]);
options.onAction?.("cut", ctx.nodeId);
}
}
);
if (node?.expandable && hasClipboard) {
items.push({
type: "item",
label: "Paste",
icon: /* @__PURE__ */ jsx3(Clipboard, { className: "h-4 w-4" }),
shortcut: "Ctrl+V",
handle: async (ctx) => {
await ctx.controller.pasteNodes(ctx.nodeId);
options.onAction?.("paste", ctx.nodeId);
}
});
}
items.push(
{ type: "separator" },
{
type: "item",
label: "Rename",
icon: /* @__PURE__ */ jsx3(Edit2, { className: "h-4 w-4" }),
shortcut: "F2",
handle: async (ctx) => {
await ctx.controller.renameNode(ctx.nodeId);
options.onAction?.("rename", ctx.nodeId);
}
},
{
type: "item",
label: "Delete",
icon: /* @__PURE__ */ jsx3(Trash2, { className: "h-4 w-4" }),
className: "text-destructive",
shortcut: "Delete",
handle: async (ctx) => {
await ctx.controller.deleteNodes([ctx.nodeId]);
options.onAction?.("delete", ctx.nodeId);
}
}
);
return items;
}, [isRoot, node, isExpanded, hasClipboard, options.onAction]);
const finalItems = useMemo2(() => {
if (!options.customItems) {
return defaultItems;
}
const customItems = typeof options.customItems === "function" ? options.customItems(context) : options.customItems;
return [...defaultItems, ...customItems];
}, [defaultItems, options.customItems, context]);
return {
items: finalItems,
context
};
}
// src/components/ContextMenu.tsx
import { jsx as jsx4 } from "react/jsx-runtime";
function ContextMenu({
x,
y,
nodeId,
containerElementRef,
onClose,
className,
onAction,
items,
useDefaultItems = true
}) {
const elementRef = useRef(null);
const { items: defaultItems, context } = useNodeTreeMenuItems(nodeId, {
onAction
});
const finalItems = items && items.length > 0 ? items : useDefaultItems ? defaultItems : [];
useEffect(() => {
return () => {
containerElementRef?.current?.focus();
};
}, [containerElementRef]);
if (finalItems.length === 0) {
return null;
}
return /* @__PURE__ */ jsx4(
FloatingMenu,
{
items: finalItems,
context,
open: true,
onOpenChange: (open) => !open && onClose(),
x,
y,
className
}
);
}
// src/components/NodeTree.tsx
import { useState, useCallback } from "react";
import { clsx as clsx3 } from "clsx";
import { Fragment, jsx as jsx5, jsxs as jsxs2 } from "react/jsx-runtime";
function NodeTree({
className,
style,
renderNode,
renderEmptyState,
onContextMenuAction,
indentSize = 20,
showContextMenu = true,
contextMenuClassName,
contextMenuItems,
emptyStateClassName,
showRootDropArea = true,
rootDropAreaClassName,
renderRootDropArea,
resolveIcon
}) {
const context = useNodeTree3();
const indexedNodes = useNodeTreeState4((state) => state.indexedNodes);
const nodes = useNodeTreeState4((state) => state.nodes);
const dragOverId = useNodeTreeState4((state) => state.dragOverId);
const dragStartId = useNodeTreeState4((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__ */ jsx5(
TreeNode,
{
row: enhancedRow,
indentSize,
resolveIcon
},
row.id
);
},
[nodes, context.services.store, indentSize]
);
const defaultRenderEmptyState = () => /* @__PURE__ */ jsx5(
"div",
{
className: emptyStateClassName || "text-center py-8 text-gray-500 dark:text-gray-400",
children: "No nodes to display"
}
);
const defaultRenderRootDropArea = (isDragOver) => /* @__PURE__ */ jsx5(
"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__ */ jsxs2(Fragment, { children: [
/* @__PURE__ */ jsxs2(
"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__ */ jsx5("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__ */ jsx5(
"div",
{
...context.handlers.rootDropProps,
style: { flex: 1, display: "flex" },
children: renderRootDropArea ? renderRootDropArea(dragOverId === "root") : defaultRenderRootDropArea(dragOverId === "root")
}
)
]
}
),
showContextMenu && contextMenu && /* @__PURE__ */ jsx5(
ContextMenu,
{
x: contextMenu.x,
y: contextMenu.y,
nodeId: contextMenu.nodeId,
containerElementRef: contextMenu.containerElementRef,
onClose: closeContextMenu,
className: contextMenuClassName,
onAction: onContextMenuAction,
items: contextMenuItems ? typeof contextMenuItems === "function" ? contextMenuItems(contextMenu.nodeId) : contextMenuItems : void 0,
useDefaultItems: !contextMenuItems
}
)
] });
}
export {
ContextMenu,
NodeTree,
NodeWrapper,
TreeNode,
useNodeTreeMenuItems
};
//# sourceMappingURL=index.mjs.map