1014 lines
60 KiB
Plaintext

@model FloorPlanEditorViewModel
@{
ViewData["Title"] = Model.FloorPlan.FloorPlanID == 0 ? "New Floor Plan" : Model.FloorPlan.Name;
ViewData["ProjectSection"] = "Floor Plans";
var activeFloor = Model.Floors.OrderBy(x => x.SortOrder).ThenBy(x => x.Name).FirstOrDefault();
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">
<a asp-controller="Projects" asp-action="Index">Projects</a>
<a asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">@Model.Project.ProjectName</a>
<a asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Floor Plans</a>
<span>@Model.FloorPlan.Name</span>
</nav>
<partial name="_ProjectSectionNav" model="Model.Project" />
<div class="page-heading compact">
<div>
<p class="eyebrow">Location floor plan</p>
<h1>@Model.FloorPlan.Name</h1>
</div>
<a class="btn btn-outline-secondary" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Back to floor plans</a>
</div>
@if (TempData["FloorPlanMessage"] is string message)
{
<div class="alert alert-success">@message</div>
}
@if (TempData["FloorPlanError"] is string error)
{
<div class="alert alert-danger">@error</div>
}
<section class="edit-panel floor-plan-meta floor-plan-meta-wide">
<form asp-action="SavePlan" method="post" class="row g-3">
<input asp-for="FloorPlan.FloorPlanID" type="hidden" name="FloorPlanID" />
<input asp-for="FloorPlan.ProjectID" type="hidden" name="ProjectID" />
<div class="col-md-4">
<label asp-for="FloorPlan.Name" class="form-label"></label>
<input asp-for="FloorPlan.Name" class="form-control" name="Name" />
<span asp-validation-for="FloorPlan.Name" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="FloorPlan.Description" class="form-label"></label>
<input asp-for="FloorPlan.Description" class="form-control" name="Description" />
</div>
<div class="col-md-2">
<label class="form-label">Root Location</label>
<select asp-for="FloorPlan.LocationID" asp-items="Model.LocationOptions" class="form-select" name="LocationID"></select>
</div>
<div class="col-md-2 d-flex align-items-end">
<button class="btn btn-outline-primary w-100" type="submit">Save details</button>
</div>
<div class="col-12">
<p class="muted mb-0">Linked Location: @(string.IsNullOrWhiteSpace(Model.FloorPlan.LocationPath) ? "A matching Location will be created or linked on save." : Model.FloorPlan.LocationPath)</p>
</div>
</form>
</section>
@if (!Model.Floors.Any())
{
<section class="empty-panel">
<h2>No floors yet</h2>
<p>Add a floor to start arranging linked locations.</p>
<div class="floor-plan-tool-card mt-3">
<h3>Add floor</h3>
<input asp-for="NewFloor.FloorPlanID" type="hidden" name="FloorPlanID" form="floor-plan-add-floor-form" />
<input asp-for="NewFloor.SortOrder" type="hidden" name="SortOrder" form="floor-plan-add-floor-form" />
<div class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label">New floor</label>
<input asp-for="NewFloor.Name" class="form-control form-control-sm" name="Name" form="floor-plan-add-floor-form" />
</div>
<div class="col-md-2">
<label class="form-label">Width</label>
<input asp-for="NewFloor.GridWidth" class="form-control form-control-sm" name="GridWidth" min="8" max="80" form="floor-plan-add-floor-form" />
</div>
<div class="col-md-2">
<label class="form-label">Height</label>
<input asp-for="NewFloor.GridHeight" class="form-control form-control-sm" name="GridHeight" min="8" max="80" form="floor-plan-add-floor-form" />
</div>
<div class="col-md-2">
<button class="btn btn-outline-primary btn-sm w-100" type="submit" form="floor-plan-add-floor-form">Add floor</button>
</div>
</div>
</div>
</section>
<form id="floor-plan-add-floor-form" asp-action="AddFloor" method="post"></form>
}
else
{
<form asp-action="SaveLayout" method="post" class="floor-plan-editor" data-floor-plan-editor>
<input asp-for="FloorPlan.FloorPlanID" type="hidden" />
<input type="hidden" name="floorPlanId" value="@Model.FloorPlan.FloorPlanID" />
<input asp-for="FloorPlan.ProjectID" type="hidden" />
<input asp-for="FloorPlan.LocationID" type="hidden" />
<input asp-for="FloorPlan.Name" type="hidden" />
<input asp-for="FloorPlan.Description" type="hidden" />
<section class="floor-plan-editor-toolbar">
<div>
<span class="muted">Active floor</span>
<strong data-current-floor-name>@activeFloor?.Name</strong>
</div>
<div class="floor-plan-tabs" role="tablist" aria-label="Floors">
@foreach (var floor in Model.Floors.OrderBy(x => x.SortOrder).ThenBy(x => x.Name))
{
var isActive = floor.FloorPlanFloorID == activeFloor?.FloorPlanFloorID;
<button type="button" class="floor-plan-tab @(isActive ? "active" : string.Empty)" data-floor-tab="@floor.FloorPlanFloorID" data-floor-name="@floor.Name" data-floor-location-id="@floor.LocationID">@floor.Name</button>
}
</div>
<div class="button-row">
<span class="floor-plan-save-status" data-save-status>Saved</span>
<a class="btn btn-outline-secondary btn-sm" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Back to floor plans</a>
<button class="btn btn-primary btn-sm" type="submit" data-save-layout-button>Save layout</button>
</div>
</section>
<div class="floor-plan-layout">
<section class="floor-plan-stage">
@for (var i = 0; i < Model.Floors.Count; i++)
{
var floor = Model.Floors[i];
var isActive = floor.FloorPlanFloorID == activeFloor?.FloorPlanFloorID;
var floorBlocks = blocksByFloor.GetValueOrDefault(floor.FloorPlanFloorID) ?? [];
<div class="floor-plan-floor @(isActive ? "active" : string.Empty)" data-floor-panel="@floor.FloorPlanFloorID">
<div class="floor-plan-grid-wrap">
<div class="floor-plan-grid"
style="--grid-width:@floor.GridWidth;--grid-height:@floor.GridHeight;"
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);
<button type="button"
class="@BlockClass(block.BlockType)"
style="--x:@block.X;--y:@block.Y;--w:@block.WidthCells;--h:@block.HeightCells;"
data-block="@block.FloorPlanBlockID"
data-block-index="@blockIndex"
data-floor-id="@block.FloorPlanFloorID"
title="@block.LocationPath">
<span class="floor-plan-block-label">@block.DisplayLabel</span>
<span class="floor-plan-resize-handle" data-resize-handle aria-hidden="true"></span>
</button>
}
</div>
</div>
</div>
}
</section>
<aside class="floor-plan-side-panel">
<div class="floor-plan-toolbox" data-toolbox>
<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">
<section class="floor-plan-tool-card active" id="floor-plan-tool-add" role="tabpanel" aria-labelledby="floor-plan-tool-tab-add" data-toolbox-panel="add">
<h2>Add block</h2>
<p class="muted">Adds a block to <span data-current-floor-name>@activeFloor?.Name</span>. Use a child Location or create a new room under this floor.</p>
<input type="hidden" name="FloorPlanBlockID" value="0" form="floor-plan-add-block-form" />
<input type="hidden" name="FloorPlanFloorID" value="@activeFloor?.FloorPlanFloorID" data-add-block-floor-id form="floor-plan-add-block-form" />
<input type="hidden" name="X" value="0" form="floor-plan-add-block-form" />
<input type="hidden" name="Y" value="0" form="floor-plan-add-block-form" />
<input type="hidden" name="WidthCells" value="1" data-add-block-width form="floor-plan-add-block-form" />
<input type="hidden" name="HeightCells" value="1" data-add-block-height form="floor-plan-add-block-form" />
<div class="floor-plan-choice-row" role="group" aria-label="Add block location mode">
<label><input type="radio" name="AddBlockLocationMode" value="Existing" checked form="floor-plan-add-block-form" data-add-block-mode /> Use existing location</label>
<label><input type="radio" name="AddBlockLocationMode" value="Create" form="floor-plan-add-block-form" data-add-block-mode /> Create new room/location</label>
</div>
<div class="mb-2" data-existing-location-panel>
<label class="form-label" for="add-block-location">Existing child Location</label>
<select id="add-block-location" class="form-select form-select-sm" name="LocationID" form="floor-plan-add-block-form" data-add-block-location>
<option value="">Choose a child Location</option>
@foreach (var location in Model.Locations)
{
<option value="@location.LocationID" data-parent-location-id="@location.ParentLocationID">@location.LocationPath</option>
}
</select>
</div>
<div class="mb-2 d-none" data-new-location-panel>
<label class="form-label" for="add-block-new-location">New room/location name</label>
<input id="add-block-new-location" class="form-control form-control-sm" name="NewLocationName" form="floor-plan-add-block-form" />
</div>
<div class="mb-2">
<label class="form-label" for="add-block-type">Block type</label>
<select id="add-block-type" class="form-select form-select-sm" name="BlockType" asp-items="Model.BlockTypeOptions" form="floor-plan-add-block-form"></select>
</div>
<div class="mb-2">
<label class="form-label" for="add-block-size-preset">Size preset</label>
<select id="add-block-size-preset" class="form-select form-select-sm" data-size-preset>
<option value="1x1">Small Room 1 x 1</option>
<option value="2x1" selected>Medium Room 2 x 1</option>
<option value="2x2">Large Room 2 x 2</option>
<option value="1x3">Corridor 1 x 3</option>
<option value="3x2">Wide Hall 3 x 2</option>
</select>
</div>
<div class="mb-3">
<label class="form-label" for="add-block-label">Optional label override</label>
<input id="add-block-label" class="form-control form-control-sm" name="LabelOverride" form="floor-plan-add-block-form" />
</div>
<button class="btn btn-primary btn-sm w-100" type="submit" form="floor-plan-add-block-form">Add block</button>
</section>
<section class="floor-plan-tool-card" id="floor-plan-tool-selected" role="tabpanel" aria-labelledby="floor-plan-tool-tab-selected" data-toolbox-panel="selected" hidden>
<h2>Selected block</h2>
<p class="muted" data-no-selection>Select a block on the grid to edit its details.</p>
<div class="floor-plan-overlap-warning d-none" data-overlap-warning>Some blocks overlap on this floor.</div>
@for (var i = 0; i < Model.Blocks.Count; i++)
{
<div class="floor-plan-block-editor d-none" data-block-editor="@Model.Blocks[i].FloorPlanBlockID">
<input asp-for="Blocks[i].FloorPlanBlockID" type="hidden" />
<input asp-for="Blocks[i].FloorPlanFloorID" type="hidden" data-block-field="floor" />
<input asp-for="Blocks[i].LocationName" type="hidden" />
<input asp-for="Blocks[i].LocationPath" type="hidden" />
<div class="mb-2">
<label class="form-label">Location</label>
<select asp-for="Blocks[i].LocationID" asp-items="Model.LocationOptions" class="form-select form-select-sm"></select>
</div>
<div class="mb-2">
<label class="form-label">Type</label>
<select asp-for="Blocks[i].BlockType" asp-items="Model.BlockTypeOptions" class="form-select form-select-sm" data-block-field="type"></select>
</div>
<div class="row g-2">
<div class="col-6">
<label class="form-label">X</label>
<input asp-for="Blocks[i].X" class="form-control form-control-sm" min="0" max="79" data-block-field="x" />
</div>
<div class="col-6">
<label class="form-label">Y</label>
<input asp-for="Blocks[i].Y" class="form-control form-control-sm" min="0" max="79" data-block-field="y" />
</div>
<div class="col-6">
<label class="form-label">Width</label>
<input asp-for="Blocks[i].WidthCells" class="form-control form-control-sm" min="1" max="80" data-block-field="w" />
</div>
<div class="col-6">
<label class="form-label">Height</label>
<input asp-for="Blocks[i].HeightCells" class="form-control form-control-sm" min="1" max="80" data-block-field="h" />
</div>
</div>
<div class="mt-2">
<label class="form-label">Label override</label>
<input asp-for="Blocks[i].LabelOverride" class="form-control form-control-sm" />
</div>
<div class="mt-2">
<label class="form-label">Notes</label>
<textarea asp-for="Blocks[i].Notes" class="form-control form-control-sm" rows="3"></textarea>
</div>
<div class="button-row mt-3">
<button class="btn btn-outline-danger btn-sm"
type="button"
data-bs-toggle="modal"
data-bs-target="#delete-block-@Model.Blocks[i].FloorPlanBlockID">Delete block</button>
</div>
</div>
}
</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">
<h3>Add floor</h3>
<input asp-for="NewFloor.FloorPlanID" type="hidden" name="FloorPlanID" form="floor-plan-add-floor-form" />
<input asp-for="NewFloor.SortOrder" type="hidden" name="SortOrder" form="floor-plan-add-floor-form" />
<div class="mb-2">
<label class="form-label">New floor</label>
<input asp-for="NewFloor.Name" class="form-control form-control-sm" name="Name" form="floor-plan-add-floor-form" />
</div>
<div class="row g-2">
<div class="col-6">
<label class="form-label">Width</label>
<input asp-for="NewFloor.GridWidth" class="form-control form-control-sm" name="GridWidth" min="8" max="80" form="floor-plan-add-floor-form" />
</div>
<div class="col-6">
<label class="form-label">Height</label>
<input asp-for="NewFloor.GridHeight" class="form-control form-control-sm" name="GridHeight" min="8" max="80" form="floor-plan-add-floor-form" />
</div>
</div>
<button class="btn btn-outline-primary btn-sm w-100 mt-2" type="submit" form="floor-plan-add-floor-form">Add floor</button>
</div>
<div class="floor-plan-tool-subsection">
<h3>Current floor</h3>
@for (var i = 0; i < Model.Floors.Count; i++)
{
var floor = Model.Floors[i];
var isActive = floor.FloorPlanFloorID == activeFloor?.FloorPlanFloorID;
<div class="floor-plan-floor-editor @(isActive ? string.Empty : "d-none")" data-floor-editor="@floor.FloorPlanFloorID">
<input asp-for="Floors[i].FloorPlanFloorID" type="hidden" />
<input asp-for="Floors[i].FloorPlanID" type="hidden" />
<input asp-for="Floors[i].SortOrder" type="hidden" />
<input asp-for="Floors[i].LocationID" type="hidden" />
<div class="mb-2">
<label class="form-label">Floor name</label>
<input asp-for="Floors[i].Name" class="form-control form-control-sm" />
</div>
<p class="muted">Location: @(string.IsNullOrWhiteSpace(floor.LocationPath) ? "A child Location will be created or linked on save." : floor.LocationPath)</p>
<div class="row g-2">
<div class="col-6">
<label class="form-label">Grid width</label>
<input asp-for="Floors[i].GridWidth" class="form-control form-control-sm" min="8" max="80" data-floor-width="@floor.FloorPlanFloorID" />
</div>
<div class="col-6">
<label class="form-label">Grid height</label>
<input asp-for="Floors[i].GridHeight" class="form-control form-control-sm" min="8" max="80" data-floor-height="@floor.FloorPlanFloorID" />
</div>
</div>
<button class="btn btn-outline-danger btn-sm w-100 mt-2"
type="button"
data-bs-toggle="modal"
data-bs-target="#delete-floor-@floor.FloorPlanFloorID">Delete floor</button>
</div>
}
</div>
</section>
</div>
</div>
</aside>
</div>
<div class="button-row mt-3">
<button class="btn btn-primary" type="submit" data-save-layout-button>Save layout</button>
</div>
</form>
<form id="floor-plan-add-block-form" asp-action="AddBlock" method="post">
<input type="hidden" name="floorPlanId" value="@Model.FloorPlan.FloorPlanID" />
</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)
{
var placedBlockCount = Model.Blocks.Count(x => x.FloorPlanFloorID == floor.FloorPlanFloorID);
<div class="modal fade" id="delete-floor-@floor.FloorPlanFloorID" tabindex="-1" aria-labelledby="delete-floor-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="delete-floor-title-@floor.FloorPlanFloorID">Delete floor?</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Delete <strong>@floor.Name</strong>?</p>
<p>This deletes @placedBlockCount placed block(s) on this floor. 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="DeleteFloor" 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">Delete floor</button>
</form>
</div>
</div>
</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)
{
<div class="modal fade" id="delete-block-@block.FloorPlanBlockID" tabindex="-1" aria-labelledby="delete-block-title-@block.FloorPlanBlockID" 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="delete-block-title-@block.FloorPlanBlockID">Delete placed block?</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Remove the placed block for <strong>@block.DisplayLabel</strong>?</p>
<p>Only this block is removed from the floor plan. The linked Location 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="DeleteBlock" method="post" data-no-delete-confirm="true">
<input type="hidden" name="floorPlanId" value="@Model.FloorPlan.FloorPlanID" />
<input type="hidden" name="floorPlanBlockId" value="@block.FloorPlanBlockID" />
<button class="btn btn-danger" type="submit">Delete block</button>
</form>
</div>
</div>
</div>
</div>
}
}
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<script>
(() => {
const editor = document.querySelector("[data-floor-plan-editor]");
if (!editor) return;
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]");
const addBlockFloor = document.querySelector("[data-add-block-floor-id]");
const addBlockFloorNames = [...document.querySelectorAll("[data-current-floor-name]")];
const addBlockLocation = document.querySelector("[data-add-block-location]");
const addBlockModes = [...document.querySelectorAll("[data-add-block-mode]")];
const addBlockType = document.querySelector("#add-block-type");
const existingLocationPanel = document.querySelector("[data-existing-location-panel]");
const newLocationPanel = document.querySelector("[data-new-location-panel]");
const sizePreset = document.querySelector("[data-size-preset]");
const addBlockWidth = document.querySelector("[data-add-block-width]");
const addBlockHeight = document.querySelector("[data-add-block-height]");
const toolboxTabs = [...editor.querySelectorAll("[data-toolbox-tab]")];
const toolboxPanels = [...editor.querySelectorAll("[data-toolbox-panel]")];
const saveStatus = editor.querySelector("[data-save-status]");
const saveButtons = [...editor.querySelectorAll("[data-save-layout-button]")];
const initialSelectedBlockId = "@selectedBlockId";
const saveStatusText = {
saved: "Saved",
dirty: "Unsaved changes",
saving: "Saving...",
failed: "Save failed"
};
const sizePresets = {
Room: [
["1x1", "Small Room 1 x 1"],
["2x1", "Medium Room 2 x 1"],
["2x2", "Large Room 2 x 2"],
["3x1", "Long Room 3 x 1"],
["3x2", "Grand Room 3 x 2"]
],
Corridor: [
["1x2", "Short Corridor 1 x 2"],
["1x4", "Long Corridor 1 x 4"],
["2x3", "Wide Corridor 2 x 3"],
["4x1", "Horizontal Corridor 4 x 1"],
["5x1", "Gallery 5 x 1"]
],
OpenSpace: [
["4x4", "Courtyard 4 x 4"],
["6x5", "Large Courtyard 6 x 5"],
["6x4", "Garden Area 6 x 4"],
["2x2", "Lightwell 2 x 2"]
],
Exterior: [
["3x3", "Yard 3 x 3"],
["4x2", "Drive 4 x 2"],
["6x4", "Garden 6 x 4"],
["4x1", "Terrace 4 x 1"]
],
Other: [
["1x1", "Small 1 x 1"],
["2x1", "Medium 2 x 1"],
["2x2", "Large 2 x 2"],
["3x2", "Wide 3 x 2"]
]
};
let selectedBlock = null;
let pointerState = null;
let saveTimer = null;
let saveInFlight = false;
let saveAgain = false;
const backgroundTimers = new Map();
function setToolboxTab(tabName) {
toolboxTabs.forEach(tab => {
const active = tab.dataset.toolboxTab === tabName;
tab.classList.toggle("active", active);
tab.setAttribute("aria-selected", active ? "true" : "false");
});
toolboxPanels.forEach(panel => {
const active = panel.dataset.toolboxPanel === tabName;
panel.classList.toggle("active", active);
panel.hidden = !active;
});
}
function setSaveStatus(state, message) {
if (!saveStatus) return;
saveStatus.dataset.saveState = state;
saveStatus.textContent = message || saveStatusText[state] || state;
}
function markDirty() {
setSaveStatus("dirty");
window.clearTimeout(saveTimer);
saveTimer = window.setTimeout(() => saveLayoutAsync(), 1000);
}
async function saveLayoutAsync() {
window.clearTimeout(saveTimer);
if (saveInFlight) {
saveAgain = true;
return;
}
saveInFlight = true;
saveButtons.forEach(button => button.disabled = true);
setSaveStatus("saving");
try {
const response = await fetch(editor.action, {
method: "POST",
body: new FormData(editor),
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 layout.");
}
setSaveStatus("saved");
} catch (error) {
setSaveStatus("failed", error.message || "Save failed");
} finally {
saveInFlight = false;
saveButtons.forEach(button => button.disabled = false);
if (saveAgain) {
saveAgain = false;
markDirty();
}
}
}
function setActiveFloor(floorId) {
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) {
addBlockFloorNames.forEach(item => item.textContent = activeTab.dataset.floorName || activeTab.textContent || "the current floor");
}
filterAddBlockLocations(activeTab?.dataset.floorLocationId || "");
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 = "";
[...addBlockLocation.options].forEach(option => {
if (!option.value) {
option.hidden = false;
option.disabled = false;
return;
}
const matches = option.dataset.parentLocationId === floorLocationId;
option.hidden = !matches;
option.disabled = !matches;
if (matches && !firstValue) firstValue = option.value;
});
if (!addBlockLocation.value || addBlockLocation.selectedOptions[0]?.disabled) {
addBlockLocation.value = firstValue;
}
}
function updateAddBlockMode() {
const selectedMode = addBlockModes.find(input => input.checked)?.value || "Existing";
const isCreate = selectedMode === "Create";
existingLocationPanel?.classList.toggle("d-none", isCreate);
newLocationPanel?.classList.toggle("d-none", !isCreate);
}
function selectBlock(block) {
selectedBlock = block;
blocks.forEach(item => item.classList.toggle("selected", item === block));
editor.querySelectorAll("[data-block-editor]").forEach(panel => panel.classList.add("d-none"));
const panel = editor.querySelector(`[data-block-editor="${block.dataset.block}"]`);
if (panel) panel.classList.remove("d-none");
if (noSelection) noSelection.classList.add("d-none");
setToolboxTab("selected");
}
function fieldsFor(block) {
const panel = editor.querySelector(`[data-block-editor="${block.dataset.block}"]`);
return {
panel,
floor: panel?.querySelector("[data-block-field='floor']"),
x: panel?.querySelector("[data-block-field='x']"),
y: panel?.querySelector("[data-block-field='y']"),
w: panel?.querySelector("[data-block-field='w']"),
h: panel?.querySelector("[data-block-field='h']"),
type: panel?.querySelector("[data-block-field='type']")
};
}
function gridFor(block) {
return editor.querySelector(`[data-floor-grid="${block.dataset.floorId}"]`);
}
function clampBlock(block, values) {
const grid = gridFor(block);
const gridWidth = Number(grid?.dataset.gridWidth || 24);
const gridHeight = Number(grid?.dataset.gridHeight || 16);
const w = Math.max(1, Math.min(Number(values.w), gridWidth));
const h = Math.max(1, Math.min(Number(values.h), gridHeight));
const x = Math.max(0, Math.min(Number(values.x), gridWidth - w));
const y = Math.max(0, Math.min(Number(values.y), gridHeight - h));
return { x, y, w, h };
}
function readBlock(block) {
const fields = fieldsFor(block);
return {
x: Number(fields.x?.value || block.style.getPropertyValue("--x") || 0),
y: Number(fields.y?.value || block.style.getPropertyValue("--y") || 0),
w: Number(fields.w?.value || block.style.getPropertyValue("--w") || 1),
h: Number(fields.h?.value || block.style.getPropertyValue("--h") || 1)
};
}
function writeBlock(block, values) {
const next = clampBlock(block, values);
block.style.setProperty("--x", next.x);
block.style.setProperty("--y", next.y);
block.style.setProperty("--w", next.w);
block.style.setProperty("--h", next.h);
const fields = fieldsFor(block);
if (fields.x) fields.x.value = next.x;
if (fields.y) fields.y.value = next.y;
if (fields.w) fields.w.value = next.w;
if (fields.h) fields.h.value = next.h;
checkOverlaps();
}
function cellFromPointer(grid, event) {
const rect = grid.getBoundingClientRect();
const gridWidth = Number(grid.dataset.gridWidth || 24);
const gridHeight = Number(grid.dataset.gridHeight || 16);
return {
x: Math.floor((event.clientX - rect.left) / (rect.width / gridWidth)),
y: Math.floor((event.clientY - rect.top) / (rect.height / gridHeight))
};
}
function checkOverlaps() {
const activePanel = panels.find(panel => panel.classList.contains("active"));
if (!activePanel) return;
const visibleBlocks = [...activePanel.querySelectorAll("[data-block]")].map(block => ({ block, ...readBlock(block) }));
let overlaps = false;
visibleBlocks.forEach(a => {
a.block.classList.remove("overlap");
visibleBlocks.forEach(b => {
if (a === b) return;
const separated = a.x + a.w <= b.x || b.x + b.w <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y;
if (!separated) {
overlaps = true;
a.block.classList.add("overlap");
b.block.classList.add("overlap");
}
});
});
warning?.classList.toggle("d-none", !overlaps);
}
function applySizePreset() {
const [width, height] = (sizePreset?.value || "2x1").split("x");
if (addBlockWidth) addBlockWidth.value = width || "2";
if (addBlockHeight) addBlockHeight.value = height || "1";
}
function updateSizePresets() {
if (!sizePreset) return;
const presets = sizePresets[addBlockType?.value || "Room"] || sizePresets.Room;
sizePreset.innerHTML = "";
presets.forEach(([value, label], index) => {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
option.selected = index === 0;
sizePreset.append(option);
});
applySizePreset();
}
tabs.forEach(tab => tab.addEventListener("click", () => setActiveFloor(tab.dataset.floorTab)));
toolboxTabs.forEach(tab => tab.addEventListener("click", () => setToolboxTab(tab.dataset.toolboxTab)));
editor.addEventListener("submit", event => {
event.preventDefault();
saveLayoutAsync();
});
blocks.forEach(block => {
block.addEventListener("pointerdown", event => {
selectBlock(block);
const grid = gridFor(block);
if (!grid) return;
const start = cellFromPointer(grid, event);
const current = readBlock(block);
const resizing = event.target.matches("[data-resize-handle]");
pointerState = { block, grid, start, current, resizing };
block.setPointerCapture(event.pointerId);
event.preventDefault();
});
});
editor.addEventListener("pointermove", event => {
if (!pointerState) return;
const currentCell = cellFromPointer(pointerState.grid, event);
const dx = currentCell.x - pointerState.start.x;
const dy = currentCell.y - pointerState.start.y;
if (pointerState.resizing) {
writeBlock(pointerState.block, {
...pointerState.current,
w: pointerState.current.w + dx,
h: pointerState.current.h + dy
});
} else {
writeBlock(pointerState.block, {
...pointerState.current,
x: pointerState.current.x + dx,
y: pointerState.current.y + dy
});
}
});
editor.addEventListener("pointerup", () => {
if (pointerState) markDirty();
pointerState = null;
});
editor.addEventListener("pointercancel", () => {
if (pointerState) markDirty();
pointerState = null;
});
editor.querySelectorAll("[data-block-field='x'],[data-block-field='y'],[data-block-field='w'],[data-block-field='h']").forEach(input => {
input.addEventListener("change", () => {
if (!selectedBlock) return;
writeBlock(selectedBlock, readBlock(selectedBlock));
markDirty();
});
});
editor.querySelectorAll("[data-block-field='type']").forEach(select => {
select.addEventListener("change", () => {
if (!selectedBlock) return;
selectedBlock.className = selectedBlock.className
.split(" ")
.filter(name => !name.startsWith("floor-plan-block--"))
.concat(`floor-plan-block--${select.value.toLowerCase()}`)
.join(" ");
markDirty();
});
});
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();
addBlockModes.forEach(input => input.addEventListener("change", updateAddBlockMode));
updateAddBlockMode();
const initialBlock = blocks.find(block => block.dataset.block === initialSelectedBlockId) || blocks[0];
if (initialBlock) {
setActiveFloor(initialBlock.dataset.floorId);
selectBlock(initialBlock);
} else if (tabs[0]) {
setActiveFloor(tabs[0].dataset.floorTab);
}
checkOverlaps();
})();
</script>
}