<commit_before>// Decent Multi-Tenancy (c) 2014-2015 Bertrand Le Roy, under MIT. See LICENSE.txt for licensing details.
'use strict';

// TODO: Add configurable timeout to request handling.
// TODO: Enable tenants to be restarted.
// TODO: add opt-out for debug mode.
// TODO: log all errors instead of sending them to the console.
// TODO: emit errors up onto the tenant.
// TODO: add logger as an option, fallback to console.

let EventEmitter = require('events').EventEmitter;
let util = require('util');
let path = require('path');
let fs = require('fs');
let http = require('http');
let https = require('https');
let domain = require('domain');

/**
 * A tenant is an independant site in the system.
 */
class Tenant extends EventEmitter {
  /**
  * One should usually refrain from using this constructor directly, and do a `Tenant.load` instead.
  * @param {Object} options
  * @param {String} [options.name] The technical name of the tenant. Default is "default".
  * @param {String} [options.displayName] The display name of the tenant. Default is `name`.
  * @param {String} [options.host] The host name under which the tenant answers. If not specified, this is process.env.IP, or `localhost`.
  * @param {String} [options.debugHost] A host name under which the tenant answers, that causes `request.debug` to be true.
  * @param {Number} [options.port] The port to which the tenant answers. Defaults to `process.env.port`, or 80 if that's not found.
  * @param {Number} [options.debugPort] The port associated with the debug host. Defaults to `process.env.port`, or 1337 if that's not found.
  * @param {Boolean} [options.https] True if the site must use HTTPS. False by default.
  * @param {Boolean} [options.debugHttps] True if the site must use HTTPS in debug mode. False by default.
  * @param {String} [options.cert] The path to the SSL certificate to use with this tenant.
  * @param {String} [options.key] The path to the SSL key to use with this tenant.
  * @param {String} [options.pfx] The path to the pfx SSL certificate to use with this tenant.
  * @param {String} [options.debugCert] The path to the SSL certificate to use with this tenant in debug mode. Defaults to `cert`.
  * @param {String} [options.debugKey] The path to the SSL key to use with this tenant in debug mode. Defaults to `key`.
  * @param {String} [options.debugPfx] The path to the pfx SSL certificate to use with this tenant in debug mode. Defaults to `pfx`.
  * @param {Boolean} [options.active] True if the tenant is active.
  * @param {Object} [options.logger] A logger object with a log(level:string, message:string) method. If not specified, log messages go to the console.
  */
  constructor(options) {
    super();
    Object.assign(this, {
      settings: options || {},
      name: options.name || 'default',
      displayName: options.displayName || options.name,
      host: Array.isArray(options.host)
        ? options.host
        : (options.host
          ? [options.host]
          : (process.env.IP
            ? [process.env.IP]
            : ['localhost'])),
      debugHost: Array.isArray(options.debugHost)
        ? options.debugHost
        : (options.debugHost ? [options.debugHost] : ['localhost']),
      port: options.port || process.env.PORT || 80,
      debugPort: options.debugPort || process.env.PORT || 1337,
      https: !!options.https,
      debugHttps: !!options.debugHttps,
      cert: options.cert,
      key: options.key,
      pfx: options.pfx,
      debugCert: options.debugCert || options.cert,
      debugKey: options.debugKey || options.key,
      debugPfx: options.debugPfx || options.pfx,
      features: options.features || {},
      active: !(options.active === false),
      log: (options.logger ? options.logger.log : null) ||
        function log() {
          console.log.apply(null, arguments);
        }
    });
  }

  /**
   * Creates a tenant from a settings file or object, and adds it to `Tenant.list`.
   * @param {String|Object} settingsh The path of the settings file, or a settings object.
   * @param {Object} [defaults] Default settings.
   * @returns {Tenant} The new tenant.
   */
  static load(settings, defaults) {
    defaults = defaults || {};
    if (typeof settings == 'string') {
      settings.settingsPath = settings;
      settings.rootPath = settings;
      settings = require(settings);
    }
    Object.getOwnPropertyNames(defaults)
      .forEach(function forEachDefaultSetting(settingName) {
        if (!settings.hasOwnProperty(settingName)) {
          settings[settingName] = defaults[settingName];
        }
      });
    let tenant = new Tenant(settings);
    Tenant.list[tenant.name] = tenant;
    return tenant;
  };

  /**
   * Discovers all tenants in the rootPath directory.
   * This method scans the directory for subdirectories with `settings.json` files
   * in them. Those files are then loaded as site settings.
   * @param {Object} defaults The default settings for the shells.
   * @param {String} rootPath The root path where to look for shell settings files. Defaults to "./sites".
   */
  static discover(defaults, rootPath) {
    rootPath = rootPath || './sites';
    let siteNames = fs.readdirSync(rootPath);
    siteNames.forEach(function foreachSiteName(siteName) {
      if (siteName[0] === '.') return;
      let resolvedSitePath = path.resolve(rootPath, siteName, 'settings.json');
      try {
        let settings = require(resolvedSitePath);
      }
      catch(ex) {
        ex.path = resolvedSitePath;
        ex.message = 'Failed to load site settings for ' + siteName;
        throw ex;
      }
      settings.settingsPath = settings;
      settings.rootPath = settings;
      settings.name = settings.name || siteName;
      Tenant.load(settings, defaults);
    });
  };

  /**
   * Returns the tenant that should handle this request.
   * @param {IncomingMessage} request The request.
   * @returns {Tenant} The tenant that should handle the request, null if no active tenant is fit to do it.
   */
  static resolve(request) {
    let shellNames = Object.getOwnPropertyNames(Tenant.list);
    // If there's only one shell, always return that.
    if (shellNames.length === 1) return Tenant.list[shellNames[0]];
    // Otherwise let each shell decide if it can handle the request.
    for (let i = 0; i < shellNames.length; i++) {
      let shell = Tenant.list[shellNames[i]];

      if (shell.active && shell.canHandle(request)) {
        return shell;
      }
    }
    // Unresolved requests should not go to a default shell
    // if there's more than one.
    return null;
  };

  /**
   * Starts http listeners covering for all active tenants in `Tenant.list`.
   */
  static listen() {
    let httpServers = {};
    let httpServer;
    let port = process.env.PORT || 1337;

    // If iisnode, only create one server.
    // TODO: properly handle https on IIS.
    if (process.env.IISNODE_VERSION && port.substr(0, 9) === '\\\\.\\pipe\\') {
      httpServer = new http.Server();
      httpServer.on('request', localHandler(httpServer));
      httpServer.listen(port);
      return;
    }
    // Listen for each tenant
    for (let shellName in Tenant.list) {
      let tenant = Tenant.list[shellName];
      if (!tenant.active) continue;
      let hosts = Array.isArray(tenant.host) ? tenant.host : [tenant.host];
      for (let i = 0; i < hosts.length; i++) {
        let host = hosts[i];
        let currentPort = tenant.port !== '*' ? tenant.port : port;
        // TODO: create debug servers as needed.
        let serverKey = host + currentPort + (tenant.key || "") + (tenant.cert || "") + (tenant.pfx || "");
        httpServer = httpServers.hasOwnProperty(serverKey)
          ? httpServers[serverKey]
          : httpServers[serverKey] = tenant.https
            ? https.createServer({
              host: host,
              key: tenant.key,
              cert: tenant.cert,
              pfx: tenant.pfx
            })
            : new http.Server();
        httpServer.on('request', localHandler(httpServer));
        httpServer.listen(currentPort);
        tenant.log('info', `${tenant.name} started listening on port ${currentPort}.`)
      }
    }
  }

  /**
   * Determines if the tenant can handle that request.
   * @param request The request
   * @returns {Boolean} True if the tenant can handle the request.
   */
  canHandle(request) {
    let host = request.headers.host;
    let hosts = this.host.concat(this.debugHost);
    for (let i = 0; i < hosts.length; i++) {
      let thisHost = hosts[i];
      if ((
        (
        ((this.https && this.port === 443) || this.port === 80 || this.port === '*')
        && thisHost === host
        )
        || (this.port === '*' && host.substr(0, thisHost.length) === thisHost)
        || (thisHost + ':' + this.port === host)
      ) && (
      !this.path
      || request.url.substr(0, this.path.length) === this.path
      )) {
        if (i >= this.host.length) {
          request.debug = true;
        }
        // We also set the tenant into debug mode, which could cause problems
        // if the same tenant is on a server configured and able to answer on both
        // the host and debug host. That should however never happen.
        this.debug = request.debug;
        return true;
      }
    }
    return false;
  };

  /**
   * Enables or disables the tenant.
   * @param {Boolean} state If provided, sets the enabled state of the tenant. Otherwise, enables the tenant.
   */
  enable(state) {
    this.active = typeof state === 'undefined' || !!state;
  };

  /**
   * Disables the tenant.
   */
  disable() {
    this.active = false;
  };

  /**
   * Middleware for use, for example, with Express.
   * @param {http.IncomingMessage} request Request
   * @param {http.ServerResponse} response Response
   * @param {Function} next The callback.
   */
  middleware(request, response, next) {
    if (!this.canHandle(request)) {
      next();
      return;
    }
    this.handleRequest(request, response, next);
  };

  /**
   * Handles the request for the tenant.
   * Emits the following events:
   * * decent.multi-tenancy.start-request {tenant, request, response}
   * * decent.multi-tenancy.handle-request {tenant, request, response}
   * * decent.multi-tenancy.end-request {tenant, request, response}
   * * error
   * @param request Request
   * @param response Response
   * @param {Function} next The callback
   */
  handleRequest(request, response, next) {
    let self = this;
    let log = this.log;
    // All events use the same context object.
    let context = {
      tenant: self,
      request: request,
      response: response
    };
    self.emit(Tenant.startRequestEvent, context);
    self.emit(Tenant.handleRequestEvent, context);
    self.emit(Tenant.endRequestEvent, context);
    if (request.tearDown) request.tearDown();
    this.log('info', 'Handled request', {
      tenant: self.name,
      url: request.url,
      status: response.statusCode
    });
    if (next) next();
  };
}

/**
 * An empty shell that you can use to initialize services if you don't need multitenancy.
 */
Tenant.empty = new Tenant({
  name: "Empty shell"
});

/**
 * @description
 * The list of tenants.
 */
Tenant.list = {};

function localHandler(server) {
  return function defaultHandler(req, res) {
    let d = domain.create();
    d.on('error', function onError(err) {
      console.error('Unrecoverable error', err.stack || err.message || err);
      try {
        // Close down within 30 seconds
        let killTimer = setTimeout(function processKill() {
          process.exit(1);
        }, 30000);
        // But don't keep the process open just for that!
        killTimer.unref();

        // stop taking new requests.
        server.close();

        if (res && !res.finished) {
          // try to send an error to the request that triggered the problem
          res.statusCode = 500;
          // TODO: let route handlers set headers, including powered by.
          res.end('Oops, the server choked on this request!\n');
          // TODO: broadcast the error to give loggers a chance to use it.
        }
      } catch (er2) {
        // oh well, not much we can do at this point.
        console.error('Error sending 500!', er2.stack);
        return;
      }
    });
    d.add(req);
    d.add(res);
    let tenant = Tenant.resolve(req);
    if (!tenant) {
      console.error('Could not resolve shell.', {url: req.url, host: req.headers.host});
      return;
    }
    d.add(tenant);

    // Now run the handler function in the domain.
    d.run(function runHandler() {
      tenant.handleRequest(req, res, function() {
        res.end('');
      });
    });
  }
}

/**
 * @description
 * The event that is broadcast at the beginning of request handling.
 * This is a good time to attach a service to the request object.
 * @type {string}
 */
Tenant.startRequestEvent = 'decent.multi-tenancy.start-request';
Tenant.startRequestEvent_payload = {
  tenant: 'Tenant',
  request: 'IncomingMessage',
  response: 'ServerResponse'
};

