// src/plugin/editor-suggest.ts /** * EditorSuggest framework. * * Implements Obsidian's EditorSuggest public API for editor-anchored * autocomplete popovers (wikilinks, commands, mentions, etc). * * Inheritance chain matches Obsidian: * EditorSuggest extends PopoverSuggest * * Where PopoverSuggest implements ISuggestOwner, CloseableComponent * * `PopoverSuggest` is declared here (not in a separate file) so plugins * subclassing PopoverSuggest typecheck against our compat layer. Reams does * NOT yet implement non-editor popovers, so PopoverSuggest.open()/.close() * throw clear "not implemented" errors. */ import type { Editor } from './editor' import type { TFile } from './files' import type { EditorPosition } from './types' import type { Instruction, CloseableComponent } from './obsidian-stubs' import { Scope } from './obsidian-stubs' /** Result of EditorSuggest.onTrigger() — describes the trigger range. */ export interface EditorSuggestTriggerInfo { /** Position where the trigger token starts (inclusive). */ start: EditorPosition /** Position of the cursor at trigger time (exclusive). */ end: EditorPosition /** The user's query string between trigger and cursor (excluding trigger chars). */ query: string } /** * Context passed to getSuggestions / selectSuggestion. * @public * @since 0.12.17 */ export interface EditorSuggestContext extends EditorSuggestTriggerInfo { /** @public */ editor: Editor /** @public */ file: TFile } /** * Reams-internal extended context with AbortSignal support. * * This mirrors EditorSuggestContext but adds a `signal` property for * cancellation and allows `file: TFile | null` for unsaved docs. * * The dispatcher passes this to built-in suggesters and Reams-internal code. * External plugins receive the base type (they can still access `signal` at * runtime if present, but it's not part of the upstream contract). * * Contract: * - Suggesters performing fetch() or other cancellable async I/O SHOULD * pass `signal` through (e.g. `fetch(url, { signal: ctx.signal })`). * - Suggesters performing CPU-bound work in a tight loop SHOULD * periodically check `signal.aborted` and return [] early if true. * - Synchronous suggesters can safely ignore `signal`; the dispatcher * discards their results if the signal aborted before they ran. * * @internal */ export interface ReamsEditorSuggestContext extends EditorSuggestTriggerInfo { /** Editor instance. */ editor: Editor /** * Source file, may be null for unsaved/untitled docs. * Upstream Obsidian requires TFile; Reams allows null. */ file: TFile | null /** * AbortSignal that fires when this trigger is superseded (newer keystroke, * dropdown closed, or editor unmounted). */ signal: AbortSignal } /** * Input-side interface (parity with Obsidian's `ISuggestOwner`). The "owner" * is the suggester instance — render and select are owner methods called by * the popover/editor host. */ export interface ISuggestOwner { renderSuggestion(value: T, el: HTMLElement): void selectSuggestion(value: T, evt: MouseEvent | KeyboardEvent): void } /** * Abstract base for any popover-style suggest UI (parity with Obsidian's * `PopoverSuggest`). Obsidian uses this for non-editor popovers * (e.g. command palette inputs). * * Reams declares this as an abstract intermediate so the inheritance chain * `EditorSuggest extends PopoverSuggest` matches Obsidian. Plugins typed * `class X extends PopoverSuggest` typecheck against our compat layer. * * Deliberate divergence: we do NOT implement `open()`/`close()` portal * scaffolding. Subclasses that try to use this base directly throw at * runtime — failure mode is loud, not silent. * * @public */ export abstract class PopoverSuggest implements ISuggestOwner, CloseableComponent { /** @public */ app: import('./app').App /** @public */ scope: Scope /** @public */ constructor(app: import('./app').App, scope?: Scope) { this.app = app this.scope = scope ?? new Scope() } abstract renderSuggestion(value: T, el: HTMLElement): void abstract selectSuggestion(value: T, evt: MouseEvent | KeyboardEvent): void /** Stub: not implemented; subclass `EditorSuggest` instead for editor popovers. */ open(): void { throw new Error('PopoverSuggest.open() not implemented in Reams; subclass EditorSuggest instead') } /** Stub: not implemented; subclass `EditorSuggest` instead for editor popovers. */ close(): void { throw new Error('PopoverSuggest.close() not implemented in Reams; subclass EditorSuggest instead') } } /** * Base class for editor-anchored suggesters. Subclasses implement four methods: * - `onTrigger(cursor, editor, file)` — return triggerInfo or null on every keystroke. * The trigger MUST be single-line: `start.line` and `end.line` MUST equal * `cursor.line`, and `end` MUST equal `cursor`. Multi-line triggers are silently * rejected by the dispatcher's `pickWinningSuggesters`. * - `getSuggestions(context)` — return the list (may be async) * - `renderSuggestion(item, el)` — DOM rendering of one item * - `selectSuggestion(item, evt)` — handle item selection (mutate editor, etc.). * At call time, `this.context` holds the EditorSuggestContext that produced * the selection (matching Obsidian's documented contract). Use * `this.context.start`/`.end`/`.editor` to know the trigger range and the * editor to mutate; do NOT re-derive the range from the live cursor — * that would race against keystrokes between getSuggestions and select. */ export abstract class EditorSuggest extends PopoverSuggest { /** * The active EditorSuggestContext, retained while suggestions are open and * cleared on dismissal. Mirrors * Obsidian's documented contract: subclasses read `this.context.start`, * `.end`, `.editor`, etc. inside `selectSuggestion` to mutate the editor. * * Stateful suggesters may reuse its start position on subsequent keystrokes. */ context: EditorSuggestContext | null = null /** * Override this to use a different limit for suggestion items. * @since 0.12.17 (Obsidian API) * * Note: The Reams dispatcher does not yet enforce this limit. Subclasses * wanting to limit results should do so in getSuggestions() for now. */ limit: number = 100 constructor(app: import('./app').App) { super(app) } /** * Set instructions to display at the bottom of the suggestion popover. * Instructions are hints to the user (e.g. "↵ to insert", "esc to dismiss"). * @since 0.13.0 (Obsidian API) * * Note: This is a stub for API compatibility. Reams does not yet render * instructions in the suggestion dropdown UI; calls are silently ignored. */ setInstructions(_instructions: Instruction[]): void { // Stub for API compatibility. Rendering not yet implemented. } /** Called on every cursor/doc change. Return null for "no match". */ abstract onTrigger( cursor: EditorPosition, editor: Editor, file: TFile | null, ): EditorSuggestTriggerInfo | null /** Return suggestions for the trigger context. Sync or async. */ abstract getSuggestions(context: EditorSuggestContext): T[] | Promise // renderSuggestion / selectSuggestion are inherited as abstract from PopoverSuggest. } /** * Attach to an `` element or a `
` to add type-ahead * support. * * Subclasses implement `getSuggestions(query)` and `renderSuggestion(item, el)`. * The `selectSuggestion` method is provided with a default that invokes the * `onSelect` callback; subclasses may override. * * This is a minimal implementation for Obsidian API compatibility. Reams does * NOT yet render a suggestion dropdown UI for AbstractInputSuggest, but its * lifecycle methods are safe no-ops so plugins can mount and unmount inputs. * Plugins wanting full autocomplete should use EditorSuggest instead. * * @public * @since 1.4.10 */ export abstract class AbstractInputSuggest extends PopoverSuggest { /** * Limit to the number of elements rendered at once. Set to 0 to disable. Defaults to 100. * @public * @since 1.4.10 */ limit: number = 100 /** * The attached input or contenteditable element. * @internal */ #textInputEl: HTMLInputElement | HTMLDivElement /** * Callback registered via onSelect(). * @internal */ // eslint-disable-next-line @typescript-eslint/no-explicit-any #onSelectCallback: ((value: T, evt: MouseEvent | KeyboardEvent) => any) | null = null /** * Accepts an `` text box or a contenteditable div. * @public */ constructor(app: import('./app').App, textInputEl: HTMLInputElement | HTMLDivElement) { super(app) this.#textInputEl = textInputEl } /** No-op until Reams implements input-anchored suggestion popovers. */ override close(): void {} /** * Sets the value into the input element. * @public * @since 1.4.10 */ setValue(value: string): void { if (this.#textInputEl instanceof HTMLInputElement) { this.#textInputEl.value = value } else { // contenteditable div this.#textInputEl.textContent = value } } /** * Gets the value from the input element. * @public * @since 1.4.10 */ getValue(): string { if (this.#textInputEl instanceof HTMLInputElement) { return this.#textInputEl.value } else { // contenteditable div return this.#textInputEl.textContent ?? '' } } /** * Return suggestions for the current query. May be async. * @public * @since 1.5.7 */ protected abstract getSuggestions(query: string): T[] | Promise /** * Handle suggestion selection. Default implementation invokes the onSelect * callback if registered. Subclasses may override for custom behavior. * @public * @since 1.6.6 */ selectSuggestion(value: T, evt: MouseEvent | KeyboardEvent): void { if (this.#onSelectCallback) { this.#onSelectCallback(value, evt) } } /** * Registers a callback to handle when a suggestion is selected by the user. * @public * @since 1.4.10 */ // eslint-disable-next-line @typescript-eslint/no-explicit-any onSelect(callback: (value: T, evt: MouseEvent | KeyboardEvent) => any): this { this.#onSelectCallback = callback return this } }