Fix WebGL line reveal renderer
This commit is contained in:
@@ -30,8 +30,6 @@ class BookTextureRendererModule extends BaseModule {
|
||||
this.revealedBlockIds = new Set();
|
||||
this.pendingRevealBlockIds = new Set();
|
||||
this.preparedRevealCache = new Map();
|
||||
this.revealBounds = null;
|
||||
this.revealWords = null;
|
||||
this.revealBaseCanvases = null;
|
||||
this.revealPublishBlockIds = null;
|
||||
this.lastDrawSignature = null;
|
||||
@@ -64,7 +62,12 @@ class BookTextureRendererModule extends BaseModule {
|
||||
'drawImageFitted',
|
||||
'drawLine',
|
||||
'drawWord',
|
||||
'recordRevealRect',
|
||||
'buildRevealRegions',
|
||||
'collectRevealRegionCandidates',
|
||||
'createRevealRegionForLine',
|
||||
'getLineInkRect',
|
||||
'getLineNaturalWidth',
|
||||
'getImageRevealDurationMs',
|
||||
'getInlineStyleState',
|
||||
'updateInlineStyleState',
|
||||
'getCanvasFont',
|
||||
@@ -204,8 +207,6 @@ class BookTextureRendererModule extends BaseModule {
|
||||
revealBlockIds: this.revealPublishBlockIds ? Array.from(this.revealPublishBlockIds) : [],
|
||||
preloadOnly: Boolean(options.preloadOnly)
|
||||
});
|
||||
this.revealBounds = { left: null, right: null };
|
||||
this.revealWords = { left: [], right: [] };
|
||||
this.revealBaseCanvases = { left: null, right: null };
|
||||
sidesToDraw.forEach((side) => {
|
||||
if (!this.canvases[side]) return;
|
||||
@@ -220,8 +221,6 @@ class BookTextureRendererModule extends BaseModule {
|
||||
sides: sidesToDraw,
|
||||
preloadOnly: Boolean(options.preloadOnly)
|
||||
});
|
||||
this.revealBounds = null;
|
||||
this.revealWords = null;
|
||||
this.revealBaseCanvases = null;
|
||||
this.revealPublishBlockIds = null;
|
||||
if (!options.preloadOnly && !hasReveal) this.lastDrawSignature = drawSignature;
|
||||
@@ -448,7 +447,6 @@ 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, 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';
|
||||
@@ -578,51 +576,177 @@ class BookTextureRendererModule extends BaseModule {
|
||||
const value = segment?.value || '';
|
||||
this.applyTextStyle(ctx, fontPx, smallCaps, segment?.style || {});
|
||||
ctx.fillText(value, x, baseY);
|
||||
const width = Number(segment?.width || 0) || ctx.measureText(value).width || fontPx;
|
||||
this.recordRevealRect(side, lineRecord, x, baseY - fontPx, width, lineHeightPx, localWordIndex);
|
||||
}
|
||||
|
||||
recordRevealRect(side, lineRecord, x, y, width, height, localWordIndex = 0) {
|
||||
if (!this.revealBounds || !this.revealPublishBlockIds) return;
|
||||
const blockId = String(lineRecord?.blockId ?? '');
|
||||
if (!blockId || !this.revealPublishBlockIds.has(blockId)) return;
|
||||
const animation = this.activeAnimations.get(blockId);
|
||||
if (!animation || animation.completed) return;
|
||||
const padding = Math.max(2, Number(lineRecord.fontPx || 18) * 0.12);
|
||||
const nextRect = {
|
||||
x: Math.max(0, x - padding),
|
||||
y: Math.max(0, y - padding),
|
||||
right: Math.min(this.metrics.width, x + width + padding),
|
||||
bottom: Math.min(this.metrics.height, y + height + padding)
|
||||
};
|
||||
const current = this.revealBounds[side];
|
||||
this.revealBounds[side] = current ? {
|
||||
x: Math.min(current.x, nextRect.x),
|
||||
y: Math.min(current.y, nextRect.y),
|
||||
right: Math.max(current.right, nextRect.right),
|
||||
bottom: Math.max(current.bottom, nextRect.bottom),
|
||||
blockIds: current.blockIds.add(blockId)
|
||||
} : {
|
||||
...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))
|
||||
}
|
||||
buildRevealRegions(side) {
|
||||
if (!this.revealPublishBlockIds || !this.metrics) return null;
|
||||
const candidates = this.collectRevealRegionCandidates();
|
||||
if (!candidates.length) return null;
|
||||
const byBlock = candidates.reduce((map, region) => {
|
||||
if (!map.has(region.blockId)) map.set(region.blockId, []);
|
||||
map.get(region.blockId).push(region);
|
||||
return map;
|
||||
}, new Map());
|
||||
const regions = [];
|
||||
byBlock.forEach((blockRegions, blockId) => {
|
||||
const animation = this.activeAnimations.get(blockId);
|
||||
if (!animation || animation.completed) return;
|
||||
const fixedRegions = blockRegions.filter(region => region.fixedDurationMs > 0);
|
||||
const textRegions = blockRegions.filter(region => !(region.fixedDurationMs > 0));
|
||||
let delay = 0;
|
||||
const textDuration = Math.max(0, Number(animation.totalDuration || 0));
|
||||
const totalArea = textRegions.reduce((sum, region) => sum + Math.max(1, region.area), 0);
|
||||
textRegions.forEach((region) => {
|
||||
const duration = totalArea > 0
|
||||
? Math.max(1, textDuration * (Math.max(1, region.area) / totalArea))
|
||||
: Math.max(1, textDuration / Math.max(1, textRegions.length));
|
||||
regions.push({
|
||||
...region,
|
||||
timing: {
|
||||
delay,
|
||||
duration
|
||||
}
|
||||
});
|
||||
delay += duration;
|
||||
});
|
||||
fixedRegions.forEach((region) => {
|
||||
regions.push({
|
||||
...region,
|
||||
timing: {
|
||||
delay,
|
||||
duration: Math.max(1, region.fixedDurationMs)
|
||||
}
|
||||
});
|
||||
delay += Math.max(1, region.fixedDurationMs);
|
||||
});
|
||||
});
|
||||
const sideRegions = regions.filter(region => region.side === side);
|
||||
if (!sideRegions.length) return null;
|
||||
const bounds = sideRegions.reduce((box, region) => ({
|
||||
x: Math.min(box.x, region.pixelRect.x),
|
||||
y: Math.min(box.y, region.pixelRect.y),
|
||||
right: Math.max(box.right, region.pixelRect.right),
|
||||
bottom: Math.max(box.bottom, region.pixelRect.bottom)
|
||||
}), {
|
||||
x: this.metrics.width,
|
||||
y: this.metrics.height,
|
||||
right: 0,
|
||||
bottom: 0
|
||||
});
|
||||
return {
|
||||
blockIds: Array.from(byBlock.keys()),
|
||||
durationMs: regions.reduce((maxDuration, region) => Math.max(maxDuration, region.timing.delay + region.timing.duration), 0),
|
||||
baseCanvas: null,
|
||||
lineRects: sideRegions.map(region => ({
|
||||
blockId: region.blockId,
|
||||
lineIndex: region.lineIndex,
|
||||
rect: region.rect,
|
||||
timing: region.timing
|
||||
})),
|
||||
bounds: {
|
||||
x: bounds.x / this.metrics.width,
|
||||
y: bounds.y / this.metrics.height,
|
||||
width: Math.max(0.001, (bounds.right - bounds.x) / this.metrics.width),
|
||||
height: Math.max(0.001, (bounds.bottom - bounds.y) / this.metrics.height)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
collectRevealRegionCandidates() {
|
||||
const candidates = [];
|
||||
['left', 'right'].forEach((side) => {
|
||||
const spreadLines = Array.isArray(this.currentSpread?.[side]) ? this.currentSpread[side] : [];
|
||||
spreadLines.forEach((lineRecord) => {
|
||||
const region = this.createRevealRegionForLine(side, lineRecord);
|
||||
if (region) candidates.push(region);
|
||||
});
|
||||
});
|
||||
return candidates;
|
||||
}
|
||||
|
||||
createRevealRegionForLine(side, lineRecord = {}) {
|
||||
const blockId = String(lineRecord?.blockId ?? '');
|
||||
if (!blockId || !this.revealPublishBlockIds.has(blockId)) return null;
|
||||
const animation = this.activeAnimations.get(blockId);
|
||||
if (!animation || animation.completed) return null;
|
||||
if (lineRecord.type === 'image' || lineRecord.kind === 'image') {
|
||||
const content = this.getPageContent(side);
|
||||
const rect = lineRecord.metadata?.imageLayout?.textureRect || {};
|
||||
const x = content.x + Number(rect.x || 0);
|
||||
const y = content.y + Number(rect.y || 0);
|
||||
const width = Math.max(1, Number(rect.width || content.width));
|
||||
const height = Math.max(1, Number(rect.height || this.metrics.typographyLineHeightPx));
|
||||
return this.normalizeRevealRegion(side, blockId, lineRecord, x, y, width, height, this.getImageRevealDurationMs(lineRecord));
|
||||
}
|
||||
const rect = this.getLineInkRect(side, lineRecord);
|
||||
if (!rect) return null;
|
||||
return this.normalizeRevealRegion(side, blockId, lineRecord, rect.x, rect.y, rect.width, rect.height, 0);
|
||||
}
|
||||
|
||||
normalizeRevealRegion(side, blockId, lineRecord, x, y, width, height, fixedDurationMs = 0) {
|
||||
const padding = Math.max(2, Number(lineRecord.fontPx || 18) * 0.12);
|
||||
const left = Math.max(0, x - padding);
|
||||
const top = Math.max(0, y - padding);
|
||||
const right = Math.min(this.metrics.width, x + width + padding);
|
||||
const bottom = Math.min(this.metrics.height, y + height + padding);
|
||||
const rectWidth = Math.max(1, right - left);
|
||||
const rectHeight = Math.max(1, bottom - top);
|
||||
return {
|
||||
side,
|
||||
blockId,
|
||||
lineIndex: Number(lineRecord.lineIndex ?? lineRecord.pageLine ?? 0),
|
||||
fixedDurationMs,
|
||||
area: rectWidth * rectHeight,
|
||||
pixelRect: { x: left, y: top, right, bottom },
|
||||
rect: {
|
||||
x: left / this.metrics.width,
|
||||
y: top / this.metrics.height,
|
||||
width: Math.max(0.001, rectWidth / this.metrics.width),
|
||||
height: Math.max(0.001, rectHeight / this.metrics.height)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
getLineInkRect(side, lineRecord = {}) {
|
||||
const content = this.getPageContent(side);
|
||||
const fontPx = Math.max(1, Number(lineRecord.fontPx || 22));
|
||||
const lineHeightPx = Math.max(fontPx + 2, Number(lineRecord.lineHeightPx || this.metrics.typographyLineHeightPx || 30));
|
||||
const line = lineRecord.line || {};
|
||||
const naturalWidth = this.getLineNaturalWidth(line);
|
||||
const centerOffset = line.align === 'center'
|
||||
? Math.max(0, (content.width - naturalWidth) / 2)
|
||||
: Number(line.offset || 0);
|
||||
const measuredWidth = Number(line.measure || lineRecord.measure || 0);
|
||||
const isJustified = line.align !== 'center' && !line.isFinal;
|
||||
let x = content.x + centerOffset;
|
||||
let y = content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx);
|
||||
let width = Math.max(1, Math.min(content.width - centerOffset, isJustified ? (measuredWidth || content.width - centerOffset) : (naturalWidth || measuredWidth || content.width - centerOffset)));
|
||||
let height = lineHeightPx;
|
||||
if (lineRecord.dropCapText) {
|
||||
const dropCapFontPx = Math.round(fontPx * 2.68);
|
||||
const dropCapY = content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) + (fontPx * 0.25);
|
||||
const dropCapWidth = fontPx * 2.9;
|
||||
const normalRight = x + width;
|
||||
x = Math.min(content.x, x);
|
||||
y = Math.min(y, dropCapY);
|
||||
width = Math.max(normalRight, content.x + dropCapWidth) - x;
|
||||
height = Math.max((content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx)) + lineHeightPx, dropCapY + (dropCapFontPx * 0.9)) - y;
|
||||
}
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
getLineNaturalWidth(line = {}) {
|
||||
const nodes = Array.isArray(line.nodes) ? line.nodes : [];
|
||||
return nodes.reduce((sum, node) => {
|
||||
if (node?.type === 'box' || node?.type === 'glue') return sum + Number(node.width || 0);
|
||||
return sum;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
getImageRevealDurationMs(lineRecord = {}) {
|
||||
const metadata = lineRecord.metadata || {};
|
||||
const explicit = Number(metadata.animationMs || metadata.revealMs || metadata.imageRevealMs || 0);
|
||||
return Number.isFinite(explicit) && explicit > 0 ? explicit : 2000;
|
||||
}
|
||||
|
||||
startRevealAnimation(detail = {}) {
|
||||
@@ -822,6 +946,10 @@ class BookTextureRendererModule extends BaseModule {
|
||||
this.animationFrameId = window.setTimeout(() => this.tickAnimations(performance.now()), this.targetFrameDurationMs);
|
||||
}
|
||||
|
||||
isWebGLPageFlipActive() {
|
||||
return document.documentElement.dataset.webglPageFlipActive === 'true';
|
||||
}
|
||||
|
||||
tickAnimations(now) {
|
||||
this.animationFrameId = null;
|
||||
if (now - this.lastAnimationFrameAt < this.targetFrameDurationMs) {
|
||||
@@ -832,6 +960,18 @@ class BookTextureRendererModule extends BaseModule {
|
||||
|
||||
let hasActive = false;
|
||||
const currentNow = performance.now();
|
||||
if (this.isWebGLPageFlipActive()) {
|
||||
this.activeAnimations.forEach((animation) => {
|
||||
if (animation.completed) return;
|
||||
if (!Array.isArray(animation.wordTimings) || animation.wordTimings.length === 0) return;
|
||||
hasActive = true;
|
||||
if (animation.startedAt != null) {
|
||||
animation.startedAt += this.targetFrameDurationMs;
|
||||
}
|
||||
});
|
||||
if (hasActive) this.requestAnimationFrame();
|
||||
return;
|
||||
}
|
||||
this.activeAnimations.forEach((animation) => {
|
||||
if (animation.completed) return;
|
||||
if (!Array.isArray(animation.wordTimings) || animation.wordTimings.length === 0) return;
|
||||
@@ -853,9 +993,9 @@ class BookTextureRendererModule extends BaseModule {
|
||||
|
||||
publishSpread(sides = null, options = {}) {
|
||||
const sidesToPublish = Array.isArray(sides) && sides.length ? sides : ['left', 'right'];
|
||||
const wordCounts = {
|
||||
left: this.revealWords?.left?.length || 0,
|
||||
right: this.revealWords?.right?.length || 0
|
||||
const regionCounts = {
|
||||
left: 0,
|
||||
right: 0
|
||||
};
|
||||
const detail = {
|
||||
metrics: this.metrics,
|
||||
@@ -872,38 +1012,20 @@ class BookTextureRendererModule extends BaseModule {
|
||||
}
|
||||
const reveal = {};
|
||||
sidesToPublish.forEach((side) => {
|
||||
const bounds = this.revealBounds?.[side];
|
||||
if (!bounds) return;
|
||||
const blockIds = Array.from(bounds.blockIds || []);
|
||||
const durationMs = blockIds.reduce((maxDuration, blockId) => {
|
||||
const animation = this.activeAnimations.get(String(blockId));
|
||||
return Math.max(maxDuration, Number(animation?.totalDuration || 0));
|
||||
}, 0);
|
||||
if (durationMs <= 0) return;
|
||||
reveal[side] = {
|
||||
blockIds,
|
||||
durationMs,
|
||||
baseCanvas: options.preloadOnly ? this.cloneCanvas(this.revealBaseCanvases?.[side]) : this.revealBaseCanvases?.[side] || null,
|
||||
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,
|
||||
width: Math.max(0.001, (bounds.right - bounds.x) / this.metrics.width),
|
||||
height: Math.max(0.001, (bounds.bottom - bounds.y) / this.metrics.height)
|
||||
}
|
||||
};
|
||||
const sideReveal = this.buildRevealRegions(side);
|
||||
if (!sideReveal) return;
|
||||
sideReveal.baseCanvas = options.preloadOnly
|
||||
? this.cloneCanvas(this.revealBaseCanvases?.[side])
|
||||
: this.revealBaseCanvases?.[side] || null;
|
||||
regionCounts[side] = sideReveal.lineRects.length;
|
||||
reveal[side] = sideReveal;
|
||||
});
|
||||
if (Object.keys(reveal).length) detail.reveal = reveal;
|
||||
this.cachePublishedPages(sidesToPublish, detail);
|
||||
this.markPipelineTiming('publishSpread', {
|
||||
sides: sidesToPublish,
|
||||
hasReveal: Object.keys(reveal).length > 0,
|
||||
wordCounts,
|
||||
regionCounts,
|
||||
preloadOnly: Boolean(options.preloadOnly)
|
||||
});
|
||||
document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', {
|
||||
|
||||
Reference in New Issue
Block a user