mirror of
https://github.com/turtlebasket/userscripts.git
synced 2026-03-04 11:34:41 -08:00
twitter status preview
This commit is contained in:
4
twitter-link-preview/README.md
Normal file
4
twitter-link-preview/README.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# Twitter Status Preview
|
||||
|
||||
Hover to preview tweet content (detachable modal) so you don't have to fully open x dot com the everything app
|
||||
|
||||
640
twitter-link-preview/twitter-preview.js
Normal file
640
twitter-link-preview/twitter-preview.js
Normal file
@@ -0,0 +1,640 @@
|
||||
// ==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();
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user