diff --git a/.editorconfig b/.editorconfig index 436a2fbf..7f67143a 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,24 +1,5 @@ root = true - -# All files -[*] indent_style = space -csharp_indent_labels = no_change -csharp_using_directive_placement = outside_namespace:silent -csharp_prefer_simple_using_statement = true:suggestion -csharp_prefer_braces = true:silent -csharp_style_namespace_declarations = block_scoped:silent -csharp_style_prefer_method_group_conversion = true:silent -csharp_style_prefer_top_level_statements = true:silent -csharp_style_prefer_primary_constructors = true:suggestion -csharp_style_expression_bodied_methods = false:silent -csharp_style_expression_bodied_constructors = false:silent -csharp_style_expression_bodied_operators = false:silent -csharp_style_expression_bodied_properties = true:silent -csharp_style_expression_bodied_indexers = true:silent -csharp_style_expression_bodied_accessors = true:silent -csharp_style_expression_bodied_lambdas = true:silent -csharp_style_expression_bodied_local_functions = false:silent [*.{csproj,sln,vcxproj,vcxproj.filters,proj,nativeproj,locproj}] indent_size = 4 @@ -44,7 +25,23 @@ indent_style = space indent_size = 2 [*.{cs,vb}] -#### Naming styles #### +# Naming styles +csharp_indent_labels = no_change +csharp_using_directive_placement = outside_namespace:silent +csharp_prefer_simple_using_statement = true:suggestion +csharp_prefer_braces = true:silent +csharp_style_namespace_declarations = block_scoped:silent +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_top_level_statements = true:silent +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_lambdas = true:silent +csharp_style_expression_bodied_local_functions = false:silent # Naming rules @@ -64,33 +61,21 @@ dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case dotnet_naming_symbols.interface.applicable_kinds = interface dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.interface.required_modifiers = dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.types.required_modifiers = dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.non_field_members.required_modifiers = # Naming styles dotnet_naming_style.begins_with_i.required_prefix = I -dotnet_naming_style.begins_with_i.required_suffix = -dotnet_naming_style.begins_with_i.word_separator = dotnet_naming_style.begins_with_i.capitalization = pascal_case - -dotnet_naming_style.pascal_case.required_prefix = -dotnet_naming_style.pascal_case.required_suffix = -dotnet_naming_style.pascal_case.word_separator = dotnet_naming_style.pascal_case.capitalization = pascal_case - -dotnet_naming_style.pascal_case.required_prefix = -dotnet_naming_style.pascal_case.required_suffix = -dotnet_naming_style.pascal_case.word_separator = dotnet_naming_style.pascal_case.capitalization = pascal_case dotnet_style_operator_placement_when_wrapping = beginning_of_line + tab_width = 4 indent_size = 4 end_of_line = crlf diff --git a/.github/linters/.jscpd.json b/.github/linters/.jscpd.json index 05cb2c3d..8e44c666 100644 --- a/.github/linters/.jscpd.json +++ b/.github/linters/.jscpd.json @@ -7,7 +7,8 @@ "**/*.md", "**/*excubowebcompiler.json", "**/bin/**", - "**/obj/**" + "**/obj/**", + "**/node_modules/**" ], "absolute": true, "minTokens": 75 diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 138bed8b..3eccca07 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -20,6 +20,8 @@ jobs: uses: CropperBlazor/Cropper.Blazor/.github/workflows/build-test-template.yml@dev secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + run-js-tests: true deploy-to-github-pages: permissions: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6c134c5..782f3fcf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,28 @@ jobs: uses: CropperBlazor/Cropper.Blazor/.github/workflows/build-test-template.yml@dev secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + run-js-tests: true + + es-linting: + permissions: + contents: read + needs: code-quality-check + name: ESLint - TypeScript Linting + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + persist-credentials: false + + - name: Install npm dependencies + run: npm install + working-directory: src/Cropper.Blazor/Cropper.Blazor + + - name: Run ESLint + run: npm run lint + working-directory: src/Cropper.Blazor/Cropper.Blazor code-linting: permissions: @@ -63,8 +85,10 @@ jobs: VALIDATE_MARKDOWN_PRETTIER: false VALIDATE_YAML_PRETTIER: false VALIDATE_JSON_PRETTIER: false + VALIDATE_TYPESCRIPT_PRETTIER: false + VALIDATE_TYPESCRIPT_ES: false - FILTER_REGEX_EXCLUDE: '(\W|^)(obj/|bin/)|(\W|^)(.*([.]min[.]css))($)|(\W|^)(.*([.]min[.]js))($)' + FILTER_REGEX_EXCLUDE: '(\W|^)(obj/|bin/|node_modules/|webpack\.config\.js)|(\W|^).*([.]min[.]css)($)|(\W|^).*([.]min[.]js)($)' FILTER_REGEX_INCLUDE: "/github/workspace/src/Cropper.Blazor/.*|/github/workspace/.github/.*" JSCPD_CONFIG_FILE: ".jscpd.json" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 233512b6..6318e5c0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,6 +18,7 @@ jobs: with: configuration: "Release" publish-coverage: true + run-js-tests: true deploy-to-nuget: name: Deploy to NuGet @@ -73,6 +74,15 @@ jobs: if ! jq -e '.Root.Children["cropper.min.js"]' "$json_path" > /dev/null; then error_messages+="For .NET $net_version: Key 'cropper.min.js' does not exist.\n" fi + if ! jq -e '.Root.Children["blob-helper.d.ts"]' "$json_path" > /dev/null; then + error_messages+="For .NET $net_version: Key 'blob-helper.d.ts' does not exist.\n" + fi + if ! jq -e '.Root.Children["cropperJsInterop.d.ts"]' "$json_path" > /dev/null; then + error_messages+="For .NET $net_version: Key 'cropperJsInterop.d.ts' does not exist.\n" + fi + if ! jq -e '.Root.Children["cropper-url-image-helper.d.ts"]' "$json_path" > /dev/null; then + error_messages+="For .NET $net_version: Key 'cropper-url-image-helper.d.ts' does not exist.\n" + fi else error_messages+="File $json_path does not exist for .NET $net_version.\n" fi diff --git a/.gitignore b/.gitignore index e7c92439..391f649b 100644 --- a/.gitignore +++ b/.gitignore @@ -29,5 +29,10 @@ _ReSharper*/ [Tt]est[Rr]esult* .vs/ .idea/ +*.cobertura.xml #Nuget packages folder packages/ +# Node.js +node_modules/ +coverage/ +package-lock.json diff --git a/NuGet_README.md b/NuGet_README.md index 585f42b3..78538612 100644 --- a/NuGet_README.md +++ b/NuGet_README.md @@ -8,7 +8,7 @@ [![NuGet Downloads](https://img.shields.io/nuget/dt/Cropper.Blazor?logo=nuget&label=nuget%20downloads&color=ff5c9b)](https://www.nuget.org/packages/Cropper.Blazor) [![NuGet Version](https://img.shields.io/nuget/v/Cropper.Blazor?logo=nuget&label=nuget%20version&color=009DEA)](https://www.nuget.org/packages/Cropper.Blazor) -The most powerful image cropping tool for Blazor WebAssembly / Server, Hybrid with MAUI, MVC and +The most powerful image cropping tool for Blazor Web App / WebAssembly / Server, Hybrid with MAUI, MVC and other frameworks. Cropper.Blazor is an essential component for building interactive image cropping and manipulation features in Blazor web applications. This versatile Blazor library empowers developers to integrate intuitive image cropping functionality directly into their Blazor projects, offering users a seamless and responsive image editing experience. @@ -33,6 +33,7 @@ Cropper.Blazor is an essential component for building interactive image cropping | 1.5.x | [.NET 6](https://dotnet.microsoft.com/download/dotnet/6.0), [.NET 7](https://dotnet.microsoft.com/en-us/download/dotnet/7.0), [.NET 8](https://dotnet.microsoft.com/en-us/download/dotnet/8.0), [.NET 9](https://dotnet.microsoft.com/en-us/download/dotnet/9.0), [.NET 10](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) | :heavy_check_mark: | - Supported .NET 10.0, .NET 9.0, .NET 8.0, .NET 7.0, .NET 6.0 versions for these web platforms: + - Blazor Web App - Blazor WebAssembly - Blazor Server - Blazor Server Hybrid with MVC diff --git a/README.md b/README.md index adbc85c8..c14b5951 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ [![NuGet Downloads](https://img.shields.io/nuget/dt/Cropper.Blazor?logo=nuget&label=nuget%20downloads&color=ff5c9b)](https://www.nuget.org/packages/Cropper.Blazor) [![NuGet Version](https://img.shields.io/nuget/v/Cropper.Blazor?logo=nuget&label=nuget%20version&color=009DEA)](https://www.nuget.org/packages/Cropper.Blazor) -The most powerful image cropping tool for Blazor WebAssembly / Server, Hybrid with MAUI, MVC and +The most powerful image cropping tool for Blazor Web App / WebAssembly / Server, Hybrid with MAUI, MVC and other frameworks. Cropper.Blazor is an essential component for building interactive image cropping and manipulation features in Blazor web applications. This versatile Blazor library empowers developers to integrate intuitive image cropping functionality directly into their Blazor projects, offering users a seamless and responsive image editing experience. @@ -36,6 +36,7 @@ Cropper.Blazor is an essential component for building interactive image cropping | 1.5.x | [.NET 6](https://dotnet.microsoft.com/download/dotnet/6.0), [.NET 7](https://dotnet.microsoft.com/en-us/download/dotnet/7.0), [.NET 8](https://dotnet.microsoft.com/en-us/download/dotnet/8.0), [.NET 9](https://dotnet.microsoft.com/en-us/download/dotnet/9.0), [.NET 10](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) | :heavy_check_mark: | - Supported .NET 10.0, .NET 9.0, .NET 8.0, .NET 7.0, .NET 6.0 versions for these web platforms: + - Blazor Web App - Blazor WebAssembly - Blazor Server - Blazor Server Hybrid with MVC diff --git a/examples/Cropper.Blazor.MAUI.Net9/Cropper.Blazor.MAUI.Net9.csproj b/examples/Cropper.Blazor.MAUI.Net9/Cropper.Blazor.MAUI.Net9.csproj index 360d69d2..2ad68b79 100644 --- a/examples/Cropper.Blazor.MAUI.Net9/Cropper.Blazor.MAUI.Net9.csproj +++ b/examples/Cropper.Blazor.MAUI.Net9/Cropper.Blazor.MAUI.Net9.csproj @@ -60,8 +60,8 @@ - - + + diff --git a/examples/Cropper.Blazor.Server.Net8/Cropper.Blazor.Server.Net8.csproj b/examples/Cropper.Blazor.Server.Net8/Cropper.Blazor.Server.Net8.csproj index e4f253d6..62be134e 100644 --- a/examples/Cropper.Blazor.Server.Net8/Cropper.Blazor.Server.Net8.csproj +++ b/examples/Cropper.Blazor.Server.Net8/Cropper.Blazor.Server.Net8.csproj @@ -7,7 +7,7 @@ - + diff --git a/examples/Cropper.Blazor.Server.Net9/Cropper.Blazor.Server.Net9.csproj b/examples/Cropper.Blazor.Server.Net9/Cropper.Blazor.Server.Net9.csproj index 003affe0..c53b59a6 100644 --- a/examples/Cropper.Blazor.Server.Net9/Cropper.Blazor.Server.Net9.csproj +++ b/examples/Cropper.Blazor.Server.Net9/Cropper.Blazor.Server.Net9.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/Cropper.Blazor/Client/Components/Docs/ApiMethod.cs b/src/Cropper.Blazor/Client/Components/Docs/ApiMethod.cs index f9929cae..a32d8b0d 100644 --- a/src/Cropper.Blazor/Client/Components/Docs/ApiMethod.cs +++ b/src/Cropper.Blazor/Client/Components/Docs/ApiMethod.cs @@ -10,5 +10,6 @@ public class ApiMethod public string Documentation { get; set; } public MethodInfo MethodInfo { get; set; } public ParameterInfo[] Parameters { get; set; } + public bool IsJsInvokable { get; set; } } } diff --git a/src/Cropper.Blazor/Client/Components/Docs/DocsApi.razor b/src/Cropper.Blazor/Client/Components/Docs/DocsApi.razor index fc7bde81..15f793a2 100644 --- a/src/Cropper.Blazor/Client/Components/Docs/DocsApi.razor +++ b/src/Cropper.Blazor/Client/Components/Docs/DocsApi.razor @@ -9,277 +9,327 @@ @using MudBlazor; @using Cropper.Blazor.Shared.Extensions; - + - @if (!IsContract) + @if (Type is not null) { - - - - - - } - else - { - - @if (Type.IsEnum) - { -
- Enum @TypeNameHelper.GetTypeDisplay(Type, false, true) -
- } - else - { -
- Contract @TypeNameHelper.GetTypeDisplay(Type, false, true) + @if (!IsContract && !IsHelper) + { + (string? Href, string? Desc) pageInfo = GetHrefPageWithDesc(); + + + + + - } + + } + else + { + + @if (Type.IsEnum) + { +
+ Enum @TypeNameHelper.GetTypeDisplay(Type, false, true) +
+ } + else if (Type.IsInterface) + { +
+ Interface @TypeNameHelper.GetTypeDisplay(Type, false, true) +
+ } + else + { +
+ Contract @TypeNameHelper.GetTypeDisplay(Type, false, true) +
+ } +
+ } + + Description - } - @{ + + @(new MarkupString(AnalyseMethodDocumentation(GetClassDescription(), "summary"))) + // save as lists to speed up displaying the page - var properties = GetProperties().ToList(); - var methods = GetMethods().ToList(); - var eventCallbacks = GetEventCallbacks().ToList(); - } + var properties = GetProperties()?.ToList(); + bool? isShowPropertiesDescription = properties?.All(p => !string.IsNullOrWhiteSpace(p.Description)); + var methods = GetMethods()?.ToList(); + var eventCallbacks = GetEventCallbacks()?.ToList(); - @if (properties.Count() > 0) - { - - - - - - Name - Type - Default - Description - - - @if (_propertiesGrouping == Grouping.Inheritance && (Type)context.Key != Type) - { - - @($"Inherited from {((Type)context.Key).GetTypeDisplayName()}") - - } - else if (_propertiesGrouping == Grouping.Categories) - { - - @context.Key - - } - - - - @context.Name - @if (_propertiesGrouping == Grouping.Inheritance && IsOverridden(context.PropertyInfo)) + @if (properties?.Count() > 0) + { + + + + + + Name + Type + Default + @if (isShowPropertiesDescription == true) { - - overridden - - } - - -
- - @if (context.IsTwoWay) - { - - - - } -
-
- - @{ - var def = context.Default.PresentDefaultValue(); + Description } - @if (def.Contains(" + + @if (_propertiesGrouping == Grouping.Inheritance && (Type)context.Key != Type) { - + + @($"Inherited from {((Type)context.Key).GetTypeDisplayName()}") + } - else + else if (_propertiesGrouping == Grouping.Categories) { - @def + + @context.Key + } - - - -
- @(new MarkupString(HttpUtility.HtmlDecode(AnalyseMethodDocumentation(context.Description, "summary")))) -
-
-
- - -
- @(new MarkupString(HttpUtility.HtmlDecode(AnalyseMethodDocumentation(context.Description, "summary")))) -
-
-
-
- -
-
-
- } - - @if (eventCallbacks.Count() > 0) - { - - - - - - Name - Type - Description - - - @context.Name - - -
@(new MarkupString(context.Type.GetFormattedReturnSignature()))
-
-
- - @(new MarkupString(context.Type.GetFormattedReturnSignature())) - - - @HttpUtility.HtmlDecode(AnalyseMethodDocumentation(context.Description, "summary")) - -
-
-
-
- } - - @if (methods.Count() > 0) - { - - - - - - Name - Parameters - Return - Description - - - - - @if (!string.IsNullOrWhiteSpace(context.WarningSignatureMessage)) + + + + @context.Name + @if (_propertiesGrouping == Grouping.Inheritance && IsOverridden(context.PropertyInfo)) { - -
@context.WarningSignatureMessage
- @context.Signature -
- - } - else - { - @context.Signature + + overridden + }
- - @if (context.Parameters != null) - { - foreach (var parameterInfo in context.Parameters) + +
+ + @if (context.IsTwoWay) { -
-
@(new MarkupString($"
{parameterInfo.ParameterType.GetTypeDisplayName()} {parameterInfo.Name}
{AnalyseMethodDocumentation(context.Documentation, "param", parameterInfo.Name)}"))
-
+ + + } - } +
- + @{ - string methodReturn = AnalyseMethodDocumentation(context.Documentation, "returns"); + var def = context.Default.PresentDefaultValue(context.PropertyInfo); } - @if (!string.IsNullOrEmpty(methodReturn)) + @if (def.Contains("@(new MarkupString($"{methodReturn}"))
+ } else { -
@TypeNameHelper.GetTypeDisplay(context.Return.ParameterType, false, true)
+ @def } - -
@(new MarkupString(HttpUtility.HtmlDecode(AnalyseMethodDocumentation(context.Documentation, "summary"))))
-
- - - - @context.Signature - @if (!string.IsNullOrWhiteSpace(context.WarningSignatureMessage)) - { - -
- @context.WarningSignatureMessage + @if (isShowPropertiesDescription == true) + { + + +
+ @(new MarkupString(HttpUtility.HtmlDecode(AnalyseMethodDocumentation(context.Description, "summary"))))
- - } +
+
+ + +
+ @(new MarkupString(HttpUtility.HtmlDecode(AnalyseMethodDocumentation(context.Description, "summary")))) +
+
+
+ } + + + + + + } + + @if (eventCallbacks?.Count() > 0) + { + + + + + + Name + Type + Description + + + @context.Name + + +
@(new MarkupString(context.Type.GetFormattedReturnSignature()))
+
+
+ + @(new MarkupString(context.Type.GetFormattedReturnSignature())) + + + @HttpUtility.HtmlDecode(AnalyseMethodDocumentation(context.Description, "summary")) - - @if (context.Parameters != null) - { - foreach (var parameterInfo in context.Parameters) +
+
+
+
+ } + + @if (methods?.Count() > 0) + { + + + + + + Name + Parameters + Return + Description + + + + + @if (!string.IsNullOrWhiteSpace(context.WarningSignatureMessage)) { -
-
@(new MarkupString($"
{parameterInfo.ParameterType.GetTypeDisplayName()} {parameterInfo.Name}
{AnalyseMethodDocumentation(context.Documentation, "param", parameterInfo.Name)}"))
-
-
+ + @if (context.IsJsInvokable) + { +
+ Internally invokable from JS +
+ } +
@context.WarningSignatureMessage
+ @context.Signature +
+ } - } -
- - @{ - string methodReturn = AnalyseMethodDocumentation(context.Documentation, "returns"); - } - @if (!string.IsNullOrEmpty(methodReturn)) - { -
@(new MarkupString($"{methodReturn}"))
- } - else - { -
@TypeNameHelper.GetTypeDisplay(context.Return.ParameterType, false, true)
- } -
- @(new MarkupString(HttpUtility.HtmlDecode(AnalyseMethodDocumentation(context.Documentation, "summary")))) -
-
- - - - - - - - -
-
-
+ else + { + + @if (context.IsJsInvokable) + { +
+ Internally invokable from JS +
+ } + @context.Signature +
+ } + + + + @if (context.Parameters != null) + { + foreach (var parameterInfo in context.Parameters) + { +
+
@(new MarkupString($"
{TypeNameHelper.GetTypeDisplay(parameterInfo.ParameterType, false, true, Cropper.Blazor.Shared.Extensions.MethodInfoExtensions.CreateLink)} {parameterInfo.Name}
{AnalyseMethodDocumentation(context.Documentation, "param", parameterInfo.Name)}"))
+
+ } + } +
+ + @{ + string methodReturn = AnalyseMethodDocumentation(context.Documentation, "returns"); + } + @if (!string.IsNullOrEmpty(methodReturn)) + { +
@(new MarkupString($"{methodReturn}"))
+ } + else + { +
@TypeNameHelper.GetTypeDisplay(context.Return.ParameterType, false, true)
+ } +
+ +
@(new MarkupString(HttpUtility.HtmlDecode(AnalyseMethodDocumentation(context.Documentation, "summary"))))
+
+ + + + @context.Signature + @if (context.IsJsInvokable) + { + +
+ Internally invokable from JS +
+
+ } + @if (!string.IsNullOrWhiteSpace(context.WarningSignatureMessage)) + { + +
+ @context.WarningSignatureMessage +
+
+ } +
+ + @if (context.Parameters != null) + { + foreach (var parameterInfo in context.Parameters) + { +
+
@(new MarkupString($"
{TypeNameHelper.GetTypeDisplay(parameterInfo.ParameterType, false, true, Cropper.Blazor.Shared.Extensions.MethodInfoExtensions.CreateLink)} {parameterInfo.Name}
{AnalyseMethodDocumentation(context.Documentation, "param", parameterInfo.Name)}"))
+
+
+ } + } +
+ + @{ + string methodReturn = AnalyseMethodDocumentation(context.Documentation, "returns"); + } + @if (!string.IsNullOrEmpty(methodReturn)) + { +
@(new MarkupString($"{methodReturn}"))
+ } + else + { +
@TypeNameHelper.GetTypeDisplay(context.Return.ParameterType, false, true)
+ } +
+ @(new MarkupString(HttpUtility.HtmlDecode(AnalyseMethodDocumentation(context.Documentation, "summary")))) +
+ + + + + + + + + + + + + } + } + else + { + + The requested contract or component does not exist. + } - -
- @RenderTheType() -
\ No newline at end of file diff --git a/src/Cropper.Blazor/Client/Components/Docs/DocsApi.razor.cs b/src/Cropper.Blazor/Client/Components/Docs/DocsApi.razor.cs index 13286ff9..8e9bf294 100644 --- a/src/Cropper.Blazor/Client/Components/Docs/DocsApi.razor.cs +++ b/src/Cropper.Blazor/Client/Components/Docs/DocsApi.razor.cs @@ -13,6 +13,16 @@ namespace Cropper.Blazor.Client.Components.Docs { public partial class DocsApi { + [Parameter] public Type Type { get; set; } + [Parameter] public bool IsContract { get; set; } = false; + [Parameter] public bool IsHelper { get; set; } = false; + [Parameter] public bool? IsComponentContract { get; set; } = null; + [Inject] NavigationManager NavigationManager { get; set; } = null!; + + public DocsPage DocsPage { get; set; } + + // used for default value getting + private object CompInstance; private readonly List _hiddenMethods = [ "ToString", @@ -23,17 +33,38 @@ public partial class DocsApi "ReferenceEquals" ]; - [Parameter] public Type Type { get; set; } - [Parameter] public bool IsContract { get; set; } = false; - [Inject] NavigationManager NavigationManager { get; set; } = null!; + protected override async Task OnParametersSetAsync() + { + CompInstance = !Type.IsAssignableTo(typeof(IComponent)) ? null : Activator.CreateInstance(Type); - // used for default value getting - private object CompInstance; + await base.OnParametersSetAsync(); + } - public DocsPage DocsPage { get; set; } + private (string? Href, string? Desc) GetHrefPageWithDesc() + { + if (Type == typeof(CropperComponent)) + { + return ("examples/cropperusage", ""); + } + else if (Type == typeof(CroppedCanvasReceiver)) + { + return ("examples/cropping#crop-a-polygon-image-in-background", "See 'Crop in Background' example."); + } + else if (Type == typeof(ImageReceiver)) + { + return ("examples/cropping#crop-a-round-image-in-background", "See 'Crop a polygon image in Background' or 'Crop a round image in Background' examples."); + } + + return (null, null); + } private IEnumerable GetEventCallbacks() { + if (Type == null) + { + yield break; + } + string saveTypename = DocStrings.GetSaveTypename(Type); if (IsContract) @@ -42,7 +73,10 @@ private IEnumerable GetEventCallbacks() } else { - foreach (var info in Type.GetPropertyInfosWithAttribute().OrderBy(x => x.Name)) + IEnumerable? propertyInfos = IsComponentContract == true + ? Type.GetPropertyInfos() + : Type.GetPropertyInfosWithAttribute(); + foreach (var info in propertyInfos.OrderBy(x => x.Name)) { if (IsEventCallback(info)) { @@ -51,7 +85,7 @@ private IEnumerable GetEventCallbacks() Name = info.Name, PropertyInfo = info, Default = string.Empty, - Description = DocStrings.GetMemberDescription(saveTypename, info), + Description = DocStrings.GetMemberDescription(saveTypename, info, IsContract, IsComponentContract), IsTwoWay = CheckIsTwoWayEventCallback(info), Type = info.PropertyType, }; @@ -60,8 +94,35 @@ private IEnumerable GetEventCallbacks() } } + private string GetClassDescription() + { + if (Type.IsClass) + { + string saveTypename = DocStrings.GetSaveTypename(Type); + + return DocStrings.GetClassDescription(saveTypename); + } + else if (Type.IsInterface) + { + string saveTypename = DocStrings.GetSaveTypename(Type); + + return DocStrings.GetInterfaceDescription(saveTypename); + } + else if (Type.IsEnum) + { + return DocStrings.GetEnumDescription(Type.Name); + } + + return string.Empty; + } + private IEnumerable GetMethods() { + if (Type == null) + { + yield break; + } + string saveTypename = DocStrings.GetSaveTypename(Type); if (IsContract) @@ -74,29 +135,29 @@ private IEnumerable GetMethods() { if (!_hiddenMethods.Any(x => x.Contains(info.Name)) && !info.Name.StartsWith("get_") && !info.Name.StartsWith("set_")) { - if (info.GetCustomAttributes(typeof(JSInvokableAttribute), true).Length == 0) + bool hasNoJsInvokableAttribute = info.GetCustomAttributes(typeof(JSInvokableAttribute), true).Length == 0; + + Attribute? attribute = info + .GetCustomAttribute(typeof(ObsoleteAttribute), true); + string? warningSignatureMessage = null; + + if (attribute != null) { - Attribute? attribute = info - .GetCustomAttribute(typeof(ObsoleteAttribute), true); - string? warningSignatureMessage = null; - - if (attribute != null) - { - ObsoleteAttribute obsoleteAttr = (ObsoleteAttribute)attribute; - - warningSignatureMessage = obsoleteAttr.Message; - } - - yield return new ApiMethod() - { - MethodInfo = info, - WarningSignatureMessage = warningSignatureMessage, - Return = info.ReturnParameter, - Signature = info.GetSignature(), - Parameters = info.GetParameters(), - Documentation = DocStrings.GetMemberDescription(saveTypename, info) - }; + ObsoleteAttribute obsoleteAttr = (ObsoleteAttribute)attribute; + + warningSignatureMessage = obsoleteAttr.Message; } + + yield return new ApiMethod() + { + MethodInfo = info, + IsJsInvokable = !hasNoJsInvokableAttribute, + WarningSignatureMessage = warningSignatureMessage, + Return = info.ReturnParameter, + Signature = info.GetSignature(), + Parameters = info.GetParameters(), + Documentation = DocStrings.GetMemberDescription(saveTypename, info, IsContract, IsComponentContract) + }; } } } @@ -111,10 +172,15 @@ private static bool IsEventCallback(PropertyInfo? propertyInfo) private IEnumerable GetProperties() { + if (Type == null) + { + yield break; + } + string saveTypename = DocStrings.GetSaveTypename(Type); IEnumerable types = null!; - if (IsContract) + if (IsContract || IsComponentContract == true) { types = Type .GetPropertyInfos(); @@ -148,13 +214,15 @@ private IEnumerable GetProperties() private ApiProperty ToApiProperty(PropertyInfo info, string saveTypename) { + object defaultValue = GetDefaultValue(info); + return new ApiProperty { Name = info.Name, PropertyInfo = info, - Default = GetDefaultValue(info), + Default = defaultValue, IsTwoWay = CheckIsTwoWayProperty(info), - Description = DocStrings.GetMemberDescription(saveTypename, info, IsContract), + Description = DocStrings.GetMemberDescription(saveTypename, info, IsContract, IsComponentContract), Type = info.PropertyType }; } @@ -166,7 +234,7 @@ private static ApiProperty ToApiProperty(Type type, string? enumDisplayStatus, s Name = enumDisplayStatus, PropertyInfo = null, Default = value, - Description = DocStrings.GetEnumDescription(type.Name, enumDisplayStatus), + Description = DocStrings.GetEnumValueDescription(type.Name, enumDisplayStatus), Type = type }; } @@ -220,18 +288,6 @@ private bool CheckIsTwoWayProperty(PropertyInfo propertyInfo) eventCallbackInfo.GetCustomAttribute() == null; } - RenderFragment RenderTheType() - { - if (!Type.IsAssignableTo(typeof(IComponent))) - return null; - return new RenderFragment(builder => - { - builder.OpenComponent(0, Type); - builder.AddComponentReferenceCapture(1, inst => { CompInstance = inst; }); - builder.CloseComponent(); - }); - } - private async Task OnPageChanged(int newPage) { await DocsPage.ContentNavigation.ScrollToSection(new Uri(NavigationManager.BaseUri + "/api#methods")); @@ -306,19 +362,6 @@ private enum Grouping { Categories, Inheritance, None } private static bool IsOverridden(PropertyInfo p) => IsOverridden(p.GetMethod ?? p.SetMethod); // used for the "overridden" chip - // used for ordering groups of properties - private static int NumberOfAncestorClasses(Type type) - { - int n = 0; - - while ((type = type.BaseType) != null) - { - n++; - } - - return n; - } - #endregion } } diff --git a/src/Cropper.Blazor/Client/Cropper.Blazor.Client.csproj b/src/Cropper.Blazor/Client/Cropper.Blazor.Client.csproj index 0da5ec72..a31600de 100644 --- a/src/Cropper.Blazor/Client/Cropper.Blazor.Client.csproj +++ b/src/Cropper.Blazor/Client/Cropper.Blazor.Client.csproj @@ -19,9 +19,9 @@ - - - + + + diff --git a/src/Cropper.Blazor/Client/Models/DocStrings.cs b/src/Cropper.Blazor/Client/Models/DocStrings.cs index ac11fa91..cb799ebe 100644 --- a/src/Cropper.Blazor/Client/Models/DocStrings.cs +++ b/src/Cropper.Blazor/Client/Models/DocStrings.cs @@ -11,13 +11,13 @@ public static partial class DocStrings * string saveTypename = DocStrings.GetSaveTypename(type); // calculate it only once * DocStrings.GetMemberDescription(saveTypename, member); */ - public static string GetMemberDescription(string saveTypename, MemberInfo member, bool isContract = false) + public static string GetMemberDescription(string saveTypename, MemberInfo member, bool isContract, bool? isComponentContract) { string name; if (member is PropertyInfo property) { - if (isContract) + if (isContract || isComponentContract == true) { name = saveTypename.Replace("<>", string.Empty) + "_property_" + property.Name; } @@ -38,13 +38,34 @@ public static string GetMemberDescription(string saveTypename, MemberInfo member return GetDocStrings(name); } - public static string GetEnumDescription(string enumName, string? enumValue) + public static string GetEnumValueDescription(string enumName, string? enumValue) { string name = $"{enumName}_enum_{enumValue}"; return GetDocStrings(name); } + public static string GetEnumDescription(string enumName) + { + string name = $"{enumName}_enum"; + + return GetDocStrings(name); + } + + public static string GetClassDescription(string className) + { + string name = $"{className}_class"; + + return GetDocStrings(name); + } + + public static string GetInterfaceDescription(string interfaceName) + { + string name = $"{interfaceName}_interface"; + + return GetDocStrings(name); + } + private static string GetDocStrings(string name) { var field = typeof(DocStrings).GetField(name, BindingFlags.Public | BindingFlags.Static | BindingFlags.GetField); diff --git a/src/Cropper.Blazor/Client/Pages/Api.razor b/src/Cropper.Blazor/Client/Pages/Api.razor deleted file mode 100644 index 50b543c7..00000000 --- a/src/Cropper.Blazor/Client/Pages/Api.razor +++ /dev/null @@ -1,39 +0,0 @@ -@page "/api" -@attribute [SitemapUrl(changeFreq: ChangeFreq.Daily, priority: 0.8)] - -@using Cropper.Blazor.Client.Components.Docs -@using Cropper.Blazor.Components; -@using Cropper.Blazor.Shared.Attributes -@using Cropper.Blazor.Shared.Models - - - -@if (ComponentType == null) -{ - -} -else -{ - -} - - -@code { - public Type ComponentType { get; set; } = null!; - - protected override void OnParametersSet() - { - ComponentType = typeof(CropperComponent); - StateHasChanged(); - } -} diff --git a/src/Cropper.Blazor/Client/Pages/CropperDemo.razor.cs b/src/Cropper.Blazor/Client/Pages/CropperDemo.razor.cs index 950369e4..7a18abf7 100644 --- a/src/Cropper.Blazor/Client/Pages/CropperDemo.razor.cs +++ b/src/Cropper.Blazor/Client/Pages/CropperDemo.razor.cs @@ -309,7 +309,7 @@ public async void OnCropEvent(JSEventData cropJSEvent) { await InvokeAsync(() => { - //JSRuntime!.InvokeVoidAsync("console.log", $"CropJSEvent {JsonSerializer.Serialize(cropJSEvent)}"); + JSRuntime!.InvokeVoidAsync("console.log", $"CropJSEvent {JsonSerializer.Serialize(cropJSEvent)}"); CropperDataPreview?.OnCropEvent(cropJSEvent.Detail); }); } @@ -408,7 +408,8 @@ public async void OnZoomEvent(JSEventData zoomJSEvent) { await InvokeAsync(() => { - //JSRuntime!.InvokeVoidAsync("console.log", $"ZoomEvent {JsonSerializer.Serialize(zoomJSEvent)}"); + JSRuntime!.InvokeVoidAsync("console.log", $"ZoomEvent {JsonSerializer.Serialize(zoomJSEvent)}"); + GetSetCropperData!.OnZoomEvent(zoomJSEvent.Detail); }); diff --git a/src/Cropper.Blazor/Client/Pages/DataContract.razor b/src/Cropper.Blazor/Client/Pages/DataContract.razor index dea85a0a..61b6f7e7 100644 --- a/src/Cropper.Blazor/Client/Pages/DataContract.razor +++ b/src/Cropper.Blazor/Client/Pages/DataContract.razor @@ -1,4 +1,7 @@ -@page "/api/{name}" +@page "/api" +@page "/api/{name}" +@attribute [SitemapUrl(changeFreq: ChangeFreq.Daily, priority: 0.8, url: "/api")] +@attribute [SitemapUrl(changeFreq: ChangeFreq.Daily, priority: 0.8, url: "/api/CropperComponent")] @attribute [SitemapUrl(changeFreq: ChangeFreq.Daily, priority: 0.7, url: "/api/CanvasData")] @attribute [SitemapUrl(changeFreq: ChangeFreq.Daily, priority: 0.7, url: "/api/ContainerData")] @attribute [SitemapUrl(changeFreq: ChangeFreq.Daily, priority: 0.7, url: "/api/CropBoxData")] @@ -22,29 +25,27 @@ @attribute [SitemapUrl(changeFreq: ChangeFreq.Daily, priority: 0.7, url: "/api/CroppedCanvasReceiver")] @attribute [SitemapUrl(changeFreq: ChangeFreq.Daily, priority: 0.7, url: "/api/ImageReceiver")] @attribute [SitemapUrl(changeFreq: ChangeFreq.Daily, priority: 0.7, url: "/api/ImageProcessingException")] +@attribute [SitemapUrl(changeFreq: ChangeFreq.Daily, priority: 0.7, url: "/api/CropEvent")] +@attribute [SitemapUrl(changeFreq: ChangeFreq.Daily, priority: 0.7, url: "/api/ActionEvent")] +@attribute [SitemapUrl(changeFreq: ChangeFreq.Daily, priority: 0.7, url: "/api/ImageSmoothingQuality")] @using Cropper.Blazor.Client.Components.Docs @using Cropper.Blazor.Shared.Attributes @using Cropper.Blazor.Shared.Models -@if (ComponentType is not null) +@if (ComponentType is not null && !HasName) { - - - - - + new[] { "API", "Cropper component API", "Cropper API", "Cropper.Blazor component API", "Cropper.Blazor API" })" /> } -else +else if (ComponentType is not null && HasName) { - - @($"Contract '{Name}' does not exist"); - + } + + diff --git a/src/Cropper.Blazor/Client/Pages/DataContract.razor.cs b/src/Cropper.Blazor/Client/Pages/DataContract.razor.cs index 4da4e8bf..168c5af7 100644 --- a/src/Cropper.Blazor/Client/Pages/DataContract.razor.cs +++ b/src/Cropper.Blazor/Client/Pages/DataContract.razor.cs @@ -1,4 +1,5 @@ using Cropper.Blazor.Client.Components.Docs; +using Cropper.Blazor.Components; using Microsoft.AspNetCore.Components; namespace Cropper.Blazor.Client.Pages @@ -10,10 +11,41 @@ public partial class DataContract public Type? ComponentType { get; set; } + private bool IsHelper = false; + private bool IsContract = true; + private bool? IsComponentContract = null; + + private bool HasName => !string.IsNullOrWhiteSpace(Name); + protected override void OnParametersSet() { - ComponentType = ApiLink.GetTypeFromComponentLink(Name); - StateHasChanged(); + // RESET state derived from parameters + IsHelper = false; + IsContract = true; + IsComponentContract = null; + + ComponentType = HasName + ? ApiLink.GetTypeFromComponentLink(Name) + : typeof(CropperComponent); + + if (ComponentType is not null && ComponentType.IsInterface) + { + IsHelper = true; + IsContract = false; + IsComponentContract = false; + } + else if (ComponentType == typeof(CropperComponent)) + { + IsContract = false; + IsComponentContract = false; + } + else if (ComponentType == typeof(ImageReceiver) || ComponentType == typeof(CroppedCanvasReceiver)) + { + IsContract = false; + IsComponentContract = true; + } + + base.OnParametersSet(); } } } diff --git a/src/Cropper.Blazor/Client/Pages/Index.razor b/src/Cropper.Blazor/Client/Pages/Index.razor index e8c714d5..b731584e 100644 --- a/src/Cropper.Blazor/Client/Pages/Index.razor +++ b/src/Cropper.Blazor/Client/Pages/Index.razor @@ -13,194 +13,194 @@ + new[] + { + "home", + "installation" + })" /> - - - - - - Cropper.Blazor - - - - - - - - - - Cropper.Blazor - is a component that wraps around - - Cropper.js - - version 1.6.2 - - - - - - View Demo - - - Star on GitHub - - -

- - Deploy to NuGet - - - Deploy to GitHub Pages - - - Code coverage - - - License - - - Last commit - - - Nuget downloads - - - Nuget version - -

- -
- - - - - - - Cropper.Blazor — most powerful image cropping tool for Blazor WebAssembly / Server, Hybrid with MAUI, MVC and other frameworks. - - Cropper.Blazor is an essential component for building interactive image cropping and manipulation features in Blazor web applications. This versatile Blazor library empowers developers to integrate intuitive image cropping functionality directly into their Blazor projects, offering users a seamless and responsive image editing experience. - - - - - - - - - - - - - - - - - - - - + + + + + + Cropper.Blazor + + + + + + + + + + Cropper.Blazor + is a component that wraps around + + Cropper.js + + version 1.6.2 + + + + + + View Demo + + + Star on GitHub + + +

+ + Deploy to NuGet + + + Deploy to GitHub Pages + + + Code coverage + + + License + + + Last commit + + + Nuget downloads + + + Nuget version + +

+ +
+ + + + + + + Cropper.Blazor — most powerful image cropping tool for Blazor Web App / WebAssembly / Server, Hybrid with MAUI, MVC and other frameworks. + + Cropper.Blazor is an essential component for building interactive image cropping and manipulation features in Blazor web applications. This versatile Blazor library empowers developers to integrate intuitive image cropping functionality directly into their Blazor projects, offering users a seamless and responsive image editing experience. + + + + + + + + + + + + + + + + + + + + + + + + If you already have a project and want to add Cropper.Blazor to it, either from a default template or a working application. + + + + + Find the package through NuGet Package Manager or install it with following command: + + + + + + + After the package is added, you need to add the following in your _Imports.razor + + + + + + + Add the following to your HTML head section, it's either index.html or _Layout.cshtml/_Host.cshtml depending on whether you're running WebAssembly/MAUI or Server. + + + + + + + In the same file but located in the end of it add the Cropper.Blazor js file, it should be in the same location as the default blazor script. + + + + + + + + Add the following in Program.cs +

+ You can change the path to the cropperJSInterop.min.js module if for some reason + it is located outside the server root folder using the examples below to override the internal or full global cropperJSInterop.min.js module path. + Actions are usually required when an application is deployed to an IIS Web Server. +
+
+
- - - If you already have a project and want to add Cropper.Blazor to it, either from a default template or a working application. - - - - - Find the package through NuGet Package Manager or install it with following command: - - - - - - - After the package is added, you need to add the following in your _Imports.razor - - - - - - - Add the following to your HTML head section, it's either index.html or _Layout.cshtml/_Host.cshtml depending on whether you're running WebAssembly/MAUI or Server. - - - - - - - In the same file but located in the end of it add the Cropper.Blazor js file, it should be in the same location as the default blazor script. - - - - - - - - Add the following in Program.cs -

- You can change the path to the cropperJSInterop.min.js module if for some reason - it is located outside the server root folder using the examples below to override the internal or full global cropperJSInterop.min.js module path. - Actions are usually required when an application is deployed to an IIS Web Server. -
-
- -
- - - - Also for server-side (Blazor Server or MVC with Blazor Server) you need add configuration SignalR, increase MaximumReceiveMessageSize of a single incoming hub message (default is 32KB) and map SignalR to your path. However, if your images are too large, the MaximumReceiveMessageSize variable should be increased to the desired value. - - - - - - - - Run following command and rebuilt the project. If that doesn't help, try the step below about override package versions. - - - - - - - - - When resolving MAUI project dependency conflicts, you can override an individual package version by using the VersionOverride property on a @("") item. - - - - - - - - - - Add the following components to your MainLayout.razor - - -
- -
-
-
-
-
-
-
+ + + Also for server-side (Blazor Server or MVC with Blazor Server) you need add configuration SignalR, increase MaximumReceiveMessageSize of a single incoming hub message (default is 32KB) and map SignalR to your path. However, if your images are too large, the MaximumReceiveMessageSize variable should be increased to the desired value. + + + + + + + + Run following command and rebuilt the project. If that doesn't help, try the step below about override package versions. + + + + + + + + + When resolving MAUI project dependency conflicts, you can override an individual package version by using the VersionOverride property on a @("") item. + + + + + + + + + + Add the following components to your MainLayout.razor + + +
+ +
+
+
+ + + + + diff --git a/src/Cropper.Blazor/Client/Services/MenuService.cs b/src/Cropper.Blazor/Client/Services/MenuService.cs index b3190fa8..654c1d22 100644 --- a/src/Cropper.Blazor/Client/Services/MenuService.cs +++ b/src/Cropper.Blazor/Client/Services/MenuService.cs @@ -36,25 +36,33 @@ public class MenuService : IMenuService public IEnumerable DocsLinkApi => Api ??= new List { new() {Title = "CropperComponent", Href = "api"}, - new() {Title = "ViewMode", Href = "api/ViewMode"}, - new() {Title = "DragMode", Href = "api/DragMode"}, - new() {Title = "CropperComponentType", Href = "api/CropperComponentType"}, - new() {Group = "Options", Title = "Options", Href = "api/Options"}, - new() {Group = "Options", Title = "GetCroppedCanvasOptions", Href = "api/GetCroppedCanvasOptions"}, - new() {Group = "Options", Title = "SetCropBoxDataOptions", Href = "api/SetCropBoxDataOptions"}, - new() {Group = "Options", Title = "SetCanvasDataOptions", Href = "api/SetCanvasDataOptions"}, - new() {Group = "Options", Title = "SetDataOptions", Href = "api/SetDataOptions"}, - new() {Group = "Event", Title = "CropEndEvent", Href = "api/CropEndEvent"}, - new() {Group = "Event", Title = "CropMoveEvent", Href = "api/CropMoveEvent"}, - new() {Group = "Event", Title = "CropStartEvent", Href = "api/CropStartEvent"}, - new() {Group = "Event", Title = "CropReadyEvent", Href = "api/CropReadyEvent"}, - new() {Group = "Event", Title = "ZoomEvent", Href = "api/ZoomEvent"}, + new() {Title = "CroppedCanvasReceiver", Href = "api/CroppedCanvasReceiver"}, + new() {Title = "ImageReceiver", Href = "api/ImageReceiver"}, + new() {Group = "Data", Title = "ViewMode", Href = "api/ViewMode"}, + new() {Group = "Data", Title = "DragMode", Href = "api/DragMode"}, + new() {Group = "Data", Title = "CropperComponentType", Href = "api/CropperComponentType"}, new() {Group = "Data", Title = "CropperData", Href = "api/CropperData"}, new() {Group = "Data", Title = "ImageData", Href = "api/ImageData"}, new() {Group = "Data", Title = "ContainerData", Href = "api/ContainerData"}, new() {Group = "Data", Title = "CanvasData", Href = "api/CanvasData"}, new() {Group = "Data", Title = "CropBoxData", Href = "api/CropBoxData"}, - new() {Group = "Data", Title = "JSEventData", Href = "api/JSEventData"} + new() {Group = "Data", Title = "JSEventData", Href = "api/JSEventData"}, + new() {Group = "Data", Title = "CroppedCanvas", Href = "api/CroppedCanvas"}, + new() {Group = "Data", Title = "ActionEvent", Href = "api/ActionEvent"}, + new() {Group = "Data", Title = "ImageSmoothingQuality", Href = "api/ImageSmoothingQuality"}, + new() {Group = "Options", Title = "Options", Href = "api/Options"}, + new() {Group = "Options", Title = "GetCroppedCanvasOptions", Href = "api/GetCroppedCanvasOptions"}, + new() {Group = "Options", Title = "SetCropBoxDataOptions", Href = "api/SetCropBoxDataOptions"}, + new() {Group = "Options", Title = "SetCanvasDataOptions", Href = "api/SetCanvasDataOptions"}, + new() {Group = "Options", Title = "SetDataOptions", Href = "api/SetDataOptions"}, + new() {Group = "Events", Title = "CropEndEvent", Href = "api/CropEndEvent"}, + new() {Group = "Events", Title = "CropMoveEvent", Href = "api/CropMoveEvent"}, + new() {Group = "Events", Title = "CropStartEvent", Href = "api/CropStartEvent"}, + new() {Group = "Events", Title = "CropReadyEvent", Href = "api/CropReadyEvent"}, + new() {Group = "Events", Title = "ZoomEvent", Href = "api/ZoomEvent"}, + new() {Group = "Events", Title = "CropEvent", Href = "api/CropEvent"}, + new() {Group = "Exceptions", Title = "ImageProcessingException", Href = "api/ImageProcessingException"}, + new() {Group = "Helpers", Title = "IUrlImageInterop", Href = "api/IUrlImageInterop"}, }; } } diff --git a/src/Cropper.Blazor/Client/Services/UserPreferences/UserPreferences.cs b/src/Cropper.Blazor/Client/Services/UserPreferences/UserPreferences.cs index 5e9ddea4..dc142429 100644 --- a/src/Cropper.Blazor/Client/Services/UserPreferences/UserPreferences.cs +++ b/src/Cropper.Blazor/Client/Services/UserPreferences/UserPreferences.cs @@ -1,6 +1,7 @@ using Cropper.Blazor.Client.Enums; namespace Cropper.Blazor.Client.Services.UserPreferences; + public class UserPreferences { /// diff --git a/src/Cropper.Blazor/Client/Services/UserPreferences/UserPreferencesService.cs b/src/Cropper.Blazor/Client/Services/UserPreferences/UserPreferencesService.cs index b69c77b3..d149462c 100644 --- a/src/Cropper.Blazor/Client/Services/UserPreferences/UserPreferencesService.cs +++ b/src/Cropper.Blazor/Client/Services/UserPreferences/UserPreferencesService.cs @@ -1,6 +1,7 @@ using Blazored.LocalStorage; namespace Cropper.Blazor.Client.Services.UserPreferences; + public interface IUserPreferencesService { /// diff --git a/src/Cropper.Blazor/Client/Styles/components/docssection.scss b/src/Cropper.Blazor/Client/Styles/components/docssection.scss index 28eecf90..b88d6ad3 100644 --- a/src/Cropper.Blazor/Client/Styles/components/docssection.scss +++ b/src/Cropper.Blazor/Client/Styles/components/docssection.scss @@ -1,7 +1,7 @@ .docs-page-section { .docs-section-header { .mud-typography-h5 { - margin-top: 60px; + margin-top: 40px; margin-bottom: 20px; } diff --git a/src/Cropper.Blazor/Client/wwwroot/service-worker.published.js b/src/Cropper.Blazor/Client/wwwroot/service-worker.published.js index 9d2f75c1..2c958ad6 100644 --- a/src/Cropper.Blazor/Client/wwwroot/service-worker.published.js +++ b/src/Cropper.Blazor/Client/wwwroot/service-worker.published.js @@ -3,28 +3,10 @@ self.importScripts('./service-worker-assets.js') self.addEventListener('install', event => { - event.waitUntil( - Promise.all([ - onInstall(), - self.skipWaiting() - ]) - ) + event.waitUntil(onInstall()); }) self.addEventListener('activate', event => { - event.waitUntil( - Promise.all( - [ - onActivate(), - self.clients.claim(), - self.skipWaiting() - ] - ) - .catch( - (err) => { // eslint-disable-line - event.skipWaiting() - } - ) - ) + event.waitUntil(onActivate()) }) self.addEventListener('fetch', event => event.respondWith(onFetch(event))) diff --git a/src/Cropper.Blazor/Client/wwwroot/sw-registrator.js b/src/Cropper.Blazor/Client/wwwroot/sw-registrator.js index 7dc64239..041e8326 100644 --- a/src/Cropper.Blazor/Client/wwwroot/sw-registrator.js +++ b/src/Cropper.Blazor/Client/wwwroot/sw-registrator.js @@ -15,6 +15,7 @@ window.updateAvailable = new Promise((resolve, reject) => { }, 60 * 1000) // 60000ms -> check each minute registration.onupdatefound = () => { + console.info(`Service worker onupdatefound event`) const installingServiceWorker = registration.installing installingServiceWorker.onstatechange = () => { if (installingServiceWorker.state === 'installed') { @@ -27,4 +28,10 @@ window.updateAvailable = new Promise((resolve, reject) => { console.error('Service worker registration failed with error:', error) reject(error) }) + + navigator.serviceWorker.addEventListener('controllerchange', () => { + console.log('Service worker controller changed, reloading page') + window.location.reload() + resolve(true) + }); }) diff --git a/src/Cropper.Blazor/Cropper.Blazor.Client.Compiler/DocStrings.cs b/src/Cropper.Blazor/Cropper.Blazor.Client.Compiler/DocStrings.cs index de0020ea..ed40919a 100644 --- a/src/Cropper.Blazor/Cropper.Blazor.Client.Compiler/DocStrings.cs +++ b/src/Cropper.Blazor/Cropper.Blazor.Client.Compiler/DocStrings.cs @@ -34,7 +34,10 @@ public bool Execute() cb.IndentLevel++; Assembly assembly = typeof(CropperComponent).Assembly; - IOrderedEnumerable types = assembly.GetTypes().OrderBy(t => GetSaveTypename(t)); + IOrderedEnumerable types = assembly + .GetTypes() + .Where(x => x.FullName.StartsWith("Cropper.Blazor.")) + .OrderBy(t => GetSaveTypename(t)); foreach (var type in types) { @@ -86,18 +89,44 @@ public bool Execute() if (type.IsEnum) { string[] enumNames = type.GetEnumNames(); + string docEnum = type.GetDocumentation(); + string description = EscapeDescription(docEnum); - foreach (string enumName in enumNames) + cb.AddLine($"public const string {GetSaveTypename(type)}_enum = @\"{description}\";\n"); + + foreach (string enumItemName in enumNames) { - Enum enumValue = (Enum)Enum.Parse(type, enumName); + Enum enumValue = (Enum)Enum.Parse(type, enumItemName); string doc = enumValue.GetDocumentation(); doc = NormalizeWord(doc); doc = ConvertCrefToHTML(doc); doc = ConvertMarkdownToHTML(doc); + string descriptionItemValue = EscapeDescription(doc); + + cb.AddLine($"public const string {GetSaveTypename(type)}_enum_{enumItemName} = @\"{descriptionItemValue}\";\n"); + } + } + else if (type.IsClass) + { + string doc = type.GetDocumentation(); + + if (doc is not null) + { + string description = EscapeDescription(doc); + + cb.AddLine($"public const string {GetSaveTypename(type)}_class = @\"{description}\";\n"); + } + } + else if (type.IsInterface) + { + string doc = type.GetDocumentation(); + + if (doc is not null) + { string description = EscapeDescription(doc); - cb.AddLine($"public const string {GetSaveTypename(type)}_enum_{enumName} = @\"{description}\";\n"); + cb.AddLine($"public const string {GetSaveTypename(type)}_interface = @\"{description}\";\n"); } } } @@ -193,13 +222,17 @@ private static string ConvertSeeTagsForMethod(string doc, string formattedReturn string result = doc .Replace("
", "") .Replace("", "scaleX") + .Replace("", "IJSObjectReference") .Replace("", "ElementReference") .Replace("", "IBrowserFile") + .Replace("", "DotNetObjectReference") .Replace("", "DotNetStreamReference") .Replace("", "ValueTask") .Replace("", $"{formattedReturnSignature}") + .Replace("", "Task") + .Replace("", $"{formattedReturnSignature}") .Replace("", $"{formattedReturnSignature}") - .Replace("", "JSEventData<>") + .Replace("", "JSEventData") .Replace("", "CancellationToken"); return result; diff --git a/src/Cropper.Blazor/Cropper.Blazor.Shared/Cropper.Blazor.Shared.csproj b/src/Cropper.Blazor/Cropper.Blazor.Shared/Cropper.Blazor.Shared.csproj index e7e2ca5b..a3d5708b 100644 --- a/src/Cropper.Blazor/Cropper.Blazor.Shared/Cropper.Blazor.Shared.csproj +++ b/src/Cropper.Blazor/Cropper.Blazor.Shared/Cropper.Blazor.Shared.csproj @@ -6,7 +6,7 @@ - + diff --git a/src/Cropper.Blazor/Cropper.Blazor.Shared/Extensions/MethodInfoExtensions.cs b/src/Cropper.Blazor/Cropper.Blazor.Shared/Extensions/MethodInfoExtensions.cs index efbe0cfa..a695436d 100644 --- a/src/Cropper.Blazor/Cropper.Blazor.Shared/Extensions/MethodInfoExtensions.cs +++ b/src/Cropper.Blazor/Cropper.Blazor.Shared/Extensions/MethodInfoExtensions.cs @@ -125,7 +125,7 @@ public static string GetSignature(this MethodInfo method, bool callable = false) Culture = CultureInfo.InvariantCulture }; - public static string PresentDefaultValue(this object value) + public static string PresentDefaultValue(this object value, PropertyInfo? propertyInfo = null) { if (value is null) { @@ -158,7 +158,21 @@ public static string PresentDefaultValue(this object value) if (type.IsGenericType) // for instance event callbacks { - return ""; + if (propertyInfo == null) + { + return ""; + } + else + { + var propertyType = propertyInfo.PropertyType; + + // Default value for the property type + object? defaultValue = propertyType.IsValueType + ? Activator.CreateInstance(propertyType) + : null; + + return PresentDefaultValue(defaultValue); + } } if (type.IsValueType) @@ -214,7 +228,7 @@ public static string GetAliases(string value, Type type = null) ///
/// Type. May be generic or nullable /// Full type name, fully qualified namespaces - public static string TypeName(this Type type, Func? GenericArgumentFormatter = null) + public static string TypeName(this Type type, bool isShowGenericPart = true, Func? GenericArgumentFormatter = null) { var first = true; var nullableType = Nullable.GetUnderlyingType(type); @@ -229,37 +243,40 @@ public static string TypeName(this Type type, Func? GenericArgum return GetAliases(type.Name.ToUpperInvariant(), type); } - var stringBuilder = new StringBuilder(type.Name.Substring(0, type.Name.IndexOf('`'))); + StringBuilder stringBuilder = new(type.Name.Substring(0, type.Name.IndexOf('`'))); - if (GenericArgumentFormatter is not null) - { - stringBuilder.Append("<"); - } - else + if (isShowGenericPart) { - stringBuilder.Append('<'); - } - - foreach (var t in type.GetGenericArguments()) - { - if (!first) + if (GenericArgumentFormatter is not null) { - stringBuilder.Append(','); + stringBuilder.Append("<"); } - - string typeName = t.TypeName(); - - if (GenericArgumentFormatter is not null) + else { - typeName = GenericArgumentFormatter(typeName); + stringBuilder.Append('<'); } - stringBuilder.Append(typeName); + foreach (var t in type.GetGenericArguments()) + { + if (!first) + { + stringBuilder.Append(','); + } + + string typeName = t.TypeName(); - first = false; - } + if (GenericArgumentFormatter is not null) + { + typeName = GenericArgumentFormatter(typeName); + } - stringBuilder.Append('>'); + stringBuilder.Append(typeName); + + first = false; + } + + stringBuilder.Append('>'); + } // Return result return stringBuilder.ToString(); @@ -294,7 +311,7 @@ public static string GetFormattedReturnSignature(this Type type, bool callable = if (callable == false) { // Append return type - stringBuilder.Append(type.TypeName(CreateLink)); + stringBuilder.Append(type.TypeName(GenericArgumentFormatter: CreateLink)); stringBuilder.Append(' '); } @@ -312,6 +329,22 @@ public static string CreateLink(this string name) { return $"{name}"; } + else if (name == "IBrowserFile") + { + return $"{name}"; + } + else if (name == "ElementReference") + { + return $"{name}"; + } + else if (name == "DotNetStreamReference") + { + return $"{name}"; + } + else if (name == "DotNetObjectReference") + { + return $"{name}"; + } else if (name == "ErrorEventArgs") { return $"{name}"; @@ -320,6 +353,10 @@ public static string CreateLink(this string name) { return $"{name}"; } + else if (name == "MemoryStream") + { + return $"{name}"; + } else if (name == "IJSObjectReference") { return $"{name}"; diff --git a/src/Cropper.Blazor/Cropper.Blazor.Shared/Extensions/TypeNameHelper.cs b/src/Cropper.Blazor/Cropper.Blazor.Shared/Extensions/TypeNameHelper.cs index 1477b9bd..67b16c70 100644 --- a/src/Cropper.Blazor/Cropper.Blazor.Shared/Extensions/TypeNameHelper.cs +++ b/src/Cropper.Blazor/Cropper.Blazor.Shared/Extensions/TypeNameHelper.cs @@ -43,10 +43,10 @@ public static class TypeNameHelper /// true to print a fully qualified name. /// true to include generic parameter names. /// The pretty printed type name. - public static string GetTypeDisplay(Type type, bool fullName = true, bool includeGenericParameterNames = false) + public static string GetTypeDisplay(Type type, bool fullName = true, bool includeGenericParameterNames = false, Func? genericArgumentFormatter = null) { var builder = new StringBuilder(); - ProcessType(builder, type, new DisplayNameOptions(fullName, includeGenericParameterNames)); + ProcessType(builder, type, new DisplayNameOptions(fullName, includeGenericParameterNames, genericArgumentFormatter)); return builder.ToString(); } @@ -113,7 +113,15 @@ private static void ProcessType(StringBuilder builder, Type type, DisplayNameOpt } else { - builder.Append(options.FullName ? type.FullName ?? type.Name : type.Name); + if (options.GenericArgumentFormatter is not null) + { + string typeName = type.TypeName(); + builder.Append(options.GenericArgumentFormatter(typeName)); + } + else + { + builder.Append(options.FullName ? type.FullName ?? type.Name : type.Name); + } } } @@ -177,12 +185,24 @@ private static void ProcessGenericType(StringBuilder builder, Type type, Type[] { builder.Append(builtInName); } + else if (options.GenericArgumentFormatter is not null) + { + builder.Append(options.GenericArgumentFormatter(type.Name.Substring(0, type.Name.IndexOf('`')))); + } else { builder.Append(type.Name, 0, genericPartIndex); } - builder.Append('<'); + if (options.GenericArgumentFormatter is not null) + { + builder.Append("<"); + } + else + { + builder.Append('<'); + } + for (var i = offset; i < length; i++) { ProcessType(builder, genericArguments[i], options); @@ -202,15 +222,18 @@ private static void ProcessGenericType(StringBuilder builder, Type type, Type[] private struct DisplayNameOptions { - public DisplayNameOptions(bool fullName, bool includeGenericParameterNames) + public DisplayNameOptions(bool fullName, bool includeGenericParameterNames, Func? genericArgumentFormatter = null) { FullName = fullName; IncludeGenericParameterNames = includeGenericParameterNames; + GenericArgumentFormatter = genericArgumentFormatter; } public bool FullName { get; } public bool IncludeGenericParameterNames { get; } + + public Func? GenericArgumentFormatter { get; } } } } diff --git a/src/Cropper.Blazor/Cropper.Blazor.Testing/BunitContextExtensions.cs b/src/Cropper.Blazor/Cropper.Blazor.Testing/BunitContextExtensions.cs index 65aa61bf..33d3a975 100644 --- a/src/Cropper.Blazor/Cropper.Blazor.Testing/BunitContextExtensions.cs +++ b/src/Cropper.Blazor/Cropper.Blazor.Testing/BunitContextExtensions.cs @@ -31,5 +31,14 @@ public static IRenderedComponent GetIRenderedComponent(this TestContext te .RenderComponent(actionParameters); #endif } + + public static void DisposeTestContext(this TestContext testContext) + { +#if NET6_0 || NET7_0 + testContext.DisposeComponents(); +#endif + + testContext.Dispose(); + } } } diff --git a/src/Cropper.Blazor/Cropper.Blazor.Testing/Cropper.Blazor.Testing.csproj b/src/Cropper.Blazor/Cropper.Blazor.Testing/Cropper.Blazor.Testing.csproj index 8916ab2d..9b8d1e8a 100644 --- a/src/Cropper.Blazor/Cropper.Blazor.Testing/Cropper.Blazor.Testing.csproj +++ b/src/Cropper.Blazor/Cropper.Blazor.Testing/Cropper.Blazor.Testing.csproj @@ -22,18 +22,18 @@ - - + + - - + + - - + + diff --git a/src/Cropper.Blazor/Cropper.Blazor.UnitTests/Components/CropperComponent_Dispose_Should.cs b/src/Cropper.Blazor/Cropper.Blazor.UnitTests/Components/CropperComponent_Dispose_Should.cs new file mode 100644 index 00000000..2dfc74a3 --- /dev/null +++ b/src/Cropper.Blazor/Cropper.Blazor.UnitTests/Components/CropperComponent_Dispose_Should.cs @@ -0,0 +1,149 @@ +using System; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Bogus; +using Bunit; +using Cropper.Blazor.Components; +using Cropper.Blazor.Services; +using Cropper.Blazor.Testing; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Xunit; + +#if NET8_0_OR_GREATER +using TestContext = Bunit.BunitContext; +#endif + +namespace Cropper.Blazor.UnitTests.Components +{ + public class CropperComponent_Dispose_Should : IDisposable + { + private readonly TestContext _testContext; + private readonly Mock _mockCropperJsInterop; + + public CropperComponent_Dispose_Should() + { + _testContext = new Faker() + .Generate(); + + _mockCropperJsInterop = new Mock(); + + _testContext.Services.AddSingleton(_mockCropperJsInterop.Object); + } + + [Fact] + public void Should_Dispose_CropperComponent_After_Render() + { + // arrange + CancellationToken cancellationToken = new(); + + // act + IRenderedComponent cropperComponent = _testContext + .GetIRenderedComponent(); + + cropperComponent.Instance.Dispose(); + + // assert + Guid cropperComponentId = (Guid)cropperComponent.Instance + .GetInstanceField("CropperComponentId"); + + _mockCropperJsInterop.Verify(c => c.TryLoadModuleAsync(cancellationToken), Times.Once()); + _mockCropperJsInterop.Verify(c => c.DisposeAsync(), Times.Never()); + _mockCropperJsInterop.Verify(c => c.DestroyAsync(cropperComponentId, cancellationToken), Times.Once()); + _mockCropperJsInterop.VerifyNoOtherCalls(); + } + + [Fact] + public void Should_Dispose_CropperComponent_When_BlazorServer_And_Prerender() + { + // arrange + CancellationToken cancellationToken = new(); + + _mockCropperJsInterop + .Setup(c => c.IsBlazorServer) + .Returns(true); + + // act + IRenderedComponent cropperComponent = _testContext + .GetIRenderedComponent(); + + FieldInfo? isRenderedField = cropperComponent + .Instance + .GetType() + .GetField("IsRendered", BindingFlags.NonPublic | BindingFlags.Instance); + isRenderedField!.SetValue(cropperComponent.Instance, false); + + cropperComponent.Instance.Dispose(); + + // assert + Guid cropperComponentId = (Guid)cropperComponent.Instance + .GetInstanceField("CropperComponentId"); + + _mockCropperJsInterop.Verify(c => c.IsBlazorServer, Times.Once()); + _mockCropperJsInterop.Verify(c => c.TryLoadModuleAsync(cancellationToken), Times.Once()); + _mockCropperJsInterop.Verify(c => c.DisposeAsync(), Times.Never()); + _mockCropperJsInterop.VerifyNoOtherCalls(); + } + + [Fact] + public async Task Should_DisposeAsync_CropperComponent_After_Render() + { + // arrange + CancellationToken cancellationToken = new(); + + // act + IRenderedComponent cropperComponent = _testContext + .GetIRenderedComponent(); + + await cropperComponent.Instance.DisposeAsync(); + + // assert + Guid cropperComponentId = (Guid)cropperComponent.Instance + .GetInstanceField("CropperComponentId"); + + _mockCropperJsInterop.Verify(c => c.TryLoadModuleAsync(cancellationToken), Times.Once()); + _mockCropperJsInterop.Verify(c => c.DisposeAsync(), Times.Never()); + _mockCropperJsInterop.Verify(c => c.DestroyAsync(cropperComponentId, cancellationToken), Times.Once()); + _mockCropperJsInterop.VerifyNoOtherCalls(); + } + + [Fact] + public async Task Should_DisposeAsync_CropperComponent_When_BlazorServer_And_Prerender() + { + // arrange + CancellationToken cancellationToken = new(); + + _mockCropperJsInterop + .Setup(c => c.IsBlazorServer) + .Returns(true); + + // act + IRenderedComponent cropperComponent = _testContext + .GetIRenderedComponent(); + + FieldInfo? isRenderedField = cropperComponent + .Instance + .GetType() + .GetField("IsRendered", BindingFlags.NonPublic | BindingFlags.Instance); + isRenderedField!.SetValue(cropperComponent.Instance, false); + + await cropperComponent.Instance.DisposeAsync(); + + // assert + Guid cropperComponentId = (Guid)cropperComponent.Instance + .GetInstanceField("CropperComponentId"); + + _mockCropperJsInterop.Verify(c => c.IsBlazorServer, Times.Once()); + _mockCropperJsInterop.Verify(c => c.TryLoadModuleAsync(cancellationToken), Times.Once()); + _mockCropperJsInterop.Verify(c => c.DisposeAsync(), Times.Never()); + _mockCropperJsInterop.VerifyNoOtherCalls(); + } + + public void Dispose() + { + _testContext.DisposeTestContext(); + GC.SuppressFinalize(this); + } + } +} diff --git a/src/Cropper.Blazor/Cropper.Blazor.UnitTests/Components/CropperComponent_Should.cs b/src/Cropper.Blazor/Cropper.Blazor.UnitTests/Components/CropperComponent_Should.cs index c99e2663..64e35442 100644 --- a/src/Cropper.Blazor/Cropper.Blazor.UnitTests/Components/CropperComponent_Should.cs +++ b/src/Cropper.Blazor/Cropper.Blazor.UnitTests/Components/CropperComponent_Should.cs @@ -185,46 +185,6 @@ await func }); } - [Fact] - private void Should_Dispose_CropperComponent_After_Render() - { - // arrange - CancellationToken cancellationToken = new(); - - // act - IRenderedComponent cropperComponent = _testContext - .GetIRenderedComponent(); - - // assert - Guid cropperComponentId = (Guid)cropperComponent.Instance - .GetInstanceField("CropperComponentId"); - - cropperComponent.Instance.Dispose(); - - _mockCropperJsInterop.Verify(c => c.DisposeAsync(), Times.Never()); - _mockCropperJsInterop.Verify(c => c.DestroyAsync(cropperComponentId, cancellationToken), Times.Once()); - } - - [Fact] - private async Task Should_DisposeAsync_CropperComponent_After_Render_Async() - { - // arrange - CancellationToken cancellationToken = new(); - - // act - IRenderedComponent cropperComponent = _testContext - .GetIRenderedComponent(); - - // assert - Guid cropperComponentId = (Guid)cropperComponent.Instance - .GetInstanceField("CropperComponentId"); - - await cropperComponent.Instance.DisposeAsync(); - - _mockCropperJsInterop.Verify(c => c.DisposeAsync(), Times.Never()); - _mockCropperJsInterop.Verify(c => c.DestroyAsync(cropperComponentId, cancellationToken), Times.Once()); - } - [Fact] public async Task Should_Render_CropperComponent_From_Image_SuccessfulAsync() { @@ -1460,11 +1420,7 @@ private bool VerifyOptions(Options options) => public void Dispose() { -#if NET6_0 || NET7_0 - _testContext.DisposeComponents(); -#endif - - _testContext.Dispose(); + _testContext.DisposeTestContext(); GC.SuppressFinalize(this); } } diff --git a/src/Cropper.Blazor/Cropper.Blazor.UnitTests/Cropper.Blazor.UnitTests.csproj b/src/Cropper.Blazor/Cropper.Blazor.UnitTests/Cropper.Blazor.UnitTests.csproj index a6bb8c4c..364f585d 100644 --- a/src/Cropper.Blazor/Cropper.Blazor.UnitTests/Cropper.Blazor.UnitTests.csproj +++ b/src/Cropper.Blazor/Cropper.Blazor.UnitTests/Cropper.Blazor.UnitTests.csproj @@ -39,7 +39,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -48,7 +48,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -57,7 +57,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Cropper.Blazor/Cropper.Blazor.UnitTests/Services/BaseJsInteropService.cs b/src/Cropper.Blazor/Cropper.Blazor.UnitTests/Services/BaseJsInteropService.cs new file mode 100644 index 00000000..3e7e2d7c --- /dev/null +++ b/src/Cropper.Blazor/Cropper.Blazor.UnitTests/Services/BaseJsInteropService.cs @@ -0,0 +1,27 @@ +using Bogus; +using Bunit; + +#if NET8_0_OR_GREATER +using TestContext = Bunit.BunitContext; +#endif + +namespace Cropper.Blazor.UnitTests.Services +{ + public abstract class BaseJsInteropService + { + protected readonly TestContext _testContext; + + public BaseJsInteropService() + { + _testContext = new Faker() + .Generate(); + } + + protected void VerifyLoadCropperModule( + string pathToCropperModule) + { + _testContext.JSInterop + .SetupModule(pathToCropperModule); + } + } +} diff --git a/src/Cropper.Blazor/Cropper.Blazor.UnitTests/Services/BaseJsInteropService_Should.cs b/src/Cropper.Blazor/Cropper.Blazor.UnitTests/Services/BaseJsInteropService_Should.cs index 273188a5..d3a846d9 100644 --- a/src/Cropper.Blazor/Cropper.Blazor.UnitTests/Services/BaseJsInteropService_Should.cs +++ b/src/Cropper.Blazor/Cropper.Blazor.UnitTests/Services/BaseJsInteropService_Should.cs @@ -1,13 +1,25 @@ -using Bogus; -using Bunit; +using System.Threading; +using System.Threading.Tasks; +using System; +using Bogus; +using Cropper.Blazor.ModuleOptions; +using Cropper.Blazor.Services; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.JSInterop; +using Xunit; +using FluentAssertions; +using Cropper.Blazor.Testing; #if NET8_0_OR_GREATER using TestContext = Bunit.BunitContext; +#else +using Bunit; #endif namespace Cropper.Blazor.UnitTests.Services { - public abstract class BaseJsInteropService_Should + public class BaseJsInteropService_Should : IDisposable { protected readonly TestContext _testContext; @@ -17,11 +29,61 @@ public BaseJsInteropService_Should() .Generate(); } - protected void VerifyLoadCropperModule( - string pathToCropperModule) + [Fact] + public void IsBlazorServer_Should_Return_True_For_RemoteJSRuntime() + { + // Arrange + RemoteJSRuntime jsRuntime = new RemoteJSRuntime(); + NavigationManager navigation = _testContext.Services.GetRequiredService(); + ICropperJsInteropOptions options = new Faker().Generate(); + TestBaseJsInterop service = new TestBaseJsInterop(jsRuntime, navigation, options); + + // Act + bool result = service.IsBlazorServer; + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void IsBlazorServer_Should_Return_False_For_OtherJSRuntime() + { + // Arrange + NavigationManager navigation = _testContext.Services.GetRequiredService(); + ICropperJsInteropOptions options = new Faker().Generate(); + TestBaseJsInterop service = new TestBaseJsInterop(_testContext.JSInterop.JSRuntime, navigation, options); + + // Act + bool result = service.IsBlazorServer; + + // Assert + result.Should().BeFalse(); + } + + public void Dispose() + { + _testContext.DisposeTestContext(); + GC.SuppressFinalize(this); + } + + private class TestBaseJsInterop : BaseJsInterop { - _testContext.JSInterop - .SetupModule(pathToCropperModule); + public TestBaseJsInterop( + IJSRuntime jsRuntime, + NavigationManager navigationManager, + ICropperJsInteropOptions cropperJsInteropOptions) : base(jsRuntime, navigationManager, cropperJsInteropOptions) + { + + } + } + + private class RemoteJSRuntime : IJSRuntime + { + public ValueTask InvokeAsync(string identifier, object?[]? args) => + throw new NotImplementedException(); + + public ValueTask InvokeAsync(string identifier, CancellationToken cancellationToken, object?[]? args) => + throw new NotImplementedException(); } } } diff --git a/src/Cropper.Blazor/Cropper.Blazor.UnitTests/Services/CropperJsInterop_Should.cs b/src/Cropper.Blazor/Cropper.Blazor.UnitTests/Services/CropperJsInterop_Should.cs index acda6a21..3a58b629 100644 --- a/src/Cropper.Blazor/Cropper.Blazor.UnitTests/Services/CropperJsInterop_Should.cs +++ b/src/Cropper.Blazor/Cropper.Blazor.UnitTests/Services/CropperJsInterop_Should.cs @@ -26,7 +26,7 @@ namespace Cropper.Blazor.UnitTests.Services { - public class CropperJsInterop_Should : BaseJsInteropService_Should, IDisposable + public class CropperJsInterop_Should : BaseJsInteropService, IDisposable { private readonly Faker _faker; private readonly ICropperJsInterop _cropperJsInterop; diff --git a/src/Cropper.Blazor/Cropper.Blazor.UnitTests/Services/UrlImageInterop_Should.cs b/src/Cropper.Blazor/Cropper.Blazor.UnitTests/Services/UrlImageInterop_Should.cs index bb91713b..17f3c90f 100644 --- a/src/Cropper.Blazor/Cropper.Blazor.UnitTests/Services/UrlImageInterop_Should.cs +++ b/src/Cropper.Blazor/Cropper.Blazor.UnitTests/Services/UrlImageInterop_Should.cs @@ -22,7 +22,7 @@ namespace Cropper.Blazor.UnitTests.Services { - public class UrlImageInterop_Should : BaseJsInteropService_Should, IDisposable + public class UrlImageInterop_Should : BaseJsInteropService, IDisposable { private readonly Faker _faker; private readonly IUrlImageInterop _urlImageInterop; diff --git a/src/Cropper.Blazor/Cropper.Blazor.sln b/src/Cropper.Blazor/Cropper.Blazor.sln index 79ea63f1..b1a1ff5b 100644 --- a/src/Cropper.Blazor/Cropper.Blazor.sln +++ b/src/Cropper.Blazor/Cropper.Blazor.sln @@ -19,6 +19,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution ProjectSection(SolutionItems) = preProject ..\..\.editorconfig = ..\..\.editorconfig ..\..\.gitattributes = ..\..\.gitattributes + ..\..\.gitignore = ..\..\.gitignore ..\..\.github\codecov.yml = ..\..\.github\codecov.yml ..\..\.github\CODE_OF_CONDUCT.md = ..\..\.github\CODE_OF_CONDUCT.md ..\..\.github\PULL_REQUEST_TEMPLATE.md = ..\..\.github\PULL_REQUEST_TEMPLATE.md diff --git a/src/Cropper.Blazor/Cropper.Blazor/.config/dotnet-tools.json b/src/Cropper.Blazor/Cropper.Blazor/.config/dotnet-tools.json deleted file mode 100644 index 2c41bf42..00000000 --- a/src/Cropper.Blazor/Cropper.Blazor/.config/dotnet-tools.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "version": 1, - "isRoot": true, - "tools": { - "excubo.webcompiler": { - "version": "4.2.1", - "commands": ["webcompiler"] - } - } -} diff --git a/src/Cropper.Blazor/Cropper.Blazor/.prettierrc b/src/Cropper.Blazor/Cropper.Blazor/.prettierrc new file mode 100644 index 00000000..d037f71d --- /dev/null +++ b/src/Cropper.Blazor/Cropper.Blazor/.prettierrc @@ -0,0 +1,9 @@ +{ + "singleQuote": false, + "semi": true, + "trailingComma": "all", + "printWidth": 100, + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "auto" +} diff --git a/src/Cropper.Blazor/Cropper.Blazor/Components/CroppedCanvasReceiver.cs b/src/Cropper.Blazor/Cropper.Blazor/Components/CroppedCanvasReceiver.cs index 58e4f70c..2f2dbc4a 100644 --- a/src/Cropper.Blazor/Cropper.Blazor/Components/CroppedCanvasReceiver.cs +++ b/src/Cropper.Blazor/Cropper.Blazor/Components/CroppedCanvasReceiver.cs @@ -36,7 +36,7 @@ public CroppedCanvasReceiver( /// /// Receives a cropped canvas reference from JavaScript. /// - /// The cropped canvas reference. + /// The used to reference the cropped canvas in JavaScript. [JSInvokable("ReceiveCanvasReference")] public void ReceiveCanvasReference(IJSObjectReference jsRuntimeObjectRef) { diff --git a/src/Cropper.Blazor/Cropper.Blazor/Components/CropperComponent.razor.Commands.cs b/src/Cropper.Blazor/Cropper.Blazor/Components/CropperComponent.razor.Commands.cs index 75c144ae..400b3432 100644 --- a/src/Cropper.Blazor/Cropper.Blazor/Components/CropperComponent.razor.Commands.cs +++ b/src/Cropper.Blazor/Cropper.Blazor/Components/CropperComponent.razor.Commands.cs @@ -8,9 +8,6 @@ namespace Cropper.Blazor.Components { - /// - /// The cropper component. - /// public partial class CropperComponent { /// diff --git a/src/Cropper.Blazor/Cropper.Blazor/Components/CropperComponent.razor.Events.cs b/src/Cropper.Blazor/Cropper.Blazor/Components/CropperComponent.razor.Events.cs index a2be4f2b..3da38a16 100644 --- a/src/Cropper.Blazor/Cropper.Blazor/Components/CropperComponent.razor.Events.cs +++ b/src/Cropper.Blazor/Cropper.Blazor/Components/CropperComponent.razor.Events.cs @@ -12,9 +12,6 @@ namespace Cropper.Blazor.Components { - /// - /// The cropper component. - /// public partial class CropperComponent { /// @@ -96,7 +93,10 @@ public void OnErrorLoadImage(ErrorEventArgs errorEventArgs) /// /// This event fires when the canvas (image wrapper) or the crop box changes. /// - /// The . + /// + /// The containing data of the underlying + /// JavaScript crop event. + /// [JSInvokable("CropperIsCroped")] public void CropperIsCroped(JSEventData jSEventData) { @@ -106,7 +106,10 @@ public void CropperIsCroped(JSEventData jSEventData) /// /// This event fires when the canvas (image wrapper) or the crop box stops changing. /// - /// The . + /// + /// The containing data of the underlying + /// JavaScript cropend event. + /// [JSInvokable("CropperIsEnded")] public void CropperIsEnded(JSEventData jSEventData) { @@ -116,7 +119,10 @@ public void CropperIsEnded(JSEventData jSEventData) /// /// This event fires when the canvas (image wrapper) or the crop box is changing. /// - /// The . + /// + /// The containing data of the underlying + /// JavaScript cropmove event. + /// [JSInvokable("CropperIsMoved")] public void CropperIsMoved(JSEventData jSEventData) { @@ -126,7 +132,10 @@ public void CropperIsMoved(JSEventData jSEventData) /// /// This event fires when the canvas (image wrapper) or the crop box starts to change. /// - /// The . + /// + /// The containing data of the underlying + /// JavaScript cropstart event. + /// [JSInvokable("CropperIsStarted")] public void CropperIsStarted(JSEventData jSEventData) { @@ -136,7 +145,10 @@ public void CropperIsStarted(JSEventData jSEventData) /// /// This event fires when a cropper instance starts to zoom in or zoom out its canvas (image wrapper). /// - /// The . + /// + /// The containing data of the underlying + /// JavaScript zoom event. + /// [JSInvokable("CropperIsZoomed")] public void CropperIsZoomed(JSEventData jSEventData) { @@ -146,7 +158,10 @@ public void CropperIsZoomed(JSEventData jSEventData) /// /// This event fires when the target image has been loaded and the cropper instance is ready for operating. /// - /// The . + /// + /// The containing the data of the + /// underlying JavaScript ready event. + /// [JSInvokable] public void IsReady(JSEventData jSEventData) { diff --git a/src/Cropper.Blazor/Cropper.Blazor/Components/CropperComponent.razor.Queries.cs b/src/Cropper.Blazor/Cropper.Blazor/Components/CropperComponent.razor.Queries.cs index f6bf0b60..826444e1 100644 --- a/src/Cropper.Blazor/Cropper.Blazor/Components/CropperComponent.razor.Queries.cs +++ b/src/Cropper.Blazor/Cropper.Blazor/Components/CropperComponent.razor.Queries.cs @@ -8,9 +8,6 @@ namespace Cropper.Blazor.Components { - /// - /// The cropper component. - /// public partial class CropperComponent { /// diff --git a/src/Cropper.Blazor/Cropper.Blazor/Components/CropperComponent.razor.cs b/src/Cropper.Blazor/Cropper.Blazor/Components/CropperComponent.razor.cs index 6d8b9385..cdd076c9 100644 --- a/src/Cropper.Blazor/Cropper.Blazor/Components/CropperComponent.razor.cs +++ b/src/Cropper.Blazor/Cropper.Blazor/Components/CropperComponent.razor.cs @@ -9,7 +9,8 @@ namespace Cropper.Blazor.Components { /// - /// The cropper component. + /// A Blazor component that provides image and canvas cropping functionality + /// via JavaScript interop, wrapping the underlying Cropper.js behavior. /// public partial class CropperComponent : ICropperComponentBase, IAsyncDisposable, IDisposable { @@ -96,6 +97,8 @@ public partial class CropperComponent : ICropperComponentBase, IAsyncDisposable, [Parameter(CaptureUnmatchedValues = true)] public Dictionary InputAttributes { get; set; } = null!; + private bool IsRendered = false; + /// /// Method invoked after each time the component has been rendered. Note that the component does /// not automatically re-render after the completion of any returned , because @@ -116,6 +119,8 @@ protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { + IsRendered = true; + await CropperJsIntertop!.TryLoadModuleAsync(); } @@ -136,6 +141,11 @@ protected override void OnInitialized() /// A representing any asynchronous operation. public async ValueTask DisposeAsync() { + if (!IsRendered && CropperJsIntertop.IsBlazorServer) + { + return; + } + ElementReference? cropperElementReference = GetCropperElementReference(); if (cropperElementReference.HasValue) @@ -149,6 +159,11 @@ public async ValueTask DisposeAsync() /// public void Dispose() { + if (!IsRendered && CropperJsIntertop.IsBlazorServer) + { + return; + } + ElementReference? cropperElementReference = GetCropperElementReference(); if (cropperElementReference.HasValue) diff --git a/src/Cropper.Blazor/Cropper.Blazor/Cropper.Blazor.csproj b/src/Cropper.Blazor/Cropper.Blazor/Cropper.Blazor.csproj index 925abadc..e71f18cc 100644 --- a/src/Cropper.Blazor/Cropper.Blazor/Cropper.Blazor.csproj +++ b/src/Cropper.Blazor/Cropper.Blazor/Cropper.Blazor.csproj @@ -1,12 +1,4 @@  - - - - IncludeGeneratedStaticFiles; - $(ResolveStaticWebAssetsInputsDependsOn) - - - net10.0;net9.0;net8.0;net7.0;net6.0 disable @@ -14,11 +6,11 @@ - 1.5.0 + 1.5.1 LICENSE NuGet.png Cropper.Blazor - Max56132, ColdForeign + @MaxymGorn (Maksym Hornytskiy), @ColdForeign (George Radchuk) Copyright 2022-present Cropper.Blazor Cropper.Blazor is a component that wraps around Cropper.js Blazor, Cropper.Blazor, Cropper.js, Blazor Components, Blazor Library, Blazor Cropper, Cropper, Image, Crop, Resize, image-cropper, crop-image, csharp, blazor-cropper @@ -80,47 +72,105 @@ - - - - false - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + - + + - - - + + - - - - + + + + false + - - - - - + - - - + + + + + + + + + + + + - - - - - @@ -130,15 +180,19 @@ - + - + - + + + + + diff --git a/src/Cropper.Blazor/Cropper.Blazor/Cropper/cropperJsInterop.test.ts b/src/Cropper.Blazor/Cropper.Blazor/Cropper/cropperJsInterop.test.ts new file mode 100644 index 00000000..3921a007 --- /dev/null +++ b/src/Cropper.Blazor/Cropper.Blazor/Cropper/cropperJsInterop.test.ts @@ -0,0 +1,9 @@ +import { describe, it, expect } from "vitest"; +import { CropperDecorator } from "./cropperJsInterop"; + +describe("CropperDecorator", () => { + it("should be instantiable", () => { + const instance = new CropperDecorator(); + expect(instance).toBeInstanceOf(CropperDecorator); + }); +}); diff --git a/src/Cropper.Blazor/Cropper.Blazor/Cropper/cropperJsInterop.ts b/src/Cropper.Blazor/Cropper.Blazor/Cropper/cropperJsInterop.ts new file mode 100644 index 00000000..52592b0a --- /dev/null +++ b/src/Cropper.Blazor/Cropper.Blazor/Cropper/cropperJsInterop.ts @@ -0,0 +1,370 @@ +import Cropper from "cropperjs"; +import type { CropperBlazor as CropperComponentBaseTypes } from "./types/components/cropper-component-base.custom"; +import type { CropperBlazor as CroppedCanvasReceiverTypes } from "./types/components/cropped-canvas-receiver.custom"; +import type { CropperBlazor as ImageReceiverTypes } from "./types/components/image-receiver.custom"; +import type { CropperBlazor as DataEventTypes } from "./types/data/cropper-event-data"; +import type { CropperBlazor as DataOptionsTypes } from "./types/data/cropper-extended-options"; +import type { CropperBlazor as DotNetTypes } from "./types/global/dotnet-global.custom"; +import { CropperBlazor as BlobHelper } from "./helpers/blob-helper"; +import { CropperBlazor as UrlImageHelper } from "./helpers/cropper-url-image-helper"; + +declare global { + interface Window { + cropper: CropperDecorator; + cropperUrlImageHelper: UrlImageHelper.Helpers.CropperUrlImageHelper; + } +} + +declare const DotNet: DotNetTypes.Global.DotNetNamespace; + +type CropperId = string; + +export class CropperDecorator { + private cropperInstances: Record = {}; + + clear(id: CropperId) { + return this.cropperInstances[id].clear(); + } + + crop(id: CropperId) { + return this.cropperInstances[id].crop(); + } + + destroy(id: CropperId) { + const instance: Cropper = this.cropperInstances[id]; + + if (instance) { + instance.destroy(); + + delete this.cropperInstances[id]; + } + } + + disable(id: CropperId) { + return this.cropperInstances[id].disable(); + } + + enable(id: CropperId) { + return this.cropperInstances[id].enable(); + } + + getCanvasData(id: CropperId): Cropper.CanvasData { + return this.cropperInstances[id].getCanvasData(); + } + + getContainerData(id: CropperId): Cropper.ContainerData { + return this.cropperInstances[id].getContainerData(); + } + + getCropBoxData(id: CropperId) { + return this.cropperInstances[id].getCropBoxData(); + } + + getCroppedCanvas(id: CropperId, options: Cropper.GetCroppedCanvasOptions) { + options.maxWidth ??= Infinity; + options.maxHeight ??= Infinity; + + return this.cropperInstances[id].getCroppedCanvas(options); + } + + async getCroppedCanvasInBackground( + id: CropperId, + options: Cropper.GetCroppedCanvasOptions, + dotNetCanvasReceiverRef: DotNetTypes.Global.DotNetObjectReference, + ) { + setTimeout(async () => { + const canvas: HTMLCanvasElement = this.getCroppedCanvas(id, options); + const jsRef: DotNetTypes.Global.JsObjectReference = DotNet.createJSObjectReference(canvas); + + await dotNetCanvasReceiverRef.invokeMethodAsync("ReceiveCanvasReference", jsRef); + }, 0); + } + + getCroppedCanvasDataURL( + id: CropperId, + options: Cropper.GetCroppedCanvasOptions, + type?: string, + encoderOptions?: number, + ) { + options.maxWidth ??= Infinity; + options.maxHeight ??= Infinity; + + return this.cropperInstances[id].getCroppedCanvas(options).toDataURL(type, encoderOptions); + } + + getData(id: CropperId, rounded?: boolean): Cropper.Data { + return this.cropperInstances[id].getData(rounded); + } + + getImageData(id: CropperId): Cropper.ImageData { + return this.cropperInstances[id].getImageData(); + } + + move(id: CropperId, offsetX: number, offsetY: number) { + return this.cropperInstances[id].move(offsetX, offsetY); + } + + moveTo(id: CropperId, x: number, y: number) { + return this.cropperInstances[id].moveTo(x, y); + } + + replace(id: CropperId, url: string, onlyColorChanged?: boolean) { + return this.cropperInstances[id].replace(url, onlyColorChanged); + } + + reset(id: CropperId) { + return this.cropperInstances[id].reset(); + } + + rotate(id: CropperId, degree: number) { + return this.cropperInstances[id].rotate(degree); + } + + rotateTo(id: CropperId, degree: number) { + return this.cropperInstances[id].rotateTo(degree); + } + + scale(id: CropperId, x: number, y: number) { + return this.cropperInstances[id].scale(x, y); + } + + scaleX(id: CropperId, x: number) { + return this.cropperInstances[id].scaleX(x); + } + + scaleY(id: CropperId, y: number) { + return this.cropperInstances[id].scaleY(y); + } + + setAspectRatio(id: CropperId, ratio: number) { + return this.cropperInstances[id].setAspectRatio(ratio); + } + + setCanvasData(id: CropperId, data: Cropper.CanvasData) { + return this.cropperInstances[id].setCanvasData(data); + } + + setCropBoxData(id: CropperId, data: Cropper.SetCropBoxDataOptions) { + return this.cropperInstances[id].setCropBoxData(data); + } + + setData(id: CropperId, data: Cropper.Data) { + return this.cropperInstances[id].setData(data); + } + + setDragMode(id: CropperId, mode: Cropper.DragMode) { + return this.cropperInstances[id].setDragMode(mode); + } + + zoom(id: CropperId, ratio: number) { + return this.cropperInstances[id].zoom(ratio); + } + + zoomTo(id: CropperId, ratio: number, pivotX: number, pivotY: number) { + return this.cropperInstances[id].zoomTo(ratio, { x: pivotX, y: pivotY }); + } + + noConflict() { + return Cropper.noConflict(); + } + + setDefaults(options: Cropper.Options) { + Cropper.setDefaults(options); + } + + // -------------------------- + // Event serialization helpers + // -------------------------- + + getJSEventData( + instance: + | Cropper.CropperEvent + | Cropper.CropEvent + | Cropper.CropStartEvent + | Cropper.CropMoveEvent + | Cropper.CropEndEvent, + correlationId: string | undefined, + ): DataEventTypes.Data.CropperJSEventData { + return { + isTrusted: instance.isTrusted, + detail: this.getJSEventDataDetail(instance), + type: instance.type, + eventPhase: instance.eventPhase, + bubbles: instance.bubbles, + cancelable: instance.cancelable, + defaultPrevented: instance.defaultPrevented, + composed: instance.composed, + timeStamp: instance.timeStamp, + returnValue: instance.returnValue, + cancelBubble: instance.cancelBubble, + correlationId, + }; + } + + getJSEventDataDetail( + instance: + | Cropper.CropperEvent + | Cropper.CropEvent + | Cropper.CropStartEvent + | Cropper.CropMoveEvent + | Cropper.CropEndEvent, + ): DataEventTypes.Data.CropperEventDataJS { + if (instance.type === "zoom") { + const zoomEventData: DataEventTypes.Data.CropperEventDataJS = { + oldRatio: instance.detail.oldRatio, + ratio: instance.detail.ratio, + originalEvent: instance.detail.originalEvent + ? DotNet.createJSObjectReference(instance.detail.originalEvent) + : null, + }; + + return zoomEventData; + } else if (["cropstart", "cropend", "cropmove"].includes(instance.type)) { + const cropEventData: DataEventTypes.Data.CropperEventDataJS = { + action: instance.detail.action, + originalEvent: instance.detail.originalEvent + ? DotNet.createJSObjectReference(instance.detail.originalEvent) + : null, + }; + + return cropEventData; + } + + return instance.detail; + } + + onReady( + imageObject: DotNetTypes.Global.DotNetObjectReference, + e: Cropper.ReadyEvent, + id: string | undefined, + ) { + imageObject.invokeMethodAsync("IsReady", this.getJSEventData(e, id)); + } + + onCropStart( + imageObject: DotNetTypes.Global.DotNetObjectReference, + e: Cropper.CropStartEvent, + id: string | undefined, + ) { + imageObject.invokeMethodAsync("CropperIsStarted", this.getJSEventData(e, id)); + } + + onCropMove( + imageObject: DotNetTypes.Global.DotNetObjectReference, + e: Cropper.CropMoveEvent, + id: string | undefined, + ) { + imageObject.invokeMethodAsync("CropperIsMoved", this.getJSEventData(e, id)); + } + + onCropEnd( + imageObject: DotNetTypes.Global.DotNetObjectReference, + e: Cropper.CropEndEvent, + id: string | undefined, + ) { + imageObject.invokeMethodAsync("CropperIsEnded", this.getJSEventData(e, id)); + } + + onCrop( + imageObject: DotNetTypes.Global.DotNetObjectReference, + e: Cropper.CropEvent, + id: string | undefined, + ) { + imageObject.invokeMethodAsync("CropperIsCroped", this.getJSEventData(e, id)); + } + + onZoom( + imageObject: DotNetTypes.Global.DotNetObjectReference, + e: Cropper.ZoomEvent, + id: string | undefined, + ) { + imageObject.invokeMethodAsync("CropperIsZoomed", this.getJSEventData(e, id)); + } + + initCropper( + id: CropperId, + image: HTMLImageElement | HTMLCanvasElement, + optionsImage: DataOptionsTypes.Data.CropperExtendedOptions, + imageObject?: DotNetTypes.Global.DotNetObjectReference, + ) { + if (!image) throw new Error("Parameter 'image' must not be null"); + if (!optionsImage) throw new Error("Parameter 'optionsImage' must not be null"); + + const options: Cropper.Options | Cropper.Options = {}; + const correlationId: string | undefined = optionsImage.correlationId; + + if (imageObject) { + options.ready = (e: Cropper.ReadyEvent) => + this.onReady(imageObject, e, correlationId); + options.cropstart = (e: Cropper.CropStartEvent) => + this.onCropStart(imageObject, e, correlationId); + options.cropmove = (e: Cropper.CropMoveEvent) => + this.onCropMove(imageObject, e, correlationId); + options.cropend = (e: Cropper.CropEndEvent) => + this.onCropEnd(imageObject, e, correlationId); + options.crop = (e: Cropper.CropEvent) => + this.onCrop(imageObject, e, correlationId); + options.zoom = (e: Cropper.ZoomEvent) => + this.onZoom(imageObject, e, correlationId); + } + + Object.assign(options, optionsImage); + + if (image instanceof HTMLImageElement) { + const cropper: Cropper = new Cropper(image, options as Cropper.Options); + this.cropperInstances[id] = cropper; + } else if (image instanceof HTMLCanvasElement) { + const cropper: Cropper = new Cropper(image, options as Cropper.Options); + this.cropperInstances[id] = cropper; + } else { + throw new Error( + `Unsupported element type for Cropper: ${Object.prototype.toString.call(image)}`, + ); + } + } + + // -------------------------- + // Chunked Blob Streaming + // -------------------------- + + async readBlobInChunks( + blob: Blob | null, + dotNetImageReceiverRef: DotNetTypes.Global.DotNetObjectReference, + maximumReceiveChunkSize?: number, + ) { + await BlobHelper.Helpers.readBlobInChunks( + blob, + dotNetImageReceiverRef, + maximumReceiveChunkSize, + ); + } + + sendImageInChunks( + cropperComponentId: CropperId, + options: Cropper.GetCroppedCanvasOptions, + dotNetImageReceiverRef: DotNetTypes.Global.DotNetObjectReference, + type?: string, + encoderOptions?: number, + maximumReceiveChunkSize?: number, + ) { + options.maxWidth ??= Infinity; + options.maxHeight ??= Infinity; + + const cropperInstance: Cropper = this.cropperInstances[cropperComponentId]; + + setTimeout(() => { + cropperInstance.getCroppedCanvas(options).toBlob( + async (blob) => { + await this.readBlobInChunks(blob, dotNetImageReceiverRef, maximumReceiveChunkSize); + }, + type, + encoderOptions, + ); + }, 0); + } +} + +if (typeof window !== "undefined") { + window.cropper = new CropperDecorator(); + window.cropperUrlImageHelper = new UrlImageHelper.Helpers.CropperUrlImageHelper(); +} diff --git a/src/Cropper.Blazor/Cropper.Blazor/Cropper/helpers/blob-helper.ts b/src/Cropper.Blazor/Cropper.Blazor/Cropper/helpers/blob-helper.ts new file mode 100644 index 00000000..1f7a8443 --- /dev/null +++ b/src/Cropper.Blazor/Cropper.Blazor/Cropper/helpers/blob-helper.ts @@ -0,0 +1,117 @@ +import type { CropperBlazor as ImageReceiverTypes } from "../types/components/image-receiver.custom"; +import type { CropperBlazor as DotNetTypes } from "../types/global/dotnet-global.custom"; + +// -------------------------- +// Chunked Blob Streaming +// Provides a utility to read a Blob in chunks and stream it to a .NET receiver +// via JS interop. Supports optional maximum chunk size to respect message limits. +// Useful for Blazor interop, SignalR, or other large file transfers. +// -------------------------- +export namespace CropperBlazor.Helpers { + export async function readBlobInChunks( + blob: Blob | null, + dotNetImageReceiverRef: DotNetTypes.Global.DotNetObjectReference | null, + maximumReceiveChunkSize?: number, + ) { + // Validate blob + if (!(blob instanceof Blob)) { + throw new TypeError("blob must be a valid Blob object."); + } + + // Validate dotNetImageReceiverRef + if (!dotNetImageReceiverRef || typeof dotNetImageReceiverRef.invokeMethodAsync !== "function") { + throw new TypeError( + "dotNetImageReceiverRef must be a valid .NET object reference with an invokeMethodAsync function.", + ); + } + + // Validate maximumReceiveChunkSize + if (maximumReceiveChunkSize != null && maximumReceiveChunkSize <= 0) { + throw new RangeError("maximumReceiveChunkSize must be greater than 0 bytes when specified."); + } + + // By default, blob.stream() reads the blob using internal chunking (typically 65536 bytes per chunk). + // To enforce a custom chunk size, especially to control serialized message size for JS interop or SignalR limits, we wrap it in a transformed ReadableStream. + // This allows us to split the default chunks further to stay within a maximum size constraint (e.g., for Blazor's JS interop or SignalR message limits). + let reader: ReadableStreamDefaultReader | null = null; + + if (maximumReceiveChunkSize == null) { + reader = blob.stream().getReader(); + } else { + const blobStream: ReadableStreamDefaultReader = blob.stream().getReader(); + + // Binary estimation of JSON size + const getJsonSizeBinary = (chunk: Uint8Array) => { + const length: number = chunk.length; + + // Max 3 digits for the number (0 to 255) + const bytesPerElement = 3; + // Comma between elements + const commas: number = length - 1; + // For '[' and ']' + const brackets = 2; + + return length * bytesPerElement + commas + brackets; + }; + + // Create a custom stream that enforces max chunk size + const transformedStream = new ReadableStream({ + async pull(controller) { + const { done, value } = await blobStream.read(); + + if (done) { + controller.close(); + + return; + } + + // Function to calculate JSON size for the current chunk using binary estimation + let offset = 0; + let lastGoodChunkSize = maximumReceiveChunkSize; + + while (offset < value.length) { + // Start with the last known good chunk size, or the remaining length + let chunkSize: number = Math.min(lastGoodChunkSize, value.length - offset); + let chunk: Uint8Array = value.slice(offset, offset + chunkSize); + let jsonSize: number = getJsonSizeBinary(chunk); + + // If the JSON size is too large, reduce the chunk size gradually + while (jsonSize > maximumReceiveChunkSize && chunkSize > 1) { + // Reduce the chunk size in steps of 512 bytes, but not below 1 byte + chunkSize = Math.max(chunkSize - 512, 1); + chunk = value.slice(offset, offset + chunkSize); + jsonSize = getJsonSizeBinary(chunk); + + // Stop reducing if the chunk size is already very small + if (chunkSize <= 512) { + break; + } + } + + // Move the offset forward by the size of the chunk just sent with update the last good chunk size + lastGoodChunkSize = chunkSize; + + offset += chunkSize; + + controller.enqueue(chunk); + } + }, + }); + + reader = transformedStream.getReader(); + } + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + await dotNetImageReceiverRef.invokeMethodAsync("ReceiveImageChunk", value); + } + + await dotNetImageReceiverRef.invokeMethodAsync("CompleteImageTransfer"); + } catch (error) { + await dotNetImageReceiverRef.invokeMethodAsync("HandleImageProcessingError", String(error)); + } + } +} diff --git a/src/Cropper.Blazor/Cropper.Blazor/Cropper/helpers/cropper-url-image-helper.test.ts b/src/Cropper.Blazor/Cropper.Blazor/Cropper/helpers/cropper-url-image-helper.test.ts new file mode 100644 index 00000000..2a4b1d59 --- /dev/null +++ b/src/Cropper.Blazor/Cropper.Blazor/Cropper/helpers/cropper-url-image-helper.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { CropperBlazor } from "./cropper-url-image-helper"; + +// Minimal mock of DotNetStreamReference +class MockDotNetStreamReference { + constructor(private data: ArrayBuffer) {} + + async arrayBuffer(): Promise { + return this.data; + } +} + +describe("CropperUrlImageHelper", () => { + const mockObjectUrl = "blob:http://localhost/mock-url"; + + beforeEach(() => { + vi.spyOn(URL, "createObjectURL").mockReturnValue(mockObjectUrl); + vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should create object URL from streamed image", async () => { + const buffer = new Uint8Array([1, 2, 3]).buffer; + const stream = new MockDotNetStreamReference(buffer) as any; + + const result = await new CropperBlazor.Helpers.CropperUrlImageHelper().getImageUsingStreaming( + stream, + ); + + expect(result).toBe(mockObjectUrl); + expect(URL.createObjectURL).toHaveBeenCalledOnce(); + expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob)); + }); + + it("should revoke object URL", () => { + new CropperBlazor.Helpers.CropperUrlImageHelper().revokeObjectUrl(mockObjectUrl); + + expect(URL.revokeObjectURL).toHaveBeenCalledOnce(); + expect(URL.revokeObjectURL).toHaveBeenCalledWith(mockObjectUrl); + }); +}); diff --git a/src/Cropper.Blazor/Cropper.Blazor/Cropper/helpers/cropper-url-image-helper.ts b/src/Cropper.Blazor/Cropper.Blazor/Cropper/helpers/cropper-url-image-helper.ts new file mode 100644 index 00000000..3e11d08c --- /dev/null +++ b/src/Cropper.Blazor/Cropper.Blazor/Cropper/helpers/cropper-url-image-helper.ts @@ -0,0 +1,18 @@ +import type { CropperBlazor as DotNetTypes } from "../types/global/dotnet-global.custom"; + +export namespace CropperBlazor.Helpers { + export class CropperUrlImageHelper { + async getImageUsingStreaming( + imageStream: DotNetTypes.Global.DotNetStreamReference, + ): Promise { + const buf: ArrayBuffer = await imageStream.arrayBuffer(); + const blob: Blob = new Blob([buf]); + + return URL.createObjectURL(blob); + } + + revokeObjectUrl(url: string) { + URL.revokeObjectURL(url); + } + } +} diff --git a/src/Cropper.Blazor/Cropper.Blazor/Cropper/types/components/cropped-canvas-receiver.custom.d.ts b/src/Cropper.Blazor/Cropper.Blazor/Cropper/types/components/cropped-canvas-receiver.custom.d.ts new file mode 100644 index 00000000..d2fe2c24 --- /dev/null +++ b/src/Cropper.Blazor/Cropper.Blazor/Cropper/types/components/cropped-canvas-receiver.custom.d.ts @@ -0,0 +1,7 @@ +export namespace CropperBlazor.Components { + /** Represents a receiver for a cropped canvas streamed from JS to .NET */ + export interface CroppedCanvasReceiver { + /** Called when a cropped canvas is received from JS */ + ReceiveCanvasReference(canvasRef: HTMLCanvasElement): void; + } +} diff --git a/src/Cropper.Blazor/Cropper.Blazor/Cropper/types/components/cropper-component-base.custom.d.ts b/src/Cropper.Blazor/Cropper.Blazor/Cropper/types/components/cropper-component-base.custom.d.ts new file mode 100644 index 00000000..0f3b5547 --- /dev/null +++ b/src/Cropper.Blazor/Cropper.Blazor/Cropper/types/components/cropper-component-base.custom.d.ts @@ -0,0 +1,24 @@ +import Cropper from "cropperjs"; + +export namespace CropperBlazor.Components { + /** Represents the C# cropper component methods callable from JS */ + export interface ICropperComponentBase { + /** Called when the cropper is ready */ + IsReady(eventData: Cropper.CropEventData | Cropper.ZoomEventData): void; + + /** Called when cropping starts */ + CropperIsStarted(eventData: Cropper.CropEventData): void; + + /** Called when cropping is moving */ + CropperIsMoved(eventData: Cropper.CropEventData): void; + + /** Called when cropping ends */ + CropperIsEnded(eventData: Cropper.CropEventData): void; + + /** Called on crop event */ + CropperIsCroped(eventData: Cropper.CropEventData): void; + + /** Called on zoom event */ + CropperIsZoomed(eventData: Cropper.ZoomEventData): void; + } +} diff --git a/src/Cropper.Blazor/Cropper.Blazor/Cropper/types/components/image-receiver.custom.d.ts b/src/Cropper.Blazor/Cropper.Blazor/Cropper/types/components/image-receiver.custom.d.ts new file mode 100644 index 00000000..eea532ad --- /dev/null +++ b/src/Cropper.Blazor/Cropper.Blazor/Cropper/types/components/image-receiver.custom.d.ts @@ -0,0 +1,13 @@ +export namespace CropperBlazor.Components { + /** Represents a receiver for image data streamed from JS to .NET */ + export interface ImageReceiver { + /** Called when an error occurs during image processing */ + HandleImageProcessingError(errorMessage: string): void; + + /** Called to receive a chunk of image data from JS */ + ReceiveImageChunk(chunk: Uint8Array): Promise; + + /** Called when the image transfer is complete */ + CompleteImageTransfer(): void; + } +} diff --git a/src/Cropper.Blazor/Cropper.Blazor/Cropper/types/data/crop-event-data.ts b/src/Cropper.Blazor/Cropper.Blazor/Cropper/types/data/crop-event-data.ts new file mode 100644 index 00000000..a6eef919 --- /dev/null +++ b/src/Cropper.Blazor/Cropper.Blazor/Cropper/types/data/crop-event-data.ts @@ -0,0 +1,8 @@ +import type { CropperBlazor as DotNetTypes } from "../../types/global/dotnet-global.custom"; + +export namespace CropperBlazor.Data { + export type CropEventDataJS = { + action: string; + originalEvent: DotNetTypes.Global.JsObjectReference | null; + }; +} diff --git a/src/Cropper.Blazor/Cropper.Blazor/Cropper/types/data/cropper-event-data.ts b/src/Cropper.Blazor/Cropper.Blazor/Cropper/types/data/cropper-event-data.ts new file mode 100644 index 00000000..4db5628e --- /dev/null +++ b/src/Cropper.Blazor/Cropper.Blazor/Cropper/types/data/cropper-event-data.ts @@ -0,0 +1,23 @@ +import type { CropperBlazor as CropEventDataTypes } from "./crop-event-data"; +import type { CropperBlazor as ZoomEventDataTypes } from "./zoom-event-data"; + +export namespace CropperBlazor.Data { + export type CropperEventDataJS = + | CropEventDataTypes.Data.CropEventDataJS + | ZoomEventDataTypes.Data.ZoomEventDataJS; + + export type CropperJSEventData = { + isTrusted: boolean; + detail: CropperEventDataJS; + type: string; + eventPhase: number; + bubbles: boolean; + cancelable: boolean; + defaultPrevented: boolean; + composed: boolean; + timeStamp: number; + returnValue: any; + cancelBubble: boolean; + correlationId?: string; + }; +} diff --git a/src/Cropper.Blazor/Cropper.Blazor/Cropper/types/data/cropper-extended-options.ts b/src/Cropper.Blazor/Cropper.Blazor/Cropper/types/data/cropper-extended-options.ts new file mode 100644 index 00000000..0cfdada3 --- /dev/null +++ b/src/Cropper.Blazor/Cropper.Blazor/Cropper/types/data/cropper-extended-options.ts @@ -0,0 +1,8 @@ +import Cropper from "cropperjs"; + +export namespace CropperBlazor.Data { + export type CropperExtendedOptions = + Cropper.Options & { + correlationId?: string; + }; +} diff --git a/src/Cropper.Blazor/Cropper.Blazor/Cropper/types/data/zoom-event-data.ts b/src/Cropper.Blazor/Cropper.Blazor/Cropper/types/data/zoom-event-data.ts new file mode 100644 index 00000000..f2a54166 --- /dev/null +++ b/src/Cropper.Blazor/Cropper.Blazor/Cropper/types/data/zoom-event-data.ts @@ -0,0 +1,9 @@ +import type { CropperBlazor as DotNetTypes } from "../../types/global/dotnet-global.custom"; + +export namespace CropperBlazor.Data { + export type ZoomEventDataJS = { + oldRatio: number; + ratio: number; + originalEvent: DotNetTypes.Global.JsObjectReference | null; + }; +} diff --git a/src/Cropper.Blazor/Cropper.Blazor/Cropper/types/global/dotnet-global.custom.d.ts b/src/Cropper.Blazor/Cropper.Blazor/Cropper/types/global/dotnet-global.custom.d.ts new file mode 100644 index 00000000..860a25aa --- /dev/null +++ b/src/Cropper.Blazor/Cropper.Blazor/Cropper/types/global/dotnet-global.custom.d.ts @@ -0,0 +1,27 @@ +/** + * Represents the global DotNet object exposed by Blazor. + * Used to invoke .NET methods from JavaScript. + */ +export namespace CropperBlazor.Global { + interface DotNetNamespace { + invokeMethodAsync( + assemblyName: string, + methodIdentifier: string, + ...args: any[] + ): Promise; + + createJSObjectReference(jsObject: any): JsObjectReference; + } + + interface DotNetStreamReference { + arrayBuffer(): Promise; + } + + interface DotNetObjectReference { + invokeMethodAsync(methodName: keyof T, ...args: any[]): Promise; + } + + interface JsObjectReference { + __jsObjectId: number; + } +} diff --git a/src/Cropper.Blazor/Cropper.Blazor/Exceptions/ImageProcessingException.cs b/src/Cropper.Blazor/Cropper.Blazor/Exceptions/ImageProcessingException.cs index 1522e467..6901b0cb 100644 --- a/src/Cropper.Blazor/Cropper.Blazor/Exceptions/ImageProcessingException.cs +++ b/src/Cropper.Blazor/Cropper.Blazor/Exceptions/ImageProcessingException.cs @@ -3,7 +3,7 @@ namespace Cropper.Blazor.Exceptions { /// - /// Represents an exception that is thrown when an error occurs during image processing. + /// Represents an exception that is thrown when an error occurs during background image processing. /// public class ImageProcessingException : Exception { diff --git a/src/Cropper.Blazor/Cropper.Blazor/Services/BaseJsInterop.cs b/src/Cropper.Blazor/Cropper.Blazor/Services/BaseJsInterop.cs index 2b139f78..ef4b0b44 100644 --- a/src/Cropper.Blazor/Cropper.Blazor/Services/BaseJsInterop.cs +++ b/src/Cropper.Blazor/Cropper.Blazor/Services/BaseJsInterop.cs @@ -75,6 +75,16 @@ private async Task LoadModuleAsync(CancellationToken cancellationToken = default "import", cancellationToken, globalPathToCropperModule); } + /// + /// Determines whether the application is running in a Blazor Server environment + /// by checking if the JavaScript runtime type is RemoteJSRuntime. + /// + /// + /// True if running on Blazor Server; otherwise, false. + /// + public bool IsBlazorServer => + string.Equals(_jsRuntime.GetType().Name, "RemoteJSRuntime", StringComparison.OrdinalIgnoreCase); + /// /// Finds path to the cropper module. /// diff --git a/src/Cropper.Blazor/Cropper.Blazor/Services/IBaseJsInterop.cs b/src/Cropper.Blazor/Cropper.Blazor/Services/IBaseJsInterop.cs index 44bbb0da..9b37a205 100644 --- a/src/Cropper.Blazor/Cropper.Blazor/Services/IBaseJsInterop.cs +++ b/src/Cropper.Blazor/Cropper.Blazor/Services/IBaseJsInterop.cs @@ -9,6 +9,15 @@ namespace Cropper.Blazor.Services /// public interface IBaseJsInterop : IAsyncDisposable { + /// + /// Determines whether the application is running in a Blazor Server environment + /// by checking if the JavaScript runtime type is RemoteJSRuntime. + /// + /// + /// True if running on Blazor Server; otherwise, false. + /// + bool IsBlazorServer { get; } + /// /// Try load JavaScript object into .NET when module empty. /// diff --git a/src/Cropper.Blazor/Cropper.Blazor/eslint.config.cjs b/src/Cropper.Blazor/Cropper.Blazor/eslint.config.cjs new file mode 100644 index 00000000..a4de41c0 --- /dev/null +++ b/src/Cropper.Blazor/Cropper.Blazor/eslint.config.cjs @@ -0,0 +1,36 @@ +const { defineConfig, globalIgnores } = require('eslint/config'); +const tsParser = require('@typescript-eslint/parser'); +const tsPlugin = require('@typescript-eslint/eslint-plugin'); +const prettierPlugin = require('eslint-plugin-prettier'); + +module.exports = defineConfig([ + globalIgnores(['**/node_modules/**']), + + { + files: ['Cropper/**/*.{ts,tsx,mts,cts}', 'webpack.config.js'], + + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + }, + + plugins: { + '@typescript-eslint': tsPlugin, + prettier: prettierPlugin, + }, + + rules: { + // TypeScript rules + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_' }, + ], + + // Prettier integration + 'prettier/prettier': 'error', + }, + }, +]); diff --git a/src/Cropper.Blazor/Cropper.Blazor/excubowebcompiler.json b/src/Cropper.Blazor/Cropper.Blazor/excubowebcompiler.json deleted file mode 100644 index 9e61bf06..00000000 --- a/src/Cropper.Blazor/Cropper.Blazor/excubowebcompiler.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "Minifiers": { - "GZip": false, - "Enabled": true, - "Css": { - "CommentMode": "Important", - "ColorNames": "Hex", - "TermSemicolons": true, - "OutputMode": "SingleLine", - "IndentSize": 2 - }, - "Javascript": { - "RenameLocals": false, - "PreserveImportantComments": true, - "EvalTreatment": "Ignore", - "TermSemicolons": true, - "OutputMode": "SingleLine", - "IndentSize": 2 - } - }, - "Autoprefix": { - "Enabled": false, - "ProcessingOptions": { - "Browsers": ["last 4 versions"], - "Cascade": true, - "Add": true, - "Remove": true, - "Supports": true, - "Flexbox": "All", - "Grid": "None", - "IgnoreUnknownVersions": false, - "Stats": "", - "SourceMap": true, - "InlineSourceMap": false, - "SourceMapIncludeContents": false, - "OmitSourceMapUrl": false - } - }, - "CompilerSettings": { - "Sass": { - "IndentType": "Space", - "IndentWidth": 2, - "OutputStyle": "Expanded", - "RelativeUrls": true, - "LineFeed": "Lf", - "SourceMap": false - } - }, - "Output": { - "Preserve": true, - "Directory": "./wwwroot" - } -} diff --git a/src/Cropper.Blazor/Cropper.Blazor/package.json b/src/Cropper.Blazor/Cropper.Blazor/package.json new file mode 100644 index 00000000..60c66421 --- /dev/null +++ b/src/Cropper.Blazor/Cropper.Blazor/package.json @@ -0,0 +1,40 @@ +{ + "name": "Cropper.Blazor", + "version": "1.5.1", + "description": "The most powerful image cropping tool for Blazor Web App / WebAssembly / Server, Hybrid with MAUI, MVC and other frameworks.", + "main": "index.js", + "scripts": { + "build:debug": "webpack --mode=development", + "build:production": "webpack --mode=production", + "test": "vitest run", + "test:watch": "vitest", + "test:ui": "vitest --ui --coverage.enabled=true", + "coverage": "vitest run --coverage", + "lint": "eslint", + "lint:fix": "eslint --fix" + }, + "author": "@MaxymGorn (Maksym Hornytskiy), @ColdForeign (George Radchuk)", + "license": "MIT", + "devDependencies": { + "@typescript-eslint/eslint-plugin": "8.54.0", + "@typescript-eslint/parser": "8.54.0", + "@vitest/coverage-istanbul": "4.0.18", + "@vitest/ui": "4.0.18", + "clean-css": "5.3.3", + "copy-webpack-plugin": "13.0.1", + "dts-bundle-generator": "^9.5.1", + "eslint": "9.39.2", + "eslint-plugin-n": "17.23.2", + "eslint-plugin-prettier": "5.5.5", + "prettier": "3.8.1", + "terser-webpack-plugin": "5.3.16", + "ts-loader": "9.5.4", + "typescript": "5.9.3", + "vitest": "4.0.18", + "webpack": "5.104.1", + "webpack-cli": "6.0.1" + }, + "dependencies": { + "cropperjs": "1.6.2" + } +} diff --git a/src/Cropper.Blazor/Cropper.Blazor/tsconfig.json b/src/Cropper.Blazor/Cropper.Blazor/tsconfig.json new file mode 100644 index 00000000..d04e0bcf --- /dev/null +++ b/src/Cropper.Blazor/Cropper.Blazor/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "strict": true, + "noEmitOnError": true, + "removeComments": true, + "sourceMap": true, + "target": "es2020", + "module": "esnext", + "moduleResolution": "node", + "lib": [ "es2016", "dom" ], + "preserveConstEnums": true, + "allowJs": false, + "baseUrl": ".", + "paths": { + "cropperjs/*": [ "node_modules/cropperjs/src/*" ], + "vitest/*": [ "node_modules/vitest/*" ] + }, + "declaration": false + }, + "exclude": [ "node_modules" ], + "include": [ + "Cropper/types/global/*.d.ts", + "Cropper/**/*.ts", + "Cropper/*.ts", + "vitest.config.ts" + ] +} diff --git a/src/Cropper.Blazor/Cropper.Blazor/vitest.config.ts b/src/Cropper.Blazor/Cropper.Blazor/vitest.config.ts new file mode 100644 index 00000000..d8f8e260 --- /dev/null +++ b/src/Cropper.Blazor/Cropper.Blazor/vitest.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['**/*.{test,spec}.{ts,tsx,js,jsx}'], + isolate: true, + coverage: { + provider: 'istanbul', + enabled: false, + reportsDirectory: './coverage', + reporter: ['text', 'html', 'cobertura'], + exclude: [ + '**/*.d.ts', + '**/*.spec.*', + '**/*.test.*', + 'node_modules/**', + 'dist/**' + ] + } + } +}); diff --git a/src/Cropper.Blazor/Cropper.Blazor/webpack.config.js b/src/Cropper.Blazor/Cropper.Blazor/webpack.config.js new file mode 100644 index 00000000..7a3c0df9 --- /dev/null +++ b/src/Cropper.Blazor/Cropper.Blazor/webpack.config.js @@ -0,0 +1,141 @@ +require("webpack"); +const path = require("path"); +const CleanCSS = require("clean-css"); +const CopyPlugin = require("copy-webpack-plugin"); +const TerserPlugin = require("terser-webpack-plugin"); + +module.exports = (env, args) => ({ + resolve: { + extensions: [".ts", ".js", ".css"], + alias: { + cropperjs: path.resolve(__dirname, "node_modules/cropperjs/src"), + }, + }, + devtool: args.mode === "development" ? "inline-source-map" : "hidden-source-map", + module: { + rules: [ + { + test: /\.ts?$/, + loader: "ts-loader", + exclude: [ + /node_modules/, + /\.test\.ts$/, // exclude test files + /\.spec\.ts$/, // exclude spec files + /vitest\.setup\.ts$/, // exclude Vitest setup + ], + }, + ], + }, + entry: { + cropperJsInterop: "./Cropper/cropperJsInterop.ts", + }, + output: { + path: path.join(__dirname, "/wwwroot"), + filename: "[name].min.js", + }, + plugins: [ + new CopyPlugin({ + patterns: [ + { + to: "cropper.min.css", + from: path.resolve(__dirname, "node_modules/cropperjs/dist", "cropper.min.css"), + transform: (content) => + new CleanCSS({ + level: 2, + }).minify(content).styles, + }, + ], + }), + { + apply: (compiler) => { + compiler.hooks.afterEmit.tap("GenerateDeclarations", () => { + try { + const { generateDtsBundle } = require("dts-bundle-generator"); + const fs = require("fs"); + + // Input TypeScript entry file + const entryFile = path.resolve(__dirname, "Cropper/cropperJsInterop.ts"); + + // Output bundled .d.ts + const outputDir = path.resolve(__dirname, "wwwroot"); + // Add any helper files you want to include in the bundle + const helperFiles = [ + path.resolve(__dirname, "Cropper/helpers/blob-helper.ts"), + path.resolve(__dirname, "Cropper/helpers/cropper-url-image-helper.ts"), + // add more helpers here if needed + ]; + + // Combine main entry + helpers + const filesToBundle = [entryFile, ...helperFiles]; + + // Create folder if it doesn't exist + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + // Generate the bundle + filesToBundle.forEach((filePath) => { + const bundledDts = generateDtsBundle([ + { + config: path.resolve(__dirname, "tsconfig.json"), + filePath, + output: { + inlineDeclareGlobals: true, + noBanner: true, + exportReferencedTypes: true, + sortNodes: true, + }, + }, + ]); + + // Clean up empty exports, relative re-exports, and extra blank lines + const cleanedDts = bundledDts[0] + .replace(/^export\s*{\s*};?\s*$/gim, "") // remove empty exports + .replace(/^export\s+\*.*?\bfrom\s+"[\.~\/].*$/gim, "") // remove relative re-exports + .replace(/^\s*[\r\n]+/gim, "") // remove empty lines + .replace(/\/\/.*$/gim, "") // remove // comments + .replace(/\/\*[\s\S]*?\*\//gim, ""); // remove /* */ comments + + const fileName = path.basename(filePath, path.extname(filePath)); + const outputFile = path.join(outputDir, fileName + ".d.ts"); + + // Write to disk + fs.writeFileSync(outputFile, cleanedDts, "utf8"); + + console.log("✅ Bundled .d.ts created at:", outputFile); + }); + } catch (error) { + console.error("⚠️ Declaration generation failed:", error); + + throw error; + } + }); + }, + }, + ], + optimization: { + minimize: true, + minimizer: [ + new TerserPlugin({ + terserOptions: { + format: { + comments: false, + }, + }, + extractComments: false, + }), + ], + splitChunks: { + chunks: "all", // Split all types of chunks (initial, async) + cacheGroups: { + // Group for node_modules (cropper) + cropper: { + test: /[\\/]node_modules[\\/]/, // Match modules in node_modules + name: "cropper", // Output filename: cropper.js + chunks: "all", + priority: -10, // Lower priority for cropper, higher for app + }, + }, + }, + }, +}); diff --git a/src/Cropper.Blazor/Cropper.Blazor/wwwroot/cropper.min.css b/src/Cropper.Blazor/Cropper.Blazor/wwwroot/cropper.min.css deleted file mode 100644 index 049cab58..00000000 --- a/src/Cropper.Blazor/Cropper.Blazor/wwwroot/cropper.min.css +++ /dev/null @@ -1,9 +0,0 @@ -/*! - * Cropper.js v1.6.1 - * https://fengyuanchen.github.io/cropperjs - * - * Copyright 2015-present Chen Fengyuan - * Released under the MIT license - * - * Date: 2023-09-17T03:44:17.565Z - */.cropper-container{direction:ltr;font-size:0;line-height:0;position:relative;-ms-touch-action:none;touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.cropper-container img{backface-visibility:hidden;display:block;height:100%;image-orientation:0deg;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;width:100%}.cropper-canvas,.cropper-crop-box,.cropper-drag-box,.cropper-modal,.cropper-wrap-box{bottom:0;left:0;position:absolute;right:0;top:0}.cropper-canvas,.cropper-wrap-box{overflow:hidden}.cropper-drag-box{background-color:#fff;opacity:0}.cropper-modal{background-color:#000;opacity:.5}.cropper-view-box{display:block;height:100%;outline:1px solid #39f;outline-color:rgba(51,153,255,.75);overflow:hidden;width:100%}.cropper-dashed{border:0 dashed #eee;display:block;opacity:.5;position:absolute}.cropper-dashed.dashed-h{border-bottom-width:1px;border-top-width:1px;height:33.33333%;left:0;top:33.33333%;width:100%}.cropper-dashed.dashed-v{border-left-width:1px;border-right-width:1px;height:100%;left:33.33333%;top:0;width:33.33333%}.cropper-center{display:block;height:0;left:50%;opacity:.75;position:absolute;top:50%;width:0}.cropper-center:after,.cropper-center:before{background-color:#eee;content:" ";display:block;position:absolute}.cropper-center:before{height:1px;left:-3px;top:0;width:7px}.cropper-center:after{height:7px;left:0;top:-3px;width:1px}.cropper-face,.cropper-line,.cropper-point{display:block;height:100%;opacity:.1;position:absolute;width:100%}.cropper-face{background-color:#fff;left:0;top:0}.cropper-line{background-color:#39f}.cropper-line.line-e{cursor:ew-resize;right:-3px;top:0;width:5px}.cropper-line.line-n{cursor:ns-resize;height:5px;left:0;top:-3px}.cropper-line.line-w{cursor:ew-resize;left:-3px;top:0;width:5px}.cropper-line.line-s{bottom:-3px;cursor:ns-resize;height:5px;left:0}.cropper-point{background-color:#39f;height:5px;opacity:.75;width:5px}.cropper-point.point-e{cursor:ew-resize;margin-top:-3px;right:-3px;top:50%}.cropper-point.point-n{cursor:ns-resize;left:50%;margin-left:-3px;top:-3px}.cropper-point.point-w{cursor:ew-resize;left:-3px;margin-top:-3px;top:50%}.cropper-point.point-s{bottom:-3px;cursor:s-resize;left:50%;margin-left:-3px}.cropper-point.point-ne{cursor:nesw-resize;right:-3px;top:-3px}.cropper-point.point-nw{cursor:nwse-resize;left:-3px;top:-3px}.cropper-point.point-sw{bottom:-3px;cursor:nesw-resize;left:-3px}.cropper-point.point-se{bottom:-3px;cursor:nwse-resize;height:20px;opacity:1;right:-3px;width:20px}@media (min-width:768px){.cropper-point.point-se{height:15px;width:15px}}@media (min-width:992px){.cropper-point.point-se{height:10px;width:10px}}@media (min-width:1200px){.cropper-point.point-se{height:5px;opacity:.75;width:5px}}.cropper-point.point-se:before{background-color:#39f;bottom:-50%;content:" ";display:block;height:200%;opacity:0;position:absolute;right:-50%;width:200%}.cropper-invisible{opacity:0}.cropper-bg{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC")}.cropper-hide{display:block;height:0;position:absolute;width:0}.cropper-hidden{display:none!important}.cropper-move{cursor:move}.cropper-crop{cursor:crosshair}.cropper-disabled .cropper-drag-box,.cropper-disabled .cropper-face,.cropper-disabled .cropper-line,.cropper-disabled .cropper-point{cursor:not-allowed} \ No newline at end of file diff --git a/src/Cropper.Blazor/Cropper.Blazor/wwwroot/cropper.min.js b/src/Cropper.Blazor/Cropper.Blazor/wwwroot/cropper.min.js deleted file mode 100644 index 3102cb54..00000000 --- a/src/Cropper.Blazor/Cropper.Blazor/wwwroot/cropper.min.js +++ /dev/null @@ -1,10 +0,0 @@ -/*! - * Cropper.js v1.6.2 - * https://fengyuanchen.github.io/cropperjs - * - * Copyright 2015-present Chen Fengyuan - * Released under the MIT license - * - * Date: 2024-04-21T07:43:05.335Z - */ -!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Cropper=e()}(this,function(){"use strict";function C(e,t){var i,a=Object.keys(e);return Object.getOwnPropertySymbols&&(i=Object.getOwnPropertySymbols(e),t&&(i=i.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),a.push.apply(a,i)),a}function S(a){for(var t=1;tt.length)&&(e=t.length);for(var i=0,a=new Array(e);it.width?3===i?o=t.height*e:h=t.width/e:3===i?h=t.width/e:o=t.height*e,{aspectRatio:e,naturalWidth:n,naturalHeight:a,width:o,height:h});this.canvasData=e,this.limited=1===i||2===i,this.limitCanvas(!0,!0),e.width=Math.min(Math.max(e.width,e.minWidth),e.maxWidth),e.height=Math.min(Math.max(e.height,e.minHeight),e.maxHeight),e.left=(t.width-e.width)/2,e.top=(t.height-e.height)/2,e.oldLeft=e.left,e.oldTop=e.top,this.initialCanvasData=g({},e)},limitCanvas:function(t,e){var i=this.options,a=this.containerData,n=this.canvasData,o=this.cropBoxData,h=i.viewMode,r=n.aspectRatio,s=this.cropped&&o;t&&(t=Number(i.minCanvasWidth)||0,i=Number(i.minCanvasHeight)||0,1=a.width&&(n.minLeft=Math.min(0,r),n.maxLeft=Math.max(0,r)),n.height>=a.height)&&(n.minTop=Math.min(0,t),n.maxTop=Math.max(0,t))):(n.minLeft=-n.width,n.minTop=-n.height,n.maxLeft=a.width,n.maxTop=a.height))},renderCanvas:function(t,e){var i,a,n,o,h=this.canvasData,r=this.imageData;e&&(e={width:r.naturalWidth*Math.abs(r.scaleX||1),height:r.naturalHeight*Math.abs(r.scaleY||1),degree:r.rotate||0},r=e.width,o=e.height,e=e.degree,i=90==(e=Math.abs(e)%180)?{width:o,height:r}:(a=e%90*Math.PI/180,i=Math.sin(a),n=r*(a=Math.cos(a))+o*i,r=r*i+o*a,90h.maxWidth||h.widthh.maxHeight||h.heighte.width?a.height=a.width/i:a.width=a.height*i),this.cropBoxData=a,this.limitCropBox(!0,!0),a.width=Math.min(Math.max(a.width,a.minWidth),a.maxWidth),a.height=Math.min(Math.max(a.height,a.minHeight),a.maxHeight),a.width=Math.max(a.minWidth,a.width*t),a.height=Math.max(a.minHeight,a.height*t),a.left=e.left+(e.width-a.width)/2,a.top=e.top+(e.height-a.height)/2,a.oldLeft=a.left,a.oldTop=a.top,this.initialCropBoxData=g({},a)},limitCropBox:function(t,e){var i,a,n=this.options,o=this.containerData,h=this.canvasData,r=this.cropBoxData,s=this.limited,c=n.aspectRatio;t&&(t=Number(n.minCropBoxWidth)||0,n=Number(n.minCropBoxHeight)||0,i=s?Math.min(o.width,h.width,h.width+h.left,o.width-h.left):o.width,a=s?Math.min(o.height,h.height,h.height+h.top,o.height-h.top):o.height,t=Math.min(t,o.width),n=Math.min(n,o.height),c&&(t&&n?ti.maxWidth||i.widthi.maxHeight||i.height=e.width&&i.height>=e.height?q:I),f(this.cropBox,g({width:i.width,height:i.height},x({translateX:i.left,translateY:i.top}))),this.cropped&&this.limited&&this.limitCanvas(!0,!0),this.disabled||this.output()},output:function(){this.preview(),y(this.element,tt,this.getData())}},i={initPreview:function(){var t=this.element,i=this.crossOrigin,e=this.options.preview,a=i?this.crossOriginUrl:this.url,n=t.alt||"The image to preview",o=document.createElement("img");i&&(o.crossOrigin=i),o.src=a,o.alt=n,this.viewBox.appendChild(o),this.viewBoxImage=o,e&&("string"==typeof(o=e)?o=t.ownerDocument.querySelectorAll(e):e.querySelector&&(o=[e]),z(this.previews=o,function(t){var e=document.createElement("img");w(t,m,{width:t.offsetWidth,height:t.offsetHeight,html:t.innerHTML}),i&&(e.crossOrigin=i),e.src=a,e.alt=n,e.style.cssText='display:block;width:100%;height:auto;min-width:0!important;min-height:0!important;max-width:none!important;max-height:none!important;image-orientation:0deg!important;"',t.innerHTML="",t.appendChild(e)}))},resetPreview:function(){z(this.previews,function(e){var i=Bt(e,m),i=(f(e,{width:i.width,height:i.height}),e.innerHTML=i.html,e),e=m;if(o(i[e]))try{delete i[e]}catch(t){i[e]=void 0}else if(i.dataset)try{delete i.dataset[e]}catch(t){i.dataset[e]=void 0}else i.removeAttribute("data-".concat(Dt(e)))})},preview:function(){var h=this.imageData,t=this.canvasData,e=this.cropBoxData,r=e.width,s=e.height,c=h.width,d=h.height,l=e.left-t.left-h.left,p=e.top-t.top-h.top;this.cropped&&!this.disabled&&(f(this.viewBoxImage,g({width:c,height:d},x(g({translateX:-l,translateY:-p},h)))),z(this.previews,function(t){var e=Bt(t,m),i=e.width,e=e.height,a=i,n=e,o=1;r&&(n=s*(o=i/r)),s&&eMath.abs(a-1)?i:a)&&(t.restore&&(o=this.getCanvasData(),h=this.getCropBoxData()),this.render(),t.restore)&&(this.setCanvasData(z(o,function(t,e){o[e]=t*n})),this.setCropBoxData(z(h,function(t,e){h[e]=t*n}))))},dblclick:function(){var t,e;this.disabled||this.options.dragMode===_||this.setDragMode((t=this.dragBox,e=Q,(t.classList?t.classList.contains(e):-1y&&(D.x=y-f);break;case k:p+D.xx&&(D.y=x-v)}}var i,a,o,n=this.options,h=this.canvasData,r=this.containerData,s=this.cropBoxData,c=this.pointers,d=this.action,l=n.aspectRatio,p=s.left,m=s.top,u=s.width,g=s.height,f=p+u,v=m+g,w=0,b=0,y=r.width,x=r.height,M=!0,C=(!l&&t.shiftKey&&(l=u&&g?u/g:1),this.limited&&(w=s.minLeft,b=s.minTop,y=w+Math.min(r.width,h.width,h.left+h.width),x=b+Math.min(r.height,h.height,h.top+h.height)),c[Object.keys(c)[0]]),D={x:C.endX-C.startX,y:C.endY-C.startY};switch(d){case I:p+=D.x,m+=D.y;break;case B:0<=D.x&&(y<=f||l&&(m<=b||x<=v))?M=!1:(e(B),(u+=D.x)<0&&(d=k,p-=u=-u),l&&(m+=(s.height-(g=u/l))/2));break;case T:D.y<=0&&(m<=b||l&&(p<=w||y<=f))?M=!1:(e(T),g-=D.y,m+=D.y,g<0&&(d=O,m-=g=-g),l&&(p+=(s.width-(u=g*l))/2));break;case k:D.x<=0&&(p<=w||l&&(m<=b||x<=v))?M=!1:(e(k),u-=D.x,p+=D.x,u<0&&(d=B,p-=u=-u),l&&(m+=(s.height-(g=u/l))/2));break;case O:0<=D.y&&(x<=v||l&&(p<=w||y<=f))?M=!1:(e(O),(g+=D.y)<0&&(d=T,m-=g=-g),l&&(p+=(s.width-(u=g*l))/2));break;case E:if(l){if(D.y<=0&&(m<=b||y<=f)){M=!1;break}e(T),g-=D.y,m+=D.y,u=g*l}else e(T),e(B),!(0<=D.x)||fMath.abs(o)&&(o=i)})}),o),t),M=!1;break;case U:D.x&&D.y?(i=Wt(this.cropper),p=C.startX-i.left,m=C.startY-i.top,u=s.minWidth,g=s.minHeight,0 or element.");this.element=t,this.options=g({},ut,u(e)&&e),this.cropped=!1,this.disabled=!1,this.pointers={},this.ready=!1,this.reloading=!1,this.replaced=!1,this.sized=!1,this.sizing=!1,this.init()}return t=n,i=[{key:"noConflict",value:function(){return window.Cropper=Pt,n}},{key:"setDefaults",value:function(t){g(ut,u(t)&&t)}}],(e=[{key:"init",value:function(){var t,e=this.element,i=e.tagName.toLowerCase();if(!e[c]){if(e[c]=this,"img"===i){if(this.isImg=!0,t=e.getAttribute("src")||"",!(this.originalUrl=t))return;t=e.src}else"canvas"===i&&window.HTMLCanvasElement&&(t=e.toDataURL());this.load(t)}}},{key:"load",value:function(t){var e,i,a,n,o,h,r=this;t&&(this.url=t,this.imageData={},e=this.element,(i=this.options).rotatable||i.scalable||(i.checkOrientation=!1),i.checkOrientation&&window.ArrayBuffer?lt.test(t)?pt.test(t)?this.read((h=(h=t).replace(Xt,""),a=atob(h),h=new ArrayBuffer(a.length),z(n=new Uint8Array(h),function(t,e){n[e]=a.charCodeAt(e)}),h)):this.clone():(o=new XMLHttpRequest,h=this.clone.bind(this),this.reloading=!0,(this.xhr=o).onabort=h,o.onerror=h,o.ontimeout=h,o.onprogress=function(){o.getResponseHeader("content-type")!==ct&&o.abort()},o.onload=function(){r.read(o.response)},o.onloadend=function(){r.reloading=!1,r.xhr=null},i.checkCrossOrigin&&Lt(t)&&e.crossOrigin&&(t=zt(t)),o.open("GET",t,!0),o.responseType="arraybuffer",o.withCredentials="use-credentials"===e.crossOrigin,o.send()):this.clone())}},{key:"read",value:function(t){var e=this.options,i=this.imageData,a=Rt(t),n=0,o=1,h=1;1
',o=(n=n.querySelector(".".concat(c,"-container"))).querySelector(".".concat(c,"-canvas")),h=n.querySelector(".".concat(c,"-drag-box")),s=(r=n.querySelector(".".concat(c,"-crop-box"))).querySelector(".".concat(c,"-face")),this.container=a,this.cropper=n,this.canvas=o,this.dragBox=h,this.cropBox=r,this.viewBox=n.querySelector(".".concat(c,"-view-box")),this.face=s,o.appendChild(i),v(t,L),a.insertBefore(n,t.nextSibling),X(i,Z),this.initPreview(),this.bind(),e.initialAspectRatio=Math.max(0,e.initialAspectRatio)||NaN,e.aspectRatio=Math.max(0,e.aspectRatio)||NaN,e.viewMode=Math.max(0,Math.min(3,Math.round(e.viewMode)))||0,v(r,L),e.guides||v(r.getElementsByClassName("".concat(c,"-dashed")),L),e.center||v(r.getElementsByClassName("".concat(c,"-center")),L),e.background&&v(n,"".concat(c,"-bg")),e.highlight||v(s,G),e.cropBoxMovable&&(v(s,V),w(s,d,I)),e.cropBoxResizable||(v(r.getElementsByClassName("".concat(c,"-line")),L),v(r.getElementsByClassName("".concat(c,"-point")),L)),this.render(),this.ready=!0,this.setDragMode(e.dragMode),e.autoCrop&&this.crop(),this.setData(e.data),l(e.ready)&&b(t,"ready",e.ready,{once:!0}),y(t,"ready"))}},{key:"unbuild",value:function(){var t;this.ready&&(this.ready=!1,this.unbind(),this.resetPreview(),(t=this.cropper.parentNode)&&t.removeChild(this.cropper),X(this.element,L))}},{key:"uncreate",value:function(){this.ready?(this.unbuild(),this.ready=!1,this.cropped=!1):this.sizing?(this.sizingImage.onload=null,this.sizing=!1,this.sized=!1):this.reloading?(this.xhr.onabort=null,this.xhr.abort()):this.image&&this.stop()}}])&&A(t.prototype,e),i&&A(t,i),Object.defineProperty(t,"prototype",{writable:!1}),t;var t,e,i}();return g(It.prototype,t,i,e,St,jt,At),It}); \ No newline at end of file diff --git a/src/Cropper.Blazor/Cropper.Blazor/wwwroot/cropperJsInterop.js b/src/Cropper.Blazor/Cropper.Blazor/wwwroot/cropperJsInterop.js deleted file mode 100644 index dacc6cab..00000000 --- a/src/Cropper.Blazor/Cropper.Blazor/wwwroot/cropperJsInterop.js +++ /dev/null @@ -1,425 +0,0 @@ -class CropperDecorator { - constructor () { - this.cropperInstances = {} - } - - clear (cropperComponentId) { - return this.cropperInstances[cropperComponentId] - .clear() - } - - crop (cropperComponentId) { - return this.cropperInstances[cropperComponentId] - .crop() - } - - destroy (cropperComponentId) { - const cropperInstance = this.cropperInstances[cropperComponentId] - - if (cropperInstance) { - cropperInstance - .destroy() - - delete this.cropperInstances[cropperComponentId] - } - } - - disable (cropperComponentId) { - return this.cropperInstances[cropperComponentId] - .disable() - } - - enable (cropperComponentId) { - return this.cropperInstances[cropperComponentId] - .enable() - } - - getCanvasData (cropperComponentId) { - return this.cropperInstances[cropperComponentId] - .getCanvasData() - } - - getContainerData (cropperComponentId) { - return this.cropperInstances[cropperComponentId] - .getContainerData() - } - - getCropBoxData (cropperComponentId) { - return this.cropperInstances[cropperComponentId] - .getCropBoxData() - } - - getCroppedCanvas (cropperComponentId, options) { - options.maxWidth ??= Infinity - options.maxHeight ??= Infinity - - return this.cropperInstances[cropperComponentId] - .getCroppedCanvas(options) - } - - getCroppedCanvasInBackground (cropperComponentId, options, dotNetCanvasReceiverRef) { - setTimeout(async () => { - const croppedCanvas = this.getCroppedCanvas(cropperComponentId, options) - const jsCroppedCanvasRef = DotNet.createJSObjectReference(croppedCanvas) // eslint-disable-line no-undef - - await dotNetCanvasReceiverRef.invokeMethodAsync('ReceiveCanvasReference', jsCroppedCanvasRef) - }, 0) - } - - getCroppedCanvasDataURL (cropperComponentId, options, type, encoderOptions) { - options.maxWidth ??= Infinity - options.maxHeight ??= Infinity - - return this.cropperInstances[cropperComponentId] - .getCroppedCanvas(options) - .toDataURL(type, encoderOptions) - } - - getData (cropperComponentId, rounded) { - return this.cropperInstances[cropperComponentId] - .getData(rounded) - } - - getImageData (cropperComponentId) { - return this.cropperInstances[cropperComponentId] - .getImageData() - } - - move (cropperComponentId, offsetX, offsetY) { - return this.cropperInstances[cropperComponentId] - .move(offsetX, offsetY) - } - - moveTo (cropperComponentId, x, y) { - return this.cropperInstances[cropperComponentId] - .moveTo(x, y) - } - - replace (cropperComponentId, url, onlyColorChanged) { - return this.cropperInstances[cropperComponentId] - .replace(url, onlyColorChanged) - } - - reset (cropperComponentId) { - return this.cropperInstances[cropperComponentId] - .reset() - } - - rotate (cropperComponentId, degree) { - return this.cropperInstances[cropperComponentId] - .rotate(degree) - } - - rotateTo (cropperComponentId, degree) { - return this.cropperInstances[cropperComponentId] - .rotateTo(degree) - } - - scale (cropperComponentId, scaleX, scaleY) { - return this.cropperInstances[cropperComponentId] - .scale(scaleX, scaleY) - } - - scaleX (cropperComponentId, scaleX) { - return this.cropperInstances[cropperComponentId] - .scaleX(scaleX) - } - - scaleY (cropperComponentId, scaleY) { - return this.cropperInstances[cropperComponentId] - .scaleY(scaleY) - } - - setAspectRatio (cropperComponentId, aspectRatio) { - return this.cropperInstances[cropperComponentId] - .setAspectRatio(aspectRatio) - } - - setCanvasData (cropperComponentId, data) { - return this.cropperInstances[cropperComponentId] - .setCanvasData(data) - } - - setCropBoxData (cropperComponentId, data) { - return this.cropperInstances[cropperComponentId] - .setCropBoxData(data) - } - - setData (cropperComponentId, data) { - return this.cropperInstances[cropperComponentId] - .setData(data) - } - - setDragMode (cropperComponentId, dragMode) { - return this.cropperInstances[cropperComponentId] - .setDragMode(dragMode) - } - - zoom (cropperComponentId, ratio) { - return this.cropperInstances[cropperComponentId] - .zoom(ratio) - } - - zoomTo (cropperComponentId, ratio, pivotX, pivotY) { - return this.cropperInstances[cropperComponentId] - .zoomTo(ratio, { pivotX, pivotY }) - } - - noConflict () { - return Cropper.noConflict() // eslint-disable-line no-undef - } - - setDefaults (options) { - return Cropper.setDefaults(options) // eslint-disable-line no-undef - } - - getJSEventData (instance, correlationId) { - return { - isTrusted: instance.isTrusted, - detail: this.getJSEventDataDetail(instance), - type: instance.type, - eventPhase: instance.eventPhase, - bubbles: instance.bubbles, - cancelable: instance.cancelable, - defaultPrevented: instance.defaultPrevented, - composed: instance.composed, - timeStamp: instance.timeStamp, - returnValue: instance.returnValue, - cancelBubble: instance.cancelBubble, - correlationId - } - } - - getJSEventDataDetail (instance) { - if (instance.type === 'zoom') { - return { - oldRatio: instance.detail.oldRatio, - ratio: instance.detail.ratio, - originalEvent: instance.detail.originalEvent - ? DotNet.createJSObjectReference(instance.detail.originalEvent) // eslint-disable-line no-undef - : null - } - } else if (instance.type === 'cropstart' || instance.type === 'cropend' || instance.type === 'cropmove') { - return { - action: instance.detail.action, - originalEvent: instance.detail.originalEvent - ? DotNet.createJSObjectReference(instance.detail.originalEvent) // eslint-disable-line no-undef - : null - } - } - - return instance.detail - } - - onReady (imageObject, event, correlationId) { - const jSEventData = this.getJSEventData(event, correlationId) - imageObject.invokeMethodAsync('IsReady', jSEventData) - } - - onCropStart (imageObject, event, correlationId) { - const jSEventData = this.getJSEventData(event, correlationId) - imageObject.invokeMethodAsync('CropperIsStarted', jSEventData) - } - - onCropMove (imageObject, event, correlationId) { - const jSEventData = this.getJSEventData(event, correlationId) - imageObject.invokeMethodAsync('CropperIsMoved', jSEventData) - } - - onCropEnd (imageObject, event, correlationId) { - const jSEventData = this.getJSEventData(event, correlationId) - imageObject.invokeMethodAsync('CropperIsEnded', jSEventData) - } - - onCrop (imageObject, event, correlationId) { - const jSEventData = this.getJSEventData(event, correlationId) - imageObject.invokeMethodAsync('CropperIsCroped', jSEventData) - } - - onZoom (imageObject, event, correlationId) { - const jSEventData = this.getJSEventData(event, correlationId) - imageObject.invokeMethodAsync('CropperIsZoomed', jSEventData) - } - - initCropper (cropperComponentId, image, optionsImage, imageObject) { - if (image == null) { - throw new Error("Parameter 'image' must be is not null!") - } - - if (optionsImage == null) { - throw new Error("Parameter 'optionsImage' must be is not null!") - } - - const options = {} - const correlationId = optionsImage.correlationId - - if (imageObject != null) { - const self = this - - options.ready = function (event) { - self.onReady(imageObject, event, correlationId) - } - options.cropstart = function (event) { - self.onCropStart(imageObject, event, correlationId) - } - options.cropmove = function (event) { - self.onCropMove(imageObject, event, correlationId) - } - options.cropend = function (event) { - self.onCropEnd(imageObject, event, correlationId) - } - options.crop = function (event) { - self.onCrop(imageObject, event, correlationId) - } - options.zoom = function (event) { - self.onZoom(imageObject, event, correlationId) - } - } - - if (optionsImage != null) { - Object.entries(optionsImage)?.forEach(([key, value]) => { - options[key] = value - }) - } - - const cropper = new Cropper(image, options) // eslint-disable-line no-undef - - this.cropperInstances[cropperComponentId] = cropper - } - - async readBlobInChunks (blob, dotNetImageReceiverRef, maximumReceiveChunkSize) { - // Validate blob - if (!(blob instanceof Blob)) { - throw new TypeError('blob must be a valid Blob object.') - } - - // Validate dotNetImageReceiverRef - if (!dotNetImageReceiverRef || typeof dotNetImageReceiverRef.invokeMethodAsync !== 'function') { - throw new TypeError('dotNetImageReceiverRef must be a valid .NET object reference with an invokeMethodAsync function.') - } - - // Validate maximumReceiveChunkSize - if (maximumReceiveChunkSize != null && maximumReceiveChunkSize <= 0) { - throw new RangeError('maximumReceiveChunkSize must be greater than 0 bytes when specified.') - } - - // By default, blob.stream() reads the blob using internal chunking (typically 65536 bytes per chunk). - // To enforce a custom chunk size, especially to control serialized message size for JS interop or SignalR limits, we wrap it in a transformed ReadableStream. - // This allows us to split the default chunks further to stay within a maximum size constraint (e.g., for Blazor's JS interop or SignalR message limits). - let reader = null - - if (maximumReceiveChunkSize == null) { - reader = blob.stream().getReader() - } else { - const blobStream = blob.stream().getReader() - - // Binary estimation of JSON size - const getJsonSizeBinary = (chunk) => { - const length = chunk.length - - // Max 3 digits for the number (0 to 255) - const bytesPerElement = 3 - // Comma between elements - const commas = length - 1 - // For '[' and ']' - const brackets = 2 - - return (length * bytesPerElement) + commas + brackets - } - - // Create a custom stream that enforces max chunk size - const transformedStream = new ReadableStream({ - async pull (controller) { - const { done, value } = await blobStream.read() - - if (done) { - controller.close() - - return - } - - // Function to calculate JSON size for the current chunk using binary estimation - let offset = 0 - let lastGoodChunkSize = maximumReceiveChunkSize - - while (offset < value.length) { - // Start with the last known good chunk size, or the remaining length - let chunkSize = Math.min(lastGoodChunkSize, value.length - offset) - let chunk = value.slice(offset, offset + chunkSize) - let jsonSize = getJsonSizeBinary(chunk) - - // If the JSON size is too large, reduce the chunk size gradually - while (jsonSize > maximumReceiveChunkSize && chunkSize > 1) { - // Reduce the chunk size in steps of 512 bytes, but not below 1 byte - chunkSize = Math.max(chunkSize - 512, 1) - chunk = value.slice(offset, offset + chunkSize) - jsonSize = getJsonSizeBinary(chunk) - - // Stop reducing if the chunk size is already very small - if (chunkSize <= 512) { - break - } - } - - // Move the offset forward by the size of the chunk just sent with update the last good chunk size - lastGoodChunkSize = chunkSize - - offset += chunkSize - - controller.enqueue(chunk) - } - } - }) - - reader = transformedStream.getReader() - } - - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - - await dotNetImageReceiverRef.invokeMethodAsync('ReceiveImageChunk', value) - } - - await dotNetImageReceiverRef.invokeMethodAsync('CompleteImageTransfer') - } catch (error) { - await dotNetImageReceiverRef.invokeMethodAsync('HandleImageProcessingError', error.toString()) - } - } - - sendImageInChunks (cropperComponentId, options, dotNetImageReceiverRef, type, encoderOptions, maximumReceiveChunkSize) { - options.maxWidth ??= Infinity - options.maxHeight ??= Infinity - - const cropperInstance = this.cropperInstances[cropperComponentId] - - setTimeout(() => { - cropperInstance.getCroppedCanvas(options).toBlob(async (blob) => { - await this.readBlobInChunks(blob, dotNetImageReceiverRef, maximumReceiveChunkSize) - }, type, encoderOptions) - }, 0) - } -} - -class CropperUrlImageHelper { - static async getImageUsingStreaming (imageStream) { - if (!imageStream || typeof imageStream.arrayBuffer !== 'function') { - throw new TypeError('Invalid image stream provided.') - } - - const arrayBuffer = await imageStream.arrayBuffer() - const blob = new Blob([arrayBuffer]) - return URL.createObjectURL(blob) - } - - static revokeObjectUrl (url) { - if (typeof url !== 'string') { - throw new TypeError('Expected a string URL to revoke.') - } - URL.revokeObjectURL(url) - } -} - -window.cropperUrlImageHelper = CropperUrlImageHelper -window.cropper = new CropperDecorator() diff --git a/src/Cropper.Blazor/Server/Cropper.Blazor.Server.csproj b/src/Cropper.Blazor/Server/Cropper.Blazor.Server.csproj index aad7e013..f898d670 100644 --- a/src/Cropper.Blazor/Server/Cropper.Blazor.Server.csproj +++ b/src/Cropper.Blazor/Server/Cropper.Blazor.Server.csproj @@ -7,7 +7,7 @@ - +