mirror of
				https://gitee.com/gitea/gitea
				synced 2025-11-04 08:30:25 +08:00 
			
		
		
		
	Introduce GitHub markdown editor, keep EasyMDE as fallback (#23876)
The first step of the plan * #23290 Thanks to @silverwind for the first try in #15394 . Close #10729 and a lot of related issues. The EasyMDE is not removed, now it works as a fallback, users can switch between these two editors. Editor list: * Issue / PR comment * Issue / PR comment edit * Issue / PR comment quote reply * PR diff view, inline comment * PR diff view, inline comment edit * PR diff view, inline comment quote reply * Release editor * Wiki editor Some editors have attached dropzone Screenshots: <details>     </details> --------- Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
		
							
								
								
									
										277
									
								
								web_src/js/features/comp/ComboMarkdownEditor.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										277
									
								
								web_src/js/features/comp/ComboMarkdownEditor.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,277 @@
 | 
			
		||||
import '@github/markdown-toolbar-element';
 | 
			
		||||
import {attachTribute} from '../tribute.js';
 | 
			
		||||
import {hideElem, showElem} from '../../utils/dom.js';
 | 
			
		||||
import {initEasyMDEImagePaste, initTextareaImagePaste} from './ImagePaste.js';
 | 
			
		||||
import $ from 'jquery';
 | 
			
		||||
import {initMarkupContent} from '../../markup/content.js';
 | 
			
		||||
import {handleGlobalEnterQuickSubmit} from './QuickSubmit.js';
 | 
			
		||||
import {attachRefIssueContextPopup} from '../contextpopup.js';
 | 
			
		||||
 | 
			
		||||
let elementIdCounter = 0;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * validate if the given textarea is non-empty.
 | 
			
		||||
 * @param {jQuery} $textarea
 | 
			
		||||
 * @returns {boolean} returns true if validation succeeded.
 | 
			
		||||
 */
 | 
			
		||||
export function validateTextareaNonEmpty($textarea) {
 | 
			
		||||
  // When using EasyMDE, the original edit area HTML element is hidden, breaking HTML5 input validation.
 | 
			
		||||
  // The workaround (https://github.com/sparksuite/simplemde-markdown-editor/issues/324) doesn't work with contenteditable, so we just show an alert.
 | 
			
		||||
  if (!$textarea.val()) {
 | 
			
		||||
    if ($textarea.is(':visible')) {
 | 
			
		||||
      $textarea.prop('required', true);
 | 
			
		||||
      const $form = $textarea.parents('form');
 | 
			
		||||
      $form[0]?.reportValidity();
 | 
			
		||||
    } else {
 | 
			
		||||
      // The alert won't hurt users too much, because we are dropping the EasyMDE and the check only occurs in a few places.
 | 
			
		||||
      alert('Require non-empty content');
 | 
			
		||||
    }
 | 
			
		||||
    return false;
 | 
			
		||||
  }
 | 
			
		||||
  return true;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class ComboMarkdownEditor {
 | 
			
		||||
  constructor(container, options = {}) {
 | 
			
		||||
    container._giteaComboMarkdownEditor = this;
 | 
			
		||||
    this.options = options;
 | 
			
		||||
    this.container = container;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async init() {
 | 
			
		||||
    this.textarea = this.container.querySelector('.markdown-text-editor');
 | 
			
		||||
    this.textarea._giteaComboMarkdownEditor = this;
 | 
			
		||||
    this.textarea.id = `_combo_markdown_editor_${String(elementIdCounter)}`;
 | 
			
		||||
    this.textarea.addEventListener('input', (e) => {this.options?.onContentChanged?.(this, e)});
 | 
			
		||||
    this.textareaMarkdownToolbar = this.container.querySelector('markdown-toolbar');
 | 
			
		||||
    this.textareaMarkdownToolbar.setAttribute('for', this.textarea.id);
 | 
			
		||||
 | 
			
		||||
    elementIdCounter++;
 | 
			
		||||
 | 
			
		||||
    this.switchToEasyMDEButton = this.container.querySelector('.markdown-switch-easymde');
 | 
			
		||||
    this.switchToEasyMDEButton?.addEventListener('click', async (e) => {
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      await this.switchToEasyMDE();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    await attachTribute(this.textarea, {mentions: true, emoji: true});
 | 
			
		||||
 | 
			
		||||
    const dropzoneParentContainer = this.container.getAttribute('data-dropzone-parent-container');
 | 
			
		||||
    if (dropzoneParentContainer) {
 | 
			
		||||
      this.dropzone = this.container.closest(this.container.getAttribute('data-dropzone-parent-container'))?.querySelector('.dropzone');
 | 
			
		||||
      initTextareaImagePaste(this.textarea, this.dropzone);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this.setupTab();
 | 
			
		||||
    this.prepareEasyMDEToolbarActions();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setupTab() {
 | 
			
		||||
    const $container = $(this.container);
 | 
			
		||||
    const $tabMenu = $container.find('.tabular.menu');
 | 
			
		||||
    const $tabs = $tabMenu.find('> .item');
 | 
			
		||||
 | 
			
		||||
    // Fomantic Tab requires the "data-tab" to be globally unique.
 | 
			
		||||
    // So here it uses our defined "data-tab-for" and "data-tab-panel" to generate the "data-tab" attribute for Fomantic.
 | 
			
		||||
    const $tabEditor = $tabs.filter(`.item[data-tab-for="markdown-writer"]`);
 | 
			
		||||
    const $tabPreviewer = $tabs.filter(`.item[data-tab-for="markdown-previewer"]`);
 | 
			
		||||
    $tabEditor.attr('data-tab', `markdown-writer-${elementIdCounter}`);
 | 
			
		||||
    $tabPreviewer.attr('data-tab', `markdown-previewer-${elementIdCounter}`);
 | 
			
		||||
    const $panelEditor = $container.find('.ui.tab[data-tab-panel="markdown-writer"]');
 | 
			
		||||
    const $panelPreviewer = $container.find('.ui.tab[data-tab-panel="markdown-previewer"]');
 | 
			
		||||
    $panelEditor.attr('data-tab', `markdown-writer-${elementIdCounter}`);
 | 
			
		||||
    $panelPreviewer.attr('data-tab', `markdown-previewer-${elementIdCounter}`);
 | 
			
		||||
    elementIdCounter++;
 | 
			
		||||
 | 
			
		||||
    $tabs.tab();
 | 
			
		||||
 | 
			
		||||
    this.previewUrl = $tabPreviewer.attr('data-preview-url');
 | 
			
		||||
    this.previewContext = $tabPreviewer.attr('data-preview-context');
 | 
			
		||||
    this.previewMode = this.options.previewMode ?? 'comment';
 | 
			
		||||
    this.previewWiki = this.options.previewWiki ?? false;
 | 
			
		||||
    $tabPreviewer.on('click', () => {
 | 
			
		||||
      $.post(this.previewUrl, {
 | 
			
		||||
        _csrf: window.config.csrfToken,
 | 
			
		||||
        mode: this.previewMode,
 | 
			
		||||
        context: this.previewContext,
 | 
			
		||||
        text: this.value(),
 | 
			
		||||
        wiki: this.previewWiki,
 | 
			
		||||
      }, (data) => {
 | 
			
		||||
        $panelPreviewer.html(data);
 | 
			
		||||
        initMarkupContent();
 | 
			
		||||
 | 
			
		||||
        const refIssues = $panelPreviewer.find('p .ref-issue');
 | 
			
		||||
        attachRefIssueContextPopup(refIssues);
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  prepareEasyMDEToolbarActions() {
 | 
			
		||||
    this.easyMDEToolbarDefault = [
 | 
			
		||||
      'bold', 'italic', 'strikethrough', '|', 'heading-1', 'heading-2', 'heading-3', 'heading-bigger', 'heading-smaller', '|',
 | 
			
		||||
      'code', 'quote', '|', 'gitea-checkbox-empty', 'gitea-checkbox-checked', '|',
 | 
			
		||||
      'unordered-list', 'ordered-list', '|', 'link', 'image', 'table', 'horizontal-rule', '|', 'clean-block', '|',
 | 
			
		||||
      'gitea-switch-to-textarea',
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    this.easyMDEToolbarActions = {
 | 
			
		||||
      'gitea-checkbox-empty': {
 | 
			
		||||
        action(e) {
 | 
			
		||||
          const cm = e.codemirror;
 | 
			
		||||
          cm.replaceSelection(`\n- [ ] ${cm.getSelection()}`);
 | 
			
		||||
          cm.focus();
 | 
			
		||||
        },
 | 
			
		||||
        className: 'fa fa-square-o',
 | 
			
		||||
        title: 'Add Checkbox (empty)',
 | 
			
		||||
      },
 | 
			
		||||
      'gitea-checkbox-checked': {
 | 
			
		||||
        action(e) {
 | 
			
		||||
          const cm = e.codemirror;
 | 
			
		||||
          cm.replaceSelection(`\n- [x] ${cm.getSelection()}`);
 | 
			
		||||
          cm.focus();
 | 
			
		||||
        },
 | 
			
		||||
        className: 'fa fa-check-square-o',
 | 
			
		||||
        title: 'Add Checkbox (checked)',
 | 
			
		||||
      },
 | 
			
		||||
      'gitea-switch-to-textarea': {
 | 
			
		||||
        action: this.switchToTextarea.bind(this),
 | 
			
		||||
        className: 'fa fa-file',
 | 
			
		||||
        title: 'Revert to simple textarea',
 | 
			
		||||
      },
 | 
			
		||||
      'gitea-code-inline': {
 | 
			
		||||
        action(e) {
 | 
			
		||||
          const cm = e.codemirror;
 | 
			
		||||
          const selection = cm.getSelection();
 | 
			
		||||
          cm.replaceSelection(`\`${selection}\``);
 | 
			
		||||
          if (!selection) {
 | 
			
		||||
            const cursorPos = cm.getCursor();
 | 
			
		||||
            cm.setCursor(cursorPos.line, cursorPos.ch - 1);
 | 
			
		||||
          }
 | 
			
		||||
          cm.focus();
 | 
			
		||||
        },
 | 
			
		||||
        className: 'fa fa-angle-right',
 | 
			
		||||
        title: 'Add Inline Code',
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  parseEasyMDEToolbar(actions) {
 | 
			
		||||
    const processed = [];
 | 
			
		||||
    for (const action of actions) {
 | 
			
		||||
      if (action.startsWith('gitea-')) {
 | 
			
		||||
        const giteaAction = this.easyMDEToolbarActions[action];
 | 
			
		||||
        if (!giteaAction) throw new Error(`Unknown EasyMDE toolbar action ${action}`);
 | 
			
		||||
        processed.push(giteaAction);
 | 
			
		||||
      } else {
 | 
			
		||||
        processed.push(action);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return processed;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async switchToTextarea() {
 | 
			
		||||
    showElem(this.textareaMarkdownToolbar);
 | 
			
		||||
    if (this.easyMDE) {
 | 
			
		||||
      this.easyMDE.toTextArea();
 | 
			
		||||
      this.easyMDE = null;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async switchToEasyMDE() {
 | 
			
		||||
    // EasyMDE's CSS should be loaded via webpack config, otherwise our own styles can not overwrite the default styles.
 | 
			
		||||
    const {default: EasyMDE} = await import(/* webpackChunkName: "easymde" */'easymde');
 | 
			
		||||
    const easyMDEOpt = {
 | 
			
		||||
      autoDownloadFontAwesome: false,
 | 
			
		||||
      element: this.textarea,
 | 
			
		||||
      forceSync: true,
 | 
			
		||||
      renderingConfig: {singleLineBreaks: false},
 | 
			
		||||
      indentWithTabs: false,
 | 
			
		||||
      tabSize: 4,
 | 
			
		||||
      spellChecker: false,
 | 
			
		||||
      inputStyle: 'contenteditable', // nativeSpellcheck requires contenteditable
 | 
			
		||||
      nativeSpellcheck: true,
 | 
			
		||||
      ...this.options.easyMDEOptions,
 | 
			
		||||
    };
 | 
			
		||||
    easyMDEOpt.toolbar = this.parseEasyMDEToolbar(easyMDEOpt.toolbar ?? this.easyMDEToolbarDefault);
 | 
			
		||||
 | 
			
		||||
    this.easyMDE = new EasyMDE(easyMDEOpt);
 | 
			
		||||
    this.easyMDE.codemirror.on('change', (...args) => {this.options?.onContentChanged?.(this, ...args)});
 | 
			
		||||
    this.easyMDE.codemirror.setOption('extraKeys', {
 | 
			
		||||
      'Cmd-Enter': (cm) => handleGlobalEnterQuickSubmit(cm.getTextArea()),
 | 
			
		||||
      'Ctrl-Enter': (cm) => handleGlobalEnterQuickSubmit(cm.getTextArea()),
 | 
			
		||||
      Enter: (cm) => {
 | 
			
		||||
        const tributeContainer = document.querySelector('.tribute-container');
 | 
			
		||||
        if (!tributeContainer || tributeContainer.style.display === 'none') {
 | 
			
		||||
          cm.execCommand('newlineAndIndent');
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      Up: (cm) => {
 | 
			
		||||
        const tributeContainer = document.querySelector('.tribute-container');
 | 
			
		||||
        if (!tributeContainer || tributeContainer.style.display === 'none') {
 | 
			
		||||
          return cm.execCommand('goLineUp');
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      Down: (cm) => {
 | 
			
		||||
        const tributeContainer = document.querySelector('.tribute-container');
 | 
			
		||||
        if (!tributeContainer || tributeContainer.style.display === 'none') {
 | 
			
		||||
          return cm.execCommand('goLineDown');
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
    await attachTribute(this.easyMDE.codemirror.getInputField(), {mentions: true, emoji: true});
 | 
			
		||||
    initEasyMDEImagePaste(this.easyMDE, this.dropzone);
 | 
			
		||||
    hideElem(this.textareaMarkdownToolbar);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  value(v = undefined) {
 | 
			
		||||
    if (v === undefined) {
 | 
			
		||||
      if (this.easyMDE) {
 | 
			
		||||
        return this.easyMDE.value();
 | 
			
		||||
      }
 | 
			
		||||
      return this.textarea.value;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (this.easyMDE) {
 | 
			
		||||
      this.easyMDE.value(v);
 | 
			
		||||
    } else {
 | 
			
		||||
      this.textarea.value = v;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  focus() {
 | 
			
		||||
    if (this.easyMDE) {
 | 
			
		||||
      this.easyMDE.codemirror.focus();
 | 
			
		||||
    } else {
 | 
			
		||||
      this.textarea.focus();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  moveCursorToEnd() {
 | 
			
		||||
    this.textarea.focus();
 | 
			
		||||
    this.textarea.setSelectionRange(this.textarea.value.length, this.textarea.value.length);
 | 
			
		||||
    if (this.easyMDE) {
 | 
			
		||||
      this.easyMDE.codemirror.focus();
 | 
			
		||||
      this.easyMDE.codemirror.setCursor(this.easyMDE.codemirror.lineCount(), 0);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function getComboMarkdownEditor(el) {
 | 
			
		||||
  if (el instanceof $) el = el[0];
 | 
			
		||||
  return el?._giteaComboMarkdownEditor;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function initComboMarkdownEditor(container, options = {}) {
 | 
			
		||||
  if (container instanceof $) {
 | 
			
		||||
    if (container.length !== 1) {
 | 
			
		||||
      throw new Error('initComboMarkdownEditor: container must be a single element');
 | 
			
		||||
    }
 | 
			
		||||
    container = container[0];
 | 
			
		||||
  }
 | 
			
		||||
  if (!container) {
 | 
			
		||||
    throw new Error('initComboMarkdownEditor: container is null');
 | 
			
		||||
  }
 | 
			
		||||
  const editor = new ComboMarkdownEditor(container, options);
 | 
			
		||||
  await editor.init();
 | 
			
		||||
  return editor;
 | 
			
		||||
}
 | 
			
		||||
@@ -1,181 +0,0 @@
 | 
			
		||||
import $ from 'jquery';
 | 
			
		||||
import {attachTribute} from '../tribute.js';
 | 
			
		||||
import {handleGlobalEnterQuickSubmit} from './QuickSubmit.js';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @returns {EasyMDE}
 | 
			
		||||
 */
 | 
			
		||||
export async function importEasyMDE() {
 | 
			
		||||
  // EasyMDE's CSS should be loaded via webpack config, otherwise our own styles can
 | 
			
		||||
  // not overwrite the default styles.
 | 
			
		||||
  const {default: EasyMDE} = await import(/* webpackChunkName: "easymde" */'easymde');
 | 
			
		||||
  return EasyMDE;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * create an EasyMDE editor for comment
 | 
			
		||||
 * @param textarea jQuery or HTMLElement
 | 
			
		||||
 * @param easyMDEOptions the options for EasyMDE
 | 
			
		||||
 * @returns {null|EasyMDE}
 | 
			
		||||
 */
 | 
			
		||||
export async function createCommentEasyMDE(textarea, easyMDEOptions = {}) {
 | 
			
		||||
  if (textarea instanceof $) {
 | 
			
		||||
    textarea = textarea[0];
 | 
			
		||||
  }
 | 
			
		||||
  if (!textarea) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const EasyMDE = await importEasyMDE();
 | 
			
		||||
 | 
			
		||||
  const easyMDE = new EasyMDE({
 | 
			
		||||
    autoDownloadFontAwesome: false,
 | 
			
		||||
    element: textarea,
 | 
			
		||||
    forceSync: true,
 | 
			
		||||
    renderingConfig: {
 | 
			
		||||
      singleLineBreaks: false,
 | 
			
		||||
    },
 | 
			
		||||
    indentWithTabs: false,
 | 
			
		||||
    tabSize: 4,
 | 
			
		||||
    spellChecker: false,
 | 
			
		||||
    inputStyle: 'contenteditable', // nativeSpellcheck requires contenteditable
 | 
			
		||||
    nativeSpellcheck: true,
 | 
			
		||||
    toolbar: ['bold', 'italic', 'strikethrough', '|',
 | 
			
		||||
      'heading-1', 'heading-2', 'heading-3', 'heading-bigger', 'heading-smaller', '|',
 | 
			
		||||
      'code', 'quote', '|', {
 | 
			
		||||
        name: 'checkbox-empty',
 | 
			
		||||
        action(e) {
 | 
			
		||||
          const cm = e.codemirror;
 | 
			
		||||
          cm.replaceSelection(`\n- [ ] ${cm.getSelection()}`);
 | 
			
		||||
          cm.focus();
 | 
			
		||||
        },
 | 
			
		||||
        className: 'fa fa-square-o',
 | 
			
		||||
        title: 'Add Checkbox (empty)',
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        name: 'checkbox-checked',
 | 
			
		||||
        action(e) {
 | 
			
		||||
          const cm = e.codemirror;
 | 
			
		||||
          cm.replaceSelection(`\n- [x] ${cm.getSelection()}`);
 | 
			
		||||
          cm.focus();
 | 
			
		||||
        },
 | 
			
		||||
        className: 'fa fa-check-square-o',
 | 
			
		||||
        title: 'Add Checkbox (checked)',
 | 
			
		||||
      }, '|',
 | 
			
		||||
      'unordered-list', 'ordered-list', '|',
 | 
			
		||||
      'link', 'image', 'table', 'horizontal-rule', '|',
 | 
			
		||||
      'clean-block', '|',
 | 
			
		||||
      {
 | 
			
		||||
        name: 'revert-to-textarea',
 | 
			
		||||
        action(e) {
 | 
			
		||||
          e.toTextArea();
 | 
			
		||||
        },
 | 
			
		||||
        className: 'fa fa-file',
 | 
			
		||||
        title: 'Revert to simple textarea',
 | 
			
		||||
      },
 | 
			
		||||
    ], ...easyMDEOptions});
 | 
			
		||||
 | 
			
		||||
  const inputField = easyMDE.codemirror.getInputField();
 | 
			
		||||
 | 
			
		||||
  easyMDE.codemirror.on('change', (...args) => {
 | 
			
		||||
    easyMDEOptions?.onChange?.(...args);
 | 
			
		||||
  });
 | 
			
		||||
  easyMDE.codemirror.setOption('extraKeys', {
 | 
			
		||||
    'Cmd-Enter': codeMirrorQuickSubmit,
 | 
			
		||||
    'Ctrl-Enter': codeMirrorQuickSubmit,
 | 
			
		||||
    Enter: (cm) => {
 | 
			
		||||
      const tributeContainer = document.querySelector('.tribute-container');
 | 
			
		||||
      if (!tributeContainer || tributeContainer.style.display === 'none') {
 | 
			
		||||
        cm.execCommand('newlineAndIndent');
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    Backspace: (cm) => {
 | 
			
		||||
      if (cm.getInputField().trigger) {
 | 
			
		||||
        cm.getInputField().trigger('input');
 | 
			
		||||
      }
 | 
			
		||||
      cm.execCommand('delCharBefore');
 | 
			
		||||
    },
 | 
			
		||||
    Up: (cm) => {
 | 
			
		||||
      const tributeContainer = document.querySelector('.tribute-container');
 | 
			
		||||
      if (!tributeContainer || tributeContainer.style.display === 'none') {
 | 
			
		||||
        return cm.execCommand('goLineUp');
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    Down: (cm) => {
 | 
			
		||||
      const tributeContainer = document.querySelector('.tribute-container');
 | 
			
		||||
      if (!tributeContainer || tributeContainer.style.display === 'none') {
 | 
			
		||||
        return cm.execCommand('goLineDown');
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
  await attachTribute(inputField, {mentions: true, emoji: true});
 | 
			
		||||
  attachEasyMDEToElements(easyMDE);
 | 
			
		||||
  return easyMDE;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * attach the EasyMDE object to its input elements (InputField, TextArea)
 | 
			
		||||
 * @param {EasyMDE} easyMDE
 | 
			
		||||
 */
 | 
			
		||||
export function attachEasyMDEToElements(easyMDE) {
 | 
			
		||||
  // TODO: that's the only way we can do now to attach the EasyMDE object to a HTMLElement
 | 
			
		||||
 | 
			
		||||
  // InputField is used by CodeMirror to accept user input
 | 
			
		||||
  const inputField = easyMDE.codemirror.getInputField();
 | 
			
		||||
  inputField._data_easyMDE = easyMDE;
 | 
			
		||||
 | 
			
		||||
  // TextArea is the real textarea element in the form
 | 
			
		||||
  const textArea = easyMDE.codemirror.getTextArea();
 | 
			
		||||
  textArea._data_easyMDE = easyMDE;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * get the attached EasyMDE editor created by createCommentEasyMDE
 | 
			
		||||
 * @param el jQuery or HTMLElement
 | 
			
		||||
 * @returns {null|EasyMDE}
 | 
			
		||||
 */
 | 
			
		||||
export function getAttachedEasyMDE(el) {
 | 
			
		||||
  if (el instanceof $) {
 | 
			
		||||
    el = el[0];
 | 
			
		||||
  }
 | 
			
		||||
  if (!el) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
  return el._data_easyMDE;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * validate if the given EasyMDE textarea is is non-empty.
 | 
			
		||||
 * @param {jQuery} $textarea
 | 
			
		||||
 * @returns {boolean} returns true if validation succeeded.
 | 
			
		||||
 */
 | 
			
		||||
export function validateTextareaNonEmpty($textarea) {
 | 
			
		||||
  const $mdeInputField = $(getAttachedEasyMDE($textarea).codemirror.getInputField());
 | 
			
		||||
  // The original edit area HTML element is hidden and replaced by the
 | 
			
		||||
  // SimpleMDE/EasyMDE editor, breaking HTML5 input validation if the text area is empty.
 | 
			
		||||
  // This is a workaround for this upstream bug.
 | 
			
		||||
  // See https://github.com/sparksuite/simplemde-markdown-editor/issues/324
 | 
			
		||||
  if (!$textarea.val()) {
 | 
			
		||||
    $mdeInputField.prop('required', true);
 | 
			
		||||
    const $form = $textarea.parents('form');
 | 
			
		||||
    if (!$form.length) {
 | 
			
		||||
      // this should never happen. we put a alert here in case the textarea would be forgotten to be put in a form
 | 
			
		||||
      alert('Require non-empty content');
 | 
			
		||||
    } else {
 | 
			
		||||
      $form[0].reportValidity();
 | 
			
		||||
    }
 | 
			
		||||
    return false;
 | 
			
		||||
  }
 | 
			
		||||
  $mdeInputField.prop('required', false);
 | 
			
		||||
  return true;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * there is no guarantee that the CodeMirror object is inside the same form as the textarea,
 | 
			
		||||
 * so can not call handleGlobalEnterQuickSubmit directly.
 | 
			
		||||
 * @param {CodeMirror.EditorFromTextArea} codeMirror
 | 
			
		||||
 */
 | 
			
		||||
export function codeMirrorQuickSubmit(codeMirror) {
 | 
			
		||||
  handleGlobalEnterQuickSubmit(codeMirror.getTextArea());
 | 
			
		||||
}
 | 
			
		||||
@@ -88,38 +88,43 @@ class CodeMirrorEditor {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
export function initEasyMDEImagePaste(easyMDE, $dropzone) {
 | 
			
		||||
const uploadClipboardImage = async (editor, dropzone, e) => {
 | 
			
		||||
  const $dropzone = $(dropzone);
 | 
			
		||||
  const uploadUrl = $dropzone.attr('data-upload-url');
 | 
			
		||||
  const $files = $dropzone.find('.files');
 | 
			
		||||
 | 
			
		||||
  if (!uploadUrl || !$files.length) return;
 | 
			
		||||
 | 
			
		||||
  const uploadClipboardImage = async (editor, e) => {
 | 
			
		||||
    const pastedImages = clipboardPastedImages(e);
 | 
			
		||||
    if (!pastedImages || pastedImages.length === 0) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    e.preventDefault();
 | 
			
		||||
    e.stopPropagation();
 | 
			
		||||
  const pastedImages = clipboardPastedImages(e);
 | 
			
		||||
  if (!pastedImages || pastedImages.length === 0) {
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
  e.preventDefault();
 | 
			
		||||
  e.stopPropagation();
 | 
			
		||||
 | 
			
		||||
    for (const img of pastedImages) {
 | 
			
		||||
      const name = img.name.slice(0, img.name.lastIndexOf('.'));
 | 
			
		||||
  for (const img of pastedImages) {
 | 
			
		||||
    const name = img.name.slice(0, img.name.lastIndexOf('.'));
 | 
			
		||||
 | 
			
		||||
      const placeholder = ``;
 | 
			
		||||
      editor.insertPlaceholder(placeholder);
 | 
			
		||||
      const data = await uploadFile(img, uploadUrl);
 | 
			
		||||
      editor.replacePlaceholder(placeholder, ``);
 | 
			
		||||
    const placeholder = ``;
 | 
			
		||||
    editor.insertPlaceholder(placeholder);
 | 
			
		||||
    const data = await uploadFile(img, uploadUrl);
 | 
			
		||||
    editor.replacePlaceholder(placeholder, ``);
 | 
			
		||||
 | 
			
		||||
      const $input = $(`<input name="files" type="hidden">`).attr('id', data.uuid).val(data.uuid);
 | 
			
		||||
      $files.append($input);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
    const $input = $(`<input name="files" type="hidden">`).attr('id', data.uuid).val(data.uuid);
 | 
			
		||||
    $files.append($input);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function initEasyMDEImagePaste(easyMDE, dropzone) {
 | 
			
		||||
  if (!dropzone) return;
 | 
			
		||||
  easyMDE.codemirror.on('paste', async (_, e) => {
 | 
			
		||||
    return uploadClipboardImage(new CodeMirrorEditor(easyMDE.codemirror), e);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  $(easyMDE.element).on('paste', async (e) => {
 | 
			
		||||
    return uploadClipboardImage(new TextareaEditor(easyMDE.element), e.originalEvent);
 | 
			
		||||
    return uploadClipboardImage(new CodeMirrorEditor(easyMDE.codemirror), dropzone, e);
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function initTextareaImagePaste(textarea, dropzone) {
 | 
			
		||||
  if (!dropzone) return;
 | 
			
		||||
  $(textarea).on('paste', async (e) => {
 | 
			
		||||
    return uploadClipboardImage(new TextareaEditor(textarea), dropzone, e.originalEvent);
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,25 +0,0 @@
 | 
			
		||||
import $ from 'jquery';
 | 
			
		||||
import {initMarkupContent} from '../../markup/content.js';
 | 
			
		||||
import {attachRefIssueContextPopup} from '../contextpopup.js';
 | 
			
		||||
 | 
			
		||||
const {csrfToken} = window.config;
 | 
			
		||||
 | 
			
		||||
export function initCompMarkupContentPreviewTab($form) {
 | 
			
		||||
  const $tabMenu = $form.find('.tabular.menu');
 | 
			
		||||
  $tabMenu.find('.item').tab();
 | 
			
		||||
  $tabMenu.find(`.item[data-tab="${$tabMenu.data('preview')}"]`).on('click', function () {
 | 
			
		||||
    const $this = $(this);
 | 
			
		||||
    $.post($this.data('url'), {
 | 
			
		||||
      _csrf: csrfToken,
 | 
			
		||||
      mode: 'comment',
 | 
			
		||||
      context: $this.data('context'),
 | 
			
		||||
      text: $form.find(`.tab[data-tab="${$tabMenu.data('write')}"] textarea`).val()
 | 
			
		||||
    }, (data) => {
 | 
			
		||||
      const $previewPanel = $form.find(`.tab[data-tab="${$tabMenu.data('preview')}"]`);
 | 
			
		||||
      $previewPanel.html(data);
 | 
			
		||||
      const refIssues = $previewPanel.find('p .ref-issue');
 | 
			
		||||
      attachRefIssueContextPopup(refIssues);
 | 
			
		||||
      initMarkupContent();
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
@@ -10,17 +10,16 @@ export function initContextPopups() {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function attachRefIssueContextPopup(refIssues) {
 | 
			
		||||
  if (!refIssues.length) return;
 | 
			
		||||
  refIssues.each(function () {
 | 
			
		||||
    if ($(this).hasClass('ref-external-issue')) {
 | 
			
		||||
  for (const refIssue of refIssues) {
 | 
			
		||||
    if (refIssue.classList.contains('ref-external-issue')) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const {owner, repo, index} = parseIssueHref($(this).attr('href'));
 | 
			
		||||
    const {owner, repo, index} = parseIssueHref(refIssue.getAttribute('href'));
 | 
			
		||||
    if (!owner) return;
 | 
			
		||||
 | 
			
		||||
    const el = document.createElement('div');
 | 
			
		||||
    this.parentNode.insertBefore(el, this.nextSibling);
 | 
			
		||||
    refIssue.parentNode.insertBefore(el, refIssue.nextSibling);
 | 
			
		||||
 | 
			
		||||
    const view = createApp(ContextPopup);
 | 
			
		||||
 | 
			
		||||
@@ -31,7 +30,7 @@ export function attachRefIssueContextPopup(refIssues) {
 | 
			
		||||
      el.textContent = 'ContextPopup failed to load';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    createTippy(this, {
 | 
			
		||||
    createTippy(refIssue, {
 | 
			
		||||
      content: el,
 | 
			
		||||
      placement: 'top-start',
 | 
			
		||||
      interactive: true,
 | 
			
		||||
@@ -40,5 +39,5 @@ export function attachRefIssueContextPopup(refIssues) {
 | 
			
		||||
        el.firstChild.dispatchEvent(new CustomEvent('ce-load-context-popup', {detail: {owner, repo, index}}));
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,8 @@
 | 
			
		||||
import $ from 'jquery';
 | 
			
		||||
import {initCompReactionSelector} from './comp/ReactionSelector.js';
 | 
			
		||||
import {initRepoIssueContentHistory} from './repo-issue-content.js';
 | 
			
		||||
import {validateTextareaNonEmpty} from './comp/EasyMDE.js';
 | 
			
		||||
import {initViewedCheckboxListenerFor, countAndUpdateViewedFiles} from './pull-view-file.js';
 | 
			
		||||
import {validateTextareaNonEmpty} from './comp/ComboMarkdownEditor.js';
 | 
			
		||||
 | 
			
		||||
const {csrfToken} = window.config;
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,9 @@
 | 
			
		||||
import $ from 'jquery';
 | 
			
		||||
import {htmlEscape} from 'escape-goat';
 | 
			
		||||
import {attachTribute} from './tribute.js';
 | 
			
		||||
import {createCommentEasyMDE, getAttachedEasyMDE} from './comp/EasyMDE.js';
 | 
			
		||||
import {initEasyMDEImagePaste} from './comp/ImagePaste.js';
 | 
			
		||||
import {initCompMarkupContentPreviewTab} from './comp/MarkupContentPreview.js';
 | 
			
		||||
import {showTemporaryTooltip, createTippy} from '../modules/tippy.js';
 | 
			
		||||
import {hideElem, showElem, toggleElem} from '../utils/dom.js';
 | 
			
		||||
import {setFileFolding} from './file-fold.js';
 | 
			
		||||
import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
 | 
			
		||||
 | 
			
		||||
const {appSubUrl, csrfToken} = window.config;
 | 
			
		||||
 | 
			
		||||
@@ -223,21 +220,6 @@ export function initRepoIssueCodeCommentCancel() {
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function initRepoIssueStatusButton() {
 | 
			
		||||
  // Change status
 | 
			
		||||
  const $statusButton = $('#status-button');
 | 
			
		||||
  $('#comment-form textarea').on('keyup', function () {
 | 
			
		||||
    const easyMDE = getAttachedEasyMDE(this);
 | 
			
		||||
    const value = easyMDE?.value() || $(this).val();
 | 
			
		||||
    $statusButton.text($statusButton.data(value.length === 0 ? 'status' : 'status-and-comment'));
 | 
			
		||||
  });
 | 
			
		||||
  $statusButton.on('click', (e) => {
 | 
			
		||||
    e.preventDefault();
 | 
			
		||||
    $('#status').val($statusButton.data('status-val'));
 | 
			
		||||
    $('#comment-form').trigger('submit');
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function initRepoPullRequestUpdate() {
 | 
			
		||||
  // Pull Request update button
 | 
			
		||||
  const $pullUpdateButton = $('.update-button > button');
 | 
			
		||||
@@ -402,35 +384,18 @@ export function initRepoIssueComments() {
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
function assignMenuAttributes(menu) {
 | 
			
		||||
  const id = Math.floor(Math.random() * Math.floor(1000000));
 | 
			
		||||
  menu.attr('data-write', menu.attr('data-write') + id);
 | 
			
		||||
  menu.attr('data-preview', menu.attr('data-preview') + id);
 | 
			
		||||
  menu.find('.item').each(function () {
 | 
			
		||||
    const tab = $(this).attr('data-tab') + id;
 | 
			
		||||
    $(this).attr('data-tab', tab);
 | 
			
		||||
  });
 | 
			
		||||
  menu.parent().find("*[data-tab='write']").attr('data-tab', `write${id}`);
 | 
			
		||||
  menu.parent().find("*[data-tab='preview']").attr('data-tab', `preview${id}`);
 | 
			
		||||
  initCompMarkupContentPreviewTab(menu.parent('.form'));
 | 
			
		||||
  return id;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function handleReply($el) {
 | 
			
		||||
  hideElem($el);
 | 
			
		||||
  const form = $el.closest('.comment-code-cloud').find('.comment-form');
 | 
			
		||||
  form.removeClass('gt-hidden');
 | 
			
		||||
 | 
			
		||||
  const $textarea = form.find('textarea');
 | 
			
		||||
  let easyMDE = getAttachedEasyMDE($textarea);
 | 
			
		||||
  if (!easyMDE) {
 | 
			
		||||
    await attachTribute($textarea.get(), {mentions: true, emoji: true});
 | 
			
		||||
    easyMDE = await createCommentEasyMDE($textarea);
 | 
			
		||||
  let editor = getComboMarkdownEditor($textarea);
 | 
			
		||||
  if (!editor) {
 | 
			
		||||
    editor = await initComboMarkdownEditor(form.find('.combo-markdown-editor'));
 | 
			
		||||
  }
 | 
			
		||||
  $textarea.focus();
 | 
			
		||||
  easyMDE.codemirror.focus();
 | 
			
		||||
  assignMenuAttributes(form.find('.menu'));
 | 
			
		||||
  return easyMDE;
 | 
			
		||||
  editor.focus();
 | 
			
		||||
  return editor;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function initRepoPullRequestReview() {
 | 
			
		||||
@@ -494,14 +459,7 @@ export function initRepoPullRequestReview() {
 | 
			
		||||
 | 
			
		||||
  const $reviewBox = $('.review-box-panel');
 | 
			
		||||
  if ($reviewBox.length === 1) {
 | 
			
		||||
    (async () => {
 | 
			
		||||
      // the editor's height is too large in some cases, and the panel cannot be scrolled with page now because there is `.repository .diff-detail-box.sticky { position: sticky; }`
 | 
			
		||||
      // the temporary solution is to make the editor's height smaller (about 4 lines). GitHub also only show 4 lines for default. We can improve the UI (including Dropzone area) in future
 | 
			
		||||
      // EasyMDE's options can not handle minHeight & maxHeight together correctly, we have to set max-height for .CodeMirror-scroll in CSS.
 | 
			
		||||
      const $reviewTextarea = $reviewBox.find('textarea');
 | 
			
		||||
      const easyMDE = await createCommentEasyMDE($reviewTextarea, {minHeight: '80px'});
 | 
			
		||||
      initEasyMDEImagePaste(easyMDE, $reviewBox.find('.dropzone'));
 | 
			
		||||
    })();
 | 
			
		||||
    const _promise = initComboMarkdownEditor($reviewBox.find('.combo-markdown-editor'));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // The following part is only for diff views
 | 
			
		||||
@@ -565,20 +523,16 @@ export function initRepoPullRequestReview() {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const td = ntr.find(`.add-comment-${side}`);
 | 
			
		||||
    let commentCloud = td.find('.comment-code-cloud');
 | 
			
		||||
    const commentCloud = td.find('.comment-code-cloud');
 | 
			
		||||
    if (commentCloud.length === 0 && !ntr.find('button[name="pending_review"]').length) {
 | 
			
		||||
      const data = await $.get($(this).closest('[data-new-comment-url]').data('new-comment-url'));
 | 
			
		||||
      td.html(data);
 | 
			
		||||
      commentCloud = td.find('.comment-code-cloud');
 | 
			
		||||
      assignMenuAttributes(commentCloud.find('.menu'));
 | 
			
		||||
      const html = await $.get($(this).closest('[data-new-comment-url]').attr('data-new-comment-url'));
 | 
			
		||||
      td.html(html);
 | 
			
		||||
      td.find("input[name='line']").val(idx);
 | 
			
		||||
      td.find("input[name='side']").val(side === 'left' ? 'previous' : 'proposed');
 | 
			
		||||
      td.find("input[name='path']").val(path);
 | 
			
		||||
      const $textarea = commentCloud.find('textarea');
 | 
			
		||||
      await attachTribute($textarea.get(), {mentions: true, emoji: true});
 | 
			
		||||
      const easyMDE = await createCommentEasyMDE($textarea);
 | 
			
		||||
      $textarea.focus();
 | 
			
		||||
      easyMDE.codemirror.focus();
 | 
			
		||||
 | 
			
		||||
      const editor = await initComboMarkdownEditor(td.find('.combo-markdown-editor'));
 | 
			
		||||
      editor.focus();
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,8 @@
 | 
			
		||||
import $ from 'jquery';
 | 
			
		||||
import {createCommentEasyMDE, getAttachedEasyMDE} from './comp/EasyMDE.js';
 | 
			
		||||
import {initCompMarkupContentPreviewTab} from './comp/MarkupContentPreview.js';
 | 
			
		||||
import {initEasyMDEImagePaste} from './comp/ImagePaste.js';
 | 
			
		||||
import {
 | 
			
		||||
  initRepoIssueBranchSelect, initRepoIssueCodeCommentCancel, initRepoIssueCommentDelete,
 | 
			
		||||
  initRepoIssueComments, initRepoIssueDependencyDelete, initRepoIssueReferenceIssue,
 | 
			
		||||
  initRepoIssueStatusButton, initRepoIssueTitleEdit, initRepoIssueWipToggle,
 | 
			
		||||
  initRepoIssueTitleEdit, initRepoIssueWipToggle,
 | 
			
		||||
  initRepoPullRequestUpdate, updateIssuesMeta, handleReply
 | 
			
		||||
} from './repo-issue.js';
 | 
			
		||||
import {initUnicodeEscapeButton} from './repo-unicode-escape.js';
 | 
			
		||||
@@ -19,27 +16,27 @@ import {
 | 
			
		||||
import {initCitationFileCopyContent} from './citation.js';
 | 
			
		||||
import {initCompLabelEdit} from './comp/LabelEdit.js';
 | 
			
		||||
import {initRepoDiffConversationNav} from './repo-diff.js';
 | 
			
		||||
import {attachTribute} from './tribute.js';
 | 
			
		||||
import {createDropzone} from './dropzone.js';
 | 
			
		||||
import {initCommentContent, initMarkupContent} from '../markup/content.js';
 | 
			
		||||
import {initCompReactionSelector} from './comp/ReactionSelector.js';
 | 
			
		||||
import {initRepoSettingBranches} from './repo-settings.js';
 | 
			
		||||
import {initRepoPullRequestMergeForm} from './repo-issue-pr-form.js';
 | 
			
		||||
import {hideElem, showElem} from '../utils/dom.js';
 | 
			
		||||
import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
 | 
			
		||||
import {attachRefIssueContextPopup} from './contextpopup.js';
 | 
			
		||||
 | 
			
		||||
const {csrfToken} = window.config;
 | 
			
		||||
 | 
			
		||||
// if there are draft comments (more than 20 chars), confirm before reloading, to avoid losing comments
 | 
			
		||||
// if there are draft comments, confirm before reloading, to avoid losing comments
 | 
			
		||||
function reloadConfirmDraftComment() {
 | 
			
		||||
  const commentTextareas = [
 | 
			
		||||
    document.querySelector('.edit-content-zone:not(.gt-hidden) textarea'),
 | 
			
		||||
    document.querySelector('.edit_area'),
 | 
			
		||||
    document.querySelector('#comment-form textarea'),
 | 
			
		||||
  ];
 | 
			
		||||
  for (const textarea of commentTextareas) {
 | 
			
		||||
    // Most users won't feel too sad if they lose a comment with 10 or 20 chars, they can re-type these in seconds.
 | 
			
		||||
    // Most users won't feel too sad if they lose a comment with 10 chars, they can re-type these in seconds.
 | 
			
		||||
    // But if they have typed more (like 50) chars and the comment is lost, they will be very unhappy.
 | 
			
		||||
    if (textarea && textarea.value.trim().length > 20) {
 | 
			
		||||
    if (textarea && textarea.value.trim().length > 10) {
 | 
			
		||||
      textarea.parentElement.scrollIntoView();
 | 
			
		||||
      if (!window.confirm('Page will be reloaded, but there are draft comments. Continuing to reload will discard the comments. Continue?')) {
 | 
			
		||||
        return;
 | 
			
		||||
@@ -85,25 +82,20 @@ export function initRepoCommentForm() {
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  (async () => {
 | 
			
		||||
    const $statusButton = $('#status-button');
 | 
			
		||||
    for (const textarea of $commentForm.find('textarea:not(.review-textarea, .no-easymde)')) {
 | 
			
		||||
      // Don't initialize EasyMDE for the dormant #edit-content-form
 | 
			
		||||
      if (textarea.closest('#edit-content-form')) {
 | 
			
		||||
        continue;
 | 
			
		||||
      }
 | 
			
		||||
      const easyMDE = await createCommentEasyMDE(textarea, {
 | 
			
		||||
        'onChange': () => {
 | 
			
		||||
          const value = easyMDE?.value().trim();
 | 
			
		||||
          $statusButton.text($statusButton.attr(value.length === 0 ? 'data-status' : 'data-status-and-comment'));
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
      initEasyMDEImagePaste(easyMDE, $commentForm.find('.dropzone'));
 | 
			
		||||
    }
 | 
			
		||||
  })();
 | 
			
		||||
  const $statusButton = $('#status-button');
 | 
			
		||||
  $statusButton.on('click', (e) => {
 | 
			
		||||
    e.preventDefault();
 | 
			
		||||
    $('#status').val($statusButton.data('status-val'));
 | 
			
		||||
    $('#comment-form').trigger('submit');
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const _promise = initComboMarkdownEditor($commentForm.find('.combo-markdown-editor'), {
 | 
			
		||||
    onContentChanged(editor) {
 | 
			
		||||
      $statusButton.text($statusButton.attr(editor.value().trim() ? 'data-status-and-comment' : 'data-status'));
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  initBranchSelector();
 | 
			
		||||
  initCompMarkupContentPreviewTab($commentForm);
 | 
			
		||||
 | 
			
		||||
  // List submits
 | 
			
		||||
  function initListSubmits(selector, outerSelector) {
 | 
			
		||||
@@ -275,7 +267,7 @@ export function initRepoCommentForm() {
 | 
			
		||||
      } else if (input_id === '#project_id') {
 | 
			
		||||
        icon = svg('octicon-project', 18, 'gt-mr-3');
 | 
			
		||||
      } else if (input_id === '#assignee_id') {
 | 
			
		||||
        icon = `<img class="ui avatar image gt-mr-3" src=${$(this).data('avatar')}>`;
 | 
			
		||||
        icon = `<img class="ui avatar image gt-mr-3" alt="avatar" src=${$(this).data('avatar')}>`;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      $list.find('.selected').html(`
 | 
			
		||||
@@ -322,162 +314,148 @@ async function onEditContent(event) {
 | 
			
		||||
  const $editContentZone = $segment.find('.edit-content-zone');
 | 
			
		||||
  const $renderContent = $segment.find('.render-content');
 | 
			
		||||
  const $rawContent = $segment.find('.raw-content');
 | 
			
		||||
  let $textarea;
 | 
			
		||||
  let easyMDE;
 | 
			
		||||
 | 
			
		||||
  // Setup new form
 | 
			
		||||
  if ($editContentZone.html().length === 0) {
 | 
			
		||||
    $editContentZone.html($('#edit-content-form').html());
 | 
			
		||||
    $textarea = $editContentZone.find('textarea');
 | 
			
		||||
    await attachTribute($textarea.get(), {mentions: true, emoji: true});
 | 
			
		||||
  let comboMarkdownEditor;
 | 
			
		||||
 | 
			
		||||
    let dz;
 | 
			
		||||
    const $dropzone = $editContentZone.find('.dropzone');
 | 
			
		||||
    if ($dropzone.length === 1) {
 | 
			
		||||
      $dropzone.data('saved', false);
 | 
			
		||||
  const setupDropzone = async ($dropzone) => {
 | 
			
		||||
    if ($dropzone.length === 0) return null;
 | 
			
		||||
    $dropzone.data('saved', false);
 | 
			
		||||
 | 
			
		||||
      const fileUuidDict = {};
 | 
			
		||||
      dz = await createDropzone($dropzone[0], {
 | 
			
		||||
        url: $dropzone.data('upload-url'),
 | 
			
		||||
        headers: {'X-Csrf-Token': csrfToken},
 | 
			
		||||
        maxFiles: $dropzone.data('max-file'),
 | 
			
		||||
        maxFilesize: $dropzone.data('max-size'),
 | 
			
		||||
        acceptedFiles: (['*/*', ''].includes($dropzone.data('accepts'))) ? null : $dropzone.data('accepts'),
 | 
			
		||||
        addRemoveLinks: true,
 | 
			
		||||
        dictDefaultMessage: $dropzone.data('default-message'),
 | 
			
		||||
        dictInvalidFileType: $dropzone.data('invalid-input-type'),
 | 
			
		||||
        dictFileTooBig: $dropzone.data('file-too-big'),
 | 
			
		||||
        dictRemoveFile: $dropzone.data('remove-file'),
 | 
			
		||||
        timeout: 0,
 | 
			
		||||
        thumbnailMethod: 'contain',
 | 
			
		||||
        thumbnailWidth: 480,
 | 
			
		||||
        thumbnailHeight: 480,
 | 
			
		||||
        init() {
 | 
			
		||||
          this.on('success', (file, data) => {
 | 
			
		||||
            file.uuid = data.uuid;
 | 
			
		||||
            fileUuidDict[file.uuid] = {submitted: false};
 | 
			
		||||
            const input = $(`<input id="${data.uuid}" name="files" type="hidden">`).val(data.uuid);
 | 
			
		||||
            $dropzone.find('.files').append(input);
 | 
			
		||||
    const fileUuidDict = {};
 | 
			
		||||
    const dz = await createDropzone($dropzone[0], {
 | 
			
		||||
      url: $dropzone.data('upload-url'),
 | 
			
		||||
      headers: {'X-Csrf-Token': csrfToken},
 | 
			
		||||
      maxFiles: $dropzone.data('max-file'),
 | 
			
		||||
      maxFilesize: $dropzone.data('max-size'),
 | 
			
		||||
      acceptedFiles: (['*/*', ''].includes($dropzone.data('accepts'))) ? null : $dropzone.data('accepts'),
 | 
			
		||||
      addRemoveLinks: true,
 | 
			
		||||
      dictDefaultMessage: $dropzone.data('default-message'),
 | 
			
		||||
      dictInvalidFileType: $dropzone.data('invalid-input-type'),
 | 
			
		||||
      dictFileTooBig: $dropzone.data('file-too-big'),
 | 
			
		||||
      dictRemoveFile: $dropzone.data('remove-file'),
 | 
			
		||||
      timeout: 0,
 | 
			
		||||
      thumbnailMethod: 'contain',
 | 
			
		||||
      thumbnailWidth: 480,
 | 
			
		||||
      thumbnailHeight: 480,
 | 
			
		||||
      init() {
 | 
			
		||||
        this.on('success', (file, data) => {
 | 
			
		||||
          file.uuid = data.uuid;
 | 
			
		||||
          fileUuidDict[file.uuid] = {submitted: false};
 | 
			
		||||
          const input = $(`<input id="${data.uuid}" name="files" type="hidden">`).val(data.uuid);
 | 
			
		||||
          $dropzone.find('.files').append(input);
 | 
			
		||||
        });
 | 
			
		||||
        this.on('removedfile', (file) => {
 | 
			
		||||
          $(`#${file.uuid}`).remove();
 | 
			
		||||
          if ($dropzone.data('remove-url') && !fileUuidDict[file.uuid].submitted) {
 | 
			
		||||
            $.post($dropzone.data('remove-url'), {
 | 
			
		||||
              file: file.uuid,
 | 
			
		||||
              _csrf: csrfToken,
 | 
			
		||||
            });
 | 
			
		||||
          }
 | 
			
		||||
        });
 | 
			
		||||
        this.on('submit', () => {
 | 
			
		||||
          $.each(fileUuidDict, (fileUuid) => {
 | 
			
		||||
            fileUuidDict[fileUuid].submitted = true;
 | 
			
		||||
          });
 | 
			
		||||
          this.on('removedfile', (file) => {
 | 
			
		||||
            $(`#${file.uuid}`).remove();
 | 
			
		||||
            if ($dropzone.data('remove-url') && !fileUuidDict[file.uuid].submitted) {
 | 
			
		||||
              $.post($dropzone.data('remove-url'), {
 | 
			
		||||
                file: file.uuid,
 | 
			
		||||
                _csrf: csrfToken,
 | 
			
		||||
              });
 | 
			
		||||
            }
 | 
			
		||||
          });
 | 
			
		||||
          this.on('submit', () => {
 | 
			
		||||
            $.each(fileUuidDict, (fileUuid) => {
 | 
			
		||||
              fileUuidDict[fileUuid].submitted = true;
 | 
			
		||||
        });
 | 
			
		||||
        this.on('reload', () => {
 | 
			
		||||
          $.getJSON($editContentZone.data('attachment-url'), (data) => {
 | 
			
		||||
            dz.removeAllFiles(true);
 | 
			
		||||
            $dropzone.find('.files').empty();
 | 
			
		||||
            $.each(data, function () {
 | 
			
		||||
              const imgSrc = `${$dropzone.data('link-url')}/${this.uuid}`;
 | 
			
		||||
              dz.emit('addedfile', this);
 | 
			
		||||
              dz.emit('thumbnail', this, imgSrc);
 | 
			
		||||
              dz.emit('complete', this);
 | 
			
		||||
              dz.files.push(this);
 | 
			
		||||
              fileUuidDict[this.uuid] = {submitted: true};
 | 
			
		||||
              $dropzone.find(`img[src='${imgSrc}']`).css('max-width', '100%');
 | 
			
		||||
              const input = $(`<input id="${this.uuid}" name="files" type="hidden">`).val(this.uuid);
 | 
			
		||||
              $dropzone.find('.files').append(input);
 | 
			
		||||
            });
 | 
			
		||||
          });
 | 
			
		||||
          this.on('reload', () => {
 | 
			
		||||
            $.getJSON($editContentZone.data('attachment-url'), (data) => {
 | 
			
		||||
              dz.removeAllFiles(true);
 | 
			
		||||
              $dropzone.find('.files').empty();
 | 
			
		||||
              $.each(data, function () {
 | 
			
		||||
                const imgSrc = `${$dropzone.data('link-url')}/${this.uuid}`;
 | 
			
		||||
                dz.emit('addedfile', this);
 | 
			
		||||
                dz.emit('thumbnail', this, imgSrc);
 | 
			
		||||
                dz.emit('complete', this);
 | 
			
		||||
                dz.files.push(this);
 | 
			
		||||
                fileUuidDict[this.uuid] = {submitted: true};
 | 
			
		||||
                $dropzone.find(`img[src='${imgSrc}']`).css('max-width', '100%');
 | 
			
		||||
                const input = $(`<input id="${this.uuid}" name="files" type="hidden">`).val(this.uuid);
 | 
			
		||||
                $dropzone.find('.files').append(input);
 | 
			
		||||
              });
 | 
			
		||||
            });
 | 
			
		||||
          });
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
        });
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
    dz.emit('reload');
 | 
			
		||||
    return dz;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const cancelAndReset = (dz) => {
 | 
			
		||||
    showElem($renderContent);
 | 
			
		||||
    hideElem($editContentZone);
 | 
			
		||||
    if (dz) {
 | 
			
		||||
      dz.emit('reload');
 | 
			
		||||
    }
 | 
			
		||||
    // Give new write/preview data-tab name to distinguish from others
 | 
			
		||||
    const $editContentForm = $editContentZone.find('.ui.comment.form');
 | 
			
		||||
    const $tabMenu = $editContentForm.find('.tabular.menu');
 | 
			
		||||
    $tabMenu.attr('data-write', $editContentZone.data('write'));
 | 
			
		||||
    $tabMenu.attr('data-preview', $editContentZone.data('preview'));
 | 
			
		||||
    $tabMenu.find('.write.item').attr('data-tab', $editContentZone.data('write'));
 | 
			
		||||
    $tabMenu.find('.preview.item').attr('data-tab', $editContentZone.data('preview'));
 | 
			
		||||
    $editContentForm.find('.write').attr('data-tab', $editContentZone.data('write'));
 | 
			
		||||
    $editContentForm.find('.preview').attr('data-tab', $editContentZone.data('preview'));
 | 
			
		||||
    easyMDE = await createCommentEasyMDE($textarea);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
    initCompMarkupContentPreviewTab($editContentForm);
 | 
			
		||||
    initEasyMDEImagePaste(easyMDE, $dropzone);
 | 
			
		||||
  const saveAndRefresh = (dz, $dropzone) => {
 | 
			
		||||
    showElem($renderContent);
 | 
			
		||||
    hideElem($editContentZone);
 | 
			
		||||
    const $attachments = $dropzone.find('.files').find('[name=files]').map(function () {
 | 
			
		||||
      return $(this).val();
 | 
			
		||||
    }).get();
 | 
			
		||||
    $.post($editContentZone.data('update-url'), {
 | 
			
		||||
      _csrf: csrfToken,
 | 
			
		||||
      content: comboMarkdownEditor.value(),
 | 
			
		||||
      context: $editContentZone.data('context'),
 | 
			
		||||
      files: $attachments,
 | 
			
		||||
    }, (data) => {
 | 
			
		||||
      if (!data.content) {
 | 
			
		||||
        $renderContent.html($('#no-content').html());
 | 
			
		||||
        $rawContent.text('');
 | 
			
		||||
      } else {
 | 
			
		||||
        $renderContent.html(data.content);
 | 
			
		||||
        $rawContent.text(comboMarkdownEditor.value());
 | 
			
		||||
 | 
			
		||||
    const $saveButton = $editContentZone.find('.save.button');
 | 
			
		||||
    $textarea.on('ce-quick-submit', () => {
 | 
			
		||||
      $saveButton.trigger('click');
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    $editContentZone.find('.cancel.button').on('click', (e) => {
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      showElem($renderContent);
 | 
			
		||||
      hideElem($editContentZone);
 | 
			
		||||
      if (dz) {
 | 
			
		||||
        dz.emit('reload');
 | 
			
		||||
        const refIssues = $renderContent.find('p .ref-issue');
 | 
			
		||||
        attachRefIssueContextPopup(refIssues);
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    $saveButton.on('click', () => {
 | 
			
		||||
      showElem($renderContent);
 | 
			
		||||
      hideElem($editContentZone);
 | 
			
		||||
      const $attachments = $dropzone.find('.files').find('[name=files]').map(function () {
 | 
			
		||||
        return $(this).val();
 | 
			
		||||
      }).get();
 | 
			
		||||
      $.post($editContentZone.data('update-url'), {
 | 
			
		||||
        _csrf: csrfToken,
 | 
			
		||||
        content: $textarea.val(),
 | 
			
		||||
        context: $editContentZone.data('context'),
 | 
			
		||||
        files: $attachments,
 | 
			
		||||
      }, (data) => {
 | 
			
		||||
        if (data.length === 0 || data.content.length === 0) {
 | 
			
		||||
          $renderContent.html($('#no-content').html());
 | 
			
		||||
          $rawContent.text('');
 | 
			
		||||
        } else {
 | 
			
		||||
          $renderContent.html(data.content);
 | 
			
		||||
          $rawContent.text($textarea.val());
 | 
			
		||||
          const refIssues = $renderContent.find('p .ref-issue');
 | 
			
		||||
          attachRefIssueContextPopup(refIssues);
 | 
			
		||||
        }
 | 
			
		||||
        const $content = $segment;
 | 
			
		||||
        if (!$content.find('.dropzone-attachments').length) {
 | 
			
		||||
          if (data.attachments !== '') {
 | 
			
		||||
            $content.append(`<div class="dropzone-attachments"></div>`);
 | 
			
		||||
            $content.find('.dropzone-attachments').replaceWith(data.attachments);
 | 
			
		||||
          }
 | 
			
		||||
        } else if (data.attachments === '') {
 | 
			
		||||
          $content.find('.dropzone-attachments').remove();
 | 
			
		||||
        } else {
 | 
			
		||||
      const $content = $segment;
 | 
			
		||||
      if (!$content.find('.dropzone-attachments').length) {
 | 
			
		||||
        if (data.attachments !== '') {
 | 
			
		||||
          $content.append(`<div class="dropzone-attachments"></div>`);
 | 
			
		||||
          $content.find('.dropzone-attachments').replaceWith(data.attachments);
 | 
			
		||||
        }
 | 
			
		||||
        if (dz) {
 | 
			
		||||
          dz.emit('submit');
 | 
			
		||||
          dz.emit('reload');
 | 
			
		||||
        }
 | 
			
		||||
        initMarkupContent();
 | 
			
		||||
        initCommentContent();
 | 
			
		||||
      });
 | 
			
		||||
      } else if (data.attachments === '') {
 | 
			
		||||
        $content.find('.dropzone-attachments').remove();
 | 
			
		||||
      } else {
 | 
			
		||||
        $content.find('.dropzone-attachments').replaceWith(data.attachments);
 | 
			
		||||
      }
 | 
			
		||||
      if (dz) {
 | 
			
		||||
        dz.emit('submit');
 | 
			
		||||
        dz.emit('reload');
 | 
			
		||||
      }
 | 
			
		||||
      initMarkupContent();
 | 
			
		||||
      initCommentContent();
 | 
			
		||||
    });
 | 
			
		||||
  } else { // use existing form
 | 
			
		||||
    $textarea = $segment.find('textarea');
 | 
			
		||||
    easyMDE = getAttachedEasyMDE($textarea);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  if (!$editContentZone.html()) {
 | 
			
		||||
    $editContentZone.html($('#issue-comment-editor-template').html());
 | 
			
		||||
    comboMarkdownEditor = await initComboMarkdownEditor($editContentZone.find('.combo-markdown-editor'));
 | 
			
		||||
 | 
			
		||||
    const $dropzone = $editContentZone.find('.dropzone');
 | 
			
		||||
    const dz = await setupDropzone($dropzone);
 | 
			
		||||
    $editContentZone.find('.cancel.button').on('click', (e) => {
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      cancelAndReset(dz);
 | 
			
		||||
    });
 | 
			
		||||
    $editContentZone.find('.save.button').on('click', (e) => {
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      saveAndRefresh(dz, $dropzone);
 | 
			
		||||
    });
 | 
			
		||||
  } else {
 | 
			
		||||
    comboMarkdownEditor = getComboMarkdownEditor($editContentZone.find('.combo-markdown-editor'));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Show write/preview tab and copy raw content as needed
 | 
			
		||||
  showElem($editContentZone);
 | 
			
		||||
  hideElem($renderContent);
 | 
			
		||||
  if ($textarea.val().length === 0) {
 | 
			
		||||
    $textarea.val($rawContent.text());
 | 
			
		||||
    easyMDE.value($rawContent.text());
 | 
			
		||||
  if (!comboMarkdownEditor.value()) {
 | 
			
		||||
    comboMarkdownEditor.value($rawContent.text());
 | 
			
		||||
  }
 | 
			
		||||
  requestAnimationFrame(() => {
 | 
			
		||||
    $textarea.focus();
 | 
			
		||||
    easyMDE.codemirror.focus();
 | 
			
		||||
  });
 | 
			
		||||
  comboMarkdownEditor.focus();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function initRepository() {
 | 
			
		||||
@@ -575,7 +553,6 @@ export function initRepository() {
 | 
			
		||||
    initRepoIssueCommentDelete();
 | 
			
		||||
    initRepoIssueDependencyDelete();
 | 
			
		||||
    initRepoIssueCodeCommentCancel();
 | 
			
		||||
    initRepoIssueStatusButton();
 | 
			
		||||
    initRepoPullRequestUpdate();
 | 
			
		||||
    initCompReactionSelector();
 | 
			
		||||
 | 
			
		||||
@@ -592,12 +569,6 @@ export function initRepository() {
 | 
			
		||||
 | 
			
		||||
      const $form = $repoComparePull.find('.pullrequest-form');
 | 
			
		||||
      showElem($form);
 | 
			
		||||
      $form.find('textarea.edit_area').each(function() {
 | 
			
		||||
        const easyMDE = getAttachedEasyMDE($(this));
 | 
			
		||||
        if (easyMDE) {
 | 
			
		||||
          easyMDE.codemirror.refresh();
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -614,24 +585,22 @@ function initRepoIssueCommentEdit() {
 | 
			
		||||
    const target = $(this).data('target');
 | 
			
		||||
    const quote = $(`#${target}`).text().replace(/\n/g, '\n> ');
 | 
			
		||||
    const content = `> ${quote}\n\n`;
 | 
			
		||||
    let easyMDE;
 | 
			
		||||
    let editor;
 | 
			
		||||
    if ($(this).hasClass('quote-reply-diff')) {
 | 
			
		||||
      const $replyBtn = $(this).closest('.comment-code-cloud').find('button.comment-form-reply');
 | 
			
		||||
      easyMDE = await handleReply($replyBtn);
 | 
			
		||||
      editor = await handleReply($replyBtn);
 | 
			
		||||
    } else {
 | 
			
		||||
      // for normal issue/comment page
 | 
			
		||||
      easyMDE = getAttachedEasyMDE($('#comment-form .edit_area'));
 | 
			
		||||
      editor = getComboMarkdownEditor($('#comment-form .combo-markdown-editor'));
 | 
			
		||||
    }
 | 
			
		||||
    if (easyMDE) {
 | 
			
		||||
      if (easyMDE.value() !== '') {
 | 
			
		||||
        easyMDE.value(`${easyMDE.value()}\n\n${content}`);
 | 
			
		||||
    if (editor) {
 | 
			
		||||
      if (editor.value()) {
 | 
			
		||||
        editor.value(`${editor.value()}\n\n${content}`);
 | 
			
		||||
      } else {
 | 
			
		||||
        easyMDE.value(`${content}`);
 | 
			
		||||
        editor.value(content);
 | 
			
		||||
      }
 | 
			
		||||
      requestAnimationFrame(() => {
 | 
			
		||||
        easyMDE.codemirror.focus();
 | 
			
		||||
        easyMDE.codemirror.setCursor(easyMDE.codemirror.lineCount(), 0);
 | 
			
		||||
      });
 | 
			
		||||
      editor.focus();
 | 
			
		||||
      editor.moveCursorToEnd();
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,6 @@
 | 
			
		||||
import $ from 'jquery';
 | 
			
		||||
import {attachTribute} from './tribute.js';
 | 
			
		||||
import {initCompMarkupContentPreviewTab} from './comp/MarkupContentPreview.js';
 | 
			
		||||
import {initEasyMDEImagePaste} from './comp/ImagePaste.js';
 | 
			
		||||
import {createCommentEasyMDE} from './comp/EasyMDE.js';
 | 
			
		||||
import {hideElem, showElem} from '../utils/dom.js';
 | 
			
		||||
import {initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
 | 
			
		||||
 | 
			
		||||
export function initRepoRelease() {
 | 
			
		||||
  $(document).on('click', '.remove-rel-attach', function() {
 | 
			
		||||
@@ -51,17 +48,9 @@ function initTagNameEditor() {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function initRepoReleaseEditor() {
 | 
			
		||||
  const $editor = $('.repository.new.release .content-editor');
 | 
			
		||||
  const $editor = $('.repository.new.release .combo-markdown-editor');
 | 
			
		||||
  if ($editor.length === 0) {
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  (async () => {
 | 
			
		||||
    const $textarea = $editor.find('textarea');
 | 
			
		||||
    await attachTribute($textarea.get(), {mentions: true, emoji: true});
 | 
			
		||||
    const easyMDE = await createCommentEasyMDE($textarea);
 | 
			
		||||
    initCompMarkupContentPreviewTab($editor);
 | 
			
		||||
    const $dropzone = $editor.parent().find('.dropzone');
 | 
			
		||||
    initEasyMDEImagePaste(easyMDE, $dropzone);
 | 
			
		||||
  })();
 | 
			
		||||
  const _promise = initComboMarkdownEditor($editor);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,194 +1,68 @@
 | 
			
		||||
import $ from 'jquery';
 | 
			
		||||
import {initMarkupContent} from '../markup/content.js';
 | 
			
		||||
import {attachEasyMDEToElements, codeMirrorQuickSubmit, importEasyMDE, validateTextareaNonEmpty} from './comp/EasyMDE.js';
 | 
			
		||||
import {initCompMarkupContentPreviewTab} from './comp/MarkupContentPreview.js';
 | 
			
		||||
import {validateTextareaNonEmpty, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
 | 
			
		||||
 | 
			
		||||
const {csrfToken} = window.config;
 | 
			
		||||
 | 
			
		||||
async function initRepoWikiFormEditor() {
 | 
			
		||||
  const $editArea = $('.repository.wiki textarea#edit_area');
 | 
			
		||||
  const $editArea = $('.repository.wiki .combo-markdown-editor textarea');
 | 
			
		||||
  if (!$editArea.length) return;
 | 
			
		||||
 | 
			
		||||
  let sideBySideChanges = 0;
 | 
			
		||||
  let sideBySideTimeout = null;
 | 
			
		||||
  let hasEasyMDE = true;
 | 
			
		||||
 | 
			
		||||
  const $form = $('.repository.wiki.new .ui.form');
 | 
			
		||||
  const EasyMDE = await importEasyMDE();
 | 
			
		||||
  const easyMDE = new EasyMDE({
 | 
			
		||||
    autoDownloadFontAwesome: false,
 | 
			
		||||
    element: $editArea[0],
 | 
			
		||||
    forceSync: true,
 | 
			
		||||
    previewRender(plainText, preview) { // Async method
 | 
			
		||||
      // FIXME: still send render request when return back to edit mode
 | 
			
		||||
      const render = function () {
 | 
			
		||||
        sideBySideChanges = 0;
 | 
			
		||||
        if (sideBySideTimeout !== null) {
 | 
			
		||||
          clearTimeout(sideBySideTimeout);
 | 
			
		||||
          sideBySideTimeout = null;
 | 
			
		||||
        }
 | 
			
		||||
        $.post($editArea.data('url'), {
 | 
			
		||||
          _csrf: csrfToken,
 | 
			
		||||
          mode: 'gfm',
 | 
			
		||||
          context: $editArea.data('context'),
 | 
			
		||||
          text: plainText,
 | 
			
		||||
          wiki: true
 | 
			
		||||
        }, (data) => {
 | 
			
		||||
          preview.innerHTML = `<div class="markup ui segment">${data}</div>`;
 | 
			
		||||
          initMarkupContent();
 | 
			
		||||
        });
 | 
			
		||||
      };
 | 
			
		||||
  const $editorContainer = $form.find('.combo-markdown-editor');
 | 
			
		||||
  let editor;
 | 
			
		||||
 | 
			
		||||
      setTimeout(() => {
 | 
			
		||||
        if (!easyMDE.isSideBySideActive()) {
 | 
			
		||||
          render();
 | 
			
		||||
        } else {
 | 
			
		||||
          // delay preview by keystroke counting
 | 
			
		||||
          sideBySideChanges++;
 | 
			
		||||
          if (sideBySideChanges > 10) {
 | 
			
		||||
            render();
 | 
			
		||||
          }
 | 
			
		||||
          // or delay preview by timeout
 | 
			
		||||
          if (sideBySideTimeout !== null) {
 | 
			
		||||
            clearTimeout(sideBySideTimeout);
 | 
			
		||||
            sideBySideTimeout = null;
 | 
			
		||||
          }
 | 
			
		||||
          sideBySideTimeout = setTimeout(render, 600);
 | 
			
		||||
        }
 | 
			
		||||
      }, 0);
 | 
			
		||||
      if (!easyMDE.isSideBySideActive()) {
 | 
			
		||||
        return 'Loading...';
 | 
			
		||||
      }
 | 
			
		||||
      return preview.innerHTML;
 | 
			
		||||
  let renderRequesting = false;
 | 
			
		||||
  let lastContent;
 | 
			
		||||
  const renderEasyMDEPreview = function () {
 | 
			
		||||
    if (renderRequesting) return;
 | 
			
		||||
 | 
			
		||||
    const $previewFull = $editorContainer.find('.EasyMDEContainer .editor-preview-active');
 | 
			
		||||
    const $previewSide = $editorContainer.find('.EasyMDEContainer .editor-preview-active-side');
 | 
			
		||||
    const $previewTarget = $previewSide.length ? $previewSide : $previewFull;
 | 
			
		||||
    const newContent = $editArea.val();
 | 
			
		||||
    if (editor && $previewTarget.length && lastContent !== newContent) {
 | 
			
		||||
      renderRequesting = true;
 | 
			
		||||
      $.post(editor.previewUrl, {
 | 
			
		||||
        _csrf: csrfToken,
 | 
			
		||||
        mode: editor.previewMode,
 | 
			
		||||
        context: editor.previewContext,
 | 
			
		||||
        text: newContent,
 | 
			
		||||
        wiki: editor.previewWiki,
 | 
			
		||||
      }).done((data) => {
 | 
			
		||||
        lastContent = newContent;
 | 
			
		||||
        $previewTarget.html(`<div class="markup ui segment">${data}</div>`);
 | 
			
		||||
        initMarkupContent();
 | 
			
		||||
      }).always(() => {
 | 
			
		||||
        renderRequesting = false;
 | 
			
		||||
        setTimeout(renderEasyMDEPreview, 1000);
 | 
			
		||||
      });
 | 
			
		||||
    } else {
 | 
			
		||||
      setTimeout(renderEasyMDEPreview, 1000);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
  renderEasyMDEPreview();
 | 
			
		||||
 | 
			
		||||
  editor = await initComboMarkdownEditor($editorContainer, {
 | 
			
		||||
    previewMode: 'gfm',
 | 
			
		||||
    previewWiki: true,
 | 
			
		||||
    easyMDEOptions: {
 | 
			
		||||
      previewRender: (_content, previewTarget) => previewTarget.innerHTML, // disable builtin preview render
 | 
			
		||||
      toolbar: ['bold', 'italic', 'strikethrough', '|',
 | 
			
		||||
        'heading-1', 'heading-2', 'heading-3', 'heading-bigger', 'heading-smaller', '|',
 | 
			
		||||
        'gitea-code-inline', 'code', 'quote', '|', 'gitea-checkbox-empty', 'gitea-checkbox-checked', '|',
 | 
			
		||||
        'unordered-list', 'ordered-list', '|',
 | 
			
		||||
        'link', 'image', 'table', 'horizontal-rule', '|',
 | 
			
		||||
        'clean-block', 'preview', 'fullscreen', 'side-by-side', '|', 'gitea-switch-to-textarea'
 | 
			
		||||
      ],
 | 
			
		||||
    },
 | 
			
		||||
    renderingConfig: {
 | 
			
		||||
      singleLineBreaks: false
 | 
			
		||||
    },
 | 
			
		||||
    indentWithTabs: false,
 | 
			
		||||
    tabSize: 4,
 | 
			
		||||
    spellChecker: false,
 | 
			
		||||
    inputStyle: 'contenteditable', // nativeSpellcheck requires contenteditable
 | 
			
		||||
    nativeSpellcheck: true,
 | 
			
		||||
    toolbar: ['bold', 'italic', 'strikethrough', '|',
 | 
			
		||||
      'heading-1', 'heading-2', 'heading-3', 'heading-bigger', 'heading-smaller', '|',
 | 
			
		||||
      {
 | 
			
		||||
        name: 'code-inline',
 | 
			
		||||
        action(e) {
 | 
			
		||||
          const cm = e.codemirror;
 | 
			
		||||
          const selection = cm.getSelection();
 | 
			
		||||
          cm.replaceSelection(`\`${selection}\``);
 | 
			
		||||
          if (!selection) {
 | 
			
		||||
            const cursorPos = cm.getCursor();
 | 
			
		||||
            cm.setCursor(cursorPos.line, cursorPos.ch - 1);
 | 
			
		||||
          }
 | 
			
		||||
          cm.focus();
 | 
			
		||||
        },
 | 
			
		||||
        className: 'fa fa-angle-right',
 | 
			
		||||
        title: 'Add Inline Code',
 | 
			
		||||
      }, 'code', 'quote', '|', {
 | 
			
		||||
        name: 'checkbox-empty',
 | 
			
		||||
        action(e) {
 | 
			
		||||
          const cm = e.codemirror;
 | 
			
		||||
          cm.replaceSelection(`\n- [ ] ${cm.getSelection()}`);
 | 
			
		||||
          cm.focus();
 | 
			
		||||
        },
 | 
			
		||||
        className: 'fa fa-square-o',
 | 
			
		||||
        title: 'Add Checkbox (empty)',
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        name: 'checkbox-checked',
 | 
			
		||||
        action(e) {
 | 
			
		||||
          const cm = e.codemirror;
 | 
			
		||||
          cm.replaceSelection(`\n- [x] ${cm.getSelection()}`);
 | 
			
		||||
          cm.focus();
 | 
			
		||||
        },
 | 
			
		||||
        className: 'fa fa-check-square-o',
 | 
			
		||||
        title: 'Add Checkbox (checked)',
 | 
			
		||||
      }, '|',
 | 
			
		||||
      'unordered-list', 'ordered-list', '|',
 | 
			
		||||
      'link', 'image', 'table', 'horizontal-rule', '|',
 | 
			
		||||
      'clean-block', 'preview', 'fullscreen', 'side-by-side', '|',
 | 
			
		||||
      {
 | 
			
		||||
        name: 'revert-to-textarea',
 | 
			
		||||
        action(e) {
 | 
			
		||||
          e.toTextArea();
 | 
			
		||||
          hasEasyMDE = false;
 | 
			
		||||
          const $root = $form.find('.field.content');
 | 
			
		||||
          const loading = $root.data('loading');
 | 
			
		||||
          $root.append(`<div class="ui bottom tab markup" data-tab="preview">${loading}</div>`);
 | 
			
		||||
          initCompMarkupContentPreviewTab($form);
 | 
			
		||||
        },
 | 
			
		||||
        className: 'fa fa-file',
 | 
			
		||||
        title: 'Revert to simple textarea',
 | 
			
		||||
      },
 | 
			
		||||
    ]
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  easyMDE.codemirror.setOption('extraKeys', {
 | 
			
		||||
    'Cmd-Enter': codeMirrorQuickSubmit,
 | 
			
		||||
    'Ctrl-Enter': codeMirrorQuickSubmit,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  attachEasyMDEToElements(easyMDE);
 | 
			
		||||
 | 
			
		||||
  $form.on('submit', () => {
 | 
			
		||||
    if (!validateTextareaNonEmpty($editArea)) {
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  setTimeout(() => {
 | 
			
		||||
    const $bEdit = $('.repository.wiki.new .previewtabs a[data-tab="write"]');
 | 
			
		||||
    const $bPrev = $('.repository.wiki.new .previewtabs a[data-tab="preview"]');
 | 
			
		||||
    const $toolbar = $('.editor-toolbar');
 | 
			
		||||
    const $bPreview = $('.editor-toolbar button.preview');
 | 
			
		||||
    const $bSideBySide = $('.editor-toolbar a.fa-columns');
 | 
			
		||||
    $bEdit.on('click', (e) => {
 | 
			
		||||
      if (!hasEasyMDE) {
 | 
			
		||||
        return false;
 | 
			
		||||
      }
 | 
			
		||||
      e.stopImmediatePropagation();
 | 
			
		||||
      if ($toolbar.hasClass('disabled-for-preview')) {
 | 
			
		||||
        $bPreview.trigger('click');
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return false;
 | 
			
		||||
    });
 | 
			
		||||
    $bPrev.on('click', (e) => {
 | 
			
		||||
      if (!hasEasyMDE) {
 | 
			
		||||
        return false;
 | 
			
		||||
      }
 | 
			
		||||
      e.stopImmediatePropagation();
 | 
			
		||||
      if (!$toolbar.hasClass('disabled-for-preview')) {
 | 
			
		||||
        $bPreview.trigger('click');
 | 
			
		||||
      }
 | 
			
		||||
      return false;
 | 
			
		||||
    });
 | 
			
		||||
    $bPreview.on('click', () => {
 | 
			
		||||
      setTimeout(() => {
 | 
			
		||||
        if ($toolbar.hasClass('disabled-for-preview')) {
 | 
			
		||||
          if ($bEdit.hasClass('active')) {
 | 
			
		||||
            $bEdit.removeClass('active');
 | 
			
		||||
          }
 | 
			
		||||
          if (!$bPrev.hasClass('active')) {
 | 
			
		||||
            $bPrev.addClass('active');
 | 
			
		||||
          }
 | 
			
		||||
        } else {
 | 
			
		||||
          if (!$bEdit.hasClass('active')) {
 | 
			
		||||
            $bEdit.addClass('active');
 | 
			
		||||
          }
 | 
			
		||||
          if ($bPrev.hasClass('active')) {
 | 
			
		||||
            $bPrev.removeClass('active');
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }, 0);
 | 
			
		||||
 | 
			
		||||
      return false;
 | 
			
		||||
    });
 | 
			
		||||
    $bSideBySide.on('click', () => {
 | 
			
		||||
      sideBySideChanges = 10;
 | 
			
		||||
    });
 | 
			
		||||
  }, 0);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function initRepoWikiForm() {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,10 @@
 | 
			
		||||
import {emojiKeys, emojiHTML, emojiString} from './emoji.js';
 | 
			
		||||
import {uniq} from '../utils.js';
 | 
			
		||||
import {htmlEscape} from 'escape-goat';
 | 
			
		||||
 | 
			
		||||
function makeCollections({mentions, emoji}) {
 | 
			
		||||
  const collections = [];
 | 
			
		||||
 | 
			
		||||
  if (mentions) {
 | 
			
		||||
  if (emoji) {
 | 
			
		||||
    collections.push({
 | 
			
		||||
      trigger: ':',
 | 
			
		||||
      requireLeadingSpace: true,
 | 
			
		||||
@@ -30,14 +29,14 @@ function makeCollections({mentions, emoji}) {
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (emoji) {
 | 
			
		||||
  if (mentions) {
 | 
			
		||||
    collections.push({
 | 
			
		||||
      values: window.config.tributeValues,
 | 
			
		||||
      requireLeadingSpace: true,
 | 
			
		||||
      menuItemTemplate: (item) => {
 | 
			
		||||
        return `
 | 
			
		||||
          <div class="tribute-item">
 | 
			
		||||
            <img src="${htmlEscape(item.original.avatar)}"/>
 | 
			
		||||
            <img src="${htmlEscape(item.original.avatar)}" class="gt-mr-3"/>
 | 
			
		||||
            <span class="name">${htmlEscape(item.original.name)}</span>
 | 
			
		||||
            ${item.original.fullname && item.original.fullname !== '' ? `<span class="fullname">${htmlEscape(item.original.fullname)}</span>` : ''}
 | 
			
		||||
          </div>
 | 
			
		||||
@@ -49,30 +48,10 @@ function makeCollections({mentions, emoji}) {
 | 
			
		||||
  return collections;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function attachTribute(elementOrNodeList, {mentions, emoji} = {}) {
 | 
			
		||||
  if (!window.config.requireTribute || !elementOrNodeList) return;
 | 
			
		||||
  const nodes = Array.from('length' in elementOrNodeList ? elementOrNodeList : [elementOrNodeList]);
 | 
			
		||||
  if (!nodes.length) return;
 | 
			
		||||
 | 
			
		||||
  const mentionNodes = nodes.filter((node) => {
 | 
			
		||||
    return mentions || node.id === 'content';
 | 
			
		||||
  });
 | 
			
		||||
  const emojiNodes = nodes.filter((node) => {
 | 
			
		||||
    return emoji || node.id === 'content' || node.classList.contains('emoji-input');
 | 
			
		||||
  });
 | 
			
		||||
  const uniqueNodes = uniq([...mentionNodes, ...emojiNodes]);
 | 
			
		||||
  if (!uniqueNodes.length) return;
 | 
			
		||||
 | 
			
		||||
export async function attachTribute(element, {mentions, emoji} = {}) {
 | 
			
		||||
  const {default: Tribute} = await import(/* webpackChunkName: "tribute" */'tributejs');
 | 
			
		||||
 | 
			
		||||
  const collections = makeCollections({
 | 
			
		||||
    mentions: mentions || mentionNodes.length > 0,
 | 
			
		||||
    emoji: emoji || emojiNodes.length > 0,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const collections = makeCollections({mentions, emoji});
 | 
			
		||||
  const tribute = new Tribute({collection: collections, noMatchTemplate: ''});
 | 
			
		||||
  for (const node of uniqueNodes) {
 | 
			
		||||
    tribute.attach(node);
 | 
			
		||||
  }
 | 
			
		||||
  tribute.attach(element);
 | 
			
		||||
  return tribute;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,6 @@ import './bootstrap.js';
 | 
			
		||||
import {initRepoActivityTopAuthorsChart} from './components/RepoActivityTopAuthors.vue';
 | 
			
		||||
import {initDashboardRepoList} from './components/DashboardRepoList.vue';
 | 
			
		||||
 | 
			
		||||
import {attachTribute} from './features/tribute.js';
 | 
			
		||||
import {initGlobalCopyToClipboardListener} from './features/clipboard.js';
 | 
			
		||||
import {initContextPopups} from './features/contextpopup.js';
 | 
			
		||||
import {initRepoGraphGit} from './features/repo-graph.js';
 | 
			
		||||
@@ -110,8 +109,6 @@ onDomReady(() => {
 | 
			
		||||
  initGlobalFormDirtyLeaveConfirm();
 | 
			
		||||
  initGlobalLinkActions();
 | 
			
		||||
 | 
			
		||||
  attachTribute(document.querySelectorAll('#content, .emoji-input'));
 | 
			
		||||
 | 
			
		||||
  initCommonIssue();
 | 
			
		||||
  initCommonOrganization();
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -30,11 +30,6 @@ export function isDarkTheme() {
 | 
			
		||||
  return style.getPropertyValue('--is-dark-theme').trim().toLowerCase() === 'true';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// removes duplicate elements in an array
 | 
			
		||||
export function uniq(arr) {
 | 
			
		||||
  return Array.from(new Set(arr));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// strip <tags> from a string
 | 
			
		||||
export function stripTags(text) {
 | 
			
		||||
  return text.replace(/<[^>]*>?/gm, '');
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
import {expect, test} from 'vitest';
 | 
			
		||||
import {
 | 
			
		||||
  basename, extname, isObject, uniq, stripTags, joinPaths, parseIssueHref,
 | 
			
		||||
  basename, extname, isObject, stripTags, joinPaths, parseIssueHref,
 | 
			
		||||
  prettyNumber, parseUrl, translateMonth, translateDay, blobToDataURI,
 | 
			
		||||
  toAbsoluteUrl,
 | 
			
		||||
} from './utils.js';
 | 
			
		||||
@@ -62,10 +62,6 @@ test('isObject', () => {
 | 
			
		||||
  expect(isObject([])).toBeFalsy();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
test('uniq', () => {
 | 
			
		||||
  expect(uniq([1, 1, 1, 2])).toEqual([1, 2]);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
test('stripTags', () => {
 | 
			
		||||
  expect(stripTags('<a>test</a>')).toEqual('test');
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user