Files
ai.interactive.fiction/public/js/ui-input-handler-module.js
Georg 97f0b913be Cursor reflects game state over the 3D scene again
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>
2026-06-19 10:00:49 +02:00

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 = `&gt; ${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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
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 = '&nbsp;'; // 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 };