tableselection.js 16 KB

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