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

Commit

Permalink
Added postfixers to view.
Browse files Browse the repository at this point in the history
  • Loading branch information
szymonkups committed Feb 22, 2018
1 parent 424dee8 commit a5b2a49
Show file tree
Hide file tree
Showing 5 changed files with 216 additions and 252 deletions.
67 changes: 67 additions & 0 deletions src/view/document.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ export default class Document {
* @member {Boolean} module:engine/view/document~Document#isFocused
*/
this.set( 'isFocused', false );

/**
* Post-fixer callbacks registered to the model document.
*
* @private
* @member {Set}
*/
this._postFixers = new Set();
}

/**
Expand All @@ -78,6 +86,65 @@ export default class Document {
getRoot( name = 'main' ) {
return this.roots.get( name );
}

/**
* TODO: update docs
* Used to register a post-fixer callback. A post-fixer mechanism guarantees that the features that listen to
* the {@link module:engine/model/model~Model#event:_change model's change event} will operate on a correct model state.
*
* An execution of a feature may lead to an incorrect document tree state. The callbacks are used to fix the document tree after
* it has changed. Post-fixers are fired just after all changes from the outermost change block were applied but
* before the {@link module:engine/model/document~Document#event:change change event} is fired. If a post-fixer callback made
* a change, it should return `true`. When this happens, all post-fixers are fired again to check if something else should
* not be fixed in the new document tree state.
*
* As a parameter, a post-fixer callback receives a {@link module:engine/model/writer~Writer writer} instance connected with the
* executed changes block. Thanks to that, all changes done by the callback will be added to the same
* {@link module:engine/model/batch~Batch batch} (and undo step) as the original changes. This makes post-fixer changes transparent
* for the user.
*
* An example of a post-fixer is a callback that checks if all the data were removed from the editor. If so, the
* callback should add an empty paragraph so that the editor is never empty:
*
* document.registerPostFixer( writer => {
* const changes = document.differ.getChanges();
*
* // Check if the changes lead to an empty root in the editor.
* for ( const entry of changes ) {
* if ( entry.type == 'remove' && entry.position.root.isEmpty ) {
* writer.insertElement( 'paragraph', entry.position.root, 0 );
*
* // It is fine to return early, even if multiple roots would need to be fixed.
* // All post-fixers will be fired again, so if there are more empty roots, those will be fixed, too.
* return true;
* }
* }
* } );
*
* @param {Function} postFixer
*/
registerPostFixer( postFixer ) {
this._postFixers.add( postFixer );
}

/**
* Performs post-fixer loops. Executes post-fixer callbacks as long as none of them has done any changes to the model.
*
* @protected
*/
_callPostFixers( writer ) {
let wasFixed = false;

do {
for ( const callback of this._postFixers ) {
wasFixed = callback( writer );

if ( wasFixed ) {
break;
}
}
} while ( wasFixed );
}
}

mix( Document, ObservableMixin );
Expand Down
78 changes: 38 additions & 40 deletions src/view/placeholder.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,8 @@
* @module engine/view/placeholder
*/

import extend from '@ckeditor/ckeditor5-utils/src/lib/lodash/extend';
import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
import '../../theme/placeholder.css';

const listener = {};
extend( listener, EmitterMixin );

// Each document stores information about its placeholder elements and check functions.
const documentPlaceholders = new WeakMap();

Expand All @@ -29,28 +24,19 @@ const documentPlaceholders = new WeakMap();
export function attachPlaceholder( view, element, placeholderText, checkFunction ) {
const document = view.document;

// Detach placeholder if was used before.
detachPlaceholder( view, element );

// Single listener per document.
if ( !documentPlaceholders.has( document ) ) {
documentPlaceholders.set( document, new Map() );

// Attach listener just before rendering and update placeholders.
listener.listenTo( view, 'render', () => updateAllPlaceholders( view ) );
// Create view post fixer that will add placeholder where needed.
document.registerPostFixer( writer => updateAllPlaceholders( document, writer ) );
}

// Store text in element's data attribute.
// This data attribute is used in CSS class to show the placeholder.
view.change( writer => {
writer.setAttribute( 'data-placeholder', placeholderText, element );
} );

// Store information about placeholder.
documentPlaceholders.get( document ).set( element, checkFunction );
// Store information about element with placeholder.
documentPlaceholders.get( document ).set( element, { placeholderText, checkFunction } );

// Update right away too.
updateSinglePlaceholder( view, element, checkFunction );
// Update view right away.
view.render();
}

