From ef449e8e92b501d2423d8511a97e1ec745781a9e Mon Sep 17 00:00:00 2001 From: RyotaUshio Date: Wed, 6 Dec 2023 06:28:47 +0900 Subject: [PATCH] Add hover preview functionality & callout support --- src/main.ts | 165 +++++++++++++++++++++++++++++---------- src/settings.ts | 44 ++++++++--- src/typings/items.d.ts | 66 ++++++++++++++++ src/typings/suggest.d.ts | 16 ++++ styles.css | 15 ++-- 5 files changed, 244 insertions(+), 62 deletions(-) create mode 100644 src/typings/items.d.ts create mode 100644 src/typings/suggest.d.ts diff --git a/src/main.ts b/src/main.ts index b4203db..bb234df 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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; +type Item = FileLinkInfo | HeadingLinkInfo | MathLinkInfo | CalloutLinkInfo | BlockLinkInfo; +type BuiltInAutocompletion = EditorSuggest & { component: Component }; export default class MyPlugin extends Plugin { settings: MyPluginSettings; - prototype: BuiltInAutocompletion | null = null; async onload() { await this.loadSettings(); @@ -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('.suggestion-title'); + if (suggestionTitleEl) { + suggestionTitleEl.replaceChildren(); + cb(suggestionTitleEl) + }; +} \ No newline at end of file diff --git a/src/settings.ts b/src/settings.ts index 91e146f..3f29714 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,17 +1,28 @@ -import { PluginSettingTab, Setting } from 'obsidian'; +import { Modifier, PluginSettingTab, Setting } from 'obsidian'; import MyPlugin from './main'; +// https://stackoverflow.com/a/50851710/13613783 +export type BooleanKeys = { [k in keyof T]: T[k] extends boolean ? k : never }[keyof T]; +export type NumberKeys = { [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 { @@ -19,22 +30,29 @@ export class SampleSettingTab extends PluginSettingTab { 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) { + 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) { + 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'); } } diff --git a/src/typings/items.d.ts b/src/typings/items.d.ts new file mode 100644 index 0000000..9ac8795 --- /dev/null +++ b/src/typings/items.d.ts @@ -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; +} \ No newline at end of file diff --git a/src/typings/suggest.d.ts b/src/typings/suggest.d.ts new file mode 100644 index 0000000..5441325 --- /dev/null +++ b/src/typings/suggest.d.ts @@ -0,0 +1,16 @@ +import { Scope } from "obsidian"; + +declare module "obsidian" { + interface EditorSuggest { + scope: Scope; + suggestions: { + selectedItem: number; + values: T[]; + containerEl: HTMLElement; + moveUp(event: KeyboardEvent): void; + moveDown(event: KeyboardEvent): void; + }; + suggestEl: HTMLElement; + isOpen: boolean; + } +} \ No newline at end of file diff --git a/styles.css b/styles.css index 71cc60f..4f10d3a 100644 --- a/styles.css +++ b/styles.css @@ -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%; +}