UNPKG

10.8 kBJavaScriptView Raw
1/**
2 * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved.
3 * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
4 */
5import { createEmptyTableCell } from '../utils/common';
6import { first } from 'ckeditor5/src/utils';
7/**
8 * Returns a function that converts the table view representation:
9 *
10 * ```xml
11 * <figure class="table"><table>...</table></figure>
12 * ```
13 *
14 * to the model representation:
15 *
16 * ```xml
17 * <table></table>
18 * ```
19 */
20export function upcastTableFigure() {
21 return (dispatcher) => {
22 dispatcher.on('element:figure', (evt, data, conversionApi) => {
23 // Do not convert if this is not a "table figure".
24 if (!conversionApi.consumable.test(data.viewItem, { name: true, classes: 'table' })) {
25 return;
26 }
27 // Find a table element inside the figure element.
28 const viewTable = getViewTableFromFigure(data.viewItem);
29 // Do not convert if table element is absent or was already converted.
30 if (!viewTable || !conversionApi.consumable.test(viewTable, { name: true })) {
31 return;
32 }
33 // Consume the figure to prevent other converters from processing it again.
34 conversionApi.consumable.consume(data.viewItem, { name: true, classes: 'table' });
35 // Convert view table to model table.
36 const conversionResult = conversionApi.convertItem(viewTable, data.modelCursor);
37 // Get table element from conversion result.
38 const modelTable = first(conversionResult.modelRange.getItems());
39 // When table wasn't successfully converted then finish conversion.
40 if (!modelTable) {
41 // Revert consumed figure so other features can convert it.
42 conversionApi.consumable.revert(data.viewItem, { name: true, classes: 'table' });
43 return;
44 }
45 conversionApi.convertChildren(data.viewItem, conversionApi.writer.createPositionAt(modelTable, 'end'));
46 conversionApi.updateConversionResult(modelTable, data);
47 });
48 };
49}
50/**
51 * View table element to model table element conversion helper.
52 *
53 * This conversion helper converts the table element as well as table rows.
54 *
55 * @returns Conversion helper.
56 */
57export default function upcastTable() {
58 return (dispatcher) => {
59 dispatcher.on('element:table', (evt, data, conversionApi) => {
60 const viewTable = data.viewItem;
61 // When element was already consumed then skip it.
62 if (!conversionApi.consumable.test(viewTable, { name: true })) {
63 return;
64 }
65 const { rows, headingRows, headingColumns } = scanTable(viewTable);
66 // Only set attributes if values is greater then 0.
67 const attributes = {};
68 if (headingColumns) {
69 attributes.headingColumns = headingColumns;
70 }
71 if (headingRows) {
72 attributes.headingRows = headingRows;
73 }
74 const table = conversionApi.writer.createElement('table', attributes);
75 if (!conversionApi.safeInsert(table, data.modelCursor)) {
76 return;
77 }
78 conversionApi.consumable.consume(viewTable, { name: true });
79 // Upcast table rows in proper order (heading rows first).
80 rows.forEach(row => conversionApi.convertItem(row, conversionApi.writer.createPositionAt(table, 'end')));
81 // Convert everything else.
82 conversionApi.convertChildren(viewTable, conversionApi.writer.createPositionAt(table, 'end'));
83 // Create one row and one table cell for empty table.
84 if (table.isEmpty) {
85 const row = conversionApi.writer.createElement('tableRow');
86 conversionApi.writer.insert(row, conversionApi.writer.createPositionAt(table, 'end'));
87 createEmptyTableCell(conversionApi.writer, conversionApi.writer.createPositionAt(row, 'end'));
88 }
89 conversionApi.updateConversionResult(table, data);
90 });
91 };
92}
93/**
94 * A conversion helper that skips empty <tr> elements from upcasting at the beginning of the table.
95 *
96 * An empty row is considered a table model error but when handling clipboard data there could be rows that contain only row-spanned cells
97 * and empty TR-s are used to maintain the table structure (also {@link module:table/tablewalker~TableWalker} assumes that there are only
98 * rows that have related `tableRow` elements).
99 *
100 * *Note:* Only the first empty rows are removed because they have no meaning and it solves the issue
101 * of an improper table with all empty rows.
102 *
103 * @returns Conversion helper.
104 */
105export function skipEmptyTableRow() {
106 return (dispatcher) => {
107 dispatcher.on('element:tr', (evt, data) => {
108 if (data.viewItem.isEmpty && data.modelCursor.index == 0) {
109 evt.stop();
110 }
111 }, { priority: 'high' });
112 };
113}
114/**
115 * A converter that ensures an empty paragraph is inserted in a table cell if no other content was converted.
116 *
117 * @returns Conversion helper.
118 */
119export function ensureParagraphInTableCell(elementName) {
120 return (dispatcher) => {
121 dispatcher.on(`element:${elementName}`, (evt, data, { writer }) => {
122 // The default converter will create a model range on converted table cell.
123 if (!data.modelRange) {
124 return;
125 }
126 const tableCell = data.modelRange.start.nodeAfter;
127 const modelCursor = writer.createPositionAt(tableCell, 0);
128 // Ensure a paragraph in the model for empty table cells for converted table cells.
129 if (data.viewItem.isEmpty) {
130 writer.insertElement('paragraph', modelCursor);
131 return;
132 }
133 const childNodes = Array.from(tableCell.getChildren());
134 // In case there are only markers inside the table cell then move them to the paragraph.
135 if (childNodes.every(node => node.is('element', '$marker'))) {
136 const paragraph = writer.createElement('paragraph');
137 writer.insert(paragraph, writer.createPositionAt(tableCell, 0));
138 for (const node of childNodes) {
139 writer.move(writer.createRangeOn(node), writer.createPositionAt(paragraph, 'end'));
140 }
141 }
142 }, { priority: 'low' });
143 };
144}
145/**
146 * Get view `<table>` element from the view widget (`<figure>`).
147 */
148function getViewTableFromFigure(figureView) {
149 for (const figureChild of figureView.getChildren()) {
150 if (figureChild.is('element', 'table')) {
151 return figureChild;
152 }
153 }
154}
155/**
156 * Scans table rows and extracts required metadata from the table:
157 *
158 * headingRows - The number of rows that go as table headers.
159 * headingColumns - The maximum number of row headings.
160 * rows - Sorted `<tr>` elements as they should go into the model - ie. if `<thead>` is inserted after `<tbody>` in the view.
161 */
162function scanTable(viewTable) {
163 let headingRows = 0;
164 let headingColumns = undefined;
165 // The `<tbody>` and `<thead>` sections in the DOM do not have to be in order `<thead>` -> `<tbody>` and there might be more than one
166 // of them.
167 // As the model does not have these sections, rows from different sections must be sorted.
168 // For example, below is a valid HTML table:
169 //
170 // <table>
171 // <tbody><tr><td>2</td></tr></tbody>
172 // <thead><tr><td>1</td></tr></thead>
173 // <tbody><tr><td>3</td></tr></tbody>
174 // </table>
175 //
176 // But browsers will render rows in order as: 1 as the heading and 2 and 3 as the body.
177 const headRows = [];
178 const bodyRows = [];
179 // Currently the editor does not support more then one <thead> section.
180 // Only the first <thead> from the view will be used as a heading row and the others will be converted to body rows.
181 let firstTheadElement;
182 for (const tableChild of Array.from(viewTable.getChildren())) {
183 // Only `<thead>`, `<tbody>` & `<tfoot>` from allowed table children can have `<tr>`s.
184 // The else is for future purposes (mainly `<caption>`).
185 if (tableChild.name !== 'tbody' && tableChild.name !== 'thead' && tableChild.name !== 'tfoot') {
186 continue;
187 }
188 // Save the first `<thead>` in the table as table header - all other ones will be converted to table body rows.
189 if (tableChild.name === 'thead' && !firstTheadElement) {
190 firstTheadElement = tableChild;
191 }
192 // There might be some extra empty text nodes between the `<tr>`s.
193 // Make sure further code operates on `tr`s only. (#145)
194 const trs = Array.from(tableChild.getChildren()).filter((el) => el.is('element', 'tr'));
195 for (const tr of trs) {
196 // This <tr> is a child of a first <thead> element.
197 if ((firstTheadElement && tableChild === firstTheadElement) ||
198 (tableChild.name === 'tbody' &&
199 Array.from(tr.getChildren()).length &&
200 Array.from(tr.getChildren()).every(e => e.is('element', 'th')))) {
201 headingRows++;
202 headRows.push(tr);
203 }
204 else {
205 bodyRows.push(tr);
206 // For other rows check how many column headings this row has.
207 const headingCols = scanRowForHeadingColumns(tr);
208 if (!headingColumns || headingCols < headingColumns) {
209 headingColumns = headingCols;
210 }
211 }
212 }
213 }
214 return {
215 headingRows,
216 headingColumns: headingColumns || 0,
217 rows: [...headRows, ...bodyRows]
218 };
219}
220/**
221 * Scans a `<tr>` element and its children for metadata:
222 * - For heading row:
223 * - Adds this row to either the heading or the body rows.
224 * - Updates the number of heading rows.
225 * - For body rows:
226 * - Calculates the number of column headings.
227 */
228function scanRowForHeadingColumns(tr) {
229 let headingColumns = 0;
230 let index = 0;
231 // Filter out empty text nodes from tr children.
232 const children = Array.from(tr.getChildren())
233 .filter(child => child.name === 'th' || child.name === 'td');
234 // Count starting adjacent <th> elements of a <tr>.
235 while (index < children.length && children[index].name === 'th') {
236 const th = children[index];
237 // Adjust columns calculation by the number of spanned columns.
238 const colspan = parseInt(th.getAttribute('colspan') || '1');
239 headingColumns = headingColumns + colspan;
240 index++;
241 }
242 return headingColumns;
243}