Files
rpg-companion-sillytavern/src/systems/dashboard/widgets/sceneInfoWidget.js
T
Lucas 'Paperboy' Rose-Winters b6c6eaee2a fix(dashboard): make Scene Info widget column-aware for proper desktop sizing
Fixed Scene Info widget using fixed sizes instead of column-aware functions,
causing it to be too narrow on desktop (3-4 columns) while working fine on mobile (2 columns).

**Problem:**
- Widget had fixed `defaultSize: {w: 2, h: 2}` and `maxAutoSize: {w: 2, h: 3}`
- Worked perfectly on mobile (2 columns) → 2×3 fills width
- Too narrow on desktop (3-4 columns) → 2×3 only uses 50-66% of width
- Reset Layout/Sort/Auto-Arrange buttons couldn't scale properly

**Root Cause:**
Scene Info widget not following established pattern used by User Info and User Stats widgets,
which use column-aware functions instead of fixed size objects.

**Fix (sceneInfoWidget.js:292-303):**

Changed from fixed sizes:
```javascript
defaultSize: { w: 2, h: 2 },
maxAutoSize: { w: 2, h: 3 },
```

To column-aware functions:
```javascript
defaultSize: (columns) => {
    if (columns <= 2) {
        return { w: 2, h: 2 }; // Mobile: 2×2 (compact, full width)
    }
    return { w: 3, h: 3 };     // Desktop: 3×3 (spacious)
},
maxAutoSize: (columns) => {
    if (columns <= 2) {
        return { w: 2, h: 3 }; // Mobile: 2×3 max (full width)
    }
    return { w: 3, h: 3 };     // Desktop: 3×3 max
},
```

**Behavior:**

Mobile (≤2 columns):
- Default: 2×2 (compact)
- Max: 2×3 (can expand vertically)
- Fills entire panel width ✓

Desktop (≥3 columns):
- Default: 3×3 (spacious)
- Max: 3×3 (properly sized)
- Uses horizontal space appropriately ✓

**Result:**
- Reset Layout: Uses correct size for current column count
- Sort Widgets: Sizes correctly after sort
- Auto-Arrange: Expands to proper maxAutoSize based on columns
- Panel resize: Widget reflowed properly when columns change
- All 5 data points (date, time, weather, temp, location) visible at all sizes

Follows same pattern as User Info (lines 42-54) and User Stats (lines 38-43) widgets.
2025-11-03 22:10:39 +11:00

