From e345715090707589c4b5d7fc6ae01e516e793c88 Mon Sep 17 00:00:00 2001 From: Lucas 'Paperboy' Rose-Winters Date: Tue, 21 Oct 2025 21:39:56 +1100 Subject: [PATCH] feat: add draggable mobile refresh button with improved UX - Repositioned mobile refresh button to bottom-right (80px from bottom) - Implemented full drag-to-reposition functionality * Touch and mouse support with 200ms/10px threshold * RequestAnimationFrame for smooth dragging * Position saved to extensionSettings.mobileRefreshPosition * Viewport constraints with 10px padding - Fixed sticky tap highlight issue * Added -webkit-tap-highlight-color: transparent * Added blur() on click to remove focus * Set user-select: none and touch-action: none - Show/hide based on panel state * Only visible when panel is expanded (rpg-mobile-open) * Listens to rpg-panel-toggled events * Auto-hides when panel closes - Prevent accidental refresh after drag * just-dragged flag prevents click for 100ms * Click handler checks flag before executing - Changed from absolute to fixed positioning for viewport-wide dragging - Added mobileRefreshPosition to default settings (bottom: 80px, right: 20px) - z-index: 99 (below FAB toggle at 100) --- index.js | 14 ++- src/core/config.js | 4 + src/core/state.js | 4 + src/systems/ui/layout.js | 6 + src/systems/ui/mobile.js | 231 +++++++++++++++++++++++++++++++++++++++ style.css | 31 ++++-- 6 files changed, 279 insertions(+), 11 deletions(-) diff --git a/index.js b/index.js index ae21007..c606f20 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, @@ -299,6 +300,15 @@ async function initUI() { // 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; @@ -308,7 +318,6 @@ async function initUI() { $(this).blur(); // Add spinning animation to mobile button - const $mobileBtn = $('#rpg-manual-update-mobile'); $mobileBtn.addClass('spinning'); try { @@ -436,6 +445,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 aff3fd5..d3e9b0f 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:', { diff --git a/src/systems/ui/mobile.js b/src/systems/ui/mobile.js index f2648b3..aed33b7 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(); @@ -716,3 +722,228 @@ export function setupContentEditableScrolling() { }, 300); }); } + +/** + * Sets up the mobile refresh button with drag functionality. + * Button is only visible when panel is open, and can be dragged to reposition. + * Tap = refresh, drag = reposition + */ +export function setupRefreshButtonDrag() { + const $refreshBtn = $('#rpg-manual-update-mobile'); + const $panel = $('#rpg-companion-panel'); + + if ($refreshBtn.length === 0) { + console.warn('[RPG Mobile] Refresh button not found in DOM'); + return; + } + + // Load and apply saved position + if (extensionSettings.mobileRefreshPosition) { + const pos = extensionSettings.mobileRefreshPosition; + if (pos.left) $refreshBtn.css('left', pos.left); + if (pos.top) $refreshBtn.css('top', pos.top); + if (pos.right) $refreshBtn.css('right', pos.right); + if (pos.bottom) $refreshBtn.css('bottom', pos.bottom); + } + + // Show/hide button based on panel state + const updateButtonVisibility = () => { + if ($panel.hasClass('rpg-mobile-open')) { + $refreshBtn.show(); + } else { + $refreshBtn.hide(); + } + }; + + // Initial visibility check + updateButtonVisibility(); + + // Listen for panel state changes (attach to panel toggle events) + // This will be triggered by setupMobileToggle + $(document).on('rpg-panel-toggled', updateButtonVisibility); + + // 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 b6f1a99..eb04705 100644 --- a/style.css +++ b/style.css @@ -2708,13 +2708,13 @@ body:has(.rpg-panel.rpg-position-left) #sheld { } /* ============================================ - REFRESH ICON BUTTON (Mobile - Floating) + REFRESH ICON BUTTON (Mobile - Floating & Draggable) ============================================ */ .rpg-refresh-icon-btn { - display: none; /* Hidden by default, shown on mobile */ - position: absolute; - top: 10px; - right: 10px; + display: none; /* Hidden by default, shown on mobile when panel open */ + position: fixed; /* Fixed for dragging anywhere on viewport */ + bottom: 80px; /* Above FAB toggle, below screen edge */ + right: 20px; width: 44px; /* Touch-friendly size */ height: 44px; padding: 0; @@ -2723,12 +2723,19 @@ body:has(.rpg-panel.rpg-position-left) #sheld { border-radius: 50%; color: var(--rpg-text); font-size: 1rem; - cursor: pointer; - transition: transform 0.2s ease, box-shadow 0.2s ease; + cursor: grab; + transition: transform 0.3s ease, box-shadow 0.3s ease, opacity 0.3s ease; align-items: center; justify-content: center; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); - z-index: 100; /* Float above content */ + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + z-index: 99; /* Below mobile FAB (100) but above content */ + + /* Fix sticky tap highlight */ + -webkit-tap-highlight-color: transparent; + -webkit-touch-callout: none; + user-select: none; + -webkit-user-select: none; + touch-action: none; } /* Remove focus outline (prevents black state) */ @@ -2736,6 +2743,12 @@ body:has(.rpg-panel.rpg-position-left) #sheld { outline: none; } +/* Disable transitions while actively dragging */ +.rpg-refresh-icon-btn.dragging { + transition: none; + cursor: grabbing; +} + .rpg-refresh-icon-btn:hover { transform: scale(1.1); box-shadow: 0 4px 12px var(--rpg-highlight);