1899 lines
114 KiB
Plaintext
1899 lines
114 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 transitionsByFloor = Model.Transitions.GroupBy(x => x.FloorPlanFloorID).ToDictionary(x => x.Key, x => x.ToList());
|
|
var selectedBlockId = int.TryParse(Context.Request.Query["selectedBlockId"], out var parsedSelectedBlockId) ? parsedSelectedBlockId : 0;
|
|
string BlockClass(string blockType) => $"floor-plan-block floor-plan-block--{blockType.ToLowerInvariant()}";
|
|
string CssNumber(decimal value) => value.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture);
|
|
string TransitionDetails(FloorPlanTransitionEditViewModel transition)
|
|
{
|
|
var details = $"{transition.FromLocationName} -> {transition.ToLocationName}\n{transition.TransitionType}";
|
|
return string.IsNullOrWhiteSpace(transition.DisplayLabel) ? details : $"{details}\n{transition.DisplayLabel}";
|
|
}
|
|
|
|
string TransitionMarkerType(string? transitionType)
|
|
{
|
|
var normalized = (transitionType ?? string.Empty)
|
|
.Trim()
|
|
.Replace(" ", string.Empty, StringComparison.OrdinalIgnoreCase)
|
|
.Replace("-", string.Empty, StringComparison.OrdinalIgnoreCase)
|
|
.ToLowerInvariant();
|
|
|
|
return normalized switch
|
|
{
|
|
"door" => "door",
|
|
"doubledoor" => "double-door",
|
|
"window" => "window",
|
|
"archway" or "opening" => "archway",
|
|
"secretdoor" => "secret-door",
|
|
"blockeddoor" => "blocked-door",
|
|
"exteriordoor" => "exterior-door",
|
|
"stairs" or "stair" => "stairs",
|
|
"passage" => "passage",
|
|
_ => "other"
|
|
};
|
|
}
|
|
|
|
string TransitionMarkerSvg(string markerType) => markerType switch
|
|
{
|
|
"door" => @"<svg viewBox=""0 0 18 18"" aria-hidden=""true"" focusable=""false""><path class=""marker-line"" d=""M2 9h6""/><path class=""marker-line"" d=""M10 9h6""/><path class=""marker-arc"" d=""M8 9a6 6 0 0 1 6-6""/></svg>",
|
|
"double-door" => @"<svg viewBox=""0 0 18 18"" aria-hidden=""true"" focusable=""false""><path class=""marker-line"" d=""M2 9h4""/><path class=""marker-line"" d=""M12 9h4""/><path class=""marker-arc"" d=""M6 9a4 4 0 0 1 4-4""/><path class=""marker-arc"" d=""M12 9a4 4 0 0 0-4-4""/></svg>",
|
|
"window" => @"<svg viewBox=""0 0 18 18"" aria-hidden=""true"" focusable=""false""><path class=""marker-window"" d=""M3 7h12""/><path class=""marker-window"" d=""M3 11h12""/><path class=""marker-line"" d=""M5 9h8""/></svg>",
|
|
"archway" => @"<svg viewBox=""0 0 18 18"" aria-hidden=""true"" focusable=""false""><path class=""marker-line"" d=""M2 14h4""/><path class=""marker-line"" d=""M12 14h4""/><path class=""marker-arc"" d=""M6 14a3 3 0 0 1 6 0""/></svg>",
|
|
"secret-door" => @"<svg viewBox=""0 0 18 18"" aria-hidden=""true"" focusable=""false""><path class=""marker-line marker-dotted"" d=""M2 9h6""/><path class=""marker-line marker-dotted"" d=""M10 9h6""/><path class=""marker-arc marker-dotted"" d=""M8 9a6 6 0 0 1 6-6""/></svg>",
|
|
"blocked-door" => @"<svg viewBox=""0 0 18 18"" aria-hidden=""true"" focusable=""false""><path class=""marker-line"" d=""M2 9h14""/><path class=""marker-blocked"" d=""M6 5l6 8""/><path class=""marker-blocked"" d=""M12 5l-6 8""/></svg>",
|
|
"exterior-door" => @"<svg viewBox=""0 0 18 18"" aria-hidden=""true"" focusable=""false""><path class=""marker-line marker-strong"" d=""M2 9h6""/><path class=""marker-line marker-strong"" d=""M10 9h6""/><path class=""marker-arc marker-strong"" d=""M8 9a6 6 0 0 1 6-6""/></svg>",
|
|
"stairs" => @"<svg viewBox=""0 0 18 18"" aria-hidden=""true"" focusable=""false""><path class=""marker-line"" d=""M4 13h10""/><path class=""marker-line"" d=""M5 10h8""/><path class=""marker-line"" d=""M6 7h6""/><path class=""marker-line"" d=""M7 4h4""/></svg>",
|
|
"passage" => @"<svg viewBox=""0 0 18 18"" aria-hidden=""true"" focusable=""false""><path class=""marker-line"" d=""M3 7h12""/><path class=""marker-line"" d=""M3 11h12""/><path class=""marker-line"" d=""M11 5l4 4-4 4""/><path class=""marker-line"" d=""M7 5L3 9l4 4""/></svg>",
|
|
_ => @"<span aria-hidden=""true"">x</span>"
|
|
};
|
|
}
|
|
|
|
<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>
|
|
|
|
<div data-floor-plan-workspace data-floor-plan-id="@Model.FloorPlan.FloorPlanID">
|
|
@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" />
|
|
<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">Level</label>
|
|
<input asp-for="NewFloor.SortOrder" class="form-control form-control-sm" name="SortOrder" min="-10" max="50" step="1" 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>
|
|
<p class="form-text mb-0">Used to order floors and later to connect stairs up/down.</p>
|
|
</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) ?? [];
|
|
var floorTransitions = transitionsByFloor.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"
|
|
data-location-id="@block.LocationID"
|
|
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>
|
|
}
|
|
@foreach (var transition in floorTransitions)
|
|
{
|
|
var transitionDetails = TransitionDetails(transition);
|
|
var markerType = TransitionMarkerType(transition.TransitionType);
|
|
<div class="floor-plan-transition-marker floor-plan-transition-marker--@markerType"
|
|
data-transition-marker="@transition.FloorPlanTransitionID"
|
|
data-from-location-id="@transition.FromLocationID"
|
|
data-to-location-id="@transition.ToLocationID"
|
|
data-transition-type="@markerType"
|
|
title="@transitionDetails"
|
|
aria-label="@transitionDetails">
|
|
@Html.Raw(TransitionMarkerSvg(markerType))
|
|
</div>
|
|
}
|
|
</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-transitions" role="tab" aria-selected="false" aria-controls="floor-plan-tool-transitions" data-toolbox-tab="transitions">Transitions</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" data-block-field="location"></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-transitions" role="tabpanel" aria-labelledby="floor-plan-tool-tab-transitions" data-toolbox-panel="transitions" hidden>
|
|
<h2>Transitions</h2>
|
|
<p class="muted">Create doors, archways, stairs, and other movement paths for <span data-current-floor-name>@activeFloor?.Name</span>.</p>
|
|
@foreach (var floor in Model.Floors)
|
|
{
|
|
var isActive = floor.FloorPlanFloorID == activeFloor?.FloorPlanFloorID;
|
|
var floorBlocks = blocksByFloor.GetValueOrDefault(floor.FloorPlanFloorID) ?? [];
|
|
var floorLocationOptions = floorBlocks
|
|
.Where(x => x.LocationID.HasValue)
|
|
.GroupBy(x => x.LocationID!.Value)
|
|
.Select(x => x.First())
|
|
.OrderBy(x => x.LocationName)
|
|
.ToList();
|
|
var floorTransitions = transitionsByFloor.GetValueOrDefault(floor.FloorPlanFloorID) ?? [];
|
|
<div class="floor-plan-transition-editor @(isActive ? string.Empty : "d-none")" data-transition-editor="@floor.FloorPlanFloorID">
|
|
<div class="accordion floor-plan-transition-accordion" id="transition-accordion-@floor.FloorPlanFloorID">
|
|
<div class="accordion-item">
|
|
<h3 class="accordion-header" id="transition-create-heading-@floor.FloorPlanFloorID">
|
|
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#transition-create-@floor.FloorPlanFloorID" aria-expanded="true" aria-controls="transition-create-@floor.FloorPlanFloorID">
|
|
Create transition
|
|
</button>
|
|
</h3>
|
|
<div id="transition-create-@floor.FloorPlanFloorID" class="accordion-collapse collapse show" aria-labelledby="transition-create-heading-@floor.FloorPlanFloorID" data-bs-parent="#transition-accordion-@floor.FloorPlanFloorID">
|
|
<div class="accordion-body">
|
|
@if (floorLocationOptions.Count < 2)
|
|
{
|
|
<p class="muted mb-0">Add at least two placed Locations on this floor before creating a transition.</p>
|
|
}
|
|
else
|
|
{
|
|
<input type="hidden" name="FloorPlanTransitionID" value="0" form="floor-plan-transition-add-@floor.FloorPlanFloorID" />
|
|
<input type="hidden" name="FloorPlanFloorID" value="@floor.FloorPlanFloorID" form="floor-plan-transition-add-@floor.FloorPlanFloorID" />
|
|
<div class="mb-2">
|
|
<label class="form-label" for="transition-from-@floor.FloorPlanFloorID">From Location</label>
|
|
<select id="transition-from-@floor.FloorPlanFloorID" class="form-select form-select-sm" name="FromLocationID" form="floor-plan-transition-add-@floor.FloorPlanFloorID">
|
|
<option value="">Choose a Location</option>
|
|
@foreach (var location in floorLocationOptions)
|
|
{
|
|
<option value="@location.LocationID">@location.LocationPath</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="mb-2">
|
|
<label class="form-label" for="transition-to-@floor.FloorPlanFloorID">To Location</label>
|
|
<select id="transition-to-@floor.FloorPlanFloorID" class="form-select form-select-sm" name="ToLocationID" form="floor-plan-transition-add-@floor.FloorPlanFloorID">
|
|
<option value="">Choose a Location</option>
|
|
@foreach (var location in floorLocationOptions)
|
|
{
|
|
<option value="@location.LocationID">@location.LocationPath</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="mb-2">
|
|
<label class="form-label" for="transition-type-@floor.FloorPlanFloorID">Transition Type</label>
|
|
<select id="transition-type-@floor.FloorPlanFloorID" class="form-select form-select-sm" name="TransitionType" asp-items="Model.TransitionTypeOptions" form="floor-plan-transition-add-@floor.FloorPlanFloorID"></select>
|
|
</div>
|
|
<div class="mb-2">
|
|
<label class="form-label" for="transition-label-@floor.FloorPlanFloorID">Display Label</label>
|
|
<input id="transition-label-@floor.FloorPlanFloorID" class="form-control form-control-sm" name="DisplayLabel" form="floor-plan-transition-add-@floor.FloorPlanFloorID" />
|
|
</div>
|
|
<div class="mb-2">
|
|
<label class="form-label" for="transition-notes-@floor.FloorPlanFloorID">Notes</label>
|
|
<textarea id="transition-notes-@floor.FloorPlanFloorID" class="form-control form-control-sm" name="Notes" rows="2" form="floor-plan-transition-add-@floor.FloorPlanFloorID"></textarea>
|
|
</div>
|
|
<label class="floor-plan-checkbox">
|
|
<input type="checkbox" name="IsLocked" value="true" form="floor-plan-transition-add-@floor.FloorPlanFloorID" />
|
|
Locked
|
|
</label>
|
|
<button class="btn btn-primary btn-sm w-100 mt-2" type="submit" form="floor-plan-transition-add-@floor.FloorPlanFloorID">Create transition</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="accordion-item">
|
|
<h3 class="accordion-header" id="transition-existing-heading-@floor.FloorPlanFloorID">
|
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#transition-existing-@floor.FloorPlanFloorID" aria-expanded="false" aria-controls="transition-existing-@floor.FloorPlanFloorID">
|
|
Existing transitions
|
|
</button>
|
|
</h3>
|
|
<div id="transition-existing-@floor.FloorPlanFloorID" class="accordion-collapse collapse" aria-labelledby="transition-existing-heading-@floor.FloorPlanFloorID" data-bs-parent="#transition-accordion-@floor.FloorPlanFloorID">
|
|
<div class="accordion-body">
|
|
@if (!floorTransitions.Any())
|
|
{
|
|
<p class="muted mb-0">No transitions on this floor yet.</p>
|
|
}
|
|
else
|
|
{
|
|
<div class="floor-plan-transition-list">
|
|
@foreach (var transition in floorTransitions)
|
|
{
|
|
<article class="floor-plan-transition-item">
|
|
<div>
|
|
<strong>@transition.FromLocationName -> @transition.ToLocationName</strong>
|
|
<span>@transition.TransitionType</span>
|
|
@if (!string.IsNullOrWhiteSpace(transition.DisplayLabel))
|
|
{
|
|
<span>@transition.DisplayLabel</span>
|
|
}
|
|
</div>
|
|
<div class="button-row">
|
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-bs-toggle="modal" data-bs-target="#edit-transition-@transition.FloorPlanTransitionID">Edit</button>
|
|
<button class="btn btn-outline-danger btn-sm" type="button" data-bs-toggle="modal" data-bs-target="#delete-transition-@transition.FloorPlanTransitionID">Delete</button>
|
|
</div>
|
|
</article>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</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" />
|
|
<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="mb-2">
|
|
<label class="form-label">Level</label>
|
|
<input asp-for="NewFloor.SortOrder" class="form-control form-control-sm" name="SortOrder" min="-10" max="50" step="1" form="floor-plan-add-floor-form" />
|
|
<div class="form-text">Used to order floors and later to connect stairs up/down.</div>
|
|
</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].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>
|
|
<div class="mb-2">
|
|
<label class="form-label">Level</label>
|
|
<input asp-for="Floors[i].SortOrder" class="form-control form-control-sm" min="-10" max="50" step="1" data-floor-level="@floor.FloorPlanFloorID" />
|
|
<div class="form-text">Used to order floors and later to connect stairs up/down.</div>
|
|
</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>
|
|
|
|
<form id="floor-plan-transition-add-@floor.FloorPlanFloorID" asp-action="SaveTransition" method="post">
|
|
<input type="hidden" name="floorPlanId" value="@Model.FloorPlan.FloorPlanID" />
|
|
</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 transition in Model.Transitions)
|
|
{
|
|
var transitionFloorBlocks = blocksByFloor.GetValueOrDefault(transition.FloorPlanFloorID) ?? [];
|
|
var transitionLocationOptions = transitionFloorBlocks
|
|
.Where(x => x.LocationID.HasValue)
|
|
.GroupBy(x => x.LocationID!.Value)
|
|
.Select(x => x.First())
|
|
.OrderBy(x => x.LocationName)
|
|
.ToList();
|
|
<div class="modal fade" id="edit-transition-@transition.FloorPlanTransitionID" tabindex="-1" aria-labelledby="edit-transition-title-@transition.FloorPlanTransitionID" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h2 class="modal-title fs-5" id="edit-transition-title-@transition.FloorPlanTransitionID">Edit transition</h2>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<form asp-action="SaveTransition" method="post">
|
|
<div class="modal-body">
|
|
<input type="hidden" name="floorPlanId" value="@Model.FloorPlan.FloorPlanID" />
|
|
<input type="hidden" name="FloorPlanTransitionID" value="@transition.FloorPlanTransitionID" />
|
|
<input type="hidden" name="FloorPlanFloorID" value="@transition.FloorPlanFloorID" />
|
|
<div class="mb-2">
|
|
<label class="form-label">From Location</label>
|
|
<select class="form-select form-select-sm" name="FromLocationID">
|
|
@foreach (var location in transitionLocationOptions)
|
|
{
|
|
<option value="@location.LocationID" selected="@(location.LocationID == transition.FromLocationID)">@location.LocationPath</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="mb-2">
|
|
<label class="form-label">To Location</label>
|
|
<select class="form-select form-select-sm" name="ToLocationID">
|
|
@foreach (var location in transitionLocationOptions)
|
|
{
|
|
<option value="@location.LocationID" selected="@(location.LocationID == transition.ToLocationID)">@location.LocationPath</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="mb-2">
|
|
<label class="form-label">Transition Type</label>
|
|
<select class="form-select form-select-sm" name="TransitionType">
|
|
@foreach (var option in Model.TransitionTypeOptions)
|
|
{
|
|
<option value="@option.Value" selected="@(option.Value == transition.TransitionType)">@option.Text</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="mb-2">
|
|
<label class="form-label">Display Label</label>
|
|
<input class="form-control form-control-sm" name="DisplayLabel" value="@transition.DisplayLabel" />
|
|
</div>
|
|
<div class="mb-2">
|
|
<label class="form-label">Notes</label>
|
|
<textarea class="form-control form-control-sm" name="Notes" rows="3">@transition.Notes</textarea>
|
|
</div>
|
|
<label class="floor-plan-checkbox">
|
|
<input type="checkbox" name="IsLocked" value="true" @(transition.IsLocked ? "checked" : string.Empty) />
|
|
Locked
|
|
</label>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button class="btn btn-primary" type="submit">Save transition</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal fade" id="delete-transition-@transition.FloorPlanTransitionID" tabindex="-1" aria-labelledby="delete-transition-title-@transition.FloorPlanTransitionID" 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-transition-title-@transition.FloorPlanTransitionID">Delete transition?</h2>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p>Delete the <strong>@transition.TransitionType</strong> transition from <strong>@transition.FromLocationName</strong> to <strong>@transition.ToLocationName</strong>?</p>
|
|
<p>Locations and blocks 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="DeleteTransition" method="post" data-no-delete-confirm="true">
|
|
<input type="hidden" name="floorPlanId" value="@Model.FloorPlan.FloorPlanID" />
|
|
<input type="hidden" name="floorPlanTransitionId" value="@transition.FloorPlanTransitionID" />
|
|
<button class="btn btn-danger" type="submit">Delete transition</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>
|
|
}
|
|
}
|
|
</div>
|
|
|
|
@section Scripts {
|
|
<partial name="_ValidationScriptsPartial" />
|
|
<script>
|
|
window.initializeFloorPlanEditor = (options = {}) => {
|
|
const editor = document.querySelector("[data-floor-plan-editor]");
|
|
if (!editor) return;
|
|
if (editor.dataset.floorPlanInitialized === "true") return;
|
|
editor.dataset.floorPlanInitialized = "true";
|
|
const workspace = editor.closest("[data-floor-plan-workspace]");
|
|
|
|
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 transitionEditors = [...editor.querySelectorAll("[data-transition-editor]")];
|
|
let 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 addBlockForm = document.querySelector("#floor-plan-add-block-form");
|
|
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 = options.selectedBlockId || "@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;
|
|
let refreshAfterSave = 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);
|
|
}
|
|
|
|
function workspaceState(overrides = {}) {
|
|
const activePanel = panels.find(panel => panel.classList.contains("active"));
|
|
const gridWrap = activePanel?.querySelector(".floor-plan-grid-wrap");
|
|
const toolboxBody = editor.querySelector(".floor-plan-toolbox-body");
|
|
return {
|
|
activeFloorId: activePanel?.dataset.floorPanel || tabs.find(tab => tab.classList.contains("active"))?.dataset.floorTab || "",
|
|
activeToolboxTab: toolboxTabs.find(tab => tab.classList.contains("active"))?.dataset.toolboxTab || "add",
|
|
selectedBlockId: selectedBlock?.dataset.block || "",
|
|
pageX: window.scrollX,
|
|
pageY: window.scrollY,
|
|
gridLeft: gridWrap?.scrollLeft || 0,
|
|
gridTop: gridWrap?.scrollTop || 0,
|
|
toolboxTop: toolboxBody?.scrollTop || 0,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function showWorkspaceError(message) {
|
|
setSaveStatus("failed", message || "Save failed");
|
|
let alert = editor.querySelector("[data-workspace-error]");
|
|
if (!alert) {
|
|
alert = document.createElement("div");
|
|
alert.className = "alert alert-danger";
|
|
alert.dataset.workspaceError = "true";
|
|
editor.prepend(alert);
|
|
}
|
|
alert.textContent = message || "Save failed";
|
|
}
|
|
|
|
function cleanupModalArtifacts() {
|
|
document.querySelectorAll(".modal-backdrop").forEach(backdrop => backdrop.remove());
|
|
document.body.classList.remove("modal-open");
|
|
document.body.style.removeProperty("overflow");
|
|
document.body.style.removeProperty("padding-right");
|
|
}
|
|
|
|
function restoreWorkspaceState(state) {
|
|
if (state.activeFloorId) {
|
|
setActiveFloor(String(state.activeFloorId));
|
|
}
|
|
if (!panels.some(panel => panel.classList.contains("active")) && tabs[0]) {
|
|
setActiveFloor(tabs[0].dataset.floorTab);
|
|
}
|
|
if (state.activeToolboxTab) {
|
|
setToolboxTab(state.activeToolboxTab);
|
|
}
|
|
if (state.selectedBlockId) {
|
|
const block = blocks.find(item => item.dataset.block === String(state.selectedBlockId));
|
|
if (block) {
|
|
selectBlock(block, { showEditor: state.activeToolboxTab === "selected" });
|
|
}
|
|
}
|
|
|
|
const activePanel = panels.find(panel => panel.classList.contains("active"));
|
|
const gridWrap = activePanel?.querySelector(".floor-plan-grid-wrap");
|
|
const toolboxBody = editor.querySelector(".floor-plan-toolbox-body");
|
|
if (gridWrap) {
|
|
gridWrap.scrollLeft = state.gridLeft || 0;
|
|
gridWrap.scrollTop = state.gridTop || 0;
|
|
}
|
|
if (toolboxBody) {
|
|
toolboxBody.scrollTop = state.toolboxTop || 0;
|
|
}
|
|
window.scrollTo(state.pageX || 0, state.pageY || 0);
|
|
document.documentElement.scrollTop = state.pageY || 0;
|
|
document.body.scrollTop = state.pageY || 0;
|
|
}
|
|
|
|
async function refreshWorkspaceAsync(state) {
|
|
const floorPlanId = workspace?.dataset.floorPlanId;
|
|
if (!workspace || !floorPlanId) return;
|
|
|
|
const response = await fetch(`/FloorPlans/Edit/${floorPlanId}`, {
|
|
method: "GET",
|
|
headers: { "X-Requested-With": "XMLHttpRequest" },
|
|
credentials: "same-origin"
|
|
});
|
|
const html = await response.text();
|
|
if (!response.ok) {
|
|
throw new Error("The floor plan workspace could not be refreshed.");
|
|
}
|
|
|
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
const nextWorkspace = doc.querySelector("[data-floor-plan-workspace]");
|
|
if (!nextWorkspace) {
|
|
throw new Error("The refreshed floor plan workspace was not found.");
|
|
}
|
|
|
|
cleanupModalArtifacts();
|
|
workspace.replaceWith(nextWorkspace);
|
|
window.initializeFloorPlanEditor({
|
|
activeFloorId: state.activeFloorId,
|
|
activeToolboxTab: state.activeToolboxTab,
|
|
selectedBlockId: state.selectedBlockId
|
|
});
|
|
const nextEditor = document.querySelector("[data-floor-plan-editor]");
|
|
const nextStatus = nextEditor?.querySelector("[data-save-status]");
|
|
if (nextStatus) {
|
|
nextStatus.dataset.saveState = "saved";
|
|
nextStatus.textContent = "Saved";
|
|
}
|
|
window.requestAnimationFrame(() => {
|
|
const refreshedEditor = document.querySelector("[data-floor-plan-editor]");
|
|
if (!refreshedEditor) return;
|
|
refreshedEditor.dispatchEvent(new CustomEvent("floor-plan-restore-state", { detail: state }));
|
|
});
|
|
}
|
|
|
|
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");
|
|
if (refreshAfterSave) {
|
|
refreshAfterSave = false;
|
|
await refreshWorkspaceAsync(workspaceState());
|
|
}
|
|
} catch (error) {
|
|
setSaveStatus("failed", error.message || "Save failed");
|
|
} finally {
|
|
saveInFlight = false;
|
|
saveButtons.forEach(button => button.disabled = false);
|
|
if (saveAgain) {
|
|
saveAgain = false;
|
|
markDirty();
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateFloorGrid(floorId) {
|
|
const grid = editor.querySelector(`[data-floor-grid="${floorId}"]`);
|
|
const widthInput = editor.querySelector(`[data-floor-width="${floorId}"]`);
|
|
const heightInput = editor.querySelector(`[data-floor-height="${floorId}"]`);
|
|
if (!grid || !widthInput || !heightInput) return;
|
|
|
|
const width = Math.max(8, Math.min(Number(widthInput.value || 24), 80));
|
|
const height = Math.max(8, Math.min(Number(heightInput.value || 16), 80));
|
|
widthInput.value = width;
|
|
heightInput.value = height;
|
|
grid.dataset.gridWidth = width;
|
|
grid.dataset.gridHeight = height;
|
|
grid.style.setProperty("--grid-width", width);
|
|
grid.style.setProperty("--grid-height", height);
|
|
|
|
grid.querySelectorAll("[data-block]").forEach(block => writeBlock(block, readBlock(block)));
|
|
checkOverlaps();
|
|
}
|
|
|
|
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;
|
|
});
|
|
transitionEditors.forEach(panel => {
|
|
const active = panel.dataset.transitionEditor === 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 attachBlockEvents(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();
|
|
});
|
|
}
|
|
|
|
function appendHidden(panel, name, value, field) {
|
|
const input = document.createElement("input");
|
|
input.type = "hidden";
|
|
input.name = name;
|
|
input.value = value ?? "";
|
|
if (field) input.dataset.blockField = field;
|
|
panel.append(input);
|
|
return input;
|
|
}
|
|
|
|
function createBlockEditor(block) {
|
|
const selectedPanel = editor.querySelector("#floor-plan-tool-selected");
|
|
if (!selectedPanel) return null;
|
|
|
|
const index = editor.querySelectorAll("[data-block-editor]").length;
|
|
const panel = document.createElement("div");
|
|
panel.className = "floor-plan-block-editor d-none";
|
|
panel.dataset.blockEditor = block.floorPlanBlockID;
|
|
|
|
appendHidden(panel, `Blocks[${index}].FloorPlanBlockID`, block.floorPlanBlockID);
|
|
appendHidden(panel, `Blocks[${index}].FloorPlanFloorID`, block.floorPlanFloorID, "floor");
|
|
appendHidden(panel, `Blocks[${index}].LocationName`, block.locationName || "");
|
|
appendHidden(panel, `Blocks[${index}].LocationPath`, block.locationPath || "");
|
|
|
|
const locationTemplate = editor.querySelector("[data-block-field='location']");
|
|
const typeTemplate = editor.querySelector("[data-block-field='type']");
|
|
|
|
const locationWrap = document.createElement("div");
|
|
locationWrap.className = "mb-2";
|
|
locationWrap.innerHTML = `<label class="form-label">Location</label>`;
|
|
const locationSelect = locationTemplate?.cloneNode(true) || document.createElement("select");
|
|
locationSelect.name = `Blocks[${index}].LocationID`;
|
|
locationSelect.dataset.blockField = "location";
|
|
locationSelect.value = block.locationID || "";
|
|
locationSelect.addEventListener("change", () => {
|
|
if (!selectedBlock) return;
|
|
selectedBlock.dataset.locationId = locationSelect.value || "";
|
|
checkOverlaps();
|
|
markDirty();
|
|
});
|
|
locationWrap.append(locationSelect);
|
|
panel.append(locationWrap);
|
|
|
|
const typeWrap = document.createElement("div");
|
|
typeWrap.className = "mb-2";
|
|
typeWrap.innerHTML = `<label class="form-label">Type</label>`;
|
|
const typeSelect = typeTemplate?.cloneNode(true) || document.createElement("select");
|
|
typeSelect.name = `Blocks[${index}].BlockType`;
|
|
typeSelect.dataset.blockField = "type";
|
|
typeSelect.value = block.blockType || "Room";
|
|
typeSelect.addEventListener("change", () => {
|
|
if (!selectedBlock) return;
|
|
selectedBlock.className = selectedBlock.className
|
|
.split(" ")
|
|
.filter(name => !name.startsWith("floor-plan-block--"))
|
|
.concat(`floor-plan-block--${typeSelect.value.toLowerCase()}`)
|
|
.join(" ");
|
|
markDirty();
|
|
});
|
|
typeWrap.append(typeSelect);
|
|
panel.append(typeWrap);
|
|
|
|
const gridFields = document.createElement("div");
|
|
gridFields.className = "row g-2";
|
|
[
|
|
["X", "x", block.x, "X"],
|
|
["Y", "y", block.y, "Y"],
|
|
["Width", "w", block.widthCells, "WidthCells"],
|
|
["Height", "h", block.heightCells, "HeightCells"]
|
|
].forEach(([label, field, value, property]) => {
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "col-6";
|
|
wrap.innerHTML = `<label class="form-label">${label}</label>`;
|
|
const input = document.createElement("input");
|
|
input.className = "form-control form-control-sm";
|
|
input.type = "number";
|
|
input.min = field === "x" || field === "y" ? "0" : "1";
|
|
input.max = field === "x" || field === "y" ? "79" : "80";
|
|
input.name = `Blocks[${index}].${property}`;
|
|
input.value = value;
|
|
input.dataset.blockField = field;
|
|
const updateBlockField = () => {
|
|
if (!selectedBlock) return;
|
|
writeBlock(selectedBlock, readBlock(selectedBlock));
|
|
markDirty();
|
|
};
|
|
input.addEventListener("input", updateBlockField);
|
|
input.addEventListener("change", updateBlockField);
|
|
wrap.append(input);
|
|
gridFields.append(wrap);
|
|
});
|
|
panel.append(gridFields);
|
|
|
|
const labelWrap = document.createElement("div");
|
|
labelWrap.className = "mt-2";
|
|
labelWrap.innerHTML = `<label class="form-label">Label override</label>`;
|
|
const labelInput = document.createElement("input");
|
|
labelInput.className = "form-control form-control-sm";
|
|
labelInput.name = `Blocks[${index}].LabelOverride`;
|
|
labelInput.value = block.labelOverride || "";
|
|
labelInput.addEventListener("input", markDirty);
|
|
labelWrap.append(labelInput);
|
|
panel.append(labelWrap);
|
|
|
|
const notesWrap = document.createElement("div");
|
|
notesWrap.className = "mt-2";
|
|
notesWrap.innerHTML = `<label class="form-label">Notes</label>`;
|
|
const notesInput = document.createElement("textarea");
|
|
notesInput.className = "form-control form-control-sm";
|
|
notesInput.name = `Blocks[${index}].Notes`;
|
|
notesInput.rows = 3;
|
|
notesInput.value = block.notes || "";
|
|
notesInput.addEventListener("input", markDirty);
|
|
notesWrap.append(notesInput);
|
|
panel.append(notesWrap);
|
|
|
|
selectedPanel.append(panel);
|
|
return panel;
|
|
}
|
|
|
|
function addBlockToGrid(block) {
|
|
const grid = editor.querySelector(`[data-floor-grid="${block.floorPlanFloorID}"]`);
|
|
if (!grid) return null;
|
|
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = `floor-plan-block floor-plan-block--${(block.blockType || "Room").toLowerCase()}`;
|
|
button.style.setProperty("--x", block.x);
|
|
button.style.setProperty("--y", block.y);
|
|
button.style.setProperty("--w", block.widthCells);
|
|
button.style.setProperty("--h", block.heightCells);
|
|
button.dataset.block = block.floorPlanBlockID;
|
|
button.dataset.blockIndex = blocks.length;
|
|
button.dataset.floorId = block.floorPlanFloorID;
|
|
button.dataset.locationId = block.locationID || "";
|
|
button.title = block.locationPath || "";
|
|
button.innerHTML = `<span class="floor-plan-block-label"></span><span class="floor-plan-resize-handle" data-resize-handle aria-hidden="true"></span>`;
|
|
button.querySelector(".floor-plan-block-label").textContent = block.displayLabel || block.locationName || "New block";
|
|
grid.append(button);
|
|
|
|
blocks.push(button);
|
|
attachBlockEvents(button);
|
|
createBlockEditor(block);
|
|
checkOverlaps();
|
|
return button;
|
|
}
|
|
|
|
async function addBlockAsync(event) {
|
|
event.preventDefault();
|
|
if (!addBlockForm) return;
|
|
if (document.activeElement instanceof HTMLElement) {
|
|
document.activeElement.blur();
|
|
}
|
|
|
|
const scrollState = workspaceState();
|
|
setSaveStatus("saving");
|
|
try {
|
|
const response = await fetch(addBlockForm.action, {
|
|
method: "POST",
|
|
body: new FormData(addBlockForm),
|
|
headers: {
|
|
"Accept": "application/json",
|
|
"X-Requested-With": "XMLHttpRequest"
|
|
},
|
|
credentials: "same-origin"
|
|
});
|
|
const result = await response.json().catch(() => ({}));
|
|
if (!response.ok || result.ok === false || !result.block) {
|
|
throw new Error(result.message || "Unable to add block.");
|
|
}
|
|
|
|
scrollState.selectedBlockId = result.block.floorPlanBlockID;
|
|
await refreshWorkspaceAsync(scrollState);
|
|
} catch (error) {
|
|
setSaveStatus("failed", error.message || "Save failed");
|
|
}
|
|
}
|
|
|
|
function selectBlock(block, options = {}) {
|
|
const showEditor = options.showEditor !== false;
|
|
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");
|
|
if (showEditor) {
|
|
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']"),
|
|
location: panel?.querySelector("[data-block-field='location']"),
|
|
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 rangesOverlap(aStart, aLength, bStart, bLength) {
|
|
return aStart < bStart + bLength && bStart < aStart + aLength;
|
|
}
|
|
|
|
function subtractRange(segments, start, end) {
|
|
return segments.flatMap(segment => {
|
|
const clippedStart = Math.max(segment.start, start);
|
|
const clippedEnd = Math.min(segment.end, end);
|
|
if (clippedStart >= clippedEnd) return [segment];
|
|
|
|
return [
|
|
{ start: segment.start, end: clippedStart },
|
|
{ start: clippedEnd, end: segment.end }
|
|
].filter(next => next.end > next.start);
|
|
});
|
|
}
|
|
|
|
function renderWallSegment(block, side, start, end, total) {
|
|
if (end <= start || total <= 0) return;
|
|
|
|
const segment = document.createElement("span");
|
|
segment.dataset.wallSegment = side;
|
|
segment.className = `floor-plan-wall-segment floor-plan-wall-segment--${side}`;
|
|
segment.style.setProperty("--wall-start", `${(start / total) * 100}%`);
|
|
segment.style.setProperty("--wall-length", `${((end - start) / total) * 100}%`);
|
|
block.append(segment);
|
|
}
|
|
|
|
function visibleEdgeSegments(item, visibleBlocks, side) {
|
|
const axisStart = side === "east" || side === "west" ? item.y : item.x;
|
|
const axisLength = side === "east" || side === "west" ? item.h : item.w;
|
|
let segments = [{ start: 0, end: axisLength }];
|
|
|
|
visibleBlocks.forEach(other => {
|
|
if (item === other || !item.block.dataset.locationId || item.block.dataset.locationId !== other.block.dataset.locationId) return;
|
|
|
|
let touches = false;
|
|
let overlapStart = 0;
|
|
let overlapEnd = 0;
|
|
|
|
if (side === "east" && item.x + item.w === other.x) {
|
|
touches = true;
|
|
overlapStart = Math.max(item.y, other.y) - axisStart;
|
|
overlapEnd = Math.min(item.y + item.h, other.y + other.h) - axisStart;
|
|
} else if (side === "west" && other.x + other.w === item.x) {
|
|
touches = true;
|
|
overlapStart = Math.max(item.y, other.y) - axisStart;
|
|
overlapEnd = Math.min(item.y + item.h, other.y + other.h) - axisStart;
|
|
} else if (side === "south" && item.y + item.h === other.y) {
|
|
touches = true;
|
|
overlapStart = Math.max(item.x, other.x) - axisStart;
|
|
overlapEnd = Math.min(item.x + item.w, other.x + other.w) - axisStart;
|
|
} else if (side === "north" && other.y + other.h === item.y) {
|
|
touches = true;
|
|
overlapStart = Math.max(item.x, other.x) - axisStart;
|
|
overlapEnd = Math.min(item.x + item.w, other.x + other.w) - axisStart;
|
|
}
|
|
|
|
if (touches && overlapStart < overlapEnd) {
|
|
segments = subtractRange(segments, overlapStart, overlapEnd);
|
|
}
|
|
});
|
|
|
|
return segments;
|
|
}
|
|
|
|
function refreshWallSegments(visibleBlocks) {
|
|
visibleBlocks.forEach(item => {
|
|
item.block.querySelectorAll("[data-wall-segment]").forEach(segment => segment.remove());
|
|
["north", "east", "south", "west"].forEach(side => {
|
|
const total = side === "east" || side === "west" ? item.h : item.w;
|
|
visibleEdgeSegments(item, visibleBlocks, side).forEach(segment => {
|
|
renderWallSegment(item.block, side, segment.start, segment.end, total);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function refreshSharedWalls(activePanel) {
|
|
const visibleBlocks = [...activePanel.querySelectorAll("[data-block]")].map(block => ({ block, ...readBlock(block) }));
|
|
refreshWallSegments(visibleBlocks);
|
|
return visibleBlocks;
|
|
}
|
|
|
|
function locationCenter(blocksForLocation) {
|
|
if (blocksForLocation.length === 0) return null;
|
|
const minX = Math.min(...blocksForLocation.map(item => item.x));
|
|
const minY = Math.min(...blocksForLocation.map(item => item.y));
|
|
const maxX = Math.max(...blocksForLocation.map(item => item.x + item.w));
|
|
const maxY = Math.max(...blocksForLocation.map(item => item.y + item.h));
|
|
return { x: (minX + maxX) / 2, y: (minY + maxY) / 2 };
|
|
}
|
|
|
|
function sharedTransitionEdge(fromBlocks, toBlocks) {
|
|
let best = null;
|
|
fromBlocks.forEach(from => {
|
|
toBlocks.forEach(to => {
|
|
if (from.x + from.w === to.x || to.x + to.w === from.x) {
|
|
const start = Math.max(from.y, to.y);
|
|
const end = Math.min(from.y + from.h, to.y + to.h);
|
|
const length = end - start;
|
|
if (length > 0 && (!best || length > best.length)) {
|
|
best = {
|
|
length,
|
|
orientation: "vertical",
|
|
start,
|
|
end,
|
|
x: from.x + from.w === to.x ? to.x : from.x,
|
|
y: start + length / 2
|
|
};
|
|
}
|
|
}
|
|
|
|
if (from.y + from.h === to.y || to.y + to.h === from.y) {
|
|
const start = Math.max(from.x, to.x);
|
|
const end = Math.min(from.x + from.w, to.x + to.w);
|
|
const length = end - start;
|
|
if (length > 0 && (!best || length > best.length)) {
|
|
best = {
|
|
length,
|
|
orientation: "horizontal",
|
|
start,
|
|
end,
|
|
x: start + length / 2,
|
|
y: from.y + from.h === to.y ? to.y : from.y
|
|
};
|
|
}
|
|
}
|
|
});
|
|
});
|
|
return best;
|
|
}
|
|
|
|
function positionTransitions(activePanel, visibleBlocks) {
|
|
const markerPositions = [];
|
|
activePanel.querySelectorAll("[data-transition-marker]").forEach(marker => {
|
|
const fromBlocks = visibleBlocks.filter(item => item.block.dataset.locationId === marker.dataset.fromLocationId);
|
|
const toBlocks = visibleBlocks.filter(item => item.block.dataset.locationId === marker.dataset.toLocationId);
|
|
if (fromBlocks.length === 0 || toBlocks.length === 0) {
|
|
marker.hidden = true;
|
|
return;
|
|
}
|
|
|
|
const sharedEdge = sharedTransitionEdge(fromBlocks, toBlocks);
|
|
const fromCenter = locationCenter(fromBlocks);
|
|
const toCenter = locationCenter(toBlocks);
|
|
marker.hidden = false;
|
|
marker.classList.toggle("is-shared-edge", !!sharedEdge);
|
|
marker.classList.toggle("is-horizontal", sharedEdge?.orientation === "horizontal");
|
|
marker.classList.toggle("is-vertical", sharedEdge?.orientation === "vertical");
|
|
const pairKey = [marker.dataset.fromLocationId, marker.dataset.toLocationId].sort().join("-");
|
|
const key = sharedEdge
|
|
? `${pairKey}:${sharedEdge.orientation}:${sharedEdge.x}:${sharedEdge.y}:${sharedEdge.start}:${sharedEdge.end}`
|
|
: `${pairKey}:fallback`;
|
|
markerPositions.push({ marker, sharedEdge, fromCenter, toCenter, key });
|
|
});
|
|
|
|
const groups = markerPositions.reduce((map, item) => {
|
|
if (!map.has(item.key)) map.set(item.key, []);
|
|
map.get(item.key).push(item);
|
|
return map;
|
|
}, new Map());
|
|
|
|
groups.forEach(group => {
|
|
group.forEach((item, index) => {
|
|
const fraction = (index + 1) / (group.length + 1);
|
|
let x;
|
|
let y;
|
|
if (item.sharedEdge?.orientation === "vertical") {
|
|
x = item.sharedEdge.x;
|
|
y = item.sharedEdge.start + item.sharedEdge.length * fraction;
|
|
} else if (item.sharedEdge?.orientation === "horizontal") {
|
|
x = item.sharedEdge.start + item.sharedEdge.length * fraction;
|
|
y = item.sharedEdge.y;
|
|
} else {
|
|
x = item.fromCenter.x + (item.toCenter.x - item.fromCenter.x) * fraction;
|
|
y = item.fromCenter.y + (item.toCenter.y - item.fromCenter.y) * fraction;
|
|
}
|
|
|
|
item.marker.style.setProperty("--transition-x", x);
|
|
item.marker.style.setProperty("--transition-y", y);
|
|
});
|
|
});
|
|
}
|
|
|
|
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 = refreshSharedWalls(activePanel);
|
|
positionTransitions(activePanel, visibleBlocks);
|
|
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)));
|
|
addBlockForm?.addEventListener("submit", addBlockAsync);
|
|
editor.addEventListener("floor-plan-restore-state", event => restoreWorkspaceState(event.detail || {}));
|
|
workspace?.addEventListener("submit", async event => {
|
|
if (event.defaultPrevented) return;
|
|
const form = event.target;
|
|
if (!(form instanceof HTMLFormElement)) return;
|
|
if (form === editor || form.id === "floor-plan-add-block-form" || form.matches("[data-background-settings-form]")) return;
|
|
|
|
event.preventDefault();
|
|
const state = workspaceState();
|
|
setSaveStatus("saving");
|
|
try {
|
|
const response = await fetch(form.action, {
|
|
method: (form.method || "POST").toUpperCase(),
|
|
body: new FormData(form),
|
|
credentials: "same-origin"
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error("Save failed");
|
|
}
|
|
|
|
const html = await response.text();
|
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
const nextWorkspace = doc.querySelector("[data-floor-plan-workspace]");
|
|
if (!nextWorkspace) {
|
|
throw new Error("The floor plan workspace could not be refreshed.");
|
|
}
|
|
|
|
cleanupModalArtifacts();
|
|
workspace.replaceWith(nextWorkspace);
|
|
window.initializeFloorPlanEditor({
|
|
activeFloorId: state.activeFloorId,
|
|
activeToolboxTab: state.activeToolboxTab,
|
|
selectedBlockId: state.selectedBlockId
|
|
});
|
|
window.requestAnimationFrame(() => {
|
|
const refreshedEditor = document.querySelector("[data-floor-plan-editor]");
|
|
refreshedEditor?.dispatchEvent(new CustomEvent("floor-plan-restore-state", { detail: state }));
|
|
});
|
|
} catch (error) {
|
|
showWorkspaceError(error.message || "Save failed");
|
|
}
|
|
});
|
|
|
|
editor.addEventListener("submit", event => {
|
|
event.preventDefault();
|
|
saveLayoutAsync();
|
|
});
|
|
|
|
blocks.forEach(attachBlockEvents);
|
|
|
|
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 => {
|
|
const updateBlockField = () => {
|
|
if (!selectedBlock) return;
|
|
writeBlock(selectedBlock, readBlock(selectedBlock));
|
|
markDirty();
|
|
};
|
|
input.addEventListener("input", updateBlockField);
|
|
input.addEventListener("change", updateBlockField);
|
|
});
|
|
|
|
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("[data-block-field='location']").forEach(select => {
|
|
select.addEventListener("change", () => {
|
|
if (!selectedBlock) return;
|
|
selectedBlock.dataset.locationId = select.value || "";
|
|
checkOverlaps();
|
|
markDirty();
|
|
});
|
|
});
|
|
|
|
editor.querySelectorAll("[data-floor-width],[data-floor-height]").forEach(input => {
|
|
input.addEventListener("input", () => {
|
|
const floorId = input.dataset.floorWidth || input.dataset.floorHeight;
|
|
updateFloorGrid(floorId);
|
|
markDirty();
|
|
});
|
|
input.addEventListener("change", () => {
|
|
const floorId = input.dataset.floorWidth || input.dataset.floorHeight;
|
|
updateFloorGrid(floorId);
|
|
markDirty();
|
|
});
|
|
});
|
|
|
|
editor.querySelectorAll("[data-floor-level]").forEach(input => {
|
|
const updateFloorLevel = (clampValue = false) => {
|
|
const parsed = Number(input.value);
|
|
if (!Number.isFinite(parsed)) return;
|
|
if (clampValue && Number.isFinite(parsed)) {
|
|
input.value = Math.max(-10, Math.min(parsed, 50));
|
|
}
|
|
refreshAfterSave = true;
|
|
markDirty();
|
|
};
|
|
input.addEventListener("input", () => updateFloorLevel());
|
|
input.addEventListener("change", () => updateFloorLevel(true));
|
|
});
|
|
|
|
editor.querySelectorAll("input, select, textarea").forEach(input => {
|
|
if (input.matches("[form='floor-plan-add-block-form'], [form='floor-plan-add-floor-form']")) return;
|
|
if (input.matches("[form^='floor-plan-background-'], [form^='floor-plan-transition-add-']")) return;
|
|
if (input.matches("[data-background-field]")) return;
|
|
if (input.matches("[data-floor-width],[data-floor-height],[data-floor-level]")) return;
|
|
if (input.matches("[data-block-field='x'],[data-block-field='y'],[data-block-field='w'],[data-block-field='h'],[data-block-field='type'],[data-block-field='location']")) 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);
|
|
if (initialBlock) {
|
|
setActiveFloor(initialBlock.dataset.floorId);
|
|
selectBlock(initialBlock);
|
|
} else if (tabs[0]) {
|
|
setActiveFloor(tabs[0].dataset.floorTab);
|
|
}
|
|
checkOverlaps();
|
|
};
|
|
|
|
window.initializeFloorPlanEditor();
|
|
</script>
|
|
}
|