Loading...

文章背景图

Obsidian 全局标签检索组件

2025-09-27
0
- 字
- 分钟
|

Obsidian 全局标签检索组件

// 全局可调配置(改这里即可) ====================================
const CONFIG = {
  excludedFolders: ["Template"],
  excludedTags: new Set(["mcl/list-card", "mcl/list-grid", "mcl", "算法", "蓝桥", "excalidraw"]),
  useTagCountCache: false,      // 关掉即每次实时计算,避免旧标签残留
  minTagCount: 1,               // 小于该计数的标签不显示
  defaultVisibleTags: 10,       // 初始展示的标签数量
  debounceMs: 400               // 输入防抖间隔
};

// 主题/样式配置 ================================================
const STYLE = {
  headerBg: "linear-gradient(135deg, var(--interactive-accent), #4B50E6)",
  cardBg: "var(--background-secondary)",
  cardHoverBg: "var(--background-secondary-alt)",
  textColor: "hsl(220, 25%, 85%)",
  mutedText: "var(--text-muted)",
  borderColor: "var(--background-modifier-border)",
  hoverShadow: "0 8px 24px rgba(75, 80, 230, 0.3)",
  inputBg: "var(--background-primary)",
  inputBorder: "var(--background-modifier-border)",
  inputFocusBorder: "var(--interactive-accent)",
  tagSaturation: "55%",
  tagLightness: "85%",
  tagHoverLightness: "80%",
  breakpoints: { mobile: 600, tablet: 900 },
  globalTagsBg: "var(--background-secondary-alt)",
  globalTagsBorder: "1px solid var(--background-modifier-border)",
};

// 标签计数缓存(可选)
const tagCountCache = CONFIG.useTagCountCache ? new Map() : null;

// 获取文件列表
const currentFile = app.workspace.getActiveFile();
const files = app.vault.getMarkdownFiles()
  .filter(f => f.path !== currentFile?.path)
  .filter(f => !CONFIG.excludedFolders.some(folder => f.path.startsWith(folder)));

// 辅助函数:设置样式
function setStyles(element, styles) {
  Object.assign(element.style, styles);
}

// 色相计算函数
function getTagHue(tag) {
  const hash = Array.from(tag).reduce((acc, char) => acc + char.charCodeAt(0), 0);
  return (hash * 137.508) % 360;
}

// 创建搜索框
async function createSearchBox() {
  const container = dv.el("div", "");
  setStyles(container, {
    marginBottom: "24px",
    position: "sticky",
    top: "0",
    zIndex: "100",
    padding: "12px 0",
    backdropFilter: "blur(8px)",
    background: "var(--background-primary)",
    borderRadius: "12px",
    boxShadow: "0 4px 12px rgba(0,0,0,0.05)",
    transition: "all 0.3s ease",
  });

  const inputGroup = dv.el("div", "");
  setStyles(inputGroup, {
    display: "flex",
    gap: "12px",
    alignItems: "center",
    padding: "0 16px",
  });

  const input = dv.el("input", "");
  input.placeholder = "🔍 输入标签(例:#项目/进行中)";
  setStyles(input, {
    flex: "1",
    padding: "14px 20px",
    borderRadius: "15px",
    border: `2px solid ${STYLE.inputBorder}`,
    background: STYLE.inputBg,
    fontSize: "1em",
    outline: "none",
    transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
    boxShadow: "0 2px 8px rgba(0,0,0,0.05)",
    fontFamily: "var(--font-editor)",
    color: STYLE.textColor,
  });

  input.addEventListener("focus", () => {
    setStyles(input, {
      boxShadow: `0 4px 16px ${STYLE.headerBg}40, inset 0 0 0 1px ${STYLE.inputFocusBorder}`,
      borderColor: STYLE.inputFocusBorder,
    });
  });

  input.addEventListener("blur", () => {
    setStyles(input, {
      boxShadow: "0 2px 8px rgba(0,0,0,0.05)",
      borderColor: STYLE.inputBorder,
    });
  });

  // 刷新按钮
  const refreshBtn = dv.el("button", "🔄");
  setStyles(refreshBtn, {
    padding: "8px 12px",
    borderRadius: "15px",
    border: `1px solid ${STYLE.inputBorder}`,
    background: STYLE.inputBg,
    cursor: "pointer",
    transition: "all 0.2s ease",
    color: STYLE.textColor,
  });

  refreshBtn.addEventListener("mouseenter", () => {
    setStyles(refreshBtn, {
      background: STYLE.inputBorder,
      color: "var(--background-primary)",
    });
  });

  refreshBtn.addEventListener("mouseleave", () => {
    setStyles(refreshBtn, {
      background: STYLE.inputBg,
      color: STYLE.textColor,
    });
  });

  inputGroup.appendChild(input);
  inputGroup.appendChild(refreshBtn);
  container.append(inputGroup);

  // 全局标签区域
  let globalTags = await createGlobalTags(input);
  container.appendChild(globalTags);

  // 刷新缓存功能
  refreshBtn.addEventListener("click", async () => {
    if (tagCountCache) tagCountCache.clear();
    const newGlobalTags = await createGlobalTags(input);
    container.replaceChild(newGlobalTags, globalTags);
    globalTags = newGlobalTags;
  });

  return { container, input };
}

