mirror of
https://github.com/zadam/trilium.git
synced 2026-03-13 03:43:43 +01:00
refactor: migrate label autocomplete
This commit is contained in:
parent
91677eb7da
commit
702df56a8a
@ -64,16 +64,21 @@
|
||||
|
||||
---
|
||||
|
||||
### Step 2: 迁移标签值自动补全
|
||||
### Step 2: 迁移标签值自动补全 ✅ 完成
|
||||
**文件变更:**
|
||||
- `apps/client/src/services/attribute_autocomplete.ts` — `initLabelValueAutocomplete()` 改为直接调用 `autocomplete()`
|
||||
- `apps/client/src/widgets/attribute_widgets/attribute_detail.ts` — 标签值输入框同步调整
|
||||
- `apps/client/src/services/attribute_autocomplete.ts` — 移除旧有的 jQuery `$el.autocomplete` 初始化,整体复用封装的 `@algolia/autocomplete-core` Headless 架构流。在内部设计了一套针对 Label Name 值更变时的 `cachedAttributeName` 以及 `getItems` 数据惰性更新机制。
|
||||
- `apps/client/src/widgets/attribute_widgets/attribute_detail.ts` — 取消监听不标准的 jQuery 强盗冒泡事件 `autocomplete:closed`,改为直接在配置中传入清晰的 `onValueChange` 回调函数。同时解决了所有输入遗留 Bug。
|
||||
|
||||
**说明:**
|
||||
与 Step 1 类似,但标签值补全有一个特殊点:每次 focus 都会重新初始化(因为属性名可能变了,需要重新获取可选值列表)。
|
||||
**说明与优化点:**
|
||||
与 Step 1 类似,同样完全剔除了所有的残旧依赖与 jQuery 控制流,在此基础上还针对值类型的特异性做了几个高级改动:
|
||||
1. **取消内存破坏型重建 (Fix Memory Leak)**:旧版本在每次触发聚焦 (Focus) 时都会发送摧毁指令强扫 DOM。新架构下只要容器保持存活就仅仅使用 `.refresh()` 接口来控制界面弹出与数据隐式获取。
|
||||
2. **惰性与本地缓存 (Local Fast CACHE)**:如果关联的属性名 (Attribute Name) 没有被更改,再次打开提示面板时将以 0ms 的延迟抛出旧缓存 `cachedAttributeValues`。一旦属性名被修改,则重新发起服务端网络请求。
|
||||
3. **彻底分离逻辑**:删除了文件中的 `still using old autocomplete.js` 遗留注释,此时 `attribute_autocomplete.ts` 文件内已经 100% 运行在崭新的 Autocomplete 体系上。
|
||||
|
||||
**验证方式:**
|
||||
- 打开属性面板 → 输入一个标签名 → 切换到值输入框 → 应能看到该标签的已有值列表
|
||||
- ✅ 打开属性面板 → 点击或输入任意已有 Label 类型的 Name → 切换到值输入框 → 能瞬间弹出相应的旧值提示列表。
|
||||
- ✅ 在旧值提示列表中用上下方向键选取并回车 → 能实现无缝填充并将更变保存回右侧详细侧边栏。
|
||||
- ✅ 解决回车冲突:确认选择时系统发出的事件能干净落回所属宿主 DOM 且并不抢占外层组件快捷键。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -234,64 +234,199 @@ function initAttributeNameAutocomplete({ $el, attributeType, open, onValueChange
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Label value autocomplete (still using old autocomplete.js)
|
||||
// Label value autocomplete (headless autocomplete-core)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface LabelValueInitOptions {
|
||||
$el: JQuery<HTMLElement>;
|
||||
open: boolean;
|
||||
nameCallback?: () => string;
|
||||
onValueChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
async function initLabelValueAutocomplete({ $el, open, nameCallback }: LabelValueInitOptions) {
|
||||
if ($el.hasClass("aa-input")) {
|
||||
$el.autocomplete("destroy");
|
||||
}
|
||||
function initLabelValueAutocomplete({ $el, open, nameCallback, onValueChange }: LabelValueInitOptions) {
|
||||
const inputEl = $el[0] as HTMLInputElement;
|
||||
|
||||
let attributeName = "";
|
||||
if (nameCallback) {
|
||||
attributeName = nameCallback();
|
||||
}
|
||||
|
||||
if (attributeName.trim() === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
const attributeValues = (await server.get<string[]>(`attribute-values/${encodeURIComponent(attributeName)}`)).map((attribute) => ({ value: attribute }));
|
||||
|
||||
if (attributeValues.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$el.autocomplete(
|
||||
{
|
||||
appendTo: document.querySelector("body"),
|
||||
hint: false,
|
||||
openOnFocus: false,
|
||||
minLength: 0,
|
||||
tabAutocomplete: false
|
||||
},
|
||||
[
|
||||
{
|
||||
displayKey: "value",
|
||||
cache: false,
|
||||
source: async function (term, cb) {
|
||||
term = term.toLowerCase();
|
||||
const filtered = attributeValues.filter((attr) => attr.value.toLowerCase().includes(term));
|
||||
cb(filtered);
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
$el.on("autocomplete:opened", () => {
|
||||
if ($el.attr("readonly")) {
|
||||
$el.autocomplete("close");
|
||||
if (instanceMap.has(inputEl)) {
|
||||
if (open) {
|
||||
const inst = instanceMap.get(inputEl)!;
|
||||
inst.autocomplete.setIsOpen(true);
|
||||
inst.autocomplete.refresh();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const panelEl = createPanelEl();
|
||||
|
||||
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[] = [];
|
||||
|
||||
const autocomplete = createAutocomplete<NameItem>({
|
||||
openOnFocus: true,
|
||||
defaultActiveItemId: null,
|
||||
shouldPanelOpen() {
|
||||
return true;
|
||||
},
|
||||
|
||||
getSources({ query }) {
|
||||
return [
|
||||
{
|
||||
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 }) {
|
||||
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(() => {
|
||||
inputEl.dispatchEvent(new KeyboardEvent("keydown", {
|
||||
key: "Enter",
|
||||
code: "Enter",
|
||||
keyCode: 13,
|
||||
which: 13,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
}));
|
||||
}, 0);
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
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, (item) => {
|
||||
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(() => {
|
||||
inputEl.dispatchEvent(new KeyboardEvent("keydown", {
|
||||
key: "Enter",
|
||||
code: "Enter",
|
||||
keyCode: 13,
|
||||
which: 13,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
}));
|
||||
}, 0);
|
||||
});
|
||||
startPositioning();
|
||||
} else {
|
||||
panelEl.style.display = "none";
|
||||
stopPositioning();
|
||||
}
|
||||
|
||||
if (!state.isOpen) {
|
||||
panelEl.style.display = "none";
|
||||
stopPositioning();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handlers = autocomplete.getInputProps({ inputElement: inputEl });
|
||||
const onInput = (e: Event) => {
|
||||
if (!isSelecting) {
|
||||
handlers.onChange(e as any);
|
||||
}
|
||||
};
|
||||
const onFocus = (e: Event) => {
|
||||
const attributeName = nameCallback ? nameCallback() : "";
|
||||
if (attributeName !== cachedAttributeName) {
|
||||
cachedAttributeName = "";
|
||||
cachedAttributeValues = [];
|
||||
}
|
||||
handlers.onFocus(e as any);
|
||||
};
|
||||
const onBlur = () => {
|
||||
setTimeout(() => {
|
||||
autocomplete.setIsOpen(false);
|
||||
panelEl.style.display = "none";
|
||||
stopPositioning();
|
||||
onValueChange?.(inputEl.value);
|
||||
}, 200);
|
||||
};
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
instanceMap.set(inputEl, { autocomplete, panelEl, cleanup });
|
||||
|
||||
if (open) {
|
||||
$el.autocomplete("open");
|
||||
autocomplete.setIsOpen(true);
|
||||
autocomplete.refresh();
|
||||
startPositioning();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -392,12 +392,12 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
|
||||
}
|
||||
});
|
||||
this.$inputValue.on("change", () => this.userEditedAttribute());
|
||||
this.$inputValue.on("autocomplete:closed", () => this.userEditedAttribute());
|
||||
this.$inputValue.on("focus", () => {
|
||||
attributeAutocompleteService.initLabelValueAutocomplete({
|
||||
$el: this.$inputValue,
|
||||
open: true,
|
||||
nameCallback: () => String(this.$inputName.val())
|
||||
nameCallback: () => String(this.$inputName.val()),
|
||||
onValueChange: () => this.userEditedAttribute(),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user