diff --git a/.agents/migration_plan_autocomplete.md b/.agents/migration_plan_autocomplete.md index 4f48286811..b194c1533a 100644 --- a/.agents/migration_plan_autocomplete.md +++ b/.agents/migration_plan_autocomplete.md @@ -97,13 +97,17 @@ - ✅ 实现了对 `Jump to Note (Ctrl+J)` 等真实组件的向下兼容事件 (`autocomplete:noteselected`) 无缝派发反馈。 - ✅ 在跳往某个具体笔记或在新建 Relation 面板选用特定目标笔记时,基础请求和简装提示版均工作正常。 -#### Step 3.2: 复杂 UI 渲染重构与匹配高亮 (模板渲染) +#### Step 3.2: 复杂 UI 渲染重构与匹配高亮 (模板渲染) ✅ 基本完成 **目标:** 实现与原版相同级别(甚至更好)的视觉体验(例如笔记图标、上级路径显示、搜索词高亮标红等)。 **工作内容:** - 重写原有的基于字符串或 jQuery 的构建 DOM 模板代码(专门处理带 `notePath` `icon` `isSearch` 判断等数据)。 - 将 DOM 构建系统集成到 `onStateChange` 的渲染函数里,通过 `innerHTML` 拼装或 DOM 手工建立实现原生高性能面板。 - 引入对应的样式 (`style.css`) 补全排版缺漏。 **验证方式:** 下拉出的搜索面板变得非常美观,与系统的 Dark/Light 色调融合;笔记标题对应的图标出现,匹配的字样高亮突出。 +**当前验证结果:** +- ✅ `Ctrl+J / Jump to Note`:UI 渲染、recent notes、键盘/鼠标高亮联动、删空回 recent notes 等核心交互已基本恢复。 +- ✅ `attribute_detail.ts` 等依赖 jQuery 事件的目标笔记选择入口,抽查结果正常。 +- ⚠️ React 侧消费者尚未完成迁移验收。抽查 `Move to` 时发现功能不正常,这部分应归入 **Step 5** 继续处理,而不是视为 Step 3.2 已全链路完成。 #### Step 3.3: 差异化分发逻辑与对外事件抛出 (交互改造) **目标:** 支持该组件的多态性。它能在搜笔记之外搜命令(`>` 起手)、甚至是外部链接。同时能够被外部组件监听到选择动作。 @@ -144,6 +148,10 @@ - 关系属性的目标笔记选择正常工作 - `noteId` 和 `text` props 的动态更新正确 +**当前状态:** +- ⚠️ 尚未完成。虽然底层 `note_autocomplete.ts` 已经切到新实现,但 React 消费方仍需逐一验收。 +- ⚠️ 已抽查 `Move to`,当前功能不正常,说明 Step 5 仍存在待修复问题。 + --- ### Step 6: 迁移"关闭弹窗"逻辑 + `attribute_detail.ts` 引用 diff --git a/apps/client/src/services/note_autocomplete.ts b/apps/client/src/services/note_autocomplete.ts index 0e8f530d8d..ba64d37cf7 100644 --- a/apps/client/src/services/note_autocomplete.ts +++ b/apps/client/src/services/note_autocomplete.ts @@ -1,12 +1,13 @@ -import { createAutocomplete } from "@algolia/autocomplete-core"; import type { AutocompleteApi as CoreAutocompleteApi, BaseItem } from "@algolia/autocomplete-core"; -import server from "./server.js"; +import { createAutocomplete } from "@algolia/autocomplete-core"; +import type { MentionFeedObjectItem } from "@triliumnext/ckeditor5"; + import appContext from "../components/app_context.js"; -import noteCreateService from "./note_create.js"; +import commandRegistry from "./command_registry.js"; import froca from "./froca.js"; import { t } from "./i18n.js"; -import commandRegistry from "./command_registry.js"; -import type { MentionFeedObjectItem } from "@triliumnext/ckeditor5"; +import noteCreateService from "./note_create.js"; +import server from "./server.js"; // this key needs to have this value, so it's hit by the tooltip const SELECTED_NOTE_PATH_KEY = "data-note-path"; @@ -60,6 +61,9 @@ export interface Options { interface ManagedInstance { autocomplete: CoreAutocompleteApi; panelEl: HTMLElement; + clearCursor: () => void; + showQuery: (query: string) => void; + openRecentNotes: () => void; cleanup: () => void; } @@ -123,6 +127,10 @@ function getSuggestionIconClass(item: Suggestion): string { return item.icon || "bx bx-note"; } +function getSuggestionInputValue(item: Suggestion): string { + return item.noteTitle || item.notePathTitle || item.externalLink || ""; +} + function renderCommandSuggestion(item: Suggestion): string { const iconClass = escapeHtml(item.icon || "bx bx-terminal"); const titleHtml = item.highlightedNotePathTitle || escapeHtml(item.noteTitle || ""); @@ -174,7 +182,30 @@ function renderSuggestion(item: Suggestion): string { return renderNoteSuggestion(item); } -function renderItems(panelEl: HTMLElement, items: Suggestion[], activeId: number | null, onSelect: (item: Suggestion) => void) { +function createSuggestionSource($el: JQuery, options: Options, onSelectItem: (item: Suggestion) => void) { + return { + sourceId: "note-suggestions", + async getItems({ query }: { query: string }) { + return await fetchSuggestions(query, options); + }, + getItemInputValue({ item }: { item: Suggestion }) { + return getSuggestionInputValue(item); + }, + onSelect({ item }: { item: Suggestion }) { + onSelectItem(item); + $el.trigger("autocomplete:noteselected", [item]); + } + }; +} + +function renderItems( + panelEl: HTMLElement, + items: Suggestion[], + activeId: number | null, + onSelect: (item: Suggestion) => void, + onActivate: (index: number) => void, + onDeactivate: () => void +) { if (items.length === 0) { panelEl.style.display = "none"; return; @@ -189,6 +220,7 @@ function renderItems(panelEl: HTMLElement, items: Suggestion[], activeId: number itemEl.className = "aa-core-item aa-suggestion"; itemEl.setAttribute("role", "option"); itemEl.setAttribute("aria-selected", index === activeId ? "true" : "false"); + itemEl.dataset.index = String(index); if (item.action) { itemEl.classList.add(`${item.action}-action`); @@ -198,6 +230,24 @@ function renderItems(panelEl: HTMLElement, items: Suggestion[], activeId: number } itemEl.innerHTML = renderSuggestion(item); + itemEl.onmousemove = () => { + if (activeId === index) { + return; + } + + onDeactivate(); + window.setTimeout(() => { + onActivate(index); + }, 0); + }; + itemEl.onmouseleave = (event) => { + const relatedTarget = event.relatedTarget; + if (relatedTarget instanceof HTMLElement && itemEl.contains(relatedTarget)) { + return; + } + + onDeactivate(); + }; itemEl.onmousedown = (e) => { e.preventDefault(); onSelect(item); @@ -212,32 +262,21 @@ function renderItems(panelEl: HTMLElement, items: Suggestion[], activeId: number } async function autocompleteSourceForCKEditor(queryText: string) { - return await new Promise((res, rej) => { - autocompleteSource( - queryText, - (rows) => { - res( - rows.map((row) => { - return { - action: row.action, - noteTitle: row.noteTitle, - id: `@${row.notePathTitle}`, - name: row.notePathTitle || "", - link: `#${row.notePath}`, - notePath: row.notePath, - highlightedNotePathTitle: row.highlightedNotePathTitle - }; - }) - ); - }, - { - allowCreatingNotes: true - } - ); + const rows = await fetchSuggestions(queryText, { allowCreatingNotes: true }); + return rows.map((row) => { + return { + action: row.action, + noteTitle: row.noteTitle, + id: `@${row.notePathTitle}`, + name: row.notePathTitle || "", + link: `#${row.notePath}`, + notePath: row.notePath, + highlightedNotePathTitle: row.highlightedNotePathTitle + }; }); } -async function autocompleteSource(term: string, cb: (rows: Suggestion[]) => void, options: Options = {}) { +async function fetchSuggestions(term: string, options: Options = {}): Promise { // Check if we're in command mode if (options.isCommandPalette && term.startsWith(">")) { const commandQuery = term.substring(1).trim(); @@ -259,21 +298,20 @@ async function autocompleteSource(term: string, cb: (rows: Suggestion[]) => void icon: cmd.icon })); - cb(commandSuggestions); - return; + return commandSuggestions; } const fastSearch = options.fastSearch === false ? false : true; if (fastSearch === false) { if (term.trim().length === 0) { - return; + return []; } - cb([ + return [ { noteTitle: term, highlightedNotePathTitle: t("quick-search.searching") } - ]); + ]; } const activeNoteId = appContext.tabManager.getActiveContextNoteId(); @@ -314,16 +352,40 @@ async function autocompleteSource(term: string, cb: (rows: Suggestion[]) => void ].concat(results); } - cb(results); + return results; +} + +async function fetchSuggestionsWithDelay(term: string, options: Options): Promise { + return await new Promise((resolve) => { + clearTimeout(debounceTimeoutId); + debounceTimeoutId = setTimeout(async () => { + resolve(await fetchSuggestions(term, options)); + }, searchDelay); + + if (searchDelay === 0) { + searchDelay = getSearchDelay(notesCount); + } + }); +} + +function resetSelectionState($el: JQuery) { + $el.setSelectedNotePath(""); + $el.setSelectedExternalLink(null); +} + +function getManagedInstance($el: JQuery): ManagedInstance | null { + const inputEl = $el[0] as HTMLInputElement | undefined; + return inputEl ? (instanceMap.get(inputEl) ?? null) : null; } function clearText($el: JQuery) { searchDelay = 0; - $el.setSelectedNotePath(""); + resetSelectionState($el); const inputEl = $el[0] as HTMLInputElement; - const instance = instanceMap.get(inputEl); + const instance = getManagedInstance($el); if (instance) { inputEl.value = ""; + instance.clearCursor(); instance.autocomplete.setQuery(""); instance.autocomplete.setIsOpen(false); instance.autocomplete.refresh(); @@ -331,41 +393,29 @@ function clearText($el: JQuery) { } function setText($el: JQuery, text: string) { - $el.setSelectedNotePath(""); - const inputEl = $el[0] as HTMLInputElement; - const instance = instanceMap.get(inputEl); + resetSelectionState($el); + const instance = getManagedInstance($el); if (instance) { - inputEl.value = text.trim(); - instance.autocomplete.setQuery(text.trim()); - instance.autocomplete.setIsOpen(true); - instance.autocomplete.refresh(); + instance.showQuery(text.trim()); } } function showRecentNotes($el: JQuery) { searchDelay = 0; - $el.setSelectedNotePath(""); - const inputEl = $el[0] as HTMLInputElement; - const instance = instanceMap.get(inputEl); + resetSelectionState($el); + const instance = getManagedInstance($el); if (instance) { - inputEl.value = ""; - instance.autocomplete.setQuery(""); - instance.autocomplete.setIsOpen(true); - instance.autocomplete.refresh(); + instance.openRecentNotes(); } $el.trigger("focus"); } function showAllCommands($el: JQuery) { searchDelay = 0; - $el.setSelectedNotePath(""); - const inputEl = $el[0] as HTMLInputElement; - const instance = instanceMap.get(inputEl); + resetSelectionState($el); + const instance = getManagedInstance($el); if (instance) { - inputEl.value = ">"; - instance.autocomplete.setQuery(">"); - instance.autocomplete.setIsOpen(true); - instance.autocomplete.refresh(); + instance.showQuery(">"); } } @@ -378,12 +428,10 @@ function fullTextSearch($el: JQuery, options: Options) { $el.trigger("focus"); options.fastSearch = false; searchDelay = 0; - - const instance = instanceMap.get(inputEl); + + const instance = getManagedInstance($el); if (instance) { - instance.autocomplete.setQuery(searchString); - instance.autocomplete.setIsOpen(true); - instance.autocomplete.refresh(); + instance.showQuery(searchString); } } @@ -399,6 +447,50 @@ function initNoteAutocomplete($el: JQuery, options?: Options) { const panelEl = createPanelEl(options.container); let rafId: number | null = null; + let currentQuery = inputEl.value; + let shouldAutoselectTopItem = false; + let shouldMirrorActiveItemToInput = false; + + const clearCursor = () => { + shouldMirrorActiveItemToInput = false; + autocomplete.setActiveItemId(null); + inputEl.value = currentQuery; + }; + + const prepareForQueryChange = () => { + shouldAutoselectTopItem = true; + shouldMirrorActiveItemToInput = false; + }; + + const onSelectItem = (item: Suggestion) => { + inputEl.value = getSuggestionInputValue(item); + autocomplete.setIsOpen(false); + }; + + const source = createSuggestionSource($el, options, onSelectItem); + + const showQuery = (query: string) => { + prepareForQueryChange(); + inputEl.value = query; + autocomplete.setQuery(query); + autocomplete.setIsOpen(true); + autocomplete.refresh(); + }; + + const openRecentNotes = () => { + resetSelectionState($el); + prepareForQueryChange(); + inputEl.value = ""; + autocomplete.setQuery(""); + autocomplete.setActiveItemId(null); + autocomplete.setIsOpen(false); + + fetchSuggestions("", options).then((items) => { + autocomplete.setCollections([{ source, items }]); + autocomplete.setIsOpen(items.length > 0); + }); + }; + function startPositioning() { if (panelEl.classList.contains("aa-core-panel--contained")) { positionPanel(panelEl, inputEl); @@ -429,29 +521,10 @@ function initNoteAutocomplete($el: JQuery, options?: Options) { getSources({ query }) { return [ { - sourceId: "note-suggestions", + ...source, 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]); - }, + return await fetchSuggestionsWithDelay(query, options); + } }, ]; }, @@ -460,16 +533,35 @@ function initNoteAutocomplete($el: JQuery, options?: Options) { const collections = state.collections; const items = collections.length > 0 ? (collections[0].items as Suggestion[]) : []; const activeId = state.activeItemId ?? null; + const activeItem = activeId !== null ? items[activeId] : null; + currentQuery = state.query; + + if (activeItem && shouldMirrorActiveItemToInput) { + inputEl.value = getSuggestionInputValue(activeItem); + } else { + inputEl.value = state.query; + } if (state.isOpen && items.length > 0) { renderItems(panelEl, items, activeId, (item) => { - inputEl.value = item.noteTitle || item.notePathTitle || ""; - autocomplete.setIsOpen(false); - // Also dispatch selected event + onSelectItem(item); $el.trigger("autocomplete:noteselected", [item]); + }, (index) => { + autocomplete.setActiveItemId(index); + }, () => { + clearCursor(); }); + + if (shouldAutoselectTopItem && activeId === null) { + shouldAutoselectTopItem = false; + shouldMirrorActiveItemToInput = false; + autocomplete.setActiveItemId(0); + return; + } + startPositioning(); } else { + shouldAutoselectTopItem = false; panelEl.style.display = "none"; stopPositioning(); } @@ -478,6 +570,13 @@ function initNoteAutocomplete($el: JQuery, options?: Options) { const handlers = autocomplete.getInputProps({ inputElement: inputEl }); const onInput = (e: Event) => { + const value = (e.currentTarget as HTMLInputElement).value; + if (value.trim().length === 0) { + openRecentNotes(); + return; + } + + prepareForQueryChange(); handlers.onChange(e as any); }; const onFocus = (e: Event) => { @@ -491,6 +590,9 @@ function initNoteAutocomplete($el: JQuery, options?: Options) { }, 50); }; const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + shouldMirrorActiveItemToInput = true; + } handlers.onKeyDown(e as any); }; @@ -510,7 +612,7 @@ function initNoteAutocomplete($el: JQuery, options?: Options) { } }; - instanceMap.set(inputEl, { autocomplete, panelEl, cleanup }); + instanceMap.set(inputEl, { autocomplete, panelEl, clearCursor, showQuery, openRecentNotes, 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")); diff --git a/apps/client/src/stylesheets/style.css b/apps/client/src/stylesheets/style.css index f40ca46933..a142df363f 100644 --- a/apps/client/src/stylesheets/style.css +++ b/apps/client/src/stylesheets/style.css @@ -998,7 +998,6 @@ table.promoted-attributes-in-tooltip th { white-space: normal; } -.aa-core-item:hover, .aa-core-item--active { color: var(--active-item-text-color); background-color: var(--active-item-background-color);