UNPKG

@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
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 }; }