lively.ide_studio_controls_body.cp.js

import { TilingLayout, ShadowObject, component, ViewModel, part } from 'lively.morphic';
import { Color, rect, pt } from 'lively.graphics';
import { obj, arr } from 'lively.lang';
import { once, signal, noUpdate, epiConnect } from 'lively.bindings';

import { ShadowPopup, OpacityPopup, FlipPopup, TiltPopup, CursorPopup, BlurPopup, InsetShadowPopup } from './popups.cp.js';
import { PropertySection, PropertySectionModel } from './section.cp.js';
import { PropertyLabel, RemoveButton, DarkThemeList, EnumSelector, PropertyLabelActive, PropertyLabelHovered } from '../shared.cp.js';

/**
  Controls the morph's "body" which comprises all of the dynamic effect properties.
*/
export class BodyControlModel extends PropertySectionModel {
  static get properties () {
    return {
      targetMorph: {},
      dynamicPropertyComponent: {
        isComponent: true,
        get () {
          return this.getProperty('dynamicPropertyComponent') || DynamicProperty; // eslint-disable-line no-use-before-define
        }
      },
      propConfig: {
        get () {
          return this.getProperty('propConfig') || PROP_CONFIG; // eslint-disable-line no-use-before-define
        }
      },
      availableItems: {
        derived: true,
        get () {
          const res = arr.withoutAll(Object.keys(this.propConfig), this.dynamicControls.map(m => m.selectedProp));
          if (!res.includes('Drop shadow') || !res.includes('Inner shadow')) {
            // exclude all shadows if one is applied
            return arr.withoutAll(res, ['Drop shadow', 'Inner shadow']);
          }
          return res;
        }
      },
      dynamicControls: {
        derived: true,
        get () {
          return this.view?.submorphs.filter(m => m.isControl) || [];
        }
      }
    };
  }

  get addEffectButton () {
    return this.ui.addButton;
  }

  disableAddEffectButton () {
    this.addEffectButton.reactsToPointer = false;
    this.addEffectButton.visible = false;
  }

  enableAddEffectButton () {
    this.addEffectButton.reactsToPointer = true;
    this.addEffectButton.visible = true;
  }

  /**
   * Sets the current morph the effects control is focused on.
   * @params { Morph } aMorph - The morph to be focused on.
   */
  focusOn (aMorph) {
    this.targetMorph = aMorph;
    this.ensureDynamicControls();
    // disable adding effects if the selected morph already has all effects applied
    if (this.availableItems.length === 0) this.disableAddEffectButton();
    else this.enableAddEffectButton();
  }

  /**
   * Ensure that the dynamic controls applicable to the currently
   * focused morph are displayed. These are essentially alle the
   * effect-properties that diverge from the default value.
   */
  ensureDynamicControls () {
    this.dynamicControls.forEach(m => m.remove());
    // FIXME: adjusting the resize policies should automatically cause an override
    this.view.layout = this.view.layout; // ensure the layout is overridden
    for (const prop in this.propConfig) { // eslint-disable-line no-use-before-define
      const { resetValue, accessor } = this.propConfig[prop]; // eslint-disable-line no-use-before-define
      if (!obj.equals(resetValue, this.targetMorph[accessor])) {
        if (prop === 'Background Blur' && !this.targetMorph[accessor]?.backdrop) continue;
        if (prop === 'Blur' && this.targetMorph[accessor]?.backdrop) continue;
        if (prop === 'Inner shadow' && !this.targetMorph[accessor].inset) continue;
        if (prop === 'Drop shadow' && this.targetMorph[accessor].inset) continue;
        this.addDynamicProperty(prop, false, false);
      }
    }
    this.refreshItemLists();
    this.deactivate();
  }

  /**
   * The set of items selectable in the drop downs of the
   * dynamic controls varies depending on the currently
   * applied effects. This method ensure that only the available
   * items can be selected inside the drop downs. It needs to be invoked
   * any time the set of applied effects changes.
   */
  refreshItemLists () {
    if (this._refreshing) return;
    this._refreshing = true;
    this.dynamicControls.forEach(ctrl => ctrl.refreshItems(this.availableItems));
    this._refreshing = false;
    if (this.availableItems.length < 1) {
      this.disableAddEffectButton();
    } else {
      this.enableAddEffectButton();
    }
  }

