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
This commit is contained in:
centdix
2025-03-31 18:13:55 +02:00
committed by GitHub
parent f7c8654987
commit ca0cda3ecf
3 changed files with 147 additions and 36 deletions
-7
View File
@@ -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",
-1
View File
@@ -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",
@@ -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
}
}