Home Reference Source

src/components/textarea/TextArea.js

/* global Quill */
import TextFieldComponent from '../textfield/TextField';
import _ from 'lodash';
import NativePromise from 'native-promise-only';
import { uniqueName, getIEBrowserVersion } from '../../utils/utils';

export default class TextAreaComponent extends TextFieldComponent {
  static schema(...extend) {
    return TextFieldComponent.schema({
      type: 'textarea',
      label: 'Text Area',
      key: 'textArea',
      rows: 3,
      wysiwyg: false,
      editor: '',
      fixedSize: true,
      inputFormat: 'html',
      validate: {
        minWords: '',
        maxWords: ''
      }
    }, ...extend);
  }

  static get builderInfo() {
    return {
      title: 'Text Area',
      group: 'basic',
      icon: 'font',
      documentation: 'http://help.form.io/userguide/#textarea',
      weight: 20,
      schema: TextAreaComponent.schema()
    };
  }

  init() {
    super.init();
    this.editors = [];
    this.editorsReady = [];
    this.updateSizes = [];

    // Never submit on enter for text areas.
    this.options.submitOnEnter = false;
  }

  get defaultSchema() {
    return TextAreaComponent.schema();
  }

  get inputInfo() {
    const info = super.inputInfo;
    info.type = this.component.wysiwyg ? 'div' : 'textarea';
    if (this.component.rows) {
      info.attr.rows = this.component.rows;
    }
    return info;
  }

  validateMultiple() {
    return !this.isJsonValue;
  }

  renderElement(value, index) {
    const info = this.inputInfo;
    info.attr = info.attr || {};
    info.content = value;
    if (this.options.readOnly || this.disabled) {
      return this.renderTemplate('well', {
        children: '<div ref="input" class="formio-editor-read-only-content"></div>',
        nestedKey: this.key,
        value
      });
    }

    return this.renderTemplate('input', {
      prefix: this.prefix,
      suffix: this.suffix,
      input: info,
      value,
      index
    });
  }

  get autoExpand() {
    return this.component.autoExpand;
  }

  /**
   * Updates the editor value.
   *
   * @param newValue
   */
  updateEditorValue(index, newValue) {
    newValue = this.getConvertedValue(this.trimBlanks(newValue));
    const dataValue = this.dataValue;
    if (this.component.multiple && Array.isArray(dataValue)) {
      const newArray = _.clone(dataValue);
      newArray[index] = newValue;
      newValue = newArray;
    }

    if ((!_.isEqual(newValue, dataValue)) && (!_.isEmpty(newValue) || !_.isEmpty(dataValue))) {
      this.updateValue(newValue, {
        modified: !this.autoModified
      }, index);
    }
    this.autoModified = false;
  }

