diff --git a/.agents/migration_plan_autocomplete.md b/.agents/migration_plan_autocomplete.md index 02fef34b3f..4f48286811 100644 --- a/.agents/migration_plan_autocomplete.md +++ b/.agents/migration_plan_autocomplete.md @@ -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 结构调整可能引发的影响。 +**验证方式:** 中文拼音输入法敲打途中不会错误地发起网络搜索;各种组合回车热键重新生效,整个搜索系统重回巅峰状态。 --- diff --git a/apps/client/src/services/note_autocomplete.ts b/apps/client/src/services/note_autocomplete.ts index 9ca4fa86fb..9822793a57 100644 --- a/apps/client/src/services/note_autocomplete.ts +++ b/apps/client/src/services/note_autocomplete.ts @@ -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; + panelEl: HTMLElement; + cleanup: () => void; +} + +const instanceMap = new WeakMap(); + +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((res, rej) => { autocompleteSource( @@ -163,271 +212,216 @@ async function autocompleteSource(term: string, cb: (rows: Suggestion[]) => void function clearText($el: JQuery) { 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, 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) { 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) { 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, 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, 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({ + 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((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 = $("").addClass("input-group-text input-clearer-button bx bxs-tag-x").prop("title", t("note_autocomplete.clear-text-field")); - const $showRecentNotesButton = $("").addClass("input-group-text show-recent-notes-button bx bx-time").prop("title", t("note_autocomplete.show-recent-notes")); - - const $fullTextSearchButton = $("") - .addClass("input-group-text full-text-search-button bx bx-search") - .prop("title", `${t("note_autocomplete.full-text-search")} (Shift+Enter)`); - + const $fullTextSearchButton = $("").addClass("input-group-text full-text-search-button bx bx-search").prop("title", `${t("note_autocomplete.full-text-search")} (Shift+Enter)`); const $goToSelectedNoteButton = $("").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 = `
`; - html += ``; - html += `
`; - html += `
${suggestion.highlightedNotePathTitle}
`; - if (suggestion.commandDescription) { - html += `
${suggestion.commandDescription}
`; - } - html += `
`; - if (suggestion.commandShortcut) { - html += `${suggestion.commandShortcut}`; - } - html += '
'; - 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 = `
`; - html += ``; - html += ``; - html += `${suggestion.highlightedNotePathTitle}`; - - // Add attribute snippet inline if available - if (suggestion.highlightedAttributeSnippet) { - html += `${suggestion.highlightedAttributeSnippet}`; - } - - html += ``; - html += `
`; - 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; }