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
+12
View File
@@ -1,5 +1,17 @@
# Changelog
## 0.2.2
- Drag-and-drop now supports dragging a device from the front elevation onto the rear
one (and back), reassigning its face. Front and rear share one coordinate system
(same unit height, rack offset, ...), just placed side by side on screen, so the
existing delta-based drag math is already valid for a cross-panel drop - only the
target face needed detecting (via `elementFromPoint` at drop time) and sending along.
Fixed a related bug along the way: without `pointer-events: none` on the dragged
block during the drag, `elementFromPoint` would always hit the dragged block itself
rather than the panel underneath, so the cross-face drop would never have been
detected; pointer capture keeps delivering move/up events to it regardless.
## 0.2.1
- Rework PNG export: instead of rasterizing the SVG export via `<img>`/`<canvas>`
+4 -2
View File
@@ -21,8 +21,10 @@ Wenn die Planung steht, wird das Konzept per Knopfdruck in echte NetBox-Objekte
Dazu:
* **Elevation mit Drag & Drop** – Geräte per Maus verschieben, serverseitig validiert.
Ein ungültiger Zug wird abgelehnt und ändert nichts.
* **Elevation mit Drag & Drop** – Geräte per Maus verschieben, auch von der
Front- in die Rear-Ansicht ziehen (und umgekehrt), um sie auf die andere Seite
umzuhängen. Alles serverseitig validiert; ein ungültiger Zug wird abgelehnt und
ändert nichts.
* **Geräte mit Bild und Text** – ist am Gerätetyp ein Front-/Rear-Bild hinterlegt, wird
es im Elevation-Block angezeigt; darüber liegt der Gerätename als weiße, schwarz
konturierte Schrift (kein dunkler Balken). Ein Dropdown über der Elevation schaltet
+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">
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "netbox-rack-concept"
version = "0.2.1"
version = "0.2.2"
description = "Rack blueprints for NetBox: plan racks and their equipment without creating real racks or devices"
readme = "README.md"
requires-python = ">=3.12"