Tiptap RTE: Table Properties toolbar menu + modal (#18751)

* Tiptap Table Properties modal

* Attempting to reduce the cyclomatic complexity
This commit is contained in:
Lee Kelleher
2025-03-20 16:36:28 +00:00
committed by GitHub
parent e999d3977c
commit 5fe5a16158
12 changed files with 331 additions and 15 deletions

View File

@@ -1,16 +1,40 @@
import { UmbBubbleMenuPlugin } from './tiptap-umb-bubble-menu.extension.js';
import { CellSelection, TableMap } from '@tiptap/pm/tables';
import { CellSelection, TableMap, TableView } from '@tiptap/pm/tables';
import { Decoration, DecorationSet, EditorView } from '@tiptap/pm/view';
import { EditorState, Plugin, Selection, Transaction } from '@tiptap/pm/state';
import { findParentNode, Editor } from '@tiptap/core';
import { Node as PMNode, ResolvedPos } from '@tiptap/pm/model';
import { Node as ProseMirrorNode, ResolvedPos } from '@tiptap/pm/model';
import { Table } from '@tiptap/extension-table';
import { TableCell } from '@tiptap/extension-table-cell';
import { TableHeader } from '@tiptap/extension-table-header';
import { TableRow } from '@tiptap/extension-table-row';
import type { Rect } from '@tiptap/pm/tables';
export const UmbTable = Table.configure({ resizable: true });
// NOTE: Custom TableView, to allow for custom styles to be applied to the <table> element. [LK]
// ref: https://github.com/ueberdosis/tiptap/blob/v2.11.5/packages/extension-table/src/TableView.ts
export class UmbTableView extends TableView {
constructor(node: ProseMirrorNode, cellMinWidth: number) {
super(node, cellMinWidth);
this.#updateTableStyle(node);
}
override update(node: ProseMirrorNode): boolean {
if (!super.update(node)) return false;
this.#updateTableStyle(node);
return true;
}
#updateTableStyle(node: ProseMirrorNode) {
if (node.attrs.style) {
// NOTE: The `min-width` inline style is handled by the Tiptap TableView, so we need to preserve it. [LK]
const minWidth = this.table.style.minWidth;
const styles = node.attrs.style as string;
this.table.style.cssText = `${styles}; min-width: ${minWidth};`;
}
}
}
export const UmbTable = Table.configure({ resizable: true, View: UmbTableView });
export const UmbTableRow = TableRow.extend({
allowGapCursor: false,
@@ -284,7 +308,7 @@ const getCellsInColumn = (columnIndex: number | number[]) => (selection: Selecti
return acc;
},
[] as { pos: number; start: number; node: PMNode | null | undefined }[],
[] as { pos: number; start: number; node: ProseMirrorNode | null | undefined }[],
);
}
return null;
@@ -318,7 +342,7 @@ const getCellsInRow = (rowIndex: number | number[]) => (selection: Selection) =>
return acc;
},
[] as { pos: number; start: number; node: PMNode | null | undefined }[],
[] as { pos: number; start: number; node: ProseMirrorNode | null | undefined }[],
);
}
@@ -348,7 +372,7 @@ const getCellsInTable = (selection: Selection) => {
return null;
};
const findParentNodeClosestToPos = ($pos: ResolvedPos, predicate: (node: PMNode) => boolean) => {
const findParentNodeClosestToPos = ($pos: ResolvedPos, predicate: (node: ProseMirrorNode) => boolean) => {
for (let i = $pos.depth; i > 0; i -= 1) {
const node = $pos.node(i);
@@ -366,7 +390,7 @@ const findParentNodeClosestToPos = ($pos: ResolvedPos, predicate: (node: PMNode)
};
const findCellClosestToPos = ($pos: ResolvedPos) => {
const predicate = (node: PMNode) => node.type.spec.tableRole && /cell/i.test(node.type.spec.tableRole);
const predicate = (node: ProseMirrorNode) => node.type.spec.tableRole && /cell/i.test(node.type.spec.tableRole);
return findParentNodeClosestToPos($pos, predicate);
};

View File

@@ -919,7 +919,7 @@ export const data: Array<UmbMockDocumentModel> = [
<span>This is a plain old span tag.</span>
<span style="color:red;">Hello <span style="color:blue;">world</span>.</span>
</div>
<table style="width: 100%;">
<table>
<thead>
<tr>
<th>Version</th>

View File

@@ -1,3 +1,4 @@
export * from './components/anchor-modal/constants.js';
export * from './components/character-map/constants.js';
export * from './extensions/table/components/constants.js';
export * from './property-editors/tiptap/constants.js';

View File

@@ -1,2 +1,3 @@
export * from './base.js';
export * from './table/index.js';
export type * from './types.js';

View File

@@ -0,0 +1 @@
export const UMB_TIPTAP_TABLE_PROPERTIES_MODAL_ALIAS = 'Umb.Modal.Tiptap.TableProperties';

View File

@@ -17,7 +17,7 @@ export class UmbTiptapTableInsertElement extends UmbLitElement {
this._selectedColumn = column;
this._selectedRow = row;
this.editor?.chain().focus().insertTable({ rows: row, cols: column }).run();
this.editor?.chain().focus().insertTable({ rows: row, cols: column, withHeaderRow: false }).run();
}
#onMouseover(column: number, row: number) {

View File

@@ -0,0 +1,155 @@
import type {
UmbTiptapTablePropertiesModalData,
UmbTiptapTablePropertiesModalValue,
} from './table-properties-modal.token.js';
import { css, customElement, html, repeat, when } from '@umbraco-cms/backoffice/external/lit';
import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal';
import type { UmbPropertyDatasetElement, UmbPropertyValueData } from '@umbraco-cms/backoffice/property';
import type { UmbPropertyEditorConfig } from '@umbraco-cms/backoffice/property-editor';
type UmbProperty = {
alias: string;
config?: UmbPropertyEditorConfig;
description?: string;
label: string;
propertyEditorUiAlias: string;
};
@customElement('umb-tiptap-table-properties-modal')
export class UmbTiptapTablePropertiesModalElement extends UmbModalBaseElement<
UmbTiptapTablePropertiesModalData,
UmbTiptapTablePropertiesModalValue
> {
#appearance = { labelOnTop: true };
#properties: Array<UmbProperty> = [
{ alias: 'width', label: 'Width', propertyEditorUiAlias: 'Umb.PropertyEditorUi.TextBox' },
{ alias: 'height', label: 'Height', propertyEditorUiAlias: 'Umb.PropertyEditorUi.TextBox' },
{
alias: 'backgroundColor',
label: 'Background color',
propertyEditorUiAlias: 'Umb.PropertyEditorUi.EyeDropper',
config: [{ alias: 'showPalette', value: true }],
},
{
alias: 'alignment',
label: 'Alignment',
propertyEditorUiAlias: 'Umb.PropertyEditorUi.Dropdown',
config: [
{
alias: 'items',
value: [
{ name: 'None', value: 'none' },
{ name: 'Left', value: 'left' },
{ name: 'Center', value: 'center' },
{ name: 'Right', value: 'right' },
],
},
],
},
{ alias: 'borderWidth', label: 'Border width', propertyEditorUiAlias: 'Umb.PropertyEditorUi.TextBox' },
{
alias: 'borderStyle',
label: 'Border style',
propertyEditorUiAlias: 'Umb.PropertyEditorUi.Dropdown',
config: [
{
alias: 'items',
value: [
{ name: 'Solid', value: 'solid' },
{ name: 'Dotted', value: 'dotted' },
{ name: 'Dashed', value: 'dashed' },
{ name: 'Double', value: 'double' },
{ name: 'Groove', value: 'groove' },
{ name: 'Ridge', value: 'ridge' },
{ name: 'Inset', value: 'inset' },
{ name: 'Outset', value: 'outset' },
{ name: 'None', value: 'none' },
{ name: 'Hidden', value: 'hidden' },
],
},
],
},
{
alias: 'borderColor',
label: 'Border color',
propertyEditorUiAlias: 'Umb.PropertyEditorUi.EyeDropper',
config: [{ alias: 'showPalette', value: true }],
},
];
#values: Array<UmbPropertyValueData> = [];
override connectedCallback(): void {
super.connectedCallback();
if (this.data) {
this.#values = Object.entries(this.data).map(([alias, value]) => ({ alias, value }));
}
}
#onChange(event: Event & { target: UmbPropertyDatasetElement }) {
this.#values = event.target.value;
}
#onSubmit() {
this.value = this.#values;
this._submitModal();
}
override render() {
return html`
<umb-body-layout headline="Table properties">
<uui-box>
${when(
this.#properties?.length,
() => html`
<umb-property-dataset .value=${this.#values} @change=${this.#onChange}>
${repeat(
this.#properties,
(property) => property.alias,
(property) => html`
<umb-property
alias=${property.alias}
label=${property.label}
property-editor-ui-alias=${property.propertyEditorUiAlias}
.appearance=${this.#appearance}
.config=${property.config}>
</umb-property>
`,
)}
</umb-property-dataset>
`,
() => html`<p>There are no properties for this modal.</p>`,
)}
</uui-box>
<uui-button
slot="actions"
label=${this.localize.term('general_cancel')}
@click=${this._rejectModal}></uui-button>
<uui-button
slot="actions"
color="positive"
look="primary"
label=${this.localize.term('bulk_done')}
@click=${this.#onSubmit}></uui-button>
</umb-body-layout>
`;
}
static override styles = [
css`
umb-property {
--uui-size-layout-1: var(--uui-size-space-4);
}
`,
];
}
export { UmbTiptapTablePropertiesModalElement as element };
declare global {
interface HTMLElementTagNameMap {
'umb-tiptap-table-properties-modal': UmbTiptapTablePropertiesModalElement;
}
}

View File

@@ -0,0 +1,14 @@
import { UMB_TIPTAP_TABLE_PROPERTIES_MODAL_ALIAS } from './constants.js';
import { UmbModalToken } from '@umbraco-cms/backoffice/modal';
import type { UmbPropertyValueData } from '@umbraco-cms/backoffice/property';
export type UmbTiptapTablePropertiesModalData = Record<string, unknown>;
export type UmbTiptapTablePropertiesModalValue = Array<UmbPropertyValueData>;
export const UMB_TIPTAP_TABLE_PROPERTIES_MODAL = new UmbModalToken<
UmbTiptapTablePropertiesModalData,
UmbTiptapTablePropertiesModalValue
>(UMB_TIPTAP_TABLE_PROPERTIES_MODAL_ALIAS, {
modal: { size: 'small', type: 'sidebar' },
});

View File

@@ -0,0 +1 @@
export * from './components/table-properties-modal.token.js';

View File

@@ -1,6 +1,15 @@
import type { ManifestTiptapExtension } from '../types.js';
import { UMB_TIPTAP_TABLE_PROPERTIES_MODAL_ALIAS } from './components/constants.js';
const coreExtensions: Array<ManifestTiptapExtension> = [
const modals: Array<UmbExtensionManifest> = [
{
type: 'modal',
alias: UMB_TIPTAP_TABLE_PROPERTIES_MODAL_ALIAS,
name: 'Tiptap Table Properties Modal',
element: () => import('./components/table-properties-modal.element.js'),
},
];
const coreExtensions: Array<UmbExtensionManifest> = [
{
type: 'tiptapExtension',
kind: 'button',
@@ -58,10 +67,11 @@ const toolbarExtensions: Array<UmbExtensionManifest> = [
],
separatorAfter: true,
},
{ label: 'Table properties', data: 'tableProperties' },
{ label: 'Delete table', icon: 'icon-trash', data: 'deleteTable' },
],
},
},
];
export const manifests = [...coreExtensions, ...toolbarExtensions];
export const manifests = [...modals, ...coreExtensions, ...toolbarExtensions];

