@ribajs/core
Version:
Core module of Riba.js
285 lines (253 loc) • 7.17 kB
text/typescript
import type {
Adapters,
Root,
Key,
ObserverSyncCallback,
Obj,
Options,
} from "./types/index.js";
import { isObject } from "@ribajs/utils/src/type.js";
export class Observer {
public static adapters: Adapters;
public static interfaces: string[] = [];
public static rootInterface: Root;
public static updateOptions(options: Partial<Options>): void {
if (!options.adapters) {
throw new Error("adapters are required!");
}
if (options.adapters) {
Observer.adapters = { ...Observer.adapters, ...options.adapters };
Observer.interfaces = Object.keys(Observer.adapters);
}
Observer.rootInterface = options.rootInterface || Observer.interfaces[0];
if (!Observer.rootInterface) {
throw new Error("rootInterface is required!");
}
}
/**
* Tokenize the provided keypath string into interface + path tokens for the
* observer to work with.
*/
public static tokenize(keypath: string, root: Root): any[] {
const tokens: any[] = [];
let current: Key = { i: root, path: "" };
let index: number;
let chr: string;
for (index = 0; index < keypath.length; index++) {
chr = keypath.charAt(index);
if (~Observer.interfaces.indexOf(chr)) {
tokens.push(current);
current = { i: chr, path: "" };
} else {
current.path += chr;
}
}
tokens.push(current);
return tokens;
}
/**
* Gets the provided key on an object.
* @param key
* @param obj
*/
public static get(key: Key, obj: Obj) {
return Observer.adapters[key.i].get(obj, key.path);
}
/**
* Observes or unobserve a callback on the object using the provided key.
* @param active
* @param key
* @param obj
* @param callback
*/
public static set(
active: boolean,
key: Key,
obj: Obj,
callback: ObserverSyncCallback,
) {
if (active) {
Observer.adapters[key.i].observe(obj, key.path, callback);
} else {
Observer.adapters[key.i].unobserve(obj, key.path, callback);
}
}
public keypath: string;
public callback: ObserverSyncCallback;
public objectPath: Obj[];
public obj: Obj;
public target: Obj;
public key: Key;
public tokens: Key[];
/**
* Constructs a new keypath observer and kicks things off.
* @param model
* @param keypath
* @param callback
*/
constructor(model: Obj, keypath: string, callback: ObserverSyncCallback) {
this.keypath = keypath;
this.callback = callback;
this.objectPath = [];
const parseResult = this.parse();
this.key = parseResult.key;
this.tokens = parseResult.tokens;
this.obj = this.getRootObject(model);
this.target = this.realize();
if (isObject(this.target)) {
Observer.set(true, this.key, this.target, this.callback);
}
}
/**
* Parses the keypath using the interfaces defined on the view. Sets variables
* for the tokenized keypath as well as the end key.
*/
public parse() {
let path: string;
let root: Root;
if (!Observer.interfaces || !Observer.interfaces.length) {
throw new Error(
`[Observer] Must define at least one adapter interface. interfaces: "${JSON.stringify(
Observer.interfaces,
)}" adapters: "${JSON.stringify(Observer.adapters)}"`,
);
}
if (~Observer.interfaces.indexOf(this.keypath[0])) {
root = this.keypath[0];
path = this.keypath.substr(1);
} else {
root = Observer.rootInterface;
path = this.keypath;
}
this.tokens = Observer.tokenize(path, root);
if (!this.tokens.length) {
throw new Error("[Observer] No tokens");
}
this.key = this.tokens.pop() as Key;
return {
key: this.key,
tokens: this.tokens,
};
}
/**
* Realizes the full keypath, attaching observers for every key and correcting
* old observers to any changed objects in the keypath.
*/
public realize() {
let current: Obj = this.obj;
let unreached = -1;
let prev;
let token;
for (let index = 0; index < this.tokens.length; index++) {
token = this.tokens[index];
if (isObject(current)) {
if (typeof this.objectPath[index] !== "undefined") {
prev = this.objectPath[index];
if (current !== prev) {
Observer.set(false, token, prev, this);
Observer.set(true, token, current, this);
this.objectPath[index] = current;
}
} else {
Observer.set(true, token, current, this);
this.objectPath[index] = current;
}
current = Observer.get(token, current);
} else {
if (unreached === -1) {
unreached = index;
}
prev = this.objectPath[index];
if (prev) {
Observer.set(false, token, prev, this);
}
}
}
if (unreached !== -1) {
this.objectPath.splice(unreached);
}
return current;
}
/**
* Updates the keypath. This is called when any intermediary key is changed.
*/
public sync() {
let oldValue;
let newValue;
const next = this.realize();
if (next !== this.target) {
if (isObject(this.target)) {
Observer.set(false, this.key, this.target, this.callback);
}
if (isObject(next)) {
Observer.set(true, this.key, next, this.callback);
}
oldValue = this.value();
this.target = next;
newValue = this.value();
if (newValue !== oldValue || Array.isArray(newValue)) {
this.callback.sync();
}
} else if (Array.isArray(next)) {
this.callback.sync();
}
}
/**
* Reads the current end value of the observed keypath. Returns undefined if
* the full keypath is unreachable.
*/
public value() {
if (isObject(this.target)) {
return Observer.get(this.key, this.target);
}
}
/**
* Sets the current end value of the observed keypath. Calling setValue when
* the full keypath is unreachable is a no-op.
* @param value
*/
public setValue(value: any) {
if (isObject(this.target)) {
Observer.adapters[this.key.i].set(this.target, this.key.path, value);
}
}
/**
* Unobserve the entire keypath.
*/
public unobserve() {
let obj: Obj;
let token;
for (let index = 0; index < this.tokens.length; index++) {
token = this.tokens[index];
obj = this.objectPath[index];
if (obj) {
Observer.set(false, token, obj, this);
}
}
if (isObject(this.target)) {
Observer.set(false, this.key, this.target, this.callback);
}
}
/**
* traverse the scope chain to find the scope which has the root property
* if the property is not found in chain, returns the root scope
* @param obj
*/
public getRootObject(obj: Obj) {
let rootProp;
let current;
if (!obj.$parent) {
return obj;
}
if (this.tokens.length) {
rootProp = this.tokens[0].path;
} else {
rootProp = this.key.path;
}
current = obj;
while (current.$parent && current[rootProp] === undefined) {
current = current.$parent;
}
return current;
}
}