Source: observer/MutationObserver.ts

import { TypeVoidFunction } from '../types/voidFunction';
import { EventEmitter } from '../event';
import { debounce as _debounce, noop } from '../functions';
import { IMutationObserverExtOption } from './types';

/**
 * @typedef {String} MutationRecordType
 * @alias MutationRecordType
 * @memberof MutationObserver
 * @see https://developer.mozilla.org/ko/docs/Web/API/MutationObserver#MutationObserverInit
 * @property {String} childList
 * @property {String} attributes
 * @property {String} characterData
 * @property {String} subtree
 */
// for jsdoc
/**
 * @export
 * @readonly
 * @enum {MutationRecordType}
 */
export enum MutationRecordType {
  childList = 'childList',
  attributes = 'attributes',
  characterData = 'characterData',
  subtree = 'subtree'
}


/**
 * MutationEvent Types
 * @event MutationObserver#MUTATION_EVENTS
 * @memberof MutationObserver
 * @property {String} CHANGE_CHILD_LIST - childList 변경 시점
 * @property {String} CHANGE_SUBTREE - subtree 변경 시점
 * @property {String} CHANGE_ATTRIBUTES - attributes 변경 시점
 * @property {String} CHANGE_CHARACTER_DATA - data 변경 시점
 * @property {String} CHANGE - 기타 변경 시점
 * @property {String} WILD_CARD - 모든 변경 시점
 */
// for jsdoc
/**
 * @export
 * @readonly
 * @enum {MUTATION_EVENTS}
 */
export enum MUTATION_EVENTS {
  CHANGE_CHILD_LIST = 'MUTATION_OBERSERVER-EVENTS-CHANGE_CHILD_LIST',
  CHANGE_SUBTREE = 'MUTATION_OBERSERVER-EVENTS-CHANGE_SUBTREE',
  CHANGE_ATTRIBUTES = 'MUTATION_OBERSERVER-EVENTS-CHANGE_ATTRIBUTES',
  CHANGE_CHARACTER_DATA = 'MUTATION_OBERSERVER-EVENTS-CHANGE_CHARACTER_DATA',
  CHANGE = 'MUTATION_OBERSERVER-EVENTS-CHANGE',
  WILD_CARD = 'MUTATION_OBERSERVER-EVENTS-CHANGE_WILCD_CARD'
}

class PureMutationObserver {
  private pure: any;

  constructor(callback: MutationCallback) {
    // TODO polyfill
    // const MutationObserver = require('mutation-observer');
    // this.pure = new MutationObserver(callback);
    this.pure = new (window as any).MutationObserver(callback);
  }

  public observe(target: HTMLElement | any, options: MutationObserverInit): void {
    this.pure.observe(target, options);
  }

  public disconnect(): void {
    this.pure.disconnect();
  }
}

/**
 * target 으로 설정된 element 의 변화를 감지
  <iframe
    src="https://codesandbox.io/embed/nonollcode-snippet-9gko8?autoresize=1&expanddevtools=1&fontsize=14&hidenavigation=1&initialpath=%2Fobserver-MutationObserver.html&module=%2Fobserver-MutationObserver.html&theme=dark"
    style="width:100%; height:500px; border:1px solid black; border-radius: 4px; overflow:hidden;"
    title="@nonoll/code-snippet"
    allow="geolocation; microphone; camera; midi; vr; accelerometer; gyroscope; payment; ambient-light-sensor; encrypted-media; usb"
    sandbox="allow-modals allow-forms allow-popups allow-scripts allow-same-origin"
  ></iframe>
 * @export
 * @class MutationObserver
 * @alias observer/MutationObserver
 * @extends EventEmitter
 * @see https://developer.mozilla.org/ko/docs/Web/API/MutationObserver
 * @see https://github.com/webmodules/mutation-observer
 * @see https://developer.mozilla.org/ko/docs/Web/API/MutationObserver#MutationObserverInit
 * @param {Partial<IMutationObserverExtOption>} option
 * @param {HTMLElement} [option.target=null] target
 * @param {Number} [option.debounce=300] debounce
 * @param {Function|null} [option.callback={@link noop}] callback function
 * @param {Object} [option.options={}]
 * @param {Boolean} [option.options.childList]
 * @param {Boolean} [option.options.attributes]
 * @param {Boolean} [option.options.characterData]
 * @param {Boolean} [option.options.subtree]
 * @param {Boolean} [option.options.attributeOldValue]
 * @param {Boolean} [option.options.characterDataOldValue]
 * @param {Array.<String>} [option.options.attributeFilter] [MDN attributeFilter]{@link https://developer.mozilla.org/en-US/docs/Web/API/MutationObserverInit/attributeFilter}
 * @example
import { MutationObserver, MUTATION_EVENTS } from '@nonoll/code-snippet/observer';

const createElement = ({ tag = 'div', id = '', style = '', value = '', text = '' }) => {
  const doc = window.document;
  const target = doc.createElement(tag);
  target.setAttribute('id', id);
  target.setAttribute('style', style);
  target.setAttribute('value', value);
  if (text) {
    target.textContent = text;
  }
  return target;
}

let observer;

const forExample = () => {
  if (observer) {
    console.log('already example');
    return;
  }

  const doc = window.document;

  const target = createElement({ id: 'example_target', style: 'border: 1px solid red' });
  doc.body.appendChild(target);

  const options = {
    childList: true,
    subtree: true
  };

  observer = new MutationObserver({ target, options });

  observer.on(MUTATION_EVENTS.WILD_CARD, (type, values) => {
    console.log('wildCard', type, values);
  }).on(MUTATION_EVENTS.CHANGE_CHILD_LIST, values => {
    console.log('childList', values);
  });

  observer.attach();

  const attachButton = createElement({ tag: 'button', text: 'observer attach' });
  const detachButton = createElement({ tag: 'button', text: 'observer detach' });
  const appendButton = createElement({ tag: 'button', text: 'append' });

  doc.body.appendChild(attachButton);
  doc.body.appendChild(detachButton);
  doc.body.appendChild(appendButton);

  attachButton.addEventListener('click', e => {
    e.preventDefault();
    console.log('attachButton clicked');
    if (!observer) {
      return;
    }
    observer.attach();
  });

  detachButton.addEventListener('click', e => {
    e.preventDefault();
    console.log('detachButton clicked');
    if (!observer) {
      return;
    }
    observer.detach();
  });

  appendButton.addEventListener('click', e => {
    e.preventDefault();
    console.log('appendButton clicked');
    if (!observer) {
      return;
    }
    const input = createElement({ tag: 'input', value: `${+new Date()}` });
    target.appendChild(input);
  });
}

forExample();
 */
