mirror of
https://github.com/zadam/trilium.git
synced 2026-03-10 10:23:41 +01:00
436 lines
14 KiB
TypeScript
436 lines
14 KiB
TypeScript
import type { AutocompleteApi as CoreAutocompleteApi, BaseItem } from "@algolia/autocomplete-core";
|
|
import { createAutocomplete } from "@algolia/autocomplete-core";
|
|
|
|
import type { AttributeType } from "../entities/fattribute.js";
|
|
import { bindAutocompleteInput, createHeadlessPanelController, registerHeadlessAutocompleteCloser, withHeadlessSourceDefaults } from "./autocomplete_core.js";
|
|
import server from "./server.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface NameItem extends BaseItem {
|
|
name: string;
|
|
}
|
|
|
|
interface InitAttributeNameOptions {
|
|
/** The <input> element where the user types */
|
|
$el: JQuery<HTMLElement>;
|
|
attributeType?: AttributeType | (() => AttributeType);
|
|
open: boolean;
|
|
/** Called when the user selects a value or the panel closes */
|
|
onValueChange?: (value: string) => void;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Instance tracking
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface ManagedInstance {
|
|
autocomplete: CoreAutocompleteApi<NameItem>;
|
|
panelEl: HTMLElement;
|
|
cleanup: () => void;
|
|
}
|
|
|
|
const instanceMap = new WeakMap<HTMLElement, ManagedInstance>();
|
|
|
|
function renderItems(
|
|
panelEl: HTMLElement,
|
|
items: NameItem[],
|
|
activeItemId: number | null,
|
|
onSelect: (item: NameItem) => void,
|
|
onActivate: (index: number) => void,
|
|
onDeactivate: () => void
|
|
): void {
|
|
panelEl.innerHTML = "";
|
|
if (items.length === 0) {
|
|
panelEl.style.display = "none";
|
|
return;
|
|
}
|
|
const list = document.createElement("ul");
|
|
list.className = "aa-core-list";
|
|
items.forEach((item, index) => {
|
|
const li = document.createElement("li");
|
|
li.className = "aa-core-item";
|
|
if (index === activeItemId) {
|
|
li.classList.add("aa-core-item--active");
|
|
}
|
|
li.textContent = item.name;
|
|
li.addEventListener("mousemove", () => {
|
|
if (activeItemId === index) {
|
|
return;
|
|
}
|
|
|
|
onActivate(index);
|
|
});
|
|
li.addEventListener("mouseleave", (event) => {
|
|
const relatedTarget = event.relatedTarget;
|
|
if (relatedTarget instanceof HTMLElement && li.contains(relatedTarget)) {
|
|
return;
|
|
}
|
|
|
|
onDeactivate();
|
|
});
|
|
li.addEventListener("mousedown", (e) => {
|
|
e.preventDefault(); // prevent input blur
|
|
e.stopPropagation();
|
|
onSelect(item);
|
|
});
|
|
list.appendChild(li);
|
|
});
|
|
panelEl.appendChild(list);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Attribute name autocomplete — new (autocomplete-core, headless)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function initAttributeNameAutocomplete({ $el, attributeType, open, onValueChange }: InitAttributeNameOptions) {
|
|
const inputEl = $el[0] as HTMLInputElement;
|
|
const syncQueryFromInputValue = (autocomplete: CoreAutocompleteApi<NameItem>) => {
|
|
autocomplete.setQuery(inputEl.value || "");
|
|
};
|
|
|
|
// Already initialized — just open if requested
|
|
if (instanceMap.has(inputEl)) {
|
|
if (open) {
|
|
const inst = instanceMap.get(inputEl)!;
|
|
syncQueryFromInputValue(inst.autocomplete);
|
|
inst.autocomplete.setIsOpen(true);
|
|
inst.autocomplete.refresh();
|
|
}
|
|
return;
|
|
}
|
|
|
|
const panelController = createHeadlessPanelController({ inputEl });
|
|
const { panelEl } = panelController;
|
|
|
|
let isPanelOpen = false;
|
|
let hasActiveItem = false;
|
|
|
|
const autocomplete = createAutocomplete<NameItem>({
|
|
openOnFocus: true,
|
|
defaultActiveItemId: 0,
|
|
shouldPanelOpen() {
|
|
return true;
|
|
},
|
|
|
|
getSources({ query }) {
|
|
return [
|
|
withHeadlessSourceDefaults({
|
|
sourceId: "attribute-names",
|
|
getItems() {
|
|
const type = typeof attributeType === "function" ? attributeType() : attributeType;
|
|
return server
|
|
.get<string[]>(`attribute-names/?type=${type}&query=${encodeURIComponent(query)}`)
|
|
.then((names) => names.map((name) => ({ name })));
|
|
},
|
|
getItemInputValue({ item }) {
|
|
return item.name;
|
|
},
|
|
onSelect({ item }) {
|
|
inputEl.value = item.name;
|
|
autocomplete.setQuery(item.name);
|
|
autocomplete.setIsOpen(false);
|
|
onValueChange?.(item.name);
|
|
},
|
|
}),
|
|
];
|
|
},
|
|
|
|
onStateChange({ state }) {
|
|
isPanelOpen = state.isOpen;
|
|
hasActiveItem = state.activeItemId !== null;
|
|
|
|
// Render items
|
|
const collections = state.collections;
|
|
const items = collections.length > 0 ? (collections[0].items as NameItem[]) : [];
|
|
const activeId = state.activeItemId ?? null;
|
|
|
|
if (state.isOpen && items.length > 0) {
|
|
renderItems(
|
|
panelEl,
|
|
items,
|
|
activeId,
|
|
(item) => {
|
|
inputEl.value = item.name;
|
|
autocomplete.setQuery(item.name);
|
|
autocomplete.setIsOpen(false);
|
|
onValueChange?.(item.name);
|
|
},
|
|
(index) => {
|
|
autocomplete.setActiveItemId(index);
|
|
},
|
|
() => {
|
|
autocomplete.setActiveItemId(null);
|
|
}
|
|
);
|
|
panelController.startPositioning();
|
|
} else {
|
|
panelController.hide();
|
|
}
|
|
|
|
if (!state.isOpen) {
|
|
panelController.hide();
|
|
}
|
|
},
|
|
});
|
|
|
|
const unregisterGlobalCloser = registerHeadlessAutocompleteCloser(() => {
|
|
autocomplete.setIsOpen(false);
|
|
panelController.hide();
|
|
});
|
|
|
|
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") {
|
|
if (isPanelOpen && hasActiveItem) {
|
|
// Prevent the enter key from propagating to parent dialogs
|
|
// (which might interpret it as "submit" or "save and close")
|
|
e.stopPropagation();
|
|
} else if (!isPanelOpen) {
|
|
// Panel is closed. Do not pass Enter to autocomplete-core
|
|
// so native HTML form submit handlers can run.
|
|
return;
|
|
}
|
|
}
|
|
handlers.onKeyDown(e as any);
|
|
}
|
|
});
|
|
|
|
const cleanup = () => {
|
|
unregisterGlobalCloser();
|
|
cleanupInputBindings();
|
|
panelController.destroy();
|
|
};
|
|
|
|
instanceMap.set(inputEl, { autocomplete, panelEl, cleanup });
|
|
|
|
if (open) {
|
|
syncQueryFromInputValue(autocomplete);
|
|
autocomplete.setIsOpen(true);
|
|
autocomplete.refresh();
|
|
panelController.startPositioning();
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Label value autocomplete (headless autocomplete-core)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface LabelValueInitOptions {
|
|
$el: JQuery<HTMLElement>;
|
|
open: boolean;
|
|
nameCallback?: () => string;
|
|
onValueChange?: (value: string) => void;
|
|
}
|
|
|
|
function initLabelValueAutocomplete({ $el, open, nameCallback, onValueChange }: LabelValueInitOptions) {
|
|
const inputEl = $el[0] as HTMLInputElement;
|
|
const syncQueryFromInputValue = (autocomplete: CoreAutocompleteApi<NameItem>) => {
|
|
autocomplete.setQuery(inputEl.value || "");
|
|
};
|
|
|
|
if (instanceMap.has(inputEl)) {
|
|
if (open) {
|
|
const inst = instanceMap.get(inputEl)!;
|
|
syncQueryFromInputValue(inst.autocomplete);
|
|
inst.autocomplete.setIsOpen(true);
|
|
inst.autocomplete.refresh();
|
|
}
|
|
return;
|
|
}
|
|
|
|
const panelController = createHeadlessPanelController({ inputEl });
|
|
const { panelEl } = panelController;
|
|
|
|
let isPanelOpen = false;
|
|
let hasActiveItem = false;
|
|
let isSelecting = false;
|
|
|
|
let cachedAttributeName = "";
|
|
let cachedAttributeValues: NameItem[] = [];
|
|
|
|
const handleSelect = (item: NameItem) => {
|
|
isSelecting = true;
|
|
inputEl.value = item.name;
|
|
inputEl.dispatchEvent(new Event("input", { bubbles: true }));
|
|
autocomplete.setQuery(item.name);
|
|
autocomplete.setIsOpen(false);
|
|
onValueChange?.(item.name);
|
|
isSelecting = false;
|
|
|
|
setTimeout(() => {
|
|
// Preserve the legacy contract: several consumers still commit the
|
|
// selected value from their existing Enter key handlers instead of
|
|
// listening to the autocomplete selection event directly.
|
|
inputEl.dispatchEvent(new KeyboardEvent("keydown", {
|
|
key: "Enter",
|
|
code: "Enter",
|
|
keyCode: 13,
|
|
which: 13,
|
|
bubbles: true,
|
|
cancelable: true
|
|
}));
|
|
}, 0);
|
|
};
|
|
|
|
const autocomplete = createAutocomplete<NameItem>({
|
|
openOnFocus: true,
|
|
defaultActiveItemId: null,
|
|
shouldPanelOpen() {
|
|
return true;
|
|
},
|
|
|
|
getSources({ query }) {
|
|
return [
|
|
withHeadlessSourceDefaults({
|
|
sourceId: "attribute-values",
|
|
async getItems() {
|
|
const attributeName = nameCallback ? nameCallback() : "";
|
|
if (!attributeName.trim()) {
|
|
return [];
|
|
}
|
|
|
|
if (attributeName !== cachedAttributeName || cachedAttributeValues.length === 0) {
|
|
cachedAttributeName = attributeName;
|
|
const values = await server.get<string[]>(`attribute-values/${encodeURIComponent(attributeName)}`);
|
|
cachedAttributeValues = values.map((name) => ({ name }));
|
|
}
|
|
|
|
const q = query.toLowerCase();
|
|
return cachedAttributeValues.filter((attr) => attr.name.toLowerCase().includes(q));
|
|
},
|
|
getItemInputValue({ item }) {
|
|
return item.name;
|
|
},
|
|
onSelect({ item }) {
|
|
handleSelect(item);
|
|
},
|
|
}),
|
|
];
|
|
},
|
|
|
|
onStateChange({ state }) {
|
|
isPanelOpen = state.isOpen;
|
|
hasActiveItem = state.activeItemId !== null;
|
|
|
|
const collections = state.collections;
|
|
const items = collections.length > 0 ? (collections[0].items as NameItem[]) : [];
|
|
const activeId = state.activeItemId ?? null;
|
|
|
|
if (state.isOpen && items.length > 0) {
|
|
renderItems(
|
|
panelEl,
|
|
items,
|
|
activeId,
|
|
handleSelect,
|
|
(index) => {
|
|
autocomplete.setActiveItemId(index);
|
|
},
|
|
() => {
|
|
autocomplete.setActiveItemId(null);
|
|
}
|
|
);
|
|
panelController.startPositioning();
|
|
} else {
|
|
panelController.hide();
|
|
}
|
|
|
|
if (!state.isOpen) {
|
|
panelController.hide();
|
|
}
|
|
},
|
|
});
|
|
|
|
const unregisterGlobalCloser = registerHeadlessAutocompleteCloser(() => {
|
|
autocomplete.setIsOpen(false);
|
|
panelController.hide();
|
|
});
|
|
|
|
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") {
|
|
if (isPanelOpen && hasActiveItem) {
|
|
e.stopPropagation();
|
|
} else if (!isPanelOpen) {
|
|
return; // Let native submit form bubbling happen
|
|
}
|
|
}
|
|
handlers.onKeyDown(e as any);
|
|
}
|
|
});
|
|
|
|
const cleanup = () => {
|
|
unregisterGlobalCloser();
|
|
cleanupInputBindings();
|
|
panelController.destroy();
|
|
};
|
|
|
|
instanceMap.set(inputEl, { autocomplete, panelEl, cleanup });
|
|
|
|
if (open) {
|
|
syncQueryFromInputValue(autocomplete);
|
|
autocomplete.setIsOpen(true);
|
|
autocomplete.refresh();
|
|
panelController.startPositioning();
|
|
}
|
|
}
|
|
|
|
export function destroyAutocomplete($el: JQuery<HTMLElement> | HTMLElement) {
|
|
const inputEl = $el instanceof HTMLElement ? $el : $el[0] as HTMLInputElement;
|
|
const instance = instanceMap.get(inputEl);
|
|
if (instance) {
|
|
instance.cleanup();
|
|
instanceMap.delete(inputEl);
|
|
}
|
|
}
|
|
|
|
export default {
|
|
initAttributeNameAutocomplete,
|
|
destroyAutocomplete,
|
|
initLabelValueAutocomplete,
|
|
};
|