/** * Mapping Component for ControlFreak * Handles rendering and interaction with controller mappings */ import { showNotification } from './notifications.js'; import { contextProvider } from '../core/contextProvider.js'; import { eventBus } from '../core/eventBus.js'; // Get the mapping engine from context const getMappingEngine = () => contextProvider.get('mappingEngine'); /** * Create a mapping UI component from a mapping object * @param {Object} mapping - The mapping object * @param {Object} options - Options for the mapping component * @returns {HTMLElement} - The created mapping component */ export function createMappingComponent(mapping, options = {}) { const component = document.createElement('div'); component.className = 'controller-mapping-component'; component.dataset.mappingId = mapping.id; // Control source section const controlSource = document.createElement('div'); controlSource.className = 'mapping-control-source'; const sourceIcon = document.createElement('span'); sourceIcon.className = 'mapping-icon'; sourceIcon.textContent = getControlTypeIcon(mapping.control.type); controlSource.appendChild(sourceIcon); const sourceName = document.createElement('span'); sourceName.className = 'mapping-name'; sourceName.title = getControlDetails(mapping.control); sourceName.textContent = mapping.control.name || getDefaultControlName(mapping.control); controlSource.appendChild(sourceName); component.appendChild(controlSource); // Arrow between source and target const arrow = document.createElement('div'); arrow.className = 'mapping-arrow'; arrow.textContent = '→'; component.appendChild(arrow); // Target section const target = document.createElement('div'); target.className = 'mapping-target'; const targetIcon = document.createElement('span'); targetIcon.className = 'mapping-icon'; targetIcon.textContent = getTargetTypeIcon(mapping.target.type); target.appendChild(targetIcon); const targetName = document.createElement('span'); targetName.className = 'mapping-name'; targetName.title = getTargetDetails(mapping.target); targetName.textContent = getTargetName(mapping.target); target.appendChild(targetName); component.appendChild(target); // Add the mapping type badge const mappingTypeBadge = document.createElement('div'); mappingTypeBadge.className = 'mapping-type-badge'; mappingTypeBadge.textContent = getMappingTypeLabel(mapping.mappingType); component.appendChild(mappingTypeBadge); // Add action buttons container const actionContainer = document.createElement('div'); actionContainer.className = 'mapping-actions'; // Add edit button if needed if (!options.hideEdit) { const editButton = document.createElement('button'); editButton.className = 'mapping-edit'; editButton.textContent = 'Edit'; editButton.title = 'Edit this mapping'; editButton.onclick = (e) => { e.stopPropagation(); showMappingEditor(mapping.id, component.parentNode); }; actionContainer.appendChild(editButton); } // Add delete button if allowed if (!options.hideDelete) { const deleteButton = document.createElement('button'); deleteButton.className = 'mapping-delete'; deleteButton.textContent = 'Delete'; deleteButton.title = 'Delete this mapping'; deleteButton.onclick = (e) => { e.stopPropagation(); // Confirm deletion if (confirm(`Delete mapping from ${sourceName.textContent} to ${targetName.textContent}?`)) { deleteMapping(mapping.id); // The UI will be refreshed via event listeners, so no need to manipulate DOM directly } }; actionContainer.appendChild(deleteButton); } component.appendChild(actionContainer); return component; } /** * Create a list of mapping components * @param {Array} mappings - Array of mapping objects * @param {Object} options - Options for the mapping list * @returns {HTMLElement} - The mapping list container */ export function createMappingList(mappings, options = {}) { const listContainer = document.createElement('div'); listContainer.className = 'controller-mapping-list'; if (!mappings || mappings.length === 0) { const emptyMessage = document.createElement('div'); emptyMessage.className = 'mapping-empty-message'; emptyMessage.textContent = options.emptyMessage || 'No mappings found'; listContainer.appendChild(emptyMessage); return listContainer; } // Create a component for each mapping mappings.forEach(mapping => { const component = createMappingComponent(mapping, options); listContainer.appendChild(component); }); return listContainer; } /** * Show an editor dialog for a mapping * @param {string} mappingId - The ID of the mapping to edit * @param {HTMLElement} listContainer - The parent container to potentially refresh */ export function showMappingEditor(mappingId, listContainer) { const mappingEngine = getMappingEngine(); if (!mappingEngine) { showNotification("Mapping engine not initialized", true); return; } const mapping = mappingEngine.getMappingById(mappingId); if (!mapping) { showNotification(`Mapping with ID ${mappingId} not found.`, true); return; } // Create editor overlay const overlay = document.createElement("div"); overlay.className = "mapping-editor-overlay"; // Create editor dialog - IMPORTANT: Add BOTH classes const editor = document.createElement("div"); editor.className = "mapping-editor controller-dialog"; // Add controller-dialog class // Create editor header const header = document.createElement("div"); // Use controller-dialog-header class for consistency header.className = "controller-dialog-header mapping-editor-header"; // Update to match the ControlFreak branding const title = document.createElement("h3"); title.innerHTML = ``; header.appendChild(title); // Close button - Use controller-dialog-close class const closeBtn = document.createElement("button"); closeBtn.className = "controller-dialog-close mapping-editor-close"; closeBtn.innerHTML = "×"; closeBtn.onclick = () => document.body.removeChild(overlay); header.appendChild(closeBtn); editor.appendChild(header); // Create editor content - Use controller-dialog-content class const content = document.createElement("div"); content.className = "controller-dialog-content mapping-editor-content"; // Add a subtitle for "Edit Mapping" const subtitle = document.createElement("div"); subtitle.className = "cf-dialog-subtitle"; subtitle.textContent = "Edit Mapping"; content.appendChild(subtitle); // Controller info (read-only) const controllerSection = document.createElement("div"); controllerSection.className = "controller-mapping-target"; // Reuse the section styling const controllerHeader = document.createElement("h3"); controllerHeader.textContent = "Control"; controllerHeader.style.color = "var(--cf-brand-primary)"; controllerSection.appendChild(controllerHeader); const controllerInfo = document.createElement("div"); controllerInfo.className = "controller-mapping-target-info"; const controlName = mapping.control.name || mapping.control.id; const deviceName = mapping.control.deviceName || mapping.control.deviceId; controllerInfo.innerHTML = `${controlName} (${deviceName})`; controllerSection.appendChild(controllerInfo); content.appendChild(controllerSection); // Target info (read-only) const targetSection = document.createElement("div"); targetSection.className = "controller-mapping-target"; const targetHeader = document.createElement("h3"); targetHeader.textContent = "Target"; targetHeader.style.color = "var(--cf-brand-primary)"; targetSection.appendChild(targetHeader); const targetInfo = document.createElement("div"); targetInfo.className = "controller-mapping-target-info"; let targetLabel = "Unknown Target"; if (mapping.target.type === 'widget') { targetLabel = `Widget: ${mapping.target.widgetName} (Node ${mapping.target.nodeId})`; } else if (mapping.target.type === 'command') { targetLabel = `Command: ${mapping.target.commandId}`; } else if (mapping.target.type === 'ui_element') { targetLabel = `UI Element: ${mapping.target.elementId}`; } targetInfo.innerHTML = targetLabel; targetSection.appendChild(targetInfo); content.appendChild(targetSection); // Mapping type selection const mappingTypeGroup = document.createElement("div"); mappingTypeGroup.className = "controller-mapping-type-section"; const mappingTypeHeader = document.createElement("h3"); mappingTypeHeader.textContent = "Mapping Type"; mappingTypeHeader.style.color = "var(--cf-brand-primary)"; mappingTypeGroup.appendChild(mappingTypeHeader); const mappingTypeSelect = document.createElement("select"); mappingTypeSelect.className = "controller-mapping-type-select"; // Add appropriate mapping type options based on target type if (mapping.target.type === "widget") { mappingTypeSelect.innerHTML = ` `; } else if (mapping.target.type === "command" || mapping.target.type === "ui_element") { mappingTypeSelect.innerHTML = ` `; } // Normalize mapping type for select element const currentMappingType = mapping.mappingType || "direct"; // Select the appropriate option for (const option of mappingTypeSelect.options) { if (option.value.toLowerCase() === currentMappingType.toLowerCase()) { option.selected = true; break; } } mappingTypeGroup.appendChild(mappingTypeSelect); content.appendChild(mappingTypeGroup); // Transformation settings (e.g., min/max for widgets) const transformContainer = document.createElement("div"); transformContainer.className = "controller-mapping-type-section"; // Declare inputs so they're available in the save handler let minInput, maxInput, stepInput, invertCheckbox; if (mapping.target.type === "widget") { const transformHeader = document.createElement("h3"); transformHeader.textContent = "Value Range"; transformHeader.style.color = "var(--cf-brand-primary)"; transformContainer.appendChild(transformHeader); // Create a range container for min/max const rangeContainer = document.createElement("div"); rangeContainer.className = "controller-mapping-range"; // Min Value const minGroup = document.createElement("div"); const minLabel = document.createElement("label"); minLabel.textContent = "Min Value:"; minInput = document.createElement("input"); minInput.className = "controller-mapping-min"; minInput.type = "number"; minInput.value = mapping.transform?.targetMin ?? 0; minGroup.appendChild(minLabel); minGroup.appendChild(minInput); rangeContainer.appendChild(minGroup); // Max Value const maxGroup = document.createElement("div"); const maxLabel = document.createElement("label"); maxLabel.textContent = "Max Value:"; maxInput = document.createElement("input"); maxInput.className = "controller-mapping-max"; maxInput.type = "number"; maxInput.value = mapping.transform?.targetMax ?? 1; maxGroup.appendChild(maxLabel); maxGroup.appendChild(maxInput); rangeContainer.appendChild(maxGroup); transformContainer.appendChild(rangeContainer); // Step Size (show only for relevant mapping types) const stepGroup = document.createElement("div"); stepGroup.className = "step-field hidden"; // Start hidden const stepLabel = document.createElement("label"); stepLabel.textContent = "Step Size:"; stepInput = document.createElement("input"); stepInput.className = "controller-mapping-step"; stepInput.type = "number"; stepInput.value = mapping.transform?.stepSize ?? 0.1; stepGroup.appendChild(stepLabel); stepGroup.appendChild(stepInput); transformContainer.appendChild(stepGroup); // Invert Checkbox const invertGroup = document.createElement("div"); invertGroup.className = "invert-field"; // Consistent class invertCheckbox = document.createElement("input"); invertCheckbox.type = "checkbox"; invertCheckbox.id = `mapping-edit-invert-${mapping.id}`; invertCheckbox.checked = mapping.transform?.isInverted ?? false; // Set initial state const invertLabel = document.createElement("label"); invertLabel.htmlFor = invertCheckbox.id; invertLabel.textContent = "Invert Output"; invertGroup.appendChild(invertCheckbox); invertGroup.appendChild(invertLabel); transformContainer.appendChild(invertGroup); // Function to show/hide step based on selected mapping type const updateStepVisibility = () => { const selectedType = mappingTypeSelect.value.toLowerCase(); if (selectedType === "incremental" || selectedType === "decremental") { stepGroup.classList.remove("hidden"); } else { stepGroup.classList.add("hidden"); } }; mappingTypeSelect.addEventListener('change', updateStepVisibility); updateStepVisibility(); // Initial check content.appendChild(transformContainer); } editor.appendChild(content); // Editor actions - Use controller-mapping-buttons structure const actions = document.createElement("div"); actions.className = "controller-mapping-buttons"; // Use class from dialog.css // Create Cancel Button (using dialog class) const cancelBtn = document.createElement("button"); cancelBtn.className = "controller-cancel-button"; // Use class from dialog.css cancelBtn.textContent = "CANCEL"; cancelBtn.onclick = () => document.body.removeChild(overlay); actions.appendChild(cancelBtn); // Create Save Button (using dialog class) const saveBtn = document.createElement("button"); saveBtn.className = "controller-map-button"; // Use class from dialog.css saveBtn.textContent = "SAVE CHANGES"; saveBtn.style.backgroundColor = "var(--cf-brand-primary)"; saveBtn.style.color = "white"; saveBtn.onclick = () => { try { // Collect updated data const updatedData = { mappingType: mappingTypeSelect.value, transform: { ...mapping.transform } // Start with existing transform values }; // Update transform properties if inputs exist if (minInput) updatedData.transform.targetMin = parseFloat(minInput.value); if (maxInput) updatedData.transform.targetMax = parseFloat(maxInput.value); // Only check stepGroup if it's defined (for widget targets only) if (stepInput) { // Check if step is applicable based on mapping type const isStepApplicable = ["incremental", "decremental"].includes(mappingTypeSelect.value.toLowerCase()); if (isStepApplicable) { updatedData.transform.stepSize = parseFloat(stepInput.value); } else { // Ensure stepSize is removed or nullified if not applicable delete updatedData.transform.stepSize; } } if (invertCheckbox) updatedData.transform.isInverted = invertCheckbox.checked; // <-- Get invert state // Update the mapping const mappingEngine = getMappingEngine(); if (!mappingEngine) { console.error("MappingComponent: Mapping engine not available for save"); showNotification("Mapping engine not available", "error"); return; } const success = mappingEngine.updateMapping(mappingId, updatedData); if (success) { // Save the changes to persistent storage mappingEngine.saveMappings(); showNotification("Mapping updated successfully", "success"); // Make sure overlay exists before trying to remove it if (overlay && document.body.contains(overlay)) { document.body.removeChild(overlay); } // Optional: Refresh the list component display if needed if (listContainer && typeof listContainer.refresh === 'function') { listContainer.refresh(); // Assuming a refresh method exists } else { // Fallback: Emit event for list to listen to eventBus.emit('mappings:changed'); } } else { showNotification("Failed to update mapping", "error"); } } catch (error) { console.error("Error saving mapping:", error); showNotification("Error: " + error.message, "error"); } }; actions.appendChild(saveBtn); editor.appendChild(actions); // Append editor to overlay, and overlay to body overlay.appendChild(editor); document.body.appendChild(overlay); // Focus first input maybe? const firstInput = editor.querySelector('input, select'); if(firstInput) firstInput.focus(); } /** * Get an icon for a control type * @param {string} controlType - The control type * @returns {string} - Icon representing the control type */ function getControlTypeIcon(controlType) { if (!controlType) return '?'; if (controlType.includes('midi')) { return '🎹'; } else if (controlType.includes('gamepad')) { if (controlType.includes('button')) { return '🔘'; } else if (controlType.includes('axis')) { return '↔️'; } return '🎮'; } return '🎛️'; } /** * Get an icon for a target type * @param {string} targetType - The target type * @returns {string} - Icon representing the target type */ function getTargetTypeIcon(targetType) { switch (targetType) { case 'widget': return '📊'; case 'command': return '🔨'; case 'ui_element': return '🖱️'; default: return '❓'; } } /** * Get a user-friendly name for a target * @param {Object} target - The target object * @returns {string} - A user-friendly name */ function getTargetName(target) { if (!target) return 'Unknown'; switch (target.type) { case 'widget': return `${target.nodeTitle || 'Node'} → ${target.widgetName || 'Widget'}`; case 'command': return target.label || target.commandId || 'Command'; case 'ui_element': return target.elementLabel || target.elementId || 'UI Element'; default: return 'Unknown Target'; } } /** * Get detailed information about a control * @param {Object} control - The control object * @returns {string} - Detailed information */ function getControlDetails(control) { if (!control) return 'Unknown Control'; let details = `Type: ${control.type}\n`; details += `Device: ${control.deviceId}\n`; details += `Control: ${control.id}`; return details; } /** * Get detailed information about a target * @param {Object} target - The target object * @returns {string} - Detailed information */ function getTargetDetails(target) { if (!target) return 'Unknown Target'; let details = `Type: ${target.type}\n`; switch (target.type) { case 'widget': details += `Node: ${target.nodeId}\n`; details += `Widget: ${target.widgetName}`; break; case 'command': details += `Command: ${target.commandId}`; break; case 'ui_element': details += `Element: ${target.elementId}`; break; } return details; } /** * Get a default name for a control if none is provided * @param {Object} control - The control object * @returns {string} - A default name */ function getDefaultControlName(control) { if (!control) return 'Unknown'; // Try to construct a sensible name based on type and ID const type = control.type || ''; const id = control.id || ''; if (type.includes('midi')) { if (id.startsWith('144_')) { return `MIDI Note ${id.split('_')[1]}`; } else if (id.startsWith('176_')) { return `MIDI CC ${id.split('_')[1]}`; } return `MIDI ${id}`; } else if (type.includes('gamepad')) { if (type.includes('button')) { return `Gamepad Button ${id.split('_')[1]}`; } else if (type.includes('axis')) { return `Gamepad Axis ${id.split('_')[1]}`; } return `Gamepad ${id}`; } return `Control ${id}`; } /** * Get a user-friendly label for a mapping type * @param {string} mappingType - The mapping type * @returns {string} - A user-friendly label */ function getMappingTypeLabel(mappingType) { if (!mappingType) return 'Default'; // Normalize the mapping type to lowercase for case-insensitive comparison const type = mappingType.toLowerCase(); switch (type) { case 'direct': return 'Direct'; case 'toggle': return 'Toggle'; case 'trigger': return 'Trigger'; case 'incremental': return 'Increment'; case 'decremental': return 'Decrement'; case 'momentary': return 'Momentary'; case 'absolute': return 'Direct'; // Map "Absolute" (from backend) to "Direct" (for UI) case 'increment': return 'Increment'; // Map "Increment" (from backend) to "Increment" (for UI) case 'decrement': return 'Decrement'; // Map "Decrement" (from backend) to "Decrement" (for UI) default: return mappingType; // Return original if unknown } } /** * Delete a mapping by ID * @param {string} mappingId - The ID of the mapping to delete * @returns {boolean} - Whether the deletion was successful */ export function deleteMapping(mappingId) { const mappingEngine = getMappingEngine(); if (!mappingEngine) { console.error('MappingComponent: Mapping engine not available'); return false; } // Delete the mapping using the mapping engine const success = mappingEngine.deleteMapping(mappingId); if (success) { // Emit event for mapping deleted - we use the mappingPanel eventBus listeners to refresh the UI eventBus.emit('mapping:deleted', { mappingId }); // Show notification if needed showNotification(`Mapping deleted`); } return success; }