tableselection.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  1. /**
  2. * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  4. */
  5. /**
  6. * @module table/tableselection
  7. */
  8. import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
  9. import TableWalker from './tablewalker';
  10. import TableUtils from './tableutils';
  11. import MouseEventsObserver from './tableselection/mouseeventsobserver';
  12. import {
  13. getSelectedTableCells,
  14. getTableCellsContainingSelection
  15. } from './utils';
  16. import { findAncestor } from './commands/utils';
  17. import cropTable from './tableselection/croptable';
  18. import '../theme/tableselection.css';
  19. /**
  20. * This plugin enables the advanced table cells/rows/columns selection.
  21. * It is loaded automatically by the {@link module:table/table~Table} plugin.
  22. *
  23. * @extends module:core/plugin~Plugin
  24. */
  25. export default class TableSelection extends Plugin {
  26. /**
  27. * @inheritDoc
  28. */
  29. static get pluginName() {
  30. return 'TableSelection';
  31. }
  32. /**
  33. * @inheritDoc
  34. */
  35. static get requires() {
  36. return [ TableUtils ];
  37. }
  38. /**
  39. * @inheritDoc
  40. */
  41. init() {
  42. const editor = this.editor;
  43. const model = editor.model;
  44. this.listenTo( model, 'deleteContent', ( evt, args ) => this._handleDeleteContent( evt, args ), { priority: 'high' } );
  45. // Currently the MouseObserver only handles `mouseup` events.
  46. // TODO move to the engine?
  47. editor.editing.view.addObserver( MouseEventsObserver );
  48. this._defineSelectionConverter();
  49. this._enableShiftClickSelection();
  50. this._enableMouseDragSelection();
  51. }
  52. /**
  53. * Returns currently selected table cells or `null` if not a table cells selection.
  54. *
  55. * @returns {Array.<module:engine/model/element~Element>|null}
  56. */
  57. getSelectedTableCells() {
  58. const selection = this.editor.model.document.selection;
  59. const selectedCells = getSelectedTableCells( selection );
  60. if ( selectedCells.length == 0 ) {
  61. return null;
  62. }
  63. // This should never happen, but let's know if it ever happens.
  64. // @if CK_DEBUG // if ( selectedCells.length != selection.rangeCount ) {
  65. // @if CK_DEBUG // console.warn( 'Mixed selection warning. The selection contains table cells and some other ranges.' );
  66. // @if CK_DEBUG // }
  67. return selectedCells;
  68. }
  69. /**
  70. * Returns a selected table fragment as a document fragment.
  71. *
  72. * @returns {module:engine/model/documentfragment~DocumentFragment|null}
  73. */
  74. getSelectionAsFragment() {
  75. const selectedCells = this.getSelectedTableCells();
  76. if ( !selectedCells ) {
  77. return null;
  78. }
  79. return this.editor.model.change( writer => {
  80. const documentFragment = writer.createDocumentFragment();
  81. const table = cropTable( selectedCells, this.editor.plugins.get( 'TableUtils' ), writer );
  82. writer.insert( table, documentFragment, 0 );
  83. return documentFragment;
  84. } );
  85. }
  86. /**
  87. * Defines a selection converter which marks selected cells with a specific class.
  88. *
  89. * The real DOM selection is put in the last cell. Since the order of ranges is dependent on whether the
  90. * selection is backward or not, the last cell with usually be close to the "focus" end of the selection
  91. * (a selection has anchor and focus).
  92. *
  93. * The real DOM selection is then hidden with CSS.
  94. *
  95. * @private
  96. */
  97. _defineSelectionConverter() {
  98. const editor = this.editor;
  99. const highlighted = new Set();
  100. editor.conversion.for( 'editingDowncast' ).add( dispatcher => dispatcher.on( 'selection', ( evt, data, conversionApi ) => {
  101. const viewWriter = conversionApi.writer;
  102. clearHighlightedTableCells( viewWriter );
  103. const selectedCells = this.getSelectedTableCells();
  104. if ( !selectedCells ) {
  105. return;
  106. }
  107. for ( const tableCell of selectedCells ) {
  108. const viewElement = conversionApi.mapper.toViewElement( tableCell );
  109. viewWriter.addClass( 'ck-editor__editable_selected', viewElement );
  110. highlighted.add( viewElement );
  111. }
  112. const lastViewCell = conversionApi.mapper.toViewElement( selectedCells[ selectedCells.length - 1 ] );
  113. viewWriter.setSelection( lastViewCell, 0 );
  114. }, { priority: 'lowest' } ) );
  115. function clearHighlightedTableCells( writer ) {
  116. for ( const previouslyHighlighted of highlighted ) {
  117. writer.removeClass( 'ck-editor__editable_selected', previouslyHighlighted );
  118. }
  119. highlighted.clear();
  120. }
  121. }
  122. /**
  123. * Enables making cells selection by Shift+click. Creates a selection from the cell which previously hold
  124. * the selection to the cell which was clicked (can be the same cell, in which case it selects a single cell).
  125. *
  126. * @private
  127. */
  128. _enableShiftClickSelection() {
  129. const editor = this.editor;
  130. let blockSelectionChange = false;
  131. this.listenTo( editor.editing.view.document, 'mousedown', ( evt, domEventData ) => {
  132. if ( !this.isEnabled ) {
  133. return;
  134. }
  135. if ( !domEventData.domEvent.shiftKey ) {
  136. return;
  137. }
  138. const anchorCell = getTableCellsContainingSelection( editor.model.document.selection )[ 0 ];
  139. if ( !anchorCell ) {
  140. return;
  141. }
  142. const targetCell = this._getModelTableCellFromDomEvent( domEventData );
  143. if ( targetCell && haveSameTableParent( anchorCell, targetCell ) ) {
  144. blockSelectionChange = true;
  145. this._setCellSelection( anchorCell, targetCell );
  146. domEventData.preventDefault();
  147. }
  148. } );
  149. this.listenTo( editor.editing.view.document, 'mouseup', () => {
  150. blockSelectionChange = false;
  151. } );
  152. // We need to ignore a `selectionChange` event that is fired after we render our new table cells selection.
  153. // When downcasting table cells selection to the view, we put the view selection in the last selected cell
  154. // in a place that may not be natively a "correct" location. This is – we put it directly in the `<td>` element.
  155. // All browsers fire the native `selectionchange` event.
  156. // However, all browsers except Safari return the selection in the exact place where we put it
  157. // (even though it's visually normalized). Safari returns `<td><p>^foo` that makes our selection observer
  158. // fire our `selectionChange` event (because the view selection that we set in the first step differs from the DOM selection).
  159. // Since `selectionChange` is fired, we automatically update the model selection that moves it that paragraph.
  160. // This breaks our dear cells selection.
  161. //
  162. // Theoretically this issue concerns only Safari that is the only browser that do normalize the selection.
  163. // However, to avoid code branching and to have a good coverage for this event blocker, I enabled it for all browsers.
  164. //
  165. // Note: I'm keeping the `blockSelectionChange` state separately for shift+click and mouse drag (exact same logic)
  166. // so I don't have to try to analyze whether they don't overlap in some weird cases. Probably they don't.
  167. // But I have other things to do, like writing this comment.
  168. this.listenTo( editor.editing.view.document, 'selectionChange', evt => {
  169. if ( blockSelectionChange ) {
  170. // @if CK_DEBUG // console.log( 'Blocked selectionChange to avoid breaking table cells selection.' );
  171. evt.stop();
  172. }
  173. }, { priority: 'highest' } );
  174. }
  175. /**
  176. * Enables making cells selection by dragging.
  177. *
  178. * The selection is made only on mousemove. We start tracking the mouse on mousedown.
  179. * However, the cells selection is enabled only after the mouse cursor left the anchor cell.
  180. * Thanks to that normal text selection within one cell works just fine. However, you can still select
  181. * just one cell by leaving the anchor cell and moving back to it.
  182. *
  183. * @private
  184. */
  185. _enableMouseDragSelection() {
  186. const editor = this.editor;
  187. let anchorCell, targetCell;
  188. let beganCellSelection = false;
  189. let blockSelectionChange = false;
  190. this.listenTo( editor.editing.view.document, 'mousedown', ( evt, domEventData ) => {
  191. if ( !this.isEnabled ) {
  192. return;
  193. }
  194. // Make sure to not conflict with the shift+click listener and any other possible handler.
  195. if ( domEventData.domEvent.shiftKey || domEventData.domEvent.ctrlKey || domEventData.domEvent.altKey ) {
  196. return;
  197. }
  198. anchorCell = this._getModelTableCellFromDomEvent( domEventData );
  199. } );
  200. this.listenTo( editor.editing.view.document, 'mousemove', ( evt, domEventData ) => {
  201. if ( !domEventData.domEvent.buttons ) {
  202. return;
  203. }
  204. if ( !anchorCell ) {
  205. return;
  206. }
  207. const newTargetCell = this._getModelTableCellFromDomEvent( domEventData );
  208. if ( newTargetCell && haveSameTableParent( anchorCell, newTargetCell ) ) {
  209. targetCell = newTargetCell;
  210. // Switch to the cell selection mode after the mouse cursor left the anchor cell.
  211. // Switch off only on mouseup (makes selecting a single cell possible).
  212. if ( !beganCellSelection && targetCell != anchorCell ) {
  213. beganCellSelection = true;
  214. }
  215. }
  216. // Yep, not making a cell selection yet. See method docs.
  217. if ( !beganCellSelection ) {
  218. return;
  219. }
  220. blockSelectionChange = true;
  221. this._setCellSelection( anchorCell, targetCell );
  222. domEventData.preventDefault();
  223. } );
  224. this.listenTo( editor.editing.view.document, 'mouseup', () => {
  225. beganCellSelection = false;
  226. blockSelectionChange = false;
  227. anchorCell = null;
  228. targetCell = null;
  229. } );
  230. // See the explanation in `_enableShiftClickSelection()`.
  231. this.listenTo( editor.editing.view.document, 'selectionChange', evt => {
  232. if ( blockSelectionChange ) {
  233. // @if CK_DEBUG // console.log( 'Blocked selectionChange to avoid breaking table cells selection.' );
  234. evt.stop();
  235. }
  236. }, { priority: 'highest' } );
  237. }
  238. /**
  239. * It overrides the default `model.deleteContent()` behavior over a selected table fragment.
  240. *
  241. * @private
  242. * @param {module:utils/eventinfo~EventInfo} event
  243. * @param {Array.<*>} args Delete content method arguments.
  244. */
  245. _handleDeleteContent( event, args ) {
  246. const [ selection, options ] = args;
  247. const model = this.editor.model;
  248. const isBackward = !options || options.direction == 'backward';
  249. const selectedTableCells = getSelectedTableCells( selection );
  250. if ( !selectedTableCells.length ) {
  251. return;
  252. }
  253. event.stop();
  254. model.change( writer => {
  255. const tableCellToSelect = selectedTableCells[ isBackward ? selectedTableCells.length - 1 : 0 ];
  256. model.change( writer => {
  257. for ( const tableCell of selectedTableCells ) {
  258. model.deleteContent( writer.createSelection( tableCell, 'in' ) );
  259. }
  260. } );
  261. const rangeToSelect = model.schema.getNearestSelectionRange( writer.createPositionAt( tableCellToSelect, 0 ) );
  262. if ( rangeToSelect ) {
  263. if ( selection.is( 'documentSelection' ) ) {
  264. writer.setSelection( rangeToSelect );
  265. } else {
  266. selection.setTo( rangeToSelect );
  267. }
  268. }
  269. } );
  270. }
  271. /**
  272. * Sets the model selection based on given anchor and target cells (can be the same cell).
  273. * Takes care of setting backward flag.
  274. *
  275. * @protected
  276. * @param {module:engine/model/element~Element} anchorCell
  277. * @param {module:engine/model/element~Element} targetCell
  278. */
  279. _setCellSelection( anchorCell, targetCell ) {
  280. const cellsToSelect = this._getCellsToSelect( anchorCell, targetCell );
  281. this.editor.model.change( writer => {
  282. writer.setSelection(
  283. cellsToSelect.cells.map( cell => writer.createRangeOn( cell ) ),
  284. { backward: cellsToSelect.backward }
  285. );
  286. } );
  287. }
  288. /**
  289. * Returns the model table cell element based on the target element of the passed DOM event.
  290. *
  291. * @private
  292. * @param {module:engine/view/observer/domeventdata~DomEventData} domEventData
  293. * @returns {module:engine/model/element~Element|undefined} Returns the table cell or `undefined`.
  294. */
  295. _getModelTableCellFromDomEvent( domEventData ) {
  296. // Note: Work with positions (not element mapping) because the target element can be an attribute or other non-mapped element.
  297. const viewTargetElement = domEventData.target;
  298. const viewPosition = this.editor.editing.view.createPositionAt( viewTargetElement, 0 );
  299. const modelPosition = this.editor.editing.mapper.toModelPosition( viewPosition );
  300. const modelElement = modelPosition.parent;
  301. if ( !modelElement ) {
  302. return;
  303. }
  304. if ( modelElement.is( 'tableCell' ) ) {
  305. return modelElement;
  306. }
  307. return findAncestor( 'tableCell', modelElement );
  308. }
  309. /**
  310. * Returns an array of table cells that should be selected based on the
  311. * given anchor cell and target (focus) cell.
  312. *
  313. * The cells are returned in a reverse direction if the selection is backward.
  314. *
  315. * @private
  316. * @param {module:engine/model/element~Element} anchorCell
  317. * @param {module:engine/model/element~Element} targetCell
  318. * @returns {Array.<module:engine/model/element~Element>}
  319. */
  320. _getCellsToSelect( anchorCell, targetCell ) {
  321. const tableUtils = this.editor.plugins.get( 'TableUtils' );
  322. const startLocation = tableUtils.getCellLocation( anchorCell );
  323. const endLocation = tableUtils.getCellLocation( targetCell );
  324. const startRow = Math.min( startLocation.row, endLocation.row );
  325. const endRow = Math.max( startLocation.row, endLocation.row );
  326. const startColumn = Math.min( startLocation.column, endLocation.column );
  327. const endColumn = Math.max( startLocation.column, endLocation.column );
  328. const cells = [];
  329. for ( const cellInfo of new TableWalker( findAncestor( 'table', anchorCell ), { startRow, endRow } ) ) {
  330. if ( cellInfo.column >= startColumn && cellInfo.column <= endColumn ) {
  331. cells.push( cellInfo.cell );
  332. }
  333. }
  334. // A selection started in the bottom right corner and finished in the upper left corner
  335. // should have it ranges returned in the reverse order.
  336. // However, this is only half of the story because the selection could be made to the left (then the left cell is a focus)
  337. // or to the right (then the right cell is a focus), while being a forward selection in both cases
  338. // (because it was made from top to bottom). This isn't handled.
  339. // This method would need to be smarter, but the ROI is microscopic, so I skip this.
  340. if ( checkIsBackward( startLocation, endLocation ) ) {
  341. return { cells: cells.reverse(), backward: true };
  342. }
  343. return { cells, backward: false };
  344. }
  345. }
  346. // Naively check whether the selection should be backward or not. See the longer explanation where this function is used.
  347. function checkIsBackward( startLocation, endLocation ) {
  348. if ( startLocation.row > endLocation.row ) {
  349. return true;
  350. }
  351. if ( startLocation.row == endLocation.row && startLocation.column > endLocation.column ) {
  352. return true;
  353. }
  354. return false;
  355. }
  356. function haveSameTableParent( cellA, cellB ) {
  357. return cellA.parent.parent == cellB.parent.parent;
  358. }