97f0b913be
Two regressions made the cursor stop communicating game state: - The canvas had a hardcoded `cursor: grab`, overriding the document-level process-state cursor everywhere over the 3D scene (always a hand). Removed it so the canvas inherits the state cursor; grab is now shown only transiently while right-drag-rotating the camera. - normalizeProcessState pinned ready/waiting-generating to the playing (feather) cursor whenever playbackCoordinator.isPlaying was set, which lingered at choice prompts — so an open choice showed the feather instead of the input cursor. Now, when an input prompt is open AND no sentence is actively playing (timeline's webglBookPlaybackActive), the playback overlay is stripped (playing-ready->ready, playing-generating->waiting-generating) and the input/server cursor shows. Opening an input mode also refreshes the cursor immediately. Verified live over the canvas: feather while a sentence plays, input arrow at a choice/idle, and they switch correctly with playback state (no stuck feather, no constant grab). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
746 lines
29 KiB
JavaScript
746 lines
29 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);
|
|
// The player is in control when an input prompt is open AND the book is not actively
|
|
// playing a sentence (the timeline owns webglBookPlaybackActive). Then the cursor must
|
|
// show the input/server state, never the playback feather — even if a stale playing-*
|
|
// state lingers — so strip the playback overlay. While a sentence is actually playing
|
|
// the feather wins, even if an input mode is still set from the previous turn.
|
|
const playbackActive = document.documentElement.dataset.webglBookPlaybackActive === 'true';
|
|
const awaitingPlayer = !playbackActive && ['choice', 'text', 'end'].includes(this.inputMode);
|
|
if (awaitingPlayer) {
|
|
if (state === 'playing-ready') return 'ready';
|
|
if (state === 'playing-generating') return 'waiting-generating';
|
|
return state;
|
|
}
|
|
|
|
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');
|
|
// Opening an input-awaiting prompt hands control to the player; reflect that in the
|
|
// cursor immediately instead of leaving the prior playback state showing (the live
|
|
// flow does not always dispatch a fresh process-state when the prompt appears).
|
|
if (this.inputMode !== 'none') {
|
|
this.setProcessState('ready', { reason: `input-mode:${this.inputMode}` });
|
|
}
|
|
}
|
|
|
|
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 };
|