Skip to content

Commit

Permalink
Merge pull request #43 from skoro/4-feat-recent-copied-items-in-tray-…
Browse files Browse the repository at this point in the history
…menu

[Feat] Recent copied items in tray menu
  • Loading branch information
skoro authored Dec 21, 2024
2 parents b82f965 + 01b12ce commit f155a6e
Show file tree
Hide file tree
Showing 11 changed files with 207 additions and 36 deletions.
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

0 comments on commit f155a6e

Please sign in to comment.