1318 lines
55 KiB
JavaScript
1318 lines
55 KiB
JavaScript
/**
|
||
* 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 } from '../../core/persistence.js';
|
||
import { getSafeThumbnailUrl } from '../../utils/avatars.js';
|
||
|
||
/**
|
||
* 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);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Escapes HTML attribute values to prevent quotes from breaking HTML
|
||
*/
|
||
function escapeHtmlAttr(str) {
|
||
if (!str) return '';
|
||
return String(str).replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
/**
|
||
* 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)}`;
|
||
}
|
||
|
||
/**
|
||
* 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 quotes from both names and match
|
||
// This allows "Dottore (Prime)" to match "Dottore" card for avatar lookup
|
||
// and "Marianna "Mari"" to match "Marianna" or "Mari" cards
|
||
const stripParensAndQuotes = (s) => s.replace(/\s*\([^)]*\)/g, '').replace(/["']/g, '').trim();
|
||
const cardCore = stripParensAndQuotes(cardName).toLowerCase();
|
||
const aiCore = stripParensAndQuotes(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);
|
||
}
|
||
|
||
/**
|
||
* Gets relationship emoji from relationship string
|
||
* Returns a default emoji (⚖️) if relationship is not in the predefined map
|
||
*/
|
||
function getRelationshipEmoji(relationship) {
|
||
if (!relationship) return null;
|
||
const map = {
|
||
'Enemy': '⚔️',
|
||
'Neutral': '⚖️',
|
||
'Friend': '⭐',
|
||
'Lover': '❤️',
|
||
'Ally': '🤝',
|
||
'Rival': '🎯',
|
||
'Family': '👨👩👧',
|
||
'Stranger': '❓'
|
||
};
|
||
// Return mapped emoji or default '⚖️' for unknown relationships
|
||
return map[relationship] || '⚖️';
|
||
}
|
||
|
||
/**
|
||
* Gets character avatar URL
|
||
*/
|
||
function getCharacterAvatarUrl(characterName) {
|
||
// Try to find matching character from SillyTavern
|
||
try {
|
||
const context = getContext();
|
||
if (context && characters) {
|
||
const char = characters.find(c => namesMatch(c.name, characterName));
|
||
if (char && char.avatar) {
|
||
return getSafeThumbnailUrl('avatar', char.avatar);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
debugLog('[RPG Thoughts] Error getting avatar:', e);
|
||
}
|
||
return FALLBACK_AVATAR_DATA_URI;
|
||
}
|
||
|
||
export function renderThoughts() {
|
||
if (!extensionSettings.showCharacterThoughts || !$thoughtsContainer) {
|
||
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;
|
||
|
||
// Convert structured character data to text format for the original fancy renderer
|
||
let characterThoughtsData = lastGeneratedData.characterThoughts || committedTrackerData.characterThoughts || '';
|
||
|
||
// If we have structured data, convert it to text format
|
||
if (extensionSettings.charactersData && Array.isArray(extensionSettings.charactersData) && extensionSettings.charactersData.length > 0) {
|
||
const lines = [];
|
||
for (const char of extensionSettings.charactersData) {
|
||
// Character name line
|
||
lines.push(`- ${char.name || 'Unknown'}`);
|
||
|
||
// Details line with emoji and fields
|
||
const details = [char.emoji || '😶'];
|
||
const charFields = char.fields || {};
|
||
for (const [key, value] of Object.entries(charFields)) {
|
||
if (value) details.push(`${key}: ${value}`);
|
||
}
|
||
lines.push(`Details: ${details.join(' | ')}`);
|
||
|
||
// Relationship line
|
||
if (char.relationship) {
|
||
lines.push(`Relationship: ${char.relationship}`);
|
||
}
|
||
|
||
// Stats line
|
||
const charStats = char.stats || {};
|
||
if (Object.keys(charStats).length > 0) {
|
||
const statsStr = Object.entries(charStats).map(([k, v]) => `${k}: ${v}%`).join(' | ');
|
||
lines.push(`Stats: ${statsStr}`);
|
||
}
|
||
|
||
// Thoughts line
|
||
if (char.thoughts) {
|
||
const thoughtsFieldName = config?.thoughts?.name || 'Thoughts';
|
||
lines.push(`${thoughtsFieldName}: ${char.thoughts}`);
|
||
}
|
||
}
|
||
if (lines.length > 0) {
|
||
characterThoughtsData = lines.join('\n');
|
||
debugLog('[RPG Thoughts] Converted structured data to text format');
|
||
}
|
||
}
|
||
|
||
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));
|
||
|
||
const lines = characterThoughtsData.split('\n');
|
||
const presentCharacters = [];
|
||
|
||
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;
|
||
|
||
// Pre-process: normalize the format to handle cases where "- char" appears mid-line
|
||
// This handles: "Thoughts: ... - char 2" by splitting it into separate lines
|
||
const normalizedLines = [];
|
||
for (let line of lines) {
|
||
// Check if line contains "- [name]" pattern after some content (not at start)
|
||
// Match pattern like "some text - CharName" where there's content before the dash
|
||
const midLineCharMatch = line.match(/^(.+?)\s+-\s+([A-Z][a-zA-Z\s]+)$/);
|
||
if (midLineCharMatch && !line.trim().startsWith('- ')) {
|
||
// Split: first part stays as one line, "- Name" becomes new line
|
||
normalizedLines.push(midLineCharMatch[1].trim());
|
||
normalizedLines.push('- ' + midLineCharMatch[2].trim());
|
||
} else {
|
||
normalizedLines.push(line);
|
||
}
|
||
}
|
||
|
||
for (const line of normalizedLines) {
|
||
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)`);
|
||
}
|
||
}
|
||
|
||
// 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';
|
||
}
|
||
|
||
const escapedDefaultName = escapeHtmlAttr(defaultName);
|
||
|
||
html += '<div class="rpg-thoughts-content">';
|
||
html += `
|
||
<div class="rpg-character-card" data-character-name="${escapedDefaultName}">
|
||
<div class="rpg-character-avatar">
|
||
<img src="${defaultPortrait}" alt="${escapedDefaultName}" onerror="this.style.opacity='0.5';this.onerror=null;" />
|
||
<div class="rpg-relationship-badge rpg-editable" contenteditable="true" data-character="${escapedDefaultName}" data-field="relationship" title="Click to edit (use emoji: ⚔️ ⚖️ ⭐ ❤️)">⚖️</div>
|
||
</div>
|
||
<div class="rpg-character-info">
|
||
<div class="rpg-character-header">
|
||
<span class="rpg-character-emoji rpg-editable" contenteditable="true" data-character="${escapedDefaultName}" data-field="emoji" title="Click to edit emoji">😊</span>
|
||
<span class="rpg-character-name rpg-editable" contenteditable="true" data-character="${escapedDefaultName}" data-field="name" title="Click to edit name">${defaultName}</span>
|
||
</div>
|
||
`;
|
||
|
||
// Add custom fields dynamically
|
||
for (const field of enabledFields) {
|
||
const fieldId = field.name.toLowerCase().replace(/\s+/g, '-');
|
||
html += `
|
||
<div class="rpg-character-field rpg-character-${fieldId} rpg-editable" contenteditable="true" data-character="${escapedDefaultName}" data-field="${escapeHtmlAttr(field.name)}" title="Click to edit ${field.name}"></div>
|
||
`;
|
||
}
|
||
|
||
html += `
|
||
</div>
|
||
</div>
|
||
`;
|
||
html += '</div>';
|
||
} else {
|
||
html += '<div class="rpg-thoughts-content">';
|
||
|
||
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}`);
|
||
|
||
// For group chats, search through group members first
|
||
if (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 emoji
|
||
let relationshipText = 'Neutral'; // Default text for tooltip
|
||
let relationshipFieldName = 'Relationship';
|
||
|
||
if (hasRelationshipEnabled) {
|
||
// In the new format, relationship is always stored in char.Relationship
|
||
if (char.Relationship) {
|
||
relationshipText = char.Relationship;
|
||
// Try to map text to emoji, fall back to default link emoji for unknown types
|
||
relationshipBadge = relationshipEmojis[char.Relationship] || '⚖️';
|
||
}
|
||
}
|
||
|
||
debugLog(`[RPG Thoughts] Building HTML card for ${char.name}...`);
|
||
|
||
// Escape character name for use in HTML attributes
|
||
const escapedName = escapeHtmlAttr(char.name);
|
||
|
||
html += `
|
||
<div class="rpg-character-card" data-character-name="${escapedName}">
|
||
<div class="rpg-character-avatar">
|
||
<img src="${characterPortrait}" alt="${escapedName}" onerror="this.style.opacity='0.5';this.onerror=null;" />
|
||
${hasRelationshipEnabled ? `<div class="rpg-relationship-badge rpg-editable" contenteditable="true" data-character="${escapedName}" data-field="${relationshipFieldName}" title="${escapeHtmlAttr(relationshipText)}">${relationshipBadge}</div>` : ''}
|
||
</div>
|
||
<div class="rpg-character-content">
|
||
<div class="rpg-character-info">
|
||
<div class="rpg-character-header">
|
||
<span class="rpg-character-emoji rpg-editable" contenteditable="true" data-character="${escapedName}" data-field="emoji" title="Click to edit emoji">${char.emoji}</span>
|
||
<span class="rpg-character-name rpg-editable" contenteditable="true" data-character="${escapedName}" data-field="name" title="Click to edit name">${char.name}</span>
|
||
<button class="rpg-character-remove" data-character="${escapedName}" title="Remove character">×</button>
|
||
</div>
|
||
`;
|
||
|
||
// Render custom fields dynamically
|
||
for (const field of enabledFields) {
|
||
const fieldValue = char[field.name] || '';
|
||
const fieldId = field.name.toLowerCase().replace(/\s+/g, '-');
|
||
html += `
|
||
<div class="rpg-character-field rpg-character-${fieldId} rpg-editable" contenteditable="true" data-character="${escapedName}" data-field="${escapeHtmlAttr(field.name)}" title="Click to edit ${field.name}">${fieldValue}</div>
|
||
`;
|
||
}
|
||
|
||
html += `
|
||
</div>
|
||
`;
|
||
|
||
// Render character stats if enabled (outside rpg-character-info)
|
||
if (enabledCharStats.length > 0) {
|
||
html += `<div class="rpg-character-stats"><div class="rpg-character-stats-inner">`;
|
||
for (const stat of enabledCharStats) {
|
||
const statValue = char[stat.name] || 0;
|
||
const statColor = getStatColor(statValue, extensionSettings.statBarColorLow, extensionSettings.statBarColorHigh);
|
||
html += `
|
||
<div class="rpg-character-stat">
|
||
<span class="rpg-stat-name">${stat.name}: </span><span class="rpg-editable" contenteditable="true" data-character="${escapedName}" data-field="${escapeHtmlAttr(stat.name)}" style="color: ${statColor}" title="Click to edit ${stat.name}">${statValue}%</span>
|
||
</div>
|
||
`;
|
||
}
|
||
html += `</div></div>`;
|
||
}
|
||
|
||
html += `
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
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 += '</div>';
|
||
}
|
||
|
||
$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);
|
||
});
|
||
|
||
// Add event handlers for remove character buttons
|
||
$thoughtsContainer.find('.rpg-character-remove').on('click', function(e) {
|
||
e.stopPropagation();
|
||
const characterName = $(this).data('character');
|
||
if (characterName && confirm(`Remove ${characterName} from present characters?`)) {
|
||
removeCharacter(characterName);
|
||
}
|
||
});
|
||
|
||
// 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 lines = lastGeneratedData.characterThoughts.split('\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) || [];
|
||
|
||
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();
|
||
renderThoughts();
|
||
|
||
const thoughtsFieldName = presentCharsConfig?.thoughts?.name || 'Thoughts';
|
||
if (field === thoughtsFieldName) {
|
||
setTimeout(() => updateChatThoughts(), 100);
|
||
} else {
|
||
updateChatThoughts();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Removes a character from Present Characters data and re-renders.
|
||
* Works with both structured (charactersData) and text (characterThoughts) formats.
|
||
*
|
||
* @param {string} characterName - Name of the character to remove
|
||
*/
|
||
export function removeCharacter(characterName) {
|
||
console.log('[RPG Companion] Removing character:', characterName);
|
||
|
||
// Remove from structured data if it exists
|
||
if (extensionSettings.charactersData && Array.isArray(extensionSettings.charactersData)) {
|
||
const initialLength = extensionSettings.charactersData.length;
|
||
extensionSettings.charactersData = extensionSettings.charactersData.filter(
|
||
char => char.name && char.name.toLowerCase() !== characterName.toLowerCase()
|
||
);
|
||
if (extensionSettings.charactersData.length < initialLength) {
|
||
console.log('[RPG Companion] Removed character from structured data');
|
||
}
|
||
}
|
||
|
||
// Remove from text format
|
||
if (!lastGeneratedData.characterThoughts) {
|
||
console.log('[RPG Companion] No characterThoughts data to remove from');
|
||
return;
|
||
}
|
||
|
||
const lines = lastGeneratedData.characterThoughts.split('\n');
|
||
const presentCharsConfig = extensionSettings.trackerConfig?.presentCharacters;
|
||
|
||
let characterFound = false;
|
||
let inTargetCharacter = false;
|
||
let characterStartIndex = -1;
|
||
let characterEndIndex = -1;
|
||
const linesToRemove = [];
|
||
|
||
// 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;
|
||
linesToRemove.push(i);
|
||
} else if (inTargetCharacter) {
|
||
characterEndIndex = i;
|
||
break;
|
||
}
|
||
} else if (inTargetCharacter) {
|
||
// Include all lines until the next character or end of file
|
||
linesToRemove.push(i);
|
||
// Check if this is a character name line (next character)
|
||
if (line.startsWith('- ')) {
|
||
characterEndIndex = i;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (characterFound && characterEndIndex === -1) {
|
||
characterEndIndex = lines.length;
|
||
}
|
||
|
||
if (characterFound && linesToRemove.length > 0) {
|
||
// Remove lines in reverse order to maintain indices
|
||
for (let i = linesToRemove.length - 1; i >= 0; i--) {
|
||
lines.splice(linesToRemove[i], 1);
|
||
}
|
||
|
||
// Clean up any trailing empty lines after removal
|
||
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
|
||
lines.pop();
|
||
}
|
||
|
||
lastGeneratedData.characterThoughts = lines.join('\n');
|
||
committedTrackerData.characterThoughts = lines.join('\n');
|
||
|
||
console.log('[RPG Companion] Removed character from text format');
|
||
|
||
// Update chat swipe data
|
||
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();
|
||
renderThoughts();
|
||
updateChatThoughts();
|
||
} else {
|
||
console.log('[RPG Companion] Character not found in text format:', characterName);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
const lines = lastGeneratedData.characterThoughts.split('\n');
|
||
const thoughtsArray = []; // Array of {name, emoji, thought}
|
||
const thoughtsConfig = extensionSettings.trackerConfig?.presentCharacters?.thoughts;
|
||
const thoughtsLabel = thoughtsConfig?.name || 'Thoughts';
|
||
|
||
// 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
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* 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) {
|
||
// 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) => {
|
||
const escapedThoughtName = escapeHtmlAttr(thought.name);
|
||
thoughtsHtml += `
|
||
<div class="rpg-thought-item">
|
||
<div class="rpg-thought-emoji-box">
|
||
${thought.emoji}
|
||
</div>
|
||
<div class="rpg-thought-content rpg-editable" contenteditable="true" data-character="${escapedThoughtName}" data-field="thoughts" title="Click to edit thoughts">
|
||
${thought.thought}
|
||
</div>
|
||
</div>
|
||
`;
|
||
// Add divider between thoughts (except for last one)
|
||
if (index < thoughtsArray.length - 1) {
|
||
thoughtsHtml += '<div class="rpg-thought-divider"></div>';
|
||
}
|
||
});
|
||
|
||
// Create the floating thought panel with theme
|
||
const $thoughtPanel = $(`
|
||
<div id="rpg-thought-panel" class="rpg-thought-panel" data-theme="${theme}">
|
||
<button class="rpg-thought-close" title="Hide thoughts">×</button>
|
||
<div class="rpg-thought-circles">
|
||
<div class="rpg-thought-circle rpg-circle-1"></div>
|
||
<div class="rpg-thought-circle rpg-circle-2"></div>
|
||
<div class="rpg-thought-circle rpg-circle-3"></div>
|
||
</div>
|
||
<div class="rpg-thought-bubble">
|
||
${thoughtsHtml}
|
||
</div>
|
||
</div>
|
||
`);
|
||
|
||
// Create the collapsed thought icon
|
||
const $thoughtIcon = $(`
|
||
<div id="rpg-thought-icon" class="rpg-thought-icon" data-theme="${theme}" title="Show thoughts">
|
||
💭
|
||
</div>
|
||
`);
|
||
|
||
// 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);
|
||
}
|
||
|
||
// Force a consistent width for the bubble to ensure proper positioning
|
||
$thoughtPanel.css('width', '350px');
|
||
|
||
// Append to body so it's not clipped by chat container
|
||
$('body').append($thoughtPanel);
|
||
$('body').append($thoughtIcon); // Position the panel next to the avatar
|
||
const panelWidth = 350;
|
||
const panelMargin = 20;
|
||
|
||
let top = avatarRect.top + (avatarRect.height / 2);
|
||
let left;
|
||
let right;
|
||
let useRightPosition = false;
|
||
let iconTop = avatarRect.top;
|
||
let iconLeft;
|
||
|
||
// Detect mobile viewport (matches CSS breakpoint)
|
||
const isMobile = window.innerWidth <= 1000;
|
||
|
||
if (isMobile) {
|
||
// On mobile: position icon horizontally centered on avatar
|
||
// The CSS transform will shift it upward by 60px
|
||
iconTop = avatarRect.top; // Start at avatar top (CSS will move it up)
|
||
iconLeft = avatarRect.left + (avatarRect.width / 2) - 18; // Centered horizontally (18px = half of 36px icon width)
|
||
|
||
// Center the thought panel horizontally on mobile
|
||
left = window.innerWidth / 2 - panelWidth / 2;
|
||
top = avatarRect.top + avatarRect.height + 60; // Position below icon with spacing
|
||
|
||
// No side-specific classes on mobile
|
||
$thoughtPanel.removeClass('rpg-thought-panel-left rpg-thought-panel-right');
|
||
$thoughtIcon.removeClass('rpg-thought-icon-left rpg-thought-icon-right');
|
||
|
||
console.log('[RPG Companion] Mobile thought icon positioning:', {
|
||
isMobile,
|
||
windowWidth: window.innerWidth,
|
||
avatarLeft: avatarRect.left,
|
||
avatarWidth: avatarRect.width,
|
||
iconLeft,
|
||
iconTop
|
||
});
|
||
} else if (panelPosition === 'left') {
|
||
// Main panel is on left, so thought bubble goes to RIGHT side
|
||
// Mirror the left side positioning: bubble should be same distance from avatar
|
||
// but on the opposite side, extending to the right
|
||
const chatContainer = $('#chat')[0];
|
||
const chatRect = chatContainer ? chatContainer.getBoundingClientRect() : { right: window.innerWidth };
|
||
|
||
// Position bubble starting from chat edge, extending right
|
||
left = chatRect.right + panelMargin; // Start at chat's right edge + margin
|
||
useRightPosition = false; // Use left positioning so it extends right
|
||
iconLeft = chatRect.right + 10; // Icon just at the chat edge
|
||
$thoughtPanel.addClass('rpg-thought-panel-right');
|
||
$thoughtIcon.addClass('rpg-thought-icon-right');
|
||
|
||
// Position circles to flow from left (toward chat/avatar) to right (toward panel)
|
||
$thoughtPanel.find('.rpg-thought-circles').css({
|
||
top: 'calc(50% - 50px)',
|
||
left: '-25px',
|
||
bottom: 'auto',
|
||
right: 'auto'
|
||
});
|
||
// Mirror the circle flow for right side (left-to-right)
|
||
$thoughtPanel.find('.rpg-thought-circles').css('align-items', 'flex-start');
|
||
$thoughtPanel.find('.rpg-circle-1').css({ 'align-self': 'flex-start', 'margin-right': '0', 'margin-left': '0' });
|
||
$thoughtPanel.find('.rpg-circle-2').css({ 'align-self': 'flex-start', 'margin-right': '0', 'margin-left': '4px' });
|
||
$thoughtPanel.find('.rpg-circle-3').css({ 'align-self': 'flex-start', 'margin-right': '0', 'margin-left': '8px' });
|
||
} else {
|
||
// Main panel is on right, so thought bubble goes on left (near avatar)
|
||
left = avatarRect.left - panelWidth - panelMargin;
|
||
iconLeft = avatarRect.left - 40;
|
||
$thoughtPanel.addClass('rpg-thought-panel-left');
|
||
$thoughtIcon.addClass('rpg-thought-icon-left');
|
||
|
||
// Position circles to flow from avatar (left) to bubble (more left)
|
||
// Circles should flow right-to-left when bubble is on left
|
||
$thoughtPanel.find('.rpg-thought-circles').css({
|
||
top: 'calc(50% - 50px)',
|
||
right: '-25px',
|
||
bottom: 'auto',
|
||
left: 'auto'
|
||
});
|
||
// Keep the circle flow for left side (right-to-left) - default from CSS
|
||
$thoughtPanel.find('.rpg-thought-circles').css('align-items', 'flex-end');
|
||
$thoughtPanel.find('.rpg-circle-1').css({ 'align-self': 'flex-end', 'margin-left': '0', 'margin-right': '0' });
|
||
$thoughtPanel.find('.rpg-circle-2').css({ 'align-self': 'flex-end', 'margin-left': '0', 'margin-right': '4px' });
|
||
$thoughtPanel.find('.rpg-circle-3').css({ 'align-self': 'flex-end', 'margin-left': '0', 'margin-right': '8px' });
|
||
}
|
||
|
||
if (useRightPosition) {
|
||
$thoughtPanel.css({
|
||
top: `${top}px`,
|
||
right: `${right}px`,
|
||
left: 'auto' // Clear left positioning
|
||
});
|
||
} else {
|
||
$thoughtPanel.css({
|
||
top: `${top}px`,
|
||
left: `${left}px`,
|
||
right: 'auto' // Clear right positioning
|
||
});
|
||
}
|
||
|
||
$thoughtIcon.css({
|
||
top: `${iconTop}px`,
|
||
left: `${iconLeft}px`,
|
||
right: 'auto' // Clear any right positioning
|
||
});
|
||
|
||
// Check if always show bubble is enabled
|
||
if (extensionSettings.alwaysShowThoughtBubble) {
|
||
// Always show panel expanded, hide both close button and icon
|
||
$thoughtPanel.show();
|
||
$thoughtPanel.find('.rpg-thought-close').hide();
|
||
$thoughtIcon.hide();
|
||
} else {
|
||
// Initially hide the panel and show the icon
|
||
$thoughtPanel.hide();
|
||
$thoughtIcon.show();
|
||
|
||
// Close button functionality - only when always show is disabled
|
||
$thoughtPanel.find('.rpg-thought-close').on('click', function(e) {
|
||
e.stopPropagation();
|
||
$thoughtPanel.fadeOut(200);
|
||
$thoughtIcon.fadeIn(200);
|
||
});
|
||
|
||
// Icon click to show panel - only when always show is disabled
|
||
$thoughtIcon.on('click', function(e) {
|
||
e.stopPropagation();
|
||
$thoughtIcon.fadeOut(200);
|
||
$thoughtPanel.fadeIn(200);
|
||
});
|
||
}
|
||
|
||
// console.log('[RPG Companion] Thought panel created at:', { top, left });
|
||
|
||
// 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);
|
||
});
|
||
|
||
// RAF throttling for smooth position updates
|
||
let positionUpdateRaf = null;
|
||
|
||
// Update position on scroll with RAF throttling
|
||
const updatePanelPosition = () => {
|
||
if (!$message.is(':visible')) {
|
||
$thoughtPanel.hide();
|
||
$thoughtIcon.hide();
|
||
return;
|
||
}
|
||
|
||
// Cancel any pending RAF
|
||
if (positionUpdateRaf) {
|
||
cancelAnimationFrame(positionUpdateRaf);
|
||
}
|
||
|
||
// Schedule update on next frame
|
||
positionUpdateRaf = requestAnimationFrame(() => {
|
||
const newAvatarRect = $avatar[0].getBoundingClientRect();
|
||
const newTop = newAvatarRect.top + (newAvatarRect.height / 2);
|
||
const newIconTop = newAvatarRect.top;
|
||
let newLeft, newIconLeft;
|
||
|
||
if (panelPosition === 'left') {
|
||
// Position at chat's right edge, extending right
|
||
const chatContainer = $('#chat')[0];
|
||
const chatRect = chatContainer ? chatContainer.getBoundingClientRect() : { right: window.innerWidth };
|
||
newLeft = chatRect.right + panelMargin;
|
||
newIconLeft = chatRect.right + 10;
|
||
|
||
$thoughtPanel.css({
|
||
top: `${newTop}px`,
|
||
left: `${newLeft}px`,
|
||
right: 'auto'
|
||
});
|
||
} else {
|
||
// Left position relative to avatar
|
||
newLeft = newAvatarRect.left - panelWidth - panelMargin;
|
||
newIconLeft = newAvatarRect.left - 40;
|
||
|
||
$thoughtPanel.css({
|
||
top: `${newTop}px`,
|
||
left: `${newLeft}px`,
|
||
right: 'auto'
|
||
});
|
||
}
|
||
|
||
$thoughtIcon.css({
|
||
top: `${newIconTop}px`,
|
||
left: `${newIconLeft}px`,
|
||
right: 'auto'
|
||
});
|
||
|
||
if ($thoughtPanel.is(':visible')) {
|
||
$thoughtPanel.show();
|
||
}
|
||
if ($thoughtIcon.is(':visible')) {
|
||
$thoughtIcon.show();
|
||
}
|
||
|
||
positionUpdateRaf = null;
|
||
});
|
||
};
|
||
|
||
// Update position on scroll and resize
|
||
$('#chat').on('scroll.thoughtPanel', updatePanelPosition);
|
||
$(window).on('resize.thoughtPanel', updatePanelPosition);
|
||
|
||
// Remove panel when clicking outside - only if always show is disabled
|
||
if (!extensionSettings.alwaysShowThoughtBubble) {
|
||
$(document).on('click.thoughtPanel', function(e) {
|
||
if (!$(e.target).closest('#rpg-thought-panel, #rpg-thought-icon').length) {
|
||
// Hide the panel and show the icon instead of removing
|
||
$thoughtPanel.fadeOut(200);
|
||
$thoughtIcon.fadeIn(200);
|
||
}
|
||
});
|
||
}
|
||
}
|