lively.ide_studio_controls_text.cp.js

import { pt, rect, Color } from 'lively.graphics';
import { TilingLayout, config, Icon, ViewModel, part, add, component } from 'lively.morphic';
import { obj } from 'lively.lang';
import {
  EnumSelector,
  BoundsContainerHovered,
  BoundsContainerInactive,
  PropertyLabelHovered,
  DarkNumberIconWidget,
  PropertyLabel,
  DarkThemeList
} from '../shared.cp.js';
import { ColorInput } from '../../styling/color-picker.cp.js';
import { PropertySection } from './section.cp.js';
import { disconnect, epiConnect } from 'lively.bindings';

import { DarkColorPicker } from '../dark-color-picker.cp.js';
import { PaddingControlsDark } from './popups.cp.js';
import { availableFonts, DEFAULT_FONTS } from 'lively.morphic/rendering/fonts.js';
import { fontWeightToString, fontWeightNameToNumeric } from 'lively.morphic/rendering/font-metric.js';
import { sanitizeFont } from 'lively.morphic/helpers.js';
import { rainbow } from 'lively.graphics/color.js';
import { openFontManager } from '../font-manager.cp.js';
import { capitalize } from 'lively.lang/string.js';

/**
 * This model provides functionality for rich-text-editing frontends.
 * There are two distinct use cases for this: Changing the global defaults of a Text,
 * or changing the textattributes of an selection. Those two modes are utilized by different frontends
 * (side bar vs. formatting popup). The mode is controlled by the `globalMode` flag on the model.
 */
export class RichTextControlModel extends ViewModel {
  static get properties () {
    return {
      globalMode: {
        defaultValue: false
      },
      targetMorph: {},
      styledProps: {
        readOnly: true,
        get () {
          return ['fontSize', 'lineHeight', 'letterSpacing', 'fontColor', 'fontFamily', 'fontWeight', 'textAlign', 'textDecoration', 'fontStyle'];
        }
      },
      expose: {
        get () {
          return ['update', 'targetMorph'];
        }
      },
      bindings: {
        get () {
          return [
            { target: 'font family selector', signal: 'selection', handler: 'changeFontFamily' },
            { target: 'font weight selector', signal: 'selection', handler: 'changeFontWeight' },
            { target: 'line wrapping selector', signal: 'selection', handler: 'changeLineWrapping' },
            { target: 'font size input', signal: 'number', handler: 'changeFontSize' },
            { target: 'line height input', signal: 'number', handler: 'changeLineHeight' },
            { target: 'letter spacing input', signal: 'number', handler: 'changeLetterSpacing' },
            { target: 'font color input', signal: 'color', handler: 'changeFontColor' },
            { target: 'alignment controls', signal: 'onMouseDown', handler: 'selectTextAlignment' },
            { target: 'resizing controls', signal: 'onMouseDown', handler: 'selectBoundsResizing' },
            { target: 'inline link', signal: 'onMouseDown', handler: 'changeLink' },
            { target: 'italic style', signal: 'onMouseDown', handler: 'toggleItalic' },
            { target: 'quote', signal: 'onMouseDown', handler: 'toggleQuote' },
            { target: 'underline style', signal: 'onMouseDown', handler: 'toggleUnderline' },
            { target: 'padding controls', signal: 'paddingChanged', handler: 'changePadding' },
            { target: 'add button', signal: 'onMouseDown', handler: 'installCustomFont' }
          ];
        }
      }
    };
  }

  installCustomFont () {
    const p = openFontManager();
    p.env.forceUpdate(p);
    p.topRight = this.view.globalBounds().topLeft();
    p.topLeft = this.world().visibleBounds().translateForInclusion(p.globalBounds()).topLeft();
    this.update();
  }

  focusOn (target) {
    if (this.targetMorph) { disconnect(this.targetMorph, 'selectionChange', this, 'update'); }
    if (target.isText || target.isLabel) { this.targetMorph = target; }
    this.update();
    // also watch for changes in selection
    if (target.isText && !this.globalMode) {
      epiConnect(target, 'selectionChange', this, 'update');
    }
  }

