您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Add Twitter/Mastodon-style keyboard shortcuts to Bluesky
// ==UserScript== // @name Bluesky Keyboard Shortcuts // @namespace http://tampermonkey.net/ // @version 1.3 // @description Add Twitter/Mastodon-style keyboard shortcuts to Bluesky // @author You // @match https://bsky.app/* // @match https://deer.social/* // @grant none // @run-at document-end // ==/UserScript== (function () { "use strict"; // Add CSS styles const css = ` .shortcuts-modal { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.4); display: flex; align-items: center; justify-content: center; z-index: 10000; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } .shortcuts-content { background: white; border-radius: 16px; padding: 24px; max-width: 600px; width: 90%; max-height: 80vh; overflow-y: auto; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); } [data-colormode="dark"] .shortcuts-content { background: #16181c; color: #e7e9ea; } .shortcuts-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; padding-bottom: 16px; border-bottom: 1px solid #eff3f4; } [data-colormode="dark"] .shortcuts-header { border-bottom-color: #2f3336; } .shortcuts-title { font-size: 20px; font-weight: 700; margin: 0; } .shortcuts-close { background: none; border: none; font-size: 24px; cursor: pointer; padding: 8px; border-radius: 50%; color: #536471; transition: background-color 0.2s; } .shortcuts-close:hover { background: rgba(15, 20, 25, 0.1); } [data-colormode="dark"] .shortcuts-close:hover { background: rgba(231, 233, 234, 0.1); } .shortcuts-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; } @media (max-width: 640px) { .shortcuts-grid { grid-template-columns: 1fr; } } .shortcuts-section { margin-bottom: 24px; } .shortcuts-section h3 { font-size: 16px; font-weight: 700; margin: 0 0 12px 0; color: #0f1419; } [data-colormode="dark"] .shortcuts-section h3 { color: #e7e9ea; } .shortcut-item { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; } .shortcut-description { font-size: 15px; color: #536471; } .shortcut-keys { display: flex; gap: 4px; } .shortcut-key { background: #f7f9fa; border: 1px solid #cfd9de; border-radius: 4px; padding: 2px 6px; font-size: 12px; font-weight: 600; font-family: monospace; color: #0f1419; } [data-colormode="dark"] .shortcut-key { background: #202327; border-color: #2f3336; color: #e7e9ea; } .shortcuts-footer { margin-top: 24px; padding-top: 16px; border-top: 1px solid #eff3f4; text-align: center; font-size: 14px; color: #536471; } [data-colormode="dark"] .shortcuts-footer { border-top-color: #2f3336; } .shortcuts-notification { position: fixed; top: 20px; right: 20px; background: #1d9bf0; color: white; padding: 12px 16px; border-radius: 8px; font-size: 14px; font-weight: 500; z-index: 10001; opacity: 0; transform: translateY(-10px); transition: all 0.3s ease; } .shortcuts-notification.show { opacity: 1; transform: translateY(0); } .shortcuts-notification.error { background: #f91880; } [data-colormode="dark"] .shortcuts-notification { background: #1d9bf0; } [data-colormode="dark"] .shortcuts-notification.error { background: #f91880; } `; // Inject CSS const styleSheet = document.createElement("style"); styleSheet.textContent = css; document.head.appendChild(styleSheet); class BlueskyKeyboardShortcuts { constructor() { this.gPressed = false; this.gTimeout = null; this.failureCount = {}; this.currentFeedIndex = 0; this.shortcuts = { navigation: { "g+h": { action: "home", description: "Go to Home" }, "g+e": { action: "explore", description: "Go to Explore" }, "g+n": { action: "notifications", description: "Go to Notifications" }, "g+c": { action: "chat", description: "Go to Chat" }, "g+f": { action: "feeds", description: "Go to Feeds" }, "g+l": { action: "lists", description: "Go to Lists" }, "g+p": { action: "profile", description: "Go to Profile" }, "g+s": { action: "settings", description: "Go to Settings" }, }, feeds: { "alt+↓": { action: "nextFeed", description: "Next feed" }, "alt+↑": { action: "prevFeed", description: "Previous feed" }, }, general: { "/": { action: "search", description: "Focus search" }, "shift+?": { action: "help", description: "Show keyboard shortcuts", }, "escape": { action: "closeModal", description: "Close modal" }, }, }; // Add numbered feed shortcuts for (let i = 1; i <= 9; i++) { this.shortcuts.feeds[i.toString()] = { action: "feed", feedIndex: i - 1, description: `Go to feed ${i}`, }; } this.init(); } init() { this.bindEvents(); this.createHelpModal(); console.log("Bluesky Keyboard Shortcuts loaded! Press Shift+? for help"); } bindEvents() { document.addEventListener("keydown", (e) => this.handleKeyDown(e), true); document.addEventListener("keyup", (e) => this.handleKeyUp(e)); } isTyping() { const activeElement = document.activeElement; if (!activeElement) return false; return ( activeElement.tagName === "INPUT" || activeElement.tagName === "TEXTAREA" || activeElement.contentEditable === "true" || activeElement.closest('[contenteditable="true"]') || activeElement.closest('[data-testid="textInput"]') || activeElement.closest('[data-testid="composer"]') || activeElement.closest('[aria-multiline="true"]') || activeElement.closest('textarea') || activeElement.closest('input[type="text"]') || activeElement.closest('input[type="search"]') ); } showNotification(message, isError = false) { const notification = document.createElement("div"); notification.className = `shortcuts-notification ${isError ? 'error' : ''}`; notification.textContent = message; document.body.appendChild(notification); // Trigger animation setTimeout(() => notification.classList.add("show"), 10); // Remove after 2 seconds setTimeout(() => { notification.classList.remove("show"); setTimeout(() => notification.remove(), 300); }, 2000); } trackFailure(action) { this.failureCount[action] = (this.failureCount[action] || 0) + 1; if (this.failureCount[action] >= 10) { this.showNotification(`${action} shortcut appears to be broken`, true); this.failureCount[action] = 0; // Reset counter } } handleKeyDown(e) { // Always allow escape to close modals if (e.key === "Escape") { e.preventDefault(); this.closeHelp(); return; } // Handle Shift + ? for help (works even when typing) if (e.shiftKey && e.key === "?") { e.preventDefault(); this.showHelp(); return; } // Handle / for search (only when not typing) if (e.key === "/" && !e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey && !this.isTyping()) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); this.focusSearch(); return; } // Skip other shortcuts if typing if (this.isTyping()) { return; } // Handle Alt + Arrow keys for feed navigation if (e.altKey && (e.key === "ArrowUp" || e.key === "ArrowDown")) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); if (e.key === "ArrowDown") { this.navigateToNextFeed(); } else { this.navigateToPrevFeed(); } return; } // Handle G key sequences if (e.key.toLowerCase() === "g" && !e.ctrlKey && !e.altKey && !e.metaKey) { e.preventDefault(); this.gPressed = true; // Clear any existing timeout if (this.gTimeout) { clearTimeout(this.gTimeout); } // Reset after 1 second this.gTimeout = setTimeout(() => { this.gPressed = false; this.gTimeout = null; }, 1000); return; } // Handle G + letter combinations if (this.gPressed && !e.ctrlKey && !e.altKey && !e.metaKey) { const combination = `g+${e.key.toLowerCase()}`; if (this.shortcuts.navigation[combination]) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); this.executeAction(this.shortcuts.navigation[combination]); this.gPressed = false; if (this.gTimeout) { clearTimeout(this.gTimeout); this.gTimeout = null; } return; } } // Handle number keys for feeds (only when not typing) if (/^[1-9]$/.test(e.key) && !e.ctrlKey && !e.altKey && !e.metaKey && !this.gPressed) { const shortcut = this.shortcuts.feeds[e.key]; if (shortcut) { e.preventDefault(); this.executeAction(shortcut); return; } } } handleKeyUp(e) { // Additional key up handling if needed } executeAction(shortcut) { switch (shortcut.action) { case "home": this.navigateTo("/", "home"); break; case "explore": this.navigateTo("/search", "explore"); break; case "notifications": this.navigateTo("/notifications", "notifications"); break; case "chat": this.navigateTo("/messages", "chat"); break; case "feeds": this.navigateTo("/feeds", "feeds"); break; case "lists": this.navigateTo("/lists", "lists"); break; case "profile": this.navigateToProfile(); break; case "settings": this.navigateTo("/settings", "settings"); break; case "feed": this.navigateToFeed(shortcut.feedIndex); break; case "nextFeed": this.navigateToNextFeed(); break; case "prevFeed": this.navigateToPrevFeed(); break; case "search": this.focusSearch(); break; case "help": this.showHelp(); break; case "closeModal": this.closeHelp(); break; } } focusSearch() { try { // Look for search input in various locations const searchSelectors = [ 'input[placeholder*="Search" i]', 'input[aria-label*="Search" i]', 'input[type="search"]', '[data-testid="searchInput"]', '[data-testid="searchTextInput"]', 'input[name*="search" i]', 'input[id*="search" i]', // Look in header/navigation areas 'header input[type="text"]', 'nav input[type="text"]', // Generic text inputs that might be search 'input[type="text"]:not([data-testid*="composer"]):not([aria-label*="post" i])', ]; for (const selector of searchSelectors) { const searchInput = document.querySelector(selector); if (searchInput && searchInput.offsetParent !== null) { // Check if visible searchInput.focus(); searchInput.select(); return; } } // If no search input found, try to navigate to search page first this.navigateTo("/search", "search"); // Then try to focus search input after a brief delay setTimeout(() => { for (const selector of searchSelectors) { const searchInput = document.querySelector(selector); if (searchInput && searchInput.offsetParent !== null) { searchInput.focus(); searchInput.select(); return; } } }, 200); } catch (error) { console.log("Search focus error:", error); this.trackFailure("search"); } } navigateTo(path, action) { try { // Look for navigation links in the sidebar const navSelectors = [ `nav a[href="${path}"]`, `a[href="${path}"]`, `a[href$="${path}"]`, `[data-testid="homeTab"] a`, `[data-testid="searchTab"] a`, `[data-testid="notificationsTab"] a`, `[data-testid="messagesTab"] a`, `[data-testid="feedsTab"] a`, `[data-testid="listsTab"] a`, `[data-testid="settingsTab"] a`, ]; for (const selector of navSelectors) { const link = document.querySelector(selector); if (link && (link.href.endsWith(path) || link.href.includes(path))) { link.click(); return; } } // Fallback: use history API for SPA navigation if (window.history && window.history.pushState) { const newUrl = window.location.origin + path; window.history.pushState({}, "", newUrl); window.dispatchEvent(new PopStateEvent("popstate")); return; } // If we get here, navigation failed this.trackFailure(action); } catch (error) { console.log("Navigation error:", error); this.trackFailure(action); } } navigateToProfile() { try { // Look for the user's own profile link in the sidebar const profileSelectors = [ 'nav a[href*="/profile/"][aria-label*="Profile"]', 'nav a[href*="/profile/"] img[alt*="avatar"]', '[data-testid="profileTab"] a', 'nav a[href*="/profile/"]:not([href*="/profile/undefined"])', ]; // Try to find the user's own profile by looking at the current user context const userMenuButton = document.querySelector('[data-testid="userMenu"], [aria-label*="Account menu"], button[aria-haspopup="menu"]'); if (userMenuButton) { const userHandle = this.getCurrentUserHandle(); if (userHandle) { const userProfileLink = document.querySelector(`a[href="/profile/${userHandle}"], a[href$="/profile/${userHandle}"]`); if (userProfileLink) { userProfileLink.click(); return; } } } // Try the profile selectors for (const selector of profileSelectors) { const profileLink = document.querySelector(selector); if (profileLink) { profileLink.click(); return; } } // Fallback: construct profile URL from user handle const userHandle = this.getCurrentUserHandle(); if (userHandle) { this.navigateTo(`/profile/${userHandle}`, "profile"); return; } this.trackFailure("profile"); } catch (error) { console.log("Profile navigation error:", error); this.trackFailure("profile"); } } getFeedLinks() { // Look for feeds in the right sidebar or main feed area const feedSelectors = [ // Right sidebar feed links 'aside a[href*="/feed/"]', '[data-testid="rightColumn"] a[href*="/feed/"]', // Feed tabs or buttons in the main area '[role="tablist"] a[href*="/feed/"]', '[role="tablist"] button[data-testid*="feed"]', // My Feeds or pinned feeds section '[data-testid="myFeeds"] a', '[data-testid="savedFeeds"] a', '[data-testid="pinnedFeeds"] a', // General feed links in sidebars 'nav a[href*="/feed/"]', // Feed selector dropdown or menu '[data-testid="feedSelector"] a', '[aria-label*="feed" i] a[href*="/feed/"]', // Look for "Following", "Discover", etc. tabs '[role="tab"][href*="/feed/"]', 'button[role="tab"][data-testid*="feed"]', ]; let feedLinks = []; for (const selector of feedSelectors) { const links = document.querySelectorAll(selector); if (links.length > 0) { const validLinks = Array.from(links).filter(link => { const href = link.href || link.getAttribute('data-href') || ''; return href.includes('/feed/') && !href.includes('undefined') && href !== window.location.href; }); if (validLinks.length > 0) { feedLinks = validLinks; break; } } } // If no feed links found, look for tab-like elements or buttons if (feedLinks.length === 0) { const tabSelectors = [ '[role="tablist"] [role="tab"]', '[role="tablist"] button', '[data-testid*="tab"]', 'nav button[data-testid*="feed"]', ]; for (const selector of tabSelectors) { const tabs = document.querySelectorAll(selector); if (tabs.length > 0) { feedLinks = Array.from(tabs); break; } } } return feedLinks; } navigateToFeed(index) { try { const feedLinks = this.getFeedLinks(); if (feedLinks[index]) { feedLinks[index].click(); this.currentFeedIndex = index; } else { this.navigateTo("/feeds", `feed-${index + 1}`); } } catch (error) { console.log("Feed navigation error:", error); this.trackFailure(`feed-${index + 1}`); } } navigateToNextFeed() { try { const feedLinks = this.getFeedLinks(); if (feedLinks.length === 0) { this.trackFailure("nextFeed"); return; } this.currentFeedIndex = (this.currentFeedIndex + 1) % feedLinks.length; feedLinks[this.currentFeedIndex].click(); } catch (error) { console.log("Next feed navigation error:", error); this.trackFailure("nextFeed"); } } navigateToPrevFeed() { try { const feedLinks = this.getFeedLinks(); if (feedLinks.length === 0) { this.trackFailure("prevFeed"); return; } this.currentFeedIndex = this.currentFeedIndex <= 0 ? feedLinks.length - 1 : this.currentFeedIndex - 1; feedLinks[this.currentFeedIndex].click(); } catch (error) { console.log("Previous feed navigation error:", error); this.trackFailure("prevFeed"); } } getCurrentUserHandle() { try { const handleSelectors = [ '[data-testid="userMenu"] [data-testid="handle"]', '[aria-label*="Account menu"] + * [data-testid="handle"]', 'nav [data-testid="handle"]', '[data-testid="profileTab"] a', ]; for (const selector of handleSelectors) { const element = document.querySelector(selector); if (element) { if (element.href && element.href.includes('/profile/')) { const match = element.href.match(/\/profile\/(.+?)(?:\?|$)/); if (match) return match[1]; } const text = element.textContent; if (text && text.startsWith('@')) { return text.substring(1); } } } if (window.location.pathname.includes('/profile/')) { const match = window.location.pathname.match(/\/profile\/(.+?)(?:\/|$)/); if (match) return match[1]; } return null; } catch (error) { console.log("Error getting user handle:", error); return null; } } createHelpModal() { const modal = document.createElement("div"); modal.className = "shortcuts-modal"; modal.style.display = "none"; modal.id = "keyboard-shortcuts-modal"; modal.innerHTML = ` <div class="shortcuts-content"> <div class="shortcuts-header"> <h2 class="shortcuts-title">Keyboard shortcuts</h2> <button class="shortcuts-close" aria-label="Close">×</button> </div> <div class="shortcuts-grid"> <div class="shortcuts-section"> <h3>Navigation</h3> ${Object.entries(this.shortcuts.navigation) .map( ([key, shortcut]) => ` <div class="shortcut-item"> <span class="shortcut-description">${shortcut.description}</span> <div class="shortcut-keys"> ${this.formatShortcutKey(key)} </div> </div> ` ) .join("")} <div class="shortcut-item"> <span class="shortcut-description">Focus search</span> <div class="shortcut-keys"> <span class="shortcut-key">/</span> </div> </div> </div> <div class="shortcuts-section"> <h3>Feeds</h3> <div class="shortcut-item"> <span class="shortcut-description">Next feed</span> <div class="shortcut-keys"> <span class="shortcut-key">Alt</span> <span class="shortcut-key">↓</span> </div> </div> <div class="shortcut-item"> <span class="shortcut-description">Previous feed</span> <div class="shortcut-keys"> <span class="shortcut-key">Alt</span> <span class="shortcut-key">↑</span> </div> </div> ${Object.entries(this.shortcuts.feeds) .filter(([key]) => /^[1-9]$/.test(key)) .slice(0, 5) .map( ([key, shortcut]) => ` <div class="shortcut-item"> <span class="shortcut-description">${shortcut.description}</span> <div class="shortcut-keys"> <span class="shortcut-key">${key}</span> </div> </div> ` ) .join("")} ${ Object.keys(this.shortcuts.feeds).filter(k => /^[1-9]$/.test(k)).length > 5 ? `<div class="shortcut-item"> <span class="shortcut-description">And more...</span> <div class="shortcut-keys"> <span class="shortcut-key">6-9</span> </div> </div>` : "" } </div> </div> <div class="shortcuts-footer"> Press <strong>Shift + ?</strong> to toggle this help </div> </div> `; // Add click handlers modal.addEventListener("click", (e) => { if (e.target === modal) { this.closeHelp(); } }); modal .querySelector(".shortcuts-close") .addEventListener("click", () => this.closeHelp()); document.body.appendChild(modal); } formatShortcutKey(key) { if (key === "shift+?") { return ` <span class="shortcut-key">Shift</span> <span class="shortcut-key">?</span> `; } if (key.includes("+")) { return key .split("+") .map((k) => `<span class="shortcut-key">${k.toUpperCase()}</span>`) .join(""); } return `<span class="shortcut-key">${key.toUpperCase()}</span>`; } showHelp() { const modal = document.getElementById("keyboard-shortcuts-modal"); if (modal) { modal.style.display = "flex"; document.body.style.overflow = "hidden"; } } closeHelp() { const modal = document.getElementById("keyboard-shortcuts-modal"); if (modal) { modal.style.display = "none"; document.body.style.overflow = ""; } } } // Initialize when DOM is ready function initShortcuts() { if (document.querySelector('#keyboard-shortcuts-modal')) { return; // Already initialized } new BlueskyKeyboardShortcuts(); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", initShortcuts); } else { initShortcuts(); } // Handle SPA navigation - reinitialize if needed let lastUrl = location.href; new MutationObserver(() => { const url = location.href; if (url !== lastUrl) { lastUrl = url; setTimeout(() => { if (!document.querySelector('#keyboard-shortcuts-modal')) { initShortcuts(); } }, 100); } }).observe(document, { subtree: true, childList: true }); })();