From 76910f213f2765b0bdf16e0d5ec48ffbd3c9f55e Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Wed, 29 Jun 2022 01:52:58 +0800 Subject: [PATCH] Enable spellcheck for EasyMDE, use contenteditable mode (#19776) Enable spellcheck for EasyMDE, use contenteditable mode. Rewrite and refactor the ImagePaste code. --- web_src/js/features/comp/EasyMDE.js | 2 + web_src/js/features/comp/ImagePaste.js | 135 +++++++++++++++---------- web_src/js/features/repo-issue.js | 7 +- web_src/js/features/repo-legacy.js | 16 +-- web_src/js/features/repo-release.js | 5 +- web_src/js/features/repo-wiki.js | 2 + 6 files changed, 102 insertions(+), 65 deletions(-) diff --git a/web_src/js/features/comp/EasyMDE.js b/web_src/js/features/comp/EasyMDE.js index 61aaf23e89..7c1db9a998 100644 --- a/web_src/js/features/comp/EasyMDE.js +++ b/web_src/js/features/comp/EasyMDE.js @@ -38,6 +38,8 @@ export async function createCommentEasyMDE(textarea, easyMDEOptions = {}) { 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', '|', { diff --git a/web_src/js/features/comp/ImagePaste.js b/web_src/js/features/comp/ImagePaste.js index 79aeffa02b..da41e7611a 100644 --- a/web_src/js/features/comp/ImagePaste.js +++ b/web_src/js/features/comp/ImagePaste.js @@ -1,4 +1,5 @@ import $ from 'jquery'; + const {csrfToken} = window.config; async function uploadFile(file, uploadUrl) { @@ -21,72 +22,104 @@ function clipboardPastedImages(e) { if (!item.type || !item.type.startsWith('image/')) continue; files.push(item.getAsFile()); } - - if (files.length) { - e.preventDefault(); - e.stopPropagation(); - } return files; } +class TextareaEditor { + constructor(editor) { + this.editor = editor; + } -function insertAtCursor(field, value) { - if (field.selectionStart || field.selectionStart === 0) { - const startPos = field.selectionStart; - const endPos = field.selectionEnd; - field.value = field.value.substring(0, startPos) + value + field.value.substring(endPos, field.value.length); - field.selectionStart = startPos + value.length; - field.selectionEnd = startPos + value.length; - } else { - field.value += value; + insertPlaceholder(value) { + const editor = this.editor; + const startPos = editor.selectionStart; + const endPos = editor.selectionEnd; + editor.value = editor.value.substring(0, startPos) + value + editor.value.substring(endPos); + editor.selectionStart = startPos; + editor.selectionEnd = startPos + value.length; + editor.focus(); + } + + replacePlaceholder(oldVal, newVal) { + const editor = this.editor; + const startPos = editor.selectionStart; + const endPos = editor.selectionEnd; + if (editor.value.substring(startPos, endPos) === oldVal) { + editor.value = editor.value.substring(0, startPos) + newVal + editor.value.substring(endPos); + editor.selectionEnd = startPos + newVal.length; + } else { + editor.value = editor.value.replace(oldVal, newVal); + editor.selectionEnd -= oldVal.length; + editor.selectionEnd += newVal.length; + } + editor.selectionStart = editor.selectionEnd; + editor.focus(); } } -function replaceAndKeepCursor(field, oldval, newval) { - if (field.selectionStart || field.selectionStart === 0) { - const startPos = field.selectionStart; - const endPos = field.selectionEnd; - field.value = field.value.replace(oldval, newval); - field.selectionStart = startPos + newval.length - oldval.length; - field.selectionEnd = endPos + newval.length - oldval.length; - } else { - field.value = field.value.replace(oldval, newval); +class CodeMirrorEditor { + constructor(editor) { + this.editor = editor; + } + + insertPlaceholder(value) { + const editor = this.editor; + const startPoint = editor.getCursor('start'); + const endPoint = editor.getCursor('end'); + editor.replaceSelection(value); + endPoint.ch = startPoint.ch + value.length; + editor.setSelection(startPoint, endPoint); + editor.focus(); + } + + replacePlaceholder(oldVal, newVal) { + const editor = this.editor; + const endPoint = editor.getCursor('end'); + if (editor.getSelection() === oldVal) { + editor.replaceSelection(newVal); + } else { + editor.setValue(editor.getValue().replace(oldVal, newVal)); + } + endPoint.ch -= oldVal.length; + endPoint.ch += newVal.length; + editor.setSelection(endPoint, endPoint); + editor.focus(); } } -export function initCompImagePaste($target) { - $target.each(function () { - const dropzone = this.querySelector('.dropzone'); - if (!dropzone) { + +export function initEasyMDEImagePaste(easyMDE, $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; } - const uploadUrl = dropzone.getAttribute('data-upload-url'); - const dropzoneFiles = dropzone.querySelector('.files'); - for (const textarea of this.querySelectorAll('textarea')) { - textarea.addEventListener('paste', async (e) => { - for (const img of clipboardPastedImages(e)) { - const name = img.name.slice(0, img.name.lastIndexOf('.')); - insertAtCursor(textarea, `![${name}]()`); - const data = await uploadFile(img, uploadUrl); - replaceAndKeepCursor(textarea, `![${name}]()`, `![${name}](/attachments/${data.uuid})`); - const input = $(``).val(data.uuid); - dropzoneFiles.appendChild(input[0]); - } - }, false); - } - }); -} + e.preventDefault(); + e.stopPropagation(); -export function initEasyMDEImagePaste(easyMDE, dropzone, files) { - const uploadUrl = dropzone.getAttribute('data-upload-url'); - easyMDE.codemirror.on('paste', async (_, e) => { - for (const img of clipboardPastedImages(e)) { + for (const img of pastedImages) { const name = img.name.slice(0, img.name.lastIndexOf('.')); + + const placeholder = `![${name}](uploading ...)`; + editor.insertPlaceholder(placeholder); const data = await uploadFile(img, uploadUrl); - const pos = easyMDE.codemirror.getCursor(); - easyMDE.codemirror.replaceRange(`![${name}](/attachments/${data.uuid})`, pos); - const input = $(``).val(data.uuid); - files.append(input); + editor.replacePlaceholder(placeholder, `![${name}](/attachments/${data.uuid})`); + + const $input = $(``).attr('id', data.uuid).val(data.uuid); + $files.append($input); } + }; + + 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); }); } diff --git a/web_src/js/features/repo-issue.js b/web_src/js/features/repo-issue.js index bdd616f071..12900c2455 100644 --- a/web_src/js/features/repo-issue.js +++ b/web_src/js/features/repo-issue.js @@ -2,7 +2,7 @@ import $ from 'jquery'; import {htmlEscape} from 'escape-goat'; import attachTribute from './tribute.js'; import {createCommentEasyMDE, getAttachedEasyMDE} from './comp/EasyMDE.js'; -import {initCompImagePaste} from './comp/ImagePaste.js'; +import {initEasyMDEImagePaste} from './comp/ImagePaste.js'; import {initCompMarkupContentPreviewTab} from './comp/MarkupContentPreview.js'; const {appSubUrl, csrfToken} = window.config; @@ -480,8 +480,9 @@ export function initRepoPullRequestReview() { // 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. - await createCommentEasyMDE($reviewBox.find('textarea'), {minHeight: '80px'}); - initCompImagePaste($reviewBox); + const $reviewTextarea = $reviewBox.find('textarea'); + const easyMDE = await createCommentEasyMDE($reviewTextarea, {minHeight: '80px'}); + initEasyMDEImagePaste(easyMDE, $reviewBox.find('.dropzone')); })(); } diff --git a/web_src/js/features/repo-legacy.js b/web_src/js/features/repo-legacy.js index 2bf80d5511..11c97ccfb0 100644 --- a/web_src/js/features/repo-legacy.js +++ b/web_src/js/features/repo-legacy.js @@ -1,7 +1,7 @@ import $ from 'jquery'; import {createCommentEasyMDE, getAttachedEasyMDE} from './comp/EasyMDE.js'; import {initCompMarkupContentPreviewTab} from './comp/MarkupContentPreview.js'; -import {initCompImagePaste, initEasyMDEImagePaste} from './comp/ImagePaste.js'; +import {initEasyMDEImagePaste} from './comp/ImagePaste.js'; import { initRepoIssueBranchSelect, initRepoIssueCodeCommentCancel, initRepoIssueCommentDelete, @@ -33,7 +33,8 @@ import initRepoPullRequestMergeForm from './repo-issue-pr-form.js'; const {csrfToken} = window.config; export function initRepoCommentForm() { - if ($('.comment.form').length === 0) { + const $commentForm = $('.comment.form'); + if ($commentForm.length === 0) { return; } @@ -67,12 +68,13 @@ export function initRepoCommentForm() { } (async () => { - await createCommentEasyMDE($('.comment.form textarea:not(.review-textarea)')); - initCompImagePaste($('.comment.form')); + const $textarea = $commentForm.find('textarea:not(.review-textarea)'); + const easyMDE = await createCommentEasyMDE($textarea); + initEasyMDEImagePaste(easyMDE, $commentForm.find('.dropzone')); })(); initBranchSelector(); - initCompMarkupContentPreviewTab($('.comment.form')); + initCompMarkupContentPreviewTab($commentForm); // List submits function initListSubmits(selector, outerSelector) { @@ -352,9 +354,7 @@ async function onEditContent(event) { easyMDE = await createCommentEasyMDE($textarea); initCompMarkupContentPreviewTab($editContentForm); - if ($dropzone.length === 1) { - initEasyMDEImagePaste(easyMDE, $dropzone[0], $dropzone.find('.files')); - } + initEasyMDEImagePaste(easyMDE, $dropzone); const $saveButton = $editContentZone.find('.save.button'); $textarea.on('ce-quick-submit', () => { diff --git a/web_src/js/features/repo-release.js b/web_src/js/features/repo-release.js index a44b91f35b..b68a7a6cd5 100644 --- a/web_src/js/features/repo-release.js +++ b/web_src/js/features/repo-release.js @@ -23,10 +23,9 @@ export function initRepoReleaseEditor() { (async () => { const $textarea = $editor.find('textarea'); await attachTribute($textarea.get(), {mentions: false, emoji: true}); - const $files = $editor.parent().find('.files'); const easyMDE = await createCommentEasyMDE($textarea); initCompMarkupContentPreviewTab($editor); - const dropzone = $editor.parent().find('.dropzone')[0]; - initEasyMDEImagePaste(easyMDE, dropzone, $files); + const $dropzone = $editor.parent().find('.dropzone'); + initEasyMDEImagePaste(easyMDE, $dropzone); })(); } diff --git a/web_src/js/features/repo-wiki.js b/web_src/js/features/repo-wiki.js index 27f44f4e22..4555b32e5f 100644 --- a/web_src/js/features/repo-wiki.js +++ b/web_src/js/features/repo-wiki.js @@ -67,6 +67,8 @@ async function initRepoWikiFormEditor() { 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', '|', {