MiddleFun-Expansion-tools

专门为 MiddleFun 设计的头像拓展信息工具插件

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         MiddleFun-Expansion-tools
// @namespace    https://www.middlefun.com/
// @version      5.4.1
// @description  专门为 MiddleFun 设计的头像拓展信息工具插件
// @author       evan.xin
// @match        https://www.middlefun.com/*
// @match        https://middlefun.com/*
// @run-at       document-idle
// @grant        none
// @noframes
// @license MIT
// ==/UserScript==

(() => {
  'use strict';

  // 通过文档级事件代理处理单个头像悬停,并在站内无刷新换页时主动重置状态。
  const HIDE_DELAY = 90;
  const HOVER_INTENT_DELAY = 180;
  const REQUEST_TIMEOUT = 12 * 1000;
  const CACHE_LIMIT = 24;
  const CACHE_TTL = 10 * 60 * 1000;
  const CACHE_STORAGE_KEY = 'mf-hover-profile-cache-v2';
  const BADGE_LIMIT = 12;
  const CARD_ID = 'mf-avatar-hover-card';
  const memberPath = /^\/members\/([^/?#]+)/;
  const joinPattern = /(\d{4})年\s*(\d{1,2})月\s*(\d{1,2})日\s*加入/;
  const rankPattern = /第\s*(\d+)\s*位会员/;

  // 仅保存最近 24 位成员的公开资料;会话内复用,十分钟后按需重新读取以识别更新。
  const profileCache = loadProfileCache();

  let activeHover = null;
  let hideTimer = 0;
  let hoverTimer = 0;
  let cacheSaveTimer = 0;
  let activeController = null;
  let positionFrame = 0;
  let sequence = 0;

  const card = createCard();
  injectStyle();

  function createCard() {
    const node = document.createElement('section');
    node.id = CARD_ID;
    node.setAttribute('role', 'dialog');
    node.setAttribute('aria-label', '成员资料');
    node.innerHTML = '<div class="mf-loading" aria-live="polite">读取公开资料…</div>';
    node.addEventListener('pointerenter', () => clearTimeout(hideTimer));
    node.addEventListener('pointerleave', () => closeCard(true));
    (document.body || document.documentElement).append(node);
    return node;
  }

  function injectStyle() {
    const style = document.createElement('style');
    style.textContent = `
      #${CARD_ID}{position:fixed;z-index:2147483647;box-sizing:border-box;width:292px;max-width:calc(100vw - 16px);padding:13px;border:1px solid rgba(0,0,0,.09);border-radius:12px;background:#fff;color:#18181b;box-shadow:0 12px 32px rgba(0,0,0,.16);font:13px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;opacity:0;visibility:hidden;transform:translateY(3px);transition:opacity .12s ease,transform .12s ease,visibility .12s;pointer-events:none}
      #${CARD_ID}.mf-visible{opacity:1;visibility:visible;transform:translateY(0);pointer-events:auto}
      #${CARD_ID} *{box-sizing:border-box}
      #${CARD_ID} .mf-top{display:flex;align-items:center;justify-content:space-between;gap:10px;padding-bottom:9px;border-bottom:1px solid rgba(0,0,0,.08)}
      #${CARD_ID} .mf-user{min-width:0;font-size:15px;font-weight:650;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
      #${CARD_ID} .mf-actions{display:flex;align-items:center;gap:2px;flex:0 0 auto}
      #${CARD_ID} .mf-site{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:28px;height:28px;border-radius:7px;color:#52525b;text-decoration:none}
      #${CARD_ID} .mf-site:hover{background:#f4f4f5;color:#18181b}
      #${CARD_ID} .mf-site svg{width:17px;height:17px;fill:none;stroke:currentColor;stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round}
      #${CARD_ID} .mf-meta{display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:8px;margin:9px 0;padding:7px 10px;border-radius:8px;background:#fafafa;white-space:nowrap;overflow:hidden}
      #${CARD_ID} .mf-meta-item{display:inline-flex;align-items:baseline;gap:2px;min-width:0;color:#71717a;font-weight:400}
      #${CARD_ID} .mf-meta-item:first-child{justify-self:start}
      #${CARD_ID} .mf-meta-item:last-child{justify-self:end;max-width:100%;overflow:hidden;text-overflow:ellipsis}
      #${CARD_ID} .mf-meta-item span:last-child{color:#27272a;font-weight:400}
      #${CARD_ID} .mf-meta-date{justify-self:center;color:#27272a;font-weight:400}
      #${CARD_ID} .mf-caption{color:#71717a;white-space:nowrap;font-weight:400}
      #${CARD_ID} .mf-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;padding:9px 0;border-top:1px solid rgba(0,0,0,.08)}
      #${CARD_ID} .mf-stat{display:block;padding:6px 4px;border-radius:8px;background:#fafafa;text-align:center;font-weight:400;color:inherit;text-decoration:none}
      #${CARD_ID} a.mf-stat{cursor:pointer}
      #${CARD_ID} a.mf-stat:hover{background:#f4f4f5}
      #${CARD_ID} .mf-stat-value{display:block;font-size:14px;line-height:1.2;color:#27272a;font-weight:400}
      #${CARD_ID} .mf-stat span{display:block;margin-top:2px;color:#71717a;font-size:11px}
      #${CARD_ID} .mf-badge-area{display:flex;align-items:flex-start;gap:9px;padding-top:9px;border-top:1px solid rgba(0,0,0,.08)}
      #${CARD_ID} .mf-badge-list{display:flex;flex:1;flex-wrap:wrap;gap:4px;min-height:18px}
      #${CARD_ID} .mf-badge-list img{display:block;width:18px;height:18px;border-radius:50%;object-fit:contain;background:#f4f4f5}
      #${CARD_ID} .mf-empty{color:#a1a1aa}
      #${CARD_ID} .mf-loading,#${CARD_ID} .mf-error{padding:6px 2px;color:#71717a}
      html.dark #${CARD_ID}{border-color:rgba(255,255,255,.12);background:#18181b;color:#f4f4f5;box-shadow:0 12px 32px rgba(0,0,0,.42)}
      html.dark #${CARD_ID} .mf-top,html.dark #${CARD_ID} .mf-stats,html.dark #${CARD_ID} .mf-badge-area{border-color:rgba(255,255,255,.1)}
      html.dark #${CARD_ID} .mf-user,html.dark #${CARD_ID} .mf-meta-item span:last-child,html.dark #${CARD_ID} .mf-meta-date,html.dark #${CARD_ID} .mf-stat-value{color:#f4f4f5}
      html.dark #${CARD_ID} .mf-site{color:#d4d4d8}html.dark #${CARD_ID} .mf-site:hover,html.dark #${CARD_ID} .mf-stat,html.dark #${CARD_ID} .mf-meta{background:#27272a}
      html.dark #${CARD_ID} a.mf-stat:hover{background:#3f3f46}
      html.dark #${CARD_ID} .mf-badge-list img{background:#27272a}
    `;
    (document.head || document.documentElement).append(style);
  }

  function getRouteKey() {
    return `${location.pathname}${location.search}${location.hash}`;
  }

  function isMemberLink(link) {
    if (!(link instanceof HTMLAnchorElement)) return false;
    try {
      return memberPath.test(new URL(link.href, location.origin).pathname);
    } catch {
      return false;
    }
  }

  function getAvatarNode(link) {
    if (!(link instanceof Element)) return null;
    return link.querySelector(':scope > [data-slot="avatar"], :scope [data-slot="avatar"]');
  }

  function getListAvatarLink(target) {
    if (!(target instanceof Element)) return null;

    const avatarPart = target.closest('[data-slot="avatar"], [data-slot="avatar-image"], [data-slot="avatar-fallback"]');
    const avatarLink = avatarPart?.closest('a[href]');
    if (isMemberLink(avatarLink) && getAvatarNode(avatarLink)) return avatarLink;

    const link = target.closest('a[href]');
    return isMemberLink(link) && getAvatarNode(link) ? link : null;
  }

  function sameMemberLink(a, b) {
    return !!a && !!b && a.href === b.href;
  }

  function onPointerOver(event) {
    if (card.contains(event.target)) return;
    const link = getListAvatarLink(event.target);
    if (!link) return;

    const relatedLink = getListAvatarLink(event.relatedTarget);
    if (sameMemberLink(link, relatedLink)) return;

    const member = getMember(link);
    const avatar = getAvatarNode(link);
    if (!member || !avatar) return;

    if (activeHover?.anchor === avatar && activeHover.key === member.key) {
      clearTimeout(hideTimer);
      return;
    }
    startHover(link, avatar, member);
  }

  function onPointerOut(event) {
    if (!activeHover || card.contains(event.target)) return;
    const link = getListAvatarLink(event.target);
    if (!sameMemberLink(link, activeHover.link)) return;

    if (card.contains(event.relatedTarget)) return;
    const relatedLink = getListAvatarLink(event.relatedTarget);
    if (sameMemberLink(relatedLink, link)) return;

    closeCard();
  }

  function getMember(anchor) {
    const url = new URL(anchor.href, location.origin);
    const matched = url.pathname.match(memberPath);
    if (!matched) return null;
    const username = decodeURIComponent(matched[1]);
    if (!username) return null;
    return { key: url.pathname, username, url: url.href };
  }

  function startHover(link, anchor, member) {
    clearTimeout(hideTimer);
    clearTimeout(hoverTimer);
    activeController?.abort();
    activeController = null;

    const current = { link, anchor, routeKey: getRouteKey(), ...member, id: ++sequence, loading: false };
    activeHover = current;

    const cached = getCached(current.key);
    if (cached) {
      renderProfile(cached);
      revealNear(anchor);
      return;
    }

    // 只有指针稳定停留后才发请求,避免扫过头像时下载多个完整成员页面。
    hoverTimer = window.setTimeout(() => {
      if (activeHover !== current || current.id !== sequence || current.routeKey !== getRouteKey()) return;
      current.loading = true;
      renderLoading(current);
      revealNear(anchor);

      activeController?.abort();
      activeController = new AbortController();
      const controller = activeController;

      fetchProfile(current, controller.signal)
        .then(profile => {
          if (activeHover !== current || current.id !== sequence || current.routeKey !== getRouteKey() || !profile) return;
          renderProfile(profile);
          revealNear(anchor);
        })
        .catch(error => {
          if (error?.name === 'AbortError') return;
          if (activeHover === current && current.id === sequence) {
            renderError();
            revealNear(anchor);
          }
        })
        .finally(() => {
          current.loading = false;
          if (activeController === controller) activeController = null;
        });
    }, HOVER_INTENT_DELAY);
  }

  function closeCard(immediate = false) {
    activeHover = null;
    sequence += 1;
    clearTimeout(hideTimer);
    clearTimeout(hoverTimer);
    activeController?.abort();
    activeController = null;
    if (positionFrame) {
      cancelAnimationFrame(positionFrame);
      positionFrame = 0;
    }
    if (immediate) {
      card.classList.remove('mf-visible');
      return;
    }
    hideTimer = window.setTimeout(() => card.classList.remove('mf-visible'), HIDE_DELAY);
  }

  function resetForRouteChange() {
    closeCard(true);
    if (!card.isConnected) (document.body || document.documentElement).append(card);
  }

  function installRouteListeners() {
    const wrap = name => {
      const original = history[name];
      history[name] = function (...args) {
        const result = original.apply(this, args);
        resetForRouteChange();
        requestAnimationFrame(resetForRouteChange);
        return result;
      };
    };

    wrap('pushState');
    wrap('replaceState');
    window.addEventListener('popstate', () => {
      resetForRouteChange();
      requestAnimationFrame(resetForRouteChange);
    }, { passive: true });
    window.addEventListener('hashchange', () => {
      resetForRouteChange();
      requestAnimationFrame(resetForRouteChange);
    }, { passive: true });
    window.addEventListener('pageshow', resetForRouteChange, { passive: true });
    document.addEventListener('visibilitychange', () => {
      if (document.hidden) closeCard(true);
    }, { passive: true });
  }

  function loadProfileCache() {
    try {
      const stored = JSON.parse(sessionStorage.getItem(CACHE_STORAGE_KEY) || '[]');
      const now = Date.now();
      const entries = Array.isArray(stored) ? stored.filter(item =>
        Array.isArray(item) && item[0] && item[1]?.savedAt && now - item[1].savedAt < CACHE_TTL
      ).slice(-CACHE_LIMIT) : [];
      return new Map(entries);
    } catch {
      return new Map();
    }
  }

  function saveProfileCache() {
    clearTimeout(cacheSaveTimer);
    cacheSaveTimer = window.setTimeout(() => {
      try {
        sessionStorage.setItem(CACHE_STORAGE_KEY, JSON.stringify([...profileCache.entries()]));
      } catch {
        // 存储不可用时仅使用当前内存缓存,不影响悬停功能。
      }
    }, 250);
  }

  function getCached(key) {
    const cached = profileCache.get(key);
    if (!cached) return null;
    if (Date.now() - cached.savedAt >= CACHE_TTL) {
      profileCache.delete(key);
      saveProfileCache();
      return null;
    }
    profileCache.delete(key);
    profileCache.set(key, cached);
    return cached.profile;
  }

  function putCached(key, profile) {
    if (profileCache.has(key)) profileCache.delete(key);
    profileCache.set(key, { profile, savedAt: Date.now() });
    if (profileCache.size > CACHE_LIMIT) profileCache.delete(profileCache.keys().next().value);
    saveProfileCache();
  }

  function fetchProfile(member, signal) {
    const timeout = AbortSignal.timeout(REQUEST_TIMEOUT);
    const combinedSignal = AbortSignal.any([signal, timeout]);
    const request = fetch(member.url, {
      credentials: 'same-origin',
      headers: { Accept: 'text/html' },
      signal: combinedSignal,
    })
      .then(response => {
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        return response.text();
      })
      .then(html => parseProfile(html, member))
      .then(profile => {
        putCached(member.key, profile);
        return profile;
      });

    return request;
  }

  function parseProfile(html, member) {
    const doc = new DOMParser().parseFromString(html, 'text/html');
    const h1 = doc.querySelector('h1');
    const profileRoot = h1?.closest('main') || doc.querySelector('main') || doc.body;
    const bodyText = normalize(profileRoot?.textContent || '');
    const username = normalize(h1?.textContent || member.username) || member.username;
    const rank = bodyText.match(rankPattern)?.[1] || '';
    const joined = formatJoinDate(bodyText.match(joinPattern));
    const stats = {
      posts: findStat(doc, '帖子'),
      replies: findStat(doc, '回复'),
      equipment: findStat(doc, '装备'),
    };

    const headerRoot = findHeaderRoot(h1);
    // 个人网站只取头部公开字段;社交链接中的 GitHub、LinkedIn 等不再误作个人网站。
    const website = findHeaderWebsite(h1, headerRoot);
    const location = findPublicLocation(headerRoot, h1);
    const badges = extractBadges(doc);

    return { username, rank, joined, location, stats, website, badges, memberUrl: member.url };
  }

  function normalize(value) {
    return String(value || '').replace(/\s+/g, ' ').trim();
  }

  function formatJoinDate(match) {
    if (!match) return '';
    const [, year, month, day] = match;
    return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}加入`;
  }

  function findStat(doc, label) {
    const pattern = new RegExp(`^${label}\\s*(\\d+(?:\\+)?)$`);
    for (const button of doc.querySelectorAll('button')) {
      const matched = normalize(button.textContent).match(pattern);
      if (matched) return matched[1];
    }
    return '';
  }

  function findHeaderRoot(h1) {
    let node = h1;
    for (let depth = 0; node && depth < 7; depth += 1, node = node.parentElement) {
      const text = normalize(node.textContent);
      if (joinPattern.test(text) && /帖子\s*\d+/.test(text) && /回复\s*\d+/.test(text)) return node;
    }
    return h1?.parentElement?.parentElement || null;
  }

  function firstExternalUrl(root) {
    if (!root) return '';
    for (const anchor of root.querySelectorAll('a[href]')) {
      const url = safeExternalUrl(anchor.href);
      if (url) return url;
    }
    return '';
  }

  function findHeaderWebsite(h1, headerRoot) {
    // parsedDoc 的节点必须与 parsedDoc.body 比较,不能与当前页面 document.body 比较。
    if (headerRoot) {
      const website = firstExternalUrl(headerRoot);
      if (website) return website;
    }
    const parsedBody = h1?.ownerDocument?.body;
    for (let node = h1; node && node !== parsedBody; node = node.parentElement) {
      if (!joinPattern.test(normalize(node.textContent))) continue;
      const website = firstExternalUrl(node);
      if (website) return website;
    }
    return '';
  }

  function findPublicLocation(headerRoot, h1) {
    // 当前个人页用地图图标表达地区,没有“地区”文字标签。
    let node = h1;
    for (let depth = 0; node && depth < 7; depth += 1, node = node.parentElement) {
      const mapIcon = node.querySelector('svg.lucide-map-pin, svg[class*="lucide-map-pin"]');
      const iconValue = normalize(mapIcon?.parentElement?.querySelector('span')?.textContent);
      if (iconValue) return iconValue;
    }

    if (!headerRoot) return '';
    const labelPattern = /^(?:地区|所在地|位置)$/;
    for (const label of headerRoot.querySelectorAll('span,div,p')) {
      if (!labelPattern.test(normalize(label.textContent))) continue;
      const value = normalize(label.nextElementSibling?.textContent || label.parentElement?.lastElementChild?.textContent);
      if (value && value !== normalize(label.textContent)) return value;
    }
    const text = normalize(headerRoot.textContent);
    const matched = text.match(/(?:地区|所在地|位置)\s*[::]\s*([^\s·•|]{1,40})/);
    return matched?.[1] || '';
  }

  function safeExternalUrl(raw) {
    try {
      const url = new URL(raw, location.origin);
      return (url.protocol === 'https:' || url.protocol === 'http:') && url.origin !== location.origin ? url.href : '';
    } catch {
      return '';
    }
  }

  function extractBadges(doc) {
    const heading = [...doc.querySelectorAll('h1,h2,h3,h4,h5,h6')]
      .find(node => normalize(node.textContent) === '徽章');
    const section = heading?.parentElement?.parentElement || doc;
    const seen = new Set();
    const badges = [];

    for (const image of section.querySelectorAll('img[src*="/badges/"]')) {
      const src = image.getAttribute('src') || '';
      if (!src || seen.has(src)) continue;
      seen.add(src);
      badges.push({ src: new URL(src, location.origin).href, name: normalize(image.alt) || '徽章' });
      if (badges.length === BADGE_LIMIT) break;
    }
    return badges;
  }

  function renderLoading(member) {
    const preview = document.createDocumentFragment();
    const top = makeNode('div', 'mf-top');
    top.append(makeNode('div', 'mf-user', member.username));
    preview.append(top, makeNode('div', 'mf-loading', '正在读取公开资料…'));
    card.replaceChildren(preview);
  }

  function renderError() {
    card.replaceChildren(makeNode('div', 'mf-error', '公开资料暂时无法读取'));
  }

  function renderProfile(profile) {
    const fragment = document.createDocumentFragment();
    const top = makeNode('div', 'mf-top');
    top.append(makeNode('div', 'mf-user', profile.username));

    const actions = makeNode('div', 'mf-actions');
    const about = document.createElement('a');
    about.className = 'mf-site';
    about.href = memberTabUrl(profile.memberUrl, 'about');
    about.title = '查看个人简介';
    about.setAttribute('aria-label', '查看个人简介');
    about.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="3"></circle><path d="M5.5 19a6.5 6.5 0 0 1 13 0"></path><rect x="3" y="3" width="18" height="18" rx="3"></rect></svg>';
    actions.append(about);

    if (profile.website) {
      const website = document.createElement('a');
      website.className = 'mf-site';
      website.href = profile.website;
      website.target = '_blank';
      website.rel = 'noopener noreferrer';
      website.title = '打开个人网站';
      website.setAttribute('aria-label', '打开个人网站');
      website.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"></circle><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"></path></svg>';
      actions.append(website);
    }
    top.append(actions);
    fragment.append(top);

    const meta = makeNode('div', 'mf-meta');
    appendMeta(meta, 'ID', profile.rank);
    if (profile.joined) meta.append(makeNode('span', 'mf-meta-date', profile.joined));
    if (profile.location) appendMeta(meta, '', profile.location);
    fragment.append(meta);

    const stats = makeNode('div', 'mf-stats');
    appendStat(stats, '帖子', profile.stats.posts, memberTabUrl(profile.memberUrl, 'posts'));
    appendStat(stats, '回复', profile.stats.replies, memberTabUrl(profile.memberUrl, 'comments'));
    appendStat(stats, '装备', profile.stats.equipment, memberTabUrl(profile.memberUrl, 'gears'));
    fragment.append(stats);

    const badgeArea = makeNode('div', 'mf-badge-area');
    badgeArea.append(makeNode('span', 'mf-caption', '徽章'));
    const badgeList = makeNode('div', 'mf-badge-list');
    if (profile.badges.length) {
      for (const badge of profile.badges) {
        const image = document.createElement('img');
        image.src = badge.src;
        image.alt = badge.name;
        image.title = badge.name;
        image.loading = 'lazy';
        image.decoding = 'async';
        badgeList.append(image);
      }
    } else {
      badgeList.append(makeNode('span', 'mf-empty', '暂无公开徽章'));
    }
    badgeArea.append(badgeList);
    fragment.append(badgeArea);

    card.replaceChildren(fragment);
  }

  function appendMeta(parent, label, value) {
    if (!value) return;
    const item = makeNode('span', 'mf-meta-item');
    item.append(makeNode('span', '', label), makeNode('span', '', value));
    parent.append(item);
  }

  function memberTabUrl(memberUrl, tab) {
    try {
      const url = new URL(memberUrl, location.origin);
      url.search = '';
      url.searchParams.set('tab', tab);
      return url.href;
    } catch {
      return '';
    }
  }

  function appendStat(parent, label, value, href) {
    const item = href ? document.createElement('a') : document.createElement('div');
    item.className = 'mf-stat';
    if (href) {
      item.href = href;
      item.title = `查看该用户的${label}`;
      item.setAttribute('aria-label', `查看该用户的${label}`);
    }
    item.append(makeNode('span', 'mf-stat-value', value || '—'), makeNode('span', '', label));
    parent.append(item);
  }

  function makeNode(tag, className, text) {
    const node = document.createElement(tag);
    if (className) node.className = className;
    if (text) node.textContent = text;
    return node;
  }

  function revealNear(anchor) {
    clearTimeout(hideTimer);
    card.classList.add('mf-visible');
    schedulePosition(anchor);
  }

  function schedulePosition(anchor) {
    if (positionFrame) return;
    positionFrame = requestAnimationFrame(() => {
      positionFrame = 0;
      positionCard(anchor);
    });
  }

  function positionCard(anchor) {
    if (!anchor?.isConnected || !card.classList.contains('mf-visible')) {
      if (activeHover?.anchor === anchor) closeCard(true);
      return;
    }
    const gap = 10;
    const margin = 8;
    const source = anchor.getBoundingClientRect();
    const box = card.getBoundingClientRect();
    let left = source.right + gap;
    let top = source.top;

    if (left + box.width > innerWidth - margin) left = source.left - box.width - gap;
    if (left < margin) {
      left = Math.min(Math.max(margin, source.left), innerWidth - box.width - margin);
      top = source.bottom + gap;
    }
    top = Math.max(margin, Math.min(top, innerHeight - box.height - margin));

    card.style.left = `${Math.round(left)}px`;
    card.style.top = `${Math.round(top)}px`;
  }

  // 单头像模式:只响应成员头像,不响应用户名文字;站内换页时重置旧状态。
  installRouteListeners();
  document.addEventListener('pointerover', onPointerOver, true);
  document.addEventListener('pointerout', onPointerOut, true);

  window.addEventListener('scroll', () => {
    if (activeHover) schedulePosition(activeHover.anchor);
  }, { passive: true });
  window.addEventListener('resize', () => {
    if (activeHover) schedulePosition(activeHover.anchor);
  }, { passive: true });

})();