From ca0cda3ecf5bd449f9c371cf5102c11d880c9822 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Mon, 31 Mar 2025 18:13:55 +0200 Subject: [PATCH] fix(frontend): use custom caret position function (#5544) * use custom caret position function * add try catch to not show the tooltip if any error occured * log error --- frontend/package-lock.json | 7 - frontend/package.json | 1 - .../copilot/chat/ContextTextarea.svelte | 175 +++++++++++++++--- 3 files changed, 147 insertions(+), 36 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 64124a9a65..e8a77bc11a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -62,7 +62,6 @@ "svelte-infinite-loading": "^1.4.0", "svelte-tiny-virtual-list": "^2.0.5", "tailwind-merge": "^1.13.2", - "textarea-caret": "^3.1.0", "vscode": "npm:@codingame/monaco-vscode-api@~11.1.2", "vscode-languageclient": "~9.0.1", "vscode-uri": "~3.0.8", @@ -11882,12 +11881,6 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, - "node_modules/textarea-caret": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/textarea-caret/-/textarea-caret-3.1.0.tgz", - "integrity": "sha512-cXAvzO9pP5CGa6NKx0WYHl+8CHKZs8byMkt3PCJBCmq2a34YA9pO1NrQET5pzeqnBjBdToF5No4rrmkDUgQC2Q==", - "license": "MIT" - }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index ab203c2b47..2580cd35f0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -138,7 +138,6 @@ "svelte-infinite-loading": "^1.4.0", "svelte-tiny-virtual-list": "^2.0.5", "tailwind-merge": "^1.13.2", - "textarea-caret": "^3.1.0", "vscode": "npm:@codingame/monaco-vscode-api@~11.1.2", "vscode-languageclient": "~9.0.1", "vscode-uri": "~3.0.8", diff --git a/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte b/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte index 01ca3bd56b..605b67628f 100644 --- a/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte +++ b/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte @@ -3,7 +3,6 @@ import { createEventDispatcher } from 'svelte' import type { ContextElement } from './core' import AvailableContextList from './AvailableContextList.svelte' - import getCaretCoordinates from 'textarea-caret' export let instructions: string export let availableContext: ContextElement[] @@ -22,6 +21,120 @@ let textarea: HTMLTextAreaElement let selectedSuggestionIndex = 0 + // Properties to copy for caret position calculation + const properties = [ + 'direction', + 'boxSizing', + 'width', + 'height', + 'overflowX', + 'overflowY', + 'borderTopWidth', + 'borderRightWidth', + 'borderBottomWidth', + 'borderLeftWidth', + 'borderStyle', + 'paddingTop', + 'paddingRight', + 'paddingBottom', + 'paddingLeft', + 'fontStyle', + 'fontVariant', + 'fontWeight', + 'fontStretch', + 'fontSize', + 'fontSizeAdjust', + 'lineHeight', + 'fontFamily', + 'textAlign', + 'textTransform', + 'textIndent', + 'textDecoration', + 'letterSpacing', + 'wordSpacing', + 'tabSize', + 'MozTabSize' + ] + + function getCaretCoordinates(element: HTMLTextAreaElement, position: number) { + // Create mirror div + const div = document.createElement('div') + div.id = 'input-textarea-caret-position-mirror-div' + document.body.appendChild(div) + + // Set styles + const style = div.style + const computed = window.getComputedStyle(element) + const isInput = element.nodeName === 'INPUT' + + // Default textarea styles + style.whiteSpace = 'pre-wrap' + if (!isInput) style.wordWrap = 'break-word' + + // Position off-screen + style.position = 'absolute' + style.visibility = 'hidden' + + // Transfer properties + properties.forEach(function (prop) { + if (isInput && prop === 'lineHeight') { + // Special case for inputs + if (computed.boxSizing === 'border-box') { + const height = parseInt(computed.height) + const outerHeight = + parseInt(computed.paddingTop) + + parseInt(computed.paddingBottom) + + parseInt(computed.borderTopWidth) + + parseInt(computed.borderBottomWidth) + const targetHeight = outerHeight + parseInt(computed.lineHeight) + if (height > targetHeight) { + style.lineHeight = height - outerHeight + 'px' + } else if (height === targetHeight) { + style.lineHeight = computed.lineHeight + } else { + style.lineHeight = '0' + } + } else { + style.lineHeight = computed.height + } + } else { + style[prop] = computed[prop] + } + }) + + // Firefox special handling + const isFirefox = + (window as typeof window & { mozInnerScreenX: number }).mozInnerScreenX != null + if (isFirefox) { + if (element.scrollHeight > parseInt(computed.height)) style.overflowY = 'scroll' + } else { + style.overflow = 'hidden' + } + + // Add content before caret + div.textContent = element.value.substring(0, position) + + // Replace spaces with non-breaking spaces for input elements + if (isInput) div.textContent = div.textContent.replace(/\s/g, '\u00a0') + + // Create span for position calculation + const span = document.createElement('span') + span.textContent = element.value.substring(position) || '.' + div.appendChild(span) + + // Get coordinates + const coordinates = { + top: span.offsetTop + parseInt(computed['borderTopWidth']), + left: span.offsetLeft + parseInt(computed['borderLeftWidth']), + height: parseInt(computed['lineHeight']) + } + + // Cleanup + document.body.removeChild(div) + + return coordinates + } + function getHighlightedText(text: string) { return text.replace(/@[\w/.-]+/g, (match) => { const contextElement = availableContext.find((c) => c.title === match.slice(1)) @@ -57,40 +170,46 @@ ) { if (!textarea || !showContextTooltip) return - const coords = getCaretCoordinates(textarea, textarea.selectionEnd) - const rect = textarea.getBoundingClientRect() + try { + const coords = getCaretCoordinates(textarea, textarea.selectionEnd) + const rect = textarea.getBoundingClientRect() - const filteredAvailableContext = availableContext.filter( - (c) => !contextTooltipWord || c.title.toLowerCase().includes(contextTooltipWord.slice(1)) - ) + const filteredAvailableContext = availableContext.filter( + (c) => !contextTooltipWord || c.title.toLowerCase().includes(contextTooltipWord.slice(1)) + ) - const itemHeight = 28 // Estimated height of one item + gap (Button: p-1(8px) + text-xs(16px) = 24px; Parent: gap-1(4px) = 28px) - const containerPadding = 8 // p-1 top + p-1 bottom = 4px + 4px = 8px - const maxHeight = 192 + containerPadding // max-h-48 (192px) + containerPadding (8px) + const itemHeight = 28 // Estimated height of one item + gap (Button: p-1(8px) + text-xs(16px) = 24px; Parent: gap-1(4px) = 28px) + const containerPadding = 8 // p-1 top + p-1 bottom = 4px + 4px = 8px + const maxHeight = 192 + containerPadding // max-h-48 (192px) + containerPadding (8px) - // Calculate uncapped height, subtract gap from last item as it's not needed - const numItems = filteredAvailableContext.length - let uncappedHeight = - numItems > 0 ? numItems * itemHeight - 4 + containerPadding : containerPadding - // Ensure height is at least containerPadding even if no items - uncappedHeight = Math.max(uncappedHeight, containerPadding) + // Calculate uncapped height, subtract gap from last item as it's not needed + const numItems = filteredAvailableContext.length + let uncappedHeight = + numItems > 0 ? numItems * itemHeight - 4 + containerPadding : containerPadding + // Ensure height is at least containerPadding even if no items + uncappedHeight = Math.max(uncappedHeight, containerPadding) - const estimatedTooltipHeight = Math.min(uncappedHeight, maxHeight) - const margin = 6 // Small margin between caret and tooltip + const estimatedTooltipHeight = Math.min(uncappedHeight, maxHeight) + const margin = 6 // Small margin between caret and tooltip - let finalY: number + let finalY: number - if (isFirstMessage) { - // Position below the caret line - finalY = rect.top + coords.top + coords.height - 3 - } else { - // Position above the caret line - finalY = rect.top + coords.top - estimatedTooltipHeight - margin - } + if (isFirstMessage) { + // Position below the caret line + finalY = rect.top + coords.top + coords.height - 3 + } else { + // Position above the caret line + finalY = rect.top + coords.top - estimatedTooltipHeight - margin + } - tooltipPosition = { - x: rect.left + coords.left - 70, - y: finalY + tooltipPosition = { + x: rect.left + coords.left - 70, + y: finalY + } + } catch (error) { + // Hide tooltip on any error related to position calculation + console.error('Error updating tooltip position', error) + showContextTooltip = false } }