368 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Scene Info Grid Widget
*
* Displays calendar, weather, temperature, clock, and location in a compact
* information-dense grid layout. All data points visible at once for maximum
* scannability.
*
* Design: 2-column grid with location header + 4 data cards
* Inspiration: Apple Widgets, Material Design, modern dashboard patterns
*/
import { parseInfoBoxData } from './infoBoxWidgets.js';
/**
* Format date for display
* @param {string} fullDate - Full date string from infoBox
* @param {string} weekday - Weekday name
* @param {string} month - Month/day description (e.g. "3rd Day of the Ninth Month")
* @returns {Object} Formatted date parts
*/
function formatDate(fullDate, weekday, month) {
if (!fullDate && !month) {
return { icon: '📅', value: 'No Date', label: '' };
}
// parseInfoBoxData splits date on commas:
// "Tuesday, 3rd Day of the Ninth Month, Autumn, Year..." becomes:
// weekday = "Tuesday"
// month = "3rd Day of the Ninth Month"
// year = "Autumn"
// Display the most important part (month/day) with weekday as label
const displayValue = month || fullDate;
const displayLabel = weekday || '';
return {
icon: '📅',
value: displayValue,
label: displayLabel
};
}
/**
* Format time for display
* @param {string} timeStart - Start time
* @param {string} timeEnd - End time
* @returns {Object} Formatted time parts
*/
function formatTime(timeStart, timeEnd) {
const timeDisplay = timeEnd || timeStart || '12:00';
return {
icon: '🕐',
value: timeDisplay,
label: '' // Could add timezone if available
};
}
/**
* Format weather for display
* @param {string} weatherEmoji - Weather emoji or symbol string
* @param {string} weatherForecast - Weather description
* @returns {Object} Formatted weather parts
*/
function formatWeather(weatherEmoji, weatherForecast) {
const forecast = weatherForecast || 'Clear';
// If no emoji provided, display forecast text only
if (!weatherEmoji) {
return {
icon: '',
value: forecast,
label: ''
};
}
// Validate emoji/symbol (relaxed check)
// Allow: actual emojis, custom symbols (+++, ***, etc.)
const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u;
const symbolRegex = /^[+*#~\-=_]+$/; // Custom weather symbols
const looksLikeEmojiOrSymbol = weatherEmoji.length <= 5 && (
emojiRegex.test(weatherEmoji) ||
symbolRegex.test(weatherEmoji)
);
if (looksLikeEmojiOrSymbol) {
// Valid emoji or symbol - append to forecast
return {
icon: '',
value: `${forecast} ${weatherEmoji}`,
label: ''
};
} else {
// weatherEmoji is actually text (e.g., "Clear") - combine with forecast
// Handles: prose weather like "The air crackles with magical energy"
return {
icon: '',
value: `${weatherEmoji} ${forecast}`.trim(),
label: ''
};
}
}
/**
* Format temperature for display
* @param {string} temperature - Temperature value
* @returns {Object} Formatted temperature parts
*/
function formatTemp(temperature) {
if (!temperature) {
return { icon: '🌡️', value: '20°C', label: '' };
}
return {
icon: '🌡️',
value: temperature,
label: '' // Could add "Feels like" if available
};
}
/**
* Format location for display
* @param {string} location - Location name
* @returns {Object} Formatted location parts
*/
function formatLocation(location) {
if (!location || location === 'Location') {
return { value: 'No Location', label: '' };
}
// Split on FIRST comma only to get primary location + context
// Preserves hyphens in names (e.g., "Seol Yi-hwan")
// Example: "The Winding Stair, Third Floor, East Wing, Palace, City"
// -> value: "The Winding Stair", label: "Third Floor, East Wing, Palace, City"
const firstCommaIndex = location.indexOf(',');
if (firstCommaIndex !== -1 && firstCommaIndex < location.length - 1) {
return {
value: location.substring(0, firstCommaIndex).trim(),
label: location.substring(firstCommaIndex + 1).trim() // Keep all remaining text
};
}
// No comma or comma at end - display full text
return {
value: location,
label: ''
};
}
/**
* Render info grid item
* @param {Object} item - Item data
* @param {string} item.icon - Icon emoji (optional)
* @param {string} item.value - Primary value
* @param {string} item.label - Secondary label
* @param {string} field - Field name for editing
* @param {string} gridArea - CSS grid area name
* @returns {string} HTML for grid item
*/
function renderInfoItem(item, field, gridArea) {
const hasLabel = item.label && item.label !== '';
const hasIcon = item.icon && item.icon !== '';
const areaClass = gridArea ? `rpg-info-${gridArea}` : '';
return `
<div class="rpg-info-item ${areaClass}" data-field="${field}">
${hasIcon ? `<span class="item-icon">${item.icon}</span>` : ''}
<div class="item-content">
<span class="item-value rpg-editable" contenteditable="true" data-field="${field}" title="Click to edit">${item.value}</span>
${hasLabel ? `<span class="item-label">${item.label}</span>` : ''}
</div>
</div>
`;
}
/**
* Render location header (full width)
* @param {Object} location - Location data
* @returns {string} HTML for location header
*/
function renderLocationHeader(location) {
const hasDescription = location.label && location.label !== '';
return `
<div class="rpg-info-item rpg-info-location" data-field="location">
<span class="item-icon">📍</span>
<div class="item-content">
<span class="item-value rpg-editable" contenteditable="true" data-field="location" title="Click to edit">${location.value}</span>
${hasDescription ? `<span class="item-label">${location.label}</span>` : ''}
</div>
</div>
`;
}
/**
* Attach edit handlers to editable fields
* @param {HTMLElement} container - Widget container
* @param {Object} dependencies - Widget dependencies
*/
function attachEditHandlers(container, dependencies) {
const editableFields = container.querySelectorAll('.rpg-editable');
editableFields.forEach(field => {
const fieldName = field.dataset.field;
let originalValue = field.textContent.trim();
field.addEventListener('focus', () => {
originalValue = field.textContent.trim();
// Select all text on focus
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) {
updateInfoBoxField(dependencies, 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);
});
});
}
/**
* Update info box field in shared data
* @param {Object} dependencies - Widget dependencies
* @param {string} field - Field name
* @param {string} value - New value
*/
function updateInfoBoxField(dependencies, field, value) {
const { getInfoBoxData, setInfoBoxData, onDataChange } = dependencies;
let infoBoxData = getInfoBoxData() || '';
// Simple replace for now - could be more sophisticated
const fieldMap = {
'date': /Date: [^\n]+/,
'time': /Time: [^\n]+/,
'weather': /Weather: [^\n]+/,
'temperature': /Temperature: [^\n]+/,
'location': /Location: [^\n]+/
};
const pattern = fieldMap[field];
if (pattern) {
const replacement = `${field.charAt(0).toUpperCase() + field.slice(1)}: ${value}`;
if (pattern.test(infoBoxData)) {
infoBoxData = infoBoxData.replace(pattern, replacement);
} else {
infoBoxData += `\n${replacement}`;
}
setInfoBoxData(infoBoxData);
if (onDataChange) {
onDataChange('infoBox', field, value);
}
}
}
/**
* Register Scene Info Widget
*/
export function registerSceneInfoWidget(registry, dependencies) {
registry.register('sceneInfo', {
name: 'Scene Info',
icon: '🗺️',
description: 'Compact scene information grid (calendar, weather, time, location)',
category: 'scene',
minSize: { w: 2, h: 2 },
// Column-aware sizing: compact on mobile, spacious on desktop
defaultSize: (columns) => {
if (columns <= 2) {
return { w: 2, h: 2 }; // Mobile: 2×2 (compact, full width)
}
return { w: 3, h: 3 }; // Desktop: 3×3 (spacious)
},
maxAutoSize: (columns) => {
if (columns <= 2) {
return { w: 2, h: 3 }; // Mobile: 2×3 max (full width)
}
return { w: 3, h: 3 }; // Desktop: 3×3 max
},
requiresSchema: false,
/**
* Render the widget
* @param {HTMLElement} container - Widget container
* @param {Object} config - Widget configuration
*/
render(container, config = {}) {
const { getInfoBoxData } = dependencies;
const data = parseInfoBoxData(getInfoBoxData());
// Format data for display
const date = formatDate(data.date, data.weekday, data.month);
const time = formatTime(data.timeStart, data.timeEnd);
const weather = formatWeather(data.weatherEmoji, data.weatherForecast);
const temp = formatTemp(data.temperature);
const location = formatLocation(data.location);
// Build grid HTML
const html = `
<div class="rpg-scene-info-grid">
${renderLocationHeader(location)}
${renderInfoItem(date, 'date', 'calendar')}
${renderInfoItem(time, 'time', 'clock')}
${renderInfoItem(weather, 'weather', 'weather')}
${renderInfoItem(temp, 'temperature', 'temperature')}
</div>
`;
container.innerHTML = html;
attachEditHandlers(container, dependencies);
},
/**
* Get configuration options
* @returns {Object} Configuration schema
*/
getConfig() {
return {
showLabels: {
type: 'boolean',
label: 'Show Secondary Labels',
default: true,
description: 'Show secondary text (weekday, timezone, etc.)'
},
compactMode: {
type: 'boolean',
label: 'Compact Mode',
default: false,
description: 'Reduce padding and font sizes'
}
};
},
/**
* Handle configuration changes
* @param {HTMLElement} container - Widget container
* @param {Object} newConfig - New configuration
*/
onConfigChange(container, newConfig) {
this.render(container, newConfig);
}
});
}