导航目录大体功能:(使用方法:把代码放进 js 代码片段,粘贴完注意第 8 条)
- 随着笔记标签页的切换,导航内容也能相应切换
- 目录字体大小可调
- 可折叠起来
- 导航目录过长,可滚动
- 鼠标点击目录,可以导航到正文相应位置
- 鼠标悬停在目录上,可弹出浮窗
- 鼠标单击块,浮动大纲中对应的标题内容颜色会改变。再次点击会找上一级标题

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

// ========== 工具函数 ==========
// 使用原生DOMParser解析HTML并提取纯文本内容
const parseHtmlToText = (html) => {
return html.replace(/<[^>]+>/g, '').replace(/ /g, ' '); // 注意,使用时要在 后面加一个分号;
};
// 通用高亮函数,返回是否匹配成功
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();

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