UNPKG

@geogirafe/lib-geoportal

Version:

GeoGirafe is a flexible application to build online geoportals.

487 lines (486 loc) 20.2 kB
// SPDX-License-Identifier: Apache-2.0 import areEqual from './equality.js'; import { deepClone, deepFreeze, isConstructor, isFunction, isIgnoredProperty, isIterator, isPrimitive, isTypeSupported, isVirtualProperty } from './tools.js'; export default class Brain { proxyCount = 0; initialState; stateProxy; externalCallback; proxyIds = new WeakMap(); targetToProxy = new WeakMap(); proxyToTarget = new WeakMap(); proxyChildren = new WeakMap(); proxyParents = new WeakMap(); proxyToFullPaths = new WeakMap(); mergeMinimalPathsCache = new Map(); delayed = false; delayedCallbacks = new Map(); constructor(initialState, callback) { this.initialState = initialState; this.stateProxy = this.createProxy(this.initialState, ''); this.externalCallback = callback; } callback(path, oldValue, newValue, parents) { if (this.delayed) { // Remember all callback infos const infos = this.delayedCallbacks.get(path); if (infos) { // Just set the new value infos.newValue = newValue; } else { this.delayedCallbacks.set(path, { oldValue: oldValue, newValue: newValue, parents: parents }); } } else { // Immediate Callback this.externalCallback(path, oldValue, newValue, parents); } } /** * * Creates a proxy for the given target object * @param target The target object to create a proxy for * @param prop The property name of this target in its parent * @param parent The parent proxy (optional for root element) * @returns The created proxy */ createProxy(target, prop, parent) { const proxy = new Proxy(target, this.objectHandler()); this.proxyIds.set(proxy, ++this.proxyCount); this.targetToProxy.set(target, proxy); this.proxyToTarget.set(proxy, target); this.proxyChildren.set(proxy, new Map()); if (parent) { this.proxyParents.set(proxy, [parent]); this.proxyChildren.get(parent).set(prop, proxy); const paths = parent.__brainFullPaths.map((path) => this.getFullPath(path, prop)); this.proxyToFullPaths.set(proxy, paths); } else { // Root element this.proxyToFullPaths.set(proxy, ['']); } return proxy; } /** * Ensure we keep only minimal unique paths for a proxy. * And ensure that the selectd paths have the same prefix than the parents path * Example: * existing: ["group"] * candidates: ["group.children.0.parent"] * => keep only ["group"] * Other example if parentsPaths is defined: * existing: ["group.inner"] * candidates: ["group.inner", "group2.inner"] * parrents: ["group2"] * => keep only ["group2.inner"] (Because it is the only one still linked to the right parent) */ mergeMinimalPaths(proxyId, existingPaths, candidatePaths, parents) { const cacheKey = JSON.stringify({ proxyId, existingPaths, candidatePaths, parents: parents.map((p) => p.__brainId).join('|') }); if (this.mergeMinimalPathsCache.has(cacheKey)) { return this.mergeMinimalPathsCache.get(cacheKey); } const minimalPaths = new Set(); const parentsPaths = []; for (const p of parents) { parentsPaths.push(...p.__brainFullPaths); } for (const candidate of [...existingPaths, ...candidatePaths]) { // Already present if (minimalPaths.has(candidate)) { continue; } // If the candidate hat a minimal path as prefix if (Array.from(minimalPaths).some((path) => candidate.startsWith(`${path}.`))) { continue; } // If not prefixed by any parent if (!this.isRightParent(candidate, parentsPaths)) { continue; } minimalPaths.add(candidate); } const returnValue = Array.from(minimalPaths); this.mergeMinimalPathsCache.set(cacheKey, returnValue); return returnValue; } isRightParent(candidate, parentsPaths) { if (parentsPaths.length === 0 || parentsPaths[0].length === 0) { // On the root return true; } for (const parentPath of parentsPaths) { if (parentPath.includes(`${candidate}.`)) { // Circular reference return true; } if (candidate.startsWith(`${parentPath}.`)) { // Parent found return true; } } // Not a valid parent return false; } getOrCreateProxyForValue(proxy, prop, value, childPaths) { if (isPrimitive(value)) { // No proxy for primitives return undefined; } let valueProxy = this.targetToProxy.get(value); if (valueProxy) { // Proxy already exists // Link parent to child proxy.__brainChildren.set(prop, valueProxy); // Add parent if not already present const parents = valueProxy.__brainParents; if (!parents.includes(proxy)) { parents.push(proxy); } // Merge full paths (ensure minimal paths) const merged = this.mergeMinimalPaths(valueProxy.__brainId, valueProxy.__brainFullPaths, childPaths, valueProxy.__brainParents); const fullPaths = valueProxy.__brainFullPaths; if (merged.length !== fullPaths.length || merged.some((p, i) => p !== fullPaths[i])) { fullPaths.splice(0, fullPaths.length, ...merged); this.updateChildPathsRecursively(valueProxy, fullPaths); } } else { // Create a new proxy valueProxy = this.createProxy(value, prop, proxy); const minimal = this.mergeMinimalPaths(valueProxy.__brainId, [], childPaths, valueProxy.__brainParents); this.proxyToFullPaths.set(valueProxy, minimal); this.updateChildPathsRecursively(valueProxy, minimal); } return valueProxy; } cleanProxyForValue(proxy, prop, value, childPaths) { if (isPrimitive(value)) { // Nothing to clean for primitives return; } const valueProxy = this.targetToProxy.get(value); if (!valueProxy) { // The value was never proxied => Nothing to clean return; } // Remove the child link for this property proxy.__brainChildren.delete(prop); // Remove only the paths that were coming from this proxy const fullPaths = valueProxy.__brainFullPaths; for (const childPath of childPaths) { const index = fullPaths.indexOf(childPath); if (index >= 0) { fullPaths.splice(index, 1); } } // Check if this proxy is still a parent of the child // We only remove the parent if NO other child at this level points to it. const parents = valueProxy.__brainParents; const stillReferenced = Array.from(proxy.__brainChildren.values()).includes(valueProxy); if (!stillReferenced) { const index = parents.indexOf(proxy); if (index >= 0) { parents.splice(index, 1); } } // Recursively update child paths for consistency this.updateChildPathsRecursively(valueProxy, valueProxy.__brainFullPaths); } recalculateChildrenForArray(proxy, target, oldValue) { if (!Array.isArray(target)) { throw new TypeError('This method is only for arrays'); } // Clean previous childs proxy.__brainChildren.clear(); for (let i = 0; i < target.length; ++i) { const childValue = target[i]; const childPaths = proxy.__brainFullPaths.map((path) => this.getFullPath(path, i.toString())); const childOldValue = oldValue.find((oldChild) => areEqual(oldChild, childValue)); if (childOldValue) { const oldChildIndex = oldValue.indexOf(childOldValue); const oldChildPaths = proxy.__brainFullPaths.map((path) => this.getFullPath(path, oldChildIndex)); this.cleanProxyForValue(proxy, i.toString(), childValue, oldChildPaths); } const childProxy = this.getOrCreateProxyForValue(proxy, i.toString(), childValue, childPaths); if (childProxy) { proxy.__brainChildren.set(i.toString(), childProxy); } if (oldValue && oldValue.length > target.length) { for (let i = target.length; i < oldValue.length; i++) { const childPaths = proxy.__brainFullPaths.map((path) => this.getFullPath(path, i.toString())); this.cleanProxyForValue(proxy, i.toString(), oldValue[i], childPaths); } } for (const [key, childProxy] of proxy.__brainChildren.entries()) { const childPaths = proxy.__brainFullPaths.map((path) => this.getFullPath(path, key)); this.proxyToFullPaths.set(childProxy, childPaths); this.updateChildPathsRecursively(childProxy, childPaths); } } } updateChildPathsRecursively(proxy, parentPaths, visited = new Set()) { if (visited.has(proxy)) { return; } visited.add(proxy); for (const [prop, childProxy] of proxy.__brainChildren.entries()) { let fullPaths = this.proxyToFullPaths.get(childProxy); if (!fullPaths) { fullPaths = []; this.proxyToFullPaths.set(childProxy, fullPaths); } const candidatePaths = parentPaths.map((path) => this.getFullPath(path, prop)); const merged = this.mergeMinimalPaths(childProxy.__brainId, fullPaths, candidatePaths, childProxy.__brainParents); const changed = merged.length !== fullPaths.length || merged.some((p, i) => p !== fullPaths[i]); if (changed) { fullPaths.splice(0, fullPaths.length, ...merged); this.updateChildPathsRecursively(childProxy, fullPaths, visited); } } } handleGetVirtualProperties(proxy, target, prop) { switch (prop) { case '__brainIsProxy': return true; case '__brainId': return this.proxyIds.get(proxy); case '__brainTarget': return target; case '__brainParents': return this.proxyParents.get(proxy); case '__brainChildren': return this.proxyChildren.get(proxy); case '__brainFullPaths': return this.proxyToFullPaths.get(proxy) ?? []; } throw new Error(`Unknown virtual property: ${prop}`); } handleGetIgnored(target, prop) { const value = target[prop]; if (isIgnoredProperty(target, prop)) { return value; } throw new Error(`Unknown ignored property: ${prop}`); } handleGetFunctions(proxy, target, func) { return (...args) => { // args can contain a callback for func likes filter, map, reduce, forEach, ... if (args.length === 1 && isFunction(args[0])) { const originalCallback = args[0]; args[0] = (...cbArgs) => { // We want to be able to use proxies in the callback cbArgs = cbArgs.map((arg) => this.targetToProxy.get(arg) ?? arg); return originalCallback(...cbArgs); }; } else { // If args contains some proxies, we replace them with the target objects args = args.map((arg) => (arg.__brainIsProxy ? arg.__brainTarget : arg)); } const oldValue = deepClone(target); deepFreeze(oldValue); let result = target[func](...args); if (result instanceof Promise) { throw new Error('Promises are not supported yet.'); } if (Array.isArray(result)) { // Get proxied results result = result.map((r) => this.targetToProxy.get(r) ?? r); } else if (isIterator(result)) { // Iterator const originalIterator = result; result = { [Symbol.iterator]() { return { next: () => { const nextVal = originalIterator.next(); if (nextVal.done) { return nextVal; } return { done: false, // Get proxied results value: this.targetToProxy.get(nextVal.value) ?? nextVal.value }; } }; } }; } else { // Unique result result = this.targetToProxy.get(result) ?? result; } if (!areEqual(oldValue, target)) { if (Array.isArray(target)) { // The array has changed. We have to recalculate the children and paths for the elements this.recalculateChildrenForArray(proxy, target, oldValue); } for (const path of proxy.__brainFullPaths) { this.callback(path, oldValue, proxy, proxy.__brainParents); } } return result; }; } getHandler(proxy, target, prop) { if (isIgnoredProperty(target, prop)) { return this.handleGetIgnored(target, prop); } if (isVirtualProperty(prop)) { return this.handleGetVirtualProperties(proxy, target, prop); } if (isConstructor(prop)) { return target.constructor; } let value = target[prop]; if (isPrimitive(value)) { // No proxy for primitives return value; } if (value.__brainIsProxy) { // Proxy already exist. // This can happen when a proxy was added to a not proxied object // And then if the not proxied object is added to the state. // In this case, brain should not recreate a proxy but simply reuse it. value = value.__brainTarget; } if (!isTypeSupported(value)) { throw new Error(`The type of the property ${prop} is not supported.`); } if (isFunction(value)) { if (Array.isArray(target)) { // Create proxies for all children for (const i in target) { const childPaths = proxy.__brainFullPaths.map((path) => this.getFullPath(path, i)); this.getOrCreateProxyForValue(proxy, i, target[i], childPaths); } } return this.handleGetFunctions(proxy, target, prop); } const childPaths = proxy.__brainFullPaths.map((path) => this.getFullPath(path, prop)); const childProxy = this.getOrCreateProxyForValue(proxy, prop, value, childPaths); return childProxy; } setHandler(proxy, target, prop, newValue) { try { let newValueProxy; if (newValue?.__brainIsProxy) { newValueProxy = newValue; newValue = newValueProxy.__brainTarget; } if (isFunction(newValue)) { // For functions, just set the property, there is nothing else to do. target[prop] = newValue; return true; } // Clean proxies let oldValue = target[prop]; const childPaths = proxy.__brainFullPaths.map((path) => this.getFullPath(path, prop)); this.cleanProxyForValue(proxy, prop, oldValue, childPaths); if (isIgnoredProperty(target, prop)) { // Ignored => No callback and no clone of the old version // Just set the new value target[prop] = newValue; return true; } // Create frozen clone as oldValue oldValue = deepClone(oldValue); deepFreeze(oldValue); target[prop] = newValue; if (!isTypeSupported(newValue)) { throw new Error(`The type of the property ${prop} is not supported.`); } if (areEqual(oldValue, newValue)) { // No change => No callback return true; } // Call callbacks newValueProxy = this.getOrCreateProxyForValue(proxy, prop, newValue, childPaths); for (const childPath of childPaths) { this.callback(childPath, oldValue, newValueProxy ?? newValue, [proxy]); } return true; } catch (error) { console.error(`Cannot set ${prop} on ${target} : ${error}`); return false; } } setArrayHandler(proxy, target, prop, newValue) { try { if (!Array.isArray(target)) { throw new Error('This method should only be called on arrays'); } if (newValue?.__brainIsProxy) { newValue = newValue.__brainTarget; } if (isFunction(Reflect.get(target, prop)) || isFunction(newValue)) { throw new Error('Setting functions is not supported.'); } const oldTarget = deepClone(target); deepFreeze(oldTarget); Reflect.set(target, prop, newValue); if (areEqual(oldTarget, target)) { // No change => No callback return true; } // Call callbacks const childPaths = proxy.__brainFullPaths.map((path) => this.getFullPath(path, prop)); this.getOrCreateProxyForValue(proxy, prop, newValue, childPaths); for (const childPath of proxy.__brainFullPaths) { this.callback(childPath, oldTarget, proxy, proxy.__brainParents); } return true; } catch (error) { console.error(`Cannot set ${prop} on ${target} : ${error}`); return false; } } getFullPath(parentPath, prop) { if (parentPath.trim().length > 0) { return `${parentPath}.${prop}`; } return prop; } objectHandler = () => ({ get: (target, prop, _receiver) => { const proxy = this.targetToProxy.get(target); return this.getHandler(proxy, target, prop); }, set: (target, prop, newValue, _receiver) => { const proxy = this.targetToProxy.get(target); if (Array.isArray(target)) { return this.setArrayHandler(proxy, target, prop, newValue); } return this.setHandler(proxy, target, prop, newValue); } }); delay(statechanges) { this.delayed = true; try { // Execute state changes statechanges(this.stateProxy); } finally { this.delayed = false; // Call all callbacks this.delayedCallbacks.forEach((infos, key) => { this.externalCallback(key, infos.oldValue, infos.newValue, infos.parents); }); this.delayedCallbacks.clear(); } } getState() { return this.stateProxy; } }