Queue WebGL book reveal masks

This commit is contained in:
2026-06-07 13:52:07 +02:00
parent 7fc083fb58
commit 9434950826
31 changed files with 383 additions and 73 deletions
+90 -2
View File
@@ -29,6 +29,7 @@ class BookTextureRendererModule extends BaseModule {
this.revealedBlockIds = new Set();
this.pendingRevealBlockIds = new Set();
this.revealBounds = null;
this.revealWords = null;
this.revealPublishBlockIds = null;
this.animationFrameId = null;
this.lastAnimationFrameAt = 0;
@@ -47,6 +48,8 @@ class BookTextureRendererModule extends BaseModule {
'getPageContent',
'buildLineSegments',
'startRevealAnimation',
'prepareRevealBlock',
'startPreparedRevealAnimation',
'fastForwardAnimations',
'stopAnimations',
'getBlockSides',
@@ -90,6 +93,9 @@ class BookTextureRendererModule extends BaseModule {
this.addEventListener(document, 'book-texture:reveal-block', (event) => {
this.startRevealAnimation(event.detail || {});
});
this.addEventListener(document, 'book-texture:prepare-reveal-block', (event) => {
this.prepareRevealBlock(event.detail || {});
});
this.addEventListener(document, 'book-texture:fast-forward', this.fastForwardAnimations);
this.addEventListener(document, 'ui:command', (event) => {
if (event.detail?.type === 'continue') this.fastForwardAnimations();
@@ -121,6 +127,7 @@ class BookTextureRendererModule extends BaseModule {
this.currentSpread = spread || { left: [], right: [] };
const sidesToDraw = Array.isArray(sides) && sides.length ? sides : ['left', 'right'];
this.revealBounds = { left: null, right: null };
this.revealWords = { left: [], right: [] };
sidesToDraw.forEach((side) => {
if (!this.canvases[side]) return;
this.drawPageBase(side);
@@ -128,6 +135,7 @@ class BookTextureRendererModule extends BaseModule {
});
this.publishSpread(sidesToDraw);
this.revealBounds = null;
this.revealWords = null;
this.revealPublishBlockIds = null;
}
@@ -202,7 +210,7 @@ class BookTextureRendererModule extends BaseModule {
ctx.font = `${dropCapFontPx}px "EB Garamond Initials", ${metrics.typography.fontFamily}`;
ctx.textBaseline = 'top';
ctx.fillText(String(lineRecord.dropCapText), dropCapX, dropCapY);
this.recordRevealRect(side, lineRecord, dropCapX, dropCapY, fontPx * 2.9, dropCapFontPx * 0.9);
this.recordRevealRect(side, lineRecord, dropCapX, dropCapY, fontPx * 2.9, dropCapFontPx * 0.9, 0);
ctx.restore();
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
if ('letterSpacing' in ctx) ctx.letterSpacing = smallCaps ? `${fontPx * 0.012}px` : '0px';
@@ -301,11 +309,33 @@ class BookTextureRendererModule extends BaseModule {
...nextRect,
blockIds: new Set([blockId])
};
const globalWordIndex = Math.max(0, Number(lineRecord.blockWordStart || 0) + Number(localWordIndex || 0));
const timing = Array.isArray(animation.wordTimings) ? animation.wordTimings[globalWordIndex] : null;
if (!timing || !this.revealWords?.[side]) return;
this.revealWords[side].push({
blockId,
wordIndex: globalWordIndex,
rect: {
x: nextRect.x / this.metrics.width,
y: nextRect.y / this.metrics.height,
width: Math.max(0.001, (nextRect.right - nextRect.x) / this.metrics.width),
height: Math.max(0.001, (nextRect.bottom - nextRect.y) / this.metrics.height)
},
timing: {
delay: Math.max(0, Number(timing.delay || 0)),
duration: Math.max(1, Number(timing.duration || 1))
}
});
}
startRevealAnimation(detail = {}) {
const blockId = detail.blockId ?? detail.id ?? null;
if (blockId == null || !Array.isArray(detail.wordTimings)) return;
const existing = this.activeAnimations.get(String(blockId));
if (existing && existing.prepared) {
this.startPreparedRevealAnimation(blockId);
return;
}
this.activeAnimations.set(String(blockId), {
blockId,
wordTimings: detail.wordTimings,
@@ -319,21 +349,69 @@ class BookTextureRendererModule extends BaseModule {
this.pendingRevealBlockIds.delete(String(blockId));
this.revealPublishBlockIds = new Set([String(blockId)]);
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getBlockSides(blockId));
document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-start', {
detail: {
blockId
}
}));
this.requestAnimationFrame();
}
prepareRevealBlock(detail = {}) {
const blockId = detail.blockId ?? detail.id ?? null;
if (blockId == null || !Array.isArray(detail.wordTimings)) return;
const id = String(blockId);
const wordTimings = detail.wordTimings;
this.activeAnimations.set(id, {
blockId,
wordTimings,
startedAt: null,
totalDuration: Math.max(
Number(detail.totalDuration || 0),
...wordTimings.map(timing => Number(timing.delay || 0) + Number(timing.duration || 0))
),
completed: false,
prepared: true
});
this.pendingRevealBlockIds.delete(id);
this.revealPublishBlockIds = new Set([id]);
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getBlockSides(blockId));
}
startPreparedRevealAnimation(blockId) {
const id = String(blockId ?? '');
const animation = this.activeAnimations.get(id);
if (!animation) return false;
animation.startedAt = performance.now();
animation.prepared = false;
animation.completed = false;
document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-start', {
detail: {
blockId: animation.blockId
}
}));
this.requestAnimationFrame();
return true;
}
fastForwardAnimations() {
let changed = false;
const blockIds = [];
this.activeAnimations.forEach((animation) => {
if (!animation.completed) {
animation.completed = true;
this.revealedBlockIds.add(String(animation.blockId ?? ''));
blockIds.push(animation.blockId);
changed = true;
}
});
if (changed) {
this.pendingRevealBlockIds.clear();
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getAnimatedSides(true));
document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-fast-forward', {
detail: {
blockIds
}
}));
}
}
@@ -393,6 +471,10 @@ class BookTextureRendererModule extends BaseModule {
this.activeAnimations.forEach((animation) => {
if (animation.completed) return;
if (!Array.isArray(animation.wordTimings) || animation.wordTimings.length === 0) return;
if (animation.startedAt == null) {
hasActive = true;
return;
}
const lastTiming = animation.wordTimings.at(-1);
const total = Number(lastTiming?.delay || 0) + Number(lastTiming?.duration || 0);
if (currentNow - animation.startedAt >= total + 50) {
@@ -426,6 +508,12 @@ class BookTextureRendererModule extends BaseModule {
reveal[side] = {
blockIds,
durationMs,
wordRects: (this.revealWords?.[side] || []).map(word => ({
blockId: word.blockId,
wordIndex: word.wordIndex,
rect: word.rect,
timing: word.timing
})),
bounds: {
x: bounds.x / this.metrics.width,
y: bounds.y / this.metrics.height,