Apply existing formatting to pasted content, preserve whitespace

This commit is contained in:
Scott Nonnenberg 2023-08-04 09:29:47 -07:00 committed by GitHub
parent f597f15faf
commit e3ffc70389
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 191 additions and 53 deletions

View file

@ -1,5 +1,5 @@
diff --git a/node_modules/quill/dist/quill.js b/node_modules/quill/dist/quill.js 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 --- a/node_modules/quill/dist/quill.js
+++ b/node_modules/quill/dist/quill.js +++ b/node_modules/quill/dist/quill.js
@@ -8896,7 +8896,8 @@ var debug = (0, _logger2.default)('quill:clipboard'); @@ -8896,7 +8896,8 @@ var debug = (0, _logger2.default)('quill:clipboard');
@ -27,7 +27,7 @@ index 811b3d0..1082f2a 100644
_this.matchers = []; _this.matchers = [];
CLIPBOARD_CONFIG.concat(_this.options.matchers).forEach(function (_ref) { CLIPBOARD_CONFIG.concat(_this.options.matchers).forEach(function (_ref) {
var _ref2 = _slicedToArray(_ref, 2), var _ref2 = _slicedToArray(_ref, 2),
@@ -8941,15 +8942,18 @@ var Clipboard = function (_Module) { @@ -8941,28 +8942,33 @@ var Clipboard = function (_Module) {
key: 'convert', key: 'convert',
value: function convert(html) { value: function convert(html) {
if (typeof html === 'string') { if (typeof html === 'string') {
@ -52,7 +52,14 @@ index 811b3d0..1082f2a 100644
var _prepareMatching = this.prepareMatching(), var _prepareMatching = this.prepareMatching(),
_prepareMatching2 = _slicedToArray(_prepareMatching, 2), _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)); delta = delta.compose(new _quillDelta2.default().retain(delta.length() - 1).delete(1));
} }
debug.log('convert', this.container.innerHTML, delta); debug.log('convert', this.container.innerHTML, delta);
@ -62,7 +69,7 @@ index 811b3d0..1082f2a 100644
return delta; return delta;
} }
}, { }, {
@@ -9056,9 +9061,10 @@ function applyFormat(delta, format, value) { @@ -9056,9 +9062,10 @@ function applyFormat(delta, format, value) {
} }
function computeStyle(node) { function computeStyle(node) {
@ -76,7 +83,7 @@ index 811b3d0..1082f2a 100644
} }
function deltaEndsWith(delta, text) { function deltaEndsWith(delta, text) {
@@ -9074,7 +9080,8 @@ function deltaEndsWith(delta, text) { @@ -9074,24 +9081,30 @@ function deltaEndsWith(delta, text) {
function isLine(node) { function isLine(node) {
if (node.childNodes.length === 0) return false; // Exclude embed blocks if (node.childNodes.length === 0) return false; // Exclude embed blocks
var style = computeStyle(node); 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'; + return ['block', 'list-item'].indexOf(style.display) > -1 || node.nodeName === 'DIV' || node.nodeName === 'P' || node.nodeName === 'TIME';
} }
function traverse(node, elementMatchers, textMatchers) { -function traverse(node, elementMatchers, textMatchers) {
@@ -9177,8 +9184,10 @@ function matchIndent(node, delta) { +// 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) { function matchNewline(node, delta) {
@ -99,3 +133,22 @@ index 811b3d0..1082f2a 100644
delta.insert('\n'); 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 <o:p>&nbsp;</o:p>
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

View file

@ -213,7 +213,7 @@ export function CompositionInput(props: Props): React.ReactElement {
}, []); }, []);
const nodes = collapseRangeTree({ tree, text }); const nodes = collapseRangeTree({ tree, text });
const opsWithFormattingAndMentions = insertFormattingAndMentionsOps(nodes); const opsWithFormattingAndMentions = insertFormattingAndMentionsOps(nodes);
const opsWithEmojis = insertEmojiOps(opsWithFormattingAndMentions); const opsWithEmojis = insertEmojiOps(opsWithFormattingAndMentions, {});
return new Delta(opsWithEmojis); return new Delta(opsWithEmojis);
}; };

View file

@ -2,33 +2,56 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import Delta from 'quill-delta'; import Delta from 'quill-delta';
import type { Matcher, AttributeMap } from 'quill';
import { insertEmojiOps } from '../util'; import { insertEmojiOps } from '../util';
export const matchEmojiImage = (node: Element, delta: Delta): Delta => { export const matchEmojiImage: Matcher = (
node: Element,
delta: Delta,
attributes: AttributeMap
): Delta => {
if ( if (
node.classList.contains('emoji') || node.classList.contains('emoji') ||
node.classList.contains('module-emoji__image--16px') node.classList.contains('module-emoji__image--16px')
) { ) {
const emoji = node.getAttribute('aria-label'); const emoji = node.getAttribute('aria-label');
return new Delta().insert({ emoji }); return new Delta().insert({ emoji }, attributes);
} }
return delta; 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')) { if (node.classList.contains('emoji-blot')) {
const { emoji } = node.dataset; const { emoji } = node.dataset;
return new Delta().insert({ emoji }); return new Delta().insert({ emoji }, attributes);
} }
return delta; return delta;
}; };
export const matchEmojiText = (node: Text): Delta => { export const matchEmojiText: Matcher = (
if (node.data.replace(/(\n|\r\n)/g, '') === '') { node: HTMLElement,
_delta: Delta,
attributes: AttributeMap
): Delta => {
if (!('data' in node)) {
return new Delta(); 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));
}; };

View file

@ -2,13 +2,20 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import Delta from 'quill-delta'; import Delta from 'quill-delta';
import type { Matcher, AttributeMap } from 'quill';
import { QuillFormattingStyle } from './menu'; import { QuillFormattingStyle } from './menu';
function applyStyleToOps(delta: Delta, style: QuillFormattingStyle): Delta { function applyStyleToOps(
delta: Delta,
style: QuillFormattingStyle,
attributes: AttributeMap
): Delta {
return new Delta( return new Delta(
delta.map(op => ({ delta.map(op => ({
...op, ...op,
attributes: { attributes: {
...attributes,
...op.attributes, ...op.attributes,
[style]: true, [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) { if (delta.length() > 0) {
return applyStyleToOps(delta, QuillFormattingStyle.bold); return applyStyleToOps(delta, QuillFormattingStyle.bold, attributes);
} }
return delta; 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) { if (delta.length() > 0) {
return applyStyleToOps(delta, QuillFormattingStyle.italic); return applyStyleToOps(delta, QuillFormattingStyle.italic, attributes);
} }
return delta; 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) { if (delta.length() > 0) {
return applyStyleToOps(delta, QuillFormattingStyle.strike); return applyStyleToOps(delta, QuillFormattingStyle.strike, attributes);
} }
return delta; return delta;
}; };
export const matchMonospace = (node: HTMLElement, delta: Delta): Delta => { export const matchMonospace: Matcher = (
node: HTMLElement,
delta: Delta,
attributes: AttributeMap
): Delta => {
const classes = [ const classes = [
'MessageTextRenderer__formatting--monospace', 'MessageTextRenderer__formatting--monospace',
'quill--monospace', 'quill--monospace',
@ -55,13 +78,17 @@ export const matchMonospace = (node: HTMLElement, delta: Delta): Delta => {
node.classList.contains(classes[1]) || node.classList.contains(classes[1]) ||
node.attributes.getNamedItem('style')?.value?.includes(fontFamily)) node.attributes.getNamedItem('style')?.value?.includes(fontFamily))
) { ) {
return applyStyleToOps(delta, QuillFormattingStyle.monospace); return applyStyleToOps(delta, QuillFormattingStyle.monospace, attributes);
} }
return delta; return delta;
}; };
export const matchSpoiler = (node: HTMLElement, delta: Delta): Delta => { export const matchSpoiler: Matcher = (
node: HTMLElement,
delta: Delta,
attributes: AttributeMap
): Delta => {
const classes = [ const classes = [
'quill--spoiler', 'quill--spoiler',
'MessageTextRenderer__formatting--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[1]) ||
node.classList.contains(classes[2])) node.classList.contains(classes[2]))
) { ) {
return applyStyleToOps(delta, QuillFormattingStyle.spoiler); return applyStyleToOps(delta, QuillFormattingStyle.spoiler, attributes);
} }
return delta; return delta;
}; };

View file

@ -3,11 +3,15 @@
import Delta from 'quill-delta'; import Delta from 'quill-delta';
import type { RefObject } from 'react'; import type { RefObject } from 'react';
import type { Matcher, AttributeMap } from 'quill';
import type { MemberRepository } from '../memberRepository'; import type { MemberRepository } from '../memberRepository';
export const matchMention = export const matchMention: (
memberRepositoryRef: RefObject<MemberRepository>
) => Matcher =
(memberRepositoryRef: RefObject<MemberRepository>) => (memberRepositoryRef: RefObject<MemberRepository>) =>
(node: HTMLElement, delta: Delta): Delta => { (node: HTMLElement, delta: Delta, attributes: AttributeMap): Delta => {
const memberRepository = memberRepositoryRef.current; const memberRepository = memberRepositoryRef.current;
if (memberRepository) { if (memberRepository) {
@ -18,15 +22,18 @@ export const matchMention =
const conversation = memberRepository.getMemberById(id); const conversation = memberRepository.getMemberById(id);
if (conversation && conversation.uuid) { if (conversation && conversation.uuid) {
return new Delta().insert({ return new Delta().insert(
mention: { {
title, mention: {
uuid: conversation.uuid, title,
uuid: conversation.uuid,
},
}, },
}); attributes
);
} }
return new Delta().insert(`@${title}`); return new Delta().insert(`@${title}`, attributes);
} }
if (node.classList.contains('mention-blot')) { if (node.classList.contains('mention-blot')) {
@ -34,15 +41,18 @@ export const matchMention =
const conversation = memberRepository.getMemberByUuid(uuid); const conversation = memberRepository.getMemberByUuid(uuid);
if (conversation && conversation.uuid) { if (conversation && conversation.uuid) {
return new Delta().insert({ return new Delta().insert(
mention: { {
title: title || conversation.title, mention: {
uuid: conversation.uuid, title: title || conversation.title,
uuid: conversation.uuid,
},
}, },
}); attributes
);
} }
return new Delta().insert(`@${title}`); return new Delta().insert(`@${title}`, attributes);
} }
} }

View file

@ -4,17 +4,19 @@
import type Quill from 'quill'; import type Quill from 'quill';
import Delta from 'quill-delta'; import Delta from 'quill-delta';
const replaceAngleBrackets = (text: string) => { const prepareText = (text: string) => {
const entities: Array<[RegExp, string]> = [ const entities: Array<[RegExp, string]> = [
[/&/g, '&amp;'], [/&/g, '&amp;'],
[/</g, '&lt;'], [/</g, '&lt;'],
[/>/g, '&gt;'], [/>/g, '&gt;'],
]; ];
return entities.reduce( const escapedEntities = entities.reduce(
(acc, [re, replaceValue]) => acc.replace(re, replaceValue), (acc, [re, replaceValue]) => acc.replace(re, replaceValue),
text text
); );
return `<span>${escapedEntities}</span>`;
}; };
export class SignalClipboard { export class SignalClipboard {
@ -53,7 +55,7 @@ export class SignalClipboard {
const clipboardDelta = signal const clipboardDelta = signal
? clipboard.convert(signal) ? clipboard.convert(signal)
: clipboard.convert(replaceAngleBrackets(text)); : clipboard.convert(prepareText(text));
const { scrollTop } = this.quill.scrollingContainer; const { scrollTop } = this.quill.scrollingContainer;

10
ts/quill/types.d.ts vendored
View file

@ -25,6 +25,16 @@ declare module 'quill' {
shortKey?: boolean; 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 = ( export type UpdatedTextChangeHandler = (
delta: UpdatedDelta, delta: UpdatedDelta,
oldContents: UpdatedDelta, oldContents: UpdatedDelta,

View file

@ -3,7 +3,7 @@
import emojiRegex from 'emoji-regex'; import emojiRegex from 'emoji-regex';
import Delta from 'quill-delta'; 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 Op from 'quill-delta/dist/Op';
import type { import type {
@ -387,7 +387,10 @@ export const insertMentionOps = (
return ops; return ops;
}; };
export const insertEmojiOps = (incomingOps: ReadonlyArray<Op>): Array<Op> => { export const insertEmojiOps = (
incomingOps: ReadonlyArray<Op>,
existingAttributes: AttributeMap
): Array<Op> => {
return incomingOps.reduce((ops, op) => { return incomingOps.reduce((ops, op) => {
if (typeof op.insert === 'string') { if (typeof op.insert === 'string') {
const text = op.insert; const text = op.insert;
@ -400,7 +403,10 @@ export const insertEmojiOps = (incomingOps: ReadonlyArray<Op>): Array<Op> => {
while ((match = re.exec(text))) { while ((match = re.exec(text))) {
const [emoji] = match; const [emoji] = match;
ops.push({ insert: text.slice(index, match.index), attributes }); 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; index = match.index + emoji.length;
} }

View file

@ -90,25 +90,29 @@ const EMPTY_DELTA = new Delta();
describe('matchMention', () => { describe('matchMention', () => {
it('handles an AtMentionify from clipboard', () => { it('handles an AtMentionify from clipboard', () => {
const existingAttributes = { italic: true };
const result = matcher( const result = matcher(
createMockAtMentionElement({ createMockAtMentionElement({
id: memberMahershala.id, id: memberMahershala.id,
title: memberMahershala.title, title: memberMahershala.title,
}), }),
EMPTY_DELTA EMPTY_DELTA,
existingAttributes
); );
const { ops } = result; const { ops } = result;
assert.isNotEmpty(ops); assert.isNotEmpty(ops);
const [op] = ops; const [op] = ops;
const { insert } = op; const { insert, attributes } = op;
if (isMention(insert)) { if (isMention(insert)) {
const { title, uuid } = insert.mention; const { title, uuid } = insert.mention;
assert.equal(title, memberMahershala.title); assert.equal(title, memberMahershala.title);
assert.equal(uuid, memberMahershala.uuid); assert.equal(uuid, memberMahershala.uuid);
assert.deepEqual(existingAttributes, attributes, 'attributes');
} else { } else {
assert.fail('insert is invalid'); assert.fail('insert is invalid');
} }
@ -120,7 +124,8 @@ describe('matchMention', () => {
uuid: memberMahershala.uuid || '', uuid: memberMahershala.uuid || '',
title: memberMahershala.title, title: memberMahershala.title,
}), }),
EMPTY_DELTA EMPTY_DELTA,
{}
); );
const { ops } = result; const { ops } = result;
@ -145,7 +150,8 @@ describe('matchMention', () => {
id: 'florp', id: 'florp',
title: 'Nonexistent', title: 'Nonexistent',
}), }),
EMPTY_DELTA EMPTY_DELTA,
{}
); );
const { ops } = result; const { ops } = result;
@ -167,7 +173,8 @@ describe('matchMention', () => {
uuid: 'florp', uuid: 'florp',
title: 'Nonexistent', title: 'Nonexistent',
}), }),
EMPTY_DELTA EMPTY_DELTA,
{}
); );
const { ops } = result; const { ops } = result;
@ -184,7 +191,7 @@ describe('matchMention', () => {
}); });
it('passes other clipboard elements through', () => { 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); assert.equal(result, EMPTY_DELTA);
}); });
}); });