/**
 * @description
 * The event that is broadcast when the request is ready to be handled.
 * @type {string}
 */
Tenant.handleRequestEvent = 'decent.multi-tenancy.handle-request';
Tenant.handleRequestEvent_payload = {
  tenant: 'Tenant',
  request: 'IncomingMessage',
  response: 'ServerResponse'
};

/**
 * @description
 * The event that is broadcast at the end of request handling.
 * This is a good time to clean-up, detach services and events.
 * @type {string}
 */
Tenant.endRequestEvent = 'decent.multi-tenancy.end-request';
Tenant.endRequestEvent_payload = {
  tenant: 'Tenant',
  request: 'IncomingMessage',
  response: 'ServerResponse'
};

module.exports = Tenant;
<commit_msg>Use for..of instead of .forEach to copy default settings.<commit_after>// Decent Multi-Tenancy (c) 2014-2015 Bertrand Le Roy, under MIT. See LICENSE.txt for licensing details.
'use strict';

// TODO: Add configurable timeout to request handling.
// TODO: Enable tenants to be restarted.
// TODO: add opt-out for debug mode.
// TODO: log all errors instead of sending them to the console.
// TODO: emit errors up onto the tenant.
// TODO: add logger as an option, fallback to console.

let EventEmitter = require('events').EventEmitter;
let util = require('util');
let path = require('path');
let fs = require('fs');
let http = require('http');
let https = require('https');
let domain = require('domain');

/**
 * A tenant is an independant site in the system.
 */
class Tenant extends EventEmitter {
  /**
  * One should usually refrain from using this constructor directly, and do a `Tenant.load` instead.
  * @param {Object} options
  * @param {String} [options.name] The technical name of the tenant. Default is "default".
  * @param {String} [options.displayName] The display name of the tenant. Default is `name`.
  * @param {String} [options.host] The host name under which the tenant answers. If not specified, this is process.env.IP, or `localhost`.
  * @param {String} [options.debugHost] A host name under which the tenant answers, that causes `request.debug` to be true.
  * @param {Number} [options.port] The port to which the tenant answers. Defaults to `process.env.port`, or 80 if that's not found.
  * @param {Number} [options.debugPort] The port associated with the debug host. Defaults to `process.env.port`, or 1337 if that's not found.
  * @param {Boolean} [options.https] True if the site must use HTTPS. False by default.
  * @param {Boolean} [options.debugHttps] True if the site must use HTTPS in debug mode. False by default.
  * @param {String} [options.cert] The path to the SSL certificate to use with this tenant.
  * @param {String} [options.key] The path to the SSL key to use with this tenant.
  * @param {String} [options.pfx] The path to the pfx SSL certificate to use with this tenant.
  * @param {String} [options.debugCert] The path to the SSL certificate to use with this tenant in debug mode. Defaults to `cert`.
  * @param {String} [options.debugKey] The path to the SSL key to use with this tenant in debug mode. Defaults to `key`.
  * @param {String} [options.debugPfx] The path to the pfx SSL certificate to use with this tenant in debug mode. Defaults to `pfx`.
  * @param {Boolean} [options.active] True if the tenant is active.
  * @param {Object} [options.logger] A logger object with a log(level:string, message:string) method. If not specified, log messages go to the console.
  */
  constructor(options) {
    super();
    Object.assign(this, {
      settings: options || {},
      name: options.name || 'default',
      displayName: options.displayName || options.name,
      host: Array.isArray(options.host)
        ? options.host
        : (options.host
          ? [options.host]
          : (process.env.IP
            ? [process.env.IP]
            : ['localhost'])),
      debugHost: Array.isArray(options.debugHost)
        ? options.debugHost
        : (options.debugHost ? [options.debugHost] : ['localhost']),
      port: options.port || process.env.PORT || 80,
      debugPort: options.debugPort || process.env.PORT || 1337,
      https: !!options.https,
      debugHttps: !!options.debugHttps,
      cert: options.cert,
      key: options.key,
      pfx: options.pfx,
      debugCert: options.debugCert || options.cert,
      debugKey: options.debugKey || options.key,
      debugPfx: options.debugPfx || options.pfx,
      features: options.features || {},
      active: !(options.active === false),
      log: (options.logger ? options.logger.log : null) ||
        function log() {
          console.log.apply(null, arguments);
        }
    });
  }

  /**
   * Creates a tenant from a settings file or object, and adds it to `Tenant.list`.
   * @param {String|Object} settingsh The path of the settings file, or a settings object.
   * @param {Object} [defaults] Default settings.
   * @returns {Tenant} The new tenant.
   */
  static load(settings, defaults) {
    defaults = defaults || {};
    if (typeof settings == 'string') {
      settings.settingsPath = settings;
      settings.rootPath = settings;
      settings = require(settings);
    }
    for (let settingName of Object.getOwnPropertyNames(defaults)) {
      if (!settings.hasOwnProperty(settingName)) {
        settings[settingName] = defaults[settingName];
      }
    }
    let tenant = new Tenant(settings);
    Tenant.list[tenant.name] = tenant;
    return tenant;
  };

  /**
   * Discovers all tenants in the rootPath directory.
   * This method scans the directory for subdirectories with `settings.json` files
   * in them. Those files are then loaded as site settings.
   * @param {Object} defaults The default settings for the shells.
   * @param {String} rootPath The root path where to look for shell settings files. Defaults to "./sites".
   */
  static discover(defaults, rootPath) {
    rootPath = rootPath || './sites';
    let siteNames = fs.readdirSync(rootPath);
    siteNames.forEach(function foreachSiteName(siteName) {
      if (siteName[0] === '.') return;
      let resolvedSitePath = path.resolve(rootPath, siteName, 'settings.json');
      try {
        let settings = require(resolvedSitePath);
      }
      catch(ex) {
        ex.path = resolvedSitePath;
        ex.message = 'Failed to load site settings for ' + siteName;
        throw ex;
      }
      settings.settingsPath = settings;
      settings.rootPath = settings;
      settings.name = settings.name || siteName;
      Tenant.load(settings, defaults);
    });
  };

  /**
   * Returns the tenant that should handle this request.
   * @param {IncomingMessage} request The request.
   * @returns {Tenant} The tenant that should handle the request, null if no active tenant is fit to do it.
   */
  static resolve(request) {
    let shellNames = Object.getOwnPropertyNames(Tenant.list);
    // If there's only one shell, always return that.
    if (shellNames.length === 1) return Tenant.list[shellNames[0]];
    // Otherwise let each shell decide if it can handle the request.
    for (let i = 0; i < shellNames.length; i++) {
      let shell = Tenant.list[shellNames[i]];

      if (shell.active && shell.canHandle(request)) {
        return shell;
      }
    }
    // Unresolved requests should not go to a default shell
    // if there's more than one.
    return null;
  };

  /**
   * Starts http listeners covering for all active tenants in `Tenant.list`.
    let httpServer;
   */
  static listen() {
    let httpServers = {};
    let httpServer;
    let port = process.env.PORT || 1337;

    // If iisnode, only create one server.
    // TODO: properly handle https on IIS.
    if (process.env.IISNODE_VERSION && port.substr(0, 9) === '\\\\.\\pipe\\') {
      httpServer = new http.Server();
      httpServer.on('request', localHandler(httpServer));
      httpServer.listen(port);
      return;
    }
    // Listen for each tenant
    for (let shellName in Tenant.list) {
      let tenant = Tenant.list[shellName];
      if (!tenant.active) continue;
      let hosts = Array.isArray(tenant.host) ? tenant.host : [tenant.host];
      for (let i = 0; i < hosts.length; i++) {
        let host = hosts[i];
        let currentPort = tenant.port !== '*' ? tenant.port : port;
        // TODO: create debug servers as needed.
        let serverKey = host + currentPort + (tenant.key || "") + (tenant.cert || "") + (tenant.pfx || "");
        httpServer = httpServers.hasOwnProperty(serverKey)
          ? httpServers[serverKey]
          : httpServers[serverKey] = tenant.https
            ? https.createServer({
              host: host,
              key: tenant.key,
              cert: tenant.cert,
              pfx: tenant.pfx
            })
            : new http.Server();
        httpServer.on('request', localHandler(httpServer));
        httpServer.listen(currentPort);
        tenant.log('info', `${tenant.name} started listening on port ${currentPort}.`)
      }
    }
  }

  /**
   * Determines if the tenant can handle that request.
   * @param request The request
   * @returns {Boolean} True if the tenant can handle the request.
   */
  canHandle(request) {
    let host = request.headers.host;
    let hosts = this.host.concat(this.debugHost);
    for (let i = 0; i < hosts.length; i++) {
      let thisHost = hosts[i];
      if ((
        (
        ((this.https && this.port === 443) || this.port === 80 || this.port === '*')
        && thisHost === host
        )
        || (this.port === '*' && host.substr(0, thisHost.length) === thisHost)
        || (thisHost + ':' + this.port === host)
      ) && (
      !this.path
      || request.url.substr(0, this.path.length) === this.path
      )) {
        if (i >= this.host.length) {
          request.debug = true;
        }
        // We also set the tenant into debug mode, which could cause problems
        // if the same tenant is on a server configured and able to answer on both
        // the host and debug host. That should however never happen.
        this.debug = request.debug;
        return true;
      }
    }
    return false;
  };

  /**
   * Enables or disables the tenant.
   * @param {Boolean} state If provided, sets the enabled state of the tenant. Otherwise, enables the tenant.
   */
  enable(state) {
    this.active = typeof state === 'undefined' || !!state;
  };

  /**
   * Disables the tenant.
   */
  disable() {
    this.active = false;
  };

  /**
   * Middleware for use, for example, with Express.
   * @param {http.IncomingMessage} request Request
   * @param {http.ServerResponse} response Response
   * @param {Function} next The callback.
   */
  middleware(request, response, next) {
    if (!this.canHandle(request)) {
      next();
      return;
    }
    this.handleRequest(request, response, next);
  };

  /**
   * Handles the request for the tenant.
   * Emits the following events:
   * * decent.multi-tenancy.start-request {tenant, request, response}
   * * decent.multi-tenancy.handle-request {tenant, request, response}
   * * decent.multi-tenancy.end-request {tenant, request, response}
   * * error
   * @param request Request
   * @param response Response
   * @param {Function} next The callback
   */
  handleRequest(request, response, next) {
    let self = this;
    let log = this.log;
    // All events use the same context object.
    let context = {
      tenant: self,
      request: request,
      response: response
    };
    self.emit(Tenant.startRequestEvent, context);
    self.emit(Tenant.handleRequestEvent, context);
    self.emit(Tenant.endRequestEvent, context);
    if (request.tearDown) request.tearDown();
    this.log('info', 'Handled request', {
      tenant: self.name,
      url: request.url,
      status: response.statusCode
    });
    if (next) next();
  };
}

/**
 * An empty shell that you can use to initialize services if you don't need multitenancy.
 */
Tenant.empty = new Tenant({
  name: "Empty shell"
});

/**
 * @description
 * The list of tenants.
 */
Tenant.list = {};

function localHandler(server) {
  return function defaultHandler(req, res) {
    let d = domain.create();
    d.on('error', function onError(err) {
      console.error('Unrecoverable error', err.stack || err.message || err);
      try {
        // Close down within 30 seconds
        let killTimer = setTimeout(function processKill() {
          process.exit(1);
        }, 30000);
        // But don't keep the process open just for that!
        killTimer.unref();

        // stop taking new requests.
        server.close();

        if (res && !res.finished) {
          // try to send an error to the request that triggered the problem
          res.statusCode = 500;
          // TODO: let route handlers set headers, including powered by.
          res.end('Oops, the server choked on this request!\n');
          // TODO: broadcast the error to give loggers a chance to use it.
        }
      } catch (er2) {
        // oh well, not much we can do at this point.
        console.error('Error sending 500!', er2.stack);
        return;
      }
    });
    d.add(req);
    d.add(res);
    let tenant = Tenant.resolve(req);
    if (!tenant) {
      console.error('Could not resolve shell.', {url: req.url, host: req.headers.host});
      return;
    }
    d.add(tenant);

    // Now run the handler function in the domain.
    d.run(function runHandler() {
      tenant.handleRequest(req, res, function() {
        res.end('');
      });
    });
  }
}

