@plcmp/pl-virtual-scroll 0.1.14 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/pl-virtual-scroll.js +205 -116
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plcmp/pl-virtual-scroll",
3
- "version": "0.1.14",
3
+ "version": "1.0.1",
4
4
  "description": "Component for lazy list render.",
5
5
  "main": "pl-virtual-scroll.js",
6
6
  "repository": {
@@ -23,7 +23,7 @@
23
23
  }
24
24
  ],
25
25
  "dependencies": {
26
- "polylib": "^1.1.2",
26
+ "polylib": "^1.1.12",
27
27
  "@plcmp/utils": "^0.1.0"
28
28
  }
29
29
  }
@@ -1,29 +1,35 @@
1
- import { html, PlElement, TemplateInstance } from "polylib";
2
- import { PlaceHolder } from "@plcmp/utils";
3
- import {ContextMixin} from "polylib/engine/v1/ctx.js";
4
- import {normalizePath} from "polylib/common.js";
1
+ import { html, PlElement, TemplateInstance } from 'polylib';
2
+ import { PlaceHolder } from '@plcmp/utils';
3
+ import { ContextMixin } from 'polylib/engine/v1/ctx.js';
4
+ import { normalizePath } from 'polylib/common.js';
5
5
 
6
6
  /** @typedef VirtualScrollItem
7
- * @property {LightDataContext} ctx
8
- * @property {TemplateInstance} ti
9
- * @property {Number} index
10
- * @property {Number} h - height of rendered item
7
+ * @property { RepeatItem } ctx
8
+ * @property { TemplateInstance } ti
9
+ * @property { number | null } index
10
+ * @property { number } h - height of rendered item
11
+ * @property { number } offset
11
12
  */
12
13
 
13
14
  class PlVirtualScroll extends PlElement {
14
- /** @type VirtualScrollItem[]*/
15
+ /** @type VirtualScrollItem[] */
15
16
  phyPool = [];
17
+ /** @type {number | undefined} */
18
+ elementHeight;
19
+
16
20
  constructor() {
17
21
  super({ lightDom: true });
18
22
  }
23
+
19
24
  static properties = {
20
25
  as: { value: 'item' },
21
26
  items: { type: Array, observer: '_dataChanged' },
22
- renderedStart: { type: Number, value: 0 },
23
- renderedCount: { type: Number, value: 0 },
24
27
  phyItems: { type: Array, value: () => [] },
25
- canvas: { type: Object }
26
- }
28
+ canvas: { type: Object },
29
+ variableRowHeight: { type: Boolean, value: false },
30
+ rowHeight: { type: Number }
31
+ };
32
+
27
33
  static template = html`
28
34
  <style>
29
35
  pl-virtual-scroll {
@@ -50,37 +56,32 @@ class PlVirtualScroll extends PlElement {
50
56
  </style>
51
57
  <div id="vsCanvas"></div>
52
58
  `;
59
+
53
60
  static repTpl = html`<template d:repeat="{{phyItems}}" d:as="[[as]]"><div class="vs-item">[[sTpl]]</div></template>`;
61
+
54
62
  connectedCallback() {
55
63
  super.connectedCallback();
56
64
 
57
65
  this.canvas = this.canvas ?? this.$.vsCanvas;
58
- this.canvas.parentNode.addEventListener('scroll', e => this.onScroll(e) );
59
- let tplEl = [...this.childNodes].find( n => n.nodeType === document.COMMENT_NODE && n.textContent.startsWith('tpl:'));
66
+ this.canvas.parentNode.addEventListener('scroll', e => this.onScroll(e));
67
+ let tplEl = [...this.childNodes].find(n => n.nodeType === document.COMMENT_NODE && n.textContent.startsWith('tpl:'));
60
68
  this.sTpl = tplEl?._tpl;
61
69
  this._hctx = tplEl?._hctx;
62
-
63
- /* let ti = new TemplateInstance(PlVirtualScroll.repTpl);
64
- ti.attach(canvas, this, this);
65
- */
66
- /* render items if them already assigned */
67
- /*if (Array.isArray(this.items) && this.items.length > 0) {
68
- this.render();
69
- }*/
70
70
  }
