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 = true; this.historyIndex = -1; this.commandHistory = []; this.inputBuffer = ''; this.inputMode = 'text'; // 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', '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.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.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); } // 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.setProcessState('ready', { reason: 'input-initialized' }); this.focusInput(); requestAnimationFrame(() => this.focusInput()); setTimeout(() => this.focusInput(), 250); } 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', 'command-waiting', 'waiting-generating', 'playing-generating', 'playing-ready' ]; const nextState = knownStates.includes(state) ? state : 'ready'; 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(); } } } 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'].includes(mode) ? mode : 'text'; this.setInputAvailability(this.inputMode === 'text'); } 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); } else { root.style.removeProperty('--process-cursor'); } } 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}`; } getMouseCursorSvg(state) { 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 icons = { '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 };