Skip to content
This repository has been archived by the owner on Jun 26, 2020. It is now read-only.

Commit

Permalink
Merge pull request #25 from ckeditor/t/ckeditor5/479
Browse files Browse the repository at this point in the history
Feature: Enabled the decoupled editor placeholder (see ckeditor/ckeditor5#479).

BREAKING CHANGE: The second argument of `DecoupledEditorUIView.constructor()` is an editing view instance now.
  • Loading branch information
Piotr Jasiun committed Feb 7, 2019
2 parents 02a52e6 + 0728249 commit edd400f
Show file tree
Hide file tree
Showing 8 changed files with 260 additions and 43 deletions.
3 changes: 2 additions & 1 deletion src/decouplededitor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
}

/**
Expand Down
95 changes: 79 additions & 16 deletions src/decouplededitorui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -49,38 +50,100 @@ 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' );
}

/**
* @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
} );
}
}
}
5 changes: 3 additions & 2 deletions src/decouplededitoruiview.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 );

/**
Expand All @@ -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.
Expand Down
136 changes: 115 additions & 21 deletions tests/decouplededitorui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,7 +25,7 @@ describe( 'DecoupledEditorUI', () => {

beforeEach( () => {
return VirtualDecoupledTestEditor
.create( {
.create( '', {
toolbar: [ 'foo', 'bar' ],
} )
.then( newEditor => {
Expand Down Expand Up @@ -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 => {
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -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 );
Expand Down Expand Up @@ -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' ) );
Expand All @@ -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 )
Expand Down

0 comments on commit edd400f

Please sign in to comment.