-
Notifications
You must be signed in to change notification settings - Fork 3.6k
/
downcast.js
563 lines (451 loc) · 21.3 KB
/
downcast.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
/**
* @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/
/**
* @module table/converters/downcast
*/
import TableWalker from './../tablewalker';
import { toWidgetEditable } from '@ckeditor/ckeditor5-widget/src/utils';
import { toTableWidget } from '../utils';
/**
* Model table element to view table element conversion helper.
*
* This conversion helper creates the whole table element with child elements.
*
* @param {Object} options
* @param {Boolean} options.asWidget If set to `true`, the downcast conversion will produce a widget.
* @returns {Function} Conversion helper.
*/
export function downcastInsertTable( options = {} ) {
return dispatcher => dispatcher.on( 'insert:table', ( evt, data, conversionApi ) => {
const table = data.item;
if ( !conversionApi.consumable.consume( table, 'insert' ) ) {
return;
}
// Consume attributes if present to not fire attribute change downcast
conversionApi.consumable.consume( table, 'attribute:headingRows:table' );
conversionApi.consumable.consume( table, 'attribute:headingColumns:table' );
const asWidget = options && options.asWidget;
const figureElement = conversionApi.writer.createContainerElement( 'figure', { class: 'table' } );
const tableElement = conversionApi.writer.createContainerElement( 'table' );
conversionApi.writer.insert( conversionApi.writer.createPositionAt( figureElement, 0 ), tableElement );
let tableWidget;
if ( asWidget ) {
tableWidget = toTableWidget( figureElement, conversionApi.writer );
}
const tableWalker = new TableWalker( table );
const tableAttributes = {
headingRows: table.getAttribute( 'headingRows' ) || 0,
headingColumns: table.getAttribute( 'headingColumns' ) || 0
};
// Cache for created table rows.
const viewRows = new Map();
for ( const tableWalkerValue of tableWalker ) {
const { row, cell } = tableWalkerValue;
const tableSection = getOrCreateTableSection( getSectionName( row, tableAttributes ), tableElement, conversionApi );
const tableRow = table.getChild( row );
const trElement = viewRows.get( row ) || createTr( tableRow, row, tableSection, conversionApi );
viewRows.set( row, trElement );
// Consume table cell - it will be always consumed as we convert whole table at once.
conversionApi.consumable.consume( cell, 'insert' );
const insertPosition = conversionApi.writer.createPositionAt( trElement, 'end' );
createViewTableCellElement( tableWalkerValue, tableAttributes, insertPosition, conversionApi, options );
}
const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
conversionApi.mapper.bindElements( table, asWidget ? tableWidget : figureElement );
conversionApi.writer.insert( viewPosition, asWidget ? tableWidget : figureElement );
} );
}
/**
* Model row element to view `<tr>` element conversion helper.
*
* This conversion helper creates the whole `<tr>` element with child elements.
*
* @returns {Function} Conversion helper.
*/
export function downcastInsertRow( options = {} ) {
return dispatcher => dispatcher.on( 'insert:tableRow', ( evt, data, conversionApi ) => {
const tableRow = data.item;
if ( !conversionApi.consumable.consume( tableRow, 'insert' ) ) {
return;
}
const table = tableRow.parent;
const figureElement = conversionApi.mapper.toViewElement( table );
const tableElement = getViewTable( figureElement );
const row = table.getChildIndex( tableRow );
const tableWalker = new TableWalker( table, { startRow: row, endRow: row } );
const tableAttributes = {
headingRows: table.getAttribute( 'headingRows' ) || 0,
headingColumns: table.getAttribute( 'headingColumns' ) || 0
};
// Cache for created table rows.
const viewRows = new Map();
for ( const tableWalkerValue of tableWalker ) {
const tableSection = getOrCreateTableSection( getSectionName( row, tableAttributes ), tableElement, conversionApi );
const trElement = viewRows.get( row ) || createTr( tableRow, row, tableSection, conversionApi );
viewRows.set( row, trElement );
// Consume table cell - it will be always consumed as we convert whole row at once.
conversionApi.consumable.consume( tableWalkerValue.cell, 'insert' );
const insertPosition = conversionApi.writer.createPositionAt( trElement, 'end' );
createViewTableCellElement( tableWalkerValue, tableAttributes, insertPosition, conversionApi, options );
}
} );
}
/**
* Model table cell element to view `<td>` or `<th>` element conversion helper.
*
* This conversion helper will create proper `<th>` elements for table cells that are in the heading section (heading row or column)
* and `<td>` otherwise.
*
* @returns {Function} Conversion helper.
*/
export function downcastInsertCell( options = {} ) {
return dispatcher => dispatcher.on( 'insert:tableCell', ( evt, data, conversionApi ) => {
const tableCell = data.item;
if ( !conversionApi.consumable.consume( tableCell, 'insert' ) ) {
return;
}
const tableRow = tableCell.parent;
const table = tableRow.parent;
const rowIndex = table.getChildIndex( tableRow );
const tableWalker = new TableWalker( table, { startRow: rowIndex, endRow: rowIndex } );
const tableAttributes = {
headingRows: table.getAttribute( 'headingRows' ) || 0,
headingColumns: table.getAttribute( 'headingColumns' ) || 0
};
// We need to iterate over a table in order to get proper row & column values from a walker
for ( const tableWalkerValue of tableWalker ) {
if ( tableWalkerValue.cell === tableCell ) {
const trElement = conversionApi.mapper.toViewElement( tableRow );
const insertPosition = conversionApi.writer.createPositionAt( trElement, tableRow.getChildIndex( tableCell ) );
createViewTableCellElement( tableWalkerValue, tableAttributes, insertPosition, conversionApi, options );
// No need to iterate further.
return;
}
}
} );
}
/**
* Conversion helper that acts on heading row table attribute change.
*
* This converter will:
*
* * Rename `<td>` to `<th>` elements or vice versa depending on headings.
* * Create `<thead>` or `<tbody>` elements if needed.
* * Remove empty `<thead>` or `<tbody>` if needed.
*
* @returns {Function} Conversion helper.
*/
export function downcastTableHeadingRowsChange( options = {} ) {
const asWidget = !!options.asWidget;
return dispatcher => dispatcher.on( 'attribute:headingRows:table', ( evt, data, conversionApi ) => {
const table = data.item;
if ( !conversionApi.consumable.consume( data.item, evt.name ) ) {
return;
}
const figureElement = conversionApi.mapper.toViewElement( table );
const viewTable = getViewTable( figureElement );
const oldRows = data.attributeOldValue;
const newRows = data.attributeNewValue;
// The head section has grown so move rows from <tbody> to <thead>.
if ( newRows > oldRows ) {
// Filter out only those rows that are in wrong section.
const rowsToMove = Array.from( table.getChildren() ).filter( ( { index } ) => isBetween( index, oldRows - 1, newRows ) );
const viewTableHead = getOrCreateTableSection( 'thead', viewTable, conversionApi );
moveViewRowsToTableSection( rowsToMove, viewTableHead, conversionApi, 'end' );
// Rename all table cells from moved rows to 'th' as they lands in <thead>.
for ( const tableRow of rowsToMove ) {
for ( const tableCell of tableRow.getChildren() ) {
renameViewTableCell( tableCell, 'th', conversionApi, asWidget );
}
}
}
// The head section has shrunk so move rows from <thead> to <tbody>.
else {
// Filter out only those rows that are in wrong section.
const rowsToMove = Array.from( table.getChildren() )
.filter( ( { index } ) => isBetween( index, newRows - 1, oldRows ) )
.reverse(); // The rows will be moved from <thead> to <tbody> in reverse order at the beginning of a <tbody>.
const viewTableBody = getOrCreateTableSection( 'tbody', viewTable, conversionApi );
moveViewRowsToTableSection( rowsToMove, viewTableBody, conversionApi, 0 );
// Check if cells moved from <thead> to <tbody> requires renaming to <td> as this depends on current heading columns attribute.
const tableWalker = new TableWalker( table, { startRow: newRows ? newRows - 1 : newRows, endRow: oldRows - 1 } );
const tableAttributes = {
headingRows: table.getAttribute( 'headingRows' ) || 0,
headingColumns: table.getAttribute( 'headingColumns' ) || 0
};
for ( const tableWalkerValue of tableWalker ) {
renameViewTableCellIfRequired( tableWalkerValue, tableAttributes, conversionApi, asWidget );
}
}
// Cleanup: Ensure that thead & tbody sections are removed if left empty after moving rows. See #6437, #6391.
removeTableSectionIfEmpty( 'thead', viewTable, conversionApi );
removeTableSectionIfEmpty( 'tbody', viewTable, conversionApi );
function isBetween( index, lower, upper ) {
return index > lower && index < upper;
}
} );
}
/**
* Conversion helper that acts on heading column table attribute change.
*
* Depending on changed attributes this converter will rename `<td` to `<th>` elements or vice versa depending on the cell column index.
*
* @returns {Function} Conversion helper.
*/
export function downcastTableHeadingColumnsChange( options = {} ) {
const asWidget = !!options.asWidget;
return dispatcher => dispatcher.on( 'attribute:headingColumns:table', ( evt, data, conversionApi ) => {
const table = data.item;
if ( !conversionApi.consumable.consume( data.item, evt.name ) ) {
return;
}
const tableAttributes = {
headingRows: table.getAttribute( 'headingRows' ) || 0,
headingColumns: table.getAttribute( 'headingColumns' ) || 0
};
const oldColumns = data.attributeOldValue;
const newColumns = data.attributeNewValue;
const lastColumnToCheck = ( oldColumns > newColumns ? oldColumns : newColumns ) - 1;
for ( const tableWalkerValue of new TableWalker( table ) ) {
// Skip cells that were not in heading section before and after the change.
if ( tableWalkerValue.column > lastColumnToCheck ) {
continue;
}
renameViewTableCellIfRequired( tableWalkerValue, tableAttributes, conversionApi, asWidget );
}
} );
}
/**
* Conversion helper that acts on a removed row.
*
* @returns {Function} Conversion helper.
*/
export function downcastRemoveRow() {
return dispatcher => dispatcher.on( 'remove:tableRow', ( evt, data, conversionApi ) => {
// Prevent default remove converter.
evt.stop();
const viewWriter = conversionApi.writer;
const mapper = conversionApi.mapper;
const viewStart = mapper.toViewPosition( data.position ).getLastMatchingPosition( value => !value.item.is( 'tr' ) );
const viewItem = viewStart.nodeAfter;
const tableSection = viewItem.parent;
const viewTable = tableSection.parent;
// Remove associated <tr> from the view.
const removeRange = viewWriter.createRangeOn( viewItem );
const removed = viewWriter.remove( removeRange );
for ( const child of viewWriter.createRangeIn( removed ).getItems() ) {
mapper.unbindViewElement( child );
}
// Cleanup: Ensure that thead & tbody sections are removed if left empty after removing rows. See #6437, #6391.
removeTableSectionIfEmpty( 'thead', viewTable, conversionApi );
removeTableSectionIfEmpty( 'tbody', viewTable, conversionApi );
}, { priority: 'higher' } );
}
// Renames an existing table cell in the view to a given element name.
//
// **Note** This method will not do anything if a view table cell has not been converted yet.
//
// @param {module:engine/model/element~Element} tableCell
// @param {String} desiredCellElementName
// @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
// @param {Boolean} asWidget
function renameViewTableCell( tableCell, desiredCellElementName, conversionApi, asWidget ) {
const viewWriter = conversionApi.writer;
const viewCell = conversionApi.mapper.toViewElement( tableCell );
// View cell might be not yet converted - skip it as it will be properly created by cell converter later on.
if ( !viewCell ) {
return;
}
let renamedCell;
if ( asWidget ) {
const editable = viewWriter.createEditableElement( desiredCellElementName, viewCell.getAttributes() );
renamedCell = toWidgetEditable( editable, viewWriter );
viewWriter.insert( viewWriter.createPositionAfter( viewCell ), renamedCell );
viewWriter.move( viewWriter.createRangeIn( viewCell ), viewWriter.createPositionAt( renamedCell, 0 ) );
viewWriter.remove( viewWriter.createRangeOn( viewCell ) );
} else {
renamedCell = viewWriter.rename( desiredCellElementName, viewCell );
}
conversionApi.mapper.unbindViewElement( viewCell );
conversionApi.mapper.bindElements( tableCell, renamedCell );
}
// Renames a table cell element in the view according to its location in the table.
//
// @param {module:table/tablewalker~TableWalkerValue} tableWalkerValue
// @param {{headingColumns, headingRows}} tableAttributes
// @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
// @param {Boolean} asWidget
function renameViewTableCellIfRequired( tableWalkerValue, tableAttributes, conversionApi, asWidget ) {
const { cell } = tableWalkerValue;
// Check whether current columnIndex is overlapped by table cells from previous rows.
const desiredCellElementName = getCellElementName( tableWalkerValue, tableAttributes );
const viewCell = conversionApi.mapper.toViewElement( cell );
// If in single change we're converting attribute changes and inserting cell the table cell might not be inserted into view
// because of child conversion is done after parent.
if ( viewCell && viewCell.name !== desiredCellElementName ) {
renameViewTableCell( cell, desiredCellElementName, conversionApi, asWidget );
}
}
// Creates a table cell element in the view.
//
// @param {module:table/tablewalker~TableWalkerValue} tableWalkerValue
// @param {module:engine/view/position~Position} insertPosition
// @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
function createViewTableCellElement( tableWalkerValue, tableAttributes, insertPosition, conversionApi, options ) {
const asWidget = options && options.asWidget;
const cellElementName = getCellElementName( tableWalkerValue, tableAttributes );
const cellElement = asWidget ?
toWidgetEditable( conversionApi.writer.createEditableElement( cellElementName ), conversionApi.writer ) :
conversionApi.writer.createContainerElement( cellElementName );
const tableCell = tableWalkerValue.cell;
const firstChild = tableCell.getChild( 0 );
const isSingleParagraph = tableCell.childCount === 1 && firstChild.name === 'paragraph';
conversionApi.writer.insert( insertPosition, cellElement );
if ( isSingleParagraph && !hasAnyAttribute( firstChild ) ) {
const innerParagraph = tableCell.getChild( 0 );
const paragraphInsertPosition = conversionApi.writer.createPositionAt( cellElement, 'end' );
conversionApi.consumable.consume( innerParagraph, 'insert' );
if ( options.asWidget ) {
// Use display:inline-block to force Chrome/Safari to limit text mutations to this element.
// See #6062.
const fakeParagraph = conversionApi.writer.createContainerElement( 'span', { style: 'display:inline-block' } );
conversionApi.mapper.bindElements( innerParagraph, fakeParagraph );
conversionApi.writer.insert( paragraphInsertPosition, fakeParagraph );
conversionApi.mapper.bindElements( tableCell, cellElement );
} else {
conversionApi.mapper.bindElements( tableCell, cellElement );
conversionApi.mapper.bindElements( innerParagraph, cellElement );
}
} else {
conversionApi.mapper.bindElements( tableCell, cellElement );
}
}
// Creates a `<tr>` view element.
//
// @param {module:engine/view/element~Element} tableRow
// @param {Number} rowIndex
// @param {module:engine/view/element~Element} tableSection
// @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
// @returns {module:engine/view/element~Element}
function createTr( tableRow, rowIndex, tableSection, conversionApi ) {
// Will always consume since we're converting <tableRow> element from a parent <table>.
conversionApi.consumable.consume( tableRow, 'insert' );
const trElement = conversionApi.writer.createContainerElement( 'tr' );
conversionApi.mapper.bindElements( tableRow, trElement );
const headingRows = tableRow.parent.getAttribute( 'headingRows' ) || 0;
const offset = headingRows > 0 && rowIndex >= headingRows ? rowIndex - headingRows : rowIndex;
const position = conversionApi.writer.createPositionAt( tableSection, offset );
conversionApi.writer.insert( position, trElement );
return trElement;
}
// Returns `th` for heading cells and `td` for other cells for the current table walker value.
//
// @param {module:table/tablewalker~TableWalkerValue} tableWalkerValue
// @param {{headingColumns, headingRows}} tableAttributes
// @returns {String}
function getCellElementName( tableWalkerValue, tableAttributes ) {
const { row, column } = tableWalkerValue;
const { headingColumns, headingRows } = tableAttributes;
// Column heading are all tableCells in the first `columnHeading` rows.
const isColumnHeading = headingRows && headingRows > row;
// So a whole row gets <th> element.
if ( isColumnHeading ) {
return 'th';
}
// Row heading are tableCells which columnIndex is lower then headingColumns.
const isRowHeading = headingColumns && headingColumns > column;
return isRowHeading ? 'th' : 'td';
}
// Returns the table section name for the current table walker value.
//
// @param {Number} row
// @param {{headingColumns, headingRows}} tableAttributes
// @returns {String}
function getSectionName( row, tableAttributes ) {
return row < tableAttributes.headingRows ? 'thead' : 'tbody';
}
// Creates or returns an existing `<tbody>` or `<thead>` element with caching.
//
// @param {String} sectionName
// @param {module:engine/view/element~Element} viewTable
// @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
// @param {Object} cachedTableSection An object that stores cached elements.
// @returns {module:engine/view/containerelement~ContainerElement}
function getOrCreateTableSection( sectionName, viewTable, conversionApi ) {
const viewTableSection = getExistingTableSectionElement( sectionName, viewTable );
return viewTableSection ? viewTableSection : createTableSection( sectionName, viewTable, conversionApi );
}
// Finds an existing `<tbody>` or `<thead>` element or returns undefined.
//
// @param {String} sectionName
// @param {module:engine/view/element~Element} tableElement
// @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
function getExistingTableSectionElement( sectionName, tableElement ) {
for ( const tableSection of tableElement.getChildren() ) {
if ( tableSection.name == sectionName ) {
return tableSection;
}
}
}
// Creates a table section at the end of the table.
//
// @param {String} sectionName
// @param {module:engine/view/element~Element} tableElement
// @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
// @returns {module:engine/view/containerelement~ContainerElement}
function createTableSection( sectionName, tableElement, conversionApi ) {
const tableChildElement = conversionApi.writer.createContainerElement( sectionName );
const insertPosition = conversionApi.writer.createPositionAt( tableElement, sectionName == 'tbody' ? 'end' : 0 );
conversionApi.writer.insert( insertPosition, tableChildElement );
return tableChildElement;
}
// Removes an existing `<tbody>` or `<thead>` element if it is empty.
//
// @param {String} sectionName
// @param {module:engine/view/element~Element} tableElement
// @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
function removeTableSectionIfEmpty( sectionName, tableElement, conversionApi ) {
const tableSection = getExistingTableSectionElement( sectionName, tableElement );
if ( tableSection && tableSection.childCount === 0 ) {
conversionApi.writer.remove( conversionApi.writer.createRangeOn( tableSection ) );
}
}
// Moves view table rows associated with passed model rows to the provided table section element.
//
// **Note**: This method will skip not converted table rows.
//
// @param {Array.<module:engine/model/element~Element>} rowsToMove
// @param {module:engine/view/element~Element} viewTableSection
// @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
// @param {Number|'end'|'before'|'after'} offset Offset or one of the flags.
function moveViewRowsToTableSection( rowsToMove, viewTableSection, conversionApi, offset ) {
for ( const tableRow of rowsToMove ) {
const viewTableRow = conversionApi.mapper.toViewElement( tableRow );
// View table row might be not yet converted - skip it as it will be properly created by cell converter later on.
if ( viewTableRow ) {
conversionApi.writer.move(
conversionApi.writer.createRangeOn( viewTableRow ),
conversionApi.writer.createPositionAt( viewTableSection, offset )
);
}
}
}
// Finds a '<table>' element inside the `<figure>` widget.
//
// @param {module:engine/view/element~Element} viewFigure
function getViewTable( viewFigure ) {
for ( const child of viewFigure.getChildren() ) {
if ( child.name === 'table' ) {
return child;
}
}
}
// Checks if an element has any attributes set.
//
// @param {module:engine/model/element~Element element
// @returns {Boolean}
function hasAnyAttribute( element ) {
return !![ ...element.getAttributeKeys() ].length;
}