[lexical-table] Support first column freeze (#7134)

This commit is contained in:
Ivaylo Pavlov
2025-02-13 08:41:58 +00:00
committed by GitHub
parent f9e01fc659
commit f26c848044
6 changed files with 591 additions and 0 deletions

View File

@ -1330,6 +1330,7 @@ i.page-break,
.table-cell-action-button-container {
position: absolute;
z-index: 3;
top: 0;
left: 0;
will-change: transform;

View File

@ -448,6 +448,21 @@ function TableActionMenu({
});
}, [editor, tableCellNode, clearTableSelection, onClose]);
const toggleFirstColumnFreeze = useCallback(() => {
editor.update(() => {
if (tableCellNode.isAttached()) {
const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode);
if (tableNode) {
tableNode.setFrozenColumns(
tableNode.getFrozenColumns() === 0 ? 1 : 0,
);
}
}
clearTableSelection();
onClose();
});
}, [editor, tableCellNode, clearTableSelection, onClose]);
const handleCellBackgroundColor = useCallback(
(value: string) => {
editor.update(() => {
@ -587,6 +602,13 @@ function TableActionMenu({
</div>
</DropDownItem>
</DropDown>
<button
type="button"
className="item"
onClick={() => toggleFirstColumnFreeze()}
data-test-id="table-freeze-first-column">
<span className="text">Toggle First Column Freeze</span>
</button>
<hr />
<button
type="button"

View File

@ -214,6 +214,27 @@
margin-top: 25px;
margin-bottom: 30px;
}
.PlaygroundEditorTheme__tableFrozenColumn tr > td:first-child {
background-color: #ffffff;
position: sticky;
z-index: 2;
left: 0;
}
.PlaygroundEditorTheme__tableFrozenColumn tr > th:first-child {
background-color: #f2f3f5;
position: sticky;
z-index: 2;
left: 0;
}
.PlaygroundEditorTheme__tableFrozenColumn tr > :first-child::after {
content: '';
position: absolute;
left: 0;
top: 0;
right: 0;
height: 100%;
border-right: 1px solid #bbb;
}
.PlaygroundEditorTheme__tableRowStriping tr:nth-child(even) {
background-color: #f2f5fb;
}

View File

@ -104,6 +104,7 @@ const theme: EditorThemeClasses = {
tableCellHeader: 'PlaygroundEditorTheme__tableCellHeader',
tableCellResizer: 'PlaygroundEditorTheme__tableCellResizer',
tableCellSelected: 'PlaygroundEditorTheme__tableCellSelected',
tableFrozenColumn: 'PlaygroundEditorTheme__tableFrozenColumn',
tableRowStriping: 'PlaygroundEditorTheme__tableRowStriping',
tableScrollableWrapper: 'PlaygroundEditorTheme__tableScrollableWrapper',
tableSelected: 'PlaygroundEditorTheme__tableSelected',

View File

@ -48,6 +48,7 @@ export type SerializedTableNode = Spread<
{
colWidths?: readonly number[];
rowStriping?: boolean;
frozenColumnCount?: number;
},
SerializedElementNode
>;
@ -88,6 +89,20 @@ function setRowStriping(
}
}
function setFrozenColumns(
dom: HTMLElement,
config: EditorConfig,
frozenColumnCount: number,
): void {
if (frozenColumnCount > 0) {
addClassNamesToElement(dom, config.theme.tableFrozenColumn);
dom.setAttribute('data-lexical-frozen-column', 'true');
} else {
removeClassNamesFromElement(dom, config.theme.tableFrozenColumn);
dom.removeAttribute('data-lexical-frozen-column');
}
}
function alignTableElement(
dom: HTMLElement,
config: EditorConfig,
@ -137,6 +152,7 @@ export function setScrollableTablesActive(
export class TableNode extends ElementNode {
/** @internal */
__rowStriping: boolean;
__frozenColumnCount: number;
__colWidths?: readonly number[];
static getType(): string {
@ -164,6 +180,7 @@ export class TableNode extends ElementNode {
super.afterCloneFrom(prevNode);
this.__colWidths = prevNode.__colWidths;
this.__rowStriping = prevNode.__rowStriping;
this.__frozenColumnCount = prevNode.__frozenColumnCount;
}
static importDOM(): DOMConversionMap | null {
@ -183,18 +200,23 @@ export class TableNode extends ElementNode {
return super
.updateFromJSON(serializedNode)
.setRowStriping(serializedNode.rowStriping || false)
.setFrozenColumns(serializedNode.frozenColumnCount || 0)
.setColWidths(serializedNode.colWidths);
}
constructor(key?: NodeKey) {
super(key);
this.__rowStriping = false;
this.__frozenColumnCount = 0;
}
exportJSON(): SerializedTableNode {
return {
...super.exportJSON(),
colWidths: this.getColWidths(),
frozenColumnCount: this.__frozenColumnCount
? this.__frozenColumnCount
: undefined,
rowStriping: this.__rowStriping ? this.__rowStriping : undefined,
};
}
@ -234,6 +256,9 @@ export class TableNode extends ElementNode {
addClassNamesToElement(tableElement, config.theme.table);
alignTableElement(tableElement, config, this.getFormatType());
if (this.__frozenColumnCount) {
setFrozenColumns(tableElement, config, this.__frozenColumnCount);
}
if (this.__rowStriping) {
setRowStriping(tableElement, config, true);
}
@ -256,6 +281,9 @@ export class TableNode extends ElementNode {
if (prevNode.__rowStriping !== this.__rowStriping) {
setRowStriping(dom, config, this.__rowStriping);
}
if (prevNode.__frozenColumnCount !== this.__frozenColumnCount) {
setFrozenColumns(dom, config, this.__frozenColumnCount);
}
updateColgroup(dom, config, this.getColumnCount(), this.getColWidths());
alignTableElement(
this.getDOMSlot(dom).element,
@ -472,6 +500,16 @@ export class TableNode extends ElementNode {
return self;
}
setFrozenColumns(columnCount: number): this {
const self = this.getWritable();
self.__frozenColumnCount = columnCount;
return self;
}
getFrozenColumns(): number {
return this.getLatest().__frozenColumnCount;
}
canSelectBefore(): true {
return true;
}

View File

@ -77,6 +77,7 @@ const editorConfig = Object.freeze({
center: 'test-table-alignment-center',
right: 'test-table-alignment-right',
},
tableFrozenColumn: 'test-table-frozen-column-class',
tableRowStriping: 'test-table-row-striping-class',
tableScrollableWrapper: 'table-scrollable-wrapper',
},
@ -1044,6 +1045,513 @@ describe('LexicalTableNode tests', () => {
});
});
test('Toggle frozen first column ON/OFF', async () => {
const {editor} = testEnv;
await editor.update(() => {
const root = $getRoot();
const table = $createTableNodeWithDimensions(4, 4, true);
root.append(table);
});
await editor.update(() => {
const root = $getRoot();
const table = root.getLastChild<TableNode>();
if (table) {
table.setFrozenColumns(1);
}
});
await editor.update(() => {
const root = $getRoot();
const table = root.getLastChild<TableNode>();
expectTableHtmlToBeEqual(
table!.createDOM(editorConfig).outerHTML,
html`
<table
class="${editorConfig.theme.table} ${editorConfig.theme
.tableFrozenColumn}"
data-lexical-frozen-column="true">
<colgroup>
<col />
<col />
<col />
<col />
</colgroup>
</table>
`,
);
});
const stringifiedEditorState = JSON.stringify(
editor.getEditorState(),
);
const expectedStringifiedEditorState = `{
"root": {
"children": [
{
"children": [],
"direction": null,
"format": "",
"indent": 0,
"textFormat": 0,
"textStyle": "",
"type": "paragraph",
"version": 1
},
{
"children": [
{
"children": [
{
"backgroundColor": null,
"children": [
{
"children": [],
"direction": null,
"format": "",
"indent": 0,
"textFormat": 0,
"textStyle": "",
"type": "paragraph",
"version": 1
}
],
"colSpan": 1,
"direction": null,
"format": "",
"headerState": 3,
"indent": 0,
"rowSpan": 1,
"type": "tablecell",
"version": 1
},
{
"backgroundColor": null,
"children": [
{
"children": [],
"direction": null,
"format": "",
"indent": 0,
"textFormat": 0,
"textStyle": "",
"type": "paragraph",
"version": 1
}
],
"colSpan": 1,
"direction": null,
"format": "",
"headerState": 1,
"indent": 0,
"rowSpan": 1,
"type": "tablecell",
"version": 1
},
{
"backgroundColor": null,
"children": [
{
"children": [],
"direction": null,
"format": "",
"indent": 0,
"textFormat": 0,
"textStyle": "",
"type": "paragraph",
"version": 1
}
],
"colSpan": 1,
"direction": null,
"format": "",
"headerState": 1,
"indent": 0,
"rowSpan": 1,
"type": "tablecell",
"version": 1
},
{
"backgroundColor": null,
"children": [
{
"children": [],
"direction": null,
"format": "",
"indent": 0,
"textFormat": 0,
"textStyle": "",
"type": "paragraph",
"version": 1
}
],
"colSpan": 1,
"direction": null,
"format": "",
"headerState": 1,
"indent": 0,
"rowSpan": 1,
"type": "tablecell",
"version": 1
}
],
"direction": null,
"format": "",
"indent": 0,
"type": "tablerow",
"version": 1
},
{
"children": [
{
"backgroundColor": null,
"children": [
{
"children": [],
"direction": null,
"format": "",
"indent": 0,
"textFormat": 0,
"textStyle": "",
"type": "paragraph",
"version": 1
}
],
"colSpan": 1,
"direction": null,
"format": "",
"headerState": 2,
"indent": 0,
"rowSpan": 1,
"type": "tablecell",
"version": 1
},
{
"backgroundColor": null,
"children": [
{
"children": [],
"direction": null,
"format": "",
"indent": 0,
"textFormat": 0,
"textStyle": "",
"type": "paragraph",
"version": 1
}
],
"colSpan": 1,
"direction": null,
"format": "",
"headerState": 0,
"indent": 0,
"rowSpan": 1,
"type": "tablecell",
"version": 1
},
{
"backgroundColor": null,
"children": [
{
"children": [],
"direction": null,
"format": "",
"indent": 0,
"textFormat": 0,
"textStyle": "",
"type": "paragraph",
"version": 1
}
],
"colSpan": 1,
"direction": null,
"format": "",
"headerState": 0,
"indent": 0,
"rowSpan": 1,
"type": "tablecell",
"version": 1
},
{
"backgroundColor": null,
"children": [
{
"children": [],
"direction": null,
"format": "",
"indent": 0,
"textFormat": 0,
"textStyle": "",
"type": "paragraph",
"version": 1
}
],
"colSpan": 1,
"direction": null,
"format": "",
"headerState": 0,
"indent": 0,
"rowSpan": 1,
"type": "tablecell",
"version": 1
}
],
"direction": null,
"format": "",
"indent": 0,
"type": "tablerow",
"version": 1
},
{
"children": [
{
"backgroundColor": null,
"children": [
{
"children": [],
"direction": null,
"format": "",
"indent": 0,
"textFormat": 0,
"textStyle": "",
"type": "paragraph",
"version": 1
}
],
"colSpan": 1,
"direction": null,
"format": "",
"headerState": 2,
"indent": 0,
"rowSpan": 1,
"type": "tablecell",
"version": 1
},
{
"backgroundColor": null,
"children": [
{
"children": [],
"direction": null,
"format": "",
"indent": 0,
"textFormat": 0,
"textStyle": "",
"type": "paragraph",
"version": 1
}
],
"colSpan": 1,
"direction": null,
"format": "",
"headerState": 0,
"indent": 0,
"rowSpan": 1,
"type": "tablecell",
"version": 1
},
{
"backgroundColor": null,
"children": [
{
"children": [],
"direction": null,
"format": "",
"indent": 0,
"textFormat": 0,
"textStyle": "",
"type": "paragraph",
"version": 1
}
],
"colSpan": 1,
"direction": null,
"format": "",
"headerState": 0,
"indent": 0,
"rowSpan": 1,
"type": "tablecell",
"version": 1
},
{
"backgroundColor": null,
"children": [
{
"children": [],
"direction": null,
"format": "",
"indent": 0,
"textFormat": 0,
"textStyle": "",
"type": "paragraph",
"version": 1
}
],
"colSpan": 1,
"direction": null,
"format": "",
"headerState": 0,
"indent": 0,
"rowSpan": 1,
"type": "tablecell",
"version": 1
}
],
"direction": null,
"format": "",
"indent": 0,
"type": "tablerow",
"version": 1
},
{
"children": [
{
"backgroundColor": null,
"children": [
{
"children": [],
"direction": null,
"format": "",
"indent": 0,
"textFormat": 0,
"textStyle": "",
"type": "paragraph",
"version": 1
}
],
"colSpan": 1,
"direction": null,
"format": "",
"headerState": 2,
"indent": 0,
"rowSpan": 1,
"type": "tablecell",
"version": 1
},
{
"backgroundColor": null,
"children": [
{
"children": [],
"direction": null,
"format": "",
"indent": 0,
"textFormat": 0,
"textStyle": "",
"type": "paragraph",
"version": 1
}
],
"colSpan": 1,
"direction": null,
"format": "",
"headerState": 0,
"indent": 0,
"rowSpan": 1,
"type": "tablecell",
"version": 1
},
{
"backgroundColor": null,
"children": [
{
"children": [],
"direction": null,
"format": "",
"indent": 0,
"textFormat": 0,
"textStyle": "",
"type": "paragraph",
"version": 1
}
],
"colSpan": 1,
"direction": null,
"format": "",
"headerState": 0,
"indent": 0,
"rowSpan": 1,
"type": "tablecell",
"version": 1
},
{
"backgroundColor": null,
"children": [
{
"children": [],
"direction": null,
"format": "",
"indent": 0,
"textFormat": 0,
"textStyle": "",
"type": "paragraph",
"version": 1
}
],
"colSpan": 1,
"direction": null,
"format": "",
"headerState": 0,
"indent": 0,
"rowSpan": 1,
"type": "tablecell",
"version": 1
}
],
"direction": null,
"format": "",
"indent": 0,
"type": "tablerow",
"version": 1
}
],
"direction": null,
"format": "",
"frozenColumnCount": 1,
"indent": 0,
"type": "table",
"version": 1
}
],
"direction": null,
"format": "",
"indent": 0,
"type": "root",
"version": 1
}
}`;
expect(JSON.parse(stringifiedEditorState)).toEqual(
JSON.parse(expectedStringifiedEditorState),
);
await editor.update(() => {
const root = $getRoot();
const table = root.getLastChild<TableNode>();
if (table) {
table.setFrozenColumns(0);
}
});
await editor.update(() => {
const root = $getRoot();
const table = root.getLastChild<TableNode>();
expectTableHtmlToBeEqual(
table!.createDOM(editorConfig).outerHTML,
html`
<table class="${editorConfig.theme.table}">
<colgroup>
<col />
<col />
<col />
<col />
</colgroup>
</table>
`,
);
});
});
test('Change Table-level alignment', async () => {
const {editor} = testEnv;