/**
Expand All @@ -76,12 +62,17 @@ export function detachPlaceholder( view, element ) {
//
// @private
// @param {module:engine/view/view~View} view
function updateAllPlaceholders( view ) {
const placeholders = documentPlaceholders.get( view.document );
function updateAllPlaceholders( document, writer ) {
const placeholders = documentPlaceholders.get( document );
let changed = false;

for ( const [ element, checkFunction ] of placeholders ) {
updateSinglePlaceholder( view, element, checkFunction );
for ( const [ element, info ] of placeholders ) {
if ( updateSinglePlaceholder( writer, element, info ) ) {
changed = true;
}
}

return changed;
}

// Updates placeholder class of given element.
Expand All @@ -90,26 +81,34 @@ function updateAllPlaceholders( view ) {
// @param {module:engine/view/view~View} view
// @param {module:engine/view/element~Element} element
// @param {Function} checkFunction
function updateSinglePlaceholder( view, element, checkFunction ) {
function updateSinglePlaceholder( writer, element, info ) {
const document = element.document;
const text = info.placeholderText;
let changed = false;

// Element was removed from document.
if ( !document ) {
return;
return false;
}

// Update data attribute if needed.
if ( element.getAttribute( 'data-placeholder' ) !== text ) {
writer.setAttribute( 'data-placeholder', text, element );
changed = true;
}

const viewSelection = document.selection;
const anchor = viewSelection.anchor;
const checkFunction = info.checkFunction;

// If checkFunction is provided and returns false - remove placeholder.
if ( checkFunction && !checkFunction() ) {
if ( element.hasClass( 'ck-placeholder' ) ) {
view.change( writer => {
writer.removeClass( 'ck-placeholder', element );
} );
writer.removeClass( 'ck-placeholder', element );
changed = true;
}

return;
return changed;
}

// Element is empty for placeholder purposes when it has no children or only ui elements.
Expand All @@ -119,26 +118,25 @@ function updateSinglePlaceholder( view, element, checkFunction ) {
// If element is empty and editor is blurred.
if ( !document.isFocused && isEmptyish ) {
if ( !element.hasClass( 'ck-placeholder' ) ) {
view.change( writer => {
writer.addClass( 'ck-placeholder', element );
} );
writer.addClass( 'ck-placeholder', element );
changed = true;
}

return;
return changed;
}

// It there are no child elements and selection is not placed inside element.
if ( isEmptyish && anchor && anchor.parent !== element ) {
if ( !element.hasClass( 'ck-placeholder' ) ) {
view.change( writer => {
writer.addClass( 'ck-placeholder', element );
} );
writer.addClass( 'ck-placeholder', element );
changed = true;
}
} else {
if ( element.hasClass( 'ck-placeholder' ) ) {
view.change( writer => {
writer.removeClass( 'ck-placeholder', element );
} );
writer.removeClass( 'ck-placeholder', element );
changed = true;
}
}

return changed;
}
70 changes: 39 additions & 31 deletions src/view/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import mix from '@ckeditor/ckeditor5-utils/src/mix';
import { scrollViewportToShowTarget } from '@ckeditor/ckeditor5-utils/src/dom/scroll';
import { injectUiElementHandling } from './uielement';
import { injectQuirksHandling } from './filler';
import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';

/**
* Editor's view controller class. Its main responsibility is DOM - View management for editing purposes, to provide
Expand Down Expand Up @@ -107,8 +108,21 @@ export default class View {
*/
this._ongoingChange = false;

this._renderingEventProcessing = false;
this._callbacksWaiting = [];
/**
* Used to prevent calling {@link #render} and {@link #change) during rendering view to the DOM.
*
* @private
* @member {Boolean} module:engine/view/view~View#_renderingInProgress
*/
this._renderingInProgress = false;

