diff --git a/CHANGELOG.md b/CHANGELOG.md index 613d1a4..4f6c34c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,9 @@ # svelte-virtual-scroll-list changelog -## 1.3.0 +## 1.0.1 -- Expose `index` from VirtualScroll component +- Fixed issue where data changes wouldn't refresh the item contents -## 1.2.0 +## 1.0.0 -- Move example to SvelteKit -- Package distribution by SvelteKit too -- Add classes on VS wrappers -- Add example for horizontal scroll -- Fix pageMode with SSR -- Support Svelte 4 \ No newline at end of file +- Forked and heavily modified from original package svelte-virtual-scroll-list \ No newline at end of file diff --git a/README.md b/README.md index 3b8b4f0..05e9a2b 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,17 @@ # svelte-virtual-scroll-list -[![npm](https://img.shields.io/npm/v/svelte-virtual-scroll-list?style=for-the-badge)](https://npmjs.com/package/svelte-virtual-scroll-list/) +[![npm](https://img.shields.io/npm/v/@josesan9/svelte-virtual-scroll-list?style=for-the-badge)](https://npmjs.com/package/@josesan9/svelte-virtual-scroll-list/) -Svelte implementation of vue library [vue-virtual-scroll-list](https://github.com/tangbc/vue-virtual-scroll-list) +fork of same library by v1ack [svelte-virtual-scroll-list](https://github.com/v1ack/svelte-virtual-scroll-list) -Virtualized scrolling for big lists - ---- -**Support dynamic both-directional lists** (see example) - ---- - -Online demo: [https://v1ack.github.io/svelte-virtual-scroll-list/](https://v1ack.github.io/svelte-virtual-scroll-list/) - -[Simple example in Svelte REPL](https://ru.svelte.dev/repl/eae82aab17b04420885851d58de50a2e?version=3.38.2) +Virtualized scrolling for big lists. For now this does not support bi-directionality (unlike v1ach's implementation) # Getting started -## Installing from npm +`npm i josesan9/svelte-virtual-scroll-list -D` -`npm i svelte-virtual-scroll-list -D` -or - -`yarn add svelte-virtual-scroll-list -D` - -## Using +## Usage ```html @@ -61,7 +47,7 @@ More examples available in `example` folder |---------------------------|----------------------------|---------------------|----------------------------------| | handle dynamic size data | + | + | - | | scroll methods (to index) | + | - | + | -| infinity scrolling | two-directional | - | one-directional with another lib | +| infinity scrolling | + | - | one-directional with another lib | | initial scroll position | + | - | + | | sticky items | - | - | + | | top/bottom slots | + | - | + | @@ -125,7 +111,13 @@ Access to methods by component binding ## Additional -### Get index of current rendering items +### Params passed down to each virtual list item + +| param | description | +|------------|------------------------------------------------------| +| data | data item | +| index | index of item (in relation to full list) | +| localIndex | index of item (in relation to rendered items only) | ```html @@ -134,6 +126,7 @@ Access to methods by component binding key="id" let:data let:index + let:localIndex >
{data.text} {index} diff --git a/package-lock.json b/package-lock.json index 5b11758..0d33cd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "svelte-virtual-scroll-list", - "version": "1.2.0", + "name": "@josesan9/svelte-virtual-scroll-list", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "svelte-virtual-scroll-list", - "version": "1.2.0", + "name": "@josesan9/svelte-virtual-scroll-list", + "version": "1.0.1", "devDependencies": { "@sveltejs/adapter-static": "^2.0.2", "@sveltejs/kit": "^1.20.4", diff --git a/package.json b/package.json index 3565a65..1642bca 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { - "name": "svelte-virtual-scroll-list", + "name": "@josesan9/svelte-virtual-scroll-list", "description": "Svelte lib for virtualizing lists", "author": { - "name": "v1ack", - "url": "https://github.com/v1ack" + "name": "josesan9", + "url": "https://github.com/josesan9" }, "keywords": [ "svelte", @@ -11,10 +11,10 @@ "virtual-list", "virtual-scroll" ], - "version": "1.3.0", + "version": "1.0.1", "repository": { "type": "git", - "url": "git+https://github.com/v1ack/svelte-virtual-scroll-list.git" + "url": "git+https://github.com/josesan9/svelte-virtual-scroll-list.git" }, "scripts": { "dev": "vite dev", diff --git a/src/lib/Item.svelte b/src/lib/Item.svelte index b28728f..102ba6b 100644 --- a/src/lib/Item.svelte +++ b/src/lib/Item.svelte @@ -1,13 +1,13 @@ - @@ -269,13 +257,13 @@ {/if}
- {#each displayItems as dataItem, dataIndex (dataItem[key])} + {#each displayItems as dataItem, dataIndex (keyFn(dataItem, dataIndex + range.start))} - + {/each}
diff --git a/src/lib/index.js b/src/lib/index.ts similarity index 100% rename from src/lib/index.js rename to src/lib/index.ts diff --git a/src/lib/virtual.js b/src/lib/virtual.js deleted file mode 100644 index 034cace..0000000 --- a/src/lib/virtual.js +++ /dev/null @@ -1,327 +0,0 @@ -/** - * virtual list core calculating center - */ - -const DIRECTION_TYPE = { - FRONT: "FRONT", // scroll up or left - BEHIND: "BEHIND", // scroll down or right -} -const CALC_TYPE = { - INIT: "INIT", - FIXED: "FIXED", - DYNAMIC: "DYNAMIC", -} -const LEADING_BUFFER = 2 - -export default class { - param - callUpdate - firstRangeTotalSize = 0 - firstRangeAverageSize = 0 - lastCalcIndex = 0 - fixedSizeValue = 0 - calcType = CALC_TYPE.INIT - offset = 0 - direction = "" - range - - constructor(param, callUpdate) { - this.init(param, callUpdate) - } - - init(param, callUpdate) { - // param data - this.param = param - this.callUpdate = callUpdate - - // size data - this.sizes = new Map() - this.firstRangeTotalSize = 0 - this.firstRangeAverageSize = 0 - this.fixedSizeValue = 0 - this.calcType = CALC_TYPE.INIT - - // scroll data - this.offset = 0 - this.direction = "" - - // range data - this.range = Object.create(null) - if (param) { - this.checkRange(0, param.keeps - 1) - } - - // benchmark example data - // this.__bsearchCalls = 0 - // this.__getIndexOffsetCalls = 0 - } - - destroy() { - this.init(null, null) - } - - // return current render range - getRange() { - const range = Object.create(null) - range.start = this.range.start - range.end = this.range.end - range.padFront = this.range.padFront - range.padBehind = this.range.padBehind - return range - } - - isBehind() { - return this.direction === DIRECTION_TYPE.BEHIND - } - - isFront() { - return this.direction === DIRECTION_TYPE.FRONT - } - - // return start index offset - getOffset(start) { - return (start < 1 ? 0 : this.getIndexOffset(start)) + this.param.slotHeaderSize - } - - updateParam(key, value) { - if (this.param && (key in this.param)) { - // if uniqueIds change, find out deleted id and remove from size map - if (key === "uniqueIds") { - this.sizes.forEach((v, key) => { - if (!value.includes(key)) { - this.sizes.delete(key) - } - }) - } - this.param[key] = value - } - } - - // save each size map by id - saveSize(id, size) { - this.sizes.set(id, size) - - // we assume size type is fixed at the beginning and remember first size value - // if there is no size value different from this at next coming saving - // we think it's a fixed size list, otherwise is dynamic size list - if (this.calcType === CALC_TYPE.INIT) { - this.fixedSizeValue = size - this.calcType = CALC_TYPE.FIXED - } else if (this.calcType === CALC_TYPE.FIXED && this.fixedSizeValue !== size) { - this.calcType = CALC_TYPE.DYNAMIC - // it's no use at all - delete this.fixedSizeValue - } - - // calculate the average size only in the first range - if (this.calcType !== CALC_TYPE.FIXED && typeof this.firstRangeTotalSize !== "undefined") { - if (this.sizes.size < Math.min(this.param.keeps, this.param.uniqueIds.length)) { - this.firstRangeTotalSize = [...this.sizes.values()].reduce((acc, val) => acc + val, 0) - this.firstRangeAverageSize = Math.round(this.firstRangeTotalSize / this.sizes.size) - } else { - // it's done using - delete this.firstRangeTotalSize - } - } - } - - // in some special situation (e.g. length change) we need to update in a row - // try going to render next range by a leading buffer according to current direction - handleDataSourcesChange() { - let start = this.range.start - - if (this.isFront()) { - start = start - LEADING_BUFFER - } else if (this.isBehind()) { - start = start + LEADING_BUFFER - } - - start = Math.max(start, 0) - - this.updateRange(this.range.start, this.getEndByStart(start)) - } - - // when slot size change, we also need force update - handleSlotSizeChange() { - this.handleDataSourcesChange() - } - - // calculating range on scroll - handleScroll(offset) { - this.direction = offset < this.offset ? DIRECTION_TYPE.FRONT : DIRECTION_TYPE.BEHIND - this.offset = offset - - if (!this.param) { - return - } - - if (this.direction === DIRECTION_TYPE.FRONT) { - this.handleFront() - } else if (this.direction === DIRECTION_TYPE.BEHIND) { - this.handleBehind() - } - } - - // ----------- public method end ----------- - - handleFront() { - const overs = this.getScrollOvers() - // should not change range if start doesn't exceed overs - if (overs > this.range.start) { - return - } - - // move up start by a buffer length, and make sure its safety - const start = Math.max(overs - this.param.buffer, 0) - this.checkRange(start, this.getEndByStart(start)) - } - - handleBehind() { - const overs = this.getScrollOvers() - // range should not change if scroll overs within buffer - if (overs < this.range.start + this.param.buffer) { - return - } - - this.checkRange(overs, this.getEndByStart(overs)) - } - - // return the pass overs according to current scroll offset - getScrollOvers() { - // if slot header exist, we need subtract its size - const offset = this.offset - this.param.slotHeaderSize - if (offset <= 0) { - return 0 - } - - // if is fixed type, that can be easily - if (this.isFixedType()) { - return Math.floor(offset / this.fixedSizeValue) - } - - let low = 0 - let middle = 0 - let middleOffset = 0 - let high = this.param.uniqueIds.length - - while (low <= high) { - // this.__bsearchCalls++ - middle = low + Math.floor((high - low) / 2) - middleOffset = this.getIndexOffset(middle) - - if (middleOffset === offset) { - return middle - } else if (middleOffset < offset) { - low = middle + 1 - } else if (middleOffset > offset) { - high = middle - 1 - } - } - - return low > 0 ? --low : 0 - } - - // return a scroll offset from given index, can efficiency be improved more here? - // although the call frequency is very high, its only a superposition of numbers - getIndexOffset(givenIndex) { - if (!givenIndex) { - return 0 - } - - let offset = 0 - let indexSize = 0 - for (let index = 0; index < givenIndex; index++) { - // this.__getIndexOffsetCalls++ - indexSize = this.sizes.get(this.param.uniqueIds[index]) - offset = offset + (typeof indexSize === "number" ? indexSize : this.getEstimateSize()) - } - - // remember last calculate index - this.lastCalcIndex = Math.max(this.lastCalcIndex, givenIndex - 1) - this.lastCalcIndex = Math.min(this.lastCalcIndex, this.getLastIndex()) - - return offset - } - - // is fixed size type - isFixedType() { - return this.calcType === CALC_TYPE.FIXED - } - - // return the real last index - getLastIndex() { - return this.param.uniqueIds.length - 1 - } - - // in some conditions range is broke, we need correct it - // and then decide whether need update to next range - checkRange(start, end) { - const keeps = this.param.keeps - const total = this.param.uniqueIds.length - - // data less than keeps, render all - if (total <= keeps) { - start = 0 - end = this.getLastIndex() - } else if (end - start < keeps - 1) { - // if range length is less than keeps, correct it base on end - start = end - keeps + 1 - } - - if (this.range.start !== start) { - this.updateRange(start, end) - } - } - - // setting to a new range and rerender - updateRange(start, end) { - this.range.start = start - this.range.end = end - this.range.padFront = this.getPadFront() - this.range.padBehind = this.getPadBehind() - this.callUpdate(this.getRange()) - } - - // return end base on start - getEndByStart(start) { - const theoryEnd = start + this.param.keeps - 1 - const truelyEnd = Math.min(theoryEnd, this.getLastIndex()) - return truelyEnd - } - - // return total front offset - getPadFront() { - if (this.isFixedType()) { - return this.fixedSizeValue * this.range.start - } else { - return this.getIndexOffset(this.range.start) - } - } - - // return total behind offset - getPadBehind() { - const end = this.range.end - const lastIndex = this.getLastIndex() - - if (this.isFixedType()) { - return (lastIndex - end) * this.fixedSizeValue - } - - // if it's all calculated, return the exactly offset - if (this.lastCalcIndex === lastIndex) { - return this.getIndexOffset(lastIndex) - this.getIndexOffset(end) - } else { - // if not, use a estimated value - return (lastIndex - end) * this.getEstimateSize() - } - } - - // get the item estimate size - getEstimateSize() { - return this.isFixedType() ? this.fixedSizeValue : (this.firstRangeAverageSize || this.param.estimateSize) - } -} - -export function isBrowser() { - return typeof document !== "undefined" -} \ No newline at end of file diff --git a/src/lib/virtual.ts b/src/lib/virtual.ts new file mode 100644 index 0000000..27c9a27 --- /dev/null +++ b/src/lib/virtual.ts @@ -0,0 +1,155 @@ +type DataKey = ((item: T, index: number) => any) +type EstimateSize = ((item: T) => number) + +interface IParam { + slotHeaderSize: number, + slotFooterSize: number, + overflow: number, + data: T[], +} + +interface IRange { + start: number, + end: number, + padFront: number, + padBehind: number, +} + +export class Virtual{ + param: IParam + callUpdate: (range: IRange) => void + currOffset = 0 + clientHeight = 0 + range: IRange + /** array of sizes for each container */ + sizes = new Map() + /** array of offsets for each container */ + offsets: number[] + keyFn: DataKey + estimateSize: EstimateSize + + constructor(param: IParam, callUpdate: typeof this.callUpdate, keyFn: DataKey, estimateSize: number | EstimateSize) { + // param data + this.param = param + this.callUpdate = callUpdate + this.keyFn = keyFn + this.estimateSize = estimateSize instanceof Function ? estimateSize : () => estimateSize + + // size data + this.sizes = new Map() + this.offsets = [] + this.param.data.forEach((d, i) => { + let estimateSize = this.estimateSize instanceof Function ? this.estimateSize(d) : this.estimateSize + this.sizes.set(this.keyFn instanceof Function ? this.keyFn(d, i) : d[this.keyFn], estimateSize) + }) + this.rebuildOffsets() + + this.range = Object.create({ start: -1, end: -1, padFront: 0, padBehind: 0 }) + } + + // return current render range + getRange() { + return { ...this.range } + } + + // return start index offset + getOffset(start: number) { + return (start < 1 ? 0 : this.getIndexOffset(start)) + this.param.slotHeaderSize + } + + updateParam>(key: K, value: IParam[K]) { + if (this.param && (key in this.param)) { + this.param[key] = value + // if data change, find out deleted id and remove from size map + if (key === "data") { + let ids = (value as T[]).map(this.keyFn) + this.sizes.forEach((v, key) => { + if (!ids.includes(key)) { + this.sizes.delete(key) + } + }) + this.rebuildOffsets() + this.handleScroll(this.currOffset, this.clientHeight, true) + } + } + } + + // save each size map by id + saveSize(id: any, size: number) { + if (this.sizes.get(id) === size) { + return + } + this.sizes.set(id, size) + this.rebuildOffsets(this.param.data.findIndex((d, i) => this.keyFn(d, i) === id)) + } + // calculating range on scroll + handleScroll(offset: number, clientHeight: number, forceUpdate = false) { + this.currOffset = offset + this.clientHeight = clientHeight + let startIndex = Math.max(this.offsets.findIndex(o => o >= offset) - 1 - this.param.overflow, 0) + let endIndex = this.offsets.findIndex(o => o >= offset + clientHeight) + if (endIndex === -1) { + endIndex = this.param.data.length - 1 + } else if (endIndex < startIndex) { + endIndex = startIndex + } + endIndex = Math.min(endIndex + this.param.overflow, this.param.data.length - 1) + this.updateRange(startIndex, endIndex, forceUpdate) + } + + // ----------- public method end ----------- + + // rebuilds the offset array + rebuildOffsets(startIndex?: number) { + if (startIndex === undefined) { + this.offsets = [0] + startIndex = 0 + } + if (startIndex === -1) return + let lastOffset = this.offsets[startIndex] || 0 + for (let i = startIndex + 1; i < this.param.data.length; i++) { + let id = this.keyFn(this.param.data[i - 1], i - 1) + lastOffset += this.sizes.get(id)! + if (Number.isNaN(lastOffset)) { + } + this.offsets[i] = lastOffset + } + } + + // return a scroll offset from given index, can efficiency be improved more here? + // although the call frequency is very high, its only a superposition of numbers + getIndexOffset(givenIndex: number) { + if (!givenIndex) { + return 0 + } + + return this.offsets[givenIndex] || 0 + } + + // setting to a new range and rerender + updateRange(start: number, end: number, forceUpdate = false) { + if (!forceUpdate && start === this.range.start && end === this.range.end) return; + this.range.start = start + this.range.end = end + this.range.padFront = this.getPadFront() + this.range.padBehind = this.getPadBehind() + this.callUpdate(this.range) + } + + // return total front offset + getPadFront() { + return this.offsets[this.range.start] || 0 + } + + // return total behind offset + getPadBehind() { + if (this.range.end >= this.param.data.length - 1) { + return 0 + } + return this.offsets[this.offsets.length - 1] + this.sizes.get(this.keyFn(this.param.data[this.param.data.length - 1], this.param.data.length - 1))! - (this.offsets[this.range.end + 1]) + } +} + +export function isBrowser() { + return typeof document !== "undefined" +} \ No newline at end of file diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 8ab1367..d2359ee 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -35,7 +35,7 @@ class:active={current_path === page.component} >{page.name} {/each} - Source + Source
diff --git a/tsconfig.json b/tsconfig.json index 9a9b2ea..48089ad 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,8 @@ "skipLibCheck": true, "sourceMap": true, "strict": true, - "moduleResolution": "NodeNext" + "moduleResolution": "NodeNext", + "module": "NodeNext", + "outDir": "./dist", } }