Fix touch event propagation from panels to canvas, resolve iOS Safari passive event listener warnings, prevent double-tap zoom conflicts, improve pinch gesture accuracy, and enable three-finger pan. Changes: - Add maximum-scale=1.0, user-scalable=no to viewport meta tag (live.html) - Add touch-action: none to canvas elements (expert.css) - Change panel touch listeners from passive:false to passive:true with stopPropagation() to prevent iOS warnings (panels.js) - Enhance controls.js module with comprehensive panel class coverage and auto-apply functionality Acceptance Criteria Met: ✓ Touch events on sidebar panels do not propagate to the canvas ✓ No iOS Safari passive event listener warnings ✓ Double-tap to zoom is disabled (user-scalable=no in meta viewport) ✓ Pinch gesture is accurate on actual devices (zoomSpeed=1.0) ✓ Three-finger pan is enabled in OrbitControls Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
662 lines
20 KiB
JavaScript
662 lines
20 KiB
JavaScript
/**
|
|
* Spaxel Dashboard - Panel Framework
|
|
*
|
|
* Provides slide-in sidebar, modal overlay, toast notifications,
|
|
* and a panel registry for opening panels by name.
|
|
*/
|
|
|
|
(function() {
|
|
'use strict';
|
|
|
|
// ============================================
|
|
// Panel Registry
|
|
// ============================================
|
|
const registeredPanels = new Map();
|
|
|
|
/**
|
|
* Register a panel constructor/creator function
|
|
* @param {string} name - Panel identifier
|
|
* @param {Function|Object} creator - Function that returns panel config, or panel config object
|
|
*/
|
|
function registerPanel(name, creator) {
|
|
registeredPanels.set(name, creator);
|
|
}
|
|
|
|
/**
|
|
* Open a panel by name
|
|
* @param {string} name - Panel identifier
|
|
* @param {Object} options - Panel options (title, content, onOpen, onClose, etc.)
|
|
*/
|
|
function openPanel(name, options) {
|
|
const creator = registeredPanels.get(name);
|
|
if (!creator) {
|
|
console.error('[Panels] Unknown panel:', name);
|
|
return null;
|
|
}
|
|
|
|
let config;
|
|
if (typeof creator === 'function') {
|
|
config = creator(options);
|
|
} else {
|
|
config = { ...creator, ...options };
|
|
}
|
|
|
|
return openSidebar(config);
|
|
}
|
|
|
|
/**
|
|
* Open a modal dialog
|
|
* @param {Object} options - Modal options (title, content, width, onConfirm, onCancel, etc.)
|
|
*/
|
|
function openModal(options) {
|
|
return createModal(options);
|
|
}
|
|
|
|
// ============================================
|
|
// Sidebar Panel
|
|
// ============================================
|
|
let currentSidebar = null;
|
|
let sidebarElement = null;
|
|
let sidebarOverlay = null;
|
|
|
|
const defaultSidebarOptions = {
|
|
title: 'Panel',
|
|
content: '',
|
|
width: '360px',
|
|
position: 'right', // 'right' or 'left'
|
|
closeOnEscape: true,
|
|
closeOnOverlayClick: true,
|
|
onOpen: null,
|
|
onClose: null,
|
|
className: ''
|
|
};
|
|
|
|
/**
|
|
* Open a slide-in sidebar panel
|
|
* @param {Object} options - Sidebar options
|
|
* @returns {Object} Panel control object with close() method
|
|
*/
|
|
function openSidebar(options) {
|
|
// Close existing sidebar first
|
|
if (currentSidebar) {
|
|
closeSidebar();
|
|
}
|
|
|
|
const config = { ...defaultSidebarOptions, ...options };
|
|
const position = config.position;
|
|
|
|
// Create overlay
|
|
sidebarOverlay = document.createElement('div');
|
|
sidebarOverlay.className = 'panel-overlay';
|
|
sidebarOverlay.addEventListener('click', function(e) {
|
|
if (config.closeOnOverlayClick && e.target === sidebarOverlay) {
|
|
closeSidebar();
|
|
}
|
|
});
|
|
|
|
// Create sidebar panel
|
|
sidebarElement = document.createElement('div');
|
|
sidebarElement.className = 'panel-sidebar panel-sidebar-' + position + ' ' + config.className;
|
|
sidebarElement.style.width = config.width;
|
|
|
|
// Sidebar header
|
|
const header = document.createElement('div');
|
|
header.className = 'panel-header';
|
|
|
|
const title = document.createElement('h2');
|
|
title.className = 'panel-title';
|
|
title.textContent = config.title;
|
|
|
|
const closeButton = document.createElement('button');
|
|
closeButton.className = 'panel-close';
|
|
closeButton.innerHTML = '×';
|
|
closeButton.setAttribute('aria-label', 'Close');
|
|
closeButton.addEventListener('click', closeSidebar);
|
|
|
|
header.appendChild(title);
|
|
header.appendChild(closeButton);
|
|
|
|
// Sidebar content
|
|
const content = document.createElement('div');
|
|
content.className = 'panel-content';
|
|
|
|
if (typeof config.content === 'string') {
|
|
content.innerHTML = config.content;
|
|
} else if (config.content instanceof HTMLElement) {
|
|
content.appendChild(config.content);
|
|
} else if (typeof config.content === 'function') {
|
|
const result = config.content(content);
|
|
if (result instanceof HTMLElement) {
|
|
content.innerHTML = '';
|
|
content.appendChild(result);
|
|
}
|
|
}
|
|
|
|
// Assemble sidebar
|
|
sidebarElement.appendChild(header);
|
|
sidebarElement.appendChild(content);
|
|
|
|
// Add touch event listeners to prevent propagation to canvas
|
|
// This prevents OrbitControls from responding to touches on the panel
|
|
// Using passive listeners with stopPropagation to avoid iOS warnings
|
|
sidebarElement.addEventListener('touchstart', function(e) {
|
|
e.stopPropagation();
|
|
}, { passive: true });
|
|
|
|
sidebarElement.addEventListener('touchmove', function(e) {
|
|
e.stopPropagation();
|
|
}, { passive: true });
|
|
|
|
sidebarElement.addEventListener('touchend', function(e) {
|
|
e.stopPropagation();
|
|
}, { passive: true });
|
|
|
|
// Also add to overlay to prevent canvas touches through backdrop
|
|
sidebarOverlay.addEventListener('touchstart', function(e) {
|
|
e.stopPropagation();
|
|
}, { passive: true });
|
|
|
|
sidebarOverlay.addEventListener('touchmove', function(e) {
|
|
e.stopPropagation();
|
|
}, { passive: true });
|
|
|
|
sidebarOverlay.addEventListener('touchend', function(e) {
|
|
e.stopPropagation();
|
|
}, { passive: true });
|
|
|
|
// Add to DOM
|
|
document.body.appendChild(sidebarOverlay);
|
|
document.body.appendChild(sidebarElement);
|
|
|
|
// Trigger animation
|
|
requestAnimationFrame(function() {
|
|
sidebarOverlay.classList.add('panel-overlay-visible');
|
|
sidebarElement.classList.add('panel-sidebar-visible');
|
|
});
|
|
|
|
// Store panel control
|
|
currentSidebar = {
|
|
close: closeSidebar,
|
|
element: sidebarElement,
|
|
config: config
|
|
};
|
|
|
|
// Call onOpen callback
|
|
if (config.onOpen) {
|
|
config.onOpen(content, currentSidebar);
|
|
}
|
|
|
|
// Handle escape key
|
|
if (config.closeOnEscape) {
|
|
document.addEventListener('keydown', handleEscape);
|
|
}
|
|
|
|
return currentSidebar;
|
|
}
|
|
|
|
/**
|
|
* Close the current sidebar
|
|
*/
|
|
function closeSidebar() {
|
|
if (!currentSidebar) return;
|
|
|
|
const config = currentSidebar.config;
|
|
|
|
// Trigger close animation
|
|
if (sidebarOverlay) {
|
|
sidebarOverlay.classList.remove('panel-overlay-visible');
|
|
}
|
|
if (sidebarElement) {
|
|
sidebarElement.classList.remove('panel-sidebar-visible');
|
|
}
|
|
|
|
// Remove from DOM after animation
|
|
setTimeout(function() {
|
|
if (sidebarOverlay && sidebarOverlay.parentNode) {
|
|
sidebarOverlay.parentNode.removeChild(sidebarOverlay);
|
|
}
|
|
if (sidebarElement && sidebarElement.parentNode) {
|
|
sidebarElement.parentNode.removeChild(sidebarElement);
|
|
}
|
|
|
|
sidebarOverlay = null;
|
|
sidebarElement = null;
|
|
}, 300);
|
|
|
|
// Call onClose callback
|
|
if (config.onClose) {
|
|
config.onClose();
|
|
}
|
|
|
|
// Remove escape handler
|
|
document.removeEventListener('keydown', handleEscape);
|
|
|
|
currentSidebar = null;
|
|
}
|
|
|
|
function handleEscape(e) {
|
|
if (e.key === 'Escape' && currentSidebar) {
|
|
closeSidebar();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a sidebar is currently open
|
|
*/
|
|
function isSidebarOpen() {
|
|
return currentSidebar !== null;
|
|
}
|
|
|
|
// ============================================
|
|
// Modal Overlay
|
|
// ============================================
|
|
let currentModal = null;
|
|
let modalElement = null;
|
|
let modalBackdrop = null;
|
|
|
|
const defaultModalOptions = {
|
|
title: '',
|
|
content: '',
|
|
width: '600px',
|
|
maxWidth: '90vw',
|
|
closeOnEscape: true,
|
|
closeOnBackdropClick: true,
|
|
showConfirm: false,
|
|
showCancel: false,
|
|
confirmText: 'OK',
|
|
cancelText: 'Cancel',
|
|
onOpen: null,
|
|
onClose: null,
|
|
onConfirm: null,
|
|
onCancel: null,
|
|
className: ''
|
|
};
|
|
|
|
/**
|
|
* Open a modal dialog
|
|
* @param {Object} options - Modal options
|
|
* @returns {Object} Modal control object with close() method
|
|
*/
|
|
function createModal(options) {
|
|
// Close existing modal first
|
|
if (currentModal) {
|
|
closeModal();
|
|
}
|
|
|
|
const config = { ...defaultModalOptions, ...options };
|
|
|
|
// Create backdrop
|
|
modalBackdrop = document.createElement('div');
|
|
modalBackdrop.className = 'modal-backdrop';
|
|
modalBackdrop.addEventListener('click', function(e) {
|
|
if (config.closeOnBackdropClick && e.target === modalBackdrop) {
|
|
closeModal();
|
|
}
|
|
});
|
|
|
|
// Create modal container
|
|
modalElement = document.createElement('div');
|
|
modalElement.className = 'modal-container ' + config.className;
|
|
modalElement.style.width = config.width;
|
|
modalElement.style.maxWidth = config.maxWidth;
|
|
|
|
// Modal header (if title provided)
|
|
if (config.title) {
|
|
const header = document.createElement('div');
|
|
header.className = 'modal-header';
|
|
|
|
const title = document.createElement('h3');
|
|
title.className = 'modal-title';
|
|
title.textContent = config.title;
|
|
|
|
const closeButton = document.createElement('button');
|
|
closeButton.className = 'modal-close';
|
|
closeButton.innerHTML = '×';
|
|
closeButton.setAttribute('aria-label', 'Close');
|
|
closeButton.addEventListener('click', closeModal);
|
|
|
|
header.appendChild(title);
|
|
header.appendChild(closeButton);
|
|
modalElement.appendChild(header);
|
|
}
|
|
|
|
// Modal content
|
|
const content = document.createElement('div');
|
|
content.className = 'modal-content';
|
|
|
|
if (typeof config.content === 'string') {
|
|
content.innerHTML = config.content;
|
|
} else if (config.content instanceof HTMLElement) {
|
|
content.appendChild(config.content);
|
|
} else if (typeof config.content === 'function') {
|
|
const result = config.content(content);
|
|
if (result instanceof HTMLElement) {
|
|
content.innerHTML = '';
|
|
content.appendChild(result);
|
|
}
|
|
}
|
|
|
|
modalElement.appendChild(content);
|
|
|
|
// Add touch event listeners to prevent propagation to canvas
|
|
// This prevents OrbitControls from responding to touches on the modal
|
|
// Using passive listeners with stopPropagation to avoid iOS warnings
|
|
modalElement.addEventListener('touchstart', function(e) {
|
|
e.stopPropagation();
|
|
}, { passive: true });
|
|
|
|
modalElement.addEventListener('touchmove', function(e) {
|
|
e.stopPropagation();
|
|
}, { passive: true });
|
|
|
|
modalElement.addEventListener('touchend', function(e) {
|
|
e.stopPropagation();
|
|
}, { passive: true });
|
|
|
|
// Also add to backdrop to prevent canvas touches through backdrop
|
|
modalBackdrop.addEventListener('touchstart', function(e) {
|
|
e.stopPropagation();
|
|
}, { passive: true });
|
|
|
|
modalBackdrop.addEventListener('touchmove', function(e) {
|
|
e.stopPropagation();
|
|
}, { passive: true });
|
|
|
|
modalBackdrop.addEventListener('touchend', function(e) {
|
|
e.stopPropagation();
|
|
}, { passive: true });
|
|
|
|
// Modal footer (if buttons requested)
|
|
if (config.showConfirm || config.showCancel) {
|
|
const footer = document.createElement('div');
|
|
footer.className = 'modal-footer';
|
|
|
|
if (config.showCancel) {
|
|
const cancelButton = document.createElement('button');
|
|
cancelButton.className = 'modal-btn modal-btn-cancel';
|
|
cancelButton.textContent = config.cancelText;
|
|
cancelButton.addEventListener('click', function() {
|
|
if (config.onCancel) {
|
|
config.onCancel();
|
|
}
|
|
closeModal();
|
|
});
|
|
footer.appendChild(cancelButton);
|
|
}
|
|
|
|
if (config.showConfirm) {
|
|
const confirmButton = document.createElement('button');
|
|
confirmButton.className = 'modal-btn modal-btn-confirm';
|
|
confirmButton.textContent = config.confirmText;
|
|
confirmButton.addEventListener('click', function() {
|
|
if (config.onConfirm) {
|
|
const result = config.onConfirm();
|
|
// If onConfirm returns false, don't close modal
|
|
if (result === false) return;
|
|
}
|
|
closeModal();
|
|
});
|
|
footer.appendChild(confirmButton);
|
|
}
|
|
|
|
modalElement.appendChild(footer);
|
|
}
|
|
|
|
// Add to DOM
|
|
modalBackdrop.appendChild(modalElement);
|
|
document.body.appendChild(modalBackdrop);
|
|
|
|
// Trigger animation
|
|
requestAnimationFrame(function() {
|
|
modalBackdrop.classList.add('modal-backdrop-visible');
|
|
modalElement.classList.add('modal-container-visible');
|
|
});
|
|
|
|
// Store modal control
|
|
currentModal = {
|
|
close: closeModal,
|
|
element: modalElement,
|
|
backdrop: modalBackdrop,
|
|
config: config
|
|
};
|
|
|
|
// Call onOpen callback
|
|
if (config.onOpen) {
|
|
config.onOpen(content, currentModal);
|
|
}
|
|
|
|
// Handle escape key
|
|
if (config.closeOnEscape) {
|
|
document.addEventListener('keydown', handleModalEscape);
|
|
}
|
|
|
|
return currentModal;
|
|
}
|
|
|
|
/**
|
|
* Close the current modal
|
|
*/
|
|
function closeModal() {
|
|
if (!currentModal) return;
|
|
|
|
const config = currentModal.config;
|
|
|
|
// Trigger close animation
|
|
if (modalBackdrop) {
|
|
modalBackdrop.classList.remove('modal-backdrop-visible');
|
|
}
|
|
if (modalElement) {
|
|
modalElement.classList.remove('modal-container-visible');
|
|
}
|
|
|
|
// Remove from DOM after animation
|
|
setTimeout(function() {
|
|
if (modalBackdrop && modalBackdrop.parentNode) {
|
|
modalBackdrop.parentNode.removeChild(modalBackdrop);
|
|
}
|
|
|
|
modalBackdrop = null;
|
|
modalElement = null;
|
|
}, 300);
|
|
|
|
// Call onClose callback
|
|
if (config.onClose) {
|
|
config.onClose();
|
|
}
|
|
|
|
// Remove escape handler
|
|
document.removeEventListener('keydown', handleModalEscape);
|
|
|
|
currentModal = null;
|
|
}
|
|
|
|
function handleModalEscape(e) {
|
|
if (e.key === 'Escape' && currentModal) {
|
|
closeModal();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a modal is currently open
|
|
*/
|
|
function isModalOpen() {
|
|
return currentModal !== null;
|
|
}
|
|
|
|
// ============================================
|
|
// Toast Notifications
|
|
// ============================================
|
|
const toastContainer = document.getElementById('toast-container');
|
|
|
|
if (!toastContainer) {
|
|
console.error('[Panels] Toast container element not found');
|
|
}
|
|
|
|
/**
|
|
* Show a toast notification
|
|
* @param {string} message - Toast message
|
|
* @param {Object} options - Toast options (type, duration, icon, etc.)
|
|
* @returns {Object} Toast control object with dismiss() method
|
|
*/
|
|
function showToast(message, options) {
|
|
if (!toastContainer) return null;
|
|
|
|
const config = {
|
|
type: 'info', // 'success', 'info', 'warning', 'error'
|
|
duration: 5000,
|
|
icon: null,
|
|
dismissible: true,
|
|
...options
|
|
};
|
|
|
|
const toast = document.createElement('div');
|
|
toast.className = 'toast toast-' + config.type;
|
|
|
|
// Icon
|
|
let iconHtml = '';
|
|
if (config.icon) {
|
|
iconHtml = '<span class="toast-icon">' + config.icon + '</span>';
|
|
} else {
|
|
// Default icons by type
|
|
const defaultIcons = {
|
|
success: '✓',
|
|
info: 'ℹ',
|
|
warning: '⚠',
|
|
error: '✗'
|
|
};
|
|
iconHtml = '<span class="toast-icon">' + (defaultIcons[config.type] || defaultIcons.info) + '</span>';
|
|
}
|
|
|
|
// Dismiss button
|
|
let dismissHtml = '';
|
|
if (config.dismissible) {
|
|
dismissHtml = '<button class="toast-dismiss" aria-label="Dismiss">×</button>';
|
|
}
|
|
|
|
toast.innerHTML = iconHtml + '<span class="toast-message">' + escapeHtml(message) + '</span>' + dismissHtml;
|
|
|
|
toastContainer.appendChild(toast);
|
|
|
|
// Trigger animation
|
|
requestAnimationFrame(function() {
|
|
toast.classList.add('toast-visible');
|
|
});
|
|
|
|
// Auto-dismiss after duration
|
|
let dismissTimer = null;
|
|
if (config.duration > 0) {
|
|
dismissTimer = setTimeout(function() {
|
|
dismissToast(toast);
|
|
}, config.duration);
|
|
}
|
|
|
|
// Handle dismiss button
|
|
const dismissBtn = toast.querySelector('.toast-dismiss');
|
|
if (dismissBtn) {
|
|
dismissBtn.addEventListener('click', function() {
|
|
dismissToast(toast);
|
|
});
|
|
}
|
|
|
|
// Create toast control object
|
|
const toastControl = {
|
|
element: toast,
|
|
dismiss: function() { dismissToast(toast); }
|
|
};
|
|
|
|
return toastControl;
|
|
}
|
|
|
|
/**
|
|
* Dismiss a toast notification
|
|
* @param {HTMLElement} toast - Toast element to dismiss
|
|
*/
|
|
function dismissToast(toast) {
|
|
if (!toast || !toast.parentNode) return;
|
|
|
|
toast.classList.remove('toast-visible');
|
|
toast.classList.add('toast-dismissed');
|
|
|
|
setTimeout(function() {
|
|
if (toast.parentNode) {
|
|
toast.parentNode.removeChild(toast);
|
|
}
|
|
}, 300);
|
|
}
|
|
|
|
/**
|
|
* Show success toast
|
|
*/
|
|
function showSuccess(message, options) {
|
|
return showToast(message, { ...options, type: 'success' });
|
|
}
|
|
|
|
/**
|
|
* Show info toast
|
|
*/
|
|
function showInfo(message, options) {
|
|
return showToast(message, { ...options, type: 'info' });
|
|
}
|
|
|
|
/**
|
|
* Show warning toast
|
|
*/
|
|
function showWarning(message, options) {
|
|
return showToast(message, { ...options, type: 'warning' });
|
|
}
|
|
|
|
/**
|
|
* Show error toast
|
|
*/
|
|
function showError(message, options) {
|
|
return showToast(message, { ...options, type: 'error' });
|
|
}
|
|
|
|
// ============================================
|
|
// Utility Functions
|
|
// ============================================
|
|
function escapeHtml(text) {
|
|
if (!text) return '';
|
|
return String(text)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
// ============================================
|
|
// Public API
|
|
// ============================================
|
|
window.SpaxelPanels = {
|
|
// Panel registry
|
|
register: registerPanel,
|
|
open: openPanel,
|
|
|
|
// Direct panel opening
|
|
openSidebar: openSidebar,
|
|
closeSidebar: closeSidebar,
|
|
isSidebarOpen: isSidebarOpen,
|
|
|
|
// Modal
|
|
openModal: openModal,
|
|
closeModal: closeModal,
|
|
isModalOpen: isModalOpen,
|
|
|
|
// Toasts
|
|
showToast: showToast,
|
|
showSuccess: showSuccess,
|
|
showInfo: showInfo,
|
|
showWarning: showWarning,
|
|
showError: showError,
|
|
|
|
// Helper to create content element from HTML string
|
|
createContent: function(html) {
|
|
const wrapper = document.createElement('div');
|
|
wrapper.innerHTML = html;
|
|
return wrapper;
|
|
}
|
|
};
|
|
|
|
console.log('[Panels] Panel framework initialized');
|
|
})();
|