From a7eacdace0dc6ae985c90ff5ca4f2a98bc046419 Mon Sep 17 00:00:00 2001 From: Gary Ritchie Date: Fri, 3 Apr 2026 08:52:51 -0600 Subject: [PATCH] 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. --- src/bulk-edit-modal.ts | 105 +++++++++++++++++++++++++++++++++++++---- src/files.ts | 27 +++++++++++ 2 files changed, 124 insertions(+), 8 deletions(-) diff --git a/src/bulk-edit-modal.ts b/src/bulk-edit-modal.ts index 58ebe4a..0a5e493 100644 --- a/src/bulk-edit-modal.ts +++ b/src/bulk-edit-modal.ts @@ -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 { + 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; @@ -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(); } }); diff --git a/src/files.ts b/src/files.ts index f6f0734..3beca02 100644 --- a/src/files.ts +++ b/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(); + 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)); +}