  attachElement(element, index) {
    if (this.autoExpand && (this.isPlain || this.options.readOnly || this.options.htmlView)) {
      if (element.nodeName === 'TEXTAREA') {
        this.addAutoExpanding(element, index);
      }
    }

    if (this.options.readOnly) {
      return element;
    }

    if (this.component.wysiwyg && !this.component.editor) {
      this.component.editor = 'ckeditor';
    }

    let settings = _.isEmpty(this.component.wysiwyg) ?
      this.wysiwygDefault[this.component.editor] || this.wysiwygDefault.default
      : this.component.wysiwyg;

    // Keep track of when this editor is ready.
    this.editorsReady[index] = new NativePromise((editorReady) => {
      // Attempt to add a wysiwyg editor. In order to add one, it must be included on the global scope.
      switch (this.component.editor) {
        case 'ace':
          if (!settings) {
            settings = {};
          }
          settings.mode = this.component.as;
          this.addAce(element, settings, (newValue) => this.updateEditorValue(index, newValue)).then((ace) => {
            this.editors[index] = ace;
            let dataValue = this.dataValue;
            dataValue = (this.component.multiple && Array.isArray(dataValue)) ? dataValue[index] : dataValue;
            ace.setValue(this.setConvertedValue(dataValue, index));
            editorReady(ace);
            return ace;
          }).catch(err => console.warn(err));
          break;
        case 'quill':
          // Normalize the configurations for quill.
          if (settings.hasOwnProperty('toolbarGroups') || settings.hasOwnProperty('toolbar')) {
            console.warn('The WYSIWYG settings are configured for CKEditor. For this renderer, you will need to use configurations for the Quill Editor. See https://quilljs.com/docs/configuration for more information.');
            settings = this.wysiwygDefault.quill;
          }

          // Add the quill editor.
          this.addQuill(
            element,
            settings, () => this.updateEditorValue(index, this.editors[index].root.innerHTML)
          ).then((quill) => {
            this.editors[index] = quill;
            if (this.component.isUploadEnabled) {
              const _this = this;
              quill.getModule('uploader').options.handler = function(...args) {
                //we need initial 'this' because quill calls this method with its own context and we need some inner quill methods exposed in it
                //we also need current component instance as we use some fields and methods from it as well
                _this.imageHandler.call(_this, this, ...args);
              };
            }
            quill.root.spellcheck = this.component.spellcheck;
            if (this.options.readOnly || this.disabled) {
              quill.disable();
            }

            let dataValue = this.dataValue;
            dataValue = (this.component.multiple && Array.isArray(dataValue)) ? dataValue[index] : dataValue;
            quill.setContents(quill.clipboard.convert({ html: this.setConvertedValue(dataValue, index) }));
            editorReady(quill);
            return quill;
          }).catch(err => console.warn(err));
          break;
        case 'ckeditor':
          settings = settings || {};
          settings.rows = this.component.rows;
          this.addCKE(element, settings, (newValue) => this.updateEditorValue(index, newValue))
            .then((editor) => {
              this.editors[index] = editor;
              let dataValue = this.dataValue;
              dataValue = (this.component.multiple && Array.isArray(dataValue)) ? dataValue[index] : dataValue;
              const value = this.setConvertedValue(dataValue, index);
              const isReadOnly = this.options.readOnly || this.disabled;

              if (getIEBrowserVersion()) {
                editor.on('instanceReady', () => {
                  editor.setReadOnly(isReadOnly);
                  editor.setData(value);
                });
              }
              else {
                const numRows = parseInt(this.component.rows, 10);

                if (_.isFinite(numRows) && _.has(editor, 'ui.view.editable.editableElement')) {
                  // Default height is 21px with 10px margin + a 14px top margin.
                  const editorHeight = (numRows * 31) + 14;
                  editor.ui.view.editable.editableElement.style.height = `${(editorHeight)}px`;
                }
                editor.isReadOnly = isReadOnly;
                editor.data.set(value);
              }

              editorReady(editor);
              return editor;
            });
          break;
        default:
          super.attachElement(element, index);
          break;
      }
    });

    return element;
  }

  attach(element) {
    const attached = super.attach(element);
    // Make sure we restore the value after attaching since wysiwygs and readonly texts need an additional set.
    this.restoreValue();
    return attached;
  }

  imageHandler(moduleInstance, range, files) {
    const quillInstance = moduleInstance.quill;

    if (!files || !files.length) {
      console.warn('No files selected');
      return;
    }

    quillInstance.enable(false);
    const { uploadStorage, uploadUrl, uploadOptions, uploadDir, fileKey } = this.component;
    let requestData;
    this.fileService
      .uploadFile(
        uploadStorage,
        files[0],
        uniqueName(files[0].name),
        uploadDir || '', //should pass empty string if undefined
        null,
        uploadUrl,
        uploadOptions,
        fileKey
      )
      .then(result => {
        requestData = result;
        return this.fileService.downloadFile(result);
      })
      .then(result => {
        quillInstance.enable(true);
        const Delta = Quill.import('delta');
        quillInstance.updateContents(new Delta()
            .retain(range.index)
            .delete(range.length)
            .insert(
              {
                image: result.url
              },
              {
                alt: JSON.stringify(requestData),
              })
          , Quill.sources.USER);
      }).catch(error => {
      console.warn('Quill image upload failed');
      console.warn(error);
      quillInstance.enable(true);
    });
  }

