324 lines
14 KiB
JavaScript
324 lines
14 KiB
JavaScript
/**
|
|
* Layout Renderer Module
|
|
* Renders calculated paragraph layouts into the DOM with proper animations
|
|
*/
|
|
import { BaseModule } from './base-module.js';
|
|
|
|
class LayoutRendererModule extends BaseModule {
|
|
constructor() {
|
|
super('layout-renderer', 'Layout Renderer');
|
|
|
|
// Module dependencies
|
|
this.dependencies = [];
|
|
|
|
// Configuration
|
|
this.updateConfig({
|
|
animation: {
|
|
defaultSpeed: 1.0,
|
|
wordAnimationClass: 'animate-word'
|
|
}
|
|
});
|
|
|
|
// Bind methods
|
|
this.bindMethods([
|
|
'renderParagraph',
|
|
'initializeContainers',
|
|
'adjustJustification'
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Initialize the module
|
|
* @returns {Promise<boolean>} - Resolves with success status
|
|
*/
|
|
async initialize() {
|
|
try {
|
|
this.reportProgress(10, "Initializing Layout Renderer");
|
|
this.reportProgress(100, "Layout Renderer ready");
|
|
return true;
|
|
} catch (error) {
|
|
console.error("Error initializing Layout Renderer:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize default containers
|
|
*/
|
|
initializeContainers() {
|
|
// Check if story container exists
|
|
const storyContainer = document.getElementById('story');
|
|
if (!storyContainer) {
|
|
console.log('Story container not found, creating it');
|
|
const container = document.createElement('div');
|
|
container.id = 'story';
|
|
document.body.appendChild(container);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render a paragraph from layout data (pure DOM creation, no animation)
|
|
* @param {Object} layoutData - Layout data containing breaks, nodes, and measures
|
|
* @param {Object} options - Rendering options
|
|
* @returns {HTMLElement} - The created paragraph element
|
|
*/
|
|
renderParagraph(layoutData, options = {}) {
|
|
const { id = `p-${Date.now()}` } = options;
|
|
const { breaks, nodes, measures, fontSize, fontFamily, lineHeightPx } = layoutData;
|
|
|
|
if (!breaks || !nodes) {
|
|
console.error('LayoutRenderer: Invalid layout data');
|
|
return null;
|
|
}
|
|
|
|
// Create paragraph container
|
|
const paragraph = document.createElement('p');
|
|
paragraph.id = id;
|
|
paragraph.className = [
|
|
layoutData.role ? `story-${layoutData.role}` : '',
|
|
layoutData.addTopSpace ? 'story-textblock-start' : '',
|
|
layoutData.dropCap ? 'story-dropcap-paragraph' : ''
|
|
].filter(Boolean).join(' ');
|
|
paragraph.style.position = 'relative';
|
|
paragraph.style.margin = '0';
|
|
if (fontSize) paragraph.style.fontSize = fontSize;
|
|
if (fontFamily) paragraph.style.fontFamily = fontFamily;
|
|
if (Array.isArray(measures) && measures.length > 0) {
|
|
paragraph.style.width = `${Math.max(...measures)}px`;
|
|
paragraph.style.maxWidth = '100%';
|
|
}
|
|
|
|
// Calculate paragraph height
|
|
const storyElement = document.getElementById('story');
|
|
if (!storyElement) {
|
|
console.error('LayoutRenderer: Story container not found');
|
|
return null;
|
|
}
|
|
|
|
if (!Number.isFinite(Number(lineHeightPx)) || Number(lineHeightPx) <= 0) {
|
|
throw new Error('LayoutRenderer: Missing canonical lineHeightPx for story layout.');
|
|
}
|
|
const lineHeight = Number(lineHeightPx);
|
|
let marginLines = 0;
|
|
if (layoutData.role === 'chapter-heading') {
|
|
paragraph.style.marginTop = `${lineHeight * 2}px`;
|
|
paragraph.style.marginBottom = `${lineHeight}px`;
|
|
marginLines = 3;
|
|
} else if (layoutData.role === 'section-heading') {
|
|
paragraph.style.marginTop = `${lineHeight}px`;
|
|
paragraph.style.marginBottom = `${lineHeight}px`;
|
|
marginLines = 2;
|
|
} else if (layoutData.addTopSpace) {
|
|
paragraph.style.marginTop = `${lineHeight}px`;
|
|
marginLines = 1;
|
|
}
|
|
const maxLineWidth = Array.isArray(measures) && measures.length > 0
|
|
? Math.max(...measures)
|
|
: storyElement.clientWidth;
|
|
// Height should include all lines (breaks.length represents number of lines)
|
|
const numLines = Math.max(1, breaks.length - 1);
|
|
paragraph.style.height = `${lineHeight * numLines}px`;
|
|
paragraph.dataset.heightLines = String(numLines + marginLines);
|
|
|
|
console.log(`LayoutRenderer: Rendering paragraph ${id} - ${breaks.length} breaks (${numLines} lines), lineHeight: ${lineHeight}px, total height: ${lineHeight * numLines}px`);
|
|
|
|
// Debug: log break ratios
|
|
breaks.forEach((brk, idx) => {
|
|
if (idx > 0) {
|
|
console.log(` Line ${idx - 1}: break position ${brk.position}, ratio ${brk.ratio ? brk.ratio.toFixed(3) : 'undefined'}`);
|
|
}
|
|
});
|
|
|
|
// Position words according to layout with proper justification
|
|
let wordCount = 0;
|
|
let lastChild = null;
|
|
let syllable = "";
|
|
const stack = [paragraph];
|
|
|
|
if (layoutData.dropCap && layoutData.dropCapText) {
|
|
const dropCap = document.createElement('span');
|
|
dropCap.className = 'drop-cap story-drop-cap';
|
|
dropCap.textContent = layoutData.dropCapText;
|
|
paragraph.appendChild(dropCap);
|
|
}
|
|
|
|
for (let i = 1; i < breaks.length; i++) {
|
|
const lineIndex = i - 1;
|
|
const lineWidth = measures[Math.min(lineIndex, measures.length - 1)];
|
|
const currentBreak = breaks[i];
|
|
const isFinalLine = i === breaks.length - 1;
|
|
const isCentered = layoutData.align === 'center' ||
|
|
layoutData.role === 'chapter-heading' ||
|
|
layoutData.role === 'section-heading';
|
|
const ratio = (isFinalLine || isCentered) ? 0 : (currentBreak.ratio || 0);
|
|
const naturalLineWidth = isCentered
|
|
? this.measureNaturalLineWidth(nodes, breaks[i - 1].position, currentBreak.position)
|
|
: lineWidth;
|
|
const lineOffset = isCentered
|
|
? Math.max(0, (maxLineWidth - naturalLineWidth) / 2)
|
|
: Array.isArray(layoutData.lineOffsets)
|
|
? (layoutData.lineOffsets[Math.min(lineIndex, layoutData.lineOffsets.length - 1)] || 0)
|
|
: maxLineWidth - lineWidth;
|
|
|
|
let currentLeft = 0;
|
|
lastChild = null;
|
|
|
|
// Iterate through nodes on this line (break positions are inclusive)
|
|
for (let j = breaks[i-1].position; j <= currentBreak.position; j++) {
|
|
const node = nodes[j];
|
|
|
|
if (node.type === 'box' && node.value !== '' && j < currentBreak.position) {
|
|
const followsGlue = j > 0 && nodes[j - 1].type === 'glue';
|
|
const isTrailingPunctuation = /^[,.;:!?…)]$/.test(node.value) && !followsGlue;
|
|
|
|
// Check if this box follows a penalty (hyphenation point)
|
|
if (lastChild && isTrailingPunctuation) {
|
|
syllable += node.value;
|
|
lastChild.innerHTML = syllable;
|
|
currentLeft += node.width;
|
|
} else if (j > breaks[i-1].position + 1 &&
|
|
nodes[j-1].type === 'penalty' &&
|
|
lastChild) {
|
|
// Combine with previous syllable using zero-width non-joiner
|
|
syllable += '\u200c' + node.value;
|
|
lastChild.innerHTML = syllable;
|
|
currentLeft += node.width;
|
|
} else {
|
|
// Create new word span
|
|
const word = document.createElement('span');
|
|
word.className = 'word';
|
|
word.style.position = 'absolute';
|
|
word.style.display = 'inline-block';
|
|
word.style.whiteSpace = 'nowrap';
|
|
word.dataset.line = String(lineIndex);
|
|
word.dataset.lineStart = String(lineOffset);
|
|
word.dataset.lineWidth = String(lineWidth);
|
|
|
|
// Calculate position with proper line and justification
|
|
const topPercent = (lineIndex * lineHeight * 100) / parseFloat(paragraph.style.height);
|
|
const leftPercent = ((lineOffset + currentLeft) * 100) / maxLineWidth;
|
|
|
|
word.style.top = `${topPercent}%`;
|
|
word.style.left = `${leftPercent}%`;
|
|
word.style.opacity = '0'; // Hidden until animated
|
|
word.style.visibility = 'hidden';
|
|
word.style.clipPath = 'inset(0 100% 0 0)';
|
|
syllable = node.value;
|
|
word.innerHTML = syllable;
|
|
lastChild = word;
|
|
|
|
if (wordCount < 5 || (wordCount % 20 === 0)) {
|
|
console.log(` Word ${wordCount} "${node.value}" at line ${lineIndex}, top: ${topPercent.toFixed(1)}%, left: ${leftPercent.toFixed(1)}%`);
|
|
}
|
|
wordCount++;
|
|
|
|
stack[stack.length - 1].appendChild(word);
|
|
currentLeft += node.width;
|
|
}
|
|
|
|
} else if (node.type === 'tag') {
|
|
if (node.value.substr(0, 2) === '</') {
|
|
if (stack.length > 1) stack.pop();
|
|
} else {
|
|
const template = document.createElement('div');
|
|
template.innerHTML = node.value;
|
|
const tag = template.firstChild;
|
|
if (tag) {
|
|
tag.style.display = 'contents';
|
|
stack[stack.length - 1].appendChild(tag);
|
|
stack.push(tag);
|
|
}
|
|
}
|
|
|
|
} else if (node.type === 'glue' && j > breaks[i-1].position && node.width !== 0 && j <= currentBreak.position) {
|
|
// Apply justification: adjust glue width based on line's ratio
|
|
let adjustedWidth = node.width;
|
|
|
|
if (ratio > 0) {
|
|
// Line needs stretching
|
|
adjustedWidth = node.width + (node.stretch * ratio);
|
|
} else if (ratio < 0) {
|
|
// Line needs shrinking
|
|
adjustedWidth = node.width + (node.shrink * ratio);
|
|
}
|
|
// If ratio === 0, line fits perfectly, use natural width
|
|
|
|
if (wordCount < 3) {
|
|
// Debug first line's glue adjustments
|
|
console.log(` Glue at position ${j}: natural=${node.width.toFixed(2)}px, adjusted=${adjustedWidth.toFixed(2)}px, ratio=${ratio.toFixed(3)}, left before: ${currentLeft.toFixed(2)}px`);
|
|
}
|
|
|
|
// Increment position by the adjusted glue width
|
|
currentLeft += adjustedWidth;
|
|
|
|
} else if (node.type === 'penalty' && node.penalty === 100 && j === currentBreak.position) {
|
|
// Add hyphen at line break
|
|
if (lastChild) {
|
|
lastChild.innerHTML = `${lastChild.innerHTML}-`;
|
|
continue;
|
|
}
|
|
|
|
const word = document.createElement('span');
|
|
word.className = 'word';
|
|
word.style.position = 'absolute';
|
|
word.style.display = 'inline-block';
|
|
word.style.whiteSpace = 'nowrap';
|
|
word.dataset.line = String(lineIndex);
|
|
word.dataset.lineStart = String(lineOffset);
|
|
word.dataset.lineWidth = String(lineWidth);
|
|
const topPercent = (lineIndex * lineHeight * 100) / parseFloat(paragraph.style.height);
|
|
const leftPercent = ((lineOffset + currentLeft) * 100) / maxLineWidth;
|
|
word.style.top = `${topPercent}%`;
|
|
word.style.left = `${leftPercent}%`;
|
|
word.style.opacity = '0';
|
|
word.style.visibility = 'hidden';
|
|
word.style.clipPath = 'inset(0 100% 0 0)';
|
|
word.innerHTML = "-";
|
|
stack[stack.length - 1].appendChild(word);
|
|
}
|
|
}
|
|
}
|
|
|
|
return paragraph;
|
|
}
|
|
|
|
measureNaturalLineWidth(nodes, startPosition, endPosition) {
|
|
let width = 0;
|
|
for (let j = startPosition; j <= endPosition; j++) {
|
|
const node = nodes[j];
|
|
if (!node) continue;
|
|
if (node.type === 'box' || node.type === 'glue') {
|
|
width += node.width || 0;
|
|
} else if (node.type === 'penalty' && node.penalty === 100 && j === endPosition) {
|
|
width += node.width || 0;
|
|
}
|
|
}
|
|
return width;
|
|
}
|
|
|
|
/**
|
|
* Paragraph positions are already computed from browser DOM measurements.
|
|
* Keep this hook for callers that still invoke it, but do not reflow the
|
|
* prototype layout after rendering.
|
|
* @param {HTMLElement} paragraph - Rendered paragraph element
|
|
*/
|
|
adjustJustification(paragraph) {
|
|
return paragraph;
|
|
}
|
|
|
|
}
|
|
|
|
// Create the singleton instance
|
|
const LayoutRenderer = new LayoutRendererModule();
|
|
|
|
// Export the module
|
|
export { LayoutRenderer };
|
|
|
|
// Register with the module registry
|
|
if (window.moduleRegistry) {
|
|
window.moduleRegistry.register(LayoutRenderer);
|
|
}
|
|
|
|
// Keep a reference in window for loader system
|
|
window.LayoutRenderer = LayoutRenderer;
|