UNPKG

derby

Version:

MVC framework making it easy to write realtime, collaborative applications that run in both Node.js and browsers.

601 lines (600 loc) 26.5 kB
/* * components.js * * Components associate custom script functionality with a view. They can be * distributed as standalone modules containing templates, scripts, and styles. * They can also be used to modularize application functionality. * */ var __extends = (this && this.__extends) || (function () { var extendStatics = function (d, b) { extendStatics = Object.setPrototypeOf || ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) || function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; }; return extendStatics(d, b); }; return function (d, b) { if (typeof b !== "function" && b !== null) throw new TypeError("Class extends value " + String(b) + " is not a constructor or null"); extendStatics(d, b); function __() { this.constructor = d; } d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.extendComponent = exports.ComponentFactory = exports.ComponentModelData = exports.createFactory = exports.ComponentAttributeBinding = exports.ComponentAttribute = exports.Component = void 0; var racer_1 = require("racer"); var Controller_1 = require("./Controller"); var derbyTemplates = require("./templates"); var templates_1 = require("./templates/templates"); var util_1 = require("./templates/util"); var expressions = derbyTemplates.expressions, templates = derbyTemplates.templates; var slice = [].slice; var Component = /** @class */ (function (_super) { __extends(Component, _super); function Component(context, data) { var _this = this; var parent = context.controller; var id = context.id(); var scope = ['$components', id]; var model = parent.model.root.eventContext(id); model._at = scope.join('.'); data.id = id; model._set(scope, data); // Store a reference to the component's scope such that the expression // getters are relative to the component model.data = data; // IMPORTANT: call super _after_ model created _this = _super.call(this, context.controller.app, context.controller.page, model) || this; _this.parent = parent; _this.context = context.componentChild(_this); _this.id = id; _this._scope = scope; // Add reference to this component on the page so that all components // associated with a page can be destroyed when the page transitions _this.page._components[id] = _this; _this.isDestroyed = false; return _this; } Component.prototype.destroy = function () { this.emit('destroy'); this.model.removeContextListeners(); this.model.destroy(); delete this.page._components[this.id]; if (this.page._eventModel.object) { var components = this.page._eventModel.object.$components; if (components) delete components.object[this.id]; } this.isDestroyed = true; }; /** * Generate a function, bound function to the component instance's `this`. * The returned function will no longer be invoked once the component is destroyed. * * @param fn - A function to be invoked with the component as its `this` value. * @returns a bound function, similar to JavaScript's Function.bind() * * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_objects/Function/bind */ Component.prototype.bind = function (fn) { // eslint-disable-next-line @typescript-eslint/no-this-alias var _component = this; var _fn = fn; this.on('destroy', function () { // Reduce potential for memory leaks by removing references to the component // and the passed in callback, which could have closure references _component = null; // Cease calling back after component is removed from the DOM _fn = null; }); return function componentBindWrapper() { var args = []; for (var _i = 0; _i < arguments.length; _i++) { args[_i] = arguments[_i]; } if (!_fn) return; return _fn.apply(_component, args); }; }; /** * Generate a function that, when passing in a numeric delay, calls the function at * most once per that many milliseconds. Additionally, implements an interface * intended to be used with window.requestAnimationFrame, process.nextTick, or window.setImmediate. * * @param fn - A function to be invoked with the component instance as its `this` value. * @param delayArg - Amount of time (in ms) to wait until invoking `fn` again. Default '0'. * * When passing in a numeric delay, calls the function at most once per that * many milliseconds. Like Underscore, the function will be called on the * leading and the trailing edge of the delay as appropriate. Unlike Underscore, * calls are consistently called via setTimeout and are never synchronous. This * should be used for reducing the frequency of ongoing updates, such as scroll * events or other continuous streams of events. * * Additionally, implements an interface intended to be used with * window.requestAnimationFrame or process.nextTick. If one of these is passed, * it will be used to create a single async call following any number of * synchronous calls. This mode is typically used to coalesce many synchronous * events (such as multiple model events) into a single async event. * Like component.bind(), will no longer call back once the component is - * destroyed, which avoids possible bugs and memory leaks. * * @returns a bound function */ Component.prototype.throttle = function (fn, delayArg) { // eslint-disable-next-line @typescript-eslint/no-this-alias var _component = this; this.on('destroy', function () { // Reduce potential for memory leaks by removing references to the component // and the passed in callback, which could have closure references _component = null; // Cease calling back after component is removed from the DOM fn = null; }); // throttle(callback) // throttle(callback, 150) if (delayArg == null || typeof delayArg === 'number') { var delay_1 = delayArg || 0; var nextArgs_1; var previous_1; var boundCallback_1 = function () { var args = nextArgs_1; nextArgs_1 = null; previous_1 = +new Date(); if (fn && args) { fn.apply(_component, args); } }; return function componentThrottleWrapper() { var args = []; for (var _i = 0; _i < arguments.length; _i++) { args[_i] = arguments[_i]; } var queueCall = !nextArgs_1; nextArgs_1 = slice.call(args); if (queueCall) { var now = +new Date(); var remaining = Math.max(previous_1 + delay_1 - now, 0); setTimeout(boundCallback_1, remaining); } }; } // throttle(callback, window.requestAnimationFrame) // throttle(callback, process.nextTick) if (typeof delayArg === 'function') { var nextArgs_2; var boundCallback_2 = function () { var args = nextArgs_2; nextArgs_2 = null; if (fn && args) { fn.apply(_component, args); } }; return function componentThrottleWrapper() { var args = []; for (var _i = 0; _i < arguments.length; _i++) { args[_i] = arguments[_i]; } var queueCall = !nextArgs_2; nextArgs_2 = slice.call(args); if (queueCall) delayArg(boundCallback_2); }; } throw new Error('Second argument must be a delay function or number'); }; /** * Safe wrapper around `window.requestAnimationFrame` that ensures function not invoked * when component has been destroyed * @param fn - A function to be invoked with the component instance as its `this` value. */ Component.prototype.requestAnimationFrame = function (fn) { var safeCallback = _safeWrap(this, fn); window.requestAnimationFrame(safeCallback); }; /** * Safe wrapper around `process.nextTick` that ensures function not invoked * when component has been destroyed * @param fn - A function to be invoked with the component instance as its `this` value. */ Component.prototype.nextTick = function (fn) { var safeCallback = _safeWrap(this, fn); process.nextTick(safeCallback); }; /** * Suppresses calls until the function is no longer called for that many milliseconds. * This should be used for delaying updates triggered by user input or typing text. * * @param fn - A function to be invoked with the component instance as its `this` value. * @param delay - Amount of time (in ms) to wait until invoking `fn`. Default '0'. * * @returns a bound function */ Component.prototype.debounce = function (fn, delay) { delay = delay || 0; if (typeof delay !== 'number') { throw new Error('Second argument must be a number'); } // eslint-disable-next-line @typescript-eslint/no-this-alias var component = this; this.on('destroy', function () { // Reduce potential for memory leaks by removing references to the component // and the passed in callback, which could have closure references component = null; // Cease calling back after component is removed from the DOM fn = null; }); var nextArgs; var timeout; var boundCallback = function () { var args = nextArgs; nextArgs = null; timeout = null; if (fn && args) { fn.apply(component, args); } }; return function componentDebounceWrapper() { var args = []; for (var _i = 0; _i < arguments.length; _i++) { args[_i] = arguments[_i]; } nextArgs = slice.call(args); if (timeout) clearTimeout(timeout); timeout = setTimeout(boundCallback, delay); }; }; /** * Like debounce(), suppresses calls until the function is no longer called for * that many milliseconds. In addition, suppresses calls while the callback * function is running. In other words, the callback will not be called again * until the supplied done() argument is called. When the debounced function is * called while the callback is running, the callback will be called again * immediately after done() is called. Thus, the callback will always receive * the last value passed to the debounced function. * * This avoids the potential for multiple callbacks to execute in parallel and * complete out of order. It also acts as an adaptive rate limiter. Use this * method to debounce any field that triggers an async call as the user types. * * Like component.bind(), will no longer call back once the component is * destroyed, which avoids possible bugs and memory leaks. * * Forked from: https://github.com/juliangruber/async-debounce * * @param fn - A function to be invoked with the component instance as its `this` value. * @param delay - Amount of time (in ms) to wait until invoking `fn`. Default '0'. * * @returns a bound function */ Component.prototype.debounceAsync = function (fn, delay) { var applyArguments = fn.length !== 1; delay = delay || 0; if (typeof delay !== 'number') { throw new Error('Second argument must be a number'); } // eslint-disable-next-line @typescript-eslint/no-this-alias var component = this; this.on('destroy', function () { // Reduce potential for memory leaks by removing references to the component // and the passed in callback, which could have closure references component = null; // Cease calling back after component is removed from the DOM fn = null; }); var running = false; var nextArgs; var timeout; function done() { var args = nextArgs; nextArgs = null; timeout = null; if (fn && args) { running = true; args.push(done); fn.apply(component, args); } else { running = false; } } return function componentDebounceAsyncWrapper() { var args = []; for (var _i = 0; _i < arguments.length; _i++) { args[_i] = arguments[_i]; } nextArgs = (applyArguments) ? slice.call(args) : []; if (timeout) clearTimeout(timeout); if (running) return; timeout = setTimeout(done, delay); }; }; Component.prototype.get = function (viewName, unescaped) { var view = this.getView(viewName); return view.get(this.context, unescaped); }; Component.prototype.getFragment = function (viewName, _ns) { var view = this.getView(viewName); return view.getFragment(this.context); }; Component.prototype.getView = function (viewName, _ns) { var contextView = this.context.getView(); return (viewName) ? this.app.views.find(viewName, contextView.namespace) : contextView; }; /** * Retrieve the appropriate view attribute's value for a given view instance. * If the value is a template, it will be rendered prior to being returned. * * @param attrName the name of the view attribute used with a component instance * @returns any of the possible values that can be expressed with a view attribute * * @see https://derbyjs.github.io/derby/views/template-syntax/view-attributes */ Component.prototype.getAttribute = function (attrName) { var attributeContext = this.context.forAttribute(attrName); if (!attributeContext) return; var value = attributeContext.attributes[attrName]; if (value instanceof expressions.Expression) { value = value.get(attributeContext); } return expressions.renderValue(value, this.context); }; Component.prototype.setAttribute = function (key, value) { (0, util_1.checkKeyIsSafe)(key); this.context.parent.attributes[key] = value; }; Component.prototype.setNullAttribute = function (key, value) { (0, util_1.checkKeyIsSafe)(key); var attributes = this.context.parent.attributes; if (attributes[key] == null) attributes[key] = value; }; return Component; }(Controller_1.Controller)); exports.Component = Component; function _safeWrap(component, callback) { return function () { if (component.isDestroyed) return; callback.call(component); }; } var ComponentAttribute = /** @class */ (function () { function ComponentAttribute(expression, model, key) { (0, util_1.checkKeyIsSafe)(key); this.expression = expression; this.model = model; this.key = key; } ComponentAttribute.prototype.update = function (context, binding) { var value = this.expression.get(context); // @ts-expect-error Unsure what type binding should be w condition binding.condition = value; this.model.setDiff(this.key, value); }; return ComponentAttribute; }()); exports.ComponentAttribute = ComponentAttribute; var ComponentAttributeBinding = /** @class */ (function (_super) { __extends(ComponentAttributeBinding, _super); function ComponentAttributeBinding(expression, model, key, context) { var _this = _super.call(this) || this; _this.template = new ComponentAttribute(expression, model, key); _this.context = context; _this.condition = expression.get(context); return _this; } return ComponentAttributeBinding; }(templates_1.Binding)); exports.ComponentAttributeBinding = ComponentAttributeBinding; function setModelAttributes(context, model) { var attributes = context.parent.attributes; if (!attributes) return; // Set attribute values on component model for (var key in attributes) { var value = attributes[key]; setModelAttribute(context, model, key, value); } } function setModelAttribute(context, model, key, value) { // If an attribute is an Expression, set its current value in the model // and keep it up to date. When it is a resolvable path, use a Racer ref, // which makes it a two-way binding. Otherwise, set to the current value // and create a binding that will set the value in the model as the // expression's dependencies change. if (value instanceof expressions.Expression) { var segments = value.pathSegments(context); if (segments) { model.root.ref(model._at + '.' + key, segments.join('.'), { updateIndices: true }); } else { var binding = new ComponentAttributeBinding(value, model, key, context); context.addBinding(binding); model.set(key, binding.condition); } return; } // If an attribute is a Template, set a template object in the model. // Eagerly rendering a template can cause excessive rendering when the // developer wants to pass in a complex chunk of HTML, and if we were to // set a string in the model that represents the template value, we'd lose // the ability to use the value in the component's template, since HTML // would be escaped and we'd lose the ability to create proper bindings. // // This may be of surprise to developers, since it may not be intuitive // whether a passed in value will produce an expression or a template. To // get the rendered value consistently, the component's getAttribute(key) // method may be used to get the value that would be rendered. if (value instanceof templates.Template) { var template = new templates.ContextClosure(value, context); model.set(key, template); return; } // For all other value types, set the passed in value directly. Passed in // values will only be set initially, so model paths should be used if // bindings are desired. model.set(key, value); } function createFactory(constructor) { // DEPRECATED: constructor.prototype.singleton is deprecated. "singleton" // static property on the constructor is preferred return (constructor.singleton === true) ? new SingletonComponentFactory(constructor) : new ComponentFactory(constructor); } exports.createFactory = createFactory; function emitInitHooks(context, component) { if (!context.initHooks) return; // Run initHooks for `on` listeners immediately before init for (var i = 0, len = context.initHooks.length; i < len; i++) { context.initHooks[i].emit(context, component); } } var ComponentModelData = /** @class */ (function () { function ComponentModelData() { } return ComponentModelData; }()); exports.ComponentModelData = ComponentModelData; var ComponentFactory = /** @class */ (function () { function ComponentFactory(constructorFn) { this.constructorFn = constructorFn; } ComponentFactory.prototype.init = function (context) { var DataConstructor = this.constructorFn.DataConstructor || ComponentModelData; var data = new DataConstructor(); // eslint-disable-next-line new-cap var component = new this.constructorFn(context, data); // Detect whether the component constructor already called super by checking // for one of the properties it sets. If not, call the Component constructor if (!component.context) { Component.call(component, context, data); } setModelAttributes(component.context, component.model); // Do the user-specific initialization. The component constructor should be // an empty function and the actual initialization code should be done in the // component's init method. This means that we don't have to rely on users // properly calling the Component constructor method and avoids having to // play nice with how CoffeeScript extends class constructors emitInitHooks(context, component); component.emit('init', component); if (component.init) { if (racer_1.util.isProduction) { component.init(component.model); } else { var initReturn = component.init(component.model); if (initReturn instanceof Promise) { console.warn("Component ".concat(component.constructor.name, " init() should not be an async function:"), component.init); } } } return component.context; }; ComponentFactory.prototype.create = function (context) { var component = context.controller; component.emit('create', component); // Call the component's create function after its view is rendered if (component.create) { component.create(component.model, component.dom); } }; return ComponentFactory; }()); exports.ComponentFactory = ComponentFactory; function noop() { } var SingletonComponentFactory = /** @class */ (function () { function SingletonComponentFactory(constructorFn) { this.constructorFn = constructorFn; this.component = null; // Disable component from being destroyed, since it is intended to // be used multiple times constructorFn.prototype.destroy = noop; } SingletonComponentFactory.prototype.init = function (context) { // eslint-disable-next-line new-cap if (!this.component) this.component = new this.constructorFn(); return context.componentChild(this.component); }; // Don't call the init or create methods for singleton components SingletonComponentFactory.prototype.create = function () { }; return SingletonComponentFactory; }()); function isBasePrototype(object) { return (object === Object.prototype) || (object === Function.prototype) || (object === null); } function getRootPrototype(object) { // eslint-disable-next-line no-constant-condition while (true) { var prototype = Object.getPrototypeOf(object); if (isBasePrototype(prototype)) return object; object = prototype; } } var _extendComponent = (Object.setPrototypeOf && Object.getPrototypeOf) ? // Modern version, which supports ES6 classes function (constructor) { // Find the end of the prototype chain var rootPrototype = getRootPrototype(constructor.prototype); // This guard is a workaround to a bug that has occurred in Chakra when // app.component() is invoked twice on the same constructor. In that case, // the `instanceof Component` check in extendComponent incorrectly returns // false after the prototype has already been set to `Component.prototype`. // Then, this code proceeds to set the prototype of Component.prototype // to itself, which throws a "Cyclic __proto__ value" error. // https://github.com/Microsoft/ChakraCore/issues/5915 if (rootPrototype === Component.prototype) return; // Establish inheritance with the pattern that Node's util.inherits() uses // if Object.setPrototypeOf() is available (all modern browsers & IE11). // This inhertance pattern is not equivalent to class extends, but it does // work to make instances of the constructor inherit the desired prototype // https://github.com/nodejs/node/issues/4179 Object.setPrototypeOf(rootPrototype, Component.prototype); } : // Fallback for older browsers function (constructor) { // In this version, we iterate over all of the properties on the // constructor's prototype and merge them into a new prototype object. // This flattens the prototype chain, meaning that instanceof will not // work for classes from which the current component inherits var prototype = constructor.prototype; // Otherwise, modify constructor.prototype. This won't work with ES6 // classes, since their prototype property is non-writeable. However, it // does work in older browsers that don't support Object.setPrototypeOf(), // and those browsers don't support ES6 classes either constructor.prototype = Object.create(Component.prototype); constructor.prototype.constructor = constructor; racer_1.util.mergeInto(constructor.prototype, prototype); }; function extendComponent(constructor) { if (constructor.singleton) { if (constructor.prototype instanceof Component) { throw new Error('Singleton compoment must not extend the Component class'); } else { return; } } // Normal components' constructors must extend Component. if (constructor.prototype instanceof Component) { return; } // For backwards compatibility, if a normal component doesn't already extend Component, // then append Component.prototype to the constructor's prototype chain _extendComponent(constructor); } exports.extendComponent = extendComponent;