Skip to content

Commit 0957421

Browse files
committed
refactor: migrate label autocomplete
1 parent e208bba commit 0957421

3 files changed

Lines changed: 185 additions & 45 deletions

File tree

.agents/migration_plan_autocomplete.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,16 +64,21 @@
6464

6565
---
6666

67-
### Step 2: 迁移标签值自动补全
67+
### Step 2: 迁移标签值自动补全 ✅ 完成
6868
**文件变更:**
69-
- `apps/client/src/services/attribute_autocomplete.ts``initLabelValueAutocomplete()` 改为直接调用 `autocomplete()`
70-
- `apps/client/src/widgets/attribute_widgets/attribute_detail.ts`标签值输入框同步调整
69+
- `apps/client/src/services/attribute_autocomplete.ts`移除旧有的 jQuery `$el.autocomplete` 初始化,整体复用封装的 `@algolia/autocomplete-core` Headless 架构流。在内部设计了一套针对 Label Name 值更变时的 `cachedAttributeName` 以及 `getItems` 数据惰性更新机制。
70+
- `apps/client/src/widgets/attribute_widgets/attribute_detail.ts`取消监听不标准的 jQuery 强盗冒泡事件 `autocomplete:closed`,改为直接在配置中传入清晰的 `onValueChange` 回调函数。同时解决了所有输入遗留 Bug。
7171

72-
**说明:**
73-
与 Step 1 类似,但标签值补全有一个特殊点:每次 focus 都会重新初始化(因为属性名可能变了,需要重新获取可选值列表)。
72+
**说明与优化点:**
73+
与 Step 1 类似,同样完全剔除了所有的残旧依赖与 jQuery 控制流,在此基础上还针对值类型的特异性做了几个高级改动:
74+
1. **取消内存破坏型重建 (Fix Memory Leak)**:旧版本在每次触发聚焦 (Focus) 时都会发送摧毁指令强扫 DOM。新架构下只要容器保持存活就仅仅使用 `.refresh()` 接口来控制界面弹出与数据隐式获取。
75+
2. **惰性与本地缓存 (Local Fast CACHE)**:如果关联的属性名 (Attribute Name) 没有被更改,再次打开提示面板时将以 0ms 的延迟抛出旧缓存 `cachedAttributeValues`。一旦属性名被修改,则重新发起服务端网络请求。
76+
3. **彻底分离逻辑**:删除了文件中的 `still using old autocomplete.js` 遗留注释,此时 `attribute_autocomplete.ts` 文件内已经 100% 运行在崭新的 Autocomplete 体系上。
7477

7578
**验证方式:**
76-
- 打开属性面板 → 输入一个标签名 → 切换到值输入框 → 应能看到该标签的已有值列表
79+
- ✅ 打开属性面板 → 点击或输入任意已有 Label 类型的 Name → 切换到值输入框 → 能瞬间弹出相应的旧值提示列表。
80+
- ✅ 在旧值提示列表中用上下方向键选取并回车 → 能实现无缝填充并将更变保存回右侧详细侧边栏。
81+
- ✅ 解决回车冲突:确认选择时系统发出的事件能干净落回所属宿主 DOM 且并不抢占外层组件快捷键。
7782

7883
---
7984

apps/client/src/services/attribute_autocomplete.ts