71
- _dataChanged(data, old, mutation) {
71
+
72
+ _dataChanged(data, old, /** DataMutation */ mutation) {
72
73
  // set microtask, element may be not inserted in dom tree yet,
73
74
  // but we need to know viewport height to render
74
75
  let [, index, ...rest] = normalizePath(mutation.path);
75
76
  switch (mutation.action) {
76
77
  case 'upd':
77
- if(mutation.path == 'items' && Array.isArray(mutation.value) && Array.isArray(mutation.oldValue)) {
78
- this.phyPool.forEach(i => {
78
+ if (mutation.path === 'items' && Array.isArray(mutation.value) && Array.isArray(mutation.oldValue)) {
79
+ this.phyPool.forEach((i) => {
79
80
  if (i.index !== null && i.index < this.items.length) {
80
81
  if (this.items[i.index] instanceof PlaceHolder) this.items.load?.(this.items[i.index]);
81
82
 
82
83
  i.ctx.replace(this.items[i.index]);
83
- i.ctx.applyEffects();
84
+ i.ctx.applyEffects(undefined);
84
85
  i.ctx._ti.applyBinds();
85
86
  } else if (i.index >= this.items.length) {
86
87
  i.index = null;
@@ -93,119 +94,188 @@ class PlVirtualScroll extends PlElement {
93
94
  if (el && rest.length > 0) {
94
95
  let path = [this.as, ...rest].join('.');
95
96
  el.ctx.applyEffects({ ...mutation, path });
96
- if (this.items[el.index] instanceof PlaceHolder) this.items.load?.(this.items[el.index])
97
+ if (this.items[el.index] instanceof PlaceHolder) this.items.load?.(this.items[el.index]);
97
98
  }
98
99
  } else {
99
100
  setTimeout(() => this.render(), 0);
100
101
  }
101
102
  break;
102
- case 'splice':
103
+ case 'splice': {
103
104
  let { index: spliceIndex } = mutation;
104
- // if mutation is not root try to apply effects to childs (need when pushing to arrya inside array)
105
- if(rest.length > 0) {
105
+ // if mutation is not root try to apply effects to children (need when pushing to array inside array)
106
+ if (rest.length > 0) {
106
107
  let path = [this.as, ...rest].join('.');
107
108
  this.phyPool[index].ctx.applyEffects({ ...mutation, path });
108
109
  } else {
109
- this.phyPool.forEach(i => {
110
+ this.phyPool.forEach((i) => {
110
111
  if (i.index !== null && i.index >= spliceIndex && i.index < this.items.length) {
111
112
  if (this.items[i.index] instanceof PlaceHolder) this.items.load?.(this.items[i.index]);
112
-
113
+
113
114
  i.ctx.replace(this.items[i.index]);
114
- i.ctx.applyEffects();
115
+ i.ctx.applyEffects(undefined);
115
116
  i.ctx._ti.applyBinds();
116
117
  } else if (i.index >= this.items.length) {
117
118
  i.index = null;
119
+ i.offset = -10000;
120
+ fixOffset(i);
118
121
  }
119
122
  });
120
123
  }
121
124
 
122
- //TODO: add more Heuristic to scroll list if visible elements that not changed? like insert rows before
125
+ // TODO: add more Heuristic to scroll list if visible elements that not changed? like insert rows before
123
126
  // visible area
124
127
 
125
- //refresh all PHY if they can be affected
126
-
128
+ // refresh all PHY if they can be affected
127
129
  setTimeout(() => this.render(), 0);
128
130
 
129
131
  break;
132
+ }
130
133
  }
131
134
  }
132
135
 
133
- /**
134
- *
135
- * @param {Boolean} [scroll] - render for new scroll position
136
- */
137
- render(scroll) {
138
- let canvas = this.canvas;
139
- let offset = canvas.parentNode.scrollTop;
140
- let height = canvas.parentNode.offsetHeight;
141
- if (height === 0 || !this.items) return;
142
-
143
- if (!this.elementHeight && this.items.length > 0) {
144
- let el = this.renderItem(0, undefined, 0);
145
- this.elementHeight = el.h;
146
- }
147
- /*let first = Math.floor(offset / this.elementHeight);
148
- let last = Math.ceil((offset+height) / this.elementHeight);*/
149
- // Reset scroll position if update data smaller than current visible index
150
- if (!scroll && this.items.length < Math.floor((offset + height) / this.elementHeight)) {
151
- canvas.parentNode.scrollTop = this.items.length * this.elementHeight - height;
136
+ render() {
137
+ const canvas = this.canvas;
138
+ let visibleStart = canvas.parentNode.scrollTop,
139
+ height = canvas.parentNode.offsetHeight,
140
+ visibleEnd = visibleStart + height,
141
+ // render cant complete on too small window, set minimal shadow window
142
+ shadowSize = Math.max(height / 2, 500),
143
+ shadowStart = visibleStart - shadowSize,
144
+ shadowEnd = visibleEnd + shadowSize;
145
+
146
+ // cancel render on invisible canvas or empty data
147
+ if (height === 0 || !this.items || this.items.length === 0) {
148
+ canvas.style.setProperty('height', 0);
149
+ return;
152
150
  }
153
- let shadowEnd = Math.min(this.items.length, Math.ceil((offset + height * 1.5) / this.elementHeight));
154
- let shadowBegin = Math.max(0, Math.floor(Math.min((offset - height / 2) / this.elementHeight, shadowEnd - height * 2 / this.elementHeight)));
155
-
156
- let used = [], unused = [];
157
- this.phyPool.forEach(x => {
158
- if (x.index !== null && shadowBegin <= x.index && x.index <= shadowEnd && x.index < this.items.length) {
159
- used.push(x);
160
- } else {
161
- unused.push(x);
162
- }
163
- });
164
151
 
165
- if (used.length > 0) {
166
- used = used.sort((a, b) => a.index - b.index);
167
- if (used[used.length - 1].index < shadowEnd) {
168
- let prev = used[used.length - 1];
169
- let lastUsedIndex = prev.index;
170
- for (let i = lastUsedIndex + 1; i <= Math.min(shadowEnd, this.items.length - 1); i++) {
171
- prev = this.renderItem(i, unused.pop(), prev);
152
+ let used = this.phyPool
153
+ .filter((i) => {
154
+ if (i.offset + i.h < shadowStart || i.offset > shadowEnd) {
155
+ i.index = null;
172
156
  }
173
- }
174
- if (used[0].index > shadowBegin) {
175
- let prev = used[0];
176
- let lastUsedIndex = prev.index;
177
- for (let i = lastUsedIndex - 1; i >= shadowBegin; i--) {
178
- prev = this.renderItem(i, unused.pop(), prev, true);
157
+ return i.index !== null;
158
+ })
159
+ .sort((a, b) => a.index - b.index);
160
+
161
+ // check items height and offset
162
+ if (this.variableRowHeight) {
163
+ let firstVisible = used.findIndex(i => i.offset >= visibleStart && i.offset < visibleEnd);
164
+ if (firstVisible >= 0) {
165
+ // fix forward
166
+ for (let i = firstVisible + 1; i < used.length && used[i].offset < shadowEnd; i++) {
167
+ const newHeight = calcNodesRect(used[i - 1].ctx._ti._nodes).height;
168
+ if (used[i - 1].h !== newHeight) used[i - 1].h = newHeight;
169
+ if (used[i - 1].offset + newHeight !== used[i].offset) {
170
+ used[i].offset = used[i - 1].offset + newHeight;
171
+ fixOffset(used[i]);
172
+ }
173
+ }
174
+ // fix last height
175
+ const last = used[used.length - 1];
176
+ last.h = calcNodesRect(last.ctx._ti._nodes).height;
177
+ // fix backward
178
+ for (let i = firstVisible - 1; i >= 0 && used[i].offset > shadowStart; i--) {
179
+ const newHeight = calcNodesRect(used[i].ctx._ti._nodes).height;
180
+ if (used[i].h !== newHeight) used[i].h = newHeight;
181
+ if (used[i].offset + newHeight !== used[i + 1].offset) {
182
+ used[i].offset = used[i + 1].offset - newHeight;
183
+ fixOffset(used[i]);
184
+ }
179
185
  }
186
+ used = used
187
+ .filter((i) => {
188
+ if (i.offset + i.h < shadowStart || i.offset > shadowEnd) {
189
+ i.index = null;
190
+ }
191
+ return i.index !== null;
192
+ })
193
+ .sort((a, b) => a.index - b.index);
180
194
  }
181
- } else if (shadowBegin >= 0 && this.items.length > 0) {
182
- let prev = this.renderItem(shadowBegin, unused.pop(), shadowBegin * this.elementHeight);
183
- for (let i = shadowBegin + 1; i <= shadowEnd; i++) {
184
- prev = this.renderItem(i, unused.pop(), prev);
195
+ }
196
+ // filter
197
+
198
+ let unused = this.phyPool.filter(i => i.index === null);
199
+
200
+ let firstShadow = used.find(i => i.offset + i.h > shadowStart && i.offset < shadowEnd);
201
+ let lastShadow = used.findLast(i => i.offset < shadowEnd && i.offset + i.h > shadowStart);
202
+
203
+ if (!firstShadow && !lastShadow) {
204
+ // jump to nowhere,
205
+ if (this.canvas.parentNode.scrollTop === 0)
206
+ firstShadow = lastShadow = this.renderItem(0, unused.pop());
207
+ else {
208
+ const heightForStart
209
+ = this.phyPool.length > 0
210
+ ? this.phyPool.reduce((a, i) => a + i.h, 0) / this.phyPool.length
211
+ : 32; // TODO: replace w/o constant
212
+ const predictedStart = Math.min(Math.ceil(this.canvas.parentNode.scrollTop / heightForStart), this.items.length - 1);
213
+ firstShadow = lastShadow = this.renderItem(predictedStart, unused.pop(), this.canvas.parentNode.scrollTop);
214
+ used.unshift(firstShadow);
185
215
  }
186
216
  }
187
217
 
188
- unused.forEach(u => {
189
- u.index = null;
190
- u.ctx._ti._nodes.forEach(i => { if (i.style) i.style.transform = `translateY(-1000px)`; });
218
+ // render forward
219
+ while (
220
+ lastShadow.offset + lastShadow.h < shadowEnd // последний нарисованный не дотягивает до конца окна рисования
221
+ && lastShadow.index < this.items.length - 1 // при этом данные еще не кончились
222
+ ) {
223
+ lastShadow = this.renderItem(lastShadow ? lastShadow.index + 1 : 0, unused.pop(), lastShadow);
224
+ used.push(lastShadow);
225
+ }
226
+
227
+ // render backward
228
+ while (
229
+ firstShadow.offset > shadowStart // последний нарисованный не дотягивает до конца окна рисования
230
+ && firstShadow.index > 0 // при этом данные еще не кончились
231
+ ) {
232
+ firstShadow = this.renderItem(firstShadow.index - 1, unused.pop(), firstShadow, true);
233
+ used.unshift(firstShadow);
234
+ }
235
+
236
+ // move unused to invisible place
237
+ unused.forEach((i) => {
238
+ i.offset = -10000;
239
+ fixOffset(i);
191
240
  });
192
241
 
193
- // fill .5 height window in background
194
- // while phy window not filed, expect +-.5 screen
195
- // render must begin from visible start forward, then backward to .5 s/h
242
+ // calc offset and canvas size
243
+ // TODO: reduce scroll jump
244
+ const avgHeight = used.reduce((a, i) => a + i.h, 0) / used.length;
245
+
246
+ if (lastShadow && !isNaN(avgHeight) && isFinite(avgHeight)) {
247
+ const
248
+ lastRenderedPixel = lastShadow.offset + lastShadow.h,
249
+ restRows = this.items.length - lastShadow.index - 1,
250
+ currentHeight = canvas.offsetHeight,
251
+ predictedHeight = lastRenderedPixel + restRows * avgHeight;
252
+
253
+ if (Math.abs(predictedHeight - currentHeight) > restRows / 10 * avgHeight) {
254
+ canvas.style.setProperty('height', predictedHeight + 'px');
255
+ }
256
+ }
196
257
 
258
+ if (firstShadow && !isNaN(avgHeight) && isFinite(avgHeight)) {
259
+ const
260
+ firstRenderedPixel = firstShadow.offset,
261
+ restRows = firstShadow.index,
262
+ predictedOffset = firstRenderedPixel - restRows * avgHeight;
197
263
 
198
- if (this.elementHeight && this.items.length)
199
- canvas.style.setProperty('height', this.elementHeight * this.items.length + 'px')
200
- else
201
- canvas.style.setProperty('height', 0)
264
+ if (Math.abs(predictedOffset) > restRows / 10 * avgHeight) {
265
+ used.forEach((i) => {
266
+ i.offset -= predictedOffset;
267
+ fixOffset(i);
268
+ });
269
+ this.canvas.parentNode.scrollTop -= predictedOffset;
270
+ }
271
+ }
202
272
  }
203
273
 
204
274
  /**
205
275
  *
206
276
  * @param index
207
277
  * @param {VirtualScrollItem} p_item
208
- * @param prev
278
+ * @param [prev]
209
279
  * @param [backward]
210
280
  * @return {VirtualScrollItem}
211
281
  */
@@ -216,52 +286,59 @@ class PlVirtualScroll extends PlElement {
216
286
  if (p_item) p_item.index = null;
217
287
  return p_item;
218
288
  }
219
- if (this.items[index] instanceof PlaceHolder) this.items.load?.(this.items[index])
289
+ if (this.items[index] instanceof PlaceHolder) this.items.load?.(this.items[index]);
220
290
  let target = p_item ?? this.createNewItem(this.items[index]);
221
291
 
222
292
  target.index = index;
223
293
  if (p_item) {
224
- p_item.ctx.replace(this.items[index])
294
+ p_item.ctx.replace(this.items[index]);
225
295
  p_item.ctx._ti.applyBinds();
226
- p_item.ctx.applyEffects();
296
+ p_item.ctx.applyEffects(undefined);
297
+ if (!this.variableRowHeight) p_item.h = calcNodesRect(p_item.ctx._ti._nodes).height;
227
298
  } else {
228
299
  this.phyPool.push(target);
229
300
  }
301
+ prev ??= 0;
230
302
  target.offset = typeof (prev) == 'number' ? prev : (backward ? prev.offset - target.h : prev.offset + prev.h);
231
- target.ctx._ti._nodes.forEach(n => {
303
+ target.ctx._ti._nodes.forEach((n) => {
232
304
  if (n.style) {
233
305
  n.style.transform = `translateY(${target.offset}px)`;
234
306
  n.style.position = 'absolute';
235
- n.setAttribute('virtualOffset', target.offset);
236
307
  }
237
308
  });
238
309
  return target;
239
310
  }
311
+
240
312
  createNewItem(v) {
241
313
  if (!this.sTpl) return;
242
314
  let inst = new TemplateInstance(this.sTpl);
243
315
 
244
- let ctx = new RepeatItem(v, this.as, (ctx, m) => this.onItemChanged(ctx, m) );
245
- ctx._ti = inst
246
- inst.attach(this.canvas, undefined, [ctx, ...this._hctx ]);
247
- let h = this.elementHeight ?? calcNodesRect(inst._nodes).height;
316
+ let ctx = new RepeatItem(v, this.as, (ctx, m) => this.onItemChanged(ctx, m));
317
+ ctx._ti = inst;
318
+ inst.attach(this.canvas, undefined, [ctx, ...this._hctx]);
319
+ let h = !this.variableRowHeight && this.elementHeight ? this.elementHeight : calcNodesRect(inst._nodes).height;
320
+
321
+ if (!this.variableRowHeight && !this.elementHeight) {
322
+ this.elementHeight = h;
323
+ }
248
324
 
249
325
  return { ctx, h };
250
326
  }
327
+
251
328
  onScroll() {
252
329
  this.render(true);
253
330
  }
331
+
254
332
  onItemChanged(ctx, m) {
255
333
  // skip replace data call
256
334
  if (!m) return;
257
- let ind = this.items.findIndex( i => i === ctx[this.as]);
335
+ let ind = this.items.findIndex(i => i === ctx[this.as]);
258
336
  if (ind < 0) console.warn('repeat item not found');
259
337
  if (m.path === this.as) {
260
338
  this.set(['items', ind], m.value, m.wmh);
261
339
  } else {
262
- this.forwardNotify(m,this.as, 'items.'+ind);
340
+ this.forwardNotify(m, this.as, 'items.' + ind);
263
341
  }
264
-
265
342
  }
266
343
  }
267
344
 
@@ -270,31 +347,43 @@ class RepeatItem extends ContextMixin(EventTarget) {
270
347
  super();
271
348
  this.as = as;
272
349
  this[as] = item;
273
- this.addEffect(as, m => cb(this, m))
350
+ this.addEffect(as, m => cb(this, m));
274
351
  }
352
+
275
353
  get model() {
276
354
  return this[this.as];
277
355
  }
356
+
278
357
  replace(v) {
279
358
  this[this.as] = v;
280
359
  this.wmh = {};
281
360
  }
282
361
  }
283
362
 
363
+ function fixOffset(item) {
364
+ item.ctx._ti._nodes.forEach((n) => {
365
+ if (n.style) {
366
+ n.style.transform = `translateY(${item.offset}px)`;
367
+ }
368
+ });
369
+ }
370
+
284
371
  function calcNodesRect(nodes) {
285
372
  nodes = nodes.filter(n => n.getBoundingClientRect);
286
373
  let rect = nodes[0].getBoundingClientRect();
287
374
  let { top, bottom, left, right } = rect;
288
- ({ top, bottom, left, right } = nodes.map(n => n.getBoundingClientRect()).filter(i => i).reduce((a, c) => (
289
- {
290
- top: Math.min(a.top, c.top),
291
- bottom: Math.max(a.bottom, c.bottom),
292
- left: Math.min(a.left, c.left),
293
- right: Math.max(a.right, c.right)
294
- })
375
+ ({ top, bottom, left, right } = nodes.map(n => n.getBoundingClientRect())
376
+ .filter(i => i)
377
+ .reduce((a, c) => (
378
+ {
379
+ top: Math.min(a.top, c.top),
380
+ bottom: Math.max(a.bottom, c.bottom),
381
+ left: Math.min(a.left, c.left),
382
+ right: Math.max(a.right, c.right)
383
+ })
295
384
  , { top, bottom, left, right }));
296
385
  let { x, y, height, width } = { x: left, y: top, width: right - left, height: bottom - top };
297
386
  return { x, y, height, width };
298
387
  }
299
388
 
300
- customElements.define('pl-virtual-scroll', PlVirtualScroll);
389
+ customElements.define('pl-virtual-scroll', PlVirtualScroll);