diff --git a/ts/quill/signal-clipboard/index.dom.ts b/ts/quill/signal-clipboard/index.dom.ts index ed62a50c32..c1a56a791f 100644 --- a/ts/quill/signal-clipboard/index.dom.ts +++ b/ts/quill/signal-clipboard/index.dom.ts @@ -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; + 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, {})); diff --git a/ts/test-electron/quill/signal-clipboard_test.dom.ts b/ts/test-electron/quill/signal-clipboard_test.dom.ts new file mode 100644 index 0000000000..a2ac1af3de --- /dev/null +++ b/ts/test-electron/quill/signal-clipboard_test.dom.ts @@ -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) => Delta; + }; + public selection: { + getRange: () => Array | [null]; + update: (mode: string) => void; + }; + public getContents: ( + index: number, + length: number + ) => { ops: Array }; + public getSelection: () => { index: number; length: number } | null; + public getLength: () => number; + public getFormat: (index: number) => Record; + 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) => { + // 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 | null = null; + mockQuill.clipboard.convert = ( + _data: unknown, + formats: Record + ) => { + 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 | null = null; + mockQuill.clipboard.convert = ( + _data: unknown, + formats: Record + ) => { + 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, {}); + }); + }); + }); +});