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

Disable inline filling character removal during composition #1355

Closed
wants to merge 23 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a460fb9
Bind 'isComposing' flag to renderer.
f1ames Mar 5, 2018
7078b0c
Tests: inline filler is not removed during composition.
f1ames Mar 12, 2018
b72117d
Tests: inline filler removal manual test.
f1ames Mar 12, 2018
097fdf2
Tests: method names update after rebase.
f1ames Mar 12, 2018
0af21d2
Tests: invalid use of 'headings' in editor config.
f1ames Mar 12, 2018
d07520a
Tests: fixed typos.
f1ames Mar 13, 2018
e70891d
Small test improvement.
Reinmar Mar 26, 2018
6ac359f
Tests: add two new unit tests and refactored existing ones.
f1ames Mar 30, 2018
77edc01
Docs adjustments.
f1ames Apr 3, 2018
d068959
Tests: refactoring.
f1ames Apr 9, 2018
4abf718
Do not remove inline filler during composition.
f1ames Apr 9, 2018
23fd7d8
Handle inline filler during composition.
f1ames Apr 10, 2018
c81e416
Merge pull request #1405 from ckeditor/t/1404
Apr 11, 2018
f627660
Tests: refactoring.
f1ames Apr 11, 2018
6266a4c
Merge branch 'master' into t/898
f1ames Apr 11, 2018
2c5122e
Docs adjustments.
f1ames Apr 11, 2018
5a6fec7
Tests: manual tests adjustments.
f1ames Apr 11, 2018
f0fbeb2
Comments adjustments.
f1ames Apr 11, 2018
583b958
Improved non-collapsed selection handling during composition.
f1ames May 8, 2018
bfeabff
Tests: more unit test for non-collapsed selection during composition …
f1ames May 8, 2018
e9b4c50
Merge branch 'master' into t/898
f1ames May 8, 2018
65e899f
Merge branch 'master' into t/898
Reinmar May 23, 2018
aa32fff
Moved comments out of if() blocks and other minor refactoring.
Reinmar May 23, 2018
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 132 additions & 34 deletions src/view/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,20 +90,29 @@ export default class Renderer {
this.selection = selection;

/**
* The text node in which the inline filler was rendered.
* Indicates whether the view document is focused and selection can be rendered. Selection will not be rendered if
* this is set to `false`.
*
* @private
* @member {Text}
* @readonly
* @member {Boolean}
*/
this._inlineFiller = null;
this.isFocused = false;

/**
* Indicates if the view document is focused and selection can be rendered. Selection will not be rendered if
* this is set to `false`.
* Indicates whether text composition takes place in the document.
*
* @readonly
* @member {Boolean}
*/
this.isFocused = false;
this.isComposing = false;

/**
* The text node in which the inline filler was rendered.
*
* @private
* @member {Text}
*/
this._inlineFiller = null;

/**
* DOM element containing fake selection.
Expand Down Expand Up @@ -180,23 +189,46 @@ export default class Renderer {
render() {
let inlineFillerPosition;

// There was inline filler rendered in the DOM but it's not
// at the selection position any more, so we can remove it
// (cause even if it's needed, it must be placed in another location).
if ( this._inlineFiller && !this._isSelectionInInlineFiller() ) {
this._removeInlineFiller();
}

// If we've got the filler, let's try to guess its position in the view.
if ( this._inlineFiller ) {
inlineFillerPosition = this._getInlineFillerPosition();
// There was inline filler rendered in the DOM but it's not at the selection position any more, so we can remove
// it if not during composition (cause even if it's needed, it must be placed in another location).
if ( !this.isComposing && !this._isSelectionInInlineFiller( false ) ) {
this._removeInlineFiller();
} else if ( this.isComposing ) {
// When selection has 0 ranges, `isCollapsed` returns false, but here
// we are only interested in non-collapsed selection (so with at least 1 range).
const isSelectionNotCollapsed = !this.selection.isCollapsed && this.selection.rangeCount > 0;

// During composition selection may be extended and its start/end moved to a different text
// node (using 'shift + up' in Chrome on Windows during composition). In such situations
// filler should not be moved or deleted (because it is possible to continue composing).
if ( isSelectionNotCollapsed ) {
inlineFillerPosition = this._getExistingInlineFillerPosition();
}
// Check for situations like:
//
// <p>text{}</p><p>FILLERtext</p> or <p>{}</p><p>FILLERtext</p>
//
// when collapsed selection was moved to a different node (without inline filler) during composition.
// It means `compositionend` event was not fired properly and inline filler should be removed.
else if ( !this._isValidCompositionSelection() ) {
this._removeInlineFiller();
}
}
}
// Otherwise, if it's needed, create it at the selection position.
else if ( this._needsInlineFillerAtSelection() ) {
inlineFillerPosition = this.selection.getFirstPosition();

// Do not use `markToSync` so it will be added even if the parent is already added.
this.markedChildren.add( inlineFillerPosition.parent );
if ( !inlineFillerPosition ) {
// If we've got the filler, let's try to guess its position in the view.
if ( this._inlineFiller ) {
inlineFillerPosition = this._getInlineFillerPosition();
}
// Otherwise, if it's needed, create it at the selection position.
else if ( this._needsInlineFillerAtSelection() ) {
inlineFillerPosition = this.selection.getFirstPosition();

// Do not use `markToSync` so it will be added even if the parent is already added.
this.markedChildren.add( inlineFillerPosition.parent );
}
}

for ( const element of this.markedAttributes ) {
Expand All @@ -219,20 +251,28 @@ export default class Renderer {
// Similarly, if it was removed at the beginning of this function and then neither text nor children were updated,
// it will not be present.
// Fix those and similar scenarios.
this._inlineFiller = null; // Reset filler first.

if ( inlineFillerPosition ) {
const fillerDomPosition = this.domConverter.viewPositionToDom( inlineFillerPosition );
const domDocument = fillerDomPosition.parent.ownerDocument;

if ( !startsWithFiller( fillerDomPosition.parent ) ) {
// Filler has not been created at filler position. Create it now.
this._inlineFiller = this._addInlineFiller( domDocument, fillerDomPosition.parent, fillerDomPosition.offset );
} else {
// Filler has been found, save it.
this._inlineFiller = fillerDomPosition.parent;
// `fillerDomPosition` may be null during composition in situations like:
//
// <p>Foo Bar<b>FILLER{}</b></p> -> 'Shift' + 'Arrow up' -> <p>{Foo Bar}</p>
//
// because `inlineFillerPosition` points to `AttributeElement` (`strong`) which was removed during rendering.
// It means inline filler is already removed from DOM so `this._inlineFiller` should be null.
if ( fillerDomPosition ) {
const domDocument = fillerDomPosition.parent.ownerDocument;

if ( !startsWithFiller( fillerDomPosition.parent ) ) {
// Filler has not been created at filler position. Create it now.
this._inlineFiller = this._addInlineFiller( domDocument, fillerDomPosition.parent, fillerDomPosition.offset );
} else {
// Filler has been found, save it.
this._inlineFiller = fillerDomPosition.parent;
}
}
} else {
// There is no filler needed.
this._inlineFiller = null;
}

this._updateSelection();
Expand Down Expand Up @@ -280,7 +320,7 @@ export default class Renderer {
* Gets the position of the inline filler based on the current selection.
* Here, we assume that we know that the filler is needed and
* {@link #_isSelectionInInlineFiller is at the selection position}, and, since it's needed,
* it's somewhere at the selection postion.
* it's somewhere at the selection position.
*
* Note: we cannot restore the filler position based on the filler's DOM text node, because
* when this method is called (before rendering) the bindings will often be broken. View to DOM
Expand All @@ -299,15 +339,37 @@ export default class Renderer {
}
}

/**
* Gets the position of the inline filler based on the filler's DOM text node.
*
* Note: Here, we assume that {@link #_inlineFiller inline filler} exists. In most cases
* {@link #_getInlineFillerPosition} should be used to get inline filler position which
* will be valid after rendering. This method is used only in special cases (for example
* when we know only selection changed so bindings will not get outdated).
*
* @private
* @returns {module:engine/view/position~Position}
*/
_getExistingInlineFillerPosition() {
let fillerPosition = this.domConverter.domPositionToView( this._inlineFiller, 0 );
if ( fillerPosition.parent.is( 'text' ) ) {
const textNode = fillerPosition.parent;
fillerPosition = new ViewPosition( textNode.parent, textNode.index );
}
return fillerPosition;
}

/**
* Returns `true` if the selection hasn't left the inline filler's text node.
* If it is `true` it means that the filler had been added for a reason and the selection does not
* left the filler's text node. E.g. the user can be in the middle of a composition so it should not be touched.
*
* @private
* @param {Boolean} [withText=true] Whether to check if selection is inside text node containing inline filler only
* or additional text is also allowed.
* @returns {Boolean} True if the inline filler and selection are in the same place.
*/
_isSelectionInInlineFiller() {
_isSelectionInInlineFiller( withText = true ) {
if ( this.selection.rangeCount != 1 || !this.selection.isCollapsed ) {
return false;
}
Expand All @@ -324,8 +386,44 @@ export default class Renderer {
const selectionPosition = this.selection.getFirstPosition();
const position = this.domConverter.viewPositionToDom( selectionPosition );

if ( position && isText( position.parent ) && startsWithFiller( position.parent ) ) {
if ( position && isText( position.parent ) ) {
return withText ? startsWithFiller( position.parent ) : isInlineFiller( position.parent );
}

return false;
}

/**
* Checks if the selection is in the same node in which current composition
* was started based on inline filler position.
*
* Note: we assume that inline filler exists and composition takes place.
*
* @private
* @returns {Boolean}
*/
_isValidCompositionSelection() {
if ( this.selection.rangeCount != 1 ) {
return false;
}

const position = this.domConverter.viewPositionToDom( this.selection.getFirstPosition() );

if ( !position ) {
// Position is not mapped properly when new inline element is inserted:
//
// <p>foo<b>{}</b></p> -> <p>foo<b><i>{}</i></b></p>
//
// or new character in the empty inline element is inserted:
//
// <p>foo<b>{}</b></p> -> <p>foo<b>a{}</b></p>
//
// so we assume it is valid position during composition.
return true;
} else if ( position && isText( position.parent ) ) {
// If during composition there is an inline filler present, but in another
// node it means the composition was started in that node and the selection is in different one.
return this._isSelectionInInlineFiller();
}

return false;
Expand Down
1 change: 1 addition & 0 deletions src/view/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export default class View {
*/
this._renderer = new Renderer( this.domConverter, this.document.selection );
this._renderer.bind( 'isFocused' ).to( this.document );
this._renderer.bind( 'isComposing' ).to( this.document );

/**
* Roots of the DOM tree. Map on the `HTMLElement`s with roots names as keys.
Expand Down
20 changes: 20 additions & 0 deletions tests/view/manual/inline-filler-removal.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<button id="composition-toggle" style="margin-bottom: 25px;">Toggle composition</button>

<div id="editor">
<h2>Heading 1</h2>
<p>Paragraph</p>
<p></p>
<p><strong>Bold</strong> <i>Italic</i> <a href="https://ckeditor.com">Link</a></p>
<ul>
<li>UL List item 1</li>
<li>UL List item 2</li>
</ul>
<blockquote>
<p>Quote</p>
<ul>
<li>Quoted UL List item 1</li>
<li>Quoted UL List item 2</li>
</ul>
<p>Quote</p>
</blockquote>
</div>
46 changes: 46 additions & 0 deletions tests/view/manual/inline-filler-removal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md.
*/

/* globals console, window, document */

import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';

import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';

ClassicEditor
.create( document.querySelector( '#editor' ), {
plugins: [ ArticlePluginSet ],
toolbar: [ 'bold', 'italic', 'link', 'bulletedList', 'blockQuote', 'undo', 'redo' ]
} )
.then( editor => {
window.editor = editor;

let isComposing = true;

// Prevent stealing the focus.
document.querySelector( '#composition-toggle' ).addEventListener( 'mousedown', evt => {
evt.preventDefault();
} );

document.querySelector( '#composition-toggle' ).addEventListener( 'click', evt => {
editor.editing.view._renderer.isComposing = isComposing;
console.log( `Composition is ${ isComposing ? 'on' : 'off' } (toggle button).` );
isComposing = !isComposing;

// Prevent stealing the focus.
evt.preventDefault();
} );

editor.editing.view.document.on( 'compositionstart', () => {
console.log( 'Composition is on (native event).' );
} );

editor.editing.view.document.on( 'compositionend', () => {
console.log( 'Composition is off (native event).' );
} );
} )
.catch( err => {
console.error( err.stack );
} );
13 changes: 13 additions & 0 deletions tests/view/manual/inline-filler-removal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Inline filler removal

Check whether inline filler is properly removed when typing inside empty inline elements:

* By default inline filler should be removed when anything is typed inside empty inline element.
* During composition, inline filler should not be removed until composition ends.

You may emulate composition by clicking **Toggle composition** or use e.g. Spanish or Japanese keyboard
(the composition state is logged in the browser dev console).

**Notice:** While emulating composition (or if you are able to create situation in which `compositionend` is not fired properly),
inline filler should behave as described above in the inline elements, but should not be inserted in other non-empty elements.
Also there should by always maximum one inline filler in the whole content.
Loading