Selection Context

Get the selected text along with text before and after the selection

이 스크립트는 직접 설치하는 용도가 아닙니다. 다른 스크립트에서 메타 지시문 // @require https://update.greasyfork.org/scripts/528822/1737952/Selection%20Context.js을(를) 사용하여 포함하는 라이브러리입니다.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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;
}