├── package.json ├── .gitignore ├── LICENSE ├── README.md └── index.js /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hain-plugin-clipboard", 3 | "version": "0.6.2", 4 | "description": "Restore clipboard history", 5 | "main": "index.js", 6 | "author": "Luke Plaster", 7 | "license": "MIT", 8 | "keywords": [ 9 | "hain-0.1.0" 10 | ], 11 | "hain": { 12 | "prefix": "/clipboard", 13 | "usage": "type /clipboard to show clipboard history", 14 | "icon": "#fa fa-clipboard", 15 | "redirect": "/clipboard ", 16 | "group": "Clipboard History" 17 | }, 18 | "dependencies": { 19 | "copy-paste-win32fix": "^1.2.0", 20 | "eskape": "^1.2.0", 21 | "s-ago": "^1.0.6" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Luke Plaster 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hain-plugin-clipboard 2 | > Restore clipboard history in Windows like in Alfred for Mac OS! 3 | 4 | ![](https://img.shields.io/npm/v/hain-plugin-clipboard.svg) ![](https://img.shields.io/npm/dm/hain-plugin-clipboard.svg) 5 | 6 | ![Preview!](https://cloud.githubusercontent.com/assets/1255926/15283851/240ff918-1b80-11e6-9910-9f20db21deb3.png) 7 | 8 | ## Installation 9 | 10 | [Install](https://github.com/appetizermonster/hain/releases) and open [Hain](https://github.com/appetizermonster/hain) (alt+space), then type this command: 11 | ``` 12 | /hpm install hain-plugin-clipboard 13 | ``` 14 | then, once installed: 15 | ``` 16 | /reload 17 | ``` 18 | 19 | ## How it works 20 | 21 | This plugin polls the clipboard every few seconds to look for changes in clipboard content. 22 | If the current clipboard content is not present in the clipboard history it's added to the list that appears when you type `/clipboard`. 23 | 24 | ## Limitations 25 | 26 | A maximum of 100 entries are saved in order to conserve memory usage. 27 | 28 | This limit will be soon be configurable in the preferences pane! 29 | 30 | ## Future work 31 | 32 | - Add preferences 33 | - Support replaying rich text and/or images 34 | - Allow clearing by time range (5 mins, 1 hour, 1 day, ...) 35 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | (function(){ 4 | 5 | const ncp = require('copy-paste-win32fix'); 6 | const ago = require('s-ago'); 7 | const eskape = require('eskape'); 8 | 9 | const MAX_CLIPS = 100; 10 | const POLL_INTERVAL_MS = 3000; 11 | const DEFAULT_CLIP_DISPLAY_MAX_CHARS = 100; 12 | const CLIP_PREVIEW_WHEN_MAX_CHARS = 100; 13 | const PREVIEW_HTML = (text) => eskape` 14 | 15 | 16 | 17 | 26 | 27 |
${text}
28 | `; 29 | 30 | module.exports = (context) => { 31 | const CURRENT_API_VERSION = context.CURRENT_API_VERSION; 32 | 33 | const app = context.app; 34 | const toast = context.toast; 35 | const matchutil = context.matchutil; 36 | 37 | const isLegacyAPIVersion = 38 | ! CURRENT_API_VERSION || ['hain0', 'hain-0.1.0', 'hain-0.3.0', 'hain-0.4.0'] 39 | .indexOf(CURRENT_API_VERSION) !== -1; 40 | 41 | const clips = []; 42 | let lastClips; // a shallow copy of `clips`, kept between search/execute 43 | 44 | function abbr(num) { 45 | if (num >= 1000000) { 46 | return `${num/1000000}m`; 47 | } else if (num >= 1000) { 48 | return `${num/1000}k`; 49 | } else { 50 | return num; 51 | } 52 | } 53 | 54 | function startup() { 55 | var lastClip = null; 56 | var saveClipFn; 57 | setInterval(saveClipFn = () => { 58 | ncp.paste((err, clip) => { 59 | if (err) return; 60 | if (typeof clip !== 'string' 61 | || ! clip.trim().length 62 | || lastClip === clip) { 63 | return; 64 | } 65 | clip = clip.toString(); 66 | clips.unshift({ 67 | content: lastClip = clip, 68 | size: clip.length, 69 | time: new Date() 70 | }); 71 | if (clips.length > MAX_CLIPS) { 72 | clips.pop(); 73 | } 74 | }); 75 | }, POLL_INTERVAL_MS); 76 | saveClipFn(); 77 | } 78 | 79 | function search(query, res) { 80 | const querytrim = query.replace(' ', ''); 81 | let results; 82 | lastClips = clips.concat(); // shallow copy 83 | if (querytrim.length) { 84 | results = matchutil.fuzzy(clips, querytrim, x => x.content); 85 | } else { 86 | results = clips; 87 | res.add([{ 88 | id: 'clear', 89 | payload: 'clear', 90 | title: 'Clear this list', 91 | icon: '#fa fa-trash' 92 | }]); 93 | } 94 | res.add(results.map((clip, idx) => { 95 | let title, isTrimmed, isPreviewable; 96 | 97 | // only trim down the string for old hain APIs 98 | const maxTitleCharsToDisplay = isLegacyAPIVersion ? DEFAULT_CLIP_DISPLAY_MAX_CHARS : 1024; 99 | 100 | if (clip.elem) { 101 | // fuzzy match 102 | const trimmed = clip.elem.content.trim(); 103 | const clipped = trimmed.substr(0, maxTitleCharsToDisplay); 104 | isPreviewable = trimmed.length >= CLIP_PREVIEW_WHEN_MAX_CHARS; 105 | if (maxTitleCharsToDisplay === clipped.length) { 106 | title = clipped; // no bold - it looks strange when clipped 107 | } else { 108 | title = matchutil.makeStringBoldHtml(clipped, clip.matches); 109 | } 110 | idx = clips.indexOf(clip = clip.elem); 111 | } else { 112 | // normal result 113 | title = clip.content.substr(0, maxTitleCharsToDisplay); 114 | isPreviewable = clip.content.length >= CLIP_PREVIEW_WHEN_MAX_CHARS; 115 | } 116 | isTrimmed = maxTitleCharsToDisplay === title.length; 117 | 118 | // TODO: remove hack to escape html but keep bolds 119 | title = title.replace(//gi, '%%B%%'); 120 | title = title.replace(/<\/b>/gi, '%%EB%%'); 121 | title = title.replace(/&/g, '&'); 122 | title = title.replace(/'); 124 | title = title.replace(/%%EB%%/g, ''); 125 | 126 | if (isTrimmed) { 127 | title += '…'; 128 | } 129 | title = title.replace(/[\r\n]/g, ''); 130 | 131 | return { 132 | id: idx, 133 | payload: '', 134 | title: isLegacyAPIVersion ? title : { singleLine: true, text: title }, 135 | icon: '#fa fa-clipboard', 136 | desc: `Copy to clipboard (${abbr(clip.size)} characters, ${ago(clip.time)})`, 137 | preview: isPreviewable 138 | }; 139 | })); 140 | } 141 | 142 | function execute(id, payload) { 143 | if (payload === 'clear') { 144 | clips.length = lastClips.length = 0; 145 | toast.enqueue('Clipboard history cleared!'); 146 | setTimeout(() => { 147 | app.close(); 148 | }, 1000); 149 | } else { 150 | ncp.copy(lastClips[id].content, () => { 151 | app.close(); 152 | }); 153 | } 154 | } 155 | 156 | function renderPreview(id, payload, render) { 157 | if (payload.length) return; 158 | render(PREVIEW_HTML(lastClips[id].content)); 159 | } 160 | 161 | return { startup, search, execute, renderPreview }; 162 | }; 163 | 164 | })(); 165 | --------------------------------------------------------------------------------