Source: mixins/CustomCodeEditorMixin.js

import _ from 'lodash';
import ClipboardJS from 'clipboard';

/**
 * @model CustomCodeEditorMixin
 * @desc Mixin for Custom Code Editor
 */
export default {
  methods: {

    /**
     * @method init
     * @desc Init Custom Code Editor
     * @param {HTMLElement} element
     * @return {aceEditor} Return editor intialized from ACE JS
     */
    init(element) {
      const self = this;

      // Set Default Submit Value
      this.setDefaultSubmitValue();
      // Default Empty Value
      this.beforeInit(element);
      const editor = this.editor(element);
      this.setEditor(editor);
      editor.setValue('');
      editor.session.setMode(this.ace.aceModePath + this.ace.defaultMode.toLowerCase());
      editor.setTheme(this.ace.aceThemePath + this.ace.theme);
      if (apos.customCodeEditor.browser.mode === 'production') {
        editor.setOption('useWorker', false);
      }
      editor.setOption('enableEmmet', false);

      // Register Editor Events
      this.editorEvents.call(self, editor);

      // Merge Config
      this.mergeConfig();

      // Editor Configurations
      if (_.has(this.ace.config, 'editorHeight') || _.has(this.ace.config, 'fontSize')) {
        this.configEditor();
      }

      // Options reference: https://github.com/ajaxorg/ace/wiki/Configuring-Ace
      if (_.has(this.ace, 'options')) {
        const options = this.ace.options;
        for (const key in options) {
          // eslint-disable-next-line no-prototype-builtins
          if (options.hasOwnProperty(key)) {
            editor.setOptions(apos.util.assign(options));
          }
        }
      }

      // Enable dropdown
      if (_.has(this.ace, 'config.dropdown') && _.has(this.ace, 'config.dropdown.enable') && this.ace.config.dropdown.enable) {
        this.setDropdown();
        this.configDropdown();
      }

      if (ClipboardJS.isSupported() && (!_.has(this.ace, 'config.optionsCustomizer.enable') || !_.has(this.field, 'ace.config.optionsCustomizer.enable'))) {
        // Init ClipboardJS. Need to make sure that optionsCustomizer is enable so that we can initialize ClipboardJS
        if (this.$el.querySelector('button.copy-options')) {
          this.initCopyClipboard(this.$el.querySelector('button.copy-options'));
        }
      } else if (!ClipboardJS.isSupported()) {
        console.warn('ClipboardJS is not supported in this browser');
      }

      // Invoke after init
      this.afterInit(element);

      return editor;
    },

    /**
     * @method initCopyClipboard
     * @desc Init ClipboardJS
     * @param {HTMLElement} copyButton
     */
    initCopyClipboard(copyButton) {
      this.clipboard = new ClipboardJS(copyButton);
      this.clipboardEvents(this.clipboard);
    },

    /**
     * @method clipboardEvents
     * @desc Register Clipboard events
     * @param {ClipboardJS} clipboard
     */
    clipboardEvents(clipboard) {
      clipboard.on('success', this.successClipboard);
      clipboard.on('error', this.errorClipboard);
    },

    /**
     * @method successClipboard
     * @desc Success Clipboard Event
     * @param {ClipboardJS.Event} e
     */
    successClipboard(e) {
      apos.notify('"' + this.field.name + '" field: ' + 'Options Copied!', {
        type: 'success',
        dismiss: true
      });

      e.clearSelection();
    },

    /**
     * @method errorClipboard
     * @desc Error Clipboard Event
     * @param {ClipboardJS.Event} e
     */
    errorClipboard(e) {
      console.error('Action: ', e.action);
      console.error('Trigger: ', e.trigger);
      apos.notify('Unable to copy options', {
        type: 'error',
        dismiss: true
      });
    },

    /**
     * @method destroyClipboard
     * @desc Destroy Clipboard event
     */
    destroyClipboard() {
      if (this.clipboard) {
        this.clipboard.off('success', this.successClipboard);
        this.clipboard.off('error', this.errorClipboard);
        this.clipboard.destroy();
      }
    },

    /**
     * @method setDropdown
     * @desc To Set Dropdown if dropdown enable
     */
    setDropdown() {
      const editor = this.getEditor();
      const self = this;

      // Save new value when press save command
      editor.commands.addCommand({
        name: 'saveNewCode',
        bindKey: {
          win: (_.has(this.ace, 'config.saveCommand.win')) ? this.ace.config.saveCommand.win : 'Ctrl-Shift-S',
          mac: (_.has(this.ace, 'config.saveCommand.mac')) ? this.ace.config.saveCommand.mac : 'Command-Shift-S'
        },
        exec: function (editor) {
          // If Two or more editor in single schema , show field name
          if (self.$root.$el.querySelectorAll('.editor-container').length > 1) {
            apos.notify((_.has(self.ace, 'config.saveCommand.message')) ? self.ace.config.saveCommand.message + ' - Field Name : ' + self.field.name : 'Selected Code Saved Successfully' + ' - Field Name : ' + self.field.name, {
              type: 'success',
              dismiss: 2
            });
          } else {
            apos.notify((_.has(self.ace, 'config.saveCommand.message')) ? self.ace.config.saveCommand.message : 'Selected Code Saved Successfully', {
              type: 'success',
              dismiss: 2
            });
          }
          self.originalValue = editor.getSelectedText();
        },
        readOnly: false
      });

      // Remove Save Command if null
      if (_.has(this.field, 'ace.config.saveCommand') && this.field.ace.config.saveCommand === null) {
        editor.commands.removeCommand('saveNewCode');
      }

      // create dropdown modes
      for (let i = 0; i < this.ace.modes.length; i++) {

        // Set defaultMode if found defined modes
        if (self.ace.defaultMode.toLowerCase() === self.ace.modes[i].name.toLowerCase()) {

          editor.session.setMode('ace/mode/' + self.ace.defaultMode.toLowerCase());

          this.$el.querySelector('.dropdown-title').innerText = this.next.type.length > 0 ? this.next.type : self.ace.defaultMode;

          if (self.ace.modes[i].snippet && !self.ace.modes[i].disableSnippet) {
            let beautify = ace.require('ace/ext/beautify');
            editor.session.setValue(self.ace.modes[i].snippet);
            beautify.beautify(editor.session);
            // Find the template for replace the code area
            const find = editor.find('@code-here', {
              backwards: false,
              wrap: true,
              caseSensitive: true,
              wholeWord: true,
              regExp: false
            });

            // If found
            if (find) {
              editor.replace('');
            }
          }
        }
      }
    },

    /**
     * @method configDropdown
     * @desc CSS Config for Dropdown. Only accept this config object:
     * ```js
     * ace: {
     *    config: {
     *      dropdown: {
     *          enable: <Boolean>,
     *          height: <Number or String>,
     *          borderRadius: <Number or String>,
     *          fontFamily: <String>,
     *          fontSize: <Number or String>,
     *          backgroundColor: <String>,
     *          textColor: <String>,
     *          position: {
     *              top: <Number or String>,
     *              bottom: <Number or String>,
     *              right: <Number or String>,
     *              left: <Number or String>
     *          },
     *          arrowColor: <String (HEX or RGB or RGBA)>
     *    }
     * }
     * ```
     */
    configDropdown() {
      // Assign Styles to new object
      const styles = _.assign({}, _.omitBy({
        height: (_.has(this.ace.config.dropdown, 'height')) ? _.isNumber(this.ace.config.dropdown.height) ? this.ace.config.dropdown.height + 'px' : this.ace.config.dropdown.height : null,
        borderRadius: (_.has(this.ace.config.dropdown, 'borderRadius')) ? _.isNumber(this.ace.config.dropdown.borderRadius) ? this.ace.config.dropdown.borderRadius + 'px' : this.ace.config.dropdown.borderRadius : null,
        boxShadow: (_.has(this.ace.config.dropdown, 'boxShadow')) ? this.ace.config.dropdown.boxShadow : null,
        width: (_.has(this.ace.config.dropdown, 'width')) ? _.isNumber(this.ace.config.dropdown.width) ? this.ace.config.dropdown.width + 'px' : this.ace.config.dropdown.width : null,
        backgroundColor: (_.has(this.ace.config.dropdown, 'backgroundColor')) ? this.ace.config.dropdown.backgroundColor : null
      }, _.isNil), _.has(this.ace.config.dropdown, 'position') && {
        position: _.omitBy({
          top: (_.has(this.ace.config.dropdown, 'position.top')) ? _.isNumber(this.ace.config.dropdown.position.top) ? this.ace.config.dropdown.position.top + 'px' : this.ace.config.dropdown.position.top : null,
          left: (_.has(this.ace.config.dropdown, 'position.left')) ? _.isNumber(this.ace.config.dropdown.position.left) ? this.ace.config.dropdown.position.left + 'px' : this.ace.config.dropdown.position.left : null,
          right: (_.has(this.ace.config.dropdown, 'position.right')) ? _.isNumber(this.ace.config.dropdown.position.right) ? this.ace.config.dropdown.position.right + 'px' : this.ace.config.dropdown.position.right : null,
          bottom: (_.has(this.ace.config.dropdown, 'position.bottom')) ? _.isNumber(this.ace.config.dropdown.position.bottom) ? this.ace.config.dropdown.position.bottom + 'px' : this.ace.config.dropdown.position.bottom : null
        }, _.isNil)
      });

      const titleStyles = _.assign({}, _.omitBy({
        color: (_.has(this.ace.config.dropdown, 'textColor')) ? this.ace.config.dropdown.textColor : null,
        fontFamily: (_.has(this.ace.config.dropdown, 'fontFamily')) ? this.ace.config.dropdown.fontFamily : null,
        fontSize: (_.has(this.ace.config.dropdown, 'fontSize')) ? this.ace.config.dropdown.fontSize : null
      }, _.isNil));

      // Loop & assign dropdown style
      for (let prop of Object.keys(styles)) {
        if (Object.prototype.hasOwnProperty.call(styles, prop)) {
          if (typeof styles[prop.toString()] === 'object' && Object.keys(styles[prop.toString()]).length > 0) {
            for (let innerProp of Object.keys(styles[prop.toString()])) {
              this.$el.querySelector('.dropdown').style[innerProp.toString()] = styles[prop.toString()][innerProp.toString()];
            }
          }

          this.$el.querySelector('.dropdown').style[prop.toString()] = styles[prop.toString()];
        }
      }

      // Loop & assign dropdown-title style
      for (let prop of Object.keys(titleStyles)) {
        if (Object.prototype.hasOwnProperty.call(titleStyles, prop)) {
          this.$el.querySelector('.dropdown-title').style[prop.toString()] = titleStyles[prop.toString()];
        }
      }
    },

    /**
     * @method mergeConfig
     * @desc to merge any `this.field` specific schema options to merge with project level module options
     */
    mergeConfig() {
      let merge = false;
      let mergeOptions = {};
      // Customize field options if any. Only allow some override options to be available
      if (_.has(this.field, 'ace')) {
          mergeOptions = {
            defaultMode: _.has(this.field, 'ace.defaultMode') ? this.field.ace.defaultMode.toLowerCase() : this.ace.defaultMode.toLowerCase(),
            // Let the devs to set undefined on config property if any, else just override it
            config: !_.has(this.field, 'ace.config') ? null : _.assign({}, this.ace.config, this.field.ace.config)
          };

          let cloneAce = _.cloneDeep(this.ace);

          // Non-Immutable Copies
          this.ace = _.mergeWith(
                        {}, cloneAce, mergeOptions,
                        (a, b) => b === null ? null : b
                      );

          merge = true;
      }

      if (merge) {
        // Clone to new object
        const cloneOptions = _.cloneDeep(apos.customCodeEditor.browser.ace);

        // Remove unnecessary arrays/objects that will affect web performance
        for (let optionsName of Object.keys(cloneOptions)) {
          if (!Object.prototype.hasOwnProperty.call(mergeOptions, optionsName)) {
            delete cloneOptions[optionsName];
          }
        }

        // Add with specific schema options
        apos.customCodeEditor.browser = {
          ...apos.customCodeEditor.browser,
          // Store all merged that has override on schema level options into `fieldAce`.
          fieldAce: {
            [this.field.name]: _.merge({}, cloneOptions, mergeOptions)
          }
        };
      }
    },

    /**
     * @method configEditor
     * @desc CSS Config for AceJS Editor. Only accept this config object:
     * ```js
     * ace: {
     *    config: {
     *        editorHeight: <Number or String>,
     *        fontSize: <Number or String>
     *    }
     * }
     * ```
     *
     */
    configEditor() {
      const editorStyles = _.assign({}, _.omitBy({
        height: (_.has(this.ace.config, 'editorHeight')) ? _.isNumber(this.ace.config.editorHeight) ? this.ace.config.editorHeight + 'px' : this.ace.config.editorHeight : null,
        fontSize: (_.has(this.ace.config, 'fontSize')) ? _.isNumber(this.ace.config.fontSize) ? this.ace.config.fontSize + 'px' : this.ace.config.fontSize : null
      }, _.isNil));

      // Loop & assign editor style
      for (let prop of Object.keys(editorStyles)) {
        if (Object.prototype.hasOwnProperty.call(editorStyles, prop)) {
          this.$el.querySelector('[data-editor]').style[prop.toString()] = editorStyles[prop.toString()];
        }
      }
    },

    /**
     * @method filterModesList
     * @desc Filter Modes by Search Input Event
     * @param {Event} e
     */
    filterModesList(e) {
      let input, filter, li, i, div, txtValue;
      // eslint-disable-next-line prefer-const
      input = e.currentTarget;
      // eslint-disable-next-line prefer-const
      filter = input.value.toUpperCase();
      // eslint-disable-next-line prefer-const
      div = this.$el.querySelector('.dropdown-content');
      // eslint-disable-next-line prefer-const
      li = div.querySelectorAll('li');
      for (i = 0; i < li.length; i++) {
        (function (i) {
          txtValue = li[i].innerText;

          if (txtValue.toUpperCase().indexOf(filter) > -1) {
            li[i].style.display = '';
          } else {
            li[i].style.display = 'none';
          }
        }(i));
      }
    },

    /**
     * @method changeMode
     * @desc Change Mode event when mode dropdown is clicked
     * @param {Event} e Event Listener
     * @return {null} Return nothing because it will automatically assign Mode to editor
     */
    changeMode(e) {
      const getText = e.currentTarget.getAttribute('data-name');
      const getTitle = e.currentTarget.getAttribute('data-title');
      const editor = this.getEditor();
      this.$el.querySelector('.dropdown-title').innerText = ((getTitle) || this.getName(getText));
      for (let i = 0; i < this.ace.modes.length; i++) {
        if (getText === this.ace.modes[i].name.toLowerCase()) {

          editor.session.setMode('ace/mode/' + this.ace.modes[i].name.toLowerCase());

          if (this.ace.modes[i].snippet) {
            // If got disableContent , get out from this if else
            if (this.ace.modes[i].disableSnippet) {
              return;
            }

            let beautify = ace.require('ace/ext/beautify');
            editor.session.setValue(this.ace.modes[i].snippet);
            beautify.beautify(editor.session);
            // If changing mode got existing codes , replace the value
            if (editor.getSelectedText().length > 1) {
              this.originalValue = editor.replace(editor.getSelectedText());
              return;
            }

            // Find the template for replace the code area
            const find = editor.find('@code-here', {
              backwards: false,
              wrap: true,
              caseSensitive: true,
              wholeWord: true,
              regExp: false
            });

            // If found
            if (find && !_.isUndefined(this.originalValue)) {
              editor.replace(this.originalValue);
            } else {
              editor.replace('');
            }
          }
        }
      }
    },

    /**
     * @method setEditor
     * @desc Set Editor
     * @param {aceEditor} editor
     */
    setEditor(editor) {
      apos.customCodeEditor.browser.editor = apos.util.assign({}, apos.customCodeEditor.browser.editor, {
        [this.field.name]: editor
      });
    },

    /**
     * @method getEditor
     * @desc Get Editor
     * @return {aceEditor} Get Editor or Null
     */
    getEditor() {
      if (_.has(apos.customCodeEditor.browser, `editor.${this.field.name}`)) {
        return apos.customCodeEditor.browser.editor[this.field.name];
      }

      return null;
    },

    /**
     * @method editor
     * @desc Initialize editor
     * @param {HTMLElement} element
     * @return {aceEditor} Return initialized AceJS Editor from Element
     */
    editor(element) {
      return ace.edit(element);
    },

    /**
     * @method getName
     * @desc To convert any 'camelCase' name to 'Camel Case'
     * @param {String} name
     * @return {String} String that has been trimmed and modified
     */
    getName(name) {
      return name.replace(/(_|-)/g, ' ')
        .trim()
        .replace(/\w\S*/g, function (str) {
          return str.charAt(0).toUpperCase() + str.substr(1);
        })
        .replace(/([a-z])([A-Z])/g, '$1 $2')
        .replace(/([A-Z])([A-Z][a-z])/g, '$1 $2')
        .trim();
    },

    /**
     * @method setDefaultSubmitValue
     * @desc To set default Submit value for `this.next` that will contains:
     * ```js
     *  this.next = {
     *    code: '',
     *    type: ''
     *  }
     * ```
     */
    setDefaultSubmitValue() {
      if (!_.isObject(this.next)) {
        this.next = {
          code: '',
          type: ''
        };
      } else {
        // Assign to original value;
        this.originalValue = this.next.code;
      }
    },

    /**
     * @method setEditorValue
     * @desc Set editor value on `mounted` function.
     */
    setEditorValue() {
      const editor = this.getEditor();
      if (_.isObject(this.next) && (_.has(this.next, 'code') && !_.isEmpty(this.next.code)) && (_.has(this.next, 'type') && !_.isEmpty(this.next.type))) {
        editor.session.setValue(this.next.code);
        editor.session.setMode('ace/mode/' + this.next.type.toLowerCase());
      }
    },

    /**
     * @method setSubmitValue
     * @desc To set submit value whenever editor is blur
     * @param {aceEditor} editor
     */
    setSubmitValue(editor) {
      const mode = editor.session.getMode().$id.match(/(?!(\/|\\))(?:\w)*$/g)[0];
      if (editor.getValue() !== this.next.code) {
        this.next.code = editor.getValue();
      }

      if ((editor.getValue().length > 0 || this.next.type.length > 0) && mode !== this.next.type) {
        this.next.type = mode;
      }
    },

    /**
     * @method editorEvents
     * @desc (DON'T OVERRIDE THIS) To register blur & focus Ace Editor Events
     * @param {aceEditor} editor
     */
    editorEvents(editor) {
      const self = this;
      // Set schema value onBlur event editor
      editor.on('blur', function () {
        // eslint-disable-next-line no-useless-call
        self.setSubmitValue.call(self, editor);
      });

      // When editor is on focus
      editor.on('focus', function () {
        // Remove Options Container if options container is on show class
        if (self.optionsClick) {
          self.optionsClick = false;
        }
      });
    }
  }
};