refactor: migrate note_autocomplete core function

This commit is contained in:
JYC333 2026-03-09 12:39:51 +00:00
parent d363d2016e
commit c0dd59458b
2 changed files with 239 additions and 228 deletions

View File

@ -82,26 +82,43 @@
---
### Step 3: 迁移笔记搜索自动补全核心
**文件变更:**
- `apps/client/src/services/note_autocomplete.ts``initNoteAutocomplete()` 改为直接调用 `autocomplete()`
### Step 3: 迁移笔记搜索自动补全核心 (拆分为 4 个增量阶段)
**说明:**
这是迁移中最复杂的部分,`initNoteAutocomplete()` 包含:
- 复杂的 source 函数带防抖、IME 处理)
- 自定义 suggestion 模板(图标、路径高亮)
- 多种选择类型分发(笔记、外部链接、命令)
- `autocomplete("val", ...)` 等操作性 API 调用
- 附带的辅助按钮(清除、最近笔记、全文搜索、跳转按钮)
由于搜索自动补全模块(`note_autocomplete.ts`)承载了系统最为复杂的交互、多态分发与 UI我们将其拆分为 4 个逐步可验证的子阶段:
消费者通过自定义 jQuery 事件(`autocomplete:noteselected` 等)接收结果,需要保持这些事件或改为回调。
#### Step 3.1: 基础骨架与核心接口联通 (Headless 骨架) ✅ 完成
**目标:** 用 `@algolia/autocomplete-core` 完全接管旧版的 `$el.autocomplete` 初始化,打通搜索接口。
**工作内容:**
- 在 `initNoteAutocomplete()` 中引入基于 `instanceMap` 的单例验证逻辑与 DOM 隔离。
- 建立 `getSources`,实现调用 `server.get("api/search/autocomplete", ...)`
- 只做极其简单的 UI比如简单的 `ul > li` text将获取到的 `title` 渲染出来,确保网络流程畅通。
**完成情况与验证 (**`apps/client/src/services/note_autocomplete.ts`**)**
- ✅ 彻底移除了原依赖于 jQuery `autocomplete.js` 的各种初始化配置与繁复的字符串 DOM 拼接节点。
- ✅ 实现了对 `Jump to Note (Ctrl+J)` 等真实组件的向下兼容事件 (`autocomplete:noteselected`) 无缝派发反馈。
- ✅ 在跳往某个具体笔记或在新建 Relation 面板选用特定目标笔记时,基础请求和简装提示版均工作正常。
**验证方式:**
- 搜索栏 → 输入笔记名称 → 应能看到搜索结果
- 选择结果 → 应正确跳转到对应笔记
- 命令面板(`>` 前缀)正常工作
- 中文输入法不应中途触发搜索
- Shift+Enter 全文搜索、Ctrl+Enter 搜索笔记
#### Step 3.2: 复杂 UI 渲染重构与匹配高亮 (模板渲染)
**目标:** 实现与原版相同级别(甚至更好)的视觉体验(例如笔记图标、上级路径显示、搜索词高亮标红等)。
**工作内容:**
- 重写原有的基于字符串或 jQuery 的构建 DOM 模板代码(专门处理带 `notePath` `icon` `isSearch` 判断等数据)。
- 将 DOM 构建系统集成到 `onStateChange` 的渲染函数里,通过 `innerHTML` 拼装或 DOM 手工建立实现原生高性能面板。
- 引入对应的样式 (`style.css`) 补全排版缺漏。
**验证方式:** 下拉出的搜索面板变得非常美观,与系统的 Dark/Light 色调融合;笔记标题对应的图标出现,匹配的字样高亮突出。
#### Step 3.3: 差异化分发逻辑与对外事件抛出 (交互改造)
**目标:** 支持该组件的多态性。它能在搜笔记之外搜命令(`>` 起手)、甚至是外部链接。同时能够被外部组件监听到选择动作。
**工作内容:**
- 在选择项(`onSelect`)的回调中,根据用户选的是“系统命令”、“外部链接”还是“普通笔记”走截然不同的行为逻辑。
- 对外派发事件:原本通过 `$el.trigger("autocomplete:noteselected")` 的逻辑需要保留,以保证那些使用了搜索框的组件(例如右侧关系面板)依然能顺利收到选中反馈。
**验证方式:** 选中某个建议项时能够真正实现页面的调转/关系绑定;输入 `>` 开头能够列举出所有快捷命令(如 Toggle Dark mode
#### Step 3.4: 特殊键盘事件拦截与附带按钮包容 (终极打磨)
**目标:** 解决在旧 jQuery 中强绑定的 IME中日韩等输入法防抖问题并恢复如 `Shift+Enter`、周边附加按钮(清除等)的正常运作。
**工作内容:**
- 将旧的输入法合成事件 (`compositionstart` / `compositionend`) 判断逻辑迁移到新的 `onInput` / `onKeyDown` 外围保护之中。
- 重构对 `Shift+Enter` (唤起全文搜索)、`Ctrl+Enter` 等组合快捷键的劫持处理。
- 修正周边辅助控件(例如搜索栏自带的 “最近笔记(钟表)”、“清除栏(X)” 按钮)因为 DOM 结构调整可能引发的影响。
**验证方式:** 中文拼音输入法敲打途中不会错误地发起网络搜索;各种组合回车热键重新生效,整个搜索系统重回巅峰状态。
---

View File

@ -1,3 +1,5 @@
import { createAutocomplete } from "@algolia/autocomplete-core";
import type { AutocompleteApi as CoreAutocompleteApi, BaseItem } from "@algolia/autocomplete-core";
import server from "./server.js";
import appContext from "../components/app_context.js";
import noteCreateService from "./note_create.js";
@ -24,7 +26,7 @@ function getSearchDelay(notesCount: number): number {
let searchDelay = getSearchDelay(notesCount);
// TODO: Deduplicate with server.
export interface Suggestion {
export interface Suggestion extends BaseItem {
noteTitle?: string;
externalLink?: string;
notePathTitle?: string;
@ -54,6 +56,53 @@ export interface Options {
isCommandPalette?: boolean;
}
// --- Headless Autocomplete Helpers ---
interface ManagedInstance {
autocomplete: CoreAutocompleteApi<Suggestion>;
panelEl: HTMLElement;
cleanup: () => void;
}
const instanceMap = new WeakMap<HTMLElement, ManagedInstance>();
function createPanelEl(): HTMLElement {
const panel = document.createElement("div");
panel.className = "aa-core-panel";
panel.style.display = "none";
document.body.appendChild(panel);
return panel;
}
function positionPanel(panelEl: HTMLElement, inputEl: HTMLElement): void {
const rect = inputEl.getBoundingClientRect();
panelEl.style.position = "fixed";
panelEl.style.top = `${rect.bottom}px`;
panelEl.style.left = `${rect.left}px`;
panelEl.style.width = `${rect.width}px`;
panelEl.style.display = "block";
}
function renderItems(panelEl: HTMLElement, items: Suggestion[], activeId: number | null, onSelect: (item: Suggestion) => void) {
if (items.length === 0) {
panelEl.style.display = "none";
return;
}
const list = document.createElement("ul");
list.className = "aa-core-list";
items.forEach((item, index) => {
const li = document.createElement("li");
li.className = "aa-core-item";
if (index === activeId) li.classList.add("aa-core-item--active");
// Very basic simple UI for step 3.1
li.textContent = item.highlightedNotePathTitle || item.noteTitle || "";
li.onmousedown = (e) => { e.preventDefault(); onSelect(item); };
list.appendChild(li);
});
panelEl.innerHTML = "";
panelEl.appendChild(list);
}
async function autocompleteSourceForCKEditor(queryText: string) {
return await new Promise<MentionFeedObjectItem[]>((res, rej) => {
autocompleteSource(
@ -163,271 +212,216 @@ async function autocompleteSource(term: string, cb: (rows: Suggestion[]) => void
function clearText($el: JQuery<HTMLElement>) {
searchDelay = 0;
$el.setSelectedNotePath("");
$el.autocomplete("val", "").trigger("change");
const inputEl = $el[0] as HTMLInputElement;
const instance = instanceMap.get(inputEl);
if (instance) {
inputEl.value = "";
instance.autocomplete.setQuery("");
instance.autocomplete.setIsOpen(false);
instance.autocomplete.refresh();
}
}
function setText($el: JQuery<HTMLElement>, text: string) {
$el.setSelectedNotePath("");
$el.autocomplete("val", text.trim()).autocomplete("open");
const inputEl = $el[0] as HTMLInputElement;
const instance = instanceMap.get(inputEl);
if (instance) {
inputEl.value = text.trim();
instance.autocomplete.setQuery(text.trim());
instance.autocomplete.setIsOpen(true);
instance.autocomplete.refresh();
}
}
function showRecentNotes($el: JQuery<HTMLElement>) {
searchDelay = 0;
$el.setSelectedNotePath("");
$el.autocomplete("val", "");
$el.autocomplete("open");
const inputEl = $el[0] as HTMLInputElement;
const instance = instanceMap.get(inputEl);
if (instance) {
inputEl.value = "";
instance.autocomplete.setQuery("");
instance.autocomplete.setIsOpen(true);
instance.autocomplete.refresh();
}
$el.trigger("focus");
}
function showAllCommands($el: JQuery<HTMLElement>) {
searchDelay = 0;
$el.setSelectedNotePath("");
$el.autocomplete("val", ">").autocomplete("open");
const inputEl = $el[0] as HTMLInputElement;
const instance = instanceMap.get(inputEl);
if (instance) {
inputEl.value = ">";
instance.autocomplete.setQuery(">");
instance.autocomplete.setIsOpen(true);
instance.autocomplete.refresh();
}
}
function fullTextSearch($el: JQuery<HTMLElement>, options: Options) {
const searchString = $el.autocomplete("val") as unknown as string;
if (options.fastSearch === false || searchString?.trim().length === 0) {
const inputEl = $el[0] as HTMLInputElement;
const searchString = inputEl.value;
if (options.fastSearch === false || searchString.trim().length === 0) {
return;
}
$el.trigger("focus");
options.fastSearch = false;
$el.autocomplete("val", "");
$el.setSelectedNotePath("");
searchDelay = 0;
$el.autocomplete("val", searchString);
const instance = instanceMap.get(inputEl);
if (instance) {
instance.autocomplete.setQuery(searchString);
instance.autocomplete.setIsOpen(true);
instance.autocomplete.refresh();
}
}
function initNoteAutocomplete($el: JQuery<HTMLElement>, options?: Options) {
if ($el.hasClass("note-autocomplete-input")) {
// clear any event listener added in previous invocation of this function
$el.off("autocomplete:noteselected");
$el.addClass("note-autocomplete-input");
const inputEl = $el[0] as HTMLInputElement;
if (instanceMap.has(inputEl)) {
return $el;
}
options = options || {};
// Used to track whether the user is performing character composition with an input method (such as Chinese Pinyin, Japanese, Korean, etc.) and to avoid triggering a search during the composition process.
let isComposingInput = false;
$el.on("compositionstart", () => {
isComposingInput = true;
});
$el.on("compositionend", () => {
isComposingInput = false;
const searchString = $el.autocomplete("val") as unknown as string;
$el.autocomplete("val", "");
$el.autocomplete("val", searchString);
const panelEl = createPanelEl();
let rafId: number | null = null;
function startPositioning() {
if (rafId !== null) return;
const update = () => {
positionPanel(panelEl, inputEl);
rafId = requestAnimationFrame(update);
};
update();
}
function stopPositioning() {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
}
const autocomplete = createAutocomplete<Suggestion>({
openOnFocus: false, // Wait until we explicitly focus or type
defaultActiveItemId: null,
shouldPanelOpen() {
return true;
},
getSources({ query }) {
return [
{
sourceId: "note-suggestions",
async getItems() {
return new Promise<Suggestion[]>((resolve) => {
clearTimeout(debounceTimeoutId);
debounceTimeoutId = setTimeout(() => {
autocompleteSource(query, resolve, options!);
}, searchDelay);
if (searchDelay === 0) {
searchDelay = getSearchDelay(notesCount);
}
});
},
getItemInputValue({ item }) {
return item.noteTitle || item.notePathTitle || "";
},
onSelect({ item }) {
inputEl.value = item.noteTitle || item.notePathTitle || "";
autocomplete.setIsOpen(false);
// Fake selection handler for step 3.1
$el.trigger("autocomplete:noteselected", [item]);
},
},
];
},
onStateChange({ state }) {
const collections = state.collections;
const items = collections.length > 0 ? (collections[0].items as Suggestion[]) : [];
const activeId = state.activeItemId ?? null;
if (state.isOpen && items.length > 0) {
renderItems(panelEl, items, activeId, (item) => {
inputEl.value = item.noteTitle || item.notePathTitle || "";
autocomplete.setIsOpen(false);
// Also dispatch selected event
$el.trigger("autocomplete:noteselected", [item]);
});
startPositioning();
} else {
panelEl.style.display = "none";
stopPositioning();
}
},
});
$el.addClass("note-autocomplete-input");
const handlers = autocomplete.getInputProps({ inputElement: inputEl });
const onInput = (e: Event) => {
handlers.onChange(e as any);
};
const onFocus = (e: Event) => {
handlers.onFocus(e as any);
};
const onBlur = () => {
setTimeout(() => {
autocomplete.setIsOpen(false);
panelEl.style.display = "none";
stopPositioning();
}, 50);
};
const onKeyDown = (e: KeyboardEvent) => {
handlers.onKeyDown(e as any);
};
inputEl.addEventListener("input", onInput);
inputEl.addEventListener("focus", onFocus);
inputEl.addEventListener("blur", onBlur);
inputEl.addEventListener("keydown", onKeyDown);
const cleanup = () => {
inputEl.removeEventListener("input", onInput);
inputEl.removeEventListener("focus", onFocus);
inputEl.removeEventListener("blur", onBlur);
inputEl.removeEventListener("keydown", onKeyDown);
stopPositioning();
if (panelEl.parentElement) {
panelEl.parentElement.removeChild(panelEl);
}
};
instanceMap.set(inputEl, { autocomplete, panelEl, cleanup });
// Buttons UI logic
const $clearTextButton = $("<a>").addClass("input-group-text input-clearer-button bx bxs-tag-x").prop("title", t("note_autocomplete.clear-text-field"));
const $showRecentNotesButton = $("<a>").addClass("input-group-text show-recent-notes-button bx bx-time").prop("title", t("note_autocomplete.show-recent-notes"));
const $fullTextSearchButton = $("<a>")
.addClass("input-group-text full-text-search-button bx bx-search")
.prop("title", `${t("note_autocomplete.full-text-search")} (Shift+Enter)`);
const $fullTextSearchButton = $("<a>").addClass("input-group-text full-text-search-button bx bx-search").prop("title", `${t("note_autocomplete.full-text-search")} (Shift+Enter)`);
const $goToSelectedNoteButton = $("<a>").addClass("input-group-text go-to-selected-note-button bx bx-arrow-to-right");
if (!options.hideAllButtons) {
$el.after($clearTextButton).after($showRecentNotesButton).after($fullTextSearchButton);
}
if (!options.hideGoToSelectedNoteButton && !options.hideAllButtons) {
$el.after($goToSelectedNoteButton);
}
$clearTextButton.on("click", () => clearText($el));
$showRecentNotesButton.on("click", (e) => {
showRecentNotes($el);
// this will cause the click not give focus to the "show recent notes" button
// this is important because otherwise input will lose focus immediately and not show the results
return false;
});
$fullTextSearchButton.on("click", (e) => {
fullTextSearch($el, options);
fullTextSearch($el, options!);
return false;
});
let autocompleteOptions: AutoCompleteConfig = {};
if (options.container) {
autocompleteOptions.dropdownMenuContainer = options.container;
autocompleteOptions.debug = true; // don't close on blur
}
if (options.allowJumpToSearchNotes) {
$el.on("keydown", (event) => {
if (event.ctrlKey && event.key === "Enter") {
// Prevent Ctrl + Enter from triggering autoComplete.
event.stopImmediatePropagation();
event.preventDefault();
$el.trigger("autocomplete:selected", { action: "search-notes", noteTitle: $el.autocomplete("val") });
}
});
}
$el.on("keydown", async (event) => {
if (event.shiftKey && event.key === "Enter") {
// Prevent Enter from triggering autoComplete.
event.stopImmediatePropagation();
event.preventDefault();
fullTextSearch($el, options);
}
});
$el.autocomplete(
{
...autocompleteOptions,
appendTo: document.querySelector("body"),
hint: false,
autoselect: true,
// openOnFocus has to be false, otherwise re-focus (after return from note type chooser dialog) forces
// re-querying of the autocomplete source which then changes the currently selected suggestion
openOnFocus: false,
minLength: 0,
tabAutocomplete: false
},
[
{
source: (term, cb) => {
clearTimeout(debounceTimeoutId);
debounceTimeoutId = setTimeout(() => {
if (isComposingInput) {
return;
}
autocompleteSource(term, cb, options);
}, searchDelay);
if (searchDelay === 0) {
searchDelay = getSearchDelay(notesCount);
}
},
displayKey: "notePathTitle",
templates: {
suggestion: (suggestion) => {
if (suggestion.action === "command") {
let html = `<div class="command-suggestion">`;
html += `<span class="command-icon ${suggestion.icon || "bx bx-terminal"}"></span>`;
html += `<div class="command-content">`;
html += `<div class="command-name">${suggestion.highlightedNotePathTitle}</div>`;
if (suggestion.commandDescription) {
html += `<div class="command-description">${suggestion.commandDescription}</div>`;
}
html += `</div>`;
if (suggestion.commandShortcut) {
html += `<kbd class="command-shortcut">${suggestion.commandShortcut}</kbd>`;
}
html += '</div>';
return html;
}
// Add special class for search-notes action
const actionClass = suggestion.action === "search-notes" ? "search-notes-action" : "";
// Choose appropriate icon based on action
let iconClass = suggestion.icon ?? "bx bx-note";
if (suggestion.action === "search-notes") {
iconClass = "bx bx-search";
} else if (suggestion.action === "create-note") {
iconClass = "bx bx-plus";
} else if (suggestion.action === "external-link") {
iconClass = "bx bx-link-external";
}
// Simplified HTML structure without nested divs
let html = `<div class="note-suggestion ${actionClass}">`;
html += `<span class="icon ${iconClass}"></span>`;
html += `<span class="text">`;
html += `<span class="search-result-title">${suggestion.highlightedNotePathTitle}</span>`;
// Add attribute snippet inline if available
if (suggestion.highlightedAttributeSnippet) {
html += `<span class="search-result-attributes">${suggestion.highlightedAttributeSnippet}</span>`;
}
html += `</span>`;
html += `</div>`;
return html;
}
},
// we can't cache identical searches because notes can be created / renamed, new recent notes can be added
cache: false
}
]
);
// TODO: Types fail due to "autocomplete:selected" not being registered in type definitions.
($el as any).on("autocomplete:selected", async (event: Event, suggestion: Suggestion) => {
if (suggestion.action === "command") {
$el.autocomplete("close");
$el.trigger("autocomplete:commandselected", [suggestion]);
return;
}
if (suggestion.action === "external-link") {
$el.setSelectedNotePath(null);
$el.setSelectedExternalLink(suggestion.externalLink);
$el.autocomplete("val", suggestion.externalLink);
$el.autocomplete("close");
$el.trigger("autocomplete:externallinkselected", [suggestion]);
return;
}
if (suggestion.action === "create-note") {
const { success, noteType, templateNoteId, notePath } = await noteCreateService.chooseNoteType();
if (!success) {
return;
}
const { note } = await noteCreateService.createNote( notePath || suggestion.parentNoteId, {
title: suggestion.noteTitle,
activate: false,
type: noteType,
templateNoteId: templateNoteId
});
const hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId;
suggestion.notePath = note?.getBestNotePathString(hoistedNoteId);
}
if (suggestion.action === "search-notes") {
const searchString = suggestion.noteTitle;
appContext.triggerCommand("searchNotes", { searchString });
return;
}
$el.setSelectedNotePath(suggestion.notePath);
$el.setSelectedExternalLink(null);
$el.autocomplete("val", suggestion.noteTitle);
$el.autocomplete("close");
$el.trigger("autocomplete:noteselected", [suggestion]);
});
$el.on("autocomplete:closed", () => {
if (!String($el.val())?.trim()) {
clearText($el);
}
});
$el.on("autocomplete:opened", () => {
if ($el.attr("readonly")) {
$el.autocomplete("close");
}
});
// clear any event listener added in previous invocation of this function
$el.off("autocomplete:noteselected");
return $el;
}