mirror of
https://github.com/turtlebasket/userscripts.git
synced 2026-03-04 11:34:41 -08:00
641 lines
17 KiB
JavaScript
641 lines
17 KiB
JavaScript
// ==UserScript==
|
||
// @name X/Twitter status tooltip preview
|
||
// @namespace local
|
||
// @version 0.4.0
|
||
// @description Hover X/Twitter status links to show an embedded tweet tooltip with header + theme-aware styling.
|
||
// @match *://*/*
|
||
// @grant GM_xmlhttpRequest
|
||
// @connect publish.twitter.com
|
||
// ==/UserScript==
|
||
|
||
(() => {
|
||
"use strict";
|
||
|
||
// ---------- config ----------
|
||
const SHOW_DELAY_MS = 250;
|
||
const HIDE_DELAY_MS = 200;
|
||
const MAX_WIDTH_PX = 420; // visual width cap for tooltip
|
||
const OFFSET_PX = 14;
|
||
const ANIM_MS = 140; // must match CSS transition timing
|
||
|
||
// ---------- cache ----------
|
||
// key: `${normalizedUrl}|${theme}`
|
||
const cache = new Map();
|
||
|
||
// ---------- helpers ----------
|
||
function isStatusUrl(u) {
|
||
try {
|
||
const url = new URL(u);
|
||
const host = url.hostname.replace(/^www\./, "");
|
||
if (host !== "twitter.com" && host !== "x.com") return false;
|
||
return /\/status\/\d+/.test(url.pathname) || /\/i\/web\/status\/\d+/.test(url.pathname);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function normalizeToTwitterDotCom(u) {
|
||
const url = new URL(u);
|
||
url.hostname = "twitter.com";
|
||
return url.toString();
|
||
}
|
||
|
||
function detectTheme() {
|
||
// Use OS color scheme for widget theme.
|
||
return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
|
||
? "dark"
|
||
: "light";
|
||
}
|
||
|
||
function ensureWidgetsJs() {
|
||
if (document.getElementById("xwidgetsjs")) return;
|
||
const s = document.createElement("script");
|
||
s.id = "xwidgetsjs";
|
||
s.async = true;
|
||
s.src = "https://platform.twitter.com/widgets.js";
|
||
document.head.appendChild(s);
|
||
}
|
||
|
||
function gmGet(url) {
|
||
return new Promise((resolve, reject) => {
|
||
GM_xmlhttpRequest({
|
||
method: "GET",
|
||
url,
|
||
onload: (r) => {
|
||
if (r.status >= 200 && r.status < 300) resolve(r.responseText);
|
||
else reject(new Error(`HTTP ${r.status}`));
|
||
},
|
||
onerror: () => reject(new Error("Network error")),
|
||
});
|
||
});
|
||
}
|
||
|
||
async function fetchOEmbed(tweetUrl, theme) {
|
||
const normalized = normalizeToTwitterDotCom(tweetUrl);
|
||
const key = `${normalized}|${theme}`;
|
||
if (cache.has(key)) return { normalized, html: cache.get(key) };
|
||
|
||
// X oEmbed endpoint.
|
||
const oembedUrl =
|
||
"https://publish.twitter.com/oembed" +
|
||
"?omit_script=1" +
|
||
"&dnt=1" +
|
||
"&maxwidth=" + encodeURIComponent(String(MAX_WIDTH_PX)) +
|
||
"&theme=" + encodeURIComponent(theme) +
|
||
"&url=" + encodeURIComponent(normalized);
|
||
|
||
const text = await gmGet(oembedUrl);
|
||
const data = JSON.parse(text);
|
||
if (!data || typeof data.html !== "string") throw new Error("Unexpected oEmbed response");
|
||
|
||
// Add a theme hint for widgets.js.
|
||
const themedHtml = data.html.replace(
|
||
/<blockquote\s+class="twitter-tweet"/i,
|
||
`<blockquote class="twitter-tweet" data-theme="${theme}"`
|
||
);
|
||
|
||
cache.set(key, themedHtml);
|
||
return { normalized, html: themedHtml };
|
||
}
|
||
|
||
function closestLink(el) {
|
||
return el && el.closest ? el.closest("a[href]") : null;
|
||
}
|
||
|
||
function clamp(n, lo, hi) {
|
||
return Math.max(lo, Math.min(hi, n));
|
||
}
|
||
|
||
// ---------- UI / styles ----------
|
||
const style = document.createElement("style");
|
||
style.textContent = `
|
||
.xttip {
|
||
position: fixed;
|
||
z-index: 2147483647;
|
||
|
||
width: min(${MAX_WIDTH_PX}px, calc(100vw - 24px));
|
||
max-height: min(420px, calc(100vh - 24px));
|
||
overflow: auto;
|
||
|
||
padding: 12px 12px 10px;
|
||
border-radius: 14px;
|
||
|
||
background: rgba(18, 18, 18, 0.92);
|
||
color: rgba(255, 255, 255, 0.92);
|
||
border: 1px solid rgba(255, 255, 255, 0.10);
|
||
box-shadow:
|
||
0 18px 60px rgba(0,0,0,0.55),
|
||
0 2px 10px rgba(0,0,0,0.35);
|
||
|
||
backdrop-filter: blur(10px);
|
||
-webkit-backdrop-filter: blur(10px);
|
||
|
||
display: none;
|
||
transform-origin: top left;
|
||
transform: translateY(6px) scale(0.98);
|
||
opacity: 0;
|
||
transition: opacity 110ms ease, transform 130ms ease;
|
||
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.xttip {
|
||
transition:
|
||
opacity 110ms ease,
|
||
transform 130ms ease,
|
||
border-color 160ms ease,
|
||
box-shadow 180ms ease,
|
||
background-color 180ms ease;
|
||
}
|
||
|
||
.xttip[data-open="1"] {
|
||
opacity: 1;
|
||
transform: translateY(0) scale(1);
|
||
}
|
||
|
||
.xttip::before {
|
||
content: "";
|
||
position: absolute;
|
||
width: 10px;
|
||
height: 10px;
|
||
left: 18px;
|
||
top: -6px;
|
||
transform: rotate(45deg);
|
||
background: rgba(18, 18, 18, 0.92);
|
||
border-left: 1px solid rgba(255, 255, 255, 0.10);
|
||
border-top: 1px solid rgba(255, 255, 255, 0.10);
|
||
}
|
||
|
||
.xttip, .xttip * {
|
||
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif !important;
|
||
}
|
||
|
||
.xttip-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
margin-bottom: 10px;
|
||
padding-bottom: 8px;
|
||
border-bottom: 1px solid rgba(255,255,255,0.10);
|
||
}
|
||
|
||
.xttip-header-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.xttip-icon {
|
||
width: 18px;
|
||
height: 18px;
|
||
flex: 0 0 auto;
|
||
opacity: 0.95;
|
||
}
|
||
|
||
.xttip-title {
|
||
font-size: 12px;
|
||
line-height: 1.1;
|
||
font-weight: 600;
|
||
letter-spacing: 0.2px;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
opacity: 0.9;
|
||
}
|
||
|
||
.xttip[data-detached="1"]::before {
|
||
display: none;
|
||
}
|
||
|
||
.xttip[data-detached="1"] .xttip-header {
|
||
cursor: move;
|
||
user-select: none;
|
||
}
|
||
|
||
.xttip[data-detached="1"] {
|
||
border-color: rgba(96, 165, 250, 0.65);
|
||
box-shadow:
|
||
0 22px 68px rgba(0,0,0,0.62),
|
||
0 0 0 2px rgba(96,165,250,0.28);
|
||
}
|
||
|
||
.xttip[data-detach-just="1"] {
|
||
animation: xttip-detach-pop 230ms cubic-bezier(0.2, 0.9, 0.2, 1);
|
||
}
|
||
|
||
@keyframes xttip-detach-pop {
|
||
0% {
|
||
transform: translateY(0) scale(1);
|
||
filter: saturate(1);
|
||
}
|
||
45% {
|
||
transform: translateY(-2px) scale(1.02);
|
||
filter: saturate(1.16);
|
||
}
|
||
100% {
|
||
transform: translateY(0) scale(1);
|
||
filter: saturate(1);
|
||
}
|
||
}
|
||
|
||
.xttip-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex: 0 0 auto;
|
||
}
|
||
|
||
.xttip-open {
|
||
font-size: 12px;
|
||
text-decoration: none;
|
||
opacity: 0.85;
|
||
padding: 4px 6px;
|
||
border-radius: 8px;
|
||
border: 1px solid rgba(255,255,255,0.14);
|
||
}
|
||
.xttip-open:hover { opacity: 1; }
|
||
|
||
.xttip-close {
|
||
cursor: pointer;
|
||
border: 0;
|
||
background: transparent;
|
||
color: inherit;
|
||
font-size: 18px;
|
||
line-height: 1;
|
||
padding: 2px 6px;
|
||
border-radius: 8px;
|
||
opacity: 0.75;
|
||
}
|
||
.xttip-close:hover { opacity: 1; }
|
||
|
||
.xttip-body { padding-top: 2px; }
|
||
|
||
.xttip-loading {
|
||
font-size: 13px;
|
||
line-height: 1.35;
|
||
opacity: 0.85;
|
||
padding: 8px 2px;
|
||
}
|
||
|
||
/* Light mode variant */
|
||
@media (prefers-color-scheme: light) {
|
||
.xttip {
|
||
background: rgba(255, 255, 255, 0.94);
|
||
color: rgba(0, 0, 0, 0.88);
|
||
border: 1px solid rgba(0, 0, 0, 0.10);
|
||
box-shadow:
|
||
0 18px 60px rgba(0,0,0,0.20),
|
||
0 2px 10px rgba(0,0,0,0.12);
|
||
}
|
||
.xttip::before {
|
||
background: rgba(255, 255, 255, 0.94);
|
||
border-left: 1px solid rgba(0, 0, 0, 0.10);
|
||
border-top: 1px solid rgba(0, 0, 0, 0.10);
|
||
}
|
||
.xttip-header { border-bottom: 1px solid rgba(0,0,0,0.10); }
|
||
.xttip-open { border: 1px solid rgba(0,0,0,0.14); }
|
||
}
|
||
|
||
/* Reduce default margins inside embeds so it fits better */
|
||
.xttip .twitter-tweet { margin: 0 !important; }
|
||
`;
|
||
document.documentElement.appendChild(style);
|
||
|
||
const tip = document.createElement("div");
|
||
tip.className = "xttip";
|
||
tip.setAttribute("role", "dialog");
|
||
tip.setAttribute("aria-label", "Tweet preview tooltip");
|
||
document.documentElement.appendChild(tip);
|
||
|
||
// ---------- tooltip positioning ----------
|
||
function positionTipFromEvent(ev) {
|
||
const vw = window.innerWidth;
|
||
const vh = window.innerHeight;
|
||
|
||
// Ensure it has layout for correct dimensions
|
||
const rect = tip.getBoundingClientRect();
|
||
const w = rect.width || MAX_WIDTH_PX;
|
||
const h = rect.height || 220;
|
||
|
||
let x = ev.clientX + OFFSET_PX;
|
||
let y = ev.clientY + OFFSET_PX;
|
||
|
||
// clamp within viewport (small margins)
|
||
x = clamp(x, 8, vw - w - 8);
|
||
y = clamp(y, 8, vh - h - 8);
|
||
|
||
tip.style.left = `${x}px`;
|
||
tip.style.top = `${y}px`;
|
||
}
|
||
|
||
// ---------- open/close with animation ----------
|
||
function openTip() {
|
||
tip.style.display = "block";
|
||
requestAnimationFrame(() => tip.setAttribute("data-open", "1"));
|
||
}
|
||
|
||
function closeTip() {
|
||
tip.removeAttribute("data-open");
|
||
window.setTimeout(() => {
|
||
tip.style.display = "none";
|
||
tip.innerHTML = "";
|
||
}, ANIM_MS);
|
||
}
|
||
|
||
// ---------- header + body composition ----------
|
||
function xIconSvg(theme) {
|
||
const fill = theme === "dark" ? "rgba(255,255,255,0.92)" : "rgba(0,0,0,0.82)";
|
||
return `
|
||
<svg class="xttip-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||
<path fill="${fill}" d="M18.9 2H22l-6.8 7.8L23 22h-6.7l-5.2-6.7L5.2 22H2l7.4-8.5L1 2h6.8l4.7 6.1L18.9 2zm-1.2 18h1.7L6.2 3.9H4.4L17.7 20z"/>
|
||
</svg>
|
||
`;
|
||
}
|
||
|
||
function setTipSkeleton(tweetUrl, theme, statusText) {
|
||
const safeUrl = tweetUrl; // already absolute string
|
||
tip.innerHTML = `
|
||
<div class="xttip-header">
|
||
<div class="xttip-header-left">
|
||
${xIconSvg(theme)}
|
||
<div class="xttip-title">[space] to detach</div>
|
||
</div>
|
||
<div class="xttip-actions">
|
||
<a class="xttip-open" href="${safeUrl}" target="_blank" rel="noopener noreferrer">Open</a>
|
||
<button class="xttip-close" type="button" title="Close" aria-label="Close">×</button>
|
||
</div>
|
||
</div>
|
||
<div class="xttip-body">
|
||
<div class="xttip-loading">${statusText}</div>
|
||
</div>
|
||
`;
|
||
|
||
const closeBtn = tip.querySelector(".xttip-close");
|
||
setupDragHandlers();
|
||
updateDetachHint();
|
||
closeBtn.addEventListener("click", () => {
|
||
activeLink = null;
|
||
activeKey = null;
|
||
isDetached = false;
|
||
tip.removeAttribute("data-detached");
|
||
closeTip();
|
||
});
|
||
}
|
||
|
||
function setTipEmbed(tweetUrl, theme, embedHtml) {
|
||
const safeUrl = tweetUrl;
|
||
tip.innerHTML = `
|
||
<div class="xttip-header">
|
||
<div class="xttip-header-left">
|
||
${xIconSvg(theme)}
|
||
<div class="xttip-title">[space] to detach</div>
|
||
</div>
|
||
<div class="xttip-actions">
|
||
<a class="xttip-open" href="${safeUrl}" target="_blank" rel="noopener noreferrer">Open</a>
|
||
<button class="xttip-close" type="button" title="Close" aria-label="Close">×</button>
|
||
</div>
|
||
</div>
|
||
<div class="xttip-body">${embedHtml}</div>
|
||
`;
|
||
|
||
const closeBtn = tip.querySelector(".xttip-close");
|
||
setupDragHandlers();
|
||
updateDetachHint();
|
||
closeBtn.addEventListener("click", () => {
|
||
activeLink = null;
|
||
activeKey = null;
|
||
isDetached = false;
|
||
tip.removeAttribute("data-detached");
|
||
closeTip();
|
||
});
|
||
|
||
ensureWidgetsJs();
|
||
if (window.twttr?.widgets?.load) window.twttr.widgets.load(tip);
|
||
}
|
||
|
||
// ---------- hover state ----------
|
||
let showTimer = null;
|
||
let hideTimer = null;
|
||
|
||
let activeLink = null; // <a>
|
||
let activeKey = null; // `${normalized}|${theme}`
|
||
let lastEv = null; // last pointer event for positioning
|
||
let isDetached = false; // detached from hover tracking; draggable
|
||
let isDragging = false;
|
||
let dragOffsetX = 0;
|
||
let dragOffsetY = 0;
|
||
let detachFxTimer = null;
|
||
|
||
function updateDetachHint() {
|
||
const title = tip.querySelector(".xttip-title");
|
||
if (!title) return;
|
||
title.textContent = isDetached ? "[space] to close" : "[space] to detach";
|
||
}
|
||
|
||
function playDetachEffect() {
|
||
tip.setAttribute("data-detach-just", "1");
|
||
if (detachFxTimer) clearTimeout(detachFxTimer);
|
||
detachFxTimer = setTimeout(() => {
|
||
tip.removeAttribute("data-detach-just");
|
||
detachFxTimer = null;
|
||
}, 260);
|
||
}
|
||
|
||
function clearTimers() {
|
||
if (showTimer) clearTimeout(showTimer);
|
||
if (hideTimer) clearTimeout(hideTimer);
|
||
showTimer = null;
|
||
hideTimer = null;
|
||
}
|
||
|
||
function scheduleHide() {
|
||
if (isDetached) return;
|
||
if (hideTimer) clearTimeout(hideTimer);
|
||
hideTimer = setTimeout(() => {
|
||
activeLink = null;
|
||
activeKey = null;
|
||
closeTip();
|
||
}, HIDE_DELAY_MS);
|
||
}
|
||
|
||
// Keep open when hovering tooltip itself
|
||
tip.addEventListener("pointerenter", () => {
|
||
if (isDetached) return;
|
||
if (hideTimer) clearTimeout(hideTimer);
|
||
hideTimer = null;
|
||
});
|
||
tip.addEventListener("pointerleave", () => {
|
||
if (isDetached) return;
|
||
scheduleHide();
|
||
});
|
||
|
||
function clampTipPosition(x, y) {
|
||
const vw = window.innerWidth;
|
||
const vh = window.innerHeight;
|
||
const rect = tip.getBoundingClientRect();
|
||
const w = rect.width || MAX_WIDTH_PX;
|
||
const h = rect.height || 220;
|
||
return {
|
||
x: clamp(x, 8, vw - w - 8),
|
||
y: clamp(y, 8, vh - h - 8),
|
||
};
|
||
}
|
||
|
||
function onDragMove(ev) {
|
||
if (!isDragging || !isDetached) return;
|
||
const next = clampTipPosition(ev.clientX - dragOffsetX, ev.clientY - dragOffsetY);
|
||
tip.style.left = `${next.x}px`;
|
||
tip.style.top = `${next.y}px`;
|
||
}
|
||
|
||
function onDragEnd() {
|
||
if (!isDragging) return;
|
||
isDragging = false;
|
||
document.removeEventListener("pointermove", onDragMove, true);
|
||
document.removeEventListener("pointerup", onDragEnd, true);
|
||
document.removeEventListener("pointercancel", onDragEnd, true);
|
||
}
|
||
|
||
function setupDragHandlers() {
|
||
const header = tip.querySelector(".xttip-header");
|
||
if (!header) return;
|
||
header.addEventListener("pointerdown", (ev) => {
|
||
if (!isDetached) return;
|
||
const interactive = ev.target.closest("a,button");
|
||
if (interactive) return;
|
||
const rect = tip.getBoundingClientRect();
|
||
dragOffsetX = ev.clientX - rect.left;
|
||
dragOffsetY = ev.clientY - rect.top;
|
||
isDragging = true;
|
||
document.addEventListener("pointermove", onDragMove, true);
|
||
document.addEventListener("pointerup", onDragEnd, true);
|
||
document.addEventListener("pointercancel", onDragEnd, true);
|
||
ev.preventDefault();
|
||
});
|
||
}
|
||
|
||
document.addEventListener(
|
||
"pointerenter",
|
||
(ev) => {
|
||
if (isDetached) return;
|
||
const link = closestLink(ev.target);
|
||
if (!link) return;
|
||
|
||
const href = link.getAttribute("href");
|
||
if (!href) return;
|
||
|
||
const abs = new URL(href, location.href).toString();
|
||
if (!isStatusUrl(abs)) return;
|
||
|
||
// Already on this link
|
||
if (activeLink === link) return;
|
||
|
||
clearTimers();
|
||
|
||
activeLink = link;
|
||
lastEv = ev;
|
||
|
||
const theme = detectTheme();
|
||
|
||
showTimer = setTimeout(async () => {
|
||
if (activeLink !== link) return;
|
||
|
||
openTip();
|
||
positionTipFromEvent(lastEv);
|
||
|
||
// Loading skeleton (with header already)
|
||
setTipSkeleton(abs, theme, "Loading…");
|
||
|
||
try {
|
||
const { normalized, html } = await fetchOEmbed(abs, theme);
|
||
const key = `${normalized}|${theme}`;
|
||
|
||
// If user moved away while fetching, ignore.
|
||
if (activeLink !== link) return;
|
||
|
||
activeKey = key;
|
||
setTipEmbed(abs, theme, html);
|
||
positionTipFromEvent(lastEv);
|
||
} catch (err) {
|
||
if (activeLink !== link) return;
|
||
setTipSkeleton(abs, theme, `Failed to load preview: ${String(err.message || err)}`);
|
||
positionTipFromEvent(lastEv);
|
||
}
|
||
}, SHOW_DELAY_MS);
|
||
},
|
||
true
|
||
);
|
||
|
||
document.addEventListener(
|
||
"pointerleave",
|
||
(ev) => {
|
||
const link = closestLink(ev.target);
|
||
if (!link) return;
|
||
if (link !== activeLink) return;
|
||
if (isDetached) return;
|
||
|
||
// If leaving link INTO tooltip, do not hide.
|
||
const toEl = ev.relatedTarget;
|
||
if (toEl && tip.contains(toEl)) return;
|
||
|
||
clearTimers();
|
||
scheduleHide();
|
||
},
|
||
true
|
||
);
|
||
|
||
// Follow cursor while hovering the active link.
|
||
document.addEventListener(
|
||
"pointermove",
|
||
(ev) => {
|
||
if (!activeLink) return;
|
||
if (isDetached) return;
|
||
const link = closestLink(ev.target);
|
||
if (link !== activeLink) return;
|
||
|
||
lastEv = ev;
|
||
if (tip.style.display === "block") positionTipFromEvent(ev);
|
||
},
|
||
true
|
||
);
|
||
|
||
// Escape closes
|
||
window.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape" && tip.style.display === "block") {
|
||
activeLink = null;
|
||
activeKey = null;
|
||
isDetached = false;
|
||
tip.removeAttribute("data-detached");
|
||
closeTip();
|
||
}
|
||
if (e.key === " " && tip.style.display === "block") {
|
||
const t = e.target;
|
||
if (
|
||
t instanceof HTMLElement &&
|
||
(t.isContentEditable || /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(t.tagName))
|
||
) {
|
||
return;
|
||
}
|
||
e.preventDefault();
|
||
if (!isDetached) {
|
||
isDetached = true;
|
||
clearTimers();
|
||
tip.setAttribute("data-detached", "1");
|
||
playDetachEffect();
|
||
activeLink = null;
|
||
activeKey = null;
|
||
updateDetachHint();
|
||
} else {
|
||
isDetached = false;
|
||
tip.removeAttribute("data-detached");
|
||
activeLink = null;
|
||
activeKey = null;
|
||
closeTip();
|
||
}
|
||
}
|
||
});
|
||
})();
|