diff --git a/index.js b/index.js index 160c515..037592d 100644 --- a/index.js +++ b/index.js @@ -98,7 +98,8 @@ import { setupMobileTabs, removeMobileTabs, setupMobileKeyboardHandling, - setupContentEditableScrolling + setupContentEditableScrolling, + setupRefreshButtonDrag } from './src/systems/ui/mobile.js'; import { setupDesktopTabs, @@ -209,6 +210,14 @@ async function initUI() { `; $('body').append(mobileToggleHtml); + // Add mobile refresh button (same pattern as toggle button) + const mobileRefreshHtml = ` + + `; + $('body').append(mobileRefreshHtml); + // Cache UI elements using state setters setPanelContainer($('#rpg-companion-panel')); setUserStatsContainer($('#rpg-user-stats')); @@ -297,12 +306,34 @@ async function initUI() { toggleAnimations(); }); - $('#rpg-manual-update').on('click', async function() { + // Bind to both desktop and mobile refresh buttons + $('#rpg-manual-update, #rpg-manual-update-mobile').on('click', async function() { + // Get mobile button reference + const $mobileBtn = $('#rpg-manual-update-mobile'); + + // Skip if we just finished dragging the mobile button + if ($mobileBtn.data('just-dragged')) { + console.log('[RPG Companion] Click blocked - just finished dragging refresh button'); + return; + } + if (!extensionSettings.enabled) { // console.log('[RPG Companion] Extension is disabled. Please enable it in the Extensions tab.'); return; } - await updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory); + + // Remove focus to prevent sticky black state on mobile + $(this).blur(); + + // Add spinning animation to mobile button + $mobileBtn.addClass('spinning'); + + try { + await updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory); + } finally { + // Remove spinning animation when done + $mobileBtn.removeClass('spinning'); + } }); $('#rpg-stat-bar-color-low').on('change', function() { @@ -422,6 +453,7 @@ async function initUI() { setupPlotButtons(sendPlotProgression); setupMobileKeyboardHandling(); setupContentEditableScrolling(); + setupRefreshButtonDrag(); initInventoryEventListeners(); } diff --git a/src/core/config.js b/src/core/config.js index 93d2495..2fe9030 100644 --- a/src/core/config.js +++ b/src/core/config.js @@ -49,6 +49,10 @@ export const defaultSettings = { top: 'calc(var(--topBarBlockSize) + 60px)', right: '12px' }, // Saved position for mobile FAB button + mobileRefreshPosition: { + bottom: '80px', + right: '20px' + }, // Saved position for mobile refresh button userStats: { health: 100, satiety: 100, diff --git a/src/core/state.js b/src/core/state.js index 4e86ae3..c837bba 100644 --- a/src/core/state.js +++ b/src/core/state.js @@ -37,6 +37,10 @@ export let extensionSettings = { top: 'calc(var(--topBarBlockSize) + 60px)', right: '12px' }, // Saved position for mobile FAB button + mobileRefreshPosition: { + bottom: '80px', + right: '20px' + }, // Saved position for mobile refresh button userStats: { health: 100, satiety: 100, diff --git a/src/systems/ui/layout.js b/src/systems/ui/layout.js index 9993154..b88b5f0 100644 --- a/src/systems/ui/layout.js +++ b/src/systems/ui/layout.js @@ -34,6 +34,9 @@ export function closeMobilePanelWithAnimation() { $panel.removeClass('rpg-mobile-open').addClass('rpg-mobile-closing'); $mobileToggle.removeClass('active'); + // Trigger event for other components (like refresh button) + $(document).trigger('rpg-panel-toggled', { isOpen: false }); + // Wait for animation to complete before hiding $panel.one('animationend', function() { $panel.removeClass('rpg-mobile-closing'); @@ -127,6 +130,9 @@ export function setupCollapseToggle() { const $overlay = $('
'); $('body').append($overlay); + // Trigger event for other components (like refresh button) + $(document).trigger('rpg-panel-toggled', { isOpen: true }); + // Debug: Check state after animation should complete setTimeout(() => { console.log('[RPG Mobile] 500ms after opening:', { @@ -267,10 +273,13 @@ export function applyPanelPosition() { */ export function updateGenerationModeUI() { if (extensionSettings.generationMode === 'together') { - // In "together" mode, manual update button is hidden + // In "together" mode, hide both update buttons $('#rpg-manual-update').hide(); + $('#rpg-manual-update-mobile').hide(); } else { - // In "separate" mode, manual update button is visible + // In "separate" mode, show both buttons + // (CSS media queries control which one is visible based on viewport) $('#rpg-manual-update').show(); + $('#rpg-manual-update-mobile').show(); } } diff --git a/src/systems/ui/mobile.js b/src/systems/ui/mobile.js index f2648b3..b3f7b95 100644 --- a/src/systems/ui/mobile.js +++ b/src/systems/ui/mobile.js @@ -278,6 +278,9 @@ export function setupMobileToggle() { $('body').append($overlay); $mobileToggle.addClass('active'); + // Trigger event for other components (like refresh button) + $(document).trigger('rpg-panel-toggled', { isOpen: true }); + // Close when clicking overlay $overlay.on('click', function() { closeMobilePanelWithAnimation(); @@ -310,6 +313,9 @@ export function setupMobileToggle() { $('body').append($overlay); $mobileToggle.addClass('active'); + // Trigger event for other components (like refresh button) + $(document).trigger('rpg-panel-toggled', { isOpen: true }); + $overlay.on('click', function() { console.log('[RPG Mobile] Overlay clicked - closing panel'); closeMobilePanelWithAnimation(); @@ -434,32 +440,41 @@ export function setupMobileToggle() { * 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. + * @param {jQuery} $button - Optional button element (defaults to mobile toggle) */ -export function constrainFabToViewport() { +export function constrainFabToViewport($button = null) { + // Default to mobile toggle if no button specified + if (!$button) { + $button = $('#rpg-mobile-toggle'); + } + + if ($button.length === 0) return; + + // Determine which position setting to check based on button ID + const isRefreshButton = $button.attr('id') === 'rpg-manual-update-mobile'; + const positionSetting = isRefreshButton ? 'mobileRefreshPosition' : 'mobileFabPosition'; + // Only constrain if user has set a custom position - if (!extensionSettings.mobileFabPosition) { + if (!extensionSettings[positionSetting]) { 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')) { + if (!$button.is(':visible')) { console.log('[RPG Mobile] Skipping viewport constraint - button not visible'); return; } // Get current position - const offset = $mobileToggle.offset(); + const offset = $button.offset(); if (!offset) return; let currentX = offset.left; let currentY = offset.top; - const buttonWidth = $mobileToggle.outerWidth(); - const buttonHeight = $mobileToggle.outerHeight(); + const buttonWidth = $button.outerWidth(); + const buttonHeight = $button.outerHeight(); // Get top bar height from CSS variable (fallback to 50px if not set) const topBarHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--topBarBlockSize')) || 50; @@ -485,15 +500,15 @@ export function constrainFabToViewport() { }); // Apply new position - $mobileToggle.css({ + $button.css({ left: newX + 'px', top: newY + 'px', right: 'auto', bottom: 'auto' }); - // Save corrected position - extensionSettings.mobileFabPosition = { + // Save corrected position to appropriate setting + extensionSettings[positionSetting] = { left: newX + 'px', top: newY + 'px' }; @@ -716,3 +731,219 @@ export function setupContentEditableScrolling() { }, 300); }); } + +/** + * Sets up the mobile refresh button with drag functionality. + * Same pattern as mobile toggle button. + * Tap = refresh, drag = reposition + */ +export function setupRefreshButtonDrag() { + const $refreshBtn = $('#rpg-manual-update-mobile'); + + if ($refreshBtn.length === 0) { + console.warn('[RPG Mobile] Refresh button not found in DOM'); + return; + } + + console.log('[RPG Mobile] setupRefreshButtonDrag called'); + + // Load and apply saved position + if (extensionSettings.mobileRefreshPosition) { + const pos = extensionSettings.mobileRefreshPosition; + console.log('[RPG Mobile] Loading saved refresh button position:', pos); + + // Apply saved position + if (pos.top) $refreshBtn.css('top', pos.top); + if (pos.right) $refreshBtn.css('right', pos.right); + if (pos.bottom) $refreshBtn.css('bottom', pos.bottom); + if (pos.left) $refreshBtn.css('left', pos.left); + + // Constrain to viewport after position is applied + requestAnimationFrame(() => constrainFabToViewport($refreshBtn)); + } + + // 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; + const MOVE_THRESHOLD = 10; + let rafId = null; + let pendingX = null; + let pendingY = null; + + // Update position using requestAnimationFrame + function updatePosition() { + if (pendingX !== null && pendingY !== null) { + $refreshBtn.css({ + left: pendingX + 'px', + top: pendingY + 'px', + right: 'auto', + bottom: 'auto' + }); + pendingX = null; + pendingY = null; + } + rafId = null; + } + + // Touch start + $refreshBtn.on('touchstart', function(e) { + const touch = e.originalEvent.touches[0]; + touchStartTime = Date.now(); + touchStartX = touch.clientX; + touchStartY = touch.clientY; + + const offset = $refreshBtn.offset(); + buttonStartX = offset.left; + buttonStartY = offset.top; + + isDragging = false; + }); + + // Touch move + $refreshBtn.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); + + if (!isDragging && (timeSinceStart > LONG_PRESS_DURATION || distance > MOVE_THRESHOLD)) { + isDragging = true; + $refreshBtn.addClass('dragging'); + } + + if (isDragging) { + e.preventDefault(); + + let newX = buttonStartX + deltaX; + let newY = buttonStartY + deltaY; + + const buttonWidth = $refreshBtn.outerWidth(); + const buttonHeight = $refreshBtn.outerHeight(); + + 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)); + + pendingX = newX; + pendingY = newY; + if (!rafId) { + rafId = requestAnimationFrame(updatePosition); + } + } + }); + + // Touch end + $refreshBtn.on('touchend', function(e) { + if (isDragging) { + // Save new position + const offset = $refreshBtn.offset(); + const newPosition = { + left: offset.left + 'px', + top: offset.top + 'px' + }; + + extensionSettings.mobileRefreshPosition = newPosition; + saveSettings(); + + setTimeout(() => { + $refreshBtn.removeClass('dragging'); + }, 50); + + // Set flag to prevent click handler from firing + $refreshBtn.data('just-dragged', true); + setTimeout(() => { + $refreshBtn.data('just-dragged', false); + }, 100); + + isDragging = false; + } + }); + + // Mouse support for desktop + let mouseDown = false; + + $refreshBtn.on('mousedown', function(e) { + e.preventDefault(); + touchStartTime = Date.now(); + touchStartX = e.clientX; + touchStartY = e.clientY; + + const offset = $refreshBtn.offset(); + buttonStartX = offset.left; + buttonStartY = offset.top; + + mouseDown = true; + isDragging = false; + }); + + $(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); + + if (!isDragging && (timeSinceStart > LONG_PRESS_DURATION || distance > MOVE_THRESHOLD)) { + isDragging = true; + $refreshBtn.addClass('dragging'); + } + + if (isDragging) { + let newX = buttonStartX + deltaX; + let newY = buttonStartY + deltaY; + + const buttonWidth = $refreshBtn.outerWidth(); + const buttonHeight = $refreshBtn.outerHeight(); + + 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)); + + pendingX = newX; + pendingY = newY; + if (!rafId) { + rafId = requestAnimationFrame(updatePosition); + } + } + }); + + $(document).on('mouseup', function(e) { + if (mouseDown && isDragging) { + const offset = $refreshBtn.offset(); + const newPosition = { + left: offset.left + 'px', + top: offset.top + 'px' + }; + + extensionSettings.mobileRefreshPosition = newPosition; + saveSettings(); + + setTimeout(() => { + $refreshBtn.removeClass('dragging'); + }, 50); + + $refreshBtn.data('just-dragged', true); + setTimeout(() => { + $refreshBtn.data('just-dragged', false); + }, 100); + } + + mouseDown = false; + isDragging = false; + }); +} diff --git a/style.css b/style.css index 8b906b1..d6b53af 100644 --- a/style.css +++ b/style.css @@ -744,7 +744,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld { font-size: 1em; font-weight: 700; color: var(--rpg-highlight-color); - padding: clamp(1px, 0.2vh, 2px) 0.375em; + padding: 0 0.375em; background: var(--rpg-accent-color); border-radius: clamp(2px, 0.3vh, 3px); border: 1px solid var(--rpg-highlight-color); @@ -2670,7 +2670,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld { } /* ============================================ - MANUAL UPDATE BUTTON + MANUAL UPDATE BUTTON (Desktop) ============================================ */ .rpg-manual-update-btn { width: 100%; @@ -2707,6 +2707,64 @@ body:has(.rpg-panel.rpg-position-left) #sheld { box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); } +/* ============================================ + MOBILE REFRESH BUTTON (FAB - Same pattern as toggle) + ============================================ */ +.rpg-mobile-refresh { + display: none; + align-items: center; + justify-content: center; + position: fixed; + /* Position set by JavaScript based on saved settings */ + width: 44px; + height: 44px; + border-radius: 50%; + background: var(--SmartThemeBlurTintColor); + border: 2px solid var(--SmartThemeBorderColor); + color: var(--rpg-text, #ecf0f1); + font-size: 1.85vw; + cursor: grab; + z-index: 1001; /* Above panel (1000) but below mobile toggle (10002) */ + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + transition: opacity 0.3s ease, transform 0.2s ease, top 0.3s ease, left 0.3s ease, right 0.3s ease, bottom 0.3s ease; + user-select: none; + -webkit-user-select: none; + will-change: top, left; +} + +/* Disable transitions while actively dragging */ +.rpg-mobile-refresh.dragging { + transition: none; + cursor: grabbing; +} + +.rpg-mobile-refresh:hover { + transform: scale(1.1); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4); +} + +.rpg-mobile-refresh:active { + transform: scale(0.95); +} + +/* Spinning animation when refreshing */ +.rpg-mobile-refresh.spinning i { + animation: rpg-spin 0.8s linear infinite; +} + +.rpg-mobile-refresh i { + pointer-events: none; +} + +@keyframes rpg-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + /* ============================================ SETTINGS BUTTON ============================================ */ @@ -3292,6 +3350,31 @@ body:has(.rpg-panel.rpg-position-left) #sheld { /* Mobile-specific panel behavior - matches SillyTavern's 1000px breakpoint */ /* CACHE BUST v2025-01-16 */ +/* Mobile refresh button visibility - opposite of toggle */ +@media (max-width: 1000px) { + /* Show mobile refresh button */ + .rpg-mobile-refresh { + display: flex; + } + + /* Hide desktop refresh button */ + .rpg-manual-update-btn { + display: none !important; + } + + /* Show refresh button when panel is open (opposite of toggle) */ + body:has(.rpg-panel.rpg-mobile-open) .rpg-mobile-refresh { + opacity: 1; + pointer-events: auto; + } + + /* Hide refresh button when panel is closed */ + .rpg-mobile-refresh { + opacity: 0; + pointer-events: none; + } +} + @media (max-width: 1000px) { /* ======================================== MOBILE PANEL FOUNDATION @@ -3616,6 +3699,9 @@ body:has(.rpg-panel.rpg-position-left) #sheld { .rpg-calendar-day { font-size: clamp(11px, 2.9vw, 14px) !important; + min-height: 3em !important; /* Ensure enough height for content to center */ + padding: 0.75em 0.5em !important; /* More vertical padding on mobile */ + line-height: 1.2 !important; /* Tighter line height */ } .rpg-calendar-year { @@ -3753,14 +3839,20 @@ body:has(.rpg-panel.rpg-position-left) #sheld { grid-row: 3; display: flex; flex-direction: column; - gap: 6px; + gap: 3px !important; /* Reduced from 6px for more compact display */ min-width: 0; + padding: 4px 0.375em !important; /* Reduced vertical padding */ } /* Make mood text readable on mobile */ .rpg-mood-conditions { font-size: clamp(11px, 2.8vw, 14px); - line-height: 1.3; + line-height: 1.2 !important; /* Tighter line height */ + } + + /* Smaller emoji on mobile */ + .rpg-mood-emoji { + font-size: clamp(14px, 3.5vw, 18px) !important; /* Slightly smaller */ } /* Attributes - right side, rows 4-6 aligned with mood */ @@ -3960,6 +4052,11 @@ body:has(.rpg-panel.rpg-position-left) #sheld { font-size: clamp(20px, 5.1vw, 26px) !important; } + /* Larger mobile refresh icon (same as toggle) */ + .rpg-mobile-refresh { + font-size: clamp(20px, 5.1vw, 26px) !important; + } + /* ======================================== MOBILE SETTINGS POPUP ======================================== */ @@ -4059,6 +4156,13 @@ body:has(.rpg-panel.rpg-position-left) #sheld { min-height: 2.75rem; } + /* Exception: Level value should stay compact */ + .rpg-level-value.rpg-editable { + padding: 0 0.375em; + min-height: auto; + line-height: 1.2; + } + /* Larger close buttons */ .rpg-thought-close { min-width: 2.75rem;