code.gitea.io/gitea@v1.21.7/web_src/js/features/comp/ComboMarkdownEditor.js (about)

     1  import '@github/markdown-toolbar-element';
     2  import '@github/text-expander-element';
     3  import $ from 'jquery';
     4  import {attachTribute} from '../tribute.js';
     5  import {hideElem, showElem, autosize} from '../../utils/dom.js';
     6  import {initEasyMDEImagePaste, initTextareaImagePaste} from './ImagePaste.js';
     7  import {handleGlobalEnterQuickSubmit} from './QuickSubmit.js';
     8  import {renderPreviewPanelContent} from '../repo-editor.js';
     9  import {easyMDEToolbarActions} from './EasyMDEToolbarActions.js';
    10  import {initTextExpander} from './TextExpander.js';
    11  import {showErrorToast} from '../../modules/toast.js';
    12  
    13  let elementIdCounter = 0;
    14  
    15  /**
    16   * validate if the given textarea is non-empty.
    17   * @param {jQuery} $textarea
    18   * @returns {boolean} returns true if validation succeeded.
    19   */
    20  export function validateTextareaNonEmpty($textarea) {
    21    // When using EasyMDE, the original edit area HTML element is hidden, breaking HTML5 input validation.
    22    // The workaround (https://github.com/sparksuite/simplemde-markdown-editor/issues/324) doesn't work with contenteditable, so we just show an alert.
    23    if (!$textarea.val()) {
    24      if ($textarea.is(':visible')) {
    25        $textarea.prop('required', true);
    26        const $form = $textarea.parents('form');
    27        $form[0]?.reportValidity();
    28      } else {
    29        // The alert won't hurt users too much, because we are dropping the EasyMDE and the check only occurs in a few places.
    30        showErrorToast('Require non-empty content');
    31      }
    32      return false;
    33    }
    34    return true;
    35  }
    36  
    37  class ComboMarkdownEditor {
    38    constructor(container, options = {}) {
    39      container._giteaComboMarkdownEditor = this;
    40      this.options = options;
    41      this.container = container;
    42    }
    43  
    44    async init() {
    45      this.prepareEasyMDEToolbarActions();
    46      this.setupContainer();
    47      this.setupTab();
    48      this.setupDropzone();
    49      this.setupTextarea();
    50  
    51      await this.switchToUserPreference();
    52    }
    53  
    54    applyEditorHeights(el, heights) {
    55      if (!heights) return;
    56      if (heights.minHeight) el.style.minHeight = heights.minHeight;
    57      if (heights.height) el.style.height = heights.height;
    58      if (heights.maxHeight) el.style.maxHeight = heights.maxHeight;
    59    }
    60  
    61    setupContainer() {
    62      initTextExpander(this.container.querySelector('text-expander'));
    63      this.container.addEventListener('ce-editor-content-changed', (e) => this.options?.onContentChanged?.(this, e));
    64    }
    65  
    66    setupTextarea() {
    67      this.textarea = this.container.querySelector('.markdown-text-editor');
    68      this.textarea._giteaComboMarkdownEditor = this;
    69      this.textarea.id = `_combo_markdown_editor_${String(elementIdCounter++)}`;
    70      this.textarea.addEventListener('input', (e) => this.options?.onContentChanged?.(this, e));
    71      this.applyEditorHeights(this.textarea, this.options.editorHeights);
    72  
    73      if (this.textarea.getAttribute('data-disable-autosize') !== 'true') {
    74        this.textareaAutosize = autosize(this.textarea, {viewportMarginBottom: 130});
    75      }
    76  
    77      this.textareaMarkdownToolbar = this.container.querySelector('markdown-toolbar');
    78      this.textareaMarkdownToolbar.setAttribute('for', this.textarea.id);
    79      for (const el of this.textareaMarkdownToolbar.querySelectorAll('.markdown-toolbar-button')) {
    80        // upstream bug: The role code is never executed in base MarkdownButtonElement https://github.com/github/markdown-toolbar-element/issues/70
    81        el.setAttribute('role', 'button');
    82        // the editor usually is in a form, so the buttons should have "type=button", avoiding conflicting with the form's submit.
    83        if (el.nodeName === 'BUTTON' && !el.getAttribute('type')) el.setAttribute('type', 'button');
    84      }
    85  
    86      const monospaceButton = this.container.querySelector('.markdown-switch-monospace');
    87      const monospaceEnabled = localStorage?.getItem('markdown-editor-monospace') === 'true';
    88      const monospaceText = monospaceButton.getAttribute(monospaceEnabled ? 'data-disable-text' : 'data-enable-text');
    89      monospaceButton.setAttribute('data-tooltip-content', monospaceText);
    90      monospaceButton.setAttribute('aria-checked', String(monospaceEnabled));
    91  
    92      monospaceButton?.addEventListener('click', (e) => {
    93        e.preventDefault();
    94        const enabled = localStorage?.getItem('markdown-editor-monospace') !== 'true';
    95        localStorage.setItem('markdown-editor-monospace', String(enabled));
    96        this.textarea.classList.toggle('gt-mono', enabled);
    97        const text = monospaceButton.getAttribute(enabled ? 'data-disable-text' : 'data-enable-text');
    98        monospaceButton.setAttribute('data-tooltip-content', text);
    99        monospaceButton.setAttribute('aria-checked', String(enabled));
   100      });
   101  
   102      const easymdeButton = this.container.querySelector('.markdown-switch-easymde');
   103      easymdeButton?.addEventListener('click', async (e) => {
   104        e.preventDefault();
   105        this.userPreferredEditor = 'easymde';
   106        await this.switchToEasyMDE();
   107      });
   108  
   109      if (this.dropzone) {
   110        initTextareaImagePaste(this.textarea, this.dropzone);
   111      }
   112    }
   113  
   114    setupDropzone() {
   115      const dropzoneParentContainer = this.container.getAttribute('data-dropzone-parent-container');
   116      if (dropzoneParentContainer) {
   117        this.dropzone = this.container.closest(this.container.getAttribute('data-dropzone-parent-container'))?.querySelector('.dropzone');
   118      }
   119    }
   120  
   121    setupTab() {
   122      const $container = $(this.container);
   123      const $tabMenu = $container.find('.tabular.menu');
   124      const $tabs = $tabMenu.find('> .item');
   125  
   126      // Fomantic Tab requires the "data-tab" to be globally unique.
   127      // So here it uses our defined "data-tab-for" and "data-tab-panel" to generate the "data-tab" attribute for Fomantic.
   128      const $tabEditor = $tabs.filter(`.item[data-tab-for="markdown-writer"]`);
   129      const $tabPreviewer = $tabs.filter(`.item[data-tab-for="markdown-previewer"]`);
   130      $tabEditor.attr('data-tab', `markdown-writer-${elementIdCounter}`);
   131      $tabPreviewer.attr('data-tab', `markdown-previewer-${elementIdCounter}`);
   132      const $panelEditor = $container.find('.ui.tab[data-tab-panel="markdown-writer"]');
   133      const $panelPreviewer = $container.find('.ui.tab[data-tab-panel="markdown-previewer"]');
   134      $panelEditor.attr('data-tab', `markdown-writer-${elementIdCounter}`);
   135      $panelPreviewer.attr('data-tab', `markdown-previewer-${elementIdCounter}`);
   136      elementIdCounter++;
   137  
   138      $tabEditor[0].addEventListener('click', () => {
   139        requestAnimationFrame(() => {
   140          this.focus();
   141        });
   142      });
   143  
   144      $tabs.tab();
   145  
   146      this.previewUrl = $tabPreviewer.attr('data-preview-url');
   147      this.previewContext = $tabPreviewer.attr('data-preview-context');
   148      this.previewMode = this.options.previewMode ?? 'comment';
   149      this.previewWiki = this.options.previewWiki ?? false;
   150      $tabPreviewer.on('click', () => {
   151        $.post(this.previewUrl, {
   152          _csrf: window.config.csrfToken,
   153          mode: this.previewMode,
   154          context: this.previewContext,
   155          text: this.value(),
   156          wiki: this.previewWiki,
   157        }, (data) => {
   158          renderPreviewPanelContent($panelPreviewer, data);
   159        });
   160      });
   161    }
   162  
   163    prepareEasyMDEToolbarActions() {
   164      this.easyMDEToolbarDefault = [
   165        'bold', 'italic', 'strikethrough', '|', 'heading-1', 'heading-2', 'heading-3',
   166        'heading-bigger', 'heading-smaller', '|', 'code', 'quote', '|', 'gitea-checkbox-empty',
   167        'gitea-checkbox-checked', '|', 'unordered-list', 'ordered-list', '|', 'link', 'image',
   168        'table', 'horizontal-rule', '|', 'gitea-switch-to-textarea',
   169      ];
   170    }
   171  
   172    parseEasyMDEToolbar(EasyMDE, actions) {
   173      this.easyMDEToolbarActions = this.easyMDEToolbarActions || easyMDEToolbarActions(EasyMDE, this);
   174      const processed = [];
   175      for (const action of actions) {
   176        const actionButton = this.easyMDEToolbarActions[action];
   177        if (!actionButton) throw new Error(`Unknown EasyMDE toolbar action ${action}`);
   178        processed.push(actionButton);
   179      }
   180      return processed;
   181    }
   182  
   183    async switchToUserPreference() {
   184      if (this.userPreferredEditor === 'easymde') {
   185        await this.switchToEasyMDE();
   186      } else {
   187        this.switchToTextarea();
   188      }
   189    }
   190  
   191    switchToTextarea() {
   192      if (!this.easyMDE) return;
   193      showElem(this.textareaMarkdownToolbar);
   194      if (this.easyMDE) {
   195        this.easyMDE.toTextArea();
   196        this.easyMDE = null;
   197      }
   198    }
   199  
   200    async switchToEasyMDE() {
   201      if (this.easyMDE) return;
   202      // EasyMDE's CSS should be loaded via webpack config, otherwise our own styles can not overwrite the default styles.
   203      const {default: EasyMDE} = await import(/* webpackChunkName: "easymde" */'easymde');
   204      const easyMDEOpt = {
   205        autoDownloadFontAwesome: false,
   206        element: this.textarea,
   207        forceSync: true,
   208        renderingConfig: {singleLineBreaks: false},
   209        indentWithTabs: false,
   210        tabSize: 4,
   211        spellChecker: false,
   212        inputStyle: 'contenteditable', // nativeSpellcheck requires contenteditable
   213        nativeSpellcheck: true,
   214        ...this.options.easyMDEOptions,
   215      };
   216      easyMDEOpt.toolbar = this.parseEasyMDEToolbar(EasyMDE, easyMDEOpt.toolbar ?? this.easyMDEToolbarDefault);
   217  
   218      this.easyMDE = new EasyMDE(easyMDEOpt);
   219      this.easyMDE.codemirror.on('change', (...args) => {this.options?.onContentChanged?.(this, ...args)});
   220      this.easyMDE.codemirror.setOption('extraKeys', {
   221        'Cmd-Enter': (cm) => handleGlobalEnterQuickSubmit(cm.getTextArea()),
   222        'Ctrl-Enter': (cm) => handleGlobalEnterQuickSubmit(cm.getTextArea()),
   223        Enter: (cm) => {
   224          const tributeContainer = document.querySelector('.tribute-container');
   225          if (!tributeContainer || tributeContainer.style.display === 'none') {
   226            cm.execCommand('newlineAndIndent');
   227          }
   228        },
   229        Up: (cm) => {
   230          const tributeContainer = document.querySelector('.tribute-container');
   231          if (!tributeContainer || tributeContainer.style.display === 'none') {
   232            return cm.execCommand('goLineUp');
   233          }
   234        },
   235        Down: (cm) => {
   236          const tributeContainer = document.querySelector('.tribute-container');
   237          if (!tributeContainer || tributeContainer.style.display === 'none') {
   238            return cm.execCommand('goLineDown');
   239          }
   240        },
   241      });
   242      this.applyEditorHeights(this.container.querySelector('.CodeMirror-scroll'), this.options.editorHeights);
   243      await attachTribute(this.easyMDE.codemirror.getInputField(), {mentions: true, emoji: true});
   244      initEasyMDEImagePaste(this.easyMDE, this.dropzone);
   245      hideElem(this.textareaMarkdownToolbar);
   246    }
   247  
   248    value(v = undefined) {
   249      if (v === undefined) {
   250        if (this.easyMDE) {
   251          return this.easyMDE.value();
   252        }
   253        return this.textarea.value;
   254      }
   255  
   256      if (this.easyMDE) {
   257        this.easyMDE.value(v);
   258      } else {
   259        this.textarea.value = v;
   260      }
   261      this.textareaAutosize?.resizeToFit();
   262    }
   263  
   264    focus() {
   265      if (this.easyMDE) {
   266        this.easyMDE.codemirror.focus();
   267      } else {
   268        this.textarea.focus();
   269      }
   270    }
   271  
   272    moveCursorToEnd() {
   273      this.textarea.focus();
   274      this.textarea.setSelectionRange(this.textarea.value.length, this.textarea.value.length);
   275      if (this.easyMDE) {
   276        this.easyMDE.codemirror.focus();
   277        this.easyMDE.codemirror.setCursor(this.easyMDE.codemirror.lineCount(), 0);
   278      }
   279    }
   280  
   281    get userPreferredEditor() {
   282      return window.localStorage.getItem(`markdown-editor-${this.options.useScene ?? 'default'}`);
   283    }
   284    set userPreferredEditor(s) {
   285      window.localStorage.setItem(`markdown-editor-${this.options.useScene ?? 'default'}`, s);
   286    }
   287  }
   288  
   289  export function getComboMarkdownEditor(el) {
   290    if (el instanceof $) el = el[0];
   291    return el?._giteaComboMarkdownEditor;
   292  }
   293  
   294  export async function initComboMarkdownEditor(container, options = {}) {
   295    if (container instanceof $) {
   296      if (container.length !== 1) {
   297        throw new Error('initComboMarkdownEditor: container must be a single element');
   298      }
   299      container = container[0];
   300    }
   301    if (!container) {
   302      throw new Error('initComboMarkdownEditor: container is null');
   303    }
   304    const editor = new ComboMarkdownEditor(container, options);
   305    await editor.init();
   306    return editor;
   307  }