  /**
   * Applies an additional effect(property) to the current morph.
   * @param { String } selectedProp - The name of the property this effect is controlled by.
   * @param { Boolean } refresh - Wether or not the selectable items of all the other dynamic
   *                              properties should be updated. Skipping this can make sense
   *                              for bulk updates.
   */
  addDynamicProperty (selectedProp, reset = true, applyDefault = true) {
    const { targetMorph, propConfig } = this;
    if (!selectedProp) selectedProp = this.availableItems[0];
    const control = this.view.addMorph(part(this.dynamicPropertyComponent, { viewModel: { targetMorph, propConfig } }));
    this.view.layout.setResizePolicyFor(control, { height: 'fixed', width: 'fill' });
    control.choose(selectedProp, reset, applyDefault);
    once(control, 'removePropertyControl', this, 'deactivate');
    epiConnect(control, 'selectedProp', this, 'refreshItemLists');
    this.refreshItemLists();
  }

  /**
   * If no effects are applied to the current morph this will
   * add a first default effect to the morph which can in turn be
   * configured as needed.
   */
  activate () {
    this.view.layout = this.view.layout.with({ padding: rect(0, 10, 0, 10) });
    this.view.master.setState(null);
    this.addDynamicProperty(null, false);
  }

  /**
   * This is only invoked when all applied effects are removed from a morph
   * or if the morph does not have any applied effects to begin with.
   * Ensures that the appearance of the body control is faded out.
   */
  deactivate () {
    this.refreshItemLists();
    // close any open popups
    this.dynamicControls.forEach(ctr => ctr.closePopup());
    if (this.dynamicControls.length > 0) {
      this.view.master.setState(null);
      return;
    }
    this.view.layout = this.view.layout.with({ padding: rect(0, 10, 0, 0) });
    this.view.master.setState('inactive');
  }
}

/**
  Dynamic properties are effects like Opacity, Blur or Drop-shadow that can
  be applied to a morph as needed. Each of these is managed individually
  by this controller.
*/
export class DynamicPropertyModel extends ViewModel {
  static get properties () {
    return {
      targetMorph: {},
      selectedProp: {
        // secure this separately
        defaultValue: 'Opacity'
      },
      accessor: {
        get () {
          return this.propConfig[this.selectedProp].accessor;
        }
      },
      propConfig: {
        serialize: false, // Dynamic properties are ephemeral, so we dont care
        get () {
          return this.getProperty('propConfig') || PROP_CONFIG; // eslint-disable-line no-use-before-define
        }
      },
      popupComponent: {
        readOnly: true,
        serialize: false,
        get () {
          return this.propConfig[this.selectedProp].popupComponent;
        }
      },
      isControl: { get () { return true; } },
      expose: {
        get () { return ['refreshItems', 'chooseDefault', 'isControl', 'selectedProp', 'choose', 'closePopup', 'applyDefault', 'removePropertyControl']; }
      },
      bindings: {
        get () {
          return [
            { target: 'effect selector', signal: 'selection', handler: 'selectProperty', updated: ($upd) => $upd(true, true) },
            { target: 'open popup', signal: 'onMouseDown', handler: 'togglePopup' },
            { target: 'remove', signal: 'onMouseDown', handler: 'removePropertyControl' }];
        }
      }
    };
  }

  /**
   * Programatically sets the selected property of this dynamic property.
   * @param { string } prop - The name of the dynamic property.
   * @param { boolean } [resetValue = false] - Wether or not to reset the previously selected property on the target morph.
   * @param { boolean } [applyDefault = false] - Wether or not to apply the default value of the chosen property right away.
   */
  choose (prop, resetValue = true, applyDefault = false) {
    noUpdate(() => {
      this.ui.effectSelector.selection = prop;
    });
    this.selectProperty(resetValue, applyDefault);
  }

  /**
   * Automatically selects the first effect property currently
   * available.
   */
  chooseDefault () {
    this.choose(this.ui.effectSelector.items[0].value);
  }

