lively.ide_studio_controls_border.cp.js

import { Color, rect, Rectangle, pt } from 'lively.graphics';
import { TilingLayout, without, Morph, Label, part, add, component } from 'lively.morphic';
import { AddButton, PropertyLabel, DarkPopupWindow, DarkThemeList, PropertyLabelActive, EnumSelector, DarkNumberIconWidget, PropertyLabelHovered } from '../shared.cp.js';
import { ColorInput } from '../../styling/color-picker.cp.js';

import { arr, string } from 'lively.lang';
import { once, epiConnect, signal } from 'lively.bindings';
import { PropertySection, PropertySectionModel } from './section.cp.js';
import { DarkColorPicker } from '../dark-color-picker.cp.js';
import { PopupModel } from './popups.cp.js';

/**
 * Implements the control elements for border values. This includes the color, width and style of the border.
 * Note that the border radius is NOT part of this. The border control is used directly embedded in the sidebar
 * but is also embedded in the advanced border popup.
 */
export class BorderControlModel extends PropertySectionModel {
  static get properties () {
    return {
      targetMorph: {},
      popup: {},
      updateDirectly: { defaultValue: true },
      borderPopupComponent: {
        isComponent: true,
        get () {
          return this.getProperty('borderPopupComponent') || BorderPopup; // eslint-disable-line no-use-before-define
        }
      },
      hasMixedColor: {
        derived: true,
        get () {
          if (!this.targetMorph) return false;
          const { top, left, bottom, right } = this.targetMorph.borderColor;
          return this.updateDirectly && arr.uniqBy([top, left, bottom, right], (a, b) => a?.equals(b)).length > 1;
        }
      },
      hasMixedStyle: {
        derived: true,
        get () {
          if (!this.targetMorph) return false;
          const { top, left, bottom, right } = this.targetMorph.borderStyle;
          return this.updateDirectly && arr.uniq([top, left, bottom, right]).length > 1;
        }
      },
      hasMixedWidth: {
        derived: true,
        get () {
          if (!this.targetMorph) return false;
          const { top, left, bottom, right } = this.targetMorph.borderWidth;
          return this.updateDirectly && arr.uniq([top, left, bottom, right]).length > 1;
        }
      },

      bindings: {
        get () {
          return [
            ...super.prototype.bindings,
            {
              target: 'border color input',
              signal: 'color',
              handler: 'confirm'
            },
            {
              target: 'border width input',
              signal: 'number',
              handler: 'confirm'
            },
            {
              target: 'border style selector',
              signal: 'selection',
              handler: 'confirm'
            },
            {
              target: 'more button',
              signal: 'onMouseDown',
              handler: 'openPopup'
            },
            {
              signal: 'onMouseDown',
              handler: 'onMouseDown'
            }
          ];
        }
      }
    };
  }

  onMouseDown () {
    this.closePopup();
    this.models.borderColorInput.closeColorPicker();
  }

  onRefresh (prop) {
    if (prop === 'popup') {
      this.ui.moreButton.master.setState(this.popup ? 'active' : null);
    }
  }

  /**
   * 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.ui.moreButton.visible = !aMorph.isPath;
    if (
      Color.white.equals(this.targetMorph.borderColor.valueOf()) &&
      this.targetMorph.borderWidth.valueOf() === 0 &&
      this.targetMorph.borderStyle.valueOf() === 'solid') {
      this.deactivate();
    } else {
      this.activate(false);
      this.update();
    }
  }

  /**
   * Refresh the UI to reflect the current border of the focused morph.
   */
  update () {
    this.ui.borderStyleSelector.items = this.targetMorph.borderOptions.map(v => ({
      string: string.capitalize(v), value: v, isListItem: true
    }));
    this.withBorder(this.targetMorph.border);
  }

