@player-ui/player
Version:
181 lines (155 loc) • 5.77 kB
text/typescript
import { SyncHook, SyncWaterfallHook } from "tapable-ts";
import queueMicrotask from "queue-microtask";
import { Registry } from "@player-ui/partial-match-registry";
import type { View, NavigationFlowViewState } from "@player-ui/types";
import { resolveDataRefsInString } from "../../string-resolver";
import type { Resolve } from "../../view";
import { ViewInstance } from "../../view";
import type { Logger } from "../../logger";
import type { FlowInstance, FlowController } from "../flow";
import type { DataController } from "../data/controller";
import { AssetTransformCorePlugin } from "./asset-transform";
import type { TransformRegistry } from "./types";
import type { BindingInstance } from "../../binding";
export interface ViewControllerOptions {
/** Where to get data from */
model: DataController;
/** Where to log data */
logger?: Logger;
/** A flow-controller instance to listen for view changes */
flowController: FlowController;
}
/** A controller to manage updating/switching views */
export class ViewController {
public readonly hooks = {
/** Do any processing before the `View` instance is created */
resolveView: new SyncWaterfallHook<
[View | undefined, string, NavigationFlowViewState]
>(),
// The hook right before the View starts resolving. Attach anything custom here
view: new SyncHook<[ViewInstance]>(),
};
private readonly viewMap: Record<string, View>;
private readonly viewOptions: Resolve.ResolverOptions & ViewControllerOptions;
private pendingUpdate?: {
/** pending data binding changes */
changedBindings?: Set<BindingInstance>;
/** Whether we have a microtask queued to handle this pending update */
scheduled?: boolean;
};
public currentView?: ViewInstance;
public transformRegistry: TransformRegistry = new Registry();
public optimizeUpdates = true;
constructor(
initialViews: View[],
options: Resolve.ResolverOptions & ViewControllerOptions,
) {
this.viewOptions = options;
this.viewMap = initialViews.reduce<Record<string, View>>(
(viewMap, view) => {
// eslint-disable-next-line no-param-reassign
viewMap[view.id] = view;
return viewMap;
},
{},
);
new AssetTransformCorePlugin(this.transformRegistry).apply(this);
options.flowController.hooks.flow.tap(
"viewController",
(flow: FlowInstance) => {
flow.hooks.transition.tap("viewController", (_oldState, newState) => {
if (newState.value.state_type === "VIEW") {
this.onView(newState.value);
} else {
this.currentView = undefined;
}
});
},
);
/** Trigger a view update */
const update = (updates: Set<BindingInstance>, silent = false) => {
if (this.currentView) {
if (this.optimizeUpdates) {
this.queueUpdate(updates, silent);
} else {
this.currentView.update();
}
}
};
options.model.hooks.onUpdate.tap(
"viewController",
(updates, updateOptions) => {
update(
new Set(updates.map((t) => t.binding)),
updateOptions?.silent ?? false,
);
},
);
options.model.hooks.onDelete.tap("viewController", (binding) => {
const parentBinding = binding.parent();
const property = binding.key();
// Deleting an array item will trigger an update for the entire array
if (typeof property === "number" && parentBinding) {
update(new Set([parentBinding]));
} else {
update(new Set([binding]));
}
});
}
private queueUpdate(bindings: Set<BindingInstance>, silent = false) {
if (this.pendingUpdate?.changedBindings) {
// If there's already a pending update, just add to it don't worry about silent updates here yet
this.pendingUpdate.changedBindings = new Set([
...this.pendingUpdate.changedBindings,
...bindings,
]);
} else {
this.pendingUpdate = { changedBindings: bindings, scheduled: false };
}
// If there's no pending update, schedule one only if this one isn't silent
// otherwise if this is silent, we'll just wait for the next non-silent update and make sure our bindings are included
if (!this.pendingUpdate.scheduled && !silent) {
this.pendingUpdate.scheduled = true;
queueMicrotask(() => {
const updates = this.pendingUpdate?.changedBindings;
this.pendingUpdate = undefined;
this.currentView?.update(updates);
});
}
}
private getViewForRef(viewRef: string): View | undefined {
// First look for a 1:1 viewRef -> id mapping (this is most common)
if (this.viewMap[viewRef]) {
return this.viewMap[viewRef];
}
// The view ids saved may also contain model refs, resolve those and try again
const matchingViewId = Object.keys(this.viewMap).find(
(possibleViewIdMatch) =>
viewRef ===
resolveDataRefsInString(possibleViewIdMatch, {
model: this.viewOptions.model,
evaluate: this.viewOptions.evaluator.evaluate,
}),
);
if (matchingViewId && this.viewMap[matchingViewId]) {
return this.viewMap[matchingViewId];
}
}
public onView(state: NavigationFlowViewState) {
const viewId = state.ref;
const source = this.hooks.resolveView.call(
this.getViewForRef(viewId),
viewId,
state,
);
if (!source) {
throw new Error(`No view with id ${viewId}`);
}
const view = new ViewInstance(source, this.viewOptions);
this.currentView = view;
// Give people a chance to attach their
// own listeners to the view before we resolve it
this.hooks.view.call(view);
view.update();
}
}