Skip to content

Virtual Tree Component Code ​

Dependencies

This component requires:

  • Vue 3 with Composition API
  • useVirtualTree composable
  • TreeRow.vue component (used internally by VirtualTree)

VirtualTree.vue ​

Structure only. Add your implementation in the marked sections.

vue
<script setup>
import TreeRow from "./TreeRow.vue";
import { useVirtualTree } from "@/composables/useVirtualTree";

/**
 * Virtual tree: roots loaded in chunks (optional) and children on expand.
 * Data: setNodes(roots) or loadNodeChunk(start, end) + loadChildren(node).
 * Optional: draggable (drag-and-drop + Ctrl+Up/Down).
 */
const props = defineProps({
  loadChildren: { type: Function, default: null },
  loadNodeChunk: { type: Function, default: null },
  chunkSize: { type: Number, default: 500 },
  draggable: { type: Boolean, default: false },
});

const emit = defineEmits(["load-children", "node-click", "node-move"]);

const {
  treeRef,
  viewportRef,
  visibleRows,
  spacerTopPx,
  spacerBottomPx,
  isChunkLoading,
  selectedId,
  setSelected,
  getDropTarget,
  moveNode,
  moveSelectedUp,
  moveSelectedDown,
  onScroll,
  toggleNode,
  expandIfCollapsed,
  expandAll,
  collapseAll,
  getExpandedIds,
  loadChunk,
  setNodes,
} = useVirtualTree(props);

function onToggle(nodeId) {
  toggleNode(nodeId, emit);
}

function onSelect(nodeId) {
  if (props.draggable) setSelected(nodeId);
}

function onExpandHover(nodeId) {
  if (props.draggable && nodeId) expandIfCollapsed(nodeId, emit);
}

async function onDrop(payload) {
  if (!props.draggable || !payload) return;
  const target = getDropTarget(
    payload.targetNodeId,
    payload.dropPosition,
    payload.insertAtParentLevel,
    payload.insertAsChild
  );
  if (target) await moveNode(payload.draggedNodeId, target, emit);
}

async function onKeydown(e) {
  if (!props.draggable) return;
  if (e.ctrlKey && e.key === "ArrowUp") {
    e.preventDefault();
    await moveSelectedUp(emit);
  } else if (e.ctrlKey && e.key === "ArrowDown") {
    e.preventDefault();
    await moveSelectedDown(emit);
  }
}

defineExpose({
  expandAll,
  collapseAll,
  getExpandedNodes: getExpandedIds,
  loadChunk,
  setNodes,
  isChunkLoading,
  selectedNodeId: selectedId,
  setSelectedNode: setSelected,
  moveNode,
});
</script>

<template>
  <!-- Root: tree container; viewport scrolls and shows virtual rows -->
  <div
    ref="treeRef"
    class="virtual-tree"
    :class="{ 'virtual-tree--draggable': draggable }"
  >
    <div
      ref="viewportRef"
      class="viewport"
      :tabindex="draggable ? 0 : -1"
      @scroll="onScroll"
      @keydown="onKeydown"
    >
      <div class="spacer" :style="{ height: spacerTopPx + 'px' }" />
      <TreeRow
        v-for="row in visibleRows"
        :key="row.id"
        :node="row"
        :selected="draggable && selectedId === row.id"
        :draggable="draggable"
        :is-last-sibling="row.isLastSibling"
        @toggle="onToggle"
        @select="onSelect"
        @drop="onDrop"
        @expand-hover="onExpandHover"
      />
      <div class="spacer" :style="{ height: spacerBottomPx + 'px' }" />
    </div>
  </div>
</template>

<style scoped lang="scss">
.virtual-tree {
  width: 100%;
  border: 1px solid #e0e0e0;
  border-radius: 4px;
  overflow: hidden;
  background: #fff;
  --viewport-height: 37.5rem;
  --row-height: 1.75rem;
  --row-gap: 0.5rem;
  --buffer: 5;
}

.viewport {
  height: var(--viewport-height, 37.5rem);
  overflow-y: auto;
  overflow-x: hidden;
  position: relative;
}

