import { BaseModule } from './base-module.js';
class UIInputHandlerModule extends BaseModule {
constructor() {
super('ui-input-handler', 'UI Input Handler');
// Explicitly declare ui-display-handler as a dependency
this.dependencies = ['ui-display-handler', 'markup-parser', 'playback-coordinator'];
// Input elements
this.inputArea = null;
this.playerInput = null;
this.cursor = null;
this.commandHistoryElement = null;
// Input state
this.inputEnabled = false;
this.historyIndex = -1;
this.commandHistory = [];
this.inputBuffer = '';
this.inputMode = 'none';
this.cursorAnimationTimer = null;
this.cursorAnimationFrame = 0;
// Bind methods using the parent class bindMethods utility
this.bindMethods([
'setupInputElements',
'handlePlayerInput',
'handleInputKeyDown',
'positionCursor',
'handleKeyboardInput',
'submitCommand',
'addToHistory',
'bindHistoryToTurn',
'highlightHistoryTurn',
'formatCommandHistory',
'resetCursorPosition',
'focusInput',
'setProcessState',
'setInputAvailability',
'setMode',
'setInputModeDataset',
'installMouseCursors',
'startMouseCursorAnimation',
'stopMouseCursorAnimation',
'normalizeProcessState',
'clearHistory'
]);
console.log('UIInputHandler: Constructor initialized');
}
async initialize() {
try {
this.reportProgress(0, 'Initializing UI Input Handler');
// Get display handler reference through the parent's getModule method
this.displayHandler = this.getModule('ui-display-handler');
if (!this.displayHandler) {
console.error('UIInputHandler: Display handler module not found');
return false;
}
this.reportProgress(30, 'Setting up keyboard listeners');
// Use the parent's addEventListener for automatic cleanup
this.addEventListener(document, 'keydown', this.handleKeyboardInput);
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 || 'none');
});
this.addEventListener(document, 'story:turn-start', (event) => {
this.bindHistoryToTurn(event.detail?.turnId);
});
this.addEventListener(document, 'story:visible-turn', (event) => {
this.highlightHistoryTurn(event.detail?.turnId);
});
this.reportProgress(100, 'UI Input Handler ready');
return true;
} catch (error) {
console.error('Error initializing UI Input Handler:', error);
return false;
}
}
/**
* Handle keyboard shortcuts and input globally
* @param {KeyboardEvent} event - The keyboard event
*/
handleKeyboardInput(event) {
if (!this.playerInput) return;
if (event.key === 'Escape') {
this.playerInput.blur();
return;
}
const optionsModal = document.getElementById('options-modal');
if (optionsModal && optionsModal.style.display !== 'none') {
return;
}
if (event.key === ' ' && (this.isPlaybackActive() || this.isSkippablePauseActive())) {
document.dispatchEvent(new CustomEvent('ui:command', {
detail: { type: 'continue', source: 'spacebar' }
}));
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
if (document.body.dataset.gameRunning !== 'true' || this.inputMode !== 'text') {
return;
}
this.submitCommand();
return;
}
if (event.ctrlKey || event.metaKey || event.altKey) {
return;
}
if (event.key.length === 1 && document.activeElement !== this.playerInput) {
if (document.body.dataset.gameRunning !== 'true') {
return;
}
if (this.inputMode !== 'text') {
return;
}
event.preventDefault();
this.focusInput();
const start = this.playerInput.selectionStart ?? this.playerInput.value.length;
const end = this.playerInput.selectionEnd ?? start;
this.playerInput.setRangeText(event.key, start, end, 'end');
this.playerInput.dispatchEvent(new Event('input', { bubbles: true }));
}
}
setupInputElements() {
console.log("UIInputHandler: Setting up input elements in document flow");
// Find the left page - this is created by the display handler
const pageLeft = document.getElementById('page_left');
if (!pageLeft) {
console.error('UIInputHandler: Left page not found, cannot create input elements');
return;
}
// Only create choices container if it doesn't already exist
let choicesContainer = document.getElementById('choices');
if (!choicesContainer) {
choicesContainer = document.createElement('div');
choicesContainer.id = 'choices';
choicesContainer.className = 'container';
// Use natural document flow, not absolute positioning
// Do NOT add a separator here, as it already exists in the CSS
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');
if (!commandHistory) {
commandHistory = document.createElement('div');
commandHistory.id = 'command_history';
choicesContainer.appendChild(commandHistory);
this.commandHistoryElement = commandHistory;
} else {
this.commandHistoryElement = commandHistory;
}
// Create input container if needed
let commandInput = document.getElementById('command_input');
if (!commandInput) {
commandInput = document.createElement('div');
commandInput.id = 'command_input';
choicesContainer.appendChild(commandInput);
}
// Create input wrapper if needed
let inputWrapper = commandInput.querySelector('.input-wrapper');
if (!inputWrapper) {
inputWrapper = document.createElement('div');
inputWrapper.className = 'input-wrapper';
commandInput.appendChild(inputWrapper);
}
// Create the textarea if needed
let playerInput = document.getElementById('player_input');
if (!playerInput) {
playerInput = document.createElement('textarea');
playerInput.id = 'player_input';
playerInput.rows = 1;
playerInput.placeholder = 'What will you do?';
// Fix horizontal scrolling by ensuring the textbox wraps text
playerInput.style.overflowX = 'hidden';
playerInput.style.wordWrap = 'break-word';
playerInput.style.whiteSpace = 'pre-wrap';
inputWrapper.appendChild(playerInput);
}
this.playerInput = playerInput;
this.applyTextInputAttributes(playerInput);
// Create the cursor if needed
let cursor = document.getElementById('cursor');
if (!cursor) {
cursor = document.createElement('span');
cursor.id = 'cursor';
inputWrapper.appendChild(cursor);
}
this.cursor = cursor;
// Set up input event handlers
if (playerInput) {
playerInput.addEventListener('input', this.handlePlayerInput);
playerInput.addEventListener('keydown', this.handleInputKeyDown);
// Auto-resize input field
playerInput.addEventListener('input', () => {
playerInput.style.height = 'auto';
playerInput.style.height = playerInput.scrollHeight + 'px';
});
}
// Position the cursor
if (playerInput && cursor) {
this.positionCursor(playerInput, cursor);
this.setInputModeDataset();
this.setInputAvailability(false);
}
console.log('UIInputHandler: Input elements setup complete');
}
focusInput() {
if (document.body.dataset.gameRunning !== 'true') {
return;
}
if (!this.playerInput) {
this.playerInput = document.getElementById('player_input');
}
if (this.playerInput && !this.playerInput.disabled) {
this.playerInput.focus();
}
}
setProcessState(state, detail = {}) {
const knownStates = [
'ready',
'paused',
'command-waiting',
'waiting-generating',
'playing-generating',
'playing-ready'
];
const requestedState = knownStates.includes(state) ? state : 'ready';
const nextState = this.normalizeProcessState(requestedState);
this.applyMouseCursor(nextState);
if (this.cursor) {
knownStates.forEach(value => this.cursor.classList.remove(`cursor-${value}`));
this.cursor.classList.add(`cursor-${nextState}`);
this.cursor.dataset.processState = nextState;
this.cursor.setAttribute('aria-label', 'text input cursor');
this.cursor.innerHTML = '';
}
console.log(`Cursor process state: ${nextState}`, detail);
this.setInputAvailability(nextState === 'ready');
}
setInputAvailability(enabled) {
this.inputEnabled = Boolean(enabled) && this.inputMode === 'text';
const commandInput = document.getElementById('command_input');
if (commandInput) {
commandInput.classList.toggle('fading', !this.inputEnabled);
commandInput.setAttribute('aria-hidden', this.inputEnabled ? 'false' : 'true');
}
if (this.playerInput) {
this.playerInput.disabled = !this.inputEnabled;
this.playerInput.readOnly = !this.inputEnabled;
if (this.inputEnabled && document.body.dataset.gameRunning === 'true') {
this.focusInput();
}
}
}
normalizeProcessState(state) {
const playbackCoordinator = this.getModule('playback-coordinator');
const isPlaying = Boolean(playbackCoordinator?.isPlaying);
if (isPlaying && state === 'ready') {
return 'playing-ready';
}
if (isPlaying && state === 'waiting-generating') {
return 'playing-generating';
}
return state;
}
applyTextInputAttributes(playerInput) {
if (!playerInput) return;
const attributes = {
autocomplete: 'off',
autocorrect: 'off',
autocapitalize: 'sentences',
spellcheck: 'true',
'aria-autocomplete': 'none',
'data-form-type': 'other',
'data-1p-ignore': 'true',
'data-lpignore': 'true',
'data-bwignore': 'true'
};
Object.entries(attributes).forEach(([name, value]) => {
playerInput.setAttribute(name, value);
});
}
setMode(mode) {
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) {
const root = document.documentElement;
if (!root) {
return;
}
root.dataset.processState = state;
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' || state === 'paused') {
return '';
}
if (state === 'command-waiting' || state === 'waiting-generating') {
return this.buildMouseCursor(state, 'progress', 16, 16, this.cursorAnimationFrame);
}
return this.buildMouseCursor(state, 'default', 5, 24, this.cursorAnimationFrame);
}
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 = '#2a1b10';
const common = `xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none" stroke="${stroke}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"`;
const makeSpinner = (centerX, centerY, innerRadius, outerRadius, strokeWidth = 1.8) => Array.from({ length: 8 }, (_, index) => {
const opacity = 0.25 + (((index + frame) % 8) / 7) * 0.75;
const angle = (index * 45) * Math.PI / 180;
const x1 = centerX + Math.cos(angle) * innerRadius;
const y1 = centerY + Math.sin(angle) * innerRadius;
const x2 = centerX + Math.cos(angle) * outerRadius;
const y2 = centerY + Math.sin(angle) * outerRadius;
return ``;
}).join('');
const spinnerSpokes = makeSpinner(24, 24, 3, 5);
const largeSpinnerSpokes = makeSpinner(16, 16, 5.2, 10.2, 2.2);
const arrow = '';
const pointer = '';
const feather = '';
const speaker = '';
const spinner = `${spinnerSpokes}`;
const largeSpinner = `${largeSpinnerSpokes}`;
const hourglassSand = frame % 4 < 2 ? '' : '';
const hourglass = `${hourglassSand}`;
const icons = {
'default': ``,
'pointer': ``,
'command-waiting': ``,
'waiting-generating': ``,
'playing-generating': ``,
'playing-ready': ``
};
return icons[state] || icons['waiting-generating'];
}
toCursorDataUrl(svg) {
return `data:image/svg+xml,${encodeURIComponent(svg.replace(/\s+/g, ' ').trim())}`;
}
/**
* Handle player input changes
* @param {Event} e - Input event
*/
handlePlayerInput(e) {
if (!this.playerInput) return;
// Auto-resize the input field based on content
this.playerInput.style.height = 'auto';
this.playerInput.style.height = `${this.playerInput.scrollHeight}px`;
// Update the cursor position with the current input text
if (this.cursor) {
this.positionCursor(this.playerInput, this.cursor);
}
// Use the parent class dispatchEvent method instead of custom _dispatchModuleEvent
this.dispatchEvent('ui:input:change', {
text: this.playerInput.value
});
}
/**
* Handle key down events in the input field
* @param {KeyboardEvent} e - Keyboard event
*/
handleInputKeyDown(e) {
if (!this.playerInput) return;
// Check for Enter key
if (e.key === 'Enter') {
if (!e.shiftKey) {
// Prevent default (new line) if not holding shift
e.preventDefault();
// Submit command
this.submitCommand();
}
}
}
/**
* Submit the current input as a command
*/
submitCommand() {
if (!this.playerInput || !this.playerInput.value.trim()) return;
if (document.body.dataset.gameRunning !== 'true' || !this.inputEnabled) return;
if (this.inputMode !== 'text') return;
const command = this.playerInput.value.trim();
console.log(`UIInputHandler: Submitting command: "${command}"`);
this.addToHistory(command);
this.dispatchEvent('ui:command', {
type: 'input',
text: command
});
// Clear input field
this.playerInput.value = '';
this.playerInput.style.height = 'auto';
// Update cursor position
if (this.cursor) {
this.positionCursor(this.playerInput, this.cursor);
}
// Focus input field
this.playerInput.focus();
}
clearHistory() {
this.commandHistory = [];
this.historyIndex = -1;
if (!this.commandHistoryElement) {
this.commandHistoryElement = document.getElementById('command_history');
}
if (this.commandHistoryElement) {
this.commandHistoryElement.innerHTML = '';
}
}
/**
* Add command to history
* @param {string} command - Command to add to history
*/
addToHistory(command) {
// Add to history array
this.commandHistory.push(command);
// Limit history size
if (this.commandHistory.length > 50) {
this.commandHistory.shift();
}
// Reset history index
this.historyIndex = -1;
// Update visual history if element exists
if (this.commandHistoryElement && this.commandHistoryElement.appendChild) {
const historyItem = document.createElement('div');
historyItem.className = 'history-item';
historyItem.dataset.turnId = 'pending';
historyItem.innerHTML = `> ${this.formatCommandHistory(command)}`;
historyItem.addEventListener('click', () => {
const turnId = historyItem.dataset.turnId;
if (!turnId || turnId === 'pending') return;
document.dispatchEvent(new CustomEvent('story:scroll-to-turn', {
detail: { turnId: Number(turnId) }
}));
});
this.commandHistoryElement.appendChild(historyItem);
// Limit visible history items
while (this.commandHistoryElement.childElementCount > 10) {
this.commandHistoryElement.removeChild(this.commandHistoryElement.firstChild);
}
// Scroll to bottom
this.commandHistoryElement.scrollTop = this.commandHistoryElement.scrollHeight;
}
}
bindHistoryToTurn(turnId) {
if (!Number.isInteger(Number(turnId))) return;
if (!this.commandHistoryElement) {
this.commandHistoryElement = document.getElementById('command_history');
}
const pending = this.commandHistoryElement?.querySelector('.history-item[data-turn-id="pending"]');
if (!pending) return;
pending.dataset.turnId = String(turnId);
pending.classList.remove('history-pending');
}
highlightHistoryTurn(turnId) {
if (!this.commandHistoryElement || turnId == null) return;
const id = String(turnId);
this.commandHistoryElement.querySelectorAll('.history-item').forEach((item) => {
item.classList.toggle('active', item.dataset.turnId === id);
});
}
formatCommandHistory(command) {
const parser = this.getModule('markup-parser') || window.MarkupParser;
if (parser && typeof parser.markdownToHtml === 'function') {
return parser.markdownToHtml(command);
}
return String(command)
.replace(/&/g, '&')
.replace(//g, '>');
}
isPlaybackActive() {
const playbackCoordinator = this.getModule('playback-coordinator') || window.PlaybackCoordinator;
return Boolean(playbackCoordinator && playbackCoordinator.isPlaying);
}
isSkippablePauseActive() {
return document.documentElement.dataset.skippablePause === 'true';
}
/**
* Resets the cursor position to the start.
*/
resetCursorPosition() {
if (this.cursor) {
this.cursor.style.left = '0px';
// Adjust top based on computed style padding or a default
const computedStyle = window.getComputedStyle(this.playerInput);
const paddingTop = parseFloat(computedStyle.paddingTop) || 6;
this.cursor.style.top = `${paddingTop}px`;
}
}
/**
* Position cursor based on input text position
* @param {HTMLTextAreaElement} inputElement - The input element
* @param {HTMLElement} cursorElement - The visual cursor element
*/
positionCursor(inputElement, cursorElement) {
if (!inputElement || !cursorElement) return;
this.cursor = cursorElement;
this.playerInput = inputElement;
const updatePosition = () => {
try {
const input = this.playerInput;
const cursor = this.cursor;
const caretPosition = input.selectionStart || 0;
const inputText = input.value;
// If no text, position cursor at the beginning based on padding
if (inputText.length === 0 && caretPosition === 0) {
this.resetCursorPosition();
return;
}
// Create a temporary measurement div
const div = document.createElement('div');
const style = getComputedStyle(input);
// Apply relevant styles from the textarea to the div
div.style.position = 'absolute';
div.style.top = '-9999px';
div.style.left = '-9999px';
div.style.width = style.width;
div.style.height = 'auto';
div.style.padding = style.padding;
div.style.border = style.border;
div.style.fontFamily = style.fontFamily;
div.style.fontSize = style.fontSize;
div.style.fontWeight = style.fontWeight;
div.style.lineHeight = style.lineHeight;
div.style.whiteSpace = 'pre-wrap';
div.style.wordWrap = 'break-word';
div.style.boxSizing = style.boxSizing;
// Create spans for text before and after the caret, and a marker span
const preCaretText = document.createTextNode(inputText.substring(0, caretPosition));
const caretMarker = document.createElement('span');
caretMarker.innerHTML = ' '; // Use non-breaking space for measurement
const postCaretText = document.createTextNode(inputText.substring(caretPosition));
// Append spans to the div
div.appendChild(preCaretText);
div.appendChild(caretMarker);
div.appendChild(postCaretText);
// Append div to body for measurement
document.body.appendChild(div);
// Get position relative to the div's content box
const markerRect = caretMarker.getBoundingClientRect();
const divRect = div.getBoundingClientRect();
// Calculate position relative to the input's top-left, considering scroll
const cursorLeft = markerRect.left - divRect.left;
const cursorTop = markerRect.top - divRect.top - input.scrollTop;
// Set cursor position
cursor.style.left = `${cursorLeft}px`;
cursor.style.top = `${cursorTop}px`;
// Clean up the temporary div
document.body.removeChild(div); } catch (error) {
console.error('Error positioning cursor:', error);
}
};
// Update on various events
inputElement.addEventListener('input', updatePosition);
inputElement.addEventListener('click', updatePosition);
inputElement.addEventListener('keyup', updatePosition);
inputElement.addEventListener('focus', updatePosition);
// Initial position update
updatePosition();
}
}
// Create the singleton instance
const uiInputHandler = new UIInputHandlerModule();
// Export the module
export { uiInputHandler as UIInputHandler };