/* 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";

const { Actor } = require("resource://devtools/shared/protocol.js");
const {
  nodeSpec,
  nodeListSpec,
} = require("resource://devtools/shared/specs/node.js");

const {
  PSEUDO_CLASSES,
} = require("resource://devtools/shared/css/constants.js");

loader.lazyRequireGetter(
  this,
  ["getCssPath", "getXPath", "findCssSelector"],
  "resource://devtools/shared/inspector/css-logic.js",
  true
);

loader.lazyRequireGetter(
  this,
  [
    "getShadowRootMode",
    "isAfterPseudoElement",
    "isAnonymous",
    "isBeforePseudoElement",
    "isDirectShadowHostChild",
    "isFrameBlockedByCSP",
    "isFrameWithChildTarget",
    "isMarkerPseudoElement",
    "isNativeAnonymous",
    "isShadowHost",
    "isShadowRoot",
  ],
  "resource://devtools/shared/layout/utils.js",
  true
);

loader.lazyRequireGetter(
  this,
  [
    "getBackgroundColor",
    "getClosestBackgroundColor",
    "getNodeDisplayName",
    "imageToImageData",
    "isNodeDead",
  ],
  "resource://devtools/server/actors/inspector/utils.js",
  true
);
loader.lazyRequireGetter(
  this,
  "LongStringActor",
  "resource://devtools/server/actors/string.js",
  true
);
loader.lazyRequireGetter(
  this,
  "getFontPreviewData",
  "resource://devtools/server/actors/utils/style-utils.js",
  true
);
loader.lazyRequireGetter(
  this,
  "CssLogic",
  "resource://devtools/server/actors/inspector/css-logic.js",
  true
);
loader.lazyRequireGetter(
  this,
  "EventCollector",
  "resource://devtools/server/actors/inspector/event-collector.js",
  true
);
loader.lazyRequireGetter(
  this,
  "DOMHelpers",
  "resource://devtools/shared/dom-helpers.js",
  true
);

const FONT_FAMILY_PREVIEW_TEXT = "The quick brown fox jumps over the lazy dog";
const FONT_FAMILY_PREVIEW_TEXT_SIZE = 20;

/**
 * Server side of the node actor.
 */
class NodeActor extends Actor {
  constructor(walker, node) {
    super(walker.conn, nodeSpec);
    this.walker = walker;
    this.rawNode = node;
    this._eventCollector = new EventCollector(this.walker.targetActor);
    // Map<id -> nsIEventListenerInfo> that we maintain to be able to disable/re-enable event listeners
    // The id is generated from getEventListenerInfo
    this._nsIEventListenersInfo = new Map();

    // Store the original display type and scrollable state and whether or not the node is
    // displayed to track changes when reflows occur.
    const wasScrollable = this.isScrollable;

    this.currentDisplayType = this.displayType;
    this.wasDisplayed = this.isDisplayed;
    this.wasScrollable = wasScrollable;
    this.currentContainerType = this.containerType;

    if (wasScrollable) {
      this.walker.updateOverflowCausingElements(
        this,
        this.walker.overflowCausingElementsMap
      );
    }
  }

  toString() {
    return (
      "[NodeActor " + this.actorID + " for " + this.rawNode.toString() + "]"
    );
  }

  isDocumentElement() {
    return (
      this.rawNode.ownerDocument &&
      this.rawNode.ownerDocument.documentElement === this.rawNode
    );
  }

  destroy() {
    super.destroy();

    if (this.mutationObserver) {
      if (!Cu.isDeadWrapper(this.mutationObserver)) {
        this.mutationObserver.disconnect();
      }
      this.mutationObserver = null;
    }

    if (this.slotchangeListener) {
      if (!isNodeDead(this)) {
        this.rawNode.removeEventListener("slotchange", this.slotchangeListener);
      }
      this.slotchangeListener = null;
    }

    if (this._waitForFrameLoadAbortController) {
      this._waitForFrameLoadAbortController.abort();
      this._waitForFrameLoadAbortController = null;
    }
    if (this._waitForFrameLoadIntervalId) {
      clearInterval(this._waitForFrameLoadIntervalId);
      this._waitForFrameLoadIntervalId = null;
    }

    if (this._nsIEventListenersInfo) {
      // Re-enable all event listeners that we might have disabled
      for (const nsIEventListenerInfo of this._nsIEventListenersInfo.values()) {
        // If event listeners/node don't exist anymore, accessing nsIEventListenerInfo.enabled
        // will throw.
        try {
          if (!nsIEventListenerInfo.enabled) {
            nsIEventListenerInfo.enabled = true;
          }
        } catch (e) {
          // ignore
        }
      }
      this._nsIEventListenersInfo = null;
    }

    this._eventCollector.destroy();
    this._eventCollector = null;
    this.rawNode = null;
    this.walker = null;
  }

