8
0

tableselection.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  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 { cropTableToDimensions, adjustLastRowIndex, adjustLastColumnIndex } from './utils/structure';
  13. import { getColumnIndexes, getRowIndexes, getSelectedTableCells, isSelectionRectangular } from './utils/selection';
  14. import '../theme/tableselection.css';
  15. /**
  16. * This plugin enables the advanced table cells, rows and columns selection.
  17. * It is loaded automatically by the {@link module:table/table~Table} plugin.
  18. *
  19. * @extends module:core/plugin~Plugin
  20. */
  21. export default class TableSelection extends Plugin {
  22. /**
  23. * @inheritDoc
  24. */
  25. static get pluginName() {
  26. return 'TableSelection';
  27. }
  28. /**
  29. * @inheritDoc
  30. */
  31. static get requires() {
  32. return [ TableUtils ];
  33. }
  34. /**
  35. * @inheritDoc
  36. */
  37. init() {
  38. const editor = this.editor;
  39. const model = editor.model;
  40. this.listenTo( model, 'deleteContent', ( evt, args ) => this._handleDeleteContent( evt, args ), { priority: 'high' } );
  41. this._defineSelectionConverter();
  42. this._enablePluginDisabling(); // sic!
  43. }
  44. /**
  45. * Returns the currently selected table cells or `null` if it is not a table cells selection.
  46. *
  47. * @returns {Array.<module:engine/model/element~Element>|null}
  48. */
  49. getSelectedTableCells() {
  50. const selection = this.editor.model.document.selection;
  51. const selectedCells = getSelectedTableCells( selection );
  52. if ( selectedCells.length == 0 ) {
  53. return null;
  54. }
  55. // This should never happen, but let's know if it ever happens.
  56. // @if CK_DEBUG // /* istanbul ignore next */
  57. // @if CK_DEBUG // if ( selectedCells.length != selection.rangeCount ) {
  58. // @if CK_DEBUG // console.warn( 'Mixed selection warning. The selection contains table cells and some other ranges.' );
  59. // @if CK_DEBUG // }
  60. return selectedCells;
  61. }
  62. /**
  63. * Returns the selected table fragment as a document fragment.
  64. *
  65. * @returns {module:engine/model/documentfragment~DocumentFragment|null}
  66. */
  67. getSelectionAsFragment() {
  68. const selectedCells = this.getSelectedTableCells();
  69. if ( !selectedCells ) {
  70. return null;
  71. }
  72. return this.editor.model.change( writer => {
  73. const documentFragment = writer.createDocumentFragment();
  74. const tableUtils = this.editor.plugins.get( 'TableUtils' );
  75. const { first: firstColumn, last: lastColumn } = getColumnIndexes( selectedCells );
  76. const { first: firstRow, last: lastRow } = getRowIndexes( selectedCells );
  77. const sourceTable = selectedCells[ 0 ].findAncestor( 'table' );
  78. let adjustedLastRow = lastRow;
  79. let adjustedLastColumn = lastColumn;
  80. // If the selection is rectangular there could be a case of all cells in the last row/column spanned over
  81. // next row/column so the real lastRow/lastColumn should be updated.
  82. if ( isSelectionRectangular( selectedCells, tableUtils ) ) {
  83. const dimensions = {
  84. firstColumn,
  85. lastColumn,
  86. firstRow,
  87. lastRow
  88. };
  89. adjustedLastRow = adjustLastRowIndex( sourceTable, dimensions );
  90. adjustedLastColumn = adjustLastColumnIndex( sourceTable, dimensions );
  91. }
  92. const cropDimensions = {
  93. startRow: firstRow,
  94. startColumn: firstColumn,
  95. endRow: adjustedLastRow,
  96. endColumn: adjustedLastColumn
  97. };
  98. const table = cropTableToDimensions( sourceTable, cropDimensions, writer );
  99. writer.insert( table, documentFragment, 0 );
  100. return documentFragment;
  101. } );
  102. }
  103. /**
  104. * Sets the model selection based on given anchor and target cells (can be the same cell).
  105. * Takes care of setting the backward flag.
  106. *
  107. * const modelRoot = editor.model.document.getRoot();
  108. * const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
  109. * const lastCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] );
  110. *
  111. * const tableSelection = editor.plugins.get( 'TableSelection' );
  112. * tableSelection.setCellSelection( firstCell, lastCell );
  113. *
  114. * @param {module:engine/model/element~Element} anchorCell
  115. * @param {module:engine/model/element~Element} targetCell
  116. */
  117. setCellSelection( anchorCell, targetCell ) {
  118. const cellsToSelect = this._getCellsToSelect( anchorCell, targetCell );
  119. this.editor.model.change( writer => {
  120. writer.setSelection(
  121. cellsToSelect.cells.map( cell => writer.createRangeOn( cell ) ),
  122. { backward: cellsToSelect.backward }
  123. );
  124. } );
  125. }
  126. /**
  127. * Returns the focus cell from the current selection.
  128. *
  129. * @returns {module:engine/model/element~Element}
  130. */
  131. getFocusCell() {
  132. const selection = this.editor.model.document.selection;
  133. const focusCellRange = [ ...selection.getRanges() ].pop();
  134. const element = focusCellRange.getContainedElement();
  135. if ( element && element.is( 'tableCell' ) ) {
  136. return element;
  137. }
  138. return null;
  139. }
  140. /**
  141. * Returns the anchor cell from the current selection.
  142. *
  143. * @returns {module:engine/model/element~Element} anchorCell
  144. */
  145. getAnchorCell() {
  146. const selection = this.editor.model.document.selection;
  147. const anchorCellRange = first( selection.getRanges() );
  148. const element = anchorCellRange.getContainedElement();
  149. if ( element && element.is( 'tableCell' ) ) {
  150. return element;
  151. }
  152. return null;
  153. }
  154. /**
  155. * Defines a selection converter which marks the selected cells with a specific class.
  156. *
  157. * The real DOM selection is put in the last cell. Since the order of ranges is dependent on whether the
  158. * selection is backward or not, the last cell will usually be close to the "focus" end of the selection
  159. * (a selection has anchor and focus).
  160. *
  161. * The real DOM selection is then hidden with CSS.
  162. *
  163. * @private
  164. */
  165. _defineSelectionConverter() {
  166. const editor = this.editor;
  167. const highlighted = new Set();
  168. editor.conversion.for( 'editingDowncast' ).add( dispatcher => dispatcher.on( 'selection', ( evt, data, conversionApi ) => {
  169. const viewWriter = conversionApi.writer;
  170. clearHighlightedTableCells( viewWriter );
  171. const selectedCells = this.getSelectedTableCells();
  172. if ( !selectedCells ) {
  173. return;
  174. }
  175. for ( const tableCell of selectedCells ) {
  176. const viewElement = conversionApi.mapper.toViewElement( tableCell );
  177. viewWriter.addClass( 'ck-editor__editable_selected', viewElement );
  178. highlighted.add( viewElement );
  179. }
  180. const lastViewCell = conversionApi.mapper.toViewElement( selectedCells[ selectedCells.length - 1 ] );
  181. viewWriter.setSelection( lastViewCell, 0 );
  182. }, { priority: 'lowest' } ) );
  183. function clearHighlightedTableCells( writer ) {
  184. for ( const previouslyHighlighted of highlighted ) {
  185. writer.removeClass( 'ck-editor__editable_selected', previouslyHighlighted );
  186. }
  187. highlighted.clear();
  188. }
  189. }
  190. /**
  191. * Creates a listener that reacts to changes in {@link #isEnabled} and, if the plugin was disabled,
  192. * it collapses the multi-cell selection to a regular selection placed inside a table cell.
  193. *
  194. * This listener helps features that disable the table selection plugin bring the selection
  195. * to a clear state they can work with (for instance, because they don't support multiple cell selection).
  196. */
  197. _enablePluginDisabling() {
  198. const editor = this.editor;
  199. this.on( 'change:isEnabled', () => {
  200. if ( !this.isEnabled ) {
  201. const selectedCells = this.getSelectedTableCells();
  202. if ( !selectedCells ) {
  203. return;
  204. }
  205. editor.model.change( writer => {
  206. const position = writer.createPositionAt( selectedCells[ 0 ], 0 );
  207. const range = editor.model.schema.getNearestSelectionRange( position );
  208. writer.setSelection( range );
  209. } );
  210. }
  211. } );
  212. }
  213. /**
  214. * Overrides the default `model.deleteContent()` behavior over a selected table fragment.
  215. *
  216. * @private
  217. * @param {module:utils/eventinfo~EventInfo} event
  218. * @param {Array.<*>} args Delete content method arguments.
  219. */
  220. _handleDeleteContent( event, args ) {
  221. const [ selection, options ] = args;
  222. const model = this.editor.model;
  223. const isBackward = !options || options.direction == 'backward';
  224. const selectedTableCells = getSelectedTableCells( selection );
  225. if ( !selectedTableCells.length ) {
  226. return;
  227. }
  228. event.stop();
  229. model.change( writer => {
  230. const tableCellToSelect = selectedTableCells[ isBackward ? selectedTableCells.length - 1 : 0 ];
  231. model.change( writer => {
  232. for ( const tableCell of selectedTableCells ) {
  233. model.deleteContent( writer.createSelection( tableCell, 'in' ) );
  234. }
  235. } );
  236. const rangeToSelect = model.schema.getNearestSelectionRange( writer.createPositionAt( tableCellToSelect, 0 ) );
  237. // Note: we ignore the case where rangeToSelect may be null because deleteContent() will always (unless someone broke it)
  238. // create an empty paragraph to accommodate the selection.
  239. if ( selection.is( 'documentSelection' ) ) {
  240. writer.setSelection( rangeToSelect );
  241. } else {
  242. selection.setTo( rangeToSelect );
  243. }
  244. } );
  245. }
  246. /**
  247. * Returns an array of table cells that should be selected based on the
  248. * given anchor cell and target (focus) cell.
  249. *
  250. * The cells are returned in a reverse direction if the selection is backward.
  251. *
  252. * @private
  253. * @param {module:engine/model/element~Element} anchorCell
  254. * @param {module:engine/model/element~Element} targetCell
  255. * @returns {Array.<module:engine/model/element~Element>}
  256. */
  257. _getCellsToSelect( anchorCell, targetCell ) {
  258. const tableUtils = this.editor.plugins.get( 'TableUtils' );
  259. const startLocation = tableUtils.getCellLocation( anchorCell );
  260. const endLocation = tableUtils.getCellLocation( targetCell );
  261. const startRow = Math.min( startLocation.row, endLocation.row );
  262. const endRow = Math.max( startLocation.row, endLocation.row );
  263. const startColumn = Math.min( startLocation.column, endLocation.column );
  264. const endColumn = Math.max( startLocation.column, endLocation.column );
  265. // 2-dimensional array of the selected cells to ease flipping the order of cells for backward selections.
  266. const selectionMap = new Array( endRow - startRow + 1 ).fill( null ).map( () => [] );
  267. const walkerOptions = {
  268. startRow,
  269. endRow,
  270. startColumn,
  271. endColumn
  272. };
  273. for ( const { row, cell } of new TableWalker( anchorCell.findAncestor( 'table' ), walkerOptions ) ) {
  274. selectionMap[ row - startRow ].push( cell );
  275. }
  276. const flipVertically = endLocation.row < startLocation.row;
  277. const flipHorizontally = endLocation.column < startLocation.column;
  278. if ( flipVertically ) {
  279. selectionMap.reverse();
  280. }
  281. if ( flipHorizontally ) {
  282. selectionMap.forEach( row => row.reverse() );
  283. }
  284. return {
  285. cells: selectionMap.flat(),
  286. backward: flipVertically || flipHorizontally
  287. };
  288. }
  289. }