From 5fe5a16158e4ae5af6fa473b662e67679e3d6c1a Mon Sep 17 00:00:00 2001 From: Lee Kelleher Date: Thu, 20 Mar 2025 16:36:28 +0000 Subject: [PATCH] Tiptap RTE: Table Properties toolbar menu + modal (#18751) * Tiptap Table Properties modal * Attempting to reduce the cyclomatic complexity --- .../extensions/tiptap-umb-table.extension.ts | 38 ++++- .../src/mocks/data/document/document.data.ts | 2 +- .../src/packages/tiptap/constants.ts | 1 + .../src/packages/tiptap/extensions/index.ts | 1 + .../extensions/table/components/constants.ts | 1 + .../table/components/table-insert.element.ts | 2 +- .../table-properties-modal.element.ts | 155 ++++++++++++++++++ .../table-properties-modal.token.ts | 14 ++ .../packages/tiptap/extensions/table/index.ts | 1 + .../tiptap/extensions/table/manifests.ts | 16 +- .../extensions/table/table.tiptap-api.ts | 4 +- .../table/table.tiptap-toolbar-api.ts | 111 +++++++++++++ 12 files changed, 331 insertions(+), 15 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/components/constants.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/components/table-properties-modal.element.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/components/table-properties-modal.token.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/index.ts diff --git a/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-umb-table.extension.ts b/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-umb-table.extension.ts index e015be74e2..e734a5faf7 100644 --- a/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-umb-table.extension.ts +++ b/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-umb-table.extension.ts @@ -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 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); }; diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/document/document.data.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/document/document.data.ts index 45be5334cf..93c3322f44 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/document/document.data.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/document/document.data.ts @@ -919,7 +919,7 @@ export const data: Array = [ This is a plain old span tag. Hello world. -
+
diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/constants.ts index 8613d3f9b4..ee5bc0046b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/constants.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/constants.ts @@ -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'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/index.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/index.ts index ffd2be35b6..3442599e46 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/index.ts @@ -1,2 +1,3 @@ export * from './base.js'; +export * from './table/index.js'; export type * from './types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/components/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/components/constants.ts new file mode 100644 index 0000000000..676d7498c6 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/components/constants.ts @@ -0,0 +1 @@ +export const UMB_TIPTAP_TABLE_PROPERTIES_MODAL_ALIAS = 'Umb.Modal.Tiptap.TableProperties'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/components/table-insert.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/components/table-insert.element.ts index a3112de1ad..3f229a250c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/components/table-insert.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/components/table-insert.element.ts @@ -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) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/components/table-properties-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/components/table-properties-modal.element.ts new file mode 100644 index 0000000000..33c5a127cb --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/components/table-properties-modal.element.ts @@ -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 = [ + { 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 = []; + + 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` + + + ${when( + this.#properties?.length, + () => html` + + ${repeat( + this.#properties, + (property) => property.alias, + (property) => html` + + + `, + )} + + `, + () => html`

There are no properties for this modal.

`, + )} +
+ + +
+ `; + } + + 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; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/components/table-properties-modal.token.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/components/table-properties-modal.token.ts new file mode 100644 index 0000000000..3530efed4b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/components/table-properties-modal.token.ts @@ -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; + +export type UmbTiptapTablePropertiesModalValue = Array; + +export const UMB_TIPTAP_TABLE_PROPERTIES_MODAL = new UmbModalToken< + UmbTiptapTablePropertiesModalData, + UmbTiptapTablePropertiesModalValue +>(UMB_TIPTAP_TABLE_PROPERTIES_MODAL_ALIAS, { + modal: { size: 'small', type: 'sidebar' }, +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/index.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/index.ts new file mode 100644 index 0000000000..cda941411e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/index.ts @@ -0,0 +1 @@ +export * from './components/table-properties-modal.token.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/manifests.ts index 493add2b69..5fa21b5de5 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/manifests.ts @@ -1,6 +1,15 @@ -import type { ManifestTiptapExtension } from '../types.js'; +import { UMB_TIPTAP_TABLE_PROPERTIES_MODAL_ALIAS } from './components/constants.js'; -const coreExtensions: Array = [ +const modals: Array = [ + { + 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 = [ { type: 'tiptapExtension', kind: 'button', @@ -58,10 +67,11 @@ const toolbarExtensions: Array = [ ], 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]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/table.tiptap-api.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/table.tiptap-api.ts index 652984408d..c16dac9bc8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/table.tiptap-api.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/table.tiptap-api.ts @@ -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); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/table.tiptap-toolbar-api.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/table.tiptap-toolbar-api.ts index 576a318c24..f641221b55 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/table.tiptap-toolbar-api.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/table.tiptap-toolbar-api.ts @@ -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 = {}; + + 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 = {}; + + // 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();
Version