From ebf95f637a199fa4493013933fabf073d4113bb4 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 10 Aug 2024 13:14:55 +0100 Subject: [PATCH] Lexical: Wired table properties, and other buttons --- .../js/wysiwyg/nodes/custom-table-cell.ts | 9 +- .../js/wysiwyg/nodes/custom-table-row.ts | 11 +- resources/js/wysiwyg/nodes/custom-table.ts | 22 +++- resources/js/wysiwyg/todo.md | 9 +- .../js/wysiwyg/ui/defaults/buttons/tables.ts | 56 ++++------ .../js/wysiwyg/ui/defaults/forms/tables.ts | 76 ++++++++++--- resources/js/wysiwyg/utils/dom.ts | 25 +++++ resources/js/wysiwyg/utils/styles.ts | 11 -- resources/js/wysiwyg/utils/tables.ts | 103 +++++++++++++++++- 9 files changed, 243 insertions(+), 79 deletions(-) delete mode 100644 resources/js/wysiwyg/utils/styles.ts diff --git a/resources/js/wysiwyg/nodes/custom-table-cell.ts b/resources/js/wysiwyg/nodes/custom-table-cell.ts index b73a21807..c8fe58c77 100644 --- a/resources/js/wysiwyg/nodes/custom-table-cell.ts +++ b/resources/js/wysiwyg/nodes/custom-table-cell.ts @@ -20,7 +20,7 @@ import { TableCellNode } from "@lexical/table"; import {TableCellHeaderState} from "@lexical/table/LexicalTableCellNode"; -import {createStyleMapFromDomStyles, StyleMap} from "../utils/styles"; +import {extractStyleMapFromElement, StyleMap} from "../utils/dom"; export type SerializedCustomTableCellNode = Spread<{ styles: Record, @@ -45,6 +45,11 @@ export class CustomTableCellNode extends TableCellNode { return cellNode; } + clearWidth(): void { + const self = this.getWritable(); + self.__width = undefined; + } + getStyles(): StyleMap { const self = this.getLatest(); return new Map(self.__styles); @@ -122,7 +127,7 @@ function $convertCustomTableCellNodeElement(domNode: Node): DOMConversionOutput const output = $convertTableCellNodeElement(domNode); if (domNode instanceof HTMLElement && output.node instanceof CustomTableCellNode) { - output.node.setStyles(createStyleMapFromDomStyles(domNode.style)); + output.node.setStyles(extractStyleMapFromElement(domNode)); } return output; diff --git a/resources/js/wysiwyg/nodes/custom-table-row.ts b/resources/js/wysiwyg/nodes/custom-table-row.ts index effaaa50d..f4702f36d 100644 --- a/resources/js/wysiwyg/nodes/custom-table-row.ts +++ b/resources/js/wysiwyg/nodes/custom-table-row.ts @@ -1,8 +1,4 @@ import { - $createParagraphNode, - $isElementNode, - $isLineBreakNode, - $isTextNode, DOMConversionMap, DOMConversionOutput, EditorConfig, @@ -11,14 +7,11 @@ import { } from "lexical"; import { - $createTableCellNode, - $isTableCellNode, SerializedTableRowNode, - TableCellHeaderStates, TableRowNode } from "@lexical/table"; -import {createStyleMapFromDomStyles, StyleMap} from "../utils/styles"; import {NodeKey} from "lexical/LexicalNode"; +import {extractStyleMapFromElement, StyleMap} from "../utils/dom"; export type SerializedCustomTableRowNode = Spread<{ styles: Record, @@ -98,7 +91,7 @@ export function $convertTableRowElement(domNode: Node): DOMConversionOutput { const rowNode = $createCustomTableRowNode(); if (domNode instanceof HTMLElement) { - rowNode.setStyles(createStyleMapFromDomStyles(domNode.style)); + rowNode.setStyles(extractStyleMapFromElement(domNode)); } return {node: rowNode}; diff --git a/resources/js/wysiwyg/nodes/custom-table.ts b/resources/js/wysiwyg/nodes/custom-table.ts index 99351d852..1d95b7896 100644 --- a/resources/js/wysiwyg/nodes/custom-table.ts +++ b/resources/js/wysiwyg/nodes/custom-table.ts @@ -2,17 +2,19 @@ import {SerializedTableNode, TableNode} from "@lexical/table"; import {DOMConversion, DOMConversionMap, DOMConversionOutput, LexicalNode, Spread} from "lexical"; import {EditorConfig} from "lexical/LexicalEditor"; -import {el} from "../utils/dom"; +import {el, extractStyleMapFromElement, StyleMap} from "../utils/dom"; import {getTableColumnWidths} from "../utils/tables"; export type SerializedCustomTableNode = Spread<{ id: string; colWidths: string[]; + styles: Record, }, SerializedTableNode> export class CustomTableNode extends TableNode { __id: string = ''; __colWidths: string[] = []; + __styles: StyleMap = new Map; static getType() { return 'custom-table'; @@ -38,10 +40,21 @@ export class CustomTableNode extends TableNode { 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) { const newNode = new CustomTableNode(node.__key); newNode.__id = node.__id; newNode.__colWidths = node.__colWidths; + newNode.__styles = new Map(node.__styles); return newNode; } @@ -65,6 +78,10 @@ export class CustomTableNode extends TableNode { dom.append(colgroup); } + for (const [name, value] of this.__styles.entries()) { + dom.style.setProperty(name, value); + } + return dom; } @@ -79,6 +96,7 @@ export class CustomTableNode extends TableNode { version: 1, id: this.__id, colWidths: this.__colWidths, + styles: Object.fromEntries(this.__styles), }; } @@ -86,6 +104,7 @@ export class CustomTableNode extends TableNode { const node = $createCustomTableNode(); node.setId(serializedNode.id); node.setColWidths(serializedNode.colWidths); + node.setStyles(new Map(Object.entries(serializedNode.styles))); return node; } @@ -102,6 +121,7 @@ export class CustomTableNode extends TableNode { const colWidths = getTableColumnWidths(element as HTMLTableElement); node.setColWidths(colWidths); + node.setStyles(extractStyleMapFromElement(element)); return {node}; }, diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index b6325688e..9e501fb24 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -2,13 +2,6 @@ ## In progress -- Table features - - Table properties form logic - - Caption text support - - Resize to contents button - - Remove formatting button - - Cut/Copy/Paste column - ## Main Todo - Alignments: Use existing classes for blocks (including table cells) @@ -23,6 +16,8 @@ - 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) - Media resize support (like images) +- Table caption text support +- Table Cut/Copy/Paste column ## Secondary Todo diff --git a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts index c98f6c02f..6242f0b1d 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts @@ -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 insertRowBelowIcon from "@icons/editor/table-insert-row-below.svg"; import {EditorUiContext} from "../../framework/core"; -import {$createNodeSelection, $createRangeSelection, $getSelection, BaseSelection} from "lexical"; +import {$getSelection, BaseSelection} from "lexical"; import {$isCustomTableNode} from "../../../nodes/custom-table"; import { $deleteTableColumn__EXPERIMENTAL, $deleteTableRow__EXPERIMENTAL, $insertTableColumn__EXPERIMENTAL, $insertTableRow__EXPERIMENTAL, - $isTableNode, $isTableRowNode, $isTableSelection, $unmergeCell, TableCellNode, + $isTableNode, $isTableSelection, $unmergeCell, TableCellNode, } from "@lexical/table"; import {$getNodeFromSelection, $selectionContainsNodeType} from "../../../utils/selection"; import {$getParentOfType} from "../../../utils/nodes"; import {$isCustomTableCellNode} from "../../../nodes/custom-table-cell"; -import {$showCellPropertiesForm, $showRowPropertiesForm} from "../forms/tables"; -import {$getTableRowsFromSelection, $mergeTableCellsInSelection} from "../../../utils/tables"; +import {$showCellPropertiesForm, $showRowPropertiesForm, $showTablePropertiesForm} from "../forms/tables"; +import { + $clearTableFormatting, + $clearTableSizes, $getTableFromSelection, + $getTableRowsFromSelection, + $mergeTableCellsInSelection +} from "../../../utils/tables"; import {$isCustomTableRowNode, CustomTableRowNode} from "../../../nodes/custom-table-row"; import {NodeClipboard} from "../../../services/node-clipboard"; -import {r} from "@codemirror/legacy-modes/mode/r"; -import {$generateHtmlFromNodes} from "@lexical/html"; const neverActive = (): boolean => false; const cellNotSelected = (selection: BaseSelection|null) => !$selectionContainsNodeType(selection, $isCustomTableCellNode); @@ -40,15 +43,10 @@ export const tableProperties: EditorButtonDefinition = { icon: tableIcon, action(context: EditorUiContext) { context.editor.getEditorState().read(() => { - const cell = $getNodeFromSelection($getSelection(), $isCustomTableCellNode); - if (!$isCustomTableCellNode(cell)) { - return; + const table = $getTableFromSelection($getSelection()); + if ($isCustomTableNode(table)) { + $showTablePropertiesForm(table, context); } - - const table = $getParentOfType(cell, $isTableNode); - const modalForm = context.manager.createModal('table_properties'); - modalForm.show({}); - // TODO }); }, isActive: neverActive, @@ -59,14 +57,16 @@ export const clearTableFormatting: EditorButtonDefinition = { label: 'Clear table formatting', format: 'long', action(context: EditorUiContext) { - context.editor.getEditorState().read(() => { + context.editor.update(() => { const cell = $getNodeFromSelection($getSelection(), $isCustomTableCellNode); if (!$isCustomTableCellNode(cell)) { return; } const table = $getParentOfType(cell, $isTableNode); - // TODO + if ($isCustomTableNode(table)) { + $clearTableFormatting(table); + } }); }, isActive: neverActive, @@ -77,22 +77,15 @@ export const resizeTableToContents: EditorButtonDefinition = { label: 'Resize to contents', format: 'long', action(context: EditorUiContext) { - context.editor.getEditorState().read(() => { + context.editor.update(() => { const cell = $getNodeFromSelection($getSelection(), $isCustomTableCellNode); if (!$isCustomTableCellNode(cell)) { return; } const table = $getParentOfType(cell, $isCustomTableNode); - if (!$isCustomTableNode(table)) { - return; - } - - 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 - } + if ($isCustomTableNode(table)) { + $clearTableSizes(table); } }); }, @@ -165,14 +158,9 @@ export const rowProperties: EditorButtonDefinition = { format: 'long', action(context: EditorUiContext) { context.editor.getEditorState().read(() => { - const cell = $getNodeFromSelection($getSelection(), $isCustomTableCellNode); - if (!$isCustomTableCellNode(cell)) { - return; - } - - const row = $getParentOfType(cell, $isCustomTableRowNode); - if ($isCustomTableRowNode(row)) { - $showRowPropertiesForm(row, context); + const rows = $getTableRowsFromSelection($getSelection()); + if ($isCustomTableRowNode(rows[0])) { + $showRowPropertiesForm(rows[0], context); } }); }, diff --git a/resources/js/wysiwyg/ui/defaults/forms/tables.ts b/resources/js/wysiwyg/ui/defaults/forms/tables.ts index c4879efae..5a41c85b3 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/tables.ts @@ -9,13 +9,15 @@ import {CustomTableCellNode} from "../../../nodes/custom-table-cell"; import {EditorFormModal} from "../../framework/modals"; import {$getSelection, ElementFormatType} from "lexical"; import { + $forEachTableCell, $getCellPaddingForTable, $getTableCellColumnWidth, - $getTableCellsFromSelection, + $getTableCellsFromSelection, $getTableFromSelection, $getTableRowsFromSelection, $setTableCellColumnWidth } from "../../../utils/tables"; import {formatSizeValue} from "../../../utils/dom"; import {CustomTableRowNode} from "../../../nodes/custom-table-row"; +import {CustomTableNode} from "../../../nodes/custom-table"; const borderStyleInput: EditorSelectFormFieldDefinition = { label: 'Border style', @@ -213,10 +215,58 @@ export const rowProperties: EditorFormDefinition = { 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 = { submitText: 'Save', 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; }, fields: [ @@ -224,42 +274,42 @@ export const tableProperties: EditorFormDefinition = { build() { const generalFields: EditorFormFieldDefinition[] = [ { - label: 'Width', + label: 'Width', // Style - width name: 'width', type: 'text', }, { - label: 'Height', + label: 'Height', // Style - height name: 'height', type: 'text', }, { - label: 'Cell spacing', + label: 'Cell spacing', // Style - border-spacing name: 'cell_spacing', type: 'text', }, { - label: 'Cell padding', + label: 'Cell padding', // Style - padding on child cells? name: 'cell_padding', type: 'text', }, { - label: 'Border width', + label: 'Border width', // Style - border-width name: 'border_width', type: 'text', }, { - label: 'caption', - name: 'height', + label: 'caption', // Caption element + name: 'caption', type: 'text', // TODO - }, - alignmentInput, + alignmentInput, // alignment class ]; const advancedFields: EditorFormFieldDefinition[] = [ - borderStyleInput, - borderColorInput, - backgroundColorInput, + borderStyleInput, // Style - border-style + borderColorInput, // Style - border-color + backgroundColorInput, // Style - background-color ]; return new EditorFormTabs([ diff --git a/resources/js/wysiwyg/utils/dom.ts b/resources/js/wysiwyg/utils/dom.ts index 7426ac592..a307bdd75 100644 --- a/resources/js/wysiwyg/utils/dom.ts +++ b/resources/js/wysiwyg/utils/dom.ts @@ -29,4 +29,29 @@ export function formatSizeValue(size: number | string, defaultSuffix: string = ' } return size; +} + +export type StyleMap = Map; + +/** + * 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; } \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/styles.ts b/resources/js/wysiwyg/utils/styles.ts deleted file mode 100644 index 8767a7998..000000000 --- a/resources/js/wysiwyg/utils/styles.ts +++ /dev/null @@ -1,11 +0,0 @@ - -export type StyleMap = Map; - -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; -} \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/tables.ts b/resources/js/wysiwyg/utils/tables.ts index e808fd595..d0fd17e2c 100644 --- a/resources/js/wysiwyg/utils/tables.ts +++ b/resources/js/wysiwyg/utils/tables.ts @@ -206,8 +206,107 @@ export function $getTableRowsFromSelection(selection: BaseSelection|null): Custo 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 || ''; +}