Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for @containerless elements and @templateController attributes to @children and @child decorators #717

Open
euglv opened this issue Nov 27, 2024 · 0 comments

Comments

@euglv
Copy link

euglv commented Nov 27, 2024

I'm submitting a feature request

  • Library Version:
    1.4.1

Current behavior:
@children and @child decorators do not support containerless custom elements and template controller custom attributes (which in fact are shortcuts to containerless custom elements)

Expected/desired behavior:
It is easy to track children of containerless elements. When @containerless element or @templateController custom attribute is created - it is attached to <!--anchor--> Comment DOM node . You can get this node before any children of containerless element are created by injecting Element to constructor. Later all children will be inserted before this <!--anchor--> node by Aurelia engine. So it is possible to insert another Comment node <!--@children--> before <!--anchor--> node, and later all children will be added to DOM tree between those two Comment nodes. And here we can add MutationObserver to <!--anchor--> parent node and use compareDocumentPosition() DOM API to filter children of containerless element.

Here is a modified version of ChildObserverBinder that is used by @children decorator from aurelia-templating/src/child-observation.ts that implements the above logic:

child-observation.js
import {DOM} from 'aurelia-pal';
import {metadata} from 'aurelia-metadata';
import {HtmlBehaviorResource} from 'aurelia-templating';
/*import { Controller } from './controller';
import { SlotMarkedNode } from './type-extension';
import { ShadowSlot } from './shadow-dom';*/


function createChildObserverDecorator(selectorOrConfig, all) {
  return function (target, key, descriptor) {
    let actualTarget = typeof key === 'string' ? target.constructor : target;
    let r = metadata.getOrCreateOwn(metadata.resource, HtmlBehaviorResource, actualTarget);
    if (typeof selectorOrConfig === 'string') {
      selectorOrConfig = {
        selector: selectorOrConfig,
        name: key
      };
    }
    if (descriptor) {
      descriptor.writable = true;
      descriptor.configurable = true;
    }
    selectorOrConfig.all = all;
    r.addChildBinding(new ChildObserver(selectorOrConfig));
  };
}
export function children(selectorOrConfig) {
  return createChildObserverDecorator(selectorOrConfig, true);
}
export function child(selectorOrConfig) {
  return createChildObserverDecorator(selectorOrConfig, false);
}

export function bindChildObserver(selector, viewModel, propName, element, all = true){
  const observer = new ChildObserver({
    selector,
    name: propName,
    all
  });
  const binder = observer.create(element, viewModel);
  binder.bind(viewModel);
  if (!Array.isArray(viewModel[propName]))
    viewModel[propName] = [];

  viewModel[propName].unbind = () => {
    binder.unbind()
  }
}