Lines changed: 172 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -234,64 +234,199 @@ function initAttributeNameAutocomplete({ $el, attributeType, open, onValueChange
234234

235235

236236
// ---------------------------------------------------------------------------
237-
// Label value autocomplete (still using old autocomplete.js)
237+
// Label value autocomplete (headless autocomplete-core)
238238
// ---------------------------------------------------------------------------
239239

240240
interface LabelValueInitOptions {
241241
$el: JQuery<HTMLElement>;
242242
open: boolean;
243243
nameCallback?: () => string;
244+
onValueChange?: (value: string) => void;
244245
}
245246

246-
async function initLabelValueAutocomplete({ $el, open, nameCallback }: LabelValueInitOptions) {
247-
if ($el.hasClass("aa-input")) {
248-
$el.autocomplete("destroy");
249-
}
250-
251-
let attributeName = "";
252-
if (nameCallback) {
253-
attributeName = nameCallback();
254-
}
247+
function initLabelValueAutocomplete({ $el, open, nameCallback, onValueChange }: LabelValueInitOptions) {
248+
const inputEl = $el[0] as HTMLInputElement;
255249

256-
if (attributeName.trim() === "") {
250+
if (instanceMap.has(inputEl)) {
251+
if (open) {
252+
const inst = instanceMap.get(inputEl)!;
253+
inst.autocomplete.setIsOpen(true);
254+
inst.autocomplete.refresh();
255+
}
257256
return;
258257
}
259258

260-
const attributeValues = (await server.get<string[]>(`attribute-values/${encodeURIComponent(attributeName)}`)).map((attribute) => ({ value: attribute }));
259+
const panelEl = createPanelEl();
260+
261+
let isPanelOpen = false;
262+
let hasActiveItem = false;
263+
let isSelecting = false;
261264

262-
if (attributeValues.length === 0) {
263-
return;
265+
let rafId: number | null = null;
266+
function startPositioning() {
267+
if (rafId !== null) return;
268+
const update = () => {
269+
positionPanel(panelEl, inputEl);
270+
rafId = requestAnimationFrame(update);
271+
};
272+
update();
264273
}
274+
function stopPositioning() {
275+
if (rafId !== null) {
276+
cancelAnimationFrame(rafId);
277+
rafId = null;
278+
}
279+
}
280+
281+
let cachedAttributeName = "";
282+
let cachedAttributeValues: NameItem[] = [];
265283

266-
$el.autocomplete(
267-
{
268-
appendTo: document.querySelector("body"),
269-
hint: false,
270-
openOnFocus: false,
271-
minLength: 0,
272-
tabAutocomplete: false
284+
const autocomplete = createAutocomplete<NameItem>({
285+
openOnFocus: true,
286+
defaultActiveItemId: null,
287+
shouldPanelOpen() {
288+
return true;
289+
},
290+
291+
getSources({ query }) {
292+
return [
293+
{
294+
sourceId: "attribute-values",
295+
async getItems() {
296+
const attributeName = nameCallback ? nameCallback() : "";
297+
if (!attributeName.trim()) {
298+
return [];
299+
}
300+
301+
if (attributeName !== cachedAttributeName || cachedAttributeValues.length === 0) {
302+
cachedAttributeName = attributeName;
303+
const values = await server.get<string[]>(`attribute-values/${encodeURIComponent(attributeName)}`);
304+
cachedAttributeValues = values.map((name) => ({ name }));
305+
}
306+
307+
const q = query.toLowerCase();
308+
return cachedAttributeValues.filter((attr) => attr.name.toLowerCase().includes(q));
309+
},
310+
getItemInputValue({ item }) {
311+
return item.name;
312+
},
313+
onSelect({ item }) {
314+
isSelecting = true;
315+
inputEl.value = item.name;
316+
inputEl.dispatchEvent(new Event("input", { bubbles: true }));
317+
autocomplete.setQuery(item.name);
318+
autocomplete.setIsOpen(false);
319+
onValueChange?.(item.name);
320+
isSelecting = false;
321+
322+
setTimeout(() => {
323+
inputEl.dispatchEvent(new KeyboardEvent("keydown", {
324+
key: "Enter",
325+
code: "Enter",
326+
keyCode: 13,
327+
which: 13,
328+
bubbles: true,
329+
cancelable: true
330+
}));
331+
}, 0);
332+
},
333+
},
334+
];
273335
},
274-
[
275-
{
276-
displayKey: "value",
277-
cache: false,
278-
source: async function (term, cb) {
279-
term = term.toLowerCase();
280-
const filtered = attributeValues.filter((attr) => attr.value.toLowerCase().includes(term));
281-
cb(filtered);
282-
}
336+
337+
onStateChange({ state }) {
338+
isPanelOpen = state.isOpen;
339+
hasActiveItem = state.activeItemId !== null;
340+
341+
const collections = state.collections;
342+
const items = collections.length > 0 ? (collections[0].items as NameItem[]) : [];
343+
const activeId = state.activeItemId ?? null;
344+
345+
if (state.isOpen && items.length > 0) {
346+
renderItems(panelEl, items, activeId, (item) => {
347+
isSelecting = true;
348+
inputEl.value = item.name;
349+
inputEl.dispatchEvent(new Event("input", { bubbles: true }));
350+
autocomplete.setQuery(item.name);
351+
autocomplete.setIsOpen(false);
352+
onValueChange?.(item.name);
353+
isSelecting = false;
354+
355+
setTimeout(() => {
356+
inputEl.dispatchEvent(new KeyboardEvent("keydown", {
357+
key: "Enter",
358+
code: "Enter",
359+
keyCode: 13,
360+
which: 13,
361+
bubbles: true,
362+
cancelable: true
363+
}));
364+
}, 0);
365+
});
366+
startPositioning();
367+
} else {
368+
panelEl.style.display = "none";
369+
stopPositioning();
283370
}
284-
]
285-
);
286371

287-
$el.on("autocomplete:opened", () => {
288-
if ($el.attr("readonly")) {
289-
$el.autocomplete("close");
290-
}
372+
if (!state.isOpen) {
373+
panelEl.style.display = "none";
374+
stopPositioning();
375+
}
376+
},
291377
});
292378

379+
const handlers = autocomplete.getInputProps({ inputElement: inputEl });
380+
const onInput = (e: Event) => {
381+
if (!isSelecting) {
382+
handlers.onChange(e as any);
383+
}
384+
};
385+
const onFocus = (e: Event) => {
386+
const attributeName = nameCallback ? nameCallback() : "";
387+
if (attributeName !== cachedAttributeName) {
388+
cachedAttributeName = "";
389+
cachedAttributeValues = [];
390+
}
391+
handlers.onFocus(e as any);
392+
};
393+
const onBlur = () => {
394+
setTimeout(() => {
395+
autocomplete.setIsOpen(false);
396+
panelEl.style.display = "none";
397+
stopPositioning();
398+
onValueChange?.(inputEl.value);
399+
}, 200);
400+
};
401+
const onKeyDown = (e: KeyboardEvent) => {
402+
if (e.key === "Enter" && isPanelOpen && hasActiveItem) {
403+
e.stopPropagation();
404+
}
405+
handlers.onKeyDown(e as any);
406+
};
407+
408+
inputEl.addEventListener("input", onInput);
409+
inputEl.addEventListener("focus", onFocus);
410+
inputEl.addEventListener("blur", onBlur);
411+
inputEl.addEventListener("keydown", onKeyDown);
412+
413+
const cleanup = () => {
414+
inputEl.removeEventListener("input", onInput);
415+
inputEl.removeEventListener("focus", onFocus);
416+
inputEl.removeEventListener("blur", onBlur);
417+
inputEl.removeEventListener("keydown", onKeyDown);
418+
stopPositioning();
419+
if (panelEl.parentElement) {
420+
panelEl.parentElement.removeChild(panelEl);
421+
}
422+
};
423+
424+
instanceMap.set(inputEl, { autocomplete, panelEl, cleanup });
425+
293426
if (open) {
294-
$el.autocomplete("open");
427+
autocomplete.setIsOpen(true);
428+
autocomplete.refresh();
429+
startPositioning();
295430
}
296431
}
297432

apps/client/src/widgets/attribute_widgets/attribute_detail.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -392,12 +392,12 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
392392
}
393393
});
394394
this.$inputValue.on("change", () => this.userEditedAttribute());
395-
this.$inputValue.on("autocomplete:closed", () => this.userEditedAttribute());
396395
this.$inputValue.on("focus", () => {
397396
attributeAutocompleteService.initLabelValueAutocomplete({
398397
$el: this.$inputValue,
399398
open: true,
400-
nameCallback: () => String(this.$inputName.val())
399+
nameCallback: () => String(this.$inputName.val()),
400+
onValueChange: () => this.userEditedAttribute(),
401401
});
402402
});
403403

0 commit comments

Comments
 (0)