diff --git a/index.js b/index.js index 26170ce..cef4158 100644 --- a/index.js +++ b/index.js @@ -126,7 +126,8 @@ import { removeMobileTabs, setupMobileKeyboardHandling, setupContentEditableScrolling, - updateMobileTabLabels + updateMobileTabLabels, + updateFabWidgets } from './src/systems/ui/mobile.js'; import { setupDesktopTabs, @@ -608,6 +609,71 @@ async function initUI() { updateDiceDisplay(); }); + // Mobile FAB Widget toggles - simplified, no position saving (auto-positioned) + $('#rpg-toggle-fab-widgets-enabled').on('change', function() { + if (!extensionSettings.mobileFabWidgets) extensionSettings.mobileFabWidgets = {}; + extensionSettings.mobileFabWidgets.enabled = $(this).prop('checked'); + saveSettings(); + updateFabWidgets(); + $('#rpg-fab-widget-options').toggle(extensionSettings.mobileFabWidgets.enabled); + }); + + $('#rpg-toggle-fab-weather-icon').on('change', function() { + if (!extensionSettings.mobileFabWidgets) extensionSettings.mobileFabWidgets = {}; + if (!extensionSettings.mobileFabWidgets.weatherIcon) extensionSettings.mobileFabWidgets.weatherIcon = {}; + extensionSettings.mobileFabWidgets.weatherIcon.enabled = $(this).prop('checked'); + saveSettings(); + updateFabWidgets(); + }); + + $('#rpg-toggle-fab-weather-desc').on('change', function() { + if (!extensionSettings.mobileFabWidgets) extensionSettings.mobileFabWidgets = {}; + if (!extensionSettings.mobileFabWidgets.weatherDesc) extensionSettings.mobileFabWidgets.weatherDesc = {}; + extensionSettings.mobileFabWidgets.weatherDesc.enabled = $(this).prop('checked'); + saveSettings(); + updateFabWidgets(); + }); + + $('#rpg-toggle-fab-clock').on('change', function() { + if (!extensionSettings.mobileFabWidgets) extensionSettings.mobileFabWidgets = {}; + if (!extensionSettings.mobileFabWidgets.clock) extensionSettings.mobileFabWidgets.clock = {}; + extensionSettings.mobileFabWidgets.clock.enabled = $(this).prop('checked'); + saveSettings(); + updateFabWidgets(); + }); + + $('#rpg-toggle-fab-date').on('change', function() { + if (!extensionSettings.mobileFabWidgets) extensionSettings.mobileFabWidgets = {}; + if (!extensionSettings.mobileFabWidgets.date) extensionSettings.mobileFabWidgets.date = {}; + extensionSettings.mobileFabWidgets.date.enabled = $(this).prop('checked'); + saveSettings(); + updateFabWidgets(); + }); + + $('#rpg-toggle-fab-location').on('change', function() { + if (!extensionSettings.mobileFabWidgets) extensionSettings.mobileFabWidgets = {}; + if (!extensionSettings.mobileFabWidgets.location) extensionSettings.mobileFabWidgets.location = {}; + extensionSettings.mobileFabWidgets.location.enabled = $(this).prop('checked'); + saveSettings(); + updateFabWidgets(); + }); + + $('#rpg-toggle-fab-stats').on('change', function() { + if (!extensionSettings.mobileFabWidgets) extensionSettings.mobileFabWidgets = {}; + if (!extensionSettings.mobileFabWidgets.stats) extensionSettings.mobileFabWidgets.stats = {}; + extensionSettings.mobileFabWidgets.stats.enabled = $(this).prop('checked'); + saveSettings(); + updateFabWidgets(); + }); + + $('#rpg-toggle-fab-attributes').on('change', function() { + if (!extensionSettings.mobileFabWidgets) extensionSettings.mobileFabWidgets = {}; + if (!extensionSettings.mobileFabWidgets.attributes) extensionSettings.mobileFabWidgets.attributes = {}; + extensionSettings.mobileFabWidgets.attributes.enabled = $(this).prop('checked'); + saveSettings(); + updateFabWidgets(); + }); + $('#rpg-manual-update').on('click', async function() { if (!extensionSettings.enabled) { // console.log('[RPG Companion] Extension is disabled. Please enable it in the Extensions tab.'); @@ -825,6 +891,20 @@ async function initUI() { $('#rpg-toggle-auto-avatars-panel').prop('checked', extensionSettings.autoGenerateAvatars || false); $('#rpg-toggle-dice-display').prop('checked', extensionSettings.showDiceDisplay); + + // Initialize Mobile FAB Widget checkboxes + const fabWidgets = extensionSettings.mobileFabWidgets || {}; + $('#rpg-toggle-fab-widgets-enabled').prop('checked', fabWidgets.enabled || false); + $('#rpg-toggle-fab-weather-icon').prop('checked', fabWidgets.weatherIcon?.enabled || false); + $('#rpg-toggle-fab-weather-desc').prop('checked', fabWidgets.weatherDesc?.enabled || false); + $('#rpg-toggle-fab-clock').prop('checked', fabWidgets.clock?.enabled || false); + $('#rpg-toggle-fab-date').prop('checked', fabWidgets.date?.enabled || false); + $('#rpg-toggle-fab-location').prop('checked', fabWidgets.location?.enabled || false); + $('#rpg-toggle-fab-stats').prop('checked', fabWidgets.stats?.enabled || false); + $('#rpg-toggle-fab-attributes').prop('checked', fabWidgets.attributes?.enabled || false); + // Toggle visibility of widget options based on master toggle + $('#rpg-fab-widget-options').toggle(fabWidgets.enabled || false); + $('#rpg-stat-bar-color-low').val(extensionSettings.statBarColorLow); $('#rpg-stat-bar-color-high').val(extensionSettings.statBarColorHigh); $('#rpg-theme-select').val(extensionSettings.theme); @@ -969,6 +1049,8 @@ jQuery(async () => { // Load chat-specific data for current chat try { loadChatData(); + // Initialize FAB widgets with any loaded data + updateFabWidgets(); } catch (error) { console.error('[RPG Companion] Chat data load failed, using defaults:', error); } diff --git a/src/core/state.js b/src/core/state.js index 2be2ed4..b1cedb9 100644 --- a/src/core/state.js +++ b/src/core/state.js @@ -65,6 +65,17 @@ export let extensionSettings = { top: 'calc(var(--topBarBlockSize) + 60px)', right: '12px' }, // Saved position for mobile FAB button + // Mobile FAB widget display options (8-position system around the button) + mobileFabWidgets: { + enabled: false, // Master toggle for FAB widgets + weatherIcon: { enabled: false, position: 0 }, // Weather emoji (☀️, 🌧️, etc.) + weatherDesc: { enabled: false, position: 1 }, // Weather description text + clock: { enabled: false, position: 2 }, // Current time display + date: { enabled: false, position: 3 }, // Date display + location: { enabled: false, position: 4 }, // Location name + stats: { enabled: false, position: 5 }, // All stats as compact numbers + attributes: { enabled: false, position: 6 } // Compact RPG attributes display + }, userStats: JSON.stringify({ stats: [ { id: 'health', name: 'Health', value: 100 }, diff --git a/src/systems/features/classicStats.js b/src/systems/features/classicStats.js index 46337fa..9e322f6 100644 --- a/src/systems/features/classicStats.js +++ b/src/systems/features/classicStats.js @@ -8,6 +8,7 @@ import { $userStatsContainer } from '../../core/state.js'; import { saveSettings, saveChatData } from '../../core/persistence.js'; +import { updateFabWidgets } from '../ui/mobile.js'; /** * Sets up event listeners for classic stat +/- buttons using delegation. @@ -25,6 +26,7 @@ export function setupClassicStatsButtons() { saveChatData(); // Update only the specific stat value, not the entire stats panel $(this).closest('.rpg-classic-stat').find('.rpg-classic-stat-value').text(extensionSettings.classicStats[stat]); + updateFabWidgets(); } }); @@ -37,6 +39,7 @@ export function setupClassicStatsButtons() { saveChatData(); // Update only the specific stat value, not the entire stats panel $(this).closest('.rpg-classic-stat').find('.rpg-classic-stat-value').text(extensionSettings.classicStats[stat]); + updateFabWidgets(); } }); } diff --git a/src/systems/generation/apiClient.js b/src/systems/generation/apiClient.js index 1cbabc1..234a649 100644 --- a/src/systems/generation/apiClient.js +++ b/src/systems/generation/apiClient.js @@ -34,6 +34,7 @@ import { renderQuests } from '../rendering/quests.js'; import { renderMusicPlayer } from '../rendering/musicPlayer.js'; import { i18n } from '../../core/i18n.js'; import { generateAvatarsForCharacters } from '../features/avatarGenerator.js'; +import { setFabLoadingState, updateFabWidgets } from '../ui/mobile.js'; // Store the original preset name to restore after tracker generation let originalPresetName = null; @@ -235,6 +236,7 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough try { setIsGenerating(true); + setFabLoadingState(true); // Show spinning FAB on mobile // Update button to show "Updating..." state const $updateBtn = $('#rpg-manual-update'); @@ -391,6 +393,8 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough } } finally { setIsGenerating(false); + setFabLoadingState(false); // Stop spinning FAB on mobile + updateFabWidgets(); // Update FAB widgets with new data // Restore button to original state const $updateBtn = $('#rpg-manual-update'); diff --git a/src/systems/integration/sillytavern.js b/src/systems/integration/sillytavern.js index 0064fff..18321cd 100644 --- a/src/systems/integration/sillytavern.js +++ b/src/systems/integration/sillytavern.js @@ -43,6 +43,9 @@ import { renderMusicPlayer } from '../rendering/musicPlayer.js'; // Utils import { getSafeThumbnailUrl } from '../../utils/avatars.js'; +// UI +import { setFabLoadingState, updateFabWidgets } from '../ui/mobile.js'; + // Chapter checkpoint import { updateAllCheckpointIndicators } from '../ui/checkpointUI.js'; import { restoreCheckpointOnLoad } from '../features/chapterCheckpoint.js'; @@ -111,6 +114,11 @@ export function onMessageSent() { // This allows auto-update to distinguish between new generations and loading chat history setIsAwaitingNewMessage(true); + // Show FAB loading state for together mode (starts spinning) + if (extensionSettings.generationMode === 'together') { + setFabLoadingState(true); + } + // For separate mode with auto-update disabled, commit displayed tracker if (extensionSettings.generationMode === 'separate' && !extensionSettings.autoUpdate) { if (lastGeneratedData.userStats || lastGeneratedData.infoBox || lastGeneratedData.characterThoughts) { @@ -260,6 +268,9 @@ export async function onMessageReceived(data) { if (extensionSettings.autoUpdate && isAwaitingNewMessage) { setTimeout(async () => { await updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory); + // Update FAB widgets after separate/external mode update completes + setFabLoadingState(false); + updateFabWidgets(); }, 500); } } @@ -282,6 +293,10 @@ export async function onMessageReceived(data) { // console.log('[RPG Companion] Plot progression generation completed'); } + // Stop FAB loading state and update widgets + setFabLoadingState(false); + updateFabWidgets(); + // Re-apply checkpoint in case SillyTavern unhid messages during generation await restoreCheckpointOnLoad(); } @@ -319,6 +334,9 @@ export function onCharacterChanged() { renderQuests(); renderMusicPlayer($musicPlayerContainer[0]); + // Update FAB widgets with loaded data + updateFabWidgets(); + // Update chat thought overlays updateChatThoughts(); diff --git a/src/systems/rendering/infoBox.js b/src/systems/rendering/infoBox.js index 6790500..1c95d6a 100644 --- a/src/systems/rendering/infoBox.js +++ b/src/systems/rendering/infoBox.js @@ -14,6 +14,7 @@ import { saveChatData } from '../../core/persistence.js'; import { i18n } from '../../core/i18n.js'; import { isItemLocked } from '../generation/lockManager.js'; import { repairJSON } from '../../utils/jsonRepair.js'; +import { updateFabWidgets } from '../ui/mobile.js'; /** * Helper to generate lock icon HTML if setting is enabled @@ -615,6 +616,9 @@ export function renderInfoBox() { } else { updateInfoBoxField(field, value); } + + // Update FAB widgets to reflect changes + updateFabWidgets(); }); // Update location size on input as well (real-time) diff --git a/src/systems/rendering/userStats.js b/src/systems/rendering/userStats.js index a85069e..88eed94 100644 --- a/src/systems/rendering/userStats.js +++ b/src/systems/rendering/userStats.js @@ -20,6 +20,7 @@ import { import { getSafeThumbnailUrl } from '../../utils/avatars.js'; import { buildInventorySummary } from '../generation/promptBuilder.js'; import { isItemLocked, setItemLock } from '../generation/lockManager.js'; +import { updateFabWidgets } from '../ui/mobile.js'; /** * Builds the user stats text string using custom stat names @@ -424,8 +425,9 @@ export function renderUserStats() { saveChatData(); updateMessageSwipeData(); - // Re-render to update the bar + // Re-render to update the bar and FAB widgets renderUserStats(); + updateFabWidgets(); }); // Add event listeners for mood/conditions editing diff --git a/src/systems/ui/mobile.js b/src/systems/ui/mobile.js index 9426140..b3f9a42 100644 --- a/src/systems/ui/mobile.js +++ b/src/systems/ui/mobile.js @@ -3,7 +3,7 @@ * Handles mobile-specific UI functionality: FAB dragging, tabs, keyboard handling */ -import { extensionSettings } from '../../core/state.js'; +import { extensionSettings, committedTrackerData, lastGeneratedData } from '../../core/state.js'; import { saveSettings } from '../../core/persistence.js'; import { closeMobilePanelWithAnimation, updateCollapseToggleIcon } from './layout.js'; import { setupDesktopTabs, removeDesktopTabs } from './desktop.js'; @@ -106,6 +106,14 @@ export function setupMobileToggle() { right: 'auto', bottom: 'auto' }); + // Also update widget container position during drag + const $container = $('#rpg-fab-widget-container'); + if ($container.length > 0) { + $container.css({ + top: pendingY + 'px', + left: pendingX + 'px' + }); + } pendingX = null; pendingY = null; } @@ -253,7 +261,10 @@ export function setupMobileToggle() { // console.log('[RPG Mobile] Saved new FAB position (mouse):', newPosition); // Constrain to viewport bounds (now that position is saved) - setTimeout(() => constrainFabToViewport(), 10); + setTimeout(() => { + constrainFabToViewport(); + updateFabWidgetPosition(); // Update widget container position + }, 10); // Re-enable transitions with smooth animation setTimeout(() => { @@ -294,7 +305,10 @@ export function setupMobileToggle() { // console.log('[RPG Mobile] Saved new FAB position:', newPosition); // Constrain to viewport bounds (now that position is saved) - setTimeout(() => constrainFabToViewport(), 10); + setTimeout(() => { + constrainFabToViewport(); + updateFabWidgetPosition(); // Update widget container position + }, 10); // Re-enable transitions with smooth animation setTimeout(() => { @@ -1230,3 +1244,390 @@ export function setupDebugButtonDrag() { isDragging = false; }); } + +// ============================================ +// FAB WIDGETS - Info display around FAB button +// ============================================ + +/** + * Updates the FAB widgets display based on current tracker data and settings. + * Widgets are positioned in 8 positions around the FAB (N, NE, E, SE, S, SW, W, NW). + */ +export function updateFabWidgets() { + const $fab = $('#rpg-mobile-toggle'); + if ($fab.length === 0) return; + + // Remove existing widget container and clean up event listeners + $('#rpg-fab-widget-container').remove(); + $(document).off('click.fabWidgets touchstart.fabWidgets'); + + // Check if widgets are enabled + const widgetSettings = extensionSettings.mobileFabWidgets; + if (!widgetSettings || !widgetSettings.enabled) return; + + // Don't show widgets on desktop or when panel is open + if (window.innerWidth > 1000) return; + + // Get tracker data - prefer lastGeneratedData (most recent) over committedTrackerData + const infoBox = lastGeneratedData?.infoBox || committedTrackerData?.infoBox; + const userStats = lastGeneratedData?.userStats || committedTrackerData?.userStats; + + // Parse infoBox if it's a string + let infoData = null; + if (infoBox) { + try { + infoData = typeof infoBox === 'string' ? JSON.parse(infoBox) : infoBox; + } catch (e) { + console.warn('[RPG FAB Widgets] Failed to parse infoBox:', e); + } + } + + // Parse userStats if it's a string + let statsData = null; + if (userStats) { + try { + statsData = typeof userStats === 'string' ? JSON.parse(userStats) : userStats; + } catch (e) { + console.warn('[RPG FAB Widgets] Failed to parse userStats:', e); + } + } + + // Create widget container positioned at FAB location + const fabOffset = $fab.offset(); + const fabWidth = $fab.outerWidth(); + const fabHeight = $fab.outerHeight(); + + const $container = $('
'); + $container.css({ + top: fabOffset.top + 'px', + left: fabOffset.left + 'px', + width: fabWidth + 'px', + height: fabHeight + 'px' + }); + + // Build widgets based on settings - auto-assign positions sequentially + const widgets = []; + + // Collect enabled widgets in display priority order + // Large widgets (Stats, Attributes) go to West/Northwest + // Small widgets spread around other positions + + // Weather Icon (small) + if (widgetSettings.weatherIcon?.enabled && infoData?.weather?.emoji) { + widgets.push({ + type: 'small', + html: `
${infoData.weather.emoji}
` + }); + } + + // Weather Description (small) + if (widgetSettings.weatherDesc?.enabled && infoData?.weather?.forecast) { + const desc = infoData.weather.forecast.length > 15 ? infoData.weather.forecast.substring(0, 13) + '…' : infoData.weather.forecast; + widgets.push({ + type: 'small', + html: `
${desc}
` + }); + } + + // Helper to create expandable text widget HTML + const createExpandableText = (fullText, maxLen, emoji) => { + if (fullText.length <= maxLen) { + return `${emoji} ${fullText}`; + } + const truncated = fullText.substring(0, maxLen - 2) + '…'; + return `${emoji} ${truncated}${fullText}`; + }; + + // Check if text needs truncation for data attribute + const needsExpand = (text, maxLen) => text.length > maxLen; + + // Helper to parse time string and calculate clock hand angles + const parseTimeForClock = (timeStr) => { + const timeMatch = timeStr.match(/(\d+):(\d+)/); + if (timeMatch) { + const hours = parseInt(timeMatch[1]); + const minutes = parseInt(timeMatch[2]); + const hourAngle = (hours % 12) * 30 + minutes * 0.5; // 30° per hour + 0.5° per minute + const minuteAngle = minutes * 6; // 6° per minute + return { hourAngle, minuteAngle }; + } + return { hourAngle: 0, minuteAngle: 0 }; + }; + + // Clock/Time (bottom position with animated clock face) + if (widgetSettings.clock?.enabled && infoData?.time) { + const timeStr = infoData.time.end || infoData.time.value || infoData.time.start || ''; + if (timeStr) { + const { hourAngle, minuteAngle } = parseTimeForClock(timeStr); + widgets.push({ + type: 'bottom', // Special type for bottom position + html: `
+
+
+
+
+
+ ${timeStr} +
` + }); + } + } + + // Date (small) + if (widgetSettings.date?.enabled && infoData?.date?.value) { + const dateVal = infoData.date.value; + const expandAttr = needsExpand(dateVal, 12) ? ' data-full-text="true"' : ''; + widgets.push({ + type: 'small', + html: `
${createExpandableText(dateVal, 12, '📅')}
` + }); + } + + // Location (small) + if (widgetSettings.location?.enabled && infoData?.location?.value) { + const loc = infoData.location.value; + const expandAttr = needsExpand(loc, 14) ? ' data-full-text="true"' : ''; + widgets.push({ + type: 'small', + html: `
${createExpandableText(loc, 14, '📍')}
` + }); + } + + // Stats (large - goes to West) - respects trackerConfig.userStats.customStats + // Use extensionSettings.userStats as primary source (contains all stats), fallback to committedTrackerData + let allStats = []; + try { + const userStatsJson = extensionSettings.userStats; + const parsedUserStats = typeof userStatsJson === 'string' ? JSON.parse(userStatsJson) : userStatsJson; + if (parsedUserStats?.stats) { + allStats = parsedUserStats.stats; + } + } catch (e) { + console.warn('[RPG FAB Widgets] Failed to parse extensionSettings.userStats:', e); + } + // Fallback to statsData if extensionSettings.userStats is empty + if (allStats.length === 0 && statsData?.stats) { + allStats = statsData.stats; + } + + if (widgetSettings.stats?.enabled && allStats.length > 0) { + // Get enabled stats from trackerConfig - match by id (lowercase) + const configuredStats = extensionSettings.trackerConfig?.userStats?.customStats || []; + const enabledStatMap = new Map(); + configuredStats.forEach(s => { + if (s.enabled !== false) { + enabledStatMap.set(s.id?.toLowerCase(), true); + enabledStatMap.set(s.name?.toLowerCase(), true); + } + }); + + const statsHtml = allStats + .filter(s => { + // If no config, show all stats + if (configuredStats.length === 0) return true; + // Check if stat is enabled in trackerConfig (match by id or name, case-insensitive) + const statId = s.id?.toLowerCase(); + const statName = s.name?.toLowerCase(); + return enabledStatMap.has(statId) || enabledStatMap.has(statName); + }) + .map(stat => { + const value = typeof stat.value === 'number' ? stat.value : parseInt(stat.value) || 0; + const color = getStatColor(value); + const abbr = stat.name.substring(0, 3).toUpperCase(); + return `${abbr}:${value}`; + }) + .join(''); + + if (statsHtml) { + widgets.push({ + type: 'large', + preferredPos: 6, // West + html: `
${statsHtml}
` + }); + } + } + + // RPG Attributes (large - goes to Northwest) - respects trackerConfig.userStats.rpgAttributes + if (widgetSettings.attributes?.enabled) { + // Check if RPG attributes are enabled in trackerConfig + const showRPGAttributes = extensionSettings.trackerConfig?.userStats?.showRPGAttributes !== false; + + if (showRPGAttributes && extensionSettings.classicStats) { + // Get enabled attributes from trackerConfig + const configuredAttrs = extensionSettings.trackerConfig?.userStats?.rpgAttributes || []; + const enabledAttrIds = configuredAttrs.filter(a => a.enabled !== false).map(a => a.id); + + const attrs = extensionSettings.classicStats; + const attrItems = Object.entries(attrs) + .filter(([key]) => { + // Check if attribute is enabled in trackerConfig + if (enabledAttrIds.length > 0) { + return enabledAttrIds.includes(key.toLowerCase()); + } + return true; + }) + .map(([key, value]) => `
${key.toUpperCase()}${value}
`) + .join(''); + + if (attrItems) { + widgets.push({ + type: 'large', + preferredPos: 7, // Northwest + html: `
${attrItems}
` + }); + } + } + } + + // Auto-assign positions intelligently + // Large widgets get their preferred positions first (West=6, Northwest=7) + // Bottom widgets get position 4 (South) + // Small widgets fill remaining positions clockwise from North (0) + const usedPositions = new Set(); + const positionedWidgets = []; + + // Position order for small widgets: N(0), NE(1), E(2), SE(3), SW(5) - skip S(4) for bottom/clock + const smallPositionOrder = [0, 1, 2, 3, 5]; + let smallPosIndex = 0; + + // Check if only one large widget exists (for centering) + const largeWidgets = widgets.filter(w => w.type === 'large'); + const singleLargeWidget = largeWidgets.length === 1; + + // First: assign bottom widgets to position 4 (South) + widgets.filter(w => w.type === 'bottom').forEach(w => { + const pos = 4; // South position + usedPositions.add(pos); + const finalHtml = w.html.replace('class="rpg-fab-widget', `class="rpg-fab-widget rpg-fab-widget-pos-${pos}`); + positionedWidgets.push({ position: pos, html: finalHtml }); + }); + + // Second: assign large widgets to their preferred positions + largeWidgets.forEach(w => { + let pos = w.preferredPos; + // If preferred position is taken, find next available from large positions + if (usedPositions.has(pos)) { + pos = pos === 6 ? 7 : 6; // Try the other large position + } + usedPositions.add(pos); + // Add centered class if this is the only large widget + const centeredClass = singleLargeWidget ? ' rpg-fab-widget-centered' : ''; + const finalHtml = w.html.replace('class="rpg-fab-widget', `class="rpg-fab-widget rpg-fab-widget-pos-${pos}${centeredClass}`); + positionedWidgets.push({ position: pos, html: finalHtml }); + }); + + // Third: assign small widgets to remaining positions + widgets.filter(w => w.type === 'small').forEach(w => { + // Find next available position from small position order + while (smallPosIndex < smallPositionOrder.length && usedPositions.has(smallPositionOrder[smallPosIndex])) { + smallPosIndex++; + } + const pos = smallPosIndex < smallPositionOrder.length ? smallPositionOrder[smallPosIndex] : (smallPosIndex % 8); + usedPositions.add(pos); + smallPosIndex++; + const finalHtml = w.html.replace('class="rpg-fab-widget', `class="rpg-fab-widget rpg-fab-widget-pos-${pos}`); + positionedWidgets.push({ position: pos, html: finalHtml }); + }); + + // Add widgets to container + positionedWidgets.forEach(w => $container.append(w.html)); + + // Append container to body + if (positionedWidgets.length > 0) { + $('body').append($container); + + // Add mobile tap handler for expandable widgets + $container.find('.rpg-fab-widget[data-full-text]').on('click touchstart', function(e) { + e.stopPropagation(); + const $this = $(this); + const wasExpanded = $this.hasClass('expanded'); + + // Collapse all other expanded widgets + $container.find('.rpg-fab-widget.expanded').removeClass('expanded'); + + // Toggle this one + if (!wasExpanded) { + $this.addClass('expanded'); + } + }); + + // Collapse on tap outside + $(document).on('click.fabWidgets touchstart.fabWidgets', function(e) { + if (!$(e.target).closest('.rpg-fab-widget').length) { + $container.find('.rpg-fab-widget.expanded').removeClass('expanded'); + } + }); + } +} + +/** + * Gets a color for a stat value (0-100) using a gradient from low to high. + * @param {number} value - The stat value (0-100) + * @returns {string} CSS color value + */ +function getStatColor(value) { + const lowColor = extensionSettings.statBarColorLow || '#cc3333'; + const highColor = extensionSettings.statBarColorHigh || '#33cc66'; + + // Simple linear interpolation between low and high colors + const percent = Math.min(100, Math.max(0, value)) / 100; + + // Parse colors + const lowRGB = hexToRgb(lowColor); + const highRGB = hexToRgb(highColor); + + if (!lowRGB || !highRGB) return value > 50 ? highColor : lowColor; + + const r = Math.round(lowRGB.r + (highRGB.r - lowRGB.r) * percent); + const g = Math.round(lowRGB.g + (highRGB.g - lowRGB.g) * percent); + const b = Math.round(lowRGB.b + (highRGB.b - lowRGB.b) * percent); + + return `rgb(${r}, ${g}, ${b})`; +} + +/** + * Converts a hex color to RGB object. + * @param {string} hex - Hex color string (e.g., "#cc3333") + * @returns {{r: number, g: number, b: number}|null} + */ +function hexToRgb(hex) { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16) + } : null; +} + +/** + * Updates the FAB widget container position to match FAB button position. + * Call this after FAB is dragged. + */ +export function updateFabWidgetPosition() { + const $fab = $('#rpg-mobile-toggle'); + const $container = $('#rpg-fab-widget-container'); + + if ($fab.length === 0 || $container.length === 0) return; + + const fabOffset = $fab.offset(); + $container.css({ + top: fabOffset.top + 'px', + left: fabOffset.left + 'px' + }); +} + +/** + * Sets the FAB loading state (spinning animation during API requests). + * @param {boolean} loading - Whether to show loading state + */ +export function setFabLoadingState(loading) { + const $fab = $('#rpg-mobile-toggle'); + if ($fab.length === 0) return; + + if (loading) { + $fab.addClass('rpg-fab-loading'); + } else { + $fab.removeClass('rpg-fab-loading'); + } +} + diff --git a/src/systems/ui/trackerEditor.js b/src/systems/ui/trackerEditor.js index 4dedd2c..0f3ec82 100644 --- a/src/systems/ui/trackerEditor.js +++ b/src/systems/ui/trackerEditor.js @@ -28,6 +28,7 @@ import { import { renderUserStats } from '../rendering/userStats.js'; import { renderInfoBox } from '../rendering/infoBox.js'; import { renderThoughts } from '../rendering/thoughts.js'; +import { updateFabWidgets } from './mobile.js'; let $editorModal = null; let activeTab = 'userStats'; @@ -258,6 +259,7 @@ function applyTrackerConfig() { renderUserStats(); renderInfoBox(); renderThoughts(); + updateFabWidgets(); // Update FAB widgets to reflect new config } /** diff --git a/style.css b/style.css index 423e960..80fbf1e 100644 --- a/style.css +++ b/style.css @@ -5154,6 +5154,21 @@ body:has(.rpg-panel.rpg-position-left) #sheld { transform: rotate(180deg); } +/* FAB Spinning animation during API requests */ +.rpg-mobile-toggle.rpg-fab-loading i { + animation: fa-spin 1s infinite linear; +} + +.rpg-mobile-toggle.rpg-fab-loading { + box-shadow: 0 0 12px rgba(233, 69, 96, 0.6), 0 4px 16px rgba(0, 0, 0, 0.5); +} + +/* Hide FAB widgets when panel is open */ +body:has(.rpg-panel.rpg-mobile-open) .rpg-fab-widget-container { + opacity: 0; + pointer-events: none; +} + /* Mobile overlay backdrop */ .rpg-mobile-overlay { display: none; @@ -9857,3 +9872,336 @@ body[data-theme="cyberpunk"] .rpg-music-widget-play { color: var(--SmartThemeBodyColor, #ccc); text-decoration: underline; } + +/* ============================================ + FAB Widget System - Improved Layout + ============================================ */ + +/* Widget container - positioned relative to FAB */ +.rpg-fab-widget-container { + position: fixed; + pointer-events: none; + z-index: 9998; +} + +/* Hide FAB widgets on desktop viewport */ +@media (min-width: 1001px) { + .rpg-fab-widget-container { + display: none !important; + } +} + +/* Individual widget base styling */ +.rpg-fab-widget { + position: absolute; + pointer-events: auto; + background: rgba(20, 20, 35, 0.95); + border: 1px solid rgba(100, 150, 255, 0.3); + border-radius: 8px; + padding: 6px 10px; + font-size: 11px; + color: #fff; + white-space: nowrap; + backdrop-filter: blur(10px); + box-shadow: 0 3px 12px rgba(0, 0, 0, 0.5), 0 0 1px rgba(100, 150, 255, 0.3); + transition: opacity 0.2s ease, transform 0.15s ease; +} + +.rpg-fab-widget:hover { + border-color: rgba(100, 150, 255, 0.5); +} + +/* Expanded state for truncated widgets - desktop hover and mobile tap */ +.rpg-fab-widget[data-full-text]:hover, +.rpg-fab-widget[data-full-text].expanded { + z-index: 9999 !important; + max-width: none !important; + white-space: nowrap; +} + +.rpg-fab-widget[data-full-text]:hover .rpg-fab-widget-text, +.rpg-fab-widget[data-full-text].expanded .rpg-fab-widget-text { + /* Show full text on hover/tap */ +} + +/* Hide truncated text and show full text on expand */ +.rpg-fab-widget[data-full-text]:hover .rpg-truncated, +.rpg-fab-widget[data-full-text].expanded .rpg-truncated { + display: none; +} + +.rpg-fab-widget[data-full-text]:hover .rpg-full-text, +.rpg-fab-widget[data-full-text].expanded .rpg-full-text { + display: inline; +} + +/* Default: show truncated, hide full */ +.rpg-fab-widget .rpg-full-text { + display: none; +} + +.rpg-fab-widget .rpg-truncated { + display: inline; +} + +/* 8-Position system - spread out more to avoid overlap + Positions: 0=N, 1=NE, 2=E, 3=SE, 4=S, 5=SW, 6=W, 7=NW */ + +/* Position 0: North (top center) */ +.rpg-fab-widget-pos-0 { + bottom: calc(100% + 15px); + left: 50%; + transform: translateX(-50%); +} +.rpg-fab-widget-pos-0:hover { transform: translateX(-50%) scale(1.05); } + +/* Position 1: Northeast */ +.rpg-fab-widget-pos-1 { + bottom: calc(100% + 10px); + left: calc(100% + 15px); +} + +/* Position 2: East (right center) */ +.rpg-fab-widget-pos-2 { + top: 50%; + left: calc(100% + 15px); + transform: translateY(-50%); +} +.rpg-fab-widget-pos-2:hover { transform: translateY(-50%) scale(1.05); } + +/* Position 3: Southeast */ +.rpg-fab-widget-pos-3 { + top: calc(100% + 10px); + left: calc(100% + 15px); +} + +/* Position 4: South (bottom center) */ +.rpg-fab-widget-pos-4 { + top: calc(100% + 15px); + left: 50%; + transform: translateX(-50%); +} +.rpg-fab-widget-pos-4:hover { transform: translateX(-50%) scale(1.05); } + +/* Position 5: Southwest */ +.rpg-fab-widget-pos-5 { + top: calc(100% + 10px); + right: calc(100% + 15px); + left: auto; +} + +/* Position 6: West - Stats (top edge at FAB center + gap, grows DOWN) */ +.rpg-fab-widget-pos-6 { + top: calc(50% + 8px); + right: calc(100% + 15px); + left: auto; +} + +/* Position 7: Northwest - Attributes (bottom edge at FAB center - gap, grows UP) */ +.rpg-fab-widget-pos-7 { + bottom: calc(50% + 8px); + right: calc(100% + 15px); + left: auto; +} + +/* Centered large widget (when only one is visible) - vertically centered */ +.rpg-fab-widget-centered.rpg-fab-widget-pos-6, +.rpg-fab-widget-centered.rpg-fab-widget-pos-7 { + top: 50%; + bottom: auto; + transform: translateY(-50%); +} + +/* Weather icon widget - larger emoji display */ +.rpg-fab-widget-weather-icon { + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + padding: 6px; + min-width: 32px; + min-height: 32px; + width: 32px; + height: 32px; + border-radius: 50%; +} + +/* Weather description widget */ +.rpg-fab-widget-weather-desc { + max-width: 100px; + font-size: 10px; +} + +/* Clock/Time widget - bottom position with animated clock */ +.rpg-fab-widget-clock { + display: flex; + flex-direction: column; + align-items: center; + font-family: 'Roboto Mono', 'Consolas', monospace; + font-size: 11px; + letter-spacing: 0.5px; + padding: 4px 8px; + gap: 2px; +} + +/* Mini animated clock face */ +.rpg-fab-clock-face { + position: relative; + width: 24px; + height: 24px; + border: 2px solid var(--rpg-border, #4a7ba7); + border-radius: 50%; + background: var(--rpg-accent, rgba(22, 33, 62, 0.9)); +} + +.rpg-fab-clock-hour { + position: absolute; + width: 2px; + height: 7px; + background: var(--rpg-text, #eaeaea); + left: 50%; + bottom: 50%; + margin-left: -1px; + transform-origin: bottom center; + border-radius: 1px; +} + +.rpg-fab-clock-minute { + position: absolute; + width: 1.5px; + height: 9px; + background: var(--rpg-highlight, #4a90e2); + left: 50%; + bottom: 50%; + margin-left: -0.75px; + transform-origin: bottom center; + border-radius: 1px; +} + +.rpg-fab-clock-center { + position: absolute; + width: 4px; + height: 4px; + background: var(--rpg-highlight, #4a90e2); + border-radius: 50%; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.rpg-fab-clock-time { + font-size: 11px; + white-space: nowrap; +} + +/* Date widget */ +.rpg-fab-widget-date { + font-size: 10px; +} + +/* Location widget - two lines */ +.rpg-fab-widget-location { + max-width: 90px; + font-size: 10px; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Attributes widget - compact grid */ +.rpg-fab-widget-attributes { + padding: 6px 10px; +} + +.rpg-fab-widget-attr-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 4px; +} + +.rpg-fab-widget-attr-item { + display: flex; + flex-direction: column; + align-items: center; + line-height: 1.2; +} + +.rpg-fab-widget-attr-name { + font-size: 8px; + opacity: 0.7; + text-transform: uppercase; +} + +.rpg-fab-widget-attr-value { + font-size: 12px; + font-weight: bold; + color: var(--rpg-highlight, #4a90e2); + white-space: nowrap; +} + +/* Stats widget - vertical compact list */ +.rpg-fab-widget-stats { + padding: 6px 10px; + min-width: 70px; + text-align: center; +} + +.rpg-fab-widget-stats-row { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} + +.rpg-fab-widget-stat-item { + font-size: 11px; + font-family: 'Roboto Mono', 'Consolas', monospace; + font-weight: 600; + white-space: nowrap; + display: block; +} + +/* RPG Attributes widget - 2x3 grid */ +.rpg-fab-widget-attributes { + padding: 6px 8px; +} + +.rpg-fab-widget-attr-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 2px 10px; +} + +.rpg-fab-widget-attr-item { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.rpg-fab-widget-attr-name { + font-size: 8px; + color: rgba(255, 255, 255, 0.6); + letter-spacing: 0.5px; +} + +.rpg-fab-widget-attr-value { + font-size: 12px; + font-weight: 700; + color: #6af; + font-family: 'Roboto Mono', 'Consolas', monospace; +} + +/* FAB Loading State */ +#rpg-mobile-toggle.rpg-fab-loading { + animation: fabSpin 1s linear infinite; +} + +#rpg-mobile-toggle.rpg-fab-loading i { + opacity: 0.7; +} + +@keyframes fabSpin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + diff --git a/template.html b/template.html index 524c718..ea4241c 100644 --- a/template.html +++ b/template.html @@ -395,6 +395,62 @@ + +
+

Mobile Button Widgets

+ + Show compact info widgets around the floating button on mobile. Widgets are positioned automatically. + + + + + Master toggle to show info widgets around the mobile floating button. + + +
+ + + + + + + + + + + + + +
+
+

Advanced