mirror of
https://github.com/zadam/trilium.git
synced 2026-03-22 08:13:46 +01:00
refactor: use headless autocomplete, migrate attribute deatil
This commit is contained in:
parent
1cdd04e193
commit
622f7ef263
@ -43,22 +43,23 @@
|
||||
|
||||
---
|
||||
|
||||
### Step 1: 迁移属性名称自动补全
|
||||
### Step 1: 迁移属性名称自动补全 ✅ 完成
|
||||
**文件变更:**
|
||||
- `apps/client/src/services/attribute_autocomplete.ts` — `initAttributeNameAutocomplete()` 改为直接调用 `autocomplete()`
|
||||
- `apps/client/src/widgets/attribute_widgets/attribute_detail.ts` — 属性名称输入框从 `<input>` 改为 `<div>` 容器,调整值读写
|
||||
- `apps/client/src/widgets/type_widgets/relation_map/RelationMap.tsx` — 关系名输入调整
|
||||
- `apps/client/src/services/attribute_autocomplete.ts` — `initAttributeNameAutocomplete()` 支持双 API:传 `container` 走新库,传 `$el` 走旧库
|
||||
- `apps/client/src/widgets/attribute_widgets/attribute_detail.ts` — 属性名称输入框从 `<input>` 改为 `<div>` 容器,值读写改用 `getInputValue()/setInputValue()`
|
||||
- `apps/client/src/stylesheets/style.css` — 添加新库 CSS(隐藏搜索图标、样式对齐)
|
||||
- `RelationMap.tsx` — **不变**,继续用旧 `$el` API
|
||||
|
||||
**说明:**
|
||||
属性名称补全是最简单的场景。变更模式:
|
||||
1. HTML 模板中的 `<input class="attr-input-name">` → `<div class="attr-input-name-container"></div>`
|
||||
2. `initAttributeNameAutocomplete()` 接收容器元素,调用 `autocomplete({ container, getSources })`
|
||||
3. 消费者通过返回的 `api` 对象(或回调)获取选中的值
|
||||
4. 为 `autocomplete-js` 生成的 `<input>` 添加 `form-control` 等样式类
|
||||
`initAttributeNameAutocomplete()` 同时支持新旧两种调用方式(overloaded interface),让消费者可以逐步迁移:
|
||||
- `attribute_detail.ts` 传 `{ container }` → 使用 `autocomplete-js`
|
||||
- `RelationMap.tsx` 传 `{ $el }` → 使用旧 `autocomplete.js`
|
||||
- RelationMap 的迁移推迟到后续步骤(因为它的 prompt dialog 由 React 管理 input)
|
||||
|
||||
**验证方式:**
|
||||
- 打开一个笔记 → 点击属性面板 → 点击属性名称输入框 → 应能看到自动补全的属性名称列表
|
||||
- 选择一个名称后,值应正确填入
|
||||
- ⬜ 打开一个笔记 → 点击属性面板 → 点击属性名称输入框 → 应能看到自动补全的属性名称列表
|
||||
- ⬜ 选择一个名称后,值应正确填入
|
||||
- ⬜ 关系图创建关系时,关系名输入框的自动补全仍正常(旧库路径)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -1,25 +1,8 @@
|
||||
import { autocomplete } from "@algolia/autocomplete-js";
|
||||
import type { AutocompleteApi } from "@algolia/autocomplete-js";
|
||||
import type { BaseItem } from "@algolia/autocomplete-core";
|
||||
import { createAutocomplete } from "@algolia/autocomplete-core";
|
||||
import type { AutocompleteApi as CoreAutocompleteApi, BaseItem } from "@algolia/autocomplete-core";
|
||||
import type { AttributeType } from "../entities/fattribute.js";
|
||||
import server from "./server.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global instance registry for "close all" functionality
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const activeInstances = new Set<AutocompleteApi<any>>();
|
||||
|
||||
export function closeAllAttributeAutocompletes(): void {
|
||||
for (const api of activeInstances) {
|
||||
api.setIsOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const instanceMap = new WeakMap<HTMLElement, AutocompleteApi<any>>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -28,15 +11,19 @@ interface NameItem extends BaseItem {
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** New API: pass a container div, autocomplete-js creates its own input inside. */
|
||||
/** New API: pass the input element + a container for the dropdown panel */
|
||||
interface NewInitOptions {
|
||||
container: HTMLElement;
|
||||
/** 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;
|
||||
/** Use the new autocomplete-core library instead of old autocomplete.js */
|
||||
useNewLib: true;
|
||||
}
|
||||
|
||||
/** Old API: pass a jQuery input element, uses legacy autocomplete.js plugin. */
|
||||
/** Old API: uses legacy autocomplete.js jQuery plugin */
|
||||
interface OldInitOptions {
|
||||
$el: JQuery<HTMLElement>;
|
||||
attributeType?: AttributeType | (() => AttributeType);
|
||||
@ -46,41 +33,103 @@ interface OldInitOptions {
|
||||
type InitAttributeNameOptions = NewInitOptions | OldInitOptions;
|
||||
|
||||
function isNewApi(opts: InitAttributeNameOptions): opts is NewInitOptions {
|
||||
return "container" in opts;
|
||||
return "useNewLib" in opts && opts.useNewLib === true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Attribute name autocomplete
|
||||
// Instance tracking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initAttributeNameAutocomplete(opts: InitAttributeNameOptions) {
|
||||
if (isNewApi(opts)) {
|
||||
initAttributeNameNew(opts);
|
||||
} else {
|
||||
initAttributeNameLegacy(opts);
|
||||
interface ManagedInstance {
|
||||
autocomplete: CoreAutocompleteApi<NameItem>;
|
||||
panelEl: HTMLElement;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
const instanceMap = new WeakMap<HTMLElement, ManagedInstance>();
|
||||
|
||||
function destroyInstance(el: HTMLElement): void {
|
||||
const inst = instanceMap.get(el);
|
||||
if (inst) {
|
||||
inst.cleanup();
|
||||
inst.panelEl.remove();
|
||||
instanceMap.delete(el);
|
||||
}
|
||||
}
|
||||
|
||||
/** New implementation using @algolia/autocomplete-js */
|
||||
function initAttributeNameNew({ container, attributeType, open, onValueChange }: NewInitOptions) {
|
||||
// Only init once per container
|
||||
if (instanceMap.has(container)) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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) {
|
||||
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("mousedown", (e) => {
|
||||
e.preventDefault(); // prevent input blur
|
||||
onSelect(item);
|
||||
});
|
||||
list.appendChild(li);
|
||||
});
|
||||
panelEl.appendChild(list);
|
||||
}
|
||||
|
||||
function positionPanel(panelEl: HTMLElement, inputEl: HTMLElement): void {
|
||||
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";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Attribute name autocomplete — new (autocomplete-core, headless)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initAttributeNameNew({ $el, attributeType, open, onValueChange }: NewInitOptions) {
|
||||
const inputEl = $el[0] as HTMLInputElement;
|
||||
|
||||
// Already initialized — just open if requested
|
||||
if (instanceMap.has(inputEl)) {
|
||||
if (open) {
|
||||
const api = instanceMap.get(container)!;
|
||||
api.setIsOpen(true);
|
||||
api.refresh();
|
||||
const inst = instanceMap.get(inputEl)!;
|
||||
inst.autocomplete.setIsOpen(true);
|
||||
inst.autocomplete.refresh();
|
||||
positionPanel(inst.panelEl, inputEl);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const api = autocomplete<NameItem>({
|
||||
container,
|
||||
panelContainer: document.body,
|
||||
const panelEl = createPanelEl();
|
||||
|
||||
let isPanelOpen = false;
|
||||
let hasActiveItem = false;
|
||||
|
||||
const autocomplete = createAutocomplete<NameItem>({
|
||||
openOnFocus: true,
|
||||
detachedMediaQuery: "none",
|
||||
placeholder: "",
|
||||
classNames: {
|
||||
input: "form-control",
|
||||
defaultActiveItemId: 0,
|
||||
shouldPanelOpen() {
|
||||
return true;
|
||||
},
|
||||
|
||||
getSources({ query }) {
|
||||
@ -97,38 +146,94 @@ function initAttributeNameNew({ container, attributeType, open, onValueChange }:
|
||||
return item.name;
|
||||
},
|
||||
onSelect({ item }) {
|
||||
inputEl.value = item.name;
|
||||
autocomplete.setQuery(item.name);
|
||||
autocomplete.setIsOpen(false);
|
||||
onValueChange?.(item.name);
|
||||
},
|
||||
templates: {
|
||||
item({ item, html }) {
|
||||
return html`<div>${item.name}</div>`;
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
onStateChange({ state, prevState }) {
|
||||
if (!state.isOpen && prevState.isOpen) {
|
||||
onValueChange?.(state.query);
|
||||
}
|
||||
},
|
||||
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;
|
||||
|
||||
shouldPanelOpen() {
|
||||
return true;
|
||||
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);
|
||||
});
|
||||
positionPanel(panelEl, inputEl);
|
||||
} else {
|
||||
panelEl.style.display = "none";
|
||||
}
|
||||
|
||||
if (!state.isOpen) {
|
||||
panelEl.style.display = "none";
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
instanceMap.set(container, api);
|
||||
activeInstances.add(api);
|
||||
// Wire up the input events
|
||||
const handlers = autocomplete.getInputProps({ inputElement: inputEl });
|
||||
const onInput = (e: Event) => {
|
||||
handlers.onChange(e as any);
|
||||
};
|
||||
const onFocus = (e: Event) => {
|
||||
handlers.onFocus(e as any);
|
||||
};
|
||||
const onBlur = () => {
|
||||
// Delay to allow mousedown on panel items
|
||||
setTimeout(() => {
|
||||
autocomplete.setIsOpen(false);
|
||||
panelEl.style.display = "none";
|
||||
onValueChange?.(inputEl.value);
|
||||
}, 200);
|
||||
};
|
||||
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.
|
||||
}
|
||||
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);
|
||||
};
|
||||
|
||||
instanceMap.set(inputEl, { autocomplete, panelEl, cleanup });
|
||||
|
||||
if (open) {
|
||||
api.setIsOpen(true);
|
||||
api.refresh();
|
||||
autocomplete.setIsOpen(true);
|
||||
autocomplete.refresh();
|
||||
positionPanel(panelEl, inputEl);
|
||||
}
|
||||
}
|
||||
|
||||
/** Legacy implementation using old autocomplete.js jQuery plugin */
|
||||
// ---------------------------------------------------------------------------
|
||||
// Attribute name autocomplete — legacy (old autocomplete.js)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initAttributeNameLegacy({ $el, attributeType, open }: OldInitOptions) {
|
||||
if (!$el.hasClass("aa-input")) {
|
||||
$el.autocomplete(
|
||||
@ -165,6 +270,18 @@ function initAttributeNameLegacy({ $el, attributeType, open }: OldInitOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initAttributeNameAutocomplete(opts: InitAttributeNameOptions) {
|
||||
if (isNewApi(opts)) {
|
||||
initAttributeNameNew(opts);
|
||||
} else {
|
||||
initAttributeNameLegacy(opts);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Label value autocomplete (still using old autocomplete.js)
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -227,35 +344,7 @@ async function initLabelValueAutocomplete({ $el, open, nameCallback }: LabelValu
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utilities for the new autocomplete-js containers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getInput(container: HTMLElement): HTMLInputElement | null {
|
||||
return container.querySelector<HTMLInputElement>(".aa-Input");
|
||||
}
|
||||
|
||||
function setInputValue(container: HTMLElement, value: string): void {
|
||||
const input = getInput(container);
|
||||
if (input) {
|
||||
input.value = value;
|
||||
}
|
||||
const api = instanceMap.get(container);
|
||||
if (api) {
|
||||
api.setQuery(value);
|
||||
}
|
||||
}
|
||||
|
||||
function getInputValue(container: HTMLElement): string {
|
||||
const input = getInput(container);
|
||||
return input?.value ?? "";
|
||||
}
|
||||
|
||||
export default {
|
||||
initAttributeNameAutocomplete,
|
||||
initLabelValueAutocomplete,
|
||||
closeAllAttributeAutocompletes,
|
||||
getInput,
|
||||
setInputValue,
|
||||
getInputValue,
|
||||
};
|
||||
|
||||
@ -960,6 +960,38 @@ table.promoted-attributes-in-tooltip th {
|
||||
background-color: var(--active-item-background-color);
|
||||
}
|
||||
|
||||
/* ===== @algolia/autocomplete-core (headless, custom panel) ===== */
|
||||
|
||||
.aa-core-panel {
|
||||
z-index: 10000;
|
||||
background-color: var(--main-background-color);
|
||||
border: 1px solid var(--main-border-color);
|
||||
border-top: none;
|
||||
max-height: 500px;
|
||||
overflow: auto;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.aa-core-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.aa-core-item {
|
||||
cursor: pointer;
|
||||
padding: 6px 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.aa-core-item:hover,
|
||||
.aa-core-item--active {
|
||||
color: var(--active-item-text-color);
|
||||
background-color: var(--active-item-background-color);
|
||||
}
|
||||
|
||||
.help-button {
|
||||
float: inline-end;
|
||||
background: none;
|
||||
|
||||
@ -378,7 +378,9 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
|
||||
attributeAutocompleteService.initAttributeNameAutocomplete({
|
||||
$el: this.$inputName,
|
||||
attributeType: () => (["relation", "relation-definition"].includes(this.attrType || "") ? "relation" : "label"),
|
||||
open: true
|
||||
open: true,
|
||||
useNewLib: true,
|
||||
onValueChange: () => this.userEditedAttribute(),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user