mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-11-03 20:36:07 +01:00 
			
		
		
		
	Support pasting URLs over markdown text (#29566)
Support pasting URLs over selection text in the textarea editor. Does not work in EasyMDE and I don't intend to support it. Image paste works as usual in both Textarea and EasyMDE. The new `replaceTextareaSelection` function changes textarea content via [`insertText`](https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand#using_inserttext) command, which preserves history, e.g. `CTRL-Z` works and is also demostrated below. We should later refactor the image paste code to use the same function because it currently destroys history. Overriding the formatting via `Shift` key is supported as well, e.g. `Ctrl+Shift+V` will insert the URL as-is, like on GitHub. 
This commit is contained in:
		@@ -3,7 +3,7 @@ import '@github/text-expander-element';
 | 
				
			|||||||
import $ from 'jquery';
 | 
					import $ from 'jquery';
 | 
				
			||||||
import {attachTribute} from '../tribute.js';
 | 
					import {attachTribute} from '../tribute.js';
 | 
				
			||||||
import {hideElem, showElem, autosize, isElemVisible} from '../../utils/dom.js';
 | 
					import {hideElem, showElem, autosize, isElemVisible} from '../../utils/dom.js';
 | 
				
			||||||
import {initEasyMDEImagePaste, initTextareaImagePaste} from './ImagePaste.js';
 | 
					import {initEasyMDEPaste, initTextareaPaste} from './Paste.js';
 | 
				
			||||||
import {handleGlobalEnterQuickSubmit} from './QuickSubmit.js';
 | 
					import {handleGlobalEnterQuickSubmit} from './QuickSubmit.js';
 | 
				
			||||||
import {renderPreviewPanelContent} from '../repo-editor.js';
 | 
					import {renderPreviewPanelContent} from '../repo-editor.js';
 | 
				
			||||||
import {easyMDEToolbarActions} from './EasyMDEToolbarActions.js';
 | 
					import {easyMDEToolbarActions} from './EasyMDEToolbarActions.js';
 | 
				
			||||||
@@ -84,6 +84,17 @@ class ComboMarkdownEditor {
 | 
				
			|||||||
      if (el.nodeName === 'BUTTON' && !el.getAttribute('type')) el.setAttribute('type', 'button');
 | 
					      if (el.nodeName === 'BUTTON' && !el.getAttribute('type')) el.setAttribute('type', 'button');
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.textarea.addEventListener('keydown', (e) => {
 | 
				
			||||||
 | 
					      if (e.shiftKey) {
 | 
				
			||||||
 | 
					        e.target._shiftDown = true;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    this.textarea.addEventListener('keyup', (e) => {
 | 
				
			||||||
 | 
					      if (!e.shiftKey) {
 | 
				
			||||||
 | 
					        e.target._shiftDown = false;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const monospaceButton = this.container.querySelector('.markdown-switch-monospace');
 | 
					    const monospaceButton = this.container.querySelector('.markdown-switch-monospace');
 | 
				
			||||||
    const monospaceEnabled = localStorage?.getItem('markdown-editor-monospace') === 'true';
 | 
					    const monospaceEnabled = localStorage?.getItem('markdown-editor-monospace') === 'true';
 | 
				
			||||||
    const monospaceText = monospaceButton.getAttribute(monospaceEnabled ? 'data-disable-text' : 'data-enable-text');
 | 
					    const monospaceText = monospaceButton.getAttribute(monospaceEnabled ? 'data-disable-text' : 'data-enable-text');
 | 
				
			||||||
@@ -108,7 +119,7 @@ class ComboMarkdownEditor {
 | 
				
			|||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (this.dropzone) {
 | 
					    if (this.dropzone) {
 | 
				
			||||||
      initTextareaImagePaste(this.textarea, this.dropzone);
 | 
					      initTextareaPaste(this.textarea, this.dropzone);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -241,7 +252,7 @@ class ComboMarkdownEditor {
 | 
				
			|||||||
    });
 | 
					    });
 | 
				
			||||||
    this.applyEditorHeights(this.container.querySelector('.CodeMirror-scroll'), this.options.editorHeights);
 | 
					    this.applyEditorHeights(this.container.querySelector('.CodeMirror-scroll'), this.options.editorHeights);
 | 
				
			||||||
    await attachTribute(this.easyMDE.codemirror.getInputField(), {mentions: true, emoji: true});
 | 
					    await attachTribute(this.easyMDE.codemirror.getInputField(), {mentions: true, emoji: true});
 | 
				
			||||||
    initEasyMDEImagePaste(this.easyMDE, this.dropzone);
 | 
					    initEasyMDEPaste(this.easyMDE, this.dropzone);
 | 
				
			||||||
    hideElem(this.textareaMarkdownToolbar);
 | 
					    hideElem(this.textareaMarkdownToolbar);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,8 @@
 | 
				
			|||||||
import {htmlEscape} from 'escape-goat';
 | 
					import {htmlEscape} from 'escape-goat';
 | 
				
			||||||
import {POST} from '../../modules/fetch.js';
 | 
					import {POST} from '../../modules/fetch.js';
 | 
				
			||||||
import {imageInfo} from '../../utils/image.js';
 | 
					import {imageInfo} from '../../utils/image.js';
 | 
				
			||||||
 | 
					import {getPastedContent, replaceTextareaSelection} from '../../utils/dom.js';
 | 
				
			||||||
 | 
					import {isUrl} from '../../utils/url.js';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async function uploadFile(file, uploadUrl) {
 | 
					async function uploadFile(file, uploadUrl) {
 | 
				
			||||||
  const formData = new FormData();
 | 
					  const formData = new FormData();
 | 
				
			||||||
@@ -10,17 +12,6 @@ async function uploadFile(file, uploadUrl) {
 | 
				
			|||||||
  return await res.json();
 | 
					  return await res.json();
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function clipboardPastedImages(e) {
 | 
					 | 
				
			||||||
  if (!e.clipboardData) return [];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const files = [];
 | 
					 | 
				
			||||||
  for (const item of e.clipboardData.items || []) {
 | 
					 | 
				
			||||||
    if (!item.type || !item.type.startsWith('image/')) continue;
 | 
					 | 
				
			||||||
    files.push(item.getAsFile());
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  return files;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
function triggerEditorContentChanged(target) {
 | 
					function triggerEditorContentChanged(target) {
 | 
				
			||||||
  target.dispatchEvent(new CustomEvent('ce-editor-content-changed', {bubbles: true}));
 | 
					  target.dispatchEvent(new CustomEvent('ce-editor-content-changed', {bubbles: true}));
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -91,20 +82,16 @@ class CodeMirrorEditor {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const uploadClipboardImage = async (editor, dropzone, e) => {
 | 
					async function handleClipboardImages(editor, dropzone, images, e) {
 | 
				
			||||||
  const uploadUrl = dropzone.getAttribute('data-upload-url');
 | 
					  const uploadUrl = dropzone.getAttribute('data-upload-url');
 | 
				
			||||||
  const filesContainer = dropzone.querySelector('.files');
 | 
					  const filesContainer = dropzone.querySelector('.files');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (!uploadUrl || !filesContainer) return;
 | 
					  if (!dropzone || !uploadUrl || !filesContainer || !images.length) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const pastedImages = clipboardPastedImages(e);
 | 
					 | 
				
			||||||
  if (!pastedImages || pastedImages.length === 0) {
 | 
					 | 
				
			||||||
    return;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  e.preventDefault();
 | 
					  e.preventDefault();
 | 
				
			||||||
  e.stopPropagation();
 | 
					  e.stopPropagation();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  for (const img of pastedImages) {
 | 
					  for (const img of images) {
 | 
				
			||||||
    const name = img.name.slice(0, img.name.lastIndexOf('.'));
 | 
					    const name = img.name.slice(0, img.name.lastIndexOf('.'));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const placeholder = ``;
 | 
					    const placeholder = ``;
 | 
				
			||||||
@@ -131,18 +118,37 @@ const uploadClipboardImage = async (editor, dropzone, e) => {
 | 
				
			|||||||
    input.value = uuid;
 | 
					    input.value = uuid;
 | 
				
			||||||
    filesContainer.append(input);
 | 
					    filesContainer.append(input);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
};
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function initEasyMDEImagePaste(easyMDE, dropzone) {
 | 
					function handleClipboardText(textarea, text, e) {
 | 
				
			||||||
  if (!dropzone) return;
 | 
					  // when pasting links over selected text, turn it into [text](link), except when shift key is held
 | 
				
			||||||
  easyMDE.codemirror.on('paste', async (_, e) => {
 | 
					  const {value, selectionStart, selectionEnd, _shiftDown} = textarea;
 | 
				
			||||||
    return uploadClipboardImage(new CodeMirrorEditor(easyMDE.codemirror), dropzone, e);
 | 
					  if (_shiftDown) return;
 | 
				
			||||||
 | 
					  const selectedText = value.substring(selectionStart, selectionEnd);
 | 
				
			||||||
 | 
					  const trimmedText = text.trim();
 | 
				
			||||||
 | 
					  if (selectedText && isUrl(trimmedText)) {
 | 
				
			||||||
 | 
					    e.stopPropagation();
 | 
				
			||||||
 | 
					    e.preventDefault();
 | 
				
			||||||
 | 
					    replaceTextareaSelection(textarea, `[${selectedText}](${trimmedText})`);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function initEasyMDEPaste(easyMDE, dropzone) {
 | 
				
			||||||
 | 
					  easyMDE.codemirror.on('paste', (_, e) => {
 | 
				
			||||||
 | 
					    const {images} = getPastedContent(e);
 | 
				
			||||||
 | 
					    if (images.length) {
 | 
				
			||||||
 | 
					      handleClipboardImages(new CodeMirrorEditor(easyMDE.codemirror), dropzone, images, e);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function initTextareaImagePaste(textarea, dropzone) {
 | 
					export function initTextareaPaste(textarea, dropzone) {
 | 
				
			||||||
  if (!dropzone) return;
 | 
					  textarea.addEventListener('paste', (e) => {
 | 
				
			||||||
  textarea.addEventListener('paste', async (e) => {
 | 
					    const {images, text} = getPastedContent(e);
 | 
				
			||||||
    return uploadClipboardImage(new TextareaEditor(textarea), dropzone, e);
 | 
					    if (images.length) {
 | 
				
			||||||
 | 
					      handleClipboardImages(new TextareaEditor(textarea), dropzone, images, e);
 | 
				
			||||||
 | 
					    } else if (text) {
 | 
				
			||||||
 | 
					      handleClipboardText(textarea, text, e);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -243,3 +243,39 @@ export function isElemVisible(element) {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  return Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
 | 
					  return Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// extract text and images from "paste" event
 | 
				
			||||||
 | 
					export function getPastedContent(e) {
 | 
				
			||||||
 | 
					  const images = [];
 | 
				
			||||||
 | 
					  for (const item of e.clipboardData?.items ?? []) {
 | 
				
			||||||
 | 
					    if (item.type?.startsWith('image/')) {
 | 
				
			||||||
 | 
					      images.push(item.getAsFile());
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  const text = e.clipboardData?.getData?.('text') ?? '';
 | 
				
			||||||
 | 
					  return {text, images};
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// replace selected text in a textarea while preserving editor history, e.g. CTRL-Z works after this
 | 
				
			||||||
 | 
					export function replaceTextareaSelection(textarea, text) {
 | 
				
			||||||
 | 
					  const before = textarea.value.slice(0, textarea.selectionStart ?? undefined);
 | 
				
			||||||
 | 
					  const after = textarea.value.slice(textarea.selectionEnd ?? undefined);
 | 
				
			||||||
 | 
					  let success = true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  textarea.contentEditable = 'true';
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    success = document.execCommand('insertText', false, text);
 | 
				
			||||||
 | 
					  } catch {
 | 
				
			||||||
 | 
					    success = false;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  textarea.contentEditable = 'false';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (success && !textarea.value.slice(0, textarea.selectionStart ?? undefined).endsWith(text)) {
 | 
				
			||||||
 | 
					    success = false;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (!success) {
 | 
				
			||||||
 | 
					    textarea.value = `${before}${text}${after}`;
 | 
				
			||||||
 | 
					    textarea.dispatchEvent(new CustomEvent('change', {bubbles: true, cancelable: true}));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,3 +1,15 @@
 | 
				
			|||||||
export function pathEscapeSegments(s) {
 | 
					export function pathEscapeSegments(s) {
 | 
				
			||||||
  return s.split('/').map(encodeURIComponent).join('/');
 | 
					  return s.split('/').map(encodeURIComponent).join('/');
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function stripSlash(url) {
 | 
				
			||||||
 | 
					  return url.endsWith('/') ? url.slice(0, -1) : url;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function isUrl(url) {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    return stripSlash((new URL(url).href)).trim() === stripSlash(url).trim();
 | 
				
			||||||
 | 
					  } catch {
 | 
				
			||||||
 | 
					    return false;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,13 @@
 | 
				
			|||||||
import {pathEscapeSegments} from './url.js';
 | 
					import {pathEscapeSegments, isUrl} from './url.js';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
test('pathEscapeSegments', () => {
 | 
					test('pathEscapeSegments', () => {
 | 
				
			||||||
  expect(pathEscapeSegments('a/b/c')).toEqual('a/b/c');
 | 
					  expect(pathEscapeSegments('a/b/c')).toEqual('a/b/c');
 | 
				
			||||||
  expect(pathEscapeSegments('a/b/ c')).toEqual('a/b/%20c');
 | 
					  expect(pathEscapeSegments('a/b/ c')).toEqual('a/b/%20c');
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					test('isUrl', () => {
 | 
				
			||||||
 | 
					  expect(isUrl('https://example.com')).toEqual(true);
 | 
				
			||||||
 | 
					  expect(isUrl('https://example.com/')).toEqual(true);
 | 
				
			||||||
 | 
					  expect(isUrl('https://example.com/index.html')).toEqual(true);
 | 
				
			||||||
 | 
					  expect(isUrl('/index.html')).toEqual(false);
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user