base/src/list.js
2026-05-17 16:59:44 +08:00

337 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
let rowGap = 0
let topMargin = 0
let itemMarginTop = null
let itemMarginBottom = null
let listInited = false
return {
// 核心1重置与首屏预渲染
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))
},
// 核心2无痛初始化缓存 (通过 rAF 调用)
init: (list, refreshCallback) => {
if (listInited) return
const size = list.length
const 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()
},
// 核心3原汁原味的极速高度更新
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)
const groupIndex = absoluteIndex - (absoluteIndex % groupItemCount)
if (groupHeights.has(groupIndex)) groupHeights.set(groupIndex, groupHeights.get(groupIndex) + offset)
}
},
// 核心4原汁原味的状态机滚动计算
calc: (container, list) => {
if (!listInited || !list) return null
const size = list.length
const visibleCount = Math.ceil((container.clientHeight || 100) / (avg.get() || 32))
let prev = padTop + topMargin + rowGap
let post = 0
let status = 0
let listStartIndex = 0
let 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: prev - padTop - topMargin - rowGap,
postHeight: post,
renderedList: list.slice(listStartIndex, listEndIndex),
listStartIndex
}
}
}
}
export const FastListComponent = Component.register('FastList', container => {
const vs = VirtualScroll()
container.state.renderedList = []
container.onItemUpdate = (index, node) => {
vs.update(index + (container.state._listStartIndex || 0), node)
}
container.refresh = () => {
const res = vs.calc(container, container.state.list)
if (res) {
container.state.prevHeight = res.prevHeight
container.state.postHeight = res.postHeight
container.state._listStartIndex = res.listStartIndex
container.state.renderedList = res.renderedList
}
}
container.state.__watch('list', list => {
container.state._listStartIndex = 0
container.state.renderedList = vs.reset(list, container) || []
requestAnimationFrame(() => {
if (container.state.list !== list) return
vs.init(list, container.refresh)
})
})
}, Util.makeDom(/*html*/`
<div class="overflow-auto" onscroll="this.refresh()" style="overflow-anchor:none">
<div $if="this.state.prevHeight>0" $style="height:\${this.state.prevHeight}px;" class="flex-shrink-0"></div>
<div slot-id="item" class="flex-shrink-0" $each="this.state.renderedList" $onupdate="this.onItemUpdate(index,thisNode)"></div>
<div $if="this.state.postHeight>0" $style="height:\${this.state.postHeight}px;" class="flex-shrink-0"></div>
</div>
`))
Component.attachSelectFeature = (container, eventName = 'itemclick', selectActionName = 'selectItem', activeActionName = 'itemActiveTag', selectedItemKey = 'selectedItem', idfieldKey = 'idfield', selectableAttr = 'auto-select') => {
const selectable = selectableAttr && container.hasAttribute(selectableAttr)
container[selectActionName] = (item, index) => {
if (selectable) container.state[selectedItemKey] = container.state[selectedItemKey] === item[container[idfieldKey]] ? null : item[container[idfieldKey]]
container.dispatchEvent(new CustomEvent(eventName, { bubbles: false, detail: !selectable || container.state[selectedItemKey] ? { item, index } : { item: {}, index: null } }))
}
container[activeActionName] = (item) => container.state[selectedItemKey] !== null && container.state[selectedItemKey] === item[container[idfieldKey]] ? 'active' : ''
}
Component.register('List', container => {
Component.attachSelectFeature(container)
const defaultSets = { idfield: 'id', labelfield: 'label', summaryfield: 'summary' }
container.state.__watch('list', oldList => {
Util.updateDefaults(container, defaultSets)
})
}, Util.makeDom(/*html*/`
<div class="list-group overflow-auto">
<div slot="item" slot-id="item" $each="this.state.list" $onclick="this.selectItem(item,index)" $class="list-group-item list-group-item-action list-group-item-heading d-inline-flex pe-auto align-items-baseline \${this.itemActiveTag?.(item)}">
<span $if="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>
</div>
</div>
`))
Component.register('GroupedList', container => {
Component.getSetupFunction('List')(container)
container._selectItem = container.selectItem
container.selectItem = (item, index) => {
container.state.selectedGroup = null
container._selectItem(item, index)
}
Component.attachSelectFeature(container, 'groupclick', 'selectGroup', 'groupActiveTag', 'selectedGroup', 'groupidfield', container.hasAttribute('auto-select-group') || container.hasAttribute('auto-select') && 'group-selectable')
container._selectGroup = container.selectGroup
container.selectGroup = (item, index) => {
container.state.selectedItem = null
container._selectGroup(item, index)
}
const defaultSets = { typefield: 'type', groupidfield: 'id', grouplabelfield: 'label', groupsummaryfield: 'summary', groupfield: 'group' }
container.state.__watch('list', oldList => {
Util.updateDefaults(container, defaultSets)
const newList = [];
(container.state.groups || []).forEach(group => {
newList.push({ [container.typefield]: 'group', ...group })
if (oldList) oldList.forEach(item => {
if (item[container.groupfield] === group[container.groupidfield]) {
newList.push({ [container.typefield]: 'item', ...item })
}
})
})
return newList
})
}, Util.makeDom(/*html*/`
<div class="list-group overflow-auto">
<template $each="this.state.list">
<div slot-id="group" $if="item.type === 'group'" $onclick="this.selectGroup(item,index)" style="height: 36px" $class="list-group-item list-group-item-action small d-inline-flex align-items-center ps-2 pe-2 \${this.groupActiveTag(item)}">
<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>
</div>
<div slot-id="item" $if="item.type === 'item'" $onclick="this.selectItem(item,index)" $class="list-group-item list-group-item-action d-inline-flex align-items-baseline ps-3 pe-2 \${this.itemActiveTag?.(item)}">
<span $if="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>
</div>
</template>
</div>
`))
Component.register('FastGroupedList', container => {
Component.getSetupFunction('List')(container)
Component.getSetupFunction('GroupedList')(container)
}, Util.makeDom(/*html*/`
<FastList class="list-group">
<div slot="item">
<div slot-id="group" $if="item.type === 'group'" $onclick="this.selectGroup(item,index)" style="height: 36px" $class="list-group-item list-group-item-action small d-inline-flex align-items-center ps-2 pe-2 \${this.groupActiveTag(item)}">
<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>
</div>
<div slot-id="item" $if="item.type === 'item'" $onclick="this.selectItem(item,index)" $class="list-group-item list-group-item-action d-inline-flex align-items-baseline ps-3 pe-2 \${this.itemActiveTag?.(item)}">
<span $if="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>
</div>
</div>
</FastList>
`))
Component.register('Tree', container => {
const defaultSets = { parentfield: 'parent', groupicon: 'folder', itemicon: 'file' }
container.state.__watch('list', () => {
Util.updateDefaults(container, defaultSets)
const childrenMap = {};
(container.state.list || []).forEach(item => (childrenMap[item[container.parentfield] || ''] ??= []).push(item))
const newList = []
const traverse = (list, level) => list.forEach(item => {
const id = item[container.idfield]
const hasChildren = !!childrenMap[id]?.length
newList.push({ ...item, _level: level, _hasChildren: hasChildren })
if (hasChildren) traverse(childrenMap[id], level + 1)
})
traverse(childrenMap[''] || [], 0)
return newList
})
}, Util.makeDom(/*html*/`
<List>
<div slot="item" $class="list-group-item list-group-item-action d-flex ps-2 align-items-center \${this.itemActiveTag?.(item)}" $onclick="this.selectItem(item,index)">
<div $style="width:\${item._level * 16}px" class="flex-shrink-0"></div>
<div $class="text-muted bi bi-\${item._hasChildren?this.groupicon:this.itemicon}"></div>
<div class="flex-grow-1 text-truncate px-2" $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>
</div>
</List>
`))
export const FastTreeComponent = Component.register('FastTree', container => {
Component.getSetupFunction('List')(container)
Component.getSetupFunction('Tree')(container)
}, Util.makeDom(/*html*/`
<FastList class="list-group list-group-action">
<div slot="item" $class="list-group-item list-group-item-action d-flex ps-2 align-items-center \${this.itemActiveTag?.(item)}" $onclick="this.selectItem(item,index)">
<div $style="width:\${item._level * 16}px" class="flex-shrink-0"></div>
<div $class="text-muted bi bi-\${item._hasChildren?this.groupicon:this.itemicon}"></div>
<div class="flex-grow-1 text-truncate px-2" $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>
</div>
</FastList>
`))
Component.register('CollapseTree', container => {
Component.attachSelectFeature(container)
const defaultSets = { idfield: 'id', parentfield: 'parent', labelfield: 'label', summaryfield: 'summary' }
container.collapsed = NewState({})
container.state.__watch('list', () => {
Util.updateDefaults(container, defaultSets)
const childrenMap = {};
(container.state.list || []).forEach(item => (childrenMap[item[container.parentfield] || ''] ??= []).push(item))
const newList = []
const traverse = (list, level, parents) => list.forEach(item => {
const id = item[container.idfield]
const hasChildren = !!childrenMap[id]?.length
newList.push({ ...item, _level: level, _hasChildren: hasChildren, _parents: parents })
if (hasChildren) traverse(childrenMap[id], level + 1, [...parents, id])
})
traverse(childrenMap[''] || [], 0, [])
return newList
})
}, Util.makeDom(/*html*/`
<List>
<div slot="item" slot-id="item" $if="!item._parents.some(cur=>this.collapsed[cur])" $class="list-group-item list-group-item-action d-flex ps-2 align-items-center \${this.itemActiveTag?.(item)}" $onclick="this.selectItem(item,index)">
<div $style="width:\${item._level * 16 + 16}px; cursor: pointer;" class="text-end text-muted flex-shrink-0" $onclick="event.stopPropagation();this.collapsed[item[this.idfield]] = !this.collapsed[item[this.idfield]]">
<i $if="item._hasChildren" $class="bi \${this.collapsed[item[this.idfield]] ? 'bi-caret-right-fill' : 'bi-caret-down-fill'}"></i>
</div>
<div class="flex-grow-1 text-truncate 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>
</div>
</List>
`))