8
0

tableselection.js 15 KB

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