export class MutationObserver extends EventEmitter {
  private target: HTMLElement;
  private callback: TypeVoidFunction;
  private observer: PureMutationObserver;
  private options: MutationObserverInit;

  constructor({ target = null, debounce = 300, callback = noop, options = {} }: Partial<IMutationObserverExtOption>) {
    super();

    const defaultOptions = { childList: true, subtree: true };

    this.target = target;
    this.options = Object.assign({}, defaultOptions, options);
    this.onMutationObserverListener = this.onMutationObserverListener.bind(this);
    /* istanbul ignore next: for noop */
    this.callback = callback || noop;

    if (debounce) {
      this.observer = new PureMutationObserver(_debounce(this.onMutationObserverListener, debounce) as MutationCallback);
    } else {
      this.observer = new PureMutationObserver(this.onMutationObserverListener);
    }
  }

  /**
   * 이벤트 감지 설정
   * @returns {MutationObserver}
   * @memberof MutationObserver
   * @example
   observer.attach();
   */
  public attach(): MutationObserver {
    this.observer.observe(this.target, this.options);
    return this;
  }

  /**
   * 이벤트 감지 등록
   * @override
   * @param {string} eventName
   * @param {TypeVoidFunction} [listener={@link noop}]
   * @param {*} [context]
   * @memberof MutationObserver
   * @listens MutationObserver#MUTATION_EVENTS
   * @returns {MutationObserver}
   */
  public on(eventName: string, /* istanbul ignore next: for noop */ listener: TypeVoidFunction = noop, context?: any): MutationObserver {
    super.on(eventName, listener, context);
    return this;
  }

  /**
   * 변화 감지 이벤트 리스너
   * @private
   * @param {MutationRecord[]} mutations [MDN MutationRecord]{@link https://developer.mozilla.org/en-US/docs/Web/API/MutationRecord}
   * @memberof MutationObserver
   * @fires MutationObserver#MUTATION_EVENTS
   */
  private onMutationObserverListener(mutations: MutationRecord[]): void {
    mutations.forEach(mutation => {
      const type = mutation.type as MutationRecordType;
      const values = { type, mutation };

      switch (type) {
        case MutationRecordType.childList:
          this.callback(values);
          this.emit(MUTATION_EVENTS.CHANGE_CHILD_LIST, values);
          this.emit(MUTATION_EVENTS.WILD_CARD, MUTATION_EVENTS.CHANGE_CHILD_LIST, values);
          break;

        case MutationRecordType.subtree:
          this.callback(values);
          this.emit(MUTATION_EVENTS.CHANGE_SUBTREE, values);
          this.emit(MUTATION_EVENTS.WILD_CARD, MUTATION_EVENTS.CHANGE_SUBTREE, values);
          break;

        case MutationRecordType.attributes:
          this.callback(values);
          this.emit(MUTATION_EVENTS.CHANGE_ATTRIBUTES, values);
          this.emit(MUTATION_EVENTS.WILD_CARD, MUTATION_EVENTS.CHANGE_ATTRIBUTES, values);
          break;

        case MutationRecordType.characterData:
          this.callback(values);
          this.emit(MUTATION_EVENTS.CHANGE_CHARACTER_DATA, values);
          this.emit(MUTATION_EVENTS.WILD_CARD, MUTATION_EVENTS.CHANGE_CHARACTER_DATA, values);
          break;

        /* istanbul ignore next: for default */
        default:
          this.callback(values);
          this.emit(MUTATION_EVENTS.CHANGE, type, values);
          this.emit(MUTATION_EVENTS.WILD_CARD, type, values);
          break;
      }
    });
  }

  /**
   * 이벤트 감지 해제
   * @returns {MutationObserver}
   * @memberof MutationObserver
   * @example
   observer.detach();
   */
  public detach(): MutationObserver {
    if (this.observer) {
      this.observer.disconnect();
    }
    return this;
  }

  /**
   * destory
   * @returns {MutationObserver}
   * @memberof MutationObserver
   * @example
   observer.destroy();
   */
  public destroy(): MutationObserver {
    this.detach();
    this.observer = null;
    return this;
  }
}