Feature: Tiptap: Generic markup support (#18124)

* Adds "HTML Global Attributes" Tiptap extension

This is to add `class`, `id` and `data-*` attributes
to any markup within Tiptap contents.

* Adds "Span" element Tiptap extension

to support generic markup modifications.

* Adds "Div" element Tiptap extension

to support generic markup modifications.

Also modifies "umbEmbeddedMedia" to check explicitly
for the `umb-embed-holder` class name. This is to differentiate
from the generic `div` tag.

* Adds "Rich Text Essentials" Tiptap extension

Previously this was a faux extension, but it is now real.

This extension adds the core extensions for Umbraco RTE support.
e.g. StarterKit, et al, and the new global attributes and generic elements.

* Reverts `elementName` constant
This commit is contained in:
Lee Kelleher
2025-01-27 12:29:44 +00:00
committed by GitHub
parent 1e89b5a1e0
commit d90002247d
11 changed files with 230 additions and 47 deletions

View File

@@ -0,0 +1,32 @@
import { Node, mergeAttributes } from '@tiptap/core';
export interface DivOptions {
/**
* HTML attributes to add to the element.
* @default {}
* @example { class: 'foo' }
*/
HTMLAttributes: Record<string, any>;
}
export const Div = Node.create<DivOptions>({
name: 'div',
priority: 50,
group: 'block',
content: 'inline*',
addOptions() {
return { HTMLAttributes: {} };
},
parseHTML() {
return [{ tag: 'div' }];
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
},
});

View File

@@ -0,0 +1,52 @@
import { Extension } from '@tiptap/core';
/**
* Converts camelCase to kebab-case.
* @param {string} str - The string to convert.
* @returns {string} The converted string.
*/
function camelCaseToKebabCase(str: string): string {
return str.replace(/[A-Z]+(?![a-z])|[A-Z]/g, ($, ofs) => (ofs ? '-' : '') + $.toLowerCase());
}
export interface HtmlGlobalAttributesOptions {
/**
* The types where the text align attribute can be applied.
* @default []
* @example ['heading', 'paragraph']
*/
types: Array<string>;
}
export const HtmlGlobalAttributes = Extension.create<HtmlGlobalAttributesOptions>({
name: 'htmlGlobalAttributes',
addOptions() {
return { types: [] };
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
class: {},
dataset: {
parseHTML: (element) => element.dataset,
renderHTML: (attributes) => {
const keys = attributes.dataset ? Object.keys(attributes.dataset) : [];
if (!keys.length) return {};
const dataAtrrs: Record<string, string> = {};
keys.forEach((key) => {
dataAtrrs['data-' + camelCaseToKebabCase(key)] = attributes.dataset[key];
});
return dataAtrrs;
},
},
id: {},
style: {},
},
},
];
},
});

View File

