From 0dee06262bea859450579267afb599b7c052e692 Mon Sep 17 00:00:00 2001 From: JYC333 <22962980+JYC333@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:50:35 +0000 Subject: [PATCH] refactor: migrate react part --- .agents/migration_plan_autocomplete.md | 8 +- apps/client/src/services/note_autocomplete.ts | 32 ++++- .../src/widgets/react/NoteAutocomplete.tsx | 122 +++++++++++------- 3 files changed, 106 insertions(+), 56 deletions(-) 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 (