/** * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. * For licensing, see LICENSE.md. */ /** * @module typing/utils/injectAndroidBackspaceMutationsHandling */ import Selection from '@ckeditor/ckeditor5-engine/src/model/selection'; import diff from '@ckeditor/ckeditor5-utils/src/diff'; import { containerChildrenMutated } from '../utils'; /** * Handles mutations responsible for block elements removal generated by `Backspace` key on Android. * * @param {module:core/editor/editor~Editor} editor The editor instance. */ export default function injectAndroidBackspaceMutationsHandling( editor ) { const model = editor.model; const view = editor.editing.view; const selectionChangeToleranceMs = 200; let previousSelection = null; let currentSelection = new Selection( model.document.selection ); let latestSelectionChangeMs = Date.now(); model.document.selection.on( 'change', handleSelectionChange ); view.document.on( 'mutations', handleMutations, { priority: 'highest' } ); // Saves current and previous selection when it changes. Saved selections are used // to remove correct piece of content when `Backspace` mutations are detected. // // @param {Object} evt function handleSelectionChange( evt ) { const newSelection = new Selection( evt.source ); if ( !currentSelection.isEqual( newSelection ) ) { previousSelection = currentSelection; currentSelection = newSelection; latestSelectionChangeMs = Date.now(); } } // Handles DOM mutations and checks if they should be processed as block elements removal mutations. // // @param {Object} evt // @param {Array.} mutations function handleMutations( evt, mutations ) { if ( containerChildrenMutated( mutations ) && containsContainersRemoval( mutations ) ) { handleContainerRemovalMutations(); // Stop mutations event propagation so other mutation handlers are not triggered. evt.stop(); } } // Handles situations when mutations were generated by container removal. // It happens on Android devices where every typing input has `229` key code // and delete observer will not be triggered. In such cases we need to handle // container removal mutations manually. function handleContainerRemovalMutations() { if ( shouldUsePreviousSelection() ) { // If previous selection is used, update model selection in order // to use `delete` command and to make `undo` work correctly. model.enqueueChange( writer => { writer.setSelection( previousSelection ); } ); } editor.execute( 'delete' ); } // Whether previously saved selection should be used to remove content instead of the current one. // // On Android devices when pressing backspace on non-collapsed selection, selection like: // // `

[Foo

Bar]

` // // is changed to: // // `

Foo

Bar[]

` // // even before `keypress` event, so in such cases we have to rely on previous selection to correctly process selected content. // // Previous selection will be used if: // // * current selection is collapsed (see example above), // * previous selection exists, is non-collapsed and has same ending (last position) as the current one, // * change of the selection happened not earlier than X milliseconds ago (see `selectionChangeToleranceMs`). // // The last check is needed, because user can manually collapse the selection on its current end and then press `Backspace`. // In such situations timing determines if selection change was caused by the user or browser native behaviour. // However, this happens only if selection is collapsed by the user on the beginning of the paragraph (so mutations // still will show container removal). // // @returns {Boolean} function shouldUsePreviousSelection() { return Date.now() - latestSelectionChangeMs < selectionChangeToleranceMs && previousSelection && !previousSelection.isCollapsed && currentSelection.isCollapsed && currentSelection.getLastPosition().isEqual( previousSelection.getLastPosition() ); } } // Checks whether mutations array contains mutation generated by container/containers removal. // For example mutations generated on Android when pressing `backspace` on the beginning of the line: // //

Header1

//

{}Paragraph

// // are: // // [ // { newChildren: [], oldChildren: [ 'Paragraph' ], node: P, type: 'children' }, // { newChildren: [ ContainerElement ], oldChildren: [ ContainerElement, ContainerElement ], node: Root, type: 'children' }, // { newChildren: [ 'Heading 1Paragraph' ], oldChildren: [ 'Heading 1' ], node: H1, type: 'children' } // ] // // The 1st and 3rd mutations show just changes in a text (1st - text in `p` element was removed, 3rd - text in `h2` was changed) // and the 2nd one shows that one `ContainerElement` was removed. We have to recognize if mutations like 2nd one are present. // Based on that heuristic mutations are treated as the one removing container element. // // @private // @param {Array.} mutations // @returns {Boolean} function containsContainersRemoval( mutations ) { for ( const mutation of mutations ) { if ( mutation.type !== 'children' ) { continue; } const childrenBefore = mutation.oldChildren; const childrenAfter = mutation.newChildren; // Check if only containers were present before the mutation. if ( !hasOnlyContainers( childrenBefore ) ) { continue; } const diffResult = diff( childrenBefore, childrenAfter ); // Check if there was only removing in that mutation without any insertions. const hasDelete = diffResult.some( item => item === 'delete' ); const hasInsert = diffResult.some( item => item === 'insert' ); if ( hasDelete && !hasInsert ) { return true; } } return false; } // Returns true if provided array contains only nodes of `containerElement` type. // // @private // @param {Array.} children // @returns {Boolean} function hasOnlyContainers( children ) { return children.every( child => child.is( 'containerElement' ) ); }