diff --git a/.eslintrc b/.eslintrc.json similarity index 52% rename from .eslintrc rename to .eslintrc.json index 0807290..2f79cdf 100644 --- a/.eslintrc +++ b/.eslintrc.json @@ -8,16 +8,22 @@ "extends": [ "eslint:recommended", "plugin:@typescript-eslint/eslint-recommended", - "plugin:@typescript-eslint/recommended" + "plugin:@typescript-eslint/recommended-type-checked" ], "parserOptions": { - "sourceType": "module" + "sourceType": "module", + "project": true }, "rules": { "no-unused-vars": "off", "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], "@typescript-eslint/ban-ts-comment": "off", "no-prototype-builtins": "off", - "@typescript-eslint/no-empty-function": "off" + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/no-floating-promises": "off", + "@typescript-eslint/no-misused-promises": "off" } } \ No newline at end of file diff --git a/README.md b/README.md index de9a553..1b4adab 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # Image Context Menus This plugin provides the following context menus for images in [Obsidian](https://obsidian.md/): -- Copy Image +- Copy image to clipboard - Open image in default app - Show in system explorer - Reveal file in navigation +- Open in new tab + - also available through middle mouse button click It also has an `Open PDF externally` context menu for PDFs. @@ -40,8 +42,9 @@ Contributions are welcome. Original plugin by [NomarCub](https://github.com/NomarCub). If you like this plugin you can sponsor me here on GitHub: [![Sponsor NomarCub](https://img.shields.io/static/v1?label=Sponsor%20NomarCub&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/NomarCub), on Ko-fi here: Buy Me a Coffee at ko-fi.com, or on PayPal here: [![Paypal](https://img.shields.io/badge/paypal-nomarcub-yellow?style=social&logo=paypal)](https://paypal.me/nomarcub). -[Copying](https://github.com/NomarCub/obsidian-copy-url-in-preview/pull/2) [images](https://github.com/NomarCub/obsidian-copy-url-in-preview/pull/3) developed by [luckman212](https://github.com/luckman212). -[Android image sharing](https://github.com/NomarCub/obsidian-copy-url-in-preview/issues/5) developed by [mnaoumov](https://github.com/mnaoumov). -[Open PDF externally](https://github.com/NomarCub/obsidian-copy-url-in-preview/issues/9) feature developed by [mnaoumov](https://github.com/mnaoumov). +- [Open in new tab](https://github.com/NomarCub/obsidian-copy-url-in-preview/pull/37) developed by [waterproofsodium](https://github.com/waterproofsodium) +- [Copying](https://github.com/NomarCub/obsidian-copy-url-in-preview/pull/2) [images](https://github.com/NomarCub/obsidian-copy-url-in-preview/pull/3) developed by [luckman212](https://github.com/luckman212). +- [Android image sharing](https://github.com/NomarCub/obsidian-copy-url-in-preview/issues/5) developed by [mnaoumov](https://github.com/mnaoumov). +- [Open PDF externally](https://github.com/NomarCub/obsidian-copy-url-in-preview/issues/9) feature developed by [mnaoumov](https://github.com/mnaoumov). Thank you to the makers of the [Tag Wrangler plugin](https://github.com/pjeby/tag-wrangler), as it was a great starting point for working with context menus in Obsidian. diff --git a/manifest.json b/manifest.json index bffcdce..2a9a9a0 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "copy-url-in-preview", "name": "Image Context Menus", - "version": "1.6.0", + "version": "1.7.0", "minAppVersion": "1.5.7", "description": "Copy, open in default app, show in system explorer, reveal in navigation context menu for images. Also Open PDF externally context menu.", "author": "NomarCub", diff --git a/package-lock.json b/package-lock.json index 5c6a5ea..315f183 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "copy-url-in-preview", - "version": "1.6.0", + "version": "1.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "copy-url-in-preview", - "version": "1.6.0", + "version": "1.7.0", "license": "MIT", "devDependencies": { "@types/node": "^16.11.6", @@ -16,7 +16,7 @@ "esbuild": "0.17.3", "eslint": "8.46.0", "obsidian": "~1.5.7-1", - "obsidian-typings": "^1.0.6", + "obsidian-typings": "^1.1.2", "tslib": "2.4.0", "typescript": "4.7.4" } @@ -1632,9 +1632,9 @@ } }, "node_modules/obsidian-typings": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/obsidian-typings/-/obsidian-typings-1.0.6.tgz", - "integrity": "sha512-pgQeeIa7Lj6qYjNFAewv8ZcguJsTRTXl9e3cboCGTSmvicmyBATH58UNbC+ioUjvm8uBA4sMDUUbTeDpGxRH4Q==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obsidian-typings/-/obsidian-typings-1.1.2.tgz", + "integrity": "sha512-i9a4OI40owbyq4V27B6bQ7+KqizM5zwu15Gw81CGpbCJuonC07W7r72q+czkv93F9zUW9jf2El9Ryyyym+YtpQ==", "dev": true, "dependencies": { "obsidian": "^1.4.11" diff --git a/package.json b/package.json index 495ca53..0ad898b 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,11 @@ { "name": "copy-url-in-preview", - "version": "1.6.0", + "version": "1.7.0", "description": "Copy Image, Copy URL and Open PDF externally context menu in reading view (formerly preview mode) for Obsidian (https://obsidian.md)", "main": "main.js", "scripts": { "dev": "node esbuild.config.mjs", + "lint": "eslint src", "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", "version": "node version-bump.mjs && git add manifest.json versions.json" }, @@ -19,7 +20,7 @@ "esbuild": "0.17.3", "eslint": "8.46.0", "obsidian": "~1.5.7-1", - "obsidian-typings": "^1.0.6", + "obsidian-typings": "^1.1.2", "tslib": "2.4.0", "typescript": "4.7.4" } diff --git a/src/helpers.ts b/src/helpers.ts index a33bb7b..f5cdc8e 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,4 +1,4 @@ -import { FileSystemAdapter } from "obsidian"; +import { App, FileSystemAdapter, View } from "obsidian"; const loadImageBlobTimeout = 5_000; @@ -15,60 +15,100 @@ export interface Listener { } export function withTimeout(ms: number, promise: Promise): Promise { - const timeout = new Promise((resolve, reject) => { + const timeout = new Promise((_resolve, reject) => { const id = setTimeout(() => { clearTimeout(id); reject(`timed out after ${ms} ms`) }, ms) - }) + }) as unknown as Promise; return Promise.race([ promise, timeout - ]) as Promise + ]); } // https://developer.mozilla.org/en-US/docs/Web/HTML/CORS_enabled_image // option?: https://www.npmjs.com/package/html-to-image export async function loadImageBlob(imgSrc: string): Promise { - const loadImageBlobCore = () => { - return new Promise((resolve, reject) => { - const image = new Image(); - image.crossOrigin = "anonymous"; - image.onload = () => { - const canvas = document.createElement("canvas"); - canvas.width = image.width; - canvas.height = image.height; - const ctx = canvas.getContext("2d")!; - ctx.drawImage(image, 0, 0); - canvas.toBlob((blob: Blob) => { - resolve(blob); - }); - }; - image.onerror = async () => { - try { - await fetch(image.src, { "mode": "no-cors" }); + const loadImageBlobCore = () => new Promise((resolve, reject) => { + const image = new Image(); + image.crossOrigin = "anonymous"; + image.onload = () => { + const canvas = document.createElement("canvas"); + canvas.width = image.width; + canvas.height = image.height; + const ctx = canvas.getContext("2d")!; + ctx.drawImage(image, 0, 0); + canvas.toBlob((blob: Blob) => { + resolve(blob); + }); + }; + image.onerror = async () => { + try { + await fetch(image.src, { "mode": "no-cors" }); - // console.log("possible CORS violation, falling back to allOrigins proxy"); - // https://github.com/gnuns/allOrigins - const blob = await loadImageBlob(`https://api.allorigins.win/raw?url=${encodeURIComponent(imgSrc)}`); - resolve(blob); - } catch { - reject(); - } + // console.log("possible CORS violation, falling back to allOrigins proxy"); + // https://github.com/gnuns/allOrigins + const blob = await loadImageBlob(`https://api.allorigins.win/raw?url=${encodeURIComponent(imgSrc)}`); + resolve(blob); + } catch { + reject(); } - image.src = imgSrc; - }); - }; + } + image.src = imgSrc; + }); return withTimeout(loadImageBlobTimeout, loadImageBlobCore()) } export function onElement( - el: Document, - event: keyof HTMLElementEventMap, - selector: string, + el: Document, event: keyof HTMLElementEventMap, selector: string, listener: Listener, options?: { capture?: boolean; } ) { el.on(event, selector, listener, options); return () => el.off(event, selector, listener, options); } + +export function imageElementFromMouseEvent(event: MouseEvent): HTMLImageElement | undefined { + const imageElement = event.target; + if (!(imageElement instanceof HTMLImageElement)) { + console.error("imageElement is supposed to be a HTMLImageElement. imageElement:"); + console.error(imageElement); + } + else { + return imageElement; + } +} + +export function getRelativePath(url: URL, app: App): string | undefined { + // getResourcePath("") also works for root path + const baseFileUrl = app.vault.adapter.getFilePath(""); + const basePath = baseFileUrl.replace("file://", ""); + + const urlPathName: string = url.pathname; + if (urlPathName.startsWith(basePath)) { + const relativePath = urlPathName.substring(basePath.length + 1); + return decodeURI(relativePath); + } +} + +export function openImageFromMouseEvent(event: MouseEvent, app: App) { + const image = imageElementFromMouseEvent(event); + if (!image) return; + + const leaf = app.workspace.getLeaf(true); + app.workspace.setActiveLeaf(leaf, { focus: true }); + + const relativePath = getRelativePath(new URL(image.currentSrc), app); + if (relativePath) { + const titleContainerEl = (leaf.view as View & { titleContainerEl: Node }).titleContainerEl; + titleContainerEl.empty(); + titleContainerEl.createEl("div", { text: relativePath }) + } + + const contentEl = (leaf.view as View & { contentEl: Node }).contentEl; + contentEl.empty(); + const div = contentEl.createEl("div", {}); + const img = div.appendChild(document.createElement("img")); + img.src = image.currentSrc; +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index f1666d7..b23e962 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,8 @@ import { Menu, Plugin, Notice, MenuItem, Platform, TFile, MarkdownView } from "obsidian"; import { - loadImageBlob, onElement, - ElectronWindow, FileSystemAdapterWithInternalApi + loadImageBlob, onElement, openImageFromMouseEvent, + ElectronWindow, FileSystemAdapterWithInternalApi, + imageElementFromMouseEvent, getRelativePath } from "./helpers" // eslint-disable-next-line @typescript-eslint/no-unused-vars import * as internal from 'obsidian-typings'; @@ -25,7 +26,7 @@ export default class CopyUrlInPreview extends Plugin { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); } async saveSettings() { - this.saveData(this.settings); + await this.saveData(this.settings); } async onload() { @@ -33,7 +34,7 @@ export default class CopyUrlInPreview extends Plugin { this.addSettingTab(new CopyUrlInPreviewSettingTab(this.app, this)); this.registerDocument(document); this.app.workspace.on("window-open", - (workspaceWindow, window) => { + (_workspaceWindow, window) => { this.registerDocument(window.document); }); } @@ -41,18 +42,14 @@ export default class CopyUrlInPreview extends Plugin { registerDocument(document: Document) { this.register( onElement( - document, - "mouseover", - ".pdf-embed iframe, .pdf-embed div.pdf-container, .workspace-leaf-content[data-type=pdf]", + document, "mouseover", ".pdf-embed iframe, .pdf-embed div.pdf-container, .workspace-leaf-content[data-type=pdf]", this.showOpenPdfMenu.bind(this) ) ) this.register( onElement( - document, - "mousemove", - ".pdf-canvas", + document, "mousemove", ".pdf-canvas", this.showOpenPdfMenu.bind(this) ) ) @@ -60,54 +57,49 @@ export default class CopyUrlInPreview extends Plugin { if (Platform.isDesktop) { this.register( onElement( - document, - "contextmenu", - "img", - this.onClickImage.bind(this) + document, "contextmenu", "img", + this.onImageContextMenu.bind(this) ) - ) + ); this.register( onElement( - document, - "mouseover", - ".cm-link, .cm-hmd-internal-link", + document, "mouseup", "img", + this.onImageMouseUp.bind(this) + ) + ); + + this.register( + onElement( + document, "mouseover", ".cm-link, .cm-hmd-internal-link", this.storeLastHoveredLinkInEditor.bind(this) ) ); this.register( onElement( - document, - "mouseover", - "a.internal-link", + document, "mouseover", "a.internal-link", this.storeLastHoveredLinkInPreview.bind(this) ) ); } else { this.register( onElement( - document, - "touchstart", - "img", + document, "touchstart", "img", this.startWaitingForLongTap.bind(this) ) ); this.register( onElement( - document, - "touchend", - "img", + document, "touchend", "img", this.stopWaitingForLongTap.bind(this) ) ); this.register( onElement( - document, - "touchmove", - "img", + document, "touchmove", "img", this.stopWaitingForLongTap.bind(this) ) ); @@ -127,7 +119,7 @@ export default class CopyUrlInPreview extends Plugin { this.lastHoveredLinkTarget = token.text; } - storeLastHoveredLinkInPreview(event: MouseEvent, link: HTMLAnchorElement) { + storeLastHoveredLinkInPreview(_event: MouseEvent, link: HTMLAnchorElement) { this.lastHoveredLinkTarget = link.getAttribute("data-href")!; } @@ -158,7 +150,7 @@ export default class CopyUrlInPreview extends Plugin { pdfLink = pdfLink?.replace(/#page=\d+$/, ''); const currentNotePath = this.app.workspace.getActiveFile()!.path; - pdfFile = this.app.metadataCache.getFirstLinkpathDest(pdfLink!, currentNotePath!)!; + pdfFile = this.app.metadataCache.getFirstLinkpathDest(pdfLink!, currentNotePath)!; } else { pdfFile = this.app.workspace.getActiveFile()!; } @@ -189,9 +181,7 @@ export default class CopyUrlInPreview extends Plugin { registerEscapeButton(menu: Menu, document: Document = activeDocument) { menu.register( onElement( - document, - "keydown", - "*", + document, "keydown", "*", (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); @@ -267,17 +257,13 @@ export default class CopyUrlInPreview extends Plugin { // Positions are not accurate from PointerEvent. // There's also TouchEvent // The event has target, path, toEvent (null on Android) for finding the link - onClickImage(event: MouseEvent) { - const imgElement = event.target; - if (!(imgElement instanceof HTMLImageElement)) { - console.error("imgElement is supposed to be a HTMLImageElement. imgElement:"); - console.error(imgElement); - return; - } + onImageContextMenu(event: MouseEvent) { + const imageElement = imageElementFromMouseEvent(event); + if (!imageElement) return; event.preventDefault(); const menu = new Menu(); - const image = imgElement.currentSrc; + const image = imageElement.currentSrc; const url = new URL(image); const protocol = url.protocol; switch (protocol) { @@ -300,14 +286,15 @@ export default class CopyUrlInPreview extends Plugin { }) ); if (protocol === "app:" && Platform.isDesktop) { - // getResourcePath("") also works for root path - const baseFilePath = this.app.vault.adapter.getFilePath(""); - const baseFilePathName: string = baseFilePath.replace("file://", ""); - const urlPathName: string = url.pathname; - if (urlPathName.startsWith(baseFilePathName)) { - let relativePath = urlPathName.replace(baseFilePathName, ""); - relativePath = decodeURI(relativePath); - + const relativePath = getRelativePath(url, this.app); + if (relativePath) { + menu.addItem((item: MenuItem) => item + .setIcon("arrow-up-right") + .setTitle("Open in new tab") + .onClick(() => { + openImageFromMouseEvent(event, this.app); + }) + ); menu.addItem((item: MenuItem) => item .setIcon("arrow-up-right") .setTitle("Open in default app") @@ -324,7 +311,7 @@ export default class CopyUrlInPreview extends Plugin { .setIcon("folder") .setTitle("Reveal file in navigation") .onClick(() => { - const file = this.app.vault.getFileByPath(relativePath.substring(1)); + const file = this.app.vault.getFileByPath(relativePath); if (!file) { console.warn(`getFileByPath returned null for ${relativePath}`) return; @@ -344,4 +331,11 @@ export default class CopyUrlInPreview extends Plugin { menu.showAtPosition({ x: event.pageX, y: event.pageY }); this.app.workspace.trigger("copy-url-in-preview:contextmenu", menu); } + + onImageMouseUp(event: MouseEvent) { + const middleButtonNumber = 1; + if (event.button == middleButtonNumber && this.settings.middleClickNewTab) { + openImageFromMouseEvent(event, this.app); + } + } } diff --git a/src/settings.ts b/src/settings.ts index 56a0c0b..b390142 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -3,10 +3,12 @@ import { App, PluginSettingTab, Setting } from "obsidian"; export interface CopyUrlInPreviewSettings { pdfMenu: boolean; + middleClickNewTab: boolean; } export const DEFAULT_SETTINGS: CopyUrlInPreviewSettings = { - pdfMenu: true + pdfMenu: true, + middleClickNewTab: true } export class CopyUrlInPreviewSettingTab extends PluginSettingTab { @@ -27,5 +29,13 @@ export class CopyUrlInPreviewSettingTab extends PluginSettingTab { this.plugin.saveSettings(); }) }) + new Setting(containerEl) + .setName("Middle mouse click on image link to open in new tab") + .addToggle((toggle) => { + toggle.setValue(this.plugin.settings.middleClickNewTab).onChange((value) => { + this.plugin.settings.middleClickNewTab = value; + this.plugin.saveSettings(); + }); + }) } } \ No newline at end of file