思源笔记可移动的导航大纲:js 代码片段

导航目录大体功能:(使用方法:把代码放进 js 代码片段,粘贴完注意第 8 条)

  1. 随着笔记标签页的切换,导航内容也能相应切换
  2. 目录字体大小可调
  3. 可折叠起来
  4. 导航目录过长,可滚动
  5. 鼠标点击目录,可以导航到正文相应位置
  6. 鼠标悬停在目录上,可弹出浮窗
  7. 鼠标单击块,浮动大纲中对应的标题内容颜色会改变。再次点击会找上一级标题
  8. image.png

看下图,应该能明白。更新时间 20250227,修复了一些 bug

screenshots.gif

// ========== 工具函数 ========== // 使用原生DOMParser解析HTML并提取纯文本内容 const parseHtmlToText = (html) => { return html.replace(/<[^>]+>/g, '').replace(/&nbsp/g, ' '); // 注意,使用时要在&nbsp后面加一个分号; }; // 通用高亮函数,返回是否匹配成功 function highlightOutlineElements(outlineContent, nodeId, textContent) { const outlineElements = outlineContent.querySelectorAll("[data-href]"); let isMatched = false; // 用于标记是否找到匹配项 outlineElements.forEach((element) => { const href = element.getAttribute('data-href'); const hrefId = href.split('/').pop(); const isMatch = nodeId ? hrefId === nodeId : element.textContent.trim() === textContent; // 只在必要时修改样式 if (isMatch && element.style.backgroundColor !== 'green') { element.style.backgroundColor = 'green'; // 高亮背景颜色 isMatched = true; // 标记匹配成功 } else if (!isMatch && element.style.backgroundColor === 'green') { element.style.backgroundColor = ''; // 恢复默认背景颜色 } }); return isMatched; // 返回是否匹配成功 } // ========== 数据获取函数 ========== // 获取文档信息,包括 rootID async function getRootID({ id }) { const response = await fetch(`/api/block/getDocInfo`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ id }), }); const data = await response.json(); return data.data.rootID; // 直接返回 rootID } // 获取文档大纲的函数 const getDocOutline = async (docId) => { try { const response = await fetch(`/api/outline/getDocOutline`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ id: docId }), }); if (!response.ok) { throw new Error(`网络请求失败:${response.status}`); } const data = await response.json(); if (data.code === 0) { return data.data; } else { console.error('获取文档目录结构失败:', data.msg); return null; } } catch (error) { console.error('获取文档目录结构失败:', error.message); return null; } }; // ========== 大纲处理函数 ========== // 收集大纲标题的函数 const collectTitles = (data) => { let titles = []; for (let item of data) { let text = (item.name || item.content).trim(); // 解析HTML为纯文本 const parsedText = parseHtmlToText(text); titles.push({ text, parsedText, id: item.id, depth: item.depth, needParse: text !== parsedText, }); // 递归处理子标题 if (item.count > 0) { titles = titles.concat(collectTitles(item.blocks ?? item.children)); } } return titles; }; // 生成大纲内容的函数 const generateOutline = async (nodeId, outlineContent) => { console.log("开始生成大纲,节点 ID:", nodeId); // 调试日志 try { if (!nodeId) { outlineContent.innerHTML = "<li>未找到有效节点,请点击文档内容。</li>"; return; } // 检查是否需要重新生成大纲 const currentOutlineNodeId = outlineContent.getAttribute('data-current-node-id'); if (currentOutlineNodeId === nodeId) { console.log("大纲已存在,无需重新生成"); // 调试日志 return; } const docOutline = await getDocOutline(nodeId); // 获取文档大纲 if (!docOutline) { outlineContent.innerHTML = "<li>无法获取文档目录结构。</li>"; return; } const titles = collectTitles(docOutline); // 收集大纲标题 const fragment = document.createDocumentFragment(); // 创建文档片段 // 遍历标题并生成列表项 titles.forEach(title => { const listItem = document.createElement("li"); const link = document.createElement("span"); link.setAttribute("data-type", "a"); link.setAttribute("data-href", `siyuan://blocks/${title.id}`); // 使用 textContent 设置内容,避免HTML被解析 link.textContent = title.parsedText; // 设置链接样式 Object.assign(link.style, { color: "#000", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", display: "inline-block", maxWidth: "100%", }); // 添加链接点击事件 link.addEventListener("mousedown", (e) => { if (e.button === 0) { e.preventDefault(); const selection = window.getSelection(); const range = document.createRange(); range.selectNodeContents(link); selection.removeAllRanges(); selection.addRange(range); } }); link.addEventListener('click', function (event) { event.preventDefault(); const href = this.getAttribute("data-href"); window.open(href, '_blank'); }); // 设置列表项的缩进 listItem.style.paddingLeft = `${title.depth * 15}px`; listItem.appendChild(link); fragment.appendChild(listItem); }); // 清空并更新大纲内容 outlineContent.innerHTML = ""; outlineContent.appendChild(fragment); // 更新当前大纲的节点 ID outlineContent.setAttribute('data-current-node-id', nodeId); console.log("大纲生成完成"); // 调试日志 } catch (error) { outlineContent.innerHTML = `<li>生成大纲失败:${error.message}</li>`; } }; // ========== 页面交互函数 ========== // 修改函数:根据 rootId 查找并返回所有兄弟元素的 title 文本数组 function getLastSiblingTitle(rootId) { const allBreadcrumbItems = document.querySelectorAll('.protyle-breadcrumb__item'); for (const item of allBreadcrumbItems) { if (item.getAttribute('data-node-id') === rootId && item.classList.contains('protyle-breadcrumb__item--active')) { const parent = item.parentElement; const siblings = Array.from(parent.children).filter(sibling => sibling !== item); const titles = []; siblings.forEach(sibling => { const titleElement = sibling.querySelector('.protyle-breadcrumb__text'); if (titleElement && titleElement.getAttribute('title')) { titles.push(titleElement.getAttribute('title')); } }); return titles; // 返回包含所有兄弟节点标题的数组 } } return []; // 如果未找到符合条件的元素,返回空数组 } // 处理 NodeHeading 类型 function handleNodeHeading(outlineContent, nodeId) { console.log("处理 NodeHeading 类型,节点 ID:", nodeId); // 调试日志 highlightOutlineElements(outlineContent, nodeId, null); } // 处理非 NodeHeading 类型 function handleNonNodeHeading(outlineContent, textContentArray) { console.log("处理非 NodeHeading 类型,兄弟节点标题数组:", textContentArray); // 调试日志 if (Array.isArray(textContentArray) && textContentArray.length > 0) { // 倒序遍历数组,依次尝试匹配 for (let i = textContentArray.length - 1; i >= 0; i--) { const currentLine = textContentArray[i]; if (highlightOutlineElements(outlineContent, null, currentLine)) { return; // 匹配成功后退出循环 } } } } // 全局点击事件处理函数 const handleClick = async (e, outlineContent) => { console.log("点击事件触发,目标元素:", e.target); // 调试日志 let target = e.target; while (target && target !== document && !target.hasAttribute('data-node-id')) { target = target.parentNode; } if (target && target.hasAttribute('data-node-id')) { const nodeId = target.getAttribute('data-node-id'); try { // 获取 rootID const rootID = await getRootID({ id: nodeId }); if (rootID) { console.log("开始生成大纲,节点 ID:", nodeId); // 调试日志 await generateOutline(nodeId, outlineContent); if (target.getAttribute('data-type') === 'NodeHeading') { handleNodeHeading(outlineContent, nodeId); } else { const siblingTitles = getLastSiblingTitle(rootID); if (siblingTitles.length > 0) { handleNonNodeHeading(outlineContent, siblingTitles); } } } } catch (error) { console.error('获取 rootID 失败:', error.message); } } else { outlineContent.innerHTML = "<li>未找到有效节点,请点击文档内容。</li>"; } }; // ========== UI 创建函数 ========== // 创建大纲面板的函数 const createOutlinePanel = () => { const outlinePanel = document.createElement("div"); Object.assign(outlinePanel.style, { position: "fixed", top: "100px", left: "1000px", width: "200px", height: "30px", background: "#f1f1f1", border: "1px solid #ccc", borderRadius: "5px", padding: "0", boxShadow: "0 0 10px rgba(0, 0, 0, 0.1)", zIndex: "1000", overflow: "hidden", }); const topButtonsContainer = createTopButtonsContainer(); outlinePanel.appendChild(topButtonsContainer); const outlineTitle = document.createElement("h3"); outlineTitle.textContent = "大纲"; Object.assign(outlineTitle.style, { margin: "0 0 10px", }); outlinePanel.appendChild(outlineTitle); const outlineContent = document.createElement("ul"); outlineContent.id = "outline-list"; Object.assign(outlineContent.style, { listStyle: "none", padding: "0", margin: "0", fontSize: "14px", marginTop: "20px", overflowY: "auto", maxHeight: "340px", }); outlineContent.setAttribute("draggable", "false"); outlineContent.addEventListener("dragstart", (e) => e.preventDefault()); outlinePanel.appendChild(outlineContent); const toggleButtonInstance = topButtonsContainer.querySelector('button:nth-child(2)'); toggleButtonInstance.textContent = "展开"; let isExpanded = false; toggleButtonInstance.addEventListener("click", () => { isExpanded = !isExpanded; outlinePanel.style.height = isExpanded ? "400px" : `${topButtonsContainer.offsetHeight}px`; toggleButtonInstance.textContent = isExpanded ? "折叠" : "展开"; }); enableDragging(outlinePanel, topButtonsContainer); return { outlinePanel, outlineContent }; }; // 创建顶部按钮容器的函数 const createTopButtonsContainer = () => { const topButtonsContainer = document.createElement("div"); Object.assign(topButtonsContainer.style, { position: "absolute", top: "0", left: "0", right: "0", height: "20px", backgroundColor: "#f1f1f1", display: "flex", justifyContent: "space-between", alignItems: "center", padding: "5px", }); const showOutlineButton = document.createElement("button"); showOutlineButton.textContent = "思源大纲"; Object.assign(showOutlineButton.style, { padding: "5px", background: "#007bff", color: "#fff", border: "none", borderRadius: "5px", cursor: "pointer", }); const toggleButtonElement = document.createElement("button"); toggleButtonElement.textContent = "展开"; Object.assign(toggleButtonElement.style, { padding: "5px", background: "#ccc", border: "none", borderRadius: "5px", cursor: "pointer", }); topButtonsContainer.appendChild(showOutlineButton); topButtonsContainer.appendChild(toggleButtonElement); return topButtonsContainer; }; // 创建字体大小调整按钮的函数 const createFontButtonsContainer = (outlineContent) => { const fontButtonsContainer = document.createElement("div"); Object.assign(fontButtonsContainer.style, { display: "flex", gap: "5px", }); const decreaseFontSizeButton = document.createElement("button"); decreaseFontSizeButton.textContent = "-"; Object.assign(decreaseFontSizeButton.style, { width: "20px", height: "20px", fontSize: "16px", border: "none", background: "#ccc", borderRadius: "50%", }); decreaseFontSizeButton.addEventListener("click", () => { const currentSize = parseFloat(outlineContent.style.fontSize); outlineContent.style.fontSize = `${Math.max(currentSize - 1, 10)}px`; }); const increaseFontSizeButton = document.createElement("button"); increaseFontSizeButton.textContent = "+"; Object.assign(increaseFontSizeButton.style, { width: "20px", height: "20px", fontSize: "16px", border: "none", background: "#ccc", borderRadius: "50%", }); increaseFontSizeButton.addEventListener("click", () => { const currentSize = parseFloat(outlineContent.style.fontSize); outlineContent.style.fontSize = `${Math.min(currentSize + 1, 24)}px`; }); fontButtonsContainer.appendChild(decreaseFontSizeButton); fontButtonsContainer.appendChild(increaseFontSizeButton); return fontButtonsContainer; }; // 实现面板拖动功能的函数 const enableDragging = (outlinePanel, topButtonsContainer) => { let isDragging = false; let offsetX, offsetY; topButtonsContainer.style.cursor = "move"; topButtonsContainer.addEventListener("mousedown", (e) => { if (isDragging) return; isDragging = true; offsetX = e.clientX - Number(outlinePanel.style.left.replace('px', '')); offsetY = e.clientY - Number(outlinePanel.style.top.replace('px', '')); const onMouseMove = (e) => { if (!isDragging) return; let newX = e.clientX - offsetX; let newY = e.clientY - offsetY; const minX = 20; const minY = 20; const maxX = window.innerWidth - outlinePanel.offsetWidth - 20; const maxY = window.innerHeight - outlinePanel.offsetHeight - 20; newX = Math.max(minX, Math.min(newX, maxX)); newY = Math.max(minY, Math.min(newY, maxY)); outlinePanel.style.left = `${newX}px`; outlinePanel.style.top = `${newY}px`; }; const onMouseUp = () => { isDragging = false; document.removeEventListener("mousemove", onMouseMove); document.removeEventListener("mouseup", onMouseUp); }; document.addEventListener("mousemove", onMouseMove); document.addEventListener("mouseup", onMouseUp); }); }; // ========== 主函数 ========== const main = () => { const { outlinePanel, outlineContent } = createOutlinePanel(); document.body.appendChild(outlinePanel); const fontButtonsContainer = createFontButtonsContainer(outlineContent); const topButtonsContainer = outlinePanel.querySelector('div'); topButtonsContainer.appendChild(fontButtonsContainer); document.addEventListener('click', (e) => handleClick(e, outlineContent)); }; main();
  • 思源笔记

    思源笔记是一款隐私优先的个人知识管理系统,支持完全离线使用,同时也支持端到端加密同步。

    融合块、大纲和双向链接,重构你的思维。

    25730 引用 • 106476 回帖
