Skip to content

Instantly share code, notes, and snippets.

@leonardpauli
Last active March 24, 2025 20:58
Show Gist options
  • Save leonardpauli/1a84d2ce6ea8d925e07099e582d39381 to your computer and use it in GitHub Desktop.
Save leonardpauli/1a84d2ce6ea8d925e07099e582d39381 to your computer and use it in GitHub Desktop.
Display DOM element relative to caret anywhere on web page
// web_caret_track_overlay
// created by Leonard Pauli, 2025-03-24, at WeWork chatting with Charles etc.
// usage:
// Paste in the browser console, then click in an input field or a content editable field, and type some, and then wait for two seconds.
// you should see a tiny dot under your caret that moves along with the caret and when pausing it should pulsate and then expand into a suggestion panel.
// also try both in end of input box (to see inline) and middle of contentEditable (for under box)
// also try on dark page (like chatgpt) and light (like gmail); it somewhat adapts and uses font and color
// performance:
// note, currently doing polling every 100ms should probably be adapted to be more performant, only reacting to relevant change events.
// doing multiple getBoundingClientRect is expensive
// issues:
// might have position issues if an input is inside `display: flex;` and line-height is smaller than container.
// future:
// - tests more extensively in multiple scenarios
// - clean code
// - instead of ellipsis, have text and the box gradient mask fade out
// - and when applying, use slick animated gradient reveal
// - capture tab when relevant and use for auto-completion triggering
// - incorporate llm
// - properly detect if should show inline or under
// - Currently, `pointer-events: none` on overlay, but might want suggestion to be clickable with mouse
// - and esc to dismiss it, making it bigger than just dot, so you can click to bring it back
// - handle if placeholder, or other text under when inline?
// - ability to show multiple suggestions, and larger possibly edit actions
// - don't interpret clicking to move cursor as "typing", and thus keep dismissed/hidden
// - the dot is "flying around" if clicking between inputs, maybe neater to just fade out/fade it on bit distance moves
// (but also a bit nice to have it "follow along" with you)
// development:
// - you can modify and re-paste into console (it will clean up after itself)
// - you might want to bundle this into the client script of a chrome extension
// - you can remove `|| !editable` to show for any selection, even non-editable dom
(function() {
const c = tag => document.createElement(tag)
const key = 'caret_tracking'
const log = m => console.log(`${key}.${m}`)
window[key]?.deinit()
const state = window[key] = {
lastTyped: 0,
isExpanded: false,
currentText: ''
}
state.deinit = ()=> {
clearInterval(state.interval_id)
state.overlay?.remove()
delete window[key]
log(`deactivated`)
return true
}
const overlay = c('div')
overlay.style.cssText = `
position:fixed;top:0;left:0;width:100%;height:100%;
pointer-events:none;
z-index:100000;
`
const indicator_unit = 10
const indicator = c('div')
indicator.style.cssText = `
position:absolute;
width:${indicator_unit}px;height:${indicator_unit}px;
background:rgba(255,0,0,0.2);
transition: transform 100ms linear;
transform-origin:0% 0%;
`
const suggestionDot = c('div')
suggestionDot.style.cssText = `
position:absolute;
width:2px;height:2px;
background:rgba(0,0,0,0);
border-radius:50%;
transform-origin:center;
opacity:0.1;
transition: all 200ms ease;
backdrop-filter: invert(1);
`
const suggestionEl = c('div')
suggestionEl.style.cssText = `
position:absolute;
padding:1px 6px; left: -6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width:300px;
border-radius:2px;
opacity:0;
transform-origin:top left;
transition: all 200ms ease;
`
overlay.appendChild(indicator)
overlay.appendChild(suggestionDot)
overlay.appendChild(suggestionEl)
document.body.appendChild(overlay)
Object.assign(state, {
overlay,
indicator,
suggestionDot,
suggestionEl,
prev: {left:0, top:0, width:0, height:0}
})
// Computes the selection rect for input/textarea by using a mirror element
// and applies a vertical correction if the input's height is larger than its line-height.
const get_input_selection_rect = input => {
const mirror = document.createElement('div')
const style = window.getComputedStyle(input)
const properties = [
'direction','boxSizing','width','height','overflowX','overflowY',
'borderTopWidth','borderRightWidth','borderBottomWidth','borderLeftWidth',
'paddingTop','paddingRight','paddingBottom','paddingLeft',
'fontStyle','fontVariant','fontWeight','fontStretch','fontSize','lineHeight',
'fontFamily','textAlign','textTransform','textIndent','textDecoration'
]
properties.forEach(prop => {
mirror.style[prop] = style[prop]
})
mirror.style.position = 'absolute'
mirror.style.visibility = 'hidden'
mirror.style.whiteSpace = input.tagName === 'TEXTAREA' ? 'pre-wrap' : 'pre'
mirror.style.wordWrap = 'break-word'
const start = input.selectionStart
const end = input.selectionEnd
const text_before = input.value.substring(0, start)
const text_after = input.value.substring(end)
const selected_text = input.value.substring(start, end) || ''
state.currentText = selected_text
state.text_after = text_after
mirror.textContent = text_before
const span = document.createElement('span')
span.textContent = selected_text
mirror.appendChild(span)
document.body.appendChild(mirror)
const span_rect = span.getBoundingClientRect()
const mirror_rect = mirror.getBoundingClientRect()
document.body.removeChild(mirror)
let offset_left = span_rect.left - mirror_rect.left
let offset_top = span_rect.top - mirror_rect.top
// Correct for extra vertical space (e.g. when inside a flex container)
// (incomplete correction?)
const line_height = parseFloat(style.lineHeight)
state.lineHeight = line_height
state.fontSize = parseFloat(style.fontSize)
state.color = style.color
const input_height = input.clientHeight
if (input_height > line_height) {
const correction = (input_height - line_height) / 2
offset_top -= correction
}
const input_rect = input.getBoundingClientRect()
return {
left: input_rect.left + offset_left,
top: input_rect.top + offset_top,
width: span_rect.width,
height: span_rect.height
}
}
const selection_is_editable = sel => {
if (!sel?.rangeCount) return false
let node = sel.anchorNode
while (node) {
if (node.nodeType === Node.ELEMENT_NODE) {
const tag = node.tagName
if (node.isContentEditable) return true
if ((tag === 'INPUT' || tag === 'TEXTAREA') && !node.readOnly && !node.disabled) return true
}
node = node.parentNode
}
return false
}
// returns {rect?: {left, top, width, height}, editable: bool}
const get_caret_rect = () => {
const active = document.activeElement
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) {
return {rect: get_input_selection_rect(active), editable: !active.readOnly && !active.disabled}
}
const sel = document.getSelection()
if (!(sel && sel.rangeCount > 0)) return null
const range_rect = sel.getRangeAt(0).getBoundingClientRect()
state.currentText = sel.toString()
state.text_after = '.' // tmp hack
const style = window.getComputedStyle(sel.anchorNode.parentElement)
state.lineHeight = parseFloat(style.lineHeight)
state.fontSize = parseFloat(style.fontSize)
state.color = style.color
if (range_rect.width === 0 && range_rect.height === 0) return null
const editable = selection_is_editable(sel)
const rect = {
left: range_rect.left,
top: range_rect.top,
width: range_rect.width,
height: range_rect.height
}
return {rect, editable}
}
const is_color_dark = c => {
const rgb = c.match(/rgb?\((\d+),\s*(\d+),\s*(\d+)\)/)
if (!rgb) return false
const [r, g, b] = rgb.slice(1).map(Number)
return r * 0.299 + g * 0.587 + b * 0.114 < 128
}
const adjust_rgb_opacity = (c, alpha)=> {
const rgb = c.match(/rgb?\((\d+),\s*(\d+),\s*(\d+)\)/)
if (!rgb) return c
const [r, g, b] = rgb.slice(1).map(Number)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
const update_indicator = () => {
const {rect: new_rect, editable} = get_caret_rect() ?? {rect: null, editable: false}
const r = new_rect ?? {...state.prev, top: state.prev.top + state.prev.height, width: 0, height: 0}
const changed = r.left !== state.prev.left || r.top !== state.prev.top
const hidden = r.height === 0 || !editable
state.prev = r
// log(`r ${JSON.stringify(r)}`)
indicator.style.transform =
`translateX(${r.left}px) translateY(${r.top}px) scaleX(${Math.max(r.width, 2) / indicator_unit}) scaleY(${r.height / indicator_unit})`
indicator.style.opacity = '0'
const now = Date.now()
if (changed) {
state.lastTyped = now
}
const timeSinceTyped = now - state.lastTyped
;(function updates() {
const inline = !(state.text_after?.length > 0)
suggestionDot.style.transform = `translateX(${r.left}px) translateY(${r.top + r.height + 3}px)`
suggestionEl.style.transform = `translateX(${r.left}px) translateY(${r.top + (inline? -2 :r.height + 6)}px)`
if (inline) {
suggestionEl.style.backdropFilter = ''
suggestionEl.style.boxShadow = ''
suggestionEl.style.backgroundColor = ''
} else {
suggestionEl.style.backdropFilter = 'blur(5px)'
suggestionEl.style.boxShadow = '0px 1px 2px rgba(0, 0, 0, 0.15)'
suggestionEl.style.backgroundColor = !is_color_dark(state.color) ? 'rgba(0,0,0,0.15)' : 'rgba(255,255,255,0.15)'
}
suggestionEl.style.color = adjust_rgb_opacity(state.color, 0.7)
suggestionEl.style.fontSize = `${state.fontSize}px`
if (hidden) {
suggestionDot.style.opacity = '0'
suggestionEl.style.opacity = '0'
return
}
if (timeSinceTyped < 500) {
// Show dot while typing
suggestionDot.style.opacity = '1'
suggestionEl.style.opacity = '0'
state.isExpanded = false
return
}
if (timeSinceTyped < 1200) {
// Pulsate dot
const pulsePhase = (timeSinceTyped - 500) / 700
const scale = 1 + Math.sin(pulsePhase * Math.PI * 2) * 0.5
const opacity = 0.5 + Math.sin(pulsePhase * Math.PI * 2) * 0.5
suggestionDot.style.transform += ` scale(${scale})`
suggestionDot.style.opacity = opacity.toString()
return
}
if (!state.isExpanded) {
// Expand to show suggestion
state.isExpanded = true
suggestionDot.style.opacity = '0'
suggestionEl.style.opacity = '1'
suggestionEl.style.width = Math.min(200, state.lineHeight * 10) + 'px'
suggestionEl.style.height = state.lineHeight*1.2 + 'px'
suggestionEl.textContent = state.currentText + ' generated suggestion'
}
})()
}
state.interval_id = setInterval(update_indicator, 100)
document.addEventListener('keydown', () => state.lastTyped = Date.now())
log(`activated (run again to toggle off)`)
})()
@leonardpauli
Copy link
Author

web_caret_track

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment