Last active
March 24, 2025 20:58
-
-
Save leonardpauli/1a84d2ce6ea8d925e07099e582d39381 to your computer and use it in GitHub Desktop.
Display DOM element relative to caret anywhere on web page
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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)`) | |
})() |
Author
leonardpauli
commented
Mar 24, 2025
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment