Checkpoint before line-grid scrolling refactor
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
/**
|
||||
* Story History Module
|
||||
* Stores rendered story blocks in IndexedDB and keeps only a short live window
|
||||
* in the page DOM.
|
||||
* Stores received story output blocks in IndexedDB and tracks the render playhead.
|
||||
*/
|
||||
import { BaseModule } from './base-module.js';
|
||||
|
||||
@@ -11,12 +10,14 @@ class StoryHistoryModule extends BaseModule {
|
||||
|
||||
this.dependencies = ['persistence-manager'];
|
||||
this.dbName = 'ttsAudioCacheDB';
|
||||
this.dbVersion = 2;
|
||||
this.dbVersion = 3;
|
||||
this.historyStore = 'storyHistoryStore';
|
||||
this.saveStore = 'storySaveStore';
|
||||
this.db = null;
|
||||
this.currentGameId = null;
|
||||
this.nextBlockId = 1;
|
||||
this.latestRenderedBlockId = 0;
|
||||
this.renderedLineCount = 0;
|
||||
this.visibleLimit = 20;
|
||||
|
||||
this.bindMethods([
|
||||
@@ -25,11 +26,18 @@ class StoryHistoryModule extends BaseModule {
|
||||
'startNewGame',
|
||||
'setCurrentGame',
|
||||
'recordBlock',
|
||||
'recordBlocks',
|
||||
'markRendered',
|
||||
'updateBlockMetrics',
|
||||
'saveSlot',
|
||||
'loadSlot',
|
||||
'hasSaveSlot',
|
||||
'getSaveSlots',
|
||||
'getBlocks',
|
||||
'getBlocksRange',
|
||||
'getWindowForTurn',
|
||||
'getRenderedLineCount',
|
||||
'findBlockForLine',
|
||||
'clearGame',
|
||||
'tx'
|
||||
]);
|
||||
@@ -52,6 +60,11 @@ class StoryHistoryModule extends BaseModule {
|
||||
};
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains('audioCacheStore')) {
|
||||
const audioStore = db.createObjectStore('audioCacheStore', { keyPath: 'hash' });
|
||||
audioStore.createIndex('lastAccessed', 'lastAccessed', { unique: false });
|
||||
audioStore.createIndex('size', 'size', { unique: false });
|
||||
}
|
||||
if (!db.objectStoreNames.contains(this.historyStore)) {
|
||||
const historyStore = db.createObjectStore(this.historyStore, { keyPath: 'key' });
|
||||
historyStore.createIndex('gameId', 'gameId', { unique: false });
|
||||
@@ -72,14 +85,18 @@ class StoryHistoryModule extends BaseModule {
|
||||
const gameId = `game-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
this.currentGameId = gameId;
|
||||
this.nextBlockId = 1;
|
||||
this.latestRenderedBlockId = 0;
|
||||
this.renderedLineCount = 0;
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
persistenceManager?.updatePreference?.('app', 'currentGameId', gameId);
|
||||
return gameId;
|
||||
}
|
||||
|
||||
setCurrentGame(gameId, latestBlockId = 0) {
|
||||
setCurrentGame(gameId, latestBlockId = 0, latestRenderedBlockId = 0, renderedLineCount = 0) {
|
||||
this.currentGameId = gameId || this.currentGameId;
|
||||
this.nextBlockId = Math.max(1, Number(latestBlockId || 0) + 1);
|
||||
this.latestRenderedBlockId = Math.max(0, Number(latestRenderedBlockId || 0));
|
||||
this.renderedLineCount = Math.max(0, Number(renderedLineCount || 0));
|
||||
}
|
||||
|
||||
recordBlock(block) {
|
||||
@@ -99,6 +116,66 @@ class StoryHistoryModule extends BaseModule {
|
||||
});
|
||||
}
|
||||
|
||||
async recordBlocks(blocks = []) {
|
||||
if (!Array.isArray(blocks) || blocks.length === 0) return [];
|
||||
const records = [];
|
||||
for (const block of blocks) {
|
||||
const record = await this.recordBlock(block);
|
||||
if (record) records.push(record);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
markRendered(blockId) {
|
||||
const rendered = Math.max(0, Number(blockId || 0));
|
||||
if (rendered > this.latestRenderedBlockId) {
|
||||
this.latestRenderedBlockId = rendered;
|
||||
}
|
||||
return this.latestRenderedBlockId;
|
||||
}
|
||||
|
||||
async updateBlockMetrics(blockId, metrics = {}) {
|
||||
if (!this.db || !this.currentGameId || blockId == null) return null;
|
||||
const id = Math.max(1, Number(blockId || 1));
|
||||
const key = `${this.currentGameId}:${id}`;
|
||||
const record = await new Promise((resolve, reject) => {
|
||||
const request = this.tx(this.historyStore).get(key);
|
||||
request.onsuccess = () => resolve(request.result || null);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
if (!record) return null;
|
||||
|
||||
const lineCount = Math.max(1, Number(metrics.lineCount || record.lineCount || 1));
|
||||
const previousLineCount = Number(record.lineCount || 0);
|
||||
const hadLineStart = Number.isFinite(Number(record.lineStart));
|
||||
const lineStart = hadLineStart
|
||||
? Math.max(0, Number(record.lineStart))
|
||||
: this.renderedLineCount;
|
||||
|
||||
const updated = {
|
||||
...record,
|
||||
lineStart,
|
||||
lineCount,
|
||||
heightPx: Math.max(0, Number(metrics.heightPx || record.heightPx || 0)),
|
||||
measuredLineHeightPx: Math.max(0, Number(metrics.lineHeightPx || record.measuredLineHeightPx || 0)),
|
||||
metricsUpdatedAt: Date.now()
|
||||
};
|
||||
|
||||
if (!hadLineStart) {
|
||||
this.renderedLineCount = Math.max(this.renderedLineCount, lineStart + lineCount);
|
||||
} else if (lineStart + previousLineCount >= this.renderedLineCount) {
|
||||
this.renderedLineCount = Math.max(lineStart + lineCount, this.renderedLineCount + (lineCount - previousLineCount));
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const request = this.tx(this.historyStore, 'readwrite').put(updated);
|
||||
request.onsuccess = () => resolve(true);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
saveSlot(slot = 1, saveData = {}) {
|
||||
if (!this.db) return Promise.resolve(false);
|
||||
const record = {
|
||||
@@ -106,6 +183,8 @@ class StoryHistoryModule extends BaseModule {
|
||||
...saveData,
|
||||
gameId: saveData.gameId || this.currentGameId,
|
||||
latestBlockId: Math.max(0, this.nextBlockId - 1),
|
||||
latestRenderedBlockId: Math.max(0, Number(saveData.latestRenderedBlockId ?? this.latestRenderedBlockId ?? 0)),
|
||||
renderedLineCount: Math.max(0, Number(saveData.renderedLineCount ?? this.renderedLineCount ?? 0)),
|
||||
savedAt: Date.now()
|
||||
};
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -158,6 +237,86 @@ class StoryHistoryModule extends BaseModule {
|
||||
});
|
||||
}
|
||||
|
||||
getBlocksRange(gameId = this.currentGameId, startBlockId = 1, endBlockId = Infinity) {
|
||||
if (!this.db || !gameId) return Promise.resolve([]);
|
||||
const start = Math.max(1, Number(startBlockId || 1));
|
||||
const end = Number.isFinite(endBlockId) ? Number(endBlockId) : Number.MAX_SAFE_INTEGER;
|
||||
if (end < start) return Promise.resolve([]);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const blocks = [];
|
||||
const index = this.tx(this.historyStore).index('gameOrder');
|
||||
const range = IDBKeyRange.bound([gameId, start], [gameId, end]);
|
||||
const request = index.openCursor(range, 'next');
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
if (!cursor) {
|
||||
resolve(blocks);
|
||||
return;
|
||||
}
|
||||
blocks.push(cursor.value);
|
||||
cursor.continue();
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async getWindowForTurn(gameId = this.currentGameId, turnId, visibleLimit = this.visibleLimit) {
|
||||
if (!this.db || !gameId || turnId == null) return { blocks: [], targetBlockId: null };
|
||||
const target = await new Promise((resolve, reject) => {
|
||||
const index = this.tx(this.historyStore).index('gameId');
|
||||
const request = index.openCursor(IDBKeyRange.only(gameId), 'next');
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
if (!cursor) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
if (String(cursor.value?.turnId) === String(turnId)) {
|
||||
resolve(cursor.value);
|
||||
return;
|
||||
}
|
||||
cursor.continue();
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
if (!target?.blockId) return { blocks: [], targetBlockId: null };
|
||||
const latest = Math.max(0, this.nextBlockId - 1);
|
||||
const limit = Math.max(1, Number(visibleLimit || this.visibleLimit));
|
||||
const halfBefore = Math.floor(limit / 2);
|
||||
const maxStart = Math.max(1, latest - limit + 1);
|
||||
const start = Math.max(1, Math.min(maxStart, target.blockId - halfBefore));
|
||||
const blocks = await this.getBlocksRange(gameId, start, Math.min(latest, start + limit - 1));
|
||||
return { blocks, targetBlockId: target.blockId };
|
||||
}
|
||||
|
||||
async getRenderedLineCount(gameId = this.currentGameId, latestRenderedBlockId = this.latestRenderedBlockId) {
|
||||
const latest = Math.max(0, Number(latestRenderedBlockId || 0));
|
||||
if (!this.db || !gameId || latest <= 0) return 0;
|
||||
const blocks = await this.getBlocksRange(gameId, 1, latest);
|
||||
const measured = blocks.reduce((max, block) => {
|
||||
const start = Number(block.lineStart);
|
||||
const count = Number(block.lineCount);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(count)) return max;
|
||||
return Math.max(max, start + Math.max(1, count));
|
||||
}, 0);
|
||||
this.renderedLineCount = Math.max(this.renderedLineCount, measured);
|
||||
return this.renderedLineCount;
|
||||
}
|
||||
|
||||
async findBlockForLine(gameId = this.currentGameId, line = 0, latestRenderedBlockId = this.latestRenderedBlockId) {
|
||||
const latest = Math.max(0, Number(latestRenderedBlockId || 0));
|
||||
if (!this.db || !gameId || latest <= 0) return null;
|
||||
const targetLine = Math.max(0, Number(line || 0));
|
||||
const blocks = await this.getBlocksRange(gameId, 1, latest);
|
||||
return blocks.find((block) => {
|
||||
const start = Number(block.lineStart);
|
||||
const count = Math.max(1, Number(block.lineCount || 1));
|
||||
return Number.isFinite(start) && targetLine >= start && targetLine < start + count;
|
||||
}) || blocks.at(-1) || null;
|
||||
}
|
||||
|
||||
clearGame(gameId = this.currentGameId) {
|
||||
if (!this.db || !gameId) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
Reference in New Issue
Block a user