728 lines
28 KiB
JavaScript
728 lines
28 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 = false;
|
|
this.historyIndex = -1;
|
|
this.commandHistory = [];
|
|
this.inputBuffer = '';
|
|
this.inputMode = 'none';
|
|
this.cursorAnimationTimer = null;
|
|
this.cursorAnimationFrame = 0;
|
|
|
|
// 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',
|
|
'setInputModeDataset',
|
|
'installMouseCursors',
|
|
'startMouseCursorAnimation',
|
|
'stopMouseCursorAnimation',
|
|
'normalizeProcessState',
|
|
'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.installMouseCursors();
|
|
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 || 'none');
|
|
});
|
|
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);
|
|
}
|
|
|
|
if (!document.getElementById('left_control_separator')) {
|
|
const controlSeparator = document.createElement('div');
|
|
controlSeparator.id = 'left_control_separator';
|
|
choicesContainer.insertBefore(controlSeparator, choicesContainer.firstChild);
|
|
}
|
|
|
|
// 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.setInputModeDataset();
|
|
this.setInputAvailability(false);
|
|
}
|
|
|
|
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',
|
|
'paused',
|
|
'command-waiting',
|
|
'waiting-generating',
|
|
'playing-generating',
|
|
'playing-ready'
|
|
];
|
|
const requestedState = knownStates.includes(state) ? state : 'ready';
|
|
const nextState = this.normalizeProcessState(requestedState);
|
|
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
|
|
normalizeProcessState(state) {
|
|
const playbackCoordinator = this.getModule('playback-coordinator');
|
|
const isPlaying = Boolean(playbackCoordinator?.isPlaying);
|
|
|
|
if (isPlaying && state === 'ready') {
|
|
return 'playing-ready';
|
|
}
|
|
|
|
if (isPlaying && state === 'waiting-generating') {
|
|
return 'playing-generating';
|
|
}
|
|
|
|
return state;
|
|
}
|
|
|
|
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', 'none'].includes(mode) ? mode : 'none';
|
|
this.setInputModeDataset();
|
|
const state = document.documentElement.dataset.processState || 'loading';
|
|
this.setInputAvailability(this.inputMode === 'text' && state === 'ready');
|
|
}
|
|
|
|
setInputModeDataset() {
|
|
document.documentElement.dataset.inputMode = this.inputMode || 'none';
|
|
}
|
|
|
|
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);
|
|
this.startMouseCursorAnimation(state);
|
|
} else {
|
|
root.style.removeProperty('--process-cursor');
|
|
this.stopMouseCursorAnimation();
|
|
}
|
|
}
|
|
|
|
installMouseCursors() {
|
|
const root = document.documentElement;
|
|
if (!root) return;
|
|
root.style.setProperty('--default-cursor', this.buildMouseCursor('default', 'default', 4, 3));
|
|
root.style.setProperty('--pointer-cursor', this.buildMouseCursor('pointer', 'pointer', 7, 2));
|
|
}
|
|
|
|
getMouseCursor(state) {
|
|
if (state === 'ready' || state === 'paused') {
|
|
return '';
|
|
}
|
|
|
|
if (state === 'command-waiting' || state === 'waiting-generating') {
|
|
return this.buildMouseCursor(state, 'progress', 16, 16, this.cursorAnimationFrame);
|
|
}
|
|
|
|
return this.buildMouseCursor(state, 'default', 5, 24, this.cursorAnimationFrame);
|
|
}
|
|
|
|
buildMouseCursor(state, fallback = 'default', hotspotX = 12, hotspotY = 12, frame = 0) {
|
|
const svg = this.getMouseCursorSvg(state, frame);
|
|
return `url("${this.toCursorDataUrl(svg)}") ${hotspotX} ${hotspotY}, ${fallback}`;
|
|
}
|
|
|
|
startMouseCursorAnimation(state) {
|
|
const animatedStates = new Set(['command-waiting', 'waiting-generating', 'playing-generating']);
|
|
if (!animatedStates.has(state)) {
|
|
this.stopMouseCursorAnimation();
|
|
return;
|
|
}
|
|
if (this.cursorAnimationTimer) return;
|
|
this.cursorAnimationTimer = window.setInterval(() => {
|
|
const currentState = document.documentElement.dataset.processState || 'ready';
|
|
if (!animatedStates.has(currentState)) {
|
|
this.stopMouseCursorAnimation();
|
|
return;
|
|
}
|
|
this.cursorAnimationFrame = (this.cursorAnimationFrame + 1) % 8;
|
|
document.documentElement.style.setProperty('--process-cursor', this.getMouseCursor(currentState));
|
|
}, 160);
|
|
}
|
|
|
|
stopMouseCursorAnimation() {
|
|
if (this.cursorAnimationTimer) {
|
|
window.clearInterval(this.cursorAnimationTimer);
|
|
this.cursorAnimationTimer = null;
|
|
}
|
|
this.cursorAnimationFrame = 0;
|
|
}
|
|
|
|
getMouseCursorSvg(state, frame = 0) {
|
|
const stroke = '#2a1b10';
|
|
const common = `xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none" stroke="${stroke}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"`;
|
|
const makeSpinner = (centerX, centerY, innerRadius, outerRadius, strokeWidth = 1.8) => Array.from({ length: 8 }, (_, index) => {
|
|
const opacity = 0.25 + (((index + frame) % 8) / 7) * 0.75;
|
|
const angle = (index * 45) * Math.PI / 180;
|
|
const x1 = centerX + Math.cos(angle) * innerRadius;
|
|
const y1 = centerY + Math.sin(angle) * innerRadius;
|
|
const x2 = centerX + Math.cos(angle) * outerRadius;
|
|
const y2 = centerY + Math.sin(angle) * outerRadius;
|
|
return `<path opacity="${opacity.toFixed(2)}" stroke-width="${strokeWidth}" d="M${x1.toFixed(2)} ${y1.toFixed(2)} ${x2.toFixed(2)} ${y2.toFixed(2)}"/>`;
|
|
}).join('');
|
|
const spinnerSpokes = makeSpinner(24, 24, 3, 5);
|
|
const largeSpinnerSpokes = makeSpinner(16, 16, 5.2, 10.2, 2.2);
|
|
const arrow = '<path fill="#f6efe2" d="M4 3l9 21 2.4-8.5L24 13z"/><path d="M15.4 15.5 21 21"/>';
|
|
const pointer = '<path fill="#f6efe2" d="M9 14.8V5a2.2 2.2 0 0 1 4.4 0v6.8V8.5a2.1 2.1 0 0 1 4.2 0V13v-.7a2.1 2.1 0 0 1 4.2 0v4.2A8.1 8.1 0 0 1 13.7 24h-1.2a6.8 6.8 0 0 1-5.5-3L3.6 15a2.1 2.1 0 0 1 3.4-2.4L9 14.8z"/><path d="M9 14.8V5a2.2 2.2 0 0 1 4.4 0v7.5"/><path d="M13.4 12V8.5a2.1 2.1 0 0 1 4.2 0v5"/><path d="M17.6 14v-1.7a2.1 2.1 0 0 1 4.2 0v4.2A8.1 8.1 0 0 1 13.7 24h-1.2a6.8 6.8 0 0 1-5.5-3L3.6 15a2.1 2.1 0 0 1 3.4-2.4l2 2.5"/>';
|
|
const feather = '<path fill="#f6efe2" d="M5 26c5.8-1.7 12.5-7.9 18.4-20.7 2.3 7.6-.2 16.1-11.8 19.9"/><path d="M5 26c4.8-4.5 8.7-9.2 13-15"/><path d="M12 25.2 5 26"/>';
|
|
const speaker = '<g transform="translate(20 2) scale(.48)"><path fill="#f6efe2" d="M11 5 6 9H2v6h4l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M19 5a10 10 0 0 1 0 14"/></g>';
|
|
const spinner = `<g>${spinnerSpokes}</g>`;
|
|
const largeSpinner = `<g>${largeSpinnerSpokes}</g>`;
|
|
const hourglassSand = frame % 4 < 2 ? '<path d="M5.7 4.7h4.6"/><path d="M6.7 7h2.6"/>' : '<path d="M6.7 12h2.6"/><path d="M5.7 14.3h4.6"/>';
|
|
const hourglass = `<g transform="translate(1 17) scale(.82)"><path fill="#f6efe2" d="M4 2h8M4 16h8M10.8 2v3.1a2 2 0 0 1-.6 1.4L8 8.5l-2.2-2A2 2 0 0 1 5.2 5.1V2M5.2 16v-3.1a2 2 0 0 1 .6-1.4L8 9.5l2.2 2a2 2 0 0 1 .6 1.4V16"/>${hourglassSand}</g>`;
|
|
const icons = {
|
|
'default': `<svg ${common}>${arrow}</svg>`,
|
|
'pointer': `<svg ${common}>${pointer}</svg>`,
|
|
'command-waiting': `<svg ${common}>${largeSpinner}${hourglass}</svg>`,
|
|
'waiting-generating': `<svg ${common}>${largeSpinner}</svg>`,
|
|
'playing-generating': `<svg ${common}>${feather}${speaker}${spinner}</svg>`,
|
|
'playing-ready': `<svg ${common}>${feather}${speaker}</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 };
|