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:
32
src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-div.extension.ts
vendored
Normal file
32
src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-div.extension.ts
vendored
Normal 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];
|
||||
},
|
||||
});
|
||||
@@ -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: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
32
src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-span.extension.ts
vendored
Normal file
32
src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-span.extension.ts
vendored
Normal 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];
|
||||
},
|
||||
});
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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());
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface MetaTiptapExtension {
|
||||
icon: string;
|
||||
label: string;
|
||||
group: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user