lively.ide_components_editor.js

import { ComponentDescriptor, morph, component } from 'lively.morphic';
import { ExpressionSerializer } from 'lively.serializer2';
import { string, obj } from 'lively.lang';
import module from 'lively.modules/src/module.js';
import { withAllViewModelsDo } from 'lively.morphic/components/policy.js';
import lint from '../js/linter.js';
import { ComponentChangeTracker } from './change-tracker.js';
import { findComponentDef, getComponentNode, scanForNamesInGenerator } from './helpers.js';
import { replaceComponentDefinition, Reconciliation, createInitialComponentDefinition } from './reconciliation.js';
import { parse } from 'lively.ast';
import { once } from 'lively.bindings';
import { evalAsSpec } from 'lively.morphic/components/core.js';

const metaSymbol = Symbol.for('lively-module-meta');
const exprSerializer = new ExpressionSerializer();

/**
 * We implement our editor by means of a relatively simple
 * ComponentDescriptor subclass. This InteractiveComponentDescriptor
 * allows us to open and close direct manipulation editing sessions
 * and handles all the bookkeeping in the background.
 */
export class InteractiveComponentDescriptor extends ComponentDescriptor {
  get moduleName () { return this[metaSymbol].moduleId; }

  get targetModule () { return module(this.System, this.moduleName); }

  get componentName () { return this[metaSymbol].exportedName; }

  get isInteractive () { return true; }

  get isScoped () { return !!this[metaSymbol].path; }

  static prepareUsedNamesSet (generatorFunction) {
    const usedNames = new Set(scanForNamesInGenerator(generatorFunction));
    usedNames.initialSize = usedNames.size;
    return usedNames;
  }

  static for (generatorFunction, meta, system, recorder, declaredName) {
    const newDescr = super.for(generatorFunction, meta, system); // force a new descriptor
    if (recorder?.__revived__) {
      // if we are in a bundle and this part of the bundle has been revived,
      // we just implement the behavior of the base class. The interactive
      // capabilities are not required in frozen parts of the system.
      return newDescr;
    }
    const prev = recorder?.[declaredName];
    if (prev?.isComponentDescriptor && !recorder?.__module_hash__) {
      if (prev.constructor !== this) { obj.adoptObject(prev, this); }
      const dependants = prev.getDependants(true);
      prev.stylePolicy = newDescr.stylePolicy;
      let c;
      if (c = prev._cachedComponent) {
        delete prev._cachedComponent;
        prev.ensureComponentMorphUpToDate(c);
      }
      dependants.forEach(m => {
        evalAsSpec(() => {
          m.master = exprSerializer.deserializeExprObj(m.master.__serialize__());
        });
      });
      newDescr.refreshDependants(dependants);
      prev.checkForGeneratedNames();
      return prev;
    }
    return newDescr;
  }

  init (generatorFunctionOrInlinePolicy, meta = { moduleId: import.meta.url }) {
    super.init(generatorFunctionOrInlinePolicy, meta);
    this.subscribeToParent();
    this.refreshDependants();
    this.previouslyRemovedMorphs = new WeakMap();
    this.checkForGeneratedNames();
    return this;
  }

  ensureNamesInSourceCode () {
    if (this._hasGeneratedNames) {
      this._hasGeneratedNames = false;
      Reconciliation.ensureNamesInSourceCode(this);
    }
  }

  checkForGeneratedNames () {
    this._hasGeneratedNames = morph.usedNames?.size > morph.usedNames?.initialSize;
  }

  getModuleSource () {
    return this.targetModule._source;
  }

  getASTNode (sourceCode = this.moduleSource) {
    if (obj.isString(sourceCode)) sourceCode = parse(sourceCode);
    return getComponentNode(sourceCode, this.componentName);
  }

  recordRemovedMorph (removedMorph, meta) {
    this.previouslyRemovedMorphs.set(removedMorph, meta);

    once(removedMorph, 'removeMorph', () => {
      this.previouslyRemovedMorphs.delete(removedMorph);
    });
  }

  /**
   * Returns (and initializes) a morph that represents the component definition
   * and can be directly manipulated via Halo and other tools in order
   * to adjust the component definition.
   * @returns { Morph } The component morph.
   */
  getComponentMorph (alive = false) {
    let c = this._cachedComponent;
    if (c) return c;
    c = morph(this.stylePolicy.asBuildSpec(true));
    c.hasFixedPosition = false; // always ensure components are not rendered fixed (this fucks up the halo interface)
    c[metaSymbol] = this[metaSymbol];
    c.isComponent = true;
    c._context = $world;
    alive && withAllViewModelsDo(c, m => m.viewModel.attach(m));
    c.name = string.decamelize(this.componentName);
    return this._cachedComponent = c;
  }