2 操作
cxg318 在 2025-02-27 01:25:05 更新了该帖
cxg318 在 2025-02-24 00:11:08 更新了该帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...
  • NieJianYing via macOS

    丸辣,手机伺服用不了,会从网页跳到桌面程序

  • 其他回帖
  • att88 2 评论

    我个人可太需要这个了好嘛!之前论坛找了好几次是否有悬浮大纲悬浮文档树,结果都是没有。思源大纲虽说可以四处移动,但是高宽还是受到主页面限制,有时候大纲没几行内容还占了一大片,老按快捷键开关也很麻烦。就很希望能像 ps 那样工具面板都能页面外悬浮,能专注页面本身不被视觉中心外的东西干扰。感谢作者!希望尽快上架

    文档树原来就是可以悬浮的,但悬浮大纲这个是真没有
    Floria233
    请问文档树怎么悬浮?
    att88
  • att88

    确实填的是 js。代码中有一段 // 解码HTML实体的函数 我这边报错,应该是大大代码块粘贴的时候 html 直接给解析了。改回去后我现在运行正常。感谢!!

    image.png

    1 回复
  • cxg318
    作者

    确实,粘贴时自动被系统解析了。只能粘贴完代码,手动改一下了。因为这是发贴的系统自动改的。

  • 查看全部回帖