  attach (view) {
    super.attach(view);
    this.update();
  }

  update () {
    this.withoutBindingsDo(() => {
      this.withContextDo(text => {
        const {
          fontFamilySelector, fontWeightSelector, fontSizeInput,
          lineHeightInput, letterSpacingInput, fontColorInput,
          leftAlign, centerAlign, rightAlign, blockAlign, inlineLink,
          italicStyle, underlineStyle, quote,
          lineWrappingSelector, paddingControls
        } = this.ui;

        const fontItemCreator = font => {
          return {
            value: font,
            string: font.name,
            isListItem: true,
            tooltip: font.name
          };
        };

        const shortcutFonts = [{ name: 'Monospace' }, { name: 'Sans-Serif' }, { name: 'Serif' }];
        const shortcutFontItems = shortcutFonts.map(fontItemCreator);
        shortcutFontItems[2].style = {
          borderWidth: { bottom: 5, top: 0, left: 0, right: 0, top: 0 },
          borderStyle: { bottom: 'double', top: 'none', left: 'none', right: 'none', top: 'none' }
        };

        if ($world.openedProject) {
          const projectFontItems = $world.openedProject.projectFonts.map(fontItemCreator);
          if (projectFontItems.length > 0) {
            projectFontItems[projectFontItems.length - 1].style = {
              borderWidth: { bottom: 5, top: 0, left: 0, right: 0, top: 0 },
              borderStyle: { bottom: 'double', top: 'none', left: 'none', right: 'none', top: 'none' }
            };
          }
          this.models.fontFamilySelector.items = projectFontItems.concat(shortcutFontItems).concat(DEFAULT_FONTS.map(fontItemCreator));
        } else this.models.fontFamilySelector.items = shortcutFontItems.concat(DEFAULT_FONTS.map(fontItemCreator));

        fontFamilySelector.selection = text.fontFamily?.replace(/^"(.*)"$/, '$1');
        if (text.fontFamilyMixed || this.globalMode && text.hasMixedTextAttributes('fontFamily')) fontFamilySelector.setMixed();

        fontWeightSelector.selection = /\d/.test(text.fontWeight) ? fontWeightToString(text.fontWeight) : capitalize(text.fontWeight);

        if (text.fontWeightMixed || this.globalMode && text.hasMixedTextAttributes('fontWeight')) fontWeightSelector.setMixed();
        this.updateFontWeightChoices(text.fontFamily);

        fontSizeInput.number = text.fontSize;
        if (text.fontSizeMixed || this.globalMode && text.hasMixedTextAttributes('fontSize')) fontSizeInput.setMixed();

        lineHeightInput.number = text.lineHeight;
        if (text.lineHeightMixed || this.globalMode && text.hasMixedTextAttributes('lineHeight')) lineHeightInput.setMixed();

        if (letterSpacingInput) {
          letterSpacingInput.number = text.letterSpacing;
          if (text.letterSpacingMixed || this.globalMode && text.hasMixedTextAttributes('letterSpacing')) letterSpacingInput.setMixed();
        }

        if (lineWrappingSelector) {
          lineWrappingSelector.selection = text.lineWrapping;
          if (text.lineWrappingMixed || this.globalMode && text.hasMixedTextAttributes('lineWrapping')) lineWrappingSelector.setMixed();
        }

        fontColorInput.setColor(text.fontColor);
        if (text.fontColorMixed || this.globalMode && text.hasMixedTextAttributes('fontColor')) fontColorInput.setMixed(rainbow);

        leftAlign.master.setState(text.textAlign === 'left' ? 'active' : null);
        centerAlign.master.setState(text.textAlign === 'center' ? 'active' : null);
        rightAlign.master.setState(text.textAlign === 'right' ? 'active' : null);
        blockAlign.master.setState(text.textAlign === 'justify' ? 'active' : null);
        italicStyle.master.setState(text.fontStyle === 'italic' ? 'active' : null);
        underlineStyle.master.setState(text.textDecoration === 'underline' ? 'active' : null);
        if (quote) quote.master.setState(text.quote === 1 ? 'active' : null);
        if (inlineLink) inlineLink.master.setState(text.link ? 'active' : null);
        if (paddingControls) paddingControls.startPadding(text.padding);
      });
    });
  }

