智能目录

智能提取网页标题生成目录,支持记忆拖拽位置、双击收起、重置位置等。正文首标题避让+10px,最高150px

// ==UserScript==
// @name         智能目录
// @namespace    https://greasyfork.org/users/1171320
// @version      1.1
// @description  智能提取网页标题生成目录,支持记忆拖拽位置、双击收起、重置位置等。正文首标题避让+10px,最高150px
// @match        *://*/*
// @grant        none
// @author       yzcjd
// @author2     ChatGPT4 辅助
// @grant        GM_addStyle
// @license      MIT
// ==/UserScript==

(function() {
  'use strict';

  // 平滑滚动函数,easeInOutCubic缓动
  function easeInOutCubic(t) {
    return t < 0.5
      ? 4 * t * t * t
      : 1 - Math.pow(-2 * t + 2, 3) / 2;
  }

  function smoothScrollTo(targetY, duration = 600) {
    const startY = window.scrollY || window.pageYOffset;
    const distance = targetY - startY;
    let startTime = null;

    function step(currentTime) {
      if (!startTime) startTime = currentTime;
      const elapsed = currentTime - startTime;
      const progress = Math.min(elapsed / duration, 1);
      const easedProgress = easeInOutCubic(progress);
      window.scrollTo(0, startY + distance * easedProgress);
      if (elapsed < duration) {
        requestAnimationFrame(step);
      }
    }

    requestAnimationFrame(step);
  }

  const hostname = location.hostname;
  const storageKey = 'SmartTOC:v3.8:' + hostname;
  const state = JSON.parse(localStorage.getItem(storageKey) || '{}');

  const excludeKeywords = ['cloudflare','captcha','challenge','login','auth','verify'];
  if (excludeKeywords.some(k => hostname.includes(k) || location.pathname.includes(k))) return;

  const toc = document.createElement('div');
  toc.id = 'smart-toc';
  toc.style.cssText = `
    position:fixed; top:50px; right:50px; width:250px; max-height:80vh;
    background:#fff; border:1px solid #ccc; border-radius:8px;
    box-shadow:0 2px 8px rgba(0,0,0,0.15); overflow:hidden;
    z-index:99999; font-family:sans-serif;
  `;

  toc.innerHTML = `
    <div id="toc-header" style="background:#ccc; padding:5px; cursor:move;">
      📑 目录 <label id="toc-toggle" style="float:right; font-size:12px; cursor:pointer;">排除</label>
    </div>
    <div id="toc-list" style="
      margin:0; padding:0; overflow:auto;
      max-height: calc(80vh - 30px);
      opacity:1;
      transition: max-height 0.6s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.4s ease;
    "></div>
  `;
  document.body.appendChild(toc);

  const tocList = toc.querySelector('#toc-list');
  const toggleLabel = toc.querySelector('#toc-toggle');

  if (state.exclude) {
    toggleLabel.textContent = '启用';
    toc.style.width = '120px';
    tocList.style.display = 'none';
  }

  toggleLabel.addEventListener('click', () => {
    state.exclude = !state.exclude;
    localStorage.setItem(storageKey, JSON.stringify(state));
    toggleLabel.textContent = state.exclude ? '启用' : '排除';
    toc.style.width = state.exclude ? '120px' : '250px';
    tocList.style.display = state.exclude ? 'none' : '';
  });

  const ignoreSelectors = ['header','footer','nav','aside','.navbar','.sidebar','.avatar','.logo','.banner','.desc','.tabs','.player','.playlist','.switch','.user','.meta','.repository-content','.file-navigation','.breadcrumb','.table-list-header','.file-header','.BtnGroup','.Box-header'];
  const isVisible = el => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
  const isValidHeading = el => {
    const text = el.textContent.trim();
    const size = parseFloat(getComputedStyle(el).fontSize);
    const nearLogo = el.closest('.logo,.site-branding');
    if (!isVisible(el)) return false;
    if (ignoreSelectors.some(sel => el.closest(sel))) return false;
    if (text.length < 3 || text.length > 100) return false;
    if (size > 32 || size < 10) return false;
    if (/^(用户|作者|id|导航|选项卡|登录|注册|文件|文件夹|提交|历史|上传|添加|修改|移除|repository|files|commit|history|breadcrumb|upload|drop)/i.test(text)) return false;
    if (nearLogo) return false;
    return true;
  };

  let headings = [...document.querySelectorAll('main h1,h2,h3,h4,h5,h6, article h1,h2,h3,h4,h5,h6, .content h1,h2,h3,h4,h5,h6, #content h1,h2,h3,h4,h5,h6')].filter(isValidHeading);
  if (headings.length < 3) headings = [...document.querySelectorAll('h1,h2,h3,h4,h5,h6')].filter(isValidHeading);
  if (!headings.length) { toc.remove(); return; }

  const longest = Math.max(...headings.map(el => el.textContent.trim().length));
  if (longest >= 20) toc.style.width = '375px';

  headings.forEach((el, idx) => { if (!el.id) el.id = 'smart-toc-' + idx; });

  const getLevel = tag => parseInt(tag.replace('H',''));
  headings.forEach(el => {
    const level = Math.min(getLevel(el.tagName), 3);
    const a = document.createElement('a');
    a.href = `#${el.id}`;
    a.textContent = el.textContent.trim();
    const indent = level === 1 ? 0 : (level - 1) * 1.5;
    a.style.cssText = `display:block;padding:3px 10px;padding-left:${indent}em;color:inherit;text-decoration:none;user-select:none;`;
    tocList.appendChild(a);
  });

  toc.querySelector('#toc-header').addEventListener('mousedown', e => {
    let offsetX = e.clientX - toc.offsetLeft, offsetY = e.clientY - toc.offsetTop;
    const move = e => { toc.style.left = `${e.clientX - offsetX}px`; toc.style.top = `${e.clientY - offsetY}px`; };
    const up = () => {
      state.position = { x: toc.offsetLeft, y: toc.offsetTop };
      localStorage.setItem(storageKey, JSON.stringify(state));
      document.removeEventListener('mousemove', move);
      document.removeEventListener('mouseup', up);
    };
    document.addEventListener('mousemove', move);
    document.addEventListener('mouseup', up);
  });
  if (state.position) {
    toc.style.left = state.position.x + 'px';
    toc.style.top = state.position.y + 'px';
  }

  let isCollapsed = false;

  // 双击目录折叠/展开动画
  toc.addEventListener('dblclick', e => {
    e.stopPropagation();
    e.preventDefault();
    if (isCollapsed) {
      // 展开
      tocList.style.display = '';
      requestAnimationFrame(() => {
        tocList.style.maxHeight = 'calc(80vh - 30px)';
        tocList.style.opacity = '1';
      });
    } else {
      // 收起
      tocList.style.maxHeight = '0';
      tocList.style.opacity = '0';
      setTimeout(() => {
        tocList.style.display = 'none';
      }, 600);
    }
    isCollapsed = !isCollapsed;
  }, true);

  // 点击目录标题时,使用自定义平滑滚动动画
  tocList.addEventListener('click', e => {
    if (isCollapsed) {
      e.preventDefault();
      e.stopPropagation();
      return;
    }
    if (e.target.tagName.toLowerCase() === 'a') {
      const href = e.target.getAttribute('href');
      if (!href.startsWith('#')) return;
      const id = href.slice(1);
      const target = document.getElementById(id);
      if (target) {
        e.preventDefault();
        setTimeout(() => {
          const rect = target.getBoundingClientRect();
          const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
          const distance = rect.top - document.documentElement.getBoundingClientRect().top + 10;
          const offsetY = scrollTop + rect.top - Math.min(distance, 150);
          smoothScrollTo(offsetY, 600);
        }, 10);
      }
    }
  });

  console.log('智能目录 v3.8 已启用(带平滑跳转动画)');
})();