import { Component, NewState, Util, Hash } from '@web/state' export const VirtualScroll = () => { const itemHeights = new Map() const groupHeights = new Map() let groupItemCount = 1 const avg = Util.newAvg() let padTop = 0, rowGap = 0, topMargin = 0, itemMarginTop = null, itemMarginBottom = null, listInited = false return { reset: (list, container) => { 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)); }, init: (list, refreshCallback) => { 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); for (let i = 0; i < size; i += groupItemCount) { let sum = 0; for (let j = i; j < Math.min(i + groupItemCount, size); j++) sum += itemHeights.get(j); groupHeights.set(i, sum); } listInited = true; refreshCallback(); }, update: (absoluteIndex, node) => { if (itemMarginTop === null) { const style = window.getComputedStyle(node); itemMarginTop = parseFloat(style.marginTop) || 0; itemMarginBottom = parseFloat(style.marginBottom) || 0; } if (absoluteIndex === 0) topMargin = itemMarginTop; const newHeight = node.offsetHeight + itemMarginTop + itemMarginBottom + rowGap; const oldHeight = itemHeights.get(absoluteIndex); if (newHeight !== oldHeight) { 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); } }, calc: (container, list) => { if (!listInited || !list) return null; const size = list.length, visibleCount = Math.ceil((container.clientHeight || 100) / (avg.get() || 32)); let prev = padTop + topMargin + rowGap, post = 0, status = 0, listStartIndex = 0, listEndIndex = 0; for (let i = 0; i < size; i++) { if (status === 0) { const gh = groupHeights.get(i); if (gh && prev + gh < container.scrollTop) { prev += gh; i += Math.min(groupItemCount, size - i) - 1; } else { const ih = itemHeights.get(i); if (prev + ih < container.scrollTop) prev += ih; else { status = 1; let visibleStartIndex = Math.max(0, i); 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); } } } else if (status === 1) { const gh = groupHeights.get(i); if (gh) { post += gh; i += groupItemCount - 1; } else post += itemHeights.get(i); } } return { prevHeight: Math.max(0, prev - padTop - topMargin - rowGap), postHeight: post, renderedList: list.slice(listStartIndex, listEndIndex), listStartIndex }; } } } 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 } container.state.__watch('list', updateFlatList) const vs = container.fast ? VirtualScroll() : null container.state._renderedList = [] container.refresh = () => { if (!container.fast) return const res = vs.calc(container, container.state._flatList) if (res) { container.state.prevHeight = res.prevHeight container.state.postHeight = res.postHeight container.state._listStartIndex = res.listStartIndex container.state._renderedList = res.renderedList } } container.onItemUpdate = (index, node) => { if (container.fast) vs.update(index + (container.state._listStartIndex || 0), node) } 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 }) container.selectItem = (item, index) => { 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) } })) } container.selectGroup = (item, index) => { 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 } })) } container.toggleCollapse = (item) => { if (container.collapsible && item._hasChildren) { container.collapsed[item[container.idfield]] = !container.collapsed[item[container.idfield]]; updateFlatList(); } } updateFlatList() }, Util.makeDom(/*html*/`
`))