diff --git a/ts/quill/signal-clipboard/util.ts b/ts/quill/signal-clipboard/util.ts
index de4ed3654..c959749eb 100644
--- a/ts/quill/signal-clipboard/util.ts
+++ b/ts/quill/signal-clipboard/util.ts
@@ -35,7 +35,7 @@ export function createEventHandler({
const container = document.createElement('div');
for (let i = 0, max = selection.rangeCount; i < max; i += 1) {
const range = selection.getRangeAt(i);
- container.appendChild(range.cloneContents());
+ container.appendChild(getRangeWithContainer(range));
}
// We fail over to selection.toString() because we can't pull values from the DOM if
@@ -111,3 +111,125 @@ function getStringFromNode(
}
return result;
}
+
+const CONTAINER_CLASSES = ['ql-editor', 'module-message__text'];
+const BOLD_TAG = 'strong';
+const ITALIC_TAG = 'em';
+const STRIKETHROUGH_TAG = 's';
+const MONOSPACE_CLASSES = [
+ 'quill--monospace',
+ 'MessageTextRenderer__formatting--monospace',
+];
+const SPOILER_CLASSES = [
+ 'quill--spoiler',
+ 'MessageTextRenderer__formatting--spoiler',
+ 'MessageTextRenderer__formatting--spoiler--revealed',
+];
+
+// When the user cuts/copies single-line text which don't cross any mentions/emojo or
+// formatting boundaries, we don't get the surrounding formatting nodes in our selection.
+// So, we need to walk the DOM and re-create those containing nodes.
+function getRangeWithContainer(range: Range): Node {
+ const fragment = range.cloneContents();
+
+ // We're talking about HTML that might look like this, from the composer:
+
+ //
+ //
+ //
+ //
+ //
+ //
+ //
+ // All formatting, with no formatting boundaries, mentions or emoji.
+ //
+ //
+ //
+ //
+ //
+ //
+ //
+
+ // Or like this, from a message bubble:
+
+ //
+ //
+ //
+ //
+ //
+ //
+ //
+ // All formatting, with no formatting boundaries, mentions or emoji.
+ //
+ //
+ //
+ //
+ //
+ //
+ //
+ //
+
+ // If the range spans multiple elements, we don't have to worry about this
+ const { startContainer, endContainer } = range;
+ if (startContainer !== endContainer) {
+ return fragment;
+ }
+
+ let currentNode: Element | null;
+ if (startContainer.nodeType !== Node.TEXT_NODE) {
+ return fragment;
+ }
+
+ if (fragment.childNodes.length > 1) {
+ return fragment;
+ }
+ let finalNode = fragment.childNodes.item(0);
+ if (!finalNode) {
+ return fragment;
+ }
+
+ currentNode = startContainer.parentElement as HTMLElement;
+ while (
+ currentNode &&
+ // eslint-disable-next-line no-loop-func
+ CONTAINER_CLASSES.every(item => !currentNode?.classList.contains(item))
+ ) {
+ const tagName = currentNode.tagName.toLowerCase();
+ if (tagName === BOLD_TAG) {
+ const newNode = document.createElement(BOLD_TAG);
+ newNode.appendChild(finalNode);
+ finalNode = newNode;
+ } else if (tagName === ITALIC_TAG) {
+ const newNode = document.createElement(ITALIC_TAG);
+ newNode.appendChild(finalNode);
+ finalNode = newNode;
+ } else if (tagName === STRIKETHROUGH_TAG) {
+ const newNode = document.createElement(STRIKETHROUGH_TAG);
+ newNode.appendChild(finalNode);
+ finalNode = newNode;
+ } else if (
+ // eslint-disable-next-line no-loop-func
+ MONOSPACE_CLASSES.some(item => currentNode?.classList.contains(item))
+ ) {
+ const newNode = document.createElement('span');
+ // Matchers check for all classes, so we just add the first
+ newNode.classList.add(MONOSPACE_CLASSES[0]);
+ newNode.appendChild(finalNode);
+ finalNode = newNode;
+ } else if (
+ // eslint-disable-next-line no-loop-func
+ SPOILER_CLASSES.some(item => currentNode?.classList.contains(item))
+ ) {
+ const newNode = document.createElement('span');
+ // Matchers check for all classes, so we just add the first
+ newNode.classList.add(SPOILER_CLASSES[0]);
+ newNode.appendChild(finalNode);
+ finalNode = newNode;
+ }
+
+ currentNode = currentNode.parentElement;
+ }
+
+ fragment.replaceChildren(finalNode);
+ return fragment;
+}