Alt+Click to place a caretmark.
// ==UserScript==
// @name CaretMark
// @namespace http://tampermonkey.net/
// @version 2026-05-19
// @description Alt+Click to place a caretmark.
// @author Gemini, skygate2012
// @match *://*/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const getStorageKey = () => location.href;
const style = document.createElement('style');
style.textContent = `
/* Caret Animation */
@keyframes smoothBlink {
0%, 100% { opacity: 1; transform: scaleY(1); }
50% { opacity: 0.3; transform: scaleY(0.9); }
}
/* Highlight Flash Animation */
@keyframes flashAmber {
0% { background-color: rgba(255, 165, 0, 0); }
10% { background-color: rgba(255, 165, 0, 0.6); box-shadow: 0 0 15px rgba(255, 165, 0, 0.4); }
100% { background-color: rgba(255, 165, 0, 0); }
}
/* The Caret Line */
.caret-mark {
position: absolute;
width: 3px;
background: #00e600;
box-shadow: 0 0 4px rgba(0,255,0,0.8);
animation: smoothBlink 1.2s ease-in-out infinite;
pointer-events: none;
z-index: 2147483647;
border-radius: 2px;
}
/* The Flash Strip */
.caret-flash-strip {
position: absolute;
left: 0;
width: 100%;
pointer-events: none;
z-index: 2147483646;
animation: flashAmber 1.5s ease-out forwards;
mix-blend-mode: multiply;
}
/* The Floating Indicator */
.caret-indicator {
position: fixed;
left: 50%;
transform: translateX(-50%);
padding: 8px 16px;
background: linear-gradient(135deg, #ff8c00, #ff5500);
color: white;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-weight: 600;
font-size: 14px;
border-radius: 20px;
box-shadow: 0 4px 15px rgba(255, 85, 0, 0.4);
z-index: 2147483647;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s cubic-bezier(0.18, 0.89, 0.32, 1.28);
border: 1px solid rgba(255,255,255,0.2);
text-shadow: 0 1px 2px rgba(0,0,0,0.2);
}
.caret-indicator:hover {
transform: translateX(-50%) scale(1.05);
box-shadow: 0 6px 20px rgba(255, 85, 0, 0.5);
}
.caret-indicator:active {
transform: translateX(-50%) scale(0.95);
}
.caret-indicator.top { top: 20px; }
.caret-indicator.bottom { bottom: 20px; }
/* Arrow Pointer on Indicator */
.caret-indicator::after {
content: '';
position: absolute;
left: 50%;
transform: translateX(-50%);
border: 8px solid transparent;
}
/* Pointing Up */
.caret-indicator.top::after {
top: -15px;
border-bottom-color: #ff8c00;
}
/* Pointing Down */
.caret-indicator.bottom::after {
bottom: -15px;
border-top-color: #ff5500;
}
`;
document.head.appendChild(style);
/* ----------------- State ----------------- */
let caretEl = null;
let indicatorEl = null;
let currentRange = null;
/* ----------------- XPath Helpers ----------------- */
function getXPath(node) {
if (node.nodeType === Node.TEXT_NODE) node = node.parentNode;
if (node.id) return `//*[@id="${node.id}"]`;
const parts = [];
while (node && node.nodeType === Node.ELEMENT_NODE) {
let count = 0;
let sibling = node.previousSibling;
while (sibling) {
if (sibling.nodeType === Node.ELEMENT_NODE && sibling.nodeName === node.nodeName) {
count++;
}
sibling = sibling.previousSibling;
}
const tag = node.nodeName.toLowerCase();
const pathIndex = count > 0 ? `[${count + 1}]` : '';
parts.unshift(tag + pathIndex);
node = node.parentNode;
}
return parts.length ? '/' + parts.join('/') : null;
}
function getNodeByXPath(path) {
try {
const evaluator = new XPathEvaluator();
const result = evaluator.evaluate(path, document.documentElement, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
return result.singleNodeValue;
} catch (e) {
return null;
}
}
/* ----------------- Core Logic ----------------- */
function clearVisuals() {
caretEl?.remove();
indicatorEl?.remove();
caretEl = indicatorEl = currentRange = null;
}
// Differentiate between user-invoked removal (clear storage) and systemic redrawing
function removeCaret(userAction = true) {
clearVisuals();
if (userAction) GM_deleteValue(getStorageKey());
}
function drawCaretFromRange(range) {
if (!range) return;
const rects = range.getClientRects();
const rect = rects.length > 0 ? rects[0] : range.getBoundingClientRect();
if (rect.height === 0 && rect.width === 0) return;
if (!caretEl) {
caretEl = document.createElement('div');
caretEl.className = 'caret-mark';
document.body.appendChild(caretEl);
}
const absoluteTop = rect.top + window.scrollY;
const absoluteLeft = rect.left + window.scrollX;
caretEl.style.top = `${absoluteTop}px`;
caretEl.style.left = `${absoluteLeft}px`;
caretEl.style.height = `${rect.height}px`;
currentRange = range;
updateIndicator();
}
function saveLocation(node, offset) {
let textNodeIndex = 0;
if (node.nodeType === Node.TEXT_NODE) {
let sibling = node.previousSibling;
while(sibling) {
if(sibling.nodeType === Node.TEXT_NODE) textNodeIndex++;
sibling = sibling.previousSibling;
}
}
const data = {
xpath: getXPath(node),
offset: offset,
isText: node.nodeType === Node.TEXT_NODE,
textIndex: textNodeIndex
};
GM_setValue(getStorageKey(), JSON.stringify(data));
}
function restoreCaret() {
const raw = GM_getValue(getStorageKey());
if (!raw) return;
const data = JSON.parse(raw);
let parentNode = getNodeByXPath(data.xpath);
if (!parentNode) return;
let targetNode = parentNode;
if (data.isText) {
let currentIndex = 0;
let found = false;
for (let child of parentNode.childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
if (currentIndex === data.textIndex) {
targetNode = child;
found = true;
break;
}
currentIndex++;
}
}
if (!found) targetNode = parentNode;
}
try {
const range = document.createRange();
const maxLen = targetNode.length || targetNode.childNodes.length || 0;
const safeOffset = Math.min(data.offset, maxLen);
range.setStart(targetNode, safeOffset);
range.collapse(true);
drawCaretFromRange(range);
} catch (e) {
// Suppress noisy console logs on dynamic page failures
clearVisuals();
}
}
/* ----------------- Indicator & Highlight Logic ----------------- */
function estimateLineHeight() {
return parseInt(getComputedStyle(document.body).lineHeight) || 20;
}
function updateIndicator() {
if (!currentRange || !caretEl) return;
const rect = caretEl.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const isVisible = rect.top >= -10 && rect.bottom <= viewportHeight + 10;
if (isVisible) {
if (indicatorEl) indicatorEl.style.display = 'none';
return;
}
if (!indicatorEl) {
indicatorEl = document.createElement('div');
indicatorEl.className = 'caret-indicator';
indicatorEl.addEventListener('click', scrollToCaret);
document.body.appendChild(indicatorEl);
}
indicatorEl.style.display = 'block';
indicatorEl.classList.remove('top', 'bottom');
const lh = estimateLineHeight();
if (rect.top < 0) {
const lines = Math.ceil(Math.abs(rect.top) / lh);
indicatorEl.innerHTML = `↑ ${lines} lines up`;
indicatorEl.classList.add('top');
} else {
const lines = Math.ceil((rect.top - viewportHeight) / lh);
indicatorEl.innerHTML = `↓ ${lines} lines down`;
indicatorEl.classList.add('bottom');
}
}
function flashHighlightLine() {
if (!caretEl) return;
const caretRect = caretEl.getBoundingClientRect();
const absoluteTop = caretRect.top + window.scrollY;
const height = caretRect.height || 20;
const strip = document.createElement('div');
strip.className = 'caret-flash-strip';
strip.style.top = `${absoluteTop}px`;
strip.style.height = `${height}px`;
document.body.appendChild(strip);
setTimeout(() => {
strip.remove();
}, 1600);
}
function scrollToCaret(e) {
if (e) e.stopPropagation();
if (!currentRange) return;
const rect = caretEl.getBoundingClientRect();
const absoluteTop = rect.top + window.scrollY;
window.scrollTo({
top: absoluteTop - (window.innerHeight / 2),
behavior: 'smooth'
});
setTimeout(flashHighlightLine, 300);
}
/* ----------------- Event Listeners & Observers ----------------- */
document.addEventListener('click', (e) => {
if (!e.altKey) return;
if (e.target.tagName === 'A') return;
if (e.target.closest('.caret-indicator') || e.target.closest('.caret-mark')) return;
let range;
let offsetNode, offset;
if (document.caretPositionFromPoint) {
const pos = document.caretPositionFromPoint(e.clientX, e.clientY);
if (!pos) return;
offsetNode = pos.offsetNode;
offset = pos.offset;
range = document.createRange();
range.setStart(offsetNode, offset);
} else if (document.caretRangeFromPoint) {
range = document.caretRangeFromPoint(e.clientX, e.clientY);
offsetNode = range.startContainer;
offset = range.startOffset;
}
if (!range) return;
removeCaret(true); // User generated, so completely overwrite
range.collapse(true);
drawCaretFromRange(range);
saveLocation(offsetNode, offset);
});
// Handle SPA URL Changes
let lastUrl = location.href;
function handleUrlChange() {
if (location.href !== lastUrl) {
lastUrl = location.href;
removeCaret(false); // Clear visually, don't delete from storage
setTimeout(restoreCaret, 200); // Slight delay for framework router mounting
}
}
const originalPushState = history.pushState;
history.pushState = function() {
originalPushState.apply(this, arguments);
handleUrlChange();
};
const originalReplaceState = history.replaceState;
history.replaceState = function() {
originalReplaceState.apply(this, arguments);
handleUrlChange();
};
window.addEventListener('popstate', handleUrlChange);
// Handle DOM shifts (Images loading, accordion expanding, etc.)
if (typeof ResizeObserver !== 'undefined') {
const resizeObserver = new ResizeObserver(() => {
if (currentRange && document.contains(currentRange.startContainer)) {
drawCaretFromRange(currentRange);
}
});
resizeObserver.observe(document.body);
}
// Handle DOM framework re-renders (React/Vue tearing down and rebuilding nodes)
let restoreDebounce;
const domObserver = new MutationObserver(() => {
// 1. We have data, but no caret is currently drawn (e.g., async data just loaded)
if (!currentRange && GM_getValue(getStorageKey())) {
clearTimeout(restoreDebounce);
restoreDebounce = setTimeout(restoreCaret, 500);
}
// 2. We have a caret, but the underlying text node was destroyed by a framework
else if (currentRange && !document.contains(currentRange.startContainer)) {
clearVisuals();
clearTimeout(restoreDebounce);
restoreDebounce = setTimeout(restoreCaret, 300);
}
});
// Wait for initial load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
domObserver.observe(document.body, { childList: true, subtree: true });
restoreCaret();
});
} else {
domObserver.observe(document.body, { childList: true, subtree: true });
restoreCaret();
setTimeout(restoreCaret, 1000);
}
window.addEventListener('scroll', updateIndicator, { passive: true });
document.addEventListener('keydown', (e) => e.key === 'Escape' && removeCaret(true));
})();