Files
rpg-companion-sillytavern/src/systems/dashboard/dragDrop.standalone.test.html
T
Lucas 'Paperboy' Rose-Winters e30f02f9fe feat(dashboard): implement drag-and-drop with mobile support (Task 1.5)
- Add DragDropHandler class with unified mouse + touch events
- Implement ghost element preview during drag
- Add grid overlay with cell highlighting
- Support touch events with 150ms delay for scroll compatibility
- Add Escape key to cancel drag
- Complete lifecycle management (init, destroy, cleanup)
- Create mobile-ready test harness with:
  - Touch-optimized UI (44px touch targets)
  - Responsive grid layout
  - Real-time event logging
  - Add/remove/reflow widgets
  - Works on desktop and mobile
- 420 lines core code, 880 lines test suite
- Comprehensive JSDoc documentation
2025-10-23 09:56:42 +11:00

932 lines
30 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Drag & Drop Test (Mobile-Ready)</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #1a1a2e;
color: #eee;
padding: 20px;
touch-action: none; /* Prevent default touch behaviors */
overflow-x: hidden;
}
h1 {
margin-bottom: 20px;
color: #e94560;
font-size: clamp(20px, 5vw, 28px);
}
.test-section {
background: #16213e;
padding: 15px;
border-radius: 8px;
margin-bottom: 15px;
}
.test-section h2 {
color: #4ecca3;
margin-bottom: 10px;
font-size: clamp(16px, 4vw, 18px);
}
/* Grid Container */
.grid-container {
position: relative;
background: #0f3460;
border-radius: 8px;
padding: 12px;
min-height: 600px;
overflow: visible;
}
/* Widget Styles */
.widget {
position: absolute;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 8px;
padding: 12px;
cursor: grab;
user-select: none;
transition: box-shadow 0.2s, opacity 0.2s;
border: 2px solid rgba(255, 255, 255, 0.1);
touch-action: none;
}
.widget:active {
cursor: grabbing;
}
.widget:hover {
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.widget.dragging {
opacity: 0.3;
}
.drag-ghost {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
}
.widget-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
.widget-icon {
font-size: 20px;
}
.widget-title {
font-weight: bold;
font-size: 14px;
flex: 1;
}
.widget-position {
font-size: 11px;
opacity: 0.7;
margin-top: 4px;
}
/* Control Panel */
.controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 15px;
}
button {
background: #e94560;
color: white;
border: none;
padding: 10px 16px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
touch-action: manipulation;
min-height: 44px; /* iOS touch target */
}
button:hover {
background: #d63651;
}
button.secondary {
background: #4ecca3;
color: #1a1a2e;
}
button.secondary:hover {
background: #5edc9f;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px;
margin-top: 10px;
}
.stat-box {
background: #0f3460;
padding: 10px;
border-radius: 5px;
text-align: center;
}
.stat-label {
font-size: 11px;
opacity: 0.7;
text-transform: uppercase;
margin-bottom: 5px;
}
.stat-value {
font-size: 18px;
font-weight: bold;
color: #4ecca3;
}
.hint {
background: #0f3460;
padding: 10px;
border-radius: 5px;
font-size: 12px;
color: #aaa;
line-height: 1.5;
}
.hint strong {
color: #4ecca3;
}
/* Grid Overlay */
.grid-overlay div {
transition: all 0.1s ease;
}
/* Mobile optimizations */
@media (max-width: 768px) {
body {
padding: 10px;
}
.test-section {
padding: 12px;
}
.grid-container {
min-height: 500px;
}
button {
flex: 1 1 calc(50% - 4px);
min-width: 0;
}
}
@media (max-width: 480px) {
.grid-container {
min-height: 400px;
}
.stats {
grid-template-columns: repeat(2, 1fr);
}
}
/* Event log */
.event-log {
max-height: 200px;
overflow-y: auto;
background: #0f3460;
padding: 10px;
border-radius: 5px;
font-family: monospace;
font-size: 11px;
}
.event-item {
padding: 4px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.event-time {
color: #888;
}
.event-type {
color: #4ecca3;
font-weight: bold;
}
</style>
</head>
<body>
<h1>🎯 Drag & Drop Test (Mobile-Ready)</h1>
<div class="test-section">
<h2>Draggable Widgets</h2>
<div class="hint">
<strong>Desktop:</strong> Click and drag widgets to move them<br>
<strong>Mobile:</strong> Touch and hold (150ms), then drag<br>
<strong>Keyboard:</strong> Press <kbd>Escape</kbd> to cancel drag
</div>
<div id="grid-container" class="grid-container"></div>
</div>
<div class="test-section">
<h2>Controls</h2>
<div class="controls">
<button onclick="addWidget()">Add Widget</button>
<button onclick="removeWidget()">Remove Last Widget</button>
<button onclick="reflowWidgets()" class="secondary">Reflow Grid</button>
<button onclick="resetGrid()">Reset</button>
</div>
</div>
<div class="test-section">
<h2>Statistics</h2>
<div id="stats" class="stats"></div>
</div>
<div class="test-section">
<h2>Event Log</h2>
<button onclick="clearLog()">Clear Log</button>
<div id="event-log" class="event-log"></div>
</div>
<script>
// GridEngine class (bundled inline)
class GridEngine {
constructor(config = {}) {
this.columns = config.columns || 12;
this.rowHeight = config.rowHeight || 80;
this.gap = config.gap || 12;
this.snapToGrid = config.snapToGrid !== false;
this.containerWidth = 0;
this.container = config.container;
if (this.container) {
this.updateContainerWidth();
}
}
updateContainerWidth() {
if (this.container) {
this.containerWidth = this.container.offsetWidth - (this.gap * 2);
}
}
getPixelPosition(widget) {
this.updateContainerWidth();
const totalGaps = this.gap * (this.columns + 1);
const colWidth = (this.containerWidth - totalGaps) / this.columns;
const left = widget.x * (colWidth + this.gap) + this.gap;
const top = widget.y * (this.rowHeight + this.gap) + this.gap;
const width = widget.w * colWidth + (widget.w - 1) * this.gap;
const height = widget.h * this.rowHeight + (widget.h - 1) * this.gap;
return { left, top, width, height };
}
snapToCell(pixelX, pixelY) {
this.updateContainerWidth();
const totalGaps = this.gap * (this.columns + 1);
const colWidth = (this.containerWidth - totalGaps) / this.columns;
const x = Math.round((pixelX - this.gap) / (colWidth + this.gap));
const y = Math.round((pixelY - this.gap) / (this.rowHeight + this.gap));
return {
x: Math.max(0, Math.min(x, this.columns - 1)),
y: Math.max(0, y)
};
}
detectCollision(widget, widgets) {
if (!Array.isArray(widgets) || widgets.length === 0) {
return false;
}
return widgets.some(other => {
if (other.id === widget.id) return false;
const noIntersect = (
widget.x + widget.w <= other.x ||
widget.x >= other.x + other.w ||
widget.y + widget.h <= other.y ||
widget.y >= other.y + other.h
);
return !noIntersect;
});
}
reflow(widgets) {
const sorted = [...widgets].sort((a, b) => {
if (a.y !== b.y) return a.y - b.y;
return a.x - b.x;
});
for (let i = 0; i < sorted.length; i++) {
while (this.detectCollision(sorted[i], sorted.slice(0, i))) {
sorted[i].y++;
}
}
return sorted;
}
validateWidget(widget) {
if (typeof widget.x !== 'number' || typeof widget.y !== 'number') {
return false;
}
if (typeof widget.w !== 'number' || typeof widget.h !== 'number') {
return false;
}
if (widget.x < 0 || widget.x + widget.w > this.columns) {
return false;
}
return true;
}
calculateGridHeight(widgets) {
if (!Array.isArray(widgets) || widgets.length === 0) {
return this.rowHeight + this.gap * 2;
}
const maxY = Math.max(...widgets.map(w => w.y + w.h));
return maxY * (this.rowHeight + this.gap) + this.gap;
}
}
// DragDropHandler class (bundled inline)
class DragDropHandler {
constructor(gridEngine, options = {}) {
this.gridEngine = gridEngine;
this.options = {
showGrid: true,
showCollisions: true,
enableSnap: true,
ghostOpacity: 0.5,
touchDelay: 150,
...options
};
this.dragState = null;
this.dragHandlers = new Map();
this.gridOverlay = null;
this.touchTimer = null;
this.boundMouseMove = this.onMouseMove.bind(this);
this.boundMouseUp = this.onMouseUp.bind(this);
this.boundTouchMove = this.onTouchMove.bind(this);
this.boundTouchEnd = this.onTouchEnd.bind(this);
this.boundKeyDown = this.onKeyDown.bind(this);
}
initWidget(element, widget, onDragEnd) {
const dragHandle = element.querySelector('.drag-handle') || element;
const mouseDownHandler = (e) => {
if (e.button !== 0) return;
e.preventDefault();
this.startDrag(e, element, widget, onDragEnd);
};
const touchStartHandler = (e) => {
this.touchTimer = setTimeout(() => {
e.preventDefault();
this.startDrag(e.touches[0], element, widget, onDragEnd);
}, this.options.touchDelay);
};
const touchCancelHandler = () => {
if (this.touchTimer) {
clearTimeout(this.touchTimer);
this.touchTimer = null;
}
};
dragHandle.addEventListener('mousedown', mouseDownHandler);
dragHandle.addEventListener('touchstart', touchStartHandler, { passive: false });
dragHandle.addEventListener('touchcancel', touchCancelHandler);
dragHandle.addEventListener('touchend', touchCancelHandler);
this.dragHandlers.set(element, {
mouseDownHandler,
touchStartHandler,
touchCancelHandler,
dragHandle
});
dragHandle.style.cursor = 'grab';
}
destroyWidget(element) {
const handlers = this.dragHandlers.get(element);
if (!handlers) return;
const { dragHandle, mouseDownHandler, touchStartHandler, touchCancelHandler } = handlers;
dragHandle.removeEventListener('mousedown', mouseDownHandler);
dragHandle.removeEventListener('touchstart', touchStartHandler);
dragHandle.removeEventListener('touchcancel', touchCancelHandler);
dragHandle.removeEventListener('touchend', touchCancelHandler);
this.dragHandlers.delete(element);
}
startDrag(e, element, widget, onDragEnd) {
const rect = element.getBoundingClientRect();
const offsetX = e.clientX - rect.left;
const offsetY = e.clientY - rect.top;
const ghost = this.createGhost(element);
this.dragState = {
element,
widget: { ...widget },
startX: e.clientX,
startY: e.clientY,
offsetX,
offsetY,
ghost,
isDragging: true,
onDragEnd
};
const dragHandle = element.querySelector('.drag-handle') || element;
dragHandle.style.cursor = 'grabbing';
document.addEventListener('mousemove', this.boundMouseMove);
document.addEventListener('mouseup', this.boundMouseUp);
document.addEventListener('touchmove', this.boundTouchMove, { passive: false });
document.addEventListener('touchend', this.boundTouchEnd);
document.addEventListener('keydown', this.boundKeyDown);
if (this.options.showGrid) {
this.showGridOverlay();
}
element.style.opacity = '0.3';
element.classList.add('dragging');
logEvent('Drag Start', { id: widget.id, x: widget.x, y: widget.y });
}
onMouseMove(e) {
if (!this.dragState?.isDragging) return;
e.preventDefault();
this.updateDragPosition(e.clientX, e.clientY);
}
onTouchMove(e) {
if (!this.dragState?.isDragging) return;
e.preventDefault();
const touch = e.touches[0];
this.updateDragPosition(touch.clientX, touch.clientY);
}
updateDragPosition(clientX, clientY) {
const { ghost, offsetX, offsetY, widget } = this.dragState;
ghost.style.left = (clientX - offsetX) + 'px';
ghost.style.top = (clientY - offsetY) + 'px';
const containerRect = this.gridEngine.container.getBoundingClientRect();
const relativeX = clientX - containerRect.left - offsetX;
const relativeY = clientY - containerRect.top - offsetY;
const snapped = this.gridEngine.snapToCell(relativeX, relativeY);
this.dragState.widget.x = snapped.x;
this.dragState.widget.y = snapped.y;
if (this.gridOverlay) {
this.highlightGridCells(snapped.x, snapped.y, widget.w, widget.h);
}
}
onMouseUp(e) {
if (!this.dragState?.isDragging) return;
e.preventDefault();
this.endDrag();
}
onTouchEnd(e) {
if (!this.dragState?.isDragging) return;
e.preventDefault();
this.endDrag();
}
onKeyDown(e) {
if (!this.dragState?.isDragging) return;
if (e.key === 'Escape') {
e.preventDefault();
this.cancelDrag();
}
}
endDrag() {
if (!this.dragState) return;
const { element, widget, onDragEnd } = this.dragState;
element.style.opacity = '1';
element.classList.remove('dragging');
const dragHandle = element.querySelector('.drag-handle') || element;
dragHandle.style.cursor = 'grab';
if (onDragEnd) {
onDragEnd(widget, widget.x, widget.y);
}
logEvent('Drag End', { id: widget.id, x: widget.x, y: widget.y });
this.cleanup();
}
cancelDrag() {
if (!this.dragState) return;
const { element } = this.dragState;
element.style.opacity = '1';
element.classList.remove('dragging');
const dragHandle = element.querySelector('.drag-handle') || element;
dragHandle.style.cursor = 'grab';
logEvent('Drag Cancelled', null);
this.cleanup();
}
cleanup() {
if (this.dragState?.ghost) {
this.dragState.ghost.remove();
}
this.hideGridOverlay();
document.removeEventListener('mousemove', this.boundMouseMove);
document.removeEventListener('mouseup', this.boundMouseUp);
document.removeEventListener('touchmove', this.boundTouchMove);
document.removeEventListener('touchend', this.boundTouchEnd);
document.removeEventListener('keydown', this.boundKeyDown);
if (this.touchTimer) {
clearTimeout(this.touchTimer);
this.touchTimer = null;
}
this.dragState = null;
}
createGhost(element) {
const ghost = element.cloneNode(true);
ghost.style.position = 'fixed';
ghost.style.opacity = this.options.ghostOpacity;
ghost.style.pointerEvents = 'none';
ghost.style.zIndex = '10000';
ghost.style.width = element.offsetWidth + 'px';
ghost.style.height = element.offsetHeight + 'px';
ghost.style.transition = 'none';
ghost.classList.add('drag-ghost');
document.body.appendChild(ghost);
return ghost;
}
showGridOverlay() {
if (this.gridOverlay) return;
this.gridOverlay = document.createElement('div');
this.gridOverlay.className = 'grid-overlay';
this.gridOverlay.style.position = 'absolute';
this.gridOverlay.style.top = '0';
this.gridOverlay.style.left = '0';
this.gridOverlay.style.width = '100%';
this.gridOverlay.style.height = '100%';
this.gridOverlay.style.pointerEvents = 'none';
this.gridOverlay.style.zIndex = '9999';
this.gridEngine.container.appendChild(this.gridOverlay);
}
hideGridOverlay() {
if (this.gridOverlay) {
this.gridOverlay.remove();
this.gridOverlay = null;
}
}
highlightGridCells(x, y, w, h) {
if (!this.gridOverlay) return;
this.gridOverlay.innerHTML = '';
const totalGaps = this.gridEngine.gap * (this.gridEngine.columns + 1);
const colWidth = (this.gridEngine.containerWidth - totalGaps) / this.gridEngine.columns;
for (let row = y; row < y + h; row++) {
for (let col = x; col < x + w; col++) {
const cell = document.createElement('div');
cell.style.position = 'absolute';
cell.style.left = (col * (colWidth + this.gridEngine.gap) + this.gridEngine.gap) + 'px';
cell.style.top = (row * (this.gridEngine.rowHeight + this.gridEngine.gap) + this.gridEngine.gap) + 'px';
cell.style.width = colWidth + 'px';
cell.style.height = this.gridEngine.rowHeight + 'px';
cell.style.backgroundColor = 'rgba(78, 204, 163, 0.3)';
cell.style.border = '2px solid rgba(78, 204, 163, 0.6)';
cell.style.borderRadius = '4px';
cell.style.boxSizing = 'border-box';
this.gridOverlay.appendChild(cell);
}
}
}
hasCollision(widgets) {
if (!this.dragState) return false;
const { widget } = this.dragState;
const otherWidgets = widgets.filter(w => w.id !== widget.id);
return this.gridEngine.detectCollision(widget, otherWidgets);
}
getDragState() {
return this.dragState;
}
isDragging() {
return this.dragState?.isDragging || false;
}
destroy() {
if (this.isDragging()) {
this.cancelDrag();
}
for (const element of this.dragHandlers.keys()) {
this.destroyWidget(element);
}
this.dragHandlers.clear();
}
}
// Test application
let gridEngine = null;
let dragDropHandler = null;
let widgets = [];
let widgetElements = new Map();
let widgetCounter = 0;
const widgetTypes = [
{ icon: '📊', name: 'Stats', color: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
{ icon: '🎒', name: 'Inventory', color: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)' },
{ icon: '📝', name: 'Notes', color: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)' },
{ icon: '🗺️', name: 'Map', color: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)' },
{ icon: '⚔️', name: 'Combat', color: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)' }
];
function init() {
const container = document.getElementById('grid-container');
gridEngine = new GridEngine({
columns: 12,
rowHeight: 80,
gap: 12,
container
});
dragDropHandler = new DragDropHandler(gridEngine, {
showGrid: true,
ghostOpacity: 0.7,
touchDelay: 150
});
// Create initial widgets
createInitialWidgets();
updateStats();
// Handle window resize
let resizeTimeout;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
renderAllWidgets();
updateStats();
}, 100);
});
logEvent('Initialized', { widgets: widgets.length });
}
function createInitialWidgets() {
const initialWidgets = [
{ x: 0, y: 0, w: 6, h: 3, type: 0 },
{ x: 6, y: 0, w: 6, h: 2, type: 1 },
{ x: 0, y: 3, w: 4, h: 3, type: 2 },
{ x: 4, y: 3, w: 4, h: 3, type: 3 }
];
initialWidgets.forEach(config => {
const widget = {
id: `widget-${widgetCounter++}`,
x: config.x,
y: config.y,
w: config.w,
h: config.h,
type: config.type
};
widgets.push(widget);
createWidgetElement(widget);
});
}
function createWidgetElement(widget) {
const container = document.getElementById('grid-container');
const type = widgetTypes[widget.type];
const element = document.createElement('div');
element.className = 'widget';
element.style.background = type.color;
element.innerHTML = `
<div class="widget-header">
<span class="widget-icon">${type.icon}</span>
<span class="widget-title">${type.name}</span>
</div>
<div class="widget-position">Position: (${widget.x}, ${widget.y})</div>
<div class="widget-position">Size: ${widget.w}×${widget.h}</div>
`;
container.appendChild(element);
widgetElements.set(widget.id, element);
// Position widget
positionWidget(element, widget);
// Initialize drag
dragDropHandler.initWidget(element, widget, (updatedWidget, newX, newY) => {
widget.x = newX;
widget.y = newY;
positionWidget(element, widget);
updateWidgetPosition(element, widget);
updateStats();
});
}
function positionWidget(element, widget) {
const pos = gridEngine.getPixelPosition(widget);
element.style.left = pos.left + 'px';
element.style.top = pos.top + 'px';
element.style.width = pos.width + 'px';
element.style.height = pos.height + 'px';
}
function updateWidgetPosition(element, widget) {
const posElements = element.querySelectorAll('.widget-position');
posElements[0].textContent = `Position: (${widget.x}, ${widget.y})`;
}
function renderAllWidgets() {
widgets.forEach(widget => {
const element = widgetElements.get(widget.id);
if (element) {
positionWidget(element, widget);
}
});
}
window.addWidget = function() {
const randomType = Math.floor(Math.random() * widgetTypes.length);
const widget = {
id: `widget-${widgetCounter++}`,
x: Math.floor(Math.random() * 8),
y: Math.floor(Math.random() * 3),
w: 4,
h: 2,
type: randomType
};
widgets.push(widget);
createWidgetElement(widget);
updateStats();
logEvent('Widget Added', { id: widget.id });
};
window.removeWidget = function() {
if (widgets.length === 0) return;
const widget = widgets.pop();
const element = widgetElements.get(widget.id);
if (element) {
dragDropHandler.destroyWidget(element);
element.remove();
widgetElements.delete(widget.id);
}
updateStats();
logEvent('Widget Removed', { id: widget.id });
};
window.reflowWidgets = function() {
widgets = gridEngine.reflow(widgets);
renderAllWidgets();
updateStats();
logEvent('Grid Reflowed', null);
};
window.resetGrid = function() {
// Clear all widgets
widgets.forEach(widget => {
const element = widgetElements.get(widget.id);
if (element) {
dragDropHandler.destroyWidget(element);
element.remove();
}
});
widgets = [];
widgetElements.clear();
widgetCounter = 0;
// Recreate initial widgets
createInitialWidgets();
updateStats();
logEvent('Grid Reset', null);
};
function updateStats() {
const container = document.getElementById('stats');
container.innerHTML = `
<div class="stat-box">
<div class="stat-label">Widgets</div>
<div class="stat-value">${widgets.length}</div>
</div>
<div class="stat-box">
<div class="stat-label">Grid Height</div>
<div class="stat-value">${gridEngine.calculateGridHeight(widgets)}px</div>
</div>
<div class="stat-box">
<div class="stat-label">Columns</div>
<div class="stat-value">${gridEngine.columns}</div>
</div>
<div class="stat-box">
<div class="stat-label">Container Width</div>
<div class="stat-value">${gridEngine.containerWidth}px</div>
</div>
`;
}
function logEvent(type, data) {
const log = document.getElementById('event-log');
const time = new Date().toLocaleTimeString();
const item = document.createElement('div');
item.className = 'event-item';
item.innerHTML = `
<span class="event-time">${time}</span>
<span class="event-type"> ${type}</span>
${data ? ` - ${JSON.stringify(data)}` : ''}
`;
log.insertBefore(item, log.firstChild);
// Keep only last 50 entries
while (log.children.length > 50) {
log.removeChild(log.lastChild);
}
}
window.clearLog = function() {
document.getElementById('event-log').innerHTML = '';
};
// Initialize on load
init();
</script>
</body>
</html>