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);