UNPKG

@darksnow-ui/node-tree-react

Version:
533 lines (526 loc) 16.7 kB
// 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