]> BookStack Code Mirror - bookstack/commitdiff
Lexical: Wired table properties, and other buttons
authorDan Brown <redacted>
Sat, 10 Aug 2024 12:14:55 +0000 (13:14 +0100)
committerDan Brown <redacted>
Sat, 10 Aug 2024 12:14:55 +0000 (13:14 +0100)
resources/js/wysiwyg/nodes/custom-table-cell.ts
resources/js/wysiwyg/nodes/custom-table-row.ts
resources/js/wysiwyg/nodes/custom-table.ts
resources/js/wysiwyg/todo.md
resources/js/wysiwyg/ui/defaults/buttons/tables.ts
resources/js/wysiwyg/ui/defaults/forms/tables.ts
resources/js/wysiwyg/utils/dom.ts
resources/js/wysiwyg/utils/styles.ts [deleted file]
resources/js/wysiwyg/utils/tables.ts

index b73a2180716420723b125c639fd7e9e2a49212c5..c8fe58c772d4d76975f96625ea852a653ee3948e 100644 (file)
@@ -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<string, string>,
@@ -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;
index effaaa50d014893d6f92893302b314a54afb5de9..f4702f36dd501c5a88488489d796635986d60503 100644 (file)
@@ -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<string, string>,
@@ -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};
index 99351d8527a4ca53c9f810a3bcaa68e5f2a8086d..1d95b789625e1b0ab48204cae062959fd7b8001a 100644 (file)
@@ -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<string, string>,
 }, 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};
                     },
index b6325688e582eaf36d3913ef9088ffed8bb28334..9e501fb2421c4f1102129807165f3afd0fa12c36 100644 (file)
@@ -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
 
index c98f6c02f37f091f28c8a6adc42470752faf4a3d..6242f0b1dbc936aa4db4ed06b38d8ed0a7bed90c 100644 (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 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);
             }
         });
     },
index c4879efae66163bd0798fedd8d60e43450b26f88..5a41c85b3dabbb8d7b479b43b9d96a568d8f3a1b 100644 (file)
@@ -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([
index 7426ac5925290dd6d43c1d2863293194e5cd7a76..a307bdd7531986289187cd3e53483cb8d515a178 100644 (file)
@@ -29,4 +29,29 @@ export function formatSizeValue(size: number | string, defaultSuffix: string = '
     }
 
     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;
 }
\ 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 (file)
index 8767a79..0000000
+++ /dev/null
@@ -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;
-}
\ No newline at end of file
index e808fd595cccd84e9283aba95456041895b0596d..d0fd17e2ce7746d21ec7a60d1d93b29a6212d788 100644 (file)
@@ -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 || '';
+}
 
 
 
Morty Proxy This is a proxified and sanitized view of the page, visit original site.