  /**
   * Replace an existing component morph with a version that is ensured
   * to by consistent with the policy wrapped by the descriptor.
   * @param { Morph } [c] - The component morph.
   */
  ensureComponentMorphUpToDate (c) {
    if (c?.world()) {
      const sceneGraph = c.world().sceneGraph;
      const pos = c.position;
      const prevOwner = c.owner;
      c.remove();
      if (sceneGraph) sceneGraph.refresh();
      const updatedComponentMorph = prevOwner.addMorph(this.getComponentMorph());
      updatedComponentMorph.position = pos;
      if (!updatedComponentMorph._changeTracker) {
        new ComponentChangeTracker(updatedComponentMorph, this);
      }
    }
  }

  /**
   * In order to reliably reset any unintended changes that happen due to
   * direct manipulation this method allows to take a snapshot of the current
   * component definition as code.
   */
  async ensureComponentDefBackup () {
    if (this._backupComponentDef) return;
    const { moduleId, exportedName } = this[metaSymbol];
    const source = await this.targetModule.source();
    const { start, end } = findComponentDef(await this.targetModule.ast(), exportedName);
    this._backupComponentDef = source.slice(start, end);
  }

  /**
   * Revert the component definition back to when we started the edit session.
   */
  async reset () {
    const dependants = this.stylePolicy._dependants;
    if (this._backupComponentDef) { await replaceComponentDefinition(this._backupComponentDef, this.componentName, this.targetModule); }
    delete this._backupComponentDef;
    this._dirty = false;
    this.stylePolicy._dependants = dependants;
    await this.withDerivedComponentsDo(async cb => {
      await cb.reset();
    });
  }

  /**
   * Initiates a direct manipulation editing session of the component definition.
   * @returns { Promise<Morph> } The component morph.
   */
  async edit (alive = false) {
    const c = this.getComponentMorph(alive);
    this.ensureComponentDefBackup();
    if (!c._changeTracker) { new ComponentChangeTracker(c, this); }
    return await c._changeTracker.whenReady() && c;
  }

  stopEditSession () {
    this._backupComponentDef = null;
    this._cachedComponent = null;
  }

  /**
   * Subscribe to any changes that happen to the parent policy if present.
   */
  subscribeToParent () {
    const { parent } = this.stylePolicy;
    if (parent) {
      const dependants = parent._dependants || new Set();
      dependants.add(exprSerializer.exprStringEncode(this.stylePolicy.__serialize__({ expressionSerializer: exprSerializer })));
      parent._dependants = dependants;
    }
  }

  getDependants (immediate = false) {
    return $world.withAllSubmorphsSelect(m =>
      m.master?.uses(this.stylePolicy, immediate)
    );
  }

  /**
   * Traverses the world and manually applys each morph which is styled
   * via a policy derived from this one.
   */
  refreshDependants (dependants = this.getDependants()) {
    dependants.forEach(m => m.master?.applyIfNeeded(true));
  }

  async withDerivedComponentsDo (cb) {
    if (!this.stylePolicy._dependants) return;
    for (let expr of [...this.stylePolicy._dependants.values()]) {
      const policyOrDescr = exprSerializer.deserializeExpr(expr);
      // this will most often reference an inline policy,
      // and not the actual component descriptor
      let descr;
      if (policyOrDescr[metaSymbol]?.path?.length > 0) {
        descr = module(this.System, policyOrDescr[metaSymbol].moduleId).recorder[policyOrDescr[metaSymbol].exportedName];
      } else descr = policyOrDescr;
      if (descr) await cb(InteractiveComponentDescriptor.ensureInteractive(descr));
    }
  }

  /**
   * This method recursively checks if there are any name conflicts
   * within the component scope or any of the scopes in any of the
   * derived components. If there is a conflict, the default resolution
   * is to adjust the addedMorph's name in such a way that it no longer
   * causes a name collision.
   * Note, that it does not matter wether or not the `addedMorph` is a entirely
   * new morph or one that was reintroduced. At all times the renaming is applied
   * to the `addedMorph`. This also means that derived adjustments will have to
   * alter the name that they reference `addedMorph` by, if they are part
   * of a **reintroduction**. This concerns cases in which we remove a morph 'bob' from a component,
   * rename another morph in the component to 'bob' and then reintroduce the removed 'bob' once again.
   */
  ensureNoNameCollisionInDerived (nameCandidate, skip = false) {
    return this.stylePolicy.ensureNoNameCollisionInDerived(nameCandidate, this, skip);
  }

  getSourceCode () {
    this._cachedComponent = null; // ensure to recreate the component morph
    return lint(createInitialComponentDefinition(this.getComponentMorph()))[0];
  }

  makeDirty () {
    this.ensureComponentDefBackup();
    this._dirty = true;
  }

  isDirty () {
    return this._dirty;
  }

  static ensureInteractive (descr) {
    if (!this._descriptorCache) { this._descriptorCache = new WeakMap(); }
    if (descr.isPolicy) {
      return this._descriptorCache.get(descr) ||
        this._descriptorCache
          .set(descr, new InteractiveComponentDescriptor(descr, descr[metaSymbol]))
          .get(descr);
    }
    obj.adoptObject(descr, InteractiveComponentDescriptor);
    if (!descr.previouslyRemovedMorphs) descr.previouslyRemovedMorphs = new WeakMap();
    return descr;
  }
}