Add glossary hover presentation

This commit is contained in:
2026-05-19 07:34:52 +02:00
parent 751ac5f62b
commit 121b174f2c
19 changed files with 2356 additions and 1912 deletions
+180 -1
View File
@@ -23,7 +23,16 @@ class LayoutRendererModule extends BaseModule {
this.bindMethods([
'renderParagraph',
'initializeContainers',
'adjustJustification'
'adjustJustification',
'decorateInlineWord',
'applyGlossaryEntries',
'normalizeGlossaryText',
'decorateGlossaryWord',
'ensureGlossaryTooltip',
'showGlossaryTooltip',
'hideGlossaryTooltip',
'positionGlossaryTooltip',
'escapeRegExp'
]);
}
@@ -142,6 +151,7 @@ class LayoutRendererModule extends BaseModule {
maxLineWidth
});
});
this.applyGlossaryEntries(paragraph, layoutData.glossaryEntries);
return paragraph;
}
@@ -184,6 +194,7 @@ class LayoutRendererModule extends BaseModule {
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' &&
@@ -191,6 +202,7 @@ class LayoutRendererModule extends BaseModule {
// 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
@@ -214,6 +226,7 @@ class LayoutRendererModule extends BaseModule {
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)) {
@@ -283,14 +296,178 @@ class LayoutRendererModule extends BaseModule {
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);
@@ -311,6 +488,7 @@ class LayoutRendererModule extends BaseModule {
if (lastChild && isTrailingPunctuation) {
syllable += node.value;
lastChild.innerHTML = syllable;
this.decorateInlineWord(lastChild, stack);
currentLeft += node.width || 0;
continue;
}
@@ -330,6 +508,7 @@ class LayoutRendererModule extends BaseModule {
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;