8
0
Эх сурвалжийг харах

Merge pull request #872 from ckeditor/t/857

Feature: Added placeholder utility that can be applied to view elements. Closes #857.
Piotr Jasiun 8 жил өмнө
parent
commit
d44fe607b2

+ 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' );
+	}
+}

+ 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.

+ 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;
+}