feat: optimize updateFlatList O(N+M) and release base v1.0.3
This commit is contained in:
parent
388a4d69b1
commit
08e448f845
339
dist/base.js
vendored
339
dist/base.js
vendored
@ -362,14 +362,8 @@ const VirtualScroll = () => {
|
||||
const groupHeights = /* @__PURE__ */ 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;
|
||||
let padTop = 0, rowGap = 0, topMargin = 0, itemMarginTop = null, itemMarginBottom = null, listInited = false;
|
||||
return {
|
||||
// 核心1:重置与首屏预渲染
|
||||
reset: (list, container) => {
|
||||
listInited = false;
|
||||
itemHeights.clear();
|
||||
@ -386,25 +380,18 @@ const VirtualScroll = () => {
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
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);
|
||||
@ -417,21 +404,14 @@ const VirtualScroll = () => {
|
||||
if (newHeight !== oldHeight) {
|
||||
itemHeights.set(absoluteIndex, newHeight);
|
||||
avg.add(newHeight);
|
||||
const offset = newHeight - (oldHeight || 0);
|
||||
const groupIndex = absoluteIndex - absoluteIndex % groupItemCount;
|
||||
const offset = newHeight - (oldHeight || 0), 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;
|
||||
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);
|
||||
@ -440,9 +420,8 @@ const VirtualScroll = () => {
|
||||
i += Math.min(groupItemCount, size - i) - 1;
|
||||
} else {
|
||||
const ih = itemHeights.get(i);
|
||||
if (prev + ih < container.scrollTop) {
|
||||
prev += ih;
|
||||
} else {
|
||||
if (prev + ih < container.scrollTop) prev += ih;
|
||||
else {
|
||||
status = 1;
|
||||
let visibleStartIndex = Math.max(0, i);
|
||||
listStartIndex = Math.max(0, visibleStartIndex - visibleCount);
|
||||
@ -456,248 +435,130 @@ const VirtualScroll = () => {
|
||||
if (gh) {
|
||||
post += gh;
|
||||
i += groupItemCount - 1;
|
||||
} else {
|
||||
post += itemHeights.get(i);
|
||||
} else post += itemHeights.get(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
prevHeight: prev - padTop - topMargin - rowGap,
|
||||
postHeight: post,
|
||||
renderedList: list.slice(listStartIndex, listEndIndex),
|
||||
listStartIndex
|
||||
};
|
||||
return { prevHeight: Math.max(0, prev - padTop - topMargin - rowGap), postHeight: post, renderedList: list.slice(listStartIndex, listEndIndex), listStartIndex };
|
||||
}
|
||||
};
|
||||
};
|
||||
const FastListComponent = Component.register("FastList", (container) => {
|
||||
const vs = VirtualScroll();
|
||||
container.state.renderedList = [];
|
||||
container.onItemUpdate = (index2, node) => {
|
||||
vs.update(index2 + (container.state._listStartIndex || 0), node);
|
||||
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) => {
|
||||
var _a;
|
||||
return (itemMap[_a = item[container.groupfield]] ?? (itemMap[_a] = [])).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) => {
|
||||
var _a;
|
||||
return (childrenMap[_a = item[container.parentfield] || ""] ?? (childrenMap[_a] = [])).push(item);
|
||||
});
|
||||
const traverse = (items, level, parents) => items.forEach((item) => {
|
||||
var _a;
|
||||
const id = item[container.idfield], hasChildren = !!((_a = childrenMap[id]) == null ? void 0 : _a.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 = () => {
|
||||
const res = vs.calc(container, container.state.list);
|
||||
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.state._renderedList = res.renderedList;
|
||||
}
|
||||
};
|
||||
container.state.__watch("list", (list) => {
|
||||
container.onItemUpdate = (index2, node) => {
|
||||
if (container.fast) vs.update(index2 + (container.state._listStartIndex || 0), node);
|
||||
};
|
||||
container.state.__watch("_flatList", (flatList) => {
|
||||
if (container.fast) {
|
||||
container.state._listStartIndex = 0;
|
||||
container.state.renderedList = vs.reset(list, container) || [];
|
||||
container.state._renderedList = vs.reset(flatList, container) || [];
|
||||
requestAnimationFrame(() => {
|
||||
if (container.state.list !== list) return;
|
||||
vs.init(list, container.refresh);
|
||||
if (container.state._flatList === flatList) vs.init(flatList, container.refresh);
|
||||
});
|
||||
} else container.state._renderedList = flatList;
|
||||
});
|
||||
}, 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, index2) => {
|
||||
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: index2 } : { 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, index2) => {
|
||||
container.state.selectedGroup = null;
|
||||
container._selectItem(item, index2);
|
||||
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: index2 + (container.fast ? container.state._listStartIndex || 0 : 0) } }));
|
||||
};
|
||||
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, index2) => {
|
||||
container.state.selectedItem = null;
|
||||
container._selectGroup(item, index2);
|
||||
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: index2 } }));
|
||||
};
|
||||
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 });
|
||||
container.toggleCollapse = (item) => {
|
||||
if (container.collapsible && item._hasChildren) {
|
||||
container.collapsed[item[container.idfield]] = !container.collapsed[item[container.idfield]];
|
||||
updateFlatList();
|
||||
}
|
||||
});
|
||||
});
|
||||
return newList;
|
||||
});
|
||||
};
|
||||
updateFlatList();
|
||||
}, 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)}">
|
||||
<div class="list-group overflow-auto" $onscroll="this.fast && this.refresh()" style="overflow-anchor:none">
|
||||
<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 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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
<div $if="this.fast && (this.state?.postHeight || 0) > 0" $style="height:\${this.state?.postHeight}px;" class="flex-shrink-0"></div>
|
||||
</div>
|
||||
`
|
||||
));
|
||||
Component.register("FastGroupedList", (container) => {
|
||||
Component.getSetupFunction("List")(container);
|
||||
Component.getSetupFunction("GroupedList")(container);
|
||||
container.refresh = () => {
|
||||
var _a;
|
||||
return (_a = container.querySelector("FastList")) == null ? void 0 : _a.refresh();
|
||||
};
|
||||
}, Util.makeDom(
|
||||
/*html*/
|
||||
`
|
||||
<FastList class="list-group" $.state.list="this.state.list">
|
||||
<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) => {
|
||||
var _a;
|
||||
return (childrenMap[_a = item[container.parentfield] || ""] ?? (childrenMap[_a] = [])).push(item);
|
||||
});
|
||||
const newList = [];
|
||||
const traverse = (list, level) => list.forEach((item) => {
|
||||
var _a;
|
||||
const id = item[container.idfield];
|
||||
const hasChildren = !!((_a = childrenMap[id]) == null ? void 0 : _a.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>
|
||||
`
|
||||
));
|
||||
const FastTreeComponent = Component.register("FastTree", (container) => {
|
||||
Component.getSetupFunction("List")(container);
|
||||
Component.getSetupFunction("Tree")(container);
|
||||
container.refresh = () => {
|
||||
var _a;
|
||||
return (_a = container.querySelector("FastList")) == null ? void 0 : _a.refresh();
|
||||
};
|
||||
}, Util.makeDom(
|
||||
/*html*/
|
||||
`
|
||||
<FastList class="list-group list-group-action" $.state.list="this.state.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>
|
||||
</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) => {
|
||||
var _a;
|
||||
return (childrenMap[_a = item[container.parentfield] || ""] ?? (childrenMap[_a] = [])).push(item);
|
||||
});
|
||||
const newList = [];
|
||||
const traverse = (list, level, parents) => list.forEach((item) => {
|
||||
var _a;
|
||||
const id = item[container.idfield];
|
||||
const hasChildren = !!((_a = childrenMap[id]) == null ? void 0 : _a.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>
|
||||
`
|
||||
));
|
||||
Component.register("Nav", (container) => {
|
||||
container.click = (item, noselect) => {
|
||||
if (!item.noselect && !noselect) Hash.nav = item.name;
|
||||
@ -818,8 +679,6 @@ if (typeof document !== "undefined") {
|
||||
export {
|
||||
APIComponent,
|
||||
AutoForm,
|
||||
FastListComponent,
|
||||
FastTreeComponent,
|
||||
HTTP,
|
||||
MouseMover,
|
||||
State,
|
||||
|
||||
2
dist/base.min.js
vendored
2
dist/base.min.js
vendored
File diff suppressed because one or more lines are too long
386
src/list.js
386
src/list.js
@ -5,334 +5,166 @@ export const VirtualScroll = () => {
|
||||
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
|
||||
let padTop = 0, rowGap = 0, topMargin = 0, itemMarginTop = null, itemMarginBottom = null, 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))
|
||||
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)
|
||||
}
|
||||
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)
|
||||
let sum = 0;
|
||||
for (let j = i; j < Math.min(i + groupItemCount, size); j++) sum += itemHeights.get(j);
|
||||
groupHeights.set(i, sum);
|
||||
}
|
||||
groupHeights.set(i, sum)
|
||||
}
|
||||
listInited = true
|
||||
refreshCallback()
|
||||
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
|
||||
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 (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)
|
||||
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);
|
||||
}
|
||||
},
|
||||
|
||||
// 核心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
|
||||
|
||||
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)
|
||||
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)
|
||||
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
|
||||
}
|
||||
return { prevHeight: Math.max(0, 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 = []
|
||||
Component.register('List', container => {
|
||||
container.mode = container.getAttribute('mode') || 'normal'
|
||||
container.fast = container.hasAttribute('fast')
|
||||
container.collapsible = container.hasAttribute('collapsible')
|
||||
|
||||
container.onItemUpdate = (index, node) => {
|
||||
vs.update(index + (container.state._listStartIndex || 0), node)
|
||||
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 = () => {
|
||||
const res = vs.calc(container, container.state.list)
|
||||
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.state._renderedList = res.renderedList
|
||||
}
|
||||
}
|
||||
|
||||
container.state.__watch('list', list => {
|
||||
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(list, container) || []
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (container.state.list !== list) return
|
||||
vs.init(list, container.refresh)
|
||||
})
|
||||
container.state._renderedList = vs.reset(flatList, container) || []
|
||||
requestAnimationFrame(() => { if (container.state._flatList === flatList) vs.init(flatList, container.refresh) })
|
||||
} else container.state._renderedList = flatList
|
||||
})
|
||||
|
||||
}, 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)
|
||||
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) } }))
|
||||
}
|
||||
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)
|
||||
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(); } }
|
||||
|
||||
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
|
||||
})
|
||||
updateFlatList()
|
||||
}, 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)}">
|
||||
<div class="list-group overflow-auto" $onscroll="this.fast && this.refresh()" style="overflow-anchor:none">
|
||||
<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 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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
<div $if="this.fast && (this.state?.postHeight || 0) > 0" $style="height:\${this.state?.postHeight}px;" class="flex-shrink-0"></div>
|
||||
</div>
|
||||
`))
|
||||
|
||||
Component.register('FastGroupedList', container => {
|
||||
Component.getSetupFunction('List')(container)
|
||||
Component.getSetupFunction('GroupedList')(container)
|
||||
container.refresh = () => container.querySelector('FastList')?.refresh()
|
||||
}, Util.makeDom(/*html*/`
|
||||
<FastList class="list-group" $.state.list="this.state.list">
|
||||
<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)
|
||||
container.refresh = () => container.querySelector('FastList')?.refresh()
|
||||
}, Util.makeDom(/*html*/`
|
||||
<FastList class="list-group list-group-action" $.state.list="this.state.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>
|
||||
</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>
|
||||
`))
|
||||
|
||||
@ -67,6 +67,7 @@ export async function runTests() {
|
||||
const time = performance.now() - start;
|
||||
window.benchResults[name] = time;
|
||||
console.log(`BENCHMARK: ${name} scroll & refresh: ${time.toFixed(2)}ms`);
|
||||
console.log(`DEBUG [${id}]: _renderedList len: ${el.state._renderedList?.length}, _flatList len: ${el.state._flatList?.length}`);
|
||||
};
|
||||
|
||||
await measure('ll', 'FastList');
|
||||
|
||||
@ -28,17 +28,17 @@
|
||||
}
|
||||
</script>
|
||||
<div class="d-flex flex-fill flex-wrap overflow-auto">
|
||||
<FastList id="ll" auto-select class="p-4 h-50 w-50 d-flex flex-column gap-3 bg-body-secondary rounded" $.state.list="list_data" $onitemclick="console.log(index, item)">
|
||||
<List fast id="ll" auto-select class="p-4 h-50 w-50 d-flex flex-column gap-3 bg-body-secondary rounded" $.state.list="list_data" $onitemclick="console.log(index, item)">
|
||||
<template slot="item">
|
||||
<div class="d-flex justify-content-center align-items-center border border-primary rounded" $text="item.label" $.style.height="${(item.index%10)*5+40}px"></div>
|
||||
</template>
|
||||
</FastList>
|
||||
<FastGroupedList id="gl" auto-select auto-select-group class="p-4 h-50 w-50 d-flex flex-column border border-info rounded" $.state.groups="group_list" $.state.list="list_data"
|
||||
</List>
|
||||
<List fast mode="group" id="gl" auto-select auto-select-group class="p-4 h-50 w-50 d-flex flex-column border border-info rounded" $.state.groups="group_list" $.state.list="list_data"
|
||||
$ongroupclick="console.log(index, item)">
|
||||
</FastGroupedList>
|
||||
<FastTree id="tt" auto-select class="p-4 h-50 w-50 d-flex flex-column border border-info rounded" $.state.list="list_data" $onitemclick="console.log(index, item)"></FastTree>
|
||||
<CollapseTree id="ct" auto-select class="p-4 h-50 w-50 d-flex flex-column border border-info rounded" $.state.list="list_data.slice(0, 1200)" $onitemclick="console.log(index, item)">
|
||||
</CollapseTree>
|
||||
</List>
|
||||
<List fast mode="tree" id="tt" auto-select class="p-4 h-50 w-50 d-flex flex-column border border-info rounded" $.state.list="list_data" $onitemclick="console.log(index, item)"></List>
|
||||
<List mode="tree" collapsible id="ct" auto-select class="p-4 h-50 w-50 d-flex flex-column border border-info rounded" $.state.list="list_data.slice(0, 1200)" $onitemclick="console.log(index, item)">
|
||||
</List>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user