.viewport::-webkit-scrollbar {
  width: 0.5rem;
}
.viewport::-webkit-scrollbar-track {
  background: #f5f5f5;
}
.viewport::-webkit-scrollbar-thumb {
  background: #c0c0c0;
  border-radius: 4px;
}
.viewport::-webkit-scrollbar-thumb:hover {
  background: #a0a0a0;
}

.spacer {
  width: 100%;
  pointer-events: none;
}
</style>

TreeRow.vue ​

Structure only. Add your implementation in the marked sections.

vue
<script setup>
import { computed, onUnmounted } from "vue";

/**
 * Single row: label, chevron (if has children), expand/collapse.
 * When draggable: left of content = drop at parent level; center = as child; top/bottom edge = between rows.
 */
const props = defineProps({
  node: {
    type: Object,
    required: true,
  },
  selected: {
    type: Boolean,
    default: false,
  },
  draggable: {
    type: Boolean,
    default: false,
  },
  isLastSibling: {
    type: Boolean,
    default: false,
  },
});

const emit = defineEmits(["toggle", "select", "drop", "expand-hover"]);

/** Left padding in px from node level (16px per level). */
const indentPx = computed(() => props.node.level * 16);

/** Stops event bubbling and emits toggle so only the chevron toggles, not the row. */
function onChevronClick(e) {
  e.stopPropagation();
  emit("toggle", props.node.id);
}

/** Emits select when draggable; emits toggle when the node has children (row click expands/collapses). */
function onRowClick() {
  if (props.draggable) emit("select", props.node.id);
  if (props.node.hasChildren) emit("toggle", props.node.id);
}

/** Sets the dragged node id on dataTransfer so the drop target can resolve which node was dragged. */
function onDragStart(e) {
  if (!props.draggable) return;
  e.dataTransfer.setData("text/plain", props.node.id);
  e.dataTransfer.effectAllowed = "move";
  e.dataTransfer.dropEffect = "move";
}

const HOVER_EXPAND_MS = 300;
let hoverTimer = null;
let hoverNodeId = null;

/** Clears the hover-expand timeout and resets hover state so expand does not fire after leave. */
function clearHoverTimer() {
  if (hoverTimer) {
    clearTimeout(hoverTimer);
    hoverTimer = null;
  }
  hoverNodeId = null;
}

onUnmounted(clearHoverTimer);

/** Allows drop and shows move cursor; starts a short delay to emit expand-hover when over a collapsed node with children. */
function onDragOver(e) {
  if (!props.draggable) return;
  e.preventDefault();
  e.dataTransfer.dropEffect = "move";
  if (props.node.hasChildren && !props.node.expanded) {
    if (hoverNodeId === props.node.id) return;
    clearHoverTimer();
    hoverNodeId = props.node.id;
    hoverTimer = setTimeout(() => {
      emit("expand-hover", props.node.id);
      clearHoverTimer();
    }, HOVER_EXPAND_MS);
  } else {
    clearHoverTimer();
  }
}

/** Clears the hover-expand timer when the pointer leaves the row. */
function onDragLeave() {
  clearHoverTimer();
}

const EDGE = 0.15;
const LAST_SIBLING_AFTER = 0.4;

/** Computes drop intent from pointer position (parent level vs child vs before/after) and emits drop with target and intent. */
function onDrop(e) {
  if (!props.draggable) return;
  e.preventDefault();
  const draggedId = e.dataTransfer.getData("text/plain");
  if (!draggedId || draggedId === props.node.id) return;

  const rect = e.currentTarget.getBoundingClientRect();
  const contentLeft = rect.left + indentPx.value;
  const ratioY = (e.clientY - rect.top) / rect.height;
  const leftOfContent = e.clientX < contentLeft;

  let insertAtParentLevel = false;
  let insertAsChild = false;
  let dropPosition = "after";

  if (leftOfContent) {
    insertAtParentLevel = true;
    dropPosition = ratioY < 0.5 ? "before" : "after";
  } else {
    if (props.isLastSibling && ratioY > LAST_SIBLING_AFTER) {
      dropPosition = "after";
    } else if (ratioY >= EDGE && ratioY <= 1 - EDGE) {
      insertAsChild = true;
      dropPosition = "after";
    } else {
      dropPosition = ratioY < 0.5 ? "before" : "after";
    }
  }

  emit("drop", {
    draggedNodeId: draggedId,
    targetNodeId: props.node.id,
    dropPosition,
    insertAtParentLevel,
    insertAsChild,
  });
}
</script>

