feat: Quick Preview external dependency removed

- Added new modules for Quick Preview:
  - `hoverParent.ts`, `patcher.ts`, `popoverManager.ts`, `types.ts`, and `utils.ts`.
- Updated `declarations.d.ts` with reusable `Suggestions` interface for type safety.
- Removed `obsidian-quick-preview` dependency from `main.ts`.
- Integrated internal Quick Preview patcher for `LinkAutocomplete` and `MathSearchModal`.
- Improved item normalization for precise linking via block IDs.
- Enhanced modularity and user experience with better hover popover management.
This commit is contained in:
JK 2025-11-08 17:16:36 +01:00
parent 230193aa1d
commit 06ffb81f65
7 changed files with 289 additions and 29 deletions

37
src/declarations.d.ts vendored
View file

@ -1,7 +1,17 @@
import { PaneType, SplitDirection } from "obsidian";
import { PaneType, SplitDirection, UserEvent } from "obsidian";
import { EditorView } from "@codemirror/view";
declare module "obsidian" {
// 1. DEFINE the complete Suggestions interface here
interface Suggestions<T> {
selectedItem: number;
values: T[];
containerEl: HTMLElement;
moveUp(event: KeyboardEvent): void;
moveDown(event: KeyboardEvent): void;
setSelectedItem(index: number, event: UserEvent | null): void;
}
interface App {
plugins: {
enabledPlugins: Set<string>;
@ -10,33 +20,24 @@ declare module "obsidian" {
};
getPlugin: (id: string) => Plugin | null;
};
internalPlugins: {
getPluginById(id: string): Plugin & { instance: any };
};
}
interface Editor {
cm?: EditorView;
}
// Reference: https://github.com/tadashi-aikawa/obsidian-various-complements-plugin/blob/be4a12c3f861c31f2be3c0f81809cfc5ab6bb5fd/src/ui/AutoCompleteSuggest.ts#L595-L619
interface EditorSuggest<T> {
scope: Scope;
suggestions: {
selectedItem: number;
values: T[];
containerEl: HTMLElement;
};
// 2. USE the complete interface
suggestions: Suggestions<T>;
suggestEl: HTMLElement;
}
// Reference: https://github.com/tadashi-aikawa/obsidian-another-quick-switcher/blob/6aa40a46fe817d25c11847a46ec6c765c742d629/src/ui/UnsafeModalInterface.ts#L5
interface SuggestModal<T> {
chooser: {
values: T[] | null;
selectedItem: number;
setSelectedItem(
item: number,
event?: KeyboardEvent,
): void;
useSelectedItem(ev: Partial<KeyboardEvent>): void;
suggestions: Element[];
};
// 3. USE the complete interface here as well
chooser: Suggestions<T>;
}
}

View file

