Add storage-backed story history
This commit is contained in:
@@ -9,7 +9,7 @@ class GameLoopModule extends BaseModule {
|
||||
super('game-loop', 'Game Loop');
|
||||
|
||||
// Dependencies
|
||||
this.dependencies = ['ui-controller', 'socket-client', 'text-buffer', 'sentence-queue', 'playback-coordinator', 'animation-queue', 'audio-manager', 'tts-factory', 'ui-input-handler'];
|
||||
this.dependencies = ['ui-controller', 'socket-client', 'text-buffer', 'sentence-queue', 'playback-coordinator', 'animation-queue', 'audio-manager', 'tts-factory', 'ui-input-handler', 'story-history'];
|
||||
|
||||
// Game state
|
||||
this.gameState = {
|
||||
@@ -30,6 +30,7 @@ class GameLoopModule extends BaseModule {
|
||||
'updateGameState',
|
||||
'updateUIState',
|
||||
'refreshGameApiState',
|
||||
'hasSaveGame',
|
||||
'requestStartGame',
|
||||
'requestSaveGame',
|
||||
'requestLoadGame',
|
||||
@@ -136,7 +137,7 @@ class GameLoopModule extends BaseModule {
|
||||
|
||||
const [running, hasSave] = await Promise.all([
|
||||
socketClient.isGameRunning(),
|
||||
socketClient.hasSaveGame(1)
|
||||
this.hasSaveGame(1)
|
||||
]);
|
||||
|
||||
this.gameState.started = Boolean(running?.result);
|
||||
@@ -191,6 +192,10 @@ class GameLoopModule extends BaseModule {
|
||||
if (!socketClient) return;
|
||||
|
||||
await this.resetClientPlaybackAndDisplay();
|
||||
const storyHistory = this.getModule('story-history');
|
||||
if (storyHistory && typeof storyHistory.startNewGame === 'function') {
|
||||
await storyHistory.startNewGame();
|
||||
}
|
||||
const response = await socketClient.newGame();
|
||||
if (!response?.success) {
|
||||
console.error('GameLoop: newGame failed', response);
|
||||
@@ -211,6 +216,12 @@ class GameLoopModule extends BaseModule {
|
||||
|
||||
const response = await socketClient.saveGame(1);
|
||||
if (response?.success) {
|
||||
const storyHistory = this.getModule('story-history');
|
||||
if (storyHistory && typeof storyHistory.saveSlot === 'function') {
|
||||
await storyHistory.saveSlot(1, {
|
||||
inkState: response.savedState || null
|
||||
});
|
||||
}
|
||||
this.gameState.canLoad = true;
|
||||
this.updateUIState();
|
||||
}
|
||||
@@ -223,7 +234,11 @@ class GameLoopModule extends BaseModule {
|
||||
const socketClient = this.getModule('socket-client');
|
||||
if (!socketClient) return;
|
||||
|
||||
const hasSave = await socketClient.hasSaveGame(1);
|
||||
const storyHistory = this.getModule('story-history');
|
||||
const browserSave = storyHistory && typeof storyHistory.loadSlot === 'function'
|
||||
? await storyHistory.loadSlot(1)
|
||||
: null;
|
||||
const hasSave = browserSave ? { result: true } : await socketClient.hasSaveGame(1);
|
||||
if (!hasSave?.result) {
|
||||
this.gameState.canLoad = false;
|
||||
this.updateUIState();
|
||||
@@ -231,7 +246,14 @@ class GameLoopModule extends BaseModule {
|
||||
}
|
||||
|
||||
await this.resetClientPlaybackAndDisplay();
|
||||
const response = await socketClient.loadGame(1);
|
||||
if (browserSave?.gameId && storyHistory?.setCurrentGame) {
|
||||
storyHistory.setCurrentGame(browserSave.gameId, browserSave.latestBlockId || 0);
|
||||
}
|
||||
const uiController = this.getModule('ui-controller');
|
||||
if (browserSave && uiController?.displayHandler?.restoreFromHistory) {
|
||||
await uiController.displayHandler.restoreFromHistory(browserSave);
|
||||
}
|
||||
const response = await socketClient.loadGame(1, browserSave?.inkState || null);
|
||||
if (response?.success) {
|
||||
this.gameState.started = true;
|
||||
this.gameState.canSave = true;
|
||||
@@ -240,6 +262,16 @@ class GameLoopModule extends BaseModule {
|
||||
}
|
||||
}
|
||||
|
||||
async hasSaveGame(slot = 1) {
|
||||
const storyHistory = this.getModule('story-history');
|
||||
if (storyHistory && typeof storyHistory.hasSaveSlot === 'function') {
|
||||
const hasBrowserSave = await storyHistory.hasSaveSlot(slot);
|
||||
if (hasBrowserSave) return { success: true, result: true, slot };
|
||||
}
|
||||
const socketClient = this.getModule('socket-client');
|
||||
return socketClient?.hasSaveGame ? socketClient.hasSaveGame(slot) : { success: false, result: false };
|
||||
}
|
||||
|
||||
async resetClientPlaybackAndDisplay() {
|
||||
const playbackCoordinator = this.getModule('playback-coordinator');
|
||||
if (playbackCoordinator && typeof playbackCoordinator.stop === 'function') {
|
||||
|
||||
@@ -103,6 +103,7 @@ const ModuleLoader = (function() {
|
||||
// Core functionality modules
|
||||
{ id: 'persistence-manager', script: '/js/persistence-manager-module.js', weight: 12 },
|
||||
{ id: 'localization', script: '/js/localization-module.js', weight: 12 },
|
||||
{ id: 'story-history', script: '/js/story-history-module.js', weight: 8 },
|
||||
{ id: 'game-config', script: '/js/game-config-module.js', weight: 8 },
|
||||
{ id: 'text-processor', script: '/js/text-processor-module.js', weight: 15 },
|
||||
{ id: 'markup-parser', script: '/js/markup-parser-module.js', weight: 5 },
|
||||
|
||||
@@ -21,6 +21,8 @@ class SentenceQueueModule extends BaseModule {
|
||||
this.prefetchingCache = new Map();
|
||||
this.activeImageWrap = null;
|
||||
this.autoplay = true;
|
||||
this.inputMode = 'text';
|
||||
this.lastContinueAt = 0;
|
||||
|
||||
// Bind methods
|
||||
this.bindMethods([
|
||||
@@ -79,6 +81,14 @@ class SentenceQueueModule extends BaseModule {
|
||||
this.autoplay = value !== false;
|
||||
}
|
||||
});
|
||||
this.addEventListener(document, 'story:input-mode', (event) => {
|
||||
this.inputMode = ['text', 'choice', 'end'].includes(event.detail) ? event.detail : 'text';
|
||||
});
|
||||
this.addEventListener(document, 'ui:command', (event) => {
|
||||
if (event.detail?.type === 'continue') {
|
||||
this.lastContinueAt = performance.now();
|
||||
}
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error initializing Sentence Queue:", error);
|
||||
@@ -139,6 +149,7 @@ class SentenceQueueModule extends BaseModule {
|
||||
if (this.onSentenceReadyCallback) {
|
||||
await new Promise(resolve => {
|
||||
sentence.onComplete = resolve;
|
||||
sentence.playbackStartedAt = performance.now();
|
||||
this.onSentenceReadyCallback(sentence, resolve);
|
||||
});
|
||||
}
|
||||
@@ -148,7 +159,7 @@ class SentenceQueueModule extends BaseModule {
|
||||
await this.waitForSkippableMediaPause(mediaPauseSeconds, sentence.kind, sentence.id);
|
||||
}
|
||||
|
||||
if (sentence.kind === 'paragraph' && !this.shouldAutoplay()) {
|
||||
if (this.shouldPauseAfterSentence(sentence)) {
|
||||
await this.waitForManualContinue(sentence.id);
|
||||
}
|
||||
|
||||
@@ -495,6 +506,19 @@ class SentenceQueueModule extends BaseModule {
|
||||
}
|
||||
}
|
||||
|
||||
shouldPauseAfterSentence(sentence) {
|
||||
if (sentence.kind !== 'paragraph' || this.shouldAutoplay()) {
|
||||
return false;
|
||||
}
|
||||
if (this.lastContinueAt >= (sentence.playbackStartedAt || 0)) {
|
||||
return false;
|
||||
}
|
||||
if (this.sentenceQueue.length <= 1 && this.inputMode === 'choice') {
|
||||
return false;
|
||||
}
|
||||
return this.sentenceQueue.length > 1;
|
||||
}
|
||||
|
||||
shouldAutoplay() {
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager && typeof persistenceManager.getPreference === 'function') {
|
||||
|
||||
@@ -131,7 +131,7 @@ class SocketClientModule extends BaseModule {
|
||||
// Create Socket.IO connection (will automatically use /socket.io endpoint)
|
||||
this.socket = window.io(socketUrl, {
|
||||
reconnection: false, // We handle reconnection ourselves
|
||||
transports: ['websocket', 'polling'] // Prefer WebSocket
|
||||
transports: ['polling', 'websocket']
|
||||
});
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
@@ -573,8 +573,8 @@ class SocketClientModule extends BaseModule {
|
||||
return this.callGameApi('newGame', []);
|
||||
}
|
||||
|
||||
loadGame(slot = 1) {
|
||||
return this.callGameApi('loadGame', [slot]);
|
||||
loadGame(slot = 1, savedState = null) {
|
||||
return this.callGameApi('loadGame', savedState ? [slot, savedState] : [slot]);
|
||||
}
|
||||
|
||||
saveGame(slot = 1) {
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Story History Module
|
||||
* Stores rendered story blocks in IndexedDB and keeps only a short live window
|
||||
* in the page DOM.
|
||||
*/
|
||||
import { BaseModule } from './base-module.js';
|
||||
|
||||
class StoryHistoryModule extends BaseModule {
|
||||
constructor() {
|
||||
super('story-history', 'Story History');
|
||||
|
||||
this.dependencies = ['persistence-manager'];
|
||||
this.dbName = 'ttsAudioCacheDB';
|
||||
this.dbVersion = 2;
|
||||
this.historyStore = 'storyHistoryStore';
|
||||
this.saveStore = 'storySaveStore';
|
||||
this.db = null;
|
||||
this.currentGameId = null;
|
||||
this.nextBlockId = 1;
|
||||
this.visibleLimit = 20;
|
||||
|
||||
this.bindMethods([
|
||||
'initialize',
|
||||
'openDB',
|
||||
'startNewGame',
|
||||
'setCurrentGame',
|
||||
'recordBlock',
|
||||
'saveSlot',
|
||||
'loadSlot',
|
||||
'hasSaveSlot',
|
||||
'getSaveSlots',
|
||||
'getBlocks',
|
||||
'clearGame',
|
||||
'tx'
|
||||
]);
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
await this.openDB();
|
||||
this.reportProgress(100, 'Story history ready');
|
||||
return true;
|
||||
}
|
||||
|
||||
openDB() {
|
||||
if (this.db) return Promise.resolve(this.db);
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(this.dbName, this.dbVersion);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
resolve(this.db);
|
||||
};
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(this.historyStore)) {
|
||||
const historyStore = db.createObjectStore(this.historyStore, { keyPath: 'key' });
|
||||
historyStore.createIndex('gameId', 'gameId', { unique: false });
|
||||
historyStore.createIndex('gameOrder', ['gameId', 'blockId'], { unique: true });
|
||||
}
|
||||
if (!db.objectStoreNames.contains(this.saveStore)) {
|
||||
db.createObjectStore(this.saveStore, { keyPath: 'slot' });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
tx(storeName, mode = 'readonly') {
|
||||
return this.db.transaction(storeName, mode).objectStore(storeName);
|
||||
}
|
||||
|
||||
async startNewGame() {
|
||||
const gameId = `game-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
this.currentGameId = gameId;
|
||||
this.nextBlockId = 1;
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
persistenceManager?.updatePreference?.('app', 'currentGameId', gameId);
|
||||
return gameId;
|
||||
}
|
||||
|
||||
setCurrentGame(gameId, latestBlockId = 0) {
|
||||
this.currentGameId = gameId || this.currentGameId;
|
||||
this.nextBlockId = Math.max(1, Number(latestBlockId || 0) + 1);
|
||||
}
|
||||
|
||||
recordBlock(block) {
|
||||
if (!this.db || !this.currentGameId || !block) return Promise.resolve(null);
|
||||
const blockId = this.nextBlockId++;
|
||||
const record = {
|
||||
...block,
|
||||
key: `${this.currentGameId}:${blockId}`,
|
||||
gameId: this.currentGameId,
|
||||
blockId,
|
||||
createdAt: Date.now()
|
||||
};
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = this.tx(this.historyStore, 'readwrite').put(record);
|
||||
request.onsuccess = () => resolve(record);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
saveSlot(slot = 1, saveData = {}) {
|
||||
if (!this.db) return Promise.resolve(false);
|
||||
const record = {
|
||||
slot,
|
||||
...saveData,
|
||||
gameId: saveData.gameId || this.currentGameId,
|
||||
latestBlockId: Math.max(0, this.nextBlockId - 1),
|
||||
savedAt: Date.now()
|
||||
};
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = this.tx(this.saveStore, 'readwrite').put(record);
|
||||
request.onsuccess = () => resolve(record);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
loadSlot(slot = 1) {
|
||||
if (!this.db) return Promise.resolve(null);
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = this.tx(this.saveStore).get(slot);
|
||||
request.onsuccess = () => resolve(request.result || null);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async hasSaveSlot(slot = 1) {
|
||||
return Boolean(await this.loadSlot(slot));
|
||||
}
|
||||
|
||||
getSaveSlots() {
|
||||
if (!this.db) return Promise.resolve([]);
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = this.tx(this.saveStore).getAllKeys();
|
||||
request.onsuccess = () => resolve(request.result || []);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
getBlocks(gameId = this.currentGameId, limit = this.visibleLimit, beforeBlockId = Infinity) {
|
||||
if (!this.db || !gameId) return Promise.resolve([]);
|
||||
return new Promise((resolve, reject) => {
|
||||
const blocks = [];
|
||||
const index = this.tx(this.historyStore).index('gameOrder');
|
||||
const upper = Number.isFinite(beforeBlockId) ? beforeBlockId : Number.MAX_SAFE_INTEGER;
|
||||
const range = IDBKeyRange.bound([gameId, 0], [gameId, upper], false, true);
|
||||
const request = index.openCursor(range, 'prev');
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
if (!cursor || blocks.length >= limit) {
|
||||
resolve(blocks.reverse());
|
||||
return;
|
||||
}
|
||||
blocks.push(cursor.value);
|
||||
cursor.continue();
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
clearGame(gameId = this.currentGameId) {
|
||||
if (!this.db || !gameId) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this.tx(this.historyStore, 'readwrite');
|
||||
const index = store.index('gameId');
|
||||
const request = index.openCursor(IDBKeyRange.only(gameId));
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
if (!cursor) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
cursor.delete();
|
||||
cursor.continue();
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const storyHistory = new StoryHistoryModule();
|
||||
|
||||
export { storyHistory as StoryHistory };
|
||||
|
||||
if (window.moduleRegistry) {
|
||||
window.moduleRegistry.register(storyHistory);
|
||||
}
|
||||
|
||||
window.StoryHistory = storyHistory;
|
||||
@@ -32,7 +32,7 @@ class TTSFactoryModule extends BaseModule {
|
||||
this.db = null; // Will hold the DB connection
|
||||
this.dbName = 'ttsAudioCacheDB';
|
||||
this.storeName = 'audioCacheStore';
|
||||
this.dbVersion = 1;
|
||||
this.dbVersion = 2;
|
||||
this.currentCacheSize = 0; // Track current size in bytes
|
||||
this.maxCacheSizeBytes = 100 * 1024 * 1024; // 100 MB by default
|
||||
this.cacheInitialized = false;
|
||||
@@ -1537,6 +1537,16 @@ class TTSFactoryModule extends BaseModule {
|
||||
console.log("Created 'size' index.");
|
||||
}
|
||||
}
|
||||
if (!db.objectStoreNames.contains('storyHistoryStore')) {
|
||||
const historyStore = db.createObjectStore('storyHistoryStore', { keyPath: 'key' });
|
||||
historyStore.createIndex('gameId', 'gameId', { unique: false });
|
||||
historyStore.createIndex('gameOrder', ['gameId', 'blockId'], { unique: true });
|
||||
console.log("Object store 'storyHistoryStore' created.");
|
||||
}
|
||||
if (!db.objectStoreNames.contains('storySaveStore')) {
|
||||
db.createObjectStore('storySaveStore', { keyPath: 'slot' });
|
||||
console.log("Object store 'storySaveStore' created.");
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
super('ui-display-handler', 'UI Display Handler');
|
||||
|
||||
// Module dependencies
|
||||
this.dependencies = ['layout-renderer', 'playback-coordinator', 'game-config', 'localization'];
|
||||
this.dependencies = ['layout-renderer', 'playback-coordinator', 'game-config', 'localization', 'story-history', 'sentence-queue'];
|
||||
|
||||
// DOM elements
|
||||
this.container = null;
|
||||
@@ -20,6 +20,7 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
this.resizeTimer = null;
|
||||
this.storyResizeObserver = null;
|
||||
this.lastStoryMetrics = null;
|
||||
this.visibleBlockLimit = 20;
|
||||
|
||||
// Resources to preload
|
||||
this.cssPath = '/css/style.css';
|
||||
@@ -35,6 +36,12 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
'applyTranslations',
|
||||
'displayText',
|
||||
'renderSentence',
|
||||
'recordRenderedItem',
|
||||
'trimVisibleBlocks',
|
||||
'restoreFromHistory',
|
||||
'renderStoredItem',
|
||||
'loadPreviousHistoryPage',
|
||||
'updateStoryScrollbar',
|
||||
'handleDeferredMediaBlock',
|
||||
'renderImageBlock',
|
||||
'calculateImageMetrics',
|
||||
@@ -76,6 +83,7 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
this.playbackCoordinator = this.getModule('playback-coordinator');
|
||||
this.gameConfig = this.getModule('game-config');
|
||||
this.localization = this.getModule('localization');
|
||||
this.storyHistory = this.getModule('story-history');
|
||||
|
||||
this.reportProgress(50, "Initializing display containers");
|
||||
|
||||
@@ -97,6 +105,14 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
this.addEventListener(document, 'story:scroll-to-turn', (event) => {
|
||||
this.scrollToTurn(event.detail?.turnId);
|
||||
});
|
||||
this.addEventListener(document, 'story:history-updated', (event) => {
|
||||
this.updateStoryScrollbar(event.detail || {});
|
||||
});
|
||||
this.addEventListener(document, 'wheel', (event) => {
|
||||
if (event.target?.closest?.('#page_right') && event.deltaY < 0 && this.pageRight?.scrollTop <= 2) {
|
||||
this.loadPreviousHistoryPage();
|
||||
}
|
||||
}, { passive: true });
|
||||
this.addEventListener(document, 'story:process-state', (event) => {
|
||||
const state = event.detail?.state || 'ready';
|
||||
const remark = document.getElementById('remark_text');
|
||||
@@ -294,6 +310,12 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
this.pageRight.id = 'page_right';
|
||||
bookContainer.appendChild(this.pageRight);
|
||||
}
|
||||
if (!document.getElementById('story_scrollbar')) {
|
||||
const storyScrollbar = document.createElement('div');
|
||||
storyScrollbar.id = 'story_scrollbar';
|
||||
storyScrollbar.innerHTML = '<div id="story_scrollbar_thumb"></div>';
|
||||
this.pageRight.appendChild(storyScrollbar);
|
||||
}
|
||||
|
||||
// Create or find story container
|
||||
this.container = document.getElementById('story');
|
||||
@@ -478,7 +500,7 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
|
||||
// Store element reference in sentence
|
||||
sentence.element = paragraphElement;
|
||||
this.renderedItems.push({
|
||||
await this.recordRenderedItem({
|
||||
type: sentence.kind === 'heading' ? 'heading' : 'paragraph',
|
||||
id: sentence.id,
|
||||
turnId: sentence.turnId ?? null,
|
||||
@@ -493,6 +515,7 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
paragraphIndex: sentence.paragraphIndex
|
||||
}
|
||||
});
|
||||
await this.trimVisibleBlocks();
|
||||
|
||||
this.scrollStoryToEnd(true);
|
||||
|
||||
@@ -591,6 +614,137 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
});
|
||||
}
|
||||
|
||||
async restoreFromHistory(saveRecord = {}) {
|
||||
if (!this.paragraphContainer || !this.storyHistory || !saveRecord?.gameId) return;
|
||||
const sentenceQueue = this.getModule('sentence-queue');
|
||||
if (!sentenceQueue || typeof sentenceQueue.prepareLayout !== 'function') return;
|
||||
|
||||
const blocks = await this.storyHistory.getBlocks(
|
||||
saveRecord.gameId,
|
||||
this.visibleBlockLimit,
|
||||
(saveRecord.latestBlockId || Number.MAX_SAFE_INTEGER) + 1
|
||||
);
|
||||
this.paragraphContainer.innerHTML = '';
|
||||
this.renderedItems = [];
|
||||
|
||||
for (const item of blocks) {
|
||||
await this.renderStoredItem(item);
|
||||
}
|
||||
|
||||
this.updateStoryScrollbar({ latestBlockId: saveRecord.latestBlockId || blocks.at(-1)?.blockId || 1 });
|
||||
this.scrollStoryToEnd(false);
|
||||
}
|
||||
|
||||
async renderStoredItem(item) {
|
||||
const sentenceQueue = this.getModule('sentence-queue');
|
||||
if (!sentenceQueue) return null;
|
||||
this.renderedItems.push(item);
|
||||
|
||||
if (item.type === 'image') {
|
||||
const imageLayout = typeof sentenceQueue.prepareImageLayout === 'function'
|
||||
? await sentenceQueue.prepareImageLayout(item.metadata || {})
|
||||
: null;
|
||||
const imageElement = this.renderImageBlock({
|
||||
...(item.metadata || {}),
|
||||
imageLayout: imageLayout || item.metadata?.imageLayout
|
||||
}, false);
|
||||
if (imageElement && item.blockId != null) imageElement.dataset.storyBlockId = String(item.blockId);
|
||||
return imageElement;
|
||||
}
|
||||
|
||||
if (item.type !== 'heading' && item.type !== 'paragraph') return null;
|
||||
const layout = await sentenceQueue.prepareLayout(item.text, item.metadata || {});
|
||||
const element = this.layoutRenderer.renderParagraph(layout, { id: item.id });
|
||||
if (item.turnId != null) {
|
||||
element.dataset.turnId = String(item.turnId);
|
||||
element.classList.add('story-turn-block');
|
||||
}
|
||||
if (item.blockId != null) element.dataset.storyBlockId = String(item.blockId);
|
||||
element.querySelectorAll('.word').forEach(word => {
|
||||
word.style.transition = 'none';
|
||||
word.style.animation = 'none';
|
||||
word.style.visibility = 'visible';
|
||||
word.style.opacity = '1';
|
||||
word.style.transform = 'translateY(0)';
|
||||
word.style.clipPath = 'inset(0 0 0 0)';
|
||||
});
|
||||
this.paragraphContainer.appendChild(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
async loadPreviousHistoryPage() {
|
||||
if (!this.storyHistory || !this.paragraphContainer || this.loadingHistoryPage) return;
|
||||
const firstBlock = this.paragraphContainer.querySelector('[data-story-block-id]');
|
||||
const beforeBlockId = Number(firstBlock?.dataset?.storyBlockId || 0);
|
||||
if (!beforeBlockId || beforeBlockId <= 1) return;
|
||||
|
||||
this.loadingHistoryPage = true;
|
||||
try {
|
||||
const blocks = await this.storyHistory.getBlocks(
|
||||
this.storyHistory.currentGameId,
|
||||
this.visibleBlockLimit,
|
||||
beforeBlockId
|
||||
);
|
||||
if (!blocks.length) return;
|
||||
this.paragraphContainer.innerHTML = '';
|
||||
this.renderedItems = [];
|
||||
for (const item of blocks) {
|
||||
await this.renderStoredItem(item);
|
||||
}
|
||||
this.pageRight.scrollTop = 0;
|
||||
this.updateStoryScrollbar({ latestBlockId: this.storyHistory.nextBlockId - 1 });
|
||||
} finally {
|
||||
this.loadingHistoryPage = false;
|
||||
}
|
||||
}
|
||||
|
||||
async recordRenderedItem(item) {
|
||||
this.renderedItems.push(item);
|
||||
if (this.storyHistory && typeof this.storyHistory.recordBlock === 'function') {
|
||||
try {
|
||||
const record = await this.storyHistory.recordBlock(item);
|
||||
if (record && item.id) {
|
||||
item.blockId = record.blockId;
|
||||
item.gameId = record.gameId;
|
||||
const element = document.getElementById(item.id);
|
||||
if (element) element.dataset.storyBlockId = String(record.blockId);
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent('story:history-updated', {
|
||||
detail: {
|
||||
gameId: record?.gameId || null,
|
||||
latestBlockId: record?.blockId || null
|
||||
}
|
||||
}));
|
||||
} catch (error) {
|
||||
console.warn('UIDisplayHandler: Failed to store story history item:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateStoryScrollbar(detail = {}) {
|
||||
const thumb = document.getElementById('story_scrollbar_thumb');
|
||||
if (!thumb) return;
|
||||
const latest = Math.max(1, Number(detail.latestBlockId || this.storyHistory?.nextBlockId || 1));
|
||||
const visible = Math.min(this.visibleBlockLimit, latest);
|
||||
const heightPercent = Math.max(8, Math.min(100, (visible / latest) * 100));
|
||||
const topPercent = latest <= visible ? 0 : 100 - heightPercent;
|
||||
thumb.style.height = `${heightPercent}%`;
|
||||
thumb.style.top = `${topPercent}%`;
|
||||
}
|
||||
|
||||
async trimVisibleBlocks() {
|
||||
if (!this.paragraphContainer) return;
|
||||
const blocks = Array.from(this.paragraphContainer.querySelectorAll('.story-turn-block'));
|
||||
const excess = blocks.length - this.visibleBlockLimit;
|
||||
if (excess <= 0) return;
|
||||
|
||||
blocks.slice(0, excess).forEach(block => {
|
||||
block.classList.add('story-block-archiving');
|
||||
window.setTimeout(() => block.remove(), 360);
|
||||
});
|
||||
this.renderedItems = this.renderedItems.slice(Math.max(0, this.renderedItems.length - this.visibleBlockLimit));
|
||||
}
|
||||
|
||||
animatePageScroll(targetTop, duration = 720) {
|
||||
if (!this.pageRight) return;
|
||||
if (!duration) {
|
||||
@@ -662,14 +816,15 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
}));
|
||||
|
||||
if (sentence.kind === 'image') {
|
||||
const element = this.renderImageBlock(sentence.metadata || {}, true);
|
||||
this.renderedItems.push({
|
||||
const element = this.renderImageBlock({ ...(sentence.metadata || {}), id: sentence.id }, true);
|
||||
await this.recordRenderedItem({
|
||||
type: 'image',
|
||||
id: sentence.id,
|
||||
turnId: sentence.turnId ?? null,
|
||||
text: '',
|
||||
metadata: sentence.metadata || {}
|
||||
metadata: { ...(sentence.metadata || {}), id: sentence.id }
|
||||
});
|
||||
await this.trimVisibleBlocks();
|
||||
|
||||
this.scrollStoryToEnd(true);
|
||||
|
||||
@@ -743,6 +898,9 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
|
||||
const metrics = metadata.imageLayout || this.calculateImageMetrics(metadata.size);
|
||||
const figure = document.createElement('figure');
|
||||
if (metadata.id) {
|
||||
figure.id = metadata.id;
|
||||
}
|
||||
figure.className = [
|
||||
'story-image-block',
|
||||
`story-image-${metrics.size || 'landscape'}`,
|
||||
|
||||
Reference in New Issue
Block a user