<template>
  <!-- Row root: receives click/drag events; padding-left indents by level. -->
  <div
    class="tree-row"
    :class="{
      'tree-row--selected': selected,
      'tree-row--draggable': draggable,
    }"
    :style="{ paddingLeft: indentPx + 'px' }"
    :draggable="draggable"
    @click="onRowClick"
    @dragstart="onDragStart"
    @dragover="onDragOver"
    @dragleave="onDragLeave"
    @drop="onDrop"
  >
    <!-- Inner content: chevron (or spacer), then label. Chevron rotates when expanded. -->
    <div class="tree-row-content">
      <span
        v-if="node.hasChildren"
        class="chevron"
        :class="{ expanded: node.expanded }"
        @click="onChevronClick"
      >
        <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
          <path
            d="M9 18L15 12L9 6"
            stroke="currentColor"
            stroke-width="2"
            stroke-linecap="round"
            stroke-linejoin="round"
          />
        </svg>
      </span>
      <span v-else class="chevron-spacer" />
      <span class="tree-label">{{ node.label }}</span>
    </div>
  </div>
</template>

<style scoped lang="scss">
/* Row container: fixed height from CSS var, gap below, no text selection; hover/selected states. */
.tree-row {
  height: var(--row-height, 28px);
  padding-bottom: var(--row-gap, 0.5rem);
  display: flex;
  align-items: center;
  padding-right: 0.5rem;
  cursor: pointer;
  user-select: none;
  transition: background-color 0.15s ease;
  position: relative;
  box-sizing: border-box;
}

.tree-row:hover {
  background-color: #f5f5f5;
}

.tree-row--selected {
  background-color: #e3f2fd;
}

.tree-row--selected:hover {
  background-color: #bbdefb;
}

/* When draggable, row shows grab cursor; grabbing while dragging. */
.tree-row--draggable {
  cursor: grab;
}

.tree-row--draggable:active {
  cursor: grabbing;
}

/* Flex container for chevron + label; label can shrink and ellipsis. */
.tree-row-content {
  display: flex;
  align-items: center;
  gap: 0.25rem;
  width: 100%;
  min-width: 0;
}

/* Chevron icon: fixed size, rotates 90deg when expanded. */
.chevron {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 1rem;
  height: 1rem;
  flex-shrink: 0;
  color: #666;
  transition: transform 0.2s ease;
  cursor: pointer;
}

.chevron:hover {
  color: #1976d2;
}

.chevron.expanded {
  transform: rotate(90deg);
}

.chevron svg {
  width: 0.875rem;
  height: 0.875rem;
}

/* Invisible spacer when node has no children so label alignment matches. */
.chevron-spacer {
  width: 1rem;
  height: 1rem;
  flex-shrink: 0;
}

/* Node label: fills remaining space, truncates with ellipsis. */
.tree-label {
  flex: 1;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 0.875rem;
  color: #333;
  line-height: 1.5;
}
</style>

useVirtualTree.js ​

Structure only. Add your implementation in the marked sections.

js
import {
  ref,
  computed,
  watch,
  onMounted,
  onUnmounted,
  markRaw,
  toRaw,
} from "vue";

/**
 * Virtual tree: chunked roots + lazy children. Data via setNodes(roots) or loadNodeChunk(start, end).
 * Tree data is kept in plain variables (not refs) so Vue never holds the big structure. Only
 * updateTrigger ref drives view updates: when tree or chunks change we bump it; computed read
 * updateTrigger then read the plain data. Capacity: 100k–1M+ roots with chunking; tens of
 * thousands expanded; DOM stays viewport-sized.
 */
