Skip to content

Commit ab2b74e

Browse files
[#7512] Improve copy-paste formatting inheritance
Co-authored-by: Brian Harder <[email protected]>
1 parent c7bf855 commit ab2b74e

File tree

2 files changed

+225
-27
lines changed

2 files changed

+225
-27
lines changed

ts/quill/signal-clipboard/index.dom.ts

Lines changed: 35 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -80,33 +80,41 @@ export class SignalClipboard {
8080
}
8181

8282
const { ops } = this.quill.getContents(selection.index, selection.length);
83-
// Only enable formatting on the pasted text if the entire selection has it enabled!
84-
const formats =
85-
selection.length === 0
86-
? this.quill.getFormat(selection.index)
87-
: {
88-
[QuillFormattingStyle.bold]: FormattingMenu.isStyleEnabledForOps(
89-
ops,
90-
QuillFormattingStyle.bold
91-
),
92-
[QuillFormattingStyle.italic]: FormattingMenu.isStyleEnabledForOps(
93-
ops,
94-
QuillFormattingStyle.italic
95-
),
96-
[QuillFormattingStyle.monospace]:
97-
FormattingMenu.isStyleEnabledForOps(
98-
ops,
99-
QuillFormattingStyle.monospace
100-
),
101-
[QuillFormattingStyle.spoiler]: FormattingMenu.isStyleEnabledForOps(
102-
ops,
103-
QuillFormattingStyle.spoiler
104-
),
105-
[QuillFormattingStyle.strike]: FormattingMenu.isStyleEnabledForOps(
106-
ops,
107-
QuillFormattingStyle.strike
108-
),
109-
};
83+
84+
// Check if we're selecting all content
85+
const totalLength = this.quill.getLength();
86+
const isSelectingAll = selection.length >= totalLength - 1;
87+
88+
let formats: Record<string, unknown>;
89+
if (selection.length === 0) {
90+
formats = this.quill.getFormat(selection.index);
91+
} else if (isSelectingAll) {
92+
// No formatting for select-all
93+
formats = {};
94+
} else {
95+
formats = {
96+
[QuillFormattingStyle.bold]: FormattingMenu.isStyleEnabledForOps(
97+
ops,
98+
QuillFormattingStyle.bold
99+
),
100+
[QuillFormattingStyle.italic]: FormattingMenu.isStyleEnabledForOps(
101+
ops,
102+
QuillFormattingStyle.italic
103+
),
104+
[QuillFormattingStyle.monospace]: FormattingMenu.isStyleEnabledForOps(
105+
ops,
106+
QuillFormattingStyle.monospace
107+
),
108+
[QuillFormattingStyle.spoiler]: FormattingMenu.isStyleEnabledForOps(
109+
ops,
110+
QuillFormattingStyle.spoiler
111+
),
112+
[QuillFormattingStyle.strike]: FormattingMenu.isStyleEnabledForOps(
113+
ops,
114+
QuillFormattingStyle.strike
115+
),
116+
};
117+
}
110118
const clipboardDelta = signal
111119
? clipboard.convert({ html: signal }, formats)
112120
: new Delta(insertEmojiOps(clipboard.convert({ text }, formats).ops, {}));
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
// Copyright 2024 Signal Messenger, LLC
2+
// SPDX-License-Identifier: AGPL-3.0-only
3+
4+
import { assert } from 'chai';
5+
import { Delta } from '@signalapp/quill-cjs';
6+
import type Quill from '@signalapp/quill-cjs';
7+
8+
import { SignalClipboard } from '../../quill/signal-clipboard/index.dom.js';
9+
import { QuillFormattingStyle } from '../../quill/formatting/menu.dom.js';
10+
11+
class MockQuill {
12+
public root: HTMLElement;
13+
public clipboard: {
14+
convert: (data: unknown, formats: Record<string, unknown>) => Delta;
15+
};
16+
public selection: {
17+
getRange: () => Array<unknown> | [null];
18+
update: (mode: string) => void;
19+
};
20+
public getContents: (
21+
index: number,
22+
length: number
23+
) => { ops: Array<unknown> };
24+
public getSelection: () => { index: number; length: number } | null;
25+
public getLength: () => number;
26+
public getFormat: (index: number) => Record<string, unknown>;
27+
public updateContents: (delta: Delta, source: string) => void;
28+
public setSelection: (index: number, length: number, mode: string) => void;
29+
public scrollSelectionIntoView: () => void;
30+
public focus: () => void;
31+
32+
constructor() {
33+
this.root = document.createElement('div');
34+
this.clipboard = {
35+
convert: (_data: unknown, formats: Record<string, unknown>) => {
36+
// Mock clipboard conversion - returns delta
37+
const text = 'test';
38+
return new Delta([{ insert: text, attributes: formats }]);
39+
},
40+
};
41+
this.selection = {
42+
getRange: () => [null],
43+
update: () => {
44+
// Placeholder for linter
45+
},
46+
};
47+
this.getContents = (_index: number, _length: number) => ({ ops: [] });
48+
this.getSelection = () => ({ index: 0, length: 0 });
49+
this.getLength = () => 1;
50+
this.getFormat = () => ({});
51+
this.updateContents = () => {
52+
// Placeholder for linter
53+
};
54+
this.setSelection = () => {
55+
// Placeholder for linter
56+
};
57+
this.scrollSelectionIntoView = () => {
58+
// Placeholder for linter
59+
};
60+
this.focus = () => {
61+
// Placeholder for linter
62+
};
63+
}
64+
}
65+
66+
function createMockClipboardEvent(
67+
textData: string | null = null,
68+
signalData: string | null = null
69+
): ClipboardEvent {
70+
const event = new Event('paste') as ClipboardEvent;
71+
Object.defineProperty(event, 'clipboardData', {
72+
value: {
73+
getData: (format: string) => {
74+
if (format === 'text/plain') {
75+
return textData || '';
76+
}
77+
if (format === 'text/signal') {
78+
return signalData || '';
79+
}
80+
return '';
81+
},
82+
files: null,
83+
} as unknown as DataTransfer,
84+
writable: false,
85+
});
86+
return event;
87+
}
88+
89+
function createMockQuillWithContent(
90+
content: string,
91+
hasStrike: boolean = false
92+
): MockQuill {
93+
const mockQuill = new MockQuill();
94+
95+
mockQuill.getContents = () => ({
96+
ops: [
97+
{
98+
insert: content,
99+
attributes: hasStrike ? { [QuillFormattingStyle.strike]: true } : {},
100+
},
101+
],
102+
});
103+
104+
mockQuill.getLength = () => content.length + 1;
105+
106+
return mockQuill;
107+
}
108+
109+
describe('SignalClipboard', () => {
110+
let mockQuill: MockQuill;
111+
let clipboard: SignalClipboard;
112+
113+
beforeEach(() => {
114+
mockQuill = new MockQuill();
115+
clipboard = new SignalClipboard(mockQuill as unknown as Quill, {
116+
isDisabled: false,
117+
});
118+
});
119+
120+
describe('onCapturePaste', () => {
121+
describe('when pasting plain text', () => {
122+
it('should not inherit strikethrough formatting from selected text', () => {
123+
const content = 'Hello world';
124+
mockQuill = createMockQuillWithContent(content, true);
125+
clipboard = new SignalClipboard(mockQuill as unknown as Quill, {
126+
isDisabled: false,
127+
});
128+
129+
// Select all
130+
mockQuill.getSelection = () => ({ index: 0, length: content.length });
131+
132+
// Conversion to delta
133+
let capturedFormats: Record<string, unknown> | null = null;
134+
mockQuill.clipboard.convert = (
135+
_data: unknown,
136+
formats: Record<string, unknown>
137+
) => {
138+
capturedFormats = formats;
139+
return new Delta([{ insert: 'test', attributes: formats }]);
140+
};
141+
142+
// Paste
143+
const pasteEvent = createMockClipboardEvent('New text', null);
144+
clipboard.onCapturePaste(pasteEvent);
145+
146+
// Assert no formatting
147+
assert.deepEqual(capturedFormats, {});
148+
});
149+
150+
it('should not inherit any formatting from selected text', () => {
151+
const content = 'Hello world';
152+
mockQuill = createMockQuillWithContent(content, false);
153+
mockQuill.getContents = () => ({
154+
ops: [
155+
{
156+
insert: content,
157+
attributes: {
158+
[QuillFormattingStyle.bold]: true,
159+
[QuillFormattingStyle.italic]: true,
160+
},
161+
},
162+
],
163+
});
164+
clipboard = new SignalClipboard(mockQuill as unknown as Quill, {
165+
isDisabled: false,
166+
});
167+
168+
// Select all content
169+
mockQuill.getSelection = () => ({ index: 0, length: content.length });
170+
171+
// Conversion to delta
172+
let capturedFormats: Record<string, unknown> | null = null;
173+
mockQuill.clipboard.convert = (
174+
_data: unknown,
175+
formats: Record<string, unknown>
176+
) => {
177+
capturedFormats = formats;
178+
return new Delta([{ insert: 'test', attributes: formats }]);
179+
};
180+
181+
// Paste
182+
const pasteEvent = createMockClipboardEvent('New text', null);
183+
clipboard.onCapturePaste(pasteEvent);
184+
185+
// Assert no formatting
186+
assert.deepEqual(capturedFormats, {});
187+
});
188+
});
189+
});
190+
});

0 commit comments

Comments
 (0)