|
|
import { id as mid } from "module";
|
|
|
import { TraceSource } from "@implab/core-amd/log/TraceSource";
|
|
|
import { argumentNotNull } from "@implab/core-amd/safe";
|
|
|
import { getScope, render } from "./render";
|
|
|
import { RenditionBase } from "./RenditionBase";
|
|
|
import { Scope } from "./Scope";
|
|
|
import { Cancellation } from "@implab/core-amd/Cancellation";
|
|
|
import { collectNodes, destroy as safeDestroy, isDocumentFragmentNode, isElementNode, isMounted, placeAt, startupWidgets } from "./traits";
|
|
|
import { IDestroyable } from "@implab/core-amd/interfaces";
|
|
|
import { play } from "../play";
|
|
|
import * as fx from "dojo/fx";
|
|
|
import { isSubsribable, Subscribable } from "../observable";
|
|
|
import { QueryResultUpdate } from "../tsx";
|
|
|
|
|
|
const trace = TraceSource.get(mid);
|
|
|
|
|
|
interface ItemRendition {
|
|
|
nodes: Node[];
|
|
|
|
|
|
scope: IDestroyable;
|
|
|
|
|
|
destroy(): void;
|
|
|
}
|
|
|
|
|
|
interface ObservableResults<T> {
|
|
|
/**
|
|
|
* Allows observation of results
|
|
|
*/
|
|
|
observe(listener: (object: T, previousIndex: number, newIndex: number) => void, includeUpdates?: boolean): {
|
|
|
remove(): void;
|
|
|
};
|
|
|
}
|
|
|
|
|
|
interface RenderTask<T> extends QueryResultUpdate<T> {
|
|
|
animate: boolean;
|
|
|
}
|
|
|
|
|
|
export interface AnimationAttrs {
|
|
|
animate?: boolean;
|
|
|
|
|
|
animateIn?: (nodes: Node[]) => Promise<void>;
|
|
|
|
|
|
animateOut?: (nodes: Node[]) => Promise<void>;
|
|
|
}
|
|
|
|
|
|
export interface WatchForRenditionAttrs<T> extends AnimationAttrs {
|
|
|
subject: T[] | Subscribable<QueryResultUpdate<T>>;
|
|
|
|
|
|
component: (arg: T, index: number) => unknown;
|
|
|
}
|
|
|
|
|
|
const isObservable = <T>(v: ArrayLike<T>): v is ArrayLike<T> & ObservableResults<T> =>
|
|
|
v && (typeof (v as { observe?: unknown; }).observe === "function");
|
|
|
|
|
|
const noop = () => { };
|
|
|
|
|
|
const fadeIn = (nodes: Node[]) => Promise.all(nodes
|
|
|
.filter(isElementNode)
|
|
|
.map(el => play(fx.fadeIn({ node: el as HTMLElement })))
|
|
|
).then(noop);
|
|
|
|
|
|
const fadeOut = (nodes: Node[]) => Promise.all(nodes
|
|
|
.filter(isElementNode)
|
|
|
.map(el => play(fx.fadeOut({ node: el as HTMLElement })))
|
|
|
).then(noop);
|
|
|
|
|
|
|
|
|
export class WatchForRendition<T> extends RenditionBase<Node> {
|
|
|
private readonly _component: (arg: T, index: number) => unknown;
|
|
|
|
|
|
private readonly _node: Node;
|
|
|
|
|
|
private readonly _itemRenditions: ItemRendition[] = [];
|
|
|
|
|
|
private readonly _subject: T[] | Subscribable<QueryResultUpdate<T>>;
|
|
|
|
|
|
private readonly _renderTasks: RenderTask<T>[] = [];
|
|
|
|
|
|
private readonly _animate: boolean;
|
|
|
|
|
|
private readonly _animateIn: (nodes: Node[]) => Promise<void>;
|
|
|
|
|
|
private readonly _animateOut: (nodes: Node[]) => Promise<void>;
|
|
|
|
|
|
private _ct = Cancellation.none;
|
|
|
|
|
|
constructor({ subject, component, animate, animateIn, animateOut }: WatchForRenditionAttrs<T>) {
|
|
|
super();
|
|
|
argumentNotNull(component, "component");
|
|
|
argumentNotNull(subject, "component");
|
|
|
|
|
|
this._component = component;
|
|
|
|
|
|
this._subject = subject;
|
|
|
|
|
|
this._node = document.createComment("[WatchFor]");
|
|
|
this._animate = !!animate;
|
|
|
this._animateIn = animateIn ?? fadeIn;
|
|
|
this._animateOut = animateOut ?? fadeOut;
|
|
|
}
|
|
|
|
|
|
protected _create() {
|
|
|
const scope = getScope();
|
|
|
scope.own(() => {
|
|
|
this._itemRenditions.forEach(safeDestroy);
|
|
|
safeDestroy(this._node);
|
|
|
});
|
|
|
|
|
|
const result = this._subject;
|
|
|
|
|
|
if (result) {
|
|
|
if (isSubsribable<QueryResultUpdate<T>>(result)) {
|
|
|
let animate = false;
|
|
|
const subscription = result.subscribe({
|
|
|
next: ({ item, prevIndex, newIndex }) => this._onItemUpdated({ item, prevIndex, newIndex, animate })
|
|
|
});
|
|
|
scope.own(subscription);
|
|
|
animate = this._animate;
|
|
|
} else {
|
|
|
if (isObservable(result))
|
|
|
scope.own(result.observe((item, prevIndex, newIndex) => this._onItemUpdated({ item, prevIndex, newIndex, animate: false }), true));
|
|
|
|
|
|
for (let i = 0, n = result.length; i < n; i++)
|
|
|
this._onItemUpdated({ item: result[i], prevIndex: -1, newIndex: i, animate: this._animate });
|
|
|
}
|
|
|
}
|
|
|
this._ct = new Cancellation(cancel => scope.own(cancel));
|
|
|
}
|
|
|
|
|
|
private readonly _onItemUpdated = (item: RenderTask<T>) => {
|
|
|
if (!this._renderTasks.length) {
|
|
|
// schedule a new job
|
|
|
this._renderTasks.push(item);
|
|
|
this._render().catch(e => trace.error(e));
|
|
|
} else {
|
|
|
// update existing job
|
|
|
this._renderTasks.push(item);
|
|
|
}
|
|
|
};
|
|
|
|
|
|
private async _render() {
|
|
|
// fork
|
|
|
await Promise.resolve();
|
|
|
// don't render destroyed rendition
|
|
|
if (this._ct.isRequested())
|
|
|
return;
|
|
|
|
|
|
this._renderTasks.forEach(this._onRenderItem);
|
|
|
this._renderTasks.length = 0;
|
|
|
}
|
|
|
|
|
|
private readonly _onRenderItem = ({ item, newIndex, prevIndex, animate: _animate }: RenderTask<T>) => {
|
|
|
const animate = _animate && prevIndex !== newIndex;
|
|
|
|
|
|
if (prevIndex > -1) {
|
|
|
// if we need to delete previous rendition
|
|
|
const [{ nodes, destroy }] = this._itemRenditions.splice(prevIndex, 1);
|
|
|
if (animate) {
|
|
|
this._animateOut(nodes)
|
|
|
.then(destroy)
|
|
|
.catch(e => trace.error(e));
|
|
|
} else {
|
|
|
destroy();
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (newIndex > -1) {
|
|
|
// if we need to create the new rendition
|
|
|
|
|
|
// 1. create a new scope for rendering a content
|
|
|
const scope = new Scope();
|
|
|
|
|
|
// 2. render the content
|
|
|
const itemNode = render(this._component(item, newIndex), scope);
|
|
|
|
|
|
// 3. track nodes
|
|
|
const nodes = isDocumentFragmentNode(itemNode) ?
|
|
|
collectNodes(itemNode.childNodes) :
|
|
|
[itemNode];
|
|
|
|
|
|
// 5. insert node at the correct position
|
|
|
|
|
|
const { nodes: [beforeNode] } = this._itemRenditions[newIndex] ?? { nodes: [] };
|
|
|
|
|
|
if (beforeNode)
|
|
|
placeAt(itemNode, beforeNode, "before");
|
|
|
else
|
|
|
placeAt(itemNode, this._node, "before");
|
|
|
|
|
|
// 6. store information about rendition
|
|
|
this._itemRenditions.splice(newIndex, 0, {
|
|
|
scope,
|
|
|
nodes,
|
|
|
destroy: () => {
|
|
|
scope.destroy();
|
|
|
nodes.forEach(safeDestroy);
|
|
|
}
|
|
|
});
|
|
|
|
|
|
// 7. startup widgets if needed
|
|
|
if (isMounted(this._node))
|
|
|
nodes.forEach(n => startupWidgets(n));
|
|
|
|
|
|
// 8. optionally play the animation
|
|
|
if (animate)
|
|
|
this._animateIn(nodes).catch(e => trace.error(e));
|
|
|
}
|
|
|
};
|
|
|
|
|
|
protected _getDomNode() {
|
|
|
if (!this._node)
|
|
|
throw new Error("The instance of the widget isn't created");
|
|
|
return this._node;
|
|
|
}
|
|
|
}
|
|
|
|