Fixed multiple floor plan transitions between same source and target.

This commit is contained in:
Nick Beckley 2026-06-21 18:22:03 +01:00
parent ee504d878f
commit 0944874510
4 changed files with 354 additions and 23 deletions

View File

@ -219,20 +219,66 @@ public sealed class FloorPlansController(IFloorPlanService floorPlans) : Control
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> AddBlock(int floorPlanId, FloorPlanBlockEditViewModel model) public async Task<IActionResult> AddBlock(int floorPlanId, FloorPlanBlockEditViewModel model)
{ {
var isAsyncSave = string.Equals(Request.Headers["X-Requested-With"], "XMLHttpRequest", StringComparison.OrdinalIgnoreCase);
try try
{ {
if (ModelState.IsValid) if (ModelState.IsValid)
{ {
var floorPlanBlockId = await floorPlans.SaveBlockAsync(model); var floorPlanBlockId = await floorPlans.SaveBlockAsync(model);
if (isAsyncSave)
{
var editor = await floorPlans.GetEditorAsync(floorPlanId);
var block = editor?.Blocks.FirstOrDefault(x => x.FloorPlanBlockID == floorPlanBlockId);
if (block is null)
{
Response.StatusCode = StatusCodes.Status400BadRequest;
return Json(new { ok = false, message = "Block was saved but could not be loaded." });
}
return Json(new
{
ok = true,
message = "Saved",
block = new
{
block.FloorPlanBlockID,
block.FloorPlanFloorID,
block.LocationID,
block.LocationName,
block.LocationPath,
block.DisplayLabel,
block.X,
block.Y,
block.WidthCells,
block.HeightCells,
block.BlockType,
block.LabelOverride,
block.Notes
}
});
}
TempData["FloorPlanMessage"] = "Block added."; TempData["FloorPlanMessage"] = "Block added.";
return RedirectToAction(nameof(Edit), new { id = floorPlanId, selectedBlockId = floorPlanBlockId }); return RedirectToAction(nameof(Edit), new { id = floorPlanId, selectedBlockId = floorPlanBlockId });
} }
} }
catch (InvalidOperationException ex) catch (InvalidOperationException ex)
{ {
if (isAsyncSave)
{
Response.StatusCode = StatusCodes.Status400BadRequest;
return Json(new { ok = false, message = ex.Message });
}
TempData["FloorPlanError"] = ex.Message; TempData["FloorPlanError"] = ex.Message;
} }
if (isAsyncSave)
{
Response.StatusCode = StatusCodes.Status400BadRequest;
return Json(new { ok = false, message = "Block could not be added." });
}
return RedirectToAction(nameof(Edit), new { id = floorPlanId }); return RedirectToAction(nameof(Edit), new { id = floorPlanId });
} }

View File

