TipTap Editor: How do I add a wrapping DIV when inserting the 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