  get isPlain() {
    return (!this.component.wysiwyg && !this.component.editor);
  }

  get htmlView() {
    return this.options.readOnly && (this.component.editor || this.component.wysiwyg);
  }

  setValueAt(index, value, flags = {}) {
    super.setValueAt(index, value, flags);

    if (this.editorsReady[index]) {
      const setEditorsValue = (flags) => (editor) => {
        this.autoModified = true;
        if (!flags.skipWysiwyg) {
          switch (this.component.editor) {
            case 'ace':
              editor.setValue(this.setConvertedValue(value, index));
              break;
            case 'quill':
              if (this.component.isUploadEnabled) {
                this.setAsyncConvertedValue(value)
                  .then(result => {
                    const content = editor.clipboard.convert({ html: result });
                    editor.setContents(content);
                  });
              }
              else {
                const convertedValue = this.setConvertedValue(value, index);
                const content = editor.clipboard.convert({ html: convertedValue });
                editor.setContents(content);
              }
              break;
            case 'ckeditor':
              editor.data.set(this.setConvertedValue(value, index));
              break;
          }
        }
      };

      this.editorsReady[index].then(setEditorsValue(_.clone(flags)));
    }
  }

  setValue(value, flags = {}) {
    if (this.isPlain || this.options.readOnly || this.disabled) {
      value = (this.component.multiple && Array.isArray(value)) ?
        value.map((val, index) => this.setConvertedValue(val, index)) :
        this.setConvertedValue(value);
      return super.setValue(value, flags);
    }
    flags.skipWysiwyg = _.isEqual(value, this.getValue());
    return super.setValue(value, flags);
  }

  setReadOnlyValue(value, index) {
    index = index || 0;
    if (this.options.readOnly || this.disabled) {
      if (this.refs.input && this.refs.input[index]) {
        this.setContent(this.refs.input[index], this.interpolate(value));
      }
    }
  }

  get isJsonValue() {
    return this.component.as && this.component.as === 'json';
  }

  setConvertedValue(value, index) {
    if (this.isJsonValue && !_.isNil(value)) {
      try {
        value = JSON.stringify(value, null, 2);
      }
      catch (err) {
        console.warn(err);
      }
    }

    if (!_.isString(value)) {
      value = '';
    }

    this.setReadOnlyValue(value, index);
    return value;
  }

  setAsyncConvertedValue(value) {
    if (this.isJsonValue && value) {
      try {
        value = JSON.stringify(value, null, 2);
      }
      catch (err) {
        console.warn(err);
      }
    }

    if (!_.isString(value)) {
      value = '';
    }

    const htmlDoc = new DOMParser().parseFromString(value,'text/html');
    const images = htmlDoc.getElementsByTagName('img');
    if (images.length) {
      return this.setImagesUrl(images)
        .then( () => {
          value = htmlDoc.getElementsByTagName('body')[0].innerHTML;
          return value;
        });
    }
    else {
      return NativePromise.resolve(value);
    }
  }

  setImagesUrl(images) {
    return NativePromise.all(_.map(images, image => {
      let requestData;
      try {
        requestData = JSON.parse(image.getAttribute('alt'));
      }
      catch (error) {
        console.warn(error);
      }

      return this.fileService.downloadFile(requestData)
        .then((result) => {
          image.setAttribute('src', result.url);
        });
    }));
  }

