experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #7 from autonome/cmd

Cmd

authored by

Dietrich Ayala and committed by
GitHub
9db8d8af 8f48b393

+1367 -28
+284
features/cmd/commands.js
··· 1 + (async () => { 2 + 3 + const DEBUG = 1; 4 + const TEST = 0; 5 + 6 + dbg('POPUP INIT'); 7 + 8 + if (TEST === 1) { 9 + let title = '+pin'; 10 + let url = "javascript:q=location.href;if(document.getSelection){d=document.getSelection();}else{d='';};p=document.title;void(open('https://pinboard.in/add?showtags=yes&url='+encodeURIComponent(q)+'&description='+encodeURIComponent(d)+'&title='+encodeURIComponent(p),'Pinboard','toolbar=no,scrollbars=yes,width=750,height=700'));" 11 + let r = await browser.bookmarks.search({ title }) 12 + if (r.length == 0) { 13 + let bm = await browser.bookmarks.create({ title, url }) 14 + } 15 + } 16 + 17 + 18 + let commands = {}; 19 + 20 + function onCommandsUpdated () { 21 + window.dispatchEvent(new CustomEvent('cmd-update-commands', { detail: commands })); 22 + dbg('main sending updated commands out', Object.keys(commands)) 23 + } 24 + 25 + window.addEventListener('DOMContentLoaded', initializeCommandSources); 26 + 27 + /* 28 + command is an object with two properties: 29 + 30 + - name: string label 31 + - execute: method 32 + 33 + TODO: 34 + - add canRun check - eg, switchContainer cannot run on about: urls 35 + - enable command generation at call time (instead of in advance) 36 + 37 + */ 38 + function addCommand(command) { 39 + commands[command.name] = command; 40 + onCommandsUpdated(); 41 + } 42 + 43 + function initializeCommandSources() { 44 + dbg('initializeCommandSources'); 45 + 46 + sourceOpenURL(); 47 + //sourceBookmarklets(); 48 + //sourceBookmark(); 49 + //sourceEmail(); 50 + //sourceGoogleDocs(); 51 + //sourceSendToWindow(); 52 + //sourceSwitchToWindow(); 53 + //sourceNewContainerTab(); 54 + //sourceSwitchTabContainer(); 55 + onCommandsUpdated(); 56 + } 57 + 58 + const sourceOpenURL = () => { 59 + const cmdName = 'open'; 60 + addCommand({ 61 + name: cmdName, 62 + execute: msg => { 63 + console.log(cmdName, 'msg', msg); 64 + const parts = msg.typed.split(' '); 65 + parts.shift(); 66 + const address = parts.shift(); 67 + return { 68 + command: 'openWebWindow', 69 + address 70 + }; 71 + } 72 + }); 73 + } 74 + 75 + /* 76 + async function sourceBookmarklets() { 77 + // add bookmarklets as commands 78 + let bmarklets = await browser.bookmarks.search({ query: 'javascript:'} ); 79 + bmarklets.map(b => { 80 + return { 81 + name: b.title, 82 + async execute(cmd) { 83 + //let tags = cmd.typed.split(' ').filter(w => w != cmd.name) 84 + //console.log('tags', tags) 85 + let tabs = await browser.tabs.query({active:true}); 86 + browser.tabs.executeScript(tabs[0].id, { 87 + code: b.url.replace('javascript:', '') 88 + }); 89 + } 90 + }; 91 + }).forEach(addCommand); 92 + } 93 + */ 94 + 95 + /* 96 + async function sourceBookmark() { 97 + addCommand({ 98 + name: 'bookmark current page', 99 + async execute() { 100 + let tab = await browser.tabs.query({active:true}); 101 + let node = await browser.bookmarks.create({ 102 + title: tab[0].title, 103 + url: tab[0].url 104 + }); 105 + } 106 + }); 107 + } 108 + */ 109 + 110 + /* 111 + // FIXME 112 + async function sourceEmail() { 113 + addCommand({ 114 + name: 'Email page to', 115 + async execute(msg) { 116 + let tabs = await browser.tabs.query({active:true}); 117 + let email = msg.typed.replace(msg.name, '').trim(); 118 + let url = 119 + 'mailto:' + email + 120 + '?subject=Web%20page!&body=' + 121 + encodeURIComponent(tabs[0].title) + 122 + '%0D%0A' + 123 + encodeURIComponent(tabs[0].url); 124 + tabs[0].url = url; 125 + } 126 + }); 127 + } 128 + */ 129 + 130 + /* 131 + async function sourceGoogleDocs() { 132 + [ 133 + { 134 + cmd: 'New Google doc', 135 + url: 'http://docs.google.com/document/create?hl=en' 136 + }, 137 + { 138 + cmd: 'New Google sheet', 139 + url: 'http://spreadsheets.google.com/ccc?new&hl=en' 140 + } 141 + ].forEach(function(doc) { 142 + addCommand({ 143 + name: doc.cmd, 144 + async execute(msg) { 145 + await browser.tabs.create({ 146 + url: doc.url 147 + }); 148 + } 149 + }); 150 + }); 151 + } 152 + */ 153 + 154 + /* 155 + async function sourceSendToWindow() { 156 + const cmdPrefix = 'Move to window: '; 157 + const windows = await browser.windows.getAll({windowTypes: ['normal']}); 158 + windows.forEach((w) => { 159 + addCommand({ 160 + name: cmdPrefix + w.title, 161 + async execute(msg) { 162 + const activeTabs = await browser.tabs.query({active: true}); 163 + browser.tabs.move(activeTabs[0].id, {windowId: w.id, index: -1}); 164 + } 165 + }); 166 + }); 167 + } 168 + */ 169 + 170 + /* 171 + async function sourceSwitchToWindow() { 172 + const cmdPrefix = 'Switch to window: '; 173 + const windows = await browser.windows.getAll({}); 174 + windows.forEach((w) => { 175 + addCommand({ 176 + name: cmdPrefix + w.title, 177 + async execute(msg) { 178 + browser.windows.update(w.id, { focused: true }); 179 + } 180 + }); 181 + }); 182 + } 183 + */ 184 + 185 + /* 186 + async function sourceNewContainerTab() { 187 + const cmdPrefix = 'New container tab: '; 188 + browser.contextualIdentities.query({}) 189 + .then((identities) => { 190 + if (!identities.length) 191 + return; 192 + for (let identity of identities) { 193 + addCommand({ 194 + name: cmdPrefix + identity.name, 195 + async execute(msg) { 196 + browser.tabs.create({url: '', cookieStoreId: identity.cookieStoreId }); 197 + } 198 + }); 199 + } 200 + }); 201 + } 202 + */ 203 + 204 + /* 205 + async function sourceSwitchTabContainer() { 206 + const cmdPrefix = 'Switch container to: '; 207 + browser.contextualIdentities.query({}) 208 + .then((identities) => { 209 + if (!identities.length) 210 + return; 211 + for (let identity of identities) { 212 + addCommand({ 213 + name: cmdPrefix + identity.name, 214 + async execute(msg) { 215 + const activeTabs = await browser.tabs.query({currentWindow: true, active: true}); 216 + const tab = activeTabs[0]; 217 + // some risk of losing old tab if new tab was not created successfully 218 + // but putting remove in creation was getting killed by window close 219 + // so when execution is moved to background script, try moving this back 220 + browser.tabs.remove(tab.id); 221 + browser.tabs.create({url: tab.url, cookieStoreId: identity.cookieStoreId, index: tab.index+1, pinned: tab.pinned }).then(() => { 222 + // tab remove should be here 223 + }); 224 + } 225 + }); 226 + } 227 + }); 228 + } 229 + */ 230 + 231 + /* 232 + async function sourceNote() { 233 + addCommand({ 234 + name: 'note', 235 + async execute(msg) { 236 + console.log('note execd', msg) 237 + if (msg.typed.indexOf(' ')) { 238 + let note = msg.typed.replace('note ', ''); 239 + await saveNewNote(note) 240 + notify('note saved!', note) 241 + } 242 + } 243 + }); 244 + 245 + const STG_KEY = 'cmd:notes'; 246 + const STG_TYPE = 'local'; 247 + 248 + async function saveNewNote(note) { 249 + let store = await browser.storage[STG_TYPE].get(STG_KEY) 250 + console.log('store', store) 251 + if (Object.keys(store).indexOf(STG_KEY) == -1) { 252 + console.log('new store') 253 + store = { 254 + notes: [] 255 + } 256 + } 257 + else { 258 + store = store[STG_KEY] 259 + } 260 + store.notes.push(note) 261 + 262 + await browser.storage[STG_TYPE].set({ [STG_KEY] : store}) 263 + console.log('saved store', store); 264 + } 265 + } 266 + await sourceNote() 267 + */ 268 + 269 + function dbg(...args) { 270 + if (DEBUG == 1) { 271 + console.log(...args) 272 + } 273 + } 274 + 275 + function notify(title, content) { 276 + browser.notifications.create({ 277 + "type": "basic", 278 + "iconUrl": browser.extension.getURL("images/icon.png"), 279 + "title": title, 280 + "message": content 281 + }); 282 + } 283 + 284 + })();
+20
features/cmd/panel.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <title>cmd</title> 5 + <meta charset="utf-8"> 6 + <meta name="description" content=""> 7 + <meta name="viewport" content="width=device-width, initial-scale=1"> 8 + <style> 9 + body { 10 + margin: 0; 11 + padding: 0; 12 + } 13 + </style> 14 + </head> 15 + <body> 16 + <script src="./panel.js"></script> 17 + <script src="./commands.js"></script> 18 + </body> 19 + </html> 20 +
+411
features/cmd/panel.js
··· 1 + /* 2 + 3 + TODO: NOW 4 + * multistring search, eg "new pl" matches "new container tab: PL" 5 + * <tab> to move to next in list (figure out vs params, chaining, etc) 6 + * store state data in add-on, not localStorage 7 + * placeholder text not working in release 8 + * fix default command 9 + * move command execution to background script 10 + 11 + TODO: NEXT 12 + * command suggestions (listed below - eg, see windows) 13 + * command parameters 14 + * command screenshots (eg, switch to window) 15 + * command chaining 16 + 17 + TODO: FUTURE 18 + * remember last-executed command across restarts 19 + * better visual fix for overflow text 20 + * commands that identify things in the page and act on them (locations, events, people) 21 + 22 + TODO: Settings 23 + * add settings to right corner 24 + * settings page 25 + * configurable shortcut 26 + 27 + TODO: Long running jobs 28 + * add support for long-running jobs 29 + * add support for "log in to <svc>" 30 + * add notifications to right corner 31 + 32 + TODO: Commands 33 + * switch to window command, searching by title (named windows?) 34 + * IPFS 35 + * Flickr 36 + * Pocket 37 + 38 + */ 39 + 40 + (async () => { 41 + 42 + let state = { 43 + commands: [], // array of command names 44 + matches: [], // array of commands matching the typed text 45 + matchIndex: 0, // index of ??? 46 + matchCounts: {}, // match counts - selectedcommand:numberofselections 47 + matchFeedback: {}, // adaptive matching - partiallytypedandselected:fullname 48 + typed: '', // text typed by user so far, if any 49 + lastExecuted: '' // text last typed by user when last they hit return 50 + }; 51 + 52 + window.addEventListener('cmd-update-commands', function(e) { 53 + //console.log('ui received updated commands'); 54 + state.commands = e.detail; 55 + }); 56 + 57 + async function render() { 58 + // Outer container 59 + let panel = document.createElement('div'); 60 + panel.id = 'cmdPanel'; 61 + panel.classList.add('cmdPanel'); 62 + 63 + await css(panel, { 64 + //border: '1px solid black', 65 + display: 'flex', 66 + flexDirection: 'column', 67 + justifyContent: 'center', 68 + margin: '0', 69 + padding: '0', 70 + height: '3rem', 71 + width: '20rem', 72 + paddingLeft: '1rem', 73 + paddingRight: '1rem', 74 + }); 75 + 76 + // Where text is shown 77 + let input = document.createElement('div'); 78 + input.id = 'cmdInput'; 79 + 80 + await css(input, { 81 + //border: '1px solid black', 82 + //overflow: 'hidden', 83 + whiteSpace: 'nowrap', 84 + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif', 85 + fontSize: 'large' 86 + }); 87 + 88 + panel.appendChild(input); 89 + 90 + document.body.appendChild(panel); 91 + 92 + updateInputUI('Start typing...') 93 + 94 + // add event listeners 95 + document.addEventListener('keyup', onKeyup, true); 96 + document.addEventListener('keypress', onKeyDummyStop, true); 97 + document.addEventListener('keydown', onKeyDummyStop, true); 98 + document.addEventListener('input', onKeyDummyStop, true); 99 + } 100 + 101 + render(); 102 + 103 + async function css(el, props) { 104 + Object.keys(props).forEach(p => el.style[p] = props[p]); 105 + } 106 + 107 + async function execute(name, typed) { 108 + if (state.commands[name]) { 109 + // execute command 110 + const msg = state.commands[name].execute({typed}); 111 + window.app.sendMessage({ 112 + feature: 'cmd', 113 + data: msg 114 + }); 115 + // close cmd popup 116 + // NOTE: this kills command execution 117 + // hrghhh, gotta turn execution completion promise 118 + // or run em async in background script 119 + setTimeout(shutdown, 100) 120 + } 121 + } 122 + 123 + function findMatchingCommands(text) { 124 + const r = true; 125 + r || console.log('findMatchingCommands', text, state.commands.length); 126 + 127 + let count = state.commands.length, 128 + matches = []; 129 + 130 + // Iterate over all commands, searching for matches 131 + //for (var i = 0; i < count; i++) { 132 + //for (const [name, properties] of Object.entries(state.commands)) { 133 + for (const name of Object.keys(state.commands)) { 134 + // Match when: 135 + // 1. typed string is anywhere in a command name 136 + // 2. command name is at beginning of typed string 137 + // (eg: for command input - "weather san diego") 138 + r || console.log('testing option...', name); 139 + if (name.toLowerCase().indexOf(state.typed.toLowerCase()) != -1 || 140 + state.typed.toLowerCase().indexOf(name.toLowerCase()) === 0) { 141 + matches.push(name); 142 + } 143 + } 144 + 145 + // sort by match count 146 + state.matches.sort(function(a, b) { 147 + var aCount = state.matchCounts[a] || 0; 148 + var bCount = state.matchCounts[b] || 0; 149 + return bCount - aCount; 150 + }) 151 + 152 + // insert adaptive feedback 153 + if (state.matchFeedback[state.typed]) { 154 + state.matches.unshift(state.matchFeedback[state.typed]) 155 + } 156 + 157 + return matches; 158 + } 159 + 160 + function updateMatchFeedback(typed, name) { 161 + state.matchFeedback[typed] = name; 162 + } 163 + 164 + function updateMatchCount(name) { 165 + if (!state.matchCounts[name]); 166 + state.matchCounts[name] = 0; 167 + state.matchCounts[name]++; 168 + } 169 + 170 + async function shutdown() { 171 + window.close(); 172 + /* 173 + let container = document.querySelector('#cmdContainer'); 174 + if (container) { 175 + document.body.removeChild(container); 176 + } 177 + document.removeEventListener('keyup', onKeyup, true); 178 + document.removeEventListener('keypress', onKeyDummyStop, true); 179 + document.removeEventListener('keydown', onKeyDummyStop, true); 180 + document.removeEventListener('input', onKeyDummyStop, true); 181 + */ 182 + //console.log('ui shutdown complete'); 183 + } 184 + 185 + function onKeyDummyStop(e) { 186 + e.preventDefault(); 187 + } 188 + 189 + async function onKeyup(e) { 190 + // flag for logging 191 + const r = true; 192 + 193 + e.preventDefault(); 194 + 195 + if (isModifier(e)) { 196 + return; 197 + } 198 + 199 + r || console.log('onKeyup', e.key, e.which) 200 + r || console.log('hasModifier', hasModifier(e), 'isModifier', isModifier(e), 'isIgnorable', isIgnorable(e)); 201 + 202 + e.preventDefault(); 203 + 204 + // if user pressed escape, go away 205 + if (e.key == 'Escape' && !hasModifier(e)) { 206 + r || console.log('onKeyUp: escape!'); 207 + await shutdown(); 208 + } 209 + 210 + // if user pressed return, attempt to execute command 211 + else if (e.key == 'Enter' && !hasModifier(e)) { 212 + r || console.log('onKeyUp: enter!', state.typed); 213 + let name = state.matches[state.matchIndex]; 214 + if (Object.keys(state.commands).indexOf(name) > -1) { 215 + //await shutdown(); 216 + execute(name, state.typed); 217 + state.lastExecuted = name; 218 + updateMatchCount(name); 219 + updateMatchFeedback(state.typed, name); 220 + state.typed = ''; 221 + } 222 + } 223 + 224 + // attempt to complete typed characters to a command 225 + // or do other modifications based on user typed keys 226 + else if (!hasModifier(e) && !isModifier(e) && !isIgnorable(e)) { 227 + r || console.log('LEGIT... no modifier, is not a modifier and not ignorable') 228 + 229 + // correct on backspace 230 + if (e.key == 'Backspace') { 231 + r || console.log('back', state.typed); 232 + if (state.typed.length > 0) { 233 + r || console.log('back, no typed tho'); 234 + state.typed = state.typed.substring(0, state.typed.length - 1); 235 + } 236 + } 237 + // otherwise add typed character to buffer 238 + else { 239 + r || console.log('updating', e.key); 240 + state.typed += e.key 241 + } 242 + 243 + // search, and update UI 244 + state.matches = findMatchingCommands(state.typed); 245 + if (state.matches.length) { 246 + r || console.log('matches!', state.matches); 247 + updateInputUI(state.typed, state.matches[0]); 248 + state.matchIndex = 0; 249 + } 250 + else { 251 + r || console.log('no matches for ', state.typed); 252 + updateInputUI(state.typed); 253 + } 254 + } 255 + 256 + // if up arrow and currently visible command is not first, select one previous 257 + else if (e.key == 'ArrowUp' && state.matchIndex) { 258 + r || console.log('onKeyUp: arrow up!'); 259 + updateInputUI(state.typed, state.matches[--state.matchIndex]); 260 + } 261 + 262 + // if down arrow and there are more matches, select the next one 263 + else if (e.key == 'ArrowDown' && state.matchIndex + 1 < state.matches.length) { 264 + r || console.log('onKeyUp: arrow down!'); 265 + updateInputUI(state.typed, state.matches[++state.matchIndex]); 266 + } 267 + 268 + // Old behavior on tab: 269 + // tab -> shift to next result 270 + // shift + tab -> shift to previous result 271 + // New behavior on tab: 272 + // autocomplete to the matched command 273 + // which allows easy adding onto a command name 274 + // without having to type all the same visible text 275 + else if (e.key == 'Tab' && state.matches) { 276 + r || console.log('onKeyUp: tab!'); 277 + state.typed = state.matches[state.matchIndex] 278 + updateInputUI(state.typed, state.matches[state.matchIndex]); 279 + //if (e.shiftKey && matchIndex) 280 + // updateInputUI(state.typed, state.matches[--state.matchIndex]); 281 + //else if (state.matchIndex + 1 < state.matches.length) 282 + // updateInputUI(state.typed, state.matches[++state.matchIndex]); 283 + } 284 + } 285 + 286 + function hasModifier(e) { 287 + return e.altKey || e.ctrlKey || e.metaKey; 288 + } 289 + 290 + function isModifier(e) { 291 + return ['Alt', 'Control', 'Shift', 'Meta'].indexOf(e.key) != -1; 292 + } 293 + 294 + function isIgnorable(e) { 295 + switch(e.which) { 296 + case 38: //up arrow 297 + case 40: //down arrow 298 + case 37: //left arrow 299 + case 39: //right arrow 300 + case 33: //page up 301 + case 34: //page down 302 + case 36: //home 303 + case 35: //end 304 + case 13: //enter 305 + case 9: //tab 306 + case 27: //esc 307 + case 16: //shift 308 + case 17: //ctrl 309 + case 18: //alt 310 + case 20: //caps lock 311 + // we handle this for editing 312 + //case 8: //backspace 313 + // need to handle for editing also? 314 + case 46: //delete 315 + case 224: //meta 316 + case 0: 317 + return true; 318 + break; 319 + default: 320 + return false; 321 + } 322 + } 323 + 324 + function updateInputUI(typed, completed) { 325 + const r = true; 326 + r || console.log('updateInputUI', typed, completed); 327 + let str = '' 328 + if (completed) { 329 + str = generateUnderlined(typed, completed); 330 + } 331 + // no match 332 + else if (typed) { 333 + str = typed; 334 + } 335 + 336 + let parser = new DOMParser(); 337 + let doc = parser.parseFromString(str, 'text/html'); 338 + 339 + let input = document.querySelector('#cmdInput'); 340 + if (input && input.firstElementChild) 341 + input.removeChild(input.firstElementChild); 342 + input.appendChild(doc.firstElementChild); 343 + 344 + /* 345 + let parent = input.parentNode; 346 + state.matches.forEach(match => { 347 + let node = document.createElement('div') 348 + node.innerText = match 349 + parent.appendChild(node) 350 + }); 351 + */ 352 + } 353 + 354 + // typed text, inline matching suggestion 355 + function generateUnderlined(typed, match) { 356 + const r = 1; 357 + r || console.log('generateUnderlined', typed, match); 358 + // user already matched a commmand and added params 359 + /* 360 + if (match.length > typed.length && 361 + match.indexOf(typed) === 0) { 362 + return match; 363 + } 364 + */ 365 + 366 + if (typed.length == 0 || match.length == 0) 367 + return typed; 368 + 369 + // look for typed within match 370 + var startIndex = match.toLowerCase().indexOf(typed.toLowerCase()); 371 + if (startIndex == -1) { 372 + // otherwise look for match in typed 373 + // (why would this happen?!) 374 + startIndex = match.toLowerCase().indexOf(typed.toLowerCase()); 375 + if (startIndex == -1) { 376 + return typed; 377 + } 378 + } 379 + 380 + var endIndex = startIndex + match.length; 381 + r || console.log('startIndex', startIndex, 'endIndex', endIndex); 382 + var str = '' 383 + 384 + // substring is empty 385 + if (!match) { 386 + r || console.log('no suggestion, so no underline') 387 + str = '<span>' + typed + '</span>' 388 + } 389 + // occurs at beginning 390 + else if (startIndex === 0) { 391 + r || console.log('start'); 392 + str = '<span style="text-decoration: underline;">' + typed + '</span>' + 393 + '<span style="color: #6E6E6E;">' + match.substring(typed.length) + '</span>'; 394 + } 395 + // occurs in middle 396 + else if (startIndex > 0) { 397 + r || console.log('middle'); 398 + str = "<span style='color: #6E6E6E;'>" + match.substring(0, startIndex) + "</span>" + 399 + "<span style='text-decoration: underline;'>" + match.substring(startIndex, startIndex + typed.length) + "</span>" + 400 + "<span style='color: #6E6E6E;'>" + match.substring(startIndex + typed.length) + "</span>"; 401 + } 402 + // occurs at the end 403 + else { 404 + r || console.log('end'); 405 + str = "<span class='completed'>" + typed.substring(0, startIndex) + "</span>" + 406 + "<span class='typed'>" + match + "</span>"; 407 + } 408 + return str; 409 + } 410 + 411 + })();
+46 -25
features/input/input.js features/cmd/cmd.js
··· 1 - // input/input.js 1 + // cmd/cmd.js 2 2 (async () => { 3 3 4 - console.log('input/input'); 4 + console.log('cmd/cmd'); 5 5 6 6 const labels = { 7 - featureType: 'input', 8 - featureDisplay: 'Input', 7 + featureType: 'cmd', 8 + featureDisplay: 'Cmd', 9 9 prefs: { 10 - shortcutKey: 'Input shortcut', 10 + shortcutKey: 'Cmd shortcut', 11 11 } 12 12 }; 13 13 ··· 23 23 24 24 const prefsSchema = { 25 25 "$schema": "https://json-schema.org/draft/2020-12/schema", 26 - "$id": "peek.input.prefs.schema.json", 27 - "title": "Input preferences", 28 - "description": "Peek app Input user preferences", 26 + "$id": "peek.cmd.prefs.schema.json", 27 + "title": "Cmd preferences", 28 + "description": "Peek app Cmd user preferences", 29 29 "type": "object", 30 30 "properties": { 31 31 "shortcutKey": { 32 - "description": "Global OS hotkey to open input", 32 + "description": "Global OS hotkey to open command panel", 33 33 "type": "string", 34 34 "default": "Option+Space" 35 35 }, ··· 37 37 "required": [ "shortcutKey"] 38 38 }; 39 39 40 + /* 40 41 const itemSchema = { 41 42 "$schema": "https://json-schema.org/draft/2020-12/schema", 42 - "$id": "peek.input.entry.schema.json", 43 - "title": "Peek - input entry", 44 - "description": "Peek input entry", 43 + "$id": "peek.cmd.entry.schema.json", 44 + "title": "Peek - command entry", 45 + "description": "Peek command entry", 45 46 "type": "object", 46 47 "properties": { 47 48 "keyNum": { ··· 95 96 type: 'array', 96 97 items: { "$ref": "#/$defs/entry" } 97 98 }; 99 + */ 98 100 99 101 // TODO: schemaize 0-9 constraints for peeks 100 102 const schemas = { 101 103 prefs: prefsSchema, 102 - item: itemSchema, 103 - items: listSchema 104 + //item: itemSchema, 105 + //items: listSchema 104 106 }; 105 107 106 108 const _defaults = { ··· 111 113 112 114 let _windows = {}; 113 115 114 - const openInputWindow = (api, item) => { 115 - // TODO: implement me 116 + const openInputWindow = (api) => { 117 + const params = { 118 + type: labels.featureType, 119 + file: 'features/cmd/panel.html', 120 + height: 50, 121 + width: 600 122 + }; 123 + 124 + _api.openWebWindow(params); 125 + 116 126 return; 117 127 118 - const height = item.height || 600; 119 - const width = item.width || 800; 128 + const height = 50; 129 + const width = 600; 120 130 121 131 let win = null; 122 132 123 - const windowKey = labels.featureType + item.keyNum; 133 + const windowKey = labels.featureType; 124 134 125 135 if (_windows[windowKey]) { 126 - console.log(labels.featureType, item.keyNum, 'using stored window'); 136 + console.log(labels.featureType, 'using stored window'); 127 137 win = _windows[windowKey]; 128 138 win.show(); 129 139 } 130 140 else { 131 - console.log(labels.featureType, item.keyNum, 'creating new window'); 141 + console.log(labels.featureType, 'creating new window'); 132 142 133 143 win = new BrowserWindow({ 134 144 height, ··· 162 172 win.on('blur', onGoAway); 163 173 win.on('close', onGoAway); 164 174 165 - win.webContents.send('window', { type: labels.featureType, id: win.id, data: item }); 175 + win.webContents.send('window', { type: labels.featureType, id: win.id}); 176 + 177 + //win.webContents.openDevTools(); 166 178 167 - win.loadURL(item.address); 179 + win.loadFile('features/cmd/panel.html'); 180 + console.log('loaded'); 168 181 }; 169 182 170 183 const initStore = (store, data) => { ··· 211 224 // TODO only update store if changed 212 225 // and re-init 213 226 if (changed.prefs) { 214 - console.log('input: updating prefs', changed.prefs); 227 + console.log('cmd: updating prefs', changed.prefs); 215 228 _store.set('prefs', changed.prefs); 216 229 } 217 230 218 231 if (changed.items) { 219 232 _store.set('items', changed.items); 233 + } 234 + }; 235 + 236 + const onMessage = msg => { 237 + console.log('cmd:onMessage', msg) 238 + if (msg.command == 'openWebWindow') { 239 + _api.openWebWindow(msg); 220 240 } 221 241 }; 222 242 ··· 236 256 data: { 237 257 get prefs() { return _store.get('prefs'); }, 238 258 }, 239 - onChange 259 + onChange, 260 + onMessage: onMessage 240 261 }; 241 262 242 263
+33
features/settings/content.css
··· 1 + body { 2 + font-family: -apple-system, BlinkMacSystemFont, helvetica neue, helvetica, sans-serif; 3 + font-feature-settings: "tnum"; 4 + font-size: 12.4px; 5 + font-variant-numeric: tabular-nums; 6 + margin: 12px; 7 + } 8 + 9 + body > div { 10 + margin-bottom: 10px; 11 + } 12 + 13 + body > div > div { 14 + margin-bottom: 10px; 15 + } 16 + 17 + h1 { 18 + margin-top: 1px; 19 + margin-bottom: 2px; 20 + } 21 + 22 + .houseofpane { 23 + display: flex; 24 + flex-wrap: wrap; 25 + justify-content: space-between; 26 + } 27 + 28 + /* tweakpanes */ 29 + .houseofpane > div > div { 30 + min-width: 380px; 31 + max-width: 380px; 32 + margin-bottom: 10px; 33 + }
+30
features/settings/content.html
··· 1 + <!--index.html--> 2 + 3 + <!DOCTYPE html> 4 + <html> 5 + <head> 6 + <meta charset="UTF-8"> 7 + <!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP --> 8 + <meta http-equiv="Content-Security-Policy" content="script-src 'self';"> 9 + <title>peek</title> 10 + <link rel="stylesheet" href="content.css"> 11 + </head> 12 + <body> 13 + <div> 14 + <h1>peek</h1> 15 + </div> 16 + 17 + <div class="houseofpane"> 18 + </div> 19 + 20 + <div> 21 + Node.js <span id="node-version"></span><br> 22 + Chromium <span id="chrome-version"></span><br> 23 + Electron <span id="electron-version"></span><br> 24 + </div> 25 + 26 + <script type=module src="./../../node_modules/tweakpane/dist/tweakpane.js"></script> 27 + <script type=module src="./content.js"></script> 28 + 29 + </body> 30 + </html>
+253
features/settings/content.js
··· 1 + console.log('renderer'); 2 + 3 + // TODO: capture and internally navigate out of panes 4 + window.addEventListener('keyup', e => { 5 + //console.log('renderer', 'onkeyup', e); 6 + if (e.key == 'Escape') { 7 + //ipcRenderer.send('esc', ''); 8 + } 9 + }); 10 + 11 + // send changes back to main process 12 + // it will notify us when saved 13 + // and we'll reload entirely 😐 14 + const updateToMain = data => { 15 + console.log('renderer: updating to main', data); 16 + window.app.setConfig(data); 17 + }; 18 + 19 + let panes = []; 20 + 21 + const init = cfg => { 22 + console.log('renderer: init'); 23 + //console.log('renderer: cfg', cfg); 24 + 25 + let { prefs, features } = cfg; 26 + 27 + const container = document.querySelector('.houseofpane'); 28 + 29 + // blow away panes if this is an update 30 + if (panes.length > 0) { 31 + panes.forEach(p => { 32 + p.pane.dispose(); 33 + }); 34 + panes = []; 35 + } 36 + 37 + let rowContainer = document.createElement('div'); 38 + 39 + cfg.features.forEach((feature, i) => { 40 + // new row 41 + if (i % 2 == 0) { 42 + rowContainer = document.createElement('div'); 43 + container.appendChild(rowContainer); 44 + } 45 + 46 + const type = feature.labels.featureType; 47 + 48 + const paneContainer = document.createElement('div'); 49 + rowContainer.appendChild(paneContainer); 50 + 51 + const allowNew = feature.config.allowNew || false; 52 + const disabled = feature.config.disabled || []; 53 + 54 + const onChange = newData => { 55 + const p = {}; 56 + p[type] = newData; 57 + updateToMain(p); 58 + }; 59 + 60 + const pane = initFeaturePane( 61 + paneContainer, 62 + feature, 63 + onChange 64 + ); 65 + 66 + panes.push({ 67 + paneContainer, 68 + pane 69 + }); 70 + }); 71 + }; 72 + 73 + const fillPaneFromSchema = (pane, labels, schema, data, onChange, disabled) => { 74 + const props = schema.properties; 75 + Object.keys(props).forEach(k => { 76 + // schema for property 77 + const s = props[k]; 78 + 79 + // value (or default) 80 + const v = 81 + (data && data.hasOwnProperty(k)) 82 + ? data[k] 83 + : props[k].default; 84 + 85 + const params = {}; 86 + const opts = {}; 87 + 88 + // dedecimalize 89 + if (s.type == 'integer') { 90 + opts.step = 1; 91 + } 92 + 93 + // disabled fields 94 + if (disabled.includes(k)) { 95 + opts.disabled = true; 96 + } 97 + 98 + params[k] = v; 99 + 100 + const input = pane.addInput(params, k, opts); 101 + 102 + // TODO: consider inline state management 103 + input.on('change', ev => { 104 + // TODO: validate against schema 105 + console.log('inline field change', k, ev) 106 + data[k] = ev.value; 107 + onChange(data) 108 + }); 109 + }); 110 + }; 111 + 112 + // TODO: fuckfuckfuck 113 + // https://github.com/cocopon/tweakpane/issues/431 114 + const exportPaneData = pane => { 115 + const children = pane.rackApi_.children.filter(p => p.children); 116 + const val = pane.rackApi_.children.filter(p => p.children).map(paneChild => { 117 + return paneChild.children.reduce((obj, field) => { 118 + const k = field.label; 119 + if (!k) { 120 + return obj; 121 + } 122 + 123 + let v = null; 124 + 125 + const input = field.element.querySelector('.tp-txtv_i') 126 + if (input) { 127 + v = input.value; 128 + } 129 + 130 + const checkbox = field.element.querySelector('.tp-ckbv_i'); 131 + if (checkbox) { 132 + v = checkbox.checked; 133 + } 134 + 135 + // TODO: drop fields not supported for now 136 + if (v) { 137 + obj[k] = v; 138 + } 139 + 140 + return obj; 141 + }, {}); 142 + }); 143 + return val; 144 + }; 145 + 146 + const initFeaturePane = (container, feature, onChange) => { 147 + const { config, labels, prefs, schemas, data } = feature; 148 + 149 + const pane = new Tweakpane.Pane({ 150 + container: container, 151 + title: labels.featureDisplay 152 + }); 153 + 154 + const update = (all) => { 155 + const paneData = exportPaneData(pane); 156 + 157 + console.log('folder level update for', labels.featureDisplay, paneData); 158 + 159 + let updated = { 160 + }; 161 + 162 + // TODO: make this right, ugh 163 + if (data.prefs) { 164 + updated.prefs = paneData.shift(); 165 + } 166 + 167 + // remove "new item" entry if not editable feature 168 + // TODO: make this right 169 + if (!all) { 170 + newData.pop(); 171 + } 172 + 173 + if (paneData.length > 0) { 174 + updated.items = paneData; 175 + } 176 + 177 + onChange(updated); 178 + }; 179 + 180 + // prefs pane 181 + if (data.prefs) { 182 + 183 + const prefsFolder = pane.addFolder({ 184 + title: schemas.prefs.title, 185 + expanded: true 186 + }); 187 + 188 + const onPrefChange = changed => { 189 + console.log('initFeaturePane::onPrefChange', changed) 190 + update(!config.allowNew); 191 + }; 192 + 193 + fillPaneFromSchema(prefsFolder, labels, schemas.prefs, data.prefs, onPrefChange, []); 194 + } 195 + 196 + // add items 197 + if (data.hasOwnProperty('items')) { 198 + data.items.forEach(item => { 199 + const folder = pane.addFolder({ 200 + title: item.title, 201 + expanded: false 202 + }); 203 + 204 + fillPaneFromSchema(folder, labels, schemas.item, item, update, config.disabled); 205 + 206 + // TODO: implement 207 + //folder.addButton({title: labels.testBtn}); 208 + 209 + if (config.allowNew) { 210 + const delBtn = folder.addButton({title: labels.delBtn}); 211 + delBtn.on('click', () => { 212 + pane.remove(folder); 213 + // TODO: https://github.com/cocopon/tweakpane/issues/533 214 + update(); 215 + }); 216 + } 217 + 218 + //folder.on('change', () => update(!config.allowNew)); 219 + }); 220 + } 221 + 222 + /* 223 + if (config.allowNew) { 224 + // add new item entry 225 + const folder = pane.addFolder({ 226 + title: labels.newFolder, 227 + expanded: false 228 + }); 229 + 230 + //fillPaneFromSchema(folder, labels, schema); 231 + fillPaneFromSchema(folder, labels, schema, {}, onChange, disabled); 232 + 233 + const btn = pane.addButton({title: labels.addBtn}); 234 + 235 + // handle adds of new entries 236 + btn.on('click', () => { 237 + update(true); 238 + }); 239 + } 240 + */ 241 + 242 + return pane; 243 + }; 244 + 245 + // listen for data changes 246 + window.app.onConfigChange(() => { 247 + console.log('onconfigchange'); 248 + window.app.getConfig.then(init); 249 + }); 250 + 251 + // initialization: get data and load ui 252 + window.app.getConfig.then(init); 253 +
+207
features/settings/settings.js
··· 1 + // settings/settings.js 2 + (async () => { 3 + 4 + console.log('settings/settings'); 5 + 6 + const labels = { 7 + featureType: 'settings', 8 + featureDisplay: 'Settings', 9 + prefs: { 10 + shortcutKey: 'Settings shortcut', 11 + } 12 + }; 13 + 14 + const { 15 + BrowserWindow, 16 + globalShortcut, 17 + } = require('electron'); 18 + 19 + const path = require('path'); 20 + 21 + let _store = null; 22 + let _data = {}; 23 + 24 + const prefsSchema = { 25 + "$schema": "https://json-schema.org/draft/2020-12/schema", 26 + "$id": "peek.prefs.schema.json", 27 + "title": "Application and Settings preferences", 28 + "description": "Peek user preferences", 29 + "type": "object", 30 + "properties": { 31 + "shortcutKey": { 32 + "description": "App keyboard shortcut to load settings", 33 + "type": "string", 34 + "default": "CommandOrControl+," 35 + }, 36 + "height": { 37 + "description": "User-set or -defined height of Settings page", 38 + "type": "integer", 39 + "default": 600 40 + }, 41 + "width": { 42 + "description": "User-set or -defined width of Settings page", 43 + "type": "integer", 44 + "default": 800 45 + }, 46 + }, 47 + "required": [ "shortcutKey" ] 48 + }; 49 + 50 + // TODO: schemaize 0-9 constraints for peeks 51 + const schemas = { 52 + prefs: prefsSchema, 53 + }; 54 + 55 + const _defaults = { 56 + prefs: { 57 + shortcutKey: 'Option+,', 58 + height: 600, 59 + width: 800, 60 + }, 61 + }; 62 + 63 + let _windows = {}; 64 + 65 + const openSettingsWindow = (api, prefs) => { 66 + const height = prefs.height || 600; 67 + const width = prefs.width || 800; 68 + 69 + let win = null; 70 + 71 + const windowKey = labels.featureType; 72 + 73 + if (_windows[windowKey]) { 74 + console.log(labels.featureType, 'using stored window'); 75 + win = _windows[windowKey]; 76 + win.show(); 77 + } 78 + else { 79 + console.log(labels.featureType, 'creating new window'); 80 + 81 + win = new BrowserWindow({ 82 + height, 83 + width, 84 + center: true, 85 + skipTaskbar: true, 86 + autoHideMenuBar: true, 87 + titleBarStyle: 'hidden', 88 + webPreferences: { 89 + preload: api.preloadPath, 90 + // isolate content and do not persist it 91 + partition: Date.now() 92 + } 93 + }); 94 + 95 + //_windows[windowKey] = win; 96 + } 97 + 98 + win.webContents.openDevTools(); 99 + 100 + /* 101 + win.on('close', (e) => { 102 + console.log('onClose - just hiding'); 103 + e.preventDefault(); 104 + win.hide(); 105 + }); 106 + */ 107 + 108 + const onGoAway = () => { 109 + /* 110 + if (item.keepLive) { 111 + _windows[windowKey] = win; 112 + win.hide(); 113 + } 114 + else { 115 + win.destroy(); 116 + } 117 + */ 118 + win.destroy(); 119 + } 120 + win.on('blur', onGoAway); 121 + win.on('close', onGoAway); 122 + 123 + win.webContents.send('window', { type: labels.featureType, id: win.id, data: prefs }); 124 + 125 + win.loadFile('features/settings/content.html'); 126 + }; 127 + 128 + const initStore = (store, data) => { 129 + const sp = store.get('prefs'); 130 + if (!sp) { 131 + store.set('prefs', data.prefs); 132 + } 133 + }; 134 + 135 + const initShortcut = (api, prefs) => { 136 + const shortcut = prefs.shortcutKey; 137 + 138 + if (globalShortcut.isRegistered(shortcut)) { 139 + globalShortcut.unregister(shortcut); 140 + } 141 + 142 + const ret = globalShortcut.register(shortcut, () => { 143 + openSettingsWindow(api, prefs); 144 + }); 145 + 146 + if (!ret) { 147 + console.error('Unable to register shortcut', shortcut); 148 + } 149 + }; 150 + 151 + const init = (api, store) => { 152 + console.log('settings: init'); 153 + 154 + _store = store; 155 + _api = api; 156 + 157 + initStore(_store, _defaults); 158 + 159 + _data = { 160 + get prefs() { return _store.get('prefs'); }, 161 + //get items() { return _store.get('items'); }, 162 + }; 163 + 164 + initShortcut(api, _data.prefs); 165 + }; 166 + 167 + const onChange = (changed, old) => { 168 + console.log(labels.featureType, 'onChange', changed); 169 + 170 + // TODO only update store if changed 171 + // and re-init 172 + if (changed.prefs) { 173 + _store.set('prefs', changed.prefs); 174 + } 175 + 176 + if (changed.items) { 177 + _store.set('items', changed.items); 178 + } 179 + }; 180 + 181 + // ui config 182 + const config = { 183 + // allow user to create new items 184 + allowNew: false, 185 + // fields that are view only 186 + disabled: ['keyNum'], 187 + }; 188 + 189 + const open = () => { 190 + openSettingsWindow(_api, _data.prefs); 191 + }; 192 + 193 + module.exports = { 194 + init: init, 195 + config, 196 + labels, 197 + schemas, 198 + data: { 199 + get prefs() { 200 + return _store.get('prefs'); 201 + } 202 + }, 203 + open, 204 + onChange 205 + }; 206 + 207 + })();
+73 -3
index.js
··· 62 62 63 63 const features = { 64 64 settings: require('./features/settings/settings'), 65 - input: require('./features/input/input'), 65 + cmd: require('./features/cmd/cmd'), 66 66 peeks: require('./features/peeks/peeks'), 67 67 slides: require('./features/slides/slides'), 68 68 scripts: require('./features/scripts/scripts'), ··· 107 107 _tray = new Tray(ICON_PATH); 108 108 _tray.setToolTip(labels.tray.tooltip); 109 109 _tray.on('click', () => { 110 - getMainWindow().show(); 110 + features.settings.open(); 111 111 }); 112 112 } 113 113 return _tray; ··· 159 159 // eventually get to less tight coupling 160 160 const api = { 161 161 preloadPath, 162 + openWebWindow 162 163 }; 163 164 164 165 const datastorePrefix = 'peekFeature'; ··· 209 210 if (BrowserWindow.getAllWindows().length === 0) { 210 211 features.settings.open(); 211 212 } 212 - 213 213 }; 214 214 215 215 app.whenReady().then(onReady); ··· 229 229 updateData(newData); 230 230 }); 231 231 232 + // generic dispatch - messages only from trusted code (💀) 233 + ipcMain.on('sendmessage', (event, msg) => { 234 + console.log('sendmsg', msg); 235 + 236 + if (!msg.hasOwnProperty('feature')) { 237 + console.error('sendMessage', 'no feature property in message'); 238 + return; 239 + } 240 + 241 + const fkey = msg.feature; 242 + 243 + if (Object.keys(features).findIndex(k => k==fkey) == -1) { 244 + console.error('sendMessage', 'no matching feature'); 245 + return; 246 + } 247 + 248 + const feature = features[fkey]; 249 + 250 + if (!feature.hasOwnProperty('onMessage')) { 251 + console.error('sendMessage', 'feature has no message handler for', fkey); 252 + return; 253 + } 254 + 255 + feature.onMessage(msg.data); 256 + }); 257 + 232 258 // ipc ESC handler 233 259 ipcMain.on('esc', (event, title) => { 234 260 console.log('esc'); ··· 250 276 } 251 277 */ 252 278 }); 279 + 280 + 281 + const openWebWindow = (params) => { 282 + console.log('creating new web window', params); 283 + 284 + const height = params.height || 600; 285 + const width = params.width || 800; 286 + 287 + const win = new BrowserWindow({ 288 + height, 289 + width, 290 + center: true, 291 + skipTaskbar: true, 292 + autoHideMenuBar: true, 293 + titleBarStyle: 'hidden', 294 + webPreferences: { 295 + preload: preloadPath, 296 + // isolate content and do not persist it 297 + partition: Date.now() 298 + } 299 + }); 300 + 301 + const onGoAway = () => { 302 + win.destroy(); 303 + } 304 + win.on('blur', onGoAway); 305 + win.on('close', onGoAway); 306 + 307 + //win.webContents.send('window', { type: labels.featureType, id: win.id}); 308 + 309 + if (params.debug) { 310 + win.webContents.openDevTools(); 311 + } 312 + 313 + if (params.address) { 314 + win.loadURL(params.address); 315 + } 316 + else if (params.file) { 317 + win.loadFile('features/cmd/panel.html'); 318 + } 319 + else { 320 + console.error('openWindow: neither address nor file!'); 321 + } 322 + }; 253 323 254 324 // Quit when all windows are closed, except on macOS. There, it's common 255 325 // for applications and their menu bar to stay active until the user quits
+10
preload.js
··· 59 59 ipcRenderer.send('setconfig', cfg); 60 60 }; 61 61 62 + api.sendMessage = msg => { 63 + // noop if not an internal app file 64 + const isMain = window.location.protocol == 'file:'; 65 + if (!isMain) { 66 + return; 67 + } 68 + 69 + ipcRenderer.send('sendmessage', msg); 70 + }; 71 + 62 72 contextBridge.exposeInMainWorld('app', api); 63 73 64 74 const handleMainWindow = () => {