/** * Character Thoughts Rendering Module * Handles rendering of character thoughts panel and floating thought bubbles in chat */ import { getContext } from '../../../../../../extensions.js'; import { this_chid, characters } from '../../../../../../../script.js'; import { selected_group, getGroupMembers } from '../../../../../../group-chats.js'; import { extensionSettings, lastGeneratedData, committedTrackerData, $thoughtsContainer, FALLBACK_AVATAR_DATA_URI, addDebugLog } from '../../core/state.js'; import { saveChatData, saveSettings } from '../../core/persistence.js'; import { getSafeThumbnailUrl } from '../../utils/avatars.js'; import { isItemLocked, setItemLock } from '../generation/lockManager.js'; /** * Helper to generate lock icon HTML if setting is enabled * @param {string} tracker - Tracker name * @param {string} path - Item path * @returns {string} Lock icon HTML or empty string */ function getLockIconHtml(tracker, path) { const showLockIcons = extensionSettings.showLockIcons ?? true; if (!showLockIcons) return ''; const isLocked = isItemLocked(tracker, path); const lockIcon = isLocked ? '🔒' : '🔓'; const lockTitle = isLocked ? 'Locked' : 'Unlocked'; const lockedClass = isLocked ? ' locked' : ''; return `${lockIcon}`; } /** * Helper to log to both console and debug logs array */ function debugLog(message, data = null) { // console.log(message, data || ''); if (extensionSettings.debugMode) { addDebugLog(message, data); } } /** * Interpolates color based on percentage value between low and high colors * @param {number} percentage - Value from 0-100 * @param {string} lowColor - Hex color for low values (e.g., '#ff0000') * @param {string} highColor - Hex color for high values (e.g., '#00ff00') * @returns {string} Interpolated hex color */ function getStatColor(percentage, lowColor, highColor) { // Clamp percentage to 0-100 const percent = Math.max(0, Math.min(100, percentage)) / 100; // Parse hex colors const parsehex = (hex) => { const clean = hex.replace('#', ''); return { r: parseInt(clean.substring(0, 2), 16), g: parseInt(clean.substring(2, 4), 16), b: parseInt(clean.substring(4, 6), 16) }; }; const low = parsehex(lowColor); const high = parsehex(highColor); // Interpolate each channel const r = Math.round(low.r + (high.r - low.r) * percent); const g = Math.round(low.g + (high.g - low.g) * percent); const b = Math.round(low.b + (high.b - low.b) * percent); // Convert back to hex const toHex = (n) => n.toString(16).padStart(2, '0'); return `#${toHex(r)}${toHex(g)}${toHex(b)}`; } /** * Strips leading and trailing square brackets from a string value. * Used to clean placeholder notation that AI might include in responses. * @param {string} value - The value to clean * @returns {string} Cleaned value without surrounding brackets */ function stripBrackets(value) { if (typeof value !== 'string') return value; return value.replace(/^\[|\]$/g, '').trim(); } /** * Extracts the actual value from a field that might be locked. * If the field is an object with {value, locked}, returns the value. * Otherwise returns the field as-is. * @param {any} fieldValue - The field value (might be string or {value, locked} object) * @returns {string} The actual string value */ function extractFieldValue(fieldValue) { if (fieldValue && typeof fieldValue === 'object' && 'value' in fieldValue) { return fieldValue.value || ''; } return fieldValue || ''; } /** * Converts a field name to snake_case for use as JSON key * Example: "Test Tracker" -> "test_tracker" * @param {string} name - Field name to convert * @returns {string} snake_case version */ function toSnakeCase(name) { return name .toLowerCase() .replace(/[^a-z0-9]+/g, '_') .replace(/^_+|_+$/g, ''); } /** * Fuzzy name matching that handles: * - Exact matches: "Sabrina" === "Sabrina" * - Parenthetical additions: "Sabrina" matches "Sabrina (Margrokha's Avatar)" * - Title additions: "Sabrina" matches "Princess Sabrina" * - Word boundaries: "Sabrina" won't match "Sabrina's Mother" * * @param {string} cardName - Name from the character card * @param {string} aiName - Name generated by the AI * @returns {boolean} True if names match */ function namesMatch(cardName, aiName) { if (!cardName || !aiName) return false; // 1. Exact match (fast path) if (cardName.toLowerCase() === aiName.toLowerCase()) return true; // 2. Strip parentheses and match const stripParens = (s) => s.replace(/\s*\([^)]*\)/g, '').trim(); const cardCore = stripParens(cardName).toLowerCase(); const aiCore = stripParens(aiName).toLowerCase(); if (cardCore === aiCore) return true; // 3. Check if card name appears as complete word in AI name // Escape special regex characters to prevent "Invalid regular expression" errors const escapedCardCore = cardCore.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const wordBoundary = new RegExp(`\\b${escapedCardCore}\\b`); return wordBoundary.test(aiCore); } /** * Renders character thoughts (Present Characters) panel. * Displays character cards with avatars, relationship badges, and traits. * Includes event listeners for editable character fields. */ export function renderThoughts() { if (!extensionSettings.showCharacterThoughts || !$thoughtsContainer) { return; } // Don't render if no data exists (e.g., after cache clear) const thoughtsData = lastGeneratedData.characterThoughts || committedTrackerData.characterThoughts; if (!thoughtsData) { $thoughtsContainer.html('
No character data generated yet
'); return; } debugLog('[RPG Thoughts] ==================== RENDERING PRESENT CHARACTERS ===================='); debugLog('[RPG Thoughts] showCharacterThoughts setting:', extensionSettings.showCharacterThoughts); debugLog('[RPG Thoughts] Container exists:', !!$thoughtsContainer); // Add updating class for animation if (extensionSettings.enableAnimations) { $thoughtsContainer.addClass('rpg-content-updating'); } // Get tracker configuration const config = extensionSettings.trackerConfig?.presentCharacters; const enabledFields = config?.customFields?.filter(f => f && f.enabled && f.name) || []; const characterStatsConfig = config?.characterStats; const enabledCharStats = characterStatsConfig?.enabled && characterStatsConfig?.customStats?.filter(s => s && s.enabled && s.name) || []; const relationshipFields = config?.relationshipFields || []; const hasRelationshipEnabled = relationshipFields.length > 0; // Use committedTrackerData as fallback if lastGeneratedData is empty (e.g., after page refresh) const characterThoughtsData = lastGeneratedData.characterThoughts || committedTrackerData.characterThoughts || ''; // console.log('[RPG Companion] renderThoughts - Reading from lastGeneratedData:', JSON.stringify(lastGeneratedData.characterThoughts)); // console.log('[RPG Companion] renderThoughts - Reading from committedTrackerData:', JSON.stringify(committedTrackerData.characterThoughts)); debugLog('[RPG Thoughts] Raw characterThoughts data:', characterThoughtsData); debugLog('[RPG Thoughts] Data length:', characterThoughtsData.length + ' chars'); debugLog('[RPG Thoughts] Enabled custom fields:', enabledFields.map(f => f.name)); debugLog('[RPG Thoughts] Enabled character stats:', enabledCharStats.map(s => s.name)); let presentCharacters = []; // Try parsing as JSON first (new format) try { const parsed = typeof characterThoughtsData === 'string' ? JSON.parse(characterThoughtsData) : characterThoughtsData; // Handle both {characters: [...]} and direct array formats const charactersArray = Array.isArray(parsed) ? parsed : (parsed.characters || []); if (charactersArray.length > 0) { // JSON format: array of character objects presentCharacters = charactersArray.map(char => { const character = { name: char.name, emoji: char.emoji || '👤' }; // Extract details (appearance, demeanor, etc.) if (char.details) { // Map details object to custom fields for (const field of enabledFields) { // First try exact field name (for manually edited values) if (char.details[field.name] !== undefined) { character[field.name] = stripBrackets(char.details[field.name]); } else { // Fall back to snake_case for AI-generated values const fieldKey = toSnakeCase(field.name); if (char.details[fieldKey] !== undefined) { character[field.name] = stripBrackets(char.details[fieldKey]); } } } } // Also check for fields at root level (for backward compatibility) // Only use if not already set from details for (const field of enabledFields) { if (character[field.name] === undefined) { const fieldKey = toSnakeCase(field.name); if (char[fieldKey] !== undefined) { character[field.name] = stripBrackets(char[fieldKey]); } } } // Extract relationship // Prefer the new flat format (char.Relationship) over the old nested format (char.relationship.status) if (char.Relationship) { character.Relationship = stripBrackets(char.Relationship); } else if (char.relationship) { character.Relationship = stripBrackets(char.relationship.status || char.relationship); } // Extract thoughts content for bubble display if (char.thoughts) { character.ThoughtsContent = stripBrackets(char.thoughts.content || char.thoughts); } // Extract character stats if present if (char.stats && enabledCharStats.length > 0) { // Handle both object format {Health: 100, Energy: 95} and array format [{name: "Health", value: 100}] if (Array.isArray(char.stats)) { // Array format: [{name: "Health", value: 100}, {name: "Energy", value: 95}] for (const statObj of char.stats) { if (statObj.name && statObj.value !== undefined) { const matchingStat = enabledCharStats.find(s => s.name === statObj.name); if (matchingStat) { character[statObj.name] = statObj.value; } } } } else { // Object format: {Health: 100, Energy: 95} for (const stat of enabledCharStats) { if (char.stats[stat.name] !== undefined) { character[stat.name] = char.stats[stat.name]; } } } } return character; }); debugLog('[RPG Thoughts] ✓ Parsed JSON format, characters:', presentCharacters.length); } } catch (e) { debugLog('[RPG Thoughts] Not JSON format, falling back to text parsing'); } // If JSON parsing failed or returned empty, try text format if (presentCharacters.length === 0) { const lines = characterThoughtsData.split('\n'); debugLog('[RPG Thoughts] Split into lines count:', lines.length); debugLog('[RPG Thoughts] Lines:', lines); // Parse new multi-line format: // - [Name] // Details: [Emoji] | [Field1] | [Field2] | ... // Relationship: [Relationship] // Stats: Stat1: X% | Stat2: X% | ... // Thoughts: [Description] let lineNumber = 0; let currentCharacter = null; for (const line of lines) { lineNumber++; // Skip empty lines, headers, dividers, and code fences if (!line.trim() || line.includes('Present Characters') || line.includes('---') || line.trim().startsWith('```') || line.trim() === '- …' || line.includes('(Repeat the format')) { continue; } debugLog(`[RPG Thoughts] Processing line ${lineNumber}:`, line); // Check if this is a character name line (starts with "- ") if (line.trim().startsWith('- ')) { const name = line.trim().substring(2).trim(); if (name && name.toLowerCase() !== 'unavailable') { currentCharacter = { name }; presentCharacters.push(currentCharacter); debugLog(`[RPG Thoughts] ✓ Started new character: ${name}`); } else { currentCharacter = null; debugLog(`[RPG Thoughts] ✗ Rejected character - name: "${name}" (unavailable or empty)`); } } // Check if this is a Details line else if (line.trim().startsWith('Details:') && currentCharacter) { const detailsContent = line.substring(line.indexOf(':') + 1).trim(); const parts = detailsContent.split('|').map(p => p.trim()); // First part is the emoji if (parts.length > 0) { currentCharacter.emoji = parts[0]; debugLog(`[RPG Thoughts] Parsed emoji: ${parts[0]}`); } // Remaining parts are custom fields for (let i = 0; i < enabledFields.length && i + 1 < parts.length; i++) { const fieldName = enabledFields[i].name; currentCharacter[fieldName] = parts[i + 1]; debugLog(`[RPG Thoughts] Parsed field ${fieldName}: ${parts[i + 1]}`); } } // Check if this is a Relationship line else if (line.trim().startsWith('Relationship:') && currentCharacter) { const relationship = line.substring(line.indexOf(':') + 1).trim(); currentCharacter.Relationship = relationship; debugLog(`[RPG Thoughts] Parsed relationship: ${relationship}`); } // Check if this is a Stats line else if (line.trim().startsWith('Stats:') && currentCharacter && enabledCharStats.length > 0) { const statsContent = line.substring(line.indexOf(':') + 1).trim(); const statParts = statsContent.split('|').map(p => p.trim()); for (const statPart of statParts) { const statMatch = statPart.match(/^(.+?):\s*(\d+)%$/); if (statMatch) { const statName = statMatch[1].trim(); const statValue = parseInt(statMatch[2]); currentCharacter[statName] = statValue; debugLog(`[RPG Thoughts] Parsed stat: ${statName} = ${statValue}%`); } } } // Check if this is a Thoughts line (handled separately for thought bubbles) else if (line.trim().match(/^[A-Z][a-z]+:/) && currentCharacter) { // This could be Thoughts, Feelings, etc. - skip for now, handled in thought bubble rendering debugLog(`[RPG Thoughts] Skipping thoughts/feelings line (handled in bubble rendering)`); } } } // End of text format parsing // Get relationship emojis from config (with fallback defaults) const relationshipEmojis = config?.relationshipEmojis || { 'Enemy': '⚔️', 'Neutral': '⚖️', 'Friend': '⭐', 'Lover': '❤️' }; debugLog('[RPG Thoughts] ==================== PARSING COMPLETE ===================='); debugLog('[RPG Thoughts] Total characters parsed:', presentCharacters.length); debugLog('[RPG Thoughts] Characters array:', presentCharacters); // Build HTML let html = ''; debugLog('[RPG Thoughts] ==================== BUILDING HTML ===================='); debugLog('[RPG Thoughts] Starting HTML generation for', presentCharacters.length + ' characters'); // If no characters parsed, show a placeholder editable card if (presentCharacters.length === 0) { debugLog('[RPG Thoughts] ⚠ No characters parsed - showing placeholder card'); // Get default character portrait let defaultPortrait = FALLBACK_AVATAR_DATA_URI; let defaultName = 'Character'; if (this_chid !== undefined && characters[this_chid]) { if (characters[this_chid].avatar && characters[this_chid].avatar !== 'none') { const thumbnailUrl = getSafeThumbnailUrl('avatar', characters[this_chid].avatar); if (thumbnailUrl) { defaultPortrait = thumbnailUrl; } } defaultName = characters[this_chid].name || 'Character'; } html += '
'; html += `
${defaultName}
⚖️
😊 ${defaultName}
`; // Add custom fields dynamically for (const field of enabledFields) { const fieldId = field.name.toLowerCase().replace(/\s+/g, '-'); html += `
`; } html += `
`; html += '
'; } else { html += '
'; let characterIndex = 0; for (const char of presentCharacters) { characterIndex++; try { debugLog(`[RPG Thoughts] Building HTML for character ${characterIndex}/${presentCharacters.length}:`, char.name); // Find character portrait // Use a base64-encoded SVG placeholder as fallback to avoid 400 errors let characterPortrait = FALLBACK_AVATAR_DATA_URI; debugLog(`[RPG Thoughts] Looking up avatar for: ${char.name}`); // First, check if user manually uploaded a custom avatar if (extensionSettings.npcAvatars && extensionSettings.npcAvatars[char.name]) { characterPortrait = extensionSettings.npcAvatars[char.name]; debugLog('[RPG Thoughts] Found custom uploaded avatar'); } // For group chats, search through group members if (characterPortrait === FALLBACK_AVATAR_DATA_URI && selected_group) { debugLog('[RPG Thoughts] In group chat, checking group members...'); try { const groupMembers = getGroupMembers(selected_group); debugLog('[RPG Thoughts] Group members count:', groupMembers ? groupMembers.length : 0); if (groupMembers && groupMembers.length > 0) { const matchingMember = groupMembers.find(member => member && member.name && namesMatch(member.name, char.name) ); if (matchingMember && matchingMember.avatar && matchingMember.avatar !== 'none') { const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingMember.avatar); if (thumbnailUrl) { characterPortrait = thumbnailUrl; debugLog('[RPG Thoughts] Found avatar in group members'); } } } } catch (groupError) { debugLog('[RPG Thoughts] Error checking group members:', groupError.message); } } // For regular chats or if not found in group, search all characters if (characterPortrait === FALLBACK_AVATAR_DATA_URI && characters && characters.length > 0) { debugLog('[RPG Thoughts] Searching all characters...'); const matchingCharacter = characters.find(c => c && c.name && namesMatch(c.name, char.name) ); if (matchingCharacter && matchingCharacter.avatar && matchingCharacter.avatar !== 'none') { const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingCharacter.avatar); if (thumbnailUrl) { characterPortrait = thumbnailUrl; debugLog('[RPG Thoughts] Found avatar in all characters'); } } } // If this is the current character in a 1-on-1 chat, use their portrait if (this_chid !== undefined && characters[this_chid] && characters[this_chid].name && namesMatch(characters[this_chid].name, char.name)) { const thumbnailUrl = getSafeThumbnailUrl('avatar', characters[this_chid].avatar); if (thumbnailUrl) { characterPortrait = thumbnailUrl; debugLog('[RPG Thoughts] Found avatar from current character'); } } debugLog(`[RPG Thoughts] Final avatar for ${char.name}:`, characterPortrait.substring(0, 50) + '...'); // Get relationship badge - only if relationships are enabled in config let relationshipBadge = '⚖️'; // Default let relationshipFieldName = 'Relationship'; if (hasRelationshipEnabled) { // In the new format, relationship is always stored in char.Relationship if (char.Relationship) { // console.log(`[RPG Companion] Rendering ${char.name} - char.Relationship:`, char.Relationship); // console.log('[RPG Companion] relationshipEmojis mapping:', relationshipEmojis); // Try to map text to emoji relationshipBadge = relationshipEmojis[char.Relationship] || char.Relationship; // console.log('[RPG Companion] Final relationshipBadge:', relationshipBadge); } } debugLog(`[RPG Thoughts] Building HTML card for ${char.name}...`); html += `
${char.name} ${hasRelationshipEnabled ? `
${relationshipBadge}
` : ''}
${char.emoji} ${char.name}
`; // Render custom fields dynamically for (const field of enabledFields) { const rawValue = char[field.name]; const fieldValue = extractFieldValue(rawValue); const fieldId = field.name.toLowerCase().replace(/\s+/g, '-'); const fieldNameLower = field.name.toLowerCase(); // Skip lock icons for thoughts field const showLock = !fieldNameLower.includes('thought'); if (showLock) { const lockIconHtml = getLockIconHtml('characters', `${char.name}.${field.name}`); html += `
${lockIconHtml} ${fieldValue}
`; } else { html += `
${fieldValue}
`; } } html += `
`; // Render character stats if enabled (outside rpg-character-info) if (enabledCharStats.length > 0) { const lockIconHtml = getLockIconHtml('characters', `${char.name}.stats`); html += `
${lockIconHtml}
`; for (const stat of enabledCharStats) { const statValue = char[stat.name] || 0; const statColor = getStatColor(statValue, extensionSettings.statBarColorLow, extensionSettings.statBarColorHigh); html += `
${stat.name}: ${statValue}%
`; } html += `
`; } html += `
`; debugLog(`[RPG Thoughts] ✓ Successfully built HTML for ${char.name}`); } catch (charError) { debugLog(`[RPG Thoughts] ✗ ERROR building HTML for ${char.name}:`, charError.message); debugLog('[RPG Thoughts] Error stack:', charError.stack); // Continue with next character instead of crashing } } debugLog('[RPG Thoughts] Finished building all character cards'); html += '
'; } $thoughtsContainer.html(html); debugLog('[RPG Thoughts] ✓ HTML rendered to container'); debugLog('[RPG Thoughts] ======================================================='); // Add event handlers for editable character fields $thoughtsContainer.find('.rpg-editable').on('blur', function() { const character = $(this).data('character'); const field = $(this).data('field'); const value = $(this).text().trim(); // console.log('[RPG Companion] Character stat edit:', { character, field, value }); updateCharacterField(character, field, value); }); // Prevent click events on editable elements from bubbling to avatar upload handler $thoughtsContainer.find('.rpg-editable').on('click mousedown', function(e) { e.stopPropagation(); }); // Add event listener for section lock icon clicks (support both click and touch) $thoughtsContainer.find('.rpg-section-lock-icon').on('click touchend', function(e) { e.preventDefault(); e.stopPropagation(); const $icon = $(this); const trackerType = $icon.data('tracker'); const itemPath = $icon.data('path'); const currentlyLocked = isItemLocked(trackerType, itemPath); // Toggle lock state setItemLock(trackerType, itemPath, !currentlyLocked); // Update icon const newIcon = !currentlyLocked ? '🔒' : '🔓'; const newTitle = !currentlyLocked ? 'Locked' : 'Unlocked'; $icon.text(newIcon); $icon.attr('title', newTitle); // Toggle 'locked' class for persistent visibility $icon.toggleClass('locked', !currentlyLocked); // Save settings saveSettings(); }); // Add event listener for avatar upload clicks $thoughtsContainer.find('.rpg-avatar-upload').on('click', function(e) { e.preventDefault(); e.stopPropagation(); const characterName = $(this).data('character'); // Create hidden file input const fileInput = $(''); fileInput.on('change', function() { const file = this.files[0]; if (!file) return; // Read file as data URL const reader = new FileReader(); reader.onload = function(e) { const imageUrl = e.target.result; // Store in npcAvatars if (!extensionSettings.npcAvatars) { extensionSettings.npcAvatars = {}; } extensionSettings.npcAvatars[characterName] = imageUrl; // Save settings saveSettings(); // Update the avatar image immediately const $avatar = $thoughtsContainer.find(`.rpg-avatar-upload[data-character="${characterName}"] img`); $avatar.attr('src', imageUrl); console.log(`[RPG Companion] Avatar uploaded for ${characterName}`); }; reader.readAsDataURL(file); }); // Trigger file selection fileInput.trigger('click'); }); // Remove updating class after animation if (extensionSettings.enableAnimations) { setTimeout(() => $thoughtsContainer.removeClass('rpg-content-updating'), 600); } // Update chat overlay if enabled if (extensionSettings.showThoughtsInChat) { updateChatThoughts(); } } /** * Updates a specific character field in Present Characters data and re-renders. * Works with the new multi-line format. * * @param {string} characterName - Name of the character to update * @param {string} field - Field to update (emoji, name, custom field name, Relationship, stat name) * @param {string} value - New value for the field */ export function updateCharacterField(characterName, field, value) { // Initialize if it doesn't exist if (!lastGeneratedData.characterThoughts) { lastGeneratedData.characterThoughts = 'Present Characters\n---\n'; } const presentCharsConfig = extensionSettings.trackerConfig?.presentCharacters; const enabledFields = presentCharsConfig?.customFields?.filter(f => f && f.enabled && f.name) || []; const characterStats = presentCharsConfig?.characterStats; const enabledCharStats = characterStats?.enabled && characterStats?.customStats?.filter(s => s && s.enabled && s.name) || []; // Get relationship emoji mappings from config const relationshipEmojis = presentCharsConfig?.relationshipEmojis || { 'Enemy': '⚔️', 'Neutral': '⚖️', 'Friend': '⭐', 'Lover': '❤️' }; // Create reverse mapping (emoji → name) const emojiToRelationship = {}; for (const [name, emoji] of Object.entries(relationshipEmojis)) { emojiToRelationship[emoji] = name; } // Check if data is in JSON format let isJSON = false; let parsedData = null; try { parsedData = typeof lastGeneratedData.characterThoughts === 'string' ? JSON.parse(lastGeneratedData.characterThoughts) : lastGeneratedData.characterThoughts; if (Array.isArray(parsedData) || (parsedData && parsedData.characters)) { isJSON = true; } } catch (e) { // Not JSON, continue with text format } // Handle JSON format if (isJSON) { const charactersArray = Array.isArray(parsedData) ? parsedData : (parsedData.characters || []); const charIndex = charactersArray.findIndex(c => c.name && c.name.toLowerCase() === characterName.toLowerCase() ); if (charIndex !== -1) { const char = charactersArray[charIndex]; // console.log('[RPG Companion] Updating character:', characterName, 'field:', field, 'value:', value); // console.log('[RPG Companion] Before update - char.Relationship:', char.Relationship); // Update the appropriate field if (field === 'name') { char.name = value; } else if (field === 'emoji') { char.emoji = value; } else if (field === 'Relationship') { // Store relationship as text, converting emoji if needed // First check if it's an emoji → convert to text if (emojiToRelationship[value]) { char.Relationship = emojiToRelationship[value]; } else { // It's text - find matching relationship name (case-insensitive) const matchingRelationship = Object.keys(relationshipEmojis).find( name => name.toLowerCase() === value.toLowerCase() ); char.Relationship = matchingRelationship || value; } // console.log('[RPG Companion] After update - char.Relationship:', char.Relationship); // console.log('[RPG Companion] relationshipEmojis:', relationshipEmojis); // console.log('[RPG Companion] emojiToRelationship:', emojiToRelationship); } else if (field.toLowerCase() === 'thoughts' || field === (presentCharsConfig?.thoughts?.name || 'Thoughts')) { if (!char.thoughts) char.thoughts = {}; char.thoughts.content = value; } else { // Check if it's a character stat const isStatField = enabledCharStats.findIndex(s => s.name === field) !== -1; if (isStatField) { if (!char.stats) char.stats = {}; let numValue = parseInt(value.replace('%', '').trim()); if (isNaN(numValue)) numValue = 0; numValue = Math.max(0, Math.min(100, numValue)); char.stats[field] = numValue; } else { // It's a custom detail field if (!char.details) char.details = {}; char.details[field] = value; } } } // Save back to lastGeneratedData lastGeneratedData.characterThoughts = Array.isArray(parsedData) ? charactersArray : { ...parsedData, characters: charactersArray }; committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts; // console.log('[RPG Companion] Saved to lastGeneratedData.characterThoughts:', JSON.stringify(lastGeneratedData.characterThoughts)); // console.log('[RPG Companion] Saved to committedTrackerData.characterThoughts:', JSON.stringify(committedTrackerData.characterThoughts)); // Update in chat metadata const chat = getContext().chat; if (chat && chat.length > 0) { for (let i = chat.length - 1; i >= 0; i--) { const message = chat[i]; if (!message.is_user) { if (message.extra && message.extra.rpg_companion_swipes) { const swipeId = message.swipe_id || 0; if (message.extra.rpg_companion_swipes[swipeId]) { message.extra.rpg_companion_swipes[swipeId].characterThoughts = lastGeneratedData.characterThoughts; } } break; } } } saveChatData(); // console.log('[RPG Companion] JSON format updated successfully'); // console.log('[RPG Companion] Updated data:', lastGeneratedData.characterThoughts); // Re-render the thoughts panel to show updated value renderThoughts(); // Update chat thought overlays if editing thoughts const thoughtsFieldName = presentCharsConfig?.thoughts?.name || 'Thoughts'; const isEditingThoughts = field === thoughtsFieldName || field === 'thoughts'; if (isEditingThoughts && extensionSettings.showThoughtsInChat) { updateChatThoughts(); } return; // Exit early for JSON format } // Continue with text format handling below const lines = lastGeneratedData.characterThoughts.split('\n'); let characterFound = false; let inTargetCharacter = false; let characterStartIndex = -1; let characterEndIndex = -1; // Find the character block for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (line.startsWith('- ')) { const name = line.substring(2).trim(); if (name.toLowerCase() === characterName.toLowerCase()) { characterFound = true; inTargetCharacter = true; characterStartIndex = i; } else if (inTargetCharacter) { characterEndIndex = i; break; } } } if (characterFound && characterEndIndex === -1) { characterEndIndex = lines.length; } if (characterFound) { // Check if we're updating a character stat const isStatField = enabledCharStats.findIndex(s => s.name === field) !== -1; let statsLineExists = false; let statsLineIndex = -1; // Get the configured thoughts field name const thoughtsFieldName = presentCharsConfig?.thoughts?.name || 'Thoughts'; const isThoughtsField = field.toLowerCase() === 'thoughts' || field === thoughtsFieldName; // First pass: check if Stats line exists and update other fields for (let i = characterStartIndex; i < characterEndIndex; i++) { const line = lines[i].trim(); if (line.startsWith('Stats:')) { statsLineExists = true; statsLineIndex = i; } if (field === 'name' && line.startsWith('- ')) { lines[i] = `- ${value}`; } else if (field === 'emoji' && line.startsWith('Details:')) { const parts = line.substring(line.indexOf(':') + 1).split('|').map(p => p.trim()); parts[0] = value; lines[i] = `Details: ${parts.join(' | ')}`; } else if (line.startsWith('Details:')) { const fieldIndex = enabledFields.findIndex(f => f.name === field); if (fieldIndex !== -1) { const parts = line.substring(line.indexOf(':') + 1).split('|').map(p => p.trim()); if (parts.length > fieldIndex + 1) { parts[fieldIndex + 1] = value; lines[i] = `Details: ${parts.join(' | ')}`; } } } else if (field === 'Relationship' && line.startsWith('Relationship:')) { const emojiToRelationship = { '⚔️': 'Enemy', '⚖️': 'Neutral', '⭐': 'Friend', '❤️': 'Lover' }; const relationshipValue = emojiToRelationship[value] || value; lines[i] = `Relationship: ${relationshipValue}`; } else if (isThoughtsField && line.startsWith(thoughtsFieldName + ':')) { // Update thoughts field lines[i] = `${thoughtsFieldName}: ${value}`; // console.log('[RPG Companion] Updated thoughts:', lines[i]); } } // Handle stat updates if (isStatField) { // Clean the value: remove % if present, parse as integer, clamp 0-100 let cleanValue = value.replace('%', '').trim(); let numValue = parseInt(cleanValue); if (isNaN(numValue)) { numValue = 0; } numValue = Math.max(0, Math.min(100, numValue)); // console.log('[RPG Companion] Updating stat:', { field, rawValue: value, cleanValue, numValue }); if (statsLineExists) { // Update existing Stats line const line = lines[statsLineIndex]; const statsContent = line.substring(line.indexOf(':') + 1).trim(); const statParts = statsContent.split('|').map(p => p.trim()); let statFound = false; for (let j = 0; j < statParts.length; j++) { if (statParts[j].startsWith(field + ':')) { statParts[j] = `${field}: ${numValue}%`; statFound = true; // console.log('[RPG Companion] Updated stat part:', statParts[j]); break; } } // If stat wasn't found in existing parts, add it if (!statFound) { statParts.push(`${field}: ${numValue}%`); // console.log('[RPG Companion] Added new stat to existing line:', `${field}: ${numValue}%`); } lines[statsLineIndex] = `Stats: ${statParts.join(' | ')}`; // console.log('[RPG Companion] Updated stats line:', lines[statsLineIndex]); } else { // Create new Stats line with all enabled stats (defaulting to 0% except the one being edited) const statsParts = enabledCharStats.map(s => { if (s.name === field) { return `${s.name}: ${numValue}%`; } return `${s.name}: 0%`; }); const newStatsLine = `Stats: ${statsParts.join(' | ')}`; // Insert before Thoughts line or at end of character block let insertIndex = characterEndIndex; for (let i = characterStartIndex; i < characterEndIndex; i++) { const line = lines[i].trim(); const thoughtsFieldName = presentCharsConfig?.thoughts?.name || 'Thoughts'; if (line.startsWith(thoughtsFieldName + ':')) { insertIndex = i; break; } } lines.splice(insertIndex, 0, newStatsLine); // console.log('[RPG Companion] Created new stats line:', newStatsLine); characterEndIndex++; // Adjust end index since we inserted a line } } } else { // Create new character block const dividerIndex = lines.findIndex(line => line.includes('---')); if (dividerIndex >= 0) { const newCharacterLines = [`- ${characterName}`]; let detailsParts = [field === 'emoji' ? value : '😊']; for (let i = 0; i < enabledFields.length; i++) { detailsParts.push(field === enabledFields[i].name ? value : ''); } newCharacterLines.push(`Details: ${detailsParts.join(' | ')}`); if (presentCharsConfig?.relationshipFields?.length > 0) { const emojiToRelationship = { '⚔️': 'Enemy', '⚖️': 'Neutral', '⭐': 'Friend', '❤️': 'Lover' }; const relationshipValue = field === 'Relationship' ? (emojiToRelationship[value] || value) : 'Neutral'; newCharacterLines.push(`Relationship: ${relationshipValue}`); } if (enabledCharStats.length > 0) { const statsParts = enabledCharStats.map(s => { if (field === s.name) { // Clean the value: remove % if present, parse as integer, clamp 0-100 let cleanValue = value.replace('%', '').trim(); let numValue = parseInt(cleanValue); if (isNaN(numValue)) { numValue = 0; } numValue = Math.max(0, Math.min(100, numValue)); return `${s.name}: ${numValue}%`; } return `${s.name}: 0%`; }); newCharacterLines.push(`Stats: ${statsParts.join(' | ')}`); } lines.splice(dividerIndex + 1, 0, ...newCharacterLines); } } lastGeneratedData.characterThoughts = lines.join('\n'); committedTrackerData.characterThoughts = lines.join('\n'); // console.log('[RPG Companion] Updated characterThoughts data:', lastGeneratedData.characterThoughts); const chat = getContext().chat; if (chat && chat.length > 0) { for (let i = chat.length - 1; i >= 0; i--) { const message = chat[i]; if (!message.is_user) { if (message.extra && message.extra.rpg_companion_swipes) { const swipeId = message.swipe_id || 0; if (message.extra.rpg_companion_swipes[swipeId]) { message.extra.rpg_companion_swipes[swipeId].characterThoughts = lines.join('\n'); } } break; } } } saveChatData(); // Don't re-render to avoid overwriting user edits while they're still editing // The changes are already saved to lastGeneratedData and committedTrackerData // Re-rendering would cause the display to reset and lose focus // console.log('[RPG Companion] updateCharacterField called:', { characterName, field, value }); // console.log('[RPG Companion] Before update - lastGeneratedData.characterThoughts:', lastGeneratedData.characterThoughts); // Only update chat thought overlays if editing thoughts field const thoughtsFieldName = presentCharsConfig?.thoughts?.name || 'Thoughts'; const isEditingThoughts = field === thoughtsFieldName || field === 'thoughts'; // console.log('[RPG Companion] Is editing thoughts?', isEditingThoughts, 'Field:', field, 'Thoughts field name:', thoughtsFieldName); // console.log('[RPG Companion] After update - lastGeneratedData.characterThoughts:', lastGeneratedData.characterThoughts); if (isEditingThoughts && extensionSettings.showThoughtsInChat) { // console.log('[RPG Companion] Updating chat thought bubbles'); // Update chat thought bubbles when thoughts are edited updateChatThoughts(); } // Note: Don't call renderThoughts() here - it would overwrite the user's edits } /** * Renders only the sidebar thoughts panel without updating chat bubbles */ function renderThoughtsSidebarOnly() { if (!extensionSettings.showCharacterThoughts || !$thoughtsContainer) { return; } // This is a simplified version that only updates the sidebar // Copy the rendering logic from renderThoughts but skip the updateChatThoughts call const thoughtsData = lastGeneratedData.characterThoughts || committedTrackerData.characterThoughts; if (!thoughtsData) { $thoughtsContainer.html('
No character data generated yet
'); return; } // Re-render sidebar content (this would be the full logic from renderThoughts) // For now, just call renderThoughts but set a flag const originalShowInChat = extensionSettings.showThoughtsInChat; extensionSettings.showThoughtsInChat = false; renderThoughts(); extensionSettings.showThoughtsInChat = originalShowInChat; } /** * Updates or removes thought overlays in the chat. * Creates floating thought bubbles positioned near character avatars. */ export function updateChatThoughts() { // console.log('[RPG Companion] ======== updateChatThoughts called ========'); // console.log('[RPG Companion] Extension enabled:', extensionSettings.enabled); // console.log('[RPG Companion] showThoughtsInChat setting:', extensionSettings.showThoughtsInChat); // console.log('[RPG Companion] Toggle element checked:', $('#rpg-toggle-thoughts-in-chat').prop('checked')); // console.log('[RPG Companion] lastGeneratedData.characterThoughts:', lastGeneratedData.characterThoughts); // Remove existing thought panel and icon $('#rpg-thought-panel').remove(); $('#rpg-thought-icon').remove(); $('#chat').off('scroll.thoughtPanel'); $(window).off('resize.thoughtPanel'); $(document).off('click.thoughtPanel'); // If extension is disabled, thoughts in chat are disabled, or no thoughts, just return if (!extensionSettings.enabled || !extensionSettings.showThoughtsInChat || !lastGeneratedData.characterThoughts) { // console.log('[RPG Companion] Thoughts in chat disabled or no data'); return; } // Parse the Present Characters data to get thoughts let thoughtsArray = []; // Array of {name, emoji, thought} const thoughtsConfig = extensionSettings.trackerConfig?.presentCharacters?.thoughts; const thoughtsLabel = thoughtsConfig?.name || 'Thoughts'; // Try JSON format first try { const parsed = typeof lastGeneratedData.characterThoughts === 'string' ? JSON.parse(lastGeneratedData.characterThoughts) : lastGeneratedData.characterThoughts; // Handle both {characters: [...]} and direct array formats const charactersArray = Array.isArray(parsed) ? parsed : (parsed.characters || []); if (charactersArray.length > 0) { // Extract thoughts from JSON character objects thoughtsArray = charactersArray .filter(char => char.thoughts && char.thoughts.content) .map(char => ({ name: (char.name || '').toLowerCase(), emoji: char.emoji || '👤', thought: char.thoughts.content })); debugLog('[RPG Thoughts Bubble] ✓ Parsed JSON format, thoughts:', thoughtsArray.length); } } catch (e) { debugLog('[RPG Thoughts Bubble] Not JSON format, falling back to text parsing'); } // If JSON parsing failed or returned empty, try text format if (thoughtsArray.length === 0) { const lines = lastGeneratedData.characterThoughts.split('\n'); // console.log('[RPG Companion] Parsing thoughts from lines:', lines); // Parse new format to build character map and thoughts let currentCharName = null; let currentCharEmoji = null; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (!line || line.includes('Present Characters') || line.includes('---') || line.startsWith('```') || line.trim() === '- …' || line.includes('(Repeat the format')) { continue; } // Check if this is a character name line (starts with "- ") if (line.startsWith('- ')) { const name = line.substring(2).trim(); if (name && name.toLowerCase() !== 'unavailable') { currentCharName = name; currentCharEmoji = null; // Reset emoji for new character } else { currentCharName = null; currentCharEmoji = null; } } // Check if this is a Details line (contains the emoji) else if (line.startsWith('Details:') && currentCharName) { const detailsContent = line.substring(line.indexOf(':') + 1).trim(); const parts = detailsContent.split('|').map(p => p.trim()); // First part is the emoji if (parts.length > 0) { currentCharEmoji = parts[0]; } } // Check if this is a Thoughts line else if (line.startsWith(thoughtsLabel + ':') && currentCharName && currentCharEmoji) { const thoughtContent = line.substring(thoughtsLabel.length + 1).trim(); // The thought content is just the text (no emoji prefix in new format) if (thoughtContent) { thoughtsArray.push({ name: currentCharName.toLowerCase(), emoji: currentCharEmoji, thought: thoughtContent }); } } } } // End of text format parsing for thoughts bubbles debugLog('[RPG Thoughts] Parsed thoughts:', thoughtsArray); // If no thoughts parsed, return if (thoughtsArray.length === 0) { // console.log('[RPG Companion] No thoughts parsed, returning'); return; } // console.log('[RPG Companion] Total thoughts:', thoughtsArray.length); // console.log('[RPG Companion] Thoughts array:', thoughtsArray); // Find the last message to position near const $messages = $('#chat .mes'); let $targetMessage = null; // Find the most recent non-user message for (let i = $messages.length - 1; i >= 0; i--) { const $message = $messages.eq(i); if ($message.attr('is_user') !== 'true') { $targetMessage = $message; break; } } if (!$targetMessage) { // console.log('[RPG Companion] No target message found'); return; } // Create the thought panel with all thoughts createThoughtPanel($targetMessage, thoughtsArray); } // ===== GLOBAL DRAGGING SETUP FOR THOUGHT ICON (MOBILE ONLY) ===== // These variables and handlers are set up once, outside createThoughtPanel let isDragging = false; let touchMoved = false; let dragStartTime = 0; let dragStartX = 0; let dragStartY = 0; let iconStartX = 0; let iconStartY = 0; const DRAG_THRESHOLD = 10; const LONG_PRESS_DURATION = 200; let rafId = null; let pendingX = null; let pendingY = null; let thoughtIconDragHandlersInitialized = false; let justFinishedDragging = false; // Flag to block clicks immediately after drag function updateIconDragPosition() { if (pendingX !== null && pendingY !== null) { $('#rpg-thought-icon').css({ left: pendingX + 'px', top: pendingY + 'px', right: 'auto', bottom: 'auto' }); pendingX = null; pendingY = null; } rafId = null; } function initThoughtIconDragHandlers() { if (thoughtIconDragHandlersInitialized) return; thoughtIconDragHandlersInitialized = true; // console.log('[Thought Icon] Initializing drag handlers ONCE - will attach to icon when created'); } // Function to attach drag handlers to a specific icon element function attachDragHandlersToIcon($icon) { // console.log('[Thought Icon] Attaching handlers to icon element'); // Remove any existing handlers $icon.off('.thoughtIconDrag'); // Test: add a simple click handler to verify events work $icon.on('click.thoughtIconDrag', function(e) { // Check global flag set immediately after drag completes if (justFinishedDragging) { // console.log('[Thought Icon] CLICK blocked - just finished dragging'); e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); return false; } // console.log('[Thought Icon] CLICK detected on icon!'); }); // Touch drag support - mobile only $icon.on('touchstart.thoughtIconDrag', function(e) { if (window.innerWidth > 1000) return; // console.log('[Thought Icon] touchstart'); touchMoved = false; dragStartTime = Date.now(); const touch = e.originalEvent.touches[0]; dragStartX = touch.clientX; dragStartY = touch.clientY; const offset = $(this).offset(); iconStartX = offset.left; iconStartY = offset.top; isDragging = false; }); $icon.on('touchmove.thoughtIconDrag', function(e) { if (window.innerWidth > 1000) return; if (!touchMoved) { // console.log('[Thought Icon] touchmove - first movement'); } touchMoved = true; const touch = e.originalEvent.touches[0]; const deltaX = touch.clientX - dragStartX; const deltaY = touch.clientY - dragStartY; const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); const timeSinceStart = Date.now() - dragStartTime; if (!isDragging && (timeSinceStart > LONG_PRESS_DURATION || distance > DRAG_THRESHOLD)) { isDragging = true; $(this).addClass('dragging'); } if (isDragging) { e.preventDefault(); let newX = iconStartX + deltaX; let newY = iconStartY + deltaY; const iconWidth = $(this).outerWidth(); const iconHeight = $(this).outerHeight(); const minX = 10; const maxX = window.innerWidth - iconWidth - 10; const minY = 10; const maxY = window.innerHeight - iconHeight - 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(updateIconDragPosition); } } }); $icon.on('touchend.thoughtIconDrag', function(e) { // console.log('[Thought Icon] touchend - isDragging:', isDragging, 'touchMoved:', touchMoved); if (isDragging) { const offset = $(this).offset(); const newPosition = { left: offset.left + 'px', top: offset.top + 'px' }; extensionSettings.thoughtIconPosition = newPosition; saveSettings(); setTimeout(() => { const $currentIcon = $('#rpg-thought-icon'); if ($currentIcon.length) { constrainIconToViewport($currentIcon); } }, 10); setTimeout(() => { $(this).removeClass('dragging'); }, 50); isDragging = false; $(this).data('just-dragged', true); setTimeout(() => { $(this).data('just-dragged', false); }, 300); e.preventDefault(); e.stopPropagation(); } else if (!touchMoved) { // console.log('[Thought Icon] Opening panel - was a tap'); const $panel = $('#rpg-thought-panel'); const iconOffset = $(this).offset(); if (iconOffset) { $panel.css({ top: iconOffset.top + 'px', left: iconOffset.left + 'px', display: 'none' }); } $(this).addClass('rpg-hidden'); $panel.fadeIn(200); } else { // console.log('[Thought Icon] Did nothing - touchMoved but not isDragging'); } }); // Mouse drag support - mobile only let mouseDown = false; $icon.on('mousedown.thoughtIconDrag', function(e) { if (window.innerWidth > 1000) return; // console.log('[Thought Icon] mousedown'); e.preventDefault(); mouseDown = true; touchMoved = false; dragStartTime = Date.now(); dragStartX = e.clientX; dragStartY = e.clientY; const offset = $(this).offset(); iconStartX = offset.left; iconStartY = offset.top; isDragging = false; }); $(document).on('mousemove.thoughtIconDrag', function(e) { if (!mouseDown || window.innerWidth > 1000) return; if (!touchMoved) { // console.log('[Thought Icon] mousemove - first movement'); } touchMoved = true; const deltaX = e.clientX - dragStartX; const deltaY = e.clientY - dragStartY; const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); const timeSinceStart = Date.now() - dragStartTime; if (!isDragging && (timeSinceStart > LONG_PRESS_DURATION || distance > DRAG_THRESHOLD)) { isDragging = true; $('#rpg-thought-icon').addClass('dragging'); } if (isDragging) { e.preventDefault(); let newX = iconStartX + deltaX; let newY = iconStartY + deltaY; const $currentIcon = $('#rpg-thought-icon'); const iconWidth = $currentIcon.outerWidth(); const iconHeight = $currentIcon.outerHeight(); const minX = 10; const maxX = window.innerWidth - iconWidth - 10; const minY = 10; const maxY = window.innerHeight - iconHeight - 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(updateIconDragPosition); } } }); $(document).on('mouseup.thoughtIconDrag', function(e) { if (!mouseDown) return; // console.log('[Thought Icon] mouseup - isDragging:', isDragging, 'touchMoved:', touchMoved); mouseDown = false; if (isDragging) { // Set global flag IMMEDIATELY to block click event justFinishedDragging = true; setTimeout(() => { justFinishedDragging = false; }, 300); const $currentIcon = $('#rpg-thought-icon'); // Remove dragging class immediately to restore transitions and cursor $currentIcon.removeClass('dragging'); const offset = $currentIcon.offset(); const newPosition = { left: offset.left + 'px', top: offset.top + 'px' }; extensionSettings.thoughtIconPosition = newPosition; saveSettings(); setTimeout(() => { if ($currentIcon.length) { constrainIconToViewport($currentIcon); } }, 10); isDragging = false; // Prevent default and stop all propagation e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); return false; } // If not dragging but touchMoved, do nothing (small drag below threshold) }); } function constrainIconToViewport($icon) { if (!extensionSettings.thoughtIconPosition) return; const offset = $icon.offset(); if (!offset) return; let currentX = offset.left; let currentY = offset.top; const iconWidth = $icon.outerWidth(); const iconHeight = $icon.outerHeight(); const minX = 10; const maxX = window.innerWidth - iconWidth - 10; const minY = 10; const maxY = window.innerHeight - iconHeight - 10; let newX = Math.max(minX, Math.min(maxX, currentX)); let newY = Math.max(minY, Math.min(maxY, currentY)); if (newX !== currentX || newY !== currentY) { $icon.css({ left: newX + 'px', top: newY + 'px', right: 'auto', bottom: 'auto' }); extensionSettings.thoughtIconPosition = { left: newX + 'px', top: newY + 'px' }; saveSettings(); } } /** * Creates or updates the floating thought panel positioned next to the character's avatar. * Handles responsive positioning for left/right panel modes and mobile viewports. * * @param {jQuery} $message - Message element to position the panel relative to * @param {Array} thoughtsArray - Array of thought objects {name, emoji, thought} */ export function createThoughtPanel($message, thoughtsArray) { // Initialize drag handlers once initThoughtIconDragHandlers(); // Remove existing thought panel $('#rpg-thought-panel').remove(); $('#rpg-thought-icon').remove(); // Get the avatar position from the message const $avatar = $message.find('.avatar img'); if (!$avatar.length) { // console.log('[RPG Companion] No avatar found in message'); return; } const avatarRect = $avatar[0].getBoundingClientRect(); const panelPosition = extensionSettings.panelPosition; const theme = extensionSettings.theme; // Build thought bubbles HTML let thoughtsHtml = ''; thoughtsArray.forEach((thought, index) => { thoughtsHtml += `
${thought.emoji}
${thought.thought}
`; // Add divider between thoughts (except for last one) if (index < thoughtsArray.length - 1) { thoughtsHtml += '
'; } }); // Create the floating thought panel with theme const $thoughtPanel = $(`
${thoughtsHtml}
`); // Create the collapsed thought icon const $thoughtIcon = $(`
💭
`); // Apply custom theme colors if custom theme if (theme === 'custom') { const customStyles = { '--rpg-bg': extensionSettings.customColors.bg, '--rpg-accent': extensionSettings.customColors.accent, '--rpg-text': extensionSettings.customColors.text, '--rpg-highlight': extensionSettings.customColors.highlight }; $thoughtPanel.css(customStyles); $thoughtIcon.css(customStyles); } // Append to body so it's not clipped by chat container $('body').append($thoughtPanel); $('body').append($thoughtIcon); // Attach drag handlers to the icon attachDragHandlersToIcon($thoughtIcon); // Simple viewport-based positioning - always top-left corner const margin = 20; const topMargin = 10; // Space between ST's top bar and thought panel // Function to calculate top position based on ST's top bar const getTopPosition = () => { const topBar = $('#top-settings-holder'); if (topBar.length) { return (topBar.outerHeight() || 140) + topMargin; } return 140 + topMargin; // Fallback }; // Function to update bubble position and width const updateBubblePosition = () => { const topPosition = getTopPosition(); const sheld = $('#sheld')[0]; const isRightPanel = extensionSettings.panelPosition === 'right'; // Update top position for panel (always in desktop) $thoughtPanel.css('top', `${topPosition}px`); // Update horizontal position based on panel position // If panel is on right, thoughts on left (default) // If panel is on left, thoughts on right (mirrored) if (isRightPanel) { $thoughtPanel.css({ left: `${margin}px`, right: 'auto' }).removeClass('rpg-thought-panel-right').addClass('rpg-thought-panel-left'); } else { // Panel on left, so thoughts on right $thoughtPanel.css({ left: 'auto', right: `${margin}px` }).removeClass('rpg-thought-panel-left').addClass('rpg-thought-panel-right'); } // Only update icon position if in desktop mode or if no saved position in mobile if (window.innerWidth > 1000) { // Desktop: update icon to match panel position (though it's hidden) if (isRightPanel) { $thoughtIcon.css({ left: `${margin}px`, right: 'auto' }); } else { $thoughtIcon.css({ left: 'auto', right: `${margin}px` }); } } else { // Mobile: only set default if no saved position exists const iconPos = extensionSettings.thoughtIconPosition; if (!iconPos || (!iconPos.top && !iconPos.left)) { // Position icon in the center of the viewport const defaultTop = window.innerHeight / 2; const defaultLeft = window.innerWidth / 2; $thoughtIcon.css({ 'top': `${defaultTop}px`, 'left': `${defaultLeft}px` }); } } // Update width based on available space if (sheld) { const sheldRect = sheld.getBoundingClientRect(); let availableWidth; if (isRightPanel) { availableWidth = sheldRect.left - (margin * 2); } else { // Panel on left, calculate space on right availableWidth = window.innerWidth - sheldRect.right - (margin * 2); } const maxWidth = Math.min(350, Math.max(200, availableWidth)); $thoughtPanel.css('max-width', `${maxWidth}px`); } else { $thoughtPanel.css('max-width', '350px'); } }; // Set initial position and width (will be updated by updateBubblePosition) const isRightPanel = extensionSettings.panelPosition === 'right'; if (isRightPanel) { $thoughtPanel.css({ left: `${margin}px`, right: 'auto' }).addClass('rpg-thought-panel-left'); $thoughtIcon.css({ left: `${margin}px`, right: 'auto' }); } else { $thoughtPanel.css({ left: 'auto', right: `${margin}px` }).addClass('rpg-thought-panel-right'); $thoughtIcon.css({ left: 'auto', right: `${margin}px` }); } updateBubblePosition(); // Update on window resize and when ST's layout changes $(window).on('resize.rpgThoughtBubble', updateBubblePosition); // Desktop: always show panel, hide icon // Mobile: show icon, hide panel initially const isMobileView = window.innerWidth <= 1000; if (isMobileView) { $thoughtPanel.hide(); // Remove force-hide class to let CSS media query show icon $thoughtIcon.removeClass('rpg-force-hide'); // Load saved icon position in mobile, or default to center of viewport if (extensionSettings.thoughtIconPosition && extensionSettings.thoughtIconPosition.top && extensionSettings.thoughtIconPosition.left) { const pos = extensionSettings.thoughtIconPosition; // Validate saved position - check if it's not at the very top (likely invalid) const savedTop = parseInt(pos.top); const topBar = $('#top-settings-holder'); const topBarHeight = topBar.length ? topBar.outerHeight() : 60; // If saved position is above or too close to top bar, recalculate default if (savedTop < topBarHeight + 50) { // console.log('[Thought Icon] Saved position invalid (too close to top), recalculating default'); // Clear invalid saved position delete extensionSettings.thoughtIconPosition; saveSettings(); // Calculate new default position setTimeout(() => { const viewportHeight = window.innerHeight; const viewportWidth = window.innerWidth; const defaultTop = topBarHeight + ((viewportHeight - topBarHeight) / 2) - 22; const defaultLeft = (viewportWidth * 0.75) - 22; // console.log('[Thought Icon] Setting new default position:', { // topBarHeight, // viewportHeight, // viewportWidth, // calculatedTop: defaultTop, // calculatedLeft: defaultLeft // }); $thoughtIcon.css({ top: `${defaultTop}px`, left: `${defaultLeft}px`, transform: 'none', right: 'auto', bottom: 'auto' }); }, 100); } else { // Position is valid, use it $thoughtIcon.css({ top: pos.top, left: pos.left, transform: 'none', right: 'auto', bottom: 'auto' }); } } else { // Default position: center-right of viewport, accounting for top bar // Use setTimeout to ensure DOM is fully rendered before calculating positions setTimeout(() => { const topBar = $('#top-settings-holder'); const topBarHeight = topBar.length ? topBar.outerHeight() : 60; const viewportHeight = window.innerHeight; const viewportWidth = window.innerWidth; // Position in the center vertically (accounting for top bar) and slightly right const defaultTop = topBarHeight + ((viewportHeight - topBarHeight) / 2) - 22; // 22 = half of icon height const defaultLeft = (viewportWidth * 0.75) - 22; // 75% from left, minus half icon width // console.log('[Thought Icon] Setting default position:', { // topBarHeight, // viewportHeight, // viewportWidth, // calculatedTop: defaultTop, // calculatedLeft: defaultLeft // }); $thoughtIcon.css({ top: `${defaultTop}px`, left: `${defaultLeft}px`, transform: 'none', right: 'auto', bottom: 'auto' }); }, 100); } } else { // Desktop: always start with panel expanded on page load/refresh $thoughtPanel.css('display', 'block'); $thoughtIcon.addClass('rpg-force-hide').removeClass('rpg-collapsed-desktop'); } // Handle viewport changes between mobile and desktop let wasMobileView = window.innerWidth <= 1000; $(window).on('resize.thoughtIconDrag', () => { const isMobileNow = window.innerWidth <= 1000; if (!wasMobileView && isMobileNow) { // Switched to mobile - apply saved position if exists const $currentIcon = $('#rpg-thought-icon'); if (extensionSettings.thoughtIconPosition) { const pos = extensionSettings.thoughtIconPosition; if (pos.top) $currentIcon.css('top', pos.top); if (pos.left) $currentIcon.css('left', pos.left); } } // Constrain icon if in mobile view if (isMobileNow) { setTimeout(() => { const $currentIcon = $('#rpg-thought-icon'); if ($currentIcon.length) { constrainIconToViewport($currentIcon); } }, 10); } wasMobileView = isMobileNow; }); // Close button functionality - support both click and touch $thoughtPanel.find('.rpg-thought-close').on('click touchend', function(e) { e.preventDefault(); e.stopPropagation(); const isMobileView = window.innerWidth <= 1000; if (isMobileView) { // Mobile: hide panel and show icon $thoughtPanel.fadeOut(200, function() { // Make sure icon is visible and clean state when panel closes (use selector, not variable) const $icon = $('#rpg-thought-icon'); $icon.removeClass('rpg-hidden dragging'); $icon.data('just-dragged', false); }); } else { // Desktop: collapse to icon at panel position const panelRect = $thoughtPanel[0].getBoundingClientRect(); const $icon = $('#rpg-thought-icon'); // Position icon where the panel is $icon.css({ top: `${panelRect.top}px`, left: isRightPanel ? `${panelRect.left}px` : 'auto', right: isRightPanel ? 'auto' : `${window.innerWidth - panelRect.right}px` }); // Mark as collapsed desktop state (session only, not persisted) $icon.addClass('rpg-collapsed-desktop'); // Hide panel and show icon $thoughtPanel.fadeOut(200, function() { $icon.removeClass('rpg-hidden rpg-force-hide'); }); } }); // Icon click/tap to show panel const handleThoughtIconTap = function(e) { const isMobileView = window.innerWidth <= 1000; const $icon = $('#rpg-thought-icon'); // Desktop collapsed state: expand panel and hide icon if (!isMobileView && $icon.hasClass('rpg-collapsed-desktop')) { e.preventDefault(); e.stopPropagation(); // Remove collapsed state (no need to save, state is session-only) $icon.addClass('rpg-force-hide').removeClass('rpg-collapsed-desktop'); // Show panel $('#rpg-thought-panel').fadeIn(200); return; } // Skip if we just finished dragging (mobile only) if ($thoughtIcon.data('just-dragged')) { return; } e.preventDefault(); e.stopPropagation(); // In mobile view, position panel below ST's top bar and fit full screen if (window.innerWidth <= 1000) { const topBar = $('#top-settings-holder'); const topBarHeight = topBar.length ? topBar.outerHeight() : 60; const topPosition = topBarHeight + 10; // 10px margin below top bar $thoughtPanel.css({ top: topPosition + 'px', display: 'none' // Keep hidden while setting position }); } $thoughtIcon.addClass('rpg-hidden'); $thoughtPanel.fadeIn(200); }; // Support both click and touch events for mobile $thoughtIcon.on('click touchend', handleThoughtIconTap); // Add event handlers for editable thoughts in the bubble $thoughtPanel.find('.rpg-editable').on('blur', function() { const character = $(this).data('character'); const field = $(this).data('field'); const value = $(this).text().trim(); // console.log('[RPG Companion] 💭 Thought bubble blur event - character:', character, 'field:', field, 'value:', value); updateCharacterField(character, field, value); }); // Add event listener for section lock icon clicks (support both click and touch) $thoughtPanel.find('.rpg-section-lock-icon').on('click touchend', function(e) { e.preventDefault(); e.stopPropagation(); const $icon = $(this); const trackerType = $icon.data('tracker'); const itemPath = $icon.data('path'); const currentlyLocked = isItemLocked(trackerType, itemPath); // Toggle lock state setItemLock(trackerType, itemPath, !currentlyLocked); // Update icon const newIcon = !currentlyLocked ? '🔒' : '🔓'; const newTitle = !currentlyLocked ? 'Locked' : 'Unlocked'; $icon.text(newIcon); $icon.attr('title', newTitle); // Toggle 'locked' class for persistent visibility $icon.toggleClass('locked', !currentlyLocked); // Save settings saveSettings(); }); // RAF throttling for smooth position updates let positionUpdateRaf = null; // Update position on scroll with RAF throttling - DISABLED for fixed top-left positioning const updatePanelPosition = () => { // Bubble is now fixed at top-left, no need to update position on scroll // Just check visibility if (!$message.is(':visible')) { $thoughtPanel.hide(); $thoughtIcon.hide(); } else { if ($thoughtPanel.is(':visible')) { $thoughtPanel.show(); } if ($thoughtIcon.is(':visible')) { $thoughtIcon.show(); } } }; // Update visibility on scroll (but not position) $('#chat').on('scroll.thoughtPanel', updatePanelPosition); // Don't listen to window resize for position - we handle width separately // Position stays fixed at top-left // Remove panel when clicking outside (mobile only) $(document).on('click.thoughtPanel', function(e) { // Only hide on click outside in mobile view if (window.innerWidth <= 1000) { if (!$(e.target).closest('#rpg-thought-panel, #rpg-thought-icon').length) { // Hide the panel and show the icon instead of removing (use selectors, not variables) $('#rpg-thought-panel').fadeOut(200); $('#rpg-thought-icon').removeClass('rpg-hidden').fadeIn(200); } } }); }