  /**
   * Sets the selected property based on the selection in the UI
   * controlled by the user.
   * @param { boolean } [resetValue = false] - Wether or not to reset the previously selected property on the target morph.
   * @param { boolean } [applyDefault = false] - Wether or not to apply the default value of the chosen property right away.
   */
  selectProperty (resetValue = false, applyDefault = false) {
    const { effectSelector } = this.ui;
    if (resetValue &&
        this.selectedProp &&
        this.selectedProp !== effectSelector.selection) {
      this.resetProperty(); // only when we do that interactively
    }
    this.selectedProp = effectSelector.selection;
    signal(this.view, 'selectedProp', this.selectedProp);
    if (applyDefault) this.applyDefault();
  }

  /**
   * Sets the currently selected property to the corresponding "default" value of that property.
   * Note that this is not to be confused with the value we employ for resetting a property.
   * Resetting a property will turn it into a neutral state.
   * Default values on the other hand are decisively chosen to have a characteristic effect
   * in order to illustrate what the property controls in the morph's appearance.
   */
  applyDefault () {
    this.targetMorph.withMetaDo({ reconcileChanges: true }, () => {
      this.targetMorph[this.accessor] = PROP_CONFIG[this.selectedProp].defaultValue; // eslint-disable-line no-use-before-define
    });
  }

  /**
   * Sets the selected property based on the selection in the UI
   * controlled by the user.
   */
  refreshItems (openItems) {
    const { effectSelector } = this.ui;
    let availableItems = [this.selectedProp, ...openItems];
    if (['Drop shadow', 'Inner shadow'].includes(this.selectedProp)) {
      availableItems = arr.uniq(['Drop shadow', 'Inner shadow'].concat(availableItems));
    }
    effectSelector.items = arr.compact(availableItems);
    noUpdate(() => {
      effectSelector.selection = this.selectedProp;
    });
  }

  /**
   * Resets the currently controlled effect property back to its default value
   * in order to "leave no trace behind".
   */
  resetProperty () {
    this.targetMorph.withMetaDo({ reconcileChanges: true }, () => {
      this.targetMorph[this.accessor] = PROP_CONFIG[this.selectedProp].resetValue; // eslint-disable-line no-use-before-define
    });
  }

  /**
   * Removes this dynamic property from the current morph.
   */
  removePropertyControl () {
    this.view.remove();
    signal(this.view, 'removePropertyControl');
    this.resetProperty();
  }

  /**
   * Toggles the popup that controls the current effect property.
   */
  togglePopup () {
    this.popup ? this.closePopup() : this.openPopup();
  }

  /**
   * Get the default configuration of the popup for this property.
   */
  getInitPopupProps () {
    return PROP_CONFIG[this.selectedProp].defaultModelProps(this.targetMorph); // eslint-disable-line no-use-before-define
  }

  /**
   * Update the current morph to reflect the changes.
   */
  confirm (v) {
    const { converter } = PROP_CONFIG[this.selectedProp]; // eslint-disable-line no-use-before-define
    this.view.withMetaDo({ reconcileChanges: true }, () => {
      this.targetMorph[this.accessor] = converter ? converter(this.targetMorph, v) : v;
    });
  }

  /**
   * Opens the popup responsible for controlling the property.
   */
  openPopup () {
    this.view.fill = Color.gray.withA(0.3);
    const p = this.popup = part(this.popupComponent, { viewModel: this.getInitPopupProps() }).openInWorld();
    /*
      fixme: the problem is that the css layout of the popup window is not yet applied
      beacuse the vdom has not yet rendered the layout and we can not determine the
      total height via measuring. This will be gone once we move away from the vdom issue.
    */
    p.env.forceUpdate(p);
    p.topRight = this.view.globalBounds().topLeft();
    p.topLeft = this.world().visibleBounds().translateForInclusion(p.globalBounds()).topLeft();
    once(p, 'remove', this, 'closePopup');
    epiConnect(p, 'value', this, 'confirm');
  }

  /**
   * Closes the popup responsible for controlling the property.
   */
  closePopup () {
    this.view.fill = Color.transparent;
    if (!this.popup) return;
    this.popup.remove();
    this.popup = null;
  }
}

