Files
userscripts/twitter-link-preview/twitter-preview.js
2026-02-25 19:39:03 -08:00

641 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==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();
}
}
});
})();