GitHub Project Filter Formatter with Saved Filters

Adds fuzzy search button inside input and frequently used filters toolbar above GitHub project filter input.

  1. // ==UserScript==
  2. // @name GitHub Project Filter Formatter with Saved Filters
  3. // @namespace http://tampermonkey.net/
  4. // @version 2.4
  5. // @description Adds fuzzy search button inside input and frequently used filters toolbar above GitHub project filter input.
  6. // @author xiaohaoxing
  7. // @match https://github.com/orgs/.*/projects/.*
  8. // @match https://github.com/*/*/projects/*
  9. // @grant none
  10. // @icon https://github.githubassets.com/favicons/favicon.svg
  11. // @license MIT
  12. // ==/UserScript==
  13.  
  14. (function() {
  15. 'use strict';
  16.  
  17. const INPUT_ID = 'filter-bar-component-input';
  18. const BUTTON_ID = 'tampermonkey-format-title-button';
  19. const BUTTON_TEXT = '模糊';
  20. const SAVE_BUTTON_ID = 'tampermonkey-save-filter-button';
  21. const SAVE_BUTTON_TEXT = '⭐';
  22. const DROPDOWN_ID = 'tampermonkey-saved-filters-dropdown';
  23. const TOOLBAR_ID = 'tampermonkey-filter-toolbar';
  24. const WRAPPER_CLASS = 'tampermonkey-input-wrapper';
  25. const MIN_INPUT_TEXT_AREA_WIDTH = 50;
  26. const STORAGE_KEY = 'github-project-saved-filters';
  27.  
  28. let filterInput = null;
  29. let formatButton = null;
  30. let saveButton = null;
  31. let savedFiltersDropdown = null;
  32. let toolbar = null;
  33. let inputWrapper = null;
  34. let resizeObserver = null;
  35.  
  36. function getSavedFilters() {
  37. const saved = localStorage.getItem(STORAGE_KEY);
  38. return saved ? JSON.parse(saved) : [];
  39. }
  40.  
  41. function saveFilter(name, value) {
  42. const filters = getSavedFilters();
  43. const newFilter = { name, value, id: Date.now() };
  44. filters.push(newFilter);
  45. localStorage.setItem(STORAGE_KEY, JSON.stringify(filters));
  46. updateDropdown();
  47. return newFilter;
  48. }
  49.  
  50. function deleteFilter(id) {
  51. const filters = getSavedFilters();
  52. const updated = filters.filter(f => f.id !== id);
  53. localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
  54. updateDropdown();
  55. }
  56.  
  57. function showManageFiltersDialog() {
  58. const filters = getSavedFilters();
  59. if (filters.length === 0) {
  60. alert('没有保存的筛选条件');
  61. return;
  62. }
  63.  
  64. let message = '已保存的筛选条件:\n\n';
  65. filters.forEach((filter, index) => {
  66. message += `${index + 1}. ${filter.name}\n 值: ${filter.value}\n\n`;
  67. });
  68. message += '\n输入要删除的筛选条件编号(1-' + filters.length + '),或点击取消:';
  69.  
  70. const input = prompt(message);
  71. if (input) {
  72. const index = parseInt(input) - 1;
  73. if (index >= 0 && index < filters.length) {
  74. const filterToDelete = filters[index];
  75. if (confirm(`确定要删除"${filterToDelete.name}"吗?`)) {
  76. deleteFilter(filterToDelete.id);
  77. alert('筛选条件已删除');
  78. }
  79. } else {
  80. alert('无效的编号');
  81. }
  82. }
  83. }
  84.  
  85. function applyFilter(value) {
  86. const currentDomInput = document.getElementById(INPUT_ID);
  87. if (!currentDomInput) return;
  88. filterInput = currentDomInput;
  89. const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set ||
  90. Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
  91. if (valueSetter) {
  92. valueSetter.call(filterInput, value);
  93. } else {
  94. filterInput.value = value;
  95. }
  96. const inputEvent = new Event('input', { bubbles: true, cancelable: true });
  97. filterInput.dispatchEvent(inputEvent);
  98. filterInput.focus();
  99. }
  100.  
  101. function createSaveButton() {
  102. const existingButton = document.getElementById(SAVE_BUTTON_ID);
  103. if (existingButton) {
  104. return existingButton;
  105. }
  106.  
  107. const button = document.createElement('button');
  108. button.id = SAVE_BUTTON_ID;
  109. button.textContent = SAVE_BUTTON_TEXT;
  110. button.type = 'button';
  111. button.title = '保存当前筛选条件';
  112. button.className = 'btn btn-sm';
  113. button.style.fontSize = '12px';
  114. button.style.lineHeight = '18px';
  115. button.style.height = '28px';
  116.  
  117. // Override specific styles to ensure consistency
  118. button.style.display = 'inline-flex';
  119. button.style.alignItems = 'center';
  120. button.style.gap = '4px';
  121. button.style.marginLeft = '0';
  122. button.style.padding = '4px 8px';
  123. button.style.minWidth = 'auto';
  124. button.style.fontWeight = '500';
  125.  
  126. // Add hover effects
  127. button.addEventListener('mouseenter', () => {
  128. button.classList.add('btn-hover');
  129. });
  130. button.addEventListener('mouseleave', () => {
  131. button.classList.remove('btn-hover');
  132. });
  133.  
  134. button.addEventListener('click', (event) => {
  135. event.preventDefault();
  136. event.stopPropagation();
  137. const currentDomInput = document.getElementById(INPUT_ID);
  138. if (!currentDomInput) return;
  139. filterInput = currentDomInput;
  140. if (filterInput.value.trim() !== '') {
  141. const filterName = prompt('请输入筛选条件的名称:', filterInput.value.substring(0, 20));
  142. if (filterName && filterName.trim()) {
  143. saveFilter(filterName.trim(), filterInput.value);
  144. alert('筛选条件已保存!');
  145. }
  146. } else {
  147. alert('请先输入筛选条件');
  148. }
  149. });
  150.  
  151. return button;
  152. }
  153.  
  154. function createDropdown() {
  155. const existingDropdown = document.getElementById(DROPDOWN_ID);
  156. if (existingDropdown) {
  157. return existingDropdown;
  158. }
  159.  
  160. const dropdown = document.createElement('select');
  161. dropdown.id = DROPDOWN_ID;
  162. dropdown.className = 'form-select select-sm';
  163. // Override specific styles
  164. dropdown.style.minWidth = '120px';
  165. dropdown.style.maxWidth = '200px';
  166. dropdown.style.marginRight = '8px'; // Small gap between dropdown and button
  167. dropdown.style.height = '28px';
  168. dropdown.style.fontSize = '12px';
  169. dropdown.style.lineHeight = '18px';
  170. dropdown.style.paddingLeft = '12px';
  171. dropdown.style.paddingRight = '32px';
  172. dropdown.style.border = '1px solid var(--color-border-default, rgba(31, 35, 40, 0.15))';
  173. dropdown.style.borderRadius = '6px';
  174. dropdown.style.backgroundColor = 'var(--color-canvas-default, #ffffff)';
  175. dropdown.style.color = 'var(--color-fg-default, #24292f)';
  176. dropdown.style.backgroundImage = 'url("data:image/svg+xml,%3Csvg width=\'16\' height=\'16\' viewBox=\'0 0 16 16\' fill=\'%23586069\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cpath d=\'M4.427 7.427l3.396 3.396a.25.25 0 00.354 0l3.396-3.396A.25.25 0 0011.396 7H4.604a.25.25 0 00-.177.427z\'/%3E%3C/svg%3E")';
  177. dropdown.style.backgroundRepeat = 'no-repeat';
  178. dropdown.style.backgroundPosition = 'right 8px center';
  179. dropdown.style.backgroundSize = '16px';
  180. dropdown.style.appearance = 'none';
  181. dropdown.style.cursor = 'pointer';
  182.  
  183. // Add hover effect
  184. dropdown.addEventListener('mouseenter', () => {
  185. dropdown.style.borderColor = 'var(--color-border-muted, rgba(31, 35, 40, 0.3))';
  186. dropdown.style.backgroundColor = 'var(--color-canvas-subtle, #f6f8fa)';
  187. });
  188. dropdown.addEventListener('mouseleave', () => {
  189. dropdown.style.borderColor = 'var(--color-border-default, rgba(31, 35, 40, 0.15))';
  190. dropdown.style.backgroundColor = 'var(--color-canvas-default, #ffffff)';
  191. });
  192.  
  193. // Add focus styles
  194. dropdown.addEventListener('focus', () => {
  195. dropdown.style.outline = '2px solid var(--color-accent-fg, #0969da)';
  196. dropdown.style.outlineOffset = '-1px';
  197. });
  198. dropdown.addEventListener('blur', () => {
  199. dropdown.style.outline = 'none';
  200. });
  201.  
  202. dropdown.addEventListener('change', (event) => {
  203. const selectedValue = event.target.value;
  204. if (!selectedValue) return;
  205.  
  206. if (selectedValue === 'manage') {
  207. // Show management dialog
  208. showManageFiltersDialog();
  209. } else {
  210. const selectedId = parseInt(selectedValue);
  211. const filters = getSavedFilters();
  212. const filter = filters.find(f => f.id === selectedId);
  213. if (filter) {
  214. applyFilter(filter.value);
  215. }
  216. }
  217. dropdown.value = '';
  218. });
  219.  
  220. updateDropdown(dropdown);
  221. return dropdown;
  222. }
  223.  
  224. function updateDropdown(dropdown) {
  225. dropdown = dropdown || document.getElementById(DROPDOWN_ID);
  226. if (!dropdown) return;
  227.  
  228. const filters = getSavedFilters();
  229. dropdown.innerHTML = '';
  230.  
  231. const defaultOption = document.createElement('option');
  232. defaultOption.value = '';
  233. defaultOption.textContent = filters.length > 0 ? '常用筛选...' : '无保存的筛选';
  234. dropdown.appendChild(defaultOption);
  235.  
  236. filters.forEach(filter => {
  237. const option = document.createElement('option');
  238. option.value = filter.id;
  239. option.textContent = filter.name;
  240. option.title = filter.value;
  241. dropdown.appendChild(option);
  242. });
  243.  
  244. // Add manage option if there are saved filters
  245. if (filters.length > 0) {
  246. const separator = document.createElement('option');
  247. separator.disabled = true;
  248. separator.textContent = '────────';
  249. dropdown.appendChild(separator);
  250.  
  251. const manageOption = document.createElement('option');
  252. manageOption.value = 'manage';
  253. manageOption.textContent = '🗑️ 管理筛选条件';
  254. manageOption.style.fontStyle = 'italic';
  255. dropdown.appendChild(manageOption);
  256. }
  257.  
  258. dropdown.style.display = filters.length > 0 ? 'block' : 'none';
  259. }
  260.  
  261. function createFormatButton() {
  262. const existingButton = document.getElementById(BUTTON_ID);
  263. if (existingButton) {
  264. return existingButton;
  265. }
  266.  
  267. const button = document.createElement('button');
  268. button.id = BUTTON_ID;
  269. button.textContent = BUTTON_TEXT;
  270. button.type = 'button';
  271. button.title = '转换为模糊搜索格式';
  272. button.className = 'btn-sm';
  273.  
  274. // Style for button inside input with GitHub native look
  275. button.style.position = 'absolute';
  276. button.style.top = '50%';
  277. button.style.right = '28px';
  278. button.style.transform = 'translateY(-50%)';
  279. button.style.zIndex = '10';
  280. button.style.padding = '3px 8px';
  281. button.style.fontSize = '12px';
  282. button.style.height = '24px';
  283. button.style.lineHeight = '1';
  284. button.style.display = 'inline-flex';
  285. button.style.alignItems = 'center';
  286. button.style.justifyContent = 'center';
  287. button.style.border = '1px solid var(--color-border-default, rgba(31, 35, 40, 0.15))';
  288. button.style.borderRadius = '6px';
  289. button.style.backgroundColor = 'var(--color-btn-bg, #f6f8fa)';
  290. button.style.color = 'var(--color-fg-default, #24292f)';
  291. button.style.cursor = 'pointer';
  292. button.style.fontWeight = '500';
  293. button.style.whiteSpace = 'nowrap';
  294. button.style.transition = 'background-color 0.2s, border-color 0.2s';
  295.  
  296. button.addEventListener('mouseenter', () => {
  297. button.style.backgroundColor = 'var(--color-btn-hover-bg, #f3f4f6)';
  298. button.style.borderColor = 'var(--color-border-muted, rgba(31, 35, 40, 0.3))';
  299. });
  300. button.addEventListener('mouseleave', () => {
  301. button.style.backgroundColor = 'var(--color-btn-bg, #f6f8fa)';
  302. button.style.borderColor = 'var(--color-border-default, rgba(31, 35, 40, 0.15))';
  303. });
  304.  
  305. button.addEventListener('click', (event) => {
  306. event.preventDefault();
  307. event.stopPropagation();
  308. const currentDomInput = document.getElementById(INPUT_ID);
  309. if (!currentDomInput) return;
  310. filterInput = currentDomInput;
  311.  
  312. if (filterInput.value.trim() !== '') {
  313. const originalValue = filterInput.value;
  314. const newValue = `title:*${originalValue}*`;
  315. applyFilter(newValue);
  316. }
  317. });
  318. return button;
  319. }
  320.  
  321. function createToolbar() {
  322. const existingToolbar = document.getElementById(TOOLBAR_ID);
  323. if (existingToolbar) {
  324. return existingToolbar;
  325. }
  326.  
  327. const toolbar = document.createElement('div');
  328. toolbar.id = TOOLBAR_ID;
  329. toolbar.style.display = 'flex';
  330. toolbar.style.alignItems = 'center';
  331. toolbar.style.marginBottom = '4px';
  332. toolbar.style.gap = '0';
  333. toolbar.style.paddingLeft = '0';
  334. toolbar.style.paddingRight = '0';
  335. toolbar.style.width = 'auto';
  336.  
  337. return toolbar;
  338. }
  339.  
  340. function adjustLayout() {
  341. if (!filterInput || !inputWrapper || !formatButton) return;
  342.  
  343. // Ensure input fills the wrapper and has box-sizing: border-box
  344. filterInput.style.width = '100%';
  345. filterInput.style.boxSizing = 'border-box';
  346.  
  347. // Temporarily show button to measure its width if it was hidden
  348. const wasButtonHidden = formatButton.style.display === 'none';
  349. if (wasButtonHidden) {
  350. formatButton.style.visibility = 'hidden';
  351. formatButton.style.display = 'inline-flex';
  352. }
  353.  
  354. const buttonActualWidth = formatButton.offsetWidth;
  355.  
  356. if (wasButtonHidden) {
  357. formatButton.style.display = 'none';
  358. formatButton.style.visibility = 'visible';
  359. }
  360.  
  361. const wrapperInnerWidth = inputWrapper.clientWidth -
  362. (parseFloat(getComputedStyle(inputWrapper).paddingLeft) || 0) -
  363. (parseFloat(getComputedStyle(inputWrapper).paddingRight) || 0);
  364.  
  365. const spaceForButtonAndGap = buttonActualWidth + 16; // Button width + gap from right edge
  366.  
  367. // Check if there's enough space for the button AND a minimum text area
  368. if (buttonActualWidth > 0 && (wrapperInnerWidth - spaceForButtonAndGap) >= MIN_INPUT_TEXT_AREA_WIDTH) {
  369. filterInput.style.paddingRight = `${spaceForButtonAndGap}px`;
  370. formatButton.style.display = 'inline-flex';
  371. } else {
  372. // Not enough space, hide button and reset padding
  373. filterInput.style.paddingRight = '8px';
  374. formatButton.style.display = 'none';
  375. }
  376. }
  377.  
  378. function setupInputAndButton() {
  379. const currentDomInput = document.getElementById(INPUT_ID);
  380.  
  381. if (!currentDomInput) {
  382. // Input disappeared, cleanup
  383. if (resizeObserver && inputWrapper) {
  384. resizeObserver.unobserve(inputWrapper);
  385. }
  386. if (formatButton && formatButton.parentNode) formatButton.parentNode.removeChild(formatButton);
  387. if (toolbar && toolbar.parentNode) toolbar.parentNode.removeChild(toolbar);
  388. if (inputWrapper && inputWrapper.parentNode && inputWrapper.classList.contains(WRAPPER_CLASS)) {
  389. const originalParent = inputWrapper.parentNode;
  390. if (filterInput && document.body.contains(filterInput) && filterInput.parentElement !== originalParent) {
  391. originalParent.insertBefore(filterInput, inputWrapper);
  392. filterInput.style.paddingRight = '';
  393. filterInput.style.width = '';
  394. }
  395. originalParent.removeChild(inputWrapper);
  396. }
  397. filterInput = null;
  398. inputWrapper = null;
  399. formatButton = null;
  400. toolbar = null;
  401. saveButton = null;
  402. savedFiltersDropdown = null;
  403. return;
  404. }
  405.  
  406. filterInput = currentDomInput;
  407.  
  408. // Setup input wrapper for fuzzy button
  409. if (filterInput.parentElement && filterInput.parentElement.classList.contains(WRAPPER_CLASS)) {
  410. inputWrapper = filterInput.parentElement;
  411. } else {
  412. inputWrapper = document.createElement('div');
  413. inputWrapper.classList.add(WRAPPER_CLASS);
  414. inputWrapper.style.position = 'relative';
  415.  
  416. // Mimic original input's display
  417. const originalInputComputedStyle = getComputedStyle(filterInput);
  418. inputWrapper.style.display = originalInputComputedStyle.display;
  419. if (originalInputComputedStyle.display === 'block' || originalInputComputedStyle.display === 'flex') {
  420. inputWrapper.style.width = '100%';
  421. } else if (originalInputComputedStyle.display === 'inline-block') {
  422. if (originalInputComputedStyle.width !== 'auto' && !originalInputComputedStyle.width.includes('%')) {
  423. inputWrapper.style.width = originalInputComputedStyle.width;
  424. }
  425. }
  426. if (filterInput.parentElement && getComputedStyle(filterInput.parentElement).display.includes('flex')) {
  427. inputWrapper.style.flexGrow = originalInputComputedStyle.flexGrow;
  428. inputWrapper.style.flexShrink = originalInputComputedStyle.flexShrink;
  429. inputWrapper.style.flexBasis = originalInputComputedStyle.flexBasis;
  430. }
  431.  
  432. if (filterInput.parentNode) {
  433. filterInput.parentNode.insertBefore(inputWrapper, filterInput);
  434. inputWrapper.appendChild(filterInput);
  435. } else {
  436. console.warn("[Tampermonkey] Filter input has no parent, cannot wrap.");
  437. return;
  438. }
  439. }
  440.  
  441. // Add fuzzy button to input wrapper
  442. if (!formatButton || !inputWrapper.contains(formatButton)) {
  443. formatButton = createFormatButton();
  444. inputWrapper.appendChild(formatButton);
  445. }
  446.  
  447. // Setup toolbar above input
  448. // Find the filter-bar-module__Filter_0--v8FnK div which is the form container
  449. let formContainer = filterInput.closest('[id="filter-bar-component"]');
  450. if (!formContainer) {
  451. console.warn("[Tampermonkey] Could not find filter-bar-component");
  452. return;
  453. }
  454.  
  455. // Find the parent that contains the entire filter section
  456. let filterContainer = formContainer.closest('.tokenized-filter-input-module__Box--w5A7b');
  457. if (!filterContainer) {
  458. // Try alternative selector for the filter container
  459. filterContainer = formContainer.parentElement;
  460. if (!filterContainer) {
  461. console.warn("[Tampermonkey] Could not find tokenized filter container");
  462. return;
  463. }
  464. }
  465.  
  466. // Create or find toolbar
  467. if (!toolbar || !document.body.contains(toolbar)) {
  468. toolbar = createToolbar();
  469. // Insert toolbar before the filter container
  470. if (filterContainer.parentNode) {
  471. filterContainer.parentNode.insertBefore(toolbar, filterContainer);
  472. } else {
  473. console.warn("[Tampermonkey] Could not insert toolbar");
  474. return;
  475. }
  476. }
  477.  
  478. // Add dropdown and save button to toolbar
  479. if (!savedFiltersDropdown || !toolbar.contains(savedFiltersDropdown)) {
  480. savedFiltersDropdown = createDropdown();
  481. toolbar.appendChild(savedFiltersDropdown);
  482. }
  483.  
  484. if (!saveButton || !toolbar.contains(saveButton)) {
  485. saveButton = createSaveButton();
  486. toolbar.appendChild(saveButton);
  487. }
  488.  
  489. // Initial layout adjustment
  490. adjustLayout();
  491.  
  492. // Observe wrapper for size changes
  493. if (typeof ResizeObserver !== 'undefined') {
  494. if (resizeObserver) {
  495. resizeObserver.disconnect();
  496. }
  497. resizeObserver = new ResizeObserver(entries => {
  498. requestAnimationFrame(adjustLayout);
  499. });
  500. if (inputWrapper) {
  501. resizeObserver.observe(inputWrapper);
  502. }
  503. }
  504.  
  505. // Update dropdown visibility
  506. updateDropdown();
  507. }
  508.  
  509. const mutationObserver = new MutationObserver(() => {
  510. const currentInput = document.getElementById(INPUT_ID);
  511. if (currentInput) {
  512. if (!inputWrapper || !inputWrapper.contains(currentInput) || !formatButton || !inputWrapper.contains(formatButton) || !toolbar || !document.body.contains(toolbar)) {
  513. setupInputAndButton();
  514. }
  515. } else if (filterInput) {
  516. setupInputAndButton(); // This will trigger the cleanup logic
  517. }
  518. });
  519.  
  520. mutationObserver.observe(document.body, { childList: true, subtree: true });
  521.  
  522. setTimeout(setupInputAndButton, 1000);
  523.  
  524. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址