/*

handles fill, shadow, opacity and clipping
BodyControl.openInWorld()
*/
const BodyControl = component(PropertySection, {
  name: 'body control',
  defaultViewModel: BodyControlModel,
  submorphs: [{
    name: 'h floater',
    submorphs: [{
      name: 'section headline',
      textAndAttributes: ['Effects', null]
    }]
  }]
});

const PROP_CONFIG = {
  'Drop shadow': {
    accessor: 'dropShadow',
    defaultModelProps: (target) => {
      const v = target.dropShadow;
      const opts = { fastShadow: v && v.fast, insetShadow: false };
      if (v) opts.shadowValue = v;
      return opts;
    },
    popupComponent: ShadowPopup,
    resetValue: null,
    defaultValue: new ShadowObject({ color: Color.black, blur: 15 })
  },
  'Inner shadow': {
    accessor: 'dropShadow',
    defaultModelProps: (target) => {
      const v = target.dropShadow;
      const opts = { fastShadow: v && v.fast, insetShadow: true };
      if (v) opts.shadowValue = v;
      return opts;
    },
    popupComponent: InsetShadowPopup,
    resetValue: null,
    defaultValue: new ShadowObject({ color: Color.black, inset: true, blur: 15 })
  },
  Opacity: {
    accessor: 'opacity',
    defaultModelProps: (target) => {
      return { value: target.opacity };
    },
    converter: (target, newValue) => {
      return { ...target.blur, value: newValue };
    },
    popupComponent: OpacityPopup,
    resetValue: 1,
    defaultValue: 1
  },
  Blur: {
    accessor: 'blur',
    defaultModelProps: target => {
      let { value = target.blur } = target.blur;
      return { value };
    },
    converter: (target, newValue) => {
      return { ...target.blur, value: newValue, backdrop: false };
    },
    popupComponent: BlurPopup,
    resetValue: 0,
    defaultValue: { value: 1, backdrop: false }
  },
  'Background Blur': {
    accessor: 'blur',
    defaultModelProps: target => {
      let { value = target.blur } = target.blur;
      return { value };
    },
    converter: (target, newValue) => {
      return { ...target.blur, value: newValue, backdrop: true };
    },
    popupComponent: BlurPopup,
    resetValue: 0,
    defaultValue: { value: 1, backdrop: true }
  },
  Cursor: {
    accessor: 'nativeCursor',
    popupComponent: CursorPopup,
    resetValue: 'auto',
    defaultModelProps: target => {
      return { selection: target.nativeCursor };
    },
    defaultValue: 'pointer'
  },
  Tilted: {
    accessor: 'tilted',
    popupComponent: TiltPopup,
    resetValue: 0,
    defaultModelProps: target => {
      return { value: target.tilted };
    },
    defaultValue: 0.5
  },
  Flipped: {
    accessor: 'flipped',
    popupComponent: FlipPopup,
    resetValue: 0,
    defaultModelProps: target => {
      return { value: target.flipped };
    },
    defaultValue: 0.5
  }
};

const DynamicProperty = component({
  defaultViewModel: DynamicPropertyModel,
  name: 'dynamic property',
  layout: new TilingLayout({
    axisAlign: 'center',
    justifySubmorphs: 'spaced',
    padding: rect(10, 0, 0, 0),
    resizePolicies: [['effect selector', {
      height: 'fixed',
      width: 'fill'
    }]],
    spacing: 5
  }),
  fill: Color.rgba(255, 255, 255, 0),
  extent: pt(183.2, 30),
  submorphs: [part(PropertyLabel, {
    name: 'open popup',
    tooltip: 'Open Property Popup',
    padding: rect(4, 0, 0, 0),
    master: {
      auto: PropertyLabel,
      hover: PropertyLabelHovered,
      click: PropertyLabelActive
    }
  }), part(EnumSelector, {
    name: 'effect selector',
    clipMode: 'hidden',
    viewModel: {
      listAlign: 'selection',
      openListInWorld: true,
      listMaster: DarkThemeList,
      listHeight: 1000,
      // these should be set by the model
      items: Object.keys(PROP_CONFIG)
    }
  }), part(RemoveButton, {
    master: { auto: RemoveButton, hover: PropertyLabelHovered },
    name: 'remove',
    tooltip: 'Remove Property/Effect',
    padding: rect(4, 4, 0, 0)
  })]
});

export { BodyControl, DynamicProperty };