add: note link hover preview

resolve: #1004
This commit is contained in:
windingwind 2024-06-27 00:12:39 +08:00
parent a808742523
commit 5bb5ef1d9a
15 changed files with 406 additions and 11 deletions

View File

@ -39,6 +39,11 @@
native="true" native="true"
preference="__prefsPrefix__.workspace.outline.keepLinks" preference="__prefsPrefix__.workspace.outline.keepLinks"
/> />
<checkbox
data-l10n-id="editor-noteLinkPreview"
native="true"
preference="__prefsPrefix__.editor.noteLinkPreview"
/>
</groupbox> </groupbox>
<groupbox> <groupbox>
<label><html:h2 data-l10n-id="sync-title"></html:h2></label> <label><html:h2 data-l10n-id="sync-title"></html:h2></label>

View File

@ -8,6 +8,8 @@ editor-title = Note Editor
editor-expandLevel-label = Outline expand to heading level editor-expandLevel-label = Outline expand to heading level
editor-keepLinks = editor-keepLinks =
.label = Show note links in outline .label = Show note links in outline
editor-noteLinkPreview =
.label = Show note link preview on hover
sync-title = Sync sync-title = Sync
sync-period-label = Auto-sync period (seconds) sync-period-label = Auto-sync period (seconds)

View File

@ -8,6 +8,8 @@ editor-title = Note Editor
editor-expandLevel-label = Espansione dello schema al livello delle intestazioni editor-expandLevel-label = Espansione dello schema al livello delle intestazioni
editor-keepLinks = editor-keepLinks =
.label = Mostra i collegamenti delle note nello schema .label = Mostra i collegamenti delle note nello schema
editor-noteLinkPreview =
.label = Show note link preview on hover
sync-title = Sincronizzazione sync-title = Sincronizzazione
sync-period-label = Intervallo della sincronizzazione automatica (secondi) sync-period-label = Intervallo della sincronizzazione automatica (secondi)

View File

@ -8,6 +8,8 @@ editor-title = Note Editor
editor-expandLevel-label = Outline расширить до уровня заголовка editor-expandLevel-label = Outline расширить до уровня заголовка
editor-keepLinks = editor-keepLinks =
.label = Сохранить ссылки .label = Сохранить ссылки
editor-noteLinkPreview =
.label = Show note link preview on hover
sync-title = Синк sync-title = Синк
sync-period-label = Авто-синк период (сек) sync-period-label = Авто-синк период (сек)

View File

@ -8,6 +8,8 @@ editor-title = Note Editor
editor-expandLevel-label = Anahat başlık seviyesine genişletildi editor-expandLevel-label = Anahat başlık seviyesine genişletildi
editor-keepLinks = editor-keepLinks =
.label = Not linklerini anahatta göster .label = Not linklerini anahatta göster
editor-noteLinkPreview =
.label = Show note link preview on hover
sync-title = Senkronize Et sync-title = Senkronize Et
sync-period-label = Oto-Senkronize aralığı (saniye) sync-period-label = Oto-Senkronize aralığı (saniye)

View File

@ -8,6 +8,8 @@ editor-title = 笔记编辑器
editor-expandLevel-label = 大纲展开至标题层级 editor-expandLevel-label = 大纲展开至标题层级
editor-keepLinks = editor-keepLinks =
.label = 在大纲中显示笔记链接 .label = 在大纲中显示笔记链接
editor-noteLinkPreview =
.label = 鼠标悬停时显示笔记链接预览
sync-title = 同步 sync-title = 同步
sync-period-label = 自动同步周期 (秒) sync-period-label = 自动同步周期 (秒)

View File

@ -18,15 +18,10 @@ pref("__prefsPrefix__.exportPDF", false);
pref("__prefsPrefix__.exportFreeMind", false); pref("__prefsPrefix__.exportFreeMind", false);
pref("__prefsPrefix__.exportNote", 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.expandLevel", 2);
pref("__prefsPrefix__.workspace.outline.keepLinks", true); pref("__prefsPrefix__.workspace.outline.keepLinks", true);
pref("__prefsPrefix__.editor.noteLinkPreview", true);
pref("__prefsPrefix__.openNote.takeover", true); pref("__prefsPrefix__.openNote.takeover", true);
pref("__prefsPrefix__.openNote.defaultAsWindow", false); pref("__prefsPrefix__.openNote.defaultAsWindow", false);

View File

@ -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(`
<style>
.link-preview > .popup {
max-width: 360px;
max-height: 360px;
overflow: hidden;
}
.link-preview > .popup > * {
margin-block: 0;
}
.link-preview .primary-editor img:not(.ProseMirror-separator) {
max-width: 100%;
height: auto;
}
</style>`),
]);
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);
}

120
src/extras/editor/popup.ts Normal file
View File

