@plcmp/pl-virtual-scroll 0.1.14 → 1.0.0

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 +204 -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.0",
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,187 @@ 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);
185
214
  }
186
215
  }
187
216
 
188
- unused.forEach(u => {
189
- u.index = null;
190
- u.ctx._ti._nodes.forEach(i => { if (i.style) i.style.transform = `translateY(-1000px)`; });
217
+ // render forward
218
+ while (
219
+ lastShadow.offset + lastShadow.h < shadowEnd // последний нарисованный не дотягивает до конца окна рисования
220
+ && lastShadow.index < this.items.length - 1 // при этом данные еще не кончились
221
+ ) {
222
+ lastShadow = this.renderItem(lastShadow ? lastShadow.index + 1 : 0, unused.pop(), lastShadow);
223
+ used.push(lastShadow);
224
+ }
225
+
226
+ // render backward
227
+ while (
228
+ firstShadow.offset > shadowStart // последний нарисованный не дотягивает до конца окна рисования
229
+ && firstShadow.index > 0 // при этом данные еще не кончились
230
+ ) {
231
+ firstShadow = this.renderItem(firstShadow.index - 1, unused.pop(), firstShadow, true);
232
+ used.unshift(firstShadow);
233
+ }
234
+
235
+ // move unused to invisible place
236
+ unused.forEach((i) => {
237
+ i.offset = -10000;
238
+ fixOffset(i);
191
239
  });
192
240
 
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
241
+ // calc offset and canvas size
242
+ // TODO: reduce scroll jump
243
+ const avgHeight = used.reduce((a, i) => a + i.h, 0) / used.length;
244
+
245
+ if (lastShadow && !isNaN(avgHeight) && isFinite(avgHeight)) {
246
+ const
247
+ lastRenderedPixel = lastShadow.offset + lastShadow.h,
248
+ restRows = this.items.length - lastShadow.index - 1,
249
+ currentHeight = canvas.offsetHeight,
250
+ predictedHeight = lastRenderedPixel + restRows * avgHeight;
251
+
252
+ if (Math.abs(predictedHeight - currentHeight) > restRows / 10 * avgHeight) {
253
+ canvas.style.setProperty('height', predictedHeight + 'px');
254
+ }
255
+ }
196
256
 
257
+ if (firstShadow && !isNaN(avgHeight) && isFinite(avgHeight)) {
258
+ const
259
+ firstRenderedPixel = firstShadow.offset,
260
+ restRows = firstShadow.index,
261
+ predictedOffset = firstRenderedPixel - restRows * avgHeight;
197
262
 
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)
263
+ if (Math.abs(predictedOffset) > restRows / 10 * avgHeight) {
264
+ used.forEach((i) => {
265
+ i.offset -= predictedOffset;
266
+ fixOffset(i);
267
+ });
268
+ this.canvas.parentNode.scrollTop -= predictedOffset;
269
+ }
270
+ }
202
271
  }
203
272
 
204
273
  /**
205
274
  *
206
275
  * @param index
207
276
  * @param {VirtualScrollItem} p_item
208
- * @param prev
277
+ * @param [prev]
209
278
  * @param [backward]
210
279
  * @return {VirtualScrollItem}
211
280
  */
@@ -216,52 +285,59 @@ class PlVirtualScroll extends PlElement {
216
285
  if (p_item) p_item.index = null;
217
286
  return p_item;
218
287
  }
219
- if (this.items[index] instanceof PlaceHolder) this.items.load?.(this.items[index])
288
+ if (this.items[index] instanceof PlaceHolder) this.items.load?.(this.items[index]);
220
289
  let target = p_item ?? this.createNewItem(this.items[index]);
221
290
 
222
291
  target.index = index;
223
292
  if (p_item) {
224
- p_item.ctx.replace(this.items[index])
293
+ p_item.ctx.replace(this.items[index]);
225
294
  p_item.ctx._ti.applyBinds();
226
- p_item.ctx.applyEffects();
295
+ p_item.ctx.applyEffects(undefined);
296
+ if (!this.variableRowHeight) p_item.h = calcNodesRect(p_item.ctx._ti._nodes).height;
227
297
  } else {
228
298
  this.phyPool.push(target);
229
299
  }
