Checkpoint current interactive fiction changes
This commit is contained in:
@@ -8,11 +8,13 @@ class ChoiceDisplayModule extends BaseModule {
|
||||
constructor() {
|
||||
super('choice-display', 'Choice Display');
|
||||
|
||||
this.dependencies = ['socket-client', 'markup-parser'];
|
||||
this.dependencies = ['socket-client', 'markup-parser', 'layout-renderer'];
|
||||
this.socketClient = null;
|
||||
this.markupParser = null;
|
||||
this.layoutRenderer = null;
|
||||
this.container = null;
|
||||
this.choices = [];
|
||||
this.currentGlossaryEntries = [];
|
||||
this.inputMode = 'none';
|
||||
this.processState = document.documentElement.dataset.processState || 'loading';
|
||||
this.currentTurnId = 0;
|
||||
@@ -52,13 +54,15 @@ class ChoiceDisplayModule extends BaseModule {
|
||||
'getTagValue',
|
||||
'getTag',
|
||||
'getTemplateCell',
|
||||
'renderChoiceText'
|
||||
'renderChoiceText',
|
||||
'applyChoiceGlossary'
|
||||
]);
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
this.socketClient = this.getModule('socket-client');
|
||||
this.markupParser = this.getModule('markup-parser');
|
||||
this.layoutRenderer = this.getModule('layout-renderer');
|
||||
this.setupContainer();
|
||||
|
||||
this.addEventListener(document, 'story:choices', (event) => {
|
||||
@@ -124,7 +128,14 @@ class ChoiceDisplayModule extends BaseModule {
|
||||
}
|
||||
|
||||
handleChoices(choices) {
|
||||
this.choices = this.normalizeChoices(Array.isArray(choices) ? choices : []);
|
||||
const detail = Array.isArray(choices)
|
||||
? { choices, glossaryEntries: [] }
|
||||
: {
|
||||
choices: Array.isArray(choices?.choices) ? choices.choices : [],
|
||||
glossaryEntries: Array.isArray(choices?.glossaryEntries) ? choices.glossaryEntries : []
|
||||
};
|
||||
this.currentGlossaryEntries = detail.glossaryEntries;
|
||||
this.choices = this.normalizeChoices(detail.choices);
|
||||
this.render();
|
||||
}
|
||||
|
||||
@@ -344,6 +355,7 @@ class ChoiceDisplayModule extends BaseModule {
|
||||
const renderedText = this.renderChoiceText(choice.text);
|
||||
const displayKey = this.formatChoiceKey(choice.letter);
|
||||
button.innerHTML = `<kbd>${this.escapeHtml(displayKey)}</kbd><span>${choice.optional ? `<em>${renderedText}</em>` : renderedText}</span>`;
|
||||
this.applyChoiceGlossary(button.querySelector('span'), choice);
|
||||
button.addEventListener('click', () => this.selectChoice(choice.index));
|
||||
item.appendChild(button);
|
||||
list.appendChild(item);
|
||||
@@ -470,6 +482,27 @@ class ChoiceDisplayModule extends BaseModule {
|
||||
.replace(/_([^_\s][^_]*?)_/g, '<em>$1</em>');
|
||||
}
|
||||
|
||||
applyChoiceGlossary(label, choice = null) {
|
||||
if (!label) return;
|
||||
if (!this.layoutRenderer) {
|
||||
this.layoutRenderer = this.getModule('layout-renderer');
|
||||
}
|
||||
if (!this.markupParser) {
|
||||
this.markupParser = this.getModule('markup-parser');
|
||||
}
|
||||
const choiceEntries = this.markupParser && typeof this.markupParser.extractGlossaryTags === 'function'
|
||||
? this.markupParser.extractGlossaryTags(choice?.tags || [])
|
||||
: [];
|
||||
const entries = [
|
||||
...(Array.isArray(this.currentGlossaryEntries) ? this.currentGlossaryEntries : []),
|
||||
...choiceEntries
|
||||
];
|
||||
if (entries.length === 0) return;
|
||||
if (this.layoutRenderer && typeof this.layoutRenderer.applyGlossaryEntriesToInline === 'function') {
|
||||
this.layoutRenderer.applyGlossaryEntriesToInline(label, entries);
|
||||
}
|
||||
}
|
||||
|
||||
formatChoiceKey(key) {
|
||||
const value = String(key || '').trim().charAt(0);
|
||||
return /^[A-Z]$/.test(value) ? value.toLowerCase() : value;
|
||||
|
||||
@@ -26,6 +26,7 @@ class LayoutRendererModule extends BaseModule {
|
||||
'adjustJustification',
|
||||
'decorateInlineWord',
|
||||
'applyGlossaryEntries',
|
||||
'applyGlossaryEntriesToInline',
|
||||
'normalizeGlossaryText',
|
||||
'normalizeGlossaryToken',
|
||||
'normalizeGlossaryCompact',
|
||||
@@ -34,6 +35,7 @@ class LayoutRendererModule extends BaseModule {
|
||||
'decorateGlossarySegment',
|
||||
'decorateGlossaryRange',
|
||||
'decorateGlossaryWord',
|
||||
'renderGlossaryHtml',
|
||||
'ensureGlossaryTooltip',
|
||||
'showGlossaryTooltip',
|
||||
'hideGlossaryTooltip',
|
||||
@@ -390,6 +392,81 @@ class LayoutRendererModule extends BaseModule {
|
||||
});
|
||||
}
|
||||
|
||||
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')
|
||||
@@ -568,6 +645,17 @@ class LayoutRendererModule extends BaseModule {
|
||||
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;
|
||||
@@ -598,8 +686,8 @@ class LayoutRendererModule extends BaseModule {
|
||||
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 || '';
|
||||
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');
|
||||
|
||||
@@ -67,6 +67,9 @@ class SocketClientModule extends BaseModule {
|
||||
'isTimedCueTag',
|
||||
'isRenderMetadataTag',
|
||||
'cueMarkersFromTags',
|
||||
'collectGlossaryEntriesForTurn',
|
||||
'applyTurnGlossaryEntries',
|
||||
'mergeGlossaryEntries',
|
||||
'dispatchChoices',
|
||||
'dispatchInputMode',
|
||||
'handleServerError',
|
||||
@@ -302,9 +305,11 @@ class SocketClientModule extends BaseModule {
|
||||
}
|
||||
}
|
||||
|
||||
const turnGlossaryEntries = this.collectGlossaryEntriesForTurn(turnBlocks, globalTags);
|
||||
this.applyTurnGlossaryEntries(turnBlocks, turnGlossaryEntries);
|
||||
const choices = Array.isArray(data.choices) ? data.choices : [];
|
||||
const inputMode = data.inputMode || (choices.length > 0 ? 'choice' : 'none');
|
||||
this.dispatchChoices(choices);
|
||||
this.dispatchChoices(choices, turnGlossaryEntries);
|
||||
this.dispatchInputMode(inputMode);
|
||||
|
||||
await this.storeAndQueueBlocks(turnBlocks);
|
||||
@@ -336,9 +341,49 @@ class SocketClientModule extends BaseModule {
|
||||
});
|
||||
}
|
||||
|
||||
dispatchChoices(choices) {
|
||||
collectGlossaryEntriesForTurn(blocks = [], globalTags = []) {
|
||||
const markupParser = this.getModule('markup-parser');
|
||||
const fromGlobal = markupParser && typeof markupParser.extractGlossaryTags === 'function'
|
||||
? markupParser.extractGlossaryTags(globalTags)
|
||||
: [];
|
||||
const entries = [
|
||||
...fromGlobal,
|
||||
...blocks.flatMap(block => Array.isArray(block?.glossaryEntries) ? block.glossaryEntries : [])
|
||||
];
|
||||
const seen = new Set();
|
||||
return entries.filter((entry) => {
|
||||
const key = `${entry?.term || ''}\u0000${entry?.definition || ''}`;
|
||||
if (!entry?.term || !entry?.definition || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
applyTurnGlossaryEntries(blocks = [], entries = []) {
|
||||
if (!Array.isArray(blocks) || !Array.isArray(entries) || entries.length === 0) return;
|
||||
|
||||
blocks
|
||||
.filter(block => block?.type === 'paragraph' || block?.type === 'heading')
|
||||
.forEach((block) => {
|
||||
block.glossaryEntries = this.mergeGlossaryEntries(block.glossaryEntries, entries);
|
||||
});
|
||||
}
|
||||
|
||||
mergeGlossaryEntries(...entryLists) {
|
||||
const seen = new Set();
|
||||
return entryLists
|
||||
.flatMap(list => Array.isArray(list) ? list : [])
|
||||
.filter((entry) => {
|
||||
const key = `${entry?.term || ''}\u0000${entry?.definition || ''}`;
|
||||
if (!entry?.term || !entry?.definition || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
dispatchChoices(choices, glossaryEntries = []) {
|
||||
document.dispatchEvent(new CustomEvent('story:choices', {
|
||||
detail: choices
|
||||
detail: { choices, glossaryEntries }
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
super('ui-display-handler', 'UI Display Handler');
|
||||
|
||||
// Module dependencies
|
||||
this.dependencies = ['layout-renderer', 'playback-coordinator', 'game-config', 'localization', 'story-history', 'sentence-queue', 'persistence-manager'];
|
||||
this.dependencies = ['layout-renderer', 'playback-coordinator', 'game-config', 'localization', 'story-history', 'sentence-queue', 'persistence-manager', 'markup-parser'];
|
||||
|
||||
// DOM elements
|
||||
this.container = null;
|
||||
@@ -146,7 +146,8 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
'displayNextNotification',
|
||||
'queueTerminalNotification',
|
||||
'flushTerminalNotifications',
|
||||
'closeNotification'
|
||||
'closeNotification',
|
||||
'renderInlineMarkup'
|
||||
]);
|
||||
|
||||
console.log('UIDisplayHandler: Constructor initialized');
|
||||
@@ -906,8 +907,8 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
}
|
||||
|
||||
modal.dataset.kind = next.kind;
|
||||
title.textContent = next.title;
|
||||
message.textContent = next.message;
|
||||
title.innerHTML = this.renderInlineMarkup(next.title);
|
||||
message.innerHTML = this.renderInlineMarkup(next.message);
|
||||
if (okButton) {
|
||||
okButton.textContent = this.t('popup.ok');
|
||||
setTimeout(() => okButton.focus(), 0);
|
||||
@@ -928,6 +929,17 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
this.notificationActive = false;
|
||||
setTimeout(() => this.displayNextNotification(), 0);
|
||||
}
|
||||
|
||||
renderInlineMarkup(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, '>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure text width using canvas
|
||||
@@ -1470,6 +1482,7 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
|
||||
handleHistoryWheel(event) {
|
||||
if (!event.target?.closest?.('#page_right') || !this.pageRight) return;
|
||||
if (event.target?.closest?.('.story-choices')) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.handleManualScrollStart('wheel');
|
||||
|
||||
Reference in New Issue
Block a user