/**
 * @description
 * The event that is broadcast at the beginning of request handling.
 * This is a good time to attach a service to the request object.
 * @type {string}
 */
Tenant.startRequestEvent = 'decent.multi-tenancy.start-request';
Tenant.startRequestEvent_payload = {
  tenant: 'Tenant',
  request: 'IncomingMessage',
  response: 'ServerResponse'
};

/**
 * @description
 * The event that is broadcast when the request is ready to be handled.
 * @type {string}
 */
Tenant.handleRequestEvent = 'decent.multi-tenancy.handle-request';
Tenant.handleRequestEvent_payload = {
  tenant: 'Tenant',
  request: 'IncomingMessage',
  response: 'ServerResponse'
};

/**
 * @description
 * The event that is broadcast at the end of request handling.
 * This is a good time to clean-up, detach services and events.
 * @type {string}
 */
Tenant.endRequestEvent = 'decent.multi-tenancy.end-request';
Tenant.endRequestEvent_payload = {
  tenant: 'Tenant',
  request: 'IncomingMessage',
  response: 'ServerResponse'
};

module.exports = Tenant;
<|endoftext|>


<commit_before>/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

"use strict";

/**
 * Test verifies the menu options for a default top site.
 */

test_newtab({
  before: setDefaultTopSites,
  test: async function defaultTopSites_menuOptions() {
    await ContentTaskUtils.waitForCondition(() => content.document.querySelector(".top-site-icon"),
      "Topsite tippytop icon not found");

    let contextMenuItems = content.openContextMenuAndGetOptions(".top-sites-list li:first-child").map(v => v.textContent);

    Assert.equal(contextMenuItems.length, 5, "Number of options is correct");

    const expectedItemsText = ["Pin", "Edit", "Open in a New Window", "Open in a New Private Window", "Dismiss"];

    for (let i = 0; i < contextMenuItems.length; i++) {
      Assert.equal(contextMenuItems[i], expectedItemsText[i], "Name option is correct");
    }
  }
});
<commit_msg>Fix Bug 1410828 - Add a test to verify that the next top site in queue replaces a dismissed top site<commit_after>/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

"use strict";

test_newtab({
  before: setDefaultTopSites,
  // Test verifies the menu options for a default top site.
  test: async function defaultTopSites_menuOptions() {
    await ContentTaskUtils.waitForCondition(() => content.document.querySelector(".top-site-icon"),
      "Topsite tippytop icon not found");

    let contextMenuItems = content.openContextMenuAndGetOptions(".top-sites-list li:first-child").map(v => v.textContent);

    Assert.equal(contextMenuItems.length, 5, "Number of options is correct");

    const expectedItemsText = ["Pin", "Edit", "Open in a New Window", "Open in a New Private Window", "Dismiss"];

    for (let i = 0; i < contextMenuItems.length; i++) {
      Assert.equal(contextMenuItems[i], expectedItemsText[i], "Name option is correct");
    }
  }
});

test_newtab({
  before: setDefaultTopSites,
  // Test verifies that the next top site in queue replaces a dismissed top site.
  test: async function defaultTopSites_dismiss() {
    await ContentTaskUtils.waitForCondition(() => content.document.querySelector(".top-site-icon"),
      "Topsite tippytop icon not found");

    let defaultTopSitesNumber = content.document.querySelector(".top-sites-list").querySelectorAll("[class=\"top-site-outer\"]").length;
    Assert.equal(defaultTopSitesNumber, 6, "6 top sites are loaded by default");

    let secondTopSite = content.document.querySelector(".top-sites-list li:nth-child(2) a").getAttribute("href");

    let contextMenuItems = content.openContextMenuAndGetOptions(".top-sites-list li:first-child");
    Assert.equal(contextMenuItems[4].textContent, "Dismiss", "'Dismiss' is the 5th item in the context menu list");

    contextMenuItems[4].querySelector("a").click();

    // Need to wait for dismiss action.
    await ContentTaskUtils.waitForCondition(() => content.document.querySelector(".top-sites-list li:first-child a").getAttribute("href") === secondTopSite,
      "First topsite was dismissed");

    defaultTopSitesNumber = content.document.querySelector(".top-sites-list").querySelectorAll("[class=\"top-site-outer\"]").length;
    Assert.equal(defaultTopSitesNumber, 5, "5 top sites are displayed after one of them is dismissed");
  },
  async after() {
    await new Promise(resolve => NewTabUtils.undoAll(resolve));
  }
});
<|endoftext|>


