diff --git a/.agents/migration_plan_autocomplete.md b/.agents/migration_plan_autocomplete.md
index fa00076c1c..36b90d094d 100644
--- a/.agents/migration_plan_autocomplete.md
+++ b/.agents/migration_plan_autocomplete.md
@@ -164,7 +164,7 @@
---
-### Step 5: 迁移 `NoteAutocomplete.tsx` (React/Preact 组件)
+### Step 5: 迁移 `NoteAutocomplete.tsx` (React/Preact 组件) ✅ 基本完成
**文件变更:**
- `apps/client/src/widgets/react/NoteAutocomplete.tsx` — 传入容器 `
`,管理 `api` 生命周期
@@ -173,8 +173,10 @@
- `noteId` 和 `text` props 的动态更新正确
**当前状态:**
-- ⚠️ 尚未完成。虽然底层 `note_autocomplete.ts` 已经切到新实现,但 React 消费方仍需逐一验收。
-- ⚠️ 已抽查 `Move to`,当前功能不正常,说明 Step 5 仍存在待修复问题。
+- ✅ `NoteAutocomplete.tsx` 已移除残留的旧 `.autocomplete("val")` 调用,改为完全走 `note_autocomplete.ts` 暴露的 helper。
+- ✅ 组件现在会显式管理 headless autocomplete 的初始化/销毁生命周期,并清理 React 侧追加的 DOM / jQuery 监听,避免重复绑定。
+- ✅ `noteId` / `text` prop 同步已切到新状态流,`setNote()` 也会同步内部 query,避免仅改 DOM 值导致的状态漂移。
+- ⚠️ 仍需继续做手动回归验收,重点应覆盖 `Move to`、`Clone to`、`Include note`、`Add link`、bulk actions 等主要 React 消费方。
---
diff --git a/apps/client/src/services/note_autocomplete.ts b/apps/client/src/services/note_autocomplete.ts
index e487b2226d..265dbee0e6 100644
--- a/apps/client/src/services/note_autocomplete.ts
+++ b/apps/client/src/services/note_autocomplete.ts
@@ -454,7 +454,7 @@ async function handleSuggestionSelection(
$el.trigger("autocomplete:noteselected", [suggestion]);
}
-function clearText($el: JQuery) {
+export function clearText($el: JQuery) {
searchDelay = 0;
resetSelectionState($el);
const inputEl = $el[0] as HTMLInputElement;
@@ -594,6 +594,7 @@ function initNoteAutocomplete($el: JQuery, options?: Options) {
fetchResolvedSuggestions("", options).then((items) => {
autocomplete.setCollections([{ source, items }]);
+ autocomplete.setActiveItemId(items.length > 0 ? 0 : null);
autocomplete.setIsOpen(items.length > 0);
});
};
@@ -620,7 +621,9 @@ function initNoteAutocomplete($el: JQuery, options?: Options) {
const autocomplete = createAutocomplete({
openOnFocus: false, // Wait until we explicitly focus or type
- defaultActiveItemId: null,
+ // Old autocomplete.js used `autoselect: true`, so the first item
+ // should be immediately selectable when the panel opens.
+ defaultActiveItemId: 0,
shouldPanelOpen() {
return true;
},
@@ -794,6 +797,7 @@ function initNoteAutocomplete($el: JQuery, options?: Options) {
inputEl.removeEventListener("compositionstart", onCompositionStart);
inputEl.removeEventListener("compositionend", onCompositionEnd);
stopPositioning();
+ autocomplete.destroy();
if (panelEl.parentElement) {
panelEl.parentElement.removeChild(panelEl);
}
@@ -836,6 +840,15 @@ function initNoteAutocomplete($el: JQuery, options?: Options) {
return $el;
}
+export function destroyAutocomplete($el: JQuery | HTMLElement) {
+ const inputEl = $el instanceof HTMLElement ? $el : $el[0] as HTMLInputElement;
+ const instance = instanceMap.get(inputEl);
+ if (instance) {
+ instance.cleanup();
+ instanceMap.delete(inputEl);
+ }
+}
+
function init() {
$.fn.getSelectedNotePath = function () {
if (!String($(this).val())?.trim()) {
@@ -878,10 +891,19 @@ function init() {
$.fn.setNote = async function (noteId) {
const note = noteId ? await froca.getNote(noteId, true) : null;
+ const $el = $(this as unknown as HTMLElement);
+ const instance = getManagedInstance($el);
+ const noteTitle = note ? note.title : "";
- $(this)
- .val(note ? note.title : "")
+ $el
+ .val(noteTitle)
.setSelectedNotePath(noteId);
+
+ if (instance) {
+ instance.clearCursor();
+ instance.autocomplete.setQuery(noteTitle);
+ instance.autocomplete.setIsOpen(false);
+ }
};
}
@@ -902,6 +924,8 @@ export function triggerRecentNotes(inputElement: HTMLInputElement | null | undef
export default {
autocompleteSourceForCKEditor,
+ clearText,
+ destroyAutocomplete,
initNoteAutocomplete,
showRecentNotes,
showAllCommands,
diff --git a/apps/client/src/widgets/react/NoteAutocomplete.tsx b/apps/client/src/widgets/react/NoteAutocomplete.tsx
index fb96969401..cd720e158a 100644
--- a/apps/client/src/widgets/react/NoteAutocomplete.tsx
+++ b/apps/client/src/widgets/react/NoteAutocomplete.tsx
@@ -28,73 +28,97 @@ export default function NoteAutocomplete({ id, inputRef: externalInputRef, text,
if (!ref.current) return;
const $autoComplete = $(ref.current);
- // clear any event listener added in previous invocation of this function
- $autoComplete
- .off("autocomplete:noteselected")
- .off("autocomplete:commandselected")
-
note_autocomplete.initNoteAutocomplete($autoComplete, {
...opts,
container: container?.current
});
- if (onTextChange) {
- $autoComplete.on("input", () => onTextChange($autoComplete[0].value));
- }
- if (onKeyDown) {
- $autoComplete.on("keydown", (e) => e.originalEvent && onKeyDown(e.originalEvent));
- }
- if (onBlur) {
- $autoComplete.on("blur", () => onBlur($autoComplete.getSelectedNoteId() ?? ""));
- }
}, [opts, container?.current]);
- // On change event handlers.
useEffect(() => {
if (!ref.current) return;
const $autoComplete = $(ref.current);
+ const inputListener = () => onTextChange?.($autoComplete[0].value);
+ const keyDownListener = (e) => e.originalEvent && onKeyDown?.(e.originalEvent);
+ const blurListener = () => onBlur?.($autoComplete.getSelectedNoteId() ?? "");
- if (onChange || noteIdChanged) {
- const autoCompleteListener = (_e, suggestion) => {
- onChange?.(suggestion);
-
- if (noteIdChanged) {
- const noteId = suggestion?.notePath?.split("/")?.at(-1);
- noteIdChanged(noteId);
- }
- };
- const changeListener = (e) => {
- if (!ref.current?.value) {
- autoCompleteListener(e, null);
- }
- };
- $autoComplete
- .on("autocomplete:noteselected", autoCompleteListener)
- .on("autocomplete:externallinkselected", autoCompleteListener)
- .on("autocomplete:commandselected", autoCompleteListener)
- .on("change", changeListener);
- return () => {
- $autoComplete
- .off("autocomplete:noteselected", autoCompleteListener)
- .off("autocomplete:externallinkselected", autoCompleteListener)
- .off("autocomplete:commandselected", autoCompleteListener)
- .off("change", changeListener);
- };
+ if (onTextChange) {
+ $autoComplete.on("input", inputListener);
}
- }, [opts, container?.current, onChange, noteIdChanged])
+ if (onKeyDown) {
+ $autoComplete.on("keydown", keyDownListener);
+ }
+ if (onBlur) {
+ $autoComplete.on("blur", blurListener);
+ }
+
+ return () => {
+ if (onTextChange) {
+ $autoComplete.off("input", inputListener);
+ }
+ if (onKeyDown) {
+ $autoComplete.off("keydown", keyDownListener);
+ }
+ if (onBlur) {
+ $autoComplete.off("blur", blurListener);
+ }
+ };
+ }, [onBlur, onKeyDown, onTextChange]);
+
+ useEffect(() => {
+ if (!ref.current) return;
+ const $autoComplete = $(ref.current);
+ if (!(onChange || noteIdChanged)) {
+ return;
+ }
+
+ const autoCompleteListener = (_e, suggestion) => {
+ onChange?.(suggestion);
+
+ if (noteIdChanged) {
+ const noteId = suggestion?.notePath?.split("/")?.at(-1);
+ noteIdChanged(noteId);
+ }
+ };
+ const changeListener = (e) => {
+ if (!ref.current?.value) {
+ autoCompleteListener(e, null);
+ }
+ };
+
+ $autoComplete
+ .on("autocomplete:noteselected", autoCompleteListener)
+ .on("autocomplete:externallinkselected", autoCompleteListener)
+ .on("autocomplete:commandselected", autoCompleteListener)
+ .on("change", changeListener);
+
+ return () => {
+ $autoComplete
+ .off("autocomplete:noteselected", autoCompleteListener)
+ .off("autocomplete:externallinkselected", autoCompleteListener)
+ .off("autocomplete:commandselected", autoCompleteListener)
+ .off("change", changeListener);
+ };
+ }, [onChange, noteIdChanged]);
useEffect(() => {
if (!ref.current) return;
const $autoComplete = $(ref.current);
if (noteId) {
- $autoComplete.setNote(noteId);
- } else if (text) {
- note_autocomplete.setText($autoComplete, text);
- } else {
- $autoComplete.setSelectedNotePath("");
- $autoComplete.autocomplete("val", "");
- ref.current.value = "";
+ void $autoComplete.setNote(noteId);
+ return;
}
+
+ if (text !== undefined) {
+ if (text) {
+ note_autocomplete.setText($autoComplete, text);
+ } else {
+ note_autocomplete.clearText($autoComplete);
+ }
+ return;
+ }
+
+ note_autocomplete.clearText($autoComplete);
}, [text, noteId]);
return (