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 | */
|
5 | import { createEmptyTableCell } from '../utils/common';
|
6 | import { 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 | */
|
20 | export 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 | */
|
57 | export 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 | */
|
105 | export 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 | */
|
119 | export 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 | */
|
148 | function 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 | */
|
162 | function 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 | */
|
228 | function 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 | }
|