300
+ prev ??= 0;
230
301
  target.offset = typeof (prev) == 'number' ? prev : (backward ? prev.offset - target.h : prev.offset + prev.h);
231
- target.ctx._ti._nodes.forEach(n => {
302
+ target.ctx._ti._nodes.forEach((n) => {
232
303
  if (n.style) {
233
304
  n.style.transform = `translateY(${target.offset}px)`;
234
305
  n.style.position = 'absolute';
235
- n.setAttribute('virtualOffset', target.offset);
236
306
  }
237
307
  });
238
308
  return target;
239
309
  }
310
+
240
311
  createNewItem(v) {
241
312
  if (!this.sTpl) return;
242
313
  let inst = new TemplateInstance(this.sTpl);
243
314
 
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;
315
+ let ctx = new RepeatItem(v, this.as, (ctx, m) => this.onItemChanged(ctx, m));
316
+ ctx._ti = inst;
317
+ inst.attach(this.canvas, undefined, [ctx, ...this._hctx]);
318
+ let h = !this.variableRowHeight && this.elementHeight ? this.elementHeight : calcNodesRect(inst._nodes).height;
319
+
320
+ if (!this.variableRowHeight && !this.elementHeight) {
321
+ this.elementHeight = h;
322
+ }
248
323
 
249
324
  return { ctx, h };
250
325
  }
326
+
251
327
  onScroll() {
252
328
  this.render(true);
253
329
  }
330
+
254
331
  onItemChanged(ctx, m) {
255
332
  // skip replace data call
256
333
  if (!m) return;
257
- let ind = this.items.findIndex( i => i === ctx[this.as]);
334
+ let ind = this.items.findIndex(i => i === ctx[this.as]);
258
335
  if (ind < 0) console.warn('repeat item not found');
259
336
  if (m.path === this.as) {
260
337
  this.set(['items', ind], m.value, m.wmh);
261
338
  } else {
262
- this.forwardNotify(m,this.as, 'items.'+ind);
339
+ this.forwardNotify(m, this.as, 'items.' + ind);
263
340
  }
264
-
265
341
  }
266
342
  }
267
343
 
@@ -270,31 +346,43 @@ class RepeatItem extends ContextMixin(EventTarget) {
270
346
  super();
271
347
  this.as = as;
272
348
  this[as] = item;
273
- this.addEffect(as, m => cb(this, m))
349
+ this.addEffect(as, m => cb(this, m));
274
350
  }
351
+
275
352
  get model() {
276
353
  return this[this.as];
277
354
  }
355
+
278
356
  replace(v) {
279
357
  this[this.as] = v;
280
358
  this.wmh = {};
281
359
  }
282
360
  }
283
361
 
362
+ function fixOffset(item) {
363
+ item.ctx._ti._nodes.forEach((n) => {
364
+ if (n.style) {
365
+ n.style.transform = `translateY(${item.offset}px)`;
366
+ }
367
+ });
368
+ }
369
+
284
370
  function calcNodesRect(nodes) {
285
371
  nodes = nodes.filter(n => n.getBoundingClientRect);
286
372
  let rect = nodes[0].getBoundingClientRect();
287
373
  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
- })
374
+ ({ top, bottom, left, right } = nodes.map(n => n.getBoundingClientRect())
375
+ .filter(i => i)
376
+ .reduce((a, c) => (
377
+ {
378
+ top: Math.min(a.top, c.top),
379
+ bottom: Math.max(a.bottom, c.bottom),
380
+ left: Math.min(a.left, c.left),
381
+ right: Math.max(a.right, c.right)
382
+ })
295
383
  , { top, bottom, left, right }));
296
384
  let { x, y, height, width } = { x: left, y: top, width: right - left, height: bottom - top };
297
385
  return { x, y, height, width };
298
386
  }
299
387
 
300
- customElements.define('pl-virtual-scroll', PlVirtualScroll);
388
+ customElements.define('pl-virtual-scroll', PlVirtualScroll);