tableselection.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  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 { setupTableSelectionHighlighting } from './tableselection/converters';
  12. import MouseSelectionHandler from './tableselection/mouseselectionhandler';
  13. import { findAncestor } from './commands/utils';
  14. /**
  15. * The table selection plugin.
  16. *
  17. * It introduces the ability to select table cells. Table selection is described by two nodes: start and end.
  18. * Both are the oposite corners of an rectangle that spans over them.
  19. *
  20. * Consider a table:
  21. *
  22. * 0 1 2 3
  23. * +---+---+---+---+
  24. * 0 | a | b | c | d |
  25. * +-------+ +---+
  26. * 1 | e | f | | g |
  27. * +---+---+---+---+
  28. * 2 | h | i | j |
  29. * +---+---+---+---+
  30. *
  31. * Setting table selection start as table cell "b" and end as table cell "g" will select table cells: "b", "c", "d", "f", and "g".
  32. * The cells that spans over multiple rows or columns can extend over the selection rectangle. For instance setting a selection from
  33. * table cell "a" to table cell "i" will create a selection in which table cell "i" will be extended over a rectangular of the selected
  34. * cell: "a", "b", "e", "f", "h", and "i".
  35. *
  36. * @extends module:core/plugin~Plugin
  37. */
  38. export default class TableSelection extends Plugin {
  39. /**
  40. * @inheritDoc
  41. */
  42. static get pluginName() {
  43. return 'TableSelection';
  44. }
  45. /**
  46. * @inheritDoc
  47. */
  48. static get requires() {
  49. return [ TableUtils ];
  50. }
  51. /**
  52. * @inheritDoc
  53. */
  54. constructor( editor ) {
  55. super( editor );
  56. /**
  57. * A mouse selection handler.
  58. *
  59. * @private
  60. * @readonly
  61. * @member {module:table/tableselection/mouseselectionhandler~MouseSelectionHandler}
  62. */
  63. this._mouseHandler = new MouseSelectionHandler( this, this.editor.editing );
  64. /**
  65. * A table utilities.
  66. *
  67. * @private
  68. * @readonly
  69. * @member {module:table/tableutils~TableUtils}
  70. */
  71. }
  72. /**
  73. * Flag indicating that there are selected table cells and the selection has more than one table cell.
  74. *
  75. * @type {Boolean}
  76. */
  77. get hasMultiCellSelection() {
  78. return !!this._startElement && !!this._endElement && this._startElement !== this._endElement;
  79. }
  80. /**
  81. * @inheritDoc
  82. */
  83. init() {
  84. const editor = this.editor;
  85. const selection = editor.model.document.selection;
  86. this._tableUtils = editor.plugins.get( 'TableUtils' );
  87. setupTableSelectionHighlighting( editor, this );
  88. selection.on( 'change:range', () => this._clearSelectionOnExternalChange( selection ) );
  89. this.listenTo( editor.editing.view.document, 'copy', ( evt, data ) => {
  90. if ( !this.hasMultiCellSelection ) {
  91. return;
  92. }
  93. const dataTransfer = data.dataTransfer;
  94. data.preventDefault();
  95. evt.stop();
  96. const content = editor.data.toView( this.getSelectionAsFragment() );
  97. editor.editing.view.document.fire( 'clipboardOutput', { dataTransfer, content, method: evt.name } );
  98. }, { priority: 'normal' } );
  99. this.listenTo( editor.editing.view.document, 'cut', ( evt, data ) => {
  100. if ( this.hasMultiCellSelection ) {
  101. data.preventDefault();
  102. evt.stop();
  103. }
  104. }, { priority: 'high' } );
  105. }
  106. /**
  107. * @inheritDoc
  108. */
  109. destroy() {
  110. super.destroy();
  111. this._mouseHandler.stopListening();
  112. }
  113. /**
  114. * Starts a selection process.
  115. *
  116. * This method enables the table selection process.
  117. *
  118. * editor.plugins.get( 'TableSelection' ).startSelectingFrom( tableCell );
  119. *
  120. * @param {module:engine/model/element~Element} tableCell
  121. */
  122. startSelectingFrom( tableCell ) {
  123. this.clearSelection();
  124. this._startElement = tableCell;
  125. this._endElement = tableCell;
  126. }
  127. /**
  128. * Updates current table selection end element. Table selection is defined by #start and #end element.
  129. * This method updates the #end element. Must be preceded by {@link #startSelectingFrom}.
  130. *
  131. * editor.plugins.get( 'TableSelection' ).startSelectingFrom( startTableCell );
  132. *
  133. * editor.plugins.get( 'TableSelection' ).setSelectingTo( endTableCell );
  134. *
  135. * @param {module:engine/model/element~Element} tableCell
  136. */
  137. setSelectingTo( tableCell ) {
  138. if ( !this._startElement ) {
  139. this._startElement = tableCell;
  140. }
  141. const table = this._startElement.parent.parent;
  142. // Do not add tableCell to selection if it is from other table or is already set as end element.
  143. if ( table !== tableCell.parent.parent || this._endElement === tableCell ) {
  144. return;
  145. }
  146. this._endElement = tableCell;
  147. this._updateModelSelection();
  148. }
  149. /**
  150. * Stops selection process (but do not clear the current selection). The selecting process is ended but the selection in model remains.
  151. *
  152. * editor.plugins.get( 'TableSelection' ).startSelectingFrom( startTableCell );
  153. * editor.plugins.get( 'TableSelection' ).setSelectingTo( endTableCell );
  154. * editor.plugins.get( 'TableSelection' ).stopSelection();
  155. *
  156. * To clear selection use {@link #clearSelection}.
  157. *
  158. * @param {module:engine/model/element~Element} [tableCell]
  159. */
  160. stopSelection( tableCell ) {
  161. if ( tableCell && tableCell.parent.parent === this._startElement.parent.parent ) {
  162. this._endElement = tableCell;
  163. }
  164. this._updateModelSelection();
  165. }
  166. /**
  167. * Stops current selection process and clears table selection.
  168. *
  169. * editor.plugins.get( 'TableSelection' ).startSelectingFrom( startTableCell );
  170. * editor.plugins.get( 'TableSelection' ).setSelectingTo( endTableCell );
  171. * editor.plugins.get( 'TableSelection' ).stopSelection();
  172. *
  173. * editor.plugins.get( 'TableSelection' ).clearSelection();
  174. */
  175. clearSelection() {
  176. this._startElement = undefined;
  177. this._endElement = undefined;
  178. }
  179. /**
  180. * Returns selected table fragment as a document fragment.
  181. *
  182. * @returns {module:engine/model/documentfragment~DocumentFragment|undefined}
  183. */
  184. getSelectionAsFragment() {
  185. if ( !this.hasMultiCellSelection ) {
  186. return;
  187. }
  188. return this.editor.model.change( writer => {
  189. const fragment = writer.createDocumentFragment();
  190. const tableCopy = writer.createElement( 'table' );
  191. writer.insert( tableCopy, fragment, 0 );
  192. const rowsMap = new Map();
  193. const columnsIndexesMap = new Map();
  194. for ( const tableCell of this.getSelectedTableCells() ) {
  195. const row = tableCell.parent;
  196. if ( !rowsMap.has( row ) ) {
  197. const newRow = row._clone();
  198. writer.append( newRow, tableCopy );
  199. rowsMap.set( row, newRow );
  200. }
  201. const clonedCell = tableCell._clone( true );
  202. columnsIndexesMap.set( clonedCell, this._tableUtils.getCellLocation( tableCell ) );
  203. writer.append( clonedCell, rowsMap.get( row ) );
  204. }
  205. const { row: startRow, column: startColumn } = this._tableUtils.getCellLocation( this._startElement );
  206. const { row: endRow, column: endColumn } = this._tableUtils.getCellLocation( this._endElement );
  207. const sourceTable = findAncestor( 'table', this._startElement );
  208. // Calculate headings.
  209. const headingRows = parseInt( sourceTable.getAttribute( 'headingRows' ) || 0 );
  210. if ( headingRows > 0 ) {
  211. const copiedRows = headingRows - startRow;
  212. writer.setAttribute( 'headingRows', copiedRows, tableCopy );
  213. }
  214. const headingColumns = parseInt( sourceTable.getAttribute( 'headingColumns' ) || 0 );
  215. if ( headingColumns > 0 ) {
  216. const copiedColumns = headingColumns - startColumn;
  217. writer.setAttribute( 'headingColumns', copiedColumns, tableCopy );
  218. }
  219. // Prepend cells.
  220. for ( const row of tableCopy.getChildren() ) {
  221. for ( const tableCell of Array.from( row.getChildren() ) ) {
  222. const { column } = this._tableUtils.getCellLocation( tableCell );
  223. const { column: originalColumn } = columnsIndexesMap.get( tableCell );
  224. const shiftedColumn = originalColumn - startColumn;
  225. if ( column !== shiftedColumn ) {
  226. for ( let i = 0; i < shiftedColumn - column; i++ ) {
  227. const prepCell = writer.createElement( 'tableCell' );
  228. writer.insert( prepCell, writer.createPositionBefore( tableCell ) );
  229. const paragraph = writer.createElement( 'paragraph' );
  230. writer.insert( paragraph, prepCell, 0 );
  231. writer.insertText( '', paragraph, 0 );
  232. }
  233. }
  234. }
  235. }
  236. // Trim table.
  237. const width = endColumn - startColumn + 1;
  238. const height = endRow - startRow + 1;
  239. for ( const row of tableCopy.getChildren() ) {
  240. for ( const tableCell of row.getChildren() ) {
  241. const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
  242. const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );
  243. const { row, column } = this._tableUtils.getCellLocation( tableCell );
  244. if ( column + colspan > width ) {
  245. const newSpan = width - column;
  246. if ( newSpan > 1 ) {
  247. writer.setAttribute( 'colspan', newSpan, tableCell );
  248. } else {
  249. writer.removeAttribute( 'colspan', tableCell );
  250. }
  251. }
  252. if ( row + rowspan > height ) {
  253. const newSpan = height - row;
  254. if ( newSpan > 1 ) {
  255. writer.setAttribute( 'rowspan', newSpan, tableCell );
  256. } else {
  257. writer.removeAttribute( 'rowspan', tableCell );
  258. }
  259. }
  260. }
  261. }
  262. return fragment;
  263. } );
  264. }
  265. /**
  266. * Returns iterator for selected table cells.
  267. *
  268. * tableSelection.startSelectingFrom( startTableCell );
  269. * tableSelection.stopSelection( endTableCell );
  270. *
  271. * const selectedTableCells = Array.from( tableSelection.getSelectedTableCells() );
  272. * // The above array will consist a rectangular table selection.
  273. *
  274. * @returns {Iterable.<module:engine/model/element~Element>}
  275. */
  276. * getSelectedTableCells() {
  277. if ( !this.hasMultiCellSelection ) {
  278. return;
  279. }
  280. const startLocation = this._tableUtils.getCellLocation( this._startElement );
  281. const endLocation = this._tableUtils.getCellLocation( this._endElement );
  282. const startRow = startLocation.row > endLocation.row ? endLocation.row : startLocation.row;
  283. const endRow = startLocation.row > endLocation.row ? startLocation.row : endLocation.row;
  284. const startColumn = startLocation.column > endLocation.column ? endLocation.column : startLocation.column;
  285. const endColumn = startLocation.column > endLocation.column ? startLocation.column : endLocation.column;
  286. for ( const cellInfo of new TableWalker( this._startElement.parent.parent, { startRow, endRow } ) ) {
  287. if ( cellInfo.column >= startColumn && cellInfo.column <= endColumn ) {
  288. yield cellInfo.cell;
  289. }
  290. }
  291. }
  292. /**
  293. * Set proper model selection for currently selected table cells.
  294. *
  295. * @private
  296. */
  297. _updateModelSelection() {
  298. if ( !this.hasMultiCellSelection ) {
  299. return;
  300. }
  301. const editor = this.editor;
  302. const model = editor.model;
  303. const modelRanges = [];
  304. for ( const tableCell of this.getSelectedTableCells() ) {
  305. modelRanges.push( model.createRangeOn( tableCell ) );
  306. }
  307. // Update model's selection
  308. model.change( writer => {
  309. writer.setSelection( modelRanges );
  310. } );
  311. }
  312. /**
  313. * Checks if selection has changed from an external source and it is required to clear internal state.
  314. *
  315. * @param {module:engine/model/documentselection~DocumentSelection} selection
  316. * @private
  317. */
  318. _clearSelectionOnExternalChange( selection ) {
  319. if ( selection.rangeCount <= 1 && this.hasMultiCellSelection ) {
  320. this.clearSelection();
  321. }
  322. }
  323. }