v0.2.2: support dragging a device between front and rear elevations

Front and rear panels already share one coordinate system (same
UNIT_HEIGHT, RACK_X, ...), just placed side by side on screen, so the
existing delta-based drag math is equally valid for a drop on the
other panel - only needed to detect which face the pointer ended up
over (elementFromPoint at drop time) and include it as `face` in the
submitted move; the reorder endpoint already supported changing it.

Also fixed a bug this surfaced: without pointer-events: none on the
dragged block during the drag, elementFromPoint() at drop time always
hit the dragged block itself (it's what's rendered under the cursor),
never the panel actually underneath - cross-face drops would never
have been detected. Pointer capture keeps delivering move/up events to
the block regardless of its own pointer-events value, so this is safe.

Added overflow: visible on the elevation SVGs so a device being
dragged toward the other panel doesn't just vanish at its own SVG's
edge mid-drag.

Updated the German translation for the changed drag-hint string.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 13:50:57 +02:00
co-authored by Claude Sonnet 5
parent 67d999e234
commit 41141ec318
9 changed files with 57 additions and 11 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
from netbox.plugins import PluginConfig
__version__ = '0.2.1'
__version__ = '0.2.2'
class RackConceptConfig(PluginConfig):
Binary file not shown.
@@ -340,9 +340,6 @@ msgstr "Geräte"
msgid "Dimensions"
msgstr "Abmessungen"
msgid "Drag devices to move them"
msgstr "Geräte zum Verschieben ziehen"
msgid "Draw.io (.drawio)"
msgstr "Draw.io (.drawio)"
@@ -926,3 +923,8 @@ msgid "{{ counter }} planned device will be created per rack."
msgid_plural "{{ counter }} planned devices will be created per rack."
msgstr[0] "Pro Rack wird {{ counter }} geplantes Gerät angelegt."
msgstr[1] "Pro Rack werden {{ counter }} geplante Geräte angelegt."
msgid "Drag devices to move or flip them between front and rear"
msgstr ""
"Geräte ziehen, um sie zu verschieben oder zwischen Front und Rückseite zu "
"wechseln"
@@ -10,6 +10,10 @@
height: auto;
user-select: none;
touch-action: none;
/* A device being dragged toward the other face's panel shouldn't just vanish the
moment it crosses this SVG's own edge - let it render past the boundary while
it's mid-drag, into whatever sits next to it (the other panel, side by side). */
overflow: visible;
}
.concept-elevation .unit-slot {
@@ -113,10 +113,18 @@
return Math.min(Math.max(value, min), max);
}
/* Which .concept-elevation panel (front or rear) the pointer is currently over -
used at drop time to tell whether a device was dragged onto the other face. */
function panelAt(clientX, clientY) {
const el = document.elementFromPoint(clientX, clientY);
return el ? el.closest('.concept-elevation') : null;
}
container.querySelectorAll('.concept-elevation').forEach(function (panel) {
const svg = panel.querySelector('svg');
const geo = geometry(svg);
if (!geo) return;
const sourceFace = panel.dataset.face;
panel.querySelectorAll('.device-block:not(.opposite)').forEach(function (block) {
block.addEventListener('pointerdown', onPointerDown);
@@ -139,6 +147,11 @@
let dy = 0;
block.classList.add('dragging');
// Without this, elementFromPoint() at drop time would always hit the
// dragged block itself (it's what's rendered under the cursor), never
// whatever panel is actually underneath it - pointer capture (below)
// keeps move/up events coming to `block` regardless, so this is safe.
block.style.pointerEvents = 'none';
block.setPointerCapture(event.pointerId);
function onMove(moveEvent) {
@@ -152,11 +165,12 @@
block.setAttribute('transform', 'translate(' + dx + ' ' + snappedY + ')');
}
function onUp() {
function onUp(upEvent) {
block.removeEventListener('pointermove', onMove);
block.removeEventListener('pointerup', onUp);
block.removeEventListener('pointercancel', onUp);
block.classList.remove('dragging');
block.style.pointerEvents = '';
if (!moved) {
// Treat it as a click: follow the device link.
@@ -166,6 +180,14 @@
return;
}
// Front and rear share one coordinate system (same UNIT_HEIGHT,
// RACK_X, ...), just placed side by side on screen - the delta
// computed against the source panel is equally valid whichever
// panel the pointer ends up over, so dropping onto the other face
// only needs to swap which face gets submitted.
const targetPanel = panelAt(upEvent.clientX, upEvent.clientY);
const targetFace = (targetPanel && targetPanel.dataset.face) || sourceFace;
const unitDelta = Math.round(dy / geo.unitHeight) * (descUnits ? 1 : -1);
const lowest = startingUnit;
const highest = startingUnit + rackHeight - deviceUnits;
@@ -180,7 +202,8 @@
);
}
submit(block, newPosition, fraction > 1 ? horizontal : null);
submit(block, newPosition, fraction > 1 ? horizontal : null,
targetFace !== sourceFace ? targetFace : null);
}
block.addEventListener('pointermove', onMove);
@@ -190,7 +213,7 @@
});
});
function submit(block, position, horizontalPosition) {
function submit(block, position, horizontalPosition, face) {
const move = {
id: parseInt(block.dataset.deviceId, 10),
position: position
@@ -198,6 +221,9 @@
if (horizontalPosition !== null) {
move.horizontal_position = horizontalPosition;
}
if (face) {
move.face = face;
}
setStatus('Saving…', false);
@@ -138,7 +138,7 @@
<option value="labels-only">{% trans "Labels only" %}</option>
</select>
{% if perms.netbox_rack_concept.change_conceptdevice %}
<span class="badge text-bg-secondary">{% trans "Drag devices to move them" %}</span>
<span class="badge text-bg-secondary">{% trans "Drag devices to move or flip them between front and rear" %}</span>
{% endif %}
<div class="dropdown">
<button type="button" class="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">