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 { /** * Allows observation of results */ observe(listener: (object: T, previousIndex: number, newIndex: number) => void, includeUpdates?: boolean): { remove(): void; }; } interface RenderTask extends QueryResultUpdate { animate: boolean; } export interface AnimationAttrs { animate?: boolean; animateIn?: (nodes: Node[]) => Promise; animateOut?: (nodes: Node[]) => Promise; } export interface WatchForRenditionAttrs extends AnimationAttrs { subject: T[] | Subscribable>; component: (arg: T, index: number) => unknown; } const isObservable = (v: ArrayLike): v is ArrayLike & ObservableResults => 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 extends RenditionBase { private readonly _component: (arg: T, index: number) => unknown; private readonly _node: Node; private readonly _itemRenditions: ItemRendition[] = []; private readonly _subject: T[] | Subscribable>; private readonly _renderTasks: RenderTask[] = []; private readonly _animate: boolean; private readonly _animateIn: (nodes: Node[]) => Promise; private readonly _animateOut: (nodes: Node[]) => Promise; private _ct = Cancellation.none; constructor({ subject, component, animate, animateIn, animateOut }: WatchForRenditionAttrs) { 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>(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) => { 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) => { 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; } }