From 2766d52803e15db0332144b2f7f0611be5817128 Mon Sep 17 00:00:00 2001 From: leekelleher Date: Wed, 24 Jun 2026 14:50:15 +0100 Subject: [PATCH 01/18] Tiptap RTE Blocks: support Element Library (reusable content) references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `allContents` observable to `UmbBlockManagerContext` merging local + external content - Switch block observation from `context.contents` to `combineLatest([layouts, allContents])`; skips blocks whose content type hasn't resolved yet (external content arrives async) - Add `data-key` attribute (layout key) to RTE block ProseMirror nodes so duplicate library element instances are tracked independently; `data-content-key` is retained for backwards-compat - Legacy markup (no `data-key`) defaults `data-key = data-content-key` on parse, which is safe because pre-v19 data always has `layout.key === layout.contentKey` - Switch `_filterUnusedBlocks` and undo stash from content-key to layout-key tracking; external blocks skip local content stash (their content lives in `#externalContentValues`) - Rename `contentKey` params to `layoutKey` in `requestPendingDeletion`, `clearPendingDeletion`, `removeOneLayout`, `removeManyLayouts`, and `delete` — semantically safe for legacy data - `umb-rte-block` exposes `data-key` as the primary property and deprecates `data-content-key` / `contentKey` (removal v21) --- .../context/block-rte-entries.context.ts | 6 +- .../context/block-rte-manager.context.ts | 22 ++--- .../block/context/block-manager.context.ts | 9 ++ .../rte/components/rte-base.element.ts | 88 +++++++++-------- .../src/packages/rte/constants.ts | 5 + .../extensions/block/block.tiptap-api.ts | 99 ++++++++----------- .../block/block.tiptap-extension.ts | 45 +++++++-- .../property-editor-ui-tiptap.element.ts | 20 ++-- 8 files changed, 168 insertions(+), 126 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/context/block-rte-entries.context.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/context/block-rte-entries.context.ts index 8e15829ea949..36da3f46e9db 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/context/block-rte-entries.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/context/block-rte-entries.context.ts @@ -222,11 +222,11 @@ export class UmbBlockRteEntriesContext extends UmbBlockEntriesContext< * Delete a block by requesting its removal through the pending deletion mechanism. * This enables undo support by removing the HTML element first via Tiptap, * which triggers _filterUnusedBlocks to store block data before removal. - * @param {string} contentKey - The content key of the block to delete. + * @param {string} layoutKey - The layout key of the block to delete. */ - override async delete(contentKey: string) { + override async delete(layoutKey: string) { await this._retrieveManager; - this._manager?.requestPendingDeletion(contentKey); + this._manager?.requestPendingDeletion(layoutKey); } async #insertFromRtePropertyValues( diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/context/block-rte-manager.context.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/context/block-rte-manager.context.ts index 7d68605fa704..3f78a65e287b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/context/block-rte-manager.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/context/block-rte-manager.context.ts @@ -23,28 +23,28 @@ export class UmbBlockRteManagerContext< public readonly pendingDeletions = this.#pendingDeletions.asObservable(); /** - * Request a block to be deleted. This adds the contentKey to pending deletions, + * Request a block to be deleted. This adds the layout key to pending deletions, * which will be processed by the Tiptap API to remove the HTML element first, * enabling undo support. - * @param {string} contentKey - The content key of the block to delete. + * @param {string} layoutKey - The layout key of the block to delete. */ - public requestPendingDeletion(contentKey: string) { - this.#pendingDeletions.appendOne(contentKey); + public requestPendingDeletion(layoutKey: string) { + this.#pendingDeletions.appendOne(layoutKey); } /** * Clear a pending deletion after it has been processed. - * @param {string} contentKey - The content key to clear from pending deletions. + * @param {string} layoutKey - The layout key to clear from pending deletions. */ - public clearPendingDeletion(contentKey: string) { - this.#pendingDeletions.removeOne(contentKey); + public clearPendingDeletion(layoutKey: string) { + this.#pendingDeletions.removeOne(layoutKey); } - removeOneLayout(contentKey: string) { - this._layouts.removeOne(contentKey); + removeOneLayout(layoutKey: string) { + this._layouts.removeOne(layoutKey); } - removeManyLayouts(contentKeys: Array) { - this._layouts.remove(contentKeys); + removeManyLayouts(layoutKeys: Array) { + this._layouts.remove(layoutKeys); } /** diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-manager.context.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-manager.context.ts index 88795ce95da8..5fcf7b15cb94 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-manager.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-manager.context.ts @@ -84,6 +84,15 @@ export abstract class UmbBlockManagerContext< public readonly contents = this.#contents.asObservable(); readonly #externalContentValues = new UmbArrayState(>[], (x) => x.key); + + /** + * Combined observable of local block content and resolved external (library element) content. + * Use this instead of `contents` when you need to react to library elements becoming available. + */ + public readonly allContents = mergeObservables( + [this.#contents.asObservable(), this.#externalContentValues.asObservable()], + ([local, external]) => [...(local ?? []), ...(external ?? [])], + ); readonly #externalContentVariants = new UmbArrayState( < Array<{ key: string; variants: Array<{ culture: string | null; segment: string | null; state: string | null }> }> diff --git a/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts b/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts index 7be0149093ce..7c6e15e99468 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts @@ -310,54 +310,64 @@ export abstract class UmbPropertyEditorUiRteElementBase #setUnusedBlockLookups(unusedLayouts: Array) { if (unusedLayouts.length) { unusedLayouts.forEach((layout) => { - if (layout.contentKey) { - this.#unusedLayoutLookup.set(layout.contentKey, layout); - - const contentBlock = this.#managerContext.getContentOf(layout.contentKey); - if (contentBlock) { - this.#unusedContentLookup.set(layout.contentKey, contentBlock); - } else { - console.warn( - `Expected content block for '${layout.contentKey}' was not found. This may indicate a data consistency issue.`, - ); - } - - if (layout.settingsKey) { - const settingsBlock = this.#managerContext.getSettingsOf(layout.settingsKey); - if (settingsBlock) { - this.#unusedSettingsLookup.set(layout.settingsKey, settingsBlock); + if (layout.key) { + this.#unusedLayoutLookup.set(layout.key, layout); + + // External (library element) content lives in the manager's externalContentValues state and + // is never cleaned up on layout removal, so it remains available if the layout is restored + // (e.g. via RTE undo). No need to stash it here. + if (!layout.isExternalContent) { + const contentBlock = this.#managerContext.getContentOf(layout.contentKey); + if (contentBlock) { + this.#unusedContentLookup.set(layout.key, contentBlock); } else { console.warn( - `Expected settings block for '${layout.settingsKey}' was not found. This may indicate a data consistency issue.`, + `Expected content block for '${layout.contentKey}' was not found. This may indicate a data consistency issue.`, ); } + + if (layout.settingsKey) { + const settingsBlock = this.#managerContext.getSettingsOf(layout.settingsKey); + if (settingsBlock) { + this.#unusedSettingsLookup.set(layout.settingsKey, settingsBlock); + } else { + console.warn( + `Expected settings block for '${layout.settingsKey}' was not found. This may indicate a data consistency issue.`, + ); + } + } } } }); } } - #restoreUnusedBlocks(usedContentKeys: Array) { - if (usedContentKeys.length) { - usedContentKeys.forEach((contentKey) => { - if (contentKey && this.#unusedLayoutLookup.has(contentKey)) { - const layout = this.#unusedLayoutLookup.get(contentKey); + #restoreUnusedBlocks(usedLayoutKeys: Array) { + if (usedLayoutKeys.length) { + usedLayoutKeys.forEach((layoutKey) => { + if (layoutKey && this.#unusedLayoutLookup.has(layoutKey)) { + const layout = this.#unusedLayoutLookup.get(layoutKey); if (layout) { this.#managerContext.setOneLayout(layout); - this.#unusedLayoutLookup.delete(contentKey); - - const contentBlock = this.#unusedContentLookup.get(contentKey); - if (contentBlock) { - this.#managerContext.setOneContent(contentBlock); - this.#managerContext.setOneExpose(contentKey, UmbVariantId.CreateInvariant()); - this.#unusedContentLookup.delete(contentKey); - } + this.#unusedLayoutLookup.delete(layoutKey); + + // External blocks have no local content to restore — their content remains in + // the manager's externalContentValues state and will be re-synced into the editor + // DOM automatically when #updateBlocks re-fires after the layout is restored. + if (!layout.isExternalContent) { + const contentBlock = this.#unusedContentLookup.get(layoutKey); + if (contentBlock) { + this.#managerContext.setOneContent(contentBlock); + this.#managerContext.setOneExpose(layout.contentKey, UmbVariantId.CreateInvariant()); + this.#unusedContentLookup.delete(layoutKey); + } - if (layout.settingsKey && this.#unusedSettingsLookup.has(layout.settingsKey)) { - const settingsBlock = this.#unusedSettingsLookup.get(layout.settingsKey); - if (settingsBlock) { - this.#managerContext.setOneSettings(settingsBlock); - this.#unusedSettingsLookup.delete(layout.settingsKey); + if (layout.settingsKey && this.#unusedSettingsLookup.has(layout.settingsKey)) { + const settingsBlock = this.#unusedSettingsLookup.get(layout.settingsKey); + if (settingsBlock) { + this.#managerContext.setOneSettings(settingsBlock); + this.#unusedSettingsLookup.delete(layout.settingsKey); + } } } } @@ -366,11 +376,11 @@ export abstract class UmbPropertyEditorUiRteElementBase } } - protected _filterUnusedBlocks(usedContentKeys: (string | null)[]) { - const unusedLayouts = this.#managerContext.getLayouts().filter((x) => usedContentKeys.indexOf(x.contentKey) === -1); + protected _filterUnusedBlocks(usedLayoutKeys: (string | null)[]) { + const unusedLayouts = this.#managerContext.getLayouts().filter((x) => usedLayoutKeys.indexOf(x.key) === -1); // Temporarily set the unused layouts to the lookup, as they could be restored later, e.g. via an RTE undo action. [LK] - this.#restoreUnusedBlocks(usedContentKeys); + this.#restoreUnusedBlocks(usedLayoutKeys); this.#setUnusedBlockLookups(unusedLayouts); const unusedContentKeys = unusedLayouts.map((x) => x.contentKey); @@ -381,7 +391,7 @@ export abstract class UmbPropertyEditorUiRteElementBase this.#managerContext.removeManyContent(unusedContentKeys); this.#managerContext.removeManySettings(unusedSettingsKeys); - this.#managerContext.removeManyLayouts(unusedContentKeys); + this.#managerContext.removeManyLayouts(unusedLayouts.map((x) => x.key)); } protected _fireChangeEvent() { diff --git a/src/Umbraco.Web.UI.Client/src/packages/rte/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/rte/constants.ts index e361c47d187b..fd26921ec990 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/rte/constants.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/rte/constants.ts @@ -4,3 +4,8 @@ export const UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS = 'Umbraco.RichText'; * The attribute where the block content key is stored. */ export const UMB_BLOCK_RTE_DATA_CONTENT_KEY = 'data-content-key'; + +/** + * The attribute where the block layout key is stored. + */ +export const UMB_BLOCK_RTE_DATA_LAYOUT_KEY = 'data-key'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/block.tiptap-api.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/block.tiptap-api.ts index f70e36e564f2..09e73698c828 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/block.tiptap-api.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/block.tiptap-api.ts @@ -1,14 +1,12 @@ import { UmbTiptapExtensionApiBase } from '../tiptap-extension-api-base.js'; import { umbRteBlock, umbRteBlockInline } from './block.tiptap-extension.js'; -import { debounceTime } from '@umbraco-cms/backoffice/external/rxjs'; -import { UMB_BLOCK_RTE_DATA_CONTENT_KEY } from '@umbraco-cms/backoffice/rte'; +import { combineLatest } from '@umbraco-cms/backoffice/external/rxjs'; +import { UMB_BLOCK_RTE_DATA_LAYOUT_KEY } from '@umbraco-cms/backoffice/rte'; import { UMB_BLOCK_RTE_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/block-rte'; -import type { UmbBlockDataModel } from '@umbraco-cms/backoffice/block'; -import type { UmbBlockRteTypeModel } from '@umbraco-cms/backoffice/block-rte'; +import type { UmbBlockRteLayoutModel } from '@umbraco-cms/backoffice/block-rte'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; export default class UmbTiptapBlockElementApi extends UmbTiptapExtensionApiBase { - #blockTypes?: Map; #managerContext?: typeof UMB_BLOCK_RTE_MANAGER_CONTEXT.TYPE; constructor(host: UmbControllerHost) { @@ -20,21 +18,11 @@ export default class UmbTiptapBlockElementApi extends UmbTiptapExtensionApiBase this.#managerContext = context; this.observe( - context.blockTypes, - (blockTypes) => { - this.#blockTypes = new Map( - blockTypes?.map((x) => [x.contentElementTypeKey, x] as [string, UmbBlockRteTypeModel]), - ); + combineLatest([context.layouts, context.allContents]), + ([layouts]) => { + this.#updateBlocks(layouts); }, - '_observeBlockTypes', - ); - - this.observe( - context.contents.pipe(debounceTime(20)), - (contents) => { - this.#updateBlocks(contents); - }, - '_observeContents', + '_observeBlocks', ); this.observe( @@ -52,69 +40,66 @@ export default class UmbTiptapBlockElementApi extends UmbTiptapExtensionApiBase } /** - * Process pending deletions by removing blocks from the editor. - * This is triggered when blocks are deleted via the delete button. - * Removing HTML first enables undo support via _filterUnusedBlocks. - * @param {Array} pendingDeletions - Array of content keys to delete. + * Sync blocks from manager layouts to the editor. + * Observes both layouts (structure) and allContents (data availability) so that + * library element blocks are inserted once their external content has been fetched. + * Skips layout entries whose content type is not yet resolved (deferred until allContents re-emits). + * @param {Array} layouts - Current layout entries. */ - #processPendingDeletions(pendingDeletions: Array) { - if (pendingDeletions.length === 0) return; + #updateBlocks(layouts: Array) { const editor = this._editor; if (!editor) return; - // Remove blocks from editor - this.#removeBlocksFromEditor(pendingDeletions); + const existingLayoutKeys = Array.from(editor.view.dom.querySelectorAll('umb-rte-block, umb-rte-block-inline')).map( + (x) => x.getAttribute(UMB_BLOCK_RTE_DATA_LAYOUT_KEY), + ); - // Clear processed pending deletions - pendingDeletions.forEach((contentKey) => { - this.#managerContext?.clearPendingDeletion(contentKey); + const newLayouts = layouts.filter((x) => !existingLayoutKeys.includes(x.key)); + + newLayouts.forEach((layout) => { + const contentTypeKey = this.#managerContext?.getContentTypeKeyOfContentKey(layout.contentKey); + if (!contentTypeKey) return; // External content not yet fetched — will retry when allContents emits. + + const inline = this.#managerContext?.getBlockTypeOf(contentTypeKey)?.displayInline ?? false; + if (inline) { + editor.commands.setBlockInline({ layoutKey: layout.key, contentKey: layout.contentKey }); + } else { + editor.commands.setBlock({ layoutKey: layout.key, contentKey: layout.contentKey }); + } }); } /** - * Sync blocks from manager contents to the editor. - * This adds blocks that exist in contents but not in the editor. - * Note: Removal is handled by _filterUnusedBlocks in the RTE base element, - * which enables undo support by storing block data before removal. - * @param {Array} contents - Array of block data models. + * Process pending deletions by removing blocks from the editor. + * This is triggered when blocks are deleted via the delete button. + * Removing HTML first enables undo support via _filterUnusedBlocks. + * @param {Array} pendingDeletions - Array of layout keys to delete. */ - #updateBlocks(contents?: Array) { - if (!contents?.length) return; + #processPendingDeletions(pendingDeletions: Array) { + if (pendingDeletions.length === 0) return; const editor = this._editor; if (!editor) return; - // Find existing blocks in the editor - const existingBlocks = Array.from(editor.view.dom.querySelectorAll('umb-rte-block, umb-rte-block-inline')).map( - (x) => x.getAttribute(UMB_BLOCK_RTE_DATA_CONTENT_KEY), - ); - - // ADD blocks that are in contents but NOT in editor - const newBlocks = contents.filter((x) => !existingBlocks.includes(x.key)); + this.#removeBlocksFromEditor(pendingDeletions); - newBlocks.forEach((block) => { - const inline = this.#blockTypes?.get(block.contentTypeKey)?.displayInline ?? false; - if (inline) { - editor.commands.setBlockInline({ contentKey: block.key }); - } else { - editor.commands.setBlock({ contentKey: block.key }); - } + pendingDeletions.forEach((layoutKey) => { + this.#managerContext?.clearPendingDeletion(layoutKey); }); } /** - * Remove blocks from the editor by content keys. - * @param {Array} contentKeys - Array of content keys to remove. + * Remove blocks from the editor by layout keys. + * @param {Array} layoutKeys - Array of layout keys to remove. */ - #removeBlocksFromEditor(contentKeys: Array) { + #removeBlocksFromEditor(layoutKeys: Array) { const editor = this._editor; if (!editor) return; - // Collect positions to delete const nodesToDelete: Array<{ pos: number; size: number }> = []; editor.state.doc.descendants((node, pos) => { - const contentKey = node.attrs[UMB_BLOCK_RTE_DATA_CONTENT_KEY]; - if (contentKey && contentKeys.includes(contentKey)) { + const layoutKey = node.attrs[UMB_BLOCK_RTE_DATA_LAYOUT_KEY]; + if (layoutKey && layoutKeys.includes(layoutKey)) { nodesToDelete.push({ pos, size: node.nodeSize }); } return true; // Continue traversal to find all matches diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/block.tiptap-extension.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/block.tiptap-extension.ts index 3e6b66462fa5..01b5bdb7e26c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/block.tiptap-extension.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/block.tiptap-extension.ts @@ -1,13 +1,13 @@ import { Node } from '../../externals.js'; -import { UMB_BLOCK_RTE_DATA_CONTENT_KEY } from '@umbraco-cms/backoffice/rte'; +import { UMB_BLOCK_RTE_DATA_CONTENT_KEY, UMB_BLOCK_RTE_DATA_LAYOUT_KEY } from '@umbraco-cms/backoffice/rte'; declare module '@tiptap/core' { interface Commands { umbRteBlock: { - setBlock: (options: { contentKey: string }) => ReturnType; + setBlock: (options: { layoutKey: string; contentKey: string }) => ReturnType; }; umbRteBlockInline: { - setBlockInline: (options: { contentKey: string }) => ReturnType; + setBlockInline: (options: { layoutKey: string; contentKey: string }) => ReturnType; }; } } @@ -23,6 +23,9 @@ export const umbRteBlock = Node.create({ addAttributes() { return { + [UMB_BLOCK_RTE_DATA_LAYOUT_KEY]: { + isRequired: true, + }, [UMB_BLOCK_RTE_DATA_CONTENT_KEY]: { isRequired: true, }, @@ -30,7 +33,19 @@ export const umbRteBlock = Node.create({ }, parseHTML() { - return [{ tag: `umb-rte-block[${UMB_BLOCK_RTE_DATA_CONTENT_KEY}]` }]; + return [ + { + tag: `umb-rte-block[${UMB_BLOCK_RTE_DATA_CONTENT_KEY}]`, + getAttrs: (node) => { + const el = node as HTMLElement; + const contentKey = el.getAttribute(UMB_BLOCK_RTE_DATA_CONTENT_KEY); + // Migrate legacy markup that has no data-key: use content key as layout key. + // Safe because setLayouts() coerces layout.key ??= layout.contentKey for persisted data. + const layoutKey = el.getAttribute(UMB_BLOCK_RTE_DATA_LAYOUT_KEY) ?? contentKey; + return { [UMB_BLOCK_RTE_DATA_LAYOUT_KEY]: layoutKey, [UMB_BLOCK_RTE_DATA_CONTENT_KEY]: contentKey }; + }, + }, + ]; }, renderHTML({ HTMLAttributes }) { @@ -42,7 +57,10 @@ export const umbRteBlock = Node.create({ setBlock: (options) => ({ commands }) => { - const attrs = { [UMB_BLOCK_RTE_DATA_CONTENT_KEY]: options.contentKey }; + const attrs = { + [UMB_BLOCK_RTE_DATA_LAYOUT_KEY]: options.layoutKey, + [UMB_BLOCK_RTE_DATA_CONTENT_KEY]: options.contentKey, + }; return commands.insertContent({ type: this.name, attrs, @@ -58,7 +76,17 @@ export const umbRteBlockInline = umbRteBlock.extend({ inline: true, parseHTML() { - return [{ tag: `umb-rte-block-inline[${UMB_BLOCK_RTE_DATA_CONTENT_KEY}]` }]; + return [ + { + tag: `umb-rte-block-inline[${UMB_BLOCK_RTE_DATA_CONTENT_KEY}]`, + getAttrs: (node) => { + const el = node as HTMLElement; + const contentKey = el.getAttribute(UMB_BLOCK_RTE_DATA_CONTENT_KEY); + const layoutKey = el.getAttribute(UMB_BLOCK_RTE_DATA_LAYOUT_KEY) ?? contentKey; + return { [UMB_BLOCK_RTE_DATA_LAYOUT_KEY]: layoutKey, [UMB_BLOCK_RTE_DATA_CONTENT_KEY]: contentKey }; + }, + }, + ]; }, renderHTML({ HTMLAttributes }) { @@ -70,7 +98,10 @@ export const umbRteBlockInline = umbRteBlock.extend({ setBlockInline: (options) => ({ commands }) => { - const attrs = { [UMB_BLOCK_RTE_DATA_CONTENT_KEY]: options.contentKey }; + const attrs = { + [UMB_BLOCK_RTE_DATA_LAYOUT_KEY]: options.layoutKey, + [UMB_BLOCK_RTE_DATA_CONTENT_KEY]: options.contentKey, + }; return commands.insertContent({ type: this.name, attrs, diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap-rte/property-editor-ui-tiptap.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap-rte/property-editor-ui-tiptap.element.ts index 2ac8f55a88ca..7b3febe48a5c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap-rte/property-editor-ui-tiptap.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap-rte/property-editor-ui-tiptap.element.ts @@ -21,16 +21,18 @@ export class UmbPropertyEditorUiTiptapElement extends UmbPropertyEditorUiRteElem const markup = tipTapElement.value; // Remove unused Blocks of Blocks Layout. Leaving only the Blocks that are present in Markup. - const usedContentKeys: string[] = []; + // Extract the layout key (data-key) from each block element to uniquely identify layout entries. + // For legacy markup without data-key, fall back to data-content-key — safe because + // setLayouts() coerces layout.key ??= layout.contentKey for persisted data without a key. + const usedLayoutKeys: string[] = []; - // Regex matching all block elements in the markup, and extracting the content key. It's the same as the one used on the backend. - const regex = new RegExp( - /(?:)?<\/umb-rte-block(?:-inline)?>/gi, - ); + const blockRegex = /]*)>/gi; let blockElement: RegExpExecArray | null; - while ((blockElement = regex.exec(markup)) !== null) { - if (blockElement.groups?.key) { - usedContentKeys.push(blockElement.groups.key); + while ((blockElement = blockRegex.exec(markup)) !== null) { + const tag = blockElement[0]; + const layoutKey = tag.match(/ data-key="([^"]+)"/)?.[1] ?? tag.match(/ data-content-key="([^"]+)"/)?.[1]; + if (layoutKey) { + usedLayoutKeys.push(layoutKey); } } @@ -52,7 +54,7 @@ export class UmbPropertyEditorUiTiptapElement extends UmbPropertyEditorUiRteElem } // lets run this one after we set the value, to make sure we don't reset the value. - this._filterUnusedBlocks(usedContentKeys); + this._filterUnusedBlocks(usedLayoutKeys); this._fireChangeEvent(); } From 9b4c617804abc0e200347b46afc91e51e78a3200 Mon Sep 17 00:00:00 2001 From: leekelleher Date: Wed, 24 Jun 2026 14:57:44 +0100 Subject: [PATCH 02/18] Tiptap RTE Blocks: wire up is-reference attribute for Element Library entries --- .../block-rte-entry/block-rte-entry.element.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts index 37c15a4418e5..d24d4867a5fa 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts @@ -100,6 +100,10 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert config: { showContentEdit: false, showSettingsEdit: false }, }; // Set to undefined cause it will be set before we render. + // 'is-reference' attribute is used for styling purpose. + @property({ type: Boolean, attribute: 'is-reference', reflect: true }) + private _isExternalContent = false; + // 'content-invalid' attribute is used for styling purpose. @property({ type: Boolean, attribute: 'content-invalid', reflect: true }) // eslint-disable-next-line @typescript-eslint/naming-convention @@ -183,6 +187,14 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert null, ); + this.observe( + this.#context.isExternalContent, + (isExternalContent) => { + this._isExternalContent = isExternalContent ?? false; + }, + null, + ); + this.observe(this.#context.actionsVisibility, (showActions) => (this._showActions = showActions), null); // Data props: @@ -350,6 +362,7 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert :host { position: relative; display: block; + margin-top: 10px; user-select: all; user-drag: auto; white-space: nowrap; From ab054cb23ab6265647d46081f8693ebccdd6f66d Mon Sep 17 00:00:00 2001 From: leekelleher Date: Wed, 24 Jun 2026 15:04:30 +0100 Subject: [PATCH 03/18] Tiptap RTE Blocks: fix unpublished state for Element Library references --- .../block-rte-entry.element.ts | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts index d24d4867a5fa..ba0e608713ed 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts @@ -3,6 +3,7 @@ import { UMB_BLOCK_RTE } from '../../constants.js'; import { UmbBlockRteEntryContext } from '../../context/block-rte-entry.context.js'; import { css, customElement, html, nothing, property, state } from '@umbraco-cms/backoffice/external/lit'; import { stringOrStringArrayContains, UmbDeprecation } from '@umbraco-cms/backoffice/utils'; +import { UmbElementVariantState } from '@umbraco-cms/backoffice/element'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbDataPathBlockElementDataQuery } from '@umbraco-cms/backoffice/block'; import { UmbObserveValidationStateController } from '@umbraco-cms/backoffice/validation'; @@ -82,6 +83,11 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert @state() private _exposed?: boolean; + private _localExpose?: boolean; + + @state() + private _externalContentVariantState: string | null | undefined; + @state() private _showActions?: boolean; @@ -114,6 +120,16 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert // eslint-disable-next-line @typescript-eslint/naming-convention _settingsInvalid?: boolean; + #updateExposedState() { + // External content blocks use the element's variant state; local blocks use the expose entry + const isExposed = this._isExternalContent + ? this._externalContentVariantState === UmbElementVariantState.PUBLISHED || + this._externalContentVariantState === UmbElementVariantState.PUBLISHED_PENDING_CHANGES + : this._localExpose; + this.#updateBlockViewProps({ unpublished: !isExposed }); + this._exposed = isExposed; + } + #updateBlockViewProps(incoming: Partial>) { this._blockViewProps = { ...this._blockViewProps, ...incoming }; this.requestUpdate('_blockViewProps'); @@ -181,8 +197,8 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert this.observe( this.#context.hasExpose, (exposed) => { - this.#updateBlockViewProps({ unpublished: !exposed }); - this._exposed = exposed; + this._localExpose = exposed; + this.#updateExposedState(); }, null, ); @@ -191,6 +207,16 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert this.#context.isExternalContent, (isExternalContent) => { this._isExternalContent = isExternalContent ?? false; + this.#updateExposedState(); + }, + null, + ); + + this.observe( + this.#context.externalContentVariantState, + (state) => { + this._externalContentVariantState = state; + this.#updateExposedState(); }, null, ); From 1e6900951e3f648d0b6def801e7d9eda6fa75461 Mon Sep 17 00:00:00 2001 From: leekelleher Date: Wed, 24 Jun 2026 15:37:01 +0100 Subject: [PATCH 04/18] Tiptap RTE Blocks: address PR review feedback - Remove accidental margin-top regression on all RTE block entries - Fix settings not stashed/restored for external blocks on delete+undo - Reduce deep nesting in #restoreUnusedBlocks / #setUnusedBlockLookups - De-duplicate getAttrs and insert-attrs logic in block.tiptap-extension.ts - Clarify JSDoc on allContents and _filterUnusedBlocks parameter semantics --- .../block-rte-entry.element.ts | 1 - .../block/context/block-manager.context.ts | 2 +- .../rte/components/rte-base.element.ts | 116 +++++++++--------- .../block/block.tiptap-extension.ts | 66 ++++------ 4 files changed, 82 insertions(+), 103 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts index ba0e608713ed..483085dfe300 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts @@ -388,7 +388,6 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert :host { position: relative; display: block; - margin-top: 10px; user-select: all; user-drag: auto; white-space: nowrap; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-manager.context.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-manager.context.ts index 5fcf7b15cb94..c2c4a7019cc2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-manager.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-manager.context.ts @@ -87,7 +87,7 @@ export abstract class UmbBlockManagerContext< /** * Combined observable of local block content and resolved external (library element) content. - * Use this instead of `contents` when you need to react to library elements becoming available. + * Use this alongside `contents` when you also need to react to library elements becoming available. */ public readonly allContents = mergeObservables( [this.#contents.asObservable(), this.#externalContentValues.asObservable()], diff --git a/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts b/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts index 7c6e15e99468..e588fb45aeec 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts @@ -308,74 +308,76 @@ export abstract class UmbPropertyEditorUiRteElementBase } #setUnusedBlockLookups(unusedLayouts: Array) { - if (unusedLayouts.length) { - unusedLayouts.forEach((layout) => { - if (layout.key) { - this.#unusedLayoutLookup.set(layout.key, layout); - - // External (library element) content lives in the manager's externalContentValues state and - // is never cleaned up on layout removal, so it remains available if the layout is restored - // (e.g. via RTE undo). No need to stash it here. - if (!layout.isExternalContent) { - const contentBlock = this.#managerContext.getContentOf(layout.contentKey); - if (contentBlock) { - this.#unusedContentLookup.set(layout.key, contentBlock); - } else { - console.warn( - `Expected content block for '${layout.contentKey}' was not found. This may indicate a data consistency issue.`, - ); - } + for (const layout of unusedLayouts) { + if (!layout.key) continue; + + this.#unusedLayoutLookup.set(layout.key, layout); + + // External (library element) content lives in the manager's externalContentValues state and + // is never cleaned up on layout removal, so it remains available if the layout is restored + // (e.g. via RTE undo). No need to stash it here. + if (!layout.isExternalContent) { + const contentBlock = this.#managerContext.getContentOf(layout.contentKey); + if (contentBlock) { + this.#unusedContentLookup.set(layout.key, contentBlock); + } else { + console.warn( + `Expected content block for '${layout.contentKey}' was not found. This may indicate a data consistency issue.`, + ); + } + } - if (layout.settingsKey) { - const settingsBlock = this.#managerContext.getSettingsOf(layout.settingsKey); - if (settingsBlock) { - this.#unusedSettingsLookup.set(layout.settingsKey, settingsBlock); - } else { - console.warn( - `Expected settings block for '${layout.settingsKey}' was not found. This may indicate a data consistency issue.`, - ); - } - } - } + // Settings can exist for both local and external blocks; always stash them. + if (layout.settingsKey) { + const settingsBlock = this.#managerContext.getSettingsOf(layout.settingsKey); + if (settingsBlock) { + this.#unusedSettingsLookup.set(layout.settingsKey, settingsBlock); + } else { + console.warn( + `Expected settings block for '${layout.settingsKey}' was not found. This may indicate a data consistency issue.`, + ); } - }); + } } } #restoreUnusedBlocks(usedLayoutKeys: Array) { - if (usedLayoutKeys.length) { - usedLayoutKeys.forEach((layoutKey) => { - if (layoutKey && this.#unusedLayoutLookup.has(layoutKey)) { - const layout = this.#unusedLayoutLookup.get(layoutKey); - if (layout) { - this.#managerContext.setOneLayout(layout); - this.#unusedLayoutLookup.delete(layoutKey); - - // External blocks have no local content to restore — their content remains in - // the manager's externalContentValues state and will be re-synced into the editor - // DOM automatically when #updateBlocks re-fires after the layout is restored. - if (!layout.isExternalContent) { - const contentBlock = this.#unusedContentLookup.get(layoutKey); - if (contentBlock) { - this.#managerContext.setOneContent(contentBlock); - this.#managerContext.setOneExpose(layout.contentKey, UmbVariantId.CreateInvariant()); - this.#unusedContentLookup.delete(layoutKey); - } + for (const layoutKey of usedLayoutKeys) { + if (!layoutKey || !this.#unusedLayoutLookup.has(layoutKey)) continue; + + const layout = this.#unusedLayoutLookup.get(layoutKey); + if (!layout) continue; + + this.#managerContext.setOneLayout(layout); + this.#unusedLayoutLookup.delete(layoutKey); + + // External blocks have no local content to restore — their content remains in + // the manager's externalContentValues state and will be re-synced into the editor + // DOM automatically when #updateBlocks re-fires after the layout is restored. + if (!layout.isExternalContent) { + const contentBlock = this.#unusedContentLookup.get(layoutKey); + if (contentBlock) { + this.#managerContext.setOneContent(contentBlock); + this.#managerContext.setOneExpose(layout.contentKey, UmbVariantId.CreateInvariant()); + this.#unusedContentLookup.delete(layoutKey); + } + } - if (layout.settingsKey && this.#unusedSettingsLookup.has(layout.settingsKey)) { - const settingsBlock = this.#unusedSettingsLookup.get(layout.settingsKey); - if (settingsBlock) { - this.#managerContext.setOneSettings(settingsBlock); - this.#unusedSettingsLookup.delete(layout.settingsKey); - } - } - } - } + // Settings can exist for both local and external blocks; always restore them. + if (layout.settingsKey && this.#unusedSettingsLookup.has(layout.settingsKey)) { + const settingsBlock = this.#unusedSettingsLookup.get(layout.settingsKey); + if (settingsBlock) { + this.#managerContext.setOneSettings(settingsBlock); + this.#unusedSettingsLookup.delete(layout.settingsKey); } - }); + } } } + /** + * @param {(string | null)[]} usedLayoutKeys - Layout keys (not content keys) currently present in the editor markup. + * @since 19.0.0 — parameter semantics changed from content keys to layout keys. + */ protected _filterUnusedBlocks(usedLayoutKeys: (string | null)[]) { const unusedLayouts = this.#managerContext.getLayouts().filter((x) => usedLayoutKeys.indexOf(x.key) === -1); diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/block.tiptap-extension.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/block.tiptap-extension.ts index 01b5bdb7e26c..86327c6bad8c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/block.tiptap-extension.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/block.tiptap-extension.ts @@ -12,6 +12,22 @@ declare module '@tiptap/core' { } } +function getBlockAttrs(node: Element) { + const el = node as HTMLElement; + const contentKey = el.getAttribute(UMB_BLOCK_RTE_DATA_CONTENT_KEY); + // Migrate legacy markup that has no data-key: use content key as layout key. + // Safe because setLayouts() coerces layout.key ??= layout.contentKey for persisted data. + const layoutKey = el.getAttribute(UMB_BLOCK_RTE_DATA_LAYOUT_KEY) ?? contentKey; + return { [UMB_BLOCK_RTE_DATA_LAYOUT_KEY]: layoutKey, [UMB_BLOCK_RTE_DATA_CONTENT_KEY]: contentKey }; +} + +function buildInsertAttrs(options: { layoutKey: string; contentKey: string }) { + return { + [UMB_BLOCK_RTE_DATA_LAYOUT_KEY]: options.layoutKey, + [UMB_BLOCK_RTE_DATA_CONTENT_KEY]: options.contentKey, + }; +} + export const umbRteBlock = Node.create({ name: 'umbRteBlock', group: 'block', @@ -33,19 +49,7 @@ export const umbRteBlock = Node.create({ }, parseHTML() { - return [ - { - tag: `umb-rte-block[${UMB_BLOCK_RTE_DATA_CONTENT_KEY}]`, - getAttrs: (node) => { - const el = node as HTMLElement; - const contentKey = el.getAttribute(UMB_BLOCK_RTE_DATA_CONTENT_KEY); - // Migrate legacy markup that has no data-key: use content key as layout key. - // Safe because setLayouts() coerces layout.key ??= layout.contentKey for persisted data. - const layoutKey = el.getAttribute(UMB_BLOCK_RTE_DATA_LAYOUT_KEY) ?? contentKey; - return { [UMB_BLOCK_RTE_DATA_LAYOUT_KEY]: layoutKey, [UMB_BLOCK_RTE_DATA_CONTENT_KEY]: contentKey }; - }, - }, - ]; + return [{ tag: `umb-rte-block[${UMB_BLOCK_RTE_DATA_CONTENT_KEY}]`, getAttrs: getBlockAttrs }]; }, renderHTML({ HTMLAttributes }) { @@ -56,16 +60,8 @@ export const umbRteBlock = Node.create({ return { setBlock: (options) => - ({ commands }) => { - const attrs = { - [UMB_BLOCK_RTE_DATA_LAYOUT_KEY]: options.layoutKey, - [UMB_BLOCK_RTE_DATA_CONTENT_KEY]: options.contentKey, - }; - return commands.insertContent({ - type: this.name, - attrs, - }); - }, + ({ commands }) => + commands.insertContent({ type: this.name, attrs: buildInsertAttrs(options) }), }; }, }); @@ -76,17 +72,7 @@ export const umbRteBlockInline = umbRteBlock.extend({ inline: true, parseHTML() { - return [ - { - tag: `umb-rte-block-inline[${UMB_BLOCK_RTE_DATA_CONTENT_KEY}]`, - getAttrs: (node) => { - const el = node as HTMLElement; - const contentKey = el.getAttribute(UMB_BLOCK_RTE_DATA_CONTENT_KEY); - const layoutKey = el.getAttribute(UMB_BLOCK_RTE_DATA_LAYOUT_KEY) ?? contentKey; - return { [UMB_BLOCK_RTE_DATA_LAYOUT_KEY]: layoutKey, [UMB_BLOCK_RTE_DATA_CONTENT_KEY]: contentKey }; - }, - }, - ]; + return [{ tag: `umb-rte-block-inline[${UMB_BLOCK_RTE_DATA_CONTENT_KEY}]`, getAttrs: getBlockAttrs }]; }, renderHTML({ HTMLAttributes }) { @@ -97,16 +83,8 @@ export const umbRteBlockInline = umbRteBlock.extend({ return { setBlockInline: (options) => - ({ commands }) => { - const attrs = { - [UMB_BLOCK_RTE_DATA_LAYOUT_KEY]: options.layoutKey, - [UMB_BLOCK_RTE_DATA_CONTENT_KEY]: options.contentKey, - }; - return commands.insertContent({ - type: this.name, - attrs, - }); - }, + ({ commands }) => + commands.insertContent({ type: this.name, attrs: buildInsertAttrs(options) }), }; }, }); From 007d6cdf43c08ad2356733b939f4885d1b19ca32 Mon Sep 17 00:00:00 2001 From: leekelleher Date: Wed, 24 Jun 2026 15:38:31 +0100 Subject: [PATCH 05/18] Tiptap RTE Blocks: restore margin-top on RTE block entries The umb-entity-frame label tab clips when a block is at the top of the RTE without this spacing. --- .../components/block-rte-entry/block-rte-entry.element.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts index 483085dfe300..fc3c3e630747 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts @@ -388,6 +388,7 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert :host { position: relative; display: block; + margin-top: var(--uui-size-3); user-select: all; user-drag: auto; white-space: nowrap; From 3f388fcab230998b7cfd5b854dbdf6ac1d6cc533 Mon Sep 17 00:00:00 2001 From: leekelleher Date: Tue, 7 Jul 2026 08:36:06 +0100 Subject: [PATCH 06/18] RTE: address SonarCloud code smells on PR #23195 Reduce cognitive complexity of #restoreUnusedBlocks by extracting content/settings restoration into helper methods, use .includes()/Set.has() over indexOf()/Array.includes(), and use RegExp.exec() over String.match(). --- .../rte/components/rte-base.element.ts | 49 +++++++++++-------- .../extensions/block/block.tiptap-api.ts | 8 +-- .../property-editor-ui-tiptap.element.ts | 2 +- 3 files changed, 34 insertions(+), 25 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts b/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts index e588fb45aeec..fe3926b20590 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts @@ -351,35 +351,42 @@ export abstract class UmbPropertyEditorUiRteElementBase this.#managerContext.setOneLayout(layout); this.#unusedLayoutLookup.delete(layoutKey); - // External blocks have no local content to restore — their content remains in - // the manager's externalContentValues state and will be re-synced into the editor - // DOM automatically when #updateBlocks re-fires after the layout is restored. - if (!layout.isExternalContent) { - const contentBlock = this.#unusedContentLookup.get(layoutKey); - if (contentBlock) { - this.#managerContext.setOneContent(contentBlock); - this.#managerContext.setOneExpose(layout.contentKey, UmbVariantId.CreateInvariant()); - this.#unusedContentLookup.delete(layoutKey); - } - } - - // Settings can exist for both local and external blocks; always restore them. - if (layout.settingsKey && this.#unusedSettingsLookup.has(layout.settingsKey)) { - const settingsBlock = this.#unusedSettingsLookup.get(layout.settingsKey); - if (settingsBlock) { - this.#managerContext.setOneSettings(settingsBlock); - this.#unusedSettingsLookup.delete(layout.settingsKey); - } - } + this.#restoreUnusedContent(layout, layoutKey); + this.#restoreUnusedSettings(layout); } } + // External blocks have no local content to restore — their content remains in + // the manager's externalContentValues state and will be re-synced into the editor + // DOM automatically when #updateBlocks re-fires after the layout is restored. + #restoreUnusedContent(layout: UmbBlockRteLayoutModel, layoutKey: string) { + if (layout.isExternalContent) return; + + const contentBlock = this.#unusedContentLookup.get(layoutKey); + if (!contentBlock) return; + + this.#managerContext.setOneContent(contentBlock); + this.#managerContext.setOneExpose(layout.contentKey, UmbVariantId.CreateInvariant()); + this.#unusedContentLookup.delete(layoutKey); + } + + // Settings can exist for both local and external blocks; always restore them. + #restoreUnusedSettings(layout: UmbBlockRteLayoutModel) { + if (!layout.settingsKey || !this.#unusedSettingsLookup.has(layout.settingsKey)) return; + + const settingsBlock = this.#unusedSettingsLookup.get(layout.settingsKey); + if (!settingsBlock) return; + + this.#managerContext.setOneSettings(settingsBlock); + this.#unusedSettingsLookup.delete(layout.settingsKey); + } + /** * @param {(string | null)[]} usedLayoutKeys - Layout keys (not content keys) currently present in the editor markup. * @since 19.0.0 — parameter semantics changed from content keys to layout keys. */ protected _filterUnusedBlocks(usedLayoutKeys: (string | null)[]) { - const unusedLayouts = this.#managerContext.getLayouts().filter((x) => usedLayoutKeys.indexOf(x.key) === -1); + const unusedLayouts = this.#managerContext.getLayouts().filter((x) => !usedLayoutKeys.includes(x.key)); // Temporarily set the unused layouts to the lookup, as they could be restored later, e.g. via an RTE undo action. [LK] this.#restoreUnusedBlocks(usedLayoutKeys); diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/block.tiptap-api.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/block.tiptap-api.ts index 09e73698c828..a07a96b71039 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/block.tiptap-api.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/block.tiptap-api.ts @@ -50,11 +50,13 @@ export default class UmbTiptapBlockElementApi extends UmbTiptapExtensionApiBase const editor = this._editor; if (!editor) return; - const existingLayoutKeys = Array.from(editor.view.dom.querySelectorAll('umb-rte-block, umb-rte-block-inline')).map( - (x) => x.getAttribute(UMB_BLOCK_RTE_DATA_LAYOUT_KEY), + const existingLayoutKeys = new Set( + Array.from(editor.view.dom.querySelectorAll('umb-rte-block, umb-rte-block-inline')).map((x) => + x.getAttribute(UMB_BLOCK_RTE_DATA_LAYOUT_KEY), + ), ); - const newLayouts = layouts.filter((x) => !existingLayoutKeys.includes(x.key)); + const newLayouts = layouts.filter((x) => !existingLayoutKeys.has(x.key)); newLayouts.forEach((layout) => { const contentTypeKey = this.#managerContext?.getContentTypeKeyOfContentKey(layout.contentKey); diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap-rte/property-editor-ui-tiptap.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap-rte/property-editor-ui-tiptap.element.ts index 7b3febe48a5c..53e13bacd4f6 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap-rte/property-editor-ui-tiptap.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap-rte/property-editor-ui-tiptap.element.ts @@ -30,7 +30,7 @@ export class UmbPropertyEditorUiTiptapElement extends UmbPropertyEditorUiRteElem let blockElement: RegExpExecArray | null; while ((blockElement = blockRegex.exec(markup)) !== null) { const tag = blockElement[0]; - const layoutKey = tag.match(/ data-key="([^"]+)"/)?.[1] ?? tag.match(/ data-content-key="([^"]+)"/)?.[1]; + const layoutKey = / data-key="([^"]+)"/.exec(tag)?.[1] ?? / data-content-key="([^"]+)"/.exec(tag)?.[1]; if (layoutKey) { usedLayoutKeys.push(layoutKey); } From 37498c022406609cd503189588b0d6295171e04d Mon Sep 17 00:00:00 2001 From: leekelleher Date: Fri, 7 Aug 2026 12:52:46 +0100 Subject: [PATCH 07/18] test(tiptap): add layout-key coverage for block doc-walk Adds a sibling test to the incoming v19/dev doc-walk test, asserting the walk also finds layout keys (including the legacy data-key-missing fallback), matching how #updateBlocks now discovers blocks post-merge. --- .../block/block.tiptap-extension.test.ts | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/block.tiptap-extension.test.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/block.tiptap-extension.test.ts index 791b91940efc..5d2835f5a14f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/block.tiptap-extension.test.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/block.tiptap-extension.test.ts @@ -1,5 +1,5 @@ import { Document, Editor, Paragraph, Text } from '../../externals.js'; -import { UMB_BLOCK_RTE_DATA_CONTENT_KEY } from '@umbraco-cms/backoffice/rte'; +import { UMB_BLOCK_RTE_DATA_CONTENT_KEY, UMB_BLOCK_RTE_DATA_LAYOUT_KEY } from '@umbraco-cms/backoffice/rte'; import { umbRteBlock, umbRteBlockInline } from './block.tiptap-extension.js'; import { expect } from '@open-wc/testing'; @@ -39,4 +39,24 @@ describe('block.tiptap-extension', () => { expect(foundKeys).to.deep.equal(['block-1', 'inline-1']); }); + + // #updateBlocks (block.tiptap-api.ts) now discovers existing blocks by layout key, not content key, + // since layout entries are the real identity for RTE blocks. This asserts the walk finds layout keys, + // including the legacy-markup fallback in getBlockAttrs where a missing data-key falls back to the content key. + it('finds block and inline-block layout keys by walking the document', () => { + editor.commands.setContent( + '' + + '