  /**
   * Set the input elements to display the given border value.
   * @param { BorderValue } borderValue - The value of the border (style, color, width) that is to be displayed.
   */
  withBorder (borderValue) {
    this.withoutBindingsDo(() => {
      const { borderColorInput, borderWidthInput, borderStyleSelector } = this.ui;
      const { color, width, style } = borderValue;

      if (this.hasMixedColor) {
        borderColorInput.setMixed(Object.values(this.targetMorph.borderColor).filter(c => c.isColor));
      } else {
        borderColorInput.setColor(color.valueOf ? color.valueOf() : color);
      }

      if (this.hasMixedWidth) {
        borderWidthInput.setMixed();
      } else {
        borderWidthInput.number = width.valueOf ? width.valueOf() : width;
      }

      if (this.hasMixedStyle) {
        borderStyleSelector.setMixed();
      } else {
        borderStyleSelector.selection = style.valueOf ? style.valueOf() : style;
      }
    });
  }

  /**
   * If the border is just the default morphic border
   * the controls are inactive. Activating the controls
   * causes them to be initialized to a default initial
   * border with width 1.
   * @param { Boolean }
   */
  activate (initBorder = true) {
    super.activate();
    const { elementsWrapper } = this.ui;
    elementsWrapper.visible = true;
    this.view.layout = this.view.layout.with({ padding: rect(0, 10, 0, 10) });
    if (initBorder) {
      this.targetMorph.withMetaDo({ reconcileChanges: true }, () => {
        this.targetMorph.border = { color: Color.white, width: 1, style: 'solid' };
      });
      this.update();
    }
  }

  /**
   * Resets the border to the default border value of width 0.
   * Consequently deactivates the border control for this focused morph.
   */
  deactivate () {
    super.deactivate();
    this.closePopup();
    this.models.borderColorInput.closeColorPicker();
    if (!this.targetMorph) return;
    const { elementsWrapper } = this.ui;
    elementsWrapper.visible = false;
    this.view.layout = this.view.layout.with({ padding: rect(0, 10, 0, 0) });
    this.targetMorph.withMetaDo({ reconcileChanges: true }, () => {
      this.targetMorph.borderWidth = 0;
      this.targetMorph.borderColor = Color.white;
      this.targetMorph.borderStyle = 'solid';
    });
  }

  /**
   * Opens the popup responsible for controlling the property.
   */
  openPopup () {
    const p = this.popup = this.popup || part(this.borderPopupComponent);
    p.targetMorph = this.targetMorph;
    p.openInWorld();
    p.alignAtButton(this.ui.moreButton);
    once(p, 'close', this, 'closePopup');
    epiConnect(p, 'target updated', this, 'update');
  }

  /**
 * Closes the popup responsible for controlling the property.
   */
  closePopup () {
    if (!this.popup) return;
    this.popup.close();
    this.popup = null;
  }

  /**
   * Update the current border value based on the inputs in the UI.
   * This is invoked in response to user interactions.
   */
  confirm () {
    if (!this.targetMorph && this.updateDirectly) return;
    const { borderColorInput, borderWidthInput, borderStyleSelector } = this.ui;
    const border = this.targetMorph?.border || {};
    const updateBorder = () => {
      if (!borderWidthInput.isMixed) border.width = borderWidthInput.number;
      if (!borderColorInput.isMixed) border.color = borderColorInput.colorValue;
      if (!borderStyleSelector.isMixed) border.style = borderStyleSelector.selection;
    };
    if (this.updateDirectly) {
      this.targetMorph.withMetaDo({ reconcileChanges: true }, () => {
        updateBorder();
        this.targetMorph.border = border;
      });
    } else {
      updateBorder();
      signal(this.view, 'value', border);
    }
  }
}

/**
 * The popup window to control the separate border sides of a morph
 * individually.
 */
export class BorderPopupWindow extends PopupModel {
  static get properties () {
    return {
      targetMorph: {}, // this is fine because it only works in the context of a morph
      selectedBorder: { defaultValue: 'all' },
      isHaloItem: { defaultValue: true },
      expose: {
        get () {
          return ['close', 'isHaloItem', 'isPropertiesPanelPopup', 'alignAtButton', 'targetMorph'];
        }
      },
      bindings: {
        get () {
          return [{
            target: 'close button',
            signal: 'onMouseDown',
            handler: 'close'
          }, {
            target: 'border selector wrapper',
            signal: 'onMouseDown',
            handler: 'selectBorderSide'
          }, {
            target: 'border control',
            signal: 'value',
            handler: 'updateBorder'
          }];
        }
      }
    };
  }

