848 lines
36 KiB
JavaScript
848 lines
36 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',
|
|
'decorateInlineWord',
|
|
'applyGlossaryEntries',
|
|
'applyGlossaryEntriesToInline',
|
|
'normalizeGlossaryText',
|
|
'normalizeGlossaryToken',
|
|
'normalizeGlossaryCompact',
|
|
'buildGlossaryTermPatterns',
|
|
'buildCompactGlossaryTermPatterns',
|
|
'decorateGlossarySegment',
|
|
'decorateGlossaryRange',
|
|
'decorateGlossaryWord',
|
|
'renderGlossaryHtml',
|
|
'ensureGlossaryTooltip',
|
|
'showGlossaryTooltip',
|
|
'hideGlossaryTooltip',
|
|
'positionGlossaryTooltip',
|
|
'escapeRegExp'
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 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 = '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) === '</') {
|
|
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 = ((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 = [];
|
|
let compactCursor = 0;
|
|
const compactSegments = [];
|
|
const fullText = words.map((word, index) => {
|
|
if (index > 0) cursor += 1;
|
|
const start = cursor;
|
|
cursor += word.text.length;
|
|
segments.push({ ...word, start, end: cursor });
|
|
|
|
const compactText = this.normalizeGlossaryCompact(word.text);
|
|
if (compactText) {
|
|
const compactStart = compactCursor;
|
|
compactCursor += compactText.length;
|
|
compactSegments.push({ ...word, start: compactStart, end: compactCursor });
|
|
}
|
|
|
|
return word.text;
|
|
}).join(' ');
|
|
const compactFullText = words.map(word => this.normalizeGlossaryCompact(word.text)).join('');
|
|
|
|
entries
|
|
.filter(entry => entry && entry.term && entry.definition)
|
|
.forEach(entry => {
|
|
this.buildGlossaryTermPatterns(entry.term).forEach((pattern) => {
|
|
const matcher = new RegExp(`(^|\\s)(${pattern})(?=\\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.decorateGlossarySegment(segment, entry, matchStart, matchEnd, 'text'));
|
|
}
|
|
});
|
|
this.buildCompactGlossaryTermPatterns(entry.term).forEach((pattern) => {
|
|
const matcher = new RegExp(pattern, 'giu');
|
|
let match;
|
|
while ((match = matcher.exec(compactFullText)) !== null) {
|
|
const matchStart = match.index;
|
|
const matchEnd = matchStart + match[0].length;
|
|
compactSegments
|
|
.filter(segment => segment.end > matchStart && segment.start < matchEnd)
|
|
.forEach(segment => this.decorateGlossarySegment(segment, entry, matchStart, matchEnd, 'compact'));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
applyGlossaryEntriesToInline(container, entries = []) {
|
|
if (!container || !Array.isArray(entries) || entries.length === 0) return;
|
|
|
|
const filter = window.NodeFilter || NodeFilter;
|
|
entries
|
|
.filter(entry => entry && entry.term && entry.definition)
|
|
.forEach(entry => {
|
|
const walker = document.createTreeWalker(container, filter.SHOW_TEXT, {
|
|
acceptNode: (node) => {
|
|
if (!node?.nodeValue?.trim()) return filter.FILTER_REJECT;
|
|
if (node.parentElement?.closest?.('.story-glossary-word')) return filter.FILTER_REJECT;
|
|
return filter.FILTER_ACCEPT;
|
|
}
|
|
});
|
|
const textNodes = [];
|
|
let node;
|
|
while ((node = walker.nextNode())) {
|
|
textNodes.push(node);
|
|
}
|
|
|
|
textNodes.forEach((textNode) => {
|
|
if (!textNode.parentNode) return;
|
|
const original = textNode.nodeValue || '';
|
|
if (!this.normalizeGlossaryText(original)) return;
|
|
|
|
const matches = [];
|
|
this.buildGlossaryTermPatterns(entry.term).forEach((pattern) => {
|
|
const matcher = new RegExp(`(^|\\s)(${pattern})(?=\\s|$|[.,;:!?])`, 'giu');
|
|
let match;
|
|
while ((match = matcher.exec(original)) !== null) {
|
|
const matchStart = match.index + match[1].length;
|
|
const matchEnd = matchStart + match[2].length;
|
|
if (!matches.some(existing => existing.start === matchStart && existing.end === matchEnd)) {
|
|
matches.push({ start: matchStart, end: matchEnd });
|
|
}
|
|
}
|
|
});
|
|
this.buildCompactGlossaryTermPatterns(entry.term).forEach((pattern) => {
|
|
const compactOriginal = this.normalizeGlossaryCompact(original);
|
|
if (!compactOriginal) return;
|
|
const matcher = new RegExp(pattern, 'giu');
|
|
let match;
|
|
while ((match = matcher.exec(compactOriginal)) !== null) {
|
|
if (match.index !== 0 || match[0].length !== compactOriginal.length) continue;
|
|
if (!matches.some(existing => existing.start === 0 && existing.end === original.length)) {
|
|
matches.push({ start: 0, end: original.length });
|
|
}
|
|
}
|
|
});
|
|
matches.sort((a, b) => a.start - b.start);
|
|
if (matches.length === 0) return;
|
|
|
|
const parent = textNode.parentNode;
|
|
let cursor = 0;
|
|
matches.forEach((match) => {
|
|
if (match.start < cursor) return;
|
|
const before = original.slice(cursor, match.start);
|
|
const matched = original.slice(match.start, match.end);
|
|
if (before) parent.insertBefore(document.createTextNode(before), textNode);
|
|
if (matched) {
|
|
const gloss = document.createElement('span');
|
|
gloss.textContent = matched;
|
|
this.decorateGlossaryWord(gloss, entry);
|
|
parent.insertBefore(gloss, textNode);
|
|
}
|
|
cursor = match.end;
|
|
});
|
|
|
|
const after = original.slice(cursor);
|
|
if (after) parent.insertBefore(document.createTextNode(after), textNode);
|
|
parent.removeChild(textNode);
|
|
});
|
|
});
|
|
}
|
|
|
|
normalizeGlossaryText(text) {
|
|
return String(text || '')
|
|
.normalize('NFC')
|
|
.replace(/\u200c/g, '')
|
|
.replace(/\u00ad/g, '')
|
|
.replace(/-\s*$/g, '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
normalizeGlossaryToken(text) {
|
|
return this.normalizeGlossaryText(text)
|
|
.replace(/^[.,;:!?()[\]{}"'„“”‚‘’»«]+|[.,;:!?()[\]{}"'„“”‚‘’»«]+$/g, '');
|
|
}
|
|
|
|
normalizeGlossaryCompact(text) {
|
|
return this.normalizeGlossaryToken(text)
|
|
.replace(/[-\s]+/g, '')
|
|
.replace(/[.,;:!?()[\]{}"'„“”‚‘’»«]+/g, '');
|
|
}
|
|
|
|
buildGlossaryTermPatterns(term) {
|
|
const normalizedTerm = this.normalizeGlossaryText(term);
|
|
if (!normalizedTerm) return [];
|
|
|
|
const exact = normalizedTerm
|
|
.split(/\s+/)
|
|
.map(token => this.escapeRegExp(this.normalizeGlossaryToken(token)))
|
|
.filter(Boolean)
|
|
.join('\\s+');
|
|
if (!exact) return [];
|
|
|
|
const inflected = normalizedTerm
|
|
.split(/\s+/)
|
|
.map((token, index, tokens) => {
|
|
const normalized = this.normalizeGlossaryToken(token);
|
|
if (!normalized) return '';
|
|
const escaped = this.escapeRegExp(normalized);
|
|
const isLast = index === tokens.length - 1;
|
|
return isLast ? `${escaped}(?:s|es|e|en|er|n)?` : `${escaped}(?:e|en|er|es|n)?`;
|
|
})
|
|
.filter(Boolean)
|
|
.join('\\s+');
|
|
|
|
return [...new Set([exact, inflected])];
|
|
}
|
|
|
|
buildCompactGlossaryTermPatterns(term) {
|
|
const tokens = this.normalizeGlossaryText(term)
|
|
.split(/\s+/)
|
|
.map(token => this.normalizeGlossaryCompact(token))
|
|
.filter(Boolean);
|
|
if (tokens.length === 0) return [];
|
|
|
|
const exact = tokens.map(token => this.escapeRegExp(token)).join('');
|
|
const inflected = tokens
|
|
.map((token, index) => {
|
|
const escaped = this.escapeRegExp(token);
|
|
const isLast = index === tokens.length - 1;
|
|
return isLast ? `${escaped}(?:s|es|e|en|er|n)?` : `${escaped}(?:e|en|er|es|n)?`;
|
|
})
|
|
.join('');
|
|
|
|
return [...new Set([exact, inflected])];
|
|
}
|
|
|
|
decorateGlossarySegment(segment, entry, matchStart, matchEnd, mode = 'text') {
|
|
if (!segment?.element || !entry?.definition) return;
|
|
|
|
const localStart = Math.max(0, matchStart - segment.start);
|
|
const localEnd = Math.min(segment.end - segment.start, matchEnd - segment.start);
|
|
if (localEnd <= localStart) return;
|
|
|
|
const segmentLength = mode === 'compact'
|
|
? this.normalizeGlossaryCompact(segment.text).length
|
|
: segment.text.length;
|
|
|
|
if (localStart <= 0 && localEnd >= segmentLength) {
|
|
this.decorateGlossaryWord(segment.element, entry);
|
|
return;
|
|
}
|
|
|
|
if (mode === 'compact') {
|
|
return;
|
|
}
|
|
|
|
this.decorateGlossaryRange(segment.element, entry, localStart, localEnd);
|
|
}
|
|
|
|
decorateGlossaryRange(word, entry, start, end) {
|
|
if (!word || !entry?.definition) return;
|
|
|
|
const text = word.textContent || '';
|
|
const safeStart = Math.max(0, Math.min(text.length, start));
|
|
const safeEnd = Math.max(safeStart, Math.min(text.length, end));
|
|
if (safeStart === 0 && safeEnd >= text.length) {
|
|
this.decorateGlossaryWord(word, entry);
|
|
return;
|
|
}
|
|
if (safeEnd <= safeStart) return;
|
|
|
|
word.dataset.glossaryPartial = 'true';
|
|
|
|
const textNodes = [];
|
|
const filter = window.NodeFilter || NodeFilter;
|
|
const walker = document.createTreeWalker(word, filter.SHOW_TEXT);
|
|
let node;
|
|
while ((node = walker.nextNode())) {
|
|
textNodes.push(node);
|
|
}
|
|
|
|
let offset = 0;
|
|
textNodes.forEach((textNode) => {
|
|
const nodeText = textNode.nodeValue || '';
|
|
const nodeStart = offset;
|
|
const nodeEnd = nodeStart + nodeText.length;
|
|
offset = nodeEnd;
|
|
|
|
const overlapStart = Math.max(safeStart, nodeStart);
|
|
const overlapEnd = Math.min(safeEnd, nodeEnd);
|
|
if (overlapEnd <= overlapStart || !textNode.parentNode) return;
|
|
|
|
const localStart = overlapStart - nodeStart;
|
|
const localEnd = overlapEnd - nodeStart;
|
|
const before = nodeText.slice(0, localStart);
|
|
const matched = nodeText.slice(localStart, localEnd);
|
|
const after = nodeText.slice(localEnd);
|
|
const parent = textNode.parentNode;
|
|
|
|
if (before) {
|
|
parent.insertBefore(document.createTextNode(before), textNode);
|
|
}
|
|
|
|
if (matched) {
|
|
const gloss = document.createElement('span');
|
|
gloss.textContent = matched;
|
|
this.decorateGlossaryWord(gloss, entry);
|
|
parent.insertBefore(gloss, textNode);
|
|
}
|
|
|
|
if (after) {
|
|
parent.insertBefore(document.createTextNode(after), textNode);
|
|
}
|
|
|
|
parent.removeChild(textNode);
|
|
});
|
|
|
|
if (textNodes.length === 0) {
|
|
const before = text.slice(0, safeStart);
|
|
const matched = text.slice(safeStart, safeEnd);
|
|
const after = text.slice(safeEnd);
|
|
word.textContent = '';
|
|
if (before) word.appendChild(document.createTextNode(before));
|
|
const gloss = document.createElement('span');
|
|
gloss.textContent = matched;
|
|
this.decorateGlossaryWord(gloss, entry);
|
|
word.appendChild(gloss);
|
|
if (after) word.appendChild(document.createTextNode(after));
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
renderGlossaryHtml(text) {
|
|
const markupParser = this.getModule('markup-parser');
|
|
if (markupParser && typeof markupParser.markdownToHtml === 'function') {
|
|
return markupParser.markdownToHtml(String(text || ''));
|
|
}
|
|
return String(text || '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
}
|
|
|
|
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.innerHTML = this.renderGlossaryHtml(word.dataset.glossaryTerm || this.normalizeGlossaryText(word.textContent || ''));
|
|
if (body) body.innerHTML = this.renderGlossaryHtml(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('</')) {
|
|
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' && 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;
|