Skip to content

Commit

Permalink
Merge pull request #34 from dhilt/issue-29-dom-routines-refactoring-p…
Browse files Browse the repository at this point in the history
…art-3

DOM Routines minor refactoring, part 3
  • Loading branch information
dhilt committed Jan 22, 2022
2 parents 1eb11a4 + c993b6b commit 385a2f6
Show file tree
Hide file tree
Showing 13 changed files with 320 additions and 135 deletions.
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2021 Denis Hilt (https://github.com/dhilt)
Copyright (c) 2022 Denis Hilt (https://github.com/dhilt)

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
33 changes: 17 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,17 +140,18 @@ This obliges the Datasource.get method to deal with _Data_ items and also provid
A callback that is called every time the Workflow decides that the UI needs to be changed. Its argument is a list of items to be present in the UI. This is a consumer responsibility to detect changes and display them in the UI.

```js
run: items => {
// assume currentItems contains a list of items currently presented in the UI
if (!items.length && !currentItems.length) {
run: (newItems) => {
// assume oldItems contains a list of items that are currently present in the UI
if (!newItems.length && !oldItems.length) {
return;
}
displayNewItemsInsteadCurrentOnes(items, currentItems);
currentItems = items;
}
// make newItems to be present in the UI instead of oldItems
processItems(newItems, oldItems);
oldItems = newItems;
};
```

Each item is an instance of the [Item class](https://github.com/dhilt/vscroll/blob/v1.0.0/src/classes/item.ts) implementing the [Item interface](https://github.com/dhilt/vscroll/blob/v1.0.0/src/interfaces/item.ts), whose props can be used for proper implementation of the `run` callback:
Each item (in both `newItems` and `oldItems` lists) is an instance of the [Item class](https://github.com/dhilt/vscroll/blob/v1.0.0/src/classes/item.ts) implementing the [Item interface](https://github.com/dhilt/vscroll/blob/v1.0.0/src/interfaces/item.ts), whose props can be used for proper implementation of the `run` callback:

|Name|Type|Description|
|:--|:--|:----|
Expand All @@ -162,19 +163,19 @@ Each item is an instance of the [Item class](https://github.com/dhilt/vscroll/bl

`Run` callback is the most complex and environment-specific part of the `vscroll` API, which is fully depends on the environment for which the consumer is being created. Framework specific consumer should rely on internal mechanism of the framework to provide runtime DOM modifications.

There are some requirements on how the `items` should be processed by `run(items)` call:
- after the `run(items)` callback is completed, there must be `items.length` elements in the DOM between backward and forward padding elements;
- old items that are not in the list should be removed from DOM; use `currentItems[].element` reference for this purpose;
- old items that are in the list should not be removed and recreated, as it may lead to an unwanted shift of the scroll position; just don't touch them;
- new items elements should be rendered in accordance with `items[].$index` comparable to `$index` of elements that remain: `$index` must increase continuously and the directions of increase must persist across the `run` calls; Scroller maintains `$index` internally, so you only need to properly inject the `items[].element` into the DOM;
- new elements should be rendered but not visible, and this should be achieved by "fixed" positioning and "left"/"top" coordinates placing the item element out of view; the Workflow will take care of visibility after calculations; an additional attribute `items[].invisible` can be used to determine if a given element should be hidden;
- new items elements should have "data-sid" attribute, which value should reflect `items[].$index`;
There are some requirements on how the items should be processed by `run` call:
- after the `run` callback is completed, there must be `newItems.length` elements in the DOM between backward and forward padding elements;
- old items that are not in the new item list should be removed from DOM; use `oldItems[].element` references for this purpose;
- old items that are in the list should not be removed and recreated, as it may lead to an unwanted shift of the scroll position; just don't touch them;
- new items elements should be rendered in accordance with `newItems[].$index` comparable to `$index` of elements that remain: `$index` must increase continuously and the directions of increase must persist across the `run` calls; Scroller maintains `$index` internally, so you only need to properly inject a set of `newItems[].element` into the DOM;
- new elements should be rendered but not visible, and this should be achieved by "fixed" positioning and "left"/"top" coordinates placing the item element out of view; the Workflow will take care of visibility after calculations; an additional attribute `newItems[].invisible` can be used to determine if a given element should be hidden;
- new items elements should have "data-sid" attribute, which value should reflect `newItems[].$index`;

## Live

This repository has a minimal demonstration of the App-consumer implementation considering all of the requirements listed above: https://dhilt.github.io/vscroll/. This is all-in-one HTML demo with `vscroll` taken from CDN. The source code of the demo is [here](https://github.com/dhilt/vscroll/blob/main/demo/index.html). The approach is rough and non-optimized, if you are seeking for more general solution for native JavaScript applications, please take a look at [vscroll-native](https://github.com/dhilt/vscroll-native) project. It is relatively new and has no good documentation, but its [source code](https://github.com/dhilt/vscroll-native/tree/main/src) and [demo](https://github.com/dhilt/vscroll-native/tree/main/demo) may shed light on `vscroll` usage in no-framework environment.

Another example is [ngx-ui-scroll](https://github.com/dhilt/ngx-ui-scroll). Before 2021 `vscroll` was part of `ngx-ui-scroll`, and its [demo page](https://dhilt.github.io/ngx-ui-scroll/#/) contains well-documented samples that can be used to get an idea on the API and functionality offered by `vscroll`. The code of the [UiScrollComponent](https://github.com/dhilt/ngx-ui-scroll/blob/v2.0.0-rc.1/src/ui-scroll.component.ts) clearly demonstrates the `Workflow` instantiation in the context of Angular. Also, since ngx-ui-scroll is the intermediate layer between `vscroll` and the end Application, the Datasource is being provided from the outside. Method `makeDatasource` is used to provide `Datasource` class to the end Application.
Another example is [ngx-ui-scroll](https://github.com/dhilt/ngx-ui-scroll). Before 2021 `vscroll` was part of `ngx-ui-scroll`, and its [demo page](https://dhilt.github.io/ngx-ui-scroll/#/) contains well-documented samples that can be used to get an idea on the API and functionality offered by `vscroll`. The code of the [UiScrollComponent](https://github.com/dhilt/ngx-ui-scroll/blob/v2.2.0/src/ui-scroll.component.ts) clearly demonstrates the `Workflow` instantiation in the context of Angular. Also, since ngx-ui-scroll is the intermediate layer between `vscroll` and the end Application, the Datasource is being provided from the outside. Method `makeDatasource` is used to provide `Datasource` class to the end Application.

## Adapter API

Expand Down Expand Up @@ -223,4 +224,4 @@ VScroll will receive its own Adapter API documentation later, but for now please

__________

2021 © [Denis Hilt](https://github.com/dhilt)
2022 © [Denis Hilt](https://github.com/dhilt)
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "vscroll",
"version": "1.4.3",
"version": "1.4.4",
"description": "Virtual scroll engine",
"main": "dist/bundles/vscroll.umd.js",
"module": "dist/bundles/vscroll.esm5.js",
Expand Down
164 changes: 90 additions & 74 deletions src/classes/domRoutines.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import { Settings } from './settings';
import { Direction } from '../inputs/index';
import { IRoutines } from '../interfaces/index';

export class Routines {
export class Routines implements IRoutines {

readonly horizontal: boolean;
readonly window: boolean;
readonly viewport: HTMLElement | null;
readonly settings: IRoutines['settings'];
readonly element: HTMLElement;
readonly viewport: HTMLElement;

constructor(settings: Settings) {
this.horizontal = settings.horizontal;
this.window = settings.windowViewport;
this.viewport = settings.viewport;
constructor(element: HTMLElement, settings: Settings) {
this.settings = {
viewport: settings.viewport,
horizontal: settings.horizontal,
window: settings.windowViewport
};
this.element = element;
this.viewport = this.getViewportElement();
this.onInit(settings);
}

checkElement(element: HTMLElement): void {
Expand All @@ -19,117 +25,125 @@ export class Routines {
}
}

getHostElement(element: HTMLElement): HTMLElement {
if (this.window) {
getViewportElement(): HTMLElement {
if (this.settings.window) {
return document.documentElement;
}
if (this.viewport) {
return this.viewport;
if (this.settings.viewport) {
return this.settings.viewport;
}
this.checkElement(element);
const parent = element.parentElement as HTMLElement;
this.checkElement(this.element);
const parent = this.element.parentElement as HTMLElement;
this.checkElement(parent);
return parent;
}

getScrollEventReceiver(element: HTMLElement): HTMLElement | Window {
if (this.window) {
return window;
onInit(settings: Settings): void {
if (settings.windowViewport) {
if ('scrollRestoration' in history) {
history.scrollRestoration = 'manual';
}
}
return this.getHostElement(element);
}

setupScrollRestoration(): void {
if ('scrollRestoration' in history) {
history.scrollRestoration = 'manual';
if (settings.dismissOverflowAnchor) {
this.viewport.style.overflowAnchor = 'none';
}
}

dismissOverflowAnchor(element: HTMLElement): void {
this.checkElement(element);
element.style.overflowAnchor = 'none';
}

findElementBySelector(element: HTMLElement, selector: string): HTMLElement | null {
this.checkElement(element);
return element.querySelector(selector);
}

findPaddingElement(element: HTMLElement, direction: Direction): HTMLElement | null {
return this.findElementBySelector(element, `[data-padding-${direction}]`);
findPaddingElement(direction: Direction): HTMLElement | null {
return this.findElementBySelector(this.element, `[data-padding-${direction}]`);
}

findItemElement(element: HTMLElement, id: string): HTMLElement | null {
return this.findElementBySelector(element, `[data-sid="${id}"]`);
findItemElement(id: string): HTMLElement | null {
return this.findElementBySelector(this.element, `[data-sid="${id}"]`);
}

getScrollPosition(element: HTMLElement): number {
if (this.window) {
return window.pageYOffset;
getScrollPosition(): number {
if (this.settings.window) {
return this.settings.horizontal ? window.pageXOffset : window.pageYOffset;
}
this.checkElement(element);
return element[this.horizontal ? 'scrollLeft' : 'scrollTop'];
return this.viewport[this.settings.horizontal ? 'scrollLeft' : 'scrollTop'];
}

setScrollPosition(element: HTMLElement, value: number): void {
setScrollPosition(value: number): void {
value = Math.max(0, value);
if (this.window) {
if (this.horizontal) {
if (this.settings.window) {
if (this.settings.horizontal) {
window.scrollTo(value, window.scrollY);
} else {
window.scrollTo(window.scrollX, value);
}
return;
}
this.checkElement(element);
element[this.horizontal ? 'scrollLeft' : 'scrollTop'] = value;
this.viewport[this.settings.horizontal ? 'scrollLeft' : 'scrollTop'] = value;
}

getParams(element: HTMLElement, doNotBind?: boolean): DOMRect {
getElementParams(element: HTMLElement): DOMRect {
this.checkElement(element);
if (this.window && doNotBind) {
const { clientWidth, clientHeight, clientLeft, clientTop } = element;
return {
'height': clientHeight,
'width': clientWidth,
'top': clientTop,
'bottom': clientTop + clientHeight,
'left': clientLeft,
'right': clientLeft + clientWidth,
'x': clientLeft,
'y': clientTop,
'toJSON': () => null,
};
}
return element.getBoundingClientRect();
}

getSize(element: HTMLElement, doNotBind?: boolean): number {
return this.getParams(element, doNotBind)[this.horizontal ? 'width' : 'height'];
getWindowParams(): DOMRect {
const { clientWidth, clientHeight, clientLeft, clientTop } = this.viewport;
return {
'height': clientHeight,
'width': clientWidth,
'top': clientTop,
'bottom': clientTop + clientHeight,
'left': clientLeft,
'right': clientLeft + clientWidth,
'x': clientLeft,
'y': clientTop,
'toJSON': () => null,
};
}

getSize(element: HTMLElement): number {
return this.getElementParams(element)[this.settings.horizontal ? 'width' : 'height'];
}

getScrollerSize(): number {
return this.getElementParams(this.element)[this.settings.horizontal ? 'width' : 'height'];
}

getViewportSize(): number {
if (this.settings.window) {
return this.getWindowParams()[this.settings.horizontal ? 'width' : 'height'];
}
return this.getSize(this.viewport);
}

getSizeStyle(element: HTMLElement): number {
this.checkElement(element);
const size = element.style[this.horizontal ? 'width' : 'height'];
const size = element.style[this.settings.horizontal ? 'width' : 'height'];
return parseFloat(size as string) || 0;
}

setSizeStyle(element: HTMLElement, value: number): void {
this.checkElement(element);
value = Math.max(0, Math.round(value));
element.style[this.horizontal ? 'width' : 'height'] = `${value}px`;
element.style[this.settings.horizontal ? 'width' : 'height'] = `${value}px`;
}

getEdge(element: HTMLElement, direction: Direction, doNotBind?: boolean): number {
const params = this.getParams(element, doNotBind);
getEdge(element: HTMLElement, direction: Direction): number {
const { horizontal } = this.settings;
const params = this.getElementParams(element);
const isFwd = direction === Direction.forward;
return params[isFwd ? (this.horizontal ? 'right' : 'bottom') : (this.horizontal ? 'left' : 'top')];
return params[isFwd ? (horizontal ? 'right' : 'bottom') : (horizontal ? 'left' : 'top')];
}

getEdge2(element: HTMLElement, direction: Direction, relativeElement: HTMLElement, opposite: boolean): number {
// vertical only ?
return element.offsetTop - (relativeElement ? relativeElement.scrollTop : 0) +
(direction === (!opposite ? Direction.forward : Direction.backward) ? this.getSize(element) : 0);
getViewportEdge(direction: Direction): number {
const { window, horizontal } = this.settings;
if (window) {
const params = this.getWindowParams();
const isFwd = direction === Direction.forward;
return params[isFwd ? (horizontal ? 'right' : 'bottom') : (horizontal ? 'left' : 'top')];
}
return this.getEdge(this.viewport, direction);
}

makeElementVisible(element: HTMLElement): void {
Expand All @@ -144,9 +158,10 @@ export class Routines {
element.style.display = 'none';
}

getOffset(element: HTMLElement): number {
this.checkElement(element);
return (this.horizontal ? element.offsetLeft : element.offsetTop) || 0;
getOffset(): number {
const get = (element: HTMLElement) =>
(this.settings.horizontal ? element.offsetLeft : element.offsetTop) || 0;
return get(this.element) - (!this.settings.window ? get(this.viewport) : 0);
}

scrollTo(element: HTMLElement, argument?: boolean | ScrollIntoViewOptions): void {
Expand All @@ -164,9 +179,10 @@ export class Routines {
return () => cancelAnimationFrame(animationFrameId);
}

onScroll(element: HTMLElement | Window, handler: EventListener): () => void {
element.addEventListener('scroll', handler);
return () => element.removeEventListener('scroll', handler);
onScroll(handler: EventListener): () => void {
const eventReceiver = this.settings.window ? window : this.viewport;
eventReceiver.addEventListener('scroll', handler);
return () => eventReceiver.removeEventListener('scroll', handler);
}

}
6 changes: 3 additions & 3 deletions src/classes/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export class Logger {
readonly getWorkflowCycleData: () => string;
readonly getLoopId: () => string;
readonly getLoopIdNext: () => string;
readonly getScrollPosition: (element: HTMLElement) => number;
readonly getScrollPosition: () => number;
private logs: unknown[][] = [];

constructor(scroller: Scroller, packageInfo: IPackages, adapter?: { id: number }) {
Expand Down Expand Up @@ -47,7 +47,7 @@ export class Logger {
this.getLoopIdNext = (): string => scroller.state.cycle.loopIdNext;
this.getWorkflowCycleData = (): string =>
`${settings.instanceIndex}-${scroller.state.cycle.count}`;
this.getScrollPosition = (element: HTMLElement) => scroller.routines.getScrollPosition(element);
this.getScrollPosition = () => scroller.routines.getScrollPosition();
this.log(() =>
'vscroll Workflow has been started, ' +
`core: ${packageInfo.core.name} v${packageInfo.core.version}, ` +
Expand Down Expand Up @@ -109,7 +109,7 @@ export class Logger {

prepareForLog(data: unknown): unknown {
return data instanceof Event && data.target
? this.getScrollPosition(data.target as HTMLElement)
? this.getScrollPosition()
: data;
}

Expand Down
10 changes: 5 additions & 5 deletions src/classes/paddings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ export class Padding {
direction: Direction;
routines: Routines;

constructor(element: HTMLElement, direction: Direction, routines: Routines) {
const found = routines.findPaddingElement(element, direction);
constructor(direction: Direction, routines: Routines) {
const found = routines.findPaddingElement(direction);
routines.checkElement(found as HTMLElement);
this.element = found as HTMLElement;
this.direction = direction;
Expand All @@ -35,10 +35,10 @@ export class Paddings {
forward: Padding;
backward: Padding;

constructor(element: HTMLElement, routines: Routines, settings: Settings) {
constructor(routines: Routines, settings: Settings) {
this.settings = settings;
this.forward = new Padding(element, Direction.forward, routines);
this.backward = new Padding(element, Direction.backward, routines);
this.forward = new Padding(Direction.forward, routines);
this.backward = new Padding(Direction.backward, routines);
}

byDirection(direction: Direction, opposite?: boolean): Padding {
Expand Down
Loading

0 comments on commit 385a2f6

Please sign in to comment.