diff --git a/src/utils.js b/src/utils.js index da2cd779..84b2ad54 100644 --- a/src/utils.js +++ b/src/utils.js @@ -262,7 +262,7 @@ export function toWidgetEditable( editable, writer ) { export function findOptimalInsertionPosition( selection, model ) { const selectedElement = selection.getSelectedElement(); - if ( selectedElement ) { + if ( selectedElement && model.schema.isBlock( selectedElement ) ) { return model.createPositionAfter( selectedElement ); } @@ -289,6 +289,65 @@ export function findOptimalInsertionPosition( selection, model ) { return selection.focus; } +/** + * A util to be used in order to map view positions to correct model positions when implementing a widget + * which renders non-empty view element for an empty model element. + * + * For example: + * + * // Model: + * + * + * // View: + * name + * + * In such case, view positions inside `` cannot be correct mapped to the model (because the model element is empty). + * To handle mapping positions inside `` to the model use this util as follows: + * + * editor.editing.mapper.on( + * 'viewToModelPosition', + * viewToModelPositionOutsideModelElement( model, viewElement => viewElement.hasClass( 'placeholder' ) ) + * ); + * + * The callback will try to map the view offset of selection to an expected model position. + * + * 1. When the position is at the end (or in the middle) of the inline widget: + * + * // View: + *

foo name| bar

+ * + * // Model: + * foo | bar + * + * 2. When the position is at the beginning of the inline widget: + * + * // View: + *

foo |name bar

+ * + * // Model: + * foo | bar + * + * @param {module:engine/model/model~Model} model Model instance on which the callback operates. + * @param {Function} viewElementMatcher Function that is passed a view element and should return `true` if the custom mapping + * should be applied to the given view element. + * @return {Function} + */ +export function viewToModelPositionOutsideModelElement( model, viewElementMatcher ) { + return ( evt, data ) => { + const { mapper, viewPosition } = data; + + const viewParent = mapper.findMappedViewAncestor( viewPosition ); + + if ( !viewElementMatcher( viewParent ) ) { + return; + } + + const modelParent = mapper.toModelElement( viewParent ); + + data.modelPosition = model.createPositionAt( modelParent, viewPosition.isAtStart ? 'before' : 'after' ); + }; +} + // Default filler offset function applied to all widget elements. // // @returns {null} diff --git a/tests/manual/inline-widget.html b/tests/manual/inline-widget.html new file mode 100644 index 00000000..163ce927 --- /dev/null +++ b/tests/manual/inline-widget.html @@ -0,0 +1,38 @@ +
+

Hello {name}!

+ +

We would like to invite you to {place} that will take place on {date}.

+
+ +

Model contents:

+ +

+
+
diff --git a/tests/manual/inline-widget.js b/tests/manual/inline-widget.js
new file mode 100644
index 00000000..da2c22ee
--- /dev/null
+++ b/tests/manual/inline-widget.js
@@ -0,0 +1,144 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global console, window */
+
+import { getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
+import Enter from '@ckeditor/ckeditor5-enter/src/enter';
+import Heading from '@ckeditor/ckeditor5-heading/src/heading';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Typing from '@ckeditor/ckeditor5-typing/src/typing';
+import Undo from '@ckeditor/ckeditor5-undo/src/undo';
+import Widget from '@ckeditor/ckeditor5-widget/src/widget';
+import { toWidget, viewToModelPositionOutsideModelElement } from '../../src/utils';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
+import ShiftEnter from '@ckeditor/ckeditor5-enter/src/shiftenter';
+import Table from '@ckeditor/ckeditor5-table/src/table';
+
+class InlineWidget extends Plugin {
+	constructor( editor ) {
+		super( editor );
+
+		editor.model.schema.register( 'placeholder', {
+			allowWhere: '$text',
+			isObject: true,
+			isInline: true,
+			allowAttributes: [ 'type' ]
+		} );
+
+		editor.conversion.for( 'editingDowncast' ).elementToElement( {
+			model: 'placeholder',
+			view: ( modelItem, viewWriter ) => {
+				const widgetElement = createPlaceholderView( modelItem, viewWriter );
+
+				return toWidget( widgetElement, viewWriter );
+			}
+		} );
+
+		editor.conversion.for( 'dataDowncast' ).elementToElement( {
+			model: 'placeholder',
+			view: createPlaceholderView
+		} );
+
+		editor.conversion.for( 'upcast' ).elementToElement( {
+			view: 'placeholder',
+			model: ( viewElement, modelWriter ) => {
+				let type = 'general';
+
+				if ( viewElement.childCount ) {
+					const text = viewElement.getChild( 0 );
+
+					if ( text.is( 'text' ) ) {
+						type = text.data.slice( 1, -1 );
+					}
+				}
+
+				return modelWriter.createElement( 'placeholder', { type } );
+			}
+		} );
+
+		editor.editing.mapper.on(
+			'viewToModelPosition',
+			viewToModelPositionOutsideModelElement( editor.model, viewElement => viewElement.name == 'placeholder' )
+		);
+
+		this._createToolbarButton();
+
+		function createPlaceholderView( modelItem, viewWriter ) {
+			const widgetElement = viewWriter.createContainerElement( 'placeholder' );
+			const viewText = viewWriter.createText( '{' + modelItem.getAttribute( 'type' ) + '}' );
+
+			viewWriter.insert( viewWriter.createPositionAt( widgetElement, 0 ), viewText );
+
+			return widgetElement;
+		}
+	}
+
+	_createToolbarButton() {
+		const editor = this.editor;
+		const t = editor.t;
+
+		editor.ui.componentFactory.add( 'placeholder', locale => {
+			const buttonView = new ButtonView( locale );
+
+			buttonView.set( {
+				label: t( 'Insert placeholder' ),
+				tooltip: true,
+				withText: true
+			} );
+
+			this.listenTo( buttonView, 'execute', () => {
+				const model = editor.model;
+
+				model.change( writer => {
+					const placeholder = writer.createElement( 'placeholder', { type: 'placeholder' } );
+
+					model.insertContent( placeholder );
+
+					writer.setSelection( placeholder, 'on' );
+				} );
+			} );
+
+			return buttonView;
+		} );
+	}
+}
+
+ClassicEditor
+	.create( global.document.querySelector( '#editor' ), {
+		plugins: [ Enter, Typing, Paragraph, Heading, Bold, Undo, Clipboard, Widget, ShiftEnter, InlineWidget, Table ],
+		toolbar: [ 'heading', '|', 'bold', '|', 'placeholder', '|', 'insertTable', '|', 'undo', 'redo' ]
+	} )
+	.then( editor => {
+		window.editor = editor;
+
+		editor.model.document.on( 'change', () => {
+			printModelContents( editor );
+		} );
+
+		printModelContents( editor );
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );
+
+const modelDiv = global.document.querySelector( '#model' );
+
+function printModelContents( editor ) {
+	modelDiv.innerText = formatData( getData( editor.model ) );
+}
+
+function formatData( data ) {
+	return data
+		.replace( /<(paragraph|\/tableRow|tableCell|table|heading[0-5])>/g, '\n<$1>' )
+		.replace( /()\n()/g, '$1$2' )
+		.replace( /\n()/g, '\n\t$1' );
+}
diff --git a/tests/manual/inline-widget.md b/tests/manual/inline-widget.md
new file mode 100644
index 00000000..8543eca9
--- /dev/null
+++ b/tests/manual/inline-widget.md
@@ -0,0 +1,9 @@
+### Inline widget
+
+* You should be able to type after inline widget (if it is last child of parent  or ).
+* Select text before/after widget and expand selection beyond widget. The widget should be selected and selection expanded beyond it on further expanding.
+* The inline-widget should work in nested editable widget (table).
+* Use "insert placeholder" toolbar button to insert inline widget.
+* Selected inline widget should have
+    * widget border (blue)
+    * activate fake selection - the "fake selection" element will be shown above the editor
diff --git a/tests/utils.js b/tests/utils.js
index 31a99906..f7707f8c 100644
--- a/tests/utils.js
+++ b/tests/utils.js
@@ -6,8 +6,9 @@
 /* global document */
 
 import DowncastWriter from '@ckeditor/ckeditor5-engine/src/view/downcastwriter';
-import Text from '@ckeditor/ckeditor5-engine/src/view/text';
+import ViewText from '@ckeditor/ckeditor5-engine/src/view/text';
 import ViewElement from '@ckeditor/ckeditor5-engine/src/view/element';
+import ViewPosition from '@ckeditor/ckeditor5-engine/src/view/position';
 import ViewEditableElement from '@ckeditor/ckeditor5-engine/src/view/editableelement';
 import ViewDocument from '@ckeditor/ckeditor5-engine/src/view/document';
 import {
@@ -18,6 +19,7 @@ import {
 	toWidgetEditable,
 	setHighlightHandling,
 	findOptimalInsertionPosition,
+	viewToModelPositionOutsideModelElement,
 	WIDGET_CLASS_NAME
 } from '../src/utils';
 import UIElement from '@ckeditor/ckeditor5-engine/src/view/uielement';
@@ -25,6 +27,9 @@ import env from '@ckeditor/ckeditor5-utils/src/env';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import Model from '@ckeditor/ckeditor5-engine/src/model/model';
 import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import Mapper from '@ckeditor/ckeditor5-engine/src/conversion/mapper';
+import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element';
+import ModelText from '@ckeditor/ckeditor5-engine/src/model/text';
 
 describe( 'widget utils', () => {
 	let element, writer, viewDocument;
@@ -146,7 +151,7 @@ describe( 'widget utils', () => {
 		} );
 
 		it( 'should return false for text node', () => {
-			expect( isWidget( new Text( 'p' ) ) ).to.be.false;
+			expect( isWidget( new ViewText( 'p' ) ) ).to.be.false;
 		} );
 	} );
 
@@ -175,7 +180,7 @@ describe( 'widget utils', () => {
 		} );
 	} );
 
-	describe( 'toWidgetEditable', () => {
+	describe( 'toWidgetEditable()', () => {
 		let viewDocument, element;
 
 		beforeEach( () => {
@@ -361,7 +366,8 @@ describe( 'widget utils', () => {
 
 			model.schema.extend( 'image', {
 				allowIn: '$root',
-				isObject: true
+				isObject: true,
+				isBlock: true
 			} );
 
 			model.schema.extend( 'span', { allowIn: 'paragraph' } );
@@ -376,6 +382,20 @@ describe( 'widget utils', () => {
 			expect( pos.path ).to.deep.equal( [ 2 ] );
 		} );
 
+		it( 'returns position before parent block if an inline object is selected', () => {
+			model.schema.register( 'placeholder', {
+				allowWhere: '$text',
+				isInline: true,
+				isObject: true
+			} );
+
+			setData( model, 'xf[]ooy' );
+
+			const pos = findOptimalInsertionPosition( doc.selection, model );
+
+			expect( pos.path ).to.deep.equal( [ 1 ] );
+		} );
+
 		it( 'returns position inside empty block', () => {
 			setData( model, 'x[]y' );
 
@@ -392,7 +412,7 @@ describe( 'widget utils', () => {
 			expect( pos.path ).to.deep.equal( [ 1 ] );
 		} );
 
-		it( 'returns position before block if in the middle of that block', () => {
+		it( 'returns position before block if in the middle of that block (collapsed selection)', () => {
 			setData( model, 'xf[]ooy' );
 
 			const pos = findOptimalInsertionPosition( doc.selection, model );
@@ -400,6 +420,14 @@ describe( 'widget utils', () => {
 			expect( pos.path ).to.deep.equal( [ 1 ] );
 		} );
 
+		it( 'returns position before block if in the middle of that block (non-collapsed selection)', () => {
+			setData( model, 'xf[o]oy' );
+
+			const pos = findOptimalInsertionPosition( doc.selection, model );
+
+			expect( pos.path ).to.deep.equal( [ 1 ] );
+		} );
+
 		it( 'returns position after block if at the end of that block', () => {
 			setData( model, 'xfoo[]y' );
 
@@ -426,4 +454,86 @@ describe( 'widget utils', () => {
 			expect( pos.path ).to.deep.equal( [ 3 ] );
 		} );
 	} );
+
+	describe( 'viewToModelPositionOutsideModelElement()', () => {
+		let mapper, model, modelP, viewP, viewXyz, modelSpan, viewSpan;
+
+		beforeEach( () => {
+			mapper = new Mapper();
+			model = new Model();
+
+			// MODEL: 

foobar

+ const modelFoo = new ModelText( 'foo' ); + modelSpan = new ModelElement( 'span' ); + const modelBar = new ModelText( 'bar' ); + modelP = new ModelElement( 'p', null, [ modelFoo, modelSpan, modelBar ] ); + + // VIEW:

fooxyzbar

+ const viewFoo = new ViewText( 'foo' ); + viewXyz = new ViewText( 'xyz' ); + viewSpan = new ViewElement( 'span', null, viewXyz ); + const viewBar = new ViewText( 'bar' ); + viewP = new ViewElement( 'p', null, [ viewFoo, viewSpan, viewBar ] ); + + mapper.bindElements( modelP, viewP ); + mapper.bindElements( modelSpan, viewSpan ); + } ); + + it( 'should map view position that is at the beginning of the view element to a position before the model element', () => { + mapper.on( 'viewToModelPosition', viewToModelPositionOutsideModelElement( model, viewElement => viewElement.name == 'span' ) ); + + // View: + //

foo|xyzbar

. + const viewPosition = new ViewPosition( viewXyz, 0 ); + + // Model: + //

foo|bar

. + const modelPosition = mapper.toModelPosition( viewPosition ); + + expect( modelPosition.path ).to.deep.equal( [ 3 ] ); + } ); + + it( 'should map view position that is in the middle of the view element to a position after the model element', () => { + mapper.on( 'viewToModelPosition', viewToModelPositionOutsideModelElement( model, viewElement => viewElement.name == 'span' ) ); + + // View: + //

foox|yzbar

. + const viewPosition = new ViewPosition( viewXyz, 1 ); + + // Model: + //

foo|bar

. + const modelPosition = mapper.toModelPosition( viewPosition ); + + expect( modelPosition.path ).to.deep.equal( [ 4 ] ); + } ); + + it( 'should map view position that is at the end of the view element to a position after the model element', () => { + mapper.on( 'viewToModelPosition', viewToModelPositionOutsideModelElement( model, viewElement => viewElement.name == 'span' ) ); + + // View: + //

fooxyz|bar

. + const viewPosition = new ViewPosition( viewXyz, 3 ); + + // Model: + //

foo|bar

. + const modelPosition = mapper.toModelPosition( viewPosition ); + + expect( modelPosition.path ).to.deep.equal( [ 4 ] ); + } ); + + it( 'should not fire if view element is not matched', () => { + mapper.on( 'viewToModelPosition', viewToModelPositionOutsideModelElement( model, () => false ) ); + + // View: + //

foox|yzbar

. + const viewPosition = new ViewPosition( viewXyz, 1 ); + + // Model: + //

foox|yzbar

. + modelSpan._appendChild( new ModelText( 'xyz' ) ); + const modelPosition = mapper.toModelPosition( viewPosition ); + + expect( modelPosition.path ).to.deep.equal( [ 3, 1 ] ); + } ); + } ); } );