From e5a9c0de49ac268ac48eccfdd8a44810980ca1c3 Mon Sep 17 00:00:00 2001 From: JYC333 <22962980+JYC333@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:21:18 +0000 Subject: [PATCH] refactor: extract common logic --- .../src/services/attribute_autocomplete.ts | 226 ++++++------------ apps/client/src/services/autocomplete_core.ts | 159 +++++++++++- apps/client/src/services/note_autocomplete.ts | 195 ++++++--------- 3 files changed, 300 insertions(+), 280 deletions(-) diff --git a/apps/client/src/services/attribute_autocomplete.ts b/apps/client/src/services/attribute_autocomplete.ts index c69fb32983..37b84c6e28 100644 --- a/apps/client/src/services/attribute_autocomplete.ts +++ b/apps/client/src/services/attribute_autocomplete.ts @@ -2,7 +2,7 @@ import type { AutocompleteApi as CoreAutocompleteApi, BaseItem } from "@algolia/ import { createAutocomplete } from "@algolia/autocomplete-core"; import type { AttributeType } from "../entities/fattribute.js"; -import { withHeadlessSourceDefaults } from "./autocomplete_core.js"; +import { bindAutocompleteInput, createHeadlessPanelController, withHeadlessSourceDefaults } from "./autocomplete_core.js"; import server from "./server.js"; // --------------------------------------------------------------------------- @@ -34,18 +34,6 @@ interface ManagedInstance { const instanceMap = new WeakMap(); -// --------------------------------------------------------------------------- -// 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) { @@ -70,19 +58,6 @@ function renderItems(panelEl: HTMLElement, items: NameItem[], activeItemId: numb panelEl.appendChild(list); } -function positionPanel(panelEl: HTMLElement, inputEl: HTMLElement): void { - const rect = inputEl.getBoundingClientRect(); - const top = `${rect.bottom}px`; - const left = `${rect.left}px`; - const width = `${rect.width}px`; - - panelEl.style.position = "fixed"; - if (panelEl.style.top !== top) panelEl.style.top = top; - if (panelEl.style.left !== left) panelEl.style.left = left; - if (panelEl.style.width !== width) panelEl.style.width = width; - if (panelEl.style.display !== "block") panelEl.style.display = "block"; -} - // --------------------------------------------------------------------------- // Attribute name autocomplete — new (autocomplete-core, headless) // --------------------------------------------------------------------------- @@ -104,27 +79,12 @@ function initAttributeNameAutocomplete({ $el, attributeType, open, onValueChange return; } - const panelEl = createPanelEl(); + const panelController = createHeadlessPanelController({ inputEl }); + const { panelEl } = panelController; let isPanelOpen = false; let hasActiveItem = false; - let rafId: number | null = null; - function startPositioning() { - if (rafId !== null) return; - const update = () => { - positionPanel(panelEl, inputEl); - rafId = requestAnimationFrame(update); - }; - update(); - } - function stopPositioning() { - if (rafId !== null) { - cancelAnimationFrame(rafId); - rafId = null; - } - } - const autocomplete = createAutocomplete({ openOnFocus: true, defaultActiveItemId: 0, @@ -171,62 +131,48 @@ function initAttributeNameAutocomplete({ $el, attributeType, open, onValueChange autocomplete.setIsOpen(false); onValueChange?.(item.name); }); - startPositioning(); + panelController.startPositioning(); } else { - panelEl.style.display = "none"; - stopPositioning(); + panelController.hide(); } if (!state.isOpen) { - panelEl.style.display = "none"; - stopPositioning(); + panelController.hide(); } }, }); - // Wire up the input events - const handlers = autocomplete.getInputProps({ inputElement: inputEl }); - const onInput = (e: Event) => { - handlers.onChange(e as any); - }; - const onFocus = (e: Event) => { - syncQueryFromInputValue(autocomplete); - handlers.onFocus(e as any); - }; - const onBlur = () => { - // Delay to allow mousedown on panel items - setTimeout(() => { - autocomplete.setIsOpen(false); - panelEl.style.display = "none"; - stopPositioning(); - onValueChange?.(inputEl.value); - }, 50); - }; - 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. + const cleanupInputBindings = bindAutocompleteInput({ + inputEl, + autocomplete, + onInput(e, handlers) { + handlers.onChange(e as any); + }, + onFocus(e, handlers) { + syncQueryFromInputValue(autocomplete); + handlers.onFocus(e as any); + }, + onBlur() { + // Delay to allow mousedown on panel items + setTimeout(() => { + autocomplete.setIsOpen(false); + panelController.hide(); + onValueChange?.(inputEl.value); + }, 50); + }, + onKeyDown(e, handlers) { + 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(); + } + handlers.onKeyDown(e as any); } - 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); - stopPositioning(); - if (panelEl.parentElement) { - panelEl.parentElement.removeChild(panelEl); - } + cleanupInputBindings(); + panelController.destroy(); }; instanceMap.set(inputEl, { autocomplete, panelEl, cleanup }); @@ -235,7 +181,7 @@ function initAttributeNameAutocomplete({ $el, attributeType, open, onValueChange syncQueryFromInputValue(autocomplete); autocomplete.setIsOpen(true); autocomplete.refresh(); - startPositioning(); + panelController.startPositioning(); } } @@ -268,28 +214,13 @@ function initLabelValueAutocomplete({ $el, open, nameCallback, onValueChange }: return; } - const panelEl = createPanelEl(); + const panelController = createHeadlessPanelController({ inputEl }); + const { panelEl } = panelController; let isPanelOpen = false; let hasActiveItem = false; let isSelecting = false; - let rafId: number | null = null; - function startPositioning() { - if (rafId !== null) return; - const update = () => { - positionPanel(panelEl, inputEl); - rafId = requestAnimationFrame(update); - }; - update(); - } - function stopPositioning() { - if (rafId !== null) { - cancelAnimationFrame(rafId); - rafId = null; - } - } - let cachedAttributeName = ""; let cachedAttributeValues: NameItem[] = []; @@ -363,63 +294,52 @@ function initLabelValueAutocomplete({ $el, open, nameCallback, onValueChange }: if (state.isOpen && items.length > 0) { renderItems(panelEl, items, activeId, handleSelect); - startPositioning(); + panelController.startPositioning(); } else { - panelEl.style.display = "none"; - stopPositioning(); + panelController.hide(); } if (!state.isOpen) { - panelEl.style.display = "none"; - stopPositioning(); + panelController.hide(); } }, }); - const handlers = autocomplete.getInputProps({ inputElement: inputEl }); - const onInput = (e: Event) => { - if (!isSelecting) { - handlers.onChange(e as any); + const cleanupInputBindings = bindAutocompleteInput({ + inputEl, + autocomplete, + onInput(e, handlers) { + if (!isSelecting) { + handlers.onChange(e as any); + } + }, + onFocus(e, handlers) { + const attributeName = nameCallback ? nameCallback() : ""; + if (attributeName !== cachedAttributeName) { + cachedAttributeName = ""; + cachedAttributeValues = []; + } + syncQueryFromInputValue(autocomplete); + handlers.onFocus(e as any); + }, + onBlur() { + setTimeout(() => { + autocomplete.setIsOpen(false); + panelController.hide(); + onValueChange?.(inputEl.value); + }, 50); + }, + onKeyDown(e, handlers) { + if (e.key === "Enter" && isPanelOpen && hasActiveItem) { + e.stopPropagation(); + } + handlers.onKeyDown(e as any); } - }; - const onFocus = (e: Event) => { - const attributeName = nameCallback ? nameCallback() : ""; - if (attributeName !== cachedAttributeName) { - cachedAttributeName = ""; - cachedAttributeValues = []; - } - syncQueryFromInputValue(autocomplete); - handlers.onFocus(e as any); - }; - const onBlur = () => { - setTimeout(() => { - autocomplete.setIsOpen(false); - panelEl.style.display = "none"; - stopPositioning(); - onValueChange?.(inputEl.value); - }, 50); - }; - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Enter" && isPanelOpen && hasActiveItem) { - e.stopPropagation(); - } - 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); - stopPositioning(); - if (panelEl.parentElement) { - panelEl.parentElement.removeChild(panelEl); - } + cleanupInputBindings(); + panelController.destroy(); }; instanceMap.set(inputEl, { autocomplete, panelEl, cleanup }); @@ -428,7 +348,7 @@ function initLabelValueAutocomplete({ $el, open, nameCallback, onValueChange }: syncQueryFromInputValue(autocomplete); autocomplete.setIsOpen(true); autocomplete.refresh(); - startPositioning(); + panelController.startPositioning(); } } diff --git a/apps/client/src/services/autocomplete_core.ts b/apps/client/src/services/autocomplete_core.ts index 7332f59fab..c58ff68518 100644 --- a/apps/client/src/services/autocomplete_core.ts +++ b/apps/client/src/services/autocomplete_core.ts @@ -1,4 +1,4 @@ -import type { AutocompleteSource, BaseItem } from "@algolia/autocomplete-core"; +import type { AutocompleteApi, AutocompleteSource, BaseItem } from "@algolia/autocomplete-core"; export function withHeadlessSourceDefaults( source: AutocompleteSource @@ -13,3 +13,160 @@ export function withHeadlessSourceDefaults( ...source }; } + +interface HeadlessPanelControllerOptions { + inputEl: HTMLElement; + container?: HTMLElement | null; + className?: string; + containedClassName?: string; +} + +export function createHeadlessPanelController({ + inputEl, + container, + className = "aa-core-panel", + containedClassName = "aa-core-panel--contained" +}: HeadlessPanelControllerOptions) { + const panelEl = document.createElement("div"); + panelEl.className = className; + + const isContained = Boolean(container); + if (isContained) { + panelEl.classList.add(containedClassName); + container!.appendChild(panelEl); + } else { + document.body.appendChild(panelEl); + } + + panelEl.style.display = "none"; + + let rafId: number | null = null; + + const positionPanel = () => { + if (isContained) { + panelEl.style.position = "static"; + panelEl.style.top = ""; + panelEl.style.left = ""; + panelEl.style.width = "100%"; + panelEl.style.display = "block"; + return; + } + + 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"; + }; + + const stopPositioning = () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + }; + + const startPositioning = () => { + if (isContained) { + positionPanel(); + return; + } + + if (rafId !== null) { + return; + } + + const update = () => { + positionPanel(); + rafId = requestAnimationFrame(update); + }; + + update(); + }; + + const hide = () => { + panelEl.style.display = "none"; + stopPositioning(); + }; + + const destroy = () => { + hide(); + panelEl.remove(); + }; + + return { + panelEl, + hide, + destroy, + startPositioning, + stopPositioning + }; +} + +type InputHandlers = ReturnType["getInputProps"]>; + +interface InputBinding { + type: string; + listener: (event: TEvent) => void; +} + +interface BindAutocompleteInputOptions { + inputEl: HTMLInputElement; + autocomplete: AutocompleteApi; + onInput?: (event: Event, handlers: InputHandlers) => void; + onFocus?: (event: Event, handlers: InputHandlers) => void; + onBlur?: (event: Event, handlers: InputHandlers) => void; + onKeyDown?: (event: KeyboardEvent, handlers: InputHandlers) => void; + extraBindings?: InputBinding[]; +} + +export function bindAutocompleteInput({ + inputEl, + autocomplete, + onInput, + onFocus, + onBlur, + onKeyDown, + extraBindings = [] +}: BindAutocompleteInputOptions) { + const handlers = autocomplete.getInputProps({ inputElement: inputEl }); + + const bindings: InputBinding[] = [ + { + type: "input", + listener: (event: Event) => { + onInput?.(event, handlers); + } + }, + { + type: "focus", + listener: (event: Event) => { + onFocus?.(event, handlers); + } + }, + { + type: "blur", + listener: (event: Event) => { + onBlur?.(event, handlers); + } + }, + { + type: "keydown", + listener: (event: KeyboardEvent) => { + onKeyDown?.(event, handlers); + } + }, + ...extraBindings + ]; + + bindings.forEach(({ type, listener }) => { + inputEl.addEventListener(type, listener as EventListener); + }); + + return () => { + bindings.forEach(({ type, listener }) => { + inputEl.removeEventListener(type, listener as EventListener); + }); + }; +} diff --git a/apps/client/src/services/note_autocomplete.ts b/apps/client/src/services/note_autocomplete.ts index 7b061d11b7..a0fb1e2d3a 100644 --- a/apps/client/src/services/note_autocomplete.ts +++ b/apps/client/src/services/note_autocomplete.ts @@ -3,7 +3,7 @@ import { createAutocomplete } from "@algolia/autocomplete-core"; import type { MentionFeedObjectItem } from "@triliumnext/ckeditor5"; import appContext from "../components/app_context.js"; -import { withHeadlessSourceDefaults } from "./autocomplete_core.js"; +import { bindAutocompleteInput, createHeadlessPanelController, withHeadlessSourceDefaults } from "./autocomplete_core.js"; import commandRegistry from "./command_registry.js"; import froca from "./froca.js"; import { t } from "./i18n.js"; @@ -72,37 +72,6 @@ interface ManagedInstance { const instanceMap = new WeakMap(); -function createPanelEl(container?: HTMLElement | null): HTMLElement { - const panel = document.createElement("div"); - panel.className = "aa-core-panel aa-dropdown-menu"; - if (container) { - panel.classList.add("aa-core-panel--contained"); - container.appendChild(panel); - } else { - document.body.appendChild(panel); - } - panel.style.display = "none"; - return panel; -} - -function positionPanel(panelEl: HTMLElement, inputEl: HTMLElement): void { - if (panelEl.classList.contains("aa-core-panel--contained")) { - panelEl.style.position = "static"; - panelEl.style.top = ""; - panelEl.style.left = ""; - panelEl.style.width = "100%"; - panelEl.style.display = "block"; - return; - } - - 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"; -} - function escapeHtml(text: string): string { return text .replaceAll("&", "&") @@ -535,8 +504,12 @@ function initNoteAutocomplete($el: JQuery, options?: Options) { options = options || {}; let isComposingInput = false; - const panelEl = createPanelEl(options.container); - let rafId: number | null = null; + const panelController = createHeadlessPanelController({ + inputEl, + container: options.container, + className: "aa-core-panel aa-dropdown-menu" + }); + const { panelEl } = panelController; let currentQuery = inputEl.value; let shouldAutoselectTopItem = false; let shouldMirrorActiveItemToInput = false; @@ -600,26 +573,6 @@ function initNoteAutocomplete($el: JQuery, options?: Options) { }); }; - function startPositioning() { - if (panelEl.classList.contains("aa-core-panel--contained")) { - positionPanel(panelEl, inputEl); - return; - } - - if (rafId !== null) return; - const update = () => { - positionPanel(panelEl, inputEl); - rafId = requestAnimationFrame(update); - }; - update(); - } - function stopPositioning() { - if (rafId !== null) { - cancelAnimationFrame(rafId); - rafId = null; - } - } - const autocomplete = createAutocomplete({ openOnFocus: false, // Wait until we explicitly focus or type // Old autocomplete.js used `autoselect: true`, so the first item @@ -713,68 +666,14 @@ function initNoteAutocomplete($el: JQuery, options?: Options) { return; } - startPositioning(); + panelController.startPositioning(); } else { shouldAutoselectTopItem = false; - panelEl.style.display = "none"; - stopPositioning(); + panelController.hide(); } }, }); - 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) => { - if (inputEl.readOnly) { - autocomplete.setIsOpen(false); - panelEl.style.display = "none"; - stopPositioning(); - return; - } - handlers.onFocus(e as any); - }; - const onBlur = () => { - if (options.container) { - return; - } - setTimeout(() => { - autocomplete.setIsOpen(false); - panelEl.style.display = "none"; - stopPositioning(); - }, 50); - }; - const onKeyDown = (e: KeyboardEvent) => { - if (options.allowJumpToSearchNotes && e.ctrlKey && e.key === "Enter") { - e.stopImmediatePropagation(); - e.preventDefault(); - void handleSuggestionSelection($el, autocomplete, inputEl, { - action: "search-notes", - noteTitle: inputEl.value - }); - return; - } - - if (e.shiftKey && e.key === "Enter") { - e.stopImmediatePropagation(); - e.preventDefault(); - fullTextSearch($el, options); - return; - } - - if (e.key === "ArrowDown" || e.key === "ArrowUp") { - shouldMirrorActiveItemToInput = true; - } - handlers.onKeyDown(e as any); - }; const onCompositionStart = () => { isComposingInput = true; }; @@ -783,25 +682,69 @@ function initNoteAutocomplete($el: JQuery, options?: Options) { rerunQuery(inputEl.value); }; - inputEl.addEventListener("input", onInput); - inputEl.addEventListener("focus", onFocus); - inputEl.addEventListener("blur", onBlur); - inputEl.addEventListener("keydown", onKeyDown); - inputEl.addEventListener("compositionstart", onCompositionStart); - inputEl.addEventListener("compositionend", onCompositionEnd); + const cleanupInputBindings = bindAutocompleteInput({ + inputEl, + autocomplete, + onInput(e, handlers) { + const value = (e.currentTarget as HTMLInputElement).value; + if (value.trim().length === 0) { + openRecentNotes(); + return; + } + + prepareForQueryChange(); + handlers.onChange(e as any); + }, + onFocus(e, handlers) { + if (inputEl.readOnly) { + autocomplete.setIsOpen(false); + panelController.hide(); + return; + } + handlers.onFocus(e as any); + }, + onBlur() { + if (options.container) { + return; + } + setTimeout(() => { + autocomplete.setIsOpen(false); + panelController.hide(); + }, 50); + }, + onKeyDown(e, handlers) { + if (options.allowJumpToSearchNotes && e.ctrlKey && e.key === "Enter") { + e.stopImmediatePropagation(); + e.preventDefault(); + void handleSuggestionSelection($el, autocomplete, inputEl, { + action: "search-notes", + noteTitle: inputEl.value + }); + return; + } + + if (e.shiftKey && e.key === "Enter") { + e.stopImmediatePropagation(); + e.preventDefault(); + fullTextSearch($el, options); + return; + } + + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + shouldMirrorActiveItemToInput = true; + } + handlers.onKeyDown(e as any); + }, + extraBindings: [ + { type: "compositionstart", listener: onCompositionStart }, + { type: "compositionend", listener: onCompositionEnd } + ] + }); const cleanup = () => { - inputEl.removeEventListener("input", onInput); - inputEl.removeEventListener("focus", onFocus); - inputEl.removeEventListener("blur", onBlur); - inputEl.removeEventListener("keydown", onKeyDown); - inputEl.removeEventListener("compositionstart", onCompositionStart); - inputEl.removeEventListener("compositionend", onCompositionEnd); - stopPositioning(); + cleanupInputBindings(); autocomplete.destroy(); - if (panelEl.parentElement) { - panelEl.parentElement.removeChild(panelEl); - } + panelController.destroy(); }; instanceMap.set(inputEl, {