zotero-better-notes/src/extras/editor/linkPreview.ts

248 lines
6.1 KiB
TypeScript

import { EditorState, Plugin, PluginKey } from "prosemirror-state";
import { Popup } from "./popup";
export { initLinkPreviewPlugin, LinkPreviewOptions };
declare const _currentEditorInstance: {
_editorCore: EditorCore;
};
interface LinkPreviewOptions {
setPreviewContent: (
link: string,
setContent: (content: string) => void,
) => void;
openURL: (url: string) => void;
requireCtrl: boolean;
}
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.tryOpenPopupByHover();
}
}
}
if (!isValid && this.currentLink) {
this.hasHover = false;
this.currentLink = null;
this.tryClosePopup();
}
};
handleKeydown = async (event: KeyboardEvent) => {
if (!this.options.requireCtrl) {
return;
}
if (!this.hasHover || !this.currentLink) {
return;
}
const isMac =
typeof navigator != "undefined" ? /Mac/.test(navigator.platform) : false;
if ((isMac && event.metaKey) || (!isMac && event.ctrlKey)) {
this.tryTogglePopupByKey();
}
};
tryOpenPopupByHover() {
if (this.options.requireCtrl) {
return;
}
const href = this.currentLink!;
setTimeout(() => {
if (this.currentLink === href) {
this._openPopup();
}
}, 300);
}
tryTogglePopupByKey() {
if (this._hasPopup()) {
this._closePopup();
} else {
this._openPopup();
}
}
_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 auto;
}
.link-preview > .popup > * {
margin-block: 0;
}
.link-preview .primary-editor img:not(.ProseMirror-separator) {
max-width: 100%;
height: auto;
}
.link-preview .primary-editor li {
white-space: nowrap;
}
</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;
}
_hasPopup() {
return !!document.querySelector(".link-preview");
}
}
function initLinkPreviewPlugin(
plugins: readonly Plugin[],
options: LinkPreviewOptions,
) {
const core = _currentEditorInstance._editorCore;
console.log("Init BN Link Preview Plugin");
const key = new PluginKey("linkPreviewPlugin");
return [
...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);
},
keydown: (view, event) => {
const pluginState = key.getState(view.state) as LinkPreviewState;
pluginState.handleKeydown(event);
},
wheel: (view, event) => {
const pluginState = key.getState(view.state) as LinkPreviewState;
pluginState.popup?.layoutPopup(pluginState);
},
},
},
view: (editorView) => {
return {
update(view, prevState) {
const pluginState = key.getState(view.state) as LinkPreviewState;
pluginState.update(view.state, prevState);
},
destroy() {
const pluginState = key.getState(
editorView.state,
) as LinkPreviewState;
pluginState.destroy();
},
};
},
}),
];
}