Files
meshcore-analyzer/test-drag-manager.js
Kpa-clawbot bc22dbdb14 feat: DragManager — core drag mechanics (#608 M1) (#697)
## 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>
2026-04-11 20:41:35 -07:00

455 lines
13 KiB
JavaScript

/* 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);