Add glossary hover presentation
This commit is contained in:
@@ -1467,6 +1467,23 @@ html[data-process-state="playing-ready"] [role="button"] {
|
||||
font-feature-settings: "kern" on, "liga" on, "onum" on, "pnum" on, "dlig" on, "clig" on, "calt" on;
|
||||
}
|
||||
|
||||
.story-glossary-word {
|
||||
border-bottom: none;
|
||||
text-decoration-line: underline;
|
||||
text-decoration-style: dotted;
|
||||
text-decoration-color: rgba(74, 54, 33, 0.62);
|
||||
text-decoration-thickness: 0.035em;
|
||||
text-underline-offset: 0.12em;
|
||||
cursor: var(--pointer-cursor, help);
|
||||
}
|
||||
|
||||
.story-glossary-word:hover,
|
||||
.story-glossary-word:focus-visible {
|
||||
color: var(--ink-strong);
|
||||
clip-path: none !important;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@keyframes wordReveal {
|
||||
0% {
|
||||
opacity: 1;
|
||||
@@ -1478,6 +1495,48 @@ html[data-process-state="playing-ready"] [role="button"] {
|
||||
}
|
||||
}
|
||||
|
||||
.story-glossary-tooltip {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 240;
|
||||
width: min(calc(var(--book-width) * 0.22), 28rem);
|
||||
max-width: calc(100vw - 2rem);
|
||||
background: var(--panel-paper);
|
||||
color: var(--ink-text);
|
||||
border: 1px solid var(--panel-border);
|
||||
box-shadow: 0 1.2rem 3rem rgba(0, 0, 0, 0.34);
|
||||
font-family: 'EB Garamond', var(--book-font), serif;
|
||||
font-size: var(--ui-modal-font-size);
|
||||
line-height: 1.25;
|
||||
opacity: 0;
|
||||
transform: translateY(0.22rem);
|
||||
pointer-events: none;
|
||||
transition: opacity 140ms ease, transform 140ms ease;
|
||||
}
|
||||
|
||||
.story-glossary-tooltip.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.story-glossary-tooltip-header {
|
||||
border-bottom: 1px solid var(--rule-brown);
|
||||
padding: calc(var(--story-line-height) * 0.35) calc(var(--story-line-height) * 0.55) calc(var(--story-line-height) * 0.24);
|
||||
}
|
||||
|
||||
.story-glossary-tooltip-title {
|
||||
margin: 0;
|
||||
color: var(--ink-strong);
|
||||
font: inherit;
|
||||
font-style: italic;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.story-glossary-tooltip-body {
|
||||
padding: calc(var(--story-line-height) * 0.38) calc(var(--story-line-height) * 0.55) calc(var(--story-line-height) * 0.48);
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: bold;
|
||||
font-synthesis: none;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -18,6 +18,7 @@ class MarkupParserModule extends BaseModule {
|
||||
'parse',
|
||||
'parseParagraph',
|
||||
'parseInline',
|
||||
'extractGlossaryTags',
|
||||
'parseImageOptions',
|
||||
'parseSfxOptions',
|
||||
'parseMusicOptions',
|
||||
@@ -227,6 +228,21 @@ class MarkupParserModule extends BaseModule {
|
||||
return this.smartypants(plain).replace(/\s{2,}/g, ' ').trim();
|
||||
}
|
||||
|
||||
extractGlossaryTags(tags = []) {
|
||||
if (!Array.isArray(tags)) return [];
|
||||
|
||||
return tags
|
||||
.filter(tag => String(tag?.key || '').toLowerCase() === 'gloss')
|
||||
.map(tag => {
|
||||
const term = String(tag?.value || '').trim();
|
||||
const definition = String(tag?.param || '').trim();
|
||||
if (!term || !definition) return null;
|
||||
return { term, definition };
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => b.term.length - a.term.length);
|
||||
}
|
||||
|
||||
smartypants(text) {
|
||||
const result = String(text)
|
||||
.replace(/---/g, '\u2014')
|
||||
|
||||
@@ -499,6 +499,8 @@ class SentenceQueueModule extends BaseModule {
|
||||
blockId: metadata.blockId ?? null,
|
||||
gameId: metadata.gameId ?? null,
|
||||
paragraphIndex: metadata.paragraphIndex ?? null,
|
||||
layoutText: metadata.layoutText || text,
|
||||
glossaryEntries: Array.isArray(metadata.glossaryEntries) ? metadata.glossaryEntries : [],
|
||||
isFirstParagraphInChapter: Boolean(metadata.isFirstParagraphInChapter),
|
||||
role: metadata.role || (metadata.type === 'heading' ? 'chapter-heading' : 'body'),
|
||||
dropCap: Boolean(metadata.dropCap),
|
||||
@@ -629,6 +631,7 @@ class SentenceQueueModule extends BaseModule {
|
||||
lines: layout.lines || null,
|
||||
processedText: layout.processedText || text,
|
||||
sourceLayoutText: layoutText,
|
||||
glossaryEntries: Array.isArray(metadata.glossaryEntries) ? metadata.glossaryEntries : [],
|
||||
measures,
|
||||
lineOffsets,
|
||||
indentWidth,
|
||||
|
||||
@@ -58,6 +58,7 @@ class SocketClientModule extends BaseModule {
|
||||
'normalizeHistoryBlock',
|
||||
'dispatchTurnTags',
|
||||
'isTimedCueTag',
|
||||
'isRenderMetadataTag',
|
||||
'cueMarkersFromTags',
|
||||
'dispatchChoices',
|
||||
'dispatchInputMode',
|
||||
@@ -360,12 +361,22 @@ class SocketClientModule extends BaseModule {
|
||||
: { role: pendingParagraph || null, cueTags: [] };
|
||||
const tags = Array.isArray(paragraph?.tags) ? paragraph.tags : [];
|
||||
const { blocks, paragraphRole } = this.blocksFromTags(tags, turnId);
|
||||
const text = String(paragraph?.text || '').trim();
|
||||
const rawText = String(paragraph?.text || '').trim();
|
||||
const markupParser = this.getModule('markup-parser');
|
||||
const parsedParagraph = rawText && markupParser && typeof markupParser.parseParagraph === 'function'
|
||||
? markupParser.parseParagraph(rawText)
|
||||
: null;
|
||||
const text = String(parsedParagraph?.text || rawText).trim();
|
||||
const layoutText = parsedParagraph?.layoutText || paragraph.layoutText || text;
|
||||
const glossaryEntries = markupParser && typeof markupParser.extractGlossaryTags === 'function'
|
||||
? markupParser.extractGlossaryTags(tags)
|
||||
: [];
|
||||
const cueTags = tags.filter(tag => this.isTimedCueTag(tag));
|
||||
const deferredTags = tags.filter(tag => this.isDeferredPopupTag(tag));
|
||||
const immediateTags = tags.filter(tag =>
|
||||
!this.isStructuralTag(tag) &&
|
||||
!this.isTimedCueTag(tag) &&
|
||||
!this.isRenderMetadataTag(tag) &&
|
||||
!this.isDeferredPopupTag(tag)
|
||||
);
|
||||
|
||||
@@ -390,6 +401,7 @@ class SocketClientModule extends BaseModule {
|
||||
const role = pending.role || paragraphRole || 'body';
|
||||
const cueMarkers = [
|
||||
...(Array.isArray(paragraph.cueMarkers) ? paragraph.cueMarkers : []),
|
||||
...(Array.isArray(parsedParagraph?.cueMarkers) ? parsedParagraph.cueMarkers : []),
|
||||
...this.cueMarkersFromTags([
|
||||
...(Array.isArray(pending.cueTags) ? pending.cueTags : []),
|
||||
...cueTags
|
||||
@@ -398,7 +410,8 @@ class SocketClientModule extends BaseModule {
|
||||
blocks.push({
|
||||
type: 'paragraph',
|
||||
text,
|
||||
layoutText: paragraph.layoutText || text,
|
||||
layoutText,
|
||||
glossaryEntries,
|
||||
cueMarkers,
|
||||
deferredTags: [
|
||||
...(Array.isArray(pending.deferredTags) ? pending.deferredTags : []),
|
||||
@@ -467,6 +480,11 @@ class SocketClientModule extends BaseModule {
|
||||
return ['sfx', 'sound', 'audio'].includes(key);
|
||||
}
|
||||
|
||||
isRenderMetadataTag(tag) {
|
||||
const key = String(tag?.key || '').toLowerCase();
|
||||
return ['gloss'].includes(key);
|
||||
}
|
||||
|
||||
isDeferredPopupTag(tag) {
|
||||
const key = String(tag?.key || '').toLowerCase();
|
||||
return ['alert', 'achievement', 'score', 'error'].includes(key);
|
||||
|
||||
@@ -1477,6 +1477,7 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
addTopSpace: Boolean(item.addTopSpace ?? item.metadata?.addTopSpace),
|
||||
paragraphIndex: item.paragraphIndex ?? item.metadata?.paragraphIndex,
|
||||
cueMarkers: item.cueMarkers || item.metadata?.cueMarkers || [],
|
||||
glossaryEntries: item.glossaryEntries || item.metadata?.glossaryEntries || [],
|
||||
turnId: item.turnId ?? item.metadata?.turnId,
|
||||
blockId: item.blockId ?? item.metadata?.blockId,
|
||||
gameId: item.gameId ?? item.metadata?.gameId
|
||||
|
||||
Reference in New Issue
Block a user