@particle-academy/react-fancy 1.9.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -10564,12 +10564,67 @@ function ChevronIcon({ open }) {
10564
10564
  }
10565
10565
  );
10566
10566
  }
10567
+ function isDescendantOf(nodes, ancestorId, targetId) {
10568
+ function findNode(haystack, id) {
10569
+ for (const n of haystack) {
10570
+ if (n.id === id) return n;
10571
+ if (n.children) {
10572
+ const found = findNode(n.children, id);
10573
+ if (found) return found;
10574
+ }
10575
+ }
10576
+ return void 0;
10577
+ }
10578
+ function hasDescendant(node, id) {
10579
+ if (!node.children) return false;
10580
+ for (const child of node.children) {
10581
+ if (child.id === id) return true;
10582
+ if (hasDescendant(child, id)) return true;
10583
+ }
10584
+ return false;
10585
+ }
10586
+ const ancestor = findNode(nodes, ancestorId);
10587
+ return ancestor ? hasDescendant(ancestor, targetId) : false;
10588
+ }
10589
+ function computeDropPosition(e, isFolder) {
10590
+ const rect = e.currentTarget.getBoundingClientRect();
10591
+ const offsetY = e.clientY - rect.top;
10592
+ const third = rect.height / 3;
10593
+ if (offsetY < third) return "before";
10594
+ if (offsetY > third * 2) return "after";
10595
+ return isFolder ? "inside" : "after";
10596
+ }
10567
10597
  function TreeNode({ node, depth }) {
10568
- const { selectedId, onSelect, onNodeContextMenu, expandedIds, toggle, indentSize, showIcons } = useTreeNav();
10598
+ const {
10599
+ selectedId,
10600
+ onSelect,
10601
+ onNodeContextMenu,
10602
+ expandedIds,
10603
+ toggle,
10604
+ indentSize,
10605
+ showIcons,
10606
+ draggable,
10607
+ dragState,
10608
+ setDragState,
10609
+ onNodeMove,
10610
+ nodes,
10611
+ expandNode
10612
+ } = useTreeNav();
10569
10613
  const isFolder = node.type === "folder" || node.children && node.children.length > 0;
10570
10614
  const isExpanded = expandedIds.includes(node.id);
10571
10615
  const isSelected = selectedId === node.id;
10572
10616
  const paddingLeft = depth * indentSize + 4;
10617
+ const isDragging = dragState.draggedNodeId === node.id;
10618
+ const isDropTarget = dragState.dropTargetId === node.id;
10619
+ const dropPosition = isDropTarget ? dragState.dropPosition : null;
10620
+ const autoExpandTimer = react.useRef(null);
10621
+ const clearAutoExpand = react.useCallback(() => {
10622
+ if (autoExpandTimer.current) {
10623
+ clearTimeout(autoExpandTimer.current);
10624
+ autoExpandTimer.current = null;
10625
+ }
10626
+ }, []);
10627
+ react.useEffect(() => clearAutoExpand, [clearAutoExpand]);
10573
10628
  const handleClick = () => {
10574
10629
  if (node.disabled) return;
10575
10630
  if (isFolder) {
@@ -10583,30 +10638,116 @@ function TreeNode({ node, depth }) {
10583
10638
  onNodeContextMenu(e, node);
10584
10639
  }
10585
10640
  };
10586
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { "data-react-fancy-tree-node": "", children: [
10587
- /* @__PURE__ */ jsxRuntime.jsxs(
10588
- "button",
10589
- {
10590
- type: "button",
10591
- onClick: handleClick,
10592
- onContextMenu: handleContextMenu,
10593
- disabled: node.disabled,
10594
- className: cn(
10595
- "flex w-full items-center gap-1 rounded-md py-0.5 text-left text-[13px] transition-colors",
10596
- isSelected ? "bg-blue-500/15 text-blue-600 dark:text-blue-400" : "text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800",
10597
- node.disabled && "pointer-events-none opacity-40"
10598
- ),
10599
- style: { paddingLeft },
10600
- children: [
10601
- isFolder && /* @__PURE__ */ jsxRuntime.jsx(ChevronIcon, { open: isExpanded }),
10602
- !isFolder && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "w-3.5 shrink-0" }),
10603
- showIcons && (node.icon ?? (isFolder ? /* @__PURE__ */ jsxRuntime.jsx(FolderIcon, { open: isExpanded }) : /* @__PURE__ */ jsxRuntime.jsx(FileIcon, { ext: node.ext ?? node.label.split(".").pop() }))),
10604
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "truncate", children: node.label })
10605
- ]
10641
+ const handleDragStart = react.useCallback((e) => {
10642
+ e.dataTransfer.setData("text/plain", node.id);
10643
+ e.dataTransfer.effectAllowed = "move";
10644
+ requestAnimationFrame(() => {
10645
+ setDragState({ draggedNodeId: node.id, dropTargetId: null, dropPosition: null });
10646
+ });
10647
+ }, [node.id, setDragState]);
10648
+ const handleDragEnd = react.useCallback(() => {
10649
+ clearAutoExpand();
10650
+ setDragState({ draggedNodeId: null, dropTargetId: null, dropPosition: null });
10651
+ }, [clearAutoExpand, setDragState]);
10652
+ const handleDragOver = react.useCallback((e) => {
10653
+ if (!dragState.draggedNodeId) return;
10654
+ const sourceId = dragState.draggedNodeId;
10655
+ if (sourceId === node.id) return;
10656
+ if (isDescendantOf(nodes, sourceId, node.id)) return;
10657
+ e.preventDefault();
10658
+ e.stopPropagation();
10659
+ e.dataTransfer.dropEffect = "move";
10660
+ const position = computeDropPosition(e, !!isFolder);
10661
+ if (isFolder && !isExpanded && position === "inside") {
10662
+ if (!autoExpandTimer.current) {
10663
+ autoExpandTimer.current = setTimeout(() => {
10664
+ expandNode(node.id);
10665
+ autoExpandTimer.current = null;
10666
+ }, 500);
10606
10667
  }
10607
- ),
10608
- isFolder && isExpanded && node.children && /* @__PURE__ */ jsxRuntime.jsx("div", { "data-react-fancy-tree-node-children": "", children: node.children.map((child) => /* @__PURE__ */ jsxRuntime.jsx(TreeNode, { node: child, depth: depth + 1 }, child.id)) })
10609
- ] });
10668
+ } else {
10669
+ clearAutoExpand();
10670
+ }
10671
+ if (dragState.dropTargetId !== node.id || dragState.dropPosition !== position) {
10672
+ setDragState({ draggedNodeId: sourceId, dropTargetId: node.id, dropPosition: position });
10673
+ }
10674
+ }, [dragState, node.id, isFolder, isExpanded, nodes, setDragState, expandNode, clearAutoExpand]);
10675
+ const handleDragLeave = react.useCallback((e) => {
10676
+ if (!e.currentTarget.contains(e.relatedTarget)) {
10677
+ clearAutoExpand();
10678
+ if (dragState.dropTargetId === node.id) {
10679
+ setDragState({ ...dragState, dropTargetId: null, dropPosition: null });
10680
+ }
10681
+ }
10682
+ }, [dragState, node.id, setDragState, clearAutoExpand]);
10683
+ const handleDrop = react.useCallback((e) => {
10684
+ e.preventDefault();
10685
+ e.stopPropagation();
10686
+ clearAutoExpand();
10687
+ const sourceId = dragState.draggedNodeId;
10688
+ const position = dragState.dropPosition;
10689
+ if (!sourceId || !position) return;
10690
+ if (sourceId === node.id) return;
10691
+ if (isDescendantOf(nodes, sourceId, node.id)) return;
10692
+ onNodeMove?.(sourceId, node.id, position);
10693
+ setDragState({ draggedNodeId: null, dropTargetId: null, dropPosition: null });
10694
+ }, [dragState, node.id, nodes, onNodeMove, setDragState, clearAutoExpand]);
10695
+ const canDrag = draggable && !node.disabled;
10696
+ return /* @__PURE__ */ jsxRuntime.jsxs(
10697
+ "div",
10698
+ {
10699
+ "data-react-fancy-tree-node": "",
10700
+ onDragOver: draggable ? handleDragOver : void 0,
10701
+ onDragLeave: draggable ? handleDragLeave : void 0,
10702
+ onDrop: draggable ? handleDrop : void 0,
10703
+ children: [
10704
+ isDropTarget && dropPosition === "before" && /* @__PURE__ */ jsxRuntime.jsx(
10705
+ "div",
10706
+ {
10707
+ "data-react-fancy-tree-drop-indicator": "before",
10708
+ className: "pointer-events-none h-0.5 rounded-full bg-blue-500",
10709
+ style: { marginLeft: paddingLeft }
10710
+ }
10711
+ ),
10712
+ /* @__PURE__ */ jsxRuntime.jsxs(
10713
+ "button",
10714
+ {
10715
+ type: "button",
10716
+ draggable: canDrag,
10717
+ onDragStart: canDrag ? handleDragStart : void 0,
10718
+ onDragEnd: canDrag ? handleDragEnd : void 0,
10719
+ onClick: handleClick,
10720
+ onContextMenu: handleContextMenu,
10721
+ disabled: node.disabled,
10722
+ className: cn(
10723
+ "flex w-full items-center gap-1 rounded-md py-0.5 text-left text-[13px] transition-colors",
10724
+ isSelected ? "bg-blue-500/15 text-blue-600 dark:text-blue-400" : "text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800",
10725
+ node.disabled && "pointer-events-none opacity-40",
10726
+ isDragging && "opacity-50",
10727
+ canDrag && "cursor-grab active:cursor-grabbing",
10728
+ isDropTarget && dropPosition === "inside" && "bg-blue-500/10 ring-1 ring-blue-500/30 ring-inset"
10729
+ ),
10730
+ style: { paddingLeft },
10731
+ children: [
10732
+ isFolder && /* @__PURE__ */ jsxRuntime.jsx(ChevronIcon, { open: isExpanded }),
10733
+ !isFolder && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "w-3.5 shrink-0" }),
10734
+ showIcons && (node.icon ?? (isFolder ? /* @__PURE__ */ jsxRuntime.jsx(FolderIcon, { open: isExpanded }) : /* @__PURE__ */ jsxRuntime.jsx(FileIcon, { ext: node.ext ?? node.label.split(".").pop() }))),
10735
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "truncate", children: node.label })
10736
+ ]
10737
+ }
10738
+ ),
10739
+ isDropTarget && dropPosition === "after" && /* @__PURE__ */ jsxRuntime.jsx(
10740
+ "div",
10741
+ {
10742
+ "data-react-fancy-tree-drop-indicator": "after",
10743
+ className: "pointer-events-none h-0.5 rounded-full bg-blue-500",
10744
+ style: { marginLeft: paddingLeft }
10745
+ }
10746
+ ),
10747
+ isFolder && isExpanded && node.children && /* @__PURE__ */ jsxRuntime.jsx("div", { "data-react-fancy-tree-node-children": "", children: node.children.map((child) => /* @__PURE__ */ jsxRuntime.jsx(TreeNode, { node: child, depth: depth + 1 }, child.id)) })
10748
+ ]
10749
+ }
10750
+ );
10610
10751
  }
