630 lines
23 KiB
JavaScript
630 lines
23 KiB
JavaScript
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': `<svg ${common}><path d="M5 22h14"/><path d="M5 2h14"/><path d="M17 22v-4.172a2 2 0 0 0-.586-1.414L12 12l-4.414 4.414A2 2 0 0 0 7 17.828V22"/><path d="M7 2v4.172a2 2 0 0 0 .586 1.414L12 12l4.414-4.414A2 2 0 0 0 17 6.172V2"/></svg>`,
|
|
'waiting-generating': `<svg ${common}><path d="M12 2v4"/><path d="M12 18v4"/><path d="m4.93 4.93 2.83 2.83"/><path d="m16.24 16.24 2.83 2.83"/><path d="M2 12h4"/><path d="M18 12h4"/><path d="m4.93 19.07 2.83-2.83"/><path d="m16.24 7.76 2.83-2.83"/></svg>`,
|
|
'playing-generating': `<svg ${common}><path d="M11 5 6 9H2v6h4l5 4z"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M12 2v3"/><path d="M12 19v3"/></svg>`,
|
|
'playing-ready': `<svg ${common}><path d="M11 5 6 9H2v6h4l5 4z"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>`
|
|
};
|
|
|
|
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, '<')
|
|
.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 };
|