  /*
   * Execute the callback with the currently focused text/label morph or the
   * current selection (if present).
   * @param { Function } cb - Callback to be called with the context as the argument.
   */
  withContextDo (cb) {
    const { targetMorph } = this;
    if (!targetMorph) return;
    const sel = targetMorph.selection;
    if (sel && !sel.isEmpty() && !this.globalMode) cb({ ...obj.select(targetMorph, this.styledProps), ...targetMorph.getStyleInRange(sel) });
    else cb(targetMorph);
  }

  /*
   * Changes the text attributes of the entire morph or the selection of a text morph.
   * @param { String } name - Property name to be changed.
   * @param { Function | Number | String } valueOrFn - The new value of the property or a function where
   *                                                   the return method will be used as the new property value.
   */
  confirm (name, valueOrFn) {
    const { targetMorph } = this;
    if (!targetMorph) return;
    targetMorph.withMetaDo({ reconcileChanges: true }, () => {
      const sel = targetMorph.selection;
      if (targetMorph.isLabel || sel && sel.isEmpty() || this.globalMode) {
        targetMorph[name] = typeof valueOrFn === 'function'
          ? valueOrFn(targetMorph[name])
          : valueOrFn;
      } else {
        targetMorph.undoManager.group();
        targetMorph.changeStyleProperty(name,
          oldVal => typeof valueOrFn === 'function'
            ? valueOrFn(oldVal)
            : valueOrFn);
        targetMorph.undoManager.group();
      }
    });
  }

  /*
   * Opens a prompt that allows to change/set the link of a selection in
   * the currently focused text morph.
   */
  async changeLink () {
    const { targetMorph } = this;
    const sel = targetMorph.selection;
    const { link } = targetMorph.getStyleInRange(sel);
    const newLink = await this.world().prompt('Set link', { input: link || 'https://' });
    targetMorph.undoManager.group();
    targetMorph.setStyleInRange({ link: newLink || undefined }, sel);
    targetMorph.undoManager.group();
  }

  /*
   * Set the alignment of the text inside the selection/morph based on which button was
   * clicked in the UI.
   * @param { Event } evt - Mouse down event triggered in one of the text alignment buttons.
   */
  selectTextAlignment (evt) {
    const align = ({
      'left align': 'left',
      'right align': 'right',
      'center align': 'center',
      'block align': 'justify'
    })[evt.targetMorph.name];
    if (align) {
      if (this.globalMode) this.targetMorph.removePlainTextAttribute('textAlign');
      this.confirm('textAlign', align);
      this.update();
    }
  }

  /*
   * Selects the desired resizing behavior of the text morph dispatched
   * on the button that was clicked inside the UI.
   * @param { Event } evt - Mouse down event targeted on one of the resizing buttons.
   */
  selectBoundsResizing (evt) {
    // only apply to entire morph
    const text = this.targetMorph;
    switch (evt.targetMorph.name) {
      case 'auto width':
        text.fixedWidth = false;
        text.fixedHeight = true;
        break;
      case 'auto height':
        text.fixedWidth = true;
        text.fixedHeight = false;
        break;
      case 'fixed extent':
        text.fixedWidth = true;
        text.fixedHeight = true;
        break;
    }
    this.update();
  }

  changeLineWrapping () {
    // depending on the line wrapping we adjust the bounds resizing
    const text = this.targetMorph;
    if (text) {
      text.withMetaDo({ reconcileChanges: true }, () => {
        text.lineWrapping = this.ui.lineWrappingSelector.selection;
      });
    }
  }

  toggleItalic () {
    if (this.globalMode) this.targetMorph.removePlainTextAttribute('fontStyle');
    this.confirm('fontStyle', style =>
      style === 'italic' ? 'normal' : 'italic');
    this.update();
  }