  get isPropertiesPanelPopup () {
    return true;
  }

  attach (view) {
    super.attach(view);
    this.selectedBorder = 'left';
  }

  onRefresh (prop) {
    if (prop === 'selectedBorder') {
      const { leftBorder, rightBorder, bottomBorder, topBorder } = this.ui;
      const valToElem = {
        left: leftBorder,
        right: rightBorder,
        bottom: bottomBorder,
        top: topBorder
      };
      Object.values(valToElem).forEach(control => control.master.setState(null));
      valToElem[this.selectedBorder].master.setState('active');
    }

    if (prop === 'targetMorph') {
      this.models.borderControl.withBorder(this.targetMorph.borderLeft);
    }
  }

  /**
   * Close the popup.
   */
  close () {
    signal(this.view, 'close');
    this.view.remove();
  }

  /**
   * Switches the currently selected border side to be controlled. Run in response to the user
   * touching the selection buttons.
   * @params { Event } evt - The mouse down event targeted to one of the border side selection buttons.
   */
  selectBorderSide (evt) {
    const { borderControl } = this.models;
    const elemToVal = {
      'left border': 'left',
      'right border': 'right',
      'top border': 'top',
      'bottom border': 'bottom'
    };
    if (elemToVal[evt.targetMorph.name]) {
      this.selectedBorder = elemToVal[evt.targetMorph.name];
      borderControl.withBorder(this.targetMorph['border' + string.capitalize(this.selectedBorder)]);
    }
  }

  updateBorder (borderStyle) {
    if (!this.targetMorph) return;
    const { borderRadius } = this.targetMorph;
    this.targetMorph.withMetaDo({ reconcileChanges: true }, () => {
      this.targetMorph['border' + string.capitalize(this.selectedBorder)] = { ...borderStyle, borderRadius };
    });
    signal(this.view, 'target updated');
  }
}

const BorderControlElements = component({
  name: 'border control elements',
  fill: Color.transparent,
  layout: new TilingLayout({
    spacing: 10,
    wrapSubmorphs: true,
    hugContentsVertically: true
  }),
  submorphs: [part(ColorInput, {
    name: 'border color input',
    viewModel: {
      colorPickerComponent: DarkColorPicker
    }
  }), {
    name: 'border width control',
    extent: pt(196.4, 25),
    fill: Color.rgba(255, 255, 255, 0),
    layout: new TilingLayout({
      axisAlign: 'center',
      justifySubmorphs: 'spaced',
      orderByIndex: true,
      padding: rect(15, 0, -10, 0),
      spacing: 10
    }),
    submorphs: [part(DarkNumberIconWidget, {
      name: 'border width input',
      viewModel: { min: 0 },
      tooltip: 'Border Width',
      extent: pt(90, 22),
      submorphs: [{
        name: 'interactive label',
        textAndAttributes: ['', {
          fontSize: 16,
          fontFamily: 'Material Icons'
        }]
      }, without('button holder')]
    }), part(EnumSelector, {
      name: 'border style selector',
      tooltip: 'Select Border Style',
      clipMode: 'hidden',
      extent: pt(90, 22),
      viewModel: {
        listAlign: 'selection',
        openListInWorld: true,
        listHeight: 500,
        listMaster: DarkThemeList,
        items: Morph.prototype.borderOptions
      },
      submorphs: [add({
        type: Label,
        name: 'interactive label',
        fontColor: Color.rgb(255, 255, 255),
        padding: rect(0, 4, 7, -4),
        textAndAttributes: ['', {
          fontSize: 18,
          fontFamily: 'Material Icons'
        }]
      }, 'label'), {
        name: 'label',
        fontSize: 12
      }]
    })]
  }]
});

