diff --git a/packages/ckeditor5-widget/src/widget.ts b/packages/ckeditor5-widget/src/widget.ts index ea580b3f5ad..a6536c92d43 100644 --- a/packages/ckeditor5-widget/src/widget.ts +++ b/packages/ckeditor5-widget/src/widget.ts @@ -11,6 +11,7 @@ import { Plugin } from '@ckeditor/ckeditor5-core'; import { MouseObserver, + TreeWalker, type DomEventData, type DowncastSelectionEvent, type DowncastWriter, @@ -19,7 +20,9 @@ import { type ViewDocumentArrowKeyEvent, type ViewDocumentFragment, type ViewDocumentMouseDownEvent, - type ViewElement + type ViewElement, + type Schema, + type Position } from '@ckeditor/ckeditor5-engine'; import { Delete, type ViewDocumentDeleteEvent } from '@ckeditor/ckeditor5-typing'; @@ -202,27 +205,20 @@ export default class Widget extends Plugin { const viewDocument = view.document; let element: ViewElement | null = domEventData.target; - // Do nothing for single or double click inside nested editable. - if ( isInsideNestedEditable( element ) ) { - // But at least triple click inside nested editable causes broken selection in Safari. - // For such event, we select the entire nested editable element. - // See: https://github.com/ckeditor/ckeditor5/issues/1463. - if ( ( env.isSafari || env.isGecko ) && domEventData.domEvent.detail >= 3 ) { - const mapper = editor.editing.mapper; - const viewElement = element.is( 'attributeElement' ) ? - element.findAncestor( element => !element.is( 'attributeElement' ) )! : element; - const modelElement = mapper.toModelElement( viewElement )!; - + // If triple click should select entire paragraph. + if ( domEventData.domEvent.detail >= 3 ) { + if ( this._selectBlockContent( element ) ) { domEventData.preventDefault(); - - this.editor.model.change( writer => { - writer.setSelection( modelElement, 'in' ); - } ); } return; } + // Do nothing for single or double click inside nested editable. + if ( isInsideNestedEditable( element ) ) { + return; + } + // If target is not a widget element - check if one of the ancestors is. if ( !isWidget( element ) ) { element = element.findAncestor( isWidget ); @@ -249,6 +245,38 @@ export default class Widget extends Plugin { this._setSelectionOverElement( modelElement! ); } + /** + * Selects entire block content, e.g. on triple click it selects entire paragraph. + */ + private _selectBlockContent( element: ViewElement ): boolean { + const editor = this.editor; + const model = editor.model; + const mapper = editor.editing.mapper; + const schema = model.schema; + + const viewElement = mapper.findMappedViewAncestor( this.editor.editing.view.createPositionAt( element, 0 ) ); + const modelElement = findTextBlockAncestor( mapper.toModelElement( viewElement )!, model.schema ); + + if ( !modelElement ) { + return false; + } + + model.change( writer => { + const nextTextBlock = !schema.isLimit( modelElement ) ? + findNextTextBlock( writer.createPositionAfter( modelElement ), schema ) : + null; + + const start = writer.createPositionAt( modelElement, 0 ); + const end = nextTextBlock ? + writer.createPositionAt( nextTextBlock, 0 ) : + writer.createPositionAt( modelElement, 'end' ); + + writer.setSelection( writer.createRange( start, end ) ); + } ); + + return true; + } + /** * Handles {@link module:engine/view/document~Document#event:keydown keydown} events and changes * the model selection when: @@ -479,3 +507,40 @@ function isChild( element: ViewElement, parent: ViewElement | null ) { return Array.from( element.getAncestors() ).includes( parent ); } + +/** + * Returns nearest text block ancestor. + */ +function findTextBlockAncestor( modelElement: Element, schema: Schema ): Element | null { + for ( const element of modelElement.getAncestors( { includeSelf: true, parentFirst: true } ) ) { + if ( schema.checkChild( element as Element, '$text' ) ) { + return element as Element; + } + + // Do not go beyond nested editable. + if ( schema.isLimit( element ) && !schema.isObject( element ) ) { + break; + } + } + + return null; +} + +/** + * Returns next text block where could put selection. + */ +function findNextTextBlock( position: Position, schema: Schema ): Element | null { + const treeWalker = new TreeWalker( { startPosition: position } ); + + for ( const { item } of treeWalker ) { + if ( schema.isLimit( item ) || !item.is( 'element' ) ) { + return null; + } + + if ( schema.checkChild( item, '$text' ) ) { + return item; + } + } + + return null; +} diff --git a/packages/ckeditor5-widget/tests/widget-integration.js b/packages/ckeditor5-widget/tests/widget-integration.js index acb1fb4fdee..2cfee632fb8 100644 --- a/packages/ckeditor5-widget/tests/widget-integration.js +++ b/packages/ckeditor5-widget/tests/widget-integration.js @@ -9,6 +9,7 @@ import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor' import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import Typing from '@ckeditor/ckeditor5-typing/src/typing'; import LinkEditing from '@ckeditor/ckeditor5-link/src/linkediting'; +import Image from '@ckeditor/ckeditor5-image/src/image'; import Widget from '../src/widget'; import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; @@ -33,7 +34,7 @@ describe( 'Widget - integration', () => { editorElement = document.createElement( 'div' ); document.body.appendChild( editorElement ); - return ClassicEditor.create( editorElement, { plugins: [ Paragraph, Widget, Typing, LinkEditing ] } ) + return ClassicEditor.create( editorElement, { plugins: [ Paragraph, Widget, Typing, LinkEditing, Image ] } ) .then( newEditor => { editor = newEditor; model = editor.model; @@ -205,8 +206,8 @@ describe( 'Widget - integration', () => { '
' + '
' + '

foo

' + - '

{foo bar]

' + - '

bar

' + + '

{foo bar

' + + '

}bar

' + '
' + '
' + '
' @@ -215,8 +216,8 @@ describe( 'Widget - integration', () => { '' + '' + 'foo' + - '[foo <$text linkHref="abc">bar]' + - 'bar' + + '[foo <$text linkHref="abc">bar' + + ']bar' + '' + '' ); @@ -275,55 +276,26 @@ describe( 'Widget - integration', () => { expect( getModelData( model ) ).to.equal( '[foo bar]' ); } ); - it( 'should select the inline widget if triple clicked', () => { - setModelData( model, 'Foofoo barBar' ); + it( 'should select image block if triple clicked', () => { + setModelData( model, '[]' ); - const viewParagraph = viewDocument.getRoot().getChild( 0 ); - const viewInlineWidget = viewParagraph.getChild( 1 ); + const image = viewDocument.getRoot().getChild( 0 ); const preventDefault = sinon.spy(); const domEventDataMock = new DomEventData( view, { - target: view.domConverter.mapViewToDom( viewInlineWidget ), + target: view.domConverter.mapViewToDom( image ), preventDefault, detail: 3 } ); viewDocument.fire( 'mousedown', domEventDataMock ); - expect( viewDocument.selection.isFake ).to.be.true; expect( getViewData( view ) ).to.equal( - '

Foo[foo bar]Bar

' - ); - - expect( getModelData( model ) ).to.equal( 'Foo[foo bar]Bar' ); - } ); - - it( 'should do nothing for non-Safari and non-Gecko browser', () => { - testUtils.sinon.stub( env, 'isSafari' ).get( () => false ); - testUtils.sinon.stub( env, 'isGecko' ).get( () => false ); - - setModelData( model, '[]foo bar' ); - - const viewDiv = viewDocument.getRoot().getChild( 1 ); - const viewFigcaption = viewDiv.getChild( 0 ); - const preventDefault = sinon.spy(); - const domEventDataMock = new DomEventData( view, { - target: view.domConverter.mapViewToDom( viewFigcaption ), - preventDefault, - detail: 4 - } ); - - viewDocument.fire( 'mousedown', domEventDataMock ); - - sinon.assert.notCalled( preventDefault ); - - expect( getViewData( view ) ).to.equal( - '

[]

' + - '
' + - '
foo bar
' + + '[
' + + '' + '
' + - '
' + ']' ); - expect( getModelData( model ) ).to.equal( '[]foo bar' ); + expect( getModelData( model ) ).to.equal( '[]' ); } ); } ); diff --git a/packages/ckeditor5-widget/tests/widget.js b/packages/ckeditor5-widget/tests/widget.js index bfc58533884..0b5e9aa1313 100644 --- a/packages/ckeditor5-widget/tests/widget.js +++ b/packages/ckeditor5-widget/tests/widget.js @@ -428,6 +428,307 @@ describe( 'Widget', () => { ); } ); + describe( 'triple click', () => { + it( 'should set selection on entire paragraph', () => { + setModelData( model, + 'f[o]o' + + 'foo bar' + ); + + const viewRoot = viewDocument.getRoot(); + const paragraph = viewRoot.getChild( 1 ); + const preventDefault = sinon.spy(); + const domEventDataMock = new DomEventData( view, { + target: view.domConverter.mapViewToDom( paragraph ), + preventDefault, + detail: 3 + } ); + + viewDocument.fire( 'mousedown', domEventDataMock ); + + sinon.assert.called( preventDefault ); + + expect( getModelData( model ) ).to.equal( + 'foo' + + '[foo bar]' + ); + } ); + + it( 'should set selection on entire paragraph with attributes in text', () => { + setModelData( model, + 'f[o]o' + + 'foo <$text attr="true">bar' + ); + + const viewRoot = viewDocument.getRoot(); + const paragraph = viewRoot.getChild( 1 ); + const bold = paragraph.getChild( 1 ); + const preventDefault = sinon.spy(); + const domEventDataMock = new DomEventData( view, { + target: view.domConverter.mapViewToDom( bold ), + preventDefault, + detail: 3 + } ); + + viewDocument.fire( 'mousedown', domEventDataMock ); + + sinon.assert.called( preventDefault ); + + expect( getModelData( model ) ).to.equal( + 'foo' + + '[foo <$text attr="true">bar]' + ); + } ); + + it( 'should extend selection to include start of next text block', () => { + setModelData( model, + 'f[o]o' + + 'foo bar' + + 'baz' + ); + + const viewRoot = viewDocument.getRoot(); + const paragraph = viewRoot.getChild( 1 ); + const preventDefault = sinon.spy(); + const domEventDataMock = new DomEventData( view, { + target: view.domConverter.mapViewToDom( paragraph ), + preventDefault, + detail: 3 + } ); + + viewDocument.fire( 'mousedown', domEventDataMock ); + + sinon.assert.called( preventDefault ); + + expect( getModelData( model ) ).to.equal( + 'foo' + + '[foo bar' + + ']baz' + ); + } ); + + it( 'should not extend selection to include following block widget', () => { + setModelData( model, + 'f[o]o' + + 'foo bar' + + 'foo bar' + ); + + const viewRoot = viewDocument.getRoot(); + const paragraph = viewRoot.getChild( 1 ); + const preventDefault = sinon.spy(); + const domEventDataMock = new DomEventData( view, { + target: view.domConverter.mapViewToDom( paragraph ), + preventDefault, + detail: 3 + } ); + + viewDocument.fire( 'mousedown', domEventDataMock ); + + sinon.assert.called( preventDefault ); + + expect( getModelData( model ) ).to.equal( + 'foo' + + '[foo bar]' + + 'foo bar' + ); + } ); + + it( 'should extend selection to include start of next text block in block quote', () => { + setModelData( model, + 'f[o]o' + + 'foo bar' + + '
foo bar
' + ); + + const viewRoot = viewDocument.getRoot(); + const paragraph = viewRoot.getChild( 1 ); + const preventDefault = sinon.spy(); + const domEventDataMock = new DomEventData( view, { + target: view.domConverter.mapViewToDom( paragraph ), + preventDefault, + detail: 3 + } ); + + viewDocument.fire( 'mousedown', domEventDataMock ); + + sinon.assert.called( preventDefault ); + + expect( getModelData( model ) ).to.equal( + 'foo' + + '[foo bar' + + '
]foo bar
' + ); + } ); + + it( 'should extend selection starting in block quote', () => { + setModelData( model, + 'f[o]o' + + '
foo bar
' + + 'foo bar' + ); + + const viewRoot = viewDocument.getRoot(); + const blockQuote = viewRoot.getChild( 1 ); + const paragraph = blockQuote.getChild( 0 ); + const preventDefault = sinon.spy(); + + const domEventDataMock = new DomEventData( view, { + target: view.domConverter.mapViewToDom( paragraph ), + preventDefault, + detail: 3 + } ); + + viewDocument.fire( 'mousedown', domEventDataMock ); + + sinon.assert.called( preventDefault ); + + expect( getModelData( model ) ).to.equal( + 'foo' + + '
[foo bar
' + + ']foo bar' + ); + } ); + + it( 'should set selection on entire parqgraph with inline inside', () => { + setModelData( model, + 'f[o]o' + + 'foobar' + ); + + const viewRoot = viewDocument.getRoot(); + const paragraph = viewRoot.getChild( 1 ); + const preventDefault = sinon.spy(); + + const domEventDataMock = new DomEventData( view, { + target: view.domConverter.mapViewToDom( paragraph ), + preventDefault, + detail: 3 + } ); + + viewDocument.fire( 'mousedown', domEventDataMock ); + + sinon.assert.called( preventDefault ); + + expect( getModelData( model ) ).to.equal( + 'foo' + + '[foobar]' + ); + } ); + + it( 'should extend selection with inline in paragraph', () => { + setModelData( model, + 'f[o]o' + + 'foobar' + + 'foo' + ); + + const viewRoot = viewDocument.getRoot(); + const paragraph = viewRoot.getChild( 1 ); + const preventDefault = sinon.spy(); + + const domEventDataMock = new DomEventData( view, { + target: view.domConverter.mapViewToDom( paragraph ), + preventDefault, + detail: 3 + } ); + + viewDocument.fire( 'mousedown', domEventDataMock ); + + sinon.assert.called( preventDefault ); + + expect( getModelData( model ) ).to.equal( + 'foo' + + '[foobar' + + ']foo' + ); + } ); + + it( 'should extend selection with inline in paragraph (when inline widget clicked)', () => { + setModelData( model, + 'f[o]o' + + 'foobar' + + 'foo' + ); + + const viewRoot = viewDocument.getRoot(); + const paragraph = viewRoot.getChild( 1 ); + const inline = paragraph.getChild( 1 ); + const preventDefault = sinon.spy(); + + const domEventDataMock = new DomEventData( view, { + target: view.domConverter.mapViewToDom( inline ), + preventDefault, + detail: 3 + } ); + + viewDocument.fire( 'mousedown', domEventDataMock ); + + sinon.assert.called( preventDefault ); + + expect( getModelData( model ) ).to.equal( + 'foo' + + '[foobar' + + ']foo' + ); + } ); + + it( 'should not extend selection inside widget', () => { + setModelData( model, + 'f[o]o' + + 'foo bar' + + 'foo bar' + ); + + const viewRoot = viewDocument.getRoot(); + const widget = viewRoot.getChild( 1 ); + const nested = widget.getChild( 0 ); + const preventDefault = sinon.spy(); + const domEventDataMock = new DomEventData( view, { + target: view.domConverter.mapViewToDom( nested ), + preventDefault, + detail: 3 + } ); + + viewDocument.fire( 'mousedown', domEventDataMock ); + + sinon.assert.called( preventDefault ); + + expect( getModelData( model ) ).to.equal( + 'foo' + + '[foo bar]' + + 'foo bar' + ); + } ); + + it( 'should not extend selection outside limit element', () => { + setModelData( model, + 'f[o]o' + + 'foo' + + 'foo bar' + ); + + const viewRoot = viewDocument.getRoot(); + const widget = viewRoot.getChild( 1 ); + const preventDefault = sinon.spy(); + const domEventDataMock = new DomEventData( view, { + target: view.domConverter.mapViewToDom( widget ), + preventDefault, + detail: 3 + } ); + + viewDocument.fire( 'mousedown', domEventDataMock ); + + sinon.assert.called( preventDefault ); + + expect( getModelData( model ) ).to.equal( + 'foo' + + '[foo]' + + 'foo bar' + ); + } ); + } ); + describe( 'keys handling', () => { describe( 'arrows', () => { test(