diff --git a/plugin/knowledgePoint.js b/plugin/knowledgePoint.js
new file mode 100644
index 00000000..b34b46af
--- /dev/null
+++ b/plugin/knowledgePoint.js
@@ -0,0 +1,1193 @@
+class knowledgePoint extends BaseCustomPlugin {
+ beforeProcess() {
+ try {
+ this.fs = reqnode("fs");
+ this.path = reqnode("path");
+ } catch (e) {
+ return this.utils.stopLoadPluginError;
+ }
+ }
+
+ style() {
+ const css = `
+/* ==================== Panel ==================== */
+#plugin-knowledge-point-panel {
+ position: fixed;
+ top: 0;
+ right: -340px;
+ width: 320px;
+ height: 100vh;
+ z-index: 9999;
+ background: var(--bg-color, #fff);
+ border-left: 1px solid var(--border-color, #e0e0e0);
+ box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
+ display: flex;
+ flex-direction: column;
+ transition: right 0.3s ease;
+ font-family: var(--font-family, sans-serif);
+ color: var(--text-color, #333);
+}
+
+#plugin-knowledge-point-panel.open {
+ right: 0;
+}
+
+/* ==================== Header ==================== */
+.plugin-kp-header {
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--border-color, #e0e0e0);
+ flex-shrink: 0;
+}
+
+.plugin-kp-header-top {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 10px;
+}
+
+.plugin-kp-header-actions {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.plugin-kp-header-title {
+ font-size: 16px;
+ font-weight: 600;
+ margin-bottom: 10px;
+}
+
+.plugin-kp-scope-switch {
+ display: flex;
+ gap: 4px;
+}
+
+.plugin-kp-scope-btn {
+ padding: 4px 12px;
+ border: 1px solid var(--border-color, #e0e0e0);
+ border-radius: 4px;
+ background: transparent;
+ color: var(--text-color, #333);
+ font-size: 12px;
+ cursor: pointer;
+ transition: background 0.2s, color 0.2s;
+}
+
+.plugin-kp-scope-btn:hover {
+ background: var(--bg-color-secondary, #f5f5f5);
+}
+
+.plugin-kp-scope-btn.active {
+ background: var(--primary-color, #4a90d9);
+ color: #fff;
+ border-color: var(--primary-color, #4a90d9);
+}
+
+/* ==================== Search ==================== */
+.plugin-kp-search {
+ padding: 10px 16px;
+ flex-shrink: 0;
+}
+
+.plugin-kp-search-input {
+ width: 100%;
+ padding: 6px 10px;
+ border: 1px solid var(--border-color, #e0e0e0);
+ border-radius: 4px;
+ font-size: 13px;
+ background: var(--bg-color, #fff);
+ color: var(--text-color, #333);
+ outline: none;
+ box-sizing: border-box;
+}
+
+.plugin-kp-search-input:focus {
+ border-color: var(--primary-color, #4a90d9);
+ box-shadow: 0 0 0 2px rgba(74, 144, 217, 0.2);
+}
+
+/* ==================== List ==================== */
+.plugin-kp-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0 8px;
+}
+
+/* ==================== Knowledge Point Item ==================== */
+.plugin-kp-item {
+ border-bottom: 1px solid var(--border-color, #e0e0e0);
+}
+
+.plugin-kp-item-header {
+ display: flex;
+ align-items: center;
+ padding: 8px;
+ cursor: pointer;
+ user-select: none;
+ border-radius: 4px;
+ transition: background 0.2s;
+}
+
+.plugin-kp-item-header:hover {
+ background: var(--bg-color-secondary, #f5f5f5);
+}
+
+.plugin-kp-item-arrow {
+ margin-right: 6px;
+ font-size: 10px;
+ transition: transform 0.2s;
+}
+
+.plugin-kp-item.expanded .plugin-kp-item-arrow {
+ transform: rotate(90deg);
+}
+
+.plugin-kp-item-name {
+ font-weight: 500;
+ font-size: 14px;
+ flex: 1;
+}
+
+.plugin-kp-item-count {
+ font-size: 11px;
+ color: var(--text-color-secondary, #999);
+ margin-left: 8px;
+}
+
+.plugin-kp-item-entries {
+ display: none;
+ padding: 0 8px 8px 24px;
+}
+
+.plugin-kp-item.expanded .plugin-kp-item-entries {
+ display: block;
+}
+
+/* ==================== Entry Items ==================== */
+.plugin-kp-entry {
+ padding: 6px 8px;
+ margin-bottom: 4px;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: background 0.2s;
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+.plugin-kp-entry:hover {
+ background: var(--bg-color-secondary, #f5f5f5);
+}
+
+.plugin-kp-entry.disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ pointer-events: none;
+}
+
+.plugin-kp-entry-type {
+ display: inline-block;
+ padding: 1px 6px;
+ border-radius: 3px;
+ font-size: 11px;
+ font-weight: 500;
+ margin-right: 6px;
+}
+
+.plugin-kp-entry-type.def {
+ background: #fff3cd;
+ color: #856404;
+}
+
+.plugin-kp-entry-type.ref {
+ background: #cce5ff;
+ color: #004085;
+}
+
+.plugin-kp-entry-location {
+ font-size: 11px;
+ color: var(--text-color-secondary, #999);
+ margin-left: 4px;
+}
+
+.plugin-kp-entry-context {
+ margin-top: 4px;
+ font-size: 12px;
+ color: var(--text-color-secondary, #666);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* ==================== Footer ==================== */
+.plugin-kp-footer {
+ padding: 8px 16px;
+ border-top: 1px solid var(--border-color, #e0e0e0);
+ flex-shrink: 0;
+}
+
+.plugin-kp-status {
+ font-size: 11px;
+ color: var(--text-color-secondary, #999);
+}
+
+.plugin-kp-refresh-btn {
+ padding: 4px 10px;
+ border: 1px solid var(--border-color, #e0e0e0);
+ border-radius: 4px;
+ background: transparent;
+ color: var(--text-color, #333);
+ font-size: 12px;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.plugin-kp-refresh-btn:hover {
+ background: var(--bg-color-secondary, #f5f5f5);
+}
+
+/* ==================== Empty State ==================== */
+.plugin-kp-empty {
+ padding: 20px 16px;
+ text-align: center;
+ color: var(--text-color-secondary, #999);
+ font-size: 13px;
+}
+
+/* ==================== Highlight Animation ==================== */
+@keyframes kp-highlight-fade {
+ from { background-color: rgba(255, 235, 59, 0.6); }
+ to { background-color: transparent; }
+}
+
+.plugin-kp-highlight {
+ animation: kp-highlight-fade 2s ease-out forwards;
+}
+
+/* ==================== Resize Handle ==================== */
+.plugin-kp-resize-handle {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 4px;
+ height: 100%;
+ cursor: col-resize;
+ z-index: 1;
+}
+
+.plugin-kp-resize-handle:hover {
+ background: var(--primary-color, #4a90d9);
+}
+
+/* ==================== Window Control Buttons ==================== */
+.plugin-kp-win-btn {
+ width: 32px;
+ height: 28px;
+ border: none;
+ background: transparent;
+ color: var(--text-color, #333);
+ font-size: 12px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ border-radius: 0;
+}
+
+.plugin-kp-minimize-btn:hover,
+.plugin-kp-maximize-btn:hover {
+ background: rgba(0, 0, 0, 0.06);
+}
+
+.plugin-kp-close-btn:hover {
+ background: #e81123;
+ color: #fff;
+}
+
+/* ==================== Autocomplete Dropdown ==================== */
+.plugin-kp-autocomplete {
+ position: fixed;
+ display: none;
+ z-index: 10000;
+ background: var(--bg-color, #fff);
+ border: 1px solid var(--border-color, #e0e0e0);
+ border-radius: 4px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ max-height: 200px;
+ overflow-y: auto;
+ min-width: 160px;
+ max-width: 300px;
+}
+
+.plugin-kp-autocomplete.visible {
+ display: block;
+}
+
+.plugin-kp-autocomplete-item {
+ padding: 6px 12px;
+ font-size: 13px;
+ cursor: pointer;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ transition: background 0.15s;
+}
+
+.plugin-kp-autocomplete-item:hover,
+.plugin-kp-autocomplete-item.active {
+ background: var(--primary-color, #4a90d9);
+ color: #fff;
+}
+
+/* ==================== Inline Rendering (WYSIWYG) ==================== */
+.kp-delim {
+ font-size: 0;
+ line-height: 0;
+ letter-spacing: -1px;
+ user-select: none;
+}
+
+.kp-tag {
+ color: #7c3aed;
+ background: rgba(124, 58, 237, 0.12);
+ padding: 2px 4px;
+ border-radius: 3px;
+}
+
+/* Source mode override: show @@ normally */
+#write.source .kp-delim {
+ font-size: inherit;
+ line-height: inherit;
+ letter-spacing: normal;
+ user-select: auto;
+}
+
+#write.source .kp-tag {
+ color: inherit;
+ background: transparent;
+ padding: 0;
+ border-radius: 0;
+}
+`;
+ return css;
+ }
+
+ html() {
+ return `
+
+
+`;
+ }
+
+ hint() {
+ return "知识点检索 — 按标签查找知识点 (Ctrl+Shift+K)";
+ }
+
+ hotkey() {
+ return [this.config.hotkey];
+ }
+
+ init() {
+ this.panel = document.querySelector("#plugin-knowledge-point-panel");
+ this.listEl = this.panel.querySelector(".plugin-kp-list");
+ this.searchInput = this.panel.querySelector(".plugin-kp-search-input");
+ this.statusEl = this.panel.querySelector(".plugin-kp-status");
+ this.scopeButtons = this.panel.querySelectorAll(".plugin-kp-scope-btn");
+ this.refreshBtn = this.panel.querySelector(".plugin-kp-refresh-btn");
+ this.closeBtn = this.panel.querySelector(".plugin-kp-close-btn");
+ this.minimizeBtn = this.panel.querySelector(".plugin-kp-minimize-btn");
+ this.maximizeBtn = this.panel.querySelector(".plugin-kp-maximize-btn");
+ this.searchBox = this.panel.querySelector(".plugin-kp-search");
+ this.footer = this.panel.querySelector(".plugin-kp-footer");
+ this.resizeHandle = this.panel.querySelector(".plugin-kp-resize-handle");
+ this.autocompleteEl = document.querySelector(".plugin-kp-autocomplete");
+ this._initResize();
+
+ // index: Map>
+ this.index = new Map();
+ this.scope = this.config.default_scope || "folder";
+ this.currentFile = this.utils.getFilePath() || "";
+ this.folderPath = this.currentFile ? this.path.dirname(this.currentFile) : "";
+ this.isMaximized = false;
+ this.defaultWidth = 320;
+ this.acVisible = false;
+ this.acIndex = -1;
+ this.acItems = [];
+ }
+
+ process() {
+ // Search input filtering
+ this.searchInput.addEventListener("input", () => {
+ const query = this.searchInput.value.trim();
+ this._filter(query);
+ });
+
+ // Scope switching
+ this.scopeButtons.forEach(btn => {
+ btn.addEventListener("click", () => {
+ this.scope = btn.dataset.scope;
+ this.scopeButtons.forEach(b => b.classList.remove("active"));
+ btn.classList.add("active");
+ this.callback();
+ });
+ });
+
+ // Refresh button — auto-saves current file then re-scans
+ this.refreshBtn.addEventListener("click", () => {
+ this._refresh();
+ });
+
+ // Close button
+ this.closeBtn.addEventListener("click", () => {
+ this.panel.classList.remove("open");
+ });
+
+ // Minimize button — collapse panel body
+ this.minimizeBtn.addEventListener("click", () => {
+ const collapsed = this.panel.classList.toggle("minimized");
+ this.searchBox.style.display = collapsed ? "none" : "";
+ this.listEl.style.display = collapsed ? "none" : "";
+ this.footer.style.display = collapsed ? "none" : "";
+ });
+
+ // Maximize button — expand to full width
+ this.maximizeBtn.addEventListener("click", () => {
+ this.isMaximized = !this.isMaximized;
+ if (this.isMaximized) {
+ this.defaultWidth = this.panel.offsetWidth;
+ this.panel.style.width = "100%";
+ } else {
+ this.panel.style.width = this.defaultWidth + "px";
+ }
+ });
+
+ // Listen for file edits to re-scan (debounced, always active)
+ const { eventHub } = this.utils;
+ const debouncedRefresh = this.utils.debounce(() => {
+ if (this.panel.classList.contains("open")) {
+ this._refresh();
+ }
+ }, 500);
+ eventHub.addEventListener(eventHub.eventType.fileEdited, debouncedRefresh);
+
+ // Close panel on Escape (first restore from minimized, then close)
+ document.addEventListener("keydown", (e) => {
+ if (e.key === "Escape" && this.panel.classList.contains("open")) {
+ if (this.panel.classList.contains("minimized")) {
+ this.panel.classList.remove("minimized");
+ this.searchBox.style.display = "";
+ this.listEl.style.display = "";
+ this.footer.style.display = "";
+ } else {
+ this.panel.classList.remove("open");
+ }
+ }
+ });
+
+ // Search box: Escape clears or closes
+ this.searchInput.addEventListener("keydown", (e) => {
+ if (e.key === "Escape") {
+ if (this.searchInput.value) {
+ this.searchInput.value = "";
+ this._filter("");
+ } else {
+ this.panel.classList.remove("open");
+ }
+ e.stopPropagation();
+ }
+ });
+
+ // Re-scan when window regains focus (user switched files externally)
+ let focusTimer = null;
+ window.addEventListener("focus", () => {
+ if (!this.panel.classList.contains("open")) return;
+ clearTimeout(focusTimer);
+ focusTimer = setTimeout(() => {
+ const newFile = this.utils.getFilePath() || "";
+ if (newFile && newFile !== this.currentFile) {
+ this._refresh();
+ }
+ }, 300);
+ });
+
+ // === Inline rendering: hide @@ and style tag name in WYSIWYG ===
+ this._rendering = false;
+ const writeEl = document.querySelector("#write");
+ if (writeEl) {
+ this._observer = new MutationObserver(() => {
+ if (this._rendering) return;
+ this._rendering = true;
+ try {
+ this._renderInline(writeEl);
+ } finally {
+ this._rendering = false;
+ }
+ });
+ this._observer.observe(writeEl, { childList: true, subtree: true, characterData: true });
+ // Initial render
+ this._renderInline(writeEl);
+ }
+
+ // === Autocomplete on @@ ===
+ // Listen for input on editor to detect @@
+ const editorEl = document.querySelector("#write");
+ if (editorEl) {
+ editorEl.addEventListener("input", () => {
+ this._acCheck();
+ });
+ }
+
+ // Keyboard navigation for autocomplete
+ document.addEventListener("keydown", (e) => {
+ if (!this.acVisible) return;
+
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ e.stopPropagation();
+ this._acMove(1);
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ e.stopPropagation();
+ this._acMove(-1);
+ } else if (e.key === "Enter" || e.key === "Tab") {
+ if (this.acIndex >= 0 && this.acIndex < this.acItems.length) {
+ e.preventDefault();
+ e.stopPropagation();
+ this._acSelect(this.acItems[this.acIndex]);
+ }
+ } else if (e.key === "Escape") {
+ this._acHide();
+ }
+ }, true);
+
+ // Close autocomplete on click outside
+ document.addEventListener("mousedown", (e) => {
+ if (this.acVisible && !this.autocompleteEl.contains(e.target)) {
+ this._acHide();
+ }
+ });
+
+ // Update dropdown position on scroll
+ document.addEventListener("scroll", () => {
+ if (this.acVisible) {
+ this._acPosition();
+ }
+ }, true);
+ }
+
+ callback = anchorNode => {
+ const isOpen = this.panel.classList.toggle("open");
+ if (!isOpen) return;
+
+ // Get current file path
+ this.currentFile = this.utils.getFilePath();
+ if (this.currentFile) {
+ this.folderPath = this.path.dirname(this.currentFile);
+ }
+
+ // Trigger scan and render
+ this._scan();
+ this._renderPanel();
+ }
+
+ _refresh() {
+ // Auto-save current file before scanning
+ try {
+ if (typeof File !== "undefined" && File.editor && typeof File.save === "function") {
+ File.save();
+ }
+ } catch (e) { /* ignore save errors */ }
+
+ this.currentFile = this.utils.getFilePath() || "";
+ if (this.currentFile) {
+ this.folderPath = this.path.dirname(this.currentFile);
+ }
+ this._scan();
+ this._renderPanel();
+ }
+
+ _initResize() {
+ let startX, startWidth;
+ const panel = this.panel;
+
+ const onMouseMove = (e) => {
+ const dx = startX - e.clientX;
+ const newWidth = Math.max(240, Math.min(600, startWidth + dx));
+ panel.style.width = newWidth + "px";
+ };
+
+ const onMouseUp = () => {
+ document.removeEventListener("mousemove", onMouseMove);
+ document.removeEventListener("mouseup", onMouseUp);
+ document.body.style.cursor = "";
+ document.body.style.userSelect = "";
+ };
+
+ this.resizeHandle.addEventListener("mousedown", (e) => {
+ startX = e.clientX;
+ startWidth = panel.offsetWidth;
+ document.addEventListener("mousemove", onMouseMove);
+ document.addEventListener("mouseup", onMouseUp);
+ document.body.style.cursor = "col-resize";
+ document.body.style.userSelect = "none";
+ e.preventDefault();
+ });
+ }
+
+ _escapeRegex(str) {
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ }
+
+ _findMdFiles(dirPath, depth = 0) {
+ const maxDepth = 5;
+ const excluded = new Set(["node_modules", ".git", ".svn", ".hg"]);
+ const results = [];
+ if (depth > maxDepth) return results;
+ let entries;
+ try {
+ entries = this.fs.readdirSync(dirPath, { withFileTypes: true });
+ } catch (e) {
+ return results;
+ }
+ for (const entry of entries) {
+ if (excluded.has(entry.name)) continue;
+ const fullPath = this.path.join(dirPath, entry.name);
+ if (entry.isDirectory()) {
+ if (!entry.name.startsWith(".")) {
+ results.push(...this._findMdFiles(fullPath, depth + 1));
+ }
+ } else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
+ results.push(fullPath);
+ }
+ }
+ return results;
+ }
+
+ _scanFile(filePath) {
+ const prefix = this._escapeRegex(this.config.tag_prefix);
+ const suffix = this._escapeRegex(this.config.tag_suffix);
+ const regex = new RegExp(`${prefix}(.+?)${suffix}`, "g");
+ const entries = [];
+ const basename = this.path.basename(filePath);
+ let content;
+ try {
+ content = this.fs.readFileSync(filePath, "utf-8");
+ } catch (e) {
+ return entries;
+ }
+ const lines = content.split("\n");
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ regex.lastIndex = 0;
+ let match;
+ while ((match = regex.exec(line)) !== null) {
+ entries.push({
+ name: match[1],
+ file: basename,
+ filePath: filePath,
+ line: i + 1,
+ context: line.trim(),
+ element: null,
+ });
+ }
+ }
+ return entries;
+ }
+
+ _scan() {
+ this.index = new Map();
+ if (!this.currentFile || !this.folderPath) return;
+
+ let allEntries = [];
+ if (this.scope === "folder") {
+ const mdFiles = this._findMdFiles(this.folderPath);
+ mdFiles.sort();
+ for (const f of mdFiles) {
+ allEntries = allEntries.concat(this._scanFile(f));
+ }
+ } else if (this.scope === "file") {
+ allEntries = this._scanFile(this.currentFile);
+ }
+
+ // Build index: first occurrence = definition, subsequent = references
+ for (const entry of allEntries) {
+ if (!this.index.has(entry.name)) {
+ this.index.set(entry.name, { definition: entry, references: [] });
+ } else {
+ this.index.get(entry.name).references.push(entry);
+ }
+ }
+
+ this._scanCurrentDOM();
+ }
+
+ _scanCurrentDOM() {
+ if (!this.currentFile) return;
+
+ const prefix = this._escapeRegex(this.config.tag_prefix);
+ const suffix = this._escapeRegex(this.config.tag_suffix);
+ const regex = new RegExp(`${prefix}(.+?)${suffix}`, "g");
+
+ const elements = document.querySelectorAll("#write > [cid]");
+ const domMap = new Map(); // name -> DOM element[]
+
+ for (const el of elements) {
+ const text = el.textContent || "";
+ regex.lastIndex = 0;
+ let match;
+ while ((match = regex.exec(text)) !== null) {
+ const name = match[1];
+ if (!domMap.has(name)) {
+ domMap.set(name, []);
+ }
+ domMap.get(name).push(el);
+ }
+ }
+
+ // Associate DOM elements with index entries for the current file
+ for (const [name, item] of this.index) {
+ const isDefInCurrentFile = item.definition && item.definition.filePath === this.currentFile;
+ const refsInCurrentFile = item.references.filter(r => r.filePath === this.currentFile);
+ const domElements = domMap.get(name) || [];
+
+ if (domElements.length === 0) continue;
+
+ let elIdx = 0;
+ if (isDefInCurrentFile && item.definition) {
+ item.definition.element = domElements[elIdx] || null;
+ elIdx++;
+ }
+ for (const ref of refsInCurrentFile) {
+ ref.element = domElements[elIdx] || null;
+ elIdx++;
+ }
+ }
+ }
+
+ _renderPanel() {
+ this.listEl.innerHTML = "";
+
+ if (this.index.size === 0) {
+ const empty = document.createElement("div");
+ empty.className = "plugin-kp-empty";
+ empty.textContent = "未找到知识点标记";
+ this.listEl.appendChild(empty);
+ this.statusEl.textContent = "共 0 个知识点,0 处引用";
+ return;
+ }
+
+ const sorted = Array.from(this.index.entries()).sort((a, b) => a[0].localeCompare(b[0]));
+ let totalRefs = 0;
+
+ for (const [name, item] of sorted) {
+ const refCount = item.references.length;
+ totalRefs += refCount;
+
+ const itemEl = document.createElement("div");
+ itemEl.className = "plugin-kp-item";
+ itemEl.dataset.name = name;
+
+ const header = document.createElement("div");
+ header.className = "plugin-kp-item-header";
+
+ const arrow = document.createElement("span");
+ arrow.className = "plugin-kp-item-arrow";
+ arrow.textContent = "▸";
+
+ const nameSpan = document.createElement("span");
+ nameSpan.className = "plugin-kp-item-name";
+ nameSpan.textContent = name;
+
+ const count = document.createElement("span");
+ count.className = "plugin-kp-item-count";
+ count.textContent = `${refCount + 1}处`;
+
+ header.appendChild(arrow);
+ header.appendChild(nameSpan);
+ header.appendChild(count);
+
+ header.addEventListener("click", () => {
+ itemEl.classList.toggle("expanded");
+ });
+
+ const entries = document.createElement("div");
+ entries.className = "plugin-kp-item-entries";
+
+ if (item.definition) {
+ entries.appendChild(this._createEntryElement(item.definition, "def"));
+ }
+ for (const ref of item.references) {
+ entries.appendChild(this._createEntryElement(ref, "ref"));
+ }
+
+ itemEl.appendChild(header);
+ itemEl.appendChild(entries);
+ this.listEl.appendChild(itemEl);
+ }
+
+ this.statusEl.textContent = `共 ${this.index.size} 个知识点,${totalRefs + this.index.size} 处引用`;
+ }
+
+ _createEntryElement(entry, type) {
+ const el = document.createElement("div");
+ el.className = "plugin-kp-entry";
+
+ const isCurrentFile = entry.filePath === this.currentFile;
+ const fileExists = this.fs.existsSync(entry.filePath);
+ if (!fileExists && !isCurrentFile) {
+ el.classList.add("disabled");
+ }
+
+ const typeLabel = document.createElement("span");
+ typeLabel.className = `plugin-kp-entry-type ${type}`;
+ typeLabel.textContent = type === "def" ? "★ 定义" : "引用";
+
+ const location = document.createElement("span");
+ location.className = "plugin-kp-entry-location";
+ location.textContent = `${entry.file}:${entry.line}`;
+
+ const context = document.createElement("div");
+ context.className = "plugin-kp-entry-context";
+ context.textContent = entry.context;
+
+ el.appendChild(typeLabel);
+ el.appendChild(location);
+ el.appendChild(context);
+
+ el.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this._jumpTo(entry);
+ });
+
+ return el;
+ }
+
+ _filter(query) {
+ const items = this.listEl.querySelectorAll(".plugin-kp-item");
+ const q = query.trim().toLowerCase();
+ let visibleCount = 0;
+
+ for (const item of items) {
+ const name = (item.dataset.name || "").toLowerCase();
+ if (!q || name.includes(q)) {
+ item.style.display = "";
+ visibleCount++;
+ } else {
+ item.style.display = "none";
+ }
+ }
+
+ // Show/hide "no match" message
+ const existing = this.listEl.querySelector(".plugin-kp-empty-filter");
+ if (visibleCount === 0 && q) {
+ if (!existing) {
+ const empty = document.createElement("div");
+ empty.className = "plugin-kp-empty plugin-kp-empty-filter";
+ empty.textContent = "未匹配到知识点";
+ this.listEl.appendChild(empty);
+ }
+ } else {
+ if (existing) existing.remove();
+ }
+ }
+
+ _jumpTo(entry) {
+ // If entry has a DOM element (current file), scroll and highlight
+ if (entry.element) {
+ entry.element.scrollIntoView({ behavior: "smooth", block: "center" });
+ this._highlight(entry.element);
+ return;
+ }
+
+ // Cross-file jump: open file in same window then scroll to line
+ if (entry.filePath !== this.currentFile) {
+ const { eventHub } = this.utils;
+ const targetLine = entry.line;
+
+ // One-time listener for when the new file finishes loading
+ const onLoaded = () => {
+ eventHub.removeEventListener(eventHub.eventType.fileContentLoaded, onLoaded);
+ // scrollSourceView uses 0-indexed line numbers
+ this.utils.scrollSourceView(targetLine - 1);
+ this.currentFile = entry.filePath;
+ this.folderPath = this.path.dirname(entry.filePath);
+ // Re-scan DOM so entries now have elements, then highlight
+ this._scanCurrentDOM();
+ if (entry.element) {
+ entry.element.scrollIntoView({ behavior: "smooth", block: "center" });
+ this._highlight(entry.element);
+ }
+ };
+ eventHub.addEventListener(eventHub.eventType.fileContentLoaded, onLoaded);
+
+ // Open file in same window via Typora API
+ try {
+ if (typeof File !== "undefined" && File.editor && File.editor.library) {
+ File.editor.library.openFile(entry.filePath);
+ return;
+ }
+ } catch (e) {
+ // fallback to utils.openFile
+ }
+
+ try {
+ this.utils.openFile(entry.filePath);
+ return;
+ } catch (e) {
+ eventHub.removeEventListener(eventHub.eventType.fileContentLoaded, onLoaded);
+ }
+
+ // Last resort: copy path to clipboard
+ navigator.clipboard.writeText(entry.filePath);
+ alert("文件路径已复制到剪贴板: " + entry.filePath);
+ }
+ }
+
+ // ==================== Autocomplete Helpers ====================
+
+ _acCheck() {
+ const sel = window.getSelection();
+ if (!sel.rangeCount) { this._acHide(); return; }
+
+ const range = sel.getRangeAt(0);
+ if (!range.collapsed) { this._acHide(); return; }
+
+ // Verify cursor is inside #write
+ const writeEl = document.querySelector("#write");
+ if (!writeEl || !writeEl.contains(range.startContainer)) { this._acHide(); return; }
+
+ // Get text before cursor within its block element
+ const block = range.startContainer.nodeType === Node.ELEMENT_NODE
+ ? range.startContainer.closest("[cid]")
+ : range.startContainer.parentElement ? range.startContainer.parentElement.closest("[cid]") : null;
+ if (!block) { this._acHide(); return; }
+
+ const blockRange = document.createRange();
+ blockRange.selectNodeContents(block);
+ blockRange.setEnd(range.startContainer, range.startOffset);
+ const textBefore = blockRange.toString();
+
+ const prefix = this.config.tag_prefix;
+ const lastIdx = textBefore.lastIndexOf(prefix);
+
+ if (lastIdx === -1) { this._acHide(); return; }
+
+ // Count suffix after prefix occurrence to check if tag is closed
+ const afterPrefix = textBefore.substring(lastIdx + prefix.length);
+ const suffix = this.config.tag_suffix;
+ if (afterPrefix.includes(suffix)) { this._acHide(); return; }
+
+ const query = afterPrefix.toLowerCase();
+
+ // Ensure index is populated (scan if panel hasn't been opened yet)
+ if (this.index.size === 0 && this.currentFile) {
+ this._scan();
+ }
+
+ // Get all knowledge point names, filter by query
+ let names = Array.from(this.index.keys()).sort((a, b) => a.localeCompare(b, "zh"));
+ if (query) {
+ names = names.filter(n => n.toLowerCase().includes(query));
+ }
+
+ if (names.length === 0) { this._acHide(); return; }
+
+ this.acItems = names;
+ this.acIndex = 0;
+ this._acRender();
+ this._acPosition();
+ this.acVisible = true;
+ this.autocompleteEl.classList.add("visible");
+ }
+
+ _acRender() {
+ this.autocompleteEl.innerHTML = "";
+ for (let i = 0; i < this.acItems.length; i++) {
+ const item = document.createElement("div");
+ item.className = "plugin-kp-autocomplete-item";
+ if (i === this.acIndex) item.classList.add("active");
+ item.textContent = this.acItems[i];
+ item.addEventListener("mousedown", (e) => {
+ e.preventDefault();
+ this._acSelect(this.acItems[i]);
+ });
+ this.autocompleteEl.appendChild(item);
+ }
+ }
+
+ _acMove(dir) {
+ const len = this.acItems.length;
+ if (len === 0) return;
+ this.acIndex = (this.acIndex + dir + len) % len;
+ const items = this.autocompleteEl.querySelectorAll(".plugin-kp-autocomplete-item");
+ items.forEach((el, i) => el.classList.toggle("active", i === this.acIndex));
+
+ // Scroll active item into view
+ const activeEl = items[this.acIndex];
+ if (activeEl) activeEl.scrollIntoView({ block: "nearest" });
+ }
+
+ _acPosition() {
+ const sel = window.getSelection();
+ if (!sel.rangeCount) return;
+ const rect = sel.getRangeAt(0).getBoundingClientRect();
+ let top = rect.bottom + 4;
+ let left = rect.left;
+ // If rect is empty (collapsed cursor), use a fallback
+ if (rect.top === 0 && rect.bottom === 0) {
+ const writeEl = document.querySelector("#write");
+ if (writeEl) {
+ const writeRect = writeEl.getBoundingClientRect();
+ left = writeRect.left + 20;
+ top = writeRect.top + 20;
+ }
+ }
+ this.autocompleteEl.style.top = top + "px";
+ this.autocompleteEl.style.left = left + "px";
+ }
+
+ _acSelect(name) {
+ const sel = window.getSelection();
+ if (!sel.rangeCount) { this._acHide(); return; }
+
+ const range = sel.getRangeAt(0);
+ const block = range.startContainer.nodeType === Node.ELEMENT_NODE
+ ? range.startContainer.closest("[cid]")
+ : range.startContainer.parentElement ? range.startContainer.parentElement.closest("[cid]") : null;
+ if (!block) { this._acHide(); return; }
+
+ // Get text before cursor to find trigger position
+ const blockRange = document.createRange();
+ blockRange.selectNodeContents(block);
+ blockRange.setEnd(range.startContainer, range.startOffset);
+ const textBefore = blockRange.toString();
+ const prefix = this.config.tag_prefix;
+ const triggerPos = textBefore.lastIndexOf(prefix);
+ if (triggerPos === -1) { this._acHide(); return; }
+
+ // Calculate how many chars to delete (from triggerPos to cursor)
+ const charsToDelete = textBefore.length - triggerPos;
+
+ // Build replacement: prefix + name + suffix
+ const suffix = this.config.tag_suffix;
+ const fullText = prefix + name + suffix;
+
+ // Move cursor back by charsToDelete, then delete and insert
+ for (let i = 0; i < charsToDelete; i++) {
+ sel.modify("extend", "backward", "character");
+ }
+ document.execCommand("delete");
+ document.execCommand("insertText", false, fullText);
+
+ this._acHide();
+ }
+
+ _acHide() {
+ if (!this.acVisible) return;
+ this.acVisible = false;
+ this.acIndex = -1;
+ this.acItems = [];
+ this.autocompleteEl.classList.remove("visible");
+ this.autocompleteEl.innerHTML = "";
+ }
+
+ // ==================== Inline Rendering Helpers ====================
+
+ _renderInline(container) {
+ const prefix = this.config.tag_prefix;
+ const suffix = this.config.tag_suffix;
+ const escapedPrefix = this._escapeRegex(prefix);
+ const escapedSuffix = this._escapeRegex(suffix);
+ const tagRe = new RegExp(`${escapedPrefix}(.+?)${escapedSuffix}`);
+
+ const blocks = container.querySelectorAll("[cid]");
+ for (const block of blocks) {
+ if (block.querySelector(".kp-tag")) continue;
+
+ const text = block.textContent || "";
+ if (!tagRe.test(text)) continue;
+ tagRe.lastIndex = 0;
+
+ const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
+ let node;
+ while ((node = walker.nextNode())) {
+ this._processTextNode(node, tagRe, prefix, suffix);
+ }
+ }
+ }
+
+ _processTextNode(textNode, tagRe, prefix, suffix) {
+ let remaining = textNode;
+ while (remaining) {
+ const text = remaining.textContent;
+ const match = tagRe.exec(text);
+ if (!match) break;
+
+ const name = match[1];
+ const tagLen = prefix.length + name.length + suffix.length;
+
+ // Split: remaining keeps [0, match.index), nameNode gets [match.index, end)
+ const nameNode = remaining.splitText(match.index);
+ // Split: nameNode keeps [0, tagLen), afterNode gets [tagLen, end)
+ const afterNode = nameNode.splitText(tagLen);
+
+ // DOM now: remaining=[before] nameNode=[@@name@@] afterNode=[after]
+ const delimBefore = document.createElement("span");
+ delimBefore.className = "kp-delim";
+ delimBefore.textContent = prefix;
+
+ const tagSpan = document.createElement("span");
+ tagSpan.className = "kp-tag";
+ tagSpan.textContent = name;
+
+ const delimAfter = document.createElement("span");
+ delimAfter.className = "kp-delim";
+ delimAfter.textContent = suffix;
+
+ // Replace nameNode with three spans
+ nameNode.parentNode.insertBefore(delimBefore, nameNode);
+ nameNode.parentNode.insertBefore(tagSpan, nameNode);
+ nameNode.parentNode.insertBefore(delimAfter, nameNode);
+ nameNode.parentNode.removeChild(nameNode);
+
+ // Continue with the text after the match
+ remaining = afterNode;
+ }
+ }
+
+ _highlight(element) {
+ if (!element) return;
+ element.classList.add("plugin-kp-highlight");
+ // Remove after animation completes
+ setTimeout(() => {
+ element.classList.remove("plugin-kp-highlight");
+ }, 2100);
+ }
+}
+
+module.exports = { plugin: knowledgePoint };