diff --git a/package.json b/package.json
index d3eb83fd0f711..832a629e7166f 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
     "@citation-js/plugin-csl": "0.7.18",
     "@citation-js/plugin-software-formats": "0.6.1",
     "@github/markdown-toolbar-element": "2.2.3",
+    "@github/paste-markdown": "1.5.3",
     "@github/relative-time-element": "4.4.8",
     "@github/text-expander-element": "2.9.2",
     "@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3ff860f2c7b58..3ba3d91d55ddb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -41,6 +41,9 @@ importers:
       '@github/markdown-toolbar-element':
         specifier: 2.2.3
         version: 2.2.3
+      '@github/paste-markdown':
+        specifier: 1.5.3
+        version: 1.5.3
       '@github/relative-time-element':
         specifier: 4.4.8
         version: 4.4.8
@@ -718,6 +721,9 @@ packages:
   '@github/markdown-toolbar-element@2.2.3':
     resolution: {integrity: sha512-AlquKGee+IWiAMYVB0xyHFZRMnu4n3X4HTvJHu79GiVJ1ojTukCWyxMlF5NMsecoLcBKsuBhx3QPv2vkE/zQ0A==}
 
+  '@github/paste-markdown@1.5.3':
+    resolution: {integrity: sha512-PzZ1b3PaqBzYqbT4fwKEhiORf38h2OcGp2+JdXNNM7inZ7egaSmfmhyNkQILpqWfS0AYtRS3CDq6z03eZ8yOMQ==}
+
   '@github/relative-time-element@4.4.8':
     resolution: {integrity: sha512-FSLYm6F3TSQnqHE1EMQUVVgi2XjbCvsESwwXfugHFpBnhyF1uhJOtu0Psp/BB/qqazfdkk7f5fVcu7WuXl3t8Q==}
 
@@ -5024,6 +5030,8 @@ snapshots:
 
   '@github/markdown-toolbar-element@2.2.3': {}
 
+  '@github/paste-markdown@1.5.3': {}
+
   '@github/relative-time-element@4.4.8': {}
 
   '@github/text-expander-element@2.9.2':
diff --git a/web_src/js/features/comp/EditorUpload.test.ts b/web_src/js/features/comp/EditorUpload.test.ts
index e6e5f4de13a8e..55f3f743893ec 100644
--- a/web_src/js/features/comp/EditorUpload.test.ts
+++ b/web_src/js/features/comp/EditorUpload.test.ts
@@ -1,4 +1,4 @@
-import {pasteAsMarkdownLink, removeAttachmentLinksFromMarkdown} from './EditorUpload.ts';
+import {removeAttachmentLinksFromMarkdown} from './EditorUpload.ts';
 
 test('removeAttachmentLinksFromMarkdown', () => {
   expect(removeAttachmentLinksFromMarkdown('a foo b', 'foo')).toBe('a foo b');
@@ -12,13 +12,3 @@ test('removeAttachmentLinksFromMarkdown', () => {
   expect(removeAttachmentLinksFromMarkdown('a  b', 'foo')).toBe('a  b');
   expect(removeAttachmentLinksFromMarkdown('a
 b', 'foo')).toBe('a  b');
   expect(removeAttachmentLinksFromMarkdown('a  b', 'foo')).toBe('a  b');
 });
-
-test('preparePasteAsMarkdownLink', () => {
-  expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 0}, 'bar')).toBeNull();
-  expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 0}, 'https://gitea.com')).toBeNull();
-  expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 3}, 'bar')).toBeNull();
-  expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 3}, 'https://gitea.com')).toBe('[foo](https://gitea.com)');
-  expect(pasteAsMarkdownLink({value: '..(url)', selectionStart: 3, selectionEnd: 6}, 'https://gitea.com')).toBe('[url](https://gitea.com)');
-  expect(pasteAsMarkdownLink({value: '[](url)', selectionStart: 3, selectionEnd: 6}, 'https://gitea.com')).toBeNull();
-  expect(pasteAsMarkdownLink({value: 'https://example.com', selectionStart: 0, selectionEnd: 19}, 'https://gitea.com')).toBeNull();
-});
diff --git a/web_src/js/features/comp/EditorUpload.ts b/web_src/js/features/comp/EditorUpload.ts
index bf78f58daf48e..3e4a84568cf9e 100644
--- a/web_src/js/features/comp/EditorUpload.ts
+++ b/web_src/js/features/comp/EditorUpload.ts
@@ -1,12 +1,11 @@
 import {imageInfo} from '../../utils/image.ts';
