├── src ├── shims │ └── codemirror.js ├── background-tools │ ├── index.js │ └── ws-bridge.js ├── .eslintrc.yaml ├── content-script-tools │ ├── index.js │ ├── custom-events │ │ ├── index.js │ │ ├── workflowy.js │ │ ├── common.js │ │ └── google-inbox.js │ ├── content-events.js │ ├── element-normalizer.js │ └── text-syncer.js ├── background.js ├── handlers │ ├── injected │ │ ├── factory.js │ │ ├── index.js │ │ ├── codemirror.js │ │ ├── ace.js │ │ └── base.js │ ├── ace.js │ ├── factory.js │ ├── textarea.js │ ├── index.js │ ├── codemirror.js │ ├── base.js │ ├── content-editable.js │ └── injector.js ├── util │ └── string.js ├── content-script.js └── injected.js ├── .gitignore ├── app ├── images │ ├── icon.png │ ├── icon-128.png │ ├── icon-16.png │ ├── icon-19.png │ └── icon-38.png ├── _locales │ └── en │ │ └── messages.json └── manifest.json ├── package.json ├── .eslintrc.yaml ├── webpack.config.js ├── LICENSE └── README.md /src/shims/codemirror.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | app/scripts 2 | node_modules/ 3 | app/atomic-chrome.zip 4 | -------------------------------------------------------------------------------- /src/background-tools/index.js: -------------------------------------------------------------------------------- 1 | export {default as wsBridge} from './ws-bridge'; 2 | -------------------------------------------------------------------------------- /app/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danhper/atomic-chrome/HEAD/app/images/icon.png -------------------------------------------------------------------------------- /app/images/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danhper/atomic-chrome/HEAD/app/images/icon-128.png -------------------------------------------------------------------------------- /app/images/icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danhper/atomic-chrome/HEAD/app/images/icon-16.png -------------------------------------------------------------------------------- /app/images/icon-19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danhper/atomic-chrome/HEAD/app/images/icon-19.png -------------------------------------------------------------------------------- /app/images/icon-38.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danhper/atomic-chrome/HEAD/app/images/icon-38.png -------------------------------------------------------------------------------- /src/.eslintrc.yaml: -------------------------------------------------------------------------------- 1 | extends: ../.eslintrc.yaml 2 | 3 | env: 4 | browser: true 5 | 6 | parserOptions: 7 | sourceType: module 8 | 9 | globals: 10 | chrome: true 11 | ace: true 12 | -------------------------------------------------------------------------------- /src/content-script-tools/index.js: -------------------------------------------------------------------------------- 1 | export {default as textSyncer} from './text-syncer'; 2 | export {default as contentEvents} from './content-events'; 3 | export {default as elementNormalizer} from './element-normalizer'; 4 | -------------------------------------------------------------------------------- /src/content-script-tools/custom-events/index.js: -------------------------------------------------------------------------------- 1 | import common from './common'; 2 | import googleInbox from './google-inbox'; 3 | import workflowy from './workflowy'; 4 | 5 | export default [ 6 | common, 7 | googleInbox, 8 | workflowy 9 | ]; 10 | -------------------------------------------------------------------------------- /src/background.js: -------------------------------------------------------------------------------- 1 | import {wsBridge} from './background-tools'; 2 | 3 | chrome.browserAction.onClicked.addListener(() => { 4 | chrome.tabs.executeScript(null, { 5 | file: 'scripts/content-script.js' 6 | }); 7 | }); 8 | 9 | chrome.runtime.onConnect.addListener((port) => { 10 | wsBridge.openConnection(port); 11 | }); 12 | -------------------------------------------------------------------------------- /src/handlers/injected/factory.js: -------------------------------------------------------------------------------- 1 | class InjectedHandlerFactory { 2 | constructor() { 3 | this.handlers = {}; 4 | } 5 | 6 | registerHandler(name, klass) { 7 | this.handlers[name] = klass; 8 | } 9 | 10 | getHandler(name) { 11 | return this.handlers[name]; 12 | } 13 | } 14 | 15 | export default new InjectedHandlerFactory(); 16 | -------------------------------------------------------------------------------- /src/content-script-tools/content-events.js: -------------------------------------------------------------------------------- 1 | import events from './custom-events'; 2 | 3 | export default { 4 | bind: (target, window) => { 5 | const origin = window.location.origin; 6 | for (const event of events) { 7 | if (origin.match(event.url)) { 8 | event.bind.call(target, window); 9 | } 10 | } 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /src/handlers/injected/index.js: -------------------------------------------------------------------------------- 1 | import injectedHandlerFactory from './factory'; 2 | 3 | import InjectedAceHandler from './ace'; 4 | import InjectedCodeMirrorHandler from './codemirror'; 5 | 6 | injectedHandlerFactory.registerHandler('ace', InjectedAceHandler); 7 | injectedHandlerFactory.registerHandler('codemirror', InjectedCodeMirrorHandler); 8 | 9 | export {injectedHandlerFactory as injectedHandlerFactory}; 10 | -------------------------------------------------------------------------------- /src/handlers/ace.js: -------------------------------------------------------------------------------- 1 | import InjectorHandler from './injector'; 2 | 3 | const aceClassName = 'ace_text-input'; 4 | 5 | class AceHandler extends InjectorHandler { 6 | constructor(elem, contentEvents) { 7 | super(elem, contentEvents, 'ace'); 8 | } 9 | } 10 | 11 | AceHandler.canHandle = function (elem) { 12 | return elem.classList.contains(aceClassName); 13 | }; 14 | 15 | 16 | export default AceHandler; 17 | -------------------------------------------------------------------------------- /src/handlers/factory.js: -------------------------------------------------------------------------------- 1 | class HandlerFactory { 2 | constructor() { 3 | this.handlers = []; 4 | } 5 | 6 | registerHandler(handler) { 7 | this.handlers.push(handler); 8 | } 9 | 10 | handlerFor(elem) { 11 | for (const Handler of this.handlers) { 12 | if (Handler.canHandle(elem)) { 13 | return Handler; 14 | } 15 | } 16 | return false; 17 | } 18 | } 19 | 20 | export default new HandlerFactory(); 21 | -------------------------------------------------------------------------------- /app/_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "appName": { 3 | "message": "Atomic Chrome", 4 | "description": "The name of the application" 5 | }, 6 | "appShortName": { 7 | "message": "Atomic", 8 | "description": "The short name of the application" 9 | }, 10 | "appDescription": { 11 | "message": "Allows to edit textareas and contenteditable elements directly in Atom", 12 | "description": "The description of the application" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/content-script-tools/custom-events/workflowy.js: -------------------------------------------------------------------------------- 1 | import string from 'ac-util/string'; 2 | 3 | export default { 4 | url: new RegExp('https://workflowy\.com.*', 'i'), 5 | // override setvalue 6 | bind: function (window) { 7 | this.setValue = (value) => { 8 | this.elem.innerHTML = string.htmlEscape(value); 9 | }; 10 | 11 | this.extractTextFromUnknownElem = (elem, options) => { 12 | return elem.innerText; 13 | }; 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/util/string.js: -------------------------------------------------------------------------------- 1 | export default { 2 | capitalize: function (s) { 3 | if (!s) { 4 | return s; 5 | } 6 | return s[0].toUpperCase() + s.slice(1); 7 | }, 8 | 9 | htmlEscape: function (s) { 10 | if (!s) { 11 | return s; 12 | } 13 | return s 14 | .replace(/&/g, '&') 15 | .replace(/"/g, '"') 16 | .replace(/'/g, ''') 17 | .replace(//g, '>'); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /src/handlers/textarea.js: -------------------------------------------------------------------------------- 1 | import BaseHandler from './base'; 2 | 3 | class TextareaHandler extends BaseHandler { 4 | setValue(value) { 5 | this.elem.value = value; 6 | super.setValue(value); 7 | } 8 | 9 | getValue() { 10 | return Promise.resolve(this.elem.value); 11 | } 12 | } 13 | 14 | TextareaHandler.canHandle = function (elem) { 15 | return elem.tagName && elem.tagName.toLowerCase() === 'textarea'; 16 | }; 17 | 18 | export default TextareaHandler; 19 | -------------------------------------------------------------------------------- /src/content-script-tools/custom-events/common.js: -------------------------------------------------------------------------------- 1 | // trigger keypress when the value is set 2 | 3 | export default { 4 | url: /.*/, 5 | bind: function (window) { 6 | this.on('valueSet', (value, options) => { 7 | if (options && options.triggerDOMEvent === false) { 8 | return; 9 | } 10 | const evt = window.document.createEvent('KeyboardEvent'); 11 | evt.initEvent('keypress'); 12 | this.elem.dispatchEvent(evt); 13 | }); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/handlers/index.js: -------------------------------------------------------------------------------- 1 | import CodeMirrorHandler from './codemirror'; 2 | import AceHandler from './ace'; 3 | import ContentEditableHandler from './content-editable'; 4 | import TextareaHandler from './textarea'; 5 | 6 | import handlerFactory from './factory'; 7 | 8 | handlerFactory.registerHandler(CodeMirrorHandler); 9 | handlerFactory.registerHandler(AceHandler); 10 | handlerFactory.registerHandler(ContentEditableHandler); 11 | handlerFactory.registerHandler(TextareaHandler); 12 | 13 | export {handlerFactory as handlerFactory}; 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "atomic-chrome", 3 | "private": true, 4 | "engines": { 5 | "node": ">=0.8.0" 6 | }, 7 | "devDependencies": { 8 | "babel-core": "^6.4.5", 9 | "babel-loader": "^6.2.1", 10 | "babel-preset-es2015": "^6.6.0", 11 | "string-replace-loader": "^1.0.0", 12 | "webpack": "^1.12.12" 13 | }, 14 | "scripts": { 15 | "prepublish": "npm run build", 16 | "build": "./node_modules/.bin/webpack -p", 17 | "dev": "./node_modules/.bin/webpack --watch" 18 | }, 19 | "dependencies": { 20 | "codemirror": "^5.11.0", 21 | "uuid": "^2.0.1" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/handlers/codemirror.js: -------------------------------------------------------------------------------- 1 | import InjectorHandler from './injector'; 2 | 3 | class CodeMirrorHandler extends InjectorHandler { 4 | constructor(elem, contentEvents) { 5 | super(elem, contentEvents, 'codemirror'); 6 | } 7 | 8 | setValue(value, options) { 9 | options = Object.assign({}, {triggerDOMEvent: false}, options); 10 | super.setValue(value, options); 11 | } 12 | } 13 | 14 | CodeMirrorHandler.canHandle = function (elem) { 15 | while (elem) { 16 | if (elem.classList.contains('CodeMirror')) { 17 | return true; 18 | } 19 | elem = elem.parentElement; 20 | } 21 | return false; 22 | }; 23 | 24 | export default CodeMirrorHandler; 25 | -------------------------------------------------------------------------------- /.eslintrc.yaml: -------------------------------------------------------------------------------- 1 | root: true 2 | 3 | env: 4 | node: true 5 | es6: true 6 | 7 | rules: 8 | no-debugger: 2 9 | no-dupe-args: 2 10 | no-dupe-keys: 2 11 | no-duplicate-case: 2 12 | no-ex-assign: 2 13 | no-unreachable: 2 14 | valid-typeof: 2 15 | no-fallthrough: 2 16 | quotes: [2, "single", "avoid-escape"] 17 | indent: [2, 2] 18 | comma-spacing: 2 19 | semi: [2, "always"] 20 | keyword-spacing: 2 21 | space-infix-ops: 2 22 | space-before-function-paren: [2, {named: "never"}] 23 | space-before-blocks: [2, "always"] 24 | new-parens: 2 25 | max-len: [2, 100, 2] 26 | no-multiple-empty-lines: [2, {max: 2}] 27 | eol-last: 2 28 | no-trailing-spaces: 2 29 | strict: [2, "global"] 30 | no-undef: 2 31 | -------------------------------------------------------------------------------- /src/content-script.js: -------------------------------------------------------------------------------- 1 | import {handlerFactory} from './handlers'; 2 | import {textSyncer, contentEvents, elementNormalizer} from './content-script-tools'; 3 | 4 | function run() { 5 | const url = document.URL; 6 | const title = document.title; 7 | const activeElement = elementNormalizer.normalize(document.activeElement); 8 | 9 | const Handler = handlerFactory.handlerFor(activeElement); 10 | 11 | if (!Handler) { 12 | const elemName = activeElement.tagName.toLowerCase(); 13 | console.error(`Atomic Chrome does not support <${elemName}> (yet?)`); 14 | return; 15 | } 16 | 17 | const handler = new Handler(activeElement, contentEvents); 18 | 19 | handler.load().then((options) => { 20 | textSyncer.linkElem(url, title, handler, options); 21 | }); 22 | } 23 | 24 | run(); 25 | -------------------------------------------------------------------------------- /app/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "__MSG_appName__", 3 | "short_name": "__MSG_appShortName__", 4 | "version": "0.2.8", 5 | "manifest_version": 2, 6 | "description": "__MSG_appDescription__", 7 | "icons": { 8 | "16": "images/icon-16.png", 9 | "128": "images/icon-128.png" 10 | }, 11 | "default_locale": "en", 12 | "background": { 13 | "scripts": [ 14 | "scripts/background.js" 15 | ] 16 | }, 17 | "web_accessible_resources": [ 18 | "scripts/injected.js" 19 | ], 20 | "permissions": [ 21 | "tabs", 22 | "activeTab", 23 | "http://*/*", 24 | "https://*/*" 25 | ], 26 | "browser_action": { 27 | "default_icon": { 28 | "19": "images/icon-19.png", 29 | "38": "images/icon-38.png" 30 | }, 31 | "default_title": "Atomic Chrome" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/content-script-tools/custom-events/google-inbox.js: -------------------------------------------------------------------------------- 1 | // Google Inbox custom events 2 | // removes label when start typing 3 | 4 | export default { 5 | url: new RegExp('https://inbox\.google\.com.*', 'i'), 6 | // remove placeholder 7 | bind: function (window) { 8 | const hideLabel = () => { 9 | const label = this.elem.previousSibling; 10 | if (!label || !label.tagName || label.tagName.toLowerCase() !== 'label') { 11 | return; 12 | } 13 | label.innerText = ''; 14 | label.style.display = 'none'; 15 | }; 16 | 17 | const handleValueSet = () => { 18 | if (this.getValue()) { 19 | hideLabel(); 20 | } else { 21 | this.once('valueSet', handleValueSet); 22 | } 23 | }; 24 | 25 | this.once('valueSet', handleValueSet); 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /src/content-script-tools/element-normalizer.js: -------------------------------------------------------------------------------- 1 | import string from 'ac-util/string'; 2 | 3 | class ElementNormalizer { 4 | normalize(elem) { 5 | const tagName = this._tagName(elem); 6 | const method = `normalize${string.capitalize(tagName)}`; 7 | if (this[method]) { 8 | return this[method](elem); 9 | } 10 | return elem; 11 | } 12 | 13 | normalizeFrame(elem) { 14 | try { 15 | return elem.contentDocument.activeElement; 16 | } catch (e) { 17 | console.warn(`Could not get ${this._tagName(elem)} activeElement. Is it cross domain?`); 18 | return elem; 19 | } 20 | } 21 | 22 | normalizeIframe(elem) { 23 | return this.normalizeFrame(elem); 24 | } 25 | 26 | _tagName(elem) { 27 | return elem.tagName && elem.tagName.toLowerCase(); 28 | } 29 | } 30 | 31 | export default new ElementNormalizer(); 32 | -------------------------------------------------------------------------------- /src/handlers/base.js: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'events'; 2 | 3 | export default class BaseHandler extends EventEmitter { 4 | constructor(elem, contentEvents) { 5 | super(); 6 | this.document = elem.ownerDocument; 7 | this.window = this.document.defaultView; 8 | this.elem = elem; 9 | contentEvents.bind(this, this.window); 10 | } 11 | 12 | load() { 13 | return Promise.resolve(); 14 | } 15 | 16 | setValue(value, options) { 17 | this.emit('valueSet', value, options || {}); 18 | } 19 | 20 | getValue() { 21 | throw new Error('not implemented'); 22 | } 23 | 24 | bindChange(f) { 25 | this.elem.addEventListener('keyup', f, false); 26 | this.elem.addEventListener('change', f, false); 27 | } 28 | 29 | unbindChange(f) { 30 | this.elem.removeEventListener('keyup', f, false); 31 | this.elem.removeEventListener('change', f, false); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | 5 | module.exports = { 6 | entry: { 7 | background: ['./src/background.js'], 8 | 'content-script': ['./src/content-script.js'], 9 | injected: ['./src/injected.js'] 10 | }, 11 | output: { 12 | filename: '[name].js', 13 | path: './app/scripts' 14 | }, 15 | module: { 16 | loaders: [{ 17 | test: /\.js$/, 18 | loader: 'babel?presets[]=es2015', 19 | exclude: /node_modules/ 20 | }, { 21 | test: /codemirror\/mode\/meta/, 22 | loader: 'string-replace?search=../lib/codemirror,replace=dummy-codemirror' 23 | }] 24 | }, 25 | resolve: { 26 | alias: { 27 | 'ac-util': path.join(__dirname, 'src', 'util'), 28 | 'dummy-codemirror': path.join(__dirname, 'src', 'shims', 'codemirror') 29 | } 30 | }, 31 | externals: { 32 | chrome: 'chrome' 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /src/injected.js: -------------------------------------------------------------------------------- 1 | import {injectedHandlerFactory} from './handlers/injected'; 2 | 3 | const handlers = []; 4 | 5 | function isSourceTrusted(source) { 6 | let win; 7 | for (win = window; win !== window.parent; win = window.parent) { 8 | if (source === window) { 9 | return true; 10 | } 11 | } 12 | return win === source; 13 | } 14 | 15 | window.addEventListener('message', function (message) { 16 | if (!isSourceTrusted(message.source)) { 17 | return; 18 | } 19 | if (message.data.type === 'initialize') { 20 | const handlerName = message.data.payload.name; 21 | const Handler = injectedHandlerFactory.getHandler(handlerName); 22 | if (!Handler) { 23 | console.error(`Atomic Chrome received bad handler name: ${handlerName}`); 24 | return; 25 | } 26 | const handler = new Handler(document.activeElement, message.data.uuid); 27 | handler.setup().then(() => { 28 | handlers.push(handler); 29 | handler.postReady(); 30 | }); 31 | } else { 32 | for (const handler of handlers) { 33 | handler.handleMessage(message.data); 34 | } 35 | } 36 | }); 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Daniel Perez 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /src/background-tools/ws-bridge.js: -------------------------------------------------------------------------------- 1 | const WS_PORT = 64292; 2 | const WS_URL = `ws://localhost:${WS_PORT}`; 3 | 4 | class WSBridge { 5 | openConnection(port) { 6 | const queue = []; 7 | const ws = this.makeWS(port, queue); 8 | port.onMessage.addListener((msg) => this.sendMessage(ws, queue, msg)); 9 | port.onDisconnect.addListener(() => ws.close()); 10 | } 11 | 12 | makeWS(port, queue) { 13 | const ws = new WebSocket(WS_URL); 14 | ws.onopen = () => { 15 | while (queue.length > 0) { 16 | ws.send(queue.shift()); 17 | } 18 | }; 19 | ws.onmessage = (wsMsg) => { 20 | port.postMessage(JSON.parse(wsMsg.data)); 21 | }; 22 | ws.onclose = (evt) => { 23 | port.postMessage({type: 'closed', payload: {code: evt.code, reason: evt.reason}}); 24 | port.disconnect(); 25 | }; 26 | return ws; 27 | } 28 | 29 | sendMessage(ws, queue, msg) { 30 | msg = JSON.stringify(msg); 31 | if (ws.readyState === ws.CONNECTING) { 32 | queue.push(msg); 33 | } else if (ws.readyState === ws.OPEN) { 34 | ws.send(msg); 35 | } 36 | } 37 | } 38 | 39 | export default new WSBridge(); 40 | -------------------------------------------------------------------------------- /src/handlers/injected/codemirror.js: -------------------------------------------------------------------------------- 1 | import BaseInjectedHandler from './base'; 2 | import 'codemirror/mode/meta'; 3 | import CodeMirror from 'dummy-codemirror'; 4 | 5 | // NOTE: keep modes which could conflict or which do not resolve here 6 | const commonModes = { 7 | css: 'css', 8 | htmlmixed: 'html', 9 | html: 'html', 10 | javascript: 'js' 11 | }; 12 | 13 | class InjectedCodeMirrorHandler extends BaseInjectedHandler { 14 | load() { 15 | while (!this.elem.classList.contains('CodeMirror')) { 16 | this.elem = this.elem.parentElement; 17 | } 18 | this.editor = this.elem.CodeMirror; 19 | return Promise.resolve(); 20 | } 21 | 22 | getValue() { 23 | return this.editor.getValue(); 24 | } 25 | 26 | setValue(text) { 27 | this.executeSilenced(() => this.editor.setValue(text)); 28 | } 29 | 30 | bindChange(f) { 31 | this.editor.on('change', this.wrapSilence(f)); 32 | } 33 | 34 | unbindChange(f) { 35 | this.editor.off('change', f); 36 | } 37 | 38 | getExtension() { 39 | const currentModeName = this.editor.getMode().name; 40 | if (commonModes[currentModeName]) { 41 | return commonModes[currentModeName]; 42 | } 43 | for (const mode of CodeMirror.modeInfo) { 44 | if (mode.mode === currentModeName && mode.ext) { 45 | return mode.ext[0]; 46 | } 47 | } 48 | return null; 49 | } 50 | } 51 | 52 | export default InjectedCodeMirrorHandler; 53 | -------------------------------------------------------------------------------- /src/handlers/content-editable.js: -------------------------------------------------------------------------------- 1 | import BaseHandler from './base'; 2 | import string from 'ac-util/string'; 3 | 4 | class ContentEditableHandler extends BaseHandler { 5 | getValue() { 6 | const result = this.extractText(this.elem); 7 | return Promise.resolve(result); 8 | } 9 | 10 | // TODO: extract this to a dedicated class 11 | extractText(elem, options) { 12 | options = options || {}; 13 | return Array.from(elem.childNodes).map((child, i) => { 14 | if (child.wholeText) { 15 | return child.wholeText + (options.noLinebreak ? '' : '\n'); 16 | } 17 | const tag = child.tagName.toLowerCase(); 18 | switch (tag) { 19 | case 'div': 20 | return this.extractText(child, {noLinebreak: true}) + '\n'; 21 | case 'br': 22 | const noBreak = options.noLinebreak || i === this.elem.childNodes.length - 1; 23 | return noBreak ? '' : '\n'; 24 | default: 25 | return this.extractTextFromUnknownElem(child, options); 26 | } 27 | }).join(''); 28 | } 29 | 30 | extractTextFromUnknownElem(elem, _options) { 31 | return elem.outerHTML; 32 | } 33 | 34 | setValue(value) { 35 | const htmlValue = value.split('\n').map((v) => { 36 | if (v.trim().length === 0) { 37 | return '
'; 38 | } 39 | return '
' + string.htmlEscape(v) + '
'; 40 | }).join(''); 41 | this.elem.innerHTML = htmlValue; 42 | super.setValue(value); 43 | } 44 | } 45 | 46 | ContentEditableHandler.canHandle = function (elem) { 47 | return elem.isContentEditable; 48 | }; 49 | 50 | export default ContentEditableHandler; 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Atomic Chrome 2 | 3 | ## Use Atom to edit in Chrome 4 | 5 | ![atom-icon](https://cloud.githubusercontent.com/assets/1436271/12668235/c228c514-c697-11e5-8cea-e71acabcd300.png) 6 | ![plus-icon](https://cloud.githubusercontent.com/assets/1436271/12668237/c23ab44a-c697-11e5-9076-50b70a1c3be7.png) 7 | ![chrome-icon](https://cloud.githubusercontent.com/assets/1436271/12668236/c233a4c0-c697-11e5-8bba-882291db3f65.png) 8 | 9 | ## Screencast 10 | 11 | ### Github issue (textarea) 12 | 13 | ![github](https://cloud.githubusercontent.com/assets/1436271/12668227/afee6a52-c697-11e5-9b19-c880a0e54132.gif) 14 | 15 | ### Gmail (contenteditable) 16 | 17 | ![gmail](https://cloud.githubusercontent.com/assets/1436271/12668226/afe32e26-c697-11e5-9814-2158e665f774.gif) 18 | 19 | ## Installation 20 | 21 | You need to install 22 | 23 | * [The Chrome Plugin](https://chrome.google.com/webstore/detail/atomic-chrome/lhaoghhllmiaaagaffababmkdllgfcmc) (Atomic Chrome from the Chrome Store) 24 | * [The Atom package](https://atom.io/packages/atomic-chrome) (`atomic-chrome` from apm) 25 | 26 | ## Usage 27 | 28 | Atom needs to be running for this to work. 29 | 30 | 1. Focus a textarea or a contenteditable element 31 | 2. Press the icon of Atomic Chrome (or the shortcut). 32 | 33 | Note that the tab will open in the first launched instance of Atom. 34 | 35 | ### How do I bind a shortcut 36 | 37 | 1. Navigate to `chrome://extensions` 38 | 2. Scroll to the bottom of the page 39 | 3. Press 'Keyboard shortcuts' 40 | 4. Set a shortcut for Atomic Chrome 41 | 42 | ## Development 43 | 44 | This repository is for the Chrome plugin development. 45 | For the Atom package development, see https://github.com/tuvistavie/atomic-chrome-atom. 46 | Contributions are welcome. 47 | -------------------------------------------------------------------------------- /src/handlers/injected/ace.js: -------------------------------------------------------------------------------- 1 | import BaseInjectedHandler from './base'; 2 | 3 | class InjectedAceHandler extends BaseInjectedHandler { 4 | constructor(elem, uuid) { 5 | super(elem, uuid); 6 | this.silenced = false; 7 | } 8 | 9 | load() { 10 | return new Promise((resolve) => { 11 | this.editor = ace.edit(this.elem.parentElement); 12 | this.editor.$blockScrolling = Infinity; 13 | if (!ace.config || !ace.config.loadModule) { 14 | return resolve(); 15 | } 16 | ace.config.loadModule('ace/ext/modelist', (m) => { 17 | this.modes = m.modes; 18 | this.loaded = true; 19 | resolve(); 20 | }); 21 | // NOTE: no callback when loadModule fails, so add a timeout 22 | setTimeout(() => { 23 | if (!this.loaded) { 24 | resolve(); 25 | } 26 | }, 3000); 27 | }); 28 | } 29 | 30 | getExtension() { 31 | if (!this.modes) { 32 | return null; 33 | } 34 | const session = this.editor.getSession(); 35 | const currentMode = session && session.getMode() && session.getMode().$id; 36 | if (!currentMode) { 37 | return null; 38 | } 39 | for (const mode of this.modes) { 40 | if (mode.mode === currentMode) { 41 | return mode.extensions.split('|')[0]; 42 | } 43 | } 44 | return null; 45 | } 46 | 47 | getValue() { 48 | return this.editor.getValue(); 49 | } 50 | 51 | setValue(text) { 52 | this.executeSilenced(() => this.editor.setValue(text, 1)); 53 | } 54 | 55 | bindChange(f) { 56 | this.editor.on('change', this.wrapSilence(f)); 57 | } 58 | 59 | unbindChange(f) { 60 | this.editor.off('change', f); 61 | } 62 | } 63 | 64 | export default InjectedAceHandler; 65 | -------------------------------------------------------------------------------- /src/handlers/injected/base.js: -------------------------------------------------------------------------------- 1 | import string from 'ac-util/string'; 2 | 3 | export default class BaseInjectedHandler { 4 | constructor(elem, uuid) { 5 | this.elem = elem; 6 | this.uuid = uuid; 7 | } 8 | 9 | setup() { 10 | return this.load().then((res) => { 11 | this.bindChange(() => this.postToInjector('change')); 12 | return res; 13 | }); 14 | } 15 | 16 | load() { 17 | return Promise.resolve(); 18 | } 19 | 20 | handleMessage(data) { 21 | const method = `on${string.capitalize(data.type)}`; 22 | if (data.uuid === this.uuid && this[method]) { 23 | this[method](data.payload); 24 | } 25 | } 26 | 27 | onGetValue() { 28 | this.postToInjector('value', {text: this.getValue()}); 29 | } 30 | 31 | onSetValue(payload) { 32 | this.setValue(payload.text); 33 | } 34 | 35 | getValue() { 36 | throw new Error('not implemented'); 37 | } 38 | 39 | setValue() { 40 | throw new Error('not implemented'); 41 | } 42 | 43 | bindChange() { 44 | throw new Error('not implemented'); 45 | } 46 | 47 | executeSilenced(f) { 48 | this.silenced = true; 49 | f(); 50 | this.silenced = false; 51 | } 52 | 53 | postReady() { 54 | const payload = {}; 55 | const extension = this.getExtension(); 56 | if (extension) { 57 | payload.extension = extension; 58 | } 59 | this.postToInjector('ready', payload); 60 | } 61 | 62 | getExtension() { 63 | } 64 | 65 | wrapSilence(f) { 66 | return (...args) => { 67 | if (!this.silenced) { 68 | f(...args); 69 | } 70 | }; 71 | } 72 | 73 | postToInjector(type, payload) { 74 | const message = { 75 | type: type, 76 | uuid: this.uuid, 77 | payload: payload || {} 78 | }; 79 | window.postMessage(message, location.origin); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/content-script-tools/text-syncer.js: -------------------------------------------------------------------------------- 1 | const NORMAL_CLOSE_CODE = 1000; 2 | 3 | class TextSyncer { 4 | linkElem(url, title, handler, options) { 5 | const port = chrome.runtime.connect(); 6 | this.register(port, url, title, handler, options); 7 | port.onMessage.addListener(this.makeMessageListener(handler)); 8 | const textChangeListener = this.makeTextChangeListener(port, handler); 9 | handler.bindChange(textChangeListener, false); 10 | port.onDisconnect.addListener(() => { 11 | handler.unbindChange(textChangeListener, false); 12 | }); 13 | } 14 | 15 | makeMessageListener(handler) { 16 | return (msg) => { 17 | if (this[msg.type]) { 18 | return this[msg.type](handler, msg.payload); 19 | } 20 | console.warn('Atomic Chrome received unknown message:', msg); 21 | }; 22 | } 23 | 24 | updateText(handler, payload) { 25 | handler.setValue(payload.text); 26 | } 27 | 28 | closed(handler, payload) { 29 | const code = payload.code; 30 | if (code !== NORMAL_CLOSE_CODE) { 31 | console.warn(`Atomic Chrome connection was closed with code ${code}`); 32 | } 33 | } 34 | 35 | makeTextChangeListener(port, handler) { 36 | return () => { 37 | handler.getValue().then((text) => { 38 | this.post(port, 'updateText', {text: text}); 39 | }); 40 | }; 41 | } 42 | 43 | register(port, url, title, handler, options) { 44 | options = options || {}; 45 | handler.getValue().then((text) => { 46 | const payload = {url: url, title: title, text: text}; 47 | let extension = options.extension; 48 | if (extension) { 49 | if (extension[0] !== '.') { 50 | extension = `.${extension}`; 51 | } 52 | payload.extension = extension; 53 | } 54 | this.post(port, 'register', payload); 55 | }); 56 | } 57 | 58 | post(port, type, payload) { 59 | port.postMessage({type: type, payload: payload}); 60 | } 61 | } 62 | 63 | export default new TextSyncer(); 64 | -------------------------------------------------------------------------------- /src/handlers/injector.js: -------------------------------------------------------------------------------- 1 | import uuid from 'uuid'; 2 | 3 | import BaseHandler from './base'; 4 | 5 | export default class InjectorHandler extends BaseHandler { 6 | constructor(elem, contentEvents, name) { 7 | super(elem, contentEvents); 8 | this.name = name; 9 | this.uuid = uuid.v4(); 10 | 11 | this.window.addEventListener('message', (message) => { 12 | if (message.source !== this.window || message.data.uuid !== this.uuid) { 13 | return; 14 | } 15 | this.emit(message.data.type, message.data.payload); 16 | }); 17 | } 18 | 19 | load() { 20 | this.injectScript(() => this.postToInjected('initialize', {name: this.name})); 21 | return new Promise((resolve) => this.once('ready', resolve)); 22 | } 23 | 24 | setValue(value, options) { 25 | this.postToInjected('setValue', {text: value}); 26 | super.setValue(value, options); 27 | } 28 | 29 | getValue() { 30 | this.postToInjected('getValue'); 31 | return new Promise((resolve) => { 32 | if (this._getValueCallback) { 33 | this.removeListener('value', this._getValueCallback); 34 | } 35 | this._getValueCallback = (payload) => { 36 | resolve(payload.text); 37 | this._getValueCallback = null; 38 | }; 39 | this.once('value', this._getValueCallback);; 40 | }); 41 | } 42 | 43 | injectScript(onload) { 44 | if (this.document.atomicScriptInjected) { 45 | return onload && onload(); 46 | } 47 | this.document.atomicScriptInjected = true; 48 | this.executeInjectScript(onload); 49 | } 50 | 51 | executeInjectScript(onload) { 52 | const s = this.document.createElement('script'); 53 | s.src = chrome.extension.getURL('scripts/injected.js'); 54 | s.onload = function () { 55 | this.parentNode.removeChild(this); 56 | if (onload) { 57 | onload(); 58 | } 59 | }; 60 | this.document.body.appendChild(s); 61 | } 62 | 63 | postToInjected(type, payload) { 64 | const message = { 65 | type: type, 66 | uuid: this.uuid, 67 | payload: payload || {} 68 | }; 69 | this.window.postMessage(message, this.window.location.origin); 70 | } 71 | 72 | bindChange(f) { 73 | this.on('change', f); 74 | } 75 | 76 | unbindChange(f) { 77 | this.removeListener('change', f); 78 | } 79 | } 80 | --------------------------------------------------------------------------------