/** * User Stats Widget * * Displays user health/satiety/energy/hygiene/arousal bars, * mood/conditions, and classic D&D stats (STR/DEX/CON/INT/WIS/CHA). * * Features: * - Editable stat values with live update * - Progress bars with customizable colors * - User portrait and level display * - Classic stats with +/- buttons * - Mobile-responsive layout */ import { createProgressBar, attachEditableHandlers, parseNumber } from '../widgetBase.js'; /** * Register User Stats Widget * @param {WidgetRegistry} registry - Widget registry instance * @param {Object} dependencies - External dependencies * @param {Function} dependencies.getContext - Get SillyTavern context * @param {Function} dependencies.getUserAvatar - Get user avatar URL * @param {Function} dependencies.getExtensionSettings - Get extension settings * @param {Function} dependencies.onStatsChange - Callback when stats change */ export function registerUserStatsWidget(registry, dependencies) { const { getContext, getUserAvatar, getExtensionSettings, onStatsChange } = dependencies; registry.register('userStats', { name: 'User Stats', icon: '❤️', description: 'Health, energy, satiety bars and classic RPG stats', minSize: { w: 1, h: 2 }, defaultSize: { w: 2, h: 3 }, requiresSchema: false, /** * Render widget content * @param {HTMLElement} container - Widget container * @param {Object} config - Widget configuration */ render(container, config = {}) { const settings = getExtensionSettings(); const stats = settings.userStats; const classicStats = settings.classicStats; const context = getContext(); const userName = context.name1; const userPortrait = getUserAvatar(); // Merge default config with user config const finalConfig = { showClassicStats: true, showMood: true, showPortrait: true, statBarGradient: true, visibleStats: ['health', 'satiety', 'energy', 'hygiene', 'arousal'], ...config }; // Create gradient for stat bars const gradient = finalConfig.statBarGradient ? `linear-gradient(to right, ${settings.statBarColorLow}, ${settings.statBarColorHigh})` : settings.statBarColorHigh; // Build progress bars HTML const progressBarsHtml = finalConfig.visibleStats.map(statName => { const label = statName.charAt(0).toUpperCase() + statName.slice(1); return createProgressBar({ label, value: stats[statName], gradient, editable: true, field: statName }); }).join(''); // Build classic stats HTML const classicStatsHtml = finalConfig.showClassicStats ? `
${['str', 'dex', 'con', 'int', 'wis', 'cha'].map(stat => `
${stat.toUpperCase()}
${classicStats[stat]}
`).join('')}
` : ''; // Build mood section HTML const moodHtml = finalConfig.showMood ? `
${stats.mood}
${stats.conditions}
` : ''; // Build portrait section HTML const portraitHtml = finalConfig.showPortrait ? `
${userName} ${userName} | LVL ${settings.level}
` : ''; // Render complete HTML const html = `
${portraitHtml}
${progressBarsHtml}
${moodHtml}
${classicStatsHtml}
`; container.innerHTML = html; // Attach event handlers attachEventHandlers(container, settings, onStatsChange); }, /** * Get configuration options * @returns {Object} Configuration schema */ getConfig() { return { showClassicStats: { type: 'boolean', label: 'Show Classic Stats (STR/DEX/etc)', default: true }, showMood: { type: 'boolean', label: 'Show Mood & Conditions', default: true }, showPortrait: { type: 'boolean', label: 'Show User Portrait', default: true }, statBarGradient: { type: 'boolean', label: 'Use Gradient for Stat Bars', default: true }, visibleStats: { type: 'multiselect', label: 'Visible Stats', default: ['health', 'satiety', 'energy', 'hygiene', 'arousal'], options: [ { value: 'health', label: 'Health' }, { value: 'satiety', label: 'Satiety' }, { value: 'energy', label: 'Energy' }, { value: 'hygiene', label: 'Hygiene' }, { value: 'arousal', label: 'Arousal' } ] } }; }, /** * Handle configuration changes * @param {HTMLElement} container - Widget container * @param {Object} newConfig - New configuration */ onConfigChange(container, newConfig) { // Re-render with new config this.render(container, newConfig); }, /** * Handle widget resize * @param {HTMLElement} container - Widget container * @param {number} newW - New width * @param {number} newH - New height */ onResize(container, newW, newH) { // Adjust layout based on size const statsContent = container.querySelector('.rpg-stats-content'); if (!statsContent) return; // Stack vertically on narrow widgets if (newW < 5) { statsContent.style.flexDirection = 'column'; } else { statsContent.style.flexDirection = 'row'; } } }); } /** * Attach event handlers to widget * @private */ function attachEventHandlers(container, settings, onStatsChange) { // Handle editable stat value changes (health, satiety, etc.) const editableStats = container.querySelectorAll('.rpg-editable-stat'); editableStats.forEach(field => { const fieldName = field.dataset.field; let originalValue = parseNumber(field.textContent.replace('%', '').trim(), 0, 0, 100); field.addEventListener('focus', () => { originalValue = parseNumber(field.textContent.replace('%', '').trim(), 0, 0, 100); // Select all text const range = document.createRange(); range.selectNodeContents(field); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); }); field.addEventListener('blur', () => { const textValue = field.textContent.replace('%', '').trim(); const value = parseNumber(textValue, originalValue, 0, 100); // Update display field.textContent = `${value}%`; // Update settings if changed if (value !== originalValue) { settings.userStats[fieldName] = value; // Update the bar fill const bar = field.parentElement.querySelector('.rpg-stat-fill'); if (bar) { bar.style.width = `${100 - value}%`; } // Trigger change callback if (onStatsChange) { onStatsChange('userStats', fieldName, value); } } }); field.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); field.blur(); } if (e.key === 'Escape') { e.preventDefault(); field.textContent = `${originalValue}%`; field.blur(); } }); // Prevent paste with formatting field.addEventListener('paste', (e) => { e.preventDefault(); const text = (e.clipboardData || window.clipboardData).getData('text/plain'); document.execCommand('insertText', false, text); }); }); // Handle mood emoji editing const moodEmoji = container.querySelector('.rpg-mood-emoji.rpg-editable'); if (moodEmoji) { let originalMood = moodEmoji.textContent.trim(); moodEmoji.addEventListener('focus', () => { originalMood = moodEmoji.textContent.trim(); const range = document.createRange(); range.selectNodeContents(moodEmoji); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); }); moodEmoji.addEventListener('blur', () => { const value = moodEmoji.textContent.trim() || '😐'; moodEmoji.textContent = value; if (value !== originalMood) { settings.userStats.mood = value; if (onStatsChange) { onStatsChange('userStats', 'mood', value); } } }); moodEmoji.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); moodEmoji.blur(); } if (e.key === 'Escape') { e.preventDefault(); moodEmoji.textContent = originalMood; moodEmoji.blur(); } }); } // Handle conditions editing const moodConditions = container.querySelector('.rpg-mood-conditions.rpg-editable'); if (moodConditions) { let originalConditions = moodConditions.textContent.trim(); moodConditions.addEventListener('focus', () => { originalConditions = moodConditions.textContent.trim(); const range = document.createRange(); range.selectNodeContents(moodConditions); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); }); moodConditions.addEventListener('blur', () => { const value = moodConditions.textContent.trim() || 'None'; moodConditions.textContent = value; if (value !== originalConditions) { settings.userStats.conditions = value; if (onStatsChange) { onStatsChange('userStats', 'conditions', value); } } }); moodConditions.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); moodConditions.blur(); } if (e.key === 'Escape') { e.preventDefault(); moodConditions.textContent = originalConditions; moodConditions.blur(); } }); } // Handle level editing const levelValue = container.querySelector('.rpg-level-value.rpg-editable'); if (levelValue) { let originalLevel = parseNumber(levelValue.textContent.trim(), 1, 1, 100); levelValue.addEventListener('focus', () => { originalLevel = parseNumber(levelValue.textContent.trim(), 1, 1, 100); const range = document.createRange(); range.selectNodeContents(levelValue); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); }); levelValue.addEventListener('blur', () => { const value = parseNumber(levelValue.textContent.trim(), originalLevel, 1, 100); levelValue.textContent = value; if (value !== originalLevel) { settings.level = value; if (onStatsChange) { onStatsChange('level', null, value); } } }); levelValue.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); levelValue.blur(); } if (e.key === 'Escape') { e.preventDefault(); levelValue.textContent = originalLevel; levelValue.blur(); } }); } // Handle classic stat +/- buttons const increaseButtons = container.querySelectorAll('.rpg-stat-increase'); const decreaseButtons = container.querySelectorAll('.rpg-stat-decrease'); increaseButtons.forEach(btn => { btn.addEventListener('click', () => { const statName = btn.dataset.stat; const valueSpan = btn.parentElement.querySelector('.rpg-classic-stat-value'); const currentValue = parseNumber(valueSpan.textContent, 10, 1, 20); const newValue = Math.min(20, currentValue + 1); valueSpan.textContent = newValue; settings.classicStats[statName] = newValue; if (onStatsChange) { onStatsChange('classicStats', statName, newValue); } }); }); decreaseButtons.forEach(btn => { btn.addEventListener('click', () => { const statName = btn.dataset.stat; const valueSpan = btn.parentElement.querySelector('.rpg-classic-stat-value'); const currentValue = parseNumber(valueSpan.textContent, 10, 1, 20); const newValue = Math.max(1, currentValue - 1); valueSpan.textContent = newValue; settings.classicStats[statName] = newValue; if (onStatsChange) { onStatsChange('classicStats', statName, newValue); } }); }); }