downcast.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491
  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/converters/downcast
  7. */
  8. import TableWalker from './../tablewalker';
  9. import { toWidget, toWidgetEditable, setHighlightHandling } from '@ckeditor/ckeditor5-widget/src/utils';
  10. /**
  11. * Model table element to view table element conversion helper.
  12. *
  13. * This conversion helper creates the whole table element with child elements.
  14. *
  15. * @param {Object} options
  16. * @param {Boolean} options.asWidget If set to `true`, the downcast conversion will produce a widget.
  17. * @returns {Function} Conversion helper.
  18. */
  19. export function downcastInsertTable( options = {} ) {
  20. return dispatcher => dispatcher.on( 'insert:table', ( evt, data, conversionApi ) => {
  21. const table = data.item;
  22. if ( !conversionApi.consumable.consume( table, 'insert' ) ) {
  23. return;
  24. }
  25. // Consume attributes if present to not fire attribute change downcast
  26. conversionApi.consumable.consume( table, 'attribute:headingRows:table' );
  27. conversionApi.consumable.consume( table, 'attribute:headingColumns:table' );
  28. const asWidget = options && options.asWidget;
  29. const figureElement = conversionApi.writer.createContainerElement( 'figure', { class: 'table' } );
  30. const tableElement = conversionApi.writer.createContainerElement( 'table' );
  31. conversionApi.writer.insert( conversionApi.writer.createPositionAt( figureElement, 0 ), tableElement );
  32. let tableWidget;
  33. if ( asWidget ) {
  34. tableWidget = toTableWidget( figureElement, conversionApi.writer );
  35. }
  36. const tableWalker = new TableWalker( table );
  37. const tableAttributes = {
  38. headingRows: table.getAttribute( 'headingRows' ) || 0,
  39. headingColumns: table.getAttribute( 'headingColumns' ) || 0
  40. };
  41. // Cache for created table rows.
  42. const viewRows = new Map();
  43. for ( const tableSlot of tableWalker ) {
  44. const { row, cell } = tableSlot;
  45. const tableRow = table.getChild( row );
  46. const trElement = viewRows.get( row ) || createTr( tableElement, tableRow, row, tableAttributes, conversionApi );
  47. viewRows.set( row, trElement );
  48. // Consume table cell - it will be always consumed as we convert whole table at once.
  49. conversionApi.consumable.consume( cell, 'insert' );
  50. const insertPosition = conversionApi.writer.createPositionAt( trElement, 'end' );
  51. createViewTableCellElement( tableSlot, tableAttributes, insertPosition, conversionApi, options );
  52. }
  53. // Insert empty TR elements if there are any rows without anchored cells. Since the model is always normalized
  54. // this can happen only in the document fragment that only part of the table is down-casted.
  55. for ( const tableRow of table.getChildren() ) {
  56. const rowIndex = tableRow.index;
  57. if ( !viewRows.has( rowIndex ) ) {
  58. viewRows.set( rowIndex, createTr( tableElement, tableRow, rowIndex, tableAttributes, conversionApi ) );
  59. }
  60. }
  61. const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
  62. conversionApi.mapper.bindElements( table, asWidget ? tableWidget : figureElement );
  63. conversionApi.writer.insert( viewPosition, asWidget ? tableWidget : figureElement );
  64. } );
  65. }
  66. /**
  67. * Model row element to view `<tr>` element conversion helper.
  68. *
  69. * This conversion helper creates the whole `<tr>` element with child elements.
  70. *
  71. * @returns {Function} Conversion helper.
  72. */
  73. export function downcastInsertRow() {
  74. return dispatcher => dispatcher.on( 'insert:tableRow', ( evt, data, conversionApi ) => {
  75. const tableRow = data.item;
  76. if ( !conversionApi.consumable.consume( tableRow, 'insert' ) ) {
  77. return;
  78. }
  79. const table = tableRow.parent;
  80. const figureElement = conversionApi.mapper.toViewElement( table );
  81. const tableElement = getViewTable( figureElement );
  82. const row = table.getChildIndex( tableRow );
  83. const tableWalker = new TableWalker( table, { row } );
  84. const tableAttributes = {
  85. headingRows: table.getAttribute( 'headingRows' ) || 0,
  86. headingColumns: table.getAttribute( 'headingColumns' ) || 0
  87. };
  88. // Cache for created table rows.
  89. const viewRows = new Map();
  90. for ( const tableSlot of tableWalker ) {
  91. const trElement = viewRows.get( row ) || createTr( tableElement, tableRow, row, tableAttributes, conversionApi );
  92. viewRows.set( row, trElement );
  93. // Consume table cell - it will be always consumed as we convert whole row at once.
  94. conversionApi.consumable.consume( tableSlot.cell, 'insert' );
  95. const insertPosition = conversionApi.writer.createPositionAt( trElement, 'end' );
  96. createViewTableCellElement( tableSlot, tableAttributes, insertPosition, conversionApi, { asWidget: true } );
  97. }
  98. } );
  99. }
  100. /**
  101. * Model table cell element to view `<td>` or `<th>` element conversion helper.
  102. *
  103. * This conversion helper will create proper `<th>` elements for table cells that are in the heading section (heading row or column)
  104. * and `<td>` otherwise.
  105. *
  106. * @returns {Function} Conversion helper.
  107. */
  108. export function downcastInsertCell() {
  109. return dispatcher => dispatcher.on( 'insert:tableCell', ( evt, data, conversionApi ) => {
  110. const tableCell = data.item;
  111. if ( !conversionApi.consumable.consume( tableCell, 'insert' ) ) {
  112. return;
  113. }
  114. const tableRow = tableCell.parent;
  115. const table = tableRow.parent;
  116. const rowIndex = table.getChildIndex( tableRow );
  117. const tableWalker = new TableWalker( table, { row: rowIndex } );
  118. const tableAttributes = {
  119. headingRows: table.getAttribute( 'headingRows' ) || 0,
  120. headingColumns: table.getAttribute( 'headingColumns' ) || 0
  121. };
  122. // We need to iterate over a table in order to get proper row & column values from a walker
  123. for ( const tableSlot of tableWalker ) {
  124. if ( tableSlot.cell === tableCell ) {
  125. const trElement = conversionApi.mapper.toViewElement( tableRow );
  126. const insertPosition = conversionApi.writer.createPositionAt( trElement, tableRow.getChildIndex( tableCell ) );
  127. createViewTableCellElement( tableSlot, tableAttributes, insertPosition, conversionApi, { asWidget: true } );
  128. // No need to iterate further.
  129. return;
  130. }
  131. }
  132. } );
  133. }
  134. /**
  135. * Conversion helper that acts on heading column table attribute change.
  136. *
  137. * Depending on changed attributes this converter will rename `<td` to `<th>` elements or vice versa depending on the cell column index.
  138. *
  139. * @returns {Function} Conversion helper.
  140. */
  141. export function downcastTableHeadingColumnsChange() {
  142. return dispatcher => dispatcher.on( 'attribute:headingColumns:table', ( evt, data, conversionApi ) => {
  143. const table = data.item;
  144. if ( !conversionApi.consumable.consume( data.item, evt.name ) ) {
  145. return;
  146. }
  147. const tableAttributes = {
  148. headingRows: table.getAttribute( 'headingRows' ) || 0,
  149. headingColumns: table.getAttribute( 'headingColumns' ) || 0
  150. };
  151. const oldColumns = data.attributeOldValue;
  152. const newColumns = data.attributeNewValue;
  153. const lastColumnToCheck = ( oldColumns > newColumns ? oldColumns : newColumns ) - 1;
  154. for ( const tableSlot of new TableWalker( table, { endColumn: lastColumnToCheck } ) ) {
  155. renameViewTableCellIfRequired( tableSlot, tableAttributes, conversionApi );
  156. }
  157. } );
  158. }
  159. /**
  160. * Conversion helper that acts on a removed row.
  161. *
  162. * @returns {Function} Conversion helper.
  163. */
  164. export function downcastRemoveRow() {
  165. return dispatcher => dispatcher.on( 'remove:tableRow', ( evt, data, conversionApi ) => {
  166. // Prevent default remove converter.
  167. evt.stop();
  168. const viewWriter = conversionApi.writer;
  169. const mapper = conversionApi.mapper;
  170. const viewStart = mapper.toViewPosition( data.position ).getLastMatchingPosition( value => !value.item.is( 'tr' ) );
  171. const viewItem = viewStart.nodeAfter;
  172. const tableSection = viewItem.parent;
  173. const viewTable = tableSection.parent;
  174. // Remove associated <tr> from the view.
  175. const removeRange = viewWriter.createRangeOn( viewItem );
  176. const removed = viewWriter.remove( removeRange );
  177. for ( const child of viewWriter.createRangeIn( removed ).getItems() ) {
  178. mapper.unbindViewElement( child );
  179. }
  180. // Cleanup: Ensure that thead & tbody sections are removed if left empty after removing rows. See #6437, #6391.
  181. removeTableSectionIfEmpty( 'thead', viewTable, conversionApi );
  182. removeTableSectionIfEmpty( 'tbody', viewTable, conversionApi );
  183. }, { priority: 'higher' } );
  184. }
  185. // Converts a given {@link module:engine/view/element~Element} to a table widget:
  186. // * Adds a {@link module:engine/view/element~Element#_setCustomProperty custom property} allowing to recognize the table widget element.
  187. // * Calls the {@link module:widget/utils~toWidget} function with the proper element's label creator.
  188. //
  189. // @param {module:engine/view/element~Element} viewElement
  190. // @param {module:engine/view/downcastwriter~DowncastWriter} writer An instance of the view writer.
  191. // @param {String} label The element's label. It will be concatenated with the table `alt` attribute if one is present.
  192. // @returns {module:engine/view/element~Element}
  193. function toTableWidget( viewElement, writer ) {
  194. writer.setCustomProperty( 'table', true, viewElement );
  195. return toWidget( viewElement, writer, { hasSelectionHandle: true } );
  196. }
  197. // Renames an existing table cell in the view to a given element name.
  198. //
  199. // **Note** This method will not do anything if a view table cell has not been converted yet.
  200. //
  201. // @param {module:engine/model/element~Element} tableCell
  202. // @param {String} desiredCellElementName
  203. // @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
  204. function renameViewTableCell( tableCell, desiredCellElementName, conversionApi ) {
  205. const viewWriter = conversionApi.writer;
  206. const viewCell = conversionApi.mapper.toViewElement( tableCell );
  207. const editable = viewWriter.createEditableElement( desiredCellElementName, viewCell.getAttributes() );
  208. const renamedCell = toWidgetEditable( editable, viewWriter );
  209. setHighlightHandling(
  210. renamedCell,
  211. viewWriter,
  212. ( element, descriptor, writer ) => writer.addClass( normalizeToArray( descriptor.classes ), element ),
  213. ( element, descriptor, writer ) => writer.removeClass( normalizeToArray( descriptor.classes ), element )
  214. );
  215. viewWriter.insert( viewWriter.createPositionAfter( viewCell ), renamedCell );
  216. viewWriter.move( viewWriter.createRangeIn( viewCell ), viewWriter.createPositionAt( renamedCell, 0 ) );
  217. viewWriter.remove( viewWriter.createRangeOn( viewCell ) );
  218. conversionApi.mapper.unbindViewElement( viewCell );
  219. conversionApi.mapper.bindElements( tableCell, renamedCell );
  220. }
  221. // Renames a table cell element in the view according to its location in the table.
  222. //
  223. // @param {module:table/tablewalker~TableSlot} tableSlot
  224. // @param {{headingColumns, headingRows}} tableAttributes
  225. // @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
  226. function renameViewTableCellIfRequired( tableSlot, tableAttributes, conversionApi ) {
  227. const { cell } = tableSlot;
  228. // Check whether current columnIndex is overlapped by table cells from previous rows.
  229. const desiredCellElementName = getCellElementName( tableSlot, tableAttributes );
  230. const viewCell = conversionApi.mapper.toViewElement( cell );
  231. // If in single change we're converting attribute changes and inserting cell the table cell might not be inserted into view
  232. // because of child conversion is done after parent.
  233. if ( viewCell && viewCell.name !== desiredCellElementName ) {
  234. renameViewTableCell( cell, desiredCellElementName, conversionApi );
  235. }
  236. }
  237. // Creates a table cell element in the view.
  238. //
  239. // @param {module:table/tablewalker~TableSlot} tableSlot
  240. // @param {module:engine/view/position~Position} insertPosition
  241. // @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
  242. function createViewTableCellElement( tableSlot, tableAttributes, insertPosition, conversionApi, options ) {
  243. const asWidget = options && options.asWidget;
  244. const cellElementName = getCellElementName( tableSlot, tableAttributes );
  245. const cellElement = asWidget ?
  246. toWidgetEditable( conversionApi.writer.createEditableElement( cellElementName ), conversionApi.writer ) :
  247. conversionApi.writer.createContainerElement( cellElementName );
  248. if ( asWidget ) {
  249. setHighlightHandling(
  250. cellElement,
  251. conversionApi.writer,
  252. ( element, descriptor, writer ) => writer.addClass( normalizeToArray( descriptor.classes ), element ),
  253. ( element, descriptor, writer ) => writer.removeClass( normalizeToArray( descriptor.classes ), element )
  254. );
  255. }
  256. const tableCell = tableSlot.cell;
  257. const firstChild = tableCell.getChild( 0 );
  258. const isSingleParagraph = tableCell.childCount === 1 && firstChild.name === 'paragraph';
  259. conversionApi.writer.insert( insertPosition, cellElement );
  260. if ( isSingleParagraph && !hasAnyAttribute( firstChild ) ) {
  261. const innerParagraph = tableCell.getChild( 0 );
  262. const paragraphInsertPosition = conversionApi.writer.createPositionAt( cellElement, 'end' );
  263. conversionApi.consumable.consume( innerParagraph, 'insert' );
  264. if ( asWidget ) {
  265. // Use display:inline-block to force Chrome/Safari to limit text mutations to this element.
  266. // See #6062.
  267. const fakeParagraph = conversionApi.writer.createContainerElement( 'span', { style: 'display:inline-block' } );
  268. conversionApi.mapper.bindElements( innerParagraph, fakeParagraph );
  269. conversionApi.writer.insert( paragraphInsertPosition, fakeParagraph );
  270. conversionApi.mapper.bindElements( tableCell, cellElement );
  271. } else {
  272. conversionApi.mapper.bindElements( tableCell, cellElement );
  273. conversionApi.mapper.bindElements( innerParagraph, cellElement );
  274. }
  275. } else {
  276. conversionApi.mapper.bindElements( tableCell, cellElement );
  277. }
  278. }
  279. // Creates a `<tr>` view element.
  280. //
  281. // @param {module:engine/view/element~Element} tableElement
  282. // @param {module:engine/model/element~Element} tableRow
  283. // @param {Number} rowIndex
  284. // @param {{headingColumns, headingRows}} tableAttributes
  285. // @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
  286. // @returns {module:engine/view/element~Element}
  287. function createTr( tableElement, tableRow, rowIndex, tableAttributes, conversionApi ) {
  288. // Will always consume since we're converting <tableRow> element from a parent <table>.
  289. conversionApi.consumable.consume( tableRow, 'insert' );
  290. const trElement = tableRow.isEmpty ?
  291. conversionApi.writer.createEmptyElement( 'tr' ) :
  292. conversionApi.writer.createContainerElement( 'tr' );
  293. conversionApi.mapper.bindElements( tableRow, trElement );
  294. const headingRows = tableAttributes.headingRows;
  295. const tableSection = getOrCreateTableSection( getSectionName( rowIndex, tableAttributes ), tableElement, conversionApi );
  296. const offset = headingRows > 0 && rowIndex >= headingRows ? rowIndex - headingRows : rowIndex;
  297. const position = conversionApi.writer.createPositionAt( tableSection, offset );
  298. conversionApi.writer.insert( position, trElement );
  299. return trElement;
  300. }
  301. // Returns `th` for heading cells and `td` for other cells for the current table walker value.
  302. //
  303. // @param {module:table/tablewalker~TableSlot} tableSlot
  304. // @param {{headingColumns, headingRows}} tableAttributes
  305. // @returns {String}
  306. function getCellElementName( tableSlot, tableAttributes ) {
  307. const { row, column } = tableSlot;
  308. const { headingColumns, headingRows } = tableAttributes;
  309. // Column heading are all tableCells in the first `columnHeading` rows.
  310. const isColumnHeading = headingRows && headingRows > row;
  311. // So a whole row gets <th> element.
  312. if ( isColumnHeading ) {
  313. return 'th';
  314. }
  315. // Row heading are tableCells which columnIndex is lower then headingColumns.
  316. const isRowHeading = headingColumns && headingColumns > column;
  317. return isRowHeading ? 'th' : 'td';
  318. }
  319. // Returns the table section name for the current table walker value.
  320. //
  321. // @param {Number} row
  322. // @param {{headingColumns, headingRows}} tableAttributes
  323. // @returns {String}
  324. function getSectionName( row, tableAttributes ) {
  325. return row < tableAttributes.headingRows ? 'thead' : 'tbody';
  326. }
  327. // Creates or returns an existing `<tbody>` or `<thead>` element with caching.
  328. //
  329. // @param {String} sectionName
  330. // @param {module:engine/view/element~Element} viewTable
  331. // @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
  332. // @param {Object} cachedTableSection An object that stores cached elements.
  333. // @returns {module:engine/view/containerelement~ContainerElement}
  334. function getOrCreateTableSection( sectionName, viewTable, conversionApi ) {
  335. const viewTableSection = getExistingTableSectionElement( sectionName, viewTable );
  336. return viewTableSection ? viewTableSection : createTableSection( sectionName, viewTable, conversionApi );
  337. }
  338. // Finds an existing `<tbody>` or `<thead>` element or returns undefined.
  339. //
  340. // @param {String} sectionName
  341. // @param {module:engine/view/element~Element} tableElement
  342. // @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
  343. function getExistingTableSectionElement( sectionName, tableElement ) {
  344. for ( const tableSection of tableElement.getChildren() ) {
  345. if ( tableSection.name == sectionName ) {
  346. return tableSection;
  347. }
  348. }
  349. }
  350. // Creates a table section at the end of the table.
  351. //
  352. // @param {String} sectionName
  353. // @param {module:engine/view/element~Element} tableElement
  354. // @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
  355. // @returns {module:engine/view/containerelement~ContainerElement}
  356. function createTableSection( sectionName, tableElement, conversionApi ) {
  357. const tableChildElement = conversionApi.writer.createContainerElement( sectionName );
  358. const insertPosition = conversionApi.writer.createPositionAt( tableElement, sectionName == 'tbody' ? 'end' : 0 );
  359. conversionApi.writer.insert( insertPosition, tableChildElement );
  360. return tableChildElement;
  361. }
  362. // Removes an existing `<tbody>` or `<thead>` element if it is empty.
  363. //
  364. // @param {String} sectionName
  365. // @param {module:engine/view/element~Element} tableElement
  366. // @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
  367. function removeTableSectionIfEmpty( sectionName, tableElement, conversionApi ) {
  368. const tableSection = getExistingTableSectionElement( sectionName, tableElement );
  369. if ( tableSection && tableSection.childCount === 0 ) {
  370. conversionApi.writer.remove( conversionApi.writer.createRangeOn( tableSection ) );
  371. }
  372. }
  373. // Finds a '<table>' element inside the `<figure>` widget.
  374. //
  375. // @param {module:engine/view/element~Element} viewFigure
  376. function getViewTable( viewFigure ) {
  377. for ( const child of viewFigure.getChildren() ) {
  378. if ( child.name === 'table' ) {
  379. return child;
  380. }
  381. }
  382. }
  383. // Checks if an element has any attributes set.
  384. //
  385. // @param {module:engine/model/element~Element element
  386. // @returns {Boolean}
  387. function hasAnyAttribute( element ) {
  388. return !![ ...element.getAttributeKeys() ].length;
  389. }
  390. function normalizeToArray( classes ) {
  391. return Array.isArray( classes ) ? classes : [ classes ];
  392. }