refactor: extract rendering systems

Extract rendering logic from index.js into modular system:
- src/utils/avatars.js: Safe thumbnail URL generation with error handling
- src/systems/rendering/userStats.js: User stats panel with progress bars and classic RPG stats
- src/systems/rendering/infoBox.js: Info box dashboard with weather, date, time, and location widgets
- src/systems/rendering/thoughts.js: Character thoughts panel and floating chat bubbles

Reduces index.js from 3,829 to 2,430 lines (-1,399 lines, -36.5%)
All rendering functions now properly modularized with full JSDoc documentation
Event listeners preserved in render functions for interactive fields
This commit is contained in:
Lucas 'Paperboy' Rose-Winters
2025-10-17 11:16:29 +11:00
parent 17736d9140
commit d2d5593e00
5 changed files with 1501 additions and 1415 deletions
+745
View File
@@ -0,0 +1,745 @@
/**
* 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,
$thoughtsContainer,
FALLBACK_AVATAR_DATA_URI
} from '../../core/state.js';
import { saveChatData } from '../../core/persistence.js';
import { getSafeThumbnailUrl } from '../../utils/avatars.js';
/**
* 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;
}
// Add updating class for animation
if (extensionSettings.enableAnimations) {
$thoughtsContainer.addClass('rpg-content-updating');
}
// Initialize if no data yet
if (!lastGeneratedData.characterThoughts) {
lastGeneratedData.characterThoughts = '';
}
const lines = lastGeneratedData.characterThoughts.split('\n');
const presentCharacters = [];
// console.log('[RPG Companion] Raw Present Characters:', lastGeneratedData.characterThoughts);
// console.log('[RPG Companion] Split into lines:', lines);
// Parse format: [Emoji]: [Name, Status, Demeanor] | [Relationship] | [Thoughts]
for (const line of lines) {
// Skip empty lines, headers, dividers, and code fences
if (line.trim() &&
!line.includes('Present Characters') &&
!line.includes('---') &&
!line.trim().startsWith('```')) {
// Match the new format with pipes
const parts = line.split('|').map(p => p.trim());
if (parts.length >= 2) {
// First part: [Emoji]: [Name, Status, Demeanor]
const firstPart = parts[0].trim();
const emojiMatch = firstPart.match(/^(.+?):\s*(.+)$/);
if (emojiMatch) {
const emoji = emojiMatch[1].trim();
const info = emojiMatch[2].trim();
const relationship = parts[1].trim(); // Enemy/Neutral/Friend/Lover
const thoughts = parts[2] ? parts[2].trim() : '';
// Parse name from info (first part before comma)
const infoParts = info.split(',').map(p => p.trim());
const name = infoParts[0] || '';
const traits = infoParts.slice(1).join(', ');
if (name && name.toLowerCase() !== 'unavailable') {
presentCharacters.push({ emoji, name, traits, relationship, thoughts });
// console.log('[RPG Companion] Parsed character:', { name, relationship });
}
}
}
}
}
// Relationship status to emoji mapping
const relationshipEmojis = {
'Enemy': '⚔️',
'Neutral': '⚖️',
'Friend': '⭐',
'Lover': '❤️'
};
// Build HTML
let html = '';
// console.log('[RPG Companion] Total characters parsed:', presentCharacters.length);
// console.log('[RPG Companion] Characters array:', presentCharacters);
// If no characters parsed, show a placeholder editable card
if (presentCharacters.length === 0) {
// Get default character portrait (try to use the current character if in 1-on-1 chat)
// Use a base64-encoded SVG placeholder as fallback to avoid 400 errors
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 += '<div class="rpg-thoughts-content">';
html += `
<div class="rpg-character-card" data-character-name="${defaultName}">
<div class="rpg-character-avatar">
<img src="${defaultPortrait}" alt="${defaultName}" onerror="this.style.opacity='0.5';this.onerror=null;" />
<div class="rpg-relationship-badge rpg-editable" contenteditable="true" data-character="${defaultName}" 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="${defaultName}" data-field="emoji" title="Click to edit emoji">😊</span>
<span class="rpg-character-name rpg-editable" contenteditable="true" data-character="${defaultName}" data-field="name" title="Click to edit name">${defaultName}</span>
</div>
<div class="rpg-character-traits rpg-editable" contenteditable="true" data-character="${defaultName}" data-field="traits" title="Click to edit traits">Traits</div>
</div>
</div>
`;
html += '</div>';
} else {
html += '<div class="rpg-thoughts-content">';
for (const char of presentCharacters) {
// Find character portrait
// Use a base64-encoded SVG placeholder as fallback to avoid 400 errors
let characterPortrait = FALLBACK_AVATAR_DATA_URI;
// console.log('[RPG Companion] Looking for avatar for:', char.name);
// For group chats, search through group members first
if (selected_group) {
const groupMembers = getGroupMembers(selected_group);
const matchingMember = groupMembers.find(member =>
member && member.name && member.name.toLowerCase() === char.name.toLowerCase()
);
if (matchingMember && matchingMember.avatar && matchingMember.avatar !== 'none') {
const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingMember.avatar);
if (thumbnailUrl) {
characterPortrait = thumbnailUrl;
}
}
}
// For regular chats or if not found in group, search all characters
if (characterPortrait === FALLBACK_AVATAR_DATA_URI && characters && characters.length > 0) {
const matchingCharacter = characters.find(c =>
c && c.name && c.name.toLowerCase() === char.name.toLowerCase()
);
if (matchingCharacter && matchingCharacter.avatar && matchingCharacter.avatar !== 'none') {
const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingCharacter.avatar);
if (thumbnailUrl) {
characterPortrait = thumbnailUrl;
}
}
}
// 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 && characters[this_chid].name.toLowerCase() === char.name.toLowerCase()) {
const thumbnailUrl = getSafeThumbnailUrl('avatar', characters[this_chid].avatar);
if (thumbnailUrl) {
characterPortrait = thumbnailUrl;
}
}
// Get relationship emoji
const relationshipEmoji = relationshipEmojis[char.relationship] || '⚖️';
html += `
<div class="rpg-character-card" data-character-name="${char.name}">
<div class="rpg-character-avatar">
<img src="${characterPortrait}" alt="${char.name}" onerror="this.style.opacity='0.5';this.onerror=null;" />
<div class="rpg-relationship-badge rpg-editable" contenteditable="true" data-character="${char.name}" data-field="relationship" title="Click to edit (use emoji: ⚔️ ⚖️ ⭐ ❤️)">${relationshipEmoji}</div>
</div>
<div class="rpg-character-info">
<div class="rpg-character-header">
<span class="rpg-character-emoji rpg-editable" contenteditable="true" data-character="${char.name}" data-field="emoji" title="Click to edit emoji">${char.emoji}</span>
<span class="rpg-character-name rpg-editable" contenteditable="true" data-character="${char.name}" data-field="name" title="Click to edit name">${char.name}</span>
</div>
<div class="rpg-character-traits rpg-editable" contenteditable="true" data-character="${char.name}" data-field="traits" title="Click to edit traits">${char.traits}</div>
</div>
</div>
`;
}
html += '</div>';
}
$thoughtsContainer.html(html);
// 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();
updateCharacterField(character, field, value);
});
// 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.
* Handles character creation if character doesn't exist yet.
*
* @param {string} characterName - Name of the character to update
* @param {string} field - Field to update (emoji, name, traits, thoughts, relationship)
* @param {string} value - New value for the field
*/
export function updateCharacterField(characterName, field, value) {
// console.log('[RPG Companion] 📝 updateCharacterField called - character:', characterName, 'field:', field, 'value:', value);
// console.log('[RPG Companion] 📝 Current lastGeneratedData.characterThoughts:', lastGeneratedData.characterThoughts);
// Initialize if it doesn't exist
if (!lastGeneratedData.characterThoughts) {
lastGeneratedData.characterThoughts = 'Present Characters\n---\n';
}
const lines = lastGeneratedData.characterThoughts.split('\n');
let characterFound = false;
const updatedLines = lines.map(line => {
// Case-insensitive character name matching
if (line.toLowerCase().includes(characterName.toLowerCase())) {
characterFound = true;
const parts = line.split('|').map(p => p.trim());
if (parts.length >= 2) {
const firstPart = parts[0];
const emojiMatch = firstPart.match(/^(.+?):\s*(.+)$/);
if (emojiMatch) {
let emoji = emojiMatch[1].trim();
let info = emojiMatch[2].trim();
let relationship = parts[1];
let thoughts = parts[2] || '';
const infoParts = info.split(',').map(p => p.trim());
let name = infoParts[0];
let traits = infoParts.slice(1).join(', ');
if (field === 'emoji') {
emoji = value;
} else if (field === 'name') {
name = value;
} else if (field === 'traits') {
traits = value;
} else if (field === 'thoughts') {
thoughts = value;
} else if (field === 'relationship') {
const emojiToRelationship = {
'⚔️': 'Enemy',
'⚖️': 'Neutral',
'⭐': 'Friend',
'❤️': 'Lover'
};
relationship = emojiToRelationship[value] || value;
}
const newInfo = traits ? `${name}, ${traits}` : name;
return `${emoji}: ${newInfo} | ${relationship} | ${thoughts}`;
}
}
}
return line;
});
// If character wasn't found, create a new character line
if (!characterFound) {
// Find the divider line
const dividerIndex = updatedLines.findIndex(line => line.includes('---'));
if (dividerIndex >= 0) {
// Create initial character line with the edited field
let emoji = '😊';
let name = characterName;
let traits = 'Traits';
let relationship = 'Neutral';
let thoughts = '';
// Apply the edited field
if (field === 'emoji') {
emoji = value;
} else if (field === 'name') {
name = value;
} else if (field === 'traits') {
traits = value;
} else if (field === 'thoughts') {
thoughts = value;
} else if (field === 'relationship') {
const emojiToRelationship = {
'⚔️': 'Enemy',
'⚖️': 'Neutral',
'⭐': 'Friend',
'❤️': 'Lover'
};
relationship = emojiToRelationship[value] || value;
}
const newCharacterLine = `${emoji}: ${name}, ${traits} | ${relationship} | ${thoughts}`;
// Insert after the divider
updatedLines.splice(dividerIndex + 1, 0, newCharacterLine);
}
}
lastGeneratedData.characterThoughts = updatedLines.join('\n');
// console.log('[RPG Companion] 💾 Updated lastGeneratedData.characterThoughts:', lastGeneratedData.characterThoughts);
// Also update the last assistant message's swipe data
const chat = getContext().chat;
if (chat && chat.length > 0) {
// Find the last assistant message
for (let i = chat.length - 1; i >= 0; i--) {
const message = chat[i];
if (!message.is_user) {
// Found last assistant message - update its swipe data
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 = updatedLines.join('\n');
// console.log('[RPG Companion] Updated thoughts in message swipe data');
}
}
break;
}
}
}
saveChatData();
// Always update the sidebar panel
renderThoughts();
// For thoughts edited from the bubble, delay recreation to allow blur event to complete
// This ensures the edit is saved first, then the bubble is recreated with correct layout
if (field === 'thoughts') {
setTimeout(() => {
updateChatThoughts();
}, 100);
} else {
// For other fields, recreate immediately
updateChatThoughts();
}
}
/**
* 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}
// console.log('[RPG Companion] Parsing thoughts from lines:', lines);
for (const line of lines) {
if (line.trim() &&
!line.includes('Present Characters') &&
!line.includes('---') &&
!line.trim().startsWith('```')) {
const parts = line.split('|').map(p => p.trim());
// console.log('[RPG Companion] Line parts:', parts);
if (parts.length >= 3) {
const firstPart = parts[0].trim();
const emojiMatch = firstPart.match(/^(.+?):\s*(.+)$/);
if (emojiMatch) {
const emoji = emojiMatch[1].trim();
const info = emojiMatch[2].trim();
const thoughts = parts[2] ? parts[2].trim() : '';
const infoParts = info.split(',').map(p => p.trim());
const name = infoParts[0] || '';
// console.log('[RPG Companion] Parsed thought - Name:', name, 'Thought:', thoughts);
if (name && thoughts && name.toLowerCase() !== 'unavailable') {
thoughtsArray.push({ name: name.toLowerCase(), emoji, thought: thoughts });
// console.log('[RPG Companion] Added to thoughtsArray:', name.toLowerCase());
}
}
}
}
}
// 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) => {
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="${thought.name}" 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
});
// Initially hide the panel and show the icon
$thoughtPanel.hide();
$thoughtIcon.show();
// console.log('[RPG Companion] Thought panel created at:', { top, left });
// Close button functionality
$thoughtPanel.find('.rpg-thought-close').on('click', function(e) {
e.stopPropagation();
$thoughtPanel.fadeOut(200);
$thoughtIcon.fadeIn(200);
});
// Icon click to show panel
$thoughtIcon.on('click', function(e) {
e.stopPropagation();
$thoughtIcon.fadeOut(200);
$thoughtPanel.fadeIn(200);
});
// 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 (but not when clicking icon or panel)
$(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);
}
});
}