v0.2.3: visible cross-panel drag ghost, front-image fallback, shorter hint

- Fix invisible device while dragging from front to rear: the block
  being dragged stayed structurally inside its own source <svg>, so it
  rendered underneath the other panel once it visually overlapped it.
  Replaced the in-SVG transform with a free-floating HTML ghost fixed
  to <body> that tracks the pointer, unaffected by either SVG's own
  stacking - the original block just dims in place during the drag, as
  before.
- Fix rear elevation showing nothing for a device type with no
  rear_image: only full-depth devices (which have a genuinely
  different back) are restricted to their own rear image now; a
  shallow device (e.g. a PDU) falls back to its front image on the
  rear view, since that's usually the only picture that exists and is
  still recognisable either way.
- Shorten the drag-hint badge text; the German translation of the
  previous, longer wording didn't fit well next to the other controls
  above the elevation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 14:01:42 +02:00
co-authored by Claude Sonnet 5
parent 41141ec318
commit 1793bf13e2
10 changed files with 94 additions and 22 deletions
+16
View File
@@ -1,5 +1,21 @@
# Changelog # Changelog
## 0.2.3
- Fix: dragging a device from front to rear (or back) made it invisible mid-drag - it
stayed structurally inside its own source `<svg>`, so it rendered underneath the
other panel once it overlapped it instead of over it. Replaced the in-SVG transform
with a free-floating HTML "ghost" fixed to `<body>` that tracks the pointer; the
original block just dims in place while dragging, exactly like before.
- Fix: the rear elevation only ever showed a device type's `rear_image`, leaving
shallow devices without one (e.g. PDUs) blank on the rear view even when a perfectly
usable `front_image` exists. Full-depth devices are unaffected (their rear view still
only shows their own rear image, or nothing - showing the front picture there would
be wrong); only non-full-depth devices now fall back to the front image on the rear
view.
- Shorten the drag-hint badge text above the elevation (the German translation in
particular had gotten too long for the badge).
## 0.2.2 ## 0.2.2
- Drag-and-drop now supports dragging a device from the front elevation onto the rear - Drag-and-drop now supports dragging a device from the front elevation onto the rear
+6 -3
View File
@@ -27,9 +27,12 @@ Dazu:
ändert nichts. ändert nichts.
* **Geräte mit Bild und Text** – ist am Gerätetyp ein Front-/Rear-Bild hinterlegt, wird * **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 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 konturierte Schrift (kein dunkler Balken). Auf der Rear-Seite zeigt ein **volltiefes**
zwischen „Images and labels", „Images only" und „Labels only" um — genau wie bei Gerät nur sein eigenes Rear-Bild (oder gar keins); ein **nicht volltiefes** Gerät ohne
echten Racks in NetBox, inklusive Merken der Wahl im Browser. eigenes Rear-Bild (z.B. eine Steckdosenleiste) zeigt dort stattdessen sein Front-Bild.
Ein Dropdown über der Elevation schaltet zwischen „Images and labels", „Images only"
und „Labels only" um — genau wie bei echten Racks in NetBox, inklusive Merken der
Wahl im Browser.
* **Teilbreiten** (1/1, 1/2, 1/3, 1/4 plus horizontale Slot-Position), passend zum * **Teilbreiten** (1/1, 1/2, 1/3, 1/4 plus horizontale Slot-Position), passend zum
Teilbreiten-Feature von `netbox_utilities`. Teilbreiten-Feature von `netbox_utilities`.
* **Mandant und Mandantengruppe** an Konzept und an jedem geplanten Gerät. * **Mandant und Mandantengruppe** an Konzept und an jedem geplanten Gerät.
+1 -1
View File
@@ -1,6 +1,6 @@
from netbox.plugins import PluginConfig from netbox.plugins import PluginConfig
__version__ = '0.2.2' __version__ = '0.2.3'
class RackConceptConfig(PluginConfig): class RackConceptConfig(PluginConfig):
+12 -2
View File
@@ -95,12 +95,22 @@ class ElevationBlock:
The device type's image for the face this block is drawn on, i.e. what's The device type's image for the face this block is drawn on, i.e. what's
physically visible from that side of the rack - not the device's own mounting physically visible from that side of the rack - not the device's own mounting
face, which only matters for half-depth devices occupying one face at a time. face, which only matters for half-depth devices occupying one face at a time.
A full-depth device genuinely has two different sides, so its rear view only
ever shows its own rear image (or nothing, if none is set) - showing the front
picture there would misrepresent what's actually visible from the back. A
shallow device (e.g. a PDU) has no real "back" of its own; for those, the rear
view falls back to the front image, since that's usually the only picture that
exists and is still perfectly recognisable from either side.
""" """
device_type = self.device.device_type device_type = self.device.device_type
if device_type is None: if device_type is None:
return None return None
image = device_type.front_image if self.face == DeviceFaceChoices.FACE_FRONT else device_type.rear_image if self.face == DeviceFaceChoices.FACE_FRONT:
return image if image else None return device_type.front_image or None
if self.device.effective_full_depth:
return device_type.rear_image or None
return device_type.rear_image or device_type.front_image or None
class Elevation: class Elevation:
Binary file not shown.
@@ -924,7 +924,5 @@ msgid_plural "{{ counter }} planned devices will be created per rack."
msgstr[0] "Pro Rack wird {{ counter }} geplantes Gerät angelegt." msgstr[0] "Pro Rack wird {{ counter }} geplantes Gerät angelegt."
msgstr[1] "Pro Rack werden {{ counter }} geplante Geräte angelegt." msgstr[1] "Pro Rack werden {{ counter }} geplante Geräte angelegt."
msgid "Drag devices to move or flip them between front and rear" msgid "Drag to move or flip sides"
msgstr "" msgstr "Ziehen zum Verschieben/Wechseln"
"Geräte ziehen, um sie zu verschieben oder zwischen Front und Rückseite zu "
"wechseln"
@@ -10,10 +10,6 @@
height: auto; height: auto;
user-select: none; user-select: none;
touch-action: 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 { .concept-elevation .unit-slot {
@@ -108,6 +104,26 @@
} }
.concept-elevation .device-block.dragging { .concept-elevation .device-block.dragging {
opacity: 0.6; opacity: 0.3;
cursor: grabbing;
}
/* Follows the pointer during a drag - a plain HTML element fixed to <body>, not
scoped to either elevation's <svg>, so it stays visible the whole way from the
front panel to the rear one and back. */
.concept-drag-ghost {
position: fixed;
z-index: 2000;
pointer-events: none;
display: flex;
align-items: center;
border-radius: 2px;
padding-left: 6px;
font-size: 11px;
color: #ffffff;
text-shadow: 0 0 2px #000000, 0 0 2px #000000, 0 0 2px #000000;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
white-space: nowrap;
overflow: hidden;
cursor: grabbing; cursor: grabbing;
} }
@@ -120,6 +120,31 @@
return el ? el.closest('.concept-elevation') : null; return el ? el.closest('.concept-elevation') : null;
} }
/* A free-floating copy of the device block, fixed to <body> and tracking the
pointer - not scoped to either SVG, so it stays visible while crossing from one
panel to the other. */
function createGhost(block, rectEl, clientX, clientY) {
const screenRect = rectEl.getBoundingClientRect();
const label = block.querySelector('.device-label');
const ghost = document.createElement('div');
ghost.className = 'concept-drag-ghost';
ghost.style.width = screenRect.width + 'px';
ghost.style.height = screenRect.height + 'px';
ghost.style.backgroundColor = rectEl.getAttribute('fill') || '#c0c0c0';
ghost.textContent = label ? label.textContent.trim() : '';
ghost.dataset.offsetX = clientX - screenRect.left;
ghost.dataset.offsetY = clientY - screenRect.top;
document.body.appendChild(ghost);
moveGhost(ghost, clientX, clientY);
return ghost;
}
function moveGhost(ghost, clientX, clientY) {
ghost.style.left = (clientX - ghost.dataset.offsetX) + 'px';
ghost.style.top = (clientY - ghost.dataset.offsetY) + 'px';
}
container.querySelectorAll('.concept-elevation').forEach(function (panel) { container.querySelectorAll('.concept-elevation').forEach(function (panel) {
const svg = panel.querySelector('svg'); const svg = panel.querySelector('svg');
const geo = geometry(svg); const geo = geometry(svg);
@@ -154,6 +179,13 @@
block.style.pointerEvents = 'none'; block.style.pointerEvents = 'none';
block.setPointerCapture(event.pointerId); block.setPointerCapture(event.pointerId);
// A device dragged toward the other panel is still, structurally,
// content of its own source <svg> - it would render underneath the
// other panel's own markup once it overlapped it, effectively
// vanishing. A free-floating HTML ghost fixed to <body> sidesteps that
// entirely: it's not inside either SVG, so it stays visible over both.
const ghost = createGhost(block, rect, event.clientX, event.clientY);
function onMove(moveEvent) { function onMove(moveEvent) {
const now = svgPoint(svg, moveEvent); const now = svgPoint(svg, moveEvent);
dx = now.x - start.x; dx = now.x - start.x;
@@ -161,8 +193,7 @@
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) { if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
moved = true; moved = true;
} }
const snappedY = Math.round(dy / geo.unitHeight) * geo.unitHeight; moveGhost(ghost, moveEvent.clientX, moveEvent.clientY);
block.setAttribute('transform', 'translate(' + dx + ' ' + snappedY + ')');
} }
function onUp(upEvent) { function onUp(upEvent) {
@@ -171,10 +202,10 @@
block.removeEventListener('pointercancel', onUp); block.removeEventListener('pointercancel', onUp);
block.classList.remove('dragging'); block.classList.remove('dragging');
block.style.pointerEvents = ''; block.style.pointerEvents = '';
ghost.remove();
if (!moved) { if (!moved) {
// Treat it as a click: follow the device link. // Treat it as a click: follow the device link.
block.removeAttribute('transform');
const link = block.querySelector('a'); const link = block.querySelector('a');
if (link) window.location = link.getAttribute('href'); if (link) window.location = link.getAttribute('href');
return; return;
@@ -245,12 +276,10 @@
if (result.ok) { if (result.ok) {
window.location.reload(); window.location.reload();
} else { } else {
block.removeAttribute('transform');
setStatus(result.body.error || 'The move was rejected.', true); setStatus(result.body.error || 'The move was rejected.', true);
} }
}) })
.catch(function () { .catch(function () {
block.removeAttribute('transform');
setStatus('The move could not be saved.', true); setStatus('The move could not be saved.', true);
}); });
} }
@@ -138,7 +138,7 @@
<option value="labels-only">{% trans "Labels only" %}</option> <option value="labels-only">{% trans "Labels only" %}</option>
</select> </select>
{% if perms.netbox_rack_concept.change_conceptdevice %} {% if perms.netbox_rack_concept.change_conceptdevice %}
<span class="badge text-bg-secondary">{% trans "Drag devices to move or flip them between front and rear" %}</span> <span class="badge text-bg-secondary">{% trans "Drag to move or flip sides" %}</span>
{% endif %} {% endif %}
<div class="dropdown"> <div class="dropdown">
<button type="button" class="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false"> <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] [project]
name = "netbox-rack-concept" name = "netbox-rack-concept"
version = "0.2.2" version = "0.2.3"
description = "Rack blueprints for NetBox: plan racks and their equipment without creating real racks or devices" description = "Rack blueprints for NetBox: plan racks and their equipment without creating real racks or devices"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"