tableselection.js 16 KB

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