Lexical: Wired table properties, and other buttons

This commit is contained in:
Dan Brown 2024-08-10 13:14:55 +01:00
parent abbfd42a6c
commit ebf95f637a
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
9 changed files with 243 additions and 79 deletions

View File

@ -20,7 +20,7 @@ import {
TableCellNode TableCellNode
} from "@lexical/table"; } from "@lexical/table";
import {TableCellHeaderState} from "@lexical/table/LexicalTableCellNode"; import {TableCellHeaderState} from "@lexical/table/LexicalTableCellNode";
import {createStyleMapFromDomStyles, StyleMap} from "../utils/styles"; import {extractStyleMapFromElement, StyleMap} from "../utils/dom";
export type SerializedCustomTableCellNode = Spread<{ export type SerializedCustomTableCellNode = Spread<{
styles: Record<string, string>, styles: Record<string, string>,
@ -45,6 +45,11 @@ export class CustomTableCellNode extends TableCellNode {
return cellNode; return cellNode;
} }
clearWidth(): void {
const self = this.getWritable();
self.__width = undefined;
}
getStyles(): StyleMap { getStyles(): StyleMap {
const self = this.getLatest(); const self = this.getLatest();
return new Map(self.__styles); return new Map(self.__styles);
@ -122,7 +127,7 @@ function $convertCustomTableCellNodeElement(domNode: Node): DOMConversionOutput
const output = $convertTableCellNodeElement(domNode); const output = $convertTableCellNodeElement(domNode);
if (domNode instanceof HTMLElement && output.node instanceof CustomTableCellNode) { if (domNode instanceof HTMLElement && output.node instanceof CustomTableCellNode) {
output.node.setStyles(createStyleMapFromDomStyles(domNode.style)); output.node.setStyles(extractStyleMapFromElement(domNode));
} }
return output; return output;

View File

@ -1,8 +1,4 @@
import { import {
$createParagraphNode,
$isElementNode,
$isLineBreakNode,
$isTextNode,
DOMConversionMap, DOMConversionMap,
DOMConversionOutput, DOMConversionOutput,
EditorConfig, EditorConfig,
@ -11,14 +7,11 @@ import {
} from "lexical"; } from "lexical";
import { import {
$createTableCellNode,
$isTableCellNode,
SerializedTableRowNode, SerializedTableRowNode,
TableCellHeaderStates,
TableRowNode TableRowNode
} from "@lexical/table"; } from "@lexical/table";
import {createStyleMapFromDomStyles, StyleMap} from "../utils/styles";
import {NodeKey} from "lexical/LexicalNode"; import {NodeKey} from "lexical/LexicalNode";
import {extractStyleMapFromElement, StyleMap} from "../utils/dom";
export type SerializedCustomTableRowNode = Spread<{ export type SerializedCustomTableRowNode = Spread<{
styles: Record<string, string>, styles: Record<string, string>,
@ -98,7 +91,7 @@ export function $convertTableRowElement(domNode: Node): DOMConversionOutput {
const rowNode = $createCustomTableRowNode(); const rowNode = $createCustomTableRowNode();
if (domNode instanceof HTMLElement) { if (domNode instanceof HTMLElement) {
rowNode.setStyles(createStyleMapFromDomStyles(domNode.style)); rowNode.setStyles(extractStyleMapFromElement(domNode));
} }
return {node: rowNode}; return {node: rowNode};

View File

@ -2,17 +2,19 @@ import {SerializedTableNode, TableNode} from "@lexical/table";
import {DOMConversion, DOMConversionMap, DOMConversionOutput, LexicalNode, Spread} from "lexical"; import {DOMConversion, DOMConversionMap, DOMConversionOutput, LexicalNode, Spread} from "lexical";
import {EditorConfig} from "lexical/LexicalEditor"; import {EditorConfig} from "lexical/LexicalEditor";
import {el} from "../utils/dom"; import {el, extractStyleMapFromElement, StyleMap} from "../utils/dom";
import {getTableColumnWidths} from "../utils/tables"; import {getTableColumnWidths} from "../utils/tables";
export type SerializedCustomTableNode = Spread<{ export type SerializedCustomTableNode = Spread<{
id: string; id: string;
colWidths: string[]; colWidths: string[];
styles: Record<string, string>,
}, SerializedTableNode> }, SerializedTableNode>
export class CustomTableNode extends TableNode { export class CustomTableNode extends TableNode {
__id: string = ''; __id: string = '';
__colWidths: string[] = []; __colWidths: string[] = [];
__styles: StyleMap = new Map;
static getType() { static getType() {
return 'custom-table'; return 'custom-table';
@ -38,10 +40,21 @@ export class CustomTableNode extends TableNode {
return self.__colWidths; return self.__colWidths;
} }
getStyles(): StyleMap {
const self = this.getLatest();
return new Map(self.__styles);
}
setStyles(styles: StyleMap): void {
const self = this.getWritable();
self.__styles = new Map(styles);
}
static clone(node: CustomTableNode) { static clone(node: CustomTableNode) {
const newNode = new CustomTableNode(node.__key); const newNode = new CustomTableNode(node.__key);
newNode.__id = node.__id; newNode.__id = node.__id;
newNode.__colWidths = node.__colWidths; newNode.__colWidths = node.__colWidths;
newNode.__styles = new Map(node.__styles);
return newNode; return newNode;
} }
@ -65,6 +78,10 @@ export class CustomTableNode extends TableNode {
dom.append(colgroup); dom.append(colgroup);
} }
for (const [name, value] of this.__styles.entries()) {
dom.style.setProperty(name, value);
}
return dom; return dom;
} }
@ -79,6 +96,7 @@ export class CustomTableNode extends TableNode {
version: 1, version: 1,
id: this.__id, id: this.__id,
colWidths: this.__colWidths, colWidths: this.__colWidths,
styles: Object.fromEntries(this.__styles),
}; };
} }
@ -86,6 +104,7 @@ export class CustomTableNode extends TableNode {
const node = $createCustomTableNode(); const node = $createCustomTableNode();
node.setId(serializedNode.id); node.setId(serializedNode.id);
node.setColWidths(serializedNode.colWidths); node.setColWidths(serializedNode.colWidths);
node.setStyles(new Map(Object.entries(serializedNode.styles)));
return node; return node;
} }
@ -102,6 +121,7 @@ export class CustomTableNode extends TableNode {
const colWidths = getTableColumnWidths(element as HTMLTableElement); const colWidths = getTableColumnWidths(element as HTMLTableElement);
node.setColWidths(colWidths); node.setColWidths(colWidths);
node.setStyles(extractStyleMapFromElement(element));
return {node}; return {node};
}, },

View File

@ -2,13 +2,6 @@
## In progress ## In progress
- Table features
- Table properties form logic
- Caption text support
- Resize to contents button
- Remove formatting button
- Cut/Copy/Paste column
## Main Todo ## Main Todo
- Alignments: Use existing classes for blocks (including table cells) - Alignments: Use existing classes for blocks (including table cells)
@ -23,6 +16,8 @@
- Drawing gallery integration - Drawing gallery integration
- Support media src conversions (https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts) - Support media src conversions (https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts)
- Media resize support (like images) - Media resize support (like images)
- Table caption text support
- Table Cut/Copy/Paste column
## Secondary Todo ## Secondary Todo

View File

@ -8,24 +8,27 @@ import insertColumnBeforeIcon from "@icons/editor/table-insert-column-before.svg
import insertRowAboveIcon from "@icons/editor/table-insert-row-above.svg"; import insertRowAboveIcon from "@icons/editor/table-insert-row-above.svg";
import insertRowBelowIcon from "@icons/editor/table-insert-row-below.svg"; import insertRowBelowIcon from "@icons/editor/table-insert-row-below.svg";
import {EditorUiContext} from "../../framework/core"; import {EditorUiContext} from "../../framework/core";
import {$createNodeSelection, $createRangeSelection, $getSelection, BaseSelection} from "lexical"; import {$getSelection, BaseSelection} from "lexical";
import {$isCustomTableNode} from "../../../nodes/custom-table"; import {$isCustomTableNode} from "../../../nodes/custom-table";
import { import {
$deleteTableColumn__EXPERIMENTAL, $deleteTableColumn__EXPERIMENTAL,
$deleteTableRow__EXPERIMENTAL, $deleteTableRow__EXPERIMENTAL,
$insertTableColumn__EXPERIMENTAL, $insertTableColumn__EXPERIMENTAL,
$insertTableRow__EXPERIMENTAL, $insertTableRow__EXPERIMENTAL,
$isTableNode, $isTableRowNode, $isTableSelection, $unmergeCell, TableCellNode, $isTableNode, $isTableSelection, $unmergeCell, TableCellNode,
} from "@lexical/table"; } from "@lexical/table";
import {$getNodeFromSelection, $selectionContainsNodeType} from "../../../utils/selection"; import {$getNodeFromSelection, $selectionContainsNodeType} from "../../../utils/selection";
import {$getParentOfType} from "../../../utils/nodes"; import {$getParentOfType} from "../../../utils/nodes";
import {$isCustomTableCellNode} from "../../../nodes/custom-table-cell"; import {$isCustomTableCellNode} from "../../../nodes/custom-table-cell";
import {$showCellPropertiesForm, $showRowPropertiesForm} from "../forms/tables"; import {$showCellPropertiesForm, $showRowPropertiesForm, $showTablePropertiesForm} from "../forms/tables";
import {$getTableRowsFromSelection, $mergeTableCellsInSelection} from "../../../utils/tables"; import {
$clearTableFormatting,
$clearTableSizes, $getTableFromSelection,
$getTableRowsFromSelection,
$mergeTableCellsInSelection
} from "../../../utils/tables";
import {$isCustomTableRowNode, CustomTableRowNode} from "../../../nodes/custom-table-row"; import {$isCustomTableRowNode, CustomTableRowNode} from "../../../nodes/custom-table-row";
import {NodeClipboard} from "../../../services/node-clipboard"; import {NodeClipboard} from "../../../services/node-clipboard";
import {r} from "@codemirror/legacy-modes/mode/r";
import {$generateHtmlFromNodes} from "@lexical/html";
const neverActive = (): boolean => false; const neverActive = (): boolean => false;
const cellNotSelected = (selection: BaseSelection|null) => !$selectionContainsNodeType(selection, $isCustomTableCellNode); const cellNotSelected = (selection: BaseSelection|null) => !$selectionContainsNodeType(selection, $isCustomTableCellNode);
@ -40,15 +43,10 @@ export const tableProperties: EditorButtonDefinition = {
icon: tableIcon, icon: tableIcon,
action(context: EditorUiContext) { action(context: EditorUiContext) {
context.editor.getEditorState().read(() => { context.editor.getEditorState().read(() => {
const cell = $getNodeFromSelection($getSelection(), $isCustomTableCellNode); const table = $getTableFromSelection($getSelection());
if (!$isCustomTableCellNode(cell)) { if ($isCustomTableNode(table)) {
return; $showTablePropertiesForm(table, context);
} }
const table = $getParentOfType(cell, $isTableNode);
const modalForm = context.manager.createModal('table_properties');
modalForm.show({});
// TODO
}); });
}, },
isActive: neverActive, isActive: neverActive,
@ -59,14 +57,16 @@ export const clearTableFormatting: EditorButtonDefinition = {
label: 'Clear table formatting', label: 'Clear table formatting',
format: 'long', format: 'long',
action(context: EditorUiContext) { action(context: EditorUiContext) {
context.editor.getEditorState().read(() => { context.editor.update(() => {
const cell = $getNodeFromSelection($getSelection(), $isCustomTableCellNode); const cell = $getNodeFromSelection($getSelection(), $isCustomTableCellNode);
if (!$isCustomTableCellNode(cell)) { if (!$isCustomTableCellNode(cell)) {
return; return;
} }
const table = $getParentOfType(cell, $isTableNode); const table = $getParentOfType(cell, $isTableNode);
// TODO if ($isCustomTableNode(table)) {
$clearTableFormatting(table);
}
}); });
}, },
isActive: neverActive, isActive: neverActive,
@ -77,22 +77,15 @@ export const resizeTableToContents: EditorButtonDefinition = {
label: 'Resize to contents', label: 'Resize to contents',
format: 'long', format: 'long',
action(context: EditorUiContext) { action(context: EditorUiContext) {
context.editor.getEditorState().read(() => { context.editor.update(() => {
const cell = $getNodeFromSelection($getSelection(), $isCustomTableCellNode); const cell = $getNodeFromSelection($getSelection(), $isCustomTableCellNode);
if (!$isCustomTableCellNode(cell)) { if (!$isCustomTableCellNode(cell)) {
return; return;
} }
const table = $getParentOfType(cell, $isCustomTableNode); const table = $getParentOfType(cell, $isCustomTableNode);
if (!$isCustomTableNode(table)) { if ($isCustomTableNode(table)) {
return; $clearTableSizes(table);
}
for (const row of table.getChildren()) {
if ($isTableRowNode(row)) {
// TODO - Come back later as this may depend on if we
// are using a custom table row
}
} }
}); });
}, },
@ -165,14 +158,9 @@ export const rowProperties: EditorButtonDefinition = {
format: 'long', format: 'long',
action(context: EditorUiContext) { action(context: EditorUiContext) {
context.editor.getEditorState().read(() => { context.editor.getEditorState().read(() => {
const cell = $getNodeFromSelection($getSelection(), $isCustomTableCellNode); const rows = $getTableRowsFromSelection($getSelection());
if (!$isCustomTableCellNode(cell)) { if ($isCustomTableRowNode(rows[0])) {
return; $showRowPropertiesForm(rows[0], context);
}
const row = $getParentOfType(cell, $isCustomTableRowNode);
if ($isCustomTableRowNode(row)) {
$showRowPropertiesForm(row, context);
} }
}); });
}, },

View File

@ -9,13 +9,15 @@ import {CustomTableCellNode} from "../../../nodes/custom-table-cell";
import {EditorFormModal} from "../../framework/modals"; import {EditorFormModal} from "../../framework/modals";
import {$getSelection, ElementFormatType} from "lexical"; import {$getSelection, ElementFormatType} from "lexical";
import { import {
$forEachTableCell, $getCellPaddingForTable,
$getTableCellColumnWidth, $getTableCellColumnWidth,
$getTableCellsFromSelection, $getTableCellsFromSelection, $getTableFromSelection,
$getTableRowsFromSelection, $getTableRowsFromSelection,
$setTableCellColumnWidth $setTableCellColumnWidth
} from "../../../utils/tables"; } from "../../../utils/tables";
import {formatSizeValue} from "../../../utils/dom"; import {formatSizeValue} from "../../../utils/dom";
import {CustomTableRowNode} from "../../../nodes/custom-table-row"; import {CustomTableRowNode} from "../../../nodes/custom-table-row";
import {CustomTableNode} from "../../../nodes/custom-table";
const borderStyleInput: EditorSelectFormFieldDefinition = { const borderStyleInput: EditorSelectFormFieldDefinition = {
label: 'Border style', label: 'Border style',
@ -213,10 +215,58 @@ export const rowProperties: EditorFormDefinition = {
backgroundColorInput, // style on tr: height backgroundColorInput, // style on tr: height
], ],
}; };
export function $showTablePropertiesForm(table: CustomTableNode, context: EditorUiContext): EditorFormModal {
const styles = table.getStyles();
const modalForm = context.manager.createModal('table_properties');
modalForm.show({
width: styles.get('width') || '',
height: styles.get('height') || '',
cell_spacing: styles.get('cell-spacing') || '',
cell_padding: $getCellPaddingForTable(table),
border_width: styles.get('border-width') || '',
border_style: styles.get('border-style') || '',
border_color: styles.get('border-color') || '',
background_color: styles.get('background-color') || '',
// caption: '', TODO
align: table.getFormatType(),
});
return modalForm;
}
export const tableProperties: EditorFormDefinition = { export const tableProperties: EditorFormDefinition = {
submitText: 'Save', submitText: 'Save',
async action(formData, context: EditorUiContext) { async action(formData, context: EditorUiContext) {
// TODO context.editor.update(() => {
const table = $getTableFromSelection($getSelection());
if (!table) {
return;
}
const styles = table.getStyles();
styles.set('width', formatSizeValue(formData.get('width')?.toString() || ''));
styles.set('height', formatSizeValue(formData.get('height')?.toString() || ''));
styles.set('cell-spacing', formatSizeValue(formData.get('cell_spacing')?.toString() || ''));
styles.set('border-width', formatSizeValue(formData.get('border_width')?.toString() || ''));
styles.set('border-style', formData.get('border_style')?.toString() || '');
styles.set('border-color', formData.get('border_color')?.toString() || '');
styles.set('background-color', formData.get('background_color')?.toString() || '');
table.setStyles(styles);
table.setFormat(formData.get('align') as ElementFormatType);
const cellPadding = (formData.get('cell_padding')?.toString() || '');
if (cellPadding) {
const cellPaddingFormatted = formatSizeValue(cellPadding);
$forEachTableCell(table, (cell: CustomTableCellNode) => {
const styles = cell.getStyles();
styles.set('padding', cellPaddingFormatted);
cell.setStyles(styles);
});
}
// TODO - cell caption
});
return true; return true;
}, },
fields: [ fields: [
@ -224,42 +274,42 @@ export const tableProperties: EditorFormDefinition = {
build() { build() {
const generalFields: EditorFormFieldDefinition[] = [ const generalFields: EditorFormFieldDefinition[] = [
{ {
label: 'Width', label: 'Width', // Style - width
name: 'width', name: 'width',
type: 'text', type: 'text',
}, },
{ {
label: 'Height', label: 'Height', // Style - height
name: 'height', name: 'height',
type: 'text', type: 'text',
}, },
{ {
label: 'Cell spacing', label: 'Cell spacing', // Style - border-spacing
name: 'cell_spacing', name: 'cell_spacing',
type: 'text', type: 'text',
}, },
{ {
label: 'Cell padding', label: 'Cell padding', // Style - padding on child cells?
name: 'cell_padding', name: 'cell_padding',
type: 'text', type: 'text',
}, },
{ {
label: 'Border width', label: 'Border width', // Style - border-width
name: 'border_width', name: 'border_width',
type: 'text', type: 'text',
}, },
{ {
label: 'caption', label: 'caption', // Caption element
name: 'height', name: 'caption',
type: 'text', // TODO - type: 'text', // TODO -
}, },
alignmentInput, alignmentInput, // alignment class
]; ];
const advancedFields: EditorFormFieldDefinition[] = [ const advancedFields: EditorFormFieldDefinition[] = [
borderStyleInput, borderStyleInput, // Style - border-style
borderColorInput, borderColorInput, // Style - border-color
backgroundColorInput, backgroundColorInput, // Style - background-color
]; ];
return new EditorFormTabs([ return new EditorFormTabs([

View File

@ -30,3 +30,28 @@ export function formatSizeValue(size: number | string, defaultSuffix: string = '
return size; return size;
} }
export type StyleMap = Map<string, string>;
/**
* Creates a map from an element's styles.
* Uses direct attribute value string handling since attempting to iterate
* over .style will expand out any shorthand properties (like 'padding') making
* rather than being representative of the actual properties set.
*/
export function extractStyleMapFromElement(element: HTMLElement): StyleMap {
const map: StyleMap = new Map();
const styleText= element.getAttribute('style') || '';
const rules = styleText.split(';');
for (const rule of rules) {
const [name, value] = rule.split(':');
if (!name || !value) {
continue;
}
map.set(name.trim().toLowerCase(), value.trim());
}
return map;
}

View File

@ -1,11 +0,0 @@
export type StyleMap = Map<string, string>;
export function createStyleMapFromDomStyles(domStyles: CSSStyleDeclaration): StyleMap {
const styleMap: StyleMap = new Map();
const styleNames: string[] = Array.from(domStyles);
for (const style of styleNames) {
styleMap.set(style, domStyles.getPropertyValue(style));
}
return styleMap;
}

View File

@ -206,8 +206,107 @@ export function $getTableRowsFromSelection(selection: BaseSelection|null): Custo
return Object.values(rowsByKey); return Object.values(rowsByKey);
} }
export function $getTableFromSelection(selection: BaseSelection|null): CustomTableNode|null {
const cells = $getTableCellsFromSelection(selection);
if (cells.length === 0) {
return null;
}
const table = $getParentOfType(cells[0], $isCustomTableNode);
if ($isCustomTableNode(table)) {
return table;
}
return null;
}
export function $clearTableSizes(table: CustomTableNode): void {
table.setColWidths([]);
// TODO - Extra form things once table properties and extra things
// are supported
for (const row of table.getChildren()) {
if (!$isCustomTableRowNode(row)) {
continue;
}
const rowStyles = row.getStyles();
rowStyles.delete('height');
rowStyles.delete('width');
row.setStyles(rowStyles);
const cells = row.getChildren().filter(c => $isCustomTableCellNode(c));
for (const cell of cells) {
const cellStyles = cell.getStyles();
cellStyles.delete('height');
cellStyles.delete('width');
cell.setStyles(cellStyles);
cell.clearWidth();
}
}
}
export function $clearTableFormatting(table: CustomTableNode): void {
table.setColWidths([]);
table.setStyles(new Map);
for (const row of table.getChildren()) {
if (!$isCustomTableRowNode(row)) {
continue;
}
row.setStyles(new Map);
row.setFormat('');
const cells = row.getChildren().filter(c => $isCustomTableCellNode(c));
for (const cell of cells) {
cell.setStyles(new Map);
cell.clearWidth();
cell.setFormat('');
}
}
}
/**
* Perform the given callback for each cell in the given table.
* Returning false from the callback stops the function early.
*/
export function $forEachTableCell(table: CustomTableNode, callback: (c: CustomTableCellNode) => void|false): void {
outer: for (const row of table.getChildren()) {
if (!$isCustomTableRowNode(row)) {
continue;
}
const cells = row.getChildren();
for (const cell of cells) {
if (!$isCustomTableCellNode(cell)) {
return;
}
const result = callback(cell);
if (result === false) {
break outer;
}
}
}
}
export function $getCellPaddingForTable(table: CustomTableNode): string {
let padding: string|null = null;
$forEachTableCell(table, (cell: CustomTableCellNode) => {
const cellPadding = cell.getStyles().get('padding') || ''
if (padding === null) {
padding = cellPadding;
}
if (cellPadding !== padding) {
padding = null;
return false;
}
});
return padding || '';
}