浏览代码

Merge branch 'master' into t/ckeditor5/1529

Aleksander Nowodzinski 6 年之前
父节点
当前提交
696027f5c0

+ 60 - 1
packages/ckeditor5-widget/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:
+ *		<placeholder type="name"></placeholder>
+ *
+ *		// View:
+ *		<span class="placeholder">name</span>
+ *
+ * In such case, view positions inside `<span>` cannot be correct mapped to the model (because the model element is empty).
+ * To handle mapping positions inside `<span class="placeholder">` 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:
+ *		<p>foo <span class="placeholder">name|</span> bar</p>
+ *
+ *		// Model:
+ *		<paragraph>foo <placeholder type="name"></placeholder>| bar</paragraph>
+ *
+ * 2. When the position is at the beginning of the inline widget:
+ *
+ *		// View:
+ *		<p>foo <span class="placeholder">|name</span> bar</p>
+ *
+ *		// Model:
+ *		<paragraph>foo |<placeholder type="name"></placeholder> bar</paragraph>
+ *
+ * @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}

+ 38 - 0
packages/ckeditor5-widget/tests/manual/inline-widget.html

@@ -0,0 +1,38 @@
+<div id="editor">
+	<h1>Hello <placeholder>{name}</placeholder>!</h1>
+
+	<p>We would like to invite you to <placeholder>{place}</placeholder> that will take place on <placeholder>{date}</placeholder>.</p>
+</div>
+
+<h2>Model contents:</h2>
+
+<pre id="model"></pre>
+
+<style>
+	placeholder {
+		background: #ffff00;
+		padding: 4px 2px;
+		outline-offset: -2px;
+		line-height: 1em;
+	}
+
+	placeholder::selection {
+		display: none;
+	}
+
+	/* This will show box when the fake selection is active. */
+	.ck.ck-content div[style*="left: -9999px;"]::before {
+		background: hsla(9,100%,56%,.3);
+		border: 1px dotted hsl(15, 100%, 43%);
+		color: #333;
+		content: 'fake selection set';
+		display: block;
+		height: 20px;
+		left: calc(50% - 60px);
+		line-height: 20px;
+		padding: 2px 5px;
+		position: fixed;
+		top: 5px;
+		z-index: 1;
+	}
+</style>

+ 144 - 0
packages/ckeditor5-widget/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( /(<tableCell>)\n(<paragraph>)/g, '$1$2' )
+		.replace( /\n(<tableCell>)/g, '\n\t$1' );
+}

+ 9 - 0
packages/ckeditor5-widget/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 <heading> or <paragraph>).
+* 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

+ 115 - 5
packages/ckeditor5-widget/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, '<paragraph>x</paragraph><paragraph>f[<placeholder></placeholder>]oo</paragraph><paragraph>y</paragraph>' );
+
+			const pos = findOptimalInsertionPosition( doc.selection, model );
+
+			expect( pos.path ).to.deep.equal( [ 1 ] );
+		} );
+
 		it( 'returns position inside empty block', () => {
 			setData( model, '<paragraph>x</paragraph><paragraph>[]</paragraph><paragraph>y</paragraph>' );
 
@@ -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, '<paragraph>x</paragraph><paragraph>f[]oo</paragraph><paragraph>y</paragraph>' );
 
 			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, '<paragraph>x</paragraph><paragraph>f[o]o</paragraph><paragraph>y</paragraph>' );
+
+			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, '<paragraph>x</paragraph><paragraph>foo[]</paragraph><paragraph>y</paragraph>' );
 
@@ -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: <p>foo<span></span>bar</p>
+			const modelFoo = new ModelText( 'foo' );
+			modelSpan = new ModelElement( 'span' );
+			const modelBar = new ModelText( 'bar' );
+			modelP = new ModelElement( 'p', null, [ modelFoo, modelSpan, modelBar ] );
+
+			// VIEW: <p>foo<span>xyz</span>bar</p>
+			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:
+			// <p>foo<span>|xyz</span>bar</p>.
+			const viewPosition = new ViewPosition( viewXyz, 0 );
+
+			// Model:
+			// <p>foo|<span></span>bar</p>.
+			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:
+			// <p>foo<span>x|yz</span>bar</p>.
+			const viewPosition = new ViewPosition( viewXyz, 1 );
+
+			// Model:
+			// <p>foo|<span></span>bar</p>.
+			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:
+			// <p>foo<span>xyz|</span>bar</p>.
+			const viewPosition = new ViewPosition( viewXyz, 3 );
+
+			// Model:
+			// <p>foo<span></span>|bar</p>.
+			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:
+			// <p>foo<span>x|yz</span>bar</p>.
+			const viewPosition = new ViewPosition( viewXyz, 1 );
+
+			// Model:
+			// <p>foo<span>x|yz</span>bar</p>.
+			modelSpan._appendChild( new ModelText( 'xyz' ) );
+			const modelPosition = mapper.toModelPosition( viewPosition );
+
+			expect( modelPosition.path ).to.deep.equal( [ 3, 1 ] );
+		} );
+	} );
 } );