From e3ffc703892abc0332994eb93a363eb74ef1e9bb Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Fri, 4 Aug 2023 09:29:47 -0700 Subject: [PATCH] Apply existing formatting to pasted content, preserve whitespace --- patches/quill+1.3.7.patch | 67 ++++++++++++++++++-- ts/components/CompositionInput.tsx | 2 +- ts/quill/emoji/matchers.ts | 39 +++++++++--- ts/quill/formatting/matchers.ts | 49 ++++++++++---- ts/quill/mentions/matchers.ts | 38 +++++++---- ts/quill/signal-clipboard/index.ts | 8 ++- ts/quill/types.d.ts | 10 +++ ts/quill/util.ts | 12 +++- ts/test-node/quill/mentions/matchers_test.ts | 19 ++++-- 9 files changed, 191 insertions(+), 53 deletions(-) diff --git a/patches/quill+1.3.7.patch b/patches/quill+1.3.7.patch index 7205d73523b..b21d23eddb7 100644 --- a/patches/quill+1.3.7.patch +++ b/patches/quill+1.3.7.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/quill/dist/quill.js b/node_modules/quill/dist/quill.js -index 811b3d0..1082f2a 100644 +index 811b3d0..135dfb2 100644 --- a/node_modules/quill/dist/quill.js +++ b/node_modules/quill/dist/quill.js @@ -8896,7 +8896,8 @@ var debug = (0, _logger2.default)('quill:clipboard'); @@ -27,7 +27,7 @@ index 811b3d0..1082f2a 100644 _this.matchers = []; CLIPBOARD_CONFIG.concat(_this.options.matchers).forEach(function (_ref) { var _ref2 = _slicedToArray(_ref, 2), -@@ -8941,15 +8942,18 @@ var Clipboard = function (_Module) { +@@ -8941,28 +8942,33 @@ var Clipboard = function (_Module) { key: 'convert', value: function convert(html) { if (typeof html === 'string') { @@ -52,7 +52,14 @@ index 811b3d0..1082f2a 100644 var _prepareMatching = this.prepareMatching(), _prepareMatching2 = _slicedToArray(_prepareMatching, 2), -@@ -8962,7 +8966,8 @@ var Clipboard = function (_Module) { + elementMatchers = _prepareMatching2[0], + textMatchers = _prepareMatching2[1]; + +- var delta = traverse(this.container, elementMatchers, textMatchers); ++ // var delta = traverse(this.container, elementMatchers, textMatchers); ++ var delta = traverse(this.container, elementMatchers, textMatchers, formats); + // Remove trailing newline + if (deltaEndsWith(delta, '\n') && delta.ops[delta.ops.length - 1].attributes == null) { delta = delta.compose(new _quillDelta2.default().retain(delta.length() - 1).delete(1)); } debug.log('convert', this.container.innerHTML, delta); @@ -62,7 +69,7 @@ index 811b3d0..1082f2a 100644 return delta; } }, { -@@ -9056,9 +9061,10 @@ function applyFormat(delta, format, value) { +@@ -9056,9 +9062,10 @@ function applyFormat(delta, format, value) { } function computeStyle(node) { @@ -76,7 +83,7 @@ index 811b3d0..1082f2a 100644 } function deltaEndsWith(delta, text) { -@@ -9074,7 +9080,8 @@ function deltaEndsWith(delta, text) { +@@ -9074,24 +9081,30 @@ function deltaEndsWith(delta, text) { function isLine(node) { if (node.childNodes.length === 0) return false; // Exclude embed blocks var style = computeStyle(node); @@ -85,8 +92,35 @@ index 811b3d0..1082f2a 100644 + return ['block', 'list-item'].indexOf(style.display) > -1 || node.nodeName === 'DIV' || node.nodeName === 'P' || node.nodeName === 'TIME'; } - function traverse(node, elementMatchers, textMatchers) { -@@ -9177,8 +9184,10 @@ function matchIndent(node, delta) { +-function traverse(node, elementMatchers, textMatchers) { ++// function traverse(node, elementMatchers, textMatchers) { ++function traverse(node, elementMatchers, textMatchers, attributes) { + // Post-order + if (node.nodeType === node.TEXT_NODE) { + return textMatchers.reduce(function (delta, matcher) { +- return matcher(node, delta); ++ // return matcher(node, delta); ++ return matcher(node, delta, attributes); + }, new _quillDelta2.default()); + } else if (node.nodeType === node.ELEMENT_NODE) { + return [].reduce.call(node.childNodes || [], function (delta, childNode) { +- var childrenDelta = traverse(childNode, elementMatchers, textMatchers); ++ // var childrenDelta = traverse(childNode, elementMatchers, textMatchers); ++ var childrenDelta = traverse(childNode, elementMatchers, textMatchers, attributes); + if (childNode.nodeType === node.ELEMENT_NODE) { + childrenDelta = elementMatchers.reduce(function (childrenDelta, matcher) { +- return matcher(childNode, childrenDelta); ++ // return matcher(childNode, childrenDelta); ++ return matcher(childNode, childrenDelta, attributes); + }, childrenDelta); + childrenDelta = (childNode[DOM_KEY] || []).reduce(function (childrenDelta, matcher) { +- return matcher(childNode, childrenDelta); ++ // return matcher(childNode, childrenDelta); ++ return matcher(childNode, childrenDelta, attributes); + }, childrenDelta); + } + return delta.concat(childrenDelta); +@@ -9177,8 +9190,10 @@ function matchIndent(node, delta) { } function matchNewline(node, delta) { @@ -99,3 +133,22 @@ index 811b3d0..1082f2a 100644 delta.insert('\n'); } } +@@ -9214,7 +9229,7 @@ function matchStyles(node, delta) { + return delta; + } + +-function matchText(node, delta) { ++function matchText(node, delta, attributes) { + var text = node.data; + // Word represents empty line with   + if (node.parentNode.tagName === 'O:P') { +@@ -9238,7 +9253,7 @@ function matchText(node, delta) { + text = text.replace(/\s+$/, replacer.bind(replacer, false)); + } + } +- return delta.insert(text); ++ return delta.insert(text, attributes); + } + + exports.default = Clipboard; +\ No newline at end of file diff --git a/ts/components/CompositionInput.tsx b/ts/components/CompositionInput.tsx index c07ebf89cc4..40caed66147 100644 --- a/ts/components/CompositionInput.tsx +++ b/ts/components/CompositionInput.tsx @@ -213,7 +213,7 @@ export function CompositionInput(props: Props): React.ReactElement { }, []); const nodes = collapseRangeTree({ tree, text }); const opsWithFormattingAndMentions = insertFormattingAndMentionsOps(nodes); - const opsWithEmojis = insertEmojiOps(opsWithFormattingAndMentions); + const opsWithEmojis = insertEmojiOps(opsWithFormattingAndMentions, {}); return new Delta(opsWithEmojis); }; diff --git a/ts/quill/emoji/matchers.ts b/ts/quill/emoji/matchers.ts index 39b3af61e0b..60b454f511c 100644 --- a/ts/quill/emoji/matchers.ts +++ b/ts/quill/emoji/matchers.ts @@ -2,33 +2,56 @@ // SPDX-License-Identifier: AGPL-3.0-only import Delta from 'quill-delta'; +import type { Matcher, AttributeMap } from 'quill'; + import { insertEmojiOps } from '../util'; -export const matchEmojiImage = (node: Element, delta: Delta): Delta => { +export const matchEmojiImage: Matcher = ( + node: Element, + delta: Delta, + attributes: AttributeMap +): Delta => { if ( node.classList.contains('emoji') || node.classList.contains('module-emoji__image--16px') ) { const emoji = node.getAttribute('aria-label'); - return new Delta().insert({ emoji }); + return new Delta().insert({ emoji }, attributes); } return delta; }; -export const matchEmojiBlot = (node: HTMLElement, delta: Delta): Delta => { +export const matchEmojiBlot: Matcher = ( + node: HTMLElement, + delta: Delta, + attributes: AttributeMap +): Delta => { if (node.classList.contains('emoji-blot')) { const { emoji } = node.dataset; - return new Delta().insert({ emoji }); + return new Delta().insert({ emoji }, attributes); } return delta; }; -export const matchEmojiText = (node: Text): Delta => { - if (node.data.replace(/(\n|\r\n)/g, '') === '') { +export const matchEmojiText: Matcher = ( + node: HTMLElement, + _delta: Delta, + attributes: AttributeMap +): Delta => { + if (!('data' in node)) { return new Delta(); } - const nodeAsInsert = { insert: node.data }; + const { data } = node; + if (!data || typeof data !== 'string') { + return new Delta(); + } - return new Delta(insertEmojiOps([nodeAsInsert])); + if (data.replace(/(\n|\r\n)/g, '') === '') { + return new Delta(); + } + + const nodeAsInsert = { insert: data, attributes }; + + return new Delta(insertEmojiOps([nodeAsInsert], attributes)); }; diff --git a/ts/quill/formatting/matchers.ts b/ts/quill/formatting/matchers.ts index 4804a448442..5f867441bce 100644 --- a/ts/quill/formatting/matchers.ts +++ b/ts/quill/formatting/matchers.ts @@ -2,13 +2,20 @@ // SPDX-License-Identifier: AGPL-3.0-only import Delta from 'quill-delta'; +import type { Matcher, AttributeMap } from 'quill'; + import { QuillFormattingStyle } from './menu'; -function applyStyleToOps(delta: Delta, style: QuillFormattingStyle): Delta { +function applyStyleToOps( + delta: Delta, + style: QuillFormattingStyle, + attributes: AttributeMap +): Delta { return new Delta( delta.map(op => ({ ...op, attributes: { + ...attributes, ...op.attributes, [style]: true, }, @@ -16,31 +23,47 @@ function applyStyleToOps(delta: Delta, style: QuillFormattingStyle): Delta { ); } -export const matchBold = (_node: HTMLElement, delta: Delta): Delta => { +export const matchBold: Matcher = ( + _node: HTMLElement, + delta: Delta, + attributes: AttributeMap +): Delta => { if (delta.length() > 0) { - return applyStyleToOps(delta, QuillFormattingStyle.bold); + return applyStyleToOps(delta, QuillFormattingStyle.bold, attributes); } return delta; }; -export const matchItalic = (_node: HTMLElement, delta: Delta): Delta => { +export const matchItalic: Matcher = ( + _node: HTMLElement, + delta: Delta, + attributes: AttributeMap +): Delta => { if (delta.length() > 0) { - return applyStyleToOps(delta, QuillFormattingStyle.italic); + return applyStyleToOps(delta, QuillFormattingStyle.italic, attributes); } return delta; }; -export const matchStrikethrough = (_node: HTMLElement, delta: Delta): Delta => { +export const matchStrikethrough: Matcher = ( + _node: HTMLElement, + delta: Delta, + attributes: AttributeMap +): Delta => { if (delta.length() > 0) { - return applyStyleToOps(delta, QuillFormattingStyle.strike); + return applyStyleToOps(delta, QuillFormattingStyle.strike, attributes); } return delta; }; -export const matchMonospace = (node: HTMLElement, delta: Delta): Delta => { +export const matchMonospace: Matcher = ( + node: HTMLElement, + delta: Delta, + attributes: AttributeMap +): Delta => { const classes = [ 'MessageTextRenderer__formatting--monospace', 'quill--monospace', @@ -55,13 +78,17 @@ export const matchMonospace = (node: HTMLElement, delta: Delta): Delta => { node.classList.contains(classes[1]) || node.attributes.getNamedItem('style')?.value?.includes(fontFamily)) ) { - return applyStyleToOps(delta, QuillFormattingStyle.monospace); + return applyStyleToOps(delta, QuillFormattingStyle.monospace, attributes); } return delta; }; -export const matchSpoiler = (node: HTMLElement, delta: Delta): Delta => { +export const matchSpoiler: Matcher = ( + node: HTMLElement, + delta: Delta, + attributes: AttributeMap +): Delta => { const classes = [ 'quill--spoiler', 'MessageTextRenderer__formatting--spoiler', @@ -74,7 +101,7 @@ export const matchSpoiler = (node: HTMLElement, delta: Delta): Delta => { node.classList.contains(classes[1]) || node.classList.contains(classes[2])) ) { - return applyStyleToOps(delta, QuillFormattingStyle.spoiler); + return applyStyleToOps(delta, QuillFormattingStyle.spoiler, attributes); } return delta; }; diff --git a/ts/quill/mentions/matchers.ts b/ts/quill/mentions/matchers.ts index 526663816f5..6bbc8cb9b6c 100644 --- a/ts/quill/mentions/matchers.ts +++ b/ts/quill/mentions/matchers.ts @@ -3,11 +3,15 @@ import Delta from 'quill-delta'; import type { RefObject } from 'react'; +import type { Matcher, AttributeMap } from 'quill'; + import type { MemberRepository } from '../memberRepository'; -export const matchMention = +export const matchMention: ( + memberRepositoryRef: RefObject +) => Matcher = (memberRepositoryRef: RefObject) => - (node: HTMLElement, delta: Delta): Delta => { + (node: HTMLElement, delta: Delta, attributes: AttributeMap): Delta => { const memberRepository = memberRepositoryRef.current; if (memberRepository) { @@ -18,15 +22,18 @@ export const matchMention = const conversation = memberRepository.getMemberById(id); if (conversation && conversation.uuid) { - return new Delta().insert({ - mention: { - title, - uuid: conversation.uuid, + return new Delta().insert( + { + mention: { + title, + uuid: conversation.uuid, + }, }, - }); + attributes + ); } - return new Delta().insert(`@${title}`); + return new Delta().insert(`@${title}`, attributes); } if (node.classList.contains('mention-blot')) { @@ -34,15 +41,18 @@ export const matchMention = const conversation = memberRepository.getMemberByUuid(uuid); if (conversation && conversation.uuid) { - return new Delta().insert({ - mention: { - title: title || conversation.title, - uuid: conversation.uuid, + return new Delta().insert( + { + mention: { + title: title || conversation.title, + uuid: conversation.uuid, + }, }, - }); + attributes + ); } - return new Delta().insert(`@${title}`); + return new Delta().insert(`@${title}`, attributes); } } diff --git a/ts/quill/signal-clipboard/index.ts b/ts/quill/signal-clipboard/index.ts index ffd86c907a1..a8a39b25284 100644 --- a/ts/quill/signal-clipboard/index.ts +++ b/ts/quill/signal-clipboard/index.ts @@ -4,17 +4,19 @@ import type Quill from 'quill'; import Delta from 'quill-delta'; -const replaceAngleBrackets = (text: string) => { +const prepareText = (text: string) => { const entities: Array<[RegExp, string]> = [ [/&/g, '&'], [//g, '>'], ]; - return entities.reduce( + const escapedEntities = entities.reduce( (acc, [re, replaceValue]) => acc.replace(re, replaceValue), text ); + + return `${escapedEntities}`; }; export class SignalClipboard { @@ -53,7 +55,7 @@ export class SignalClipboard { const clipboardDelta = signal ? clipboard.convert(signal) - : clipboard.convert(replaceAngleBrackets(text)); + : clipboard.convert(prepareText(text)); const { scrollTop } = this.quill.scrollingContainer; diff --git a/ts/quill/types.d.ts b/ts/quill/types.d.ts index cb661d307e6..2e046c0857b 100644 --- a/ts/quill/types.d.ts +++ b/ts/quill/types.d.ts @@ -25,6 +25,16 @@ declare module 'quill' { shortKey?: boolean; } + export type AttributeMap = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; + }; + export type Matcher = ( + node: HTMLElement, + delta: UpdatedDelta, + attributes: AttributeMap + ) => UpdatedDelta; + export type UpdatedTextChangeHandler = ( delta: UpdatedDelta, oldContents: UpdatedDelta, diff --git a/ts/quill/util.ts b/ts/quill/util.ts index 2a5a92cb3da..0e2263d1aeb 100644 --- a/ts/quill/util.ts +++ b/ts/quill/util.ts @@ -3,7 +3,7 @@ import emojiRegex from 'emoji-regex'; import Delta from 'quill-delta'; -import type { LeafBlot, DeltaOperation } from 'quill'; +import type { LeafBlot, DeltaOperation, AttributeMap } from 'quill'; import type Op from 'quill-delta/dist/Op'; import type { @@ -387,7 +387,10 @@ export const insertMentionOps = ( return ops; }; -export const insertEmojiOps = (incomingOps: ReadonlyArray): Array => { +export const insertEmojiOps = ( + incomingOps: ReadonlyArray, + existingAttributes: AttributeMap +): Array => { return incomingOps.reduce((ops, op) => { if (typeof op.insert === 'string') { const text = op.insert; @@ -400,7 +403,10 @@ export const insertEmojiOps = (incomingOps: ReadonlyArray): Array => { while ((match = re.exec(text))) { const [emoji] = match; ops.push({ insert: text.slice(index, match.index), attributes }); - ops.push({ insert: { emoji }, attributes }); + ops.push({ + insert: { emoji }, + attributes: { ...existingAttributes, ...attributes }, + }); index = match.index + emoji.length; } diff --git a/ts/test-node/quill/mentions/matchers_test.ts b/ts/test-node/quill/mentions/matchers_test.ts index a1adaf3abc5..f5e0139575e 100644 --- a/ts/test-node/quill/mentions/matchers_test.ts +++ b/ts/test-node/quill/mentions/matchers_test.ts @@ -90,25 +90,29 @@ const EMPTY_DELTA = new Delta(); describe('matchMention', () => { it('handles an AtMentionify from clipboard', () => { + const existingAttributes = { italic: true }; const result = matcher( createMockAtMentionElement({ id: memberMahershala.id, title: memberMahershala.title, }), - EMPTY_DELTA + EMPTY_DELTA, + existingAttributes ); const { ops } = result; assert.isNotEmpty(ops); const [op] = ops; - const { insert } = op; + const { insert, attributes } = op; if (isMention(insert)) { const { title, uuid } = insert.mention; assert.equal(title, memberMahershala.title); assert.equal(uuid, memberMahershala.uuid); + + assert.deepEqual(existingAttributes, attributes, 'attributes'); } else { assert.fail('insert is invalid'); } @@ -120,7 +124,8 @@ describe('matchMention', () => { uuid: memberMahershala.uuid || '', title: memberMahershala.title, }), - EMPTY_DELTA + EMPTY_DELTA, + {} ); const { ops } = result; @@ -145,7 +150,8 @@ describe('matchMention', () => { id: 'florp', title: 'Nonexistent', }), - EMPTY_DELTA + EMPTY_DELTA, + {} ); const { ops } = result; @@ -167,7 +173,8 @@ describe('matchMention', () => { uuid: 'florp', title: 'Nonexistent', }), - EMPTY_DELTA + EMPTY_DELTA, + {} ); const { ops } = result; @@ -184,7 +191,7 @@ describe('matchMention', () => { }); it('passes other clipboard elements through', () => { - const result = matcher(createMockElement('ignore', {}), EMPTY_DELTA); + const result = matcher(createMockElement('ignore', {}), EMPTY_DELTA, {}); assert.equal(result, EMPTY_DELTA); }); });