Tiptap RTE: Table Properties toolbar menu + modal (#18751)
* Tiptap Table Properties modal * Attempting to reduce the cyclomatic complexity
This commit is contained in:
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './base.js';
|
||||
export * from './table/index.js';
|
||||
export type * from './types.js';
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const UMB_TIPTAP_TABLE_PROPERTIES_MODAL_ALIAS = 'Umb.Modal.Tiptap.TableProperties';
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from './components/table-properties-modal.token.js';
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user