From fe841fdfc6144f8feca08a8cd50038980720b0d8 Mon Sep 17 00:00:00 2001 From: SonicGD Date: Fri, 5 Apr 2024 01:27:36 +0500 Subject: [PATCH] feat: more nice things --- Sitko.Blockly.sln | 15 ++ src/Sitko.EditorJS/Blocks/BlocksAccessor.cs | 23 +++ src/Sitko.EditorJS/Blocks/ContentBlock.cs | 16 ++ .../Blocks/ContentBlockAccessor.cs | 12 ++ .../Blocks/ContentBlockAttribute.cs | 7 + .../Blocks/ContentBlockConfig.cs | 3 + src/Sitko.EditorJS/Blocks/ContentBlockData.cs | 3 + .../Blocks/ContentBlockOptions.cs | 15 ++ .../Blocks/ContentBlockRegistration.cs | 3 + .../Blocks/ContentBlocksRegistry.cs | 48 +++++ src/Sitko.EditorJS/Blocks/IBlocksAccessor.cs | 8 + src/Sitko.EditorJS/Blocks/IContentBlock.cs | 6 + .../Blocks/IContentBlockAccessor.cs | 8 + .../Blocks/IContentBlockOptions.cs | 15 ++ .../Blocks/Paragraph/ParagraphBlock.cs | 4 + .../Blocks/Paragraph/ParagraphBlockConfig.cs | 10 + .../Blocks/Paragraph/ParagraphBlockData.cs | 8 + .../Blocks/Paragraph/ParagraphBlockOptions.cs | 7 + .../Blocks/SimpleImage/SimpleImageBlock.cs | 4 + .../SimpleImage/SimpleImageBlockData.cs | 16 ++ .../SimpleImage/SimpleImageBlockOptions.cs | 7 + .../Configuration/EditorJSBuilder.cs | 35 ++++ .../Configuration/EditorJSOptions.cs | 6 + .../Configuration/IEditorJSBuilder.cs | 10 + src/Sitko.EditorJS/Data/EditorJSData.cs | 12 ++ src/Sitko.EditorJS/EditorJS.razor | 10 +- src/Sitko.EditorJS/EditorJS.razor.cs | 50 ++--- src/Sitko.EditorJS/EditorJS.razor.js | 6 +- src/Sitko.EditorJS/Helpers/ValueCollection.cs | 63 +++++++ .../Json/ContentBlockConverter.cs | 81 ++++++++ .../Json/ContentBlocksTypeResolver.cs | 20 ++ .../ServiceCollectionExtensions.cs | 174 +----------------- tests/Sitko.EditorJS.Tests/JsonTests.cs | 110 +++++++++++ .../Sitko.EditorJS.Tests.csproj | 28 +++ 34 files changed, 643 insertions(+), 200 deletions(-) create mode 100644 src/Sitko.EditorJS/Blocks/BlocksAccessor.cs create mode 100644 src/Sitko.EditorJS/Blocks/ContentBlock.cs create mode 100644 src/Sitko.EditorJS/Blocks/ContentBlockAccessor.cs create mode 100644 src/Sitko.EditorJS/Blocks/ContentBlockAttribute.cs create mode 100644 src/Sitko.EditorJS/Blocks/ContentBlockConfig.cs create mode 100644 src/Sitko.EditorJS/Blocks/ContentBlockData.cs create mode 100644 src/Sitko.EditorJS/Blocks/ContentBlockOptions.cs create mode 100644 src/Sitko.EditorJS/Blocks/ContentBlockRegistration.cs create mode 100644 src/Sitko.EditorJS/Blocks/ContentBlocksRegistry.cs create mode 100644 src/Sitko.EditorJS/Blocks/IBlocksAccessor.cs create mode 100644 src/Sitko.EditorJS/Blocks/IContentBlock.cs create mode 100644 src/Sitko.EditorJS/Blocks/IContentBlockAccessor.cs create mode 100644 src/Sitko.EditorJS/Blocks/IContentBlockOptions.cs create mode 100644 src/Sitko.EditorJS/Blocks/Paragraph/ParagraphBlock.cs create mode 100644 src/Sitko.EditorJS/Blocks/Paragraph/ParagraphBlockConfig.cs create mode 100644 src/Sitko.EditorJS/Blocks/Paragraph/ParagraphBlockData.cs create mode 100644 src/Sitko.EditorJS/Blocks/Paragraph/ParagraphBlockOptions.cs create mode 100644 src/Sitko.EditorJS/Blocks/SimpleImage/SimpleImageBlock.cs create mode 100644 src/Sitko.EditorJS/Blocks/SimpleImage/SimpleImageBlockData.cs create mode 100644 src/Sitko.EditorJS/Blocks/SimpleImage/SimpleImageBlockOptions.cs create mode 100644 src/Sitko.EditorJS/Configuration/EditorJSBuilder.cs create mode 100644 src/Sitko.EditorJS/Configuration/EditorJSOptions.cs create mode 100644 src/Sitko.EditorJS/Configuration/IEditorJSBuilder.cs create mode 100644 src/Sitko.EditorJS/Data/EditorJSData.cs create mode 100644 src/Sitko.EditorJS/Helpers/ValueCollection.cs create mode 100644 src/Sitko.EditorJS/Json/ContentBlockConverter.cs create mode 100644 src/Sitko.EditorJS/Json/ContentBlocksTypeResolver.cs create mode 100644 tests/Sitko.EditorJS.Tests/JsonTests.cs create mode 100644 tests/Sitko.EditorJS.Tests/Sitko.EditorJS.Tests.csproj diff --git a/Sitko.Blockly.sln b/Sitko.Blockly.sln index 8da9843..a9571b4 100644 --- a/Sitko.Blockly.sln +++ b/Sitko.Blockly.sln @@ -60,6 +60,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sitko.Blockly.Data", "apps\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sitko.EditorJS", "src\Sitko.EditorJS\Sitko.EditorJS.csproj", "{8103ED8B-B508-438F-8180-B2EF88ED20F2}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sitko.EditorJS.Tests", "tests\Sitko.EditorJS.Tests\Sitko.EditorJS.Tests.csproj", "{0C314690-0274-4840-A9CE-98C931BC7AB6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -217,6 +219,18 @@ Global {8103ED8B-B508-438F-8180-B2EF88ED20F2}.Release|x64.Build.0 = Release|Any CPU {8103ED8B-B508-438F-8180-B2EF88ED20F2}.Release|x86.ActiveCfg = Release|Any CPU {8103ED8B-B508-438F-8180-B2EF88ED20F2}.Release|x86.Build.0 = Release|Any CPU + {0C314690-0274-4840-A9CE-98C931BC7AB6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0C314690-0274-4840-A9CE-98C931BC7AB6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0C314690-0274-4840-A9CE-98C931BC7AB6}.Debug|x64.ActiveCfg = Debug|Any CPU + {0C314690-0274-4840-A9CE-98C931BC7AB6}.Debug|x64.Build.0 = Debug|Any CPU + {0C314690-0274-4840-A9CE-98C931BC7AB6}.Debug|x86.ActiveCfg = Debug|Any CPU + {0C314690-0274-4840-A9CE-98C931BC7AB6}.Debug|x86.Build.0 = Debug|Any CPU + {0C314690-0274-4840-A9CE-98C931BC7AB6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0C314690-0274-4840-A9CE-98C931BC7AB6}.Release|Any CPU.Build.0 = Release|Any CPU + {0C314690-0274-4840-A9CE-98C931BC7AB6}.Release|x64.ActiveCfg = Release|Any CPU + {0C314690-0274-4840-A9CE-98C931BC7AB6}.Release|x64.Build.0 = Release|Any CPU + {0C314690-0274-4840-A9CE-98C931BC7AB6}.Release|x86.ActiveCfg = Release|Any CPU + {0C314690-0274-4840-A9CE-98C931BC7AB6}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {0367FAD9-C3FD-4E07-9CBE-103A17666A1D} = {DA312F56-FDB4-4853-A7A1-A8B2527399FC} @@ -232,5 +246,6 @@ Global {4C374AF7-0748-4915-988D-0338996B6AF9} = {32D6C989-DF2C-4C06-BD48-C322E72888B9} {88B081A7-F838-4E90-BB2E-581BAFAECEA6} = {32D6C989-DF2C-4C06-BD48-C322E72888B9} {8103ED8B-B508-438F-8180-B2EF88ED20F2} = {DA312F56-FDB4-4853-A7A1-A8B2527399FC} + {0C314690-0274-4840-A9CE-98C931BC7AB6} = {BE2C5E4A-BFBD-4645-AE0A-8B57A9EC1CB2} EndGlobalSection EndGlobal diff --git a/src/Sitko.EditorJS/Blocks/BlocksAccessor.cs b/src/Sitko.EditorJS/Blocks/BlocksAccessor.cs new file mode 100644 index 0000000..687c2ef --- /dev/null +++ b/src/Sitko.EditorJS/Blocks/BlocksAccessor.cs @@ -0,0 +1,23 @@ +namespace Sitko.EditorJS.Blocks; + +internal class BlocksAccessor(IEnumerable blockAccessors) : IBlocksAccessor +{ + private readonly IContentBlockAccessor[] blockAccessors = blockAccessors.ToArray(); + + public EditorJSConfig GetConfig(string holder) + { + var config = new EditorJSConfig { Holder = holder }; + foreach (var blockOptionsAccessor in blockAccessors) + { + config.Tools[blockOptionsAccessor.Key] = blockOptionsAccessor.Options.GetConfig(); + } + + return config; + } + + public IReadOnlyDictionary GetScripts() => + blockAccessors.ToDictionary(accessor => accessor.Key, accessor => accessor.Options.ScriptUrl); + + public IReadOnlyDictionary GetBlockTypes() => + blockAccessors.ToDictionary(block => block.Key, block => block.Type); +} \ No newline at end of file diff --git a/src/Sitko.EditorJS/Blocks/ContentBlock.cs b/src/Sitko.EditorJS/Blocks/ContentBlock.cs new file mode 100644 index 0000000..31bbdb6 --- /dev/null +++ b/src/Sitko.EditorJS/Blocks/ContentBlock.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; +using Sitko.EditorJS.Json; + +namespace Sitko.EditorJS.Blocks; + +[JsonConverter(typeof(ContentBlockConverter))] +public record ContentBlock : IContentBlock +{ + [JsonPropertyName("id")] public required string Id { get; init; } = ""; +} + +public record ContentBlock + : ContentBlock where TData : ContentBlockData, new() +{ + [JsonPropertyName("data")] public required TData Data { get; init; } +} diff --git a/src/Sitko.EditorJS/Blocks/ContentBlockAccessor.cs b/src/Sitko.EditorJS/Blocks/ContentBlockAccessor.cs new file mode 100644 index 0000000..7927415 --- /dev/null +++ b/src/Sitko.EditorJS/Blocks/ContentBlockAccessor.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.Options; + +namespace Sitko.EditorJS.Blocks; + +public class ContentBlockAccessor(IOptions options) : IContentBlockAccessor + where TBlock : ContentBlock + where TBlockOptions : class, IContentBlockOptions +{ + public Type Type => typeof(TBlock); + public IContentBlockOptions Options { get; } = options.Value; + public string Key { get; } = ContentBlocksRegistry.GetKey().Key; +} \ No newline at end of file diff --git a/src/Sitko.EditorJS/Blocks/ContentBlockAttribute.cs b/src/Sitko.EditorJS/Blocks/ContentBlockAttribute.cs new file mode 100644 index 0000000..e7bab81 --- /dev/null +++ b/src/Sitko.EditorJS/Blocks/ContentBlockAttribute.cs @@ -0,0 +1,7 @@ +namespace Sitko.EditorJS.Blocks; + +[AttributeUsage(AttributeTargets.Class)] +public class ContentBlockAttribute(string key) : Attribute +{ + public string Key { get; } = key; +} \ No newline at end of file diff --git a/src/Sitko.EditorJS/Blocks/ContentBlockConfig.cs b/src/Sitko.EditorJS/Blocks/ContentBlockConfig.cs new file mode 100644 index 0000000..802c9fd --- /dev/null +++ b/src/Sitko.EditorJS/Blocks/ContentBlockConfig.cs @@ -0,0 +1,3 @@ +namespace Sitko.EditorJS.Blocks; + +public record ContentBlockConfig; \ No newline at end of file diff --git a/src/Sitko.EditorJS/Blocks/ContentBlockData.cs b/src/Sitko.EditorJS/Blocks/ContentBlockData.cs new file mode 100644 index 0000000..5e6c78f --- /dev/null +++ b/src/Sitko.EditorJS/Blocks/ContentBlockData.cs @@ -0,0 +1,3 @@ +namespace Sitko.EditorJS.Blocks; + +public abstract record ContentBlockData; \ No newline at end of file diff --git a/src/Sitko.EditorJS/Blocks/ContentBlockOptions.cs b/src/Sitko.EditorJS/Blocks/ContentBlockOptions.cs new file mode 100644 index 0000000..555e725 --- /dev/null +++ b/src/Sitko.EditorJS/Blocks/ContentBlockOptions.cs @@ -0,0 +1,15 @@ +namespace Sitko.EditorJS.Blocks; + +public abstract record ContentBlockOptions : IContentBlockOptions + where TBlock : ContentBlock + where TConfig : ContentBlockConfig, new() +{ + public abstract string ScriptUrl { get; set; } + public abstract string ClassName { get; set; } + public EditorJSToolConfig GetConfig() => new() { ClassName = ClassName, Config = Config }; + + public TConfig Config { get; } = new(); +} + +public abstract record ContentBlockOptions : ContentBlockOptions + where TBlock : ContentBlock; diff --git a/src/Sitko.EditorJS/Blocks/ContentBlockRegistration.cs b/src/Sitko.EditorJS/Blocks/ContentBlockRegistration.cs new file mode 100644 index 0000000..c7d41ba --- /dev/null +++ b/src/Sitko.EditorJS/Blocks/ContentBlockRegistration.cs @@ -0,0 +1,3 @@ +namespace Sitko.EditorJS.Blocks; + +internal record ContentBlockRegistration(string Key, Type BlockType, Type OptionsType); \ No newline at end of file diff --git a/src/Sitko.EditorJS/Blocks/ContentBlocksRegistry.cs b/src/Sitko.EditorJS/Blocks/ContentBlocksRegistry.cs new file mode 100644 index 0000000..776a486 --- /dev/null +++ b/src/Sitko.EditorJS/Blocks/ContentBlocksRegistry.cs @@ -0,0 +1,48 @@ +namespace Sitko.EditorJS.Blocks; + +public static class ContentBlocksRegistry +{ + private static readonly Dictionary BlocksByKey = new(); + private static readonly Dictionary BlocksByType = new(); + + + public static IReadOnlyDictionary GetBlockTypes() => + BlocksByType.ToDictionary(block => block.Value.Key, block => block.Key); + + public static void Register() where TBlock : ContentBlock + where TBlockOptions : IContentBlockOptions + { + var attribute = typeof(TBlock).GetCustomAttributes(typeof(ContentBlockAttribute), false) + .OfType().FirstOrDefault() ?? + throw new InvalidOperationException( + $"Class {typeof(TBlock)} should have {typeof(ContentBlockAttribute)} attribute"); + if (BlocksByType.ContainsKey(typeof(TBlock))) + { + throw new InvalidOperationException($"Block {typeof(TBlock)} already registered"); + } + + if (BlocksByKey.TryGetValue(attribute.Key, out var blockRegistration)) + { + throw new InvalidOperationException( + $"Block with key {attribute.Key} already registered: {blockRegistration.BlockType}"); + } + + var registration = new ContentBlockRegistration(attribute.Key, typeof(TBlock), typeof(TBlockOptions)); + BlocksByType[typeof(TBlock)] = registration; + BlocksByKey[attribute.Key] = registration; + //PolymorphismOptions.DerivedTypes.Add(new JsonDerivedType(typeof(TBlock), attribute.Key)); + } + + internal static ContentBlockRegistration GetKey() where TBlock : ContentBlock => GetKey(typeof(TBlock)); + internal static ContentBlockRegistration GetKey(Type blockType) => BlocksByType[blockType]; + + internal static ContentBlockRegistration GetBlockMetadata(string key) => BlocksByKey[key]; + + internal static void Clear() + { + BlocksByType.Clear(); + BlocksByKey.Clear(); + } +} + +// TODO: In .NET 9 we can use this with https://github.com/dotnet/runtime/issues/72604 diff --git a/src/Sitko.EditorJS/Blocks/IBlocksAccessor.cs b/src/Sitko.EditorJS/Blocks/IBlocksAccessor.cs new file mode 100644 index 0000000..5cd0660 --- /dev/null +++ b/src/Sitko.EditorJS/Blocks/IBlocksAccessor.cs @@ -0,0 +1,8 @@ +namespace Sitko.EditorJS.Blocks; + +public interface IBlocksAccessor +{ + EditorJSConfig GetConfig(string holder); + IReadOnlyDictionary GetScripts(); + IReadOnlyDictionary GetBlockTypes(); +} \ No newline at end of file diff --git a/src/Sitko.EditorJS/Blocks/IContentBlock.cs b/src/Sitko.EditorJS/Blocks/IContentBlock.cs new file mode 100644 index 0000000..c13d0e7 --- /dev/null +++ b/src/Sitko.EditorJS/Blocks/IContentBlock.cs @@ -0,0 +1,6 @@ +namespace Sitko.EditorJS.Blocks; + +internal interface IContentBlock +{ + string Id { get; } +} \ No newline at end of file diff --git a/src/Sitko.EditorJS/Blocks/IContentBlockAccessor.cs b/src/Sitko.EditorJS/Blocks/IContentBlockAccessor.cs new file mode 100644 index 0000000..8fe5420 --- /dev/null +++ b/src/Sitko.EditorJS/Blocks/IContentBlockAccessor.cs @@ -0,0 +1,8 @@ +namespace Sitko.EditorJS.Blocks; + +internal interface IContentBlockAccessor +{ + public Type Type { get; } + public IContentBlockOptions Options { get; } + public string Key { get; } +} \ No newline at end of file diff --git a/src/Sitko.EditorJS/Blocks/IContentBlockOptions.cs b/src/Sitko.EditorJS/Blocks/IContentBlockOptions.cs new file mode 100644 index 0000000..25308b1 --- /dev/null +++ b/src/Sitko.EditorJS/Blocks/IContentBlockOptions.cs @@ -0,0 +1,15 @@ +namespace Sitko.EditorJS.Blocks; + +public interface IContentBlockOptions +{ + string ScriptUrl { get; } + EditorJSToolConfig GetConfig(); +} + +public interface IContentBlockOptions : IContentBlockOptions where TBlock : ContentBlock; + +public interface IContentBlockOptions : IContentBlockOptions + where TConfig : ContentBlockConfig, new() where TBlock : ContentBlock +{ + TConfig Config { get; } +} diff --git a/src/Sitko.EditorJS/Blocks/Paragraph/ParagraphBlock.cs b/src/Sitko.EditorJS/Blocks/Paragraph/ParagraphBlock.cs new file mode 100644 index 0000000..e601d8e --- /dev/null +++ b/src/Sitko.EditorJS/Blocks/Paragraph/ParagraphBlock.cs @@ -0,0 +1,4 @@ +namespace Sitko.EditorJS.Blocks.Paragraph; + +[ContentBlock("paragraph")] +public record ParagraphBlock : ContentBlock; diff --git a/src/Sitko.EditorJS/Blocks/Paragraph/ParagraphBlockConfig.cs b/src/Sitko.EditorJS/Blocks/Paragraph/ParagraphBlockConfig.cs new file mode 100644 index 0000000..2df28aa --- /dev/null +++ b/src/Sitko.EditorJS/Blocks/Paragraph/ParagraphBlockConfig.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; + +namespace Sitko.EditorJS.Blocks.Paragraph; + +public record ParagraphBlockConfig : ContentBlockConfig +{ + [JsonPropertyName("placeholder")] public string Placeholder { get; set; } = ""; + + [JsonPropertyName("preserveBlank")] public bool PreserveBlank { get; set; } = false; +} \ No newline at end of file diff --git a/src/Sitko.EditorJS/Blocks/Paragraph/ParagraphBlockData.cs b/src/Sitko.EditorJS/Blocks/Paragraph/ParagraphBlockData.cs new file mode 100644 index 0000000..cae1a0b --- /dev/null +++ b/src/Sitko.EditorJS/Blocks/Paragraph/ParagraphBlockData.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Serialization; + +namespace Sitko.EditorJS.Blocks.Paragraph; + +public record ParagraphBlockData : ContentBlockData +{ + [JsonPropertyName("text")] public string Text { get; set; } = ""; +} \ No newline at end of file diff --git a/src/Sitko.EditorJS/Blocks/Paragraph/ParagraphBlockOptions.cs b/src/Sitko.EditorJS/Blocks/Paragraph/ParagraphBlockOptions.cs new file mode 100644 index 0000000..da43fad --- /dev/null +++ b/src/Sitko.EditorJS/Blocks/Paragraph/ParagraphBlockOptions.cs @@ -0,0 +1,7 @@ +namespace Sitko.EditorJS.Blocks.Paragraph; + +public record ParagraphBlockOptions : ContentBlockOptions +{ + public override string ScriptUrl { get; set; } = "https://cdn.jsdelivr.net/npm/@editorjs/paragraph@latest"; + public override string ClassName { get; set; } = "Paragraph"; +} \ No newline at end of file diff --git a/src/Sitko.EditorJS/Blocks/SimpleImage/SimpleImageBlock.cs b/src/Sitko.EditorJS/Blocks/SimpleImage/SimpleImageBlock.cs new file mode 100644 index 0000000..4259597 --- /dev/null +++ b/src/Sitko.EditorJS/Blocks/SimpleImage/SimpleImageBlock.cs @@ -0,0 +1,4 @@ +namespace Sitko.EditorJS.Blocks.SimpleImage; + +[ContentBlock("image")] +public record SimpleImageBlock : ContentBlock; diff --git a/src/Sitko.EditorJS/Blocks/SimpleImage/SimpleImageBlockData.cs b/src/Sitko.EditorJS/Blocks/SimpleImage/SimpleImageBlockData.cs new file mode 100644 index 0000000..a35f7b8 --- /dev/null +++ b/src/Sitko.EditorJS/Blocks/SimpleImage/SimpleImageBlockData.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace Sitko.EditorJS.Blocks.SimpleImage; + +public record SimpleImageBlockData : ContentBlockData +{ + [JsonPropertyName("url")] public string Url { get; set; } = ""; + + [JsonPropertyName("caption")] public string Caption { get; set; } = ""; + + [JsonPropertyName("withBorder")] public bool? WithBorder { get; set; } + + [JsonPropertyName("withBackground")] public bool? WithBackground { get; set; } + + [JsonPropertyName("stretched")] public bool? Stretched { get; set; } +} \ No newline at end of file diff --git a/src/Sitko.EditorJS/Blocks/SimpleImage/SimpleImageBlockOptions.cs b/src/Sitko.EditorJS/Blocks/SimpleImage/SimpleImageBlockOptions.cs new file mode 100644 index 0000000..5a9953e --- /dev/null +++ b/src/Sitko.EditorJS/Blocks/SimpleImage/SimpleImageBlockOptions.cs @@ -0,0 +1,7 @@ +namespace Sitko.EditorJS.Blocks.SimpleImage; + +public record SimpleImageBlockOptions : ContentBlockOptions +{ + public override string ScriptUrl { get; set; } = "https://cdn.jsdelivr.net/npm/@editorjs/simple-image@latest"; + public override string ClassName { get; set; } = "SimpleImage"; +} \ No newline at end of file diff --git a/src/Sitko.EditorJS/Configuration/EditorJSBuilder.cs b/src/Sitko.EditorJS/Configuration/EditorJSBuilder.cs new file mode 100644 index 0000000..59dd231 --- /dev/null +++ b/src/Sitko.EditorJS/Configuration/EditorJSBuilder.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Scrutor; +using Sitko.Blazor.ScriptInjector; +using Sitko.EditorJS.Blocks; + +namespace Sitko.EditorJS.Configuration; + +internal class EditorJSBuilder : IEditorJSBuilder +{ + private readonly IServiceCollection serviceCollection; + + public EditorJSBuilder(IServiceCollection serviceCollection) + { + this.serviceCollection = serviceCollection; + serviceCollection.AddOptions(); + serviceCollection.AddScriptInjector(); + serviceCollection.AddSingleton(); + } + + public IEditorJSBuilder AddBlock(Action configure) + where TBlock : ContentBlock where TBlockOptions : class, IContentBlockOptions + { + serviceCollection.Scan(selector => + selector.FromType().AsSelfWithInterfaces().WithScopedLifetime()); + serviceCollection.AddOptions().PostConfigure((options, + configuration) => + { + configure(configuration, options); + }); + serviceCollection.AddSingleton>(); + ContentBlocksRegistry.Register(); + return this; + } +} \ No newline at end of file diff --git a/src/Sitko.EditorJS/Configuration/EditorJSOptions.cs b/src/Sitko.EditorJS/Configuration/EditorJSOptions.cs new file mode 100644 index 0000000..55f2a83 --- /dev/null +++ b/src/Sitko.EditorJS/Configuration/EditorJSOptions.cs @@ -0,0 +1,6 @@ +namespace Sitko.EditorJS.Configuration; + +public record EditorJSOptions +{ + public string EditorJSScriptUrl { get; set; } = "https://cdn.jsdelivr.net/npm/@editorjs/editorjs@latest"; +} diff --git a/src/Sitko.EditorJS/Configuration/IEditorJSBuilder.cs b/src/Sitko.EditorJS/Configuration/IEditorJSBuilder.cs new file mode 100644 index 0000000..dacd9ea --- /dev/null +++ b/src/Sitko.EditorJS/Configuration/IEditorJSBuilder.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.Configuration; +using Sitko.EditorJS.Blocks; + +namespace Sitko.EditorJS.Configuration; + +public interface IEditorJSBuilder +{ + IEditorJSBuilder AddBlock(Action configure) + where TBlock : ContentBlock where TBlockOptions : class, IContentBlockOptions; +} diff --git a/src/Sitko.EditorJS/Data/EditorJSData.cs b/src/Sitko.EditorJS/Data/EditorJSData.cs new file mode 100644 index 0000000..fac4a8d --- /dev/null +++ b/src/Sitko.EditorJS/Data/EditorJSData.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; +using Sitko.EditorJS.Blocks; +using Sitko.EditorJS.Helpers; + +namespace Sitko.EditorJS.Data; + +public record EditorJSData +{ + [JsonPropertyName("time")] public long Time { get; set; } + [JsonPropertyName("version")] public string Version { get; set; } = "unknown"; + [JsonPropertyName("blocks")] public ValueCollection Blocks { get; set; } = new(); +} \ No newline at end of file diff --git a/src/Sitko.EditorJS/EditorJS.razor b/src/Sitko.EditorJS/EditorJS.razor index 3350b9b..ebac1e1 100644 --- a/src/Sitko.EditorJS/EditorJS.razor +++ b/src/Sitko.EditorJS/EditorJS.razor @@ -1,8 +1,12 @@ -
+@using System.Text.Json +
-
-@Data
+@if (Data is not null)
+{
+    
+@JsonSerializer.Serialize(Data, PrettyPrintJsonOptions)
 
+} @code { diff --git a/src/Sitko.EditorJS/EditorJS.razor.cs b/src/Sitko.EditorJS/EditorJS.razor.cs index 1082707..7a2ffd2 100644 --- a/src/Sitko.EditorJS/EditorJS.razor.cs +++ b/src/Sitko.EditorJS/EditorJS.razor.cs @@ -1,19 +1,20 @@ using System.Text.Json; using System.Text.Json.Serialization; +using JetBrains.Annotations; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Options; using Microsoft.JSInterop; using Sitko.Blazor.ScriptInjector; +using Sitko.EditorJS.Blocks; +using Sitko.EditorJS.Blocks.Paragraph; +using Sitko.EditorJS.Configuration; +using Sitko.EditorJS.Data; namespace Sitko.EditorJS; public partial class EditorJS : ComponentBase, IAsyncDisposable { - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; + private static readonly JsonSerializerOptions PrettyPrintJsonOptions = new() { WriteIndented = true }; private DotNetObjectReference? instance; private bool rendered; @@ -21,9 +22,20 @@ public partial class EditorJS : ComponentBase, IAsyncDisposable [Inject] protected IBlocksAccessor BlocksAccessor { get; set; } = null!; [Inject] protected IOptions EditorJSOptions { get; set; } = null!; [Inject] protected IJSRuntime JsRuntime { get; set; } = null!; - protected ElementReference EditorRef { get; set; } [Parameter] public EditorJSConfig? Config { get; set; } - private string Data { get; set; } = ""; + + private EditorJSData? Data { get; set; } = new() + { + Time = DateTime.UtcNow.Ticks, + Version = "somever", + Blocks = + [ + new ParagraphBlock + { + Id = Guid.NewGuid().ToString(), Data = new ParagraphBlockData { Text = "Мой клёвый текст" } + } + ] + }; public Guid Id { get; } = Guid.NewGuid(); @@ -41,7 +53,6 @@ protected override async Task OnAfterRenderAsync(bool firstRender) { instance = DotNetObjectReference.Create(this); - var config = GetConfig(); var injectRequests = new List { ScriptInjectRequest.FromUrl("SitkoEditorJS", "_content/Sitko.EditorJS/EditorJS.razor.js", @@ -52,16 +63,6 @@ protected override async Task OnAfterRenderAsync(bool firstRender) { injectRequests.Add(ScriptInjectRequest.FromUrl($"editorjs-{key}", script, InjectScope.Scoped)); } - // if (!string.IsNullOrEmpty(OptionsProvider.Options.StylePath)) - // { - // injectRequests.Add(CssInjectRequest.FromUrl($"{OptionsProvider.Options.EditorClassName}Css", - // OptionsProvider.Options.StylePath)); - // } - - // foreach (var (key, path) in OptionsProvider.Options.GetAdditionalScripts(config)) - // { - // injectRequests.Add(ScriptInjectRequest.FromUrl(key, path)); - // } await ScriptInjector.InjectAsync(injectRequests, InitializeEditorAsync); } @@ -70,24 +71,23 @@ protected override async Task OnAfterRenderAsync(bool firstRender) private async Task InitializeEditorAsync(CancellationToken cancellationToken) { await JsRuntime.InvokeVoidAsync("window.SitkoEditorJS.init", cancellationToken, Id.ToString(), - JsonSerializer.Serialize(GetConfig(), JsonOptions), instance); + GetConfig(), instance, Data); rendered = true; } - private EditorJSConfig? GetConfig() => Config ?? BlocksAccessor.GetConfig(Id.ToString()); + private EditorJSConfig GetConfig() => Config ?? BlocksAccessor.GetConfig(Id.ToString()); - protected ValueTask DestroyEditor() + private ValueTask DestroyEditor() { rendered = false; return JsRuntime.InvokeVoidAsync("window.SitkoEditorJS.destroy", Id); } [JSInvokable] - public Task OnSave(string data) + public Task OnSave(EditorJSData data) { - using var jDoc = JsonDocument.Parse(data); - Data = JsonSerializer.Serialize(jDoc, new JsonSerializerOptions { WriteIndented = true }); + Data = data; StateHasChanged(); return Task.CompletedTask; } @@ -96,6 +96,7 @@ public Task OnSave(string data) // await JsRuntime.InvokeVoidAsync("window.SitkoBlazorCKEditor.update", Id, EditorValue); } +[PublicAPI] public record EditorJSConfig { [JsonPropertyName("holder")] public required string Holder { get; init; } @@ -103,6 +104,7 @@ public record EditorJSConfig [JsonPropertyName("tools")] public Dictionary Tools { get; } = new(); } +[PublicAPI] public record EditorJSToolConfig { [JsonPropertyName("className")] public required string ClassName { get; init; } diff --git a/src/Sitko.EditorJS/EditorJS.razor.js b/src/Sitko.EditorJS/EditorJS.razor.js index 581f7cb..0dca861 100644 --- a/src/Sitko.EditorJS/EditorJS.razor.js +++ b/src/Sitko.EditorJS/EditorJS.razor.js @@ -2,12 +2,12 @@ window.SitkoEditorJS = { editors: [], timeouts: [], - init: function (id, configJson, instance) { - const config = JSON.parse(configJson) ?? {}; + init: function (id, config, instance, data) { const editorConfig = { holder: config.holder, tools: {}, minHeight : 0, + data: data, onChange: (api, event) => { window.SitkoEditorJS.editors[id].save().then((outputData) => { if (window.SitkoEditorJS.timeouts[id]) { @@ -15,7 +15,7 @@ } window.SitkoEditorJS.timeouts[id] = setTimeout(function () { //console.debug(id, 'Update text'); - instance.invokeMethodAsync('OnSave', JSON.stringify(outputData)); + instance.invokeMethodAsync('OnSave', outputData); delete window.SitkoEditorJS.timeouts[id]; }, 50) }).catch((error) => { diff --git a/src/Sitko.EditorJS/Helpers/ValueCollection.cs b/src/Sitko.EditorJS/Helpers/ValueCollection.cs new file mode 100644 index 0000000..389aa95 --- /dev/null +++ b/src/Sitko.EditorJS/Helpers/ValueCollection.cs @@ -0,0 +1,63 @@ +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Globalization; + +namespace Sitko.EditorJS.Helpers; + +public sealed class ValueCollection : Collection, IEquatable>, IFormattable +{ + private readonly IEqualityComparer? equalityComparer; + + public ValueCollection() : this(new List()) { } + + public ValueCollection(IEqualityComparer? equalityComparer = null) : this(new List(), equalityComparer) + { + } + + public ValueCollection(IList list, IEqualityComparer? equalityComparer = null) : base(list) => + this.equalityComparer = equalityComparer ?? EqualityComparer.Default; + + public bool Equals(ValueCollection? other) + { + Debug.Assert(equalityComparer != null, "_equalityComparer != null"); + + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + if (Count != other.Count) + { + return false; + } + + using var enumerator1 = GetEnumerator(); + + while (enumerator1.MoveNext()) + { + if (other.All(arg => arg?.Equals(enumerator1.Current) != true)) + { + return false; + } + } + + return true; + } + + public string ToString(string? format, IFormatProvider? formatProvider) => $"[{typeof(T).Name}[{Count}]]"; + + public override bool Equals(object? obj) => + obj is { } && (ReferenceEquals(this, obj) || (obj is ValueCollection coll && Equals(coll))); + + public override int GetHashCode() => + unchecked(Items.Aggregate(0, + (current, element) => (current * 397) ^ (element is null ? 0 : equalityComparer!.GetHashCode(element)) + )); + + public override string ToString() => ToString(null, CultureInfo.CurrentCulture); +} \ No newline at end of file diff --git a/src/Sitko.EditorJS/Json/ContentBlockConverter.cs b/src/Sitko.EditorJS/Json/ContentBlockConverter.cs new file mode 100644 index 0000000..efada30 --- /dev/null +++ b/src/Sitko.EditorJS/Json/ContentBlockConverter.cs @@ -0,0 +1,81 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Sitko.EditorJS.Blocks; + +namespace Sitko.EditorJS.Json; + +public class ContentBlockConverter : JsonConverter +{ + private readonly JsonSerializerOptions jsonSerializerOptions; + private const string TypePropertyName = "type"; + + public ContentBlockConverter() + { + var polymorphismOptions = new JsonPolymorphismOptions() + { + TypeDiscriminatorPropertyName = TypePropertyName, + IgnoreUnrecognizedTypeDiscriminators = true, + UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization + }; + + foreach (var (key, type) in ContentBlocksRegistry.GetBlockTypes()) + { + polymorphismOptions.DerivedTypes.Add(new JsonDerivedType(type, key)); + } + + jsonSerializerOptions = new JsonSerializerOptions() + { + TypeInfoResolver = new ContentBlocksTypeResolver(polymorphismOptions) + }; + } + + public override ContentBlock? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var readerClone = reader; + var typePropertyFound = false; + while (readerClone.Read()) + { + if (readerClone.TokenType != JsonTokenType.PropertyName) + { + continue; + } + + var propertyName = readerClone.GetString(); + if (propertyName != TypePropertyName) + { + continue; + } + + typePropertyFound = true; + + break; + } + + ContentBlock? block = null; + if (typePropertyFound) + { + readerClone.Read(); + if (readerClone.TokenType != JsonTokenType.String) + { + throw new JsonException(); + } + + var key = readerClone.GetString(); + + if (!string.IsNullOrEmpty(key)) + { + var descriptor = ContentBlocksRegistry.GetBlockMetadata(key); + if (JsonSerializer.Deserialize(ref reader, descriptor.BlockType) is ContentBlock contentBlock) + { + block = contentBlock; + } + } + } + + return block; + } + + public override void Write(Utf8JsonWriter writer, ContentBlock value, JsonSerializerOptions options) => + JsonSerializer.Serialize(writer, value, typeof(IContentBlock), jsonSerializerOptions); +} \ No newline at end of file diff --git a/src/Sitko.EditorJS/Json/ContentBlocksTypeResolver.cs b/src/Sitko.EditorJS/Json/ContentBlocksTypeResolver.cs new file mode 100644 index 0000000..6817628 --- /dev/null +++ b/src/Sitko.EditorJS/Json/ContentBlocksTypeResolver.cs @@ -0,0 +1,20 @@ +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using Sitko.EditorJS.Blocks; + +namespace Sitko.EditorJS.Json; + +internal class ContentBlocksTypeResolver(JsonPolymorphismOptions polymorphismOptions) : DefaultJsonTypeInfoResolver +{ + public override JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options) + { + var jsonTypeInfo = base.GetTypeInfo(type, options); + + if (jsonTypeInfo.Type == typeof(IContentBlock)) + { + jsonTypeInfo.PolymorphismOptions = polymorphismOptions; + } + + return jsonTypeInfo; + } +} diff --git a/src/Sitko.EditorJS/ServiceCollectionExtensions.cs b/src/Sitko.EditorJS/ServiceCollectionExtensions.cs index a73c24e..71f13e3 100644 --- a/src/Sitko.EditorJS/ServiceCollectionExtensions.cs +++ b/src/Sitko.EditorJS/ServiceCollectionExtensions.cs @@ -1,13 +1,11 @@ -using System.Text.Json.Serialization; -using Microsoft.Extensions.Configuration; +using System.Runtime.CompilerServices; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Scrutor; -using Sitko.Blazor.ScriptInjector; +using Sitko.EditorJS.Configuration; +[assembly: InternalsVisibleTo("Sitko.EditorJS.Tests")] namespace Sitko.EditorJS; -public static class HostApplicationBuilderExtensions +public static class ServiceCollectionExtensions { public static IEditorJSBuilder AddEditorJS(this IServiceCollection serviceCollection) { @@ -15,167 +13,3 @@ public static IEditorJSBuilder AddEditorJS(this IServiceCollection serviceCollec return builder; } } - -public interface IEditorJSBuilder -{ - IEditorJSBuilder AddBlock(Action configure) - where TBlock : ContentBlock where TBlockOptions : class, IContentBlockOptions; -} - -internal class EditorJSBuilder : IEditorJSBuilder -{ - private readonly IServiceCollection serviceCollection; - - public EditorJSBuilder(IServiceCollection serviceCollection) - { - this.serviceCollection = serviceCollection; - serviceCollection.AddOptions(); - serviceCollection.AddScriptInjector(); - serviceCollection.AddSingleton(); - } - - public IEditorJSBuilder AddBlock(Action configure) - where TBlock : ContentBlock where TBlockOptions : class, IContentBlockOptions - { - serviceCollection.Scan(selector => - selector.FromType().AsSelfWithInterfaces().WithScopedLifetime()); - serviceCollection.AddOptions().PostConfigure((options, - configuration) => - { - configure(configuration, options); - }); - serviceCollection.AddSingleton>(); - return this; - } -} - -public interface IBlocksAccessor -{ - EditorJSConfig GetConfig(string holder); - IReadOnlyCollection<(string Key, string ScriptUrl)> GetScripts(); -} - -internal class BlocksAccessor : IBlocksAccessor -{ - private readonly List blocks; - private readonly List blockOptionsAccessors; - - public BlocksAccessor(IEnumerable blocks, IEnumerable blockOptionsAccessors) - { - this.blocks = blocks.ToList(); - this.blockOptionsAccessors = blockOptionsAccessors.ToList(); - } - - public EditorJSConfig GetConfig(string holder) - { - var config = new EditorJSConfig { Holder = holder }; - foreach (var blockOptionsAccessor in blockOptionsAccessors) - { - config.Tools[blockOptionsAccessor.Current.Type] = blockOptionsAccessor.Current.GetConfig(); - } - - return config; - } - - public IReadOnlyCollection<(string Key, string ScriptUrl)> GetScripts() => blockOptionsAccessors - .Select(accessor => (accessor.Current.Type, accessor.Current.ScriptUrl)).ToArray(); -} - -public interface IBlockOptionsAccessor -{ - IContentBlockOptions Current { get; } -} - -public class BlockOptionsAccessor : IBlockOptionsAccessor where TOptions : class, IContentBlockOptions -{ - private readonly IOptions options; - - public BlockOptionsAccessor(IOptions options) => this.options = options; - - public IContentBlockOptions Current => options.Value; -} - -public interface IContentBlockOptions -{ - string ScriptUrl { get; } - string Type { get; } - EditorJSToolConfig GetConfig(); -} - -public interface IContentBlockOptions : IContentBlockOptions where TBlock : ContentBlock -{ -} - -public interface IContentBlockOptions : IContentBlockOptions - where TConfig : ContentBlockConfig, new() where TBlock : ContentBlock -{ - TConfig Config { get; } -} - -public record ContentBlockConfig -{ -} - -public abstract record ContentBlockOptions : IContentBlockOptions - where TBlock : ContentBlock - where TConfig : ContentBlockConfig, new() -{ - public abstract string ScriptUrl { get; set; } - public abstract string Type { get; set; } - public abstract string ClassName { get; set; } - public EditorJSToolConfig GetConfig() => new() { ClassName = ClassName, Config = Config }; - - public TConfig Config { get; } = new(); -} - -public abstract record ContentBlockOptions : ContentBlockOptions - where TBlock : ContentBlock -{ -} - -public record EditorJSOptions -{ - public string EditorJSScriptUrl { get; set; } = "https://cdn.jsdelivr.net/npm/@editorjs/editorjs@latest"; -} - -public interface IEditorJSTool -{ -} - -public interface IEditorJSTool : IEditorJSTool where TBlock : ContentBlock -{ -} - -public abstract record ContentBlock -{ - public Guid Id { get; set; } = Guid.NewGuid(); -} - -public record SimpleImageBlock : ContentBlock -{ -} - -public record SimpleImageBlockOptions : ContentBlockOptions -{ - public override string ScriptUrl { get; set; } = "https://cdn.jsdelivr.net/npm/@editorjs/simple-image@latest"; - public override string Type { get; set; } = "image"; - public override string ClassName { get; set; } = "SimpleImage"; -} - -public record ParagraphBlock : ContentBlock -{ -} - -public record ParagraphBlockOptions : ContentBlockOptions -{ - public override string ScriptUrl { get; set; } = "https://cdn.jsdelivr.net/npm/@editorjs/paragraph@latest"; - public override string Type { get; set; } = "paragraph"; - public override string ClassName { get; set; } = "Paragraph"; -} - -public record ParagraphBlockConfig : ContentBlockConfig -{ - [JsonPropertyName("placeholder")] public string Placeholder { get; set; } = ""; - - [JsonPropertyName("preserveBlank")] public bool PreserveBlank { get; set; } = false; -} diff --git a/tests/Sitko.EditorJS.Tests/JsonTests.cs b/tests/Sitko.EditorJS.Tests/JsonTests.cs new file mode 100644 index 0000000..3077115 --- /dev/null +++ b/tests/Sitko.EditorJS.Tests/JsonTests.cs @@ -0,0 +1,110 @@ +using System.Text.Json; +using FluentAssertions; +using Sitko.EditorJS.Blocks; +using Sitko.EditorJS.Blocks.Paragraph; +using Sitko.EditorJS.Blocks.SimpleImage; +using Sitko.EditorJS.Data; + +namespace Sitko.EditorJS.Tests; + +[CollectionDefinition("Json", DisableParallelization = true)] +public class JsonTests +{ + [Fact] + public void Deserialize() + { + ContentBlocksRegistry.Clear(); + var json = """ + { + "time": 1712168519971, + "blocks": [ + { + "id": "oJJ-PIRzzz", + "type": "paragraph", + "data": { + "text": "\u0412\u0430\u0443, \u0442\u0443\u0442 \u043C\u043E\u0436\u043D\u043E \u043F\u0438\u0441\u0430\u0442\u044C\u003Cbr\u003E" + } + }, + { + "id": "2tsihegW-Y", + "type": "paragraph", + "data": { + "text": "\u0418 \u0442\u0443\u0442\u003Cbr\u003E" + } + }, + { + "id": "Aqta7JFSeu", + "type": "paragraph", + "data": { + "text": "\u003Cb\u003E\u0418 \u0441\u0442\u0438\u043B\u0438 \u043A\u0430\u043A\u0438\u0435-\u0442\u043E\u003C/b\u003E\u003Cbr\u003E" + } + }, + { + "id": "zuU72THimY", + "type": "image", + "data": { + "url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAoHCBYWFRgVFhYYGBgaGhoeHBwaGhweGhwZHBwaGh4aHBgeIS4lHB4rIRgcJzgmKy8xNTU1GiQ7QDszPy40NTEBDAwMEA8QHxISHzQrJSs0NDQ0NDQ0NDQ0NDQ0PTQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NP/AABEIAMIBAwMBIgACEQEDEQH/xAAcAAABBQEBAQAAAAAAAAAAAAAFAAIDBAYHAQj/xAA6EAACAQIEAwYEBQMFAAMBAAABAhEAAwQSITEFQVEGImFxgZETMqGxQlLB0fAHYnIUIzOS4UNTohf/xAAZAQADAQEBAAAAAAAAAAAAAAAAAQIDBAX/xAAnEQADAQADAQABAwMFAAAAAAAAAQIRAyExEkETMlEEYcEUIjNCof/aAAwDAQACEQMRAD8AK8WtIjhRmbYBmGXQ7ED8poFdZpmCAD9ennRWzezEzudp1gD\u002BfSocZdzsCdZ3gQJ2kj2rn1bpoh9virBfhBu63KO7J/EfGtBwlEe4oUaZFDjaSPmVeoMDzFZdcOFZtZAbw8YIFEOHYwqZiTr5aiJ86c1jE0dGweFQIuVREcxrEzBrPdpeFKoV17okhvXb9qL8Bx63LYH4lkEfWapdo8XmBRDMKc4gEQQIJ8Nd\u002BVaV5pK/gxaprHM6Uy9hj3hzOkc6LcAsM7uFUN3Cpndc2mYc5BrzjToxD6ZyAGA1WeZJ0IMjbxqPnrSt7wzbYFx\u002BGr3DrzWzrpoQfKmrmGzkz7AdKctxgZ0PnWbZRaGLBBJzZ9g2wy859NhpTzeCOGAMHLImQzDmR7mhq4kFupqdcRB1HdnXn51LGHuDOik51BzDQ6EjvaiPyxH1qlxSxDZlGQNIyzJAAEEkcjMwKrWr/fDqDkXUHlqDA6ctRUOJx/fQLMRrI\u002BYnc\u002BFIYSTFXUTKsjvaNyCEAFT4TBpiOS2Zy7LtI7pBHOI\u002BUVErvlEnTcT9JqfhuEFxnBeGykgE7\u002BQppNi8Fj/h51\u002BCM0LHeJHeMxG2oolwvE5yZXKwRQw6sJ1qqvDWCZnVguaIiNtd94O08qm4UBnYKSVVQNeuuoMCV0\u002BhqvnH2VL7Q/jGFFxGXwoH2duBEydDHtWrZZrLYvD/AArpA0VtvOp3DrxUFcRc9uZ/bqaG3TrtH\u002BZA/wDyNat20Jgx69KpPi0zlBBPWhsaSXSFB8/SvKbisWyiAJMelA7/ABG7sYUdQNqSWhT\u002BV2abs9pduDqu48D/AO1c4lhhOYVkuz14piEY31fNoymQdfPetrj0MaU6WCmvrsEvJU1d4HxEW370hW39Ovh5dKq3FMRVS5QhckqkdCOISA\u002BaQQII2g7eVCHxJt4lu4zlx3WGizA0J9KC4bijIihWj80zqRzozgOOgxnZSI3EyI6g9aapo4XOBvCllQZzJ3JPLwnn51JeuGO6JPnHrNVP9VbdCS6hWBGpHlTMKiIshpEfNIPiNqHdYJI9\u002BDd/\u002BwDwA2pVTu8YEmIpVnhp8V/BgGuBMqP8w3y67/8AlRm8paATEncQdNpnbrQ/EXGczAnnAifGKdYvKFLHVwRCkEggzPkf/K3UkssrjO9rJE79B6VaTFrvt9KDG90HvT0eRy8qbkRp8LxFkMqxHWDBI8xrUX\u002BsBJZzM7yx18zzoF8Q7E1C9wzrtzpY/Aw0Fx3WXR2EyCRpKnkSDrVcsTGYgR40Ow2LyyN9CRI/FyqB3djqQZ8vaKMfgwu95QcpOw9PDzqxheHreBZr6WlXctMnyG1ZtYQZn0A0A6n7xTg7M3eOsdNI8ByqplJg/Ag\u002BFVLndcOoO\u002Bo9fKraLOhZQNtafgeA33QOmVVOxY6n0ipG7NPHeuD2NNzL7Gt/gV5yqFdGOYnMG3BEbfrXtvAnOVDK5iQVmNpO8bfpQ2/wi4sw4Poafw\u002B\u002B9p0\u002BKoZNASu6j8w8RUOVnQfNL1BjGYZlJ0CqANyJEnXUb60U7IXz8VkCmBqddNYGaTz1OlT8WwVvIXDhkCzOpgFZBMcjG/jWUTEMrBkJXUNAaNRqKiHj0Vdo0/aTjhZjZUkKGIfk2UDbyJ8tKbw52DQVynIszzMn9CKzHEuIqbhcLqwGadjvJ8zRXs67kwxkSzARsTG557VdPvRz00aO4aFdoLGa3nG6waLOKhvICpB6RFZs757Rz9uKXnUkuwTYRzincPD6nKfY/epMJhwl90dC6bqAYifH1NGbKfgUEL0Jk7zqTvVVS8ImXpKcKWtho86q4WyiMWZA4Igg/vyo5hhKFfOhT3srQ2lZ\u002BGrX0C8ZhF/AuXvZpklgfAnlWws3A9tT1AoMGWNhFN4RxIZ2tnQjUDl5A0dsXypLV9YNUryb1ev6marX00Bpg10DcQ8Ltt4da0XBeFFrIuERpK67j\u002B4bcqz9xwCGIDAHUHYirWL7SFk\u002BHblNNQCIgcgav5bOO0tHWsRDasAAROg2EmCB7UTPaa1cb4Q7j8tO6x0AAP6GsN/rMzFRLGPQbanrVTDnOxgA6\u002BMjpEHernhfrMftJnRfiP8AkQ0qD2\u002BM31ABRGIGpbc\u002BdKj9Gjo/1PGZ5EGrHQabbA1VdIPSdp6VfslA0ssgESNdfDwNQcQdmYM2xGnkPtRJiyqqHlrT0EamnW1102ivStNsEj0W/wCa074JbbX96VoMZ02/m9SM0HQ\u002B3WkIrJZaSY2PuegqyLYADD18OteWrkZgDEjpUloqJD/L05k9KPWI8w2EVwb1wwqyEXrr0qLBobl51kfh9BUD4qSANulMwGOWzirdxzCEw/SBXR84tRH1vR134YRFWICqBVC93jP0pcU4l8WRYBYhVYlVY5QRpm6acqA2sXiFZS7dwn8nLzFc78OqHuIuYq3QfFJM0YxHFLcbqdY8Z6R18KzmOe83eCQsmND16mon015MUnuBxzgNYznTb\u002B5SZVT5En0im3gwJLQBuIEGqWGYtek93uAeZkk/TWimIGcZmY6DYRrH35U66ZysGuhjPErmidPA6jeNd6J8GxQF5AAVE\u002BWtU7mqzMCee23NR4VDhMQvxVZWzDMBJ3Inx50egvTpmIWKqu8CiuLQFAw8KDXrc86ivTs4a\u002BpM7j3VLwY8/wCfrV83hlGXT\u002Bdaz3aoMCuSTlOsV7wvH90A9KTl5poqX1hqMA4EzVHiWQmcwB8aYlguRLmOgqyMCi6xP1\u002B9IpsHoe7O4HOs/jMWUcOuhBFaDiWKCoQKxuLuZjB1/etOOezO666Oh8KxqXUDA\u002BlXXthtIiuc8Gxb227vy8x\u002B3vW6w7l1Dq0g0qn5YTX0irjsLlnmKyvFE\u002BEDlnM\u002B56L0HnW1vXAwj\u002BTQDi2DzqVjXl4eNXxVjMeeNWgNEyYYv\u002BK40TzyjSPU1b7KIA1y6w7ltZJO08hVTtDdGdLS/LbRV05mASferDn4eAVdmvXGJ6lBAHpvXVunntYB8TiGd2eT3iTvSqGlTwZtLGBLkKiMzkDNGus7\u002BA1G9Q43AMoCuII0iQTHpV/gjw93OSO5KhTGbXYn8uoNMawWYcwY/aJrkxmulTDYVVQMxMtIAA1nlB2PX0NPs2xJWAORJGin9/CtLgODuwHdhklhm5gz3Z5AxoPM1U7SvZw7gQ0k5nWREmDA9DE01LYnRBbwttM6HKykBs0kabjKoME\u002BYrLYnEorEKZHIka1LxDjCkHIsA9Kyt68ZNaKBfQXfiABgdamxl0zAgAfyaDYe07wQJ2k8oom8Sw5gn2rRSkTTYxTTsagOQxMMPbSoHflTxLoV6U/wJenVcNhwiFlUxcCMSvNlUqcw8o9qELgMplc7QSQuQzvMbQBVjsjxgXcMqkjOndI56c6IYi66ywAPQTFcdPGejEJrQDj\u002BDi2EvafGz/EJ5ZjuvlGk9daG4uyWkozkGdCNiYJ9aNcU4rKAFBm89Pc8qFYTFBRLwAeQ/Sj6YOFvZQxDBFTMIOeA39zAAae49au4dgzZWbI3eGWCW0H5Y50G7V3w1s5SPmXnB35Vd7J3Q1h7xzO6uEIBGzcz\u002BLUaTO81bX\u002B36OaklWBHE8KufDRyhUPoBu0\u002BPQedV04OyNmIzag/t9qL4jFMjATKggpMwV3IYczrHpVa5xA3MTbkd0MMvXcbnnsKzTYJadBx5hFU76aeQrMYzEGYHWi3FbpnflQVNSWO/3NKnrOvhn5kz2IfvsCd\u002BRih13Dgag\u002BlR8ZuMtx9wJMVTsYsg6npXTPGvk5L5mrYVw3EHQ6mRRYcYDDQEnagdpg45SPtRPA4Bbqlc2VxsSYB8z1rGpw6eO9QOxN0u3eYdIGtUsfg8mh09Oo28K01rhCIc128kL\u002BC33nPhQXtLiFYlgMo/mlOX3iHWYCEvhFJO528P5p7VpOxXFM\u002Ba0513XxHOsJfulj4VZ4XjDauK43U/TnWtRqOZcuVqOuMo6UNxFqiOEuh0V1MgqDUeITSubw7fUY3jfDSwLKNR9RVbtHeB\u002BEimVt21XwzRJ\u002BtafEWtxyrH8cwzI\u002BaO6foeldMXqw4eaMeg6aVQ0q17Oc6XwXAF8Qts7AtJB/CNwD5VtrnALZyLJCrOkCTPVok6xWS7K4lVvlnIHdYjnmJI0Xx1JjwrYYjj9lEZyT3dxznp51lKWGlejMbeTC2zdYlmAjVvm8ANgPKuUdq\u002BLNiGNwgLIAgbaVreJvfv4Z8TcOVC021iYQ7H251zvG3J0rScIaKDuadw\u002B2ruoYaE61FcWpuDvF1Z60DWGvGDA2URyigOPQrdJHyv7ZhrWtsMCKo8TiIyacuUGslTT7NnOpoytw0sNchvPT3qyeHOVZp1HKP1oeDOo3rVVplU4FOFY02LxE6N95rb2MU76AZgRIM6\u002BUVzXiTNOedQfpy\u002Blabs7xEOgUtBAmsOaP\u002Bx08F9fIcxdhte4w8SNKz2IQhtToOQ6\u002BdFMfiH2NxorP8RxEI7bwvXcnT71nK015HnaAvGcTncAHRRHrzqXgmNa1mInvRz6c6CWtSOpMe9bjAJbsShXOWVTr11DfpXWp6w4KrvSJOK3XIXRjO5EmPOtV2d4W73VZ1y97ujeFHXxrH27n\u002B8WAgHltAro3YJs7uxk5FAE9WP7KaKlZ4SqehDi3zkeFDvlUnpNX\u002BJNLmhuNaEauJ\u002Bnqz\u002B1HP\u002BI8TZ3KvqATHhE0LdopuMfvsejH70xnmu1eI8y/3MlS\u002BRsxohhOMMm\u002Bvif1oQFmkykUnKfoTdT2jQXe0KwQI9Fj60Cx2OZ94HlURXSkLXhSmJk0fNVLGyAmpLa04IKdVmLZt\u002BxHFZU2GO2q\u002BVap0kRXJuHYw2riuDsdR1FdWwt0OiuDIYA\u002B9cvLOPTv4L\u002Bpx/grXEmhHEsKHUqaO3hrVHEJUS8Zrc/SMHc4Y8nSaVbPIOle1v8AZy/pIscBwCPnLsFXuAMfmVjJJUc9FHvQ3iHF3dbeHYgqHzFvxMSYk9d96rpcYsU66yeoH2ohwrhttsSFuEN/to6KCRrJlT1Ige9RNYQ51mx49YzWMuYKoSAOQIG9cZxAhiJ29q7Rj7a3AqfhYAdeRM\u002BNck7Q4bJdZeY38\u002BtXFd4Fw0kwPlLNA3O1H\u002BH8JUETM7\u002BFALNwq4cbitnhySgIUSR1\u002BtHJTXhXFMv0tWSRoeVMfFAtDD2NVTfZdCdZ186n4fwe9clwhyfmOi\u002B5qFrNH16RY5xlJRimmp30rIY1GR2U6GdR9f1rofFsOmDWwGAc3s\u002BeR\u002BBQIVPyklpnfuisVxW4puhfnRhKMfmH9pO\u002BkVtMv05rpPpFO64YA8iI9qisMyNKmIqa/hNIUxrOvLlE9KOdjUwtx/gYpGDMwCOGIhj\u002BFvA8jVNExWdg0cRc/Nr6VHxi4fg\u002BLEe29dWvf08wx\u002BV3X/qf0r3/APn2FKFHLP0JgZT1Ee1Qp7NH/UL5aOEWZFbDs/ikdybssAjFRMd/QAEjl\u002B1RdteybYG4sHNaf5WjUEbo3jznmKCWWKwB0n61oY7q02nEERX7qRtPeB1iYkCtz2Ds5bD3fzt9EEb9JJrnWmQEDvbnz3rq3CbPwsFbXmUBPm2v61NPJKUp0kgbjDLTQrilyEPl\u002BlEsQ0saEcVPcby/SuM9NeHLsae\u002B3\u002BR\u002B9RokV7i27xPifvTEueNdqPMrstW2iiCXLRAzJQhnqVLmlMkv4kWvwSKqFxUJNKgD1mmmk013imO00AeLvXQOxPEMyG0T3l2/xrn6DWivBsZ8K8j8uY6g6VFz9Sa8VfNI6hcSdZqBhNJLkjQ6Gva4z0ioyDpSq3SoFiAt1ALpbQAqeWxpnEsKbiI1t5dUlWJgq6ics\u002B8VrV7LXSDmZJKgRJ5Rzj0oVjcEcBDG8rTBFvLJPXfSPE1tM1/BxVc\u002B6H7\u002BJK2jcClmW2rZR80sFkeYzH2rnfF7BdcxUjXfpM6E1Yx/bnEsGRMiKdCcoLEeZ0\u002BlZzF8UuuuV3ZhMwdp8hWi437pU/1PzLWbpDasqXAmR9K1yNlQAnXlWIR4NbXszxBLjZnWfhIWyxMkQFOviZ9KdTrRlPIlrNZwDsorqt2\u002BDqZVOo5ZvPp0oz2g/wCMWkAUFkWTooE7AftQLDcYZbYYs7OQzM867d1Qp038Kx\u002BL4y91/wDcdpAgDkBzgDbarUYY1boJf1ZxSs\u002BGyMDlFwEjxy9OWlc5x5PdI2BBFHOJpIQMc3eMGeRymhWJtyGXodKrMEmWHfMsSQG1BB\u002BnlTUJAVgYzd0kcnQ5kb7j1rzAqWt5ea7U/DpJKHTPGQ7Q41GvQ6j1pMDs/Y/tKuLtwe7eQAOpO/LOvUH6UUxmJZHBMFCPUGdfpXCBiHsut1CysDM6gq4OoPhzjxrrXZ7tNax1sqe5dESPE6Ar1EnUUIzuXmot9reDrjcOUUgsveQ/3AGB67Vwm9bKPlIKkCCOYZTBB8f3ruGJvXLM5BDfKM3y94aNp0JH1rl/anDubxuMpzkjOYAGfkRGhBjceFNoOKtXZbwaZ2RBrmZR7xrXVuK3AoAGyiPsP0rmXY3DFsSh/IM09OlbzGX8361jyv8AB2cE69KjUJ4oe63rRO62lCsfqrDwNczO/OjmeLTVgd5P3qjm1ir/ABH/AJG8zVJ1rtXh5jWNjwaerxUCmpRTET17TLe1PiaCSO5TApqyluKtWbA33nrRow32S7GvilN1nVLYMTEux8BsB40U4twGxhmGQEmR3mgn9hW27P2Bbw1pAIAUH1IrJduMWiNmcwZ0UfMSJ2H1muBctXyOV4dEypWss4K5IirdYfhHaFnvBSAqnbrPia2SXaqpc\u002BnTxWqQ\u002BaVNpVB04dJmuH9pMe1\u002B/cdiYLEKOiAkKP51rsfE8SEtu06hGIHpXDsS8616EnhsE3jE1Xd6s4gb\u002BdUrtN\u002BjQviUY7NOfjgDmjgjqMpMH1AoFNaPsQim\u002B7N\u002BG1cI8yMo\u002B5oBroPrdIBHI0E4vaghhuPrRhljQ0P4qJEdR9aZnPoLs4jOondDoPMR9xVfEWzr4mqqX8j6z0PQjrRLEXABBGv09/5vS/BfgzhizoN6s4vAGCf5NVeCcS\u002BDdDrEidCJHSiV/HveZ2gS2pMQB5CmhP0psS6AvyORjzBglG8dARNLhGKOHuy22qtpMZhAcAcxMgivM2QlmJKN3XHPLMhl6FTB9xzpuMsx3DGbTK3JgdQAehGo8dKRR2K0FxGERwfiiMwY6FhPeDAbHeRFZbtlggFXKuRdDlgwI17v7eNL\u002BlHEV/3MOxOb5lBOhUaEBeo5npRbtgwKCyDJDaaakmdPLlTTMGsoo9jcLlR7hGrGAf7Rv5a0UuNqafhrQSyEHhPnzqteeK5OWtZ6vBOIjvPOnSht/UGrV1oFVXaKwZ0nOeMpluuPGf1qkdaJdoli6xHOhStXfH7UeZf7meMIp6Nyry6NKgtNFMk3nY/s0mJR2Z1kaKonunx9xQXiODW07Ir58u7RAnoAai4V2gvYdSlpgmYyTEkyIiTsKZdxjXGzuZY7mslNfTbfQ21gxGmrlm4qlWYSFIMdfCqaAzTnEGKsk1WN7YYl0hHFpAIAtgAxtqxBJ9IrC8QvMzFnZmbqTJ9zRgrCedLD9nL905ihRPzOCJ/xG5NSlE/2GvpgPCXCCGB1Brp3C8WLltWBB0rJ8V4Uli0QoltJY7nUewqbsnjYJQny86m8qdRtxV812bPNXlMzUq5Tv00XajGOlxk/C9sRMx\u002BINB9R9K5ffWCfOuy4HHYfG2g6hXHNT8yk8iNx\u002Btc37Y8NFnEFV\u002BVhmHhJOk16Enjtrwyl5IqlcSid62TSHCbjalSvSdPpQwXYCdSDWn7GYVyXePwhR03k6\u002BFNwXZ0u/faEGpjc\u002BFa20Vt22CKAFgDw3oTW4U08YPvCGPmaG45tdtCKvk0J4oSNtx9qoyQB4xh/wAS7Vc4TfDpB1KaHxHI1WW/mlWpnDj8O8AflcR59KW9mmdE9\u002BzkeIosj91WPl6GN/v6VU4rb1Vhzq1lOQHkaaEyK9bkEGqmAuh1/wBM5AdJCSYDLubebqDqvtVpbs6H36x/5QTiloqwdf4aKBfwFMFjnsXluJpdtmYIIzqJkEDWYkMPWuhYa6cTet3GAgrn0nL10nx3rm15zdtLdBh0gNG\u002BnyOPsfIV0LsKxOHLt/cB4Axp7k\u002B9TTxDU6w7fckmqV9tasO3OqT8/WuGnrPU45xEF56q3j3TUpqviW7sVHrNDEdoh/uHyoG7RR/tF848qz\u002BI3rvj9qPM5P3MsDaoLlszMafzWprT1efDhkDc4qiAWpjepUc0zJG/jUtvlQBdt3NK1fYrgKYlme9mKKPlUwS3ielZCK6r2CVbeFzuyoGMyzKo59TWH9RTmG59HxpN9mgwvCbFsAJZRY55ZP8A2Mmq/G8PmUGPD9qhx/anCWgS15XP5bffM9JGg9a512l7aXsQSiE2rfIKe\u002BfFnH2FcXFxcl19P/06KqUsPO1uIthTbzy8iVXWPBjy8qzGFvFGBB1BmoXMmvK9KZ\u002BVhg616dAwvGAUU6bdaVYhbxHM0qj9I0/VL/BuOXcM/wAS02VtjzBHRhzFGcd2lfFnPcVAVEdwEA\u002BJknXSsca9DmMo2NbGDR0fs3YRlLwCauY3DktO01kOyWPa0xVvlbat3ehlkVhbaemsrwGtbIEA15ni2wPMjf61M6xvVRkzoWk6E\u002B0Cjje0VyylJBmHWh2N1\u002BtWShXnv/P1qni21roOVLEZ/H28rhhpNR4lsyAjdTPpRHFAMCKE2W5HypFI0buHQdCAR5kVHaukoyncbVX4ZczWgOaGB5TpXivqfGjQGi546ztSxaFkMgae9ORjsKvWrGRGc6tHOgAVwO6IK7jUMDzB3H86V0jskQuHyA829QTpNcw4U8OR4mtpwDGZCQToT96i03PRfG0q7NaTMiqzmJqW1ybr9aiv6TFcR6iKVU8S3896ttVDEsAJ5b\u002BlTK7FXhk\u002B0b98DwoDcWTV3iN/Pcdup08ht9KgjSvQlYkjzae02MXSp7eJYCJqClTJHMZ20pttqciU070AWkeane8xABJIGwJJA8hyqmrc6mVpFAu/wPd6rsZqYioDQCIiKeq06lQMVKlSoA8ZPCvAtdouf0xw5Ol26P8Aqf0plr\u002Bl1gHvXbjDoAo\u002BtL6A5Vhj3RWt4JxYFMrcq97dcCw\u002BFa3bshsxEtmM93YfWsbfulAIMVNSqQ5rGbjH478u0VT4NjMyupBIzRpykdKyycRbLEz51ouxNwF7gPRT9Tt40RGMfLeyPxtzK0a6a\u002Bn8\u002B1VnWasY8M7s0TqdqrK2sE1sYr\u002B4FvsVciKHYzumRzo7xiwdDQDENpFSUi7wO58y9RV901ignDXh6OPrBFLQZJhretXLrSj\u002BVQ2enWvce4W0390\u002B2xqs6FpncAf9wf5GtHnI2rM4Md/1mtG21JeMH0zW8Dxudcu7DaavXm3FYvh\u002BNNtp5Vp7OKDiedcfJOM9Dg5PqcfozEvArNdocblXIDqdT4CjvFMcLSFjvsPOuf4m6XYsTJNHDO9sfPyZPyiud6ks6nwrzKVbKQQSAfQiR7jWn211rrOAhca15T741pgFAydBrUF7Q1atLVbFLqPOgD1KtAVUQ1KDQInqNwBrFIPT6BpdDGTpXmQ1LUTqZoJQylU80qB6fTtKlSqBnLf6nWIxCP8AmQD2J/euacRGtdK/qTj0uXlRGBNsFWjkxMx9K5pj2lqv8Er0rpRvsrigmIUEwHBX6T\u002BlBRUmGuZbiN0Yft\u002BtMbWm6xoyEgnnvQR3E7irWd7iQTopIHjQnE2WXcU9ISJcfigyhQZigOIXerjsKrYl0y7manSkQ4D5wJ3286OgMPmG3jWaBjUVoMPeNxVgTyJ8aaBl3DGYpnHXhcg5D/2rWETKQPOh3G0IYnkabEvQbgF71aFUkehoBgz3q0VvY\u002BX60SKvSmNZqNMY6bEx0qd\u002B7Hiar49NCRrpMeP8FKp6KmselfHYpn\u002BYkjkOk0R7N9nmvuGfu2xqSQZbwA5zWn7Jdj7eItpfdwUbXLz0MEGt0uCt2SCkGIhTqI2gdNppTKXRHJys4z23sZMUWgAFbZUDbIFCiP8AqaFW9TpzroX9Q\u002BEvfYYhFnIpDhR\u002BHkR1iNfOud4RQGNN\u002BjilSQ3EDWo0FOxDSa9tDakWWrS6VUxI2q\u002BBVXFJzpICsnKpaiUVIKYz0GpEYzUYr0igCelUdqpKCTzIKVe0qAPpym3Nj5GlSqBnBMaZe4TqTcM\u002BOprO4v5v50pUq0ZKIqY24pUqRZtMN/wer/pQ7E/IfMfavaVUvDP8gLG7GhlKlUloVHeBfKf8j9qVKheirwO2Pw\u002BbfaqHHth6UqVUxIEYT5q0uF5\u002BQ\u002B4pUqF4D9KeI5edRYjY\u002BX70qVDEja/05Y/6M6//ACN9lrS3flPp96VKrnw5b/5Cbh\u002BoAOoKPIOs6Nv1rnf9QMJbS8MiIn\u002BKgcz0pUqivTXi/wAmHvb1Ph9/alSqTo/BcWq2K2pUqBFNN6lpUqCj1NxT7te0qBfk8tVJSpUCYqVKlQB//9k=", + "caption": "\u0418 \u041A\u0410\u0420\u0422\u0418\u041D\u041A\u0418\u003Cbr\u003E", + "withBorder": false, + "withBackground": false, + "stretched": true + } + }, + { + "id": "Hgq37g4Gh_", + "type": "paragraph", + "data": { + "text": "OMG" + } + }, + { + "id": "OXV5zEDJ0K", + "type": "paragraph", + "data": { + "text": "\u0418 \u0432\u0441\u0451 \u044D\u0442\u043E JSON\u043E\u043C \u0443\u043B\u0435\u0442\u0430\u0435\u0442 \u0432 Blazor\u003Cbr\u003E" + } + } + ], + "version": "2.29.1" + } + """; + + ContentBlocksRegistry.Register(); + ContentBlocksRegistry.Register(); + var data = JsonSerializer.Deserialize(json); + data.Should().NotBeNull(); + data!.Time.Should().Be(1712168519971); + data.Version.Should().Be("2.29.1"); + data.Blocks.Should().NotBeEmpty(); + + var textBlock = data.Blocks.OfType().FirstOrDefault(b => b.Id == "oJJ-PIRzzz"); + textBlock.Should().NotBeNull(); + textBlock!.Data.Text.Should().Be("Вау, тут можно писать
"); + + var imageBlock = data.Blocks.OfType().FirstOrDefault(b => b.Id == "zuU72THimY"); + imageBlock.Should().NotBeNull(); + imageBlock!.Data.Stretched.Should().BeTrue(); + } + + [Fact] + public void Serialize() + { + ContentBlocksRegistry.Clear(); + ContentBlocksRegistry.Register(); + ContentBlocksRegistry.Register(); + var data = new EditorJSData + { + Time = 1, + Version = "something", + Blocks = + [ + new ParagraphBlock { Id = "foo", Data = new ParagraphBlockData { Text = "some text" } }, + new SimpleImageBlock { Id = "bar", Data = new SimpleImageBlockData { Url = "some url" } } + ] + }; + + var json = JsonSerializer.Serialize(data); + var data2 = JsonSerializer.Deserialize(json); + data2.Should().Be(data); + } +} diff --git a/tests/Sitko.EditorJS.Tests/Sitko.EditorJS.Tests.csproj b/tests/Sitko.EditorJS.Tests/Sitko.EditorJS.Tests.csproj new file mode 100644 index 0000000..01dc1cf --- /dev/null +++ b/tests/Sitko.EditorJS.Tests/Sitko.EditorJS.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + +