Stabilize WebGL book pagination restore
This commit is contained in:
@@ -19,11 +19,16 @@ class BookPaginationModule extends BaseModule {
|
|||||||
this.latestBlockId = 0;
|
this.latestBlockId = 0;
|
||||||
this.latestRenderedBlockId = 0;
|
this.latestRenderedBlockId = 0;
|
||||||
this.appliedPageReserveBlocks = new Set();
|
this.appliedPageReserveBlocks = new Set();
|
||||||
|
this.preparedBlockCache = new Map();
|
||||||
|
|
||||||
this.bindMethods([
|
this.bindMethods([
|
||||||
'initialize',
|
'initialize',
|
||||||
'refreshFromHistory',
|
'refreshFromHistory',
|
||||||
'preparePendingBlock',
|
'preparePendingBlock',
|
||||||
|
'getPreparedBlockCacheKey',
|
||||||
|
'rememberPreparedBlock',
|
||||||
|
'takePreparedBlock',
|
||||||
|
'clearPreparedBlocks',
|
||||||
'buildSpreads',
|
'buildSpreads',
|
||||||
'buildPages',
|
'buildPages',
|
||||||
'buildSpreadsFromPages',
|
'buildSpreadsFromPages',
|
||||||
@@ -86,6 +91,10 @@ class BookPaginationModule extends BaseModule {
|
|||||||
this.setCurrentSpread(this.currentSpreadIndex + direction);
|
this.setCurrentSpread(this.currentSpreadIndex + direction);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
this.pages = this.buildPages([]);
|
||||||
|
this.spreads = this.buildSpreadsFromPages(this.pages);
|
||||||
|
this.currentSpreadIndex = 0;
|
||||||
|
this.publish({ reason: 'initial-title-spread', allowFutureUnrendered: true });
|
||||||
this.reportProgress(100, 'Book pagination ready');
|
this.reportProgress(100, 'Book pagination ready');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -93,11 +102,13 @@ class BookPaginationModule extends BaseModule {
|
|||||||
handlePageCountChanged(event) {
|
handlePageCountChanged(event) {
|
||||||
this.pageFormat?.setPageCount?.(event.detail?.pageCount);
|
this.pageFormat?.setPageCount?.(event.detail?.pageCount);
|
||||||
this.metrics = this.pageFormat.getTextureMetrics(this.pageFormat.getTextureWidth?.());
|
this.metrics = this.pageFormat.getTextureMetrics(this.pageFormat.getTextureWidth?.());
|
||||||
|
this.clearPreparedBlocks();
|
||||||
this.refreshFromHistory();
|
this.refreshFromHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshFromHistory(event = null) {
|
async refreshFromHistory(event = null) {
|
||||||
const token = ++this.refreshToken;
|
const token = ++this.refreshToken;
|
||||||
|
this.clearPreparedBlocks();
|
||||||
const detail = event?.detail || {};
|
const detail = event?.detail || {};
|
||||||
const gameId = detail.gameId || this.storyHistory?.currentGameId || null;
|
const gameId = detail.gameId || this.storyHistory?.currentGameId || null;
|
||||||
const latestRenderedBlockId = Math.max(
|
const latestRenderedBlockId = Math.max(
|
||||||
@@ -117,7 +128,7 @@ class BookPaginationModule extends BaseModule {
|
|||||||
this.latestRenderedBlockId = 0;
|
this.latestRenderedBlockId = 0;
|
||||||
this.currentSpreadIndex = 0;
|
this.currentSpreadIndex = 0;
|
||||||
this.appliedPageReserveBlocks.clear();
|
this.appliedPageReserveBlocks.clear();
|
||||||
this.publish();
|
this.publish({ reason: 'empty-history' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +146,7 @@ class BookPaginationModule extends BaseModule {
|
|||||||
: renderedSpreadIndex >= 0
|
: renderedSpreadIndex >= 0
|
||||||
? renderedSpreadIndex
|
? renderedSpreadIndex
|
||||||
: Math.max(0, Math.min(this.currentSpreadIndex, Math.max(0, this.spreads.length - 1)));
|
: Math.max(0, Math.min(this.currentSpreadIndex, Math.max(0, this.spreads.length - 1)));
|
||||||
this.publish();
|
this.publish({ reason: 'history-refresh', allowFutureUnrendered: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
getContinuationBlockId(latestBlockId = 0, latestRenderedBlockId = 0) {
|
getContinuationBlockId(latestBlockId = 0, latestRenderedBlockId = 0) {
|
||||||
@@ -158,6 +169,31 @@ class BookPaginationModule extends BaseModule {
|
|||||||
const historyEndBlockId = options.includeUnrenderedHistory
|
const historyEndBlockId = options.includeUnrenderedHistory
|
||||||
? Math.max(0, pendingBlockId - 1)
|
? Math.max(0, pendingBlockId - 1)
|
||||||
: latestRenderedBlockId;
|
: latestRenderedBlockId;
|
||||||
|
const cacheKey = this.getPreparedBlockCacheKey(gameId, pendingBlockId, historyEndBlockId, latestRenderedBlockId, options);
|
||||||
|
const cached = options.activate !== false ? this.takePreparedBlock(cacheKey) : null;
|
||||||
|
if (cached) {
|
||||||
|
this.latestBlockId = pendingBlockId;
|
||||||
|
this.latestRenderedBlockId = latestRenderedBlockId;
|
||||||
|
this.pages = cached.pages;
|
||||||
|
this.spreads = cached.spreads;
|
||||||
|
this.currentSpreadIndex = cached.targetSpread
|
||||||
|
? cached.targetSpread.index
|
||||||
|
: Math.max(0, Math.min(this.currentSpreadIndex, Math.max(0, this.spreads.length - 1)));
|
||||||
|
if (options.publish !== false) this.publish({ reason: 'prepared-cache-activate' });
|
||||||
|
document.dispatchEvent(new CustomEvent('book-pagination:block-prepared', {
|
||||||
|
detail: {
|
||||||
|
blockId: pendingBlockId,
|
||||||
|
spread: cached.targetSpread || this.getCurrentSpread(),
|
||||||
|
spreadIndex: cached.targetSpread?.index ?? this.currentSpreadIndex,
|
||||||
|
latestBlockId: pendingBlockId,
|
||||||
|
latestRenderedBlockId,
|
||||||
|
preloadOnly: false,
|
||||||
|
reusedPreparedPagination: true
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return cached.targetSpread || this.getCurrentSpread();
|
||||||
|
}
|
||||||
|
|
||||||
const historyBlocks = historyEndBlockId > 0
|
const historyBlocks = historyEndBlockId > 0
|
||||||
? await this.storyHistory.getBlocksRange(gameId, 1, historyEndBlockId)
|
? await this.storyHistory.getBlocksRange(gameId, 1, historyEndBlockId)
|
||||||
: [];
|
: [];
|
||||||
@@ -181,6 +217,13 @@ class BookPaginationModule extends BaseModule {
|
|||||||
const lines = Array.isArray(spread?.[side]) ? spread[side] : [];
|
const lines = Array.isArray(spread?.[side]) ? spread[side] : [];
|
||||||
return lines.some(line => Number(line?.blockId || 0) === pendingBlockId);
|
return lines.some(line => Number(line?.blockId || 0) === pendingBlockId);
|
||||||
}));
|
}));
|
||||||
|
if (options.activate === false) {
|
||||||
|
this.rememberPreparedBlock(cacheKey, {
|
||||||
|
pages: preparedPages,
|
||||||
|
spreads: preparedSpreads,
|
||||||
|
targetSpread: targetSpread || null
|
||||||
|
});
|
||||||
|
}
|
||||||
if (options.activate !== false) {
|
if (options.activate !== false) {
|
||||||
this.latestBlockId = pendingBlockId;
|
this.latestBlockId = pendingBlockId;
|
||||||
this.latestRenderedBlockId = latestRenderedBlockId;
|
this.latestRenderedBlockId = latestRenderedBlockId;
|
||||||
@@ -189,7 +232,7 @@ class BookPaginationModule extends BaseModule {
|
|||||||
this.currentSpreadIndex = Math.max(0, Math.min(this.spreads.length - 1, this.currentSpreadIndex));
|
this.currentSpreadIndex = Math.max(0, Math.min(this.spreads.length - 1, this.currentSpreadIndex));
|
||||||
if (targetSpread) this.currentSpreadIndex = targetSpread.index;
|
if (targetSpread) this.currentSpreadIndex = targetSpread.index;
|
||||||
}
|
}
|
||||||
if (options.publish !== false) this.publish();
|
if (options.publish !== false) this.publish({ reason: options.activate === false ? 'prepare-preload' : 'prepare-activate' });
|
||||||
document.dispatchEvent(new CustomEvent('book-pagination:block-prepared', {
|
document.dispatchEvent(new CustomEvent('book-pagination:block-prepared', {
|
||||||
detail: {
|
detail: {
|
||||||
blockId: pendingBlockId,
|
blockId: pendingBlockId,
|
||||||
@@ -203,6 +246,39 @@ class BookPaginationModule extends BaseModule {
|
|||||||
return targetSpread || (options.activate === false ? preparedSpreads[0] : this.getCurrentSpread());
|
return targetSpread || (options.activate === false ? preparedSpreads[0] : this.getCurrentSpread());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getPreparedBlockCacheKey(gameId, blockId, historyEndBlockId, latestRenderedBlockId, options = {}) {
|
||||||
|
const includeUnrendered = options.includeUnrenderedHistory === true ? 'unrendered' : 'rendered';
|
||||||
|
return [
|
||||||
|
gameId || 'game',
|
||||||
|
Math.max(0, Number(blockId || 0)),
|
||||||
|
Math.max(0, Number(historyEndBlockId || 0)),
|
||||||
|
Math.max(0, Number(latestRenderedBlockId || 0)),
|
||||||
|
includeUnrendered,
|
||||||
|
this.metrics?.width || 0,
|
||||||
|
this.metrics?.height || 0
|
||||||
|
].join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
rememberPreparedBlock(key, prepared) {
|
||||||
|
if (!key || !prepared?.pages || !prepared?.spreads) return;
|
||||||
|
this.preparedBlockCache.set(key, prepared);
|
||||||
|
while (this.preparedBlockCache.size > 12) {
|
||||||
|
const oldestKey = this.preparedBlockCache.keys().next().value;
|
||||||
|
this.preparedBlockCache.delete(oldestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
takePreparedBlock(key) {
|
||||||
|
if (!key || !this.preparedBlockCache.has(key)) return null;
|
||||||
|
const prepared = this.preparedBlockCache.get(key);
|
||||||
|
this.preparedBlockCache.delete(key);
|
||||||
|
return prepared;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPreparedBlocks() {
|
||||||
|
this.preparedBlockCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
buildSpreads(blocks = []) {
|
buildSpreads(blocks = []) {
|
||||||
this.pages = this.buildPages(blocks);
|
this.pages = this.buildPages(blocks);
|
||||||
return this.buildSpreadsFromPages(this.pages);
|
return this.buildSpreadsFromPages(this.pages);
|
||||||
@@ -891,11 +967,11 @@ class BookPaginationModule extends BaseModule {
|
|||||||
|
|
||||||
setCurrentSpread(index = 0) {
|
setCurrentSpread(index = 0) {
|
||||||
this.currentSpreadIndex = Math.max(0, Math.min(Math.round(Number(index || 0)), Math.max(0, this.spreads.length - 1)));
|
this.currentSpreadIndex = Math.max(0, Math.min(Math.round(Number(index || 0)), Math.max(0, this.spreads.length - 1)));
|
||||||
this.publish();
|
this.publish({ reason: 'set-current-spread', allowFutureUnrendered: true });
|
||||||
return this.currentSpreadIndex;
|
return this.currentSpreadIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
publish() {
|
publish(options = {}) {
|
||||||
const writtenPageLimit = Math.max(0, (Math.max(0, this.spreads.length - 1) * 2) - 1);
|
const writtenPageLimit = Math.max(0, (Math.max(0, this.spreads.length - 1) * 2) - 1);
|
||||||
document.dispatchEvent(new CustomEvent('book-pagination:spread-updated', {
|
document.dispatchEvent(new CustomEvent('book-pagination:spread-updated', {
|
||||||
detail: {
|
detail: {
|
||||||
@@ -904,7 +980,9 @@ class BookPaginationModule extends BaseModule {
|
|||||||
spreadCount: this.spreads.length,
|
spreadCount: this.spreads.length,
|
||||||
writtenPageLimit,
|
writtenPageLimit,
|
||||||
latestBlockId: this.latestBlockId,
|
latestBlockId: this.latestBlockId,
|
||||||
latestRenderedBlockId: this.latestRenderedBlockId
|
latestRenderedBlockId: this.latestRenderedBlockId,
|
||||||
|
reason: options.reason || 'publish',
|
||||||
|
allowFutureUnrendered: options.allowFutureUnrendered === true
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,14 +117,25 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
this.addEventListener(document, 'webgl-book:page-count-changed', this.handlePageCountChanged);
|
this.addEventListener(document, 'webgl-book:page-count-changed', this.handlePageCountChanged);
|
||||||
this.addEventListener(document, 'book-pagination:spread-updated', (event) => {
|
this.addEventListener(document, 'book-pagination:spread-updated', (event) => {
|
||||||
const spread = event.detail?.spread || this.pagination?.getCurrentSpread?.();
|
const spread = event.detail?.spread || this.pagination?.getCurrentSpread?.();
|
||||||
|
const spreadIndex = Math.max(0, Number(event.detail?.spreadIndex ?? spread?.index ?? 0));
|
||||||
const latestBlockId = event.detail?.latestBlockId;
|
const latestBlockId = event.detail?.latestBlockId;
|
||||||
const latestRenderedBlockId = Math.max(0, Number(event.detail?.latestRenderedBlockId || 0));
|
const latestRenderedBlockId = Math.max(0, Number(event.detail?.latestRenderedBlockId || 0));
|
||||||
this.currentSpread = spread || { left: [], right: [] };
|
this.currentSpread = spread || { left: [], right: [] };
|
||||||
if (latestBlockId && Number(latestBlockId) > latestRenderedBlockId) {
|
if (latestBlockId && Number(latestBlockId) > latestRenderedBlockId) {
|
||||||
this.markPendingReveal(latestBlockId);
|
this.markPendingReveal(latestBlockId);
|
||||||
const id = String(latestBlockId);
|
const id = String(latestBlockId);
|
||||||
|
if (event.detail?.allowFutureUnrendered === true && !this.activeAnimations.has(id)) {
|
||||||
|
this.drawSpread(this.stripUnrenderedLines(this.currentSpread, latestRenderedBlockId), ['left', 'right']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.activeAnimations.has(id)) {
|
if (this.activeAnimations.has(id)) {
|
||||||
this.revealPublishBlockIds = new Set([id]);
|
this.revealPublishBlockIds = new Set([id]);
|
||||||
|
const visibleSpread = Math.max(0, Number(window.BookLabDebug?.getBookState?.().spreadIndex || 0));
|
||||||
|
const flipActive = document.documentElement.dataset.webglPageFlipActive === 'true';
|
||||||
|
if (!flipActive && event.detail?.allowFutureUnrendered !== true && spreadIndex > visibleSpread) {
|
||||||
|
this.drawSpread(this.currentSpread, ['left', 'right'], { preloadOnly: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.drawSpread(this.currentSpread, ['left', 'right']);
|
this.drawSpread(this.currentSpread, ['left', 'right']);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -143,10 +154,24 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
});
|
});
|
||||||
this.addEventListener(document, 'story:manual-scroll', this.fastForwardAnimations);
|
this.addEventListener(document, 'story:manual-scroll', this.fastForwardAnimations);
|
||||||
this.addEventListener(document, 'story:history-restoring', this.stopAnimations);
|
this.addEventListener(document, 'story:history-restoring', this.stopAnimations);
|
||||||
|
this.currentSpread = this.pagination?.getCurrentSpread?.() || { index: 0, left: [], right: [], pageMeta: { left: null, right: null } };
|
||||||
|
this.drawSpread(this.currentSpread);
|
||||||
this.reportProgress(100, 'Book texture renderer ready');
|
this.reportProgress(100, 'Book texture renderer ready');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stripUnrenderedLines(spread = {}, latestRenderedBlockId = 0) {
|
||||||
|
const latestRendered = Math.max(0, Number(latestRenderedBlockId || 0));
|
||||||
|
return {
|
||||||
|
...spread,
|
||||||
|
left: (Array.isArray(spread.left) ? spread.left : [])
|
||||||
|
.filter(line => Math.max(0, Number(line?.blockId || 0)) <= latestRendered),
|
||||||
|
right: (Array.isArray(spread.right) ? spread.right : [])
|
||||||
|
.filter(line => Math.max(0, Number(line?.blockId || 0)) <= latestRendered),
|
||||||
|
pageMeta: spread.pageMeta || { left: null, right: null }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
markPipelineTiming(name, detail = {}) {
|
markPipelineTiming(name, detail = {}) {
|
||||||
const entry = {
|
const entry = {
|
||||||
name,
|
name,
|
||||||
@@ -602,7 +627,8 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
if (!animation || animation.completed) return;
|
if (!animation || animation.completed) return;
|
||||||
regions.push(...this.assignRevealTiming(blockRegions, animation));
|
regions.push(...this.assignRevealTiming(blockRegions, animation));
|
||||||
});
|
});
|
||||||
const sideRegions = regions.filter(region => region.side === side);
|
const currentSpreadIndex = Math.max(0, Number(this.currentSpread?.index ?? this.pagination?.currentSpreadIndex ?? 0));
|
||||||
|
const sideRegions = regions.filter(region => region.side === side && Math.max(0, Number(region.spreadIndex || 0)) === currentSpreadIndex);
|
||||||
if (!sideRegions.length) return null;
|
if (!sideRegions.length) return null;
|
||||||
const bounds = sideRegions.reduce((box, region) => ({
|
const bounds = sideRegions.reduce((box, region) => ({
|
||||||
x: Math.min(box.x, region.pixelRect.x),
|
x: Math.min(box.x, region.pixelRect.x),
|
||||||
@@ -636,26 +662,30 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
|
|
||||||
collectRevealRegionCandidates() {
|
collectRevealRegionCandidates() {
|
||||||
const candidates = [];
|
const candidates = [];
|
||||||
|
const sourceSpreads = Array.isArray(this.pagination?.spreads) && this.pagination.spreads.length
|
||||||
|
? this.pagination.spreads
|
||||||
|
: [this.currentSpread || { index: 0, left: [], right: [] }];
|
||||||
|
sourceSpreads.forEach((spread) => {
|
||||||
['left', 'right'].forEach((side) => {
|
['left', 'right'].forEach((side) => {
|
||||||
const spreadLines = Array.isArray(this.currentSpread?.[side]) ? this.currentSpread[side] : [];
|
const spreadLines = Array.isArray(spread?.[side]) ? spread[side] : [];
|
||||||
spreadLines.forEach((lineRecord) => {
|
spreadLines.forEach((lineRecord) => {
|
||||||
const region = this.createRevealRegionForLine(side, lineRecord);
|
const region = this.createRevealRegionForLine(side, lineRecord, spread?.index);
|
||||||
if (region) candidates.push(region);
|
if (region) candidates.push(region);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
return candidates;
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
assignRevealTiming(blockRegions = [], animation = {}) {
|
assignRevealTiming(blockRegions = [], animation = {}) {
|
||||||
const wordTimings = Array.isArray(animation.wordTimings) ? animation.wordTimings : [];
|
|
||||||
const totalDuration = Math.max(
|
const totalDuration = Math.max(
|
||||||
Number(animation.totalDuration || 0),
|
Number(animation.totalDuration || 0),
|
||||||
...wordTimings.map(timing => Number(timing.delay || 0) + Number(timing.duration || 0))
|
...((Array.isArray(animation.wordTimings) ? animation.wordTimings : []).map(timing => Number(timing.delay || 0) + Number(timing.duration || 0)))
|
||||||
);
|
);
|
||||||
const sortedRegions = [...blockRegions].sort((a, b) => {
|
const sortedRegions = [...blockRegions].sort((a, b) => {
|
||||||
const aStart = Math.max(0, Number(a.wordStart || 0));
|
const aSpread = Math.max(0, Number(a.spreadIndex || 0));
|
||||||
const bStart = Math.max(0, Number(b.wordStart || 0));
|
const bSpread = Math.max(0, Number(b.spreadIndex || 0));
|
||||||
if (aStart !== bStart) return aStart - bStart;
|
if (aSpread !== bSpread) return aSpread - bSpread;
|
||||||
const aLine = Math.max(0, Number(a.lineIndex || 0));
|
const aLine = Math.max(0, Number(a.lineIndex || 0));
|
||||||
const bLine = Math.max(0, Number(b.lineIndex || 0));
|
const bLine = Math.max(0, Number(b.lineIndex || 0));
|
||||||
return aLine - bLine;
|
return aLine - bLine;
|
||||||
@@ -667,25 +697,14 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
const totalArea = textRegions.reduce((sum, region) => sum + Math.max(1, region.area), 0);
|
const totalArea = textRegions.reduce((sum, region) => sum + Math.max(1, region.area), 0);
|
||||||
|
|
||||||
textRegions.forEach((region) => {
|
textRegions.forEach((region) => {
|
||||||
const wordStart = Math.max(0, Number(region.wordStart || 0));
|
const duration = totalArea > 0
|
||||||
const wordEnd = Math.max(wordStart + 1, Number(region.wordEnd || wordStart + 1));
|
|
||||||
const firstTiming = wordTimings[wordStart] || null;
|
|
||||||
const lastTiming = wordTimings[Math.min(wordTimings.length - 1, wordEnd - 1)] || firstTiming;
|
|
||||||
let delay = firstTiming ? Math.max(0, Number(firstTiming.delay || 0)) : fallbackDelay;
|
|
||||||
let duration = lastTiming
|
|
||||||
? Math.max(1, (Number(lastTiming.delay || 0) + Number(lastTiming.duration || 0)) - delay)
|
|
||||||
: 0;
|
|
||||||
if (!Number.isFinite(duration) || duration <= 0) {
|
|
||||||
duration = totalArea > 0
|
|
||||||
? Math.max(1, totalDuration * (Math.max(1, region.area) / totalArea))
|
? Math.max(1, totalDuration * (Math.max(1, region.area) / totalArea))
|
||||||
: Math.max(1, totalDuration / Math.max(1, textRegions.length));
|
: Math.max(1, totalDuration / Math.max(1, textRegions.length));
|
||||||
delay = fallbackDelay;
|
|
||||||
}
|
|
||||||
timedRegions.push({
|
timedRegions.push({
|
||||||
...region,
|
...region,
|
||||||
timing: { delay, duration }
|
timing: { delay: fallbackDelay, duration }
|
||||||
});
|
});
|
||||||
fallbackDelay = Math.max(fallbackDelay, delay + duration);
|
fallbackDelay += duration;
|
||||||
});
|
});
|
||||||
|
|
||||||
fixedRegions.forEach((region) => {
|
fixedRegions.forEach((region) => {
|
||||||
@@ -707,7 +726,7 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
createRevealRegionForLine(side, lineRecord = {}) {
|
createRevealRegionForLine(side, lineRecord = {}, spreadIndex = null) {
|
||||||
const blockId = String(lineRecord?.blockId ?? '');
|
const blockId = String(lineRecord?.blockId ?? '');
|
||||||
if (!blockId || !this.revealPublishBlockIds.has(blockId)) return null;
|
if (!blockId || !this.revealPublishBlockIds.has(blockId)) return null;
|
||||||
const animation = this.activeAnimations.get(blockId);
|
const animation = this.activeAnimations.get(blockId);
|
||||||
@@ -719,16 +738,14 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
const y = content.y + Number(rect.y || 0);
|
const y = content.y + Number(rect.y || 0);
|
||||||
const width = Math.max(1, Number(rect.width || content.width));
|
const width = Math.max(1, Number(rect.width || content.width));
|
||||||
const height = Math.max(1, Number(rect.height || this.metrics.typographyLineHeightPx));
|
const height = Math.max(1, Number(rect.height || this.metrics.typographyLineHeightPx));
|
||||||
return this.normalizeRevealRegion(side, blockId, lineRecord, x, y, width, height, this.getImageRevealDurationMs(lineRecord));
|
return this.normalizeRevealRegion(side, blockId, lineRecord, x, y, width, height, this.getImageRevealDurationMs(lineRecord), spreadIndex);
|
||||||
}
|
}
|
||||||
const rect = this.getLineInkRect(side, lineRecord);
|
const rect = this.getLineInkRect(side, lineRecord);
|
||||||
if (!rect) return null;
|
if (!rect) return null;
|
||||||
const wordStart = Math.max(0, Number(lineRecord.blockWordStart || 0));
|
return this.normalizeRevealRegion(side, blockId, lineRecord, rect.x, rect.y, rect.width, rect.height, 0, spreadIndex);
|
||||||
const wordCount = Math.max(1, this.getLineWordCount(lineRecord.line || {}));
|
|
||||||
return this.normalizeRevealRegion(side, blockId, lineRecord, rect.x, rect.y, rect.width, rect.height, 0, wordStart, wordStart + wordCount);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
normalizeRevealRegion(side, blockId, lineRecord, x, y, width, height, fixedDurationMs = 0, wordStart = 0, wordEnd = 0) {
|
normalizeRevealRegion(side, blockId, lineRecord, x, y, width, height, fixedDurationMs = 0, spreadIndex = null) {
|
||||||
const padding = Math.max(2, Number(lineRecord.fontPx || 18) * 0.12);
|
const padding = Math.max(2, Number(lineRecord.fontPx || 18) * 0.12);
|
||||||
const left = Math.max(0, x - padding);
|
const left = Math.max(0, x - padding);
|
||||||
const top = Math.max(0, y - padding);
|
const top = Math.max(0, y - padding);
|
||||||
@@ -738,11 +755,10 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
const rectHeight = Math.max(1, bottom - top);
|
const rectHeight = Math.max(1, bottom - top);
|
||||||
return {
|
return {
|
||||||
side,
|
side,
|
||||||
|
spreadIndex: Math.max(0, Number((spreadIndex ?? Math.floor(Number(lineRecord.pageIndex || 0) / 2)) || 0)),
|
||||||
blockId,
|
blockId,
|
||||||
lineIndex: Number(lineRecord.lineIndex ?? lineRecord.pageLine ?? 0),
|
lineIndex: Number(lineRecord.lineIndex ?? lineRecord.pageLine ?? 0),
|
||||||
fixedDurationMs,
|
fixedDurationMs,
|
||||||
wordStart,
|
|
||||||
wordEnd,
|
|
||||||
area: rectWidth * rectHeight,
|
area: rectWidth * rectHeight,
|
||||||
pixelRect: { x: left, y: top, right, bottom },
|
pixelRect: { x: left, y: top, right, bottom },
|
||||||
rect: {
|
rect: {
|
||||||
|
|||||||
@@ -1074,14 +1074,31 @@ class UIDisplayHandlerModule extends BaseModule {
|
|||||||
publish: false,
|
publish: false,
|
||||||
includeUnrenderedHistory: true
|
includeUnrenderedHistory: true
|
||||||
});
|
});
|
||||||
|
const previewRevealDetail = {
|
||||||
|
id: sentence.id,
|
||||||
|
blockId: sentence.blockId,
|
||||||
|
wordTimings: sentence.animation?.wordTimings || [],
|
||||||
|
cueTimings: sentence.animation?.cueTimings || [],
|
||||||
|
totalDuration: sentence.animation?.totalDuration || 0,
|
||||||
|
spread: previewSpread,
|
||||||
|
preloadOnly: true
|
||||||
|
};
|
||||||
|
if (previewSpread && typeof bookTextureRenderer.prepareRevealBlock === 'function') {
|
||||||
|
bookTextureRenderer.prepareRevealBlock(previewRevealDetail, { preloadOnly: true });
|
||||||
|
}
|
||||||
if (Number(previewSpread?.index || 0) > currentSpreadIndex) {
|
if (Number(previewSpread?.index || 0) > currentSpreadIndex) {
|
||||||
await this.waitForWebGLPageFlip({
|
const flipped = await this.waitForWebGLPageFlip({
|
||||||
direction: 1,
|
direction: 1,
|
||||||
reason: 'pending-block-overflow',
|
reason: 'pending-block-overflow',
|
||||||
targetSpread: previewSpread.index
|
targetSpread: previewSpread.index
|
||||||
});
|
});
|
||||||
|
if (!flipped) {
|
||||||
|
throw new Error(`WebGL book page flip did not start for prepared spread ${previewSpread.index}`);
|
||||||
}
|
}
|
||||||
preparedSpread = await bookPagination.preparePendingBlock(sentence);
|
}
|
||||||
|
preparedSpread = await bookPagination.preparePendingBlock(sentence, {
|
||||||
|
includeUnrenderedHistory: true
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
document.dispatchEvent(new CustomEvent('book-pagination:prepare-block', {
|
document.dispatchEvent(new CustomEvent('book-pagination:prepare-block', {
|
||||||
detail: {
|
detail: {
|
||||||
@@ -1110,23 +1127,39 @@ class UIDisplayHandlerModule extends BaseModule {
|
|||||||
waitForWebGLPageFlip(detail = {}) {
|
waitForWebGLPageFlip(detail = {}) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let resolved = false;
|
let resolved = false;
|
||||||
const finish = () => {
|
const cleanup = () => {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
document.removeEventListener('webgl-book:page-flip-started', onStarted);
|
||||||
|
document.removeEventListener('webgl-book:page-flip-finished', onFinished);
|
||||||
|
};
|
||||||
|
const finish = (result) => {
|
||||||
if (resolved) return;
|
if (resolved) return;
|
||||||
resolved = true;
|
resolved = true;
|
||||||
window.clearTimeout(timeout);
|
cleanup();
|
||||||
document.removeEventListener('webgl-book:page-flip-finished', finish);
|
resolve(result);
|
||||||
resolve(true);
|
|
||||||
};
|
};
|
||||||
const timeout = window.setTimeout(finish, 1400);
|
const requestedTargetSpread = Number.isFinite(Number(detail.targetSpread))
|
||||||
document.addEventListener('webgl-book:page-flip-finished', finish, { once: true });
|
? Math.max(0, Math.round(Number(detail.targetSpread)))
|
||||||
|
: null;
|
||||||
|
const matchesTarget = (eventDetail = {}) => requestedTargetSpread == null
|
||||||
|
|| Math.max(0, Math.round(Number(eventDetail.targetSpread || 0))) === requestedTargetSpread;
|
||||||
|
const onStarted = (event) => {
|
||||||
|
if (!matchesTarget(event.detail || {})) return;
|
||||||
|
document.documentElement.dataset.webglLastStartedPageFlip = JSON.stringify(event.detail || {});
|
||||||
|
};
|
||||||
|
const onFinished = (event) => {
|
||||||
|
if (!matchesTarget(event.detail || {})) return;
|
||||||
|
finish(true);
|
||||||
|
};
|
||||||
|
const timeout = window.setTimeout(() => finish(false), 2400);
|
||||||
|
document.addEventListener('webgl-book:page-flip-started', onStarted);
|
||||||
|
document.addEventListener('webgl-book:page-flip-finished', onFinished);
|
||||||
document.dispatchEvent(new CustomEvent('webgl-book:request-page-flip', {
|
document.dispatchEvent(new CustomEvent('webgl-book:request-page-flip', {
|
||||||
detail: {
|
detail: {
|
||||||
direction: Math.sign(Number(detail.direction || 1)) || 1,
|
direction: Math.sign(Number(detail.direction || 1)) || 1,
|
||||||
reason: detail.reason || 'pending-block-overflow',
|
reason: detail.reason || 'pending-block-overflow',
|
||||||
force: true,
|
force: true,
|
||||||
targetSpread: Number.isFinite(Number(detail.targetSpread))
|
targetSpread: requestedTargetSpread
|
||||||
? Math.max(0, Math.round(Number(detail.targetSpread)))
|
|
||||||
: null
|
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|||||||
+112
-27
@@ -204,6 +204,7 @@ let bookPaginationState = {
|
|||||||
spreadCount: 1,
|
spreadCount: 1,
|
||||||
writtenPageLimit: 0
|
writtenPageLimit: 0
|
||||||
};
|
};
|
||||||
|
let maxVisitedPagePosition = 0;
|
||||||
const normalFlipDuration = 900;
|
const normalFlipDuration = 900;
|
||||||
const fastFlipDuration = 520;
|
const fastFlipDuration = 520;
|
||||||
const fastFlipCount = 10;
|
const fastFlipCount = 10;
|
||||||
@@ -364,7 +365,7 @@ const materials = {
|
|||||||
emissive: 0x100d08,
|
emissive: 0x100d08,
|
||||||
emissiveIntensity: 0.004,
|
emissiveIntensity: 0.004,
|
||||||
envMapIntensity: 0.01,
|
envMapIntensity: 0.01,
|
||||||
side: THREE.DoubleSide
|
side: THREE.FrontSide
|
||||||
}),
|
}),
|
||||||
leftPage: new THREE.MeshStandardMaterial({
|
leftPage: new THREE.MeshStandardMaterial({
|
||||||
color: 0xffffff,
|
color: 0xffffff,
|
||||||
@@ -414,7 +415,7 @@ const materials = {
|
|||||||
};
|
};
|
||||||
materials.flipPageBackSurface = materials.flipPageSurface.clone();
|
materials.flipPageBackSurface = materials.flipPageSurface.clone();
|
||||||
materials.flipPageBackSurface.map = getBlankPageTexture();
|
materials.flipPageBackSurface.map = getBlankPageTexture();
|
||||||
materials.flipPageBackSurface.side = THREE.DoubleSide;
|
materials.flipPageBackSurface.side = THREE.FrontSide;
|
||||||
materials.flipPageEdge = materials.pageSurface.clone();
|
materials.flipPageEdge = materials.pageSurface.clone();
|
||||||
materials.flipPageEdge.map = paperTextures.edge;
|
materials.flipPageEdge.map = paperTextures.edge;
|
||||||
materials.flipPageEdge.normalMap = paperTextures.normal;
|
materials.flipPageEdge.normalMap = paperTextures.normal;
|
||||||
@@ -531,6 +532,7 @@ window.BookLabDebug = {
|
|||||||
pageReserve,
|
pageReserve,
|
||||||
progress: readingProgress,
|
progress: readingProgress,
|
||||||
pagePosition: getCurrentPagePosition(),
|
pagePosition: getCurrentPagePosition(),
|
||||||
|
maxVisitedPagePosition,
|
||||||
spreadIndex: bookPaginationState.spreadIndex,
|
spreadIndex: bookPaginationState.spreadIndex,
|
||||||
writtenPageLimit: bookPaginationState.writtenPageLimit
|
writtenPageLimit: bookPaginationState.writtenPageLimit
|
||||||
};
|
};
|
||||||
@@ -541,10 +543,17 @@ window.BookLabDebug = {
|
|||||||
spreadCount: Math.max(1, Number(state.spreadCount ?? bookPaginationState.spreadCount ?? 1)),
|
spreadCount: Math.max(1, Number(state.spreadCount ?? bookPaginationState.spreadCount ?? 1)),
|
||||||
writtenPageLimit: Math.max(0, Number(state.writtenPageLimit ?? bookPaginationState.writtenPageLimit ?? 0))
|
writtenPageLimit: Math.max(0, Number(state.writtenPageLimit ?? bookPaginationState.writtenPageLimit ?? 0))
|
||||||
};
|
};
|
||||||
|
maxVisitedPagePosition = Math.max(maxVisitedPagePosition, getCurrentPagePosition());
|
||||||
growBookIfWritableLimitReached();
|
growBookIfWritableLimitReached();
|
||||||
syncBookControls();
|
syncBookControls();
|
||||||
return this.getBookState();
|
return this.getBookState();
|
||||||
},
|
},
|
||||||
|
setMaxVisitedPagePosition(value) {
|
||||||
|
const page = Math.max(0, Math.round(Number(value || 0)));
|
||||||
|
maxVisitedPagePosition = Math.max(maxVisitedPagePosition, page);
|
||||||
|
syncBookControls();
|
||||||
|
return maxVisitedPagePosition;
|
||||||
|
},
|
||||||
navigateToPagePosition(value) {
|
navigateToPagePosition(value) {
|
||||||
return navigateToPagePosition(value);
|
return navigateToPagePosition(value);
|
||||||
},
|
},
|
||||||
@@ -616,9 +625,26 @@ document.addEventListener('webgl-book:page-cache-problem', (event) => {
|
|||||||
});
|
});
|
||||||
document.addEventListener('book-pagination:spread-updated', (event) => {
|
document.addEventListener('book-pagination:spread-updated', (event) => {
|
||||||
const detail = event.detail || {};
|
const detail = event.detail || {};
|
||||||
|
const incomingSpreadIndex = Math.max(0, Number(detail.spreadIndex || 0));
|
||||||
|
const latestBlockId = Math.max(0, Number(detail.latestBlockId || 0));
|
||||||
|
const latestRenderedBlockId = Math.max(0, Number(detail.latestRenderedBlockId || 0));
|
||||||
|
if (
|
||||||
|
latestBlockId > latestRenderedBlockId
|
||||||
|
&& detail.allowFutureUnrendered !== true
|
||||||
|
&& activeFlips.length === 0
|
||||||
|
&& incomingSpreadIndex > Math.max(0, Number(bookPaginationState.spreadIndex || 0))
|
||||||
|
) {
|
||||||
|
markPageTextureTiming('spreadUpdate:deferred-future-unrendered', {
|
||||||
|
incomingSpreadIndex,
|
||||||
|
visibleSpreadIndex: bookPaginationState.spreadIndex,
|
||||||
|
latestBlockId,
|
||||||
|
latestRenderedBlockId
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const previousPageCount = bookPageCount;
|
const previousPageCount = bookPageCount;
|
||||||
bookPaginationState = {
|
bookPaginationState = {
|
||||||
spreadIndex: Math.max(0, Number(detail.spreadIndex || 0)),
|
spreadIndex: incomingSpreadIndex,
|
||||||
spreadCount: Math.max(1, Number(detail.spreadCount || 1)),
|
spreadCount: Math.max(1, Number(detail.spreadCount || 1)),
|
||||||
writtenPageLimit: Math.max(0, Number(detail.writtenPageLimit || 0))
|
writtenPageLimit: Math.max(0, Number(detail.writtenPageLimit || 0))
|
||||||
};
|
};
|
||||||
@@ -1920,10 +1946,10 @@ function ensureBottomNavigation() {
|
|||||||
startButton.addEventListener('click', () => navigateToPagePosition(0));
|
startButton.addEventListener('click', () => navigateToPagePosition(0));
|
||||||
backButton.addEventListener('click', () => navigateByPageDelta(-1));
|
backButton.addEventListener('click', () => navigateByPageDelta(-1));
|
||||||
forwardButton.addEventListener('click', () => navigateByPageDelta(1));
|
forwardButton.addEventListener('click', () => navigateByPageDelta(1));
|
||||||
endButton.addEventListener('click', () => navigateToPagePosition(bookPaginationState.writtenPageLimit));
|
endButton.addEventListener('click', () => navigateToPagePosition(maxVisitedPagePosition));
|
||||||
slider.addEventListener('input', () => {
|
slider.addEventListener('input', () => {
|
||||||
const requested = Number(slider.value);
|
const requested = Number(slider.value);
|
||||||
const clamped = Math.min(requested, Math.max(0, bookPaginationState.writtenPageLimit || 0), getWritablePageLimit());
|
const clamped = Math.min(requested, maxVisitedPagePosition, getWritablePageLimit());
|
||||||
if (requested !== clamped) slider.value = String(clamped);
|
if (requested !== clamped) slider.value = String(clamped);
|
||||||
pageLabel.textContent = `${appInitialState.t?.('webgl.page') || 'Page'} ${clamped}`;
|
pageLabel.textContent = `${appInitialState.t?.('webgl.page') || 'Page'} ${clamped}`;
|
||||||
});
|
});
|
||||||
@@ -1952,8 +1978,7 @@ function navigateByPageDelta(delta) {
|
|||||||
|
|
||||||
function navigateToPagePosition(pagePosition) {
|
function navigateToPagePosition(pagePosition) {
|
||||||
const writableLimit = getWritablePageLimit();
|
const writableLimit = getWritablePageLimit();
|
||||||
const writtenLimit = Math.max(0, bookPaginationState.writtenPageLimit || 0);
|
const targetPage = THREE.MathUtils.clamp(Math.round(Number(pagePosition || 0)), 0, Math.min(writableLimit, maxVisitedPagePosition));
|
||||||
const targetPage = THREE.MathUtils.clamp(Math.round(Number(pagePosition || 0)), 0, Math.min(writableLimit, writtenLimit));
|
|
||||||
const currentPage = getCurrentPagePosition();
|
const currentPage = getCurrentPagePosition();
|
||||||
if (targetPage === currentPage) {
|
if (targetPage === currentPage) {
|
||||||
syncBookControls();
|
syncBookControls();
|
||||||
@@ -1984,9 +2009,8 @@ function syncBookControls() {
|
|||||||
function syncBottomNavigation() {
|
function syncBottomNavigation() {
|
||||||
if (!bottomNavigation) return;
|
if (!bottomNavigation) return;
|
||||||
const currentPage = getCurrentPagePosition();
|
const currentPage = getCurrentPagePosition();
|
||||||
const writtenLimit = Math.max(0, bookPaginationState.writtenPageLimit || 0);
|
|
||||||
const writableLimit = getWritablePageLimit();
|
const writableLimit = getWritablePageLimit();
|
||||||
const navigableLimit = Math.min(writtenLimit, writableLimit);
|
const navigableLimit = Math.min(maxVisitedPagePosition, writableLimit);
|
||||||
const reservedStart = Math.max(0, writableLimit);
|
const reservedStart = Math.max(0, writableLimit);
|
||||||
bottomNavigation.slider.max = String(Math.max(0, bookPageCount));
|
bottomNavigation.slider.max = String(Math.max(0, bookPageCount));
|
||||||
bottomNavigation.slider.value = String(Math.min(currentPage, navigableLimit));
|
bottomNavigation.slider.value = String(Math.min(currentPage, navigableLimit));
|
||||||
@@ -1994,7 +2018,7 @@ function syncBottomNavigation() {
|
|||||||
bottomNavigation.maxLabel.textContent = String(bookPageCount);
|
bottomNavigation.maxLabel.textContent = String(bookPageCount);
|
||||||
bottomNavigation.pageLabel.textContent = `${appInitialState.t?.('webgl.page') || 'Page'} ${Math.min(currentPage, navigableLimit)}`;
|
bottomNavigation.pageLabel.textContent = `${appInitialState.t?.('webgl.page') || 'Page'} ${Math.min(currentPage, navigableLimit)}`;
|
||||||
bottomNavigation.root.style.setProperty('--book-nav-position', `${bookPageCount > 0 ? currentPage / bookPageCount : 0}`);
|
bottomNavigation.root.style.setProperty('--book-nav-position', `${bookPageCount > 0 ? currentPage / bookPageCount : 0}`);
|
||||||
bottomNavigation.root.style.setProperty('--book-nav-written', `${bookPageCount > 0 ? writtenLimit / bookPageCount : 0}`);
|
bottomNavigation.root.style.setProperty('--book-nav-written', `${bookPageCount > 0 ? maxVisitedPagePosition / bookPageCount : 0}`);
|
||||||
bottomNavigation.root.style.setProperty('--book-nav-reserve-start', `${bookPageCount > 0 ? reservedStart / bookPageCount : 1}`);
|
bottomNavigation.root.style.setProperty('--book-nav-reserve-start', `${bookPageCount > 0 ? reservedStart / bookPageCount : 1}`);
|
||||||
bottomNavigation.root.dataset.bookSize = String(bookPageCount);
|
bottomNavigation.root.dataset.bookSize = String(bookPageCount);
|
||||||
bottomNavigation.root.dataset.pageReserve = String(pageReserve);
|
bottomNavigation.root.dataset.pageReserve = String(pageReserve);
|
||||||
@@ -2132,7 +2156,7 @@ function recordPageCacheProblem(detail = {}) {
|
|||||||
function rememberResidentPageTexture(pageMeta = null, texture = null, sourceCanvas = null, ownsTexture = true) {
|
function rememberResidentPageTexture(pageMeta = null, texture = null, sourceCanvas = null, ownsTexture = true) {
|
||||||
const pageIndex = Number(pageMeta?.pageIndex);
|
const pageIndex = Number(pageMeta?.pageIndex);
|
||||||
if (!texture || !Number.isFinite(pageIndex) || pageIndex < 0) return null;
|
if (!texture || !Number.isFinite(pageIndex) || pageIndex < 0) return null;
|
||||||
const key = makePageMetaForCache(pageIndex).pageIndex;
|
const key = makeResidentPageTextureKey(pageMeta);
|
||||||
const existing = residentPageTextures.get(key);
|
const existing = residentPageTextures.get(key);
|
||||||
if (isOlderPageTextureMeta(pageMeta, existing?.pageMeta)) return existing?.texture || null;
|
if (isOlderPageTextureMeta(pageMeta, existing?.pageMeta)) return existing?.texture || null;
|
||||||
if (existing?.ownsTexture && existing.texture && existing.texture !== texture) existing.texture.dispose?.();
|
if (existing?.ownsTexture && existing.texture && existing.texture !== texture) existing.texture.dispose?.();
|
||||||
@@ -2167,13 +2191,26 @@ function isOlderPageTextureMeta(incoming = {}, existing = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makePageMetaForCache(pageIndex) {
|
function makePageMetaForCache(pageIndex) {
|
||||||
|
const index = Math.max(0, Math.round(Number(pageIndex || 0)));
|
||||||
|
const paginationMeta = getPaginationPageMeta(index) || {};
|
||||||
return {
|
return {
|
||||||
pageIndex: Math.max(0, Math.round(Number(pageIndex || 0))),
|
...paginationMeta,
|
||||||
|
pageIndex: index,
|
||||||
width: pageTextureWidth,
|
width: pageTextureWidth,
|
||||||
height: leftCanvas?.height || Math.round(pageTextureWidth * PROCEDURAL_BOOK.PAGE_DEPTH / PROCEDURAL_BOOK.PAGE_WIDTH)
|
height: leftCanvas?.height || Math.round(pageTextureWidth * PROCEDURAL_BOOK.PAGE_DEPTH / PROCEDURAL_BOOK.PAGE_WIDTH)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeResidentPageTextureKey(pageMetaOrIndex = {}) {
|
||||||
|
const pageMeta = typeof pageMetaOrIndex === 'number'
|
||||||
|
? makePageMetaForCache(pageMetaOrIndex)
|
||||||
|
: pageMetaOrIndex || {};
|
||||||
|
const pageIndex = Math.max(0, Math.round(Number(pageMeta.pageIndex || 0)));
|
||||||
|
const kind = String(pageMeta.kind || 'content');
|
||||||
|
const section = String(pageMeta.section || 'body');
|
||||||
|
return `${pageIndex}:${kind}:${section}`;
|
||||||
|
}
|
||||||
|
|
||||||
function spreadPageIndices(spreadIndex) {
|
function spreadPageIndices(spreadIndex) {
|
||||||
const spread = Math.max(0, Math.round(Number(spreadIndex || 0)));
|
const spread = Math.max(0, Math.round(Number(spreadIndex || 0)));
|
||||||
return {
|
return {
|
||||||
@@ -2183,7 +2220,7 @@ function spreadPageIndices(spreadIndex) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getResidentPageTexture(pageIndex) {
|
function getResidentPageTexture(pageIndex) {
|
||||||
const key = makePageMetaForCache(pageIndex).pageIndex;
|
const key = makeResidentPageTextureKey(pageIndex);
|
||||||
const resident = residentPageTextures.get(key);
|
const resident = residentPageTextures.get(key);
|
||||||
if (!resident) return null;
|
if (!resident) return null;
|
||||||
resident.lastUsedAt = performance.now();
|
resident.lastUsedAt = performance.now();
|
||||||
@@ -2195,7 +2232,7 @@ function getResidentPageTexture(pageIndex) {
|
|||||||
function getResidentPageTextureForMeta(pageMeta = null) {
|
function getResidentPageTextureForMeta(pageMeta = null) {
|
||||||
const pageIndex = Number(pageMeta?.pageIndex);
|
const pageIndex = Number(pageMeta?.pageIndex);
|
||||||
if (!Number.isFinite(pageIndex)) return null;
|
if (!Number.isFinite(pageIndex)) return null;
|
||||||
const key = makePageMetaForCache(pageIndex).pageIndex;
|
const key = makeResidentPageTextureKey(pageMeta);
|
||||||
const resident = residentPageTextures.get(key);
|
const resident = residentPageTextures.get(key);
|
||||||
if (!resident || isOlderPageTextureMeta(pageMeta, resident.pageMeta)) return null;
|
if (!resident || isOlderPageTextureMeta(pageMeta, resident.pageMeta)) return null;
|
||||||
return getResidentPageTexture(pageIndex);
|
return getResidentPageTexture(pageIndex);
|
||||||
@@ -2203,13 +2240,20 @@ function getResidentPageTextureForMeta(pageMeta = null) {
|
|||||||
|
|
||||||
async function preloadCachedPageTexture(pageIndex) {
|
async function preloadCachedPageTexture(pageIndex) {
|
||||||
const meta = makePageMetaForCache(pageIndex);
|
const meta = makePageMetaForCache(pageIndex);
|
||||||
if (residentPageTextures.has(meta.pageIndex)) {
|
const residentKey = makeResidentPageTextureKey(meta);
|
||||||
|
if (residentPageTextures.has(residentKey)) {
|
||||||
getResidentPageTexture(meta.pageIndex);
|
getResidentPageTexture(meta.pageIndex);
|
||||||
return residentPageTextures.get(meta.pageIndex)?.texture || null;
|
return residentPageTextures.get(residentKey)?.texture || null;
|
||||||
}
|
}
|
||||||
const cache = window.WebGLPageCache || window.moduleRegistry?.getModule?.('webgl-page-cache') || null;
|
const cache = window.WebGLPageCache || window.moduleRegistry?.getModule?.('webgl-page-cache') || null;
|
||||||
const sourceCanvas = await cache?.getPageCanvas?.(meta);
|
const sourceCanvas = await cache?.getPageCanvas?.(meta);
|
||||||
if (!sourceCanvas) {
|
if (!sourceCanvas) {
|
||||||
|
const pageMeta = getPaginationPageMeta(meta.pageIndex);
|
||||||
|
if (pageMeta?.kind === 'blank') {
|
||||||
|
const blankTexture = getBlankPageTexture();
|
||||||
|
rememberResidentPageTexture({ ...meta, ...pageMeta }, blankTexture, null, false);
|
||||||
|
return blankTexture;
|
||||||
|
}
|
||||||
recordPageCacheProblem({
|
recordPageCacheProblem({
|
||||||
type: 'db-cache-miss',
|
type: 'db-cache-miss',
|
||||||
pageIndex: meta.pageIndex,
|
pageIndex: meta.pageIndex,
|
||||||
@@ -2220,7 +2264,7 @@ async function preloadCachedPageTexture(pageIndex) {
|
|||||||
}
|
}
|
||||||
const texture = createPageCanvasTexture(sourceCanvas);
|
const texture = createPageCanvasTexture(sourceCanvas);
|
||||||
const cachedMeta = sourceCanvas.__webglPageCacheMeta || meta;
|
const cachedMeta = sourceCanvas.__webglPageCacheMeta || meta;
|
||||||
residentPageTextures.set(meta.pageIndex, {
|
residentPageTextures.set(residentKey, {
|
||||||
texture,
|
texture,
|
||||||
sourceCanvas,
|
sourceCanvas,
|
||||||
lastUsedAt: performance.now(),
|
lastUsedAt: performance.now(),
|
||||||
@@ -2236,6 +2280,19 @@ async function preloadCachedPageTexture(pageIndex) {
|
|||||||
return texture;
|
return texture;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPaginationPageMeta(pageIndex) {
|
||||||
|
const index = Math.max(0, Math.round(Number(pageIndex || 0)));
|
||||||
|
const spreadIndex = Math.floor(index / 2);
|
||||||
|
const side = index % 2 === 0 ? 'left' : 'right';
|
||||||
|
const pagination = window.moduleRegistry?.getModule?.('book-pagination') || null;
|
||||||
|
const spread = typeof pagination?.getSpread === 'function'
|
||||||
|
? pagination.getSpread(spreadIndex)
|
||||||
|
: Array.isArray(pagination?.spreads)
|
||||||
|
? pagination.spreads[spreadIndex]
|
||||||
|
: null;
|
||||||
|
return spread?.pageMeta?.[side] || null;
|
||||||
|
}
|
||||||
|
|
||||||
async function prewarmSpreadTextures(spreadIndex) {
|
async function prewarmSpreadTextures(spreadIndex) {
|
||||||
const indices = spreadPageIndices(spreadIndex);
|
const indices = spreadPageIndices(spreadIndex);
|
||||||
const [left, right] = await Promise.all([
|
const [left, right] = await Promise.all([
|
||||||
@@ -2276,7 +2333,8 @@ function takePreparedPageTexture(side, revealDetail = {}) {
|
|||||||
function uploadPageTextureDirect(side, sourceCanvas, pageMeta = null) {
|
function uploadPageTextureDirect(side, sourceCanvas, pageMeta = null) {
|
||||||
const texture = side === 'left' ? leftTexture : rightTexture;
|
const texture = side === 'left' ? leftTexture : rightTexture;
|
||||||
const material = side === 'left' ? materials.leftPage : materials.rightPage;
|
const material = side === 'left' ? materials.leftPage : materials.rightPage;
|
||||||
const residentTexture = Number.isFinite(Number(pageMeta?.pageIndex))
|
const shouldUseResidentTexture = pageMeta?.kind !== 'title';
|
||||||
|
const residentTexture = shouldUseResidentTexture && Number.isFinite(Number(pageMeta?.pageIndex))
|
||||||
? getResidentPageTextureForMeta(pageMeta)
|
? getResidentPageTextureForMeta(pageMeta)
|
||||||
: null;
|
: null;
|
||||||
markPageTextureTiming('directUpload:start', {
|
markPageTextureTiming('directUpload:start', {
|
||||||
@@ -2703,6 +2761,13 @@ function startPageFlipPrepared(direction, options = {}) {
|
|||||||
delete document.documentElement.dataset.webglPendingPageFlip;
|
delete document.documentElement.dataset.webglPendingPageFlip;
|
||||||
activeFlips.push(flip);
|
activeFlips.push(flip);
|
||||||
setPageFlipActiveFlag();
|
setPageFlipActiveFlag();
|
||||||
|
document.dispatchEvent(new CustomEvent('webgl-book:page-flip-started', {
|
||||||
|
detail: {
|
||||||
|
direction: flip.direction,
|
||||||
|
sourceSide: flip.sourcePageSide || (flip.direction > 0 ? 'right' : 'left'),
|
||||||
|
targetSpread: Number.isFinite(Number(flip.targetSpread)) ? Math.max(0, Math.round(Number(flip.targetSpread))) : null
|
||||||
|
}
|
||||||
|
}));
|
||||||
syncBookControls();
|
syncBookControls();
|
||||||
updateActiveFlips(flip.startTime);
|
updateActiveFlips(flip.startTime);
|
||||||
return true;
|
return true;
|
||||||
@@ -2723,6 +2788,7 @@ function startFastPageFlipPrepared(direction, options = {}) {
|
|||||||
if (activeFlips.length || !currentProceduralBookModel || !canPageFlip(direction)) return false;
|
if (activeFlips.length || !currentProceduralBookModel || !canPageFlip(direction)) return false;
|
||||||
const firstFlip = createPageFlip(direction, performance.now(), fastFlipDuration);
|
const firstFlip = createPageFlip(direction, performance.now(), fastFlipDuration);
|
||||||
if (!firstFlip) return false;
|
if (!firstFlip) return false;
|
||||||
|
firstFlip.targetSpread = Number.isFinite(Number(options.targetSpread)) ? Math.max(0, Math.round(Number(options.targetSpread))) : null;
|
||||||
if (!prepareStaticPageForFlip(firstFlip, options.prewarm || null)) return false;
|
if (!prepareStaticPageForFlip(firstFlip, options.prewarm || null)) return false;
|
||||||
const startTime = firstFlip.startTime;
|
const startTime = firstFlip.startTime;
|
||||||
const interval = fastFlipDuration / fastFlipOverlap;
|
const interval = fastFlipDuration / fastFlipOverlap;
|
||||||
@@ -2740,6 +2806,14 @@ function startFastPageFlipPrepared(direction, options = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
setPageFlipActiveFlag();
|
setPageFlipActiveFlag();
|
||||||
|
document.dispatchEvent(new CustomEvent('webgl-book:page-flip-started', {
|
||||||
|
detail: {
|
||||||
|
direction: firstFlip.direction,
|
||||||
|
sourceSide: firstFlip.sourcePageSide || (firstFlip.direction > 0 ? 'right' : 'left'),
|
||||||
|
targetSpread: Number.isFinite(Number(firstFlip.targetSpread)) ? Math.max(0, Math.round(Number(firstFlip.targetSpread))) : null,
|
||||||
|
fast: true
|
||||||
|
}
|
||||||
|
}));
|
||||||
syncBookControls();
|
syncBookControls();
|
||||||
updateActiveFlips(startTime);
|
updateActiveFlips(startTime);
|
||||||
return true;
|
return true;
|
||||||
@@ -2774,7 +2848,8 @@ function prepareStaticPageForFlip(flip, prewarm = null) {
|
|||||||
: Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)) + Math.sign(Number(flip.direction || 0)));
|
: Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)) + Math.sign(Number(flip.direction || 0)));
|
||||||
const targetPages = spreadPageIndices(targetSpread);
|
const targetPages = spreadPageIndices(targetSpread);
|
||||||
const targetBackPageIndex = flip.direction > 0 ? targetPages.left : targetPages.right;
|
const targetBackPageIndex = flip.direction > 0 ? targetPages.left : targetPages.right;
|
||||||
const residentBackTexture = getResidentPageTexture(targetBackPageIndex);
|
const prewarmedBackTexture = flip.direction > 0 ? prewarm?.next?.left : prewarm?.next?.right;
|
||||||
|
const residentBackTexture = prewarmedBackTexture || getResidentPageTexture(targetBackPageIndex);
|
||||||
const requiresWrittenTexture = targetBackPageIndex <= Math.max(2, Number(bookPaginationState.writtenPageLimit || 0));
|
const requiresWrittenTexture = targetBackPageIndex <= Math.max(2, Number(bookPaginationState.writtenPageLimit || 0));
|
||||||
if (!residentBackTexture && requiresWrittenTexture) {
|
if (!residentBackTexture && requiresWrittenTexture) {
|
||||||
recordPageCacheProblem({
|
recordPageCacheProblem({
|
||||||
@@ -2827,7 +2902,7 @@ function prepareStaticPageForFlip(flip, prewarm = null) {
|
|||||||
function canPageFlip(direction) {
|
function canPageFlip(direction) {
|
||||||
if (!currentProceduralBookModel) return false;
|
if (!currentProceduralBookModel) return false;
|
||||||
const currentPage = getCurrentPagePosition();
|
const currentPage = getCurrentPagePosition();
|
||||||
const maxNavigablePage = Math.min(Math.max(0, bookPaginationState.writtenPageLimit || 0), getWritablePageLimit());
|
const maxNavigablePage = Math.min(maxVisitedPagePosition, getWritablePageLimit());
|
||||||
if (direction > 0) return currentPage < maxNavigablePage;
|
if (direction > 0) return currentPage < maxNavigablePage;
|
||||||
return currentPage > 0;
|
return currentPage > 0;
|
||||||
}
|
}
|
||||||
@@ -3031,7 +3106,7 @@ function lineYAtX(points, x) {
|
|||||||
|
|
||||||
function setActivePageGeometry(flip, surface) {
|
function setActivePageGeometry(flip, surface) {
|
||||||
if (!flip.mesh) {
|
if (!flip.mesh) {
|
||||||
const geometry = createFlippingPageGeometry(surface);
|
const geometry = createFlippingPageGeometry(surface, flip.direction);
|
||||||
flip.mesh = new THREE.Mesh(geometry, [
|
flip.mesh = new THREE.Mesh(geometry, [
|
||||||
materials.flipPageSurface,
|
materials.flipPageSurface,
|
||||||
materials.flipPageBackSurface,
|
materials.flipPageBackSurface,
|
||||||
@@ -3045,13 +3120,13 @@ function setActivePageGeometry(flip, surface) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!updateFlippingPageGeometry(flip.mesh.geometry, surface)) {
|
if (!updateFlippingPageGeometry(flip.mesh.geometry, surface)) {
|
||||||
const geometry = createFlippingPageGeometry(surface);
|
const geometry = createFlippingPageGeometry(surface, flip.direction);
|
||||||
flip.mesh.geometry.dispose();
|
flip.mesh.geometry.dispose();
|
||||||
flip.mesh.geometry = geometry;
|
flip.mesh.geometry = geometry;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFlippingPageGeometry(surface) {
|
function createFlippingPageGeometry(surface, direction = 1) {
|
||||||
const positions = [];
|
const positions = [];
|
||||||
const uvs = [];
|
const uvs = [];
|
||||||
const indices = [];
|
const indices = [];
|
||||||
@@ -3063,10 +3138,12 @@ function createFlippingPageGeometry(surface) {
|
|||||||
const pageThickness = Math.max(0.0008, Number(PROCEDURAL_BOOK.SHEET_THICKNESS_MODEL || 0.001));
|
const pageThickness = Math.max(0.0008, Number(PROCEDURAL_BOOK.SHEET_THICKNESS_MODEL || 0.001));
|
||||||
const widthSegments = surface.length - 1;
|
const widthSegments = surface.length - 1;
|
||||||
const depthSegments = surface[0].length - 1;
|
const depthSegments = surface[0].length - 1;
|
||||||
const push = (point, yOffset, u, v) => {
|
const sourceSide = direction > 0 ? 1 : -1;
|
||||||
|
const targetSide = -sourceSide;
|
||||||
|
const push = (point, yOffset, uv) => {
|
||||||
const index = positions.length / 3;
|
const index = positions.length / 3;
|
||||||
positions.push(point.x, point.y + yOffset, point.z);
|
positions.push(point.x, point.y + yOffset, point.z);
|
||||||
uvs.push(u, v);
|
uvs.push(uv.x, uv.y);
|
||||||
return index;
|
return index;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3076,8 +3153,8 @@ function createFlippingPageGeometry(surface) {
|
|||||||
const u = widthSegments <= 0 ? 0 : widthIndex / widthSegments;
|
const u = widthSegments <= 0 ? 0 : widthIndex / widthSegments;
|
||||||
rowPoints.forEach((point, depthIndex) => {
|
rowPoints.forEach((point, depthIndex) => {
|
||||||
const v = depthSegments <= 0 ? 0 : depthIndex / depthSegments;
|
const v = depthSegments <= 0 ? 0 : depthIndex / depthSegments;
|
||||||
topRow.push(push(point, pageThickness, u, v));
|
topRow.push(push(point, pageThickness, pageUvForSide(sourceSide, u, v)));
|
||||||
bottomRow.push(push(point, 0, u, 1 - v));
|
bottomRow.push(push(point, 0, pageUvForSide(targetSide, u, v)));
|
||||||
});
|
});
|
||||||
topGrid.push(topRow);
|
topGrid.push(topRow);
|
||||||
bottomGrid.push(bottomRow);
|
bottomGrid.push(bottomRow);
|
||||||
@@ -3123,6 +3200,13 @@ function createFlippingPageGeometry(surface) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pageUvForSide(side, u, v) {
|
||||||
|
return {
|
||||||
|
x: side < 0 ? 1 - u : u,
|
||||||
|
y: 1 - v
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function updateFlippingPageGeometry(geometry, surface) {
|
function updateFlippingPageGeometry(geometry, surface) {
|
||||||
const position = geometry?.getAttribute?.('position');
|
const position = geometry?.getAttribute?.('position');
|
||||||
if (!position || !surface?.length || !surface[0]?.length) return false;
|
if (!position || !surface?.length || !surface[0]?.length) return false;
|
||||||
@@ -3160,6 +3244,7 @@ function finishActiveFlip(flip) {
|
|||||||
...bookPaginationState,
|
...bookPaginationState,
|
||||||
spreadIndex: Math.max(0, Math.round(Number(flip.targetSpread)))
|
spreadIndex: Math.max(0, Math.round(Number(flip.targetSpread)))
|
||||||
};
|
};
|
||||||
|
maxVisitedPagePosition = Math.max(maxVisitedPagePosition, getCurrentPagePosition());
|
||||||
syncReadingProgressToCurrentPage();
|
syncReadingProgressToCurrentPage();
|
||||||
}
|
}
|
||||||
document.dispatchEvent(new CustomEvent('webgl-book:page-flip-finished', {
|
document.dispatchEvent(new CustomEvent('webgl-book:page-flip-finished', {
|
||||||
|
|||||||
@@ -351,6 +351,10 @@ class WebGLBookSceneModule extends BaseModule {
|
|||||||
this.persistenceManager?.updatePreference?.('webgl', 'bookProgress', progress);
|
this.persistenceManager?.updatePreference?.('webgl', 'bookProgress', progress);
|
||||||
window.BookLabDebug?.setReadingProgress?.(progress);
|
window.BookLabDebug?.setReadingProgress?.(progress);
|
||||||
}
|
}
|
||||||
|
const maxVisitedPagePosition = Number(state.maxVisitedPagePosition ?? state.pagePosition);
|
||||||
|
if (Number.isFinite(maxVisitedPagePosition)) {
|
||||||
|
window.BookLabDebug?.setMaxVisitedPagePosition?.(maxVisitedPagePosition);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,11 +84,13 @@ class WebGLPageCacheModule extends BaseModule {
|
|||||||
return this.db.transaction([this.storeName], mode).objectStore(this.storeName);
|
return this.db.transaction([this.storeName], mode).objectStore(this.storeName);
|
||||||
}
|
}
|
||||||
|
|
||||||
makePageKey({ pageIndex, width, height, cacheKey = window.MODULE_CACHE_BUSTER || 'dev' } = {}) {
|
makePageKey({ pageIndex, width, height, kind = 'content', section = 'body', cacheKey = window.MODULE_CACHE_BUSTER || 'dev' } = {}) {
|
||||||
const safePage = Math.max(0, Math.round(Number(pageIndex || 0)));
|
const safePage = Math.max(0, Math.round(Number(pageIndex || 0)));
|
||||||
const safeWidth = Math.max(1, Math.round(Number(width || 0)));
|
const safeWidth = Math.max(1, Math.round(Number(width || 0)));
|
||||||
const safeHeight = Math.max(1, Math.round(Number(height || 0)));
|
const safeHeight = Math.max(1, Math.round(Number(height || 0)));
|
||||||
return `${cacheKey}:page:${safePage}:${safeWidth}x${safeHeight}`;
|
const safeKind = String(kind || 'content').replace(/[^a-z0-9_-]/gi, '');
|
||||||
|
const safeSection = String(section || 'body').replace(/[^a-z0-9_-]/gi, '');
|
||||||
|
return `${cacheKey}:page:${safePage}:${safeKind}:${safeSection}:${safeWidth}x${safeHeight}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async cachePageCanvas(pageMeta = {}, canvas = null) {
|
async cachePageCanvas(pageMeta = {}, canvas = null) {
|
||||||
@@ -99,6 +101,8 @@ class WebGLPageCacheModule extends BaseModule {
|
|||||||
pageIndex,
|
pageIndex,
|
||||||
width: canvas.width,
|
width: canvas.width,
|
||||||
height: canvas.height,
|
height: canvas.height,
|
||||||
|
kind: pageMeta.kind,
|
||||||
|
section: pageMeta.section,
|
||||||
cacheKey: pageMeta.cacheKey
|
cacheKey: pageMeta.cacheKey
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
@@ -119,6 +123,8 @@ class WebGLPageCacheModule extends BaseModule {
|
|||||||
height: canvas.height,
|
height: canvas.height,
|
||||||
contentVersion: Math.max(0, Number(pageMeta.contentVersion || 0)),
|
contentVersion: Math.max(0, Number(pageMeta.contentVersion || 0)),
|
||||||
completenessScore: Math.max(0, Number(pageMeta.completenessScore || 0)),
|
completenessScore: Math.max(0, Number(pageMeta.completenessScore || 0)),
|
||||||
|
kind: pageMeta.kind || 'content',
|
||||||
|
section: pageMeta.section || 'body',
|
||||||
maxBlockId: Math.max(0, Number(pageMeta.maxBlockId || 0)),
|
maxBlockId: Math.max(0, Number(pageMeta.maxBlockId || 0)),
|
||||||
lineCount: Math.max(0, Number(pageMeta.lineCount || 0)),
|
lineCount: Math.max(0, Number(pageMeta.lineCount || 0)),
|
||||||
blob,
|
blob,
|
||||||
@@ -168,6 +174,8 @@ class WebGLPageCacheModule extends BaseModule {
|
|||||||
const canvas = await this.blobToCanvas(entry.blob, entry.width, entry.height);
|
const canvas = await this.blobToCanvas(entry.blob, entry.width, entry.height);
|
||||||
if (canvas) canvas.__webglPageCacheMeta = {
|
if (canvas) canvas.__webglPageCacheMeta = {
|
||||||
pageIndex: entry.pageIndex,
|
pageIndex: entry.pageIndex,
|
||||||
|
kind: entry.kind || pageMeta.kind || 'content',
|
||||||
|
section: entry.section || pageMeta.section || 'body',
|
||||||
contentVersion: entry.contentVersion,
|
contentVersion: entry.contentVersion,
|
||||||
completenessScore: entry.completenessScore,
|
completenessScore: entry.completenessScore,
|
||||||
maxBlockId: entry.maxBlockId,
|
maxBlockId: entry.maxBlockId,
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ const checks = [
|
|||||||
['webgl page text textures avoid mipmap generation', /function configurePageCanvasTexture/.test(source) && /texture\.minFilter = THREE\.LinearFilter/.test(source) && /texture\.generateMipmaps = false/.test(source)],
|
['webgl page text textures avoid mipmap generation', /function configurePageCanvasTexture/.test(source) && /texture\.minFilter = THREE\.LinearFilter/.test(source) && /texture\.generateMipmaps = false/.test(source)],
|
||||||
['webgl reveal shader masks against a base-page texture instead of flat color blocks', /bookRevealBaseMap/.test(source) && /bookRevealUseBaseMap/.test(source) && /revealBaseColor/.test(source) && /baseCanvas/.test(textureRendererSource)],
|
['webgl reveal shader masks against a base-page texture instead of flat color blocks', /bookRevealBaseMap/.test(source) && /bookRevealUseBaseMap/.test(source) && /revealBaseColor/.test(source) && /baseCanvas/.test(textureRendererSource)],
|
||||||
['webgl reveal shader masks antialiased ink and uses smooth line-dominant scan', /smoothstep\(0\.52, 0\.9, luminance\)/.test(source) && /local\.x \* 0\.96/.test(source) && /bookRevealSoftness = \{ value: 0\.025 \}/.test(source)],
|
['webgl reveal shader masks antialiased ink and uses smooth line-dominant scan', /smoothstep\(0\.52, 0\.9, luminance\)/.test(source) && /local\.x \* 0\.96/.test(source) && /bookRevealSoftness = \{ value: 0\.025 \}/.test(source)],
|
||||||
['webgl reveal line timings use absolute block word timing across split pages', /assignRevealTiming/.test(textureRendererSource) && /wordStart/.test(textureRendererSource) && /blockWordStart/.test(textureRendererSource) && /wordTimings\[wordStart\]/.test(textureRendererSource) && /durationMs: sideRegions\.reduce/.test(textureRendererSource)],
|
['webgl reveal line timings use global area timing across split-page spreads', /assignRevealTiming/.test(textureRendererSource) && /sourceSpreads/.test(textureRendererSource) && /this\.pagination\?\.spreads/.test(textureRendererSource) && /spreadIndex/.test(textureRendererSource) && /totalDuration \* \(Math\.max\(1, region\.area\) \/ totalArea\)/.test(textureRendererSource) && /durationMs: sideRegions\.reduce/.test(textureRendererSource)],
|
||||||
['webgl page format reduces only outer margins from previous value', /outerBaseIn: 0\.27/.test(pageFormatSource) && /outerThicknessFactor: 0\.015/.test(pageFormatSource) && /outerMaxIn: 0\.315/.test(pageFormatSource) && /innerBaseIn: 0\.42/.test(pageFormatSource)],
|
['webgl page format reduces only outer margins from previous value', /outerBaseIn: 0\.27/.test(pageFormatSource) && /outerThicknessFactor: 0\.015/.test(pageFormatSource) && /outerMaxIn: 0\.315/.test(pageFormatSource) && /innerBaseIn: 0\.42/.test(pageFormatSource)],
|
||||||
['webgl mode enlarges and inverts DOM overlay text without touching 2D mode', /body\.webgl-mode \{[\s\S]*font-size: 18px;/.test(styleSource) && /body\.webgl-mode \.choice-list \.choice-button/.test(styleSource) && /rgba\(246, 231, 201/.test(styleSource)],
|
['webgl mode enlarges and inverts DOM overlay text without touching 2D mode', /body\.webgl-mode \{[\s\S]*font-size: 18px;/.test(styleSource) && /body\.webgl-mode \.choice-list \.choice-button/.test(styleSource) && /rgba\(246, 231, 201/.test(styleSource)],
|
||||||
['webgl choice overlay hides title clutter and prevents horizontal scrollbar', /body\.webgl-mode #page_left #game_title/.test(styleSource) && /body\.webgl-mode #page_left #start_prompt/.test(styleSource) && /overflow-x: hidden/.test(styleSource) && /book\.style\.width = 'min\(44rem/.test(webglSceneSource)],
|
['webgl choice overlay hides title clutter and prevents horizontal scrollbar', /body\.webgl-mode #page_left #game_title/.test(styleSource) && /body\.webgl-mode #page_left #start_prompt/.test(styleSource) && /overflow-x: hidden/.test(styleSource) && /book\.style\.width = 'min\(44rem/.test(webglSceneSource)],
|
||||||
@@ -175,12 +175,13 @@ const checks = [
|
|||||||
['webgl scene does not republish 3D page textures from DOM refresh events', !/addEventListener\(document, 'story:turn-start', this\.triggerTextureRefresh\)/.test(webglSceneSource) && !/addEventListener\(document, 'story:turn-complete', this\.triggerTextureRefresh\)/.test(webglSceneSource) && !/addEventListener\(document, 'story:history-updated', this\.triggerTextureRefresh\)/.test(webglSceneSource) && !/addEventListener\(document, 'input', this\.triggerTextureRefresh/.test(webglSceneSource) && !/addEventListener\(document, 'change', this\.triggerTextureRefresh/.test(webglSceneSource)],
|
['webgl scene does not republish 3D page textures from DOM refresh events', !/addEventListener\(document, 'story:turn-start', this\.triggerTextureRefresh\)/.test(webglSceneSource) && !/addEventListener\(document, 'story:turn-complete', this\.triggerTextureRefresh\)/.test(webglSceneSource) && !/addEventListener\(document, 'story:history-updated', this\.triggerTextureRefresh\)/.test(webglSceneSource) && !/addEventListener\(document, 'input', this\.triggerTextureRefresh/.test(webglSceneSource) && !/addEventListener\(document, 'change', this\.triggerTextureRefresh/.test(webglSceneSource)],
|
||||||
['webgl scene adoptPageContent does not republish 3D page textures', !/triggerTextureRefresh/.test(methodBody(webglSceneSource, 'adoptPageContent'))],
|
['webgl scene adoptPageContent does not republish 3D page textures', !/triggerTextureRefresh/.test(methodBody(webglSceneSource, 'adoptPageContent'))],
|
||||||
['webgl book starts at progress zero', /const DEFAULT_BOOK_PROGRESS = 0;/.test(webglSceneSource) && /appInitialState\.progress \?\? '0'/.test(source)],
|
['webgl book starts at progress zero', /const DEFAULT_BOOK_PROGRESS = 0;/.test(webglSceneSource) && /appInitialState\.progress \?\? '0'/.test(source)],
|
||||||
['pagination opens with blank left and title right spread', /this\.createBlankPage\(0, \{ section: 'frontmatter' \}\)/.test(bookPaginationSource) && /this\.createTitlePage\(1\)/.test(bookPaginationSource) && /this\.createBlankPage\(2, \{ section: 'frontmatter' \}\)/.test(bookPaginationSource)],
|
['pagination opens with blank left and title right spread', /this\.createBlankPage\(0, \{ section: 'frontmatter' \}\)/.test(bookPaginationSource) && /this\.createTitlePage\(1\)/.test(bookPaginationSource) && /this\.createBlankPage\(2, \{ section: 'frontmatter' \}\)/.test(bookPaginationSource) && /this\.pages = this\.buildPages\(\[\]\);/.test(bookPaginationSource) && /this\.currentSpreadIndex = 0;[\s\S]*this\.publish\(\{ reason: 'initial-title-spread', allowFutureUnrendered: true \}\);/.test(bookPaginationSource)],
|
||||||
|
['texture renderer adopts initial pagination spread so title page is painted after loader order', /this\.currentSpread = this\.pagination\?\.getCurrentSpread\?\.\(\) \|\| \{ index: 0/.test(textureRendererSource) && /this\.drawSpread\(this\.currentSpread\);/.test(textureRendererSource)],
|
||||||
['pagination publishes page metadata and advances near the end of physical flips', /pageMeta/.test(bookPaginationSource) && /webgl-book:page-flip-near-end/.test(bookPaginationSource) && /this\.setCurrentSpread\(this\.currentSpreadIndex \+ direction\)/.test(bookPaginationSource)],
|
['pagination publishes page metadata and advances near the end of physical flips', /pageMeta/.test(bookPaginationSource) && /webgl-book:page-flip-near-end/.test(bookPaginationSource) && /this\.setCurrentSpread\(this\.currentSpreadIndex \+ direction\)/.test(bookPaginationSource)],
|
||||||
['texture renderer draws title page and page numbers from versioned page metadata', /drawTitlePage/.test(textureRendererSource) && /game_title/.test(textureRendererSource) && /drawPageNumber/.test(textureRendererSource) && /pageMeta: this\.buildPublishPageMeta\(sidesToPublish\)/.test(textureRendererSource)],
|
['texture renderer draws title page and page numbers from versioned page metadata', /drawTitlePage/.test(textureRendererSource) && /game_title/.test(textureRendererSource) && /drawPageNumber/.test(textureRendererSource) && /pageMeta: this\.buildPublishPageMeta\(sidesToPublish\)/.test(textureRendererSource)],
|
||||||
['texture renderer uses plural page margin metrics for page numbers', /this\.metrics\.margins\.bottom/.test(textureRendererSource) && !/this\.metrics\.margin\.bottom/.test(textureRendererSource)],
|
['texture renderer uses plural page margin metrics for page numbers', /this\.metrics\.margins\.bottom/.test(textureRendererSource) && !/this\.metrics\.margin\.bottom/.test(textureRendererSource)],
|
||||||
['webgl flip borrows resident page texture and blanks right stack before forward animation', /prepareStaticPageForFlip/.test(source) && /materials\.flipPageSurface\.map = sourceTexture/.test(source) && /materials\.rightPage\.map = blankTexture/.test(source) && /webgl-book:page-flip-near-end/.test(source)],
|
['webgl flip borrows resident page texture and blanks right stack before forward animation', /prepareStaticPageForFlip/.test(source) && /materials\.flipPageSurface\.map = sourceTexture/.test(source) && /materials\.rightPage\.map = blankTexture/.test(source) && /webgl-book:page-flip-near-end/.test(source)],
|
||||||
['webgl flip never falls back to the opposite visible stack for target back texture', /const residentBackTexture = getResidentPageTexture\(targetBackPageIndex\)/.test(source) && /const backTexture = residentBackTexture \|\| getBlankPageTexture\(\)/.test(source) && !/oppositeMaterial\?\.map/.test(methodBody(source, 'prepareStaticPageForFlip'))],
|
['webgl flip never falls back to the opposite visible stack for target back texture', /const prewarmedBackTexture = flip\.direction > 0 \? prewarm\?\.next\?\.left : prewarm\?\.next\?\.right/.test(source) && /const residentBackTexture = prewarmedBackTexture \|\| getResidentPageTexture\(targetBackPageIndex\)/.test(source) && /const backTexture = residentBackTexture \|\| getBlankPageTexture\(\)/.test(source) && !/oppositeMaterial\?\.map/.test(methodBody(source, 'prepareStaticPageForFlip'))],
|
||||||
['webgl page canvas metadata accepts explicit blank sides instead of retaining stale pages', /hasLeftMeta/.test(source) && /hasRightMeta/.test(source) && /Object\.prototype\.hasOwnProperty\.call\(detail\.pageMeta, 'right'\)/.test(source)],
|
['webgl page canvas metadata accepts explicit blank sides instead of retaining stale pages', /hasLeftMeta/.test(source) && /hasRightMeta/.test(source) && /Object\.prototype\.hasOwnProperty\.call\(detail\.pageMeta, 'right'\)/.test(source)],
|
||||||
['texture renderer publishes both spread sides for reveal preparation and fallback start', /this\.drawSpread\(this\.currentSpread \|\| this\.pagination\?\.getCurrentSpread\?\.\(\), \['left', 'right'\]\)/.test(textureRendererSource) && /const sides = \['left', 'right'\]/.test(textureRendererSource) && /pageMeta: prepared\.pageMeta \|\| \{\}/.test(textureRendererSource)],
|
['texture renderer publishes both spread sides for reveal preparation and fallback start', /this\.drawSpread\(this\.currentSpread \|\| this\.pagination\?\.getCurrentSpread\?\.\(\), \['left', 'right'\]\)/.test(textureRendererSource) && /const sides = \['left', 'right'\]/.test(textureRendererSource) && /pageMeta: prepared\.pageMeta \|\| \{\}/.test(textureRendererSource)],
|
||||||
['texture renderer marks page canvases with content versions before cache writes', /pageContentVersions/.test(textureRendererSource) && /buildPublishPageMeta/.test(textureRendererSource) && /completenessScore: \(maxBlockId \* 1000\) \+ lineCount/.test(textureRendererSource)],
|
['texture renderer marks page canvases with content versions before cache writes', /pageContentVersions/.test(textureRendererSource) && /buildPublishPageMeta/.test(textureRendererSource) && /completenessScore: \(maxBlockId \* 1000\) \+ lineCount/.test(textureRendererSource)],
|
||||||
@@ -188,7 +189,7 @@ const checks = [
|
|||||||
['webgl resident page texture cache rejects older page versions before direct reuse', /isOlderPageTextureMeta/.test(source) && /getResidentPageTextureForMeta/.test(source) && /getResidentPageTextureForMeta\(pageMeta\)/.test(source)],
|
['webgl resident page texture cache rejects older page versions before direct reuse', /isOlderPageTextureMeta/.test(source) && /getResidentPageTextureForMeta/.test(source) && /getResidentPageTextureForMeta\(pageMeta\)/.test(source)],
|
||||||
['webgl flip page is a two-sided body using paper-thickness constants', /flipPageBackSurface/.test(source) && /flipPageEdge/.test(source) && /new THREE\.Mesh\(geometry, \[\s*materials\.flipPageSurface,\s*materials\.flipPageBackSurface,\s*materials\.flipPageEdge\s*\]\)/.test(source) && /PROCEDURAL_BOOK\.SHEET_THICKNESS_MODEL/.test(source) && /geometry\.addGroup\(0, topIndices\.length, 0\)/.test(source)],
|
['webgl flip page is a two-sided body using paper-thickness constants', /flipPageBackSurface/.test(source) && /flipPageEdge/.test(source) && /new THREE\.Mesh\(geometry, \[\s*materials\.flipPageSurface,\s*materials\.flipPageBackSurface,\s*materials\.flipPageEdge\s*\]\)/.test(source) && /PROCEDURAL_BOOK\.SHEET_THICKNESS_MODEL/.test(source) && /geometry\.addGroup\(0, topIndices\.length, 0\)/.test(source)],
|
||||||
['webgl animated page front and back maps are independently switchable before animation starts', /materials\.flipPageBackSurface = materials\.flipPageSurface\.clone\(\)/.test(source) && /materials\.flipPageSurface\.map = sourceTexture/.test(source) && /materials\.flipPageBackSurface\.map = backTexture/.test(source) && /flip\.sourceTexture = sourceTexture/.test(source) && /flip\.backTexture = backTexture/.test(source)],
|
['webgl animated page front and back maps are independently switchable before animation starts', /materials\.flipPageBackSurface = materials\.flipPageSurface\.clone\(\)/.test(source) && /materials\.flipPageSurface\.map = sourceTexture/.test(source) && /materials\.flipPageBackSurface\.map = backTexture/.test(source) && /flip\.sourceTexture = sourceTexture/.test(source) && /flip\.backTexture = backTexture/.test(source)],
|
||||||
['webgl animated page back face uses its own unflipped page orientation', /bottomRow\.push\(push\(point, 0, u, 1 - v\)\)/.test(source)],
|
['webgl animated page back face uses destination-side page UV orientation', /pageUvForSide\(targetSide, u, v\)/.test(source) && /pageUvForSide\(sourceSide, u, v\)/.test(source) && /side < 0 \? 1 - u : u/.test(source)],
|
||||||
['webgl animated page reuses geometry buffers during flips', /function updateFlippingPageGeometry/.test(source) && /position\.needsUpdate = true/.test(source) && /updateFlippingPageGeometry\(flip\.mesh\.geometry, surface\)/.test(source) && !/flip\.mesh\.geometry\.dispose\(\);\s*flip\.mesh\.geometry = geometry;/.test(methodBody(source, 'setActivePageGeometry'))],
|
['webgl animated page reuses geometry buffers during flips', /function updateFlippingPageGeometry/.test(source) && /position\.needsUpdate = true/.test(source) && /updateFlippingPageGeometry\(flip\.mesh\.geometry, surface\)/.test(source) && !/flip\.mesh\.geometry\.dispose\(\);\s*flip\.mesh\.geometry = geometry;/.test(methodBody(source, 'setActivePageGeometry'))],
|
||||||
['webgl scene targets 60fps with browser-frame scheduling live mirror and static heavy refresh', /const targetFrameDurationMs = 1000 \/ 60/.test(source) && /this\.targetFrameDurationMs = 1000 \/ 60/.test(textureRendererSource) && /requestAnimationFrame\(animate\)/.test(source) && /const refreshStaticSceneBuffers = staticSceneBuffersDirty \|\| activeFlips\.length > 0/.test(source) && /updateTableReflection\(\);/.test(source) && /mirrorRefreshesEveryFrame: true/.test(source) && !/setTimeout\(animate/.test(source)],
|
['webgl scene targets 60fps with browser-frame scheduling live mirror and static heavy refresh', /const targetFrameDurationMs = 1000 \/ 60/.test(source) && /this\.targetFrameDurationMs = 1000 \/ 60/.test(textureRendererSource) && /requestAnimationFrame\(animate\)/.test(source) && /const refreshStaticSceneBuffers = staticSceneBuffersDirty \|\| activeFlips\.length > 0/.test(source) && /updateTableReflection\(\);/.test(source) && /mirrorRefreshesEveryFrame: true/.test(source) && !/setTimeout\(animate/.test(source)],
|
||||||
['webgl scene lowers mirror target and caps table film maps to 2k', /const reflectionPixelRatio = 1/.test(source) && /const tableReflectionBaseWidth = 2048/.test(source) && /const tableReflectionBaseHeight = 1152/.test(source) && /tableDustTexture = loadUtilityTexture\('\/assets\/webgl\/table_dust_4k\.png', \{ maxSize: 2048 \}\)/.test(source) && /tableGreaseTexture = loadUtilityTexture\('\/assets\/webgl\/table_grease_4k\.png', \{ maxSize: 2048 \}\)/.test(source)],
|
['webgl scene lowers mirror target and caps table film maps to 2k', /const reflectionPixelRatio = 1/.test(source) && /const tableReflectionBaseWidth = 2048/.test(source) && /const tableReflectionBaseHeight = 1152/.test(source) && /tableDustTexture = loadUtilityTexture\('\/assets\/webgl\/table_dust_4k\.png', \{ maxSize: 2048 \}\)/.test(source) && /tableGreaseTexture = loadUtilityTexture\('\/assets\/webgl\/table_grease_4k\.png', \{ maxSize: 2048 \}\)/.test(source)],
|
||||||
@@ -205,7 +206,12 @@ const checks = [
|
|||||||
['webgl right-page completion arms a durable autoplay-targeted flip without bypassing choices', /handleRevealCommittedForPageFlip/.test(source) && /tryStartPendingRightPageFlip/.test(source) && /pendingRightPageFlipAutoplay/.test(source) && /const targetSpread = Math\.max\(0, Math\.round\(Number\(bookPaginationState\.spreadIndex \|\| 0\)\) \+ 1\)/.test(source) && /force: options\.force === true \|\| pendingRightPageFlipAutoplay/.test(source) && /isChoiceAwaitingPlayer/.test(source) && /pendingRightPageFlip = true/.test(source)],
|
['webgl right-page completion arms a durable autoplay-targeted flip without bypassing choices', /handleRevealCommittedForPageFlip/.test(source) && /tryStartPendingRightPageFlip/.test(source) && /pendingRightPageFlipAutoplay/.test(source) && /const targetSpread = Math\.max\(0, Math\.round\(Number\(bookPaginationState\.spreadIndex \|\| 0\)\) \+ 1\)/.test(source) && /force: options\.force === true \|\| pendingRightPageFlipAutoplay/.test(source) && /isChoiceAwaitingPlayer/.test(source) && /pendingRightPageFlip = true/.test(source)],
|
||||||
['webgl reveal clock follows absolute playback time and continues across page flips', /activeRevealBlockStarts/.test(source) && /state\.visualElapsedMs = Math\.max\(0, now - state\.startedAt\)/.test(source) && !/Math\.min\(revealFrameDeltaMs, targetFrameDurationMs\)/.test(source) && /prewarmFlipTextures\(1, targetSpread\)/.test(source)],
|
['webgl reveal clock follows absolute playback time and continues across page flips', /activeRevealBlockStarts/.test(source) && /state\.visualElapsedMs = Math\.max\(0, now - state\.startedAt\)/.test(source) && !/Math\.min\(revealFrameDeltaMs, targetFrameDurationMs\)/.test(source) && /prewarmFlipTextures\(1, targetSpread\)/.test(source)],
|
||||||
['texture renderer preloads every spread touched by an active reveal block', /preloadAdditionalRevealSpreads/.test(textureRendererSource) && /spreadContainsBlock/.test(textureRendererSource) && /this\.drawSpread\(spread, \['left', 'right'\], \{ preloadOnly: true \}\)/.test(textureRendererSource) && /this\.activeAnimations\.has\(id\)/.test(textureRendererSource)],
|
['texture renderer preloads every spread touched by an active reveal block', /preloadAdditionalRevealSpreads/.test(textureRendererSource) && /spreadContainsBlock/.test(textureRendererSource) && /this\.drawSpread\(spread, \['left', 'right'\], \{ preloadOnly: true \}\)/.test(textureRendererSource) && /this\.activeAnimations\.has\(id\)/.test(textureRendererSource)],
|
||||||
|
['webgl visible spread state ignores future unrendered prepare publishes before flip', /spreadUpdate:deferred-future-unrendered/.test(source) && /incomingSpreadIndex > Math\.max\(0, Number\(bookPaginationState\.spreadIndex/.test(source) && /this\.drawSpread\(this\.currentSpread, \['left', 'right'\], \{ preloadOnly: true \}\)/.test(textureRendererSource)],
|
||||||
|
['3D overflow reveal preloads target spread before forced page flip', /previewRevealDetail/.test(uiDisplayHandlerSource) && /preloadOnly: true/.test(uiDisplayHandlerSource) && /bookTextureRenderer\.prepareRevealBlock\(previewRevealDetail, \{ preloadOnly: true \}\)/.test(uiDisplayHandlerSource) && /await this\.waitForWebGLPageFlip/.test(uiDisplayHandlerSource)],
|
||||||
|
['webgl navigation buttons use visited page limit instead of future prepared pages', /maxVisitedPagePosition/.test(source) && /navigateToPagePosition\(maxVisitedPagePosition\)/.test(source) && /const navigableLimit = Math\.min\(maxVisitedPagePosition, writableLimit\)/.test(source) && !/navigateToPagePosition\(bookPaginationState\.writtenPageLimit\)/.test(source)],
|
||||||
|
['webgl save restore carries visited page limit for navigation', /maxVisitedPagePosition/.test(source) && /setMaxVisitedPagePosition/.test(source) && /state\.maxVisitedPagePosition \?\? state\.pagePosition/.test(webglSceneSource)],
|
||||||
['webgl page flips require resident back textures before animation starts', /prepareStaticPageForFlip\(flip, prewarm = null\)/.test(source) && /flip-back-texture-missing/.test(source) && /return false;/.test(methodBody(source, 'prepareStaticPageForFlip')) && /flipTexturePreflight:ready/.test(source) && /if \(!prepareStaticPageForFlip\(flip, options\.prewarm \|\| null\)\) \{[\s\S]*return false;[\s\S]*\}/.test(source)],
|
['webgl page flips require resident back textures before animation starts', /prepareStaticPageForFlip\(flip, prewarm = null\)/.test(source) && /flip-back-texture-missing/.test(source) && /return false;/.test(methodBody(source, 'prepareStaticPageForFlip')) && /flipTexturePreflight:ready/.test(source) && /if \(!prepareStaticPageForFlip\(flip, options\.prewarm \|\| null\)\) \{[\s\S]*return false;[\s\S]*\}/.test(source)],
|
||||||
|
['webgl fast page flips preflight the actual target spread', /firstFlip\.targetSpread = Number\.isFinite\(Number\(options\.targetSpread\)\)/.test(source) && /if \(!prepareStaticPageForFlip\(firstFlip, options\.prewarm \|\| null\)\) return false/.test(source)],
|
||||||
['markup and 3d pagination accept full-page images', /'full'/.test(markupParserSource) && /size === 'full'/.test(bookPaginationSource)],
|
['markup and 3d pagination accept full-page images', /'full'/.test(markupParserSource) && /size === 'full'/.test(bookPaginationSource)],
|
||||||
['story history can persist 3d pagination decisions', /persistPaginationMetrics/.test(bookPaginationSource) && /collectPaginationMetrics/.test(bookPaginationSource) && /pageStart/.test(storyHistorySource) && /pagination: metrics\.pagination/.test(storyHistorySource)]
|
['story history can persist 3d pagination decisions', /persistPaginationMetrics/.test(bookPaginationSource) && /collectPaginationMetrics/.test(bookPaginationSource) && /pageStart/.test(storyHistorySource) && /pagination: metrics\.pagination/.test(storyHistorySource)]
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user