From 9f5c752eef7771a150fb155917f22575c55d14a6 Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Thu, 30 Jan 2025 13:14:50 -0600 Subject: [PATCH] Fix copy/paste of a single-line of formatting text Co-authored-by: Scott Nonnenberg --- ts/quill/signal-clipboard/util.ts | 124 +++++++++++++++++++++++++++++- 1 file changed, 123 insertions(+), 1 deletion(-) 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; +}