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
+1 -1
View File
@@ -1,6 +1,6 @@
from netbox.plugins import PluginConfig
__version__ = '0.2.2'
__version__ = '0.2.3'
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
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.
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
if device_type is None:
return None
image = device_type.front_image if self.face == DeviceFaceChoices.FACE_FRONT else device_type.rear_image
return image if image else None
if self.face == DeviceFaceChoices.FACE_FRONT:
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:
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[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"
msgid "Drag to move or flip sides"
msgstr "Ziehen zum Verschieben/Wechseln"
@@ -10,10 +10,6 @@
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 {
@@ -108,6 +104,26 @@
}
.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;
}
@@ -120,6 +120,31 @@
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) {
const svg = panel.querySelector('svg');
const geo = geometry(svg);
@@ -154,6 +179,13 @@
block.style.pointerEvents = 'none';
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) {
const now = svgPoint(svg, moveEvent);
dx = now.x - start.x;
@@ -161,8 +193,7 @@
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
moved = true;
}
const snappedY = Math.round(dy / geo.unitHeight) * geo.unitHeight;
block.setAttribute('transform', 'translate(' + dx + ' ' + snappedY + ')');
moveGhost(ghost, moveEvent.clientX, moveEvent.clientY);
}
function onUp(upEvent) {
@@ -171,10 +202,10 @@
block.removeEventListener('pointercancel', onUp);
block.classList.remove('dragging');
block.style.pointerEvents = '';
ghost.remove();
if (!moved) {
// Treat it as a click: follow the device link.
block.removeAttribute('transform');
const link = block.querySelector('a');
if (link) window.location = link.getAttribute('href');
return;
@@ -245,12 +276,10 @@
if (result.ok) {
window.location.reload();
} else {
block.removeAttribute('transform');
setStatus(result.body.error || 'The move was rejected.', true);
}
})
.catch(function () {
block.removeAttribute('transform');
setStatus('The move could not be saved.', true);
});
}
@@ -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 or flip them between front and rear" %}</span>
<span class="badge text-bg-secondary">{% trans "Drag to move or flip sides" %}</span>
{% endif %}
<div class="dropdown">
<button type="button" class="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">