// 创建全局标签
async function createGlobalTags(inputRef) {
  const container = dv.el("div", "");
  setStyles(container, {
    display: "flex",
    alignItems: "baseline",
    gap: "8px",
    flexWrap: "wrap",
    margin: "12px 0 0",
    padding: "12px 18px",
    background: STYLE.globalTagsBg,
    borderRadius: "8px",
    border: STYLE.globalTagsBorder,
    transition: "all 0.3s ease",
  });

  const label = dv.el("span", "🏷️ 知识图谱标签:");
  setStyles(label, {
    fontSize: "0.85em",
    whiteSpace: "nowrap",
    fontWeight: "500",
    color: STYLE.textColor,
  });
  container.appendChild(label);

  // 加载状态
  const loadingEl = dv.el("span", "标签加载中...");
  setStyles(loadingEl, {
    marginLeft: "8px",
    color: STYLE.mutedText,
  });
  container.appendChild(loadingEl);

  try {
    const tagsData = await getAllTags();
    container.removeChild(loadingEl);

    const [visibleTags, hiddenTags] = [
      tagsData.slice(0, CONFIG.defaultVisibleTags),
      tagsData.slice(CONFIG.defaultVisibleTags)
    ];

    // 创建标签元素(确保同一标签只生成一次)
    const createTagElement = (tagData) => {
      const tagEl = dv.el("span", `${tagData.tag}(${tagData.count})`);
      tagEl.classList.add('tag');
      tagEl.dataset.tag = tagData.tag; // 给标签加唯一标识,便于排查
      setStyles(tagEl, {
        '--hue': getTagHue(tagData.tag),
        '--tag-saturation': STYLE.tagSaturation,
        '--tag-lightness': STYLE.tagLightness,
        padding: "4px 12px",
        margin: "2px",
        borderRadius: "15px",
        cursor: "pointer",
        transition: "transform 0.2s ease, background 0.2s ease",
        background: `hsl(var(--hue), var(--tag-saturation), var(--tag-lightness))`,
        color: "#2c2c2c",
      });

      tagEl.addEventListener("mouseenter", () => {
        setStyles(tagEl, {
          transform: "scale(1.05)",
          background: `hsl(var(--hue), var(--tag-saturation), ${STYLE.tagHoverLightness})`,
        });
      });

      tagEl.addEventListener("mouseleave", () => {
        setStyles(tagEl, {
          transform: "none",
          background: `hsl(var(--hue), var(--tag-saturation), var(--tag-lightness))`,
        });
      });

      tagEl.addEventListener("click", (e) => {
        e.stopPropagation();
        inputRef.value = `#${tagData.tag}`;
        inputRef.dispatchEvent(new Event("input"));
      });

      return tagEl;
    };

    // 添加可见标签(直接遍历去重后的tagsData切片)
    visibleTags.forEach(tagData => {
      container.appendChild(createTagElement(tagData));
    });

    // 折叠/展开功能
    if (hiddenTags.length > 0) {
      let isExpanded = false;
      const toggleBtn = dv.el("button", `展开更多 (${hiddenTags.length}+)`);
      setStyles(toggleBtn, {
        border: `1px solid ${STYLE.headerBg}30`,
        color: STYLE.headerBg,
        padding: "4px 12px",
        borderRadius: "15px",
        fontSize: "0.8em",
        cursor: "pointer",
        transition: "all 0.2s ease",
        order: 999,
        background: "transparent",
      });

      toggleBtn.addEventListener("mouseenter", () => {
        setStyles(toggleBtn, {
          background: `${STYLE.headerBg}10`,
        });
      });

      toggleBtn.addEventListener("mouseleave", () => {
        setStyles(toggleBtn, {
          background: "transparent",
        });
      });

      // 添加隐藏标签(只添加tagsData中未显示的)
      hiddenTags.forEach(tagData => {
        const tagEl = createTagElement(tagData);
        tagEl.style.display = "none";
        container.appendChild(tagEl);
      });

      // 切换显示状态
      toggleBtn.addEventListener("click", () => {
        isExpanded = !isExpanded;
        const tagElements = Array.from(container.children).filter(el => el.classList.contains('tag'));
        const hiddenTagElements = tagElements.slice(CONFIG.defaultVisibleTags);

        hiddenTagElements.forEach(el => {
          el.style.display = isExpanded ? "inline-block" : "none";
        });

        toggleBtn.textContent = isExpanded ?
          `收起标签 (${hiddenTags.length}-)` :
          `展开更多 (${hiddenTags.length}+)`;
      });

      container.appendChild(toggleBtn);
    }
  } catch (error) {
    container.removeChild(loadingEl);
    const errEl = dv.el("span", "标签加载失败");
    setStyles(errEl, { color: "var(--text-error)" });
    container.appendChild(errEl);
    console.error("创建全局标签失败:", error);
  }

  return container;
}

