Checkpoint current UI and ink integration state
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? '🔊' : '🔇';
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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">×</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, '&').replace(/</g, '<').replace(/>/g, '>')}</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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
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">×</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 {
|
||||
|
||||
@@ -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>`
|
||||
};
|
||||
|
||||
|
||||
Vendored
+2189
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user