Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feat] Recent copied items in tray menu #43

Merged
merged 11 commits into from
Dec 21, 2024
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* Preview text and images.
* Responsive by window size: 1, 2, 3, 4 columns.
* Search text items.
* Access clipboard from the application tray menu.
* Available for Windows and Linux.

## App
Expand Down
35 changes: 23 additions & 12 deletions src/main/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { clipboardEventEmitter } from './clipboard';
import { keyboard } from '../renderer/keyshortcuts';
import {
setStartAppAtLogin, isPlatformLinux, isPlatformDarwin, saveImage, saveText, quitApp,
updateTrayContextMenu,
} from './system';

// Handle creating/removing shortcuts on Windows when installing/uninstalling.
Expand All @@ -15,9 +16,20 @@ if (require('electron-squirrel-startup')) {
app.quit();
}

class AppBrowserWindow extends BrowserWindow {
/** @type {?Electron.Menu} */
trayContextMenu = null;
/** @type {?Electron.Tray} */
tray = null;

toggleVisibility() {
this.isVisible() ? this.hide() : this.show();
};
}

const createMainWindow = () => {
// Create the browser window.
const mainWindow = new BrowserWindow({
const mainWindow = new AppBrowserWindow({
width: 380,
height: 640,
minWidth: 260,
Expand Down Expand Up @@ -70,28 +82,23 @@ const createMainWindow = () => {
ipcMain.on('open:url', (_, url) => shell.openExternal(url));
ipcMain.on('save:image', (_, image) => saveImage(mainWindow, image));
ipcMain.on('save:text', (_, text) => saveText(mainWindow, text));
// updates clipboard items in tray context menu
ipcMain.on('clipboard:top', (_, clips) => updateTrayContextMenu(mainWindow.tray, mainWindow.trayContextMenu, clips));

return mainWindow;
};

// Create and setup the application tray icon.
/**
* @param {BrowserWindow} mainWindow
* @param {AppBrowserWindow} mainWindow
*/
const createTrayIcon = (mainWindow) => {
const icon = nativeImage.createFromPath(path.join(__dirname, '..', '..', 'resources', 'icon.png'));
const tray = new Tray(icon);

const showHideCallback = () => {
if (mainWindow.isVisible()) {
mainWindow.hide();
} else {
mainWindow.show();
}
};

const contextMenu = Menu.buildFromTemplate([
{
id: 'quit',
label: 'Quit',
click: quitApp,
},
Expand All @@ -101,16 +108,20 @@ const createTrayIcon = (mainWindow) => {
// There is a menu item does the same as clicking on the icon to show/hide the main window.
if (isPlatformLinux()) {
contextMenu.insert(0, new MenuItem({
id: 'lnx-show-toggle',
label: 'Show/Hide',
click: () => showHideCallback(),
click: () => mainWindow.toggleVisibility(),
}));
contextMenu.insert(1, new MenuItem({ type: 'separator' }));
}

tray.setContextMenu(contextMenu);
tray.setToolTip('Pasted');

tray.on('click', () => showHideCallback());
tray.on('click', () => mainWindow.toggleVisibility());

mainWindow.trayContextMenu = contextMenu;
mainWindow.tray = tray;
};

/**
Expand Down
71 changes: 69 additions & 2 deletions src/main/system.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { app, dialog, nativeImage } from 'electron';
import {
app, dialog, nativeImage, MenuItem, Menu,
} from 'electron';
import path from 'node:path';
import { writeFile } from 'node:fs/promises';
import * as linux from './linux';
import { clipboardEventEmitter } from './clipboard';

/**
* @returns {boolean}
Expand All @@ -25,12 +28,14 @@ function isPlatformDarwin() {
}

/**
* Sets or unsets starting the application at the user login.
*
* @param {boolean} open Should the app opened at login.
*/
async function setStartAppAtLogin(open) {
if (isPlatformLinux()) {
// ElectronJS does not support yet open application at login on Linux.
// There is be a custom solution of openAtLogin.
// There is a custom solution of openAtLogin.
await (open ? linux.enableAutostart() : linux.disableAutostart());
} else {
app.setLoginItemSettings({
Expand All @@ -39,7 +44,11 @@ async function setStartAppAtLogin(open) {
}
}

/**
* Quits the application.
*/
function quitApp() {
// isQuiting is a dynamic property. It is preventing quit the app in the closing window.
app.isQuiting = true;
app.quit();
}
Expand Down Expand Up @@ -150,6 +159,63 @@ async function saveText(parentWindow, text, filename) {
}
}

/**
* Updates the application tray icon menu.
*
* @param {Electron.Tray} tray
* @param {Electron.Menu} contextMenu The tray context menu.
* @param {import('../models/clip').Model[]} [clipItems] Appends the clipboard items to the context menu.
* @returns {Electron.Menu} The modified context menu.
*/
function updateTrayContextMenu(tray, contextMenu, clipItems) {
const ITEM_PREFIX = 'clipboard--';
// Remove previously added clipboard items.
const menuItems = contextMenu.items.filter((item) => ! item.id?.startsWith(ITEM_PREFIX));

contextMenu = Menu.buildFromTemplate(menuItems);

if (clipItems?.length > 0) {
// Inserting at 0 like unshift() on arrays - adding an item to the head of the array.
contextMenu.insert(0, new MenuItem({
id: `${ITEM_PREFIX}sep`,
type: 'separator',
}));

for (const clipItem of clipItems) {
const label = clipItem.image ? '[IMAGE]' : stringCut(clipItem.data, 50);
contextMenu.insert(0, new MenuItem({
id: `${ITEM_PREFIX}item-${clipItem.id}`,
label,
click: () => clipboardEventEmitter.copy(clipItem),
}));
}
}

// In order for changes made to individual MenuItems to take effect, you have to call setContextMenu again.
// https://www.electronjs.org/docs/latest/api/tray
tray.setContextMenu(contextMenu);

return contextMenu;
}

/**
* Cuts a string to the specified limit of characters.
*
* @param {string} str
* @param {number} limit A desired string limit.
* @param {string} [trail='...'] A trail appended to the end of the string longer than limit.
* @return {string}
*/
function stringCut(str, limit, trail = '...') {
const cutStr = str.trim().split('\n')[0].trim();

if (cutStr.length <= limit) {
return cutStr;
}

return cutStr.slice(0, limit) + trail;
}

export {
isPlatformLinux,
isPlatformWindows,
Expand All @@ -158,4 +224,5 @@ export {
saveImage,
saveText,
quitApp,
updateTrayContextMenu,
};
52 changes: 48 additions & 4 deletions src/preload/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,59 @@ contextBridge.exposeInMainWorld('versions', {
electron: () => process.versions.electron,
});

/**
* @callback onClipboardNew
*
* @callback removeClipModel
* @param {string} clipModelId
*
* @callback selectClipModel
* @param {import('../models/clip').Model} clipModel
*
* @callback clearList
*
* @callback willShowWindow
*
* @callback willHideWindow
*
* @callback changeStartAtLogin
* @param {boolean} openAtLogin
*
* @callback openUrl
* @param {string} url
*
* @callback saveImage
* @param {string} data
*
* @callback saveText
* @param {string} text
*
* @callback clipboardTop
* @param {import('../models/clip').Model[]} clips
*
* @typedef {Object} electronAPI
* @property {onClipboardNew} onClipboardNew
* @property {removeClipModel} removeClipModel
* @property {selectClipModel} selectClipModel
* @property {clearList} clearList
* @property {willShowWindow} willShowWindow
* @property {willHideWindow} willHideWindow
* @property {changeStartAtLogin} changeStartAtLogin
* @property {openUrl} openUrl
* @property {saveImage} saveImage
* @property {saveText} saveText
* @property {clipboardTop} clipboardTop
*/
contextBridge.exposeInMainWorld('electronAPI', {
onClipboardNew: (callback) => ipcRenderer.on('clipboard:new', (_event, clipModel) => callback(clipModel)),
removeClipModel: (clipModelId) => ipcRenderer.send('clip:remove', clipModelId),
selectClipModel: (clipModel) => ipcRenderer.send('clip:select', clipModel),
clearList: () => ipcRenderer.send('clear:list'),
willShowWindow: () => ipcRenderer.send('will-show-window'),
willHideWindow: () => ipcRenderer.send('will-hide-window'),
changeStartAtLogin: (value) => ipcRenderer.send('pref:startAtLogin', value),
openUrl: (value) => ipcRenderer.send('open:url', value),
saveImage: (value) => ipcRenderer.send('save:image', value),
saveText: (value) => ipcRenderer.send('save:text', value),
changeStartAtLogin: (openAtLogin) => ipcRenderer.send('pref:startAtLogin', openAtLogin),
openUrl: (url) => ipcRenderer.send('open:url', url),
saveImage: (data) => ipcRenderer.send('save:image', data),
saveText: (text) => ipcRenderer.send('save:text', text),
clipboardTop: (clips) => ipcRenderer.send('clipboard:top', clips),
});
10 changes: 6 additions & 4 deletions src/renderer/components/ClipboardItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const props = defineProps({

const emit = defineEmits(['peek-item', 'qr-item']);

/** @type {{electronAPI: import('../../preload/preload').electronAPI}} */
const { electronAPI } = window;
const isCopied = ref(false);
const clipboardStore = useClipboardStore();
const context = shallowRef(ClipboardItemView);
Expand Down Expand Up @@ -44,7 +46,7 @@ function onCopyItem() {
setTimeout(() => {
isCopied.value = false;
}, 800);
window.electronAPI.selectClipModel(toRaw(props.clip));
electronAPI.selectClipModel(toRaw(props.clip));
setViewContext();
}

Expand All @@ -58,7 +60,7 @@ function onRemoveItem() {
|| (props.clip.starred && confirm('Are you sure you want to remove ?'))) {
clipboardStore.remove(props.clip.id);
// FIXME: there should be original object, vue proxied object cannot be cloned by ipc
window.electronAPI.removeClipModel(props.clip.id);
electronAPI.removeClipModel(props.clip.id);
}
}

Expand All @@ -73,12 +75,12 @@ function onQrItem() {
}

function onOpenUrl() {
window.electronAPI.openUrl(props.clip.data);
electronAPI.openUrl(props.clip.data);
}

function onSaveItem() {
const method = props.clip.image ? 'saveImage' : 'saveText';
window.electronAPI[method](props.clip.data);
electronAPI[method](props.clip.data);
}
</script>

Expand Down
8 changes: 5 additions & 3 deletions src/renderer/components/pages/ItemViewerPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ const props = defineProps({
},
});

/** @type {{electronAPI: import('../../../preload/preload').electronAPI}} */
const { electronAPI } = window;
const emit = defineEmits(['open-page', 'close-page']);

const clipboard = useClipboardStore();
Expand All @@ -36,14 +38,14 @@ function closePage() {
}

function copyClip() {
window.electronAPI.selectClipModel(toRaw(props.clip));
electronAPI.selectClipModel(toRaw(props.clip));
closePage();
}

function removeClip() {
if (confirm('Are you sure you want to remove ?')) {
clipboard.remove(props.clip.id);
window.electronAPI.removeClipModel(props.clip.id);
electronAPI.removeClipModel(props.clip.id);
closePage();
}
}
Expand All @@ -54,7 +56,7 @@ function toggleStarred() {

function saveClip() {
const method = props.clip.image ? 'saveImage' : 'saveText';
window.electronAPI[method](props.clip.data);
electronAPI[method](props.clip.data);
}

/**
Expand Down
6 changes: 4 additions & 2 deletions src/renderer/components/pages/PreferencesPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { version as appVersion, homepage } from '../../../../package.json';

const emit = defineEmits(['close-page']);

/** @type {{electronAPI: import('../../../preload/preload').electronAPI}} */
const { electronAPI } = window;
const prefs = usePreferencesStore();

function closePage() {
Expand All @@ -21,15 +23,15 @@ function closePage() {
* @param {string} url
*/
function openUrl(url) {
window.electronAPI.openUrl(url);
electronAPI.openUrl(url);
}

onMounted(() => {
bindEscKey(closePage);
});

onUnmounted(() => {
window.electronAPI.changeStartAtLogin(prefs.startAtLogin);
electronAPI.changeStartAtLogin(prefs.startAtLogin);
});
</script>

Expand Down
17 changes: 17 additions & 0 deletions src/renderer/plugins/plugin-clipboard-top.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// This plugin sends 'clipboard:top' ipc event
// after mutating actions on Clipboard store.

/** @type {{electronAPI: import("../../preload/preload").electronAPI}} */
const { electronAPI } = window;
// The list of actions mutate the store.
const mutateActions = ['clear', 'remove', 'moveToTop', 'put'];

export function pluginClipboardTop({ store }) {
store.$onAction(({ name, store, after }) => {
if (store.$id === 'clips' && mutateActions.indexOf(name) !== -1) {
after(() => {
electronAPI.clipboardTop(store.top());
});
}
});
}
Loading