mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-04 12:01:19 +00:00
bc22dbdb14
## Summary Implements M1 of the draggable panels spec from #608: the `DragManager` class with core drag mechanics. Fixes #608 (M1: DragManager core drag mechanics) ## What's New ### `public/drag-manager.js` (~215 lines) - **State machine:** `IDLE → PENDING → DRAGGING → IDLE` - **5px dead zone** on `.panel-header` to disambiguate click vs drag — prevents hijacking corner toggle and close button clicks - **Pointer events** with `setPointerCapture` for reliable tracking - **`transform: translate()`** during drag — zero layout reflow - **Snap-to-edge** on release: 20px threshold snaps to 12px margin - **Z-index management** — dragged panel comes to front (counter from 1000) - **`_detachFromCorner()`** — transitions panel from M0 corner CSS to fixed positioning - **Escape key** cancels drag and reverts to pre-drag position - **`restorePositions()`** — applies saved viewport percentages on init - **`handleResize()`** — clamps dragged panels inside viewport on window resize - **`enable()`/`disable()`** — responsive gate control ### `public/live.js` integration - Instantiates `DragManager` after `initPanelPositions()` - Registers `liveFeed`, `liveLegend`, `liveNodeDetail` panels - **Responsive gate:** `matchMedia('(pointer: fine) and (min-width: 768px)')` — disables drag on touch/small screens, reverts to M0 corner toggle - **Resize clamping** debounced at 200ms ### `public/live.css` additions - `cursor: grab/grabbing` on `.panel-header` (desktop only via `@media (pointer: fine)`) - `.is-dragging` class: opacity 0.92, elevated box-shadow, `will-change: transform`, transitions disabled - `[data-dragged="true"]` disables corner transition animations - `prefers-reduced-motion` support ### Persistence - **Format:** `panel-drag-{id}` → `{ xPct, yPct }` (viewport percentages) - **Survives resize:** positions recalculated from percentages - **Corner toggle still works:** clicking corner button after drag clears drag state (handled by existing M0 code) ## Tests 14 new unit tests in `test-drag-manager.js`: - State machine transitions (IDLE → PENDING → DRAGGING → IDLE) - Dead zone enforcement - Button click guard (no drag on button pointerdown) - Snap-to-edge behavior - Position persistence as viewport percentages - Restore from localStorage - Resize clamping - Disable/enable ## Performance - `transform: translate()` during drag — compositor-only, no layout reflow - `will-change: transform` only during active drag (`.is-dragging`), removed on drop - `localStorage` write only on `pointerup`, never during `pointermove` - Resize handler debounced at 200ms - Single `style.transform` assignment per pointermove frame — negligible cost --------- Co-authored-by: you <you@example.com>
217 lines
7.2 KiB
JavaScript
217 lines
7.2 KiB
JavaScript
/* drag-manager.js — Free-form panel dragging (#608 M1)
|
|
* State machine: IDLE → PENDING → DRAGGING → IDLE
|
|
* Pointer events on .panel-header, transform: translate() during drag,
|
|
* snap-to-edge on release, z-index on focus, viewport % persistence.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
var DEAD_ZONE = 5; // px — disambiguate click vs drag
|
|
var SNAP_THRESHOLD = 20; // px — snap to edge on release
|
|
var SNAP_MARGIN = 12; // px — margin when snapped
|
|
|
|
function DragManager() {
|
|
this.state = 'IDLE';
|
|
this.activePanel = null;
|
|
this.startX = 0;
|
|
this.startY = 0;
|
|
this.panelStartX = 0;
|
|
this.panelStartY = 0;
|
|
this.preTransform = '';
|
|
this.enabled = true;
|
|
this.zCounter = 1000;
|
|
this._panels = [];
|
|
this._onKeyDown = this._handleKeyDown.bind(this);
|
|
}
|
|
|
|
DragManager.prototype.register = function (panel) {
|
|
if (!panel) return;
|
|
var header = panel.querySelector('.panel-header');
|
|
if (!header) return;
|
|
this._panels.push(panel);
|
|
var self = this;
|
|
|
|
header.addEventListener('pointerdown', function (e) {
|
|
if (!self.enabled) return;
|
|
if (e.button !== 0) return;
|
|
if (e.target.closest('button')) return;
|
|
e.preventDefault();
|
|
header.setPointerCapture(e.pointerId);
|
|
|
|
self.state = 'PENDING';
|
|
self.activePanel = panel;
|
|
self.startX = e.clientX;
|
|
self.startY = e.clientY;
|
|
|
|
var rect = panel.getBoundingClientRect();
|
|
self.panelStartX = rect.left;
|
|
self.panelStartY = rect.top;
|
|
self.preTransform = panel.style.transform || '';
|
|
document.addEventListener('keydown', self._onKeyDown);
|
|
});
|
|
|
|
header.addEventListener('pointermove', function (e) {
|
|
if (self.state === 'IDLE') return;
|
|
if (self.activePanel !== panel) return;
|
|
var dx = e.clientX - self.startX;
|
|
var dy = e.clientY - self.startY;
|
|
|
|
if (self.state === 'PENDING') {
|
|
if (Math.hypot(dx, dy) < DEAD_ZONE) return;
|
|
self.state = 'DRAGGING';
|
|
panel.classList.add('is-dragging');
|
|
panel.style.zIndex = ++self.zCounter;
|
|
self._detachFromCorner(panel);
|
|
}
|
|
|
|
panel.style.transform = 'translate(' + dx + 'px, ' + dy + 'px)';
|
|
});
|
|
|
|
header.addEventListener('pointerup', function (e) {
|
|
if (self.activePanel !== panel) return;
|
|
header.releasePointerCapture(e.pointerId);
|
|
if (self.state === 'DRAGGING') {
|
|
panel.classList.remove('is-dragging');
|
|
self._finalizePosition(panel);
|
|
}
|
|
self._reset();
|
|
});
|
|
|
|
header.addEventListener('pointercancel', function () {
|
|
if (self.activePanel !== panel) return;
|
|
panel.classList.remove('is-dragging');
|
|
if (self.state === 'DRAGGING') {
|
|
self._finalizePosition(panel);
|
|
}
|
|
self._reset();
|
|
});
|
|
};
|
|
|
|
DragManager.prototype._handleKeyDown = function (e) {
|
|
if (e.key === 'Escape' && this.state === 'DRAGGING' && this.activePanel) {
|
|
this.activePanel.classList.remove('is-dragging');
|
|
this.activePanel.style.transform = this.preTransform;
|
|
// Revert: re-attach to corner if it was cornered before
|
|
var saved = localStorage.getItem('panel-drag-' + this.activePanel.id);
|
|
if (!saved) {
|
|
// Was in corner mode — restore corner CSS
|
|
delete this.activePanel.dataset.dragged;
|
|
this.activePanel.style.top = '';
|
|
this.activePanel.style.left = '';
|
|
this.activePanel.style.right = '';
|
|
this.activePanel.style.bottom = '';
|
|
this.activePanel.style.transform = '';
|
|
// Re-apply corner position from M0
|
|
var corner = localStorage.getItem('panel-corner-' + this.activePanel.id);
|
|
if (corner) this.activePanel.setAttribute('data-position', corner);
|
|
} else {
|
|
// Was already dragged — revert to pre-drag position
|
|
this.activePanel.style.transform = 'none';
|
|
}
|
|
this._reset();
|
|
}
|
|
};
|
|
|
|
DragManager.prototype._reset = function () {
|
|
document.removeEventListener('keydown', this._onKeyDown);
|
|
this.state = 'IDLE';
|
|
this.activePanel = null;
|
|
};
|
|
|
|
DragManager.prototype._detachFromCorner = function (panel) {
|
|
var rect = panel.getBoundingClientRect();
|
|
panel.removeAttribute('data-position');
|
|
panel.dataset.dragged = 'true';
|
|
panel.style.position = 'fixed';
|
|
panel.style.top = rect.top + 'px';
|
|
panel.style.left = rect.left + 'px';
|
|
panel.style.right = 'auto';
|
|
panel.style.bottom = 'auto';
|
|
panel.style.transform = 'none';
|
|
};
|
|
|
|
DragManager.prototype._finalizePosition = function (panel) {
|
|
var rect = panel.getBoundingClientRect();
|
|
var vw = window.innerWidth;
|
|
var vh = window.innerHeight;
|
|
|
|
var x = Math.max(0, Math.min(rect.left, vw - 40));
|
|
var y = Math.max(0, Math.min(rect.top, vh - 40));
|
|
|
|
// Snap to edge
|
|
if (x < SNAP_THRESHOLD) x = SNAP_MARGIN;
|
|
if (y < SNAP_THRESHOLD) y = SNAP_MARGIN;
|
|
if (x + rect.width > vw - SNAP_THRESHOLD) x = vw - rect.width - SNAP_MARGIN;
|
|
if (y + rect.height > vh - SNAP_THRESHOLD) y = vh - rect.height - SNAP_MARGIN;
|
|
|
|
panel.style.top = y + 'px';
|
|
panel.style.left = x + 'px';
|
|
panel.style.transform = 'none';
|
|
|
|
this._persist(panel.id, x / vw, y / vh);
|
|
};
|
|
|
|
DragManager.prototype._persist = function (id, xPct, yPct) {
|
|
try {
|
|
localStorage.setItem('panel-drag-' + id,
|
|
JSON.stringify({ xPct: xPct, yPct: yPct }));
|
|
} catch (_) { /* quota exceeded — silent */ }
|
|
};
|
|
|
|
DragManager.prototype.enable = function () { this.enabled = true; };
|
|
DragManager.prototype.disable = function () {
|
|
this.enabled = false;
|
|
if (this.state !== 'IDLE' && this.activePanel) {
|
|
this.activePanel.classList.remove('is-dragging');
|
|
this._reset();
|
|
}
|
|
};
|
|
|
|
DragManager.prototype.restorePositions = function () {
|
|
var panels = this._panels;
|
|
for (var i = 0; i < panels.length; i++) {
|
|
var panel = panels[i];
|
|
var raw = localStorage.getItem('panel-drag-' + panel.id);
|
|
if (!raw) continue;
|
|
try {
|
|
var pos = JSON.parse(raw);
|
|
var x = pos.xPct * window.innerWidth;
|
|
var y = pos.yPct * window.innerHeight;
|
|
panel.removeAttribute('data-position');
|
|
panel.dataset.dragged = 'true';
|
|
panel.style.position = 'fixed';
|
|
panel.style.top = y + 'px';
|
|
panel.style.left = x + 'px';
|
|
panel.style.right = 'auto';
|
|
panel.style.bottom = 'auto';
|
|
panel.style.transform = 'none';
|
|
} catch (_) {
|
|
localStorage.removeItem('panel-drag-' + panel.id);
|
|
}
|
|
}
|
|
};
|
|
|
|
DragManager.prototype.handleResize = function () {
|
|
var panels = document.querySelectorAll('.live-overlay[data-dragged="true"]');
|
|
for (var i = 0; i < panels.length; i++) {
|
|
var panel = panels[i];
|
|
var rect = panel.getBoundingClientRect();
|
|
var vw = window.innerWidth;
|
|
var vh = window.innerHeight;
|
|
var x = rect.left, y = rect.top, moved = false;
|
|
if (rect.right > vw) { x = vw - rect.width - SNAP_MARGIN; moved = true; }
|
|
if (rect.bottom > vh) { y = vh - rect.height - SNAP_MARGIN; moved = true; }
|
|
if (x < 0) { x = SNAP_MARGIN; moved = true; }
|
|
if (y < 0) { y = SNAP_MARGIN; moved = true; }
|
|
if (moved) {
|
|
panel.style.left = x + 'px';
|
|
panel.style.top = y + 'px';
|
|
this._persist(panel.id, x / vw, y / vh);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Export
|
|
window.DragManager = DragManager;
|
|
})();
|