Skip to content

Commit 6a1cf18

Browse files
authored
[lexical-table] Bug Fix: TableNode exportDOM fixes for partial table selection (#6889)
1 parent 230dcf2 commit 6a1cf18

File tree

4 files changed

+214
-8
lines changed

4 files changed

+214
-8
lines changed

packages/lexical-table/src/LexicalTableCellNode.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
$isLineBreakNode,
2727
$isTextNode,
2828
ElementNode,
29+
isHTMLElement,
2930
} from 'lexical';
3031

3132
import {COLUMN_WIDTH, PIXEL_VALUE_REG_EXP} from './constants';
@@ -150,8 +151,12 @@ export class TableCellNode extends ElementNode {
150151
exportDOM(editor: LexicalEditor): DOMExportOutput {
151152
const output = super.exportDOM(editor);
152153

153-
if (output.element) {
154+
if (output.element && isHTMLElement(output.element)) {
154155
const element = output.element as HTMLTableCellElement;
156+
element.setAttribute(
157+
'data-temporary-table-cell-lexical-key',
158+
this.getKey(),
159+
);
155160
element.style.border = '1px solid black';
156161
if (this.__colSpan > 1) {
157162
element.colSpan = this.__colSpan;

packages/lexical-table/src/LexicalTableNode.ts

+69-6
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
*
77
*/
88

9+
import type {TableRowNode} from './LexicalTableRowNode';
10+
911
import {
1012
addClassNamesToElement,
1113
isHTMLElement,
@@ -15,6 +17,7 @@ import {
1517
$applyNodeReplacement,
1618
$getEditor,
1719
$getNearestNodeFromDOMNode,
20+
BaseSelection,
1821
DOMConversionMap,
1922
DOMConversionOutput,
2023
DOMExportOutput,
@@ -31,13 +34,13 @@ import {
3134
import invariant from 'shared/invariant';
3235

3336
import {PIXEL_VALUE_REG_EXP} from './constants';
34-
import {$isTableCellNode, TableCellNode} from './LexicalTableCellNode';
37+
import {$isTableCellNode, type TableCellNode} from './LexicalTableCellNode';
3538
import {TableDOMCell, TableDOMTable} from './LexicalTableObserver';
36-
import {TableRowNode} from './LexicalTableRowNode';
3739
import {
3840
$getNearestTableCellInTableFromDOMNode,
3941
getTable,
4042
} from './LexicalTableSelectionHelpers';
43+
import {$computeTableMapSkipCellCheck} from './LexicalTableUtils';
4144

4245
export type SerializedTableNode = Spread<
4346
{
@@ -170,6 +173,14 @@ export class TableNode extends ElementNode {
170173
};
171174
}
172175

176+
extractWithChild(
177+
child: LexicalNode,
178+
selection: BaseSelection | null,
179+
destination: 'clone' | 'html',
180+
): boolean {
181+
return destination === 'html';
182+
}
183+
173184
getDOMSlot(element: HTMLElement): ElementDOMSlot {
174185
const tableElement =
175186
(element.nodeName !== 'TABLE' && element.querySelector('table')) ||
@@ -227,11 +238,12 @@ export class TableNode extends ElementNode {
227238
}
228239

229240
exportDOM(editor: LexicalEditor): DOMExportOutput {
230-
const {element, after} = super.exportDOM(editor);
241+
const superExport = super.exportDOM(editor);
242+
const {element} = superExport;
231243
return {
232244
after: (tableElement) => {
233-
if (after) {
234-
tableElement = after(tableElement);
245+
if (superExport.after) {
246+
tableElement = superExport.after(tableElement);
235247
}
236248
if (
237249
tableElement &&
@@ -243,11 +255,62 @@ export class TableNode extends ElementNode {
243255
if (!tableElement || !isHTMLElement(tableElement)) {
244256
return null;
245257
}
258+
259+
// Scan the table map to build a map of table cell key to the columns it needs
260+
const [tableMap] = $computeTableMapSkipCellCheck(this, null, null);
261+
const cellValues = new Map<
262+
NodeKey,
263+
{startColumn: number; colSpan: number}
264+
>();
265+
for (const mapRow of tableMap) {
266+
for (const mapValue of mapRow) {
267+
const key = mapValue.cell.getKey();
268+
if (!cellValues.has(key)) {
269+
cellValues.set(key, {
270+
colSpan: mapValue.cell.getColSpan(),
271+
startColumn: mapValue.startColumn,
272+
});
273+
}
274+
}
275+
}
276+
277+
// scan the DOM to find the table cell keys that were used and mark those columns
278+
const knownColumns = new Set<number>();
279+
for (const cellDOM of tableElement.querySelectorAll(
280+
':scope > tr > [data-temporary-table-cell-lexical-key]',
281+
)) {
282+
const key = cellDOM.getAttribute(
283+
'data-temporary-table-cell-lexical-key',
284+
);
285+
if (key) {
286+
const cellSpan = cellValues.get(key);
287+
cellDOM.removeAttribute('data-temporary-table-cell-lexical-key');
288+
if (cellSpan) {
289+
cellValues.delete(key);
290+
for (let i = 0; i < cellSpan.colSpan; i++) {
291+
knownColumns.add(i + cellSpan.startColumn);
292+
}
293+
}
294+
}
295+
}
296+
297+
// Compute the colgroup and columns in the export
298+
const colGroup = tableElement.querySelector(':scope > colgroup');
299+
if (colGroup) {
300+
// Only include the <col /> for rows that are in the output
301+
const cols = Array.from(
302+
tableElement.querySelectorAll(':scope > colgroup > col'),
303+
).filter((dom, i) => knownColumns.has(i));
304+
colGroup.replaceChildren(...cols);
305+
}
306+
246307
// Wrap direct descendant rows in a tbody for export
247308
const rows = tableElement.querySelectorAll(':scope > tr');
248309
if (rows.length > 0) {
249310
const tBody = document.createElement('tbody');
250-
tBody.append(...rows);
311+
for (const row of rows) {
312+
tBody.appendChild(row);
313+
}
251314
tableElement.append(tBody);
252315
}
253316
return tableElement;

packages/lexical-table/src/LexicalTableRowNode.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*
77
*/
88

9-
import type {Spread} from 'lexical';
9+
import type {BaseSelection, Spread} from 'lexical';
1010

1111
import {addClassNamesToElement} from '@lexical/utils';
1212
import {
@@ -81,6 +81,14 @@ export class TableRowNode extends ElementNode {
8181
return element;
8282
}
8383

84+
extractWithChild(
85+
child: LexicalNode,
86+
selection: BaseSelection | null,
87+
destination: 'clone' | 'html',
88+
): boolean {
89+
return destination === 'html';
90+
}
91+
8492
isShadowRoot(): boolean {
8593
return true;
8694
}

packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx

+130
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,16 @@
77
*/
88

99
import {$insertDataTransferForRichText} from '@lexical/clipboard';
10+
import {$generateHtmlFromNodes} from '@lexical/html';
1011
import {TablePlugin} from '@lexical/react/LexicalTablePlugin';
1112
import {
1213
$createTableNode,
1314
$createTableNodeWithDimensions,
1415
$createTableSelection,
1516
$insertTableColumn__EXPERIMENTAL,
17+
$isTableCellNode,
1618
} from '@lexical/table';
19+
import {$dfs} from '@lexical/utils';
1720
import {
1821
$createParagraphNode,
1922
$createTextNode,
@@ -136,6 +139,133 @@ describe('LexicalTableNode tests', () => {
136139
});
137140
});
138141

142+
test('TableNode.exportDOM() with range selection', async () => {
143+
const {editor} = testEnv;
144+
145+
await editor.update(() => {
146+
const tableNode = $createTableNodeWithDimensions(
147+
2,
148+
2,
149+
).setColWidths([100, 200]);
150+
tableNode
151+
.getAllTextNodes()
152+
.forEach((node, i) => node.setTextContent(String(i)));
153+
$getRoot().clear().append(tableNode);
154+
expectHtmlToBeEqual(
155+
$generateHtmlFromNodes(editor, $getRoot().select(0)),
156+
html`
157+
<table class="${editorConfig.theme.table}">
158+
<colgroup>
159+
<col style="width: 100px" />
160+
<col style="width: 200px" />
161+
</colgroup>
162+
<tbody>
163+
<tr>
164+
<th
165+
style="
166+
border: 1px solid black;
167+
width: 75px;
168+
vertical-align: top;
169+
text-align: start;
170+
background-color: rgb(242, 243, 245);
171+
">
172+
<p><span style="white-space: pre-wrap">0</span></p>
173+
</th>
174+
<th
175+
style="
176+
border: 1px solid black;
177+
width: 75px;
178+
vertical-align: top;
179+
text-align: start;
180+
background-color: rgb(242, 243, 245);
181+
">
182+
<p><span style="white-space: pre-wrap">1</span></p>
183+
</th>
184+
</tr>
185+
<tr>
186+
<th
187+
style="
188+
border: 1px solid black;
189+
width: 75px;
190+
vertical-align: top;
191+
text-align: start;
192+
background-color: rgb(242, 243, 245);
193+
">
194+
<p><span style="white-space: pre-wrap">2</span></p>
195+
</th>
196+
<td
197+
style="
198+
border: 1px solid black;
199+
width: 75px;
200+
vertical-align: top;
201+
text-align: start;
202+
">
203+
<p><span style="white-space: pre-wrap">3</span></p>
204+
</td>
205+
</tr>
206+
</tbody>
207+
</table>
208+
`,
209+
);
210+
});
211+
});
212+
213+
test('TableNode.exportDOM() with partial table selection', async () => {
214+
const {editor} = testEnv;
215+
216+
await editor.update(() => {
217+
const tableNode = $createTableNodeWithDimensions(
218+
2,
219+
2,
220+
).setColWidths([100, 200]);
221+
tableNode
222+
.getAllTextNodes()
223+
.forEach((node, i) => node.setTextContent(String(i)));
224+
$getRoot().append(tableNode);
225+
const tableSelection = $createTableSelection();
226+
tableSelection.tableKey = tableNode.getKey();
227+
const cells = $dfs(tableNode).flatMap(({node}) =>
228+
$isTableCellNode(node) ? [node] : [],
229+
);
230+
// second column
231+
tableSelection.anchor.set(cells[1].getKey(), 0, 'element');
232+
tableSelection.focus.set(cells[3].getKey(), 0, 'element');
233+
expectHtmlToBeEqual(
234+
$generateHtmlFromNodes(editor, tableSelection),
235+
html`
236+
<table class="${editorConfig.theme.table}">
237+
<colgroup><col style="width: 200px" /></colgroup>
238+
<tbody>
239+
<tr>
240+
<th
241+
style="
242+
border: 1px solid black;
243+
width: 75px;
244+
vertical-align: top;
245+
text-align: start;
246+
background-color: rgb(242, 243, 245);
247+
">
248+
<p><span style="white-space: pre-wrap">1</span></p>
249+
</th>
250+
</tr>
251+
<tr>
252+
<td
253+
style="
254+
border: 1px solid black;
255+
width: 75px;
256+
vertical-align: top;
257+
text-align: start;
258+
">
259+
<p><span style="white-space: pre-wrap">3</span></p>
260+
</td>
261+
</tr>
262+
</tbody>
263+
</table>
264+
`,
265+
);
266+
});
267+
});
268+
139269
test('Copy table from an external source', async () => {
140270
const {editor} = testEnv;
141271

0 commit comments

Comments
 (0)