Add storage-backed story history
This commit is contained in:
@@ -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