8
0

widget.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. /**
  2. * @license Copyright (c) 2003-2018, 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, getCode, parseKeystroke } from '@ckeditor/ckeditor5-utils/src/keyboard';
  17. import '../theme/widget.css';
  18. const selectAllKeystrokeCode = parseKeystroke( 'Ctrl+A' );
  19. /**
  20. * The widget plugin.
  21. * Registers model to view selection converter for editing pipeline. It is hooked after default selection conversion.
  22. * If converted selection is placed around widget element, selection is marked as fake. Additionally, proper CSS class
  23. * is added to indicate that widget has been selected.
  24. * Adds default {@link module:engine/view/document~Document#event:mousedown mousedown} handling on widget elements.
  25. *
  26. * @extends module:core/plugin~Plugin.
  27. */
  28. export default class Widget extends Plugin {
  29. /**
  30. * @inheritDoc
  31. */
  32. static get pluginName() {
  33. return 'Widget';
  34. }
  35. /**
  36. * @inheritDoc
  37. */
  38. init() {
  39. const view = this.editor.editing.view;
  40. const viewDocument = view.document;
  41. /**
  42. * Holds previously selected widgets.
  43. *
  44. * @private
  45. * @type {Set.<module:engine/view/element~Element>}
  46. */
  47. this._previouslySelected = new Set();
  48. // Model to view selection converter.
  49. // Converts selection placed over widget element to fake selection
  50. this.editor.editing.downcastDispatcher.on( 'selection', ( evt, data, conversionApi ) => {
  51. // Remove selected class from previously selected widgets.
  52. this._clearPreviouslySelectedWidgets( conversionApi.writer );
  53. const viewWriter = conversionApi.writer;
  54. const viewSelection = viewWriter.document.selection;
  55. const selectedElement = viewSelection.getSelectedElement();
  56. for ( const range of viewSelection.getRanges() ) {
  57. for ( const value of range ) {
  58. const node = value.item;
  59. if ( node.is( 'element' ) && isWidget( node ) ) {
  60. viewWriter.addClass( WIDGET_SELECTED_CLASS_NAME, node );
  61. this._previouslySelected.add( node );
  62. // Check if widget is a single element selected.
  63. if ( node == selectedElement ) {
  64. viewWriter.setSelection( viewSelection.getRanges(), { fake: true, label: getLabel( selectedElement ) } );
  65. }
  66. }
  67. }
  68. }
  69. }, { priority: 'low' } );
  70. // If mouse down is pressed on widget - create selection over whole widget.
  71. view.addObserver( MouseObserver );
  72. this.listenTo( viewDocument, 'mousedown', ( ...args ) => this._onMousedown( ...args ) );
  73. // Handle custom keydown behaviour.
  74. this.listenTo( viewDocument, 'keydown', ( ...args ) => this._onKeydown( ...args ), { priority: 'high' } );
  75. // Handle custom delete behaviour.
  76. this.listenTo( viewDocument, 'delete', ( evt, data ) => {
  77. if ( this._handleDelete( data.direction == 'forward' ) ) {
  78. data.preventDefault();
  79. evt.stop();
  80. }
  81. }, { priority: 'high' } );
  82. }
  83. /**
  84. * Handles {@link module:engine/view/document~Document#event:mousedown mousedown} events on widget elements.
  85. *
  86. * @private
  87. * @param {module:utils/eventinfo~EventInfo} eventInfo
  88. * @param {module:engine/view/observer/domeventdata~DomEventData} domEventData
  89. */
  90. _onMousedown( eventInfo, domEventData ) {
  91. const editor = this.editor;
  92. const view = editor.editing.view;
  93. const viewDocument = view.document;
  94. let element = domEventData.target;
  95. // Do nothing if inside nested editable.
  96. if ( isInsideNestedEditable( element ) ) {
  97. return;
  98. }
  99. // If target is not a widget element - check if one of the ancestors is.
  100. if ( !isWidget( element ) ) {
  101. element = element.findAncestor( isWidget );
  102. if ( !element ) {
  103. return;
  104. }
  105. }
  106. domEventData.preventDefault();
  107. // Focus editor if is not focused already.
  108. if ( !viewDocument.isFocused ) {
  109. view.focus();
  110. }
  111. // Create model selection over widget.
  112. const modelElement = editor.editing.mapper.toModelElement( element );
  113. this._setSelectionOverElement( modelElement );
  114. }
  115. /**
  116. * Handles {@link module:engine/view/document~Document#event:keydown keydown} events.
  117. *
  118. * @private
  119. * @param {module:utils/eventinfo~EventInfo} eventInfo
  120. * @param {module:engine/view/observer/domeventdata~DomEventData} domEventData
  121. */
  122. _onKeydown( eventInfo, domEventData ) {
  123. const keyCode = domEventData.keyCode;
  124. const isForward = keyCode == keyCodes.delete || keyCode == keyCodes.arrowdown || keyCode == keyCodes.arrowright;
  125. let wasHandled = false;
  126. // Checks if the keys were handled and then prevents the default event behaviour and stops
  127. // the propagation.
  128. if ( isArrowKeyCode( keyCode ) ) {
  129. wasHandled = this._handleArrowKeys( isForward );
  130. } else if ( isSelectAllKeyCode( domEventData ) ) {
  131. wasHandled = this._selectAllNestedEditableContent() || this._selectAllContent();
  132. } else if ( keyCode === keyCodes.enter ) {
  133. wasHandled = this._handleEnterKey( domEventData.shiftKey );
  134. }
  135. if ( wasHandled ) {
  136. domEventData.preventDefault();
  137. eventInfo.stop();
  138. }
  139. }
  140. /**
  141. * Handles delete keys: backspace and delete.
  142. *
  143. * @private
  144. * @param {Boolean} isForward Set to true if delete was performed in forward direction.
  145. * @returns {Boolean|undefined} Returns `true` if keys were handled correctly.
  146. */
  147. _handleDelete( isForward ) {
  148. // Do nothing when the read only mode is enabled.
  149. if ( this.editor.isReadOnly ) {
  150. return;
  151. }
  152. const modelDocument = this.editor.model.document;
  153. const modelSelection = modelDocument.selection;
  154. // Do nothing on non-collapsed selection.
  155. if ( !modelSelection.isCollapsed ) {
  156. return;
  157. }
  158. const objectElement = this._getObjectElementNextToSelection( isForward );
  159. if ( objectElement ) {
  160. this.editor.model.change( writer => {
  161. let previousNode = modelSelection.anchor.parent;
  162. // Remove previous element if empty.
  163. while ( previousNode.isEmpty ) {
  164. const nodeToRemove = previousNode;
  165. previousNode = nodeToRemove.parent;
  166. writer.remove( nodeToRemove );
  167. }
  168. this._setSelectionOverElement( objectElement );
  169. } );
  170. return true;
  171. }
  172. }
  173. /**
  174. * Handles arrow keys.
  175. *
  176. * @private
  177. * @param {Boolean} isForward Set to true if arrow key should be handled in forward direction.
  178. * @returns {Boolean|undefined} Returns `true` if keys were handled correctly.
  179. */
  180. _handleArrowKeys( isForward ) {
  181. const model = this.editor.model;
  182. const schema = model.schema;
  183. const modelDocument = model.document;
  184. const modelSelection = modelDocument.selection;
  185. const objectElement = modelSelection.getSelectedElement();
  186. // If object element is selected.
  187. if ( objectElement && schema.isObject( objectElement ) ) {
  188. const position = isForward ? modelSelection.getLastPosition() : modelSelection.getFirstPosition();
  189. const newRange = schema.getNearestSelectionRange( position, isForward ? 'forward' : 'backward' );
  190. if ( newRange ) {
  191. model.change( writer => {
  192. writer.setSelection( newRange );
  193. } );
  194. }
  195. return true;
  196. }
  197. // If selection is next to object element.
  198. // Return if not collapsed.
  199. if ( !modelSelection.isCollapsed ) {
  200. return;
  201. }
  202. const objectElement2 = this._getObjectElementNextToSelection( isForward );
  203. if ( objectElement2 instanceof ModelElement && schema.isObject( objectElement2 ) ) {
  204. this._setSelectionOverElement( objectElement2 );
  205. return true;
  206. }
  207. }
  208. /**
  209. * Handles the enter key, giving users and access to positions in the editable directly before
  210. * (<kbd>Shift</kbd>+<kbd>Enter</kbd>) or after (<kbd>Enter</kbd>) the selected widget.
  211. * It improves the UX, mainly when the widget is the first or last child of the root editable
  212. * and there's no other way to type after or before it.
  213. *
  214. * @private
  215. * @param {Boolean} isBackwards Set to true if the new paragraph is to be inserted before
  216. * the selected widget (<kbd>Shift</kbd>+<kbd>Enter</kbd>).
  217. * @returns {Boolean|undefined} Returns `true` if keys were handled correctly.
  218. */
  219. _handleEnterKey( isBackwards ) {
  220. const model = this.editor.model;
  221. const modelSelection = model.document.selection;
  222. const objectElement = modelSelection.getSelectedElement();
  223. if ( objectElement && model.schema.isObject( objectElement ) ) {
  224. model.change( writer => {
  225. const paragraph = writer.createElement( 'paragraph' );
  226. writer.insert( paragraph, objectElement, isBackwards ? 'before' : 'after' );
  227. writer.setSelection( paragraph, 'in' );
  228. } );
  229. return true;
  230. }
  231. }
  232. /**
  233. * Extends the {@link module:engine/model/selection~Selection document's selection} to span the entire
  234. * content of the nested editable if already anchored in one.
  235. *
  236. * See: {@link module:engine/model/schema~Schema#getLimitElement}.
  237. *
  238. * @private
  239. */
  240. _selectAllNestedEditableContent() {
  241. const model = this.editor.model;
  242. const documentSelection = model.document.selection;
  243. const limitElement = model.schema.getLimitElement( documentSelection );
  244. if ( documentSelection.getFirstRange().root == limitElement ) {
  245. return false;
  246. }
  247. model.change( writer => {
  248. writer.setSelection( ModelRange.createIn( limitElement ) );
  249. } );
  250. return true;
  251. }
  252. /**
  253. * Handles <kbd>CTRL + A</kbd> when widget is selected.
  254. *
  255. * @private
  256. * @returns {Boolean} Returns true if widget was selected and selecting all was handled by this method.
  257. */
  258. _selectAllContent() {
  259. const model = this.editor.model;
  260. const editing = this.editor.editing;
  261. const view = editing.view;
  262. const viewDocument = view.document;
  263. const viewSelection = viewDocument.selection;
  264. const selectedElement = viewSelection.getSelectedElement();
  265. // Only widget is selected.
  266. // https://github.com/ckeditor/ckeditor5-widget/issues/23
  267. if ( selectedElement && isWidget( selectedElement ) ) {
  268. const widgetParent = editing.mapper.toModelElement( selectedElement.parent );
  269. model.change( writer => {
  270. writer.setSelection( ModelRange.createIn( widgetParent ) );
  271. } );
  272. return true;
  273. }
  274. return false;
  275. }
  276. /**
  277. * Sets {@link module:engine/model/selection~Selection document's selection} over given element.
  278. *
  279. * @private
  280. * @param {module:engine/model/element~Element} element
  281. */
  282. _setSelectionOverElement( element ) {
  283. this.editor.model.change( writer => {
  284. writer.setSelection( ModelRange.createOn( element ) );
  285. } );
  286. }
  287. /**
  288. * Checks if {@link module:engine/model/element~Element element} placed next to the current
  289. * {@link module:engine/model/selection~Selection model selection} exists and is marked in
  290. * {@link module:engine/model/schema~Schema schema} as `object`.
  291. *
  292. * @private
  293. * @param {Boolean} forward Direction of checking.
  294. * @returns {module:engine/model/element~Element|null}
  295. */
  296. _getObjectElementNextToSelection( forward ) {
  297. const model = this.editor.model;
  298. const schema = model.schema;
  299. const modelSelection = model.document.selection;
  300. // Clone current selection to use it as a probe. We must leave default selection as it is so it can return
  301. // to its current state after undo.
  302. const probe = new ModelSelection( modelSelection );
  303. model.modifySelection( probe, { direction: forward ? 'forward' : 'backward' } );
  304. const objectElement = forward ? probe.focus.nodeBefore : probe.focus.nodeAfter;
  305. if ( objectElement instanceof ModelElement && schema.isObject( objectElement ) ) {
  306. return objectElement;
  307. }
  308. return null;
  309. }
  310. /**
  311. * Removes CSS class from previously selected widgets.
  312. *
  313. * @private
  314. * @param {module:engine/view/writer~Writer} writer
  315. */
  316. _clearPreviouslySelectedWidgets( writer ) {
  317. for ( const widget of this._previouslySelected ) {
  318. writer.removeClass( WIDGET_SELECTED_CLASS_NAME, widget );
  319. }
  320. this._previouslySelected.clear();
  321. }
  322. }
  323. // Returns 'true' if provided key code represents one of the arrow keys.
  324. //
  325. // @param {Number} keyCode
  326. // @returns {Boolean}
  327. function isArrowKeyCode( keyCode ) {
  328. return keyCode == keyCodes.arrowright ||
  329. keyCode == keyCodes.arrowleft ||
  330. keyCode == keyCodes.arrowup ||
  331. keyCode == keyCodes.arrowdown;
  332. }
  333. // Returns 'true' if provided (DOM) key event data corresponds with the Ctrl+A keystroke.
  334. //
  335. // @param {module:engine/view/observer/keyobserver~KeyEventData} domEventData
  336. // @returns {Boolean}
  337. function isSelectAllKeyCode( domEventData ) {
  338. return getCode( domEventData ) == selectAllKeystrokeCode;
  339. }
  340. // Returns `true` when element is a nested editable or is placed inside one.
  341. //
  342. // @param {module:engine/view/element~Element}
  343. // @returns {Boolean}
  344. function isInsideNestedEditable( element ) {
  345. while ( element ) {
  346. if ( element instanceof ViewEditableElement && !( element instanceof RootEditableElement ) ) {
  347. return true;
  348. }
  349. element = element.parent;
  350. }
  351. return false;
  352. }