/** * LayoutRenderer Module * Translates the abstract layout data into concrete visual elements (DOM nodes). */ export class LayoutRenderer { /** * Create a new LayoutRenderer * @param {Object} animationQueue - The AnimationQueue instance */ constructor(animationQueue) { this.animationQueue = animationQueue; this.fastForwardingAll = false; } /** * Render a paragraph based on layout data * @param {Object} paragraphData - The layout data from ParagraphLayout * @param {number} delay - Initial delay for animations * @param {Array} measure - Array of line width measurements * @returns {Array} Array containing the paragraph element and the final delay */ renderParagraph(paragraphData, delay = 0, measure = []) { const stack = []; let left = 0; const p = document.createElement("p"); p.style.position = 'relative'; p.classList.add("latest-paragraph"); p.dataset.numberOfLines = paragraphData.breaks.length - 1; const lineHeight = parseFloat(window.getComputedStyle(document.querySelector('#ruler')).lineHeight); const lineWidth = parseFloat(window.getComputedStyle(document.getElementById('story')).width); const pageHeight = parseFloat(window.getComputedStyle(document.getElementById('page_right')).height); p.style.height = lineHeight * (paragraphData.breaks.length - 1) + 'px'; const paragraphHeight = parseFloat(p.style.height); p.dataset.vpc = paragraphHeight * 100 / pageHeight; p.style.marginBlockEnd = 0; stack.push(p); for (let i = 1; i < paragraphData.breaks.length; i++) { left = measure[measure.length - 1] - measure[Math.min(i - 1, measure.length - 1)]; let lastChild = null; let syllable = ""; for (let j = paragraphData.breaks[i-1].position; j <= paragraphData.breaks[i].position; j++) { if (paragraphData.nodes[j].type === 'box' && paragraphData.nodes[j].value !== '' && j < paragraphData.breaks[i].position) { if (j > paragraphData.breaks[i-1].position + 1 && paragraphData.nodes[j-1].type === 'penalty' && lastChild) { syllable += '\u200c' + paragraphData.nodes[j].value; lastChild.innerHTML = syllable; left += paragraphData.nodes[j].width; } else { let word = document.createElement("span"); word.style.position = 'absolute'; word.classList.add("fade-in"); word.style.animationDuration = this.animationQueue.getSpeed() * 10 + 'ms'; word.style.top = lineHeight * (i - 1) * 100 / paragraphHeight + '%'; word.style.left = left * 100 / lineWidth + '%'; syllable = paragraphData.nodes[j].value; word.innerHTML = syllable; lastChild = word; if (!this.fastForwardingAll) { this.insertAfter(delay, stack[stack.length-1], word); } delay += this.animationQueue.getSpeed(); left += paragraphData.nodes[j].width; } } else if (paragraphData.nodes[j].type === 'tag') { if (paragraphData.nodes[j].value.substr(0, 2) == ' paragraphData.breaks[i-1].position && paragraphData.nodes[j].type === 'glue' && paragraphData.nodes[j].width !== 0 && j <= paragraphData.breaks[i].position) { // Insert space character if (paragraphData.breaks[i].ratio > 0) { left += paragraphData.nodes[j].width + paragraphData.breaks[i].ratio * paragraphData.nodes[j].stretch; } else { left += paragraphData.nodes[j].width + paragraphData.breaks[i].ratio * paragraphData.nodes[j].shrink; } let word = document.createElement("span"); word.style.position = 'absolute'; word.classList.add("fade-in"); word.style.top = lineHeight * (i - 1) * 100 / paragraphHeight + '%'; word.style.left = left * 100 / lineWidth + '%'; word.innerHTML = " "; if (!this.fastForwardingAll) { this.insertAfter(delay, stack[stack.length-1], word); } } else if (paragraphData.nodes[j].type === 'penalty' && paragraphData.nodes[j].penalty === 100 && j === paragraphData.breaks[i].position) { // Create a hyphen at the end of the line if breaking at a hyphenation point let hyphen = document.createElement("span"); hyphen.style.position = 'absolute'; hyphen.classList.add("fade-in"); hyphen.classList.add("hyphen-marker"); // Add a class for easier styling if needed hyphen.style.top = lineHeight * (i - 1) * 100 / paragraphHeight + '%'; hyphen.style.left = left * 100 / lineWidth + '%'; hyphen.innerHTML = "-"; // Ensure hyphen is visible with stronger styling hyphen.style.fontWeight = "normal"; hyphen.style.opacity = "1"; if (!this.fastForwardingAll) { this.insertAfter(delay, stack[stack.length-1], hyphen); // Log for debugging console.log("Inserted hyphen at line break:", i, "position:", left); } delay += this.animationQueue.getSpeed(); } } } return [p, delay]; } /** * Insert an element after a delay * @param {number} delay - The delay in milliseconds * @param {HTMLElement} target - The target element to append to * @param {HTMLElement} el - The element to insert * @param {boolean} fadeIn - Whether to fade in the element */ insertAfter(delay, target, el, fadeIn = true) { if (fadeIn) { el.classList.add("fade-in"); this.animationQueue.schedule(function() { target.appendChild(el); }, delay); } else { this.animationQueue.schedule(function() { target.appendChild(el); }, delay); } } /** * Show an element after a delay * @param {number} delay - The delay in milliseconds * @param {HTMLElement} el - The element to show */ showAfter(delay, el) { el.classList.add("hide"); setTimeout(function() { setTimeout(function() { el.classList.remove("hide") }, delay); }); } /** * Render a visual tag * @param {string} tagType - The type of tag (IMAGE, BACKGROUND, etc.) * @param {string} tagValue - The value of the tag * @param {HTMLElement} container - The container to append to * @param {number} delay - The delay in milliseconds * @returns {HTMLElement|null} The created element or null */ renderVisualTag(tagType, tagValue, container, delay = 0) { switch (tagType) { case "IMAGE": const imageElement = document.createElement('img'); imageElement.src = tagValue; container.appendChild(imageElement); this.showAfter(delay, imageElement); return imageElement; case "BACKGROUND": const outerScrollContainer = document.querySelector('#book'); outerScrollContainer.style.backgroundImage = 'url(' + tagValue + ')'; return null; case "CHAPTER": const h = document.createElement('H2'); h.appendChild(document.createTextNode(tagValue)); h.classList.add("chapter-heading"); h.classList.add("fade-in"); container.appendChild(h); return h; case "SEPARATOR": const d = document.createElement('double'); d.appendChild(document.createTextNode('\u2766')); d.classList.add("fade-in"); d.classList.add("separator"); container.appendChild(d); return d; default: return null; } } /** * Set the fast forwarding state * @param {boolean} state - The fast forwarding state */ setFastForwardingAll(state) { this.fastForwardingAll = state; } /** * Get the fast forwarding state * @returns {boolean} The fast forwarding state */ getFastForwardingAll() { return this.fastForwardingAll; } /** * Smooth scroll to an element * @param {HTMLElement} target - The target element to scroll to * @param {number} duration - The duration of the scroll animation */ smoothScroll(target, duration) { const display = document.getElementById('page_right'); const targetPosition = target.getBoundingClientRect().top; const startPosition = display.scrollTop; const distance = targetPosition; let startTime = null; if (duration < 5) { display.scrollTo(0, targetPosition); return; } function animation(currentTime) { if (startTime === null) startTime = currentTime; const timeElapsed = currentTime - startTime; const run = ease(timeElapsed, startPosition, distance, duration); display.scrollTo(0, run); if (timeElapsed < duration) requestAnimationFrame(animation); } function ease(t, b, c, d) { t /= d / 2; if (t < 1) return c / 2 * t * t + b; t--; return -c / 2 * (t * (t - 2) - 1) + b; } requestAnimationFrame(animation); } }