Appearance
Virtual Tree Component Code ​
Dependencies
This component requires:
- Vue 3 with Composition API
useVirtualTreecomposableTreeRow.vuecomponent (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,
};
}