injectandroidbackspacemutationshandling.js 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. /**
  2. * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. /**
  6. * @module typing/utils/injectandroidbackspacenutationshandling
  7. */
  8. import Selection from '@ckeditor/ckeditor5-engine/src/model/selection';
  9. import diff from '@ckeditor/ckeditor5-utils/src/diff';
  10. import { containerChildrenMutated } from './utils';
  11. /**
  12. * Handles mutations triggered by <kbd>Backspace</kbd> on Android.
  13. * Due to the fact that on Android `keydown` events don't have the `keyCode` set, we are not able
  14. * to handle backspacing directly. We need to guess that from mutations which the IME
  15. * on Android caused.
  16. *
  17. * @param {module:core/editor/editor~Editor} editor The editor instance.
  18. */
  19. export default function injectAndroidBackspaceMutationsHandling( editor ) {
  20. const model = editor.model;
  21. const view = editor.editing.view;
  22. const selectionChangeToleranceMs = 200;
  23. let previousSelection = null;
  24. let currentSelection = new Selection( model.document.selection );
  25. let latestSelectionChangeMs = Date.now();
  26. model.document.selection.on( 'change', handleSelectionChange );
  27. view.document.on( 'mutations', handleMutations, { priority: 'highest' } );
  28. // Saves current and previous selection when it changes. Saved selections are used
  29. // to remove correct piece of content when `Backspace` mutations are detected.
  30. //
  31. // @param {Object} evt
  32. function handleSelectionChange( evt ) {
  33. const newSelection = new Selection( evt.source );
  34. if ( !currentSelection.isEqual( newSelection ) ) {
  35. previousSelection = currentSelection;
  36. currentSelection = newSelection;
  37. latestSelectionChangeMs = Date.now();
  38. }
  39. }
  40. // Handles DOM mutations and checks if they should be processed as block elements removal mutations.
  41. //
  42. // @param {Object} evt
  43. // @param {Array.<module:engine/view/observer/mutationobserver~MutatedText|
  44. // module:engine/view/observer/mutationobserver~MutatedChildren>} mutations
  45. function handleMutations( evt, mutations ) {
  46. if ( containerChildrenMutated( mutations ) && containsContainersRemoval( mutations ) ) {
  47. handleContainerRemovalMutations();
  48. // Stop mutations event propagation so other mutation handlers are not triggered.
  49. evt.stop();
  50. }
  51. }
  52. // Handles situations when mutations were generated by container removal.
  53. // It happens on Android devices where every typing input has `229` key code
  54. // and delete observer will not be triggered. In such cases we need to handle
  55. // container removal mutations manually.
  56. function handleContainerRemovalMutations() {
  57. if ( shouldUsePreviousSelection() ) {
  58. // If previous selection is used, update model selection in order
  59. // to use `delete` command and to make `undo` work correctly.
  60. model.enqueueChange( writer => {
  61. writer.setSelection( previousSelection );
  62. } );
  63. }
  64. editor.execute( 'delete' );
  65. }
  66. // Whether previously saved selection should be used instead of the current one to remove content.
  67. //
  68. // On Android devices when pressing backspace on non-collapsed selection, selection like:
  69. //
  70. // `<h1>[Foo</h1><p>Bar]</p>`
  71. //
  72. // is changed to:
  73. //
  74. // `<h1>Foo</h1><p>Bar[]</p>`
  75. //
  76. // even before `keypress` event, so in such cases we have to rely on previous selection to correctly process selected content.
  77. //
  78. // Previous selection will be used if:
  79. //
  80. // * current selection is collapsed (see example above),
  81. // * previous selection exists, is non-collapsed and has same ending (last position) as the current one,
  82. // * change of the selection happened not earlier than X milliseconds ago (see `selectionChangeToleranceMs`).
  83. //
  84. // The last check is needed, because user can manually collapse the selection on its current end and then press `Backspace`.
  85. // In such situations timing determines if the selection change was caused by the user or browser native behaviour.
  86. // However, this happens only if selection was collapsed by the user on the beginning of the paragraph (so mutations
  87. // still will show container removal).
  88. //
  89. // @returns {Boolean}
  90. function shouldUsePreviousSelection() {
  91. return Date.now() - latestSelectionChangeMs < selectionChangeToleranceMs &&
  92. previousSelection && !previousSelection.isCollapsed && currentSelection.isCollapsed &&
  93. currentSelection.getLastPosition().isEqual( previousSelection.getLastPosition() );
  94. }
  95. }
  96. // Checks whether mutations array contains mutation generated by container/containers removal.
  97. // For example mutations generated on Android when pressing `backspace` on the beginning of the line:
  98. //
  99. // <h1>Header1</h1>
  100. // <p>{}Paragraph</p>
  101. //
  102. // are:
  103. //
  104. // [
  105. // { newChildren: [], oldChildren: [ 'Paragraph' ], node: P, type: 'children' },
  106. // { newChildren: [ ContainerElement ], oldChildren: [ ContainerElement, ContainerElement ], node: Root, type: 'children' },
  107. // { newChildren: [ 'Heading 1Paragraph' ], oldChildren: [ 'Heading 1' ], node: H1, type: 'children' }
  108. // ]
  109. //
  110. // The 1st and 3rd mutations are just changes in a text (1st - text in `p` element was removed, 3rd - text in `h2` was changed)
  111. // and the 2nd one shows that one `ContainerElement` was removed. We have to recognize if mutations like 2nd one are present.
  112. // Based on that heuristic mutations are treated as the one removing container element.
  113. //
  114. // @private
  115. // @param {Array.<module:engine/view/observer/mutationobserver~MutatedText|
  116. // module:engine/view/observer/mutationobserver~MutatedChildren>} mutations
  117. // @returns {Boolean}
  118. function containsContainersRemoval( mutations ) {
  119. for ( const mutation of mutations ) {
  120. if ( mutation.type !== 'children' ) {
  121. continue;
  122. }
  123. const childrenBefore = mutation.oldChildren;
  124. const childrenAfter = mutation.newChildren;
  125. // Check if only containers were present before the mutation.
  126. if ( !hasOnlyContainers( childrenBefore ) ) {
  127. continue;
  128. }
  129. const diffResult = diff( childrenBefore, childrenAfter );
  130. // Check if there was only removing in that mutation without any insertions.
  131. const hasDelete = diffResult.some( item => item === 'delete' );
  132. const hasInsert = diffResult.some( item => item === 'insert' );
  133. if ( hasDelete && !hasInsert ) {
  134. return true;
  135. }
  136. }
  137. return false;
  138. }
  139. // Whether provided array contains only nodes of `containerElement` type.
  140. //
  141. // @private
  142. // @param {Array.<module:engine/view/node~Node>} children
  143. // @returns {Boolean}
  144. function hasOnlyContainers( children ) {
  145. return children.every( child => child.is( 'containerElement' ) );
  146. }