Skip to content

Commit

Permalink
Add hover preview functionality & callout support
Browse files Browse the repository at this point in the history
  • Loading branch information
RyotaUshio committed Dec 5, 2023
1 parent ebc8173 commit ef449e8
Show file tree
Hide file tree
Showing 5 changed files with 244 additions and 62 deletions.
165 changes: 122 additions & 43 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,14 @@
import { EditorSuggest, Plugin, SearchMatches, TFile, renderMath } from 'obsidian';
import { MarkdownRenderer, finishRenderMath } from 'obsidian';
import { Component, EditorSuggest, HoverParent, HoverPopover, Keymap, MarkdownView, Plugin, SearchMatches, TFile, renderMath, stripHeadingForLink } from 'obsidian';
import { DEFAULT_SETTINGS, MyPluginSettings, SampleSettingTab } from './settings';
import { around } from 'monkey-around';
import { BlockLinkInfo, CalloutLinkInfo, FileLinkInfo, HeadingLinkInfo, MathLinkInfo, MathNode } from 'typings/items';





interface LinkInfo {
file: TFile;
matches: SearchMatches | null;
path: string;
score: number;
subpath?: string;
};

interface FileLinkInfo extends LinkInfo {
type: "file";
}

interface HeadingLinkInfo extends LinkInfo {
type: "heading";
heading: string;
level: number;
subpath: string;
}

interface BlockLinkInfo extends LinkInfo {
type: "block";
idMatch: SearchMatches | null;
subpath: string;
node?: any;
display: string;
content: string;
}

type Item = FileLinkInfo | HeadingLinkInfo | BlockLinkInfo;
type BuiltInAutocompletion = EditorSuggest<Item>;
type Item = FileLinkInfo | HeadingLinkInfo | MathLinkInfo | CalloutLinkInfo | BlockLinkInfo;
type BuiltInAutocompletion = EditorSuggest<Item> & { component: Component };

export default class MyPlugin extends Plugin {
settings: MyPluginSettings;
prototype: BuiltInAutocompletion | null = null;

async onload() {
await this.loadSettings();
Expand All @@ -61,22 +30,132 @@ export default class MyPlugin extends Plugin {

patch() {
// @ts-ignore
const prototype = this.app.workspace.editorSuggest.suggests[0].constructor.prototype as BuiltInAutocompletion;
const suggest = this.app.workspace.editorSuggest.suggests[0] as BuiltInAutocompletion;
const prototype = suggest.constructor.prototype;
const plugin = this;

const uninstaller = around(prototype, {
const app = this.app;

this.addChild(suggest.component = new Component());
suggest.isOpen ? suggest.component.load() : suggest.component.unload();

this.register(around(prototype, {
open(old) {
return function () {
old.call(this);
this.component.load();
(this.component as Component).registerDomEvent(window, 'keydown', (event) => {
if (suggest.isOpen && Keymap.isModifier(event, plugin.settings.modifierToPreview)) {
const item = suggest.suggestions.values[suggest.suggestions.selectedItem];
const parent = new KeyupHandlingHoverParent(plugin, suggest);
this.component.addChild(parent);
if (item.type === 'file') {
app.workspace.trigger('link-hover', parent, null, item.file.path, "")
} else if (item.type === 'heading') {
app.workspace.trigger('link-hover', parent, null, item.file.path + '#' + stripHeadingForLink(item.heading), "")
} else if (item.type === 'block') {
app.workspace.trigger('link-hover', parent, null, item.file.path, "", { scroll: item.node.position.start.line })
}
}
});
}
},
close(old) {
return function () {
old.call(this);
this.component.unload();
}
},
renderSuggestion(old) {
return function (item: Item, el: HTMLElement) {
old.call(this, item, el);

if (plugin.settings.dev) console.log(item);

if (item.type === "block") {
if (plugin.settings.math && item.node?.type === "math") {
el.appendChild(renderMath(item.node.value, true))
renderInSuggestionTitleEl(el, (titleEl) => {
titleEl.replaceChildren(renderMath((item.node as MathNode).value, true));
});
finishRenderMath();
return;
}

if (plugin.settings.callout && item.node?.type === "callout") {
renderInSuggestionTitleEl(el, async (titleEl) => {
await MarkdownRenderer.render(
app,
extractCalloutTitle(item.content.slice(item.node.position.start.offset, item.node.position.end.offset)),
titleEl,
item.file.path,
plugin
);
});
return;
}
}
old.call(this, item, el);
}
}
});
this.register(uninstaller);
}));
}
}

export class KeyupHandlingHoverParent extends Component implements HoverParent {
#hoverPopover: HoverPopover | null;

constructor(private plugin: MyPlugin, private suggest: BuiltInAutocompletion) {
super();
this.#hoverPopover = null;
}

onunload() {
super.onunload();
this.hideChild();
}

get hoverPopover() {
return this.#hoverPopover;
}

set hoverPopover(hoverPopover: HoverPopover | null) {
this.#hoverPopover = hoverPopover;
if (this.#hoverPopover) {
this.addChild(this.#hoverPopover);
this.#hoverPopover.hoverEl.addClass('math-booster');
this.#hoverPopover.hoverEl.toggleClass('compact-font', this.plugin.settings.compactPreview);
this.#hoverPopover.registerDomEvent(document.body, 'keydown', (event: KeyboardEvent) => {
if (event.key === 'ArrowUp') {
event.preventDefault();
this.hideChild();
this.suggest.suggestions.moveUp(event);
} else if (event.key === 'ArrowDown') {
event.preventDefault();
this.hideChild();
this.suggest.suggestions.moveDown(event);
}

})
this.#hoverPopover.registerDomEvent(window, 'keyup', (event: KeyboardEvent) => {
if (event.key === this.plugin.settings.modifierToPreview) this.hideChild();
})
}
}

