refactor: extract common logic

This commit is contained in:
JYC333 2026-03-09 17:21:18 +00:00
parent 2690256f27
commit e5a9c0de49
3 changed files with 300 additions and 280 deletions

View File

@ -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<HTMLElement, ManagedInstance>();
// ---------------------------------------------------------------------------
// 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<NameItem>({
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<NameItem>({
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<NameItem>({
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();
}
}

View File

@ -1,4 +1,4 @@
import type { AutocompleteSource, BaseItem } from "@algolia/autocomplete-core";
import type { AutocompleteApi, AutocompleteSource, BaseItem } from "@algolia/autocomplete-core";
export function withHeadlessSourceDefaults<TItem extends BaseItem>(
source: AutocompleteSource<TItem>
@ -13,3 +13,160 @@ export function withHeadlessSourceDefaults<TItem extends BaseItem>(
...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<TItem extends BaseItem> = ReturnType<AutocompleteApi<TItem>["getInputProps"]>;
interface InputBinding<TEvent extends Event = Event> {
type: string;
listener: (event: TEvent) => void;
}
interface BindAutocompleteInputOptions<TItem extends BaseItem> {
inputEl: HTMLInputElement;
autocomplete: AutocompleteApi<TItem>;
onInput?: (event: Event, handlers: InputHandlers<TItem>) => void;
onFocus?: (event: Event, handlers: InputHandlers<TItem>) => void;
onBlur?: (event: Event, handlers: InputHandlers<TItem>) => void;
onKeyDown?: (event: KeyboardEvent, handlers: InputHandlers<TItem>) => void;
extraBindings?: InputBinding[];
}
export function bindAutocompleteInput<TItem extends BaseItem>({
inputEl,
autocomplete,
onInput,
onFocus,
onBlur,
onKeyDown,
extraBindings = []
}: BindAutocompleteInputOptions<TItem>) {
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);
});
};
}

View File

@ -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<HTMLElement, ManagedInstance>();
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("&", "&amp;")
@ -535,8 +504,12 @@ function initNoteAutocomplete($el: JQuery<HTMLElement>, 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<HTMLElement>, 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<Suggestion>({
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<HTMLElement>, 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<HTMLElement>, 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<Suggestion>({
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, {