Implemented the Floor Plans background-image phase.

This commit is contained in:
Nick Beckley 2026-06-20 21:06:53 +01:00
parent b2ac7b5eeb
commit e455018d73
11 changed files with 843 additions and 10 deletions

View File

@ -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)

View File

@ -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();

View File

@ -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; }
}

View File

@ -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>();

View File

@ -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()

View 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");
}
}

View 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

View File

@ -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

View File

@ -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();

View File

@ -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,

File diff suppressed because one or more lines are too long