├── BlazorSvgEditor.SvgEditor ├── _Imports.razor ├── wwwroot │ ├── background.png │ └── svgEditor.js ├── Helper │ ├── ToStringExtension.cs │ ├── NumberExtensions.cs │ └── Coord.cs ├── Misc │ ├── EditMode.cs │ ├── ImageManipulations.cs │ ├── ShapeChangedEventArgs.cs │ ├── Anchor.razor │ └── BoundingBox.cs ├── SvgEditor.JsInteropt.cs ├── BlazorSvgEditor.SvgEditor.csproj ├── ShapeEditors │ ├── ShapeEditor.cs │ ├── CircleEditor.razor │ ├── RectangleEditor.razor │ └── PolygonEditor.razor ├── SvgEditor.PointerEvents.cs ├── SvgEditor.AddEditLogic.cs ├── SvgEditor.TouchEvents.cs ├── Shapes │ ├── Shape.cs │ ├── Circle.cs │ ├── Polygon.cs │ └── Rectangle.cs ├── SvgEditor.razor ├── SvgEditor.Main.cs ├── SvgEditor.PublicMethods.cs └── SvgEditor.Transformations.cs ├── BlazorSvgEditor.MsTest ├── Usings.cs ├── BlazorSvgEditor.MsTest.csproj └── BoundingBoxTest.cs ├── nuget_logo.png ├── BlazorSvgEditor.WasmTest ├── wwwroot │ ├── assets │ │ └── tailwind-src.css │ ├── logo.png │ ├── example01.png │ ├── index.html │ └── css │ │ ├── app.css │ │ └── tailwind.css ├── Shared │ └── MainLayout.razor ├── _Imports.razor ├── Program.cs ├── App.razor ├── Properties │ └── launchSettings.json ├── BlazorSvgEditor.WasmTest.csproj └── Pages │ ├── Preview.razor.cs │ ├── Index.razor │ └── Preview.razor ├── .gitignore ├── BlazorSvgEditor.sln.DotSettings.user ├── LICENSE ├── .github └── workflows │ ├── deploy-website.yml │ └── deploy-nuget.yml ├── BlazorSvgEditor.sln ├── README.md └── Readme-nuget.md /BlazorSvgEditor.SvgEditor/_Imports.razor: -------------------------------------------------------------------------------- 1 | @using Microsoft.AspNetCore.Components.Web -------------------------------------------------------------------------------- /BlazorSvgEditor.MsTest/Usings.cs: -------------------------------------------------------------------------------- 1 | global using Microsoft.VisualStudio.TestTools.UnitTesting; -------------------------------------------------------------------------------- /nuget_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/florian03-1/BlazorSvgEditor/HEAD/nuget_logo.png -------------------------------------------------------------------------------- /BlazorSvgEditor.WasmTest/wwwroot/assets/tailwind-src.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /BlazorSvgEditor.WasmTest/wwwroot/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/florian03-1/BlazorSvgEditor/HEAD/BlazorSvgEditor.WasmTest/wwwroot/logo.png -------------------------------------------------------------------------------- /BlazorSvgEditor.WasmTest/wwwroot/example01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/florian03-1/BlazorSvgEditor/HEAD/BlazorSvgEditor.WasmTest/wwwroot/example01.png -------------------------------------------------------------------------------- /BlazorSvgEditor.SvgEditor/wwwroot/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/florian03-1/BlazorSvgEditor/HEAD/BlazorSvgEditor.SvgEditor/wwwroot/background.png -------------------------------------------------------------------------------- /BlazorSvgEditor.WasmTest/Shared/MainLayout.razor: -------------------------------------------------------------------------------- 1 | @inherits LayoutComponentBase 2 | 3 |
4 | @Body 5 |
6 | -------------------------------------------------------------------------------- /BlazorSvgEditor.SvgEditor/Helper/ToStringExtension.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | 3 | namespace BlazorSvgEditor.SvgEditor.Helper; 4 | 5 | public static class ToStringExtension 6 | { 7 | public static string ToInvString(this double value) => value.ToString(CultureInfo.InvariantCulture); 8 | } -------------------------------------------------------------------------------- /BlazorSvgEditor.SvgEditor/Misc/EditMode.cs: -------------------------------------------------------------------------------- 1 | namespace BlazorSvgEditor.SvgEditor.Misc; 2 | 3 | public enum EditMode 4 | { 5 | None, 6 | AddTool, //Add item but no start point is set 7 | Add, 8 | Move, 9 | MoveAnchor, 10 | } 11 | 12 | public enum ShapeType 13 | { 14 | None, 15 | Polygon, 16 | Rectangle, 17 | Circle, 18 | } 19 | 20 | -------------------------------------------------------------------------------- /BlazorSvgEditor.WasmTest/_Imports.razor: -------------------------------------------------------------------------------- 1 | @using System.Net.Http 2 | @using System.Net.Http.Json 3 | @using Microsoft.AspNetCore.Components.Forms 4 | @using Microsoft.AspNetCore.Components.Routing 5 | @using Microsoft.AspNetCore.Components.Web 6 | @using Microsoft.AspNetCore.Components.Web.Virtualization 7 | @using Microsoft.AspNetCore.Components.WebAssembly.Http 8 | @using Microsoft.JSInterop 9 | @using BlazorSvgEditor.WasmTest -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Visual Studio 2 | .vscode 3 | .vs/ 4 | 5 | # ReSharper is a .NET coding add-in 6 | _ReSharper* 7 | _dotCover* 8 | .idea 9 | riderModule.iml 10 | 11 | # User-specific files 12 | *.suo 13 | *.user 14 | *.userosscache 15 | *.sln.docstates 16 | 17 | # Build results 18 | [Dd]ebug/ 19 | [Dd]ebugPublic/ 20 | [Rr]elease/ 21 | [Rr]eleases/ 22 | [Pp]ackages/ 23 | x64/ 24 | x86/ 25 | build/ 26 | bld/ 27 | [Bb]in/ 28 | [Oo]bj/ -------------------------------------------------------------------------------- /BlazorSvgEditor.WasmTest/Program.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Components.Web; 2 | using Microsoft.AspNetCore.Components.WebAssembly.Hosting; 3 | using BlazorSvgEditor.WasmTest; 4 | 5 | var builder = WebAssemblyHostBuilder.CreateDefault(args); 6 | builder.RootComponents.Add("#app"); 7 | builder.RootComponents.Add("head::after"); 8 | 9 | builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); 10 | 11 | await builder.Build().RunAsync(); -------------------------------------------------------------------------------- /BlazorSvgEditor.WasmTest/App.razor: -------------------------------------------------------------------------------- 1 | @using BlazorSvgEditor.WasmTest.Shared 2 | 3 | 4 | 5 | 6 | 7 | 8 | Not found 9 | 10 |

Sorry, there's nothing at this address.

11 |
12 |
13 |
-------------------------------------------------------------------------------- /BlazorSvgEditor.SvgEditor/Misc/ImageManipulations.cs: -------------------------------------------------------------------------------- 1 | namespace BlazorSvgEditor.SvgEditor.Misc; 2 | 3 | public class ImageManipulations 4 | { 5 | public ImageManipulations() 6 | { 7 | //https://www.w3schools.com/cssref/css3_pr_filter.php 8 | 9 | //Values in percent, 100% is no change 10 | Brightness = 100; 11 | Contrast = 100; 12 | Saturation = 100; 13 | 14 | //Values in degrees 15 | Hue = 0; //until 360 16 | } 17 | 18 | public int Brightness { get; set; } 19 | public int Contrast { get; set; } 20 | public int Saturation { get; set; } 21 | public int Hue { get; set; } 22 | } -------------------------------------------------------------------------------- /BlazorSvgEditor.sln.DotSettings.user: -------------------------------------------------------------------------------- 1 | 2 | <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> 3 | <Solution /> 4 | </SessionState> -------------------------------------------------------------------------------- /BlazorSvgEditor.SvgEditor/Helper/NumberExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace BlazorSvgEditor.SvgEditor.Helper; 2 | 3 | public static class NumberExtensions 4 | { 5 | public static double MaxAbs(double a, double b) 6 | { 7 | var aAbs = Math.Abs(a); 8 | var bAbs = Math.Abs(b); 9 | return aAbs > bAbs ? a : b; 10 | } 11 | 12 | public static int ToInt(this double d) => Convert.ToInt32(d); 13 | 14 | //Double Comparison 15 | public static bool IsEqual(this double a, double b, double epsilon = 0.0001) 16 | { 17 | return Math.Abs(a - b) < epsilon; 18 | } 19 | 20 | public static double Round(this double d, int decimals = 2) => Math.Round(d, decimals); 21 | } -------------------------------------------------------------------------------- /BlazorSvgEditor.SvgEditor/wwwroot/svgEditor.js: -------------------------------------------------------------------------------- 1 | // This is a JavaScript module that is loaded on demand. It can export any number of 2 | // functions, and may import other JavaScript modules if required. 3 | 4 | export function showPrompt(message) { 5 | return prompt(message, 'Type anything here'); 6 | } 7 | 8 | export function getBoundingBox(element) { return element ? element.getBoundingClientRect() : {}; } 9 | export function getElementWidth(element) { return element ? element.clientWidth : 0; } 10 | export function getElementHeight(element) { return element ? element.clientHeight : 0; } 11 | 12 | 13 | export function getElementWidthAndHeight(element) { 14 | return { width: getElementWidth(element), height: getElementHeight(element) }; 15 | } -------------------------------------------------------------------------------- /BlazorSvgEditor.MsTest/BlazorSvgEditor.MsTest.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | enable 6 | enable 7 | 8 | false 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /BlazorSvgEditor.WasmTest/wwwroot/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | BlazorSvgEditor.WasmTest 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 |
22 |
23 | 24 |
25 | An unhandled error has occurred. 26 | Reload 27 | 🗙 28 |
29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Florian 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/deploy-website.yml: -------------------------------------------------------------------------------- 1 | on: workflow_dispatch 2 | 3 | jobs: 4 | deploy-website: 5 | name: Deploy BlazorSvgEditor demo website 6 | runs-on: ubuntu-latest 7 | 8 | steps: 9 | 10 | - name: Checkout repository 11 | uses: actions/checkout@v3 12 | 13 | - name: Setup dotnet version 14 | uses: actions/setup-dotnet@v3 15 | with: 16 | dotnet-version: 7.x.x 17 | 18 | # Generate the website 19 | 20 | - name: Publish 21 | run: dotnet publish BlazorSvgEditor.WasmTest/BlazorSvgEditor.WasmTest.csproj --configuration Release --output build 22 | 23 | - name: Change base-tag in index.html from / to BlazorGitHubPagesDemo 24 | run: sed -i 's///g' build/wwwroot/index.html 25 | 26 | # Publish the website 27 | - name: GitHub Pages action 28 | uses: peaceiris/actions-gh-pages@v3.9.2 29 | with: 30 | deploy_key: ${{ secrets.DEPLOY_PRIVATE_SSH }} 31 | publish_branch: gh-pages 32 | publish_dir: build/wwwroot 33 | allow_empty_commit: false 34 | keep_files: false 35 | force_orphan: true 36 | -------------------------------------------------------------------------------- /BlazorSvgEditor.SvgEditor/SvgEditor.JsInteropt.cs: -------------------------------------------------------------------------------- 1 | using BlazorSvgEditor.SvgEditor.Misc; 2 | using Microsoft.AspNetCore.Components; 3 | using Microsoft.JSInterop; 4 | 5 | namespace BlazorSvgEditor.SvgEditor; 6 | 7 | public partial class SvgEditor : IAsyncDisposable 8 | { 9 | [Inject] 10 | protected IJSRuntime JsRuntime { get; set; } = null!; //DI 11 | 12 | private Lazy> moduleTask = null!; //wird gesetzt in OnInitialized 13 | 14 | 15 | private JsBoundingBox _containerBoundingBox = new(); 16 | private ElementReference _containerElementReference; 17 | 18 | private async Task GetBoundingBox(ElementReference elementReference) 19 | { 20 | var module = await moduleTask.Value; 21 | 22 | return await module.InvokeAsync("getBoundingBox", elementReference); 23 | } 24 | 25 | private async Task SetContainerBoundingBox() 26 | { 27 | if (_containerElementReference.Id == null) throw new Exception("ContainerElementReference or SvgElementReference is null"); 28 | 29 | _containerBoundingBox = await GetBoundingBox(_containerElementReference); 30 | } 31 | 32 | public async ValueTask DisposeAsync() 33 | { 34 | if (moduleTask.IsValueCreated) 35 | { 36 | var module = await moduleTask.Value; 37 | await module.DisposeAsync(); 38 | } 39 | GC.SuppressFinalize(this); 40 | } 41 | } -------------------------------------------------------------------------------- /BlazorSvgEditor.WasmTest/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:46296", 7 | "sslPort": 44341 8 | } 9 | }, 10 | "profiles": { 11 | "http": { 12 | "commandName": "Project", 13 | "dotnetRunMessages": true, 14 | "launchBrowser": true, 15 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", 16 | "applicationUrl": "http://localhost:5006", 17 | "environmentVariables": { 18 | "ASPNETCORE_ENVIRONMENT": "Development" 19 | } 20 | }, 21 | "https": { 22 | "commandName": "Project", 23 | "dotnetRunMessages": true, 24 | "launchBrowser": true, 25 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", 26 | "applicationUrl": "https://localhost:7094;http://localhost:5006", 27 | "environmentVariables": { 28 | "ASPNETCORE_ENVIRONMENT": "Development" 29 | } 30 | }, 31 | "IIS Express": { 32 | "commandName": "IISExpress", 33 | "launchBrowser": true, 34 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", 35 | "environmentVariables": { 36 | "ASPNETCORE_ENVIRONMENT": "Development" 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /BlazorSvgEditor.WasmTest/BlazorSvgEditor.WasmTest.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | <_ContentIncludedByDefault Remove="wwwroot\css\open-iconic\FONT-LICENSE" /> 25 | <_ContentIncludedByDefault Remove="wwwroot\css\open-iconic\font\css\open-iconic-bootstrap.min.css" /> 26 | <_ContentIncludedByDefault Remove="wwwroot\css\open-iconic\font\fonts\open-iconic.eot" /> 27 | <_ContentIncludedByDefault Remove="wwwroot\css\open-iconic\font\fonts\open-iconic.otf" /> 28 | <_ContentIncludedByDefault Remove="wwwroot\css\open-iconic\font\fonts\open-iconic.svg" /> 29 | <_ContentIncludedByDefault Remove="wwwroot\css\open-iconic\font\fonts\open-iconic.ttf" /> 30 | <_ContentIncludedByDefault Remove="wwwroot\css\open-iconic\font\fonts\open-iconic.woff" /> 31 | <_ContentIncludedByDefault Remove="wwwroot\css\open-iconic\ICON-LICENSE" /> 32 | <_ContentIncludedByDefault Remove="wwwroot\css\open-iconic\README.md" /> 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /BlazorSvgEditor.SvgEditor/Helper/Coord.cs: -------------------------------------------------------------------------------- 1 | namespace BlazorSvgEditor.SvgEditor.Helper; 2 | 3 | public struct Coord 4 | { 5 | public T X { get; set; } 6 | public T Y { get; set; } 7 | 8 | public Coord(T x, T y) 9 | { 10 | X = x; 11 | Y = y; 12 | } 13 | 14 | public Coord() 15 | { 16 | this = Zero; 17 | } 18 | 19 | public Coord(Coord coord) 20 | { 21 | X = coord.X; 22 | Y = coord.Y; 23 | } 24 | 25 | public static Coord Zero => new(default(T) ?? (dynamic)0,default(T)??(dynamic)0); 26 | 27 | public static Coord operator +(Coord a, Coord b) => new((dynamic)a.X! + b.X, (dynamic)a.Y! + b.Y); 28 | public static Coord operator -(Coord a, Coord b) => new((dynamic)a.X! - b.X, (dynamic)a.Y! - b.Y); 29 | 30 | public static bool operator ==(Coord a, Coord b) => (dynamic)a.X! == b.X && (dynamic)a.Y! == b.Y; 31 | public static bool operator !=(Coord a, Coord b) => !(a == b); 32 | 33 | public override bool Equals(object? obj) => obj is Coord poT && this == poT; 34 | public override int GetHashCode() => HashCode.Combine(X, Y); 35 | 36 | public override string ToString() => $"({X}, {Y})"; 37 | 38 | 39 | public static Coord Max(Coord a, Coord b) => new(Math.Max((dynamic)a.X!, (dynamic)b.X!), Math.Max((dynamic)a.Y!, (dynamic)b.Y!)); 40 | 41 | //Cast to Coord 42 | public static implicit operator Coord(Coord coord) => new((int)(dynamic)coord.X!, (int)(dynamic)coord.Y!); 43 | public static implicit operator Coord(Coord coord) => new((double)(dynamic)coord.X!, (double)(dynamic)coord.Y!); 44 | 45 | //Abstand 46 | public static double Distance(Coord a, Coord b) => Math.Sqrt(Math.Pow((dynamic)a.X! - b.X, 2) + Math.Pow((dynamic)a.Y! - b.Y, 2)); 47 | } 48 | 49 | 50 | -------------------------------------------------------------------------------- /BlazorSvgEditor.SvgEditor/Misc/ShapeChangedEventArgs.cs: -------------------------------------------------------------------------------- 1 | using BlazorSvgEditor.SvgEditor.Shapes; 2 | 3 | namespace BlazorSvgEditor.SvgEditor; 4 | 5 | public class ShapeChangedEventArgs : EventArgs 6 | { 7 | public ShapeChangeType ChangeType { get; set; } 8 | public Shape? Shape { get; private set; } = null!; 9 | 10 | private int _shapeId = 0; 11 | public int ShapeId => Shape?.CustomId ?? _shapeId; 12 | 13 | public static ShapeChangedEventArgs ShapeMoved(Shape shape) 14 | { 15 | return new ShapeChangedEventArgs() 16 | { 17 | ChangeType = ShapeChangeType.Move, 18 | Shape = shape 19 | }; 20 | } 21 | 22 | public static ShapeChangedEventArgs ShapeEdited(Shape shape) 23 | { 24 | return new ShapeChangedEventArgs() 25 | { 26 | ChangeType = ShapeChangeType.Edit, 27 | Shape = shape 28 | }; 29 | } 30 | 31 | public static ShapeChangedEventArgs ShapeAdded(Shape shape) 32 | { 33 | return new ShapeChangedEventArgs() 34 | { 35 | ChangeType = ShapeChangeType.Add, 36 | Shape = shape 37 | }; 38 | } 39 | 40 | public static ShapeChangedEventArgs ShapeDeleted(int deletedShapeId) 41 | { 42 | return new ShapeChangedEventArgs() 43 | { 44 | ChangeType = ShapeChangeType.Delete, 45 | Shape = null!, 46 | _shapeId = deletedShapeId 47 | }; 48 | } 49 | 50 | public static ShapeChangedEventArgs ShapesCleared() 51 | { 52 | return new ShapeChangedEventArgs() 53 | { 54 | ChangeType = ShapeChangeType.ClearAll, 55 | Shape = null! 56 | }; 57 | } 58 | } 59 | public enum ShapeChangeType 60 | { 61 | Move, 62 | Edit, 63 | Add, 64 | Delete, 65 | ClearAll, 66 | Other 67 | } 68 | -------------------------------------------------------------------------------- /BlazorSvgEditor.SvgEditor/BlazorSvgEditor.SvgEditor.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | enable 6 | enable 7 | BlazorSvgEditor 8 | 9 | 10 | 11 | MIT 12 | nuget_logo.png 13 | @florian03-1 14 | Florian (florian03) 15 | (c) 2023 by Florian 16 | Readme-nuget.md 17 | Blazor, Components, Blazor-Components, Blazor Library, SVG, SVG Editor, Image 18 | BlazorSvgEditor is an simple Image SVG Editor for Blazor (WASM). It is designed to annotate images with SVG elements. There are many different ways to load images - there are also extensible Shape classes for custom annotation types. 19 | https://florian03.de/BlazorSvgEditor 20 | https://github.com/florian03-1/BlazorSvgEditor 21 | git 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /BlazorSvgEditor.SvgEditor/Misc/Anchor.razor: -------------------------------------------------------------------------------- 1 | @using BlazorSvgEditor.SvgEditor.Helper 2 | 3 | 4 | 5 | 6 | @code { 7 | 8 | [Parameter] 9 | public SvgEditor? SvgEditor { get; set; } 10 | 11 | [Parameter] 12 | public EventCallback OnPointerDown { get; set; } 13 | 14 | [Parameter] 15 | public EventCallback OnDoubleClick { get; set; } 16 | 17 | [Parameter, EditorRequired] 18 | public Coord Position { get; set; } 19 | 20 | [Parameter] 21 | public string RingColor { get; set; } = "#ff8c00"; //Oraange 22 | 23 | [Parameter] 24 | public string MiddleColor { get; set; } = "white"; 25 | 26 | [Parameter] 27 | public double ScaleFactor { get; set; } = 1; 28 | 29 | private double ExternalScaleFactor => SvgEditor?.Scale ?? 1; 30 | 31 | [Parameter] 32 | public string CssCursor { get; set; } = "pointer"; 33 | 34 | private string X => Position.X.ToInvString(); 35 | 36 | private string Y => Position.Y.ToInvString(); 37 | 38 | private string _State { get; set; } = string.Empty; 39 | 40 | private string State => $"{Position.X}{Position.Y}{RingColor}{MiddleColor}{ExternalScaleFactor}"; 41 | 42 | protected override bool ShouldRender() 43 | { 44 | if (_State != State) 45 | { 46 | _State = State; 47 | return true; 48 | } 49 | return false; 50 | } 51 | 52 | } -------------------------------------------------------------------------------- /BlazorSvgEditor.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorSvgEditor.WasmTest", "BlazorSvgEditor.WasmTest\BlazorSvgEditor.WasmTest.csproj", "{267FFF34-3AFE-4025-A03C-3E5D476A1E67}" 4 | EndProject 5 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorSvgEditor.SvgEditor", "BlazorSvgEditor.SvgEditor\BlazorSvgEditor.SvgEditor.csproj", "{7FC27B0D-5CDF-4274-9DAA-234190FA74CB}" 6 | EndProject 7 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorSvgEditor.MsTest", "BlazorSvgEditor.MsTest\BlazorSvgEditor.MsTest.csproj", "{53995D15-3742-465C-A13F-EDC8FBBBB5C0}" 8 | EndProject 9 | Global 10 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 11 | Debug|Any CPU = Debug|Any CPU 12 | Release|Any CPU = Release|Any CPU 13 | EndGlobalSection 14 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 15 | {267FFF34-3AFE-4025-A03C-3E5D476A1E67}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 16 | {267FFF34-3AFE-4025-A03C-3E5D476A1E67}.Debug|Any CPU.Build.0 = Debug|Any CPU 17 | {267FFF34-3AFE-4025-A03C-3E5D476A1E67}.Release|Any CPU.ActiveCfg = Release|Any CPU 18 | {267FFF34-3AFE-4025-A03C-3E5D476A1E67}.Release|Any CPU.Build.0 = Release|Any CPU 19 | {7FC27B0D-5CDF-4274-9DAA-234190FA74CB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 20 | {7FC27B0D-5CDF-4274-9DAA-234190FA74CB}.Debug|Any CPU.Build.0 = Debug|Any CPU 21 | {7FC27B0D-5CDF-4274-9DAA-234190FA74CB}.Release|Any CPU.ActiveCfg = Release|Any CPU 22 | {7FC27B0D-5CDF-4274-9DAA-234190FA74CB}.Release|Any CPU.Build.0 = Release|Any CPU 23 | {53995D15-3742-465C-A13F-EDC8FBBBB5C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 24 | {53995D15-3742-465C-A13F-EDC8FBBBB5C0}.Debug|Any CPU.Build.0 = Debug|Any CPU 25 | {53995D15-3742-465C-A13F-EDC8FBBBB5C0}.Release|Any CPU.ActiveCfg = Release|Any CPU 26 | {53995D15-3742-465C-A13F-EDC8FBBBB5C0}.Release|Any CPU.Build.0 = Release|Any CPU 27 | EndGlobalSection 28 | EndGlobal 29 | -------------------------------------------------------------------------------- /BlazorSvgEditor.SvgEditor/ShapeEditors/ShapeEditor.cs: -------------------------------------------------------------------------------- 1 | using BlazorSvgEditor.SvgEditor.Misc; 2 | using BlazorSvgEditor.SvgEditor.Shapes; 3 | using Microsoft.AspNetCore.Components; 4 | using Microsoft.AspNetCore.Components.Web; 5 | 6 | namespace BlazorSvgEditor.SvgEditor.ShapeEditors; 7 | 8 | public abstract class ShapeEditor : ComponentBase where TShape : Shape 9 | { 10 | [Parameter, EditorRequired] 11 | public TShape SvgElement { get; set; } = null!; //Wird zwingend im SvgEditor bei der Initialisierung gesetzt 12 | 13 | protected ElementReference ElementReference { get; set; } 14 | 15 | 16 | protected void Enter(PointerEventArgs e) 17 | { 18 | if(e.PointerType == "touch") return; //Touch events are handled seperately 19 | 20 | if(SvgElement.SvgEditor.EditMode != EditMode.None) return; 21 | SvgElement.HoverShape(); 22 | } 23 | 24 | protected void Leave(PointerEventArgs e) 25 | { 26 | if(e.PointerType == "touch") return; //Touch events are handled seperately 27 | 28 | SvgElement.UnHoverShape(); 29 | } 30 | 31 | protected void Select(PointerEventArgs eventArgs) 32 | { 33 | if(eventArgs.PointerType == "touch") return; //Touch events are handled seperately 34 | 35 | if (SvgElement.SvgEditor.EditMode == EditMode.Add) return; 36 | if (SvgElement.SvgEditor.ReadOnly) return; //Beim ReadOnly Modus kann man keine Shapes auswählen 37 | 38 | SvgElement.SelectShape(); 39 | SvgElement.SvgEditor.SelectShape(SvgElement, eventArgs); 40 | } 41 | 42 | protected void ClickShape() 43 | { 44 | SvgElement.SvgEditor.OnShapeClicked.InvokeAsync(SvgElement.CustomId); 45 | } 46 | 47 | protected void OnAnchorSelected(int anchorIndex) 48 | { 49 | SvgElement.SvgEditor.EditMode = EditMode.MoveAnchor; 50 | SvgElement.SvgEditor.SelectedShape = SvgElement; 51 | SvgElement.SvgEditor.SelectedAnchorIndex = anchorIndex; 52 | } 53 | } -------------------------------------------------------------------------------- /.github/workflows/deploy-nuget.yml: -------------------------------------------------------------------------------- 1 | name: deploy-svgeditor-nuget 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: 'Tag to publish v[0-9]+.[0-9]+.[0-9]+*' 8 | required: true 9 | default: '' 10 | type: string 11 | push: 12 | tags: 13 | - "v[0-9]+.[0-9]+.[0-9]+*" 14 | 15 | jobs: 16 | get-version: 17 | name: Get version to deploy 18 | runs-on: ubuntu-latest 19 | env: 20 | VERSION: 1.0.0 21 | outputs: 22 | VERSION: ${{ steps.output-version.outputs.VERSION }} 23 | steps: 24 | - name: Set tag from input 25 | if: ${{ github.event.inputs.version != '' }} 26 | env: 27 | TAG: ${{ github.event.inputs.version }} 28 | run: echo "VERSION=${TAG#v}" >> $GITHUB_ENV 29 | 30 | - name: Set version variable from tag 31 | if: ${{ github.ref_type == 'tag' }} 32 | env: 33 | TAG: ${{ github.ref_name }} 34 | run: echo "VERSION=${TAG#v}" >> $GITHUB_ENV 35 | 36 | - name: VERSION to job output 37 | id: output-version 38 | run: | 39 | echo "VERSION=${{ env.VERSION }}" >> $GITHUB_OUTPUT 40 | 41 | 42 | deploy-nuget: 43 | name: Deploy mudblazor nuget to nuget.org 44 | needs: get-version 45 | runs-on: ubuntu-latest 46 | steps: 47 | - name: Checkout repository 48 | uses: actions/checkout@v3 49 | with: 50 | ref: 'refs/tags/v${{ needs.get-version.outputs.VERSION }}' 51 | - name: Setup dotnet version 52 | uses: actions/setup-dotnet@v3 53 | with: 54 | dotnet-version: 7.x.x 55 | - name: Pack nuget package 56 | run: dotnet pack -c Release --output nupkgs /p:PackageVersion=${{ needs.get-version.outputs.VERSION }} /p:AssemblyVersion=${{ needs.get-version.outputs.VERSION }} /p:Version=${{ needs.get-version.outputs.VERSION }} 57 | working-directory: ./BlazorSvgEditor.SvgEditor 58 | - name: Publish nuget package 59 | run: dotnet nuget push nupkgs/*.nupkg -k ${{ secrets.NUGET_KEY }} -s https://api.nuget.org/v3/index.json 60 | working-directory: ./BlazorSvgEditor.SvgEditor 61 | -------------------------------------------------------------------------------- /BlazorSvgEditor.SvgEditor/ShapeEditors/CircleEditor.razor: -------------------------------------------------------------------------------- 1 | @using BlazorSvgEditor.SvgEditor.Helper 2 | @using BlazorSvgEditor.SvgEditor.Misc 3 | @using BlazorSvgEditor.SvgEditor.Shapes 4 | @inherits ShapeEditor 5 | 6 | 28 | 29 | 30 | @if (SvgElement.State == ShapeState.Selected && SvgElement.SvgEditor.EditMode != EditMode.Add && SvgElement.SvgEditor.EditMode != EditMode.Move) 31 | { 32 | 33 | 34 | 35 | 36 | } -------------------------------------------------------------------------------- /BlazorSvgEditor.SvgEditor/ShapeEditors/RectangleEditor.razor: -------------------------------------------------------------------------------- 1 | @using BlazorSvgEditor.SvgEditor.Helper 2 | @using BlazorSvgEditor.SvgEditor.Misc 3 | @using BlazorSvgEditor.SvgEditor.Shapes 4 | @inherits ShapeEditor 5 | 6 | 29 | 30 | 31 | 32 | 33 | @if (SvgElement.State == ShapeState.Selected && SvgElement.SvgEditor.EditMode != EditMode.Add && SvgElement.SvgEditor.EditMode != EditMode.Move) 34 | { 35 | 36 | 37 | 38 | 39 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Blazor Svg Editor 2 | 3 | ![Nuget](https://img.shields.io/nuget/v/BlazorSvgEditor) 4 | 5 | Blazor Svg Editor is a simple SVG editor for **Blazor** that allows annotations (in the form of SVG elements) to be placed on images. You are able to scale and translate the image (of course with the annotations) and manipulate it with css filters. 6 | Actually it supports circles, rectangles and polygons - but because of the abstract interface they can be easily extended. The shapes are movable and resizable. It is also checked that the shapes are not placed outside the image. 7 | 8 | ### Demo 9 | A demo application is hosted by GitHub-Pages. You can visit it [here](https://florian03-1.github.io/BlazorSvgEditor/). 10 | 11 | ## Documentation 12 | 13 | ### Avaiable Properties 14 | - **CssClass/CssStyle:** Own classes and Styles for the component 15 | - **MinScale/MaxScale** 16 | - **ImageManipulations**: A class with properties for *brightness*, *contrast*, *saturation* and *hue* - they get applied on the loaded image 17 | - **ImageSize**: Set image height and width 18 | - **ImageSource:** There are two ways: 19 | - **Directly**: (via Propertie ImageSource) 20 | - **ImageSourceLoadingFunc:** A async func which loads the image source string (e.g. when you get a base64 string from an api) 21 | - **OnShapeChanged:** Event when Shapes get changed (with information about the ChangeType and the affected shape 22 | - **bind-SelectedShapeId:** The id from the selected shape (can also be set from outside) 23 | 24 | ### Sample Code for Implementation 25 | Here is a sample code for the implementation. 26 | 27 | 28 | 29 | The used ImageLoadFunc look as follows: 30 | 31 | private async Task GetImageSource() 32 | { 33 | await Task.Delay(1000); //An async api call is also possible... 34 | return "https://url-to-image.de"; 35 | } 36 | 37 | For the other methods please take a look at the example project in the Repository. 38 | 39 | ## Questions, Ideas, Feedback? 40 | 41 | Please report bugs or request new features by opening an issue. You can also make an Pull-Request when you implement a new feature. 42 | -------------------------------------------------------------------------------- /Readme-nuget.md: -------------------------------------------------------------------------------- 1 | # Blazor Svg Editor 2 | 3 | Blazor Svg Editor is a simple SVG editor for **Blazor** that allows annotations (in the form of SVG elements) to be placed on images. You are able to scale and translate the image (of course with the annotations). 4 | Actually it supports circles, rectangles and polygons - but because of the abstract interface they can be easily extended. The shapes are movable and resizable. It is also checked that the shapes are not placed outside the image. 5 | 6 | ### Demo 7 | A demo application is hosted by GitHub-Pages. You can visit it [here](https://florian03-1.github.io/BlazorSvgEditor/). 8 | 9 | ## Documentation 10 | 11 | ### Avaiable Properties 12 | - **CssClass/CssStyle:** Own classes and Styles for the component 13 | - **MinScale/MaxScale** 14 | - **ImageManipulations**: A class with properties for *brightness*, *contrast*, *saturation* and *hue* - they get applied on the loaded image 15 | - **ImageSize**: Set image height and width 16 | - **ImageSource:** There are two ways: 17 | - **Directly**: (via Propertie ImageSource) 18 | - **ImageSourceLoadingFunc:** A async func which loads the image source string (e.g. when you get a base64 string from an api) 19 | - **OnShapeChanged:** Event when Shapes get changed (with information about the ChangeType and the affected shape 20 | - **bind-SelectedShapeId:** The id from the selected shape (can also be set from outside) 21 | 22 | ### Sample Code for Implementation 23 | Here is a sample code for the implementation. 24 | 25 | 26 | 27 | The used ImageLoadFunc look as follows: 28 | 29 | private async Task GetImageSource() 30 | { 31 | await Task.Delay(1000); //An async api call is also possible... 32 | return "https://url-to-image.de"; 33 | } 34 | 35 | For the other methods please take a look at the example project in the [GitHub](https://github.com/florian03-1/BlazorSvgEditor) Repository. 36 | 37 | ## Questions, Ideas, Feedback? 38 | 39 | You can visit the [GitHub Repository](https://github.com/florian03-1/BlazorSvgEditor) here. Please report bugs or request new features by opening an issue. You can also make an Pull-Request when you implement a new feature. 40 | -------------------------------------------------------------------------------- /BlazorSvgEditor.SvgEditor/SvgEditor.PointerEvents.cs: -------------------------------------------------------------------------------- 1 | using BlazorSvgEditor.SvgEditor.Helper; 2 | using BlazorSvgEditor.SvgEditor.Misc; 3 | using Microsoft.AspNetCore.Components.Web; 4 | 5 | namespace BlazorSvgEditor.SvgEditor; 6 | 7 | public partial class SvgEditor 8 | { 9 | private const int MOVE_BUTTON_INDEX = 2; 10 | 11 | private Coord _pointerPosition; 12 | 13 | //Container events (pointer events, wheel events) 14 | private async Task OnContainerPointerDown(PointerEventArgs e) 15 | { 16 | if(e.PointerType == "touch") return; //Touch events are handled seperately 17 | 18 | if (e.Button == MOVE_BUTTON_INDEX) 19 | { 20 | IsTranslating = true; 21 | } 22 | 23 | var point = DetransformPoint(e.OffsetX, e.OffsetY); 24 | if (point.X < 0 || point.Y < 0 || point.X > ImageSize.Width || point.Y > ImageSize.Height) 25 | { 26 | if (EditMode == EditMode.Add) return; //Wenn das Polygon erstellt wird und währenddessen aus dem Bild rausgezogen wird, soll nichts passieren 27 | OnUnselectPanelPointerDown(e); 28 | } 29 | else //Pointer is inside the image 30 | { 31 | if (EditMode == EditMode.AddTool) 32 | { 33 | await AddToolPointerDown(e); 34 | } 35 | } 36 | } 37 | 38 | private void OnContainerPointerUp(PointerEventArgs e) 39 | { 40 | IsTranslating = false; 41 | SelectedShape?.HandlePointerUp(e); 42 | SelectedShape?.SnapToInteger(); 43 | } 44 | 45 | private async Task OnContainerPointerMove(PointerEventArgs e) 46 | { 47 | if(e.PointerType == "touch") return; //Touch events are handled seperately 48 | 49 | if(ShowDiagnosticInformation) _pointerPosition = new Coord((int)e.OffsetX, (int) e.OffsetY); 50 | 51 | if (IsTranslating) await Pan(e.MovementX, e.MovementY); 52 | 53 | if (SelectedShape != null && ReadOnly == false) 54 | { 55 | SelectedShape.HandlePointerMove(e); 56 | MoveStartDPoint = DetransformOffset(e); 57 | } 58 | } 59 | 60 | private async Task OnContainerWheel(WheelEventArgs e) 61 | { 62 | //Zoom 63 | await Zoom(e.DeltaY, e.OffsetX, e.OffsetY); 64 | } 65 | 66 | 67 | private void OnUnselectPanelPointerDown(PointerEventArgs e) 68 | { 69 | if(e.PointerType == "touch") return; //Touch events are handled seperately 70 | 71 | SelectedShape?.UnSelectShape(); 72 | SelectedShape = null; 73 | } 74 | 75 | private void OnContainerDoubleClick() 76 | { 77 | if (EditMode == EditMode.Add && SelectedShape != null) 78 | { 79 | //SelectedShape.Complete(); 80 | //Führt aktuell selten zu Fehlern 81 | } 82 | } 83 | } -------------------------------------------------------------------------------- /BlazorSvgEditor.SvgEditor/ShapeEditors/PolygonEditor.razor: -------------------------------------------------------------------------------- 1 | @using BlazorSvgEditor.SvgEditor.Helper 2 | @using BlazorSvgEditor.SvgEditor.Misc 3 | @using BlazorSvgEditor.SvgEditor.Shapes 4 | @inherits ShapeEditor 5 | 6 | 26 | 27 | 28 | 29 | 30 | @if (SvgElement.State == ShapeState.Selected && SvgElement.SvgEditor.EditMode != EditMode.Add && SvgElement.SvgEditor.EditMode != EditMode.Move) 31 | { 32 | for (int i = 0; i < SvgElement.Points.Count; i++) 33 | { 34 | int j = i; 35 | 37 | 38 | var firstPointIndex = i; 39 | var secondPointIndex = i + 1; 40 | 41 | if (secondPointIndex >= SvgElement.Points.Count) secondPointIndex = 0; 42 | 43 | 44 | 46 | } 47 | } 48 | 49 | @if (SvgElement.State == ShapeState.Selected && SvgElement.SvgEditor.EditMode == EditMode.Add) 50 | { 51 | for (int i = 0; i < SvgElement.Points.Count; i++) 52 | { 53 | if (i == 0) 54 | { 55 | 57 | } 58 | else if (i == SvgElement.Points.Count - 1) 59 | { 60 | 62 | } 63 | 64 | else 65 | { 66 | 68 | } 69 | } 70 | } -------------------------------------------------------------------------------- /BlazorSvgEditor.SvgEditor/SvgEditor.AddEditLogic.cs: -------------------------------------------------------------------------------- 1 | using BlazorSvgEditor.SvgEditor.Helper; 2 | using BlazorSvgEditor.SvgEditor.Misc; 3 | using BlazorSvgEditor.SvgEditor.Shapes; 4 | using Microsoft.AspNetCore.Components.Web; 5 | 6 | namespace BlazorSvgEditor.SvgEditor; 7 | 8 | public partial class SvgEditor 9 | { 10 | private ShapeType ShapeType { get; set; } = ShapeType.None; 11 | 12 | public void SelectShape(Shape shape, PointerEventArgs eventArgs) 13 | { 14 | SelectedShape?.UnSelectShape(); 15 | 16 | SelectedShape = shape; //Das neue Shape wird ausgewählt 17 | SelectedShape.SelectShape(); //Das neue Shape wird ausgewählt 18 | 19 | EditMode = EditMode.Move; 20 | MoveStartDPoint = DetransformOffset(eventArgs); 21 | StateHasChanged(); 22 | } 23 | 24 | private void AddElement(ShapeType shapeType) 25 | { 26 | if(_imageSourceLoading) return; 27 | 28 | EditMode = EditMode.AddTool; 29 | ShapeType = shapeType; 30 | 31 | SelectedShape?.UnSelectShape(); 32 | SelectedShape = null; 33 | } 34 | 35 | private string? _newShapeColor = null; 36 | private async Task AddToolPointerDown(PointerEventArgs e) 37 | { 38 | Shape? newShape = null; 39 | switch (ShapeType) 40 | { 41 | case ShapeType.None: 42 | return; 43 | 44 | case ShapeType.Polygon: 45 | newShape = new Polygon(this) 46 | { 47 | Points = new List>() 48 | { 49 | new(DetransformOffset(e)) 50 | } 51 | }; 52 | break; 53 | 54 | case ShapeType.Rectangle: 55 | newShape = new Rectangle(this) 56 | { 57 | X = DetransformOffset(e).X, 58 | Y = DetransformOffset(e).Y 59 | }; 60 | break; 61 | 62 | case ShapeType.Circle: 63 | newShape = new Circle(this) 64 | { 65 | Cx = DetransformOffset(e).X, 66 | Cy = DetransformOffset(e).Y 67 | }; 68 | break; 69 | 70 | default: 71 | throw new ArgumentOutOfRangeException(); 72 | } 73 | 74 | var newShapeId = -1; 75 | if (Shapes.Count > 0) newShapeId = Math.Min(Enumerable.Min(Shapes, x => x.CustomId) - 1, newShapeId); 76 | 77 | newShape.CustomId = newShapeId; 78 | 79 | if (_newShapeColor != null) newShape.Color = _newShapeColor; 80 | 81 | Shapes.Add(newShape); 82 | 83 | SelectedShape = newShape; 84 | SelectedShape.SelectShape(); 85 | 86 | EditMode = EditMode.Add; 87 | 88 | await Task.Yield(); 89 | } 90 | 91 | public async Task ShapeAddedCompleted(Shape shape) 92 | { 93 | SelectedShape = shape; 94 | SelectedShape.SelectShape(); 95 | await OnShapeChanged.InvokeAsync(ShapeChangedEventArgs.ShapeAdded(shape)); 96 | } 97 | 98 | } -------------------------------------------------------------------------------- /BlazorSvgEditor.SvgEditor/SvgEditor.TouchEvents.cs: -------------------------------------------------------------------------------- 1 | using BlazorSvgEditor.SvgEditor.Helper; 2 | using Microsoft.AspNetCore.Components.Web; 3 | 4 | namespace BlazorSvgEditor.SvgEditor; 5 | 6 | public partial class SvgEditor 7 | { 8 | private Coord _lastTouchPoint = new(0, 0); 9 | private Coord _lastZoomCenterPoint = new(0, 0); 10 | private double _lastTouchDistance = 0; 11 | 12 | private async Task OnTouchMove(TouchEventArgs touchEventArgs) 13 | { 14 | if (touchEventArgs.Touches.Length == 3) //Touch mit 3 Finger (Panning) 15 | { 16 | //Es wird immer um den Abstand zwischen dem letzen und dem aktuellen Touchpunkt gepannt (bei drei Fingern wird immer der 1. genommen) 17 | 18 | var currentTouchPoint = new Coord(touchEventArgs.Touches[0].ClientX, touchEventArgs.Touches[0].ClientY); 19 | if (_lastTouchPoint != new Coord(0, 0)) 20 | { 21 | var delta = currentTouchPoint - _lastTouchPoint; 22 | await Pan(delta.X, delta.Y); 23 | } 24 | _lastTouchPoint = currentTouchPoint; 25 | } 26 | else 27 | { 28 | //Falls während dem Touchvorgang auf 2 Finger gewechselt wird, muss der Ankerpunkt für die Panning-Berechnung zurückgesetzt werden 29 | _lastTouchPoint = new Coord(0, 0); 30 | } 31 | 32 | if (touchEventArgs.Touches.Length == 2) //Touch mit 2 Fingern (Zooming) 33 | { 34 | //Es wird sowohl gezoomt als auch gepannt, um den Mittelpunkt der beiden Finger zu halten 35 | 36 | var p1 = new Coord(touchEventArgs.Touches[0].ClientX, touchEventArgs.Touches[0].ClientY); 37 | var p2 = new Coord(touchEventArgs.Touches[1].ClientX, touchEventArgs.Touches[1].ClientY); 38 | var currentDistance = Coord.Distance(p1, p2); //Distanz zwischen den beiden Fingern 39 | 40 | if (_lastTouchDistance != 0) 41 | { 42 | var currentTouchPoint = new Coord((p1.X + p2.X) / 2, (p1.Y + p2.Y) / 2); 43 | var delta = currentTouchPoint - _lastZoomCenterPoint; 44 | var containerCenter = new Coord(_containerBoundingBox.Width / 2, _containerBoundingBox.Height / 2); 45 | 46 | var distanceDelta = currentDistance - _lastTouchDistance; 47 | await TouchZoom(distanceDelta ,containerCenter, delta); 48 | } 49 | 50 | _lastZoomCenterPoint = new Coord((p1.X + p2.X) / 2, (p1.Y + p2.Y) / 2); 51 | _lastTouchDistance = currentDistance; 52 | } 53 | await Task.Yield(); 54 | } 55 | 56 | private async Task OnTouchStart(TouchEventArgs touchEventArgs) 57 | { 58 | //Wird benötigt, um den Mittelpunkt des Containers für das Zooming berechnen - hier wird es nur einmal zum Beginn des Touchvorgangs berechnet 59 | await SetContainerBoundingBox(); 60 | } 61 | 62 | private async Task OnTouchEnd(TouchEventArgs touchEventArgs) 63 | { 64 | _lastTouchPoint = new Coord(0, 0); 65 | _lastZoomCenterPoint = new Coord(0, 0); 66 | _lastTouchDistance = 0; 67 | await Task.Yield(); 68 | } 69 | } -------------------------------------------------------------------------------- /BlazorSvgEditor.WasmTest/wwwroot/css/app.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; 3 | } 4 | 5 | #blazor-error-ui { 6 | background: lightyellow; 7 | bottom: 0; 8 | box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); 9 | display: none; 10 | left: 0; 11 | padding: 0.6rem 1.25rem 0.7rem 1.25rem; 12 | position: fixed; 13 | width: 100%; 14 | z-index: 1000; 15 | } 16 | 17 | #blazor-error-ui .dismiss { 18 | cursor: pointer; 19 | position: absolute; 20 | right: 0.75rem; 21 | top: 0.5rem; 22 | } 23 | 24 | .blazor-error-boundary { 25 | background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; 26 | padding: 1rem 1rem 1rem 3.7rem; 27 | color: white; 28 | } 29 | 30 | .blazor-error-boundary::after { 31 | content: "An error has occurred." 32 | } 33 | 34 | .loading-progress { 35 | position: relative; 36 | display: block; 37 | width: 8rem; 38 | height: 8rem; 39 | margin: 20vh auto 1rem auto; 40 | } 41 | 42 | .loading-progress circle { 43 | fill: none; 44 | stroke: #e0e0e0; 45 | stroke-width: 0.6rem; 46 | transform-origin: 50% 50%; 47 | transform: rotate(-90deg); 48 | } 49 | 50 | .loading-progress circle:last-child { 51 | stroke: #1b6ec2; 52 | stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%; 53 | transition: stroke-dasharray 0.05s ease-in-out; 54 | } 55 | 56 | .loading-progress-text { 57 | position: absolute; 58 | text-align: center; 59 | font-weight: bold; 60 | inset: calc(20vh + 3.25rem) 0 auto 0.2rem; 61 | } 62 | 63 | .loading-progress-text:after { 64 | content: var(--blazor-load-percentage-text, "Loading"); 65 | } 66 | -------------------------------------------------------------------------------- /BlazorSvgEditor.SvgEditor/Shapes/Shape.cs: -------------------------------------------------------------------------------- 1 | using BlazorSvgEditor.SvgEditor.Helper; 2 | using BlazorSvgEditor.SvgEditor.Misc; 3 | using Microsoft.AspNetCore.Components.Web; 4 | 5 | namespace BlazorSvgEditor.SvgEditor.Shapes; 6 | 7 | public abstract class Shape 8 | { 9 | protected Shape(SvgEditor svgEditor) 10 | { 11 | SvgEditor = svgEditor; 12 | } 13 | 14 | public SvgEditor SvgEditor { get; set; } 15 | 16 | //Helper properties and methods for easier access 17 | private double GetScaleValue(double value, int decimals = 1) 18 | { 19 | return !SvgEditor.ScaleShapes ? value : SvgEditor.GetScaledValue(value, decimals); 20 | } 21 | 22 | 23 | internal abstract Type Presenter { get; } 24 | 25 | public int CustomId { get; set; } = 0; 26 | public abstract ShapeType ShapeType { get; } 27 | public string Color { get; set; } = "#ff8c00"; 28 | 29 | internal string Fill { get; set; } = "transparent"; 30 | internal double FillOpacity { get; set; } = 1; 31 | internal string Stroke => Color; //Orange 32 | 33 | private int _normalRawStrokeWidth = 3; 34 | private double RawStrokeWidth { get; set; } = 3; 35 | internal string StrokeWidth => GetScaleValue(RawStrokeWidth).ToInvString() + "px"; 36 | 37 | internal string StrokeLinejoin { get; set; } = "round"; 38 | internal string StrokeLinecap { get; set; } = "round"; 39 | 40 | private int RawStrokeDasharray { get; set; } = 0; 41 | internal string StrokeDasharray => GetScaleValue(RawStrokeDasharray).ToInvString(); 42 | internal double StrokeDashoffset { get; set; } 43 | 44 | internal ShapeState State { get; set; } = ShapeState.None; 45 | 46 | 47 | //Logic for visual styles - for changing selectedState use method from SvgEditor 48 | internal void SelectShape() 49 | { 50 | State = ShapeState.Selected; 51 | 52 | //Visual select logic 53 | RawStrokeWidth = _normalRawStrokeWidth * 1.5; 54 | RawStrokeDasharray = 10; 55 | StrokeDashoffset = 0; 56 | Fill = Color; 57 | FillOpacity = 0.4; 58 | } 59 | 60 | internal void UnSelectShape() 61 | { 62 | State = ShapeState.None; 63 | 64 | //Visual unselect logic 65 | RawStrokeWidth = _normalRawStrokeWidth; 66 | RawStrokeDasharray = 0; 67 | Fill = "transparent"; 68 | FillOpacity = 1; 69 | } 70 | 71 | internal void HoverShape() 72 | { 73 | if (State == ShapeState.Selected) return; 74 | 75 | State = ShapeState.Hovered; 76 | 77 | //Visual hover logic 78 | Fill = Color; 79 | FillOpacity = 0.2; 80 | } 81 | 82 | internal void UnHoverShape() 83 | { 84 | if (State != ShapeState.Hovered) return; 85 | 86 | State = ShapeState.None; 87 | 88 | //Visual unhover logic 89 | Fill = "transparent"; 90 | FillOpacity = 1; 91 | } 92 | 93 | public abstract BoundingBox Bounds { get; } 94 | 95 | internal abstract void SnapToInteger(); 96 | internal abstract void HandlePointerMove(PointerEventArgs eventArgs); 97 | internal abstract Task HandlePointerUp(PointerEventArgs eventArgs); 98 | internal abstract void HandlePointerOut(PointerEventArgs eventArgs); 99 | 100 | internal virtual async Task Complete() 101 | { 102 | await SvgEditor.ShapeAddedCompleted(this); 103 | } 104 | 105 | 106 | 107 | protected async Task FireOnShapeChangedMove() => await SvgEditor.OnShapeChanged.InvokeAsync(ShapeChangedEventArgs.ShapeMoved(this)); 108 | protected async Task FireOnShapeChangedEdit() => await SvgEditor.OnShapeChanged.InvokeAsync(ShapeChangedEventArgs.ShapeEdited(this)); 109 | 110 | public override string ToString() 111 | { 112 | return $"{GetType().Name}: {Bounds}"; 113 | } 114 | } 115 | 116 | internal enum ShapeState 117 | { 118 | None, 119 | Selected, 120 | Hovered, 121 | } -------------------------------------------------------------------------------- /BlazorSvgEditor.SvgEditor/Shapes/Circle.cs: -------------------------------------------------------------------------------- 1 | using BlazorSvgEditor.SvgEditor.Helper; 2 | using BlazorSvgEditor.SvgEditor.Misc; 3 | using BlazorSvgEditor.SvgEditor.ShapeEditors; 4 | using Microsoft.AspNetCore.Components.Web; 5 | 6 | namespace BlazorSvgEditor.SvgEditor.Shapes; 7 | 8 | public class Circle : Shape 9 | { 10 | 11 | public Circle(SvgEditor svgEditor) : base(svgEditor) { } 12 | 13 | internal override Type Presenter => typeof(CircleEditor); 14 | public override ShapeType ShapeType => ShapeType.Circle; 15 | 16 | //Own Properties 17 | public double Cx { get; set; } 18 | public double Cy { get; set; } 19 | public double R { get; set; } 20 | 21 | public override BoundingBox Bounds => new BoundingBox(Cx -R, Cy - R, Cx + R, Cy + R); 22 | 23 | internal override void SnapToInteger() 24 | { 25 | Cx = Cx.ToInt(); 26 | Cy = Cy.ToInt(); 27 | R = R.ToInt(); 28 | } 29 | 30 | bool isMoved = false; 31 | 32 | internal override void HandlePointerMove(PointerEventArgs eventArgs) 33 | { 34 | var point = SvgEditor.DetransformPoint(eventArgs.OffsetX, eventArgs.OffsetY); 35 | 36 | switch (SvgEditor.EditMode) 37 | { 38 | case EditMode.Add: 39 | var askedRadius = Math.Max(Math.Abs(point.X - Cx), Math.Abs(point.Y - Cy)); 40 | R = GetMaxRadius(SvgEditor.ImageBoundingBox, new Coord(Cx, Cy), askedRadius).Round(); 41 | 42 | break; 43 | 44 | case EditMode.Move: 45 | var diff = (point - SvgEditor.MoveStartDPoint); 46 | var result = BoundingBox.GetAvailableMovingCoord(SvgEditor.ImageBoundingBox, Bounds, diff); 47 | 48 | Cx = (Cx + result.X).Round(); 49 | Cy = (Cy + result.Y).Round(); 50 | 51 | isMoved = true; 52 | break; 53 | case EditMode.MoveAnchor: 54 | 55 | SvgEditor.SelectedAnchorIndex ??= 0; 56 | 57 | switch (SvgEditor.SelectedAnchorIndex) 58 | { 59 | case 0: 60 | case 1: 61 | R = GetMaxRadius(SvgEditor.ImageBoundingBox, new Coord(Cx, Cy), point.X - Cx).Round(); 62 | break; 63 | case 2: 64 | case 3: 65 | R = GetMaxRadius(SvgEditor.ImageBoundingBox, new Coord(Cx, Cy), point.Y - Cy).Round(); 66 | break; 67 | } 68 | 69 | if (R < 1) R = 1; //Mindestgröße des Kreises 70 | 71 | break; 72 | } 73 | } 74 | 75 | internal override async Task HandlePointerUp(PointerEventArgs eventArgs) 76 | { 77 | if (SvgEditor.EditMode == EditMode.Add) 78 | { 79 | if (R == 0) R = GetMaxRadius(SvgEditor.ImageBoundingBox, new Coord(Cx, Cy), 15); //Wenn Radius 0 ist, wurde der Kreis nur durch ein Klicken erzeugt, also wird er auf 15 gesetzt 80 | await Complete(); 81 | } 82 | 83 | if (SvgEditor.EditMode == EditMode.Move && isMoved) 84 | { 85 | isMoved = false; 86 | await FireOnShapeChangedMove(); 87 | } 88 | else if (SvgEditor.EditMode == EditMode.MoveAnchor) await FireOnShapeChangedEdit(); 89 | 90 | SvgEditor.EditMode = EditMode.None; 91 | 92 | } 93 | 94 | internal override void HandlePointerOut(PointerEventArgs eventArgs) 95 | { 96 | throw new NotImplementedException(); 97 | } 98 | 99 | 100 | 101 | 102 | //Own BoundingBox Methods because Radius makes it more complicated 103 | private double GetMaxRadius(BoundingBox outerBox, Coord centerCoord, double askedRadius) 104 | { 105 | var availableMovingValues = BoundingBox.GetAvailableMovingValues(outerBox, centerCoord); 106 | var maxRadius = Math.Min(Math.Min(availableMovingValues.Top, availableMovingValues.Left), Math.Min(availableMovingValues.Bottom, availableMovingValues.Right)); 107 | 108 | if (Math.Abs(askedRadius) > maxRadius) return maxRadius; 109 | return Math.Abs(askedRadius); 110 | } 111 | 112 | } -------------------------------------------------------------------------------- /BlazorSvgEditor.WasmTest/Pages/Preview.razor.cs: -------------------------------------------------------------------------------- 1 | using BlazorSvgEditor.SvgEditor.Misc; 2 | using BlazorSvgEditor.SvgEditor.Shapes; 3 | 4 | namespace BlazorSvgEditor.WasmTest.Pages; 5 | 6 | using BlazorSvgEditor.SvgEditor; 7 | 8 | public partial class Preview 9 | { 10 | private SvgEditor? svgEditor; 11 | private int SelectedShapeId { get; set; } 12 | private bool ReadOnly { get; set; } = true; 13 | 14 | 15 | private List Shapes = new(); 16 | string status = "--Status--"; 17 | 18 | private ImageManipulations ImageManipulations = new(); 19 | 20 | private string imageUrl = "example01.png"; 21 | private string delayString = "1000"; 22 | 23 | 24 | protected override async Task OnAfterRenderAsync(bool firstRender) 25 | { 26 | if (firstRender) 27 | { 28 | if (svgEditor == null) return; 29 | await svgEditor.ReloadImage(); 30 | await svgEditor.AddExistingShape(new Circle(svgEditor){CustomId = 1, Cx = 400, Cy = 300 , R = 100}); 31 | await svgEditor.AddExistingShape(new Rectangle(svgEditor){CustomId = 2, X = 700, Y = 400 , Width = 100, Height = 50}); 32 | } 33 | await base.OnAfterRenderAsync(firstRender); 34 | } 35 | 36 | 37 | private void AddShape(ShapeType shapeType, string? color = null) => svgEditor?.AddNewShape(shapeType, color); 38 | private void ShapeSelected(int shapeId) => SelectedShapeId = shapeId; 39 | private void ResetTransform() => svgEditor?.ResetTransform(); 40 | private void ClearAll() => svgEditor?.ClearShapes(); 41 | 42 | 43 | private async Task ReloadEditorImage() 44 | { 45 | if (svgEditor == null) return; 46 | await svgEditor.ReloadImage(); 47 | } 48 | 49 | private async Task<(string imageSource, int width, int height)> GetImageSource() 50 | { 51 | ImageManipulations = new(); 52 | await Task.Delay(int.Parse(delayString)); 53 | return (imageUrl, 1000, 750); 54 | } 55 | 56 | private void EditorShapeChanged(ShapeChangedEventArgs e) 57 | { 58 | if (e.ChangeType == ShapeChangeType.Add && e.Shape?.CustomId <= 0) //Wenn das Shape neu ist und es noch keine ID hat... 59 | { 60 | //Get new id 61 | var newId = Shapes.Any() ? Shapes.Max(x => x.CustomId) + 1 : 1; 62 | e.Shape.CustomId = newId; 63 | } 64 | 65 | 66 | switch (e.ChangeType) 67 | { 68 | case ShapeChangeType.Add: 69 | status = $"{e.Shape.ShapeType} (Id: {e.Shape.CustomId}) was added"; 70 | break; 71 | case ShapeChangeType.Edit: 72 | status = $"{e.Shape.ShapeType} (Id: {e.Shape.CustomId}) was edited"; 73 | break; 74 | case ShapeChangeType.Delete: 75 | status = $"Shape was deleted"; 76 | break; 77 | case ShapeChangeType.ClearAll: 78 | status = "All shapes were deleted"; 79 | break; 80 | case ShapeChangeType.Move: 81 | status = $"{e.Shape.ShapeType} (Id: {e.Shape.CustomId}) was moved"; 82 | break; 83 | default: 84 | throw new ArgumentOutOfRangeException(); 85 | } 86 | 87 | if (e.ChangeType == ShapeChangeType.Delete) 88 | { 89 | Shapes.Remove(Shapes.First(x => x.CustomId == e.ShapeId)); 90 | return; 91 | } 92 | 93 | if (e.ChangeType == ShapeChangeType.ClearAll) 94 | { 95 | Shapes.Clear(); 96 | return; 97 | } 98 | 99 | 100 | if (Shapes.Any(x => x.CustomId == e.Shape?.CustomId)) 101 | { 102 | //Remove old shape 103 | Shapes.Remove(Shapes.First(x => x.CustomId == e.Shape?.CustomId)); 104 | } 105 | 106 | Shapes.Add(e.Shape); 107 | 108 | Shapes = Shapes.OrderBy(x => x.CustomId).ToList(); 109 | } 110 | 111 | private void DeleteShape() 112 | { 113 | svgEditor?.RemoveSelectedShape(); 114 | } 115 | 116 | private async Task ZoomToShape() 117 | { 118 | if (svgEditor == null) return; 119 | if (SelectedShapeId <= 0) return; 120 | await svgEditor.ZoomToShape(SelectedShapeId); 121 | } 122 | } -------------------------------------------------------------------------------- /BlazorSvgEditor.SvgEditor/SvgEditor.razor: -------------------------------------------------------------------------------- 1 | @implements IAsyncDisposable 2 | 3 | @using System.Globalization 4 | @using BlazorSvgEditor.SvgEditor.Helper 5 | 6 | 7 |
21 | 22 | 23 | @if (ShowLoadingSpinner) 24 | { 25 | @LoadingSpinner 26 | } 27 | else 28 | { 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | @foreach (var shape in Shapes) 39 | { 40 | 41 | } 42 | 43 | 44 | @if (ShowDiagnosticInformation) 45 | { 46 | 47 | Scale 1:@(Math.Round(Scale, 3).ToString()) 48 |
49 | Translate: X: @(Math.Round(Translate.X, 1))px Y: @(Math.Round(Translate.Y, 1))px 50 |
51 | Pointer Offset: X: @(_pointerPosition.X)px Y: @(_pointerPosition.Y)px 52 |
53 | Detramsform: X: @(Math.Round((_pointerPosition.X - Translate.X) / Scale, 1))px Y: @(Math.Round(_pointerPosition.Y / Scale - Translate.Y / Scale, 1))px 54 |
55 | 56 | 57 | ContainerBox: X: @(Math.Round(_containerBoundingBox.Width, 1))px Y: @(Math.Round(_containerBoundingBox.Height, 1))px 58 | 59 | 60 | 61 | @foreach(var shape in Shapes) 62 | { 63 | @shape.ToString() 64 |
65 | } 66 |
67 | } 68 | 69 |
70 | 71 | } 72 | 73 |
74 | 75 | @if (EnableImageManipulations) 76 | { 77 | 82 | } 83 | 84 | 95 | -------------------------------------------------------------------------------- /BlazorSvgEditor.MsTest/BoundingBoxTest.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using BlazorSvgEditor.SvgEditor; 3 | using BlazorSvgEditor.SvgEditor.Helper; 4 | using BlazorSvgEditor.SvgEditor.Misc; 5 | 6 | namespace BlazorSvgEditor.MsTest; 7 | 8 | [TestClass] 9 | public class BoundingBoxTest 10 | { 11 | [TestInitialize] 12 | public void Setuo() 13 | { 14 | Console.WriteLine("Setup test enviorment..."); 15 | } 16 | 17 | 18 | [TestMethod("Get Available Moving Values With (double) Coords")] 19 | public void GetAvailableMovingValuesWithCoordsDoubleTest() 20 | { 21 | var coord = new Coord(30.15, 40.37); 22 | var outerBox = new BoundingBox(40.5, 60.6); 23 | 24 | var res = BoundingBox.GetAvailableMovingValues(outerBox, coord); 25 | 26 | Assert.IsTrue(res.Left.IsEqual(30.15)); 27 | Assert.IsTrue(res.Top.IsEqual(40.37)); 28 | Assert.IsTrue(res.Right.IsEqual(10.35)); 29 | Assert.IsTrue(res.Bottom.IsEqual(20.23)); 30 | } 31 | 32 | [TestMethod("Get Available Moving Values With (int) Coords")] 33 | public void GetAvailableMovingValuesWithCoordsIntTest() 34 | { 35 | var coord = new Coord(30, 40); 36 | var outerBox = new BoundingBox(40.5, 60.6); 37 | 38 | var res = BoundingBox.GetAvailableMovingValues(outerBox, coord); 39 | 40 | Assert.IsTrue(res.Left.IsEqual(30)); 41 | Assert.IsTrue(res.Top.IsEqual(40)); 42 | Assert.IsTrue(res.Right.IsEqual(10.5)); 43 | Assert.IsTrue(res.Bottom.IsEqual(20.6)); 44 | } 45 | 46 | [TestMethod("Get Available Moving Coord With (double) Coords")] 47 | public void GetAvailableMoovingCoordTest() 48 | { 49 | Stopwatch sw = Stopwatch.StartNew(); 50 | 51 | var actualCoord = new Coord(30, 40); 52 | var moovingCoord = new Coord(10.7, -20); 53 | var outerBox = new BoundingBox(40.5, 60.6); 54 | var availableMovingValues = BoundingBox.GetAvailableMovingValues(outerBox, actualCoord); 55 | var res = BoundingBox.GetAvailableMovingCoord(availableMovingValues, moovingCoord); 56 | 57 | Assert.IsTrue(res.X.IsEqual(10.5)); 58 | Assert.IsTrue(res.Y.IsEqual(-20)); 59 | 60 | var res2 = BoundingBox.GetAvailableMovingCoord(outerBox, actualCoord, moovingCoord); 61 | 62 | Assert.IsTrue(res2.X.IsEqual(10.5)); 63 | Assert.IsTrue(res2.Y.IsEqual(-20)); 64 | 65 | var res3 = BoundingBox.GetAvailableMovingCoord(outerBox, actualCoord, new Coord(10.4, -40)); 66 | 67 | Assert.IsTrue(res3.X.IsEqual(10.4)); 68 | Assert.IsTrue(res3.Y.IsEqual(-40)); 69 | 70 | sw.Stop(); 71 | Console.WriteLine("Elapsed={0}ms ({1}µs)",sw.Elapsed.TotalMilliseconds, sw.Elapsed.TotalMicroseconds ); 72 | } 73 | 74 | 75 | [TestMethod("Get Available Result Coord")] 76 | public void GetAvailableResultCoord() 77 | { 78 | Stopwatch sw = Stopwatch.StartNew(); 79 | 80 | var outerBox = new BoundingBox(700, 394); //Normales Bild 81 | 82 | var res1 = BoundingBox.GetAvailableResultCoord(outerBox, new Coord(32, 34)); 83 | Assert.IsTrue(res1.X.IsEqual(32)); 84 | Assert.IsTrue(res1.Y.IsEqual(34)); 85 | 86 | var res2 = BoundingBox.GetAvailableResultCoord(outerBox, new Coord(-32, -34)); 87 | Assert.IsTrue(res2.X.IsEqual(0)); 88 | Assert.IsTrue(res2.Y.IsEqual(0)); 89 | 90 | var res3 = BoundingBox.GetAvailableResultCoord(outerBox, new Coord(-32, 34)); 91 | Assert.IsTrue(res3.X.IsEqual(0)); 92 | Assert.IsTrue(res3.Y.IsEqual(34)); 93 | 94 | var res4 = BoundingBox.GetAvailableResultCoord(outerBox, new Coord(32, -34)); 95 | Assert.IsTrue(res4.X.IsEqual(32)); 96 | Assert.IsTrue(res4.Y.IsEqual(0)); 97 | 98 | var res5 = BoundingBox.GetAvailableResultCoord(outerBox, new Coord(-32, 0)); 99 | Assert.IsTrue(res5.X.IsEqual(0)); 100 | Assert.IsTrue(res5.Y.IsEqual(0)); 101 | 102 | var res6 = BoundingBox.GetAvailableResultCoord(outerBox, new Coord(54.334, -34.45)); 103 | Assert.IsTrue(res6.X.IsEqual(54.334)); 104 | Assert.IsTrue(res6.Y.IsEqual(0)); 105 | 106 | sw.Stop(); 107 | Console.WriteLine("Elapsed={0}ms ({1}µs)",sw.Elapsed.TotalMilliseconds, sw.Elapsed.TotalMicroseconds ); 108 | 109 | } 110 | 111 | } -------------------------------------------------------------------------------- /BlazorSvgEditor.SvgEditor/Misc/BoundingBox.cs: -------------------------------------------------------------------------------- 1 | using BlazorSvgEditor.SvgEditor.Helper; 2 | 3 | namespace BlazorSvgEditor.SvgEditor.Misc; 4 | 5 | public struct JsBoundingBox 6 | { 7 | public double Width { get; set; } 8 | 9 | public double Height { get; set; } 10 | 11 | public double X { get; set; } 12 | public double Y { get; set; } 13 | 14 | } 15 | 16 | public struct BoundingBox 17 | { 18 | public double Left { get; set; } 19 | public double Top { get; set; } 20 | public double Right { get; set; } 21 | public double Bottom { get; set; } 22 | 23 | public double XPos => Right; 24 | public double YPos => Bottom; 25 | public double XNeg => Left; 26 | public double YNeg => Top; 27 | 28 | public double Width => Right - Left; 29 | public double Height => Bottom - Top; 30 | 31 | public BoundingBox(double left, double top, double right, double bottom) 32 | { 33 | Left = left; 34 | Top = top; 35 | Right = right; 36 | Bottom = bottom; 37 | } 38 | 39 | public BoundingBox(double width, double height) 40 | { 41 | Left = 0; 42 | Top = 0; 43 | Right = width; 44 | Bottom = height; 45 | } 46 | 47 | public override string ToString() 48 | { 49 | return $"L: {Left.ToInvString()}, T: {Top.ToInvString()}, R: {Right.ToInvString()}, B: {Bottom.ToInvString()}"; 50 | } 51 | 52 | public static BoundingBox GetAvailableMovingValues(BoundingBox outerBox, Coord coords) 53 | { 54 | return new BoundingBox( 55 | coords.X, 56 | coords.Y, 57 | outerBox.Right - coords.X, 58 | outerBox.Bottom - coords.Y 59 | ); 60 | } 61 | 62 | public static BoundingBox GetAvailableMovingValues(BoundingBox outerBox, Coord coords) 63 | { 64 | return GetAvailableMovingValues(outerBox, (Coord) coords); 65 | } 66 | 67 | public static BoundingBox GetAvailableMovingValues(BoundingBox outerBox, BoundingBox innerBox) 68 | { 69 | return new BoundingBox( 70 | innerBox.Left, 71 | innerBox.Top, 72 | outerBox.Right - innerBox.Right, 73 | outerBox.Bottom - innerBox.Bottom 74 | ); 75 | } 76 | 77 | //Für 1.000.000 Aufrufe ca. 100ms (ohne Debug) - 1 Aufruf ca. 20ms 78 | public static Coord GetAvailableMovingCoord(BoundingBox maxMovingValues, Coord movingCoord) 79 | { 80 | Coord result = new(); 81 | 82 | if (movingCoord.X <= 0) //Moving left 83 | { 84 | if (Math.Abs(movingCoord.X) > maxMovingValues.Left) result.X = -maxMovingValues.Left; 85 | else result.X = movingCoord.X; 86 | } 87 | else //Moving right 88 | { 89 | if (movingCoord.X > maxMovingValues.Right) movingCoord.X = maxMovingValues.Right; 90 | result.X = movingCoord.X; 91 | } 92 | 93 | if (movingCoord.Y <= 0) //Moving up 94 | { 95 | if (Math.Abs(movingCoord.Y) > maxMovingValues.Top) result.Y = -maxMovingValues.Top; 96 | else result.Y = movingCoord.Y; 97 | } 98 | else //Moving down 99 | { 100 | if (movingCoord.Y > maxMovingValues.Bottom) movingCoord.Y = maxMovingValues.Bottom; 101 | result.Y = movingCoord.Y; 102 | } 103 | 104 | return result; 105 | } 106 | 107 | //Methode die AvailableMovingValues und AvaiableMoovingCoords vereint 108 | public static Coord GetAvailableMovingCoord(BoundingBox outerBox, BoundingBox innerBox, Coord movingCoord) 109 | { 110 | return GetAvailableMovingCoord(GetAvailableMovingValues(outerBox, innerBox), movingCoord); 111 | } 112 | public static Coord GetAvailableMovingCoord(BoundingBox outerBox, Coord coords, Coord movingCoord) 113 | { 114 | return GetAvailableMovingCoord(GetAvailableMovingValues(outerBox, coords), movingCoord); 115 | } 116 | 117 | 118 | public static Coord GetAvailableResultCoord(BoundingBox outerBox, Coord coord) 119 | { 120 | var result = new Coord(); 121 | 122 | if (coord.X < outerBox.Left) result.X = outerBox.Left; 123 | else if (coord.X >= outerBox.Left && coord.X <= outerBox.Right) result.X = coord.X; 124 | else if (coord.X > outerBox.Right) result.X = outerBox.Right; 125 | 126 | if (coord.Y < outerBox.Top) result.Y = outerBox.Top; 127 | else if (coord.Y >= outerBox.Top && coord.Y <= outerBox.Bottom) result.Y = coord.Y; 128 | else if (coord.Y > outerBox.Bottom) result.Y = outerBox.Bottom; 129 | 130 | return result; 131 | } 132 | } -------------------------------------------------------------------------------- /BlazorSvgEditor.SvgEditor/Shapes/Polygon.cs: -------------------------------------------------------------------------------- 1 | using BlazorSvgEditor.SvgEditor.Helper; 2 | using BlazorSvgEditor.SvgEditor.Misc; 3 | using BlazorSvgEditor.SvgEditor.ShapeEditors; 4 | using Microsoft.AspNetCore.Components.Web; 5 | 6 | namespace BlazorSvgEditor.SvgEditor.Shapes; 7 | 8 | public class Polygon : Shape 9 | { 10 | public Polygon(SvgEditor svgEditor) : base(svgEditor){} 11 | 12 | internal override Type Presenter => typeof(PolygonEditor); 13 | public override ShapeType ShapeType => ShapeType.Polygon; 14 | 15 | //Own Properties 16 | 17 | public List> Points { get; set; } = new(); 18 | internal string PointsString => Points.Aggregate("", (current, point) => current + $"{point.X.ToInvString()},{point.Y.ToInvString()} "); 19 | 20 | private bool _addNewPointOnCreate = true; 21 | 22 | //Create Polygon Anchor Settings 23 | private double _polygonCompleteThreshold => 10; 24 | 25 | bool isMoved = false; 26 | internal override void HandlePointerMove(PointerEventArgs eventArgs) 27 | { 28 | var point = SvgEditor.DetransformPoint(eventArgs.OffsetX, eventArgs.OffsetY); 29 | Coord resultCoord = BoundingBox.GetAvailableResultCoord(SvgEditor.ImageBoundingBox, point); 30 | 31 | switch (SvgEditor.EditMode) 32 | { 33 | case EditMode.Add: 34 | if (_addNewPointOnCreate) 35 | { 36 | _addNewPointOnCreate = false; 37 | Points.Add(point); 38 | } 39 | 40 | Points[^1] = resultCoord; 41 | 42 | break; 43 | 44 | case EditMode.Move: 45 | var diff = (point - SvgEditor.MoveStartDPoint); 46 | var result = BoundingBox.GetAvailableMovingCoord(SvgEditor.ImageBoundingBox, Bounds, diff); 47 | 48 | List> newPoints = new List>(); 49 | foreach (var p in Points) 50 | { 51 | var newPoint = (p + result); 52 | newPoints.Add(newPoint); 53 | } 54 | Points = newPoints; 55 | 56 | isMoved = true; 57 | break; 58 | case EditMode.MoveAnchor: 59 | 60 | SvgEditor.SelectedAnchorIndex ??= 0; 61 | 62 | if (SvgEditor.SelectedAnchorIndex < Points.Count) //wenn ja, dann ist es ein "echter" Anchor 63 | { 64 | Points[SvgEditor.SelectedAnchorIndex.Value] = resultCoord; 65 | } 66 | else 67 | { 68 | int index = SvgEditor.SelectedAnchorIndex.Value - Points.Count + 1; 69 | var coord = new Coord(point); 70 | Points.Insert(index, coord); 71 | SvgEditor.SelectedAnchorIndex = index; 72 | } 73 | break; 74 | } 75 | } 76 | 77 | //Delete Point 78 | internal void OnAnchorDoubleClicked(int anchorIndex) 79 | { 80 | if (SvgEditor.EditMode == EditMode.Add) return; 81 | if (Points.Count <= 3) return; //Mindestens 3 Punkte für ein Polygon 82 | if (anchorIndex < Points.Count) //wenn ja, dann ist es ein "echter" Anchor 83 | { 84 | Points.RemoveAt(anchorIndex); 85 | } 86 | } 87 | 88 | 89 | internal override async Task HandlePointerUp(PointerEventArgs eventArgs) 90 | { 91 | if (SvgEditor.EditMode == EditMode.Add) 92 | { 93 | if (eventArgs.Button == 2 && Points.Count > 3) //Right button clicked -> End of Polygon (if possible) 94 | { 95 | //Ende des Polygonss 96 | Points.RemoveAt(Points.Count - 1); 97 | await Complete(); 98 | return; 99 | } 100 | 101 | if (Coord.Distance(Points[^1], Points[0]) < _polygonCompleteThreshold && Points.Count > 3) //Es müssen mehr als 3 Punkte sein da gleich ja einer entfernt wird 102 | { 103 | //Ende des Polygonss 104 | Points.RemoveAt(Points.Count - 1); 105 | await Complete(); 106 | } 107 | if (!(Coord.Distance(Points[^1], Points[0]) < _polygonCompleteThreshold)) //Die Punkte sind zu nah beieinander - keinen neuen Punkt im Polygon erstellen 108 | { 109 | _addNewPointOnCreate = true; 110 | } 111 | } 112 | else 113 | { 114 | if (SvgEditor.EditMode == EditMode.Move && isMoved) 115 | { 116 | isMoved = false; 117 | await FireOnShapeChangedMove(); 118 | } 119 | 120 | else if (SvgEditor.EditMode == EditMode.MoveAnchor) await FireOnShapeChangedEdit(); 121 | 122 | SvgEditor.EditMode = EditMode.None; 123 | } 124 | } 125 | 126 | 127 | public override BoundingBox Bounds => new() 128 | { 129 | Left = Points.OrderBy(x => x.X).FirstOrDefault().X, 130 | Right = Points.OrderByDescending(x => x.X).FirstOrDefault().X, 131 | Top = Points.OrderBy(x => x.Y).FirstOrDefault().Y, 132 | Bottom = Points.OrderByDescending(x => x.Y).FirstOrDefault().Y, 133 | }; 134 | 135 | internal override void SnapToInteger() 136 | { 137 | List> newPoints = new List>(); 138 | foreach (var p in Points) 139 | { 140 | newPoints.Add(new Coord(p.X.ToInt(), p.Y.ToInt())); 141 | } 142 | Points = newPoints; 143 | } 144 | 145 | 146 | 147 | internal override void HandlePointerOut(PointerEventArgs eventArgs) 148 | { 149 | throw new NotImplementedException(); 150 | } 151 | 152 | internal override async Task Complete() 153 | { 154 | await base.Complete(); 155 | SvgEditor.EditMode = EditMode.None; 156 | } 157 | } -------------------------------------------------------------------------------- /BlazorSvgEditor.SvgEditor/Shapes/Rectangle.cs: -------------------------------------------------------------------------------- 1 | using BlazorSvgEditor.SvgEditor.Helper; 2 | using BlazorSvgEditor.SvgEditor.Misc; 3 | using BlazorSvgEditor.SvgEditor.ShapeEditors; 4 | using Microsoft.AspNetCore.Components.Web; 5 | 6 | namespace BlazorSvgEditor.SvgEditor.Shapes; 7 | 8 | public class Rectangle : Shape 9 | { 10 | public Rectangle(SvgEditor svgEditor) : base(svgEditor){ } 11 | 12 | internal override Type Presenter => typeof(RectangleEditor); 13 | public override ShapeType ShapeType => ShapeType.Rectangle; 14 | 15 | public double X { get; set; } 16 | public double Y { get; set; } 17 | public double Width { get; set; } 18 | public double Height { get; set; } 19 | 20 | private Coord AddPosition = new(-1, -1); 21 | 22 | public override BoundingBox Bounds => new BoundingBox(X, Y, X + Width, Y + Height); 23 | 24 | internal override void SnapToInteger() 25 | { 26 | X = X.ToInt(); 27 | Y = Y.ToInt(); 28 | Width = Width.ToInt(); 29 | Height = Height.ToInt(); 30 | } 31 | 32 | bool isMoved = false; 33 | internal override void HandlePointerMove(PointerEventArgs eventArgs) 34 | { 35 | var point = SvgEditor.DetransformPoint(eventArgs.OffsetX, eventArgs.OffsetY); 36 | Coord resultCoord = BoundingBox.GetAvailableResultCoord(SvgEditor.ImageBoundingBox, point); 37 | 38 | switch (SvgEditor.EditMode) 39 | { 40 | case EditMode.Add: 41 | 42 | if (AddPosition.X.IsEqual(-1)) AddPosition = new Coord(X, Y); 43 | 44 | if (resultCoord.X < AddPosition.X) 45 | { 46 | X = resultCoord.X; 47 | Width = AddPosition.X - resultCoord.X; 48 | } 49 | else 50 | { 51 | X = AddPosition.X; 52 | Width = resultCoord.X - AddPosition.X; 53 | } 54 | if (resultCoord.Y < AddPosition.Y) 55 | { 56 | Y = resultCoord.Y; 57 | Height = AddPosition.Y - resultCoord.Y; 58 | } 59 | else 60 | { 61 | Y = AddPosition.Y; 62 | Height = resultCoord.Y - AddPosition.Y; 63 | } 64 | 65 | if (Width < 1) Width = 1; 66 | if (Height < 1) Height = 1; 67 | 68 | break; 69 | 70 | case EditMode.Move: 71 | var diff = (point - SvgEditor.MoveStartDPoint); 72 | var result = BoundingBox.GetAvailableMovingCoord(SvgEditor.ImageBoundingBox, Bounds, diff); 73 | 74 | X += result.X; 75 | Y += result.Y; 76 | 77 | isMoved = true; 78 | break; 79 | 80 | case EditMode.MoveAnchor: 81 | SvgEditor.SelectedAnchorIndex ??= 0; 82 | 83 | switch (SvgEditor.SelectedAnchorIndex) 84 | { 85 | case 0: 86 | Width -= resultCoord.X - X; 87 | Height -= resultCoord.Y - Y; 88 | X = resultCoord.X; 89 | Y = resultCoord.Y; 90 | break; 91 | case 1: 92 | Width = resultCoord.X - X; 93 | Height -= resultCoord.Y - Y; 94 | Y = resultCoord.Y; 95 | break; 96 | case 2: 97 | Width = resultCoord.X - X; 98 | Height = resultCoord.Y - Y; 99 | break; 100 | case 3: 101 | Width -= resultCoord.X - X; 102 | Height = resultCoord.Y - Y; 103 | X = resultCoord.X; 104 | break; 105 | } 106 | 107 | if (Width < 0) 108 | { 109 | Width = -Width; 110 | X -= Width; 111 | if (SvgEditor.SelectedAnchorIndex == 0) SvgEditor.SelectedAnchorIndex = 1; 112 | else if (SvgEditor.SelectedAnchorIndex == 1) SvgEditor.SelectedAnchorIndex = 0; 113 | else if (SvgEditor.SelectedAnchorIndex == 2) SvgEditor.SelectedAnchorIndex = 3; 114 | else if (SvgEditor.SelectedAnchorIndex == 3) SvgEditor.SelectedAnchorIndex = 2; 115 | } 116 | if (Height < 0) 117 | { 118 | Height = -Height; 119 | Y -= Height; 120 | if (SvgEditor.SelectedAnchorIndex == 0) SvgEditor.SelectedAnchorIndex = 3; 121 | else if (SvgEditor.SelectedAnchorIndex == 1) SvgEditor.SelectedAnchorIndex = 2; 122 | else if (SvgEditor.SelectedAnchorIndex == 2) SvgEditor.SelectedAnchorIndex = 1; 123 | else if (SvgEditor.SelectedAnchorIndex == 3) SvgEditor.SelectedAnchorIndex = 0; 124 | } 125 | 126 | if (Width < 1) Width = 1; 127 | if (Height < 1) Height = 1; 128 | 129 | break; 130 | } 131 | } 132 | 133 | internal override async Task HandlePointerUp(PointerEventArgs eventArgs) 134 | { 135 | if (SvgEditor.EditMode == EditMode.Add) 136 | { 137 | if (Width < 1) Width = 1; 138 | if (Height < 1) Height = 1; 139 | await Complete(); 140 | } 141 | 142 | if (SvgEditor.EditMode == EditMode.Move && isMoved) 143 | { 144 | isMoved = false; 145 | await FireOnShapeChangedMove(); 146 | } 147 | else if (SvgEditor.EditMode == EditMode.MoveAnchor) await FireOnShapeChangedEdit(); 148 | 149 | SvgEditor.EditMode = EditMode.None; 150 | } 151 | 152 | internal override void HandlePointerOut(PointerEventArgs eventArgs) 153 | { 154 | throw new NotImplementedException(); 155 | } 156 | } -------------------------------------------------------------------------------- /BlazorSvgEditor.SvgEditor/SvgEditor.Main.cs: -------------------------------------------------------------------------------- 1 | using BlazorSvgEditor.SvgEditor.Helper; 2 | using BlazorSvgEditor.SvgEditor.Misc; 3 | using BlazorSvgEditor.SvgEditor.Shapes; 4 | using Microsoft.AspNetCore.Components; 5 | using Microsoft.JSInterop; 6 | 7 | namespace BlazorSvgEditor.SvgEditor; 8 | 9 | public partial class SvgEditor 10 | { 11 | //Css Class and Style Properties (for the Container) 12 | [Parameter] public string CssClass { get; set; } = string.Empty; 13 | [Parameter] public string CssStyle { get; set; } = string.Empty; 14 | 15 | 16 | [Parameter] public bool ShowDiagnosticInformation { get; set; } = false; //Show Diagnostic Information (for debugging) 17 | 18 | [Parameter] public (int Width, int Height) ImageSize { get; set; } 19 | [Parameter] public string ImageSource { get; set; } = string.Empty; //Can be an link or also a base64 string 20 | 21 | public bool SnapToInteger { get; set; } = true; //Snap to integer coordinates (for performance reasons, it has to be true actually) 22 | 23 | 24 | public BoundingBox ImageBoundingBox = new(); 25 | 26 | 27 | [Parameter] public bool ScaleShapes { get; set; } = true; //Scale shapes with the container 28 | 29 | [Parameter] public double MinScale { get; set; } = 0.4; 30 | 31 | [Parameter] public double MaxScale { get; set; } = 5; 32 | 33 | //Touch Settings 34 | 35 | /// 36 | /// Divides the touch input by 1/TouchSensitivity (higher touch sensitivity means less sensitivity) 37 | /// 38 | [Parameter] public double TouchSensitivity { get; set; } = 40; 39 | private int touchSensitivity => (int) (10000 / TouchSensitivity); 40 | 41 | 42 | public List Shapes { get; set; } = new(); //List of all shapes, is no parameter 43 | 44 | 45 | [Parameter] public EventCallback OnShapeChanged { get; set; } //Event for shape changes 46 | 47 | [Parameter] public EventCallback<(Coord translate, double scale)> TranslationChanged { get; set; } 48 | private async Task InvokeTranslationChanged() => await TranslationChanged.InvokeAsync((Translate, Scale)); 49 | 50 | [Parameter] public EventCallback OnImageLoaded { get; set; } //Event for image loaded 51 | 52 | //ReadOnly 53 | [Parameter] public bool ReadOnly { get; set; } = false; //Is the editor read only? 54 | 55 | 56 | //Selected Shape (intern property) and SelectedShapeId (public property) 57 | private Shape? _selectedShape; 58 | public Shape? SelectedShape 59 | { 60 | get => _selectedShape; 61 | set 62 | { 63 | if (_selectedShape != value) 64 | { 65 | if(ShowDiagnosticInformation) Console.WriteLine("SelectedShape changed from " + _selectedShape?.CustomId + " to " + value?.CustomId); 66 | _selectedShape = value; 67 | SelectedShapeIdChanged.InvokeAsync(SelectedShapeId); 68 | } 69 | } 70 | } 71 | 72 | //SelectedShapeId is the CustomId of the selected shape (public and bindable) 73 | [Parameter] 74 | public int SelectedShapeId 75 | { 76 | get => SelectedShape?.CustomId ?? 0; 77 | set 78 | { 79 | if(value == SelectedShapeId) return; 80 | if (value == 0) 81 | { 82 | SelectedShape?.UnSelectShape(); 83 | SelectedShape = null; 84 | SelectedAnchorIndex = null; 85 | } 86 | else 87 | { 88 | SelectedShape?.UnSelectShape(); 89 | SelectedShape = Shapes.FirstOrDefault(s => s.CustomId == value); 90 | SelectedShape?.SelectShape(); 91 | } 92 | } 93 | } 94 | [Parameter] public EventCallback SelectedShapeIdChanged { get; set; } 95 | [Parameter] public EventCallback OnShapeClicked { get; set; } 96 | 97 | 98 | 99 | //Func for ImageSource Loading Task 100 | [Parameter] public Func>? ImageSourceLoadingFunc { get; set; } 101 | [Parameter] public RenderFragment? LoadingSpinner { get; set; } 102 | 103 | private bool _imageSourceLoading = false; 104 | private bool ShowLoadingSpinner => ImageSourceLoadingFunc != null && _imageSourceLoading; 105 | 106 | 107 | 108 | //Image Manipulations 109 | [Parameter] public bool EnableImageManipulations { get; set; } = true; //Use Image Manipulations (Brightness, Contrast, Saturation, Hue) 110 | [Parameter] public ImageManipulations? ImageManipulations { get; set; } = new(); //Image Manipulations (Brightness, Contrast, Saturation, Hue) 111 | 112 | 113 | public EditMode EditMode { get; set; } = EditMode.None; //Current edit mode 114 | public int? SelectedAnchorIndex { get; set; } = null; //Selected Anchor Index 115 | 116 | 117 | protected override Task OnParametersSetAsync() 118 | { 119 | ImageBoundingBox = new BoundingBox(ImageSize.Width, ImageSize.Height); //Set the ImageBoundingBox to the new ImageSize 120 | 121 | return base.OnParametersSetAsync(); 122 | } 123 | 124 | protected override async Task OnInitializedAsync() 125 | { 126 | //Initialize the task for JsInvokeAsync 127 | moduleTask = new(async () => await JsRuntime.InvokeAsync("import","./_content/BlazorSvgEditor/svgEditor.js")); 128 | 129 | await base.OnInitializedAsync(); 130 | } 131 | 132 | 133 | //Css Class and Style for the Container 134 | private string _containerCssStyle => CssStyle; 135 | 136 | private string _containerCssClass { 137 | get 138 | { 139 | string result = CssClass + " "; 140 | 141 | if (EditMode != EditMode.AddTool) return result.Trim(); 142 | 143 | return ShapeType switch 144 | { 145 | ShapeType.Polygon => result + "cursor-add-polygon", 146 | ShapeType.Rectangle => result + "cursor-add-rectangle", 147 | ShapeType.Circle => result + "cursor-add-circle", 148 | _ => result.Trim() 149 | }; 150 | } 151 | } 152 | } -------------------------------------------------------------------------------- /BlazorSvgEditor.SvgEditor/SvgEditor.PublicMethods.cs: -------------------------------------------------------------------------------- 1 | using BlazorSvgEditor.SvgEditor.Helper; 2 | using BlazorSvgEditor.SvgEditor.Misc; 3 | using BlazorSvgEditor.SvgEditor.Shapes; 4 | 5 | namespace BlazorSvgEditor.SvgEditor; 6 | 7 | public partial class SvgEditor 8 | { 9 | //Methods for component communication 10 | public async Task AddExistingShape(Shape shape) 11 | { 12 | Shapes.Add(shape); 13 | StateHasChanged(); 14 | await OnShapeChanged.InvokeAsync(ShapeChangedEventArgs.ShapeAdded(shape)); 15 | } 16 | 17 | public void AddNewShape(ShapeType shapeType, string? color = null) 18 | { 19 | EditMode = EditMode.AddTool; 20 | ShapeType = shapeType; 21 | 22 | _newShapeColor = color; 23 | 24 | SelectedShape?.UnSelectShape(); 25 | SelectedShape = null; 26 | } 27 | 28 | public void SetEditModeToNone() 29 | { 30 | EditMode = EditMode.None; 31 | SelectedShape?.UnSelectShape(); 32 | SelectedShape = null; 33 | } 34 | 35 | public async Task RemoveSelectedShape() 36 | { 37 | if (SelectedShape != null) 38 | { 39 | int deletedShapeId = SelectedShape.CustomId; 40 | Shapes.Remove(SelectedShape); 41 | SelectedShape = null; 42 | SelectedAnchorIndex = null; 43 | 44 | await OnShapeChanged.InvokeAsync(ShapeChangedEventArgs.ShapeDeleted(deletedShapeId)); 45 | } 46 | else 47 | { 48 | if (ShowDiagnosticInformation) Console.WriteLine("No shape selected - so nothing to delete"); 49 | } 50 | } 51 | 52 | public async Task RemoveShape(int shapeId) 53 | { 54 | Shape? shape = Shapes.FirstOrDefault(s => s.CustomId == shapeId); 55 | if (shape != null) 56 | { 57 | Shapes.Remove(shape); 58 | await OnShapeChanged.InvokeAsync(ShapeChangedEventArgs.ShapeDeleted(shapeId)); 59 | } 60 | else 61 | { 62 | if(ShowDiagnosticInformation) Console.WriteLine("Shape with id " + shapeId + " not found - so nothing to delete"); 63 | } 64 | } 65 | 66 | public async Task ClearShapes() 67 | { 68 | Shapes.Clear(); 69 | await OnShapeChanged.InvokeAsync(ShapeChangedEventArgs.ShapesCleared()); 70 | } 71 | 72 | public async Task ResetTransform() 73 | { 74 | await SetContainerBoundingBox(); 75 | await ResetTransformation(); 76 | } 77 | 78 | 79 | public async Task ZoomToShape(int shapeId, double marginPercentage) 80 | { 81 | Shape? shape = Shapes.FirstOrDefault(s => s.CustomId == shapeId); 82 | if (shape != null) 83 | { 84 | await SetContainerBoundingBox(); 85 | 86 | var shapeWidth = shape.Bounds.Right - shape.Bounds.Left; 87 | var shapeHeight = shape.Bounds.Bottom - shape.Bounds.Top; 88 | var marginPixels = Math.Max(shapeWidth, shapeHeight) * marginPercentage; 89 | 90 | //Die BoundingBox des elements ist im Verhältnis breiter als die des Containers 91 | var shapeBoundingBoxWithMargin = new BoundingBox 92 | { 93 | Left = shape.Bounds.Left - marginPixels, 94 | Right = shape.Bounds.Right + marginPixels, 95 | Top = shape.Bounds.Top - marginPixels, 96 | Bottom = shape.Bounds.Bottom + marginPixels 97 | }; 98 | 99 | ZoomToShape(shapeBoundingBoxWithMargin); 100 | } 101 | } 102 | 103 | public async Task ZoomToShape(int shapeId, int marginPixels) 104 | { 105 | Shape? shape = Shapes.FirstOrDefault(s => s.CustomId == shapeId); 106 | if (shape != null) 107 | { 108 | await SetContainerBoundingBox(); 109 | 110 | var shapeWidth = shape.Bounds.Right - shape.Bounds.Left; 111 | var shapeHeight = shape.Bounds.Bottom - shape.Bounds.Top; 112 | bool isShapeWiderThanContainer = (shapeWidth / shapeHeight) > (_containerBoundingBox.Width / _containerBoundingBox.Height); 113 | 114 | var scaleForCalculatingMargin = isShapeWiderThanContainer ? (double)_containerBoundingBox.Width / shapeWidth : (double)_containerBoundingBox.Height / shapeHeight; 115 | 116 | //Die BoundingBox des elements ist im Verhältnis breiter als die des Containers 117 | var shapeBoundingBoxWithMargin = new BoundingBox 118 | { 119 | Left = shape.Bounds.Left - marginPixels / scaleForCalculatingMargin, 120 | Right = shape.Bounds.Right + marginPixels / scaleForCalculatingMargin, 121 | Top = shape.Bounds.Top - marginPixels / scaleForCalculatingMargin, 122 | Bottom = shape.Bounds.Bottom + marginPixels / scaleForCalculatingMargin 123 | }; 124 | 125 | ZoomToShape(shapeBoundingBoxWithMargin); 126 | } 127 | } 128 | 129 | public async Task ZoomToShape(int shapeId) 130 | { 131 | await ZoomToShape(shapeId, 0.05); 132 | } 133 | 134 | 135 | //Use this method to set the translation to a specific value -> e.g. to syncronize the translation of two SvgEditors 136 | public void SetTranslateAndScale(Coord? newTranslate = null, double? newScale = null) 137 | { 138 | if(newTranslate != null) Translate = newTranslate.Value; 139 | if (newScale != null) Scale = newScale.Value; 140 | StateHasChanged(); 141 | } 142 | public (Coord translation, double scale) GetTranslateAndScale() => (Translate, Scale); 143 | 144 | public async Task ReloadImage() 145 | { 146 | _imageSourceLoading = true; 147 | StateHasChanged(); 148 | 149 | (string imageSource, int width, int height) result; 150 | if (ImageSourceLoadingFunc != null) 151 | { 152 | result = await ImageSourceLoadingFunc(); 153 | ImageSize = (result.width, result.height); 154 | ImageSource = result.imageSource; 155 | } 156 | 157 | _imageSourceLoading = false; 158 | await OnImageLoaded.InvokeAsync(); 159 | StateHasChanged(); 160 | } 161 | 162 | public void Refresh() => StateHasChanged(); 163 | } -------------------------------------------------------------------------------- /BlazorSvgEditor.SvgEditor/SvgEditor.Transformations.cs: -------------------------------------------------------------------------------- 1 | using BlazorSvgEditor.SvgEditor.Helper; 2 | using BlazorSvgEditor.SvgEditor.Misc; 3 | using Microsoft.AspNetCore.Components.Web; 4 | 5 | namespace BlazorSvgEditor.SvgEditor; 6 | 7 | public partial class SvgEditor 8 | { 9 | //TRANSFORMATION Logic 10 | 11 | public double Scale = 1; 12 | private Coord Translate; 13 | 14 | private bool IsTranslating = false; 15 | 16 | internal Coord MoveStartDPoint; 17 | 18 | //Delta is the amount of change in the mouse wheel (+ -> zoom in, - -> zoom out) 19 | private async Task Zoom(double delta, double x, double y) 20 | { 21 | var previousScale = Scale; 22 | var newScale = Scale * (1 - delta / 1000.0); 23 | 24 | if (newScale > MinScale && newScale < MaxScale) Scale = newScale.Round(3); 25 | else if (newScale < MinScale) Scale = MinScale; 26 | else if (newScale > MaxScale) Scale = MaxScale; 27 | 28 | Translate = new (Translate.X + (x - Translate.X) * (1 - Scale / previousScale), Translate.Y + (y - Translate.Y) * (1 - Scale / previousScale)); 29 | Translate = new (Translate.X.Round(3), Translate.Y.Round(3)); 30 | 31 | await InvokeTranslationChanged(); 32 | } 33 | 34 | private async Task TouchZoom(double distanceDelta, Coord containerCenter, Coord delta) 35 | { 36 | //DistanceDelta is the amount of change in the distance between the two fingers (+ -> zoom in, - -> zoom out) 37 | var distanceDeltaFactor = Scale; //Damit das Skalieren zu jeder Zeit gleichmäßig ist, wird die Distanz mit dem aktuellen Scale multipliziert 38 | 39 | if (Scale < MinScale && distanceDelta < 0) distanceDeltaFactor = 0; //Wenn Scale kleiner als MinScale ist, darf nicht herausgezoomt werden 40 | else if (Scale > MaxScale && distanceDelta > 0) distanceDeltaFactor = 0; //Wenn Scale größer als MaxScale ist, darf nicht hereingezoomt werden 41 | 42 | var newScale = Scale + (distanceDelta * distanceDeltaFactor) / touchSensitivity; 43 | 44 | var previousScale = Scale; 45 | Scale = newScale; 46 | 47 | //Set the translation, that the center of the container stays in the center of the image and pan it by the delta 48 | Translate = new (Translate.X + (containerCenter.X - Translate.X) * (1 - Scale / previousScale) + delta.X, Translate.Y + (containerCenter.Y - Translate.Y) * (1 - Scale / previousScale) + delta.Y); 49 | Translate = new (Translate.X.Round(3), Translate.Y.Round(3)); 50 | 51 | await InvokeTranslationChanged(); 52 | } 53 | 54 | 55 | //x and y are the amount of change the current translation 56 | private async Task Pan(double x, double y) 57 | { 58 | Translate.X = (Translate.X + x).Round(3); 59 | Translate.Y = (Translate.Y + y).Round(3); 60 | 61 | await InvokeTranslationChanged(); 62 | } 63 | 64 | 65 | 66 | private async Task ResetTransformation() 67 | { 68 | var containerRatio = (double)_containerBoundingBox.Width / _containerBoundingBox.Height; 69 | var imageRatio = (double)ImageSize.Width / ImageSize.Height; 70 | 71 | Translate = Coord.Zero; 72 | 73 | if (containerRatio > imageRatio) 74 | { 75 | //Das Bild passt von der Breite her in den Container, aber nicht von der Höhe her 76 | Scale = (double)_containerBoundingBox.Height / ImageSize.Height; 77 | 78 | var newImageWidth = Scale * ImageSize.Width; 79 | Translate = new (Translate.X + (_containerBoundingBox.Width - newImageWidth) / 2, Translate.Y); 80 | } 81 | else 82 | { 83 | Scale = (double)_containerBoundingBox.Width / ImageSize.Width; 84 | 85 | var newImageHeight = Scale * ImageSize.Height; 86 | Translate = new (Translate.X, Translate.Y + (_containerBoundingBox.Height - newImageHeight) / 2); 87 | } 88 | 89 | StateHasChanged(); 90 | 91 | await InvokeTranslationChanged(); 92 | } 93 | 94 | private void ZoomToShape(BoundingBox shapeBoundingBox) 95 | { 96 | var newShapeWidth = shapeBoundingBox.Right - shapeBoundingBox.Left; 97 | var newShapeHeight = shapeBoundingBox.Bottom - shapeBoundingBox.Top; 98 | 99 | bool isShapeWiderThanContainer = (newShapeWidth / newShapeHeight) > (_containerBoundingBox.Width / _containerBoundingBox.Height); 100 | 101 | if (isShapeWiderThanContainer) 102 | { 103 | Scale = (double)_containerBoundingBox.Width / newShapeWidth; 104 | 105 | var translateX = (shapeBoundingBox.Left * Scale * -1) + (_containerBoundingBox.Width - newShapeWidth * Scale) / 2; 106 | var translateY = (shapeBoundingBox.Top * Scale * -1) + (_containerBoundingBox.Height - newShapeHeight * Scale) / 2; 107 | 108 | Translate = new (translateX, translateY); 109 | } 110 | else 111 | { 112 | Scale = (double)_containerBoundingBox.Height / newShapeHeight; 113 | 114 | var translateX = (shapeBoundingBox.Left * Scale * -1) + (_containerBoundingBox.Width - newShapeWidth * Scale) / 2; 115 | var translateY = (shapeBoundingBox.Top * Scale * -1) + (_containerBoundingBox.Height - newShapeHeight * Scale) / 2; 116 | 117 | Translate = new (translateX, translateY); 118 | } 119 | } 120 | 121 | 122 | 123 | internal double GetScaledValue(double value, int decimals = 1) => (value *(1/ Scale )).Round(decimals); 124 | 125 | 126 | //Transformation Logic 127 | 128 | //Rechnet die Koordinaten des Mauszeigers in die Koordinaten des SVG-Elements um 129 | internal Coord DetransformPoint(Coord point) 130 | { 131 | Coord result = new() 132 | { 133 | X = (point.X - Translate.X) / Scale, 134 | Y = (point.Y - Translate.Y) / Scale 135 | }; 136 | return result; 137 | } 138 | internal Coord DetransformPoint(double x, double y) 139 | { 140 | return DetransformPoint(new Coord(x, y)); 141 | } 142 | internal Coord DetransformOffset(PointerEventArgs pointerEventArgs) 143 | { 144 | return DetransformPoint(new Coord(pointerEventArgs.OffsetX, pointerEventArgs.OffsetY)); 145 | } 146 | } -------------------------------------------------------------------------------- /BlazorSvgEditor.WasmTest/Pages/Index.razor: -------------------------------------------------------------------------------- 1 | @page "/t" 2 | @using BlazorSvgEditor.SvgEditor 3 | @using BlazorSvgEditor.SvgEditor.Helper 4 | @using BlazorSvgEditor.SvgEditor.Misc 5 | @using BlazorSvgEditor.SvgEditor.Shapes 6 | @using SvgEditor = BlazorSvgEditor.SvgEditor.SvgEditor 7 | 8 |
9 | 10 |
11 | 13 | 14 | 15 |
16 |
17 | 21 | Loading... 22 |
23 |
24 | 25 |
26 |
27 |
28 | 29 |
30 |

Events

31 | 32 | 33 |
34 | 35 | 36 | 37 | 38 | 39 |
40 | 41 | 42 |
43 | Selected Item Id: @_selectedItemId 44 |
45 | 46 |
47 | Select Shape: 48 | 49 | 50 |
51 | 52 |
53 | Select Shape: 54 | 55 | 56 | 57 | 58 | 59 | 60 |
61 | 62 | @foreach (var shape in Changes) 63 | { 64 |

@shape

65 |
66 | } 67 |
68 | 69 | 70 |
71 | ICH BIN EIN FOOTER 72 |
73 |
74 | 75 | 95 | 96 | 97 | @code 98 | { 99 | 100 | string imageSource = "https://www.bentleymotors.com/content/dam/bentley/Master/World%20of%20Bentley/Mulliner/redesign/coachbuilt/Mulliner%20Batur%201920x1080.jpg/_jcr_content/renditions/original.image_file.700.394.file/Mulliner%20Batur%201920x1080.jpg"; //700 x 394 101 | SvgEditor? svgEditor; 102 | 103 | public ImageManipulations ImageManipulations { get; set; } = new(); 104 | 105 | private int _selectedItemId = 0; 106 | public List Changes { get; set; } = new List(); 107 | 108 | private List _shapes = new(); 109 | 110 | private string input = ""; 111 | 112 | private string brighnessinput = ""; 113 | private string contrastinput = ""; 114 | private string hueinput = ""; 115 | 116 | 117 | private void SelectItem() 118 | { 119 | if (int.TryParse(input, out var id)) 120 | { 121 | if (id != 0) _selectedItemId = id; 122 | } 123 | } 124 | 125 | private void AddNewShape(ShapeType shapeType) 126 | { 127 | svgEditor?.AddNewShape(shapeType); 128 | } 129 | 130 | protected async Task OnChange(ShapeChangedEventArgs args) 131 | { 132 | if (args.ChangeType == ShapeChangeType.ClearAll) 133 | { 134 | _shapes.Clear(); 135 | await AddSeedingShapes(); 136 | return; 137 | } 138 | 139 | if (args.ChangeType == ShapeChangeType.Add && args.Shape?.CustomId <= 0) 140 | { 141 | //Get new id 142 | var newId = _shapes.Any() ? _shapes.Max(x => x.CustomId) + 1 : 1; 143 | args.Shape.CustomId = newId; 144 | } 145 | 146 | if (_shapes.Any(x => x.CustomId == args.Shape?.CustomId)) 147 | { 148 | //Remove old shape 149 | _shapes.Remove(_shapes.First(x => x.CustomId == args.Shape?.CustomId)); 150 | } 151 | _shapes.Add(args.Shape); 152 | 153 | Changes.Add(args.ChangeType.ToString() + ": " + args.Shape?.ShapeType.ToString() + " " + args.Shape?.CustomId); 154 | } 155 | 156 | 157 | private void SetImageManipulations() 158 | { 159 | ImageManipulations.Brightness = int.Parse(brighnessinput); 160 | ImageManipulations.Contrast = int.Parse(contrastinput); 161 | ImageManipulations.Hue = int.Parse(hueinput); 162 | } 163 | 164 | private async Task AddSeedingShapes() 165 | { 166 | if (svgEditor == null) return; 167 | 168 | await svgEditor.AddExistingShape(new Circle(svgEditor) { Cy = 300, Cx = 300, R = 40, CustomId = 1 }); 169 | await svgEditor.AddExistingShape(new Rectangle(svgEditor) { X = 100, Y = 100, Width = 100, Height = 100, CustomId = 2 }); 170 | await svgEditor.AddExistingShape(new Polygon(svgEditor) { Points = new List>(){new (500,50), new (600,50), new(600,100)}, CustomId = 3 }); 171 | 172 | /*var poligonPoints = new List>(); 173 | for (int i = 0; i < 100; i++) 174 | { 175 | poligonPoints.Add(new (rnd.Next(100, 400), rnd.Next(50, 350))); 176 | } 177 | 178 | Shapes.Add(new Polygon(this){Points = poligonPoints, CustomId = 3}); 179 | await OnShapeChanged.InvokeAsync(ShapeChangedEventArgs.ShapeAdded(Shapes.Last()));*/ 180 | 181 | /*var poligonPoints2 = new List>(); 182 | for (int i = 0; i < 15; i++) 183 | { 184 | poligonPoints2.Add(new (rnd.Next(500, 650), rnd.Next(50, 350))); 185 | } 186 | 187 | Shapes.Add(new Polygon(this){Points = poligonPoints2,CustomId =5}); 188 | await OnShapeChanged.InvokeAsync(ShapeChangedEventArgs.ShapeAdded(Shapes.Last()));*/ 189 | } 190 | 191 | 192 | public async Task<(string source, int width, int height)> GetImageSource() 193 | { 194 | await Task.Delay(1000); 195 | return (imageSource, 700, 394); 196 | } 197 | 198 | 199 | 200 | protected override async Task OnAfterRenderAsync(bool firstRender) 201 | { 202 | if (firstRender) 203 | { 204 | await AddSeedingShapes(); 205 | await svgEditor?.ReloadImage(); 206 | } 207 | await base.OnAfterRenderAsync(firstRender); 208 | } 209 | 210 | } 211 | -------------------------------------------------------------------------------- /BlazorSvgEditor.WasmTest/Pages/Preview.razor: -------------------------------------------------------------------------------- 1 | @page "/" 2 | @using BlazorSvgEditor.SvgEditor 3 | @using BlazorSvgEditor.SvgEditor.Misc 4 | @using Microsoft.AspNetCore.Components 5 | 6 | BlazorSvgEditor Demo 7 | 8 |
9 |
10 |
11 |

Blazor Svg Editor Demo

12 |
13 |
14 | 15 |
16 | 19 | 20 | 21 |
22 |
23 | 27 | Loading... 28 |
29 |
30 | 31 |
32 |
33 |
34 | 35 |
36 | 37 |
38 | 39 |
40 | 41 | Add item/Editor control 42 |
43 | 49 | 55 | 61 |
62 | 63 |
64 | 65 | 66 | 67 |
68 |
69 | 70 | 71 |
72 | 73 | Image Path: 74 |
75 | 76 | @* 77 |
78 | 79 | 80 |
81 | 82 | *@ 83 | 84 |
85 | 86 | 87 |
88 | 89 |
90 | 91 | 92 |
93 | 94 | 95 | 96 |
97 | 98 | Image Manipulation: 99 |
100 | Brigt.: 101 | 102 |
103 | 104 |
105 | Contrast: 106 | 107 |
108 | 109 |
110 | Satur.: 111 | 112 |
113 | 114 |
115 | Hue.: 116 | 117 |
118 |
119 | 120 | 121 |
122 | 123 | 124 | Items: 125 | 126 | @foreach (var shape in Shapes) 127 | { 128 |
129 | @(shape.ShapeType.ToString()): 130 |

(Id: @shape.CustomId)

131 |
132 | } 133 | 134 | @if (Shapes.Count == 0) 135 | { 136 |

No items

137 | } 138 | else 139 | { 140 | 141 | 142 | } 143 | 144 |
145 | 146 |
147 | 148 |
149 | 150 | 163 |
164 | 165 | 166 | 190 | 191 | @code { 192 | 193 | } -------------------------------------------------------------------------------- /BlazorSvgEditor.WasmTest/wwwroot/css/tailwind.css: -------------------------------------------------------------------------------- 1 | /* 2 | ! tailwindcss v3.2.6 | MIT License | https://tailwindcss.com 3 | */ 4 | 5 | /* 6 | 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) 7 | 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) 8 | */ 9 | 10 | *, 11 | ::before, 12 | ::after { 13 | box-sizing: border-box; 14 | /* 1 */ 15 | border-width: 0; 16 | /* 2 */ 17 | border-style: solid; 18 | /* 2 */ 19 | border-color: #e5e7eb; 20 | /* 2 */ 21 | } 22 | 23 | ::before, 24 | ::after { 25 | --tw-content: ''; 26 | } 27 | 28 | /* 29 | 1. Use a consistent sensible line-height in all browsers. 30 | 2. Prevent adjustments of font size after orientation changes in iOS. 31 | 3. Use a more readable tab size. 32 | 4. Use the user's configured `sans` font-family by default. 33 | 5. Use the user's configured `sans` font-feature-settings by default. 34 | */ 35 | 36 | html { 37 | line-height: 1.5; 38 | /* 1 */ 39 | -webkit-text-size-adjust: 100%; 40 | /* 2 */ 41 | -moz-tab-size: 4; 42 | /* 3 */ 43 | -o-tab-size: 4; 44 | tab-size: 4; 45 | /* 3 */ 46 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 47 | /* 4 */ 48 | font-feature-settings: normal; 49 | /* 5 */ 50 | } 51 | 52 | /* 53 | 1. Remove the margin in all browsers. 54 | 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. 55 | */ 56 | 57 | body { 58 | margin: 0; 59 | /* 1 */ 60 | line-height: inherit; 61 | /* 2 */ 62 | } 63 | 64 | /* 65 | 1. Add the correct height in Firefox. 66 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) 67 | 3. Ensure horizontal rules are visible by default. 68 | */ 69 | 70 | hr { 71 | height: 0; 72 | /* 1 */ 73 | color: inherit; 74 | /* 2 */ 75 | border-top-width: 1px; 76 | /* 3 */ 77 | } 78 | 79 | /* 80 | Add the correct text decoration in Chrome, Edge, and Safari. 81 | */ 82 | 83 | abbr:where([title]) { 84 | -webkit-text-decoration: underline dotted; 85 | text-decoration: underline dotted; 86 | } 87 | 88 | /* 89 | Remove the default font size and weight for headings. 90 | */ 91 | 92 | h1, 93 | h2, 94 | h3, 95 | h4, 96 | h5, 97 | h6 { 98 | font-size: inherit; 99 | font-weight: inherit; 100 | } 101 | 102 | /* 103 | Reset links to optimize for opt-in styling instead of opt-out. 104 | */ 105 | 106 | a { 107 | color: inherit; 108 | text-decoration: inherit; 109 | } 110 | 111 | /* 112 | Add the correct font weight in Edge and Safari. 113 | */ 114 | 115 | b, 116 | strong { 117 | font-weight: bolder; 118 | } 119 | 120 | /* 121 | 1. Use the user's configured `mono` font family by default. 122 | 2. Correct the odd `em` font sizing in all browsers. 123 | */ 124 | 125 | code, 126 | kbd, 127 | samp, 128 | pre { 129 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 130 | /* 1 */ 131 | font-size: 1em; 132 | /* 2 */ 133 | } 134 | 135 | /* 136 | Add the correct font size in all browsers. 137 | */ 138 | 139 | small { 140 | font-size: 80%; 141 | } 142 | 143 | /* 144 | Prevent `sub` and `sup` elements from affecting the line height in all browsers. 145 | */ 146 | 147 | sub, 148 | sup { 149 | font-size: 75%; 150 | line-height: 0; 151 | position: relative; 152 | vertical-align: baseline; 153 | } 154 | 155 | sub { 156 | bottom: -0.25em; 157 | } 158 | 159 | sup { 160 | top: -0.5em; 161 | } 162 | 163 | /* 164 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) 165 | 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) 166 | 3. Remove gaps between table borders by default. 167 | */ 168 | 169 | table { 170 | text-indent: 0; 171 | /* 1 */ 172 | border-color: inherit; 173 | /* 2 */ 174 | border-collapse: collapse; 175 | /* 3 */ 176 | } 177 | 178 | /* 179 | 1. Change the font styles in all browsers. 180 | 2. Remove the margin in Firefox and Safari. 181 | 3. Remove default padding in all browsers. 182 | */ 183 | 184 | button, 185 | input, 186 | optgroup, 187 | select, 188 | textarea { 189 | font-family: inherit; 190 | /* 1 */ 191 | font-size: 100%; 192 | /* 1 */ 193 | font-weight: inherit; 194 | /* 1 */ 195 | line-height: inherit; 196 | /* 1 */ 197 | color: inherit; 198 | /* 1 */ 199 | margin: 0; 200 | /* 2 */ 201 | padding: 0; 202 | /* 3 */ 203 | } 204 | 205 | /* 206 | Remove the inheritance of text transform in Edge and Firefox. 207 | */ 208 | 209 | button, 210 | select { 211 | text-transform: none; 212 | } 213 | 214 | /* 215 | 1. Correct the inability to style clickable types in iOS and Safari. 216 | 2. Remove default button styles. 217 | */ 218 | 219 | button, 220 | [type='button'], 221 | [type='reset'], 222 | [type='submit'] { 223 | -webkit-appearance: button; 224 | /* 1 */ 225 | background-color: transparent; 226 | /* 2 */ 227 | background-image: none; 228 | /* 2 */ 229 | } 230 | 231 | /* 232 | Use the modern Firefox focus style for all focusable elements. 233 | */ 234 | 235 | :-moz-focusring { 236 | outline: auto; 237 | } 238 | 239 | /* 240 | Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) 241 | */ 242 | 243 | :-moz-ui-invalid { 244 | box-shadow: none; 245 | } 246 | 247 | /* 248 | Add the correct vertical alignment in Chrome and Firefox. 249 | */ 250 | 251 | progress { 252 | vertical-align: baseline; 253 | } 254 | 255 | /* 256 | Correct the cursor style of increment and decrement buttons in Safari. 257 | */ 258 | 259 | ::-webkit-inner-spin-button, 260 | ::-webkit-outer-spin-button { 261 | height: auto; 262 | } 263 | 264 | /* 265 | 1. Correct the odd appearance in Chrome and Safari. 266 | 2. Correct the outline style in Safari. 267 | */ 268 | 269 | [type='search'] { 270 | -webkit-appearance: textfield; 271 | /* 1 */ 272 | outline-offset: -2px; 273 | /* 2 */ 274 | } 275 | 276 | /* 277 | Remove the inner padding in Chrome and Safari on macOS. 278 | */ 279 | 280 | ::-webkit-search-decoration { 281 | -webkit-appearance: none; 282 | } 283 | 284 | /* 285 | 1. Correct the inability to style clickable types in iOS and Safari. 286 | 2. Change font properties to `inherit` in Safari. 287 | */ 288 | 289 | ::-webkit-file-upload-button { 290 | -webkit-appearance: button; 291 | /* 1 */ 292 | font: inherit; 293 | /* 2 */ 294 | } 295 | 296 | /* 297 | Add the correct display in Chrome and Safari. 298 | */ 299 | 300 | summary { 301 | display: list-item; 302 | } 303 | 304 | /* 305 | Removes the default spacing and border for appropriate elements. 306 | */ 307 | 308 | blockquote, 309 | dl, 310 | dd, 311 | h1, 312 | h2, 313 | h3, 314 | h4, 315 | h5, 316 | h6, 317 | hr, 318 | figure, 319 | p, 320 | pre { 321 | margin: 0; 322 | } 323 | 324 | fieldset { 325 | margin: 0; 326 | padding: 0; 327 | } 328 | 329 | legend { 330 | padding: 0; 331 | } 332 | 333 | ol, 334 | ul, 335 | menu { 336 | list-style: none; 337 | margin: 0; 338 | padding: 0; 339 | } 340 | 341 | /* 342 | Prevent resizing textareas horizontally by default. 343 | */ 344 | 345 | textarea { 346 | resize: vertical; 347 | } 348 | 349 | /* 350 | 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) 351 | 2. Set the default placeholder color to the user's configured gray 400 color. 352 | */ 353 | 354 | input::-moz-placeholder, textarea::-moz-placeholder { 355 | opacity: 1; 356 | /* 1 */ 357 | color: #9ca3af; 358 | /* 2 */ 359 | } 360 | 361 | input::placeholder, 362 | textarea::placeholder { 363 | opacity: 1; 364 | /* 1 */ 365 | color: #9ca3af; 366 | /* 2 */ 367 | } 368 | 369 | /* 370 | Set the default cursor for buttons. 371 | */ 372 | 373 | button, 374 | [role="button"] { 375 | cursor: pointer; 376 | } 377 | 378 | /* 379 | Make sure disabled buttons don't get the pointer cursor. 380 | */ 381 | 382 | :disabled { 383 | cursor: default; 384 | } 385 | 386 | /* 387 | 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) 388 | 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) 389 | This can trigger a poorly considered lint error in some tools but is included by design. 390 | */ 391 | 392 | img, 393 | svg, 394 | video, 395 | canvas, 396 | audio, 397 | iframe, 398 | embed, 399 | object { 400 | display: block; 401 | /* 1 */ 402 | vertical-align: middle; 403 | /* 2 */ 404 | } 405 | 406 | /* 407 | Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) 408 | */ 409 | 410 | img, 411 | video { 412 | max-width: 100%; 413 | height: auto; 414 | } 415 | 416 | /* Make elements with the HTML hidden attribute stay hidden by default */ 417 | 418 | [hidden] { 419 | display: none; 420 | } 421 | 422 | [type='text'],[type='email'],[type='url'],[type='password'],[type='number'],[type='date'],[type='datetime-local'],[type='month'],[type='search'],[type='tel'],[type='time'],[type='week'],[multiple],textarea,select { 423 | -webkit-appearance: none; 424 | -moz-appearance: none; 425 | appearance: none; 426 | background-color: #fff; 427 | border-color: #6b7280; 428 | border-width: 1px; 429 | border-radius: 0px; 430 | padding-top: 0.5rem; 431 | padding-right: 0.75rem; 432 | padding-bottom: 0.5rem; 433 | padding-left: 0.75rem; 434 | font-size: 1rem; 435 | line-height: 1.5rem; 436 | --tw-shadow: 0 0 #0000; 437 | } 438 | 439 | [type='text']:focus, [type='email']:focus, [type='url']:focus, [type='password']:focus, [type='number']:focus, [type='date']:focus, [type='datetime-local']:focus, [type='month']:focus, [type='search']:focus, [type='tel']:focus, [type='time']:focus, [type='week']:focus, [multiple]:focus, textarea:focus, select:focus { 440 | outline: 2px solid transparent; 441 | outline-offset: 2px; 442 | --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); 443 | --tw-ring-offset-width: 0px; 444 | --tw-ring-offset-color: #fff; 445 | --tw-ring-color: #2563eb; 446 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); 447 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); 448 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); 449 | border-color: #2563eb; 450 | } 451 | 452 | input::-moz-placeholder, textarea::-moz-placeholder { 453 | color: #6b7280; 454 | opacity: 1; 455 | } 456 | 457 | input::placeholder,textarea::placeholder { 458 | color: #6b7280; 459 | opacity: 1; 460 | } 461 | 462 | ::-webkit-datetime-edit-fields-wrapper { 463 | padding: 0; 464 | } 465 | 466 | ::-webkit-date-and-time-value { 467 | min-height: 1.5em; 468 | } 469 | 470 | ::-webkit-datetime-edit,::-webkit-datetime-edit-year-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-meridiem-field { 471 | padding-top: 0; 472 | padding-bottom: 0; 473 | } 474 | 475 | select { 476 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); 477 | background-position: right 0.5rem center; 478 | background-repeat: no-repeat; 479 | background-size: 1.5em 1.5em; 480 | padding-right: 2.5rem; 481 | -webkit-print-color-adjust: exact; 482 | print-color-adjust: exact; 483 | } 484 | 485 | [multiple] { 486 | background-image: initial; 487 | background-position: initial; 488 | background-repeat: unset; 489 | background-size: initial; 490 | padding-right: 0.75rem; 491 | -webkit-print-color-adjust: unset; 492 | print-color-adjust: unset; 493 | } 494 | 495 | [type='checkbox'],[type='radio'] { 496 | -webkit-appearance: none; 497 | -moz-appearance: none; 498 | appearance: none; 499 | padding: 0; 500 | -webkit-print-color-adjust: exact; 501 | print-color-adjust: exact; 502 | display: inline-block; 503 | vertical-align: middle; 504 | background-origin: border-box; 505 | -webkit-user-select: none; 506 | -moz-user-select: none; 507 | user-select: none; 508 | flex-shrink: 0; 509 | height: 1rem; 510 | width: 1rem; 511 | color: #2563eb; 512 | background-color: #fff; 513 | border-color: #6b7280; 514 | border-width: 1px; 515 | --tw-shadow: 0 0 #0000; 516 | } 517 | 518 | [type='checkbox'] { 519 | border-radius: 0px; 520 | } 521 | 522 | [type='radio'] { 523 | border-radius: 100%; 524 | } 525 | 526 | [type='checkbox']:focus,[type='radio']:focus { 527 | outline: 2px solid transparent; 528 | outline-offset: 2px; 529 | --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); 530 | --tw-ring-offset-width: 2px; 531 | --tw-ring-offset-color: #fff; 532 | --tw-ring-color: #2563eb; 533 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); 534 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); 535 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); 536 | } 537 | 538 | [type='checkbox']:checked,[type='radio']:checked { 539 | border-color: transparent; 540 | background-color: currentColor; 541 | background-size: 100% 100%; 542 | background-position: center; 543 | background-repeat: no-repeat; 544 | } 545 | 546 | [type='checkbox']:checked { 547 | background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); 548 | } 549 | 550 | [type='radio']:checked { 551 | background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e"); 552 | } 553 | 554 | [type='checkbox']:checked:hover,[type='checkbox']:checked:focus,[type='radio']:checked:hover,[type='radio']:checked:focus { 555 | border-color: transparent; 556 | background-color: currentColor; 557 | } 558 | 559 | [type='checkbox']:indeterminate { 560 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e"); 561 | border-color: transparent; 562 | background-color: currentColor; 563 | background-size: 100% 100%; 564 | background-position: center; 565 | background-repeat: no-repeat; 566 | } 567 | 568 | [type='checkbox']:indeterminate:hover,[type='checkbox']:indeterminate:focus { 569 | border-color: transparent; 570 | background-color: currentColor; 571 | } 572 | 573 | [type='file'] { 574 | background: unset; 575 | border-color: inherit; 576 | border-width: 0; 577 | border-radius: 0; 578 | padding: 0; 579 | font-size: unset; 580 | line-height: inherit; 581 | } 582 | 583 | [type='file']:focus { 584 | outline: 1px solid ButtonText; 585 | outline: 1px auto -webkit-focus-ring-color; 586 | } 587 | 588 | *, ::before, ::after { 589 | --tw-border-spacing-x: 0; 590 | --tw-border-spacing-y: 0; 591 | --tw-translate-x: 0; 592 | --tw-translate-y: 0; 593 | --tw-rotate: 0; 594 | --tw-skew-x: 0; 595 | --tw-skew-y: 0; 596 | --tw-scale-x: 1; 597 | --tw-scale-y: 1; 598 | --tw-pan-x: ; 599 | --tw-pan-y: ; 600 | --tw-pinch-zoom: ; 601 | --tw-scroll-snap-strictness: proximity; 602 | --tw-ordinal: ; 603 | --tw-slashed-zero: ; 604 | --tw-numeric-figure: ; 605 | --tw-numeric-spacing: ; 606 | --tw-numeric-fraction: ; 607 | --tw-ring-inset: ; 608 | --tw-ring-offset-width: 0px; 609 | --tw-ring-offset-color: #fff; 610 | --tw-ring-color: rgb(59 130 246 / 0.5); 611 | --tw-ring-offset-shadow: 0 0 #0000; 612 | --tw-ring-shadow: 0 0 #0000; 613 | --tw-shadow: 0 0 #0000; 614 | --tw-shadow-colored: 0 0 #0000; 615 | --tw-blur: ; 616 | --tw-brightness: ; 617 | --tw-contrast: ; 618 | --tw-grayscale: ; 619 | --tw-hue-rotate: ; 620 | --tw-invert: ; 621 | --tw-saturate: ; 622 | --tw-sepia: ; 623 | --tw-drop-shadow: ; 624 | --tw-backdrop-blur: ; 625 | --tw-backdrop-brightness: ; 626 | --tw-backdrop-contrast: ; 627 | --tw-backdrop-grayscale: ; 628 | --tw-backdrop-hue-rotate: ; 629 | --tw-backdrop-invert: ; 630 | --tw-backdrop-opacity: ; 631 | --tw-backdrop-saturate: ; 632 | --tw-backdrop-sepia: ; 633 | } 634 | 635 | ::backdrop { 636 | --tw-border-spacing-x: 0; 637 | --tw-border-spacing-y: 0; 638 | --tw-translate-x: 0; 639 | --tw-translate-y: 0; 640 | --tw-rotate: 0; 641 | --tw-skew-x: 0; 642 | --tw-skew-y: 0; 643 | --tw-scale-x: 1; 644 | --tw-scale-y: 1; 645 | --tw-pan-x: ; 646 | --tw-pan-y: ; 647 | --tw-pinch-zoom: ; 648 | --tw-scroll-snap-strictness: proximity; 649 | --tw-ordinal: ; 650 | --tw-slashed-zero: ; 651 | --tw-numeric-figure: ; 652 | --tw-numeric-spacing: ; 653 | --tw-numeric-fraction: ; 654 | --tw-ring-inset: ; 655 | --tw-ring-offset-width: 0px; 656 | --tw-ring-offset-color: #fff; 657 | --tw-ring-color: rgb(59 130 246 / 0.5); 658 | --tw-ring-offset-shadow: 0 0 #0000; 659 | --tw-ring-shadow: 0 0 #0000; 660 | --tw-shadow: 0 0 #0000; 661 | --tw-shadow-colored: 0 0 #0000; 662 | --tw-blur: ; 663 | --tw-brightness: ; 664 | --tw-contrast: ; 665 | --tw-grayscale: ; 666 | --tw-hue-rotate: ; 667 | --tw-invert: ; 668 | --tw-saturate: ; 669 | --tw-sepia: ; 670 | --tw-drop-shadow: ; 671 | --tw-backdrop-blur: ; 672 | --tw-backdrop-brightness: ; 673 | --tw-backdrop-contrast: ; 674 | --tw-backdrop-grayscale: ; 675 | --tw-backdrop-hue-rotate: ; 676 | --tw-backdrop-invert: ; 677 | --tw-backdrop-opacity: ; 678 | --tw-backdrop-saturate: ; 679 | --tw-backdrop-sepia: ; 680 | } 681 | 682 | .sr-only { 683 | position: absolute; 684 | width: 1px; 685 | height: 1px; 686 | padding: 0; 687 | margin: -1px; 688 | overflow: hidden; 689 | clip: rect(0, 0, 0, 0); 690 | white-space: nowrap; 691 | border-width: 0; 692 | } 693 | 694 | .absolute { 695 | position: absolute; 696 | } 697 | 698 | .relative { 699 | position: relative; 700 | } 701 | 702 | .left-1 { 703 | left: 0.25rem; 704 | } 705 | 706 | .top-2 { 707 | top: 0.5rem; 708 | } 709 | 710 | .z-10 { 711 | z-index: 10; 712 | } 713 | 714 | .mx-auto { 715 | margin-left: auto; 716 | margin-right: auto; 717 | } 718 | 719 | .my-3 { 720 | margin-top: 0.75rem; 721 | margin-bottom: 0.75rem; 722 | } 723 | 724 | .mb-2 { 725 | margin-bottom: 0.5rem; 726 | } 727 | 728 | .mr-2 { 729 | margin-right: 0.5rem; 730 | } 731 | 732 | .mt-2 { 733 | margin-top: 0.5rem; 734 | } 735 | 736 | .mt-3 { 737 | margin-top: 0.75rem; 738 | } 739 | 740 | .block { 741 | display: block; 742 | } 743 | 744 | .inline { 745 | display: inline; 746 | } 747 | 748 | .flex { 749 | display: flex; 750 | } 751 | 752 | .inline-flex { 753 | display: inline-flex; 754 | } 755 | 756 | .grid { 757 | display: grid; 758 | } 759 | 760 | .hidden { 761 | display: none; 762 | } 763 | 764 | .h-2 { 765 | height: 0.5rem; 766 | } 767 | 768 | .h-4 { 769 | height: 1rem; 770 | } 771 | 772 | .h-\[100vh\] { 773 | height: 100vh; 774 | } 775 | 776 | .h-full { 777 | height: 100%; 778 | } 779 | 780 | .h-auto { 781 | height: auto; 782 | } 783 | 784 | .h-8 { 785 | height: 2rem; 786 | } 787 | 788 | .h-16 { 789 | height: 4rem; 790 | } 791 | 792 | .w-4 { 793 | width: 1rem; 794 | } 795 | 796 | .w-full { 797 | width: 100%; 798 | } 799 | 800 | .w-8 { 801 | width: 2rem; 802 | } 803 | 804 | .w-16 { 805 | width: 4rem; 806 | } 807 | 808 | .flex-grow { 809 | flex-grow: 1; 810 | } 811 | 812 | .origin-\[0\] { 813 | transform-origin: 0; 814 | } 815 | 816 | .-translate-y-4 { 817 | --tw-translate-y: -1rem; 818 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 819 | } 820 | 821 | .scale-75 { 822 | --tw-scale-x: .75; 823 | --tw-scale-y: .75; 824 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 825 | } 826 | 827 | .transform { 828 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 829 | } 830 | 831 | @keyframes spin { 832 | to { 833 | transform: rotate(360deg); 834 | } 835 | } 836 | 837 | .animate-spin { 838 | animation: spin 1s linear infinite; 839 | } 840 | 841 | .cursor-pointer { 842 | cursor: pointer; 843 | } 844 | 845 | .appearance-none { 846 | -webkit-appearance: none; 847 | -moz-appearance: none; 848 | appearance: none; 849 | } 850 | 851 | .flex-col { 852 | flex-direction: column; 853 | } 854 | 855 | .items-center { 856 | align-items: center; 857 | } 858 | 859 | .justify-center { 860 | justify-content: center; 861 | } 862 | 863 | .gap-2 { 864 | gap: 0.5rem; 865 | } 866 | 867 | .gap-3 { 868 | gap: 0.75rem; 869 | } 870 | 871 | .overflow-hidden { 872 | overflow: hidden; 873 | } 874 | 875 | .rounded-lg { 876 | border-radius: 0.5rem; 877 | } 878 | 879 | .rounded-md { 880 | border-radius: 0.375rem; 881 | } 882 | 883 | .rounded-l-lg { 884 | border-top-left-radius: 0.5rem; 885 | border-bottom-left-radius: 0.5rem; 886 | } 887 | 888 | .rounded-r-md { 889 | border-top-right-radius: 0.375rem; 890 | border-bottom-right-radius: 0.375rem; 891 | } 892 | 893 | .border { 894 | border-width: 1px; 895 | } 896 | 897 | .border-b { 898 | border-bottom-width: 1px; 899 | } 900 | 901 | .border-t { 902 | border-top-width: 1px; 903 | } 904 | 905 | .border-gray-200 { 906 | --tw-border-opacity: 1; 907 | border-color: rgb(229 231 235 / var(--tw-border-opacity)); 908 | } 909 | 910 | .border-gray-300 { 911 | --tw-border-opacity: 1; 912 | border-color: rgb(209 213 219 / var(--tw-border-opacity)); 913 | } 914 | 915 | .border-gray-600 { 916 | --tw-border-opacity: 1; 917 | border-color: rgb(75 85 99 / var(--tw-border-opacity)); 918 | } 919 | 920 | .border-red-900 { 921 | --tw-border-opacity: 1; 922 | border-color: rgb(127 29 29 / var(--tw-border-opacity)); 923 | } 924 | 925 | .bg-blue-700 { 926 | --tw-bg-opacity: 1; 927 | background-color: rgb(29 78 216 / var(--tw-bg-opacity)); 928 | } 929 | 930 | .bg-gray-200 { 931 | --tw-bg-opacity: 1; 932 | background-color: rgb(229 231 235 / var(--tw-bg-opacity)); 933 | } 934 | 935 | .bg-red-700 { 936 | --tw-bg-opacity: 1; 937 | background-color: rgb(185 28 28 / var(--tw-bg-opacity)); 938 | } 939 | 940 | .bg-transparent { 941 | background-color: transparent; 942 | } 943 | 944 | .bg-white { 945 | --tw-bg-opacity: 1; 946 | background-color: rgb(255 255 255 / var(--tw-bg-opacity)); 947 | } 948 | 949 | .bg-yellow-400 { 950 | --tw-bg-opacity: 1; 951 | background-color: rgb(250 204 21 / var(--tw-bg-opacity)); 952 | } 953 | 954 | .bg-red-400 { 955 | --tw-bg-opacity: 1; 956 | background-color: rgb(248 113 113 / var(--tw-bg-opacity)); 957 | } 958 | 959 | .bg-red-300 { 960 | --tw-bg-opacity: 1; 961 | background-color: rgb(252 165 165 / var(--tw-bg-opacity)); 962 | } 963 | 964 | .fill-current { 965 | fill: currentColor; 966 | } 967 | 968 | .fill-blue-600 { 969 | fill: #2563eb; 970 | } 971 | 972 | .p-3 { 973 | padding: 0.75rem; 974 | } 975 | 976 | .p-2 { 977 | padding: 0.5rem; 978 | } 979 | 980 | .p-1 { 981 | padding: 0.25rem; 982 | } 983 | 984 | .p-1\.5 { 985 | padding: 0.375rem; 986 | } 987 | 988 | .px-2 { 989 | padding-left: 0.5rem; 990 | padding-right: 0.5rem; 991 | } 992 | 993 | .px-2\.5 { 994 | padding-left: 0.625rem; 995 | padding-right: 0.625rem; 996 | } 997 | 998 | .px-4 { 999 | padding-left: 1rem; 1000 | padding-right: 1rem; 1001 | } 1002 | 1003 | .px-5 { 1004 | padding-left: 1.25rem; 1005 | padding-right: 1.25rem; 1006 | } 1007 | 1008 | .py-2 { 1009 | padding-top: 0.5rem; 1010 | padding-bottom: 0.5rem; 1011 | } 1012 | 1013 | .py-2\.5 { 1014 | padding-top: 0.625rem; 1015 | padding-bottom: 0.625rem; 1016 | } 1017 | 1018 | .pb-2 { 1019 | padding-bottom: 0.5rem; 1020 | } 1021 | 1022 | .pb-2\.5 { 1023 | padding-bottom: 0.625rem; 1024 | } 1025 | 1026 | .pt-4 { 1027 | padding-top: 1rem; 1028 | } 1029 | 1030 | .text-center { 1031 | text-align: center; 1032 | } 1033 | 1034 | .text-sm { 1035 | font-size: 0.875rem; 1036 | line-height: 1.25rem; 1037 | } 1038 | 1039 | .text-xl { 1040 | font-size: 1.25rem; 1041 | line-height: 1.75rem; 1042 | } 1043 | 1044 | .font-bold { 1045 | font-weight: 700; 1046 | } 1047 | 1048 | .font-medium { 1049 | font-weight: 500; 1050 | } 1051 | 1052 | .italic { 1053 | font-style: italic; 1054 | } 1055 | 1056 | .text-gray-500 { 1057 | --tw-text-opacity: 1; 1058 | color: rgb(107 114 128 / var(--tw-text-opacity)); 1059 | } 1060 | 1061 | .text-gray-900 { 1062 | --tw-text-opacity: 1; 1063 | color: rgb(17 24 39 / var(--tw-text-opacity)); 1064 | } 1065 | 1066 | .text-white { 1067 | --tw-text-opacity: 1; 1068 | color: rgb(255 255 255 / var(--tw-text-opacity)); 1069 | } 1070 | 1071 | .text-gray-400 { 1072 | --tw-text-opacity: 1; 1073 | color: rgb(156 163 175 / var(--tw-text-opacity)); 1074 | } 1075 | 1076 | .text-gray-200 { 1077 | --tw-text-opacity: 1; 1078 | color: rgb(229 231 235 / var(--tw-text-opacity)); 1079 | } 1080 | 1081 | .text-gray-800 { 1082 | --tw-text-opacity: 1; 1083 | color: rgb(31 41 55 / var(--tw-text-opacity)); 1084 | } 1085 | 1086 | .underline { 1087 | text-decoration-line: underline; 1088 | } 1089 | 1090 | .shadow-md { 1091 | --tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); 1092 | --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color); 1093 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 1094 | } 1095 | 1096 | .shadow-sm { 1097 | --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); 1098 | --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color); 1099 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 1100 | } 1101 | 1102 | .duration-300 { 1103 | transition-duration: 300ms; 1104 | } 1105 | 1106 | .hover\:bg-blue-800:hover { 1107 | --tw-bg-opacity: 1; 1108 | background-color: rgb(30 64 175 / var(--tw-bg-opacity)); 1109 | } 1110 | 1111 | .hover\:bg-gray-900:hover { 1112 | --tw-bg-opacity: 1; 1113 | background-color: rgb(17 24 39 / var(--tw-bg-opacity)); 1114 | } 1115 | 1116 | .hover\:bg-red-800:hover { 1117 | --tw-bg-opacity: 1; 1118 | background-color: rgb(153 27 27 / var(--tw-bg-opacity)); 1119 | } 1120 | 1121 | .hover\:bg-yellow-500:hover { 1122 | --tw-bg-opacity: 1; 1123 | background-color: rgb(234 179 8 / var(--tw-bg-opacity)); 1124 | } 1125 | 1126 | .hover\:text-white:hover { 1127 | --tw-text-opacity: 1; 1128 | color: rgb(255 255 255 / var(--tw-text-opacity)); 1129 | } 1130 | 1131 | .focus\:z-10:focus { 1132 | z-index: 10; 1133 | } 1134 | 1135 | .focus\:border-blue-600:focus { 1136 | --tw-border-opacity: 1; 1137 | border-color: rgb(37 99 235 / var(--tw-border-opacity)); 1138 | } 1139 | 1140 | .focus\:bg-gray-900:focus { 1141 | --tw-bg-opacity: 1; 1142 | background-color: rgb(17 24 39 / var(--tw-bg-opacity)); 1143 | } 1144 | 1145 | .focus\:text-white:focus { 1146 | --tw-text-opacity: 1; 1147 | color: rgb(255 255 255 / var(--tw-text-opacity)); 1148 | } 1149 | 1150 | .focus\:outline-none:focus { 1151 | outline: 2px solid transparent; 1152 | outline-offset: 2px; 1153 | } 1154 | 1155 | .focus\:ring-0:focus { 1156 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); 1157 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color); 1158 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); 1159 | } 1160 | 1161 | .focus\:ring-2:focus { 1162 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); 1163 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); 1164 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); 1165 | } 1166 | 1167 | .focus\:ring-4:focus { 1168 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); 1169 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color); 1170 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); 1171 | } 1172 | 1173 | .focus\:ring-blue-300:focus { 1174 | --tw-ring-opacity: 1; 1175 | --tw-ring-color: rgb(147 197 253 / var(--tw-ring-opacity)); 1176 | } 1177 | 1178 | .focus\:ring-gray-500:focus { 1179 | --tw-ring-opacity: 1; 1180 | --tw-ring-color: rgb(107 114 128 / var(--tw-ring-opacity)); 1181 | } 1182 | 1183 | .focus\:ring-red-300:focus { 1184 | --tw-ring-opacity: 1; 1185 | --tw-ring-color: rgb(252 165 165 / var(--tw-ring-opacity)); 1186 | } 1187 | 1188 | .focus\:ring-yellow-300:focus { 1189 | --tw-ring-opacity: 1; 1190 | --tw-ring-color: rgb(253 224 71 / var(--tw-ring-opacity)); 1191 | } 1192 | 1193 | .peer:-moz-placeholder-shown ~ .peer-placeholder-shown\:top-1\/2 { 1194 | top: 50%; 1195 | } 1196 | 1197 | .peer:placeholder-shown ~ .peer-placeholder-shown\:top-1\/2 { 1198 | top: 50%; 1199 | } 1200 | 1201 | .peer:-moz-placeholder-shown ~ .peer-placeholder-shown\:-translate-y-1\/2 { 1202 | --tw-translate-y: -50%; 1203 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 1204 | } 1205 | 1206 | .peer:placeholder-shown ~ .peer-placeholder-shown\:-translate-y-1\/2 { 1207 | --tw-translate-y: -50%; 1208 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 1209 | } 1210 | 1211 | .peer:-moz-placeholder-shown ~ .peer-placeholder-shown\:scale-100 { 1212 | --tw-scale-x: 1; 1213 | --tw-scale-y: 1; 1214 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 1215 | } 1216 | 1217 | .peer:placeholder-shown ~ .peer-placeholder-shown\:scale-100 { 1218 | --tw-scale-x: 1; 1219 | --tw-scale-y: 1; 1220 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 1221 | } 1222 | 1223 | .peer:focus ~ .peer-focus\:top-2 { 1224 | top: 0.5rem; 1225 | } 1226 | 1227 | .peer:focus ~ .peer-focus\:-translate-y-4 { 1228 | --tw-translate-y: -1rem; 1229 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 1230 | } 1231 | 1232 | .peer:focus ~ .peer-focus\:scale-75 { 1233 | --tw-scale-x: .75; 1234 | --tw-scale-y: .75; 1235 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 1236 | } 1237 | 1238 | .peer:focus ~ .peer-focus\:px-2 { 1239 | padding-left: 0.5rem; 1240 | padding-right: 0.5rem; 1241 | } 1242 | 1243 | .peer:focus ~ .peer-focus\:text-blue-600 { 1244 | --tw-text-opacity: 1; 1245 | color: rgb(37 99 235 / var(--tw-text-opacity)); 1246 | } 1247 | 1248 | @media (prefers-color-scheme: dark) { 1249 | .dark\:text-gray-600 { 1250 | --tw-text-opacity: 1; 1251 | color: rgb(75 85 99 / var(--tw-text-opacity)); 1252 | } 1253 | } --------------------------------------------------------------------------------