/** * Input Handler Module * Manages the multi-line text input field with a custom cursor. */ export class InputHandler { constructor(inputId = 'player_input', cursorId = 'cursor') { this.playerInput = document.getElementById(inputId); this.cursor = document.getElementById(cursorId); this.commandInputContainer = document.getElementById('command_input'); // Assuming this container exists if (!this.playerInput || !this.cursor || !this.commandInputContainer) { console.error('InputHandler: Required DOM elements not found.'); return; } this.commandSubmitCallback = null; // Callback for when a command is submitted this.bindEvents(); this.adjustTextareaHeight(); // Initial adjustment this.updateCursorPosition(); // Initial position // Setup handler for window load event to ensure proper initialization window.addEventListener('load', () => { console.log('InputHandler: Window loaded, adjusting text area height and cursor position'); this.adjustTextareaHeight(); this.updateCursorPosition(); }); } /** * Register a callback function to be called when a command is submitted. * @param {function(string)} callback - The function to call with the command text. */ onCommandSubmit(callback) { this.commandSubmitCallback = callback; } /** * Bind event handlers to the input element. */ bindEvents() { // Submit command on Enter key without Shift this.playerInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); // Prevent default to avoid newline this.submitCommand(); } // Allow Shift+Enter for new lines (default behavior) }); // Auto-resize textarea and update cursor on input this.playerInput.addEventListener('input', () => { this.adjustTextareaHeight(); this.updateCursorPosition(); }); // Update cursor on various events this.playerInput.addEventListener('click', this.updateCursorPosition.bind(this)); this.playerInput.addEventListener('keyup', this.updateCursorPosition.bind(this)); // Show/hide cursor on focus/blur this.playerInput.addEventListener('focus', () => { if (this.cursor) this.cursor.style.opacity = '1'; this.updateCursorPosition(); }); this.playerInput.addEventListener('blur', () => { if (this.cursor) this.cursor.style.opacity = '0'; }); // Handle paste events this.playerInput.addEventListener('paste', () => { // Use setTimeout to let the paste complete before adjusting setTimeout(() => { this.adjustTextareaHeight(); this.updateCursorPosition(); }, 10); }); // Handle window resize window.addEventListener('resize', () => { this.adjustTextareaHeight(); this.updateCursorPosition(); }); } /** * Submit the current command. */ submitCommand() { const command = this.playerInput.value.trim(); if (command === '' || !this.commandSubmitCallback) return; // Fade out the input field container if (this.commandInputContainer) { this.commandInputContainer.classList.add('fading'); } // Disable input temporarily this.playerInput.disabled = true; // Call the registered callback this.commandSubmitCallback(command); // Clear input this.clearInput(); } /** * Clears the input field and resets its state. */ clearInput() { this.playerInput.value = ''; this.resetCursorPosition(); this.adjustTextareaHeight(); } /** * Re-enables the input field after a command submission or response. */ enableInput() { if (this.commandInputContainer) { // Remove fading class and add fade-in animation this.commandInputContainer.classList.remove('fading'); this.commandInputContainer.classList.add('fade-in-input'); // Remove animation class after it completes setTimeout(() => { if (this.commandInputContainer) { this.commandInputContainer.classList.remove('fade-in-input'); } }, 500); // Match CSS animation duration } this.playerInput.disabled = false; this.focus(); } /** * Focuses the input field. */ focus() { this.playerInput.focus(); // Ensure cursor is visible and positioned correctly after focus setTimeout(() => { if (this.cursor) this.cursor.style.opacity = '1'; this.updateCursorPosition(); }, 10); } /** * Gets the current value of the input field. * @returns {string} The input text. */ getValue() { return this.playerInput.value; } /** * Sets the value of the input field. * @param {string} value - The text to set. */ setValue(value) { this.playerInput.value = value; this.adjustTextareaHeight(); this.updateCursorPosition(); this.focus(); // Focus after setting value } /** * 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`; } } /** * Update the custom cursor position based on input text and caret position. * Uses a temporary div for accurate measurement. */ updateCursorPosition() { if (!this.cursor || !this.playerInput) return; 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); } /** * Adjust textarea height based on its content. */ adjustTextareaHeight() { if (!this.playerInput) return; const textarea = this.playerInput; // Temporarily reset height to accurately measure scrollHeight textarea.style.height = 'auto'; // Set height to scrollHeight to fit content, adding a small buffer if needed textarea.style.height = `${textarea.scrollHeight}px`; } /** * Sets up focus management to keep the input field focused. * Note: Some parts might be better handled by the main application logic * depending on overall focus requirements (e.g., clicking outside input). */ setupFocusManagement() { // Focus input field when the handler is initialized this.focus(); // Re-focus input when user returns to this browser tab/window window.addEventListener('focus', () => this.focus()); window.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { setTimeout(() => this.focus(), 100); } }); // Optional: Add a listener to the document to refocus if needed, // but be careful not to interfere with other interactive elements. /* document.addEventListener('click', (e) => { // Example: Refocus if click is not on specific elements if (!e.target.closest('button, a, .interactive-ui-element')) { this.focus(); } }); */ } }