class ChildObserver {
  constructor(config) {
    this.name = config.name;
    this.changeHandler = config.changeHandler || this.name + 'Changed';
    this.selector = config.selector;
    this.all = config.all;
  }
  create(viewHost, viewModel, controller) {
    return new ChildObserverBinder(this.selector, viewHost, this.name, viewModel, controller, this.changeHandler, this.all);
  }
}
const noMutations = [];
function trackMutation(groupedMutations, binder, record) {
  let mutations = groupedMutations.get(binder);
  if (!mutations) {
    mutations = [];
    groupedMutations.set(binder, mutations);
  }
  mutations.push(record);
}
function onChildChange(mutations, observer) {
  let binders = observer.binders;
  let bindersLength = binders.length;
  let groupedMutations = new Map();
  for (let i = 0, ii = mutations.length; i < ii; ++i) {
    let record = mutations[i];
    let added = record.addedNodes;
    let removed = record.removedNodes;
    for (let j = 0, jj = removed.length; j < jj; ++j) {
      let node = removed[j];
      if (node.nodeType === 1) {
        for (let k = 0; k < bindersLength; ++k) {
          let binder = binders[k];
          if (binder.onRemove(node)) {
            trackMutation(groupedMutations, binder, record);
          }
        }
      }
    }
    for (let j = 0, jj = added.length; j < jj; ++j) {
      let node = added[j];
      if (node.nodeType === 1) {
        for (let k = 0; k < bindersLength; ++k) {
          let binder = binders[k];
          if (binder.onAdd(node)) {
            trackMutation(groupedMutations, binder, record);
          }
        }
      }
    }
  }
  groupedMutations.forEach((mutationRecords, binder) => {
    if (binder.isBound && binder.changeHandler !== null) {
      binder.viewModel[binder.changeHandler](mutationRecords);
    }
  });
}
class ChildObserverBinder {
  constructor(selector, viewHost, property, viewModel, controller, changeHandler, all) {
    this.selector = selector;
    this.viewHost = viewHost;
    this.property = property;
    this.viewModel = viewModel;
    this.controller = controller;
    this.changeHandler = changeHandler in viewModel ? changeHandler : null;
    this.all = all;
    this.contentView = null;
    if (controller) {
      this.usesShadowDOM = controller.behavior.usesShadowDOM;

      if (!this.usesShadowDOM && controller.view && controller.view.contentView) {
        this.contentView = controller.view.contentView;
      }
      else {
        this.contentView = null;
      }
    } else {
      this.contentView = null;
    }

    this.source = null;
    this.isBound = false;
  }
  matches(element) {
    const viewHost = this.viewHost;
    if (viewHost.__childrenStartNode__) { // Extra check for containerless children support
      let start = viewHost.__childrenStartNode__;
      if (!(element.isSameNode(start) || element.isSameNode(viewHost) ||
        (element.compareDocumentPosition(start) & Node.DOCUMENT_POSITION_PRECEDING && element.compareDocumentPosition(viewHost) & Node.DOCUMENT_POSITION_FOLLOWING)))
        return false;
    }

    if (element.matches(this.selector)) {
      if (this.contentView === null) {
        return true;
      }
      let contentView = this.contentView;
      let assignedSlot = element.auAssignedSlot;
      if (assignedSlot && assignedSlot.projectFromAnchors) {
        let anchors = assignedSlot.projectFromAnchors;
        for (let i = 0, ii = anchors.length; i < ii; ++i) {
          if (anchors[i].auOwnerView === contentView) {
            return true;
          }
        }
        return false;
      }
      return element.auOwnerView === contentView;
    }
    return false;
  }
  bind(source) {
    if (this.isBound) {
      if (this.source === source) {
        return;
      }
      this.source = source;
    }
    this.isBound = true;
    let viewHost = this.viewHost;
    let viewModel = this.viewModel;
    let observer = viewHost.__childObserver__;
    if (!observer) {
      observer = viewHost.__childObserver__ = DOM.createMutationObserver(onChildChange);
      let options = {
        childList: true,
        subtree: !this.usesShadowDOM
      };
      let observerHost; // support containerless
      if (viewHost instanceof Element) // viewHost is container
        observerHost = viewHost
      else { // viewHost is Comment node - containerless view host
        observerHost = viewHost.parentNode;
        const childrenStartNode = DOM.createComment('@children');
        viewHost.parentNode.insertBefore(childrenStartNode, viewHost);
        viewHost.__childrenStartNode__ = childrenStartNode;
      }
      observer.observe(observerHost, options);
      observer.binders = [];
    }
    observer.binders.push(this);
    if (this.usesShadowDOM) {
      let current = viewHost.firstElementChild;
      if (this.all) {
        let items = viewModel[this.property];
        if (!items) {
          items = viewModel[this.property] = [];
        }
        else {
          items.splice(0);
        }
        while (current) {
          if (this.matches(current)) {
            items.push(current.au && current.au.controller ? current.au.controller.viewModel : current);
          }
          current = current.nextElementSibling;
        }
        if (this.changeHandler !== null) {
          this.viewModel[this.changeHandler](noMutations);
        }
      }
      else {
        while (current) {
          if (this.matches(current)) {
            let value = current.au && current.au.controller ? current.au.controller.viewModel : current;
            this.viewModel[this.property] = value;
            if (this.changeHandler !== null) {
              this.viewModel[this.changeHandler](value);
            }
            break;
          }
          current = current.nextElementSibling;
        }
      }
    }
  }
  onRemove(element) {
    if (this.matches(element)) {
      let value = element.au && element.au.controller ? element.au.controller.viewModel : element;
      if (this.all) {
        let items = (this.viewModel[this.property] || (this.viewModel[this.property] = []));
        let index = items.indexOf(value);
        if (index !== -1) {
          items.splice(index, 1);
        }
        return true;
      }
      const currentValue = this.viewModel[this.property];
      if (currentValue === value) {
        this.viewModel[this.property] = null;
        if (this.isBound && this.changeHandler !== null) {
          this.viewModel[this.changeHandler](value);
        }
      }
    }
    return false;
  }
  onAdd(element) {
    if (this.matches(element)) {
      let value = element.au && element.au.controller ? element.au.controller.viewModel : element;
      if (this.all) {
        let items = (this.viewModel[this.property] || (this.viewModel[this.property] = []));
        if (this.selector === '*') {
          items.push(value);
          return true;
        }
        let index = 0;
        let prev = element.previousElementSibling;
        while (prev) {
          if (this.matches(prev)) {
            index++;
          }
          prev = prev.previousElementSibling;
        }
        items.splice(index, 0, value);
        return true;
      }
      this.viewModel[this.property] = value;
      if (this.isBound && this.changeHandler !== null) {
        this.viewModel[this.changeHandler](value);
      }
    }
    return false;
  }
  unbind() {
    if (!this.isBound) {
      return;
    }
    this.isBound = false;
    this.source = null;
    let childObserver = this.viewHost.__childObserver__;
    if (childObserver) {
      let binders = childObserver.binders;
      if (binders && binders.length) {
        let idx = binders.indexOf(this);
        if (idx !== -1) {
          binders.splice(idx, 1);
        }
        if (binders.length === 0) {
          childObserver.disconnect();
          this.viewHost.__childObserver__ = null;
        }
      }
      if (this.usesShadowDOM) {
        this.viewModel[this.property] = null;
      }
    }
  }
}

