Implemented the Floor Plans UX/data-model refinement pass.
This commit is contained in:
parent
030a4a10da
commit
b2ac7b5eeb
@ -58,13 +58,25 @@ public sealed class FloorPlansController(IFloorPlanService floorPlans) : Control
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> SaveLayout(FloorPlanEditorViewModel model)
|
||||
{
|
||||
var isAsyncSave = string.Equals(Request.Headers["X-Requested-With"], "XMLHttpRequest", StringComparison.OrdinalIgnoreCase);
|
||||
try
|
||||
{
|
||||
await floorPlans.SaveLayoutAsync(model);
|
||||
if (isAsyncSave)
|
||||
{
|
||||
return Json(new { ok = true, message = "Saved" });
|
||||
}
|
||||
|
||||
TempData["FloorPlanMessage"] = "Layout saved.";
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
if (isAsyncSave)
|
||||
{
|
||||
Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
return Json(new { ok = false, message = ex.Message });
|
||||
}
|
||||
|
||||
TempData["FloorPlanError"] = ex.Message;
|
||||
}
|
||||
|
||||
|
||||
@ -1170,6 +1170,7 @@ public sealed class FloorPlanFloor
|
||||
|
||||
public sealed class FloorPlanBlock
|
||||
{
|
||||
// Future L-shape support: ShapeType plus FootWidthCells, FootHeightCells, and CornerPosition can extend rectangles without a freeform polygon editor.
|
||||
public int FloorPlanBlockID { get; set; }
|
||||
public int FloorPlanFloorID { get; set; }
|
||||
public int LocationID { get; set; }
|
||||
|
||||
37
PlotLine/Sql/073_FloorPlanDeleteProcedureSettings.sql
Normal file
37
PlotLine/Sql/073_FloorPlanDeleteProcedureSettings.sql
Normal file
@ -0,0 +1,37 @@
|
||||
SET ANSI_NULLS ON;
|
||||
GO
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.FloorPlan_Delete
|
||||
@FloorPlanID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DELETE dbo.FloorPlans
|
||||
WHERE FloorPlanID = @FloorPlanID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.FloorPlanFloor_Delete
|
||||
@FloorPlanFloorID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DELETE dbo.FloorPlanFloors
|
||||
WHERE FloorPlanFloorID = @FloorPlanFloorID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.FloorPlanBlock_Delete
|
||||
@FloorPlanBlockID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DELETE dbo.FloorPlanBlocks
|
||||
WHERE FloorPlanBlockID = @FloorPlanBlockID;
|
||||
END;
|
||||
GO
|
||||
@ -34,7 +34,7 @@
|
||||
<div class="alert alert-danger">@error</div>
|
||||
}
|
||||
|
||||
<section class="edit-panel floor-plan-meta">
|
||||
<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" />
|
||||
@ -114,8 +114,9 @@ else
|
||||
}
|
||||
</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">Save layout</button>
|
||||
<button class="btn btn-primary btn-sm" type="submit" data-save-layout-button>Save layout</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -154,7 +155,14 @@ else
|
||||
</section>
|
||||
|
||||
<aside class="floor-plan-side-panel">
|
||||
<section class="floor-plan-tool-card">
|
||||
<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-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" />
|
||||
@ -202,7 +210,7 @@ else
|
||||
<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">
|
||||
<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>
|
||||
@ -258,7 +266,7 @@ else
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="floor-plan-tool-card">
|
||||
<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>
|
||||
@ -314,11 +322,13 @@ else
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="button-row mt-3">
|
||||
<button class="btn btn-primary" type="submit">Save layout</button>
|
||||
<button class="btn btn-primary" type="submit" data-save-layout-button>Save layout</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -399,14 +409,125 @@ else
|
||||
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;
|
||||
|
||||
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));
|
||||
@ -454,6 +575,7 @@ else
|
||||
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) {
|
||||
@ -538,7 +660,33 @@ else
|
||||
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 => {
|
||||
@ -574,13 +722,20 @@ else
|
||||
}
|
||||
});
|
||||
|
||||
editor.addEventListener("pointerup", () => pointerState = null);
|
||||
editor.addEventListener("pointercancel", () => pointerState = null);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -592,15 +747,20 @@ else
|
||||
.filter(name => !name.startsWith("floor-plan-block--"))
|
||||
.concat(`floor-plan-block--${select.value.toLowerCase()}`)
|
||||
.join(" ");
|
||||
markDirty();
|
||||
});
|
||||
});
|
||||
|
||||
sizePreset?.addEventListener("change", () => {
|
||||
const [width, height] = (sizePreset.value || "2x1").split("x");
|
||||
if (addBlockWidth) addBlockWidth.value = width || "2";
|
||||
if (addBlockHeight) addBlockHeight.value = height || "1";
|
||||
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-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);
|
||||
});
|
||||
sizePreset?.dispatchEvent(new Event("change"));
|
||||
|
||||
sizePreset?.addEventListener("change", applySizePreset);
|
||||
addBlockType?.addEventListener("change", updateSizePresets);
|
||||
updateSizePresets();
|
||||
addBlockModes.forEach(input => input.addEventListener("change", updateAddBlockMode));
|
||||
updateAddBlockMode();
|
||||
|
||||
|
||||
@ -4479,6 +4479,10 @@ body.dragging-location [data-drag-type="location"] {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.floor-plan-meta-wide {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.floor-plan-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@ -4527,6 +4531,34 @@ body.dragging-location [data-drag-type="location"] {
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.floor-plan-save-status {
|
||||
border: 1px solid rgba(47, 111, 99, .2);
|
||||
border-radius: 999px;
|
||||
color: var(--plotline-accent-dark, #22534a);
|
||||
background: rgba(47, 111, 99, .08);
|
||||
font-size: .78rem;
|
||||
font-weight: 800;
|
||||
padding: 4px 9px;
|
||||
}
|
||||
|
||||
.floor-plan-save-status[data-save-state="dirty"] {
|
||||
border-color: rgba(184, 118, 43, .32);
|
||||
color: #7b4b12;
|
||||
background: rgba(251, 236, 211, .72);
|
||||
}
|
||||
|
||||
.floor-plan-save-status[data-save-state="saving"] {
|
||||
border-color: rgba(51, 117, 155, .3);
|
||||
color: #245b78;
|
||||
background: rgba(233, 243, 248, .82);
|
||||
}
|
||||
|
||||
.floor-plan-save-status[data-save-state="failed"] {
|
||||
border-color: rgba(163, 61, 61, .32);
|
||||
color: #8a3434;
|
||||
background: rgba(255, 232, 232, .9);
|
||||
}
|
||||
|
||||
.floor-plan-floor {
|
||||
display: none;
|
||||
}
|
||||
@ -4647,8 +4679,6 @@ body.dragging-location [data-drag-type="location"] {
|
||||
|
||||
.floor-plan-side-panel {
|
||||
align-self: start;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
position: sticky;
|
||||
top: 82px;
|
||||
}
|
||||
@ -4663,6 +4693,47 @@ body.dragging-location [data-drag-type="location"] {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.floor-plan-toolbox {
|
||||
background: var(--panel-bg, #fff);
|
||||
border: 1px solid var(--border-color, #d8ded9);
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
max-height: calc(100vh - 250px);
|
||||
min-height: min(58vh, 520px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.floor-plan-toolbox-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
border-bottom: 1px solid var(--border-color, #d8ded9);
|
||||
}
|
||||
|
||||
.floor-plan-toolbox-tab {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--border-color, #d8ded9);
|
||||
color: inherit;
|
||||
font-size: .82rem;
|
||||
font-weight: 800;
|
||||
padding: 10px 8px;
|
||||
}
|
||||
|
||||
.floor-plan-toolbox-tab:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.floor-plan-toolbox-tab.active {
|
||||
background: rgba(47, 111, 99, .1);
|
||||
color: var(--plotline-accent-dark, #22534a);
|
||||
}
|
||||
|
||||
.floor-plan-toolbox-body {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.floor-plan-tool-card {
|
||||
background: var(--panel-bg, #fff);
|
||||
border: 1px solid var(--border-color, #d8ded9);
|
||||
@ -4670,6 +4741,16 @@ body.dragging-location [data-drag-type="location"] {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.floor-plan-toolbox .floor-plan-tool-card {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.floor-plan-tool-card[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.floor-plan-tool-card p {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
@ -4718,11 +4799,24 @@ body.dragging-location [data-drag-type="location"] {
|
||||
|
||||
[data-bs-theme="dark"] .floor-plan-editor-toolbar,
|
||||
.dark .floor-plan-editor-toolbar,
|
||||
[data-bs-theme="dark"] .floor-plan-toolbox,
|
||||
.dark .floor-plan-toolbox,
|
||||
[data-bs-theme="dark"] .floor-plan-tool-card,
|
||||
.dark .floor-plan-tool-card {
|
||||
background: var(--panel-bg, #1f2522);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .floor-plan-toolbox .floor-plan-tool-card,
|
||||
.dark .floor-plan-toolbox .floor-plan-tool-card {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .floor-plan-toolbox-tab.active,
|
||||
.dark .floor-plan-toolbox-tab.active {
|
||||
background: rgba(212, 154, 98, .16);
|
||||
color: var(--plotline-accent-dark, #f0c99d);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .floor-plan-block,
|
||||
.dark .floor-plan-block {
|
||||
color: #f1f6f3;
|
||||
@ -4766,4 +4860,8 @@ body.dragging-location [data-drag-type="location"] {
|
||||
.floor-plan-side-panel {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.floor-plan-toolbox {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
2
PlotLine/wwwroot/css/site.min.css
vendored
2
PlotLine/wwwroot/css/site.min.css
vendored
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user