Get the selected text along with text before and after the selection
This script should not be not be installed directly. It is a library for other scripts to include with the meta directive // @require https://update.greatest.deepsurf.us/scripts/528822/1737952/Selection%20Context.js
// ==UserScript==
// @name Selection Context
// @namespace http://tampermonkey.net/
// @version 0.3.2
// @description Get the selected text along with text before and after the selection
// @author RoCry
// @license MIT
// ==/UserScript==
const DEFAULT_CONTEXT_LENGTH = 500;
const MAX_CONTEXT_LENGTH = 8192;
const BLOCK_SELECTORS =
"article, section, main, p, div, li, td, th, blockquote, pre";
function getSelectionRoot(range) {
const container =
range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
? range.commonAncestorContainer
: range.commonAncestorContainer.parentElement;
if (!container) return document.body;
return container.closest(BLOCK_SELECTORS) || document.body;
}
function extractSelectedHTML(range) {
try {
const fragment = range.cloneContents();
const container = document.createElement("div");
container.appendChild(fragment);
const parentElement =
range.commonAncestorContainer.nodeType === Node.TEXT_NODE
? range.commonAncestorContainer.parentElement
: range.commonAncestorContainer;
if (parentElement && parentElement.nodeName !== "BODY") {
const tagName = parentElement.nodeName.toLowerCase();
return `<${tagName}>${container.innerHTML}</${tagName}>`;
}
return container.innerHTML;
} catch (error) {
console.error("Error extracting HTML from selection:", error);
return null;
}
}
function getTextNodesIn(node) {
const textNodes = [];
const walker = document.createTreeWalker(
node,
NodeFilter.SHOW_TEXT,
null,
false,
);
let currentNode = walker.nextNode();
while (currentNode) {
textNodes.push(currentNode);
currentNode = walker.nextNode();
}
return textNodes;
}
/**
* Gets the selected text along with text before and after the selection
* @param {number} tryContextLength - Desired length of context to try to collect (before + after selection)
* @returns {Object|null} Object containing selectedHTML, selectedText, textBefore, textAfter, paragraphText
*/
function GetSelectionContext(tryContextLength = DEFAULT_CONTEXT_LENGTH) {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return null;
const selectedText = selection.toString().trim();
if (!selectedText) return null;
const range = selection.getRangeAt(0);
const actualContextLength = Math.min(tryContextLength, MAX_CONTEXT_LENGTH);
const halfContextLength = Math.floor(actualContextLength / 2);
const root = getSelectionRoot(range) || document.body;
const allTextNodes = getTextNodesIn(root);
const startNode = range.startContainer;
const endNode = range.endContainer;
const startIndex = allTextNodes.indexOf(startNode);
const endIndex = allTextNodes.indexOf(endNode);
if (startIndex === -1 || endIndex === -1) {
console.warn(
"Selection nodes not found in text node list. Returning minimal context.",
);
return {
selectedHTML: extractSelectedHTML(range) || selectedText,
selectedText,
textBefore: "",
textAfter: "",
paragraphText: selectedText,
};
}
let textBefore = "";
let textAfter = "";
let currentLength = 0;
if (startNode.nodeType === Node.TEXT_NODE) {
textBefore = startNode.textContent.substring(0, range.startOffset);
currentLength = textBefore.length;
}
let beforeIndex = startIndex - 1;
while (beforeIndex >= 0 && currentLength < halfContextLength) {
const nodeText = allTextNodes[beforeIndex].textContent || "";
textBefore = `${nodeText}\n${textBefore}`;
currentLength += nodeText.length;
beforeIndex -= 1;
}
if (beforeIndex >= 0) {
textBefore = `...\n${textBefore}`;
}
currentLength = 0;
if (endNode.nodeType === Node.TEXT_NODE) {
textAfter = endNode.textContent.substring(range.endOffset);
currentLength = textAfter.length;
}
let afterIndex = endIndex + 1;
while (
afterIndex < allTextNodes.length &&
currentLength < halfContextLength
) {
const nodeText = allTextNodes[afterIndex].textContent || "";
textAfter += `${nodeText}\n`;
currentLength += nodeText.length;
afterIndex += 1;
}
if (afterIndex < allTextNodes.length) {
textAfter += "\n...";
}
textBefore = textBefore.trim();
textAfter = textAfter.trim();
const paragraphText = `${textBefore} ${selectedText} ${textAfter}`.trim();
return {
selectedHTML: extractSelectedHTML(range) || selectedText,
selectedText,
textBefore,
textAfter,
paragraphText,
};
}
const TextExplainerUI = (() => {
const IDS = {
popup: "explainer-popup",
overlay: "explainer-overlay",
content: "explainer-content",
loading: "explainer-loading",
error: "explainer-error",
floatingButton: "explainer-floating-button",
};
const POPUP_WIDTH = 450;
const POPUP_MAX_HEIGHT_RATIO = 0.8;
const STYLE_TEXT = `#${IDS.popup}{position:absolute;width:${POPUP_WIDTH}px;max-width:90vw;max-height:80vh;padding:16px 16px 14px;z-index:2147483647;overflow:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;background:rgba(255,255,255,0.96);border:1px solid rgba(15,23,42,0.12);border-radius:10px;box-shadow:0 12px 28px rgba(15,23,42,0.12);color:#0f172a;font-family:inherit;font-size:0.98rem;line-height:1.65;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);transition:opacity 0.2s ease,transform 0.2s ease;}#${IDS.popup}.dark-theme{background:rgba(24,24,28,0.96);border:1px solid rgba(255,255,255,0.12);box-shadow:0 16px 34px rgba(0,0,0,0.5);color:#e5e7eb;}#${IDS.overlay}{position:fixed;top:0;left:0;right:0;bottom:0;z-index:2147483646;background:transparent;}@supports (-webkit-touch-callout: none){#${IDS.popup}{backdrop-filter:none;-webkit-backdrop-filter:none;}}@keyframes fadeIn{from{opacity:0}to{opacity:1}}@keyframes fadeOut{from{opacity:1}to{opacity:0}}#${IDS.content}{font-family:inherit;font-size:0.98rem;line-height:1.65;}#${IDS.content} p{margin:0 0 10px;}#${IDS.content} ul,#${IDS.content} ol{margin:6px 0 10px 20px;padding:0;}#${IDS.content} li{margin:4px 0;}#${IDS.content} a{color:inherit;text-decoration:underline;text-decoration-color:rgba(15,23,42,0.35);text-decoration-thickness:2px;text-underline-offset:3px;}#${IDS.popup}.dark-theme #${IDS.content} a{text-decoration-color:rgba(229,231,235,0.5);}#${IDS.content} code{font-family:ui-monospace,"SFMono-Regular","Menlo",monospace;font-size:0.92em;background:rgba(15,23,42,0.08);padding:2px 4px;border-radius:4px;}#${IDS.popup}.dark-theme #${IDS.content} code{background:rgba(255,255,255,0.12);}#${IDS.loading}{text-align:center;padding:14px 0;display:flex;align-items:center;justify-content:center;}#${IDS.loading}:after{content:"";width:20px;height:20px;border:3px solid rgba(15,23,42,0.12);border-top:3px solid rgba(15,23,42,0.45);border-radius:50%;animation:spin 1s linear infinite;display:inline-block;}#${IDS.popup}.dark-theme #${IDS.loading}:after{border:3px solid rgba(255,255,255,0.18);border-top:3px solid rgba(255,255,255,0.55);}@keyframes spin{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}#${IDS.error}{color:#b42318;padding:8px 10px;border-radius:6px;margin-bottom:10px;font-size:0.9rem;display:none;background:rgba(180,35,24,0.08);}#${IDS.popup}.dark-theme #${IDS.error}{background:rgba(220,80,80,0.18);color:#ffb4b4;}@media (prefers-color-scheme: dark){#${IDS.popup}{background:rgba(24,24,28,0.96);color:#e5e7eb;}#${IDS.floatingButton}{background-color:rgba(33,150,243,0.9);}}@media (hover:none) and (pointer:coarse){#${IDS.popup}{width:95vw;max-height:90vh;padding:16px;font-size:1rem;}#${IDS.popup} p,#${IDS.popup} li{line-height:1.7;margin-bottom:12px;}#${IDS.popup} a{padding:8px 0;}}`;
let stylesInjected = false;
let currentPopup = null;
function ensureStyles() {
if (stylesInjected) return;
const addStyle =
typeof GM_addStyle === "function"
? GM_addStyle
: (cssText) => {
const style = document.createElement("style");
style.textContent = cssText;
document.head.appendChild(style);
return style;
};
if (!document.head) {
throw new Error("document.head is not available");
}
addStyle(STYLE_TEXT);
stylesInjected = true;
}
function isTouchDevice() {
return (
"ontouchstart" in window ||
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0
);
}
function parseRgb(color) {
const match = color.match(
/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*[\d.]+)?\)/,
);
if (!match) return null;
return {
r: Number(match[1]),
g: Number(match[2]),
b: Number(match[3]),
};
}
function luminance(color) {
const rgb = parseRgb(color);
if (!rgb) return 128;
return 0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b;
}
function isPageDarkMode() {
const bodyStyle = window.getComputedStyle(document.body);
const htmlStyle = window.getComputedStyle(document.documentElement);
const bodyBg = bodyStyle.backgroundColor;
const htmlBg = htmlStyle.backgroundColor;
const threshold = 128;
const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)",
).matches;
if (luminance(bodyBg) < threshold) return true;
if (bodyBg === "rgba(0, 0, 0, 0)" && luminance(htmlBg) < threshold)
return true;
if (bodyBg === "rgba(0, 0, 0, 0)" && htmlBg === "rgba(0, 0, 0, 0)")
return prefersDark;
return false;
}
function calculatePopupPosition() {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
const selectionRect = range.getBoundingClientRect();
const scrollLeft = window.scrollX || document.documentElement.scrollLeft;
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const popupHeight = Math.min(500, viewportHeight * POPUP_MAX_HEIGHT_RATIO);
const margin = 20;
const position = {};
if (selectionRect.bottom + margin + popupHeight <= viewportHeight) {
position.top = selectionRect.bottom + scrollTop + margin;
position.left = Math.min(
Math.max(
10 + scrollLeft,
selectionRect.left +
scrollLeft +
selectionRect.width / 2 -
POPUP_WIDTH / 2,
),
viewportWidth + scrollLeft - POPUP_WIDTH - 10,
);
position.placement = "below";
return position;
}
if (selectionRect.top - margin - popupHeight >= 0) {
position.top = selectionRect.top + scrollTop - margin - popupHeight;
position.left = Math.min(
Math.max(
10 + scrollLeft,
selectionRect.left +
scrollLeft +
selectionRect.width / 2 -
POPUP_WIDTH / 2,
),
viewportWidth + scrollLeft - POPUP_WIDTH - 10,
);
position.placement = "above";
return position;
}
if (selectionRect.right + margin + POPUP_WIDTH <= viewportWidth) {
position.top = Math.max(
10 + scrollTop,
Math.min(
selectionRect.top + scrollTop,
viewportHeight + scrollTop - popupHeight - 10,
),
);
position.left = selectionRect.right + scrollLeft + margin;
position.placement = "right";
return position;
}
if (selectionRect.left - margin - POPUP_WIDTH >= 0) {
position.top = Math.max(
10 + scrollTop,
Math.min(
selectionRect.top + scrollTop,
viewportHeight + scrollTop - popupHeight - 10,
),
);
position.left = selectionRect.left + scrollLeft - margin - POPUP_WIDTH;
position.placement = "left";
return position;
}
position.top = Math.max(
10 + scrollTop,
Math.min(
selectionRect.top + selectionRect.height + scrollTop + margin,
viewportHeight / 2 + scrollTop - popupHeight / 2,
),
);
position.left = Math.max(
10 + scrollLeft,
Math.min(
selectionRect.left +
selectionRect.width / 2 +
scrollLeft -
POPUP_WIDTH / 2,
viewportWidth + scrollLeft - POPUP_WIDTH - 10,
),
);
position.placement = "center";
return position;
}
function openPopup({ isTouch, isDark }) {
ensureStyles();
closePopup();
const popup = document.createElement("div");
popup.id = IDS.popup;
if (isDark) popup.classList.add("dark-theme");
popup.innerHTML = `
<div id="${IDS.error}"></div>
<div id="${IDS.loading}"></div>
<div id="${IDS.content}"></div>
`;
if (!document.body) {
throw new Error("document.body is not available");
}
document.body.appendChild(popup);
if (isTouch) {
popup.style.position = "fixed";
popup.style.top = "50%";
popup.style.left = "50%";
popup.style.transform = "translate(-50%, -50%)";
popup.style.width = "90vw";
popup.style.maxHeight = "85vh";
} else {
const position = calculatePopupPosition();
if (position) {
popup.style.transform = "none";
if (position.top !== undefined) popup.style.top = `${position.top}px`;
if (position.left !== undefined)
popup.style.left = `${position.left}px`;
} else {
popup.style.top = "50%";
popup.style.left = "50%";
popup.style.transform = "translate(-50%, -50%)";
}
}
popup.style.animation = "fadeIn 0.3s ease";
const popupState = {
popup,
contentEl: popup.querySelector(`#${IDS.content}`),
loadingEl: popup.querySelector(`#${IDS.loading}`),
errorEl: popup.querySelector(`#${IDS.error}`),
overlay: null,
cleanup: [],
};
function closeOnEsc(event) {
if (event.key === "Escape") {
closePopup();
}
}
document.addEventListener("keydown", closeOnEsc);
popupState.cleanup.push(() =>
document.removeEventListener("keydown", closeOnEsc),
);
if (isTouch) {
const overlay = document.createElement("div");
overlay.id = IDS.overlay;
popupState.overlay = overlay;
document.body.appendChild(overlay);
let touchStarted = false;
let startX = 0;
let startY = 0;
const moveThreshold = 30;
function onOverlayTouchStart(event) {
touchStarted = true;
startX = event.touches[0].clientX;
startY = event.touches[0].clientY;
}
function onOverlayTouchEnd(event) {
if (!touchStarted) return;
const touch = event.changedTouches[0];
const moveX = Math.abs(touch.clientX - startX);
const moveY = Math.abs(touch.clientY - startY);
if (moveX < moveThreshold && moveY < moveThreshold) {
closePopup();
}
touchStarted = false;
}
function stopPropagation(event) {
event.stopPropagation();
}
overlay.addEventListener("touchstart", onOverlayTouchStart, {
passive: true,
});
overlay.addEventListener("touchmove", () => {}, { passive: true });
overlay.addEventListener("touchend", onOverlayTouchEnd, {
passive: true,
});
popup.addEventListener("touchstart", stopPropagation, { passive: false });
popupState.cleanup.push(() =>
overlay.removeEventListener("touchstart", onOverlayTouchStart),
);
popupState.cleanup.push(() =>
overlay.removeEventListener("touchend", onOverlayTouchEnd),
);
popupState.cleanup.push(() =>
popup.removeEventListener("touchstart", stopPropagation),
);
} else {
function onOutsideClick(event) {
if (popup.contains(event.target)) return;
closePopup();
}
document.addEventListener("click", onOutsideClick);
popupState.cleanup.push(() =>
document.removeEventListener("click", onOutsideClick),
);
}
currentPopup = popupState;
return popupState;
}
function closePopup() {
if (!currentPopup) return;
const popup = currentPopup.popup;
popup.style.animation = "fadeOut 0.2s ease";
const { overlay, cleanup } = currentPopup;
const remove = () => {
cleanup.forEach((fn) => fn());
if (overlay) overlay.remove();
popup.remove();
currentPopup = null;
};
setTimeout(remove, 200);
}
function setLoading(popupState, isVisible) {
if (!popupState || !popupState.loadingEl) return;
popupState.loadingEl.style.display = isVisible ? "flex" : "none";
}
function showError(popupState, message) {
if (!popupState || !popupState.errorEl) return;
popupState.errorEl.textContent = message;
popupState.errorEl.style.display = "block";
setLoading(popupState, false);
}
function updateContent(popupState, text) {
if (!popupState || !popupState.contentEl) return;
if (!text) return;
let content = text.trim();
if (!content) return;
try {
if (content.startsWith("```")) {
if (content.endsWith("```")) {
content = content.split("\n").slice(1, -1).join("\n");
} else {
content = content.split("\n").slice(1).join("\n");
}
}
if (!content.startsWith("<")) {
content = `<p>${content.replace(/\n/g, "<br>")}</p>`;
}
popupState.contentEl.innerHTML = content;
} catch (error) {
popupState.contentEl.innerHTML = `<p>${content.replace(/\n/g, "<br>")}</p>`;
}
}
function createFloatingButton({ size, onTrigger, label }) {
const button = document.createElement("div");
button.id = IDS.floatingButton;
let buttonSize = "50px";
if (size === "small") buttonSize = "40px";
if (size === "large") buttonSize = "60px";
button.style.cssText = `
width: ${buttonSize};
height: ${buttonSize};
border-radius: 50%;
background-color: rgba(33, 150, 243, 0.8);
color: white;
display: flex;
align-items: center;
justify-content: center;
position: fixed;
z-index: 9999;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
cursor: pointer;
font-weight: bold;
font-size: ${parseInt(buttonSize, 10) * 0.4}px;
opacity: 0;
transition: opacity 0.3s ease, transform 0.2s ease;
pointer-events: none;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
`;
button.setAttribute("aria-label", "Explain selection");
button.innerHTML = label || "TE";
if (!document.body) {
throw new Error("document.body is not available");
}
document.body.appendChild(button);
function handleButtonAction(event) {
event.preventDefault();
event.stopPropagation();
if (typeof onTrigger === "function") {
onTrigger(event);
}
}
button.addEventListener("click", handleButtonAction);
button.addEventListener(
"touchstart",
(event) => {
event.preventDefault();
event.stopPropagation();
button.style.transform = "scale(0.95)";
},
{ passive: false },
);
button.addEventListener(
"touchend",
(event) => {
event.preventDefault();
event.stopPropagation();
button.style.transform = "scale(1)";
handleButtonAction(event);
},
{ passive: false },
);
button.addEventListener("mousedown", (event) => {
event.preventDefault();
event.stopPropagation();
});
return button;
}
function showFloatingButton(button) {
if (!button) return false;
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
hideFloatingButton(button);
return false;
}
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
const buttonSize = parseInt(button.style.width, 10);
const margin = 10;
let top = rect.bottom + margin;
let left = rect.left + rect.width / 2 - buttonSize / 2;
if (top + buttonSize > window.innerHeight) {
top = rect.top - buttonSize - margin;
}
left = Math.max(10, Math.min(left, window.innerWidth - buttonSize - 10));
button.style.top = `${top}px`;
button.style.left = `${left}px`;
button.style.opacity = "1";
button.style.pointerEvents = "auto";
return true;
}
function hideFloatingButton(button) {
if (!button) return;
button.style.opacity = "0";
button.style.pointerEvents = "none";
}
return {
ensureStyles,
isTouchDevice,
isPageDarkMode,
openPopup,
closePopup,
setLoading,
showError,
updateContent,
createFloatingButton,
showFloatingButton,
hideFloatingButton,
};
})();
window.GetSelectionContext = GetSelectionContext;
window.TextExplainerUI = TextExplainerUI;
if (typeof module !== "undefined" && module.exports) {
module.exports = { GetSelectionContext, TextExplainerUI };
} else {
window.SelectionUtils = window.SelectionUtils || {};
window.SelectionUtils.GetSelectionContext = GetSelectionContext;
}