diff --git a/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-div.extension.ts b/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-div.extension.ts new file mode 100644 index 000000000000..86563b88c64a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-div.extension.ts @@ -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; +} + +export const Div = Node.create({ + 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]; + }, +}); diff --git a/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-html-global-attributes.extension.ts b/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-html-global-attributes.extension.ts new file mode 100644 index 000000000000..eb6b255c69b0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-html-global-attributes.extension.ts @@ -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; +} + +export const HtmlGlobalAttributes = Extension.create({ + 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 = {}; + keys.forEach((key) => { + dataAtrrs['data-' + camelCaseToKebabCase(key)] = attributes.dataset[key]; + }); + return dataAtrrs; + }, + }, + id: {}, + style: {}, + }, + }, + ]; + }, +}); diff --git a/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-span.extension.ts b/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-span.extension.ts new file mode 100644 index 000000000000..084ae6e3d0b7 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-span.extension.ts @@ -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; +} + +export const Span = Node.create({ + 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]; + }, +}); diff --git a/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-umb-embedded-media.extension.ts b/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-umb-embedded-media.extension.ts index 3a13add2cbe7..daa8071f263e 100644 --- a/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-umb-embedded-media.extension.ts +++ b/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-umb-embedded-media.extension.ts @@ -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 }) { diff --git a/src/Umbraco.Web.UI.Client/src/external/tiptap/index.ts b/src/Umbraco.Web.UI.Client/src/external/tiptap/index.ts index 16db5db67694..68be91b9fe04 100644 --- a/src/Umbraco.Web.UI.Client/src/external/tiptap/index.ts +++ b/src/Umbraco.Web.UI.Client/src/external/tiptap/index.ts @@ -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'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/input-tiptap/input-tiptap.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/input-tiptap/input-tiptap.element.ts index 359e84376d18..60a5819869af 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/input-tiptap/input-tiptap.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/input-tiptap/input-tiptap.element.ts @@ -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(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 = []; - @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 = []; + @state() _toolbar: UmbTiptapToolbarValue = [[[]]]; @@ -76,7 +63,13 @@ export class UmbInputTiptapElement extends UmbFormControlMixin((resolve) => { this.observe(umbExtensionsRegistry.byType('tiptapExtension'), async (manifests) => { - const enabledExtensions = this.configuration?.getValueByAlias('extensions') ?? []; + let enabledExtensions = this.configuration?.getValueByAlias('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 { this._extensions.forEach((ext) => ext.setEditor(editor)); }, onUpdate: ({ editor }) => { - this.#markup = editor.getHTML(); + this.#value = editor.getHTML(); this.dispatchEvent(new UmbChangeEvent()); }, }); diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/core/rich-text-essentials.tiptap-api.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/core/rich-text-essentials.tiptap-api.ts new file mode 100644 index 000000000000..bee52bf76dfc --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/core/rich-text-essentials.tiptap-api.ts @@ -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, + ]; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/manifests.ts index 07bd7e0b1622..9e2e9fd7cab1 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/manifests.ts @@ -15,6 +15,19 @@ const kinds: Array = [ ]; const coreExtensions: Array = [ + { + 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', diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/tiptap.extension.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/tiptap.extension.ts index 351325ec37da..3c5ea41df424 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/tiptap.extension.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/tiptap.extension.ts @@ -11,6 +11,7 @@ export interface MetaTiptapExtension { icon: string; label: string; group: string; + description?: string; } declare global { diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap/components/property-editor-ui-tiptap-extensions-configuration.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap/components/property-editor-ui-tiptap-extensions-configuration.element.ts index 40fabc53e45d..5f9756f9af1c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap/components/property-editor-ui-tiptap-extensions-configuration.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap/components/property-editor-ui-tiptap-extensions-configuration.element.ts @@ -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; } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap/property-editor-ui-tiptap.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap/property-editor-ui-tiptap.element.ts index 2613164ea77d..cff1839022bc 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap/property-editor-ui-tiptap.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap/property-editor-ui-tiptap.element.ts @@ -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; } }