@player-ui/player 0.0.1-next.1

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.
@@ -0,0 +1,147 @@
1
+ import type { Node } from '@player-ui/view';
2
+ import { NodeType } from '@player-ui/view';
3
+ import { LocalStateStore } from './store';
4
+ import type { TransformRegistry } from './types';
5
+ import type { ViewController } from './controller';
6
+ import { PlayerPlugin } from '..';
7
+
8
+ /** Traverse up the nodes until the target is found */
9
+ function findUp(node: Node.Node, target: Node.Node): boolean {
10
+ if (node === target) {
11
+ return true;
12
+ }
13
+
14
+ if (node.parent) {
15
+ return findUp(node.parent, target);
16
+ }
17
+
18
+ return false;
19
+ }
20
+
21
+ /**
22
+ * A plugin to register custom transforms on certain asset types
23
+ * This allows users to embed stateful data into transforms.
24
+ */
25
+ export class AssetTransformCorePlugin {
26
+ public readonly stateStore: Map<Node.Node, LocalStateStore>;
27
+ private readonly registry: TransformRegistry;
28
+ private beforeResolveSymbol: symbol;
29
+ private resolveSymbol: symbol;
30
+ private beforeResolveCountSymbol: symbol;
31
+ private resolveCountSymbol: symbol;
32
+
33
+ constructor(registry: TransformRegistry) {
34
+ this.registry = registry;
35
+ this.stateStore = new Map();
36
+ this.beforeResolveSymbol = Symbol('before resolve');
37
+ this.resolveSymbol = Symbol('resolve');
38
+ this.beforeResolveCountSymbol = Symbol('before resolve count');
39
+ this.resolveCountSymbol = Symbol('resolve count');
40
+ }
41
+
42
+ apply(viewController: ViewController) {
43
+ viewController.hooks.view.tap('asset-transform', (view) => {
44
+ // Clear out everything when we create a new view
45
+ this.stateStore.clear();
46
+
47
+ view.hooks.resolver.tap('asset-transform', (resolver) => {
48
+ let lastUpdatedNode: Node.Node | undefined;
49
+
50
+ /** A function to update the state and trigger a view re-compute */
51
+ const updateState = (node: Node.Node) => {
52
+ lastUpdatedNode = node;
53
+ view.update(new Set());
54
+ };
55
+
56
+ /** Given a node and a transform step, fetch a local store */
57
+ const getStore = (node: Node.Node, stepKey: symbol) => {
58
+ let store: LocalStateStore;
59
+ const countKey =
60
+ stepKey === this.resolveSymbol
61
+ ? this.resolveCountSymbol
62
+ : this.beforeResolveCountSymbol;
63
+
64
+ const storedState = this.stateStore.get(node);
65
+
66
+ if (storedState) {
67
+ store = storedState;
68
+ store.removeKey(countKey);
69
+ } else {
70
+ store = new LocalStateStore(() => {
71
+ updateState(node);
72
+ });
73
+ this.stateStore.set(node, store);
74
+ }
75
+
76
+ return {
77
+ useSharedState: (
78
+ key: string | symbol
79
+ ): (<T>(initialState: T) => readonly [T, (value: T) => void]) => {
80
+ return store.useSharedState(key);
81
+ },
82
+ useLocalState: <T>(initialState: T) => {
83
+ return store.getLocalStateFunction<T>(
84
+ stepKey,
85
+ countKey
86
+ )(initialState);
87
+ },
88
+ };
89
+ };
90
+
91
+ resolver.hooks.beforeResolve.tap('asset-transform', (node, options) => {
92
+ if (node && (node.type === 'asset' || node.type === 'view')) {
93
+ const transform = this.registry.get(node.value);
94
+
95
+ if (transform?.beforeResolve) {
96
+ const store = getStore(node, this.beforeResolveSymbol);
97
+
98
+ return transform.beforeResolve(node, options, store);
99
+ }
100
+ }
101
+
102
+ return node;
103
+ });
104
+
105
+ resolver.hooks.afterUpdate.tap('asset-transform', () => {
106
+ lastUpdatedNode = undefined;
107
+ });
108
+
109
+ resolver.hooks.skipResolve.tap('asset-transform', (skip, node) => {
110
+ if (!skip || !lastUpdatedNode) {
111
+ return skip;
112
+ }
113
+
114
+ const isParentOfUpdated = findUp(lastUpdatedNode, node);
115
+ const isChildOfUpdated = findUp(node, lastUpdatedNode);
116
+
117
+ return !isParentOfUpdated && !isChildOfUpdated;
118
+ });
119
+
120
+ resolver.hooks.afterResolve.tap(
121
+ 'asset-transform',
122
+ (value, node, options) => {
123
+ if (node.type !== NodeType.Asset && node.type !== NodeType.View) {
124
+ return value;
125
+ }
126
+
127
+ const originalNode = resolver.getSourceNode(node);
128
+
129
+ if (!originalNode) {
130
+ return value;
131
+ }
132
+
133
+ const transform = this.registry.get(value);
134
+
135
+ if (transform?.resolve) {
136
+ const store = getStore(originalNode, this.resolveSymbol);
137
+
138
+ return transform?.resolve(value, options, store);
139
+ }
140
+
141
+ return value;
142
+ }
143
+ );
144
+ });
145
+ });
146
+ }
147
+ }
@@ -0,0 +1,148 @@
1
+ import { SyncHook, SyncWaterfallHook } from 'tapable';
2
+ import type { Resolve } from '@player-ui/view';
3
+ import { ViewInstance } from '@player-ui/view';
4
+ import type { Logger } from '@player-ui/logger';
5
+ import type { FlowInstance, FlowController } from '@player-ui/flow';
6
+ import type { View, NavigationFlowViewState } from '@player-ui/types';
7
+ import { resolveDataRefsInString } from '@player-ui/string-resolver';
8
+ import { Registry } from '@player-ui/partial-match-registry';
9
+ import queueMicrotask from 'queue-microtask';
10
+ import type { DataController } from '../data';
11
+ import { AssetTransformCorePlugin } from './asset-transform';
12
+ import type { TransformRegistry } from './types';
13
+ import type { BindingInstance } from '..';
14
+
15
+ export interface ViewControllerOptions {
16
+ /** Where to get data from */
17
+ model: DataController;
18
+
19
+ /** Where to log data */
20
+ logger?: Logger;
21
+
22
+ /** A flow-controller instance to listen for view changes */
23
+ flowController: FlowController;
24
+ }
25
+
26
+ /** A controller to manage updating/switching views */
27
+ export class ViewController {
28
+ public readonly hooks = {
29
+ /** Do any processing before the `View` instance is created */
30
+ resolveView: new SyncWaterfallHook<View, string, NavigationFlowViewState>([
31
+ 'view',
32
+ 'viewRef',
33
+ 'viewState',
34
+ ]),
35
+
36
+ // The hook right before the View starts resolving. Attach anything custom here
37
+ view: new SyncHook<ViewInstance>(['view']),
38
+ };
39
+
40
+ private readonly viewMap: Record<string, View>;
41
+ private readonly viewOptions: Resolve.ResolverOptions & ViewControllerOptions;
42
+ private pendingUpdate?: {
43
+ /** pending data binding changes */
44
+ changedBindings?: Set<BindingInstance>;
45
+ };
46
+
47
+ public currentView?: ViewInstance;
48
+ public transformRegistry: TransformRegistry = new Registry();
49
+ public optimizeUpdates = true;
50
+
51
+ constructor(
52
+ initialViews: View[],
53
+ options: Resolve.ResolverOptions & ViewControllerOptions
54
+ ) {
55
+ this.viewOptions = options;
56
+ this.viewMap = initialViews.reduce<Record<string, View>>(
57
+ (viewMap, view) => ({
58
+ ...viewMap,
59
+ [view.id]: view,
60
+ }),
61
+ {}
62
+ );
63
+
64
+ new AssetTransformCorePlugin(this.transformRegistry).apply(this);
65
+
66
+ options.flowController.hooks.flow.tap(
67
+ 'viewController',
68
+ (flow: FlowInstance) => {
69
+ flow.hooks.transition.tap('viewController', (_oldState, newState) => {
70
+ if (newState.value.state_type === 'VIEW') {
71
+ this.onView(newState.value);
72
+ } else {
73
+ this.currentView = undefined;
74
+ }
75
+ });
76
+ }
77
+ );
78
+
79
+ options.model.hooks.onUpdate.tap('viewController', (updates) => {
80
+ if (this.currentView) {
81
+ if (this.optimizeUpdates) {
82
+ this.queueUpdate(new Set(updates.map((t) => t.binding)));
83
+ } else {
84
+ this.currentView.update();
85
+ }
86
+ }
87
+ });
88
+ }
89
+
90
+ private queueUpdate(bindings: Set<BindingInstance>) {
91
+ if (this.pendingUpdate?.changedBindings) {
92
+ this.pendingUpdate.changedBindings = new Set([
93
+ ...this.pendingUpdate.changedBindings,
94
+ ...bindings,
95
+ ]);
96
+ } else {
97
+ this.pendingUpdate = { changedBindings: bindings };
98
+ queueMicrotask(() => {
99
+ const updates = this.pendingUpdate?.changedBindings;
100
+ this.pendingUpdate = undefined;
101
+ this.currentView?.update(updates);
102
+ });
103
+ }
104
+ }
105
+
106
+ private getViewForRef(viewRef: string): View | undefined {
107
+ // First look for a 1:1 viewRef -> id mapping (this is most common)
108
+ if (this.viewMap[viewRef]) {
109
+ return this.viewMap[viewRef];
110
+ }
111
+
112
+ // The view ids saved may also contain model refs, resolve those and try again
113
+ const matchingViewId = Object.keys(this.viewMap).find(
114
+ (possibleViewIdMatch) =>
115
+ viewRef ===
116
+ resolveDataRefsInString(possibleViewIdMatch, {
117
+ model: this.viewOptions.model,
118
+ evaluate: this.viewOptions.evaluator.evaluate,
119
+ })
120
+ );
121
+
122
+ if (matchingViewId && this.viewMap[matchingViewId]) {
123
+ return this.viewMap[matchingViewId];
124
+ }
125
+ }
126
+
127
+ public onView(state: NavigationFlowViewState) {
128
+ const viewId = state.ref;
129
+
130
+ const source = this.hooks.resolveView.call(
131
+ this.getViewForRef(viewId),
132
+ viewId,
133
+ state
134
+ );
135
+
136
+ if (!source) {
137
+ throw new Error(`No view with id ${viewId}`);
138
+ }
139
+
140
+ const view = new ViewInstance(source, this.viewOptions);
141
+ this.currentView = view;
142
+
143
+ // Give people a chance to attach their
144
+ // own listeners to the view before we resolve it
145
+ this.hooks.view.call(view);
146
+ view.update();
147
+ }
148
+ }
@@ -0,0 +1,4 @@
1
+ export * from './asset-transform';
2
+ export * from './controller';
3
+ export * from './store';
4
+ export * from './types';
@@ -0,0 +1,94 @@
1
+ export interface Store {
2
+ useLocalState<T>(initialState: T): readonly [T, (value: T) => void];
3
+ useSharedState<T>(
4
+ key: string | symbol
5
+ ): (initialState: T) => readonly [T, (value: T) => void];
6
+ }
7
+
8
+ interface SharedStore {
9
+ getLocalStateFunction<T>(
10
+ key: string | symbol,
11
+ countKey: symbol
12
+ ): (initialState: T) => readonly [T, (value: T) => void];
13
+ useSharedState<T>(
14
+ key: string | symbol
15
+ ): (initialState: T) => readonly [T, (value: T) => void];
16
+ }
17
+
18
+ /** A store that holds on to state for a transform */
19
+ export class LocalStateStore implements SharedStore {
20
+ private state: Map<string | symbol, any>;
21
+
22
+ private updateCallback?: () => void;
23
+
24
+ constructor(onUpdate?: () => void) {
25
+ this.updateCallback = onUpdate;
26
+
27
+ this.state = new Map();
28
+ }
29
+
30
+ public removeKey(key: symbol | string) {
31
+ this.state.delete(key);
32
+ }
33
+
34
+ public reset() {
35
+ this.state.clear();
36
+ }
37
+
38
+ useSharedState<T>(key: string | symbol) {
39
+ return (initialState: T) => {
40
+ if (!this.state.has(key)) {
41
+ this.state.set(key, initialState);
42
+ }
43
+
44
+ return [
45
+ this.state.get(key) as T,
46
+ (newState: T) => {
47
+ const current = this.state.get(key) as T;
48
+
49
+ this.state.set(key, newState);
50
+
51
+ if (current !== newState) {
52
+ this.updateCallback?.();
53
+ }
54
+ },
55
+ ] as const;
56
+ };
57
+ }
58
+
59
+ getLocalStateFunction<T>(key: symbol, countKey: symbol) {
60
+ return (initialState: T) => {
61
+ // initialize if not already created
62
+ if (!this.state.has(key)) {
63
+ this.state.set(key, []);
64
+ }
65
+
66
+ if (!this.state.has(countKey)) {
67
+ this.state.set(countKey, 0);
68
+ }
69
+
70
+ const localState = this.state.get(key);
71
+ const oldCount = this.state.get(countKey);
72
+
73
+ this.state.set(countKey, oldCount + 1);
74
+
75
+ if (localState.length <= oldCount) {
76
+ localState.push(initialState);
77
+ }
78
+
79
+ const value = localState[oldCount] as T;
80
+
81
+ return [
82
+ value,
83
+ (newState: T) => {
84
+ const oldValue = localState[oldCount] as T;
85
+ localState[oldCount] = newState;
86
+
87
+ if (oldValue !== newState) {
88
+ this.updateCallback?.();
89
+ }
90
+ },
91
+ ] as const;
92
+ };
93
+ }
94
+ }
@@ -0,0 +1,31 @@
1
+ import type { Asset } from '@player-ui/types';
2
+ import type { Resolve, Node } from '@player-ui/view';
3
+ import type { Registry } from '@player-ui/partial-match-registry';
4
+ import type { Store } from './store';
5
+
6
+ /** Transform function that is ran on the Asset before it's resolved */
7
+ export type BeforeTransformFunction<AuthoredAsset extends Asset = Asset> = (
8
+ asset: Node.Asset<AuthoredAsset> | Node.View<AuthoredAsset>,
9
+ options: Resolve.NodeResolveOptions,
10
+ store: Store
11
+ ) => Node.Node;
12
+
13
+ /** Transform function that is ran on the Asset after it's resolved */
14
+ export type TransformFunction<
15
+ AuthoredAsset extends Asset = Asset,
16
+ TransformedAsset extends Asset = AuthoredAsset
17
+ > = (
18
+ asset: AuthoredAsset,
19
+ options: Resolve.NodeResolveOptions,
20
+ store: Store
21
+ ) => TransformedAsset;
22
+
23
+ export interface TransformFunctions {
24
+ /** A function that is executed as an AST -> AST transform before resolving the node to a value */
25
+ beforeResolve?: BeforeTransformFunction<any>;
26
+
27
+ /** A function to resolve an AST to a value */
28
+ resolve?: TransformFunction<any>;
29
+ }
30
+
31
+ export type TransformRegistry = Registry<TransformFunctions>;