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)
This commit is contained in:
Lucas 'Paperboy' Rose-Winters
2025-10-21 21:39:56 +11:00
parent dd392e50d1
commit e345715090
6 changed files with 279 additions and 11 deletions
+12 -2
View File
@@ -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();
}
+4
View File
@@ -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,
+4
View File
@@ -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,
+6
View File
@@ -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 = $('<div class="rpg-mobile-overlay"></div>');
$('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:', {
+231
View File
@@ -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;
});
}
+22 -9
View File
@@ -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);