/** * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ import LinkEditing from '../src/linkediting'; import LinkCommand from '../src/linkcommand'; import UnlinkCommand from '../src/unlinkcommand'; import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import BoldEditing from '@ckeditor/ckeditor5-basic-styles/src/bold/boldediting'; import ItalicEditing from '@ckeditor/ckeditor5-basic-styles/src/italic/italicediting'; import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard'; import Enter from '@ckeditor/ckeditor5-enter/src/enter'; import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; 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'; import { isLinkElement } from '../src/utils'; import '@ckeditor/ckeditor5-core/tests/_utils/assertions/attribute'; /* global document */ describe( 'LinkEditing', () => { let element, editor, model, view; beforeEach( async () => { element = document.createElement( 'div' ); document.body.appendChild( element ); editor = await ClassicTestEditor.create( element, { plugins: [ Paragraph, LinkEditing, Enter ], link: { decorators: { isExternal: { mode: 'manual', label: 'Open in a new window', attributes: { target: '_blank', rel: 'noopener noreferrer' } } } } } ); editor.model.schema.extend( '$text', { allowAttributes: 'bold' } ); editor.conversion.attributeToElement( { model: 'bold', view: 'b' } ); model = editor.model; view = editor.editing.view; } ); afterEach( async () => { element.remove(); await editor.destroy(); } ); it( 'should have pluginName', () => { expect( LinkEditing.pluginName ).to.equal( 'LinkEditing' ); } ); it( 'should be loaded', () => { expect( editor.plugins.get( LinkEditing ) ).to.be.instanceOf( LinkEditing ); } ); it( 'should set proper schema rules', () => { expect( model.schema.checkAttribute( [ '$block', '$text' ], 'linkHref' ) ).to.be.true; expect( model.schema.checkAttribute( [ '$clipboardHolder', '$text' ], 'linkHref' ) ).to.be.true; expect( model.schema.checkAttribute( [ '$block' ], 'linkHref' ) ).to.be.false; } ); // Let's check only the minimum to not duplicate `TwoStepCaretMovement` tests. // Testing minimum is better than testing using spies that might give false positive results. describe( 'two-step caret movement', () => { it( 'should be bound to the `linkHref` attribute (LTR)', () => { const selection = editor.model.document.selection; // Put selection before the link element. setModelData( editor.model, 'foo[]<$text linkHref="url">bar' ); // The selection's gravity should read attributes from the left. expect( selection ).not.to.have.attribute( 'linkHref' ); // So let's simulate the `keydown` event. editor.editing.view.document.fire( 'keydown', { keyCode: keyCodes.arrowright, preventDefault: () => {}, domTarget: document.body } ); expect( getModelData( model ) ).to.equal( 'foo<$text linkHref="url">[]bar' ); // Selection should get the attributes from the right. expect( selection ).to.have.attribute( 'linkHref' ); expect( selection ).to.have.attribute( 'linkHref', 'url' ); } ); it( 'should be bound to the `linkHref` attribute (RTL)', async () => { const editor = await ClassicTestEditor.create( element, { plugins: [ Paragraph, LinkEditing, Enter ], language: { content: 'ar' } } ); model = editor.model; view = editor.editing.view; const selection = editor.model.document.selection; // Put selection before the link element. setModelData( editor.model, 'foo[]<$text linkHref="url">bar' ); // The selection's gravity should read attributes from the left. expect( selection ).not.to.have.attribute( 'linkHref' ); // So let's simulate the `keydown` event. editor.editing.view.document.fire( 'keydown', { keyCode: keyCodes.arrowleft, preventDefault: () => {}, domTarget: document.body } ); expect( getModelData( model ) ).to.equal( 'foo<$text linkHref="url">[]bar' ); // Selection should get the attributes from the right. expect( selection ).to.have.attribute( 'linkHref' ); expect( selection ).to.have.attribute( 'linkHref', 'url' ); await editor.destroy(); } ); } ); // https://github.com/ckeditor/ckeditor5/issues/6053 describe( 'selection attribute management on paste', () => { it( 'should remove link atttributes when pasting a link', () => { setModelData( model, 'foo[]' ); model.change( writer => { model.insertContent( writer.createText( 'INSERTED', { linkHref: 'ckeditor.com' } ) ); } ); expect( getModelData( model ) ).to.equal( 'foo<$text linkHref="ckeditor.com">INSERTED[]' ); expect( [ ...model.document.selection.getAttributeKeys() ] ).to.be.empty; } ); it( 'should remove all atttributes starting with "link" (e.g. decorator attributes) when pasting a link', () => { setModelData( model, 'foo[]' ); model.change( writer => { model.insertContent( writer.createText( 'INSERTED', { linkHref: 'ckeditor.com', linkIsExternal: true } ) ); } ); expect( getModelData( model ) ).to.equal( '' + 'foo<$text linkHref="ckeditor.com" linkIsExternal="true">INSERTED[]' + '' ); expect( [ ...model.document.selection.getAttributeKeys() ] ).to.be.empty; } ); it( 'should not remove link atttributes when pasting a non-link content', () => { setModelData( model, '<$text linkHref="ckeditor.com">foo[]' ); model.change( writer => { model.insertContent( writer.createText( 'INSERTED', { bold: 'true' } ) ); } ); expect( getModelData( model ) ).to.equal( '' + '<$text linkHref="ckeditor.com">foo' + '<$text bold="true">INSERTED[]' + '' ); expect( model.document.selection ).to.have.attribute( 'bold' ); } ); it( 'should not remove link atttributes when pasting in the middle of a link with the same URL', () => { setModelData( model, '<$text linkHref="ckeditor.com">fo[]o' ); model.change( writer => { model.insertContent( writer.createText( 'INSERTED', { linkHref: 'ckeditor.com' } ) ); } ); expect( getModelData( model ) ).to.equal( '<$text linkHref="ckeditor.com">foINSERTED[]o' ); expect( model.document.selection ).to.have.attribute( 'linkHref' ); } ); it( 'should not remove link atttributes from the selection when pasting before a link when the gravity is overridden', () => { setModelData( model, 'foo[]<$text linkHref="ckeditor.com">bar' ); view.document.fire( 'keydown', { keyCode: keyCodes.arrowright, preventDefault: () => {}, domTarget: document.body } ); expect( model.document.selection ).to.have.property( 'isGravityOverridden', true ); model.change( writer => { model.insertContent( writer.createText( 'INSERTED', { bold: true } ) ); } ); expect( getModelData( model ) ).to.equal( '' + 'foo' + '<$text bold="true">INSERTED' + '<$text linkHref="ckeditor.com">[]bar' + '' ); expect( model.document.selection ).to.have.property( 'isGravityOverridden', true ); expect( model.document.selection ).to.have.attribute( 'linkHref' ); } ); it( 'should not remove link atttributes when pasting a link into another link (different URLs, no merge)', () => { setModelData( model, '<$text linkHref="ckeditor.com">f[]oo' ); model.change( writer => { model.insertContent( writer.createText( 'INSERTED', { linkHref: 'http://INSERTED' } ) ); } ); expect( getModelData( model ) ).to.equal( '' + '<$text linkHref="ckeditor.com">f' + '<$text linkHref="http://INSERTED">INSERTED[]' + '<$text linkHref="ckeditor.com">oo' + '' ); expect( model.document.selection ).to.have.attribute( 'linkHref' ); } ); it( 'should not remove link atttributes when pasting before another link (different URLs, no merge)', () => { setModelData( model, '[]<$text linkHref="ckeditor.com">foo' ); expect( model.document.selection ).to.have.property( 'isGravityOverridden', false ); model.change( writer => { model.insertContent( writer.createText( 'INSERTED', { linkHref: 'http://INSERTED' } ) ); } ); expect( getModelData( model ) ).to.equal( '' + '<$text linkHref="http://INSERTED">INSERTED[]' + '<$text linkHref="ckeditor.com">foo' + '' ); expect( model.document.selection ).to.have.attribute( 'linkHref' ); expect( model.document.selection ).to.have.attribute( 'linkHref', 'http://INSERTED' ); } ); } ); describe( 'command', () => { it( 'should register link command', () => { const command = editor.commands.get( 'link' ); expect( command ).to.be.instanceOf( LinkCommand ); } ); it( 'should register unlink command', () => { const command = editor.commands.get( 'unlink' ); expect( command ).to.be.instanceOf( UnlinkCommand ); } ); } ); describe( 'data pipeline conversions', () => { it( 'should convert `` to `linkHref="url"` attribute', () => { editor.setData( '

foobar

' ); expect( getModelData( model, { withoutSelection: true } ) ) .to.equal( '<$text linkHref="url">foobar' ); expect( editor.getData() ).to.equal( '

foobar

' ); } ); it( 'should be integrated with autoparagraphing', () => { editor.setData( 'foobar' ); expect( getModelData( model, { withoutSelection: true } ) ) .to.equal( '<$text linkHref="url">foobar' ); expect( editor.getData() ).to.equal( '

foobar

' ); } ); // https://github.com/ckeditor/ckeditor5/issues/500 it( 'should not pick up ``', () => { editor.setData( '

foobar

' ); expect( getModelData( model, { withoutSelection: true } ) ) .to.equal( 'foobar' ); } ); // CKEditor 4 does. And CKEditor 5's balloon allows creating such links. it( 'should pick up ``', () => { editor.setData( '

foobar

' ); expect( getModelData( model, { withoutSelection: true } ) ) .to.equal( '<$text linkHref="">foobar' ); expect( editor.getData() ).to.equal( '

foobar

' ); } ); // The editor's role is not to filter out potentially malicious data. // Its job is to not let this code be executed inside the editor (see the test in "editing pipeline conversion"). it( 'should output a link with a potential XSS code', () => { setModelData( model, '[]' ); model.change( writer => { writer.insertText( 'foo', { linkHref: 'javascript:alert(1)' }, model.document.selection.getFirstPosition() ); } ); expect( editor.getData() ).to.equal( '

foo

' ); } ); it( 'should load a link with a potential XSS code', () => { editor.setData( '

foo

' ); expect( getModelData( model, { withoutSelection: true } ) ) .to.equal( '<$text linkHref="javascript:alert(1)">foo' ); } ); } ); describe( 'editing pipeline conversion', () => { it( 'should convert attribute', () => { setModelData( model, '<$text linkHref="url">foobar' ); expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( '

foobar

' ); } ); it( 'should convert to link element instance', () => { setModelData( model, '<$text linkHref="url">foobar' ); const element = editor.editing.view.document.getRoot().getChild( 0 ).getChild( 0 ); expect( isLinkElement( element ) ).to.be.true; } ); // https://github.com/ckeditor/ckeditor5-link/issues/121 it( 'should should set priority for `linkHref` higher than all other attribute elements', () => { model.schema.extend( '$text', { allowAttributes: 'foo' } ); editor.conversion.for( 'downcast' ).attributeToElement( { model: 'foo', view: 'f' } ); setModelData( model, '' + '<$text linkHref="url">a<$text foo="true" linkHref="url">b<$text linkHref="url">c' + '' ); expect( editor.getData() ).to.equal( '

abc

' ); } ); it( 'must not render a link with a potential XSS code', () => { setModelData( model, '<$text linkHref="javascript:alert(1)">[]foobar[]' ); expect( getViewData( editor.editing.view, { withoutSelection: true } ) ) .to.equal( '

foobar

' ); } ); } ); describe( 'link highlighting', () => { it( 'should convert the highlight to a proper view classes', () => { setModelData( model, 'foo <$text linkHref="url">b{}ar baz' ); expect( model.document.selection ).to.have.attribute( 'linkHref' ); expect( getViewData( view ) ).to.equal( '

foo b{}ar baz

' ); } ); it( 'should work whenever selection has linkHref attribute - link start', () => { setModelData( model, 'foo {}<$text linkHref="url">bar baz' ); expect( model.document.selection ).to.not.have.attribute( 'linkHref' ); model.change( writer => { writer.setSelectionAttribute( 'linkHref', 'url' ); } ); expect( model.document.selection ).to.have.attribute( 'linkHref' ); expect( getViewData( view ) ).to.equal( '

foo {}bar baz

' ); } ); it( 'should work whenever selection has linkHref attribute - link end', () => { setModelData( model, 'foo <$text linkHref="url">bar{} baz' ); expect( model.document.selection ).to.have.attribute( 'linkHref' ); expect( getViewData( view ) ).to.equal( '

foo bar{} baz

' ); } ); it( 'should render highlight correctly after splitting the link', () => { setModelData( model, 'foo <$text linkHref="url">li{}nk baz' ); editor.execute( 'enter' ); expect( getModelData( model ) ).to.equal( 'foo <$text linkHref="url">li' + '<$text linkHref="url">[]nk baz' ); expect( model.document.selection ).to.have.attribute( 'linkHref' ); expect( getViewData( view ) ).to.equal( '

foo li

' + '

{}nk baz

' ); } ); it( 'should remove classes when selection is moved out from the link', () => { setModelData( model, 'foo <$text linkHref="url">li{}nk baz' ); expect( getViewData( view ) ).to.equal( '

foo li{}nk baz

' ); model.change( writer => writer.setSelection( model.document.getRoot().getChild( 0 ), 0 ) ); expect( getViewData( view ) ).to.equal( '

{}foo link baz

' ); } ); it( 'should work correctly when selection is moved inside link', () => { setModelData( model, 'foo <$text linkHref="url">li{}nk baz' ); expect( getViewData( view ) ).to.equal( '

foo li{}nk baz

' ); model.change( writer => writer.setSelection( model.document.getRoot().getChild( 0 ), 5 ) ); expect( getViewData( view ) ).to.equal( '

foo l{}ink baz

' ); } ); describe( 'downcast conversion integration', () => { it( 'works for the #insert event', () => { setModelData( model, 'foo <$text linkHref="url">li{}nk baz' ); model.change( writer => { writer.insertText( 'FOO', { linkHref: 'url' }, model.document.selection.getFirstPosition() ); } ); expect( getViewData( view ) ).to.equal( '

foo liFOO{}nk baz

' ); } ); it( 'works for the #remove event', () => { setModelData( model, 'foo <$text linkHref="url">li{}nk baz' ); model.change( writer => { writer.remove( writer.createRange( writer.createPositionAt( model.document.getRoot().getChild( 0 ), 0 ), writer.createPositionAt( model.document.getRoot().getChild( 0 ), 5 ) ) ); } ); expect( getViewData( view ) ).to.equal( '

i{}nk baz

' ); } ); it( 'works for the #attribute event', () => { setModelData( model, 'foo <$text linkHref="url">li{}nk baz' ); model.change( writer => { writer.setAttribute( 'linkHref', 'new-url', writer.createRange( model.document.selection.getFirstPosition().getShiftedBy( -1 ), model.document.selection.getFirstPosition().getShiftedBy( 1 ) ) ); } ); expect( getViewData( view ) ).to.equal( '

foo li{}nk baz

' ); } ); it( 'works for the #selection event', () => { setModelData( model, 'foo <$text linkHref="url">li{}nk baz' ); model.change( writer => { writer.setSelection( writer.createRange( model.document.selection.getFirstPosition().getShiftedBy( -1 ), model.document.selection.getFirstPosition().getShiftedBy( 1 ) ) ); } ); expect( getViewData( view ) ).to.equal( '

foo l{in}k baz

' ); } ); it( 'works for the addMarker and removeMarker events', () => { editor.conversion.for( 'editingDowncast' ).markerToHighlight( { model: 'fooMarker', view: {} } ); setModelData( model, 'foo <$text linkHref="url">li{}nk baz' ); model.change( writer => { const range = writer.createRange( writer.createPositionAt( model.document.getRoot().getChild( 0 ), 0 ), writer.createPositionAt( model.document.getRoot().getChild( 0 ), 5 ) ); writer.addMarker( 'fooMarker', { range, usingOperation: true } ); } ); expect( getViewData( view ) ).to.equal( '

foo li{}nk baz

' ); model.change( writer => writer.removeMarker( 'fooMarker' ) ); expect( getViewData( view ) ).to.equal( '

foo li{}nk baz

' ); } ); } ); } ); describe( 'link attributes decorator', () => { describe( 'default behavior', () => { const testLinks = [ { external: true, url: 'http://example.com' }, { external: true, url: 'https://cksource.com' }, { external: false, url: 'ftp://server.io' }, { external: true, url: '//schemaless.org' }, { external: false, url: 'www.ckeditor.com' }, { external: false, url: '/relative/url.html' }, { external: false, url: 'another/relative/url.html' }, { external: false, url: '#anchor' }, { external: false, url: 'mailto:some@user.org' }, { external: false, url: 'tel:123456789' } ]; it( 'link.addTargetToExternalLinks is predefined as false value', () => { expect( editor.config.get( 'link.addTargetToExternalLinks' ) ).to.be.false; } ); describe( 'for link.addTargetToExternalLinks = false', () => { let editor, model; beforeEach( async () => { editor = await ClassicTestEditor.create( element, { plugins: [ Paragraph, LinkEditing, Enter ], link: { addTargetToExternalLinks: true } } ); model = editor.model; view = editor.editing.view; } ); afterEach( async () => { await editor.destroy(); } ); it( 'link.addTargetToExternalLinks is set as true value', () => { expect( editor.config.get( 'link.addTargetToExternalLinks' ) ).to.be.true; } ); testLinks.forEach( link => { it( `link: ${ link.url } should be treat as ${ link.external ? 'external' : 'non-external' } link`, () => { editor.setData( `

foobar

` ); expect( getModelData( model, { withoutSelection: true } ) ) .to.equal( `<$text linkHref="${ link.url }">foobar` ); if ( link.external ) { expect( editor.getData() ) .to.equal( `

foobar

` ); } else { expect( editor.getData() ).to.equal( `

foobar

` ); } } ); } ); } ); testLinks.forEach( link => { it( `link: ${ link.url } should not get 'target' and 'rel' attributes`, () => { editor.setData( `

foobar

` ); expect( getModelData( model, { withoutSelection: true } ) ) .to.equal( `<$text linkHref="${ link.url }">foobar` ); expect( editor.getData() ).to.equal( `

foobar

` ); } ); } ); } ); describe( 'custom config', () => { describe( 'mode: automatic', () => { const testLinks = [ { url: 'relative/url.html', attributes: {} }, { url: 'http://exmaple.com', attributes: { target: '_blank' } }, { url: 'https://example.com/download/link.pdf', attributes: { target: '_blank', download: 'download' } }, { url: 'mailto:some@person.io', attributes: { class: 'mail-url' } } ]; beforeEach( async () => { await editor.destroy(); editor = await ClassicTestEditor.create( element, { plugins: [ Paragraph, LinkEditing, Enter ], link: { addTargetToExternalLinks: false, decorators: { isExternal: { mode: 'automatic', callback: url => url.startsWith( 'http' ), attributes: { target: '_blank' } }, isDownloadable: { mode: 'automatic', callback: url => url.includes( 'download' ), attributes: { download: 'download' } }, isMail: { mode: 'automatic', callback: url => url.startsWith( 'mailto:' ), attributes: { class: 'mail-url' } } } } } ); model = editor.model; view = editor.editing.view; } ); testLinks.forEach( link => { it( `Link: ${ link.url } should get attributes: ${ JSON.stringify( link.attributes ) }`, () => { const ORDER = [ 'target', 'download', 'class' ]; const attr = Object.entries( link.attributes ).sort( ( a, b ) => { const aIndex = ORDER.indexOf( a[ 0 ] ); const bIndex = ORDER.indexOf( b[ 0 ] ); return aIndex - bIndex; } ); const reducedAttr = attr.reduce( ( acc, cur ) => { return acc + `${ cur[ 0 ] }="${ cur[ 1 ] }" `; }, '' ); editor.setData( `

foobar

` ); expect( getModelData( model, { withoutSelection: true } ) ) .to.equal( `<$text linkHref="${ link.url }">foobar` ); // Order of attributes is important, that's why this is assert is construct in such way. expect( editor.getData() ).to.equal( `

foobar

` ); } ); } ); it( 'stores decorators in LinkCommand#automaticDecorators collection', () => { expect( editor.commands.get( 'link' ).automaticDecorators.length ).to.equal( 3 ); } ); } ); } ); describe( 'custom linkHref converter', () => { beforeEach( async () => { class CustomLinks extends Plugin { init() { const editor = this.editor; editor.conversion.for( 'downcast' ).add( dispatcher => { dispatcher.on( 'attribute:linkHref', ( evt, data, conversionApi ) => { conversionApi.consumable.consume( data.item, 'attribute:linkHref' ); // Very simplified downcast just for test assertion. const viewWriter = conversionApi.writer; const linkElement = viewWriter.createAttributeElement( 'a', { href: data.attributeNewValue }, { priority: 5 } ); viewWriter.setCustomProperty( 'link', true, linkElement ); viewWriter.wrap( conversionApi.mapper.toViewRange( data.range ), linkElement ); }, { priority: 'highest' } ); } ); } } await editor.destroy(); editor = await ClassicTestEditor.create( element, { plugins: [ Paragraph, LinkEditing, Enter, CustomLinks ], link: { addTargetToExternalLinks: true } } ); model = editor.model; view = editor.editing.view; } ); it( 'has possibility to override default one', () => { editor.setData( '

foobar

' ); expect( getModelData( model, { withoutSelection: true } ) ) .to.equal( '<$text linkHref="http://example.com">foobar' ); expect( editor.getData() ).to.equal( '

foobar

' ); } ); } ); describe( 'upcast converter', () => { let element, editor; beforeEach( () => { element = document.createElement( 'div' ); document.body.appendChild( element ); } ); afterEach( () => { element.remove(); } ); it( 'should upcast attributes from initial data', async () => { editor = await ClassicTestEditor.create( element, { initialData: '

Foo' + 'Bar

', plugins: [ Paragraph, LinkEditing, Enter ], link: { decorators: { isExternal: { mode: 'manual', label: 'Open in a new window', attributes: { target: '_blank', rel: 'noopener noreferrer' } }, isDownloadable: { mode: 'manual', label: 'Downloadable', attributes: { download: 'file' } } } } } ); model = editor.model; expect( getModelData( model, { withoutSelection: true } ) ).to.equal( '' + '<$text linkHref="url" linkIsDownloadable="true" linkIsExternal="true">Foo' + '<$text linkHref="example.com" linkIsDownloadable="true">Bar' + '' ); await editor.destroy(); } ); it( 'should not upcast partial and incorrect attributes', async () => { editor = await ClassicTestEditor.create( element, { initialData: '

Foo' + 'Bar

', plugins: [ Paragraph, LinkEditing, Enter ], link: { decorators: { isExternal: { mode: 'manual', label: 'Open in a new window', attributes: { target: '_blank', rel: 'noopener noreferrer' } }, isDownloadable: { mode: 'manual', label: 'Downloadable', attributes: { download: 'file' } } } } } ); model = editor.model; expect( getModelData( model, { withoutSelection: true } ) ).to.equal( '' + '<$text linkHref="url">Foo' + '<$text linkHref="example.com">Bar' + '' ); await editor.destroy(); } ); } ); } ); // https://github.com/ckeditor/ckeditor5/issues/1016 describe( 'typing around the link after a click', () => { let editor; beforeEach( async () => { editor = await ClassicTestEditor.create( element, { plugins: [ Paragraph, LinkEditing, Enter, Input, 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 insert content after the link', () => { setModelData( model, '<$text linkHref="url">Bar[]' ); editor.editing.view.document.fire( 'mousedown' ); editor.editing.view.document.fire( 'selectionChange', { newSelection: view.document.selection } ); expect( getModelData( model ) ).to.equal( '<$text linkHref="url">Bar[]' ); editor.execute( 'input', { text: 'Foo' } ); expect( getModelData( model ) ).to.equal( '<$text linkHref="url">BarFoo[]' ); } ); it( 'should insert content before the link', () => { setModelData( model, '<$text linkHref="url">[]Bar' ); editor.editing.view.document.fire( 'mousedown' ); editor.editing.view.document.fire( 'selectionChange', { newSelection: view.document.selection } ); expect( getModelData( model ) ).to.equal( '[]<$text linkHref="url">Bar' ); editor.execute( 'input', { text: 'Foo' } ); expect( getModelData( model ) ).to.equal( 'Foo[]<$text linkHref="url">Bar' ); } ); it( 'should insert content to the link if clicked inside it', () => { setModelData( model, '<$text linkHref="url">B[]ar' ); editor.editing.view.document.fire( 'mousedown' ); editor.editing.view.document.fire( 'selectionChange', { newSelection: view.document.selection } ); expect( getModelData( model ) ).to.equal( '<$text linkHref="url">B[]ar' ); editor.execute( 'input', { text: 'ar. B' } ); expect( getModelData( model ) ).to.equal( '<$text linkHref="url">Bar. B[]ar' ); } ); it( 'should insert content between two links (selection at the end of the first link)', () => { setModelData( model, '<$text linkHref="foo">Foo[]<$text linkHref="bar">Bar' ); editor.editing.view.document.fire( 'mousedown' ); editor.editing.view.document.fire( 'selectionChange', { newSelection: view.document.selection } ); expect( getModelData( model ) ).to.equal( '<$text linkHref="foo">Foo[]<$text linkHref="bar">Bar' ); editor.execute( 'input', { text: 'Foo' } ); expect( getModelData( model ) ).to.equal( '<$text linkHref="foo">FooFoo[]<$text linkHref="bar">Bar' ); } ); it( 'should insert content between two links (selection at the beginning of the second link)', () => { setModelData( model, '<$text linkHref="foo">Foo<$text linkHref="bar">[]Bar' ); editor.editing.view.document.fire( 'mousedown' ); editor.editing.view.document.fire( 'selectionChange', { newSelection: view.document.selection } ); expect( getModelData( model ) ).to.equal( '<$text linkHref="foo">Foo[]<$text linkHref="bar">Bar' ); editor.execute( 'input', { text: 'Foo' } ); expect( getModelData( model ) ).to.equal( '<$text linkHref="foo">FooFoo[]<$text linkHref="bar">Bar' ); } ); it( 'should not touch other attributes than `linkHref`', () => { setModelData( model, '<$text bold="true" linkHref="url">Bar[]' ); editor.editing.view.document.fire( 'mousedown' ); editor.editing.view.document.fire( 'selectionChange', { newSelection: view.document.selection } ); expect( getModelData( model ) ).to.equal( '<$text bold="true" linkHref="url">Bar<$text bold="true">[]' ); editor.execute( 'input', { text: 'Foo' } ); expect( getModelData( model ) ).to.equal( '<$text bold="true" linkHref="url">Bar<$text bold="true">Foo[]' ); } ); it( 'should do nothing if the text was not clicked', () => { setModelData( model, '<$text linkHref="url">Bar[]' ); editor.editing.view.document.fire( 'selectionChange', { newSelection: view.document.selection } ); expect( getModelData( model ) ).to.equal( '<$text linkHref="url">Bar[]' ); } ); it( 'should do nothing if the selection is not collapsed after the click', () => { setModelData( model, '[<$text linkHref="url">Bar]' ); editor.editing.view.document.fire( 'mousedown' ); editor.editing.view.document.fire( 'selectionChange', { newSelection: view.document.selection } ); expect( getModelData( model ) ).to.equal( '[<$text linkHref="url">Bar]' ); } ); it( 'should do nothing if the text is not a link', () => { setModelData( model, '<$text bold="true">Bar[]' ); editor.editing.view.document.fire( 'mousedown' ); editor.editing.view.document.fire( 'selectionChange', { newSelection: view.document.selection } ); expect( getModelData( model ) ).to.equal( '<$text bold="true">Bar[]' ); } ); it( 'should remove manual decorators', () => { setModelData( model, '<$text linkIsFoo="true" linkIsBar="true" linkHref="url">Bar[]' ); editor.editing.view.document.fire( 'mousedown' ); editor.editing.view.document.fire( 'selectionChange', { newSelection: view.document.selection } ); expect( getModelData( model ) ).to.equal( '<$text linkHref="url" linkIsBar="true" linkIsFoo="true">Bar[]' ); editor.execute( 'input', { text: 'Foo' } ); expect( getModelData( model ) ).to.equal( '<$text linkHref="url" linkIsBar="true" linkIsFoo="true">BarFoo[]' ); } ); } ); // https://github.com/ckeditor/ckeditor5/issues/4762 describe( 'typing over the link', () => { let editor; beforeEach( async () => { editor = await ClassicTestEditor.create( element, { plugins: [ Paragraph, LinkEditing, Enter, BoldEditing, ItalicEditing, ImageEditing ], 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 require Clipboard plugin', () => { expect( LinkEditing.requires.includes( Clipboard ) ).to.equal( true ); } ); it( 'should require Input plugin', () => { expect( LinkEditing.requires.includes( Input ) ).to.equal( true ); } ); it( 'should preserve selection attributes when the entire link is selected', () => { setModelData( model, 'This is [<$text linkHref="foo">Foo] from <$text linkHref="bar">Bar.' ); editor.execute( 'input', { text: 'Abcde' } ); expect( getModelData( model ) ).to.equal( 'This is <$text linkHref="foo">Abcde[] from <$text linkHref="bar">Bar.' ); } ); it( 'should preserve selection attributes when the entire link is selected (mixed attributes in the link)', () => { setModelData( model, '' + 'This is [' + '<$text linkHref="foo" italic="true">F' + '<$text linkHref="foo" bold="true">o' + '<$text linkHref="foo" bold="true" italic="true">o' + '<$text linkHref="foo" bold="true">B' + '<$text linkHref="foo" bold="true" italic="true">a' + '<$text linkHref="foo">r]' + ' from ' + '<$text linkHref="bar">Bar' + '.' + '' ); editor.execute( 'input', { text: 'Abcde' } ); expect( getModelData( model ) ).to.equal( '' + 'This is ' + '<$text italic="true" linkHref="foo">Abcde' + '<$text italic="true">[]' + ' from ' + '<$text linkHref="bar">Bar' + '.' + '' ); } ); it( 'should preserve selection attributes when the selection starts at the beginning of the link', () => { setModelData( model, 'This is [<$text linkHref="foo">Fo]o from <$text linkHref="bar">Bar.' ); editor.execute( 'input', { text: 'Abcde' } ); expect( getModelData( model ) ).to.equal( 'This is <$text linkHref="foo">Abcde[]o from <$text linkHref="bar">Bar.' ); } ); it( 'should preserve selection attributes when it starts at the beginning of the link (mixed attributes in the link)', () => { setModelData( model, '' + 'This is [' + '<$text linkHref="foo" italic="true">F' + '<$text linkHref="foo" bold="true">o' + '<$text linkHref="foo" bold="true" italic="true">o' + '<$text linkHref="foo" bold="true">B' + '<$text linkHref="foo" bold="true" italic="true">a]' + '<$text linkHref="foo">r' + ' from ' + '<$text linkHref="bar">Bar' + '.' + '' ); editor.execute( 'input', { text: 'Abcde' } ); expect( getModelData( model ) ).to.equal( '' + 'This is ' + '<$text italic="true" linkHref="foo">Abcde[]' + '<$text linkHref="foo">r' + ' from ' + '<$text linkHref="bar">Bar' + '.' + '' ); } ); it( 'should preserve all attributes of the link node (decorators check)', () => { setModelData( model, '' + 'This is ' + '<$text linkIsFoo="true" linkIsBar="true" linkHref="foo">[Foo]' + ' from ' + '<$text linkHref="bar">Bar' + '.' + '' ); editor.execute( 'input', { text: 'Abcde' } ); expect( getModelData( model ) ).to.equal( '' + 'This is ' + '<$text linkHref="foo" linkIsBar="true" linkIsFoo="true">Abcde[]' + ' from ' + '<$text linkHref="bar">Bar' + '.' + '' ); } ); it( 'should not preserve selection attributes when the changes are not caused by typing', () => { setModelData( model, 'This is [<$text linkHref="foo">Foo] from <$text linkHref="bar">Bar.' ); model.change( writer => { model.deleteContent( model.document.selection ); model.insertContent( writer.createText( 'Abcde' ) ); } ); expect( getModelData( model ) ).to.equal( 'This is Abcde[] from <$text linkHref="bar">Bar.' ); } ); it( 'should not preserve selection attributes when the changes are not caused by typing (pasting check)', () => { setModelData( model, 'This is [<$text linkHref="foo">Foo] from <$text linkHref="bar">Bar.' ); view.document.fire( 'paste', { dataTransfer: createDataTransfer( { 'text/html': '

Abcde

', 'text/plain': 'Abcde' } ), preventDefault: sinon.spy(), stopPropagation: sinon.spy() } ); expect( getModelData( model ) ).to.equal( 'This is Abcde[] from <$text linkHref="bar">Bar.' ); } ); it( 'should not preserve selection attributes when typed after cutting the content', () => { setModelData( model, 'This is [<$text linkHref="foo">Foo] from <$text linkHref="bar">Bar.' ); view.document.fire( 'cut', { dataTransfer: createDataTransfer(), preventDefault: sinon.spy(), stopPropagation: sinon.spy() } ); editor.execute( 'input', { text: 'Abcde' } ); expect( getModelData( model ) ).to.equal( 'This is Abcde[] from <$text linkHref="bar">Bar.' ); } ); it( 'should not preserve anything if selected an element instead of text', () => { setModelData( model, '[]' ); editor.execute( 'input', { text: 'Abcde' } ); expect( getModelData( model ) ).to.equal( 'Abcde[]' ); } ); it( 'should not preserve anything if selected text does not have the `linkHref` attribute`', () => { setModelData( model, 'This is [<$text bold="foo">Foo] from <$text linkHref="bar">Bar.' ); editor.execute( 'input', { text: 'Abcde' } ); expect( getModelData( model ) ).to.equal( 'This is Abcde[] from <$text linkHref="bar">Bar.' ); } ); it( 'should not preserve selection attributes when the entire link is selected and pressed "Backspace"', () => { setModelData( model, 'This is [<$text linkHref="foo">Foo] from <$text linkHref="bar">Bar.' ); view.document.fire( 'delete', new DomEventData( view.document, { keyCode: keyCodes.backspace, preventDefault: () => {} } ) ); editor.execute( 'input', { text: 'Abcde' } ); expect( getModelData( model ) ).to.equal( 'This is Abcde[] from <$text linkHref="bar">Bar.' ); } ); it( 'should not preserve selection attributes when the entire link is selected and pressed "Delete"', () => { setModelData( model, 'This is [<$text linkHref="foo">Foo] from <$text linkHref="bar">Bar.' ); view.document.fire( 'delete', new DomEventData( view.document, { keyCode: keyCodes.delete, preventDefault: () => {} } ) ); editor.execute( 'input', { text: 'Abcde' } ); expect( getModelData( model ) ).to.equal( 'This is Abcde[] from <$text linkHref="bar">Bar.' ); } ); it( 'should not preserve selection attributes when selected different links', () => { setModelData( model, 'This is <$text linkHref="foo">[Foo from <$text linkHref="bar">Bar].' ); editor.execute( 'input', { text: 'Abcde' } ); expect( getModelData( model ) ).to.equal( 'This is Abcde[].' ); } ); it( 'should not preserve selection attributes when selected more than single link (start of the selection)', () => { setModelData( model, 'This is[ <$text linkHref="foo">Foo] from <$text linkHref="bar">Bar.' ); editor.execute( 'input', { text: 'Abcde' } ); expect( getModelData( model ) ).to.equal( 'This isAbcde[] from <$text linkHref="bar">Bar.' ); } ); it( 'should not preserve selection attributes when selected more than single link (end of the selection)', () => { setModelData( model, 'This is <$text linkHref="foo">[Foo ]from <$text linkHref="bar">Bar.' ); editor.execute( 'input', { text: 'Abcde' } ); expect( getModelData( model ) ).to.equal( 'This is Abcde[]from <$text linkHref="bar">Bar.' ); } ); function createDataTransfer( data ) { return { getData( type ) { return data[ type ]; }, setData() {} }; } } ); // 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 ); } ); } ); } );