Compare commits
3
Commits
1f8591d4ec
...
c9d604ab68
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9d604ab68 | ||
|
|
f8894a9f3c | ||
|
|
24c964c38d |
@@ -53,6 +53,7 @@ import { renderInventory } from '../rendering/inventory.js';
|
||||
import { renderEquipment } from '../rendering/equipment.js';
|
||||
import { renderQuests } from '../rendering/quests.js';
|
||||
import { renderMusicPlayer } from '../rendering/musicPlayer.js';
|
||||
import { scheduleRender, clearAllCaches } from '../rendering/renderUtils.js';
|
||||
|
||||
// Utils
|
||||
import { getSafeThumbnailUrl } from '../../utils/avatars.js';
|
||||
@@ -323,7 +324,28 @@ function restoreOrRepairLatestTrackerState() {
|
||||
return restored;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders all RPG state panels.
|
||||
* Uses requestAnimationFrame batching to coalesce multiple render calls
|
||||
* into a single DOM update cycle, reducing reflow/repaint costs.
|
||||
*/
|
||||
function rerenderRpgState() {
|
||||
scheduleRender(renderUserStats);
|
||||
scheduleRender(renderInfoBox);
|
||||
scheduleRender(renderThoughts);
|
||||
scheduleRender(renderInventory);
|
||||
scheduleRender(renderEquipment);
|
||||
scheduleRender(renderQuests);
|
||||
scheduleRender(() => renderMusicPlayer($musicPlayerContainer[0]));
|
||||
scheduleRender(updateFabWidgets);
|
||||
scheduleRender(updateStripWidgets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately renders all RPG state panels (synchronous).
|
||||
* Use this when you need the DOM to be updated before the next line of code.
|
||||
*/
|
||||
function rerenderRpgStateSync() {
|
||||
renderUserStats();
|
||||
renderInfoBox();
|
||||
renderThoughts();
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { extensionSettings, $equipmentContainer } from '../../core/state.js';
|
||||
import { i18n } from '../../core/i18n.js';
|
||||
import { EQUIPMENT_CATEGORIES, SLOTS_LIST, escapeHtml } from '../equipment/constants.js';
|
||||
import { updateIfChanged } from './renderUtils.js';
|
||||
|
||||
/**
|
||||
* Renders a single equipment slot
|
||||
@@ -150,7 +151,7 @@ export function renderEquipment() {
|
||||
}
|
||||
|
||||
const html = generateEquipmentHTML();
|
||||
$equipmentContainer.html(html);
|
||||
updateIfChanged($equipmentContainer, html, 'rpg-equipment');
|
||||
|
||||
// Re-apply translations
|
||||
i18n.applyTranslations($equipmentContainer[0]);
|
||||
|
||||
@@ -15,6 +15,7 @@ import { i18n } from '../../core/i18n.js';
|
||||
import { isItemLocked } from '../generation/lockManager.js';
|
||||
import { repairJSON } from '../../utils/jsonRepair.js';
|
||||
import { updateFabWidgets } from '../ui/mobile.js';
|
||||
import { updateIfChanged } from './renderUtils.js';
|
||||
|
||||
/**
|
||||
* Helper to generate lock icon HTML if setting is enabled
|
||||
@@ -575,8 +576,10 @@ export function renderInfoBox() {
|
||||
// Close the scrollable content wrapper
|
||||
html += '</div>';
|
||||
|
||||
$infoBoxContainer.html(html);
|
||||
const domUpdated = updateIfChanged($infoBoxContainer, html, 'rpg-info-box');
|
||||
|
||||
// Only re-bind event handlers and apply dynamic styling if DOM was updated
|
||||
if (domUpdated) {
|
||||
// Add dynamic text scaling for location field
|
||||
const updateLocationTextSize = ($element) => {
|
||||
const text = $element.text();
|
||||
@@ -659,6 +662,7 @@ export function renderInfoBox() {
|
||||
saveSettings();
|
||||
});
|
||||
});
|
||||
} // end if (domUpdated)
|
||||
|
||||
// Remove updating class after animation
|
||||
if (extensionSettings.enableAnimations) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { updateInventoryItem } from '../interaction/inventoryEdit.js';
|
||||
import { parseItems } from '../../utils/itemParser.js';
|
||||
import { isItemLocked, setItemLock } from '../generation/lockManager.js';
|
||||
import { i18n } from '../../core/i18n.js';
|
||||
import { updateIfChanged } from './renderUtils.js';
|
||||
|
||||
// Type imports
|
||||
/** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */
|
||||
@@ -594,11 +595,13 @@ export function renderInventory() {
|
||||
|
||||
// Generate HTML and update DOM
|
||||
const html = generateInventoryHTML(inventory, options);
|
||||
$inventoryContainer.html(html);
|
||||
const domUpdated = updateIfChanged($inventoryContainer, html, 'rpg-inventory');
|
||||
|
||||
// Restore form states after re-rendering (fixes Bug #1)
|
||||
restoreFormStates();
|
||||
|
||||
// Only re-bind event handlers if DOM was actually updated
|
||||
if (domUpdated) {
|
||||
// Event listener for editing item names (mobile-friendly contenteditable)
|
||||
$inventoryContainer.find('.rpg-item-name.rpg-editable').on('blur', function() {
|
||||
const field = $(this).data('field');
|
||||
@@ -632,6 +635,7 @@ export function renderInventory() {
|
||||
// Save settings
|
||||
saveSettings();
|
||||
});
|
||||
} // end if (domUpdated)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,7 @@ import { extensionSettings, $questsContainer, committedTrackerData, lastGenerate
|
||||
import { saveSettings, saveChatData } from '../../core/persistence.js';
|
||||
import { isItemLocked, setItemLock } from '../generation/lockManager.js';
|
||||
import { i18n } from '../../core/i18n.js';
|
||||
import { updateIfChanged } from './renderUtils.js';
|
||||
|
||||
/**
|
||||
* Syncs the current extensionSettings.quests to committedTrackerData.userStats
|
||||
@@ -247,10 +248,12 @@ export function renderQuests() {
|
||||
}
|
||||
html += '</div></div>';
|
||||
|
||||
$questsContainer.html(html);
|
||||
const domUpdated = updateIfChanged($questsContainer, html, 'rpg-quests');
|
||||
|
||||
// Attach event handlers
|
||||
// Attach event handlers only if DOM was updated
|
||||
if (domUpdated) {
|
||||
attachQuestEventHandlers();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Rendering Utilities Module
|
||||
* Provides performance optimization utilities for the rendering pipeline:
|
||||
* - requestAnimationFrame batching for render calls
|
||||
* - Lightweight change detection to skip unnecessary DOM updates
|
||||
*/
|
||||
|
||||
/**
|
||||
* Cached HTML content per container for change detection.
|
||||
* @type {Map<string, string>}
|
||||
*/
|
||||
const htmlCache = new Map();
|
||||
|
||||
/**
|
||||
* Pending render functions queued for the next animation frame.
|
||||
* @type {Array<Function>}
|
||||
*/
|
||||
let pendingRenders = [];
|
||||
|
||||
/**
|
||||
* Whether a requestAnimationFrame is already scheduled.
|
||||
* @type {boolean}
|
||||
*/
|
||||
let rafScheduled = false;
|
||||
|
||||
/**
|
||||
* The ID of the currently scheduled requestAnimationFrame, if any.
|
||||
* @type {number|null}
|
||||
*/
|
||||
let rafId = null;
|
||||
|
||||
/**
|
||||
* Schedules a render function to run in the next animation frame.
|
||||
* Multiple calls coalesce into a single rAF callback, batching DOM updates.
|
||||
*
|
||||
* @param {Function} renderFn - A synchronous render function to execute
|
||||
* @returns {void}
|
||||
*/
|
||||
export function scheduleRender(renderFn) {
|
||||
if (typeof renderFn !== 'function') return;
|
||||
|
||||
pendingRenders.push(renderFn);
|
||||
|
||||
if (!rafScheduled) {
|
||||
rafScheduled = true;
|
||||
rafId = requestAnimationFrame(flushRenders);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushes all pending render functions in a single animation frame.
|
||||
* @private
|
||||
*/
|
||||
function flushRenders() {
|
||||
rafScheduled = false;
|
||||
rafId = null;
|
||||
|
||||
const renders = pendingRenders;
|
||||
pendingRenders = [];
|
||||
|
||||
for (const fn of renders) {
|
||||
try {
|
||||
fn();
|
||||
} catch (e) {
|
||||
console.error('[RPG Companion] Render error:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels any pending scheduled renders.
|
||||
* Useful when a newer render call makes older ones stale.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
export function cancelScheduledRenders() {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
pendingRenders = [];
|
||||
rafScheduled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a jQuery container's HTML only if the new content differs from the cached version.
|
||||
* This avoids unnecessary DOM teardown/rebuild, event listener re-binding overhead,
|
||||
* and reduces reflow/repaint costs.
|
||||
*
|
||||
* @param {jQuery} $container - jQuery element to update
|
||||
* @param {string} newHtml - New HTML content
|
||||
* @param {string} [cacheKey] - Optional cache key; defaults to the container's selector
|
||||
* @returns {boolean} True if the DOM was actually updated, false if skipped (no change)
|
||||
*/
|
||||
export function updateIfChanged($container, newHtml, cacheKey) {
|
||||
if (!$container || !$container.length) return false;
|
||||
|
||||
if (!cacheKey) {
|
||||
cacheKey = $container.selector || $container.attr('id') || String($container[0]);
|
||||
}
|
||||
|
||||
const cached = htmlCache.get(cacheKey);
|
||||
|
||||
if (cached === newHtml) {
|
||||
return false; // No change, skip DOM update
|
||||
}
|
||||
|
||||
htmlCache.set(cacheKey, newHtml);
|
||||
$container.html(newHtml);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the HTML cache for a specific key, forcing the next render to update.
|
||||
* Use this when you know content changed but want to control when it re-renders.
|
||||
*
|
||||
* @param {string} cacheKey - The cache key to clear
|
||||
* @returns {void}
|
||||
*/
|
||||
export function invalidateCache(cacheKey) {
|
||||
htmlCache.delete(cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the entire HTML cache.
|
||||
* Use sparingly — e.g., on chat change or full reset.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
export function clearAllCaches() {
|
||||
htmlCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an existing render function with change detection.
|
||||
* The wrapped function generates HTML, compares it to the cache,
|
||||
* and only updates the DOM if content changed.
|
||||
*
|
||||
* @param {Function} renderFn - Function that returns { $container, html } or calls updateIfChanged internally
|
||||
* @param {string} cacheKey - Cache key for this render target
|
||||
* @returns {Function} Wrapped render function
|
||||
*/
|
||||
export function withChangeDetection(renderFn, cacheKey) {
|
||||
return function () {
|
||||
return renderFn();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Batches multiple DOM updates into a single reflow by using
|
||||
* document fragment pattern. Reduces layout thrashing.
|
||||
*
|
||||
* @param {Function} callback - Function that receives a document fragment and populates it
|
||||
* @param {jQuery} $container - Container to append the fragment to
|
||||
* @returns {void}
|
||||
*/
|
||||
export function batchDomUpdates(callback, $container) {
|
||||
if (!$container || !$container.length) return;
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
callback(fragment);
|
||||
$container[0].appendChild(fragment);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { isItemLocked, setItemLock } from '../generation/lockManager.js';
|
||||
import { renderAlternatePresentCharacters } from '../ui/alternatePresentCharacters.js';
|
||||
import { queueThoughtBasedExpressionsUpdate } from '../integration/thoughtBasedExpressions.js';
|
||||
import { updateIfChanged } from './renderUtils.js';
|
||||
|
||||
/**
|
||||
* Helper to generate lock icon HTML if setting is enabled
|
||||
@@ -477,11 +478,13 @@ export function renderThoughts({ preserveScroll = false, useCommittedFallback =
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
$thoughtsContainer.html(html);
|
||||
const domUpdated = updateIfChanged($thoughtsContainer, html, 'rpg-thoughts');
|
||||
|
||||
debugLog('[RPG Thoughts] ✓ HTML rendered to container');
|
||||
debugLog('[RPG Thoughts] ✓ HTML rendered to container, updated:', domUpdated);
|
||||
debugLog('[RPG Thoughts] =======================================================');
|
||||
|
||||
// Only re-bind event handlers if DOM was actually updated
|
||||
if (domUpdated) {
|
||||
// Add event handlers for editable character fields
|
||||
$thoughtsContainer.find('.rpg-editable').on('blur', function () {
|
||||
const character = $(this).data('character');
|
||||
@@ -598,6 +601,7 @@ export function renderThoughts({ preserveScroll = false, useCommittedFallback =
|
||||
}
|
||||
}
|
||||
});
|
||||
} // end if (domUpdated)
|
||||
|
||||
// Remove updating class after animation
|
||||
if (extensionSettings.enableAnimations) {
|
||||
|
||||
@@ -5,25 +5,16 @@
|
||||
|
||||
import { getContext } from '../../../../../../extensions.js';
|
||||
import { user_avatar } from '../../../../../../../script.js';
|
||||
import {
|
||||
extensionSettings,
|
||||
lastGeneratedData,
|
||||
committedTrackerData,
|
||||
$userStatsContainer,
|
||||
FALLBACK_AVATAR_DATA_URI
|
||||
} from '../../core/state.js';
|
||||
import { extensionSettings, lastGeneratedData, committedTrackerData, $userStatsContainer, FALLBACK_AVATAR_DATA_URI } from '../../core/state.js';
|
||||
import { i18n } from '../../core/i18n.js';
|
||||
import {
|
||||
saveSettings,
|
||||
saveChatData,
|
||||
updateMessageSwipeData
|
||||
} from '../../core/persistence.js';
|
||||
import { saveSettings, saveChatData, updateMessageSwipeData } from '../../core/persistence.js';
|
||||
import { getSafeThumbnailUrl } from '../../utils/avatars.js';
|
||||
import { buildInventorySummary } from '../generation/promptBuilder.js';
|
||||
import { isItemLocked, setItemLock } from '../generation/lockManager.js';
|
||||
import { updateFabWidgets } from '../ui/mobile.js';
|
||||
import { getStatBarColors } from '../ui/theme.js';
|
||||
import { getEquipmentBonuses } from '../interaction/equipmentActions.js';
|
||||
import { updateIfChanged } from './renderUtils.js';
|
||||
|
||||
/**
|
||||
* Extracts the base name (before parentheses) and converts to snake_case for use as JSON key.
|
||||
@@ -449,11 +440,13 @@ export function renderUserStats() {
|
||||
// console.log('[RPG UserStats Render] Container exists:', !!$userStatsContainer, '$userStatsContainer length:', $userStatsContainer?.length);
|
||||
|
||||
// Always render to the #rpg-user-stats container (mobile layout just moves it around in DOM)
|
||||
$userStatsContainer.html(html);
|
||||
// console.log('[RPG UserStats Render] ✓ HTML rendered to #rpg-user-stats container');
|
||||
// Use change detection to skip unnecessary DOM updates
|
||||
const domUpdated = updateIfChanged($userStatsContainer, html, 'rpg-user-stats');
|
||||
// console.log('[RPG UserStats Render] ✓ HTML rendered to #rpg-user-stats container, updated:', domUpdated);
|
||||
|
||||
// Add event listeners for editable stat values
|
||||
$('.rpg-editable-stat').on('blur', function () {
|
||||
// Only re-bind event listeners if DOM was actually updated
|
||||
if (domUpdated) {
|
||||
$userStatsContainer.find('.rpg-editable-stat').on('blur', function () {
|
||||
const field = $(this).data('field');
|
||||
const mode = $(this).data('mode');
|
||||
const maxValue = parseInt($(this).data('max')) || 100;
|
||||
@@ -461,82 +454,61 @@ export function renderUserStats() {
|
||||
let value;
|
||||
|
||||
if (mode === 'number') {
|
||||
// In number mode, parse "X/MAX" or just "X"
|
||||
const parts = textValue.split('/');
|
||||
value = parseInt(parts[0]);
|
||||
|
||||
// Validate and clamp value between 0 and maxValue
|
||||
if (isNaN(value)) {
|
||||
value = 0;
|
||||
}
|
||||
value = Math.max(0, Math.min(maxValue, value));
|
||||
} else {
|
||||
// In percentage mode, parse "X%" or just "X"
|
||||
value = parseInt(textValue.replace('%', ''));
|
||||
|
||||
// Validate and clamp value between 0 and 100
|
||||
if (isNaN(value)) {
|
||||
value = 0;
|
||||
}
|
||||
value = Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
// Update the setting
|
||||
extensionSettings.userStats[field] = value;
|
||||
|
||||
// Update userStats data (maintains JSON or text format)
|
||||
updateUserStatsData();
|
||||
|
||||
saveSettings();
|
||||
saveChatData();
|
||||
updateMessageSwipeData();
|
||||
|
||||
// Re-render to update the bar and FAB widgets
|
||||
renderUserStats();
|
||||
updateFabWidgets();
|
||||
});
|
||||
|
||||
// Add event listeners for mood/conditions editing
|
||||
$('.rpg-mood-emoji.rpg-editable').on('blur', function () {
|
||||
$userStatsContainer.find('.rpg-mood-emoji.rpg-editable').on('blur', function () {
|
||||
const value = $(this).text().trim();
|
||||
extensionSettings.userStats.mood = value || '😐';
|
||||
|
||||
// Update userStats data (maintains JSON or text format)
|
||||
updateUserStatsData();
|
||||
|
||||
saveSettings();
|
||||
saveChatData();
|
||||
updateMessageSwipeData();
|
||||
});
|
||||
|
||||
$('.rpg-mood-conditions.rpg-editable').on('blur', function () {
|
||||
$userStatsContainer.find('.rpg-mood-conditions.rpg-editable').on('blur', function () {
|
||||
const value = $(this).text().trim();
|
||||
const fieldKey = $(this).data('field');
|
||||
extensionSettings.userStats[fieldKey] = value || 'None';
|
||||
|
||||
// Update userStats data (maintains JSON or text format)
|
||||
updateUserStatsData();
|
||||
|
||||
saveSettings();
|
||||
saveChatData();
|
||||
updateMessageSwipeData();
|
||||
});
|
||||
|
||||
// Add event listener for skills editing
|
||||
$('.rpg-skills-value.rpg-editable').on('blur', function () {
|
||||
$userStatsContainer.find('.rpg-skills-value.rpg-editable').on('blur', function () {
|
||||
const value = $(this).text().trim();
|
||||
extensionSettings.userStats.skills = value || 'None';
|
||||
|
||||
// Update userStats data (maintains JSON or text format)
|
||||
updateUserStatsData();
|
||||
|
||||
saveSettings();
|
||||
saveChatData();
|
||||
updateMessageSwipeData();
|
||||
});
|
||||
|
||||
// Add event listeners for stat name editing
|
||||
$('.rpg-editable-stat-name').on('blur', function () {
|
||||
$userStatsContainer.find('.rpg-editable-stat-name').on('blur', function () {
|
||||
const field = $(this).data('field');
|
||||
const value = $(this).text().trim().replace(':', '');
|
||||
|
||||
@@ -551,34 +523,28 @@ export function renderUserStats() {
|
||||
}
|
||||
|
||||
extensionSettings.statNames[field] = value || extensionSettings.statNames[field];
|
||||
|
||||
saveSettings();
|
||||
saveChatData();
|
||||
|
||||
// Re-render to update the display
|
||||
renderUserStats();
|
||||
});
|
||||
|
||||
// Add event listener for level editing
|
||||
$('.rpg-level-value.rpg-editable').on('blur', function () {
|
||||
$userStatsContainer.find('.rpg-level-value.rpg-editable').on('blur', function () {
|
||||
let value = parseInt($(this).text().trim());
|
||||
if (isNaN(value) || value < 1) {
|
||||
value = 1;
|
||||
}
|
||||
// Set reasonable max level
|
||||
value = Math.min(100, value);
|
||||
|
||||
extensionSettings.level = value;
|
||||
saveSettings();
|
||||
saveChatData();
|
||||
updateMessageSwipeData();
|
||||
|
||||
// Re-render to update the display
|
||||
renderUserStats();
|
||||
});
|
||||
|
||||
// Prevent line breaks in level field
|
||||
$('.rpg-level-value.rpg-editable').on('keydown', function (e) {
|
||||
$userStatsContainer.find('.rpg-level-value.rpg-editable').on('keydown', function (e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
$(this).blur();
|
||||
@@ -586,7 +552,7 @@ export function renderUserStats() {
|
||||
});
|
||||
|
||||
// Add event listener for section lock icon clicks (support both click and touch)
|
||||
$('.rpg-section-lock-icon').on('click touchend', function (e) {
|
||||
$userStatsContainer.find('.rpg-section-lock-icon').on('click touchend', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const $icon = $(this);
|
||||
@@ -594,19 +560,15 @@ export function renderUserStats() {
|
||||
const itemPath = $icon.data('path');
|
||||
const currentlyLocked = isItemLocked(trackerType, itemPath);
|
||||
|
||||
// Toggle lock state
|
||||
setItemLock(trackerType, itemPath, !currentlyLocked);
|
||||
|
||||
// Update icon
|
||||
const newIcon = !currentlyLocked ? '🔒' : '🔓';
|
||||
const newTitle = !currentlyLocked ? (i18n.getTranslation('infoBox.locked') || 'Locked') : (i18n.getTranslation('infoBox.unlocked') || 'Unlocked');
|
||||
$icon.text(newIcon);
|
||||
$icon.attr('title', newTitle);
|
||||
|
||||
// Toggle 'locked' class for persistent visibility
|
||||
$icon.toggleClass('locked', !currentlyLocked);
|
||||
|
||||
// Save settings
|
||||
saveSettings();
|
||||
});
|
||||
} // end if (domUpdated)
|
||||
}
|
||||
|
||||
@@ -6881,6 +6881,8 @@ body:has(.rpg-panel.rpg-mobile-open) .rpg-fab-widget-container {
|
||||
gap: 1rem;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: 0 500px;
|
||||
}
|
||||
|
||||
/* Sub-tabs Navigation */
|
||||
@@ -12079,6 +12081,8 @@ body.documentstyle .rpg-inline-thoughts {
|
||||
|
||||
.rpg-equipment-container {
|
||||
padding: 8px 0;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: 0 300px;
|
||||
}
|
||||
|
||||
.rpg-equipment-header {
|
||||
|
||||
Reference in New Issue
Block a user