推荐标签 标签

  • 爬虫

    网络爬虫(Spider、Crawler),是一种按照一定的规则,自动地抓取万维网信息的程序。

    106 引用 • 275 回帖 • 1 关注
  • Logseq

    Logseq 是一个隐私优先、开源的知识库工具。

    Logseq is a joyful, open-source outliner that works on top of local plain-text Markdown and Org-mode files. Use it to write, organize and share your thoughts, keep your to-do list, and build your own digital garden.

    7 引用 • 69 回帖 • 7 关注
  • 心情

    心是产生任何想法的源泉,心本体会陷入到对自己本体不能理解的状态中,因为心能产生任何想法,不能分出对错,不能分出自己。

    59 引用 • 369 回帖
  • 链滴

    链滴是一个记录生活的地方。

    记录生活,连接点滴

    178 引用 • 3866 回帖
  • ZooKeeper

    ZooKeeper 是一个分布式的,开放源码的分布式应用程序协调服务,是 Google 的 Chubby 一个开源的实现,是 Hadoop 和 HBase 的重要组件。它是一个为分布式应用提供一致性服务的软件,提供的功能包括:配置维护、域名服务、分布式同步、组服务等。

    59 引用 • 29 回帖 • 3 关注
  • Elasticsearch

    Elasticsearch 是一个基于 Lucene 的搜索服务器。它提供了一个分布式多用户能力的全文搜索引擎,基于 RESTful 接口。Elasticsearch 是用 Java 开发的,并作为 Apache 许可条款下的开放源码发布,是当前流行的企业级搜索引擎。设计用于云计算中,能够达到实时搜索,稳定,可靠,快速,安装使用方便。

    117 引用 • 99 回帖 • 198 关注
  • ZeroNet

    ZeroNet 是一个基于比特币加密技术和 BT 网络技术的去中心化的、开放开源的网络和交流系统。

    1 引用 • 21 回帖 • 648 关注
  • FlowUs

    FlowUs.息流 个人及团队的新一代生产力工具。

    让复杂的信息管理更轻松、自由、充满创意。

    1 引用 • 2 关注
  • 导航

    各种网址链接、内容导航。

    44 引用 • 177 回帖
  • Swift

    Swift 是苹果于 2014 年 WWDC(苹果开发者大会)发布的开发语言,可与 Objective-C 共同运行于 Mac OS 和 iOS 平台,用于搭建基于苹果平台的应用程序。

    34 引用 • 37 回帖 • 551 关注
  • 快应用

    快应用 是基于手机硬件平台的新型应用形态;标准是由主流手机厂商组成的快应用联盟联合制定;快应用标准的诞生将在研发接口、能力接入、开发者服务等层面建设标准平台;以平台化的生态模式对个人开发者和企业开发者全品类开放。

    15 引用 • 127 回帖 • 1 关注
  • Swagger

    Swagger 是一款非常流行的 API 开发工具,它遵循 OpenAPI Specification(这是一种通用的、和编程语言无关的 API 描述规范)。Swagger 贯穿整个 API 生命周期,如 API 的设计、编写文档、测试和部署。

    26 引用 • 35 回帖 • 4 关注
  • Outlook
    1 引用 • 5 回帖 • 3 关注
  • 学习

    “梦想从学习开始,事业从实践起步” —— 习近平

    172 引用 • 534 回帖
  • IPFS

    IPFS(InterPlanetary File System,星际文件系统)是永久的、去中心化保存和共享文件的方法,这是一种内容可寻址、版本化、点对点超媒体的分布式协议。请浏览 IPFS 入门笔记了解更多细节。

    20 引用 • 245 回帖 • 227 关注
  • 安全

    安全永远都不是一个小问题。

    199 引用 • 818 回帖
  • LeetCode

    LeetCode(力扣)是一个全球极客挚爱的高质量技术成长平台,想要学习和提升专业能力从这里开始,充足技术干货等你来啃,轻松拿下 Dream Offer!

    209 引用 • 72 回帖
  • Mobi.css

    Mobi.css is a lightweight, flexible CSS framework that focus on mobile.

    1 引用 • 6 回帖 • 764 关注
  • Q&A

    提问之前请先看《提问的智慧》,好的问题比好的答案更有价值。

    9858 引用 • 44812 回帖 • 78 关注
  • danl
    171 关注
  • SQLite

    SQLite 是一个进程内的库,实现了自给自足的、无服务器的、零配置的、事务性的 SQL 数据库引擎。SQLite 是全世界使用最为广泛的数据库引擎。

    4 引用 • 7 回帖 • 8 关注
  • 区块链

    区块链是分布式数据存储、点对点传输、共识机制、加密算法等计算机技术的新型应用模式。所谓共识机制是区块链系统中实现不同节点之间建立信任、获取权益的数学算法 。

    92 引用 • 752 回帖
  • 创业

    你比 99% 的人都优秀么?

    82 引用 • 1395 回帖
  • 外包

    有空闲时间是接外包好呢还是学习好呢?

    26 引用 • 233 回帖 • 1 关注
  • JetBrains

    JetBrains 是一家捷克的软件开发公司,该公司位于捷克的布拉格,并在俄国的圣彼得堡及美国麻州波士顿都设有办公室,该公司最为人所熟知的产品是 Java 编程语言开发撰写时所用的集成开发环境:IntelliJ IDEA

    18 引用 • 54 回帖 • 1 关注
  • V2Ray
    1 引用 • 15 回帖 • 1 关注
  • SMTP

    SMTP(Simple Mail Transfer Protocol)即简单邮件传输协议,它是一组用于由源地址到目的地址传送邮件的规则,由它来控制信件的中转方式。SMTP 协议属于 TCP/IP 协议簇,它帮助每台计算机在发送或中转信件时找到下一个目的地。

    4 引用 • 18 回帖 • 635 关注