View File

@@ -17,7 +17,6 @@ export default class UmbTiptapTableExtensionApi extends UmbTiptapExtensionApiBas
border-radius: 0.25rem;
border-spacing: 0;
box-sizing: border-box;
width: 100%;
max-width: 100%;
td,
@@ -45,7 +44,6 @@ export default class UmbTiptapTableExtensionApi extends UmbTiptapExtensionApiBas
}
th {
background-color: var(--uui-color-background);
font-weight: bold;
}
@@ -71,7 +69,7 @@ export default class UmbTiptapTableExtensionApi extends UmbTiptapExtensionApiBas
}
.selectedCell {
background-color: var(--uui-color-surface-emphasis);
background-color: color-mix(in srgb, var(--uui-color-surface-emphasis) 50%, transparent);
border-color: var(--uui-color-selected);
}

View File

@@ -1,5 +1,7 @@
import { UmbTiptapToolbarElementApiBase } from '../base.js';
import type { MetaTiptapToolbarMenuItem } from '../types.js';
import { UMB_TIPTAP_TABLE_PROPERTIES_MODAL } from './components/table-properties-modal.token.js';
import { UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal';
import type { Editor } from '@umbraco-cms/backoffice/external/tiptap';
export class UmbTiptapToolbarTableExtensionApi extends UmbTiptapToolbarElementApiBase {
@@ -24,8 +26,117 @@ export class UmbTiptapToolbarTableExtensionApi extends UmbTiptapToolbarElementAp
// Table
deleteTable: (editor) => editor?.chain().focus().deleteTable().run(),
tableProperties: (editor) => this.#tableProperties(editor),
};
async #tableProperties(editor?: Editor) {
if (!editor || !editor.isActive('table')) return;
const modalData = this.#getModalData(editor);
const modalManager = await this.getContext(UMB_MODAL_MANAGER_CONTEXT);
const modal = modalManager.open(this, UMB_TIPTAP_TABLE_PROPERTIES_MODAL, modalData);
if (!modal) return;
const data = await modal.onSubmit().catch(() => undefined);
if (!data) return;
const style = this.#getStyles(data);
if (!style) return;
editor?.chain().focus().updateAttributes('table', { style }).run();
}
#getModalData(editor?: Editor) {
const tableStyles = (editor?.getAttributes('table').style as string) ?? '';
const table = document.createElement('table');
table.style.cssText = tableStyles;
const data: Record<string, unknown> = {};
data.alignment = this.#getAlignment(table.style);
if (table.style.backgroundColor) data.backgroundColor = table.style.backgroundColor;
if (table.style.borderColor) data.borderColor = table.style.borderColor;
if (table.style.borderStyle) data.borderStyle = table.style.borderStyle;
if (table.style.borderWidth) data.borderWidth = table.style.borderWidth;
if (table.style.height) data.height = table.style.height;
if (table.style.width) data.width = table.style.width;
return { data };
}
#getAlignment(style: CSSStyleDeclaration) {
if (style.marginLeft === 'auto' && style.marginRight === 'auto') {
return 'center';
} else if (style.marginRight === 'auto') {
return 'left';
} else if (style.marginLeft === 'auto') {
return 'right';
}
return 'none';
}
#getStyles(data: typeof UMB_TIPTAP_TABLE_PROPERTIES_MODAL.VALUE) {
const styles: Record<string, unknown> = {};
// TODO: Move this to a shared utility function. [LK]
const camelCaseToKebabCase = (str: string): string => {
return str.replace(/[A-Z]+(?![a-z])|[A-Z]/g, ($, ofs) => (ofs ? '-' : '') + $.toLowerCase());
};
for (const item of data) {
if (!item.value) continue;
switch (item.alias) {
case 'alignment': {
const alignment =
Array.isArray(item.value) && item.value.length ? (item.value[0] as string) : ((item.value as string) ?? '');
switch (alignment) {
case 'left':
styles['margin-right'] = 'auto';
break;
case 'center':
styles['margin-left'] = 'auto';
styles['margin-right'] = 'auto';
break;
case 'right':
styles['margin-left'] = 'auto;';
break;
default:
styles['margin-left'] = 'none';
styles['margin-right'] = 'none';
break;
}
break;
}
case 'borderStyle': {
const borderStyle =
Array.isArray(item.value) && item.value.length ? (item.value[0] as string) : ((item.value as string) ?? '');
if (borderStyle) styles['border-style'] = borderStyle;
break;
}
case 'backgroundColor':
case 'borderColor':
case 'borderWidth':
case 'height':
case 'width': {
const propertyName = camelCaseToKebabCase(item.alias);
styles[propertyName] = item.value;
break;
}
default:
break;
}
}
return Object.entries(styles)
.map(([key, value]) => `${key}: ${value}`)
.join(';');
}
override execute(editor?: Editor, item?: MetaTiptapToolbarMenuItem) {
if (!item?.data) return;
const key = item.data.toString();