From ae84d324ab873650ab5f9e4ef334453a27b149dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Tue, 31 Oct 2023 12:52:35 +0100 Subject: [PATCH] V13/feature/blocks in rte (#15029) * insert umb rte block web component in rte * First stab at moving the RTE markup to a nested "markup" property in the property value. * initial work * only rewrite markup * transform RTE into component * parse scope in grid.rte * revert use a fallback instead * block insertion and sync in place * block picker partly impl * remove test of old controller * remove test of old controller * block with block data * proper block with api connection * remove log * styling * Persist blocks data (still a temporary solution) * styling allows for interaction * block actions * tinyMCE styling * paste feature * prevalue display Inline toggle * inline mode in RTE * todo note * fixes wording * preparation for editor communication * remove val-server-match for now * clean up blocks that does not belong in markup * remove blocks not used in the markup * liveEditing * displayAsBlock formatting * clean up * TODO note * Serverside handling for RTE blocks (incl. refactor of Block List and Block Grid) * ensure rich text loads after block editor * trigger resize on block init * Handle RTE blocks output in Delivery API * sanitize ng classes * simplify calls to init blocks * move sanitisation * make validation work * only warn when missing one * clean up * remove validation border as it does not work * more clean up * add unsupported block entry editor * Revert breaking functionality for Block List and Grid * prevent re-inits of blocks * make sure delete blocks triggers an update * Refactor RichTextPropertyIndexValueFactory to index values from blocks + clean up RichTextPropertyEditor dependencies * first working cursor solution * inline element approach * Handle both inline and block level blocks * Fix the RTE block parser regex so it handles multiple inline blocks. * Fix reference and tags tracking, add tests, make the editor backwards compatible and make deploy happy * Use RichTextPropertyEditorHelper serialization in tests * Ensure correct model in Block Grid value converter (incl unit test to prove it) * do not include umbblockpicker in grid * make blocks the new default, instead of macros * only send value of body from DOMParser * Blocks of deleted ElementTypes shows unsupported * do not edit a unsupported block * remove trying to be smart on the init * fix missing culture issue * set dirty * alert when no blocks * Revert "make blocks the new default, instead of macros" This reverts commit 283e8aa473fdfde075197d34aa47e35dfc64a8ae. --------- Co-authored-by: kjac --- .../Json/DeliveryApiJsonTypeResolver.cs | 2 +- .../Blocks/IPartialViewBlockEngine.cs | 9 + .../Models/RichTextEditorSettings.cs | 4 + .../DeliveryApi/IApiRichTextElementParser.cs | 7 +- .../EmbeddedResources/Lang/da.xml | 2 + .../EmbeddedResources/Lang/en.xml | 2 + .../EmbeddedResources/Lang/en_us.xml | 2 + .../Models/Blocks/RichTextBlockItem.cs | 129 +++ .../Models/Blocks/RichTextBlockModel.cs | 38 + .../Models/DeliveryApi/RichTextModel.cs | 4 + .../Models/DeliveryApi/RichTextRootElement.cs | 19 + .../IRichTextPropertyIndexValueFactory.cs | 5 + .../PropertyEditors/RichTextConfiguration.cs | 47 +- .../DeliveryApi/ApiRichTextElementParser.cs | 87 +- .../DeliveryApi/ApiRichTextMarkupParser.cs | 23 + .../UmbracoBuilder.CoreServices.cs | 1 + .../DeliveryApiBlockReferenceExtensions.cs | 16 + .../Models/Blocks/BlockEditorDataConverter.cs | 18 +- .../Models/Blocks/RichTextBlockLayoutItem.cs | 21 + .../RichTextEditorBlockDataConverter.cs | 20 + .../Models/RichTextEditorValue.cs | 14 + .../BlockEditorPropertyValueEditor.cs | 151 +-- .../PropertyEditors/BlockEditorValidator.cs | 50 +- .../BlockEditorValidatorBase.cs | 51 + .../PropertyEditors/BlockEditorValues.cs | 10 + .../BlockValuePropertyValueEditorBase.cs | 185 ++++ .../RichTextEditorBlockValidator.cs | 40 + .../PropertyEditors/RichTextPropertyEditor.cs | 250 +++-- .../RichTextPropertyEditorHelper.cs | 62 ++ .../RichTextPropertyIndexValueFactory.cs | 68 ++ .../BlockGridPropertyValueConverter.cs | 82 +- .../BlockGridPropertyValueCreator.cs | 68 ++ .../BlockListPropertyValueConverter.cs | 49 +- .../BlockListPropertyValueCreator.cs | 35 + .../BlockPropertyValueConverterBase.cs | 1 + .../BlockPropertyValueCreatorBase.cs | 266 +++++ .../RichTextBlockPropertyValueCreator.cs | 38 + .../ValueConverters/RichTextParsingRegexes.cs | 9 + .../RteMacroRenderingValueConverter.cs | 131 ++- .../Blocks/PartialViewBlockEngine.cs | 74 ++ .../UmbracoBuilderExtensions.cs | 3 + src/Umbraco.Web.UI.Client/gulp/config.js | 3 +- .../components/grid/grid.rte.directive.js | 8 +- .../validation/valservermatch.directive.js | 22 +- .../blockeditormodelobject.service.js | 47 +- .../src/common/services/tinymce.service.js | 198 +++- .../src/less/rte-content.less | 8 + src/Umbraco.Web.UI.Client/src/less/rte.less | 11 + .../umbBlockListPropertyEditor.component.js | 24 +- .../blocklist/umbblocklistblock.component.js | 2 +- .../views/propertyeditors/rte/blockrteui.less | 114 +++ .../labelblock/rtelabelblock.editor.html | 58 ++ .../unsupportedblock.editor.html | 59 ++ .../blockrte.blockconfiguration.controller.js | 246 +++++ .../prevalue/blockrte.blockconfiguration.html | 25 + ...e.blockconfiguration.overlay.controller.js | 314 ++++++ .../blockrte.blockconfiguration.overlay.html | 281 ++++++ .../blockrte.blockconfiguration.overlay.less | 103 ++ .../rte/blocks/umb-rte-block.component.js | 127 +++ .../propertyeditors/rte/rte.component.js | 955 ++++++++++++++++++ .../propertyeditors/rte/rte.controller.js | 133 --- .../src/views/propertyeditors/rte/rte.html | 14 +- .../rte/rte.prevalues.controller.js | 10 +- .../rte/umb-rte-property-editor.html | 10 + .../propertyeditors/rte-controller.spec.js | 11 +- .../RichTextPropertyEditorTests.cs | 179 ++++ .../DeliveryApi/RichTextParserTests.cs | 190 +++- .../BlockGridPropertyValueConverterTests.cs | 48 + .../BlockListPropertyValueConverterTests.cs | 104 +- .../BlockPropertyValueConverterTestsBase.cs | 64 ++ .../RichTextPropertyEditorHelperTests.cs | 178 ++++ 71 files changed, 4945 insertions(+), 694 deletions(-) create mode 100644 src/Umbraco.Core/Blocks/IPartialViewBlockEngine.cs create mode 100644 src/Umbraco.Core/Models/Blocks/RichTextBlockItem.cs create mode 100644 src/Umbraco.Core/Models/Blocks/RichTextBlockModel.cs create mode 100644 src/Umbraco.Core/Models/DeliveryApi/RichTextRootElement.cs create mode 100644 src/Umbraco.Core/PropertyEditors/IRichTextPropertyIndexValueFactory.cs create mode 100644 src/Umbraco.Infrastructure/Extensions/DeliveryApiBlockReferenceExtensions.cs create mode 100644 src/Umbraco.Infrastructure/Models/Blocks/RichTextBlockLayoutItem.cs create mode 100644 src/Umbraco.Infrastructure/Models/Blocks/RichTextEditorBlockDataConverter.cs create mode 100644 src/Umbraco.Infrastructure/Models/RichTextEditorValue.cs create mode 100644 src/Umbraco.Infrastructure/PropertyEditors/BlockEditorValidatorBase.cs create mode 100644 src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs create mode 100644 src/Umbraco.Infrastructure/PropertyEditors/RichTextEditorBlockValidator.cs create mode 100644 src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditorHelper.cs create mode 100644 src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyIndexValueFactory.cs create mode 100644 src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockGridPropertyValueCreator.cs create mode 100644 src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockListPropertyValueCreator.cs create mode 100644 src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockPropertyValueCreatorBase.cs create mode 100644 src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RichTextBlockPropertyValueCreator.cs create mode 100644 src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RichTextParsingRegexes.cs create mode 100644 src/Umbraco.Web.Common/Blocks/PartialViewBlockEngine.cs create mode 100644 src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blockrteui.less create mode 100644 src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blocks/blockrteentryeditors/labelblock/rtelabelblock.editor.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blocks/blockrteentryeditors/unsupportedblock/unsupportedblock.editor.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blocks/prevalue/blockrte.blockconfiguration.controller.js create mode 100644 src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blocks/prevalue/blockrte.blockconfiguration.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blocks/prevalue/blockrte.blockconfiguration.overlay.controller.js create mode 100644 src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blocks/prevalue/blockrte.blockconfiguration.overlay.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blocks/prevalue/blockrte.blockconfiguration.overlay.less create mode 100644 src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blocks/umb-rte-block.component.js create mode 100644 src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.component.js delete mode 100644 src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.controller.js create mode 100644 src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/umb-rte-property-editor.html create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditorTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockGridPropertyValueConverterTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockPropertyValueConverterTestsBase.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/RichTextPropertyEditorHelperTests.cs diff --git a/src/Umbraco.Cms.Api.Delivery/Json/DeliveryApiJsonTypeResolver.cs b/src/Umbraco.Cms.Api.Delivery/Json/DeliveryApiJsonTypeResolver.cs index 8ffcd00d67..10f052485a 100644 --- a/src/Umbraco.Cms.Api.Delivery/Json/DeliveryApiJsonTypeResolver.cs +++ b/src/Umbraco.Cms.Api.Delivery/Json/DeliveryApiJsonTypeResolver.cs @@ -22,7 +22,7 @@ public class DeliveryApiJsonTypeResolver : DefaultJsonTypeInfoResolver } else if (jsonTypeInfo.Type == typeof(IRichTextElement)) { - ConfigureJsonPolymorphismOptions(jsonTypeInfo, typeof(RichTextGenericElement), typeof(RichTextTextElement)); + ConfigureJsonPolymorphismOptions(jsonTypeInfo, typeof(RichTextRootElement), typeof(RichTextGenericElement), typeof(RichTextTextElement)); } return jsonTypeInfo; diff --git a/src/Umbraco.Core/Blocks/IPartialViewBlockEngine.cs b/src/Umbraco.Core/Blocks/IPartialViewBlockEngine.cs new file mode 100644 index 0000000000..3b462d0865 --- /dev/null +++ b/src/Umbraco.Core/Blocks/IPartialViewBlockEngine.cs @@ -0,0 +1,9 @@ +using Umbraco.Cms.Core.Models.Blocks; +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Core.Blocks; + +public interface IPartialViewBlockEngine +{ + Task ExecuteAsync(IBlockReference blockReference); +} diff --git a/src/Umbraco.Core/Configuration/Models/RichTextEditorSettings.cs b/src/Umbraco.Core/Configuration/Models/RichTextEditorSettings.cs index 006c590163..fce3b36373 100644 --- a/src/Umbraco.Core/Configuration/Models/RichTextEditorSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/RichTextEditorSettings.cs @@ -82,6 +82,10 @@ public class RichTextEditorSettings { Alias = "umbmediapicker", Name = "Image", Mode = RichTextEditorCommandMode.Insert, }, + new RichTextEditorCommand + { + Alias = "umbblockpicker", Name = "Block", Mode = RichTextEditorCommandMode.All, + }, new RichTextEditorCommand { Alias = "umbmacro", Name = "Macro", Mode = RichTextEditorCommandMode.All }, new RichTextEditorCommand { Alias = "table", Name = "Table", Mode = RichTextEditorCommandMode.Insert }, new RichTextEditorCommand diff --git a/src/Umbraco.Core/DeliveryApi/IApiRichTextElementParser.cs b/src/Umbraco.Core/DeliveryApi/IApiRichTextElementParser.cs index 067cdf068d..50b9b5d581 100644 --- a/src/Umbraco.Core/DeliveryApi/IApiRichTextElementParser.cs +++ b/src/Umbraco.Core/DeliveryApi/IApiRichTextElementParser.cs @@ -1,8 +1,13 @@ -using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.Blocks; +using Umbraco.Cms.Core.Models.DeliveryApi; namespace Umbraco.Cms.Core.DeliveryApi; public interface IApiRichTextElementParser { + // NOTE: remember to also remove the default implementation of the method overload when this one is removed. + [Obsolete($"Please use the overload that accepts {nameof(RichTextBlockModel)}. Will be removed in V15.")] IRichTextElement? Parse(string html); + + IRichTextElement? Parse(string html, RichTextBlockModel? richTextBlockModel) => null; } diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/da.xml b/src/Umbraco.Core/EmbeddedResources/Lang/da.xml index a4217a3525..128ed70229 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/da.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/da.xml @@ -2320,6 +2320,8 @@ Mange hilsner fra Umbraco robotten Konfigurer område Slet område Tilføj mulighed for %0% koloner + Indsæt Blok + Vis på linje med tekst Hvad er Indholdsskabeloner? diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml index 6047de2b10..9d1b110b8a 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml @@ -2883,6 +2883,8 @@ To manage your website, simply open the Umbraco backoffice and start adding cont Configure area Delete area Add spanning %0% columns option + Insert Block + Display inline with text What are Content Templates? diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml b/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml index c55b78a38b..77272aa79b 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml @@ -2997,6 +2997,8 @@ To manage your website, simply open the Umbraco backoffice and start adding cont Configure area Delete area Add spanning %0% columns option + Insert Block + Display inline with text What are Content Templates? diff --git a/src/Umbraco.Core/Models/Blocks/RichTextBlockItem.cs b/src/Umbraco.Core/Models/Blocks/RichTextBlockItem.cs new file mode 100644 index 0000000000..f5be6f9e23 --- /dev/null +++ b/src/Umbraco.Core/Models/Blocks/RichTextBlockItem.cs @@ -0,0 +1,129 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using System.Runtime.Serialization; +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Core.Models.Blocks; + +/// +/// Represents a layout item for the Block List editor. +/// +/// +[DataContract(Name = "block", Namespace = "")] +public class RichTextBlockItem : IBlockReference +{ + /// + /// Initializes a new instance of the class. + /// + /// The content UDI. + /// The content. + /// The settings UDI. + /// The settings. + /// + /// contentUdi + /// or + /// content + /// + public RichTextBlockItem(Udi contentUdi, IPublishedElement content, Udi settingsUdi, IPublishedElement settings) + { + ContentUdi = contentUdi ?? throw new ArgumentNullException(nameof(contentUdi)); + Content = content ?? throw new ArgumentNullException(nameof(content)); + SettingsUdi = settingsUdi; + Settings = settings; + } + + /// + /// Gets the content. + /// + /// + /// The content. + /// + [DataMember(Name = "content")] + public IPublishedElement Content { get; } + + /// + /// Gets the settings UDI. + /// + /// + /// The settings UDI. + /// + [DataMember(Name = "settingsUdi")] + public Udi SettingsUdi { get; } + + /// + /// Gets the content UDI. + /// + /// + /// The content UDI. + /// + [DataMember(Name = "contentUdi")] + public Udi ContentUdi { get; } + + /// + /// Gets the settings. + /// + /// + /// The settings. + /// + [DataMember(Name = "settings")] + public IPublishedElement Settings { get; } +} + +/// +/// Represents a layout item with a generic content type for the Block List editor. +/// +/// The type of the content. +/// +public class RichTextBlockItem : RichTextBlockItem + where T : IPublishedElement +{ + /// + /// Initializes a new instance of the class. + /// + /// The content UDI. + /// The content. + /// The settings UDI. + /// The settings. + public RichTextBlockItem(Udi contentUdi, T content, Udi settingsUdi, IPublishedElement settings) + : base(contentUdi, content, settingsUdi, settings) => + Content = content; + + /// + /// Gets the content. + /// + /// + /// The content. + /// + public new T Content { get; } +} + +/// +/// Represents a layout item with generic content and settings types for the Block List editor. +/// +/// The type of the content. +/// The type of the settings. +/// +public class RichTextBlockItem : RichTextBlockItem + where TContent : IPublishedElement + where TSettings : IPublishedElement +{ + /// + /// Initializes a new instance of the class. + /// + /// The content udi. + /// The content. + /// The settings udi. + /// The settings. + public RichTextBlockItem(Udi contentUdi, TContent content, Udi settingsUdi, TSettings settings) + : base(contentUdi, content, settingsUdi, settings) => + Settings = settings; + + /// + /// Gets the settings. + /// + /// + /// The settings. + /// + public new TSettings Settings { get; } +} diff --git a/src/Umbraco.Core/Models/Blocks/RichTextBlockModel.cs b/src/Umbraco.Core/Models/Blocks/RichTextBlockModel.cs new file mode 100644 index 0000000000..76ed496684 --- /dev/null +++ b/src/Umbraco.Core/Models/Blocks/RichTextBlockModel.cs @@ -0,0 +1,38 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using System.Runtime.Serialization; + +namespace Umbraco.Cms.Core.Models.Blocks; + +/// +/// The strongly typed model for blocks in the Rich Text editor. +/// +[DataContract(Name = "richTextEditorBlocks", Namespace = "")] +public class RichTextBlockModel : BlockModelCollection +{ + /// + /// Initializes a new instance of the class. + /// + /// The list to wrap. + public RichTextBlockModel(IList list) + : base(list) + { + } + + /// + /// Prevents a default instance of the class from being created. + /// + private RichTextBlockModel() + : this(new List()) + { + } + + /// + /// Gets the empty . + /// + /// + /// The empty . + /// + public static RichTextBlockModel Empty { get; } = new(); +} diff --git a/src/Umbraco.Core/Models/DeliveryApi/RichTextModel.cs b/src/Umbraco.Core/Models/DeliveryApi/RichTextModel.cs index 2af7570183..6280343c03 100644 --- a/src/Umbraco.Core/Models/DeliveryApi/RichTextModel.cs +++ b/src/Umbraco.Core/Models/DeliveryApi/RichTextModel.cs @@ -3,4 +3,8 @@ public class RichTextModel { public required string Markup { get; set; } + + public required IEnumerable Blocks { get; set; } + + public static RichTextModel Empty() => new() { Markup = string.Empty, Blocks = Array.Empty() }; } diff --git a/src/Umbraco.Core/Models/DeliveryApi/RichTextRootElement.cs b/src/Umbraco.Core/Models/DeliveryApi/RichTextRootElement.cs new file mode 100644 index 0000000000..8174d288d2 --- /dev/null +++ b/src/Umbraco.Core/Models/DeliveryApi/RichTextRootElement.cs @@ -0,0 +1,19 @@ +namespace Umbraco.Cms.Core.Models.DeliveryApi; + +public sealed class RichTextRootElement : IRichTextElement +{ + public RichTextRootElement(Dictionary attributes, IEnumerable elements, IEnumerable blocks) + { + Attributes = attributes; + Elements = elements; + Blocks = blocks; + } + + public string Tag => "#root"; + + public Dictionary Attributes { get; } + + public IEnumerable Elements { get; } + + public IEnumerable Blocks { get; } +} diff --git a/src/Umbraco.Core/PropertyEditors/IRichTextPropertyIndexValueFactory.cs b/src/Umbraco.Core/PropertyEditors/IRichTextPropertyIndexValueFactory.cs new file mode 100644 index 0000000000..f48f7ad254 --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/IRichTextPropertyIndexValueFactory.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Core.PropertyEditors; + +public interface IRichTextPropertyIndexValueFactory : IPropertyIndexValueFactory +{ +} diff --git a/src/Umbraco.Core/PropertyEditors/RichTextConfiguration.cs b/src/Umbraco.Core/PropertyEditors/RichTextConfiguration.cs index 6a80144d0d..3c028b9a39 100644 --- a/src/Umbraco.Core/PropertyEditors/RichTextConfiguration.cs +++ b/src/Umbraco.Core/PropertyEditors/RichTextConfiguration.cs @@ -1,3 +1,5 @@ +using System.Runtime.Serialization; + namespace Umbraco.Cms.Core.PropertyEditors; /// @@ -9,7 +11,13 @@ public class RichTextConfiguration : IIgnoreUserStartNodesConfig [ConfigurationField("editor", "Editor", "views/propertyeditors/rte/rte.prevalues.html", HideLabel = true)] public object? Editor { get; set; } - [ConfigurationField("overlaySize", "Overlay Size", "overlaysize", Description = "Select the width of the overlay (link picker).")] + [ConfigurationField("blocks", "Available Blocks", "views/propertyeditors/rte/blocks/prevalue/blockrte.blockconfiguration.html", Description = "Define the available blocks.")] + public RichTextBlockConfiguration[]? Blocks { get; set; } = null!; + + [ConfigurationField("useLiveEditing", "Blocks Live editing mode", "boolean", Description = "Live updated Block Elements when they are edited.")] + public bool UseLiveEditing { get; set; } + + [ConfigurationField("overlaySize", "Overlay Size", "overlaysize", Description = "Select the width of the link picker overlay.")] public string? OverlaySize { get; set; } [ConfigurationField("hideLabel", "Hide Label", "boolean")] @@ -24,4 +32,41 @@ public class RichTextConfiguration : IIgnoreUserStartNodesConfig "boolean", Description = "Selecting this option allows a user to choose nodes that they normally don't have access to.")] public bool IgnoreUserStartNodes { get; set; } + + [DataContract] + public class RichTextBlockConfiguration : IBlockConfiguration + { + [DataMember(Name = "backgroundColor")] + public string? BackgroundColor { get; set; } + + [DataMember(Name = "iconColor")] + public string? IconColor { get; set; } + + [DataMember(Name = "thumbnail")] + public string? Thumbnail { get; set; } + + [DataMember(Name = "contentElementTypeKey")] + public Guid ContentElementTypeKey { get; set; } + + [DataMember(Name = "settingsElementTypeKey")] + public Guid? SettingsElementTypeKey { get; set; } + + [DataMember(Name = "view")] + public string? View { get; set; } + + [DataMember(Name = "stylesheet")] + public string? Stylesheet { get; set; } + + [DataMember(Name = "label")] + public string? Label { get; set; } + + [DataMember(Name = "editorSize")] + public string? EditorSize { get; set; } + + [DataMember(Name = "forceHideContentEditorInOverlay")] + public bool ForceHideContentEditorInOverlay { get; set; } + + [DataMember(Name = "displayInline")] + public bool DisplayInline { get; set; } + } } diff --git a/src/Umbraco.Infrastructure/DeliveryApi/ApiRichTextElementParser.cs b/src/Umbraco.Infrastructure/DeliveryApi/ApiRichTextElementParser.cs index c40debd690..eeb279e1b7 100644 --- a/src/Umbraco.Infrastructure/DeliveryApi/ApiRichTextElementParser.cs +++ b/src/Umbraco.Infrastructure/DeliveryApi/ApiRichTextElementParser.cs @@ -1,9 +1,14 @@ using HtmlAgilityPack; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Models.Blocks; using Umbraco.Cms.Core.Models.DeliveryApi; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Infrastructure.Extensions; using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.DeliveryApi; @@ -11,29 +16,51 @@ namespace Umbraco.Cms.Infrastructure.DeliveryApi; internal sealed class ApiRichTextElementParser : ApiRichTextParserBase, IApiRichTextElementParser { private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; + private readonly IApiElementBuilder _apiElementBuilder; private readonly ILogger _logger; private const string TextNodeName = "#text"; + private const string CommentNodeName = "#comment"; + [Obsolete($"Please use the constructor that accepts {nameof(IApiElementBuilder)}. Will be removed in V15.")] public ApiRichTextElementParser( IApiContentRouteBuilder apiContentRouteBuilder, IPublishedUrlProvider publishedUrlProvider, IPublishedSnapshotAccessor publishedSnapshotAccessor, ILogger logger) + : this( + apiContentRouteBuilder, + publishedUrlProvider, + publishedSnapshotAccessor, + StaticServiceProvider.Instance.GetRequiredService(), + logger) + { + } + + public ApiRichTextElementParser( + IApiContentRouteBuilder apiContentRouteBuilder, + IPublishedUrlProvider publishedUrlProvider, + IPublishedSnapshotAccessor publishedSnapshotAccessor, + IApiElementBuilder apiElementBuilder, + ILogger logger) : base(apiContentRouteBuilder, publishedUrlProvider) { _publishedSnapshotAccessor = publishedSnapshotAccessor; + _apiElementBuilder = apiElementBuilder; _logger = logger; } - public IRichTextElement? Parse(string html) + [Obsolete($"Please use the overload that accepts {nameof(RichTextBlockModel)}. Will be removed in V15.")] + public IRichTextElement? Parse(string html) => Parse(html, null); + + public IRichTextElement? Parse(string html, RichTextBlockModel? richTextBlockModel) { try { IPublishedSnapshot publishedSnapshot = _publishedSnapshotAccessor.GetRequiredPublishedSnapshot(); var doc = new HtmlDocument(); doc.LoadHtml(html); - return ParseRecursively(doc.DocumentNode, publishedSnapshot); + return ParseRootElement(doc.DocumentNode, publishedSnapshot, richTextBlockModel); } catch (Exception ex) { @@ -44,8 +71,8 @@ internal sealed class ApiRichTextElementParser : ApiRichTextParserBase, IApiRich private IRichTextElement ParseRecursively(HtmlNode current, IPublishedSnapshot publishedSnapshot) => current.Name == TextNodeName - ? ParseTextElement(current) - : ParseElement(current, publishedSnapshot); + ? ParseTextElement(current) + : ParseGenericElement(current, publishedSnapshot); private RichTextTextElement ParseTextElement(HtmlNode element) { @@ -57,16 +84,40 @@ internal sealed class ApiRichTextElementParser : ApiRichTextParserBase, IApiRich return new RichTextTextElement(element.InnerText); } - private RichTextGenericElement ParseElement(HtmlNode element, IPublishedSnapshot publishedSnapshot) + private RichTextRootElement ParseRootElement(HtmlNode element, IPublishedSnapshot publishedSnapshot, RichTextBlockModel? richTextBlockModel) + { + ApiBlockItem[] blocks = richTextBlockModel is not null + ? richTextBlockModel.Select(item => item.CreateApiBlockItem(_apiElementBuilder)).ToArray() + : Array.Empty(); + + return ParseElement( + element, + publishedSnapshot, + (_, attributes, childElements) => new RichTextRootElement(attributes, childElements, blocks)); + } + + private RichTextGenericElement ParseGenericElement(HtmlNode element, IPublishedSnapshot publishedSnapshot) { if (element.Name == TextNodeName) { throw new ArgumentException($"{TextNodeName} elements should be handled by {nameof(ParseTextElement)}"); } - // grab all non-#text nodes + all non-empty #text nodes as valid node children + return ParseElement( + element, + publishedSnapshot, + (tag, attributes, childElements) => new RichTextGenericElement(tag, attributes, childElements)); + } + + private T ParseElement(HtmlNode element, IPublishedSnapshot publishedSnapshot, Func, IRichTextElement[], T> createElement) + where T : IRichTextElement + { + // grab all valid node children: + // - non-#comment nodes + // - non-#text nodes + // - non-empty #text nodes HtmlNode[] childNodes = element.ChildNodes - .Where(c => c.Name != TextNodeName || string.IsNullOrWhiteSpace(c.InnerText) is false) + .Where(c => c.Name != CommentNodeName && (c.Name != TextNodeName || string.IsNullOrWhiteSpace(c.InnerText) is false)) .ToArray(); var tag = TagName(element); @@ -76,16 +127,18 @@ internal sealed class ApiRichTextElementParser : ApiRichTextParserBase, IApiRich ReplaceLocalImages(publishedSnapshot, tag, attributes); + CleanUpBlocks(tag, attributes); + SanitizeAttributes(attributes); IRichTextElement[] childElements = childNodes.Any() ? childNodes.Select(child => ParseRecursively(child, publishedSnapshot)).ToArray() : Array.Empty(); - return new RichTextGenericElement(tag, attributes, childElements); + return createElement(tag, attributes, childElements); } - private string TagName(HtmlNode htmlNode) => htmlNode.Name == "#document" ? "#root" : htmlNode.Name; + private string TagName(HtmlNode htmlNode) => htmlNode.Name; private void ReplaceLocalLinks(IPublishedSnapshot publishedSnapshot, Dictionary attributes) { @@ -120,6 +173,22 @@ internal sealed class ApiRichTextElementParser : ApiRichTextParserBase, IApiRich }); } + private void CleanUpBlocks(string tag, Dictionary attributes) + { + if (tag.StartsWith("umb-rte-block") is false || attributes.ContainsKey("data-content-udi") is false || attributes["data-content-udi"] is not string dataUdi) + { + return; + } + + if (UdiParser.TryParse(dataUdi, out GuidUdi? guidUdi) is false) + { + return; + } + + attributes["content-id"] = guidUdi.Guid; + attributes.Remove("data-content-udi"); + } + private static void SanitizeAttributes(Dictionary attributes) { KeyValuePair[] dataAttributes = attributes diff --git a/src/Umbraco.Infrastructure/DeliveryApi/ApiRichTextMarkupParser.cs b/src/Umbraco.Infrastructure/DeliveryApi/ApiRichTextMarkupParser.cs index 04344905e4..f7eeba0f18 100644 --- a/src/Umbraco.Infrastructure/DeliveryApi/ApiRichTextMarkupParser.cs +++ b/src/Umbraco.Infrastructure/DeliveryApi/ApiRichTextMarkupParser.cs @@ -1,5 +1,6 @@ using HtmlAgilityPack; using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Routing; @@ -35,6 +36,8 @@ internal sealed class ApiRichTextMarkupParser : ApiRichTextParserBase, IApiRichT ReplaceLocalImages(doc, publishedSnapshot); + CleanUpBlocks(doc); + return doc.DocumentNode.InnerHtml; } catch (Exception ex) @@ -91,4 +94,24 @@ internal sealed class ApiRichTextMarkupParser : ApiRichTextParserBase, IApiRichT }); } } + + private void CleanUpBlocks(HtmlDocument doc) + { + HtmlNode[] blocks = doc.DocumentNode.SelectNodes("//*[starts-with(local-name(),'umb-rte-block')]")?.ToArray() ?? Array.Empty(); + foreach (HtmlNode block in blocks) + { + var dataUdi = block.GetAttributeValue("data-content-udi", string.Empty); + if (UdiParser.TryParse(dataUdi, out GuidUdi? guidUdi) is false) + { + continue; + } + + // swap the content UDI for the content ID + block.Attributes.Remove("data-content-udi"); + block.SetAttributeValue("data-content-id", guidUdi.Guid.ToString("D")); + + // remove the inner comment placed by the RTE + block.RemoveAllChildren(); + } + } } diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs index 1c21352af2..373c4a2008 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs @@ -240,6 +240,7 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); return builder; } diff --git a/src/Umbraco.Infrastructure/Extensions/DeliveryApiBlockReferenceExtensions.cs b/src/Umbraco.Infrastructure/Extensions/DeliveryApiBlockReferenceExtensions.cs new file mode 100644 index 0000000000..70ecdaee51 --- /dev/null +++ b/src/Umbraco.Infrastructure/Extensions/DeliveryApiBlockReferenceExtensions.cs @@ -0,0 +1,16 @@ +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.Blocks; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Infrastructure.Extensions; + +internal static class DeliveryApiBlockReferenceExtensions +{ + internal static ApiBlockItem CreateApiBlockItem( + this IBlockReference blockItem, + IApiElementBuilder apiElementBuilder) + => new ApiBlockItem( + apiElementBuilder.Build(blockItem.Content), + blockItem.Settings != null ? apiElementBuilder.Build(blockItem.Settings) : null); +} diff --git a/src/Umbraco.Infrastructure/Models/Blocks/BlockEditorDataConverter.cs b/src/Umbraco.Infrastructure/Models/Blocks/BlockEditorDataConverter.cs index 2f2c7ae1ec..350ce31ab2 100644 --- a/src/Umbraco.Infrastructure/Models/Blocks/BlockEditorDataConverter.cs +++ b/src/Umbraco.Infrastructure/Models/Blocks/BlockEditorDataConverter.cs @@ -43,15 +43,7 @@ public abstract class BlockEditorDataConverter return Convert(value); } - /// - /// Return the collection of from the block editor's Layout (which could be an array or - /// an object depending on the editor) - /// - /// - /// - protected abstract IEnumerable? GetBlockReferences(JToken jsonLayout); - - private BlockEditorData Convert(BlockValue? value) + public BlockEditorData Convert(BlockValue? value) { if (value?.Layout == null) { @@ -65,4 +57,12 @@ public abstract class BlockEditorDataConverter return new BlockEditorData(_propertyEditorAlias, references!, value); } + + /// + /// Return the collection of from the block editor's Layout (which could be an array or + /// an object depending on the editor) + /// + /// + /// + protected abstract IEnumerable? GetBlockReferences(JToken jsonLayout); } diff --git a/src/Umbraco.Infrastructure/Models/Blocks/RichTextBlockLayoutItem.cs b/src/Umbraco.Infrastructure/Models/Blocks/RichTextBlockLayoutItem.cs new file mode 100644 index 0000000000..93179e82ed --- /dev/null +++ b/src/Umbraco.Infrastructure/Models/Blocks/RichTextBlockLayoutItem.cs @@ -0,0 +1,21 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Newtonsoft.Json; +using Umbraco.Cms.Infrastructure.Serialization; + +namespace Umbraco.Cms.Core.Models.Blocks; + +/// +/// Used for deserializing the rich text block layouts +/// +public class RichTextBlockLayoutItem : IBlockLayoutItem +{ + [JsonProperty("contentUdi", Required = Required.Always)] + [JsonConverter(typeof(UdiJsonConverter))] + public Udi? ContentUdi { get; set; } + + [JsonProperty("settingsUdi", NullValueHandling = NullValueHandling.Ignore)] + [JsonConverter(typeof(UdiJsonConverter))] + public Udi? SettingsUdi { get; set; } +} diff --git a/src/Umbraco.Infrastructure/Models/Blocks/RichTextEditorBlockDataConverter.cs b/src/Umbraco.Infrastructure/Models/Blocks/RichTextEditorBlockDataConverter.cs new file mode 100644 index 0000000000..e47fd82a52 --- /dev/null +++ b/src/Umbraco.Infrastructure/Models/Blocks/RichTextEditorBlockDataConverter.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json.Linq; + +namespace Umbraco.Cms.Core.Models.Blocks; + +/// +/// Data converter for blocks in the richtext property editor +/// +internal sealed class RichTextEditorBlockDataConverter : BlockEditorDataConverter +{ + public RichTextEditorBlockDataConverter() + : base(Constants.PropertyEditors.Aliases.TinyMce) + { + } + + protected override IEnumerable? GetBlockReferences(JToken jsonLayout) + { + IEnumerable? blockListLayout = jsonLayout.ToObject>(); + return blockListLayout?.Select(x => new ContentAndSettingsReference(x.ContentUdi, x.SettingsUdi)).ToList(); + } +} diff --git a/src/Umbraco.Infrastructure/Models/RichTextEditorValue.cs b/src/Umbraco.Infrastructure/Models/RichTextEditorValue.cs new file mode 100644 index 0000000000..11754ccc3b --- /dev/null +++ b/src/Umbraco.Infrastructure/Models/RichTextEditorValue.cs @@ -0,0 +1,14 @@ +using System.Runtime.Serialization; +using Umbraco.Cms.Core.Models.Blocks; + +namespace Umbraco.Cms.Core; + +[DataContract] +public class RichTextEditorValue +{ + [DataMember(Name = "markup")] + public required string Markup { get; set; } + + [DataMember(Name = "blocks")] + public required BlockValue? Blocks { get; set; } +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyValueEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyValueEditor.cs index c524c2c39b..b8d9a6c468 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyValueEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyValueEditor.cs @@ -13,12 +13,9 @@ using Umbraco.Cms.Core.Strings; namespace Umbraco.Cms.Core.PropertyEditors; -internal abstract class BlockEditorPropertyValueEditor : DataValueEditor, IDataValueReference, IDataValueTags +internal abstract class BlockEditorPropertyValueEditor : BlockValuePropertyValueEditorBase { private BlockEditorValues? _blockEditorValues; - private readonly IDataTypeService _dataTypeService; - private readonly ILogger _logger; - private readonly PropertyEditorCollection _propertyEditors; protected BlockEditorPropertyValueEditor( DataEditorAttribute attribute, @@ -29,11 +26,8 @@ internal abstract class BlockEditorPropertyValueEditor : DataValueEditor, IDataV IShortStringHelper shortStringHelper, IJsonSerializer jsonSerializer, IIOHelper ioHelper) - : base(textService, shortStringHelper, jsonSerializer, ioHelper, attribute) + : base(attribute, propertyEditors, dataTypeService, textService, logger, shortStringHelper, jsonSerializer, ioHelper) { - _propertyEditors = propertyEditors; - _dataTypeService = dataTypeService; - _logger = logger; } protected BlockEditorValues BlockEditorValues @@ -42,7 +36,8 @@ internal abstract class BlockEditorPropertyValueEditor : DataValueEditor, IDataV set => _blockEditorValues = value; } - public IEnumerable GetReferences(object? value) + /// + public override IEnumerable GetReferences(object? value) { var rawJson = value == null ? string.Empty : value is string str ? str : value.ToString(); @@ -53,32 +48,11 @@ internal abstract class BlockEditorPropertyValueEditor : DataValueEditor, IDataV return Enumerable.Empty(); } - // loop through all content and settings data - foreach (BlockItemData row in blockEditorData.BlockValue.ContentData.Concat(blockEditorData.BlockValue.SettingsData)) - { - foreach (KeyValuePair prop in row.PropertyValues) - { - IDataEditor? propEditor = _propertyEditors[prop.Value.PropertyType.PropertyEditorAlias]; - - IDataValueEditor? valueEditor = propEditor?.GetValueEditor(); - if (!(valueEditor is IDataValueReference reference)) - { - continue; - } - - var val = prop.Value.Value?.ToString(); - - IEnumerable refs = reference.GetReferences(val); - - result.AddRange(refs); - } - } - - return result; + return GetBlockValueReferences(blockEditorData.BlockValue); } /// - public IEnumerable GetTags(object? value, object? dataTypeConfiguration, int? languageId) + public override IEnumerable GetTags(object? value, object? dataTypeConfiguration, int? languageId) { var rawJson = value == null ? string.Empty : value is string str ? str : value.ToString(); @@ -88,31 +62,9 @@ internal abstract class BlockEditorPropertyValueEditor : DataValueEditor, IDataV return Enumerable.Empty(); } - var result = new List(); - // loop through all content and settings data - foreach (BlockItemData row in blockEditorData.BlockValue.ContentData.Concat(blockEditorData.BlockValue.SettingsData)) - { - foreach (KeyValuePair prop in row.PropertyValues) - { - IDataEditor? propEditor = _propertyEditors[prop.Value.PropertyType.PropertyEditorAlias]; - - IDataValueEditor? valueEditor = propEditor?.GetValueEditor(); - if (valueEditor is not IDataValueTags tagsProvider) - { - continue; - } - - object? configuration = _dataTypeService.GetDataType(prop.Value.PropertyType.DataTypeKey)?.Configuration; - - result.AddRange(tagsProvider.GetTags(prop.Value.Value, configuration, languageId)); - } - } - - return result; + return GetBlockValueTags(blockEditorData.BlockValue, languageId); } - #region Convert database // editor - // note: there is NO variant support here /// @@ -142,8 +94,7 @@ internal abstract class BlockEditorPropertyValueEditor : DataValueEditor, IDataV return string.Empty; } - MapBlockItemDataToEditor(property, blockEditorData.BlockValue.ContentData); - MapBlockItemDataToEditor(property, blockEditorData.BlockValue.SettingsData); + MapBlockValueToEditor(property, blockEditorData.BlockValue); // return json convertable object return blockEditorData.BlockValue; @@ -178,93 +129,9 @@ internal abstract class BlockEditorPropertyValueEditor : DataValueEditor, IDataV return string.Empty; } - MapBlockItemDataFromEditor(blockEditorData.BlockValue.ContentData); - MapBlockItemDataFromEditor(blockEditorData.BlockValue.SettingsData); + MapBlockValueFromEditor(blockEditorData.BlockValue); // return json return JsonConvert.SerializeObject(blockEditorData.BlockValue, Formatting.None); } - - private void MapBlockItemDataToEditor(IProperty property, List items) - { - var valEditors = new Dictionary(); - - foreach (BlockItemData row in items) - { - foreach (KeyValuePair prop in row.PropertyValues) - { - // create a temp property with the value - // - force it to be culture invariant as the block editor can't handle culture variant element properties - prop.Value.PropertyType.Variations = ContentVariation.Nothing; - var tempProp = new Property(prop.Value.PropertyType); - tempProp.SetValue(prop.Value.Value); - - IDataEditor? propEditor = _propertyEditors[prop.Value.PropertyType.PropertyEditorAlias]; - if (propEditor == null) - { - // NOTE: This logic was borrowed from Nested Content and I'm unsure why it exists. - // if the property editor doesn't exist I think everything will break anyways? - // update the raw value since this is what will get serialized out - row.RawPropertyValues[prop.Key] = tempProp.GetValue()?.ToString(); - continue; - } - - IDataType? dataType = _dataTypeService.GetDataType(prop.Value.PropertyType.DataTypeId); - if (dataType == null) - { - // deal with weird situations by ignoring them (no comment) - row.PropertyValues.Remove(prop.Key); - _logger.LogWarning( - "ToEditor removed property value {PropertyKey} in row {RowId} for property type {PropertyTypeAlias}", - prop.Key, - row.Key, - property.PropertyType.Alias); - continue; - } - - if (!valEditors.TryGetValue(dataType.Id, out IDataValueEditor? valEditor)) - { - var tempConfig = dataType.Configuration; - valEditor = propEditor.GetValueEditor(tempConfig); - - valEditors.Add(dataType.Id, valEditor); - } - - var convValue = valEditor.ToEditor(tempProp); - - // update the raw value since this is what will get serialized out - row.RawPropertyValues[prop.Key] = convValue; - } - } - } - - private void MapBlockItemDataFromEditor(List items) - { - foreach (BlockItemData row in items) - { - foreach (KeyValuePair prop in row.PropertyValues) - { - // Fetch the property types prevalue - var propConfiguration = _dataTypeService.GetDataType(prop.Value.PropertyType.DataTypeId)?.Configuration; - - // Lookup the property editor - IDataEditor? propEditor = _propertyEditors[prop.Value.PropertyType.PropertyEditorAlias]; - if (propEditor == null) - { - continue; - } - - // Create a fake content property data object - var contentPropData = new ContentPropertyData(prop.Value.Value, propConfiguration); - - // Get the property editor to do it's conversion - var newValue = propEditor.GetValueEditor().FromEditor(contentPropData, prop.Value.Value); - - // update the raw value since this is what will get serialized out - row.RawPropertyValues[prop.Key] = newValue; - } - } - } - - #endregion } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorValidator.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorValidator.cs index 0e4e99f421..8e17c6c477 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorValidator.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorValidator.cs @@ -1,65 +1,27 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Blocks; using Umbraco.Cms.Core.Services; namespace Umbraco.Cms.Core.PropertyEditors; -internal class BlockEditorValidator : ComplexEditorValidator +internal class BlockEditorValidator : BlockEditorValidatorBase { private readonly BlockEditorValues _blockEditorValues; - private readonly IContentTypeService _contentTypeService; public BlockEditorValidator( IPropertyValidationService propertyValidationService, BlockEditorValues blockEditorValues, IContentTypeService contentTypeService) - : base(propertyValidationService) - { - _blockEditorValues = blockEditorValues; - _contentTypeService = contentTypeService; - } + : base(propertyValidationService, contentTypeService) + => _blockEditorValues = blockEditorValues; protected override IEnumerable GetElementTypeValidation(object? value) { BlockEditorData? blockEditorData = _blockEditorValues.DeserializeAndClean(value); - if (blockEditorData != null) - { - // There is no guarantee that the client will post data for every property defined in the Element Type but we still - // need to validate that data for each property especially for things like 'required' data to work. - // Lookup all element types for all content/settings and then we can populate any empty properties. - var allElements = blockEditorData.BlockValue.ContentData.Concat(blockEditorData.BlockValue.SettingsData).ToList(); - var allElementTypes = _contentTypeService.GetAll(allElements.Select(x => x.ContentTypeKey).ToArray()).ToDictionary(x => x.Key); - - foreach (BlockItemData row in allElements) - { - if (!allElementTypes.TryGetValue(row.ContentTypeKey, out IContentType? elementType)) - { - throw new InvalidOperationException($"No element type found with key {row.ContentTypeKey}"); - } - - // now ensure missing properties - foreach (IPropertyType elementTypeProp in elementType.CompositionPropertyTypes) - { - if (!row.PropertyValues.ContainsKey(elementTypeProp.Alias)) - { - // set values to null - row.PropertyValues[elementTypeProp.Alias] = new BlockItemData.BlockPropertyValue(null, elementTypeProp); - row.RawPropertyValues[elementTypeProp.Alias] = null; - } - } - - var elementValidation = new ElementTypeValidationModel(row.ContentTypeAlias, row.Key); - foreach (KeyValuePair prop in row.PropertyValues) - { - elementValidation.AddPropertyTypeValidation( - new PropertyTypeValidationModel(prop.Value.PropertyType, prop.Value.Value)); - } - - yield return elementValidation; - } - } + return blockEditorData is not null + ? GetBlockEditorDataValidation(blockEditorData) + : Array.Empty(); } } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorValidatorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorValidatorBase.cs new file mode 100644 index 0000000000..977d235229 --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorValidatorBase.cs @@ -0,0 +1,51 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Blocks; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Core.PropertyEditors; + +internal abstract class BlockEditorValidatorBase : ComplexEditorValidator +{ + private readonly IContentTypeService _contentTypeService; + + protected BlockEditorValidatorBase(IPropertyValidationService propertyValidationService, IContentTypeService contentTypeService) + : base(propertyValidationService) + => _contentTypeService = contentTypeService; + + protected IEnumerable GetBlockEditorDataValidation(BlockEditorData blockEditorData) + { + // There is no guarantee that the client will post data for every property defined in the Element Type but we still + // need to validate that data for each property especially for things like 'required' data to work. + // Lookup all element types for all content/settings and then we can populate any empty properties. + var allElements = blockEditorData.BlockValue.ContentData.Concat(blockEditorData.BlockValue.SettingsData).ToList(); + var allElementTypes = _contentTypeService.GetAll(allElements.Select(x => x.ContentTypeKey).ToArray()).ToDictionary(x => x.Key); + + foreach (BlockItemData row in allElements) + { + if (!allElementTypes.TryGetValue(row.ContentTypeKey, out IContentType? elementType)) + { + throw new InvalidOperationException($"No element type found with key {row.ContentTypeKey}"); + } + + // now ensure missing properties + foreach (IPropertyType elementTypeProp in elementType.CompositionPropertyTypes) + { + if (!row.PropertyValues.ContainsKey(elementTypeProp.Alias)) + { + // set values to null + row.PropertyValues[elementTypeProp.Alias] = new BlockItemData.BlockPropertyValue(null, elementTypeProp); + row.RawPropertyValues[elementTypeProp.Alias] = null; + } + } + + var elementValidation = new ElementTypeValidationModel(row.ContentTypeAlias, row.Key); + foreach (KeyValuePair prop in row.PropertyValues) + { + elementValidation.AddPropertyTypeValidation( + new PropertyTypeValidationModel(prop.Value.PropertyType, prop.Value.Value)); + } + + yield return elementValidation; + } + } +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorValues.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorValues.cs index 773b9e3a62..3270351838 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorValues.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorValues.cs @@ -34,7 +34,17 @@ internal class BlockEditorValues } BlockEditorData blockEditorData = _dataConverter.Deserialize(propertyValueAsString); + return Clean(blockEditorData); + } + public BlockEditorData? ConvertAndClean(BlockValue blockValue) + { + BlockEditorData blockEditorData = _dataConverter.Convert(blockValue); + return Clean(blockEditorData); + } + + private BlockEditorData? Clean(BlockEditorData blockEditorData) + { if (blockEditorData.BlockValue.ContentData.Count == 0) { // if there's no content ensure there's no settings too diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs new file mode 100644 index 0000000000..d83d71abaa --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs @@ -0,0 +1,185 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Blocks; +using Umbraco.Cms.Core.Models.Editors; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Strings; + +namespace Umbraco.Cms.Core.PropertyEditors; + +internal abstract class BlockValuePropertyValueEditorBase : DataValueEditor, IDataValueReference, IDataValueTags +{ + private readonly IDataTypeService _dataTypeService; + private readonly PropertyEditorCollection _propertyEditors; + private readonly ILogger _logger; + + protected BlockValuePropertyValueEditorBase( + DataEditorAttribute attribute, + PropertyEditorCollection propertyEditors, + IDataTypeService dataTypeService, + ILocalizedTextService textService, + ILogger logger, + IShortStringHelper shortStringHelper, + IJsonSerializer jsonSerializer, + IIOHelper ioHelper) + : base(textService, shortStringHelper, jsonSerializer, ioHelper, attribute) + { + _propertyEditors = propertyEditors; + _dataTypeService = dataTypeService; + _logger = logger; + } + + /// + public abstract IEnumerable GetReferences(object? value); + + protected IEnumerable GetBlockValueReferences(BlockValue blockValue) + { + var result = new List(); + + // loop through all content and settings data + foreach (BlockItemData row in blockValue.ContentData.Concat(blockValue.SettingsData)) + { + foreach (KeyValuePair prop in row.PropertyValues) + { + IDataEditor? propEditor = _propertyEditors[prop.Value.PropertyType.PropertyEditorAlias]; + + IDataValueEditor? valueEditor = propEditor?.GetValueEditor(); + if (!(valueEditor is IDataValueReference reference)) + { + continue; + } + + var val = prop.Value.Value?.ToString(); + + IEnumerable refs = reference.GetReferences(val); + + result.AddRange(refs); + } + } + + return result; + } + + /// + public abstract IEnumerable GetTags(object? value, object? dataTypeConfiguration, int? languageId); + + protected IEnumerable GetBlockValueTags(BlockValue blockValue, int? languageId) + { + var result = new List(); + // loop through all content and settings data + foreach (BlockItemData row in blockValue.ContentData.Concat(blockValue.SettingsData)) + { + foreach (KeyValuePair prop in row.PropertyValues) + { + IDataEditor? propEditor = _propertyEditors[prop.Value.PropertyType.PropertyEditorAlias]; + + IDataValueEditor? valueEditor = propEditor?.GetValueEditor(); + if (valueEditor is not IDataValueTags tagsProvider) + { + continue; + } + + object? configuration = _dataTypeService.GetDataType(prop.Value.PropertyType.DataTypeKey)?.Configuration; + + result.AddRange(tagsProvider.GetTags(prop.Value.Value, configuration, languageId)); + } + } + + return result; + } + + protected void MapBlockValueFromEditor(BlockValue blockValue) + { + MapBlockItemDataFromEditor(blockValue.ContentData); + MapBlockItemDataFromEditor(blockValue.SettingsData); + } + + protected void MapBlockValueToEditor(IProperty property, BlockValue blockValue) + { + MapBlockItemDataToEditor(property, blockValue.ContentData); + MapBlockItemDataToEditor(property, blockValue.SettingsData); + } + + private void MapBlockItemDataToEditor(IProperty property, List items) + { + var valEditors = new Dictionary(); + + foreach (BlockItemData row in items) + { + foreach (KeyValuePair prop in row.PropertyValues) + { + // create a temp property with the value + // - force it to be culture invariant as the block editor can't handle culture variant element properties + prop.Value.PropertyType.Variations = ContentVariation.Nothing; + var tempProp = new Property(prop.Value.PropertyType); + tempProp.SetValue(prop.Value.Value); + + IDataEditor? propEditor = _propertyEditors[prop.Value.PropertyType.PropertyEditorAlias]; + if (propEditor == null) + { + // NOTE: This logic was borrowed from Nested Content and I'm unsure why it exists. + // if the property editor doesn't exist I think everything will break anyways? + // update the raw value since this is what will get serialized out + row.RawPropertyValues[prop.Key] = tempProp.GetValue()?.ToString(); + continue; + } + + IDataType? dataType = _dataTypeService.GetDataType(prop.Value.PropertyType.DataTypeId); + if (dataType == null) + { + // deal with weird situations by ignoring them (no comment) + row.PropertyValues.Remove(prop.Key); + _logger.LogWarning( + "ToEditor removed property value {PropertyKey} in row {RowId} for property type {PropertyTypeAlias}", + prop.Key, + row.Key, + property.PropertyType.Alias); + continue; + } + + if (!valEditors.TryGetValue(dataType.Id, out IDataValueEditor? valEditor)) + { + var tempConfig = dataType.Configuration; + valEditor = propEditor.GetValueEditor(tempConfig); + + valEditors.Add(dataType.Id, valEditor); + } + + var convValue = valEditor.ToEditor(tempProp); + + // update the raw value since this is what will get serialized out + row.RawPropertyValues[prop.Key] = convValue; + } + } + } + + private void MapBlockItemDataFromEditor(List items) + { + foreach (BlockItemData row in items) + { + foreach (KeyValuePair prop in row.PropertyValues) + { + // Fetch the property types prevalue + var propConfiguration = _dataTypeService.GetDataType(prop.Value.PropertyType.DataTypeId)?.Configuration; + + // Lookup the property editor + IDataEditor? propEditor = _propertyEditors[prop.Value.PropertyType.PropertyEditorAlias]; + if (propEditor == null) + { + continue; + } + + // Create a fake content property data object + var contentPropData = new ContentPropertyData(prop.Value.Value, propConfiguration); + + // Get the property editor to do it's conversion + var newValue = propEditor.GetValueEditor().FromEditor(contentPropData, prop.Value.Value); + + // update the raw value since this is what will get serialized out + row.RawPropertyValues[prop.Key] = newValue; + } + } + } +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/RichTextEditorBlockValidator.cs b/src/Umbraco.Infrastructure/PropertyEditors/RichTextEditorBlockValidator.cs new file mode 100644 index 0000000000..01d10e46f0 --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/RichTextEditorBlockValidator.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Models.Blocks; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Core.PropertyEditors; + +internal class RichTextEditorBlockValidator : BlockEditorValidatorBase +{ + private readonly BlockEditorValues _blockEditorValues; + private readonly IJsonSerializer _jsonSerializer; + private readonly ILogger _logger; + + public RichTextEditorBlockValidator( + IPropertyValidationService propertyValidationService, + BlockEditorValues blockEditorValues, + IContentTypeService contentTypeService, + IJsonSerializer jsonSerializer, + ILogger logger) + : base(propertyValidationService, contentTypeService) + { + _blockEditorValues = blockEditorValues; + _jsonSerializer = jsonSerializer; + _logger = logger; + } + + protected override IEnumerable GetElementTypeValidation(object? value) + { + RichTextPropertyEditorHelper.TryParseRichTextEditorValue(value, _jsonSerializer, _logger, out RichTextEditorValue? richTextEditorValue); + if (richTextEditorValue?.Blocks is null) + { + return Array.Empty(); + } + + BlockEditorData? blockEditorData = _blockEditorValues.ConvertAndClean(richTextEditorValue.Blocks); + return blockEditorData is not null + ? GetBlockEditorDataValidation(blockEditorData) + : Array.Empty(); + } +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs index 053e98d9cb..8d38b218b5 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs @@ -1,18 +1,20 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Media; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Blocks; using Umbraco.Cms.Core.Models.Editors; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Core.Templates; -using Umbraco.Cms.Infrastructure.Examine; using Umbraco.Cms.Infrastructure.Macros; using Umbraco.Cms.Infrastructure.Templates; using Umbraco.Extensions; @@ -33,16 +35,11 @@ namespace Umbraco.Cms.Core.PropertyEditors; ValueEditorIsReusable = true)] public class RichTextPropertyEditor : DataEditor { - private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; private readonly IEditorConfigurationParser _editorConfigurationParser; - private readonly HtmlImageSourceParser _imageSourceParser; - private readonly IImageUrlGenerator _imageUrlGenerator; private readonly IIOHelper _ioHelper; - private readonly HtmlLocalLinkParser _localLinkParser; - private readonly IHtmlMacroParameterParser _macroParameterParser; - private readonly RichTextEditorPastedImages _pastedImages; + private readonly IRichTextPropertyIndexValueFactory _richTextPropertyIndexValueFactory; - [Obsolete("Use the constructor which takes an IHtmlMacroParameterParser instead")] + [Obsolete("Use the constructor which takes an IHtmlMacroParameterParser instead. Will be removed in V15.")] public RichTextPropertyEditor( IDataValueEditorFactory dataValueEditorFactory, IBackOfficeSecurityAccessor backOfficeSecurityAccessor, @@ -65,7 +62,7 @@ public class RichTextPropertyEditor : DataEditor { } - [Obsolete("Use the constructor which takes an IHtmlMacroParameterParser instead")] + [Obsolete("Use the constructor which takes an IHtmlMacroParameterParser instead. Will be removed in V15.")] public RichTextPropertyEditor( IDataValueEditorFactory dataValueEditorFactory, IBackOfficeSecurityAccessor backOfficeSecurityAccessor, @@ -87,9 +84,7 @@ public class RichTextPropertyEditor : DataEditor { } - /// - /// The constructor will setup the property editor based on the attribute if one is found. - /// + [Obsolete($"Use the constructor which accepts an {nameof(IRichTextPropertyIndexValueFactory)} parameter. Will be removed in V15.")] public RichTextPropertyEditor( IDataValueEditorFactory dataValueEditorFactory, IBackOfficeSecurityAccessor backOfficeSecurityAccessor, @@ -100,20 +95,57 @@ public class RichTextPropertyEditor : DataEditor IImageUrlGenerator imageUrlGenerator, IHtmlMacroParameterParser macroParameterParser, IEditorConfigurationParser editorConfigurationParser) + : this( + dataValueEditorFactory, + backOfficeSecurityAccessor, + imageSourceParser, + localLinkParser, + pastedImages, + ioHelper, + imageUrlGenerator, + macroParameterParser, + editorConfigurationParser, + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + [Obsolete($"Use the non-obsolete constructor. Will be removed in V15.")] + public RichTextPropertyEditor( + IDataValueEditorFactory dataValueEditorFactory, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + HtmlImageSourceParser imageSourceParser, + HtmlLocalLinkParser localLinkParser, + RichTextEditorPastedImages pastedImages, + IIOHelper ioHelper, + IImageUrlGenerator imageUrlGenerator, + IHtmlMacroParameterParser macroParameterParser, + IEditorConfigurationParser editorConfigurationParser, + IRichTextPropertyIndexValueFactory richTextPropertyIndexValueFactory) + : this( + dataValueEditorFactory, + editorConfigurationParser, + ioHelper, + richTextPropertyIndexValueFactory) + { + } + + /// + /// The constructor will setup the property editor based on the attribute if one is found. + /// + public RichTextPropertyEditor( + IDataValueEditorFactory dataValueEditorFactory, + IEditorConfigurationParser editorConfigurationParser, + IIOHelper ioHelper, + IRichTextPropertyIndexValueFactory richTextPropertyIndexValueFactory) : base(dataValueEditorFactory) { - _backOfficeSecurityAccessor = backOfficeSecurityAccessor; - _imageSourceParser = imageSourceParser; - _localLinkParser = localLinkParser; - _pastedImages = pastedImages; _ioHelper = ioHelper; - _imageUrlGenerator = imageUrlGenerator; - _macroParameterParser = macroParameterParser; + _richTextPropertyIndexValueFactory = richTextPropertyIndexValueFactory; _editorConfigurationParser = editorConfigurationParser; SupportsReadOnly = true; } - public override IPropertyIndexValueFactory PropertyIndexValueFactory => new RichTextPropertyIndexValueFactory(); + public override IPropertyIndexValueFactory PropertyIndexValueFactory => _richTextPropertyIndexValueFactory; /// /// Create a custom value editor @@ -129,67 +161,48 @@ public class RichTextPropertyEditor : DataEditor /// A custom value editor to ensure that macro syntax is parsed when being persisted and formatted correctly for /// display in the editor /// - internal class RichTextPropertyValueEditor : DataValueEditor, IDataValueReference + internal class RichTextPropertyValueEditor : BlockValuePropertyValueEditorBase { private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; private readonly IHtmlSanitizer _htmlSanitizer; private readonly HtmlImageSourceParser _imageSourceParser; - private readonly IImageUrlGenerator _imageUrlGenerator; private readonly HtmlLocalLinkParser _localLinkParser; private readonly IHtmlMacroParameterParser _macroParameterParser; private readonly RichTextEditorPastedImages _pastedImages; + private readonly IJsonSerializer _jsonSerializer; + private readonly IContentTypeService _contentTypeService; + private readonly ILogger _logger; public RichTextPropertyValueEditor( DataEditorAttribute attribute, + PropertyEditorCollection propertyEditors, + IDataTypeService dataTypeService, + ILogger logger, IBackOfficeSecurityAccessor backOfficeSecurityAccessor, ILocalizedTextService localizedTextService, IShortStringHelper shortStringHelper, HtmlImageSourceParser imageSourceParser, HtmlLocalLinkParser localLinkParser, RichTextEditorPastedImages pastedImages, - IImageUrlGenerator imageUrlGenerator, IJsonSerializer jsonSerializer, IIOHelper ioHelper, IHtmlSanitizer htmlSanitizer, - IHtmlMacroParameterParser macroParameterParser) - : base(localizedTextService, shortStringHelper, jsonSerializer, ioHelper, attribute) + IHtmlMacroParameterParser macroParameterParser, + IContentTypeService contentTypeService, + IPropertyValidationService propertyValidationService) + : base(attribute, propertyEditors, dataTypeService, localizedTextService, logger, shortStringHelper, jsonSerializer, ioHelper) { _backOfficeSecurityAccessor = backOfficeSecurityAccessor; _imageSourceParser = imageSourceParser; _localLinkParser = localLinkParser; _pastedImages = pastedImages; - _imageUrlGenerator = imageUrlGenerator; _htmlSanitizer = htmlSanitizer; _macroParameterParser = macroParameterParser; - } + _contentTypeService = contentTypeService; + _jsonSerializer = jsonSerializer; + _logger = logger; - [Obsolete("Use the constructor which takes an HtmlMacroParameterParser instead")] - public RichTextPropertyValueEditor( - DataEditorAttribute attribute, - IBackOfficeSecurityAccessor backOfficeSecurityAccessor, - ILocalizedTextService localizedTextService, - IShortStringHelper shortStringHelper, - HtmlImageSourceParser imageSourceParser, - HtmlLocalLinkParser localLinkParser, - RichTextEditorPastedImages pastedImages, - IImageUrlGenerator imageUrlGenerator, - IJsonSerializer jsonSerializer, - IIOHelper ioHelper, - IHtmlSanitizer htmlSanitizer) - : this( - attribute, - backOfficeSecurityAccessor, - localizedTextService, - shortStringHelper, - imageSourceParser, - localLinkParser, - pastedImages, - imageUrlGenerator, - jsonSerializer, - ioHelper, - htmlSanitizer, - StaticServiceProvider.Instance.GetRequiredService()) - { + Validators.Add(new RichTextEditorBlockValidator(propertyValidationService, CreateBlockEditorValues(), contentTypeService, jsonSerializer, logger)); } /// @@ -221,30 +234,57 @@ public class RichTextPropertyEditor : DataEditor /// /// /// - public IEnumerable GetReferences(object? value) + public override IEnumerable GetReferences(object? value) { - var asString = value == null ? string.Empty : value is string str ? str : value.ToString()!; - - foreach (Udi udi in _imageSourceParser.FindUdisFromDataAttributes(asString)) + if (TryParseEditorValue(value, out RichTextEditorValue? richTextEditorValue) is false) { - yield return new UmbracoEntityReference(udi); + return Array.Empty(); } - foreach (Udi? udi in _localLinkParser.FindUdisFromLocalLinks(asString)) - { - if (udi is not null) - { - yield return new UmbracoEntityReference(udi); - } - } + var references = new List(); + + // image references from markup + references.AddRange(_imageSourceParser + .FindUdisFromDataAttributes(richTextEditorValue.Markup) + .Select(udi => new UmbracoEntityReference(udi))); + + // local link references from markup + references.AddRange(_localLinkParser + .FindUdisFromLocalLinks(richTextEditorValue.Markup) + .WhereNotNull() + .Select(udi => new UmbracoEntityReference(udi))); // TODO: Detect Macros too ... but we can save that for a later date, right now need to do media refs // UPDATE: We are getting the Macros in 'FindUmbracoEntityReferencesFromEmbeddedMacros' - perhaps we just return the macro Udis here too or do they need their own relationAlias? - foreach (UmbracoEntityReference umbracoEntityReference in _macroParameterParser - .FindUmbracoEntityReferencesFromEmbeddedMacros(asString)) + references.AddRange(_macroParameterParser.FindUmbracoEntityReferencesFromEmbeddedMacros(richTextEditorValue.Markup)); + + // references from blocks + if (richTextEditorValue.Blocks is not null) { - yield return umbracoEntityReference; + BlockEditorData? blockEditorData = ConvertAndClean(richTextEditorValue.Blocks); + if (blockEditorData is not null) + { + references.AddRange(GetBlockValueReferences(blockEditorData.BlockValue)); + } } + + return references; + } + + public override IEnumerable GetTags(object? value, object? dataTypeConfiguration, int? languageId) + { + if (TryParseEditorValue(value, out RichTextEditorValue? richTextEditorValue) is false || richTextEditorValue.Blocks is null) + { + return Array.Empty(); + } + + BlockEditorData? blockEditorData = ConvertAndClean(richTextEditorValue.Blocks); + if (blockEditorData is null) + { + return Array.Empty(); + } + + return GetBlockValueTags(blockEditorData.BlockValue, languageId); } /// @@ -255,17 +295,20 @@ public class RichTextPropertyEditor : DataEditor /// public override object? ToEditor(IProperty property, string? culture = null, string? segment = null) { - var val = property.GetValue(culture, segment); - if (val == null) + var value = property.GetValue(culture, segment); + if (TryParseEditorValue(value, out RichTextEditorValue? richTextEditorValue) is false) { return null; } - var propertyValueWithMediaResolved = _imageSourceParser.EnsureImageSources(val.ToString()!); + var propertyValueWithMediaResolved = _imageSourceParser.EnsureImageSources(richTextEditorValue.Markup); var parsed = MacroTagParser.FormatRichTextPersistedDataForEditor( propertyValueWithMediaResolved, new Dictionary()); - return parsed; + richTextEditorValue.Markup = parsed; + + // return json convertable object + return CleanAndMapBlocks(richTextEditorValue, blockValue => MapBlockValueToEditor(property, blockValue)); } /// @@ -276,7 +319,7 @@ public class RichTextPropertyEditor : DataEditor /// public override object? FromEditor(ContentPropertyData editorValue, object? currentValue) { - if (editorValue.Value == null) + if (TryParseEditorValue(editorValue.Value, out RichTextEditorValue? richTextEditorValue) is false) { return null; } @@ -288,46 +331,65 @@ public class RichTextPropertyEditor : DataEditor GuidUdi? mediaParent = config?.MediaParentId; Guid mediaParentId = mediaParent == null ? Guid.Empty : mediaParent.Guid; - if (string.IsNullOrWhiteSpace(editorValue.Value.ToString())) + if (string.IsNullOrWhiteSpace(richTextEditorValue.Markup)) { return null; } var parseAndSaveBase64Images = _pastedImages.FindAndPersistEmbeddedImages( - editorValue.Value.ToString()!, mediaParentId, userId); + richTextEditorValue.Markup, mediaParentId, userId); var parseAndSavedTempImages = _pastedImages.FindAndPersistPastedTempImages(parseAndSaveBase64Images, mediaParentId, userId); var editorValueWithMediaUrlsRemoved = _imageSourceParser.RemoveImageSources(parseAndSavedTempImages); var parsed = MacroTagParser.FormatRichTextContentForPersistence(editorValueWithMediaUrlsRemoved); var sanitized = _htmlSanitizer.Sanitize(parsed); - return sanitized.NullOrWhiteSpaceAsNull(); + richTextEditorValue.Markup = sanitized.NullOrWhiteSpaceAsNull() ?? string.Empty; + + RichTextEditorValue cleanedUpRichTextEditorValue = CleanAndMapBlocks(richTextEditorValue, MapBlockValueFromEditor); + + // return json + return RichTextPropertyEditorHelper.SerializeRichTextEditorValue(cleanedUpRichTextEditorValue, _jsonSerializer); } - } - internal class RichTextPropertyIndexValueFactory : IPropertyIndexValueFactory - { - public IEnumerable>> GetIndexValues(IProperty property, string? culture, string? segment, bool published, IEnumerable availableCultures) + private bool TryParseEditorValue(object? value, [NotNullWhen(true)] out RichTextEditorValue? richTextEditorValue) + => RichTextPropertyEditorHelper.TryParseRichTextEditorValue(value, _jsonSerializer, _logger, out richTextEditorValue); + + private RichTextEditorValue CleanAndMapBlocks(RichTextEditorValue richTextEditorValue, Action handleMapping) { - var val = property.GetValue(culture, segment, published); - - if (!(val is string strVal)) + if (richTextEditorValue.Blocks is null) { - yield break; + // no blocks defined, store empty block value + return MarkupWithEmptyBlocks(); } - // index the stripped HTML values - yield return new KeyValuePair>( - property.Alias, - new object[] { strVal.StripHtml() }); + BlockEditorData? blockEditorData = ConvertAndClean(richTextEditorValue.Blocks); - // store the raw value - yield return new KeyValuePair>( - $"{UmbracoExamineFieldNames.RawFieldPrefix}{property.Alias}", new object[] { strVal }); + if (blockEditorData is not null) + { + handleMapping(blockEditorData.BlockValue); + return new RichTextEditorValue + { + Markup = richTextEditorValue.Markup, Blocks = blockEditorData.BlockValue + }; + } + + // could not deserialize the blocks or handle the mapping, store empty block value + return MarkupWithEmptyBlocks(); + + RichTextEditorValue MarkupWithEmptyBlocks() => new() + { + Markup = richTextEditorValue.Markup, Blocks = new BlockValue() + }; } - [Obsolete("Use the overload with the 'availableCultures' parameter instead, scheduled for removal in v14")] - public IEnumerable>> GetIndexValues(IProperty property, string? culture, string? segment, bool published) - => GetIndexValues(property, culture, segment, published, Enumerable.Empty()); + private BlockEditorData? ConvertAndClean(BlockValue blockValue) + { + BlockEditorValues blockEditorValues = CreateBlockEditorValues(); + return blockEditorValues.ConvertAndClean(blockValue); + } + + private BlockEditorValues CreateBlockEditorValues() + => new(new RichTextEditorBlockDataConverter(), _contentTypeService, _logger); } } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditorHelper.cs b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditorHelper.cs new file mode 100644 index 0000000000..72f7d10dc5 --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditorHelper.cs @@ -0,0 +1,62 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.PropertyEditors; + +// NOTE: this class is deliberately made accessible to 3rd party consumers (i.e. Deploy, uSync, ...) +public static class RichTextPropertyEditorHelper +{ + /// + /// Attempts to parse a instance from a property value. + /// + /// The property value. + /// The system JSON serializer. + /// A logger for error message handling. + /// The parsed instance, or null if parsing fails. + /// True if the parsing succeeds, false otherwise + /// + /// The passed value can be: + /// - a JSON string. + /// - a JSON object. + /// - a raw markup string (for backwards compatability). + /// + public static bool TryParseRichTextEditorValue(object? value, IJsonSerializer jsonSerializer, ILogger logger, [NotNullWhen(true)] out RichTextEditorValue? richTextEditorValue) + { + var stringValue = value as string ?? value?.ToString(); + if (stringValue is null) + { + richTextEditorValue = null; + return false; + } + + if (stringValue.DetectIsJson() is false) + { + // assume value is raw markup and construct the model accordingly (no blocks stored) + richTextEditorValue = new RichTextEditorValue { Markup = stringValue, Blocks = null }; + return true; + } + + try + { + richTextEditorValue = jsonSerializer.Deserialize(stringValue); + return richTextEditorValue != null; + } + catch (Exception exception) + { + logger.LogError(exception, "Could not parse rich text editor value, see exception for details."); + richTextEditorValue = null; + return false; + } + } + + /// + /// Serializes a instance for property value storage. + /// + /// The instance to serialize. + /// The system JSON serializer. + /// A string value representing the passed instance. + public static string SerializeRichTextEditorValue(RichTextEditorValue richTextEditorValue, IJsonSerializer jsonSerializer) + => jsonSerializer.Serialize(richTextEditorValue); +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyIndexValueFactory.cs b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyIndexValueFactory.cs new file mode 100644 index 0000000000..be49e280cb --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyIndexValueFactory.cs @@ -0,0 +1,68 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Blocks; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Examine; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.PropertyEditors; + +internal class RichTextPropertyIndexValueFactory : NestedPropertyIndexValueFactoryBase, IRichTextPropertyIndexValueFactory +{ + private readonly IJsonSerializer _jsonSerializer; + private readonly IContentTypeService _contentTypeService; + private readonly ILogger _logger; + + public RichTextPropertyIndexValueFactory( + PropertyEditorCollection propertyEditorCollection, + IJsonSerializer jsonSerializer, + IOptionsMonitor indexingSettings, + IContentTypeService contentTypeService, + ILogger logger) + : base(propertyEditorCollection, jsonSerializer, indexingSettings) + { + _jsonSerializer = jsonSerializer; + _contentTypeService = contentTypeService; + _logger = logger; + } + + public IEnumerable>> GetIndexValues(IProperty property, string? culture, string? segment, bool published, IEnumerable availableCultures) + { + var val = property.GetValue(culture, segment, published); + if (RichTextPropertyEditorHelper.TryParseRichTextEditorValue(val, _jsonSerializer, _logger, out RichTextEditorValue? richTextEditorValue) is false) + { + yield break; + } + + // the "blocks values resume" (the combined searchable text values from all blocks) is stored as a string value under the property alias by the base implementation + var blocksIndexValues = base.GetIndexValues(property, culture, segment, published, availableCultures).ToDictionary(pair => pair.Key, pair => pair.Value); + var blocksIndexValuesResume = blocksIndexValues.TryGetValue(property.Alias, out IEnumerable? blocksIndexValuesResumeValue) + ? blocksIndexValuesResumeValue.FirstOrDefault() as string + : null; + + // index the stripped HTML values combined with "blocks values resume" value + yield return new KeyValuePair>( + property.Alias, + new object[] { $"{richTextEditorValue.Markup.StripHtml()} {blocksIndexValuesResume}" }); + + // store the raw value + yield return new KeyValuePair>( + $"{UmbracoExamineFieldNames.RawFieldPrefix}{property.Alias}", new object[] { richTextEditorValue.Markup }); + } + + [Obsolete("Use the overload with the 'availableCultures' parameter instead, scheduled for removal in v14")] + public IEnumerable>> GetIndexValues(IProperty property, string? culture, string? segment, bool published) + => GetIndexValues(property, culture, segment, published, Enumerable.Empty()); + + protected override IContentType? GetContentTypeOfNestedItem(BlockItemData nestedItem) + => _contentTypeService.Get(nestedItem.ContentTypeKey); + + protected override IDictionary GetRawProperty(BlockItemData blockItemData) + => blockItemData.RawPropertyValues; + + protected override IEnumerable GetDataItems(RichTextEditorValue input) + => input.Blocks?.ContentData ?? new List(); +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockGridPropertyValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockGridPropertyValueConverter.cs index d7748f7e98..5b877bd9b9 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockGridPropertyValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockGridPropertyValueConverter.cs @@ -11,14 +11,14 @@ using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PropertyEditors.DeliveryApi; using Umbraco.Cms.Core.Serialization; using Umbraco.Extensions; -using static Umbraco.Cms.Core.PropertyEditors.BlockGridConfiguration; namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters { [DefaultPropertyValueConverter(typeof(JsonValueConverter))] - public class BlockGridPropertyValueConverter : BlockPropertyValueConverterBase, IDeliveryApiPropertyValueConverter + public class BlockGridPropertyValueConverter : PropertyValueConverterBase, IDeliveryApiPropertyValueConverter { private readonly IProfilingLogger _proflog; + private readonly BlockEditorConverter _blockConverter; private readonly IJsonSerializer _jsonSerializer; private readonly IApiElementBuilder _apiElementBuilder; @@ -32,15 +32,14 @@ namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters } - // Niels, Change: I would love if this could be general, so we don't need a specific one for each block property editor.... public BlockGridPropertyValueConverter( IProfilingLogger proflog, BlockEditorConverter blockConverter, IJsonSerializer jsonSerializer, IApiElementBuilder apiElementBuilder) - : base(blockConverter) { _proflog = proflog; + _blockConverter = blockConverter; _jsonSerializer = jsonSerializer; _apiElementBuilder = apiElementBuilder; } @@ -49,14 +48,22 @@ namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters public override bool IsConverter(IPublishedPropertyType propertyType) => propertyType.EditorAlias.InvariantEquals(Constants.PropertyEditors.Aliases.BlockGrid); + /// + public override Type GetPropertyValueType(IPublishedPropertyType propertyType) + => typeof(BlockGridModel); + + /// public override object? ConvertIntermediateToObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview) => ConvertIntermediateToBlockGridModel(propertyType, referenceCacheLevel, inter, preview); + /// public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => GetPropertyCacheLevel(propertyType); + /// public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType) => typeof(ApiBlockGridModel); + /// public object? ConvertIntermediateToDeliveryApiObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview, bool expanding) { const int defaultColumns = 12; @@ -96,65 +103,28 @@ namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters { using (!_proflog.IsEnabled(LogLevel.Debug) ? null : _proflog.DebugDuration($"ConvertPropertyToBlockGrid ({propertyType.DataType.Id})")) { + // NOTE: this is to retain backwards compatability + if (inter is null) + { + return BlockGridModel.Empty; + } + + // NOTE: The intermediate object is just a JSON string, we don't actually convert from source -> intermediate since source is always just a JSON string + if (inter is not string intermediateBlockModelValue) + { + return null; + } + // Get configuration - var configuration = propertyType.DataType.ConfigurationAs(); + BlockGridConfiguration? configuration = propertyType.DataType.ConfigurationAs(); if (configuration is null) { return null; } - BlockGridModel CreateEmptyModel() => BlockGridModel.Empty; - - BlockGridModel CreateModel(IList items) => new BlockGridModel(items, configuration.GridColumns); - - BlockGridItem? EnrichBlockItem(BlockGridItem blockItem, BlockGridLayoutItem layoutItem, BlockGridBlockConfiguration blockConfig, CreateBlockItemModelFromLayout createBlockItem) - { - // enrich block item with additional configs + setup areas - var blockConfigAreaMap = blockConfig.Areas.ToDictionary(area => area.Key); - - blockItem.RowSpan = layoutItem.RowSpan!.Value; - blockItem.ColumnSpan = layoutItem.ColumnSpan!.Value; - blockItem.AreaGridColumns = blockConfig.AreaGridColumns; - blockItem.GridColumns = configuration.GridColumns; - blockItem.Areas = layoutItem.Areas.Select(area => - { - if (!blockConfigAreaMap.TryGetValue(area.Key, out BlockGridAreaConfiguration? areaConfig)) - { - return null; - } - - var items = area.Items.Select(item => createBlockItem(item)).WhereNotNull().ToList(); - return new BlockGridArea(items, areaConfig.Alias!, areaConfig.RowSpan!.Value, areaConfig.ColumnSpan!.Value); - }).WhereNotNull().ToArray(); - - return blockItem; - } - - BlockGridModel blockModel = UnwrapBlockModel( - referenceCacheLevel, - inter, - preview, - configuration.Blocks, - CreateEmptyModel, - CreateModel, - EnrichBlockItem - ); - - return blockModel; + var creator = new BlockGridPropertyValueCreator(_blockConverter, _jsonSerializer); + return creator.CreateBlockModel(referenceCacheLevel, intermediateBlockModelValue, preview, configuration.Blocks, configuration.GridColumns); } } - - protected override BlockEditorDataConverter CreateBlockEditorDataConverter() => new BlockGridEditorDataConverter(_jsonSerializer); - - protected override BlockItemActivator CreateBlockItemActivator() => new BlockGridItemActivator(BlockEditorConverter); - - private class BlockGridItemActivator : BlockItemActivator - { - public BlockGridItemActivator(BlockEditorConverter blockConverter) : base(blockConverter) - { - } - - protected override Type GenericItemType => typeof(BlockGridItem<,>); - } } } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockGridPropertyValueCreator.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockGridPropertyValueCreator.cs new file mode 100644 index 0000000000..b50d95f5c3 --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockGridPropertyValueCreator.cs @@ -0,0 +1,68 @@ +using Umbraco.Cms.Core.Models.Blocks; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; + +internal class BlockGridPropertyValueCreator : BlockPropertyValueCreatorBase +{ + private readonly IJsonSerializer _jsonSerializer; + + public BlockGridPropertyValueCreator(BlockEditorConverter blockEditorConverter, IJsonSerializer jsonSerializer) + : base(blockEditorConverter) + => _jsonSerializer = jsonSerializer; + + public BlockGridModel CreateBlockModel(PropertyCacheLevel referenceCacheLevel, string intermediateBlockModelValue, bool preview, BlockGridConfiguration.BlockGridBlockConfiguration[] blockConfigurations, int? gridColumns) + { + BlockGridModel CreateEmptyModel() => BlockGridModel.Empty; + + BlockGridModel CreateModel(IList items) => new BlockGridModel(items, gridColumns); + + BlockGridItem? EnrichBlockItem(BlockGridItem blockItem, BlockGridLayoutItem layoutItem, BlockGridConfiguration.BlockGridBlockConfiguration blockConfig, CreateBlockItemModelFromLayout createBlockItem) + { + // enrich block item with additional configs + setup areas + var blockConfigAreaMap = blockConfig.Areas.ToDictionary(area => area.Key); + + blockItem.RowSpan = layoutItem.RowSpan!.Value; + blockItem.ColumnSpan = layoutItem.ColumnSpan!.Value; + blockItem.AreaGridColumns = blockConfig.AreaGridColumns; + blockItem.GridColumns = gridColumns; + blockItem.Areas = layoutItem.Areas.Select(area => + { + if (!blockConfigAreaMap.TryGetValue(area.Key, out BlockGridConfiguration.BlockGridAreaConfiguration? areaConfig)) + { + return null; + } + + var items = area.Items.Select(item => createBlockItem(item)).WhereNotNull().ToList(); + return new BlockGridArea(items, areaConfig.Alias!, areaConfig.RowSpan!.Value, areaConfig.ColumnSpan!.Value); + }).WhereNotNull().ToArray(); + + return blockItem; + } + + BlockGridModel blockModel = CreateBlockModel( + referenceCacheLevel, + intermediateBlockModelValue, + preview, + blockConfigurations, + CreateEmptyModel, + CreateModel, + EnrichBlockItem); + + return blockModel; + } + + protected override BlockEditorDataConverter CreateBlockEditorDataConverter() => new BlockGridEditorDataConverter(_jsonSerializer); + + protected override BlockItemActivator CreateBlockItemActivator() => new BlockGridItemActivator(BlockEditorConverter); + + private class BlockGridItemActivator : BlockItemActivator + { + public BlockGridItemActivator(BlockEditorConverter blockConverter) : base(blockConverter) + { + } + + protected override Type GenericItemType => typeof(BlockGridItem<,>); + } +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockListPropertyValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockListPropertyValueConverter.cs index 52efe3755a..4c65963093 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockListPropertyValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockListPropertyValueConverter.cs @@ -1,7 +1,6 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System.Reflection; using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.Logging; @@ -11,17 +10,18 @@ using Umbraco.Cms.Core.Models.DeliveryApi; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PropertyEditors.DeliveryApi; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Extensions; using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; -using static Umbraco.Cms.Core.PropertyEditors.BlockListConfiguration; namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; [DefaultPropertyValueConverter(typeof(JsonValueConverter))] -public class BlockListPropertyValueConverter : BlockPropertyValueConverterBase, IDeliveryApiPropertyValueConverter +public class BlockListPropertyValueConverter : PropertyValueConverterBase, IDeliveryApiPropertyValueConverter { private readonly IContentTypeService _contentTypeService; private readonly IProfilingLogger _proflog; + private readonly BlockEditorConverter _blockConverter; private readonly IApiElementBuilder _apiElementBuilder; [Obsolete("Use the constructor that takes all parameters, scheduled for removal in V14")] @@ -37,9 +37,9 @@ public class BlockListPropertyValueConverter : BlockPropertyValueConverterBase new ApiBlockItem( - _apiElementBuilder.Build(item.Content), - item.Settings != null ? _apiElementBuilder.Build(item.Settings) : null)) - .ToArray() + ? model.Select(item => item.CreateApiBlockItem(_apiElementBuilder)).ToArray() : Array.Empty()); } private BlockListModel? ConvertIntermediateToBlockListModel(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview) { - // NOTE: The intermediate object is just a JSON string, we don't actually convert from source -> intermediate since source is always just a JSON string using (!_proflog.IsEnabled(LogLevel.Debug) ? null : _proflog.DebugDuration( $"ConvertPropertyToBlockList ({propertyType.DataType.Id})")) { + // NOTE: this is to retain backwards compatability + if (inter is null) + { + return BlockListModel.Empty; + } + + // NOTE: The intermediate object is just a JSON string, we don't actually convert from source -> intermediate since source is always just a JSON string + if (inter is not string intermediateBlockModelValue) + { + return null; + } + // Get configuration BlockListConfiguration? configuration = propertyType.DataType.ConfigurationAs(); if (configuration is null) @@ -143,26 +150,8 @@ public class BlockListPropertyValueConverter : BlockPropertyValueConverterBase BlockListModel.Empty; - - BlockListModel CreateModel(IList items) => new BlockListModel(items); - - BlockListModel blockModel = UnwrapBlockModel(referenceCacheLevel, inter, preview, configuration.Blocks, CreateEmptyModel, CreateModel); - - return blockModel; + var creator = new BlockListPropertyValueCreator(_blockConverter); + return creator.CreateBlockModel(referenceCacheLevel, intermediateBlockModelValue, preview, configuration.Blocks); } } - - protected override BlockEditorDataConverter CreateBlockEditorDataConverter() => new BlockListEditorDataConverter(); - - protected override BlockItemActivator CreateBlockItemActivator() => new BlockListItemActivator(BlockEditorConverter); - - private class BlockListItemActivator : BlockItemActivator - { - public BlockListItemActivator(BlockEditorConverter blockConverter) : base(blockConverter) - { - } - - protected override Type GenericItemType => typeof(BlockListItem<,>); - } } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockListPropertyValueCreator.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockListPropertyValueCreator.cs new file mode 100644 index 0000000000..952dc43e2f --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockListPropertyValueCreator.cs @@ -0,0 +1,35 @@ +using Umbraco.Cms.Core.Models.Blocks; + +namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; + +internal class BlockListPropertyValueCreator : BlockPropertyValueCreatorBase +{ + public BlockListPropertyValueCreator(BlockEditorConverter blockEditorConverter) + : base(blockEditorConverter) + { + } + + public BlockListModel CreateBlockModel(PropertyCacheLevel referenceCacheLevel, string intermediateBlockModelValue, bool preview, BlockListConfiguration.BlockConfiguration[] blockConfigurations) + { + BlockListModel CreateEmptyModel() => BlockListModel.Empty; + + BlockListModel CreateModel(IList items) => new BlockListModel(items); + + BlockListModel blockModel = CreateBlockModel(referenceCacheLevel, intermediateBlockModelValue, preview, blockConfigurations, CreateEmptyModel, CreateModel); + + return blockModel; + } + + protected override BlockEditorDataConverter CreateBlockEditorDataConverter() => new BlockListEditorDataConverter(); + + protected override BlockItemActivator CreateBlockItemActivator() => new BlockListItemActivator(BlockEditorConverter); + + private class BlockListItemActivator : BlockItemActivator + { + public BlockListItemActivator(BlockEditorConverter blockConverter) : base(blockConverter) + { + } + + protected override Type GenericItemType => typeof(BlockListItem<,>); + } +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockPropertyValueConverterBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockPropertyValueConverterBase.cs index 7da2bd8b7a..d10412dd4a 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockPropertyValueConverterBase.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockPropertyValueConverterBase.cs @@ -8,6 +8,7 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; +[Obsolete("Please use implementations of BlockPropertyValueCreatorBase instead of this. See BlockListPropertyValueConverter for inspiration.. Will be removed in V15.")] public abstract class BlockPropertyValueConverterBase : PropertyValueConverterBase where TBlockItemModel : class, IBlockReference where TBlockLayoutItem : IBlockLayoutItem diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockPropertyValueCreatorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockPropertyValueCreatorBase.cs new file mode 100644 index 0000000000..84a82338db --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockPropertyValueCreatorBase.cs @@ -0,0 +1,266 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using System.Reflection; +using Umbraco.Cms.Core.Models.Blocks; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; + +internal abstract class BlockPropertyValueCreatorBase + where TBlockModel : BlockModelCollection + where TBlockItemModel : class, IBlockReference + where TBlockLayoutItem : IBlockLayoutItem + where TBlockConfiguration : IBlockConfiguration +{ + /// + /// Creates a specific data converter for the block property implementation. + /// + /// + protected abstract BlockEditorDataConverter CreateBlockEditorDataConverter(); + + /// + /// Creates a specific block item activator for the block property implementation. + /// + /// + protected abstract BlockItemActivator CreateBlockItemActivator(); + + /// + /// Creates an empty block model, i.e. for uninitialized or invalid property values. + /// + /// + protected delegate TBlockModel CreateEmptyBlockModel(); + + /// + /// Creates a block model for a list of block items. + /// + /// The block items to base the block model on. + /// + protected delegate TBlockModel CreateBlockModelFromItems(IList blockItems); + + /// + /// Creates a block item from a block layout item. + /// + /// The block layout item to base the block item on. + /// + protected delegate TBlockItemModel? CreateBlockItemModelFromLayout(TBlockLayoutItem layoutItem); + + /// + /// Enriches a block item after it has been created by the block item activator. Use this to set block item data based on concrete block layout and configuration. + /// + /// The block item to enrich. + /// The block layout item for the block item being enriched. + /// The configuration of the block. + /// Delegate for creating new block items from block layout items. + /// + protected delegate TBlockItemModel? EnrichBlockItemModelFromConfiguration(TBlockItemModel item, TBlockLayoutItem layoutItem, TBlockConfiguration configuration, CreateBlockItemModelFromLayout blockItemModelCreator); + + protected BlockPropertyValueCreatorBase(BlockEditorConverter blockEditorConverter) => BlockEditorConverter = blockEditorConverter; + + protected BlockEditorConverter BlockEditorConverter { get; } + + protected TBlockModel CreateBlockModel( + PropertyCacheLevel referenceCacheLevel, + string intermediateBlockModelValue, + bool preview, + IEnumerable blockConfigurations, + CreateEmptyBlockModel createEmptyModel, + CreateBlockModelFromItems createModelFromItems, + EnrichBlockItemModelFromConfiguration? enrichBlockItem = null) + { + // Short-circuit on empty values + if (intermediateBlockModelValue.IsNullOrWhiteSpace()) + { + return createEmptyModel(); + } + + BlockEditorDataConverter blockEditorDataConverter = CreateBlockEditorDataConverter(); + BlockEditorData converted = blockEditorDataConverter.Deserialize(intermediateBlockModelValue); + return CreateBlockModel(referenceCacheLevel, converted, preview, blockConfigurations, createEmptyModel, createModelFromItems, enrichBlockItem); + } + + protected TBlockModel CreateBlockModel( + PropertyCacheLevel referenceCacheLevel, + BlockValue blockValue, + bool preview, + IEnumerable blockConfigurations, + CreateEmptyBlockModel createEmptyModel, + CreateBlockModelFromItems createModelFromItems, + EnrichBlockItemModelFromConfiguration? enrichBlockItem = null) + { + BlockEditorDataConverter blockEditorDataConverter = CreateBlockEditorDataConverter(); + BlockEditorData converted = blockEditorDataConverter.Convert(blockValue); + return CreateBlockModel(referenceCacheLevel, converted, preview, blockConfigurations, createEmptyModel, createModelFromItems, enrichBlockItem); + } + + private TBlockModel CreateBlockModel( + PropertyCacheLevel referenceCacheLevel, + BlockEditorData converted, + bool preview, + IEnumerable blockConfigurations, + CreateEmptyBlockModel createEmptyModel, + CreateBlockModelFromItems createModelFromItems, + EnrichBlockItemModelFromConfiguration? enrichBlockItem = null) + { + if (converted.BlockValue.ContentData.Count == 0) + { + return createEmptyModel(); + } + + IEnumerable? layout = converted.Layout?.ToObject>(); + if (layout is null) + { + return createEmptyModel(); + } + + var blockConfigMap = blockConfigurations.ToDictionary(bc => bc.ContentElementTypeKey); + + // Convert the content data + var contentPublishedElements = new Dictionary(); + foreach (BlockItemData data in converted.BlockValue.ContentData) + { + if (!blockConfigMap.ContainsKey(data.ContentTypeKey)) + { + continue; + } + + IPublishedElement? element = BlockEditorConverter.ConvertToElement(data, referenceCacheLevel, preview); + if (element == null) + { + continue; + } + + contentPublishedElements[element.Key] = element; + } + + // If there are no content elements, it doesn't matter what is stored in layout + if (contentPublishedElements.Count == 0) + { + return createEmptyModel(); + } + + // Convert the settings data + var settingsPublishedElements = new Dictionary(); + var validSettingsElementTypes = blockConfigMap.Values.Select(x => x.SettingsElementTypeKey) + .Where(x => x.HasValue).Distinct().ToList(); + foreach (BlockItemData data in converted.BlockValue.SettingsData) + { + if (!validSettingsElementTypes.Contains(data.ContentTypeKey)) + { + continue; + } + + IPublishedElement? element = BlockEditorConverter.ConvertToElement(data, referenceCacheLevel, preview); + if (element is null) + { + continue; + } + + settingsPublishedElements[element.Key] = element; + } + + BlockItemActivator blockItemActivator = CreateBlockItemActivator(); + + TBlockItemModel? CreateBlockItem(TBlockLayoutItem layoutItem) + { + // Get the content reference + var contentGuidUdi = (GuidUdi?)layoutItem.ContentUdi; + if (contentGuidUdi is null || + !contentPublishedElements.TryGetValue(contentGuidUdi.Guid, out IPublishedElement? contentData)) + { + return null; + } + + if (!blockConfigMap.TryGetValue( + contentData.ContentType.Key, + out TBlockConfiguration? blockConfig)) + { + return null; + } + + // Get the setting reference + IPublishedElement? settingsData = null; + var settingGuidUdi = (GuidUdi?)layoutItem.SettingsUdi; + if (settingGuidUdi is not null) + { + settingsPublishedElements.TryGetValue(settingGuidUdi.Guid, out settingsData); + } + + // This can happen if they have a settings type, save content, remove the settings type, and display the front-end page before saving the content again + // We also ensure that the content types match, since maybe the settings type has been changed after this has been persisted + if (settingsData is not null && (!blockConfig.SettingsElementTypeKey.HasValue || + settingsData.ContentType.Key != blockConfig.SettingsElementTypeKey)) + { + settingsData = null; + } + + // Create instance (use content/settings type from configuration) + var blockItem = blockItemActivator.CreateInstance(blockConfig.ContentElementTypeKey, blockConfig.SettingsElementTypeKey, contentGuidUdi, contentData, settingGuidUdi, settingsData); + if (blockItem == null) + { + return null; + } + + if (enrichBlockItem != null) + { + blockItem = enrichBlockItem(blockItem, layoutItem, blockConfig, CreateBlockItem); + } + + return blockItem; + } + + var blockItems = layout.Select(CreateBlockItem).WhereNotNull().ToList(); + return createModelFromItems(blockItems); + } + + // Cache constructors locally (it's tied to the current IPublishedSnapshot and IPublishedModelFactory) + protected abstract class BlockItemActivator + { + protected abstract Type GenericItemType { get; } + + private readonly BlockEditorConverter _blockConverter; + + private readonly + Dictionary<(Guid, Guid?), Func> + _constructorCache = new(); + + public BlockItemActivator(BlockEditorConverter blockConverter) + => _blockConverter = blockConverter; + + public T CreateInstance(Guid contentTypeKey, Guid? settingsTypeKey, Udi contentUdi, IPublishedElement contentData, Udi? settingsUdi, IPublishedElement? settingsData) + { + if (!_constructorCache.TryGetValue( + (contentTypeKey, settingsTypeKey), + out Func? constructor)) + { + constructor = _constructorCache[(contentTypeKey, settingsTypeKey)] = + EmitConstructor(contentTypeKey, settingsTypeKey); + } + + return constructor(contentUdi, contentData, settingsUdi, settingsData); + } + + private Func EmitConstructor( + Guid contentTypeKey, Guid? settingsTypeKey) + { + Type contentType = _blockConverter.GetModelType(contentTypeKey); + Type settingsType = settingsTypeKey.HasValue + ? _blockConverter.GetModelType(settingsTypeKey.Value) + : typeof(IPublishedElement); + Type type = GenericItemType.MakeGenericType(contentType, settingsType); + + ConstructorInfo? constructor = + type.GetConstructor(new[] { typeof(Udi), contentType, typeof(Udi), settingsType }); + if (constructor == null) + { + throw new InvalidOperationException($"Could not find the required public constructor on {type}."); + } + + // We use unsafe here, because we know the constructor parameter count and types match + return ReflectionUtilities + .EmitConstructorUnsafe>( + constructor); + } + } +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RichTextBlockPropertyValueCreator.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RichTextBlockPropertyValueCreator.cs new file mode 100644 index 0000000000..b4ff9510f1 --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RichTextBlockPropertyValueCreator.cs @@ -0,0 +1,38 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Umbraco.Cms.Core.Models.Blocks; + +namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; + +internal class RichTextBlockPropertyValueCreator : BlockPropertyValueCreatorBase +{ + public RichTextBlockPropertyValueCreator(BlockEditorConverter blockEditorConverter) + : base(blockEditorConverter) + { + } + + public RichTextBlockModel CreateBlockModel(PropertyCacheLevel referenceCacheLevel, BlockValue blockValue, bool preview, RichTextConfiguration.RichTextBlockConfiguration[] blockConfigurations) + { + RichTextBlockModel CreateEmptyModel() => RichTextBlockModel.Empty; + + RichTextBlockModel CreateModel(IList items) => new RichTextBlockModel(items); + + RichTextBlockModel blockModel = CreateBlockModel(referenceCacheLevel, blockValue, preview, blockConfigurations, CreateEmptyModel, CreateModel); + + return blockModel; + } + + protected override BlockEditorDataConverter CreateBlockEditorDataConverter() => new RichTextEditorBlockDataConverter(); + + protected override BlockItemActivator CreateBlockItemActivator() => new RichTextBlockItemActivator(BlockEditorConverter); + + private class RichTextBlockItemActivator : BlockItemActivator + { + public RichTextBlockItemActivator(BlockEditorConverter blockConverter) : base(blockConverter) + { + } + + protected override Type GenericItemType => typeof(RichTextBlockItem<,>); + } +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RichTextParsingRegexes.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RichTextParsingRegexes.cs new file mode 100644 index 0000000000..c7fa4e4592 --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RichTextParsingRegexes.cs @@ -0,0 +1,9 @@ +using System.Text.RegularExpressions; + +namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; + +internal static partial class RichTextParsingRegexes +{ + [GeneratedRegex(".[^\"]*)\"><\\/umb-rte-block(?:-inline)?>")] + public static partial Regex BlockRegex(); +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteMacroRenderingValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteMacroRenderingValueConverter.cs index 5af2520cfc..649fbf36df 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteMacroRenderingValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteMacroRenderingValueConverter.cs @@ -3,9 +3,12 @@ using System.Globalization; using System.Text; +using System.Text.RegularExpressions; using HtmlAgilityPack; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Blocks; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Macros; @@ -15,8 +18,11 @@ using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Core.Templates; using Umbraco.Cms.Core.Web; using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.Blocks; using Umbraco.Cms.Infrastructure.Macros; using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Infrastructure.Extensions; using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; @@ -35,6 +41,11 @@ public class RteMacroRenderingValueConverter : SimpleTinyMceValueConverter, IDel private readonly HtmlUrlParser _urlParser; private readonly IApiRichTextElementParser _apiRichTextElementParser; private readonly IApiRichTextMarkupParser _apiRichTextMarkupParser; + private readonly IPartialViewBlockEngine _partialViewBlockEngine; + private readonly BlockEditorConverter _blockEditorConverter; + private readonly IJsonSerializer _jsonSerializer; + private readonly ILogger _logger; + private readonly IApiElementBuilder _apiElementBuilder; private DeliveryApiSettings _deliveryApiSettings; [Obsolete("Please use the constructor that takes all arguments. Will be removed in V14.")] @@ -52,9 +63,34 @@ public class RteMacroRenderingValueConverter : SimpleTinyMceValueConverter, IDel { } + [Obsolete("Please use the constructor that takes all arguments. Will be removed in V15.")] public RteMacroRenderingValueConverter(IUmbracoContextAccessor umbracoContextAccessor, IMacroRenderer macroRenderer, HtmlLocalLinkParser linkParser, HtmlUrlParser urlParser, HtmlImageSourceParser imageSourceParser, IApiRichTextElementParser apiRichTextElementParser, IApiRichTextMarkupParser apiRichTextMarkupParser, IOptionsMonitor deliveryApiSettingsMonitor) + : this( + umbracoContextAccessor, + macroRenderer, + linkParser, + urlParser, + imageSourceParser, + apiRichTextElementParser, + apiRichTextMarkupParser, + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService>(), + deliveryApiSettingsMonitor + ) + { + } + + public RteMacroRenderingValueConverter(IUmbracoContextAccessor umbracoContextAccessor, IMacroRenderer macroRenderer, + HtmlLocalLinkParser linkParser, HtmlUrlParser urlParser, HtmlImageSourceParser imageSourceParser, + IApiRichTextElementParser apiRichTextElementParser, IApiRichTextMarkupParser apiRichTextMarkupParser, + IPartialViewBlockEngine partialViewBlockEngine, BlockEditorConverter blockEditorConverter, IJsonSerializer jsonSerializer, + IApiElementBuilder apiElementBuilder, ILogger logger, + IOptionsMonitor deliveryApiSettingsMonitor) { _umbracoContextAccessor = umbracoContextAccessor; _macroRenderer = macroRenderer; @@ -63,6 +99,11 @@ public class RteMacroRenderingValueConverter : SimpleTinyMceValueConverter, IDel _imageSourceParser = imageSourceParser; _apiRichTextElementParser = apiRichTextElementParser; _apiRichTextMarkupParser = apiRichTextMarkupParser; + _partialViewBlockEngine = partialViewBlockEngine; + _blockEditorConverter = blockEditorConverter; + _jsonSerializer = jsonSerializer; + _apiElementBuilder = apiElementBuilder; + _logger = logger; _deliveryApiSettings = deliveryApiSettingsMonitor.CurrentValue; deliveryApiSettingsMonitor.OnChange(settings => _deliveryApiSettings = settings); } @@ -73,6 +114,26 @@ public class RteMacroRenderingValueConverter : SimpleTinyMceValueConverter, IDel // to be cached at the published snapshot level, because we have no idea what the macros may depend on actually. PropertyCacheLevel.Snapshot; + // to counterweigh the cache level, we're going to do as much of the heavy lifting as we can while converting source to intermediate + public override object? ConvertSourceToIntermediate(IPublishedElement owner, IPublishedPropertyType propertyType, object? source, bool preview) + { + if (RichTextPropertyEditorHelper.TryParseRichTextEditorValue(source, _jsonSerializer, _logger, out RichTextEditorValue? richTextEditorValue) is false) + { + return null; + } + + // the reference cache level is .Element here, as is also the case when rendering at property level. + RichTextBlockModel? richTextBlockModel = richTextEditorValue.Blocks is not null + ? ParseRichTextBlockModel(richTextEditorValue.Blocks, propertyType, PropertyCacheLevel.Element, preview) + : null; + + return new RichTextEditorIntermediateValue + { + Markup = richTextEditorValue.Markup, + RichTextBlockModel = richTextBlockModel + }; + } + public override object ConvertIntermediateToObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview) { @@ -90,18 +151,18 @@ public class RteMacroRenderingValueConverter : SimpleTinyMceValueConverter, IDel public object? ConvertIntermediateToDeliveryApiObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview, bool expanding) { - var sourceString = inter?.ToString(); - if (sourceString.IsNullOrWhiteSpace()) + if (inter is not RichTextEditorIntermediateValue richTextEditorIntermediateValue + || richTextEditorIntermediateValue.Markup.IsNullOrWhiteSpace()) { // different return types for the JSON configuration forces us to have different return values for empty properties return _deliveryApiSettings.RichTextOutputAsJson is false - ? new RichTextModel { Markup = string.Empty } + ? RichTextModel.Empty() : null; } return _deliveryApiSettings.RichTextOutputAsJson is false - ? new RichTextModel { Markup = _apiRichTextMarkupParser.Parse(sourceString) } - : _apiRichTextElementParser.Parse(sourceString); + ? CreateRichTextModel(richTextEditorIntermediateValue) + : _apiRichTextElementParser.Parse(richTextEditorIntermediateValue.Markup, richTextEditorIntermediateValue.RichTextBlockModel); } // NOT thread-safe over a request because it modifies the @@ -135,12 +196,12 @@ public class RteMacroRenderingValueConverter : SimpleTinyMceValueConverter, IDel private string? Convert(object? source, bool preview) { - if (source == null) + if (source is not RichTextEditorIntermediateValue intermediateValue) { return null; } - var sourceString = source.ToString()!; + var sourceString = intermediateValue.Markup; // ensures string is parsed for {localLink} and URLs and media are resolved correctly sourceString = _linkParser.EnsureInternalLinks(sourceString, preview); @@ -150,6 +211,9 @@ public class RteMacroRenderingValueConverter : SimpleTinyMceValueConverter, IDel // ensure string is parsed for macros and macros are executed correctly sourceString = RenderRteMacros(sourceString, preview); + // render blocks + sourceString = RenderRichTextBlockModel(sourceString, intermediateValue.RichTextBlockModel); + // find and remove the rel attributes used in the Umbraco UI from img tags var doc = new HtmlDocument(); doc.LoadHtml(sourceString); @@ -192,4 +256,57 @@ public class RteMacroRenderingValueConverter : SimpleTinyMceValueConverter, IDel return sourceString; } + + private RichTextBlockModel? ParseRichTextBlockModel(BlockValue blocks, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, bool preview) + { + RichTextConfiguration? configuration = propertyType.DataType.ConfigurationAs(); + if (configuration?.Blocks?.Any() is not true) + { + return null; + } + + var creator = new RichTextBlockPropertyValueCreator(_blockEditorConverter); + return creator.CreateBlockModel(referenceCacheLevel, blocks, preview, configuration.Blocks); + } + + private string RenderRichTextBlockModel(string source, RichTextBlockModel? richTextBlockModel) + { + if (richTextBlockModel is null || richTextBlockModel.Any() is false) + { + return source; + } + + var blocksByUdi = richTextBlockModel.ToDictionary(block => block.ContentUdi); + + string RenderBlock(Match match) => + UdiParser.TryParse(match.Groups["udi"].Value, out Udi? udi) && blocksByUdi.TryGetValue(udi, out RichTextBlockItem? richTextBlockItem) + ? _partialViewBlockEngine.ExecuteAsync(richTextBlockItem).GetAwaiter().GetResult() + : string.Empty; + + return RichTextParsingRegexes.BlockRegex().Replace(source, RenderBlock); + } + + private RichTextModel CreateRichTextModel(RichTextEditorIntermediateValue richTextEditorIntermediateValue) + { + var markup = _apiRichTextMarkupParser.Parse(richTextEditorIntermediateValue.Markup); + + ApiBlockItem[] blocks = richTextEditorIntermediateValue.RichTextBlockModel is not null + ? richTextEditorIntermediateValue.RichTextBlockModel + .Select(item => item.CreateApiBlockItem(_apiElementBuilder)) + .ToArray() + : Array.Empty(); + + return new RichTextModel + { + Markup = markup, + Blocks = blocks + }; + } + + private class RichTextEditorIntermediateValue + { + public required string Markup { get; set; } + + public required RichTextBlockModel? RichTextBlockModel { get; set; } + } } diff --git a/src/Umbraco.Web.Common/Blocks/PartialViewBlockEngine.cs b/src/Umbraco.Web.Common/Blocks/PartialViewBlockEngine.cs new file mode 100644 index 0000000000..069e29db92 --- /dev/null +++ b/src/Umbraco.Web.Common/Blocks/PartialViewBlockEngine.cs @@ -0,0 +1,74 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Razor; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.ViewEngines; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.Blocks; +using Umbraco.Cms.Core.Models.Blocks; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Web.Common.Controllers; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Web.Common.Blocks; + +internal sealed class PartialViewBlockEngine : IPartialViewBlockEngine +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IModelMetadataProvider _modelMetadataProvider; + private readonly ITempDataDictionaryFactory _tempDataDictionaryFactory; + + public PartialViewBlockEngine( + IHttpContextAccessor httpContextAccessor, + IModelMetadataProvider modelMetadataProvider, + ITempDataDictionaryFactory tempDataDictionaryFactory) + { + _httpContextAccessor = httpContextAccessor; + _modelMetadataProvider = modelMetadataProvider; + _tempDataDictionaryFactory = tempDataDictionaryFactory; + } + + public async Task ExecuteAsync(IBlockReference blockReference) + { + HttpContext httpContext = _httpContextAccessor.GetRequiredHttpContext(); + RouteData currentRouteData = httpContext.GetRouteData(); + + // Check if there's proxied ViewData (i.e. returned from a SurfaceController) + ProxyViewDataFeature? proxyViewDataFeature = httpContext.Features.Get(); + ViewDataDictionary viewData = proxyViewDataFeature?.ViewData + ?? new ViewDataDictionary(_modelMetadataProvider, new ModelStateDictionary()); + viewData.Model = blockReference; + + ITempDataDictionary tempData = proxyViewDataFeature?.TempData + ?? _tempDataDictionaryFactory.GetTempData(httpContext); + + var actionContext = new ActionContext(httpContext, currentRouteData, new ControllerActionDescriptor()); + IRazorViewEngine razorViewEngine = httpContext.RequestServices.GetRequiredService(); + + var viewPath = $"~/Views/Partials/richtext/Components/{blockReference.Content.ContentType.Alias}.cshtml"; + ViewEngineResult viewResult = razorViewEngine.GetView(null, viewPath, false); + + if (viewResult.View is null) + { + throw new ArgumentException($"{viewPath} does not match any available view"); + } + + await using var writer = new StringWriter(); + + var viewContext = new ViewContext( + actionContext, + viewResult.View, + viewData, + tempData, + writer, + new HtmlHelperOptions()); + + await viewResult.View.RenderAsync(viewContext); + + return writer.ToString(); + } +} diff --git a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs index 74977c9969..2dd828f9c2 100644 --- a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs @@ -19,6 +19,7 @@ using Smidge.FileProcessors; using Smidge.InMemory; using Smidge.Nuglify; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Blocks; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Composing; using Umbraco.Cms.Core.Configuration.Models; @@ -46,6 +47,7 @@ using Umbraco.Cms.Infrastructure.Persistence.SqlSyntax; using Umbraco.Cms.Web.Common; using Umbraco.Cms.Web.Common.ApplicationModels; using Umbraco.Cms.Web.Common.AspNetCore; +using Umbraco.Cms.Web.Common.Blocks; using Umbraco.Cms.Web.Common.Configuration; using Umbraco.Cms.Web.Common.Controllers; using Umbraco.Cms.Web.Common.DependencyInjection; @@ -339,6 +341,7 @@ public static partial class UmbracoBuilderExtensions }); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); // register the umbraco context factory builder.Services.AddUnique(); diff --git a/src/Umbraco.Web.UI.Client/gulp/config.js b/src/Umbraco.Web.UI.Client/gulp/config.js index a2eb211266..859f5958c9 100755 --- a/src/Umbraco.Web.UI.Client/gulp/config.js +++ b/src/Umbraco.Web.UI.Client/gulp/config.js @@ -37,7 +37,8 @@ module.exports = { umbraco: { files: "./src/less/belle.less", watch: "./src/**/*.less", out: "umbraco.min.css" }, rteContent: { files: "./src/less/rte-content.less", watch: "./src/less/**/*.less", out: "rte-content.css" }, icons: { files: "./src/less/icons.less", watch: "./src/less/**/*.less", out: "icons.css" }, - blockgridui: { files: "./src/views/propertyeditors/blockgrid/blockgridui.less", watch: "./src/views/propertyeditors/blockgrid/blockgridui.less", out: "blockgridui.css" } + blockgridui: { files: "./src/views/propertyeditors/blockgrid/blockgridui.less", watch: "./src/views/propertyeditors/blockgrid/blockgridui.less", out: "blockgridui.css" }, + blockrteui: { files: "./src/views/propertyeditors/rte/blockrteui.less", watch: "./src/views/propertyeditors/rte/blockrteui.less", out: "blockrteui.css" } }, // js files for backoffice diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/grid/grid.rte.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/grid/grid.rte.directive.js index 9090195d08..812fec6e9c 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/grid/grid.rte.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/grid/grid.rte.directive.js @@ -17,7 +17,7 @@ angular.module("umbraco.directives") scope.isLoading = true; var promises = []; - + //To id the html textarea we need to use the datetime ticks because we can have multiple rte's per a single property alias // because now we have to support having 2x (maybe more at some stage) content editors being displayed at once. This is because // we have this mini content editor panel that can be launched with MNTP. @@ -26,8 +26,8 @@ angular.module("umbraco.directives") var editorConfig = scope.configuration ? scope.configuration : null; if (!editorConfig || Utilities.isString(editorConfig)) { editorConfig = tinyMceService.defaultPrevalues(); - //for the grid by default, we don't want to include the macro toolbar - editorConfig.toolbar = _.without(editorConfig, "umbmacro"); + //for the grid by default, we don't want to include the macro or the block-picker toolbar + editorConfig.toolbar = _.without(editorConfig, "umbmacro", "umbblockpicker"); } //ensure the grid's global config is being passed up to the RTE, these 2 properties need to be in this format @@ -125,7 +125,7 @@ angular.module("umbraco.directives") } }); - + //when the element is disposed we need to unsubscribe! // NOTE: this is very important otherwise if this is part of a modal, the listener still exists because the dom // element might still be there even after the modal has been hidden. diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/validation/valservermatch.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/validation/valservermatch.directive.js index b07ab55436..69d1996e9a 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/validation/valservermatch.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/validation/valservermatch.directive.js @@ -13,7 +13,7 @@ function valServerMatch(serverValidationManager) { return { - require: ['form', '^^umbProperty', '?^^umbVariantContent'], + require: ['form', '?^^umbProperty', '?^^umbVariantContent'], restrict: "A", scope: { valServerMatch: "=" @@ -22,17 +22,19 @@ function valServerMatch(serverValidationManager) { var formCtrl = ctrls[0]; var umbPropCtrl = ctrls[1]; - if (!umbPropCtrl) { + // You can skip the requirement of ^^umbProperty, by parsing the culture and segment as part of valServerMatch object. + if (!umbPropCtrl && scope.valServerMatch.culture === undefined) { + console.log("val server blocked.", scope.valServerMatch) //we cannot proceed, this validator will be disabled return; } - // optional reference to the varaint-content-controller, needed to avoid validation when the field is invariant on non-default languages. + // optional reference to the variant-content-controller, needed to avoid validation when the field is invariant on non-default languages. var umbVariantCtrl = ctrls[2]; - var currentProperty = umbPropCtrl.property; - var currentCulture = currentProperty.culture; - var currentSegment = currentProperty.segment; + var currentProperty = umbPropCtrl ? umbPropCtrl.property : undefined; + var currentCulture = umbPropCtrl ? currentProperty.culture : scope.valServerMatch.culture; + var currentSegment = umbPropCtrl ? currentProperty.segment : scope.valServerMatch.segment; if (umbVariantCtrl) { //if we are inside of an umbVariantContent directive @@ -84,9 +86,13 @@ function valServerMatch(serverValidationManager) { if (Utilities.isObject(scope.valServerMatch)) { var allowedKeys = ["contains", "prefix", "suffix"]; - Object.keys(scope.valServerMatch).forEach(matchType => { + const objectKeys = Object.keys(scope.valServerMatch); + if(objectKeys.find(x => allowedKeys.x)) { + throw "valServerMatch dictionary keys must be one of " + allowedKeys.join(); + } + objectKeys.forEach(matchType => { if (allowedKeys.indexOf(matchType) === -1) { - throw "valServerMatch dictionary keys must be one of " + allowedKeys.join(); + return; } var matchVal = scope.valServerMatch[matchType]; diff --git a/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js b/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js index 606d16ad2d..d045340568 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js @@ -300,7 +300,7 @@ this.value.settingsData = this.value.settingsData || []; this.propertyEditorAlias = propertyEditorAlias; - this.blockConfigurations = blockConfigurations; + this.blockConfigurations = blockConfigurations ?? []; this.blockConfigurations.forEach(blockConfiguration => { if (blockConfiguration.view != null && blockConfiguration.view !== "") { @@ -396,18 +396,20 @@ // removing duplicates. scaffoldKeys = scaffoldKeys.filter((value, index, self) => self.indexOf(value) === index); - tasks.push(contentResource.getScaffoldByKeys(-20, scaffoldKeys).then(scaffolds => { - Object.values(scaffolds).forEach(scaffold => { - // self.scaffolds might not exists anymore, this happens if this instance has been destroyed before the load is complete. - if (self.scaffolds) { - self.scaffolds.push(formatScaffoldData(scaffold)); - } - }); - }).catch( - () => { - // Do nothing if we get an error. - } - )); + if(scaffoldKeys.length > 0) { + tasks.push(contentResource.getScaffoldByKeys(-20, scaffoldKeys).then(scaffolds => { + Object.values(scaffolds).forEach(scaffold => { + // self.scaffolds might not exists anymore, this happens if this instance has been destroyed before the load is complete. + if (self.scaffolds) { + self.scaffolds.push(formatScaffoldData(scaffold)); + } + }); + }).catch( + () => { + // Do nothing if we get an error. + } + )); + } return $q.all(tasks); }, @@ -525,17 +527,20 @@ } var dataModel = getDataByUdi(contentUdi, this.value.contentData); - - if (dataModel === null) { - console.error("Couldn't find content data of UDI:", contentUdi, "layoutEntry:", layoutEntry) - return null; - } - - var blockConfiguration = this.getBlockConfiguration(dataModel.contentTypeKey); + var blockConfiguration = null; var contentScaffold = null; + if (dataModel === null) { + console.error("Couldn't find content data of UDI:", contentUdi, "layoutEntry:", layoutEntry) + //return null; + } else { + blockConfiguration = this.getBlockConfiguration(dataModel.contentTypeKey); + } + if (blockConfiguration === null) { + if(dataModel) { console.warn("The block of " + contentUdi + " is not being initialized because its contentTypeKey('" + dataModel.contentTypeKey + "') is not allowed for this PropertyEditor"); + } } else { contentScaffold = this.getScaffoldFromKey(blockConfiguration.contentElementTypeKey); if (contentScaffold === null) { @@ -641,7 +646,7 @@ }; // first time instant update of label. blockObject.label = blockObject.content?.contentTypeName || ""; - blockObject.index = 0; + blockObject.index = 0; if (blockObject.config.label && blockObject.config.label !== "" && blockObject.config.unsupported !== true) { diff --git a/src/Umbraco.Web.UI.Client/src/common/services/tinymce.service.js b/src/Umbraco.Web.UI.Client/src/common/services/tinymce.service.js index 829b7d66a4..7767e3c17b 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/tinymce.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/tinymce.service.js @@ -9,11 +9,11 @@ * @doc https://www.tiny.cloud/docs/tinymce/6/ */ function tinyMceService($rootScope, $q, imageHelper, $locale, $http, $timeout, stylesheetResource, macroResource, macroService, - $routeParams, umbRequestHelper, angularHelper, userService, editorService, entityResource, eventsService, localStorageService, mediaHelper, fileManager) { + $routeParams, umbRequestHelper, angularHelper, userService, editorService, entityResource, eventsService, localStorageService, mediaHelper, fileManager, $compile) { //These are absolutely required in order for the macros to render inline //we put these as extended elements because they get merged on top of the normal allowed elements by tiny mce - var extendedValidElements = "@[id|class|style],-div[id|dir|class|align|style],ins[datetime|cite],-ul[class|style],-li[class|style],-h1[id|dir|class|align|style],-h2[id|dir|class|align|style],-h3[id|dir|class|align|style],-h4[id|dir|class|align|style],-h5[id|dir|class|align|style],-h6[id|style|dir|class|align],span[id|class|style|lang],figure,figcaption"; + var extendedValidElements = "@[id|class|style],umb-rte-block[!data-content-udi],-umb-rte-block-inline[!data-content-udi],-div[id|dir|class|align|style],ins[datetime|cite],-ul[class|style],-li[class|style],-h1[id|dir|class|align|style],-h2[id|dir|class|align|style],-h3[id|dir|class|align|style],-h4[id|dir|class|align|style],-h5[id|dir|class|align|style],-h6[id|style|dir|class|align],span[id|class|style|lang],figure,figcaption"; var fallbackStyles = [ { title: 'Headers', items: [ @@ -389,6 +389,7 @@ function tinyMceService($rootScope, $q, imageHelper, $locale, $http, $timeout, s var config = { inline: modeInline, plugins: plugins, + custom_elements: 'umb-rte-block,~umb-rte-block-inline', valid_elements: tinyMceConfig.validElements, invalid_elements: tinyMceConfig.inValidElements, extended_valid_elements: extendedValidElements, @@ -481,7 +482,7 @@ function tinyMceService($rootScope, $q, imageHelper, $locale, $http, $timeout, s Utilities.extend(config, tinyMceConfig.customConfig); } - if(!config.style_formats || !config.style_formats.length){ + if(!config.style_formats || !config.style_formats.length) { // if we have no style_formats at this point we'll revert to using the default ones (fallbackStyles) config.style_formats = fallbackStyles; } @@ -669,6 +670,7 @@ function tinyMceService($rootScope, $q, imageHelper, $locale, $http, $timeout, s } }); }, + /** * @ngdoc method * @name umbraco.services.tinyMceService#insetMediaInEditor @@ -739,6 +741,91 @@ function tinyMceService($rootScope, $q, imageHelper, $locale, $http, $timeout, s } }, + + + /** + * @ngdoc method + * @name umbraco.services.tinyMceService#createBlockPicker + * @methodOf umbraco.services.tinyMceService + * + * @description + * Creates the umbraco insert block tinymce plugin + * + * @param {Object} editor the TinyMCE editor instance + */ + createBlockPicker: function (editor, blockEditorApi, callback) { + + editor.on('preInit', function (args) { + editor.serializer.addRules('umb-rte-block'); + + /** This checks if the div is a block element*/ + editor.serializer.addNodeFilter('umb-rte-block', function (nodes, name) { + for (var i = 0; i < nodes.length; i++) { + + const blockEl = nodes[i]; + /* + const block = blockEditorApi.getBlockByContentUdi(blockEl.attr("data-content-udi")); + if(block) { + const displayAsBlock = block.config.displayInline !== true; + */ + + /* if the block is set to display inline, checks if its wrapped in a p tag and then unwraps it (removes p tag) */ + if (blockEl.parent && blockEl.parent.name.toUpperCase() === "P") { + blockEl.parent.unwrap(); + } + //} + + } + }); + }); + + editor.ui.registry.addButton('umbblockpicker', { + icon: 'visualblocks', + tooltip: 'Insert Block', + stateSelector: 'umb-rte-block[data-content-udi], umb-rte-block-inline[data-content-udi]', + onAction: function () { + + var blockEl = editor.selection.getNode(); + var blockUdi; + + if (blockEl.nodeName === 'UMB-RTE-BLOCK' || blockEl.nodeName === 'UMB-RTE-BLOCK-INLINE') { + blockUdi = blockEl.getAttribute("data-content-udi") ?? undefined; + } + + if (callback) { + angularHelper.safeApply($rootScope, function () { + callback(blockUdi); + }); + } + } + }); + }, + + /** + * @ngdoc method + * @name umbraco.services.tinyMceService#insetBlockInEditor + * @methodOf umbraco.services.tinyMceService + * + * @description + * Inserts the block element in tinymce plugin + * + * @param {Object} blockUdi UDI of Block to insert + */ + insertBlockInEditor: function (editor, blockContentUdi, displayInline) { + if (blockContentUdi) { + if(displayInline) { + editor.selection.setContent(''); + } else { + editor.selection.setContent(''); + } + + angularHelper.safeApply($rootScope, function () { + editor.dispatch("Change"); + }); + + } + }, + /** * @ngdoc method * @name umbraco.services.tinyMceService#createUmbracoMacro @@ -1270,9 +1357,15 @@ function tinyMceService($rootScope, $q, imageHelper, $locale, $http, $timeout, s if (!args.editor) { throw "args.editor is required"; } - //if (!args.model.value) { - // throw "args.model.value is required"; - //} + if (!args.scope) { + args.scope = $rootScope; + } + if (args.getValue && !args.setValue) { + throw "args.setValue is required when getValue is set"; + } + if (args.setValue && !args.getValue) { + throw "args.getValue is required when setValue is set"; + } // force TinyMCE to load plugins/themes from minified files (see http://archive.tinymce.com/wiki.php/api4:property.tinymce.suffix.static) args.editor.suffix = ".min"; @@ -1282,15 +1375,24 @@ function tinyMceService($rootScope, $q, imageHelper, $locale, $http, $timeout, s var unwatch = null; + const getPropertyValue = args.getValue ? args.getValue : function () { + return args.model.value + } + const setPropertyValue = args.setValue ? args.setValue : function (newVal) { + args.model.value = newVal; + } + //Starts a watch on the model value so that we can update TinyMCE if the model changes behind the scenes or from the server function startWatch() { - unwatch = $rootScope.$watch(() => args.model.value, function (newVal, oldVal) { + + unwatch = args.scope.$watch(() => getPropertyValue(), function (newVal, oldVal) { if (newVal !== oldVal) { //update the display val again if it has changed from the server; //uses an empty string in the editor when the value is null args.editor.setContent(newVal || "", { format: 'raw' }); + initBlocks(); - //we need to manually dispatch this event since it is only ever dispatchd based on loading from the DOM, this + // we need to manually dispatch this event since it is only ever dispatched based on loading from the DOM, this // is required for our plugins listening to this event to execute args.editor.dispatch('LoadContent', null); } @@ -1306,14 +1408,17 @@ function tinyMceService($rootScope, $q, imageHelper, $locale, $http, $timeout, s function syncContent() { - if (args.model.value === args.editor.getContent()) { + const content = args.editor.getContent() + + if (getPropertyValue() === content) { return; } //stop watching before we update the value stopWatch(); angularHelper.safeApply($rootScope, function () { - args.model.value = args.editor.getContent(); + + setPropertyValue(content); //make the form dirty manually so that the track changes works, setting our model doesn't trigger // the angular bits because tinymce replaces the textarea. @@ -1330,6 +1435,55 @@ function tinyMceService($rootScope, $q, imageHelper, $locale, $http, $timeout, s startWatch(); } + function initBlocks() { + + const blockEls = args.editor.contentDocument.querySelectorAll('umb-rte-block, umb-rte-block-inline'); + for (var blockEl of blockEls) { + if(!blockEl._isInitializedUmbBlock) { + const blockContentUdi = blockEl.getAttribute('data-content-udi'); + if(blockContentUdi && !blockEl.$block) { + const block = args.blockEditorApi.getBlockByContentUdi(blockContentUdi); + if(block) { + blockEl.removeAttribute('contenteditable'); + + if(block.config.displayInline && blockEl.nodeName.toLowerCase() === 'umb-rte-block') { + // Change element name: + const oldBlockEl = blockEl; + blockEl = document.createElement('umb-rte-block-inline'); + blockEl.appendChild(document.createComment("Umbraco-Block")); + blockEl.setAttribute('data-content-udi', blockContentUdi); + oldBlockEl.parentNode.replaceChild(blockEl, oldBlockEl); + } else if(!block.config.displayInline && blockEl.nodeName.toLowerCase() === 'umb-rte-block-inline') { + // Change element name: + const oldBlockEl = blockEl; + blockEl = document.createElement('umb-rte-block'); + blockEl.appendChild(document.createComment("Umbraco-Block")); + blockEl.setAttribute('data-content-udi', blockContentUdi); + oldBlockEl.parentNode.replaceChild(blockEl, oldBlockEl); + } + + blockEl.$index = block.index; + blockEl.$block = block; + blockEl.$api = args.blockEditorApi; + blockEl.$culture = args.culture; + blockEl.$segment = args.segment; + blockEl.$parentForm = args.parentForm; + blockEl.$valFormManager = args.valFormManager; + $compile(blockEl)(args.scope); + blockEl.setAttribute('contenteditable', 'false'); + //blockEl.setAttribute('draggable', 'true'); + + } else { + blockEl.removeAttribute('data-content-udi'); + args.editor.dom.remove(blockEl); + } + } else { + args.editor.dom.remove(blockEl); + } + } + } + } + // If we can not find the insert image/media toolbar button // Then we need to add an event listener to the editor // That will update native browser drag & drop events @@ -1413,12 +1567,15 @@ function tinyMceService($rootScope, $q, imageHelper, $locale, $http, $timeout, s }); } + initBlocks(); + }); args.editor.on('init', function () { - if (args.model.value) { - args.editor.setContent(args.model.value); + const currentValue = getPropertyValue(); + if (currentValue) { + args.editor.setContent(currentValue); } //enable browser based spell checking @@ -1526,7 +1683,7 @@ function tinyMceService($rootScope, $q, imageHelper, $locale, $http, $timeout, s //create link picker self.createLinkPicker(args.editor, function (currentTarget, anchorElement) { - entityResource.getAnchors(args.model.value).then(anchorValues => { + entityResource.getAnchors(getPropertyValue()).then(anchorValues => { const linkPicker = { currentTarget: currentTarget, @@ -1583,6 +1740,21 @@ function tinyMceService($rootScope, $q, imageHelper, $locale, $http, $timeout, s editorService.mediaPicker(mediaPicker); }); + + //Create the insert block plugin + self.createBlockPicker(args.editor, args.blockEditorApi, function (currentTarget, userData, imgDomElement) { + args.blockEditorApi.showCreateDialog(0, false, (newBlock) => { + // TODO: Handle if its an array: + if(Utilities.isArray(newBlock)) { + newBlock.forEach(block => { + self.insertBlockInEditor(args.editor, block.layout.contentUdi, block.config.displayInline); + }); + } else { + self.insertBlockInEditor(args.editor, newBlock.layout.contentUdi, newBlock.config.displayInline); + } + }); + }); + //Create the embedded plugin self.createInsertEmbeddedMedia(args.editor, function (activeElement, modify) { var embed = { diff --git a/src/Umbraco.Web.UI.Client/src/less/rte-content.less b/src/Umbraco.Web.UI.Client/src/less/rte-content.less index 5a52060a0f..ffd94ff6ad 100644 --- a/src/Umbraco.Web.UI.Client/src/less/rte-content.less +++ b/src/Umbraco.Web.UI.Client/src/less/rte-content.less @@ -48,3 +48,11 @@ color: @blueExtraDark; outline: 2px solid @pinkLight; } + + +.umb-rte.mce-content-body umb-rte-block[data-mce-selected], +.umb-rte.mce-content-body umb-rte-block-inline[data-mce-selected] { + cursor: auto; + --umb-rte-block--selected: 1; + outline: none; +} diff --git a/src/Umbraco.Web.UI.Client/src/less/rte.less b/src/Umbraco.Web.UI.Client/src/less/rte.less index f53ed06513..4f84d80495 100644 --- a/src/Umbraco.Web.UI.Client/src/less/rte.less +++ b/src/Umbraco.Web.UI.Client/src/less/rte.less @@ -146,6 +146,17 @@ } } +.umb-rte-editor-con .tox.tox-tinymce { + border-radius: 6px; + border-width: 1px; + border-color: @inputBorder; +} + +.umb-rte-editor-con .tox:not(.tox-tinymce-inline) .tox-editor-header { + box-shadow: none; + border-bottom: 1px solid #d8d7d9; +} + .tox-tinymce-inline { z-index: 999; } diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/umbBlockListPropertyEditor.component.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/umbBlockListPropertyEditor.component.js index b21eacfae0..c08b93fc72 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/umbBlockListPropertyEditor.component.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/umbBlockListPropertyEditor.component.js @@ -32,7 +32,7 @@ var unsubscribe = []; var modelObject; - + // Property actions: let copyAllBlocksAction = null; let deleteAllBlocksAction = null; @@ -113,7 +113,7 @@ vm.model.config.validationLimit.max == 1 && vm.model.config.blocks.length == 1 && vm.model.config.useSingleBlockMode; - + vm.blockEditorApi.singleBlockMode = vm.singleBlockMode; vm.validationLimit = vm.model.config.validationLimit; @@ -151,7 +151,7 @@ setDirty(); } }; - + copyAllBlocksAction = { labelKey: "clipboard_labelForCopyAllEntries", labelTokens: [vm.model.label], @@ -537,7 +537,7 @@ } vm.requestShowCreate = requestShowCreate; - + function requestShowCreate(createIndex, mouseEvent) { if (vm.blockTypePicker) { @@ -558,15 +558,15 @@ } } - + vm.requestShowClipboard = requestShowClipboard; - + function requestShowClipboard(createIndex) { showCreateDialog(createIndex, true); } vm.showCreateDialog = showCreateDialog; - + function showCreateDialog(createIndex, openClipboard) { if (vm.blockTypePicker) { @@ -618,7 +618,7 @@ } }, close: function() { - // if opned by a inline creator button(index less than length), we want to move the focus away, to hide line-creator. + // If opened by a inline creator button(index less than length), we want to move the focus away, to hide line-creator. if (createIndex < vm.layout.length) { vm.setBlockFocus(vm.layout[Math.max(createIndex-1, 0)].$block); } @@ -791,14 +791,14 @@ // make block model var blockObject = getBlockObject(layoutEntry); if (blockObject === null) { - // Initalization of the Block Object didnt go well, therefor we will fail the paste action. + // Initialization of the Block Object didn't go well, therefor we will fail the paste action. return false; } // set the BlockObject on our layout entry. layoutEntry.$block = blockObject; - // insert layout entry at the decired location in layout. + // insert layout entry at the desired location in layout. vm.layout.splice(index, 0, layoutEntry); vm.currentBlockInFocus = blockObject; @@ -808,7 +808,7 @@ function requestDeleteBlock(block) { if (vm.readonly) return; - + localizationService.localizeMany(["general_delete", "blockEditor_confirmDeleteBlockMessage", "contentTypeEditor_yesDelete"]).then(function (data) { const overlay = { title: data[0], @@ -864,7 +864,7 @@ if (copyAllBlocksAction) { copyAllBlocksAction.isDisabled = vm.layout.length === 0; } - + if (deleteAllBlocksAction) { deleteAllBlocksAction.isDisabled = vm.layout.length === 0 || vm.readonly; } diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/umbblocklistblock.component.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/umbblocklistblock.component.js index 0dc74d7edf..ec58b5a37f 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/umbblocklistblock.component.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/umbblocklistblock.component.js @@ -9,7 +9,7 @@ * If a stylesheet is used then this uses a ShadowDom to make a scoped element. * This way the backoffice styling does not collide with the block style. */ - + angular .module("umbraco") .component("umbBlockListBlock", { diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blockrteui.less b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blockrteui.less new file mode 100644 index 0000000000..428c3e980c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blockrteui.less @@ -0,0 +1,114 @@ +@import "../../../less/variables.less"; +@import "../../../less/mixins.less"; +@import "../../../less/icons.less"; +@import "../../../less/buttons.less"; +@import "../../../less/accessibility/sr-only.less"; + +@umb-block-rte__item_minimum_height: 48px; + + +.umb-block-rte__block { + position: relative; +} + +ng-form.ng-invalid .umb-block-rte__block--actions { + opacity: 1; +} + + +.umb-block-rte--view { + position: relative; +} +.umb-block-rte--view::after { + position:absolute; + content: ''; + inset: 0; + border-style: solid; + border-color: #6ab4f0; + border-width: calc(var(--umb-rte-block--selected, 0) * 2px); + border-radius:3px; + pointer-events:none; +} + +.umb-block-rte__block--actions { + + position: absolute; + top: 0px; + padding-top:10px;/** set to make sure programmatic scrolling gets the top of the block into view. */ + + right: 10px; + + /* + If child block, it can be hidden if a parents sets: --umb-block-rte--block-ui-display: none; + */ + display: flex; + opacity: 1; + z-index:3; + + font-size: 0; + background-color: rgba(255, 255, 255, .96); + border-radius: 16px; + align-items: center; + padding: 0 5px; + margin-top:10px; + + .action { + color: @ui-action-discreet-type; + font-size: 18px; + padding: 5px; + &:hover { + color: @ui-action-discreet-type-hover; + } + } + + .action { + position: relative; + display: inline-block; + + &.--error { + color: @errorBackground; + /** TODO: warning color class does not work in shadowDOM. */ + .show-validation-type-warning & { + color: @warningBackground; + } + } + + > .__error-badge { + position: absolute; + top: -2px; + right: -2px; + min-width: 8px; + color: @white; + background-color: @ui-active-type; + border: 2px solid @white; + border-radius: 50%; + font-size: 8px; + font-weight: bold; + padding: 2px; + line-height: 8px; + background-color: @errorBackground; + .show-validation-type-warning & { + background-color: @warningBackground; + } + display: none; + font-weight: 900; + } + &.--error > .__error-badge { + display: block; + + animation-duration: 1.4s; + animation-iteration-count: infinite; + animation-name: umb-block-rte__action--badge-bounce; + animation-timing-function: ease; + @keyframes umb-block-rte__action--badge-bounce { + 0% { transform: translateY(0); } + 20% { transform: translateY(-4px); } + 40% { transform: translateY(0); } + 55% { transform: translateY(-2px); } + 70% { transform: translateY(0); } + 100% { transform: translateY(0); } + } + + } + } +} diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blocks/blockrteentryeditors/labelblock/rtelabelblock.editor.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blocks/blockrteentryeditors/labelblock/rtelabelblock.editor.html new file mode 100644 index 0000000000..ea8d3bffd9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blocks/blockrteentryeditors/labelblock/rtelabelblock.editor.html @@ -0,0 +1,58 @@ + + +
+ +
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blocks/blockrteentryeditors/unsupportedblock/unsupportedblock.editor.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blocks/blockrteentryeditors/unsupportedblock/unsupportedblock.editor.html new file mode 100644 index 0000000000..259f3d72e6 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blocks/blockrteentryeditors/unsupportedblock/unsupportedblock.editor.html @@ -0,0 +1,59 @@ + + +
+
+ + {{block.config.label}} +
+
+ This content is no longer supported in this context.
+ You might want to remove this block, or contact your developer to take actions for making this block available again.

+ +
Block data:
+
{{block.data | json : 4 }}
+
+
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blocks/prevalue/blockrte.blockconfiguration.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blocks/prevalue/blockrte.blockconfiguration.controller.js new file mode 100644 index 0000000000..8e7892d5a0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blocks/prevalue/blockrte.blockconfiguration.controller.js @@ -0,0 +1,246 @@ +/** + * @ngdoc controller + * @name Umbraco.Editors.BlockRTE.BlockConfigurationController + * @function + * + * @description + * The controller for the content type editor property settings dialog + */ + +(function () { + "use strict"; + + function TransferProperties(fromObject, toObject) { + for (var p in fromObject) { + toObject[p] = fromObject[p]; + } + } + + function BlockConfigurationController($scope, elementTypeResource, overlayService, localizationService, editorService, eventsService, udiService) { + + var unsubscribe = []; + + const vm = this; + vm.openBlock = null; + + function onInit() { + + if (!$scope.model.value) { + $scope.model.value = []; + } + + loadElementTypes(); + } + + function loadElementTypes() { + return elementTypeResource.getAll().then(elementTypes => { + vm.elementTypes = elementTypes; + }); + } + + function updateUsedElementTypes(event, args) { + var key = args.documentType.key; + for (var i = 0; i { + var contentElementType = vm.getElementTypeByKey($scope.model.value[index].contentElementTypeKey); + overlayService.confirmDelete({ + title: data[0], + content: localizationService.tokenReplace(data[1], [contentElementType ? contentElementType.name : "(Unavailable ElementType)"]), + confirmMessage: data[2], + submit: () => { + vm.removeBlockByIndex(index); + overlayService.close(); + }, + close: overlayService.close() + }); + }); + + event.stopPropagation(); + }; + + vm.removeBlockByIndex = function (index) { + $scope.model.value.splice(index, 1); + }; + + vm.sortableOptions = { + "ui-floating": true, + items: "umb-block-card", + cursor: "grabbing", + placeholder: 'umb-block-card --sortable-placeholder' + }; + + vm.getAvailableElementTypes = function () { + return vm.elementTypes.filter(function (type) { + return !$scope.model.value.find(function (entry) { + return type.key === entry.contentElementTypeKey; + }); + }); + }; + + vm.getElementTypeByKey = function(key) { + if (vm.elementTypes) { + return vm.elementTypes.find(type => type.key === key) || null; + } + }; + + vm.openAddDialog = function () { + + localizationService.localize("blockEditor_headlineCreateBlock").then(localizedTitle => { + + const contentTypePicker = { + title: localizedTitle, + section: "settings", + treeAlias: "documentTypes", + entityType: "documentType", + isDialog: true, + filter: function (node) { + if (node.metaData.isElement === true) { + var key = udiService.getKey(node.udi); + + // If a Block with this ElementType as content already exists, we will emit it as a posible option. + return $scope.model.value.find(entry => entry.contentElementTypeKey === key); + } + return true; + }, + filterCssClass: "not-allowed", + select: function (node) { + vm.addBlockFromElementTypeKey(udiService.getKey(node.udi)); + editorService.close(); + }, + close: function () { + editorService.close(); + }, + extraActions: [ + { + style: "primary", + labelKey: "blockEditor_labelcreateNewElementType", + action: function () { + vm.createElementTypeAndCallback((documentTypeKey) => { + vm.addBlockFromElementTypeKey(documentTypeKey); + + // At this point we will close the contentTypePicker. + editorService.close(); + }); + } + } + ] + }; + + editorService.treePicker(contentTypePicker); + }); + }; + + vm.createElementTypeAndCallback = function(callback) { + const editor = { + create: true, + infiniteMode: true, + noTemplate: true, + isElement: true, + submit: function (model) { + loadElementTypes().then(() => { + callback(model.documentTypeKey); + }); + + editorService.close(); + }, + close: function () { + editorService.close(); + } + }; + editorService.documentTypeEditor(editor); + } + + vm.addBlockFromElementTypeKey = function(key) { + + const blockType = { + contentElementTypeKey: key, + settingsElementTypeKey: null, + labelTemplate: "", + displayInline: false, + view: null, + stylesheet: null, + editorSize: "medium", + iconColor: null, + backgroundColor: null, + thumbnail: null + }; + + $scope.model.value.push(blockType); + + vm.openBlockOverlay(blockType); + }; + + vm.openBlockOverlay = function (block) { + + var elementType = vm.getElementTypeByKey(block.contentElementTypeKey); + + if (elementType) { + + let clonedBlockData = Utilities.copy(block); + vm.openBlock = block; + + const overlayModel = { + block: clonedBlockData, + + view: "views/propertyeditors/rte/blocks/prevalue/blockrte.blockconfiguration.overlay.html", + size: "small", + submit: function(overlayModel) { + loadElementTypes()// lets load elementType again, to ensure we are up to date. + TransferProperties(overlayModel.block, block);// transfer properties back to block object. (Doing this cause we dont know if block object is added to model jet, therefor we cant use index or replace the object.) + overlayModel.close(); + }, + close: function() { + editorService.close(); + vm.openBlock = null; + } + }; + + localizationService.localize("blockEditor_blockConfigurationOverlayTitle", [elementType.name]).then(data => { + overlayModel.title = data, + + // open property settings editor + editorService.open(overlayModel); + }); + } else { + + const overlay = { + close: () => { + overlayService.close() + } + }; + + localizationService.localize("blockEditor_elementTypeDoesNotExist").then(data => { + overlay.content = data; + overlayService.open(overlay); + }); + } + + }; + + $scope.$on('$destroy', function () { + unsubscribe.forEach(u => { u(); }); + }); + + onInit(); + + } + + angular.module("umbraco").controller("Umbraco.PropertyEditors.BlockRTE.BlockConfigurationController", BlockConfigurationController); + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blocks/prevalue/blockrte.blockconfiguration.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blocks/prevalue/blockrte.blockconfiguration.html new file mode 100644 index 0000000000..c7aed33262 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blocks/prevalue/blockrte.blockconfiguration.html @@ -0,0 +1,25 @@ +
+ +
+ + +
+ +
+
+ + +
+
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blocks/prevalue/blockrte.blockconfiguration.overlay.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blocks/prevalue/blockrte.blockconfiguration.overlay.controller.js new file mode 100644 index 0000000000..fc8e0ae547 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blocks/prevalue/blockrte.blockconfiguration.overlay.controller.js @@ -0,0 +1,314 @@ +/** + * @ngdoc controller + * @name Umbraco.Editors.BlockRTE.BlockConfigurationOverlayController + * @function + * + * @description + * The controller for the content type editor property settings dialog + */ + +(function () { + "use strict"; + + function BlockConfigurationOverlayController($scope, overlayService, localizationService, editorService, elementTypeResource, eventsService, udiService, angularHelper) { + + var unsubscribe = []; + + var vm = this; + vm.block = $scope.model.block; + + vm.colorPickerOptions = { + type: "color", + allowEmpty: true, + showAlpha: true + }; + + loadElementTypes(); + + function loadElementTypes() { + return elementTypeResource.getAll().then(function(elementTypes) { + vm.elementTypes = elementTypes; + + vm.contentPreview = vm.getElementTypeByKey(vm.block.contentElementTypeKey); + vm.settingsPreview = vm.getElementTypeByKey(vm.block.settingsElementTypeKey); + }); + } + + vm.getElementTypeByKey = function(key) { + return vm.elementTypes.find(function (type) { + return type.key === key; + }); + }; + + vm.openElementType = function(elementTypeKey) { + var elementType = vm.getElementTypeByKey(elementTypeKey); + if (elementType) { + var elementTypeId = elementType.id; + const editor = { + id: elementTypeId, + submit: function (model) { + editorService.close(); + }, + close: function () { + editorService.close(); + } + }; + editorService.documentTypeEditor(editor); + } + }; + + vm.createElementTypeAndCallback = function(callback) { + const editor = { + create: true, + infiniteMode: true, + isElement: true, + noTemplate: true, + submit: function (model) { + callback(model.documentTypeKey); + editorService.close(); + }, + close: function () { + editorService.close(); + } + }; + editorService.documentTypeEditor(editor); + }; + + vm.addSettingsForBlock = function($event, block) { + + localizationService.localize("blockEditor_headlineAddSettingsElementType").then(localizedTitle => { + + const settingsTypePicker = { + title: localizedTitle, + entityType: "documentType", + isDialog: true, + filter: node => { + if (node.metaData.isElement === true) { + return false; + } + return true; + }, + filterCssClass: "not-allowed", + select: node => { + vm.applySettingsToBlock(block, udiService.getKey(node.udi)); + editorService.close(); + }, + close: () => editorService.close(), + extraActions: [ + { + style: "primary", + labelKey: "blockEditor_labelcreateNewElementType", + action: () => { + vm.createElementTypeAndCallback((key) => { + vm.applySettingsToBlock(block, key); + + // At this point we will close the contentTypePicker. + editorService.close(); + }); + } + } + ] + }; + + editorService.contentTypePicker(settingsTypePicker); + + }); + }; + + vm.applySettingsToBlock = function(block, key) { + block.settingsElementTypeKey = key; + vm.settingsPreview = vm.getElementTypeByKey(vm.block.settingsElementTypeKey); + }; + + vm.requestRemoveSettingsForBlock = function(block) { + localizationService.localizeMany(["general_remove", "defaultdialogs_confirmremoveusageof"]).then(function (data) { + + var settingsElementType = vm.getElementTypeByKey(block.settingsElementTypeKey); + + overlayService.confirmRemove({ + title: data[0], + content: localizationService.tokenReplace(data[1], [(settingsElementType ? settingsElementType.name : "(Unavailable ElementType)")]), + close: function () { + overlayService.close(); + }, + submit: function () { + vm.removeSettingsForBlock(block); + overlayService.close(); + } + }); + }); + }; + + vm.removeSettingsForBlock = function(block) { + block.settingsElementTypeKey = null; + }; + + function updateUsedElementTypes(event, args) { + var key = args.documentType.key; + for (var i = 0; i { + + const filePicker = { + title: localizedTitle, + isDialog: true, + filter: i => { + return !(i.name.indexOf(".html") !== -1); + }, + filterCssClass: "not-allowed", + select: node => { + const filepath = decodeURIComponent(node.id.replace(/\+/g, " ")); + block.view = "~/" + filepath.replace("wwwroot/", ""); + editorService.close(); + }, + close: () => editorService.close() + }; + + editorService.staticFilePicker(filePicker); + + }); + }; + + vm.requestRemoveViewForBlock = function(block) { + localizationService.localizeMany(["general_remove", "defaultdialogs_confirmremoveusageof"]).then(function (data) { + overlayService.confirmRemove({ + title: data[0], + content: localizationService.tokenReplace(data[1], [block.view]), + close: function () { + overlayService.close(); + }, + submit: function () { + vm.removeViewForBlock(block); + overlayService.close(); + } + }); + }); + }; + + vm.removeViewForBlock = function(block) { + block.view = null; + }; + + vm.addStylesheetForBlock = function(block) { + localizationService.localize("blockEditor_headlineAddCustomStylesheet").then(localizedTitle => { + + const filePicker = { + title: localizedTitle, + isDialog: true, + filter: i => { + return !(i.name.indexOf(".css") !== -1); + }, + filterCssClass: "not-allowed", + select: node => { + const filepath = decodeURIComponent(node.id.replace(/\+/g, " ")); + block.stylesheet = "~/" + filepath.replace("wwwroot/", ""); + editorService.close(); + }, + close: () => editorService.close() + }; + + editorService.staticFilePicker(filePicker); + + }); + }; + + vm.requestRemoveStylesheetForBlock = function(block) { + localizationService.localizeMany(["general_remove", "defaultdialogs_confirmremoveusageof"]).then(function (data) { + overlayService.confirmRemove({ + title: data[0], + content: localizationService.tokenReplace(data[1], [block.stylesheet]), + close: function () { + overlayService.close(); + }, + submit: function () { + vm.removeStylesheetForBlock(block); + overlayService.close(); + } + }); + }); + }; + + vm.removeStylesheetForBlock = function(block) { + block.stylesheet = null; + }; + + vm.addThumbnailForBlock = function(block) { + + localizationService.localize("blockEditor_headlineAddThumbnail").then(localizedTitle => { + + let allowedFileExtensions = ['jpg', 'jpeg', 'png', 'svg', 'webp', 'gif']; + + const thumbnailPicker = { + title: localizedTitle, + isDialog: true, + filter: i => { + let ext = i.name.substr((i.name.lastIndexOf('.') + 1)); + return allowedFileExtensions.includes(ext) === false; + }, + filterCssClass: "not-allowed", + select: file => { + const id = decodeURIComponent(file.id.replace(/\+/g, " ")); + block.thumbnail = "~/" + id.replace("wwwroot/", ""); + editorService.close(); + }, + close: () => editorService.close() + }; + + editorService.staticFilePicker(thumbnailPicker); + + }); + }; + + vm.removeThumbnailForBlock = function(entry) { + entry.thumbnail = null; + }; + + vm.changeIconColor = function (color) { + angularHelper.safeApply($scope, function () { + vm.block.iconColor = color ? color.toString() : null; + }); + }; + + vm.changeBackgroundColor = function (color) { + angularHelper.safeApply($scope, function () { + vm.block.backgroundColor = color ? color.toString() : null; + }); + }; + + vm.submit = function() { + if ($scope.model && $scope.model.submit) { + $scope.model.submit($scope.model); + } + }; + + vm.close = function() { + if ($scope.model && $scope.model.close) { + $scope.model.close($scope.model); + } + }; + + $scope.$on('$destroy', function() { + unsubscribe.forEach(u => { u(); }); + }); + + } + + angular.module("umbraco").controller("Umbraco.PropertyEditors.BlockRTE.BlockConfigurationOverlayController", BlockConfigurationOverlayController); + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blocks/prevalue/blockrte.blockconfiguration.overlay.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blocks/prevalue/blockrte.blockconfiguration.overlay.html new file mode 100644 index 0000000000..ddb1ed727e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blocks/prevalue/blockrte.blockconfiguration.overlay.html @@ -0,0 +1,281 @@ +
+ +
+ + + + + + + +
+ +
+ +
+ Editor appearance +
+ +
+ + +
+
+ +
+ +
+
+
+ + + +
+
+ +
+ + +
+
+
+ + +
+
+ + + Overwrite how this block appears in the BackOffice UI. Pick a .html file containing your presentation. + +
+
+ + +
+ +
+
+ +
+
+
+ + +
+
+ +
+
+ + +
+ +
+
+ +
+
+
+ + +
+
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+ Data Models +
+ +
+ + +
+
+ +
+
+ +
+ +
+
+
+
+
+ + +
+
+ +
+
+ +
+ + +
+
+ +
+
+
+
+
+ +
+ +
+ Catalogue appearance +
+ +
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ +
+
+ + +
+ +
+
+ +
+
+
+ +
+ +
+ +
+ +
+ Advanced +
+ +
+ + +
+
+ +
+ + +
+
+
+ +
+ +
+ +
+
+ + + + + + + + + + + + + + +
+
+
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blocks/prevalue/blockrte.blockconfiguration.overlay.less b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blocks/prevalue/blockrte.blockconfiguration.overlay.less new file mode 100644 index 0000000000..d2d875aa94 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blocks/prevalue/blockrte.blockconfiguration.overlay.less @@ -0,0 +1,103 @@ +.umb-block-list-block-configuration-overlay { + + + .umb-node-preview { + flex-grow: 1; + } + + .__control-actions { + position: absolute; + display: flex; + align-items: center; + top:0; + bottom: 0; + right: 0; + background-color: rgba(255, 255, 255, 0.8); + opacity: 0; + transition: opacity 120ms; + } + .controls:hover &, + .controls:focus &, + .controls:focus-within &, + .control-group:hover, + .control-group:focus, + .control-group:focus-within { + .__control-actions { + opacity: 1; + } + } + .__control-actions-btn { + position: relative; + color: @ui-action-discreet-type; + height: 32px; + width: 26px; + &:hover { + color: @ui-action-discreet-type-hover; + } + &:last-of-type { + margin-right: 7px; + } + } + + .umb-node-preview { + border-bottom: none; + } + + .__settings-input { + position: relative; + padding: 5px 8px; + margin-bottom: 10px; + color: @ui-action-discreet-type; + border: 1px dashed @ui-action-discreet-border; + width: 100%; + font-weight: bold; + display: inline-flex; + flex-flow: row nowrap; + + localize { + width: 100%; + } + + .umb-node-preview { + padding: 3px 0; + margin-left: 5px; + overflow: hidden; + } + + &.--noValue { + text-align: center; + border-radius: @baseBorderRadius; + color: white; + transition: color 120ms; + &:hover, &:focus { + color: @ui-action-discreet-type-hover; + border-color: @ui-action-discreet-border-hover; + } + } + + &.--hasValue { + border: 1px solid @inputBorder; + padding: 0; + } + } + + .__add-button { + width:100%; + color: @ui-action-discreet-type; + border: 1px dashed @ui-action-discreet-border; + border-radius: @baseBorderRadius; + display: flex; + align-items: center; + justify-content: center; + padding: 5px 15px; + box-sizing: border-box; + margin: 20px 0; + font-weight: bold; + } + + .__add-button:hover { + color: @ui-action-discreet-type-hover; + border-color: @ui-action-discreet-border-hover; + } + +} diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blocks/umb-rte-block.component.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blocks/umb-rte-block.component.js new file mode 100644 index 0000000000..0c4c8e2e50 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/blocks/umb-rte-block.component.js @@ -0,0 +1,127 @@ +(function () { + 'use strict'; + + /** + * A component to render the property action toggle + */ + + function umbRteBlockController($scope, $compile, $element) { + + var model = this; + + model.$onDestroy = onDestroy; + model.$onInit = onInit; + + + function onDestroy() { + $element[0]._isInitializedUmbBlock = false; + } + + function onInit() { + $element[0]._isInitializedUmbBlock = true; + $scope.block = $element[0].$block; + $scope.api = $element[0].$api; + $scope.index = $element[0].$index; + $scope.culture = $element[0].$culture || null; + $scope.segment = $element[0].$segment || null; + $scope.parentForm = $element[0].$parentForm; + $scope.valFormManager = $element[0].$valFormManager; + + const stylesheet = $scope.block.config.stylesheet; + + var shadowRoot = $element[0].attachShadow({ mode: 'open' }); + shadowRoot.innerHTML = + ` + + +
+ + +
+
+ +
+ + + + + + +
+
+
+ `; + $compile(shadowRoot)($scope); + + } + + } + + var umbRteBlockComponent = { + bindings: { + dataUdi: "<" + }, + controller: umbRteBlockController, + controllerAs: "model" + }; + + angular.module('umbraco.directives').component('umbRteBlock', umbRteBlockComponent); + angular.module('umbraco.directives').component('umbRteBlockInline', umbRteBlockComponent); + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.component.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.component.js new file mode 100644 index 0000000000..4fe1beeb85 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.component.js @@ -0,0 +1,955 @@ +(function () { + "use strict"; + + /** + * @ngdoc directive + * @name umbraco.directives.directive:umbBlockListPropertyEditor + * @function + * + * @description + * The component for the block list property editor. + */ + angular + .module("umbraco") + .component("umbRtePropertyEditor", { + templateUrl: "views/propertyeditors/rte/umb-rte-property-editor.html", + controller: BlockRteController, + controllerAs: "vm", + bindings: { + model: "=" + }, + require: { + propertyForm: "^form", + umbProperty: "?^umbProperty", + umbVariantContent: '?^^umbVariantContent', + umbVariantContentEditors: '?^^umbVariantContentEditors', + umbElementEditorContent: '?^^umbElementEditorContent', + valFormManager: "^^valFormManager" + } + }); + + function BlockRteController($element, $scope, $q, $timeout, $interpolate, assetsService, editorService, clipboardService, localizationService, overlayService, blockEditorService, udiService, serverValidationManager, angularHelper, eventsService, $attrs, tinyMceAssets, tinyMceService) { + + var unsubscribe = []; + var modelObject; + + // Property actions: + //let copyAllBlocksAction = null; + //let deleteAllBlocksAction = null; + //let pasteSingleBlockAction = null; + + var liveEditing = true; + + var vm = this; + + vm.readonly = false; + vm.tinyMceEditor = null; + + $attrs.$observe('readonly', (value) => { + vm.readonly = value !== undefined; + + vm.blockEditorApi.readonly = vm.readonly; + + /*if (deleteAllBlocksAction) { + deleteAllBlocksAction.isDisabled = vm.readonly; + }*/ + }); + + vm.loading = true; + vm.rteLoading = true; + vm.blocksLoading = true; + vm.updateLoading = function () { + if(!vm.rteLoading && !vm.blocksLoading) { + vm.loading = false; + } + } + vm.currentBlockInFocus = null; + vm.setBlockFocus = function (block) { + if (vm.currentBlockInFocus !== null) { + vm.currentBlockInFocus.focus = false; + } + vm.currentBlockInFocus = block; + block.focus = true; + }; + + vm.supportCopy = clipboardService.isSupported(); + vm.clipboardItems = []; + unsubscribe.push(eventsService.on("clipboardService.storageUpdate", updateClipboard)); + unsubscribe.push($scope.$on("editors.content.splitViewChanged", (event, eventData) => { + var compositeId = vm.umbVariantContent.editor.compositeId; + if(eventData.editors.some(x => x.compositeId === compositeId)) { + updateAllBlockObjects(); + } + })); + + vm.layout = []; // The layout object specific to this Block Editor, will be a direct reference from Property Model. + vm.availableBlockTypes = []; // Available block entries of this property editor. + vm.labels = {}; + vm.options = { + createFlow: false + }; + + localizationService.localizeMany(["blockEditor_insertBlock", "content_createEmpty"]).then(function (data) { + vm.labels.blockEditor_insertBlock = data[0]; + vm.labels.content_createEmpty = data[1]; + }); + + vm.$onInit = function() { + + if (vm.umbProperty && !vm.umbVariantContent) {// if we dont have vm.umbProperty, it means we are in the DocumentTypeEditor. + // not found, then fallback to searching the scope chain, this may be needed when DOM inheritance isn't maintained but scope + // inheritance is (i.e.infinite editing) + var found = angularHelper.traverseScopeChain($scope, s => s && s.vm && s.vm.constructor.name === "umbVariantContentController"); + vm.umbVariantContent = found ? found.vm : null; + if (!vm.umbVariantContent) { + throw "Could not find umbVariantContent in the $scope chain"; + } + } + + // set the onValueChanged callback, this will tell us if the block list model changed on the server + // once the data is submitted. If so we need to re-initialize + vm.model.onValueChanged = onServerValueChanged; + liveEditing = vm.model.config.useLiveEditing; + + vm.listWrapperStyles = {}; + + if (vm.model.config.maxPropertyWidth) { + vm.listWrapperStyles['max-width'] = vm.model.config.maxPropertyWidth; + } + + // We need to ensure that the property model value is an object, this is needed for modelObject to recive a reference and keep that updated. + ensurePropertyValue(vm.model.value); + + var scopeOfExistence = $scope; + if (vm.umbVariantContentEditors && vm.umbVariantContentEditors.getScope) { + scopeOfExistence = vm.umbVariantContentEditors.getScope(); + } else if(vm.umbElementEditorContent && vm.umbElementEditorContent.getScope) { + scopeOfExistence = vm.umbElementEditorContent.getScope(); + } + + /* + copyAllBlocksAction = { + labelKey: "clipboard_labelForCopyAllEntries", + labelTokens: [vm.model.label], + icon: "icon-documents", + method: requestCopyAllBlocks, + isDisabled: true, + useLegacyIcon: false + }; + + deleteAllBlocksAction = { + labelKey: "clipboard_labelForRemoveAllEntries", + labelTokens: [], + icon: "icon-trash", + method: requestDeleteAllBlocks, + isDisabled: true, + useLegacyIcon: false + }; + + var propertyActions = [copyAllBlocksAction, deleteAllBlocksAction]; + */ + + // Create Model Object, to manage our data for this Block Editor. + modelObject = blockEditorService.createModelObject(vm.model.value.blocks, vm.model.editor, vm.model.config.blocks, scopeOfExistence, $scope); + const blockModelObjectLoading = modelObject.load() + blockModelObjectLoading.then(onLoaded); + + + // ******************** // + // RTE PART: + // ******************** // + + + // To id the html textarea we need to use the datetime ticks because we can have multiple rte's per a single property alias + // because now we have to support having 2x (maybe more at some stage) content editors being displayed at once. This is because + // we have this mini content editor panel that can be launched with MNTP. + vm.textAreaHtmlId = vm.model.alias + "_" + String.CreateGuid(); + + var editorConfig = vm.model.config ? vm.model.config.editor : null; + if (!editorConfig || Utilities.isString(editorConfig)) { + editorConfig = tinyMceService.defaultPrevalues(); + } + + var width = editorConfig.dimensions ? parseInt(editorConfig.dimensions.width, 10) || null : null; + var height = editorConfig.dimensions ? parseInt(editorConfig.dimensions.height, 10) || null : null; + + vm.containerWidth = "auto"; + vm.containerHeight = "auto"; + vm.containerOverflow = "inherit"; + + var promises = [blockModelObjectLoading]; + + //queue file loading + tinyMceAssets.forEach(function (tinyJsAsset) { + promises.push(assetsService.loadJs(tinyJsAsset, $scope)); + }); + + promises.push(tinyMceService.getTinyMceEditorConfig({ + htmlId: vm.textAreaHtmlId, + stylesheets: editorConfig.stylesheets, + toolbar: editorConfig.toolbar, + mode: editorConfig.mode + })); + + //wait for queue to end + $q.all(promises).then(function (result) { + + var standardConfig = result[promises.length - 1]; + + if (height !== null) { + standardConfig.plugins.splice(standardConfig.plugins.indexOf("autoresize"), 1); + } + + //create a baseline Config to extend upon + var baseLineConfigObj = { + maxImageSize: editorConfig.maxImageSize, + width: width, + height: height + }; + + baseLineConfigObj.setup = function (editor) { + + //set the reference + vm.tinyMceEditor = editor; + + vm.tinyMceEditor.on('init', function (e) { + $timeout(function () { + vm.rteLoading = false; + vm.updateLoading(); + }); + }); + vm.tinyMceEditor.on("focus", function () { + $element[0].dispatchEvent(new CustomEvent('umb-rte-focus', {composed: true, bubbles: true})); + }); + vm.tinyMceEditor.on("blur", function () { + $element[0].dispatchEvent(new CustomEvent('umb-rte-blur', {composed: true, bubbles: true})); + }); + + //initialize the standard editor functionality for Umbraco + tinyMceService.initializeEditor({ + //scope: $scope, + editor: editor, + toolbar: editorConfig.toolbar, + model: vm.model, + getValue: function () { + return vm.model.value.markup; + }, + setValue: function (newVal) { + vm.model.value.markup = newVal; + $scope.$evalAsync(); + }, + culture: vm.umbProperty?.culture ?? null, + segment: vm.umbProperty?.segment ?? null, + blockEditorApi: vm.blockEditorApi, + parentForm: vm.propertyForm, + valFormManager: vm.valFormManager, + currentFormInput: $scope.rteForm.modelValue + }); + + }; + + Utilities.extend(baseLineConfigObj, standardConfig); + + // Readonly mode + baseLineConfigObj.toolbar = vm.readonly ? false : baseLineConfigObj.toolbar; + baseLineConfigObj.readonly = vm.readonly ? 1 : baseLineConfigObj.readonly; + + // We need to wait for DOM to have rendered before we can find the element by ID. + $timeout(function () { + tinymce.init(baseLineConfigObj); + }, 50); + + //listen for formSubmitting event (the result is callback used to remove the event subscription) + unsubscribe.push($scope.$on("formSubmitting", function () { + if (vm.tinyMceEditor != null && !vm.rteLoading) { + + // Remove unused Blocks of Blocks Layout. Leaving only the Blocks that are present in Markup. + var blockElements = vm.tinyMceEditor.dom.select(`umb-rte-block, umb-rte-block-inline`); + const usedContentUdis = blockElements.map(blockElement => blockElement.getAttribute('data-content-udi')); + + const unusedBlocks = vm.layout.filter(x => usedContentUdis.indexOf(x.contentUdi) === -1); + unusedBlocks.forEach(blockLayout => { + deleteBlock(blockLayout.$block); + }); + + + // Remove Angular Classes from markup: + var parser = new DOMParser(); + var doc = parser.parseFromString(vm.model.value.markup, 'text/html'); + + // Get all elements in the parsed document + var elements = doc.querySelectorAll('*[class]'); + elements.forEach(element => { + var classAttribute = element.getAttribute("class"); + if (classAttribute) { + // Split the class attribute by spaces and remove "ng-scope" and "ng-isolate-scope" + var classes = classAttribute.split(" "); + var newClasses = classes.filter(function (className) { + return className !== "ng-scope" && className !== "ng-isolate-scope"; + }); + + // Update the class attribute with the remaining classes + if (newClasses.length > 0) { + element.setAttribute('class', newClasses.join(' ')); + } else { + // If no remaining classes, remove the class attribute + element.removeAttribute('class'); + } + } + }); + + vm.model.value.markup = doc.body.innerHTML; + + } + })); + + vm.focusRTE = function () { + vm.tinyMceEditor.focus(); + } + + // When the element is disposed we need to unsubscribe! + // NOTE: this is very important otherwise if this is part of a modal, the listener still exists because the dom + // element might still be there even after the modal has been hidden. + $scope.$on('$destroy', function () { + if (vm.tinyMceEditor != null) { + if($element) { + $element[0]?.dispatchEvent(new CustomEvent('blur', {composed: true, bubbles: true})); + } + vm.tinyMceEditor.destroy(); + vm.tinyMceEditor = null; + } + }); + + }); + + }; + + // Called when we save the value, the server may return an updated data and our value is re-synced + // we need to deal with that here so that our model values are all in sync so we basically re-initialize. + function onServerValueChanged(newVal, oldVal) { + + ensurePropertyValue(newVal); + + modelObject.update(vm.model.value.blocks, $scope); + onLoaded(); + } + + function ensurePropertyValue(newVal) { + // We need to ensure that the property model value is an object, this is needed for modelObject to receive a reference and keep that updated. + if (typeof newVal !== 'object' || newVal == null) {// testing if we have null or undefined value or if the value is set to another type than Object. + vm.model.value = {markup:vm.model.value ?? "", blocks: {}}; + } else if(!newVal.markup) { + vm.model.value.markup = ""; + } else if(!newVal.blocks) { + vm.model.value.blocks = {}; + } + } + + function setDirty() { + if (vm.propertyForm) { + vm.propertyForm.$setDirty(); + } + } + + function onLoaded() { + + // Store a reference to the layout model, because we need to maintain this model. + vm.layout = modelObject.getLayout([]); + + var invalidLayoutItems = []; + + // Append the blockObjects to our layout. + vm.layout.forEach(entry => { + // $block must have the data property to be a valid BlockObject, if not its considered as a destroyed blockObject. + if (entry.$block === undefined || entry.$block === null || entry.$block.data === undefined) { + var block = getBlockObject(entry); + + // If this entry was not supported by our property-editor it would return 'null'. + if (block !== null) { + entry.$block = block; + } + else { + // then we need to filter this out and also update the underlying model. This could happen if the data + // is invalid for some reason or the data structure has changed. + invalidLayoutItems.push(entry); + } + } else { + updateBlockObject(entry.$block); + } + }); + + // remove the ones that are invalid + invalidLayoutItems.forEach(entry => { + var index = vm.layout.findIndex(x => x === entry); + if (index >= 0) { + vm.layout.splice(index, 1); + } + }); + + vm.availableContentTypesAliases = modelObject.getAvailableAliasesForBlockContent(); + vm.availableBlockTypes = modelObject.getAvailableBlocksForBlockPicker(); + + updateClipboard(true); + + vm.blocksLoading = false; + vm.updateLoading(); + + $scope.$evalAsync(); + + } + + function updateAllBlockObjects() { + // Update the blockObjects in our layout. + vm.layout.forEach(entry => { + // $block must have the data property to be a valid BlockObject, if not its considered as a destroyed blockObject. + if (entry.$block) { + updateBlockObject(entry.$block); + } + }); + } + + function getDefaultViewForBlock(block) { + + // TODO: new paths: + var defaultViewFolderPath = "views/propertyeditors/rte/blocks/blockrteentryeditors/"; + + if (block.config.unsupported === true) { + return defaultViewFolderPath + "unsupportedblock/unsupportedblock.editor.html"; + } + + return defaultViewFolderPath + "labelblock/rtelabelblock.editor.html"; + } + + /** + * Ensure that the containing content variant language and current property culture is transferred along + * to the scaffolded content object representing this block. + * This is required for validation along with ensuring that the umb-property inheritance is constantly maintained. + * @param {any} content + */ + function ensureCultureData(content) { + + if (!content) return; + + if (vm.umbVariantContent.editor.content.language) { + // set the scaffolded content's language to the language of the current editor + content.language = vm.umbVariantContent.editor.content.language; + } + // currently we only ever deal with invariant content for blocks so there's only one + content.variants[0].tabs.forEach(tab => { + tab.properties.forEach(prop => { + // set the scaffolded property to the culture of the containing property + prop.culture = vm.umbProperty.property.culture; + }); + }); + + // set the scaffolded allowed actions to the allowed actions of the document + content.allowedActions = vm.umbVariantContent.content.allowedActions; + + // set the scaffolded variants' allowed actions to the allowed actions of the current variant + content.variants.forEach(variant => { + variant.allowedActions = vm.umbVariantContent.editor.content.allowedActions; + }); + } + + function getBlockObject(entry) { + var block = modelObject.getBlockObject(entry); + + if (block === null) return null; + + block.view = (block.config.view ? block.config.view : getDefaultViewForBlock(block)); + block.showValidation = block.config.view ? true : false; + + block.hideContentInOverlay = block.config.forceHideContentEditorInOverlay === true; + block.showContent = !block.hideContentInOverlay && block.content?.variants[0].tabs?.some(tab=>tab.properties.length) === true; + block.showSettings = block.config.settingsElementTypeKey != null; + + // If we have content, otherwise it doesn't make sense to copy. + block.showCopy = vm.supportCopy && block.config.contentElementTypeKey != null; + + // Index is not begin updated in RTE Blocks, the order of element and Blocks of layout is not synced, meaning the index could be incorrect depending on the perspective. + block.index = 0; + block.setParentForm = function (parentForm) { + this._parentForm = parentForm; + }; + + /** decorator methods, to enable switching out methods without loosing references that would have been made in Block Views codes */ + block.activate = function() { + this._activate(); + }; + block.edit = function() { + this._edit(); + }; + block.editSettings = function() { + this._editSettings(); + }; + block.requestDelete = function() { + this._requestDelete(); + }; + block.delete = function() { + this._delete(); + }; + block.copy = function() { + this._copy(); + }; + updateBlockObject(block); + + return block; + } + + /** As the block object now contains references to this instance of a property editor, we need to ensure that the Block Object contains latest references. + * This is a bit hacky but the only way to maintain this reference currently. + * Notice this is most relevant for invariant properties on variant documents, specially for the scenario where the scope of the reference we stored is destroyed, therefor we need to ensure we always have references to a current running property editor*/ + function updateBlockObject(block) { + + ensureCultureData(block.content); + ensureCultureData(block.settings); + + block._activate = activateBlock.bind(null, block); + block._edit = function () { + var blockIndex = vm.layout.indexOf(this.layout); + editBlock(this, false, blockIndex, this._parentForm); + }; + block._editSettings = function () { + var blockIndex = vm.layout.indexOf(this.layout); + editBlock(this, true, blockIndex, this._parentForm); + }; + block._requestDelete = requestDeleteBlock.bind(null, block); + block._delete = deleteBlock.bind(null, block); + block._copy = copyBlock.bind(null, block); + } + + function addNewBlock(index, contentElementTypeKey) { + + // Create layout entry. (not added to property model jet.) + var layoutEntry = modelObject.create(contentElementTypeKey); + if (layoutEntry === null) { + return false; + } + + // make block model + var blockObject = getBlockObject(layoutEntry); + if (blockObject === null) { + return false; + } + + // If we reach this line, we are good to add the layoutEntry and blockObject to our models. + + // Add the Block Object to our layout entry. + layoutEntry.$block = blockObject; + + // add layout entry at the desired location in layout. + vm.layout.splice(index, 0, layoutEntry); + + // lets move focus to this new block. + vm.setBlockFocus(blockObject); + + setDirty(); + + return true; + } + + function deleteBlock(block) { + + var layoutIndex = vm.layout.findIndex(entry => entry.contentUdi === block.layout.contentUdi); + if (layoutIndex === -1) { + throw new Error("Could not find layout entry of block with udi: "+block.layout.contentUdi) + } + + setDirty(); + + var removed = vm.layout.splice(layoutIndex, 1); + removed.forEach(x => { + + var blockElementsOfThisUdi = vm.tinyMceEditor.dom.select(`umb-rte-block[data-content-udi='${x.contentUdi}'], umb-rte-block-inline[data-content-udi='${x.contentUdi}']`); + blockElementsOfThisUdi.forEach(blockElement => { + vm.tinyMceEditor.dom.remove(blockElement); + }); + + // remove any server validation errors associated + var guids = [udiService.getKey(x.contentUdi), (x.settingsUdi ? udiService.getKey(x.settingsUdi) : null)]; + guids.forEach(guid => { + if (guid) { + serverValidationManager.removePropertyError(guid, vm.umbProperty.property.culture, vm.umbProperty.property.segment, "", { matchType: "contains" }); + } + }) + }); + + if(removed.length > 0) { + vm.model.value.markup = vm.tinyMceEditor.getContent(); + $scope.$evalAsync(); + } + + modelObject.removeDataAndDestroyModel(block); + } + + /*function deleteAllBlocks() { + while(vm.layout.length) { + deleteBlock(vm.layout[0].$block); + }; + }*/ + + function activateBlock(blockObject) { + blockObject.active = true; + } + + function editBlock(blockObject, openSettings, blockIndex, parentForm, options) { + + options = options || vm.options; + + // this must be set + if (blockIndex === undefined) { + throw "blockIndex was not specified on call to editBlock"; + } + + var wasNotActiveBefore = blockObject.active !== true; + + // don't open the editor overlay if block has hidden its content editor in overlays and we are requesting to open content, not settings. + if (openSettings !== true && blockObject.hideContentInOverlay === true) { + return; + } + + // if requesting to open settings but we dont have settings then return. + if (openSettings === true && !blockObject.config.settingsElementTypeKey) { + return; + } + + activateBlock(blockObject); + + // make a clone to avoid editing model directly. + var blockContentClone = Utilities.copy(blockObject.content); + var blockSettingsClone = null; + + if (blockObject.config.settingsElementTypeKey) { + blockSettingsClone = Utilities.copy(blockObject.settings); + } + + var blockEditorModel = { + $parentScope: $scope, // pass in a $parentScope, this maintains the scope inheritance in infinite editing + $parentForm: parentForm || vm.propertyForm, // pass in a $parentForm, this maintains the FormController hierarchy with the infinite editing view (if it contains a form) + hideContent: blockObject.hideContentInOverlay, + openSettings: openSettings === true, + createFlow: options.createFlow === true, + liveEditing: liveEditing, + title: blockObject.label, + view: "views/common/infiniteeditors/blockeditor/blockeditor.html", + size: blockObject.config.editorSize || "medium", + hideSubmitButton: vm.readonly, + submit: function(blockEditorModel) { + + if (liveEditing === false) { + // transfer values when submitting in none-live-editing mode. + blockObject.retrieveValuesFrom(blockEditorModel.content, blockEditorModel.settings); + } + + setDirty(); + blockObject.active = false; + editorService.close(); + }, + close: function(blockEditorModel) { + if (blockEditorModel.createFlow) { + deleteBlock(blockObject); + } else { + if (liveEditing === true) { + // revert values when closing in live-editing mode. + blockObject.retrieveValuesFrom(blockContentClone, blockSettingsClone); + } + if (wasNotActiveBefore === true) { + blockObject.active = false; + } + } + editorService.close(); + } + }; + + if (liveEditing === true) { + blockEditorModel.content = blockObject.content; + blockEditorModel.settings = blockObject.settings; + } else { + blockEditorModel.content = blockContentClone; + blockEditorModel.settings = blockSettingsClone; + } + + // open property settings editor + editorService.open(blockEditorModel); + } + + vm.requestShowCreate = requestShowCreate; + function requestShowCreate(createIndex, mouseEvent) { + + if (vm.blockTypePicker) { + return; + } + + if (vm.availableBlockTypes.length === 1) { + var wasAdded = false; + var blockType = vm.availableBlockTypes[0]; + + wasAdded = addNewBlock(createIndex, blockType.blockConfigModel.contentElementTypeKey); + + if(wasAdded && !(mouseEvent.ctrlKey || mouseEvent.metaKey)) { + userFlowWhenBlockWasCreated(createIndex); + } + } else { + showCreateDialog(createIndex); + } + + } + + vm.requestShowClipboard = requestShowClipboard; + function requestShowClipboard(createIndex) { + showCreateDialog(createIndex, true); + } + + vm.showCreateDialog = showCreateDialog; + function showCreateDialog(createIndex, openClipboard, addedCallback) { + + if (vm.blockTypePicker) { + return; + } + + if (vm.availableBlockTypes.length === 0) { + alert("No Blocks configured for this data-type"); + return; + } + + if(createIndex === undefined) { + createIndex = vm.layout.length - 1; + } + + var amountOfAvailableTypes = vm.availableBlockTypes.length; + var blockPickerModel = { + $parentScope: $scope, // pass in a $parentScope, this maintains the scope inheritance in infinite editing + $parentForm: vm.propertyForm, // pass in a $parentForm, this maintains the FormController hierarchy with the infinite editing view (if it contains a form) + availableItems: vm.availableBlockTypes, + title: vm.labels.blockEditor_insertBlock, + openClipboard: openClipboard, + orderBy: "$index", + view: "views/common/infiniteeditors/blockpicker/blockpicker.html", + size: (amountOfAvailableTypes > 8 ? "medium" : "small"), + filter: (amountOfAvailableTypes > 8), + clickPasteItem: function(item, mouseEvent) { + if (Array.isArray(item.pasteData)) { + const BlocksThatGotPasted = []; + var indexIncrementor = 0; + item.pasteData.forEach(function (entry) { + const wasAdded = requestPasteFromClipboard(createIndex + indexIncrementor, entry, item.type) + if (wasAdded) { + const newBlock = vm.layout[createIndex + indexIncrementor].$block; + BlocksThatGotPasted.push(newBlock); + indexIncrementor++; + } + }); + if(BlocksThatGotPasted.length > 0) { + addedCallback(BlocksThatGotPasted); + } + } else { + const wasAdded = requestPasteFromClipboard(createIndex, item.pasteData, item.type); + if(wasAdded && vm.layout[createIndex]) { + const newBlock = vm.layout[createIndex].$block; + addedCallback(newBlock); + } + } + if(!(mouseEvent.ctrlKey || mouseEvent.metaKey)) { + blockPickerModel.close(); + } + }, + submit: function(blockPickerModel, mouseEvent) { + var wasAdded = false; + if (blockPickerModel && blockPickerModel.selectedItem) { + wasAdded = addNewBlock(createIndex, blockPickerModel.selectedItem.blockConfigModel.contentElementTypeKey); + if(wasAdded && vm.layout[createIndex]) { + const newBlock = vm.layout[createIndex].$block; + addedCallback(newBlock); + } + } + + if(!(mouseEvent.ctrlKey || mouseEvent.metaKey)) { + editorService.close(); + if (wasAdded) { + userFlowWhenBlockWasCreated(createIndex); + } + } + }, + close: function() { + // If opened by a inline creator button(index less than length), we want to move the focus away, to hide line-creator. + if (createIndex < vm.layout.length) { + vm.setBlockFocus(vm.layout[Math.max(createIndex-1, 0)].$block); + } + + editorService.close(); + } + }; + + blockPickerModel.clickClearClipboard = function ($event) { + clipboardService.clearEntriesOfType(clipboardService.TYPES.ELEMENT_TYPE, vm.availableContentTypesAliases); + clipboardService.clearEntriesOfType(clipboardService.TYPES.BLOCK, vm.availableContentTypesAliases); + }; + + blockPickerModel.clipboardItems = vm.clipboardItems; + + // open block picker overlay + editorService.open(blockPickerModel); + + }; + + function userFlowWhenBlockWasCreated(createIndex) { + if (vm.layout.length > createIndex) { + var blockObject = vm.layout[createIndex].$block; + if (blockObject.hideContentInOverlay !== true && blockObject.content.variants[0].tabs.find(tab => tab.properties.length > 0) !== undefined) { + vm.options.createFlow = true; + blockObject.edit(); + vm.options.createFlow = false; + } + } + } + + function updateClipboard(firstTime) { + + //var oldAmount = vm.clipboardItems.length; + + vm.clipboardItems = []; + + var entriesForPaste = clipboardService.retrieveEntriesOfType(clipboardService.TYPES.ELEMENT_TYPE, vm.availableContentTypesAliases); + entriesForPaste.forEach(function (entry) { + var pasteEntry = { + type: clipboardService.TYPES.ELEMENT_TYPE, + date: entry.date, + pasteData: entry.data, + elementTypeModel: { + name: entry.label, + icon: entry.icon + } + } + if(Array.isArray(entry.data) === false) { + var scaffold = modelObject.getScaffoldFromAlias(entry.alias); + if(scaffold) { + pasteEntry.blockConfigModel = modelObject.getBlockConfiguration(scaffold.contentTypeKey); + } + } + vm.clipboardItems.push(pasteEntry); + }); + + entriesForPaste = clipboardService.retrieveEntriesOfType(clipboardService.TYPES.BLOCK, vm.availableContentTypesAliases); + entriesForPaste.forEach(function (entry) { + var pasteEntry = { + type: clipboardService.TYPES.BLOCK, + date: entry.date, + pasteData: entry.data, + elementTypeModel: { + name: entry.label, + icon: entry.icon + } + } + if(Array.isArray(entry.data) === false) { + pasteEntry.blockConfigModel = modelObject.getBlockConfiguration(entry.data.data.contentTypeKey); + } + vm.clipboardItems.push(pasteEntry); + }); + + vm.clipboardItems.sort( (a, b) => { + return b.date - a.date + }); + + //pasteSingleBlockAction.isDisabled = vm.clipboardItems.length === 0; + } + + function copyBlock(block) { + clipboardService.copy(clipboardService.TYPES.BLOCK, block.content.contentTypeAlias, {"layout": block.layout, "data": block.data, "settingsData":block.settingsData}, block.label, block.content.icon, block.content.udi); + } + + function requestPasteFromClipboard(index, pasteEntry, pasteType) { + + if (pasteEntry === undefined) { + return false; + } + + var layoutEntry; + if (pasteType === clipboardService.TYPES.ELEMENT_TYPE) { + layoutEntry = modelObject.createFromElementType(pasteEntry); + } else if (pasteType === clipboardService.TYPES.BLOCK) { + layoutEntry = modelObject.createFromBlockData(pasteEntry); + } else { + // Not a supported paste type. + return false; + } + + if (layoutEntry === null) { + // Pasting did not go well. + return false; + } + + // make block model + var blockObject = getBlockObject(layoutEntry); + if (blockObject === null) { + // Initialization of the Block Object didn't go well, therefor we will fail the paste action. + return false; + } + + // set the BlockObject on our layout entry. + layoutEntry.$block = blockObject; + + // insert layout entry at the desired location in layout. + vm.layout.splice(index, 0, layoutEntry); + + vm.currentBlockInFocus = blockObject; + + return true; + } + + function requestDeleteBlock(block) { + if (vm.readonly) return; + + localizationService.localizeMany(["general_delete", "blockEditor_confirmDeleteBlockMessage", "contentTypeEditor_yesDelete"]).then(function (data) { + const overlay = { + title: data[0], + content: localizationService.tokenReplace(data[1], [block.label]), + submitButtonLabel: data[2], + close: function () { + overlayService.close(); + }, + submit: function () { + deleteBlock(block); + setDirty(); + overlayService.close(); + } + }; + + overlayService.confirmDelete(overlay); + }); + } + + function openSettingsForBlock(block, blockIndex, parentForm) { + editBlock(block, true, blockIndex, parentForm); + } + + function getBlockByContentUdi(blockContentUdi) { + + var layoutIndex = vm.layout.findIndex(entry => entry.contentUdi === blockContentUdi); + if (layoutIndex === -1) { + return undefined; + } + + return vm.layout[layoutIndex].$block; + } + + vm.blockEditorApi = { + getBlockByContentUdi: getBlockByContentUdi, + showCreateDialog: showCreateDialog, + activateBlock: activateBlock, + editBlock: editBlock, + copyBlock: copyBlock, + requestDeleteBlock: requestDeleteBlock, + deleteBlock: deleteBlock, + openSettingsForBlock: openSettingsForBlock, + readonly: vm.readonly, + singleBlockMode: false + }; + + $scope.$on("$destroy", function () { + for (const subscription of unsubscribe) { + subscription(); + } + }); + } + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.controller.js deleted file mode 100644 index 4973bede7a..0000000000 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.controller.js +++ /dev/null @@ -1,133 +0,0 @@ -angular.module("umbraco") - .controller("Umbraco.PropertyEditors.RTEController", - function ($scope, $q, assetsService, $timeout, tinyMceService, angularHelper, tinyMceAssets, $element) { - - // TODO: A lot of the code below should be shared between the grid rte and the normal rte - - var unsubscribe = []; - $scope.isLoading = true; - - //To id the html textarea we need to use the datetime ticks because we can have multiple rte's per a single property alias - // because now we have to support having 2x (maybe more at some stage) content editors being displayed at once. This is because - // we have this mini content editor panel that can be launched with MNTP. - $scope.textAreaHtmlId = $scope.model.alias + "_" + String.CreateGuid(); - - var editorConfig = $scope.model.config ? $scope.model.config.editor : null; - if (!editorConfig || Utilities.isString(editorConfig)) { - editorConfig = tinyMceService.defaultPrevalues(); - } - - var width = editorConfig.dimensions ? parseInt(editorConfig.dimensions.width, 10) || null : null; - var height = editorConfig.dimensions ? parseInt(editorConfig.dimensions.height, 10) || null : null; - - $scope.containerWidth = "auto"; - $scope.containerHeight = "auto"; - $scope.containerOverflow = "inherit"; - - var promises = []; - - // we need to make sure that the element is initialized before we can init TinyMCE, because we find the placeholder by ID, so it needs to be appended to document before. - var initPromise = $q((resolve, reject) => { - this.$onInit = resolve; - }); - - promises.push(initPromise); - - //queue file loading - tinyMceAssets.forEach(function (tinyJsAsset) { - promises.push(assetsService.loadJs(tinyJsAsset, $scope)); - }); - - //stores a reference to the editor - var tinyMceEditor = null; - - promises.push(tinyMceService.getTinyMceEditorConfig({ - htmlId: $scope.textAreaHtmlId, - stylesheets: editorConfig.stylesheets, - toolbar: editorConfig.toolbar, - mode: editorConfig.mode - })); - - //wait for queue to end - $q.all(promises).then(function (result) { - - var standardConfig = result[promises.length - 1]; - - if (height !== null) { - standardConfig.plugins.splice(standardConfig.plugins.indexOf("autoresize"), 1); - } - - //create a baseline Config to extend upon - var baseLineConfigObj = { - maxImageSize: editorConfig.maxImageSize, - width: width, - height: height - }; - - baseLineConfigObj.setup = function (editor) { - - //set the reference - tinyMceEditor = editor; - - tinyMceEditor.on('init', function (e) { - $timeout(function () { - $scope.isLoading = false; - }); - }); - tinyMceEditor.on("focus", function () { - $element[0].dispatchEvent(new CustomEvent('umb-rte-focus', {composed: true, bubbles: true})); - }); - tinyMceEditor.on("blur", function () { - $element[0].dispatchEvent(new CustomEvent('umb-rte-blur', {composed: true, bubbles: true})); - }); - - //initialize the standard editor functionality for Umbraco - tinyMceService.initializeEditor({ - editor: editor, - toolbar: editorConfig.toolbar, - model: $scope.model, - currentFormInput: $scope.rteForm.modelValue - }); - - }; - - Utilities.extend(baseLineConfigObj, standardConfig); - - // Readonly mode - baseLineConfigObj.toolbar = $scope.readonly ? false : baseLineConfigObj.toolbar; - baseLineConfigObj.readonly = $scope.readonly ? 1 : baseLineConfigObj.readonly; - - // We need to wait for DOM to have rendered before we can find the element by ID. - $timeout(function () { - tinymce.init(baseLineConfigObj); - }, 150); - - //listen for formSubmitting event (the result is callback used to remove the event subscription) - unsubscribe.push($scope.$on("formSubmitting", function () { - if (tinyMceEditor !== undefined && tinyMceEditor != null && !$scope.isLoading) { - $scope.model.value = tinyMceEditor.getContent(); - } - })); - - $scope.focus = function () { - tinyMceEditor.focus(); - } - - //when the element is disposed we need to unsubscribe! - // NOTE: this is very important otherwise if this is part of a modal, the listener still exists because the dom - // element might still be there even after the modal has been hidden. - $scope.$on('$destroy', function () { - for (var i = 0; i < unsubscribe.length; i++) { - unsubscribe[i](); - } - if (tinyMceEditor !== undefined && tinyMceEditor != null) { - if($element) { - $element[0]?.dispatchEvent(new CustomEvent('blur', {composed: true, bubbles: true})); - } - tinyMceEditor.destroy() - } - }); - - }); - - }); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.html index d81b858002..94307f410e 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.html +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.html @@ -1,10 +1,4 @@ -
- - - -
- -
-
-
-
+ + diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.prevalues.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.prevalues.controller.js index 1d872b6858..41055567e1 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.prevalues.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.prevalues.controller.js @@ -29,11 +29,11 @@ angular.module("umbraco").controller("Umbraco.PrevalueEditors.RteController", tinyMceService.configuration().then(config => { $scope.tinyMceConfig = config; - + // extend commands with properties for font-icon and if it is a custom command $scope.tinyMceConfig.commands = _.map($scope.tinyMceConfig.commands, obj => { const icon = getIcon(obj.alias); - + const objCmd = Utilities.extend(obj, { fontIcon: icon.name, isCustom: icon.isCustom, @@ -63,7 +63,7 @@ angular.module("umbraco").controller("Umbraco.PrevalueEditors.RteController", } }); }); - + }); stylesheetResource.getAll().then(stylesheets => { @@ -195,6 +195,10 @@ angular.module("umbraco").controller("Umbraco.PrevalueEditors.RteController", icon.name = "icon-picture"; icon.isCustom = true; break; + case "umbblockpicker": + icon.name = "icon-document"; + icon.isCustom = true; + break; case "umbmacro": icon.name = "icon-settings-alt"; icon.isCustom = true; diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/umb-rte-property-editor.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/umb-rte-property-editor.html new file mode 100644 index 0000000000..2d7307676d --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/umb-rte-property-editor.html @@ -0,0 +1,10 @@ +
+ + + +
+ +
+
+
+
diff --git a/src/Umbraco.Web.UI.Client/test/unit/app/propertyeditors/rte-controller.spec.js b/src/Umbraco.Web.UI.Client/test/unit/app/propertyeditors/rte-controller.spec.js index a835673e99..b60969b3d2 100644 --- a/src/Umbraco.Web.UI.Client/test/unit/app/propertyeditors/rte-controller.spec.js +++ b/src/Umbraco.Web.UI.Client/test/unit/app/propertyeditors/rte-controller.spec.js @@ -1,4 +1,4 @@ -describe('RTE controller tests', function () { +/*describe('RTE controller tests', function () { var scope, controllerFactory, element; //mock tinymce globals @@ -29,14 +29,15 @@ describe('RTE controller tests', function () { describe('initialization', function () { - it('should define the default properties on construction', function () { + it('should define the default properties on construction', function () { controllerFactory('Umbraco.PropertyEditors.RTEController', { $scope: scope, $routeParams: routeParams, $element: element }); }); - - }); -}); + }); + +}); +*/ diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditorTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditorTests.cs new file mode 100644 index 0000000000..faf7a2b566 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditorTests.cs @@ -0,0 +1,179 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Blocks; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.PropertyEditors; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +public class RichTextPropertyEditorTests : UmbracoIntegrationTest +{ + private IContentTypeService ContentTypeService => GetRequiredService(); + + private IContentService ContentService => GetRequiredService(); + + private IDataTypeService DataTypeService => GetRequiredService(); + + private IJsonSerializer JsonSerializer => GetRequiredService(); + + [Test] + public void Can_Use_Markup_String_As_Value() + { + var contentType = ContentTypeBuilder.CreateTextPageContentType("myContentType"); + contentType.AllowedTemplates = Enumerable.Empty(); + ContentTypeService.Save(contentType); + + var dataType = DataTypeService.GetDataType(contentType.PropertyTypes.First(propertyType => propertyType.Alias == "bodyText").DataTypeId)!; + var editor = dataType.Editor!; + var valueEditor = editor.GetValueEditor(); + + const string markup = "

This is some markup

"; + + var content = ContentBuilder.CreateTextpageContent(contentType, "My Content", -1); + content.Properties["bodyText"]!.SetValue(markup); + ContentService.Save(content); + + var toEditor = valueEditor.ToEditor(content.Properties["bodyText"]); + var richTextEditorValue = toEditor as RichTextEditorValue; + + Assert.IsNotNull(richTextEditorValue); + Assert.AreEqual(markup, richTextEditorValue.Markup); + } + + [Test] + public void Can_Use_RichTextEditorValue_As_Value() + { + var contentType = ContentTypeBuilder.CreateTextPageContentType("myContentType"); + contentType.AllowedTemplates = Enumerable.Empty(); + ContentTypeService.Save(contentType); + + var dataType = DataTypeService.GetDataType(contentType.PropertyTypes.First(propertyType => propertyType.Alias == "bodyText").DataTypeId)!; + var editor = dataType.Editor!; + var valueEditor = editor.GetValueEditor(); + + const string markup = "

This is some markup

"; + var propertyValue = RichTextPropertyEditorHelper.SerializeRichTextEditorValue(new RichTextEditorValue { Markup = markup, Blocks = null }, JsonSerializer); + + var content = ContentBuilder.CreateTextpageContent(contentType, "My Content", -1); + content.Properties["bodyText"]!.SetValue(propertyValue); + ContentService.Save(content); + + var toEditor = valueEditor.ToEditor(content.Properties["bodyText"]); + var richTextEditorValue = toEditor as RichTextEditorValue; + + Assert.IsNotNull(richTextEditorValue); + Assert.AreEqual(markup, richTextEditorValue.Markup); + } + + [Test] + public void Can_Track_Block_References() + { + var elementType = ContentTypeBuilder.CreateAllTypesContentType("myElementType", "My Element Type"); + elementType.IsElement = true; + ContentTypeService.Save(elementType); + + var contentType = ContentTypeBuilder.CreateTextPageContentType("myContentType"); + contentType.AllowedTemplates = Enumerable.Empty(); + ContentTypeService.Save(contentType); + + var pickedContent = ContentBuilder.CreateTextpageContent(contentType, "My Content", -1); + ContentService.Save(pickedContent); + + var dataType = DataTypeService.GetDataType(contentType.PropertyTypes.First(propertyType => propertyType.Alias == "bodyText").DataTypeId)!; + var editor = dataType.Editor!; + var valueEditor = (BlockValuePropertyValueEditorBase)editor.GetValueEditor(); + + var elementId = Guid.NewGuid(); + var propertyValue = RichTextPropertyEditorHelper.SerializeRichTextEditorValue( + new RichTextEditorValue + { + Markup = @$"

This is some markup

", + Blocks = JsonSerializer.Deserialize($$""" + { + "layout": { + "Umbraco.TinyMCE": [{ + "contentUdi": "umb://element/{{elementId:N}}" + } + ] + }, + "contentData": [{ + "contentTypeKey": "{{elementType.Key:B}}", + "udi": "umb://element/{{elementId:N}}", + "contentPicker": "umb://document/{{pickedContent.Key:N}}" + } + ], + "settingsData": [] + } + """) + }, + JsonSerializer); + + var content = ContentBuilder.CreateTextpageContent(contentType, "My Content", -1); + content.Properties["bodyText"]!.SetValue(propertyValue); + ContentService.Save(content); + + var references = valueEditor.GetReferences(content.GetValue("bodyText")).ToArray(); + Assert.AreEqual(1, references.Length); + var reference = references.First(); + Assert.AreEqual(Constants.Conventions.RelationTypes.RelatedDocumentAlias, reference.RelationTypeAlias); + Assert.AreEqual(pickedContent.GetUdi(), reference.Udi); + } + + [Test] + public void Can_Track_Block_Tags() + { + var elementType = ContentTypeBuilder.CreateAllTypesContentType("myElementType", "My Element Type"); + elementType.IsElement = true; + ContentTypeService.Save(elementType); + + var contentType = ContentTypeBuilder.CreateTextPageContentType("myContentType"); + contentType.AllowedTemplates = Enumerable.Empty(); + ContentTypeService.Save(contentType); + + var dataType = DataTypeService.GetDataType(contentType.PropertyTypes.First(propertyType => propertyType.Alias == "bodyText").DataTypeId)!; + var editor = dataType.Editor!; + var valueEditor = (BlockValuePropertyValueEditorBase)editor.GetValueEditor(); + + var elementId = Guid.NewGuid(); + var propertyValue = RichTextPropertyEditorHelper.SerializeRichTextEditorValue( + new RichTextEditorValue + { + Markup = @$"

This is some markup

", + Blocks = JsonSerializer.Deserialize($$""" + { + "layout": { + "Umbraco.TinyMCE": [{ + "contentUdi": "umb://element/{{elementId:N}}" + } + ] + }, + "contentData": [{ + "contentTypeKey": "{{elementType.Key:B}}", + "udi": "umb://element/{{elementId:N}}", + "tags": "['Tag One', 'Tag Two', 'Tag Three']" + } + ], + "settingsData": [] + } + """) + }, + JsonSerializer); + + var content = ContentBuilder.CreateTextpageContent(contentType, "My Content", -1); + content.Properties["bodyText"]!.SetValue(propertyValue); + ContentService.Save(content); + + var tags = valueEditor.GetTags(content.GetValue("bodyText"), null, null).ToArray(); + Assert.AreEqual(3, tags.Length); + Assert.IsNotNull(tags.Single(tag => tag.Text == "Tag One")); + Assert.IsNotNull(tags.Single(tag => tag.Text == "Tag Two")); + Assert.IsNotNull(tags.Single(tag => tag.Text == "Tag Three")); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/RichTextParserTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/RichTextParserTests.cs index 2fef067c4c..b7712b5346 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/RichTextParserTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/RichTextParserTests.cs @@ -3,8 +3,11 @@ using Moq; using NUnit.Framework; using Umbraco.Cms.Core; using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.Blocks; using Umbraco.Cms.Core.Models.DeliveryApi; using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Infrastructure.DeliveryApi; @@ -12,7 +15,7 @@ using Umbraco.Cms.Infrastructure.DeliveryApi; namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; [TestFixture] -public class RichTextParserTests +public class RichTextParserTests : PropertyValueConverterTests { private readonly Guid _contentKey = Guid.NewGuid(); private readonly Guid _contentRootKey = Guid.NewGuid(); @@ -33,7 +36,7 @@ public class RichTextParserTests { var parser = CreateRichTextElementParser(); - var element = parser.Parse("

Some text paragraph

") as RichTextGenericElement; + var element = parser.Parse("

Some text paragraph

") as RichTextRootElement; Assert.IsNotNull(element); Assert.AreEqual(1, element.Elements.Count()); var paragraph = element.Elements.Single() as RichTextGenericElement; @@ -49,7 +52,7 @@ public class RichTextParserTests { var parser = CreateRichTextElementParser(); - var element = parser.Parse("

Some text
More text
Even more text

") as RichTextGenericElement; + var element = parser.Parse("

Some text
More text
Even more text

") as RichTextRootElement; Assert.IsNotNull(element); Assert.AreEqual(1, element.Elements.Count()); var paragraph = element.Elements.Single() as RichTextGenericElement; @@ -97,7 +100,7 @@ public class RichTextParserTests { var parser = CreateRichTextElementParser(); - var element = parser.Parse("

Text in a data-something SPAN

") as RichTextGenericElement; + var element = parser.Parse("

Text in a data-something SPAN

") as RichTextRootElement; Assert.IsNotNull(element); var span = element.Elements.OfType().Single().Elements.Single() as RichTextGenericElement; Assert.IsNotNull(span); @@ -115,7 +118,7 @@ public class RichTextParserTests { var parser = CreateRichTextElementParser(); - var element = parser.Parse("

Text in a data-something SPAN

") as RichTextGenericElement; + var element = parser.Parse("

Text in a data-something SPAN

") as RichTextRootElement; Assert.IsNotNull(element); var span = element.Elements.OfType().Single().Elements.Single() as RichTextGenericElement; Assert.IsNotNull(span); @@ -130,7 +133,7 @@ public class RichTextParserTests { var parser = CreateRichTextElementParser(); - var element = parser.Parse($"

") as RichTextGenericElement; + var element = parser.Parse($"

") as RichTextRootElement; Assert.IsNotNull(element); var link = element.Elements.OfType().Single().Elements.Single() as RichTextGenericElement; Assert.IsNotNull(link); @@ -149,7 +152,7 @@ public class RichTextParserTests { var parser = CreateRichTextElementParser(); - var element = parser.Parse($"

") as RichTextGenericElement; + var element = parser.Parse($"

") as RichTextRootElement; Assert.IsNotNull(element); var link = element.Elements.OfType().Single().Elements.Single() as RichTextGenericElement; Assert.IsNotNull(link); @@ -164,7 +167,7 @@ public class RichTextParserTests { var parser = CreateRichTextElementParser(); - var element = parser.Parse($"

") as RichTextGenericElement; + var element = parser.Parse($"

") as RichTextRootElement; Assert.IsNotNull(element); var link = element.Elements.OfType().Single().Elements.Single() as RichTextGenericElement; Assert.IsNotNull(link); @@ -179,7 +182,7 @@ public class RichTextParserTests { var parser = CreateRichTextElementParser(); - var element = parser.Parse($"

This is the link text

") as RichTextGenericElement; + var element = parser.Parse($"

This is the link text

") as RichTextRootElement; Assert.IsNotNull(element); var link = element.Elements.OfType().Single().Elements.Single() as RichTextGenericElement; Assert.IsNotNull(link); @@ -195,7 +198,7 @@ public class RichTextParserTests { var parser = CreateRichTextElementParser(); - var element = parser.Parse($"

") as RichTextGenericElement; + var element = parser.Parse($"

") as RichTextRootElement; Assert.IsNotNull(element); var link = element.Elements.OfType().Single().Elements.Single() as RichTextGenericElement; Assert.IsNotNull(link); @@ -208,7 +211,7 @@ public class RichTextParserTests { var parser = CreateRichTextElementParser(); - var element = parser.Parse($"

") as RichTextGenericElement; + var element = parser.Parse($"

") as RichTextRootElement; Assert.IsNotNull(element); var link = element.Elements.OfType().Single().Elements.Single() as RichTextGenericElement; Assert.IsNotNull(link); @@ -223,7 +226,7 @@ public class RichTextParserTests { var parser = CreateRichTextElementParser(); - var element = parser.Parse($"

") as RichTextGenericElement; + var element = parser.Parse($"

") as RichTextRootElement; Assert.IsNotNull(element); var link = element.Elements.OfType().Single().Elements.Single() as RichTextGenericElement; Assert.IsNotNull(link); @@ -233,6 +236,128 @@ public class RichTextParserTests Assert.AreEqual("https://some.where/something.png?rmode=max&width=500", link.Attributes.First().Value); } + [Test] + public void ParseElement_RemovesComments() + { + var parser = CreateRichTextElementParser(); + + var element = parser.Parse("

some textsome more text

") as RichTextRootElement; + Assert.IsNotNull(element); + var paragraph = element.Elements.Single() as RichTextGenericElement; + Assert.IsNotNull(paragraph); + Assert.AreEqual(2, paragraph.Elements.Count()); + var textElements = paragraph.Elements.OfType().ToArray(); + Assert.AreEqual(2, textElements.Length); + Assert.AreEqual("some text", textElements.First().Text); + Assert.AreEqual("some more text", textElements.Last().Text); + } + + [TestCase(true)] + [TestCase(false)] + public void ParseElement_CleansUpBlocks(bool inlineBlock) + { + var parser = CreateRichTextElementParser(); + var id = Guid.NewGuid(); + + var tagName = $"umb-rte-block{(inlineBlock ? "-inline" : string.Empty)}"; + var element = parser.Parse($"

<{tagName} data-content-udi=\"umb://element/{id:N}\">

") as RichTextRootElement; + Assert.IsNotNull(element); + var paragraph = element.Elements.Single() as RichTextGenericElement; + Assert.IsNotNull(paragraph); + var block = paragraph.Elements.Single() as RichTextGenericElement; + Assert.IsNotNull(block); + Assert.AreEqual(tagName, block.Tag); + Assert.AreEqual(1, block.Attributes.Count); + Assert.IsTrue(block.Attributes.ContainsKey("content-id")); + Assert.AreEqual(id, block.Attributes["content-id"]); + Assert.IsEmpty(block.Elements); + } + + [TestCase(true)] + [TestCase(false)] + public void ParseElement_AppendsBlocks(bool inlineBlock) + { + var parser = CreateRichTextElementParser(); + var block1ContentId = Guid.NewGuid(); + var block2ContentId = Guid.NewGuid(); + var block2SettingsId = Guid.NewGuid(); + RichTextBlockModel richTextBlockModel = new RichTextBlockModel( + new List + { + new ( + Udi.Create(Constants.UdiEntityType.Element, block1ContentId), + CreateElement(block1ContentId, 123), + null!, + null!), + new ( + Udi.Create(Constants.UdiEntityType.Element, block2ContentId), + CreateElement(block2ContentId, 456), + Udi.Create(Constants.UdiEntityType.Element, block2SettingsId), + CreateElement(block2SettingsId, 789)) + }); + + var tagName = $"umb-rte-block{(inlineBlock ? "-inline" : string.Empty)}"; + var element = parser.Parse($"

<{tagName} data-content-udi=\"umb://element/{block1ContentId:N}\"><{tagName} data-content-udi=\"umb://element/{block2ContentId:N}\">

", richTextBlockModel) as RichTextRootElement; + Assert.IsNotNull(element); + var paragraph = element.Elements.Single() as RichTextGenericElement; + Assert.IsNotNull(paragraph); + Assert.AreEqual(2, paragraph.Elements.Count()); + + var block1Element = paragraph.Elements.First() as RichTextGenericElement; + Assert.IsNotNull(block1Element); + Assert.AreEqual(tagName, block1Element.Tag); + Assert.AreEqual(block1ContentId, block1Element.Attributes["content-id"]); + + var block2Element = paragraph.Elements.Last() as RichTextGenericElement; + Assert.IsNotNull(block2Element); + Assert.AreEqual(tagName, block2Element.Tag); + Assert.AreEqual(block2ContentId, block2Element.Attributes["content-id"]); + + Assert.AreEqual(2, element.Blocks.Count()); + + var block1 = element.Blocks.First(); + Assert.AreEqual(block1ContentId, block1.Content.Id); + Assert.AreEqual(123, block1.Content.Properties["number"]); + Assert.IsNull(block1.Settings); + + var block2 = element.Blocks.Last(); + Assert.AreEqual(block2ContentId, block2.Content.Id); + Assert.AreEqual(456, block2.Content.Properties["number"]); + Assert.AreEqual(block2SettingsId, block2.Settings!.Id); + Assert.AreEqual(789, block2.Settings.Properties["number"]); + } + + [Test] + public void ParseElement_CanHandleMixedInlineAndBlockLevelBlocks() + { + var parser = CreateRichTextElementParser(); + var id1 = Guid.NewGuid(); + var id2 = Guid.NewGuid(); + + var element = parser.Parse($"

") as RichTextRootElement; + Assert.IsNotNull(element); + Assert.AreEqual(2, element.Elements.Count()); + + var paragraph = element.Elements.First() as RichTextGenericElement; + Assert.IsNotNull(paragraph); + + var inlineBlock = paragraph.Elements.Single() as RichTextGenericElement; + Assert.IsNotNull(inlineBlock); + Assert.AreEqual("umb-rte-block-inline", inlineBlock.Tag); + Assert.AreEqual(1, inlineBlock.Attributes.Count); + Assert.IsTrue(inlineBlock.Attributes.ContainsKey("content-id")); + Assert.AreEqual(id1, inlineBlock.Attributes["content-id"]); + Assert.IsEmpty(inlineBlock.Elements); + + var blockLevelBlock = element.Elements.Last() as RichTextGenericElement; + Assert.IsNotNull(blockLevelBlock); + Assert.AreEqual("umb-rte-block", blockLevelBlock.Tag); + Assert.AreEqual(1, blockLevelBlock.Attributes.Count); + Assert.IsTrue(blockLevelBlock.Attributes.ContainsKey("content-id")); + Assert.AreEqual(id2, blockLevelBlock.Attributes["content-id"]); + Assert.IsEmpty(blockLevelBlock.Elements); + } + [Test] public void ParseMarkup_CanParseContentLink() { @@ -303,6 +428,29 @@ public class RichTextParserTests Assert.AreEqual(html, result); } + [TestCase(true)] + [TestCase(false)] + public void ParseMarkup_CleansUpBlocks(bool inlineBlock) + { + var parser = CreateRichTextMarkupParser(); + var id = Guid.NewGuid(); + + var tagName = $"umb-rte-block{(inlineBlock ? "-inline" : string.Empty)}"; + var result = parser.Parse($"

<{tagName} data-content-udi=\"umb://element/{id:N}\">

"); + Assert.AreEqual($"

<{tagName} data-content-id=\"{id:D}\">

", result); + } + + [Test] + public void ParseMarkup_CanHandleMixedInlineAndBlockLevelBlocks() + { + var parser = CreateRichTextMarkupParser(); + var id1 = Guid.NewGuid(); + var id2 = Guid.NewGuid(); + + var result = parser.Parse($"

"); + Assert.AreEqual($"

", result); + } + private ApiRichTextElementParser CreateRichTextElementParser() { SetupTestContent(out var routeBuilder, out var snapshotAccessor, out var urlProvider); @@ -311,6 +459,7 @@ public class RichTextParserTests routeBuilder, urlProvider, snapshotAccessor, + new ApiElementBuilder(CreateOutputExpansionStrategyAccessor()), Mock.Of>()); } @@ -362,4 +511,21 @@ public class RichTextParserTests snapshotAccessor = snapshotAccessorMock.Object; urlProvider = urlProviderMock.Object; } + + private IPublishedElement CreateElement(Guid id, int propertyValue) + { + var elementType = new Mock(); + elementType.SetupGet(c => c.Alias).Returns("theElementType"); + elementType.SetupGet(c => c.ItemType).Returns(PublishedItemType.Element); + + var element = new Mock(); + element.SetupGet(c => c.Key).Returns(id); + element.SetupGet(c => c.ContentType).Returns(elementType.Object); + + var numberPropertyType = SetupPublishedPropertyType(new IntegerValueConverter(), "number", Constants.PropertyEditors.Aliases.Label); + var property = new PublishedElementPropertyBase(numberPropertyType, element.Object, false, PropertyCacheLevel.None, propertyValue); + + element.SetupGet(c => c.Properties).Returns(new[] { property }); + return element.Object; + } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockGridPropertyValueConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockGridPropertyValueConverterTests.cs new file mode 100644 index 0000000000..89379322ee --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockGridPropertyValueConverterTests.cs @@ -0,0 +1,48 @@ +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Logging; +using Umbraco.Cms.Core.Models.Blocks; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; +using Umbraco.Cms.Infrastructure.Serialization; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors; + +[TestFixture] +public class BlockGridPropertyValueConverterTests : BlockPropertyValueConverterTestsBase +{ + protected override string PropertyEditorAlias => Constants.PropertyEditors.Aliases.BlockGrid; + + [Test] + public void Get_Value_Type() + { + var editor = CreateConverter(); + var config = ConfigForSingle(); + var propertyType = GetPropertyType(config); + + var valueType = editor.GetPropertyValueType(propertyType); + + // the result is always block grid model + Assert.AreEqual(typeof(BlockGridModel), valueType); + } + + private BlockGridPropertyValueConverter CreateConverter() + { + var publishedSnapshotAccessor = GetPublishedSnapshotAccessor(); + var publishedModelFactory = new NoopPublishedModelFactory(); + var editor = new BlockGridPropertyValueConverter( + Mock.Of(), + new BlockEditorConverter(publishedSnapshotAccessor, publishedModelFactory), + new JsonNetSerializer(), + new ApiElementBuilder(Mock.Of())); + return editor; + } + + private BlockGridConfiguration ConfigForSingle() => new() + { + Blocks = new[] { new BlockGridConfiguration.BlockGridBlockConfiguration { ContentElementTypeKey = ContentKey1 } }, + }; +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockListPropertyValueConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockListPropertyValueConverterTests.cs index 9e84217ab7..f0971ffee6 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockListPropertyValueConverterTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockListPropertyValueConverterTests.cs @@ -5,60 +5,19 @@ using Moq; using NUnit.Framework; using Umbraco.Cms.Core; using Umbraco.Cms.Core.DeliveryApi; -using Umbraco.Cms.Core.Models.DeliveryApi; using Umbraco.Cms.Core.Logging; using Umbraco.Cms.Core.Models.Blocks; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.PropertyEditors.ValueConverters; -using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Services; namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors; [TestFixture] -public class BlockListPropertyValueConverterTests +public class BlockListPropertyValueConverterTests : BlockPropertyValueConverterTestsBase { - private readonly Guid _contentKey1 = Guid.NewGuid(); - private readonly Guid _contentKey2 = Guid.NewGuid(); - private const string ContentAlias1 = "Test1"; - private const string ContentAlias2 = "Test2"; - private readonly Guid _settingKey1 = Guid.NewGuid(); - private readonly Guid _settingKey2 = Guid.NewGuid(); - private const string SettingAlias1 = "Setting1"; - private const string SettingAlias2 = "Setting2"; - - /// - /// Setup mocks for IPublishedSnapshotAccessor - /// - private IPublishedSnapshotAccessor GetPublishedSnapshotAccessor() - { - var test1ContentType = Mock.Of(x => - x.IsElement == true - && x.Key == _contentKey1 - && x.Alias == ContentAlias1); - var test2ContentType = Mock.Of(x => - x.IsElement == true - && x.Key == _contentKey2 - && x.Alias == ContentAlias2); - var test3ContentType = Mock.Of(x => - x.IsElement == true - && x.Key == _settingKey1 - && x.Alias == SettingAlias1); - var test4ContentType = Mock.Of(x => - x.IsElement == true - && x.Key == _settingKey2 - && x.Alias == SettingAlias2); - var contentCache = new Mock(); - contentCache.Setup(x => x.GetContentType(_contentKey1)).Returns(test1ContentType); - contentCache.Setup(x => x.GetContentType(_contentKey2)).Returns(test2ContentType); - contentCache.Setup(x => x.GetContentType(_settingKey1)).Returns(test3ContentType); - contentCache.Setup(x => x.GetContentType(_settingKey2)).Returns(test4ContentType); - var publishedSnapshot = Mock.Of(x => x.Content == contentCache.Object); - var publishedSnapshotAccessor = - Mock.Of(x => x.TryGetPublishedSnapshot(out publishedSnapshot)); - return publishedSnapshotAccessor; - } + protected override string PropertyEditorAlias => Constants.PropertyEditors.Aliases.BlockList; private BlockListPropertyValueConverter CreateConverter() { @@ -78,40 +37,31 @@ public class BlockListPropertyValueConverterTests { new BlockListConfiguration.BlockConfiguration { - ContentElementTypeKey = _contentKey1, - SettingsElementTypeKey = _settingKey2, + ContentElementTypeKey = ContentKey1, + SettingsElementTypeKey = SettingKey2, }, new BlockListConfiguration.BlockConfiguration { - ContentElementTypeKey = _contentKey2, - SettingsElementTypeKey = _settingKey1, + ContentElementTypeKey = ContentKey2, + SettingsElementTypeKey = SettingKey1, }, }, }; private BlockListConfiguration ConfigForSingle() => new() { - Blocks = new[] { new BlockListConfiguration.BlockConfiguration { ContentElementTypeKey = _contentKey1 } }, + Blocks = new[] { new BlockListConfiguration.BlockConfiguration { ContentElementTypeKey = ContentKey1 } }, }; private BlockListConfiguration ConfigForSingleBlockMode() => new() { - Blocks = new[] { new BlockListConfiguration.BlockConfiguration { ContentElementTypeKey = _contentKey1 } }, + Blocks = new[] { new BlockListConfiguration.BlockConfiguration { ContentElementTypeKey = ContentKey1 } }, ValidationLimit = new() { Min = 1, Max = 1 }, UseSingleBlockMode = true, }; - private IPublishedPropertyType GetPropertyType(BlockListConfiguration config) - { - var dataType = new PublishedDataType(1, "test", new Lazy(() => config)); - var propertyType = Mock.Of(x => - x.EditorAlias == Constants.PropertyEditors.Aliases.BlockList - && x.DataType == dataType); - return propertyType; - } - [Test] - public void Is_Converter_For() + public void IsConverter_For() { var editor = CreateConverter(); Assert.IsTrue(editor.IsConverter( @@ -136,7 +86,7 @@ public class BlockListPropertyValueConverterTests } [Test] - public void Get_Value_Type_Single() + public void Get_Value_TypeSingle() { var editor = CreateConverter(); var config = ConfigForSingle(); @@ -151,7 +101,7 @@ public class BlockListPropertyValueConverterTests } [Test] - public void Get_Value_Type_SingleBlockMode() + public void Get_Value_TypeSingleBlockMode() { var editor = CreateConverter(); var config = ConfigForSingleBlockMode(); @@ -263,7 +213,7 @@ data: []}"; }, contentData: [ { - 'contentTypeKey': '" + _contentKey1 + @"', + 'contentTypeKey': '" + ContentKey1 + @"', 'key': '1304E1DD-0000-4396-84FE-8A399231CB3D' } ] @@ -294,7 +244,7 @@ data: []}"; }, contentData: [ { - 'contentTypeKey': '" + _contentKey1 + @"', + 'contentTypeKey': '" + ContentKey1 + @"', 'udi': 'umb://element/1304E1DDAC87439684FE8A399231CB3D' } ] @@ -336,29 +286,29 @@ data: []}"; }, contentData: [ { - 'contentTypeKey': '" + _contentKey1 + @"', + 'contentTypeKey': '" + ContentKey1 + @"', 'udi': 'umb://element/1304E1DDAC87439684FE8A399231CB3D' }, { - 'contentTypeKey': '" + _contentKey2 + @"', + 'contentTypeKey': '" + ContentKey2 + @"', 'udi': 'umb://element/E05A034704424AB3A520E048E6197E79' }, { - 'contentTypeKey': '" + _contentKey2 + @"', + 'contentTypeKey': '" + ContentKey2 + @"', 'udi': 'umb://element/0A4A416E547D464FABCC6F345C17809A' } ], settingsData: [ { - 'contentTypeKey': '" + _settingKey1 + @"', + 'contentTypeKey': '" + SettingKey1 + @"', 'udi': 'umb://element/63027539B0DB45E7B70459762D4E83DD' }, { - 'contentTypeKey': '" + _settingKey2 + @"', + 'contentTypeKey': '" + SettingKey2 + @"', 'udi': 'umb://element/1F613E26CE274898908A561437AF5100' }, { - 'contentTypeKey': '" + _settingKey2 + @"', + 'contentTypeKey': '" + SettingKey2 + @"', 'udi': 'umb://element/BCF4BA3DA40C496C93EC58FAC85F18B9' } ], @@ -385,7 +335,7 @@ data: []}"; } [Test] - public void Data_Item_Removed_If_Removed_From_Config() + public void Data_Item_Removed_If_Removed_FromConfig() { var editor = CreateConverter(); @@ -397,7 +347,7 @@ data: []}"; { new BlockListConfiguration.BlockConfiguration { - ContentElementTypeKey = _contentKey2, + ContentElementTypeKey = ContentKey2, SettingsElementTypeKey = null, }, }, @@ -422,29 +372,29 @@ data: []}"; }, contentData: [ { - 'contentTypeKey': '" + _contentKey1 + @"', + 'contentTypeKey': '" + ContentKey1 + @"', 'udi': 'umb://element/1304E1DDAC87439684FE8A399231CB3D' }, { - 'contentTypeKey': '" + _contentKey2 + @"', + 'contentTypeKey': '" + ContentKey2 + @"', 'udi': 'umb://element/E05A034704424AB3A520E048E6197E79' }, { - 'contentTypeKey': '" + _contentKey2 + @"', + 'contentTypeKey': '" + ContentKey2 + @"', 'udi': 'umb://element/0A4A416E547D464FABCC6F345C17809A' } ], settingsData: [ { - 'contentTypeKey': '" + _settingKey1 + @"', + 'contentTypeKey': '" + SettingKey1 + @"', 'udi': 'umb://element/63027539B0DB45E7B70459762D4E83DD' }, { - 'contentTypeKey': '" + _settingKey2 + @"', + 'contentTypeKey': '" + SettingKey2 + @"', 'udi': 'umb://element/1F613E26CE274898908A561437AF5100' }, { - 'contentTypeKey': '" + _settingKey2 + @"', + 'contentTypeKey': '" + SettingKey2 + @"', 'udi': 'umb://element/BCF4BA3DA40C496C93EC58FAC85F18B9' } ], diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockPropertyValueConverterTestsBase.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockPropertyValueConverterTestsBase.cs new file mode 100644 index 0000000000..232b30da26 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockPropertyValueConverterTestsBase.cs @@ -0,0 +1,64 @@ +using Moq; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors; + +public abstract class BlockPropertyValueConverterTestsBase +{ + protected abstract string PropertyEditorAlias { get; } + + protected const string ContentAlias1 = "Test1"; + protected const string ContentAlias2 = "Test2"; + protected const string SettingAlias1 = "Setting1"; + protected const string SettingAlias2 = "Setting2"; + + protected Guid ContentKey1 { get; } = Guid.NewGuid(); + + protected Guid ContentKey2 { get; } = Guid.NewGuid(); + + protected Guid SettingKey1 { get; } = Guid.NewGuid(); + + protected Guid SettingKey2 { get; } = Guid.NewGuid(); + + /// + /// Setup mocks for IPublishedSnapshotAccessor + /// + protected IPublishedSnapshotAccessor GetPublishedSnapshotAccessor() + { + var test1ContentType = Mock.Of(x => + x.IsElement == true + && x.Key == ContentKey1 + && x.Alias == ContentAlias1); + var test2ContentType = Mock.Of(x => + x.IsElement == true + && x.Key == ContentKey2 + && x.Alias == ContentAlias2); + var test3ContentType = Mock.Of(x => + x.IsElement == true + && x.Key == SettingKey1 + && x.Alias == SettingAlias1); + var test4ContentType = Mock.Of(x => + x.IsElement == true + && x.Key == SettingKey2 + && x.Alias == SettingAlias2); + var contentCache = new Mock(); + contentCache.Setup(x => x.GetContentType(ContentKey1)).Returns(test1ContentType); + contentCache.Setup(x => x.GetContentType(ContentKey2)).Returns(test2ContentType); + contentCache.Setup(x => x.GetContentType(SettingKey1)).Returns(test3ContentType); + contentCache.Setup(x => x.GetContentType(SettingKey2)).Returns(test4ContentType); + var publishedSnapshot = Mock.Of(x => x.Content == contentCache.Object); + var publishedSnapshotAccessor = + Mock.Of(x => x.TryGetPublishedSnapshot(out publishedSnapshot)); + return publishedSnapshotAccessor; + } + + protected IPublishedPropertyType GetPropertyType(TPropertyEditorConfig config) + { + var dataType = new PublishedDataType(1, "test", new Lazy(() => config)); + var propertyType = Mock.Of(x => + x.EditorAlias == PropertyEditorAlias + && x.DataType == dataType); + return propertyType; + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/RichTextPropertyEditorHelperTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/RichTextPropertyEditorHelperTests.cs new file mode 100644 index 0000000000..120254a305 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/RichTextPropertyEditorHelperTests.cs @@ -0,0 +1,178 @@ +using Microsoft.Extensions.Logging; +using Moq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Infrastructure.Serialization; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors; + +[TestFixture] +public class RichTextPropertyEditorHelperTests +{ + [Test] + public void Can_Parse_Pure_Markup_String() + { + var result = RichTextPropertyEditorHelper.TryParseRichTextEditorValue("

this is some markup

", JsonSerializer(), Logger(), out RichTextEditorValue? value); + Assert.IsTrue(result); + Assert.IsNotNull(value); + Assert.AreEqual("

this is some markup

", value.Markup); + Assert.IsNull(value.Blocks); + } + + [Test] + public void Can_Parse_JObject() + { + var input = JObject.Parse("""" + { + "markup": "

this is some markup

", + "blocks": { + "layout": { + "Umbraco.TinyMCE": [{ + "contentUdi": "umb://element/36cc710ad8a645d0a07f7bbd8742cf02", + "settingsUdi": "umb://element/d2eeef66411142f4a1647a523eaffbc2", + } + ] + }, + "contentData": [{ + "contentTypeKey": "b2f0806c-d231-4c78-88b2-3c97d26e1123", + "udi": "umb://element/36cc710ad8a645d0a07f7bbd8742cf02", + "contentPropertyAlias": "A content property value" + } + ], + "settingsData": [{ + "contentTypeKey": "e7a9447f-e14d-44dd-9ae8-e68c3c3da598", + "udi": "umb://element/d2eeef66411142f4a1647a523eaffbc2", + "settingsPropertyAlias": "A settings property value" + } + ] + } + } + """"); + + var result = RichTextPropertyEditorHelper.TryParseRichTextEditorValue(input, JsonSerializer(), Logger(), out RichTextEditorValue? value); + Assert.IsTrue(result); + Assert.IsNotNull(value); + Assert.AreEqual("

this is some markup

", value.Markup); + + Assert.IsNotNull(value.Blocks); + + Assert.AreEqual(1, value.Blocks.ContentData.Count); + var item = value.Blocks.ContentData.Single(); + var contentTypeGuid = Guid.Parse("b2f0806c-d231-4c78-88b2-3c97d26e1123"); + var itemGuid = Guid.Parse("36cc710a-d8a6-45d0-a07f-7bbd8742cf02"); + Assert.AreEqual(contentTypeGuid, item.ContentTypeKey); + Assert.AreEqual(new GuidUdi(Constants.UdiEntityType.Element, itemGuid), item.Udi); + Assert.AreEqual(itemGuid, item.Key); + + Assert.AreEqual(1, value.Blocks.SettingsData.Count); + item = value.Blocks.SettingsData.Single(); + contentTypeGuid = Guid.Parse("e7a9447f-e14d-44dd-9ae8-e68c3c3da598"); + itemGuid = Guid.Parse("d2eeef66-4111-42f4-a164-7a523eaffbc2"); + Assert.AreEqual(contentTypeGuid, item.ContentTypeKey); + Assert.AreEqual(new GuidUdi(Constants.UdiEntityType.Element, itemGuid), item.Udi); + Assert.AreEqual(itemGuid, item.Key); + } + + [Test] + public void Can_Parse_Blocks_With_Both_Content_And_Settings() + { + const string input = """ + { + "markup": "

this is some markup

", + "blocks": { + "layout": { + "Umbraco.TinyMCE": [{ + "contentUdi": "umb://element/36cc710ad8a645d0a07f7bbd8742cf02", + "settingsUdi": "umb://element/d2eeef66411142f4a1647a523eaffbc2", + } + ] + }, + "contentData": [{ + "contentTypeKey": "b2f0806c-d231-4c78-88b2-3c97d26e1123", + "udi": "umb://element/36cc710ad8a645d0a07f7bbd8742cf02", + "contentPropertyAlias": "A content property value" + } + ], + "settingsData": [{ + "contentTypeKey": "e7a9447f-e14d-44dd-9ae8-e68c3c3da598", + "udi": "umb://element/d2eeef66411142f4a1647a523eaffbc2", + "settingsPropertyAlias": "A settings property value" + } + ] + } + } + """; + + var result = RichTextPropertyEditorHelper.TryParseRichTextEditorValue(input, JsonSerializer(), Logger(), out RichTextEditorValue? value); + Assert.IsTrue(result); + Assert.IsNotNull(value); + Assert.AreEqual("

this is some markup

", value.Markup); + + Assert.IsNotNull(value.Blocks); + + Assert.AreEqual(1, value.Blocks.ContentData.Count); + var item = value.Blocks.ContentData.Single(); + var contentTypeGuid = Guid.Parse("b2f0806c-d231-4c78-88b2-3c97d26e1123"); + var itemGuid = Guid.Parse("36cc710a-d8a6-45d0-a07f-7bbd8742cf02"); + Assert.AreEqual(contentTypeGuid, item.ContentTypeKey); + Assert.AreEqual(new GuidUdi(Constants.UdiEntityType.Element, itemGuid), item.Udi); + Assert.AreEqual(itemGuid, item.Key); + + Assert.AreEqual(1, value.Blocks.SettingsData.Count); + item = value.Blocks.SettingsData.Single(); + contentTypeGuid = Guid.Parse("e7a9447f-e14d-44dd-9ae8-e68c3c3da598"); + itemGuid = Guid.Parse("d2eeef66-4111-42f4-a164-7a523eaffbc2"); + Assert.AreEqual(contentTypeGuid, item.ContentTypeKey); + Assert.AreEqual(new GuidUdi(Constants.UdiEntityType.Element, itemGuid), item.Udi); + Assert.AreEqual(itemGuid, item.Key); + } + + [Test] + public void Can_Parse_Blocks_With_Content_Only() + { + const string input = """ + { + "markup": "

this is some markup

", + "blocks": { + "layout": { + "Umbraco.TinyMCE": [{ + "contentUdi": "umb://element/36cc710ad8a645d0a07f7bbd8742cf02" + } + ] + }, + "contentData": [{ + "contentTypeKey": "b2f0806c-d231-4c78-88b2-3c97d26e1123", + "udi": "umb://element/36cc710ad8a645d0a07f7bbd8742cf02", + "contentPropertyAlias": "A content property value" + } + ], + "settingsData": [] + } + } + """; + + var result = RichTextPropertyEditorHelper.TryParseRichTextEditorValue(input, JsonSerializer(), Logger(), out RichTextEditorValue? value); + Assert.IsTrue(result); + Assert.IsNotNull(value); + Assert.AreEqual("

this is some markup

", value.Markup); + + Assert.IsNotNull(value.Blocks); + + Assert.AreEqual(1, value.Blocks.ContentData.Count); + var item = value.Blocks.ContentData.Single(); + var contentTypeGuid = Guid.Parse("b2f0806c-d231-4c78-88b2-3c97d26e1123"); + var itemGuid = Guid.Parse("36cc710a-d8a6-45d0-a07f-7bbd8742cf02"); + Assert.AreEqual(contentTypeGuid, item.ContentTypeKey); + Assert.AreEqual(new GuidUdi(Constants.UdiEntityType.Element, itemGuid), item.Udi); + Assert.AreEqual(itemGuid, item.Key); + + Assert.AreEqual(0, value.Blocks.SettingsData.Count); + } + + private IJsonSerializer JsonSerializer() => new JsonNetSerializer(); + + private ILogger Logger() => Mock.Of(); +}