mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-01 20:28:24 +00:00
## 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>
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
/* 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;
|
||||
})();
|
||||
@@ -109,6 +109,7 @@
|
||||
<script src="audio-v1-constellation.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v2-constellation.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-lab.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="drag-manager.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-detail.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
|
||||
@@ -849,3 +849,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ── Drag Manager (#608 M1) ── */
|
||||
|
||||
/* Panel header as drag handle — desktop pointer devices only */
|
||||
@media (pointer: fine) {
|
||||
.live-overlay .panel-header {
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.live-overlay.is-dragging .panel-header {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.live-overlay .panel-header:hover {
|
||||
background: var(--bg-hover, rgba(255, 255, 255, 0.03));
|
||||
}
|
||||
}
|
||||
|
||||
/* Panel during drag */
|
||||
.live-overlay.is-dragging {
|
||||
opacity: 0.92;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
will-change: transform;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* Freely placed panel — no corner transition animations */
|
||||
.live-overlay[data-dragged="true"] {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.live-overlay.is-dragging,
|
||||
.live-overlay[data-dragged="true"] {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1161,6 +1161,48 @@
|
||||
// Populate role legend from shared roles.js
|
||||
// Initialize panel corner positions (#608 M0)
|
||||
initPanelPositions();
|
||||
|
||||
// Initialize DragManager for free-form panel dragging (#608 M1)
|
||||
if (window.DragManager) {
|
||||
var dragMgr = new DragManager();
|
||||
var dragPanels = ['liveFeed', 'liveLegend', 'liveNodeDetail'];
|
||||
for (var di = 0; di < dragPanels.length; di++) {
|
||||
dragMgr.register(document.getElementById(dragPanels[di]));
|
||||
}
|
||||
dragMgr.restorePositions();
|
||||
|
||||
// Responsive gate: disable drag below medium breakpoint or on touch
|
||||
var dragMql = window.matchMedia('(pointer: fine) and (min-width: 768px)');
|
||||
function onDragMediaChange(e) {
|
||||
if (!e.matches) {
|
||||
// Revert dragged panels to corner positions
|
||||
document.querySelectorAll('.live-overlay[data-dragged="true"]').forEach(function (p) {
|
||||
delete p.dataset.dragged;
|
||||
p.style.transform = '';
|
||||
p.style.top = '';
|
||||
p.style.left = '';
|
||||
p.style.right = '';
|
||||
p.style.bottom = '';
|
||||
});
|
||||
initPanelPositions();
|
||||
dragMgr.disable();
|
||||
} else {
|
||||
dragMgr.enable();
|
||||
dragMgr.restorePositions();
|
||||
}
|
||||
}
|
||||
dragMql.addEventListener('change', onDragMediaChange);
|
||||
// Initial check
|
||||
if (!dragMql.matches) dragMgr.disable();
|
||||
|
||||
// Resize clamping (debounced)
|
||||
var resizeTimer = null;
|
||||
window.addEventListener('resize', function () {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(function () { dragMgr.handleResize(); }, 200);
|
||||
});
|
||||
}
|
||||
|
||||
const roleLegendList = document.getElementById('roleLegendList');
|
||||
if (roleLegendList) {
|
||||
for (const role of (window.ROLE_SORT || ['repeater', 'companion', 'room', 'sensor', 'observer'])) {
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
/* test-drag-manager.js — Unit tests for DragManager (#608 M1) */
|
||||
'use strict';
|
||||
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const assert = require('assert');
|
||||
|
||||
// Minimal DOM shim
|
||||
function makePanel(id) {
|
||||
const listeners = {};
|
||||
const style = {};
|
||||
const dataset = {};
|
||||
const classList = {
|
||||
_set: new Set(),
|
||||
add(c) { this._set.add(c); },
|
||||
remove(c) { this._set.delete(c); },
|
||||
contains(c) { return this._set.has(c); }
|
||||
};
|
||||
let attrs = {};
|
||||
const header = {
|
||||
_listeners: {},
|
||||
addEventListener(ev, fn) {
|
||||
if (!this._listeners[ev]) this._listeners[ev] = [];
|
||||
this._listeners[ev].push(fn);
|
||||
},
|
||||
setPointerCapture() {},
|
||||
releasePointerCapture() {},
|
||||
_fire(ev, data) {
|
||||
(this._listeners[ev] || []).forEach(fn => fn(data));
|
||||
}
|
||||
};
|
||||
return {
|
||||
id: id,
|
||||
style: style,
|
||||
dataset: dataset,
|
||||
classList: classList,
|
||||
querySelector(sel) {
|
||||
if (sel === '.panel-header') return header;
|
||||
return null;
|
||||
},
|
||||
getAttribute(k) { return attrs[k] || null; },
|
||||
setAttribute(k, v) { attrs[k] = v; },
|
||||
removeAttribute(k) { delete attrs[k]; },
|
||||
getBoundingClientRect() {
|
||||
return {
|
||||
left: parseFloat(style.left) || 0,
|
||||
top: parseFloat(style.top) || 0,
|
||||
right: (parseFloat(style.left) || 0) + 300,
|
||||
bottom: (parseFloat(style.top) || 0) + 200,
|
||||
width: 300,
|
||||
height: 200
|
||||
};
|
||||
},
|
||||
_header: header
|
||||
};
|
||||
}
|
||||
|
||||
// Mock globals
|
||||
const storage = {};
|
||||
const mockWindow = {
|
||||
innerWidth: 1920,
|
||||
innerHeight: 1080,
|
||||
DragManager: null,
|
||||
matchMedia() { return { matches: true, addEventListener() {} }; },
|
||||
addEventListener() {}
|
||||
};
|
||||
const mockDocument = {
|
||||
addEventListener(ev, fn) {
|
||||
if (!mockDocument._listeners) mockDocument._listeners = {};
|
||||
if (!mockDocument._listeners[ev]) mockDocument._listeners[ev] = [];
|
||||
mockDocument._listeners[ev].push(fn);
|
||||
},
|
||||
removeEventListener(ev, fn) {
|
||||
if (mockDocument._listeners && mockDocument._listeners[ev]) {
|
||||
mockDocument._listeners[ev] = mockDocument._listeners[ev].filter(f => f !== fn);
|
||||
}
|
||||
},
|
||||
querySelectorAll() { return []; }
|
||||
};
|
||||
const mockLocalStorage = {
|
||||
_data: {},
|
||||
getItem(k) { return this._data[k] || null; },
|
||||
setItem(k, v) { this._data[k] = v; },
|
||||
removeItem(k) { delete this._data[k]; },
|
||||
clear() { this._data = {}; }
|
||||
};
|
||||
|
||||
// Load DragManager
|
||||
const src = fs.readFileSync(path.join(__dirname, 'public', 'drag-manager.js'), 'utf8');
|
||||
const ctx = vm.createContext({
|
||||
window: mockWindow,
|
||||
document: mockDocument,
|
||||
localStorage: mockLocalStorage,
|
||||
Math: Math,
|
||||
JSON: JSON,
|
||||
console: console,
|
||||
setTimeout: setTimeout,
|
||||
clearTimeout: clearTimeout,
|
||||
parseFloat: parseFloat
|
||||
});
|
||||
vm.runInContext(src, ctx);
|
||||
const DragManager = ctx.window.DragManager;
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
mockLocalStorage.clear();
|
||||
fn();
|
||||
passed++;
|
||||
console.log(' ✓ ' + name);
|
||||
} catch (e) {
|
||||
failed++;
|
||||
console.log(' ✗ ' + name + ': ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('DragManager tests:');
|
||||
|
||||
test('constructor initializes IDLE state', () => {
|
||||
const dm = new DragManager();
|
||||
assert.strictEqual(dm.state, 'IDLE');
|
||||
assert.strictEqual(dm.enabled, true);
|
||||
});
|
||||
|
||||
test('register adds panel', () => {
|
||||
const dm = new DragManager();
|
||||
const panel = makePanel('testPanel');
|
||||
dm.register(panel);
|
||||
assert.strictEqual(dm._panels.length, 1);
|
||||
});
|
||||
|
||||
test('register ignores null panel', () => {
|
||||
const dm = new DragManager();
|
||||
dm.register(null);
|
||||
assert.strictEqual(dm._panels.length, 0);
|
||||
});
|
||||
|
||||
test('pointerdown transitions to PENDING', () => {
|
||||
const dm = new DragManager();
|
||||
const panel = makePanel('p1');
|
||||
dm.register(panel);
|
||||
panel._header._fire('pointerdown', {
|
||||
button: 0, clientX: 100, clientY: 100,
|
||||
preventDefault() {},
|
||||
target: { closest() { return null; } }
|
||||
});
|
||||
assert.strictEqual(dm.state, 'PENDING');
|
||||
assert.strictEqual(dm.activePanel, panel);
|
||||
});
|
||||
|
||||
test('pointerdown ignores non-left button', () => {
|
||||
const dm = new DragManager();
|
||||
const panel = makePanel('p1');
|
||||
dm.register(panel);
|
||||
panel._header._fire('pointerdown', {
|
||||
button: 2, clientX: 100, clientY: 100,
|
||||
preventDefault() {},
|
||||
target: { closest() { return null; } }
|
||||
});
|
||||
assert.strictEqual(dm.state, 'IDLE');
|
||||
});
|
||||
|
||||
test('pointerdown ignores button clicks', () => {
|
||||
const dm = new DragManager();
|
||||
const panel = makePanel('p1');
|
||||
dm.register(panel);
|
||||
panel._header._fire('pointerdown', {
|
||||
button: 0, clientX: 100, clientY: 100,
|
||||
preventDefault() {},
|
||||
target: { closest(sel) { return sel === 'button' ? {} : null; } }
|
||||
});
|
||||
assert.strictEqual(dm.state, 'IDLE');
|
||||
});
|
||||
|
||||
test('pointermove within dead zone stays PENDING', () => {
|
||||
const dm = new DragManager();
|
||||
const panel = makePanel('p1');
|
||||
dm.register(panel);
|
||||
panel._header._fire('pointerdown', {
|
||||
button: 0, clientX: 100, clientY: 100,
|
||||
preventDefault() {},
|
||||
target: { closest() { return null; } }
|
||||
});
|
||||
panel._header._fire('pointermove', { clientX: 103, clientY: 102 });
|
||||
assert.strictEqual(dm.state, 'PENDING');
|
||||
assert.ok(!panel.classList.contains('is-dragging'));
|
||||
});
|
||||
|
||||
test('pointermove beyond dead zone transitions to DRAGGING', () => {
|
||||
const dm = new DragManager();
|
||||
const panel = makePanel('p1');
|
||||
panel.setAttribute('data-position', 'bl');
|
||||
dm.register(panel);
|
||||
panel._header._fire('pointerdown', {
|
||||
button: 0, clientX: 100, clientY: 100,
|
||||
preventDefault() {},
|
||||
target: { closest() { return null; } }
|
||||
});
|
||||
panel._header._fire('pointermove', { clientX: 110, clientY: 110 });
|
||||
assert.strictEqual(dm.state, 'DRAGGING');
|
||||
assert.ok(panel.classList.contains('is-dragging'));
|
||||
assert.strictEqual(panel.getAttribute('data-position'), null); // removed
|
||||
assert.strictEqual(panel.dataset.dragged, 'true');
|
||||
});
|
||||
|
||||
test('pointerup after drag finalizes position', () => {
|
||||
const dm = new DragManager();
|
||||
const panel = makePanel('p1');
|
||||
panel.setAttribute('data-position', 'bl');
|
||||
ctx.window.innerWidth = 1920;
|
||||
ctx.window.innerHeight = 1080;
|
||||
dm.register(panel);
|
||||
|
||||
panel._header._fire('pointerdown', {
|
||||
button: 0, clientX: 100, clientY: 100,
|
||||
preventDefault() {},
|
||||
target: { closest() { return null; } }
|
||||
});
|
||||
panel._header._fire('pointermove', { clientX: 200, clientY: 300 });
|
||||
panel._header._fire('pointerup', { pointerId: 1 });
|
||||
|
||||
assert.strictEqual(dm.state, 'IDLE');
|
||||
assert.ok(!panel.classList.contains('is-dragging'));
|
||||
// Should have persisted
|
||||
const saved = JSON.parse(mockLocalStorage.getItem('panel-drag-p1'));
|
||||
assert.ok(saved.xPct >= 0);
|
||||
assert.ok(saved.yPct >= 0);
|
||||
});
|
||||
|
||||
test('pointerup from PENDING (click) does not finalize', () => {
|
||||
const dm = new DragManager();
|
||||
const panel = makePanel('p1');
|
||||
dm.register(panel);
|
||||
panel._header._fire('pointerdown', {
|
||||
button: 0, clientX: 100, clientY: 100,
|
||||
preventDefault() {},
|
||||
target: { closest() { return null; } }
|
||||
});
|
||||
panel._header._fire('pointerup', { pointerId: 1 });
|
||||
assert.strictEqual(dm.state, 'IDLE');
|
||||
assert.strictEqual(mockLocalStorage.getItem('panel-drag-p1'), null);
|
||||
});
|
||||
|
||||
test('disable prevents drag', () => {
|
||||
const dm = new DragManager();
|
||||
dm.disable();
|
||||
const panel = makePanel('p1');
|
||||
dm.register(panel);
|
||||
panel._header._fire('pointerdown', {
|
||||
button: 0, clientX: 100, clientY: 100,
|
||||
preventDefault() {},
|
||||
target: { closest() { return null; } }
|
||||
});
|
||||
assert.strictEqual(dm.state, 'IDLE');
|
||||
});
|
||||
|
||||
test('snap-to-edge works within threshold', () => {
|
||||
const dm = new DragManager();
|
||||
const panel = makePanel('p1');
|
||||
panel.setAttribute('data-position', 'tl');
|
||||
ctx.window.innerWidth = 1920;
|
||||
ctx.window.innerHeight = 1080;
|
||||
dm.register(panel);
|
||||
|
||||
// Simulate drag to near top-left edge
|
||||
panel._header._fire('pointerdown', {
|
||||
button: 0, clientX: 500, clientY: 500,
|
||||
preventDefault() {},
|
||||
target: { closest() { return null; } }
|
||||
});
|
||||
panel._header._fire('pointermove', { clientX: 510, clientY: 510 }); // trigger DRAGGING
|
||||
|
||||
// Panel is now detached; set its position near edge
|
||||
panel.style.left = '5px';
|
||||
panel.style.top = '10px';
|
||||
|
||||
panel._header._fire('pointerup', { pointerId: 1 });
|
||||
|
||||
// Should have snapped to margin (12px)
|
||||
assert.strictEqual(panel.style.left, '12px');
|
||||
assert.strictEqual(panel.style.top, '12px');
|
||||
});
|
||||
|
||||
test('restorePositions applies saved viewport percentages', () => {
|
||||
const dm = new DragManager();
|
||||
const panel = makePanel('p2');
|
||||
panel.setAttribute('data-position', 'br');
|
||||
ctx.window.innerWidth = 1000;
|
||||
ctx.window.innerHeight = 800;
|
||||
dm.register(panel);
|
||||
|
||||
mockLocalStorage.setItem('panel-drag-p2', JSON.stringify({ xPct: 0.5, yPct: 0.25 }));
|
||||
dm.restorePositions();
|
||||
|
||||
assert.strictEqual(panel.style.left, '500px');
|
||||
assert.strictEqual(panel.style.top, '200px');
|
||||
assert.strictEqual(panel.dataset.dragged, 'true');
|
||||
assert.strictEqual(panel.getAttribute('data-position'), null);
|
||||
});
|
||||
|
||||
test('handleResize clamps panels inside viewport', () => {
|
||||
const dm = new DragManager();
|
||||
const panel = makePanel('p3');
|
||||
dm.register(panel);
|
||||
|
||||
// Simulate a dragged panel that's now off-screen
|
||||
panel.dataset.dragged = 'true';
|
||||
panel.style.left = '1800px';
|
||||
panel.style.top = '900px';
|
||||
ctx.window.innerWidth = 1000;
|
||||
ctx.window.innerHeight = 600;
|
||||
|
||||
// Need querySelectorAll to return this panel
|
||||
const origQSA = mockDocument.querySelectorAll;
|
||||
mockDocument.querySelectorAll = function (sel) {
|
||||
if (sel === '.live-overlay[data-dragged="true"]') return [panel];
|
||||
return [];
|
||||
};
|
||||
|
||||
dm.handleResize();
|
||||
mockDocument.querySelectorAll = origQSA;
|
||||
|
||||
// Should be clamped
|
||||
const left = parseFloat(panel.style.left);
|
||||
const top = parseFloat(panel.style.top);
|
||||
assert.ok(left + 300 <= 1000, 'left clamped: ' + left);
|
||||
assert.ok(top + 200 <= 600, 'top clamped: ' + top);
|
||||
});
|
||||
|
||||
test('Escape during drag reverts to corner position', () => {
|
||||
const dm = new DragManager();
|
||||
const panel = makePanel('esc1');
|
||||
panel.setAttribute('data-position', 'bl');
|
||||
dm.register(panel);
|
||||
|
||||
panel._header._fire('pointerdown', {
|
||||
button: 0, clientX: 100, clientY: 100,
|
||||
preventDefault() {},
|
||||
target: { closest() { return null; } }
|
||||
});
|
||||
panel._header._fire('pointermove', { clientX: 120, clientY: 120 }); // trigger DRAGGING
|
||||
assert.strictEqual(dm.state, 'DRAGGING');
|
||||
|
||||
// Simulate Escape
|
||||
dm._handleKeyDown({ key: 'Escape' });
|
||||
|
||||
assert.strictEqual(dm.state, 'IDLE');
|
||||
assert.ok(!panel.classList.contains('is-dragging'));
|
||||
assert.strictEqual(panel.dataset.dragged, undefined);
|
||||
assert.strictEqual(panel.style.transform, '');
|
||||
});
|
||||
|
||||
test('Escape during drag reverts to saved position', () => {
|
||||
const dm = new DragManager();
|
||||
const panel = makePanel('esc2');
|
||||
ctx.window.innerWidth = 1000;
|
||||
ctx.window.innerHeight = 800;
|
||||
dm.register(panel);
|
||||
|
||||
// Pre-save a dragged position
|
||||
mockLocalStorage.setItem('panel-drag-esc2', JSON.stringify({ xPct: 0.3, yPct: 0.4 }));
|
||||
dm.restorePositions();
|
||||
assert.strictEqual(panel.dataset.dragged, 'true');
|
||||
|
||||
panel._header._fire('pointerdown', {
|
||||
button: 0, clientX: 400, clientY: 400,
|
||||
preventDefault() {},
|
||||
target: { closest() { return null; } }
|
||||
});
|
||||
panel._header._fire('pointermove', { clientX: 500, clientY: 500 });
|
||||
assert.strictEqual(dm.state, 'DRAGGING');
|
||||
|
||||
dm._handleKeyDown({ key: 'Escape' });
|
||||
|
||||
assert.strictEqual(dm.state, 'IDLE');
|
||||
assert.strictEqual(panel.style.transform, 'none');
|
||||
});
|
||||
|
||||
test('pointercancel during drag finalizes position', () => {
|
||||
const dm = new DragManager();
|
||||
const panel = makePanel('pc1');
|
||||
panel.setAttribute('data-position', 'tl');
|
||||
ctx.window.innerWidth = 1920;
|
||||
ctx.window.innerHeight = 1080;
|
||||
dm.register(panel);
|
||||
|
||||
panel._header._fire('pointerdown', {
|
||||
button: 0, clientX: 100, clientY: 100,
|
||||
preventDefault() {},
|
||||
target: { closest() { return null; } }
|
||||
});
|
||||
panel._header._fire('pointermove', { clientX: 200, clientY: 200 });
|
||||
assert.strictEqual(dm.state, 'DRAGGING');
|
||||
|
||||
panel._header._fire('pointercancel', {});
|
||||
assert.strictEqual(dm.state, 'IDLE');
|
||||
assert.ok(!panel.classList.contains('is-dragging'));
|
||||
});
|
||||
|
||||
test('z-index increments on drag', () => {
|
||||
const dm = new DragManager();
|
||||
const p1 = makePanel('z1');
|
||||
const p2 = makePanel('z2');
|
||||
dm.register(p1);
|
||||
dm.register(p2);
|
||||
|
||||
// Drag p1
|
||||
p1._header._fire('pointerdown', {
|
||||
button: 0, clientX: 100, clientY: 100,
|
||||
preventDefault() {},
|
||||
target: { closest() { return null; } }
|
||||
});
|
||||
p1._header._fire('pointermove', { clientX: 110, clientY: 110 });
|
||||
const z1 = parseInt(p1.style.zIndex);
|
||||
p1._header._fire('pointerup', { pointerId: 1 });
|
||||
|
||||
// Drag p2
|
||||
p2._header._fire('pointerdown', {
|
||||
button: 0, clientX: 100, clientY: 100,
|
||||
preventDefault() {},
|
||||
target: { closest() { return null; } }
|
||||
});
|
||||
p2._header._fire('pointermove', { clientX: 110, clientY: 110 });
|
||||
const z2 = parseInt(p2.style.zIndex);
|
||||
p2._header._fire('pointerup', { pointerId: 1 });
|
||||
|
||||
assert.ok(z2 > z1, 'z2 (' + z2 + ') should be greater than z1 (' + z1 + ')');
|
||||
assert.ok(z1 >= 1001, 'z1 should be >= 1001');
|
||||
});
|
||||
|
||||
test('disable mid-drag resets state', () => {
|
||||
const dm = new DragManager();
|
||||
const panel = makePanel('dis1');
|
||||
dm.register(panel);
|
||||
|
||||
panel._header._fire('pointerdown', {
|
||||
button: 0, clientX: 100, clientY: 100,
|
||||
preventDefault() {},
|
||||
target: { closest() { return null; } }
|
||||
});
|
||||
panel._header._fire('pointermove', { clientX: 120, clientY: 120 });
|
||||
assert.strictEqual(dm.state, 'DRAGGING');
|
||||
|
||||
dm.disable();
|
||||
assert.strictEqual(dm.state, 'IDLE');
|
||||
assert.ok(!panel.classList.contains('is-dragging'));
|
||||
assert.strictEqual(dm.enabled, false);
|
||||
});
|
||||
|
||||
console.log('\n' + passed + ' passed, ' + failed + ' failed');
|
||||
if (failed > 0) process.exit(1);
|
||||
Reference in New Issue
Block a user