mirror of
https://github.com/dragonwocky/obsidian-tray.git
synced 2025-04-04 12:09:03 +00:00
e.g. CmdOrCtrl+Shift+Tab is bound by default to switch tabs in obsidian and chrome note that this will not affect macos users, as super still binds to cmd
469 lines
16 KiB
JavaScript
469 lines
16 KiB
JavaScript
/**
|
|
* obsidian-tray v0.3.4
|
|
* (c) 2023 dragonwocky <thedragonring.bod@gmail.com> (https://dragonwocky.me/)
|
|
* (https://github.com/dragonwocky/obsidian-tray/) under the MIT license
|
|
*/
|
|
|
|
"use strict";
|
|
|
|
const LOG_PREFIX = "obsidian-tray",
|
|
LOG_LOADING = "loading",
|
|
LOG_CLEANUP = "cleaning up",
|
|
LOG_SHOWING_WINDOWS = "showing windows",
|
|
LOG_HIDING_WINDOWS = "hiding windows",
|
|
LOG_WINDOW_CLOSE = "intercepting window close",
|
|
LOG_TRAY_ICON = "creating tray icon",
|
|
LOG_REGISTER_HOTKEY = "registering hotkey",
|
|
LOG_UNREGISTER_HOTKEY = "unregistering hotkey",
|
|
ACTION_QUICK_NOTE = "Quick Note",
|
|
ACTION_SHOW = "Show Vault",
|
|
ACTION_HIDE = "Hide Vault",
|
|
ACTION_RELAUNCH = "Relaunch Obsidian",
|
|
ACTION_CLOSE = "Close Vault",
|
|
DEFAULT_DATE_FORMAT = "YYYY-MM-DD",
|
|
ACCELERATOR_FORMAT = `
|
|
This hotkey is registered globally and will be detected even if Obsidian does
|
|
not have keyboard focus. Format:
|
|
<a href="https://www.electronjs.org/docs/latest/api/accelerator" target="_blank" rel="noopener">
|
|
Electron accelerator</a>
|
|
`,
|
|
MOMENT_FORMAT = `
|
|
Format:
|
|
<a href="https://momentjs.com/docs/#/displaying/format/" target="_blank" rel="noopener">
|
|
Moment.js format string</a>
|
|
`,
|
|
// 16x16 base64 obsidian icon: generated from obsidian.asar/icon.png
|
|
OBSIDIAN_BASE64_ICON = `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAHZSURBVDhPlZKxTxRBFMa/XZcF7nIG7mjxjoRCwomJxgsFdhaASqzQxFDzB1AQKgstLGxIiBQGJBpiCCGx8h+wgYaGgAWNd0dyHofeEYVwt/PmOTMZV9aDIL/s5pvZvPfN9yaL/+HR3eXcypta0m4juFbP5GHuXc9IbunDFc9db/G81/ZzhDMN7g8td47mll4R5BfHwZN4LOaA+fHa259PbUmIYzWkt3e2NZNo3/V9v1vvU6kkstk+tLW3ItUVr/m+c3N8MlkwxYqmBFcbwUQQCNOcyVzDwEAWjuPi5DhAMV/tKOYPX5hCyz8Gz1zX5SmWjBvZfmTSaRBJkGAIoxJHv+pVW2yIGNxOJ8bUVNcFEWLxuG1ia6JercTbttwQTeDwPS0kCMXiXtgk/jQrFUw7ptYSMWApF40yo/ytjHq98fdk3ayVE+cn2CxMb6ruz9qAJKFUKoWza1VJSi/n0+ffgYHdWW2gHuxXymg0gjCB0sjpmiaDnkL3RzDyzLqBUKns2ztQqUR0fk2TwSrGSf1eczqF5vsPZRCQSSAFLk6gqctgQRkc6TWRQLV2YMYQki9OoNkqzFQ9r+WOGuW5CrJbOzyAlPKr6MSGLbkcDwbf35oY/jRkt6cAfgNwowruAMz9AgAAAABJRU5ErkJggg==`,
|
|
log = (message) => console.log(`${LOG_PREFIX}: ${message}`);
|
|
|
|
let tray, plugin;
|
|
const obsidian = require("obsidian"),
|
|
{ app, Tray, Menu } = require("electron").remote,
|
|
{ nativeImage, BrowserWindow } = require("electron").remote,
|
|
{ getCurrentWindow, globalShortcut } = require("electron").remote;
|
|
|
|
const vaultWindows = new Set(),
|
|
maximizedWindows = new Set(),
|
|
getWindows = () => [...vaultWindows],
|
|
observeWindows = () => {
|
|
const onWindowCreation = (win) => {
|
|
vaultWindows.add(win);
|
|
win.setSkipTaskbar(plugin.settings.hideTaskbarIcon);
|
|
win.on("close", () => {
|
|
if (win !== getCurrentWindow()) vaultWindows.delete(win);
|
|
});
|
|
// preserve maximised windows after minimisation
|
|
if (win.isMaximized()) maximizedWindows.add(win);
|
|
win.on("maximize", () => maximizedWindows.add(win));
|
|
win.on("unmaximize", () => maximizedWindows.delete(win));
|
|
};
|
|
onWindowCreation(getCurrentWindow());
|
|
getCurrentWindow().webContents.on("did-create-window", onWindowCreation);
|
|
},
|
|
showWindows = () => {
|
|
log(LOG_SHOWING_WINDOWS);
|
|
getWindows().forEach((win) => {
|
|
maximizedWindows.has(win) ? win.maximize() : win.show();
|
|
});
|
|
},
|
|
hideWindows = () => {
|
|
log(LOG_HIDING_WINDOWS);
|
|
getWindows().forEach((win) => [
|
|
win.isFocused() && win.blur(),
|
|
plugin.settings.runInBackground ? win.hide() : win.minimize(),
|
|
]);
|
|
},
|
|
toggleWindows = (checkForFocus = true) => {
|
|
const openWindows = getWindows().some((win) => {
|
|
return (!checkForFocus || win.isFocused()) && win.isVisible();
|
|
});
|
|
if (openWindows) hideWindows();
|
|
else showWindows();
|
|
};
|
|
|
|
const onWindowClose = (event) => event.preventDefault(),
|
|
onWindowUnload = (event) => {
|
|
log(LOG_WINDOW_CLOSE);
|
|
getCurrentWindow().hide();
|
|
event.stopImmediatePropagation();
|
|
// setting return value manually is more reliable than
|
|
// via `return false` according to electron
|
|
event.returnValue = false;
|
|
},
|
|
interceptWindowClose = () => {
|
|
// intercept in renderer
|
|
window.addEventListener("beforeunload", onWindowUnload, true);
|
|
// intercept in main: is asynchronously executed when registered
|
|
// from renderer, so won't prevent close by itself, but counteracts
|
|
// the 3-second delayed window force close in obsidian.asar/main.js
|
|
getCurrentWindow().on("close", onWindowClose);
|
|
},
|
|
allowWindowClose = () => {
|
|
getCurrentWindow().removeListener("close", onWindowClose);
|
|
window.removeEventListener("beforeunload", onWindowUnload, true);
|
|
};
|
|
|
|
const hideTaskbarIcons = () => {
|
|
getWindows().forEach((win) => win.setSkipTaskbar(true));
|
|
if (process.platform === "darwin") app.dock.hide();
|
|
},
|
|
showTaskbarIcons = () => {
|
|
getWindows().forEach((win) => win.setSkipTaskbar(false));
|
|
if (process.platform === "darwin") app.dock.show();
|
|
};
|
|
|
|
const setLaunchOnStartup = () => {
|
|
const { launchOnStartup, runInBackground, hideOnLaunch } = plugin.settings;
|
|
app.setLoginItemSettings({
|
|
openAtLogin: launchOnStartup,
|
|
openAsHidden: runInBackground && hideOnLaunch,
|
|
});
|
|
},
|
|
relaunchApp = () => {
|
|
app.relaunch();
|
|
app.exit(0);
|
|
},
|
|
closeVault = () => {
|
|
log(LOG_CLEANUP);
|
|
unregisterHotkeys();
|
|
allowWindowClose();
|
|
destroyTray();
|
|
for (const win of getWindows()) win.destroy();
|
|
// force app quit if no windows remain on macos
|
|
if (!BrowserWindow.getAllWindows().length) app.quit();
|
|
};
|
|
|
|
const addQuickNote = () => {
|
|
const { quickNoteLocation, quickNoteDateFormat } = plugin.settings,
|
|
pattern = quickNoteDateFormat || DEFAULT_DATE_FORMAT,
|
|
date = obsidian.moment().format(pattern),
|
|
name = obsidian
|
|
.normalizePath(`${quickNoteLocation ?? ""}/${date}`)
|
|
.replace(/\*|"|\\|<|>|:|\||\?/g, "-"),
|
|
// manually create and open file instead of depending
|
|
// on createAndOpenMarkdownFile to force file creation
|
|
// relative to the root instead of the active file
|
|
// (in case user has default location for new notes
|
|
// set to "same folder as current file")
|
|
leaf = plugin.app.workspace.getLeaf(),
|
|
root = plugin.app.fileManager.getNewFileParent(""),
|
|
openMode = { active: true, state: { mode: "source" } };
|
|
plugin.app.fileManager
|
|
.createNewMarkdownFile(root, name)
|
|
.then((file) => leaf.openFile(file, openMode));
|
|
showWindows();
|
|
},
|
|
replaceVaultName = (str) => {
|
|
return str.replace(/{{vault}}/g, plugin.app.vault.getName());
|
|
},
|
|
destroyTray = () => {
|
|
tray?.destroy();
|
|
tray = undefined;
|
|
},
|
|
createTrayIcon = () => {
|
|
destroyTray();
|
|
if (!plugin.settings.createTrayIcon) return;
|
|
log(LOG_TRAY_ICON);
|
|
const obsidianIcon = nativeImage.createFromDataURL(
|
|
plugin.settings.trayIconImage ?? OBSIDIAN_BASE64_ICON
|
|
),
|
|
contextMenu = Menu.buildFromTemplate([
|
|
{
|
|
type: "normal",
|
|
label: ACTION_QUICK_NOTE,
|
|
accelerator: plugin.settings.quickNoteHotkey,
|
|
click: addQuickNote,
|
|
},
|
|
{
|
|
type: "normal",
|
|
label: ACTION_SHOW,
|
|
accelerator: plugin.settings.toggleWindowFocusHotkey,
|
|
click: showWindows,
|
|
},
|
|
{
|
|
type: "normal",
|
|
label: ACTION_HIDE,
|
|
accelerator: plugin.settings.toggleWindowFocusHotkey,
|
|
click: hideWindows,
|
|
},
|
|
{ type: "separator" },
|
|
{ label: ACTION_RELAUNCH, click: relaunchApp },
|
|
{ label: ACTION_CLOSE, click: closeVault },
|
|
]);
|
|
tray = new Tray(obsidianIcon);
|
|
tray.setContextMenu(contextMenu);
|
|
tray.setToolTip(replaceVaultName(plugin.settings.trayIconTooltip));
|
|
tray.on("click", () => {
|
|
if (process.platform === "darwin") {
|
|
// macos does not register separate left/right click actions
|
|
// for menu items, icon should open menu w/out causing toggle
|
|
tray.popUpContextMenu();
|
|
} else toggleWindows(false);
|
|
});
|
|
};
|
|
|
|
const registerHotkeys = () => {
|
|
log(LOG_REGISTER_HOTKEY);
|
|
try {
|
|
const { toggleWindowFocusHotkey, quickNoteHotkey } = plugin.settings;
|
|
if (toggleWindowFocusHotkey) {
|
|
globalShortcut.register(toggleWindowFocusHotkey, toggleWindows);
|
|
}
|
|
if (quickNoteHotkey) {
|
|
globalShortcut.register(quickNoteHotkey, addQuickNote);
|
|
}
|
|
} catch {}
|
|
},
|
|
unregisterHotkeys = () => {
|
|
log(LOG_UNREGISTER_HOTKEY);
|
|
try {
|
|
globalShortcut.unregister(plugin.settings.toggleWindowFocusHotkey);
|
|
globalShortcut.unregister(plugin.settings.quickNoteHotkey);
|
|
} catch {}
|
|
};
|
|
|
|
const OPTIONS = [
|
|
"Window management",
|
|
{
|
|
key: "launchOnStartup",
|
|
desc: "Open Obsidian automatically whenever you log into your computer.",
|
|
type: "toggle",
|
|
default: false,
|
|
onChange: setLaunchOnStartup,
|
|
},
|
|
{
|
|
key: "hideOnLaunch",
|
|
desc: `
|
|
Minimises Obsidian automatically whenever the app is launched. If the
|
|
"Run in background" option is enabled, windows will be hidden to the system
|
|
tray/menubar instead of minimised to the taskbar/dock.
|
|
`,
|
|
type: "toggle",
|
|
default: false,
|
|
},
|
|
{
|
|
key: "runInBackground",
|
|
desc: `
|
|
Hides the app and continues to run it in the background instead of quitting
|
|
it when pressing the window close button or toggle focus hotkey.
|
|
`,
|
|
type: "toggle",
|
|
default: false,
|
|
onChange() {
|
|
setLaunchOnStartup();
|
|
if (plugin.settings.runInBackground) interceptWindowClose();
|
|
else [allowWindowClose(), showWindows()];
|
|
},
|
|
},
|
|
{
|
|
key: "hideTaskbarIcon",
|
|
desc: `
|
|
Hides the window's icon from from the dock/taskbar. Enabling the tray icon first
|
|
is recommended if using this option. This may not work on Linux-based OSes.
|
|
`,
|
|
type: "toggle",
|
|
default: false,
|
|
onChange() {
|
|
if (plugin.settings.hideTaskbarIcon) hideTaskbarIcons();
|
|
else showTaskbarIcons();
|
|
},
|
|
},
|
|
{
|
|
key: "createTrayIcon",
|
|
desc: `
|
|
Adds an icon to your system tray/menubar to bring hidden Obsidian windows
|
|
back into focus on click or force a full quit/relaunch of the app through
|
|
the right-click menu.
|
|
`,
|
|
type: "toggle",
|
|
default: true,
|
|
onChange: createTrayIcon,
|
|
},
|
|
{
|
|
key: "trayIconImage",
|
|
desc: `
|
|
Set the image used by the tray/menubar icon. Recommended size: 16x16
|
|
<br>Preview: <img data-preview style="height: 16px; vertical-align: bottom;">
|
|
`,
|
|
type: "image",
|
|
default: OBSIDIAN_BASE64_ICON,
|
|
onChange: createTrayIcon,
|
|
},
|
|
{
|
|
key: "trayIconTooltip",
|
|
desc: `
|
|
Set a title to identify the tray/menubar icon by. The
|
|
<code>{{vault}}</code> placeholder will be replaced by the vault name.
|
|
<br>Preview: <b class="u-pop" data-preview></b>
|
|
`,
|
|
type: "text",
|
|
default: "{{vault}} | Obsidian",
|
|
postprocessor: replaceVaultName,
|
|
onChange: createTrayIcon,
|
|
},
|
|
{
|
|
key: "toggleWindowFocusHotkey",
|
|
desc: ACCELERATOR_FORMAT,
|
|
type: "hotkey",
|
|
default: "Super+Shift+Tab",
|
|
onBeforeChange: unregisterHotkeys,
|
|
onChange: registerHotkeys,
|
|
},
|
|
"Quick notes",
|
|
{
|
|
key: "quickNoteLocation",
|
|
desc: "New quick notes will be placed in this folder.",
|
|
type: "text",
|
|
placeholder: "Example: notes/quick",
|
|
},
|
|
{
|
|
key: "quickNoteDateFormat",
|
|
desc: `
|
|
New quick notes will use a filename of this pattern. ${MOMENT_FORMAT}
|
|
<br>Preview: <b class="u-pop" data-preview></b>
|
|
`,
|
|
type: "moment",
|
|
default: DEFAULT_DATE_FORMAT,
|
|
},
|
|
{
|
|
key: "quickNoteHotkey",
|
|
desc: ACCELERATOR_FORMAT,
|
|
type: "hotkey",
|
|
default: "Super+Shift+Q",
|
|
onBeforeChange: unregisterHotkeys,
|
|
onChange: registerHotkeys,
|
|
},
|
|
];
|
|
|
|
const keyToLabel = (key) =>
|
|
key[0].toUpperCase() +
|
|
key
|
|
.slice(1)
|
|
.split(/(?=[A-Z])/)
|
|
.map((word) => word.toLowerCase())
|
|
.join(" "),
|
|
htmlToFragment = (html) =>
|
|
document
|
|
.createRange()
|
|
.createContextualFragment((html ?? "").replace(/\s+/g, " "));
|
|
|
|
class SettingsTab extends obsidian.PluginSettingTab {
|
|
display() {
|
|
this.containerEl.empty();
|
|
for (const opt of OPTIONS) {
|
|
const setting = new obsidian.Setting(this.containerEl);
|
|
if (typeof opt === "string") {
|
|
setting.setName(opt);
|
|
setting.setHeading();
|
|
} else {
|
|
if (opt.default) opt.placeholder ??= `Example: ${opt.default}`;
|
|
setting.setName(keyToLabel(opt.key));
|
|
setting.setDesc(htmlToFragment(opt.desc));
|
|
const onChange = async (value) => {
|
|
await opt.onBeforeChange?.();
|
|
plugin.settings[opt.key] = value;
|
|
await plugin.saveSettings();
|
|
await opt.onChange?.();
|
|
};
|
|
|
|
const value = plugin.settings[opt.key] ?? opt.default ?? "";
|
|
if (opt.type === "toggle") {
|
|
setting.addToggle((toggle) => {
|
|
toggle.setValue(value).onChange(onChange);
|
|
});
|
|
} else if (opt.type === "image") {
|
|
const previewImg = setting.descEl.querySelector("img[data-preview");
|
|
if (previewImg) previewImg.src = value;
|
|
const fileUpload = setting.descEl.createEl("input");
|
|
fileUpload.style.visibility = "hidden";
|
|
fileUpload.type = "file";
|
|
fileUpload.onchange = (event) => {
|
|
const file = event.target.files[0],
|
|
reader = new FileReader();
|
|
reader.onloadend = () => {
|
|
onChange(reader.result);
|
|
if (previewImg) previewImg.src = reader.result;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
};
|
|
setting.addButton((button) => {
|
|
button.setIcon("image").onClick(() => fileUpload.click());
|
|
});
|
|
} else if (opt.type === "moment") {
|
|
setting.addMomentFormat((moment) => {
|
|
const previewEl = setting.descEl.querySelector("[data-preview]");
|
|
if (previewEl) moment.setSampleEl(previewEl);
|
|
moment
|
|
.setPlaceholder(opt.placeholder)
|
|
.setDefaultFormat(opt.default ?? "")
|
|
.setValue(value)
|
|
.onChange(onChange);
|
|
});
|
|
} else {
|
|
const previewEl = setting.descEl.querySelector("[data-preview]"),
|
|
updatePreview = (value) => {
|
|
if (!previewEl) return;
|
|
previewEl.innerText = opt?.postprocessor(value) ?? value;
|
|
};
|
|
updatePreview(value);
|
|
setting.addText((text) => {
|
|
text
|
|
.setPlaceholder(opt.placeholder)
|
|
.setValue(value)
|
|
.onChange((value) => [onChange(value), updatePreview(value)]);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
class TrayPlugin extends obsidian.Plugin {
|
|
async onload() {
|
|
log(LOG_LOADING);
|
|
await this.loadSettings();
|
|
this.addSettingTab(new SettingsTab(this.app, this));
|
|
const { settings } = this;
|
|
|
|
plugin = this;
|
|
createTrayIcon();
|
|
registerHotkeys();
|
|
setLaunchOnStartup();
|
|
observeWindows();
|
|
if (settings.runInBackground) interceptWindowClose();
|
|
if (settings.hideTaskbarIcon) hideTaskbarIcons();
|
|
if (settings.hideOnLaunch) {
|
|
this.registerEvent(this.app.workspace.onLayoutReady(hideWindows));
|
|
}
|
|
|
|
// add as command: can be called from command palette
|
|
// and can have non-global hotkey assigned via in-app menu
|
|
this.addCommand({
|
|
id: "relaunch-app",
|
|
name: ACTION_RELAUNCH,
|
|
callback: relaunchApp,
|
|
});
|
|
this.addCommand({
|
|
id: "close-vault",
|
|
name: ACTION_CLOSE,
|
|
callback: closeVault,
|
|
});
|
|
}
|
|
onunload() {
|
|
log(LOG_CLEANUP);
|
|
unregisterHotkeys();
|
|
allowWindowClose();
|
|
showTaskbarIcons();
|
|
destroyTray();
|
|
}
|
|
|
|
async loadSettings() {
|
|
const DEFAULT_SETTINGS = OPTIONS.map((opt) => ({ [opt.key]: opt.default }));
|
|
this.settings = Object.assign(...DEFAULT_SETTINGS, await this.loadData());
|
|
}
|
|
async saveSettings() {
|
|
await this.saveData(this.settings);
|
|
}
|
|
}
|
|
module.exports = TrayPlugin;
|