  toggleUnderline () {
    if (this.globalMode) this.targetMorph.removePlainTextAttribute('textDecoration', 'underline');
    this.confirm('textDecoration', decoration =>
      decoration === 'underline' ? 'none' : 'underline');
    this.update();
  }

  toggleQuote () {
    this.confirm('quote', quoteActive =>
      quoteActive === 1 ? 0 : 1);
    this.update();
  }

  changeFontWeight (weight) {
    if (this.globalMode) this.targetMorph.removePlainTextAttribute('fontWeight');
    this.confirm('fontWeight', fontWeightNameToNumeric().get(weight));
  }

  changeFontColor (color) {
    if (this.globalMode) this.targetMorph.removePlainTextAttribute('fontColor');
    this.confirm('fontColor', color);
  }

  changeFontSize (size) {
    if (this.globalMode) this.targetMorph.removePlainTextAttribute('fontSize');
    this.confirm('fontSize', size);
  }

  changeFontFamily (fontFamily) {
    if (this.globalMode) this.targetMorph.removePlainTextAttribute('fontFamily');
    if (fontFamily.name in config.fonts.genericFonts) fontFamily = sanitizeFont(config.fonts.genericFonts[fontFamily.name]);
    else fontFamily = sanitizeFont(fontFamily.name);
    this.confirm('fontFamily', fontFamily);
    this.updateFontWeightChoices(fontFamily);
  }

  updateFontWeightChoices (forFont) {
    const fontEntry = availableFonts().find(f => sanitizeFont(f.name) === sanitizeFont(forFont));
    const supportedFontWeights = fontEntry?.supportedWeights.map(fontWeight => fontWeightToString(fontWeight)) || [];
    this.models.fontWeightSelector.items = supportedFontWeights.length > 0 ? supportedFontWeights : [400, 700].map(fontWeight => fontWeightToString(fontWeight));
  }

  changeLineHeight (height) {
    if (this.globalMode) this.targetMorph.removePlainTextAttribute('lineHeight');
    this.confirm('lineHeight', height);
  }

  changeLetterSpacing (spacing) {
    if (this.globalMode) this.targetMorph.removePlainTextAttribute('letterSpacing');
    this.confirm('letterSpacing', spacing);
  }

  changePadding (padding) {
    this.targetMorph.withMetaDo({ reconcileChanges: true }, () => {
      this.targetMorph.padding = padding;
    });
  }

  deactivate () {
    this.models.fontColorInput.closeColorPicker();
  }
}

