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 });
|
||||
}
|
||||
|
||||
[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]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> AddBlock(int floorPlanId, FloorPlanBlockEditViewModel model)
|
||||
|
||||
@ -223,6 +223,7 @@ public interface IFloorPlanRepository
|
||||
Task<int> SavePlanAsync(FloorPlan floorPlan);
|
||||
Task DeletePlanAsync(int floorPlanId);
|
||||
Task<int> SaveFloorAsync(FloorPlanFloor floor);
|
||||
Task<bool> UpdateFloorBackgroundAsync(FloorPlanFloor floor);
|
||||
Task DeleteFloorAsync(int floorPlanFloorId);
|
||||
Task<int> SaveBlockAsync(FloorPlanBlock block);
|
||||
Task DeleteBlockAsync(int floorPlanBlockId);
|
||||
@ -591,6 +592,27 @@ public sealed class FloorPlanRepository(ISqlConnectionFactory connectionFactory)
|
||||
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)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
|
||||
@ -1164,6 +1164,13 @@ public sealed class FloorPlanFloor
|
||||
public int SortOrder { get; set; }
|
||||
public int GridWidth { get; set; } = 24;
|
||||
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? ModifiedDate { get; set; }
|
||||
}
|
||||
|
||||
@ -132,6 +132,7 @@ public class Program
|
||||
builder.Services.AddScoped<IProjectCollaborationService, ProjectCollaborationService>();
|
||||
builder.Services.AddScoped<IProjectActivityService, ProjectActivityService>();
|
||||
builder.Services.AddScoped<IBookCoverService, BookCoverService>();
|
||||
builder.Services.AddScoped<IFloorPlanBackgroundImageService, FloorPlanBackgroundImageService>();
|
||||
builder.Services.AddScoped<IBookService, BookService>();
|
||||
builder.Services.AddScoped<IChapterService, ChapterService>();
|
||||
builder.Services.AddScoped<ISceneService, SceneService>();
|
||||
|
||||
@ -187,6 +187,10 @@ public interface IFloorPlanService
|
||||
Task DeleteFloorAsync(int floorPlanFloorId);
|
||||
Task<int> SaveBlockAsync(FloorPlanBlockEditViewModel 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);
|
||||
}
|
||||
|
||||
@ -7526,6 +7530,7 @@ public sealed class FloorPlanService(
|
||||
IProjectRepository projects,
|
||||
IFloorPlanRepository floorPlans,
|
||||
ILocationRepository locations,
|
||||
IFloorPlanBackgroundImageService backgroundImages,
|
||||
IProjectActivityService activity) : IFloorPlanService
|
||||
{
|
||||
public async Task<FloorPlanListViewModel?> ListAsync(int projectId)
|
||||
@ -7706,6 +7711,85 @@ public sealed class FloorPlanService(
|
||||
|
||||
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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
if (model.LocationID.HasValue && model.LocationID.Value > 0)
|
||||
@ -7877,7 +7979,14 @@ public sealed class FloorPlanService(
|
||||
Name = floor.Name,
|
||||
SortOrder = floor.SortOrder,
|
||||
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()
|
||||
|
||||
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)]
|
||||
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
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
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;
|
||||
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">
|
||||
@ -134,6 +135,14 @@ else
|
||||
data-floor-grid="@floor.FloorPlanFloorID"
|
||||
data-grid-width="@floor.GridWidth"
|
||||
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)
|
||||
{
|
||||
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">
|
||||
<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-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>
|
||||
</div>
|
||||
<div class="floor-plan-toolbox-body">
|
||||
@ -266,6 +276,113 @@ else
|
||||
}
|
||||
</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>
|
||||
<h2>Floors</h2>
|
||||
<div class="floor-plan-tool-subsection">
|
||||
@ -338,6 +455,24 @@ else
|
||||
|
||||
<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)
|
||||
{
|
||||
var placedBlockCount = Model.Blocks.Count(x => x.FloorPlanFloorID == floor.FloorPlanFloorID);
|
||||
@ -365,6 +500,32 @@ else
|
||||
</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)
|
||||
{
|
||||
<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 panels = [...editor.querySelectorAll("[data-floor-panel]")];
|
||||
const floorEditors = [...editor.querySelectorAll("[data-floor-editor]")];
|
||||
const backgroundEditors = [...editor.querySelectorAll("[data-background-editor]")];
|
||||
const blocks = [...editor.querySelectorAll("[data-block]")];
|
||||
const noSelection = editor.querySelector("[data-no-selection]");
|
||||
const warning = editor.querySelector("[data-overlap-warning]");
|
||||
@ -465,6 +627,7 @@ else
|
||||
let saveTimer = null;
|
||||
let saveInFlight = false;
|
||||
let saveAgain = false;
|
||||
const backgroundTimers = new Map();
|
||||
|
||||
function setToolboxTab(tabName) {
|
||||
toolboxTabs.forEach(tab => {
|
||||
@ -533,6 +696,11 @@ else
|
||||
tabs.forEach(tab => tab.classList.toggle("active", tab.dataset.floorTab === floorId));
|
||||
panels.forEach(panel => panel.classList.toggle("active", panel.dataset.floorPanel === 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);
|
||||
if (addBlockFloor) addBlockFloor.value = floorId;
|
||||
if (activeTab) {
|
||||
@ -542,6 +710,68 @@ else
|
||||
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) {
|
||||
if (!addBlockLocation) return;
|
||||
let firstValue = "";
|
||||
@ -753,11 +983,17 @@ else
|
||||
|
||||
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("[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;
|
||||
const eventName = input.tagName === "TEXTAREA" || input.type === "text" ? "input" : "change";
|
||||
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);
|
||||
addBlockType?.addEventListener("change", updateSizePresets);
|
||||
updateSizePresets();
|
||||
|
||||
@ -4581,11 +4581,11 @@ body.dragging-location [data-drag-type="location"] {
|
||||
|
||||
.floor-plan-grid {
|
||||
--cell-size: 34px;
|
||||
background-color: var(--panel-bg, #fff);
|
||||
background-image:
|
||||
background:
|
||||
linear-gradient(to right, 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);
|
||||
linear-gradient(to bottom, rgba(93, 111, 101, .18) 1px, transparent 1px),
|
||||
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-radius: 8px;
|
||||
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));
|
||||
}
|
||||
|
||||
.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 {
|
||||
align-items: center;
|
||||
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));
|
||||
touch-action: none;
|
||||
width: calc(var(--w) * var(--cell-size));
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.floor-plan-block-label {
|
||||
@ -4706,7 +4726,7 @@ body.dragging-location [data-drag-type="location"] {
|
||||
|
||||
.floor-plan-toolbox-tabs {
|
||||
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);
|
||||
}
|
||||
|
||||
@ -4767,6 +4787,35 @@ body.dragging-location [data-drag-type="location"] {
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
@ -4791,10 +4840,11 @@ body.dragging-location [data-drag-type="location"] {
|
||||
|
||||
[data-bs-theme="dark"] .floor-plan-grid,
|
||||
.dark .floor-plan-grid {
|
||||
background-color: #15201d;
|
||||
background-image:
|
||||
background:
|
||||
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,
|
||||
|
||||
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