@ -0,0 +1,44 @@
import { HoverParent, HoverPopover } from "obsidian";
import { PopoverManager } from "./popoverManager";
import { PatchedSuggester } from "./types";
export class QuickPreviewHoverParent<T> implements HoverParent {
#hoverPopover: HoverPopover | null = null;
hidden: boolean;
manager: PopoverManager<T>;
constructor(private suggest: PatchedSuggester<T>) {
this.hidden = false;
this.manager = this.suggest.popoverManager;
}
hide() {
this.hoverPopover?.hide();
this.hidden = true;
if (this.manager.currentOpenHoverParent === this) {
this.manager.currentOpenHoverParent = null;
}
}
get hoverPopover() {
return this.#hoverPopover;
}
set hoverPopover(hoverPopover: HoverPopover | null) {
this.#hoverPopover = hoverPopover;
if (this.#hoverPopover) {
this.manager.addChild(this.#hoverPopover);
this.manager.currentOpenHoverParent?.hide();
this.manager.currentOpenHoverParent = this;
if (this.hidden) {
this.hide();
return;
}
this.#hoverPopover.hoverEl.addClass('quick-preview');
// is requestAnimationFrame necessary here?
this.#hoverPopover!.position(this.#hoverPopover!.shownPos = this.manager.getShownPos());
}
}
}

View file

@ -0,0 +1,32 @@
import { around } from 'monkey-around';
import LatexReferencer from 'main';
import { PopoverManager } from './popoverManager';
import { PatchedSuggester, PreviewInfo, Suggester } from './types';
export function patchSuggesterWithQuickPreview<T>(
plugin: LatexReferencer,
suggesterClass: new (...args: unknown[]) => Suggester<T>,
itemNormalizer: (item: T) => PreviewInfo | null
) {
const uninstaller = around(suggesterClass.prototype, {
open(old) {
return function (this: PatchedSuggester<T>) {
old.call(this);
if (!this.popoverManager) {
this.popoverManager = new PopoverManager<T>(plugin, this, itemNormalizer);
}
this.popoverManager.load();
}
},
close(old) {
return function (this: PatchedSuggester<T>) {
old.call(this);
// close() can be called before open() at startup, so we need the optional chaining (?.)
this.popoverManager?.unload();
}
}
});
plugin.register(uninstaller);
return uninstaller;
}

View file

@ -0,0 +1,139 @@
import { Component, Keymap, KeymapEventHandler, PopoverSuggest, SuggestModal, UserEvent, Suggestions } from "obsidian";
import LatexReferencer from "main";
import { QuickPreviewHoverParent } from "./hoverParent";
import { getSelectedItem } from "./utils";
import { PatchedSuggester, PreviewInfo } from "./types";
export class PopoverManager<T> extends Component {
suggestions: Suggestions<T>;
currentHoverParent: QuickPreviewHoverParent<T> | null = null;
currentOpenHoverParent: QuickPreviewHoverParent<T> | null = null;
lastEvent: MouseEvent | PointerEvent | null = null;
handlers: KeymapEventHandler[] = [];
popoverHeight: number | null = null;
popoverWidth: number | null = null;
constructor(private plugin: LatexReferencer, public suggest: PatchedSuggester<T>, private itemNormalizer: (item: T) => PreviewInfo | null) {
super();
if (suggest instanceof PopoverSuggest) {
this.suggestions = suggest.suggestions;
} else {
// The 'as any' cast is no longer needed because the types now match perfectly
this.suggestions = (suggest as SuggestModal<T>).chooser;
}
}
get doc() {
return this.suggestions.containerEl.doc;
}
get win() {
return this.doc.win;
}
onload() {
const modifier = this.plugin.settings.modifierToJump;
this.registerDomEvent(this.win, 'keydown', (event) => {
if (this.suggest.isOpen && Keymap.isModifier(event, modifier)) {
if (this.currentOpenHoverParent) this.hide();
else {
const item = getSelectedItem(this.suggestions);
if (item) this.spawnPreview(item);
}
}
});
this.registerDomEvent(this.win, 'keyup', (event: KeyboardEvent) => {
// We check for the specific key, not the modifier state, to avoid conflicts.
if (event.key === modifier) this.hide();
});
this.registerDomEvent(this.win, 'mousemove', (event: MouseEvent) => {
if (!Keymap.isModifier(event, modifier)) this.hide();
});
if (this.suggest instanceof PopoverSuggest) {
this.handlers.push(
this.suggest.scope.register([modifier], 'ArrowUp', (event) => {
this.suggestions.moveUp(event);
return false;
}),
this.suggest.scope.register([modifier], 'ArrowDown', (event) => {
this.suggestions.moveDown(event);
return false;
})
);
}
}
onunload() {
this.handlers.forEach((handler) => {
this.suggest.scope.unregister(handler);
});
this.handlers.length = 0;
this.currentHoverParent?.hide();
this.currentHoverParent = null;
this.currentOpenHoverParent?.hide();
this.currentOpenHoverParent = null;
this.lastEvent = null;
}
hide() {
this.currentHoverParent?.hide();
this.currentHoverParent = null;
}
spawnPreview(item: T) {
this.hide();
this.currentHoverParent = new QuickPreviewHoverParent(this.suggest);
const info = this.itemNormalizer(item);
if (info) {
// @ts-ignore - Using internal API
const self = this.plugin.app.internalPlugins.getPluginById('page-preview').instance;
// @ts-ignore - Using internal API
self.onLinkHover(this.currentHoverParent, this.doc.body, info.linktext, info.sourcePath, { scroll: info.line });
}
}
getShownPos(): { x: number, y: number } {
return this.getShownPosAuto();
}
getShownPosCorner(position: 'Top left' | 'Top right' | 'Bottom left' | 'Bottom right') {
if (position === 'Top left') return { x: 0, y: 0 };
if (position === 'Top right') return { x: this.win.innerWidth, y: 0 };
if (position === 'Bottom left') return { x: 0, y: this.win.innerHeight };
return { x: this.win.innerWidth, y: this.win.innerHeight };
}
getShownPosAuto(): { x: number, y: number } {
const el = this.suggestions.containerEl;
const { top, bottom, left, right, width } = el.getBoundingClientRect();
const popover = this.currentHoverParent?.hoverPopover;
this.popoverWidth = popover?.hoverEl.offsetWidth ?? this.popoverWidth ?? null;
if (this.popoverWidth) {
const offsetX = width * 0.1;
if (right - offsetX + this.popoverWidth < this.win.innerWidth) {
return { x: right - offsetX, y: top + 20 };
}
if (left > this.popoverWidth + offsetX) {
return { x: left - this.popoverWidth - offsetX, y: top + 20 };
}
}
const x = (left + right) * 0.5;
const y = (top + bottom) * 0.5;
if (x >= this.win.innerWidth * 0.6) {
return y >= this.win.innerHeight * 0.5 ? this.getShownPosCorner('Top left') : this.getShownPosCorner('Bottom left');
}
return y >= this.win.innerHeight * 0.5 ? this.getShownPosCorner('Top right') : this.getShownPosCorner('Bottom right');
}
}

View file

@ -0,0 +1,32 @@
import { PopoverSuggest, SuggestModal } from "obsidian";
import { PopoverManager } from "./popoverManager";
export type Suggester<T> = PopoverSuggest<T> | SuggestModal<T>;
export type PatchedSuggester<T> = Suggester<T> & { popoverManager: PopoverManager<T> };
export interface PreviewInfo {
linktext: string;
sourcePath: string;
line?: number;
}
// This module now augments the global obsidian types
declare module "obsidian" {
interface PopoverSuggest<T> {
suggestions: Suggestions<T>;
suggestEl: HTMLElement;
isOpen: boolean;
}
interface SuggestModal<T> {
isOpen: boolean;
}
interface HoverPopover {
parent: HoverParent;
targetEl: HTMLElement | null;
shownPos: { x: number, y: number } | null;
hide(): void;
position(pos: { x: number, y: number } | null): void;
}
}

View file

@ -0,0 +1,5 @@
import { Suggestions } from "obsidian";
export function getSelectedItem<T>(suggestions: Suggestions<T>): T | undefined {
return suggestions.values[suggestions.selectedItem];
}

View file

@ -1,7 +1,8 @@
import { MarkdownView, Plugin } from 'obsidian';
import type { Extension } from '@codemirror/state';
import { registerQuickPreview } from 'obsidian-quick-preview';
// REMOVED: No longer importing from the external plugin
// import { registerQuickPreview } from 'obsidian-quick-preview';
import { PluginSettings, DEFAULT_SETTINGS } from './features/settings/settings';
import { MathSettingTab } from "./features/settings/tab";
@ -18,6 +19,9 @@ import { LinkAutocomplete } from 'features/search/editor-suggest';
import { MathSearchModal } from 'features/search/modal';
import { EquationBlock } from 'types';
// ADDED: Import our new internal patcher function
import { patchSuggesterWithQuickPreview } from 'features/quick-preview/patcher';
export default class LatexReferencer extends Plugin {
settings: PluginSettings;
editorExtensions: Extension[];
@ -28,8 +32,6 @@ export default class LatexReferencer extends Plugin {
this.internalProviders.push(new LatexLinkProvider(this));
this.addSettingTab(new MathSettingTab(this.app, this));
// Commands
this.addCommand({
id: 'insert-display-math',
@ -52,18 +54,23 @@ export default class LatexReferencer extends Plugin {
// Link autocompletion
this.registerEditorSuggest(new LinkAutocomplete(this));
// REPLACED: The old external plugin logic is gone.
// We now call our internal patcher directly.
const itemNormalizer = (item: EquationBlock) => ({
linktext: item.$file,
sourcePath: '',
linktext: item.$file + '#^' + item.$blockId, // Use the block ID for more precise linking
sourcePath: item.$file,
line: item.$position.start,
});
registerQuickPreview(this.app, this, LinkAutocomplete, itemNormalizer);
registerQuickPreview(this.app, this, MathSearchModal, itemNormalizer);
// Markdown post processors for Reading View
this.registerMarkdownPostProcessor(createEquationNumberProcessor(this));
this.registerMarkdownPostProcessor(CustomMathLinksProcessor(this));
this.app.workspace.onLayoutReady(() => this.forceRerender());
patchSuggesterWithQuickPreview(this, LinkAutocomplete, itemNormalizer);
patchSuggesterWithQuickPreview(this, MathSearchModal, itemNormalizer);
// Markdown post processors for Reading View
this.registerMarkdownPostProcessor(createEquationNumberProcessor(this));
this.registerMarkdownPostProcessor(CustomMathLinksProcessor(this));
this.app.workspace.onLayoutReady(() => this.forceRerender());
}
async loadSettings() {