10611
10752
  TreeNode.displayName = "TreeNode";
10612
10753
  function collectFolderIds(nodes) {
@@ -10619,11 +10760,18 @@ function collectFolderIds(nodes) {
10619
10760
  }
10620
10761
  return ids;
10621
10762
  }
10763
+ var EMPTY_DRAG_STATE = {
10764
+ draggedNodeId: null,
10765
+ dropTargetId: null,
10766
+ dropPosition: null
10767
+ };
10622
10768
  function TreeNavRoot({
10623
10769
  nodes,
10624
10770
  selectedId,
10625
10771
  onSelect,
10626
10772
  onNodeContextMenu,
10773
+ draggable = false,
10774
+ onNodeMove,
10627
10775
  expandedIds: controlledExpanded,
10628
10776
  defaultExpandedIds,
10629
10777
  onExpandedChange,
@@ -10649,15 +10797,58 @@ function TreeNavRoot({
10649
10797
  },
10650
10798
  [expandedIds, isControlled, onExpandedChange]
10651
10799
  );
10800
+ const expandNode = react.useCallback(
10801
+ (id) => {
10802
+ if (expandedIds.includes(id)) return;
10803
+ const next = [...expandedIds, id];
10804
+ if (!isControlled) {
10805
+ setInternalExpanded(next);
10806
+ }
10807
+ onExpandedChange?.(next);
10808
+ },
10809
+ [expandedIds, isControlled, onExpandedChange]
10810
+ );
10811
+ const [dragState, setDragState] = react.useState(EMPTY_DRAG_STATE);
10812
+ const handleDragEnd = react.useCallback(() => {
10813
+ setDragState(EMPTY_DRAG_STATE);
10814
+ }, []);
10652
10815
  const ctx = react.useMemo(
10653
- () => ({ selectedId, onSelect, onNodeContextMenu, expandedIds, toggle, indentSize, showIcons }),
10654
- [selectedId, onSelect, onNodeContextMenu, expandedIds, toggle, indentSize, showIcons]
10816
+ () => ({
10817
+ selectedId,
10818
+ onSelect,
10819
+ onNodeContextMenu,
10820
+ expandedIds,
10821
+ toggle,
10822
+ indentSize,
10823
+ showIcons,
10824
+ draggable,
10825
+ dragState,
10826
+ setDragState,
10827
+ onNodeMove,
10828
+ nodes,
10829
+ expandNode
10830
+ }),
10831
+ [
10832
+ selectedId,
10833
+ onSelect,
10834
+ onNodeContextMenu,
10835
+ expandedIds,
10836
+ toggle,
10837
+ indentSize,
10838
+ showIcons,
10839
+ draggable,
10840
+ dragState,
10841
+ onNodeMove,
10842
+ nodes,
10843
+ expandNode
10844
+ ]
10655
10845
  );
10656
10846
  return /* @__PURE__ */ jsxRuntime.jsx(TreeNavContext.Provider, { value: ctx, children: /* @__PURE__ */ jsxRuntime.jsx(
10657
10847
  "nav",
10658
10848
  {
10659
10849
  "data-react-fancy-tree-nav": "",
10660
10850
  className: cn("flex flex-col gap-0.5 py-1 text-sm", className),
10851
+ onDragEnd: draggable ? handleDragEnd : void 0,
10661
10852
  children: nodes.map((node) => /* @__PURE__ */ jsxRuntime.jsx(TreeNode, { node, depth: 0 }, node.id))
10662
10853
  }
10663
10854
  ) });