injectandroidbackspacemutationshandling.js 6.1 KB

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