  // Returns the JSON representation of this object over the wire.
  form() {
    const parentNode = this.walker.parentNode(this);
    const inlineTextChild = this.walker.inlineTextChild(this);
    const shadowRoot = isShadowRoot(this.rawNode);
    const hostActor = shadowRoot
      ? this.walker.getNode(this.rawNode.host)
      : null;

    const form = {
      actor: this.actorID,
      host: hostActor ? hostActor.actorID : undefined,
      baseURI: this.rawNode.baseURI,
      parent: parentNode ? parentNode.actorID : undefined,
      nodeType: this.rawNode.nodeType,
      namespaceURI: this.rawNode.namespaceURI,
      nodeName: this.rawNode.nodeName,
      nodeValue: this.rawNode.nodeValue,
      displayName: getNodeDisplayName(this.rawNode),
      numChildren: this.numChildren,
      inlineTextChild: inlineTextChild ? inlineTextChild.form() : undefined,
      displayType: this.displayType,
      isScrollable: this.isScrollable,
      isTopLevelDocument: this.isTopLevelDocument,
      causesOverflow: this.walker.overflowCausingElementsMap.has(this.rawNode),
      containerType: this.containerType,

      // doctype attributes
      name: this.rawNode.name,
      publicId: this.rawNode.publicId,
      systemId: this.rawNode.systemId,

      attrs: this.writeAttrs(),
      customElementLocation: this.getCustomElementLocation(),
      isMarkerPseudoElement: isMarkerPseudoElement(this.rawNode),
      isBeforePseudoElement: isBeforePseudoElement(this.rawNode),
      isAfterPseudoElement: isAfterPseudoElement(this.rawNode),
      isAnonymous: isAnonymous(this.rawNode),
      isNativeAnonymous: isNativeAnonymous(this.rawNode),
      isShadowRoot: shadowRoot,
      shadowRootMode: getShadowRootMode(this.rawNode),
      isShadowHost: isShadowHost(this.rawNode),
      isDirectShadowHostChild: isDirectShadowHostChild(this.rawNode),
      pseudoClassLocks: this.writePseudoClassLocks(),
      mutationBreakpoints: this.walker.getMutationBreakpoints(this),

      isDisplayed: this.isDisplayed,
      isInHTMLDocument:
        this.rawNode.ownerDocument &&
        this.rawNode.ownerDocument.contentType === "text/html",
      hasEventListeners: this._hasEventListeners,
      traits: {},
    };

    if (this.isDocumentElement()) {
      form.isDocumentElement = true;
    }

    if (isFrameBlockedByCSP(this.rawNode)) {
      form.numChildren = 0;
    }

    // Flag the node if a different walker is needed to retrieve its children (i.e. if
    // this is a remote frame, or if it's an iframe and we're creating targets for every iframes)
    if (this.useChildTargetToFetchChildren) {
      form.useChildTargetToFetchChildren = true;
      // Declare at least one child (the #document element) so
      // that they can be expanded.
      form.numChildren = 1;
    }
    form.browsingContextID = this.rawNode.browsingContext?.id;

    return form;
  }

  /**
   * Watch the given document node for mutations using the DOM observer
   * API.
   */
  watchDocument(doc, callback) {
    if (!doc.defaultView) {
      return;
    }

    const node = this.rawNode;
    // Create the observer on the node's actor.  The node will make sure
    // the observer is cleaned up when the actor is released.
    const observer = new doc.defaultView.MutationObserver(callback);
    observer.mergeAttributeRecords = true;
    observer.observe(node, {
      attributes: true,
      characterData: true,
      characterDataOldValue: true,
      childList: true,
      subtree: true,
      chromeOnlyNodes: true,
    });
    this.mutationObserver = observer;
  }