-import {replaceTextareaSelection} from '../../utils/dom.ts';
-import {isUrl} from '../../utils/url.ts';
 import {textareaInsertText, triggerEditorContentChanged} from './EditorMarkdown.ts';
 import {
   DropzoneCustomEventRemovedFile,
   DropzoneCustomEventUploadDone,
   generateMarkdownLinkForAttachment,
 } from '../dropzone.ts';
+import {subscribe} from '@github/paste-markdown';
 import type CodeMirror from 'codemirror';
 import type EasyMDE from 'easymde';
 import type {DropzoneFile} from 'dropzone';
@@ -118,46 +117,20 @@ export function removeAttachmentLinksFromMarkdown(text: string, fileUuid: string
   return text;
 }
 
-export function pasteAsMarkdownLink(textarea: {value: string, selectionStart: number, selectionEnd: number}, pastedText: string): string | null {
-  const {value, selectionStart, selectionEnd} = textarea;
-  const selectedText = value.substring(selectionStart, selectionEnd);
-  const trimmedText = pastedText.trim();
-  const beforeSelection = value.substring(0, selectionStart);
-  const afterSelection = value.substring(selectionEnd);
-  const isInMarkdownLink = beforeSelection.endsWith('](') && afterSelection.startsWith(')');
-  const asMarkdownLink = selectedText && isUrl(trimmedText) && !isUrl(selectedText) && !isInMarkdownLink;
-  return asMarkdownLink ? `[${selectedText}](${trimmedText})` : null;
-}
-
-function handleClipboardText(textarea: HTMLTextAreaElement, e: ClipboardEvent, pastedText: string, isShiftDown: boolean) {
-  // pasting with "shift" means "paste as original content" in most applications
-  if (isShiftDown) return; // let the browser handle it
-
-  // when pasting links over selected text, turn it into [text](link)
-  const pastedAsMarkdown = pasteAsMarkdownLink(textarea, pastedText);
-  if (pastedAsMarkdown) {
-    e.preventDefault();
-    replaceTextareaSelection(textarea, pastedAsMarkdown);
-  }
-  // else, let the browser handle it
-}
-
-// extract text and images from "paste" event
-function getPastedContent(e: ClipboardEvent) {
-  const images = [];
+function getPastedImages(e: ClipboardEvent) {
+  const images: Array = [];
   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};
+  return images;
 }
 
 export function initEasyMDEPaste(easyMDE: EasyMDE, dropzoneEl: HTMLElement) {
   const editor = new CodeMirrorEditor(easyMDE.codemirror as any);
   easyMDE.codemirror.on('paste', (_, e) => {
-    const {images} = getPastedContent(e);
+    const images = getPastedImages(e);
     if (!images.length) return;
     handleUploadFiles(editor, dropzoneEl, images, e);
   });
@@ -173,19 +146,11 @@ export function initEasyMDEPaste(easyMDE: EasyMDE, dropzoneEl: HTMLElement) {
 }
 
 export function initTextareaEvents(textarea: HTMLTextAreaElement, dropzoneEl: HTMLElement) {
-  let isShiftDown = false;
-  textarea.addEventListener('keydown', (e: KeyboardEvent) => {
-    if (e.shiftKey) isShiftDown = true;
-  });
-  textarea.addEventListener('keyup', (e: KeyboardEvent) => {
-    if (!e.shiftKey) isShiftDown = false;
-  });
+  subscribe(textarea); // enable paste features
   textarea.addEventListener('paste', (e: ClipboardEvent) => {
-    const {images, text} = getPastedContent(e);
+    const images = getPastedImages(e);
     if (images.length && dropzoneEl) {
       handleUploadFiles(new TextareaEditor(textarea), dropzoneEl, images, e);
-    } else if (text) {
-      handleClipboardText(textarea, e, text, isShiftDown);
     }
   });
   textarea.addEventListener('drop', (e: DragEvent) => {
diff --git a/web_src/js/utils/dom.ts b/web_src/js/utils/dom.ts
index 1da6b71de635e..3a1b74c1db8f3 100644
--- a/web_src/js/utils/dom.ts
+++ b/web_src/js/utils/dom.ts
@@ -286,28 +286,6 @@ export function isElemVisible(el: HTMLElement): boolean {
   return !el.classList.contains('tw-hidden') && (el.offsetWidth || el.offsetHeight || el.getClientRects().length) && el.style.display !== 'none';
 }
 
-/** replace selected text in a textarea while preserving editor history, e.g. CTRL-Z works after this */
-export function replaceTextareaSelection(textarea: HTMLTextAreaElement, text: string) {
-  const before = textarea.value.slice(0, textarea.selectionStart ?? undefined);
-  const after = textarea.value.slice(textarea.selectionEnd ?? undefined);
-  let success = false;
-
-  textarea.contentEditable = 'true';
-  try {
-    success = document.execCommand('insertText', false, text); // eslint-disable-line @typescript-eslint/no-deprecated
-  } catch {} // ignore the error if execCommand is not supported or failed
-  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}));
-  }
-}
-
 export function createElementFromHTML(htmlString: string): T {
   htmlString = htmlString.trim();
   // There is no way to create some elements without a proper parent, jQuery's approach: https://github.com/jquery/jquery/blob/main/src/manipulation/wrapMap.js
diff --git a/web_src/js/utils/url.test.ts b/web_src/js/utils/url.test.ts
index bb331a6b4901a..c39dd157322eb 100644
--- a/web_src/js/utils/url.test.ts
+++ b/web_src/js/utils/url.test.ts
@@ -1,17 +1,10 @@
-import {pathEscapeSegments, isUrl, toOriginUrl} from './url.ts';
+import {pathEscapeSegments, toOriginUrl} from './url.ts';
 
 test('pathEscapeSegments', () => {
   expect(pathEscapeSegments('a/b/c')).toEqual('a/b/c');
   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);
-});
-
 test('toOriginUrl', () => {
   const oldLocation = String(window.location);
   for (const origin of ['https://example.com', 'https://example.com:3000']) {
diff --git a/web_src/js/utils/url.ts b/web_src/js/utils/url.ts
index 9991da7472a67..6bcb4c1609382 100644
--- a/web_src/js/utils/url.ts
+++ b/web_src/js/utils/url.ts
@@ -2,18 +2,6 @@ export function pathEscapeSegments(s: string): string {
   return s.split('/').map(encodeURIComponent).join('/');
 }
 
-function stripSlash(url: string): string {
-  return url.endsWith('/') ? url.slice(0, -1) : url;
-}
-
-export function isUrl(url: string): boolean {
-  try {
-    return stripSlash((new URL(url).href)).trim() === stripSlash(url).trim();
-  } catch {
-    return false;
-  }
-}
-
 /** Convert an absolute or relative URL to an absolute URL with the current origin. It only
  *  processes absolute HTTP/HTTPS URLs or relative URLs like '/xxx' or '//host/xxx'. */
 export function toOriginUrl(urlStr: string) {
 b', 'foo')).toBe('a  b');
 });
-
-test('preparePasteAsMarkdownLink', () => {
-  expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 0}, 'bar')).toBeNull();
-  expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 0}, 'https://gitea.com')).toBeNull();
-  expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 3}, 'bar')).toBeNull();
-  expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 3}, 'https://gitea.com')).toBe('[foo](https://gitea.com)');
-  expect(pasteAsMarkdownLink({value: '..(url)', selectionStart: 3, selectionEnd: 6}, 'https://gitea.com')).toBe('[url](https://gitea.com)');
-  expect(pasteAsMarkdownLink({value: '[](url)', selectionStart: 3, selectionEnd: 6}, 'https://gitea.com')).toBeNull();
-  expect(pasteAsMarkdownLink({value: 'https://example.com', selectionStart: 0, selectionEnd: 19}, 'https://gitea.com')).toBeNull();
-});
diff --git a/web_src/js/features/comp/EditorUpload.ts b/web_src/js/features/comp/EditorUpload.ts
index bf78f58daf48e..3e4a84568cf9e 100644
--- a/web_src/js/features/comp/EditorUpload.ts
+++ b/web_src/js/features/comp/EditorUpload.ts
@@ -1,12 +1,11 @@
 import {imageInfo} from '../../utils/image.ts';