@@ -0,0 +1,32 @@
import { Node, mergeAttributes } from '@tiptap/core';
export interface SpanOptions {
/**
* HTML attributes to add to the element.
* @default {}
* @example { class: 'foo' }
*/
HTMLAttributes: Record<string, any>;
}
export const Span = Node.create<SpanOptions>({
name: 'span',
group: 'inline',
inline: true,
content: 'inline*',
addOptions() {
return { HTMLAttributes: {} };
},
parseHTML() {
return [{ tag: 'span' }];
},
renderHTML({ HTMLAttributes }) {
return ['span', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
},
});

View File

@@ -8,6 +8,7 @@ export const umbEmbeddedMedia = Node.create({
inline() {
return this.options.inline;
},
atom: true,
marks: '',
draggable: true,
@@ -19,12 +20,18 @@ export const umbEmbeddedMedia = Node.create({
'data-embed-height': { default: 240 },
'data-embed-url': { default: null },
'data-embed-width': { default: 360 },
markup: { default: null },
markup: { default: null, parseHTML: (element) => element.innerHTML },
};
},
parseHTML() {
return [{ tag: 'div', class: 'umb-embed-holder', getAttrs: (node) => ({ markup: node.innerHTML }) }];
return [
{
tag: 'div',
priority: 100,
getAttrs: (dom) => dom.classList.contains('umb-embed-holder') && null,
},
];
},
renderHTML({ HTMLAttributes }) {

View File

@@ -28,8 +28,11 @@ export { TextAlign } from '@tiptap/extension-text-align';
export { Underline } from '@tiptap/extension-underline';
// CUSTOM EXTENSIONS
export * from './extensions/tiptap-umb-embedded-media.extension.js';
export * from './extensions/tiptap-div.extension.js';
export * from './extensions/tiptap-figcaption.extension.js';
export * from './extensions/tiptap-figure.extension.js';
export * from './extensions/tiptap-span.extension.js';
export * from './extensions/tiptap-html-global-attributes.extension.js';
export * from './extensions/tiptap-umb-embedded-media.extension.js';
export * from './extensions/tiptap-umb-image.extension.js';
export * from './extensions/tiptap-umb-link.extension.js';

View File

@@ -3,7 +3,7 @@ import type { UmbTiptapToolbarValue } from '../types.js';
import { css, customElement, html, property, state, when } from '@umbraco-cms/backoffice/external/lit';
import { loadManifestApi } from '@umbraco-cms/backoffice/extension-api';
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
import { Editor, Placeholder, StarterKit, TextStyle } from '@umbraco-cms/backoffice/external/tiptap';
import { Editor } from '@umbraco-cms/backoffice/external/tiptap';
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation';
@@ -12,28 +12,13 @@ import type { UmbPropertyEditorConfigCollection } from '@umbraco-cms/backoffice/
import './tiptap-hover-menu.element.js';
import './tiptap-toolbar.element.js';
const TIPTAP_CORE_EXTENSION_ALIAS = 'Umb.Tiptap.RichTextEssentials';
@customElement('umb-input-tiptap')
export class UmbInputTiptapElement extends UmbFormControlMixin<string, typeof UmbLitElement, string>(UmbLitElement) {
readonly #requiredExtensions = [
StarterKit,
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === 'heading') {
return this.localize.term('placeholders_rteHeading');
}
return this.localize.term('placeholders_rteParagraph');
},
}),
TextStyle,
];
@state()
private readonly _extensions: Array<UmbTiptapExtensionApi> = [];
@property({ type: String })
override set value(value: string) {
this.#markup = value;
this.#value = value;
// Try to set the value to the editor if it is ready.
if (this._editor) {
@@ -41,10 +26,9 @@ export class UmbInputTiptapElement extends UmbFormControlMixin<string, typeof Um
}
}
override get value() {
return this.#markup;
return this.#value;
}
#markup = '';
#value = '';
@property({ attribute: false })
configuration?: UmbPropertyEditorConfigCollection;
@@ -58,6 +42,9 @@ export class UmbInputTiptapElement extends UmbFormControlMixin<string, typeof Um
@state()
private _editor?: Editor;
@state()
private readonly _extensions: Array<UmbTiptapExtensionApi> = [];
@state()
_toolbar: UmbTiptapToolbarValue = [[[]]];
@@ -76,7 +63,13 @@ export class UmbInputTiptapElement extends UmbFormControlMixin<string, typeof Um
async #loadExtensions() {
await new Promise<void>((resolve) => {
this.observe(umbExtensionsRegistry.byType('tiptapExtension'), async (manifests) => {
const enabledExtensions = this.configuration?.getValueByAlias<string[]>('extensions') ?? [];
let enabledExtensions = this.configuration?.getValueByAlias<string[]>('extensions') ?? [];
// Ensures that the "Rich Text Essentials" extension is always enabled. [LK]
if (!enabledExtensions.includes(TIPTAP_CORE_EXTENSION_ALIAS)) {
enabledExtensions = [TIPTAP_CORE_EXTENSION_ALIAS, ...enabledExtensions];
}
for (const manifest of manifests) {
if (manifest.api) {
const extension = await loadManifestApi(manifest.api);
@@ -114,13 +107,13 @@ export class UmbInputTiptapElement extends UmbFormControlMixin<string, typeof Um
this._editor = new Editor({
element: element,
editable: !this.readonly,
extensions: [...this.#requiredExtensions, ...extensions],
content: this.#markup,
extensions: extensions,
content: this.#value,
onBeforeCreate: ({ editor }) => {
this._extensions.forEach((ext) => ext.setEditor(editor));
},
onUpdate: ({ editor }) => {
this.#markup = editor.getHTML();
this.#value = editor.getHTML();
this.dispatchEvent(new UmbChangeEvent());
},
});

View File

@@ -0,0 +1,57 @@
import { UmbTiptapExtensionApiBase } from '../base.js';
import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api';
import {
Div,
HtmlGlobalAttributes,
Placeholder,
Span,
StarterKit,
TextStyle,
} from '@umbraco-cms/backoffice/external/tiptap';
export default class UmbTiptapRichTextEssentialsExtensionApi extends UmbTiptapExtensionApiBase {
#localize = new UmbLocalizationController(this);
getTiptapExtensions = () => [
StarterKit,
Placeholder.configure({
placeholder: ({ node }) => {
return this.#localize.term(
node.type.name === 'heading' ? 'placeholders_rteHeading' : 'placeholders_rteParagraph',
);
},
}),
TextStyle,
HtmlGlobalAttributes.configure({
types: [
'bold',
'blockquote',
'bulletList',
'codeBlock',
'div',
'figcaption',
'figure',
'heading',
'horizontalRule',
'italic',
'image',
'link',
'orderedList',
'paragraph',
'span',
'strike',
'subscript',
'superscript',
'table',
'tableHeader',
'tableRow',
'tableCell',
'textStyle',
'underline',
'umbLink',
],
}),
Div,
Span,
];
}

View File

@@ -15,6 +15,19 @@ const kinds: Array<UmbExtensionManifestKind> = [
];
const coreExtensions: Array<ManifestTiptapExtension> = [
{
type: 'tiptapExtension',
alias: 'Umb.Tiptap.RichTextEssentials',
name: 'Rich Text Essentials Tiptap Extension',
api: () => import('./core/rich-text-essentials.tiptap-api.js'),
weight: 1000,
meta: {
icon: 'icon-browser-window',
label: 'Rich Text Essentials',
group: '#tiptap_extGroup_formatting',
description: 'This is a core extension, it is always enabled by default.',
},
},
{
type: 'tiptapExtension',
alias: 'Umb.Tiptap.Embed',

View File

@@ -11,6 +11,7 @@ export interface MetaTiptapExtension {
icon: string;
label: string;
group: string;
description?: string;
}
declare global {

View File

@@ -39,9 +39,7 @@ type UmbTiptapExtensionGroup = {
const TIPTAP_CORE_EXTENSION_ALIAS = 'Umb.Tiptap.RichTextEssentials';
const TIPTAP_BLOCK_EXTENSION_ALIAS = 'Umb.Tiptap.Block';
const elementName = 'umb-property-editor-ui-tiptap-extensions-configuration';
@customElement(elementName)
@customElement('umb-property-editor-ui-tiptap-extensions-configuration')
export class UmbPropertyEditorUiTiptapExtensionsConfigurationElement
extends UmbLitElement
implements UmbPropertyEditorUiElement
@@ -101,16 +99,13 @@ export class UmbPropertyEditorUiTiptapExtensionsConfigurationElement
this.observe(umbExtensionsRegistry.byType('tiptapExtension'), (extensions) => {
this._extensions = extensions
.sort((a, b) => a.alias.localeCompare(b.alias))
.map((ext) => ({ alias: ext.alias, label: ext.meta.label, icon: ext.meta.icon, group: ext.meta.group }));
// Hardcoded core extension
this._extensions.unshift({
alias: TIPTAP_CORE_EXTENSION_ALIAS,
label: 'Rich Text Essentials',
icon: 'icon-browser-window',
group: '#tiptap_extGroup_formatting',
description: 'This is a core extension, it is always enabled by default.',
});
.map((ext) => ({
alias: ext.alias,
label: ext.meta.label,
icon: ext.meta.icon,
group: ext.meta.group,
description: ext.meta.description,
}));
if (!this.value) {
// The default value is all extensions enabled
@@ -226,6 +221,6 @@ export { UmbPropertyEditorUiTiptapExtensionsConfigurationElement as element };
declare global {
interface HTMLElementTagNameMap {
[elementName]: UmbPropertyEditorUiTiptapExtensionsConfigurationElement;
'umb-property-editor-ui-tiptap-extensions-configuration': UmbPropertyEditorUiTiptapExtensionsConfigurationElement;
}
}

View File

@@ -4,12 +4,10 @@ import { customElement, html } from '@umbraco-cms/backoffice/external/lit';
import '../../components/input-tiptap/input-tiptap.element.js';
const elementName = 'umb-property-editor-ui-tiptap';
/**
* @element umb-property-editor-ui-tiptap
*/
@customElement(elementName)
@customElement('umb-property-editor-ui-tiptap')
export class UmbPropertyEditorUiTiptapElement extends UmbPropertyEditorUiRteElementBase {
#onChange(event: CustomEvent & { target: UmbInputTiptapElement }) {
const tipTapElement = event.target;
@@ -75,6 +73,6 @@ export { UmbPropertyEditorUiTiptapElement as element };
declare global {
interface HTMLElementTagNameMap {
[elementName]: UmbPropertyEditorUiTiptapElement;
'umb-property-editor-ui-tiptap': UmbPropertyEditorUiTiptapElement;
}
}