  /**
   * Watch for all "slotchange" events on the node.
   */
  watchSlotchange(callback) {
    this.slotchangeListener = callback;
    this.rawNode.addEventListener("slotchange", this.slotchangeListener);
  }

  /**
   * Check if the current node represents an element (e.g. an iframe) which has a dedicated
   * target for its underlying document that we would need to use to fetch the child nodes.
   * This will be the case for iframes if EFT is enabled, or if this is a remote iframe and
   * fission is enabled.
   */
  get useChildTargetToFetchChildren() {
    return isFrameWithChildTarget(this.walker.targetActor, this.rawNode);
  }

  get isTopLevelDocument() {
    return this.rawNode === this.walker.rootDoc;
  }

  // Estimate the number of children that the walker will return without making
  // a call to children() if possible.
  get numChildren() {
    // For pseudo elements, childNodes.length returns 1, but the walker
    // will return 0.
    if (
      isMarkerPseudoElement(this.rawNode) ||
      isBeforePseudoElement(this.rawNode) ||
      isAfterPseudoElement(this.rawNode)
    ) {
      return 0;
    }

    const rawNode = this.rawNode;
    let numChildren = rawNode.childNodes.length;
    const hasContentDocument = rawNode.contentDocument;
    const hasSVGDocument = rawNode.getSVGDocument && rawNode.getSVGDocument();
    if (numChildren === 0 && (hasContentDocument || hasSVGDocument)) {
      // This might be an iframe with virtual children.
      numChildren = 1;
    }

    // Normal counting misses ::before/::after.  Also, some anonymous children
    // may ultimately be skipped, so we have to consult with the walker.
    //
    // FIXME: We should be able to just check <slot> rather than
    // containingShadowRoot.
    if (
      numChildren === 0 ||
      isShadowHost(this.rawNode) ||
      this.rawNode.containingShadowRoot
    ) {
      numChildren = this.walker.countChildren(this);
    }

    return numChildren;
  }

  get computedStyle() {
    if (!this._computedStyle) {
      this._computedStyle = CssLogic.getComputedStyle(this.rawNode);
    }
    return this._computedStyle;
  }

  /**
   * Returns the computed display style property value of the node.
   */
  get displayType() {
    // Consider all non-element nodes as displayed.
    if (isNodeDead(this) || this.rawNode.nodeType !== Node.ELEMENT_NODE) {
      return null;
    }

    const style = this.computedStyle;
    if (!style) {
      return null;
    }

    let display = null;
    try {
      display = style.display;
    } catch (e) {
      // Fails for <scrollbar> elements.
    }

    if (
      (display === "grid" || display === "inline-grid") &&
      (style.gridTemplateRows.startsWith("subgrid") ||
        style.gridTemplateColumns.startsWith("subgrid"))
    ) {
      display = "subgrid";
    }

    return display;
  }

  /**
   * Returns the computed containerType style property value of the node.
   */
  get containerType() {
    // non-element nodes can't be containers
    if (
      isNodeDead(this) ||
      this.rawNode.nodeType !== Node.ELEMENT_NODE ||
      !this.computedStyle
    ) {
      return null;
    }

    return this.computedStyle.containerType;
  }

  /**
   * Check whether the node currently has scrollbars and is scrollable.
   */
  get isScrollable() {
    return (
      this.rawNode.nodeType === Node.ELEMENT_NODE &&
      this.rawNode.hasVisibleScrollbars
    );
  }

  /**
   * Is the node currently displayed?
   */
  get isDisplayed() {
    const type = this.displayType;

    // Consider all non-elements or elements with no display-types to be displayed.
    if (!type) {
      return true;
    }

    // Otherwise consider elements to be displayed only if their display-types is other
    // than "none"".
    return type !== "none";
  }

  /**
   * Are there event listeners that are listening on this node? This method
   * uses all parsers registered via event-parsers.js.registerEventParser() to
   * check if there are any event listeners.
   */
  get _hasEventListeners() {
    // We need to pass a debugger instance from this compartment because
    // otherwise we can't make use of it inside the event-collector module.
    const dbg = this.getParent().targetActor.makeDebugger();
    return this._eventCollector.hasEventListeners(this.rawNode, dbg);
  }