@ -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 = `
<div class="popup popup-top">
</div>
`;
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();
}
}

View File

@ -11,6 +11,7 @@ import {
} from "prosemirror-model"; } from "prosemirror-model";
import { EditorState, TextSelection } from "prosemirror-state"; import { EditorState, TextSelection } from "prosemirror-state";
import { EditorView } from "prosemirror-view"; import { EditorView } from "prosemirror-view";
import { initLinkPreviewPlugin } from "./editor/linkPreview";
declare const _currentEditorInstance: { declare const _currentEditorInstance: {
_editorCore: EditorCore; _editorCore: EditorCore;
@ -376,6 +377,7 @@ export const BetterNotesEditorAPI = {
getSliceFromHTML, getSliceFromHTML,
getNodeFromHTML, getNodeFromHTML,
setSelection, setSelection,
initLinkPreviewPlugin,
}; };
// @ts-ignore // @ts-ignore

View File

@ -1,5 +1,6 @@
import { initEditorImagePreviewer } from "./image"; import { initEditorImagePreviewer } from "./image";
import { injectEditorCSS, injectEditorScripts } from "./inject"; import { injectEditorCSS, injectEditorScripts } from "./inject";
import { initEditorLinkPreview } from "./linkPreview";
import { initEditorMenu } from "./menu"; import { initEditorMenu } from "./menu";
import { initEditorPopup } from "./popup"; import { initEditorPopup } from "./popup";
import { initEditorToolbar } from "./toolbar"; import { initEditorToolbar } from "./toolbar";
@ -36,4 +37,5 @@ async function onEditorInstanceCreated(editor: Zotero.EditorInstance) {
await initEditorToolbar(editor); await initEditorToolbar(editor);
initEditorPopup(editor); initEditorPopup(editor);
initEditorMenu(editor); initEditorMenu(editor);
initEditorLinkPreview(editor);
} }

View File

@ -0,0 +1,5 @@
import { initLinkPreview } from "../../utils/editor";
export function initEditorLinkPreview(editor: Zotero.EditorInstance) {
initLinkPreview(editor);
}

View File

@ -186,7 +186,11 @@ function link2params(link: string) {
async function link2html( async function link2html(
link: string, link: string,
options: { noteItem?: Zotero.Item; dryRun?: boolean } = {}, options: {
noteItem?: Zotero.Item;
dryRun?: boolean;
usePosition?: boolean;
} = {},
) { ) {
ztoolkit.log("link2html", link, options); ztoolkit.log("link2html", link, options);
const linkParams = getNoteLinkParams(link); const linkParams = getNoteLinkParams(link);
@ -196,8 +200,22 @@ async function link2html(
const refIds = getLinkedNotesRecursively(link); const refIds = getLinkedNotesRecursively(link);
const refNotes = options.noteItem ? Zotero.Items.get(refIds) : []; const refNotes = options.noteItem ? Zotero.Items.get(refIds) : [];
ztoolkit.log(refIds); ztoolkit.log(refIds);
const html = let html;
addon.api.sync.getNoteStatus(linkParams.noteItem.id)?.content || ""; 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) { if (options.dryRun) {
return await renderNoteHTML(html, refNotes); return await renderNoteHTML(html, refNotes);
} else { } else {

View File

@ -1,6 +1,7 @@
import TreeModel = require("tree-model"); import TreeModel = require("tree-model");
import { TextSelection } from "prosemirror-state"; import { TextSelection } from "prosemirror-state";
import { getNoteTreeFlattened } from "./note"; import { getNoteTreeFlattened } from "./note";
import { getPref } from "./prefs";
export { export {
insert, insert,
@ -24,6 +25,7 @@ export {
getTextBetween, getTextBetween,
getTextBetweenLines, getTextBetweenLines,
isImageAtCursor, isImageAtCursor,
initLinkPreview,
}; };
function insert( function insert(
@ -433,3 +435,38 @@ function getTextBetweenLines(
const to = getPositionAtLine(editor, toIndex, "end"); const to = getPositionAtLine(editor, toIndex, "end");
return core.view.state.doc.textBetween(from, to); 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(`<p style="color: red;">Invalid note link: ${link}</p>`);
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 },
),
);
}

View File

@ -225,7 +225,7 @@ async function renderNoteHTML(
if (await attachment.fileExists()) { if (await attachment.fileExists()) {
const imageNodes = Array.from( const imageNodes = Array.from(
doc.querySelectorAll(`img[data-attachment-key="${attachment.key}"]`), doc.querySelectorAll(`img[data-attachment-key="${attachment.key}"]`),
); ) as HTMLImageElement[];
if (imageNodes.length) { if (imageNodes.length) {
try { try {
const b64 = await getItemDataURL(attachment); 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) { } catch (e) {
ztoolkit.log(e); ztoolkit.log(e);