xwberry_obsidian-drag-out/main.ts
xberry1231 5d1b7e0f3e refactor(main.ts): add type guards and improve Electron remote handling
Add interfaces for ElectronRemoteLike and ElectronModuleWithRemote
Introduce isModifierKey and isDragOutSettings
Replace global document/window with activeDocument/activeWindow
Update error notices and use console.debug for logging
Validate settings shape on load
2026-04-26 18:31:06 -04:00

378 lines
12 KiB
TypeScript

import {
App,
FileSystemAdapter,
Notice,
Plugin,
PluginSettingTab,
Setting,
} from "obsidian";
import type { WebContents, NativeImage } from "electron";
import { existsSync } from "fs";
import * as path from "path";
type ModifierKey = "alt" | "shift" | "ctrl" | "none";
interface ElectronRemoteLike {
getCurrentWebContents?: () => WebContents;
}
type ElectronModule = typeof import("electron");
type ElectronModuleWithRemote = ElectronModule & {
remote?: ElectronRemoteLike;
};
type WindowWithRequire = Window & {
require?: (moduleName: string) => unknown;
};
interface DragOutSettings {
/** Modifier key required to trigger external drag. "none" = always on. */
modifierKey: ModifierKey;
/** Show a brief notice on first successful drag (helpful for verifying setup). */
debugLogging: boolean;
}
const DEFAULT_SETTINGS: DragOutSettings = {
modifierKey: process.platform === "darwin" ? "alt" : "ctrl",
debugLogging: false,
};
/**
* Selectors for items in Obsidian's file tree that we want to intercept
* drags from. .nav-file-title is a file, .nav-folder-title is a folder.
*/
const FILE_TREE_SELECTOR = ".nav-file-title, .nav-folder-title";
// Electron requires a non-empty drag icon on macOS. Keep a tiny fallback so
// releases still work even when no icon.png is bundled.
const FALLBACK_ICON_DATA_URL =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
function isModifierKey(value: unknown): value is ModifierKey {
return (
value === "alt" ||
value === "shift" ||
value === "ctrl" ||
value === "none"
);
}
function isDragOutSettings(value: unknown): value is Partial<DragOutSettings> {
if (!value || typeof value !== "object") {
return false;
}
const settings = value as Partial<Record<keyof DragOutSettings, unknown>>;
return (
(settings.modifierKey === undefined ||
isModifierKey(settings.modifierKey)) &&
(settings.debugLogging === undefined ||
typeof settings.debugLogging === "boolean")
);
}
export default class DragOutPlugin extends Plugin {
settings: DragOutSettings = DEFAULT_SETTINGS;
private iconPath: string | null = null;
async onload() {
await this.loadSettings();
// Resolve absolute path to our bundled icon (used as drag image).
this.resolveIconPath();
// Register dragstart at capture phase so we run before Obsidian's
// own handler that sets the obsidian:// URI on the dataTransfer.
this.registerDomEvent(
activeDocument,
"dragstart",
(evt) => this.handleDragStart(evt),
{ capture: true }
);
this.addSettingTab(new DragOutSettingTab(this.app, this));
this.log("Drag Out loaded");
}
onunload() {
this.log("Drag Out unloaded");
}
private resolveIconPath(): void {
const adapter = this.app.vault.adapter;
if (!(adapter instanceof FileSystemAdapter)) {
this.iconPath = null;
return;
}
const pluginDir = path.join(
adapter.getBasePath(),
this.app.vault.configDir,
"plugins",
this.manifest.id
);
const candidateIconPath = path.join(pluginDir, "icon.png");
this.iconPath = existsSync(candidateIconPath) ? candidateIconPath : null;
}
private isModifierPressed(evt: DragEvent): boolean {
switch (this.settings.modifierKey) {
case "alt":
return evt.altKey;
case "shift":
return evt.shiftKey;
case "ctrl":
// Ctrl on Windows/Linux, Cmd on Mac
return evt.ctrlKey || evt.metaKey;
case "none":
return true;
}
}
private handleDragStart(evt: DragEvent): void {
if (!this.isModifierPressed(evt)) return;
const target = evt.target as HTMLElement | null;
if (!target) return;
// Only intercept drags that originate inside the file tree.
const fileItem = target.closest(FILE_TREE_SELECTOR);
if (!fileItem) return;
const relativePath = fileItem.getAttribute("data-path");
if (!relativePath) {
this.log("file tree item missing data-path attribute");
return;
}
const adapter = this.app.vault.adapter;
if (!(adapter instanceof FileSystemAdapter)) {
// Not a desktop / FS-backed vault. Nothing we can do.
return;
}
const basePath = adapter.getBasePath();
// Collect all selected paths in case of multi-select drag.
const absolutePaths = this.collectSelectedAbsolutePaths(
relativePath,
basePath
);
if (absolutePaths.length === 0) {
this.log("no paths resolved, falling through to default drag");
return;
}
// Reach into Electron and call webContents.startDrag.
const webContents = this.getWebContents();
if (!webContents) {
new Notice(
"Could not access Electron drag APIs. " +
"Check the developer console (Ctrl+Shift+I) for details."
);
return;
}
try {
const dragItem = this.buildDragItem(absolutePaths);
webContents.startDrag(dragItem);
// Suppress Obsidian's default behavior (which writes an obsidian:// URI
// onto the clipboard / dataTransfer). We're taking over the drag.
evt.preventDefault();
evt.stopPropagation();
this.log("startDrag invoked", absolutePaths);
} catch (err) {
console.error("[DragOut] startDrag failed:", err);
new Notice("Drag failed. See console for details.");
}
}
/**
* Build the argument object passed to webContents.startDrag.
* - Single path: pass `file`.
* - Multiple paths: pass `files` (overrides `file`).
* - `icon` is required and must be non-empty on macOS.
*/
private buildDragItem(absolutePaths: string[]): {
file: string;
files?: string[];
icon: string | NativeImage;
} {
const icon = this.getDragIcon();
if (absolutePaths.length === 1) {
return { file: absolutePaths[0], icon };
}
return { file: absolutePaths[0], files: absolutePaths, icon };
}
private getDragIcon(): string | NativeImage {
if (this.iconPath) {
return this.iconPath;
}
const electron = this.requireElectron();
const fallbackIcon =
electron.nativeImage.createFromDataURL(FALLBACK_ICON_DATA_URL);
return fallbackIcon.isEmpty()
? electron.nativeImage.createEmpty()
: fallbackIcon;
}
/**
* Walk the visible file tree to find every currently-selected item.
* If nothing is "selected" (single-click drag), we just return the
* item the drag started on. Folders pass through too — Electron's
* startDrag accepts folder paths on Windows and macOS.
*/
private collectSelectedAbsolutePaths(
currentRelative: string,
basePath: string
): string[] {
const selectedNodes = activeDocument.querySelectorAll(
".nav-file-title.is-selected, .nav-folder-title.is-selected"
);
const seen = new Set<string>();
const out: string[] = [];
const push = (rel: string) => {
const abs = path.join(basePath, rel);
if (!seen.has(abs)) {
seen.add(abs);
out.push(abs);
}
};
if (selectedNodes.length === 0) {
push(currentRelative);
return out;
}
const selectedRelativePaths = Array.from(selectedNodes)
.map((el) => el.getAttribute("data-path"))
.filter((rel): rel is string => !!rel);
if (!selectedRelativePaths.includes(currentRelative)) {
push(currentRelative);
return out;
}
selectedRelativePaths.forEach(push);
return out;
}
/**
* Resolve Electron's webContents from the renderer. Tries the modern
* @electron/remote first, then falls back to the legacy electron.remote.
* Returns null if neither is reachable.
*/
private getWebContents(): WebContents | null {
// Path 1: @electron/remote (modern, requires opt-in by host)
try {
const electronRemote = (activeWindow as WindowWithRequire).require?.(
"@electron/remote"
) as ElectronRemoteLike | undefined;
if (electronRemote?.getCurrentWebContents) {
return electronRemote.getCurrentWebContents();
}
} catch {
/* fall through */
}
// Path 2: legacy electron.remote (pre-Electron 14 — Obsidian still
// exposes this on at least some versions).
try {
const electron = this.requireElectron() as ElectronModuleWithRemote;
if (electron.remote?.getCurrentWebContents) {
return electron.remote.getCurrentWebContents();
}
} catch {
/* fall through */
}
return null;
}
private requireElectron(): typeof import("electron") {
// Use window.require to bypass any bundler that might have rewritten
// a top-level require call into something else.
const req = (activeWindow as WindowWithRequire).require ?? require;
return req("electron") as ElectronModule;
}
private log(...args: unknown[]): void {
if (this.settings.debugLogging) {
console.debug("[DragOut]", ...args);
}
}
async loadSettings(): Promise<void> {
const loadedData = (await this.loadData()) as unknown;
const loadedSettings = isDragOutSettings(loadedData) ? loadedData : {};
this.settings = {
...DEFAULT_SETTINGS,
...loadedSettings,
};
}
async saveSettings(): Promise<void> {
await this.saveData(this.settings);
}
}
class DragOutSettingTab extends PluginSettingTab {
plugin: DragOutPlugin;
constructor(app: App, plugin: DragOutPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName("Modifier key")
.setDesc(
"Hold this key while dragging a file from the file explorer " +
"to drag the actual file (instead of an obsidian:// link). " +
"Pick 'None' to always use external drag — but be aware " +
"this disables Obsidian's built-in file-tree drag behavior."
)
.addDropdown((dd) =>
dd
.addOption("ctrl", "Ctrl/cmd (default on Windows/Linux)")
.addOption("alt", "Alt/option (default on macOS)")
.addOption("shift", "Shift")
.addOption("none", "None — always intercept")
.setValue(this.plugin.settings.modifierKey)
.onChange(async (value) => {
if (!isModifierKey(value)) {
return;
}
this.plugin.settings.modifierKey = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Debug logging")
.setDesc(
"Log drag events to the developer console (Ctrl+Shift+I). " +
"Useful for troubleshooting; leave off otherwise."
)
.addToggle((tg) =>
tg
.setValue(this.plugin.settings.debugLogging)
.onChange(async (value) => {
this.plugin.settings.debugLogging = value;
await this.plugin.saveSettings();
})
);
}
}