瀏覽代碼

Merge branch 'master' into t/891

Piotrek Koszuliński 8 年之前
父節點
當前提交
749831fff4

+ 1 - 1
packages/ckeditor5-engine/src/controller/insertcontent.js

@@ -135,7 +135,7 @@ class Insertion {
 		nodes = Array.from( nodes );
 
 		for ( let i = 0; i < nodes.length; i++ ) {
-			const node = nodes[ i ].clone();
+			const node = nodes[ i ];
 
 			this._handleNode( node, {
 				isFirst: i === 0 && parentContext.isFirst,

+ 26 - 0
packages/ckeditor5-engine/src/view/observer/focusobserver.js

@@ -7,6 +7,8 @@
  * @module engine/view/observer/focusobserver
  */
 
+/* globals setTimeout, clearTimeout */
+
 import DomEventObserver from './domeventobserver';
 
 /**
@@ -28,6 +30,12 @@ export default class FocusObserver extends DomEventObserver {
 
 		document.on( 'focus', () => {
 			document.isFocused = true;
+
+			// Unfortunately native `selectionchange` event is fired asynchronously.
+			// We need to wait until `SelectionObserver` handle the event and then render. Otherwise rendering will
+			// overwrite new DOM selection with selection from the view.
+			// See https://github.com/ckeditor/ckeditor5-engine/issues/795 for more details.
+			this._renderTimeoutId = setTimeout( () => document.render(), 0 );
 		} );
 
 		document.on( 'blur', ( evt, data ) => {
@@ -40,11 +48,29 @@ export default class FocusObserver extends DomEventObserver {
 				document.render();
 			}
 		} );
+
+		/**
+		 * Identifier of the timeout currently used by focus listener to delay rendering execution.
+		 *
+		 * @private
+		 * @member {Number} #_renderTimeoutId
+		 */
 	}
 
 	onDomEvent( domEvent ) {
 		this.fire( domEvent.type, domEvent );
 	}
+
+	/**
+	 * @inheritDoc
+	 */
+	destroy() {
+		if ( this._renderTimeoutId ) {
+			clearTimeout( this._renderTimeoutId );
+		}
+
+		super.destroy();
+	}
 }
 
 /**

+ 126 - 0
packages/ckeditor5-engine/src/view/placeholder.js

@@ -0,0 +1,126 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module engine/view/placeholder
+ */
+
+import extend from '@ckeditor/ckeditor5-utils/src/lib/lodash/extend';
+import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+import '../../theme/placeholder.scss';
+
+const listener = {};
+extend( listener, EmitterMixin );
+
+// Each document stores information about its placeholder elements and check functions.
+const documentPlaceholders = new WeakMap();
+
+/**
+ * Attaches placeholder to provided element and updates it's visibility. To change placeholder simply call this method
+ * once again with new parameters.
+ *
+ * @param {module:engine/view/element~Element} element Element to attach placeholder to.
+ * @param {String} placeholderText Placeholder text to use.
+ * @param {Function} [checkFunction] If provided it will be called before checking if placeholder should be displayed.
+ * If function returns `false` placeholder will not be showed.
+ */
+export function attachPlaceholder( element, placeholderText, checkFunction ) {
+	const document = element.document;
+
+	if ( !document ) {
+		/**
+		 * Provided element is not placed in any {@link module:engine/view/document~Document}.
+		 *
+		 * @error view-placeholder-element-is-detached
+		 */
+		throw new CKEditorError( 'view-placeholder-element-is-detached: Provided element is not placed in document.' );
+	}
+
+	// Detach placeholder if was used before.
+	detachPlaceholder( element );
+
+	// Single listener per document.
+	if ( !documentPlaceholders.has( document ) ) {
+		documentPlaceholders.set( document, new Map() );
+		listener.listenTo( document, 'render', () => updateAllPlaceholders( document ), { priority: 'high' } );
+	}
+
+	// Store text in element's data attribute.
+	// This data attribute is used in CSS class to show the placeholder.
+	element.setAttribute( 'data-placeholder', placeholderText );
+
+	// Store information about placeholder.
+	documentPlaceholders.get( document ).set( element, checkFunction );
+
+	// Update right away too.
+	updateSinglePlaceholder( element, checkFunction );
+}
+
+/**
+ * Removes placeholder functionality from given element.
+ *
+ * @param {module:engine/view/element~Element} element
+ */
+export function detachPlaceholder( element ) {
+	const document = element.document;
+
+	element.removeClass( 'ck-placeholder' );
+	element.removeAttribute( 'data-placeholder' );
+
+	if ( documentPlaceholders.has( document ) ) {
+		documentPlaceholders.get( document ).delete( element );
+	}
+}
+
+// Updates all placeholders of given document.
+//
+// @private
+// @param {module:engine/view/document~Document} document
+function updateAllPlaceholders( document ) {
+	const placeholders = documentPlaceholders.get( document );
+
+	for ( let [ element, checkFunction ] of placeholders ) {
+		updateSinglePlaceholder( element, checkFunction );
+	}
+}
+
+// Updates placeholder class of given element.
+//
+// @private
+// @param {module:engine/view/element~Element} element
+// @param {Function} checkFunction
+function updateSinglePlaceholder( element, checkFunction ) {
+	const document = element.document;
+
+	// Element was removed from document.
+	if ( !document ) {
+		return;
+	}
+
+	const viewSelection = document.selection;
+	const anchor = viewSelection.anchor;
+
+	// If checkFunction is provided and returns false - remove placeholder.
+	if ( checkFunction && !checkFunction() ) {
+		element.removeClass( 'ck-placeholder' );
+
+		return;
+	}
+
+	// If element is empty and editor is blurred.
+	if ( !document.isFocused && !element.childCount ) {
+		element.addClass( 'ck-placeholder' );
+
+		return;
+	}
+
+	// It there are no child elements and selection is not placed inside element.
+	if ( !element.childCount && anchor && anchor.parent !== element ) {
+		element.addClass( 'ck-placeholder' );
+	} else {
+		element.removeClass( 'ck-placeholder' );
+	}
+}

+ 20 - 0
packages/ckeditor5-engine/tests/controller/insertcontent.js

@@ -9,6 +9,7 @@ import insertContent from '../../src/controller/insertcontent';
 
 import DocumentFragment from '../../src/model/documentfragment';
 import Text from '../../src/model/text';
+import Element from '../../src/model/element';
 
 import { setData, getData, parse } from '../../src/dev-utils/model';
 
@@ -62,6 +63,25 @@ describe( 'DataController', () => {
 			expect( getData( doc ) ).to.equal( 'xa[]x' );
 		} );
 
+		it( 'should save the reference to the original object', () => {
+			const doc = new Document();
+			const dataController = new DataController( doc );
+			const batch = doc.batch();
+			const content = new Element( 'image' );
+
+			doc.createRoot();
+
+			doc.schema.registerItem( 'paragraph', '$block' );
+			doc.schema.registerItem( 'image', '$inline' );
+			doc.schema.objects.add( 'image' );
+
+			setData( doc, '<paragraph>foo[]</paragraph>' );
+
+			insertContent( dataController, content, doc.selection, batch );
+
+			expect( doc.getRoot().getChild( 0 ).getChild( 1 ) ).to.equal( content );
+		} );
+
 		describe( 'in simple scenarios', () => {
 			beforeEach( () => {
 				doc = new Document();

+ 5 - 0
packages/ckeditor5-engine/tests/manual/nestededitable.md

@@ -3,3 +3,8 @@
 * Put selection inside `foo bar baz` nested editable. Main editable and nested one should be focused (blue outline should be visible).
 * Change selection inside nested editable and see if `Model contents` change accordingly.
 * Click outside the editor. Outline from main editable and nested editable should be removed.
+* Check following scenario:
+  * put selection inside nested editable: `foo bar baz{}`,
+  * click outside the editor (outlines should be removed),
+  * put selection at exact same place as before: `foo bar baz{}`,
+  * both editables should be focused (blue outline should be visible).

+ 1 - 0
packages/ckeditor5-engine/tests/manual/placeholder.html

@@ -0,0 +1 @@
+<div id="editor"><h2></h2><p></p></div>

+ 32 - 0
packages/ckeditor5-engine/tests/manual/placeholder.js

@@ -0,0 +1,32 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global console */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classic';
+import Enter from '@ckeditor/ckeditor5-enter/src/enter';
+import Typing from '@ckeditor/ckeditor5-typing/src/typing';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Undo from '@ckeditor/ckeditor5-undo/src/undo';
+import Heading from '@ckeditor/ckeditor5-heading/src/heading';
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+import { attachPlaceholder } from '../../src/view/placeholder';
+
+ClassicEditor.create( global.document.querySelector( '#editor' ), {
+	plugins: [ Enter, Typing, Paragraph, Undo, Heading ],
+	toolbar: [ 'headings', 'undo', 'redo' ]
+} )
+.then( editor => {
+	const viewDoc = editor.editing.view;
+	const header = viewDoc.getRoot().getChild( 0 );
+	const paragraph = viewDoc.getRoot().getChild( 1 );
+
+	attachPlaceholder( header, 'Type some header text...' );
+	attachPlaceholder( paragraph, 'Type some paragraph text...' );
+	viewDoc.render();
+} )
+.catch( err => {
+	console.error( err.stack );
+} );

+ 9 - 0
packages/ckeditor5-engine/tests/manual/placeholder.md

@@ -0,0 +1,9 @@
+### Placeholder creation
+
+* You should see two placeholders:
+  * for heading: `Type some header text...`,
+  * and for paragraph: `Type some paragraph text...`.
+* Clicking on header and paragraph should remove placeholder.
+* Clicking outside the editor should show both placeholders.
+* Type some text into paragraph, and click outside. Paragraph placeholder should be hidden.
+* Remove added text and click outside - paragraph placeholder should now be visible again.

+ 81 - 2
packages/ckeditor5-engine/tests/view/observer/focusobserver.js

@@ -3,11 +3,11 @@
  * For licensing, see LICENSE.md.
  */
 
-/* globals document */
-
+/* globals document, window */
 import FocusObserver from '../../../src/view/observer/focusobserver';
 import ViewDocument from '../../../src/view/document';
 import ViewRange from '../../../src/view/range';
+import { setData } from '../../../src/dev-utils/view';
 
 describe( 'FocusObserver', () => {
 	let viewDocument, observer;
@@ -115,5 +115,84 @@ describe( 'FocusObserver', () => {
 
 			expect( viewDocument.isFocused ).to.be.true;
 		} );
+
+		it( 'should delay rendering to the next iteration of event loop', () => {
+			const renderSpy = sinon.spy( viewDocument, 'render' );
+			const clock = sinon.useFakeTimers();
+
+			observer.onDomEvent( { type: 'focus', target: domMain } );
+			sinon.assert.notCalled( renderSpy );
+			clock.tick( 0 );
+			sinon.assert.called( renderSpy );
+
+			clock.restore();
+		} );
+
+		it( 'should not call render if destroyed', () => {
+			const renderSpy = sinon.spy( viewDocument, 'render' );
+			const clock = sinon.useFakeTimers();
+
+			observer.onDomEvent( { type: 'focus', target: domMain } );
+			sinon.assert.notCalled( renderSpy );
+			observer.destroy();
+			clock.tick( 0 );
+			sinon.assert.notCalled( renderSpy );
+
+			clock.restore();
+		} );
+	} );
+
+	describe( 'integration test', () => {
+		let viewDocument, viewRoot, domRoot, observer, domSelection;
+
+		beforeEach( () => {
+			domRoot = document.createElement( 'div' );
+			document.body.appendChild( domRoot );
+			viewDocument = new ViewDocument();
+			viewRoot = viewDocument.createRoot( domRoot );
+			observer = viewDocument.getObserver( FocusObserver );
+			domSelection = window.getSelection();
+		} );
+
+		it( 'should render document after selectionChange event', ( done ) => {
+			const selectionChangeSpy = sinon.spy();
+			const renderSpy = sinon.spy();
+
+			setData( viewDocument, '<div contenteditable="true">foo bar</div>' );
+			viewDocument.render();
+			const domEditable = domRoot.childNodes[ 0 ];
+
+			viewDocument.on( 'selectionChange', selectionChangeSpy );
+			viewDocument.on( 'render', renderSpy, { priority: 'low' } );
+
+			viewDocument.on( 'render', () => {
+				sinon.assert.callOrder( selectionChangeSpy, renderSpy );
+				done();
+			}, { priority: 'low' } );
+
+			observer.onDomEvent( { type: 'focus', target: domEditable } );
+			domSelection.collapse( domEditable );
+		} );
+
+		it( 'should render without selectionChange event', ( done ) => {
+			const selectionChangeSpy = sinon.spy();
+			const renderSpy = sinon.spy();
+
+			setData( viewDocument, '<div contenteditable="true">foo bar</div>' );
+			viewDocument.render();
+			const domEditable = domRoot.childNodes[ 0 ];
+
+			viewDocument.on( 'selectionChange', selectionChangeSpy );
+			viewDocument.on( 'render', renderSpy, { priority: 'low' } );
+
+			viewDocument.on( 'render', () => {
+				sinon.assert.notCalled( selectionChangeSpy );
+				sinon.assert.called( renderSpy );
+
+				done();
+			}, { priority: 'low' } );
+
+			observer.onDomEvent( { type: 'focus', target: domEditable } );
+		} );
 	} );
 } );

+ 26 - 20
packages/ckeditor5-engine/tests/view/observer/selectionobserver.js

@@ -3,7 +3,7 @@
  * For licensing, see LICENSE.md.
  */
 
-/* globals setTimeout, document */
+/* globals setTimeout, setInterval, clearInterval, document */
 
 import ViewRange from '../../../src/view/range';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
@@ -11,23 +11,24 @@ import ViewSelection from '../../../src/view/selection';
 import ViewDocument from '../../../src/view/document';
 import SelectionObserver from '../../../src/view/observer/selectionobserver';
 import MutationObserver from '../../../src/view/observer/mutationobserver';
-
+import FocusObserver from '../../../src/view/observer/focusobserver';
 import log from '@ckeditor/ckeditor5-utils/src/log';
-
 import { parse } from '../../../src/dev-utils/view';
 
 testUtils.createSinonSandbox();
 
 describe( 'SelectionObserver', () => {
-	let viewDocument, viewRoot, mutationObserver, selectionObserver, domRoot;
+	let viewDocument, viewRoot, mutationObserver, selectionObserver, domRoot, domMain, domDocument;
 
 	beforeEach( ( done ) => {
-		domRoot = document.createElement( 'div' );
-		domRoot.innerHTML = `<div contenteditable="true" id="main"></div><div contenteditable="true" id="additional"></div>`;
-		document.body.appendChild( domRoot );
+		domDocument = document;
+		domRoot = domDocument.createElement( 'div' );
+		domRoot.innerHTML = `<div contenteditable="true"></div><div contenteditable="true" id="additional"></div>`;
+		domMain = domRoot.childNodes[ 0 ];
+		domDocument.body.appendChild( domRoot );
 
 		viewDocument = new ViewDocument();
-		viewDocument.createRoot( document.getElementById( 'main' ) );
+		viewDocument.createRoot( domMain );
 
 		mutationObserver = viewDocument.getObserver( MutationObserver );
 		selectionObserver = viewDocument.getObserver( SelectionObserver );
@@ -41,7 +42,7 @@ describe( 'SelectionObserver', () => {
 		viewDocument.render();
 
 		viewDocument.selection.removeAllRanges();
-		document.getSelection().removeAllRanges();
+		domDocument.getSelection().removeAllRanges();
 
 		viewDocument.isFocused = true;
 
@@ -59,7 +60,7 @@ describe( 'SelectionObserver', () => {
 
 	it( 'should fire selectionChange when it is the only change', ( done ) => {
 		viewDocument.on( 'selectionChange', ( evt, data ) => {
-			expect( data ).to.have.property( 'domSelection' ).that.equals( document.getSelection() );
+			expect( data ).to.have.property( 'domSelection' ).that.equals( domDocument.getSelection() );
 
 			expect( data ).to.have.property( 'oldSelection' ).that.is.instanceof( ViewSelection );
 			expect( data.oldSelection.rangeCount ).to.equal( 0 );
@@ -83,7 +84,7 @@ describe( 'SelectionObserver', () => {
 
 	it( 'should add only one listener to one document', ( done ) => {
 		// Add second roots to ensure that listener is added once.
-		viewDocument.createRoot( document.getElementById( 'additional' ), 'additional' );
+		viewDocument.createRoot( domDocument.getElementById( 'additional' ), 'additional' );
 
 		viewDocument.on( 'selectionChange', () => {
 			done();
@@ -138,6 +139,8 @@ describe( 'SelectionObserver', () => {
 	it( 'should warn and not enter infinite loop', ( done ) => {
 		// Reset infinite loop counters so other tests won't mess up with this test.
 		selectionObserver._clearInfiniteLoop();
+		clearInterval( selectionObserver._clearInfiniteLoopInterval );
+		selectionObserver._clearInfiniteLoopInterval = setInterval( () => selectionObserver._clearInfiniteLoop(), 2000 );
 
 		let counter = 100;
 
@@ -232,6 +235,9 @@ describe( 'SelectionObserver', () => {
 
 		viewDocument.on( 'selectionChangeDone', spy );
 
+		// Disable focus observer to not re-render view on each focus.
+		viewDocument.getObserver( FocusObserver ).disable();
+
 		// Change selection.
 		changeDomSelection();
 
@@ -250,7 +256,7 @@ describe( 'SelectionObserver', () => {
 				const data = spy.firstCall.args[ 1 ];
 
 				expect( spy.calledOnce ).to.true;
-				expect( data ).to.have.property( 'domSelection' ).to.equal( document.getSelection() );
+				expect( data ).to.have.property( 'domSelection' ).to.equal( domDocument.getSelection() );
 
 				expect( data ).to.have.property( 'oldSelection' ).to.instanceof( ViewSelection );
 				expect( data.oldSelection.rangeCount ).to.equal( 0 );
@@ -295,13 +301,13 @@ describe( 'SelectionObserver', () => {
 			}, 110 );
 		}, 100 );
 	} );
-} );
 
-function changeDomSelection() {
-	const domSelection = document.getSelection();
-	const domFoo = document.getElementById( 'main' ).childNodes[ 0 ].childNodes[ 0 ];
-	const offset = domSelection.anchorOffset;
+	function changeDomSelection() {
+		const domSelection = domDocument.getSelection();
+		const domFoo = domMain.childNodes[ 0 ].childNodes[ 0 ];
+		const offset = domSelection.anchorOffset;
 
-	domSelection.removeAllRanges();
-	domSelection.collapse( domFoo, offset == 2 ? 3 : 2 );
-}
+		domSelection.removeAllRanges();
+		domSelection.collapse( domFoo, offset == 2 ? 3 : 2 );
+	}
+} );

+ 169 - 0
packages/ckeditor5-engine/tests/view/placeholder.js

@@ -0,0 +1,169 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import { attachPlaceholder, detachPlaceholder } from '../../src/view/placeholder';
+import ViewContainerElement from '../../src/view/containerelement';
+import ViewDocument from '../../src/view/document';
+import ViewRange from '../../src/view/range';
+import { setData } from '../../src/dev-utils/view';
+
+describe( 'placeholder', () => {
+	let viewDocument, viewRoot;
+
+	beforeEach( () => {
+		viewDocument = new ViewDocument();
+		viewRoot = viewDocument.createRoot( 'main' );
+		viewDocument.isFocused = true;
+	} );
+
+	describe( 'createPlaceholder', () => {
+		it( 'should throw if element is not inside document', () => {
+			const element = new ViewContainerElement( 'div' );
+
+			expect( () => {
+				attachPlaceholder( element, 'foo bar baz' );
+			} ).to.throw( 'view-placeholder-element-is-detached' );
+		} );
+
+		it( 'should attach proper CSS class and data attribute', () => {
+			setData( viewDocument, '<div></div><div>{another div}</div>' );
+			const element = viewRoot.getChild( 0 );
+
+			attachPlaceholder( element, 'foo bar baz' );
+
+			expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'foo bar baz' );
+			expect( element.hasClass( 'ck-placeholder' ) ).to.be.true;
+		} );
+
+		it( 'if element has children set only data attribute', () => {
+			setData( viewDocument, '<div>first div</div><div>{another div}</div>' );
+			const element = viewRoot.getChild( 0 );
+
+			attachPlaceholder( element, 'foo bar baz' );
+
+			expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'foo bar baz' );
+			expect( element.hasClass( 'ck-placeholder' ) ).to.be.false;
+		} );
+
+		it( 'if element has selection inside set only data attribute', () => {
+			setData( viewDocument, '<div>[]</div><div>another div</div>' );
+			const element = viewRoot.getChild( 0 );
+
+			attachPlaceholder( element, 'foo bar baz' );
+
+			expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'foo bar baz' );
+			expect( element.hasClass( 'ck-placeholder' ) ).to.be.false;
+		} );
+
+		it( 'if element has selection inside but document is blurred should contain placeholder CSS class', () => {
+			setData( viewDocument, '<div>[]</div><div>another div</div>' );
+			const element = viewRoot.getChild( 0 );
+			viewDocument.isFocused = false;
+
+			attachPlaceholder( element, 'foo bar baz' );
+
+			expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'foo bar baz' );
+			expect( element.hasClass( 'ck-placeholder' ) ).to.be.true;
+		} );
+
+		it( 'use check function if one is provided', () => {
+			setData( viewDocument, '<div></div><div>{another div}</div>' );
+			const element = viewRoot.getChild( 0 );
+			const spy = sinon.spy( () => false );
+
+			attachPlaceholder( element, 'foo bar baz', spy );
+
+			sinon.assert.calledOnce( spy );
+			expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'foo bar baz' );
+			expect( element.hasClass( 'ck-placeholder' ) ).to.be.false;
+		} );
+
+		it( 'should remove CSS class if selection is moved inside', () => {
+			setData( viewDocument, '<div></div><div>{another div}</div>' );
+			const element = viewRoot.getChild( 0 );
+
+			attachPlaceholder( element, 'foo bar baz' );
+
+			expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'foo bar baz' );
+			expect( element.hasClass( 'ck-placeholder' ) ).to.be.true;
+
+			viewDocument.selection.setRanges( [ ViewRange.createIn( element ) ] );
+			viewDocument.render();
+
+			expect( element.hasClass( 'ck-placeholder' ) ).to.be.false;
+		} );
+
+		it( 'should change placeholder settings when called twice', () => {
+			setData( viewDocument, '<div></div><div>{another div}</div>' );
+			const element = viewRoot.getChild( 0 );
+
+			attachPlaceholder( element, 'foo bar baz' );
+			attachPlaceholder( element, 'new text' );
+
+			expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'new text' );
+			expect( element.hasClass( 'ck-placeholder' ) ).to.be.true;
+		} );
+
+		it( 'should not throw when element is no longer in document', () => {
+			setData( viewDocument, '<div></div><div>{another div}</div>' );
+			const element = viewRoot.getChild( 0 );
+
+			attachPlaceholder( element, 'foo bar baz' );
+			setData( viewDocument, '<p>paragraph</p>' );
+
+			viewDocument.render();
+		} );
+
+		it( 'should allow to add placeholder to elements from different documents', () => {
+			setData( viewDocument, '<div></div><div>{another div}</div>' );
+			const element = viewRoot.getChild( 0 );
+			const secondDocument = new ViewDocument();
+			secondDocument.isFocused = true;
+			const secondRoot = secondDocument.createRoot( 'main' );
+			setData( secondDocument, '<div></div><div>{another div}</div>' );
+			const secondElement = secondRoot.getChild( 0 );
+
+			attachPlaceholder( element, 'first placeholder' );
+			attachPlaceholder( secondElement, 'second placeholder' );
+
+			expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'first placeholder' );
+			expect( element.hasClass( 'ck-placeholder' ) ).to.be.true;
+
+			expect( secondElement.getAttribute( 'data-placeholder' ) ).to.equal( 'second placeholder' );
+			expect( secondElement.hasClass( 'ck-placeholder' ) ).to.be.true;
+
+			// Move selection to the elements with placeholders.
+			viewDocument.selection.setRanges( [ ViewRange.createIn( element ) ] );
+			secondDocument.selection.setRanges( [ ViewRange.createIn( secondElement ) ] );
+
+			// Render changes.
+			viewDocument.render();
+			secondDocument.render();
+
+			expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'first placeholder' );
+			expect( element.hasClass( 'ck-placeholder' ) ).to.be.false;
+
+			expect( secondElement.getAttribute( 'data-placeholder' ) ).to.equal( 'second placeholder' );
+			expect( secondElement.hasClass( 'ck-placeholder' ) ).to.be.false;
+		} );
+	} );
+
+	describe( 'detachPlaceholder', () => {
+		it( 'should remove placeholder from element', () => {
+			setData( viewDocument, '<div></div><div>{another div}</div>' );
+			const element = viewRoot.getChild( 0 );
+
+			attachPlaceholder( element, 'foo bar baz' );
+
+			expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'foo bar baz' );
+			expect( element.hasClass( 'ck-placeholder' ) ).to.be.true;
+
+			detachPlaceholder( element );
+
+			expect( element.hasAttribute( 'data-placeholder' ) ).to.be.false;
+			expect( element.hasClass( 'ck-placeholder' ) ).to.be.false;
+		} );
+	} );
+} );

+ 8 - 0
packages/ckeditor5-engine/theme/placeholder.scss

@@ -0,0 +1,8 @@
+// Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+// For licensing, see LICENSE.md or http://ckeditor.com/license
+
+.ck-placeholder::before {
+	content: attr( data-placeholder );
+	cursor: text;
+	color: #c2c2c2;
+}