hideChild() {
/// @ts-ignore
this.#hoverPopover?.hide();
}
}


function extractCalloutTitle(text: string) {
const lineBreak = text.indexOf('\n');
return lineBreak === -1 ? text : text.slice(0, lineBreak + 1);
}

function renderInSuggestionTitleEl(el: HTMLElement, cb: (suggestionTitleEl: HTMLElement) => void) {
const suggestionTitleEl = el.querySelector<HTMLElement>('.suggestion-title');
if (suggestionTitleEl) {
suggestionTitleEl.replaceChildren();
cb(suggestionTitleEl)
};
}
44 changes: 31 additions & 13 deletions src/settings.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,58 @@
import { PluginSettingTab, Setting } from 'obsidian';
import { Modifier, PluginSettingTab, Setting } from 'obsidian';
import MyPlugin from './main';


// https://stackoverflow.com/a/50851710/13613783
export type BooleanKeys<T> = { [k in keyof T]: T[k] extends boolean ? k : never }[keyof T];
export type NumberKeys<T> = { [k in keyof T]: T[k] extends number ? k : never }[keyof T];


export interface MyPluginSettings {
math: boolean;
// code: boolean;
// callout: boolean;
callout: boolean;
dev: boolean;
modifierToPreview: Modifier;
compactPreview: boolean;
}

export const DEFAULT_SETTINGS: MyPluginSettings = {
math: true,
// code: true,
// callout: true,
callout: true,
dev: false,
modifierToPreview: 'Alt',
compactPreview: false,
}

export class SampleSettingTab extends PluginSettingTab {
constructor(public plugin: MyPlugin) {
super(plugin.app, plugin);
}

addSetting(name: string, settingName: keyof MyPluginSettings) {
new Setting(this.containerEl)
.setName(name)
.addToggle((toggle) => {
toggle.setValue(this.plugin.settings.math)
toggle.onChange(async (value) => {
this.plugin.settings[settingName] = value;
await this.plugin.saveSettings();
addToggleSetting(name: string, settingName: BooleanKeys<MyPluginSettings>) {
return new Setting(this.containerEl)
.setName(name)
.addToggle((toggle) => {
toggle.setValue(this.plugin.settings[settingName])
toggle.onChange(async (value) => {
this.plugin.settings[settingName] = value;
await this.plugin.saveSettings();
});
});
});
}

addEnableSetting(name: string, settingName: BooleanKeys<MyPluginSettings>) {
return this.addToggleSetting(name, settingName).setHeading();
}

display(): void {
const { containerEl } = this;
containerEl.empty();

this.addSetting('Math', 'math');
this.addEnableSetting('Math blocks', 'math');
this.addEnableSetting('Callouts', 'callout');
this.addToggleSetting('Compact hover preview', 'compactPreview');
this.addToggleSetting('Dev mode', 'dev');
}
}
66 changes: 66 additions & 0 deletions src/typings/items.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Loc, Pos, SearchMatches, TFile } from "obsidian";

export interface LinkInfo {
file: TFile;
matches: SearchMatches | null;
path: string;
score: number;
subpath?: string;
}

export interface FileLinkInfo extends LinkInfo {
type: "file";
}

export interface HeadingLinkInfo extends LinkInfo {
type: "heading";
heading: string;
level: number;
subpath: string;
}

interface BlockLinkInfo extends LinkInfo {
type: "block";
idMatch: SearchMatches | null;
subpath: string;
node: CalloutNode | MathNode;
display: string;
content: string;
}

interface Node {
children: Node[];
position: {
start: Loc;
end: Loc;
indent: number[];
}
}

interface CalloutNode extends Node {
type: "callout",
callout: {
data: string;
type: string;
fold: string;
},
children: [CalloutTitleNode, CalloutContentNode]
}

interface CalloutTitleNode extends Node {}

interface CalloutContentNode extends Node {}

interface CalloutLinkInfo extends BlockLinkInfo {
node: CalloutNode;
}


interface MathNode extends Node {
type: "math";
value: string;
}

interface MathLinkInfo extends BlockLinkInfo {
node: MathNode;
}
16 changes: 16 additions & 0 deletions src/typings/suggest.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Scope } from "obsidian";

declare module "obsidian" {
interface EditorSuggest<T> {
scope: Scope;
suggestions: {
selectedItem: number;
values: T[];
containerEl: HTMLElement;
moveUp(event: KeyboardEvent): void;
moveDown(event: KeyboardEvent): void;
};
suggestEl: HTMLElement;
isOpen: boolean;
}
}
15 changes: 9 additions & 6 deletions styles.css
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
/*
.compact-font.hover-popover .markdown-rendered {
font-size: 100%;
}

This CSS file will be included with your plugin, and
available in the app when your plugin is enabled.
.suggestion-container {
z-index: calc(var(--layer-popover) - 1);
}

If your plugin does not need CSS, delete this file.
*/
.suggestion-content {
width: 100%;
}

0 comments on commit ef449e8

Please sign in to comment.