const RichTextControl = component(PropertySection, {
  defaultViewModel: RichTextControlModel,
  name: 'rich text control',
  extent: pt(250, 313),
  layout: new TilingLayout({
    axis: 'column',
    axisAlign: 'center',
    hugContentsVertically: true,
    orderByIndex: true,
    padding: rect(0, 10, 0, 0),
    resizePolicies: [['h floater', {
      height: 'fixed',
      width: 'fill'
    }]],
    spacing: 10
  }),
  submorphs: [{
    name: 'h floater',
    submorphs: [{
      name: 'section headline',
      textAndAttributes: ['Rich Text', null]
    }, {
      name: 'add button',
      tooltip: 'Install a custom font',
      textAndAttributes: ['', {
        fontFamily: 'Tabler Icons',
        fontSize: 18,
        fontWeight: '900'
      }]
    }]
  }, add({
    name: 'text controls',
    layout: new TilingLayout({
      orderByIndex: true,
      padding: rect(20, 0, -20, 0),
      spacing: 10,
      wrapSubmorphs: true
    }),
    borderColor: Color.rgb(23, 160, 251),
    borderWidth: 0,
    extent: pt(250, 93.8),
    fill: Color.rgba(0, 0, 0, 0),
    submorphs: [
      part(EnumSelector, {
        name: 'font family selector',
        tooltip: 'Choose Font',
        layout: new TilingLayout({
          align: 'center',
          axisAlign: 'center',
          justifySubmorphs: 'spaced',
          orderByIndex: true,
          padding: rect(10, 0, 5, 0),
          spacing: 0
        }),
        extent: pt(198.6, 23.3),
        viewModel: {
          items: ['IBM Plex Sans'],
          openListInWorld: true,
          listMaster: DarkThemeList,
          listHeight: 200,
          listAlign: 'bottom'
        },
        submorphs: [{
          name: 'label',
          fontSize: 12
        }]
      }), {
        name: 'weight and styles',
        extent: pt(198, 8.9),
        layout: new TilingLayout({
          hugContentsVertically: true,
          orderByIndex: true,
          resizePolicies: [['font weight selector', {
            height: 'fixed',
            width: 'fill'
          }]],
          spacing: 10
        }),
        fill: Color.transparent,
        submorphs: [part(EnumSelector, {
          name: 'font weight selector',
          tooltip: 'Choose Font Weight',
          extent: pt(100, 23.3),
          viewModel: {
            listMaster: DarkThemeList,
            items: [{
              isListItem: true,
              string: 'Thin',
              value: 100
            }, {
              isListItem: true,
              string: 'Extra Light',
              value: 200
            }, {
              isListItem: true,
              string: 'Light',
              value: 300
            }, {
              isListItem: true,
              string: 'Normal',
              value: 400
            }, {
              isListItem: true,
              string: 'Medium',
              value: 500
            }, {
              isListItem: true,
              string: 'Semi Bold',
              value: 600
            }, {
              isListItem: true,
              string: 'Bold',
              value: 700
            }, {
              isListItem: true,
              string: 'Extra Bold',
              value: 800
            }, {
              isListItem: true,
              string: 'Ultra Bold',
              value: 900
            }],
            listAlign: 'bottom',
            openListInWorld: true,
            listHeight: 1000
          },
          layout: new TilingLayout({
            align: 'center',
            axisAlign: 'center',
            justifySubmorphs: 'spaced',
            orderByIndex: true,
            padding: rect(10, 0, 5, 0)
          }),
          submorphs: [{
            name: 'label',
            fontSize: 12
          }]
        }),
        part(BoundsContainerInactive, {
          name: 'styling controls',
          extent: pt(87.4, 26.4),
          master: { auto: BoundsContainerInactive, hover: BoundsContainerHovered },
          layout: new TilingLayout({
            orderByIndex: true,
            hugContentsHorizontally: true
          }),
          submorphs: [add(part(PropertyLabel, {
            name: 'italic style',
            master: { states: { active: PropertyLabelHovered } },
            tooltip: 'Italic',
            fontSize: 14,
            padding: rect(2, 0, 0, 0),
            textAndAttributes: ['\ue23f', {
              fontSize: 18,
              fontFamily: 'Material Icons'
            }]
          })), add(part(PropertyLabel, {
            name: 'underline style',
            master: { states: { active: PropertyLabelHovered } },
            tooltip: 'Underline',
            fontSize: 14,
            padding: rect(2, 0, 0, 0),
            textAndAttributes: ['\ue249', {
              fontSize: 18,
              fontFamily: 'Material Icons'
            }]
          })), add(part(PropertyLabel, {
            name: 'inline link',
            master: { states: { active: PropertyLabelHovered } },
            tooltip: 'Create Link',
            fontSize: 14,
            padding: rect(2, 0, 0, 0),
            textAndAttributes: ['\ue157', {
              fontSize: 18,
              fontFamily: 'Material Icons'
            }]
          })), add(part(PropertyLabel, {
            name: 'quote',
            master: { states: { active: PropertyLabelHovered } },
            tooltip: 'Quote',
            fontSize: 14,
            padding: rect(2, 0, 0, 0),
            textAndAttributes: ['\ue244', {
              fontSize: 18,
              fontFamily: 'Material Icons'
            }]
          }))]
        })]
      },
      part(DarkNumberIconWidget, {
        name: 'font size input',
        width: 60,
        submorphs: [{
          name: 'interactive label',
          textAndAttributes: ['\ue245', {
            fontSize: 16,
            fontFamily: 'Material Icons'
          }]
        }],
        tooltip: 'Font Size'
      }), part(DarkNumberIconWidget, {
        name: 'line height input',
        width: 60,
        floatingPoint: true,
        submorphs: [{
          name: 'interactive label',
          textAndAttributes: ['\ue240', {
            fontSize: 16,
            fontFamily: 'Material Icons'
          }]
        }, {
          name: 'value',
          floatingPoint: true,
          precision: 1
        }],
        tooltip: 'Line Height'
      }), part(DarkNumberIconWidget, {
        name: 'letter spacing input',
        width: 60,
        submorphs: [{
          name: 'interactive label',
          textAndAttributes: ['\ue014', {
            fontSize: 16,
            fontFamily: 'Material Icons'
          }]
        }],
        tooltip: 'Letter Spacing'
      })]
  }), add(part(ColorInput, {
    name: 'font color input',
    viewModel: {
      colorPickerComponent: DarkColorPicker
    },
    layout: new TilingLayout({
      axisAlign: 'center',
      orderByIndex: true,
      padding: rect(20, 1, -10, 1),
      resizePolicies: [['hex input', {
        height: 'fill',
        width: 'fixed'
      }], ['opacity input', {
        height: 'fill',
        width: 'fixed'
      }]],
      spacing: 10
    }),
    extent: pt(250.3, 25),
    submorphs: [{
      name: 'hex input',
      extent: pt(74.8, 22)
    }, {
      name: 'opacity input',
      extent: pt(83.4, 22)
    }]
  })),

  add({
    name: 'bottom wrapper',
    clipMode: 'hidden',
    extent: pt(251.4, 65.6),
    fill: Color.transparent,
    layout: new TilingLayout({
      hugContentsVertically: true,
      orderByIndex: true,
      padding: rect(20, 0, -20, 0),
      spacing: 10,
      wrapSubmorphs: true
    }),
    submorphs: [{
      name: 'alignment controls',
      master: { auto: BoundsContainerInactive, hover: BoundsContainerHovered },
      extent: pt(110.3, 22),
      layout: new TilingLayout({
        hugContentsVertically: true,
        justifySubmorphs: 'spaced',
        spacing: 5
      }),
      submorphs: [part(PropertyLabel, {
        name: 'left align',
        master: { states: { active: PropertyLabelHovered } },
        tooltip: 'Align Left',
        fontSize: 14,
        padding: rect(4, 4, 0, 0),
        textAndAttributes: Icon.textAttribute('align-left')
      }), part(PropertyLabel, {
        name: 'center align',
        master: { states: { active: PropertyLabelHovered } },
        tooltip: 'Align Centered',
        fontSize: 14,
        padding: rect(4, 4, 0, 0),
        textAndAttributes: Icon.textAttribute('align-center')
      }), part(PropertyLabel, {
        name: 'right align',
        master: { states: { active: PropertyLabelHovered } },
        tooltip: 'Align Right',
        fontSize: 14,
        padding: rect(4, 4, 0, 0),
        textAndAttributes: Icon.textAttribute('align-right')
      }), part(PropertyLabel, {
        name: 'block align',
        master: { states: { active: PropertyLabelHovered } },
        tooltip: 'Justify Text',
        fontSize: 14,
        padding: rect(4, 4, 0, 0),
        textAndAttributes: Icon.textAttribute('align-justify')
      })]
    }, part(EnumSelector, {
      name: 'line wrapping selector',
      extent: pt(202.2, 23.3),
      tooltip: 'Choose Line Wrapping',
      viewModel: {
        listAlign: 'bottom',
        openListInWorld: true,
        listMaster: DarkThemeList,
        items: [
          { isListItem: true, string: 'No Wrapping', value: 'no-wrap' },
          { isListItem: true, string: 'Wrap by Words', value: 'by-words' },
          { isListItem: true, string: 'Wrap by Characters', value: 'by-chars' },
          { isListItem: true, string: 'Wrap only by Words', value: 'only-by-words' }]
      },
      submorphs: [{
        name: 'label',
        fontSize: 12
      }]
    })]
  }), add(part(PaddingControlsDark, { name: 'padding controls' }))
  ]
});

export { RichTextControl };