Checkpoint current UI and ink integration state

This commit is contained in:
2026-05-18 02:46:02 +02:00
parent 2c54498ee2
commit d7bb175167
384 changed files with 922883 additions and 764 deletions
+49
View File
@@ -27,6 +27,7 @@ class AnimationQueueModule extends BaseModule {
this.bindMethods([
'schedule',
'fastForward',
'fastForwardSequential',
'clearAll',
'setSpeed',
'beginFastForward',
@@ -216,6 +217,54 @@ class AnimationQueueModule extends BaseModule {
// Use parent's dispatchEvent method
this.dispatchEvent('ui:animation:fastforward', { state: false });
}
fastForwardSequential(maxDuration = 320) {
if (this.timeoutQueue.length === 0) {
console.log('AnimationQueue: No animations to fast forward sequentially');
return;
}
const now = Date.now();
const pending = this.timeoutQueue
.filter(timeout => !timeout.executed)
.map(timeout => {
if (timeout.timeoutId !== null) {
clearTimeout(timeout.timeoutId);
timeout.timeoutId = null;
}
const remaining = Math.max(0, (timeout.startTime + timeout.delay) - now);
return { timeout, remaining };
})
.sort((left, right) => left.remaining - right.remaining);
if (pending.length === 0) {
this.timeoutQueue = [];
this.endFastForward();
this.emitAnimationComplete();
return;
}
console.log(`AnimationQueue: Fast forwarding ${pending.length} pending items sequentially`);
this.beginFastForward();
const maxRemaining = Math.max(1, ...pending.map(item => item.remaining));
const compressedDuration = Math.max(80, Number(maxDuration) || 320);
pending.forEach(({ timeout, remaining }) => {
timeout.timeoutId = setTimeout(() => {
timeout.execute();
const index = this.timeoutQueue.indexOf(timeout);
if (index !== -1) {
this.timeoutQueue.splice(index, 1);
}
if (this.timeoutQueue.length === 0) {
this.delay = 0;
this.endFastForward();
this.emitAnimationComplete();
}
}, Math.round((remaining / maxRemaining) * compressedDuration));
});
}
/**
* Begin fast forwarding mode
+24 -30
View File
@@ -36,7 +36,8 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
'generateSpeechAudio',
'preprocessText',
'getPlaybackVolume',
'applyCurrentVolume'
'applyCurrentVolume',
'notifyReadyState'
]);
}
@@ -510,28 +511,19 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
if (wasReady !== this.isReady) {
console.log(`${this.name}: TTS ready state changed to ${this.isReady ? 'ready' : 'not ready'} after API key change`);
// Find and notify the TTS factory
const ttsFactory = this.getModule('tts-factory');
if (ttsFactory) {
// If we have a key now (and didn't before), try initializing voices
if (this.isReady && !wasReady) {
// Reload voices with the new API key
this.loadVoices().then((voicesLoaded) => {
this.isReady = voicesLoaded !== false && !!this.apiKey;
// Then set up voice from preferences
this.setupVoiceFromPreferences().then(() => {
console.log(`${this.name}: API key status: ${this.isReady ? 'ready' : 'not ready'}`);
// Notify the factory of our readiness change
ttsFactory.updateTTSAvailability();
document.dispatchEvent(new CustomEvent('tts:status:updated', {
detail: { provider: this.id, ready: this.isReady }
}));
});
// If we have a key now (and didn't before), try initializing voices.
// TTS providers must not depend back on tts-factory; they publish
// readiness and the factory listens for handler-state changes.
if (this.isReady && !wasReady) {
this.loadVoices().then((voicesLoaded) => {
this.isReady = voicesLoaded !== false && !!this.apiKey;
this.setupVoiceFromPreferences().then(() => {
console.log(`${this.name}: API key status: ${this.isReady ? 'ready' : 'not ready'}`);
this.notifyReadyState();
});
} else {
// Just update the availability
ttsFactory.updateTTSAvailability();
}
});
} else {
this.notifyReadyState();
}
}
}
@@ -566,17 +558,19 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
this.setupVoiceFromPreferences().then(() => {
console.log(`${this.name}: API URL status: ${this.isReady ? 'ready' : 'not ready'}`);
// Notify the TTS factory
const ttsFactory = this.getModule('tts-factory');
if (ttsFactory) {
ttsFactory.updateTTSAvailability();
}
document.dispatchEvent(new CustomEvent('tts:status:updated', {
detail: { provider: this.id, ready: this.isReady }
}));
this.notifyReadyState();
});
});
}
}
}
notifyReadyState() {
document.dispatchEvent(new CustomEvent('tts:handler-state-changed', {
detail: { handler: this.id, ready: this.isReady === true }
}));
document.dispatchEvent(new CustomEvent('tts:status:updated', {
detail: { provider: this.id, ready: this.isReady === true }
}));
}
}
+6 -2
View File
@@ -30,8 +30,12 @@ export class BaseModule {
this.dependencies = [];
this._loadedDependencies = new Map();
// Auto-register with module registry
moduleRegistry.register(this);
// Register after subclass constructors have assigned dependencies.
// Several older modules still call moduleRegistry.register explicitly;
// the registry treats those calls as idempotent.
queueMicrotask(() => {
moduleRegistry.register(this);
});
}
/**
+24 -10
View File
@@ -13,7 +13,7 @@ class ChoiceDisplayModule extends BaseModule {
this.markupParser = null;
this.container = null;
this.choices = [];
this.inputMode = 'text';
this.inputMode = 'none';
this.processState = document.documentElement.dataset.processState || 'loading';
this.template = {
cells: {
@@ -52,7 +52,7 @@ class ChoiceDisplayModule extends BaseModule {
this.handleChoices(event.detail || []);
});
this.addEventListener(document, 'story:input-mode', (event) => {
this.handleInputMode(event.detail || 'text');
this.handleInputMode(event.detail || 'none');
});
this.addEventListener(document, 'story:process-state', (event) => {
this.handleProcessState(event.detail?.state || 'ready');
@@ -103,7 +103,7 @@ class ChoiceDisplayModule extends BaseModule {
}
handleInputMode(inputMode) {
this.inputMode = ['text', 'choice', 'end'].includes(inputMode) ? inputMode : 'text';
this.inputMode = ['text', 'choice', 'end', 'none'].includes(inputMode) ? inputMode : 'none';
this.render();
}
@@ -137,7 +137,7 @@ class ChoiceDisplayModule extends BaseModule {
}
normalizeChoices(choices) {
return this.assignLetters(choices.slice(0, 26).map((choice, order) => {
return this.assignLetters(choices.slice(0, 36).map((choice, order) => {
const tags = Array.isArray(choice.tags) ? choice.tags : [];
const category = choice.category || this.getTagValue(tags, 'action');
return {
@@ -145,6 +145,7 @@ class ChoiceDisplayModule extends BaseModule {
text: String(choice.text || ''),
tags,
category,
optional: this.hasTag(tags, 'optional'),
letter: '',
templateCell: this.getTemplateCell({ ...choice, tags, category })
};
@@ -152,7 +153,7 @@ class ChoiceDisplayModule extends BaseModule {
}
assignLetters(choices) {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
const keySequence = '1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
const reserved = new Set();
choices.forEach((choice) => {
@@ -165,7 +166,7 @@ class ChoiceDisplayModule extends BaseModule {
.charAt(0)
.toUpperCase();
const reservedLetter = explicit || keyExplicit;
if (alphabet.includes(reservedLetter) && !reserved.has(reservedLetter)) {
if (keySequence.includes(reservedLetter) && !reserved.has(reservedLetter)) {
choice.letter = reservedLetter;
reserved.add(reservedLetter);
}
@@ -174,11 +175,11 @@ class ChoiceDisplayModule extends BaseModule {
let nextLetterIndex = 0;
choices.forEach((choice) => {
if (choice.letter) return;
while (nextLetterIndex < alphabet.length && reserved.has(alphabet[nextLetterIndex])) {
while (nextLetterIndex < keySequence.length && reserved.has(keySequence[nextLetterIndex])) {
nextLetterIndex += 1;
}
if (nextLetterIndex < alphabet.length) {
choice.letter = alphabet[nextLetterIndex];
if (nextLetterIndex < keySequence.length) {
choice.letter = keySequence[nextLetterIndex];
reserved.add(choice.letter);
nextLetterIndex += 1;
}
@@ -202,6 +203,11 @@ class ChoiceDisplayModule extends BaseModule {
return tag?.value;
}
hasTag(tags, key) {
const normalizedKey = String(key).toLowerCase();
return Array.isArray(tags) && tags.some((item) => String(item?.key || '').toLowerCase() === normalizedKey);
}
render() {
this.setupContainer();
if (!this.container) return;
@@ -226,6 +232,7 @@ class ChoiceDisplayModule extends BaseModule {
this.choices.forEach((choice) => {
const item = document.createElement('li');
item.className = 'choice-list-item';
item.classList.toggle('choice-optional', Boolean(choice.optional));
item.dataset.choiceIndex = String(choice.index);
item.dataset.choiceLetter = choice.letter;
item.dataset.templateCell = choice.templateCell;
@@ -233,7 +240,9 @@ class ChoiceDisplayModule extends BaseModule {
const button = document.createElement('button');
button.type = 'button';
button.className = 'choice-button';
button.innerHTML = `<kbd>${this.escapeHtml(choice.letter)}</kbd><span>${this.renderChoiceText(choice.text)}</span>`;
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>`;
button.addEventListener('click', () => this.selectChoice(choice.index));
item.appendChild(button);
list.appendChild(item);
@@ -290,6 +299,11 @@ class ChoiceDisplayModule extends BaseModule {
.replace(/\*([^*\s][^*]*?)\*/g, '<em>$1</em>')
.replace(/_([^_\s][^_]*?)_/g, '<em>$1</em>');
}
formatChoiceKey(key) {
const value = String(key || '').trim().charAt(0);
return /^[A-Z]$/.test(value) ? value.toLowerCase() : value;
}
}
const choiceDisplay = new ChoiceDisplayModule();
+25 -8
View File
@@ -39,9 +39,11 @@ const ModuleLoader = (function() {
let statusText = null;
let isLoadingComplete = false;
let moduleWeights = {};
let moduleProgress = {};
let createdModules = new Set(); // Track which modules we've created UI elements for
let gameLoopModule = null; // Add variable to hold game loop instance
let moduleTimings = {}; // Track timing data for modules
let finalizationTimer = null;
/**
* Initialize the loader
@@ -138,6 +140,7 @@ const ModuleLoader = (function() {
// Store module weights for progress calculation
modulesToLoad.forEach(module => {
moduleWeights[module.id] = module.weight;
moduleProgress[module.id] = 0;
});
// Create a module list entry for each module
@@ -692,15 +695,19 @@ const ModuleLoader = (function() {
*/
function handleModuleProgress(event) {
const { moduleId, progress } = event.detail;
const numericProgress = Math.min(100, Math.max(0, Number(progress) || 0));
const previousProgress = Number(moduleProgress[moduleId] || 0);
const nextProgress = Math.max(previousProgress, numericProgress);
moduleProgress[moduleId] = nextProgress;
// Get the module element
const moduleItem = document.querySelector(`#module-${moduleId}`);
if (moduleItem) {
// Update module item's before pseudo-element width using CSS variable
moduleItem.style.setProperty('--progress-width', `${progress}%`);
moduleItem.style.setProperty('--progress-width', `${nextProgress}%`);
// Also set a data attribute for browsers that don't support CSS variables
moduleItem.setAttribute('data-progress', progress);
moduleItem.setAttribute('data-progress', nextProgress);
}
updateOverallProgress();
@@ -716,6 +723,7 @@ const ModuleLoader = (function() {
// If module is finished, update overall completion
if (state === ModuleState.FINISHED) {
moduleProgress[moduleId] = 100;
// This triggers only when ALL modules are complete, so modules would be removed too quickly
// if (areAllModulesComplete()) {
// hideLoadingOverlay();
@@ -725,8 +733,13 @@ const ModuleLoader = (function() {
// Ensure module-finished class is added with a small delay to avoid race conditions
setTimeout(() => {
moduleItem.classList.add('module-finished');
}, 100);
moduleItem.addEventListener('animationend', () => {
moduleItem.remove();
}, { once: true });
}, 120);
}
} else if (state === ModuleState.ERROR) {
moduleProgress[moduleId] = 100;
}
updateOverallProgress();
@@ -783,7 +796,12 @@ const ModuleLoader = (function() {
if (allFinished && !isLoadingComplete) {
console.log('All modules finished loading. Proceeding to finalization...');
finalizeLoading();
if (!finalizationTimer) {
finalizationTimer = setTimeout(() => {
finalizationTimer = null;
finalizeLoading();
}, 900);
}
} else if (allFinished && isLoadingComplete) {
console.log('All modules are finished but isLoadingComplete is already true');
}
@@ -982,19 +1000,18 @@ const ModuleLoader = (function() {
* Update overall progress based on module weights and progress
*/
function updateOverallProgress() {
const modules = moduleRegistry.getAllModules();
const moduleIds = Object.keys(modules);
const moduleIds = Object.keys(moduleWeights);
// Calculate total weight
const totalWeight = moduleIds.reduce((sum, id) => {
return sum + (moduleWeights[id] || 1);
}, 0);
if (totalWeight <= 0) return;
// Calculate weighted progress
let overallProgress = moduleIds.reduce((sum, id) => {
const module = modules[id];
const weight = moduleWeights[id] || 1;
return sum + (module.progress * weight / totalWeight);
return sum + ((moduleProgress[id] || 0) * weight / totalWeight);
}, 0);
overallProgress = Math.min(Math.round(overallProgress), 100);
+6
View File
@@ -20,6 +20,8 @@ export class ModuleRegistry {
return;
}
const alreadyRegistered = this.modules[module.id] === module;
// Store the module
this.modules[module.id] = module;
@@ -39,6 +41,10 @@ export class ModuleRegistry {
this.moduleDependencies.set(module.id, []);
}
if (alreadyRegistered && this.readyPromises[module.id]) {
return;
}
// Create a promise that will resolve when this module is ready
this.readyPromises[module.id] = new Promise((resolve) => {
// Set up a state change listener for this module
+9 -1
View File
@@ -402,12 +402,20 @@ class OptionsUIModule extends BaseModule {
if (!button) return;
const enabled = this.getPreference(button.dataset.prefCategory, button.dataset.prefKey, true) !== false;
button.classList.toggle('is-muted', !enabled);
button.innerHTML = enabled ? '&#128266;' : '&#128263;';
button.innerHTML = this.getVolumeToggleIcon(enabled);
const titleKey = enabled ? button.dataset.muteTitleKey : button.dataset.unmuteTitleKey;
const title = this.t(titleKey);
button.title = title;
button.setAttribute('aria-label', title);
}
getVolumeToggleIcon(enabled) {
const common = `xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"`;
if (enabled) {
return `<svg ${common}><path d="M11 5 6 9H2v6h4l5 4z"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>`;
}
return `<svg ${common}><path d="M11 5 6 9H2v6h4l5 4z"/><path d="m22 9-6 6"/><path d="m16 9 6 6"/></svg>`;
}
/**
* Create API settings controls
+13 -7
View File
@@ -19,6 +19,7 @@ class ParagraphLayoutModule extends BaseModule {
hyphenationEnabled: true,
defaultFontSize: '1rem',
defaultFontFamily: "'EB Garamond', serif",
defaultFontFeatureSettings: "'kern' on, 'liga' on, 'onum' on, 'pnum' on, 'dlig' on, 'clig' on, 'calt' on",
defaultLineHeight: 1.5,
debugMode: false
});
@@ -104,10 +105,7 @@ class ParagraphLayoutModule extends BaseModule {
if (event.detail) {
const { fontSize, fontFamily } = event.detail;
if (fontSize || fontFamily) {
this.updateFont(
fontSize || this.config.defaultFontSize,
fontFamily || this.config.defaultFontFamily
);
this.updateFont(fontSize || this.config.defaultFontSize, fontFamily || this.config.defaultFontFamily);
}
}
});
@@ -119,7 +117,7 @@ class ParagraphLayoutModule extends BaseModule {
});
}
updateFont(fontSize, fontFamily) {
updateFont(fontSize, fontFamily, fontFeatureSettings = null, fontVariantCaps = null) {
if (!this.ruler) {
console.warn("Text measurement ruler not initialized");
return;
@@ -130,7 +128,8 @@ class ParagraphLayoutModule extends BaseModule {
this.ruler.style.fontSize = fontSize;
this.ruler.style.fontFamily = fontFamily;
this.ruler.style.fontFeatureSettings = "'kern' on, 'liga' on, 'onum' on, 'clig' on, 'hlig' on";
this.ruler.style.fontFeatureSettings = fontFeatureSettings || this.config.defaultFontFeatureSettings;
this.ruler.style.fontVariantCaps = fontVariantCaps || 'normal';
if (this.config.debugMode) {
console.log(`Font updated: ${fontSize} ${fontFamily}`);
@@ -214,6 +213,8 @@ class ParagraphLayoutModule extends BaseModule {
fontFamily: options.fontFamily || this.config.defaultFontFamily,
lineHeight: options.lineHeight || this.config.defaultLineHeight,
lineHeightPx: options.lineHeightPx,
fontFeatureSettings: options.fontFeatureSettings || this.config.defaultFontFeatureSettings,
fontVariantCaps: options.fontVariantCaps || null,
tolerance: options.tolerance || 3,
demerits: options.demerits || {
line: 10,
@@ -222,7 +223,12 @@ class ParagraphLayoutModule extends BaseModule {
}
};
this.updateFont(layoutOptions.fontSize, layoutOptions.fontFamily);
this.updateFont(
layoutOptions.fontSize,
layoutOptions.fontFamily,
layoutOptions.fontFeatureSettings,
layoutOptions.fontVariantCaps
);
const numericFontSize = parseFloat(layoutOptions.fontSize) || 16;
const lineHeightPx = layoutOptions.lineHeightPx || (numericFontSize * layoutOptions.lineHeight);
+50 -5
View File
@@ -22,6 +22,7 @@ class PlaybackCoordinatorModule extends BaseModule {
'animateWords',
'waitForAudioStart',
'completeSentenceVisual',
'accelerateActiveWordAnimations',
'fastForward',
'stop'
]);
@@ -92,15 +93,47 @@ class PlaybackCoordinatorModule extends BaseModule {
if (!sentence?.element) return;
sentence.element.dataset.playbackComplete = 'true';
sentence.element.querySelectorAll('.word').forEach(word => {
word.onanimationend = null;
word.style.transition = 'none';
word.style.animation = 'none';
word.style.visibility = 'visible';
word.style.opacity = '1';
word.style.transform = 'translateY(0)';
word.style.clipPath = 'inset(0 0 0 0)';
word.style.clipPath = 'none';
});
}
accelerateActiveWordAnimations(sentence) {
if (!sentence?.element) return;
sentence.element.querySelectorAll('.word').forEach(word => {
if (typeof word.getAnimations !== 'function') return;
word.getAnimations()
.filter(animation => animation.playState === 'running' || animation.playState === 'pending')
.forEach(animation => {
try {
if (animation.effect && typeof animation.effect.getTiming === 'function' && typeof animation.effect.updateTiming === 'function') {
const timing = animation.effect.getTiming();
const elapsed = Number(animation.currentTime || 0);
const duration = Math.max(1, Number(timing.duration || 1));
const remaining = Math.max(0, duration - elapsed);
const targetRemaining = 24;
if (remaining > targetRemaining) {
const playbackRate = Math.min(80, Math.max(1, remaining / targetRemaining));
if (typeof animation.updatePlaybackRate === 'function') {
animation.updatePlaybackRate(playbackRate);
} else {
animation.playbackRate = playbackRate;
}
}
}
} catch (error) {
console.warn('PlaybackCoordinator: Could not accelerate active word animation', error);
}
});
});
}
/**
* Play TTS audio for a sentence
* @param {Object} sentence - Sentence object with TTS data
@@ -217,13 +250,19 @@ class PlaybackCoordinatorModule extends BaseModule {
if (i < wordElements.length) {
animQueue.schedule(() => {
const word = wordElements[i];
const duration = Math.max(0, timing.duration || 0);
const duration = animQueue.isFastForwarding && animQueue.isFastForwarding()
? Math.min(24, Math.max(8, Math.round((timing.duration || 0) * 0.035)))
: Math.max(0, timing.duration || 0);
word.style.transition = 'none';
word.style.animation = 'none';
word.style.visibility = 'visible';
word.style.opacity = '1';
word.style.transform = 'translateY(0)';
word.style.clipPath = 'inset(0 100% 0 0)';
word.onanimationend = () => {
word.style.clipPath = 'none';
word.onanimationend = null;
};
word.style.animation = `wordReveal ${duration}ms linear forwards`;
}, timing.delay);
}
@@ -303,10 +342,15 @@ class PlaybackCoordinatorModule extends BaseModule {
}
console.log('PlaybackCoordinator: Fast forwarding');
this.accelerateActiveWordAnimations(this.currentSentence);
const animQueue = this.getModule('animation-queue');
if (animQueue) {
animQueue.fastForward();
if (typeof animQueue.fastForwardSequential === 'function') {
animQueue.fastForwardSequential(320);
} else {
animQueue.fastForward();
}
}
const ttsFactory = this.getModule('tts-factory');
@@ -321,8 +365,9 @@ class PlaybackCoordinatorModule extends BaseModule {
});
}
// Complete all word animations immediately
this.completeSentenceVisual(this.currentSentence);
if (!animQueue || typeof animQueue.fastForwardSequential !== 'function') {
this.completeSentenceVisual(this.currentSentence);
}
}
/**
+203 -4
View File
@@ -450,6 +450,7 @@ class SentenceQueueModule extends BaseModule {
dropCap: Boolean(metadata.dropCap),
addTopSpace: Boolean(metadata.addTopSpace),
cueMarkers: metadata.cueMarkers || [],
deferredTags: Array.isArray(metadata.deferredTags) ? metadata.deferredTags : [],
status: 'ready',
tts: {
duration: ttsData.duration,
@@ -513,9 +514,12 @@ class SentenceQueueModule extends BaseModule {
// first-line indent on following paragraphs.
const isHeading = metadata.type === 'heading' || metadata.role === 'chapter-heading' || metadata.role === 'section-heading';
const dropCapLines = metadata.dropCap ? 2 : 0;
const dropCapWidth = metadata.dropCap ? lineHeight * 1.45 : 0;
const indentWidth = (isHeading || metadata.isFirstParagraphInChapter || metadata.addTopSpace) ? 0 : lineHeight * 1.5;
const layoutText = metadata.layoutText || text;
const dropCapText = metadata.dropCap ? this.getDropCapText(layoutText) : '';
const dropCapWidth = metadata.dropCap
? this.measureDropCapReservation(storyElement, dropCapText, lineHeight)
: 0;
const layoutPlainText = metadata.dropCap ? this.extractDropCapText(layoutText) : layoutText;
const measures = Array.isArray(metadata.measures) && metadata.measures.length > 0
? metadata.measures
@@ -550,13 +554,16 @@ class SentenceQueueModule extends BaseModule {
console.log(`SentenceQueue: Layout calculation - indentWidth: ${indentWidth.toFixed(1)}px, measures: [${measures.map(m => m.toFixed(1)).join(', ')}], offsets: [${lineOffsets.map(m => m.toFixed(1)).join(', ')}]`);
const layout = paragraphLayout.calculateLayout(layoutPlainText, {
const layoutOptions = {
measures,
fontSize: `${fontSize}px`,
fontFamily,
lineHeight: lineHeight / fontSize,
lineHeightPx: lineHeight
});
};
const layout = metadata.dropCap
? this.calculateDropCapLayout(paragraphLayout, layoutPlainText, measures, lineOffsets, layoutOptions)
: paragraphLayout.calculateLayout(layoutPlainText, layoutOptions);
if (!layout) {
throw new Error('Paragraph layout calculation failed');
@@ -565,6 +572,7 @@ class SentenceQueueModule extends BaseModule {
return {
breaks: layout.breaks,
nodes: layout.nodes,
lines: layout.lines || null,
processedText: layout.processedText || text,
sourceLayoutText: layoutText,
measures,
@@ -572,7 +580,8 @@ class SentenceQueueModule extends BaseModule {
indentWidth,
imageWrap: metadata.imageWrap || null,
dropCap: Boolean(metadata.dropCap),
dropCapText: metadata.dropCap ? this.getDropCapText(layoutText) : '',
dropCapText,
dropCapWidth,
dropCapLines,
addTopSpace: Boolean(metadata.addTopSpace),
role: metadata.role || (isHeading ? 'chapter-heading' : 'body'),
@@ -826,6 +835,196 @@ class SentenceQueueModule extends BaseModule {
return String(text).replace(dropCap, '').trimStart();
}
measureDropCapReservation(container, dropCapText, lineHeight) {
if (!container || !dropCapText) {
return lineHeight * 1.34;
}
const probeParagraph = document.createElement('p');
const probe = document.createElement('span');
Object.assign(probeParagraph.style, {
position: 'absolute',
visibility: 'hidden',
left: '-8000px',
top: '-8000px',
margin: '0',
padding: '0',
lineHeight: `${lineHeight}px`
});
probe.className = 'drop-cap story-drop-cap';
probe.textContent = dropCapText;
probe.style.position = 'static';
probe.style.display = 'inline-block';
probeParagraph.appendChild(probe);
container.appendChild(probeParagraph);
const rect = probe.getBoundingClientRect();
const computed = window.getComputedStyle(probe);
let inkRight = 0;
try {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (context) {
context.font = [
computed.fontStyle,
computed.fontVariant,
computed.fontWeight,
computed.fontSize,
computed.fontFamily
].filter(Boolean).join(' ');
const metrics = context.measureText(dropCapText);
inkRight = Math.max(
metrics.width || 0,
metrics.actualBoundingBoxRight || 0
);
}
} catch (error) {
console.warn('SentenceQueue: Could not measure drop-cap canvas ink bounds', error);
}
probeParagraph.remove();
const measuredAdvance = Math.max(
Number.isFinite(rect.width) && rect.width > 0 ? rect.width : 0,
Number.isFinite(probe.offsetWidth) && probe.offsetWidth > 0 ? probe.offsetWidth : 0,
Number.isFinite(probe.scrollWidth) && probe.scrollWidth > 0 ? probe.scrollWidth : 0,
inkRight
);
const glyphAdvance = measuredAdvance > 0 ? measuredAdvance : lineHeight * 1.34;
return glyphAdvance + this.measureNormalTextGap(container, lineHeight);
}
measureNormalTextGap(container, lineHeight) {
const story = container?.closest?.('#story') || document.getElementById('story') || container;
const computed = window.getComputedStyle(story);
try {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (context) {
context.font = [
computed.fontStyle,
computed.fontVariant,
computed.fontWeight,
computed.fontSize,
computed.fontFamily
].filter(Boolean).join(' ');
const gap = context.measureText('\u2002').width;
if (Number.isFinite(gap) && gap > 0) {
return gap;
}
}
} catch (error) {
console.warn('SentenceQueue: Could not measure normal text gap', error);
}
return lineHeight / 2;
}
calculateDropCapLayout(paragraphLayout, text, measures, lineOffsets, layoutOptions) {
const firstLineOptions = {
...layoutOptions,
measures: [measures[0], Math.max(measures[0] * 20, 10000)],
fontVariantCaps: 'all-small-caps',
fontFeatureSettings: '"smcp" on, "c2sc" on, "kern" on, "liga" on, "onum" on, "pnum" on'
};
const firstLayout = paragraphLayout.calculateLayout(text, firstLineOptions);
if (!firstLayout?.breaks || firstLayout.breaks.length < 2) {
return paragraphLayout.calculateLayout(text, layoutOptions);
}
const firstLine = this.extractLayoutLine(firstLayout, 0, {
measure: measures[0],
offset: lineOffsets[0],
styleClass: 'story-dropcap-first-line'
});
const remainingText = this.extractRemainingLayoutText(firstLayout, firstLayout.breaks[1].position);
const remainingLayout = paragraphLayout.calculateLayout(remainingText, {
...layoutOptions,
measures: [measures[1], ...measures.slice(2)]
});
const remainingLines = [];
if (remainingLayout?.breaks?.length > 1) {
for (let lineIndex = 0; lineIndex < remainingLayout.breaks.length - 1; lineIndex += 1) {
remainingLines.push(this.extractLayoutLine(remainingLayout, lineIndex, {
measure: measures[Math.min(lineIndex + 1, measures.length - 1)],
offset: lineOffsets[Math.min(lineIndex + 1, lineOffsets.length - 1)] || 0,
styleClass: ''
}));
}
}
const lines = [firstLine, ...remainingLines].filter(Boolean);
return {
breaks: this.breaksFromLines(lines),
nodes: lines.flatMap(line => line.nodes),
lines,
originalText: text,
processedText: text,
width: layoutOptions.width,
lineHeight: layoutOptions.lineHeight,
lineHeightPx: layoutOptions.lineHeightPx,
fontSize: layoutOptions.fontSize,
fontFamily: layoutOptions.fontFamily
};
}
extractLayoutLine(layout, lineIndex, metadata = {}) {
const startBreak = layout.breaks[lineIndex];
const endBreak = layout.breaks[lineIndex + 1];
if (!startBreak || !endBreak || !Array.isArray(layout.nodes)) {
return null;
}
const nodes = [];
for (let index = startBreak.position; index <= endBreak.position; index += 1) {
const node = layout.nodes[index];
if (!node) continue;
if (node.type === 'glue' && (index === startBreak.position || index === endBreak.position)) {
continue;
}
const forcedBreak = window.linebreak?.infinity ? -window.linebreak.infinity : -100000;
if (node.type === 'penalty' && node.penalty <= forcedBreak) {
continue;
}
nodes.push({ ...node });
}
const endNode = layout.nodes[endBreak.position];
return {
nodes,
ratio: endBreak.ratio || 0,
measure: metadata.measure,
offset: metadata.offset || 0,
styleClass: metadata.styleClass || '',
hyphenated: endNode?.type === 'penalty' && endNode.penalty === 100
};
}
extractRemainingLayoutText(layout, breakPosition) {
if (!Array.isArray(layout.nodes)) return '';
const fragments = [];
for (let index = breakPosition + 1; index < layout.nodes.length; index += 1) {
const node = layout.nodes[index];
if (!node) continue;
if (node.type === 'box' || node.type === 'tag') {
fragments.push(node.value || '');
} else if (node.type === 'glue' && node.width > 0) {
fragments.push(' ');
} else if (node.type === 'penalty' && node.penalty === 100) {
fragments.push('|');
}
}
return fragments.join('').replace(/\s+/g, ' ').trimStart();
}
breaksFromLines(lines) {
const breaks = [{ position: 0, ratio: 0 }];
let position = 0;
for (const line of lines) {
position += Math.max(0, line.nodes.length - 1);
breaks.push({ position, ratio: line.ratio || 0 });
position += 1;
}
return breaks;
}
/**
* Calculate animation timing based on TTS duration
* @param {Array<string>} words - Array of words to animate
+36 -6
View File
@@ -214,7 +214,7 @@ class SocketClientModule extends BaseModule {
this.receivedParagraphCounter = 0;
}
const globalTags = Array.isArray(data.globalTags) ? data.globalTags : [];
const globalTags = Array.isArray(data.globalTags) ? [...data.globalTags] : [];
const endState = data.gameState?.endState || null;
if (endState && !globalTags.some((tag) => tag?.key === 'score' || tag?.key === 'error')) {
globalTags.push({
@@ -227,8 +227,9 @@ class SocketClientModule extends BaseModule {
document.dispatchEvent(new CustomEvent('story:global-tags', {
detail: globalTags
}));
this.dispatchTurnTags(globalTags, null);
this.dispatchTurnTags(globalTags.filter(tag => !this.isDeferredPopupTag(tag)), null);
}
const deferredGlobalTags = globalTags.filter(tag => this.isDeferredPopupTag(tag));
document.dispatchEvent(new CustomEvent('story:turn-start', {
detail: { turnId, turn: data }
@@ -244,11 +245,22 @@ class SocketClientModule extends BaseModule {
pendingParagraph = result.pendingParagraph;
turnBlocks.push(...result.blocks);
});
if (deferredGlobalTags.length > 0) {
const targetBlock = [...turnBlocks].reverse().find(block => block?.type === 'paragraph' || block?.type === 'heading');
if (targetBlock) {
targetBlock.deferredTags = [
...(Array.isArray(targetBlock.deferredTags) ? targetBlock.deferredTags : []),
...deferredGlobalTags
];
} else {
this.dispatchTurnTags(deferredGlobalTags, null);
}
}
await this.storeAndQueueBlocks(turnBlocks);
const choices = Array.isArray(data.choices) ? data.choices : [];
const inputMode = data.inputMode || (choices.length > 0 ? 'choice' : 'text');
const inputMode = data.inputMode || (choices.length > 0 ? 'choice' : 'none');
this.dispatchChoices(choices);
this.dispatchInputMode(inputMode);
if (turnBlocks.length === 0 && choices.length > 0) {
@@ -282,7 +294,7 @@ class SocketClientModule extends BaseModule {
}
dispatchInputMode(inputMode) {
const mode = ['text', 'choice', 'end'].includes(inputMode) ? inputMode : 'text';
const mode = ['text', 'choice', 'end', 'none'].includes(inputMode) ? inputMode : 'none';
document.dispatchEvent(new CustomEvent('story:input-mode', {
detail: mode
}));
@@ -296,7 +308,12 @@ class SocketClientModule extends BaseModule {
const { blocks, paragraphRole } = this.blocksFromTags(tags, turnId);
const text = String(paragraph?.text || '').trim();
const cueTags = tags.filter(tag => this.isTimedCueTag(tag));
const immediateTags = tags.filter(tag => !this.isStructuralTag(tag) && !this.isTimedCueTag(tag));
const deferredTags = tags.filter(tag => this.isDeferredPopupTag(tag));
const immediateTags = tags.filter(tag =>
!this.isStructuralTag(tag) &&
!this.isTimedCueTag(tag) &&
!this.isDeferredPopupTag(tag)
);
this.dispatchTurnTags(immediateTags, paragraph);
if (!text) {
@@ -307,6 +324,10 @@ class SocketClientModule extends BaseModule {
cueTags: [
...(Array.isArray(pending.cueTags) ? pending.cueTags : []),
...cueTags
],
deferredTags: [
...(Array.isArray(pending.deferredTags) ? pending.deferredTags : []),
...deferredTags
]
}
};
@@ -325,6 +346,10 @@ class SocketClientModule extends BaseModule {
text,
layoutText: paragraph.layoutText || text,
cueMarkers,
deferredTags: [
...(Array.isArray(pending.deferredTags) ? pending.deferredTags : []),
...deferredTags
],
role,
isFirstParagraphInChapter: role === 'chapter-first' || role === 'textblock-first',
dropCap: role === 'chapter-first',
@@ -332,7 +357,7 @@ class SocketClientModule extends BaseModule {
turnId
});
return { blocks, pendingParagraph: { role: null, cueTags: [] } };
return { blocks, pendingParagraph: { role: null, cueTags: [], deferredTags: [] } };
}
async storeAndQueueBlocks(blocks = []) {
@@ -388,6 +413,11 @@ class SocketClientModule extends BaseModule {
return ['sfx', 'sound', 'audio'].includes(key);
}
isDeferredPopupTag(tag) {
const key = String(tag?.key || '').toLowerCase();
return ['alert', 'achievement', 'score', 'error'].includes(key);
}
cueMarkersFromTags(tags) {
if (!Array.isArray(tags)) return [];
+7 -8
View File
@@ -7,7 +7,7 @@ class UIControllerModule extends BaseModule {
// Remove 'tts' from direct dependencies to break circular dependency
// UI Controller will access TTS through the Game Loop instead
this.dependencies = ['animation-queue', 'ui-display-handler', 'ui-input-handler', 'ui-effects', 'text-buffer', 'socket-client', 'sentence-queue', 'playback-coordinator', 'persistence-manager'];
this.dependencies = ['animation-queue', 'ui-display-handler', 'ui-input-handler', 'ui-effects', 'text-buffer', 'socket-client', 'sentence-queue', 'playback-coordinator', 'persistence-manager', 'tts-factory', 'options-ui'];
// References to sub-modules
this.displayHandler = null;
@@ -734,15 +734,13 @@ class UIControllerModule extends BaseModule {
// Update speech toggle button state
if (speechToggle) {
// Update the button appearance based on TTS state using existing styles
speechToggle.removeAttribute('disabled');
speechToggle.setAttribute('aria-pressed', this.ttsEnabled ? 'true' : 'false');
speechToggle.classList.toggle('is-active', this.ttsEnabled);
speechToggle.classList.toggle('is-inactive', !this.ttsEnabled);
if (this.ttsEnabled) {
speechToggle.style.fontWeight = 'bold';
speechToggle.style.color = '#000';
speechToggle.title = this.ttsAvailable ? 'Disable speech' : 'Speech enabled, selected provider is not ready';
} else {
speechToggle.style.fontWeight = 'normal';
speechToggle.style.color = '#999';
speechToggle.title = 'Enable speech';
}
}
@@ -750,8 +748,9 @@ class UIControllerModule extends BaseModule {
if (autoplayToggle) {
const autoplay = this.getStoredAppPreference('autoplay', true) !== false;
autoplayToggle.removeAttribute('disabled');
autoplayToggle.style.fontWeight = autoplay ? 'bold' : 'normal';
autoplayToggle.style.color = autoplay ? '#000' : '#999';
autoplayToggle.setAttribute('aria-pressed', autoplay ? 'true' : 'false');
autoplayToggle.classList.toggle('is-active', autoplay);
autoplayToggle.classList.toggle('is-inactive', !autoplay);
}
}
+166 -32
View File
@@ -52,6 +52,7 @@ class UIDisplayHandlerModule extends BaseModule {
this.notificationActive = false;
this.pendingTerminalNotifications = [];
this.latestInputMode = 'text';
this.markdownRendererPromise = null;
// Resources to preload
this.cssPath = '/css/style.css';
@@ -130,9 +131,14 @@ class UIDisplayHandlerModule extends BaseModule {
'openCreditsDialog',
'closeCreditsDialog',
'loadCreditsText',
'getMarkdownRenderer',
'renderMarkdown',
'populateCreativeCredits',
'creditLink',
'createNotificationDialog',
'handleStoryTag',
'getTagMessage',
'dispatchDeferredTagsForBlock',
'showNotification',
'displayNextNotification',
'queueTerminalNotification',
@@ -379,7 +385,7 @@ class UIDisplayHandlerModule extends BaseModule {
controls.innerHTML = `
<a id="speech"></a>
<a id="autoplay"></a>
<span><a id="speed_reset"><span id="speed_label"></span><sup>*</sup></a><input type="range" min="50" max="150" value="100" id="speed" name="speed" /></span>
<span><a id="speed_reset"><span id="speed_label"></span></a><input type="range" min="50" max="150" value="100" id="speed" name="speed" /></span>
<a id="rewind"></a>
<a id="save"></a>
<a id="reload" disabled="disabled"></a>
@@ -391,6 +397,10 @@ class UIDisplayHandlerModule extends BaseModule {
const choicesContainer = document.createElement('div');
choicesContainer.id = 'choices';
choicesContainer.className = 'container';
const controlSeparator = document.createElement('div');
controlSeparator.id = 'left_control_separator';
choicesContainer.appendChild(controlSeparator);
// Create command history container
const commandHistory = document.createElement('div');
@@ -414,13 +424,20 @@ class UIDisplayHandlerModule extends BaseModule {
const remark = document.createElement('div');
remark.id = 'remark';
remark.innerHTML = `
<div id="remark_hint"><i><sup>*</sup><span id="remark_text"></span></i></div>
<div id="remark_hint"><i><span id="remark_text"></span></i></div>
<div id="game_legal"></div>
`;
this.pageLeft.appendChild(remark);
bookContainer.appendChild(this.pageLeft);
}
const choicesPanel = document.getElementById('choices');
if (choicesPanel && !document.getElementById('left_control_separator')) {
const controlSeparator = document.createElement('div');
controlSeparator.id = 'left_control_separator';
choicesPanel.insertBefore(controlSeparator, choicesPanel.firstChild);
}
// Create or find page_right
this.pageRight = document.getElementById('page_right');
@@ -574,7 +591,7 @@ class UIDisplayHandlerModule extends BaseModule {
setText('remark_text', 'title.fastForwardHint');
setText('start_prompt', 'title.startPrompt');
setText('credits_dialog_title', 'credits.title');
setText('credits_close', 'credits.close');
setText('credits_close_footer', 'credits.close');
setText('story_popup_ok', 'popup.ok');
setTitle('speech', 'topbar.speechTitle');
setTitle('autoplay', 'topbar.autoplayTitle');
@@ -601,16 +618,13 @@ class UIDisplayHandlerModule extends BaseModule {
<div class="credits-dialog" role="dialog" aria-modal="true" aria-labelledby="credits_dialog_title">
<div class="credits-dialog-header">
<h2 id="credits_dialog_title"></h2>
<button type="button" id="credits_close"></button>
<button type="button" id="credits_close" class="close" aria-label="Close">&times;</button>
</div>
<div class="credits-logo-row" aria-label="Credits links">
<a href="https://openai.com/" target="_blank" rel="noreferrer"><img src="https://cdn.simpleicons.org/openai/2b2218" alt="OpenAI"></a>
<a href="https://www.inklestudios.com/ink/" target="_blank" rel="noreferrer" class="credits-wordmark">ink</a>
<a href="https://mnater.github.io/Hyphenopoly/" target="_blank" rel="noreferrer" class="credits-wordmark">Hyphenopoly</a>
<a href="https://github.com/hexgrad/kokoro" target="_blank" rel="noreferrer" class="credits-wordmark">Kokoro</a>
<a href="https://suno.com/" target="_blank" rel="noreferrer" class="credits-wordmark">Suno</a>
<div id="credits_creative" class="credits-creative"></div>
<div id="credits_content" class="credits-content"></div>
<div class="modal-footer">
<button type="button" id="credits_close_footer"></button>
</div>
<pre id="credits_content" class="credits-content"></pre>
</div>
`;
@@ -622,10 +636,9 @@ class UIDisplayHandlerModule extends BaseModule {
}
});
const closeButton = document.getElementById('credits_close');
if (closeButton) {
closeButton.addEventListener('click', () => this.closeCreditsDialog());
}
[document.getElementById('credits_close'), document.getElementById('credits_close_footer')]
.filter(Boolean)
.forEach(button => button.addEventListener('click', () => this.closeCreditsDialog()));
}
async openCreditsDialog() {
@@ -640,9 +653,10 @@ class UIDisplayHandlerModule extends BaseModule {
if (!content.dataset.loaded) {
content.textContent = this.t('credits.loading');
content.textContent = await this.loadCreditsText();
content.innerHTML = await this.renderMarkdown(await this.loadCreditsText());
content.dataset.loaded = 'true';
}
this.populateCreativeCredits();
}
closeCreditsDialog() {
@@ -667,6 +681,86 @@ class UIDisplayHandlerModule extends BaseModule {
}
}
async getMarkdownRenderer() {
if (!this.markdownRendererPromise) {
this.markdownRendererPromise = import('/js/vendor/marked.esm.js')
.then(module => module.marked || module.default || module);
}
return this.markdownRendererPromise;
}
async renderMarkdown(markdown) {
try {
const renderer = await this.getMarkdownRenderer();
if (typeof renderer.parse === 'function') {
return renderer.parse(String(markdown || ''), { async: false });
}
} catch (error) {
console.warn('UIDisplayHandler: Failed to render Markdown notices', error);
}
return `<pre>${String(markdown || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</pre>`;
}
populateCreativeCredits() {
const container = document.getElementById('credits_creative');
if (!container || container.dataset.loaded) return;
const sections = [
{
title: 'Production',
rows: [
['Produced by', ['Bad Tools Studio']],
['Story', ['Georg Tomitsch']],
['Writing', ['Georg Tomitsch', 'ChatGPT']],
['UI visual design', ['Georg Tomitsch']],
['Typography', ['EB Garamond 12 by Georg Duffner and Octavio Pardo', 'EB Garamond Initials by Georg Duffner']],
['Art direction', ['Georg Tomitsch']],
['Music', ['Georg Tomitsch', 'Suno']],
['Images', ['OpenAI GPT-image-2']]
]
},
{
title: 'Technology',
rows: [
['Runtime server programming', ['Georg Tomitsch', 'OpenAI Codex']],
['Client and UI programming', ['Georg Tomitsch', 'OpenAI Codex', 'Claude Code']],
['Game engine', ['Ink by Inkle', 'inkjs by Yannick Lohse']]
]
}
];
container.innerHTML = sections.map(section => `
<section class="credits-creative-column">
<h3>${section.title}</h3>
${section.rows.map(([label, names]) => `
<div class="credits-creative-row">
<dt>${label}</dt>
<dd>${names.map(name => this.creditLink(name)).join(', ')}</dd>
</div>
`).join('')}
</section>
`).join('');
container.dataset.loaded = 'true';
}
creditLink(name) {
const links = {
'Bad Tools Studio': '',
'OpenAI Codex': 'https://openai.com/codex/',
'OpenAI GPT-image-2': 'https://openai.com/',
'ChatGPT': 'https://chatgpt.com/',
'Claude Code': 'https://www.anthropic.com/claude-code',
'Ink by Inkle': 'https://www.inklestudios.com/ink/',
'inkjs by Yannick Lohse': 'https://www.npmjs.com/package/inkjs',
'EB Garamond 12 by Georg Duffner and Octavio Pardo': 'https://github.com/octaviopardo/EBGaramond12',
'EB Garamond Initials by Georg Duffner': 'https://github.com/georgd/EB-Garamond',
'Suno': 'https://suno.com/'
};
const escaped = String(name || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const url = links[name];
return url ? `<a href="${url}" target="_blank" rel="noreferrer">${escaped}</a>` : escaped;
}
createNotificationDialog() {
if (document.getElementById('story_popup_modal')) {
return;
@@ -678,9 +772,14 @@ class UIDisplayHandlerModule extends BaseModule {
modal.setAttribute('aria-hidden', 'true');
modal.innerHTML = `
<div class="story-popup-dialog" role="dialog" aria-modal="true" aria-labelledby="story_popup_title">
<h2 id="story_popup_title"></h2>
<div class="story-popup-dialog-header">
<h2 id="story_popup_title"></h2>
<button type="button" id="story_popup_close" class="close" aria-label="Close">&times;</button>
</div>
<div id="story_popup_message"></div>
<button type="button" id="story_popup_ok"></button>
<div class="modal-footer">
<button type="button" id="story_popup_ok"></button>
</div>
</div>
`;
@@ -696,14 +795,13 @@ class UIDisplayHandlerModule extends BaseModule {
event.stopPropagation();
});
const okButton = document.getElementById('story_popup_ok');
if (okButton) {
okButton.addEventListener('click', (event) => {
[document.getElementById('story_popup_ok'), document.getElementById('story_popup_close')]
.filter(Boolean)
.forEach(button => button.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
this.closeNotification();
});
}
}));
}
handleStoryTag(tag) {
@@ -714,13 +812,13 @@ class UIDisplayHandlerModule extends BaseModule {
const message = this.getTagMessage(tag);
if (key === 'score') {
this.queueTerminalNotification(
this.showNotification(
'ending',
this.t('popup.endingTitle'),
message || this.t('popup.defaultEnding')
);
} else if (key === 'error') {
this.queueTerminalNotification(
this.showNotification(
'error',
this.t('popup.errorTitle'),
message || this.t('popup.defaultError')
@@ -747,6 +845,28 @@ class UIDisplayHandlerModule extends BaseModule {
.join('\n');
}
dispatchDeferredTagsForBlock(block) {
const directTags = Array.isArray(block?.deferredTags) ? block.deferredTags : [];
const metadataTags = Array.isArray(block?.metadata?.deferredTags) ? block.metadata.deferredTags : [];
const tags = [...directTags, ...metadataTags];
if (tags.length === 0) return;
tags.forEach((tag) => {
if (!tag?.key) return;
document.dispatchEvent(new CustomEvent('story:tag', {
detail: {
...tag,
blockId: block.blockId ?? null,
turnId: block.turnId ?? null
}
}));
});
block.deferredTags = [];
if (block.metadata) {
block.metadata.deferredTags = [];
}
}
showNotification(kind, title, message) {
this.notificationQueue.push({ kind, title, message });
this.displayNextNotification();
@@ -852,6 +972,8 @@ class UIDisplayHandlerModule extends BaseModule {
console.log('UIDisplayHandler: Music block started', sentence.metadata || {});
}
this.dispatchDeferredTagsForBlock(sentence);
if (sentence.onComplete) {
sentence.onComplete();
}
@@ -1344,6 +1466,16 @@ class UIDisplayHandlerModule extends BaseModule {
blockId: item.blockId ?? item.metadata?.blockId,
gameId: item.gameId ?? item.metadata?.gameId
};
if (metadata.dropCap && typeof sentenceQueue.measureDropCapReservation === 'function') {
const dropCapText = typeof sentenceQueue.getDropCapText === 'function'
? sentenceQueue.getDropCapText(metadata.layoutText || item.text || '')
: String(metadata.layoutText || item.text || '').trim().charAt(0);
metadata.dropCapWidth = sentenceQueue.measureDropCapReservation(
this.container || this.paragraphContainer || document.getElementById('story'),
dropCapText,
this.measureStoryLineHeight()
);
}
const role = metadata.role;
const isHeading = type === 'heading' || role === 'chapter-heading' || role === 'section-heading';
@@ -1472,7 +1604,11 @@ class UIDisplayHandlerModule extends BaseModule {
const lineHeight = this.measureStoryLineHeight();
const isHeading = metadata.type === 'heading' || metadata.role === 'chapter-heading' || metadata.role === 'section-heading';
const dropCapLines = metadata.dropCap ? 2 : 0;
const dropCapWidth = metadata.dropCap ? lineHeight * 1.45 : 0;
const dropCapWidth = metadata.dropCap
? (Number.isFinite(Number(metadata.dropCapWidth)) && Number(metadata.dropCapWidth) > 0
? Number(metadata.dropCapWidth)
: lineHeight * 1.34)
: 0;
const indentWidth = (isHeading || metadata.isFirstParagraphInChapter || metadata.addTopSpace) ? 0 : lineHeight * 1.5;
const maxConsideredLines = Math.max(80, this.pageLineCount * 4);
const measures = [];
@@ -1510,7 +1646,7 @@ class UIDisplayHandlerModule extends BaseModule {
word.style.visibility = 'visible';
word.style.opacity = '1';
word.style.transform = 'translateY(0)';
word.style.clipPath = 'inset(0 0 0 0)';
word.style.clipPath = 'none';
});
}
@@ -2162,11 +2298,9 @@ class UIDisplayHandlerModule extends BaseModule {
: maxOuterWidth;
const naturalHeight = maxImageWidth / aspect;
const imageLineCount = Math.max(1, Math.floor(naturalHeight / lineHeight));
const verticalMargin = isPortrait ? lineHeight / 2 : 0;
const lineCount = isPortrait ? imageLineCount + 1 : imageLineCount;
const height = isPortrait
? Math.max(lineHeight, (lineCount * lineHeight) - (verticalMargin * 2))
: imageLineCount * lineHeight;
const verticalMargin = lineHeight / 2;
const lineCount = imageLineCount + 1;
const height = Math.max(lineHeight, (lineCount * lineHeight) - (verticalMargin * 2));
const width = Math.min(maxImageWidth, height * aspect);
return {
+83 -15
View File
@@ -14,11 +14,13 @@ class UIInputHandlerModule extends BaseModule {
this.commandHistoryElement = null;
// Input state
this.inputEnabled = true;
this.inputEnabled = false;
this.historyIndex = -1;
this.commandHistory = [];
this.inputBuffer = '';
this.inputMode = 'text';
this.inputMode = 'none';
this.cursorAnimationTimer = null;
this.cursorAnimationFrame = 0;
// Bind methods using the parent class bindMethods utility
this.bindMethods([
@@ -37,6 +39,10 @@ class UIInputHandlerModule extends BaseModule {
'setProcessState',
'setInputAvailability',
'setMode',
'setInputModeDataset',
'installMouseCursors',
'startMouseCursorAnimation',
'stopMouseCursorAnimation',
'clearHistory'
]);
@@ -62,11 +68,12 @@ class UIInputHandlerModule extends BaseModule {
this.reportProgress(60, 'Setting up input elements');
this.setupInputElements();
this.installMouseCursors();
this.addEventListener(document, 'story:process-state', (event) => {
this.setProcessState(event.detail?.state || 'ready', event.detail || {});
});
this.addEventListener(document, 'story:input-mode', (event) => {
this.setMode(event.detail || 'text');
this.setMode(event.detail || 'none');
});
this.addEventListener(document, 'story:turn-start', (event) => {
this.bindHistoryToTurn(event.detail?.turnId);
@@ -157,6 +164,12 @@ class UIInputHandlerModule extends BaseModule {
pageLeft.appendChild(choicesContainer);
}
if (!document.getElementById('left_control_separator')) {
const controlSeparator = document.createElement('div');
controlSeparator.id = 'left_control_separator';
choicesContainer.insertBefore(controlSeparator, choicesContainer.firstChild);
}
// Create command history container if needed
let commandHistory = document.getElementById('command_history');
@@ -227,10 +240,8 @@ class UIInputHandlerModule extends BaseModule {
// Position the cursor
if (playerInput && cursor) {
this.positionCursor(playerInput, cursor);
this.setProcessState('ready', { reason: 'input-initialized' });
this.focusInput();
requestAnimationFrame(() => this.focusInput());
setTimeout(() => this.focusInput(), 250);
this.setInputModeDataset();
this.setInputAvailability(false);
}
console.log('UIInputHandler: Input elements setup complete');
@@ -312,8 +323,14 @@ class UIInputHandlerModule extends BaseModule {
}
setMode(mode) {
this.inputMode = ['text', 'choice', 'end'].includes(mode) ? mode : 'text';
this.setInputAvailability(this.inputMode === 'text');
this.inputMode = ['text', 'choice', 'end', 'none'].includes(mode) ? mode : 'none';
this.setInputModeDataset();
const state = document.documentElement.dataset.processState || 'loading';
this.setInputAvailability(this.inputMode === 'text' && state === 'ready');
}
setInputModeDataset() {
document.documentElement.dataset.inputMode = this.inputMode || 'none';
}
applyMouseCursor(state) {
@@ -326,28 +343,79 @@ class UIInputHandlerModule extends BaseModule {
const cursor = this.getMouseCursor(state);
if (cursor) {
root.style.setProperty('--process-cursor', cursor);
this.startMouseCursorAnimation(state);
} else {
root.style.removeProperty('--process-cursor');
this.stopMouseCursorAnimation();
}
}
installMouseCursors() {
const root = document.documentElement;
if (!root) return;
root.style.setProperty('--default-cursor', this.buildMouseCursor('default', 'default', 4, 3));
root.style.setProperty('--pointer-cursor', this.buildMouseCursor('pointer', 'pointer', 7, 2));
}
getMouseCursor(state) {
if (state === 'ready') {
return '';
}
const svg = this.getMouseCursorSvg(state);
const fallback = state === 'command-waiting' ? 'wait' : 'progress';
return `url("${this.toCursorDataUrl(svg)}") 12 12, ${fallback}`;
return this.buildMouseCursor(state, fallback, 12, 12, this.cursorAnimationFrame);
}
getMouseCursorSvg(state) {
buildMouseCursor(state, fallback = 'default', hotspotX = 12, hotspotY = 12, frame = 0) {
const svg = this.getMouseCursorSvg(state, frame);
return `url("${this.toCursorDataUrl(svg)}") ${hotspotX} ${hotspotY}, ${fallback}`;
}
startMouseCursorAnimation(state) {
const animatedStates = new Set(['command-waiting', 'waiting-generating', 'playing-generating']);
if (!animatedStates.has(state)) {
this.stopMouseCursorAnimation();
return;
}
if (this.cursorAnimationTimer) return;
this.cursorAnimationTimer = window.setInterval(() => {
const currentState = document.documentElement.dataset.processState || 'ready';
if (!animatedStates.has(currentState)) {
this.stopMouseCursorAnimation();
return;
}
this.cursorAnimationFrame = (this.cursorAnimationFrame + 1) % 8;
document.documentElement.style.setProperty('--process-cursor', this.getMouseCursor(currentState));
}, 160);
}
stopMouseCursorAnimation() {
if (this.cursorAnimationTimer) {
window.clearInterval(this.cursorAnimationTimer);
this.cursorAnimationTimer = null;
}
this.cursorAnimationFrame = 0;
}
getMouseCursorSvg(state, frame = 0) {
const stroke = '#222222';
const common = `xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="${stroke}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"`;
const spinnerSpokes = Array.from({ length: 8 }, (_, index) => {
const opacity = 0.25 + (((index + frame) % 8) / 7) * 0.75;
const angle = (index * 45) * Math.PI / 180;
const x1 = 12 + Math.cos(angle) * 6;
const y1 = 12 + Math.sin(angle) * 6;
const x2 = 12 + Math.cos(angle) * 9;
const y2 = 12 + Math.sin(angle) * 9;
return `<path opacity="${opacity.toFixed(2)}" d="M${x1.toFixed(2)} ${y1.toFixed(2)} ${x2.toFixed(2)} ${y2.toFixed(2)}"/>`;
}).join('');
const sandTop = frame % 4 < 2 ? '<path d="M9 5h6"/><path d="M10 8h4"/>' : '<path d="M10 16h4"/><path d="M9 19h6"/>';
const icons = {
'command-waiting': `<svg ${common}><path d="M5 22h14"/><path d="M5 2h14"/><path d="M17 22v-4.172a2 2 0 0 0-.586-1.414L12 12l-4.414 4.414A2 2 0 0 0 7 17.828V22"/><path d="M7 2v4.172a2 2 0 0 0 .586 1.414L12 12l4.414-4.414A2 2 0 0 0 17 6.172V2"/></svg>`,
'waiting-generating': `<svg ${common}><path d="M12 2v4"/><path d="M12 18v4"/><path d="m4.93 4.93 2.83 2.83"/><path d="m16.24 16.24 2.83 2.83"/><path d="M2 12h4"/><path d="M18 12h4"/><path d="m4.93 19.07 2.83-2.83"/><path d="m16.24 7.76 2.83-2.83"/></svg>`,
'playing-generating': `<svg ${common}><path d="M11 5 6 9H2v6h4l5 4z"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M12 2v3"/><path d="M12 19v3"/></svg>`,
'default': `<svg ${common}><path d="M4 3l7.5 18 2.1-7.4L21 11z"/><path d="M13.6 13.6 18 18"/></svg>`,
'pointer': `<svg ${common}><path d="M8 11V4a2 2 0 1 1 4 0v6"/><path d="M12 10V7a2 2 0 1 1 4 0v5"/><path d="M16 12v-1a2 2 0 1 1 4 0v3a7 7 0 0 1-7 7h-1a6 6 0 0 1-5-2.7L3 13a2 2 0 0 1 3-2.6l2 2.1"/></svg>`,
'command-waiting': `<svg ${common}><path d="M5 22h14"/><path d="M5 2h14"/><path d="M17 22v-4.172a2 2 0 0 0-.586-1.414L12 12l-4.414 4.414A2 2 0 0 0 7 17.828V22"/><path d="M7 2v4.172a2 2 0 0 0 .586 1.414L12 12l4.414-4.414A2 2 0 0 0 17 6.172V2"/>${sandTop}</svg>`,
'waiting-generating': `<svg ${common}>${spinnerSpokes}</svg>`,
'playing-generating': `<svg ${common}><path d="M11 5 6 9H2v6h4l5 4z"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>${spinnerSpokes}</svg>`,
'playing-ready': `<svg ${common}><path d="M11 5 6 9H2v6h4l5 4z"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>`
};
+2189
View File
File diff suppressed because it is too large Load Diff