TipTap Editor: How do I add a wrapping DIV when inserting the table?

Heya :waving_hand:
I am migrating a project where the TinyMCE had a plugin that wrapped a DIV with a specific CSS class around the table anytime it was inserted into the editor.

Previous TinyMCE code

(function () {
    tinymce.PluginManager.add('wrap_tables', function (editor) {
        editor.on('PreInit', function () {
            editor.serializer.addNodeFilter('table', function (nodes) {
                nodes.forEach(node => {
                    // Check if the table already has a parent with class 'gsc-table__container'
                    if (node.parent && node.parent.name === 'div' && node.parent.attr('class') === 'gsc-table__container') {
                        return; // Skip if already wrapped
                    }

                    // Create a new wrapper div and wrap the table
                    let wrapper = new tinymce.html.Node('div', 1);
                    wrapper.attr('class', 'gsc-table__container');
                    node.wrap(wrapper);
                });
            });
        });
    });
})();

How would I solve this problem now?

So with TipTap what extension points do I have available to me and what would be the best way to achieve the same result?

Is any of this available or the approach I should take?

  • Extending/inheriting the current table TipTap extension to override a method when it inserts it into the editor?
  • Listen for a specific event to know when a table is added into the editor so I can then amend and wrap it?
  • Remove Umbraco’s table extension and have to replace it entirely *(hoping I dont have to do this)
  • Or… ?

Following up with my own notes

Extending TipTap

Interestingly TipTap Extensions use a pattern where there is a method called Extend that can be used to change and configure how a TipTap extension works.

Extending extensions HTML that is rendered

Off to give it a go… will report back with some code if I get it all working

1 Like

No luck… :shamrock:

So this is what I have tried, but have not been able to get to working. So just wondering if there is a step or something I may have missed.

Manifest.ts

export const manifests: Array<UmbExtensionManifest> = [
    {
        "name": "Tiptap Wrap Tables",
        "alias": "Tiptap.WrapTables",
        "type": "tiptapExtension",
        "meta": {
            "group": "Custom",
            "label": "Wrap Tables in HTML",
            "description": "Wraps tables in a div with class 'gsc-table__container' to allow for custom styling.",
            "icon": "icon-grid-2x2",
        },
        js: () => import('./wrap-table/wrap-table'),
    }
];

wrap-table.ts

import { UmbTiptapExtensionApiBase } from '@umbraco-cms/backoffice/tiptap';
import { WrapTable } from './wrap-table-extension';

export default class WrapTableExtensionApi extends UmbTiptapExtensionApiBase {
    getTiptapExtensions = () => [WrapTable]; // Add in our custom extended extension
}

wrap-table-extension.ts

import { createColGroup } from '@tiptap/extension-table';
import type { DOMOutputSpec } from '@tiptap/pm/model';
import { mergeAttributes, UmbTable } from '@umbraco-cms/backoffice/external/tiptap';

// Extend UmbTable which is extending the Tiptap Table extension
// https://github.com/umbraco/Umbraco-CMS/blob/main/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-umb-table.extension.ts#L37
export const WrapTable = UmbTable.extend({

    // https://github.com/ueberdosis/tiptap/blob/next/packages/extension-table/src/table/table.ts#L258C3-L271C5
    renderHTML({ node, HTMLAttributes }) {

        console.log('WRAP TABLE', node.toString(), HTMLAttributes);

         /*
            The first value in the array should be the name of HTML tag.
            If the second element is an object, it’s interpreted as a set of attributes. 
            Any elements after that are rendered as children.

            The number zero (representing a hole) is used to indicate where the content should be inserted.

            https://tiptap.dev/docs/editor/extensions/custom-extensions/extend-existing
        */

        // return ['strong', HTMLAttributes, 0]
        // return ['pre', ['code', HTMLAttributes, 0]]
        // return ['a', mergeAttributes(HTMLAttributes, { rel: this.options.rel }), 0]

        const { colgroup, tableWidth, tableMinWidth } = createColGroup(node, this.options.cellMinWidth)

        const table: DOMOutputSpec = [
            'div',
            { class: 'gsc-table__container' },
            [
                'table',
                mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
                    style: tableWidth ? `width: ${tableWidth}` : `min-width: ${tableMinWidth}`,
                }),
                colgroup,
                ['tbody', 0],
            ],
        ];

        return table;
    },
});

Screenshot

