Checkpoint current interactive fiction changes

This commit is contained in:
2026-06-03 12:59:43 +02:00
parent 61127c0a92
commit bccefd2a68
11 changed files with 342 additions and 895 deletions
+36 -3
View File
@@ -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;
+90 -2
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
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');
+48 -3
View File
@@ -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 }
}));
}
+17 -4
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
/**
* 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');