Implemented the Floor Plans background-image phase.
This commit is contained in:
parent
b2ac7b5eeb
commit
e455018d73
@ -114,6 +114,70 @@ public sealed class FloorPlansController(IFloorPlanService floorPlans) : Control
|
|||||||
return RedirectToAction(nameof(Edit), new { id = floorPlanId });
|
return RedirectToAction(nameof(Edit), new { id = floorPlanId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> UploadBackground(int floorPlanId, int floorPlanFloorId, IFormFile? backgroundImage)
|
||||||
|
{
|
||||||
|
if (backgroundImage is null)
|
||||||
|
{
|
||||||
|
TempData["FloorPlanError"] = "Choose a background image to upload.";
|
||||||
|
return RedirectToAction(nameof(Edit), new { id = floorPlanId });
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await floorPlans.UploadBackgroundAsync(floorPlanId, floorPlanFloorId, backgroundImage);
|
||||||
|
TempData[result.Succeeded ? "FloorPlanMessage" : "FloorPlanError"] = result.Succeeded
|
||||||
|
? "Background image uploaded."
|
||||||
|
: result.ErrorMessage ?? "Background image could not be uploaded.";
|
||||||
|
return RedirectToAction(nameof(Edit), new { id = floorPlanId });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> SaveBackgroundSettings(FloorPlanFloorBackgroundSettingsViewModel model)
|
||||||
|
{
|
||||||
|
var isAsyncSave = string.Equals(Request.Headers["X-Requested-With"], "XMLHttpRequest", StringComparison.OrdinalIgnoreCase);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await floorPlans.SaveBackgroundSettingsAsync(model);
|
||||||
|
if (isAsyncSave)
|
||||||
|
{
|
||||||
|
return Json(new { ok = true, message = "Saved" });
|
||||||
|
}
|
||||||
|
|
||||||
|
TempData["FloorPlanMessage"] = "Background settings saved.";
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
if (isAsyncSave)
|
||||||
|
{
|
||||||
|
Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||||
|
return Json(new { ok = false, message = ex.Message });
|
||||||
|
}
|
||||||
|
|
||||||
|
TempData["FloorPlanError"] = ex.Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return RedirectToAction(nameof(Edit), new { id = model.FloorPlanID });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> ResetBackground(int floorPlanId, int floorPlanFloorId)
|
||||||
|
{
|
||||||
|
await floorPlans.ResetBackgroundAsync(floorPlanId, floorPlanFloorId);
|
||||||
|
TempData["FloorPlanMessage"] = "Background position reset.";
|
||||||
|
return RedirectToAction(nameof(Edit), new { id = floorPlanId });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> RemoveBackground(int floorPlanId, int floorPlanFloorId)
|
||||||
|
{
|
||||||
|
await floorPlans.RemoveBackgroundAsync(floorPlanId, floorPlanFloorId);
|
||||||
|
TempData["FloorPlanMessage"] = "Background image removed.";
|
||||||
|
return RedirectToAction(nameof(Edit), new { id = floorPlanId });
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> AddBlock(int floorPlanId, FloorPlanBlockEditViewModel model)
|
public async Task<IActionResult> AddBlock(int floorPlanId, FloorPlanBlockEditViewModel model)
|
||||||
|
|||||||
@ -223,6 +223,7 @@ public interface IFloorPlanRepository
|
|||||||
Task<int> SavePlanAsync(FloorPlan floorPlan);
|
Task<int> SavePlanAsync(FloorPlan floorPlan);
|
||||||
Task DeletePlanAsync(int floorPlanId);
|
Task DeletePlanAsync(int floorPlanId);
|
||||||
Task<int> SaveFloorAsync(FloorPlanFloor floor);
|
Task<int> SaveFloorAsync(FloorPlanFloor floor);
|
||||||
|
Task<bool> UpdateFloorBackgroundAsync(FloorPlanFloor floor);
|
||||||
Task DeleteFloorAsync(int floorPlanFloorId);
|
Task DeleteFloorAsync(int floorPlanFloorId);
|
||||||
Task<int> SaveBlockAsync(FloorPlanBlock block);
|
Task<int> SaveBlockAsync(FloorPlanBlock block);
|
||||||
Task DeleteBlockAsync(int floorPlanBlockId);
|
Task DeleteBlockAsync(int floorPlanBlockId);
|
||||||
@ -591,6 +592,27 @@ public sealed class FloorPlanRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
commandType: CommandType.StoredProcedure);
|
commandType: CommandType.StoredProcedure);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<bool> UpdateFloorBackgroundAsync(FloorPlanFloor floor)
|
||||||
|
{
|
||||||
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
var rows = await connection.QuerySingleAsync<int>(
|
||||||
|
"dbo.FloorPlanFloor_UpdateBackground",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
floor.FloorPlanFloorID,
|
||||||
|
floor.FloorPlanID,
|
||||||
|
floor.BackgroundImagePath,
|
||||||
|
floor.BackgroundImageOriginalFileName,
|
||||||
|
floor.BackgroundOpacity,
|
||||||
|
floor.BackgroundScale,
|
||||||
|
floor.BackgroundOffsetX,
|
||||||
|
floor.BackgroundOffsetY,
|
||||||
|
floor.BackgroundLocked
|
||||||
|
},
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
return rows > 0;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task DeleteFloorAsync(int floorPlanFloorId)
|
public async Task DeleteFloorAsync(int floorPlanFloorId)
|
||||||
{
|
{
|
||||||
using var connection = connectionFactory.CreateConnection();
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
|||||||
@ -1164,6 +1164,13 @@ public sealed class FloorPlanFloor
|
|||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
public int GridWidth { get; set; } = 24;
|
public int GridWidth { get; set; } = 24;
|
||||||
public int GridHeight { get; set; } = 16;
|
public int GridHeight { get; set; } = 16;
|
||||||
|
public string? BackgroundImagePath { get; set; }
|
||||||
|
public string? BackgroundImageOriginalFileName { get; set; }
|
||||||
|
public decimal? BackgroundOpacity { get; set; } = 0.35m;
|
||||||
|
public decimal? BackgroundScale { get; set; } = 1m;
|
||||||
|
public decimal? BackgroundOffsetX { get; set; }
|
||||||
|
public decimal? BackgroundOffsetY { get; set; }
|
||||||
|
public bool BackgroundLocked { get; set; } = true;
|
||||||
public DateTime CreatedDate { get; set; }
|
public DateTime CreatedDate { get; set; }
|
||||||
public DateTime? ModifiedDate { get; set; }
|
public DateTime? ModifiedDate { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -132,6 +132,7 @@ public class Program
|
|||||||
builder.Services.AddScoped<IProjectCollaborationService, ProjectCollaborationService>();
|
builder.Services.AddScoped<IProjectCollaborationService, ProjectCollaborationService>();
|
||||||
builder.Services.AddScoped<IProjectActivityService, ProjectActivityService>();
|
builder.Services.AddScoped<IProjectActivityService, ProjectActivityService>();
|
||||||
builder.Services.AddScoped<IBookCoverService, BookCoverService>();
|
builder.Services.AddScoped<IBookCoverService, BookCoverService>();
|
||||||
|
builder.Services.AddScoped<IFloorPlanBackgroundImageService, FloorPlanBackgroundImageService>();
|
||||||
builder.Services.AddScoped<IBookService, BookService>();
|
builder.Services.AddScoped<IBookService, BookService>();
|
||||||
builder.Services.AddScoped<IChapterService, ChapterService>();
|
builder.Services.AddScoped<IChapterService, ChapterService>();
|
||||||
builder.Services.AddScoped<ISceneService, SceneService>();
|
builder.Services.AddScoped<ISceneService, SceneService>();
|
||||||
|
|||||||
@ -187,6 +187,10 @@ public interface IFloorPlanService
|
|||||||
Task DeleteFloorAsync(int floorPlanFloorId);
|
Task DeleteFloorAsync(int floorPlanFloorId);
|
||||||
Task<int> SaveBlockAsync(FloorPlanBlockEditViewModel model);
|
Task<int> SaveBlockAsync(FloorPlanBlockEditViewModel model);
|
||||||
Task SaveLayoutAsync(FloorPlanEditorViewModel model);
|
Task SaveLayoutAsync(FloorPlanEditorViewModel model);
|
||||||
|
Task<FloorPlanBackgroundImageUploadResult> UploadBackgroundAsync(int floorPlanId, int floorPlanFloorId, IFormFile upload);
|
||||||
|
Task SaveBackgroundSettingsAsync(FloorPlanFloorBackgroundSettingsViewModel model);
|
||||||
|
Task ResetBackgroundAsync(int floorPlanId, int floorPlanFloorId);
|
||||||
|
Task RemoveBackgroundAsync(int floorPlanId, int floorPlanFloorId);
|
||||||
Task DeleteBlockAsync(int floorPlanBlockId);
|
Task DeleteBlockAsync(int floorPlanBlockId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -7526,6 +7530,7 @@ public sealed class FloorPlanService(
|
|||||||
IProjectRepository projects,
|
IProjectRepository projects,
|
||||||
IFloorPlanRepository floorPlans,
|
IFloorPlanRepository floorPlans,
|
||||||
ILocationRepository locations,
|
ILocationRepository locations,
|
||||||
|
IFloorPlanBackgroundImageService backgroundImages,
|
||||||
IProjectActivityService activity) : IFloorPlanService
|
IProjectActivityService activity) : IFloorPlanService
|
||||||
{
|
{
|
||||||
public async Task<FloorPlanListViewModel?> ListAsync(int projectId)
|
public async Task<FloorPlanListViewModel?> ListAsync(int projectId)
|
||||||
@ -7706,6 +7711,85 @@ public sealed class FloorPlanService(
|
|||||||
|
|
||||||
public Task DeleteBlockAsync(int floorPlanBlockId) => floorPlans.DeleteBlockAsync(floorPlanBlockId);
|
public Task DeleteBlockAsync(int floorPlanBlockId) => floorPlans.DeleteBlockAsync(floorPlanBlockId);
|
||||||
|
|
||||||
|
public async Task<FloorPlanBackgroundImageUploadResult> UploadBackgroundAsync(int floorPlanId, int floorPlanFloorId, IFormFile upload)
|
||||||
|
{
|
||||||
|
var (plan, floor) = await GetPlanFloorAsync(floorPlanId, floorPlanFloorId);
|
||||||
|
var result = await backgroundImages.UploadAsync(plan, floor, upload);
|
||||||
|
if (!result.Succeeded)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
var previousPath = floor.BackgroundImagePath;
|
||||||
|
floor.BackgroundImagePath = result.StoragePath;
|
||||||
|
floor.BackgroundImageOriginalFileName = result.OriginalFileName;
|
||||||
|
floor.BackgroundOpacity = Clamp(floor.BackgroundOpacity ?? 0.35m, 0.05m, 1m);
|
||||||
|
floor.BackgroundScale = Clamp(floor.BackgroundScale ?? 1m, 0.1m, 5m);
|
||||||
|
floor.BackgroundOffsetX = Clamp(floor.BackgroundOffsetX ?? 0m, -10000m, 10000m);
|
||||||
|
floor.BackgroundOffsetY = Clamp(floor.BackgroundOffsetY ?? 0m, -10000m, 10000m);
|
||||||
|
floor.BackgroundLocked = true;
|
||||||
|
|
||||||
|
if (!await floorPlans.UpdateFloorBackgroundAsync(floor))
|
||||||
|
{
|
||||||
|
backgroundImages.DeleteBackgroundFile(result.StoragePath);
|
||||||
|
return new(false, ErrorMessage: "Background image could not be linked to this floor.");
|
||||||
|
}
|
||||||
|
|
||||||
|
backgroundImages.DeleteBackgroundFile(previousPath);
|
||||||
|
await activity.RecordAsync(plan.ProjectID, "Updated", "Floor Plan Background", floorPlanFloorId, floor.Name);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveBackgroundSettingsAsync(FloorPlanFloorBackgroundSettingsViewModel model)
|
||||||
|
{
|
||||||
|
var (_, floor) = await GetPlanFloorAsync(model.FloorPlanID, model.FloorPlanFloorID);
|
||||||
|
floor.BackgroundOpacity = Clamp(model.BackgroundOpacity, 0.05m, 1m);
|
||||||
|
floor.BackgroundScale = Clamp(model.BackgroundScale, 0.1m, 5m);
|
||||||
|
floor.BackgroundOffsetX = Clamp(model.BackgroundOffsetX, -10000m, 10000m);
|
||||||
|
floor.BackgroundOffsetY = Clamp(model.BackgroundOffsetY, -10000m, 10000m);
|
||||||
|
floor.BackgroundLocked = model.BackgroundLocked;
|
||||||
|
|
||||||
|
if (!await floorPlans.UpdateFloorBackgroundAsync(floor))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Background settings could not be saved.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ResetBackgroundAsync(int floorPlanId, int floorPlanFloorId)
|
||||||
|
{
|
||||||
|
var (_, floor) = await GetPlanFloorAsync(floorPlanId, floorPlanFloorId);
|
||||||
|
floor.BackgroundOpacity = 0.35m;
|
||||||
|
floor.BackgroundScale = 1m;
|
||||||
|
floor.BackgroundOffsetX = 0m;
|
||||||
|
floor.BackgroundOffsetY = 0m;
|
||||||
|
floor.BackgroundLocked = true;
|
||||||
|
|
||||||
|
if (!await floorPlans.UpdateFloorBackgroundAsync(floor))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Background settings could not be reset.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RemoveBackgroundAsync(int floorPlanId, int floorPlanFloorId)
|
||||||
|
{
|
||||||
|
var (_, floor) = await GetPlanFloorAsync(floorPlanId, floorPlanFloorId);
|
||||||
|
var previousPath = floor.BackgroundImagePath;
|
||||||
|
floor.BackgroundImagePath = null;
|
||||||
|
floor.BackgroundImageOriginalFileName = null;
|
||||||
|
floor.BackgroundOpacity = 0.35m;
|
||||||
|
floor.BackgroundScale = 1m;
|
||||||
|
floor.BackgroundOffsetX = 0m;
|
||||||
|
floor.BackgroundOffsetY = 0m;
|
||||||
|
floor.BackgroundLocked = true;
|
||||||
|
|
||||||
|
if (!await floorPlans.UpdateFloorBackgroundAsync(floor))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Background image could not be removed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
backgroundImages.DeleteBackgroundFile(previousPath);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task ValidateBlockAsync(FloorPlanBlockEditViewModel model, FloorPlanFloor floor, int projectId)
|
private async Task ValidateBlockAsync(FloorPlanBlockEditViewModel model, FloorPlanFloor floor, int projectId)
|
||||||
{
|
{
|
||||||
if (!FloorPlanBlockTypes.IsValid(model.BlockType))
|
if (!FloorPlanBlockTypes.IsValid(model.BlockType))
|
||||||
@ -7735,6 +7819,24 @@ public sealed class FloorPlanService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<(FloorPlan Plan, FloorPlanFloor Floor)> GetPlanFloorAsync(int floorPlanId, int floorPlanFloorId)
|
||||||
|
{
|
||||||
|
var plan = await floorPlans.GetAsync(floorPlanId)
|
||||||
|
?? throw new InvalidOperationException("Floor plan not found.");
|
||||||
|
var floor = await floorPlans.GetFloorAsync(floorPlanFloorId)
|
||||||
|
?? throw new InvalidOperationException("Floor not found.");
|
||||||
|
|
||||||
|
if (floor.FloorPlanID != plan.FloorPlanID)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Floor does not belong to this floor plan.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (plan, floor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal Clamp(decimal value, decimal min, decimal max)
|
||||||
|
=> Math.Min(Math.Max(value, min), max);
|
||||||
|
|
||||||
private async Task<int?> ResolveFloorPlanLocationAsync(FloorPlanEditViewModel model)
|
private async Task<int?> ResolveFloorPlanLocationAsync(FloorPlanEditViewModel model)
|
||||||
{
|
{
|
||||||
if (model.LocationID.HasValue && model.LocationID.Value > 0)
|
if (model.LocationID.HasValue && model.LocationID.Value > 0)
|
||||||
@ -7877,7 +7979,14 @@ public sealed class FloorPlanService(
|
|||||||
Name = floor.Name,
|
Name = floor.Name,
|
||||||
SortOrder = floor.SortOrder,
|
SortOrder = floor.SortOrder,
|
||||||
GridWidth = floor.GridWidth,
|
GridWidth = floor.GridWidth,
|
||||||
GridHeight = floor.GridHeight
|
GridHeight = floor.GridHeight,
|
||||||
|
BackgroundImagePath = floor.BackgroundImagePath,
|
||||||
|
BackgroundImageOriginalFileName = floor.BackgroundImageOriginalFileName,
|
||||||
|
BackgroundOpacity = floor.BackgroundOpacity ?? 0.35m,
|
||||||
|
BackgroundScale = floor.BackgroundScale ?? 1m,
|
||||||
|
BackgroundOffsetX = floor.BackgroundOffsetX ?? 0m,
|
||||||
|
BackgroundOffsetY = floor.BackgroundOffsetY ?? 0m,
|
||||||
|
BackgroundLocked = floor.BackgroundLocked
|
||||||
};
|
};
|
||||||
|
|
||||||
private static FloorPlanBlockEditViewModel ToBlockEdit(FloorPlanBlock block) => new()
|
private static FloorPlanBlockEditViewModel ToBlockEdit(FloorPlanBlock block) => new()
|
||||||
|
|||||||
91
PlotLine/Services/FloorPlanBackgroundImageService.cs
Normal file
91
PlotLine/Services/FloorPlanBackgroundImageService.cs
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
using PlotLine.Models;
|
||||||
|
using SkiaSharp;
|
||||||
|
|
||||||
|
namespace PlotLine.Services;
|
||||||
|
|
||||||
|
public interface IFloorPlanBackgroundImageService
|
||||||
|
{
|
||||||
|
Task<FloorPlanBackgroundImageUploadResult> UploadAsync(FloorPlan floorPlan, FloorPlanFloor floor, IFormFile upload);
|
||||||
|
void DeleteBackgroundFile(string? backgroundImagePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record FloorPlanBackgroundImageUploadResult(
|
||||||
|
bool Succeeded,
|
||||||
|
string? StoragePath = null,
|
||||||
|
string? OriginalFileName = null,
|
||||||
|
string? ErrorMessage = null);
|
||||||
|
|
||||||
|
public sealed class FloorPlanBackgroundImageService(
|
||||||
|
IUploadStorageService uploadStorage,
|
||||||
|
ILogger<FloorPlanBackgroundImageService> logger) : IFloorPlanBackgroundImageService
|
||||||
|
{
|
||||||
|
private const long MaxUploadBytes = 10L * 1024L * 1024L;
|
||||||
|
private static readonly HashSet<string> SupportedExtensions = new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp" };
|
||||||
|
|
||||||
|
public async Task<FloorPlanBackgroundImageUploadResult> UploadAsync(FloorPlan floorPlan, FloorPlanFloor floor, IFormFile upload)
|
||||||
|
{
|
||||||
|
if (upload.Length <= 0)
|
||||||
|
{
|
||||||
|
return new(false, ErrorMessage: "Choose a background image to upload.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (upload.Length > MaxUploadBytes)
|
||||||
|
{
|
||||||
|
return new(false, ErrorMessage: "Background image must be 10 MB or smaller.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var extension = Path.GetExtension(upload.FileName);
|
||||||
|
if (!SupportedExtensions.Contains(extension))
|
||||||
|
{
|
||||||
|
return new(false, ErrorMessage: "Background image must be a PNG, JPG, JPEG or WebP file.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var originalFileName = Path.GetFileName(upload.FileName);
|
||||||
|
if (string.IsNullOrWhiteSpace(originalFileName))
|
||||||
|
{
|
||||||
|
originalFileName = $"background{extension}";
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] bytes;
|
||||||
|
await using (var stream = upload.OpenReadStream())
|
||||||
|
using (var memory = new MemoryStream())
|
||||||
|
{
|
||||||
|
await stream.CopyToAsync(memory);
|
||||||
|
bytes = memory.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var codec = SKCodec.Create(new MemoryStream(bytes)) ?? throw new InvalidDataException();
|
||||||
|
if (codec.EncodedFormat is not (SKEncodedImageFormat.Jpeg or SKEncodedImageFormat.Png or SKEncodedImageFormat.Webp))
|
||||||
|
{
|
||||||
|
return new(false, ErrorMessage: "Background image must be a valid PNG, JPG, JPEG or WebP file.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is InvalidDataException or ArgumentException)
|
||||||
|
{
|
||||||
|
return new(false, ErrorMessage: "Background image must be a valid PNG, JPG, JPEG or WebP file.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var storedName = $"background-{Guid.NewGuid():N}{extension.ToLowerInvariant()}";
|
||||||
|
var uploadRoot = uploadStorage.GetDirectory("floor-plans", floorPlan.ProjectID.ToString(), floorPlan.FloorPlanID.ToString(), "backgrounds");
|
||||||
|
var absolutePath = Path.Combine(uploadRoot, storedName);
|
||||||
|
var storagePath = uploadStorage.GetPublicPath("floor-plans", floorPlan.ProjectID.ToString(), floorPlan.FloorPlanID.ToString(), "backgrounds", storedName);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await File.WriteAllBytesAsync(absolutePath, bytes);
|
||||||
|
return new(true, storagePath, originalFileName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Floor plan background upload failed for floor {FloorPlanFloorID}.", floor.FloorPlanFloorID);
|
||||||
|
return new(false, ErrorMessage: "Background image could not be saved. Try another image.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DeleteBackgroundFile(string? backgroundImagePath)
|
||||||
|
{
|
||||||
|
uploadStorage.TryDeleteFile(backgroundImagePath, "uploads/floor-plans");
|
||||||
|
}
|
||||||
|
}
|
||||||
216
PlotLine/Sql/074_FloorPlanFloorBackgroundImages.sql
Normal file
216
PlotLine/Sql/074_FloorPlanFloorBackgroundImages.sql
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
SET ANSI_NULLS ON;
|
||||||
|
GO
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF COL_LENGTH(N'dbo.FloorPlanFloors', N'BackgroundImagePath') IS NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.FloorPlanFloors ADD BackgroundImagePath nvarchar(max) NULL;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF COL_LENGTH(N'dbo.FloorPlanFloors', N'BackgroundImageOriginalFileName') IS NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.FloorPlanFloors ADD BackgroundImageOriginalFileName nvarchar(255) NULL;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF COL_LENGTH(N'dbo.FloorPlanFloors', N'BackgroundOpacity') IS NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.FloorPlanFloors ADD BackgroundOpacity decimal(5,2) NULL;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF COL_LENGTH(N'dbo.FloorPlanFloors', N'BackgroundScale') IS NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.FloorPlanFloors ADD BackgroundScale decimal(8,3) NULL;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF COL_LENGTH(N'dbo.FloorPlanFloors', N'BackgroundOffsetX') IS NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.FloorPlanFloors ADD BackgroundOffsetX decimal(10,2) NULL;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF COL_LENGTH(N'dbo.FloorPlanFloors', N'BackgroundOffsetY') IS NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.FloorPlanFloors ADD BackgroundOffsetY decimal(10,2) NULL;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF COL_LENGTH(N'dbo.FloorPlanFloors', N'BackgroundLocked') IS NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.FloorPlanFloors
|
||||||
|
ADD BackgroundLocked bit NOT NULL
|
||||||
|
CONSTRAINT DF_FloorPlanFloors_BackgroundLocked DEFAULT 1;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.FloorPlan_GetEditor
|
||||||
|
@FloorPlanID int
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
EXEC dbo.FloorPlan_Get @FloorPlanID;
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
fpf.FloorPlanFloorID,
|
||||||
|
fpf.FloorPlanID,
|
||||||
|
fpf.LocationID,
|
||||||
|
lp.LocationName,
|
||||||
|
lp.LocationPath,
|
||||||
|
fpf.Name,
|
||||||
|
fpf.SortOrder,
|
||||||
|
fpf.GridWidth,
|
||||||
|
fpf.GridHeight,
|
||||||
|
fpf.BackgroundImagePath,
|
||||||
|
fpf.BackgroundImageOriginalFileName,
|
||||||
|
COALESCE(fpf.BackgroundOpacity, CONVERT(decimal(5,2), 0.35)) AS BackgroundOpacity,
|
||||||
|
COALESCE(fpf.BackgroundScale, CONVERT(decimal(8,3), 1.000)) AS BackgroundScale,
|
||||||
|
COALESCE(fpf.BackgroundOffsetX, CONVERT(decimal(10,2), 0.00)) AS BackgroundOffsetX,
|
||||||
|
COALESCE(fpf.BackgroundOffsetY, CONVERT(decimal(10,2), 0.00)) AS BackgroundOffsetY,
|
||||||
|
fpf.BackgroundLocked,
|
||||||
|
fpf.CreatedDate,
|
||||||
|
fpf.ModifiedDate
|
||||||
|
FROM dbo.FloorPlanFloors fpf
|
||||||
|
LEFT JOIN dbo.LocationPaths lp ON lp.LocationID = fpf.LocationID
|
||||||
|
WHERE fpf.FloorPlanID = @FloorPlanID
|
||||||
|
ORDER BY fpf.SortOrder, fpf.Name;
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
fpb.FloorPlanBlockID,
|
||||||
|
fpb.FloorPlanFloorID,
|
||||||
|
fpb.LocationID,
|
||||||
|
lp.LocationName,
|
||||||
|
COALESCE(NULLIF(lp.LocationPath, N''), lp.LocationName) AS LocationPath,
|
||||||
|
fpb.X,
|
||||||
|
fpb.Y,
|
||||||
|
fpb.WidthCells,
|
||||||
|
fpb.HeightCells,
|
||||||
|
fpb.BlockType,
|
||||||
|
fpb.LabelOverride,
|
||||||
|
fpb.Notes,
|
||||||
|
fpb.CreatedDate,
|
||||||
|
fpb.ModifiedDate
|
||||||
|
FROM dbo.FloorPlanBlocks fpb
|
||||||
|
INNER JOIN dbo.FloorPlanFloors fpf ON fpf.FloorPlanFloorID = fpb.FloorPlanFloorID
|
||||||
|
INNER JOIN dbo.LocationPaths lp ON lp.LocationID = fpb.LocationID
|
||||||
|
WHERE fpf.FloorPlanID = @FloorPlanID
|
||||||
|
ORDER BY fpf.SortOrder, fpb.Y, fpb.X, lp.LocationName;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.FloorPlanFloor_Get
|
||||||
|
@FloorPlanFloorID int
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
fpf.FloorPlanFloorID,
|
||||||
|
fpf.FloorPlanID,
|
||||||
|
fpf.LocationID,
|
||||||
|
lp.LocationName,
|
||||||
|
lp.LocationPath,
|
||||||
|
fpf.Name,
|
||||||
|
fpf.SortOrder,
|
||||||
|
fpf.GridWidth,
|
||||||
|
fpf.GridHeight,
|
||||||
|
fpf.BackgroundImagePath,
|
||||||
|
fpf.BackgroundImageOriginalFileName,
|
||||||
|
COALESCE(fpf.BackgroundOpacity, CONVERT(decimal(5,2), 0.35)) AS BackgroundOpacity,
|
||||||
|
COALESCE(fpf.BackgroundScale, CONVERT(decimal(8,3), 1.000)) AS BackgroundScale,
|
||||||
|
COALESCE(fpf.BackgroundOffsetX, CONVERT(decimal(10,2), 0.00)) AS BackgroundOffsetX,
|
||||||
|
COALESCE(fpf.BackgroundOffsetY, CONVERT(decimal(10,2), 0.00)) AS BackgroundOffsetY,
|
||||||
|
fpf.BackgroundLocked,
|
||||||
|
fpf.CreatedDate,
|
||||||
|
fpf.ModifiedDate
|
||||||
|
FROM dbo.FloorPlanFloors fpf
|
||||||
|
LEFT JOIN dbo.LocationPaths lp ON lp.LocationID = fpf.LocationID
|
||||||
|
WHERE fpf.FloorPlanFloorID = @FloorPlanFloorID;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.FloorPlanFloor_Save
|
||||||
|
@FloorPlanFloorID int,
|
||||||
|
@FloorPlanID int,
|
||||||
|
@LocationID int = NULL,
|
||||||
|
@Name nvarchar(100),
|
||||||
|
@SortOrder int,
|
||||||
|
@GridWidth int,
|
||||||
|
@GridHeight int
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
DECLARE @ProjectID int;
|
||||||
|
SELECT @ProjectID = ProjectID FROM dbo.FloorPlans WHERE FloorPlanID = @FloorPlanID;
|
||||||
|
|
||||||
|
IF @ProjectID IS NULL
|
||||||
|
THROW 51003, 'Floor plan not found.', 1;
|
||||||
|
|
||||||
|
SET @Name = NULLIF(LTRIM(RTRIM(@Name)), N'');
|
||||||
|
IF @Name IS NULL
|
||||||
|
THROW 51001, 'Floor name is required.', 1;
|
||||||
|
|
||||||
|
IF @GridWidth NOT BETWEEN 8 AND 80 OR @GridHeight NOT BETWEEN 8 AND 80
|
||||||
|
THROW 51002, 'Grid width and height must be between 8 and 80.', 1;
|
||||||
|
|
||||||
|
IF @LocationID IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM dbo.Locations WHERE LocationID = @LocationID AND ProjectID = @ProjectID AND IsArchived = 0)
|
||||||
|
THROW 51008, 'Floor location must belong to this project.', 1;
|
||||||
|
|
||||||
|
IF @FloorPlanFloorID = 0
|
||||||
|
BEGIN
|
||||||
|
INSERT dbo.FloorPlanFloors (FloorPlanID, LocationID, Name, SortOrder, GridWidth, GridHeight, BackgroundOpacity, BackgroundScale, BackgroundOffsetX, BackgroundOffsetY, BackgroundLocked)
|
||||||
|
VALUES (@FloorPlanID, @LocationID, @Name, @SortOrder, @GridWidth, @GridHeight, 0.35, 1.000, 0.00, 0.00, 1);
|
||||||
|
|
||||||
|
SELECT CONVERT(int, SCOPE_IDENTITY());
|
||||||
|
RETURN;
|
||||||
|
END;
|
||||||
|
|
||||||
|
UPDATE dbo.FloorPlanFloors
|
||||||
|
SET LocationID = @LocationID,
|
||||||
|
Name = @Name,
|
||||||
|
SortOrder = @SortOrder,
|
||||||
|
GridWidth = @GridWidth,
|
||||||
|
GridHeight = @GridHeight,
|
||||||
|
ModifiedDate = SYSUTCDATETIME()
|
||||||
|
WHERE FloorPlanFloorID = @FloorPlanFloorID
|
||||||
|
AND FloorPlanID = @FloorPlanID;
|
||||||
|
|
||||||
|
SELECT @FloorPlanFloorID;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.FloorPlanFloor_UpdateBackground
|
||||||
|
@FloorPlanFloorID int,
|
||||||
|
@FloorPlanID int,
|
||||||
|
@BackgroundImagePath nvarchar(max) = NULL,
|
||||||
|
@BackgroundImageOriginalFileName nvarchar(255) = NULL,
|
||||||
|
@BackgroundOpacity decimal(5,2) = NULL,
|
||||||
|
@BackgroundScale decimal(8,3) = NULL,
|
||||||
|
@BackgroundOffsetX decimal(10,2) = NULL,
|
||||||
|
@BackgroundOffsetY decimal(10,2) = NULL,
|
||||||
|
@BackgroundLocked bit = 1
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
UPDATE dbo.FloorPlanFloors
|
||||||
|
SET BackgroundImagePath = @BackgroundImagePath,
|
||||||
|
BackgroundImageOriginalFileName = @BackgroundImageOriginalFileName,
|
||||||
|
BackgroundOpacity = @BackgroundOpacity,
|
||||||
|
BackgroundScale = @BackgroundScale,
|
||||||
|
BackgroundOffsetX = @BackgroundOffsetX,
|
||||||
|
BackgroundOffsetY = @BackgroundOffsetY,
|
||||||
|
BackgroundLocked = @BackgroundLocked,
|
||||||
|
ModifiedDate = SYSUTCDATETIME()
|
||||||
|
WHERE FloorPlanFloorID = @FloorPlanFloorID
|
||||||
|
AND FloorPlanID = @FloorPlanID;
|
||||||
|
|
||||||
|
SELECT @@ROWCOUNT;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
@ -1869,6 +1869,43 @@ public sealed class FloorPlanFloorEditViewModel
|
|||||||
|
|
||||||
[Range(8, 80)]
|
[Range(8, 80)]
|
||||||
public int GridHeight { get; set; } = 16;
|
public int GridHeight { get; set; } = 16;
|
||||||
|
|
||||||
|
public string? BackgroundImagePath { get; set; }
|
||||||
|
public string? BackgroundImageOriginalFileName { get; set; }
|
||||||
|
|
||||||
|
[Range(0.05, 1.0)]
|
||||||
|
public decimal BackgroundOpacity { get; set; } = 0.35m;
|
||||||
|
|
||||||
|
[Range(0.1, 5.0)]
|
||||||
|
public decimal BackgroundScale { get; set; } = 1m;
|
||||||
|
|
||||||
|
[Range(-10000, 10000)]
|
||||||
|
public decimal BackgroundOffsetX { get; set; }
|
||||||
|
|
||||||
|
[Range(-10000, 10000)]
|
||||||
|
public decimal BackgroundOffsetY { get; set; }
|
||||||
|
|
||||||
|
public bool BackgroundLocked { get; set; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class FloorPlanFloorBackgroundSettingsViewModel
|
||||||
|
{
|
||||||
|
public int FloorPlanID { get; set; }
|
||||||
|
public int FloorPlanFloorID { get; set; }
|
||||||
|
|
||||||
|
[Range(0.05, 1.0)]
|
||||||
|
public decimal BackgroundOpacity { get; set; } = 0.35m;
|
||||||
|
|
||||||
|
[Range(0.1, 5.0)]
|
||||||
|
public decimal BackgroundScale { get; set; } = 1m;
|
||||||
|
|
||||||
|
[Range(-10000, 10000)]
|
||||||
|
public decimal BackgroundOffsetX { get; set; }
|
||||||
|
|
||||||
|
[Range(-10000, 10000)]
|
||||||
|
public decimal BackgroundOffsetY { get; set; }
|
||||||
|
|
||||||
|
public bool BackgroundLocked { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class FloorPlanBlockEditViewModel
|
public sealed class FloorPlanBlockEditViewModel
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
var blocksByFloor = Model.Blocks.GroupBy(x => x.FloorPlanFloorID).ToDictionary(x => x.Key, x => x.ToList());
|
var blocksByFloor = Model.Blocks.GroupBy(x => x.FloorPlanFloorID).ToDictionary(x => x.Key, x => x.ToList());
|
||||||
var selectedBlockId = int.TryParse(Context.Request.Query["selectedBlockId"], out var parsedSelectedBlockId) ? parsedSelectedBlockId : Model.Blocks.FirstOrDefault()?.FloorPlanBlockID ?? 0;
|
var selectedBlockId = int.TryParse(Context.Request.Query["selectedBlockId"], out var parsedSelectedBlockId) ? parsedSelectedBlockId : Model.Blocks.FirstOrDefault()?.FloorPlanBlockID ?? 0;
|
||||||
string BlockClass(string blockType) => $"floor-plan-block floor-plan-block--{blockType.ToLowerInvariant()}";
|
string BlockClass(string blockType) => $"floor-plan-block floor-plan-block--{blockType.ToLowerInvariant()}";
|
||||||
|
string CssNumber(decimal value) => value.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture);
|
||||||
}
|
}
|
||||||
|
|
||||||
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
|
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
|
||||||
@ -134,6 +135,14 @@ else
|
|||||||
data-floor-grid="@floor.FloorPlanFloorID"
|
data-floor-grid="@floor.FloorPlanFloorID"
|
||||||
data-grid-width="@floor.GridWidth"
|
data-grid-width="@floor.GridWidth"
|
||||||
data-grid-height="@floor.GridHeight">
|
data-grid-height="@floor.GridHeight">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(floor.BackgroundImagePath))
|
||||||
|
{
|
||||||
|
<img class="floor-plan-background-image @(floor.BackgroundLocked ? "is-locked" : string.Empty)"
|
||||||
|
src="@floor.BackgroundImagePath"
|
||||||
|
alt=""
|
||||||
|
style="--bg-opacity:@CssNumber(floor.BackgroundOpacity);--bg-scale:@CssNumber(floor.BackgroundScale);--bg-offset-x:@CssNumber(floor.BackgroundOffsetX)px;--bg-offset-y:@CssNumber(floor.BackgroundOffsetY)px;"
|
||||||
|
data-background-image="@floor.FloorPlanFloorID" />
|
||||||
|
}
|
||||||
@foreach (var block in floorBlocks)
|
@foreach (var block in floorBlocks)
|
||||||
{
|
{
|
||||||
var blockIndex = Model.Blocks.FindIndex(x => x.FloorPlanBlockID == block.FloorPlanBlockID);
|
var blockIndex = Model.Blocks.FindIndex(x => x.FloorPlanBlockID == block.FloorPlanBlockID);
|
||||||
@ -159,6 +168,7 @@ else
|
|||||||
<div class="floor-plan-toolbox-tabs" role="tablist" aria-label="Floor plan tools">
|
<div class="floor-plan-toolbox-tabs" role="tablist" aria-label="Floor plan tools">
|
||||||
<button type="button" class="floor-plan-toolbox-tab active" id="floor-plan-tool-tab-add" role="tab" aria-selected="true" aria-controls="floor-plan-tool-add" data-toolbox-tab="add">Add block</button>
|
<button type="button" class="floor-plan-toolbox-tab active" id="floor-plan-tool-tab-add" role="tab" aria-selected="true" aria-controls="floor-plan-tool-add" data-toolbox-tab="add">Add block</button>
|
||||||
<button type="button" class="floor-plan-toolbox-tab" id="floor-plan-tool-tab-selected" role="tab" aria-selected="false" aria-controls="floor-plan-tool-selected" data-toolbox-tab="selected">Selected block</button>
|
<button type="button" class="floor-plan-toolbox-tab" id="floor-plan-tool-tab-selected" role="tab" aria-selected="false" aria-controls="floor-plan-tool-selected" data-toolbox-tab="selected">Selected block</button>
|
||||||
|
<button type="button" class="floor-plan-toolbox-tab" id="floor-plan-tool-tab-background" role="tab" aria-selected="false" aria-controls="floor-plan-tool-background" data-toolbox-tab="background">Background</button>
|
||||||
<button type="button" class="floor-plan-toolbox-tab" id="floor-plan-tool-tab-floors" role="tab" aria-selected="false" aria-controls="floor-plan-tool-floors" data-toolbox-tab="floors">Floors</button>
|
<button type="button" class="floor-plan-toolbox-tab" id="floor-plan-tool-tab-floors" role="tab" aria-selected="false" aria-controls="floor-plan-tool-floors" data-toolbox-tab="floors">Floors</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="floor-plan-toolbox-body">
|
<div class="floor-plan-toolbox-body">
|
||||||
@ -266,6 +276,113 @@ else
|
|||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="floor-plan-tool-card" id="floor-plan-tool-background" role="tabpanel" aria-labelledby="floor-plan-tool-tab-background" data-toolbox-panel="background" hidden>
|
||||||
|
<h2>Background</h2>
|
||||||
|
<p class="muted">Settings affect only <span data-current-floor-name>@activeFloor?.Name</span>.</p>
|
||||||
|
@for (var i = 0; i < Model.Floors.Count; i++)
|
||||||
|
{
|
||||||
|
var floor = Model.Floors[i];
|
||||||
|
var isActive = floor.FloorPlanFloorID == activeFloor?.FloorPlanFloorID;
|
||||||
|
<div class="floor-plan-background-editor @(isActive ? string.Empty : "d-none")" data-background-editor="@floor.FloorPlanFloorID">
|
||||||
|
@if (string.IsNullOrWhiteSpace(floor.BackgroundImagePath))
|
||||||
|
{
|
||||||
|
<p class="muted">Upload a map, sketch, or floor-plan image to trace over. The image sits behind the grid and blocks.</p>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="background-image-@floor.FloorPlanFloorID">Background image</label>
|
||||||
|
<input id="background-image-@floor.FloorPlanFloorID"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
type="file"
|
||||||
|
name="backgroundImage"
|
||||||
|
accept=".png,.jpg,.jpeg,.webp,image/png,image/jpeg,image/webp"
|
||||||
|
form="floor-plan-background-upload-@floor.FloorPlanFloorID" />
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-sm w-100" type="submit" form="floor-plan-background-upload-@floor.FloorPlanFloorID">Upload background</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="floor-plan-background-file">Background: <strong>@floor.BackgroundImageOriginalFileName</strong></p>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label" for="background-opacity-@floor.FloorPlanFloorID">Opacity</label>
|
||||||
|
<div class="floor-plan-background-range">
|
||||||
|
<input id="background-opacity-@floor.FloorPlanFloorID"
|
||||||
|
class="form-range"
|
||||||
|
type="range"
|
||||||
|
name="BackgroundOpacity"
|
||||||
|
min="0.05"
|
||||||
|
max="1"
|
||||||
|
step="0.05"
|
||||||
|
value="@CssNumber(floor.BackgroundOpacity)"
|
||||||
|
data-background-field="opacity"
|
||||||
|
data-background-floor="@floor.FloorPlanFloorID"
|
||||||
|
form="floor-plan-background-settings-@floor.FloorPlanFloorID" />
|
||||||
|
<span data-background-opacity-value="@floor.FloorPlanFloorID">@CssNumber(floor.BackgroundOpacity)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label" for="background-scale-@floor.FloorPlanFloorID">Scale</label>
|
||||||
|
<input id="background-scale-@floor.FloorPlanFloorID"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
type="number"
|
||||||
|
name="BackgroundScale"
|
||||||
|
min="0.1"
|
||||||
|
max="5"
|
||||||
|
step="0.05"
|
||||||
|
value="@CssNumber(floor.BackgroundScale)"
|
||||||
|
data-background-field="scale"
|
||||||
|
data-background-floor="@floor.FloorPlanFloorID"
|
||||||
|
form="floor-plan-background-settings-@floor.FloorPlanFloorID" />
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label" for="background-offset-x-@floor.FloorPlanFloorID">Offset X</label>
|
||||||
|
<input id="background-offset-x-@floor.FloorPlanFloorID"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
type="number"
|
||||||
|
name="BackgroundOffsetX"
|
||||||
|
min="-10000"
|
||||||
|
max="10000"
|
||||||
|
step="1"
|
||||||
|
value="@CssNumber(floor.BackgroundOffsetX)"
|
||||||
|
data-background-field="offsetX"
|
||||||
|
data-background-floor="@floor.FloorPlanFloorID"
|
||||||
|
form="floor-plan-background-settings-@floor.FloorPlanFloorID" />
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label" for="background-offset-y-@floor.FloorPlanFloorID">Offset Y</label>
|
||||||
|
<input id="background-offset-y-@floor.FloorPlanFloorID"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
type="number"
|
||||||
|
name="BackgroundOffsetY"
|
||||||
|
min="-10000"
|
||||||
|
max="10000"
|
||||||
|
step="1"
|
||||||
|
value="@CssNumber(floor.BackgroundOffsetY)"
|
||||||
|
data-background-field="offsetY"
|
||||||
|
data-background-floor="@floor.FloorPlanFloorID"
|
||||||
|
form="floor-plan-background-settings-@floor.FloorPlanFloorID" />
|
||||||
|
</div>
|
||||||
|
<div class="col-6 d-flex align-items-end">
|
||||||
|
<label class="floor-plan-checkbox">
|
||||||
|
<input type="checkbox"
|
||||||
|
name="BackgroundLocked"
|
||||||
|
value="true"
|
||||||
|
@(floor.BackgroundLocked ? "checked" : string.Empty)
|
||||||
|
data-background-field="locked"
|
||||||
|
data-background-floor="@floor.FloorPlanFloorID"
|
||||||
|
form="floor-plan-background-settings-@floor.FloorPlanFloorID" />
|
||||||
|
Locked
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="button-row mt-3">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="submit" form="floor-plan-background-reset-@floor.FloorPlanFloorID">Reset position</button>
|
||||||
|
<button class="btn btn-outline-danger btn-sm" type="button" data-bs-toggle="modal" data-bs-target="#remove-background-@floor.FloorPlanFloorID">Remove background</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="floor-plan-tool-card" id="floor-plan-tool-floors" role="tabpanel" aria-labelledby="floor-plan-tool-tab-floors" data-toolbox-panel="floors" hidden>
|
<section class="floor-plan-tool-card" id="floor-plan-tool-floors" role="tabpanel" aria-labelledby="floor-plan-tool-tab-floors" data-toolbox-panel="floors" hidden>
|
||||||
<h2>Floors</h2>
|
<h2>Floors</h2>
|
||||||
<div class="floor-plan-tool-subsection">
|
<div class="floor-plan-tool-subsection">
|
||||||
@ -338,6 +455,24 @@ else
|
|||||||
|
|
||||||
<form id="floor-plan-add-floor-form" asp-action="AddFloor" method="post"></form>
|
<form id="floor-plan-add-floor-form" asp-action="AddFloor" method="post"></form>
|
||||||
|
|
||||||
|
@foreach (var floor in Model.Floors)
|
||||||
|
{
|
||||||
|
<form id="floor-plan-background-upload-@floor.FloorPlanFloorID" asp-action="UploadBackground" method="post" enctype="multipart/form-data">
|
||||||
|
<input type="hidden" name="floorPlanId" value="@Model.FloorPlan.FloorPlanID" />
|
||||||
|
<input type="hidden" name="floorPlanFloorId" value="@floor.FloorPlanFloorID" />
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form id="floor-plan-background-settings-@floor.FloorPlanFloorID" asp-action="SaveBackgroundSettings" method="post" data-background-settings-form="@floor.FloorPlanFloorID">
|
||||||
|
<input type="hidden" name="FloorPlanID" value="@Model.FloorPlan.FloorPlanID" />
|
||||||
|
<input type="hidden" name="FloorPlanFloorID" value="@floor.FloorPlanFloorID" />
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form id="floor-plan-background-reset-@floor.FloorPlanFloorID" asp-action="ResetBackground" method="post">
|
||||||
|
<input type="hidden" name="floorPlanId" value="@Model.FloorPlan.FloorPlanID" />
|
||||||
|
<input type="hidden" name="floorPlanFloorId" value="@floor.FloorPlanFloorID" />
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
@foreach (var floor in Model.Floors)
|
@foreach (var floor in Model.Floors)
|
||||||
{
|
{
|
||||||
var placedBlockCount = Model.Blocks.Count(x => x.FloorPlanFloorID == floor.FloorPlanFloorID);
|
var placedBlockCount = Model.Blocks.Count(x => x.FloorPlanFloorID == floor.FloorPlanFloorID);
|
||||||
@ -365,6 +500,32 @@ else
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@foreach (var floor in Model.Floors.Where(x => !string.IsNullOrWhiteSpace(x.BackgroundImagePath)))
|
||||||
|
{
|
||||||
|
<div class="modal fade" id="remove-background-@floor.FloorPlanFloorID" tabindex="-1" aria-labelledby="remove-background-title-@floor.FloorPlanFloorID" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content plotline-confirm-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 class="modal-title fs-5" id="remove-background-title-@floor.FloorPlanFloorID">Remove background?</h2>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>Remove the background image for <strong>@floor.Name</strong>?</p>
|
||||||
|
<p>This removes only the tracing image from this floor. Blocks and linked Locations will not be deleted.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<form asp-action="RemoveBackground" method="post" data-no-delete-confirm="true">
|
||||||
|
<input type="hidden" name="floorPlanId" value="@Model.FloorPlan.FloorPlanID" />
|
||||||
|
<input type="hidden" name="floorPlanFloorId" value="@floor.FloorPlanFloorID" />
|
||||||
|
<button class="btn btn-danger" type="submit">Remove background</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@foreach (var block in Model.Blocks)
|
@foreach (var block in Model.Blocks)
|
||||||
{
|
{
|
||||||
<div class="modal fade" id="delete-block-@block.FloorPlanBlockID" tabindex="-1" aria-labelledby="delete-block-title-@block.FloorPlanBlockID" aria-hidden="true">
|
<div class="modal fade" id="delete-block-@block.FloorPlanBlockID" tabindex="-1" aria-labelledby="delete-block-title-@block.FloorPlanBlockID" aria-hidden="true">
|
||||||
@ -402,6 +563,7 @@ else
|
|||||||
const tabs = [...editor.querySelectorAll("[data-floor-tab]")];
|
const tabs = [...editor.querySelectorAll("[data-floor-tab]")];
|
||||||
const panels = [...editor.querySelectorAll("[data-floor-panel]")];
|
const panels = [...editor.querySelectorAll("[data-floor-panel]")];
|
||||||
const floorEditors = [...editor.querySelectorAll("[data-floor-editor]")];
|
const floorEditors = [...editor.querySelectorAll("[data-floor-editor]")];
|
||||||
|
const backgroundEditors = [...editor.querySelectorAll("[data-background-editor]")];
|
||||||
const blocks = [...editor.querySelectorAll("[data-block]")];
|
const blocks = [...editor.querySelectorAll("[data-block]")];
|
||||||
const noSelection = editor.querySelector("[data-no-selection]");
|
const noSelection = editor.querySelector("[data-no-selection]");
|
||||||
const warning = editor.querySelector("[data-overlap-warning]");
|
const warning = editor.querySelector("[data-overlap-warning]");
|
||||||
@ -465,6 +627,7 @@ else
|
|||||||
let saveTimer = null;
|
let saveTimer = null;
|
||||||
let saveInFlight = false;
|
let saveInFlight = false;
|
||||||
let saveAgain = false;
|
let saveAgain = false;
|
||||||
|
const backgroundTimers = new Map();
|
||||||
|
|
||||||
function setToolboxTab(tabName) {
|
function setToolboxTab(tabName) {
|
||||||
toolboxTabs.forEach(tab => {
|
toolboxTabs.forEach(tab => {
|
||||||
@ -533,6 +696,11 @@ else
|
|||||||
tabs.forEach(tab => tab.classList.toggle("active", tab.dataset.floorTab === floorId));
|
tabs.forEach(tab => tab.classList.toggle("active", tab.dataset.floorTab === floorId));
|
||||||
panels.forEach(panel => panel.classList.toggle("active", panel.dataset.floorPanel === floorId));
|
panels.forEach(panel => panel.classList.toggle("active", panel.dataset.floorPanel === floorId));
|
||||||
floorEditors.forEach(panel => panel.classList.toggle("d-none", panel.dataset.floorEditor !== floorId));
|
floorEditors.forEach(panel => panel.classList.toggle("d-none", panel.dataset.floorEditor !== floorId));
|
||||||
|
backgroundEditors.forEach(panel => {
|
||||||
|
const active = panel.dataset.backgroundEditor === floorId;
|
||||||
|
panel.classList.toggle("d-none", !active);
|
||||||
|
panel.hidden = !active;
|
||||||
|
});
|
||||||
const activeTab = tabs.find(tab => tab.dataset.floorTab === floorId);
|
const activeTab = tabs.find(tab => tab.dataset.floorTab === floorId);
|
||||||
if (addBlockFloor) addBlockFloor.value = floorId;
|
if (addBlockFloor) addBlockFloor.value = floorId;
|
||||||
if (activeTab) {
|
if (activeTab) {
|
||||||
@ -542,6 +710,68 @@ else
|
|||||||
checkOverlaps();
|
checkOverlaps();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function backgroundFields(floorId) {
|
||||||
|
const panel = editor.querySelector(`[data-background-editor="${floorId}"]`);
|
||||||
|
return {
|
||||||
|
form: document.querySelector(`[data-background-settings-form="${floorId}"]`),
|
||||||
|
image: editor.querySelector(`[data-background-image="${floorId}"]`),
|
||||||
|
opacity: panel?.querySelector("[data-background-field='opacity']"),
|
||||||
|
scale: panel?.querySelector("[data-background-field='scale']"),
|
||||||
|
offsetX: panel?.querySelector("[data-background-field='offsetX']"),
|
||||||
|
offsetY: panel?.querySelector("[data-background-field='offsetY']"),
|
||||||
|
locked: panel?.querySelector("[data-background-field='locked']"),
|
||||||
|
opacityValue: editor.querySelector(`[data-background-opacity-value="${floorId}"]`)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBackgroundPreview(floorId) {
|
||||||
|
const fields = backgroundFields(floorId);
|
||||||
|
if (!fields.image) return;
|
||||||
|
const opacity = Number(fields.opacity?.value || 0.35);
|
||||||
|
const scale = Number(fields.scale?.value || 1);
|
||||||
|
const offsetX = Number(fields.offsetX?.value || 0);
|
||||||
|
const offsetY = Number(fields.offsetY?.value || 0);
|
||||||
|
fields.image.style.setProperty("--bg-opacity", Math.max(0.05, Math.min(opacity, 1)));
|
||||||
|
fields.image.style.setProperty("--bg-scale", Math.max(0.1, Math.min(scale, 5)));
|
||||||
|
fields.image.style.setProperty("--bg-offset-x", `${Math.max(-10000, Math.min(offsetX, 10000))}px`);
|
||||||
|
fields.image.style.setProperty("--bg-offset-y", `${Math.max(-10000, Math.min(offsetY, 10000))}px`);
|
||||||
|
fields.image.classList.toggle("is-locked", fields.locked?.checked !== false);
|
||||||
|
if (fields.opacityValue) fields.opacityValue.textContent = fields.opacity?.value || "0.35";
|
||||||
|
}
|
||||||
|
|
||||||
|
function markBackgroundDirty(floorId) {
|
||||||
|
updateBackgroundPreview(floorId);
|
||||||
|
setSaveStatus("dirty");
|
||||||
|
window.clearTimeout(backgroundTimers.get(floorId));
|
||||||
|
backgroundTimers.set(floorId, window.setTimeout(() => saveBackgroundSettingsAsync(floorId), 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBackgroundSettingsAsync(floorId) {
|
||||||
|
const fields = backgroundFields(floorId);
|
||||||
|
if (!fields.form) return;
|
||||||
|
window.clearTimeout(backgroundTimers.get(floorId));
|
||||||
|
setSaveStatus("saving");
|
||||||
|
try {
|
||||||
|
const response = await fetch(fields.form.action, {
|
||||||
|
method: "POST",
|
||||||
|
body: new FormData(fields.form),
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"X-Requested-With": "XMLHttpRequest"
|
||||||
|
},
|
||||||
|
credentials: "same-origin"
|
||||||
|
});
|
||||||
|
const result = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok || result.ok === false) {
|
||||||
|
throw new Error(result.message || "Unable to save background settings.");
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaveStatus("saved");
|
||||||
|
} catch (error) {
|
||||||
|
setSaveStatus("failed", error.message || "Save failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function filterAddBlockLocations(floorLocationId) {
|
function filterAddBlockLocations(floorLocationId) {
|
||||||
if (!addBlockLocation) return;
|
if (!addBlockLocation) return;
|
||||||
let firstValue = "";
|
let firstValue = "";
|
||||||
@ -753,11 +983,17 @@ else
|
|||||||
|
|
||||||
editor.querySelectorAll("input, select, textarea").forEach(input => {
|
editor.querySelectorAll("input, select, textarea").forEach(input => {
|
||||||
if (input.matches("[form='floor-plan-add-block-form'], [form='floor-plan-add-floor-form']")) return;
|
if (input.matches("[form='floor-plan-add-block-form'], [form='floor-plan-add-floor-form']")) return;
|
||||||
|
if (input.matches("[data-background-field]")) return;
|
||||||
if (input.matches("[data-block-field='x'],[data-block-field='y'],[data-block-field='w'],[data-block-field='h'],[data-block-field='type']")) return;
|
if (input.matches("[data-block-field='x'],[data-block-field='y'],[data-block-field='w'],[data-block-field='h'],[data-block-field='type']")) return;
|
||||||
const eventName = input.tagName === "TEXTAREA" || input.type === "text" ? "input" : "change";
|
const eventName = input.tagName === "TEXTAREA" || input.type === "text" ? "input" : "change";
|
||||||
input.addEventListener(eventName, markDirty);
|
input.addEventListener(eventName, markDirty);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
editor.querySelectorAll("[data-background-field]").forEach(input => {
|
||||||
|
const eventName = input.type === "checkbox" ? "change" : "input";
|
||||||
|
input.addEventListener(eventName, () => markBackgroundDirty(input.dataset.backgroundFloor));
|
||||||
|
});
|
||||||
|
|
||||||
sizePreset?.addEventListener("change", applySizePreset);
|
sizePreset?.addEventListener("change", applySizePreset);
|
||||||
addBlockType?.addEventListener("change", updateSizePresets);
|
addBlockType?.addEventListener("change", updateSizePresets);
|
||||||
updateSizePresets();
|
updateSizePresets();
|
||||||
|
|||||||
@ -4581,11 +4581,11 @@ body.dragging-location [data-drag-type="location"] {
|
|||||||
|
|
||||||
.floor-plan-grid {
|
.floor-plan-grid {
|
||||||
--cell-size: 34px;
|
--cell-size: 34px;
|
||||||
background-color: var(--panel-bg, #fff);
|
background:
|
||||||
background-image:
|
|
||||||
linear-gradient(to right, rgba(93, 111, 101, .18) 1px, transparent 1px),
|
linear-gradient(to right, rgba(93, 111, 101, .18) 1px, transparent 1px),
|
||||||
linear-gradient(to bottom, rgba(93, 111, 101, .18) 1px, transparent 1px);
|
linear-gradient(to bottom, rgba(93, 111, 101, .18) 1px, transparent 1px),
|
||||||
background-size: var(--cell-size) var(--cell-size);
|
var(--panel-bg, #fff);
|
||||||
|
background-size: var(--cell-size) var(--cell-size), var(--cell-size) var(--cell-size), auto;
|
||||||
border: 1px solid var(--border-color, #d8ded9);
|
border: 1px solid var(--border-color, #d8ded9);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
height: calc(var(--grid-height) * var(--cell-size));
|
height: calc(var(--grid-height) * var(--cell-size));
|
||||||
@ -4594,6 +4594,25 @@ body.dragging-location [data-drag-type="location"] {
|
|||||||
width: calc(var(--grid-width) * var(--cell-size));
|
width: calc(var(--grid-width) * var(--cell-size));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.floor-plan-background-image {
|
||||||
|
height: auto;
|
||||||
|
left: 0;
|
||||||
|
max-width: none;
|
||||||
|
opacity: var(--bg-opacity, .35);
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
transform: translate(var(--bg-offset-x, 0), var(--bg-offset-y, 0)) scale(var(--bg-scale, 1));
|
||||||
|
transform-origin: top left;
|
||||||
|
user-select: none;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floor-plan-background-image.is-locked {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.floor-plan-block {
|
.floor-plan-block {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border: 2px solid rgba(44, 84, 75, .55);
|
border: 2px solid rgba(44, 84, 75, .55);
|
||||||
@ -4614,6 +4633,7 @@ body.dragging-location [data-drag-type="location"] {
|
|||||||
top: calc(var(--y) * var(--cell-size));
|
top: calc(var(--y) * var(--cell-size));
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
width: calc(var(--w) * var(--cell-size));
|
width: calc(var(--w) * var(--cell-size));
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.floor-plan-block-label {
|
.floor-plan-block-label {
|
||||||
@ -4706,7 +4726,7 @@ body.dragging-location [data-drag-type="location"] {
|
|||||||
|
|
||||||
.floor-plan-toolbox-tabs {
|
.floor-plan-toolbox-tabs {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
border-bottom: 1px solid var(--border-color, #d8ded9);
|
border-bottom: 1px solid var(--border-color, #d8ded9);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4767,6 +4787,35 @@ body.dragging-location [data-drag-type="location"] {
|
|||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.floor-plan-background-file {
|
||||||
|
border: 1px solid rgba(47, 111, 99, .16);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: .86rem;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floor-plan-background-range {
|
||||||
|
align-items: center;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floor-plan-background-range span {
|
||||||
|
color: var(--muted-text, #5d6f65);
|
||||||
|
font-size: .8rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floor-plan-checkbox {
|
||||||
|
align-items: center;
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 6px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.floor-plan-choice-row {
|
.floor-plan-choice-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@ -4791,10 +4840,11 @@ body.dragging-location [data-drag-type="location"] {
|
|||||||
|
|
||||||
[data-bs-theme="dark"] .floor-plan-grid,
|
[data-bs-theme="dark"] .floor-plan-grid,
|
||||||
.dark .floor-plan-grid {
|
.dark .floor-plan-grid {
|
||||||
background-color: #15201d;
|
background:
|
||||||
background-image:
|
|
||||||
linear-gradient(to right, rgba(214, 224, 218, .12) 1px, transparent 1px),
|
linear-gradient(to right, rgba(214, 224, 218, .12) 1px, transparent 1px),
|
||||||
linear-gradient(to bottom, rgba(214, 224, 218, .12) 1px, transparent 1px);
|
linear-gradient(to bottom, rgba(214, 224, 218, .12) 1px, transparent 1px),
|
||||||
|
#15201d;
|
||||||
|
background-size: var(--cell-size) var(--cell-size), var(--cell-size) var(--cell-size), auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-bs-theme="dark"] .floor-plan-editor-toolbar,
|
[data-bs-theme="dark"] .floor-plan-editor-toolbar,
|
||||||
|
|||||||
2
PlotLine/wwwroot/css/site.min.css
vendored
2
PlotLine/wwwroot/css/site.min.css
vendored
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user