diff --git a/packages/ckeditor5-engine/src/model/utils/selection-post-fixer.js b/packages/ckeditor5-engine/src/model/utils/selection-post-fixer.js index e868bdf2a0f..9c60f7b0471 100644 --- a/packages/ckeditor5-engine/src/model/utils/selection-post-fixer.js +++ b/packages/ckeditor5-engine/src/model/utils/selection-post-fixer.js @@ -82,7 +82,14 @@ function selectionPostFixer( writer, model ) { // Those ranges might overlap but will be corrected later. const correctedRange = tryFixingRange( modelRange, schema ); - if ( correctedRange ) { + // "Selection fixing" algorithms sometimes get lost. In consequence, it may happen + // that a new range is returned but, in fact, it has the same positions as the original + // range anyway. If this range is not discarded, a new selection will be set and that, + // for instance, would destroy the selection attributes. Let's make sure that the post-fixer + // actually worked first before setting a new selection. + // + // https://github.com/ckeditor/ckeditor5/issues/6693 + if ( correctedRange && !correctedRange.isEqual( modelRange ) ) { ranges.push( correctedRange ); wasFixed = true; } else { diff --git a/packages/ckeditor5-engine/tests/model/utils/selection-post-fixer.js b/packages/ckeditor5-engine/tests/model/utils/selection-post-fixer.js index 0b43ae4b81c..a7d87b40215 100644 --- a/packages/ckeditor5-engine/tests/model/utils/selection-post-fixer.js +++ b/packages/ckeditor5-engine/tests/model/utils/selection-post-fixer.js @@ -835,6 +835,35 @@ describe( 'Selection post-fixer', () => { ']' ); } ); + + it( 'should not reset the selection if the final range is the same as the initial one', () => { + setModelData( model, + '' + + '' + + '[]' + + '' + + '
' + ); + + // Setting a selection attribute will trigger the post-fixer. However, because this + // action does not affect ranges, the post-fixer should not set a new selection and, + // in consequence, should not clear the selection attribute (like it normally would when + // a new selection is set). + // https://github.com/ckeditor/ckeditor5/issues/6693 + model.change( writer => { + writer.setSelectionAttribute( 'foo', 'bar' ); + } ); + + assertEqualMarkup( getModelData( model ), + '' + + '' + + '[]' + + '' + + '
' + ); + + expect( model.document.selection.hasAttribute( 'foo' ) ).to.be.true; + } ); } ); describe( 'non-collapsed selection - image scenarios', () => { diff --git a/packages/ckeditor5-image/tests/image.js b/packages/ckeditor5-image/tests/image.js index 96003ace4f6..4fa252dee83 100644 --- a/packages/ckeditor5-image/tests/image.js +++ b/packages/ckeditor5-image/tests/image.js @@ -64,7 +64,6 @@ describe( 'Image', () => { expect( getViewData( view ) ).to.equal( '[
' + 'alt text' + @@ -82,7 +81,6 @@ describe( 'Image', () => { expect( getViewData( view ) ).to.equal( '[
' + '' + @@ -103,7 +101,6 @@ describe( 'Image', () => { expect( getViewData( view ) ).to.equal( '[
' + 'alt text' + @@ -111,7 +108,6 @@ describe( 'Image', () => { '
]' + '
' + 'alt text' + @@ -127,7 +123,6 @@ describe( 'Image', () => { expect( getViewData( view ) ).to.equal( '
' + 'alt text' + @@ -135,7 +130,6 @@ describe( 'Image', () => { '
' + '[
' + 'alt text' + diff --git a/packages/ckeditor5-paragraph/src/insertparagraphcommand.js b/packages/ckeditor5-paragraph/src/insertparagraphcommand.js index a7290da4c2d..688f962ec70 100644 --- a/packages/ckeditor5-paragraph/src/insertparagraphcommand.js +++ b/packages/ckeditor5-paragraph/src/insertparagraphcommand.js @@ -18,6 +18,10 @@ import Command from '@ckeditor/ckeditor5-core/src/command'; * position: editor.model.createPositionBefore( element ) * } ); * + * If a paragraph is disallowed in the context of the specific position, the command + * will attempt to split position ancestors to find a place where it is possible + * to insert a paragraph. + * * **Note**: This command moves the selection to the inserted paragraph. * * @extends module:core/command~Command @@ -33,15 +37,24 @@ export default class InsertParagraphCommand extends Command { */ execute( options ) { const model = this.editor.model; - - if ( !model.schema.checkChild( options.position, 'paragraph' ) ) { - return; - } + let position = options.position; model.change( writer => { const paragraph = writer.createElement( 'paragraph' ); - model.insertContent( paragraph, options.position ); + if ( !model.schema.checkChild( position.parent, paragraph ) ) { + const allowedParent = model.schema.findAllowedParent( position, paragraph ); + + // It could be there's no ancestor limit that would allow paragraph. + // In theory, "paragraph" could be disallowed even in the "$root". + if ( !allowedParent ) { + return; + } + + position = writer.split( position, allowedParent ).position; + } + + model.insertContent( paragraph, position ); writer.setSelection( paragraph, 'in' ); } ); diff --git a/packages/ckeditor5-paragraph/tests/insertparagraphcommand.js b/packages/ckeditor5-paragraph/tests/insertparagraphcommand.js index 290c0e2b029..0463082e964 100644 --- a/packages/ckeditor5-paragraph/tests/insertparagraphcommand.js +++ b/packages/ckeditor5-paragraph/tests/insertparagraphcommand.js @@ -23,7 +23,9 @@ describe( 'InsertParagraphCommand', () => { editor.commands.add( 'insertParagraph', command ); schema.register( 'paragraph', { inheritAllFrom: '$block' } ); schema.register( 'heading1', { inheritAllFrom: '$block', allowIn: 'headersOnly' } ); - schema.register( 'headersOnly', { inheritAllFrom: '$block' } ); + schema.register( 'allowP', { inheritAllFrom: '$block' } ); + schema.register( 'disallowP', { inheritAllFrom: '$block', allowIn: [ 'allowP' ] } ); + model.schema.extend( 'paragraph', { allowIn: [ 'allowP' ] } ); } ); } ); @@ -42,18 +44,38 @@ describe( 'InsertParagraphCommand', () => { expect( getData( model ) ).to.equal( '[]foo' ); } ); - it( 'should do nothing if the paragraph is not allowed at the provided position', () => { - setData( model, 'foo[]' ); + it( 'should split ancestors down to a limit where a paragraph is allowed', () => { + setData( model, 'foo' ); command.execute( { - position: model.createPositionBefore( root.getChild( 0 ).getChild( 0 ) ) + // fo[]o + position: model.createPositionAt( root.getChild( 0 ).getChild( 0 ), 2 ) } ); + expect( getData( model ) ).to.equal( + '' + + 'fo' + + '[]' + + 'o' + + '' + ); + } ); + + it( 'should do nothing if the paragraph is not allowed at the provided position', () => { + // Create a situation where "paragraph" is disallowed even in the "root". + schema.addChildCheck( ( context, childDefinition ) => { + if ( context.endsWith( '$root' ) && childDefinition.name == 'paragraph' ) { + return false; + } + } ); + + setData( model, 'foo[]' ); + command.execute( { - position: model.createPositionAfter( root.getChild( 0 ).getChild( 0 ) ) + position: model.createPositionBefore( root.getChild( 0 ) ) } ); - expect( getData( model ) ).to.equal( 'foo[]' ); + expect( getData( model ) ).to.equal( 'foo[]' ); } ); describe( 'interation with existing paragraphs in the content', () => { diff --git a/packages/ckeditor5-table/src/tablekeyboard.js b/packages/ckeditor5-table/src/tablekeyboard.js index 73f6a8e6b9d..6fdf2680228 100644 --- a/packages/ckeditor5-table/src/tablekeyboard.js +++ b/packages/ckeditor5-table/src/tablekeyboard.js @@ -13,7 +13,10 @@ import TableWalker from './tablewalker'; import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect'; import priorities from '@ckeditor/ckeditor5-utils/src/priorities'; -import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; +import { + isArrowKeyCode, + getLocalizedArrowKeyCodeDirection +} from '@ckeditor/ckeditor5-utils/src/keyboard'; import { getSelectedTableCells, getTableCellsContainingSelection } from './utils/selection'; import { findAncestor } from './utils/common'; @@ -50,11 +53,10 @@ export default class TableKeyboard extends Plugin { this.editor.keystrokes.set( 'Tab', this._getTabHandler( true ), { priority: 'low' } ); this.editor.keystrokes.set( 'Shift+Tab', this._getTabHandler( false ), { priority: 'low' } ); - // Note: This listener has the "high+1" priority because we would like to avoid collisions with other features - // (like Widgets), which take over the keydown events with the "high" priority. Table navigation takes precedence - // over Widgets in that matter (widget arrow handler stops propagation of event if object element was selected - // but getNearestSelectionRange didn't returned any range). - this.listenTo( viewDocument, 'keydown', ( ...args ) => this._onKeydown( ...args ), { priority: priorities.get( 'high' ) + 1 } ); + // Note: This listener has the "high-10" priority because it should allow the Widget plugin to handle the default + // behavior first ("high") but it should not be "prevent–defaulted" by the Widget plugin ("high-20") because of + // the fake selection retention on the fully selected widget. + this.listenTo( viewDocument, 'keydown', ( ...args ) => this._onKeydown( ...args ), { priority: priorities.get( 'high' ) - 10 } ); } /** @@ -163,13 +165,14 @@ export default class TableKeyboard extends Plugin { * @param {module:engine/view/observer/domeventdata~DomEventData} domEventData */ _onKeydown( eventInfo, domEventData ) { + const editor = this.editor; const keyCode = domEventData.keyCode; if ( !isArrowKeyCode( keyCode ) ) { return; } - const direction = getDirectionFromKeyCode( keyCode, this.editor.locale.contentLanguageDirection ); + const direction = getLocalizedArrowKeyCodeDirection( keyCode, editor.locale.contentLanguageDirection ); const wasHandled = this._handleArrowKeys( direction, domEventData.shiftKey ); if ( wasHandled ) { @@ -226,19 +229,6 @@ export default class TableKeyboard extends Plugin { return true; } - // If this is an object selected and it's not at the start or the end of cell content - // then let's allow widget handler to take care of it. - const objectElement = selection.getSelectedElement(); - - if ( objectElement && model.schema.isObject( objectElement ) ) { - return false; - } - - // If next to the selection there is an object then this is not the cell boundary (widget handler should handle this). - if ( this._isObjectElementNextToSelection( selection, isForward ) ) { - return false; - } - // If there isn't any $text position between cell edge and selection then we shall move the selection to next cell. const textRange = this._findTextRangeFromSelection( cellRange, selection, isForward ); @@ -303,27 +293,6 @@ export default class TableKeyboard extends Plugin { return focus.isEqual( probe.focus ); } - /** - * Checks if there is an {@link module:engine/model/element~Element element} next to the current - * {@link module:engine/model/selection~Selection model selection} marked in the - * {@link module:engine/model/schema~Schema schema} as an `object`. - * - * @private - * @param {module:engine/model/selection~Selection} modelSelection The selection. - * @param {Boolean} isForward The direction of checking. - * @returns {Boolean} - */ - _isObjectElementNextToSelection( modelSelection, isForward ) { - const model = this.editor.model; - const schema = model.schema; - - const probe = model.createSelection( modelSelection ); - model.modifySelection( probe, { direction: isForward ? 'forward' : 'backward' } ); - const objectElement = isForward ? probe.focus.nodeBefore : probe.focus.nodeAfter; - - return objectElement && schema.isObject( objectElement ); - } - /** * Truncates the range so that it spans from the last selection position * to the last allowed `$text` position (mirrored if `isForward` is false). @@ -515,38 +484,3 @@ export default class TableKeyboard extends Plugin { } } -// Returns `true` if the provided key code represents one of the arrow keys. -// -// @private -// @param {Number} keyCode -// @returns {Boolean} -function isArrowKeyCode( keyCode ) { - return keyCode == keyCodes.arrowright || - keyCode == keyCodes.arrowleft || - keyCode == keyCodes.arrowup || - keyCode == keyCodes.arrowdown; -} - -// Returns the direction name from `keyCode`. -// -// @private -// @param {Number} keyCode -// @param {String} contentLanguageDirection The content language direction. -// @returns {'left'|'up'|'right'|'down'} Arrow direction. -function getDirectionFromKeyCode( keyCode, contentLanguageDirection ) { - const isLtrContent = contentLanguageDirection === 'ltr'; - - switch ( keyCode ) { - case keyCodes.arrowleft: - return isLtrContent ? 'left' : 'right'; - - case keyCodes.arrowright: - return isLtrContent ? 'right' : 'left'; - - case keyCodes.arrowup: - return 'up'; - - case keyCodes.arrowdown: - return 'down'; - } -} diff --git a/packages/ckeditor5-table/tests/converters/upcasttable.js b/packages/ckeditor5-table/tests/converters/upcasttable.js index 5a85b4ab86b..4d160ef8f4c 100644 --- a/packages/ckeditor5-table/tests/converters/upcasttable.js +++ b/packages/ckeditor5-table/tests/converters/upcasttable.js @@ -3,7 +3,7 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; import { getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import ImageEditing from '@ckeditor/ckeditor5-image/src/image/imageediting'; @@ -17,8 +17,8 @@ describe( 'upcastTable()', () => { let editor, model; beforeEach( () => { - return VirtualTestEditor - .create( { + return ClassicTestEditor + .create( '', { plugins: [ TableEditing, Paragraph, ImageEditing, Widget ] } ) .then( newEditor => { @@ -31,6 +31,10 @@ describe( 'upcastTable()', () => { } ); } ); + afterEach( () => { + editor.destroy(); + } ); + function expectModel( data ) { assertEqualMarkup( getModelData( model, { withoutSelection: true } ), data ); } diff --git a/packages/ckeditor5-table/tests/table-integration.js b/packages/ckeditor5-table/tests/table-integration.js index d00f01d0564..d74bd7fd1cb 100644 --- a/packages/ckeditor5-table/tests/table-integration.js +++ b/packages/ckeditor5-table/tests/table-integration.js @@ -11,7 +11,7 @@ import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting'; import ListEditing from '@ckeditor/ckeditor5-list/src/listediting'; import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting'; import Typing from '@ckeditor/ckeditor5-typing/src/typing'; -import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; import { getData as getModelData, setData as setModelData @@ -27,14 +27,18 @@ describe( 'Table feature – integration', () => { let editor, clipboard; beforeEach( () => { - return VirtualTestEditor - .create( { plugins: [ Paragraph, TableEditing, ListEditing, BlockQuoteEditing, Widget, Clipboard ] } ) + return ClassicTestEditor + .create( '', { plugins: [ Paragraph, TableEditing, ListEditing, BlockQuoteEditing, Widget, Clipboard ] } ) .then( newEditor => { editor = newEditor; clipboard = editor.plugins.get( 'Clipboard' ); } ); } ); + afterEach( () => { + editor.destroy(); + } ); + it( 'pastes td as p when pasting into the table', () => { setModelData( editor.model, modelTable( [ [ 'foo[]' ] ] ) ); @@ -86,8 +90,8 @@ describe( 'Table feature – integration', () => { let editor, doc, root; beforeEach( () => { - return VirtualTestEditor - .create( { plugins: [ Paragraph, TableEditing, Widget, UndoEditing ] } ) + return ClassicTestEditor + .create( '', { plugins: [ Paragraph, TableEditing, Widget, UndoEditing ] } ) .then( newEditor => { editor = newEditor; doc = editor.model.document; @@ -95,6 +99,10 @@ describe( 'Table feature – integration', () => { } ); } ); + afterEach( () => { + editor.destroy(); + } ); + it( 'fixing empty roots should be transparent to undo', () => { expect( editor.getData( { trim: 'none' } ) ).to.equal( '

 

' ); expect( editor.commands.get( 'undo' ).isEnabled ).to.be.false; @@ -155,13 +163,17 @@ describe( 'Table feature – integration', () => { let editor; beforeEach( () => { - return VirtualTestEditor - .create( { plugins: [ Paragraph, TableEditing, ListEditing, BlockQuoteEditing, Widget, Typing ] } ) + return ClassicTestEditor + .create( '', { plugins: [ Paragraph, TableEditing, ListEditing, BlockQuoteEditing, Widget, Typing ] } ) .then( newEditor => { editor = newEditor; } ); } ); + afterEach( () => { + editor.destroy(); + } ); + it( 'merges elements without throwing errors', () => { setModelData( editor.model, modelTable( [ [ '
Foo
[]Bar' ] diff --git a/packages/ckeditor5-table/tests/tablekeyboard.js b/packages/ckeditor5-table/tests/tablekeyboard.js index fd3f35c741e..d718e6c7cf9 100644 --- a/packages/ckeditor5-table/tests/tablekeyboard.js +++ b/packages/ckeditor5-table/tests/tablekeyboard.js @@ -2769,6 +2769,9 @@ describe( 'TableKeyboard', () => { [ '20', '21', '22' ] ] ) ); + // Note: Two keydowns are necessary because the first one is handled by the WidgetTypeAround plugin + // to activate the "fake caret". + editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub ); editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub ); assertEqualMarkup( getModelData( model ), modelTable( [ diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-widget/widgettypearound.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-widget/widgettypearound.css index 2a18270b8f0..5b19ef07d59 100644 --- a/packages/ckeditor5-theme-lark/theme/ckeditor5-widget/widgettypearound.css +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-widget/widgettypearound.css @@ -13,6 +13,16 @@ --ck-color-widget-type-around-button-icon: var(--ck-color-base-background); } +@define-mixin ck-widget-type-around-button-visible { + opacity: 1; + pointer-events: auto; +} + +@define-mixin ck-widget-type-around-button-hidden { + opacity: 0; + pointer-events: none; +} + .ck .ck-widget { /* * Styles of the type around buttons @@ -22,11 +32,10 @@ height: var(--ck-widget-type-around-button-size); background: var(--ck-color-widget-type-around-button); border-radius: 100px; - - pointer-events: none; - opacity: 0; transition: opacity var(--ck-widget-handler-animation-duration) var(--ck-widget-handler-animation-curve), background var(--ck-widget-handler-animation-duration) var(--ck-widget-handler-animation-curve); + @mixin ck-widget-type-around-button-hidden; + & svg { width: 10px; height: 8px; @@ -77,8 +86,7 @@ &.ck-widget_selected, &:hover { & > .ck-widget__type-around > .ck-widget__type-around__button { - pointer-events: auto; - opacity: 1; + @mixin ck-widget-type-around-button-visible; } } @@ -114,6 +122,75 @@ &.ck-widget_with-selection-handle > .ck-widget__type-around > .ck-widget__type-around__button_before { margin-left: 20px; } + + /* + * Styles for the horizontal "fake caret" which is displayed when the user navigates using the keyboard. + */ + & .ck-widget__type-around__fake-caret { + pointer-events: none; + height: 1px; + animation: ck-widget-type-around-fake-caret-pulse linear 1s infinite normal forwards; + + /* + * The semit-transparent-outline+background combo improves the contrast + * when the background underneath the fake caret is dark. + */ + outline: solid 1px hsla(0, 0%, 100%, .5); + background: var(--ck-color-base-text); + } + + /* + * Styles of the widget when the "fake caret" is blinking (e.g. upon keyboard navigation). + * Despite the widget being physically selected in the model, its outline should disappear. + */ + &.ck-widget_selected { + &.ck-widget_type-around_show-fake-caret_before, + &.ck-widget_type-around_show-fake-caret_after { + outline-color: transparent; + } + } + + &.ck-widget_type-around_show-fake-caret_before, + &.ck-widget_type-around_show-fake-caret_after { + /* + * When the "fake caret" is visible we simulate that the widget is not selected + * (despite being physically selected), so the outline color should be for the + * unselected widget. + */ + &.ck-widget_selected:hover { + outline-color: var(--ck-color-widget-hover-border); + } + + /* + * Styles of the type around buttons when the "fake caret" is blinking (e.g. upon keyboard navigation). + * In this state, the type around buttons would collide with the fake carets so they should disappear. + */ + & > .ck-widget__type-around > .ck-widget__type-around__button { + @mixin ck-widget-type-around-button-hidden; + } + + /* + * Fake horizontal caret integration with the selection handle. When the caret is visible, simply + * hide the handle because it intersects with the caret (and does not make much sense anyway). + */ + &.ck-widget_with-selection-handle { + &.ck-widget_selected, + &.ck-widget_selected:hover { + & > .ck-widget__selection-handle { + opacity: 0 + } + } + } + + /* + * Fake horizontal caret integration with the resize UI. When the caret is visible, simply + * hide the resize UI because it creates too much noise. It can be visible when the user + * hovers the widget, though. + */ + &.ck-widget_selected.ck-widget_with-resizer > .ck-widget__resizer { + opacity: 0 + } + } } /* @@ -127,8 +204,7 @@ &.ck-widget_selected, &:hover { & > .ck-widget__type-around > .ck-widget__type-around__button { - pointer-events: none; - opacity: 0; + @mixin ck-widget-type-around-button-hidden; } } } @@ -174,3 +250,21 @@ box-shadow: 0 0 0 5px hsla(var(--ck-color-focus-border-coordinates), var(--ck-color-widget-type-around-button-radar-start-alpha)); } } + +@keyframes ck-widget-type-around-fake-caret-pulse { + 0% { + opacity: 1; + } + 49% { + opacity: 1; + } + 50% { + opacity: 0; + } + 99% { + opacity: 0; + } + 100% { + opacity: 1; + } +} diff --git a/packages/ckeditor5-typing/src/utils/injectunsafekeystrokeshandling.js b/packages/ckeditor5-typing/src/utils/injectunsafekeystrokeshandling.js index 7b5a33a9380..718fac76cdc 100644 --- a/packages/ckeditor5-typing/src/utils/injectunsafekeystrokeshandling.js +++ b/packages/ckeditor5-typing/src/utils/injectunsafekeystrokeshandling.js @@ -65,7 +65,7 @@ export default function injectUnsafeKeystrokesHandling( editor ) { return; } - if ( isSafeKeystroke( evtData ) || doc.selection.isCollapsed ) { + if ( isNonTypingKeystroke( evtData ) || doc.selection.isCollapsed ) { return; } @@ -154,14 +154,20 @@ for ( let code = 112; code <= 135; code++ ) { safeKeycodes.push( code ); } -// Returns `true` if a keystroke should not cause any content change caused by "typing". -// -// Note: This implementation is very simple and will need to be refined with time. -// -// @private -// @param {engine.view.observer.keyObserver.KeyEventData} keyData -// @returns {Boolean} -function isSafeKeystroke( keyData ) { +/** + * Returns `true` if a keystroke will **not** result in "typing". + * + * For instance, keystrokes that result in typing are letters "a-zA-Z", numbers "0-9", delete, backspace, etc. + * + * Keystrokes that do not cause typing are, for instance, Fn keys (F5, F8, etc.), arrow keys (←, →, ↑, ↓), + * Tab (↹), "Windows logo key" (⊞ Win), etc. + * + * Note: This implementation is very simple and will need to be refined with time. + * + * @param {module:engine/view/observer/keyobserver~KeyEventData} keyData + * @returns {Boolean} + */ +export function isNonTypingKeystroke( keyData ) { // Keystrokes which contain Ctrl don't represent typing. if ( keyData.ctrlKey ) { return true; diff --git a/packages/ckeditor5-typing/tests/utils/injectunsafekeystrokeshandling.js b/packages/ckeditor5-typing/tests/utils/injectunsafekeystrokeshandling.js new file mode 100644 index 00000000000..a283a830a31 --- /dev/null +++ b/packages/ckeditor5-typing/tests/utils/injectunsafekeystrokeshandling.js @@ -0,0 +1,85 @@ +/** + * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import { + keyCodes +} from '@ckeditor/ckeditor5-utils/src/keyboard'; +import { isNonTypingKeystroke } from '../../src/utils/injectunsafekeystrokeshandling'; + +describe( 'unsafe keystroke handling utils', () => { + describe( 'isNonTypingKeystroke()', () => { + it( 'should return "true" for any keystroke with the Ctrl key', () => { + expect( isNonTypingKeystroke( { keyCode: keyCodes.a, ctrlKey: true } ), 'Ctrl+a' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: keyCodes[ 0 ], ctrlKey: true } ), 'Ctrl+0' ).to.be.true; + } ); + + it( 'should return "true" for all arrow keys', () => { + expect( isNonTypingKeystroke( { keyCode: keyCodes.arrowup } ), 'arrow up' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: keyCodes.arrowdown } ), 'arrow down' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: keyCodes.arrowleft } ), 'arrow left' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: keyCodes.arrowright } ), 'arrow right' ).to.be.true; + } ); + + it( 'should return "true" for function (Fn) keystrokes', () => { + expect( isNonTypingKeystroke( { keyCode: keyCodes.f1 } ), 'F1' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: keyCodes.f2 } ), 'F2' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: keyCodes.f3 } ), 'F3' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: keyCodes.f4 } ), 'F4' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: keyCodes.f5 } ), 'F5' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: keyCodes.f6 } ), 'F6' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: keyCodes.f7 } ), 'F7' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: keyCodes.f8 } ), 'F8' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: keyCodes.f9 } ), 'F9' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: keyCodes.f10 } ), 'F10' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: keyCodes.f11 } ), 'F11' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: keyCodes.f12 } ), 'F12' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: 124 } ), 'F13' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: 125 } ), 'F14' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: 126 } ), 'F15' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: 127 } ), 'F16' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: 128 } ), 'F17' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: 129 } ), 'F18' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: 130 } ), 'F19' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: 131 } ), 'F20' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: 132 } ), 'F21' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: 133 } ), 'F22' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: 134 } ), 'F23' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: 135 } ), 'F24' ).to.be.true; + } ); + + it( 'should return "true" for other safe keystrokes', () => { + expect( isNonTypingKeystroke( { keyCode: keyCodes.tab } ), 'Tab' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: 16 } ), 'Shift' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: 17 } ), 'Ctrl' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: 18 } ), 'Alt' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: 19 } ), 'Pause' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: 20 } ), 'CapsLock' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: keyCodes.esc } ), 'Escape' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: 33 } ), 'PageUp' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: 34 } ), 'PageDown' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: 35 } ), 'Home' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: 36 } ), 'End' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: 45 } ), 'Insert' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: 91 } ), 'Windows' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: 93 } ), 'Menu key' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: 144 } ), 'NumLock' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: 145 } ), 'ScrollLock' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: 173 } ), 'Mute/Unmute' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: 174 } ), 'Volume up' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: 175 } ), 'Volume down' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: 176 } ), 'Next song' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: 177 } ), 'Previous song' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: 178 } ), 'Stop' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: 179 } ), 'Play/Pause' ).to.be.true; + expect( isNonTypingKeystroke( { keyCode: 255 } ), 'Display brightness (increase and decrease)' ).to.be.true; + } ); + + it( 'should return "false" for the keystrokes that result in typing', () => { + expect( isNonTypingKeystroke( { keyCode: keyCodes.a } ), 'a' ).to.be.false; + expect( isNonTypingKeystroke( { keyCode: keyCodes[ 0 ] } ), '0' ).to.be.false; + expect( isNonTypingKeystroke( { keyCode: keyCodes.a, altKey: true } ), 'Alt+a' ).to.be.false; + } ); + } ); +} ); diff --git a/packages/ckeditor5-utils/src/keyboard.js b/packages/ckeditor5-utils/src/keyboard.js index 61cb393f469..b7a64f138f1 100644 --- a/packages/ckeditor5-utils/src/keyboard.js +++ b/packages/ckeditor5-utils/src/keyboard.js @@ -129,6 +129,67 @@ export function getEnvKeystrokeText( keystroke ) { } ); } +/** + * Returns `true` if the provided key code represents one of the arrow keys. + * + * @param {Number} keyCode A key code as in {@link module:utils/keyboard~KeystrokeInfo#keyCode}. + * @returns {Boolean} + */ +export function isArrowKeyCode( keyCode ) { + return keyCode == keyCodes.arrowright || + keyCode == keyCodes.arrowleft || + keyCode == keyCodes.arrowup || + keyCode == keyCodes.arrowdown; +} + +/** + * Returns the direction in which the {@link module:engine/model/documentselection~DocumentSelection selection} + * will move when a provided arrow key code is pressed considering the language direction of the editor content. + * + * For instance, in right–to–left (RTL) content languages, pressing the left arrow means moving selection right (forward) + * in the model structure. Similarly, pressing the right arrow moves the selection left (backward). + * + * @param {Number} keyCode A key code as in {@link module:utils/keyboard~KeystrokeInfo#keyCode}. + * @param {'ltr'|'rtl'} contentLanguageDirection The content language direction, corresponding to + * {@link module:utils/locale~Locale#contentLanguageDirection}. + * @returns {'left'|'up'|'right'|'down'} Localized arrow direction. + */ +export function getLocalizedArrowKeyCodeDirection( keyCode, contentLanguageDirection ) { + const isLtrContent = contentLanguageDirection === 'ltr'; + + switch ( keyCode ) { + case keyCodes.arrowleft: + return isLtrContent ? 'left' : 'right'; + + case keyCodes.arrowright: + return isLtrContent ? 'right' : 'left'; + + case keyCodes.arrowup: + return 'up'; + + case keyCodes.arrowdown: + return 'down'; + } +} + +/** + * Determines if the provided key code moves the {@link module:engine/model/documentselection~DocumentSelection selection} + * forward or backward considering the language direction of the editor content. + * + * For instance, in right–to–left (RTL) languages, pressing the left arrow means moving forward + * in the model structure. Similarly, pressing the right arrow moves the selection backward. + * + * @param {Number} keyCode A key code as in {@link module:utils/keyboard~KeystrokeInfo#keyCode}. + * @param {'ltr'|'rtl'} contentLanguageDirection The content language direction, corresponding to + * {@link module:utils/locale~Locale#contentLanguageDirection}. + * @returns {Boolean} + */ +export function isForwardArrowKeyCode( keyCode, contentLanguageDirection ) { + const localizedKeyCodeDirection = getLocalizedArrowKeyCodeDirection( keyCode, contentLanguageDirection ); + + return localizedKeyCodeDirection === 'down' || localizedKeyCodeDirection === 'right'; +} + function generateKnownKeyCodes() { const keyCodes = { arrowleft: 37, diff --git a/packages/ckeditor5-utils/tests/keyboard.js b/packages/ckeditor5-utils/tests/keyboard.js index 6136fb30cdd..d0fda51eda9 100644 --- a/packages/ckeditor5-utils/tests/keyboard.js +++ b/packages/ckeditor5-utils/tests/keyboard.js @@ -4,7 +4,15 @@ */ import env from '../src/env'; -import { keyCodes, getCode, parseKeystroke, getEnvKeystrokeText } from '../src/keyboard'; +import { + keyCodes, + getCode, + parseKeystroke, + getEnvKeystrokeText, + isArrowKeyCode, + isForwardArrowKeyCode, + getLocalizedArrowKeyCodeDirection +} from '../src/keyboard'; import { expectToThrowCKEditorError } from './_utils/utils'; describe( 'Keyboard', () => { @@ -160,4 +168,103 @@ describe( 'Keyboard', () => { } ); } ); } ); + + describe( 'isArrowKeyCode()', () => { + it( 'should return "true" for right arrow', () => { + expect( isArrowKeyCode( keyCodes.arrowright ) ).to.be.true; + } ); + + it( 'should return "true" for left arrow', () => { + expect( isArrowKeyCode( keyCodes.arrowleft ) ).to.be.true; + } ); + + it( 'should return "true" for up arrow', () => { + expect( isArrowKeyCode( keyCodes.arrowup ) ).to.be.true; + } ); + + it( 'should return "true" for down arrow', () => { + expect( isArrowKeyCode( keyCodes.arrowdown ) ).to.be.true; + } ); + + it( 'should return "false" for non-arrow keystrokes', () => { + expect( isArrowKeyCode( keyCodes.a ) ).to.be.false; + expect( isArrowKeyCode( keyCodes.ctrl ) ).to.be.false; + } ); + } ); + + describe( 'getLocalizedArrowKeyCodeDirection()', () => { + describe( 'for a left–to–right content language direction', () => { + it( 'should return "left" for left arrow', () => { + expect( getLocalizedArrowKeyCodeDirection( keyCodes.arrowleft, 'ltr' ) ).to.equal( 'left' ); + } ); + + it( 'should return "right" for right arrow', () => { + expect( getLocalizedArrowKeyCodeDirection( keyCodes.arrowright, 'ltr' ) ).to.equal( 'right' ); + } ); + + it( 'should return "up" for up arrow', () => { + expect( getLocalizedArrowKeyCodeDirection( keyCodes.arrowup, 'ltr' ) ).to.equal( 'up' ); + } ); + + it( 'should return "down" for down arrow', () => { + expect( getLocalizedArrowKeyCodeDirection( keyCodes.arrowdown, 'ltr' ) ).to.equal( 'down' ); + } ); + } ); + + describe( 'for a right-to-left content language direction', () => { + it( 'should return "right" for left arrow', () => { + expect( getLocalizedArrowKeyCodeDirection( keyCodes.arrowleft, 'rtl' ) ).to.equal( 'right' ); + } ); + + it( 'should return "left" for right arrow', () => { + expect( getLocalizedArrowKeyCodeDirection( keyCodes.arrowright, 'rtl' ) ).to.equal( 'left' ); + } ); + + it( 'should return "up" for up arrow', () => { + expect( getLocalizedArrowKeyCodeDirection( keyCodes.arrowup, 'rtl' ) ).to.equal( 'up' ); + } ); + + it( 'should return "down" for down arrow', () => { + expect( getLocalizedArrowKeyCodeDirection( keyCodes.arrowdown, 'rtl' ) ).to.equal( 'down' ); + } ); + } ); + } ); + + describe( 'isForwardArrowKeyCode()', () => { + describe( 'for a left–to–right content language direction', () => { + it( 'should return "true" for down arrow', () => { + expect( isForwardArrowKeyCode( keyCodes.arrowdown, 'ltr' ) ).to.be.true; + } ); + + it( 'should return "true" for right arrow', () => { + expect( isForwardArrowKeyCode( keyCodes.arrowright, 'ltr' ) ).to.be.true; + } ); + + it( 'should return "false" for up arrow', () => { + expect( isForwardArrowKeyCode( keyCodes.arrowup, 'ltr' ) ).to.be.false; + } ); + + it( 'should return "false" for left arrow', () => { + expect( isForwardArrowKeyCode( keyCodes.arrowleft, 'ltr' ) ).to.be.false; + } ); + } ); + + describe( 'for a right-to-left content language direction', () => { + it( 'should return "true" for down arrow', () => { + expect( isForwardArrowKeyCode( keyCodes.arrowdown, 'rtl' ) ).to.be.true; + } ); + + it( 'should return "true" for left arrow', () => { + expect( isForwardArrowKeyCode( keyCodes.arrowleft, 'rtl' ) ).to.be.true; + } ); + + it( 'should return "false" for up arrow', () => { + expect( isForwardArrowKeyCode( keyCodes.arrowup, 'rtl' ) ).to.be.false; + } ); + + it( 'should return "false" for right arrow', () => { + expect( isForwardArrowKeyCode( keyCodes.arrowright, 'rtl' ) ).to.be.false; + } ); + } ); + } ); } ); diff --git a/packages/ckeditor5-widget/package.json b/packages/ckeditor5-widget/package.json index 9e36d380cb1..f0a5e3603d9 100644 --- a/packages/ckeditor5-widget/package.json +++ b/packages/ckeditor5-widget/package.json @@ -11,6 +11,7 @@ "dependencies": { "@ckeditor/ckeditor5-core": "^19.0.1", "@ckeditor/ckeditor5-engine": "^19.0.1", + "@ckeditor/ckeditor5-typing": "^19.0.1", "@ckeditor/ckeditor5-ui": "^19.0.1", "@ckeditor/ckeditor5-utils": "^19.0.1", "lodash-es": "^4.17.15" @@ -25,6 +26,7 @@ "@ckeditor/ckeditor5-essentials": "^19.0.1", "@ckeditor/ckeditor5-heading": "^19.0.1", "@ckeditor/ckeditor5-horizontal-line": "^19.0.1", + "@ckeditor/ckeditor5-image": "^19.0.1", "@ckeditor/ckeditor5-media-embed": "^19.1.0", "@ckeditor/ckeditor5-paragraph": "^19.1.0", "@ckeditor/ckeditor5-table": "^19.1.0", diff --git a/packages/ckeditor5-widget/src/widget.js b/packages/ckeditor5-widget/src/widget.js index bdbfd6850d7..da035fefd04 100644 --- a/packages/ckeditor5-widget/src/widget.js +++ b/packages/ckeditor5-widget/src/widget.js @@ -11,10 +11,14 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import MouseObserver from '@ckeditor/ckeditor5-engine/src/view/observer/mouseobserver'; import WidgetTypeAround from './widgettypearound/widgettypearound'; import { getLabel, isWidget, WIDGET_SELECTED_CLASS_NAME } from './utils'; -import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; +import { + isArrowKeyCode, + isForwardArrowKeyCode +} from '@ckeditor/ckeditor5-utils/src/keyboard'; import env from '@ckeditor/ckeditor5-utils/src/env'; import '../theme/widget.css'; +import priorities from '@ckeditor/ckeditor5-utils/src/priorities'; /** * The widget plugin. It enables base support for widgets. @@ -96,8 +100,24 @@ export default class Widget extends Plugin { view.addObserver( MouseObserver ); this.listenTo( viewDocument, 'mousedown', ( ...args ) => this._onMousedown( ...args ) ); - // Handle custom keydown behaviour. - this.listenTo( viewDocument, 'keydown', ( ...args ) => this._onKeydown( ...args ), { priority: 'high' } ); + // There are two keydown listeners working on different priorities. This allows other + // features such as WidgetTypeAround or TableKeyboard to attach their listeners in between + // and customize the behavior even further in different content/selection scenarios. + // + // * The first listener handles changing the selection on arrow key press + // if the widget is selected or if the selection is next to a widget and the widget + // should become selected upon the arrow key press. + // + // * The second (late) listener makes sure the default browser action on arrow key press is + // prevented when a widget is selected. This prevents the selection from being moved + // from a fake selection container. + this.listenTo( viewDocument, 'keydown', ( ...args ) => { + this._handleSelectionChangeOnArrowKeyPress( ...args ); + }, { priority: 'high' } ); + + this.listenTo( viewDocument, 'keydown', ( ...args ) => { + this._preventDefaultOnArrowKeyPress( ...args ); + }, { priority: priorities.get( 'high' ) - 20 } ); // Handle custom delete behaviour. this.listenTo( viewDocument, 'delete', ( evt, data ) => { @@ -162,27 +182,92 @@ export default class Widget extends Plugin { } /** - * Handles {@link module:engine/view/document~Document#event:keydown keydown} events. + * Handles {@link module:engine/view/document~Document#event:keydown keydown} events and changes + * the model selection when: + * + * * arrow key is pressed when the widget is selected, + * * the selection is next to a widget and the widget should become selected upon the arrow key press. + * + * See {@link #_preventDefaultOnArrowKeyPress}. + * + * @private + * @param {module:utils/eventinfo~EventInfo} eventInfo + * @param {module:engine/view/observer/domeventdata~DomEventData} domEventData + */ + _handleSelectionChangeOnArrowKeyPress( eventInfo, domEventData ) { + const keyCode = domEventData.keyCode; + + // Checks if the keys were handled and then prevents the default event behaviour and stops + // the propagation. + if ( !isArrowKeyCode( keyCode ) ) { + return; + } + + const model = this.editor.model; + const schema = model.schema; + const modelSelection = model.document.selection; + const objectElement = modelSelection.getSelectedElement(); + const isForward = isForwardArrowKeyCode( keyCode, this.editor.locale.contentLanguageDirection ); + + // If object element is selected. + if ( objectElement && schema.isObject( objectElement ) ) { + const position = isForward ? modelSelection.getLastPosition() : modelSelection.getFirstPosition(); + const newRange = schema.getNearestSelectionRange( position, isForward ? 'forward' : 'backward' ); + + if ( newRange ) { + model.change( writer => { + writer.setSelection( newRange ); + } ); + + domEventData.preventDefault(); + eventInfo.stop(); + } + + return; + } + + // If selection is next to object element. + // Return if not collapsed. + if ( !modelSelection.isCollapsed ) { + return; + } + + const objectElementNextToSelection = this._getObjectElementNextToSelection( isForward ); + + if ( objectElementNextToSelection && schema.isObject( objectElementNextToSelection ) ) { + this._setSelectionOverElement( objectElementNextToSelection ); + + domEventData.preventDefault(); + eventInfo.stop(); + } + } + + /** + * Handles {@link module:engine/view/document~Document#event:keydown keydown} events and prevents + * the default browser behavior to make sure the fake selection is not being moved from a fake selection + * container. + * + * See {@link #_handleSelectionChangeOnArrowKeyPress}. * * @private * @param {module:utils/eventinfo~EventInfo} eventInfo * @param {module:engine/view/observer/domeventdata~DomEventData} domEventData */ - _onKeydown( eventInfo, domEventData ) { + _preventDefaultOnArrowKeyPress( eventInfo, domEventData ) { const keyCode = domEventData.keyCode; - const isLtrContent = this.editor.locale.contentLanguageDirection === 'ltr'; - const isForward = keyCode == keyCodes.arrowdown || keyCode == keyCodes[ isLtrContent ? 'arrowright' : 'arrowleft' ]; - let wasHandled = false; // Checks if the keys were handled and then prevents the default event behaviour and stops // the propagation. - if ( isArrowKeyCode( keyCode ) ) { - wasHandled = this._handleArrowKeys( isForward ); - } else if ( keyCode === keyCodes.enter ) { - wasHandled = this._handleEnterKey( domEventData.shiftKey ); + if ( !isArrowKeyCode( keyCode ) ) { + return; } - if ( wasHandled ) { + const model = this.editor.model; + const schema = model.schema; + const objectElement = model.document.selection.getSelectedElement(); + + // If object element is selected. + if ( objectElement && schema.isObject( objectElement ) ) { domEventData.preventDefault(); eventInfo.stop(); } @@ -230,90 +315,10 @@ export default class Widget extends Plugin { } } - /** - * Handles arrow keys. - * - * @private - * @param {Boolean} isForward Set to true if arrow key should be handled in forward direction. - * @returns {Boolean|undefined} Returns `true` if keys were handled correctly. - */ - _handleArrowKeys( isForward ) { - const model = this.editor.model; - const schema = model.schema; - const modelDocument = model.document; - const modelSelection = modelDocument.selection; - const objectElement = modelSelection.getSelectedElement(); - - // If object element is selected. - if ( objectElement && schema.isObject( objectElement ) ) { - const position = isForward ? modelSelection.getLastPosition() : modelSelection.getFirstPosition(); - const newRange = schema.getNearestSelectionRange( position, isForward ? 'forward' : 'backward' ); - - if ( newRange ) { - model.change( writer => { - writer.setSelection( newRange ); - } ); - } - - return true; - } - - // If selection is next to object element. - // Return if not collapsed. - if ( !modelSelection.isCollapsed ) { - return; - } - - const objectElement2 = this._getObjectElementNextToSelection( isForward ); - - if ( !!objectElement2 && schema.isObject( objectElement2 ) ) { - this._setSelectionOverElement( objectElement2 ); - - return true; - } - } - - /** - * Handles the enter key, giving users and access to positions in the editable directly before - * (Shift+Enter) or after (Enter) the selected widget. - * It improves the UX, mainly when the widget is the first or last child of the root editable - * and there's no other way to type after or before it. - * - * @private - * @param {Boolean} isBackwards Set to true if the new paragraph is to be inserted before - * the selected widget (Shift+Enter). - * @returns {Boolean|undefined} Returns `true` if keys were handled correctly. - */ - _handleEnterKey( isBackwards ) { - const model = this.editor.model; - const modelSelection = model.document.selection; - const selectedElement = modelSelection.getSelectedElement(); - - if ( shouldInsertParagraph( selectedElement, model.schema ) ) { - model.change( writer => { - let position = writer.createPositionAt( selectedElement, isBackwards ? 'before' : 'after' ); - const paragraph = writer.createElement( 'paragraph' ); - - // Split the parent when inside a block element. - // https://github.com/ckeditor/ckeditor5/issues/1529 - if ( model.schema.isBlock( selectedElement.parent ) ) { - const paragraphLimit = model.schema.findAllowedParent( position, paragraph ); - - position = writer.split( position, paragraphLimit ).position; - } - - writer.insert( paragraph, position ); - writer.setSelection( paragraph, 'in' ); - } ); - - return true; - } - } - /** * Sets {@link module:engine/model/selection~Selection document's selection} over given element. * - * @private + * @protected * @param {module:engine/model/element~Element} element */ _setSelectionOverElement( element ) { @@ -327,7 +332,7 @@ export default class Widget extends Plugin { * {@link module:engine/model/selection~Selection model selection} exists and is marked in * {@link module:engine/model/schema~Schema schema} as `object`. * - * @private + * @protected * @param {Boolean} forward Direction of checking. * @returns {module:engine/model/element~Element|null} */ @@ -364,17 +369,6 @@ export default class Widget extends Plugin { } } -// Returns 'true' if provided key code represents one of the arrow keys. -// -// @param {Number} keyCode -// @returns {Boolean} -function isArrowKeyCode( keyCode ) { - return keyCode == keyCodes.arrowright || - keyCode == keyCodes.arrowleft || - keyCode == keyCodes.arrowup || - keyCode == keyCodes.arrowdown; -} - // Returns `true` when element is a nested editable or is placed inside one. // // @param {module:engine/view/element~Element} @@ -408,11 +402,3 @@ function isChild( element, parent ) { return Array.from( element.getAncestors() ).includes( parent ); } - -// Checks if enter key should insert paragraph. This should be done only on elements of type object (excluding inline objects). -// -// @param {module:engine/model/element~Element} element And element to check. -// @param {module:engine/model/schema~Schema} schema -function shouldInsertParagraph( element, schema ) { - return element && schema.isObject( element ) && !schema.isInline( element ); -} diff --git a/packages/ckeditor5-widget/src/widgettypearound/utils.js b/packages/ckeditor5-widget/src/widgettypearound/utils.js index a11956bfd72..adf777e448f 100644 --- a/packages/ckeditor5-widget/src/widgettypearound/utils.js +++ b/packages/ckeditor5-widget/src/widgettypearound/utils.js @@ -55,41 +55,3 @@ export function getClosestWidgetViewElement( domElement, domConverter ) { return domConverter.mapDomToView( widgetDomElement ); } - -/** - * For the passed widget view element, this helper returns an array of positions which - * correspond to the "tight spots" around the widget which cannot be accessed due to - * limitations of selection rendering in web browsers. - * - * @param {module:engine/view/element~Element} widgetViewElement - * @returns {Array.} - */ -export function getWidgetTypeAroundPositions( widgetViewElement ) { - const positions = []; - - if ( isFirstChild( widgetViewElement ) || hasPreviousWidgetSibling( widgetViewElement ) ) { - positions.push( 'before' ); - } - - if ( isLastChild( widgetViewElement ) || hasNextWidgetSibling( widgetViewElement ) ) { - positions.push( 'after' ); - } - - return positions; -} - -function isFirstChild( widget ) { - return !widget.previousSibling; -} - -function isLastChild( widget ) { - return !widget.nextSibling; -} - -function hasPreviousWidgetSibling( widget ) { - return widget.previousSibling && isWidget( widget.previousSibling ); -} - -function hasNextWidgetSibling( widget ) { - return widget.nextSibling && isWidget( widget.nextSibling ); -} diff --git a/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js b/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js index 2ca917421af..0681781963f 100644 --- a/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js +++ b/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js @@ -10,17 +10,25 @@ */ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; -import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import Template from '@ckeditor/ckeditor5-ui/src/template'; +import { + isArrowKeyCode, + isForwardArrowKeyCode, + keyCodes +} from '@ckeditor/ckeditor5-utils/src/keyboard'; +import priorities from '@ckeditor/ckeditor5-utils/src/priorities'; import { isTypeAroundWidget, - getWidgetTypeAroundPositions, getClosestTypeAroundDomButton, getTypeAroundButtonPosition, getClosestWidgetViewElement } from './utils'; +import { + isNonTypingKeystroke +} from '@ckeditor/ckeditor5-typing/src/utils/injectunsafekeystrokeshandling'; + import returnIcon from '../../theme/icons/return-arrow.svg'; import '../../theme/widgettypearound.css'; @@ -29,6 +37,8 @@ const POSSIBLE_INSERTION_POSITIONS = [ 'before', 'after' ]; // Do the SVG parsing once and then clone the result DOM element for each new button. const RETURN_ARROW_ICON_ELEMENT = new DOMParser().parseFromString( returnIcon, 'image/svg+xml' ).firstChild; +const TYPE_AROUND_SELECTION_ATTRIBUTE = 'widget-type-around'; + /** * A plugin that allows users to type around widgets where normally it is impossible to place the caret due * to limitations of web browsers. These "tight spots" occur, for instance, before (or after) a widget being @@ -43,13 +53,6 @@ const RETURN_ARROW_ICON_ELEMENT = new DOMParser().parseFromString( returnIcon, ' * @private */ export default class WidgetTypeAround extends Plugin { - /** - * @inheritDoc - */ - static get requires() { - return [ Paragraph ]; - } - /** * @inheritDoc */ @@ -64,32 +67,33 @@ export default class WidgetTypeAround extends Plugin { super( editor ); /** - * A set containing all widgets in all editor roots that have the type around UI injected in - * {@link #_enableTypeAroundUIInjection}. - * - * Keeping track of them saves time, for instance, when updating their CSS classes. + * A reference to the model widget element that has the "fake caret" active + * on either side of it. It is later used to remove CSS classes associated with the "fake caret" + * when the widget no longer needs it. * * @private - * @readonly - * @member {Set} #_widgetsWithTypeAroundUI + * @member {module:engine/model/element~Element|null} */ - this._widgetsWithTypeAroundUI = new Set(); + this._currentFakeCaretModelElement = null; } /** * @inheritDoc */ - destroy() { - this._widgetsWithTypeAroundUI.clear(); + init() { + this._enableTypeAroundUIInjection(); + this._enableInsertingParagraphsOnButtonClick(); + this._enableInsertingParagraphsOnEnterKeypress(); + this._enableInsertingParagraphsOnTypingKeystroke(); + this._enableTypeAroundFakeCaretActivationUsingKeyboardArrows(); + this._enableDeleteIntegration(); } /** * @inheritDoc */ - init() { - this._enableTypeAroundUIInjection(); - this._enableDetectionOfTypeAroundWidgets(); - this._enableInsertingParagraphsOnButtonClick(); + destroy() { + this._currentFakeCaretModelElement = null; } /** @@ -99,13 +103,12 @@ export default class WidgetTypeAround extends Plugin { * the viewport to the selection in the inserted paragraph. * * @protected - * @param {module:engine/view/element~Element} widgetViewElement The view widget element next to which a paragraph is inserted. + * @param {module:engine/model/element~Element} widgetModelElement The model widget element next to which a paragraph is inserted. * @param {'before'|'after'} position The position where the paragraph is inserted. Either `'before'` or `'after'` the widget. */ - _insertParagraph( widgetViewElement, position ) { + _insertParagraph( widgetModelElement, position ) { const editor = this.editor; const editingView = editor.editing.view; - const widgetModelElement = editor.editing.mapper.toModelElement( widgetViewElement ); let modelPosition; if ( position === 'before' ) { @@ -122,6 +125,35 @@ export default class WidgetTypeAround extends Plugin { editingView.scrollToTheSelection(); } + /** + * Similar to {@link #_insertParagraph}, this method inserts a paragraph except that it + * does not expect a position but it performs the insertion next to a selected widget + * according to the "widget-type-around" model selection attribute value. + * + * Because this method requires the "widget-type-around" attribute to be set, + * the insertion can only happen when the widget "fake caret" is active (e.g. activated + * using the keyboard). + * + * @private + * @returns {Boolean} Returns `true` when the paragraph was inserted (the attribute was present) and `false` otherwise. + */ + _insertParagraphAccordingToSelectionAttribute() { + const editor = this.editor; + const model = editor.model; + const modelSelection = model.document.selection; + const typeAroundSelectionAttributeValue = modelSelection.getAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE ); + + if ( !typeAroundSelectionAttributeValue ) { + return false; + } + + const selectedModelElement = modelSelection.getSelectedElement(); + + this._insertParagraph( selectedModelElement, typeAroundSelectionAttributeValue ); + + return true; + } + /** * Creates a listener in the editing conversion pipeline that injects the type around * UI into every single widget instance created in the editor. @@ -146,49 +178,260 @@ export default class WidgetTypeAround extends Plugin { // Filter out non-widgets and inline widgets. if ( isTypeAroundWidget( viewElement, data.item, schema ) ) { injectUIIntoWidget( conversionApi.writer, buttonTitles, viewElement ); - - // Keep track of widgets that have the type around UI injected. - // In the #_enableDetectionOfTypeAroundWidgets() we will iterate only over these - // widgets instead of all children of the root. This should improve the performance. - this._widgetsWithTypeAroundUI.add( viewElement ); } }, { priority: 'low' } ); } /** - * Registers an editing view post-fixer which checks all block widgets in the content - * and adds CSS classes to these which should have the typing around (UI) enabled - * and visible for the users. + * Brings support for the "fake caret" that appears when either: + * + * * the selection moves from a position next to a widget (to a widget) using arrow keys, + * * the arrow key is pressed when the widget is already selected. + * + * The "fake caret" lets the user know that they can start typing or just press + * enter to insert a paragraph at the position next to a widget as suggested by the "fake caret". + * + * The "fake caret" disappears when the user changes the selection or the editor + * gets blurred. + * + * The whole idea is as follows: + * + * 1. A user does one of the 2 scenarios described at the beginning. + * 2. The "keydown" listener is executed and the decision is made whether to show or hide the "fake caret". + * 3. If it should show up, the "widget-type-around" model selection attribute is set indicating + * on which side of the widget it should appear. + * 4. The selection dispatcher reacts to the selection attribute and sets CSS classes responsible for the + * "fake caret" on the view widget. + * 5. If the "fake caret" should disappear, the selection attribute is removed and the dispatcher + * does the CSS class clean-up in the view. + * 6. Additionally, "change:range" and FocusTracker#isFocused listeners also remove the selection + * attribute (the former also removes widget CSS classes). * * @private */ - _enableDetectionOfTypeAroundWidgets() { + _enableTypeAroundFakeCaretActivationUsingKeyboardArrows() { const editor = this.editor; + const model = editor.model; + const modelSelection = model.document.selection; + const schema = model.schema; const editingView = editor.editing.view; + // This is the main listener responsible for the "fake caret". + // Note: The priority must precede the default Widget class keydown handler ("high") and the + // TableKeyboard keydown handler ("high-10"). + editingView.document.on( 'keydown', ( evt, domEventData ) => { + if ( isArrowKeyCode( domEventData.keyCode ) ) { + this._handleArrowKeyPress( evt, domEventData ); + } + }, { priority: priorities.get( 'high' ) + 10 } ); + + // This listener makes sure the widget type around selection attribute will be gone from the model + // selection as soon as the model range changes. This attribute only makes sense when a widget is selected + // (and the "fake horizontal caret" is visible) so whenever the range changes (e.g. selection moved somewhere else), + // let's get rid of the attribute so that the selection downcast dispatcher isn't even bothered. + modelSelection.on( 'change:range', ( evt, data ) => { + // Do not reset the selection attribute when the change was indirect. + if ( !data.directChange ) { + return; + } + + const typeAroundSelectionAttribute = modelSelection.getAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE ); + + if ( !typeAroundSelectionAttribute ) { + return; + } + + // Get rid of the widget type around attribute of the selection on every change:range. + // If the range changes, it means for sure, the user is no longer in the active ("fake horizontal caret") mode. + editor.model.change( writer => { + writer.removeSelectionAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE ); + } ); + } ); + + // React to changes of the model selection attribute made by the arrow keys listener. + // If the block widget is selected and the attribute changes, downcast the attribute to special + // CSS classes associated with the active ("fake horizontal caret") mode of the widget. + editor.editing.downcastDispatcher.on( 'selection', ( evt, data, conversionApi ) => { + const writer = conversionApi.writer; + + if ( this._currentFakeCaretModelElement ) { + const selectedViewElement = conversionApi.mapper.toViewElement( this._currentFakeCaretModelElement ); + + if ( selectedViewElement ) { + // Get rid of CSS classes associated with the active ("fake horizontal caret") mode from the view widget. + writer.removeClass( POSSIBLE_INSERTION_POSITIONS.map( positionToWidgetCssClass ), selectedViewElement ); + + this._currentFakeCaretModelElement = null; + } + } + + const selectedModelElement = data.selection.getSelectedElement(); + + if ( !selectedModelElement ) { + return; + } + + const selectedViewElement = conversionApi.mapper.toViewElement( selectedModelElement ); + + if ( !isTypeAroundWidget( selectedViewElement, selectedModelElement, schema ) ) { + return; + } + + const typeAroundSelectionAttribute = data.selection.getAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE ); + + if ( !typeAroundSelectionAttribute ) { + return; + } + + writer.addClass( positionToWidgetCssClass( typeAroundSelectionAttribute ), selectedViewElement ); + + // Remember the view widget that got the "fake-caret" CSS class. This class should be removed ASAP when the + // selection changes + this._currentFakeCaretModelElement = selectedModelElement; + } ); + + this.listenTo( editor.ui.focusTracker, 'change:isFocused', ( evt, name, isFocused ) => { + if ( !isFocused ) { + editor.model.change( writer => { + writer.removeSelectionAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE ); + } ); + } + } ); + function positionToWidgetCssClass( position ) { - return `ck-widget_can-type-around_${ position }`; + return `ck-widget_type-around_show-fake-caret_${ position }`; + } + } + + /** + * A listener executed on each "keydown" in the view document, a part of + * {@link #_enableTypeAroundFakeCaretActivationUsingKeyboardArrows}. + * + * It decides whether the arrow key press should activate the "fake caret" or not (also whether it should + * be deactivated). + * + * The "fake caret" activation is done by setting the "widget-type-around" model selection attribute + * in this listener and stopping&preventing the event that would normally be handled by the Widget + * plugin that is responsible for the regular keyboard navigation near/across all widgets (that + * includes inline widgets, which are ignored by the WidgetTypeAround plugin). + * + * @private + */ + _handleArrowKeyPress( evt, domEventData ) { + const editor = this.editor; + const model = editor.model; + const modelSelection = model.document.selection; + const schema = model.schema; + const editingView = editor.editing.view; + + const keyCode = domEventData.keyCode; + const isForward = isForwardArrowKeyCode( keyCode, editor.locale.contentLanguageDirection ); + const selectedViewElement = editingView.document.selection.getSelectedElement(); + const selectedModelElement = editor.editing.mapper.toModelElement( selectedViewElement ); + let shouldStopAndPreventDefault; + + // Handle keyboard navigation when a type-around-compatible widget is currently selected. + if ( isTypeAroundWidget( selectedViewElement, selectedModelElement, schema ) ) { + shouldStopAndPreventDefault = this._handleArrowKeyPressOnSelectedWidget( isForward ); + } + // Handle keyboard arrow navigation when the selection is next to a type-around-compatible widget + // and the widget is about to be selected. + else if ( modelSelection.isCollapsed ) { + shouldStopAndPreventDefault = this._handleArrowKeyPressWhenSelectionNextToAWidget( isForward ); + } + + if ( shouldStopAndPreventDefault ) { + domEventData.preventDefault(); + evt.stop(); } + } - editingView.document.registerPostFixer( writer => { - for ( const widgetViewElement of this._widgetsWithTypeAroundUI ) { - // If the widget is no longer attached to the root (for instance, because it was removed), - // there is no need to update its classes and we can safely forget about it. - if ( !widgetViewElement.isAttached() ) { - this._widgetsWithTypeAroundUI.delete( widgetViewElement ); - } else { - // Update widgets' classes depending on possible positions for paragraph insertion. - const positions = getWidgetTypeAroundPositions( widgetViewElement ); - - // Remove all classes. In theory we could remove only these that will not be added a few lines later, - // but since there are only two... KISS. - writer.removeClass( POSSIBLE_INSERTION_POSITIONS.map( positionToWidgetCssClass ), widgetViewElement ); - - // Set CSS classes related to possible positions. They are used so the UI knows which buttons to display. - writer.addClass( positions.map( positionToWidgetCssClass ), widgetViewElement ); + /** + * Handles the keyboard navigation on "keydown" when a widget is currently selected and activates or deactivates + * the "fake caret" for that widget, depending on the current value of the "widget-type-around" model + * selection attribute and the direction of the pressed arrow key. + * + * @private + * @param {Boolean} isForward `true` when the pressed arrow key was responsible for the forward model selection movement + * as in {@link module:utils/keyboard~isForwardArrowKeyCode}. + * @returns {Boolean} `true` when the key press was handled and no other keydown listener of the editor should + * process the event any further. `false` otherwise. + */ + _handleArrowKeyPressOnSelectedWidget( isForward ) { + const editor = this.editor; + const model = editor.model; + const modelSelection = model.document.selection; + const typeAroundSelectionAttribute = modelSelection.getAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE ); + let shouldStopAndPreventDefault = false; + + model.change( writer => { + // If the selection already has the attribute... + if ( typeAroundSelectionAttribute ) { + const isLeavingWidget = typeAroundSelectionAttribute === ( isForward ? 'after' : 'before' ); + + // If the keyboard arrow works against the value of the selection attribute... + // then remove the selection attribute but prevent default DOM actions + // and do not let the Widget plugin listener move the selection. This brings + // the widget back to the state, for instance, like if was selected using the mouse. + // + // **Note**: If leaving the widget when the "fake caret" is active, then the default + // Widget handler will change the selection and, in turn, this will automatically discard + // the selection attribute. + if ( !isLeavingWidget ) { + writer.removeSelectionAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE ); + + shouldStopAndPreventDefault = true; } } + // If the selection didn't have the attribute, let's set it now according to the direction of the arrow + // key press. This also means we cannot let the Widget plugin listener move the selection. + else { + writer.setSelectionAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE, isForward ? 'after' : 'before' ); + + shouldStopAndPreventDefault = true; + } } ); + + return shouldStopAndPreventDefault; + } + + /** + * Handles the keyboard navigation on "keydown" when **no** widget is selected but the selection is **directly** next + * to one and upon the "fake caret" should become active for this widget upon arrow key press + * (AKA entering/selecting the widget). + * + * **Note**: This code mirrors the implementation from the Widget plugin but also adds the selection attribute. + * Unfortunately, there's no safe way to let the Widget plugin do the selection part first and then just set the + * selection attribute here in the WidgetTypeAround plugin. This is why this code must duplicate some from the Widget plugin. + * + * @private + * @param {Boolean} isForward `true` when the pressed arrow key was responsible for the forward model selection movement + * as in {@link module:utils/keyboard~isForwardArrowKeyCode}. + * @returns {Boolean} `true` when the key press was handled and no other keydown listener of the editor should + * process the event any further. `false` otherwise. + */ + _handleArrowKeyPressWhenSelectionNextToAWidget( isForward ) { + const editor = this.editor; + const model = editor.model; + const schema = model.schema; + const widgetPlugin = editor.plugins.get( 'Widget' ); + + // This is the widget the selection is about to be set on. + const modelElementNextToSelection = widgetPlugin._getObjectElementNextToSelection( isForward ); + const viewElementNextToSelection = editor.editing.mapper.toViewElement( modelElementNextToSelection ); + + if ( isTypeAroundWidget( viewElementNextToSelection, modelElementNextToSelection, schema ) ) { + model.change( writer => { + widgetPlugin._setSelectionOverElement( modelElementNextToSelection ); + writer.setSelectionAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE, isForward ? 'before' : 'after' ); + } ); + + // The change() block above does the same job as the Widget plugin. The event can + // be safely canceled. + return true; + } + + return false; } /** @@ -211,13 +454,180 @@ export default class WidgetTypeAround extends Plugin { const buttonPosition = getTypeAroundButtonPosition( button ); const widgetViewElement = getClosestWidgetViewElement( button, editingView.domConverter ); + const widgetModelElement = editor.editing.mapper.toModelElement( widgetViewElement ); - this._insertParagraph( widgetViewElement, buttonPosition ); + this._insertParagraph( widgetModelElement, buttonPosition ); domEventData.preventDefault(); evt.stop(); } ); } + + /** + * Creates the "enter" key listener on the view document that allows the user to insert a paragraph + * near the widget when either: + * + * * The "fake caret" was first activated using the arrow keys, + * * The entire widget is selected in the model. + * + * In the first case, the new paragraph is inserted according to the "widget-type-around" selection + * attribute (see {@link #_handleArrowKeyPress}). + * + * It the second case, the new paragraph is inserted based on whether a soft (Shift+Enter) keystroke + * was pressed or not. + * + * @private + */ + _enableInsertingParagraphsOnEnterKeypress() { + const editor = this.editor; + const editingView = editor.editing.view; + + this.listenTo( editingView.document, 'enter', ( evt, domEventData ) => { + const selectedViewElement = editingView.document.selection.getSelectedElement(); + const selectedModelElement = editor.editing.mapper.toModelElement( selectedViewElement ); + const schema = editor.model.schema; + let wasHandled; + + // First check if the widget is selected and there's a type around selection attribute associated + // with the "fake caret" that would tell where to insert a new paragraph. + if ( this._insertParagraphAccordingToSelectionAttribute() ) { + wasHandled = true; + } + // Then, if there is no selection attribute associated with the "fake caret", check if the widget + // simply is selected and create a new paragraph according to the keystroke (Shift+)Enter. + else if ( isTypeAroundWidget( selectedViewElement, selectedModelElement, schema ) ) { + this._insertParagraph( selectedModelElement, domEventData.isSoft ? 'before' : 'after' ); + + wasHandled = true; + } + + if ( wasHandled ) { + domEventData.preventDefault(); + evt.stop(); + } + } ); + } + + /** + * Similar to the {@link #_enableInsertingParagraphsOnEnterKeypress}, it allows the user + * to insert a paragraph next to a widget when the "fake caret" was activated using arrow + * keys but it responds to "typing keystrokes" instead of "enter". + * + * "Typing keystrokes" are keystrokes that insert new content into the document + * like, for instance, letters ("a") or numbers ("4"). The "keydown" listener enabled by this method + * will insert a new paragraph according to the "widget-type-around" model selection attribute + * as the user simply starts typing, which creates the impression that the "fake caret" + * behaves like a "real one" rendered by the browser (AKA your text appears where the caret was). + * + * **Note**: ATM this listener creates 2 undo steps: one for the "insertParagraph" command + * and the second for the actual typing. It's not a disaster but this may need to be fixed + * sooner or later. + * + * Learn more in {@link module:typing/utils/injectunsafekeystrokeshandling}. + * + * @private + */ + _enableInsertingParagraphsOnTypingKeystroke() { + const editor = this.editor; + const editingView = editor.editing.view; + const keyCodesHandledSomewhereElse = [ + keyCodes.enter, + keyCodes.delete, + keyCodes.backspace + ]; + + // Note: The priority must precede the default Widget class keydown handler ("high") and the + // TableKeyboard keydown handler ("high + 1"). + editingView.document.on( 'keydown', ( evt, domEventData ) => { + // Don't handle enter/backspace/delete here. They are handled in dedicated listeners. + if ( !keyCodesHandledSomewhereElse.includes( domEventData.keyCode ) && !isNonTypingKeystroke( domEventData ) ) { + this._insertParagraphAccordingToSelectionAttribute(); + } + }, { priority: priorities.get( 'high' ) + 1 } ); + } + + /** + * It creates a "delete" event listener on the view document to handle cases when delete/backspace + * is pressed and the "fake caret" is currently active. + * + * The "fake caret" should create an illusion of a "real browser caret" so that when it appears + * before/after a widget, pressing delete/backspace should remove a widget or delete a content + * before/after a widget (depending on the content surrounding the widget). + * + * @private + */ + _enableDeleteIntegration() { + const editor = this.editor; + const editingView = editor.editing.view; + const model = editor.model; + const schema = model.schema; + + // Note: The priority must precede the default Widget class delete handler. + this.listenTo( editingView.document, 'delete', ( evt, domEventData ) => { + const typeAroundSelectionAttributeValue = model.document.selection.getAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE ); + + // This listener handles only these cases when the "fake caret" is active. + if ( !typeAroundSelectionAttributeValue ) { + return; + } + + const direction = domEventData.direction; + const selectedModelWidget = model.document.selection.getSelectedElement(); + + const isFakeCaretBefore = typeAroundSelectionAttributeValue === 'before'; + const isForwardDelete = direction == 'forward'; + const shouldDeleteEntireWidget = isFakeCaretBefore === isForwardDelete; + + if ( shouldDeleteEntireWidget ) { + editor.execute( 'delete', { + selection: model.createSelection( selectedModelWidget, 'on' ) + } ); + } else { + const range = schema.getNearestSelectionRange( + model.createPositionAt( selectedModelWidget, typeAroundSelectionAttributeValue ), + direction + ); + + // If there is somewhere to move selection to, then there will be something to delete. + if ( range ) { + // If the range is NOT collapsed, then we know that the range contains an object (see getNearestSelectionRange() docs). + if ( !range.isCollapsed ) { + model.change( writer => { + writer.setSelection( range ); + editor.execute( isForwardDelete ? 'forwardDelete' : 'delete' ); + } ); + } else { + const probe = model.createSelection( range.start ); + model.modifySelection( probe, { direction } ); + + // If the range is collapsed, let's see if a non-collapsed range exists that can could be deleted. + // If such range exists, use the editor command because it it safe for collaboration (it merges where it can). + if ( !probe.focus.isEqual( range.start ) ) { + model.change( writer => { + writer.setSelection( range ); + editor.execute( isForwardDelete ? 'forwardDelete' : 'delete' ); + } ); + } + // If there is no non-collapsed range to be deleted then we are sure that there is an empty element + // next to a widget that should be removed. "delete" and "forwardDelete" commands cannot get rid of it + // so calling Model#deleteContent here manually. + else { + const deepestEmptyRangeAncestor = getDeepestEmptyElementAncestor( schema, range.start.parent ); + + model.deleteContent( model.createSelection( deepestEmptyRangeAncestor, 'on' ), { + doNotAutoparagraph: true + } ); + } + } + } + } + + // If some content was deleted, don't let the handler from the Widget plugin kick in. + // If nothing was deleted, then the default handler will have nothing to do anyway. + domEventData.preventDefault(); + evt.stop(); + }, { priority: priorities.get( 'high' ) + 1 } ); + } } // Injects the type around UI into a view widget instance. @@ -232,6 +642,7 @@ function injectUIIntoWidget( viewWriter, buttonTitles, widgetViewElement ) { const wrapperDomElement = this.toDomElement( domDocument ); injectButtons( wrapperDomElement, buttonTitles ); + injectFakeCaret( wrapperDomElement ); return wrapperDomElement; } ); @@ -266,3 +677,42 @@ function injectButtons( wrapperDomElement, buttonTitles ) { wrapperDomElement.appendChild( buttonTemplate.render() ); } } + +// @param {HTMLElement} wrapperDomElement +function injectFakeCaret( wrapperDomElement ) { + const caretTemplate = new Template( { + tag: 'div', + attributes: { + class: [ + 'ck', + 'ck-widget__type-around__fake-caret' + ] + } + } ); + + wrapperDomElement.appendChild( caretTemplate.render() ); +} + +// Returns the ancestor of an element closest to the root which is empty. For instance, +// for ``: +// +// abc +// +// it returns ``. +// +// @param {module:engine/model/schema~Schema} schema +// @param {module:engine/model/element~Element} element +// @returns {module:engine/model/element~Element|null} +function getDeepestEmptyElementAncestor( schema, element ) { + let deepestEmptyAncestor = element; + + for ( const ancestor of element.getAncestors( { parentFirst: true } ) ) { + if ( ancestor.childCount > 1 || schema.isLimit( ancestor ) ) { + break; + } + + deepestEmptyAncestor = ancestor; + } + + return deepestEmptyAncestor; +} diff --git a/packages/ckeditor5-widget/tests/manual/type-around.html b/packages/ckeditor5-widget/tests/manual/type-around.html index 908f9bd8fbb..2d95d48518b 100644 --- a/packages/ckeditor5-widget/tests/manual/type-around.html +++ b/packages/ckeditor5-widget/tests/manual/type-around.html @@ -39,13 +39,13 @@

Heading 1

 
-
+
  - +

 

bar
Caption
diff --git a/packages/ckeditor5-widget/tests/manual/type-around.js b/packages/ckeditor5-widget/tests/manual/type-around.js index 5513de7a47e..6de1fc375c8 100644 --- a/packages/ckeditor5-widget/tests/manual/type-around.js +++ b/packages/ckeditor5-widget/tests/manual/type-around.js @@ -9,6 +9,9 @@ import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor' import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset'; import HorizontalLine from '@ckeditor/ckeditor5-horizontal-line/src/horizontalline'; import MediaEmbed from '@ckeditor/ckeditor5-media-embed/src/mediaembed'; +import TableProperties from '@ckeditor/ckeditor5-table/src/tableproperties'; +import TableCellProperties from '@ckeditor/ckeditor5-table/src/tablecellproperties'; +import ImageResize from '@ckeditor/ckeditor5-image/src/imageresize'; import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview'; @@ -96,7 +99,15 @@ document.querySelector( '#toggleReadOnly' ).addEventListener( 'click', () => { ClassicEditor .create( document.querySelector( '#editor' ), { - plugins: [ ArticlePluginSet, HorizontalLine, InlineWidget, MediaEmbed ], + plugins: [ + ArticlePluginSet, + HorizontalLine, + InlineWidget, + MediaEmbed, + TableProperties, + TableCellProperties, + ImageResize + ], toolbar: [ 'heading', '|', @@ -123,7 +134,9 @@ ClassicEditor contentToolbar: [ 'tableColumn', 'tableRow', - 'mergeTableCells' + 'mergeTableCells', + 'tableProperties', + 'tableCellProperties' ] } } ) diff --git a/packages/ckeditor5-widget/tests/widget-integration.js b/packages/ckeditor5-widget/tests/widget-integration.js index 7a04d5fde3b..12d8f0e2cbd 100644 --- a/packages/ckeditor5-widget/tests/widget-integration.js +++ b/packages/ckeditor5-widget/tests/widget-integration.js @@ -6,6 +6,7 @@ /* global document */ import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import Typing from '@ckeditor/ckeditor5-typing/src/typing'; import Widget from '../src/widget'; import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; @@ -31,7 +32,7 @@ describe( 'Widget - integration', () => { editorElement = document.createElement( 'div' ); document.body.appendChild( editorElement ); - return ClassicEditor.create( editorElement, { plugins: [ Widget, Typing ] } ) + return ClassicEditor.create( editorElement, { plugins: [ Paragraph, Widget, Typing ] } ) .then( newEditor => { editor = newEditor; model = editor.model; @@ -113,7 +114,7 @@ describe( 'Widget - integration', () => { expect( getViewData( view ) ).to.equal( '

[]

' + - '
' + + '
' + '
foo bar
' + '
' + '
' @@ -139,7 +140,7 @@ describe( 'Widget - integration', () => { sinon.assert.called( preventDefault ); expect( getViewData( view ) ).to.equal( - '
' + + '
' + '
{foo bar}
' + '
' + '
' @@ -164,7 +165,7 @@ describe( 'Widget - integration', () => { sinon.assert.called( preventDefault ); expect( getViewData( view ) ).to.equal( - '
' + + '
' + '
foo
' + '
{bar}
' + '
' + @@ -191,7 +192,7 @@ describe( 'Widget - integration', () => { sinon.assert.called( preventDefault ); expect( getViewData( view ) ).to.equal( - '
' + + '
' + '
{foo bar}
' + '
' + '
' @@ -243,7 +244,7 @@ describe( 'Widget - integration', () => { expect( getViewData( view ) ).to.equal( '

[]

' + - '
' + + '
' + '
foo bar
' + '
' + '
' diff --git a/packages/ckeditor5-widget/tests/widget.js b/packages/ckeditor5-widget/tests/widget.js index 5d3b9256f93..d6ea7e13f29 100644 --- a/packages/ckeditor5-widget/tests/widget.js +++ b/packages/ckeditor5-widget/tests/widget.js @@ -6,6 +6,8 @@ /* global document */ import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import Enter from '@ckeditor/ckeditor5-enter/src/enter'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import Widget from '../src/widget'; import WidgetTypeAround from '../src/widgettypearound/widgettypearound'; import Typing from '@ckeditor/ckeditor5-typing/src/typing'; @@ -28,7 +30,7 @@ describe( 'Widget', () => { return ClassicTestEditor .create( element, { - plugins: [ Widget, Typing ] + plugins: [ Paragraph, Widget, Typing, Enter ] } ) .then( newEditor => { editor = newEditor; @@ -198,7 +200,7 @@ describe( 'Widget', () => { setModelData( model, '[foo bar]' ); expect( getViewData( view ) ).to.equal( - '[
' + 'foo bar' + '' + @@ -221,11 +223,11 @@ describe( 'Widget', () => { expect( getViewData( view ) ).to.equal( '

{foo

' + - '
' + + '
' + '' + '
' + '
' + - '
' + '' + '
' + @@ -244,7 +246,7 @@ describe( 'Widget', () => { expect( getViewData( view ) ).to.equal( '

foo

' + - '[
' + + '[
' + 'foo' + '' + '
' + @@ -257,7 +259,7 @@ describe( 'Widget', () => { expect( getViewData( view ) ).to.equal( '

{}foo

' + - '
' + + '
' + 'foo' + '' + '
' + @@ -269,7 +271,7 @@ describe( 'Widget', () => { setModelData( model, 'foo bar[baz]' ); expect( getViewData( view ) ).to.equal( - '
' + + '
' + '
foo bar
' + '' + '
' + @@ -283,112 +285,128 @@ describe( 'Widget', () => { test( 'should move selection forward from selected object - right arrow', '[]foo', - keyCodes.arrowright, + // Note: The first step is handled by the WidgetTypeAround plugin. + [ keyCodes.arrowright, keyCodes.arrowright ], '[]foo' ); test( 'should move selection forward from selected object - down arrow', '[]foo', - keyCodes.arrowdown, + // Note: The first step is handled by the WidgetTypeAround plugin. + [ keyCodes.arrowdown, keyCodes.arrowdown ], '[]foo' ); test( 'should move selection backward from selected object - left arrow', 'foo[]', - keyCodes.arrowleft, + // Note: The first step is handled by the WidgetTypeAround plugin. + [ keyCodes.arrowleft, keyCodes.arrowleft ], 'foo[]' ); test( 'should move selection backward from selected object - up arrow', 'foo[]', - keyCodes.arrowup, + // Note: The first step is handled by the WidgetTypeAround plugin. + [ keyCodes.arrowup, keyCodes.arrowup ], 'foo[]' ); test( 'should move selection to next widget - right arrow', '[]', - keyCodes.arrowright, + // Note: The first step is handled by the WidgetTypeAround plugin. + [ keyCodes.arrowright, keyCodes.arrowright ], '[]' ); test( 'should move selection to next widget - down arrow', '[]', - keyCodes.arrowdown, + // Note: The first step is handled by the WidgetTypeAround plugin. + [ keyCodes.arrowdown, keyCodes.arrowdown ], '[]' ); test( 'should move selection to previous widget - left arrow', '[]', - keyCodes.arrowleft, + // Note: The first step is handled by the WidgetTypeAround plugin. + [ keyCodes.arrowleft, keyCodes.arrowleft ], '[]' ); test( 'should move selection to previous widget - up arrow', '[]', - keyCodes.arrowup, + // Note: The first step is handled by the WidgetTypeAround plugin. + [ keyCodes.arrowup, keyCodes.arrowup ], '[]' ); + // Note: Testing an inline widget only because block widgets are handled and tested by the WidgetTypeAround plugin. test( - 'should do nothing on non-collapsed selection next to object - right arrow', - 'ba[r]', + 'should do nothing on non-collapsed selection next to an inline widget - right arrow', + 'ba[r]', keyCodes.arrowright, - 'ba[r]' + 'ba[r]' ); + // Note: Testing an inline widget only because block widgets are handled and tested by the WidgetTypeAround plugin. test( - 'should do nothing on non-collapsed selection next to object - down arrow', - 'ba[r]', + 'should do nothing on non-collapsed selection next to an inline widget - down arrow', + 'ba[r]', keyCodes.arrowdown, - 'ba[r]' + 'ba[r]' ); + // Note: Testing an inline widget only because block widgets are handled and tested by the WidgetTypeAround plugin. test( - 'should do nothing on non-collapsed selection next to object - left arrow', - '[b]ar', + 'should do nothing on non-collapsed selection next to an inline widget - left arrow', + '[b]ar', keyCodes.arrowleft, - '[b]ar' + '[b]ar' ); + // Note: Testing an inline widget only because block widgets are handled and tested by the WidgetTypeAround plugin. test( - 'should do nothing on non-collapsed selection next to object - up arrow', - '[b]ar', + 'should do nothing on non-collapsed selection next to an inline widget - up arrow', + '[b]ar', keyCodes.arrowup, - '[b]ar' + '[b]ar' ); test( 'should not move selection if there is no correct location - right arrow', 'foo[]', - keyCodes.arrowright, + // Note: The first step is handled by the WidgetTypeAround plugin. + [ keyCodes.arrowright, keyCodes.arrowright ], 'foo[]' ); test( 'should not move selection if there is no correct location - down arrow', 'foo[]', - keyCodes.arrowdown, + // Note: The first step is handled by the WidgetTypeAround plugin. + [ keyCodes.arrowdown, keyCodes.arrowdown ], 'foo[]' ); test( 'should not move selection if there is no correct location - left arrow', '[]foo', - keyCodes.arrowleft, + // Note: The first step is handled by the WidgetTypeAround plugin. + [ keyCodes.arrowleft, keyCodes.arrowleft ], '[]foo' ); test( 'should not move selection if there is no correct location - up arrow', '[]foo', - keyCodes.arrowup, + // Note: The first step is handled by the WidgetTypeAround plugin. + [ keyCodes.arrowup, keyCodes.arrowup ], '[]foo' ); @@ -409,10 +427,12 @@ describe( 'Widget', () => { setModelData( model, 'foo[]' ); viewDocument.on( 'keydown', keydownHandler ); + // Note: The first step is handled by the WidgetTypeAround plugin. + viewDocument.fire( 'keydown', domEventDataMock ); viewDocument.fire( 'keydown', domEventDataMock ); expect( getModelData( model ) ).to.equal( 'foo[]' ); - sinon.assert.calledOnce( domEventDataMock.preventDefault ); + sinon.assert.calledTwice( domEventDataMock.preventDefault ); sinon.assert.notCalled( keydownHandler ); } ); @@ -425,10 +445,12 @@ describe( 'Widget', () => { setModelData( model, '[]foo' ); viewDocument.on( 'keydown', keydownHandler ); + // Note: The first step is handled by the WidgetTypeAround plugin. + viewDocument.fire( 'keydown', domEventDataMock ); viewDocument.fire( 'keydown', domEventDataMock ); expect( getModelData( model ) ).to.equal( '[]foo' ); - sinon.assert.calledOnce( domEventDataMock.preventDefault ); + sinon.assert.calledTwice( domEventDataMock.preventDefault ); sinon.assert.notCalled( keydownHandler ); } ); @@ -491,84 +513,132 @@ describe( 'Widget', () => { test( 'should work correctly with modifier key: right arrow + ctrl', '[]foo', - { keyCode: keyCodes.arrowright, ctrlKey: true }, + // Note: The first step is handled by the WidgetTypeAround plugin. + [ + { keyCode: keyCodes.arrowright, ctrlKey: true }, + { keyCode: keyCodes.arrowright, ctrlKey: true } + ], '[]foo' ); test( 'should work correctly with modifier key: right arrow + alt', '[]foo', - { keyCode: keyCodes.arrowright, altKey: true }, + // Note: The first step is handled by the WidgetTypeAround plugin. + [ + { keyCode: keyCodes.arrowright, altKey: true }, + { keyCode: keyCodes.arrowright, altKey: true } + ], '[]foo' ); test( 'should work correctly with modifier key: right arrow + shift', '[]foo', - { keyCode: keyCodes.arrowright, shiftKey: true }, + // Note: The first step is handled by the WidgetTypeAround plugin. + [ + { keyCode: keyCodes.arrowright, shiftKey: true }, + { keyCode: keyCodes.arrowright, shiftKey: true } + ], '[]foo' ); test( 'should work correctly with modifier key: down arrow + ctrl', '[]foo', - { keyCode: keyCodes.arrowdown, ctrlKey: true }, + // Note: The first step is handled by the WidgetTypeAround plugin. + [ + { keyCode: keyCodes.arrowdown, ctrlKey: true }, + { keyCode: keyCodes.arrowdown, ctrlKey: true } + ], '[]foo' ); test( 'should work correctly with modifier key: down arrow + alt', '[]foo', - { keyCode: keyCodes.arrowdown, altKey: true }, + // Note: The first step is handled by the WidgetTypeAround plugin. + [ + { keyCode: keyCodes.arrowdown, altKey: true }, + { keyCode: keyCodes.arrowdown, altKey: true } + ], '[]foo' ); test( 'should work correctly with modifier key: down arrow + shift', '[]foo', - { keyCode: keyCodes.arrowdown, shiftKey: true }, + // Note: The first step is handled by the WidgetTypeAround plugin. + [ + { keyCode: keyCodes.arrowdown, shiftKey: true }, + { keyCode: keyCodes.arrowdown, shiftKey: true } + ], '[]foo' ); test( 'should work correctly with modifier key: left arrow + ctrl', 'foo[]', - { keyCode: keyCodes.arrowleft, ctrlKey: true }, + // Note: The first step is handled by the WidgetTypeAround plugin. + [ + { keyCode: keyCodes.arrowleft, ctrlKey: true }, + { keyCode: keyCodes.arrowleft, ctrlKey: true } + ], 'foo[]' ); test( 'should work correctly with modifier key: left arrow + alt', 'foo[]', - { keyCode: keyCodes.arrowleft, altKey: true }, + // Note: The first step is handled by the WidgetTypeAround plugin. + [ + { keyCode: keyCodes.arrowleft, altKey: true }, + { keyCode: keyCodes.arrowleft, altKey: true } + ], 'foo[]' ); test( 'should work correctly with modifier key: left arrow + shift', 'foo[]', - { keyCode: keyCodes.arrowleft, shiftKey: true }, + // Note: The first step is handled by the WidgetTypeAround plugin. + [ + { keyCode: keyCodes.arrowleft, shiftKey: true }, + { keyCode: keyCodes.arrowleft, shiftKey: true } + ], 'foo[]' ); test( 'should work correctly with modifier key: up arrow + ctrl', 'foo[]', - { keyCode: keyCodes.arrowup, ctrlKey: true }, + // Note: The first step is handled by the WidgetTypeAround plugin. + [ + { keyCode: keyCodes.arrowup, ctrlKey: true }, + { keyCode: keyCodes.arrowup, ctrlKey: true } + ], 'foo[]' ); test( 'should work correctly with modifier key: up arrow + alt', 'foo[]', - { keyCode: keyCodes.arrowup, altKey: true }, + // Note: The first step is handled by the WidgetTypeAround plugin. + [ + { keyCode: keyCodes.arrowup, altKey: true }, + { keyCode: keyCodes.arrowup, altKey: true } + ], 'foo[]' ); test( 'should work correctly with modifier key: up arrow + shift', 'foo[]', - { keyCode: keyCodes.arrowup, shiftKey: true }, + // Note: The first step is handled by the WidgetTypeAround plugin. + [ + { keyCode: keyCodes.arrowup, shiftKey: true }, + { keyCode: keyCodes.arrowup, shiftKey: true } + ], 'foo[]' ); @@ -695,7 +765,8 @@ describe( 'Widget', () => { test( 'should move selection forward from selected object - left arrow', '[]foo', - keyCodes.arrowleft, + // Note: The first step is handled by the WidgetTypeAround plugin. + [ keyCodes.arrowleft, keyCodes.arrowleft ], '[]foo', null, 'rtl' @@ -704,7 +775,8 @@ describe( 'Widget', () => { test( 'should move selection backward from selected object - right arrow', 'foo[]', - keyCodes.arrowright, + // Note: The first step is handled by the WidgetTypeAround plugin. + [ keyCodes.arrowright, keyCodes.arrowright ], 'foo[]', null, 'rtl' @@ -713,7 +785,8 @@ describe( 'Widget', () => { test( 'should move selection to next widget - left arrow', '[]', - keyCodes.arrowleft, + // Note: The first step is handled by the WidgetTypeAround plugin. + [ keyCodes.arrowleft, keyCodes.arrowleft ], '[]', null, 'rtl' @@ -722,7 +795,8 @@ describe( 'Widget', () => { test( 'should move selection to previous widget - right arrow', '[]', - keyCodes.arrowright, + // Note: The first step is handled by the WidgetTypeAround plugin. + [ keyCodes.arrowright, keyCodes.arrowright ], '[]', null, 'rtl' @@ -730,118 +804,33 @@ describe( 'Widget', () => { } ); } ); - describe( 'enter', () => { - test( - 'should insert a paragraph after the selected widget upon Enter', - '[]', - keyCodes.enter, - '[]' - ); - - test( - 'should insert a paragraph before the selected widget upon Shift+Enter', - '[]', - { keyCode: keyCodes.enter, shiftKey: true }, - '[]' - ); - - test( - 'should insert a paragraph when not a first-child of the root', - '[]foo', - keyCodes.enter, - '[]foo' - ); - - test( - 'should insert a paragraph when not a last-child of the root', - 'foo[]', - { keyCode: keyCodes.enter, shiftKey: true }, - 'foo[]' - ); + function test( name, data, actions, expected, expectedView, contentLanguageDirection = 'ltr' ) { + it( name, () => { + testUtils.sinon.stub( editor.locale, 'contentLanguageDirection' ).value( contentLanguageDirection ); - test( - 'should insert a paragraph only when an entire widget is selected (#1)', - '[foo] bar', - keyCodes.enter, - '[] bar' - ); + if ( !Array.isArray( actions ) ) { + actions = [ actions ]; + } - test( - 'should insert a paragraph only when an entire widget is selected (#2)', - 'f[oob]ar', - keyCodes.enter, - 'f[]ar' - ); + actions = actions.map( action => { + if ( typeof action === 'object' ) { + return action; + } - // https://github.com/ckeditor/ckeditor5/issues/1529 - it( 'should split parent when widget is inside a block element', () => { - model.schema.register( 'allowP', { - inheritAllFrom: '$block' - } ); - model.schema.register( 'disallowP', { - inheritAllFrom: '$block', - allowIn: [ 'allowP' ] + return { + keyCode: action + }; } ); - model.schema.extend( 'widget', { - allowIn: [ 'allowP', 'disallowP' ] - } ); - model.schema.extend( 'paragraph', { - allowIn: [ 'allowP' ] - } ); - - editor.conversion.for( 'downcast' ).elementToElement( { model: 'parent', view: 'parent' } ); - editor.conversion.for( 'downcast' ).elementToElement( { model: 'allowP', view: 'allowP' } ); - editor.conversion.for( 'downcast' ).elementToElement( { model: 'disallowP', view: 'disallowP' } ); - - setModelData( model, '[]' ); - - viewDocument.fire( 'keydown', new DomEventData( - viewDocument, - { target: document.createElement( 'div' ), preventDefault() {} }, - { keyCode: keyCodes.enter } - ) ); - - expect( getModelData( model ) ).to.equal( - '[]' - ); - } ); - - test( - 'should do nothing if selected is inline object', - 'foo[]bar', - keyCodes.enter, - 'foo[]bar' - ); - - test( - 'should insert a paragraph after the selected widget inside an element that is not a block upon Enter', - '
[]
', - keyCodes.enter, - '
[]
' - ); - - test( - 'should insert a paragraph before the selected widget inside an element that is not a block upon Shift+Enter', - '
[]
', - { keyCode: keyCodes.enter, shiftKey: true }, - '
[]
' - ); - } ); - - function test( name, data, keyCodeOrMock, expected, expectedView, contentLanguageDirection = 'ltr' ) { - it( name, () => { - testUtils.sinon.stub( editor.locale, 'contentLanguageDirection' ).value( contentLanguageDirection ); - - const domEventDataMock = ( typeof keyCodeOrMock == 'object' ) ? keyCodeOrMock : { - keyCode: keyCodeOrMock - }; setModelData( model, data ); - viewDocument.fire( 'keydown', new DomEventData( - viewDocument, - { target: document.createElement( 'div' ), preventDefault() {} }, - domEventDataMock - ) ); + + for ( const action of actions ) { + viewDocument.fire( 'keydown', new DomEventData( + viewDocument, + { target: document.createElement( 'div' ), preventDefault() {} }, + action + ) ); + } expect( getModelData( model ) ).to.equal( expected ); @@ -1274,7 +1263,7 @@ describe( 'Widget', () => { return ClassicTestEditor .create( element, { - plugins: [ Widget, Typing ] + plugins: [ Paragraph, Widget, Typing ] } ) .then( newEditor => { editor = newEditor; @@ -1349,18 +1338,16 @@ describe( 'Widget', () => { expect( getViewData( view ) ).to.equal( '[
' + '
' + '
' + '
' + '
' + - '
' + + '
' + '
' + '
' + '
' + @@ -1390,23 +1377,22 @@ describe( 'Widget', () => { viewDocument.fire( 'mousedown', domEventDataMock ); expect( getViewData( view ) ).to.equal( - '
' + '
' + '
' + '
' + '[
' + '
' + '
' + '
' + '
' + - '
' + '
' + '
' + @@ -1415,7 +1401,7 @@ describe( 'Widget', () => { '
' + '
]' + '
' + '
' + '
' + @@ -1443,7 +1429,6 @@ describe( 'Widget', () => { expect( getViewData( view ) ).to.equal( '[
' + '
foo bar
' + @@ -1479,12 +1464,10 @@ describe( 'Widget', () => { expect( getViewData( view ) ).to.equal( '
' + '
' + '
' + @@ -1492,10 +1475,9 @@ describe( 'Widget', () => { '
' + '[
' + - '
' + + '
' + '
' + '
' + '
' + @@ -1525,13 +1507,11 @@ describe( 'Widget', () => { expect( getViewData( view ) ).to.equal( '
' + '
[' + '
' + '
' + diff --git a/packages/ckeditor5-widget/tests/widgettypearound/widgettypearound.js b/packages/ckeditor5-widget/tests/widgettypearound/widgettypearound.js index 06a50eac5b7..d4d8bc445fa 100644 --- a/packages/ckeditor5-widget/tests/widgettypearound/widgettypearound.js +++ b/packages/ckeditor5-widget/tests/widgettypearound/widgettypearound.js @@ -5,19 +5,20 @@ import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'; import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset'; -import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; import global from '@ckeditor/ckeditor5-utils/src/dom/global'; +import ViewText from '@ckeditor/ckeditor5-engine/src/view/text'; import Widget from '../../src/widget'; import WidgetTypeAround from '../../src/widgettypearound/widgettypearound'; import { toWidget } from '../../src/utils'; import { setData as setModelData, getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { getCode } from '@ckeditor/ckeditor5-utils/src/keyboard'; describe( 'WidgetTypeAround', () => { - let element, plugin, editor, editingView, viewDocument, viewRoot; + let element, plugin, editor, editingView, viewDocument, modelRoot, viewRoot; beforeEach( async () => { element = global.document.createElement( 'div' ); @@ -37,6 +38,7 @@ describe( 'WidgetTypeAround', () => { editingView = editor.editing.view; viewDocument = editingView.document; viewRoot = viewDocument.getRoot(); + modelRoot = editor.model.document.getRoot(); plugin = editor.plugins.get( WidgetTypeAround ); } ); @@ -47,12 +49,12 @@ describe( 'WidgetTypeAround', () => { } ); describe( 'plugin', () => { - it( 'is loaded', () => { + it( 'should be loaded', () => { expect( editor.plugins.get( WidgetTypeAround ) ).to.be.instanceOf( WidgetTypeAround ); } ); - it( 'requires the Paragraph plugin', () => { - expect( WidgetTypeAround.requires ).to.deep.equal( [ Paragraph ] ); + it( 'should have a name', () => { + expect( WidgetTypeAround.pluginName ).to.equal( 'WidgetTypeAround' ); } ); } ); @@ -66,10 +68,10 @@ describe( 'WidgetTypeAround', () => { it( 'should execute the "insertParagraph" command when inserting a paragraph before the widget', () => { setModelData( editor.model, '' ); - plugin._insertParagraph( viewRoot.getChild( 0 ), 'before' ); + plugin._insertParagraph( modelRoot.getChild( 0 ), 'before' ); const spyExecutePosition = executeSpy.firstCall.args[ 1 ].position; - const positionBeforeWidget = editor.model.createPositionBefore( editor.model.document.getRoot().getChild( 0 ) ); + const positionBeforeWidget = editor.model.createPositionBefore( modelRoot.getChild( 0 ) ); sinon.assert.calledOnce( executeSpy ); sinon.assert.calledWith( executeSpy, 'insertParagraph' ); @@ -82,10 +84,10 @@ describe( 'WidgetTypeAround', () => { it( 'should execute the "insertParagraph" command when inserting a paragraph after the widget', () => { setModelData( editor.model, '' ); - plugin._insertParagraph( viewRoot.getChild( 0 ), 'after' ); + plugin._insertParagraph( modelRoot.getChild( 0 ), 'after' ); const spyExecutePosition = executeSpy.firstCall.args[ 1 ].position; - const positionAfterWidget = editor.model.createPositionAfter( editor.model.document.getRoot().getChild( 0 ) ); + const positionAfterWidget = editor.model.createPositionAfter( modelRoot.getChild( 0 ) ); sinon.assert.calledOnce( executeSpy ); sinon.assert.calledWith( executeSpy, 'insertParagraph' ); @@ -100,7 +102,7 @@ describe( 'WidgetTypeAround', () => { setModelData( editor.model, '' ); - plugin._insertParagraph( viewRoot.getChild( 0 ), 'after' ); + plugin._insertParagraph( modelRoot.getChild( 0 ), 'after' ); sinon.assert.calledOnce( spy ); } ); @@ -110,7 +112,7 @@ describe( 'WidgetTypeAround', () => { setModelData( editor.model, '' ); - plugin._insertParagraph( viewRoot.getChild( 0 ), 'after' ); + plugin._insertParagraph( modelRoot.getChild( 0 ), 'after' ); sinon.assert.calledOnce( spy ); } ); @@ -165,6 +167,21 @@ describe( 'WidgetTypeAround', () => { expect( domWrapper.querySelectorAll( '.ck-widget__type-around__button' ) ).to.have.length( 2 ); } ); + it( 'should inject a fake caret into the wrapper', () => { + setModelData( editor.model, '' ); + + const viewWidget = viewRoot.getChild( 0 ); + + expect( viewWidget.getChild( 1 ).is( 'uiElement' ) ).to.be.true; + expect( viewWidget.getChild( 1 ).hasClass( 'ck' ) ).to.be.true; + expect( viewWidget.getChild( 1 ).hasClass( 'ck-reset_all' ) ).to.be.true; + expect( viewWidget.getChild( 1 ).hasClass( 'ck-widget__type-around' ) ).to.be.true; + + const domWrapper = editingView.domConverter.viewToDom( viewWidget.getChild( 1 ) ); + + expect( domWrapper.querySelectorAll( '.ck-widget__type-around__fake-caret' ) ).to.have.length( 1 ); + } ); + describe( 'UI button to type around', () => { let buttonBefore, buttonAfter; @@ -174,8 +191,8 @@ describe( 'WidgetTypeAround', () => { const viewWidget = viewRoot.getChild( 0 ); const domWrapper = editingView.domConverter.viewToDom( viewWidget.getChild( 1 ) ); - buttonBefore = domWrapper.firstChild; - buttonAfter = domWrapper.lastChild; + buttonBefore = domWrapper.children[ 0 ]; + buttonAfter = domWrapper.children[ 1 ]; } ); it( 'should have proper CSS classes', () => { @@ -209,10 +226,24 @@ describe( 'WidgetTypeAround', () => { viewDocument.fire( eventInfo, domEventDataMock ); sinon.assert.calledOnce( typeAroundSpy ); - sinon.assert.calledWithExactly( typeAroundSpy, viewRoot.getChild( 1 ), 'before' ); + sinon.assert.calledWithExactly( typeAroundSpy, modelRoot.getChild( 1 ), 'before' ); sinon.assert.calledOnce( preventDefaultSpy ); sinon.assert.calledOnce( stopSpy ); } ); + + it( 'should not cause WidgetTypeAround#_insertParagraph() when clicked something other than the button', () => { + const typeAroundSpy = sinon.spy( plugin, '_insertParagraph' ); + + const eventInfo = new EventInfo( viewDocument, 'mousedown' ); + const domEventDataMock = new DomEventData( editingView, { + // Clicking a widget. + target: editingView.domConverter.viewToDom( viewRoot.getChild( 0 ) ), + preventDefault: sinon.spy() + } ); + + viewDocument.fire( eventInfo, domEventDataMock ); + sinon.assert.notCalled( typeAroundSpy ); + } ); } ); describe( 'button to type "after" a widget', () => { @@ -238,7 +269,7 @@ describe( 'WidgetTypeAround', () => { viewDocument.fire( eventInfo, domEventDataMock ); sinon.assert.calledOnce( typeAroundSpy ); - sinon.assert.calledWithExactly( typeAroundSpy, viewRoot.getChild( 0 ), 'after' ); + sinon.assert.calledWithExactly( typeAroundSpy, modelRoot.getChild( 0 ), 'after' ); sinon.assert.calledOnce( preventDefaultSpy ); sinon.assert.calledOnce( stopSpy ); } ); @@ -253,75 +284,1038 @@ describe( 'WidgetTypeAround', () => { } ); } ); - describe( 'detection and CSS classes of widgets needing the typing around support', () => { - it( 'should detect widgets that are a first child of the parent container', () => { - setModelData( editor.model, 'foo' ); + describe( 'typing around view widgets using keyboard', () => { + let model, modelSelection, eventInfoStub, domEventDataStub; - const viewWidget = viewRoot.getChild( 0 ); + beforeEach( () => { + model = editor.model; + modelSelection = model.document.selection; + } ); + + describe( '"fake caret" activation', () => { + it( 'should activate before when the collapsed selection is before a widget and the navigation is forward', () => { + setModelData( editor.model, 'foo[]' ); + + fireKeyboardEvent( 'arrowright' ); + + expect( getModelData( model ) ).to.equal( 'foo[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'before' ); + + const viewWidget = viewRoot.getChild( 1 ); + + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_before' ) ).to.be.true; + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_after' ) ).to.be.false; + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + } ); + + it( 'should activate after when the collapsed selection is after a widget and the navigation is backward', () => { + setModelData( editor.model, '[]foo' ); + + fireKeyboardEvent( 'arrowleft' ); + + expect( getModelData( model ) ).to.equal( '[]foo' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'after' ); + + const viewWidget = viewRoot.getChild( 0 ); + + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_before' ) ).to.be.false; + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_after' ) ).to.be.true; + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + } ); + + it( 'should activate after when the widget is selected and the navigation is forward', () => { + setModelData( editor.model, '[]' ); + + fireKeyboardEvent( 'arrowright' ); + + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'after' ); + + const viewWidget = viewRoot.getChild( 0 ); + + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_before' ) ).to.be.false; + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_after' ) ).to.be.true; + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + } ); + + it( 'should activate before when the widget is selected and the navigation is backward', () => { + setModelData( editor.model, '[]' ); + + fireKeyboardEvent( 'arrowleft' ); + + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'before' ); + + const viewWidget = viewRoot.getChild( 0 ); + + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_before' ) ).to.be.true; + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_after' ) ).to.be.false; + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + } ); + + it( 'should activate if an arrow key is pressed along with Shift', () => { + setModelData( editor.model, 'foo[]' ); + + fireKeyboardEvent( 'arrowright', { shiftKey: true } ); + + expect( getModelData( model ) ).to.equal( 'foo[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'before' ); + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + } ); + + it( 'should not activate when the selection is before the widget but the non-arrow key was pressed', () => { + setModelData( editor.model, 'foo[]' ); + + fireKeyboardEvent( 'a' ); + fireMutation( 'a' ); + + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + expect( getModelData( model ) ).to.equal( 'fooa[]' ); + + const viewWidget = viewRoot.getChild( 1 ); + + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_before' ) ).to.be.false; + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_after' ) ).to.be.false; + + sinon.assert.notCalled( eventInfoStub.stop ); + sinon.assert.notCalled( domEventDataStub.domEvent.preventDefault ); + } ); + + it( 'should not activate when the selection is not before the widget and navigating forward', () => { + setModelData( editor.model, 'fo[]o' ); + + fireKeyboardEvent( 'arrowright' ); + + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + + const viewWidget = viewRoot.getChild( 1 ); + + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_before' ) ).to.be.false; + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_after' ) ).to.be.false; + + sinon.assert.notCalled( eventInfoStub.stop ); + sinon.assert.notCalled( domEventDataStub.domEvent.preventDefault ); + } ); + + it( 'should not activate when the selection is not after the widget and navigating backward', () => { + setModelData( editor.model, 'f[]oo' ); + + fireKeyboardEvent( 'arrowleft' ); + + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + + const viewWidget = viewRoot.getChild( 0 ); + + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_before' ) ).to.be.false; + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_after' ) ).to.be.false; + + sinon.assert.notCalled( eventInfoStub.stop ); + sinon.assert.notCalled( domEventDataStub.domEvent.preventDefault ); + } ); + + it( 'should not activate when the non-collapsed selection is before the widget and navigating forward', () => { + setModelData( editor.model, 'fo[o]' ); + + fireKeyboardEvent( 'arrowright' ); + + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + + const viewWidget = viewRoot.getChild( 1 ); + + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_before' ) ).to.be.false; + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_after' ) ).to.be.false; + + sinon.assert.notCalled( eventInfoStub.stop ); + sinon.assert.notCalled( domEventDataStub.domEvent.preventDefault ); + } ); + + it( 'should not activate when the non-collapsed selection is after the widget and navigating backward', () => { + setModelData( editor.model, '[f]oo' ); + + fireKeyboardEvent( 'arrowleft' ); + + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + + const viewWidget = viewRoot.getChild( 0 ); + + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_before' ) ).to.be.false; + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_after' ) ).to.be.false; + + sinon.assert.notCalled( eventInfoStub.stop ); + sinon.assert.notCalled( domEventDataStub.domEvent.preventDefault ); + } ); - assertIsTypeAroundBefore( viewWidget, true ); - assertIsTypeAroundAfter( viewWidget, false ); + it( 'should not activate selection downcast when a non–type-around-friendly widget is selected', () => { + setModelData( editor.model, 'foo[]' ); + + model.change( writer => { + // Simply trigger the selection downcast. + writer.setSelectionAttribute( 'foo', 'bar' ); + } ); + + const viewWidget = viewRoot.getChild( 0 ).getChild( 1 ); + + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_before' ) ).to.be.false; + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_after' ) ).to.be.false; + + sinon.assert.notCalled( eventInfoStub.stop ); + sinon.assert.notCalled( domEventDataStub.domEvent.preventDefault ); + } ); } ); - it( 'should detect widgets that are a last child of the parent container', () => { - setModelData( editor.model, 'foo' ); + describe( '"fake caret" deactivation', () => { + it( 'should deactivate when the widget is selected and the navigation is backward to a valid position', () => { + setModelData( editor.model, 'foo[]' ); + + fireKeyboardEvent( 'arrowleft' ); + + expect( getModelData( model ) ).to.equal( 'foo[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'before' ); + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + + fireKeyboardEvent( 'arrowleft' ); + + expect( getModelData( model ) ).to.equal( 'foo[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + + const viewWidget = viewRoot.getChild( 1 ); + + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_before' ) ).to.be.false; + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_after' ) ).to.be.false; + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + } ); + + it( 'should deactivate when the widget is selected and the navigation is forward to a valid position', () => { + setModelData( editor.model, '[]foo' ); + + fireKeyboardEvent( 'arrowright' ); + + expect( getModelData( model ) ).to.equal( '[]foo' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'after' ); + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + + fireKeyboardEvent( 'arrowright' ); + + expect( getModelData( model ) ).to.equal( '[]foo' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + + const viewWidget = viewRoot.getChild( 0 ); + + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_before' ) ).to.be.false; + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_after' ) ).to.be.false; + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + } ); + + it( 'should deactivate if an arrow key is pressed along with Shift', () => { + setModelData( editor.model, 'foo[]' ); + + fireKeyboardEvent( 'arrowleft', { shiftKey: true } ); + + expect( getModelData( model ) ).to.equal( 'foo[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'before' ); + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + + fireKeyboardEvent( 'arrowleft', { shiftKey: true } ); + + expect( getModelData( model ) ).to.equal( 'foo[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + } ); + + it( 'should not deactivate when the widget is selected and the navigation is backward but there is nowhere to go', () => { + setModelData( editor.model, '[]' ); + + fireKeyboardEvent( 'arrowleft' ); + + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'before' ); + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + + fireKeyboardEvent( 'arrowleft' ); + + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'before' ); + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + + const viewWidget = viewRoot.getChild( 0 ); + + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_before' ) ).to.be.true; + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_after' ) ).to.be.false; + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + } ); + + it( 'should not deactivate when the widget is selected and the navigation is forward but there is nowhere to go', () => { + setModelData( editor.model, '[]' ); + + fireKeyboardEvent( 'arrowright' ); + + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'after' ); + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + + fireKeyboardEvent( 'arrowright' ); + + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'after' ); + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + + const viewWidget = viewRoot.getChild( 0 ); + + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_before' ) ).to.be.false; + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_after' ) ).to.be.true; + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + } ); + + it( 'should deactivate when the widget is selected and the navigation is against the fake caret (backward)', () => { + setModelData( editor.model, '[]' ); + + fireKeyboardEvent( 'arrowleft' ); + + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'before' ); + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + + fireKeyboardEvent( 'arrowright' ); + + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + + const viewWidget = viewRoot.getChild( 0 ); + + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_before' ) ).to.be.false; + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_after' ) ).to.be.false; + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + } ); + + it( 'should deactivate when the widget is selected and the navigation is against the fake caret (forward)', () => { + setModelData( editor.model, '[]' ); + + fireKeyboardEvent( 'arrowright' ); + + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'after' ); + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + + fireKeyboardEvent( 'arrowleft' ); + + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + + const viewWidget = viewRoot.getChild( 0 ); + + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_before' ) ).to.be.false; + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_after' ) ).to.be.false; + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + } ); + } ); + + it( 'should activate and deactivate the "fake caret" using all 4 arrow keys', () => { + setModelData( editor.model, 'foo[]' ); + + fireKeyboardEvent( 'arrowright' ); + + expect( getModelData( model ) ).to.equal( 'foo[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'before' ); + + fireKeyboardEvent( 'arrowdown' ); + + expect( getModelData( model ) ).to.equal( 'foo[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + + fireKeyboardEvent( 'arrowup' ); + + expect( getModelData( model ) ).to.equal( 'foo[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'before' ); + + fireKeyboardEvent( 'arrowleft' ); + + expect( getModelData( model ) ).to.equal( 'foo[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + } ); + + it( 'should quit the "fake caret" mode when the editor loses focus', () => { + editor.ui.focusTracker.isFocused = true; + + setModelData( editor.model, 'foo[]' ); + + fireKeyboardEvent( 'arrowright' ); + + expect( getModelData( model ) ).to.equal( 'foo[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'before' ); + + editor.ui.focusTracker.isFocused = false; const viewWidget = viewRoot.getChild( 1 ); - assertIsTypeAroundBefore( viewWidget, false ); - assertIsTypeAroundAfter( viewWidget, true ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_before' ) ).to.be.false; + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_after' ) ).to.be.false; } ); - it( 'should not detect widgets that are surrounded by sibling which allow the selection', () => { - setModelData( editor.model, 'foobar' ); + it( 'should quit the "fake caret" mode when the user changed the selection', () => { + setModelData( editor.model, 'foo[]' ); + + fireKeyboardEvent( 'arrowright' ); + + expect( getModelData( model ) ).to.equal( 'foo[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'before' ); + + model.change( writer => { + writer.setSelection( model.document.getRoot().getChild( 0 ), 'in' ); + } ); const viewWidget = viewRoot.getChild( 1 ); - assertIsTypeAroundBefore( viewWidget, false ); - assertIsTypeAroundAfter( viewWidget, false ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_before' ) ).to.be.false; + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_after' ) ).to.be.false; } ); - it( 'should detect widgets that have another block widget as a next sibling', () => { - setModelData( editor.model, '' ); + it( 'should not quit the "fake caret" mode when the selection changed as a result of an indirect change', () => { + setModelData( editor.model, 'foo[]' ); + + fireKeyboardEvent( 'arrowright' ); + + expect( getModelData( model ) ).to.equal( 'foo[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'before' ); + + // This could happen in collaboration. + model.document.selection.fire( 'change:range', { + directChange: false + } ); - const firstViewWidget = viewRoot.getChild( 0 ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'before' ); - assertIsTypeAroundBefore( firstViewWidget, true ); - assertIsTypeAroundAfter( firstViewWidget, true ); + const viewWidget = viewRoot.getChild( 1 ); + + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_before' ) ).to.be.true; + expect( viewWidget.hasClass( 'ck-widget_type-around_show-fake-caret_after' ) ).to.be.false; } ); - it( 'should detect widgets that have another block widget as a previous sibling', () => { - setModelData( editor.model, '' ); + describe( 'inserting a new paragraph', () => { + describe( 'on Enter key press when the "fake caret" is activated', () => { + it( 'should insert a paragraph before a widget if the caret was "before" it', () => { + setModelData( editor.model, '[]' ); + + fireKeyboardEvent( 'arrowleft' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'before' ); + + fireKeyboardEvent( 'enter' ); + expect( getModelData( model ) ).to.equal( '[]' ); + } ); + + it( 'should insert a paragraph after a widget if the caret was "after" it', () => { + setModelData( editor.model, '[]' ); + + fireKeyboardEvent( 'arrowright' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'after' ); + + fireKeyboardEvent( 'enter' ); + expect( getModelData( model ) ).to.equal( '[]' ); + } ); + + it( 'should integrate with the undo feature', () => { + setModelData( editor.model, '[]' ); + + fireKeyboardEvent( 'arrowleft' ); + fireKeyboardEvent( 'enter' ); + + expect( getModelData( model ) ).to.equal( '[]' ); + + editor.execute( 'undo' ); + + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + } ); + } ); + + describe( 'on Enter key press when the widget is selected (no "fake caret", though)', () => { + it( 'should insert a new paragraph after the widget if Enter was pressed', () => { + setModelData( editor.model, '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + + fireKeyboardEvent( 'enter' ); + + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + } ); + + it( 'should insert a new paragraph before the widget if Shift+Enter was pressed', () => { + setModelData( editor.model, '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + + fireKeyboardEvent( 'enter', { shiftKey: true } ); + + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + } ); + + it( 'should insert a new paragraph only if an entire widget is selected (selected nested editable content)', () => { + setModelData( editor.model, '[foo] bar' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + + fireKeyboardEvent( 'enter' ); + + expect( getModelData( model ) ).to.equal( '[] bar' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + } ); + + it( 'should insert a new paragraph only if an entire widget is selected (selected widget siblings)', () => { + setModelData( editor.model, 'f[ooo]o' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + + fireKeyboardEvent( 'enter' ); + + expect( getModelData( model ) ).to.equal( 'f[]o' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + } ); + + it( 'should split ancestors to find a place that allows a widget', () => { + model.schema.register( 'allowP', { + inheritAllFrom: '$block' + } ); + model.schema.register( 'disallowP', { + inheritAllFrom: '$block', + allowIn: [ 'allowP' ] + } ); + model.schema.extend( 'blockWidget', { + allowIn: [ 'allowP', 'disallowP' ] + } ); + model.schema.extend( 'paragraph', { + allowIn: [ 'allowP' ] + } ); + + editor.conversion.for( 'downcast' ).elementToElement( { model: 'allowP', view: 'allowP' } ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'disallowP', view: 'disallowP' } ); + + setModelData( model, + '' + + '[]' + + '' + ); + + fireKeyboardEvent( 'enter' ); + + expect( getModelData( model ) ).to.equal( + '' + + '' + + '[]' + + '' + + '' + ); + } ); + + it( 'should integrate with the undo feature', () => { + setModelData( editor.model, '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + + fireKeyboardEvent( 'enter' ); + + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + + editor.execute( 'undo' ); + + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + } ); + + it( 'should do nothing if a non-type-around-friendly content is selected', () => { + setModelData( editor.model, 'foo[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + + fireKeyboardEvent( 'enter' ); + + expect( getModelData( model ) ).to.equal( 'foo[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + } ); + } ); + + describe( 'on keydown of a "typing" character when the "fake caret" is activated ', () => { + it( 'should insert a character inside a new paragraph before a widget if the caret was "before" it', () => { + setModelData( editor.model, '[]' ); + + fireKeyboardEvent( 'arrowleft' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'before' ); - const lastViewWidget = viewRoot.getChild( 1 ); + fireKeyboardEvent( 'a' ); + fireMutation( 'a' ); + + expect( getModelData( model ) ).to.equal( 'a[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + } ); + + it( 'should insert a character inside a new paragraph after a widget if the caret was "after" it', () => { + setModelData( editor.model, '[]' ); + + fireKeyboardEvent( 'arrowright' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'after' ); + + fireKeyboardEvent( 'a' ); + fireMutation( 'a' ); + + expect( getModelData( model ) ).to.equal( 'a[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + } ); + + it( 'should do nothing if a "safe" keystroke was pressed', () => { + setModelData( editor.model, '[]' ); + + fireKeyboardEvent( 'arrowright' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'after' ); + + fireKeyboardEvent( 'esc' ); + fireKeyboardEvent( 'tab' ); + fireKeyboardEvent( 'd', { ctrlKey: true } ); + + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'after' ); + } ); - assertIsTypeAroundBefore( lastViewWidget, true ); - assertIsTypeAroundAfter( lastViewWidget, true ); + it( 'should integrate with the undo feature', () => { + setModelData( editor.model, '[]' ); + + fireKeyboardEvent( 'arrowleft' ); + fireKeyboardEvent( 'a' ); + fireMutation( 'a' ); + + expect( getModelData( model ) ).to.equal( 'a[]' ); + + editor.execute( 'undo' ); + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + + editor.execute( 'undo' ); + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + } ); + } ); } ); - it( 'should not detect inline widgets even if they fall in previous categories', () => { - setModelData( editor.model, - '' - ); + describe( 'delete integration', () => { + let eventInfoStub, domEventDataStub; + + describe( 'backward delete', () => { + it( 'should delete content before a widget if the "fake caret" is also before the widget', () => { + setModelData( editor.model, 'foo[]' ); + + fireKeyboardEvent( 'arrowleft' ); + + expect( getModelData( model ) ).to.equal( 'foo[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'before' ); + + fireDeleteEvent(); + expect( getModelData( model ) ).to.equal( 'fo[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; - const firstViewWidget = viewRoot.getChild( 0 ).getChild( 0 ); - const lastViewWidget = viewRoot.getChild( 0 ).getChild( 1 ); + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + } ); + + it( 'should delete an empty paragraph before a widget if the "fake caret" is also before the widget', () => { + setModelData( editor.model, '[]' ); + + fireKeyboardEvent( 'arrowleft' ); + + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'before' ); + + fireDeleteEvent(); + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'before' ); + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + } ); + + it( 'should delete an empty document tree branch before a widget if the "fake caret" is also before the widget', () => { + setModelData( editor.model, '
[]' ); + + fireKeyboardEvent( 'arrowleft' ); - assertIsTypeAroundBefore( firstViewWidget, false ); - assertIsTypeAroundAfter( firstViewWidget, false ); + expect( getModelData( model ) ).to.equal( + '
' + + '' + + '
' + + '[]' + ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'before' ); + + fireDeleteEvent(); + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'before' ); + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + } ); - assertIsTypeAroundBefore( lastViewWidget, false ); - assertIsTypeAroundAfter( lastViewWidget, false ); + it( 'should delete an empty document tree sub-branch before a widget if the "fake caret" is also before the widget', () => { + let operationType; + + setModelData( editor.model, + '
' + + 'foo' + + '' + + '
' + + '[]' + ); + + fireKeyboardEvent( 'arrowleft' ); + + expect( getModelData( model ) ).to.equal( + '
' + + 'foo' + + '' + + '
' + + '[]' + ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'before' ); + + // Assert that the paragraph is merged rather than deleted because + // it is safer for collaboration. + model.on( 'applyOperation', ( evt, [ operation ] ) => { + operationType = operation.type; + } ); + + fireDeleteEvent(); + expect( getModelData( model ) ).to.equal( + '
' + + 'foo[]' + + '
' + + '' + ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + expect( operationType ).to.equal( 'merge' ); + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + } ); + + it( 'should do nothing if the "fake caret" is before the widget but there is nothing to delete there', () => { + setModelData( editor.model, '[]' ); + + fireKeyboardEvent( 'arrowleft' ); + + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'before' ); + + fireDeleteEvent(); + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'before' ); + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + } ); + + it( 'should delete a widget if the "fake caret" is after the widget (no content after the widget)', () => { + setModelData( editor.model, '[]' ); + + fireKeyboardEvent( 'arrowright' ); + + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'after' ); + + fireDeleteEvent(); + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + } ); + + it( 'should delete a widget if the "fake caret" is after the widget (some content after the widget)', () => { + setModelData( editor.model, '[]foo' ); + + fireKeyboardEvent( 'arrowright' ); + + expect( getModelData( model ) ).to.equal( '[]foo' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'after' ); + + fireDeleteEvent(); + expect( getModelData( model ) ).to.equal( '[]foo' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + } ); + + it( 'should delete a sibling widget', () => { + setModelData( editor.model, 'foo[]' ); + + fireKeyboardEvent( 'arrowleft' ); + + expect( getModelData( model ) ).to.equal( + 'foo' + + '[]' + ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'before' ); + + fireDeleteEvent(); + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + } ); + } ); + + describe( 'forward delete', () => { + it( 'should delete content after a widget if the "fake caret" is also after the widget', () => { + setModelData( editor.model, '[]foo' ); + + fireKeyboardEvent( 'arrowright' ); + + expect( getModelData( model ) ).to.equal( '[]foo' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'after' ); + + fireDeleteEvent( true ); + expect( getModelData( model ) ).to.equal( '[]oo' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + } ); + + it( 'should delete an empty paragraph after a widget if the "fake caret" is also after the widget', () => { + setModelData( editor.model, '[]' ); + + fireKeyboardEvent( 'arrowright' ); + + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'after' ); + + fireDeleteEvent( true ); + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'after' ); + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + } ); + + it( 'should delete an empty document tree branch after a widget if the "fake caret" is also after the widget', () => { + setModelData( editor.model, '[]
' ); + + fireKeyboardEvent( 'arrowright' ); + + expect( getModelData( model ) ).to.equal( + '[]' + + '
' + ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'after' ); + + fireDeleteEvent( true ); + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'after' ); + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + } ); + + it( 'should delete an empty document tree sub-branch after a widget if the "fake caret" is also after the widget', () => { + let operationType; + + setModelData( editor.model, + '[]' + + '
' + + '' + + 'foo' + + '
' + ); + + fireKeyboardEvent( 'arrowright' ); + + expect( getModelData( model ) ).to.equal( + '[]' + + '
' + + '' + + 'foo' + + '
' + ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'after' ); + + // Assert that the paragraph is merged rather than deleted because + // it is safer for collaboration. + model.on( 'applyOperation', ( evt, [ operation ] ) => { + operationType = operation.type; + } ); + + fireDeleteEvent( true ); + expect( getModelData( model ) ).to.equal( + '' + + '
' + + '[]foo' + + '
' + ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + expect( operationType ).to.equal( 'merge' ); + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + } ); + + it( 'should do nothing if the "fake caret" is after the widget but there is nothing to delete there', () => { + setModelData( editor.model, '[]' ); + + fireKeyboardEvent( 'arrowright' ); + + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'after' ); + + fireDeleteEvent( true ); + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'after' ); + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + } ); + + it( 'should delete a widget if the "fake caret" is before the widget (no content before the widget)', () => { + setModelData( editor.model, '[]' ); + + fireKeyboardEvent( 'arrowleft' ); + + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'before' ); + + fireDeleteEvent( true ); + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + } ); + + it( 'should delete a widget if the "fake caret" is before the widget (some content before the widget)', () => { + setModelData( editor.model, 'foo[]' ); + + fireKeyboardEvent( 'arrowleft' ); + + expect( getModelData( model ) ).to.equal( 'foo[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'before' ); + + fireDeleteEvent( true ); + expect( getModelData( model ) ).to.equal( 'foo[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + } ); + + it( 'should delete a sibling widget', () => { + setModelData( editor.model, '[]foo' ); + + fireKeyboardEvent( 'arrowright' ); + + expect( getModelData( model ) ).to.equal( + '[]' + + 'foo' + ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.equal( 'after' ); + + fireDeleteEvent( true ); + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( 'widget-type-around' ) ).to.be.undefined; + + sinon.assert.calledOnce( eventInfoStub.stop ); + sinon.assert.calledOnce( domEventDataStub.domEvent.preventDefault ); + } ); + } ); + + function fireDeleteEvent( isForward = false ) { + eventInfoStub = new EventInfo( viewDocument, 'delete' ); + sinon.spy( eventInfoStub, 'stop' ); + + const data = { + direction: isForward ? 'forward' : 'backward', + unit: 'character' + }; + + domEventDataStub = new DomEventData( viewDocument, getDomEvent(), data ); + + viewDocument.fire( eventInfoStub, domEventDataStub ); + } } ); - } ); - function assertIsTypeAroundBefore( viewWidget, expected ) { - expect( viewWidget.hasClass( 'ck-widget_can-type-around_before' ) ).to.equal( expected ); - } + function getDomEvent() { + return { + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + } - function assertIsTypeAroundAfter( viewWidget, expected ) { - expect( viewWidget.hasClass( 'ck-widget_can-type-around_after' ) ).to.equal( expected ); - } + function fireKeyboardEvent( key, modifiers ) { + eventInfoStub = new EventInfo( viewDocument, 'keydown' ); + + sinon.spy( eventInfoStub, 'stop' ); + + const data = { + document: viewDocument, + domTarget: editingView.getDomRoot(), + keyCode: getCode( key ) + }; + + Object.assign( data, modifiers ); + + domEventDataStub = new DomEventData( viewDocument, getDomEvent(), data ); + + viewDocument.fire( eventInfoStub, domEventDataStub ); + } + + function fireMutation( text ) { + const placeOfMutation = viewDocument.selection.getFirstRange().start; + + viewDocument.fire( 'mutations', [ + { + type: 'children', + oldChildren: [], + newChildren: [ new ViewText( viewDocument, text ) ], + node: placeOfMutation + } + ] ); + } + } ); function blockWidgetPlugin( editor ) { editor.model.schema.register( 'blockWidget', { @@ -329,6 +1323,15 @@ describe( 'WidgetTypeAround', () => { isObject: true } ); + editor.model.schema.register( 'nested', { + allowIn: 'blockWidget', + isLimit: true + } ); + + editor.model.schema.extend( '$text', { + allowIn: [ 'nested' ] + } ); + editor.conversion.for( 'downcast' ) .elementToElement( { model: 'blockWidget', @@ -342,6 +1345,10 @@ describe( 'WidgetTypeAround', () => { label: 'block widget' } ); } + } ) + .elementToElement( { + model: 'nested', + view: ( modelItem, viewWriter ) => viewWriter.createEditableElement( 'nested', { contenteditable: true } ) } ); } diff --git a/packages/ckeditor5-widget/theme/widgettypearound.css b/packages/ckeditor5-widget/theme/widgettypearound.css index 08eca590299..34d4f0f709d 100644 --- a/packages/ckeditor5-widget/theme/widgettypearound.css +++ b/packages/ckeditor5-widget/theme/widgettypearound.css @@ -37,17 +37,6 @@ } } - /* - * Hide the type around buttons depending on which directions the widget supports. - */ - &:not(.ck-widget_can-type-around_before) > .ck-widget__type-around > .ck-widget__type-around__button_before { - display: none; - } - - &:not(.ck-widget_can-type-around_after) > .ck-widget__type-around > .ck-widget__type-around__button_after { - display: none; - } - /* * Styles for the buttons when: * - the widget is selected, @@ -64,6 +53,42 @@ z-index: calc(var(--ck-z-default) + 1); } } + + /* + * Styles for the horizontal "fake caret" which is displayed when the user navigates using the keyboard. + */ + & > .ck-widget__type-around > .ck-widget__type-around__fake-caret { + display: none; + position: absolute; + left: 0; + right: 0; + } + + /* + * When the widget is hovered the "fake caret" would normally be narrower than the + * extra outline displayed around the widget. Let's extend the "fake caret" to match + * the full width of the widget. + */ + &:hover > .ck-widget__type-around > .ck-widget__type-around__fake-caret { + left: calc( -1 * var(--ck-widget-outline-thickness) ); + right: calc( -1 * var(--ck-widget-outline-thickness) ); + } + + /* + * Styles for the horizontal "fake caret" when it should be displayed before the widget (backward keyboard navigation). + */ + &.ck-widget_type-around_show-fake-caret_before > .ck-widget__type-around > .ck-widget__type-around__fake-caret { + top: calc( -1 * var(--ck-widget-outline-thickness) - 1px ); + display: block; + } + + /* + * Styles for the horizontal "fake caret" when it should be displayed after the widget (forward keyboard navigation). + */ + &.ck-widget_type-around_show-fake-caret_after > .ck-widget__type-around > .ck-widget__type-around__fake-caret { + bottom: calc( -1 * var(--ck-widget-outline-thickness) - 1px ); + display: block; + } } /*