Files
ai.interactive.fiction/public/js/input-handler.js
T

291 lines
9.0 KiB
JavaScript

/**
* 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();
}
});
*/
}
}