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:
@@ -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
|
||||||
|
|||||||
@@ -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,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):
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user