/**
* Used to prevent calling {@link #render} and {@link #change) during rendering view to the DOM.
*
* @private
* @member {Boolean} module:engine/view/view~View#_renderingInProgress
*/
this._postFixersInProgress = false;

/**
* Writer instance used in {@link #change change method) callbacks.
Expand All @@ -129,10 +143,10 @@ export default class View {
injectQuirksHandling( this );
injectUiElementHandling( this );

// Use 'low` priority so that all listeners on 'normal` priority will be executed before.
// Use 'normal' priority so that rendering is performed as first when using that priority.
this.on( 'render', () => {
this._render();
}, { priority: 'low' } );
} );
}

/**
Expand Down Expand Up @@ -303,39 +317,30 @@ export default class View {
* @param {Function} callback Callback function which may modify the view.
*/
change( callback ) {
// When "render" event is processed all callbacks need to wait until processing of that event is complete.
// Those callbacks will be processed later and create separate "render" event.
if ( this._renderingEventProcessing ) {
this._callbacksWaiting.push( callback );
return;
if ( this._renderingInProgress || this._postFixersInProgress ) {
// TODO: better description
throw new CKEditorError( 'incorrect-view-change' );
}

// Recursive call to view.change() method - execute listener immediately.
if ( this._ongoingChange ) {
callback( this._writer );
} else {
// This lock will assure that all recursive calls to view.change() will end up in same block - one "render"
// event for all nested calls.
this._ongoingChange = true;
callback( this._writer );
this._ongoingChange = false;

// This lock will assure that all view.change() calls in listeners will wait until all callbacks are processed
// and will create separate "render" event.
this._renderingEventProcessing = true;
this.fire( 'render' );
this._renderingEventProcessing = false;

// Call waiting callbacks that were called during `render` event.
if ( this._callbacksWaiting.length ) {
const callbacks = this._callbacksWaiting;
this._callbacksWaiting = [];

while ( callbacks.length ) {
this.change( callbacks.shift() );
}
}

return;
}

// This lock will assure that all recursive calls to view.change() will end up in same block - one "render"
// event for all nested calls.
this._ongoingChange = true;
callback( this._writer );
this._ongoingChange = false;

// Execute all document post fixers after the change.
this._postFixersInProgress = true;
this.document._callPostFixers( this._writer );
this._postFixersInProgress = false;

this.fire( 'render' );
}

/**
Expand Down Expand Up @@ -367,12 +372,15 @@ export default class View {
* @private
*/
_render() {
this._renderingInProgress = true;
this.disableObservers();
this._renderer.render();
this.enableObservers();
this._renderingInProgress = false;
}

/**
* TODO: fix description
* Fired after a topmost {@link module:engine/view/view~View#change change block} is finished and the DOM rendering has
* been executed.
*
Expand Down
8 changes: 4 additions & 4 deletions tests/view/observer/focusobserver.js
Original file line number Diff line number Diff line change
Expand Up @@ -170,12 +170,12 @@ describe( 'FocusObserver', () => {
view.render();

viewDocument.on( 'selectionChange', selectionChangeSpy );
view.on( 'render', renderSpy, { priority: 'low' } );
view.on( 'render', renderSpy );

view.on( 'render', () => {
sinon.assert.callOrder( selectionChangeSpy, renderSpy );
done();
}, { priority: 'low' } );
} );

// Mock selectionchange event after focus event. Render called by focus observer should be fired after
// async selection change.
Expand All @@ -192,14 +192,14 @@ describe( 'FocusObserver', () => {
const domEditable = domRoot.childNodes[ 0 ];

viewDocument.on( 'selectionChange', selectionChangeSpy );
view.on( 'render', renderSpy, { priority: 'low' } );
view.on( 'render', renderSpy );

view.on( 'render', () => {
sinon.assert.notCalled( selectionChangeSpy );
sinon.assert.called( renderSpy );

done();
}, { priority: 'low' } );
} );

observer.onDomEvent( { type: 'focus', target: domEditable } );
} );
Expand Down
Loading

0 comments on commit a5b2a49

Please sign in to comment.