diff --git a/index.js b/index.js index af7e5f3..d750dde 100644 --- a/index.js +++ b/index.js @@ -59,6 +59,41 @@ import { createThoughtPanel } from './src/systems/rendering/thoughts.js'; +// UI Systems modules +import { + applyTheme, + applyCustomTheme, + toggleCustomColors, + toggleAnimations, + updateSettingsPopupTheme, + applyCustomThemeToSettingsPopup +} from './src/systems/ui/theme.js'; +import { + DiceModal, + SettingsModal, + setupDiceRoller, + setupSettingsPopup, + updateDiceDisplay, + addDiceQuickReply, + getSettingsModal +} from './src/systems/ui/modals.js'; +import { + togglePlotButtons, + updateCollapseToggleIcon, + setupCollapseToggle, + updatePanelVisibility, + updateSectionVisibility, + applyPanelPosition +} from './src/systems/ui/layout.js'; +import { + setupMobileToggle, + constrainFabToViewport, + setupMobileTabs, + removeMobileTabs, + setupMobileKeyboardHandling, + setupContentEditableScrolling +} from './src/systems/ui/mobile.js'; + // Old state variable declarations removed - now imported from core modules // (extensionSettings, lastGeneratedData, committedTrackerData, etc. are now in src/core/state.js) @@ -68,76 +103,18 @@ import { // Persistence functions removed - now imported from src/core/persistence.js // (loadSettings, saveSettings, saveChatData, loadChatData, updateMessageSwipeData) -/** - * Applies the selected theme to the panel. - */ -function applyTheme() { - if (!$panelContainer) return; +// Theme functions removed - now imported from src/systems/ui/theme.js +// (applyTheme, applyCustomTheme, toggleCustomColors, toggleAnimations, +// updateSettingsPopupTheme, applyCustomThemeToSettingsPopup) - const theme = extensionSettings.theme; +// Layout functions removed - now imported from src/systems/ui/layout.js +// (togglePlotButtons, updateCollapseToggleIcon, setupCollapseToggle, +// updatePanelVisibility, updateSectionVisibility, applyPanelPosition) +// Note: closeMobilePanelWithAnimation is only used internally by mobile.js - // Remove all theme attributes first - $panelContainer.removeAttr('data-theme'); - - // Clear any inline CSS variable overrides - $panelContainer.css({ - '--rpg-bg': '', - '--rpg-accent': '', - '--rpg-text': '', - '--rpg-highlight': '', - '--rpg-border': '', - '--rpg-shadow': '' - }); - - // Apply the selected theme - if (theme === 'custom') { - applyCustomTheme(); - } else if (theme !== 'default') { - // For non-default themes, set the data-theme attribute - // which will trigger the CSS theme rules - $panelContainer.attr('data-theme', theme); - } - // For 'default', we do nothing - it will use the CSS variables from .rpg-panel class - // which fall back to SillyTavern's theme variables -} - -/** - * Applies custom colors when custom theme is selected. - */ -function applyCustomTheme() { - if (!$panelContainer) return; - - const colors = extensionSettings.customColors; - - // Apply custom CSS variables as inline styles - $panelContainer.css({ - '--rpg-bg': colors.bg, - '--rpg-accent': colors.accent, - '--rpg-text': colors.text, - '--rpg-highlight': colors.highlight, - '--rpg-border': colors.highlight, - '--rpg-shadow': `${colors.highlight}80` // Add alpha for shadow - }); -} - -/** - * Toggles visibility of custom color pickers. - */ -function toggleCustomColors() { - const isCustom = extensionSettings.theme === 'custom'; - $('#rpg-custom-colors').toggle(isCustom); -} - -/** - * Toggles animations on/off by adding/removing a class to the panel. - */ -function toggleAnimations() { - if (extensionSettings.enableAnimations) { - $panelContainer.addClass('rpg-animations-enabled'); - } else { - $panelContainer.removeClass('rpg-animations-enabled'); - } -} +// Mobile UI functions removed - now imported from src/systems/ui/mobile.js +// (setupMobileToggle, constrainFabToViewport, setupMobileTabs, removeMobileTabs, +// setupMobileKeyboardHandling, setupContentEditableScrolling) /** * Adds the extension settings to the Extensions tab. @@ -283,7 +260,7 @@ async function initUI() { saveSettings(); applyTheme(); toggleCustomColors(); - updateSettingsPopupTheme(); // Update popup theme instantly + updateSettingsPopupTheme(getSettingsModal()); // Update popup theme instantly updateChatThoughts(); // Recreate thought bubbles with new theme }); @@ -293,7 +270,7 @@ async function initUI() { saveSettings(); if (extensionSettings.theme === 'custom') { applyCustomTheme(); - updateSettingsPopupTheme(); // Update popup theme instantly + updateSettingsPopupTheme(getSettingsModal()); // Update popup theme instantly updateChatThoughts(); // Update thought bubbles } }); @@ -303,7 +280,7 @@ async function initUI() { saveSettings(); if (extensionSettings.theme === 'custom') { applyCustomTheme(); - updateSettingsPopupTheme(); // Update popup theme instantly + updateSettingsPopupTheme(getSettingsModal()); // Update popup theme instantly updateChatThoughts(); // Update thought bubbles } }); @@ -313,7 +290,7 @@ async function initUI() { saveSettings(); if (extensionSettings.theme === 'custom') { applyCustomTheme(); - updateSettingsPopupTheme(); // Update popup theme instantly + updateSettingsPopupTheme(getSettingsModal()); // Update popup theme instantly updateChatThoughts(); // Update thought bubbles } }); @@ -323,7 +300,7 @@ async function initUI() { saveSettings(); if (extensionSettings.theme === 'custom') { applyCustomTheme(); - updateSettingsPopupTheme(); // Update popup theme instantly + updateSettingsPopupTheme(getSettingsModal()); // Update popup theme instantly updateChatThoughts(); // Update thought bubbles } }); @@ -430,15 +407,6 @@ function setupPlotButtons() { // Show/hide based on setting togglePlotButtons(); -}/** - * Toggles the visibility of plot buttons based on settings. - */ -function togglePlotButtons() { - if (extensionSettings.enablePlotButtons && extensionSettings.enabled) { - $('#rpg-plot-buttons').show(); - } else { - $('#rpg-plot-buttons').hide(); - } } /** @@ -510,331 +478,6 @@ async function sendPlotProgression(type) { } } -/** - * Modern DiceModal ES6 Class - * Manages dice roller modal with proper state management and CSS classes - */ -class DiceModal { - constructor() { - this.modal = document.getElementById('rpg-dice-popup'); - this.animation = document.getElementById('rpg-dice-animation'); - this.result = document.getElementById('rpg-dice-result'); - this.resultValue = document.getElementById('rpg-dice-result-value'); - this.resultDetails = document.getElementById('rpg-dice-result-details'); - this.rollBtn = document.getElementById('rpg-dice-roll-btn'); - - this.state = 'IDLE'; // IDLE, ROLLING, SHOWING_RESULT - this.isAnimating = false; - } - - /** - * Opens the modal with proper animation - */ - open() { - if (this.isAnimating) return; - - // Apply theme - const theme = extensionSettings.theme; - this.modal.setAttribute('data-theme', theme); - - // Apply custom theme if needed - if (theme === 'custom') { - this._applyCustomTheme(); - } - - // Reset to initial state - this._setState('IDLE'); - - // Open modal with CSS class - this.modal.classList.add('is-open'); - this.modal.classList.remove('is-closing'); - - // Focus management - this.modal.querySelector('#rpg-dice-popup-close')?.focus(); - } - - /** - * Closes the modal with animation - */ - close() { - if (this.isAnimating) return; - - this.isAnimating = true; - this.modal.classList.add('is-closing'); - this.modal.classList.remove('is-open'); - - // Wait for animation to complete - setTimeout(() => { - this.modal.classList.remove('is-closing'); - this.isAnimating = false; - - // Clear pending roll - setPendingDiceRoll(null); - }, 200); - } - - /** - * Starts the rolling animation - */ - startRolling() { - this._setState('ROLLING'); - } - - /** - * Shows the result - * @param {number} total - The total roll value - * @param {Array} rolls - Individual roll values - */ - showResult(total, rolls) { - this._setState('SHOWING_RESULT'); - - // Update result values - this.resultValue.textContent = total; - this.resultValue.classList.add('is-animating'); - - // Remove animation class after it completes - setTimeout(() => { - this.resultValue.classList.remove('is-animating'); - }, 500); - - // Show details if multiple rolls - if (rolls && rolls.length > 1) { - this.resultDetails.textContent = `Rolls: ${rolls.join(', ')}`; - } else { - this.resultDetails.textContent = ''; - } - } - - /** - * Manages modal state changes - * @private - */ - _setState(newState) { - this.state = newState; - - switch (newState) { - case 'IDLE': - this.rollBtn.hidden = false; - this.animation.hidden = true; - this.result.hidden = true; - break; - - case 'ROLLING': - this.rollBtn.hidden = true; - this.animation.hidden = false; - this.result.hidden = true; - this.animation.setAttribute('aria-busy', 'true'); - break; - - case 'SHOWING_RESULT': - this.rollBtn.hidden = true; - this.animation.hidden = true; - this.result.hidden = false; - this.animation.setAttribute('aria-busy', 'false'); - break; - } - } - - /** - * Applies custom theme colors - * @private - */ - _applyCustomTheme() { - const content = this.modal.querySelector('.rpg-dice-popup-content'); - if (content && extensionSettings.customColors) { - content.style.setProperty('--rpg-bg', extensionSettings.customColors.bg); - content.style.setProperty('--rpg-accent', extensionSettings.customColors.accent); - content.style.setProperty('--rpg-text', extensionSettings.customColors.text); - content.style.setProperty('--rpg-highlight', extensionSettings.customColors.highlight); - } - } -} - -// Global instance -let diceModal = null; - -/** - * Sets up the dice roller functionality. - */ -function setupDiceRoller() { - // Initialize DiceModal instance - diceModal = new DiceModal(); - // Click dice display to open popup - $('#rpg-dice-display').on('click', function() { - openDicePopup(); - }); - - // Close popup - handle both close button and backdrop clicks - $('#rpg-dice-popup-close').on('click', function() { - closeDicePopup(); - }); - - // Close on backdrop click (clicking outside content) - $('#rpg-dice-popup').on('click', function(e) { - if (e.target === this) { - closeDicePopup(); - } - }); - - // Roll dice button - $('#rpg-dice-roll-btn').on('click', async function() { - await rollDice(); - }); - - // Save roll button (closes popup and saves the roll) - $('#rpg-dice-save-btn').on('click', function() { - // Save the pending roll - if (pendingDiceRoll) { - extensionSettings.lastDiceRoll = pendingDiceRoll; - saveSettings(); - updateDiceDisplay(); - setPendingDiceRoll(null); - } - closeDicePopup(); - }); - - // Reset on Enter key - $('#rpg-dice-count, #rpg-dice-sides').on('keypress', function(e) { - if (e.which === 13) { - rollDice(); - } - }); - - // Clear dice roll button - $('#rpg-clear-dice').on('click', function(e) { - e.stopPropagation(); // Prevent opening the dice popup - clearDiceRoll(); - }); -} - -/** - * Clears the last dice roll. - */ -function clearDiceRoll() { - extensionSettings.lastDiceRoll = null; - saveSettings(); - updateDiceDisplay(); -} - -/** - * Opens the dice rolling popup. - * Backwards compatible wrapper for DiceModal class. - */ -function openDicePopup() { - if (diceModal) { - diceModal.open(); - } -} - -/** - * Closes the dice rolling popup. - * Backwards compatible wrapper for DiceModal class. - */ -function closeDicePopup() { - if (diceModal) { - diceModal.close(); - } -} - -/** - * @deprecated Legacy function - use diceModal._applyCustomTheme() instead - */ -function applyCustomThemeToPopup() { - if (diceModal) { - diceModal._applyCustomTheme(); - } -} - -/** - * Rolls the dice and displays result. - * Refactored to use DiceModal class. - */ -async function rollDice() { - if (!diceModal) return; - - const count = parseInt(String($('#rpg-dice-count').val())) || 1; - const sides = parseInt(String($('#rpg-dice-sides').val())) || 20; - - // Start rolling animation - diceModal.startRolling(); - - // Wait for animation (simulate rolling) - await new Promise(resolve => setTimeout(resolve, 1200)); - - // Execute /roll command - const rollCommand = `/roll ${count}d${sides}`; - const rollResult = await executeRollCommand(rollCommand); - - // Parse result - const total = rollResult.total || 0; - const rolls = rollResult.rolls || []; - - // Store result temporarily (not saved until "Save Roll" is clicked) - setPendingDiceRoll({ - formula: `${count}d${sides}`, - total: total, - rolls: rolls, - timestamp: Date.now() - }); - - // Show result - diceModal.showResult(total, rolls); - - // Don't update sidebar display yet - only update when user clicks "Save Roll" -} - -/** - * Executes a /roll command and returns the result. - */ -async function executeRollCommand(command) { - try { - // Parse the dice notation (e.g., "2d20") - const match = command.match(/(\d+)d(\d+)/); - if (!match) { - return { total: 0, rolls: [] }; - } - - const count = parseInt(match[1]); - const sides = parseInt(match[2]); - const rolls = []; - let total = 0; - - for (let i = 0; i < count; i++) { - const roll = Math.floor(Math.random() * sides) + 1; - rolls.push(roll); - total += roll; - } - - return { total, rolls }; - } catch (error) { - console.error('[RPG Companion] Error rolling dice:', error); - return { total: 0, rolls: [] }; - } -} - -/** - * Updates the dice display in the sidebar. - */ -function updateDiceDisplay() { - const lastRoll = extensionSettings.lastDiceRoll; - if (lastRoll) { - $('#rpg-last-roll-text').text(`Last Roll (${lastRoll.formula}): ${lastRoll.total}`); - } else { - $('#rpg-last-roll-text').text('Last Roll: None'); - } -} - -/** - * Adds the Roll Dice quick reply button. - */ -function addDiceQuickReply() { - // Create quick reply button if Quick Replies exist - if (window.quickReplyApi) { - // Quick Reply API integration would go here - // For now, the dice display in the sidebar serves as the button - } -} - /** * Sets up event listeners for classic stat +/- buttons using delegation. * Uses delegated events to persist across re-renders of the stats section. @@ -867,1111 +510,6 @@ function setupClassicStatsButtons() { }); } -/** - * SettingsModal - Manages the settings popup modal - * Handles opening, closing, theming, and animations - */ -class SettingsModal { - constructor() { - this.modal = document.getElementById('rpg-settings-popup'); - this.content = this.modal?.querySelector('.rpg-settings-popup-content'); - this.isAnimating = false; - } - - /** - * Opens the modal with proper animation - */ - open() { - if (this.isAnimating || !this.modal) return; - - // Apply theme - const theme = extensionSettings.theme || 'default'; - this.modal.setAttribute('data-theme', theme); - - // Apply custom theme if needed - if (theme === 'custom') { - this._applyCustomTheme(); - } - - // Open modal with CSS class - this.modal.classList.add('is-open'); - this.modal.classList.remove('is-closing'); - - // Focus management - this.modal.querySelector('#rpg-close-settings')?.focus(); - } - - /** - * Closes the modal with animation - */ - close() { - if (this.isAnimating || !this.modal) return; - - this.isAnimating = true; - this.modal.classList.add('is-closing'); - this.modal.classList.remove('is-open'); - - // Wait for animation to complete - setTimeout(() => { - this.modal.classList.remove('is-closing'); - this.isAnimating = false; - }, 200); - } - - /** - * Updates the theme in real-time (used when theme selector changes) - */ - updateTheme() { - if (!this.modal) return; - - const theme = extensionSettings.theme || 'default'; - this.modal.setAttribute('data-theme', theme); - - if (theme === 'custom') { - this._applyCustomTheme(); - } else { - // Clear custom CSS variables to let theme CSS take over - this._clearCustomTheme(); - } - } - - /** - * Applies custom theme colors - * @private - */ - _applyCustomTheme() { - if (!this.content || !extensionSettings.customColors) return; - - this.content.style.setProperty('--rpg-bg', extensionSettings.customColors.bg); - this.content.style.setProperty('--rpg-accent', extensionSettings.customColors.accent); - this.content.style.setProperty('--rpg-text', extensionSettings.customColors.text); - this.content.style.setProperty('--rpg-highlight', extensionSettings.customColors.highlight); - } - - /** - * Clears custom theme colors - * @private - */ - _clearCustomTheme() { - if (!this.content) return; - - this.content.style.setProperty('--rpg-bg', ''); - this.content.style.setProperty('--rpg-accent', ''); - this.content.style.setProperty('--rpg-text', ''); - this.content.style.setProperty('--rpg-highlight', ''); - } -} - -// Global instance -let settingsModal = null; - -/** - * Opens the settings popup. - * Backwards compatible wrapper for SettingsModal class. - */ -function openSettingsPopup() { - if (settingsModal) { - settingsModal.open(); - } -} - -/** - * Closes the settings popup. - * Backwards compatible wrapper for SettingsModal class. - */ -function closeSettingsPopup() { - if (settingsModal) { - settingsModal.close(); - } -} - -/** - * Applies custom theme colors to the settings popup. - * Backwards compatible wrapper for SettingsModal class. - * @deprecated Use settingsModal.updateTheme() instead - */ -function applyCustomThemeToSettingsPopup() { - if (settingsModal) { - settingsModal._applyCustomTheme(); - } -} - -/** - * Updates the settings popup theme in real-time. - * Backwards compatible wrapper for SettingsModal class. - */ -function updateSettingsPopupTheme() { - if (settingsModal) { - settingsModal.updateTheme(); - } -} - -/** - * Sets up the settings popup functionality. - */ -function setupSettingsPopup() { - // Initialize SettingsModal instance - settingsModal = new SettingsModal(); - - // Open settings popup - $('#rpg-open-settings').on('click', function() { - openSettingsPopup(); - }); - - // Close settings popup - close button - $('#rpg-close-settings').on('click', function() { - closeSettingsPopup(); - }); - - // Close on backdrop click (clicking outside content) - $('#rpg-settings-popup').on('click', function(e) { - if (e.target === this) { - closeSettingsPopup(); - } - }); - - // Clear cache button - $('#rpg-clear-cache').on('click', function() { - // Clear the data - lastGeneratedData.userStats = null; - lastGeneratedData.infoBox = null; - lastGeneratedData.characterThoughts = null; - - // Clear committed tracker data (used for generation context) - committedTrackerData.userStats = null; - committedTrackerData.infoBox = null; - committedTrackerData.characterThoughts = null; - - // Clear all message swipe data - const chat = getContext().chat; - if (chat && chat.length > 0) { - for (let i = 0; i < chat.length; i++) { - const message = chat[i]; - if (message.extra && message.extra.rpg_companion_swipes) { - delete message.extra.rpg_companion_swipes; - // console.log('[RPG Companion] Cleared swipe data from message at index', i); - } - } - } - - // Clear the UI - if ($infoBoxContainer) { - $infoBoxContainer.empty(); - } - if ($thoughtsContainer) { - $thoughtsContainer.empty(); - } - - // Reset stats to defaults and re-render - extensionSettings.userStats = { - health: 100, - satiety: 100, - energy: 100, - hygiene: 100, - arousal: 0, - mood: '😐', - conditions: 'None', - inventory: 'None' - }; - - // Reset classic stats (attributes) to defaults - extensionSettings.classicStats = { - str: 10, - dex: 10, - con: 10, - int: 10, - wis: 10, - cha: 10 - }; - - // Clear dice roll - extensionSettings.lastDiceRoll = null; - - // Save everything - saveChatData(); - saveSettings(); - - // Re-render user stats and dice display - renderUserStats(); - updateDiceDisplay(); - updateChatThoughts(); // Clear the thought bubble in chat - - // console.log('[RPG Companion] Chat cache cleared'); - }); -} - -/** - * Helper function to close the mobile panel with animation. - */ -function closeMobilePanelWithAnimation() { - const $panel = $('#rpg-companion-panel'); - const $mobileToggle = $('#rpg-mobile-toggle'); - - // Add closing class to trigger slide-out animation - $panel.removeClass('rpg-mobile-open').addClass('rpg-mobile-closing'); - $mobileToggle.removeClass('active'); - - // Wait for animation to complete before hiding - $panel.one('animationend', function() { - $panel.removeClass('rpg-mobile-closing'); - $('.rpg-mobile-overlay').remove(); - }); -} - -/** - * Sets up the mobile toggle button (FAB). - */ -function setupMobileToggle() { - const $mobileToggle = $('#rpg-mobile-toggle'); - const $panel = $('#rpg-companion-panel'); - const $overlay = $('
'); - - // DIAGNOSTIC: Check if elements exist and log setup state - console.log('[RPG Mobile] ========================================'); - console.log('[RPG Mobile] setupMobileToggle called'); - console.log('[RPG Mobile] Button exists:', $mobileToggle.length > 0, 'jQuery object:', $mobileToggle); - console.log('[RPG Mobile] Panel exists:', $panel.length > 0); - console.log('[RPG Mobile] Window width:', window.innerWidth); - console.log('[RPG Mobile] Is mobile viewport (<=1000):', window.innerWidth <= 1000); - console.log('[RPG Mobile] ========================================'); - - if ($mobileToggle.length === 0) { - console.error('[RPG Mobile] ERROR: Mobile toggle button not found in DOM!'); - console.error('[RPG Mobile] Cannot attach event handlers - button does not exist'); - return; // Exit early if button doesn't exist - } - - // Load and apply saved FAB position - if (extensionSettings.mobileFabPosition) { - const pos = extensionSettings.mobileFabPosition; - console.log('[RPG Mobile] Loading saved FAB position:', pos); - - // Apply saved position - if (pos.top) $mobileToggle.css('top', pos.top); - if (pos.right) $mobileToggle.css('right', pos.right); - if (pos.bottom) $mobileToggle.css('bottom', pos.bottom); - if (pos.left) $mobileToggle.css('left', pos.left); - - // Constrain to viewport after position is applied - requestAnimationFrame(() => constrainFabToViewport()); - } - - // Touch/drag state - let isDragging = false; - let touchStartTime = 0; - let touchStartX = 0; - let touchStartY = 0; - let buttonStartX = 0; - let buttonStartY = 0; - const LONG_PRESS_DURATION = 200; // ms to hold before enabling drag - const MOVE_THRESHOLD = 10; // px to move before enabling drag - let rafId = null; // RequestAnimationFrame ID for smooth updates - let pendingX = null; - let pendingY = null; - - // Update position using requestAnimationFrame for smooth rendering - function updateFabPosition() { - if (pendingX !== null && pendingY !== null) { - $mobileToggle.css({ - left: pendingX + 'px', - top: pendingY + 'px', - right: 'auto', - bottom: 'auto' - }); - pendingX = null; - pendingY = null; - } - rafId = null; - } - - // Touch start - begin tracking - $mobileToggle.on('touchstart', function(e) { - const touch = e.originalEvent.touches[0]; - - touchStartTime = Date.now(); - touchStartX = touch.clientX; - touchStartY = touch.clientY; - - const offset = $mobileToggle.offset(); - buttonStartX = offset.left; - buttonStartY = offset.top; - - isDragging = false; - }); - - // Touch move - check if should start dragging - $mobileToggle.on('touchmove', function(e) { - const touch = e.originalEvent.touches[0]; - const deltaX = touch.clientX - touchStartX; - const deltaY = touch.clientY - touchStartY; - const timeSinceStart = Date.now() - touchStartTime; - const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); - - // Start dragging if held long enough OR moved far enough - if (!isDragging && (timeSinceStart > LONG_PRESS_DURATION || distance > MOVE_THRESHOLD)) { - isDragging = true; - $mobileToggle.addClass('dragging'); // Disable transitions while dragging - } - - if (isDragging) { - e.preventDefault(); // Prevent scrolling while dragging - - // Calculate new position - let newX = buttonStartX + deltaX; - let newY = buttonStartY + deltaY; - - // Get button dimensions - const buttonWidth = $mobileToggle.outerWidth(); - const buttonHeight = $mobileToggle.outerHeight(); - - // Constrain to viewport with 10px padding - const minX = 10; - const maxX = window.innerWidth - buttonWidth - 10; - const minY = 10; - const maxY = window.innerHeight - buttonHeight - 10; - - newX = Math.max(minX, Math.min(maxX, newX)); - newY = Math.max(minY, Math.min(maxY, newY)); - - // Store pending position and request animation frame for smooth update - pendingX = newX; - pendingY = newY; - if (!rafId) { - rafId = requestAnimationFrame(updateFabPosition); - } - } - }); - - // Mouse drag support for desktop - let mouseDown = false; - - $mobileToggle.on('mousedown', function(e) { - // Prevent default to avoid text selection - e.preventDefault(); - - touchStartTime = Date.now(); - touchStartX = e.clientX; - touchStartY = e.clientY; - - const offset = $mobileToggle.offset(); - buttonStartX = offset.left; - buttonStartY = offset.top; - - isDragging = false; - mouseDown = true; - }); - - // Mouse move - only track if mouse is down - $(document).on('mousemove', function(e) { - if (!mouseDown) return; - - const deltaX = e.clientX - touchStartX; - const deltaY = e.clientY - touchStartY; - const timeSinceStart = Date.now() - touchStartTime; - const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); - - // Start dragging if held long enough OR moved far enough - if (!isDragging && (timeSinceStart > LONG_PRESS_DURATION || distance > MOVE_THRESHOLD)) { - isDragging = true; - $mobileToggle.addClass('dragging'); // Disable transitions while dragging - } - - if (isDragging) { - e.preventDefault(); - - // Calculate new position - let newX = buttonStartX + deltaX; - let newY = buttonStartY + deltaY; - - // Get button dimensions - const buttonWidth = $mobileToggle.outerWidth(); - const buttonHeight = $mobileToggle.outerHeight(); - - // Constrain to viewport with 10px padding - const minX = 10; - const maxX = window.innerWidth - buttonWidth - 10; - const minY = 10; - const maxY = window.innerHeight - buttonHeight - 10; - - newX = Math.max(minX, Math.min(maxX, newX)); - newY = Math.max(minY, Math.min(maxY, newY)); - - // Store pending position and request animation frame for smooth update - pendingX = newX; - pendingY = newY; - if (!rafId) { - rafId = requestAnimationFrame(updateFabPosition); - } - } - }); - - // Mouse up - save position or let click handler toggle - $(document).on('mouseup', function(e) { - if (!mouseDown) return; - - mouseDown = false; - - if (isDragging) { - // Was dragging - save new position - const offset = $mobileToggle.offset(); - const newPosition = { - left: offset.left + 'px', - top: offset.top + 'px' - }; - - extensionSettings.mobileFabPosition = newPosition; - saveSettings(); - - console.log('[RPG Mobile] Saved new FAB position (mouse):', newPosition); - - // Constrain to viewport bounds (now that position is saved) - setTimeout(() => constrainFabToViewport(), 10); - - // Re-enable transitions with smooth animation - setTimeout(() => { - $mobileToggle.removeClass('dragging'); - }, 50); - - isDragging = false; - - // Prevent click from firing after drag - e.preventDefault(); - e.stopPropagation(); - - // Add flag to prevent click handler from firing - $mobileToggle.data('just-dragged', true); - setTimeout(() => { - $mobileToggle.data('just-dragged', false); - }, 100); - } - // If not dragging, let the click handler toggle the panel - }); - - // Touch end - save position or toggle panel - $mobileToggle.on('touchend', function(e) { - // TEMPORARILY COMMENTED FOR DIAGNOSIS - might be blocking click fallback - // e.preventDefault(); - - if (isDragging) { - // Was dragging - save new position - const offset = $mobileToggle.offset(); - const newPosition = { - left: offset.left + 'px', - top: offset.top + 'px' - }; - - extensionSettings.mobileFabPosition = newPosition; - saveSettings(); - - console.log('[RPG Mobile] Saved new FAB position:', newPosition); - - // Constrain to viewport bounds (now that position is saved) - setTimeout(() => constrainFabToViewport(), 10); - - // Re-enable transitions with smooth animation - setTimeout(() => { - $mobileToggle.removeClass('dragging'); - }, 50); - - isDragging = false; - } else { - // Was a tap - toggle panel - console.log('[RPG Mobile] Quick tap detected - toggling panel'); - - if ($panel.hasClass('rpg-mobile-open')) { - // Close panel with animation - closeMobilePanelWithAnimation(); - } else { - // Open panel - $panel.addClass('rpg-mobile-open'); - $('body').append($overlay); - $mobileToggle.addClass('active'); - - // Close when clicking overlay - $overlay.on('click', function() { - closeMobilePanelWithAnimation(); - }); - } - } - }); - - // Click handler - works on both mobile and desktop - $mobileToggle.on('click', function(e) { - // Skip if we just finished dragging - if ($mobileToggle.data('just-dragged')) { - console.log('[RPG Mobile] Click blocked - just finished dragging'); - return; - } - - console.log('[RPG Mobile] >>> CLICK EVENT FIRED <<<', { - windowWidth: window.innerWidth, - isMobileViewport: window.innerWidth <= 1000, - panelOpen: $panel.hasClass('rpg-mobile-open') - }); - - // Work on both mobile and desktop (removed viewport check) - if ($panel.hasClass('rpg-mobile-open')) { - console.log('[RPG Mobile] Click: Closing panel'); - closeMobilePanelWithAnimation(); - } else { - console.log('[RPG Mobile] Click: Opening panel'); - $panel.addClass('rpg-mobile-open'); - $('body').append($overlay); - $mobileToggle.addClass('active'); - - $overlay.on('click', function() { - console.log('[RPG Mobile] Overlay clicked - closing panel'); - closeMobilePanelWithAnimation(); - }); - } - }); - - // Handle viewport resize to manage desktop/mobile transitions - let wasMobile = window.innerWidth <= 1000; - let resizeTimer; - - $(window).on('resize', function() { - clearTimeout(resizeTimer); - - const isMobile = window.innerWidth <= 1000; - const $panel = $('#rpg-companion-panel'); - const $mobileToggle = $('#rpg-mobile-toggle'); - - // Transitioning from desktop to mobile - handle immediately for smooth transition - if (!wasMobile && isMobile) { - console.log('[RPG Mobile] Transitioning desktop -> mobile'); - - // Remove desktop positioning classes - $panel.removeClass('rpg-position-right rpg-position-left rpg-position-top'); - - // Clear collapsed state - mobile doesn't use collapse - $panel.removeClass('rpg-collapsed'); - - // Close panel on mobile with animation - closeMobilePanelWithAnimation(); - - // Clear any inline styles that might be overriding CSS - $panel.attr('style', ''); - - console.log('[RPG Mobile] After cleanup:', { - panelClasses: $panel.attr('class'), - inlineStyles: $panel.attr('style'), - panelPosition: { - top: $panel.css('top'), - bottom: $panel.css('bottom'), - transform: $panel.css('transform'), - visibility: $panel.css('visibility') - } - }); - - // Set up mobile tabs IMMEDIATELY (no debounce delay) - setupMobileTabs(); - - // Update icon for mobile state - updateCollapseToggleIcon(); - - wasMobile = isMobile; - return; - } - - // For mobile to desktop transition, use debounce - resizeTimer = setTimeout(function() { - const isMobile = window.innerWidth <= 1000; - - // Transitioning from mobile to desktop - if (wasMobile && !isMobile) { - // Disable transitions to prevent left→right slide animation - $panel.css('transition', 'none'); - - $panel.removeClass('rpg-mobile-open rpg-mobile-closing'); - $mobileToggle.removeClass('active'); - $('.rpg-mobile-overlay').remove(); - - // Restore desktop positioning class - const position = extensionSettings.panelPosition || 'right'; - $panel.addClass('rpg-position-' + position); - - // Remove mobile tabs structure - removeMobileTabs(); - - // Force reflow to apply position instantly - $panel[0].offsetHeight; - - // Re-enable transitions after positioned - setTimeout(function() { - $panel.css('transition', ''); - }, 50); - } - - wasMobile = isMobile; - - // Constrain FAB to viewport after resize (only if user has positioned it) - constrainFabToViewport(); - }, 150); // Debounce only for mobile→desktop - }); - - // Initialize mobile tabs if starting on mobile - const isMobile = window.innerWidth <= 1000; - if (isMobile) { - const $panel = $('#rpg-companion-panel'); - // Clear any inline styles - $panel.attr('style', ''); - - console.log('[RPG Mobile] Initial load on mobile viewport:', { - panelClasses: $panel.attr('class'), - inlineStyles: $panel.attr('style'), - panelPosition: { - top: $panel.css('top'), - bottom: $panel.css('top'), - transform: $panel.css('transform'), - visibility: $panel.css('visibility') - } - }); - setupMobileTabs(); - // Set initial icon for mobile - updateCollapseToggleIcon(); - } -} - -/** - * Constrains the mobile FAB button to viewport bounds with top-bar awareness. - * Only runs when button is in user-controlled state (mobileFabPosition exists). - * Ensures button never goes behind the top bar or outside viewport edges. - */ -function constrainFabToViewport() { - // Only constrain if user has set a custom position - if (!extensionSettings.mobileFabPosition) { - console.log('[RPG Mobile] Skipping viewport constraint - using CSS defaults'); - return; - } - - const $mobileToggle = $('#rpg-mobile-toggle'); - if ($mobileToggle.length === 0) return; - - // Skip if button is not visible - if (!$mobileToggle.is(':visible')) { - console.log('[RPG Mobile] Skipping viewport constraint - button not visible'); - return; - } - - // Get current position - const offset = $mobileToggle.offset(); - if (!offset) return; - - let currentX = offset.left; - let currentY = offset.top; - - const buttonWidth = $mobileToggle.outerWidth(); - const buttonHeight = $mobileToggle.outerHeight(); - - // Get top bar height from CSS variable (fallback to 50px if not set) - const topBarHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--topBarBlockSize')) || 50; - - // Calculate viewport bounds with padding - // Use top bar height + extra padding for top bound - const minX = 10; - const maxX = window.innerWidth - buttonWidth - 10; - const minY = topBarHeight + 60; // Top bar + extra space for visibility - const maxY = window.innerHeight - buttonHeight - 10; - - // Constrain to bounds - let newX = Math.max(minX, Math.min(maxX, currentX)); - let newY = Math.max(minY, Math.min(maxY, currentY)); - - // Only update if position changed - if (newX !== currentX || newY !== currentY) { - console.log('[RPG Mobile] Constraining FAB to viewport:', { - old: { x: currentX, y: currentY }, - new: { x: newX, y: newY }, - viewport: { width: window.innerWidth, height: window.innerHeight }, - topBarHeight - }); - - // Apply new position - $mobileToggle.css({ - left: newX + 'px', - top: newY + 'px', - right: 'auto', - bottom: 'auto' - }); - - // Save corrected position - extensionSettings.mobileFabPosition = { - left: newX + 'px', - top: newY + 'px' - }; - saveSettings(); - } -} - -/** - * Sets up mobile tab navigation for organizing content. - * Only runs on mobile viewports (<=1000px). - */ -function setupMobileTabs() { - const isMobile = window.innerWidth <= 1000; - if (!isMobile) return; - - // Check if tabs already exist - if ($('.rpg-mobile-tabs').length > 0) return; - - const $panel = $('#rpg-companion-panel'); - const $contentBox = $panel.find('.rpg-content-box'); - - // Get existing sections - const $userStats = $('#rpg-user-stats'); - const $infoBox = $('#rpg-info-box'); - const $thoughts = $('#rpg-thoughts'); - - // If no sections exist, nothing to organize - if ($userStats.length === 0 && $infoBox.length === 0 && $thoughts.length === 0) { - return; - } - - // Create tab navigation (only show tabs for sections that exist) - const tabs = []; - const hasInfoOrCharacters = $infoBox.length > 0 || $thoughts.length > 0; - - if ($userStats.length > 0) { - tabs.push(''); - } - // Combine Info and Characters into one tab - if (hasInfoOrCharacters) { - tabs.push(''); - } - - const $tabNav = $('
' + tabs.join('') + '
'); - - // Determine which tab should be active - let firstTab = ''; - if ($userStats.length > 0) firstTab = 'stats'; - else if (hasInfoOrCharacters) firstTab = 'info-characters'; - - // Create tab content wrappers - const $statsTab = $('
'); - const $infoCharactersTab = $('
'); - - // Create combined content wrapper for Info and Characters - const $combinedWrapper = $('
'); - - // Move sections into their respective tabs (detach to preserve event handlers) - if ($userStats.length > 0) { - $statsTab.append($userStats.detach()); - $userStats.show(); - } - if ($infoBox.length > 0) { - $combinedWrapper.append($infoBox.detach()); - $infoBox.show(); - } - if ($thoughts.length > 0) { - $combinedWrapper.append($thoughts.detach()); - $thoughts.show(); - } - - // Add combined wrapper to the info-characters tab - if (hasInfoOrCharacters) { - $infoCharactersTab.append($combinedWrapper); - } - - // Hide dividers on mobile - $('.rpg-divider').hide(); - - // Build mobile tab structure - const $mobileContainer = $('
'); - $mobileContainer.append($tabNav); - - // Only append tab content wrappers that have content - if ($userStats.length > 0) $mobileContainer.append($statsTab); - if (hasInfoOrCharacters) $mobileContainer.append($infoCharactersTab); - - // Insert mobile tab structure at the beginning of content box - $contentBox.prepend($mobileContainer); - - // Handle tab switching - $tabNav.find('.rpg-mobile-tab').on('click', function() { - const tabName = $(this).data('tab'); - - // Update active tab button - $tabNav.find('.rpg-mobile-tab').removeClass('active'); - $(this).addClass('active'); - - // Update active tab content - $mobileContainer.find('.rpg-mobile-tab-content').removeClass('active'); - $mobileContainer.find('[data-tab-content="' + tabName + '"]').addClass('active'); - }); -} - -/** - * Removes mobile tab navigation and restores desktop layout. - */ -function removeMobileTabs() { - // Get sections from tabs before removing - const $userStats = $('#rpg-user-stats').detach(); - const $infoBox = $('#rpg-info-box').detach(); - const $thoughts = $('#rpg-thoughts').detach(); - - // Remove mobile tab container - $('.rpg-mobile-container').remove(); - - // Get dividers - const $dividerStats = $('#rpg-divider-stats'); - const $dividerInfo = $('#rpg-divider-info'); - - // Restore original sections to content box in correct order - const $contentBox = $('.rpg-content-box'); - - // Re-insert sections in original order - if ($dividerStats.length) { - $dividerStats.before($userStats); - $dividerInfo.before($infoBox); - $contentBox.append($thoughts); - } else { - // Fallback if dividers don't exist - $contentBox.prepend($thoughts); - $contentBox.prepend($infoBox); - $contentBox.prepend($userStats); - } - - // Show sections and dividers - $userStats.show(); - $infoBox.show(); - $thoughts.show(); - $('.rpg-divider').show(); -} - -/** - * Sets up mobile keyboard handling using Visual Viewport API. - * Prevents layout squashing when keyboard appears by detecting - * viewport changes and adding CSS classes for adjustment. - */ -function setupMobileKeyboardHandling() { - if (!window.visualViewport) { - // console.log('[RPG Mobile] Visual Viewport API not supported'); - return; - } - - const $panel = $('#rpg-companion-panel'); - let keyboardVisible = false; - - // Listen for viewport resize (keyboard show/hide) - window.visualViewport.addEventListener('resize', () => { - // Only handle if panel is open on mobile - if (!$panel.hasClass('rpg-mobile-open')) return; - - const viewportHeight = window.visualViewport.height; - const windowHeight = window.innerHeight; - - // Keyboard visible if viewport significantly smaller than window - // Using 75% threshold to account for browser UI variations - const isKeyboardShowing = viewportHeight < windowHeight * 0.75; - - if (isKeyboardShowing && !keyboardVisible) { - // Keyboard just appeared - keyboardVisible = true; - $panel.addClass('rpg-keyboard-visible'); - // console.log('[RPG Mobile] Keyboard opened'); - } else if (!isKeyboardShowing && keyboardVisible) { - // Keyboard just disappeared - keyboardVisible = false; - $panel.removeClass('rpg-keyboard-visible'); - // console.log('[RPG Mobile] Keyboard closed'); - } - }); -} - -/** - * Handles focus on contenteditable fields to ensure they're visible when keyboard appears. - * Uses smooth scrolling to bring focused field into view with proper padding. - */ -function setupContentEditableScrolling() { - const $panel = $('#rpg-companion-panel'); - - // Use event delegation for all contenteditable fields - $panel.on('focusin', '[contenteditable="true"]', function(e) { - const $field = $(this); - - // Small delay to let keyboard animate in - setTimeout(() => { - // Scroll field into view with padding - // Using 'center' to ensure field is in middle of viewport - $field[0].scrollIntoView({ - behavior: 'smooth', - block: 'center', - inline: 'nearest' - }); - }, 300); - }); -} - -/** - * Sets up the collapse/expand toggle button for side panels. - */ -function setupCollapseToggle() { - const $collapseToggle = $('#rpg-collapse-toggle'); - const $panel = $('#rpg-companion-panel'); - const $icon = $collapseToggle.find('i'); - - $collapseToggle.on('click', function(e) { - e.preventDefault(); - e.stopPropagation(); - - const isMobile = window.innerWidth <= 1000; - - // On mobile: button toggles panel open/closed (same as desktop behavior) - if (isMobile) { - const isOpen = $panel.hasClass('rpg-mobile-open'); - console.log('[RPG Mobile] Collapse toggle clicked. Current state:', { - isOpen, - panelClasses: $panel.attr('class'), - inlineStyles: $panel.attr('style'), - panelPosition: { - top: $panel.css('top'), - bottom: $panel.css('bottom'), - transform: $panel.css('transform'), - visibility: $panel.css('visibility') - } - }); - - if (isOpen) { - // Close panel with animation - console.log('[RPG Mobile] Closing panel'); - closeMobilePanelWithAnimation(); - } else { - // Open panel - console.log('[RPG Mobile] Opening panel'); - $panel.addClass('rpg-mobile-open'); - const $overlay = $('
'); - $('body').append($overlay); - - // Debug: Check state after animation should complete - setTimeout(() => { - console.log('[RPG Mobile] 500ms after opening:', { - panelClasses: $panel.attr('class'), - hasOpenClass: $panel.hasClass('rpg-mobile-open'), - visibility: $panel.css('visibility'), - transform: $panel.css('transform'), - display: $panel.css('display'), - opacity: $panel.css('opacity') - }); - }, 500); - - // Close when clicking overlay - $overlay.on('click', function() { - console.log('[RPG Mobile] Overlay clicked - closing panel'); - closeMobilePanelWithAnimation(); - updateCollapseToggleIcon(); - }); - } - - // Update icon to reflect new state - updateCollapseToggleIcon(); - - console.log('[RPG Mobile] After toggle:', { - panelClasses: $panel.attr('class'), - inlineStyles: $panel.attr('style'), - panelPosition: { - top: $panel.css('top'), - bottom: $panel.css('bottom'), - transform: $panel.css('transform'), - visibility: $panel.css('visibility') - }, - gameContainer: { - opacity: $('.rpg-game-container').css('opacity'), - visibility: $('.rpg-game-container').css('visibility') - } - }); - return; - } - - // Desktop behavior: collapse/expand side panel - const isCollapsed = $panel.hasClass('rpg-collapsed'); - - if (isCollapsed) { - // Expand panel - $panel.removeClass('rpg-collapsed'); - - // Update icon based on position - if ($panel.hasClass('rpg-position-right')) { - $icon.removeClass('fa-chevron-left').addClass('fa-chevron-right'); - } else if ($panel.hasClass('rpg-position-left')) { - $icon.removeClass('fa-chevron-right').addClass('fa-chevron-left'); - } - } else { - // Collapse panel - $panel.addClass('rpg-collapsed'); - - // Update icon based on position - if ($panel.hasClass('rpg-position-right')) { - $icon.removeClass('fa-chevron-right').addClass('fa-chevron-left'); - } else if ($panel.hasClass('rpg-position-left')) { - $icon.removeClass('fa-chevron-left').addClass('fa-chevron-right'); - } - } - }); - - // Set initial icon direction based on panel position - updateCollapseToggleIcon(); -} - -/** - * Updates the collapse toggle icon direction based on panel position. - */ -function updateCollapseToggleIcon() { - const $collapseToggle = $('#rpg-collapse-toggle'); - const $panel = $('#rpg-companion-panel'); - const $icon = $collapseToggle.find('i'); - const isMobile = window.innerWidth <= 1000; - - if (isMobile) { - // Mobile: slides from right, use same icon logic as desktop right panel - const isOpen = $panel.hasClass('rpg-mobile-open'); - console.log('[RPG Mobile] updateCollapseToggleIcon:', { - isMobile: true, - isOpen, - settingIcon: isOpen ? 'chevron-left' : 'chevron-right' - }); - if (isOpen) { - // Panel open - chevron points left (to close/slide back right) - $icon.removeClass('fa-chevron-down fa-chevron-up fa-chevron-right').addClass('fa-chevron-left'); - } else { - // Panel closed - chevron points right (to open/slide in from right) - $icon.removeClass('fa-chevron-down fa-chevron-up fa-chevron-left').addClass('fa-chevron-right'); - } - } else { - // Desktop: icon direction based on panel position and collapsed state - const isCollapsed = $panel.hasClass('rpg-collapsed'); - - if (isCollapsed) { - // When collapsed, arrow points inward (to expand) - if ($panel.hasClass('rpg-position-right')) { - $icon.removeClass('fa-chevron-right').addClass('fa-chevron-left'); - } else if ($panel.hasClass('rpg-position-left')) { - $icon.removeClass('fa-chevron-left').addClass('fa-chevron-right'); - } - } else { - // When expanded, arrow points outward (to collapse) - if ($panel.hasClass('rpg-position-right')) { - $icon.removeClass('fa-chevron-left').addClass('fa-chevron-right'); - } else if ($panel.hasClass('rpg-position-left')) { - $icon.removeClass('fa-chevron-right').addClass('fa-chevron-left'); - } - } - } -} - -/** - * Updates the visibility of the entire panel. - */ -function updatePanelVisibility() { - if (extensionSettings.enabled) { - $panelContainer.show(); - togglePlotButtons(); // Update plot button visibility - } else { - $panelContainer.hide(); - $('#rpg-plot-buttons').hide(); // Hide plot buttons when disabled - } -} - /** * Clears all extension prompts. */ @@ -1984,53 +522,6 @@ function clearExtensionPrompts() { // console.log('[RPG Companion] Cleared all extension prompts'); } -/** - * Updates the visibility of individual sections. - */ -function updateSectionVisibility() { - // Show/hide sections based on settings - $userStatsContainer.toggle(extensionSettings.showUserStats); - $infoBoxContainer.toggle(extensionSettings.showInfoBox); - $thoughtsContainer.toggle(extensionSettings.showCharacterThoughts); - - // Show/hide dividers intelligently - // Divider after User Stats: shown if User Stats is visible AND at least one section after it is visible - const showDividerAfterStats = extensionSettings.showUserStats && - (extensionSettings.showInfoBox || extensionSettings.showCharacterThoughts); - $('#rpg-divider-stats').toggle(showDividerAfterStats); - - // Divider after Info Box: shown if Info Box is visible AND Mind Reading is visible - const showDividerAfterInfo = extensionSettings.showInfoBox && - extensionSettings.showCharacterThoughts; - $('#rpg-divider-info').toggle(showDividerAfterInfo); -} - -/** - * Applies the selected panel position. - */ -function applyPanelPosition() { - if (!$panelContainer) return; - - const isMobile = window.innerWidth <= 1000; - - // Remove all position classes - $panelContainer.removeClass('rpg-position-left rpg-position-right rpg-position-top'); - - // On mobile, don't apply desktop position classes - if (isMobile) { - return; - } - - // Desktop: Add the appropriate position class - $panelContainer.addClass(`rpg-position-${extensionSettings.panelPosition}`); - - // Update collapse toggle icon direction for new position - updateCollapseToggleIcon(); -} - -/** - * Updates the model selector visibility. - */ /** * Updates the UI based on generation mode selection. */ diff --git a/src/systems/ui/layout.js b/src/systems/ui/layout.js new file mode 100644 index 0000000..2a5c88a --- /dev/null +++ b/src/systems/ui/layout.js @@ -0,0 +1,254 @@ +/** + * Layout Management Module + * Handles panel visibility, section visibility, collapse/expand toggle, and panel positioning + */ + +import { + extensionSettings, + $panelContainer, + $userStatsContainer, + $infoBoxContainer, + $thoughtsContainer +} from '../../core/state.js'; + +/** + * Toggles the visibility of plot buttons based on settings. + */ +export function togglePlotButtons() { + if (extensionSettings.enablePlotButtons && extensionSettings.enabled) { + $('#rpg-plot-buttons').show(); + } else { + $('#rpg-plot-buttons').hide(); + } +} + +/** + * Helper function to close the mobile panel with animation. + */ +export function closeMobilePanelWithAnimation() { + const $panel = $('#rpg-companion-panel'); + const $mobileToggle = $('#rpg-mobile-toggle'); + + // Add closing class to trigger slide-out animation + $panel.removeClass('rpg-mobile-open').addClass('rpg-mobile-closing'); + $mobileToggle.removeClass('active'); + + // Wait for animation to complete before hiding + $panel.one('animationend', function() { + $panel.removeClass('rpg-mobile-closing'); + $('.rpg-mobile-overlay').remove(); + }); +} + +/** + * Updates the collapse toggle icon direction based on panel position. + */ +export function updateCollapseToggleIcon() { + const $collapseToggle = $('#rpg-collapse-toggle'); + const $panel = $('#rpg-companion-panel'); + const $icon = $collapseToggle.find('i'); + const isMobile = window.innerWidth <= 1000; + + if (isMobile) { + // Mobile: slides from right, use same icon logic as desktop right panel + const isOpen = $panel.hasClass('rpg-mobile-open'); + console.log('[RPG Mobile] updateCollapseToggleIcon:', { + isMobile: true, + isOpen, + settingIcon: isOpen ? 'chevron-left' : 'chevron-right' + }); + if (isOpen) { + // Panel open - chevron points left (to close/slide back right) + $icon.removeClass('fa-chevron-down fa-chevron-up fa-chevron-right').addClass('fa-chevron-left'); + } else { + // Panel closed - chevron points right (to open/slide in from right) + $icon.removeClass('fa-chevron-down fa-chevron-up fa-chevron-left').addClass('fa-chevron-right'); + } + } else { + // Desktop: icon direction based on panel position and collapsed state + const isCollapsed = $panel.hasClass('rpg-collapsed'); + + if (isCollapsed) { + // When collapsed, arrow points inward (to expand) + if ($panel.hasClass('rpg-position-right')) { + $icon.removeClass('fa-chevron-right').addClass('fa-chevron-left'); + } else if ($panel.hasClass('rpg-position-left')) { + $icon.removeClass('fa-chevron-left').addClass('fa-chevron-right'); + } + } else { + // When expanded, arrow points outward (to collapse) + if ($panel.hasClass('rpg-position-right')) { + $icon.removeClass('fa-chevron-left').addClass('fa-chevron-right'); + } else if ($panel.hasClass('rpg-position-left')) { + $icon.removeClass('fa-chevron-right').addClass('fa-chevron-left'); + } + } + } +} + +/** + * Sets up the collapse/expand toggle button for side panels. + */ +export function setupCollapseToggle() { + const $collapseToggle = $('#rpg-collapse-toggle'); + const $panel = $('#rpg-companion-panel'); + const $icon = $collapseToggle.find('i'); + + $collapseToggle.on('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + + const isMobile = window.innerWidth <= 1000; + + // On mobile: button toggles panel open/closed (same as desktop behavior) + if (isMobile) { + const isOpen = $panel.hasClass('rpg-mobile-open'); + console.log('[RPG Mobile] Collapse toggle clicked. Current state:', { + isOpen, + panelClasses: $panel.attr('class'), + inlineStyles: $panel.attr('style'), + panelPosition: { + top: $panel.css('top'), + bottom: $panel.css('bottom'), + transform: $panel.css('transform'), + visibility: $panel.css('visibility') + } + }); + + if (isOpen) { + // Close panel with animation + console.log('[RPG Mobile] Closing panel'); + closeMobilePanelWithAnimation(); + } else { + // Open panel + console.log('[RPG Mobile] Opening panel'); + $panel.addClass('rpg-mobile-open'); + const $overlay = $('
'); + $('body').append($overlay); + + // Debug: Check state after animation should complete + setTimeout(() => { + console.log('[RPG Mobile] 500ms after opening:', { + panelClasses: $panel.attr('class'), + hasOpenClass: $panel.hasClass('rpg-mobile-open'), + visibility: $panel.css('visibility'), + transform: $panel.css('transform'), + display: $panel.css('display'), + opacity: $panel.css('opacity') + }); + }, 500); + + // Close when clicking overlay + $overlay.on('click', function() { + console.log('[RPG Mobile] Overlay clicked - closing panel'); + closeMobilePanelWithAnimation(); + updateCollapseToggleIcon(); + }); + } + + // Update icon to reflect new state + updateCollapseToggleIcon(); + + console.log('[RPG Mobile] After toggle:', { + panelClasses: $panel.attr('class'), + inlineStyles: $panel.attr('style'), + panelPosition: { + top: $panel.css('top'), + bottom: $panel.css('bottom'), + transform: $panel.css('transform'), + visibility: $panel.css('visibility') + }, + gameContainer: { + opacity: $('.rpg-game-container').css('opacity'), + visibility: $('.rpg-game-container').css('visibility') + } + }); + return; + } + + // Desktop behavior: collapse/expand side panel + const isCollapsed = $panel.hasClass('rpg-collapsed'); + + if (isCollapsed) { + // Expand panel + $panel.removeClass('rpg-collapsed'); + + // Update icon based on position + if ($panel.hasClass('rpg-position-right')) { + $icon.removeClass('fa-chevron-left').addClass('fa-chevron-right'); + } else if ($panel.hasClass('rpg-position-left')) { + $icon.removeClass('fa-chevron-right').addClass('fa-chevron-left'); + } + } else { + // Collapse panel + $panel.addClass('rpg-collapsed'); + + // Update icon based on position + if ($panel.hasClass('rpg-position-right')) { + $icon.removeClass('fa-chevron-right').addClass('fa-chevron-left'); + } else if ($panel.hasClass('rpg-position-left')) { + $icon.removeClass('fa-chevron-left').addClass('fa-chevron-right'); + } + } + }); + + // Set initial icon direction based on panel position + updateCollapseToggleIcon(); +} + +/** + * Updates the visibility of the entire panel. + */ +export function updatePanelVisibility() { + if (extensionSettings.enabled) { + $panelContainer.show(); + togglePlotButtons(); // Update plot button visibility + } else { + $panelContainer.hide(); + $('#rpg-plot-buttons').hide(); // Hide plot buttons when disabled + } +} + +/** + * Updates the visibility of individual sections. + */ +export function updateSectionVisibility() { + // Show/hide sections based on settings + $userStatsContainer.toggle(extensionSettings.showUserStats); + $infoBoxContainer.toggle(extensionSettings.showInfoBox); + $thoughtsContainer.toggle(extensionSettings.showCharacterThoughts); + + // Show/hide dividers intelligently + // Divider after User Stats: shown if User Stats is visible AND at least one section after it is visible + const showDividerAfterStats = extensionSettings.showUserStats && + (extensionSettings.showInfoBox || extensionSettings.showCharacterThoughts); + $('#rpg-divider-stats').toggle(showDividerAfterStats); + + // Divider after Info Box: shown if Info Box is visible AND Mind Reading is visible + const showDividerAfterInfo = extensionSettings.showInfoBox && + extensionSettings.showCharacterThoughts; + $('#rpg-divider-info').toggle(showDividerAfterInfo); +} + +/** + * Applies the selected panel position. + */ +export function applyPanelPosition() { + if (!$panelContainer) return; + + const isMobile = window.innerWidth <= 1000; + + // Remove all position classes + $panelContainer.removeClass('rpg-position-left rpg-position-right rpg-position-top'); + + // On mobile, don't apply desktop position classes + if (isMobile) { + return; + } + + // Desktop: Add the appropriate position class + $panelContainer.addClass(`rpg-position-${extensionSettings.panelPosition}`); + + // Update collapse toggle icon direction for new position + updateCollapseToggleIcon(); +} diff --git a/src/systems/ui/mobile.js b/src/systems/ui/mobile.js new file mode 100644 index 0000000..707ae12 --- /dev/null +++ b/src/systems/ui/mobile.js @@ -0,0 +1,694 @@ +/** + * Mobile UI Module + * Handles mobile-specific UI functionality: FAB dragging, tabs, keyboard handling + */ + +import { extensionSettings } from '../../core/state.js'; +import { saveSettings } from '../../core/persistence.js'; +import { closeMobilePanelWithAnimation, updateCollapseToggleIcon } from './layout.js'; + +/** + * Sets up the mobile toggle button (FAB) with drag functionality. + * Handles touch/mouse events for positioning and panel toggling. + */ +export function setupMobileToggle() { + const $mobileToggle = $('#rpg-mobile-toggle'); + const $panel = $('#rpg-companion-panel'); + const $overlay = $('
'); + + // DIAGNOSTIC: Check if elements exist and log setup state + console.log('[RPG Mobile] ========================================'); + console.log('[RPG Mobile] setupMobileToggle called'); + console.log('[RPG Mobile] Button exists:', $mobileToggle.length > 0, 'jQuery object:', $mobileToggle); + console.log('[RPG Mobile] Panel exists:', $panel.length > 0); + console.log('[RPG Mobile] Window width:', window.innerWidth); + console.log('[RPG Mobile] Is mobile viewport (<=1000):', window.innerWidth <= 1000); + console.log('[RPG Mobile] ========================================'); + + if ($mobileToggle.length === 0) { + console.error('[RPG Mobile] ERROR: Mobile toggle button not found in DOM!'); + console.error('[RPG Mobile] Cannot attach event handlers - button does not exist'); + return; // Exit early if button doesn't exist + } + + // Load and apply saved FAB position + if (extensionSettings.mobileFabPosition) { + const pos = extensionSettings.mobileFabPosition; + console.log('[RPG Mobile] Loading saved FAB position:', pos); + + // Apply saved position + if (pos.top) $mobileToggle.css('top', pos.top); + if (pos.right) $mobileToggle.css('right', pos.right); + if (pos.bottom) $mobileToggle.css('bottom', pos.bottom); + if (pos.left) $mobileToggle.css('left', pos.left); + + // Constrain to viewport after position is applied + requestAnimationFrame(() => constrainFabToViewport()); + } + + // Touch/drag state + let isDragging = false; + let touchStartTime = 0; + let touchStartX = 0; + let touchStartY = 0; + let buttonStartX = 0; + let buttonStartY = 0; + const LONG_PRESS_DURATION = 200; // ms to hold before enabling drag + const MOVE_THRESHOLD = 10; // px to move before enabling drag + let rafId = null; // RequestAnimationFrame ID for smooth updates + let pendingX = null; + let pendingY = null; + + // Update position using requestAnimationFrame for smooth rendering + function updateFabPosition() { + if (pendingX !== null && pendingY !== null) { + $mobileToggle.css({ + left: pendingX + 'px', + top: pendingY + 'px', + right: 'auto', + bottom: 'auto' + }); + pendingX = null; + pendingY = null; + } + rafId = null; + } + + // Touch start - begin tracking + $mobileToggle.on('touchstart', function(e) { + const touch = e.originalEvent.touches[0]; + + touchStartTime = Date.now(); + touchStartX = touch.clientX; + touchStartY = touch.clientY; + + const offset = $mobileToggle.offset(); + buttonStartX = offset.left; + buttonStartY = offset.top; + + isDragging = false; + }); + + // Touch move - check if should start dragging + $mobileToggle.on('touchmove', function(e) { + const touch = e.originalEvent.touches[0]; + const deltaX = touch.clientX - touchStartX; + const deltaY = touch.clientY - touchStartY; + const timeSinceStart = Date.now() - touchStartTime; + const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + + // Start dragging if held long enough OR moved far enough + if (!isDragging && (timeSinceStart > LONG_PRESS_DURATION || distance > MOVE_THRESHOLD)) { + isDragging = true; + $mobileToggle.addClass('dragging'); // Disable transitions while dragging + } + + if (isDragging) { + e.preventDefault(); // Prevent scrolling while dragging + + // Calculate new position + let newX = buttonStartX + deltaX; + let newY = buttonStartY + deltaY; + + // Get button dimensions + const buttonWidth = $mobileToggle.outerWidth(); + const buttonHeight = $mobileToggle.outerHeight(); + + // Constrain to viewport with 10px padding + const minX = 10; + const maxX = window.innerWidth - buttonWidth - 10; + const minY = 10; + const maxY = window.innerHeight - buttonHeight - 10; + + newX = Math.max(minX, Math.min(maxX, newX)); + newY = Math.max(minY, Math.min(maxY, newY)); + + // Store pending position and request animation frame for smooth update + pendingX = newX; + pendingY = newY; + if (!rafId) { + rafId = requestAnimationFrame(updateFabPosition); + } + } + }); + + // Mouse drag support for desktop + let mouseDown = false; + + $mobileToggle.on('mousedown', function(e) { + // Prevent default to avoid text selection + e.preventDefault(); + + touchStartTime = Date.now(); + touchStartX = e.clientX; + touchStartY = e.clientY; + + const offset = $mobileToggle.offset(); + buttonStartX = offset.left; + buttonStartY = offset.top; + + isDragging = false; + mouseDown = true; + }); + + // Mouse move - only track if mouse is down + $(document).on('mousemove', function(e) { + if (!mouseDown) return; + + const deltaX = e.clientX - touchStartX; + const deltaY = e.clientY - touchStartY; + const timeSinceStart = Date.now() - touchStartTime; + const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + + // Start dragging if held long enough OR moved far enough + if (!isDragging && (timeSinceStart > LONG_PRESS_DURATION || distance > MOVE_THRESHOLD)) { + isDragging = true; + $mobileToggle.addClass('dragging'); // Disable transitions while dragging + } + + if (isDragging) { + e.preventDefault(); + + // Calculate new position + let newX = buttonStartX + deltaX; + let newY = buttonStartY + deltaY; + + // Get button dimensions + const buttonWidth = $mobileToggle.outerWidth(); + const buttonHeight = $mobileToggle.outerHeight(); + + // Constrain to viewport with 10px padding + const minX = 10; + const maxX = window.innerWidth - buttonWidth - 10; + const minY = 10; + const maxY = window.innerHeight - buttonHeight - 10; + + newX = Math.max(minX, Math.min(maxX, newX)); + newY = Math.max(minY, Math.min(maxY, newY)); + + // Store pending position and request animation frame for smooth update + pendingX = newX; + pendingY = newY; + if (!rafId) { + rafId = requestAnimationFrame(updateFabPosition); + } + } + }); + + // Mouse up - save position or let click handler toggle + $(document).on('mouseup', function(e) { + if (!mouseDown) return; + + mouseDown = false; + + if (isDragging) { + // Was dragging - save new position + const offset = $mobileToggle.offset(); + const newPosition = { + left: offset.left + 'px', + top: offset.top + 'px' + }; + + extensionSettings.mobileFabPosition = newPosition; + saveSettings(); + + console.log('[RPG Mobile] Saved new FAB position (mouse):', newPosition); + + // Constrain to viewport bounds (now that position is saved) + setTimeout(() => constrainFabToViewport(), 10); + + // Re-enable transitions with smooth animation + setTimeout(() => { + $mobileToggle.removeClass('dragging'); + }, 50); + + isDragging = false; + + // Prevent click from firing after drag + e.preventDefault(); + e.stopPropagation(); + + // Add flag to prevent click handler from firing + $mobileToggle.data('just-dragged', true); + setTimeout(() => { + $mobileToggle.data('just-dragged', false); + }, 100); + } + // If not dragging, let the click handler toggle the panel + }); + + // Touch end - save position or toggle panel + $mobileToggle.on('touchend', function(e) { + // TEMPORARILY COMMENTED FOR DIAGNOSIS - might be blocking click fallback + // e.preventDefault(); + + if (isDragging) { + // Was dragging - save new position + const offset = $mobileToggle.offset(); + const newPosition = { + left: offset.left + 'px', + top: offset.top + 'px' + }; + + extensionSettings.mobileFabPosition = newPosition; + saveSettings(); + + console.log('[RPG Mobile] Saved new FAB position:', newPosition); + + // Constrain to viewport bounds (now that position is saved) + setTimeout(() => constrainFabToViewport(), 10); + + // Re-enable transitions with smooth animation + setTimeout(() => { + $mobileToggle.removeClass('dragging'); + }, 50); + + isDragging = false; + } else { + // Was a tap - toggle panel + console.log('[RPG Mobile] Quick tap detected - toggling panel'); + + if ($panel.hasClass('rpg-mobile-open')) { + // Close panel with animation + closeMobilePanelWithAnimation(); + } else { + // Open panel + $panel.addClass('rpg-mobile-open'); + $('body').append($overlay); + $mobileToggle.addClass('active'); + + // Close when clicking overlay + $overlay.on('click', function() { + closeMobilePanelWithAnimation(); + }); + } + } + }); + + // Click handler - works on both mobile and desktop + $mobileToggle.on('click', function(e) { + // Skip if we just finished dragging + if ($mobileToggle.data('just-dragged')) { + console.log('[RPG Mobile] Click blocked - just finished dragging'); + return; + } + + console.log('[RPG Mobile] >>> CLICK EVENT FIRED <<<', { + windowWidth: window.innerWidth, + isMobileViewport: window.innerWidth <= 1000, + panelOpen: $panel.hasClass('rpg-mobile-open') + }); + + // Work on both mobile and desktop (removed viewport check) + if ($panel.hasClass('rpg-mobile-open')) { + console.log('[RPG Mobile] Click: Closing panel'); + closeMobilePanelWithAnimation(); + } else { + console.log('[RPG Mobile] Click: Opening panel'); + $panel.addClass('rpg-mobile-open'); + $('body').append($overlay); + $mobileToggle.addClass('active'); + + $overlay.on('click', function() { + console.log('[RPG Mobile] Overlay clicked - closing panel'); + closeMobilePanelWithAnimation(); + }); + } + }); + + // Handle viewport resize to manage desktop/mobile transitions + let wasMobile = window.innerWidth <= 1000; + let resizeTimer; + + $(window).on('resize', function() { + clearTimeout(resizeTimer); + + const isMobile = window.innerWidth <= 1000; + const $panel = $('#rpg-companion-panel'); + const $mobileToggle = $('#rpg-mobile-toggle'); + + // Transitioning from desktop to mobile - handle immediately for smooth transition + if (!wasMobile && isMobile) { + console.log('[RPG Mobile] Transitioning desktop -> mobile'); + + // Remove desktop positioning classes + $panel.removeClass('rpg-position-right rpg-position-left rpg-position-top'); + + // Clear collapsed state - mobile doesn't use collapse + $panel.removeClass('rpg-collapsed'); + + // Close panel on mobile with animation + closeMobilePanelWithAnimation(); + + // Clear any inline styles that might be overriding CSS + $panel.attr('style', ''); + + console.log('[RPG Mobile] After cleanup:', { + panelClasses: $panel.attr('class'), + inlineStyles: $panel.attr('style'), + panelPosition: { + top: $panel.css('top'), + bottom: $panel.css('bottom'), + transform: $panel.css('transform'), + visibility: $panel.css('visibility') + } + }); + + // Set up mobile tabs IMMEDIATELY (no debounce delay) + setupMobileTabs(); + + // Update icon for mobile state + updateCollapseToggleIcon(); + + wasMobile = isMobile; + return; + } + + // For mobile to desktop transition, use debounce + resizeTimer = setTimeout(function() { + const isMobile = window.innerWidth <= 1000; + + // Transitioning from mobile to desktop + if (wasMobile && !isMobile) { + // Disable transitions to prevent left→right slide animation + $panel.css('transition', 'none'); + + $panel.removeClass('rpg-mobile-open rpg-mobile-closing'); + $mobileToggle.removeClass('active'); + $('.rpg-mobile-overlay').remove(); + + // Restore desktop positioning class + const position = extensionSettings.panelPosition || 'right'; + $panel.addClass('rpg-position-' + position); + + // Remove mobile tabs structure + removeMobileTabs(); + + // Force reflow to apply position instantly + $panel[0].offsetHeight; + + // Re-enable transitions after positioned + setTimeout(function() { + $panel.css('transition', ''); + }, 50); + } + + wasMobile = isMobile; + + // Constrain FAB to viewport after resize (only if user has positioned it) + constrainFabToViewport(); + }, 150); // Debounce only for mobile→desktop + }); + + // Initialize mobile tabs if starting on mobile + const isMobile = window.innerWidth <= 1000; + if (isMobile) { + const $panel = $('#rpg-companion-panel'); + // Clear any inline styles + $panel.attr('style', ''); + + console.log('[RPG Mobile] Initial load on mobile viewport:', { + panelClasses: $panel.attr('class'), + inlineStyles: $panel.attr('style'), + panelPosition: { + top: $panel.css('top'), + bottom: $panel.css('top'), + transform: $panel.css('transform'), + visibility: $panel.css('visibility') + } + }); + setupMobileTabs(); + // Set initial icon for mobile + updateCollapseToggleIcon(); + } +} + +/** + * Constrains the mobile FAB button to viewport bounds with top-bar awareness. + * Only runs when button is in user-controlled state (mobileFabPosition exists). + * Ensures button never goes behind the top bar or outside viewport edges. + */ +export function constrainFabToViewport() { + // Only constrain if user has set a custom position + if (!extensionSettings.mobileFabPosition) { + console.log('[RPG Mobile] Skipping viewport constraint - using CSS defaults'); + return; + } + + const $mobileToggle = $('#rpg-mobile-toggle'); + if ($mobileToggle.length === 0) return; + + // Skip if button is not visible + if (!$mobileToggle.is(':visible')) { + console.log('[RPG Mobile] Skipping viewport constraint - button not visible'); + return; + } + + // Get current position + const offset = $mobileToggle.offset(); + if (!offset) return; + + let currentX = offset.left; + let currentY = offset.top; + + const buttonWidth = $mobileToggle.outerWidth(); + const buttonHeight = $mobileToggle.outerHeight(); + + // Get top bar height from CSS variable (fallback to 50px if not set) + const topBarHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--topBarBlockSize')) || 50; + + // Calculate viewport bounds with padding + // Use top bar height + extra padding for top bound + const minX = 10; + const maxX = window.innerWidth - buttonWidth - 10; + const minY = topBarHeight + 60; // Top bar + extra space for visibility + const maxY = window.innerHeight - buttonHeight - 10; + + // Constrain to bounds + let newX = Math.max(minX, Math.min(maxX, currentX)); + let newY = Math.max(minY, Math.min(maxY, currentY)); + + // Only update if position changed + if (newX !== currentX || newY !== currentY) { + console.log('[RPG Mobile] Constraining FAB to viewport:', { + old: { x: currentX, y: currentY }, + new: { x: newX, y: newY }, + viewport: { width: window.innerWidth, height: window.innerHeight }, + topBarHeight + }); + + // Apply new position + $mobileToggle.css({ + left: newX + 'px', + top: newY + 'px', + right: 'auto', + bottom: 'auto' + }); + + // Save corrected position + extensionSettings.mobileFabPosition = { + left: newX + 'px', + top: newY + 'px' + }; + saveSettings(); + } +} + +/** + * Sets up mobile tab navigation for organizing content. + * Only runs on mobile viewports (<=1000px). + */ +export function setupMobileTabs() { + const isMobile = window.innerWidth <= 1000; + if (!isMobile) return; + + // Check if tabs already exist + if ($('.rpg-mobile-tabs').length > 0) return; + + const $panel = $('#rpg-companion-panel'); + const $contentBox = $panel.find('.rpg-content-box'); + + // Get existing sections + const $userStats = $('#rpg-user-stats'); + const $infoBox = $('#rpg-info-box'); + const $thoughts = $('#rpg-thoughts'); + + // If no sections exist, nothing to organize + if ($userStats.length === 0 && $infoBox.length === 0 && $thoughts.length === 0) { + return; + } + + // Create tab navigation (only show tabs for sections that exist) + const tabs = []; + const hasInfoOrCharacters = $infoBox.length > 0 || $thoughts.length > 0; + + if ($userStats.length > 0) { + tabs.push(''); + } + // Combine Info and Characters into one tab + if (hasInfoOrCharacters) { + tabs.push(''); + } + + const $tabNav = $('
' + tabs.join('') + '
'); + + // Determine which tab should be active + let firstTab = ''; + if ($userStats.length > 0) firstTab = 'stats'; + else if (hasInfoOrCharacters) firstTab = 'info-characters'; + + // Create tab content wrappers + const $statsTab = $('
'); + const $infoCharactersTab = $('
'); + + // Create combined content wrapper for Info and Characters + const $combinedWrapper = $('
'); + + // Move sections into their respective tabs (detach to preserve event handlers) + if ($userStats.length > 0) { + $statsTab.append($userStats.detach()); + $userStats.show(); + } + if ($infoBox.length > 0) { + $combinedWrapper.append($infoBox.detach()); + $infoBox.show(); + } + if ($thoughts.length > 0) { + $combinedWrapper.append($thoughts.detach()); + $thoughts.show(); + } + + // Add combined wrapper to the info-characters tab + if (hasInfoOrCharacters) { + $infoCharactersTab.append($combinedWrapper); + } + + // Hide dividers on mobile + $('.rpg-divider').hide(); + + // Build mobile tab structure + const $mobileContainer = $('
'); + $mobileContainer.append($tabNav); + + // Only append tab content wrappers that have content + if ($userStats.length > 0) $mobileContainer.append($statsTab); + if (hasInfoOrCharacters) $mobileContainer.append($infoCharactersTab); + + // Insert mobile tab structure at the beginning of content box + $contentBox.prepend($mobileContainer); + + // Handle tab switching + $tabNav.find('.rpg-mobile-tab').on('click', function() { + const tabName = $(this).data('tab'); + + // Update active tab button + $tabNav.find('.rpg-mobile-tab').removeClass('active'); + $(this).addClass('active'); + + // Update active tab content + $mobileContainer.find('.rpg-mobile-tab-content').removeClass('active'); + $mobileContainer.find('[data-tab-content="' + tabName + '"]').addClass('active'); + }); +} + +/** + * Removes mobile tab navigation and restores desktop layout. + */ +export function removeMobileTabs() { + // Get sections from tabs before removing + const $userStats = $('#rpg-user-stats').detach(); + const $infoBox = $('#rpg-info-box').detach(); + const $thoughts = $('#rpg-thoughts').detach(); + + // Remove mobile tab container + $('.rpg-mobile-container').remove(); + + // Get dividers + const $dividerStats = $('#rpg-divider-stats'); + const $dividerInfo = $('#rpg-divider-info'); + + // Restore original sections to content box in correct order + const $contentBox = $('.rpg-content-box'); + + // Re-insert sections in original order + if ($dividerStats.length) { + $dividerStats.before($userStats); + $dividerInfo.before($infoBox); + $contentBox.append($thoughts); + } else { + // Fallback if dividers don't exist + $contentBox.prepend($thoughts); + $contentBox.prepend($infoBox); + $contentBox.prepend($userStats); + } + + // Show sections and dividers + $userStats.show(); + $infoBox.show(); + $thoughts.show(); + $('.rpg-divider').show(); +} + +/** + * Sets up mobile keyboard handling using Visual Viewport API. + * Prevents layout squashing when keyboard appears by detecting + * viewport changes and adding CSS classes for adjustment. + */ +export function setupMobileKeyboardHandling() { + if (!window.visualViewport) { + // console.log('[RPG Mobile] Visual Viewport API not supported'); + return; + } + + const $panel = $('#rpg-companion-panel'); + let keyboardVisible = false; + + // Listen for viewport resize (keyboard show/hide) + window.visualViewport.addEventListener('resize', () => { + // Only handle if panel is open on mobile + if (!$panel.hasClass('rpg-mobile-open')) return; + + const viewportHeight = window.visualViewport.height; + const windowHeight = window.innerHeight; + + // Keyboard visible if viewport significantly smaller than window + // Using 75% threshold to account for browser UI variations + const isKeyboardShowing = viewportHeight < windowHeight * 0.75; + + if (isKeyboardShowing && !keyboardVisible) { + // Keyboard just appeared + keyboardVisible = true; + $panel.addClass('rpg-keyboard-visible'); + // console.log('[RPG Mobile] Keyboard opened'); + } else if (!isKeyboardShowing && keyboardVisible) { + // Keyboard just disappeared + keyboardVisible = false; + $panel.removeClass('rpg-keyboard-visible'); + // console.log('[RPG Mobile] Keyboard closed'); + } + }); +} + +/** + * Handles focus on contenteditable fields to ensure they're visible when keyboard appears. + * Uses smooth scrolling to bring focused field into view with proper padding. + */ +export function setupContentEditableScrolling() { + const $panel = $('#rpg-companion-panel'); + + // Use event delegation for all contenteditable fields + $panel.on('focusin', '[contenteditable="true"]', function(e) { + const $field = $(this); + + // Small delay to let keyboard animate in + setTimeout(() => { + // Scroll field into view with padding + // Using 'center' to ensure field is in middle of viewport + $field[0].scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'nearest' + }); + }, 300); + }); +} diff --git a/src/systems/ui/modals.js b/src/systems/ui/modals.js new file mode 100644 index 0000000..e2ed0dc --- /dev/null +++ b/src/systems/ui/modals.js @@ -0,0 +1,568 @@ +/** + * Modal Management Module + * Handles DiceModal and SettingsModal ES6 classes with state management + */ + +import { getContext } from '../../../../../../extensions.js'; +import { + extensionSettings, + lastGeneratedData, + committedTrackerData, + pendingDiceRoll, + $infoBoxContainer, + $thoughtsContainer, + setPendingDiceRoll +} from '../../core/state.js'; +import { saveSettings, saveChatData } from '../../core/persistence.js'; +import { renderUserStats } from '../rendering/userStats.js'; +import { updateChatThoughts } from '../rendering/thoughts.js'; + +/** + * Modern DiceModal ES6 Class + * Manages dice roller modal with proper state management and CSS classes + */ +export class DiceModal { + constructor() { + this.modal = document.getElementById('rpg-dice-popup'); + this.animation = document.getElementById('rpg-dice-animation'); + this.result = document.getElementById('rpg-dice-result'); + this.resultValue = document.getElementById('rpg-dice-result-value'); + this.resultDetails = document.getElementById('rpg-dice-result-details'); + this.rollBtn = document.getElementById('rpg-dice-roll-btn'); + + this.state = 'IDLE'; // IDLE, ROLLING, SHOWING_RESULT + this.isAnimating = false; + } + + /** + * Opens the modal with proper animation + */ + open() { + if (this.isAnimating) return; + + // Apply theme + const theme = extensionSettings.theme; + this.modal.setAttribute('data-theme', theme); + + // Apply custom theme if needed + if (theme === 'custom') { + this._applyCustomTheme(); + } + + // Reset to initial state + this._setState('IDLE'); + + // Open modal with CSS class + this.modal.classList.add('is-open'); + this.modal.classList.remove('is-closing'); + + // Focus management + this.modal.querySelector('#rpg-dice-popup-close')?.focus(); + } + + /** + * Closes the modal with animation + */ + close() { + if (this.isAnimating) return; + + this.isAnimating = true; + this.modal.classList.add('is-closing'); + this.modal.classList.remove('is-open'); + + // Wait for animation to complete + setTimeout(() => { + this.modal.classList.remove('is-closing'); + this.isAnimating = false; + + // Clear pending roll + setPendingDiceRoll(null); + }, 200); + } + + /** + * Starts the rolling animation + */ + startRolling() { + this._setState('ROLLING'); + } + + /** + * Shows the result + * @param {number} total - The total roll value + * @param {Array} rolls - Individual roll values + */ + showResult(total, rolls) { + this._setState('SHOWING_RESULT'); + + // Update result values + this.resultValue.textContent = total; + this.resultValue.classList.add('is-animating'); + + // Remove animation class after it completes + setTimeout(() => { + this.resultValue.classList.remove('is-animating'); + }, 500); + + // Show details if multiple rolls + if (rolls && rolls.length > 1) { + this.resultDetails.textContent = `Rolls: ${rolls.join(', ')}`; + } else { + this.resultDetails.textContent = ''; + } + } + + /** + * Manages modal state changes + * @private + */ + _setState(newState) { + this.state = newState; + + switch (newState) { + case 'IDLE': + this.rollBtn.hidden = false; + this.animation.hidden = true; + this.result.hidden = true; + break; + + case 'ROLLING': + this.rollBtn.hidden = true; + this.animation.hidden = false; + this.result.hidden = true; + this.animation.setAttribute('aria-busy', 'true'); + break; + + case 'SHOWING_RESULT': + this.rollBtn.hidden = true; + this.animation.hidden = true; + this.result.hidden = false; + this.animation.setAttribute('aria-busy', 'false'); + break; + } + } + + /** + * Applies custom theme colors + * @private + */ + _applyCustomTheme() { + const content = this.modal.querySelector('.rpg-dice-popup-content'); + if (content && extensionSettings.customColors) { + content.style.setProperty('--rpg-bg', extensionSettings.customColors.bg); + content.style.setProperty('--rpg-accent', extensionSettings.customColors.accent); + content.style.setProperty('--rpg-text', extensionSettings.customColors.text); + content.style.setProperty('--rpg-highlight', extensionSettings.customColors.highlight); + } + } +} + +/** + * SettingsModal - Manages the settings popup modal + * Handles opening, closing, theming, and animations + */ +export class SettingsModal { + constructor() { + this.modal = document.getElementById('rpg-settings-popup'); + this.content = this.modal?.querySelector('.rpg-settings-popup-content'); + this.isAnimating = false; + } + + /** + * Opens the modal with proper animation + */ + open() { + if (this.isAnimating || !this.modal) return; + + // Apply theme + const theme = extensionSettings.theme || 'default'; + this.modal.setAttribute('data-theme', theme); + + // Apply custom theme if needed + if (theme === 'custom') { + this._applyCustomTheme(); + } + + // Open modal with CSS class + this.modal.classList.add('is-open'); + this.modal.classList.remove('is-closing'); + + // Focus management + this.modal.querySelector('#rpg-close-settings')?.focus(); + } + + /** + * Closes the modal with animation + */ + close() { + if (this.isAnimating || !this.modal) return; + + this.isAnimating = true; + this.modal.classList.add('is-closing'); + this.modal.classList.remove('is-open'); + + // Wait for animation to complete + setTimeout(() => { + this.modal.classList.remove('is-closing'); + this.isAnimating = false; + }, 200); + } + + /** + * Updates the theme in real-time (used when theme selector changes) + */ + updateTheme() { + if (!this.modal) return; + + const theme = extensionSettings.theme || 'default'; + this.modal.setAttribute('data-theme', theme); + + if (theme === 'custom') { + this._applyCustomTheme(); + } else { + // Clear custom CSS variables to let theme CSS take over + this._clearCustomTheme(); + } + } + + /** + * Applies custom theme colors + * @private + */ + _applyCustomTheme() { + if (!this.content || !extensionSettings.customColors) return; + + this.content.style.setProperty('--rpg-bg', extensionSettings.customColors.bg); + this.content.style.setProperty('--rpg-accent', extensionSettings.customColors.accent); + this.content.style.setProperty('--rpg-text', extensionSettings.customColors.text); + this.content.style.setProperty('--rpg-highlight', extensionSettings.customColors.highlight); + } + + /** + * Clears custom theme colors + * @private + */ + _clearCustomTheme() { + if (!this.content) return; + + this.content.style.setProperty('--rpg-bg', ''); + this.content.style.setProperty('--rpg-accent', ''); + this.content.style.setProperty('--rpg-text', ''); + this.content.style.setProperty('--rpg-highlight', ''); + } +} + +// Global instances +let diceModal = null; +let settingsModal = null; + +/** + * Sets up the dice roller functionality. + * @returns {DiceModal} The initialized DiceModal instance + */ +export function setupDiceRoller() { + // Initialize DiceModal instance + diceModal = new DiceModal(); + + // Click dice display to open popup + $('#rpg-dice-display').on('click', function() { + openDicePopup(); + }); + + // Close popup - handle both close button and backdrop clicks + $('#rpg-dice-popup-close').on('click', function() { + closeDicePopup(); + }); + + // Close on backdrop click (clicking outside content) + $('#rpg-dice-popup').on('click', function(e) { + if (e.target === this) { + closeDicePopup(); + } + }); + + // Roll dice button + $('#rpg-dice-roll-btn').on('click', async function() { + await rollDice(); + }); + + // Save roll button (closes popup and saves the roll) + $('#rpg-dice-save-btn').on('click', function() { + // Save the pending roll + if (pendingDiceRoll) { + extensionSettings.lastDiceRoll = pendingDiceRoll; + saveSettings(); + updateDiceDisplay(); + setPendingDiceRoll(null); + } + closeDicePopup(); + }); + + // Reset on Enter key + $('#rpg-dice-count, #rpg-dice-sides').on('keypress', function(e) { + if (e.which === 13) { + rollDice(); + } + }); + + // Clear dice roll button + $('#rpg-clear-dice').on('click', function(e) { + e.stopPropagation(); // Prevent opening the dice popup + clearDiceRoll(); + }); + + return diceModal; +} + +/** + * Sets up the settings popup functionality. + * @returns {SettingsModal} The initialized SettingsModal instance + */ +export function setupSettingsPopup() { + // Initialize SettingsModal instance + settingsModal = new SettingsModal(); + + // Open settings popup + $('#rpg-open-settings').on('click', function() { + openSettingsPopup(); + }); + + // Close settings popup - close button + $('#rpg-close-settings').on('click', function() { + closeSettingsPopup(); + }); + + // Close on backdrop click (clicking outside content) + $('#rpg-settings-popup').on('click', function(e) { + if (e.target === this) { + closeSettingsPopup(); + } + }); + + // Clear cache button + $('#rpg-clear-cache').on('click', function() { + // Clear the data + lastGeneratedData.userStats = null; + lastGeneratedData.infoBox = null; + lastGeneratedData.characterThoughts = null; + + // Clear committed tracker data (used for generation context) + committedTrackerData.userStats = null; + committedTrackerData.infoBox = null; + committedTrackerData.characterThoughts = null; + + // Clear all message swipe data + const chat = getContext().chat; + if (chat && chat.length > 0) { + for (let i = 0; i < chat.length; i++) { + const message = chat[i]; + if (message.extra && message.extra.rpg_companion_swipes) { + delete message.extra.rpg_companion_swipes; + // console.log('[RPG Companion] Cleared swipe data from message at index', i); + } + } + } + + // Clear the UI + if ($infoBoxContainer) { + $infoBoxContainer.empty(); + } + if ($thoughtsContainer) { + $thoughtsContainer.empty(); + } + + // Reset stats to defaults and re-render + extensionSettings.userStats = { + health: 100, + satiety: 100, + energy: 100, + hygiene: 100, + arousal: 0, + mood: '😐', + conditions: 'None', + inventory: 'None' + }; + + // Reset classic stats (attributes) to defaults + extensionSettings.classicStats = { + str: 10, + dex: 10, + con: 10, + int: 10, + wis: 10, + cha: 10 + }; + + // Clear dice roll + extensionSettings.lastDiceRoll = null; + + // Save everything + saveChatData(); + saveSettings(); + + // Re-render user stats and dice display + renderUserStats(); + updateDiceDisplay(); + updateChatThoughts(); // Clear the thought bubble in chat + + // console.log('[RPG Companion] Chat cache cleared'); + }); + + return settingsModal; +} + +/** + * Opens the dice rolling popup. + * Backwards compatible wrapper for DiceModal class. + */ +export function openDicePopup() { + if (diceModal) { + diceModal.open(); + } +} + +/** + * Closes the dice rolling popup. + * Backwards compatible wrapper for DiceModal class. + */ +export function closeDicePopup() { + if (diceModal) { + diceModal.close(); + } +} + +/** + * Opens the settings popup. + * Backwards compatible wrapper for SettingsModal class. + */ +export function openSettingsPopup() { + if (settingsModal) { + settingsModal.open(); + } +} + +/** + * Closes the settings popup. + * Backwards compatible wrapper for SettingsModal class. + */ +export function closeSettingsPopup() { + if (settingsModal) { + settingsModal.close(); + } +} + +/** + * @deprecated Legacy function - use diceModal._applyCustomTheme() instead + */ +export function applyCustomThemeToPopup() { + if (diceModal) { + diceModal._applyCustomTheme(); + } +} + +/** + * Clears the last dice roll. + */ +export function clearDiceRoll() { + extensionSettings.lastDiceRoll = null; + saveSettings(); + updateDiceDisplay(); +} + +/** + * Rolls the dice and displays result. + * Refactored to use DiceModal class. + */ +async function rollDice() { + if (!diceModal) return; + + const count = parseInt(String($('#rpg-dice-count').val())) || 1; + const sides = parseInt(String($('#rpg-dice-sides').val())) || 20; + + // Start rolling animation + diceModal.startRolling(); + + // Wait for animation (simulate rolling) + await new Promise(resolve => setTimeout(resolve, 1200)); + + // Execute /roll command + const rollCommand = `/roll ${count}d${sides}`; + const rollResult = await executeRollCommand(rollCommand); + + // Parse result + const total = rollResult.total || 0; + const rolls = rollResult.rolls || []; + + // Store result temporarily (not saved until "Save Roll" is clicked) + setPendingDiceRoll({ + formula: `${count}d${sides}`, + total: total, + rolls: rolls, + timestamp: Date.now() + }); + + // Show result + diceModal.showResult(total, rolls); + + // Don't update sidebar display yet - only update when user clicks "Save Roll" +} + +/** + * Executes a /roll command and returns the result. + */ +async function executeRollCommand(command) { + try { + // Parse the dice notation (e.g., "2d20") + const match = command.match(/(\d+)d(\d+)/); + if (!match) { + return { total: 0, rolls: [] }; + } + + const count = parseInt(match[1]); + const sides = parseInt(match[2]); + const rolls = []; + let total = 0; + + for (let i = 0; i < count; i++) { + const roll = Math.floor(Math.random() * sides) + 1; + rolls.push(roll); + total += roll; + } + + return { total, rolls }; + } catch (error) { + console.error('[RPG Companion] Error rolling dice:', error); + return { total: 0, rolls: [] }; + } +} + +/** + * Updates the dice display in the sidebar. + */ +export function updateDiceDisplay() { + const lastRoll = extensionSettings.lastDiceRoll; + if (lastRoll) { + $('#rpg-last-roll-text').text(`Last Roll (${lastRoll.formula}): ${lastRoll.total}`); + } else { + $('#rpg-last-roll-text').text('Last Roll: None'); + } +} + +/** + * Adds the Roll Dice quick reply button. + */ +export function addDiceQuickReply() { + // Create quick reply button if Quick Replies exist + if (window.quickReplyApi) { + // Quick Reply API integration would go here + // For now, the dice display in the sidebar serves as the button + } +} + +/** + * Returns the SettingsModal instance for external use + * @returns {SettingsModal} The global SettingsModal instance + */ +export function getSettingsModal() { + return settingsModal; +} diff --git a/src/systems/ui/theme.js b/src/systems/ui/theme.js new file mode 100644 index 0000000..2f2b061 --- /dev/null +++ b/src/systems/ui/theme.js @@ -0,0 +1,100 @@ +/** + * Theme Management Module + * Handles theme application, custom colors, and animations + */ + +import { extensionSettings, $panelContainer } from '../../core/state.js'; + +/** + * Applies the selected theme to the panel. + */ +export function applyTheme() { + if (!$panelContainer) return; + + const theme = extensionSettings.theme; + + // Remove all theme attributes first + $panelContainer.removeAttr('data-theme'); + + // Clear any inline CSS variable overrides + $panelContainer.css({ + '--rpg-bg': '', + '--rpg-accent': '', + '--rpg-text': '', + '--rpg-highlight': '', + '--rpg-border': '', + '--rpg-shadow': '' + }); + + // Apply the selected theme + if (theme === 'custom') { + applyCustomTheme(); + } else if (theme !== 'default') { + // For non-default themes, set the data-theme attribute + // which will trigger the CSS theme rules + $panelContainer.attr('data-theme', theme); + } + // For 'default', we do nothing - it will use the CSS variables from .rpg-panel class + // which fall back to SillyTavern's theme variables +} + +/** + * Applies custom colors when custom theme is selected. + */ +export function applyCustomTheme() { + if (!$panelContainer) return; + + const colors = extensionSettings.customColors; + + // Apply custom CSS variables as inline styles + $panelContainer.css({ + '--rpg-bg': colors.bg, + '--rpg-accent': colors.accent, + '--rpg-text': colors.text, + '--rpg-highlight': colors.highlight, + '--rpg-border': colors.highlight, + '--rpg-shadow': `${colors.highlight}80` // Add alpha for shadow + }); +} + +/** + * Toggles visibility of custom color pickers. + */ +export function toggleCustomColors() { + const isCustom = extensionSettings.theme === 'custom'; + $('#rpg-custom-colors').toggle(isCustom); +} + +/** + * Toggles animations on/off by adding/removing a class to the panel. + */ +export function toggleAnimations() { + if (extensionSettings.enableAnimations) { + $panelContainer.addClass('rpg-animations-enabled'); + } else { + $panelContainer.removeClass('rpg-animations-enabled'); + } +} + +/** + * Updates the settings popup theme in real-time. + * Backwards compatible wrapper for SettingsModal class. + * @param {Object} settingsModal - The SettingsModal instance (passed as parameter to avoid circular dependency) + */ +export function updateSettingsPopupTheme(settingsModal) { + if (settingsModal) { + settingsModal.updateTheme(); + } +} + +/** + * Applies custom theme colors to the settings popup. + * Backwards compatible wrapper for SettingsModal class. + * @deprecated Use settingsModal.updateTheme() instead + * @param {Object} settingsModal - The SettingsModal instance (passed as parameter to avoid circular dependency) + */ +export function applyCustomThemeToSettingsPopup(settingsModal) { + if (settingsModal) { + settingsModal._applyCustomTheme(); + } +}