From 89b8cf83119ea1b5faf147127e1032f8933a47b1 Mon Sep 17 00:00:00 2001 From: Georg Tomitsch Date: Tue, 1 Apr 2025 11:54:55 +0000 Subject: [PATCH] Improved cursor and multiline input field. --- public/css/style.css | 36 +++++-- public/index.html | 2 +- public/js/ai-fiction.js | 225 ++++++++++++++++++++++++++++++++-------- 3 files changed, 214 insertions(+), 49 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index 655c937..ad8d60c 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -259,6 +259,7 @@ cap { transition: opacity 0.5s; } + /* Class applied to all choices (Will always appear inside

element by default.) @@ -579,6 +580,13 @@ ol.choice { width: 100%; position: relative; margin-top: 10px; + transition: opacity 0.5s ease; /* Add transition for fading effect */ +} + +/* Fade out command input when loading */ +#command_input.fading { + opacity: 0.3; + pointer-events: none; /* Prevent interaction while faded out */ } /* Input wrapper for positioning cursor */ @@ -586,9 +594,12 @@ ol.choice { position: relative; width: 100%; display: inline-block; + min-height: 1.5em; /* Minimum height for one line */ + max-height: 6em; /* Maximum height - about 4 lines */ + overflow: visible; /* Changed from 'auto' to 'visible' to hide scrollbars when not needed */ } -/* Player input styling */ +/* Player input styling - now a textarea for multiline support */ #player_input { width: 100%; background: transparent; @@ -601,10 +612,11 @@ ol.choice { padding: 5px 0; caret-color: transparent; /* Hide the default caret */ box-sizing: border-box; -} - -#player_input:focus { - border-bottom-color: #333; + resize: none; /* Disable manual resizing */ + overflow: hidden; /* Hide all scrollbars */ + line-height: 1.2; /* Match paragraph line height */ + height: auto; /* Allow height to adjust */ + min-height: 1.5em; /* Ensure minimum height */ } /* Custom cursor styling */ @@ -618,12 +630,14 @@ ol.choice { left: 0; animation: blink 1s step-end infinite; pointer-events: none; /* Allow clicks to pass through to the input */ + z-index: 1; /* Ensure cursor appears above text */ } -/* Placeholder styling - lighter and italic */ +/* Placeholder styling - lighter and italic, with padding to avoid cursor overlap */ #player_input::placeholder { color: #aaa; font-style: italic; + padding-left: 15px; /* Add padding to move placeholder text to the right */ } /* Blinking animation */ @@ -631,3 +645,13 @@ ol.choice { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } + +/* Fade-in animation for input area */ +@keyframes fadeInInput { + from { opacity: 0.3; } + to { opacity: 1; } +} + +.fade-in-input { + animation: fadeInInput 0.5s ease forwards; +} diff --git a/public/index.html b/public/index.html index 4a1c138..cfac093 100644 --- a/public/index.html +++ b/public/index.html @@ -34,7 +34,7 @@