Modified matcher:

  matches(element) {
    const viewHost = this.viewHost;
    if (viewHost.__childrenStartNode__) { // Extra check for containerless children support
      let start = viewHost.__childrenStartNode__;
      if (!(element.isSameNode(start) || element.isSameNode(viewHost) ||
        (element.compareDocumentPosition(start) & Node.DOCUMENT_POSITION_PRECEDING && element.compareDocumentPosition(viewHost) & Node.DOCUMENT_POSITION_FOLLOWING)))
        return false;
    }

And modified MutationObserver creation:

    if (!observer) {
      observer = viewHost.__childObserver__ = DOM.createMutationObserver(onChildChange);
      let options = {
        childList: true,
        subtree: !this.usesShadowDOM
      };
      let observerHost; // support containerless
      if (viewHost instanceof Element) // viewHost is container
        observerHost = viewHost
      else { // viewHost is Comment node - containerless view host
        observerHost = viewHost.parentNode;
        const childrenStartNode = DOM.createComment('@children');
        viewHost.parentNode.insertBefore(childrenStartNode, viewHost);
        viewHost.__childrenStartNode__ = childrenStartNode;
      }
      observer.observe(observerHost, options); 

But the above modification does not work inside @children decorator, because ChildObserver.create(viewHost, viewModel, controller) method is never called when decorator is applied to @containerless element.
I added extra function to use this technic in constructor without decorator:

export function bindChildObserver(selector, viewModel, propName, element, all = true){
  const observer = new ChildObserver({
    selector,
    name: propName,
    all
  });
  const binder = observer.create(element, viewModel);
  binder.bind(viewModel);
  if (!Array.isArray(viewModel[propName]))
    viewModel[propName] = [];

  viewModel[propName].unbind = () => {
    binder.unbind()
  }
}

Usage example:

@containerless()
@inject(Element)
export class ContainerlessCustomElement {
  children = [];

  constructor(element){
    this.element = element;
    bindChildObserver('*', this, 'children', element);
  }
  
  childrenChanged(val){
    console.log('Children changed:', this.children)
  }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant