UNPKG

@playcanvas/observer

Version:

Generic implementation of the observer pattern

1,427 lines (1,421 loc) 68.4 kB
/** * EventHandle manages the binding and unbinding of event listeners. It provides a convenient way * to add, remove, and invoke event handlers associated with specific event names. Each EventHandle * is linked to an 'owner' object, typically an instance of the Events class, allowing for elegant * event management and chaining. */ class EventHandle { /** * Creates an instance of EventHandle. * * @param owner - Owner * @param name - Name * @param fn - Callback function */ constructor(owner, name, fn) { this.owner = owner; this.name = name; this.fn = fn; } /** * Unbinds the event handle from the owner, effectively removing the event listener. After * calling this method, the event handle will no longer trigger the callback function when the * event is emitted. */ unbind() { if (!this.owner) { return; } this.owner.unbind(this.name, this.fn); this.owner = null; this.name = null; this.fn = null; } /** * Invokes the callback function associated with the event handle. This method directly * triggers the event's callback without the event being emitted by the event system. */ call(_events, ..._args) { if (!this.fn) { return; } this.fn.call(this.owner, arguments[0], arguments[1], arguments[2], arguments[3], arguments[4], arguments[5], arguments[6], arguments[7]); } /** * Registers a new event listener on the same owner as the EventHandle. This method allows * chaining additional event listeners to the owner of this event handle. * * @param name - Name * @param fn - Callback function * @returns EventHandle */ on(name, fn) { return this.owner.on(name, fn); } } /** * Base class for event handling, providing mechanisms to register, emit, and unbind events. This * class supports adding event listeners, emitting events with up to 8 arguments, and managing * multiple emitters. * * @example * // Create an instance of the Events class * const events = new Events(); * * // Register an event listener * events.on('testEvent', (arg1, arg2) => { * console.log('Event triggered with arguments:', arg1, arg2); * }); * * // Emit the event * events.emit('testEvent', 'value1', 'value2'); * * // Unbind the event listener * events.unbind('testEvent'); */ class Events { /** * Creates a new Events instance. */ constructor() { // Make internal properties non-enumerable so they don't get serialized // when the object is converted to JSON (e.g., for ShareDB sync) const props = [ ['_additionalEmitters', []], ['_events', {}], ['_suspendEvents', false] ]; for (const [name, value] of props) { Object.defineProperty(this, name, { enumerable: false, writable: true, value }); } } /** * Sets whether events are suspended. If true, the observer will not emit events when values * are set. */ set suspendEvents(value) { this._suspendEvents = !!value; } /** * Gets whether events are suspended. */ get suspendEvents() { return this._suspendEvents; } /** * Registers an event listener for the specified event name. If the event is emitted, * the callback function is executed with up to 8 arguments. * * @param name - The name of the event to listen for. * @param fn - The callback function to be executed when the event is emitted. * @returns An EventHandle object that can be used to unbind the event listener. * * @example * // Register an event listener * events.on('testEvent', (arg1, arg2) => { * console.log('Event triggered with arguments:', arg1, arg2); * }); * * // Emit the event * events.emit('testEvent', 'value1', 'value2'); */ on(name, fn) { const events = this._events[name]; if (events === undefined) { this._events[name] = [fn]; } else { if (events.indexOf(fn) === -1) { events.push(fn); } } return new EventHandle(this, name, fn); } /** * Registers a one-time event listener for the specified event name. The callback function is * executed the next time the event is emitted, and then automatically unbound. * * @param name - The name of the event to listen for. * @param fn - The callback function to be executed once when the event is emitted. * @returns An EventHandle object that can be used to unbind the event listener * before it is triggered. * * @example * // Register a one-time event listener * events.once('testEvent', (arg1, arg2) => { * console.log('Event triggered once with arguments:', arg1, arg2); * }); * * // Emit the event * events.emit('testEvent', 'value1', 'value2'); // The callback will be called and then unbound. * * // Emit the event again * events.emit('testEvent', 'value1', 'value2'); // The callback will not be called this time. */ once(name, fn) { const evt = this.on(name, (arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7) => { fn.call(this, arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7); evt.unbind(); }); return evt; } /** * Emits the specified event, executing all registered listeners for that event with the * provided arguments. If events are suspended, the emit operation will be ignored. * * @param name - The name of the event to emit. * @param arg0 - The first argument to pass to the event listeners. * @param arg1 - The second argument to pass to the event listeners. * @param arg2 - The third argument to pass to the event listeners. * @param arg3 - The fourth argument to pass to the event listeners. * @param arg4 - The fifth argument to pass to the event listeners. * @param arg5 - The sixth argument to pass to the event listeners. * @param arg6 - The seventh argument to pass to the event listeners. * @param arg7 - The eighth argument to pass to the event listeners. * @returns The current instance for chaining. * * @example * // Register an event listener * events.on('testEvent', (arg1, arg2) => { * console.log('Event triggered with arguments:', arg1, arg2); * }); * * // Emit the event * events.emit('testEvent', 'value1', 'value2'); * * // Emit the event with more arguments * events.emit('testEvent', 'value1', 'value2', 'value3', 'value4'); */ emit(name, arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7) { if (this._suspendEvents) return this; let events = this._events[name]; if (events && events.length) { events = events.slice(0); for (let i = 0; i < events.length; i++) { if (!events[i]) { continue; } try { events[i].call(this, arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7); } catch (ex) { console.info('%c%s %c(event error)', 'color: #06f', name, 'color: #f00'); console.log(ex.stack); } } } if (this._additionalEmitters.length) { const emitters = this._additionalEmitters.slice(); emitters.forEach((emitter) => { emitter.emit(name, arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7); }); } return this; } /** * Unbinds an event listener for the specified event name. If a callback function is provided, * only that specific listener is removed. If no callback is provided, all listeners for the * event are removed. If no event name is provided, all listeners for all events are removed. * * @param name - The name of the event to unbind. If not provided, all events are * unbound. * @param fn - The specific callback function to remove. If not provided, all * listeners for the event are removed. * @returns The current instance for chaining. * * @example * // Register an event listener * const callback = (arg1, arg2) => { * console.log('Event triggered with arguments:', arg1, arg2); * }; * events.on('testEvent', callback); * * // Unbind the specific event listener * events.unbind('testEvent', callback); * * // Unbind all listeners for a specific event * events.unbind('testEvent'); * * // Unbind all listeners for all events * events.unbind(); */ unbind(name, fn) { if (name) { const events = this._events[name]; if (!events) { return this; } if (fn) { const i = events.indexOf(fn); if (i !== -1) { if (events.length === 1) { delete this._events[name]; } else { events.splice(i, 1); } } } else { delete this._events[name]; } } else { this._events = {}; } return this; } /** * Adds another emitter. Any events fired by this instance will also be fired on the additional * emitter. * * @param emitter - The emitter */ addEmitter(emitter) { if (!this._additionalEmitters.includes(emitter)) { this._additionalEmitters.push(emitter); } } /** * Removes emitter. * * @param emitter - The emitter */ removeEmitter(emitter) { const idx = this._additionalEmitters.indexOf(emitter); if (idx !== -1) { this._additionalEmitters.splice(idx, 1); } } } function __awaiter(thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); } typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) { var e = new Error(message); return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e; }; /** * Manages history actions for undo/redo operations. This class keeps track of actions that can be * undone and redone, allowing for complex state management in applications such as editors, games, * or any interactive applications where state changes need to be reversible. * * @example * const history = new History(); * * // Define an action * const action = { * name: 'draw', * undo: () => { console.log('Undo draw'); }, * redo: () => { console.log('Redo draw'); } * }; * * // Add the action to history * history.add(action); * * // Perform undo * history.undo(); * * // Perform redo * history.redo(); */ class History extends Events { constructor() { super(...arguments); this._executing = 0; this._actions = []; this._currentActionIndex = -1; this._canUndo = false; this._canRedo = false; } /** * Adds a new history action to the stack. If the action has a combine flag and matches the * current action's name, the redo function of the current action is updated. If actions have * been undone before adding this new action, it removes all actions that come after the * current action to maintain a consistent history. * * @param action - The action to add. * @returns Returns `true` if the action is successfully added, `false` otherwise. */ add(action) { if (!action.name) { console.error('Trying to add history action without name'); return false; } if (!action.undo) { console.error('Trying to add history action without undo method', action.name); return false; } if (!action.redo) { console.error('Trying to add history action without redo method', action.name); return false; } // If an action is added after some actions have been undone, remove all actions that come // after the current action to ensure the history is consistent. if (this._currentActionIndex !== this._actions.length - 1) { this._actions = this._actions.slice(0, this._currentActionIndex + 1); } // If the combine flag is true and the current action has the same name, replace the redo // function of the current action with the new action's redo function. if (action.combine && this.currentAction && this.currentAction.name === action.name) { this.currentAction.redo = action.redo; } else { const length = this._actions.push(action); this._currentActionIndex = length - 1; } this.emit('add', action.name); this.canUndo = true; this.canRedo = false; return true; } /** * Adds a new history action and immediately executes its redo function. * * @param action - The action. * @returns A promise that resolves once the redo function has been executed. */ addAndExecute(action) { return __awaiter(this, void 0, void 0, function* () { if (this.add(action)) { // execute an action - don't allow history actions till it finishes try { this.executing++; yield action.redo(); } finally { this.executing--; } } }); } /** * Undoes the last history action. This method retrieves the current action from the history * stack and executes the action's undo function. * * @returns A promise that resolves once the undo function has been executed. */ undo() { return __awaiter(this, void 0, void 0, function* () { if (!this.canUndo) { return; } const name = this.currentAction.name; const undo = this.currentAction.undo; this._currentActionIndex--; this.emit('undo', name); if (this._currentActionIndex < 0) { this.canUndo = false; } this.canRedo = true; // execute an undo action - don't allow history actions until it finishes try { this.executing++; yield undo(); } catch (ex) { console.info('%c(History#undo)', 'color: #f00'); console.log(ex.stack); } finally { this.executing--; } }); } /** * Redoes the next history action. This retrieves the next action from the history stack and * executes the action's redo function. * * @returns A promise that resolves once the redo function has been executed. */ redo() { return __awaiter(this, void 0, void 0, function* () { if (!this.canRedo) { return; } this._currentActionIndex++; const redo = this.currentAction.redo; this.emit('redo', this.currentAction.name); this.canUndo = true; if (this._currentActionIndex === this._actions.length - 1) { this.canRedo = false; } // execute redo action - don't allow history actions till it finishes try { this.executing++; yield redo(); } catch (ex) { console.info('%c(History#redo)', 'color: #f00'); console.log(ex.stack); } finally { this.executing--; } }); } /** * Clears all history actions. */ clear() { if (!this._actions.length) return; this._actions.length = 0; this._currentActionIndex = -1; this.canUndo = false; this.canRedo = false; } /** * The current history action. */ get currentAction() { return this._actions[this._currentActionIndex] || null; } /** * The last action committed to the history. */ get lastAction() { return this._actions[this._actions.length - 1] || null; } /** * Sets whether we can undo at this time. */ set canUndo(value) { if (this._canUndo === value) return; this._canUndo = value; if (!this.executing) { this.emit('canUndo', value); } } /** * Gets whether we can undo at this time. */ get canUndo() { return this._canUndo && !this.executing; } /** * Sets whether we can redo at this time. */ set canRedo(value) { if (this._canRedo === value) return; this._canRedo = value; if (!this.executing) { this.emit('canRedo', value); } } /** * Gets whether we can redo at this time. */ get canRedo() { return this._canRedo && !this.executing; } /** * Sets the number of async actions currently executing. */ set executing(value) { if (this._executing === value) return; this._executing = value; if (this._executing) { this.emit('canUndo', false); this.emit('canRedo', false); } else { this.emit('canUndo', this._canUndo); this.emit('canRedo', this._canRedo); } } /** * Gets the number of async actions currently executing. */ get executing() { return this._executing; } } /** * Determines whether two arrays are deeply equal. Two arrays are considered equal if they have the * same length and corresponding elements are equal. This function also supports nested arrays, * comparing them recursively. * * @param a - The first array to compare. * @param b - The second array to compare. * @returns Returns `true` if the arrays are deeply equal, otherwise `false`. * * @example * arrayEquals([1, 2, 3], [1, 2, 3]); // true * arrayEquals([1, 2, 3], [3, 2, 1]); // false * arrayEquals([1, [2, 3]], [1, [2, 3]]); // true * arrayEquals([1, [2, 3]], [1, [3, 2]]); // false * arrayEquals([1, 2, 3], null); // false * arrayEquals(null, null); // false */ const arrayEquals = (a, b) => { if (!a || !b) { return false; } const l = a.length; if (l !== b.length) { return false; } for (let i = 0; i < l; i++) { if (a[i] instanceof Array && b[i] instanceof Array) { if (!arrayEquals(a[i], b[i])) { return false; } } else if (a[i] !== b[i]) { return false; } } return true; }; /** * Creates a deep copy of an array, recursively copying any nested arrays. * Non-array objects within the array are not deep copied (they remain references). * * @param arr - The array to copy. * @returns A deep copy of the array with all nested arrays also copied. * * @example * const original = [[1, 2], [3, 4]]; * const copy = deepCopyArray(original); * copy[0][0] = 99; * console.log(original[0][0]); // 1 (unchanged) */ const deepCopyArray = (arr) => { const copy = arr.slice(0); for (let i = 0; i < copy.length; i++) { if (copy[i] instanceof Array) { copy[i] = deepCopyArray(copy[i]); } } return copy; }; /** * The Observer class is used to observe and manage changes to an object. It allows for tracking * modifications to nested properties, emitting events on changes, and maintaining state * consistency. This is particularly useful in applications where state management and change * tracking are critical, such as in data-driven interfaces or collaborative applications. * * @example * const data = { * name: 'John', * age: 30, * address: { * city: 'New York', * zip: '10001' * } * }; * * const observer = new Observer(data); * * observer.on('name:set', (newValue, oldValue) => { * console.log(`Name changed from ${oldValue} to ${newValue}`); * }); * * observer.set('name', 'Jane'); // Logs: Name changed from John to Jane */ class Observer extends Events { /** * Creates a new Observer instance. * * @param data - The initial data to observe. * @param options - Additional options for the observer. */ constructor(data, options = {}) { super(); // Make internal properties non-enumerable so they don't get serialized // when the object is converted to JSON (e.g., for ShareDB sync) const props = [ ['_destroyed', false], ['_path', ''], ['_keys', []], ['_data', {}], ['_pathsWithDuplicates', options.pathsWithDuplicates ? new Set(options.pathsWithDuplicates) : null], ['_parent', options.parent || null], ['_parentPath', options.parentPath || ''], ['_parentField', options.parentField || null], ['_parentKey', options.parentKey || null], ['_latestFn', options.latestFn || null], ['_silent', false] ]; for (const [name, value] of props) { Object.defineProperty(this, name, { enumerable: false, writable: true, value }); } this.patch(data); const propagate = function (evt) { return function (path, arg1, arg2, arg3) { if (!this._parent) { return; } let key = this._parentKey; if (!key && (this._parentField instanceof Array)) { key = this._parentField.indexOf(this); if (key === -1) { return; } } path = `${this._parentPath}.${key}.${path}`; let state; if (this._silent) { state = this._parent.silence(); } this._parent.emit(`${path}:${evt}`, arg1, arg2, arg3); this._parent.emit(`*:${evt}`, path, arg1, arg2, arg3); if (this._silent) { this._parent.silenceRestore(state); } }; }; // propagate set this.on('*:set', propagate('set')); this.on('*:unset', propagate('unset')); this.on('*:insert', propagate('insert')); this.on('*:remove', propagate('remove')); this.on('*:move', propagate('move')); } static _splitPath(path) { const cache = Observer._splitPathsCache; let result = cache[path]; if (!result) { result = path.split('.'); cache[path] = result; } else { result = result.slice(); } return result; } silence() { this._silent = true; // history hook to prevent array values to be recorded const historyState = this.history && this.history.enabled; if (historyState) { this.history.enabled = false; } // sync hook to prevent array values to be recorded as array root already did const syncState = this.sync && this.sync.enabled; if (syncState) { this.sync.enabled = false; } return [historyState, syncState]; } silenceRestore(state) { this._silent = false; if (state[0]) { this.history.enabled = true; } if (state[1]) { this.sync.enabled = true; } } _prepare(target, key, value, silent = false, remote = false) { let i; let state; const path = (target._path ? (`${target._path}.`) : '') + key; const type = typeof value; target._keys.push(key); if (type === 'object' && (value instanceof Array)) { target._data[key] = value.slice(0); for (i = 0; i < target._data[key].length; i++) { if (typeof target._data[key][i] === 'object' && target._data[key][i] !== null) { if (target._data[key][i] instanceof Array) { target._data[key][i] = deepCopyArray(target._data[key][i]); } else { target._data[key][i] = new Observer(target._data[key][i], { parent: this, parentPath: path, parentField: target._data[key], parentKey: null }); } } else { state = this.silence(); this.emit(`${path}.${i}:set`, target._data[key][i], null, remote); this.emit('*:set', `${path}.${i}`, target._data[key][i], null, remote); this.silenceRestore(state); } } if (silent) { state = this.silence(); } this.emit(`${path}:set`, target._data[key], null, remote); this.emit('*:set', path, target._data[key], null, remote); if (silent) { this.silenceRestore(state); } } else if (type === 'object' && (value instanceof Object)) { if (typeof target._data[key] !== 'object') { target._data[key] = { _path: path, _keys: [], _data: {} }; } for (i in value) { if (typeof value[i] === 'object') { this._prepare(target._data[key], i, value[i], true, remote); } else { state = this.silence(); target._data[key]._data[i] = value[i]; target._data[key]._keys.push(i); this.emit(`${path}.${i}:set`, value[i], null, remote); this.emit('*:set', `${path}.${i}`, value[i], null, remote); this.silenceRestore(state); } } if (silent) { state = this.silence(); } // passing undefined as valueOld here // but we should get the old value to be consistent this.emit(`${path}:set`, value, undefined, remote); this.emit('*:set', path, value, undefined, remote); if (silent) { this.silenceRestore(state); } } else { if (silent) { state = this.silence(); } target._data[key] = value; this.emit(`${path}:set`, value, undefined, remote); this.emit('*:set', path, value, undefined, remote); if (silent) { this.silenceRestore(state); } } return true; } /** * @param path - Path to the property in the object. * @param value - Value to set. * @param silent - If true, the change will not be recorded in history. * @param remote - State value passed to the set event used to disable remote event emission. * @param force - If true, the value will be set even if it is the same as the current value. * @returns Returns true if the value was successfully set and false otherwise. */ set(path, value, silent = false, remote = false, force = false) { let i; let valueOld; let keys = Observer._splitPath(path); const length = keys.length; const key = keys[length - 1]; let node = this; let nodePath = ''; let obj = this; let state; for (i = 0; i < length - 1; i++) { if (node instanceof Array) { node = node[keys[i]]; if (node instanceof Observer) { path = keys.slice(i + 1).join('.'); obj = node; } } else { if (i < length && typeof node._data[keys[i]] !== 'object') { if (node._data[keys[i]]) { obj.unset((node._path ? `${node._path}.` : '') + keys[i]); } node._data[keys[i]] = { _path: path, _keys: [], _data: {} }; node._keys.push(keys[i]); } if (i === length - 1 && node._path) { nodePath = `${node._path}.${keys[i]}`; } node = node._data[keys[i]]; } } if (node instanceof Array) { const ind = parseInt(key, 10); if (node[ind] === value && !force) { return false; } valueOld = node[ind]; if (valueOld instanceof Observer) { valueOld = valueOld.json(); } else { valueOld = obj.json(valueOld); } node[ind] = value; if (value instanceof Observer) { value._parent = obj; value._parentPath = nodePath; value._parentField = node; value._parentKey = null; } if (silent) { state = obj.silence(); } obj.emit(`${path}:set`, value, valueOld, remote); obj.emit('*:set', path, value, valueOld, remote); if (silent) { obj.silenceRestore(state); } return true; } else if (node._data && !node._data.hasOwnProperty(key)) { if (typeof value === 'object') { return obj._prepare(node, key, value, false, remote); } node._data[key] = value; node._keys.push(key); if (silent) { state = obj.silence(); } obj.emit(`${path}:set`, value, null, remote); obj.emit('*:set', path, value, null, remote); if (silent) { obj.silenceRestore(state); } return true; } if (typeof value === 'object' && (value instanceof Array)) { if (arrayEquals(value, node._data[key]) && !force) { return false; } valueOld = node._data[key]; if (!(valueOld instanceof Observer)) { valueOld = obj.json(valueOld); } if (node._data[key] && node._data[key].length === value.length) { state = obj.silence(); // handle new array instance if (value.length === 0) { node._data[key] = value; } for (i = 0; i < node._data[key].length; i++) { if (node._data[key][i] instanceof Observer) { node._data[key][i].patch(value[i], true); } else if (node._data[key][i] !== value[i]) { node._data[key][i] = value[i]; obj.emit(`${path}.${i}:set`, node._data[key][i], valueOld && valueOld[i] || null, remote); obj.emit('*:set', `${path}.${i}`, node._data[key][i], valueOld && valueOld[i] || null, remote); } } obj.silenceRestore(state); } else { node._data[key] = []; value.forEach((val) => { this._doInsert(node, key, val, undefined, true); }); state = obj.silence(); for (i = 0; i < node._data[key].length; i++) { obj.emit(`${path}.${i}:set`, node._data[key][i], valueOld && valueOld[i] || null, remote); obj.emit('*:set', `${path}.${i}`, node._data[key][i], valueOld && valueOld[i] || null, remote); } obj.silenceRestore(state); } if (silent) { state = obj.silence(); } obj.emit(`${path}:set`, value, valueOld, remote); obj.emit('*:set', path, value, valueOld, remote); if (silent) { obj.silenceRestore(state); } return true; } else if (typeof value === 'object' && (value instanceof Object)) { let changed = false; valueOld = node._data[key]; if (!(valueOld instanceof Observer)) { valueOld = obj.json(valueOld); } keys = Object.keys(value); if (!node._data[key] || !node._data[key]._data) { if (node._data[key]) { obj.unset((node._path ? `${node._path}.` : '') + key); } else { changed = true; } node._data[key] = { _path: path, _keys: [], _data: {} }; } let c; for (const n in node._data[key]._data) { if (!value.hasOwnProperty(n)) { c = obj.unset(`${path}.${n}`, true); if (c) changed = true; } else if (node._data[key]._data.hasOwnProperty(n)) { if (!obj._equals(node._data[key]._data[n], value[n])) { c = obj.set(`${path}.${n}`, value[n], true); if (c) changed = true; } } else { c = obj._prepare(node._data[key], n, value[n], true, remote); if (c) changed = true; } } for (i = 0; i < keys.length; i++) { if (value[keys[i]] === undefined && node._data[key]._data.hasOwnProperty(keys[i])) { c = obj.unset(`${path}.${keys[i]}`, true); if (c) changed = true; } else if (typeof value[keys[i]] === 'object') { if (node._data[key]._data.hasOwnProperty(keys[i])) { c = obj.set(`${path}.${keys[i]}`, value[keys[i]], true); if (c) changed = true; } else { c = obj._prepare(node._data[key], keys[i], value[keys[i]], true, remote); if (c) changed = true; } } else if (!obj._equals(node._data[key]._data[keys[i]], value[keys[i]])) { if (typeof value[keys[i]] === 'object') { c = obj.set(`${node._data[key]._path}.${keys[i]}`, value[keys[i]], true); if (c) changed = true; } else if (node._data[key]._data[keys[i]] !== value[keys[i]]) { changed = true; if (node._data[key]._keys.indexOf(keys[i]) === -1) { node._data[key]._keys.push(keys[i]); } node._data[key]._data[keys[i]] = value[keys[i]]; state = obj.silence(); obj.emit(`${node._data[key]._path}.${keys[i]}:set`, node._data[key]._data[keys[i]], null, remote); obj.emit('*:set', `${node._data[key]._path}.${keys[i]}`, node._data[key]._data[keys[i]], null, remote); obj.silenceRestore(state); } } } if (changed) { if (silent) { state = obj.silence(); } const val = obj.json(node._data[key]); obj.emit(`${node._data[key]._path}:set`, val, valueOld, remote); obj.emit('*:set', node._data[key]._path, val, valueOld, remote); if (silent) { obj.silenceRestore(state); } return true; } return false; } let data; if (!node.hasOwnProperty('_data') && node.hasOwnProperty(key)) { data = node; } else { data = node._data; } if (data[key] === value && !force) { return false; } if (silent) { state = obj.silence(); } valueOld = data[key]; if (!(valueOld instanceof Observer)) { valueOld = obj.json(valueOld); } data[key] = value; obj.emit(`${path}:set`, value, valueOld, remote); obj.emit('*:set', path, value, valueOld, remote); if (silent) { obj.silenceRestore(state); } return true; } /** * Query whether the object has the specified property. * * @param path - Path to the value. * @returns Returns true if the value is present and false otherwise. */ has(path) { const keys = Observer._splitPath(path); let node = this; for (let i = 0, len = keys.length; i < len; i++) { // eslint-disable-next-line eqeqeq if (node == undefined) { return undefined; } if (node._data) { node = node._data[keys[i]]; } else { node = node[keys[i]]; } } return node !== undefined; } /** * @param path - Path to the value. * @param raw - Retrieve the observer object without converting it to JSON. * @returns The value at the specified path. */ get(path, raw = false) { const keys = Observer._splitPath(path); let node = this; for (let i = 0; i < keys.length; i++) { // eslint-disable-next-line eqeqeq if (node == undefined) { return undefined; } if (node._data) { node = node._data[keys[i]]; } else { node = node[keys[i]]; } } if (raw) { return node; } if (node == null) { return null; } return this.json(node); } getRaw(path) { return this.get(path, true); } _equals(a, b) { if (a === b) { return true; } else if (a instanceof Array && b instanceof Array && arrayEquals(a, b)) { return true; } return false; } /** * @param path - Path to the value. * @param silent - If true, the change will not be recorded in history. * @param remote - State value passed to the set event used to disable remote event emission. * @returns Returns true if the value was successfully unset and false otherwise. */ unset(path, silent = false, remote = false) { let i; const keys = Observer._splitPath(path); const key = keys[keys.length - 1]; let node = this; let obj = this; for (i = 0; i < keys.length - 1; i++) { if (node instanceof Array) { node = node[keys[i]]; if (node instanceof Observer) { path = keys.slice(i + 1).join('.'); obj = node; } } else { node = node._data[keys[i]]; } } if (!node._data || !node._data.hasOwnProperty(key)) { return false; } let valueOld = node._data[key]; if (!(valueOld instanceof Observer)) { valueOld = obj.json(valueOld); } // recursive if (node._data[key] && node._data[key]._data) { // do this in reverse order because node._data[key]._keys gets // modified as we loop for (i = node._data[key]._keys.length - 1; i >= 0; i--) { obj.unset(`${path}.${node._data[key]._keys[i]}`, true); } } node._keys.splice(node._keys.indexOf(key), 1); delete node._data[key]; let state; if (silent) { state = obj.silence(); } obj.emit(`${path}:unset`, valueOld, remote); obj.emit('*:unset', path, valueOld, remote); if (silent) { obj.silenceRestore(state); } return true; } /** * @param path - Path to the value. * @param ind - Index of the value. * @param silent - If true, the remove event will not be emitted. * @param remote - State value passed to the set event used to disable remote event emission. * @returns Returns true if the value was successfully removed and false otherwise. */ remove(path, ind, silent = false, remote = false) { const keys = Observer._splitPath(path); const key = keys[keys.length - 1]; let node = this; let obj = this; for (let i = 0; i < keys.length - 1; i++) { if (node instanceof Array) { node = node[parseInt(keys[i], 10)]; if (node instanceof Observer) { path = keys.slice(i + 1).join('.'); obj = node; } } else if (node._data && node._data.hasOwnProperty(keys[i])) { node = node._data[keys[i]]; } else { return false; } } if (!node._data || !node._data.hasOwnProperty(key) || !(node._data[key] instanceof Array)) { return false; } const arr = node._data[key]; if (arr.length < ind) { return false; } let value = arr[ind]; if (value instanceof Observer) { value._parent = null; } else { value = obj.json(value); } arr.splice(ind, 1); let state; if (silent) { state = obj.silence(); } obj.emit(`${path}:remove`, value, ind, remote); obj.emit('*:remove', path, value, ind, remote); if (silent) { obj.silenceRestore(state); } return true; } /** * @param path - Path to the value. * @param value - Value to remove. * @param silent - If true, the remove event will not be emitted. * @param remote - State value passed to the set event used to disable remote event emission. * @returns Returns true if the value was successfully removed and false otherwise. */ removeValue(path, value, silent = false, remote = false) { const keys = Observer._splitPath(path); const key = keys[keys.length - 1]; let node = this; let obj = this; for (let i = 0; i < keys.length - 1; i++) { if (node instanceof Array) { node = node[parseInt(keys[i], 10)]; if (node instanceof Observer) { path = keys.slice(i + 1).join('.'); obj = node; } } else if (node._data && node._data.hasOwnProperty(keys[i])) { node = node._data[keys[i]]; } else { return; } } if (!node._data || !node._data.hasOwnProperty(key) || !(node._data[key] instanceof Array)) { return; } const arr = node._data[key]; const ind = arr.indexOf(value); if (ind === -1) { return; } if (arr.length < ind) { return; } value = arr[ind]; if (value instanceof Observer) { value._parent = null; } else { value = obj.json(value); } arr.splice(ind, 1); let state; if (silent) { state = obj.silence(); } obj.emit(`${path}:remove`, value, ind, remote); obj.emit('*:remove', path, value, ind, remote); if (silent) { obj.silenceRestore(state); } return true; } /** * @param path - Path to the value. * @param value - Value to insert. * @param ind - Index to insert the value at. * @param silent - If true, the insert event will not be emitted. * @param remote - State value passed to the set event used to disable remote event emission. * @returns Returns true if the value was successfully inserted and false otherwise. */ insert(path, value, ind, silent = false, remote = false) { const keys = Observer._splitPath(path); const key = keys[keys.length - 1]; let node = this; let obj = this; for (let i = 0; i < keys.length - 1; i++) { if (node instanceof Array) { node = node[parseInt(keys[i], 10)]; if (node instanceof Observer) { path = keys.slice(i + 1).join('.'); obj = node; } } else if (node._data && node._data.hasOwnProperty(keys[i])) { node = node._data[keys[i]]; } else { return; } } if (!node._data || !node._data.hasOwnProperty(key) || !(node._data[key] instanceof Array)) { return; } const arr = node._data[key]; value = obj._doInsert(node, key, value, ind); if (ind === undefined) { ind = arr.length - 1; } let state; if (silent) { state = obj.silence(); } obj.emit(`${path}:insert`, value, ind, remote); obj.emit('*:insert', path, value, ind, remote); if (silent) { obj.silenceRestore(state); } return true; } _doInsert(node, key, value, ind, allowDuplicates = false) { var _a; const arr = node._data[key]; if (typeof value === 'object' && !(value instanceof Observer) && value !== null) { if (value instanceof Array) { value = value.slice(0); } else { value = new Observer(value); } } const path = node._path ? `${node._path}.${key}` : key; if (value !== null && !allowDuplicates && !((_a = this._pathsWithDuplicates) === null || _a === void 0 ? void 0 : _a.has(path))) { if (arr.indexOf(value) !== -1) { return; } } if (ind === undefined) { arr.push(value); } else { arr.splice(ind, 0, value); } if (value instanceof Observer) { value._parent = this; value._parentPath = path; value._parentField = arr; value._parentKey = null; } else { value = this.json(value); } return value; } /** * @param path - Path to the value. * @param indOld - Index of the value to move. * @param indNew - Index to move the value to. * @param silent - If true, the move event will not be emitted. * @param remote - State value passed to the set event used to disable remote event emission. * @returns Returns true if the value was successfully moved and false otherwise. */ move(path, indOld, indNew, silent = false, remote = false) { const keys = Observer._splitPath(path); const key = keys[keys.length - 1]; let node = this; let obj = this; for (let i = 0; i < keys.length - 1; i++) { if (node instanceof Array) { node = node[parseInt(keys[i], 10)]; if (node instanceof Observer) { path = keys.slice(i + 1).join('.'); obj = node; } } else if (node._data && node._data.hasOwnProperty(keys[i])) { node = node._data[keys[i]]; } else { return; } } if (!node._data || !node._data.hasOwnProperty(key) || !(node._data[key] instanceof Array)) { return; } const arr = node._data[key]; if (arr.length < indOld || arr.length < indNew || indOld === indNew) { return; } let value = arr[indOld]; arr.splice(indOld, 1); if (indNew === -1) { indNew = arr.length; } arr.splice(indNew, 0, value);