Browse Source

Merge pull request #79 from ckeditor/t/ckeditor5/479

Feature: Enabled the classic editor placeholder (see ckeditor/ckeditor5#479).
Piotr Jasiun 6 years ago
parent
commit
50e7b7edc6

+ 1 - 2
packages/ckeditor5-editor-classic/src/classiceditor.js

@@ -69,7 +69,7 @@ export default class ClassicEditor extends Editor {
 
 		this.model.document.createRoot();
 
-		this.ui = new ClassicEditorUI( this, new ClassicEditorUIView( this.locale ) );
+		this.ui = new ClassicEditorUI( this, new ClassicEditorUIView( this.locale, this.editing.view ) );
 
 		attachToForm( this );
 	}
@@ -173,7 +173,6 @@ export default class ClassicEditor extends Editor {
 			resolve(
 				editor.initPlugins()
 					.then( () => editor.ui.init( isElement( sourceElementOrData ) ? sourceElementOrData : null ) )
-					.then( () => editor.editing.view.attachDomRoot( editor.ui.getEditableElement() ) )
 					.then( () => {
 						const initialData = isElement( sourceElementOrData ) ?
 							getDataFromElement( sourceElementOrData ) :

+ 90 - 24
packages/ckeditor5-editor-classic/src/classiceditorui.js

@@ -10,6 +10,7 @@
 import EditorUI from '@ckeditor/ckeditor5-core/src/editor/editorui';
 import enableToolbarKeyboardFocus from '@ckeditor/ckeditor5-ui/src/toolbar/enabletoolbarkeyboardfocus';
 import normalizeToolbarConfig from '@ckeditor/ckeditor5-ui/src/toolbar/normalizetoolbarconfig';
+import { enablePlaceholder } from '@ckeditor/ckeditor5-engine/src/view/placeholder';
 import ElementReplacer from '@ckeditor/ckeditor5-utils/src/elementreplacer';
 
 /**
@@ -67,9 +68,78 @@ export default class ClassicEditorUI extends EditorUI {
 	init( replacementElement ) {
 		const editor = this.editor;
 		const view = this.view;
+		const editingView = editor.editing.view;
+		const editable = view.editable;
+		const editingRoot = editingView.document.getRoot();
+
+		// The editable UI and editing root should share the same name. Then name is used
+		// to recognize the particular editable, for instance in ARIA attributes.
+		editable.name = editingRoot.rootName;
 
 		view.render();
 
+		// The editable UI element in DOM is available for sure only after the editor UI view has been rendered.
+		// But it can be available earlier if a DOM element has been passed to BalloonEditor.create().
+		const editableElement = editable.element;
+
+		// Register the editable UI view in the editor. A single editor instance can aggregate multiple
+		// editable areas (roots) but the classic editor has only one.
+		this._editableElements.set( editable.name, editableElement );
+
+		// Let the global focus tracker know that the editable UI element is focusable and
+		// belongs to the editor. From now on, the focus tracker will sustain the editor focus
+		// as long as the editable is focused (e.g. the user is typing).
+		this.focusTracker.add( editableElement );
+
+		// Let the editable UI element respond to the changes in the global editor focus
+		// tracker. It has been added to the same tracker a few lines above but, in reality, there are
+		// many focusable areas in the editor, like balloons, toolbars or dropdowns and as long
+		// as they have focus, the editable should act like it is focused too (although technically
+		// it isn't), e.g. by setting the proper CSS class, visually announcing focus to the user.
+		// Doing otherwise will result in editable focus styles disappearing, once e.g. the
+		// toolbar gets focused.
+		view.editable.bind( 'isFocused' ).to( this.focusTracker );
+
+		// Bind the editable UI element to the editing view, making it an end– and entry–point
+		// of the editor's engine. This is where the engine meets the UI.
+		editingView.attachDomRoot( editableElement );
+
+		// If an element containing the initial data of the editor was provided, replace it with
+		// an editor instance's UI in DOM until the editor is destroyed. For instance, a <textarea>
+		// can be such element.
+		if ( replacementElement ) {
+			this._elementReplacer.replace( replacementElement, this.element );
+		}
+
+		this._initPlaceholder();
+		this._initToolbar();
+		this.fire( 'ready' );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	destroy() {
+		const view = this.view;
+		const editingView = this.editor.editing.view;
+
+		this._elementReplacer.restore();
+		editingView.detachDomRoot( view.editable.name );
+		view.destroy();
+
+		super.destroy();
+	}
+
+	/**
+	 * Initializes the editor toolbar.
+	 *
+	 * @private
+	 */
+	_initToolbar() {
+		const editor = this.editor;
+		const view = this.view;
+		const editingView = editor.editing.view;
+
 		// Set–up the sticky panel with toolbar.
 		view.stickyPanel.bind( 'isActive' ).to( this.focusTracker, 'isFocused' );
 		view.stickyPanel.limiterElement = view.element;
@@ -78,40 +148,36 @@ export default class ClassicEditorUI extends EditorUI {
 			view.stickyPanel.viewportTopOffset = this._toolbarConfig.viewportTopOffset;
 		}
 
-		// Setup the editable.
-		const editingRoot = editor.editing.view.document.getRoot();
-		view.editable.bind( 'isReadOnly' ).to( editingRoot );
-		view.editable.bind( 'isFocused' ).to( editor.editing.view.document );
-		view.editable.name = editingRoot.rootName;
-
-		this._editableElements.set( view.editable.name, view.editable.element );
-
-		this.focusTracker.add( view.editable.element );
-
 		view.toolbar.fillFromConfig( this._toolbarConfig.items, this.componentFactory );
 
 		enableToolbarKeyboardFocus( {
-			origin: editor.editing.view,
+			origin: editingView,
 			originFocusTracker: this.focusTracker,
 			originKeystrokeHandler: editor.keystrokes,
 			toolbar: view.toolbar
 		} );
-
-		if ( replacementElement ) {
-			this._elementReplacer.replace( replacementElement, this.element );
-		}
-
-		this.fire( 'ready' );
 	}
 
 	/**
-	 * @inheritDoc
+	 * Enable the placeholder text on the editing root, if any was configured.
+	 *
+	 * @private
 	 */
-	destroy() {
-		this._elementReplacer.restore();
-
-		this.view.destroy();
-
-		super.destroy();
+	_initPlaceholder() {
+		const editor = this.editor;
+		const editingView = editor.editing.view;
+		const editingRoot = editingView.document.getRoot();
+
+		const placeholderText = editor.config.get( 'placeholder' ) ||
+			editor.sourceElement && editor.sourceElement.getAttribute( 'placeholder' );
+
+		if ( placeholderText ) {
+			enablePlaceholder( {
+				view: editingView,
+				element: editingRoot,
+				text: placeholderText,
+				isDirectHost: false
+			} );
+		}
 	}
 }

+ 3 - 2
packages/ckeditor5-editor-classic/src/classiceditoruiview.js

@@ -25,8 +25,9 @@ export default class ClassicEditorUIView extends BoxedEditorUIView {
 	 * Creates an instance of the classic editor UI view.
 	 *
 	 * @param {module:utils/locale~Locale} locale The {@link module:core/editor/editor~Editor#locale} instance.
+	 * @param {module:engine/view/view~View} editingView The editing view instance this view is related to.
 	 */
-	constructor( locale ) {
+	constructor( locale, editingView ) {
 		super( locale );
 
 		/**
@@ -52,7 +53,7 @@ export default class ClassicEditorUIView extends BoxedEditorUIView {
 		 * @readonly
 		 * @member {module:ui/editableui/inline/inlineeditableuiview~InlineEditableUIView}
 		 */
-		this.editable = new InlineEditableUIView( locale );
+		this.editable = new InlineEditableUIView( locale, editingView );
 	}
 
 	/**

+ 119 - 25
packages/ckeditor5-editor-classic/tests/classiceditorui.js

@@ -10,11 +10,13 @@ import View from '@ckeditor/ckeditor5-ui/src/view';
 import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
 import ClassicEditorUI from '../src/classiceditorui';
 import EditorUI from '@ckeditor/ckeditor5-core/src/editor/editorui';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import ClassicEditorUIView from '../src/classiceditoruiview';
 
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import utils from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
+import { isElement } from 'lodash-es';
 
 describe( 'ClassicEditorUI', () => {
 	let editor, view, ui, viewElement;
@@ -23,7 +25,7 @@ describe( 'ClassicEditorUI', () => {
 
 	beforeEach( () => {
 		return VirtualClassicTestEditor
-			.create( {
+			.create( '', {
 				toolbar: [ 'foo', 'bar' ],
 			} )
 			.then( newEditor => {
@@ -69,7 +71,7 @@ describe( 'ClassicEditorUI', () => {
 
 			it( 'sets view.stickyPanel#viewportTopOffset, when specified in the config', () => {
 				return VirtualClassicTestEditor
-					.create( {
+					.create( '', {
 						toolbar: {
 							viewportTopOffset: 100
 						}
@@ -90,41 +92,82 @@ describe( 'ClassicEditorUI', () => {
 				expect( ui.focusTracker.isFocused ).to.true;
 			} );
 
-			it( 'sets view.editable#name', () => {
-				const editable = editor.editing.view.document.getRoot();
-
-				expect( view.editable.name ).to.equal( editable.rootName );
-			} );
-
 			it( 'binds view.editable#isFocused', () => {
 				utils.assertBinding(
 					view.editable,
 					{ isFocused: false },
 					[
-						[ editor.editing.view.document, { isFocused: true } ]
+						[ ui.focusTracker, { isFocused: true } ]
 					],
 					{ isFocused: true }
 				);
 			} );
 
-			it( 'binds view.editable#isReadOnly', () => {
+			it( 'set view.editable#name', () => {
 				const editable = editor.editing.view.document.getRoot();
 
-				utils.assertBinding(
-					view.editable,
-					{ isReadOnly: false },
-					[
-						[ editable, { isReadOnly: true } ]
-					],
-					{ isReadOnly: true }
-				);
+				expect( view.editable.name ).to.equal( editable.rootName );
+			} );
+		} );
+
+		describe( 'placeholder', () => {
+			it( 'sets placeholder from editor.config.placeholder', () => {
+				return VirtualClassicTestEditor
+					.create( 'foo', {
+						extraPlugins: [ Paragraph ],
+						placeholder: 'placeholder-text',
+					} )
+					.then( newEditor => {
+						const firstChild = newEditor.editing.view.document.getRoot().getChild( 0 );
+
+						expect( firstChild.getAttribute( 'data-placeholder' ) ).to.equal( 'placeholder-text' );
+
+						return newEditor.destroy();
+					} );
+			} );
+
+			it( 'sets placeholder from "placeholder" attribute of a passed element', () => {
+				const element = document.createElement( 'div' );
+
+				element.setAttribute( 'placeholder', 'placeholder-text' );
+
+				return VirtualClassicTestEditor
+					.create( element, {
+						extraPlugins: [ Paragraph ]
+					} )
+					.then( newEditor => {
+						const firstChild = newEditor.editing.view.document.getRoot().getChild( 0 );
+
+						expect( firstChild.getAttribute( 'data-placeholder' ) ).to.equal( 'placeholder-text' );
+
+						return newEditor.destroy();
+					} );
+			} );
+
+			it( 'uses editor.config.placeholder rather than "placeholder" attribute of a passed element', () => {
+				const element = document.createElement( 'div' );
+
+				element.setAttribute( 'placeholder', 'placeholder-text' );
+
+				return VirtualClassicTestEditor
+					.create( element, {
+						placeholder: 'config takes precedence',
+						extraPlugins: [ Paragraph ]
+					} )
+					.then( newEditor => {
+						const firstChild = newEditor.editing.view.document.getRoot().getChild( 0 );
+
+						expect( firstChild.getAttribute( 'data-placeholder' ) ).to.equal( 'config takes precedence' );
+
+						return newEditor.destroy();
+					} );
 			} );
 		} );
 
 		describe( 'view.toolbar#items', () => {
 			it( 'are filled with the config.toolbar (specified as an Array)', () => {
 				return VirtualClassicTestEditor
-					.create( {
+					.create( '', {
 						toolbar: [ 'foo', 'bar' ]
 					} )
 					.then( editor => {
@@ -139,7 +182,7 @@ describe( 'ClassicEditorUI', () => {
 
 			it( 'are filled with the config.toolbar (specified as an Object)', () => {
 				return VirtualClassicTestEditor
-					.create( {
+					.create( '', {
 						toolbar: {
 							items: [ 'foo', 'bar' ],
 							viewportTopOffset: 100
@@ -157,7 +200,7 @@ describe( 'ClassicEditorUI', () => {
 		} );
 
 		it( 'initializes keyboard navigation between view#toolbar and view#editable', () => {
-			return VirtualClassicTestEditor.create()
+			return VirtualClassicTestEditor.create( '' )
 				.then( editor => {
 					const ui = editor.ui;
 					const view = ui.view;
@@ -180,6 +223,47 @@ describe( 'ClassicEditorUI', () => {
 		} );
 	} );
 
+	describe( 'destroy()', () => {
+		it( 'detaches the DOM root then destroys the UI view', () => {
+			return VirtualClassicTestEditor.create( '' )
+				.then( newEditor => {
+					const destroySpy = sinon.spy( newEditor.ui.view, 'destroy' );
+					const detachSpy = sinon.spy( newEditor.editing.view, 'detachDomRoot' );
+
+					return newEditor.destroy()
+						.then( () => {
+							sinon.assert.callOrder( detachSpy, destroySpy );
+						} );
+				} );
+		} );
+
+		it( 'restores the editor element back to its original state', () => {
+			const domElement = document.createElement( 'div' );
+
+			domElement.setAttribute( 'foo', 'bar' );
+			domElement.setAttribute( 'data-baz', 'qux' );
+			domElement.classList.add( 'foo-class' );
+
+			return VirtualClassicTestEditor.create( domElement )
+				.then( newEditor => {
+					return newEditor.destroy()
+						.then( () => {
+							const attributes = {};
+
+							for ( const attribute of domElement.attributes ) {
+								attributes[ attribute.name ] = attribute.value;
+							}
+
+							expect( attributes ).to.deep.equal( {
+								foo: 'bar',
+								'data-baz': 'qux',
+								class: 'foo-class'
+							} );
+						} );
+				} );
+		} );
+	} );
+
 	describe( 'view()', () => {
 		it( 'returns view instance', () => {
 			expect( ui.view ).to.equal( view );
@@ -219,10 +303,14 @@ function viewCreator( name ) {
 }
 
 class VirtualClassicTestEditor extends VirtualTestEditor {
-	constructor( config ) {
+	constructor( sourceElementOrData, config ) {
 		super( config );
 
-		const view = new ClassicEditorUIView( this.locale );
+		if ( isElement( sourceElementOrData ) ) {
+			this.sourceElement = sourceElementOrData;
+		}
+
+		const view = new ClassicEditorUIView( this.locale, this.editing.view );
 		this.ui = new ClassicEditorUI( this, view );
 
 		this.ui.componentFactory.add( 'foo', viewCreator( 'foo' ) );
@@ -235,14 +323,20 @@ class VirtualClassicTestEditor extends VirtualTestEditor {
 		return super.destroy();
 	}
 
-	static create( config ) {
+	static create( sourceElementOrData, config ) {
 		return new Promise( resolve => {
-			const editor = new this( config );
+			const editor = new this( sourceElementOrData, config );
 
 			resolve(
 				editor.initPlugins()
 					.then( () => {
 						editor.ui.init();
+
+						const initialData = isElement( sourceElementOrData ) ?
+							sourceElementOrData.innerHTML :
+							sourceElementOrData;
+
+						editor.data.init( initialData );
 						editor.fire( 'ready' );
 					} )
 					.then( () => editor )

+ 7 - 2
packages/ckeditor5-editor-classic/tests/classiceditoruiview.js

@@ -4,21 +4,26 @@
  */
 
 import ClassicEditorUIView from '../src/classiceditoruiview';
+import EditingView from '@ckeditor/ckeditor5-engine/src/view/view';
 import StickyPanelView from '@ckeditor/ckeditor5-ui/src/panel/sticky/stickypanelview';
 import ToolbarView from '@ckeditor/ckeditor5-ui/src/toolbar/toolbarview';
 import InlineEditableUIView from '@ckeditor/ckeditor5-ui/src/editableui/inline/inlineeditableuiview';
 import Locale from '@ckeditor/ckeditor5-utils/src/locale';
+import createRoot from '@ckeditor/ckeditor5-engine/tests/view/_utils/createroot.js';
 
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 
 describe( 'ClassicEditorUIView', () => {
-	let locale, view;
+	let locale, view, editingView, editingViewRoot;
 
 	testUtils.createSinonSandbox();
 
 	beforeEach( () => {
 		locale = new Locale( 'en' );
-		view = new ClassicEditorUIView( locale );
+		editingView = new EditingView();
+		editingViewRoot = createRoot( editingView.document );
+		view = new ClassicEditorUIView( locale, editingView );
+		view.editable.name = editingViewRoot.rootName;
 		view.render();
 	} );
 

+ 5 - 0
packages/ckeditor5-editor-classic/tests/manual/placeholder.html

@@ -0,0 +1,5 @@
+<div id="editor-1" placeholder="Placeholder from the attribute">
+	<p>Remove this text to see the placeholder.</p>
+</div>
+<br />
+<div id="editor-2" placeholder="Placeholder from the attribute"></div>

+ 37 - 0
packages/ckeditor5-editor-classic/tests/manual/placeholder.js

@@ -0,0 +1,37 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals console:false, document, window */
+
+import ClassicEditor from '../../src/classiceditor';
+import Enter from '@ckeditor/ckeditor5-enter/src/enter';
+import Typing from '@ckeditor/ckeditor5-typing/src/typing';
+import Heading from '@ckeditor/ckeditor5-heading/src/heading';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Undo from '@ckeditor/ckeditor5-undo/src/undo';
+import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
+import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
+
+window.editors = {};
+
+function initEditor( element, placeholder ) {
+	ClassicEditor
+		.create( element, {
+			plugins: [ Enter, Typing, Paragraph, Undo, Heading, Bold, Italic ],
+			toolbar: [ 'heading', '|', 'bold', 'italic', 'undo', 'redo' ],
+			placeholder
+		} )
+		.then( newEditor => {
+			console.log( 'Editor was initialized', newEditor );
+
+			window.editors[ element.id ] = newEditor;
+		} )
+		.catch( err => {
+			console.error( err.stack );
+		} );
+}
+
+initEditor( document.querySelector( '#editor-1' ) );
+initEditor( document.querySelector( '#editor-2' ), 'The placeholder from editor.config.placeholder' );

+ 8 - 0
packages/ckeditor5-editor-classic/tests/manual/placeholder.md

@@ -0,0 +1,8 @@
+## Editor placeholder
+
+1. Make sure the placeholder is visible in the editor without content.
+1. Focus the editor — the placeholder should disappear.
+1. Blur the editor — the placeholder should re–appear.
+1. Focus the editor with the content
+1. Remove the content, no placeholder should be visible.
+1. Blur the editor, the placeholder should appear.