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', () => {
'
'
@@ -215,8 +216,8 @@ describe( 'Widget - integration', () => {
'' +
'' +
'foo' +
- '[foo <$text linkHref="abc">bar$text>]' +
- 'bar' +
+ '[foo <$text linkHref="abc">bar$text>' +
+ ']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(
- '[]
' +
- ''
+ ']'
);
- 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$text>'
+ );
+
+ 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$text>]'
+ );
+ } );
+
+ 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(