2026-05-14 20:04:31 +08:00
|
|
|
import { Component, NewState, Util, Hash } from '@web/state'
|
|
|
|
|
|
|
|
|
|
export const FastListComponent = Component.register('FastList', container => {
|
|
|
|
|
const itemHeights = new Map()
|
|
|
|
|
const groupHeights = new Map()
|
|
|
|
|
let groupItemCount = 1
|
|
|
|
|
container.state.renderedList = []
|
|
|
|
|
const avg = Util.newAvg()
|
|
|
|
|
let containerPaddingTop = 0
|
|
|
|
|
let containerRowGap = 0
|
|
|
|
|
let topMargin = 0
|
2026-05-14 23:13:04 +08:00
|
|
|
let itemMarginTop = null
|
|
|
|
|
let itemMarginBottom = null
|
2026-05-14 20:04:31 +08:00
|
|
|
let listStartIndex = 0
|
|
|
|
|
let visibleStartIndex = 0
|
|
|
|
|
let visibleCount = 10
|
|
|
|
|
let listInited = false
|
|
|
|
|
|
|
|
|
|
container.onItemUpdate = (index, node) => {
|
|
|
|
|
const absoluteIndex = index + listStartIndex
|
2026-05-14 23:13:04 +08:00
|
|
|
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 + containerRowGap
|
2026-05-14 20:04:31 +08:00
|
|
|
const oldHeight = itemHeights.get(absoluteIndex)
|
|
|
|
|
if (newHeight !== oldHeight) {
|
|
|
|
|
itemHeights.set(absoluteIndex, newHeight)
|
|
|
|
|
avg.add(newHeight)
|
2026-05-14 23:13:04 +08:00
|
|
|
const offset = newHeight - (oldHeight || 0)
|
2026-05-14 20:04:31 +08:00
|
|
|
const groupIndex = absoluteIndex - (absoluteIndex % groupItemCount)
|
|
|
|
|
if (groupHeights.has(groupIndex)) groupHeights.set(groupIndex, groupHeights.get(groupIndex) + offset)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
container.refresh = () => {
|
|
|
|
|
if (!listInited) return
|
|
|
|
|
visibleCount = Math.ceil((container.clientHeight || 100) / (avg.get() || 32))
|
|
|
|
|
let list = []
|
|
|
|
|
const fullList = container.state.list
|
|
|
|
|
const fullSize = fullList.length
|
|
|
|
|
let i = 0
|
|
|
|
|
let prev = containerPaddingTop + topMargin + containerRowGap
|
|
|
|
|
let post = 0
|
|
|
|
|
let starus = 0
|
|
|
|
|
for (i = 0; i < fullSize; i++) {
|
|
|
|
|
if (starus === 0) {
|
|
|
|
|
const gh = groupHeights.get(i)
|
|
|
|
|
if (gh && prev + gh < container.scrollTop) {
|
|
|
|
|
prev += gh
|
|
|
|
|
i += Math.min(groupItemCount, fullSize - i) - 1
|
|
|
|
|
} else {
|
|
|
|
|
const ih = itemHeights.get(i)
|
|
|
|
|
if (prev + ih < container.scrollTop) {
|
|
|
|
|
prev += ih
|
|
|
|
|
} else {
|
|
|
|
|
starus = 1
|
|
|
|
|
visibleStartIndex = Math.max(0, i)
|
|
|
|
|
listStartIndex = Math.max(0, visibleStartIndex - visibleCount)
|
|
|
|
|
const listEndIndex = Math.min(listStartIndex + visibleCount * 3, fullSize)
|
|
|
|
|
i = listEndIndex - 1
|
|
|
|
|
list = fullList.slice(listStartIndex, listEndIndex)
|
|
|
|
|
for (let j = listStartIndex; j < visibleStartIndex; j++) prev -= itemHeights.get(j)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else if (starus === 1) {
|
|
|
|
|
const gh = groupHeights.get(i)
|
|
|
|
|
if (gh) {
|
|
|
|
|
post += gh
|
|
|
|
|
i += groupItemCount - 1
|
|
|
|
|
} else {
|
|
|
|
|
post += itemHeights.get(i)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
container.state.prevHeight = prev - containerPaddingTop - topMargin - containerRowGap
|
|
|
|
|
container.state.postHeight = post
|
|
|
|
|
container.state.renderedList = list
|
|
|
|
|
}
|
|
|
|
|
container.state.__watch('list', list => {
|
|
|
|
|
listInited = false
|
|
|
|
|
itemHeights.clear()
|
|
|
|
|
groupHeights.clear()
|
|
|
|
|
avg.clear()
|
2026-05-14 23:13:04 +08:00
|
|
|
topMargin = 0
|
|
|
|
|
itemMarginTop = null
|
|
|
|
|
itemMarginBottom = null
|
2026-05-14 20:04:31 +08:00
|
|
|
if (!list?.length) return
|
|
|
|
|
|
|
|
|
|
const listSize = list.length || 0
|
|
|
|
|
groupItemCount = Math.ceil(Math.sqrt(listSize)) || 10
|
|
|
|
|
const style = window.getComputedStyle(container)
|
2026-05-14 23:13:04 +08:00
|
|
|
containerPaddingTop = parseFloat(style.paddingTop) || 0
|
2026-05-14 20:04:31 +08:00
|
|
|
containerRowGap = parseFloat(style.rowGap) || 0
|
|
|
|
|
container.state.renderedList = list.slice(0, Math.min(30, listSize)) || []
|
2026-05-14 23:13:04 +08:00
|
|
|
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
if (listInited || container.state.list !== list) return
|
|
|
|
|
const defaultHeight = avg.get() || 32
|
|
|
|
|
for (let i = 0; i < listSize; i++) {
|
|
|
|
|
if (!itemHeights.has(i)) itemHeights.set(i, defaultHeight)
|
|
|
|
|
}
|
|
|
|
|
for (let i = 0; i < listSize; i += groupItemCount) {
|
|
|
|
|
let sum = 0
|
|
|
|
|
for (let j = i; j < Math.min(i + groupItemCount, listSize); j++) {
|
|
|
|
|
sum += itemHeights.get(j)
|
|
|
|
|
}
|
|
|
|
|
groupHeights.set(i, sum)
|
|
|
|
|
}
|
2026-05-14 20:04:31 +08:00
|
|
|
listInited = true
|
|
|
|
|
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>
|
|
|
|
|
`))
|