- +
diff --git a/public/js/ai-fiction.js b/public/js/ai-fiction.js index 28a00af..3b02605 100644 --- a/public/js/ai-fiction.js +++ b/public/js/ai-fiction.js @@ -107,23 +107,159 @@ class AIFiction { }); } + /** + * Helper function to get precise caret coordinates in a textarea + * @param {HTMLTextAreaElement} element - The textarea element + * @param {number} position - The caret position + * @return {Object} Object with top and left coordinates + */ + getCaretCoordinates(element, position) { + // Create a range to represent the caret + const range = document.createRange(); + const textNode = document.createTextNode(element.value.substring(0, position)); + const span = document.createElement('span'); + span.appendChild(textNode); + + // Create a temporary div + const div = document.createElement('div'); + div.style.position = 'absolute'; + div.style.top = '-9999px'; + div.style.left = '-9999px'; + div.style.width = element.offsetWidth + 'px'; + div.style.whiteSpace = 'pre-wrap'; + div.style.wordWrap = 'break-word'; + div.style.fontFamily = window.getComputedStyle(element).fontFamily; + div.style.fontSize = window.getComputedStyle(element).fontSize; + div.style.lineHeight = window.getComputedStyle(element).lineHeight; + div.style.padding = window.getComputedStyle(element).padding; + + // Append everything to the DOM + div.appendChild(span); + document.body.appendChild(div); + + // Measure the position + const coordinates = { + top: span.offsetTop, + left: span.offsetWidth + }; + + // Clean up + document.body.removeChild(div); + + return coordinates; + } + + /** + * Update the custom cursor position based on input text and caret position + * Enhanced version with simpler, more reliable positioning + */ + updateCursorPosition() { + const input = this.playerInput; + const cursor = document.getElementById('cursor'); + + if (!cursor || !input) return; + + // Get the current caret position + const caretPosition = input.selectionStart || 0; + const inputText = input.value; + + if (inputText.length === 0) { + // If no text, position cursor at the beginning (placeholder visible) + cursor.style.left = '0px'; + cursor.style.top = '6px'; // Default top position + return; + } + + // Auto-adjust textarea height based on content + this.adjustTextareaHeight(); + + // Use a more reliable method to get cursor position: + // Create a temporary element that exactly duplicates the input's content and styling + const div = document.createElement('div'); + div.style.position = 'absolute'; + div.style.top = '-9999px'; + div.style.left = '-9999px'; + div.style.width = getComputedStyle(input).width; + div.style.height = 'auto'; + div.style.padding = getComputedStyle(input).padding; + div.style.border = getComputedStyle(input).border; + div.style.fontFamily = getComputedStyle(input).fontFamily; + div.style.fontSize = getComputedStyle(input).fontSize; + div.style.fontWeight = getComputedStyle(input).fontWeight; + div.style.lineHeight = getComputedStyle(input).lineHeight; + div.style.whiteSpace = 'pre-wrap'; + div.style.wordWrap = 'break-word'; + div.style.boxSizing = getComputedStyle(input).boxSizing; + + // Create three spans to help us position the cursor + const preCaretText = document.createElement('span'); + preCaretText.textContent = inputText.substring(0, caretPosition); + + const caretChar = document.createElement('span'); + caretChar.textContent = '|'; // Visible cursor marker for measurement + caretChar.style.display = 'inline-block'; + caretChar.style.width = '0'; + + const postCaretText = document.createElement('span'); + postCaretText.textContent = inputText.substring(caretPosition); + + // Add all elements to the DOM + div.appendChild(preCaretText); + div.appendChild(caretChar); + div.appendChild(postCaretText); + document.body.appendChild(div); + + // Get position of the caret marker + const caretRect = caretChar.getBoundingClientRect(); + const inputRect = input.getBoundingClientRect(); + + // Set cursor position + // We need to account for the input's scroll position + cursor.style.left = (caretRect.left - div.getBoundingClientRect().left) + 'px'; + cursor.style.top = (caretRect.top - div.getBoundingClientRect().top - input.scrollTop) + 'px'; + + // Clean up + document.body.removeChild(div); + } + + /** + * Adjust textarea height based on its content + */ + adjustTextareaHeight() { + const textarea = this.playerInput; + if (!textarea) return; + + // Reset height to auto to get the correct scrollHeight + textarea.style.height = 'auto'; + + // Set height to scrollHeight to fit content + textarea.style.height = textarea.scrollHeight + 'px'; + } + /** * Bind event handlers to DOM elements */ bindEvents() { - // Submit command on Enter key + // Submit command on Enter key without Shift this.playerInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); // Prevent default to avoid newline this.submitCommand(); + } else if (e.key === 'Enter' && e.shiftKey) { + // Allow Shift+Enter to create a new line + // Default behavior happens, no need to do anything } }); - // Handle cursor position based on input changes and caret position - this.playerInput.addEventListener('input', this.updateCursorPosition.bind(this)); + // Auto-resize textarea on input + this.playerInput.addEventListener('input', () => { + this.adjustTextareaHeight(); + this.updateCursorPosition(); + }); + + // Update cursor on various events this.playerInput.addEventListener('click', this.updateCursorPosition.bind(this)); this.playerInput.addEventListener('keyup', this.updateCursorPosition.bind(this)); - - // Handle cursor visibility based on input focus this.playerInput.addEventListener('focus', () => { document.getElementById('cursor').style.opacity = '1'; this.updateCursorPosition(); @@ -133,6 +269,21 @@ class AIFiction { document.getElementById('cursor').style.opacity = '0'; }); + // Handle paste events + this.playerInput.addEventListener('paste', () => { + // Use setTimeout to let the paste complete before adjusting + setTimeout(() => { + this.adjustTextareaHeight(); + this.updateCursorPosition(); + }, 10); + }); + + // Handle window resize + window.addEventListener('resize', () => { + this.adjustTextareaHeight(); + this.updateCursorPosition(); + }); + // Toggle speech this.speechButton.addEventListener('click', () => { // Check if the handler is available (it should be if button is enabled) @@ -305,6 +456,10 @@ class AIFiction { if (command === '') return; + // Fade out the input field + const commandInput = document.getElementById('command_input'); + commandInput.classList.add('fading'); + // Disable input temporarily this.playerInput.disabled = true; @@ -324,11 +479,25 @@ class AIFiction { const cursor = document.getElementById('cursor'); if (cursor) { cursor.style.left = '0px'; + cursor.style.top = '6px'; } + // Reset textarea height + this.adjustTextareaHeight(); + // Re-enable input field after a short delay (or after 8 seconds as failsafe) const timeout = setTimeout(() => { + // Remove fading class and add fade-in animation + commandInput.classList.remove('fading'); + commandInput.classList.add('fade-in-input'); + + // Remove animation class after it completes + setTimeout(() => { + commandInput.classList.remove('fade-in-input'); + }, 500); + this.playerInput.disabled = false; + this.playerInput.focus(); // Remove thinking indicator const thinkingElement = document.getElementById(thinkingId); @@ -516,49 +685,21 @@ class AIFiction { } } - /** - * Update the custom cursor position based on input text and caret position - */ - updateCursorPosition() { - const input = this.playerInput; - const cursor = document.getElementById('cursor'); - - if (!cursor) return; - - // Get the current caret position - const caretPosition = input.selectionStart || 0; - const inputText = input.value; - - if (inputText.length === 0) { - // If no text, position cursor at the beginning (placeholder visible) - cursor.style.left = '0px'; - return; - } - - // Create a temporary span to measure text width - const tempSpan = document.createElement('span'); - tempSpan.style.font = window.getComputedStyle(input).font; - tempSpan.style.position = 'absolute'; - tempSpan.style.visibility = 'hidden'; - tempSpan.style.whiteSpace = 'pre'; - tempSpan.textContent = inputText.substring(0, caretPosition); - document.body.appendChild(tempSpan); - - // Set cursor position based on the width of text before caret - const textWidth = tempSpan.getBoundingClientRect().width; - cursor.style.left = `${textWidth}px`; - - // Clean up - document.body.removeChild(tempSpan); - } - /** * Set up focus management to ensure input field is always focused */ setupFocusManagement() { // Focus input field when the page loads window.addEventListener('load', () => { + // Force immediate focus on load this.playerInput.focus(); + + // Some browsers might need a slight delay + setTimeout(() => this.playerInput.focus(), 100); + + // Also adjust textarea height and update cursor position + this.adjustTextareaHeight(); + this.updateCursorPosition(); }); // Focus input when user clicks anywhere in the document