diff --git a/README.md b/README.md index 02560d4..ecb707c 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,12 @@ **Better GitHub Navigation** 是一款给 GitHub 补效率的 Tampermonkey(油猴)脚本。 -如果你经常在 GitHub 里来回切 `Dashboard`、`Explore`、`Trending`、`Stars`,或者总要去首页左侧翻那几个最常开的仓库,这个脚本就是拿来解决这些问题的。它会把常用入口放到更顺手的位置,让你少找一步、少点一下、少绕一圈。 +如果你经常在 GitHub 里来回切 `Dashboard`、`Explore`、`Trending`、`Stars`,或者总要去首页左侧翻那几个最常开的仓库,这个脚本就是拿来解决这些问题的。它会把常用入口放到更顺手的位置,让你少找一步、少点一下、少绕一圈;在窄屏、平板和移动端场景下,也能保持头部清爽,不会因为快捷入口太多而显得拥挤。 ### 🚀 核心功能 - **常用页面一键直达**:把 `Dashboard`、`Explore`、`Trending`、`Collections`、`Stars` 这些高频入口补到顶部导航,少绕路。 - **快捷入口按你习惯来**:想显示哪些、放什么顺序,都可以自己调整,导航栏终于能配合你的工作流。 +- **窄屏和移动端也顺手**:窗口变窄时,常用入口依然好找好点,小屏浏览 GitHub 也不会被拥挤的头部打断节奏。 - **常用仓库固定在手边**:GitHub 首页左侧 `Top repositories` 支持一键置顶,把你真正常开的仓库留在最前面。 - **展开后也继续可用**:点开 `Show more` 之后,新显示出来的仓库也一样可以直接置顶。 - **熟悉的 GitHub 感还在**:脚本只帮你把常用内容提到更近的位置,不会把整套使用习惯打乱。 @@ -36,11 +37,12 @@ GitHub 首页增强, GitHub 导航增强, Top repositories 置顶, 常用仓库 **Better GitHub Navigation** is a Tampermonkey userscript built to make everyday GitHub navigation faster. -If you keep jumping between `Dashboard`, `Explore`, `Trending`, `Stars`, and a handful of repositories you open all the time, this script brings those places closer. It adds better shortcuts where you already look and keeps your favorite repos easy to reach from the home page. +If you keep jumping between `Dashboard`, `Explore`, `Trending`, `Stars`, and a handful of repositories you open all the time, this script brings those places closer. It adds better shortcuts where you already look, keeps your favorite repos easy to reach from the home page, and still feels tidy when you are browsing GitHub on narrower screens. ### 🚀 Key Features - **Jump to the pages you use most**: Add quick access to `Dashboard`, `Explore`, `Trending`, `Collections`, and `Stars` right in the header. - **Shape the header around your workflow**: Keep only the shortcuts you want and arrange them in the order that makes sense for you. +- **Comfortable on narrow screens too**: Your shortcuts stay easy to reach without making the header feel crowded on tablets, split-screen windows, or mobile browsing setups. - **Pin the repos you actually use**: The home-page `Top repositories` list gets one-click pinning, so your real favorites stay at the top. - **Still works after `Show more`**: Expand the list and newly revealed repositories can be pinned too. - **Feels like GitHub, just more convenient**: The script helps you surface what matters without making GitHub feel unfamiliar. diff --git a/better-github-nav.user.js b/better-github-nav.user.js index 483d38e..6dc6b31 100644 --- a/better-github-nav.user.js +++ b/better-github-nav.user.js @@ -2,9 +2,9 @@ // @name Better GitHub Navigation // @name:zh-CN 更好的 GitHub 导航栏 // @namespace https://github.com/ImXiangYu/better-github-nav -// @version 0.1.41 -// @description Add Dashboard, Trending, Explore, Collections, and Stars shortcuts to the top navigation bar for one-tap access to frequently used pages. Pin your most-used repositories to convenient locations. -// @description:zh-CN 顶部导航中加入 Dashboard、Trending、Explore、Collections、Stars 快捷入口,常用页面一键直达。并把你最常用的仓库固定在顺手的位置。 +// @version 0.1.44 +// @description Bring Dashboard, Trending, Explore, Collections, and Stars closer on desktop and narrow screens, and keep your most-used repositories pinned where they are easiest to reach. +// @description:zh-CN 在桌面端和窄屏场景下,把 Dashboard、Trending、Explore、Collections、Stars 放到更顺手的位置,并把你最常用的仓库固定在最容易到达的地方。 // @author Ayubass // @license MIT // @match https://github.com/* @@ -16,11 +16,12 @@ (() => { // src/constants.js - var SCRIPT_VERSION = "0.1.41"; + var SCRIPT_VERSION = "0.1.44"; var CUSTOM_BUTTON_CLASS = "custom-gh-nav-btn"; var CUSTOM_BUTTON_ACTIVE_CLASS = "custom-gh-nav-btn-active"; var CUSTOM_BUTTON_COMPACT_CLASS = "custom-gh-nav-btn-compact"; var QUICK_LINK_MARK_ATTR = "data-better-gh-nav-quick-link"; + var RESPONSIVE_TOGGLE_MARK_ATTR = "data-better-gh-nav-overflow-toggle"; var CONFIG_STORAGE_KEY = "better-gh-nav-config-v1"; var TOP_REPOSITORIES_PIN_STORAGE_KEY = "better-gh-nav-top-repositories-pins-v1"; var UI_LANG_STORAGE_KEY = "better-gh-nav-ui-lang-v1"; @@ -57,6 +58,9 @@ saveAndRefresh: "保存并刷新", restoredPendingSave: "已恢复默认,点击保存后生效。", atLeastOneLink: "至少保留 1 个快捷链接。", + openQuickLinksMenu: "展开快捷链接", + closeQuickLinksMenu: "收起快捷链接", + quickLinksMenu: "快捷链接", dragHandleTitle: "拖动调整顺序", dragRowTitle: "拖动整行调整顺序", pinTopRepository: "置顶仓库:{repo}", @@ -76,6 +80,9 @@ saveAndRefresh: "Save and Refresh", restoredPendingSave: "Defaults restored. Click save to apply.", atLeastOneLink: "Keep at least 1 quick link.", + openQuickLinksMenu: "Show quick links", + closeQuickLinksMenu: "Hide quick links", + quickLinksMenu: "Quick links", dragHandleTitle: "Drag to reorder", dragRowTitle: "Drag row to reorder", pinTopRepository: "Pin repository: {repo}", @@ -137,6 +144,35 @@ return link ? link.text : key; } + // src/i18n.js + var uiLang = detectUiLang(); + function t(key, vars = {}) { + const dict = I18N[uiLang] || I18N.en; + const fallback = I18N.en; + const template = dict[key] || fallback[key] || key; + return template.replace(/\{(\w+)\}/g, (_, varName) => String(vars[varName] ?? "")); + } + function detectUiLang() { + try { + const preferredLang = (localStorage.getItem(UI_LANG_STORAGE_KEY) || "").toLowerCase(); + if (preferredLang === "zh" || preferredLang === "en") return preferredLang; + } catch (e) { + } + const autoLang = (document.documentElement.lang || navigator.language || "").toLowerCase(); + return autoLang.startsWith("zh") ? "zh" : "en"; + } + function setUiLangPreference(lang) { + try { + if (lang === "zh" || lang === "en") { + localStorage.setItem(UI_LANG_STORAGE_KEY, lang); + } else { + localStorage.removeItem(UI_LANG_STORAGE_KEY); + } + } catch (e) { + } + uiLang = detectUiLang(); + } + // src/styles.js function ensureStyles() { if (document.getElementById("custom-gh-nav-style")) return; @@ -167,6 +203,105 @@ background-color: var(--color-neutral-muted, rgba(177, 186, 196, 0.18)); font-weight: 600; } + .custom-gh-nav-overflow-host { + position: relative; + display: inline-flex; + align-items: center; + list-style: none; + } + .custom-gh-nav-overflow-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + min-width: 28px; + min-height: 28px; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: var(--color-fg-default, #1f2328); + font: inherit; + font-weight: 600; + line-height: 1; + cursor: pointer; + } + .custom-gh-nav-overflow-toggle:hover, + .custom-gh-nav-overflow-toggle[aria-expanded="true"] { + background-color: var(--color-neutral-muted, rgba(177, 186, 196, 0.12)); + } + .custom-gh-nav-overflow-toggle:focus-visible { + outline: 2px solid var(--color-accent-fg, #0969da); + outline-offset: 1px; + } + .custom-gh-nav-overflow-toggle-icon { + flex: 0 0 auto; + transition: transform 120ms ease; + } + .custom-gh-nav-overflow-toggle[aria-expanded="true"] .custom-gh-nav-overflow-toggle-icon { + transform: rotate(180deg); + } + .custom-gh-nav-overflow-menu { + position: absolute; + top: calc(100% + 8px); + left: 0; + z-index: 2147483646; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 220px; + max-width: min(280px, calc(100vw - 16px)); + padding: 6px; + border: 1px solid var(--color-border-default, #d1d9e0); + border-radius: 12px; + background: var(--color-canvas-default, #fff); + box-shadow: var(--color-shadow-large, 0 16px 32px rgba(0, 0, 0, 0.16)); + box-sizing: border-box; + } + .custom-gh-nav-overflow-menu[hidden] { + display: none !important; + } + .custom-gh-nav-overflow-link { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 32px; + padding: 6px 10px; + border-radius: 8px; + color: var(--color-fg-default, #1f2328); + font-size: 13px; + font-weight: 600; + text-decoration: none; + } + .custom-gh-nav-overflow-link:hover { + background: var(--color-neutral-muted, rgba(177, 186, 196, 0.12)); + text-decoration: none; + } + .custom-gh-nav-overflow-link[aria-current="page"] { + color: var(--color-accent-fg, #0969da); + background: var(--color-accent-subtle, rgba(9, 105, 218, 0.08)); + } + .custom-gh-nav-overflow-link-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .custom-gh-nav-overflow-link-kbd { + flex: 0 0 auto; + margin: 0; + padding: 2px 6px; + border: none !important; + border-radius: 999px; + background: var(--color-neutral-muted, rgba(177, 186, 196, 0.18)) !important; + color: var(--color-fg-muted, #656d76); + box-shadow: none !important; + font: inherit; + font-size: 11px; + line-height: 1.2; + text-transform: uppercase; + } .custom-gh-nav-tooltip { position: fixed; z-index: 2147483647; @@ -410,6 +545,13 @@ margin: 6px 0; background: var(--color-border-muted, rgba(208, 215, 222, 0.8)); } + @media (max-width: 767px) { + .custom-gh-nav-overflow-menu { + left: auto; + right: 0; + min-width: min(240px, calc(100vw - 16px)); + } + } `; document.head.appendChild(style); } @@ -437,6 +579,8 @@ var hotkeyTooltipAnchor = null; var hotkeyTooltipGlobalBound = false; var hotkeyTooltipBoundAnchors = /* @__PURE__ */ new WeakSet(); + var responsiveQuickLinksState = null; + var responsiveQuickLinksGlobalBound = false; function normalizePath(href) { try { const url = new URL(href, location.origin); @@ -525,6 +669,257 @@ host.remove(); }); } + function insertNodeAfter(parent, node, referenceNode) { + if (!parent || !node || !referenceNode || referenceNode.parentNode !== parent) return; + const nextSibling = referenceNode.nextSibling; + if (node.parentNode === parent && node.previousSibling === referenceNode) return; + if (nextSibling === node) return; + parent.insertBefore(node, nextSibling); + } + function createOverflowChevronIcon() { + const ns = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(ns, "svg"); + svg.setAttribute("aria-hidden", "true"); + svg.setAttribute("focusable", "false"); + svg.setAttribute("viewBox", "0 0 16 16"); + svg.setAttribute("width", "16"); + svg.setAttribute("height", "16"); + svg.setAttribute("fill", "currentColor"); + svg.classList.add("custom-gh-nav-overflow-toggle-icon"); + const path = document.createElementNS(ns, "path"); + path.setAttribute( + "d", + "m4.427 7.427 3.396 3.396a.25.25 0 0 0 .354 0l3.396-3.396A.25.25 0 0 0 11.396 7H4.604a.25.25 0 0 0-.177.427Z" + ); + svg.appendChild(path); + return svg; + } + function createOverflowMenuLink(linkInfo) { + const link = document.createElement("a"); + link.className = "custom-gh-nav-overflow-link"; + link.href = linkInfo.href; + link.setAttribute("aria-label", linkInfo.text); + const text = document.createElement("span"); + text.className = "custom-gh-nav-overflow-link-text"; + text.textContent = linkInfo.text; + link.appendChild(text); + const hotkey = normalizeHotkeyValue(PRESET_LINK_SHORTCUTS[linkInfo.key]); + if (hotkey) { + const hint = document.createElement("kbd"); + hint.className = "custom-gh-nav-overflow-link-kbd"; + hint.textContent = hotkey.toUpperCase(); + hint.setAttribute("aria-hidden", "true"); + link.appendChild(hint); + } + if (isCurrentPage(linkInfo.path)) { + link.setAttribute("aria-current", "page"); + } + return link; + } + function updateResponsiveQuickLinksToggle(state) { + const label = state.menuOpen ? t("closeQuickLinksMenu") : t("openQuickLinksMenu"); + state.toggleButton.title = label; + state.toggleButton.setAttribute("aria-label", label); + state.toggleButton.setAttribute("aria-expanded", state.menuOpen ? "true" : "false"); + } + function positionResponsiveQuickLinksMenu(state) { + state.menuNode.style.left = "0"; + state.menuNode.style.right = "auto"; + const rect = state.menuNode.getBoundingClientRect(); + const viewportPadding = 8; + if (rect.right > window.innerWidth - viewportPadding) { + state.menuNode.style.left = "auto"; + state.menuNode.style.right = "0"; + } + if (state.menuNode.getBoundingClientRect().left < viewportPadding) { + state.menuNode.style.left = "0"; + state.menuNode.style.right = "auto"; + } + } + function closeResponsiveQuickLinksMenu() { + const state = responsiveQuickLinksState; + if (!state || !state.menuOpen) return; + hideHotkeyTooltip(); + state.menuOpen = false; + state.menuNode.hidden = true; + updateResponsiveQuickLinksToggle(state); + } + function toggleResponsiveQuickLinksMenu() { + const state = responsiveQuickLinksState; + if (!state || !state.isCollapsed) return; + hideHotkeyTooltip(); + state.menuOpen = !state.menuOpen; + state.menuNode.hidden = !state.menuOpen; + if (state.menuOpen) { + positionResponsiveQuickLinksMenu(state); + } + updateResponsiveQuickLinksToggle(state); + } + function restoreResponsiveInlineNodes(state) { + if (!state.inlineItems.length) return; + let insertAfter = state.referenceNode; + state.inlineItems.forEach((item) => { + insertNodeAfter(state.renderParent, item.hostNode, insertAfter); + insertAfter = item.hostNode; + }); + insertNodeAfter(state.renderParent, state.toggleHostNode, insertAfter); + } + function collapseResponsiveInlineNodes(state) { + state.inlineItems.forEach((item) => { + if (item.hostNode.parentNode) { + item.hostNode.remove(); + } + }); + insertNodeAfter(state.renderParent, state.toggleHostNode, state.referenceNode); + } + function needsResponsiveQuickLinksCollapse(state) { + const measureContainer = state.measureContainer; + const baselineRect = state.referenceNode.getBoundingClientRect(); + const containerRect = measureContainer.getBoundingClientRect(); + const containerRight = Math.min(containerRect.right, window.innerWidth - 8); + const wrapped = state.inlineItems.some((item) => { + if (!item.hostNode.isConnected) return false; + const rect = item.hostNode.getBoundingClientRect(); + if (rect.width <= 0 && rect.height <= 0) return false; + return Math.abs(rect.top - baselineRect.top) > 4; + }); + const overflowing = state.inlineItems.some((item) => { + if (!item.hostNode.isConnected) return false; + const rect = item.hostNode.getBoundingClientRect(); + if (rect.width <= 0 && rect.height <= 0) return false; + return rect.right > containerRight; + }); + const scrollOverflow = measureContainer.scrollWidth > measureContainer.clientWidth + 1 || state.renderParent.scrollWidth > state.renderParent.clientWidth + 1; + return wrapped || overflowing || scrollOverflow; + } + function syncResponsiveQuickLinksState(state) { + if (!state) return; + if (!state.renderParent.isConnected || !state.referenceNode.isConnected) { + destroyResponsiveQuickLinks(); + return; + } + hideHotkeyTooltip(); + closeResponsiveQuickLinksMenu(); + restoreResponsiveInlineNodes(state); + state.toggleHostNode.hidden = true; + const shouldCollapse = needsResponsiveQuickLinksCollapse(state); + if (shouldCollapse) { + collapseResponsiveInlineNodes(state); + state.isCollapsed = true; + state.toggleHostNode.hidden = false; + } else { + state.isCollapsed = false; + } + updateResponsiveQuickLinksToggle(state); + } + function scheduleResponsiveQuickLinksSync() { + const state = responsiveQuickLinksState; + if (!state || state.syncQueued) return; + state.syncQueued = true; + requestAnimationFrame(() => { + const latestState = responsiveQuickLinksState; + if (!latestState) return; + latestState.syncQueued = false; + syncResponsiveQuickLinksState(latestState); + }); + } + function destroyResponsiveQuickLinks() { + closeResponsiveQuickLinksMenu(); + if (responsiveQuickLinksState?.resizeObserver) { + responsiveQuickLinksState.resizeObserver.disconnect(); + } + if (responsiveQuickLinksState?.toggleHostNode?.isConnected) { + responsiveQuickLinksState.toggleHostNode.remove(); + } + responsiveQuickLinksState = null; + } + function bindResponsiveQuickLinksGlobalHandlers() { + if (responsiveQuickLinksGlobalBound) return; + responsiveQuickLinksGlobalBound = true; + window.addEventListener("resize", () => { + closeResponsiveQuickLinksMenu(); + scheduleResponsiveQuickLinksSync(); + }, { passive: true }); + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + closeResponsiveQuickLinksMenu(); + } + }, true); + document.addEventListener("pointerdown", (event) => { + const state = responsiveQuickLinksState; + if (!state || !state.menuOpen) return; + const target = event.target; + if (target && state.toggleHostNode.contains(target)) return; + closeResponsiveQuickLinksMenu(); + }, true); + } + function setupResponsiveQuickLinks({ + renderParent, + referenceNode, + inlineItems + }) { + destroyResponsiveQuickLinks(); + if (!inlineItems.length) return; + bindResponsiveQuickLinksGlobalHandlers(); + const hostTagName = inlineItems[0]?.hostNode?.tagName?.toLowerCase() || "div"; + const toggleHostNode = document.createElement(hostTagName === "li" ? "li" : "div"); + toggleHostNode.className = "custom-gh-nav-overflow-host"; + toggleHostNode.setAttribute(RESPONSIVE_TOGGLE_MARK_ATTR, "1"); + toggleHostNode.hidden = true; + const toggleButton = document.createElement("button"); + toggleButton.type = "button"; + toggleButton.className = "custom-gh-nav-overflow-toggle"; + toggleButton.setAttribute("aria-haspopup", "true"); + toggleButton.setAttribute("aria-expanded", "false"); + toggleButton.appendChild(createOverflowChevronIcon()); + const menuNode = document.createElement("nav"); + menuNode.id = "custom-gh-nav-overflow-menu"; + menuNode.className = "custom-gh-nav-overflow-menu"; + menuNode.setAttribute("aria-label", t("quickLinksMenu")); + menuNode.hidden = true; + toggleButton.setAttribute("aria-controls", menuNode.id); + inlineItems.forEach((item) => { + menuNode.appendChild(createOverflowMenuLink(item.linkInfo)); + }); + toggleButton.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + toggleResponsiveQuickLinksMenu(); + }); + menuNode.addEventListener("click", (event) => { + const link = event.target.closest("a[href]"); + if (!link) return; + closeResponsiveQuickLinksMenu(); + }); + toggleHostNode.appendChild(toggleButton); + toggleHostNode.appendChild(menuNode); + insertNodeAfter(renderParent, toggleHostNode, inlineItems[inlineItems.length - 1].hostNode || referenceNode); + const state = { + inlineItems, + isCollapsed: false, + measureContainer: renderParent.closest("nav") || renderParent, + menuNode, + menuOpen: false, + referenceNode, + renderParent, + resizeObserver: null, + syncQueued: false, + toggleButton, + toggleHostNode, + toggleLabelNode: null + }; + if (typeof ResizeObserver === "function") { + state.resizeObserver = new ResizeObserver(() => { + scheduleResponsiveQuickLinksSync(); + }); + state.resizeObserver.observe(renderParent); + if (state.measureContainer !== renderParent) { + state.resizeObserver.observe(state.measureContainer); + } + } + responsiveQuickLinksState = state; + syncResponsiveQuickLinksState(state); + } function normalizeHotkeyValue(value) { return String(value || "").trim().toLowerCase().replace(/\s+/g, " "); } @@ -721,6 +1116,7 @@ ); } function addCustomButtons() { + destroyResponsiveQuickLinks(); const userLoginMeta = document.querySelector('meta[name="user-login"]'); const username = userLoginMeta ? userLoginMeta.getAttribute("content") : ""; const navPresetLinks = getConfiguredLinks(username); @@ -848,6 +1244,7 @@ cleanupQuickLinksForContainer(insertAnchorNode.parentNode, insertAnchorNode); const hasShortcutActive = navPresetLinks.some((link) => isCurrentPage(link.path)); const renderedQuickAnchors = []; + const renderedQuickItems = []; if (isOnPresetPage && anchorTag && primaryLink) { anchorTag.id = primaryLink.id; anchorTag.setAttribute(QUICK_LINK_MARK_ATTR, "1"); @@ -886,38 +1283,19 @@ setActiveStyle(aTag, isCurrentPage(linkInfo.path), shouldUseCompactButtons); insertAfterNode.parentNode.insertBefore(newNode, insertAfterNode.nextSibling); insertAfterNode = newNode; + renderedQuickItems.push({ + anchor: aTag, + hostNode: newNode, + linkInfo + }); }); - reportHotkeyConflicts(renderedQuickAnchors); - } - } - - // src/i18n.js - var uiLang = detectUiLang(); - function t(key, vars = {}) { - const dict = I18N[uiLang] || I18N.en; - const fallback = I18N.en; - const template = dict[key] || fallback[key] || key; - return template.replace(/\{(\w+)\}/g, (_, varName) => String(vars[varName] ?? "")); - } - function detectUiLang() { - try { - const preferredLang = (localStorage.getItem(UI_LANG_STORAGE_KEY) || "").toLowerCase(); - if (preferredLang === "zh" || preferredLang === "en") return preferredLang; - } catch (e) { - } - const autoLang = (document.documentElement.lang || navigator.language || "").toLowerCase(); - return autoLang.startsWith("zh") ? "zh" : "en"; - } - function setUiLangPreference(lang) { - try { - if (lang === "zh" || lang === "en") { - localStorage.setItem(UI_LANG_STORAGE_KEY, lang); - } else { - localStorage.removeItem(UI_LANG_STORAGE_KEY); - } - } catch (e) { + setupResponsiveQuickLinks({ + inlineItems: renderedQuickItems, + referenceNode: insertAnchorNode, + renderParent: insertAnchorNode.parentNode + }); + reportHotkeyConflicts(renderedQuickAnchors.filter((anchor) => anchor.isConnected)); } - uiLang = detectUiLang(); } // src/settings-panel.js @@ -1493,7 +1871,10 @@ }); var observer = new MutationObserver(() => { const hasHeader = Boolean(document.querySelector("header")); - const missingNavButtons = hasHeader && !document.querySelector('[id^="custom-gh-btn-"]'); + const hasCustomNavUi = Boolean(document.querySelector( + '[id^="custom-gh-btn-"], [' + QUICK_LINK_MARK_ATTR + '="1"], [' + RESPONSIVE_TOGGLE_MARK_ATTR + '="1"]:not([hidden])' + )); + const missingNavButtons = hasHeader && !hasCustomNavUi; const missingTopRepoPins = isDashboardHomePage() && hasTopRepositoriesHeading() && needsTopRepositoriesEnhancement(); if (missingNavButtons || missingTopRepoPins) scheduleEnhancements(); }); diff --git a/package-lock.json b/package-lock.json index 930f70c..9b13766 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "better-github-nav", - "version": "0.1.41", + "version": "0.1.44", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "better-github-nav", - "version": "0.1.41", + "version": "0.1.44", "license": "MIT", "devDependencies": { "esbuild": "^0.27.3" diff --git a/package.json b/package.json index 4d94711..deefba4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "better-github-nav", - "version": "0.1.41", - "description": "Add Dashboard, Trending, Explore, Collections, and Stars shortcuts to the top navigation bar for one-tap access to frequently used pages. Pin your most-used repositories to convenient locations.", + "version": "0.1.44", + "description": "Bring Dashboard, Trending, Explore, Collections, and Stars closer on desktop and narrow screens, and keep your most-used repositories pinned where they are easiest to reach.", "private": true, "scripts": { "build": "node scripts/build.mjs", diff --git a/scripts/userscript-header.txt b/scripts/userscript-header.txt index 74e44ec..f42f8bc 100644 --- a/scripts/userscript-header.txt +++ b/scripts/userscript-header.txt @@ -3,8 +3,8 @@ // @name:zh-CN 更好的 GitHub 导航栏 // @namespace https://github.com/ImXiangYu/better-github-nav // @version __VERSION__ -// @description Add Dashboard, Trending, Explore, Collections, and Stars shortcuts to the top navigation bar for one-tap access to frequently used pages. Pin your most-used repositories to convenient locations. -// @description:zh-CN 顶部导航中加入 Dashboard、Trending、Explore、Collections、Stars 快捷入口,常用页面一键直达。并把你最常用的仓库固定在顺手的位置。 +// @description Bring Dashboard, Trending, Explore, Collections, and Stars closer on desktop and narrow screens, and keep your most-used repositories pinned where they are easiest to reach. +// @description:zh-CN 在桌面端和窄屏场景下,把 Dashboard、Trending、Explore、Collections、Stars 放到更顺手的位置,并把你最常用的仓库固定在最容易到达的地方。 // @author Ayubass // @license MIT // @match https://github.com/* diff --git a/src/constants.js b/src/constants.js index 03b0460..73f098e 100644 --- a/src/constants.js +++ b/src/constants.js @@ -4,6 +4,7 @@ export const CUSTOM_BUTTON_CLASS = 'custom-gh-nav-btn'; export const CUSTOM_BUTTON_ACTIVE_CLASS = 'custom-gh-nav-btn-active'; export const CUSTOM_BUTTON_COMPACT_CLASS = 'custom-gh-nav-btn-compact'; export const QUICK_LINK_MARK_ATTR = 'data-better-gh-nav-quick-link'; +export const RESPONSIVE_TOGGLE_MARK_ATTR = 'data-better-gh-nav-overflow-toggle'; export const CONFIG_STORAGE_KEY = 'better-gh-nav-config-v1'; export const TOP_REPOSITORIES_PIN_STORAGE_KEY = 'better-gh-nav-top-repositories-pins-v1'; export const UI_LANG_STORAGE_KEY = 'better-gh-nav-ui-lang-v1'; @@ -43,6 +44,9 @@ export const I18N = { saveAndRefresh: '保存并刷新', restoredPendingSave: '已恢复默认,点击保存后生效。', atLeastOneLink: '至少保留 1 个快捷链接。', + openQuickLinksMenu: '展开快捷链接', + closeQuickLinksMenu: '收起快捷链接', + quickLinksMenu: '快捷链接', dragHandleTitle: '拖动调整顺序', dragRowTitle: '拖动整行调整顺序', pinTopRepository: '置顶仓库:{repo}', @@ -62,6 +66,9 @@ export const I18N = { saveAndRefresh: 'Save and Refresh', restoredPendingSave: 'Defaults restored. Click save to apply.', atLeastOneLink: 'Keep at least 1 quick link.', + openQuickLinksMenu: 'Show quick links', + closeQuickLinksMenu: 'Hide quick links', + quickLinksMenu: 'Quick links', dragHandleTitle: 'Drag to reorder', dragRowTitle: 'Drag row to reorder', pinTopRepository: 'Pin repository: {repo}', diff --git a/src/main.js b/src/main.js index 8e11e76..2fbda96 100644 --- a/src/main.js +++ b/src/main.js @@ -1,4 +1,8 @@ -import { SCRIPT_VERSION } from './constants.js'; +import { + QUICK_LINK_MARK_ATTR, + RESPONSIVE_TOGGLE_MARK_ATTR, + SCRIPT_VERSION +} from './constants.js'; import { addCustomButtons } from './navigation.js'; import { openConfigPanel, registerConfigMenu } from './settings-panel.js'; import { ensureStyles } from './styles.js'; @@ -45,7 +49,10 @@ document.addEventListener('click', event => { // 3. 终极备用方案:使用 MutationObserver 监听 DOM 变化 const observer = new MutationObserver(() => { const hasHeader = Boolean(document.querySelector('header')); - const missingNavButtons = hasHeader && !document.querySelector('[id^="custom-gh-btn-"]'); + const hasCustomNavUi = Boolean(document.querySelector( + '[id^="custom-gh-btn-"], [' + QUICK_LINK_MARK_ATTR + '="1"], [' + RESPONSIVE_TOGGLE_MARK_ATTR + '="1"]:not([hidden])' + )); + const missingNavButtons = hasHeader && !hasCustomNavUi; const missingTopRepoPins = isDashboardHomePage() && hasTopRepositoriesHeading() && needsTopRepositoriesEnhancement(); if (missingNavButtons || missingTopRepoPins) scheduleEnhancements(); diff --git a/src/navigation.js b/src/navigation.js index cf6c021..92541b4 100644 --- a/src/navigation.js +++ b/src/navigation.js @@ -1,5 +1,11 @@ -import { PRESET_LINKS, PRESET_LINK_SHORTCUTS, QUICK_LINK_MARK_ATTR } from './constants.js'; +import { + PRESET_LINKS, + PRESET_LINK_SHORTCUTS, + QUICK_LINK_MARK_ATTR, + RESPONSIVE_TOGGLE_MARK_ATTR +} from './constants.js'; import { getConfiguredLinks } from './config.js'; +import { t } from './i18n.js'; import { setActiveStyle } from './styles.js'; let lastHotkeyConflictSignature = ''; @@ -9,6 +15,8 @@ let hotkeyTooltipHintNode = null; let hotkeyTooltipAnchor = null; let hotkeyTooltipGlobalBound = false; const hotkeyTooltipBoundAnchors = new WeakSet(); +let responsiveQuickLinksState = null; +let responsiveQuickLinksGlobalBound = false; export function normalizePath(href) { try { @@ -122,6 +130,311 @@ function cleanupQuickLinksForContainer(renderParent, keepNode) { }); } +function insertNodeAfter(parent, node, referenceNode) { + if (!parent || !node || !referenceNode || referenceNode.parentNode !== parent) return; + + const nextSibling = referenceNode.nextSibling; + if (node.parentNode === parent && node.previousSibling === referenceNode) return; + if (nextSibling === node) return; + parent.insertBefore(node, nextSibling); +} + +function createOverflowChevronIcon() { + const ns = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(ns, 'svg'); + svg.setAttribute('aria-hidden', 'true'); + svg.setAttribute('focusable', 'false'); + svg.setAttribute('viewBox', '0 0 16 16'); + svg.setAttribute('width', '16'); + svg.setAttribute('height', '16'); + svg.setAttribute('fill', 'currentColor'); + svg.classList.add('custom-gh-nav-overflow-toggle-icon'); + + const path = document.createElementNS(ns, 'path'); + path.setAttribute( + 'd', + 'm4.427 7.427 3.396 3.396a.25.25 0 0 0 .354 0l3.396-3.396A.25.25 0 0 0 11.396 7H4.604a.25.25 0 0 0-.177.427Z' + ); + svg.appendChild(path); + return svg; +} + +function createOverflowMenuLink(linkInfo) { + const link = document.createElement('a'); + link.className = 'custom-gh-nav-overflow-link'; + link.href = linkInfo.href; + link.setAttribute('aria-label', linkInfo.text); + + const text = document.createElement('span'); + text.className = 'custom-gh-nav-overflow-link-text'; + text.textContent = linkInfo.text; + link.appendChild(text); + + const hotkey = normalizeHotkeyValue(PRESET_LINK_SHORTCUTS[linkInfo.key]); + if (hotkey) { + const hint = document.createElement('kbd'); + hint.className = 'custom-gh-nav-overflow-link-kbd'; + hint.textContent = hotkey.toUpperCase(); + hint.setAttribute('aria-hidden', 'true'); + link.appendChild(hint); + } + + if (isCurrentPage(linkInfo.path)) { + link.setAttribute('aria-current', 'page'); + } + + return link; +} + +function updateResponsiveQuickLinksToggle(state) { + const label = state.menuOpen ? t('closeQuickLinksMenu') : t('openQuickLinksMenu'); + state.toggleButton.title = label; + state.toggleButton.setAttribute('aria-label', label); + state.toggleButton.setAttribute('aria-expanded', state.menuOpen ? 'true' : 'false'); +} + +function positionResponsiveQuickLinksMenu(state) { + state.menuNode.style.left = '0'; + state.menuNode.style.right = 'auto'; + + const rect = state.menuNode.getBoundingClientRect(); + const viewportPadding = 8; + + if (rect.right > window.innerWidth - viewportPadding) { + state.menuNode.style.left = 'auto'; + state.menuNode.style.right = '0'; + } + if (state.menuNode.getBoundingClientRect().left < viewportPadding) { + state.menuNode.style.left = '0'; + state.menuNode.style.right = 'auto'; + } +} + +function closeResponsiveQuickLinksMenu() { + const state = responsiveQuickLinksState; + if (!state || !state.menuOpen) return; + + hideHotkeyTooltip(); + state.menuOpen = false; + state.menuNode.hidden = true; + updateResponsiveQuickLinksToggle(state); +} + +function toggleResponsiveQuickLinksMenu() { + const state = responsiveQuickLinksState; + if (!state || !state.isCollapsed) return; + + hideHotkeyTooltip(); + state.menuOpen = !state.menuOpen; + state.menuNode.hidden = !state.menuOpen; + if (state.menuOpen) { + positionResponsiveQuickLinksMenu(state); + } + updateResponsiveQuickLinksToggle(state); +} + +function restoreResponsiveInlineNodes(state) { + if (!state.inlineItems.length) return; + + let insertAfter = state.referenceNode; + state.inlineItems.forEach(item => { + insertNodeAfter(state.renderParent, item.hostNode, insertAfter); + insertAfter = item.hostNode; + }); + insertNodeAfter(state.renderParent, state.toggleHostNode, insertAfter); +} + +function collapseResponsiveInlineNodes(state) { + state.inlineItems.forEach(item => { + if (item.hostNode.parentNode) { + item.hostNode.remove(); + } + }); + insertNodeAfter(state.renderParent, state.toggleHostNode, state.referenceNode); +} + +function needsResponsiveQuickLinksCollapse(state) { + const measureContainer = state.measureContainer; + const baselineRect = state.referenceNode.getBoundingClientRect(); + const containerRect = measureContainer.getBoundingClientRect(); + const containerRight = Math.min(containerRect.right, window.innerWidth - 8); + + const wrapped = state.inlineItems.some(item => { + if (!item.hostNode.isConnected) return false; + const rect = item.hostNode.getBoundingClientRect(); + if (rect.width <= 0 && rect.height <= 0) return false; + return Math.abs(rect.top - baselineRect.top) > 4; + }); + + const overflowing = state.inlineItems.some(item => { + if (!item.hostNode.isConnected) return false; + const rect = item.hostNode.getBoundingClientRect(); + if (rect.width <= 0 && rect.height <= 0) return false; + return rect.right > containerRight; + }); + + const scrollOverflow = ( + measureContainer.scrollWidth > measureContainer.clientWidth + 1 + || state.renderParent.scrollWidth > state.renderParent.clientWidth + 1 + ); + + return wrapped || overflowing || scrollOverflow; +} + +function syncResponsiveQuickLinksState(state) { + if (!state) return; + if (!state.renderParent.isConnected || !state.referenceNode.isConnected) { + destroyResponsiveQuickLinks(); + return; + } + + hideHotkeyTooltip(); + closeResponsiveQuickLinksMenu(); + restoreResponsiveInlineNodes(state); + state.toggleHostNode.hidden = true; + + const shouldCollapse = needsResponsiveQuickLinksCollapse(state); + if (shouldCollapse) { + collapseResponsiveInlineNodes(state); + state.isCollapsed = true; + state.toggleHostNode.hidden = false; + } else { + state.isCollapsed = false; + } + + updateResponsiveQuickLinksToggle(state); +} + +function scheduleResponsiveQuickLinksSync() { + const state = responsiveQuickLinksState; + if (!state || state.syncQueued) return; + + state.syncQueued = true; + requestAnimationFrame(() => { + const latestState = responsiveQuickLinksState; + if (!latestState) return; + latestState.syncQueued = false; + syncResponsiveQuickLinksState(latestState); + }); +} + +function destroyResponsiveQuickLinks() { + closeResponsiveQuickLinksMenu(); + + if (responsiveQuickLinksState?.resizeObserver) { + responsiveQuickLinksState.resizeObserver.disconnect(); + } + if (responsiveQuickLinksState?.toggleHostNode?.isConnected) { + responsiveQuickLinksState.toggleHostNode.remove(); + } + + responsiveQuickLinksState = null; +} + +function bindResponsiveQuickLinksGlobalHandlers() { + if (responsiveQuickLinksGlobalBound) return; + responsiveQuickLinksGlobalBound = true; + + window.addEventListener('resize', () => { + closeResponsiveQuickLinksMenu(); + scheduleResponsiveQuickLinksSync(); + }, { passive: true }); + + document.addEventListener('keydown', event => { + if (event.key === 'Escape') { + closeResponsiveQuickLinksMenu(); + } + }, true); + + document.addEventListener('pointerdown', event => { + const state = responsiveQuickLinksState; + if (!state || !state.menuOpen) return; + + const target = event.target; + if (target && state.toggleHostNode.contains(target)) return; + closeResponsiveQuickLinksMenu(); + }, true); +} + +function setupResponsiveQuickLinks({ + renderParent, + referenceNode, + inlineItems +}) { + destroyResponsiveQuickLinks(); + if (!inlineItems.length) return; + + bindResponsiveQuickLinksGlobalHandlers(); + + const hostTagName = inlineItems[0]?.hostNode?.tagName?.toLowerCase() || 'div'; + const toggleHostNode = document.createElement(hostTagName === 'li' ? 'li' : 'div'); + toggleHostNode.className = 'custom-gh-nav-overflow-host'; + toggleHostNode.setAttribute(RESPONSIVE_TOGGLE_MARK_ATTR, '1'); + toggleHostNode.hidden = true; + + const toggleButton = document.createElement('button'); + toggleButton.type = 'button'; + toggleButton.className = 'custom-gh-nav-overflow-toggle'; + toggleButton.setAttribute('aria-haspopup', 'true'); + toggleButton.setAttribute('aria-expanded', 'false'); + toggleButton.appendChild(createOverflowChevronIcon()); + + const menuNode = document.createElement('nav'); + menuNode.id = 'custom-gh-nav-overflow-menu'; + menuNode.className = 'custom-gh-nav-overflow-menu'; + menuNode.setAttribute('aria-label', t('quickLinksMenu')); + menuNode.hidden = true; + toggleButton.setAttribute('aria-controls', menuNode.id); + + inlineItems.forEach(item => { + menuNode.appendChild(createOverflowMenuLink(item.linkInfo)); + }); + + toggleButton.addEventListener('click', event => { + event.preventDefault(); + event.stopPropagation(); + toggleResponsiveQuickLinksMenu(); + }); + + menuNode.addEventListener('click', event => { + const link = event.target.closest('a[href]'); + if (!link) return; + closeResponsiveQuickLinksMenu(); + }); + + toggleHostNode.appendChild(toggleButton); + toggleHostNode.appendChild(menuNode); + insertNodeAfter(renderParent, toggleHostNode, inlineItems[inlineItems.length - 1].hostNode || referenceNode); + + const state = { + inlineItems, + isCollapsed: false, + measureContainer: renderParent.closest('nav') || renderParent, + menuNode, + menuOpen: false, + referenceNode, + renderParent, + resizeObserver: null, + syncQueued: false, + toggleButton, + toggleHostNode, + toggleLabelNode: null + }; + + if (typeof ResizeObserver === 'function') { + state.resizeObserver = new ResizeObserver(() => { + scheduleResponsiveQuickLinksSync(); + }); + state.resizeObserver.observe(renderParent); + if (state.measureContainer !== renderParent) { + state.resizeObserver.observe(state.measureContainer); + } + } + + responsiveQuickLinksState = state; + syncResponsiveQuickLinksState(state); +} + function normalizeHotkeyValue(value) { return String(value || '').trim().toLowerCase().replace(/\s+/g, ' '); } @@ -366,6 +679,8 @@ function reportHotkeyConflicts(customAnchors) { } export function addCustomButtons() { + destroyResponsiveQuickLinks(); + // 获取当前登录的用户名,用来动态生成 Stars 页面的专属链接 const userLoginMeta = document.querySelector('meta[name="user-login"]'); const username = userLoginMeta ? userLoginMeta.getAttribute('content') : ''; @@ -546,6 +861,7 @@ export function addCustomButtons() { const hasShortcutActive = navPresetLinks.some(link => isCurrentPage(link.path)); const renderedQuickAnchors = []; + const renderedQuickItems = []; if (isOnPresetPage && anchorTag && primaryLink) { // 预设页面:首个按钮替换为当前配置顺序中的第一个 @@ -598,8 +914,18 @@ export function addCustomButtons() { // 将新按钮插入到锚点之后,并更新锚点 insertAfterNode.parentNode.insertBefore(newNode, insertAfterNode.nextSibling); insertAfterNode = newNode; + renderedQuickItems.push({ + anchor: aTag, + hostNode: newNode, + linkInfo + }); }); - reportHotkeyConflicts(renderedQuickAnchors); + setupResponsiveQuickLinks({ + inlineItems: renderedQuickItems, + referenceNode: insertAnchorNode, + renderParent: insertAnchorNode.parentNode + }); + reportHotkeyConflicts(renderedQuickAnchors.filter(anchor => anchor.isConnected)); } } diff --git a/src/styles.js b/src/styles.js index 2b8834e..1442c3f 100644 --- a/src/styles.js +++ b/src/styles.js @@ -35,6 +35,105 @@ export function ensureStyles() { background-color: var(--color-neutral-muted, rgba(177, 186, 196, 0.18)); font-weight: 600; } + .custom-gh-nav-overflow-host { + position: relative; + display: inline-flex; + align-items: center; + list-style: none; + } + .custom-gh-nav-overflow-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + min-width: 28px; + min-height: 28px; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: var(--color-fg-default, #1f2328); + font: inherit; + font-weight: 600; + line-height: 1; + cursor: pointer; + } + .custom-gh-nav-overflow-toggle:hover, + .custom-gh-nav-overflow-toggle[aria-expanded="true"] { + background-color: var(--color-neutral-muted, rgba(177, 186, 196, 0.12)); + } + .custom-gh-nav-overflow-toggle:focus-visible { + outline: 2px solid var(--color-accent-fg, #0969da); + outline-offset: 1px; + } + .custom-gh-nav-overflow-toggle-icon { + flex: 0 0 auto; + transition: transform 120ms ease; + } + .custom-gh-nav-overflow-toggle[aria-expanded="true"] .custom-gh-nav-overflow-toggle-icon { + transform: rotate(180deg); + } + .custom-gh-nav-overflow-menu { + position: absolute; + top: calc(100% + 8px); + left: 0; + z-index: 2147483646; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 220px; + max-width: min(280px, calc(100vw - 16px)); + padding: 6px; + border: 1px solid var(--color-border-default, #d1d9e0); + border-radius: 12px; + background: var(--color-canvas-default, #fff); + box-shadow: var(--color-shadow-large, 0 16px 32px rgba(0, 0, 0, 0.16)); + box-sizing: border-box; + } + .custom-gh-nav-overflow-menu[hidden] { + display: none !important; + } + .custom-gh-nav-overflow-link { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 32px; + padding: 6px 10px; + border-radius: 8px; + color: var(--color-fg-default, #1f2328); + font-size: 13px; + font-weight: 600; + text-decoration: none; + } + .custom-gh-nav-overflow-link:hover { + background: var(--color-neutral-muted, rgba(177, 186, 196, 0.12)); + text-decoration: none; + } + .custom-gh-nav-overflow-link[aria-current="page"] { + color: var(--color-accent-fg, #0969da); + background: var(--color-accent-subtle, rgba(9, 105, 218, 0.08)); + } + .custom-gh-nav-overflow-link-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .custom-gh-nav-overflow-link-kbd { + flex: 0 0 auto; + margin: 0; + padding: 2px 6px; + border: none !important; + border-radius: 999px; + background: var(--color-neutral-muted, rgba(177, 186, 196, 0.18)) !important; + color: var(--color-fg-muted, #656d76); + box-shadow: none !important; + font: inherit; + font-size: 11px; + line-height: 1.2; + text-transform: uppercase; + } .custom-gh-nav-tooltip { position: fixed; z-index: 2147483647; @@ -278,6 +377,13 @@ export function ensureStyles() { margin: 6px 0; background: var(--color-border-muted, rgba(208, 215, 222, 0.8)); } + @media (max-width: 767px) { + .custom-gh-nav-overflow-menu { + left: auto; + right: 0; + min-width: min(240px, calc(100vw - 16px)); + } + } `; document.head.appendChild(style); }