  writeAttrs() {
    // If the node has no attributes or this.rawNode is the document node and a
    // node with `name="attributes"` exists in the DOM we need to bail.
    if (
      !this.rawNode.attributes ||
      !NamedNodeMap.isInstance(this.rawNode.attributes)
    ) {
      return undefined;
    }

    return [...this.rawNode.attributes].map(attr => {
      return { namespace: attr.namespace, name: attr.name, value: attr.value };
    });
  }

  writePseudoClassLocks() {
    if (this.rawNode.nodeType !== Node.ELEMENT_NODE) {
      return undefined;
    }
    let ret = undefined;
    for (const pseudo of PSEUDO_CLASSES) {
      if (InspectorUtils.hasPseudoClassLock(this.rawNode, pseudo)) {
        ret = ret || [];
        ret.push(pseudo);
      }
    }
    return ret;
  }

  /**
   * Retrieve the script location of the custom element definition for this node, when
   * relevant. To be linked to a custom element definition
   */
  getCustomElementLocation() {
    // Get a reference to the custom element definition function.
    const name = this.rawNode.localName;

    if (!this.rawNode.ownerGlobal) {
      return undefined;
    }

    const customElementsRegistry = this.rawNode.ownerGlobal.customElements;
    const customElement =
      customElementsRegistry && customElementsRegistry.get(name);
    if (!customElement) {
      return undefined;
    }
    // Create debugger object for the customElement function.
    const global = Cu.getGlobalForObject(customElement);

    const dbg = this.getParent().targetActor.makeDebugger();

    // If we hit a <browser> element of Firefox, its global will be the chrome window
    // which is system principal and will be in the same compartment as the debuggee.
    // For some reason, this happens when we run the content toolbox. As for the content
    // toolboxes, the modules are loaded in the same compartment as the <browser> element,
    // this throws as the debugger can _not_ be in the same compartment as the debugger.
    // This happens when we toggle fission for content toolbox because we try to reparent
    // the Walker of the tab. This happens because we do not detect in Walker.reparentRemoteFrame
    // that the target of the tab is the top level. That's because the target is a WindowGlobalTargetActor
    // which is retrieved via Node.getEmbedderElement and doesn't return the LocalTabTargetActor.
    // We should probably work on TabDescriptor so that the LocalTabTargetActor has a descriptor,
    // and see if we can possibly move the local tab specific out of the TargetActor and have
    // the TabDescriptor expose a pure WindowGlobalTargetActor?? (See bug 1579042)
    if (Cu.getObjectPrincipal(global) == Cu.getObjectPrincipal(dbg)) {
      return undefined;
    }

    const globalDO = dbg.addDebuggee(global);
    const customElementDO = globalDO.makeDebuggeeValue(customElement);

    // Return undefined if we can't find a script for the custom element definition.
    if (!customElementDO.script) {
      return undefined;
    }

    return {
      url: customElementDO.script.url,
      line: customElementDO.script.startLine,
      column: customElementDO.script.startColumn,
    };
  }

  /**
   * Returns a LongStringActor with the node's value.
   */
  getNodeValue() {
    return new LongStringActor(this.conn, this.rawNode.nodeValue || "");
  }

  /**
   * Set the node's value to a given string.
   */
  setNodeValue(value) {
    this.rawNode.nodeValue = value;
  }

  /**
   * Get a unique selector string for this node.
   */
  getUniqueSelector() {
    if (Cu.isDeadWrapper(this.rawNode)) {
      return "";
    }
    return findCssSelector(this.rawNode);
  }

  /**
   * Get the full CSS path for this node.
   *
   * @return {String} A CSS selector with a part for the node and each of its ancestors.
   */
  getCssPath() {
    if (Cu.isDeadWrapper(this.rawNode)) {
      return "";
    }
    return getCssPath(this.rawNode);
  }

  /**
   * Get the XPath for this node.
   *
   * @return {String} The XPath for finding this node on the page.
   */
  getXPath() {
    if (Cu.isDeadWrapper(this.rawNode)) {
      return "";
    }
    return getXPath(this.rawNode);
  }

  /**
   * Scroll the selected node into view.
   */
  scrollIntoView() {
    this.rawNode.scrollIntoView(true);
  }

