Files
rpg-companion-sillytavern/src/systems/dashboard/widgets/presentCharactersWidget.js
T
Lucas 'Paperboy' Rose-Winters 1fd6720e6b fix: update widget sizing for 1080p screens - Scene, Inventory, and Quests tabs
**Scene Tab (presentCharacters):**
- Desktop: 3×2 (wide and short, fits 1080p viewport)
- Mobile: 2×4 (narrow and tall for vertical stacking)

**Inventory Tab:**
- Desktop: 3×7 (full width, spacious) instead of 2×6
- Mobile: 2×5 (full width, compact)

**Quests Tab:**
- Desktop: 3×7 (full width, spacious) instead of 2×5
- Mobile: 2×5 (full width, compact)

All widgets now use full width at their respective column counts and
are properly sized to fit within 1080p screens without scrolling off.
2025-11-06 20:50:16 +11:00

430 lines
17 KiB
JavaScript

/**
* Present Characters Widget
*
* Displays character cards for all characters present in the scene.
* Shows:
* - Character avatars (matched via fuzzy name matching)
* - Character emoji and name
* - Traits (status, demeanor)
* - Relationship badges (Enemy/Neutral/Friend/Lover)
*
* All fields are editable and sync back to character thoughts data.
*/
/**
* Fuzzy name matching for character avatars
* Handles exact matches, parenthetical additions, and titles
*/
function namesMatch(cardName, aiName) {
if (!cardName || !aiName) return false;
// Exact match
if (cardName.toLowerCase() === aiName.toLowerCase()) return true;
// 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;
// Check if card name appears as complete word in AI name
const escapedCardCore = cardCore.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const wordBoundary = new RegExp(`\\b${escapedCardCore}\\b`);
return wordBoundary.test(aiCore);
}
/**
* Parse character thoughts data
* Format: [Emoji]: [Name, Traits] | [Relationship] | [Thoughts]
* Or: [Emoji]: [Name, Traits] | [Demeanor] | [Relationship] | [Thoughts]
*/
function parseCharacterThoughts(thoughtsText) {
if (!thoughtsText) return [];
const lines = thoughtsText.split('\n');
const presentCharacters = [];
let currentChar = null;
for (const line of lines) {
const trimmed = line.trim();
// Skip headers, dividers, and empty lines
if (!trimmed ||
trimmed.includes('Present Characters') ||
trimmed.includes('---') ||
trimmed.startsWith('```')) {
continue;
}
// New character entry (starts with -)
if (trimmed.startsWith('-')) {
// Save previous character
if (currentChar && currentChar.name && currentChar.name.toLowerCase() !== 'unavailable') {
presentCharacters.push(currentChar);
}
// Start new character
const name = trimmed.replace(/^-\s*/, '').trim();
currentChar = {
name,
emoji: '😊', // Default emoji
traits: '',
relationship: 'Neutral',
thoughts: ''
};
}
// Details line: "Details: 🧐 | Trait1, Trait2 | More traits"
else if (trimmed.startsWith('Details:') && currentChar) {
const detailsText = trimmed.replace('Details:', '').trim();
const parts = detailsText.split('|').map(p => p.trim());
// First part is emoji
if (parts[0]) {
currentChar.emoji = parts[0];
}
// Remaining parts are traits
if (parts.length > 1) {
currentChar.traits = parts.slice(1).join(', ');
}
}
// Relationship line: "Relationship: Ally (details)"
else if (trimmed.startsWith('Relationship:') && currentChar) {
currentChar.relationship = trimmed.replace('Relationship:', '').trim();
}
// Thoughts line: "Thoughts: ..."
else if (trimmed.startsWith('Thoughts:') && currentChar) {
currentChar.thoughts = trimmed.replace('Thoughts:', '').trim()
.replace(/^["']|["']$/g, ''); // Remove surrounding quotes
}
// Stats line: "Stats: ..." (optional, currently ignored but could be stored)
else if (trimmed.startsWith('Stats:') && currentChar) {
// Optional: could parse and store stats if needed
// For now, we'll skip it as the widget doesn't display character stats
}
// Legacy single-line format fallback: "🧐: Name, Traits | Relationship | Thoughts"
else if (trimmed.includes('|') && !currentChar) {
const parts = trimmed.split('|').map(p => p.trim());
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 infoParts = info.split(',').map(p => p.trim());
const name = infoParts[0] || '';
const traits = infoParts.slice(1).join(', ');
const relationship = parts[1].trim();
const thoughts = parts[2].trim();
if (name && name.toLowerCase() !== 'unavailable') {
presentCharacters.push({ emoji, name, traits, relationship, thoughts });
}
}
}
}
}
// Save last character
if (currentChar && currentChar.name && currentChar.name.toLowerCase() !== 'unavailable') {
presentCharacters.push(currentChar);
}
return presentCharacters;
}
/**
* Find character avatar
*/
function findCharacterAvatar(charName, dependencies) {
const { getCharacters, getGroupMembers, getCurrentCharId, getFallbackAvatar, getAvatarUrl } = dependencies;
let avatarUrl = getFallbackAvatar();
// Try group members first if in group chat
const groupMembers = getGroupMembers();
if (groupMembers && groupMembers.length > 0) {
const matchingMember = groupMembers.find(member =>
member && member.name && namesMatch(member.name, charName)
);
if (matchingMember && matchingMember.avatar && matchingMember.avatar !== 'none') {
const url = getAvatarUrl('avatar', matchingMember.avatar);
if (url) avatarUrl = url;
}
}
// Try all characters
if (avatarUrl === getFallbackAvatar()) {
const characters = getCharacters();
if (characters && characters.length > 0) {
const matchingChar = characters.find(c =>
c && c.name && namesMatch(c.name, charName)
);
if (matchingChar && matchingChar.avatar && matchingChar.avatar !== 'none') {
const url = getAvatarUrl('avatar', matchingChar.avatar);
if (url) avatarUrl = url;
}
}
}
// Try current character in 1-on-1 chat
if (avatarUrl === getFallbackAvatar()) {
const currentCharId = getCurrentCharId();
const characters = getCharacters();
if (currentCharId !== undefined && characters[currentCharId]) {
const currentChar = characters[currentCharId];
if (currentChar.name && namesMatch(currentChar.name, charName)) {
const url = getAvatarUrl('avatar', currentChar.avatar);
if (url) avatarUrl = url;
}
}
}
return avatarUrl;
}
/**
* Update character field in shared data
*/
function updateCharacterThoughtsField(dependencies, characterName, field, value) {
const { getCharacterThoughts, setCharacterThoughts, onDataChange } = dependencies;
let thoughtsText = getCharacterThoughts() || '';
const lines = thoughtsText.split('\n');
let updated = false;
const updatedLines = lines.map(line => {
// Find the line for this character
if (line.includes(characterName)) {
const parts = line.split('|').map(p => p.trim());
if (parts.length >= 3) {
const firstPart = parts[0].trim();
const emojiMatch = firstPart.match(/^(.+?):\s*(.+)$/);
if (emojiMatch) {
let emoji = emojiMatch[1].trim();
const info = emojiMatch[2].trim();
const infoParts = info.split(',').map(p => p.trim());
let name = infoParts[0];
let traits = infoParts.slice(1).join(', ');
let relationship, thoughts;
if (parts.length === 3) {
relationship = parts[1].trim();
thoughts = parts[2].trim();
} else {
// 4-part format
relationship = parts[2].trim();
thoughts = parts[3].trim();
}
// Update the specific field
if (field === 'emoji') emoji = value;
else if (field === 'name') name = value;
else if (field === 'traits') traits = value;
else if (field === 'relationship') {
// Convert emoji to text
const relationshipMap = {
'⚔️': 'Enemy',
'⚖️': 'Neutral',
'⭐': 'Friend',
'❤️': 'Lover'
};
relationship = relationshipMap[value] || value;
}
// Reconstruct line
const nameAndTraits = traits ? `${name}, ${traits}` : name;
updated = true;
if (parts.length === 3) {
return `${emoji}: ${nameAndTraits} | ${relationship} | ${thoughts}`;
} else {
return `${emoji}: ${nameAndTraits} | ${parts[1].trim()} | ${relationship} | ${thoughts}`;
}
}
}
}
return line;
});
if (updated) {
const newThoughtsText = updatedLines.join('\n');
setCharacterThoughts(newThoughtsText);
if (onDataChange) {
onDataChange('characterThoughts', field, value, characterName);
}
}
}
/**
* Register Present Characters Widget
*/
export function registerPresentCharactersWidget(registry, dependencies) {
const relationshipEmojis = {
'Enemy': '⚔️',
'Neutral': '⚖️',
'Friend': '⭐',
'Lover': '❤️'
};
registry.register('presentCharacters', {
name: 'Present Characters',
icon: '👥',
description: 'Character cards with avatars, traits, and relationships',
category: 'scene',
minSize: { w: 2, h: 2 },
// Column-aware sizing: narrow and tall on mobile, wide and short on desktop
defaultSize: (columns) => {
if (columns <= 2) {
return { w: 2, h: 4 }; // Mobile: 2 cols wide (full), 4 rows tall
}
return { w: 3, h: 2 }; // Desktop: 3 cols wide (full), 2 rows tall (fits 1080p)
},
// Column-aware max size: can expand vertically if needed
maxAutoSize: (columns) => {
if (columns <= 2) {
return { w: 2, h: 5 };
}
return { w: 3, h: 3 }; // Desktop: can expand to 3 rows if needed
},
requiresSchema: false,
render(container, config = {}) {
const { getCharacterThoughts, getCharacters, getFallbackAvatar } = dependencies;
const thoughtsText = getCharacterThoughts();
const presentCharacters = parseCharacterThoughts(thoughtsText);
let html = '<div class="rpg-thoughts-content">';
if (presentCharacters.length === 0) {
// Show placeholder
const characters = getCharacters();
const currentCharId = dependencies.getCurrentCharId();
let defaultPortrait = getFallbackAvatar();
let defaultName = 'Character';
if (currentCharId !== undefined && characters[currentCharId]) {
defaultPortrait = findCharacterAvatar(characters[currentCharId].name, dependencies);
defaultName = characters[currentCharId].name || 'Character';
}
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>
`;
} else {
// Render character cards
for (const char of presentCharacters) {
const characterPortrait = findCharacterAvatar(char.name, dependencies);
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>';
container.innerHTML = html;
attachCharacterHandlers(container, dependencies);
},
getConfig() {
return {
showThoughtsInChat: {
type: 'boolean',
label: 'Show thought bubbles in chat',
default: false
},
cardLayout: {
type: 'select',
label: 'Card Layout',
default: 'grid',
options: [
{ value: 'grid', label: 'Grid' },
{ value: 'list', label: 'List' },
{ value: 'compact', label: 'Compact' }
]
}
};
}
});
}
/**
* Attach character field edit handlers
*/
function attachCharacterHandlers(container, dependencies) {
const editableFields = container.querySelectorAll('.rpg-editable');
editableFields.forEach(field => {
const characterName = field.dataset.character;
const fieldName = field.dataset.field;
let originalValue = field.textContent.trim();
field.addEventListener('focus', () => {
originalValue = field.textContent.trim();
const range = document.createRange();
range.selectNodeContents(field);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
});
field.addEventListener('blur', () => {
const value = field.textContent.trim();
if (value && value !== originalValue) {
updateCharacterThoughtsField(dependencies, characterName, fieldName, value);
}
});
field.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
field.blur();
}
if (e.key === 'Escape') {
e.preventDefault();
field.textContent = originalValue;
field.blur();
}
});
// Prevent paste with formatting
field.addEventListener('paste', (e) => {
e.preventDefault();
const text = (e.clipboardData || window.clipboardData).getData('text/plain');
document.execCommand('insertText', false, text);
});
});
}