From 5bb5ef1d9adb79ab39769d0555f41c3fde7cd3d4 Mon Sep 17 00:00:00 2001 From: windingwind <33902321+windingwind@users.noreply.github.com> Date: Thu, 27 Jun 2024 00:12:39 +0800 Subject: [PATCH] add: note link hover preview resolve: #1004 --- addon/chrome/content/preferences.xhtml | 5 + addon/locale/en-US/preferences.ftl | 2 + addon/locale/it-IT/preferences.ftl | 2 + addon/locale/ru-RU/preferences.ftl | 2 + addon/locale/tr-TR/preferences.ftl | 2 + addon/locale/zh-CN/preferences.ftl | 2 + addon/prefs.js | 9 +- src/extras/editor/linkPreview.ts | 195 +++++++++++++++++++++++++ src/extras/editor/popup.ts | 120 +++++++++++++++ src/extras/editorScript.ts | 2 + src/modules/editor/initalize.ts | 2 + src/modules/editor/linkPreview.ts | 5 + src/utils/convert.ts | 24 ++- src/utils/editor.ts | 37 +++++ src/utils/note.ts | 8 +- 15 files changed, 406 insertions(+), 11 deletions(-) create mode 100644 src/extras/editor/linkPreview.ts create mode 100644 src/extras/editor/popup.ts create mode 100644 src/modules/editor/linkPreview.ts diff --git a/addon/chrome/content/preferences.xhtml b/addon/chrome/content/preferences.xhtml index 0ede4bb..7f6e3b6 100644 --- a/addon/chrome/content/preferences.xhtml +++ b/addon/chrome/content/preferences.xhtml @@ -39,6 +39,11 @@ native="true" preference="__prefsPrefix__.workspace.outline.keepLinks" /> + diff --git a/addon/locale/en-US/preferences.ftl b/addon/locale/en-US/preferences.ftl index 80294c9..929d62f 100644 --- a/addon/locale/en-US/preferences.ftl +++ b/addon/locale/en-US/preferences.ftl @@ -8,6 +8,8 @@ editor-title = Note Editor editor-expandLevel-label = Outline expand to heading level editor-keepLinks = .label = Show note links in outline +editor-noteLinkPreview = + .label = Show note link preview on hover sync-title = Sync sync-period-label = Auto-sync period (seconds) diff --git a/addon/locale/it-IT/preferences.ftl b/addon/locale/it-IT/preferences.ftl index 2a911af..538b1e3 100644 --- a/addon/locale/it-IT/preferences.ftl +++ b/addon/locale/it-IT/preferences.ftl @@ -8,6 +8,8 @@ editor-title = Note Editor editor-expandLevel-label = Espansione dello schema al livello delle intestazioni editor-keepLinks = .label = Mostra i collegamenti delle note nello schema +editor-noteLinkPreview = + .label = Show note link preview on hover sync-title = Sincronizzazione sync-period-label = Intervallo della sincronizzazione automatica (secondi) diff --git a/addon/locale/ru-RU/preferences.ftl b/addon/locale/ru-RU/preferences.ftl index 45b7545..84cec28 100644 --- a/addon/locale/ru-RU/preferences.ftl +++ b/addon/locale/ru-RU/preferences.ftl @@ -8,6 +8,8 @@ editor-title = Note Editor editor-expandLevel-label = Outline расширить до уровня заголовка editor-keepLinks = .label = Сохранить ссылки +editor-noteLinkPreview = + .label = Show note link preview on hover sync-title = Синк sync-period-label = Авто-синк период (сек) diff --git a/addon/locale/tr-TR/preferences.ftl b/addon/locale/tr-TR/preferences.ftl index 58e135b..54d84f4 100644 --- a/addon/locale/tr-TR/preferences.ftl +++ b/addon/locale/tr-TR/preferences.ftl @@ -8,6 +8,8 @@ editor-title = Note Editor editor-expandLevel-label = Anahat başlık seviyesine genişletildi editor-keepLinks = .label = Not linklerini anahatta göster +editor-noteLinkPreview = + .label = Show note link preview on hover sync-title = Senkronize Et sync-period-label = Oto-Senkronize aralığı (saniye) diff --git a/addon/locale/zh-CN/preferences.ftl b/addon/locale/zh-CN/preferences.ftl index c105369..98af473 100644 --- a/addon/locale/zh-CN/preferences.ftl +++ b/addon/locale/zh-CN/preferences.ftl @@ -8,6 +8,8 @@ editor-title = 笔记编辑器 editor-expandLevel-label = 大纲展开至标题层级 editor-keepLinks = .label = 在大纲中显示笔记链接 +editor-noteLinkPreview = + .label = 鼠标悬停时显示笔记链接预览 sync-title = 同步 sync-period-label = 自动同步周期 (秒) diff --git a/addon/prefs.js b/addon/prefs.js index 52f49b5..407478a 100644 --- a/addon/prefs.js +++ b/addon/prefs.js @@ -18,15 +18,10 @@ pref("__prefsPrefix__.exportPDF", false); pref("__prefsPrefix__.exportFreeMind", false); pref("__prefsPrefix__.exportNote", false); -pref("__prefsPrefix__.OCREngine", "bing"); -pref("__prefsPrefix__.OCRMathpix.Appid", ""); -pref("__prefsPrefix__.OCRMathpix.Appkey", ""); -pref("__prefsPrefix__.OCRXunfei.APPID", ""); -pref("__prefsPrefix__.OCRMathpix.APISecret", ""); -pref("__prefsPrefix__.OCRMathpix.APIKey", ""); - pref("__prefsPrefix__.workspace.outline.expandLevel", 2); pref("__prefsPrefix__.workspace.outline.keepLinks", true); +pref("__prefsPrefix__.editor.noteLinkPreview", true); + pref("__prefsPrefix__.openNote.takeover", true); pref("__prefsPrefix__.openNote.defaultAsWindow", false); diff --git a/src/extras/editor/linkPreview.ts b/src/extras/editor/linkPreview.ts new file mode 100644 index 0000000..f9b043f --- /dev/null +++ b/src/extras/editor/linkPreview.ts @@ -0,0 +1,195 @@ +import { EditorState, Plugin, PluginKey } from "prosemirror-state"; +import { Popup } from "./popup"; + +export { initLinkPreviewPlugin }; + +declare const _currentEditorInstance: { + _editorCore: EditorCore; +}; + +interface LinkPreviewOptions { + setPreviewContent: ( + link: string, + setContent: (content: string) => void, + ) => void; + + openURL: (url: string) => void; +} + +class LinkPreviewState { + state: EditorState; + + options: LinkPreviewOptions; + + popup: Popup | null = null; + + node: HTMLElement | null = null; + + currentLink: string | null = null; + + hasHover = false; + + constructor(state: EditorState, options: LinkPreviewOptions) { + this.state = state; + this.options = options; + this.update(state); + } + + update(state: EditorState, prevState?: EditorState) { + this.state = state; + + if ( + prevState && + prevState.doc.eq(state.doc) && + prevState.selection.eq(state.selection) + ) { + return; + } + // Handle selection change + setTimeout(() => { + this.popup?.layoutPopup(this); + }, 10); + } + + destroy() { + this.popup?.remove(); + } + + handleMouseMove = async (event: MouseEvent) => { + const { target } = event; + + let isValid = false; + if (target instanceof HTMLElement) { + const href = target.closest("a")?.getAttribute("href"); + if (href?.startsWith("zotero://note/")) { + isValid = true; + if (this.currentLink !== href) { + this.node = target; + this.currentLink = href; + this.hasHover = true; + this.tryOpenPopup(); + } + } + } + + if (!isValid && this.currentLink) { + this.hasHover = false; + this.currentLink = null; + this.tryClosePopup(); + } + }; + + tryOpenPopup() { + const href = this.currentLink!; + setTimeout(() => { + if (this.currentLink === href) { + this._openPopup(); + } + }, 300); + } + + _openPopup() { + console.log("Enter Link Preview", this.currentLink, this.options); + document.querySelectorAll(".link-preview").forEach((el) => el.remove()); + this.popup = new Popup(document, "link-preview", [ + document.createRange().createContextualFragment(` +`), + ]); + this.popup.popup.classList.add("primary-editor"); + this.popup.container.style.display = "none"; + + this.popup.layoutPopup(this); + + this.options.setPreviewContent(this.currentLink!, (content: string) => { + this.popup?.popup.append( + document.createRange().createContextualFragment(content), + ); + this.popup!.container.style.removeProperty("display"); + this.popup?.layoutPopup(this); + }); + + this.popup.container.addEventListener("mouseleave", () => { + this.currentLink = null; + this.tryClosePopup(); + }); + + this.popup.container.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + const target = event.target as HTMLElement; + if (target.localName === "a") { + const href = target.getAttribute("href"); + if (href) { + this.options.openURL(href); + } + } + this._closePopup(); + }); + } + + tryClosePopup() { + setTimeout(() => { + console.log("Close Link Preview", this.currentLink, this.popup?.hasHover); + if (this.hasHover || this.popup?.hasHover) { + return; + } + this._closePopup(); + }, 300); + } + + _closePopup() { + this.node = null; + document.querySelectorAll(".link-preview").forEach((el) => el.remove()); + this.popup = null; + } +} + +function initLinkPreviewPlugin(options: LinkPreviewOptions) { + const core = _currentEditorInstance._editorCore; + console.log("Init BN Link Preview Plugin"); + const key = new PluginKey("linkPreviewPlugin"); + const newState = core.view.state.reconfigure({ + plugins: [ + ...core.view.state.plugins, + new Plugin({ + key, + state: { + init(config, state) { + return new LinkPreviewState(state, options); + }, + apply: (tr, pluginState, oldState, newState) => { + pluginState.update(newState, oldState); + return pluginState; + }, + }, + props: { + handleDOMEvents: { + mousemove: (view, event) => { + const pluginState = key.getState(view.state) as LinkPreviewState; + pluginState.update(view.state); + pluginState.handleMouseMove(event); + }, + wheel: (view, event) => { + const pluginState = key.getState(view.state) as LinkPreviewState; + pluginState.popup?.layoutPopup(pluginState); + }, + }, + }, + }), + ], + }); + core.view.updateState(newState); +} diff --git a/src/extras/editor/popup.ts b/src/extras/editor/popup.ts new file mode 100644 index 0000000..c172d19 --- /dev/null +++ b/src/extras/editor/popup.ts @@ -0,0 +1,120 @@ +export { Popup }; + +class Popup { + _popup: HTMLDivElement; + + hasHover = false; + + get container() { + return this._popup; + } + + get popup() { + return this._popup.querySelector(".popup") as HTMLDivElement; + } + + constructor( + doc: Document, + className?: string, + children: (HTMLElement | DocumentFragment)[] = [], + ) { + this._popup = doc.createElement("div"); + this._popup.className = `popup-container ${className}`; + this._popup.innerHTML = ` + + `; + this.popup.append(...children); + doc.querySelector(".relative-container")?.appendChild(this._popup); + + this._popup.addEventListener("mouseenter", () => { + this.hasHover = true; + }); + + this._popup.addEventListener("mouseleave", () => { + this.hasHover = false; + }); + } + + layoutPopup(pluginState: any) { + const rect = pluginState.rect || pluginState.node?.getBoundingClientRect(); + if (!rect) return; + + const padding = 10; + + const editor = document.querySelector(".editor-core") as HTMLElement; + + const popupParent = this.container.parentElement!; + + const parentScrollTop = popupParent.scrollTop; + const parentTop = popupParent.getBoundingClientRect().top; + const popupHeight = this.popup.offsetHeight; + const maxWidth = this.container.offsetWidth; + const topSpace = rect.top - popupHeight - padding; + + let top; + + if (topSpace < 0) { + // Bottom + const otherPopupHeight = Array.from( + popupParent.querySelectorAll( + ".popup-container:not(.link-preview) > .popup.popup-bottom", + ), + ).reduce((acc, el) => acc + (el as HTMLElement).offsetHeight, 0); + top = + parentScrollTop + + (rect.bottom - parentTop) + + otherPopupHeight + + padding; + this.popup.classList.remove("popup-top"); + this.popup.classList.add("popup-bottom"); + } else { + // Top + const otherPopupHeight = Array.from( + popupParent.querySelectorAll( + ".popup-container:not(.link-preview) > .popup.popup-top", + ), + ).reduce((acc, el) => acc + (el as HTMLElement).offsetHeight, 0); + top = + parentScrollTop + + (rect.top - parentTop) - + popupHeight - + otherPopupHeight - + padding; + this.popup.classList.remove("popup-bottom"); + this.popup.classList.add("popup-top"); + } + + const width = this.popup.offsetWidth; + let left = rect.left + (rect.right - rect.left) / 2 - width / 2 + 1; + + if (left + width >= maxWidth) { + left = maxWidth - width; + } + + if (left < 2) { + left = 2; + } + + this.popup.style.top = Math.round(top) + "px"; + this.popup.style.left = Math.round(left) + "px"; + + // Make sure the popup height is not larger than the editor height + // if (editor) { + // const editorRect = editor.getBoundingClientRect(); + // const popupRect = this.popup.getBoundingClientRect(); + // if (popupRect.bottom > editorRect.bottom + padding) { + // this.popup.style.maxHeight = + // editorRect.bottom - popupRect.top - padding + "px"; + // } else { + // this.popup.style.maxHeight = ""; + // } + // } + + return this; + } + + remove() { + this._popup.remove(); + } +} diff --git a/src/extras/editorScript.ts b/src/extras/editorScript.ts index 21c38c9..5c913e9 100644 --- a/src/extras/editorScript.ts +++ b/src/extras/editorScript.ts @@ -11,6 +11,7 @@ import { } from "prosemirror-model"; import { EditorState, TextSelection } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; +import { initLinkPreviewPlugin } from "./editor/linkPreview"; declare const _currentEditorInstance: { _editorCore: EditorCore; @@ -376,6 +377,7 @@ export const BetterNotesEditorAPI = { getSliceFromHTML, getNodeFromHTML, setSelection, + initLinkPreviewPlugin, }; // @ts-ignore diff --git a/src/modules/editor/initalize.ts b/src/modules/editor/initalize.ts index 03cffa2..83cfdf0 100644 --- a/src/modules/editor/initalize.ts +++ b/src/modules/editor/initalize.ts @@ -1,5 +1,6 @@ import { initEditorImagePreviewer } from "./image"; import { injectEditorCSS, injectEditorScripts } from "./inject"; +import { initEditorLinkPreview } from "./linkPreview"; import { initEditorMenu } from "./menu"; import { initEditorPopup } from "./popup"; import { initEditorToolbar } from "./toolbar"; @@ -36,4 +37,5 @@ async function onEditorInstanceCreated(editor: Zotero.EditorInstance) { await initEditorToolbar(editor); initEditorPopup(editor); initEditorMenu(editor); + initEditorLinkPreview(editor); } diff --git a/src/modules/editor/linkPreview.ts b/src/modules/editor/linkPreview.ts new file mode 100644 index 0000000..6b8aa48 --- /dev/null +++ b/src/modules/editor/linkPreview.ts @@ -0,0 +1,5 @@ +import { initLinkPreview } from "../../utils/editor"; + +export function initEditorLinkPreview(editor: Zotero.EditorInstance) { + initLinkPreview(editor); +} diff --git a/src/utils/convert.ts b/src/utils/convert.ts index 0c6ed72..34932fd 100644 --- a/src/utils/convert.ts +++ b/src/utils/convert.ts @@ -186,7 +186,11 @@ function link2params(link: string) { async function link2html( link: string, - options: { noteItem?: Zotero.Item; dryRun?: boolean } = {}, + options: { + noteItem?: Zotero.Item; + dryRun?: boolean; + usePosition?: boolean; + } = {}, ) { ztoolkit.log("link2html", link, options); const linkParams = getNoteLinkParams(link); @@ -196,8 +200,22 @@ async function link2html( const refIds = getLinkedNotesRecursively(link); const refNotes = options.noteItem ? Zotero.Items.get(refIds) : []; ztoolkit.log(refIds); - const html = - addon.api.sync.getNoteStatus(linkParams.noteItem.id)?.content || ""; + let html; + if (options.usePosition) { + const item = linkParams.noteItem; + let lineIndex = linkParams.lineIndex; + + if (typeof linkParams.sectionName === "string") { + const sectionTree = addon.api.note.getNoteTreeFlattened(item); + const sectionNode = sectionTree.find( + (node) => node.model.name.trim() === linkParams.sectionName!.trim(), + ); + lineIndex = sectionNode?.model.lineIndex; + } + html = addon.api.note.getLinesInNote(item).slice(lineIndex).join("\n"); + } else { + html = addon.api.sync.getNoteStatus(linkParams.noteItem.id)?.content || ""; + } if (options.dryRun) { return await renderNoteHTML(html, refNotes); } else { diff --git a/src/utils/editor.ts b/src/utils/editor.ts index 83852f1..3334ed6 100644 --- a/src/utils/editor.ts +++ b/src/utils/editor.ts @@ -1,6 +1,7 @@ import TreeModel = require("tree-model"); import { TextSelection } from "prosemirror-state"; import { getNoteTreeFlattened } from "./note"; +import { getPref } from "./prefs"; export { insert, @@ -24,6 +25,7 @@ export { getTextBetween, getTextBetweenLines, isImageAtCursor, + initLinkPreview, }; function insert( @@ -433,3 +435,38 @@ function getTextBetweenLines( const to = getPositionAtLine(editor, toIndex, "end"); return core.view.state.doc.textBetween(from, to); } + +function initLinkPreview(editor: Zotero.EditorInstance) { + if (!getPref("editor.noteLinkPreview")) { + return; + } + const EditorAPI = getEditorAPI(editor); + EditorAPI.initLinkPreviewPlugin( + Components.utils.cloneInto( + { + setPreviewContent: ( + link: string, + setContent: (content: string) => void, + ) => { + const note = addon.api.convert.link2note(link); + if (!note) { + setContent(`

Invalid note link: ${link}

`); + return; + } + addon.api.convert + .link2html(link, { + noteItem: note, + dryRun: true, + usePosition: true, + }) + .then((content) => setContent(content)); + }, + openURL: (url: string) => { + Zotero.getActiveZoteroPane().loadURI(url); + }, + }, + editor._iframeWindow, + { wrapReflectors: true, cloneFunctions: true }, + ), + ); +} diff --git a/src/utils/note.ts b/src/utils/note.ts index cd9407a..0d8e72d 100644 --- a/src/utils/note.ts +++ b/src/utils/note.ts @@ -225,7 +225,7 @@ async function renderNoteHTML( if (await attachment.fileExists()) { const imageNodes = Array.from( doc.querySelectorAll(`img[data-attachment-key="${attachment.key}"]`), - ); + ) as HTMLImageElement[]; if (imageNodes.length) { try { const b64 = await getItemDataURL(attachment); @@ -244,6 +244,12 @@ async function renderNoteHTML( ); } } + if (node.hasAttribute("width")) { + node.style.width = `${node.getAttribute("width")}px`; + } + if (node.hasAttribute("height")) { + node.style.width = `${node.getAttribute("height")}px`; + } }); } catch (e) { ztoolkit.log(e);