diff --git a/packages/ckeditor5-link/src/linkediting.js b/packages/ckeditor5-link/src/linkediting.js index 07a3ef3b314..d3043545862 100644 --- a/packages/ckeditor5-link/src/linkediting.js +++ b/packages/ckeditor5-link/src/linkediting.js @@ -17,6 +17,7 @@ import LinkCommand from './linkcommand'; import UnlinkCommand from './unlinkcommand'; import ManualDecorator from './utils/manualdecorator'; import findAttributeRange from '@ckeditor/ckeditor5-typing/src/utils/findattributerange'; +import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; import { createLinkElement, ensureSafeUrl, getLocalizedDecorators, normalizeDecorators } from './utils'; import '../theme/link.css'; @@ -116,6 +117,9 @@ export default class LinkEditing extends Plugin { // Handle typing over the link. this._enableTypingOverLink(); + + // Handle removing the content after the link element. + this._handleDeleteContentAfterLink(); } /** @@ -223,8 +227,9 @@ export default class LinkEditing extends Plugin { const editor = this.editor; const model = editor.model; const selection = model.document.selection; + const linkCommand = editor.commands.get( 'link' ); - model.on( 'insertContent', () => { + this.listenTo( model, 'insertContent', () => { const nodeBefore = selection.anchor.nodeBefore; const nodeAfter = selection.anchor.nodeAfter; @@ -291,13 +296,8 @@ export default class LinkEditing extends Plugin { return; } - // Make the selection free of link-related model attributes. - // All link-related model attributes start with "link". That includes not only "linkHref" - // but also all decorator attributes (they have dynamic names). model.change( writer => { - [ ...model.document.selection.getAttributeKeys() ] - .filter( name => name.startsWith( 'link' ) ) - .forEach( name => writer.removeSelectionAttribute( name ) ); + removeLinkAttributesFromSelection( writer, linkCommand.manualDecorators ); } ); }, { priority: 'low' } ); } @@ -315,6 +315,7 @@ export default class LinkEditing extends Plugin { */ _enableClickingAfterLink() { const editor = this.editor; + const linkCommand = editor.commands.get( 'link' ); editor.editing.view.addObserver( MouseObserver ); @@ -353,11 +354,7 @@ export default class LinkEditing extends Plugin { // If so, remove the `linkHref` attribute. if ( position.isTouching( linkRange.start ) || position.isTouching( linkRange.end ) ) { editor.model.change( writer => { - writer.removeSelectionAttribute( 'linkHref' ); - - for ( const manualDecorator of editor.commands.get( 'link' ).manualDecorators ) { - writer.removeSelectionAttribute( manualDecorator.id ); - } + removeLinkAttributesFromSelection( writer, linkCommand.manualDecorators ); } ); } } ); @@ -438,6 +435,93 @@ export default class LinkEditing extends Plugin { selectionAttributes = null; }, { priority: 'high' } ); } + + /** + * Starts listening to {@link module:engine/model/model~Model#deleteContent} and checks whether + * removing a content right after the "linkHref" attribute. + * + * If so, the selection should not preserve the `linkHref` attribute. However, if + * the {@link module:typing/twostepcaretmovement~TwoStepCaretMovement} plugin is active and + * the selection has the "linkHref" attribute due to overriden gravity (at the end), the `linkHref` attribute should stay untouched. + * + * The purpose of this action is to allow removing the link text and keep the selection outside the link. + * + * See https://github.com/ckeditor/ckeditor5/issues/7521. + * + * @private + */ + _handleDeleteContentAfterLink() { + const editor = this.editor; + const model = editor.model; + const selection = model.document.selection; + const view = editor.editing.view; + const linkCommand = editor.commands.get( 'link' ); + + // A flag whether attributes `linkHref` attribute should be preserved. + let shouldPreserveAttributes = false; + + // A flag whether the `Backspace` key was pressed. + let hasBackspacePressed = false; + + // Detect pressing `Backspace`. + this.listenTo( view.document, 'delete', ( evt, data ) => { + hasBackspacePressed = data.domEvent.keyCode === keyCodes.backspace; + }, { priority: 'high' } ); + + // Before removing the content, check whether the selection is inside a link or at the end of link but with 2-SCM enabled. + // If so, we want to preserve link attributes. + this.listenTo( model, 'deleteContent', () => { + // Reset the state. + shouldPreserveAttributes = false; + + const position = selection.getFirstPosition(); + const linkHref = selection.getAttribute( 'linkHref' ); + + if ( !linkHref ) { + return; + } + + const linkRange = findAttributeRange( position, 'linkHref', linkHref, model ); + + // Preserve `linkHref` attribute if the selection is in the middle of the link or + // the selection is at the end of the link and 2-SCM is activated. + shouldPreserveAttributes = linkRange.containsPosition( position ) || linkRange.end.isEqual( position ); + }, { priority: 'high' } ); + + // After removing the content, check whether the current selection should preserve the `linkHref` attribute. + this.listenTo( model, 'deleteContent', () => { + // If didn't press `Backspace`. + if ( !hasBackspacePressed ) { + return; + } + + hasBackspacePressed = false; + + // Disable the mechanism if inside a link (`<$text url="foo">F[]oo` or <$text url="foo">Foo[]`). + if ( shouldPreserveAttributes ) { + return; + } + + // Use `model.enqueueChange()` in order to execute the callback at the end of the changes process. + editor.model.enqueueChange( writer => { + removeLinkAttributesFromSelection( writer, linkCommand.manualDecorators ); + } ); + }, { priority: 'low' } ); + } +} + +// Make the selection free of link-related model attributes. +// All link-related model attributes start with "link". That includes not only "linkHref" +// but also all decorator attributes (they have dynamic names). +// +// @param {module:engine/model/writer~Writer} writer +// @param {module:utils/collection~Collection} manualDecorators +function removeLinkAttributesFromSelection( writer, manualDecorators ) { + writer.removeSelectionAttribute( 'linkHref' ); + + for ( const decorator of manualDecorators ) { + writer.removeSelectionAttribute( decorator.id ); + } } // Checks whether selection's attributes should be copied to the new inserted text. diff --git a/packages/ckeditor5-link/tests/linkediting.js b/packages/ckeditor5-link/tests/linkediting.js index 5b33a338cfe..7d0dc37c65f 100644 --- a/packages/ckeditor5-link/tests/linkediting.js +++ b/packages/ckeditor5-link/tests/linkediting.js @@ -17,6 +17,7 @@ import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventd import ImageEditing from '@ckeditor/ckeditor5-image/src/image/imageediting'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import Input from '@ckeditor/ckeditor5-typing/src/input'; +import Delete from '@ckeditor/ckeditor5-typing/src/delete'; import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; @@ -1406,4 +1407,183 @@ describe( 'LinkEditing', () => { }; } } ); + + // https://github.com/ckeditor/ckeditor5/issues/7521 + describe( 'removing a character before the link element', () => { + let editor; + + beforeEach( async () => { + editor = await ClassicTestEditor.create( element, { + plugins: [ Paragraph, LinkEditing, Delete, BoldEditing ], + link: { + decorators: { + isFoo: { + mode: 'manual', + label: 'Foo', + attributes: { + class: 'foo' + } + }, + isBar: { + mode: 'manual', + label: 'Bar', + attributes: { + target: '_blank' + } + } + } + } + } ); + + model = editor.model; + view = editor.editing.view; + + model.schema.extend( '$text', { + allowIn: '$root', + allowAttributes: [ 'linkIsFoo', 'linkIsBar' ] + } ); + } ); + + afterEach( async () => { + await editor.destroy(); + } ); + + it( 'should not preserve the `linkHref` attribute when deleting content after the link', () => { + setModelData( model, 'Foo <$text linkHref="url">Bar []' ); + + expect( model.document.selection.hasAttribute( 'linkHref' ), 'initial state' ).to.equal( false ); + + view.document.fire( 'delete', new DomEventData( view.document, { + keyCode: keyCodes.backspace, + preventDefault: () => {} + } ) ); + + expect( model.document.selection.hasAttribute( 'linkHref' ), 'removing space after the link' ).to.equal( false ); + + view.document.fire( 'delete', new DomEventData( view.document, { + keyCode: keyCodes.backspace, + preventDefault: () => {} + } ) ); + + expect( model.document.selection.hasAttribute( 'linkHref' ), 'removing a character in the link' ).to.equal( false ); + expect( getModelData( model ) ).to.equal( 'Foo <$text linkHref="url">Ba[]' ); + } ); + + it( 'should not preserve the `linkHref` attribute when deleting content after the link (decorators check)', () => { + setModelData( model, + '' + + 'This is ' + + '<$text linkIsFoo="true" linkIsBar="true" linkHref="foo">Foo' + + ' []from ' + + '<$text linkHref="bar">Bar' + + '.' + + '' + ); + + expect( model.document.selection.hasAttribute( 'linkHref' ), 'initial "linkHref" state' ).to.equal( false ); + expect( model.document.selection.hasAttribute( 'linkIsFoo' ), 'initial "linkIsFoo" state' ).to.equal( false ); + expect( model.document.selection.hasAttribute( 'linkHref' ), 'initial "linkHref" state' ).to.equal( false ); + + view.document.fire( 'delete', new DomEventData( view.document, { + keyCode: keyCodes.backspace, + preventDefault: () => {} + } ) ); + + expect( model.document.selection.hasAttribute( 'linkHref' ), 'removing space after the link ("linkHref")' ).to.equal( false ); + expect( model.document.selection.hasAttribute( 'linkIsFoo' ), 'removing space after the link ("linkIsFoo")' ).to.equal( false ); + expect( model.document.selection.hasAttribute( 'linkHref' ), 'removing space after the link ("linkHref")' ).to.equal( false ); + + view.document.fire( 'delete', new DomEventData( view.document, { + keyCode: keyCodes.backspace, + preventDefault: () => {} + } ) ); + + expect( model.document.selection.hasAttribute( 'linkHref' ), 'removing a character the link ("linkHref")' ).to.equal( false ); + expect( model.document.selection.hasAttribute( 'linkIsFoo' ), 'removing a character the link ("linkIsFoo")' ).to.equal( false ); + expect( model.document.selection.hasAttribute( 'linkHref' ), 'removing a character the link ("linkHref")' ).to.equal( false ); + + expect( getModelData( model ) ).to.equal( + '' + + 'This is ' + + '<$text linkHref="foo" linkIsBar="true" linkIsFoo="true">Fo' + + '[]from ' + + '<$text linkHref="bar">Bar' + + '.' + + '' + ); + } ); + + it( 'should preserve the `linkHref` attribute when deleting content while the selection is at the end of the link', () => { + setModelData( model, 'Foo <$text linkHref="url">Bar []' ); + + expect( model.document.selection.hasAttribute( 'linkHref' ), 'initial state' ).to.equal( true ); + + view.document.fire( 'delete', new DomEventData( view.document, { + keyCode: keyCodes.backspace, + preventDefault: () => {} + } ) ); + + expect( model.document.selection.hasAttribute( 'linkHref' ), 'removing space after the link' ).to.equal( true ); + + view.document.fire( 'delete', new DomEventData( view.document, { + keyCode: keyCodes.backspace, + preventDefault: () => {} + } ) ); + + expect( model.document.selection.hasAttribute( 'linkHref' ), 'removing a character in the link' ).to.equal( true ); + expect( getModelData( model ) ).to.equal( 'Foo <$text linkHref="url">Ba[]' ); + } ); + + it( 'should preserve the `linkHref` attribute when deleting content while the selection is inside the link', () => { + setModelData( model, 'Foo <$text linkHref="url">A long URLLs[] description' ); + + expect( model.document.selection.hasAttribute( 'linkHref' ), 'initial state' ).to.equal( true ); + + view.document.fire( 'delete', new DomEventData( view.document, { + keyCode: keyCodes.backspace, + preventDefault: () => {} + } ) ); + + expect( model.document.selection.hasAttribute( 'linkHref' ), 'removing space after the link' ).to.equal( true ); + + view.document.fire( 'delete', new DomEventData( view.document, { + keyCode: keyCodes.backspace, + preventDefault: () => {} + } ) ); + + expect( model.document.selection.hasAttribute( 'linkHref' ), 'removing a character in the link' ).to.equal( true ); + expect( getModelData( model ) ).to.equal( 'Foo <$text linkHref="url">A long URL[] description' ); + } ); + + it( 'should do nothing if there is no `linkHref` attribute', () => { + setModelData( model, 'Foo <$text bold="true">Bolded. []Bar' ); + + view.document.fire( 'delete', new DomEventData( view.document, { + keyCode: keyCodes.backspace, + preventDefault: () => {} + } ) ); + + view.document.fire( 'delete', new DomEventData( view.document, { + keyCode: keyCodes.backspace, + preventDefault: () => {} + } ) ); + + expect( getModelData( model ) ).to.equal( 'Foo <$text bold="true">Bolded[]Bar' ); + } ); + + it( 'should preserve the `linkHref` attribute when deleting content using "Delete" key', () => { + setModelData( model, 'Foo <$text linkHref="url">Bar[ ]' ); + + expect( model.document.selection.hasAttribute( 'linkHref' ), 'initial state' ).to.equal( false ); + + view.document.fire( 'delete', new DomEventData( view.document, { + keyCode: keyCodes.delete, + preventDefault: () => {} + }, { direction: 'forward' } ) ); + + expect( getModelData( model ) ).to.equal( 'Foo <$text linkHref="url">Bar[]' ); + + expect( model.document.selection.hasAttribute( 'linkHref' ), 'removing space after the link' ).to.equal( true ); + } ); + } ); } );