// 创建结果容器
function createResultsContainer() {
  const container = dv.el("div", "");
  setStyles(container, {
    display: "grid",
    gap: "20px",
    gridTemplateColumns: "repeat(auto-fit, minmax(320px, 1fr))",
    margin: "20px 0",
  });

  // 响应式样式
  const style = dv.el("style", `
    @media (max-width: ${STYLE.breakpoints.mobile}px) {
      #${container.id} {
        grid-template-columns: 1fr !important;
      }
    }
  `);
  dv.container.appendChild(style);

  return container;
}

// 查找最近的标题
function findNearestHeader(headers, targetLine) {
  return headers.reduce((nearest, header) =>
    (header.line < targetLine && (!nearest || header.line > nearest.line))
      ? header
      : nearest, null);
}

// 生成锚点
function generateAnchor(text) {
  return encodeURIComponent(
    text.toLowerCase()
      .replace(/[^\w\u4e00-\u9fa5]+/g, "-")
      .replace(/^-+|-+$/g, "")
  );
}

// 创建卡片
function createCard(file, { header, anchor, preview }) {
  const card = dv.el("div", "");
  setStyles(card, {
    background: STYLE.cardBg,
    borderRadius: "12px",
    padding: "30px",
    margin: "5px",
    border: `1px solid ${STYLE.borderColor}`,
    boxShadow: "0 3px 6px rgba(0,0,0,0.05)",
    // 拆分过渡:不同属性用不同时长/曲线,让动画更细腻
    transition: `
      transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1),
      box-shadow 0.3s ease,
      background 0.3s ease
    `,
    cursor: "pointer",
  });

  card.addEventListener("mouseenter", () => {
    setStyles(card, {
      transform: "translateY(-5px) scale(1.02)", // 微调放大比例,更自然
      boxShadow: STYLE.hoverShadow,
      background: STYLE.cardHoverBg,
    });
  });

  card.addEventListener("mouseleave", () => {
    setStyles(card, {
      transform: "none",
      boxShadow: "0 3px 6px rgba(0,0,0,0.05)",
      background: STYLE.cardBg,
    });
  });

  // Wiki链接
  const wikiLinkText = `[[${file.basename}#${header}]]`;
  // Wiki链接(新增颜色过渡)
  const wikiLink = dv.el("div", wikiLinkText);
  setStyles(wikiLink, {
    color: STYLE.headerBg,
    fontWeight: "600",
    marginBottom: "10px",
    fontSize: "1.05em",
    textDecoration: "none",
    transition: "color 0.2s ease", // 单独为颜色变化添加过渡
  });

  wikiLink.addEventListener("mouseenter", () => {
    setStyles(wikiLink, {
      color: "var(--interactive-accent-hover)",
    });
  });

  wikiLink.addEventListener("mouseleave", () => {
    setStyles(wikiLink, {
      color: STYLE.headerBg,
    });
  });

  // 点击跳转
  card.onclick = () => {
    app.workspace.openLinkText(file.basename, file.path, true, {
      fragment: generateAnchor(header)
    });
  };

  // 标题
  const headerEl = dv.el("div", header);
  setStyles(headerEl, {
    color: STYLE.textColor,
    marginBottom: "12px",
    fontSize: "0.95em",
    fontWeight: "500",
  });

  // 预览文本
  const previewEl = dv.el("div", preview);
  setStyles(previewEl, {
    fontSize: "0.9em",
    lineHeight: "1.5",
    maxHeight: "4em",
    overflow: "hidden",
    textOverflow: "ellipsis",
    webkitLineClamp: "2",
    webkitBoxOrient: "vertical",
    display: "-webkit-box",
    color: STYLE.mutedText,
  });

  card.append(wikiLink, headerEl, previewEl);
  return card;
}

