diff --git a/src/decouplededitor.js b/src/decouplededitor.js index fde3e7c..06e65b6 100644 --- a/src/decouplededitor.js +++ b/src/decouplededitor.js @@ -73,7 +73,8 @@ export default class DecoupledEditor extends Editor { this.model.document.createRoot(); - this.ui = new DecoupledEditorUI( this, new DecoupledEditorUIView( this.locale, this.sourceElement ) ); + const view = new DecoupledEditorUIView( this.locale, this.editing.view, this.sourceElement ); + this.ui = new DecoupledEditorUI( this, view ); } /** diff --git a/src/decouplededitorui.js b/src/decouplededitorui.js index 33c7679..f51c949 100644 --- a/src/decouplededitorui.js +++ b/src/decouplededitorui.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'; /** * The decoupled editor UI class. @@ -49,29 +50,44 @@ export default class DecoupledEditorUI extends EditorUI { init() { 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. + view.editable.name = editingRoot.rootName; view.render(); - // Set up the editable. - const editingRoot = editor.editing.view.document.getRoot(); - view.editable.bind( 'isReadOnly' ).to( editingRoot ); - view.editable.bind( 'isFocused' ).to( editor.editing.view.document ); - editor.editing.view.attachDomRoot( view.editable.element ); - view.editable.name = editingRoot.rootName; + // 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 DecoupledEditor.create(). + const editableElement = editable.element; - this._editableElements.set( view.editable.name, view.editable.element ); + // Register the editable UI view in the editor. A single editor instance can aggregate multiple + // editable areas (roots) but the decoupled editor has only one. + this._editableElements.set( editable.name, editableElement ); - this.focusTracker.add( view.editable.element ); + // 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 ); - this.view.toolbar.fillFromConfig( this._toolbarConfig.items, this.componentFactory ); + // 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 ); - enableToolbarKeyboardFocus( { - origin: editor.editing.view, - originFocusTracker: this.focusTracker, - originKeystrokeHandler: editor.keystrokes, - toolbar: this.view.toolbar - } ); + // 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 ); + this._initPlaceholder(); + this._initToolbar(); this.fire( 'ready' ); } @@ -79,8 +95,55 @@ export default class DecoupledEditorUI extends EditorUI { * @inheritDoc */ destroy() { - this.view.destroy(); + const view = this.view; + const editingView = this.editor.editing.view; + + editingView.detachDomRoot( view.editable.name ); + view.destroy(); super.destroy(); } + + /** + * Initializes the inline editor toolbar and its panel. + * + * @private + */ + _initToolbar() { + const editor = this.editor; + const view = this.view; + const toolbar = view.toolbar; + + toolbar.fillFromConfig( this._toolbarConfig.items, this.componentFactory ); + + enableToolbarKeyboardFocus( { + origin: editor.editing.view, + originFocusTracker: this.focusTracker, + originKeystrokeHandler: editor.keystrokes, + toolbar + } ); + } + + /** + * Enable the placeholder text on the editing root, if any was configured. + * + * @private + */ + _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 + } ); + } + } } diff --git a/src/decouplededitoruiview.js b/src/decouplededitoruiview.js index 6da35b3..352cf33 100644 --- a/src/decouplededitoruiview.js +++ b/src/decouplededitoruiview.js @@ -28,10 +28,11 @@ export default class DecoupledEditorUIView extends EditorUIView { * Creates an instance of the decoupled 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. * @param {HTMLElement} [editableElement] The editable element. If not specified, it will be automatically created by * {@link module:ui/editableui/editableuiview~EditableUIView}. Otherwise, the given element will be used. */ - constructor( locale, editableElement ) { + constructor( locale, editingView, editableElement ) { super( locale ); /** @@ -48,7 +49,7 @@ export default class DecoupledEditorUIView extends EditorUIView { * @readonly * @member {module:ui/editableui/inline/inlineeditableuiview~InlineEditableUIView} */ - this.editable = new InlineEditableUIView( locale, editableElement ); + this.editable = new InlineEditableUIView( locale, editingView, editableElement ); // This toolbar may be placed anywhere in the page so things like font size need to be reset in it. // Also because of the above, make sure the toolbar supports rounded corners. diff --git a/tests/decouplededitorui.js b/tests/decouplededitorui.js index d5badc1..c0e4eec 100644 --- a/tests/decouplededitorui.js +++ b/tests/decouplededitorui.js @@ -10,11 +10,13 @@ import View from '@ckeditor/ckeditor5-ui/src/view'; import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; import DecoupledEditorUI from '../src/decouplededitorui'; import EditorUI from '@ckeditor/ckeditor5-core/src/editor/editorui'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import DecoupledEditorUIView from '../src/decouplededitoruiview'; 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( 'DecoupledEditorUI', () => { let editor, view, ui, viewElement; @@ -23,7 +25,7 @@ describe( 'DecoupledEditorUI', () => { beforeEach( () => { return VirtualDecoupledTestEditor - .create( { + .create( '', { toolbar: [ 'foo', 'bar' ], } ) .then( newEditor => { @@ -69,34 +71,75 @@ describe( 'DecoupledEditorUI', () => { view.editable, { isFocused: false }, [ - [ editor.editing.view.document, { isFocused: true } ] + [ ui.focusTracker, { isFocused: true } ] ], { isFocused: true } ); } ); - it( 'binds view.editable#isReadOnly', () => { - const editable = editor.editing.view.document.getRoot(); + it( 'attaches editable UI as view\'s DOM root', () => { + expect( editor.editing.view.getDomRoot() ).to.equal( view.editable.element ); + } ); + } ); - utils.assertBinding( - view.editable, - { isReadOnly: false }, - [ - [ editable, { isReadOnly: true } ] - ], - { isReadOnly: true } - ); + describe( 'placeholder', () => { + it( 'sets placeholder from editor.config.placeholder', () => { + return VirtualDecoupledTestEditor + .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( 'attaches editable UI as view\'s DOM root', () => { - expect( editor.editing.view.getDomRoot() ).to.equal( view.editable.element ); + it( 'sets placeholder from "placeholder" attribute of a passed element', () => { + const element = document.createElement( 'div' ); + + element.setAttribute( 'placeholder', 'placeholder-text' ); + + return VirtualDecoupledTestEditor + .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 VirtualDecoupledTestEditor + .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 VirtualDecoupledTestEditor - .create( { + .create( '', { toolbar: [ 'foo', 'bar' ] } ) .then( editor => { @@ -111,7 +154,7 @@ describe( 'DecoupledEditorUI', () => { it( 'are filled with the config.toolbar (specified as an Object)', () => { return VirtualDecoupledTestEditor - .create( { + .create( '', { toolbar: { items: [ 'foo', 'bar' ], viewportTopOffset: 100 @@ -129,7 +172,7 @@ describe( 'DecoupledEditorUI', () => { } ); it( 'initializes keyboard navigation between view#toolbar and view#editable', () => { - return VirtualDecoupledTestEditor.create() + return VirtualDecoupledTestEditor.create( '' ) .then( editor => { const ui = editor.ui; const view = ui.view; @@ -152,6 +195,47 @@ describe( 'DecoupledEditorUI', () => { } ); } ); + describe( 'destroy()', () => { + it( 'detaches the DOM root then destroys the UI view', () => { + return VirtualDecoupledTestEditor.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 VirtualDecoupledTestEditor.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( 'element()', () => { it( 'returns correct element instance', () => { expect( ui.element ).to.equal( viewElement ); @@ -185,10 +269,14 @@ function viewCreator( name ) { } class VirtualDecoupledTestEditor extends VirtualTestEditor { - constructor( config ) { + constructor( sourceElementOrData, config ) { super( config ); - const view = new DecoupledEditorUIView( this.locale ); + if ( isElement( sourceElementOrData ) ) { + this.sourceElement = sourceElementOrData; + } + + const view = new DecoupledEditorUIView( this.locale, this.editing.view ); this.ui = new DecoupledEditorUI( this, view ); this.ui.componentFactory.add( 'foo', viewCreator( 'foo' ) ); @@ -201,14 +289,20 @@ class VirtualDecoupledTestEditor 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 ) diff --git a/tests/decouplededitoruiview.js b/tests/decouplededitoruiview.js index c9e46ad..fd42fe2 100644 --- a/tests/decouplededitoruiview.js +++ b/tests/decouplededitoruiview.js @@ -6,20 +6,25 @@ /* globals document */ import DecoupledEditorUIView from '../src/decouplededitoruiview'; +import EditingView from '@ckeditor/ckeditor5-engine/src/view/view'; 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( 'DecoupledEditorUIView', () => { - let locale, view; + let locale, view, editingView, editingViewRoot; testUtils.createSinonSandbox(); beforeEach( () => { locale = new Locale( 'en' ); - view = new DecoupledEditorUIView( locale ); + editingView = new EditingView(); + editingViewRoot = createRoot( editingView.document ); + view = new DecoupledEditorUIView( locale, editingView ); + view.editable.name = editingViewRoot.rootName; } ); describe( 'constructor()', () => { @@ -57,7 +62,8 @@ describe( 'DecoupledEditorUIView', () => { it( 'can be created out of an existing DOM element', () => { const editableElement = document.createElement( 'div' ); - const testView = new DecoupledEditorUIView( locale, editableElement ); + const testView = new DecoupledEditorUIView( locale, editingView, editableElement ); + testView.editable.name = editingViewRoot.rootName; testView.render(); diff --git a/tests/manual/placeholder.html b/tests/manual/placeholder.html new file mode 100644 index 0000000..7edde17 --- /dev/null +++ b/tests/manual/placeholder.html @@ -0,0 +1,5 @@ +
+

Remove this text to see the placeholder.

+
+
+
diff --git a/tests/manual/placeholder.js b/tests/manual/placeholder.js new file mode 100644 index 0000000..7e160a9 --- /dev/null +++ b/tests/manual/placeholder.js @@ -0,0 +1,39 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* globals console:false, document, window */ + +import DecoupledEditor from '../../src/decouplededitor'; +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 ) { + DecoupledEditor + .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 ); + + element.parentNode.insertBefore( newEditor.ui.view.toolbar.element, element ); + + 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' ); diff --git a/tests/manual/placeholder.md b/tests/manual/placeholder.md new file mode 100644 index 0000000..74deb3a --- /dev/null +++ b/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.