Add storage-backed story history

This commit is contained in:
2026-05-15 21:58:30 +02:00
parent f2e786d5bc
commit 42582352d6
16 changed files with 1048 additions and 113 deletions
+163 -5
View File
@@ -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'}`,