-import {replaceTextareaSelection} from '../../utils/dom.ts';
-import {isUrl} from '../../utils/url.ts';
 import {textareaInsertText, triggerEditorContentChanged} from './EditorMarkdown.ts';
 import {
   DropzoneCustomEventRemovedFile,
   DropzoneCustomEventUploadDone,
   generateMarkdownLinkForAttachment,
 } from '../dropzone.ts';
+import {subscribe} from '@github/paste-markdown';
 import type CodeMirror from 'codemirror';
 import type EasyMDE from 'easymde';
 import type {DropzoneFile} from 'dropzone';
@@ -118,46 +117,20 @@ export function removeAttachmentLinksFromMarkdown(text: string, fileUuid: string
   return text;
 }
 
-export function pasteAsMarkdownLink(textarea: {value: string, selectionStart: number, selectionEnd: number}, pastedText: string): string | null {
-  const {value, selectionStart, selectionEnd} = textarea;
-  const selectedText = value.substring(selectionStart, selectionEnd);
-  const trimmedText = pastedText.trim();
-  const beforeSelection = value.substring(0, selectionStart);
-  const afterSelection = value.substring(selectionEnd);
-  const isInMarkdownLink = beforeSelection.endsWith('](') && afterSelection.startsWith(')');
-  const asMarkdownLink = selectedText && isUrl(trimmedText) && !isUrl(selectedText) && !isInMarkdownLink;
-  return asMarkdownLink ? `[${selectedText}](${trimmedText})` : null;
-}
-
-function handleClipboardText(textarea: HTMLTextAreaElement, e: ClipboardEvent, pastedText: string, isShiftDown: boolean) {
-  // pasting with "shift" means "paste as original content" in most applications
-  if (isShiftDown) return; // let the browser handle it
-
-  // when pasting links over selected text, turn it into [text](link)
-  const pastedAsMarkdown = pasteAsMarkdownLink(textarea, pastedText);
-  if (pastedAsMarkdown) {
-    e.preventDefault();
-    replaceTextareaSelection(textarea, pastedAsMarkdown);
-  }
-  // else, let the browser handle it
-}
-
-// extract text and images from "paste" event
-function getPastedContent(e: ClipboardEvent) {
-  const images = [];
+function getPastedImages(e: ClipboardEvent) {
+  const images: Array = [];
   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};
+  return images;
 }
 
 export function initEasyMDEPaste(easyMDE: EasyMDE, dropzoneEl: HTMLElement) {
   const editor = new CodeMirrorEditor(easyMDE.codemirror as any);
   easyMDE.codemirror.on('paste', (_, e) => {
-    const {images} = getPastedContent(e);
+    const images = getPastedImages(e);
     if (!images.length) return;
     handleUploadFiles(editor, dropzoneEl, images, e);
   });
@@ -173,19 +146,11 @@ export function initEasyMDEPaste(easyMDE: EasyMDE, dropzoneEl: HTMLElement) {
 }
 
 export function initTextareaEvents(textarea: HTMLTextAreaElement, dropzoneEl: HTMLElement) {
-  let isShiftDown = false;
-  textarea.addEventListener('keydown', (e: KeyboardEvent) => {
-    if (e.shiftKey) isShiftDown = true;
-  });
-  textarea.addEventListener('keyup', (e: KeyboardEvent) => {
-    if (!e.shiftKey) isShiftDown = false;
-  });
+  subscribe(textarea); // enable paste features
   textarea.addEventListener('paste', (e: ClipboardEvent) => {
-    const {images, text} = getPastedContent(e);
+    const images = getPastedImages(e);
     if (images.length && dropzoneEl) {
       handleUploadFiles(new TextareaEditor(textarea), dropzoneEl, images, e);
-    } else if (text) {
-      handleClipboardText(textarea, e, text, isShiftDown);
     }
   });
   textarea.addEventListener('drop', (e: DragEvent) => {
diff --git a/web_src/js/utils/dom.ts b/web_src/js/utils/dom.ts
index 1da6b71de635e..3a1b74c1db8f3 100644
--- a/web_src/js/utils/dom.ts
+++ b/web_src/js/utils/dom.ts
@@ -286,28 +286,6 @@ export function isElemVisible(el: HTMLElement): boolean {
   return !el.classList.contains('tw-hidden') && (el.offsetWidth || el.offsetHeight || el.getClientRects().length) && el.style.display !== 'none';
 }
 
-/** replace selected text in a textarea while preserving editor history, e.g. CTRL-Z works after this */
-export function replaceTextareaSelection(textarea: HTMLTextAreaElement, text: string) {
-  const before = textarea.value.slice(0, textarea.selectionStart ?? undefined);
-  const after = textarea.value.slice(textarea.selectionEnd ?? undefined);
-  let success = false;
-
-  textarea.contentEditable = 'true';
-  try {
-    success = document.execCommand('insertText', false, text); // eslint-disable-line @typescript-eslint/no-deprecated
-  } catch {} // ignore the error if execCommand is not supported or failed
-  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}));
-  }
-}
-
 export function createElementFromHTML(htmlString: string): T {
   htmlString = htmlString.trim();
   // There is no way to create some elements without a proper parent, jQuery's approach: https://github.com/jquery/jquery/blob/main/src/manipulation/wrapMap.js
diff --git a/web_src/js/utils/url.test.ts b/web_src/js/utils/url.test.ts
index bb331a6b4901a..c39dd157322eb 100644
--- a/web_src/js/utils/url.test.ts
+++ b/web_src/js/utils/url.test.ts
@@ -1,17 +1,10 @@
-import {pathEscapeSegments, isUrl, toOriginUrl} from './url.ts';
+import {pathEscapeSegments, toOriginUrl} from './url.ts';
 
 test('pathEscapeSegments', () => {
   expect(pathEscapeSegments('a/b/c')).toEqual('a/b/c');
   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);
-});
-
 test('toOriginUrl', () => {
   const oldLocation = String(window.location);
   for (const origin of ['https://example.com', 'https://example.com:3000']) {
diff --git a/web_src/js/utils/url.ts b/web_src/js/utils/url.ts
index 9991da7472a67..6bcb4c1609382 100644
--- a/web_src/js/utils/url.ts
+++ b/web_src/js/utils/url.ts
@@ -2,18 +2,6 @@ export function pathEscapeSegments(s: string): string {
   return s.split('/').map(encodeURIComponent).join('/');
 }
 
-function stripSlash(url: string): string {
-  return url.endsWith('/') ? url.slice(0, -1) : url;
-}
-
-export function isUrl(url: string): boolean {
-  try {
-    return stripSlash((new URL(url).href)).trim() === stripSlash(url).trim();
-  } catch {
-    return false;
-  }
-}
-
 /** Convert an absolute or relative URL to an absolute URL with the current origin. It only
  *  processes absolute HTTP/HTTPS URLs or relative URLs like '/xxx' or '//host/xxx'. */
 export function toOriginUrl(urlStr: string) {