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.
-
+
| Version |
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();