  /**
   * Get the node's image data if any (for canvas and img nodes).
   * Returns an imageData object with the actual data being a LongStringActor
   * and a size json object.
   * The image data is transmitted as a base64 encoded png data-uri.
   * The method rejects if the node isn't an image or if the image is missing
   *
   * Accepts a maxDim request parameter to resize images that are larger. This
   * is important as the resizing occurs server-side so that image-data being
   * transfered in the longstring back to the client will be that much smaller
   */
  getImageData(maxDim) {
    return imageToImageData(this.rawNode, maxDim).then(imageData => {
      return {
        data: new LongStringActor(this.conn, imageData.data),
        size: imageData.size,
      };
    });
  }

  /**
   * Get all event listeners that are listening on this node.
   */
  getEventListenerInfo() {
    this._nsIEventListenersInfo.clear();

    const eventListenersData = this._eventCollector.getEventListeners(
      this.rawNode
    );
    let counter = 0;
    for (const eventListenerData of eventListenersData) {
      if (eventListenerData.nsIEventListenerInfo) {
        const id = `event-listener-info-${++counter}`;
        this._nsIEventListenersInfo.set(
          id,
          eventListenerData.nsIEventListenerInfo
        );

        eventListenerData.eventListenerInfoId = id;
        // remove the nsIEventListenerInfo since we don't want to send it to the client.
        delete eventListenerData.nsIEventListenerInfo;
      }
    }
    return eventListenersData;
  }

  /**
   * Disable a specific event listener given its associated id
   *
   * @param {String} eventListenerInfoId
   */
  disableEventListener(eventListenerInfoId) {
    const nsEventListenerInfo =
      this._nsIEventListenersInfo.get(eventListenerInfoId);
    if (!nsEventListenerInfo) {
      throw new Error("Unkown nsEventListenerInfo");
    }
    nsEventListenerInfo.enabled = false;
  }

  /**
   * (Re-)enable a specific event listener given its associated id
   *
   * @param {String} eventListenerInfoId
   */
  enableEventListener(eventListenerInfoId) {
    const nsEventListenerInfo =
      this._nsIEventListenersInfo.get(eventListenerInfoId);
    if (!nsEventListenerInfo) {
      throw new Error("Unkown nsEventListenerInfo");
    }
    nsEventListenerInfo.enabled = true;
  }

  /**
   * Modify a node's attributes.  Passed an array of modifications
   * similar in format to "attributes" mutations.
   * {
   *   attributeName: <string>
   *   attributeNamespace: <optional string>
   *   newValue: <optional string> - If null or undefined, the attribute
   *     will be removed.
   * }
   *
   * Returns when the modifications have been made.  Mutations will
   * be queued for any changes made.
   */
  modifyAttributes(modifications) {
    const rawNode = this.rawNode;
    for (const change of modifications) {
      if (change.newValue == null) {
        if (change.attributeNamespace) {
          rawNode.removeAttributeNS(
            change.attributeNamespace,
            change.attributeName
          );
        } else {
          rawNode.removeAttribute(change.attributeName);
        }
      } else if (change.attributeNamespace) {
        rawNode.setAttributeDevtoolsNS(
          change.attributeNamespace,
          change.attributeName,
          change.newValue
        );
      } else {
        rawNode.setAttributeDevtools(change.attributeName, change.newValue);
      }
    }
  }

  /**
   * Given the font and fill style, get the image data of a canvas with the
   * preview text and font.
   * Returns an imageData object with the actual data being a LongStringActor
   * and the width of the text as a string.
   * The image data is transmitted as a base64 encoded png data-uri.
   */
  getFontFamilyDataURL(font, fillStyle = "black") {
    const doc = this.rawNode.ownerDocument;
    const options = {
      previewText: FONT_FAMILY_PREVIEW_TEXT,
      previewFontSize: FONT_FAMILY_PREVIEW_TEXT_SIZE,
      fillStyle,
    };
    const { dataURL, size } = getFontPreviewData(font, doc, options);

    return { data: new LongStringActor(this.conn, dataURL), size };
  }

  /**
   * Finds the computed background color of the closest parent with a set background
   * color.
   *
   * @return {String}
   *         String with the background color of the form rgba(r, g, b, a). Defaults to
   *         rgba(255, 255, 255, 1) if no background color is found.
   */
  getClosestBackgroundColor() {
    return getClosestBackgroundColor(this.rawNode);
  }