<commit_before>define('doc', ['event'], function(event) {
	var matcher = {
		isTag : function(selector) {
			return selector.match(/^\w+$/);
		},
		isClass : function(selector) {
			return selector.match(/^\.[\w-]+$/);
		},
		isId : function(selector) {
			return selector.match(/^#[\w-]+$/);
		}
	};

	var search = function(namespace, selector) {
		var selector = selector.replace(/^\s+|\s+$/g, '');
		if(matcher.isTag(selector)) {
			return convertToArray(namespace.getElementsByTagName(selector));
		} else if(matcher.isId(selector)) {
			selector = selector.replace('#', '');
			return document.getElementById(selector);
		} else if (matcher.isClass(selector) && namespace.getElementsByClassName) {
			selector = selector.replace('.', '');
			return convertToArray(namespace.getElementsByClassName(selector));
		}
		return convertToArray(namespace.querySelectorAll(selector));
	};

	var convertToArray = function(nodeList) {
		try {
			return Array.prototype.slice.call(namespace.getElementsByTagName(selector));
		} catch(e) {
			var array = [];
			var length = nodeList.length;
			for(var i = 0; i < length; i++) {
				array.push(nodeList[i]);
			}
			return array;
		}
	};

	var fallbackHasClass = function(el, clazz) {
		return el.className.match(new RegExp('(^|\\s)' + clazz + '($|\\s)'));
	};

	var query = function(elements) {
		var selectedElements = [];

		if (elements){
			if (elements instanceof Array) {
				selectedElements = elements;
			} else {
				selectedElements.push(elements);
			}
		}

		return {
			'els' : selectedElements,
			'size' : selectedElements.length,

			'each' : function(command) {
				for(var i = 0; i < this.size; i++) {
					command(this.els[i], i);
				}
			},

			'data' : function(key, value) {
				// Precisa ser assim, pois pode vir string vazia e deve entrar nesse if
				if(value !== undefined) {
					this.each(function(el) {
						el.setAttribute("data-" + key, value);
					});
					return this;
				}
				if(this.first()) {
					return this.first().getAttribute("data-" + key);
				}
				return "";
			},

			'val' : function(newValue) {
				// Precisa ser assim, pois pode vir string vazia e deve entrar nesse if
				if(newValue !== undefined) {
					this.each(function(el) {
						el.value = newValue;
					});
					return this;
				}
				if(this.first()) {
					return this.first().value;
				}
				return "";
			},

			'html' : function(newValue) {
				if(newValue !== undefined) {
					this.each(function(el) {
						el.innerHTML = newValue;
					});
					return this;
				}
				if(this.first()) {
					return this.first().innerHTML;
				}
				return "";
			},

			'append' : function(value) {
				var appendElement;
				this.each(function(el) {
					appendElement = el.appendChild(value);
				});
				return query(appendElement);
			},

			'text' : function(val) {
				var hasInnerText = (document.getElementsByTagName("body")[0].innerText != undefined) ? true : false;

				var currentValue = "";
				this.each(function(el) {
					if (typeof val === 'undefined') {
						currentValue = el.textContent || el.innerText;
					} else {
						if (!hasInnerText) {
							el.textContent = val;
						} else {
							el.innerText = val;
						}
					}
				});
				return currentValue.trim();
			},

			'attr' : function(key, newValue) {
				// Precisa ser assim, pois pode vir string vazia e deve entrar nesse if
				if(newValue !== undefined) {
					this.each(function(el) {
						el.setAttribute(key, newValue);
					});
					return this;
				}
				return this.first().getAttribute(key);
			},

			'hasClass' : function(clazz) {
				var containsClass = false;
				this.each(function(el){
					if(!el.classList) {
						if(fallbackHasClass(el, clazz)) {
							containsClass = true;
						}
					} else if(el.classList.contains(clazz)){
						containsClass = true;
					}
				});
				return containsClass;
			},

			'addClass' : function(clazz) {
				this.each(function(el) {
					if(!el.classList) {
						if(!fallbackHasClass(el, clazz)) {
							el.className += ' ' + clazz + ' ';
						}
					} else {
						el.classList.add(clazz);
					}
				});
				return this;
			},

			'removeClass' : function(clazz) {
				this.each(function(el) {
					if(!el.classList) {
						el.className = el.className.replace(new RegExp('(^|\\s)' + clazz + '($|\\s)'), '');
					} else {
						el.classList.remove(clazz);
					}
				});
				return this;
			},

			'toggleClass' : function(clazz) {
				this.each(function(el) {
					if(!el.classList) {
						var element = query(el);
						if(element.hasClass(clazz)) {
							element.removeClass(clazz);
						} else {
							element.addClass(clazz);
						}
					} else {
						el.classList.toggle(clazz);
					}
				});
				return this;
			},

			'removeItem' : function() {
				this.each(function(el, i){
					if(!el.remove){
						el.parentNode.removeChild(el);
					}else{
						el.remove();
					}
				});
				this.els = [];
				this.size = 0;
				return this;
			},

			'find' : function(selector) {
				var list = [];
				this.each(function(el) {
					var newElement = search(el, selector);
					if(list.indexOf(newElement) === -1) {
						list = list.concat(search(el, selector));
					}
				});
				return query(list);
			},

			'filter': function(filter) {
				var result = [];

				for(var i = 0; i < this.size; i++) {
					if(typeof filter === 'function' && filter(this.els[i])) {
						result.push(this.els[i]);
					} else if(typeof filter === 'string' && this.els[i][filter]) {
						result.push(this.els[i]);
					}
				}
				return query(result);
			},

			'closest' : function(selector) {
				var elements = [];

				var selectorType;

				if (matcher.isTag(selector)) {
					selectorType = 'tag';
				} else if (matcher.isClass(selector)) {
					selector = selector.replace('.', '');
					selectorType = 'class';
				} else if (matcher.isId(selector)) {
					selector = selector.replace('#', '');
					selectorType = 'id';
				}

				if (!selectorType) {
					return null;
				}

				var checkForClosestParent = function(element) {
					if (element) {
						switch (selectorType) {
							case 'tag':
								return (selector.toUpperCase() === element.tagName)
									? element
									: checkForClosestParent(element.parentElement);
								break;
							case 'class':
								return ($(element).hasClass(selector))
									? element
									: checkForClosestParent(element.parentElement);
								break;
							case 'id':
								return (element.id === selector)
									? element
									: checkForClosestParent(element.parentElement);
								break;
							default:
								break;
						}
					}
					return null;
				};

				for (var i = 0; i < this.size; i++) {
					var el = this.els[i].parentElement;

					var closestParent = checkForClosestParent(el);
					if (closestParent) {
						elements.push(closestParent);
					}
				}

				for (var i = elements.length - 1; i > 0; i--) {
					var elementToBeChecked = elements[i];
					for (var j = i-1; j >= 0; j--) {
						var elementToBeCheckedWith = elements[j];
						if (elementToBeChecked == elementToBeCheckedWith) {
							elements.pop();
							break;
						}
					}
				}

				return elements;
			},

			'find' : function(selector) {
				var list = [];
				this.each(function(el) {
					var newElement = search(el, selector);
					if(list.indexOf(newElement) === -1) {
						list = list.concat(search(el, selector));
					}
				});
				return query(list);
			},

			'first' : function() {
				return this.els[0];
			},

			'last' : function() {
				return this.els[this.size - 1];
			},

			'previous' : function() {
				var el = this.els[0];
			    if(el.previousElementSibling) {
			        return query(el.previousElementSibling);
			    } else {
			        while(el = el.previousSibling) {
			            if(el.nodeType === 1) return query(el);
			        }
			    }
			},

			'next' : function() {
				var el = this.els[0];
				if(el.nextElementSibling) {
					return query(el.nextElementSibling);
				} else {
					while(el = el.nextSibling) {
						if(el.nodeType === 1) return query(el);
					}
				}
			},

			'parent' : function() {
				var parents = [];
				for(var i = 0; i < this.size; i++) {
					parents.push(this.els[i].parentElement);
				}
				return query(parents);
			},

			'isEmpty' : function() {
				return this.els === undefined || !this.els.length;
			},

			'on' : function(eventName, command, named) {
				this.each(function(el) {
					event.addEvent(el, eventName, command, named);
				});
				return this;
			},

			'throttle' : function(eventName, command, throttleTime, named) {
				throttleTime = throttleTime || 1000;
				var previous = 0;
				var commandWithThrottle = function(event) {
					var now = + new Date();
					if(previous + throttleTime <= now) {
						command.apply(this, arguments);
						previous = + new Date();
					}
					event.preventDefault();
				}

				return this.on(eventName, commandWithThrottle, named);
			},

			'off' : function(eventName, named) {
				this.each(function(el) {
					event.removeEvent(el, eventName, named);
				});
				return this;
			},

			'selectedText' : function() {
				var input = this.els[0];
				var type = this.attr('type');
				var validSelectionStart = type? type.match('(text|search|password|tel|url)') : false;
				if(document.activeElement !== input) {
					return;
				} else if(validSelectionStart && typeof input.selectionStart !== 'undefined' && input.selectionEnd > 0) {
					return input.value.substring(input.selectionStart, input.selectionEnd);
				} else if (window.getSelection) {
					return window.getSelection().toString();
				} else if (document.getSelection) {
					return document.getSelection().toString();
				} else if (document.selection) {
					if(document.selection.createRange().text) {
						return document.selection.createRange().text;
					}
					return document.selection.createRange().htmlText;
				} else {
					return;
				}
			}
		}
	}

	return function(selector) {
		if(!selector) {
			return selector;
		} else if(typeof selector === "string") {
			return query(search(document, selector));
		}
		return query(selector);
	};
});
<commit_msg>returing as a Doc object<commit_after>define('doc', ['event'], function(event) {
	var matcher = {
		isTag : function(selector) {
			return selector.match(/^\w+$/);
		},
		isClass : function(selector) {
			return selector.match(/^\.[\w-]+$/);
		},
		isId : function(selector) {
			return selector.match(/^#[\w-]+$/);
		}
	};

	var search = function(namespace, selector) {
		var selector = selector.replace(/^\s+|\s+$/g, '');
		if(matcher.isTag(selector)) {
			return convertToArray(namespace.getElementsByTagName(selector));
		} else if(matcher.isId(selector)) {
			selector = selector.replace('#', '');
			return document.getElementById(selector);
		} else if (matcher.isClass(selector) && namespace.getElementsByClassName) {
			selector = selector.replace('.', '');
			return convertToArray(namespace.getElementsByClassName(selector));
		}
		return convertToArray(namespace.querySelectorAll(selector));
	};

	var convertToArray = function(nodeList) {
		try {
			return Array.prototype.slice.call(namespace.getElementsByTagName(selector));
		} catch(e) {
			var array = [];
			var length = nodeList.length;
			for(var i = 0; i < length; i++) {
				array.push(nodeList[i]);
			}
			return array;
		}
	};

	var fallbackHasClass = function(el, clazz) {
		return el.className.match(new RegExp('(^|\\s)' + clazz + '($|\\s)'));
	};

	var query = function(elements) {
		var selectedElements = [];

		if (elements){
			if (elements instanceof Array) {
				selectedElements = elements;
			} else {
				selectedElements.push(elements);
			}
		}

		return {
			'els' : selectedElements,
			'size' : selectedElements.length,

			'each' : function(command) {
				for(var i = 0; i < this.size; i++) {
					command(this.els[i], i);
				}
			},

			'data' : function(key, value) {
				// Precisa ser assim, pois pode vir string vazia e deve entrar nesse if
				if(value !== undefined) {
					this.each(function(el) {
						el.setAttribute("data-" + key, value);
					});
					return this;
				}
				if(this.first()) {
					return this.first().getAttribute("data-" + key);
				}
				return "";
			},

			'val' : function(newValue) {
				// Precisa ser assim, pois pode vir string vazia e deve entrar nesse if
				if(newValue !== undefined) {
					this.each(function(el) {
						el.value = newValue;
					});
					return this;
				}
				if(this.first()) {
					return this.first().value;
				}
				return "";
			},

			'html' : function(newValue) {
				if(newValue !== undefined) {
					this.each(function(el) {
						el.innerHTML = newValue;
					});
					return this;
				}
				if(this.first()) {
					return this.first().innerHTML;
				}
				return "";
			},

			'append' : function(value) {
				var appendElement;
				this.each(function(el) {
					appendElement = el.appendChild(value);
				});
				return query(appendElement);
			},

			'text' : function(val) {
				var hasInnerText = (document.getElementsByTagName("body")[0].innerText != undefined) ? true : false;

				var currentValue = "";
				this.each(function(el) {
					if (typeof val === 'undefined') {
						currentValue = el.textContent || el.innerText;
					} else {
						if (!hasInnerText) {
							el.textContent = val;
						} else {
							el.innerText = val;
						}
					}
				});
				return currentValue.trim();
			},

			'attr' : function(key, newValue) {
				// Precisa ser assim, pois pode vir string vazia e deve entrar nesse if
				if(newValue !== undefined) {
					this.each(function(el) {
						el.setAttribute(key, newValue);
					});
					return this;
				}
				return this.first().getAttribute(key);
			},

			'hasClass' : function(clazz) {
				var containsClass = false;
				this.each(function(el){
					if(!el.classList) {
						if(fallbackHasClass(el, clazz)) {
							containsClass = true;
						}
					} else if(el.classList.contains(clazz)){
						containsClass = true;
					}
				});
				return containsClass;
			},

			'addClass' : function(clazz) {
				this.each(function(el) {
					if(!el.classList) {
						if(!fallbackHasClass(el, clazz)) {
							el.className += ' ' + clazz + ' ';
						}
					} else {
						el.classList.add(clazz);
					}
				});
				return this;
			},

			'removeClass' : function(clazz) {
				this.each(function(el) {
					if(!el.classList) {
						el.className = el.className.replace(new RegExp('(^|\\s)' + clazz + '($|\\s)'), '');
					} else {
						el.classList.remove(clazz);
					}
				});
				return this;
			},

			'toggleClass' : function(clazz) {
				this.each(function(el) {
					if(!el.classList) {
						var element = query(el);
						if(element.hasClass(clazz)) {
							element.removeClass(clazz);
						} else {
							element.addClass(clazz);
						}
					} else {
						el.classList.toggle(clazz);
					}
				});
				return this;
			},

			'removeItem' : function() {
				this.each(function(el, i){
					if(!el.remove){
						el.parentNode.removeChild(el);
					}else{
						el.remove();
					}
				});
				this.els = [];
				this.size = 0;
				return this;
			},

			'find' : function(selector) {
				var list = [];
				this.each(function(el) {
					var newElement = search(el, selector);
					if(list.indexOf(newElement) === -1) {
						list = list.concat(search(el, selector));
					}
				});
				return query(list);
			},

			'filter': function(filter) {
				var result = [];

				for(var i = 0; i < this.size; i++) {
					if(typeof filter === 'function' && filter(this.els[i])) {
						result.push(this.els[i]);
					} else if(typeof filter === 'string' && this.els[i][filter]) {
						result.push(this.els[i]);
					}
				}
				return query(result);
			},

			'closest' : function(selector) {
				var elements = [];

				var selectorType;

				if (matcher.isTag(selector)) {
					selectorType = 'tag';
				} else if (matcher.isClass(selector)) {
					selector = selector.replace('.', '');
					selectorType = 'class';
				} else if (matcher.isId(selector)) {
					selector = selector.replace('#', '');
					selectorType = 'id';
				}

				if (!selectorType) {
					return null;
				}

				var checkForClosestParent = function(element) {
					if (element) {
						switch (selectorType) {
							case 'tag':
								return (selector.toUpperCase() === element.tagName)
									? element
									: checkForClosestParent(element.parentElement);
								break;
							case 'class':
								return ($(element).hasClass(selector))
									? element
									: checkForClosestParent(element.parentElement);
								break;
							case 'id':
								return (element.id === selector)
									? element
									: checkForClosestParent(element.parentElement);
								break;
							default:
								break;
						}
					}
					return null;
				};

				for (var i = 0; i < this.size; i++) {
					var el = this.els[i].parentElement;

					var closestParent = checkForClosestParent(el);
					if (closestParent) {
						elements.push(closestParent);
					}
				}

				for (var i = elements.length - 1; i > 0; i--) {
					var elementToBeChecked = elements[i];
					for (var j = i-1; j >= 0; j--) {
						var elementToBeCheckedWith = elements[j];
						if (elementToBeChecked == elementToBeCheckedWith) {
							elements.pop();
							break;
						}
					}
				}

				return query(elements);
			},

			'find' : function(selector) {
				var list = [];
				this.each(function(el) {
					var newElement = search(el, selector);
					if(list.indexOf(newElement) === -1) {
						list = list.concat(search(el, selector));
					}
				});
				return query(list);
			},

			'first' : function() {
				return this.els[0];
			},

			'last' : function() {
				return this.els[this.size - 1];
			},

			'previous' : function() {
				var el = this.els[0];
			    if(el.previousElementSibling) {
			        return query(el.previousElementSibling);
			    } else {
			        while(el = el.previousSibling) {
			            if(el.nodeType === 1) return query(el);
			        }
			    }
			},

			'next' : function() {
				var el = this.els[0];
				if(el.nextElementSibling) {
					return query(el.nextElementSibling);
				} else {
					while(el = el.nextSibling) {
						if(el.nodeType === 1) return query(el);
					}
				}
			},

			'parent' : function() {
				var parents = [];
				for(var i = 0; i < this.size; i++) {
					parents.push(this.els[i].parentElement);
				}
				return query(parents);
			},

			'isEmpty' : function() {
				return this.els === undefined || !this.els.length;
			},

			'on' : function(eventName, command, named) {
				this.each(function(el) {
					event.addEvent(el, eventName, command, named);
				});
				return this;
			},

			'throttle' : function(eventName, command, throttleTime, named) {
				throttleTime = throttleTime || 1000;
				var previous = 0;
				var commandWithThrottle = function(event) {
					var now = + new Date();
					if(previous + throttleTime <= now) {
						command.apply(this, arguments);
						previous = + new Date();
					}
					event.preventDefault();
				}

				return this.on(eventName, commandWithThrottle, named);
			},

			'off' : function(eventName, named) {
				this.each(function(el) {
					event.removeEvent(el, eventName, named);
				});
				return this;
			},

			'selectedText' : function() {
				var input = this.els[0];
				var type = this.attr('type');
				var validSelectionStart = type? type.match('(text|search|password|tel|url)') : false;
				if(document.activeElement !== input) {
					return;
				} else if(validSelectionStart && typeof input.selectionStart !== 'undefined' && input.selectionEnd > 0) {
					return input.value.substring(input.selectionStart, input.selectionEnd);
				} else if (window.getSelection) {
					return window.getSelection().toString();
				} else if (document.getSelection) {
					return document.getSelection().toString();
				} else if (document.selection) {
					if(document.selection.createRange().text) {
						return document.selection.createRange().text;
					}
					return document.selection.createRange().htmlText;
				} else {
					return;
				}
			}
		}
	}

	return function(selector) {
		if(!selector) {
			return selector;
		} else if(typeof selector === "string") {
			return query(search(document, selector));
		}
		return query(selector);
	};
});
<|endoftext|>


<commit_before>define([
	'./compose',
	'./has'
], function (compose, has) {
	'use strict';

	if (has('host-browser')) {
		return document;
	}
	else {
		var emptyElements = {};
		[
			'base',
			'link',
			'meta',
			'hr',
			'br',
			'wbr',
			'img',
			'embed',
			'param',
			'source',
			'track',
			'area',
			'col',
			'input',
			'keygen',
			'command'
		].forEach(function (tag) {
			emptyElements[tag] = true;
		});

		var currentIndentation = '';

		var property = compose.property;

		var body;

		var Element = compose(function (tag) {
			this.tag = tag;
		}, {
			nodeType: 1,
			toString: function (noClose) {
				var tag = this.tag,
					emptyElement = emptyElements[tag];
				if (doc.indentation && !noClose) {
					// using pretty printing for indentation
					var lastIndentation = currentIndentation;
					currentIndentation += doc.indentation;
					var html = (tag === 'html' ? '<!DOCTYPE html>\n<html' : '\n' + lastIndentation + '<' + tag) +
						(this.attributes ? ' class="' + this.className + '"' : '') + '>' +
						(this.children ? this.children.join('') : '') +
						(!this.mixed && !emptyElement && this.children ? '\n' + lastIndentation : '') +
						(emptyElement ? '' : ('</' + tag + '>'));

					currentIndentation = lastIndentation;
					return html;
				}
				return (this.tag === 'html' ? '<!DOCTYPE html>\n<html' : '<' + this.tag) +
					(this.attributes ? this.attributes.join('') : '') +
					(this.className ? ' class="' + this.className + '"' : '') + '>' +
					(this.children ? this.children.join('') : '') +
					((noClose || emptyElement) ? '' : ('</' + tag + '>'));
			},
			sendTo: function (stream) {
				if (typeof stream === 'function') {
					stream = {
						write: stream,
						end: stream
					};
				}
				var active = this,
					streamIndentation = '';

				function pipe(element) {
					// TODO: Perhaps consider buffering if it is any faster and having a non-indentation version that is faster
					var closing = returnTo(this);
					if (closing) {
						stream.write(closing);
					}
					if (element.tag) {
						if (doc.indentation) {
							stream.write('\n' + streamIndentation + element.toString(true));
							streamIndentation += doc.indentation;
						}
						else {
							stream.write(element.toString(true));
						}
						this.children = true;
						active = element;
						element.pipe = pipe;
					}
					else {
						stream.write(element.toString());
					}
				}

				function returnTo(element) {
					var output = '';
					while (active !== element) {
						if (!active) {
							throw new Error('Can not add to an element that has already been streamed.');
						}
						var tag = active.tag,
							emptyElement = emptyElement[tag];

						if (doc.indentation) {
							streamIndentation = streamIndentation.slice(doc.indentation.length);
							if (!emptyElement) {
								output += ((active.mixed || !active.children) ? '' : '\n' + streamIndentation) +
									'</' + tag + '>';
							}
						}
						else if (!emptyElement) {
							output += '</' + tag + '>';
						}
						active = active.parentNode;
					}
					return output;
				}

				pipe.call(this, this);
				// add on end() to close all the tags and close the stream
				this.end = function (leaveStreamOpen) {
					stream[leaveStreamOpen ? 'write' : 'end'](returnTo(this) + '\n</' + this.tag + '>');
				};
				return this;
			},
			children: false,
			attributes: false,
			insertBefore: function (child, reference) {
				child.parentNode = this;
				if (this.pipe) {
					return this.pipe(child);
				}
				var children = this.children;
				if (!children) {
					children = this.children = [];
				}
				if (reference) {
					for (var i = 0; i < children.length; i++) {
						if (reference === children[i]) {
							child.nextSibling = reference;
							if (i > 0) {
								children[i - 1].nextSibling = child;
							}
							return children.splice(i, 0, child);
						}
					}
				}
				if (children.length > 0) {
					children[children.length - 1].nextSibling = child;
				}
				children.push(child);
			},
			appendChild: function (child) {
				if (typeof child === 'string') {
					this.mixed = true;
				}
				if (this.pipe) {
					return this.pipe(child);
				}
				var children = this.children;
				if (!children) {
					children = this.children = [];
				}
				children.push(child);
			},
			setAttribute: function (name, value/*, escape*/) {
				var attributes = this.attributes || (this.attributes = []);
				attributes.push(' ' + name + '="' + value + '"');
			},
			removeAttribute: function (name) {
				var attributes = this.attributes;
				if (!attributes) {
					return;
				}
				var match = ' ' + name + '=',
					matchLength = match.length;
				for (var i = 0; i < attributes.length; i++) {
					if (attributes[i].slice(0, matchLength) === match) {
						return attributes.splice(i, 1);
					}
				}
			},
			querySelectorAll: function (selector) {
				// TODO
			},
			matches: function (selector) {
				// TODO
			},
			innerHTML: property({
				get: function () {
					return this.children.join('');
				},
				set: function (value) {
					this.mixed = true;
					if (this.pipe) {
						return this.pipe(value);
					}
					this.children = [value];
				}
			})
		});

		var DocumentFragment = compose(Element, {
			toString: function () {
				return this.children ? this.children.join('') : '';
			}
		});

		var lessThanRE = /</g,
			ampersandRE = /&/g,
			namespacePrefixes = {};

		var doc = Object.create(Object.prototype, {
			createElement: {
				value: function (tag) {
					return new Element(tag);
				},
				enumerable: true
			},
			createElementNS: {
				value: function (uri, tag) {
					return new Element(namespacePrefixes[uri] + ':' + tag);
				},
				enumerable: true
			},
			createTextNode: {
				value: function (value) {
					return (typeof value === 'string' ? value : ('' + value)).replace(lessThanRE, '<')
						.replace(ampersandRE, '&');
				},
				enumerable: true
			},
			createDocumentFragment: {
				value: function () {
					return new DocumentFragment();
				},
				enumerable: true
			},
			getElementById: {
				value: function (id) {
					// TODO
				},
				enumerable: true
			},
			querySelectorAll: {
				value: function (selector) {
					// TODO
				},
				enumerable: true
			},
			body: {
				get: function () {
					if (!body) {
						body = new Element('body');
					}
					return body;
				},
				enumerable: true
			},
			indentation: {
				value: '    ',
				writable: true
			}
		});

		return doc;
	}
});<commit_msg>More API fleshout for doc<commit_after>define([
	'./compose',
	'./has'
], function (compose, has) {
	'use strict';

	if (has('host-browser')) {
		return document;
	}
	else {
		var emptyElements = {};
		[
			'base',
			'link',
			'meta',
			'hr',
			'br',
			'wbr',
			'img',
			'embed',
			'param',
			'source',
			'track',
			'area',
			'col',
			'input',
			'keygen',
			'command'
		].forEach(function (tag) {
			emptyElements[tag] = true;
		});

		var currentIndentation = '';

		var property = compose.property;

		var body;

		var Element = compose(function (tag) {
			this.tag = tag;
		}, {
			nodeType: 1,
			toString: function (noClose) {
				var tag = this.tag,
					emptyElement = emptyElements[tag];
				if (doc.indentation && !noClose) {
					// using pretty printing for indentation
					var lastIndentation = currentIndentation;
					currentIndentation += doc.indentation;
					var html = (tag === 'html' ? '<!DOCTYPE html>\n<html' : '\n' + lastIndentation + '<' + tag) +
						(this.attributes ? ' class="' + this.className + '"' : '') + '>' +
						(this.children ? this.children.join('') : '') +
						(!this.mixed && !emptyElement && this.children ? '\n' + lastIndentation : '') +
						(emptyElement ? '' : ('</' + tag + '>'));

					currentIndentation = lastIndentation;
					return html;
				}
				return (this.tag === 'html' ? '<!DOCTYPE html>\n<html' : '<' + this.tag) +
					(this.attributes ? this.attributes.join('') : '') +
					(this.className ? ' class="' + this.className + '"' : '') + '>' +
					(this.children ? this.children.join('') : '') +
					((noClose || emptyElement) ? '' : ('</' + tag + '>'));
			},
			sendTo: function (stream) {
				if (typeof stream === 'function') {
					stream = {
						write: stream,
						end: stream
					};
				}
				var active = this,
					streamIndentation = '';

				function pipe(element) {
					// TODO: Perhaps consider buffering if it is any faster and having a non-indentation version that is faster
					var closing = returnTo(this);
					if (closing) {
						stream.write(closing);
					}
					if (element.tag) {
						if (doc.indentation) {
							stream.write('\n' + streamIndentation + element.toString(true));
							streamIndentation += doc.indentation;
						}
						else {
							stream.write(element.toString(true));
						}
						this.children = true;
						active = element;
						element.pipe = pipe;
					}
					else {
						stream.write(element.toString());
					}
				}

				function returnTo(element) {
					var output = '';
					while (active !== element) {
						if (!active) {
							throw new Error('Can not add to an element that has already been streamed.');
						}
						var tag = active.tag,
							emptyElement = emptyElement[tag];

						if (doc.indentation) {
							streamIndentation = streamIndentation.slice(doc.indentation.length);
							if (!emptyElement) {
								output += ((active.mixed || !active.children) ? '' : '\n' + streamIndentation) +
									'</' + tag + '>';
							}
						}
						else if (!emptyElement) {
							output += '</' + tag + '>';
						}
						active = active.parentNode;
					}
					return output;
				}

				pipe.call(this, this);
				// add on end() to close all the tags and close the stream
				this.end = function (leaveStreamOpen) {
					stream[leaveStreamOpen ? 'write' : 'end'](returnTo(this) + '\n</' + this.tag + '>');
				};
				return this;
			},
			firstChild: property({
				get: function () {
					return this.children && this.children.length ? this.children[0] : null;
				},
				enumerable: true
			}),
			children: false,
			attributes: false,
			insertBefore: function (child, reference) {
				child.parentNode = this;
				if (this.pipe) {
					return this.pipe(child);
				}
				var children = this.children;
				if (!children) {
					children = this.children = [];
				}
				if (reference) {
					for (var i = 0; i < children.length; i++) {
						if (reference === children[i]) {
							child.nextSibling = reference;
							if (i > 0) {
								children[i - 1].nextSibling = child;
							}
							return children.splice(i, 0, child);
						}
					}
				}
				if (children.length > 0) {
					children[children.length - 1].nextSibling = child;
				}
				children.push(child);
			},
			appendChild: function (child) {
				if (typeof child === 'string') {
					this.mixed = true;
				}
				if (this.pipe) {
					return this.pipe(child);
				}
				var children = this.children;
				if (!children) {
					children = this.children = [];
				}
				children.push(child);
			},
			removeChild: function (child) {
				if (this.children && this.children.length) {
					var idx = this.children.indexOf(child);
					if (~idx) {
						return this.children.splice(idx, 1);
					}
				}
				throw new Error('Not a child of node.');
			},
			setAttribute: function (name, value/*, escape*/) {
				var attributes = this.attributes || (this.attributes = []);
				attributes.push(' ' + name + '="' + value + '"');
			},
			removeAttribute: function (name) {
				var attributes = this.attributes;
				if (!attributes) {
					return;
				}
				var match = ' ' + name + '=',
					matchLength = match.length;
				for (var i = 0; i < attributes.length; i++) {
					if (attributes[i].slice(0, matchLength) === match) {
						return attributes.splice(i, 1);
					}
				}
			},
			querySelectorAll: function (selector) {
				// TODO
			},
			matches: function (selector) {
				// TODO
			},
			innerHTML: property({
				get: function () {
					return this.children.join('');
				},
				set: function (value) {
					this.mixed = true;
					if (this.pipe) {
						return this.pipe(value);
					}
					this.children = [value];
				}
			})
		});

		var DocumentFragment = compose(Element, {
			toString: function () {
				return this.children ? this.children.join('') : '';
			}
		});

		var lessThanRE = /</g,
			ampersandRE = /&/g,
			namespacePrefixes = {};

		var doc = Object.create(Object.prototype, {
			createElement: {
				value: function (tag) {
					return new Element(tag);
				},
				enumerable: true
			},
			createElementNS: {
				value: function (uri, tag) {
					return new Element(namespacePrefixes[uri] + ':' + tag);
				},
				enumerable: true
			},
			createTextNode: {
				value: function (value) {
					return (typeof value === 'string' ? value : ('' + value)).replace(lessThanRE, '<')
						.replace(ampersandRE, '&');
				},
				enumerable: true
			},
			createDocumentFragment: {
				value: function () {
					return new DocumentFragment();
				},
				enumerable: true
			},
			getElementById: {
				value: function (id) {
					// TODO
				},
				enumerable: true
			},
			querySelectorAll: {
				value: function (selector) {
					// TODO
				},
				enumerable: true
			},
			body: {
				get: function () {
					if (!body) {
						body = new Element('body');
					}
					return body;
				},
				enumerable: true
			},
			indentation: {
				value: '    ',
				writable: true
			}
		});

		return doc;
	}
});<|endoftext|>


<commit_before>import { html, LitElement } from 'lit-element';
import { nothing } from 'lit-html';
import SearchProvider from './providers/search.js';
import DownloadProvider from './providers/download.js';
import VisualAdjustmentProvider from './providers/visual-adjustments.js';
import SharingProvider from '../ItemNavigator/providers/sharing.js';
import BookmarksProvider from './providers/bookmarks.js';
import BRFullscreenMgr from './br-fullscreen-mgr.js';
import { Book } from './BookModel.js';
// import '../ItemNavigator/ItemNavigator.js';
import bookLoader from './book-loader.js';
import navigatorCSS from './styles/book-navigator.js';

const events = {
  menuUpdated: 'menuUpdated',
  updateSideMenu: 'updateSideMenu',
  ViewportInFullScreen: 'ViewportInFullScreen',
};
export class BookNavigator extends LitElement {
  static get styles() {
    return navigatorCSS;
  }

  static get properties() {
    return {
      book: { type: Object },
      mainBRSelector: { type: String },
      pageContainerSelector: { type: String },
      brWidth: { type: Number },
      bookReaderLoaded: { type: Boolean },
      bookreader: { type: Object },
      downloadableTypes: { type: Array },
      isAdmin: { type: Boolean },
      lendingInitialized: { type: Boolean },
      lendingStatus: { type: Object },
      menuProviders: { type: Object },
      menuShortcuts: { type: Array },
      sideMenuOpen: { type: Boolean },
      signedIn: { type: Boolean },
    };
  }

  constructor() {
    super();
    this.book = {};
    this.mainBRSelector = '#BookReader';
    this.pageContainerSelector = '.BRcontainer';
    this.brWidth = 0;
    this.bookReaderCannotLoad = false;
    this.bookReaderLoaded = false;
    this.bookreader = null;
    this.downloadableTypes = [];
    this.isAdmin = false;
    this.lendingInitialized = false;
    this.lendingStatus = {};
    this.menuProviders = {};
    this.menuShortcuts = [];
    this.sideMenuOpen = false;
    this.signedIn = false;

    // Untracked properties
    this.fullscreenMgr = new BRFullscreenMgr();
    this.model = new Book();
    this.shortcutOrder = ['volumes', 'search', 'bookmarks'];
  }

  firstUpdated() {
    this.model.setMetadata(this.book);
    this.bindEventListeners();
  }

  /**
   * Instantiates books submenus & their update callbacks
   *
   * NOTE: we are doing our best to scope bookreader's instance.
   * If your submenu provider uses a bookreader instance to read, manually
   * manipulate BookReader, please update the navigator's instance of it
   * to keep it in sync.
   */
  initializeBookSubmenus() {
    this.menuProviders = {
      search: new SearchProvider(
        (brInstance = null) => {
          if (brInstance) {
            /* refresh br instance reference */
            this.bookreader = brInstance;
          }
          this.updateMenuContents();
          if (this.brWidth >= 640) { /* open side search menu */
            this.openSideSearchMenu();
          }
        },
        this.bookreader,
      ),
      downloads: new DownloadProvider(),
      visualAdjustments: new VisualAdjustmentProvider({
        onOptionChange: (event, brInstance = null) => {
          if (brInstance) {
            /* refresh br instance reference */
            this.bookreader = brInstance;
          }
          this.updateMenuContents();
        },
        bookContainerSelector: this.pageContainerSelector,
        bookreader: this.bookreader,
      }),
      share: new SharingProvider(this.book.metadata, this.baseHost, this.itemType),
    };

    if (this.signedIn) {
      this.menuProviders.bookmarks = new BookmarksProvider(this.bookmarksOptions, this.bookreader);
    }

    this.addMenuShortcut('search'); /* start with search as a shortcut */
    this.updateMenuContents();
  }

  /** gets element that houses the bookreader in light dom */
  get mainBRContainer() {
    return document.querySelector(this.mainBRSelector);
  }

  get bookmarksOptions() {
    return {
      showItemNavigatorModal: this.showItemNavigatorModal.bind(this),
      closeItemNavigatorModal: this.closeItemNavigatorModal.bind(this),
      onBookmarksChanged: (bookmarks) => {
        const method = Object.keys(bookmarks).length ? 'add' : 'remove';
        this[`${method}MenuShortcut`]('bookmarks');
        this.updateMenuContents();
      },
    };
  }

  /**
   * Open side search menu
   */
  openSideSearchMenu() {
    const event = new CustomEvent(
      events.updateSideMenu, {
        detail: { menuId: 'search', action: 'open' },
      },
    );
    this.dispatchEvent(event);
  }

  /**
   * Sets order of menu and emits custom event when done
   */
  updateMenuContents() {
    const {
      search, downloads, visualAdjustments, share, bookmarks,
    } = this.menuProviders;
    const availableMenus = [search, bookmarks, visualAdjustments, share].filter((menu) => !!menu);

    if (this.shouldShowDownloadsMenu()) {
      downloads.update(this.downloadableTypes);
      availableMenus.splice(1, 0, downloads);
    }

    const event = new CustomEvent(
      events.menuUpdated, {
        detail: availableMenus,
      },
    );
    this.dispatchEvent(event);
  }

  /**
   * Confirms if we should show the downloads menu
   * @returns {bool}
   */
  shouldShowDownloadsMenu() {
    if (this.model.isRestricted === false) { return true; }
    if (this.isAdmin) { return true; }
    const { user_loan_record = {} } = this.lendingStatus;
    const hasNoLoanRecord = Array.isArray(user_loan_record); /* (bc PHP assoc. arrays) */

    if (hasNoLoanRecord) { return false; }

    const hasValidLoan = user_loan_record.type && (user_loan_record.type !== 'SESSION_LOAN');
    return hasValidLoan;
  }

  /**
   * Adds a provider object to the menuShortcuts array property if it isn't
   * already added. menuShortcuts are then sorted by shortcutOrder and
   * a menuShortcutsUpdated event is emitted.
   *
   * @param {string} menuId - a string matching the id property of a provider
   */
  addMenuShortcut(menuId) {
    if (this.menuShortcuts.find((m) => m.id === menuId)) { return; }

    this.menuShortcuts.push(this.menuProviders[menuId]);
    this.sortMenuShortcuts();
    this.emitMenuShortcutsUpdated();
  }

  /**
   * Removes a provider object from the menuShortcuts array and emits a
   * menuShortcutsUpdated event.
   *
   * @param {string} menuId - a string matching the id property of a provider
   */
  removeMenuShortcut(menuId) {
    this.menuShortcuts = this.menuShortcuts.filter((m) => m.id !== menuId);
    this.emitMenuShortcutsUpdated();
  }

  /**
   * Sorts the menuShortcuts property by comparing each provider's id to
   * the id in each iteration over the shortcutOrder array.
   */
  sortMenuShortcuts() {
    this.menuShortcuts = this.shortcutOrder.reduce((shortcuts, id) => {
      const menu = this.menuShortcuts.find((m) => m.id === id);
      if (menu) { shortcuts.push(menu); }
      return shortcuts;
    }, []);
  }

  emitMenuShortcutsUpdated() {
    const event = new CustomEvent('menuShortcutsUpdated', {
      detail: this.menuShortcuts,
    });
    this.dispatchEvent(event);
  }

  /**
   * Core bookreader event handler registry
   *
   * NOTE: we are trying to keep bookreader's instance in scope
   * Please update Book Navigator's instance reference of it to keep it current
   */
  bindEventListeners() {
    window.addEventListener('BookReader:PostInit', (e) => {
      this.bookreader = e.detail.props;
      this.bookReaderLoaded = true;
      this.bookReaderCannotLoad = false;
      this.initializeBookSubmenus();
      this.mainBRSelector = this.br?.el || '#BookReader';
      setTimeout(() => this.bookreader.resize(), 0);
      // eslint-disable-next-line compat/compat
      const brResizeObserver = new ResizeObserver((elements) => this.reactToBrResize(elements));
      brResizeObserver.observe(this.mainBRContainer);
    });
    window.addEventListener('BookReader:fullscreenToggled', (event) => {
      const { detail: { props: brInstance = null } } = event;
      if (brInstance) {
        this.bookreader = brInstance;
      }
      this.manageFullScreenBehavior(event);
    }, { passive: true });
    window.addEventListener('BookReader:ToggleSearchMenu', (event) => {
      this.dispatchEvent(new CustomEvent(events.updateSideMenu, {
        detail: { menuId: 'search', action: 'toggle' },
      }));
    });
    window.addEventListener('LendingFlow:PostInit', ({ detail }) => {
      const {
        downloadTypesAvailable, lendingStatus, isAdmin, previewType,
      } = detail;
      this.lendingInitialized = true;
      this.downloadableTypes = downloadTypesAvailable;
      this.lendingStatus = lendingStatus;
      this.isAdmin = isAdmin;
      this.bookReaderCannotLoad = previewType === 'singlePagePreview';
    });
    window.addEventListener('BRJSIA:PostInit', ({ detail }) => {
      const { isRestricted, downloadURLs } = detail;
      this.downloadableTypes = downloadURLs;
      this.model.setRestriction(isRestricted);
    });
  }

  /**
   * Uses resize observer to fire BookReader's `resize` functionality
   * We do not want to trigger resize IF:
   *  - book animation is happening
   *  - book is in fullscreen (fullscreen is handled separately)
   *
   * @param { Object } entries - resize observer entries
   */
  reactToBrResize(entries = []) {
    const startBrWidth = this.brWidth;
    const { animating } = this.bookreader;

    entries.forEach(({ contentRect, target }) => {
      if (target === this.mainBRContainer) {
        this.brWidth = contentRect.width;
      }
    });
    setTimeout(() => {
      if (startBrWidth && !animating) {
        this.bookreader.resize();
      }
    }, 0);
  }

  /**
   * Manages Fullscreen behavior
   * This makes sure that controls are _always_ in view
   * We need this to accommodate LOAN BAR during fullscreen
   * @param { Event } event
   */
  manageFullScreenBehavior(event) {
    this.emitFullScreenState(event);

    const { isFullscreenActive } = this.bookreader;

    if (!isFullscreenActive) {
      this.fullscreenMgr.teardown();
    } else {
      this.fullscreenMgr.setup();
    }
  }

  /**
   * Intercepts and relays fullscreen toggle events
   * @param {Event} e
   */
  emitFullScreenState({ detail }) {
    const { props: brInstance } = detail;

    const isFullScreen = brInstance.isFullscreenActive;
    const event = new CustomEvent('ViewportInFullScreen', {
      detail: { isFullScreen },
    });
    this.dispatchEvent(event);
  }

  emitShowItemNavigatorModal(e) {
    this.dispatchEvent(new CustomEvent('showItemNavigatorModal', {
      detail: e.detail,
    }));
  }

  emitCloseItemNavigatorModal() {
    this.dispatchEvent(new CustomEvent('closeItemNavigatorModal'));
  }

  showItemNavigatorModal(e) {
    this.emitShowItemNavigatorModal(e);
  }

  closeItemNavigatorModal() {
    this.emitCloseItemNavigatorModal();
  }

  get loader() {
    const loader = html`

${bookLoader}

Loading viewer

    `;
    return !this.bookReaderLoaded ? loader : nothing;
  }

  get loadingClass() {
    return !this.bookReaderLoaded ? 'loading' : '';
  }

  get itemImage() {
    const url = `https://${this.baseHost}/services/img/${this.book.metadata.identifier}`;
    return html`<img src="${url}" alt="cover image for ${this.book.metadata.identifier}">`;
  }

  render() {
    const placeholder = this.bookReaderCannotLoad ? this.itemImage : this.loader;
    return html`

      ${placeholder}
      <slot name="bookreader"></slot>

  `;
  }
}

customElements.define('book-navigator', BookNavigator);
<commit_msg>Remove loading indicator after JSIA init<commit_after>import { html, LitElement } from 'lit-element';
import { nothing } from 'lit-html';
import SearchProvider from './providers/search.js';
import DownloadProvider from './providers/download.js';
import VisualAdjustmentProvider from './providers/visual-adjustments.js';
import SharingProvider from '../ItemNavigator/providers/sharing.js';
import BookmarksProvider from './providers/bookmarks.js';
import BRFullscreenMgr from './br-fullscreen-mgr.js';
import { Book } from './BookModel.js';
// import '../ItemNavigator/ItemNavigator.js';
import bookLoader from './book-loader.js';
import navigatorCSS from './styles/book-navigator.js';

const events = {
  menuUpdated: 'menuUpdated',
  updateSideMenu: 'updateSideMenu',
  ViewportInFullScreen: 'ViewportInFullScreen',
};
export class BookNavigator extends LitElement {
  static get styles() {
    return navigatorCSS;
  }

  static get properties() {
    return {
      book: { type: Object },
      mainBRSelector: { type: String },
      pageContainerSelector: { type: String },
      brWidth: { type: Number },
      bookReaderLoaded: { type: Boolean },
      bookreader: { type: Object },
      downloadableTypes: { type: Array },
      isAdmin: { type: Boolean },
      lendingInitialized: { type: Boolean },
      lendingStatus: { type: Object },
      menuProviders: { type: Object },
      menuShortcuts: { type: Array },
      sideMenuOpen: { type: Boolean },
      signedIn: { type: Boolean },
    };
  }

  constructor() {
    super();
    this.book = {};
    this.mainBRSelector = '#BookReader';
    this.pageContainerSelector = '.BRcontainer';
    this.brWidth = 0;
    this.bookReaderCannotLoad = false;
    this.bookReaderLoaded = false;
    this.bookreader = null;
    this.downloadableTypes = [];
    this.isAdmin = false;
    this.lendingInitialized = false;
    this.lendingStatus = {};
    this.menuProviders = {};
    this.menuShortcuts = [];
    this.sideMenuOpen = false;
    this.signedIn = false;

    // Untracked properties
    this.fullscreenMgr = new BRFullscreenMgr();
    this.model = new Book();
    this.shortcutOrder = ['volumes', 'search', 'bookmarks'];
  }

  firstUpdated() {
    this.model.setMetadata(this.book);
    this.bindEventListeners();
  }

  /**
   * Instantiates books submenus & their update callbacks
   *
   * NOTE: we are doing our best to scope bookreader's instance.
   * If your submenu provider uses a bookreader instance to read, manually
   * manipulate BookReader, please update the navigator's instance of it
   * to keep it in sync.
   */
  initializeBookSubmenus() {
    this.menuProviders = {
      search: new SearchProvider(
        (brInstance = null) => {
          if (brInstance) {
            /* refresh br instance reference */
            this.bookreader = brInstance;
          }
          this.updateMenuContents();
          if (this.brWidth >= 640) { /* open side search menu */
            this.openSideSearchMenu();
          }
        },
        this.bookreader,
      ),
      downloads: new DownloadProvider(),
      visualAdjustments: new VisualAdjustmentProvider({
        onOptionChange: (event, brInstance = null) => {
          if (brInstance) {
            /* refresh br instance reference */
            this.bookreader = brInstance;
          }
          this.updateMenuContents();
        },
        bookContainerSelector: this.pageContainerSelector,
        bookreader: this.bookreader,
      }),
      share: new SharingProvider(this.book.metadata, this.baseHost, this.itemType),
    };

    if (this.signedIn) {
      this.menuProviders.bookmarks = new BookmarksProvider(this.bookmarksOptions, this.bookreader);
    }

    this.addMenuShortcut('search'); /* start with search as a shortcut */
    this.updateMenuContents();
  }

  /** gets element that houses the bookreader in light dom */
  get mainBRContainer() {
    return document.querySelector(this.mainBRSelector);
  }

  get bookmarksOptions() {
    return {
      showItemNavigatorModal: this.showItemNavigatorModal.bind(this),
      closeItemNavigatorModal: this.closeItemNavigatorModal.bind(this),
      onBookmarksChanged: (bookmarks) => {
        const method = Object.keys(bookmarks).length ? 'add' : 'remove';
        this[`${method}MenuShortcut`]('bookmarks');
        this.updateMenuContents();
      },
    };
  }

  /**
   * Open side search menu
   */
  openSideSearchMenu() {
    const event = new CustomEvent(
      events.updateSideMenu, {
        detail: { menuId: 'search', action: 'open' },
      },
    );
    this.dispatchEvent(event);
  }

  /**
   * Sets order of menu and emits custom event when done
   */
  updateMenuContents() {
    const {
      search, downloads, visualAdjustments, share, bookmarks,
    } = this.menuProviders;
    const availableMenus = [search, bookmarks, visualAdjustments, share].filter((menu) => !!menu);

    if (this.shouldShowDownloadsMenu()) {
      downloads.update(this.downloadableTypes);
      availableMenus.splice(1, 0, downloads);
    }

    const event = new CustomEvent(
      events.menuUpdated, {
        detail: availableMenus,
      },
    );
    this.dispatchEvent(event);
  }

  /**
   * Confirms if we should show the downloads menu
   * @returns {bool}
   */
  shouldShowDownloadsMenu() {
    if (this.model.isRestricted === false) { return true; }
    if (this.isAdmin) { return true; }
    const { user_loan_record = {} } = this.lendingStatus;
    const hasNoLoanRecord = Array.isArray(user_loan_record); /* (bc PHP assoc. arrays) */

    if (hasNoLoanRecord) { return false; }

    const hasValidLoan = user_loan_record.type && (user_loan_record.type !== 'SESSION_LOAN');
    return hasValidLoan;
  }

  /**
   * Adds a provider object to the menuShortcuts array property if it isn't
   * already added. menuShortcuts are then sorted by shortcutOrder and
   * a menuShortcutsUpdated event is emitted.
   *
   * @param {string} menuId - a string matching the id property of a provider
   */
  addMenuShortcut(menuId) {
    if (this.menuShortcuts.find((m) => m.id === menuId)) { return; }

    this.menuShortcuts.push(this.menuProviders[menuId]);
    this.sortMenuShortcuts();
    this.emitMenuShortcutsUpdated();
  }

  /**
   * Removes a provider object from the menuShortcuts array and emits a
   * menuShortcutsUpdated event.
   *
   * @param {string} menuId - a string matching the id property of a provider
   */
  removeMenuShortcut(menuId) {
    this.menuShortcuts = this.menuShortcuts.filter((m) => m.id !== menuId);
    this.emitMenuShortcutsUpdated();
  }

  /**
   * Sorts the menuShortcuts property by comparing each provider's id to
   * the id in each iteration over the shortcutOrder array.
   */
  sortMenuShortcuts() {
    this.menuShortcuts = this.shortcutOrder.reduce((shortcuts, id) => {
      const menu = this.menuShortcuts.find((m) => m.id === id);
      if (menu) { shortcuts.push(menu); }
      return shortcuts;
    }, []);
  }

  emitMenuShortcutsUpdated() {
    const event = new CustomEvent('menuShortcutsUpdated', {
      detail: this.menuShortcuts,
    });
    this.dispatchEvent(event);
  }

  /**
   * Core bookreader event handler registry
   *
   * NOTE: we are trying to keep bookreader's instance in scope
   * Please update Book Navigator's instance reference of it to keep it current
   */
  bindEventListeners() {
    window.addEventListener('BookReader:PostInit', (e) => {
      this.bookreader = e.detail.props;
      this.bookReaderLoaded = true;
      this.bookReaderCannotLoad = false;
      this.initializeBookSubmenus();
      this.mainBRSelector = this.br?.el || '#BookReader';
      setTimeout(() => this.bookreader.resize(), 0);
      // eslint-disable-next-line compat/compat
      const brResizeObserver = new ResizeObserver((elements) => this.reactToBrResize(elements));
      brResizeObserver.observe(this.mainBRContainer);
    });
    window.addEventListener('BookReader:fullscreenToggled', (event) => {
      const { detail: { props: brInstance = null } } = event;
      if (brInstance) {
        this.bookreader = brInstance;
      }
      this.manageFullScreenBehavior(event);
    }, { passive: true });
    window.addEventListener('BookReader:ToggleSearchMenu', (event) => {
      this.dispatchEvent(new CustomEvent(events.updateSideMenu, {
        detail: { menuId: 'search', action: 'toggle' },
      }));
    });
    window.addEventListener('LendingFlow:PostInit', ({ detail }) => {
      const {
        downloadTypesAvailable, lendingStatus, isAdmin, previewType,
      } = detail;
      this.lendingInitialized = true;
      this.downloadableTypes = downloadTypesAvailable;
      this.lendingStatus = lendingStatus;
      this.isAdmin = isAdmin;
      this.bookReaderCannotLoad = previewType === 'singlePagePreview';
    });
    window.addEventListener('BRJSIA:PostInit', ({ detail }) => {
      const { isRestricted, downloadURLs } = detail;
      this.bookReaderLoaded = true;
      this.downloadableTypes = downloadURLs;
      this.model.setRestriction(isRestricted);
    });
  }

  /**
   * Uses resize observer to fire BookReader's `resize` functionality
   * We do not want to trigger resize IF:
   *  - book animation is happening
   *  - book is in fullscreen (fullscreen is handled separately)
   *
   * @param { Object } entries - resize observer entries
   */
  reactToBrResize(entries = []) {
    const startBrWidth = this.brWidth;
    const { animating } = this.bookreader;

    entries.forEach(({ contentRect, target }) => {
      if (target === this.mainBRContainer) {
        this.brWidth = contentRect.width;
      }
    });
    setTimeout(() => {
      if (startBrWidth && !animating) {
        this.bookreader.resize();
      }
    }, 0);
  }

  /**
   * Manages Fullscreen behavior
   * This makes sure that controls are _always_ in view
   * We need this to accommodate LOAN BAR during fullscreen
   * @param { Event } event
   */
  manageFullScreenBehavior(event) {
    this.emitFullScreenState(event);

    const { isFullscreenActive } = this.bookreader;

    if (!isFullscreenActive) {
      this.fullscreenMgr.teardown();
    } else {
      this.fullscreenMgr.setup();
    }
  }

  /**
   * Intercepts and relays fullscreen toggle events
   * @param {Event} e
   */
  emitFullScreenState({ detail }) {
    const { props: brInstance } = detail;

    const isFullScreen = brInstance.isFullscreenActive;
    const event = new CustomEvent('ViewportInFullScreen', {
      detail: { isFullScreen },
    });
    this.dispatchEvent(event);
  }

  emitShowItemNavigatorModal(e) {
    this.dispatchEvent(new CustomEvent('showItemNavigatorModal', {
      detail: e.detail,
    }));
  }

  emitCloseItemNavigatorModal() {
    this.dispatchEvent(new CustomEvent('closeItemNavigatorModal'));
  }

  showItemNavigatorModal(e) {
    this.emitShowItemNavigatorModal(e);
  }

  closeItemNavigatorModal() {
    this.emitCloseItemNavigatorModal();
  }

  get loader() {
    const loader = html`

${bookLoader}

Loading viewer

    `;
    return !this.bookReaderLoaded ? loader : nothing;
  }

  get loadingClass() {
    return !this.bookReaderLoaded ? 'loading' : '';
  }

  get itemImage() {
    const url = `https://${this.baseHost}/services/img/${this.book.metadata.identifier}`;
    return html`<img src="${url}" alt="cover image for ${this.book.metadata.identifier}">`;
  }

  render() {
    const placeholder = this.bookReaderCannotLoad ? this.itemImage : this.loader;
    return html`

      ${placeholder}
      <slot name="bookreader"></slot>

  `;
  }
}

customElements.define('book-navigator', BookNavigator);
<|endoftext|>


<commit_before>//
// Module name:
//
//     dom
//
// Description:
//
//     'dom' is a JavaScript module for creating HTML elements.
//     The module exposes two object constructors, 'createElement' and 'element'.
//     The functions accept the same arguments, an HTML tag name, an attributes
//     object, an array of subelements, and an eventHandlers object.  The
//     difference is that 'element' postpones the creation of an underlying DOM
//     element, whereas 'createElement' creates and returns the DOM element.
//
//     createElement(x) === element(x).render()
//
//     By postponing the creation of the DOM, we can unit test modules
//     that return element objects without requiring a browser or a browser
//     simulator such as JsDom or Zombie.  A bare-bones JavaScript interpreter
//     such as Node.js will suffice.
//

var document = require('global/document');
var observable = require('./observable');

// Add style 's' with value 'style[s]' to the DOM element 'e'.
function addStyle(e, subscriber, style, s) {
    if (style[s] instanceof observable.Observable) {
        e.style[s] = style[s].get();
        var o = style[s].map(function(v) {e.style[s] = v;});
        subscriber.addArg(o);
    } else {
        e.style[s] = style[s];
    }
}

// Add attribute 'k' with value 'v' to the DOM element 'e'.   If the
// attribute's value is 'undefined', it will be ignored.  If the
// attribute's value is an observable, then any time its value is
// 'undefined', the attribute will be removed.
function addAttribute(e, subscriber, k, v) {
    if (v instanceof observable.Observable) {
        var val = v.get();
        if (val !== undefined) {
            e.setAttribute(k, val);
        }
        var o = v.map(function(v) {
            if (v !== undefined) {
                e.setAttribute(k, v);
            } else {
                e.removeAttribute(k);
            }
        });
        subscriber.addArg(o);
    } else {
        if (v !== undefined) {
            e.setAttribute(k, v);
        }
    }
}

function setChildren(subscriber, e, xs) {
    e.innerHTML = '';
    for (var i = 0; i < xs.length; i++) {
        var x = xs[i];
        x = typeof x === 'string' ? document.createTextNode(x) : x;
        if (typeof x.render === 'function') {
            x = x.render();
            if (typeof x.get === 'function') {
                if (subscriber.args.indexOf(x) === -1) {
                    subscriber.addArg(x);
                }
                x = x.get();
            }
        }
        e.appendChild(x);
    }
}

// Create a DOM element with tag name 'nm', attributes object 'as', style object 'sty',
// an array of subelements 'xs', and an object of event handlers 'es'.
function createElement(ps) {

    if (typeof ps === 'string') {
        ps = {name: ps};
    }

    // Create DOM node
    var e = document.createElement(ps.name);

    // Create a subscriber to watch any observables.
    var subscriber = observable.subscriber([], function(){return e;});

    // Add attributes
    var as = ps.attributes;
    var k;
    if (as) {
        for (k in as) {
            if (as.hasOwnProperty(k) && k !== 'style' && as[k] !== undefined) {
                addAttribute(e, subscriber, k, as[k]);
            }
        }
    }

    // Add Style
    var style = ps.style;
    if (style) {
        for (var s in style) {
            if (style.hasOwnProperty(s) && style[s] !== undefined) {
                addStyle(e, subscriber, style, s);
            }
        }
    }

    // Add child elements
    var xs = ps.contents;
    if (xs) {
        if (typeof xs === 'string') {
            e.appendChild(document.createTextNode(xs));
        } else {
            if (xs instanceof observable.Observable) {
                var xsObs = xs;
                xs = xsObs.get();
                var o = xsObs.map(function(xs){
                    setChildren(subscriber, e, xs);
                });
                subscriber.addArg(o);
            }
            setChildren(subscriber, e, xs);
        }
    }

    // Add event handlers
    var es = ps.handlers;
    if (typeof es === 'object') {
        for (k in es) {
            if (es.hasOwnProperty(k)) {
                e.addEventListener(k, es[k]);
            }
        }
    }

    if (subscriber.args.length > 0) {
        return subscriber;
    }

    return e;
}

//
// element({name, attributes, style, contents, handlers})
//
function ReactiveElement(as) {

    if (typeof as === 'string') {
        as = {name: as};
    }

    this.name = as.name;

    if (as.attributes !== undefined) { this.attributes = as.attributes; }
    if (as.style      !== undefined) { this.style      = as.style; }
    if (as.contents   !== undefined) { this.contents   = as.contents; }
    if (as.handlers   !== undefined) { this.handlers   = as.handlers; }
}

ReactiveElement.prototype.render = function() {
     return createElement(this);
};

function element(as) {
    return new ReactiveElement(as);
}

// Render a ReactiveElement.  If it contains observables, poll it
// for updates.
function render(e) {
    var nd = e;
    if (typeof e === 'string') {
        nd = document.createTextNode(e);
    } else if (typeof e.render === 'function') {
        nd = e.render();
    }

    if (nd instanceof observable.Observable) {
        var obs = nd;
        setInterval(function(){obs.get();}, 30);
        nd = obs.get();
    }

    return nd;
}

module.exports = {
    createElement:   createElement,
    ReactiveElement: ReactiveElement,
    element:         element,
    render:          render
};

<commit_msg>Setup polling from inside render()<commit_after>//
// Module name:
//
//     dom
//
// Description:
//
//     'dom' is a JavaScript module for creating HTML elements.
//     The module exposes two object constructors, 'createElement' and 'element'.
//     The functions accept the same arguments, an HTML tag name, an attributes
//     object, an array of subelements, and an eventHandlers object.  The
//     difference is that 'element' postpones the creation of an underlying DOM
//     element, whereas 'createElement' creates and returns the DOM element.
//
//     createElement(x) === element(x).render()
//
//     By postponing the creation of the DOM, we can unit test modules
//     that return element objects without requiring a browser or a browser
//     simulator such as JsDom or Zombie.  A bare-bones JavaScript interpreter
//     such as Node.js will suffice.
//

var document = require('global/document');
var observable = require('./observable');

// Add style 's' with value 'style[s]' to the DOM element 'e'.
function addStyle(e, subscriber, style, s) {
    if (style[s] instanceof observable.Observable) {
        e.style[s] = style[s].get();
        var o = style[s].map(function(v) {e.style[s] = v;});
        subscriber.addArg(o);
    } else {
        e.style[s] = style[s];
    }
}

// Add attribute 'k' with value 'v' to the DOM element 'e'.   If the
// attribute's value is 'undefined', it will be ignored.  If the
// attribute's value is an observable, then any time its value is
// 'undefined', the attribute will be removed.
function addAttribute(e, subscriber, k, v) {
    if (v instanceof observable.Observable) {
        var val = v.get();
        if (val !== undefined) {
            e.setAttribute(k, val);
        }
        var o = v.map(function(v) {
            if (v !== undefined) {
                e.setAttribute(k, v);
            } else {
                e.removeAttribute(k);
            }
        });
        subscriber.addArg(o);
    } else {
        if (v !== undefined) {
            e.setAttribute(k, v);
        }
    }
}

function setChildren(subscriber, e, xs) {
    e.innerHTML = '';
    for (var i = 0; i < xs.length; i++) {
        var x = xs[i];
        x = typeof x === 'string' ? document.createTextNode(x) : x;
        if (typeof x.render === 'function') {
            x = x.render();
        }
        e.appendChild(x);
    }
}

// Create a DOM element with tag name 'nm', attributes object 'as', style object 'sty',
// an array of subelements 'xs', and an object of event handlers 'es'.
function createElement(ps) {

    if (typeof ps === 'string') {
        ps = {name: ps};
    }

    // Create DOM node
    var e = document.createElement(ps.name);

    // Create a subscriber to watch any observables.
    var subscriber = observable.subscriber([], function(){return e;});

    // Add attributes
    var as = ps.attributes;
    var k;
    if (as) {
        for (k in as) {
            if (as.hasOwnProperty(k) && k !== 'style' && as[k] !== undefined) {
                addAttribute(e, subscriber, k, as[k]);
            }
        }
    }

    // Add Style
    var style = ps.style;
    if (style) {
        for (var s in style) {
            if (style.hasOwnProperty(s) && style[s] !== undefined) {
                addStyle(e, subscriber, style, s);
            }
        }
    }

    // Add child elements
    var xs = ps.contents;
    if (xs) {
        if (typeof xs === 'string') {
            e.appendChild(document.createTextNode(xs));
        } else {
            if (xs instanceof observable.Observable) {
                var xsObs = xs;
                xs = xsObs.get();
                var o = xsObs.map(function(xs){
                    setChildren(subscriber, e, xs);
                });
                subscriber.addArg(o);
            }
            setChildren(subscriber, e, xs);
        }
    }

    // Add event handlers
    var es = ps.handlers;
    if (typeof es === 'object') {
        for (k in es) {
            if (es.hasOwnProperty(k)) {
                e.addEventListener(k, es[k]);
            }
        }
    }

    if (subscriber.args.length > 0) {
        setInterval(function(){subscriber.get();}, 30);
    }

    return e;
}

//
// element({name, attributes, style, contents, handlers})
//
function ReactiveElement(as) {

    if (typeof as === 'string') {
        as = {name: as};
    }

    this.name = as.name;

    if (as.attributes !== undefined) { this.attributes = as.attributes; }
    if (as.style      !== undefined) { this.style      = as.style; }
    if (as.contents   !== undefined) { this.contents   = as.contents; }
    if (as.handlers   !== undefined) { this.handlers   = as.handlers; }
}

ReactiveElement.prototype.render = function() {
     return createElement(this);
};

function element(as) {
    return new ReactiveElement(as);
}

// Render a string or object with a render method, such as a ReactiveElement.
function render(e) {
    if (typeof e === 'string') {
        return document.createTextNode(e);
    }
    return e.render();
}

module.exports = {
    createElement:   createElement,
    ReactiveElement: ReactiveElement,
    element:         element,
    render:          render
};

<|endoftext|>