  addAutoExpanding(textarea, index) {
    let heightOffset = null;
    let previousHeight = null;

    const changeOverflow = (value) => {
      const width = textarea.style.width;

      textarea.style.width = '0px';
      textarea.offsetWidth;
      textarea.style.width = width;

      textarea.style.overflowY = value;
    };

    const preventParentScroll = (element, changeSize) => {
      const nodeScrolls = [];

      while (element && element.parentNode && element.parentNode instanceof Element) {
        if (element.parentNode.scrollTop) {
          nodeScrolls.push({
            node: element.parentNode,
            scrollTop: element.parentNode.scrollTop,
          });
        }
        element = element.parentNode;
      }

      changeSize();

      nodeScrolls.forEach((nodeScroll) => {
        nodeScroll.node.scrollTop = nodeScroll.scrollTop;
      });
    };

    const resize = () => {
      if (textarea.scrollHeight === 0) {
        return;
      }

      preventParentScroll(textarea, () => {
        textarea.style.height = '';
        textarea.style.height = `${textarea.scrollHeight + heightOffset}px`;
      });
    };

    const update = _.debounce(() => {
      resize();
      const styleHeight = Math.round(parseFloat(textarea.style.height));
      const computed = window.getComputedStyle(textarea, null);
      let currentHeight = textarea.offsetHeight;
      if (currentHeight < styleHeight && computed.overflowY === 'hidden') {
        changeOverflow('scroll');
      }
      else if (computed.overflowY !== 'hidden') {
        changeOverflow('hidden');
      }

      resize();
      currentHeight = textarea.offsetHeight;
      if (previousHeight !== currentHeight) {
        previousHeight = currentHeight;
        update();
      }
    }, 200);
    const computedStyle = window.getComputedStyle(textarea, null);

    textarea.style.resize = 'none';
    heightOffset = parseFloat(computedStyle.borderTopWidth) + parseFloat(computedStyle.borderBottomWidth) || 0;

    if (window) {
      this.addEventListener(window, 'resize', update);
    }

    this.addEventListener(textarea, 'input', update);
    this.on('initialized', update);
    this.updateSizes[index] = update;
    update();
  }

  trimBlanks(value) {
    if (!value) {
      return value;
    }

    const trimBlanks = (value) => {
      const nbsp = '<p>&nbsp;</p>';
      const br = '<p><br></p>';
      const brNbsp = '<p><br>&nbsp;</p>';
      const regExp = new RegExp(`^${nbsp}|${nbsp}$|^${br}|${br}$|^${brNbsp}|${brNbsp}$`, 'g');
      return typeof value === 'string' ? value.replace(regExp, '').trim() : value;
    };

    if (Array.isArray(value)) {
      value.forEach((input, index) => {
        value[index] = trimBlanks(input);
      });
    }
    else {
      value = trimBlanks(value);
    }
    return value;
  }

  onChange(flags, fromRoot) {
    const changed = super.onChange(flags, fromRoot);
    this.updateSizes.forEach(updateSize => updateSize());
    return changed;
  }

  hasChanged(newValue, oldValue) {
    return super.hasChanged(this.trimBlanks(newValue), this.trimBlanks(oldValue));
  }

  isEmpty(value = this.dataValue) {
    return super.isEmpty(this.trimBlanks(value));
  }

  get defaultValue() {
    let defaultValue = super.defaultValue;
    if (this.component.editor === 'quill' && !defaultValue) {
      defaultValue = '<p><br></p>';
    }
    return defaultValue;
  }

  getConvertedValue(value) {
    if (this.isJsonValue && value) {
      try {
        value = JSON.parse(value);
      }
      catch (err) {
        // console.warn(err);
      }
    }
    return value;
  }

  detach() {
    // Destroy all editors.
    this.editors.forEach(editor => {
      if (editor.destroy) {
        editor.destroy();
      }
    });
    this.editors = [];
    this.editorsReady = [];
    this.updateSizes.forEach(updateSize => this.removeEventListener(window, 'resize', updateSize));
    this.updateSizes = [];
  }

  getValue() {
    if (this.isPlain) {
      return this.getConvertedValue(super.getValue());
    }

    return this.dataValue;
  }
}