@cn-ui/core
Version:
The @cn-ui/core is a collection of UI components and utilities for building modern web applications with SolidJS.
923 lines (772 loc) • 28.5 kB
text/typescript
import { approxEqual, memo, notUndefined } from "./utils";
export * from "./utils";
//
type ScrollDirection = "forward" | "backward";
type ScrollAlignment = "start" | "center" | "end" | "auto";
type ScrollBehavior = "auto" | "smooth";
export interface ScrollToOptions {
align?: ScrollAlignment;
behavior?: ScrollBehavior;
}
type ScrollToOffsetOptions = ScrollToOptions;
type ScrollToIndexOptions = ScrollToOptions;
export interface Range {
startIndex: number;
endIndex: number;
overscan: number;
count: number;
}
type Key = number | string;
export interface VirtualItem {
key: Key;
id: number | string;
index: number;
start: number;
end: number;
size: number;
lane: number;
}
interface Rect {
width: number;
height: number;
}
//
export const defaultKeyExtractor = (index: number) => index;
export const defaultRangeExtractor = (range: Range) => {
const start = Math.max(range.startIndex - range.overscan, 0);
const end = Math.min(range.endIndex + range.overscan, range.count - 1);
const arr = [];
for (let i = start; i <= end; i++) {
arr.push(i);
}
return arr;
};
export const observeElementRect = <T extends Element>(
instance: Virtualizer<T, any>,
cb: (rect: Rect) => void,
) => {
const element = instance.scrollElement;
if (!element) {
return;
}
const handler = (rect: Rect) => {
const { width, height } = rect;
cb({ width: Math.round(width), height: Math.round(height) });
};
handler(element.getBoundingClientRect());
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry?.borderBoxSize) {
const box = entry.borderBoxSize[0];
if (box) {
handler({ width: box.inlineSize, height: box.blockSize });
return;
}
}
handler(element.getBoundingClientRect());
});
observer.observe(element, { box: "border-box" });
return () => {
observer.unobserve(element);
};
};
export const observeWindowRect = (instance: Virtualizer<Window, any>, cb: (rect: Rect) => void) => {
const element = instance.scrollElement;
if (!element) {
return;
}
const handler = () => {
cb({ width: element.innerWidth, height: element.innerHeight });
};
handler();
element.addEventListener("resize", handler, {
passive: true,
});
return () => {
element.removeEventListener("resize", handler);
};
};
export const observeElementOffset = <T extends Element>(
instance: Virtualizer<T, any>,
cb: (offset: number) => void,
) => {
const element = instance.scrollElement;
if (!element) {
return;
}
const handler = () => {
cb(instance.getElementOffset(instance));
};
handler();
element.addEventListener("scroll", handler, {
passive: true,
});
return () => {
element.removeEventListener("scroll", handler);
};
};
export const observeWindowOffset = (
instance: Virtualizer<Window, any>,
cb: (offset: number) => void,
) => {
const element = instance.scrollElement;
if (!element) {
return;
}
const handler = () => {
cb(instance.getElementOffset(instance));
};
handler();
element.addEventListener("scroll", handler, {
passive: true,
});
return () => {
element.removeEventListener("scroll", handler);
};
};
export const measureElement = <TItemElement extends Element>(
element: TItemElement,
entry: ResizeObserverEntry | undefined,
instance: Virtualizer<any, TItemElement>,
) => {
if (entry?.borderBoxSize) {
const box = entry.borderBoxSize[0];
if (box) {
const size = Math.round(box[instance.options.horizontal ? "inlineSize" : "blockSize"]);
return size;
}
}
return Math.round(
element.getBoundingClientRect()[instance.options.horizontal ? "width" : "height"],
);
};
export const windowScroll = <T extends Window>(
offset: number,
{ adjustments = 0, behavior }: { adjustments?: number; behavior?: ScrollBehavior },
instance: Virtualizer<T, any>,
) => {
const toOffset = offset + adjustments;
instance.scrollElement?.scrollTo?.({
[instance.options.horizontal ? "left" : "top"]: toOffset,
behavior,
});
};
export const elementScroll = <T extends Element>(
offset: number,
{ adjustments = 0, behavior }: { adjustments?: number; behavior?: ScrollBehavior },
instance: Virtualizer<T, any>,
) => {
const toOffset = offset + adjustments;
instance.scrollElement?.scrollTo?.({
[instance.options.horizontal ? "left" : "top"]: toOffset,
behavior,
});
};
export interface VirtualizerOptions<
TScrollElement extends Element | Window,
TItemElement extends Element,
> {
// Required from the user
count: number;
getScrollElement: () => TScrollElement | null;
estimateSize: (index: number) => number;
// Required from the framework adapter (but can be overridden)
scrollToFn: (
offset: number,
options: { adjustments?: number; behavior?: ScrollBehavior },
instance: Virtualizer<TScrollElement, TItemElement>,
) => void;
observeElementRect: (
instance: Virtualizer<TScrollElement, TItemElement>,
cb: (rect: Rect) => void,
) => undefined | (() => void);
observeElementOffset: (
instance: Virtualizer<TScrollElement, TItemElement>,
cb: (offset: number) => void,
) => undefined | (() => void);
// Optional
debug?: any;
initialRect?: Rect;
onChange?: (instance: Virtualizer<TScrollElement, TItemElement>, sync: boolean) => void;
measureElement?: (
element: TItemElement,
entry: ResizeObserverEntry | undefined,
instance: Virtualizer<TScrollElement, TItemElement>,
) => number;
getElementOffset?: (instance: Virtualizer<TScrollElement, TItemElement>) => number;
overscan?: number;
horizontal?: boolean;
paddingStart?: number;
paddingEnd?: number;
scrollPaddingStart?: number;
scrollPaddingEnd?: number;
initialOffset?: number;
getItemKey?: (index: number) => Key;
rangeExtractor?: (range: Range) => number[];
scrollMargin?: number;
scrollingDelay?: number;
indexAttribute?: string;
initialMeasurementsCache?: VirtualItem[];
lanes?: number;
}
export class Virtualizer<TScrollElement extends Element | Window, TItemElement extends Element> {
// biome-ignore lint/suspicious/noConfusingVoidType: <explanation>
private unsubs: (void | (() => void))[] = [];
options!: Required<VirtualizerOptions<TScrollElement, TItemElement>>;
scrollElement: TScrollElement | null = null;
isScrolling = false;
private isScrollingTimeoutId: ReturnType<typeof setTimeout> | null = null;
private scrollToIndexTimeoutId: ReturnType<typeof setTimeout> | null = null;
measurementsCache: VirtualItem[] = [];
private itemSizeCache = new Map<Key, number>();
private pendingMeasuredCacheIndexes: number[] = [];
scrollRect: Rect;
scrollOffset: number;
scrollDirection: ScrollDirection | null = null;
private scrollAdjustments = 0;
measureElementCache = new Map<Key, TItemElement>();
private observer = (() => {
let _ro: ResizeObserver | null = null;
const get = () => {
if (_ro) {
return _ro;
}
if (typeof ResizeObserver !== "undefined") {
// biome-ignore lint/suspicious/noAssignInExpressions: <explanation>
return (_ro = new ResizeObserver((entries) => {
entries.forEach((entry) => {
this._measureElement(entry.target as TItemElement, entry);
});
}));
}
return null;
};
return {
disconnect: () => get()?.disconnect(),
observe: (target: Element) => get()?.observe(target, { box: "border-box" }),
unobserve: (target: Element) => get()?.unobserve(target),
};
})();
range: { startIndex: number; endIndex: number } | null = null;
constructor(opts: VirtualizerOptions<TScrollElement, TItemElement>) {
this.setOptions(opts);
this.scrollRect = this.options.initialRect;
this.scrollOffset = this.options.initialOffset;
this.measurementsCache = this.options.initialMeasurementsCache;
this.measurementsCache.forEach((item) => {
this.itemSizeCache.set(item.key, item.size);
});
this.maybeNotify();
if (this.options.getElementOffset) this.getElementOffset = this.options.getElementOffset;
}
getElementOffset(instance: Virtualizer<TScrollElement, TItemElement>) {
if (instance.scrollElement === null) throw Error(" ScrollElement Void");
if (instance.scrollElement instanceof Window) {
return Math.abs(
instance.scrollElement[instance.options.horizontal ? "scrollX" : "scrollY"],
);
}
// 反向列表的数值是负数,所以需要进行正数操作
return Math.abs(
instance.scrollElement[instance.options.horizontal ? "scrollLeft" : "scrollTop"],
);
}
setOptions = (opts: VirtualizerOptions<TScrollElement, TItemElement>) => {
Object.entries(opts).forEach(([key, value]) => {
if (typeof value === "undefined") Reflect.deleteProperty(opts, key);
});
/** @ts-ignore */
this.options = {
debug: false,
initialOffset: 0,
overscan: 1,
paddingStart: 0,
paddingEnd: 0,
scrollPaddingStart: 0,
scrollPaddingEnd: 0,
horizontal: false,
getItemKey: defaultKeyExtractor,
rangeExtractor: defaultRangeExtractor,
onChange: () => {},
measureElement,
initialRect: { width: 0, height: 0 },
scrollMargin: 0,
scrollingDelay: 150,
indexAttribute: "data-index",
initialMeasurementsCache: [],
lanes: 1,
...opts,
};
};
private notify = (sync: boolean) => {
this.options.onChange?.(this, sync);
};
private maybeNotify = memo(
() => {
this.calculateRange();
return [
this.isScrolling,
this.range ? this.range.startIndex : null,
this.range ? this.range.endIndex : null,
];
},
(isScrolling) => {
this.notify(isScrolling);
},
{
key: process.env.NODE_ENV !== "production" && "maybeNotify",
debug: () => this.options.debug,
initialDeps: [
this.isScrolling,
this.range ? this.range.startIndex : null,
this.range ? this.range.endIndex : null,
] as [boolean, number | null, number | null],
},
);
private cleanup = () => {
this.unsubs.filter(Boolean).forEach((d) => d!());
this.unsubs = [];
this.scrollElement = null;
};
_didMount = () => {
this.measureElementCache.forEach(this.observer.observe);
return () => {
this.observer.disconnect();
this.cleanup();
};
};
_willUpdate = () => {
const scrollElement = this.options.getScrollElement();
if (this.scrollElement !== scrollElement) {
this.cleanup();
this.scrollElement = scrollElement;
this._scrollToOffset(this.scrollOffset, {
adjustments: undefined,
behavior: undefined,
});
this.unsubs.push(
this.options.observeElementRect(this, (rect) => {
this.scrollRect = rect;
this.maybeNotify();
}),
);
this.unsubs.push(
this.options.observeElementOffset(this, (offset) => {
this.scrollAdjustments = 0;
if (this.scrollOffset === offset) {
return;
}
if (this.isScrollingTimeoutId !== null) {
clearTimeout(this.isScrollingTimeoutId);
this.isScrollingTimeoutId = null;
}
this.isScrolling = true;
this.scrollDirection = this.scrollOffset < offset ? "forward" : "backward";
this.scrollOffset = offset;
this.maybeNotify();
this.isScrollingTimeoutId = setTimeout(() => {
this.isScrollingTimeoutId = null;
this.isScrolling = false;
this.scrollDirection = null;
this.maybeNotify();
}, this.options.scrollingDelay);
}),
);
}
};
private getSize = () => {
return this.scrollRect[this.options.horizontal ? "width" : "height"];
};
private memoOptions = memo(
() => [
this.options.count,
this.options.paddingStart,
this.options.scrollMargin,
this.options.getItemKey,
],
(count, paddingStart, scrollMargin, getItemKey) => {
this.pendingMeasuredCacheIndexes = [];
return {
count,
paddingStart,
scrollMargin,
getItemKey,
};
},
{
key: false,
},
);
private getFurthestMeasurement = (measurements: VirtualItem[], index: number) => {
const furthestMeasurementsFound = new Map<number, true>();
const furthestMeasurements = new Map<number, VirtualItem>();
for (let m = index - 1; m >= 0; m--) {
const measurement = measurements[m]!;
if (furthestMeasurementsFound.has(measurement.lane)) {
continue;
}
const previousFurthestMeasurement = furthestMeasurements.get(measurement.lane);
if (
previousFurthestMeasurement == null ||
measurement.end > previousFurthestMeasurement.end
) {
furthestMeasurements.set(measurement.lane, measurement);
} else if (measurement.end < previousFurthestMeasurement.end) {
furthestMeasurementsFound.set(measurement.lane, true);
}
if (furthestMeasurementsFound.size === this.options.lanes) {
break;
}
}
return furthestMeasurements.size === this.options.lanes
? Array.from(furthestMeasurements.values()).sort((a, b) => {
if (a.end === b.end) {
return a.index - b.index;
}
return a.end - b.end;
})[0]
: undefined;
};
private getMeasurements = memo(
() => [this.memoOptions(), this.itemSizeCache],
({ count, paddingStart, scrollMargin, getItemKey }, itemSizeCache) => {
const min =
this.pendingMeasuredCacheIndexes.length > 0
? Math.min(...this.pendingMeasuredCacheIndexes)
: 0;
this.pendingMeasuredCacheIndexes = [];
const measurements = this.measurementsCache.slice(0, min);
for (let i = min; i < count; i++) {
const key = getItemKey(i);
const furthestMeasurement =
this.options.lanes === 1
? measurements[i - 1]
: this.getFurthestMeasurement(measurements, i);
const start = furthestMeasurement
? furthestMeasurement.end
: paddingStart + scrollMargin;
const measuredSize = itemSizeCache.get(key);
const size =
typeof measuredSize === "number" ? measuredSize : this.options.estimateSize(i);
const end = start + size;
const lane = furthestMeasurement
? furthestMeasurement.lane
: i % this.options.lanes;
measurements[i] = {
index: i,
id: key,
start,
size,
end,
key,
lane,
};
}
this.measurementsCache = measurements;
return measurements;
},
{
key: process.env.NODE_ENV !== "production" && "getMeasurements",
debug: () => this.options.debug,
},
);
calculateRange = memo(
() => [this.getMeasurements(), this.getSize(), this.scrollOffset],
(measurements, outerSize, scrollOffset) => {
// biome-ignore lint/suspicious/noAssignInExpressions: <explanation>
return (this.range =
measurements.length > 0 && outerSize > 0
? calculateRange({
measurements,
outerSize,
scrollOffset,
})
: null);
},
{
key: process.env.NODE_ENV !== "production" && "calculateRange",
debug: () => this.options.debug,
},
);
private getIndexes = memo(
() => [
this.options.rangeExtractor,
this.calculateRange(),
this.options.overscan,
this.options.count,
],
(rangeExtractor, range, overscan, count) => {
return range === null
? []
: rangeExtractor({
...range,
overscan,
count,
});
},
{
key: process.env.NODE_ENV !== "production" && "getIndexes",
debug: () => this.options.debug,
},
);
indexFromElement = (node: TItemElement) => {
const attributeName = this.options.indexAttribute;
const indexStr = node.getAttribute(attributeName);
if (!indexStr) {
console.warn(`Missing attribute name '${attributeName}={index}' on measured element.`);
return -1;
}
return Number.parseInt(indexStr, 10);
};
private _measureElement = (node: TItemElement, entry: ResizeObserverEntry | undefined) => {
const item = this.measurementsCache[this.indexFromElement(node)];
if (!item || !node.isConnected) {
this.measureElementCache.forEach((cached, key) => {
if (cached === node) {
this.observer.unobserve(node);
this.measureElementCache.delete(key);
}
});
return;
}
const prevNode = this.measureElementCache.get(item.key);
if (prevNode !== node) {
if (prevNode) {
this.observer.unobserve(prevNode);
}
this.observer.observe(node);
this.measureElementCache.set(item.key, node);
}
const measuredItemSize = this.options.measureElement(node, entry, this);
this.resizeItem(item, measuredItemSize);
};
resizeItem = (item: VirtualItem, size: number) => {
const itemSize = this.itemSizeCache.get(item.key) ?? item.size;
const delta = size - itemSize;
if (delta !== 0) {
if (item.start < this.scrollOffset + this.scrollAdjustments) {
if (process.env.NODE_ENV !== "production" && this.options.debug) {
console.info("correction", delta);
}
this._scrollToOffset(this.scrollOffset, {
// biome-ignore lint/suspicious/noAssignInExpressions: <explanation>
adjustments: (this.scrollAdjustments += delta),
behavior: undefined,
});
}
this.pendingMeasuredCacheIndexes.push(item.index);
this.itemSizeCache = new Map(this.itemSizeCache.set(item.key, size));
this.notify(false);
}
};
measureElement = (node: TItemElement | null) => {
if (!node) {
return;
}
this._measureElement(node, undefined);
};
getVirtualItems = memo(
() => [this.getIndexes(), this.getMeasurements()],
(indexes, measurements) => {
const virtualItems: VirtualItem[] = [];
for (let k = 0, len = indexes.length; k < len; k++) {
const i = indexes[k]!;
const measurement = measurements[i]!;
virtualItems.push(measurement);
}
return virtualItems;
},
{
key: process.env.NODE_ENV !== "production" && "getIndexes",
debug: () => this.options.debug,
},
);
getVirtualItemForOffset = (offset: number) => {
const measurements = this.getMeasurements();
return notUndefined(
measurements[
findNearestBinarySearch(
0,
measurements.length - 1,
(index: number) => notUndefined(measurements[index]).start,
offset,
)
],
);
};
getOffsetForAlignment = (toOffset: number, align: ScrollAlignment) => {
const size = this.getSize();
if (align === "auto") {
if (toOffset <= this.scrollOffset) {
align = "start";
} else if (toOffset >= this.scrollOffset + size) {
align = "end";
} else {
align = "start";
}
}
if (align === "start") {
// toOffset = toOffset;
} else if (align === "end") {
toOffset = toOffset - size;
} else if (align === "center") {
toOffset = toOffset - size / 2;
}
const scrollSizeProp = this.options.horizontal ? "scrollWidth" : "scrollHeight";
const scrollSize = this.scrollElement
? "document" in this.scrollElement
? this.scrollElement.document.documentElement[scrollSizeProp]
: this.scrollElement[scrollSizeProp]
: 0;
const maxOffset = scrollSize - this.getSize();
return Math.max(Math.min(maxOffset, toOffset), 0);
};
getOffsetForIndex = (index: number, align: ScrollAlignment = "auto") => {
index = Math.max(0, Math.min(index, this.options.count - 1));
const measurement = notUndefined(this.getMeasurements()[index]);
if (align === "auto") {
if (
measurement.end >=
this.scrollOffset + this.getSize() - this.options.scrollPaddingEnd
) {
align = "end";
} else if (measurement.start <= this.scrollOffset + this.options.scrollPaddingStart) {
align = "start";
} else {
return [this.scrollOffset, align] as const;
}
}
const toOffset =
align === "end"
? measurement.end + this.options.scrollPaddingEnd
: measurement.start - this.options.scrollPaddingStart;
return [this.getOffsetForAlignment(toOffset, align), align] as const;
};
private isDynamicMode = () => this.measureElementCache.size > 0;
private cancelScrollToIndex = () => {
if (this.scrollToIndexTimeoutId !== null) {
clearTimeout(this.scrollToIndexTimeoutId);
this.scrollToIndexTimeoutId = null;
}
};
scrollToOffset = (
toOffset: number,
{ align = "start", behavior }: ScrollToOffsetOptions = {},
) => {
this.cancelScrollToIndex();
if (behavior === "smooth" && this.isDynamicMode()) {
console.warn("The `smooth` scroll behavior is not fully supported with dynamic size.");
}
this._scrollToOffset(this.getOffsetForAlignment(toOffset, align), {
adjustments: undefined,
behavior,
});
};
scrollToIndex = (
index: number,
{ align: initialAlign = "auto", behavior }: ScrollToIndexOptions = {},
) => {
index = Math.max(0, Math.min(index, this.options.count - 1));
this.cancelScrollToIndex();
if (behavior === "smooth" && this.isDynamicMode()) {
console.warn("The `smooth` scroll behavior is not fully supported with dynamic size.");
}
const [toOffset, align] = this.getOffsetForIndex(index, initialAlign);
this._scrollToOffset(toOffset, { adjustments: undefined, behavior });
if (behavior !== "smooth" && this.isDynamicMode()) {
this.scrollToIndexTimeoutId = setTimeout(() => {
this.scrollToIndexTimeoutId = null;
const elementInDOM = this.measureElementCache.has(this.options.getItemKey(index));
if (elementInDOM) {
const [toOffset] = this.getOffsetForIndex(index, align);
if (!approxEqual(toOffset, this.scrollOffset)) {
this.scrollToIndex(index, { align, behavior });
}
} else {
this.scrollToIndex(index, { align, behavior });
}
});
}
};
scrollBy = (delta: number, { behavior }: ScrollToOffsetOptions = {}) => {
this.cancelScrollToIndex();
if (behavior === "smooth" && this.isDynamicMode()) {
console.warn("The `smooth` scroll behavior is not fully supported with dynamic size.");
}
this._scrollToOffset(this.scrollOffset + delta, {
adjustments: undefined,
behavior,
});
};
getTotalSize = () => {
const measurements = this.getMeasurements();
let end: number;
// If there are no measurements, set the end to paddingStart
if (measurements.length === 0) {
end = this.options.paddingStart;
} else {
// If lanes is 1, use the last measurement's end, otherwise find the maximum end value among all measurements
end =
this.options.lanes === 1
? measurements[measurements.length - 1]?.end ?? 0
: Math.max(...measurements.slice(-this.options.lanes).map((m) => m.end));
}
return end - this.options.scrollMargin + this.options.paddingEnd;
};
private _scrollToOffset = (
offset: number,
{
adjustments,
behavior,
}: {
adjustments: number | undefined;
behavior: ScrollBehavior | undefined;
},
) => {
this.options.scrollToFn(offset, { behavior, adjustments }, this);
};
measure = () => {
this.itemSizeCache = new Map();
this.notify(false);
};
}
const findNearestBinarySearch = (
low: number,
high: number,
getCurrentValue: (i: number) => number,
value: number,
) => {
while (low <= high) {
const middle = ((low + high) / 2) | 0;
const currentValue = getCurrentValue(middle);
if (currentValue < value) {
low = middle + 1;
} else if (currentValue > value) {
high = middle - 1;
} else {
return middle;
}
}
if (low > 0) {
return low - 1;
}
return 0;
};
function calculateRange({
measurements,
outerSize,
scrollOffset,
}: {
measurements: VirtualItem[];
outerSize: number;
scrollOffset: number;
}) {
const count = measurements.length - 1;
const getOffset = (index: number) => measurements[index]!.start;
const startIndex = findNearestBinarySearch(0, count, getOffset, scrollOffset);
let endIndex = startIndex;
while (endIndex < count && measurements[endIndex]!.end < scrollOffset + outerSize) {
endIndex++;
}
return { startIndex, endIndex };
}