diff --git a/packages/ckeditor5-clipboard/src/clipboardmarkersutils.ts b/packages/ckeditor5-clipboard/src/clipboardmarkersutils.ts index 16700929f60..79bbb4dd154 100644 --- a/packages/ckeditor5-clipboard/src/clipboardmarkersutils.ts +++ b/packages/ckeditor5-clipboard/src/clipboardmarkersutils.ts @@ -9,16 +9,17 @@ import { mapValues } from 'lodash-es'; import { uid } from '@ckeditor/ckeditor5-utils'; -import { Plugin } from '@ckeditor/ckeditor5-core'; +import { Plugin, type NonEmptyArray } from '@ckeditor/ckeditor5-core'; import { Range, + type DocumentFragment, type Position, type Element, - type DocumentFragment, type DocumentSelection, type Selection, - type Writer + type Writer, + type Marker } from '@ckeditor/ckeditor5-engine'; /** @@ -33,7 +34,7 @@ export default class ClipboardMarkersUtils extends Plugin { * * @internal */ - private _markersToCopy: Map> = new Map(); + private _markersToCopy: Map = new Map(); /** * @inheritDoc @@ -46,44 +47,11 @@ export default class ClipboardMarkersUtils extends Plugin { * Registers marker name as copyable in clipboard pipeline. * * @param markerName Name of marker that can be copied. - * @param restrictions Preset or specified array of actions that can be performed on specified marker name. - * @internal - */ - public _registerMarkerToCopy( - markerName: string, - restrictions: ClipboardMarkerRestrictionsPreset | Array - ): void { - const allowedActions = Array.isArray( restrictions ) ? restrictions : this._mapRestrictionPresetToActions( restrictions ); - - if ( allowedActions.length ) { - this._markersToCopy.set( markerName, allowedActions ); - } - } - - /** - * Maps preset into array of clipboard operations to be allowed on marker. - * - * @param preset Restrictions preset to be mapped to actions + * @param config Configuration that describes what can be performed on specified marker. * @internal */ - private _mapRestrictionPresetToActions( preset: ClipboardMarkerRestrictionsPreset ): Array { - switch ( preset ) { - case 'always': - return [ 'copy', 'cut', 'dragstart' ]; - - case 'default': - return [ 'cut', 'dragstart' ]; - - case 'never': - return []; - - default: { - // Skip unrecognized type. - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const unreachable: never = preset; - return []; - } - } + public _registerMarkerToCopy( markerName: string, config: ClipboardMarkerConfiguration ): void { + this._markersToCopy.set( markerName, config ); } /** @@ -167,6 +135,7 @@ export default class ClipboardMarkersUtils extends Plugin { * * * `markers` are inserted into the same element that must be later transformed inside `getPastedDocumentElement`. * * Fake marker elements inside `getPastedDocumentElement` can be cloned, but their ranges cannot overlap. + * * If `duplicateOnPaste` is `true` in marker config then associated marker ID is regenerated before pasting. * * @param action Type of clipboard action. * @param markers Object that maps marker name to corresponding range. @@ -177,32 +146,58 @@ export default class ClipboardMarkersUtils extends Plugin { markers: Record | Map, getPastedDocumentElement: ( writer: Writer ) => Element ): Element { - const copyableMarkers = this._getCopyableMarkersFromRangeMap( markers ); + const pasteMarkers = this._getPasteMarkersFromRangeMap( markers ); return this.editor.model.change( writer => { - const sourceFragmentFakeMarkers = this._insertFakeMarkersElements( writer, copyableMarkers ); + // Inserts fake markers into source fragment / element that is later transformed inside `getPastedDocumentElement`. + const sourceFragmentFakeMarkers = this._insertFakeMarkersElements( writer, pasteMarkers ); + + // Modifies document fragment (for example, cloning table cells) and then inserts it into the document. const transformedElement = getPastedDocumentElement( writer ); + + // Removes markers in pasted and transformed fragment in root document. const removedFakeMarkers = this._removeFakeMarkersInsideElement( writer, transformedElement ); - // Cleanup fake markers inserted into transformed element. + // Cleans up fake markers inserted into source fragment (that one before transformation which is not pasted). for ( const element of Object.values( sourceFragmentFakeMarkers ).flat() ) { writer.remove( element ); } + // Inserts to root document fake markers. for ( const [ markerName, range ] of Object.entries( removedFakeMarkers ) ) { - const uniqueName = writer.model.markers.has( markerName ) ? this._getUniqueMarkerName( markerName ) : markerName; - - writer.addMarker( uniqueName, { - usingOperation: true, - affectsData: true, - range - } ); + if ( !writer.model.markers.has( markerName ) ) { + writer.addMarker( markerName, { + usingOperation: true, + affectsData: true, + range + } ); + } } return transformedElement; } ); } + /** + * Pastes document fragment with markers to document. + * If `duplicateOnPaste` is `true` in marker config then associated markers IDs + * are regenerated before pasting to avoid markers duplications in content. + * + * @param fragment Document fragment that should contain already processed by pipeline markers. + * @internal + */ + public _pasteFragmentWithMarkers( fragment: DocumentFragment ): Range { + const pasteMarkers = this._getPasteMarkersFromRangeMap( fragment.markers ); + + fragment.markers.clear(); + + for ( const copyableMarker of pasteMarkers ) { + fragment.markers.set( copyableMarker.name, copyableMarker.range ); + } + + return this.editor.model.insertContent( fragment ); + } + /** * In some situations we have to perform copy on selected fragment with certain markers. This function allows to temporarily bypass * restrictions on markers that we want to copy. @@ -212,12 +207,21 @@ export default class ClipboardMarkersUtils extends Plugin { * * @param markerName Which markers should be copied. * @param executor Callback executed. + * @param config Optional configuration flags used to copy (such like partial copy flag). * @internal */ - public _forceMarkersCopy( markerName: string, executor: VoidFunction ): void { + public _forceMarkersCopy( + markerName: string, + executor: VoidFunction, + config: ClipboardMarkerConfiguration = { + allowedActions: 'all', + copyPartiallySelected: true, + duplicateOnPaste: true + } + ): void { const before = this._markersToCopy.get( markerName ); - this._markersToCopy.set( markerName, this._mapRestrictionPresetToActions( 'always' ) ); + this._markersToCopy.set( markerName, config ); executor(); @@ -235,34 +239,42 @@ export default class ClipboardMarkersUtils extends Plugin { * @param action Type of clipboard action. If null then checks only if marker is registered as copyable. * @internal */ - public _canPerformMarkerClipboardAction( markerName: string, action: ClipboardMarkerRestrictedAction | null ): boolean { - const [ markerNamePrefix ] = markerName.split( ':' ); + public _isMarkerCopyable( markerName: string, action: ClipboardMarkerRestrictedAction | null ): boolean { + const config = this._getMarkerClipboardConfig( markerName ); + + if ( !config ) { + return false; + } + // If there is no action provided then only presence of marker is checked. if ( !action ) { - return this._markersToCopy.has( markerNamePrefix ); + return true; } - const possibleActions = this._markersToCopy.get( markerNamePrefix ) || []; + const { allowedActions } = config; - return possibleActions.includes( action ); + return allowedActions === 'all' || allowedActions.includes( action ); } /** - * Changes marker names for markers stored in given document fragment so that they are unique. + * Checks if marker has any clipboard copy behavior configuration. * - * @param fragment - * @internal + * @param markerName Name of checked marker. */ - public _setUniqueMarkerNamesInFragment( fragment: DocumentFragment ): void { - const markers = Array.from( fragment.markers ); - - fragment.markers.clear(); + public _hasMarkerConfiguration( markerName: string ): boolean { + return !!this._getMarkerClipboardConfig( markerName ); + } - for ( const [ name, range ] of markers ) { - const newName = this._canPerformMarkerClipboardAction( name, null ) ? this._getUniqueMarkerName( name ) : name; + /** + * Returns marker's configuration flags passed during registration. + * + * @param markerName Name of marker that should be returned. + * @internal + */ + public _getMarkerClipboardConfig( markerName: string ): ClipboardMarkerConfiguration | null { + const [ markerNamePrefix ] = markerName.split( ':' ); - fragment.markers.set( newName, range ); - } + return this._markersToCopy.get( markerNamePrefix ) || null; } /** @@ -287,6 +299,9 @@ export default class ClipboardMarkersUtils extends Plugin { /** * Returns array of markers that can be copied in specified selection. * + * If marker cannot be copied partially (according to `copyPartiallySelected` configuration flag) and + * is not present entirely in any selection range then it will be skipped. + * * @param writer An instance of the model writer. * @param selection Selection which will be checked. * @param action Type of clipboard action. If null then checks only if marker is registered as copyable. @@ -296,34 +311,103 @@ export default class ClipboardMarkersUtils extends Plugin { selection: Selection | DocumentSelection, action: ClipboardMarkerRestrictedAction | null ): Array { + const selectionRanges = Array.from( selection.getRanges()! ); + + // Picks all markers in provided ranges. Ensures that there are no duplications if + // there are multiple ranges that intersects with the same marker. + const markersInRanges = new Set( + selectionRanges.flatMap( + selectionRange => Array.from( writer.model.markers.getMarkersIntersectingRange( selectionRange ) ) + ) + ); + + const isSelectionMarkerCopyable = ( marker: Marker ) => { + // Check if marker exists in configuration and provided action can be performed on it. + const isCopyable = this._isMarkerCopyable( marker.name, action ); + + if ( !isCopyable ) { + return false; + } + + // Checks if configuration disallows to copy marker only if part of its content is selected. + // + // Example: + // Hello [ World ] + // ^ selection + // + // In this scenario `marker-a` won't be copied because selection doesn't overlap its content entirely. + const { copyPartiallySelected } = this._getMarkerClipboardConfig( marker.name )!; + + if ( !copyPartiallySelected ) { + const markerRange = marker.getRange(); + + return selectionRanges.some( selectionRange => selectionRange.containsRange( markerRange, true ) ); + } + + return true; + }; + return Array - .from( selection.getRanges()! ) - .flatMap( selectionRange => Array.from( writer.model.markers.getMarkersIntersectingRange( selectionRange ) ) ) - .filter( marker => this._canPerformMarkerClipboardAction( marker.name, action ) ) - .map( ( marker ): CopyableMarker => ( { - name: marker.name, - range: marker.getRange() - } ) ); + .from( markersInRanges ) + .filter( isSelectionMarkerCopyable ) + .map( ( copyableMarker ): CopyableMarker => { + // During `dragstart` event original marker is still present in tree. + // It is removed after the clipboard drop event, so none of the copied markers are inserted at the end. + // It happens because there already markers with specified `marker.name` when clipboard is trying to insert data + // and it aborts inserting. + const name = action === 'dragstart' ? this._getUniqueMarkerName( copyableMarker.name ) : copyableMarker.name; + + return { + name, + range: copyableMarker.getRange() + }; + } ); } /** - * Picks all markers from markers map that can be copied. + * Picks all markers from markers map that can be pasted. + * If `duplicateOnPaste` is `true`, it regenerates their IDs to ensure uniqueness. + * If marker is not registered, it will be kept in the array anyway. * * @param markers Object that maps marker name to corresponding range. * @param action Type of clipboard action. If null then checks only if marker is registered as copyable. */ - private _getCopyableMarkersFromRangeMap( + private _getPasteMarkersFromRangeMap( markers: Record | Map, action: ClipboardMarkerRestrictedAction | null = null ): Array { + const { model } = this.editor; const entries = markers instanceof Map ? Array.from( markers.entries() ) : Object.entries( markers ); - return entries - .map( ( [ markerName, range ] ): CopyableMarker => ( { - name: markerName, - range - } ) ) - .filter( marker => this._canPerformMarkerClipboardAction( marker.name, action ) ); + return entries.flatMap( ( [ markerName, range ] ): Array => { + if ( !this._hasMarkerConfiguration( markerName ) ) { + return [ + { + name: markerName, + range + } + ]; + } + + if ( this._isMarkerCopyable( markerName, action ) ) { + const copyMarkerConfig = this._getMarkerClipboardConfig( markerName )!; + const isInGraveyard = model.markers.has( markerName ) && + model.markers.get( markerName )!.getRange().root.rootName === '$graveyard'; + + if ( copyMarkerConfig.duplicateOnPaste || isInGraveyard ) { + markerName = this._getUniqueMarkerName( markerName ); + } + + return [ + { + name: markerName, + range + } + ]; + } + + return []; + } ); } /** @@ -391,15 +475,26 @@ export default class ClipboardMarkersUtils extends Plugin { // // The easiest way to bypass this issue is to rename already existing in map nodes and // set them new unique name. + let skipAssign = false; + if ( prevFakeMarker && prevFakeMarker.start && prevFakeMarker.end ) { - acc[ this._getUniqueMarkerName( fakeMarker.name ) ] = acc[ fakeMarker.name ]; + const config = this._getMarkerClipboardConfig( fakeMarker.name )!; + + if ( config.duplicateOnPaste ) { + acc[ this._getUniqueMarkerName( fakeMarker.name ) ] = acc[ fakeMarker.name ]; + } else { + skipAssign = true; + } + prevFakeMarker = null; } - acc[ fakeMarker.name ] = { - ...prevFakeMarker!, - [ fakeMarker.type ]: position - }; + if ( !skipAssign ) { + acc[ fakeMarker.name ] = { + ...prevFakeMarker!, + [ fakeMarker.type ]: position + }; + } if ( fakeMarker.markerElement ) { writer.remove( fakeMarker.markerElement ); @@ -538,16 +633,25 @@ export default class ClipboardMarkersUtils extends Plugin { export type ClipboardMarkerRestrictedAction = 'copy' | 'cut' | 'dragstart'; /** - * Specifies copy, paste or move marker restrictions in clipboard. Depending on specified mode - * it will disallow copy, cut or paste of marker in clipboard. - * - * * `'default'` - the markers will be preserved on cut-paste and drag and drop actions only. - * * `'always'` - the markers will be preserved on all clipboard actions (cut, copy, drag and drop). - * * `'never'` - the markers will be ignored by clipboard. + * Specifies behavior of markers during clipboard actions. * * @internal */ -export type ClipboardMarkerRestrictionsPreset = 'default' | 'always' | 'never'; +export type ClipboardMarkerConfiguration = { + allowedActions: NonEmptyArray | 'all'; + + // If `false`, do not copy marker when only part of its content is selected. + copyPartiallySelected?: boolean; + + // If `true` then every marker that is present in clipboard document fragment element obtain new generated ID just before pasting. + // It means that it is possible to perform copy once and then paste it multiple times wherever we want. + // + // On the other hand if it has false value the marker will be not pasted because ID already exists in the document. + // + // This flag is ignored in `cut` and `dragstart` actions because source marker is moved to graveyard and + // it is still present in `model.markers`. Pasted marker id must be regenerated to avoid duplications. + duplicateOnPaste?: boolean; +}; /** * Marker descriptor type used to revert markers into tree node. diff --git a/packages/ckeditor5-clipboard/src/clipboardpipeline.ts b/packages/ckeditor5-clipboard/src/clipboardpipeline.ts index fcee6181dc1..99097453b2b 100644 --- a/packages/ckeditor5-clipboard/src/clipboardpipeline.ts +++ b/packages/ckeditor5-clipboard/src/clipboardpipeline.ts @@ -179,12 +179,15 @@ export default class ClipboardPipeline extends Plugin { method: 'copy' | 'cut' | 'dragstart' ): void { const clipboardMarkersUtils: ClipboardMarkersUtils = this.editor.plugins.get( 'ClipboardMarkersUtils' ); - const documentFragment = clipboardMarkersUtils._copySelectedFragmentWithMarkers( method, selection ); - this.fire( 'outputTransformation', { - dataTransfer, - content: documentFragment, - method + this.editor.model.enqueueChange( { isUndoable: method === 'cut' }, () => { + const documentFragment = clipboardMarkersUtils._copySelectedFragmentWithMarkers( method, selection ); + + this.fire( 'outputTransformation', { + dataTransfer, + content: documentFragment, + method + } ); } ); } @@ -275,11 +278,7 @@ export default class ClipboardPipeline extends Plugin { }, { priority: 'low' } ); this.listenTo( this, 'contentInsertion', ( evt, data ) => { - clipboardMarkersUtils._setUniqueMarkerNamesInFragment( data.content ); - }, { priority: 'highest' } ); - - this.listenTo( this, 'contentInsertion', ( evt, data ) => { - data.resultRange = model.insertContent( data.content ); + data.resultRange = clipboardMarkersUtils._pasteFragmentWithMarkers( data.content ); }, { priority: 'low' } ); } diff --git a/packages/ckeditor5-clipboard/src/index.ts b/packages/ckeditor5-clipboard/src/index.ts index aa86a74eede..884b081b5c0 100644 --- a/packages/ckeditor5-clipboard/src/index.ts +++ b/packages/ckeditor5-clipboard/src/index.ts @@ -21,8 +21,8 @@ export { export { default as ClipboardMarkersUtils, - type ClipboardMarkerRestrictionsPreset, - type ClipboardMarkerRestrictedAction + type ClipboardMarkerRestrictedAction, + type ClipboardMarkerConfiguration } from './clipboardmarkersutils.js'; export type { diff --git a/packages/ckeditor5-clipboard/tests/clipboardmarkersutils.js b/packages/ckeditor5-clipboard/tests/clipboardmarkersutils.js index 281be8ef870..c6135c3d508 100644 --- a/packages/ckeditor5-clipboard/tests/clipboardmarkersutils.js +++ b/packages/ckeditor5-clipboard/tests/clipboardmarkersutils.js @@ -8,6 +8,7 @@ import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictest import DocumentFragment from '@ckeditor/ckeditor5-engine/src/model/documentfragment.js'; import Position from '@ckeditor/ckeditor5-engine/src/model/position.js'; import Range from '@ckeditor/ckeditor5-engine/src/model/range.js'; +import Undo from '@ckeditor/ckeditor5-undo/src/undoediting.js'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph.js'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils.js'; import { parse, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model.js'; @@ -34,7 +35,11 @@ describe( 'Clipboard Markers Utils', () => { describe( 'Check markers selection intersection', () => { beforeEach( () => { - clipboardMarkersUtils._registerMarkerToCopy( 'comment', [ 'copy' ] ); + clipboardMarkersUtils._registerMarkerToCopy( 'comment', { + allowedActions: [ 'copy' ], + copyPartiallySelected: true, + duplicateOnPaste: true + } ); } ); it( 'should copy and paste marker that is inside selection', () => { @@ -68,10 +73,10 @@ describe( 'Clipboard Markers Utils', () => { viewDocument.fire( 'paste', data ); - checkMarker( 'comment:test:pasted', model.createRange( - model.createPositionFromPath( modelRoot, [ 2, 4 ] ), - model.createPositionFromPath( modelRoot, [ 2, 7 ] ) - ) ); + checkMarker( 'comment:test:pasted', { + start: [ 2, 4 ], + end: [ 2, 7 ] + } ); } ); it( 'should copy and paste marker that is outside selection', () => { @@ -107,10 +112,10 @@ describe( 'Clipboard Markers Utils', () => { viewDocument.fire( 'paste', data ); - checkMarker( 'comment:test:pasted', model.createRange( - model.createPositionFromPath( modelRoot, [ 2, 0 ] ), - model.createPositionFromPath( modelRoot, [ 2, 12 ] ) - ) ); + checkMarker( 'comment:test:pasted', { + start: [ 2, 0 ], + end: [ 2, 12 ] + } ); } ); it( 'should copy and paste marker that starts before selection', () => { @@ -145,10 +150,10 @@ describe( 'Clipboard Markers Utils', () => { viewDocument.fire( 'paste', data ); - checkMarker( 'comment:test:pasted', model.createRange( - model.createPositionFromPath( modelRoot, [ 1, 0 ] ), - model.createPositionFromPath( modelRoot, [ 1, 5 ] ) - ) ); + checkMarker( 'comment:test:pasted', { + start: [ 1, 0 ], + end: [ 1, 5 ] + } ); } ); it( 'should copy and paste marker that starts after selection', () => { @@ -183,10 +188,10 @@ describe( 'Clipboard Markers Utils', () => { viewDocument.fire( 'paste', data ); - checkMarker( 'comment:test:pasted', model.createRange( - model.createPositionFromPath( modelRoot, [ 1, 6 ] ), - model.createPositionFromPath( modelRoot, [ 1, 11 ] ) - ) ); + checkMarker( 'comment:test:pasted', { + start: [ 1, 6 ], + end: [ 1, 11 ] + } ); } ); it( 'copy and paste markers does not affect position of markers that are after selection', () => { @@ -222,15 +227,15 @@ describe( 'Clipboard Markers Utils', () => { viewDocument.fire( 'paste', data ); - checkMarker( 'comment:test:pasted', model.createRange( - model.createPositionFromPath( modelRoot, [ 1, 6 ] ), - model.createPositionFromPath( modelRoot, [ 1, 11 ] ) - ) ); + checkMarker( 'comment:test:pasted', { + start: [ 1, 6 ], + end: [ 1, 11 ] + } ); - checkMarker( 'comment:test2:pasted', model.createRange( - model.createPositionFromPath( modelRoot, [ 1, 0 ] ), - model.createPositionFromPath( modelRoot, [ 1, 14 ] ) - ) ); + checkMarker( 'comment:test2:pasted', { + start: [ 1, 0 ], + end: [ 1, 14 ] + } ); } ); it( 'copy and paste fake marker that is inside another fake marker aligned to right', () => { @@ -263,15 +268,15 @@ describe( 'Clipboard Markers Utils', () => { viewDocument.fire( 'paste', data ); - checkMarker( 'comment:test:pasted', model.createRange( - model.createPositionFromPath( modelRoot, [ 1, 2 ] ), - model.createPositionFromPath( modelRoot, [ 1, 11 ] ) - ) ); + checkMarker( 'comment:test:pasted', { + start: [ 1, 2 ], + end: [ 1, 11 ] + } ); - checkMarker( 'comment:test2:pasted', model.createRange( - model.createPositionFromPath( modelRoot, [ 1, 6 ] ), - model.createPositionFromPath( modelRoot, [ 1, 11 ] ) - ) ); + checkMarker( 'comment:test2:pasted', { + start: [ 1, 6 ], + end: [ 1, 11 ] + } ); } ); it( 'copy and paste fake marker that is inside another fake marker aligned to left', () => { @@ -304,15 +309,15 @@ describe( 'Clipboard Markers Utils', () => { viewDocument.fire( 'paste', data ); - checkMarker( 'comment:test:pasted', model.createRange( - model.createPositionFromPath( modelRoot, [ 1, 0 ] ), - model.createPositionFromPath( modelRoot, [ 1, 11 ] ) - ) ); + checkMarker( 'comment:test:pasted', { + start: [ 1, 0 ], + end: [ 1, 11 ] + } ); - checkMarker( 'comment:test2:pasted', model.createRange( - model.createPositionFromPath( modelRoot, [ 1, 0 ] ), - model.createPositionFromPath( modelRoot, [ 1, 5 ] ) - ) ); + checkMarker( 'comment:test2:pasted', { + start: [ 1, 0 ], + end: [ 1, 5 ] + } ); } ); it( 'copy and paste fake marker that is inside another larger fake marker', () => { @@ -345,15 +350,15 @@ describe( 'Clipboard Markers Utils', () => { viewDocument.fire( 'paste', data ); - checkMarker( 'comment:test:pasted', model.createRange( - model.createPositionFromPath( modelRoot, [ 1, 0 ] ), - model.createPositionFromPath( modelRoot, [ 1, 11 ] ) - ) ); + checkMarker( 'comment:test:pasted', { + start: [ 1, 0 ], + end: [ 1, 11 ] + } ); - checkMarker( 'comment:test2:pasted', model.createRange( - model.createPositionFromPath( modelRoot, [ 1, 5 ] ), - model.createPositionFromPath( modelRoot, [ 1, 8 ] ) - ) ); + checkMarker( 'comment:test2:pasted', { + start: [ 1, 5 ], + end: [ 1, 8 ] + } ); } ); } ); @@ -384,108 +389,132 @@ describe( 'Clipboard Markers Utils', () => { expect( data.dataTransfer.getData( 'text/html' ) ).to.equal( 'Foo Bar Test' ); } ); + } ); - it( 'should be possible to force markers copy', () => { - clipboardMarkersUtils._forceMarkersCopy( 'comment', () => { - setModelData( - model, - wrapWithTag( 'paragraph', 'Start' ) + - wrapWithTag( 'paragraph', 'Foo Bar Test' ) + - wrapWithTag( 'paragraph', 'End' ) - ); - - appendMarker( 'comment:test', { start: [ 0 ], end: [ 3 ] } ); - model.change( writer => { - writer.setSelection( - writer.createRangeOn( editor.model.document.getRoot().getChild( 1 ) ), - 0 - ); - } ); + describe( 'Copy partial selection', () => { + it( 'should be possible to copy partially selected markers if `copyPartiallySelected` is set to `true`', () => { + clipboardMarkersUtils._registerMarkerToCopy( 'comment', { + allowedActions: 'all', + copyPartiallySelected: true, + duplicateOnPaste: true + } ); - const data = { - dataTransfer: createDataTransfer(), - preventDefault: () => {}, - stopPropagation: () => {} - }; + setModelData( + model, + wrapWithTag( 'paragraph', '[He]llo World' ) + wrapWithTag( 'paragraph', '' ) + ); - viewDocument.fire( 'copy', data ); + appendMarker( 'comment:test', { start: [ 0, 1 ], end: [ 0, 4 ] } ); - model.change( writer => { - writer.setSelection( - writer.createRangeIn( editor.model.document.getRoot().getChild( 2 ) ), - 0 - ); - } ); + const data = { + dataTransfer: createDataTransfer(), + preventDefault: () => {}, + stopPropagation: () => {} + }; - viewDocument.fire( 'paste', data ); + viewDocument.fire( 'cut', data ); - checkMarker( 'comment:test:pasted', model.createRange( - model.createPositionFromPath( modelRoot, [ 2, 0 ] ), - model.createPositionFromPath( modelRoot, [ 2, 12 ] ) - ) ); + model.change( writer => { + writer.setSelection( + writer.createRangeIn( editor.model.document.getRoot().getChild( 1 ) ), + 0 + ); } ); - } ); - it( 'should be possible to force markers copy #2 - unregistered marker', () => { - clipboardMarkersUtils._forceMarkersCopy( 'new', () => { - setModelData( - model, - wrapWithTag( 'paragraph', 'Start' ) + - wrapWithTag( 'paragraph', 'Foo Bar Test' ) + - wrapWithTag( 'paragraph', 'End' ) - ); + viewDocument.fire( 'paste', data ); - appendMarker( 'new:test', { start: [ 0 ], end: [ 3 ] } ); - model.change( writer => { - writer.setSelection( - writer.createRangeOn( editor.model.document.getRoot().getChild( 1 ) ), - 0 - ); - } ); + expect( model.markers.has( 'comment:test:pasted' ) ).to.true; + } ); - const data = { - dataTransfer: createDataTransfer(), - preventDefault: () => {}, - stopPropagation: () => {} - }; + it( 'should not be possible to copy partially selected markers if `copyPartiallySelected` is set to `false`', () => { + clipboardMarkersUtils._registerMarkerToCopy( 'comment', { allowedActions: 'all', copyPartiallySelected: false } ); - viewDocument.fire( 'copy', data ); + setModelData( + model, + wrapWithTag( 'paragraph', '[He]llo World' ) + wrapWithTag( 'paragraph', '' ) + ); - model.change( writer => { - writer.setSelection( - writer.createRangeIn( editor.model.document.getRoot().getChild( 2 ) ), - 0 - ); - } ); + appendMarker( 'comment:test', { start: [ 0, 1 ], end: [ 0, 4 ] } ); - viewDocument.fire( 'paste', data ); + const data = { + dataTransfer: createDataTransfer(), + preventDefault: () => {}, + stopPropagation: () => {} + }; + + viewDocument.fire( 'cut', data ); - checkMarker( 'new:test:pasted', model.createRange( - model.createPositionFromPath( modelRoot, [ 2, 0 ] ), - model.createPositionFromPath( modelRoot, [ 2, 12 ] ) - ) ); + model.change( writer => { + writer.setSelection( + writer.createRangeIn( editor.model.document.getRoot().getChild( 1 ) ), + 0 + ); } ); + + viewDocument.fire( 'paste', data ); + + expect( model.markers.has( 'comment:test:pasted' ) ).to.false; } ); } ); - describe( 'Presets', () => { - it( 'should not copy marker in default preset', () => { - clipboardMarkersUtils._registerMarkerToCopy( 'comment', 'default' ); + describe( 'duplicateOnPaste flag behavior', () => { + it( 'should insert marker with regenerated ID on cut and `duplicateOnPaste` is set to `false`', () => { + clipboardMarkersUtils._registerMarkerToCopy( 'comment', { + allowedActions: 'all', + duplicateOnPaste: false, + copyPartiallySelected: true + } ); setModelData( model, - wrapWithTag( 'paragraph', 'Hello World' ) + wrapWithTag( 'paragraph', '' ) + wrapWithTag( 'paragraph', '[Hello World]' ) + wrapWithTag( 'paragraph', '' ) ); - appendMarker( 'comment:test', { start: [ 0, 0 ], end: [ 0, 3 ] } ); + appendMarker( 'comment:test', { start: [ 0, 2 ], end: [ 0, 4 ] } ); + + const data = { + dataTransfer: createDataTransfer(), + preventDefault: () => {}, + stopPropagation: () => {} + }; + + viewDocument.fire( 'cut', data ); model.change( writer => { writer.setSelection( - writer.createRangeOn( editor.model.document.getRoot().getChild( 0 ) ), + writer.createRangeIn( editor.model.document.getRoot().getChild( 1 ) ), 0 ); } ); + viewDocument.fire( 'paste', data ); + + checkMarker( 'comment:test:pasted', { + start: [ 1, 2 ], + end: [ 1, 4 ] + } ); + + editor.execute( 'undo' ); + editor.execute( 'undo' ); + + // pasted comment is removed + expect( editor.model.markers.get( 'comment:test:pasted' ) ).to.be.null; + } ); + + it( 'should not insert marker with the same name on paste if `duplicateOnPaste` is set to `false`', () => { + clipboardMarkersUtils._registerMarkerToCopy( 'comment', { + allowedActions: 'all', + duplicateOnPaste: false, + copyPartiallySelected: true + } ); + + setModelData( + model, + wrapWithTag( 'paragraph', '[He]llo World' ) + wrapWithTag( 'paragraph', '' ) + wrapWithTag( 'paragraph', '' ) + ); + + appendMarker( 'comment:test', { start: [ 0, 1 ], end: [ 0, 4 ] } ); + const data = { dataTransfer: createDataTransfer(), preventDefault: () => {}, @@ -503,25 +532,32 @@ describe( 'Clipboard Markers Utils', () => { viewDocument.fire( 'paste', data ); + model.change( writer => { + writer.setSelection( + writer.createRangeIn( editor.model.document.getRoot().getChild( 2 ) ), + 0 + ); + } ); + + viewDocument.fire( 'paste', data ); + + expect( model.markers.has( 'comment:test' ) ).to.true; expect( model.markers.has( 'comment:test:pasted' ) ).to.false; } ); - it( 'should cut marker in default preset', () => { - clipboardMarkersUtils._registerMarkerToCopy( 'comment', 'default' ); + it( 'should insert marker with the same name on paste if `duplicateOnPaste` is set to `true`', () => { + clipboardMarkersUtils._registerMarkerToCopy( 'comment', { + allowedActions: 'all', + duplicateOnPaste: true, + copyPartiallySelected: true + } ); setModelData( model, - wrapWithTag( 'paragraph', 'Hello World' ) + wrapWithTag( 'paragraph', '' ) + wrapWithTag( 'paragraph', '[He]llo World' ) + wrapWithTag( 'paragraph', '' ) + wrapWithTag( 'paragraph', '' ) ); - appendMarker( 'comment:test', { start: [ 0, 0 ], end: [ 0, 3 ] } ); - - model.change( writer => { - writer.setSelection( - writer.createRangeOn( editor.model.document.getRoot().getChild( 0 ) ), - 0 - ); - } ); + appendMarker( 'comment:test', { start: [ 0, 1 ], end: [ 0, 4 ] } ); const data = { dataTransfer: createDataTransfer(), @@ -529,7 +565,7 @@ describe( 'Clipboard Markers Utils', () => { stopPropagation: () => {} }; - viewDocument.fire( 'cut', data ); + viewDocument.fire( 'copy', data ); model.change( writer => { writer.setSelection( @@ -538,51 +574,53 @@ describe( 'Clipboard Markers Utils', () => { ); } ); - viewDocument.fire( 'paste', data ); - expect( model.markers.has( 'comment:test:pasted' ) ).to.true; - } ); - - for ( const emptyPreset of [ 'never', 'dummy' ] ) { - it( `should not cut marker in ${ emptyPreset } preset`, () => { - clipboardMarkersUtils._registerMarkerToCopy( 'comment', emptyPreset ); + let pasteIndex = 0; + getUniqueMarkerNameStub = getUniqueMarkerNameStub.callsFake( () => `comment:test:${ pasteIndex++ }` ); - setModelData( - model, - wrapWithTag( 'paragraph', 'Hello World' ) + wrapWithTag( 'paragraph', '' ) + viewDocument.fire( 'paste', data ); + model.change( writer => { + writer.setSelection( + writer.createRangeIn( editor.model.document.getRoot().getChild( 2 ) ), + 0 ); + } ); - appendMarker( 'comment:test', { start: [ 0, 0 ], end: [ 0, 3 ] } ); + viewDocument.fire( 'paste', data ); + expect( model.markers.has( 'comment:test:0' ) ).to.true; + expect( model.markers.has( 'comment:test:1' ) ).to.true; + } ); + } ); - model.change( writer => { - writer.setSelection( - writer.createRangeOn( editor.model.document.getRoot().getChild( 0 ) ), - 0 - ); - } ); + describe( '_removeFakeMarkersInsideElement', () => { + it( 'should duplicate fake-markers in element if `duplicateOnPaste` = `true`', () => { + clipboardMarkersUtils._registerMarkerToCopy( 'comment', { + allowedActions: 'all', + duplicateOnPaste: true + } ); - const data = { - dataTransfer: createDataTransfer(), - preventDefault: () => {}, - stopPropagation: () => {} - }; + model.change( writer => { + const fragment = new DocumentFragment( [ + ...createFakeMarkerElements( writer, 'comment:123', [ + writer.createElement( 'paragraph' ) + ] ), + writer.createElement( 'paragraph' ), + ...createFakeMarkerElements( writer, 'comment:123', [ + writer.createElement( 'paragraph' ) + ] ) + ] ); - viewDocument.fire( 'cut', data ); + const markers = clipboardMarkersUtils._removeFakeMarkersInsideElement( writer, fragment ); - model.change( writer => { - writer.setSelection( - writer.createRangeIn( editor.model.document.getRoot().getChild( 1 ) ), - 0 - ); - } ); + expect( Object.keys( markers ) ).deep.equal( [ 'comment:123', 'comment:123:pasted' ] ); + } ); + } ); - viewDocument.fire( 'paste', data ); - expect( model.markers.has( 'comment:test:pasted' ) ).to.false; + it( 'should not duplicate fake-markers in element if `duplicateOnPaste` = `true`', () => { + clipboardMarkersUtils._registerMarkerToCopy( 'comment', { + allowedActions: 'all', + duplicateOnPaste: false } ); - } - } ); - describe( '_removeFakeMarkersInsideElement', () => { - it( 'should handle duplicated fake-markers in element', () => { model.change( writer => { const fragment = new DocumentFragment( [ ...createFakeMarkerElements( writer, 'comment:123', [ @@ -596,7 +634,7 @@ describe( 'Clipboard Markers Utils', () => { const markers = clipboardMarkersUtils._removeFakeMarkersInsideElement( writer, fragment ); - expect( Object.keys( markers ) ).deep.equal( [ 'comment:123', 'comment:123:pasted' ] ); + expect( Object.keys( markers ) ).deep.equal( [ 'comment:123' ] ); } ); } ); @@ -611,15 +649,105 @@ describe( 'Clipboard Markers Utils', () => { describe( '_forceMarkersCopy', () => { it( 'properly reverts old marker restricted actions', () => { - clipboardMarkersUtils._registerMarkerToCopy( 'comment', [ 'cut' ] ); + clipboardMarkersUtils._registerMarkerToCopy( 'comment', { + allowedActions: [ 'cut' ], + copyPartiallySelected: true + } ); + + clipboardMarkersUtils._forceMarkersCopy( 'comment', () => { + expect( getMarkerRestrictions() ).deep.equal( { + allowedActions: 'all', + duplicateOnPaste: true, + copyPartiallySelected: true + } ); + } ); - expect( getMarkerRestrictions() ).deep.equal( [ 'cut' ] ); + expect( getMarkerRestrictions() ).deep.equal( { + allowedActions: [ 'cut' ], + copyPartiallySelected: true + } ); + } ); + it( 'should be possible to force markers copy', () => { clipboardMarkersUtils._forceMarkersCopy( 'comment', () => { - expect( getMarkerRestrictions() ).deep.equal( clipboardMarkersUtils._mapRestrictionPresetToActions( 'always' ) ); + setModelData( + model, + wrapWithTag( 'paragraph', 'Start' ) + + wrapWithTag( 'paragraph', 'Foo Bar Test' ) + + wrapWithTag( 'paragraph', 'End' ) + ); + + appendMarker( 'comment:test', { start: [ 0 ], end: [ 3 ] } ); + model.change( writer => { + writer.setSelection( + writer.createRangeOn( editor.model.document.getRoot().getChild( 1 ) ), + 0 + ); + } ); + + const data = { + dataTransfer: createDataTransfer(), + preventDefault: () => {}, + stopPropagation: () => {} + }; + + viewDocument.fire( 'copy', data ); + + model.change( writer => { + writer.setSelection( + writer.createRangeIn( editor.model.document.getRoot().getChild( 2 ) ), + 0 + ); + } ); + + viewDocument.fire( 'paste', data ); + + checkMarker( 'comment:test:pasted', { + start: [ 2, 0 ], + end: [ 2, 12 ] + } ); } ); + } ); - expect( getMarkerRestrictions() ).deep.equal( [ 'cut' ] ); + it( 'should be possible to force markers copy #2 - unregistered marker', () => { + clipboardMarkersUtils._forceMarkersCopy( 'new', () => { + setModelData( + model, + wrapWithTag( 'paragraph', 'Start' ) + + wrapWithTag( 'paragraph', 'Foo Bar Test' ) + + wrapWithTag( 'paragraph', 'End' ) + ); + + appendMarker( 'new:test', { start: [ 0 ], end: [ 3 ] } ); + model.change( writer => { + writer.setSelection( + writer.createRangeOn( editor.model.document.getRoot().getChild( 1 ) ), + 0 + ); + } ); + + const data = { + dataTransfer: createDataTransfer(), + preventDefault: () => {}, + stopPropagation: () => {} + }; + + viewDocument.fire( 'copy', data ); + + model.change( writer => { + writer.setSelection( + writer.createRangeIn( editor.model.document.getRoot().getChild( 2 ) ), + 0 + ); + } ); + + viewDocument.fire( 'paste', data ); + + checkMarker( 'new:test:pasted', { + start: [ 2, 0 ], + end: [ 2, 12 ] + } ); + } ); } ); function getMarkerRestrictions() { @@ -627,53 +755,81 @@ describe( 'Clipboard Markers Utils', () => { } } ); - describe( '_canPerformMarkerClipboardAction', () => { + describe( '_isMarkerCopyable', () => { it( 'returns false on non existing clipboard markers', () => { - const result = clipboardMarkersUtils._canPerformMarkerClipboardAction( 'Hello', 'cut' ); + const result = clipboardMarkersUtils._isMarkerCopyable( 'Hello', 'cut' ); expect( result ).to.false; } ); } ); - describe( '_getCopyableMarkersFromRangeMap', () => { - it( 'properly filters markers Map instance', () => { - clipboardMarkersUtils._registerMarkerToCopy( 'comment', [ 'cut' ] ); + describe( '_getCopyableMarkersFromSelection', () => { + it( 'force regenerate marker id in `dragstart` action', () => { + clipboardMarkersUtils._registerMarkerToCopy( 'comment', { + allowedActions: [ 'dragstart' ], + copyPartiallySelected: true + } ); - const { markers } = createFragmentWithMarkers( - wrapWithTag( 'paragraph', 'Hello world' ), - { - 'comment:a': { start: [ 0, 0 ], end: [ 0, 6 ] }, - 'comment:b': { start: [ 0, 0 ], end: [ 0, 7 ] } - } + setModelData( + model, + wrapWithTag( 'paragraph', '[Hello World]' ) ); - let result = clipboardMarkersUtils._getCopyableMarkersFromRangeMap( markers, 'copy' ); + appendMarker( 'comment:test', { start: [ 0, 0 ], end: [ 0, 4 ] } ); + + const result = model.change( + writer => clipboardMarkersUtils._getCopyableMarkersFromSelection( writer, writer.model.document.selection, 'dragstart' ) + ); + + expect( result[ 0 ].name ).to.equal( 'comment:test:pasted' ); + } ); + } ); + + describe( '_getPasteMarkersFromRangeMap', () => { + it( 'keeps unknown markers', () => { + const copyMarkers = createCopyableMarkersMap( new DocumentFragment(), { + 'unknown-marker': { start: [ 0, 0 ], end: [ 0, 6 ] } + } ); + + const result = clipboardMarkersUtils._getPasteMarkersFromRangeMap( copyMarkers ); + + expect( result ).be.deep.equal( [ + { name: 'unknown-marker', range: copyMarkers.get( 'unknown-marker' ) } + ] ); + } ); + + it( 'properly filters markers Map instance', () => { + clipboardMarkersUtils._registerMarkerToCopy( 'comment', { allowedActions: [ 'cut' ] } ); + + const copyMarkers = createCopyableMarkersMap( new DocumentFragment(), { + 'comment:a': { start: [ 0, 0 ], end: [ 0, 6 ] }, + 'comment:b': { start: [ 0, 0 ], end: [ 0, 7 ] } + } ); + + let result = clipboardMarkersUtils._getPasteMarkersFromRangeMap( copyMarkers, 'copy' ); expect( result ).be.deep.equal( [] ); - result = clipboardMarkersUtils._getCopyableMarkersFromRangeMap( markers, 'cut' ); + result = clipboardMarkersUtils._getPasteMarkersFromRangeMap( copyMarkers, 'cut' ); expect( result ).be.deep.equal( [ - { name: 'comment:a', range: markers.get( 'comment:a' ) }, - { name: 'comment:b', range: markers.get( 'comment:b' ) } + { name: 'comment:a', range: copyMarkers.get( 'comment:a' ) }, + { name: 'comment:b', range: copyMarkers.get( 'comment:b' ) } ] ); } ); it( 'properly filters markers Record instance', () => { - clipboardMarkersUtils._registerMarkerToCopy( 'comment', [ 'cut' ] ); + clipboardMarkersUtils._registerMarkerToCopy( 'comment', { allowedActions: [ 'cut' ] } ); const markers = Object.fromEntries( - createFragmentWithMarkers( - wrapWithTag( 'paragraph', 'Hello world' ), - { - 'comment:a': { start: [ 0, 0 ], end: [ 0, 6 ] }, - 'comment:b': { start: [ 0, 0 ], end: [ 0, 7 ] } - } - ).markers.entries() + createCopyableMarkersMap( new DocumentFragment(), { + 'comment:a': { start: [ 0, 0 ], end: [ 0, 6 ] }, + 'comment:b': { start: [ 0, 0 ], end: [ 0, 7 ] } + } ).entries() ); - let result = clipboardMarkersUtils._getCopyableMarkersFromRangeMap( markers, 'copy' ); + let result = clipboardMarkersUtils._getPasteMarkersFromRangeMap( markers, 'copy' ); expect( result ).be.deep.equal( [] ); - result = clipboardMarkersUtils._getCopyableMarkersFromRangeMap( markers, 'cut' ); + result = clipboardMarkersUtils._getPasteMarkersFromRangeMap( markers, 'cut' ); expect( result ).be.deep.equal( [ { name: 'comment:a', range: markers[ 'comment:a' ] }, { name: 'comment:b', range: markers[ 'comment:b' ] } @@ -708,43 +864,15 @@ describe( 'Clipboard Markers Utils', () => { describe( '_pasteMarkersIntoTransformedElement', () => { beforeEach( () => { - clipboardMarkersUtils._registerMarkerToCopy( 'comment', 'always' ); - } ); - - it( 'should preserve original marker name if it is not duplicated', () => { - const copiedFragment = createFragmentWithMarkers( - wrapWithTag( 'paragraph', 'Hello world' ), - { - 'comment:a': { start: [ 0, 0 ], end: [ 0, 6 ] }, - 'comment:b': { start: [ 0, 0 ], end: [ 0, 7 ] } - } - ); - - clipboardMarkersUtils._pasteMarkersIntoTransformedElement( - copiedFragment.markers, - writer => { - writer.insert( copiedFragment, modelRoot, 0 ); - writer.removeMarker( 'comment:a' ); - writer.removeMarker( 'comment:b' ); - - return editor.model.document.getRoot().getChild( 0 ); - } - ); - - checkMarker( 'comment:a', model.createRange( - model.createPositionFromPath( modelRoot, [ 0, 0 ] ), - model.createPositionFromPath( modelRoot, [ 0, 6 ] ) - ) ); - - checkMarker( 'comment:b', model.createRange( - model.createPositionFromPath( modelRoot, [ 0, 0 ] ), - model.createPositionFromPath( modelRoot, [ 0, 7 ] ) - ) ); + clipboardMarkersUtils._registerMarkerToCopy( 'comment', { + allowedActions: 'all' + } ); } ); it( 'should add real markers to pasted fragment (overlap at the start)', () => { - const copiedFragment = createFragmentWithMarkers( - wrapWithTag( 'paragraph', 'Hello world' ), + const copiedFragment = createFragment( wrapWithTag( 'paragraph', 'Hello world' ) ); + const copyMarkers = createCopyableMarkersMap( + copiedFragment, { 'comment:a': { start: [ 0, 0 ], end: [ 0, 6 ] }, 'comment:b': { start: [ 0, 0 ], end: [ 0, 7 ] } @@ -752,27 +880,28 @@ describe( 'Clipboard Markers Utils', () => { ); clipboardMarkersUtils._pasteMarkersIntoTransformedElement( - copiedFragment.markers, + copyMarkers, writer => { writer.insert( copiedFragment, modelRoot, 0 ); return editor.model.document.getRoot().getChild( 0 ); } ); - checkMarker( 'comment:a:pasted', model.createRange( - model.createPositionFromPath( modelRoot, [ 0, 0 ] ), - model.createPositionFromPath( modelRoot, [ 0, 6 ] ) - ) ); + checkMarker( 'comment:a', { + start: [ 0, 0 ], + end: [ 0, 6 ] + } ); - checkMarker( 'comment:b:pasted', model.createRange( - model.createPositionFromPath( modelRoot, [ 0, 0 ] ), - model.createPositionFromPath( modelRoot, [ 0, 7 ] ) - ) ); + checkMarker( 'comment:b', { + start: [ 0, 0 ], + end: [ 0, 7 ] + } ); } ); it( 'should add real markers to pasted fragment (overlap at the end)', () => { - const copiedFragment = createFragmentWithMarkers( - wrapWithTag( 'paragraph', 'Hello world' ), + const copiedFragment = createFragment( wrapWithTag( 'paragraph', 'Hello world' ) ); + const copyMarkers = createCopyableMarkersMap( + copiedFragment, { 'comment:a': { start: [ 0, 5 ], end: [ 0, 11 ] }, 'comment:b': { start: [ 0, 4 ], end: [ 0, 11 ] } @@ -780,27 +909,28 @@ describe( 'Clipboard Markers Utils', () => { ); clipboardMarkersUtils._pasteMarkersIntoTransformedElement( - copiedFragment.markers, + copyMarkers, writer => { writer.insert( copiedFragment, modelRoot, 0 ); return editor.model.document.getRoot().getChild( 0 ); } ); - checkMarker( 'comment:a:pasted', model.createRange( - model.createPositionFromPath( modelRoot, [ 0, 5 ] ), - model.createPositionFromPath( modelRoot, [ 0, 11 ] ) - ) ); + checkMarker( 'comment:a', { + start: [ 0, 5 ], + end: [ 0, 11 ] + } ); - checkMarker( 'comment:b:pasted', model.createRange( - model.createPositionFromPath( modelRoot, [ 0, 4 ] ), - model.createPositionFromPath( modelRoot, [ 0, 11 ] ) - ) ); + checkMarker( 'comment:b', { + start: [ 0, 4 ], + end: [ 0, 11 ] + } ); } ); it( 'should add real markers to pasted fragment (overlap at center)', () => { - const copiedFragment = createFragmentWithMarkers( - wrapWithTag( 'paragraph', 'Hello world' ), + const copiedFragment = createFragment( wrapWithTag( 'paragraph', 'Hello world' ) ); + const copyMarkers = createCopyableMarkersMap( + copiedFragment, { 'comment:a': { start: [ 0, 4 ], end: [ 0, 8 ] }, 'comment:b': { start: [ 0, 0 ], end: [ 0, 11 ] } @@ -808,52 +938,28 @@ describe( 'Clipboard Markers Utils', () => { ); clipboardMarkersUtils._pasteMarkersIntoTransformedElement( - copiedFragment.markers, + copyMarkers, writer => { writer.insert( copiedFragment, modelRoot, 0 ); return editor.model.document.getRoot().getChild( 0 ); } ); - checkMarker( 'comment:a:pasted', model.createRange( - model.createPositionFromPath( modelRoot, [ 0, 4 ] ), - model.createPositionFromPath( modelRoot, [ 0, 8 ] ) - ) ); - - checkMarker( 'comment:b:pasted', model.createRange( - model.createPositionFromPath( modelRoot, [ 0, 0 ] ), - model.createPositionFromPath( modelRoot, [ 0, 11 ] ) - ) ); - } ); - } ); - - describe( '_setUniqueMarkerNamesInFragment', () => { - beforeEach( () => { - clipboardMarkersUtils._registerMarkerToCopy( 'comment', 'always' ); - } ); - - it( 'do not regenerate name of marker if not copyable', () => { - const fragment = createFragmentWithMarkers( 'ABC', { - 'comment:1123:1': { - start: [ 0 ], - end: [ 1 ] - }, - 'marker-1': { - start: [ 0 ], - end: [ 1 ] - } + checkMarker( 'comment:a', { + start: [ 0, 4 ], + end: [ 0, 8 ] } ); - clipboardMarkersUtils._setUniqueMarkerNamesInFragment( fragment ); - - expect( fragment.markers.has( 'comment:1123:1:pasted' ) ).to.be.true; - expect( fragment.markers.has( 'marker-1' ) ).to.be.true; + checkMarker( 'comment:b', { + start: [ 0, 0 ], + end: [ 0, 11 ] + } ); } ); } ); async function createEditor() { editor = await ClassicTestEditor.create( element, { - plugins: [ Paragraph, Clipboard ] + plugins: [ Undo, Paragraph, Clipboard ] } ); model = editor.model; @@ -861,7 +967,7 @@ describe( 'Clipboard Markers Utils', () => { viewDocument = editor.editing.view.document; clipboardMarkersUtils = editor.plugins.get( 'ClipboardMarkersUtils' ); - clipboardMarkersUtils._registerMarkerToCopy( 'comment', [ ] ); + clipboardMarkersUtils._registerMarkerToCopy( 'comment', { allowedActions: [ ] } ); getUniqueMarkerNameStub = sinon .stub( clipboardMarkersUtils, '_getUniqueMarkerName' ) @@ -884,6 +990,30 @@ describe( 'Clipboard Markers Utils', () => { } ); } + function createFragment( content ) { + let parsedContent = parse( content, model.schema, { + context: [ '$clipboardHolder' ] + } ); + + if ( !parsedContent.is( 'documentFragment' ) ) { + parsedContent = new DocumentFragment( [ parsedContent ] ); + } + + return parsedContent; + } + + function createCopyableMarkersMap( fragment, markers ) { + const markersMap = new Map(); + + for ( const [ name, value ] of Object.entries( markers ) ) { + markersMap.set( name, new Range( + new Position( fragment, value.start ), new Position( fragment, value.end ) + ) ); + } + + return markersMap; + } + function wrapWithTag( tag, content ) { return `<${ tag }>${ content }`; } @@ -903,7 +1033,15 @@ describe( 'Clipboard Markers Utils', () => { const marker = editor.model.markers.get( name ); expect( marker ).to.not.be.null; - expect( marker.getRange().isEqual( range ) ).to.be.true; + + if ( range instanceof Range ) { + expect( marker.getRange().isEqual( range ) ).to.be.true; + } else { + const markerRange = marker.getRange(); + + expect( markerRange.start.path ).to.deep.equal( range.start ); + expect( markerRange.end.path ).to.deep.equal( range.end ); + } } function createDataTransfer() { @@ -919,28 +1057,4 @@ describe( 'Clipboard Markers Utils', () => { } }; } - - function createFragmentWithMarkers( content, markers ) { - let parsedContent = parse( content, model.schema, { - context: [ '$clipboardHolder' ] - } ); - - if ( markers && !parsedContent.is( 'documentFragment' ) ) { - parsedContent = new DocumentFragment( [ parsedContent ] ); - } - - if ( markers ) { - const markersMap = new Map(); - - for ( const [ name, value ] of Object.entries( markers ) ) { - markersMap.set( name, new Range( - new Position( parsedContent, value.start ), new Position( parsedContent, value.end ) - ) ); - } - - parsedContent.markers = markersMap; - } - - return parsedContent; - } } ); diff --git a/packages/ckeditor5-core/src/index.ts b/packages/ckeditor5-core/src/index.ts index 2112f126523..f8de44b8a42 100644 --- a/packages/ckeditor5-core/src/index.ts +++ b/packages/ckeditor5-core/src/index.ts @@ -17,7 +17,7 @@ export { default as Context, type ContextConfig } from './context.js'; export { default as ContextPlugin, type ContextPluginDependencies } from './contextplugin.js'; export { type EditingKeystrokeCallback } from './editingkeystrokehandler.js'; -export type { PartialBy } from './typings.js'; +export type { PartialBy, NonEmptyArray } from './typings.js'; export { default as Editor, type EditorReadyEvent, type EditorDestroyEvent } from './editor/editor.js'; export type { diff --git a/packages/ckeditor5-core/src/typings.ts b/packages/ckeditor5-core/src/typings.ts index 6d7f467232d..eb273b14fe4 100644 --- a/packages/ckeditor5-core/src/typings.ts +++ b/packages/ckeditor5-core/src/typings.ts @@ -8,3 +8,7 @@ */ export type PartialBy = Omit & Partial>; + +export type NonEmptyArray = Array & { + 0: A; + }; diff --git a/packages/ckeditor5-engine/src/model/differ.ts b/packages/ckeditor5-engine/src/model/differ.ts index cc197127eb4..1c658aa7c84 100644 --- a/packages/ckeditor5-engine/src/model/differ.ts +++ b/packages/ckeditor5-engine/src/model/differ.ts @@ -385,7 +385,7 @@ export default class Differ { * * changes of markers' `affectsData` property. */ public hasDataChanges(): boolean { - if ( this._changesInElement.size > 0 ) { + if ( this.getChanges().length ) { return true; } diff --git a/packages/ckeditor5-engine/src/model/utils/insertcontent.ts b/packages/ckeditor5-engine/src/model/utils/insertcontent.ts index 676bfee7e42..c4750516338 100644 --- a/packages/ckeditor5-engine/src/model/utils/insertcontent.ts +++ b/packages/ckeditor5-engine/src/model/utils/insertcontent.ts @@ -188,7 +188,7 @@ export default function insertContent( for ( const [ name, [ start, end ] ] of Object.entries( markersData ) ) { // For now, we ignore markers if they are included in the filtered-out content. // In the future implementation we will improve that case to create markers that are not filtered out completely. - if ( start && end && start.root === end.root && start.root.document ) { + if ( start && end && start.root === end.root && start.root.document && !writer.model.markers.has( name ) ) { writer.addMarker( name, { usingOperation: true, affectsData: true, diff --git a/packages/ckeditor5-engine/tests/model/differ.js b/packages/ckeditor5-engine/tests/model/differ.js index 3caff02340a..56e3accdb24 100644 --- a/packages/ckeditor5-engine/tests/model/differ.js +++ b/packages/ckeditor5-engine/tests/model/differ.js @@ -2703,7 +2703,7 @@ describe( 'Differ', () => { expect( differ.getChangedRoots().length ).to.equal( 0 ); // It has changes in graveyard! - expect( differ.hasDataChanges() ).to.be.true; + expect( differ.hasDataChanges() ).to.be.false; } ); } ); diff --git a/packages/ckeditor5-table/src/tableclipboard.ts b/packages/ckeditor5-table/src/tableclipboard.ts index 082698f9e2f..7afb8da1f93 100644 --- a/packages/ckeditor5-table/src/tableclipboard.ts +++ b/packages/ckeditor5-table/src/tableclipboard.ts @@ -108,16 +108,18 @@ export default class TableClipboard extends Plugin { data.preventDefault(); evt.stop(); - const fragment = clipboardMarkersUtils._copySelectedFragmentWithMarkers( - evt.name, - this.editor.model.document.selection, - () => tableSelection.getSelectionAsFragment()! - ); + this.editor.model.enqueueChange( { isUndoable: evt.name === 'cut' }, () => { + const documentFragment = clipboardMarkersUtils._copySelectedFragmentWithMarkers( + evt.name, + this.editor.model.document.selection, + () => tableSelection.getSelectionAsFragment()! + ); - view.document.fire( 'clipboardOutput', { - dataTransfer: data.dataTransfer, - content: this.editor.data.toView( fragment ), - method: evt.name + view.document.fire( 'clipboardOutput', { + dataTransfer: data.dataTransfer, + content: this.editor.data.toView( documentFragment ), + method: evt.name + } ); } ); } diff --git a/packages/ckeditor5-table/tests/tableclipboard-paste.js b/packages/ckeditor5-table/tests/tableclipboard-paste.js index ed2ab427f72..a15177ab45a 100644 --- a/packages/ckeditor5-table/tests/tableclipboard-paste.js +++ b/packages/ckeditor5-table/tests/tableclipboard-paste.js @@ -17,7 +17,7 @@ import Input from '@ckeditor/ckeditor5-typing/src/input.js'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils.js'; import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model.js'; import { assertSelectedCells, formatAttributes, modelTable, viewTable } from './_utils/utils.js'; - +import { Range } from '@ckeditor/ckeditor5-engine'; import TableEditing from '../src/tableediting.js'; import TableCellPropertiesEditing from '../src/tablecellproperties/tablecellpropertiesediting.js'; import TableCellWidthEditing from '../src/tablecellwidth/tablecellwidthediting.js'; @@ -27,7 +27,7 @@ import TableClipboard from '../src/tableclipboard.js'; import TableColumnResize from '../src/tablecolumnresize.js'; describe( 'table clipboard', () => { - let editor, model, modelRoot, tableSelection, viewDocument, element, clipboardMarkersUtils; + let editor, model, modelRoot, tableSelection, viewDocument, element, clipboardMarkersUtils, getUniqueMarkerNameStub; testUtils.createSinonSandbox(); @@ -4076,10 +4076,20 @@ describe( 'table clipboard', () => { beforeEach( async () => { await createEditor(); - clipboardMarkersUtils._registerMarkerToCopy( 'comment', [ 'copy' ] ); - sinon + clipboardMarkersUtils._registerMarkerToCopy( 'comment', { + allowedActions: [ 'copy' ], + duplicateOnPaste: true + } ); + + getUniqueMarkerNameStub = sinon .stub( clipboardMarkersUtils, '_getUniqueMarkerName' ) - .callsFake( markerName => `${ markerName }:uniq` ); + .callsFake( markerName => { + if ( markerName.endsWith( 'uniq' ) ) { + return markerName; + } + + return `${ markerName }:uniq`; + } ); markerConversion(); } ); @@ -4092,7 +4102,7 @@ describe( 'table clipboard', () => { } ); pasteTable( - [ [ wrapWithMarker( 'FooBarr', 'comment', { name: 'paste' } ) ] ] + [ [ wrapWithHTMLMarker( 'FooBarr', 'comment', { name: 'paste' } ) ] ] ); const paragraph = modelRoot.getNodeByPath( [ 1, 0, 0, 0 ] ); @@ -4110,24 +4120,21 @@ describe( 'table clipboard', () => { pasteTable( [ [ - wrapWithMarker( 'First', 'comment', { name: 'pre' } ), - wrapWithMarker( 'Second', 'comment', { name: 'post' } ) + wrapWithHTMLMarker( 'First', 'comment', { name: 'pre' } ), + wrapWithHTMLMarker( 'Second', 'comment', { name: 'post' } ) ] ] ); - const prePosition = model.createRange( - model.createPositionFromPath( modelRoot, [ 1, 0, 0, 0, 0 ] ), - model.createPositionFromPath( modelRoot, [ 1, 0, 0, 0, 5 ] ) - ); - - const postPosition = model.createRange( - model.createPositionFromPath( modelRoot, [ 1, 0, 1, 0, 0 ] ), - model.createPositionFromPath( modelRoot, [ 1, 0, 1, 0, 6 ] ) - ); + checkMarker( 'comment:pre:uniq', { + start: [ 1, 0, 0, 0, 0 ], + end: [ 1, 0, 0, 0, 5 ] + } ); - checkMarker( 'comment:pre:uniq', prePosition ); - checkMarker( 'comment:post:uniq', postPosition ); + checkMarker( 'comment:post:uniq', { + start: [ 1, 0, 1, 0, 0 ], + end: [ 1, 0, 1, 0, 6 ] + } ); } ); it( 'should paste table with multiple markers to single cell', () => { @@ -4140,25 +4147,22 @@ describe( 'table clipboard', () => { pasteTable( [ [ - wrapWithMarker( 'FooBarr', 'comment', { name: 'pre' } ) + + wrapWithHTMLMarker( 'FooBarr', 'comment', { name: 'pre' } ) + 'foo fo fooo' + - wrapWithMarker( 'a foo bar fo bar', 'comment', { name: 'post' } ) + wrapWithHTMLMarker( 'a foo bar fo bar', 'comment', { name: 'post' } ) ] ] ); - const prePosition = model.createRange( - model.createPositionFromPath( modelRoot, [ 1, 0, 0, 0, 0 ] ), - model.createPositionFromPath( modelRoot, [ 1, 0, 0, 0, 7 ] ) - ); - - const postPosition = model.createRange( - model.createPositionFromPath( modelRoot, [ 1, 0, 0, 0, 18 ] ), - model.createPositionFromPath( modelRoot, [ 1, 0, 0, 0, 34 ] ) - ); + checkMarker( 'comment:pre:uniq', { + start: [ 1, 0, 0, 0, 0 ], + end: [ 1, 0, 0, 0, 7 ] + } ); - checkMarker( 'comment:pre:uniq', prePosition ); - checkMarker( 'comment:post:uniq', postPosition ); + checkMarker( 'comment:post:uniq', { + start: [ 1, 0, 0, 0, 18 ], + end: [ 1, 0, 0, 0, 34 ] + } ); } ); it( 'should handle paste markers that contain markers', () => { @@ -4168,22 +4172,22 @@ describe( 'table clipboard', () => { writer.setSelection( modelRoot.getNodeByPath( [ 1, 0, 0 ] ), 0 ); } ); - const innerMarker = wrapWithMarker( 'ello', 'comment', { name: 'inner' } ); - const outerMarker = wrapWithMarker( 'H' + innerMarker + ' world', 'comment', { name: 'outer' } ); + const innerMarker = wrapWithHTMLMarker( 'ello', 'comment', { name: 'inner' } ); + const outerMarker = wrapWithHTMLMarker( 'H' + innerMarker + ' world', 'comment', { name: 'outer' } ); pasteTable( [ [ outerMarker ] ] ); - checkMarker( 'comment:outer:uniq', model.createRange( - model.createPositionFromPath( modelRoot, [ 1, 0, 0, 0, 0 ] ), - model.createPositionFromPath( modelRoot, [ 1, 0, 0, 0, 11 ] ) - ) ); + checkMarker( 'comment:outer:uniq', { + start: [ 1, 0, 0, 0, 0 ], + end: [ 1, 0, 0, 0, 11 ] + } ); - checkMarker( 'comment:inner:uniq', model.createRange( - model.createPositionFromPath( modelRoot, [ 1, 0, 0, 0, 1 ] ), - model.createPositionFromPath( modelRoot, [ 1, 0, 0, 0, 5 ] ) - ) ); + checkMarker( 'comment:inner:uniq', { + start: [ 1, 0, 0, 0, 1 ], + end: [ 1, 0, 0, 0, 5 ] + } ); } ); it( 'should not crash if user copy column to row with markers', () => { @@ -4191,7 +4195,7 @@ describe( 'table clipboard', () => { pasteTable( [ [ - wrapWithMarker( 'First', 'comment', { name: 'pre' } ), + wrapWithHTMLMarker( 'First', 'comment', { name: 'pre' } ), '' ], [ '', '' ] @@ -4224,7 +4228,210 @@ describe( 'table clipboard', () => { ); } ); - function wrapWithMarker( contents, marker, attrs ) { + it( 'should paste table that is entirely wrapped in marker', () => { + setModelData( model, modelTable( [ [ 'A', 'B' ] ] ) + '' ); + appendMarker( 'comment:thread', { + start: [ 0 ], + end: [ 1 ] + } ); + + model.change( writer => { + writer.setSelection( writer.createRangeOn( modelRoot.getNodeByPath( [ 0 ] ) ) ); + } ); + + const data = { + dataTransfer: createDataTransfer(), + preventDefault: () => {}, + stopPropagation: () => {} + }; + + viewDocument.fire( 'copy', data ); + + model.change( writer => { + writer.setSelection( modelRoot.getNodeByPath( [ 1 ] ), 0 ); + } ); + + viewDocument.fire( 'paste', data ); + + checkMarker( 'comment:thread', { + start: [ 0 ], + end: [ 1 ] + } ); + + checkMarker( 'comment:thread:uniq', { + start: [ 1 ], + end: [ 2 ] + } ); + } ); + + describe( 'Markers duplications', () => { + beforeEach( () => { + let index = 0; + + getUniqueMarkerNameStub.callsFake( markerName => { + const markerWithoutID = markerName.split( ':' ).slice( 0, 2 ).join( ':' ); + + return `${ markerWithoutID }:${ index++ }`; + } ); + } ); + + it( 'should paste row with single marker as column with duplicated markers to empty last column', () => { + pasteTable( + [ + [ '', '', '' ], + [ '', '', '' ], + [ + wrapWithHTMLMarker( 'Foo', 'comment', { name: 'thread' } ), + '', + '' + ] + ] + ); + + // copy whole last row + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 2, 0 ] ), + modelRoot.getNodeByPath( [ 0, 2, 2 ] ) + ); + + const data = { + dataTransfer: createDataTransfer(), + preventDefault: () => {}, + stopPropagation: () => {} + }; + + viewDocument.fire( 'copy', data ); + + // paste into last column + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 2 ] ), + modelRoot.getNodeByPath( [ 0, 2, 2 ] ) + ); + + viewDocument.fire( 'paste', data ); + + // check if markers are present on proper positions + checkMarker( 'comment:thread:0', { + start: [ 0, 2, 0, 0, 0 ], + end: [ 0, 2, 0, 0, 3 ] + } ); + + checkMarker( 'comment:thread:2', { + start: [ 0, 2, 2, 0, 0 ], + end: [ 0, 2, 2, 0, 3 ] + } ); + + checkMarker( 'comment:thread:3', { + start: [ 0, 0, 2, 0, 0 ], + end: [ 0, 0, 2, 0, 3 ] + } ); + + checkMarker( 'comment:thread:4', { + start: [ 0, 1, 2, 0, 0 ], + end: [ 0, 1, 2, 0, 3 ] + } ); + } ); + + it( 'should paste row with single marker as column with duplicated markers to non-empty first-column', () => { + pasteTable( + [ + [ wrapWithHTMLMarker( 'Foo', 'comment', { name: 'thread' } ), '', '' ], + [ '', '', '' ], + [ '', '', '' ] + ] + ); + + // copy whole first row + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 0, 2 ] ) + ); + + const data = { + dataTransfer: createDataTransfer(), + preventDefault: () => {}, + stopPropagation: () => {} + }; + + viewDocument.fire( 'copy', data ); + + // paste into last column + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 2, 0 ] ) + ); + + viewDocument.fire( 'paste', data ); + + // check if markers are present on proper positions + checkMarker( 'comment:thread:2', { + start: [ 0, 2, 0, 0, 0 ], + end: [ 0, 2, 0, 0, 3 ] + } ); + + checkMarker( 'comment:thread:3', { + start: [ 0, 0, 0, 0, 0 ], + end: [ 0, 0, 0, 0, 3 ] + } ); + + checkMarker( 'comment:thread:4', { + start: [ 0, 1, 0, 0, 0 ], + end: [ 0, 1, 0, 0, 3 ] + } ); + } ); + + it( 'should paste row with two markers to first column', () => { + pasteTable( + [ + [ '', '' ], + [ + wrapWithHTMLMarker( 'Foo', 'comment', { name: 'start' } ), + wrapWithHTMLMarker( 'Foo', 'comment', { name: 'end' } ) + ] + ] + ); + + // copy whole last row + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 1, 0 ] ), + modelRoot.getNodeByPath( [ 0, 1, 1 ] ) + ); + + const data = { + dataTransfer: createDataTransfer(), + preventDefault: () => {}, + stopPropagation: () => {} + }; + + viewDocument.fire( 'copy', data ); + + // paste into first column + tableSelection.setCellSelection( + modelRoot.getNodeByPath( [ 0, 0, 0 ] ), + modelRoot.getNodeByPath( [ 0, 1, 0 ] ) + ); + + viewDocument.fire( 'paste', data ); + + // check if markers are present on proper positions + checkMarker( 'comment:start:6', { + start: [ 0, 0, 0, 0, 0 ], + end: [ 0, 0, 0, 0, 3 ] + } ); + + checkMarker( 'comment:start:4', { + start: [ 0, 1, 0, 0, 0 ], + end: [ 0, 1, 0, 0, 3 ] + } ); + + checkMarker( 'comment:end:1', { + start: [ 0, 1, 1, 0, 0 ], + end: [ 0, 1, 1, 0, 3 ] + } ); + } ); + } ); + + function wrapWithHTMLMarker( contents, marker, attrs ) { const formattedAttributes = formatAttributes( attrs ); return [ @@ -4234,11 +4441,30 @@ describe( 'table clipboard', () => { ].join( '' ); } + function appendMarker( name, { start, end } ) { + return editor.model.change( writer => { + const range = model.createRange( + model.createPositionFromPath( modelRoot, start ), + model.createPositionFromPath( modelRoot, end ) + ); + + return writer.addMarker( name, { usingOperation: false, affectsData: true, range } ); + } ); + } + function checkMarker( name, range ) { const marker = editor.model.markers.get( name ); expect( marker ).to.not.be.null; - expect( marker.getRange().isEqual( range ) ).to.be.true; + + if ( range instanceof Range ) { + expect( marker.getRange().isEqual( range ) ).to.be.true; + } else { + const markerRange = marker.getRange(); + + expect( markerRange.start.path ).to.deep.equal( range.start ); + expect( markerRange.end.path ).to.deep.equal( range.end ); + } } function markerConversion( ) {