Cacomania: Get rid of those textarea with Chrome and Sublime Text 3

Cacomania

Get rid of those textarea with Chrome and Sublime Text 3

Guido Krömer - 13. March 2014 - Tags: , , , ,

My recent project is a proof of concept connecting a HTML textarea with Sublime Text 3 using WebSockets, which lets you use all nice features of Sublime Text in a common textarea. Therefore I wrote a Chrome extension and a Sublime Text 3 plugin, the Sublime Text plugin starts a simple, single threaded, WebSocket Server awaiting a connection from the Chrome extension for sending and receiving the text when it has been changed.

I made a short video of this concept, which can be watched at YouTube.

The Sublime Text 3 plugin

First of it all, this is my first time using Python 3, please be gentle with the code I wrote.

The WebSocket server is hand crafted, I would never do it again because the WebSocket protocol is really weird, if you want to read more about the protocol internals and pit falls I recommend this article.

The server runs single threaded, which is good enough for a proof of concept, and encapsulates all complex parts. Hence, there are two events fired by the server and one method is used for sending data to client. The one event is fired when a message has arrived and the other gets fired when client closed the connection.

web_socket_server = Server('localhost', 1337)
web_socket_server.on_message(OnConnect())
web_socket_server.on_close(OnClose())
OnSelectionModifiedListener.set_web_socket_server(web_socket_server)


def web_socket_server_thread():
    global web_socket_server
    web_socket_server.start()

Thread(target = web_socket_server_thread).start()

The code above start a Server listening on port 1337 and accepts only connections from the localhost. The event handlers get bound and the server instance gets assign to the OnSelectionModifiedListener which watches all Sublime Text 3 view changes. The OnConnect listener performs the "first contact", afterwards the OnConnect handler gets replaced with the OnMessage handler.

The OnConnect handler gets called on the first contact and replaces it self with the OnMessage handler. The WindowHelper class just simplifies the creating of a new tab, in Sublime Text with the given content.

class OnConnect(AbstractOnMessage):
    def on_message(self, text):
        try:
            request = json.loads(text)
            window_helper = WindowHelper()
            current_view = window_helper.add_file(request['title'], request['text'])
            OnSelectionModifiedListener.set_view_name(request['title'])

            self._web_socket_server.on_message(OnMessage(current_view))
        except ValueError:
            print('Invalid JSON!')

The OnMessage class gets notified on each external text change, which happens if the textarea text gets modified by the user in the browser. The whole text send by the client replaces the current Sublime Text view content.

class OnMessage(AbstractOnMessage):
    def __init__(self, current_view):
        self._current_view = current_view

    def on_message(self, text):
        try:
            request = json.loads(text)
            self._current_view.run_command('replace_content', {'txt': request['text']})
        except ValueError:
            print('Invalid JSON!')

As the OnMessage class handles external text changes the OnSelectionModifiedListener gets fired by Sublime Text on each selection or text change. Since a EventListener gets called by each view change and I did not found a more elegant way, the static property _view_name is used to determine which view has changed and send only changes by a view matching the connected textarea name to the client. This property gets set by the OnConnect handler, listed above. The static property _web_socket_server contains the reference of the used WebSocket server, the server's send_message() method is used send the whole text back to client if has been changed.

class OnSelectionModifiedListener(EventListener):
    """
    Handles content changes, each changes gets send with the given web socket server to the client.
    """
    _web_socket_server = None
    _view_name = None

    def on_selection_modified(self, view):
        if not OnSelectionModifiedListener._web_socket_server:
            return

        if not OnSelectionModifiedListener._view_name or OnSelectionModifiedListener._view_name != view.name():
            return

        sel_min, sel_max = OnSelectionModifiedListener._get_max_selection(view)

        changed_text = view.substr(sublime.Region(0, view.size()))
        response = json.dumps({
            'title': view.name(),
            'text':  changed_text,
            'cursor': {'min': sel_min, 'max': sel_max}
        })
        OnSelectionModifiedListener._web_socket_server.send_message(response)

    @staticmethod
    def set_web_socket_server(web_socket_server):
        OnSelectionModifiedListener._web_socket_server = web_socket_server

    @staticmethod
    def set_view_name(name):
        OnSelectionModifiedListener._view_name = name

   …

