/** * 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', 'decorateInlineWord', 'applyGlossaryEntries', 'normalizeGlossaryText', 'decorateGlossaryWord', 'ensureGlossaryTooltip', 'showGlossaryTooltip', 'hideGlossaryTooltip', 'positionGlossaryTooltip', 'escapeRegExp' ]); } /** * Initialize the module * @returns {Promise} - 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 = 'absolute'; paragraph.style.margin = '0'; paragraph.style.left = '0'; const globalLineStart = Math.max(0, Number(layoutData.lineStart || 0)); const windowOriginLine = Math.max(0, Number(layoutData.windowOriginLine || 0)); paragraph.style.top = `${(globalLineStart - windowOriginLine) * Number(lineHeightPx || 0)}px`; if (fontSize) paragraph.style.fontSize = fontSize; if (fontFamily) paragraph.style.fontFamily = fontFamily; // Calculate paragraph height const storyElement = document.getElementById('story'); if (!storyElement) { console.error('LayoutRenderer: Story container not found'); return null; } const pageWidth = Number(layoutData.pageWidth || storyElement.clientWidth); paragraph.style.width = `${pageWidth}px`; paragraph.style.maxWidth = '100%'; if (!Number.isFinite(Number(lineHeightPx)) || Number(lineHeightPx) <= 0) { throw new Error('LayoutRenderer: Missing canonical lineHeightPx for story layout.'); } const lineHeight = Number(lineHeightPx); const contentTopLines = Math.max(0, Number(layoutData.contentTopLines || 0)); const maxLineWidth = Array.isArray(measures) && measures.length > 0 ? Math.max(pageWidth, ...measures) : pageWidth; // Height should include all lines (breaks.length represents number of lines) const numLines = Math.max(1, breaks.length - 1); const totalLines = Math.max(1, Number(layoutData.lineCount || (numLines + contentTopLines))); paragraph.style.height = `${lineHeight * totalLines}px`; paragraph.dataset.heightLines = String(totalLines); paragraph.dataset.lineStart = String(globalLineStart); paragraph.dataset.lineCount = String(totalLines); 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'}`); } }); if (layoutData.dropCap && layoutData.dropCapText) { const dropCap = document.createElement('span'); dropCap.className = 'drop-cap story-drop-cap'; dropCap.textContent = layoutData.dropCapText; dropCap.style.top = `${contentTopLines * lineHeight}px`; paragraph.appendChild(dropCap); } if (Array.isArray(layoutData.lines) && layoutData.lines.length > 0) { layoutData.lines.forEach((line, lineIndex) => { this.renderLine({ paragraph, line, lineIndex, contentTopLines, lineHeight, maxLineWidth }); }); this.applyGlossaryEntries(paragraph, layoutData.glossaryEntries); return paragraph; } // Position words according to layout with proper justification let wordCount = 0; let lastChild = null; let syllable = ""; const stack = [paragraph]; 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; this.decorateInlineWord(lastChild, stack); 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; this.decorateInlineWord(lastChild, stack); 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 = ((contentTopLines + 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; this.decorateInlineWord(word, stack); 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) === ' 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 = ((contentTopLines + 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 = "-"; this.decorateInlineWord(word, stack); stack[stack.length - 1].appendChild(word); } } } this.applyGlossaryEntries(paragraph, layoutData.glossaryEntries); return paragraph; } decorateInlineWord(word, stack = []) { if (!word || !Array.isArray(stack)) return; const glossaryElement = stack .slice() .reverse() .find(element => element?.classList?.contains('story-glossary-term')); if (!glossaryElement) return; const definition = glossaryElement.dataset.glossary || glossaryElement.getAttribute('title') || ''; word.classList.add('story-glossary-word'); word.dataset.glossary = definition; word.removeAttribute('title'); word.tabIndex = 0; word.setAttribute('aria-label', `${word.textContent}: ${definition}`); } applyGlossaryEntries(paragraph, entries = []) { if (!paragraph || !Array.isArray(entries) || entries.length === 0) return; const words = Array.from(paragraph.querySelectorAll('.word')) .map(element => ({ element, text: this.normalizeGlossaryText(element.textContent || '') })) .filter(word => word.text.length > 0 && word.text !== '-'); if (words.length === 0) return; let cursor = 0; const segments = []; const fullText = words.map((word, index) => { if (index > 0) cursor += 1; const start = cursor; cursor += word.text.length; segments.push({ ...word, start, end: cursor }); return word.text; }).join(' '); entries .filter(entry => entry && entry.term && entry.definition) .forEach(entry => { const normalizedTerm = this.normalizeGlossaryText(entry.term); if (!normalizedTerm) return; const matcher = new RegExp(`(^|\\s)(${this.escapeRegExp(normalizedTerm)})(?=\\s|$|[.,;:!?])`, 'giu'); let match; while ((match = matcher.exec(fullText)) !== null) { const matchStart = match.index + match[1].length; const matchEnd = matchStart + match[2].length; segments .filter(segment => segment.end > matchStart && segment.start < matchEnd) .forEach(segment => this.decorateGlossaryWord(segment.element, entry)); } }); } normalizeGlossaryText(text) { return String(text || '') .replace(/\u200c/g, '') .replace(/\u00ad/g, '') .replace(/-\s*$/g, '') .replace(/\s+/g, ' ') .trim(); } decorateGlossaryWord(word, entry) { if (!word || !entry?.definition) return; word.classList.add('story-glossary-word'); word.dataset.glossaryTerm = entry.term || this.normalizeGlossaryText(word.textContent || ''); word.dataset.glossary = entry.definition; word.removeAttribute('title'); word.tabIndex = 0; word.setAttribute('aria-label', `${this.normalizeGlossaryText(word.textContent || '')}: ${entry.definition}`); if (word.dataset.glossaryBound === 'true') return; word.dataset.glossaryBound = 'true'; word.addEventListener('mouseenter', this.showGlossaryTooltip); word.addEventListener('focus', this.showGlossaryTooltip); word.addEventListener('mousemove', this.positionGlossaryTooltip); word.addEventListener('mouseleave', this.hideGlossaryTooltip); word.addEventListener('blur', this.hideGlossaryTooltip); } ensureGlossaryTooltip() { let tooltip = document.getElementById('story_glossary_tooltip'); if (tooltip) return tooltip; tooltip = document.createElement('div'); tooltip.id = 'story_glossary_tooltip'; tooltip.className = 'story-glossary-tooltip'; tooltip.setAttribute('role', 'tooltip'); tooltip.setAttribute('aria-hidden', 'true'); const header = document.createElement('div'); header.className = 'story-glossary-tooltip-header'; const title = document.createElement('h2'); title.className = 'story-glossary-tooltip-title'; header.appendChild(title); const body = document.createElement('div'); body.className = 'story-glossary-tooltip-body'; tooltip.append(header, body); document.body.appendChild(tooltip); return tooltip; } showGlossaryTooltip(event) { const word = event.currentTarget; if (!word) return; const tooltip = this.ensureGlossaryTooltip(); const title = tooltip.querySelector('.story-glossary-tooltip-title'); const body = tooltip.querySelector('.story-glossary-tooltip-body'); if (title) title.textContent = word.dataset.glossaryTerm || this.normalizeGlossaryText(word.textContent || ''); if (body) body.textContent = word.dataset.glossary || ''; tooltip.dataset.anchorId = word.id || ''; tooltip.__anchorElement = word; tooltip.classList.add('visible'); tooltip.setAttribute('aria-hidden', 'false'); this.positionGlossaryTooltip(event); } hideGlossaryTooltip() { const tooltip = document.getElementById('story_glossary_tooltip'); if (!tooltip) return; tooltip.classList.remove('visible'); tooltip.setAttribute('aria-hidden', 'true'); tooltip.__anchorElement = null; } positionGlossaryTooltip(event) { const tooltip = document.getElementById('story_glossary_tooltip'); if (!tooltip || !tooltip.classList.contains('visible')) return; const anchor = event?.currentTarget || tooltip.__anchorElement; if (!anchor || typeof anchor.getBoundingClientRect !== 'function') return; const anchorRect = anchor.getBoundingClientRect(); const margin = Math.max(8, window.innerWidth * 0.006); const tooltipRect = tooltip.getBoundingClientRect(); const preferredLeft = anchorRect.left + (anchorRect.width / 2) - (tooltipRect.width / 2); let left = Math.min( Math.max(margin, preferredLeft), Math.max(margin, window.innerWidth - tooltipRect.width - margin) ); let top = anchorRect.top - tooltipRect.height - margin; if (top < margin) { top = anchorRect.bottom + margin; } top = Math.min( Math.max(margin, top), Math.max(margin, window.innerHeight - tooltipRect.height - margin) ); tooltip.style.left = `${left}px`; tooltip.style.top = `${top}px`; } escapeRegExp(value) { return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } renderLine({ paragraph, line, lineIndex, contentTopLines, lineHeight, maxLineWidth }) { const lineWidth = Number(line.measure || maxLineWidth); const lineOffset = Number(line.offset || 0); const ratio = line.isFinal ? 0 : Number(line.ratio || 0); const stack = [paragraph]; let currentLeft = 0; let lastChild = null; let syllable = ''; for (let j = 0; j < line.nodes.length; j += 1) { const node = line.nodes[j]; if (!node) continue; if (node.type === 'box' && node.value !== '') { const followsGlue = j > 0 && line.nodes[j - 1].type === 'glue'; const isTrailingPunctuation = /^[,.;:!?…)]$/.test(node.value) && !followsGlue; if (lastChild && isTrailingPunctuation) { syllable += node.value; lastChild.innerHTML = syllable; this.decorateInlineWord(lastChild, stack); currentLeft += node.width || 0; continue; } const word = document.createElement('span'); word.className = ['word', line.styleClass || ''].filter(Boolean).join(' '); 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); word.style.top = `${((contentTopLines + lineIndex) * lineHeight * 100) / parseFloat(paragraph.style.height)}%`; word.style.left = `${((lineOffset + currentLeft) * 100) / maxLineWidth}%`; word.style.opacity = '0'; word.style.visibility = 'hidden'; word.style.clipPath = 'inset(0 100% 0 0)'; syllable = node.value; word.innerHTML = syllable; this.decorateInlineWord(word, stack); stack[stack.length - 1].appendChild(word); lastChild = word; currentLeft += node.width || 0; } else if (node.type === 'tag') { if (String(node.value || '').startsWith(' 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' && node.width !== 0) { let adjustedWidth = node.width || 0; if (ratio > 0) { adjustedWidth += (node.stretch || 0) * ratio; } else if (ratio < 0) { adjustedWidth += (node.shrink || 0) * ratio; } currentLeft += adjustedWidth; } else if (node.type === 'penalty' && node.penalty === 100 && line.hyphenated && j === line.nodes.length - 1 && lastChild) { lastChild.innerHTML = `${lastChild.innerHTML}-`; } } } 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;