@ -830,7 +830,7 @@ else
const floorEditors = [...editor.querySelectorAll("[data-floor-editor]")]; const floorEditors = [...editor.querySelectorAll("[data-floor-editor]")];
const backgroundEditors = [...editor.querySelectorAll("[data-background-editor]")]; const backgroundEditors = [...editor.querySelectorAll("[data-background-editor]")];
const transitionEditors = [...editor.querySelectorAll("[data-transition-editor]")]; const transitionEditors = [...editor.querySelectorAll("[data-transition-editor]")];
const blocks = [...editor.querySelectorAll("[data-block]")]; let blocks = [...editor.querySelectorAll("[data-block]")];
const noSelection = editor.querySelector("[data-no-selection]"); const noSelection = editor.querySelector("[data-no-selection]");
const warning = editor.querySelector("[data-overlap-warning]"); const warning = editor.querySelector("[data-overlap-warning]");
const addBlockFloor = document.querySelector("[data-add-block-floor-id]"); const addBlockFloor = document.querySelector("[data-add-block-floor-id]");
@ -843,6 +843,7 @@ else
const sizePreset = document.querySelector("[data-size-preset]"); const sizePreset = document.querySelector("[data-size-preset]");
const addBlockWidth = document.querySelector("[data-add-block-width]"); const addBlockWidth = document.querySelector("[data-add-block-width]");
const addBlockHeight = document.querySelector("[data-add-block-height]"); 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 toolboxTabs = [...editor.querySelectorAll("[data-toolbox-tab]")];
const toolboxPanels = [...editor.querySelectorAll("[data-toolbox-panel]")]; const toolboxPanels = [...editor.querySelectorAll("[data-toolbox-panel]")];
const saveStatus = editor.querySelector("[data-save-status]"); const saveStatus = editor.querySelector("[data-save-status]");
@ -958,6 +959,25 @@ else
} }
} }
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) { function setActiveFloor(floorId) {
tabs.forEach(tab => tab.classList.toggle("active", tab.dataset.floorTab === floorId)); tabs.forEach(tab => tab.classList.toggle("active", tab.dataset.floorTab === floorId));
panels.forEach(panel => panel.classList.toggle("active", panel.dataset.floorPanel === floorId)); panels.forEach(panel => panel.classList.toggle("active", panel.dataset.floorPanel === floorId));
@ -1069,14 +1089,239 @@ else
newLocationPanel?.classList.toggle("d-none", !isCreate); newLocationPanel?.classList.toggle("d-none", !isCreate);
} }
function selectBlock(block) { 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;
input.addEventListener("change", () => {
if (!selectedBlock) return;
writeBlock(selectedBlock, readBlock(selectedBlock));
markDirty();
});
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 gridWrap = editor.querySelector(".floor-plan-floor.active .floor-plan-grid-wrap");
const toolboxBody = editor.querySelector(".floor-plan-toolbox-body");
const scrollState = {
pageX: window.scrollX,
pageY: window.scrollY,
gridLeft: gridWrap?.scrollLeft || 0,
gridTop: gridWrap?.scrollTop || 0,
toolboxTop: toolboxBody?.scrollTop || 0
};
const restoreScroll = () => {
if (gridWrap) {
gridWrap.scrollLeft = scrollState.gridLeft;
gridWrap.scrollTop = scrollState.gridTop;
}
if (toolboxBody) {
toolboxBody.scrollTop = scrollState.toolboxTop;
}
window.scrollTo(scrollState.pageX, scrollState.pageY);
document.documentElement.scrollTop = scrollState.pageY;
document.body.scrollTop = scrollState.pageY;
};
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.");
}
const block = addBlockToGrid(result.block);
if (block) {
selectBlock(block, { showEditor: false });
}
restoreScroll();
window.requestAnimationFrame(restoreScroll);
window.setTimeout(restoreScroll, 50);
window.setTimeout(restoreScroll, 250);
const activeFloorId = panels.find(panel => panel.classList.contains("active"))?.dataset.floorPanel;
addBlockForm.reset();
if (addBlockFloor && activeFloorId) addBlockFloor.value = activeFloorId;
updateAddBlockMode();
updateSizePresets();
setSaveStatus("saved");
} catch (error) {
setSaveStatus("failed", error.message || "Save failed");
}
}
function selectBlock(block, options = {}) {
const showEditor = options.showEditor !== false;
selectedBlock = block; selectedBlock = block;
blocks.forEach(item => item.classList.toggle("selected", item === block)); blocks.forEach(item => item.classList.toggle("selected", item === block));
editor.querySelectorAll("[data-block-editor]").forEach(panel => panel.classList.add("d-none")); editor.querySelectorAll("[data-block-editor]").forEach(panel => panel.classList.add("d-none"));
const panel = editor.querySelector(`[data-block-editor="${block.dataset.block}"]`); const panel = editor.querySelector(`[data-block-editor="${block.dataset.block}"]`);
if (panel) panel.classList.remove("d-none"); if (panel) panel.classList.remove("d-none");
if (noSelection) noSelection.classList.add("d-none"); if (noSelection) noSelection.classList.add("d-none");
setToolboxTab("selected"); if (showEditor) {
setToolboxTab("selected");
}
} }
function fieldsFor(block) { function fieldsFor(block) {
@ -1171,6 +1416,9 @@ else
if (length > 0 && (!best || length > best.length)) { if (length > 0 && (!best || length > best.length)) {
best = { best = {
length, length,
orientation: "vertical",
start,
end,
x: from.x + from.w === to.x ? to.x : from.x, x: from.x + from.w === to.x ? to.x : from.x,
y: start + length / 2 y: start + length / 2
}; };
@ -1184,6 +1432,9 @@ else
if (length > 0 && (!best || length > best.length)) { if (length > 0 && (!best || length > best.length)) {
best = { best = {
length, length,
orientation: "horizontal",
start,
end,
x: start + length / 2, x: start + length / 2,
y: from.y + from.h === to.y ? to.y : from.y y: from.y + from.h === to.y ? to.y : from.y
}; };
@ -1195,6 +1446,7 @@ else
} }
function positionTransitions(activePanel, visibleBlocks) { function positionTransitions(activePanel, visibleBlocks) {
const markerPositions = [];
activePanel.querySelectorAll("[data-transition-marker]").forEach(marker => { activePanel.querySelectorAll("[data-transition-marker]").forEach(marker => {
const fromBlocks = visibleBlocks.filter(item => item.block.dataset.locationId === marker.dataset.fromLocationId); const fromBlocks = visibleBlocks.filter(item => item.block.dataset.locationId === marker.dataset.fromLocationId);
const toBlocks = visibleBlocks.filter(item => item.block.dataset.locationId === marker.dataset.toLocationId); const toBlocks = visibleBlocks.filter(item => item.block.dataset.locationId === marker.dataset.toLocationId);
@ -1208,8 +1460,38 @@ else
const toCenter = locationCenter(toBlocks); const toCenter = locationCenter(toBlocks);
marker.hidden = false; marker.hidden = false;
marker.classList.toggle("is-shared-edge", !!sharedEdge); marker.classList.toggle("is-shared-edge", !!sharedEdge);
marker.style.setProperty("--transition-x", sharedEdge ? sharedEdge.x : (fromCenter.x + toCenter.x) / 2); const pairKey = [marker.dataset.fromLocationId, marker.dataset.toLocationId].sort().join("-");
marker.style.setProperty("--transition-y", sharedEdge ? sharedEdge.y : (fromCenter.y + toCenter.y) / 2); 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);
});
}); });
} }
@ -1266,25 +1548,14 @@ else
tabs.forEach(tab => tab.addEventListener("click", () => setActiveFloor(tab.dataset.floorTab))); tabs.forEach(tab => tab.addEventListener("click", () => setActiveFloor(tab.dataset.floorTab)));
toolboxTabs.forEach(tab => tab.addEventListener("click", () => setToolboxTab(tab.dataset.toolboxTab))); toolboxTabs.forEach(tab => tab.addEventListener("click", () => setToolboxTab(tab.dataset.toolboxTab)));
addBlockForm?.addEventListener("submit", addBlockAsync);
editor.addEventListener("submit", event => { editor.addEventListener("submit", event => {
event.preventDefault(); event.preventDefault();
saveLayoutAsync(); saveLayoutAsync();
}); });
blocks.forEach(block => { blocks.forEach(attachBlockEvents);
block.addEventListener("pointerdown", event => {
selectBlock(block);
const grid = gridFor(block);
if (!grid) return;
const start = cellFromPointer(grid, event);
const current = readBlock(block);
const resizing = event.target.matches("[data-resize-handle]");
pointerState = { block, grid, start, current, resizing };
block.setPointerCapture(event.pointerId);
event.preventDefault();
});
});
editor.addEventListener("pointermove", event => { editor.addEventListener("pointermove", event => {
if (!pointerState) return; if (!pointerState) return;
@ -1344,10 +1615,24 @@ else
}); });
}); });
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("input, select, textarea").forEach(input => { 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-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("[form^='floor-plan-background-'], [form^='floor-plan-transition-add-']")) return;
if (input.matches("[data-background-field]")) return; if (input.matches("[data-background-field]")) return;
if (input.matches("[data-floor-width],[data-floor-height]")) 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; 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"; const eventName = input.tagName === "TEXTAREA" || input.type === "text" ? "input" : "change";
input.addEventListener(eventName, markDirty); input.addEventListener(eventName, markDirty);

View File

@ -4663,19 +4663,19 @@ body.dragging-location [data-drag-type="location"] {
} }
.floor-plan-block.merge-east { .floor-plan-block.merge-east {
border-right-color: transparent; border-right-color: rgba(44, 84, 75, .28);
} }
.floor-plan-block.merge-west { .floor-plan-block.merge-west {
border-left-color: transparent; border-left-color: rgba(44, 84, 75, .28);
} }
.floor-plan-block.merge-north { .floor-plan-block.merge-north {
border-top-color: transparent; border-top-color: rgba(44, 84, 75, .28);
} }
.floor-plan-block.merge-south { .floor-plan-block.merge-south {
border-bottom-color: transparent; border-bottom-color: rgba(44, 84, 75, .28);
} }
.floor-plan-block.selected.merge-east, .floor-plan-block.selected.merge-east,

File diff suppressed because one or more lines are too long