mirror of
https://github.com/gtritchie/bulk-properties.git
synced 2026-07-22 06:08:37 +00:00
Add autocomplete suggestions for pill inputs (#26)
* Add autocomplete suggestions for tags/multitext pill inputs Show a completion dropdown with known vault values when entering tags, aliases, or multitext property values in the bulk edit modal. Uses AbstractInputSuggest attached to the pill input element. Suggestions are collected from frontmatter across all vault files, filtered to exclude already-added pills, and normalized consistently with tag input (leading # stripped for matching). * Fix focusout committing partial text when clicking a suggestion When the user clicks a suggestion in the dropdown, the pill container's focusout handler fires before selectSuggestion, which would commit the partially typed query as an unintended pill. Defer the focusout commit via requestAnimationFrame so selectSuggestion can set a flag and clear the input first. * Scope focusout suppression to mouse-triggered suggestions only Keyboard selections are captured by the suggest's Scope before focusout fires, so the didSelect flag is only needed for mouse clicks. Setting it unconditionally left the flag armed after keyboard selections, which could discard the next legitimate blur commit. * Pre-normalize tag values in suggestion list to strip # prefix Known values for tags are now normalized (leading # removed) before being passed to the suggest, so the dropdown never shows # prefixes. Deduplicates after normalization in case both #foo and foo exist. * Re-sort tag suggestions after normalization Stripping # prefixes can break the original alphabetical order from getPropertyValues(). Re-sort after normalize and dedup. * Fix suggestion selection not adding pill The selectSuggestion override replaced the base class implementation that invokes the onSelect callback, so addPill was never called. Use a direct callback property (onValueSelected) called from within selectSuggestion, matching the PropertyNameSuggest pattern.
This commit is contained in:
parent
a0d7b6d93b
commit
a7eacdace0
2 changed files with 124 additions and 8 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import {App, Modal, Notice, setIcon, Setting, TFile} from "obsidian";
|
||||
import {AbstractInputSuggest, App, Modal, Notice, setIcon, Setting, TFile} from "obsidian";
|
||||
import type BulkPropertiesPlugin from "./main";
|
||||
import {getSelectedFiles} from "./files";
|
||||
import {getPropertyValues, getSelectedFiles} from "./files";
|
||||
import {confirmEmptyValue} from "./confirm-modal";
|
||||
import {withProgress} from "./progress";
|
||||
import {makeToggleAccessible, updateToggleAriaChecked} from "./accessible-toggle";
|
||||
|
|
@ -50,6 +50,61 @@ function coerceValue(raw: string, type: string): unknown {
|
|||
}
|
||||
}
|
||||
|
||||
class PropertyValueSuggest extends AbstractInputSuggest<string> {
|
||||
private knownValues: string[];
|
||||
private currentPills: () => string[];
|
||||
private normalizeFn: (v: string) => string;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
inputEl: HTMLInputElement,
|
||||
knownValues: string[],
|
||||
currentPills: () => string[],
|
||||
normalize: (v: string) => string,
|
||||
) {
|
||||
super(app, inputEl);
|
||||
this.knownValues = knownValues;
|
||||
this.currentPills = currentPills;
|
||||
this.normalizeFn = normalize;
|
||||
}
|
||||
|
||||
override getSuggestions(query: string): string[] {
|
||||
const lower = this.normalizeFn(query).toLowerCase();
|
||||
const existing = new Set(this.currentPills().map(this.normalizeFn));
|
||||
return this.knownValues.filter(v =>
|
||||
this.normalizeFn(v).toLowerCase().includes(lower)
|
||||
&& !existing.has(this.normalizeFn(v)),
|
||||
);
|
||||
}
|
||||
|
||||
override renderSuggestion(value: string, el: HTMLElement): void {
|
||||
el.setText(value);
|
||||
}
|
||||
|
||||
onValueSelected?: (value: string, evt: MouseEvent | KeyboardEvent) => void;
|
||||
|
||||
override selectSuggestion(
|
||||
value: string,
|
||||
evt: MouseEvent | KeyboardEvent,
|
||||
): void {
|
||||
if (evt instanceof MouseEvent) {
|
||||
this.didSelect = true;
|
||||
}
|
||||
this.setValue("");
|
||||
this.close();
|
||||
this.onValueSelected?.(value, evt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set to true when a suggestion is selected via mouse click. The pill
|
||||
* container's focusout handler defers its commit and checks this flag
|
||||
* to avoid committing partial query text as a pill. Only mouse clicks
|
||||
* cause the focusout race; keyboard selections are captured by the
|
||||
* suggest's Scope before focusout fires.
|
||||
*/
|
||||
didSelect = false;
|
||||
}
|
||||
|
||||
export class BulkEditModal extends Modal {
|
||||
private plugin: BulkPropertiesPlugin;
|
||||
private fileSelection: Map<TFile, boolean>;
|
||||
|
|
@ -423,6 +478,29 @@ export class BulkEditModal extends Modal {
|
|||
pillInput.focus();
|
||||
};
|
||||
|
||||
let suggest: PropertyValueSuggest | null = null;
|
||||
const normalize = type === "tags"
|
||||
? (v: string) => v.replace(/^#/, "")
|
||||
: (v: string) => v;
|
||||
const knownValues = [...new Set(
|
||||
getPropertyValues(this.app, this.selectedProperty)
|
||||
.map(normalize)
|
||||
.filter(v => v !== ""),
|
||||
)].sort((a, b) => a.localeCompare(b));
|
||||
if (knownValues.length > 0) {
|
||||
suggest = new PropertyValueSuggest(
|
||||
this.app,
|
||||
pillInput,
|
||||
knownValues,
|
||||
() => pills,
|
||||
normalize,
|
||||
);
|
||||
suggest.onValueSelected = (value) => {
|
||||
addPill(value, true);
|
||||
this.updateCountText();
|
||||
};
|
||||
}
|
||||
|
||||
pillInput.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
if (e.isComposing) return;
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
|
|
@ -474,13 +552,24 @@ export class BulkEditModal extends Modal {
|
|||
) {
|
||||
return;
|
||||
}
|
||||
if (pillInput.value.trim() !== "") {
|
||||
const val = pillInput.value;
|
||||
pillInput.value = "";
|
||||
if (!addPill(val)) {
|
||||
pillInput.value = val;
|
||||
const commitPending = () => {
|
||||
if (suggest?.didSelect) {
|
||||
suggest.didSelect = false;
|
||||
return;
|
||||
}
|
||||
this.updateCountText();
|
||||
if (pillInput.value.trim() !== "") {
|
||||
const val = pillInput.value;
|
||||
pillInput.value = "";
|
||||
if (!addPill(val)) {
|
||||
pillInput.value = val;
|
||||
}
|
||||
this.updateCountText();
|
||||
}
|
||||
};
|
||||
if (suggest) {
|
||||
requestAnimationFrame(commitPending);
|
||||
} else {
|
||||
commitPending();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
27
src/files.ts
27
src/files.ts
|
|
@ -27,3 +27,30 @@ export function getFilesWithProperty(
|
|||
}
|
||||
return files.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns sorted unique string values for a given frontmatter property
|
||||
* across all markdown files in the vault.
|
||||
*/
|
||||
export function getPropertyValues(
|
||||
app: App,
|
||||
propertyName: string,
|
||||
): string[] {
|
||||
const values = new Set<string>();
|
||||
for (const file of app.vault.getMarkdownFiles()) {
|
||||
const cache = app.metadataCache.getFileCache(file);
|
||||
if (!cache?.frontmatter) continue;
|
||||
if (!Object.prototype.hasOwnProperty.call(cache.frontmatter, propertyName)) continue;
|
||||
const raw: unknown = cache.frontmatter[propertyName];
|
||||
if (Array.isArray(raw)) {
|
||||
for (const item of raw) {
|
||||
if (typeof item === "string" && item !== "") {
|
||||
values.add(item);
|
||||
}
|
||||
}
|
||||
} else if (typeof raw === "string" && raw !== "") {
|
||||
values.add(raw);
|
||||
}
|
||||
}
|
||||
return [...values].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue