From eaf89c63a10a21c4fc7322e0d1c87cb7e292566a Mon Sep 17 00:00:00 2001 From: JYC333 <22962980+JYC333@users.noreply.github.com> Date: Mon, 9 Mar 2026 00:42:40 +0000 Subject: [PATCH] refactor: use headless autocomplete, migrate attribute deatil --- .agents/migration_plan_autocomplete.md | 23 +- .../src/services/attribute_autocomplete.ts | 265 ++++++++++++------ apps/client/src/stylesheets/style.css | 32 +++ .../attribute_widgets/attribute_detail.ts | 4 +- 4 files changed, 224 insertions(+), 100 deletions(-) diff --git a/.agents/migration_plan_autocomplete.md b/.agents/migration_plan_autocomplete.md index 759d527705..b153779a85 100644 --- a/.agents/migration_plan_autocomplete.md +++ b/.agents/migration_plan_autocomplete.md @@ -43,22 +43,23 @@ --- -### Step 1: 迁移属性名称自动补全 +### Step 1: 迁移属性名称自动补全 ✅ 完成 **文件变更:** -- `apps/client/src/services/attribute_autocomplete.ts` — `initAttributeNameAutocomplete()` 改为直接调用 `autocomplete()` -- `apps/client/src/widgets/attribute_widgets/attribute_detail.ts` — 属性名称输入框从 `` 改为 `
` 容器,调整值读写 -- `apps/client/src/widgets/type_widgets/relation_map/RelationMap.tsx` — 关系名输入调整 +- `apps/client/src/services/attribute_autocomplete.ts` — `initAttributeNameAutocomplete()` 支持双 API:传 `container` 走新库,传 `$el` 走旧库 +- `apps/client/src/widgets/attribute_widgets/attribute_detail.ts` — 属性名称输入框从 `` 改为 `
` 容器,值读写改用 `getInputValue()/setInputValue()` +- `apps/client/src/stylesheets/style.css` — 添加新库 CSS(隐藏搜索图标、样式对齐) +- `RelationMap.tsx` — **不变**,继续用旧 `$el` API **说明:** -属性名称补全是最简单的场景。变更模式: -1. HTML 模板中的 `` → `
` -2. `initAttributeNameAutocomplete()` 接收容器元素,调用 `autocomplete({ container, getSources })` -3. 消费者通过返回的 `api` 对象(或回调)获取选中的值 -4. 为 `autocomplete-js` 生成的 `` 添加 `form-control` 等样式类 +`initAttributeNameAutocomplete()` 同时支持新旧两种调用方式(overloaded interface),让消费者可以逐步迁移: +- `attribute_detail.ts` 传 `{ container }` → 使用 `autocomplete-js` +- `RelationMap.tsx` 传 `{ $el }` → 使用旧 `autocomplete.js` +- RelationMap 的迁移推迟到后续步骤(因为它的 prompt dialog 由 React 管理 input) **验证方式:** -- 打开一个笔记 → 点击属性面板 → 点击属性名称输入框 → 应能看到自动补全的属性名称列表 -- 选择一个名称后,值应正确填入 +- ⬜ 打开一个笔记 → 点击属性面板 → 点击属性名称输入框 → 应能看到自动补全的属性名称列表 +- ⬜ 选择一个名称后,值应正确填入 +- ⬜ 关系图创建关系时,关系名输入框的自动补全仍正常(旧库路径) --- diff --git a/apps/client/src/services/attribute_autocomplete.ts b/apps/client/src/services/attribute_autocomplete.ts index 6cc82661e3..a59cf94646 100644 --- a/apps/client/src/services/attribute_autocomplete.ts +++ b/apps/client/src/services/attribute_autocomplete.ts @@ -1,25 +1,8 @@ -import { autocomplete } from "@algolia/autocomplete-js"; -import type { AutocompleteApi } from "@algolia/autocomplete-js"; -import type { BaseItem } from "@algolia/autocomplete-core"; +import { createAutocomplete } from "@algolia/autocomplete-core"; +import type { AutocompleteApi as CoreAutocompleteApi, BaseItem } from "@algolia/autocomplete-core"; import type { AttributeType } from "../entities/fattribute.js"; import server from "./server.js"; -// --------------------------------------------------------------------------- -// Global instance registry for "close all" functionality -// --------------------------------------------------------------------------- - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const activeInstances = new Set>(); - -export function closeAllAttributeAutocompletes(): void { - for (const api of activeInstances) { - api.setIsOpen(false); - } -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const instanceMap = new WeakMap>(); - // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -28,15 +11,19 @@ interface NameItem extends BaseItem { name: string; } -/** New API: pass a container div, autocomplete-js creates its own input inside. */ +/** New API: pass the input element + a container for the dropdown panel */ interface NewInitOptions { - container: HTMLElement; + /** The element where the user types */ + $el: JQuery; attributeType?: AttributeType | (() => AttributeType); open: boolean; + /** Called when the user selects a value or the panel closes */ onValueChange?: (value: string) => void; + /** Use the new autocomplete-core library instead of old autocomplete.js */ + useNewLib: true; } -/** Old API: pass a jQuery input element, uses legacy autocomplete.js plugin. */ +/** Old API: uses legacy autocomplete.js jQuery plugin */ interface OldInitOptions { $el: JQuery; attributeType?: AttributeType | (() => AttributeType); @@ -46,41 +33,103 @@ interface OldInitOptions { type InitAttributeNameOptions = NewInitOptions | OldInitOptions; function isNewApi(opts: InitAttributeNameOptions): opts is NewInitOptions { - return "container" in opts; + return "useNewLib" in opts && opts.useNewLib === true; } // --------------------------------------------------------------------------- -// Attribute name autocomplete +// Instance tracking // --------------------------------------------------------------------------- -function initAttributeNameAutocomplete(opts: InitAttributeNameOptions) { - if (isNewApi(opts)) { - initAttributeNameNew(opts); - } else { - initAttributeNameLegacy(opts); +interface ManagedInstance { + autocomplete: CoreAutocompleteApi; + panelEl: HTMLElement; + cleanup: () => void; +} + +const instanceMap = new WeakMap(); + +function destroyInstance(el: HTMLElement): void { + const inst = instanceMap.get(el); + if (inst) { + inst.cleanup(); + inst.panelEl.remove(); + instanceMap.delete(el); } } -/** New implementation using @algolia/autocomplete-js */ -function initAttributeNameNew({ container, attributeType, open, onValueChange }: NewInitOptions) { - // Only init once per container - if (instanceMap.has(container)) { +// --------------------------------------------------------------------------- +// Dropdown panel DOM helpers +// --------------------------------------------------------------------------- + +function createPanelEl(): HTMLElement { + const panel = document.createElement("div"); + panel.className = "aa-core-panel"; + panel.style.display = "none"; + document.body.appendChild(panel); + return panel; +} + +function renderItems(panelEl: HTMLElement, items: NameItem[], activeItemId: number | null, onSelect: (item: NameItem) => void): void { + panelEl.innerHTML = ""; + 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 === activeItemId) { + li.classList.add("aa-core-item--active"); + } + li.textContent = item.name; + li.addEventListener("mousedown", (e) => { + e.preventDefault(); // prevent input blur + onSelect(item); + }); + list.appendChild(li); + }); + panelEl.appendChild(list); +} + +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"; +} + +// --------------------------------------------------------------------------- +// Attribute name autocomplete — new (autocomplete-core, headless) +// --------------------------------------------------------------------------- + +function initAttributeNameNew({ $el, attributeType, open, onValueChange }: NewInitOptions) { + const inputEl = $el[0] as HTMLInputElement; + + // Already initialized — just open if requested + if (instanceMap.has(inputEl)) { if (open) { - const api = instanceMap.get(container)!; - api.setIsOpen(true); - api.refresh(); + const inst = instanceMap.get(inputEl)!; + inst.autocomplete.setIsOpen(true); + inst.autocomplete.refresh(); + positionPanel(inst.panelEl, inputEl); } return; } - const api = autocomplete({ - container, - panelContainer: document.body, + const panelEl = createPanelEl(); + + let isPanelOpen = false; + let hasActiveItem = false; + + const autocomplete = createAutocomplete({ openOnFocus: true, - detachedMediaQuery: "none", - placeholder: "", - classNames: { - input: "form-control", + defaultActiveItemId: 0, + shouldPanelOpen() { + return true; }, getSources({ query }) { @@ -97,38 +146,94 @@ function initAttributeNameNew({ container, attributeType, open, onValueChange }: return item.name; }, onSelect({ item }) { + inputEl.value = item.name; + autocomplete.setQuery(item.name); + autocomplete.setIsOpen(false); onValueChange?.(item.name); }, - templates: { - item({ item, html }) { - return html`
${item.name}
`; - }, - }, }, ]; }, - onStateChange({ state, prevState }) { - if (!state.isOpen && prevState.isOpen) { - onValueChange?.(state.query); - } - }, + onStateChange({ state }) { + isPanelOpen = state.isOpen; + hasActiveItem = state.activeItemId !== null; + + // Render items + const collections = state.collections; + const items = collections.length > 0 ? (collections[0].items as NameItem[]) : []; + const activeId = state.activeItemId ?? null; - shouldPanelOpen() { - return true; + if (state.isOpen && items.length > 0) { + renderItems(panelEl, items, activeId, (item) => { + inputEl.value = item.name; + autocomplete.setQuery(item.name); + autocomplete.setIsOpen(false); + onValueChange?.(item.name); + }); + positionPanel(panelEl, inputEl); + } else { + panelEl.style.display = "none"; + } + + if (!state.isOpen) { + panelEl.style.display = "none"; + } }, }); - instanceMap.set(container, api); - activeInstances.add(api); + // Wire up the input events + 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 = () => { + // Delay to allow mousedown on panel items + setTimeout(() => { + autocomplete.setIsOpen(false); + panelEl.style.display = "none"; + onValueChange?.(inputEl.value); + }, 200); + }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" && isPanelOpen && hasActiveItem) { + // Prevent the enter key from propagating to parent dialogs + // (which might interpret it as "submit" or "save and close") + e.stopPropagation(); + // We shouldn't preventDefault here because we want handlers.onKeyDown + // to process it properly. OnSelect will correctly close the panel. + } + 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); + }; + + instanceMap.set(inputEl, { autocomplete, panelEl, cleanup }); if (open) { - api.setIsOpen(true); - api.refresh(); + autocomplete.setIsOpen(true); + autocomplete.refresh(); + positionPanel(panelEl, inputEl); } } -/** Legacy implementation using old autocomplete.js jQuery plugin */ +// --------------------------------------------------------------------------- +// Attribute name autocomplete — legacy (old autocomplete.js) +// --------------------------------------------------------------------------- + function initAttributeNameLegacy({ $el, attributeType, open }: OldInitOptions) { if (!$el.hasClass("aa-input")) { $el.autocomplete( @@ -165,6 +270,18 @@ function initAttributeNameLegacy({ $el, attributeType, open }: OldInitOptions) { } } +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +function initAttributeNameAutocomplete(opts: InitAttributeNameOptions) { + if (isNewApi(opts)) { + initAttributeNameNew(opts); + } else { + initAttributeNameLegacy(opts); + } +} + // --------------------------------------------------------------------------- // Label value autocomplete (still using old autocomplete.js) // --------------------------------------------------------------------------- @@ -227,35 +344,7 @@ async function initLabelValueAutocomplete({ $el, open, nameCallback }: LabelValu } } -// --------------------------------------------------------------------------- -// Utilities for the new autocomplete-js containers -// --------------------------------------------------------------------------- - -function getInput(container: HTMLElement): HTMLInputElement | null { - return container.querySelector(".aa-Input"); -} - -function setInputValue(container: HTMLElement, value: string): void { - const input = getInput(container); - if (input) { - input.value = value; - } - const api = instanceMap.get(container); - if (api) { - api.setQuery(value); - } -} - -function getInputValue(container: HTMLElement): string { - const input = getInput(container); - return input?.value ?? ""; -} - export default { initAttributeNameAutocomplete, initLabelValueAutocomplete, - closeAllAttributeAutocompletes, - getInput, - setInputValue, - getInputValue, }; diff --git a/apps/client/src/stylesheets/style.css b/apps/client/src/stylesheets/style.css index 5a462b9804..78b758ef02 100644 --- a/apps/client/src/stylesheets/style.css +++ b/apps/client/src/stylesheets/style.css @@ -960,6 +960,38 @@ table.promoted-attributes-in-tooltip th { background-color: var(--active-item-background-color); } +/* ===== @algolia/autocomplete-core (headless, custom panel) ===== */ + +.aa-core-panel { + z-index: 10000; + background-color: var(--main-background-color); + border: 1px solid var(--main-border-color); + border-top: none; + max-height: 500px; + overflow: auto; + padding: 0; + margin: 0; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +.aa-core-list { + list-style: none; + padding: 0; + margin: 0; +} + +.aa-core-item { + cursor: pointer; + padding: 6px 16px; + margin: 0; +} + +.aa-core-item:hover, +.aa-core-item--active { + color: var(--active-item-text-color); + background-color: var(--active-item-background-color); +} + .help-button { float: inline-end; background: none; diff --git a/apps/client/src/widgets/attribute_widgets/attribute_detail.ts b/apps/client/src/widgets/attribute_widgets/attribute_detail.ts index 607af49952..c96974d1a5 100644 --- a/apps/client/src/widgets/attribute_widgets/attribute_detail.ts +++ b/apps/client/src/widgets/attribute_widgets/attribute_detail.ts @@ -378,7 +378,9 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget { attributeAutocompleteService.initAttributeNameAutocomplete({ $el: this.$inputName, attributeType: () => (["relation", "relation-definition"].includes(this.attrType || "") ? "relation" : "label"), - open: true + open: true, + useNewLib: true, + onValueChange: () => this.userEditedAttribute(), }); });