UNPKG

@darksnow-ui/node-tree-react

Version:
535 lines (530 loc) 17.7 kB
// 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