The Chrome extension

Since I want to keep this part short, do not expect a complete tutorial about Chrome extensions here, it covers only some points of interest. Let's begin with the manifest.json, besides some basic values like the extension name or a version the properties content_scripts, permissions and browser_action are important.

The extension needs the right to gain access all tabs and runs a content script on each, for this purpose the permission "tabs","<all_urls>" is required.

The content_scripts section's determines which scripts should be executed when a new page gets loaded. The run_at determines the execution time of the content script. The content script itself and all scripts used by this, like jQuery, one has to be declared here.

A browser_action creates small icon on the right side of the browsers address bar which can be clicked, on each click the default_popup's HTML will be shown as a popup/bubble below the icon. I use it to "activate" the content script by sending a message from the popup.js to the content script using Chromes message API on a button click, this ensures all textareas can be connected with Sublime Text even if they where added to the DOM after content script has been executed.

{
  "name": "SublimeTextArea",
  "icons": {
    "16": "icon/16.png",
    "32": "icon/32.png"
  },
  "version": "0.1",
  "manifest_version": 2,
  "description": "Connects a textare with your SublimeText editor.",
  "content_scripts": [{
        "matches": ["http://*/*", "https://*/*"],
        "js": ["vendor/jquery.min.js", "scripts/lib.js", "scripts/content.js"],
        "run_at": "document_end"
  }],
  "background": {
    "scripts": ["scripts/background.js"]
  },
  "permissions": [
    "tabs",""
    ],
  "options_page": "options.html",
  "browser_action": {
    "default_title": "SublimeTextArea",
    "default_popup": "popup.html",
    "default_icon":  "icon/32.png"
  }
}

popup.js

The popup.js passes a message to the active tab, the active tab can be found using chrome.tabs.query. A message to a specific tab can be send using chrome.tabs.sendMessage, the first parameter is the target tab, the second the message payload and the third one a callback which can be fired be the recipient.

$(document).ready(function () {
    …

    $('#btn-connect').click(function () {
        chrome.tabs.query({
            active: true,
            currentWindow: true
        },
        function(tabs){
            chrome.tabs.sendMessage(tabs[0].id, {textarea: 'connect'}, function(response) {});
        });
    });
});

content.js

The content script binds the focus event on each textarea when receiving the message form the popup script. The textarea which fires the first focus event gets connected with Sublime Text using a method provided by the lib.js. By unbinding the focus event subsequent connections to the editor gets suppressed.

chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) {
    if (!request.textarea || request.textarea != 'connect') {
        return;
    }

    sendResponse({textarea: 'connecting'});

    $("textarea").focus(function () {
        var textArea = $(this);
        textArea.unbind("focus");
        SublimeTextArea.connectTextarea($(this), $('title').text());
    });
});

lib.js

The logic for sending and receiving text changes using a WebSocket is separated into the lib script. connectTextarea() gets called by the content script when a textarea has been selected. This method connects to the server and configures the WebSocket and textarea events like onopen, onmessage and input propertychange. That's all to say about connecting a textarea using WebSockets with Sublime Text 3.

var SublimeTextArea = {
    …

    connectTextarea: function (textarea, title) {
        var that = this;

        var webSocket = new WebSocket('ws://localhost:' + SublimeTextArea.serverPort());

        webSocket.onopen = function () {
            webSocket.send(that.textChange(title, textarea));
        };

        webSocket.onmessage = function (event) {
            var response = JSON.parse(event.data);
            textarea.val(response.text);
        };

        textarea.bind('input propertychange', function() {
            webSocket.send(that.textChange(title, textarea));
        });
    },

    textChange: function (title, textarea) {
        var textareaDom = $(this).get(0);

        return JSON.stringify({
                title:  title,
                text:   textarea.val(),
                cursor: {
                    start: textareaDom.selectionStart,
                    end: textareaDom.selectionEnd
                },
            });
    }
};

Conclusion

I whole code, of this project, is hosted at GitHub and is licensed under MIT.

Feel free to leave a comment if you liked or disliked my posting.