Apply existing formatting to pasted content, preserve whitespace
This commit is contained in:
@@ -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);
|
||||
};
|
||||
|
@@ -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));
|
||||
};
|
||||
|
@@ -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;
|
||||
};
|
||||
|
@@ -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<MemberRepository>
|
||||
) => Matcher =
|
||||
(memberRepositoryRef: RefObject<MemberRepository>) =>
|
||||
(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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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, '<'],
|
||||
[/>/g, '>'],
|
||||
];
|
||||
|
||||
return entities.reduce(
|
||||
const escapedEntities = entities.reduce(
|
||||
(acc, [re, replaceValue]) => acc.replace(re, replaceValue),
|
||||
text
|
||||
);
|
||||
|
||||
return `<span>${escapedEntities}</span>`;
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
|
10
ts/quill/types.d.ts
vendored
10
ts/quill/types.d.ts
vendored
@@ -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,
|
||||
|
@@ -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<Op>): Array<Op> => {
|
||||
export const insertEmojiOps = (
|
||||
incomingOps: ReadonlyArray<Op>,
|
||||
existingAttributes: AttributeMap
|
||||
): Array<Op> => {
|
||||
return incomingOps.reduce((ops, op) => {
|
||||
if (typeof op.insert === 'string') {
|
||||
const text = op.insert;
|
||||
@@ -400,7 +403,10 @@ export const insertEmojiOps = (incomingOps: ReadonlyArray<Op>): Array<Op> => {
|
||||
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;
|
||||
}
|
||||
|
||||
|
@@ -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);
|
||||
});
|
||||
});
|
||||
|
Reference in New Issue
Block a user