Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improved paste as plain text #7886

Merged
merged 13 commits into from
Sep 11, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 30 additions & 6 deletions packages/ckeditor5-clipboard/src/clipboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/

import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import PastePlainText from './pasteplaintext';

import ClipboardObserver from './clipboardobserver';

Expand Down Expand Up @@ -35,6 +36,13 @@ export default class Clipboard extends Plugin {
return 'Clipboard';
}

/**
* @inheritDoc
*/
static get requires() {
return [ PastePlainText ];
}

/**
* @inheritDoc
*/
Expand Down Expand Up @@ -77,7 +85,11 @@ export default class Clipboard extends Plugin {
content = this._htmlDataProcessor.toView( content );

const eventInfo = new EventInfo( this, 'inputTransformation' );
this.fire( eventInfo, { content, dataTransfer } );
this.fire( eventInfo, {
content,
dataTransfer,
asPlainText: data.asPlainText
} );

// If CKEditor handled the input, do not bubble the original event any further.
// This helps external integrations recognize that fact and act accordingly.
Expand All @@ -103,16 +115,27 @@ export default class Clipboard extends Plugin {
return;
}

// While pasting plain text, apply selection attributes on the text.
if ( isPlainText( modelFragment ) ) {
const node = modelFragment.getChild( 0 );
// Plain text can be determined based on event flag (#7799) or auto detection (#1006). If detected
// preserve selection attributes on pasted items.
if ( data.asPlainText || isPlainTextFragment( modelFragment ) ) {
// Consider only formatting attributes.
const textAttributes = new Map( Array.from( modelDocument.selection.getAttributes() ).filter(
keyValuePair => editor.model.schema.getAttributeProperties( keyValuePair[ 0 ] ).isFormatting
) );

model.change( writer => {
writer.setAttributes( modelDocument.selection.getAttributes(), node );
const range = writer.createRangeIn( modelFragment );

for ( const item of range.getItems() ) {
if ( item.is( '$text' ) || item.is( '$textProxy' ) ) {
writer.setAttributes( textAttributes, item );
}
}
} );
}

model.insertContent( modelFragment );

evt.stop();
}
}, { priority: 'low' } );
Expand Down Expand Up @@ -168,6 +191,7 @@ export default class Clipboard extends Plugin {
* It can be modified by the event listeners. Read more about the clipboard pipelines in
* {@glink framework/guides/deep-dive/clipboard "Clipboard" deep dive}.
* @param {module:clipboard/datatransfer~DataTransfer} data.dataTransfer Data transfer instance.
* @param {Boolean} data.asPlainText If set to `true` content is pasted as plain text.
*/

/**
Expand Down Expand Up @@ -212,7 +236,7 @@ export default class Clipboard extends Plugin {
//
// @param {module:engine/view/documentfragment~DocumentFragment} documentFragment
// @returns {Boolean}
function isPlainText( documentFragment ) {
function isPlainTextFragment( documentFragment ) {
if ( documentFragment.childCount > 1 ) {
return false;
}
Expand Down
49 changes: 49 additions & 0 deletions packages/ckeditor5-clipboard/src/pasteplaintext.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/

/**
* @module clipboard/clipboard
*/

import Plugin from '@ckeditor/ckeditor5-core/src/plugin';

import ClipboardObserver from './clipboardobserver';

/**
* The plugin detects user intentions for pasting plain text.
*
* For example, it detects <kbd>ctrl/cmd</kbd> + <kbd>shift</kbd> + <kbd>ctrl/v</kbd> keystroke.
*
* @extends module:core/plugin~Plugin
*/
export default class PastePlainText extends Plugin {
/**
* @inheritDoc
*/
static get pluginName() {
return 'PastePlainText';
}

/**
* @inheritDoc
*/
init() {
const view = this.editor.editing.view;
const viewDocument = view.document;
let shiftPressed = false;

view.addObserver( ClipboardObserver );

this.listenTo( viewDocument, 'keydown', ( evt, data ) => {
shiftPressed = data.shiftKey;
} );

this.listenTo( viewDocument, 'clipboardInput', ( evt, data ) => {
if ( shiftPressed ) {
data.asPlainText = true;
}
}, { priority: 'high' } );
}
}
93 changes: 93 additions & 0 deletions packages/ckeditor5-clipboard/tests/clipboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,19 @@ describe( 'Clipboard feature', () => {
model = editor.model;

model.schema.extend( '$text', { allowAttributes: 'bold' } );
model.schema.extend( '$text', { allowAttributes: 'test' } );

editor.model.schema.setAttributeProperties( 'bold', { isFormatting: true } );

model.schema.register( 'softBreak', {
allowWhere: '$text',
isInline: true
} );
editor.conversion.for( 'upcast' )
.elementToElement( {
model: 'softBreak',
view: 'br'
} );
} );

it( 'should inherit selection attributes (collapsed selection)', () => {
Expand Down Expand Up @@ -451,6 +464,86 @@ describe( 'Clipboard feature', () => {

expect( getModelData( model ) ).to.equal( '<paragraph><$text bold="true">Bolded foo[]text.</$text></paragraph>' );
} );

it( 'should inherit selection attributes with data.asPlainText switch set', () => {
setModelData( model, '<paragraph><$text bold="true">Bolded []text.</$text></paragraph>' );

const dataTransferMock = createDataTransfer( {
'text/html': 'foo',
'text/plain': 'foo'
} );

viewDocument.fire( 'clipboardInput', {
dataTransfer: dataTransferMock,
asPlainText: true,
stopPropagation() {},
preventDefault() {}
} );

expect( getModelData( model ) ).to.equal( '<paragraph><$text bold="true">Bolded foo[]text.</$text></paragraph>' );
} );

it( 'should discard selection attributes with data.asPlainText switch set to false', () => {
setModelData( model, '<paragraph><$text bold="true">Bolded []text.</$text></paragraph>' );

const dataTransferMock = createDataTransfer( {
'text/html': 'foo<br>bar',
'text/plain': 'foo\nbar'
} );

viewDocument.fire( 'clipboardInput', {
dataTransfer: dataTransferMock,
asPlainText: false,
stopPropagation() {},
preventDefault() {}
} );

expect( getModelData( model ) ).to.equal( '<paragraph><$text bold="true">Bolded </$text>' +
'foo<softBreak></softBreak>bar[]' +
'<$text bold="true">text.</$text></paragraph>' );
} );

it( 'should work if the insertContent event is cancelled', () => {
// (#7887).
setModelData( model, '<paragraph><$text bold="true">Bolded []text.</$text></paragraph>' );

const dataTransferMock = createDataTransfer( {
'text/html': 'foo',
'text/plain': 'foo'
} );

model.on( 'insertContent', event => {
event.stop();
}, { priority: 'high' } );

viewDocument.fire( 'clipboardInput', {
dataTransfer: dataTransferMock,
asPlainText: false,
stopPropagation() {},
preventDefault() {}
} );

expect( getModelData( model ) ).to.equal( '<paragraph><$text bold="true">Bolded []text.</$text></paragraph>' );
} );

it( 'ignores non-formatting text attributes', () => {
setModelData( model, '<paragraph><$text test="true">Bolded []text.</$text></paragraph>' );

const dataTransferMock = createDataTransfer( {
'text/html': 'foo',
'text/plain': 'foo'
} );

viewDocument.fire( 'clipboardInput', {
dataTransfer: dataTransferMock,
asPlainText: false,
stopPropagation() {},
preventDefault() {}
} );

expect( getModelData( model ) ).to.equal(
'<paragraph><$text test="true">Bolded </$text>foo[]<$text test="true">text.</$text></paragraph>' );
} );
} );

function createDataTransfer( data ) {
Expand Down
96 changes: 96 additions & 0 deletions packages/ckeditor5-clipboard/tests/pasteplaintext.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/

import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';

import PastePlainText from '../src/pasteplaintext';
import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';

import { getCode } from '@ckeditor/ckeditor5-utils/src/keyboard';

/* global document */

describe( 'PastePlainText', () => {
let editor, viewDocument;

beforeEach( () => {
return VirtualTestEditor
.create( {
plugins: [ PastePlainText, Paragraph ]
} )
.then( newEditor => {
editor = newEditor;
viewDocument = editor.editing.view.document;
} );
} );

it( 'marks clipboard input as plain text with shift pressed', () => {
const dataTransferMock = createDataTransfer( { 'text/html': '<p>x</p>', 'text/plain': 'y' } );

viewDocument.on( 'clipboardInput', ( event, data ) => {
expect( data.asPlainText ).to.be.true;

// No need for further execution.
event.stop();
} );

viewDocument.fire( 'keydown', {
keyCode: getCode( 'v' ),
shiftKey: true,
ctrlKey: true,
preventDefault: () => {},
domTarget: document.body
} );

viewDocument.fire( 'clipboardInput', {
dataTransfer: dataTransferMock
} );
} );

it( 'ignores clipboard input as plain text when shift was released', () => {
const dataTransferMock = createDataTransfer( { 'text/html': '<p>x</p>', 'text/plain': 'y' } );

viewDocument.on( 'clipboardInput', ( event, data ) => {
expect( data.asPlainText ).to.be.undefined;

// No need for further execution.
event.stop();
} );

viewDocument.fire( 'keydown', {
keyCode: getCode( 'a' ),
shiftKey: true,
preventDefault: () => {},
domTarget: document.body
} );

viewDocument.fire( 'keyup', {
keyCode: getCode( 'a' ),
shiftKey: true,
preventDefault: () => {},
domTarget: document.body
} );

viewDocument.fire( 'keydown', {
keyCode: getCode( 'v' ),
shiftKey: false,
ctrlKey: true,
preventDefault: () => {},
domTarget: document.body
} );

viewDocument.fire( 'clipboardInput', {
dataTransfer: dataTransferMock
} );
} );

function createDataTransfer( data ) {
return {
getData( type ) {
return data[ type ];
}
};
}
} );