// 搜索功能
async function searchFiles(searchTerm) {
  if (!searchTerm || !searchTerm.trim()) return [];

  const terms = searchTerm.trim().split(/\s+/)
    .filter(t => t.startsWith("#"))
    .map(t =>
      t.slice(1)
        .replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&')
        .replace(/(\w+)\/(\w+)/g, "$1\\/$2")
    );

  if (terms.length === 0) return [];

  const results = [];
  const regex = new RegExp(`#(${terms.join("|")})`, "i");

  for (const file of files) {
    try {
      const content = await app.vault.cachedRead(file);
      const lines = content.split("\n");

      const headers = lines.map((line, i) => {
        const match = line.match(/^(#+)\s(.+)/);
        return match ? {
          level: match[1].length,
          text: match[2].trim(),
          line: i,
          anchor: generateAnchor(match[2].trim())
        } : null;
      }).filter(Boolean);

      lines.forEach((line, i) => {
        if (terms.every(term => new RegExp(`#${term}`, "i").test(line))) {
          const nearestHeader = findNearestHeader(headers, i);
          results.push({
            file,
            header: nearestHeader?.text || "无标题内容",
            anchor: nearestHeader
              ? `${file.basename}#${nearestHeader.anchor}`
              : file.basename,
            preview: line.replace(regex, "").trim().slice(0, 150)
          });
        }
      });
    } catch (error) {
      console.error("文件读取失败:", file.path, error);
    }
  }

  // 结果去重
  return [...new Map(results.map(item =>
    [item.file.path + item.anchor, item]
  )).values()];
}

// 更新结果
let resultsContainer, countInfo;
async function updateResults(searchTerm) {
  const loading = dv.el("div", "🔍 搜索中...");
  setStyles(loading, {
    textAlign: "center",
    padding: "30px",
    color: STYLE.textColor,
  });
  resultsContainer.replaceChildren(loading);

  try {
    const start = Date.now();
    const results = await searchFiles(searchTerm);
    const duration = Date.now() - start;

    resultsContainer.replaceChildren();

    if (results.length === 0) {
      const empty = dv.el("div", "🎯 未找到匹配内容");
      setStyles(empty, {
        textAlign: "center",
        padding: "30px",
        color: STYLE.mutedText,
      });
      resultsContainer.appendChild(empty);
    } else {
      resultsContainer.append(...results.map(r => createCard(r.file, r)));
    }

    countInfo.textContent = `找到 ${results.length} 个结果 | 耗时 ${duration}ms`;
    setStyles(countInfo, {
      margin: "20px 0",
      fontSize: "0.9em",
      textAlign: "right",
      color: STYLE.mutedText,
    });
  } catch (error) {
    const errMsg = dv.el("div", "⚠️ 搜索出错,请检查控制台");
    setStyles(errMsg, { color: "var(--text-error)" });
    resultsContainer.replaceChildren(errMsg);
    console.error("搜索失败:", error);
  }
}

// 关键修改:获取所有标签并强制去重(确保标签唯一)
async function getAllTags() {
  const tagsArray = [];
  const indexMap = {};

  // 1. 初步收集标签(基于page.file.tags去重)
  const pages = dv.pages()
    .filter(page => !CONFIG.excludedFolders.some(folder =>
      page.file.path.startsWith(folder)));

  for (const page of pages) {
    const pageTags = page.file.tags || [];
    for (const tag of pageTags) {
      // 统一标签格式:移除#、trim空格,避免格式差异导致的重复
      const cleanTag = tag.replace(/^#+/, "").trim();
      // 排除不需要的标签 + 已存在的标签
      if (!cleanTag || CONFIG.excludedTags.has(cleanTag) || indexMap[cleanTag]) continue;
      indexMap[cleanTag] = tagsArray.length;
      tagsArray.push({ tag: cleanTag, count: 0 });
    }
  }

  // 2. 为每个标签计算count(可选缓存)
  for (const tagItem of tagsArray) {
    const tagName = tagItem.tag;
    if (CONFIG.useTagCountCache && tagCountCache?.has(tagName)) {
      tagItem.count = tagCountCache.get(tagName);
      continue;
    }

    try {
      const searchResults = await searchFiles(`#${tagName}`);
      const resultCount = searchResults.length;
      tagItem.count = resultCount;
      if (CONFIG.useTagCountCache && tagCountCache) {
        tagCountCache.set(tagName, resultCount);
      }
    } catch (error) {
      console.error(`计算标签 [${tagName}] 结果数失败:`, error);
      tagItem.count = 0;
    }
  }

  // 3. 兜底去重:基于tag字段唯一化(即使前面有遗漏,这里也能确保唯一)
  // 使用Map的键唯一性,以tag为键,过滤重复标签
  const uniqueTags = [...new Map(tagsArray.map(item => [item.tag, item])).values()];

  // 4. 过滤计数过小的标签 + 按count降序排序
  return uniqueTags
    .filter(item => item.count >= CONFIG.minTagCount)
    .sort((a, b) => b.count - a.count);
}

// 初始化函数
async function initialize() {
  try {
    const { container: searchBox, input } = await createSearchBox();
    resultsContainer = createResultsContainer();

    countInfo = dv.el("div", "");
    setStyles(countInfo, {
      margin: "20px 0",
      fontSize: "0.9em",
      textAlign: "right",
    });

    dv.container.append(
      searchBox,
      resultsContainer,
      countInfo
    );

    // 防抖搜索
    let timeoutId;
    input.addEventListener("input", (e) => {
      clearTimeout(timeoutId);
      timeoutId = setTimeout(() => updateResults(e.target.value), CONFIG.debounceMs);
    });
  } catch (error) {
    console.error("程序初始化失败:", error);
    const errEl = dv.el("div", "⚠️ 标签搜索功能初始化出错,请检查控制台");
    setStyles(errEl, { color: "var(--text-error)", margin: "20px 0" });
    dv.container.appendChild(errEl);
  }
}

// 全局标签样式
dv.el("style", `
  .tag {
    background: hsl(var(--hue), var(--tag-saturation), var(--tag-lightness));
    color: #2c2c2c;
    border-radius: 15px;
    transition: transform 0.2s ease, background 0.2s ease;
  }
  .tag:hover {
    transform: scale(1.05);
    background: hsl(var(--hue), var(--tag-saturation), ${STYLE.tagHoverLightness});
  }
`);

// 启动程序
initialize();
原创

Obsidian 全局标签检索组件

本文链接: Obsidian 全局标签检索组件

本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。

评论交流

文章目录