diff --git a/packages/ckeditor5-clipboard/src/clipboardobserver.ts b/packages/ckeditor5-clipboard/src/clipboardobserver.ts index 6bcc25c53e1..5aa427e543b 100644 --- a/packages/ckeditor5-clipboard/src/clipboardobserver.ts +++ b/packages/ckeditor5-clipboard/src/clipboardobserver.ts @@ -19,6 +19,8 @@ import { type ViewRange } from '@ckeditor/ckeditor5-engine'; +import plainTextToHtml from './utils/plaintexttohtml.js'; + /** * Clipboard events observer. * @@ -63,10 +65,20 @@ export default class ClipboardObserver extends DomEventObserver< data.preventDefault(); const targetRanges = data.dropRange ? [ data.dropRange ] : null; + const dataTransfer = data.dataTransfer; + let content = ''; + + if ( dataTransfer.getData( 'text/html' ) ) { + content = dataTransfer.getData( 'text/html' ); + } else if ( dataTransfer.getData( 'text/plain' ) ) { + content = plainTextToHtml( dataTransfer.getData( 'text/plain' ) ); + } + const eventInfo = new EventInfo( viewDocument, type ); viewDocument.fire( eventInfo, { - dataTransfer: data.dataTransfer, + dataTransfer, + content, method: evt.name, targetRanges, target: data.target, @@ -173,9 +185,15 @@ export interface ClipboardInputEventData { targetRanges: Array | null; /** - * The content of clipboard input. + * The content of clipboard input. Defaults to `text/html`. Falls-back to `text/plain`. + */ + content: string | ViewDocumentFragment; + + /** + * Custom data stored by the `clipboardInput` event handlers. Custom properties of this object can be defined and use to + * pass parameters between listeners. Content of this property is passed to the `inputTransformation` event. */ - content?: ViewDocumentFragment; + extraContent?: unknown; } /** diff --git a/packages/ckeditor5-clipboard/src/clipboardpipeline.ts b/packages/ckeditor5-clipboard/src/clipboardpipeline.ts index 4c9a57f6a9c..f20660bf9c5 100644 --- a/packages/ckeditor5-clipboard/src/clipboardpipeline.ts +++ b/packages/ckeditor5-clipboard/src/clipboardpipeline.ts @@ -29,7 +29,6 @@ import ClipboardObserver, { type ViewDocumentClipboardInputEvent } from './clipboardobserver.js'; -import plainTextToHtml from './utils/plaintexttohtml.js'; import normalizeClipboardHtml from './utils/normalizeclipboarddata.js'; import viewToPlainText from './utils/viewtoplaintext.js'; import ClipboardMarkersUtils from './clipboardmarkersutils.js'; @@ -217,29 +216,17 @@ export default class ClipboardPipeline extends Plugin { }, { priority: 'highest' } ); this.listenTo( viewDocument, 'clipboardInput', ( evt, data ) => { - const dataTransfer = data.dataTransfer; - let content: ViewDocumentFragment; - - // Some feature could already inject content in the higher priority event handler (i.e., codeBlock). - if ( data.content ) { - content = data.content; - } else { - let contentData = ''; - - if ( dataTransfer.getData( 'text/html' ) ) { - contentData = normalizeClipboardHtml( dataTransfer.getData( 'text/html' ) ); - } else if ( dataTransfer.getData( 'text/plain' ) ) { - contentData = plainTextToHtml( dataTransfer.getData( 'text/plain' ) ); - } - - content = this.editor.data.htmlProcessor.toView( contentData ); - } + // Some feature could already inject content in the higher priority event handler (i.e., codeBlock, paste from office). + const content = typeof data.content == 'string' ? + this.editor.data.htmlProcessor.toView( normalizeClipboardHtml( data.content ) ) : + data.content; const eventInfo = new EventInfo( this, 'inputTransformation' ); this.fire( eventInfo, { content, - dataTransfer, + extraContent: data.extraContent, + dataTransfer: data.dataTransfer, targetRanges: data.targetRanges, method: data.method as 'paste' | 'drop' } ); @@ -375,6 +362,11 @@ export interface ClipboardInputTransformationData { */ content: ViewDocumentFragment; + /** + * Custom data stored by the `clipboardInput` event handlers. Content of this property is passed from the `clipboardInput` event. + */ + extraContent?: unknown; + /** * The data transfer instance. */ diff --git a/packages/ckeditor5-clipboard/tests/clipboardpipeline.js b/packages/ckeditor5-clipboard/tests/clipboardpipeline.js index 56b720dd382..77e9f4c4f6f 100644 --- a/packages/ckeditor5-clipboard/tests/clipboardpipeline.js +++ b/packages/ckeditor5-clipboard/tests/clipboardpipeline.js @@ -323,6 +323,7 @@ describe( 'ClipboardPipeline feature', () => { viewDocument.fire( 'clipboardInput', { dataTransfer: dataTransferMock, + content: dataTransferMock.getData( 'text/html' ), method: 'paste' } ); @@ -331,7 +332,8 @@ describe( 'ClipboardPipeline feature', () => { editor.disableReadOnlyMode( 'unit-test' ); viewDocument.fire( 'clipboardInput', { - dataTransfer: dataTransferMock + dataTransfer: dataTransferMock, + content: dataTransferMock.getData( 'text/html' ) } ); sinon.assert.calledOnce( spy ); diff --git a/packages/ckeditor5-clipboard/tests/dragdrop.js b/packages/ckeditor5-clipboard/tests/dragdrop.js index 1d2937c0c29..fc207d62089 100644 --- a/packages/ckeditor5-clipboard/tests/dragdrop.js +++ b/packages/ckeditor5-clipboard/tests/dragdrop.js @@ -2531,6 +2531,7 @@ describe( 'Drag and Drop', () => { ...prepareEventData( modelPositionOrRange ), method: 'dragging', dataTransfer: dataTransferMock, + content: dataTransferMock.getData( 'text/html' ), stopPropagation: () => {}, preventDefault: () => {} } ); @@ -2541,6 +2542,7 @@ describe( 'Drag and Drop', () => { ...prepareEventData( modelPosition ), method: 'drop', dataTransfer: dataTransferMock, + content: dataTransferMock.getData( 'text/html' ), stopPropagation: () => {}, preventDefault: () => {} } ); diff --git a/packages/ckeditor5-clipboard/tests/pasteplaintext.js b/packages/ckeditor5-clipboard/tests/pasteplaintext.js index 397fb88a444..a740f4b436b 100644 --- a/packages/ckeditor5-clipboard/tests/pasteplaintext.js +++ b/packages/ckeditor5-clipboard/tests/pasteplaintext.js @@ -194,6 +194,7 @@ describe( 'PastePlainText', () => { viewDocument.fire( 'clipboardInput', { dataTransfer: dataTransferMock, + content: dataTransferMock.getData( 'text/html' ), stopPropagation() {}, preventDefault() {} } ); @@ -222,6 +223,7 @@ describe( 'PastePlainText', () => { viewDocument.fire( 'clipboardInput', { dataTransfer: dataTransferMock, + content: dataTransferMock.getData( 'text/html' ), stopPropagation() {}, preventDefault() {} } ); @@ -237,6 +239,7 @@ describe( 'PastePlainText', () => { 'text/html': 'foo', 'text/plain': 'foo' } ), + content: 'foo', stopPropagation() {}, preventDefault() {} } ); @@ -252,6 +255,7 @@ describe( 'PastePlainText', () => { 'text/html': 'foo', 'text/plain': 'foo' } ), + content: 'foo', stopPropagation() {}, preventDefault() {} } ); @@ -275,6 +279,7 @@ describe( 'PastePlainText', () => { 'text/html': '', 'text/plain': 'foo' } ), + content: '', stopPropagation() {}, preventDefault() {} } ); diff --git a/packages/ckeditor5-code-block/src/codeblockediting.ts b/packages/ckeditor5-code-block/src/codeblockediting.ts index d22097f8818..33d628bffef 100644 --- a/packages/ckeditor5-code-block/src/codeblockediting.ts +++ b/packages/ckeditor5-code-block/src/codeblockediting.ts @@ -22,7 +22,7 @@ import { type Element, type SelectionChangeRangeEvent } from 'ckeditor5/src/engine.js'; -import { ClipboardPipeline, type ClipboardContentInsertionEvent } from 'ckeditor5/src/clipboard.js'; +import { ClipboardPipeline, type ClipboardContentInsertionEvent, type ViewDocumentClipboardInputEvent } from 'ckeditor5/src/clipboard.js'; import CodeBlockCommand from './codeblockcommand.js'; import IndentCodeBlockCommand from './indentcodeblockcommand.js'; @@ -178,7 +178,7 @@ export default class CodeBlockEditing extends Plugin { // Intercept the clipboard input (paste) when the selection is anchored in the code block and force the clipboard // data to be pasted as a single plain text. Otherwise, the code lines will split the code block and // "spill out" as separate paragraphs. - this.listenTo( editor.editing.view.document, 'clipboardInput', ( evt, data ) => { + this.listenTo( editor.editing.view.document, 'clipboardInput', ( evt, data ) => { let insertionRange = model.createRange( model.document.selection.anchor! ); // Use target ranges in case this is a drop. diff --git a/packages/ckeditor5-code-block/tests/codeblockediting.js b/packages/ckeditor5-code-block/tests/codeblockediting.js index 6bfb6ad0d6d..d124eab00e0 100644 --- a/packages/ckeditor5-code-block/tests/codeblockediting.js +++ b/packages/ckeditor5-code-block/tests/codeblockediting.js @@ -1646,6 +1646,7 @@ describe( 'CodeBlockEditing', () => { }; viewDoc.fire( 'clipboardInput', { + content: dataTransferMock.getData( 'text/plain' ), dataTransfer: dataTransferMock, stop: sinon.spy() } ); @@ -1669,6 +1670,7 @@ describe( 'CodeBlockEditing', () => { }; viewDoc.fire( 'clipboardInput', { + content: dataTransferMock.getData( 'text/plain' ), dataTransfer: dataTransferMock, stop: sinon.spy() } ); diff --git a/packages/ckeditor5-engine/src/view/view.ts b/packages/ckeditor5-engine/src/view/view.ts index 845f931c098..e8f63f18456 100644 --- a/packages/ckeditor5-engine/src/view/view.ts +++ b/packages/ckeditor5-engine/src/view/view.ts @@ -25,6 +25,7 @@ import type { StylesProcessor } from './stylesmap.js'; import type Element from './element.js'; import type { default as Node, ViewNodeChangeEvent } from './node.js'; import type Item from './item.js'; +import type DocumentFragment from './documentfragment.js'; import KeyObserver from './observer/keyobserver.js'; import FakeSelectionObserver from './observer/fakeselectionobserver.js'; @@ -679,7 +680,7 @@ export default class View extends /* #__PURE__ */ ObservableMixin() { * * @param element Element which is a parent for the range. */ - public createRangeIn( element: Element ): Range { + public createRangeIn( element: Element | DocumentFragment ): Range { return Range._createIn( element ); } diff --git a/packages/ckeditor5-image/src/autoimage.ts b/packages/ckeditor5-image/src/autoimage.ts index 81b337e9af8..2585ff8d6ad 100644 --- a/packages/ckeditor5-image/src/autoimage.ts +++ b/packages/ckeditor5-image/src/autoimage.ts @@ -8,7 +8,7 @@ */ import { Plugin, type Editor } from 'ckeditor5/src/core.js'; -import { Clipboard, type ClipboardPipeline } from 'ckeditor5/src/clipboard.js'; +import { Clipboard, type ClipboardInputTransformationEvent, type ClipboardPipeline } from 'ckeditor5/src/clipboard.js'; import { LivePosition, LiveRange } from 'ckeditor5/src/engine.js'; import { Undo } from 'ckeditor5/src/undo.js'; import { Delete } from 'ckeditor5/src/typing.js'; @@ -82,7 +82,7 @@ export default class AutoImage extends Plugin { // We need to listen on `Clipboard#inputTransformation` because we need to save positions of selection. // After pasting, the content between those positions will be checked for a URL that could be transformed // into an image. - this.listenTo( clipboardPipeline, 'inputTransformation', () => { + this.listenTo( clipboardPipeline, 'inputTransformation', () => { const firstRange = modelDocument.selection.getFirstRange()!; const leftLivePosition = LivePosition.fromPosition( firstRange.start ); diff --git a/packages/ckeditor5-image/src/imageupload/imageuploadediting.ts b/packages/ckeditor5-image/src/imageupload/imageuploadediting.ts index dd4f5a4f833..23107ea394c 100644 --- a/packages/ckeditor5-image/src/imageupload/imageuploadediting.ts +++ b/packages/ckeditor5-image/src/imageupload/imageuploadediting.ts @@ -22,7 +22,11 @@ import { } from 'ckeditor5/src/engine.js'; import { Notification } from 'ckeditor5/src/ui.js'; -import { ClipboardPipeline, type ViewDocumentClipboardInputEvent } from 'ckeditor5/src/clipboard.js'; +import { + ClipboardPipeline, + type ClipboardInputTransformationEvent, + type ViewDocumentClipboardInputEvent +} from 'ckeditor5/src/clipboard.js'; import { FileRepository, type UploadResponse, type FileLoader } from 'ckeditor5/src/upload.js'; import { env } from 'ckeditor5/src/utils.js'; @@ -199,7 +203,7 @@ export default class ImageUploadEditing extends Plugin { // For every image file, a new file loader is created and a placeholder image is // inserted into the content. Then, those images are uploaded once they appear in the model // (see Document#change listener below). - this.listenTo( clipboardPipeline, 'inputTransformation', ( evt, data ) => { + this.listenTo( clipboardPipeline, 'inputTransformation', ( evt, data ) => { const fetchableImages = Array.from( editor.editing.view.createRangeIn( data.content ) ) .map( value => value.item as ViewElement ) .filter( viewElement => diff --git a/packages/ckeditor5-link/src/autolink.ts b/packages/ckeditor5-link/src/autolink.ts index 2e75a97e3bf..5bd833ceb81 100644 --- a/packages/ckeditor5-link/src/autolink.ts +++ b/packages/ckeditor5-link/src/autolink.ts @@ -8,7 +8,7 @@ */ import { Plugin } from 'ckeditor5/src/core.js'; -import type { ClipboardInputTransformationData } from 'ckeditor5/src/clipboard.js'; +import type { ClipboardInputTransformationData, ClipboardInputTransformationEvent } from 'ckeditor5/src/clipboard.js'; import type { DocumentSelectionChangeEvent, Element, Model, Position, Range, Writer } from 'ckeditor5/src/engine.js'; import { Delete, TextWatcher, getLastTextLine, findAttributeRange, type TextWatcherMatchedDataEvent } from 'ckeditor5/src/typing.js'; import type { EnterCommand, ShiftEnterCommand } from 'ckeditor5/src/enter.js'; @@ -160,7 +160,7 @@ export default class AutoLink extends Plugin { const clipboardPipeline = editor.plugins.get( 'ClipboardPipeline' ); const linkCommand = editor.commands.get( 'link' )!; - clipboardPipeline.on( 'inputTransformation', ( evt, data: ClipboardInputTransformationData ) => { + clipboardPipeline.on( 'inputTransformation', ( evt, data: ClipboardInputTransformationData ) => { if ( !this.isEnabled || !linkCommand.isEnabled || selection.isCollapsed || data.method !== 'paste' ) { // Abort if we are disabled or the selection is collapsed. return; diff --git a/packages/ckeditor5-media-embed/src/automediaembed.ts b/packages/ckeditor5-media-embed/src/automediaembed.ts index 68c9cfb8f0f..10b269a690d 100644 --- a/packages/ckeditor5-media-embed/src/automediaembed.ts +++ b/packages/ckeditor5-media-embed/src/automediaembed.ts @@ -9,7 +9,7 @@ import { type Editor, Plugin } from 'ckeditor5/src/core.js'; import { LiveRange, LivePosition } from 'ckeditor5/src/engine.js'; -import { Clipboard, type ClipboardPipeline } from 'ckeditor5/src/clipboard.js'; +import { Clipboard, type ClipboardInputTransformationEvent, type ClipboardPipeline } from 'ckeditor5/src/clipboard.js'; import { Delete } from 'ckeditor5/src/typing.js'; import { Undo, type UndoCommand } from 'ckeditor5/src/undo.js'; import { global } from 'ckeditor5/src/utils.js'; @@ -79,7 +79,7 @@ export default class AutoMediaEmbed extends Plugin { // After pasting, the content between those positions will be checked for a URL that could be transformed // into media. const clipboardPipeline: ClipboardPipeline = editor.plugins.get( 'ClipboardPipeline' ); - this.listenTo( clipboardPipeline, 'inputTransformation', () => { + this.listenTo( clipboardPipeline, 'inputTransformation', () => { const firstRange = modelDocument.selection.getFirstRange()!; const leftLivePosition = LivePosition.fromPosition( firstRange.start ); diff --git a/packages/ckeditor5-paste-from-office/src/index.ts b/packages/ckeditor5-paste-from-office/src/index.ts index 83ac19e3be7..95f41b3fd9d 100644 --- a/packages/ckeditor5-paste-from-office/src/index.ts +++ b/packages/ckeditor5-paste-from-office/src/index.ts @@ -8,7 +8,7 @@ */ export { default as PasteFromOffice } from './pastefromoffice.js'; -export type { Normalizer, NormalizerData } from './normalizer.js'; +export type { Normalizer } from './normalizer.js'; export { default as MSWordNormalizer } from './normalizers/mswordnormalizer.js'; export { parseHtml } from './filters/parse.js'; diff --git a/packages/ckeditor5-paste-from-office/src/normalizer.ts b/packages/ckeditor5-paste-from-office/src/normalizer.ts index 6ea44b93367..f70e8ff4d63 100644 --- a/packages/ckeditor5-paste-from-office/src/normalizer.ts +++ b/packages/ckeditor5-paste-from-office/src/normalizer.ts @@ -8,7 +8,6 @@ */ import type { ClipboardInputTransformationData } from 'ckeditor5/src/clipboard.js'; -import type { ParseHtmlResult } from './filters/parse.js'; /** * Interface defining a content transformation pasted from an external editor. @@ -27,10 +26,5 @@ export interface Normalizer { /** * Executes the normalization of a given data. */ - execute( data: NormalizerData ): void; -} - -export interface NormalizerData extends ClipboardInputTransformationData { - _isTransformedWithPasteFromOffice?: boolean; - _parsedData: ParseHtmlResult; + execute( data: ClipboardInputTransformationData ): void; } diff --git a/packages/ckeditor5-paste-from-office/src/normalizers/googledocsnormalizer.ts b/packages/ckeditor5-paste-from-office/src/normalizers/googledocsnormalizer.ts index 7cafdd6fc90..22213a202f8 100644 --- a/packages/ckeditor5-paste-from-office/src/normalizers/googledocsnormalizer.ts +++ b/packages/ckeditor5-paste-from-office/src/normalizers/googledocsnormalizer.ts @@ -12,7 +12,8 @@ import { UpcastWriter, type ViewDocument } from 'ckeditor5/src/engine.js'; import removeBoldWrapper from '../filters/removeboldwrapper.js'; import transformBlockBrsToParagraphs from '../filters/br.js'; import { unwrapParagraphInListItem } from '../filters/list.js'; -import type { Normalizer, NormalizerData } from '../normalizer.js'; +import type { Normalizer } from '../normalizer.js'; +import type { ClipboardInputTransformationData } from 'ckeditor5/src/clipboard.js'; const googleDocsMatch = /id=("|')docs-internal-guid-[-0-9a-f]+("|')/i; @@ -41,14 +42,11 @@ export default class GoogleDocsNormalizer implements Normalizer { /** * @inheritDoc */ - public execute( data: NormalizerData ): void { + public execute( data: ClipboardInputTransformationData ): void { const writer = new UpcastWriter( this.document ); - const { body: documentFragment } = data._parsedData; - removeBoldWrapper( documentFragment, writer ); - unwrapParagraphInListItem( documentFragment, writer ); - transformBlockBrsToParagraphs( documentFragment, writer ); - - data.content = documentFragment; + removeBoldWrapper( data.content, writer ); + unwrapParagraphInListItem( data.content, writer ); + transformBlockBrsToParagraphs( data.content, writer ); } } diff --git a/packages/ckeditor5-paste-from-office/src/normalizers/googlesheetsnormalizer.ts b/packages/ckeditor5-paste-from-office/src/normalizers/googlesheetsnormalizer.ts index 95ff6d1bb2d..7bce8e78c51 100644 --- a/packages/ckeditor5-paste-from-office/src/normalizers/googlesheetsnormalizer.ts +++ b/packages/ckeditor5-paste-from-office/src/normalizers/googlesheetsnormalizer.ts @@ -13,7 +13,8 @@ import removeXmlns from '../filters/removexmlns.js'; import removeGoogleSheetsTag from '../filters/removegooglesheetstag.js'; import removeInvalidTableWidth from '../filters/removeinvalidtablewidth.js'; import removeStyleBlock from '../filters/removestyleblock.js'; -import type { Normalizer, NormalizerData } from '../normalizer.js'; +import type { Normalizer } from '../normalizer.js'; +import type { ClipboardInputTransformationData } from 'ckeditor5/src/clipboard.js'; const googleSheetsMatch = //i; const msWordMatch2 = /xmlns:o="urn:schemas-microsoft-com/i; @@ -45,15 +46,13 @@ export default class MSWordNormalizer implements Normalizer { /** * @inheritDoc */ - public execute( data: NormalizerData ): void { + public execute( data: ClipboardInputTransformationData ): void { const writer = new UpcastWriter( this.document ); - const { body: documentFragment, stylesString } = data._parsedData; + const stylesString = ( data.extraContent as { stylesString: string } ).stylesString; - transformBookmarks( documentFragment, writer ); - transformListItemLikeElementsIntoLists( documentFragment, stylesString, this.hasMultiLevelListPlugin ); - replaceImagesSourceWithBase64( documentFragment, data.dataTransfer.getData( 'text/rtf' ) ); - removeMSAttributes( documentFragment ); - - data.content = documentFragment; + transformBookmarks( data.content, writer ); + transformListItemLikeElementsIntoLists( data.content, stylesString, this.hasMultiLevelListPlugin ); + replaceImagesSourceWithBase64( data.content, data.dataTransfer.getData( 'text/rtf' ) ); + removeMSAttributes( data.content ); } } diff --git a/packages/ckeditor5-paste-from-office/src/pastefromoffice.ts b/packages/ckeditor5-paste-from-office/src/pastefromoffice.ts index ab2f60dd63d..736f423ea67 100644 --- a/packages/ckeditor5-paste-from-office/src/pastefromoffice.ts +++ b/packages/ckeditor5-paste-from-office/src/pastefromoffice.ts @@ -8,15 +8,20 @@ */ import { Plugin } from 'ckeditor5/src/core.js'; +import { priorities, insertToPriorityArray, type PriorityString } from 'ckeditor5/src/utils.js'; -import { ClipboardPipeline } from 'ckeditor5/src/clipboard.js'; +import { + ClipboardPipeline, + type ViewDocumentClipboardInputEvent, + type ClipboardInputTransformationEvent +} from 'ckeditor5/src/clipboard.js'; import MSWordNormalizer from './normalizers/mswordnormalizer.js'; import GoogleDocsNormalizer from './normalizers/googledocsnormalizer.js'; import GoogleSheetsNormalizer from './normalizers/googlesheetsnormalizer.js'; import { parseHtml } from './filters/parse.js'; -import type { Normalizer, NormalizerData } from './normalizer.js'; +import type { Normalizer } from './normalizer.js'; /** * The Paste from Office plugin. @@ -32,6 +37,14 @@ import type { Normalizer, NormalizerData } from './normalizer.js'; * For more information about this feature check the {@glink api/paste-from-office package page}. */ export default class PasteFromOffice extends Plugin { + /** + * The priority array of registered normalizers. + */ + private _normalizers = [] as Array<{ + normalizer: Normalizer; + priority: PriorityString; + }>; + /** * @inheritDoc */ @@ -53,6 +66,19 @@ export default class PasteFromOffice extends Plugin { return [ ClipboardPipeline ] as const; } + /** + * Registers a normalizer with the given priority. + */ + public registerNormalizer( + normalizer: Normalizer, + priority?: PriorityString + ): void { + insertToPriorityArray( this._normalizers, { + normalizer, + priority: priorities.get( priority ) + } ); + } + /** * @inheritDoc */ @@ -60,40 +86,40 @@ export default class PasteFromOffice extends Plugin { const editor = this.editor; const clipboardPipeline: ClipboardPipeline = editor.plugins.get( 'ClipboardPipeline' ); const viewDocument = editor.editing.view.document; - const normalizers: Array = []; const hasMultiLevelListPlugin = this.editor.plugins.has( 'MultiLevelList' ); - normalizers.push( new MSWordNormalizer( viewDocument, hasMultiLevelListPlugin ) ); - normalizers.push( new GoogleDocsNormalizer( viewDocument ) ); - normalizers.push( new GoogleSheetsNormalizer( viewDocument ) ); + this.registerNormalizer( new MSWordNormalizer( viewDocument, hasMultiLevelListPlugin ) ); + this.registerNormalizer( new GoogleDocsNormalizer( viewDocument ) ); + this.registerNormalizer( new GoogleSheetsNormalizer( viewDocument ) ); - clipboardPipeline.on( - 'inputTransformation', - ( evt, data: NormalizerData ) => { - if ( data._isTransformedWithPasteFromOffice ) { - return; - } + viewDocument.on( 'clipboardInput', ( evt, data ) => { + if ( typeof data.content != 'string' ) { + return; + } - const codeBlock = editor.model.document.selection.getFirstPosition()!.parent; + const htmlString = data.dataTransfer.getData( 'text/html' ); + const activeNormalizer = this._normalizers.find( ( { normalizer } ) => normalizer.isActive( htmlString ) ); - if ( codeBlock.is( 'element', 'codeBlock' ) ) { - return; - } + if ( activeNormalizer ) { + const parsedData = parseHtml( data.content, viewDocument.stylesProcessor ); - const htmlString = data.dataTransfer.getData( 'text/html' ); - const activeNormalizer = normalizers.find( normalizer => normalizer.isActive( htmlString ) ); + data.content = parsedData.body; + data.extraContent = { ...parsedData, isTransformedWithPasteFromOffice: true }; + } + }, { priority: priorities.low + 10 } ); - if ( activeNormalizer ) { - if ( !data._parsedData ) { - data._parsedData = parseHtml( htmlString, viewDocument.stylesProcessor ); - } + clipboardPipeline.on( 'inputTransformation', ( evt, data ) => { + if ( !data.extraContent || !( data.extraContent as any ).isTransformedWithPasteFromOffice ) { + return; + } - activeNormalizer.execute( data ); + const htmlString = data.dataTransfer.getData( 'text/html' ); + const normalizers = this._normalizers.filter( ( { normalizer } ) => normalizer.isActive( htmlString ) ); - data._isTransformedWithPasteFromOffice = true; - } - }, - { priority: 'high' } - ); + for ( const { normalizer } of normalizers ) { + normalizer.execute( data ); + } + }, + { priority: 'high' } ); } } diff --git a/packages/ckeditor5-paste-from-office/tests/_utils/utils.js b/packages/ckeditor5-paste-from-office/tests/_utils/utils.js index 94a14bcf821..3ba4c12d06a 100644 --- a/packages/ckeditor5-paste-from-office/tests/_utils/utils.js +++ b/packages/ckeditor5-paste-from-office/tests/_utils/utils.js @@ -7,18 +7,11 @@ import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor.js'; import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor.js'; -import ViewDocument from '@ckeditor/ckeditor5-engine/src/view/document.js'; -import HtmlDataProcessor from '@ckeditor/ckeditor5-engine/src/dataprocessor/htmldataprocessor.js'; -import normalizeClipboardData from '@ckeditor/ckeditor5-clipboard/src/utils/normalizeclipboarddata.js'; import normalizeHtml from '@ckeditor/ckeditor5-utils/tests/_utils/normalizehtml.js'; import { setData, stringify as stringifyModel } from '@ckeditor/ckeditor5-engine/src/dev-utils/model.js'; import { stringify as stringifyView } from '@ckeditor/ckeditor5-engine/src/dev-utils/view.js'; -import { StylesProcessor } from '@ckeditor/ckeditor5-engine/src/view/stylesmap.js'; - -const htmlDataProcessor = new HtmlDataProcessor( new ViewDocument( new StylesProcessor() ) ); - /** * Mocks dataTransfer object which can be used for simulating paste. * @@ -156,9 +149,13 @@ function generateNormalizationTests( title, fixtures, editorConfig, skip, only ) beforeEach( async () => { editor = await VirtualTestEditor.create( await editorConfig() ); + + // Stub `editor.editing.view.scrollToTheSelection` as it will fail on VirtualTestEditor without DOM. + sinon.stub( editor.editing.view, 'scrollToTheSelection' ); } ); afterEach( async () => { + sinon.restore(); await editor.destroy(); } ); @@ -174,16 +171,23 @@ function generateNormalizationTests( title, fixtures, editorConfig, skip, only ) testRunner( name, () => { // Simulate data from Clipboard event const clipboardPlugin = editor.plugins.get( 'ClipboardPipeline' ); - const content = htmlDataProcessor.toView( normalizeClipboardData( fixtures.input[ name ] ) ); const dataTransfer = createDataTransfer( { 'text/html': fixtures.input[ name ], 'text/rtf': fixtures.inputRtf && fixtures.inputRtf[ name ] } ); // data.content might be completely overwritten with a new object, so we need obtain final result for comparison. - const data = { content, dataTransfer }; - clipboardPlugin.fire( 'inputTransformation', data ); - const transformedContent = data.content; + let inputTransformationData; + + clipboardPlugin.on( 'inputTransformation', ( evt, data ) => { + inputTransformationData = data; + } ); + + const clipboardInputData = { dataTransfer, content: fixtures.input[ name ] }; + + editor.editing.view.document.fire( 'clipboardInput', clipboardInputData ); + + const transformedContent = inputTransformationData.content; expectNormalized( transformedContent, diff --git a/packages/ckeditor5-paste-from-office/tests/pastefromoffice.js b/packages/ckeditor5-paste-from-office/tests/pastefromoffice.js index 34c7397379f..706d452e01f 100644 --- a/packages/ckeditor5-paste-from-office/tests/pastefromoffice.js +++ b/packages/ckeditor5-paste-from-office/tests/pastefromoffice.js @@ -6,24 +6,18 @@ import PasteFromOffice from '../src/pastefromoffice.js'; import ClipboardPipeline from '@ckeditor/ckeditor5-clipboard/src/clipboardpipeline.js'; import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor.js'; -import HtmlDataProcessor from '@ckeditor/ckeditor5-engine/src/dataprocessor/htmldataprocessor.js'; import { createDataTransfer } from './_utils/utils.js'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils.js'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph.js'; -import { StylesProcessor } from '@ckeditor/ckeditor5-engine/src/view/stylesmap.js'; -import ViewDocument from '@ckeditor/ckeditor5-engine/src/view/document.js'; import ViewDocumentFragment from '@ckeditor/ckeditor5-engine/src/view/documentfragment.js'; import CodeBlockUI from '@ckeditor/ckeditor5-code-block/src/codeblockui.js'; import CodeBlockEditing from '@ckeditor/ckeditor5-code-block/src/codeblockediting.js'; -import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model.js'; -import { priorities } from '@ckeditor/ckeditor5-utils'; -import { DomConverter } from '@ckeditor/ckeditor5-engine'; +import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model.js'; -/* global document, DOMParser */ +/* global document */ describe( 'PasteFromOffice', () => { - const htmlDataProcessor = new HtmlDataProcessor( new ViewDocument( new StylesProcessor() ) ); - let editor, pasteFromOffice, clipboard, element; + let editor, pasteFromOffice, clipboard, element, viewDocument; testUtils.createSinonSandbox(); @@ -36,6 +30,7 @@ describe( 'PasteFromOffice', () => { } ); pasteFromOffice = editor.plugins.get( 'PasteFromOffice' ); clipboard = editor.plugins.get( 'ClipboardPipeline' ); + viewDocument = editor.editing.view.document; } ); afterEach( () => { @@ -64,38 +59,7 @@ describe( 'PasteFromOffice', () => { expect( editor.plugins.get( ClipboardPipeline ) ).to.be.instanceOf( ClipboardPipeline ); } ); - it( 'should work on already parsed data if another plugin hooked into #inputTransformation with a higher priority', () => { - const clipboardPipeline = editor.plugins.get( 'ClipboardPipeline' ); - const viewDocument = editor.editing.view.document; - - // Simulate a plugin that hooks into the pipeline earlier and parses the data. - clipboardPipeline.on( 'inputTransformation', ( evt, data ) => { - const domParser = new DOMParser(); - const htmlDocument = domParser.parseFromString( '

Existing data

', 'text/html' ); - const domConverter = new DomConverter( viewDocument, { renderingMode: 'data' } ); - const fragment = htmlDocument.createDocumentFragment(); - - data._parsedData = { - body: domConverter.domToView( fragment, { skipComments: true } ), - bodyString: 'Already parsed data', - styles: [], - stylesString: '' - }; - }, { priority: priorities.get( 'high' ) + 1 } ); - - const eventData = { - content: htmlDataProcessor.toView( '' ), - dataTransfer: createDataTransfer( { 'text/html': '' } ) - }; - - // Trigger some event that would normally trigger the paste from office plugin. - clipboard.fire( 'inputTransformation', eventData ); - - // Verify if the PFO plugin works on an already parsed data. - expect( eventData._parsedData.bodyString ).to.equal( 'Already parsed data' ); - } ); - - describe( 'isTransformedWithPasteFromOffice - flag', () => { + describe( 'parsed with extraContent property set', () => { describe( 'data which should be marked with flag', () => { it( 'should process data with microsoft word header', () => { checkCorrectData( '' ); @@ -127,14 +91,13 @@ describe( 'PasteFromOffice', () => { const data = setUpData( inputString ); const getDataSpy = sinon.spy( data.dataTransfer, 'getData' ); - clipboard.fire( 'inputTransformation', data ); + viewDocument.fire( 'clipboardInput', data ); - expect( data._isTransformedWithPasteFromOffice ).to.be.true; - expect( data._parsedData ).to.have.property( 'body' ); - expect( data._parsedData ).to.have.property( 'bodyString' ); - expect( data._parsedData ).to.have.property( 'styles' ); - expect( data._parsedData ).to.have.property( 'stylesString' ); - expect( data._parsedData.body ).to.be.instanceOf( ViewDocumentFragment ); + expect( data.extraContent ).to.have.property( 'body' ); + expect( data.extraContent ).to.have.property( 'bodyString' ); + expect( data.extraContent ).to.have.property( 'styles' ); + expect( data.extraContent ).to.have.property( 'stylesString' ); + expect( data.content ).to.be.instanceOf( ViewDocumentFragment ); sinon.assert.called( getDataSpy ); } @@ -167,10 +130,9 @@ describe( 'PasteFromOffice', () => { const data = setUpData( '

' ); const getDataSpy = sinon.spy( data.dataTransfer, 'getData' ); - clipboard.fire( 'inputTransformation', data ); + clipboard.fire( 'clipboardInput', data ); - expect( data._isTransformedWithPasteFromOffice ).to.be.undefined; - expect( data._parsedData ).to.be.undefined; + expect( data.extraContent ).to.be.undefined; sinon.assert.notCalled( getDataSpy ); } ); @@ -179,16 +141,16 @@ describe( 'PasteFromOffice', () => { const data = setUpData( inputString ); const getDataSpy = sinon.spy( data.dataTransfer, 'getData' ); - clipboard.fire( 'inputTransformation', data ); + viewDocument.fire( 'clipboardInput', data ); - expect( data._isTransformedWithPasteFromOffice ).to.be.undefined; - expect( data._parsedData ).to.be.undefined; + expect( data.extraContent ).to.be.undefined; + expect( data.content ).to.deep.equal( inputString ); sinon.assert.called( getDataSpy ); } } ); - describe( 'data which already have the flag', () => { + describe.skip( 'data which already have the flag', () => { it( 'should not process again ms word data containing a flag', () => { checkAlreadyProcessedData( '' + '

Hello world

' ); @@ -213,19 +175,79 @@ describe( 'PasteFromOffice', () => { } ); } ); + describe( 'code block integration', () => { + it( 'should not intercept input when selection anchored outside any code block', () => { + setModelData( editor.model, 'f[]oo' ); + + const clipboardPlugin = editor.plugins.get( ClipboardPipeline ); + const contentInsertionSpy = sinon.spy(); + const getDataStub = sinon.stub(); + + clipboardPlugin.on( 'contentInsertion', contentInsertionSpy ); + + getDataStub.withArgs( 'text/html' ).returns( 'abc' ); + getDataStub.withArgs( 'text/plain' ).returns( 'bar\nbaz\n' ); + + const dataTransferMock = { + getData: getDataStub + }; + + viewDocument.fire( 'clipboardInput', { + content: 'abc', + dataTransfer: dataTransferMock, + stop: sinon.spy() + } ); + + expect( getModelData( editor.model ) ).to.equal( 'fabc[]oo' ); + + // Make sure that ClipboardPipeline was not interrupted. + sinon.assert.calledOnce( contentInsertionSpy ); + } ); + + it( 'should intercept input when selection anchored in the code block', () => { + setModelData( editor.model, 'f[o]o' ); + + const clipboardPlugin = editor.plugins.get( ClipboardPipeline ); + const contentInsertionSpy = sinon.spy(); + const getDataStub = sinon.stub(); + + clipboardPlugin.on( 'contentInsertion', contentInsertionSpy ); + + getDataStub.withArgs( 'text/html' ).returns( 'abc' ); + getDataStub.withArgs( 'text/plain' ).returns( 'bar\nbaz\n' ); + + const dataTransferMock = { + getData: getDataStub + }; + + viewDocument.fire( 'clipboardInput', { + content: 'abc', + dataTransfer: dataTransferMock, + stop: sinon.spy() + } ); + + expect( getModelData( editor.model ) ).to.equal( + '' + + 'fbar' + + '' + + 'baz' + + '' + + '[]o' + + '' ); + + sinon.assert.calledOnce( dataTransferMock.getData ); + + // Make sure that ClipboardPipeline was not interrupted. + sinon.assert.calledOnce( contentInsertionSpy ); + } ); + } ); + // @param {String} inputString html to be processed by paste from office - // @param {Boolean} [isTransformedWithPasteFromOffice=false] if set, marks output data with isTransformedWithPasteFromOffice flag // @returns {Object} data object simulating content obtained from the clipboard - function setUpData( inputString, isTransformedWithPasteFromOffice = false ) { - const data = { - content: htmlDataProcessor.toView( inputString ), + function setUpData( inputString ) { + return { + content: inputString, dataTransfer: createDataTransfer( { 'text/html': inputString } ) }; - - if ( isTransformedWithPasteFromOffice ) { - data._isTransformedWithPasteFromOffice = true; - } - - return data; } } );