Fixed multiple floor plan transitions between same source and target.
This commit is contained in:
parent
ee504d878f
commit
0944874510
@ -219,20 +219,66 @@ public sealed class FloorPlansController(IFloorPlanService floorPlans) : Control
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> AddBlock(int floorPlanId, FloorPlanBlockEditViewModel model)
|
||||
{
|
||||
var isAsyncSave = string.Equals(Request.Headers["X-Requested-With"], "XMLHttpRequest", StringComparison.OrdinalIgnoreCase);
|
||||
try
|
||||
{
|
||||
if (ModelState.IsValid)
|
||||
{
|
||||
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.";
|
||||
return RedirectToAction(nameof(Edit), new { id = floorPlanId, selectedBlockId = floorPlanBlockId });
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
if (isAsyncSave)
|
||||
{
|
||||
Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
return Json(new { ok = false, message = 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 });
|
||||
}
|
||||
|
||||
|
||||
@ -830,7 +830,7 @@ else
|
||||
const floorEditors = [...editor.querySelectorAll("[data-floor-editor]")];
|
||||
const backgroundEditors = [...editor.querySelectorAll("[data-background-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 warning = editor.querySelector("[data-overlap-warning]");
|
||||
const addBlockFloor = document.querySelector("[data-add-block-floor-id]");
|
||||
@ -843,6 +843,7 @@ else
|
||||
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]");
|
||||
@ -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) {
|
||||
tabs.forEach(tab => tab.classList.toggle("active", tab.dataset.floorTab === floorId));
|
||||
panels.forEach(panel => panel.classList.toggle("active", panel.dataset.floorPanel === floorId));
|
||||
@ -1069,15 +1089,240 @@ else
|
||||
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;
|
||||
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}"]`);
|
||||
@ -1171,6 +1416,9 @@ else
|
||||
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
|
||||
};
|
||||
@ -1184,6 +1432,9 @@ else
|
||||
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
|
||||
};
|
||||
@ -1195,6 +1446,7 @@ else
|
||||
}
|
||||
|
||||
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);
|
||||
@ -1208,8 +1460,38 @@ else
|
||||
const toCenter = locationCenter(toBlocks);
|
||||
marker.hidden = false;
|
||||
marker.classList.toggle("is-shared-edge", !!sharedEdge);
|
||||
marker.style.setProperty("--transition-x", sharedEdge ? sharedEdge.x : (fromCenter.x + toCenter.x) / 2);
|
||||
marker.style.setProperty("--transition-y", sharedEdge ? sharedEdge.y : (fromCenter.y + toCenter.y) / 2);
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -1266,25 +1548,14 @@ else
|
||||
|
||||
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("submit", event => {
|
||||
event.preventDefault();
|
||||
saveLayoutAsync();
|
||||
});
|
||||
|
||||
blocks.forEach(block => {
|
||||
block.addEventListener("pointerdown", event => {
|
||||
selectBlock(block);
|
||||
const grid = gridFor(block);
|
||||
if (!grid) return;
|
||||
const start = cellFromPointer(grid, event);
|
||||
const current = readBlock(block);
|
||||
const resizing = event.target.matches("[data-resize-handle]");
|
||||
pointerState = { block, grid, start, current, resizing };
|
||||
block.setPointerCapture(event.pointerId);
|
||||
event.preventDefault();
|
||||
});
|
||||
});
|
||||
blocks.forEach(attachBlockEvents);
|
||||
|
||||
editor.addEventListener("pointermove", event => {
|
||||
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 => {
|
||||
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]")) 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);
|
||||
|
||||
@ -4663,19 +4663,19 @@ body.dragging-location [data-drag-type="location"] {
|
||||
}
|
||||
|
||||
.floor-plan-block.merge-east {
|
||||
border-right-color: transparent;
|
||||
border-right-color: rgba(44, 84, 75, .28);
|
||||
}
|
||||
|
||||
.floor-plan-block.merge-west {
|
||||
border-left-color: transparent;
|
||||
border-left-color: rgba(44, 84, 75, .28);
|
||||
}
|
||||
|
||||
.floor-plan-block.merge-north {
|
||||
border-top-color: transparent;
|
||||
border-top-color: rgba(44, 84, 75, .28);
|
||||
}
|
||||
|
||||
.floor-plan-block.merge-south {
|
||||
border-bottom-color: transparent;
|
||||
border-bottom-color: rgba(44, 84, 75, .28);
|
||||
}
|
||||
|
||||
.floor-plan-block.selected.merge-east,
|
||||
|
||||
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