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

400 lines
14 KiB
JavaScript

import { BaseModule } from './base-module.js';
import { moduleRegistry } from './module-registry.js';
class UIInputHandler extends BaseModule {
constructor() {
super('ui-input-handler', 'UI Input Handler');
// Explicitly declare ui-display-handler as a dependency
this.dependencies = ['ui-display-handler'];
// 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 = '';
// Bind methods using the parent class bindMethods utility
this.bindMethods([
'setupInputElements',
'handlePlayerInput',
'handleInputKeyDown',
'positionCursor',
'handleKeyboardInput',
'submitCommand',
'addToHistory',
'resetCursorPosition'
]);
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.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) {
// Handle global keyboard shortcuts here
// This is different from the input field's specific key handling
// For example: Escape key to blur the input
if (event.key === 'Escape') {
if (document.activeElement === this.playerInput) {
this.playerInput.blur();
}
}
}
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?';
playerInput.setAttribute('autocomplete', 'off');
playerInput.setAttribute('spellcheck', 'true');
// 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;
}
// 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);
// Focus the input to let user start typing immediately
setTimeout(() => {
playerInput.focus();
}, 100);
}
console.log('UIInputHandler: Input elements setup complete');
}
/**
* 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;
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();
}
/**
* 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.textContent = `> ${command}`;
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;
}
}
/**
* 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 UIInputHandler();
// Register with the module registry
moduleRegistry.register(uiInputHandler);
// Export the module
export { uiInputHandler as UIInputHandler };
// Keep a reference in window for loader system
console.log('UIInputHandler: Registering with window');
window.UIInputHandler = uiInputHandler;