widget.js 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. /**
  2. * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. /**
  6. * @module widget/widget
  7. */
  8. import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
  9. import MouseObserver from '@ckeditor/ckeditor5-engine/src/view/observer/mouseobserver';
  10. import ModelRange from '@ckeditor/ckeditor5-engine/src/model/range';
  11. import ModelSelection from '@ckeditor/ckeditor5-engine/src/model/selection';
  12. import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element';
  13. import ViewEditableElement from '@ckeditor/ckeditor5-engine/src/view/editableelement';
  14. import RootEditableElement from '@ckeditor/ckeditor5-engine/src/view/rooteditableelement';
  15. import { isWidget, WIDGET_SELECTED_CLASS_NAME, getLabel } from './utils';
  16. import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
  17. import '../theme/theme.scss';
  18. /**
  19. * The widget plugin.
  20. * Registers model to view selection converter for editing pipeline. It is hooked after default selection conversion.
  21. * If converted selection is placed around widget element, selection is marked as fake. Additionally, proper CSS class
  22. * is added to indicate that widget has been selected.
  23. * Adds default {@link module:engine/view/document~Document#event:mousedown mousedown} handling on widget elements.
  24. *
  25. * @extends module:core/plugin~Plugin.
  26. */
  27. export default class Widget extends Plugin {
  28. /**
  29. * @inheritDoc
  30. */
  31. static get pluginName() {
  32. return 'Widget';
  33. }
  34. /**
  35. * @inheritDoc
  36. */
  37. init() {
  38. const viewDocument = this.editor.editing.view;
  39. let previouslySelected;
  40. // Model to view selection converter.
  41. // Converts selection placed over widget element to fake selection
  42. this.editor.editing.modelToView.on( 'selection', ( evt, data, consumable, conversionApi ) => {
  43. // Remove selected class from previously selected widget.
  44. if ( previouslySelected && previouslySelected.hasClass( WIDGET_SELECTED_CLASS_NAME ) ) {
  45. previouslySelected.removeClass( WIDGET_SELECTED_CLASS_NAME );
  46. }
  47. const viewSelection = conversionApi.viewSelection;
  48. // Check if widget was clicked or some sub-element.
  49. const selectedElement = viewSelection.getSelectedElement();
  50. if ( !selectedElement || !isWidget( selectedElement ) ) {
  51. return;
  52. }
  53. viewSelection.setFake( true, { label: getLabel( selectedElement ) } );
  54. selectedElement.addClass( WIDGET_SELECTED_CLASS_NAME );
  55. previouslySelected = selectedElement;
  56. }, { priority: 'low' } );
  57. // If mouse down is pressed on widget - create selection over whole widget.
  58. viewDocument.addObserver( MouseObserver );
  59. this.listenTo( viewDocument, 'mousedown', ( ...args ) => this._onMousedown( ...args ) );
  60. // Handle custom keydown behaviour.
  61. this.listenTo( viewDocument, 'keydown', ( ...args ) => this._onKeydown( ...args ), { priority: 'high' } );
  62. }
  63. /**
  64. * Handles {@link module:engine/view/document~Document#event:mousedown mousedown} events on widget elements.
  65. *
  66. * @private
  67. * @param {module:utils/eventinfo~EventInfo} eventInfo
  68. * @param {module:engine/view/observer/domeventdata~DomEventData} domEventData
  69. */
  70. _onMousedown( eventInfo, domEventData ) {
  71. const editor = this.editor;
  72. const viewDocument = editor.editing.view;
  73. let element = domEventData.target;
  74. // Do nothing if inside nested editable.
  75. if ( isInsideNestedEditable( element ) ) {
  76. return;
  77. }
  78. // If target is not a widget element - check if one of the ancestors is.
  79. if ( !isWidget( element ) ) {
  80. element = element.findAncestor( isWidget );
  81. if ( !element ) {
  82. return;
  83. }
  84. }
  85. domEventData.preventDefault();
  86. // Focus editor if is not focused already.
  87. if ( !viewDocument.isFocused ) {
  88. viewDocument.focus();
  89. }
  90. // Create model selection over widget.
  91. const modelElement = editor.editing.mapper.toModelElement( element );
  92. editor.document.enqueueChanges( ( ) => {
  93. this._setSelectionOverElement( modelElement );
  94. } );
  95. }
  96. /**
  97. * Handles {@link module:engine/view/document~Document#event:keydown keydown} events.
  98. *
  99. * @private
  100. * @param {module:utils/eventinfo~EventInfo} eventInfo
  101. * @param {module:engine/view/observer/domeventdata~DomEventData} domEventData
  102. */
  103. _onKeydown( eventInfo, domEventData ) {
  104. const keyCode = domEventData.keyCode;
  105. const isForward = keyCode == keyCodes.delete || keyCode == keyCodes.arrowdown || keyCode == keyCodes.arrowright;
  106. // Checks if delete/backspace or arrow keys were handled and then prevents default event behaviour and stops
  107. // event propagation.
  108. if ( ( isDeleteKeyCode( keyCode ) && this._handleDelete( isForward ) ) ||
  109. ( isArrowKeyCode( keyCode ) && this._handleArrowKeys( isForward ) ) ) {
  110. domEventData.preventDefault();
  111. eventInfo.stop();
  112. }
  113. }
  114. /**
  115. * Handles delete keys: backspace and delete.
  116. *
  117. * @private
  118. * @param {Boolean} isForward Set to true if delete was performed in forward direction.
  119. * @returns {Boolean|undefined} Returns `true` if keys were handled correctly.
  120. */
  121. _handleDelete( isForward ) {
  122. const modelDocument = this.editor.document;
  123. const modelSelection = modelDocument.selection;
  124. // Do nothing on non-collapsed selection.
  125. if ( !modelSelection.isCollapsed ) {
  126. return;
  127. }
  128. const objectElement = this._getObjectElementNextToSelection( isForward );
  129. if ( objectElement ) {
  130. modelDocument.enqueueChanges( () => {
  131. // Remove previous element if empty.
  132. const previousNode = modelSelection.anchor.parent;
  133. if ( previousNode.isEmpty ) {
  134. const batch = modelDocument.batch();
  135. batch.remove( previousNode );
  136. }
  137. this._setSelectionOverElement( objectElement );
  138. } );
  139. return true;
  140. }
  141. }
  142. /**
  143. * Handles arrow keys.
  144. *
  145. * @param {Boolean} isForward Set to true if arrow key should be handled in forward direction.
  146. * @returns {Boolean|undefined} Returns `true` if keys were handled correctly.
  147. */
  148. _handleArrowKeys( isForward ) {
  149. const modelDocument = this.editor.document;
  150. const schema = modelDocument.schema;
  151. const modelSelection = modelDocument.selection;
  152. const objectElement = modelSelection.getSelectedElement();
  153. // if object element is selected.
  154. if ( objectElement && schema.objects.has( objectElement.name ) ) {
  155. const position = isForward ? modelSelection.getLastPosition() : modelSelection.getFirstPosition();
  156. const newRange = modelDocument.getNearestSelectionRange( position, isForward ? 'forward' : 'backward' );
  157. if ( newRange ) {
  158. modelDocument.enqueueChanges( () => {
  159. modelSelection.setRanges( [ newRange ] );
  160. } );
  161. }
  162. return true;
  163. }
  164. // If selection is next to object element.
  165. // Return if not collapsed.
  166. if ( !modelSelection.isCollapsed ) {
  167. return;
  168. }
  169. const objectElement2 = this._getObjectElementNextToSelection( isForward );
  170. if ( objectElement2 instanceof ModelElement && modelDocument.schema.objects.has( objectElement2.name ) ) {
  171. modelDocument.enqueueChanges( () => {
  172. this._setSelectionOverElement( objectElement2 );
  173. } );
  174. return true;
  175. }
  176. }
  177. /**
  178. * Sets {@link module:engine/model/selection~Selection document's selection} over given element.
  179. *
  180. * @private
  181. * @param {module:engine/model/element~Element} element
  182. */
  183. _setSelectionOverElement( element ) {
  184. this.editor.document.selection.setRanges( [ ModelRange.createOn( element ) ] );
  185. }
  186. /**
  187. * Checks if {@link module:engine/model/element~Element element} placed next to the current
  188. * {@link module:engine/model/selection~Selection model selection} exists and is marked in
  189. * {@link module:engine/model/schema~Schema schema} as `object`.
  190. *
  191. * @private
  192. * @param {Boolean} forward Direction of checking.
  193. * @returns {module:engine/model/element~Element|null}
  194. */
  195. _getObjectElementNextToSelection( forward ) {
  196. const modelDocument = this.editor.document;
  197. const schema = modelDocument.schema;
  198. const modelSelection = modelDocument.selection;
  199. const dataController = this.editor.data;
  200. // Clone current selection to use it as a probe. We must leave default selection as it is so it can return
  201. // to its current state after undo.
  202. const probe = ModelSelection.createFromSelection( modelSelection );
  203. dataController.modifySelection( probe, { direction: forward ? 'forward' : 'backward' } );
  204. const objectElement = forward ? probe.focus.nodeBefore : probe.focus.nodeAfter;
  205. if ( objectElement instanceof ModelElement && schema.objects.has( objectElement.name ) ) {
  206. return objectElement;
  207. }
  208. return null;
  209. }
  210. }
  211. // Returns 'true' if provided key code represents one of the arrow keys.
  212. //
  213. // @param {Number} keyCode
  214. // @returns {Boolean}
  215. function isArrowKeyCode( keyCode ) {
  216. return keyCode == keyCodes.arrowright ||
  217. keyCode == keyCodes.arrowleft ||
  218. keyCode == keyCodes.arrowup ||
  219. keyCode == keyCodes.arrowdown;
  220. }
  221. // Returns 'true' if provided key code represents one of the delete keys: delete or backspace.
  222. //
  223. // @param {Number} keyCode
  224. // @returns {Boolean}
  225. function isDeleteKeyCode( keyCode ) {
  226. return keyCode == keyCodes.delete || keyCode == keyCodes.backspace;
  227. }
  228. // Returns `true` when element is a nested editable or is placed inside one.
  229. //
  230. // @param {module:engine/view/element~Element}
  231. // @returns {Boolean}
  232. function isInsideNestedEditable( element ) {
  233. while ( element ) {
  234. if ( element instanceof ViewEditableElement && !( element instanceof RootEditableElement ) ) {
  235. return true;
  236. }
  237. element = element.parent;
  238. }
  239. return false;
  240. }