Lexical: Added missing table header row toggle button

Also updated DOM converstion, and CSS, to make lexical format (th inside
tbody) somewhat compatible/convertible with the tinymce format (td
inside thead).
This commit is contained in:
Dan Brown
2026-05-09 12:25:49 +01:00
parent d6b114de74
commit dc8f80365c
6 changed files with 71 additions and 14 deletions

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200q-33 0-56.5-23.5T120-200m293-240h134v-160H413Zm0 240h134v-160H413ZM200-440h133v-160H200Zm427 0h133v-160H627ZM200-200h133v-160H200Zm427 0h133v-160H627Z"/></svg>

After

Width:  |  Height:  |  Size: 327 B

View File

@@ -334,10 +334,13 @@ export function $convertTableCellNodeElement(
width = parseFloat(domNode_.style.width);
}
let isHeader = nodeName === 'th';
if (domNode instanceof HTMLElement && domNode.closest('thead')) {
isHeader = true;
}
const tableCellNode = $createTableCellNode(
nodeName === 'th'
? TableCellHeaderStates.ROW
: TableCellHeaderStates.NO_STATUS,
isHeader ? TableCellHeaderStates.ROW : TableCellHeaderStates.NO_STATUS,
domNode_.colSpan,
width,
);

View File

@@ -7,6 +7,7 @@ import insertColumnAfterIcon from "@icons/editor/table-insert-column-after.svg";
import insertColumnBeforeIcon from "@icons/editor/table-insert-column-before.svg";
import insertRowAboveIcon from "@icons/editor/table-insert-row-above.svg";
import insertRowBelowIcon from "@icons/editor/table-insert-row-below.svg";
import tableHeaderIcon from "@icons/editor/table-header.svg";
import {EditorUiContext} from "../../framework/core";
import {$getSelection, BaseSelection} from "lexical";
import {
@@ -14,7 +15,7 @@ import {
$deleteTableRow__EXPERIMENTAL,
$insertTableColumn__EXPERIMENTAL,
$insertTableRow__EXPERIMENTAL, $isTableCellNode,
$isTableNode, $isTableRowNode, $isTableSelection, $unmergeCell, TableCellNode,
$isTableNode, $isTableRowNode, $isTableSelection, $unmergeCell, TableCellHeaderStates, TableCellNode,
} from "@lexical/table";
import {$getNodeFromSelection, $selectionContainsNodeType} from "../../../utils/selection";
import {$getParentOfType} from "../../../utils/nodes";
@@ -23,7 +24,7 @@ import {
$clearTableFormatting,
$clearTableSizes, $getTableFromSelection,
$getTableRowsFromSelection,
$mergeTableCellsInSelection
$mergeTableCellsInSelection, $toggleRowCellHeaderState
} from "../../../utils/tables";
import {
$copySelectedColumnsToClipboard,
@@ -239,6 +240,28 @@ export const pasteRowAfter: EditorButtonDefinition = {
isDisabled: (selection) => cellNotSelected(selection) || isRowClipboardEmpty(),
};
export const toggleRowHeaders: EditorButtonDefinition = {
label: 'Row header',
format: 'small',
icon: tableHeaderIcon,
action(context: EditorUiContext, button) {
context.editor.update(() => {
const row = $getNodeFromSelection($getSelection(), $isTableCellNode)?.getParent();
if (!$isTableRowNode(row)) {
return;
}
const isNowHeader = $toggleRowCellHeaderState(row);
button.setActiveState(isNowHeader);
});
},
isActive: (selection) => {
return $selectionContainsNodeType(selection, (node) => {
return $isTableCellNode(node) && node.getHeaderStyles() !== TableCellHeaderStates.NO_STATUS;
});
}
};
export const cutColumn: EditorButtonDefinition = {
label: 'Cut column',
format: 'long',

View File

@@ -28,7 +28,7 @@ import {
pasteRowBefore, resizeTableToContents,
rowProperties,
splitCell,
table, tableProperties
table, tableProperties, toggleRowHeaders
} from "./buttons/tables";
import {about, fullscreen, redo, source, undo} from "./buttons/controls";
import {
@@ -284,6 +284,19 @@ export const contextToolbars: Record<string, EditorContextToolbarDefinition> = {
return originalTarget.closest('table') as HTMLTableElement;
}
},
table_header: {
selector: 'table tr:first-of-type td, table tr:first-of-type th',
content() {
return [
new EditorOverflowContainer('table_headers', 1, [
new EditorButton(toggleRowHeaders),
]),
];
},
displayTargetLocator(originalTarget: HTMLElement): HTMLElement {
return originalTarget.closest('table') as HTMLTableElement;
}
},
details: {
selector: 'details',
content() {

View File

@@ -3,7 +3,7 @@ import {
$isTableCellNode,
$isTableNode,
$isTableRowNode,
$isTableSelection, TableCellNode, TableNode,
$isTableSelection, TableCellHeaderStates, TableCellNode, TableNode,
TableRowNode,
TableSelection,
} from "@lexical/table";
@@ -328,9 +328,21 @@ export function $getCellPaddingForTable(table: TableNode): string {
return padding || '';
}
/**
* Toggle the header state of the cells in the provided row.
* Returns a boolean to indicate if the new state of the cells is as headers.
*/
export function $toggleRowCellHeaderState(row: TableRowNode): boolean {
const firstCell = row.getFirstChild();
const isHeader = $isTableCellNode(firstCell) ? firstCell.getHeaderStyles() !== TableCellHeaderStates.NO_STATUS : false;
const cells = row.getChildren();
for (const cell of cells) {
if ($isTableCellNode(cell)) {
cell.setHeaderStyles(isHeader ? TableCellHeaderStates.NO_STATUS : TableCellHeaderStates.ROW);
}
}
return !isHeader;
}

View File

@@ -4,10 +4,6 @@
table {
min-width: 100px;
max-width: 100%;
thead {
@include mixins.lightDark(background-color, #f8f8f8, #333);
font-weight: 500;
}
td, th {
min-width: 10px;
padding: 6px 8px;
@@ -22,6 +18,15 @@ table {
}
}
// Table Header styles.
// Initial selector for in-body th cells, intended to (somewhat) not conflict with
// previous approach (second selector) to table headers where
// we would target the thead specifically.
table:not(:has(thead)) th, table thead {
@include mixins.lightDark(background-color, #f8f8f8, #333);
font-weight: 500;
}
table.table {
width: 100%;
tr td, tr th {