2026-05-14 20:04:31 +08:00
|
|
|
import { Component, NewState, Util, Hash } from '@web/state'
|
|
|
|
|
|
2026-05-17 16:59:44 +08:00
|
|
|
export const VirtualScroll = () => {
|
2026-05-14 20:04:31 +08:00
|
|
|
const itemHeights = new Map()
|
|
|
|
|
const groupHeights = new Map()
|
|
|
|
|
let groupItemCount = 1
|
|
|
|
|
const avg = Util.newAvg()
|
2026-05-19 07:18:01 +08:00
|
|
|
let padTop = 0, rowGap = 0, topMargin = 0, itemMarginTop = null, itemMarginBottom = null, listInited = false
|
2026-05-14 20:04:31 +08:00
|
|
|
|
2026-05-17 16:59:44 +08:00
|
|
|
return {
|
|
|
|
|
reset: (list, container) => {
|
2026-05-19 07:18:01 +08:00
|
|
|
listInited = false; itemHeights.clear(); groupHeights.clear(); avg.clear(); topMargin = 0; itemMarginTop = null; itemMarginBottom = null;
|
|
|
|
|
if (!list?.length) return [];
|
|
|
|
|
const size = list.length; groupItemCount = Math.ceil(Math.sqrt(size)) || 10;
|
|
|
|
|
const style = window.getComputedStyle(container);
|
|
|
|
|
padTop = parseFloat(style.paddingTop) || 0; rowGap = parseFloat(style.rowGap) || 0;
|
|
|
|
|
return list.slice(0, Math.min(30, size));
|
2026-05-17 16:59:44 +08:00
|
|
|
},
|
|
|
|
|
init: (list, refreshCallback) => {
|
2026-05-19 07:18:01 +08:00
|
|
|
if (listInited) return;
|
|
|
|
|
const size = list.length, defaultHeight = avg.get() || 32;
|
|
|
|
|
for (let i = 0; i < size; i++) if (!itemHeights.has(i)) itemHeights.set(i, defaultHeight);
|
2026-05-17 16:59:44 +08:00
|
|
|
for (let i = 0; i < size; i += groupItemCount) {
|
2026-05-19 07:18:01 +08:00
|
|
|
let sum = 0;
|
|
|
|
|
for (let j = i; j < Math.min(i + groupItemCount, size); j++) sum += itemHeights.get(j);
|
|
|
|
|
groupHeights.set(i, sum);
|
2026-05-14 23:13:04 +08:00
|
|
|
}
|
2026-05-19 07:18:01 +08:00
|
|
|
listInited = true; refreshCallback();
|
2026-05-17 16:59:44 +08:00
|
|
|
},
|
|
|
|
|
update: (absoluteIndex, node) => {
|
|
|
|
|
if (itemMarginTop === null) {
|
2026-05-19 07:18:01 +08:00
|
|
|
const style = window.getComputedStyle(node);
|
|
|
|
|
itemMarginTop = parseFloat(style.marginTop) || 0; itemMarginBottom = parseFloat(style.marginBottom) || 0;
|
2026-05-17 16:59:44 +08:00
|
|
|
}
|
2026-05-19 07:18:01 +08:00
|
|
|
if (absoluteIndex === 0) topMargin = itemMarginTop;
|
|
|
|
|
const newHeight = node.offsetHeight + itemMarginTop + itemMarginBottom + rowGap;
|
|
|
|
|
const oldHeight = itemHeights.get(absoluteIndex);
|
2026-05-17 16:59:44 +08:00
|
|
|
if (newHeight !== oldHeight) {
|
2026-05-19 07:18:01 +08:00
|
|
|
itemHeights.set(absoluteIndex, newHeight); avg.add(newHeight);
|
|
|
|
|
const offset = newHeight - (oldHeight || 0), groupIndex = absoluteIndex - (absoluteIndex % groupItemCount);
|
|
|
|
|
if (groupHeights.has(groupIndex)) groupHeights.set(groupIndex, groupHeights.get(groupIndex) + offset);
|
2026-05-17 16:59:44 +08:00
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
calc: (container, list) => {
|
2026-05-19 07:18:01 +08:00
|
|
|
if (!listInited || !list) return null;
|
2026-05-20 08:54:56 +08:00
|
|
|
const size = list.length, visibleCount = Math.max(10, Math.ceil((container.clientHeight || 100) / (avg.get() || 32)));
|
2026-05-19 07:18:01 +08:00
|
|
|
let prev = padTop + topMargin + rowGap, post = 0, status = 0, listStartIndex = 0, listEndIndex = 0;
|
2026-05-20 08:54:56 +08:00
|
|
|
const scrollTop = container.scrollTop;
|
|
|
|
|
|
2026-05-17 16:59:44 +08:00
|
|
|
for (let i = 0; i < size; i++) {
|
|
|
|
|
if (status === 0) {
|
2026-05-19 07:18:01 +08:00
|
|
|
const gh = groupHeights.get(i);
|
2026-05-20 20:14:16 +08:00
|
|
|
if (gh && prev + gh <= scrollTop && i + groupItemCount < size) { prev += gh; i += groupItemCount - 1; }
|
2026-05-19 07:18:01 +08:00
|
|
|
else {
|
|
|
|
|
const ih = itemHeights.get(i);
|
2026-05-20 20:14:16 +08:00
|
|
|
if (prev + ih <= scrollTop && i < size - 1) prev += ih;
|
2026-05-19 07:18:01 +08:00
|
|
|
else {
|
2026-05-20 08:54:56 +08:00
|
|
|
status = 1; let visibleStartIndex = i;
|
2026-05-19 07:18:01 +08:00
|
|
|
listStartIndex = Math.max(0, visibleStartIndex - visibleCount);
|
|
|
|
|
listEndIndex = Math.min(listStartIndex + visibleCount * 3, size);
|
|
|
|
|
i = listEndIndex - 1; for (let j = listStartIndex; j < visibleStartIndex; j++) prev -= itemHeights.get(j);
|
2026-05-17 16:59:44 +08:00
|
|
|
}
|
|
|
|
|
}
|
2026-05-20 08:54:56 +08:00
|
|
|
} else {
|
2026-05-19 07:18:01 +08:00
|
|
|
const gh = groupHeights.get(i);
|
2026-05-20 08:54:56 +08:00
|
|
|
if (gh && i + groupItemCount <= size) { post += gh; i += groupItemCount - 1; }
|
|
|
|
|
else post += itemHeights.get(i);
|
2026-05-17 16:59:44 +08:00
|
|
|
}
|
|
|
|
|
}
|
2026-05-19 07:18:01 +08:00
|
|
|
return { prevHeight: Math.max(0, prev - padTop - topMargin - rowGap), postHeight: post, renderedList: list.slice(listStartIndex, listEndIndex), listStartIndex };
|
2026-05-17 16:59:44 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 07:18:01 +08:00
|
|
|
Component.register('List', container => {
|
|
|
|
|
container.mode = container.getAttribute('mode') || 'normal'
|
|
|
|
|
container.fast = container.hasAttribute('fast')
|
|
|
|
|
container.collapsible = container.hasAttribute('collapsible')
|
|
|
|
|
|
|
|
|
|
const defaultSets = {
|
|
|
|
|
idfield: 'id', labelfield: 'label', summaryfield: 'summary',
|
|
|
|
|
groupidfield: 'id', grouplabelfield: 'label', groupsummaryfield: 'summary', groupfield: 'group',
|
|
|
|
|
parentfield: 'parent', groupicon: 'folder', itemicon: 'file'
|
|
|
|
|
}
|
|
|
|
|
container.collapsed = NewState({})
|
|
|
|
|
|
|
|
|
|
const updateFlatList = () => {
|
|
|
|
|
Util.updateDefaults(container, defaultSets)
|
|
|
|
|
const list = container.state.list || [], flatList = []
|
|
|
|
|
if (container.mode === 'group') {
|
|
|
|
|
const itemMap = {}
|
|
|
|
|
list.forEach(item => (itemMap[item[container.groupfield]] ??= []).push(item));
|
|
|
|
|
(container.state.groups || []).forEach(group => {
|
|
|
|
|
flatList.push({ type: 'group', ...group })
|
|
|
|
|
const items = itemMap[group[container.groupidfield]]
|
|
|
|
|
if (items) items.forEach(item => flatList.push({ type: 'item', ...item }))
|
|
|
|
|
})
|
|
|
|
|
} else if (container.mode === 'tree') {
|
|
|
|
|
const childrenMap = {}
|
|
|
|
|
list.forEach(item => (childrenMap[item[container.parentfield] || ''] ??= []).push(item))
|
|
|
|
|
const traverse = (items, level, parents) => items.forEach(item => {
|
|
|
|
|
const id = item[container.idfield], hasChildren = !!childrenMap[id]?.length
|
|
|
|
|
const isCollapsed = container.collapsed[id]
|
|
|
|
|
flatList.push({ type: 'item', ...item, _level: level, _hasChildren: hasChildren, _parents: parents })
|
|
|
|
|
if (hasChildren && !isCollapsed) traverse(childrenMap[id], level + 1, [...parents, id])
|
|
|
|
|
})
|
|
|
|
|
traverse(childrenMap[''] || [], 0, [])
|
|
|
|
|
} else list.forEach(item => flatList.push({ type: 'item', ...item }))
|
|
|
|
|
container.state._flatList = flatList
|
2026-05-17 16:59:44 +08:00
|
|
|
}
|
2026-05-19 07:18:01 +08:00
|
|
|
|
|
|
|
|
container.state.__watch('list', updateFlatList)
|
|
|
|
|
const vs = container.fast ? VirtualScroll() : null
|
|
|
|
|
container.state._renderedList = []
|
2026-05-17 16:59:44 +08:00
|
|
|
|
|
|
|
|
container.refresh = () => {
|
2026-05-19 07:18:01 +08:00
|
|
|
if (!container.fast) return
|
|
|
|
|
const res = vs.calc(container, container.state._flatList)
|
2026-05-17 16:59:44 +08:00
|
|
|
if (res) {
|
|
|
|
|
container.state.prevHeight = res.prevHeight
|
|
|
|
|
container.state.postHeight = res.postHeight
|
|
|
|
|
container.state._listStartIndex = res.listStartIndex
|
2026-05-19 07:18:01 +08:00
|
|
|
container.state._renderedList = res.renderedList
|
2026-05-17 16:59:44 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 07:18:01 +08:00
|
|
|
container.onItemUpdate = (index, node) => { if (container.fast) vs.update(index + (container.state._listStartIndex || 0), node) }
|
2026-05-14 20:04:31 +08:00
|
|
|
|
2026-05-19 07:18:01 +08:00
|
|
|
container.state.__watch('_flatList', flatList => {
|
|
|
|
|
if (container.fast) {
|
|
|
|
|
container.state._listStartIndex = 0
|
|
|
|
|
container.state._renderedList = vs.reset(flatList, container) || []
|
|
|
|
|
requestAnimationFrame(() => { if (container.state._flatList === flatList) vs.init(flatList, container.refresh) })
|
|
|
|
|
} else container.state._renderedList = flatList
|
2026-05-14 20:04:31 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
container.selectItem = (item, index) => {
|
2026-05-19 07:18:01 +08:00
|
|
|
if (container.hasAttribute('auto-select')) container.state.selectedItem = container.state.selectedItem === item[container.idfield] ? null : item[container.idfield]
|
|
|
|
|
container.dispatchEvent(new CustomEvent('itemclick', { bubbles: false, detail: { item, index: index + (container.fast ? (container.state._listStartIndex || 0) : 0) } }))
|
2026-05-14 20:04:31 +08:00
|
|
|
}
|
|
|
|
|
container.selectGroup = (item, index) => {
|
2026-05-19 07:18:01 +08:00
|
|
|
if (container.hasAttribute('auto-select-group')) container.state.selectedGroup = container.state.selectedGroup === item[container.groupidfield] ? null : item[container.groupidfield]
|
|
|
|
|
container.dispatchEvent(new CustomEvent('groupclick', { bubbles: false, detail: { item, index } }))
|
2026-05-14 20:04:31 +08:00
|
|
|
}
|
2026-05-19 07:18:01 +08:00
|
|
|
container.toggleCollapse = (item) => { if (container.collapsible && item._hasChildren) { container.collapsed[item[container.idfield]] = !container.collapsed[item[container.idfield]]; updateFlatList(); } }
|
2026-05-14 20:04:31 +08:00
|
|
|
|
2026-05-19 07:18:01 +08:00
|
|
|
updateFlatList()
|
2026-05-14 20:04:31 +08:00
|
|
|
}, Util.makeDom(/*html*/`
|
2026-05-20 20:14:16 +08:00
|
|
|
<div class="list-group overflow-auto" $onscroll="console.log('scroll event fired', thisNode.scrollTop); thisNode.fast && thisNode.refresh()" style="overflow-anchor:none">
|
2026-05-19 07:18:01 +08:00
|
|
|
<div $if="this.fast && (this.state?.prevHeight || 0) > 0" $style="height:\${this.state?.prevHeight}px;" class="flex-shrink-0"></div>
|
|
|
|
|
<template slot-id="item" $each="this.state?._renderedList">
|
|
|
|
|
<div $onupdate="this.onItemUpdate(index, thisNode)" $class="list-group-item list-group-item-action d-inline-flex align-items-center ps-2 pe-2 \${item.type==='group'?(this.state?.selectedGroup===item[this.groupidfield]?'active':''):(this.state?.selectedItem===item[this.idfield]?'active':'')}" $onclick="item.type==='group'?this.selectGroup(item,index):this.selectItem(item,index)">
|
|
|
|
|
<template $if="item.type === 'group'">
|
|
|
|
|
<span $if="this.groupicon" $class="bi bi-\${this.groupicon} text-body"></span>
|
|
|
|
|
<div class="flex-shrink-0 px-1" $text="\${item[this.grouplabelfield]}"></div>
|
|
|
|
|
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="\${item[this.groupsummaryfield]}"></div>
|
|
|
|
|
<div slot-id="group-actions"></div>
|
|
|
|
|
</template>
|
|
|
|
|
<template $if="item.type === 'item'">
|
|
|
|
|
<div $if="this.mode === 'tree'" $style="width:\${item._level * 16 + (this.collapsible ? 16 : 0)}px; cursor:\${this.collapsible ? 'pointer' : 'default'}" class="text-end text-muted flex-shrink-0" $onclick="event.stopPropagation(); this.toggleCollapse(item)">
|
|
|
|
|
<i $if="this.collapsible && item._hasChildren" $class="bi \${this.collapsed[item[this.idfield]] ? 'bi-caret-right-fill' : 'bi-caret-down-fill'}"></i>
|
|
|
|
|
</div>
|
|
|
|
|
<span $if="this.mode === 'tree'" $class="text-muted bi bi-\${item._hasChildren ? this.groupicon : this.itemicon}"></span>
|
|
|
|
|
<span $if="this.mode !== 'tree' && this.itemicon" class="bi bi-\${this.itemicon} text-body"></span>
|
|
|
|
|
<div class="flex-shrink-0 px-1" $text="\${item[this.labelfield]}"></div>
|
|
|
|
|
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="\${item[this.summaryfield]}"></div>
|
|
|
|
|
<div slot-id="item-actions"></div>
|
|
|
|
|
</template>
|
2026-05-14 20:04:31 +08:00
|
|
|
</div>
|
2026-05-19 07:18:01 +08:00
|
|
|
</template>
|
|
|
|
|
<div $if="this.fast && (this.state?.postHeight || 0) > 0" $style="height:\${this.state?.postHeight}px;" class="flex-shrink-0"></div>
|
|
|
|
|
</div>
|
2026-05-14 20:04:31 +08:00
|
|
|
`))
|