  /**
   * Finds the background color range for the parent of a single text node
   * (i.e. for multi-colored backgrounds with gradients, images) or a single
   * background color for single-colored backgrounds. Defaults to the closest
   * background color if an error is encountered.
   *
   * @return {Object}
   *         Object with one or more of the following properties: value, min, max
   */
  getBackgroundColor() {
    return getBackgroundColor(this);
  }

  /**
   * Returns an object with the width and height of the node's owner window.
   *
   * @return {Object}
   */
  getOwnerGlobalDimensions() {
    const win = this.rawNode.ownerGlobal;
    return {
      innerWidth: win.innerWidth,
      innerHeight: win.innerHeight,
    };
  }

  /**
   * If the current node is an iframe, wait for the content window to be loaded.
   */
  async waitForFrameLoad() {
    if (this.useChildTargetToFetchChildren) {
      // If the document is handled by a dedicated target, we'll wait for a DOCUMENT_EVENT
      // on the created target.
      throw new Error(
        "iframe content document has its own target, use that one instead"
      );
    }

    if (Cu.isDeadWrapper(this.rawNode)) {
      throw new Error("Node is dead");
    }

    const { contentDocument } = this.rawNode;
    if (!contentDocument) {
      throw new Error("Can't access contentDocument");
    }

    if (contentDocument.readyState === "uninitialized") {
      // If the readyState is "uninitialized", the document is probably an about:blank
      // transient document. In such case, we want to wait until the "final" document
      // is inserted.

      const { chromeEventHandler } = this.rawNode.ownerGlobal.docShell;
      const browsingContextID = this.rawNode.browsingContext.id;
      await new Promise((resolve, reject) => {
        this._waitForFrameLoadAbortController = new AbortController();

        chromeEventHandler.addEventListener(
          "DOMDocElementInserted",
          e => {
            const { browsingContext } = e.target.defaultView;
            // Check that the document we're notified about is the iframe one.
            if (browsingContext.id == browsingContextID) {
              resolve();
              this._waitForFrameLoadAbortController.abort();
            }
          },
          { signal: this._waitForFrameLoadAbortController.signal }
        );

        // It might happen that the "final" document will be a remote one, living in a
        // different process, which means we won't get the DOMDocElementInserted event
        // here, and will wait forever. To prevent this Promise to hang forever, we use
        // a setInterval to check if the final document can be reached, so we can reject
        // if it's not.
        // This is definitely not a perfect solution, but I wasn't able to find something
        // better for this feature. I think it's _fine_ as this method will be removed
        // when EFT is  enabled everywhere in release.
        this._waitForFrameLoadIntervalId = setInterval(() => {
          if (Cu.isDeadWrapper(this.rawNode) || !this.rawNode.contentDocument) {
            reject("Can't access the iframe content document");
            clearInterval(this._waitForFrameLoadIntervalId);
            this._waitForFrameLoadIntervalId = null;
            this._waitForFrameLoadAbortController.abort();
          }
        }, 50);
      });
    }

    if (this.rawNode.contentDocument.readyState === "loading") {
      await new Promise(resolve => {
        DOMHelpers.onceDOMReady(this.rawNode.contentWindow, resolve);
      });
    }
  }
}

/**
 * Server side of a node list as returned by querySelectorAll()
 */
class NodeListActor extends Actor {
  constructor(walker, nodeList) {
    super(walker.conn, nodeListSpec);
    this.walker = walker;
    this.nodeList = nodeList || [];
  }

  /**
   * Items returned by this actor should belong to the parent walker.
   */
  marshallPool() {
    return this.walker;
  }

  // Returns the JSON representation of this object over the wire.
  form() {
    return {
      actor: this.actorID,
      length: this.nodeList ? this.nodeList.length : 0,
    };
  }

  /**
   * Get a single node from the node list.
   */
  item(index) {
    return this.walker.attachElement(this.nodeList[index]);
  }

  /**
   * Get a range of the items from the node list.
   */
  items(start = 0, end = this.nodeList.length) {
    const items = Array.prototype.slice
      .call(this.nodeList, start, end)
      .map(item => this.walker._getOrCreateNodeActor(item));
    return this.walker.attachElements(items);
  }

  release() {}
}

exports.NodeActor = NodeActor;
exports.NodeListActor = NodeListActor;