My next train of thought was that perhaps I needed to enable it, so jumped into the DataType configuration of the Rich Text Editor and saved it. So I can see that Umbraco has picked up the manifest at least correctly.

What did I miss?

I am not seeing my lovely trusty console.log debug message when I add a table to the editor by using the toolbar button.

Update

Remember to read the docs properly :see_no_evil_monkey: the manifest needs to use the property API and not JS

export const manifests: Array<UmbExtensionManifest> = [
    {
        "name": "Tiptap Wrap Tables",
        "alias": "Tiptap.WrapTables",
        "type": "tiptapExtension",
        "meta": {
            "group": "Custom",
            "label": "Wrap Tables in HTML",
            "description": "Wraps tables in a div with class 'gsc-table__container' to allow for custom styling.",
            "icon": "icon-grid-2x2",
        },
        //js: () => import('./wrap-table/wrap-table'), // NOPE
        api: () => import('./wrap-table/wrap-table'), 
    }
];

Solution

This is what I have, it may not be super perfect but it does what we need to do. I needed to unregister the one Umbraco ships with, which I done in an entrypoint.ts file I had.

manifest.ts

export const manifests: Array<UmbExtensionManifest> = [
    {
        "name": "[Consilium] Tiptap Wrap Tables",
        "alias": "Consilium.Tiptap.WrapTables",
        "type": "tiptapExtension",
        "meta": {
            "group": "Consilium",
            "label": "Wrap Tables in HTML",
            "description": "Wraps tables in a div with class 'gsc-table__container' to allow for custom styling.",
            "icon": "icon-grid-2x2",
        },
        api: () => import('./wrap-table/wrap-table'),
    }
];

wrap-table.ts

import { UmbTiptapExtensionApiBase } from '@umbraco-cms/backoffice/tiptap';
import { WrapTable } from './wrap-table-extension';
import { UmbTableCell, UmbTableHeader, UmbTableRow } from '@umbraco-cms/backoffice/external/tiptap';
import { css } from '@umbraco-cms/backoffice/external/lit';

export default class WrapTableTipTapExtensionApi extends UmbTiptapExtensionApiBase {

    // Umbraco's Table Extenion has 'UmbTableHeader, UmbTableRow, UmbTableCell'
    // We are simply replacing/extending UmbTable with WrapTable
    getTiptapExtensions = () => [WrapTable, UmbTableHeader, UmbTableRow, UmbTableCell];

    // I dont like I need to copy/paste from source
    // Was hoping I could get it from the UmbTiptapTableExtensionApi
    // https://github.com/umbraco/Umbraco-CMS/blob/main/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/table.tiptap-api.ts#L11

    override getStyles = () => css`
		.tableWrapper {
			margin: 1.5rem 0;

			table {
				border-color: rgba(0, 0, 0, 0.1);
				border-radius: 0.25rem;
				border-spacing: 0;
				box-sizing: border-box;
				max-width: 100%;

				td,
				th {
					box-sizing: border-box;
					position: relative;
					min-width: 50px;
					border: 1px solid var(--uui-color-border);
					padding: 0.5rem;
					text-align: left;
					vertical-align: top;

					&:first-of-type:not(a),
					&:first-of-type:not(a) {
						margin-top: 0;
					}

					p {
						margin: 0;
					}

					p + p {
						margin-top: 0.75rem;
					}
				}

				th {
					font-weight: bold;
				}

				.column-resize-handle {
					cursor: ew-resize;
					cursor: col-resize;
					display: flex;
					position: absolute;
					top: 0;
					bottom: -2px;
					right: -0.25rem;
					width: 0.5rem;
				}

				.column-resize-handle:before {
					margin-left: 0.5rem;
					height: 100%;
					width: 1px;
				}

				.column-resize-handle:before {
					content: '';
				}

				.selectedCell {
					background-color: color-mix(in srgb, var(--uui-color-surface-emphasis) 50%, transparent);
					border-color: var(--uui-color-selected);
				}

				.grip-column,
				.grip-row {
					position: absolute;
					z-index: 10;
					display: flex;
					cursor: pointer;
					align-items: center;
					justify-content: center;
					background-color: rgba(0, 0, 0, 0.05);
					border-color: rgba(0, 0, 0, 0.2);

					uui-symbol-more {
						visibility: hidden;
					}

					&:hover {
						background-color: rgba(0, 0, 0, 0.1);
					}

					&.selected {
						border-color: rgba(0, 0, 0, 0.3);
						background-color: rgba(0, 0, 0, 0.3);
						box-shadow:
							0 0 #0000,
							0 0 #0000,
							0 0 rgba(0, 0, 0, 0.05);
					}

					&:hover uui-symbol-more,
					&.selected uui-symbol-more {
						visibility: visible;
					}
				}

				.grip-column {
					border-left-width: 1px;
					top: -0.75rem;
					left: 0;
					height: 0.75rem;
					width: calc(100% + 1px);
					margin-left: -1px;
				}

				.grip-row {
					border-top-width: 1px;
					flex-direction: column;
					top: 0;
					left: -0.75rem;
					height: calc(100% + 1px);
					width: 0.75rem;
					margin-top: -1px;

					uui-symbol-more {
						transform: rotate(90deg);
					}
				}
			}
		}
	`;
}

