[signalapp/Signal-Desktop#7512] Improve copy-paste formatting inheritance

Co-authored-by: Brian Harder <briankharder@gmail.com>
This commit is contained in:
trevor-signal
2025-10-31 11:48:32 -04:00
committed by GitHub
parent c7bf8555c0
commit ab2b74e774
2 changed files with 225 additions and 27 deletions

View File

@@ -80,33 +80,41 @@ export class SignalClipboard {
}
const { ops } = this.quill.getContents(selection.index, selection.length);
// Only enable formatting on the pasted text if the entire selection has it enabled!
const formats =
selection.length === 0
? this.quill.getFormat(selection.index)
: {
[QuillFormattingStyle.bold]: FormattingMenu.isStyleEnabledForOps(
ops,
QuillFormattingStyle.bold
),
[QuillFormattingStyle.italic]: FormattingMenu.isStyleEnabledForOps(
ops,
QuillFormattingStyle.italic
),
[QuillFormattingStyle.monospace]:
FormattingMenu.isStyleEnabledForOps(
ops,
QuillFormattingStyle.monospace
),
[QuillFormattingStyle.spoiler]: FormattingMenu.isStyleEnabledForOps(
ops,
QuillFormattingStyle.spoiler
),
[QuillFormattingStyle.strike]: FormattingMenu.isStyleEnabledForOps(
ops,
QuillFormattingStyle.strike
),
};
// Check if we're selecting all content
const totalLength = this.quill.getLength();
const isSelectingAll = selection.length >= totalLength - 1;
let formats: Record<string, unknown>;
if (selection.length === 0) {
formats = this.quill.getFormat(selection.index);
} else if (isSelectingAll) {
// No formatting for select-all
formats = {};
} else {
formats = {
[QuillFormattingStyle.bold]: FormattingMenu.isStyleEnabledForOps(
ops,
QuillFormattingStyle.bold
),
[QuillFormattingStyle.italic]: FormattingMenu.isStyleEnabledForOps(
ops,
QuillFormattingStyle.italic
),
[QuillFormattingStyle.monospace]: FormattingMenu.isStyleEnabledForOps(
ops,
QuillFormattingStyle.monospace
),
[QuillFormattingStyle.spoiler]: FormattingMenu.isStyleEnabledForOps(
ops,
QuillFormattingStyle.spoiler
),
[QuillFormattingStyle.strike]: FormattingMenu.isStyleEnabledForOps(
ops,
QuillFormattingStyle.strike
),
};
}
const clipboardDelta = signal
? clipboard.convert({ html: signal }, formats)
: new Delta(insertEmojiOps(clipboard.convert({ text }, formats).ops, {}));

View File

@@ -0,0 +1,190 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { Delta } from '@signalapp/quill-cjs';
import type Quill from '@signalapp/quill-cjs';
import { SignalClipboard } from '../../quill/signal-clipboard/index.dom.js';
import { QuillFormattingStyle } from '../../quill/formatting/menu.dom.js';
class MockQuill {
public root: HTMLElement;
public clipboard: {
convert: (data: unknown, formats: Record<string, unknown>) => Delta;
};
public selection: {
getRange: () => Array<unknown> | [null];
update: (mode: string) => void;
};
public getContents: (
index: number,
length: number
) => { ops: Array<unknown> };
public getSelection: () => { index: number; length: number } | null;
public getLength: () => number;
public getFormat: (index: number) => Record<string, unknown>;
public updateContents: (delta: Delta, source: string) => void;
public setSelection: (index: number, length: number, mode: string) => void;
public scrollSelectionIntoView: () => void;
public focus: () => void;
constructor() {
this.root = document.createElement('div');
this.clipboard = {
convert: (_data: unknown, formats: Record<string, unknown>) => {
// Mock clipboard conversion - returns delta
const text = 'test';
return new Delta([{ insert: text, attributes: formats }]);
},
};
this.selection = {
getRange: () => [null],
update: () => {
// Placeholder for linter
},
};
this.getContents = (_index: number, _length: number) => ({ ops: [] });
this.getSelection = () => ({ index: 0, length: 0 });
this.getLength = () => 1;
this.getFormat = () => ({});
this.updateContents = () => {
// Placeholder for linter
};
this.setSelection = () => {
// Placeholder for linter
};
this.scrollSelectionIntoView = () => {
// Placeholder for linter
};
this.focus = () => {
// Placeholder for linter
};
}
}
function createMockClipboardEvent(
textData: string | null = null,
signalData: string | null = null
): ClipboardEvent {
const event = new Event('paste') as ClipboardEvent;
Object.defineProperty(event, 'clipboardData', {
value: {
getData: (format: string) => {
if (format === 'text/plain') {
return textData || '';
}
if (format === 'text/signal') {
return signalData || '';
}
return '';
},
files: null,
} as unknown as DataTransfer,
writable: false,
});
return event;
}
function createMockQuillWithContent(
content: string,
hasStrike: boolean = false
): MockQuill {
const mockQuill = new MockQuill();
mockQuill.getContents = () => ({
ops: [
{
insert: content,
attributes: hasStrike ? { [QuillFormattingStyle.strike]: true } : {},
},
],
});
mockQuill.getLength = () => content.length + 1;
return mockQuill;
}
describe('SignalClipboard', () => {
let mockQuill: MockQuill;
let clipboard: SignalClipboard;
beforeEach(() => {
mockQuill = new MockQuill();
clipboard = new SignalClipboard(mockQuill as unknown as Quill, {
isDisabled: false,
});
});
describe('onCapturePaste', () => {
describe('when pasting plain text', () => {
it('should not inherit strikethrough formatting from selected text', () => {
const content = 'Hello world';
mockQuill = createMockQuillWithContent(content, true);
clipboard = new SignalClipboard(mockQuill as unknown as Quill, {
isDisabled: false,
});
// Select all
mockQuill.getSelection = () => ({ index: 0, length: content.length });
// Conversion to delta
let capturedFormats: Record<string, unknown> | null = null;
mockQuill.clipboard.convert = (
_data: unknown,
formats: Record<string, unknown>
) => {
capturedFormats = formats;
return new Delta([{ insert: 'test', attributes: formats }]);
};
// Paste
const pasteEvent = createMockClipboardEvent('New text', null);
clipboard.onCapturePaste(pasteEvent);
// Assert no formatting
assert.deepEqual(capturedFormats, {});
});
it('should not inherit any formatting from selected text', () => {
const content = 'Hello world';
mockQuill = createMockQuillWithContent(content, false);
mockQuill.getContents = () => ({
ops: [
{
insert: content,
attributes: {
[QuillFormattingStyle.bold]: true,
[QuillFormattingStyle.italic]: true,
},
},
],
});
clipboard = new SignalClipboard(mockQuill as unknown as Quill, {
isDisabled: false,
});
// Select all content
mockQuill.getSelection = () => ({ index: 0, length: content.length });
// Conversion to delta
let capturedFormats: Record<string, unknown> | null = null;
mockQuill.clipboard.convert = (
_data: unknown,
formats: Record<string, unknown>
) => {
capturedFormats = formats;
return new Delta([{ insert: 'test', attributes: formats }]);
};
// Paste
const pasteEvent = createMockClipboardEvent('New text', null);
clipboard.onCapturePaste(pasteEvent);
// Assert no formatting
assert.deepEqual(capturedFormats, {});
});
});
});
});