lively.ide_components_change-tracker.js

import { obj, promise } from 'lively.lang';
import module from 'lively.modules/src/module.js';
import { connect } from 'lively.bindings';
import { Reconciliation } from './reconciliation.js';

/**
 * ComponentChangeTrackers listen for evals of the componet module
 * and then make sure the new master components replace the currently
 * visible ones seamlessly so direct manipulation does not happen on
 * abandoned master components any more.
 * They also listen for changes on the component morphs in case they
 * are open and reconcile the corresponding source code to reflect these changes.
 */
export class ComponentChangeTracker {
  constructor (aComponent, descriptor, S = System) {
    this.trackedComponent = aComponent;
    this.componentModuleId = aComponent[Symbol.for('lively-module-meta')].moduleId;
    this.componentModule = module(S, this.componentModuleId);
    this.componentDescriptor = descriptor;
    connect(aComponent, 'onSubmorphChange', this, 'processChangeInComponent', { garbageCollect: true });
    connect(aComponent, 'onChange', this, 'processChangeInComponent', { garbageCollect: true });
    aComponent._changeTracker = this;
  }

  /**
   * Returns the policy that is wrapped by the component descriptor.
   * @type { StylePolicy }
   */
  get componentPolicy () { return this.componentDescriptor.stylePolicy; }

  /**
   * The current source of the module object that manages
   * the source code this component is defined in.
   * @type { string }
   */
  get currentModuleSource () {
    return this.componentModule._source;
  }

  /**
   * Returns a promise that once resolves denotes that the
   * tracker is ready to reconcile changes with the module.
   * @returns { Promise<boolean> }
   */
  whenReady () {
    return !!this.componentModule.source();
  }

  /** .
   * When processing changes have to wait for the module system, since the writing of files
   * to the file system is asynchronous.
   * This method returns a promise that will resolve once all the changes that are being
   * processed by the tracker have been effective.
   * @returns { Promise<boolean> }
   */
  onceChangesProcessed () {
    return this._finishPromise ? this._finishPromise.promise : Promise.resolve(true);
  }

  /**
   * Compares two trackers in order to check if they are equivalent.
   * @param { ComponentChangeTracker } otherTracker
   * @param { string } componentName - Name of the component to track.
   * @returns { boolean }
   */
  equals (otherTracker, componentName) {
    return this.componentModuleId === otherTracker.componentModuleId &&
           otherTracker.trackedComponent.name === componentName;
  }

  /**
   * Checks if a given morph's position is dictacted
   * by a layout. In those cases reconciling position
   * changes can be skipped.
   * @param { Morph } aMorph - The morph to check for.
   * @returns { boolean }
   */
  isPositionedByLayout (aMorph) {
    const l = aMorph.isLayoutable && aMorph.owner && aMorph.owner.layout;
    if (l?.name?.call() === 'Constraint') return false;
    if (aMorph.owner?.textAndAttributes?.includes(aMorph)) return true;
    return l && l.layoutableSubmorphs.includes(aMorph);
  }

  /**
   * Filter function that allows us to check if we need
   * to reconcile a particular change or not.
   * ChangeTrackers work on a whitelisting policy. That is, for a
   * change to even be considered, it needs have set the meta property
   * `reconcileChanges` to `true`.
   * @param { object } change - The change object to check
   * @returns { boolean }
   */
  ignoreChange (change) {
    if (!change.meta?.reconcileChanges) return true;
    if (change.prop === 'name') return false;
    if (change.prop?.startsWith('_')) return true;
    if (change.prop === 'position' && (change.target === this.trackedComponent || this.isPositionedByLayout(change.target))) return true;
    if (change.prop &&
        change.prop !== 'textAndAttributes' &&
        change.prop !== 'vertices' &&
        change.prop !== 'master' &&
        !change.target.styleProperties.includes(change.prop)) return true;
    if (change.target.epiMorph) return true;
    if (['addMorphAt', 'removeMorph'].includes(change.selector) &&
        change.args.some(m => m.epiMorph)) return true;
    if (!['addMorphAt', 'removeMorph'].includes(change.selector) && change.meta && change.meta.isLayoutAction) return true;
    if (change.selector === 'addMorphAt' && change.target.textAndAttributes?.includes(change.args[0])) return true;
    if (!change.selector &&
        change.prop !== 'layout' &&
        change.prop !== 'vertices' &&
        obj.equals(change.prevValue, change.value)) return true;
    return false;
  }

  /**
   * Given a change, returns wether or not we can delay the reconciliation
   * of that change to a later time. This can be beneficial, since we
   * sometimes want to avoid degrading performance when properties that
   * are expensive to reconcile are changed in quick succession.
   * @param { object } change - The change to check.
   * @returns { boolean }
   */
  // FIXME: should be investigated again when we look at performance optimizations!
  adjournChange (change) {
    const isReplaceChange = change.selector === 'replace';
    if (!isReplaceChange) return false;
    const insertsMorph = change.args[1].find(m => m?.isMorph);
    const removesMorph = change.undo.args[1].find(m => m?.isMorph);
    return !insertsMorph && !removesMorph;
  }

  /**
   * Called in response to changes in the component morph in order to reconcile
   * these changes in the source code as well as the currently initialized policy object.
   * @param { object } change - The change to reconcile.
   */
  async processChangeInComponent (change) {
    if (this.ignoreChange(change)) return;
    this._finishPromise = promise.deferred();
    Promise.resolve(Reconciliation.perform(this.componentDescriptor, change)).then(() => this._finishPromise.resolve());
    this.componentDescriptor.makeDirty();
    this.componentDescriptor.refreshDependants();
  }
}