const BorderPopup = component(DarkPopupWindow, {
  defaultViewModel: BorderPopupWindow,
  name: 'border popup',
  submorphs: [{
    name: 'header menu',
    submorphs: [{
      name: 'title',
      textAndAttributes: ['Advanced Stroke', null]
    }]
  }, add({
    name: 'multi border control',
    borderColor: Color.rgb(23, 160, 251),
    extent: pt(241, 115.1),
    fill: Color.rgba(0, 0, 0, 0),
    clipMode: 'hidden',
    layout: new TilingLayout({
      spacing: 10,
      axis: 'column',
      hugContentsVertically: true,
      padding: Rectangle.inset(0, 10, 0, 10)
    }),
    submorphs: [
      {
        fill: Color.transparent,
        name: 'border selector wrapper',
        extent: pt(223, 28),
        position: pt(12.6, 12),
        layout: new TilingLayout({
          hugContentsVertically: true,
          justifySubmorphs: 'spaced',
          orderByIndex: true,
          spacing: 10,
          padding: Rectangle.inset(20, 0, 0, 0)
        }),
        submorphs: [
          part(AddButton, {
            master: { states: { active: PropertyLabelActive }, hover: PropertyLabelHovered },
            name: 'left border',
            tooltip: 'Configure Left Border',
            padding: rect(4, 4, 0, 0),
            fontSize: 20,
            fontFamily: 'Material Icons',
            textAndAttributes: ['\ue22e', null]
          }),
          part(AddButton, {
            name: 'top border',
            tooltip: 'Configure Top Border',
            master: { states: { active: PropertyLabelActive }, hover: PropertyLabelHovered },
            padding: rect(4, 4, 0, 0),
            fontSize: 20,
            fontFamily: 'Material Icons',
            textAndAttributes: ['\ue232', null]
          }),
          part(AddButton, {
            master: { states: { active: PropertyLabelActive }, hover: PropertyLabelHovered },
            name: 'right border',
            tooltip: 'Configure Right Border',
            padding: rect(4, 4, 0, 0),
            fontSize: 20,
            fontFamily: 'Material Icons',
            textAndAttributes: ['\ue230', null]
          }),
          part(AddButton, {
            master: { states: { active: PropertyLabelActive }, hover: PropertyLabelHovered },
            name: 'bottom border',
            tooltip: 'Configure Bottom Border',
            padding: rect(4, 4, 0, 0),
            fontSize: 20,
            fontFamily: 'Material Icons',
            textAndAttributes: ['\ue229', null]
          })
        ]
      }, part(BorderControlElements, { name: 'border control', viewModelClass: BorderControlModel, viewModel: { updateDirectly: false } })]
  })]
});

const BorderControl = component(PropertySection, {
  name: 'border control',
  defaultViewModel: BorderControlModel,
  height: 100,
  width: 250,
  submorphs: [{
    name: 'h floater',
    submorphs: [{
      name: 'section headline',
      textAndAttributes: ['Stroke', null]
    }]
  },
  add(part(BorderControlElements, {
    name: 'elements wrapper',
    extent: pt(250, 50),
    submorphs: [
      {
        name: 'border color input',
        submorphs: [{
          name: 'hex input',
          extent: pt(80, 23)
        }]
      },
      {
        name: 'border width control',
        extent: pt(175, 25),
        fill: Color.rgba(255, 255, 255, 0),
        layout: new TilingLayout({
          axisAlign: 'center',
          justifySubmorphs: 'spaced',
          orderByIndex: true,
          padding: rect(20, 0, -10, 0),
          spacing: 10
        }),
        submorphs: [{
          name: 'border width input',
          extent: pt(59.9, 22),
          tooltip: 'Border Width',
          submorphs: [{
            name: 'value',
            extent: pt(45.7, 21)
          }]
        }]
      }, add(part(AddButton, {
        master: {
          states: { active: PropertyLabelActive },
          hover: PropertyLabelHovered,
          click: PropertyLabelActive
        },
        name: 'more button',
        tooltip: 'Open Advanced Settings',
        padding: rect(6, 4, 0, 0),
        fontSize: 18,
        fontFamily: 'Material Icons',
        textAndAttributes: ['', null]
      }))
    ]
  }))
  ]
});

export { BorderControl, BorderPopup, BorderControlElements };