第二个是脚注插件 footnote compass
main.js
/* main.js - Footnote Compass (Modified Version) */
const { Plugin, ItemView, debounce, setIcon } = require('obsidian');
const VIEW_TYPE_FOOTNOTE = "footnote-compass-view";
class FootnoteListView extends ItemView {
constructor(leaf) {
super(leaf);
// 缓存原始数据(始终保持文档中的出现顺序,用于光标定位)
this.cachedRefs = [];
// 记忆最后一次活跃的 Markdown 视图
this.lastActiveMarkdownView = null;
// 排序状态:false=原样(文档顺序), true=排序(序号顺序)
this.isSortByKey = false;
// 界面元素引用
this.listRoot = null;
this.sortBtn = null;
}
getViewType() { return VIEW_TYPE_FOOTNOTE; }
getDisplayText() { return "脚注大纲"; }
getIcon() { return "message-circle-more"; }
async onOpen() {
this.renderStructure();
// 稍微延迟,等待工作区加载
setTimeout(() => {
this.checkAndUpdate();
}, 100);
}
// --- 1. 构建界面结构 (工具栏 + 列表区) ---
renderStructure() {
const container = this.containerEl.children[1];
container.empty();
// 添加容器类名,用于 CSS Flex 布局
container.addClass("footnote-compass-view-container");
// === 创建顶部工具栏 ===
const toolbar = container.createDiv({ cls: "footnote-toolbar" });
// 创建排序按钮
this.sortBtn = toolbar.createDiv({
cls: "footnote-btn",
attr: { "aria-label": "按序号重新排列" }
});
// 固定使用一个图标 "arrow-down-0-1" (表示数字排序)
setIcon(this.sortBtn, "arrow-down-0-1");
// 绑定点击事件
this.sortBtn.addEventListener("click", () => {
this.toggleSort();
});
// === 创建列表滚动区 ===
this.listRoot = container.createDiv({ cls: "footnote-list-root" });
}
// --- 2. 切换排序逻辑 ---
toggleSort() {
this.isSortByKey = !this.isSortByKey;
// 只切换 class 类名,不换图标
if (this.isSortByKey) {
this.sortBtn.addClass("is-active");
this.sortBtn.setAttribute("aria-label", "恢复文档顺序");
} else {
this.sortBtn.removeClass("is-active");
this.sortBtn.setAttribute("aria-label", "按序号重新排列");
}
// 重新渲染列表
this.renderRefList();
}
clearView(msg) {
if (this.listRoot) {
this.listRoot.empty();
if(msg) this.listRoot.createDiv({ cls: "footnote-empty", text: msg });
}
this.cachedRefs = [];
}
// 智能寻找 Markdown 视图
findBestMarkdownLeaf() {
const active = this.app.workspace.activeLeaf;
if (active && active.view.getViewType() === 'markdown') return active;
if (this.lastActiveMarkdownView && this.lastActiveMarkdownView.leaf && this.lastActiveMarkdownView.leaf.view.getViewType() === 'markdown') {
return this.lastActiveMarkdownView.leaf;
}
const recent = this.app.workspace.getMostRecentLeaf && this.app.workspace.getMostRecentLeaf();
if (recent && recent.view.getViewType() === 'markdown') return recent;
const leaves = this.app.workspace.getLeavesOfType('markdown');
if (leaves.length > 0) return leaves[0];
return null;
}
// 主更新入口
checkAndUpdate() {
const activeLeaf = this.app.workspace.activeLeaf;
if (!activeLeaf) return;
const viewType = activeLeaf.view.getViewType();
// 1. 白板 -> 清空
if (viewType === 'canvas') {
this.clearView("当前是白板 (Canvas)");
this.lastActiveMarkdownView = null;
return;
}
// 2. Markdown -> 更新并记忆
if (viewType === 'markdown') {
this.lastActiveMarkdownView = activeLeaf.view;
this.updateView(activeLeaf.view);
return;
}
// 3. 其他 -> 尝试找回最近的 Markdown
const bestLeaf = this.findBestMarkdownLeaf();
if (bestLeaf) {
this.updateView(bestLeaf.view);
}
}
// --- 3. 数据解析 (Parse) ---
updateView(view) {
if (!this.listRoot) return;
let text = "";
try {
text = view.editor.getValue();
} catch (e) { return; }
const lines = text.split("\n");
const definitionMap = new Map();
const defRegex = /^\[\^([^\]]+)\]:\s*(.*)$/;
// Step 1: 提取定义
lines.forEach((line) => {
const match = line.match(defRegex);
if (match) definitionMap.set(match[1], match[2]);
});
// Step 2: 提取引用 (这里必须按文档顺序存储,以便 syncHighlightWithCursor 计算位置)
this.cachedRefs = [];
const refRegex = /\[\^([^\]]+)\](?!:)/g;
lines.forEach((line, lineIndex) => {
if (defRegex.test(line)) return; // 跳过定义行
let match;
while ((match = refRegex.exec(line)) !== null) {
const key = match[1];
const content = definitionMap.get(key) || "(未找到定义内容)";
this.cachedRefs.push({
key: key,
content: content,
line: lineIndex,
col: match.index,
el: null // DOM 元素稍后在 renderRefList 中绑定
});
}
});
// Step 3: 渲染界面
this.renderRefList();
// Step 4: 渲染完立刻做一次高亮检查
this.syncHighlightWithCursor(view);
}
// --- 4. 界面渲染 (Render) ---
renderRefList() {
if (!this.listRoot) return;
this.listRoot.empty();
if (this.cachedRefs.length === 0) {
this.listRoot.createDiv({ cls: "footnote-empty", text: "当前文档没有脚注引用" });
return;
}
// 决定展示顺序 (副本排序,不影响原数据)
let displayRefs = [];
if (this.isSortByKey) {
// 自然排序: 1, 2, 10 (而不是 1, 10, 2)
displayRefs = [...this.cachedRefs].sort((a, b) => {
return a.key.localeCompare(b.key, undefined, { numeric: true, sensitivity: 'base' });
});
} else {
// 默认: 按文档出现顺序
displayRefs = this.cachedRefs;
}
const listContainer = this.listRoot.createDiv({ cls: "footnote-compass-container" });
displayRefs.forEach((ref) => {
const itemEl = listContainer.createDiv({ cls: "footnote-item" });
// 【关键】将 DOM 绑定回数据对象,这样无论怎么排序,高亮逻辑都能通过 ref 找到 DOM
ref.el = itemEl;
itemEl.createDiv({ cls: "footnote-key", text: `[^${ref.key}]` });
// 【修改部分】在这里加上了序号前缀
// 原来是: text: ref.content
// 现在是: text: `${ref.key}. ${ref.content}`
itemEl.createDiv({ cls: "footnote-content", text: `${ref.key}. ${ref.content}` });
// 点击跳转逻辑
itemEl.addEventListener("click", () => {
const view = this.lastActiveMarkdownView || this.findBestMarkdownLeaf()?.view;
if (view) {
// 1. 激活叶面
this.app.workspace.setActiveLeaf(view.leaf, true, true);
// 2. 构造状态对象 (仿照 Quiet Outline)
// line: 目标行号
// cursor: 光标位置范围
const state = {
line: ref.line,
cursor: {
from: { line: ref.line, ch: ref.col },
to: { line: ref.line, ch: ref.col + 2 }
}
};
// 3. 使用 Obsidian 原生 API 跳转
// 这会自动处理滚动(通常是居中)、光标放置和底层的渲染刷新
view.setEphemeralState(state);
// 4. 手动高亮侧边栏对应项
this.highlightSpecificRef(ref);
}
});
});
}
// --- 5. 光标高亮逻辑 ---
syncHighlightWithCursor(view) {
if (!this.cachedRefs.length || !view || view.getViewType() !== 'markdown') return;
const cursor = view.editor.getCursor();
const currentLine = cursor.line;
let activeRef = null;
// 使用 cachedRefs (有序的) 来查找当前行对应的脚注
for (let i = 0; i < this.cachedRefs.length; i++) {
const ref = this.cachedRefs[i];
if (ref.line <= currentLine) {
activeRef = ref;
} else {
break;
}
}
if (activeRef) {
this.highlightSpecificRef(activeRef);
}
}
highlightSpecificRef(targetRef) {
// 遍历所有引用,只高亮目标
this.cachedRefs.forEach((ref) => {
if (!ref.el) return; // 还没渲染出来
if (ref === targetRef) {
if (!ref.el.classList.contains("is-active")) {
ref.el.addClass("is-active");
ref.el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
} else {
ref.el.removeClass("is-active");
}
});
}
}
module.exports = class FootnoteCompassPlugin extends Plugin {
async onload() {
this.registerView(VIEW_TYPE_FOOTNOTE, (leaf) => new FootnoteListView(leaf));
this.addRibbonIcon('message-circle-more', '打开脚注大纲', () => {
this.activateView();
});
this.addCommand({
id: 'open-sidebar',
name: '打开/显示侧边栏',
callback: () => { this.activateView(); }
});
// 事件防抖
const debouncedUpdate = debounce(() => {
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_FOOTNOTE);
leaves.forEach(leaf => {
if (leaf.view instanceof FootnoteListView) leaf.view.checkAndUpdate();
});
}, 200, true);
const handleCursorActivity = debounce(() => {
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_FOOTNOTE);
leaves.forEach(leaf => {
if (leaf.view instanceof FootnoteListView) {
const targetView = leaf.view.lastActiveMarkdownView || this.app.workspace.activeLeaf?.view;
if(targetView && targetView.getViewType() === 'markdown') {
leaf.view.syncHighlightWithCursor(targetView);
}
}
});
}, 50, true);
this.registerEvent(this.app.workspace.on('active-leaf-change', debouncedUpdate));
this.registerEvent(this.app.workspace.on('editor-change', debouncedUpdate));
this.registerEvent(this.app.workspace.on('layout-change', debouncedUpdate));
this.registerDomEvent(document, 'click', handleCursorActivity);
this.registerDomEvent(document, 'keyup', handleCursorActivity);
}
async activateView() {
const { workspace } = this.app;
let leaf = null;
const leaves = workspace.getLeavesOfType(VIEW_TYPE_FOOTNOTE);
if (leaves.length > 0) {
leaf = leaves[0];
workspace.revealLeaf(leaf);
} else {
leaf = workspace.getRightLeaf(false);
await leaf.setViewState({ type: VIEW_TYPE_FOOTNOTE, active: true });
workspace.revealLeaf(leaf);
}
if (leaf.view instanceof FootnoteListView) leaf.view.checkAndUpdate();
}
};
manifest.json
{
"id": "footnote-compass",
"name": "Footnote Compass",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"description": "脚注",
"author": "AI Assistant",
"authorUrl": "",
"isDesktopOnly": false
}
styles.css
/* 容器布局 */
.footnote-compass-view-container {
display: flex;
flex-direction: column;
height: 100%;
}
/* 工具栏样式 (模仿 Quiet Outline) */
.footnote-toolbar {
display: flex;
padding: 6px 10px;
border-bottom: 1px solid var(--background-modifier-border);
gap: 8px;
align-items: center;
}
/* 按钮基础样式 */
.footnote-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 4px;
color: var(--text-muted);
cursor: pointer;
transition: all 0.2s ease;
}
.footnote-btn:hover {
color: var(--text-normal);
}
.footnote-btn.is-active {
color: var(--text-on-accent);
}
/* 列表区域 */
.footnote-list-root {
flex: 1;
overflow-y: auto;
padding: 10px;
}
.footnote-item {
padding: 4px 8px;
margin-bottom: 4px;
border-radius: 4px;
cursor: pointer;
display: flex;
gap: 8px;
}
.footnote-item:hover {
background-color: var(--background-modifier-hover);
}
.footnote-item.is-active {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
}
.footnote-key {
font-weight: bold;
color: var(--text-accent);
flex-shrink: 0;
}
.footnote-item.is-active .footnote-key {
color: inherit;
}
.footnote-content {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-muted);
}
.footnote-item.is-active .footnote-content {
color: inherit;
}
/* 容器布局 */
.footnote-compass-view-container {
display: flex;
flex-direction: column;
height: 100%;
}
/* 工具栏样式 */
.footnote-toolbar {
display: flex;
padding: 6px 10px;
border-bottom: 1px solid var(--background-modifier-border);
gap: 8px;
align-items: center;
flex-shrink: 0; /* 防止被压缩 */
}
/* 按钮基础样式 */
.footnote-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 4px;
color: var(--text-muted);
cursor: pointer;
transition: all 0.2s ease;
}
/* 图标尺寸修正 */
.footnote-btn svg {
width: 16px;
height: 16px;
}
.footnote-btn:hover {
background-color: var(--background-modifier-hover);
color: var(--text-normal);
}
/* 按钮激活态 */
.footnote-btn.is-active {
color: var(--text-on-accent);
}
/* 列表区域 */
.footnote-list-root {
flex: 1;
overflow-y: auto;
padding: 10px;
}
/* 列表项 */
.footnote-item {
padding: 4px 8px;
margin-bottom: 4px;
border-radius: 4px;
cursor: pointer;
display: flex;
gap: 8px;
}
.footnote-item:hover { background-color: var(--background-modifier-hover); }
.footnote-item.is-active {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
}
.footnote-key { font-weight: bold; color: var(--text-accent); flex-shrink: 0; }
.footnote-item.is-active .footnote-key { color: inherit; }
.footnote-content {
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-muted);
}
.footnote-item.is-active .footnote-content { color: inherit; }