diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..9608d30
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,12 @@
+root = true
+
+[*]
+indent_style = space
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+[*.yml]
+indent_style = space
+indent_size = 2
diff --git a/.eslintrc b/.eslintrc
deleted file mode 100644
index 09e5578..0000000
--- a/.eslintrc
+++ /dev/null
@@ -1,42 +0,0 @@
-{
- "extends": "google",
- "plugins":[
- "html"
- ],
- "env": {
- "browser": true
- },
- "parserOptions": {
- "sourceType": "module",
- "ecmaVersion": 2017,
- "ecmaFeatures": {
- "modules": true
- }
- },
- "rules": {
- "max-len": [2, 100, {
- "ignoreComments": true,
- "ignoreUrls": true,
- "tabWidth": 2
- }],
- "no-implicit-coercion": [2, {
- "boolean": false,
- "number": true,
- "string": true
- }],
- "no-unused-expressions": [2, {
- "allowShortCircuit": true,
- "allowTernary": false
- }],
- "no-unused-vars": [2, {
- "vars": "all",
- "args": "after-used",
- "argsIgnorePattern": "(^reject$|^_$)",
- "varsIgnorePattern": "(^_$)"
- }],
- "quotes": [2, "single"],
- "require-jsdoc": 0,
- "valid-jsdoc": 0,
- "arrow-parens": 0
- }
-}
diff --git a/.gitignore b/.gitignore
index 3f808e8..7b21b03 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,6 @@ node_modules
dist
npm-debug.log
.vscode
+
+build/*
+!build/assets
diff --git a/README.md b/README.md
index d316561..edbf256 100644
--- a/README.md
+++ b/README.md
@@ -6,42 +6,28 @@
# Clippy
-> A clipboard manager built on [electron](https://github.com/electron/electron/), using [WebComponents](https://developer.mozilla.org/en-US/docs/Web/Web_Components)
-## Installation
-_Linux, and Windows 7+ are supported (64-bit only)_
+> A simple clipboard manager built on [electron](https://github.com/electron/electron/)
-### Linux
-[**Download**](https://github.com/ramlmn/electron-clippy/releases/latest) the `.AppImage` or `.deb` file
-_The AppImage needs to be [made executable](http://discourse.appimage.org/t/how-to-make-an-appimage-executable/80) after download_
+## Screenshots
+![Snippet view](media/screenshot-1.png)
+![Image view](media/screenshot-2.png)
+![Settings view](media/screenshot-3.png)
-### Windows
-[**Download**](https://github.com/ramlmn/electron-clippy/releases/latest) the `.exe` file
-## Usage
-By default the app would be running in the background and can be accessed by the keyboard shortcut: `Ctrl+Shift+V`
+## Installation
-## Dev
-### Run
-```
-$ npm install && npm start
-```
+Prebuilt images can be found on the [releases](https://github.com/ramlmn/electron-clippy/releases/latest) page
-### Build
-```
-$ npm run build
-```
-or check [`electron-builder` docs](https://www.electron.build/multi-platform-build)
-## Acknowledgements
-* UI inspired from [Alfred app](https://www.alfredapp.com/)
-* Custom elements source inspired from [Polymer](https://github.com/Polymer/polymer)
-* Installer icon inspired from icon made by [Roundicons](http://www.flaticon.com/authors/roundicons) from [www.flaticon.com](http://www.flaticon.com) is licensed by [CC 3.0 BY](https://creativecommons.org/licenses/by/3.0/) (_modified_)
+## Usage/features
+
+- Persistent data storage across restarts (disabled by default)
+- Delete individual items using the Delete key
+- Clear whole clipboard at once
+- Bring the app to forground the keyboard shortcut Ctrl/Command + Shift + V
-## Screenshots
-![Clippy screenshot snippet](media/clippy-snap1.png)
-![Clippy screenshot image](media/clippy-snap2.png)
## License
[MIT](LICENSE)
diff --git a/src/build-assets/icon/png/128x128.png b/build/assets/icon/png/128x128.png
similarity index 100%
rename from src/build-assets/icon/png/128x128.png
rename to build/assets/icon/png/128x128.png
diff --git a/src/build-assets/icon/win/win.ico b/build/assets/icon/win/win.ico
similarity index 100%
rename from src/build-assets/icon/win/win.ico
rename to build/assets/icon/win/win.ico
diff --git a/media/clippy-snap1.png b/media/clippy-snap1.png
deleted file mode 100644
index a972ec6..0000000
Binary files a/media/clippy-snap1.png and /dev/null differ
diff --git a/media/clippy-snap2.png b/media/clippy-snap2.png
deleted file mode 100644
index fc6ebf0..0000000
Binary files a/media/clippy-snap2.png and /dev/null differ
diff --git a/media/screenshot-1.png b/media/screenshot-1.png
new file mode 100644
index 0000000..33fefb3
Binary files /dev/null and b/media/screenshot-1.png differ
diff --git a/media/screenshot-2.png b/media/screenshot-2.png
new file mode 100644
index 0000000..f01beaf
Binary files /dev/null and b/media/screenshot-2.png differ
diff --git a/media/screenshot-3.png b/media/screenshot-3.png
new file mode 100644
index 0000000..f928e41
Binary files /dev/null and b/media/screenshot-3.png differ
diff --git a/package.json b/package.json
index 6b556b0..ca5abcc 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
- "name": "clippy",
- "version": "1.0.0-alpha.4",
- "description": "A clipboard manager built on electron",
+ "name": "electron-clippy",
+ "version": "0.1.0",
+ "description": "A simple clipboard manager build on Electron",
"license": "MIT",
"repository": "ramlmn/electron-clippy",
"author": {
@@ -9,40 +9,62 @@
"email": "ramlmn@outlook.com",
"url": "https://ramlmn.github.io/"
},
- "homepage": "https://github.com/ramlmn/electron-clippy",
- "main": "./src/main/main.js",
+ "homepage": "https://github.com/ramlmn/electron-clippy#readme",
+ "main": "build/main/main.js",
"scripts": {
- "lint": "eslint src",
+ "lint": "xo && stylelint src/**/*.css",
+ "lint-fix": "xo --fix; stylelint --fix src/**/*.css",
"start": "electron .",
- "build": "build"
+ "watch": "webpack --watch",
+ "dev": "concurrently \"npm run watch\" \"npm run start\"",
+ "build": "cross-env NODE_ENV=production webpack && build"
+ },
+ "dependencies": {
+ "@ramlmn/view": "^1.0.0",
+ "auto-launch": "^5.0.5",
+ "global-dispatcher": "^1.0.0",
+ "hyperhtml": "^2.23.0",
+ "just-debounce-it": "^1.1.0"
},
"devDependencies": {
- "electron": "^1.8.4",
- "electron-builder": "^20.6.2",
- "eslint": "^4.19.1",
- "eslint-config-google": "^0.9.1",
- "eslint-plugin-html": "^4.0.2"
+ "clean-webpack-plugin": "^1.0.0",
+ "concurrently": "^4.1.0",
+ "copy-webpack-plugin": "^4.6.0",
+ "cross-env": "^5.2.0",
+ "css-loader": "^2.0.2",
+ "cssnano": "^4.1.8",
+ "electron": "^4.0.0",
+ "electron-builder": "^20.38.4",
+ "file-loader": "^3.0.1",
+ "html-webpack-plugin": "^3.2.0",
+ "mini-css-extract-plugin": "^0.5.0",
+ "stylelint": "^9.9.0",
+ "stylelint-config-xo-space": "^0.11.0",
+ "webpack": "^4.28.2",
+ "webpack-cli": "^3.1.2",
+ "xo": "^0.23.0"
},
"build": {
+ "appId": "com.ramlmn.electron-clippy",
"productName": "Clippy",
- "appId": "com.ramlmn.clippy",
+ "artifactName": "electron-clippy-${version}.${ext}",
"files": [
- "src/**/*",
- "node_modules",
- "!(*.markdown|*.md|*.txt|.*|LICENSE|README|test)"
+ "build/**/*",
+ "node_modules"
],
"linux": {
"category": "Productivity",
- "icon": "src/build-assets/icon/png",
+ "icon": "build/assets/icon/png",
"target": [
"AppImage",
"deb"
]
},
"win": {
- "icon": "src/build-assets/icon/win/win.ico",
+ "icon": "build/assets/icon/win/win.ico",
"target": [
- "nsis"
+ "nsis",
+ "msi"
]
},
"nsis": {
@@ -52,7 +74,38 @@
"deleteAppDataOnUninstall": true
}
},
- "dependencies": {
- "auto-launch": "^5.0.5"
+ "xo": {
+ "space": true,
+ "rules": {
+ "no-unused-expressions": [
+ 2,
+ {
+ "allowTaggedTemplates": true
+ }
+ ],
+ "import/no-unassigned-import": [
+ 1,
+ {
+ "allow": [
+ "**/*.css"
+ ]
+ }
+ ]
+ },
+ "env": [
+ "node",
+ "browser"
+ ],
+ "ignore": [
+ "app"
+ ]
+ },
+ "stylelint": {
+ "extends": "stylelint-config-xo-space",
+ "rules": {
+ "declaration-colon-newline-after": null,
+ "selector-list-comma-newline-after": null,
+ "selector-type-no-unknown": null
+ }
}
}
diff --git a/src/constants.js b/src/constants.js
new file mode 100644
index 0000000..2c18b11
--- /dev/null
+++ b/src/constants.js
@@ -0,0 +1,24 @@
+export const EVENT = {
+ APP_CLOSE: 'app-close',
+ APP_HIDE: 'app-hide',
+ APP_INIT: 'app-init',
+ APP_SHOW: 'app-show',
+ APP_STATS: 'app-stats',
+
+ ITEM_DELETE: 'item-delete',
+ ITEM_NEW: 'item-new',
+ ITEM_NEXT: 'item-next',
+ ITEM_PREVIOUS: 'item-previous',
+ ITEM_RENDER: 'item-render',
+ ITEM_SEARCH: 'item-search',
+ ITEM_COPY: 'item-copy',
+
+ ITEMS_SAVE: 'items-save',
+ ITEMS_RESTORE: 'items-restore',
+ ITEMS_CLEAR: 'items-clear',
+
+ SETTINGS_UPDATE: 'settings-update', // main -> renderer
+ SETTINGS_CHANGE: 'settings-change', // renderer -> main
+ SETTINGS_HIDE: 'settings-hide',
+ SETTINGS_SHOW: 'settings-show'
+};
diff --git a/src/main/clipboard-watcher.js b/src/main/clipboard-watcher.js
index a9f8f18..512eb6b 100644
--- a/src/main/clipboard-watcher.js
+++ b/src/main/clipboard-watcher.js
@@ -1,9 +1,6 @@
-'use strict';
-
-const {clipboard} = require('electron');
-const EventEmitter = require('events');
-const crypto = require('crypto');
-
+import {clipboard} from 'electron';
+import EventEmitter from 'events';
+import crypto from 'crypto';
/**
* A thing which tracks the system clipboard and calls a callback,
@@ -20,12 +17,10 @@ class ClipboardWatcher extends EventEmitter {
// An object storing the recent clipboard item
this._recentClipItem = {};
- // this.clipboardItems = new Map();
this._watchLoop = this._watchLoop.bind(this);
}
-
/**
* Start listening for new items in clipboard
*
@@ -39,7 +34,6 @@ class ClipboardWatcher extends EventEmitter {
this._watchLoop();
}
-
/**
* A recursive function which listens for changes in system clipboard
*
@@ -56,7 +50,6 @@ class ClipboardWatcher extends EventEmitter {
setTimeout(this._watchLoop, 1000);
}
-
/**
* The function that scrapes the clipboard, analyzes all the available types
* and generates a clipboard item
@@ -81,7 +74,7 @@ class ClipboardWatcher extends EventEmitter {
text: '',
html: '',
rtf: '',
- image: '',
+ image: ''
},
// Data for image
@@ -90,12 +83,12 @@ class ClipboardWatcher extends EventEmitter {
height: 0,
// Data for text
- length: 0,
+ length: 0
};
// Extract all the available formats of data on the clipboard
for (const format of availableFormats) {
- // html and rtf formats are also considered plain text
+ // HTML and RTF formats are also considered plain text
if (format.startsWith('text/')) {
newClipItem.type = 'text';
@@ -126,7 +119,7 @@ class ClipboardWatcher extends EventEmitter {
// also cryptographic hash to identify them
if (newClipItem.type === 'image') {
newClipItem.hash = crypto
- .createHash('sha256')
+ .createHash('md5')
.update(newClipItem.data.image)
.digest('hex');
@@ -141,7 +134,7 @@ class ClipboardWatcher extends EventEmitter {
newClipItem.length = [...newClipItem.data.text].length;
newClipItem.hash = crypto
- .createHash('sha256')
+ .createHash('md5')
.update(newClipItem.data.text)
.digest('hex');
} else {
@@ -154,7 +147,6 @@ class ClipboardWatcher extends EventEmitter {
this.emit('item', newClipItem);
}
-
/**
* Generates a base64 thumbnail for the provided native image
*
@@ -168,7 +160,7 @@ class ClipboardWatcher extends EventEmitter {
const resizeOptions = {
width: 300,
- height: 300,
+ height: 300
};
if (imageDimensions.width > resizeOptions.width) {
@@ -187,13 +179,12 @@ class ClipboardWatcher extends EventEmitter {
return thumb.toDataURL();
}
-
/**
* Compares with the old item available and determines if it is a new item
* or exactly same as the old one
*
- * @param {Object} newItem
- * @returns {Boolean}
+ * @param {Object} newItem The possibly new item object to check for
+ * @returns {Boolean} Returns true if same otherwise false
* @memberof ClipboardWatcher
*/
_isNewItem(newItem) {
@@ -214,7 +205,9 @@ class ClipboardWatcher extends EventEmitter {
// Text is considered new if any of `text`, `html`, `rtf` parts change
if (oldItem.type === 'image') {
return (oldItem.data.image !== newItem.data.image);
- } else if (oldItem.type === 'text') {
+ }
+
+ if (oldItem.type === 'text') {
return (
oldItem.data.text !== newItem.data.text ||
oldItem.data.html !== newItem.data.html ||
@@ -224,5 +217,4 @@ class ClipboardWatcher extends EventEmitter {
}
}
-
-exports.ClipboardWatcher = ClipboardWatcher;
+export default ClipboardWatcher;
diff --git a/src/main/main.js b/src/main/main.js
index 8aa9723..fac33e9 100644
--- a/src/main/main.js
+++ b/src/main/main.js
@@ -1,77 +1,85 @@
-'use strict';
-
-const url = require('url');
-const path = require('path');
-const autoLaunch = new (require('auto-launch'))({name: 'Clippy'});
-const {app, Menu, BrowserWindow, ipcMain, globalShortcut, Tray} = require('electron');
-const {ClipboardWatcher} = require('./clipboard-watcher.js');
+import fs from 'fs';
+import url from 'url';
+import path from 'path';
+import {promisify} from 'util';
+import {app, Menu, BrowserWindow, ipcMain, globalShortcut, Tray} from 'electron';
+import AutoLaunch from 'auto-launch';
+import ClipboardWatcher from './clipboard-watcher';
+import {EVENT} from '../constants';
+
+const stat = promisify(fs.stat);
+const unlink = promisify(fs.unlink);
+const readFile = promisify(fs.readFile);
+const writeFile = promisify(fs.writeFile);
+
+const isProduction = process.env.NODE_ENV === 'production';
+const autoLaunch = new AutoLaunch({name: 'Clippy'});
let mainWindow = null;
let rendererChannel = null;
let tray = null;
let accStat = null;
-let startupStat = null;
+
+const userDataPath = app.getPath('userData');
+const settingsFilePath = path.resolve(userDataPath, 'settings.json');
+const historyFilePath = path.resolve(userDataPath, 'history.json');
+const indexFilePath = path.resolve(__dirname, '../renderer/index.html');
+const trayIconPath = path.resolve(__dirname, '../renderer/img/clippy-32.png');
+
+// default app settings, will be updated later
+const appSettings = {
+ runOnStartup: false,
+ persistentHistory: false
+};
+
+const browserWindowOptions = {
+ width: 800,
+ height: 500,
+ show: false,
+ center: true,
+ resizable: false,
+ minimizable: false,
+ maximizable: !isProduction,
+ closable: !isProduction,
+ fullscreenable: false,
+ skipTaskbar: true,
+ movable: false,
+ frame: false,
+ transparent: true,
+ title: 'Clippy',
+ alwaysOnTop: true
+};
const trayTemplate = [{
- label: 'Clippy',
+ label: 'Toggle Dev Tools',
+ click: () => rendererChannel && rendererChannel.toggleDevTools()
}, {
- type: 'separator',
+ type: 'separator'
}, {
- label: 'Show',
- click: showWindow,
+ label: 'Show Clippy',
+ click: showWindow
}, {
label: 'Clear',
- click: _ => {
- rendererChannel.send('clear-items');
- },
+ click: () => {
+ rendererChannel.send(EVENT.ITEMS_CLEAR);
+ }
}, {
label: 'Quit',
- click: _ => {
+ click: () => {
mainWindow.close();
- },
- }];
-
-app.on('window-all-closed', () => {
- if (process.platform !== 'darwin') {
- app.quit();
- }
-});
-
-app.on('activate', () => {
- if (!mainWindow) {
- mainWindow = createMainWindow();
- rendererChannel = mainWindow.webContents;
+ }
}
-});
-
-app.on('ready', () => {
- mainWindow = createMainWindow();
- accStat = addEventListeners();
- rendererChannel = mainWindow.webContents;
-});
-
-
-// Settingup clipboard watcher
-const watcher = new ClipboardWatcher();
-watcher.on('item', data => {
- rendererChannel.send('clipboard-item', data);
-});
-
-
-ipcMain.once('init', onInit);
-
-ipcMain.on('hide', hideWindow);
-
-ipcMain.on('settings', handleSettings);
+];
+function registerGlobalShortcut() {
+ return globalShortcut.register('CommandOrControl+Shift+V', showWindow);
+}
-function onClosed() {
- // Dereference the window
+function onWindowClosed() {
mainWindow = null;
}
function showWindow(event) {
- // Show the window
mainWindow.show();
if (event) {
@@ -89,73 +97,147 @@ function hideWindow(event) {
}
function createMainWindow() {
- const win = new BrowserWindow({
- width: 700,
- height: 450,
- show: false,
- center: true,
- resizable: false,
- fullscreenable: false,
- skipTaskbar: true,
- movable: false,
- frame: false,
- transparent: true,
- title: 'Clippy',
- alwaysOnTop: true,
- });
+ const win = new BrowserWindow(browserWindowOptions);
// Basic events for window
- win.on('closed', onClosed);
+ win.on('closed', onWindowClosed);
win.on('minimize', hideWindow);
win.on('blur', hideWindow);
const urlToLoad = url.format({
- pathname: path.join(__dirname, '../renderer/index.html'),
+ pathname: indexFilePath,
protocol: 'file:',
- slashes: true,
+ slashes: true
});
win.loadURL(urlToLoad);
+ tray = new Tray(trayIconPath);
- // Settingup tray icon
- tray = new Tray(path.join(__dirname, '../renderer/img/clip-32x32.png'));
-
- const trayContetxtMenu = Menu.buildFromTemplate(trayTemplate);
- tray.setContextMenu(trayContetxtMenu);
+ const trayContextMenu = Menu.buildFromTemplate(trayTemplate);
+ tray.setContextMenu(trayContextMenu);
tray.setToolTip('Clippy');
tray.setTitle('Clippy');
tray.on('double-click', showWindow);
+ (async () => {
+ try {
+ // merge default and saved settings
+ const data = await readFile(settingsFilePath, {encoding: 'utf-8'});
+ Object.assign(appSettings, JSON.parse(data));
+ } catch (err) {
+ console.error('[ERR] Error reading settings', settingsFilePath);
+ console.error(err);
+ }
+ })();
+
return win;
}
-function addEventListeners() {
- return globalShortcut.register('CommandOrControl+Shift+V', showWindow);
+async function persistItems(items) {
+ try {
+ await writeFile(historyFilePath, JSON.stringify(items, null, isProduction ? '' : ' '));
+ console.log('[INF] Wrote history to', historyFilePath);
+ } catch (error) {
+ console.error('[ERR] Error while saving history:', historyFilePath);
+ console.error(error);
+ }
}
-async function onInit() {
- // Start watching clipboard
- watcher.startListening();
-
- // Check startup status
- startupStat = await autoLaunch.isEnabled();
-
- // Send stats to renderer
- rendererChannel.send('stats', {accelerator: accStat, startup: startupStat});
+async function persistSettings() {
+ try {
+ await writeFile(settingsFilePath, JSON.stringify(appSettings, null, ' '));
+ console.log('[INF] Wrote settings to', settingsFilePath);
+ } catch (error) {
+ console.error('[ERR] Error while writing settings file:', settingsFilePath);
+ console.error(error);
+ }
}
-async function handleSettings(event, args) {
- if (args.startup !== startupStat) {
- // Setting changed
- if (args.startup === true) {
+async function onSettingsChange(event, settings) {
+ if (settings) {
+ if (settings.runOnStartup === true) {
autoLaunch.enable();
} else {
autoLaunch.disable();
}
- startupStat = await autoLaunch.isEnabled();
- rendererChannel.send('stats', {startup: startupStat});
+ if (settings.persistentHistory === true) {
+ // start saving items, flush current to drive
+ } else {
+ // stop saving items, delete everything from drive
+ try {
+ const fileStat = await stat(historyFilePath);
+ if (fileStat.isFile()) {
+ await unlink(historyFilePath);
+ }
+ } catch (error) {}
+
+ }
+
+ appSettings.persistentHistory = settings.persistentHistory;
}
+
+ appSettings.runOnStartup = await autoLaunch.isEnabled();
+
+ persistSettings(); // flush settings immediately
+
+ rendererChannel.send(EVENT.SETTINGS_UPDATE, appSettings);
}
+
+async function onAppInit() {
+ const clipboardWatcher = new ClipboardWatcher();
+ clipboardWatcher.on('item', (item) => {
+ rendererChannel.send(EVENT.ITEM_NEW, item);
+ });
+ clipboardWatcher.startListening();
+
+ if (appSettings.persistentHistory === true) {
+ try {
+ console.log('[INF] Restoring persistent history');
+ const data = await readFile(historyFilePath, {encoding: 'utf-8'});
+ rendererChannel.send(EVENT.ITEMS_RESTORE, JSON.parse(data));
+ } catch (error) {
+ console.error('[ERR] Failed to restore persistent history');
+ }
+ }
+
+ // send settings to renderer at startup
+ await onSettingsChange();
+}
+
+app.on('window-all-closed', () => {
+ if (process.platform !== 'darwin') {
+ app.quit();
+ }
+});
+
+app.on('activate', () => {
+ if (!mainWindow) {
+ mainWindow = createMainWindow();
+ rendererChannel = mainWindow.webContents;
+ }
+});
+
+app.on('ready', () => {
+ try {
+ mainWindow = createMainWindow();
+ accStat = registerGlobalShortcut();
+ rendererChannel = mainWindow.webContents;
+ } catch (error) {
+ console.error(error);
+ process.exit(1);
+ }
+});
+
+ipcMain.on(EVENT.APP_INIT, onAppInit);
+ipcMain.on(EVENT.APP_HIDE, hideWindow);
+
+ipcMain.on(EVENT.SETTINGS_CHANGE, onSettingsChange);
+
+ipcMain.on(EVENT.ITEMS_SAVE, (event, items) => {
+ if (appSettings.persistentHistory) {
+ persistItems(items);
+ }
+});
diff --git a/src/renderer/components/clippy-app.html b/src/renderer/components/clippy-app.html
deleted file mode 100644
index 0b0551c..0000000
--- a/src/renderer/components/clippy-app.html
+++ /dev/null
@@ -1,85 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/src/renderer/components/clippy-app/clippy-app.css b/src/renderer/components/clippy-app/clippy-app.css
new file mode 100644
index 0000000..ec23f93
--- /dev/null
+++ b/src/renderer/components/clippy-app/clippy-app.css
@@ -0,0 +1,43 @@
+clippy-app {
+ display: flex;
+ position: relative;
+ flex-direction: column;
+ flex: 1;
+ background: #fff;
+ overflow: hidden;
+ margin: 1rem;
+ border-radius: 0.3em;
+ box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.14),
+ 0 4px 18px 0 rgba(0, 0, 0, 0.12),
+ 0 3px 5px -2px rgba(0, 0, 0, 0.2);
+}
+
+clippy-app.is-linux {
+ margin: 0;
+ box-shadow: none;
+ border-radius: none;
+}
+
+clippy-app .clippy-toolbar {
+ display: grid;
+ align-items: center;
+ grid-template-columns: 1fr auto;
+ grid-gap: 0.5rem;
+ padding: 0.5rem;
+}
+
+clippy-app .clippy-toolbar clippy-search {
+ flex: 1;
+}
+
+clippy-app .clippy-content {
+ display: grid;
+ grid-template: 1fr / repeat(2, calc(50% - 0.25rem));
+ grid-gap: 0.5rem;
+ flex: 1;
+ padding: 0.5rem;
+}
+
+clippy-app .clippy-content > * {
+ min-height: 0;
+}
diff --git a/src/renderer/components/clippy-app/index.js b/src/renderer/components/clippy-app/index.js
new file mode 100644
index 0000000..a2c7b4a
--- /dev/null
+++ b/src/renderer/components/clippy-app/index.js
@@ -0,0 +1,76 @@
+import {dispatch} from 'global-dispatcher';
+import {viewIn, shouldHandle} from '@ramlmn/view';
+import ClippyElement from '../clippy-element';
+import {EVENT} from '../../../constants';
+import '../clippy-settings';
+import '../clippy-search';
+import '../clippy-button';
+import '../clippy-items';
+import '../clippy-previewer';
+import './clippy-app.css';
+
+class ClippyApp extends ClippyElement {
+ constructor() {
+ super();
+
+ if (process.platform === 'linux') {
+ this.classList.add('is-linux');
+ }
+ }
+
+ set view(v) {
+ this._view = v;
+ return this._view;
+ }
+
+ get view() {
+ return this._view;
+ }
+
+ connectedCallback() {
+ this._view = viewIn();
+
+ document.addEventListener('keydown', event => {
+ if (!shouldHandle(this.view)) {
+ return;
+ }
+
+ if (event.code === 'ArrowDown') {
+ dispatch(EVENT.ITEM_NEXT);
+ event.preventDefault();
+ } else if (event.code === 'ArrowUp') {
+ dispatch(EVENT.ITEM_PREVIOUS);
+ event.preventDefault();
+ } else if (event.code === 'Delete') {
+ dispatch(EVENT.ITEM_DELETE);
+ event.preventDefault();
+ } else if (event.code === 'Enter') {
+ dispatch(EVENT.ITEM_COPY);
+ } else if (event.code === 'Escape') {
+ dispatch(EVENT.APP_HIDE);
+ }
+ });
+
+ this.render();
+ }
+
+ render() {
+ this.html`
+
+
+
+
+
+
+
+
+
+ `;
+ }
+}
+
+customElements.define('clippy-app', ClippyApp);
+
+export default ClippyApp;
diff --git a/src/renderer/components/clippy-button/clippy-button.css b/src/renderer/components/clippy-button/clippy-button.css
new file mode 100644
index 0000000..214ef5a
--- /dev/null
+++ b/src/renderer/components/clippy-button/clippy-button.css
@@ -0,0 +1,28 @@
+clippy-button {
+ width: 2.25rem;
+ height: 2.25rem;
+ padding: 0.125rem;
+ display: grid;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+}
+
+clippy-button:focus,
+clippy-button:active {
+ outline: none;
+}
+
+clippy-button svg {
+ padding: 0.125rem;
+ height: 100%;
+ fill: var(--color-accented-700, currentColor);
+ opacity: 0.8;
+ transition: opacity 0.2s ease;
+}
+
+clippy-button:hover svg,
+clippy-button:focus svg,
+clippy-button:active svg {
+ opacity: 1;
+}
diff --git a/src/renderer/components/clippy-button/index.js b/src/renderer/components/clippy-button/index.js
new file mode 100644
index 0000000..6212aa4
--- /dev/null
+++ b/src/renderer/components/clippy-button/index.js
@@ -0,0 +1,54 @@
+import ClippyElement from '../clippy-element';
+import './clippy-button.css';
+
+class ClippyButton extends ClippyElement {
+ connectedCallback() {
+ this.role = 'button';
+ this.tabIndex = 0;
+
+ this._icon = this.getAttribute('icon');
+ this._label = this.getAttribute('label');
+
+ this.setAttribute('aria-label', this._label);
+
+ this.addEventListener('mousedown', () => this._pressed());
+ this.addEventListener('mouseup', () => this._released());
+
+ this.addEventListener('keydown', event => {
+ if (event.code === 'Space' || event.code === 'Enter') {
+ this._pressed();
+ }
+ });
+
+ this.addEventListener('keyup', event => {
+ if (event.code === 'Space' || event.code === 'Enter') {
+ this._released();
+ this.dispatchEvent(new CustomEvent('click', {
+ bubbles: false
+ }));
+ }
+ });
+
+ this.render();
+ }
+
+ _pressed() {
+ this.setAttribute('aria-pressed', true);
+ }
+
+ _released() {
+ this.setAttribute('aria-pressed', false);
+ }
+
+ render() {
+ this.html`
+
+ `;
+ }
+}
+
+customElements.define('clippy-button', ClippyButton);
+
+export default ClippyButton;
diff --git a/src/renderer/components/clippy-element.html b/src/renderer/components/clippy-element.html
deleted file mode 100644
index a89fcf9..0000000
--- a/src/renderer/components/clippy-element.html
+++ /dev/null
@@ -1,98 +0,0 @@
-
diff --git a/src/renderer/components/clippy-element/index.js b/src/renderer/components/clippy-element/index.js
new file mode 100644
index 0000000..ba48c64
--- /dev/null
+++ b/src/renderer/components/clippy-element/index.js
@@ -0,0 +1,33 @@
+import {bind, wire} from 'hyperhtml/esm';
+
+class ClippyElement extends HTMLElement {
+ constructor(...args) {
+ super(...args);
+ this.html = bind(this);
+ this.wire = wire(this);
+ this.state = {};
+ }
+
+ connectedCallback() {
+ this.render();
+ }
+
+ render() {}
+
+ setState(state, render) {
+ const target = this.state;
+ const source = typeof state === 'function' ? state.call(this, target) : state;
+
+ for (const key in source) {
+ target[key] = source[key];
+ }
+
+ if (render !== false) {
+ this.render();
+ }
+
+ return this;
+ }
+}
+
+export default ClippyElement;
diff --git a/src/renderer/components/clippy-item/clippy-item.css b/src/renderer/components/clippy-item/clippy-item.css
new file mode 100644
index 0000000..16e80be
--- /dev/null
+++ b/src/renderer/components/clippy-item/clippy-item.css
@@ -0,0 +1,13 @@
+clippy-item {
+ display: block;
+ padding: 0.25rem 0.75rem;
+ border-radius: 2px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+clippy-item.selected {
+ background-color: var(--color-item-active-bg, #212121);
+ color: var(--color-item-active-fg, #fff);
+}
diff --git a/src/renderer/components/clippy-item/index.js b/src/renderer/components/clippy-item/index.js
new file mode 100644
index 0000000..8fabefb
--- /dev/null
+++ b/src/renderer/components/clippy-item/index.js
@@ -0,0 +1,12 @@
+import ClippyElement from '../clippy-element';
+import './clippy-item.css';
+
+class ClippyItem extends ClippyElement {
+ connectedCallback() {
+ this.setAttribute('role', 'listitem');
+ }
+}
+
+customElements.define('clippy-item', ClippyItem);
+
+export default ClippyItem;
diff --git a/src/renderer/components/clippy-items.html b/src/renderer/components/clippy-items.html
deleted file mode 100644
index 3a288cc..0000000
--- a/src/renderer/components/clippy-items.html
+++ /dev/null
@@ -1,357 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/src/renderer/components/clippy-items/clippy-items.css b/src/renderer/components/clippy-items/clippy-items.css
new file mode 100644
index 0000000..adddcc9
--- /dev/null
+++ b/src/renderer/components/clippy-items/clippy-items.css
@@ -0,0 +1,38 @@
+clippy-items {
+ overflow-y: auto;
+ padding-right: 0.5rem;
+ position: relative;
+}
+
+clippy-items clippy-item {
+ display: block;
+ padding: 0.25rem 0.75rem;
+ border-radius: 2px;
+ user-select: none;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+clippy-items clippy-item.selected {
+ background-color: var(--color-item-active-bg, #212121);
+ color: var(--color-item-active-fg, #fff);
+}
+
+clippy-items .not-found {
+ color: var(--color-primary-500, #ccc);
+ user-select: none;
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
+
+clippy-items .not-found p {
+ margin: 0.25rem 0;
+}
diff --git a/src/renderer/components/clippy-items/index.js b/src/renderer/components/clippy-items/index.js
new file mode 100644
index 0000000..03f43a9
--- /dev/null
+++ b/src/renderer/components/clippy-items/index.js
@@ -0,0 +1,228 @@
+import {clipboard, nativeImage} from 'electron';
+import {subscribe, dispatch} from 'global-dispatcher';
+import {wire} from 'hyperhtml/esm';
+import debounce from 'just-debounce-it';
+import ClippyElement from '../clippy-element';
+import {clamp} from '../../util';
+import {EVENT} from '../../../constants';
+import '../clippy-item';
+import './clippy-items.css';
+
+class ClippyItems extends ClippyElement {
+ constructor() {
+ super();
+
+ this.items = new Map();
+ this._items = [];
+ this._selectedItem = null;
+ this._itemsToRender = [];
+ this.pattern = null;
+
+ this._saveItemsLazily = debounce(this._saveItems, 200);
+ }
+
+ connectedCallback() {
+ this.role = 'list';
+
+ subscribe(EVENT.ITEM_NEW, item => this.handleNewItem(item));
+ subscribe(EVENT.ITEM_DELETE, hash => this.handleDeleteItem(hash));
+ subscribe(EVENT.ITEM_SEARCH, pattern => this.handleSearch(pattern));
+
+ subscribe(EVENT.ITEM_NEXT, () => this._selectNext());
+ subscribe(EVENT.ITEM_COPY, () => this._copyItem());
+ subscribe(EVENT.ITEM_PREVIOUS, () => this._selectPrevious());
+
+ subscribe(EVENT.ITEMS_CLEAR, () => this.handleClearItems());
+ subscribe(EVENT.ITEMS_RESTORE, (items) => this.handleRestoreItems(items));
+
+ this.render();
+ }
+
+ _saveItems() {
+ dispatch(EVENT.ITEMS_SAVE, this._items);
+ }
+
+ handleClearItems() {
+ this.items.clear();
+ this._items = [];
+ this._selectedItem = null;
+ this._itemsToRender = [];
+
+ clipboard.clear();
+
+ this._saveItems();
+
+ this.render();
+ }
+
+ handleRestoreItems(items) {
+ this.items.clear();
+ this._items = [];
+
+ for (const item of items) {
+ this.items.set(item.hash, item);
+ this._items.push(item);
+ this._items = this._sortItems(this._items);
+ }
+
+ this._itemsToRender = this._items;
+ this._selectedItem = this._items[0];
+
+ this.render();
+ }
+
+ handleNewItem(item) {
+ if (this.items.has(item.hash)) {
+ this._items = this._items.filter(i => i.hash !== item.hash);
+ }
+
+ this.items.set(item.hash, item);
+ this._items.unshift(item);
+ this._items = this._sortItems(this._items);
+
+ if (!this._pattern) {
+ this._selectedItem = item;
+ this._itemsToRender = this._items;
+ }
+
+ this._saveItemsLazily();
+
+ this.render();
+ }
+
+ handleDeleteItem(hash = this._selectedItem.hash) {
+ const position = this._itemsToRender.indexOf(this._selectedItem);
+ const length = this._itemsToRender.length;
+
+ this.items.delete(hash);
+ this._items = this._items.filter(item => item.hash !== hash);
+ this._itemsToRender = this._itemsToRender.filter(item => item.hash !== hash);
+
+ if (position === length - 1) {
+ this._selectPrevious();
+ } else {
+ this._selectNext();
+ }
+
+ this._saveItemsLazily();
+
+ this.render();
+ }
+
+ _sortItems(items) {
+ return items.sort((a, b) => {
+ return b.timestamp - a.timestamp;
+ });
+ }
+
+ _select(offset) {
+ if (this._selectedItem) {
+ const currentIndex = this._itemsToRender.indexOf(this._selectedItem);
+ const nextIndex = clamp(currentIndex + offset, 0, this._itemsToRender.length - 1);
+ this._selectedItem = this._itemsToRender[nextIndex];
+
+ if (currentIndex === nextIndex) {
+ return;
+ }
+ } else {
+ this._selectedItem = this._itemsToRender[0];
+ }
+
+ this.render();
+ }
+
+ _selectPrevious() {
+ this._select(-1);
+ }
+
+ _selectNext() {
+ this._select(1);
+ }
+
+ _copyItem() {
+ const selected = this._selectedItem;
+
+ if (selected) {
+ if (selected.type === 'image') {
+ clipboard.write({
+ image: nativeImage.createFromDataURL(selected.data.image),
+ ...selected.data
+ });
+ } else {
+ clipboard.write({
+ ...selected.data
+ });
+ }
+ }
+ }
+
+ _filterItems(pattern) {
+ if (pattern) {
+ // @TODO: Maybe use a better search algorithm
+ try {
+ const re = new RegExp(pattern, 'gim');
+ const items = this._items.filter(item => {
+ return item.type === 'text' && item.data.text.match(re);
+ });
+
+ this._selectedItem = items[0];
+
+ return items;
+ } catch (e) {
+ this._selectedItem = this._items[0];
+ }
+ }
+
+ if (!this._selectedItem) {
+ this._selectedItem = this._items[0];
+ }
+
+ return this._items;
+ }
+
+ handleSearch(pattern) {
+ this._pattern = pattern;
+ this._itemsToRender = this._filterItems(this._pattern);
+ this.render();
+ }
+
+ render() {
+ dispatch(EVENT.ITEM_RENDER, this._selectedItem);
+
+ if (this._itemsToRender.length > 0) {
+ this.html`
+ ${this._itemsToRender.map(item => wire(this, `:clippy-item-${item.hash}`)`
+
+ ${item.type === 'image'
+ ? `Image: ${item.width}x${item.height}`
+ : item.data.text.trim().substr(0, 100)}
+
+ `)}
+ `;
+
+ requestAnimationFrame(() => {
+ const currentItem = this.querySelector('clippy-item.selected');
+ if (currentItem) {
+ currentItem.scrollIntoView({
+ block: 'nearest'
+ });
+ }
+ });
+ } else {
+ this.html`
+
+
¯\\_(ツ)_/¯
+
We couldn't find anything
+
+ `;
+ }
+ }
+}
+
+customElements.define('clippy-items', ClippyItems);
+
+export default ClippyItems;
diff --git a/src/renderer/components/clippy-previewer.html b/src/renderer/components/clippy-previewer.html
deleted file mode 100644
index 5df6fc4..0000000
--- a/src/renderer/components/clippy-previewer.html
+++ /dev/null
@@ -1,141 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/renderer/components/clippy-previewer/clippy-previewer.css b/src/renderer/components/clippy-previewer/clippy-previewer.css
new file mode 100644
index 0000000..3de0671
--- /dev/null
+++ b/src/renderer/components/clippy-previewer/clippy-previewer.css
@@ -0,0 +1,46 @@
+clippy-previewer {
+ display: flex;
+ flex-direction: column;
+}
+
+clippy-previewer .preview {
+ background: var(--color-primary-50, #fafafa);
+ display: flex;
+ flex: 1;
+}
+
+clippy-previewer .preview .preview-text,
+clippy-previewer .preview .preview-image {
+ width: 100%;
+ height: 100%;
+ padding: 0.5rem;
+}
+
+clippy-previewer .preview .preview-text {
+ overflow: auto;
+ white-space: pre;
+ font-family: Consolas, SFMono-Regular, Menlo, Monaco, 'Droid Sans Mono', 'Source Code Pro',
+ 'DejaVu Sans Mono', 'Ubuntu Mono', Courier, monospace;
+ line-height: 1.25;
+ color: var(--color-primary-800);
+}
+
+clippy-previewer .preview .preview-image {
+ object-fit: scale-down;
+ background-image: url('data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%202%202%22%3E%3Cpath%20d%3D%22M1%202V0h1v1H0v1z%22%20fill-opacity%3D%22.05%22%2F%3E%3C%2Fsvg%3E');
+ background-size: 1rem;
+}
+
+clippy-previewer .preview-meta {
+ font-size: 0.85rem;
+ margin-top: 0.5rem;
+ user-select: none;
+}
+
+clippy-previewer .preview-meta p {
+ margin: 0;
+ min-height: 1.25rem;
+ line-height: 1.25rem;
+ text-align: center;
+ color: var(--color-primary-600);
+}
diff --git a/src/renderer/components/clippy-previewer/index.js b/src/renderer/components/clippy-previewer/index.js
new file mode 100644
index 0000000..82ba5d4
--- /dev/null
+++ b/src/renderer/components/clippy-previewer/index.js
@@ -0,0 +1,66 @@
+import {wire} from 'hyperhtml/esm';
+import {subscribe} from 'global-dispatcher';
+import ClippyElement from '../clippy-element';
+import {EVENT} from '../../../constants';
+import './clippy-previewer.css';
+
+class ClippyPreviewer extends ClippyElement {
+ constructor() {
+ super();
+
+ subscribe(EVENT.ITEM_RENDER, item => this.render(item));
+ }
+
+ connectedCallback() {
+ this.render();
+ }
+
+ getPreviewTextOrImage(item) {
+ if (item) {
+ if (item.type === 'image') {
+ return wire()``;
+ } else {
+ return wire()`${item.data.text}
`;
+ }
+ }
+
+ return wire()``;
+ }
+
+ getPreviewMeta(item) {
+ if (item) {
+ let meta;
+
+ if (item.type === 'image') {
+ meta = wire()`${item.width} x ${item.height}`;
+ } else {
+ meta = wire()`${[...item.data.text].length} chars`;
+ }
+
+ return wire()`
+ Copied at ${(new Date(item.timestamp)).toLocaleString()}
+ ${meta}
+ `;
+ } else {
+ return wire()`
+
+
+ `;
+ }
+ }
+
+ render(item) {
+ this.html`
+
+ ${this.getPreviewTextOrImage(item)}
+
+
+ ${this.getPreviewMeta(item)}
+
+ `;
+ }
+}
+
+customElements.define('clippy-previewer', ClippyPreviewer);
+
+export default ClippyElement;
diff --git a/src/renderer/components/clippy-search/clippy-search.css b/src/renderer/components/clippy-search/clippy-search.css
new file mode 100644
index 0000000..b36a1de
--- /dev/null
+++ b/src/renderer/components/clippy-search/clippy-search.css
@@ -0,0 +1,28 @@
+clippy-search {
+ display: flex;
+}
+
+clippy-search input[type='search'] {
+ flex: 1;
+ padding: 0.5rem;
+ border: none;
+ border-radius: 3px;
+ color: inherit;
+ font-family: inherit;
+ font-size: 1.25rem;
+ background-color: var(--color-primary-50);
+}
+
+clippy-search input[type='search']:focus,
+clippy-search input[type='search']:hover,
+clippy-search input[type='search']:active {
+ outline: none;
+}
+
+clippy-search input[type='search']::-webkit-search-decoration,
+clippy-search input[type='search']::-webkit-search-cancel-button,
+clippy-search input[type='search']::-webkit-search-results-button,
+clippy-search input[type='search']::-webkit-search-results-decoration {
+ display: none;
+}
+
diff --git a/src/renderer/components/clippy-search/index.js b/src/renderer/components/clippy-search/index.js
new file mode 100644
index 0000000..3293a92
--- /dev/null
+++ b/src/renderer/components/clippy-search/index.js
@@ -0,0 +1,64 @@
+import {dispatch} from 'global-dispatcher';
+import {shouldHandle} from '@ramlmn/view';
+import ClippyElement from '../clippy-element';
+import {EVENT} from '../../../constants';
+import './clippy-search.css';
+
+class ClippySearch extends ClippyElement {
+ constructor() {
+ super();
+
+ this._value = '';
+ }
+
+ connectedCallback() {
+ this._autoFocus = this.getAttribute('autofocus');
+
+ document.addEventListener('keyup', event => {
+ if (!shouldHandle(this.view)) {
+ return;
+ }
+
+ const input = this.querySelector('input');
+ if (document.activeElement !== input && !event.defaultPrevented) {
+ input.focus();
+ }
+ });
+
+ this.render();
+ }
+
+ get view() {
+ return this.dataset.view;
+ }
+
+ onChange(event) {
+ this._value = event.target.value;
+ dispatch(EVENT.ITEM_SEARCH, this._value);
+ }
+
+ static get observedAttributes() {
+ return ['autofocus'];
+ }
+
+ attributeChangedCallback(name, oldVal, newVal) {
+ if (name === 'autofocus') {
+ this._autoFocus = newVal;
+ this.render();
+ }
+ }
+
+ render() {
+ this.html`
+
+ `;
+ }
+}
+
+customElements.define('clippy-search', ClippySearch);
+
+export default ClippySearch;
diff --git a/src/renderer/components/clippy-settings.html b/src/renderer/components/clippy-settings.html
deleted file mode 100644
index 3ff17f8..0000000
--- a/src/renderer/components/clippy-settings.html
+++ /dev/null
@@ -1,153 +0,0 @@
-
-
-
-
-
-
-
Settings
-
-
-
-
-
diff --git a/src/renderer/components/clippy-settings/clippy-settings.css b/src/renderer/components/clippy-settings/clippy-settings.css
new file mode 100644
index 0000000..347856e
--- /dev/null
+++ b/src/renderer/components/clippy-settings/clippy-settings.css
@@ -0,0 +1,57 @@
+clippy-settings {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 9999;
+ pointer-events: none;
+ user-select: none;
+}
+
+clippy-settings.shown {
+ pointer-events: initial;
+}
+
+clippy-settings .scrim {
+ position: absolute;
+ left: 0;
+ right: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.5);
+ opacity: 0;
+ transition: opacity 0.2s cubic-bezier(0.4, 0, 1, 1);
+}
+
+clippy-settings.shown .scrim {
+ opacity: 1;
+ transition: opacity 0.2s cubic-bezier(0, 0, 0.2, 1);
+}
+
+clippy-settings .content {
+ max-width: 100%;
+ width: 32rem;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ margin: auto;
+ padding: 1rem;
+ background: #fff;
+ transform: translateY(-110%);
+ border-bottom-left-radius: 3px;
+ border-bottom-right-radius: 3px;
+ transition: transform 0.2s cubic-bezier(0.4, 0, 1, 1);
+}
+
+clippy-settings.shown .content {
+ display: grid;
+ grid-gap: 1rem;
+ transform: translateY(0);
+ transition: transform 0.2s cubic-bezier(0, 0, 0.2, 1);
+}
+
+clippy-settings .content h2 {
+ margin: 0;
+}
diff --git a/src/renderer/components/clippy-settings/index.js b/src/renderer/components/clippy-settings/index.js
new file mode 100644
index 0000000..c073052
--- /dev/null
+++ b/src/renderer/components/clippy-settings/index.js
@@ -0,0 +1,124 @@
+import {subscribe, dispatch} from 'global-dispatcher';
+import {viewIn, viewOut, shouldHandle} from '@ramlmn/view';
+import ClippyElement from '../clippy-element';
+import {EVENT} from '../../../constants';
+import '../clippy-switch';
+import './clippy-settings.css';
+
+class ClippySettings extends ClippyElement {
+ constructor() {
+ super();
+
+ this._settings = {
+ runOnStartup: false,
+ persistentHistory: false
+ };
+
+ subscribe(EVENT.SETTINGS_SHOW, () => this.show());
+ subscribe(EVENT.SETTINGS_HIDE, () => this.hide());
+ subscribe(EVENT.SETTINGS_UPDATE, settings => this._onSettingsUpdate(settings));
+ }
+
+ connectedCallback() {
+ this._visible = false;
+
+ if (this.hasAttribute('visible')) {
+ this.show();
+ } else {
+ this.hide();
+ }
+
+ document.addEventListener('keydown', event => {
+ if (!shouldHandle(this.view)) {
+ return;
+ }
+
+ if (event.code === 'Escape' && this._visible) {
+ this.hide();
+ event.preventDefault();
+ }
+ });
+
+ this.render();
+ }
+
+ get view() {
+ return this._view;
+ }
+
+ set view(view) {
+ this._view = view;
+ return this._view;
+ }
+
+ show() {
+ if (this._visible) {
+ return;
+ }
+
+ this.view = viewIn();
+
+ requestAnimationFrame(() => {
+ this._visible = true;
+ this.classList.add('shown');
+ this.removeAttribute('visible');
+ this.setAttribute('aria-hidden', false);
+ });
+ }
+
+ hide() {
+ if (!this._visible) {
+ return;
+ }
+
+ viewOut(this.view);
+
+ requestAnimationFrame(() => {
+ this._visible = false;
+ this.classList.remove('shown');
+ this.setAttribute('visible', true);
+ this.setAttribute('aria-hidden', true);
+ });
+ }
+
+ _onSettingsUpdate(settings) {
+ this._settings = Object.assign({}, this._settings, settings);
+ this.render();
+ }
+
+ _onSettingChange(setting, value) {
+ this._settings[setting] = value;
+
+ dispatch(EVENT.SETTINGS_CHANGE, this._settings);
+
+ // this.render();
+ }
+
+ render() {
+ this.html`
+
+
+
Settings
+
+
+
+ `;
+ }
+}
+
+customElements.define('clippy-settings', ClippySettings);
+
+export default ClippySettings;
diff --git a/src/renderer/components/clippy-switch/clippy-switch.css b/src/renderer/components/clippy-switch/clippy-switch.css
new file mode 100644
index 0000000..ba331bc
--- /dev/null
+++ b/src/renderer/components/clippy-switch/clippy-switch.css
@@ -0,0 +1,80 @@
+clippy-switch {
+ display: block;
+}
+
+clippy-switch .switch-container {
+ padding: 0.25rem 0;
+ width: 100%;
+ display: flex;
+ align-items: center;
+}
+
+clippy-switch .switch-container input[type='checkbox'] {
+ display: none;
+}
+
+clippy-switch .switch-container .switch {
+ display: inline-block;
+ width: 2.25rem;
+ height: 1.25em;
+ position: relative;
+}
+
+clippy-switch .switch-container .switch .track {
+ border-radius: 1.25em;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ margin: 0.25rem;
+ background: rgba(0, 0, 0, 0.16);
+}
+
+clippy-switch .switch-container .switch .handle {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 1.25rem;
+ height: 1.25rem;
+ border-radius: 100%;
+ background: #aaa;
+ overflow: hidden;
+ transition: transform 0.15s ease-out;
+ will-change: transform;
+}
+
+clippy-switch .switch-container .switch .track::before,
+clippy-switch .switch-container .switch .handle::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ opacity: 0;
+ transition: opacity 0.15s ease-out;
+ will-change: opacity;
+ border-radius: inherit;
+}
+
+clippy-switch .switch-container .switch .track::before {
+ background: var(--color-accented-100);
+}
+
+clippy-switch .switch-container .switch .handle::before {
+ background: var(--color-accented-500);
+}
+
+clippy-switch .switch-container :checked + .switch .track::before,
+clippy-switch .switch-container :checked + .switch .handle::before {
+ opacity: 1;
+}
+
+clippy-switch .switch-container :checked + .switch .handle {
+ transform: translateX(1rem);
+}
+
+clippy-switch .switch-container .switch-label {
+ padding-left: 1rem;
+}
diff --git a/src/renderer/components/clippy-switch/index.js b/src/renderer/components/clippy-switch/index.js
new file mode 100644
index 0000000..815c1aa
--- /dev/null
+++ b/src/renderer/components/clippy-switch/index.js
@@ -0,0 +1,53 @@
+import ClippyElement from '../clippy-element';
+import './clippy-switch.css';
+
+class ClippySwitch extends ClippyElement {
+ connectedCallback() {
+ this.setAttribute('tabindex', 0);
+
+ this.addEventListener('keyup', event => {
+ if (event.code === 'Space' || event.code === 'Enter') {
+ this.selected = !this.selected;
+ }
+ });
+
+ this.render();
+ }
+
+ render() {
+ this.html`
+
+ `;
+
+ this.input = this.querySelector('input');
+ }
+
+ attributeChangedCallback(attr, previousValue, currentValue) {
+ if (attr === 'selected') {
+ this.render();
+ }
+ }
+
+ static get observedAttributes() {
+ return ['selected'];
+ }
+
+ get selected() {
+ return this.getAttribute('selected') === 'true';
+ }
+
+ set selected(value) {
+ this.setAttribute('selected', value);
+ }
+}
+
+customElements.define('clippy-switch', ClippySwitch);
+
+export default ClippySwitch;
diff --git a/src/renderer/components/clippy-toolbar.html b/src/renderer/components/clippy-toolbar.html
deleted file mode 100644
index aa46655..0000000
--- a/src/renderer/components/clippy-toolbar.html
+++ /dev/null
@@ -1,133 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/src/renderer/components/material-switch.html b/src/renderer/components/material-switch.html
deleted file mode 100644
index f3e219b..0000000
--- a/src/renderer/components/material-switch.html
+++ /dev/null
@@ -1,143 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/src/renderer/fonts/noto/bold/latin-ext.woff2 b/src/renderer/fonts/noto/bold/latin-ext.woff2
deleted file mode 100644
index 63d06ff..0000000
Binary files a/src/renderer/fonts/noto/bold/latin-ext.woff2 and /dev/null differ
diff --git a/src/renderer/fonts/noto/bold/latin.woff2 b/src/renderer/fonts/noto/bold/latin.woff2
deleted file mode 100644
index 72744d5..0000000
Binary files a/src/renderer/fonts/noto/bold/latin.woff2 and /dev/null differ
diff --git a/src/renderer/fonts/noto/regular/latin-ext.woff2 b/src/renderer/fonts/noto/regular/latin-ext.woff2
deleted file mode 100644
index 69fd6d0..0000000
Binary files a/src/renderer/fonts/noto/regular/latin-ext.woff2 and /dev/null differ
diff --git a/src/renderer/fonts/noto/regular/latin.woff2 b/src/renderer/fonts/noto/regular/latin.woff2
deleted file mode 100644
index 4b3b066..0000000
Binary files a/src/renderer/fonts/noto/regular/latin.woff2 and /dev/null differ
diff --git a/src/renderer/img/clip-128x128.png b/src/renderer/img/clip-128x128.png
deleted file mode 100644
index d65023f..0000000
Binary files a/src/renderer/img/clip-128x128.png and /dev/null differ
diff --git a/src/renderer/img/clip-256x256.png b/src/renderer/img/clip-256x256.png
deleted file mode 100644
index de3c3db..0000000
Binary files a/src/renderer/img/clip-256x256.png and /dev/null differ
diff --git a/src/renderer/img/clip-32x32.png b/src/renderer/img/clippy-32.png
similarity index 100%
rename from src/renderer/img/clip-32x32.png
rename to src/renderer/img/clippy-32.png
diff --git a/src/renderer/index.html b/src/renderer/index.html
index ad6584c..932ca59 100644
--- a/src/renderer/index.html
+++ b/src/renderer/index.html
@@ -3,31 +3,11 @@
-
-
-
+
+
Clippy
-
-
-
-
-
-
-
-
-
+
-
-
-
-
-
- Run at startup
-
-
-
+