export function useVirtualTree(props) {
  let treeData = [];
  let nodeByIdMap = new Map();
  let chunkByIdMap = new Map();
  let loadedChunkIdsSet = new Set();

  const updateTrigger = ref(0);
  const scrollTop = ref(0);
  const isMounted = ref(false);
  const treeRef = ref(null);
  const viewportRef = ref(null);

  const isChunkLoading = ref(false);
  const isChunked = computed(() => !!props.loadNodeChunk);

  const selectedId = ref(null);

  function prepareNode(node, includeChildren = false) {
    const out = {
      id: node.id,
      label: node.label,
      expanded: node.expanded ?? false,
      hasChildren:
        node.hasChildren ?? !!(node.children && node.children.length > 0),
      children: [],
      _childrenLoaded: false,
    };
    if (
      node.children &&
      Array.isArray(node.children) &&
      node.children.length > 0
    ) {
      if (includeChildren) {
        out.children = node.children.map((c) => prepareNode(c, true));
        out._childrenLoaded = true;
      } else {
        out.children = node.children;
        out._childrenLoaded = true;
        out.hasChildren = true;
      }
    }
    return out;
  }

  function buildNodeMap(nodes, map = new Map()) {
    if (!nodes || !Array.isArray(nodes)) return map;
    for (const n of nodes) {
      map.set(n.id, n);
      if (n.children && n.children.length) buildNodeMap(n.children, map);
    }
    return map;
  }

  function findParentAndIndex(nodes, nodeId) {
    if (!nodes || !Array.isArray(nodes)) return null;
    for (let i = 0; i < nodes.length; i++) {
      if (nodes[i].id === nodeId) return { parentArray: nodes, index: i };
      const found = findParentAndIndex(nodes[i].children, nodeId);
      if (found) return found;
    }
    return null;
  }

  function isDescendantOf(candidateId, ancestorId) {
    const ancestor = nodeByIdMap.get(ancestorId);
    if (!ancestor?.children?.length) return false;
    const visit = (list) => {
      for (const n of list) {
        if (n.id === candidateId) return true;
        if (n.children && visit(n.children)) return true;
      }
      return false;
    };
    return visit(ancestor.children);
  }

  // --- Chunked loading: load/unload by viewport ---
  function chunkIdForFlatIndex(flatIndex) {
    const size = props.chunkSize ?? 500;
    return Math.floor(flatIndex / size);
  }

  async function fetchChunk(chunkId) {
    if (!isChunked.value) return;
    if (loadedChunkIdsSet.has(chunkId)) return;
    if (isChunkLoading.value) return;
    isChunkLoading.value = true;
    try {
      const size = props.chunkSize ?? 500;
      const start = chunkId * size;
      const end = start + size;
      const data = await props.loadNodeChunk(start, end);
      if (data && Array.isArray(data)) {
        const nodes = data.map((n) => markRaw(prepareNode(n, false)));
        chunkByIdMap.set(chunkId, nodes);
        loadedChunkIdsSet.add(chunkId);
        treeData = [...treeData, ...nodes];
        nodeByIdMap = buildNodeMap(treeData);
        updateTrigger.value++;
      }
    } catch (e) {
      console.error("useVirtualTree: fetchChunk failed", chunkId, e);
    } finally {
      isChunkLoading.value = false;
    }
  }

  async function loadChunksInView() {
    if (!isChunked.value) return;
    const size = props.chunkSize ?? 500;
    const startChunk = chunkIdForFlatIndex(flatStartIndex.value);
    const endChunk = chunkIdForFlatIndex(flatEndIndex.value);
    for (let id = startChunk; id <= endChunk + 1; id++) {
      if (!loadedChunkIdsSet.has(id)) await fetchChunk(id);
    }
  }

  function unloadChunksOutOfView() {
    if (!isChunked.value) return;
    const size = props.chunkSize ?? 500;
    const startChunk = chunkIdForFlatIndex(flatStartIndex.value);
    const endChunk = chunkIdForFlatIndex(flatEndIndex.value);
    const keep = new Set();
    for (let i = startChunk - 2; i <= endChunk + 2; i++) {
      if (i >= 0) keep.add(i);
    }
    const toRemove = [];
    loadedChunkIdsSet.forEach((id) => {
      if (!keep.has(id)) toRemove.push(id);
    });
    if (toRemove.length === 0) return;

    const minKept = keep.size ? Math.min(...keep) : 0;
    let removedFromTop = 0;
    toRemove.forEach((id) => {
      if (id < minKept) {
        const ch = chunkByIdMap.get(id);
        if (ch) removedFromTop += ch.length;
      }
    });

    toRemove.forEach((id) => {
      chunkByIdMap.delete(id);
      loadedChunkIdsSet.delete(id);
    });
    const remaining = [];
    Array.from(loadedChunkIdsSet)
      .sort((a, b) => a - b)
      .forEach((id) => {
        const chunk = chunkByIdMap.get(id);
        if (chunk) remaining.push(...chunk);
      });
    treeData = remaining;
    nodeByIdMap = buildNodeMap(treeData);
    updateTrigger.value++;

    if (removedFromTop > 0 && viewportRef.value) {
      const deduct = removedFromTop * rowTotalPx.value;
      const newScroll = Math.max(0, scrollTop.value - deduct);
      scrollTop.value = newScroll;
      viewportRef.value.scrollTop = newScroll;
    }
  }

  // --- Flatten: visible rows for virtual scroll + drop targets ---
  function flatten(nodes, level = 0, parentId = null, out = []) {
    if (!nodes?.length) return out;
    const count = nodes.length;
    nodes.forEach((n, i) => {
      const hasKids = n.hasChildren || !!(n.children && n.children.length);
      out.push({
        id: n.id,
        label: n.label,
        level,
        expanded: !!n.expanded,
        hasChildren: hasKids,
        parentId,
        siblingIndex: i,
        isLastSibling: i === count - 1,
      });
      if (n.expanded && n._childrenLoaded && n.children?.length) {
        flatten(n.children, level + 1, n.id, out);
      }
    });
    return out;
  }

  const flatList = computed(() => {
    updateTrigger.value;
    return flatten(treeData);
  });

  function buildInsertPositions(nodes, parentId = null, out = []) {
    if (!nodes?.length) return out;
    nodes.forEach((n, i) => {
      out.push({ parentId, index: i });
      if (n.expanded && n.children?.length)
        buildInsertPositions(n.children, n.id, out);
      out.push({ parentId: n.id, index: n.children?.length || 0 });
    });
    return out;
  }

  const insertPositions = computed(() => {
    updateTrigger.value;
    return buildInsertPositions(treeData);
  });

  // --- Dimensions from CSS ---
  const viewportHeight = computed(() => {
    if (!isMounted.value || !viewportRef.value) return 0;
    return viewportRef.value.clientHeight;
  });

  const rowHeightPx = computed(() => {
    if (!isMounted.value || !treeRef.value) return 0;
    const style = getComputedStyle(treeRef.value);
    const rem = parseFloat(style.getPropertyValue("--row-height")) || 1.75;
    const root = parseFloat(
      getComputedStyle(document.documentElement).fontSize
    );
    return rem * root;
  });

  const rowGapPx = computed(() => {
    if (!isMounted.value || !treeRef.value) return 0;
    const style = getComputedStyle(treeRef.value);
    const gap = (style.getPropertyValue("--row-gap") || "0.5rem").trim();
    const root = parseFloat(
      getComputedStyle(document.documentElement).fontSize
    );
    if (gap.endsWith("rem")) return parseFloat(gap) * root;
    if (gap.endsWith("px")) return parseFloat(gap);
    return parseFloat(gap) || 8;
  });

  const rowTotalPx = computed(() => rowHeightPx.value + rowGapPx.value);

  const bufferRows = computed(() => {
    if (!isMounted.value || !treeRef.value) return 5;
    const style = getComputedStyle(treeRef.value);
    return parseInt(style.getPropertyValue("--buffer"), 10) || 5;
  });

  const flatStartIndex = computed(() =>
    Math.floor(scrollTop.value / rowTotalPx.value)
  );
  const flatVisibleCount = computed(() =>
    Math.ceil(viewportHeight.value / rowTotalPx.value)
  );
  const flatEndIndex = computed(() =>
    Math.min(
      flatStartIndex.value + flatVisibleCount.value + bufferRows.value,
      flatList.value.length
    )
  );

  const visibleRows = computed(() =>
    flatList.value.slice(flatStartIndex.value, flatEndIndex.value)
  );
  const spacerTopPx = computed(() => flatStartIndex.value * rowTotalPx.value);
  const spacerBottomPx = computed(() =>
    Math.max(0, (flatList.value.length - flatEndIndex.value) * rowTotalPx.value)
  );

  let scrollRaf = null;
  let chunkRaf = null;
  function onScroll(e) {
    if (scrollRaf) cancelAnimationFrame(scrollRaf);
    scrollRaf = requestAnimationFrame(() => {
      scrollTop.value = e.target.scrollTop;
      scrollRaf = null;
      if (isChunked.value) {
        if (chunkRaf) cancelAnimationFrame(chunkRaf);
        chunkRaf = requestAnimationFrame(() => {
          loadChunksInView();
          setTimeout(unloadChunksOutOfView, 500);
          chunkRaf = null;
        });
      }
    });
  }

  // --- Init: either chunked (load first chunks) or setNodes (full roots) ---
  function processAlreadyExpanded(nodes) {
    if (!nodes?.length) return;
    for (const n of nodes) {
      if (n.expanded && n._childrenLoaded && n.children?.length) {
        const first = n.children[0];
        if (!Object.prototype.hasOwnProperty.call(first, "_childrenLoaded")) {
          n.children = n.children.map((c) => markRaw(prepareNode(c, false)));
        }
        processAlreadyExpanded(n.children);
      }
    }
  }

  async function initTree(roots) {
    if (isChunked.value) {
      treeData = [];
      nodeByIdMap = new Map();
      chunkByIdMap = new Map();
      loadedChunkIdsSet = new Set();
      await fetchChunk(0);
      await fetchChunk(1);
      return;
    }
    if (!roots?.length) {
      treeData = [];
      nodeByIdMap = new Map();
      updateTrigger.value++;
      return;
    }
    const prepared = roots.map((n) => prepareNode(n, false));
    treeData = prepared.map((n) => markRaw(n));
    processAlreadyExpanded(treeData);
    nodeByIdMap = buildNodeMap(treeData);
    updateTrigger.value++;
  }

  async function toggleNode(nodeId, emit) {
    const n = nodeByIdMap.get(nodeId);
    if (!n) return;
    emit("node-click", {
      id: n.id,
      label: n.label,
      expanded: n.expanded,
      hasChildren: n.hasChildren,
    });
    const wasExpanded = n.expanded;
    n.expanded = !n.expanded;
    updateTrigger.value++;

    if (n.expanded && !wasExpanded && n.hasChildren) {
      if (n._childrenLoaded && n.children?.length) {
        n.children = n.children.map((c) => markRaw(prepareNode(c, false)));
        nodeByIdMap = buildNodeMap(treeData);
        updateTrigger.value++;
      } else if (!n._childrenLoaded && props.loadChildren) {
        emit("load-children", {
          id: n.id,
          label: n.label,
          hasChildren: n.hasChildren,
        });
        try {
          const children = await props.loadChildren(n);
          const arr = Array.isArray(children) ? children : [];
          n.children = arr.map((c) => markRaw(prepareNode(c, true)));
          n._childrenLoaded = true;
          n.hasChildren = arr.length > 0;
          nodeByIdMap = buildNodeMap(treeData);
          updateTrigger.value++;
        } catch (e) {
          console.error("useVirtualTree: loadChildren failed", nodeId, e);
          n.expanded = false;
          updateTrigger.value++;
        }
      } else if (!n._childrenLoaded && !props.loadChildren) {
        n.expanded = false;
        updateTrigger.value++;
      }
    }
  }

  async function expandIfCollapsed(nodeId, emit) {
    const n = nodeByIdMap.get(nodeId);
    if (!n || n.expanded) return;
    if (!n.hasChildren && !n.children?.length) return;
    await toggleNode(nodeId, emit);
  }

  async function expandAll() {
    if (props.loadChildren)
      console.warn("useVirtualTree: expandAll with loadChildren may be slow");
    async function expand(nodes) {
      for (const n of nodes) {
        if (n.hasChildren || n.children?.length) {
          n.expanded = true;
          if (n.hasChildren) {
            if (n._childrenLoaded && n.children?.length) {
              n.children = n.children.map((c) =>
                markRaw(prepareNode(c, false))
              );
              nodeByIdMap = buildNodeMap(treeData);
              updateTrigger.value++;
            } else if (!n._childrenLoaded && props.loadChildren) {
              try {
                const children = await props.loadChildren(n);
                const arr = Array.isArray(children) ? children : [];
                n.children = arr.map((c) => markRaw(prepareNode(c, true)));
                n._childrenLoaded = true;
                n.hasChildren = arr.length > 0;
                nodeByIdMap = buildNodeMap(treeData);
                updateTrigger.value++;
              } catch (e) {
                console.error(
                  "useVirtualTree: expandAll loadChildren failed",
                  n.id,
                  e
                );
              }
            }
          }
          if (n._childrenLoaded && n.children?.length) await expand(n.children);
        }
      }
    }
    await expand(treeData);
    updateTrigger.value++;
  }

  function collapseAll() {
    function collapse(nodes) {
      nodes.forEach((n) => {
        if (n.children?.length) {
          n.expanded = false;
          collapse(n.children);
        }
      });
    }
    collapse(treeData);
    updateTrigger.value++;
  }

  function getExpandedIds() {
    const out = [];
    function collect(nodes) {
      nodes.forEach((n) => {
        if (n.expanded) out.push(n.id);
        if (n.children) collect(n.children);
      });
    }
    collect(treeData);
    return out;
  }

  // --- Draggable: drop target and move ---
  function getDropTarget(
    targetId,
    dropPosition,
    insertAtParentLevel,
    insertAsChild
  ) {
    const flat = flatList.value;
    const target = flat.find((r) => r.id === targetId);
    if (!target) return null;
    if (insertAsChild) {
      const full = nodeByIdMap.get(targetId);
      const len = full?.children?.length ?? 0;
      return { parentId: targetId, index: len };
    }
    if (!insertAtParentLevel) {
      return {
        parentId: target.parentId ?? null,
        index:
          dropPosition === "before"
            ? target.siblingIndex
            : target.siblingIndex + 1,
      };
    }
    const parent =
      target.parentId == null
        ? null
        : flat.find((r) => r.id === target.parentId);
    if (!parent) {
      return {
        parentId: null,
        index:
          dropPosition === "before"
            ? target.siblingIndex
            : target.siblingIndex + 1,
      };
    }
    return {
      parentId: parent.parentId ?? null,
      index:
        dropPosition === "before"
          ? parent.siblingIndex
          : parent.siblingIndex + 1,
    };
  }

  function setSelected(id) {
    selectedId.value = id;
  }

  async function moveNode(draggedId, target, emit) {
    const { parentId, index } = target;
    const from = findParentAndIndex(treeData, draggedId);
    if (!from) return false;
    const { parentArray: fromArr, index: fromIdx } = from;
    if (parentId === draggedId) return false;
    if (parentId != null && isDescendantOf(parentId, draggedId)) return false;

    if (parentId != null) {
      const parent = nodeByIdMap.get(parentId);
      if (parent && !parent.expanded) await toggleNode(parentId, emit);
    }

    let toArr;
    let toIdx = Math.max(0, index);
    if (parentId == null) {
      toArr = treeData;
    } else {
      const parent = nodeByIdMap.get(parentId);
      if (!parent) return false;
      if (!parent.children) parent.children = [];
      toArr = parent.children;
    }
    toIdx = Math.min(toIdx, toArr.length);

    const [moved] = fromArr.splice(fromIdx, 1);
    toArr.splice(toIdx, 0, moved);

    if (parentId != null) {
      const parent = nodeByIdMap.get(parentId);
      if (parent) {
        parent.expanded = true;
        parent.hasChildren = !!parent.children?.length;
        parent._childrenLoaded = true;
      }
    }

    updateTrigger.value++;
    if (emit) {
      let fromParentId = null;
      if (fromArr !== treeData) {
        function findParent(nodes, arr) {
          for (const node of nodes) {
            if (node.children === arr) return node.id;
            const id = findParent(node.children || [], arr);
            if (id != null) return id;
          }
          return null;
        }
        fromParentId = findParent(treeData, fromArr);
      }
      emit("node-move", {
        nodeId: draggedId,
        label: moved.label,
        fromParentId,
        fromIndex: fromIdx,
        toParentId: parentId,
        toIndex: toIdx,
      });
    }
    return true;
  }

  function getSelectedPositionIndex() {
    const id = selectedId.value;
    if (!id) return -1;
    const flat = flatList.value;
    const row = flat.find((r) => r.id === id);
    if (!row) return -1;
    const list = insertPositions.value;
    return list.findIndex(
      (p) =>
        (p.parentId ?? null) === (row.parentId ?? null) &&
        p.index === row.siblingIndex
    );
  }

  async function moveSelectedUp(emit) {
    const id = selectedId.value;
    if (!id) return;
    const idx = getSelectedPositionIndex();
    if (idx <= 0) return;
    const t = insertPositions.value[idx - 1];
    if (t && !wouldCycle(id, t)) await moveNode(id, t, emit);
  }

  function getNextPositionDown(nodeId) {
    const n = nodeByIdMap.get(nodeId);
    if (!n) return null;
    const from = findParentAndIndex(treeData, nodeId);
    if (!from) return null;
    const { parentArray: sibs, index: si } = from;
    if (si + 1 < sibs.length) {
      return { parentId: sibs[si + 1].id, index: 0 };
    }
    if (n.children?.length) return { parentId: nodeId, index: 0 };
    if (sibs === treeData) return null;
    function parentIdOf(arr) {
      for (const node of treeData) {
        if (node.children === arr) return node.id;
        const id = parentIdOf(node.children || []);
        if (id != null) return id;
      }
      return null;
    }
    const pid = parentIdOf(sibs);
    if (pid == null) return null;
    const pFrom = findParentAndIndex(treeData, pid);
    if (!pFrom) return null;
    const gpArr = pFrom.parentArray;
    const pSi = pFrom.index;
    const gpid = gpArr === treeData ? null : parentIdOf(gpArr);
    return { parentId: gpid, index: pSi + 1 };
  }

  async function moveSelectedDown(emit) {
    const id = selectedId.value;
    if (!id) return;
    const t = getNextPositionDown(id);
    if (!t || wouldCycle(id, t)) return;
    await moveNode(id, t, emit);
  }

  function wouldCycle(nodeId, target) {
    if (!target) return true;
    if (target.parentId == null) return false;
    if (target.parentId === nodeId) return true;
    return isDescendantOf(target.parentId, nodeId);
  }

  function setNodes(roots) {
    if (isChunked.value) return;
    const plain = roots != null && Array.isArray(roots) ? toRaw(roots) : [];
    initTree(plain);
  }

  watch(
    () => [props.chunkSize],
    () => {
      if (isChunked.value) initTree();
    }
  );

  let resizeObs = null;
  onMounted(async () => {
    isMounted.value = true;
    if (isChunked.value) await initTree();
    resizeObs = new ResizeObserver(() => {
      scrollTop.value = scrollTop.value;
    });
    resizeObs.observe(document.documentElement);
  });

  onUnmounted(() => {
    if (resizeObs) resizeObs.disconnect();
    if (scrollRaf) cancelAnimationFrame(scrollRaf);
    if (chunkRaf) cancelAnimationFrame(chunkRaf);
  });

  return {
    treeRef,
    viewportRef,
    visibleRows,
    spacerTopPx,
    spacerBottomPx,
    flatList,
    isChunkLoading,
    selectedId,
    setSelected,
    getDropTarget,
    moveNode,
    moveSelectedUp,
    moveSelectedDown,
    onScroll,
    toggleNode,
    expandIfCollapsed,
    expandAll,
    collapseAll,
    getExpandedIds,
    loadChunk: fetchChunk,
    setNodes,
  };
}