wrap-table-extension.ts

import { createColGroup } from '@tiptap/extension-table';
import type { DOMOutputSpec } from '@tiptap/pm/model';
import { mergeAttributes, UmbTable } from '@umbraco-cms/backoffice/external/tiptap';

// Extend UmbTable which is extending the Tiptap Table extension
// https://tiptap.dev/docs/editor/extensions/custom-extensions/extend-existing
// https://github.com/umbraco/Umbraco-CMS/blob/main/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-umb-table.extension.ts#L37
export const WrapTable = UmbTable.extend({

    // Uses same code from UmbTable which in turn is inhering from Tiptap's Table extension
    // https://github.com/ueberdosis/tiptap/blob/next/packages/extension-table/src/table/table.ts#L258C3-L271C5
    renderHTML({ node, HTMLAttributes }) {
        const { colgroup, tableWidth, tableMinWidth } = createColGroup(node, this.options.cellMinWidth);

        // Filter out the wrapper's class from HTMLAttributes
        const filteredHTMLAttributes = { ...HTMLAttributes };
        if (filteredHTMLAttributes.class) {
            filteredHTMLAttributes.class = filteredHTMLAttributes.class
                .split(' ')
                .filter((cls: string) => cls !== 'gsc-table__container')
                .join(' ');

            if (!filteredHTMLAttributes.class.trim()) {
                delete filteredHTMLAttributes.class;
            }
        }

        // Updated DOMOutpputSpec to include the 'gsc-table__container' class
        const table: DOMOutputSpec = [
            'div',
            { class: 'gsc-table__container' },
            [
                'table',
                mergeAttributes(this.options.HTMLAttributes, filteredHTMLAttributes, {
                    style: tableWidth ? `width: ${tableWidth}` : `min-width: ${tableMinWidth}`,
                }),
                colgroup,
                ['tbody', 0],
            ],
        ];
        return table;
    },

    // https://github.com/ueberdosis/tiptap/blob/next/packages/extension-table/src/table/table.ts#L254C3-L256C5
    parseHTML() {
        return [
            {
                tag: 'div.gsc-table__container',
            }
        ]
    }
});

entrypoint.ts

import { UmbEntryPointOnInit, UmbEntryPointOnUnload } from '@umbraco-cms/backoffice/extension-api';
import { UMB_AUTH_CONTEXT } from '@umbraco-cms/backoffice/auth';
import { client } from './api/client.gen';

// load up the manifests here
export const onInit: UmbEntryPointOnInit = (_host, _extensionRegistry) => {

  // Will use only to add in Open API config with generated TS OpenAPI HTTPS Client
  // Do the OAuth token handshake stuff
  _host.consumeContext(UMB_AUTH_CONTEXT, async (authContext) => {

    // Get the token info from Umbraco
    const config = authContext?.getOpenApiConfiguration();

    client.setConfig({
      baseUrl: config?.base,
      credentials: config?.credentials,
      auth: () => config?.token(), // Dont need to use the interceptor approach anymore
    });
  });


  // Unregister Umbraco TipTap table extension
  // As we have our own that it extends theirs to wrap with a div with a class
  //_extensionRegistry.unregister('Umb.Tiptap.Table');
  _extensionRegistry.exclude('Umb.Tiptap.Table');
};

export const onUnload: UmbEntryPointOnUnload = (_host, _extensionRegistry) => {
  // Use to do any cleanup work when the entry point is unloaded
};
1 Like