', + ); + + const foundKeys: Array = []; + editor.state.doc.descendants((node) => { + const layoutKey = node.attrs[UMB_BLOCK_RTE_DATA_LAYOUT_KEY]; + if (layoutKey) foundKeys.push(layoutKey); + return true; + }); + + // 'inline-1' via the legacy data-key fallback in getBlockAttrs + expect(foundKeys).to.deep.equal(['layout-1', 'inline-1']); + }); }); From fa83fe3fcee437576d6546daa1ab27319657cfdd Mon Sep 17 00:00:00 2001 From: leekelleher Date: Mon, 10 Aug 2026 13:30:56 +0100 Subject: [PATCH 08/18] refactor(block): derive isExposed on shared block entry context Block RTE, Grid, List and Single entry elements each duplicated an identical #updateExposedState() method and its three backing observers. Move the derivation onto UmbBlockEntryContext as a single mergeObservables-based isExposed observable, consumed the same way by all four elements. Fixes a SonarQube new-code duplication gate failure on block-rte-entry.element.ts. --- .../block-grid-entry.element.ts | 33 +++-------------- .../block-list-entry.element.ts | 33 +++-------------- .../block-rte-entry.element.ts | 35 +++---------------- .../block-single-entry.element.ts | 33 +++-------------- .../block/context/block-entry.context.ts | 16 ++++++++- 5 files changed, 32 insertions(+), 118 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entry/block-grid-entry.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entry/block-grid-entry.element.ts index 395fd84f29f3..34ac8d04c9e4 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entry/block-grid-entry.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entry/block-grid-entry.element.ts @@ -6,7 +6,6 @@ import { stringOrStringArrayContains, UmbDeprecation } from '@umbraco-cms/backof import { UmbDataPathBlockElementDataQuery } from '@umbraco-cms/backoffice/block'; import { renderHiddenUfm } from '@umbraco-cms/backoffice/ufm'; import { umbDestroyOnDisconnect, UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; -import { UmbElementVariantState } from '@umbraco-cms/backoffice/element'; import { UmbObserveValidationStateController } from '@umbraco-cms/backoffice/validation'; import { UUIBlinkAnimationValue, UUIBlinkKeyframes } from '@umbraco-cms/backoffice/external/uui'; import type { PropertyValueMap } from '@umbraco-cms/backoffice/external/lit'; @@ -137,8 +136,6 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper @state() private _exposed?: boolean; - private _localExpose?: boolean; - // Unsupported is triggered if the Block Type is not recognized, it can also be triggered by the Content Element Type not existing any longer. [NL] @state() private _unsupported?: boolean; @@ -191,9 +188,6 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper @property({ type: Boolean, attribute: 'is-reference', reflect: true }) private _isExternalContent = false; - @state() - private _externalContentVariantState: string | null | undefined; - @state() private _isReadOnly = false; @@ -246,10 +240,10 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper null, ); this.observe( - this.#context.hasExpose, - (exposed) => { - this._localExpose = exposed; - this.#updateExposedState(); + this.#context.isExposed, + (isExposed) => { + this.#updateBlockViewProps({ unpublished: !isExposed }); + this._exposed = isExposed; }, null, ); @@ -271,15 +265,6 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper this.#context.isExternalContent, (isExternalContent) => { this._isExternalContent = isExternalContent; - this.#updateExposedState(); - }, - null, - ); - this.observe( - this.#context.externalContentVariantState, - (state) => { - this._externalContentVariantState = state; - this.#updateExposedState(); }, null, ); @@ -451,16 +436,6 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper this.#context.expose(); }; - #updateExposedState() { - // External content blocks use the element's variant state; local blocks use the expose entry - const isExposed = this._isExternalContent - ? this._externalContentVariantState === UmbElementVariantState.PUBLISHED || - this._externalContentVariantState === UmbElementVariantState.PUBLISHED_PENDING_CHANGES - : this._localExpose; - this.#updateBlockViewProps({ unpublished: !isExposed }); - this._exposed = isExposed; - } - #onUfmResolved = (event: UmbUfmResolvedEvent) => { this.#context.setName(event.detail.text); }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/components/block-list-entry/block-list-entry.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/components/block-list-entry/block-list-entry.element.ts index eaec8a5a025c..0761a943b12b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/components/block-list-entry/block-list-entry.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/components/block-list-entry/block-list-entry.element.ts @@ -6,7 +6,6 @@ import { UmbLitElement, umbDestroyOnDisconnect } from '@umbraco-cms/backoffice/l import { stringOrStringArrayContains, UmbDeprecation } from '@umbraco-cms/backoffice/utils'; import { UmbDataPathBlockElementDataQuery } from '@umbraco-cms/backoffice/block'; import { renderHiddenUfm } from '@umbraco-cms/backoffice/ufm'; -import { UmbElementVariantState } from '@umbraco-cms/backoffice/element'; import { UmbObserveValidationStateController } from '@umbraco-cms/backoffice/validation'; import { UUIBlinkAnimationValue, UUIBlinkKeyframes } from '@umbraco-cms/backoffice/external/uui'; import type { @@ -120,8 +119,6 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper @state() private _exposed?: boolean; - private _localExpose?: boolean; - @state() private _unsupported?: boolean; @@ -154,9 +151,6 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper @property({ type: Boolean, attribute: 'is-reference', reflect: true }) private _isExternalContent = false; - @state() - private _externalContentVariantState: string | null | undefined; - @state() private _isReadOnly = false; @@ -206,10 +200,10 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper null, ); this.observe( - this.#context.hasExpose, - (exposed) => { - this._localExpose = exposed; - this.#updateExposedState(); + this.#context.isExposed, + (isExposed) => { + this.#updateBlockViewProps({ unpublished: !isExposed }); + this._exposed = isExposed; }, null, ); @@ -231,15 +225,6 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper this.#context.isExternalContent, (isExternalContent) => { this._isExternalContent = isExternalContent; - this.#updateExposedState(); - }, - null, - ); - this.observe( - this.#context.externalContentVariantState, - (state) => { - this._externalContentVariantState = state; - this.#updateExposedState(); }, null, ); @@ -358,16 +343,6 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper this.#context.setName(event.detail.text); }; - #updateExposedState() { - // External content blocks use the element's variant state; local blocks use the expose entry - const isExposed = this._isExternalContent - ? this._externalContentVariantState === UmbElementVariantState.PUBLISHED || - this._externalContentVariantState === UmbElementVariantState.PUBLISHED_PENDING_CHANGES - : this._localExpose; - this.#updateBlockViewProps({ unpublished: !isExposed }); - this._exposed = isExposed; - } - #extensionSlotFilterMethod = (manifest: ManifestBlockEditorCustomView) => { if (this._unsupported) { // If the block is unsupported, we should not allow any custom views to render. diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts index f945a2ae9385..16ab002d58e2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts @@ -4,7 +4,6 @@ import type { UmbBlockRteLayoutModel } from '../../types.js'; import { css, customElement, html, nothing, property, when, state } from '@umbraco-cms/backoffice/external/lit'; import { renderHiddenUfm } from '@umbraco-cms/backoffice/ufm'; import { stringOrStringArrayContains, UmbDeprecation } from '@umbraco-cms/backoffice/utils'; -import { UmbElementVariantState } from '@umbraco-cms/backoffice/element'; import { UmbDataPathBlockElementDataQuery } from '@umbraco-cms/backoffice/block'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbObserveValidationStateController } from '@umbraco-cms/backoffice/validation'; @@ -85,11 +84,6 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert @state() private _exposed?: boolean; - private _localExpose?: boolean; - - @state() - private _externalContentVariantState: string | null | undefined; - @state() private _showActions?: boolean; @@ -129,16 +123,6 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert // eslint-disable-next-line @typescript-eslint/naming-convention _settingsInvalid?: boolean; - #updateExposedState() { - // External content blocks use the element's variant state; local blocks use the expose entry - const isExposed = this._isExternalContent - ? this._externalContentVariantState === UmbElementVariantState.PUBLISHED || - this._externalContentVariantState === UmbElementVariantState.PUBLISHED_PENDING_CHANGES - : this._localExpose; - this.#updateBlockViewProps({ unpublished: !isExposed }); - this._exposed = isExposed; - } - #updateBlockViewProps(incoming: Partial>) { this._blockViewProps = { ...this._blockViewProps, ...incoming }; this.requestUpdate('_blockViewProps'); @@ -253,26 +237,17 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert null, ); this.observe( - this.#context.hasExpose, - (exposed) => { - this._localExpose = exposed; - this.#updateExposedState(); + this.#context.isExposed, + (isExposed) => { + this.#updateBlockViewProps({ unpublished: !isExposed }); + this._exposed = isExposed; }, null, ); this.observe( this.#context.isExternalContent, (isExternalContent) => { - this._isExternalContent = isExternalContent ?? false; - this.#updateExposedState(); - }, - null, - ); - this.observe( - this.#context.externalContentVariantState, - (state) => { - this._externalContentVariantState = state; - this.#updateExposedState(); + this._isExternalContent = isExternalContent; }, null, ); diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-single/components/block-single-entry/block-single-entry.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-single/components/block-single-entry/block-single-entry.element.ts index 033ec727e869..c65ebe9396c5 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-single/components/block-single-entry/block-single-entry.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-single/components/block-single-entry/block-single-entry.element.ts @@ -6,7 +6,6 @@ import { stringOrStringArrayContains, UmbDeprecation } from '@umbraco-cms/backof import { UmbLitElement, umbDestroyOnDisconnect } from '@umbraco-cms/backoffice/lit-element'; import { UmbDataPathBlockElementDataQuery } from '@umbraco-cms/backoffice/block'; import { renderHiddenUfm } from '@umbraco-cms/backoffice/ufm'; -import { UmbElementVariantState } from '@umbraco-cms/backoffice/element'; import { UmbObserveValidationStateController } from '@umbraco-cms/backoffice/validation'; import { UUIBlinkAnimationValue, UUIBlinkKeyframes } from '@umbraco-cms/backoffice/external/uui'; import type { @@ -121,8 +120,6 @@ export class UmbBlockSingleEntryElement extends UmbLitElement implements UmbProp @state() private _exposed?: boolean; - private _localExpose?: boolean; - @state() private _unsupported?: boolean; @@ -152,9 +149,6 @@ export class UmbBlockSingleEntryElement extends UmbLitElement implements UmbProp @property({ type: Boolean, attribute: 'is-reference', reflect: true }) private _isExternalContent = false; - @state() - private _externalContentVariantState: string | null | undefined; - @state() private _isReadOnly = false; @@ -204,10 +198,10 @@ export class UmbBlockSingleEntryElement extends UmbLitElement implements UmbProp null, ); this.observe( - this.#context.hasExpose, - (exposed) => { - this._localExpose = exposed; - this.#updateExposedState(); + this.#context.isExposed, + (isExposed) => { + this.#updateBlockViewProps({ unpublished: !isExposed }); + this._exposed = isExposed; }, null, ); @@ -215,15 +209,6 @@ export class UmbBlockSingleEntryElement extends UmbLitElement implements UmbProp this.#context.isExternalContent, (isExternalContent) => { this._isExternalContent = isExternalContent; - this.#updateExposedState(); - }, - null, - ); - this.observe( - this.#context.externalContentVariantState, - (state) => { - this._externalContentVariantState = state; - this.#updateExposedState(); }, null, ); @@ -321,16 +306,6 @@ export class UmbBlockSingleEntryElement extends UmbLitElement implements UmbProp this.requestUpdate('_blockViewProps'); } - #updateExposedState() { - // External content blocks use the element's variant state; local blocks use the expose entry - const isExposed = this._isExternalContent - ? this._externalContentVariantState === UmbElementVariantState.PUBLISHED || - this._externalContentVariantState === UmbElementVariantState.PUBLISHED_PENDING_CHANGES - : this._localExpose; - this.#updateBlockViewProps({ unpublished: !isExposed }); - this._exposed = isExposed; - } - override connectedCallback(): void { super.connectedCallback(); // element styling: diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-entry.context.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-entry.context.ts index 17240c98dbe5..7e0b8acb472b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-entry.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-entry.context.ts @@ -28,7 +28,11 @@ import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization- import { UmbModalRouteRegistrationController, UmbRoutePathAddendumContext } from '@umbraco-cms/backoffice/router'; import { UmbVariantId } from '@umbraco-cms/backoffice/variant'; import { UmbUfmVirtualRenderController } from '@umbraco-cms/backoffice/ufm'; -import { UMB_EDIT_ELEMENT_WORKSPACE_PATH_PATTERN, UMB_ELEMENT_ENTITY_TYPE } from '@umbraco-cms/backoffice/element'; +import { + UMB_EDIT_ELEMENT_WORKSPACE_PATH_PATTERN, + UMB_ELEMENT_ENTITY_TYPE, + UmbElementVariantState, +} from '@umbraco-cms/backoffice/element'; import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace'; import type { Observable } from '@umbraco-cms/backoffice/external/rxjs'; import type { UmbBlockTypeBaseModel } from '@umbraco-cms/backoffice/block-type'; @@ -85,6 +89,16 @@ export abstract class UmbBlockEntryContext< #hasExpose = new UmbBooleanState(undefined); readonly hasExpose = this.#hasExpose.asObservable(); + readonly isExposed = mergeObservables( + [this.hasExpose, this.isExternalContent, this.externalContentVariantState], + ([hasExpose, isExternalContent, variantState]) => + // External content blocks use the element's variant state; local blocks use the expose entry + isExternalContent + ? variantState === UmbElementVariantState.PUBLISHED || + variantState === UmbElementVariantState.PUBLISHED_PENDING_CHANGES + : hasExpose, + ); + #actionsVisibility = new UmbBooleanState(true); readonly actionsVisibility = this.#actionsVisibility.asObservable(); From cb9669c48eab62c3208dd4e3cb4dc720a79cb72e Mon Sep 17 00:00:00 2001 From: leekelleher Date: Mon, 10 Aug 2026 13:31:06 +0100 Subject: [PATCH 09/18] test(tiptap): update div-container block expectations for layout keys These two cases were merged up from v17/dev before RTE blocks gained a data-key layout attribute alongside data-content-key, so their expected markup no longer matches the block node's actual (correct) output. Use distinct data-key/data-content-key values to assert the div-nesting behaviour without relying on the legacy same-value back-fill, which is already covered by block.tiptap-extension.test.ts. --- ...html-tag-div-container.tiptap-extension.test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/html-tag-div/html-tag-div-container.tiptap-extension.test.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/html-tag-div/html-tag-div-container.tiptap-extension.test.ts index 6d928fbdefdc..94871490cfe5 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/html-tag-div/html-tag-div-container.tiptap-extension.test.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/html-tag-div/html-tag-div-container.tiptap-extension.test.ts @@ -75,16 +75,22 @@ describe('html-tag-div-container.tiptap-extension', () => { }); it('preserves an umb-rte-block nested inside a div', () => { - editor.commands.setContent('
'); + editor.commands.setContent( + '
', + ); - expect(editor.getHTML()).to.equal('
'); + expect(editor.getHTML()).to.equal( + '
', + ); }); it('falls through to the inline div for a div wrapping only an umb-rte-block-inline', () => { - editor.commands.setContent('
'); + editor.commands.setContent( + '
', + ); expect(editor.getHTML()).to.equal( - '
', + '
', ); // Both `div` and `divContainer` render as
, so assert on the parsed node type directly // to confirm the inline div (not divContainer) claimed the element. From 68b82bb2cc15f2753b6ecbe8e38415f2a0bb986c Mon Sep 17 00:00:00 2001 From: leekelleher Date: Mon, 10 Aug 2026 13:31:16 +0100 Subject: [PATCH 10/18] fix(core): allow BlockRegex to match RTE blocks carrying data-key BlockRegex required data-content-key to appear immediately after the tag name (or an optional class attribute), so it never matched once data-key started being emitted before data-content-key. That left RTE blocks rendering as unreplaced placeholders on the front end after being re-saved through the editor. Widen the pattern to accept attributes on either side of data-content-key. --- .../ValueConverters/RichTextParsingRegexes.cs | 4 +-- .../RichTextParsingRegexesTests.cs | 25 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/RichTextParsingRegexesTests.cs diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RichTextParsingRegexes.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RichTextParsingRegexes.cs index b45f86d13116..671365762b87 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RichTextParsingRegexes.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RichTextParsingRegexes.cs @@ -5,9 +5,9 @@ namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; internal static partial class RichTextParsingRegexes { /// - /// Returns a regular expression that matches Umbraco RTE block elements, including optional inline variants and class attributes. + /// Returns a regular expression that matches Umbraco RTE block elements, including optional inline variants and any other attributes (e.g. class, data-key) around data-content-key. /// /// A for matching Umbraco RTE block elements. - [GeneratedRegex(".[^\"]*)\">(?:)?<\\/umb-rte-block(?:-inline)?>")] + [GeneratedRegex("[^\"]+)\"(?:\\s+[\\w-]+=\"[^\"]*\")*\\s*>(?:)?<\\/umb-rte-block(?:-inline)?>")] public static partial Regex BlockRegex(); } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/RichTextParsingRegexesTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/RichTextParsingRegexesTests.cs new file mode 100644 index 000000000000..13ee03e1f7eb --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/RichTextParsingRegexesTests.cs @@ -0,0 +1,25 @@ +using NUnit.Framework; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors.ValueConverters; + +[TestFixture] +public class RichTextParsingRegexesTests +{ + private const string ContentKey = "36cc710a-d8a6-45d0-a07f-7bbd8742cf02"; + private const string LayoutKey = "d2eeef66-4111-42f4-a164-7a523eaffbc2"; + + [TestCase($"")] + [TestCase($"")] + [TestCase($"")] + [TestCase($"")] + [TestCase($"")] + [TestCase($"")] + public void Matches_And_Captures_The_Content_Key(string markup) + { + var match = RichTextParsingRegexes.BlockRegex().Match(markup); + + Assert.IsTrue(match.Success); + Assert.AreEqual(ContentKey, match.Groups["key"].Value); + } +} From 2d3bfb30998844380ce9d0decd56c557e935c1cc Mon Sep 17 00:00:00 2001 From: leekelleher Date: Mon, 7 Sep 2026 09:20:11 +0100 Subject: [PATCH 11/18] Changed `combineLatest` to `observeMultiple` --- .../src/packages/tiptap/extensions/block/block.tiptap-api.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/block.tiptap-api.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/block.tiptap-api.ts index 80d601cf03c9..af4348fddcdf 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/block.tiptap-api.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/block.tiptap-api.ts @@ -1,6 +1,6 @@ import { UmbTiptapExtensionApiBase } from '../tiptap-extension-api-base.js'; import { umbRteBlock, umbRteBlockInline } from './block.tiptap-extension.js'; -import { combineLatest } from '@umbraco-cms/backoffice/external/rxjs'; +import { observeMultiple } from '@umbraco-cms/backoffice/observable-api'; import { UMB_BLOCK_RTE_DATA_LAYOUT_KEY } from '@umbraco-cms/backoffice/rte'; import { UMB_BLOCK_RTE_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/block-rte'; import type { UmbBlockRteLayoutModel } from '@umbraco-cms/backoffice/block-rte'; @@ -18,7 +18,7 @@ export default class UmbTiptapBlockElementApi extends UmbTiptapExtensionApiBase this.#managerContext = context; this.observe( - combineLatest([context.layouts, context.allContents]), + observeMultiple([context.layouts, context.allContents]), ([layouts]) => { this.#updateBlocks(layouts); }, From 0298f12c60b3ffa451cce0faeec58bb8e64dd053 Mon Sep 17 00:00:00 2001 From: leekelleher Date: Mon, 7 Sep 2026 10:03:04 +0100 Subject: [PATCH 12/18] Border radius on focused block --- .../components/block-rte-entry/block-rte-entry.element.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts index c928d1877d86..1515120fb752 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts @@ -409,6 +409,7 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert :host(.ProseMirror-selectednode) { --uui-color-default-contrast: initial; + border-radius: var(--uui-border-radius); outline: 3px solid var(--uui-color-focus); } From f3b212f7afbaaf1160560bf623d75cfd823f80d2 Mon Sep 17 00:00:00 2001 From: leekelleher Date: Mon, 7 Sep 2026 12:30:38 +0100 Subject: [PATCH 13/18] Fix orphaned RTE block re-insertion when deleting mixed local/external blocks --- .../block-rte-entry.element.ts | 7 +- .../block/context/block-entry.context.ts | 4 + .../block/context/block-manager.context.ts | 2 + .../rte/components/rte-base.element.ts | 6 +- .../extensions/block/block.tiptap-api.test.ts | 130 ++++++++++++++++++ 5 files changed, 144 insertions(+), 5 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/block.tiptap-api.test.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts index 1515120fb752..bf79b2ae6f8d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts @@ -100,7 +100,6 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert @state() private _isReadOnly = false; - // TODO: consumed by label, landing in a follow-up PR; add `@state()` when used in render [LK] private _name?: string; @state() @@ -335,8 +334,10 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert return when( this.contentKey && (this._contentTypeAlias || this.unsupported), () => html` -
- +
+ + ${when(this._isExternalContent, () => html``)}${this._label} + diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-manager.context.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-manager.context.ts index f02cd1694509..5e5def437c88 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-manager.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-manager.context.ts @@ -89,6 +89,8 @@ export abstract class UmbBlockManagerContext< /** * Combined observable of local block content and resolved external (library element) content. * Use this alongside `contents` when you also need to react to library elements becoming available. + * The two arrays are concatenated without de-duplication — a key present in both (which should not + * normally happen) appears twice, with the local entry taking precedence in lookups like `getContentOf`. */ public readonly allContents = mergeObservables( [this.#contents.asObservable(), this.#externalContentValues.asObservable()], diff --git a/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts b/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts index 0d3c150f8796..655736b2ac20 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts @@ -384,7 +384,6 @@ export abstract class UmbPropertyEditorUiRteElementBase /** * @param {(string | null)[]} usedLayoutKeys - Layout keys (not content keys) currently present in the editor markup. - * @since 19.0.0 — parameter semantics changed from content keys to layout keys. */ protected _filterUnusedBlocks(usedLayoutKeys: (string | null)[]) { const unusedLayouts = this.#managerContext.getLayouts().filter((x) => !usedLayoutKeys.includes(x.key)); @@ -399,9 +398,12 @@ export abstract class UmbPropertyEditorUiRteElementBase .map((x) => x.settingsKey) .filter((x) => typeof x === 'string') as Array; + // Layouts first: removing content/settings emits synchronously, and #updateBlocks would + // re-insert any layout still present whose content is still resolvable (external content + // is never purged from the manager's externalContentValues state). [LK] + this.#managerContext.removeManyLayouts(unusedLayouts.map((x) => x.key)); this.#managerContext.removeManyContent(unusedContentKeys); this.#managerContext.removeManySettings(unusedSettingsKeys); - this.#managerContext.removeManyLayouts(unusedLayouts.map((x) => x.key)); } protected _fireChangeEvent() { diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/block.tiptap-api.test.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/block.tiptap-api.test.ts new file mode 100644 index 000000000000..38bcdf133fe8 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/block.tiptap-api.test.ts @@ -0,0 +1,130 @@ +import { Document, Editor, Paragraph, Text } from '../../externals.js'; +import { umbRteBlock, umbRteBlockInline } from './block.tiptap-extension.js'; +import UmbTiptapBlockElementApi from './block.tiptap-api.js'; +import { UMB_BLOCK_RTE_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/block-rte'; +import { UMB_BLOCK_RTE_DATA_LAYOUT_KEY } from '@umbraco-cms/backoffice/rte'; +import { UmbArrayState } from '@umbraco-cms/backoffice/observable-api'; +import { aTimeout, expect, fixture, html } from '@open-wc/testing'; +import { customElement } from '@umbraco-cms/backoffice/external/lit'; +import type { UmbElement } from '@umbraco-cms/backoffice/element-api'; +import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api'; +import type { UmbBlockRteLayoutModel } from '@umbraco-cms/backoffice/block-rte'; +import type { UmbBlockDataModel } from '@umbraco-cms/backoffice/block'; + +@customElement('umb-test-tiptap-block-api-host') +// eslint-disable-next-line @typescript-eslint/no-unused-vars +class UmbTestHostElement extends UmbElementMixin(HTMLElement) {} + +const LOCAL_LAYOUT_KEY = 'local-layout'; +const LOCAL_CONTENT_KEY = 'local-content'; +const EXTERNAL_LAYOUT_KEY = 'external-layout'; +const EXTERNAL_CONTENT_KEY = 'external-content'; +const CONTENT_TYPE_KEY = 'content-type'; + +function getLayoutKeysInDoc(editor: Editor): Array { + const keys: Array = []; + editor.state.doc.descendants((node) => { + const key = node.attrs[UMB_BLOCK_RTE_DATA_LAYOUT_KEY]; + if (key) keys.push(key); + return true; + }); + return keys; +} + +// Regression coverage for the orphan-block bug fixed alongside the layout-key rework: a library-element +// (external) block's content is never purged from the manager's externalContentValues state, so if its +// layout entry is still present when #updateBlocks re-observes, it looks like a block that needs +// (re-)inserting — even though the editor already had its node removed by the delete flow. Reproduced +// here directly against UmbTiptapBlockElementApi with a fake manager context, since the real +// UmbBlockRteManagerContext pulls in a live element repository that isn't needed to exercise this path. +describe('UmbTiptapBlockElementApi', () => { + let host: UmbElement; + let layoutsState: UmbArrayState; + let allContentsState: UmbArrayState; + let pendingDeletionsState: UmbArrayState; + let editor: Editor; + + const setUp = async () => { + host = await fixture(html``); + + layoutsState = new UmbArrayState( + [ + { key: LOCAL_LAYOUT_KEY, contentKey: LOCAL_CONTENT_KEY }, + { key: EXTERNAL_LAYOUT_KEY, contentKey: EXTERNAL_CONTENT_KEY, isExternalContent: true }, + ], + (x) => x.key, + ); + allContentsState = new UmbArrayState( + [ + { key: LOCAL_CONTENT_KEY, contentTypeKey: CONTENT_TYPE_KEY, values: [] }, + { key: EXTERNAL_CONTENT_KEY, contentTypeKey: CONTENT_TYPE_KEY, values: [] }, + ], + (x) => x.key, + ); + pendingDeletionsState = new UmbArrayState([], (x) => x); + + host.provideContext(UMB_BLOCK_RTE_MANAGER_CONTEXT, { + getHostElement: () => host, + layouts: layoutsState.asObservable(), + allContents: allContentsState.asObservable(), + pendingDeletions: pendingDeletionsState.asObservable(), + getContentTypeKeyOfContentKey: (contentKey: string) => + allContentsState.getValue().find((x) => x.key === contentKey)?.contentTypeKey, + getBlockTypeOf: () => ({ displayInline: false }), + clearPendingDeletion: () => undefined, + } as never); + + editor = new Editor({ + element: document.createElement('div'), + extensions: [Document, Paragraph, Text, umbRteBlock, umbRteBlockInline], + content: + `` + + ``, + }); + + const api = new UmbTiptapBlockElementApi(host); + api.setEditor(editor); + // consumeContext resolves asynchronously; let the initial subscription settle. + await aTimeout(0); + + // Baseline: both blocks already in the doc, so the initial #updateBlocks pass inserts nothing new. + expect(getLayoutKeysInDoc(editor)).to.have.members([LOCAL_LAYOUT_KEY, EXTERNAL_LAYOUT_KEY]); + + // Simulate the delete flow already having removed both nodes from the editor DOM, before the + // manager state has been cleaned up (mirrors _filterUnusedBlocks running after the markup change). + editor.commands.setContent('

'); + expect(getLayoutKeysInDoc(editor)).to.deep.equal([]); + }; + + afterEach(() => { + editor?.destroy(); + }); + + it('re-inserts an orphan external block when content is removed before its layout (buggy order)', async () => { + await setUp(); + + // Old, buggy order: content first (only the local content is actually removed — external content + // lives outside of what removeManyContent touches), layouts still contain both entries. + allContentsState.setValue([{ key: EXTERNAL_CONTENT_KEY, contentTypeKey: CONTENT_TYPE_KEY, values: [] }]); + + // #updateBlocks sees the external layout still present and its content still resolvable, and + // re-inserts it into the (now empty) editor doc. + expect(getLayoutKeysInDoc(editor)).to.deep.equal([EXTERNAL_LAYOUT_KEY]); + + layoutsState.setValue([]); + + // The layout is now gone, but the re-inserted node is left behind with nothing backing it. + expect(getLayoutKeysInDoc(editor)).to.deep.equal([EXTERNAL_LAYOUT_KEY]); + }); + + it('does not re-insert the external block when layouts are removed before content (fixed order)', async () => { + await setUp(); + + // Fixed order: layouts first. + layoutsState.setValue([]); + allContentsState.setValue([{ key: EXTERNAL_CONTENT_KEY, contentTypeKey: CONTENT_TYPE_KEY, values: [] }]); + + // By the time content emits, the external layout is already gone, so there's nothing to re-insert. + expect(getLayoutKeysInDoc(editor)).to.deep.equal([]); + }); +}); From 0a1e0ec5df99a8f4e08ab4f643910daa099cbd2c Mon Sep 17 00:00:00 2001 From: leekelleher Date: Mon, 7 Sep 2026 14:20:09 +0100 Subject: [PATCH 14/18] Move RTE block-key markup scan into the base class, deprecate _filterUnusedBlocks --- .../rte/components/rte-base.element.test.ts | 99 +++++++++++++++++++ .../rte/components/rte-base.element.ts | 54 +++++++++- .../property-editor-ui-tiptap.element.ts | 18 +--- 3 files changed, 153 insertions(+), 18 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.test.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.test.ts b/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.test.ts new file mode 100644 index 000000000000..aa8230ddf439 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.test.ts @@ -0,0 +1,99 @@ +import { UmbPropertyEditorUiRteElementBase } from './rte-base.element.js'; +import { UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS } from '../constants.js'; +import { aTimeout, expect, fixture, html } from '@open-wc/testing'; +import { customElement } from '@umbraco-cms/backoffice/external/lit'; +import type { UmbPropertyEditorRteValueType } from '../types.js'; +import type { UmbBlockRteLayoutModel } from '@umbraco-cms/backoffice/block-rte'; +import type { UmbBlockDataModel } from '@umbraco-cms/backoffice/block'; + +@customElement('umb-test-rte-base') +class UmbTestRteBaseElement extends UmbPropertyEditorUiRteElementBase { + public callFilterUnusedBlocksFromMarkup(markup: string) { + this._filterUnusedBlocksFromMarkup(markup); + } + + // eslint-disable-next-line @typescript-eslint/no-deprecated + public callFilterUnusedBlocks(usedLayoutKeys: Array) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + this._filterUnusedBlocks(usedLayoutKeys); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'umb-test-rte-base': UmbTestRteBaseElement; + } +} + +function makeValue(layouts: Array, contents: Array): UmbPropertyEditorRteValueType { + return { + markup: '', + blocks: { + layout: { [UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS]: layouts }, + contentData: contents, + settingsData: [], + expose: [], + }, + }; +} + +const LAYOUT_A: UmbBlockRteLayoutModel = { key: 'layout-a', contentKey: 'content-a' }; +const CONTENT_A: UmbBlockDataModel = { key: 'content-a', contentTypeKey: 'content-type', values: [] }; +const LAYOUT_B: UmbBlockRteLayoutModel = { key: 'layout-b', contentKey: 'content-b' }; +const CONTENT_B: UmbBlockDataModel = { key: 'content-b', contentTypeKey: 'content-type', values: [] }; + +describe('UmbPropertyEditorUiRteElementBase', () => { + let element: UmbTestRteBaseElement; + + beforeEach(async () => { + element = await fixture(html``); + // The manager -> value sync observer is wired up via an asynchronously resolved context; let it settle. + await aTimeout(0); + }); + + describe('_filterUnusedBlocksFromMarkup', () => { + it('keeps only blocks referenced by data-key in the given markup', () => { + element.value = makeValue([LAYOUT_A, LAYOUT_B], [CONTENT_A, CONTENT_B]); + + element.callFilterUnusedBlocksFromMarkup(''); + + const layouts = element.value?.blocks?.layout[UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS]; + expect(layouts).to.deep.equal([LAYOUT_A]); + expect(element.value?.blocks?.contentData).to.deep.equal([CONTENT_A]); + }); + + it('falls back to data-content-key for legacy layouts with no separate key', () => { + const legacyLayout: UmbBlockRteLayoutModel = { key: 'content-c', contentKey: 'content-c' }; + const legacyContent: UmbBlockDataModel = { key: 'content-c', contentTypeKey: 'content-type', values: [] }; + element.value = makeValue([legacyLayout], [legacyContent]); + + element.callFilterUnusedBlocksFromMarkup(''); + + const layouts = element.value?.blocks?.layout[UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS]; + expect(layouts).to.deep.equal([legacyLayout]); + }); + + it('removes a block entirely absent from the markup', () => { + element.value = makeValue([LAYOUT_A], [CONTENT_A]); + + element.callFilterUnusedBlocksFromMarkup('

No blocks here.

'); + + const layouts = element.value?.blocks?.layout[UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS]; + expect(layouts).to.deep.equal([]); + expect(element.value?.blocks?.contentData).to.deep.equal([]); + }); + }); + + // The deprecated overload must keep working for existing RTE implementations built against it. + describe('_filterUnusedBlocks (deprecated)', () => { + it('keeps only blocks referenced by the given layout keys', () => { + element.value = makeValue([LAYOUT_A, LAYOUT_B], [CONTENT_A, CONTENT_B]); + + element.callFilterUnusedBlocks(['layout-a']); + + const layouts = element.value?.blocks?.layout[UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS]; + expect(layouts).to.deep.equal([LAYOUT_A]); + expect(element.value?.blocks?.contentData).to.deep.equal([CONTENT_A]); + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts b/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts index 655736b2ac20..1ca660d7be9d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts @@ -1,5 +1,9 @@ import type { UmbPropertyEditorRteValueType } from '../types.js'; -import { UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS } from '../constants.js'; +import { + UMB_BLOCK_RTE_DATA_CONTENT_KEY, + UMB_BLOCK_RTE_DATA_LAYOUT_KEY, + UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS, +} from '../constants.js'; import { jsonStringComparison, observeMultiple } from '@umbraco-cms/backoffice/observable-api'; import { property, state } from '@umbraco-cms/backoffice/external/lit'; import { UmbBlockRteEntriesContext, UmbBlockRteManagerContext } from '@umbraco-cms/backoffice/block-rte'; @@ -13,6 +17,7 @@ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UMB_VARIANT_CONTEXT, UmbVariantId } from '@umbraco-cms/backoffice/variant'; import { UMB_CONTENT_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/content'; import { UMB_PROPERTY_CONTEXT } from '@umbraco-cms/backoffice/property'; +import { UmbDeprecation } from '@umbraco-cms/backoffice/utils'; import type { StyleInfo } from '@umbraco-cms/backoffice/external/lit'; import type { UmbBlockDataModel } from '@umbraco-cms/backoffice/block'; import type { UmbBlockRteLayoutModel, UmbBlockRteTypeModel } from '@umbraco-cms/backoffice/block-rte'; @@ -383,9 +388,56 @@ export abstract class UmbPropertyEditorUiRteElementBase } /** + * @deprecated Use `_filterUnusedBlocksFromMarkup` instead, passing the raw RTE markup — it derives the + * used layout keys itself, including the legacy-markup fallback to content keys. Scheduled for removal + * in Umbraco 21. * @param {(string | null)[]} usedLayoutKeys - Layout keys (not content keys) currently present in the editor markup. */ protected _filterUnusedBlocks(usedLayoutKeys: (string | null)[]) { + new UmbDeprecation({ + deprecated: 'UmbPropertyEditorUiRteElementBase._filterUnusedBlocks()', + removeInVersion: '21.0.0', + solution: 'Use _filterUnusedBlocksFromMarkup() instead, passing the raw RTE markup.', + }).warn(); + + this.#filterUnusedBlocksByLayoutKeys(usedLayoutKeys); + } + + /** + * Removes block layout entries — and their content/settings — no longer referenced in the given markup. + * RTE implementations should call this from their change handler with the editor's current markup. + * @param {string} markup - The RTE markup as currently held by the editor. + */ + protected _filterUnusedBlocksFromMarkup(markup: string) { + this.#filterUnusedBlocksByLayoutKeys(this.#getUsedRteBlockLayoutKeysInMarkup(markup)); + } + + /** + * Extracts each block element's layout key from the given markup, mirroring the backend's + * `RichTextParsingRegexes.BlockRegex`. Falls back to the content key for legacy markup that predates + * `data-key`, matching `setLayouts()`'s `layout.key ??= layout.contentKey` coercion. + * @param {string} markup - The RTE markup to scan. + * @returns {Array} The layout keys of every block element found in the markup. + */ + #getUsedRteBlockLayoutKeysInMarkup(markup: string): Array { + const usedLayoutKeys: Array = []; + + const blockRegex = /]*)>/gi; + let blockElement: RegExpExecArray | null; + while ((blockElement = blockRegex.exec(markup)) !== null) { + const tag = blockElement[0]; + const layoutKeyMatch = new RegExp(` ${UMB_BLOCK_RTE_DATA_LAYOUT_KEY}="([^"]+)"`).exec(tag)?.[1]; + const contentKeyMatch = new RegExp(` ${UMB_BLOCK_RTE_DATA_CONTENT_KEY}="([^"]+)"`).exec(tag)?.[1]; + const layoutKey = layoutKeyMatch ?? contentKeyMatch; + if (layoutKey) { + usedLayoutKeys.push(layoutKey); + } + } + + return usedLayoutKeys; + } + + #filterUnusedBlocksByLayoutKeys(usedLayoutKeys: (string | null)[]) { const unusedLayouts = this.#managerContext.getLayouts().filter((x) => !usedLayoutKeys.includes(x.key)); // Temporarily set the unused layouts to the lookup, as they could be restored later, e.g. via an RTE undo action. [LK] diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap-rte/property-editor-ui-tiptap.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap-rte/property-editor-ui-tiptap.element.ts index b7f6c874f2bb..2540bff87d51 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap-rte/property-editor-ui-tiptap.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap-rte/property-editor-ui-tiptap.element.ts @@ -59,22 +59,6 @@ export class UmbPropertyEditorUiTiptapElement extends UmbPropertyEditorUiRteElem const tipTapElement = event.target; const markup = tipTapElement.value; - // Remove unused Blocks of Blocks Layout. Leaving only the Blocks that are present in Markup. - // Extract the layout key (data-key) from each block element to uniquely identify layout entries. - // For legacy markup without data-key, fall back to data-content-key — safe because - // setLayouts() coerces layout.key ??= layout.contentKey for persisted data without a key. - const usedLayoutKeys: string[] = []; - - const blockRegex = /]*)>/gi; - let blockElement: RegExpExecArray | null; - while ((blockElement = blockRegex.exec(markup)) !== null) { - const tag = blockElement[0]; - const layoutKey = / data-key="([^"]+)"/.exec(tag)?.[1] ?? / data-content-key="([^"]+)"/.exec(tag)?.[1]; - if (layoutKey) { - usedLayoutKeys.push(layoutKey); - } - } - if (this.value) { this.value = { ...this.value, @@ -93,7 +77,7 @@ export class UmbPropertyEditorUiTiptapElement extends UmbPropertyEditorUiRteElem } // lets run this one after we set the value, to make sure we don't reset the value. - this._filterUnusedBlocks(usedLayoutKeys); + this._filterUnusedBlocksFromMarkup(markup); this._fireChangeEvent(); } From 41de41e62f89ec037b476e0f261aed8daa1f28ee Mon Sep 17 00:00:00 2001 From: leekelleher Date: Mon, 7 Sep 2026 14:24:01 +0100 Subject: [PATCH 15/18] Adds state to `_name` to update when UFM label has resolved. --- .../components/block-rte-entry/block-rte-entry.element.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts index bf79b2ae6f8d..1c22b51f5175 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts @@ -100,6 +100,7 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert @state() private _isReadOnly = false; + @state() private _name?: string; @state() @@ -219,6 +220,7 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert null, ); this.observe(this.#context.index, (index) => this.#updateBlockViewProps({ index }), null); + this.observe(this.#context.name, (name) => (this._name = name), null); this.observe( this.#context.label, (label) => { @@ -336,7 +338,7 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert () => html`
- ${when(this._isExternalContent, () => html``)}${this._label} + ${when(this._isExternalContent, () => html``)} ${this._name} Date: Mon, 7 Sep 2026 15:45:13 +0100 Subject: [PATCH 16/18] Split this PR: move server-side, shared block-entry and RTE entry UI work to their own PRs Server-side data-key parsing/stripping, the shared isExposed derivation on UmbBlockEntryContext, and the RTE entry umb-entity-frame UI are independent of the layout-key rework and are easier reviewed separately. Moved to: - v19/bugfix/rte-block-data-key-parsing - v19/improvement/block-entry-is-exposed - v19/feature/rcob-rte-block-entry-ui This branch now covers only identifying RTE blocks by layout key. --- .../ValueConverters/RichTextParsingRegexes.cs | 4 +-- .../block-grid-entry.element.ts | 33 ++++++++++++++++--- .../block-list-entry.element.ts | 33 ++++++++++++++++--- .../block-rte-entry.element.ts | 29 ++++------------ .../block-single-entry.element.ts | 33 ++++++++++++++++--- .../block/context/block-entry.context.ts | 20 +---------- .../RichTextParsingRegexesTests.cs | 25 -------------- 7 files changed, 96 insertions(+), 81 deletions(-) delete mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/RichTextParsingRegexesTests.cs diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RichTextParsingRegexes.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RichTextParsingRegexes.cs index 671365762b87..b45f86d13116 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RichTextParsingRegexes.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RichTextParsingRegexes.cs @@ -5,9 +5,9 @@ namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; internal static partial class RichTextParsingRegexes { /// - /// Returns a regular expression that matches Umbraco RTE block elements, including optional inline variants and any other attributes (e.g. class, data-key) around data-content-key. + /// Returns a regular expression that matches Umbraco RTE block elements, including optional inline variants and class attributes. /// /// A for matching Umbraco RTE block elements. - [GeneratedRegex("[^\"]+)\"(?:\\s+[\\w-]+=\"[^\"]*\")*\\s*>(?:)?<\\/umb-rte-block(?:-inline)?>")] + [GeneratedRegex(".[^\"]*)\">(?:)?<\\/umb-rte-block(?:-inline)?>")] public static partial Regex BlockRegex(); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entry/block-grid-entry.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entry/block-grid-entry.element.ts index b9df9d09f959..29a5a62dbb62 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entry/block-grid-entry.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entry/block-grid-entry.element.ts @@ -6,6 +6,7 @@ import { stringOrStringArrayContains, UmbDeprecation } from '@umbraco-cms/backof import { UmbDataPathBlockElementDataQuery } from '@umbraco-cms/backoffice/block'; import { renderHiddenUfm } from '@umbraco-cms/backoffice/ufm'; import { umbDestroyOnDisconnect, UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbElementVariantState } from '@umbraco-cms/backoffice/element'; import { UmbObserveValidationStateController } from '@umbraco-cms/backoffice/validation'; import { UUIBlinkAnimationValue, UUIBlinkKeyframes } from '@umbraco-cms/backoffice/external/uui'; import type { PropertyValueMap } from '@umbraco-cms/backoffice/external/lit'; @@ -136,6 +137,8 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper @state() private _exposed?: boolean; + private _localExpose?: boolean; + // Unsupported is triggered if the Block Type is not recognized, it can also be triggered by the Content Element Type not existing any longer. [NL] @state() private _unsupported?: boolean; @@ -188,6 +191,9 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper @property({ type: Boolean, attribute: 'is-reference', reflect: true }) private _isExternalContent = false; + @state() + private _externalContentVariantState: string | null | undefined; + @state() private _isReadOnly = false; @@ -240,10 +246,10 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper null, ); this.observe( - this.#context.isExposed, - (isExposed) => { - this.#updateBlockViewProps({ unpublished: !isExposed }); - this._exposed = isExposed; + this.#context.hasExpose, + (exposed) => { + this._localExpose = exposed; + this.#updateExposedState(); }, null, ); @@ -265,6 +271,15 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper this.#context.isExternalContent, (isExternalContent) => { this._isExternalContent = isExternalContent; + this.#updateExposedState(); + }, + null, + ); + this.observe( + this.#context.externalContentVariantState, + (state) => { + this._externalContentVariantState = state; + this.#updateExposedState(); }, null, ); @@ -436,6 +451,16 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper this.#context.expose(); }; + #updateExposedState() { + // External content blocks use the element's variant state; local blocks use the expose entry + const isExposed = this._isExternalContent + ? this._externalContentVariantState === UmbElementVariantState.PUBLISHED || + this._externalContentVariantState === UmbElementVariantState.PUBLISHED_PENDING_CHANGES + : this._localExpose; + this.#updateBlockViewProps({ unpublished: !isExposed }); + this._exposed = isExposed; + } + #onUfmResolved = (event: UmbUfmResolvedEvent) => { this.#context.setName(event.detail.text); }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/components/block-list-entry/block-list-entry.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/components/block-list-entry/block-list-entry.element.ts index db0985c14492..41113990df8d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/components/block-list-entry/block-list-entry.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/components/block-list-entry/block-list-entry.element.ts @@ -6,6 +6,7 @@ import { UmbLitElement, umbDestroyOnDisconnect } from '@umbraco-cms/backoffice/l import { stringOrStringArrayContains, UmbDeprecation } from '@umbraco-cms/backoffice/utils'; import { UmbDataPathBlockElementDataQuery } from '@umbraco-cms/backoffice/block'; import { renderHiddenUfm } from '@umbraco-cms/backoffice/ufm'; +import { UmbElementVariantState } from '@umbraco-cms/backoffice/element'; import { UmbObserveValidationStateController } from '@umbraco-cms/backoffice/validation'; import { UUIBlinkAnimationValue, UUIBlinkKeyframes } from '@umbraco-cms/backoffice/external/uui'; import type { @@ -119,6 +120,8 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper @state() private _exposed?: boolean; + private _localExpose?: boolean; + @state() private _unsupported?: boolean; @@ -151,6 +154,9 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper @property({ type: Boolean, attribute: 'is-reference', reflect: true }) private _isExternalContent = false; + @state() + private _externalContentVariantState: string | null | undefined; + @state() private _isReadOnly = false; @@ -200,10 +206,10 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper null, ); this.observe( - this.#context.isExposed, - (isExposed) => { - this.#updateBlockViewProps({ unpublished: !isExposed }); - this._exposed = isExposed; + this.#context.hasExpose, + (exposed) => { + this._localExpose = exposed; + this.#updateExposedState(); }, null, ); @@ -225,6 +231,15 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper this.#context.isExternalContent, (isExternalContent) => { this._isExternalContent = isExternalContent; + this.#updateExposedState(); + }, + null, + ); + this.observe( + this.#context.externalContentVariantState, + (state) => { + this._externalContentVariantState = state; + this.#updateExposedState(); }, null, ); @@ -343,6 +358,16 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper this.#context.setName(event.detail.text); }; + #updateExposedState() { + // External content blocks use the element's variant state; local blocks use the expose entry + const isExposed = this._isExternalContent + ? this._externalContentVariantState === UmbElementVariantState.PUBLISHED || + this._externalContentVariantState === UmbElementVariantState.PUBLISHED_PENDING_CHANGES + : this._localExpose; + this.#updateBlockViewProps({ unpublished: !isExposed }); + this._exposed = isExposed; + } + #extensionSlotFilterMethod = (manifest: ManifestBlockEditorCustomView) => { if (this._unsupported) { // If the block is unsupported, we should not allow any custom views to render. diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts index 1c22b51f5175..47981d3b1c29 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts @@ -100,7 +100,7 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert @state() private _isReadOnly = false; - @state() + // TODO: consumed by label, landing in a follow-up PR; add `@state()` when used in render [LK] private _name?: string; @state() @@ -109,10 +109,6 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert config: { showContentEdit: false, showSettingsEdit: false }, }; // Set to undefined cause it will be set before we render. - // 'is-reference' attribute is used for styling purpose. - @property({ type: Boolean, attribute: 'is-reference', reflect: true }) - private _isExternalContent = false; - // 'content-invalid' attribute is used for styling purpose. @property({ type: Boolean, attribute: 'content-invalid', reflect: true }) // eslint-disable-next-line @typescript-eslint/naming-convention @@ -220,7 +216,6 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert null, ); this.observe(this.#context.index, (index) => this.#updateBlockViewProps({ index }), null); - this.observe(this.#context.name, (name) => (this._name = name), null); this.observe( this.#context.label, (label) => { @@ -238,17 +233,10 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert null, ); this.observe( - this.#context.isExposed, - (isExposed) => { - this.#updateBlockViewProps({ unpublished: !isExposed }); - this._exposed = isExposed; - }, - null, - ); - this.observe( - this.#context.isExternalContent, - (isExternalContent) => { - this._isExternalContent = isExternalContent; + this.#context.hasExpose, + (exposed) => { + this.#updateBlockViewProps({ unpublished: !exposed }); + this._exposed = exposed; }, null, ); @@ -336,10 +324,7 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert return when( this.contentKey && (this._contentTypeAlias || this.unsupported), () => html` -
- - ${when(this._isExternalContent, () => html``)} ${this._name} - +
{ - this.#updateBlockViewProps({ unpublished: !isExposed }); - this._exposed = isExposed; + this.#context.hasExpose, + (exposed) => { + this._localExpose = exposed; + this.#updateExposedState(); }, null, ); @@ -209,6 +215,15 @@ export class UmbBlockSingleEntryElement extends UmbLitElement implements UmbProp this.#context.isExternalContent, (isExternalContent) => { this._isExternalContent = isExternalContent; + this.#updateExposedState(); + }, + null, + ); + this.observe( + this.#context.externalContentVariantState, + (state) => { + this._externalContentVariantState = state; + this.#updateExposedState(); }, null, ); @@ -306,6 +321,16 @@ export class UmbBlockSingleEntryElement extends UmbLitElement implements UmbProp this.requestUpdate('_blockViewProps'); } + #updateExposedState() { + // External content blocks use the element's variant state; local blocks use the expose entry + const isExposed = this._isExternalContent + ? this._externalContentVariantState === UmbElementVariantState.PUBLISHED || + this._externalContentVariantState === UmbElementVariantState.PUBLISHED_PENDING_CHANGES + : this._localExpose; + this.#updateBlockViewProps({ unpublished: !isExposed }); + this._exposed = isExposed; + } + override connectedCallback(): void { super.connectedCallback(); // element styling: diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-entry.context.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-entry.context.ts index e426a6e38c9c..d8d78b1ae8bb 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-entry.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-entry.context.ts @@ -28,11 +28,7 @@ import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization- import { UmbModalRouteRegistrationController, UmbRoutePathAddendumContext } from '@umbraco-cms/backoffice/router'; import { UmbVariantId } from '@umbraco-cms/backoffice/variant'; import { UmbUfmVirtualRenderController } from '@umbraco-cms/backoffice/ufm'; -import { - UMB_EDIT_ELEMENT_WORKSPACE_PATH_PATTERN, - UMB_ELEMENT_ENTITY_TYPE, - UmbElementVariantState, -} from '@umbraco-cms/backoffice/element'; +import { UMB_EDIT_ELEMENT_WORKSPACE_PATH_PATTERN, UMB_ELEMENT_ENTITY_TYPE } from '@umbraco-cms/backoffice/element'; import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace'; import type { Observable } from '@umbraco-cms/backoffice/external/rxjs'; import type { UmbBlockTypeBaseModel } from '@umbraco-cms/backoffice/block-type'; @@ -89,20 +85,6 @@ export abstract class UmbBlockEntryContext< #hasExpose = new UmbBooleanState(undefined); readonly hasExpose = this.#hasExpose.asObservable(); - /** - * Whether the block is currently published/exposed. For a block backed by external (library element) - * content, this reflects that content's own variant state rather than the local expose entry. - */ - readonly isExposed = mergeObservables( - [this.hasExpose, this.isExternalContent, this.externalContentVariantState], - ([hasExpose, isExternalContent, variantState]) => - // External content blocks use the element's variant state; local blocks use the expose entry - isExternalContent - ? variantState === UmbElementVariantState.PUBLISHED || - variantState === UmbElementVariantState.PUBLISHED_PENDING_CHANGES - : hasExpose, - ); - #actionsVisibility = new UmbBooleanState(true); readonly actionsVisibility = this.#actionsVisibility.asObservable(); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/RichTextParsingRegexesTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/RichTextParsingRegexesTests.cs deleted file mode 100644 index 13ee03e1f7eb..000000000000 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/RichTextParsingRegexesTests.cs +++ /dev/null @@ -1,25 +0,0 @@ -using NUnit.Framework; -using Umbraco.Cms.Core.PropertyEditors.ValueConverters; - -namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors.ValueConverters; - -[TestFixture] -public class RichTextParsingRegexesTests -{ - private const string ContentKey = "36cc710a-d8a6-45d0-a07f-7bbd8742cf02"; - private const string LayoutKey = "d2eeef66-4111-42f4-a164-7a523eaffbc2"; - - [TestCase($"")] - [TestCase($"")] - [TestCase($"")] - [TestCase($"")] - [TestCase($"")] - [TestCase($"")] - public void Matches_And_Captures_The_Content_Key(string markup) - { - var match = RichTextParsingRegexes.BlockRegex().Match(markup); - - Assert.IsTrue(match.Success); - Assert.AreEqual(ContentKey, match.Groups["key"].Value); - } -} From 830ad4e9d9bec07a1ed1af2d779994a82fb0e56c Mon Sep 17 00:00:00 2001 From: leekelleher Date: Mon, 7 Sep 2026 16:28:34 +0100 Subject: [PATCH 17/18] test(rte): assert against manager state, add layout-before-content ordering test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three existing assertions read element.value, which only re-syncs from manager state via a property-context-dependent observer that this fixture (no real UMB_PROPERTY_CONTEXT provider) never wires up, so they silently asserted on stale data regardless of the code under test. Assert against the manager context directly instead — that's the actual contract _filterUnusedBlocksByLayoutKeys owns. Also adds a test pinning the layout-before-content/settings removal order, which nothing previously guarded: reverting the reorder in rte-base.element.ts turned none of the existing tests red. --- .../rte/components/rte-base.element.test.ts | 49 +++++++++++++------ 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.test.ts b/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.test.ts index aa8230ddf439..f36fc0a788e0 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.test.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.test.ts @@ -1,9 +1,10 @@ import { UmbPropertyEditorUiRteElementBase } from './rte-base.element.js'; import { UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS } from '../constants.js'; -import { aTimeout, expect, fixture, html } from '@open-wc/testing'; +import { expect, fixture, html } from '@open-wc/testing'; import { customElement } from '@umbraco-cms/backoffice/external/lit'; +import { UMB_BLOCK_RTE_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/block-rte'; import type { UmbPropertyEditorRteValueType } from '../types.js'; -import type { UmbBlockRteLayoutModel } from '@umbraco-cms/backoffice/block-rte'; +import type { UmbBlockRteLayoutModel, UmbBlockRteManagerContext } from '@umbraco-cms/backoffice/block-rte'; import type { UmbBlockDataModel } from '@umbraco-cms/backoffice/block'; @customElement('umb-test-rte-base') @@ -44,11 +45,14 @@ const CONTENT_B: UmbBlockDataModel = { key: 'content-b', contentTypeKey: 'conten describe('UmbPropertyEditorUiRteElementBase', () => { let element: UmbTestRteBaseElement; + // Asserted against directly: _filterUnusedBlocksByLayoutKeys' contract is mutating manager state. + // element.value only re-syncs from that state via a property-context-dependent observer, which this + // fixture (no real UMB_PROPERTY_CONTEXT provider) never wires up. + let managerContext: UmbBlockRteManagerContext; beforeEach(async () => { element = await fixture(html``); - // The manager -> value sync observer is wired up via an asynchronously resolved context; let it settle. - await aTimeout(0); + managerContext = (await element.getContext(UMB_BLOCK_RTE_MANAGER_CONTEXT))!; }); describe('_filterUnusedBlocksFromMarkup', () => { @@ -57,9 +61,8 @@ describe('UmbPropertyEditorUiRteElementBase', () => { element.callFilterUnusedBlocksFromMarkup(''); - const layouts = element.value?.blocks?.layout[UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS]; - expect(layouts).to.deep.equal([LAYOUT_A]); - expect(element.value?.blocks?.contentData).to.deep.equal([CONTENT_A]); + expect(managerContext.getLayouts()).to.deep.equal([LAYOUT_A]); + expect(managerContext.getContents()).to.deep.equal([CONTENT_A]); }); it('falls back to data-content-key for legacy layouts with no separate key', () => { @@ -69,8 +72,7 @@ describe('UmbPropertyEditorUiRteElementBase', () => { element.callFilterUnusedBlocksFromMarkup(''); - const layouts = element.value?.blocks?.layout[UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS]; - expect(layouts).to.deep.equal([legacyLayout]); + expect(managerContext.getLayouts()).to.deep.equal([legacyLayout]); }); it('removes a block entirely absent from the markup', () => { @@ -78,9 +80,27 @@ describe('UmbPropertyEditorUiRteElementBase', () => { element.callFilterUnusedBlocksFromMarkup('

No blocks here.

'); - const layouts = element.value?.blocks?.layout[UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS]; - expect(layouts).to.deep.equal([]); - expect(element.value?.blocks?.contentData).to.deep.equal([]); + expect(managerContext.getLayouts()).to.deep.equal([]); + expect(managerContext.getContents()).to.deep.equal([]); + }); + + // Removing content/settings emits synchronously; if a still-present layout's content stays + // resolvable while that happens, #updateBlocks (block.tiptap-api.ts) re-inserts the node before + // the layout removal catches up, orphaning it. Layouts must therefore be removed first. + it('removes the layout before removing its content', () => { + element.value = makeValue([LAYOUT_A], [CONTENT_A]); + + const removalOrder: Array<'layouts' | 'contents'> = []; + managerContext.layouts.subscribe((layouts) => { + if (!layouts.some((x) => x.key === LAYOUT_A.key)) removalOrder.push('layouts'); + }); + managerContext.contents.subscribe((contents) => { + if (!contents.some((x) => x.key === CONTENT_A.key)) removalOrder.push('contents'); + }); + + element.callFilterUnusedBlocksFromMarkup('

No blocks here.

'); + + expect(removalOrder).to.deep.equal(['layouts', 'contents']); }); }); @@ -91,9 +111,8 @@ describe('UmbPropertyEditorUiRteElementBase', () => { element.callFilterUnusedBlocks(['layout-a']); - const layouts = element.value?.blocks?.layout[UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS]; - expect(layouts).to.deep.equal([LAYOUT_A]); - expect(element.value?.blocks?.contentData).to.deep.equal([CONTENT_A]); + expect(managerContext.getLayouts()).to.deep.equal([LAYOUT_A]); + expect(managerContext.getContents()).to.deep.equal([CONTENT_A]); }); }); }); From c5ce29f2c9bb14343f92575909ebec62996902bd Mon Sep 17 00:00:00 2001 From: leekelleher Date: Tue, 8 Sep 2026 07:50:32 +0100 Subject: [PATCH 18/18] perf(rte): hoist layout/content key regex out of markup scan loop Addresses Claude review feedback on #23195. --- .../src/packages/rte/components/rte-base.element.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts b/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts index 1ca660d7be9d..a09163f153bc 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts @@ -423,11 +423,13 @@ export abstract class UmbPropertyEditorUiRteElementBase const usedLayoutKeys: Array = []; const blockRegex = /]*)>/gi; + const layoutKeyRegex = new RegExp(` ${UMB_BLOCK_RTE_DATA_LAYOUT_KEY}="([^"]+)"`); + const contentKeyRegex = new RegExp(` ${UMB_BLOCK_RTE_DATA_CONTENT_KEY}="([^"]+)"`); let blockElement: RegExpExecArray | null; while ((blockElement = blockRegex.exec(markup)) !== null) { const tag = blockElement[0]; - const layoutKeyMatch = new RegExp(` ${UMB_BLOCK_RTE_DATA_LAYOUT_KEY}="([^"]+)"`).exec(tag)?.[1]; - const contentKeyMatch = new RegExp(` ${UMB_BLOCK_RTE_DATA_CONTENT_KEY}="([^"]+)"`).exec(tag)?.[1]; + const layoutKeyMatch = layoutKeyRegex.exec(tag)?.[1]; + const contentKeyMatch = contentKeyRegex.exec(tag)?.[1]; const layoutKey = layoutKeyMatch ?? contentKeyMatch; if (layoutKey) { usedLayoutKeys.push(layoutKey);